diff --git a/.env.local.example b/.env.local.example index f848864f6f..73c783739a 100644 --- a/.env.local.example +++ b/.env.local.example @@ -42,8 +42,8 @@ META_APP_SECRET=your-meta-app-secret # or a background worker that has no incoming request. NEXT_PUBLIC_SITE_URL=https://crm.example.com -# Default language locale (e.g. en) -NEXT_PUBLIC_APP_LOCALE=en +# Default language locale (e.g. es) +NEXT_PUBLIC_APP_LOCALE=es # ============================================================ # OPTIONAL — only needed if you use the feature. diff --git a/messages/es.json b/messages/es.json new file mode 100644 index 0000000000..28560476db --- /dev/null +++ b/messages/es.json @@ -0,0 +1,1696 @@ +{ + "LoginPage": { + "titleAccept": "Inicia sesión para aceptar", + "titleWelcome": "Bienvenido de nuevo", + "descAccept": "Inicia sesión y te llevaremos a la invitación.", + "descWelcome": "Inicia sesión en tu cuenta", + "emailLabel": "Correo electrónico", + "emailPlaceholder": "tu@ejemplo.com", + "passwordLabel": "Contraseña", + "forgotPassword": "¿Olvidaste tu contraseña?", + "passwordPlaceholder": "Ingresa tu contraseña", + "signingIn": "Iniciando sesión...", + "signIn": "Iniciar sesión", + "noAccount": "¿No tienes una cuenta?", + "createAccount": "Crear cuenta" + }, + "Sidebar": { + "title": "Plantilla de CRM para WhatsApp", + "dashboard": "Panel", + "inbox": "Bandeja de entrada", + "notifications": "Notificaciones", + "contacts": "Contactos", + "pipelines": "Embudos", + "broadcasts": "Difusiones", + "automations": "Automatizaciones", + "flows": "Flujos", + "aiAgents": "Agentes IA", + "settings": "Configuración", + "beta": "Beta", + "unreadConversations": "{count} {count, plural, =1 {conversación sin leer} other {conversaciones sin leer}}", + "unreadNotifications": "{count} {count, plural, =1 {notificación sin leer} other {notificaciones sin leer}}", + "roleOwner": "Propietario", + "roleAdmin": "Administrador", + "roleAgent": "Agente", + "roleViewer": "Visualizador", + "closeMenu": "Cerrar menú", + "defaultUser": "Usuario", + "defaultAvatar": "Avatar", + "menuProfile": "Perfil", + "menuSettings": "Configuración", + "menuSignOut": "Cerrar sesión" + }, + "Header": { + "dashboard": "Panel", + "inbox": "Bandeja de entrada", + "notifications": "Notificaciones", + "contacts": "Contactos", + "pipelines": "Embudos", + "broadcasts": "Difusiones", + "automations": "Automatizaciones", + "settings": "Configuración", + "openMenu": "Abrir menú", + "openAccountMenu": "Abrir menú de cuenta", + "defaultUser": "Usuario", + "defaultAvatar": "Avatar", + "menuProfile": "Perfil", + "menuSettings": "Configuración", + "menuSignOut": "Cerrar sesión" + }, + "ModeToggle": { + "switchMode": "Cambiar a modo {mode}" + }, + "Dashboard": { + "page": { + "title": "Panel", + "description": "Analítica en vivo de conversaciones, contactos, negocios, difusiones y automatizaciones.", + "activeConversations": "Conversaciones activas", + "newContactsToday": "Contactos nuevos hoy", + "openDealsValue": "Valor de negocios abiertos", + "messagesSentToday": "Mensajes enviados hoy", + "newTodayVsYesterday": "nuevos hoy vs. ayer", + "vsYesterday": "vs. ayer", + "openDeals": "{count} {count, plural, =1 {negocio abierto} other {negocios abiertos}}", + "noChange": "Sin cambios {suffix}" + }, + "quickActions": { + "newContact": "Nuevo contacto", + "newDeal": "Nuevo negocio", + "newBroadcast": "Nueva difusión", + "newAutomation": "Nueva automatización" + }, + "activityFeed": { + "title": "Actividad reciente", + "viewAll": "Ver todo →", + "noActivity": "Aún no hay actividad", + "noActivityHint": "Aquí aparecerá la actividad de mensajes, negocios, difusiones y automatizaciones.", + "showingOf": "Mostrando {visible} de {totalLoaded}{plus}", + "show": "Mostrar", + "timeS": "hace {sec}s", + "timeM": "hace {min}m", + "timeH": "hace {hr}h", + "timeD": "hace {day}d" + }, + "conversationsChart": { + "title": "Conversaciones a lo largo del tiempo", + "description": "Volumen diario de mensajes por dirección", + "days": "{count} días", + "noActivity": "No hay actividad de mensajes en este rango", + "noActivityHint": "Envía o recibe mensajes para empezar a llenar este gráfico.", + "incoming": "Entrantes", + "outgoing": "Salientes", + "tooltipIncoming": "{count} entrantes", + "tooltipOutgoing": "{count} salientes", + "ariaLabel": "Conversaciones por día" + }, + "pipelineDonut": { + "title": "Valor del embudo", + "description": "Negocios abiertos por etapa", + "noOpenDeals": "Aún no hay negocios abiertos", + "noOpenDealsHint": "Crea negocios en Embudos para ver el desglose por etapa aquí.", + "dealCount": "{count} {count, plural, =1 {negocio} other {negocios}}", + "total": "Total", + "ariaLabel": "Valor del embudo por etapa" + }, + "responseTimeChart": { + "title": "Tiempo de primera respuesta promedio", + "description": "Minutos para responder al primer mensaje sin contestar de un cliente, por día de la semana", + "target": "meta {minutes}m", + "thisWeek": "Esta semana:", + "lastWeek": "Semana pasada:", + "noReplies": "Aún no se han registrado respuestas", + "noRepliesHint": "Este gráfico se llena a medida que respondes mensajes de clientes." + }, + "emptyState": { + "title": "Aún no hay suficientes datos" + } + }, + "Inbox": { + "page": { + "whatsappNotConnected": "WhatsApp® no está conectado. Ve a Configuración para conectar tu cuenta." + }, + "conversationList": { + "searchPlaceholder": "Buscar conversaciones...", + "filterAll": "Todas", + "filterUnread": "Sin leer", + "filterOpen": "Abiertas", + "filterPending": "Pendientes", + "filterClosed": "Cerradas", + "tags": "Etiquetas", + "company": "Empresa", + "allCompanies": "Todas las empresas", + "clearAll": "Limpiar todo", + "noConversations": "No se encontraron conversaciones", + "noMessagesYet": "Aún no hay mensajes", + "unknown": "Desconocido" + }, + "messageThread": { + "backToConversations": "Volver a conversaciones", + "hideContactPanel": "Ocultar panel de contacto", + "showContactPanel": "Mostrar panel de contacto", + "hideContact": "Ocultar contacto", + "showContact": "Mostrar contacto", + "refresh": "Actualizar", + "refreshConversation": "Actualizar conversación", + "status": "Estado", + "statusOpen": "Abierta", + "statusPending": "Pendiente", + "statusClosed": "Cerrada", + "assign": "Asignar", + "assigned": "Asignado", + "unassign": "Quitar asignación", + "noTeammates": "No hay compañeros de equipo disponibles", + "me": " (yo)", + "noMessagesYet": "Aún no hay mensajes", + "sendTemplateHint": "Envía una plantilla para iniciar la conversación", + "selectConversation": "Selecciona una conversación", + "selectConversationHint": "Elige una conversación de la izquierda para empezar a escribir", + "today": "Hoy", + "yesterday": "Ayer" + }, + "sessionTimer": { + "expired": "Expirada", + "xhRemaining": "quedan {hours}h", + "xmRemaining": "quedan {minutes}m", + "noCustomerMessages": "Sin mensajes del cliente" + }, + "composer": { + "draftHint": "Toca el ✨ para redactar una respuesta con IA — puedes editarla antes de enviarla", + "sessionExpiredHint": "La sesión de 24 horas expiró. Usa una plantilla para retomar el contacto.", + "templates": "Plantillas", + "readOnlyPlaceholder": "Solo lectura — los visualizadores pueden ver pero no responder", + "sessionExpiredPlaceholder": "Sesión expirada - usa una plantilla", + "typeMessagePlaceholder": "Escribe un mensaje... (Shift+Enter para nueva línea)", + "readOnlyTitle": "Solo lectura — tu rol no puede enviar mensajes", + "recording": "Grabando… {current} / {max}", + "cancel": "Cancelar", + "stopAndAttach": "Detener y adjuntar", + "attachMedia": "Adjuntar archivo", + "photo": "Foto", + "video": "Video", + "document": "Documento", + "voiceNote": "Nota de voz", + "sendTemplate": "Enviar plantilla", + "draftWithAI": "Redactar respuesta con IA", + "addCaption": "Agregar un pie de foto…", + "removeAttachment": "Quitar archivo adjunto", + "moreActions": "Más", + "interactiveMessage": "Mensaje interactivo", + "quickReplies": "Respuestas rápidas", + "quickRepliesEmpty": "Aún no hay respuestas rápidas. Agrega algunas en Configuración → Respuestas rápidas.", + "saveAsQuickReply": "Guardar como respuesta rápida", + "send": "Enviar", + "quickReplyNamePrompt": "Nombra esta respuesta rápida:", + "quickReplySaved": "Guardada como respuesta rápida.", + "quickReplySaveError": "No se pudo guardar la respuesta rápida." + }, + "bubble": { + "photo": "Foto", + "video": "Video", + "audio": "Audio", + "unavailable": "{label} no disponible", + "document": "Documento", + "locationShared": "Ubicación compartida", + "template": "Plantilla", + "buttonReply": "Respuesta de botón", + "interactiveReply": "[Respuesta interactiva]", + "unsupported": "[Tipo de mensaje no compatible]", + "aiBadge": "IA", + "aiBadgeTitle": "Enviado automáticamente por el asistente de IA" + }, + "actions": { + "reply": "Responder", + "copyText": "Copiar texto", + "delete": "Eliminar", + "react": "Reaccionar", + "reactWith": "Reaccionar con {emoji}", + "nothingToCopy": "Nada que copiar", + "copied": "Copiado", + "copyFailed": "Error al copiar" + }, + "replyQuote": { + "photo": "[Foto]", + "video": "[Video]", + "document": "[Documento]", + "audio": "[Audio]", + "voiceMessage": "[Nota de voz]", + "location": "[Ubicación]", + "template": "[Plantilla]", + "message": "[Mensaje]", + "cancelReply": "Cancelar respuesta" + }, + "templatePicker": { + "sendTemplate": "Enviar plantilla", + "fillPlaceholders": "Completa los marcadores para generar esta plantilla. Meta exige que se establezca cada variable.", + "pickTemplate": "Elige una plantilla de WhatsApp aprobada para enviar a este contacto.", + "noApprovedTemplates": "No hay plantillas aprobadas", + "noApprovedTemplatesHint": "Aprueba una plantilla en el Administrador de WhatsApp de Meta y luego sincronízala desde Configuración → Plantillas.", + "preview": "Vista previa", + "headerValuePlaceholder": "Valor para la variable del encabezado", + "bodyValuePlaceholder": "Valor para {val}", + "urlSuffixValuePlaceholder": "Valor del sufijo de la URL", + "finalUrl": "URL final: {url}", + "back": "Atrás", + "cancel": "Cancelar", + "send": "Enviar plantilla" + }, + "sidebar": { + "contactInfo": "Información de contacto", + "tags": "Etiquetas", + "notes": "Notas", + "deals": "Negocios", + "noTags": "Sin etiquetas", + "noDeals": "Sin negocios", + "addNotePlaceholder": "Agregar una nota..." + }, + "aiBanner": { + "pausedTitle": "El asistente de IA está pausado aquí", + "activeText": "El asistente de IA está respondiendo automáticamente", + "takeOver": "Tomar el control", + "resume": "Reanudar IA", + "tookOver": "Has tomado el control de este chat.", + "resumed": "IA reanudada.", + "updateError": "No se pudo actualizar el asistente de IA.", + "networkError": "No se pudo conectar con el servidor." + } + }, + "Contacts": { + "page": { + "title": "Contactos", + "subtitle": "Administra tu lista de contactos. {count} contactos en total.", + "subtitleZero": "Administra tu lista de contactos.", + "customFieldsBtn": "Campos personalizados", + "importBtn": "Importar", + "addContactBtn": "Agregar contacto", + "searchPlaceholder": "Buscar por nombre, teléfono o correo...", + "filterByTags": "Filtrar por etiquetas", + "clearAll": "Limpiar todo", + "noTagsYet": "Aún no hay etiquetas.", + "selectedCount": "{count} seleccionados", + "clearSelection": "Limpiar", + "deleteSelected": "Eliminar seleccionados", + "tableColumns": { + "name": "Nombre", + "phone": "Teléfono", + "email": "Correo", + "company": "Empresa", + "tags": "Etiquetas", + "createdAt": "Creado" + }, + "loading": "Cargando contactos...", + "noContactsMatch": "Ningún contacto coincide con tus filtros.", + "noContactsYet": "Aún no hay contactos.", + "addFirstContact": "Agrega tu primer contacto", + "unnamed": "Sin nombre", + "editAction": "Editar", + "deleteAction": "Eliminar", + "showingPagination": "Mostrando {start}-{end} de {total}", + "pageCount": "Página {page} de {total}", + "deleteContactTitle": "Eliminar contacto", + "deleteContactDesc": "¿Seguro que quieres eliminar a {name}? Esta acción no se puede deshacer.", + "cancel": "Cancelar", + "deleteBtn": "Eliminar", + "deleteBulkTitle": "Eliminar contactos", + "deleteBulkDesc": "¿Seguro que quieres eliminar los {count} contactos seleccionados? Esta acción no se puede deshacer.", + "toastFailedLoad": "No se pudieron cargar los contactos", + "toastFailedDelete": "No se pudo eliminar el contacto", + "toastDeleted": "Contacto eliminado", + "toastBulkFailedDelete": "No se pudieron eliminar los contactos", + "toastBulkDeleted": "{count} contactos eliminados" + }, + "form": { + "addTitle": "Agregar contacto", + "addDesc": "Crea un nuevo contacto. El nombre es obligatorio.", + "editTitle": "Editar contacto", + "editDesc": "Actualiza los datos y etiquetas del contacto.", + "nameLabel": "Nombre", + "namePlaceholder": "Juana Pérez", + "phoneLabel": "Número de teléfono", + "phonePlaceholder": "+1234567890", + "phoneHint": "Incluye el código de país, p. ej. +1 para EE. UU.", + "emailLabel": "Correo electrónico", + "emailPlaceholder": "juana@ejemplo.com", + "companyLabel": "Empresa", + "companyPlaceholder": "Acme Corp", + "tagsLabel": "Etiquetas", + "tagsPlaceholder": "Selecciona etiquetas...", + "noTagsFound": "No se encontraron etiquetas.", + "createTagAction": "Crear etiqueta \"{value}\"", + "cancel": "Cancelar", + "save": "Guardar contacto", + "update": "Actualizar", + "create": "Crear", + "toastSuccessAdd": "Contacto creado con éxito", + "toastSuccessEdit": "Contacto actualizado con éxito", + "toastError": "No se pudo guardar el contacto", + "toastConflict": "Ya existe un contacto con este número de teléfono", + "viewExisting": "Ver {name}", + "dupExact": "Ya existe un contacto con este número de teléfono.", + "dupSimilar": "Ya existe un contacto con un número muy similar.", + "loadingTags": "Cargando etiquetas...", + "noTagsAvailable": "No hay etiquetas disponibles. Créalas en Configuración.", + "phoneRequired": "El número de teléfono es obligatorio" + }, + "detailView": { + "tabs": { + "details": "Detalles", + "activity": "Actividad", + "notes": "Notas", + "custom": "Campos personalizados", + "deals": "Negocios", + "noTags": "Sin etiquetas", + "noDeals": "Sin negocios", + "addNotePlaceholder": "Agregar una nota..." + }, + "actions": { + "edit": "Editar", + "message": "Mensaje", + "delete": "Eliminar" + }, + "contactInfo": "Información de contacto", + "unnamed": "Desconocido", + "contactDetailsDesc": "Datos del contacto", + "phone": "Teléfono", + "email": "Correo", + "company": "Empresa", + "createdAt": "Fecha de creación", + "customFields": "Campos personalizados", + "noCustomFields": "No hay campos personalizados definidos. Créalos en Configuración.", + "manageFields": "Administrar campos", + "enterCustomField": "Ingresa {name}...", + "activityTab": { + "title": "Historial de actividad", + "noActivity": "Aún no hay actividad." + }, + "notesTab": { + "title": "Notas internas", + "placeholder": "Escribe una nota...", + "save": "Agregar nota", + "noNotes": "Aún no hay notas." + }, + "dealsTab": { + "noDeals": "Aún no hay negocios" + }, + "tagsTab": { + "clickTagDesc": "Haz clic en una etiqueta para agregarla o quitarla de este contacto.", + "noTagsAvailable": "No hay etiquetas disponibles. Créalas en Configuración." + }, + "sendTemplateBtn": "Enviar plantilla", + "saveChangesBtn": "Guardar cambios", + "saveCustomFieldsBtn": "Guardar campos personalizados", + "toastPhoneRequired": "El número de teléfono es obligatorio", + "toastUpdateFailed": "No se pudo actualizar el contacto", + "toastUpdated": "Contacto actualizado", + "toastNotAuthenticated": "No autenticado", + "toastNoteAddFailed": "No se pudo agregar la nota", + "toastNoteAdded": "Nota agregada", + "toastNoteDeleteFailed": "No se pudo eliminar la nota", + "toastNoteDeleted": "Nota eliminada", + "toastCustomFieldsSaved": "Campos personalizados guardados", + "toastCustomFieldsFailed": "No se pudieron guardar los campos personalizados", + "toastTemplateSent": "Plantilla \"{name}\" enviada", + "toastTemplateFailed": "No se pudo enviar la plantilla: {reason}" + }, + "importModal": { + "title": "Importar contactos", + "desc": "Sube un CSV con una columna obligatoria phone. Opcional: name, email, company, tags (separadas por comas; usa comillas en celdas con varias etiquetas).", + "uploadDropzone": "Haz clic para elegir un archivo CSV", + "uploadHint": ".csv hasta el límite de tu navegador", + "rowsReady": "{count} fila lista", + "rowsReady_plural": "{count} filas listas", + "preview": "Vista previa · primeras {count}", + "previewTags": "{tags} etiqueta · {contacts} contacto", + "previewTags_plural": "{tags} etiquetas · {contacts} contactos", + "moreRows": "+ {count} fila más sin mostrar", + "moreRows_plural": "+ {count} filas más sin mostrar", + "importComplete": "Importación completa", + "resultImported": "{count} importados", + "resultTags": "{count} etiqueta asignada", + "resultTags_plural": "{count} etiquetas asignadas", + "resultSkipped": "{count} omitidos", + "resultFailed": "{count} fallidos", + "willBeCreated": "{name} (se creará al importar)", + "columns": { + "phone": "Teléfono", + "name": "Nombre", + "email": "Correo", + "company": "Empresa", + "tags": "Etiquetas" + }, + "cancel": "Cancelar", + "close": "Cerrar", + "importBtn": "Importar {count} contacto", + "importBtn_plural": "Importar {count} contactos", + "toastNoValidRows": "No se encontraron filas válidas. Asegúrate de que el CSV tenga un encabezado de columna \"phone\".", + "toastTagsWarning": "Contactos importados, pero algunas asignaciones de etiquetas fallaron.", + "toastImported": "{count} contacto importado", + "toastImported_plural": "{count} contactos importados", + "toastTagsAssigned": "{count} asignación de etiqueta aplicada", + "toastTagsAssigned_plural": "{count} asignaciones de etiquetas aplicadas", + "toastTagsSkipped": "Se omitieron etiquetas desconocidas (créalas primero en Configuración): {sample}{more}", + "toastSkipped": "{count} duplicado omitido", + "toastSkipped_plural": "{count} duplicados omitidos", + "toastFailed": "{count} contacto no se pudo importar", + "toastFailed_plural": "{count} contactos no se pudieron importar", + "toastError": "Falló la importación" + }, + "customFields": { + "title": "Campos personalizados", + "desc": "Define campos adicionales de contacto (p. ej. código postal, origen del lead). Aparecen en cada contacto y en la acción de automatización “Actualizar campo de contacto”.", + "addField": "Agregar", + "fieldName": "Nombre del nuevo campo…", + "loading": "Cargando…", + "empty": "Aún no hay campos personalizados.", + "renameAria": "Renombrar {name}", + "deleteTitle": "Eliminar campo", + "deleteConfirm": "¿Eliminar \"{name}\"? Esto también elimina su valor guardado en cada contacto. No se puede deshacer.", + "toastNoAccount": "Tu perfil no está vinculado a ninguna cuenta.", + "toastDuplicate": "Ya existe un campo llamado \"{name}\".", + "toastCreateFailed": "No se pudo crear el campo. Es posible que no tengas permiso.", + "toastCreated": "Se creó \"{name}\".", + "toastRenameFailed": "No se pudo renombrar el campo.", + "toastDeleteFailed": "No se pudo eliminar el campo.", + "toastDeleted": "Se eliminó \"{name}\"." + } + }, + "Pipelines": { + "page": { + "selectPipeline": "Seleccionar embudo", + "noPipelinesYet": "Aún no hay embudos", + "managePipelines": "Administrar embudos", + "addPipeline": "Agregar embudo", + "addDeal": "Agregar negocio", + "createToStartTracking": "Crea un embudo para empezar a dar seguimiento a negocios", + "createPipeline": "Crear embudo", + "newPipeline": "Nuevo embudo", + "pipelineName": "Nombre del embudo", + "pipelineNamePlaceholder": "p. ej., Ventas empresariales", + "defaultStagesDesc": "Las etapas predeterminadas (Nuevo lead → Ganado) se crearán automáticamente.", + "cancel": "Cancelar", + "creating": "Creando...", + "createPipelineBtn": "Crear embudo", + "toastFailedLoadPipelines": "No se pudieron cargar los embudos", + "toastFailedMoveDeal": "No se pudo mover el negocio", + "toastFailedCreatePipeline": "No se pudo crear el embudo", + "toastPipelineCreated": "Embudo creado", + "toastNotLinkedToAccount": "Tu perfil no está vinculado a ninguna cuenta." + }, + "board": { + "dropDealHere": "Suelta un negocio aquí", + "addDeal": "Agregar negocio" + }, + "card": { + "noContact": "Sin contacto", + "won": "Ganado", + "lost": "Perdido" + }, + "form": { + "editDeal": "Editar negocio", + "newDeal": "Nuevo negocio", + "title": "Título", + "titlePlaceholder": "Título del negocio", + "contact": "Contacto", + "selectContact": "Selecciona un contacto", + "linkToConversation": "Vincular a conversación", + "value": "Valor", + "currency": "Moneda", + "expectedCloseDate": "Fecha estimada de cierre", + "stage": "Etapa", + "assignedTo": "Asignado a", + "unassigned": "Sin asignar", + "notes": "Notas", + "notesPlaceholder": "Agregar notas...", + "status": "Estado", + "markAsWon": "Marcar como ganado", + "markAsLost": "Marcar como perdido", + "reopenDeal": "Reabrir negocio", + "cancel": "Cancelar", + "saving": "Guardando...", + "saveChanges": "Guardar cambios", + "createDeal": "Crear negocio", + "deletePrompt": "¿Eliminar este negocio?", + "confirm": "Confirmar", + "deleting": "Eliminando...", + "deleteDeal": "Eliminar negocio", + "toastRequired": "El título, el contacto y la etapa son obligatorios", + "toastFailedSave": "No se pudo guardar el negocio", + "toastNotSignedIn": "No has iniciado sesión", + "toastNotLinked": "Tu perfil no está vinculado a ninguna cuenta.", + "toastFailedCreate": "No se pudo crear el negocio", + "toastUpdated": "Negocio actualizado", + "toastCreated": "Negocio creado", + "toastFailedStatus": "No se pudo actualizar el estado del negocio", + "toastMarkedWon": "Marcado como ganado", + "toastMarkedLost": "Marcado como perdido", + "toastReopened": "Negocio reabierto", + "toastFailedDelete": "No se pudo eliminar el negocio", + "toastDeleted": "Negocio eliminado" + }, + "settings": { + "managePipeline": "Administrar embudo", + "deletePipeline": "Eliminar embudo", + "deletePipelineDesc": "Esto archivará todos los negocios de este embudo. No se puede deshacer.", + "cancel": "Cancelar", + "deleting": "Eliminando...", + "deletePipelineBtn": "Eliminar embudo", + "pipelineName": "Nombre del embudo", + "stages": "Etapas", + "newStageNamePlaceholder": "Nombre de la nueva etapa", + "add": "Agregar", + "createNewPipeline": "Crear un nuevo embudo", + "saving": "Guardando...", + "saveChanges": "Guardar cambios", + "dragToReorder": "Arrastra para reordenar", + "changeColor": "Cambiar color", + "toastFailedSave": "No se pudo guardar el embudo", + "toastSaved": "Embudo guardado", + "toastFailedAddStage": "No se pudo agregar la etapa", + "toastMoveOrDeleteDeals": "Primero mueve o elimina los negocios de esta etapa", + "toastFailedDeleteStage": "No se pudo eliminar la etapa", + "toastFailedDeletePipeline": "No se pudo eliminar el embudo", + "toastDeleted": "Embudo eliminado" + }, + "analytics": { + "totalDeals": "Total de negocios", + "totalDealsTooltip": "Cantidad de todos los negocios de este embudo que no están marcados como perdidos. Los negocios ganados se siguen incluyendo.", + "pipelineValue": "Valor del embudo", + "pipelineValueTooltip": "Suma de los valores en dólares de todos los negocios de este embudo, excluyendo los marcados como perdidos.", + "avgDealSize": "Tamaño promedio del negocio", + "avgDealSizeTooltip": "Valor del embudo dividido entre el total de negocios: el valor promedio de un negocio no perdido.", + "weightedValue": "Valor ponderado", + "weightedValueTooltip": "Ingreso esperado: el valor de cada negocio abierto × la probabilidad de su etapa. La primera etapa ≈ 10%, las etapas avanzan hasta 90%, Ganado = 100%. Los negocios perdidos se excluyen.", + "wonThisMonth": "Ganados este mes", + "wonThisMonthTooltip": "Negocios marcados como ganados desde el primer día del mes actual.", + "lostThisMonth": "Perdidos este mes", + "lostThisMonthTooltip": "Negocios marcados como perdidos desde el primer día del mes actual.", + "howCalculated": "Cómo se calcula {label}" + } + }, + "Broadcasts": { + "page": { + "title": "Difusiones", + "subtitle": "Envía mensajes masivos a tus contactos usando plantillas aprobadas.", + "newBroadcast": "Nueva difusión", + "noBroadcastsYet": "Aún no hay difusiones", + "createFirst": "Crea tu primera difusión para llegar a tus contactos a gran escala.", + "table": { + "name": "Nombre", + "template": "Plantilla", + "recipients": "Destinatarios", + "delivery": "Entrega", + "read": "Leído", + "status": "Estado", + "date": "Fecha" + }, + "errorLoad": "No se pudieron cargar las difusiones", + "retry": "Reintentar" + }, + "status": { + "draft": "Borrador", + "scheduled": "Programada", + "sending": "Enviando", + "sent": "Enviada", + "failed": "Fallida", + "pending": "Pendiente", + "delivered": "Entregada", + "read": "Leída", + "replied": "Respondida", + "unknown": "Desconocido" + }, + "detail": { + "backToBroadcasts": "Volver a Difusiones", + "notFound": "Difusión no encontrada", + "template": "Plantilla: {name}", + "createdAt": "Creada el {date}", + "deletePrompt": "¿Eliminar esta difusión?", + "cancel": "Cancelar", + "confirm": "Confirmar", + "deleting": "Eliminando…", + "delete": "Eliminar", + "cannotDeleteSending": "No se puede eliminar mientras una difusión se está enviando", + "deleteHover": "Eliminar esta difusión", + "funnel": "Embudo", + "recipientsHeader": "Destinatarios ({filtered} de {total})", + "recipientsHeaderAll": "Destinatarios ({total})", + "allStatuses": "Todos los estados", + "exportCsv": "Exportar CSV", + "noRecipients": "No se encontraron destinatarios.", + "noRecipientsFilter": "Ningún destinatario coincide con este filtro.", + "table": { + "contact": "Contacto", + "phone": "Teléfono", + "status": "Estado", + "sent": "Enviado", + "delivered": "Entregado", + "read": "Leído", + "error": "Error" + }, + "stats": { + "totalRecipients": "Total de destinatarios", + "sent": "Enviados", + "delivered": "Entregados", + "read": "Leídos", + "replied": "Respondidos", + "failed": "Fallidos" + }, + "toastFailedDelete": "No se pudo eliminar: {error}", + "toastDeleted": "Difusión eliminada" + }, + "new": { + "title": "Nueva difusión", + "subtitle": "Crea y envía un mensaje de difusión a tus contactos.", + "steps": { + "template": "Plantilla", + "audience": "Audiencia", + "personalize": "Personalizar", + "send": "Enviar" + }, + "toastGiveName": "Ponle un nombre a la difusión antes de guardar un borrador.", + "toastNotSignedIn": "No has iniciado sesión.", + "toastNotLinked": "Tu perfil no está vinculado a ninguna cuenta.", + "toastFailedDraft": "No se pudo guardar el borrador: {error}", + "toastDraftSaved": "Borrador guardado" + }, + "wizard": { + "next": "Siguiente", + "back": "Atrás", + "cancel": "Cancelar", + "chooseTemplate": { + "title": "Elige una plantilla", + "subtitle": "Selecciona una plantilla de mensaje aprobada para tu difusión.", + "noTemplates": "No hay plantillas disponibles.", + "createFirst": "Crea primero una plantilla en Configuración.", + "errorLoad": "No se pudieron cargar las plantillas" + }, + "selectAudience": { + "title": "Selecciona la audiencia", + "subtitle": "Elige quién recibirá esta difusión.", + "method": { + "all": "Todos los contactos", + "tags": "Filtrar por etiquetas", + "customField": "Campo personalizado", + "csv": "Subir CSV" + }, + "allDesc": "Enviar a los {count} contactos de tu base de datos.", + "allDescLoading": "Enviar a todos los contactos de tu base de datos.", + "tagDesc": "Enviar a contactos que coincidan con etiquetas específicas.", + "customFieldDesc": "Enviar a contactos que coincidan con una regla de campo personalizado.", + "csvDesc": "Sube un CSV con números de teléfono (y nombres opcionales).", + "selectTags": "Seleccionar etiquetas", + "searchTags": "Buscar etiquetas...", + "noTagsFound": "No se encontraron etiquetas.", + "excludeTags": "Excluir etiquetas (opcional)", + "searchExcludeTags": "Buscar etiquetas a excluir...", + "field": "Campo", + "selectField": "Selecciona un campo...", + "operator": "Operador", + "operatorIs": "Es", + "operatorIsNot": "No es", + "operatorContains": "Contiene", + "value": "Valor", + "valuePlaceholder": "Valor a coincidir", + "uploadCsv": "Subir archivo CSV", + "csvFormatDesc": "Debe contener una columna 'phone' (formato E.164). La columna 'name' es opcional.", + "csvContactsFound": "{count} contacto(s) válido(s) encontrados en el CSV.", + "errorLoadTags": "No se pudieron cargar las etiquetas", + "errorLoadFields": "No se pudieron cargar los campos personalizados", + "errorCsvParse": "No se pudo procesar el archivo CSV.", + "errorCsvMissingPhone": "El CSV debe tener una columna 'phone'." + }, + "personalize": { + "title": "Personalizar variables", + "subtitle": "Asocia las variables de tu plantilla con campos de contacto o proporciona valores fijos.", + "headerImage": "Imagen de encabezado", + "headerImageDesc": "Esta plantilla requiere una imagen de encabezado.", + "imageUrl": "URL de la imagen", + "imageUrlPlaceholder": "https://ejemplo.com/imagen.jpg", + "variables": "Variables", + "varDesc": "Establece valores para {{name}}", + "type": "Tipo", + "typeStatic": "Valor fijo", + "typeContact": "Campo de contacto", + "typeCustom": "Campo personalizado", + "staticValue": "Valor fijo", + "contactField": "Campo de contacto", + "selectContactField": "Selecciona un campo...", + "customField": "Campo personalizado", + "selectCustomField": "Selecciona un campo personalizado...", + "preview": "Vista previa", + "previewSample": "Mostrando vista previa con datos de muestra.", + "noPreview": "Vista previa no disponible.", + "fieldMap": { + "name": "Nombre", + "phone": "Teléfono", + "email": "Correo" + } + }, + "scheduleSend": { + "title": "Revisar y enviar", + "subtitle": "Ponle un nombre a tu difusión y confirma el envío.", + "broadcastName": "Nombre de la difusión", + "broadcastNamePlaceholder": "p. ej., Promo de verano", + "summary": "Resumen", + "template": "Plantilla:", + "audience": "Audiencia:", + "audienceAll": "Todos los contactos", + "audienceTags": "Filtrado por etiquetas", + "audienceField": "Filtrado por campo personalizado", + "audienceCsv": "Carga de CSV", + "variables": "Variables:", + "variablesMapped": "{count} asignadas", + "saveDraft": "Guardar como borrador", + "sendNow": "Enviar difusión", + "sending": "Enviando...", + "preparing": "Preparando...", + "progressDesc": "Procesando {current} de {total} destinatarios...", + "toastFailedPreview": "No se pudieron cargar los datos de vista previa" + } + } + }, + "Automations": { + "list": { + "title": "Automatizaciones", + "subtitle": "Crea flujos de trabajo que reaccionan automáticamente a eventos de WhatsApp®.", + "create": "Crear automatización", + "retry": "Reintentar", + "templatesTitle": "Plantillas de inicio rápido", + "emptyTitle": "Aún no hay automatizaciones", + "emptyDesc": "Elige una plantilla arriba o crea una desde cero.", + "runs": "{count} ejecución", + "runsPlural": "{count} ejecuciones", + "lastRun": "última {time}", + "activate": "Activar", + "deactivate": "Desactivar", + "edit": "Editar", + "duplicate": "Duplicar", + "viewLogs": "Ver registros", + "delete": "Eliminar", + "cancel": "Cancelar", + "deleteTitle": "Eliminar automatización", + "deleteDesc": "Esto elimina permanentemente {name} y su historial de ejecución. No se puede deshacer.", + "toasts": { + "activated": "Automatización activada", + "paused": "Automatización pausada", + "duplicated": "Automatización duplicada", + "deleted": "Automatización eliminada", + "updateError": "No se pudo actualizar", + "duplicateError": "No se pudo duplicar", + "deleteError": "No se pudo eliminar" + } + }, + "edit": { + "loading": "Cargando...", + "loadError": "No se pudo cargar ({status})", + "back": "Volver a Automatizaciones" + }, + "logs": { + "title": "Registros de ejecución", + "loadError": "No se pudieron cargar los registros", + "emptyTitle": "Aún no hay ejecuciones", + "emptyDesc": "Activa esta automatización para ver las ejecuciones aquí.", + "unknownContact": "Contacto desconocido", + "step": "{count} paso", + "stepPlural": "{count} pasos", + "noSteps": "No se registraron pasos.", + "status": { + "success": "éxito", + "partial": "parcial", + "failed": "fallido" + }, + "back": "Atrás", + "backAria": "Atrás" + }, + "builder": { + "steps": { + "send_message": "Enviar mensaje", + "send_buttons": "Enviar botones", + "send_list": "Enviar lista", + "send_template": "Enviar plantilla", + "add_tag": "Agregar etiqueta", + "remove_tag": "Quitar etiqueta", + "assign_conversation": "Asignar conversación", + "update_contact_field": "Actualizar campo de contacto", + "create_deal": "Crear negocio", + "wait": "Esperar", + "condition": "Condición (Si/Sino)", + "send_webhook": "Enviar webhook", + "close_conversation": "Cerrar conversación" + }, + "triggers": { + "new_message_received": { + "label": "Mensaje nuevo recibido", + "hint": "Cualquier mensaje entrante" + }, + "first_inbound_message": { + "label": "Primer mensaje del contacto", + "hint": "La primera vez que este contacto te escribe (también funciona con contactos agregados manualmente)" + }, + "keyword_match": { + "label": "Coincidencia de palabra clave", + "hint": "El mensaje contiene palabra(s) clave específicas" + }, + "interactive_reply": { + "label": "Respuesta de botón / lista", + "hint": "El cliente toca un botón o fila de lista cuyo id coincide — encadena menús entre automatizaciones" + }, + "new_contact_created": { + "label": "Contacto nuevo creado", + "hint": "Cuando se crea automáticamente un contacto a partir de un mensaje entrante" + }, + "conversation_assigned": { + "label": "Conversación asignada", + "hint": "Cuando se asigna a un agente" + }, + "tag_added": { + "label": "Etiqueta agregada", + "hint": "Cuando se agrega una etiqueta a un contacto" + }, + "time_based": { + "label": "Basado en tiempo", + "hint": "En un horario recurrente" + } + }, + "trigger": "Disparador", + "triggerType": "Tipo de disparador", + "keywords": "Palabras clave", + "keywordsHint": "Lista separada por comas (p. ej. hola, ayuda, comprar)", + "replyIds": "IDs de respuesta", + "replyIdsHint": "si, no, mas_info", + "replyIdsHelp": "IDs de botón/fila de lista a coincidir, exactos, separados por comas. Usa los mismos ids que definiste en un paso Enviar botones / Enviar lista.", + "schedule": "Programación", + "scheduleHint": "Expresión cron (p. ej. 0 9 * * 1-5)", + "active": "Activo", + "activeAria": "Activo", + "save": "Guardar", + "saveDraft": "Guardar borrador", + "untitled": "Automatización sin título", + "backToAutomations": "Volver a automatizaciones", + "toasts": { + "saved": "Automatización guardada", + "created": "Automatización creada", + "saveFailed": "Error al guardar" + }, + "tags": { + "select": "Selecciona una etiqueta…", + "placeholder": "ID de etiqueta", + "unknown": "{id} (etiqueta desconocida)" + }, + "fields": { + "name": "Nombre", + "email": "Correo", + "company": "Empresa", + "customFields": "Campos personalizados", + "unknown": "{id} (campo desconocido)" + }, + "agents": { + "select": "Selecciona un agente…", + "placeholder": "ID de agente", + "unknown": "{id} (agente desconocido)" + }, + "pipelines": { + "pipelineLabel": "Embudo", + "pipelineIdLabel": "ID de embudo", + "stageLabel": "Etapa", + "stageIdLabel": "ID de etapa", + "selectPipeline": "Selecciona un embudo…", + "selectStage": "Selecciona una etapa…", + "selectPipelineFirst": "Selecciona primero un embudo…", + "unknownPipeline": "{id} (embudo desconocido)", + "unknownStage": "{id} (etapa desconocida)" + }, + "templates": { + "templateLabel": "Plantilla", + "templateNameLabel": "Nombre de la plantilla", + "languageLabel": "Idioma", + "select": "Selecciona una plantilla…", + "unknown": "{name} ({lang}) — no está en la lista aprobada", + "unknownLang": "desconocido" + }, + "branches": { + "yes": "Sí", + "no": "No" + }, + "addStep": "Agregar paso", + "config": { + "messageText": "Texto del mensaje", + "placeholderMessageText": "¡Hola! Gracias por escribirnos…", + "tagLabel": "Etiqueta", + "modeLabel": "Modo", + "modes": { + "round_robin": "Rotación automática", + "specific": "Agente específico" + }, + "agentLabel": "Agente", + "fieldLabel": "Campo", + "valueLabel": "Valor", + "placeholderValue": "Texto o {{ vars.x }} / {{ message.text }}", + "titleLabel": "Título", + "amountLabel": "Cantidad", + "unitLabel": "Unidad", + "units": { + "minutes": "Minutos", + "hours": "Horas", + "days": "Días" + }, + "subjectLabel": "Asunto", + "subjects": { + "tag_presence": "Presencia de etiqueta", + "contact_field": "Campo de contacto", + "message_content": "Contenido del mensaje", + "time_of_day": "Hora del día" + }, + "operandLabel": "Operando", + "placeholderTime": "HH:mm-HH:mm", + "placeholderContact": "nombre / correo / empresa", + "placeholderTag": "id de etiqueta", + "urlLabel": "URL", + "headersLabel": "Encabezados", + "placeholderHeaders": "{\"Authorization\": \"Bearer ...\"}", + "bodyTemplateLabel": "Plantilla del cuerpo", + "placeholderBody": "{\"id\": \"{{ contact.id }}\"}", + "matchType": "Tipo de coincidencia", + "matchContains": "Contiene", + "matchExact": "Exacto", + "closeConversationHint": "Establece el estado de la conversación en \"cerrada\". No requiere configuración." + } + } + }, + "Flows": { + "list": { + "title": "Flujos", + "beta": "Beta", + "description": "Crea conversaciones de WhatsApp ramificadas y guiadas por botones. Útil para menús, preguntas frecuentes y clasificación antes de que intervenga un humano.", + "newFlow": "Nuevo flujo", + "statusDraft": "Borrador", + "statusActive": "Activo", + "statusArchived": "Archivado", + "loadError": "No se pudieron cargar los flujos.", + "createError": "No se pudo crear el flujo.", + "cloneError": "Error al duplicar", + "deleteConfirm": "¿Eliminar \"{name}\"? Cualquier ejecución activa terminará de inmediato.", + "deleteSuccess": "Flujo eliminado.", + "deleteError": "No se pudo eliminar el flujo.", + "emptyTitle": "Aún no hay flujos", + "emptyDesc": "Crea tu primera conversación — un menú de bienvenida, una consulta de pedidos, un bot de preguntas frecuentes. Los clientes tocan botones; el bot los dirige a la respuesta correcta (o al agente correcto).", + "createFirst": "Crea tu primer flujo", + "createTitle": "Crear un nuevo flujo", + "createDesc": "Empieza desde una plantilla o crea uno desde cero.", + "startTemplate": "Empezar desde una plantilla", + "nodeCount": "{count} {count, plural, =1 {nodo} other {nodos}}", + "startBlank": "O empezar en blanco", + "placeholderName": "p. ej. Menú de bienvenida", + "cancel": "Cancelar", + "createBlank": "Crear flujo en blanco", + "runCount": "{count} {count, plural, =1 {ejecución} other {ejecuciones}}", + "edit": "Editar", + "delete": "Eliminar", + "triggerKeywordNone": "Se activa por palabra clave (ninguna definida)", + "triggerKeyword": "Se activa con: {keywords}", + "triggerFirstInbound": "Se activa con el primer mensaje entrante de un contacto", + "triggerManual": "Activación manual" + }, + "edit": { + "notFound": "Flujo no encontrado.", + "backToFlows": "← Volver a flujos", + "loadError": "No se pudo cargar el flujo." + }, + "logs": { + "title": "Ejecuciones", + "description": "Las 50 ejecuciones más recientes de este flujo. Expande una fila para ver el registro paso a paso del motor.", + "emptyState": "Aún no hay ejecuciones. Activa el flujo desde un número de WhatsApp personal para verlo aparecer aquí.", + "loadError": "No se pudieron cargar las ejecuciones.", + "unknownContact": "Contacto desconocido", + "atNode": "en {node}", + "started": "Iniciado {time}", + "reprompts": "{count} reintentos de pregunta", + "ranFor": "duró {duration}", + "capturedVars": "Variables capturadas ({count})", + "noEvents": "No se registraron eventos para esta ejecución.", + "statusActive": "Activo", + "statusCompleted": "Completado", + "statusHandedOff": "Transferido", + "statusTimedOut": "Tiempo agotado", + "statusPaused": "Pausado por agente", + "statusFailed": "Fallido" + }, + "builder": { + "triggerTitle": "Disparador", + "whenLabel": "Cuando…", + "triggerKeywordTitle": "Un mensaje contiene una palabra clave", + "triggerFirstInboundTitle": "Primer mensaje entrante del cliente", + "triggerManualTitle": "Solo manual (sin activación automática)", + "keywordsLabel": "Palabras clave (separadas por comas)", + "keywordsPlaceholder": "soporte, ayuda, hola", + "entryNodeTitle": "Nodo de entrada:", + "entryNodePlaceholder": "Elige el primer nodo…", + "nodesTitle": "Nodos ({count})", + "nodesEmpty": "Agrega un nodo Inicio, luego un nodo Enviar botones, y después un Transferir — esa es la forma de menú de bienvenida del brief.", + "addNode": "Agregar nodo", + "setAsEntry": "Definir como entrada", + "removeNode": "Quitar nodo", + "showAdvanced": "Mostrar avanzado", + "hideAdvanced": "Ocultar avanzado", + "nodeKeyLabel": "Clave del nodo (identificador interno — mantenla estable para la analítica)", + "replyIdsHint": "Los IDs de respuesta de cada opción se muestran en línea arriba. WhatsApp los devuelve cuando un cliente toca; normalmente no necesitas tocarlos.", + "badgeEntry": "Entrada", + "noNodesYet": "Aún no hay nodos.", + "deleteNode": "Eliminar nodo", + "canvasView": "Lienzo", + "listView": "Lista", + "noNodeSelected": "Ningún nodo seleccionado", + "noNodeDesc": "Selecciona un nodo en el lienzo para editar su configuración.", + "form": { + "advancesTo": "Avanza a", + "textToCustomer": "Texto enviado al cliente", + "promptToCustomer": "Pregunta enviada al cliente", + "varKeyLabel": "Clave de variable (guardada en flow_runs.vars; alfanumérica + guion bajo)", + "varKeyPlaceholder": "p. ej. nombre, correo, empresa", + "varKeyHelp": "Interpola en las preguntas posteriores y notas de transferencia con", + "advanceAfterCapture": "Después de capturar, avanzar a", + "internalNote": "Nota interna (para el agente que la reciba)", + "endNodeHelp": "Nodo terminal. Cuando el motor llega a este nodo, la ejecución se marca como completada. No requiere configuración.", + "bodyText": "Texto del cuerpo", + "footerText": "Pie de página (opcional, 60 caracteres)", + "buttonsHelp": "Botones (1–3) — cada uno dirige a un nodo siguiente distinto", + "optionTitlePlaceholder": "Título visible (≤20 caracteres)", + "nextNodePlaceholder": "Siguiente nodo…", + "addButton": "Agregar botón", + "buttonLabel": "Etiqueta del botón para expandir (≤20 caracteres)", + "rowsHelp": "Filas (1–10 en total entre todas las secciones)", + "sectionTitlePlaceholder": "Título de la sección {count} (opcional)", + "rowTitlePlaceholder": "Título de fila (≤24)", + "addRow": "Agregar fila", + "addSection": "Agregar sección", + "ifLabel": "Si", + "capturedVariable": "Variable capturada", + "contactHasTag": "El contacto tiene la etiqueta", + "contactField": "Campo de contacto", + "varName": "nombre de variable", + "tagLabel": "Etiqueta", + "fieldLabel": "Campo", + "pickTag": "Elige una etiqueta…", + "pickField": "Elige un campo…", + "operatorLabel": "Operador", + "isPresent": "está presente", + "isAbsent": "está ausente", + "equals": "es igual a", + "contains": "contiene", + "valueLabel": "Valor", + "ifTrueAdvance": "Si es verdadero → avanzar a", + "ifFalseAdvance": "Si es falso → avanzar a", + "actionLabel": "Acción", + "addTag": "Agregar etiqueta", + "removeTag": "Quitar etiqueta", + "tagUuidPlaceholder": "UUID de etiqueta", + "thenAdvanceTo": "Luego avanzar a", + "mediaTypeLabel": "Tipo de archivo", + "imageLabel": "Imagen (PNG, JPEG, WebP)", + "videoLabel": "Video (MP4, 3GP)", + "documentLabel": "Documento (PDF, Word, Excel, PowerPoint, TXT)", + "fileLabel": "Archivo", + "removeFile": "Quitar archivo", + "uploading": "Subiendo…", + "clickToUpload": "Haz clic para subir (máx. 16 MB)", + "captionLabel": "Pie de foto (opcional, se muestra debajo del archivo)", + "filenameLabel": "Nombre de archivo mostrado al cliente (solo documentos)", + "filenamePlaceholder": "factura.pdf", + "advanceAfterSending": "Después de enviar, avanzar a", + "pickNextNode": "Elige un siguiente nodo…", + "none": "— Ninguno —" + }, + "categories": { + "messaging": "Mensajería", + "logic": "Lógica y datos", + "flow": "Control de flujo" + }, + "nodes": { + "start": { + "label": "Inicio", + "blurb": "Punto de entrada del flujo" + }, + "send_message": { + "label": "Enviar mensaje", + "blurb": "Envía un mensaje de texto de WhatsApp" + }, + "send_buttons": { + "label": "Enviar botones", + "blurb": "Envía botones de respuesta rápida" + }, + "send_list": { + "label": "Enviar lista", + "blurb": "Envía una lista de opciones táctil" + }, + "send_media": { + "label": "Enviar archivo", + "blurb": "Envía una imagen, video o documento" + }, + "collect_input": { + "label": "Capturar respuesta", + "blurb": "Hace una pregunta y guarda la respuesta" + }, + "condition": { + "label": "Si / sino", + "blurb": "Se ramifica según una regla" + }, + "set_tag": { + "label": "Etiquetar contacto", + "blurb": "Agrega o quita una etiqueta de contacto" + }, + "handoff": { + "label": "Transferir a agente", + "blurb": "Transfiere la conversación a un humano" + }, + "end": { + "label": "Fin", + "blurb": "Termina el flujo" + } + } + }, + "validation": { + "noIssues": "Sin problemas. Listo para activar.", + "summary": "{errorCount} {errorCount, plural, =1 {error} other {errores}}, {warningCount} {warningCount, plural, =1 {advertencia} other {advertencias}}", + "jumpToNode": "Ir al nodo {key}" + }, + "editorState": { + "saved": "Guardado.", + "fixIssues": "Corrige los problemas de abajo antes de activar.", + "statusActivated": "Flujo activado.", + "statusArchived": "Archivado.", + "statusDraft": "Guardado como borrador." + }, + "summary": { + "options": "{count} {count, plural, =1 {opción} other {opciones}}", + "optionsAcrossSections": "{rowCount} {rowCount, plural, =1 {opción} other {opciones}} en {sectionCount} {sectionCount, plural, =1 {sección} other {secciones}}", + "noFile": "{label} (sin archivo subido)", + "media": "Archivo", + "image": "Imagen", + "video": "Video", + "document": "Documento", + "audio": "Audio", + "hasTag": "tiene la etiqueta {tag}", + "opContains": "contiene", + "opExists": "existe", + "opMissing": "falta", + "modeAdd": "Agregar", + "modeRemove": "Quitar", + "tagPicked": "{mode} etiqueta {tag}…", + "tagNone": "{mode} etiqueta (ninguna elegida)" + } + }, + "Settings": { + "pageTitle": "Configuración", + "pageDesc": "Todo en un solo lugar — tu cuenta y tu espacio de trabajo. Elige una sección para administrarla.", + "overview": { + "notSetup": "Aún sin configurar", + "connected": "Conectado", + "needsReconnecting": "Necesita reconectarse", + "viewTeamMembers": "Ver miembros del equipo", + "membersCount": "{count} {count, plural, =1 {miembro} other {miembros}}", + "pendingInvites": "{count} {count, plural, =1 {invitación pendiente} other {invitaciones pendientes}}", + "manageTemplates": "Administrar plantillas de mensajes", + "templatesCount": "{count} {count, plural, =1 {plantilla} other {plantillas}}", + "pendingReview": "{count} en revisión", + "tagsAndFields": "Etiquetas y campos personalizados", + "tagsCount": "{count} {count, plural, =1 {etiqueta} other {etiquetas}}", + "fieldsCount": "{count} {count, plural, =1 {campo personalizado} other {campos personalizados}}", + "appearance": "modo {mode} · acento {theme}", + "yourAccount": "Tu cuenta", + "loading": "Cargando…" + }, + "members": { + "title": "Miembros del equipo", + "description": "Personas con acceso a esta cuenta. Los roles controlan lo que puede hacer cada compañero de equipo.", + "inviteMember": "Invitar miembro", + "online": "en línea", + "away": "ausente", + "offline": "sin conexión", + "memberCount": "{count} {count, plural, =1 {miembro} other {miembros}}", + "you": "Tú", + "unnamed": "Sin nombre", + "joined": "Se unió el {date}", + "remove": "Quitar", + "removedToast": "Se quitó a {name}", + "updatedToast": "Se actualizó a {name} a {role}", + "pendingInvitations": "Invitaciones pendientes", + "inviteHint": "Por seguridad, la URL de invitación en texto plano solo se muestra una vez al crearla — para volver a compartirla, revoca la invitación de abajo y crea una nueva.", + "noPendingTitle": "No hay invitaciones pendientes.", + "noPendingDesc": "Haz clic en Invitar miembro arriba para generar un enlace para compartir.", + "untitledInvite": "Invitación sin título", + "created": "Creada el {date}", + "expiresInDays": "expira en {days} {days, plural, =1 {día} other {días}}", + "expiresInHours": "expira en {hours} {hours, plural, =1 {hora} other {horas}}", + "expired": "expirada", + "revoke": "Revocar", + "revokedToast": "Invitación revocada", + "removeDialogTitle": "Quitar miembro", + "removeDialogDesc": "¿Quitar a {name} de la cuenta? Se cerrará su sesión en esta cuenta y se le dará una cuenta personal nueva en su próximo inicio de sesión. Su acceso no se elimina.", + "cancel": "Cancelar", + "removing": "Quitando...", + "removeBtn": "Quitar miembro" + }, + "invite": { + "inviteCreated": "Invitación creada", + "inviteCreatedDesc": "Comparte este enlace con tu nuevo compañero de equipo. Podrá registrarse (o iniciar sesión) y unirse a la cuenta como {role}. El enlace es válido por {days} {days, plural, =1 {día} other {días}}.", + "inviteLink": "Enlace de invitación", + "copy": "Copiar", + "saveLinkNow": "Guarda este enlace ahora.", + "saveLinkHint": "Nunca almacenamos el texto plano — una vez que cierres este cuadro de diálogo, la URL desaparece. Para volver a compartirlo, revoca esta invitación y crea una nueva.", + "sendViaWhatsApp": "Enviar por WhatsApp", + "done": "Listo", + "dialogTitle": "Invitar a un compañero de equipo", + "dialogDesc": "Genera un enlace de invitación de un solo uso. Compártelo por WhatsApp, Slack o cualquier canal que prefieras — no se requiere un servicio de correo.", + "roleLabel": "Rol", + "validForLabel": "Enlace válido por", + "days1": "1 día", + "days7": "7 días", + "days30": "30 días", + "labelTitle": "Etiqueta", + "optional": "(opcional)", + "labelPlaceholder": "p. ej. Sara — equipo de soporte", + "labelHint": "Te ayuda a recordar a quién le enviaste el enlace en la lista de pendientes de abajo.", + "cancel": "Cancelar", + "creating": "Creando...", + "generateLink": "Generar enlace", + "labelTooLong": "La etiqueta debe tener {max} caracteres o menos", + "copied": "Enlace de invitación copiado", + "clipboardBlocked": "Portapapeles bloqueado — copia el enlace manualmente", + "whatsappMessage": "Únete a {accountName} en wacrm usando este enlace (válido por {expiresInDays} días): {url}" + }, + "tagsAndFields": { + "title": "Campos y etiquetas", + "description": "Dos formas de organizar contactos: etiquetas con código de color para agrupar rápido, y campos personalizados para datos estructurados.", + "tagsTitle": "Etiquetas", + "tagsDesc": "Etiquetas con código de color para agrupar y filtrar contactos.", + "fieldsTitle": "Campos personalizados", + "fieldsDesc": "Campos adicionales de contacto (p. ej. código postal, origen del lead). Aparecen en cada contacto y en la acción de automatización “Actualizar campo de contacto”.", + "adminRole": "Administrador", + "failedToLoadTags": "No se pudieron cargar las etiquetas", + "nameRequired": "El nombre de la etiqueta es obligatorio", + "notAuthenticated": "No autenticado", + "tagCreated": "Etiqueta creada", + "failedToCreateTag": "No se pudo crear la etiqueta", + "tagDeleted": "Etiqueta eliminada", + "failedToDeleteTag": "No se pudo eliminar la etiqueta", + "deleteAria": "Eliminar {name}", + "noTags": "Aún no hay etiquetas — crea la primera abajo.", + "placeholder": "p. ej. Boletín", + "useColor": "Usar {color}", + "addTag": "Agregar etiqueta", + "deleteTag": "Eliminar etiqueta", + "deleteConfirm": "¿Eliminar la etiqueta \"{name}\"? Esto la quita de todos los contactos y no se puede deshacer.", + "cancel": "Cancelar", + "deleting": "Eliminando...", + "colors": { + "red": "Rojo", + "orange": "Naranja", + "amber": "Ámbar", + "emerald": "Esmeralda", + "cyan": "Cian", + "blue": "Azul", + "violet": "Violeta", + "pink": "Rosa" + } + }, + "templates": { + "title": "Plantillas de mensajes", + "description": "Crea plantillas y envíalas a Meta para su aprobación. Usa \"Sincronizar desde Meta\" para traer plantillas aprobadas en otro lugar.", + "syncFromMeta": "Sincronizar desde Meta", + "syncing": "Sincronizando…", + "syncTitle": "Trae las plantillas aprobadas de tu cuenta de WhatsApp Business en Meta", + "newTemplate": "Nueva plantilla", + "noTemplates": "Aún no hay plantillas.", + "createFirst": "Crea tu primera plantilla de mensaje para empezar.", + "editTitle": "Editar activa una nueva revisión de Meta — el estado cambia a PENDIENTE.", + "editLabel": "Editar plantilla", + "edit": "Editar", + "resubmitTitle": "Edita la plantilla y vuelve a enviarla a Meta para revisión.", + "resubmitLabel": "Editar y reenviar plantilla", + "resubmit": "Reenviar", + "deleteMetaLocallyAria": "Eliminar plantilla de Meta y localmente", + "deleteLocallyAria": "Eliminar plantilla localmente", + "deleteMetaLocallyTitle": "Eliminar de Meta y localmente", + "deleteLocallyTitle": "Eliminar localmente", + + "dialogEditTitle": "Editar plantilla de mensaje", + "dialogNewTitle": "Nueva plantilla de mensaje", + "dialogEditDesc": "Guarda tus cambios para reenviarlos a Meta. El estado volverá a PENDIENTE durante la revisión.", + "dialogNewDesc": "Crea una plantilla y envíala a Meta para su aprobación. Una vez aprobada, podrás usarla en difusiones y en la bandeja de entrada.", + "authWarning": "Las plantillas de AUTENTICACIÓN tienen un cuerpo fijo y un botón de OTP que requieren un creador distinto. Créalas por ahora en el Administrador de WhatsApp de Meta y usa Sincronizar desde Meta para traerlas.", + + "templateName": "Nombre de la plantilla", + "namePlaceholder": "p. ej. confirmacion_pedido", + "nameFixed": "El nombre queda fijo una vez que la plantilla existe en Meta — crea una nueva plantilla para cambiarlo.", + "nameHint": "Solo letras minúsculas, dígitos y guiones bajos.", + "category": "Categoría", + "language": "Idioma", + "langFixed": "El idioma queda fijo una vez que la plantilla existe en Meta.", + "langHint": "Debe coincidir exactamente con el código en Meta — en_US y en son distintos.", + + "header": "Encabezado", + "headerNone": "Ninguno", + "headerText": "Texto", + "headerImage": "Imagen", + "headerVideo": "Video", + "headerDocument": "Documento", + "headerTextPlaceholder": "Texto de encabezado (máx. 60 caracteres, {{1}} opcional)", + "headerSampleAria": "Valor de muestra para la variable del encabezado", + "headerSamplePlaceholder": "Valor de muestra para {{1}} (requerido para la revisión de Meta)", + + "uploadImage": "Subir imagen", + "uploadHint": "JPEG o PNG, ≤5 MB", + "mediaUrlPlaceholder": "https://… (o pega un enlace público {format})", + "imageHint": "Sube un JPEG/PNG (≤5 MB, se recomienda ≥800×418 px) o pega un enlace HTTPS público — lo subimos a Meta para revisión automáticamente.", + "mediaHint": "Debe ser un enlace HTTPS de acceso público. Meta lo consulta una vez durante la revisión, así que debe permanecer activo unas ~24 horas.", + "videoHint": " Recomendado: MP4 / 3GPP, ≤16 MB, ≤60 segundos.", + "documentHint": " Recomendado: PDF, ≤100 MB.", + + "bodyText": "Texto del cuerpo", + "bodyPlaceholder": "Hola {{1}}, tu pedido {{2}} está confirmado.", + "bodyHint": "Usa {{1}}, {{2}} para variables (deben ser contiguas empezando en {{1}}).", + "sampleValues": "Valores de muestra (Meta los usa para revisar tu plantilla)", + "sampleAria": "Valor de muestra para la variable del cuerpo {var}", + "samplePlaceholder": "Muestra para {var}", + + "footer": "Pie de página (opcional)", + "footerPlaceholder": "Texto de pie de página opcional (máx. 60 caracteres)", + + "buttons": "Botones (opcional)", + "addButton": "Agregar botón", + "buttonsLimit": "Hasta {max} botones. Los botones QUICK_REPLY deben ir antes que los de URL / teléfono / código de copia.", + "btnQuickReply": "Respuesta rápida", + "btnUrl": "URL", + "btnPhone": "Teléfono", + "btnCopyCode": "Copiar código", + "btnLabelPlaceholder": "Etiqueta del botón", + "urlPlaceholder": "https://ejemplo.com/ruta o con sufijo {{1}}", + "urlSamplePlaceholder": "Valor de ejemplo para {{1}} (requerido cuando la URL tiene una variable)", + "phonePlaceholder": "+15551234567", + "codePlaceholder": "Código de ejemplo (p. ej. VERANO20)", + + "cancel": "Cancelar", + "saving": "Guardando…", + "submitting": "Enviando…", + "saveResubmit": "Guardar y reenviar", + "submitApproval": "Enviar para aprobación", + + "deleteDialogTitle": "¿Eliminar plantilla?", + "deleteMetaDesc": "\"{name}\" se eliminará de Meta y de wacrm. Las difusiones activas que usen esta plantilla empezarán a fallar en su próximo envío. Esto no se puede deshacer.", + "deleteLocalDesc": "\"{name}\" se eliminará de wacrm. Nunca se envió a Meta, así que no se necesita limpieza remota.", + "deleting": "Eliminando…", + "delete": "Eliminar", + + "toastLoadFailed": "No se pudieron cargar las plantillas", + "toastSubmitFailed": "No se pudo enviar", + "toastSubmitEditSuccess": "Edición enviada — Meta normalmente revisa en un plazo de 24 horas.", + "toastSubmitNewSuccess": "Enviada a Meta — el tiempo de revisión típico es de 24 horas. El estado se actualiza automáticamente.", + "toastSaveEditDry": "Plantilla actualizada (simulación — sin llamada a Meta)", + "toastSaveNewDry": "Plantilla guardada (simulación — sin llamada a Meta)", + "toastSyncCount": "Se sincronizaron {total} {total, plural, =1 {plantilla} other {plantillas}} desde Meta", + "toastSyncDetails": " ({inserted} nuevas, {updated} actualizadas)", + "toastSyncFailed": "No se pudo sincronizar: {preview}", + "toastSyncTruncated": "Se sincronizaron solo las primeras 2000 plantillas — tu cuenta tiene más. Sincroniza de nuevo para continuar, o contacta a soporte si persiste.", + "toastSyncError": "No se pudieron sincronizar las plantillas", + "toastDeleteSuccess": "Plantilla eliminada", + "toastDeleteError": "No se pudo eliminar la plantilla", + "toastInvalidImage": "La imagen de encabezado debe ser JPEG o PNG.", + "toastImageTooLarge": "La imagen pesa {size} MB — el límite de Meta es 5 MB.", + "toastUploadSuccess": "Imagen subida.", + "toastUploadFailed": "Error al subir." + }, + "roles": { + "admin": "Administrador", + "adminHint": "Administra miembros y todo lo demás", + "agent": "Agente", + "agentHint": "Usa funciones; sin configuración", + "viewer": "Visualizador", + "viewerHint": "Solo lectura en toda la app", + "owner": "Propietario", + "ownerHint": "Control total sobre la cuenta y la facturación" + }, + "whatsapp": { + "title": "Conexión de WhatsApp", + "description": "Conecta tu API de WhatsApp Business de Meta. Las credenciales, el webhook y los pasos de configuración están todos aquí.", + "tokenCorrupted": "El token guardado no se puede descifrar", + "resetting": "Restableciendo...", + "resetConfig": "Restablecer configuración", + "credentialsValid": "Credenciales válidas", + "notConnected": "No conectado", + "connectedDesc": "Tu token de acceso autentica con Meta. Consulta el estado de registro abajo para saber si los webhooks están realmente conectados.", + "notConnectedDesc": "Configura tus credenciales de la API de Meta abajo para conectar tu cuenta de WhatsApp Business.", + "registered": "Registrado — Meta enviará eventos a wacrm", + "notRegistered": "No registrado — Meta no enviará eventos", + "verifyWithMeta": "Verificar con Meta", + "subscribedSince": "Suscrito desde {date}. Haz clic en Verificar con Meta si los eventos dejan de llegar.", + "unknownDate": "desconocida", + "lastAttemptFailed": "El último intento falló con: ", + "retryHint": "Ingresa (o corrige) el PIN de verificación en dos pasos abajo y haz clic en Guardar configuración para reintentar.", + "noRegistrationHint": "Este número se guardó antes de que existiera el seguimiento de registro, o el registro se omitió. Ingresa el PIN de verificación en dos pasos abajo y haz clic en Guardar configuración para suscribirlo.", + "diagnosticLastRun": "Diagnóstico — última ejecución: ", + "live": "en vivo", + "notLive": "no activo", + "apiCredentialsTitle": "Credenciales de API", + "apiCredentialsDesc": "Ingresa las credenciales de tu API de WhatsApp Business de Meta.", + "phoneNumberId": "ID del número de teléfono", + "wabaId": "ID de la cuenta de WhatsApp Business", + "accessToken": "Token de acceso permanente", + "accessTokenPlaceholder": "Ingresa tu token de acceso", + "tokenHidden": "El token está oculto por seguridad. Vuelve a ingresarlo para actualizar la configuración.", + "webhookVerifyToken": "Token de verificación de webhook", + "webhookVerifyTokenPlaceholder": "Crea un token de verificación personalizado", + "webhookVerifyTokenHint": "Una cadena personalizada que tú creas. Debe coincidir con el token que definiste en la configuración del webhook de Meta.", + "twoStepPin": "PIN de verificación en dos pasos", + "optional": "(opcional)", + "pinPlaceholder": "PIN de 6 dígitos del Administrador de WhatsApp de Meta", + "pinHint": "Solo se necesita para conectar mensajes entrantes de un número de producción. Configúralo en Meta Business Manager → Cuentas de WhatsApp → Números de teléfono → Verificación en dos pasos, y luego pégalo aquí para que wacrm pueda suscribir el número — de lo contrario Meta enruta los eventos entrantes a la última app que reclamó el número (el síntoma que afecta a números secundarios bajo una WABA compartida). Los números de prueba de Meta no tienen PIN y ya están registrados — déjalo en blanco para esos casos. Dejarlo en blanco también mantiene intacto un registro existente.", + "webhookTitle": "Configuración del webhook", + "webhookDesc": "Usa esta URL como devolución de llamada del webhook en el Panel de apps de Meta.", + "webhookUrl": "URL de devolución de llamada del webhook", + "saving": "Guardando...", + "saveConfig": "Guardar configuración", + "testing": "Probando...", + "testConnection": "Probar conexión con la API", + "setupInstructions": "Instrucciones de configuración", + "setupInstructionsDesc": "Sigue estos pasos para conectar tu API de WhatsApp Business.", + "step1": "Crea una app de Meta", + "step1_1": "Ve a developers.facebook.com", + "step1_2": "Haz clic en \"Mis apps\" y luego en \"Crear app\"", + "step1_3": "Selecciona \"Empresa\" como tipo de app", + "step1_4": "Completa los datos de la app y créala", + "step2": "Agrega el producto WhatsApp", + "step2_1": "En el panel de tu app, haz clic en \"Agregar producto\"", + "step2_2": "Busca \"WhatsApp\" y haz clic en \"Configurar\"", + "step2_3": "Sigue el asistente de configuración para vincular tu empresa", + "step3": "Obtén las credenciales de la API", + "step3_1": "Ve a WhatsApp > Configuración de la API", + "step3_2": "Copia tu ID del número de teléfono", + "step3_3": "Copia tu ID de la cuenta de WhatsApp Business", + "step3_4": "Genera un token de acceso permanente desde Configuración empresarial > Usuarios del sistema", + "step4": "Configura los webhooks", + "step4_1": "Ve a WhatsApp > Configuración", + "step4_2": "Haz clic en \"Editar\" en la sección de Webhook", + "step4_3": "Pega la URL de devolución de llamada del webhook de arriba", + "step4_4": "Ingresa el mismo token de verificación que configuraste aquí", + "step4_5": "Suscríbete al campo de webhook \"messages\"", + "metaDocs": "Documentación de la API de WhatsApp de Meta" + }, + "sections": { + "overview": "Resumen", + "profile": "Tu perfil", + "security": "Inicio de sesión y seguridad", + "appearance": "Apariencia", + "whatsapp": "WhatsApp", + "templates": "Plantillas", + "fields": "Campos y etiquetas", + "deals": "Negocios y moneda", + "members": "Miembros del equipo", + "api": "Claves de API" + }, + "groups": { + "account": "Cuenta", + "workspace": "Espacio de trabajo" + }, + "profile": { + "title": "Tu perfil", + "description": "Cómo apareces en toda la app. Tu avatar y nombre aparecen en el encabezado, la barra lateral y donde sea que tus compañeros de equipo te vean.", + "changePhoto": "Cambiar foto", + "uploadPhoto": "Subir foto", + "remove": "Quitar", + "photoHint": "PNG, JPG, WebP o GIF. Hasta 2 MB.", + "displayName": "Nombre para mostrar", + "email": "Correo electrónico", + "emailChangeHint": "Revisa la bandeja de entrada de {oldEmail} y {newEmail} — ambos deben confirmar antes de que el cambio surta efecto.", + "accountDetails": "Datos de la cuenta", + "role": "Rol", + "joined": "Se unió", + "userId": "ID de usuario", + "loading": "Cargando tu perfil…", + "saving": "Guardando…", + "saveChanges": "Guardar cambios", + "unsupportedImage": "Tipo de imagen no compatible", + "unsupportedImageDesc": "Usa PNG, JPG, WebP o GIF.", + "imageTooLarge": "La imagen es demasiado grande", + "imageTooLargeDesc": "Máximo 2 MB.", + "nameRequired": "El nombre para mostrar es obligatorio", + "invalidEmail": "Ingresa una dirección de correo válida", + "uploadFailed": "Error al subir: {message}", + "saveFailed": "Error al guardar: {message}", + "profileSaved": "Perfil guardado", + "emailChangeFailed": "Error al cambiar el correo: {message}", + "profileSavedEmailCheck": "Perfil guardado — revisa tu correo para confirmar el cambio de dirección", + + "passwordTitle": "Contraseña", + "passwordDesc": "Usa al menos {min} caracteres. Tu sesión seguirá activa en este dispositivo después de cambiarla.", + "currentPassword": "Contraseña actual", + "newPassword": "Contraseña nueva", + "confirmPassword": "Confirmar contraseña nueva", + "updating": "Actualizando…", + "updatePassword": "Actualizar contraseña", + "cannotChangeNoEmail": "No se puede cambiar la contraseña sin un correo actual", + "passwordTooShort": "La contraseña debe tener al menos {min} caracteres", + "passwordMismatch": "La contraseña nueva y la confirmación no coinciden", + "currentPasswordIncorrect": "La contraseña actual es incorrecta", + "passwordUpdateFailed": "Error al actualizar la contraseña: {message}", + "passwordUpdated": "Contraseña actualizada", + + "sessionsTitle": "Sesiones activas", + "sessionsDesc": "Cierra sesión en todos los dispositivos donde hayas iniciado sesión — incluido este. Útil si perdiste una laptop o compartiste tu contraseña.", + "signOutAll": "Cerrar sesión en todos los dispositivos", + "signOutConfirmTitle": "¿Cerrar sesión en todas partes?", + "signOutConfirmDesc": "Todos los dispositivos con sesión iniciada en esta cuenta cerrarán sesión y deberán volver a iniciar sesión. Serás redirigido a la página de inicio de sesión.", + "cancel": "Cancelar", + "signingOut": "Cerrando sesión…", + "signOutEverywhere": "Cerrar sesión en todas partes", + "signOutFailed": "Error al cerrar sesión: {message}" + }, + "appearance": { + "title": "Apariencia", + "description": "Define el modo y el color de acento usados en toda la app. Se guarda en este dispositivo — pruébalo, cambia en vivo.", + "mode": "Modo", + "useMode": "Usar modo {mode}", + "active": "Activo", + "accentColor": "Color de acento", + "useTheme": "Usar tema {name}" + }, + "security": { + "title": "Inicio de sesión y seguridad", + "description": "Cambia tu contraseña y cierra sesión en tus dispositivos. Esto mantiene tu cuenta segura." + }, + "apiKeys": { + "title": "Claves de API", + "description": "Las claves autentican la API REST pública (/api/v1) para que puedas crear tus propias automatizaciones. Envíalas como Authorization: Bearer <key>.", + "newApiKey": "Nueva clave de API", + "noApiKeys": "Aún no hay claves de API.", + "createOneHint": "Haz clic en Nueva clave de API para crear una.", + "askAdminHint": "Pide a un administrador que cree una.", + "revoked": "Revocada", + "expired": "Expirada", + "noScopes": "Sin alcances", + "created": "Creada el {date}", + "lastUsed": "último uso {date}", + "neverUsed": "nunca usada", + "expires": "expira el {date}", + "revoke": "Revocar", + "revokeSuccess": "Se revocó \"{name}\"", + "revokeFailed": "No se pudo revocar la clave", + "loadFailed": "No se pudieron cargar las claves de API", + "createError": "No se pudo crear la clave", + "nameRequired": "Ponle un nombre a la clave", + "nameLabel": "Nombre", + "namePlaceholder": "p. ej. Automatización de Zapier", + "scopesLabel": "Alcances", + "scopesHint": "Una clave sin alcances aún puede llamar a GET /api/v1/me para verificar que funciona.", + "cancel": "Cancelar", + "creating": "Creando…", + "createKey": "Crear clave", + "copyTitle": "Copia tu clave de API", + "copyDesc": "Esta es la única vez que se muestra la clave completa. Guárdala en un lugar seguro — si la pierdes, revócala y crea una nueva.", + "apiKeyLabel": "Clave de API", + "copy": "Copiar", + "copySuccess": "Clave de API copiada", + "copyFailed": "Error al copiar — selecciona y copia manualmente", + "newKeyTitle": "Nueva clave de API", + "newKeyDesc": "Nómbrala según la integración que la usará y otórgale solo los alcances que necesita.", + "done": "Listo", + "networkError": "No se pudo conectar con el servidor" + }, + "deals": { + "title": "Negocios y moneda", + "description": "La moneda usada para negocios nuevos y para los totales del embudo y del panel.", + "defaultCurrency": "Moneda predeterminada", + "defaultCurrencyDesc": "Los negocios nuevos usan esta moneda de forma predeterminada, y los totales del embudo y del panel se muestran en ella. Los negocios existentes conservan la moneda con la que se guardaron.", + "currencyLabel": "Moneda", + "adminOnlyHint": "Solo los administradores de la cuenta pueden cambiar la moneda predeterminada.", + "save": "Guardar", + "saving": "Guardando...", + "saveFailed": "No se pudo guardar la moneda predeterminada", + "saveSuccess": "Moneda predeterminada actualizada" + }, + "aiConfig": { + "title": "Configuración del agente", + "description": "Usa tu propia clave de OpenAI o Anthropic. wacrm llama al proveedor directamente con tu clave — sin tarifas de IA por usuario, y tus datos siguen siendo tuyos. Esto impulsa las respuestas redactadas por IA en la bandeja de entrada, el bot de respuesta automática y el Playground.", + "adminOnlyConfig": "Solo los administradores y propietarios pueden cambiar la configuración de IA.", + "loadFailed": "No se pudo cargar la configuración de IA", + "providerAndKey": "Proveedor y clave", + "encryptionNotice": "Tu clave se cifra en reposo (AES-256-GCM) y nunca se vuelve a mostrar después de guardarla.", + "provider": "Proveedor", + "model": "Modelo", + "apiKey": "Clave de API", + "testKey": "Probar clave", + "testSuccess": "La clave funciona — el proveedor respondió.", + "testRejected": "El proveedor rechazó la solicitud.", + "testNetworkError": "No se pudo conectar con el proveedor.", + "embeddingsKey": "Clave de embeddings", + "optionalSemanticSearch": "(opcional — habilita la búsqueda semántica en la base de conocimiento)", + "embeddingsHint": "Una clave de OpenAI usada solo para vectorizar tu base de conocimiento (text-embedding-3-small){sameKeyText}. Déjala en blanco para usar búsqueda por palabras clave en su lugar. Bórrala para desactivar la búsqueda semántica.", + "sameKeyText": " — puede ser la misma clave de arriba", + "behaviour": "Comportamiento", + "behaviourDesc": "Cuéntale al asistente sobre tu negocio — productos, tono, qué puede y no puede prometer. Este contexto alimenta tanto los borradores como las respuestas automáticas.", + "businessContext": "Contexto e instrucciones del negocio", + "promptPlaceholder": "p. ej. Somos Acme, una tienda de equipos para café. Sé cálido y conciso. Nunca cotices precios ni fechas de entrega — transfiere esos casos a un humano.", + "enableAssistant": "Habilitar asistente de IA", + "enableAssistantDesc": "Interruptor general. Activa el botón “Redactar con IA” en la bandeja de entrada.", + "autoReply": "Respuesta automática a mensajes entrantes", + "autoReplyDesc": "El bot responde automáticamente a mensajes entrantes nuevos (solo cuando ningún flujo los maneja y no hay un agente asignado). Transfiere a un humano cuando no puede ayudar.", + "maxAutoReplies": "Máximo de respuestas automáticas por conversación", + "maxAutoRepliesDesc": "Después de esta cantidad de respuestas del bot en un mismo hilo, el bot deja de responder.", + "handoffTo": "Transferir a", + "handoffToDesc": "Cuando el bot no puede ayudar — o llega al límite de respuestas — se pausa y dirige el chat aquí, con una breve nota de lo ocurrido.", + "handoffQueue": "Cola sin asignar (cualquier agente puede tomarla)", + "remove": "Quitar", + "save": "Guardar", + "missingModel": "Ingresa el nombre de un modelo.", + "missingApiKey": "Ingresa tu clave de API.", + "saveSuccess": "Asistente de IA guardado.", + "saveFailed": "No se pudo guardar.", + "removeSuccess": "Configuración de IA eliminada.", + "removeFailed": "No se pudo eliminar." + }, + "aiKnowledge": { + "loadFailed": "No se pudo cargar la base de conocimiento", + "openFailed": "No se pudo abrir el documento", + "titleContentRequired": "El título y el contenido son obligatorios.", + "saveSuccessNew": "Documento agregado.", + "saveSuccessUpdate": "Documento actualizado.", + "saveFailed": "No se pudo guardar.", + "removeSuccess": "Documento eliminado.", + "removeFailed": "No se pudo eliminar.", + "reindexSuccess": "Se reindexaron {count} documento(s).", + "reindexFailed": "Falló la reindexación.", + "title": "Base de conocimiento", + "description": "Agrega preguntas frecuentes, políticas o detalles de productos. El asistente recupera las piezas relevantes al redactar y responder automáticamente, para poder responder en lugar de transferir.{searchType}", + "semanticSearchOn": " La búsqueda semántica está activada (clave de embeddings configurada).", + "keywordSearchOn": " Usando búsqueda por palabras clave — agrega una clave de embeddings arriba para búsqueda semántica.", + "noDocs": "Aún no hay documentos.", + "editDocTitle": "Título", + "editDocTitlePlaceholder": "p. ej. Política de devoluciones y reembolsos", + "editDocContent": "Contenido", + "editDocContentPlaceholder": "Pega la respuesta a la pregunta frecuente, el texto de la política o los detalles del producto…", + "cancel": "Cancelar", + "saveDoc": "Guardar documento", + "addDoc": "Agregar documento", + "reindex": "Reindexar", + "reindexTooltip": "Vuelve a vectorizar todos los documentos (p. ej. después de agregar una clave de embeddings)", + "loading": "Cargando…" + } + } +} diff --git a/src/app/(auth)/forgot-password/page.tsx b/src/app/(auth)/forgot-password/page.tsx index d2a1784fb7..c547be730e 100644 --- a/src/app/(auth)/forgot-password/page.tsx +++ b/src/app/(auth)/forgot-password/page.tsx @@ -50,12 +50,12 @@ export default function ForgotPasswordPage() { - Check your email + Revisa tu correo - We've sent a password reset link to{" "} - {email}. Please check your - inbox. + Enviamos un enlace para restablecer tu contraseña a{" "} + {email}. Revisa tu + bandeja de entrada. @@ -64,7 +64,7 @@ export default function ForgotPasswordPage() { variant="outline" className="w-full border-border text-muted-foreground hover:bg-muted hover:text-foreground" > - Back to sign in + Volver a iniciar sesión @@ -80,9 +80,9 @@ export default function ForgotPasswordPage() {
- Reset password + Restablecer contraseña - Enter your email and we'll send you a reset link + Ingresa tu correo y te enviaremos un enlace para restablecerla @@ -95,12 +95,12 @@ export default function ForgotPasswordPage() {
setEmail(e.target.value)} required @@ -113,7 +113,7 @@ export default function ForgotPasswordPage() { disabled={loading} className="mt-2 h-10 w-full bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50" > - {loading ? "Sending..." : "Send reset link"} + {loading ? "Enviando..." : "Enviar enlace"} @@ -122,7 +122,7 @@ export default function ForgotPasswordPage() { className="mt-6 flex items-center justify-center gap-2 text-sm text-muted-foreground hover:text-foreground" > - Back to sign in + Volver a iniciar sesión diff --git a/src/app/(auth)/signup/page.tsx b/src/app/(auth)/signup/page.tsx index 56985cdb2f..1c05cbac8d 100644 --- a/src/app/(auth)/signup/page.tsx +++ b/src/app/(auth)/signup/page.tsx @@ -49,12 +49,12 @@ function SignupPageInner() { setError(null); if (password !== confirmPassword) { - setError("Passwords do not match"); + setError("Las contraseñas no coinciden"); return; } if (password.length < 6) { - setError("Password must be at least 6 characters"); + setError("La contraseña debe tener al menos 6 caracteres"); return; } @@ -98,12 +98,12 @@ function SignupPageInner() {
- Check your email + Revisa tu correo - We've sent a confirmation link to{" "} - {email}. Please check your - inbox and click the link to verify your account. + Enviamos un enlace de confirmación a{" "} + {email}. Revisa tu + bandeja de entrada y haz clic en el enlace para verificar tu cuenta. @@ -118,7 +118,7 @@ function SignupPageInner() { variant="outline" className="w-full border-border text-muted-foreground hover:bg-muted hover:text-foreground" > - Back to sign in + Volver a iniciar sesión @@ -139,12 +139,12 @@ function SignupPageInner() { )} - {inviteToken ? "Create account & join" : "Create account"} + {inviteToken ? "Crear cuenta y unirte" : "Crear cuenta"} {inviteToken - ? "Verify your email, then accept the invitation to join your team." - : "Get started with CRM Template for WhatsApp"} + ? "Verifica tu correo y luego acepta la invitación para unirte a tu equipo." + : "Comienza con la plantilla de CRM para WhatsApp"} @@ -157,12 +157,12 @@ function SignupPageInner() {
setFullName(e.target.value)} required @@ -172,12 +172,12 @@ function SignupPageInner() {
setEmail(e.target.value)} required @@ -187,12 +187,12 @@ function SignupPageInner() {
setPassword(e.target.value)} required @@ -202,12 +202,12 @@ function SignupPageInner() {
setConfirmPassword(e.target.value)} required @@ -220,12 +220,12 @@ function SignupPageInner() { disabled={loading} className="mt-2 h-10 w-full bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50" > - {loading ? "Creating account..." : "Create account"} + {loading ? "Creando cuenta..." : "Crear cuenta"}

- Already have an account?{" "} + ¿Ya tienes una cuenta?{" "} - Sign in + Iniciar sesión

diff --git a/src/app/(dashboard)/agents/page.tsx b/src/app/(dashboard)/agents/page.tsx index c3774849a4..03987edac7 100644 --- a/src/app/(dashboard)/agents/page.tsx +++ b/src/app/(dashboard)/agents/page.tsx @@ -41,12 +41,12 @@ export default function AgentsPage() {

- AI Agents + Agentes IA

- Your bring-your-own-key AI agent — set it up, then test it in the - playground before it replies to customers in the inbox. + Tu agente de IA con clave propia — configúralo y luego pruébalo en el + playground antes de que responda a los clientes en la bandeja de entrada.

{decided && ( @@ -60,11 +60,11 @@ export default function AgentsPage() { Playground - Setup + Configuración {canViewUsage && ( - Usage + Uso )} diff --git a/src/app/(dashboard)/automations/page.tsx b/src/app/(dashboard)/automations/page.tsx index 4a87a8801c..d906b1d306 100644 --- a/src/app/(dashboard)/automations/page.tsx +++ b/src/app/(dashboard)/automations/page.tsx @@ -77,7 +77,7 @@ export default function AutomationsPage() { if (fetchErr) throw fetchErr setAutomations((data ?? []) as Automation[]) } catch (err) { - setError(err instanceof Error ? err.message : "Failed to load automations") + setError(err instanceof Error ? err.message : "No se pudieron cargar las automatizaciones") } } @@ -169,7 +169,7 @@ export default function AutomationsPage() {
router.push("/automations/new")} className="bg-primary text-primary-foreground hover:bg-primary/90" > @@ -299,7 +299,7 @@ function AutomationCard({ {automation.name} {automation.is_active && ( - + @@ -336,7 +336,7 @@ function AutomationCard({ diff --git a/src/app/(dashboard)/broadcasts/[id]/page.tsx b/src/app/(dashboard)/broadcasts/[id]/page.tsx index 4dd4241558..0b212614d2 100644 --- a/src/app/(dashboard)/broadcasts/[id]/page.tsx +++ b/src/app/(dashboard)/broadcasts/[id]/page.tsx @@ -79,7 +79,7 @@ function FunnelChart({ steps }: { steps: FunnelStep[] }) { const max = Math.max(...steps.map((s) => s.value), 1); return (
-

Funnel

+

Embudo

{steps.map((step) => { const pctOfMax = Math.max(5, Math.round((step.value / max) * 100)); @@ -487,7 +487,7 @@ export default function BroadcastDetailPage() { return ( - {recipient.contact?.name ?? 'Unknown'} + {recipient.contact?.name ?? 'Desconocido'} {recipient.contact?.phone ?? '-'} diff --git a/src/app/(dashboard)/broadcasts/new/page.tsx b/src/app/(dashboard)/broadcasts/new/page.tsx index 50d1af218f..055d5cbddd 100644 --- a/src/app/(dashboard)/broadcasts/new/page.tsx +++ b/src/app/(dashboard)/broadcasts/new/page.tsx @@ -67,7 +67,7 @@ export default function NewBroadcastPage() { } catch (err) { // Previously swallowed with console.error — the wizard would // just no-op, leaving the user confused. Surface the reason. - const message = err instanceof Error ? err.message : 'Broadcast failed'; + const message = err instanceof Error ? err.message : 'Error al enviar la difusión'; console.error('Broadcast failed:', err); toast.error(message); } diff --git a/src/app/(dashboard)/broadcasts/page.tsx b/src/app/(dashboard)/broadcasts/page.tsx index ca89c8106b..415e6727f4 100644 --- a/src/app/(dashboard)/broadcasts/page.tsx +++ b/src/app/(dashboard)/broadcasts/page.tsx @@ -157,7 +157,7 @@ export default function BroadcastsPage() { {anySending && (
@@ -189,7 +189,7 @@ export default function BroadcastsPage() {
router.push('/broadcasts/new')} className="bg-primary text-primary-foreground hover:bg-primary/90" > @@ -207,7 +207,7 @@ export default function BroadcastsPage() {

router.push('/broadcasts/new')} className="mt-4 bg-primary text-primary-foreground hover:bg-primary/90" > diff --git a/src/app/(dashboard)/contacts/page.tsx b/src/app/(dashboard)/contacts/page.tsx index 0facba6932..39cd8aa888 100644 --- a/src/app/(dashboard)/contacts/page.tsx +++ b/src/app/(dashboard)/contacts/page.tsx @@ -363,7 +363,7 @@ export default function ContactsPage() { setImportOpen(true)} className="border-border text-muted-foreground hover:bg-muted" > @@ -372,7 +372,7 @@ export default function ContactsPage() { @@ -445,7 +445,7 @@ export default function ContactsPage() { toggleTagFilter(tag.id)} - aria-label={`Filter by ${tag.name}`} + aria-label={`Filtrar por ${tag.name}`} /> toggleTagFilter(id)} - aria-label={`Remove ${tag.name} filter`} + aria-label={`Quitar filtro ${tag.name}`} className="hover:opacity-70" > @@ -517,7 +517,7 @@ export default function ContactsPage() { variant="destructive" size="sm" canAct={canEdit} - gateReason="delete contacts" + gateReason="eliminar contactos" onClick={() => setBulkDeleteOpen(true)} > @@ -538,7 +538,7 @@ export default function ContactsPage() { indeterminate={!allOnPageSelected && someOnPageSelected} onCheckedChange={toggleSelectAll} disabled={contacts.length === 0} - aria-label="Select all contacts on this page" + aria-label="Seleccionar todos los contactos de esta página" /> {t('tableColumns.name')} @@ -573,7 +573,7 @@ export default function ContactsPage() { {!hasActiveFilters && ( toggleSelect(contact.id)} - aria-label={`Select ${contact.name || contact.phone}`} + aria-label={`Seleccionar ${contact.name || contact.phone}`} /> @@ -638,7 +638,7 @@ export default function ContactsPage() {
- {new Date(contact.created_at).toLocaleDateString('en-US', { + {new Date(contact.created_at).toLocaleDateString('es-MX', { month: 'short', day: 'numeric', year: 'numeric', diff --git a/src/app/(dashboard)/dashboard-shell.tsx b/src/app/(dashboard)/dashboard-shell.tsx index 06acfde102..33d175490c 100644 --- a/src/app/(dashboard)/dashboard-shell.tsx +++ b/src/app/(dashboard)/dashboard-shell.tsx @@ -31,7 +31,7 @@ function DashboardShellInner({ children }: { children: React.ReactNode }) {
-

Loading...

+

Cargando...

); diff --git a/src/app/(dashboard)/flows/page.tsx b/src/app/(dashboard)/flows/page.tsx index 0753f5a707..1d3019fc96 100644 --- a/src/app/(dashboard)/flows/page.tsx +++ b/src/app/(dashboard)/flows/page.tsx @@ -215,7 +215,7 @@ export default function FlowsPage() {
setCreateOpen(true)} > @@ -345,7 +345,7 @@ function EmptyState({

diff --git a/src/app/(dashboard)/notifications/page.tsx b/src/app/(dashboard)/notifications/page.tsx index c658fa9b92..77671119c9 100644 --- a/src/app/(dashboard)/notifications/page.tsx +++ b/src/app/(dashboard)/notifications/page.tsx @@ -104,7 +104,7 @@ export default function NotificationsPage() { .eq("id", id) .is("read_at", null); if (updateErr) { - toast.error("Failed to mark notification as read"); + toast.error("No se pudo marcar la notificación como leída"); load(); } }, @@ -137,7 +137,7 @@ export default function NotificationsPage() { .is("read_at", null); setMarkingAll(false); if (updateErr) { - toast.error("Failed to mark all as read"); + toast.error("No se pudieron marcar todas como leídas"); load(); } }, [unreadIds.length, load]); @@ -147,7 +147,7 @@ export default function NotificationsPage() {

{error}

); @@ -165,9 +165,9 @@ export default function NotificationsPage() {
-

Notifications

+

Notificaciones

- Conversations other teammates assign to you show up here. + Aquí aparecen las conversaciones que otros compañeros te asignan.

@@ -191,11 +191,11 @@ export default function NotificationsPage() {

- No notifications yet + Aún no hay notificaciones

- You'll see an alert here when someone assigns you a - conversation. + Verás una alerta aquí cuando alguien te asigne una + conversación.

) : ( @@ -241,7 +241,7 @@ export default function NotificationsPage() { {isUnread && ( )} diff --git a/src/app/(dashboard)/pipelines/page.tsx b/src/app/(dashboard)/pipelines/page.tsx index fff6f98d68..043229cf02 100644 --- a/src/app/(dashboard)/pipelines/page.tsx +++ b/src/app/(dashboard)/pipelines/page.tsx @@ -38,11 +38,11 @@ import { useTranslations } from "next-intl"; // Spec-defined seed — name and color per the product spec. const SPEC_DEFAULT_STAGES = [ - { name: "New Lead", color: "#3b82f6", position: 0 }, // blue - { name: "Qualified", color: "#eab308", position: 1 }, // yellow - { name: "Proposal Sent", color: "#f97316", position: 2 }, // orange - { name: "Negotiation", color: "#8b5cf6", position: 3 }, // purple - { name: "Won", color: "#22c55e", position: 4 }, // green + { name: "Nuevo Lead", color: "#3b82f6", position: 0 }, // blue + { name: "Calificado", color: "#eab308", position: 1 }, // yellow + { name: "Propuesta enviada", color: "#f97316", position: 2 }, // orange + { name: "Negociación", color: "#8b5cf6", position: 3 }, // purple + { name: "Ganado", color: "#22c55e", position: 4 }, // green ]; export default function PipelinesPage() { @@ -120,7 +120,7 @@ export default function PipelinesPage() { const { data: pipeline, error } = await supabase .from("pipelines") - .insert({ user_id: user.id, account_id: accountId, name: "Sales Pipeline" }) + .insert({ user_id: user.id, account_id: accountId, name: "Embudo de ventas" }) .select() .single(); @@ -370,7 +370,7 @@ export default function PipelinesPage() { setNewPipelineOpen(true)} className="border-border bg-card text-foreground hover:bg-muted" > @@ -379,7 +379,7 @@ export default function PipelinesPage() { handleAddDeal()} className="bg-primary text-primary-foreground hover:bg-primary/90" @@ -402,7 +402,7 @@ export default function PipelinesPage() {

setNewPipelineOpen(true)} className="mt-4 bg-primary text-primary-foreground hover:bg-primary/90" > diff --git a/src/app/(dashboard)/settings/page.tsx b/src/app/(dashboard)/settings/page.tsx index 97d192b356..4d0cebba98 100644 --- a/src/app/(dashboard)/settings/page.tsx +++ b/src/app/(dashboard)/settings/page.tsx @@ -47,7 +47,7 @@ export default function SettingsPage() { // already in context. const hints: Partial> = useMemo( () => ({ - appearance: mode.charAt(0).toUpperCase() + mode.slice(1), + appearance: mode === 'dark' ? 'Oscuro' : 'Claro', deals: defaultCurrency, }), [mode, defaultCurrency], diff --git a/src/app/join/[token]/page.tsx b/src/app/join/[token]/page.tsx index 32e09bbc5a..14fdbca3bb 100644 --- a/src/app/join/[token]/page.tsx +++ b/src/app/join/[token]/page.tsx @@ -66,27 +66,27 @@ interface PeekFail { type PeekResult = PeekOk | PeekFail; const ROLE_LABEL: Record = { - admin: 'Admin', - agent: 'Agent', - viewer: 'Viewer', + admin: 'Administrador', + agent: 'Agente', + viewer: 'Visualizador', }; const FAIL_COPY: Record = { not_found: { - title: 'Invite not found', - body: 'This link doesn’t match a valid invitation. Double-check the URL or ask the person who invited you to send a new one.', + title: 'Invitación no encontrada', + body: 'Este enlace no corresponde a una invitación válida. Revisa la URL o pídele a la persona que te invitó que te envíe una nueva.', }, used: { - title: 'Invite already used', - body: 'This invitation has already been accepted. If that wasn’t you, ask the account admin to send a fresh link.', + title: 'Invitación ya utilizada', + body: 'Esta invitación ya fue aceptada. Si no fuiste tú, pídele al administrador de la cuenta que envíe un nuevo enlace.', }, expired: { - title: 'Invite expired', - body: 'This invitation has expired. Ask the account admin to send a new one — they take a few seconds to generate.', + title: 'Invitación expirada', + body: 'Esta invitación ha expirado. Pídele al administrador de la cuenta que envíe una nueva; solo toma unos segundos generarla.', }, server_error: { - title: 'Something went wrong', - body: 'We couldn’t verify this invitation right now. Try refreshing the page in a moment.', + title: 'Algo salió mal', + body: 'No pudimos verificar esta invitación en este momento. Intenta actualizar la página en un momento.', }, }; @@ -183,21 +183,21 @@ export default function JoinPage() { if (res.status === 409) { setConflictMessage( payload.error || - 'You are already in another account. Sign in with a different email to join this one.', + 'Ya perteneces a otra cuenta. Inicia sesión con un correo diferente para unirte a esta.', ); } else { - toast.error(payload.error || 'Failed to accept invitation'); + toast.error(payload.error || 'No se pudo aceptar la invitación'); } setAccepting(false); return; } - toast.success('Welcome to the team'); + toast.success('Bienvenido al equipo'); // Full reload (not router.push) so AuthProvider re-fetches // the profile with the new account_id and account_role. window.location.href = '/dashboard'; } catch (err) { console.error('[join] redeem error:', err); - toast.error('Could not reach the server'); + toast.error('No se pudo conectar con el servidor'); setAccepting(false); } }, [token]); @@ -212,7 +212,7 @@ export default function JoinPage() { window.location.reload(); } catch (err) { console.error('[join] sign-out error:', err); - toast.error('Could not sign out. Try refreshing the page.'); + toast.error('No se pudo cerrar sesión. Intenta actualizar la página.'); setSigningOut(false); } }, []); @@ -223,7 +223,7 @@ export default function JoinPage() { -

Verifying invitation…

+

Verificando invitación…

); @@ -257,14 +257,14 @@ export default function JoinPage() { onClick={loadPeekAndAuth} className="w-full bg-primary text-primary-foreground hover:bg-primary/90" > - Try again + Intentar de nuevo @@ -272,7 +272,7 @@ export default function JoinPage() { <> @@ -280,7 +280,7 @@ export default function JoinPage() { variant="outline" className="w-full border-border text-muted-foreground hover:bg-muted hover:text-foreground" > - Sign in + Iniciar sesión @@ -297,16 +297,16 @@ export default function JoinPage() {
- You're invited to{' '} + Estás invitado a{' '} {peek.account_name} - You'll join as{' '} + Te unirás como{' '} {ROLE_LABEL[peek.role]} - . Link valid until{' '} + . Enlace válido hasta el{' '} {new Date(peek.expires_at).toLocaleDateString(undefined, { year: 'numeric', month: 'short', @@ -332,19 +332,19 @@ export default function JoinPage() { {accepting ? ( <> - Accepting… + Aceptando… ) : ( <> - Accept invitation + Aceptar invitación )}

- Accepting moves your login into{' '} - {peek.account_name}. Your - empty personal account from signup will be cleaned up. + Al aceptar, tu cuenta pasará a formar parte de{' '} + {peek.account_name}. Tu + cuenta personal vacía creada al registrarte será eliminada.

@@ -363,7 +363,7 @@ export default function JoinPage() { - Can't join {peek.account_name} with this account + No puedes unirte a {peek.account_name} con esta cuenta {conflictMessage} @@ -371,11 +371,11 @@ export default function JoinPage() {

- To join{' '} + Para unirte a{' '} {peek.account_name}, - sign out and sign up again with a different email address. - The invite link stays valid as long as it hasn't - expired. + cierra sesión y regístrate de nuevo con un correo diferente. + El enlace de invitación seguirá siendo válido mientras no + haya expirado.

@@ -384,7 +384,7 @@ export default function JoinPage() { onClick={() => setConflictMessage(null)} className="border-border text-popover-foreground hover:bg-muted" > - Stay signed in + Mantener sesión iniciada @@ -414,7 +414,7 @@ export default function JoinPage() { @@ -422,7 +422,7 @@ export default function JoinPage() { variant="outline" className="w-full border-border text-muted-foreground hover:bg-muted hover:text-foreground" > - I already have an account + Ya tengo una cuenta diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 4a8c05a344..af4ad8a6ae 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -25,7 +25,7 @@ export const metadata: Metadata = { default: "wacrm", template: "%s — wacrm", }, - description: "Self-hostable CRM template for WhatsApp.", + description: "Plantilla de CRM autoalojable para WhatsApp.", robots: { index: false, follow: false, diff --git a/src/components/agents/ai-playground.tsx b/src/components/agents/ai-playground.tsx index 6033cc26c5..69ab44c3ce 100644 --- a/src/components/agents/ai-playground.tsx +++ b/src/components/agents/ai-playground.tsx @@ -43,9 +43,9 @@ export function AiPlayground({ onGoToSetup }: { onGoToSetup?: () => void }) { const data = await res.json().catch(() => ({})); if (!res.ok) { if (data.code === 'ai_not_configured') { - toast.error('No agent configured yet — finish Setup first.'); + toast.error('Aún no hay un agente configurado — primero completa la Configuración.'); } else { - toast.error(data.error ?? "Couldn't get a reply."); + toast.error(data.error ?? 'No se pudo obtener una respuesta.'); } // Roll the unsent user turn back so the transcript stays clean. setTurns(turns); @@ -64,7 +64,7 @@ export function AiPlayground({ onGoToSetup }: { onGoToSetup?: () => void }) { }, ]); } catch { - toast.error("Couldn't reach the agent."); + toast.error('No se pudo contactar al agente.'); setTurns(turns); setInput(text); } finally { @@ -85,9 +85,9 @@ export function AiPlayground({ onGoToSetup }: { onGoToSetup?: () => void }) {
- Playground + Zona de pruebas - — test replies as if you were a customer + — prueba las respuestas como si fueras un cliente
@@ -106,10 +106,10 @@ export function AiPlayground({ onGoToSetup }: { onGoToSetup?: () => void }) { {turns.length === 0 && (
-

Send a message to see how your agent would reply.

+

Envía un mensaje para ver cómo respondería tu agente.

- It uses your knowledge base and behaves exactly like the - auto-reply bot — including handoff. + Usa tu base de conocimientos y se comporta igual que el bot de + respuesta automática, incluida la derivación a un humano.

{onGoToSetup && ( )}
@@ -152,7 +152,7 @@ export function AiPlayground({ onGoToSetup }: { onGoToSetup?: () => void }) { )} > - Would hand off to a human here + Aquí derivaría a un humano

)}
@@ -165,7 +165,7 @@ export function AiPlayground({ onGoToSetup }: { onGoToSetup?: () => void }) { {sending && (
- Thinking… + Pensando…
)}
@@ -176,7 +176,7 @@ export function AiPlayground({ onGoToSetup }: { onGoToSetup?: () => void }) { value={input} onChange={(e) => setInput(e.target.value)} onKeyDown={handleKeyDown} - placeholder="Type a customer message…" + placeholder="Escribe un mensaje de cliente…" rows={1} className="flex-1 resize-none rounded-xl border border-border bg-muted px-4 py-2.5 text-sm text-foreground placeholder-muted-foreground outline-none focus:border-primary/50" /> diff --git a/src/components/agents/ai-usage.tsx b/src/components/agents/ai-usage.tsx index d24f16fc34..fb0f026001 100644 --- a/src/components/agents/ai-usage.tsx +++ b/src/components/agents/ai-usage.tsx @@ -70,13 +70,13 @@ export function AiUsageCard() { }); const json = await res.json().catch(() => null); if (!res.ok) { - toast.error(json?.error ?? 'Failed to load usage'); + toast.error(json?.error ?? 'No se pudo cargar el uso'); setData(null); return; } setData(json as UsageResponse); } catch { - toast.error('Failed to load usage'); + toast.error('No se pudo cargar el uso'); setData(null); } finally { setLoading(false); @@ -105,11 +105,12 @@ export function AiUsageCard() {
- Token usage + Uso de tokens - Tokens spent on your provider key by drafts and the auto-reply - bot. Counts only — no message content is stored here. + Tokens consumidos con tu clave de proveedor por los borradores y + el bot de respuesta automática. Solo se cuentan tokens — aquí no + se guarda el contenido de los mensajes.
onConfigChange({ ...config, schedule: e.target.value }) @@ -1112,7 +1112,7 @@ function StepRenderer({
- {isCondition ? "Condition" : step.step_type === "wait" ? "Wait" : "Action"} + {isCondition ? "Condición" : step.step_type === "wait" ? "Espera" : "Acción"}
{t(`steps.${meta.label}`)}
{previewFor(step)}
@@ -1133,7 +1133,7 @@ function StepRenderer({ variant="ghost" size="icon" disabled={index === 0} - aria-label="Move up" + aria-label="Mover arriba" onClick={() => props.moveStepAt(path, -1)} > @@ -1142,7 +1142,7 @@ function StepRenderer({ variant="ghost" size="icon" disabled={index === total - 1} - aria-label="Move down" + aria-label="Mover abajo" onClick={() => props.moveStepAt(path, 1)} > @@ -1451,7 +1451,7 @@ function StepEditor({ /> {(cfg.subject === "contact_field" || cfg.subject === "message_content") && ( - + set({ value: e.target.value })} @@ -1509,18 +1509,18 @@ function FieldBlock({ function previewFor(step: BuilderStep): string { switch (step.step_type) { case "send_message": - return (step.step_config.text as string) || "no text yet" + return (step.step_config.text as string) || "sin texto todavía" case "send_buttons": case "send_list": - return interactivePayloadPreviewText(asInteractive(step.step_config)) || "no body yet" + return interactivePayloadPreviewText(asInteractive(step.step_config)) || "sin cuerpo todavía" case "send_template": - return (step.step_config.template_name as string) || "pick a template" + return (step.step_config.template_name as string) || "elige una plantilla" case "wait": return `${step.step_config.amount ?? "?"} ${step.step_config.unit ?? ""}` case "condition": - return `when ${step.step_config.subject ?? "?"}` + return `cuando ${step.step_config.subject ?? "?"}` case "send_webhook": - return (step.step_config.url as string) || "no url" + return (step.step_config.url as string) || "sin URL" default: return "" } diff --git a/src/components/broadcasts/step2-select-audience.tsx b/src/components/broadcasts/step2-select-audience.tsx index b832903a63..490f7f7aa7 100644 --- a/src/components/broadcasts/step2-select-audience.tsx +++ b/src/components/broadcasts/step2-select-audience.tsx @@ -429,11 +429,11 @@ export function Step2SelectAudience({ {/* Audience Summary */}
-

Audience Summary

+

Resumen de la audiencia

{loadingCount ? (
- Calculating… + Calculando…
) : estimatedCount !== null ? (
@@ -441,11 +441,11 @@ export function Step2SelectAudience({ {estimatedCount.toLocaleString()} - estimated recipients + destinatarios estimados
) : (

- Select an audience type to see the estimate. + Selecciona un tipo de audiencia para ver la estimación.

)}
diff --git a/src/components/broadcasts/step3-personalize.tsx b/src/components/broadcasts/step3-personalize.tsx index 990aeacdf3..04e2db8ca2 100644 --- a/src/components/broadcasts/step3-personalize.tsx +++ b/src/components/broadcasts/step3-personalize.tsx @@ -248,7 +248,7 @@ export function Step3Personalize({

{t('personalize.headerImage')}

- {mediaHeaderType} + {mediaHeaderType === 'image' ? 'imagen' : mediaHeaderType === 'video' ? 'video' : 'documento'}
@@ -344,7 +344,7 @@ export function Step3Personalize({ onChange={(e) => updateVariable(key, { value: e.target.value }) } - placeholder="Enter value..." + placeholder="Ingresa un valor..." className="border-border bg-muted text-foreground placeholder:text-muted-foreground" /> ) : mapping.type === 'field' ? ( @@ -376,10 +376,10 @@ export function Step3Personalize({ @@ -422,11 +422,11 @@ export function Step3Personalize({ {unmappedKeys.length > 0 && (
- Map every placeholder before continuing — still missing{' '} + Asigna un valor a cada marcador antes de continuar — aún falta{' '} {unmappedKeys.join(', ')} - . Otherwise those placeholders will ship to Meta as empty strings. + . De lo contrario, esos marcadores se enviarán a Meta como cadenas vacías.
)} diff --git a/src/components/broadcasts/step4-schedule-send.tsx b/src/components/broadcasts/step4-schedule-send.tsx index c4f8e68e39..40fb96f575 100644 --- a/src/components/broadcasts/step4-schedule-send.tsx +++ b/src/components/broadcasts/step4-schedule-send.tsx @@ -125,7 +125,7 @@ export function Step4ScheduleSend({

{audienceLabel}

-

Estimated Reach

+

Alcance estimado

{loadingReach ? ( @@ -138,7 +138,7 @@ export function Step4ScheduleSend({
-

Language

+

Idioma

{template.language ?? 'en_US'}

@@ -201,13 +201,13 @@ export function Step4ScheduleSend({ - Confirm Broadcast + Confirmar difusión - You are about to send this broadcast to{' '} + Estás a punto de enviar esta difusión a{' '} {estimatedReach.toLocaleString()}{' '} - contacts using the{' '} - {template.name} template. - This action cannot be undone. + contactos usando la plantilla{' '} + {template.name}. + Esta acción no se puede deshacer. diff --git a/src/components/contacts/contact-detail-view.tsx b/src/components/contacts/contact-detail-view.tsx index 5d50424de4..6139bae4f2 100644 --- a/src/components/contacts/contact-detail-view.tsx +++ b/src/components/contacts/contact-detail-view.tsx @@ -365,8 +365,8 @@ export function ContactDetailView({ toast.success(t('toastTemplateSent', { name: template.name })); } catch (err) { - const reason = err instanceof Error ? err.message : 'network error'; - toast.error(`Failed to send template: ${reason}`); + const reason = err instanceof Error ? err.message : 'error de red'; + toast.error(`Error al enviar la plantilla: ${reason}`); } finally { setSendingTemplate(false); } @@ -741,7 +741,7 @@ export function ContactDetailView({ : 'text-red-400' } > - {deal.status} + {deal.status === 'won' ? 'Ganado' : 'Perdido'} )} diff --git a/src/components/contacts/contact-form.tsx b/src/components/contacts/contact-form.tsx index 8c287f85d4..2c55e1c6bb 100644 --- a/src/components/contacts/contact-form.tsx +++ b/src/components/contacts/contact-form.tsx @@ -143,8 +143,8 @@ export function ContactForm({ data: { session }, } = await supabase.auth.getSession(); const user = session?.user; - if (!user) throw new Error('Not authenticated'); - if (!accountId) throw new Error('Your profile is not linked to an account.'); + if (!user) throw new Error('No autenticado'); + if (!accountId) throw new Error('Tu perfil no está vinculado a ninguna cuenta.'); let contactId = contact?.id; diff --git a/src/components/contacts/import-modal.tsx b/src/components/contacts/import-modal.tsx index 0cce8b4ee6..31a18f8551 100644 --- a/src/components/contacts/import-modal.tsx +++ b/src/components/contacts/import-modal.tsx @@ -214,9 +214,9 @@ export function ImportModal({ data: { session }, } = await supabase.auth.getSession(); const user = session?.user; - if (!user) throw new Error('Not authenticated'); + if (!user) throw new Error('No autenticado'); if (!accountId) - throw new Error('Your profile is not linked to an account.'); + throw new Error('Tu perfil no está vinculado a ninguna cuenta.'); let imported = 0; let skipped = 0; diff --git a/src/components/dashboard/response-time-chart.tsx b/src/components/dashboard/response-time-chart.tsx index a7d2a4c7db..8aa489f144 100644 --- a/src/components/dashboard/response-time-chart.tsx +++ b/src/components/dashboard/response-time-chart.tsx @@ -24,8 +24,8 @@ import { useTranslations } from 'next-intl' // Single category, single colour — the data is "average minutes // per weekday". Tremor expects categories as the second tuple in // the row object, so we shape the buckets into -// `{ day: 'Mon', 'Avg minutes': 4.2 }` rows below. -const CATEGORY = 'Avg minutes' +// `{ day: 'Mon', 'Minutos promedio': 4.2 }` rows below. +const CATEGORY = 'Minutos promedio' export function ResponseTimeChart({ data, diff --git a/src/components/flows/flow-editor-shell.tsx b/src/components/flows/flow-editor-shell.tsx index 384417dce1..99373b3e74 100644 --- a/src/components/flows/flow-editor-shell.tsx +++ b/src/components/flows/flow-editor-shell.tsx @@ -106,7 +106,7 @@ export function FlowEditorShell({ initialFlow, initialNodes }: Props) {
{ case "send_buttons": return { text: "", - buttons: [{ reply_id: "yes", title: "Yes", next_node_key: "" }], + buttons: [{ reply_id: "yes", title: "Sí", next_node_key: "" }], }; case "send_list": return { text: "", - button_label: "View options", + button_label: "Ver opciones", sections: [ { title: "", rows: [ - { reply_id: "row_1", title: "Option 1", next_node_key: "" }, + { reply_id: "row_1", title: "Opción 1", next_node_key: "" }, ], }, ], @@ -346,12 +346,12 @@ export function FlowEditorProvider({ }); if (!res.ok) { const json = await res.json().catch(() => ({})); - throw new Error(json.error ?? `Save failed: ${res.status}`); + throw new Error(json.error ?? `Error al guardar: ${res.status}`); } setDirty(false); toast.success(t("saved")); } catch (err) { - const msg = err instanceof Error ? err.message : "Save failed"; + const msg = err instanceof Error ? err.message : "Error al guardar"; toast.error(msg); } finally { setSaving(false); @@ -380,7 +380,7 @@ export function FlowEditorProvider({ }); if (!res.ok) { const json = await res.json().catch(() => ({})); - throw new Error(json.error ?? `Status update failed: ${res.status}`); + throw new Error(json.error ?? `Error al actualizar el estado: ${res.status}`); } setStateRaw((s) => ({ ...s, status: next })); toast.success( @@ -391,7 +391,7 @@ export function FlowEditorProvider({ : t("statusDraft") ); } catch (err) { - const msg = err instanceof Error ? err.message : "Status update failed"; + const msg = err instanceof Error ? err.message : "Error al actualizar el estado"; toast.error(msg); } finally { setActivating(false); @@ -403,17 +403,17 @@ export function FlowEditorProvider({ // ---- Delete ---- const deleteFlow = useCallback(async () => { const yes = window.confirm( - `Delete "${state.name}"? Any active runs end immediately. This can't be undone.`, + `¿Eliminar "${state.name}"? Las ejecuciones activas terminarán de inmediato. Esta acción no se puede deshacer.`, ); if (!yes) return; try { const res = await fetch(`/api/flows/${initialFlow.id}`, { method: "DELETE", }); - if (!res.ok) throw new Error(`Delete failed: ${res.status}`); + if (!res.ok) throw new Error(`Error al eliminar: ${res.status}`); router.push("/flows"); } catch (err) { - const msg = err instanceof Error ? err.message : "Delete failed"; + const msg = err instanceof Error ? err.message : "Error al eliminar"; toast.error(msg); } }, [initialFlow.id, router, state.name]); diff --git a/src/components/flows/forms/node-config-form.tsx b/src/components/flows/forms/node-config-form.tsx index 7ae1db66e1..6dc30f40a7 100644 --- a/src/components/flows/forms/node-config-form.tsx +++ b/src/components/flows/forms/node-config-form.tsx @@ -254,7 +254,7 @@ function SendButtonsForm({ ...buttons, { reply_id: `btn_${buttons.length + 1}`, - title: "Option", + title: "Opción", next_node_key: "", }, ], @@ -401,7 +401,7 @@ function SendListForm({ rows: [ { reply_id: `row_${totalRows + 1}`, - title: `Option ${totalRows + 1}`, + title: `Opción ${totalRows + 1}`, next_node_key: "", }, ], @@ -456,7 +456,7 @@ function SendListForm({ return ( <> onUpdateConfig({ text: v })} rows={3} @@ -498,7 +498,7 @@ function SendListForm({ size="sm" onClick={() => removeSection(sIdx)} className="shrink-0 text-red-400 hover:bg-red-500/10 hover:text-red-300" - aria-label="Remove section" + aria-label="Eliminar sección" > @@ -657,7 +657,7 @@ function ConditionForm({ onValueChange={(v) => onUpdateConfig({ subject_key: v })} > - + {tags.map((t) => ( @@ -805,7 +805,7 @@ function SetTagForm({ onValueChange={(v) => onUpdateConfig({ tag_id: v })} > - + {tags.map((t) => ( @@ -913,7 +913,7 @@ function SendMediaForm({ async (file: File) => { if (file.size > MEDIA_MAX_BYTES) { toast.error( - `File is ${(file.size / 1024 / 1024).toFixed(1)} MB — limit is 16 MB.`, + `El archivo pesa ${(file.size / 1024 / 1024).toFixed(1)} MB — el límite es 16 MB.`, ); return; } @@ -928,9 +928,9 @@ function SendMediaForm({ media_url: publicUrl, filename: file.name, }); - toast.success("File uploaded."); + toast.success("Archivo subido."); } catch (err) { - const msg = err instanceof Error ? err.message : "Upload failed."; + const msg = err instanceof Error ? err.message : "Error al subir el archivo."; toast.error(msg); } finally { setUploading(false); diff --git a/src/components/flows/header.tsx b/src/components/flows/header.tsx index 8d7df821c9..6f6f60bc1a 100644 --- a/src/components/flows/header.tsx +++ b/src/components/flows/header.tsx @@ -64,8 +64,8 @@ export function EditorHeader() { {state.status === "active" ? ( ) : ( )}
@@ -166,8 +166,8 @@ export function EditorHeader() { onChange={(e) => setState((s) => ({ ...s, description: e.target.value })) } - placeholder="Add a short description (internal — customers don't see this)" - aria-label="Flow description" + placeholder="Agrega una breve descripción (interna — los clientes no la ven)" + aria-label="Descripción del flujo" className="w-full max-w-[78ch] rounded-md border border-transparent bg-transparent px-2 py-1 text-[13px] text-muted-foreground outline-none transition-colors placeholder:text-muted-foreground/60 hover:bg-muted/50 focus:border-primary focus:bg-transparent focus:text-foreground" /> @@ -180,15 +180,15 @@ function StatusChip({ status }: { status: BuilderState["status"] }) { // Neutral, not amber — amber is reserved for the adjacent // "Edited" dirty signal, so the two don't read as the same alert. cls: "border-border bg-muted text-muted-foreground", - label: "Draft", + label: "Borrador", }, active: { cls: "border-emerald-600/40 bg-emerald-500/10 text-emerald-300", - label: "Active", + label: "Activo", }, archived: { cls: "border-border bg-muted/50 text-muted-foreground", - label: "Archived", + label: "Archivado", }, }[status]; return ( diff --git a/src/components/inbox/message-bubble.tsx b/src/components/inbox/message-bubble.tsx index 2332182420..77b21a4886 100644 --- a/src/components/inbox/message-bubble.tsx +++ b/src/components/inbox/message-bubble.tsx @@ -132,7 +132,7 @@ function MessageContent({ message, t }: { message: Message, t: ReturnType {message.media_url ? ( - + ) : ( )} diff --git a/src/components/inbox/message-composer.tsx b/src/components/inbox/message-composer.tsx index 7c13b2102d..2b9ac3cdff 100644 --- a/src/components/inbox/message-composer.tsx +++ b/src/components/inbox/message-composer.tsx @@ -69,6 +69,14 @@ export const MEDIA_CAPTION_MAX = 1024; * transcode limits — auto-stops the recorder when reached. */ const MAX_RECORDING_SECONDS = 5 * 60; +/** Spanish labels for the media-size-limit toast, keyed by ComposerMediaKind. */ +const KIND_LABEL_ES: Record = { + image: "la imagen", + video: "el video", + document: "el documento", + audio: "el audio", +}; + export interface SendMediaPayload { kind: ComposerMediaKind; /** Public chat-media URL Meta fetches at send time. */ @@ -269,15 +277,15 @@ export function MessageComposer({ const data = await res.json().catch(() => ({})); if (!res.ok) { if (data.code === "ai_not_configured") { - toast.error("AI isn't set up yet — enable it in Settings → AI Assistant."); + toast.error("La IA aún no está configurada — actívala en Configuración → Asistente de IA."); } else { - toast.error(data.error ?? "Couldn't draft a reply."); + toast.error(data.error ?? "No se pudo generar una respuesta."); } return; } const draftText = typeof data.draft === "string" ? data.draft.trim() : ""; if (!draftText) { - toast.error("The assistant didn't return a reply."); + toast.error("El asistente no devolvió ninguna respuesta."); return; } setText(draftText); @@ -292,7 +300,7 @@ export function MessageComposer({ } }); } catch { - toast.error("Couldn't reach the AI assistant."); + toast.error("No se pudo conectar con el asistente de IA."); } finally { setDrafting(false); } @@ -390,7 +398,7 @@ export function MessageComposer({ const max = MEDIA_MAX_BYTES_BY_KIND[kind]; if (file.size > max) { toast.error( - `File is ${(file.size / 1024 / 1024).toFixed(1)} MB — ${kind} limit is ${Math.round( + `El archivo pesa ${(file.size / 1024 / 1024).toFixed(1)} MB — el límite para ${KIND_LABEL_ES[kind]} es ${Math.round( max / 1024 / 1024, )} MB.`, ); @@ -403,7 +411,7 @@ export function MessageComposer({ removeStaged(draftRef.current?.path); setDraft({ kind, mediaUrl: publicUrl, path, filename: file.name, caption: "" }); } catch (err) { - toast.error(err instanceof Error ? err.message : "Upload failed."); + toast.error(err instanceof Error ? err.message : "Error al subir el archivo."); } finally { setBusy(false); } @@ -431,7 +439,7 @@ export function MessageComposer({ }); if (file.size === 0) return; // cancelled / empty take if (file.size > MEDIA_MAX_BYTES_BY_KIND.audio) { - toast.error("Recording is too long (over 16 MB)."); + toast.error("La grabación es demasiado larga (supera los 16 MB)."); return; } setBusy(true); @@ -440,7 +448,7 @@ export function MessageComposer({ removeStaged(draftRef.current?.path); setDraft({ kind: "audio", mediaUrl: publicUrl, path, filename: file.name, caption: "" }); } catch (err) { - toast.error(err instanceof Error ? err.message : "Upload failed."); + toast.error(err instanceof Error ? err.message : "Error al subir el archivo."); } finally { setBusy(false); } @@ -451,7 +459,7 @@ export function MessageComposer({ const startRecording = useCallback(async () => { if (inputsDisabled || busy || recording) return; if (!navigator.mediaDevices?.getUserMedia || typeof AudioContext === "undefined") { - toast.error("Voice recording isn't supported in this browser."); + toast.error("La grabación de voz no es compatible con este navegador."); return; } try { @@ -478,7 +486,7 @@ export function MessageComposer({ } catch { void recorderRef.current?.stop().catch(() => {}); recorderRef.current = null; - toast.error("Microphone access denied or unavailable."); + toast.error("Acceso al micrófono denegado o no disponible."); } }, [inputsDisabled, busy, recording, finalizeRecording]); @@ -701,7 +709,7 @@ export function MessageComposer({ variant="ghost" size="sm" canAct={!readOnly} - gateReason="send messages" + gateReason="enviar mensajes" title={readOnly ? undefined : t("sendTemplate")} className="h-9 w-9 shrink-0 p-0 text-muted-foreground hover:text-foreground" onClick={onOpenTemplates} @@ -713,7 +721,7 @@ export function MessageComposer({ variant="ghost" size="sm" canAct={!readOnly} - gateReason="send messages" + gateReason="enviar mensajes" disabled={drafting} title={readOnly ? undefined : t("draftWithAI")} className="h-9 w-9 shrink-0 p-0 text-muted-foreground hover:text-primary" @@ -753,7 +761,7 @@ export function MessageComposer({ m.sender_type === "customer"); - if (!lastCustomerMsg) return { expired: true, remaining: "No customer messages" }; + if (!lastCustomerMsg) return { expired: true, remaining: "Sin mensajes del cliente" }; const hoursSince = differenceInHours(new Date(), new Date(lastCustomerMsg.created_at)); const expired = hoursSince >= 24; @@ -482,7 +482,7 @@ export function MessageThread({ if (!res.ok) { const reason = payload?.error || `HTTP ${res.status}`; console.error("Failed to send message:", reason); - toast.error(`Failed to send: ${reason}`); + toast.error(`Error al enviar: ${reason}`); // Mark the optimistic bubble as failed so the user sees what happened onUpdateMessage(tempId, { status: "failed" }); return; @@ -494,8 +494,8 @@ export function MessageThread({ onUpdateMessage(tempId, { status: "sent" }); } catch (err) { console.error("Failed to send message:", err); - const reason = err instanceof Error ? err.message : "network error"; - toast.error(`Failed to send: ${reason}`); + const reason = err instanceof Error ? err.message : "error de red"; + toast.error(`Error al enviar: ${reason}`); onUpdateMessage(tempId, { status: "failed" }); } }, @@ -511,7 +511,7 @@ export function MessageThread({ // kinds use the caption as-is. Audio carries no caption. const contentText = payload.kind === "document" - ? payload.caption || payload.filename || "Document" + ? payload.caption || payload.filename || "Documento" : payload.caption; const tempId = `temp-${Date.now()}`; @@ -548,7 +548,7 @@ export function MessageThread({ if (!res.ok) { const reason = data?.error || `HTTP ${res.status}`; console.error("Failed to send media:", reason); - toast.error(`Failed to send: ${reason}`); + toast.error(`Error al enviar: ${reason}`); onUpdateMessage(tempId, { status: "failed" }); // The upload never reached the recipient — GC the orphaned // object rather than leaving it in the public bucket forever. @@ -559,8 +559,8 @@ export function MessageThread({ onUpdateMessage(tempId, { status: "sent" }); } catch (err) { console.error("Failed to send media:", err); - const reason = err instanceof Error ? err.message : "network error"; - toast.error(`Failed to send: ${reason}`); + const reason = err instanceof Error ? err.message : "error de red"; + toast.error(`Error al enviar: ${reason}`); onUpdateMessage(tempId, { status: "failed" }); void deleteAccountMedia(CHAT_MEDIA_BUCKET, payload.path).catch(() => {}); } @@ -605,7 +605,7 @@ export function MessageThread({ if (!res.ok) { const reason = data?.error || `HTTP ${res.status}`; console.error("Failed to send interactive message:", reason); - toast.error(`Failed to send: ${reason}`); + toast.error(`Error al enviar: ${reason}`); onUpdateMessage(tempId, { status: "failed" }); return; } @@ -613,8 +613,8 @@ export function MessageThread({ onUpdateMessage(tempId, { status: "sent" }); } catch (err) { console.error("Failed to send interactive message:", err); - const reason = err instanceof Error ? err.message : "network error"; - toast.error(`Failed to send: ${reason}`); + const reason = err instanceof Error ? err.message : "error de red"; + toast.error(`Error al enviar: ${reason}`); onUpdateMessage(tempId, { status: "failed" }); } }, @@ -694,7 +694,7 @@ export function MessageThread({ if (!res.ok) { const reason = payload?.error || `HTTP ${res.status}`; console.error("Failed to send template:", reason); - toast.error(`Failed to send template: ${reason}`); + toast.error(`Error al enviar la plantilla: ${reason}`); onUpdateMessage(tempId, { status: "failed" }); return; } @@ -702,8 +702,8 @@ export function MessageThread({ onUpdateMessage(tempId, { status: "sent" }); } catch (err) { console.error("Failed to send template:", err); - const reason = err instanceof Error ? err.message : "network error"; - toast.error(`Failed to send template: ${reason}`); + const reason = err instanceof Error ? err.message : "error de red"; + toast.error(`Error al enviar la plantilla: ${reason}`); onUpdateMessage(tempId, { status: "failed" }); } }, @@ -729,7 +729,7 @@ export function MessageThread({ return map; }, [reactions]); - const contactDisplayName = contact?.name || contact?.phone || "Customer"; + const contactDisplayName = contact?.name || contact?.phone || "Cliente"; // Author label for a quoted message: "You" when we sent the parent, // contact name when the customer sent it. @@ -737,7 +737,7 @@ export function MessageThread({ (m: Message): string => { const isAgentMsg = m.sender_type === "agent" || m.sender_type === "bot"; - return isAgentMsg ? "You" : contactDisplayName; + return isAgentMsg ? "Tú" : contactDisplayName; }, [contactDisplayName], ); @@ -764,7 +764,7 @@ export function MessageThread({ return; } if (messageId.startsWith("temp-")) { - toast.error("Wait for the message to finish sending"); + toast.error("Espera a que el mensaje termine de enviarse"); return; } @@ -809,8 +809,8 @@ export function MessageThread({ throw new Error(payload?.error || `HTTP ${res.status}`); } } catch (err) { - const reason = err instanceof Error ? err.message : "network error"; - toast.error(`Reaction failed: ${reason}`); + const reason = err instanceof Error ? err.message : "error de red"; + toast.error(`Error en la reacción: ${reason}`); setReactions(snapshot); } }, @@ -829,7 +829,7 @@ export function MessageThread({ if (error) { console.error("Failed to update assignment:", error); - toast.error("Failed to update assignment"); + toast.error("Error al actualizar la asignación"); return; } @@ -1090,8 +1090,8 @@ export function MessageThread({ ? { authorLabel: parent.sender_type === "agent" || parent.sender_type === "bot" - ? t("me") - : contact?.name || contact?.phone || "Unknown", + ? t("me") + : contact?.name || contact?.phone || "Desconocido", preview: buildReplyPreview(parent, tQuote), } : null; diff --git a/src/components/inbox/template-picker.tsx b/src/components/inbox/template-picker.tsx index 4715763b05..821d336e71 100644 --- a/src/components/inbox/template-picker.tsx +++ b/src/components/inbox/template-picker.tsx @@ -264,7 +264,7 @@ export function TemplatePicker({ {slots && slots.headerVarCount > 0 && (
(
- + { @@ -292,7 +292,7 @@ export function TemplatePicker({ {slots?.urlButtonSlots.map((slot) => (
switchKind("buttons")} /> switchKind("list")} />
- +