diff --git a/messages/de.json b/messages/de.json index 378ac8b0e..06d7c9a49 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1957,6 +1957,8 @@ "composerPlaceholder": "Frag mich etwas zu deinen Daten…", "composerHint": "Enter zum Senden, Umschalt+Enter für eine neue Zeile.", "composerDisclaimer": "Coach-Antworten sind generiert. Klinische Entscheidungen gehören in die Hand deines Arztes.", + "selfContextChipsLabel": "Der Coach würde gern wissen:", + "selfContextChipDismiss": "Frage verwerfen", "voiceComingSoon": "Spracheingabe kommt mit der iOS-App in v1.5.", "threadEmpty": "Frag mich etwas zu deinen Trends, Medikamenten oder Messwerten, um zu starten.", "historyTitle": "Unterhaltungen", @@ -3823,7 +3825,18 @@ "clearedToast": "Über-mich-Notiz gelöscht.", "saveError": "Speichern fehlgeschlagen. Bitte versuche es erneut.", "loadError": "Die Notiz konnte nicht geladen werden.", - "hint": "Wird als zusätzlicher Kontext an Coach und Tagesbriefing übergeben. Leer lassen, um nichts mitzusenden." + "hint": "Wird als zusätzlicher Kontext an Coach und Tagesbriefing übergeben. Leer lassen, um nichts mitzusenden.", + "profileHint": "Alter und Geschlecht stammen aus deinem Profil und werden automatisch mitgegeben.", + "freeTextLabel": "Über mich (Freitext)", + "conditionsLabel": "Chronische Erkrankungen", + "conditionsPlaceholder": "Z. B. Bluthochdruck, Typ-2-Diabetes …", + "allergiesLabel": "Allergien & Unverträglichkeiten", + "allergiesPlaceholder": "Z. B. Laktoseintoleranz, Penicillin-Allergie …", + "focusLabel": "Worauf soll der Coach achten?", + "focusPlaceholder": "Z. B. behalte meinen Blutdruck im Blick, motiviere mich zu mehr Bewegung …", + "completeness": "{answered} von {total} beantwortet", + "questionsTitle": "Der Coach hat Rückfragen", + "questionsOpenCoach": "Im Coach beantworten" } }, "dashboard": { @@ -4307,32 +4320,51 @@ "invites": { "title": "Einladungen", "description": "Einladungslinks für die Registrierung — funktionieren auch bei geschlossener Registrierung. Der Link wird nur einmal angezeigt.", - "createLabel": "Neue Einladung:", + "adminOnlyHint": "Nur Admins können Einladungen erstellen und widerrufen.", + "create": "Einladung erstellen", + "createTitle": "Einladung erstellen", + "createDescription": "Der Link erlaubt eine Registrierung auch bei geschlossener Registrierung.", + "ttlLegend": "Gültig für", "createDays": "{days} Tage", + "maxUsesLabel": "Maximale Nutzungen", + "maxUsesHint": "Wie viele Registrierungen dieser Link zulässt (1–50).", + "createConfirm": "Einladung erstellen", "createdToast": "Einladung erstellt.", "createError": "Einladung konnte nicht erstellt werden.", "tokenTitle": "Einladungslink", - "tokenOnceHint": "Wird nur jetzt angezeigt — kopiere den Link oder lass den QR-Code scannen.", + "tokenOnceHint": "Nur jetzt kopierbar — kopiere den Link oder lass den QR-Code scannen.", + "hashExplainer": "Gespeichert wird nur ein Hash des Links — er kann später nicht erneut angezeigt werden. Geht er verloren: Einladung widerrufen und neu erstellen.", "copyLink": "Link kopieren", "copiedToast": "Link kopiert.", "copyError": "Kopieren fehlgeschlagen.", "qrAlt": "QR-Code des Einladungslinks", "loadError": "Einladungen konnten nicht geladen werden.", - "listEmpty": "Keine Einladungen vorhanden.", - "expires": "läuft ab am {date}", - "uses": "{used} / {max} genutzt", - "createdMeta": "Erstellt am {date} von {by}", - "usedBy": "zuletzt genutzt von {username}", + "emptyTitle": "Noch keine Einladungen", + "emptyDescription": "Erstelle einen Einladungslink, um jemanden trotz geschlossener Registrierung aufzunehmen.", + "colStatus": "Status", + "colCreated": "Erstellt", + "colExpires": "Läuft ab", + "colUses": "Nutzungen", + "colRedeemedBy": "Eingelöst von", + "colActions": "Aktionen", + "byCreator": "von {by}", + "expiresInDaysOne": "in 1 Tag", + "expiresInDaysOther": "in {count} Tagen", "statusActive": "Aktiv", "statusExpired": "Abgelaufen", "statusExhausted": "Aufgebraucht", - "deleteAria": "Einladung löschen", - "deleteConfirmTitle": "Einladung löschen?", - "deleteConfirmBody": "Der Einladungslink wird sofort ungültig. Bereits registrierte Konten bleiben bestehen.", - "deleteConfirmAction": "Löschen", + "statusRevoked": "Widerrufen", + "redeemerDeleted": "gelöschtes Konto", + "redeemedByMore": "{username} + {count} weitere", + "redemptionsTitle": "{count} Einlösungen", + "revoke": "Widerrufen", + "revokeAria": "Einladung widerrufen", + "revokeConfirmTitle": "Einladung widerrufen?", + "revokeConfirmBody": "Der Einladungslink wird sofort ungültig. Bereits registrierte Konten bleiben bestehen, die Einlösungs-Historie bleibt sichtbar.", + "revokeConfirmAction": "Widerrufen", "cancel": "Abbrechen", - "deletedToast": "Einladung gelöscht.", - "deleteError": "Einladung konnte nicht gelöscht werden." + "revokedToast": "Einladung widerrufen.", + "revokeError": "Einladung konnte nicht widerrufen werden." }, "userManagement": "Benutzerverwaltung", "userName": "Name", @@ -4552,6 +4584,10 @@ "emptyDescription": "Es gibt keine Nutzer, die zum aktuellen Filter passen.", "emptyResetFilter": "Alle Nutzer anzeigen" }, + "invites": { + "title": "Einladungen", + "subtitle": "Einladungslinks erstellen, nachverfolgen und widerrufen." + }, "api-tokens": { "title": "API-Tokens", "subtitle": "Aktive Bearer-Tokens für native Clients und Integrationen." @@ -5199,7 +5235,13 @@ "bpTitle": "Blutdruck im Wochenmittel erhöht", "bpBody": "Dein Blutdruck lag diese Woche im Schnitt über deinem Ziel. Der Coach kann die Werte mit dir einordnen.", "scoreTitle": "Dein Erholungswert fällt", - "scoreBody": "Dein Erholungswert ist diese Woche deutlich gesunken. Der Coach hilft dir, mögliche Ursachen einzugrenzen." + "scoreBody": "Dein Erholungswert ist diese Woche deutlich gesunken. Der Coach hilft dir, mögliche Ursachen einzugrenzen.", + "selfContextTitle": "Hilf deinem Coach, dich besser zu kennen", + "selfContextBody": "Deine Selbstauskunft ist unvollständig oder länger nicht aktualisiert. Zwei Minuten in den Einstellungen halten den Kontext des Coachs frisch.", + "questionConditions": "Hast du chronische Erkrankungen, die der Coach kennen sollte?", + "questionFocus": "Gibt es etwas Bestimmtes, worauf der Coach achten soll?", + "questionAllergies": "Hast du Allergien oder Unverträglichkeiten?", + "questionAboutMe": "Magst du dem Coach etwas über dich erzählen — Alltag, Ziele, Kontext?" }, "clinicianView": { "title": "Geteilte Gesundheitsakte", diff --git a/messages/en.json b/messages/en.json index f39b413fe..14c8608c5 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1957,6 +1957,8 @@ "composerPlaceholder": "Ask anything about your data…", "composerHint": "Press Enter to send, Shift+Enter for a new line.", "composerDisclaimer": "Coach replies are generated. Clinical decisions belong with your doctor.", + "selfContextChipsLabel": "The Coach would like to know:", + "selfContextChipDismiss": "Dismiss question", "voiceComingSoon": "Voice input arrives with the iOS app in v1.5.", "threadEmpty": "Ask anything about your trends, medications, or readings to start a conversation.", "historyTitle": "Conversations", @@ -3823,7 +3825,18 @@ "clearedToast": "About-me note cleared.", "saveError": "Could not save. Please try again.", "loadError": "The note could not be loaded.", - "hint": "Passed to the Coach and the daily briefing as additional context. Leave empty to send nothing." + "hint": "Passed to the Coach and the daily briefing as additional context. Leave empty to send nothing.", + "profileHint": "Age and gender come from your profile and are included automatically.", + "freeTextLabel": "About me (free text)", + "conditionsLabel": "Chronic conditions", + "conditionsPlaceholder": "E.g. hypertension, type 2 diabetes…", + "allergiesLabel": "Allergies & intolerances", + "allergiesPlaceholder": "E.g. lactose intolerance, penicillin allergy…", + "focusLabel": "What should the Coach watch?", + "focusPlaceholder": "E.g. keep an eye on my blood pressure, motivate me to move more…", + "completeness": "{answered} of {total} answered", + "questionsTitle": "The Coach has follow-up questions", + "questionsOpenCoach": "Answer them in the Coach" } }, "dashboard": { @@ -4307,32 +4320,51 @@ "invites": { "title": "Invites", "description": "Registration invite links — they work even while registration is closed. The link is shown only once.", - "createLabel": "New invite:", + "adminOnlyHint": "Only admins can create and revoke invites.", + "create": "Create invite", + "createTitle": "Create invite", + "createDescription": "The link admits a signup even while registration is closed.", + "ttlLegend": "Valid for", "createDays": "{days} days", + "maxUsesLabel": "Maximum uses", + "maxUsesHint": "How many signups this link admits (1–50).", + "createConfirm": "Create invite", "createdToast": "Invite created.", "createError": "Could not create the invite.", "tokenTitle": "Invite link", - "tokenOnceHint": "Shown only now — copy the link or have the QR code scanned.", + "tokenOnceHint": "Copyable only now — copy the link or have the QR code scanned.", + "hashExplainer": "Only a hash of the link is stored, so it cannot be shown again later. If it gets lost, revoke the invite and create a new one.", "copyLink": "Copy link", "copiedToast": "Link copied.", "copyError": "Copy failed.", "qrAlt": "QR code for the invite link", "loadError": "Could not load the invites.", - "listEmpty": "No invites yet.", - "expires": "expires {date}", - "uses": "{used} / {max} used", - "createdMeta": "Created {date} by {by}", - "usedBy": "last used by {username}", + "emptyTitle": "No invites yet", + "emptyDescription": "Create an invite link to admit someone while registration is closed.", + "colStatus": "Status", + "colCreated": "Created", + "colExpires": "Expires", + "colUses": "Uses", + "colRedeemedBy": "Redeemed by", + "colActions": "Actions", + "byCreator": "by {by}", + "expiresInDaysOne": "in 1 day", + "expiresInDaysOther": "in {count} days", "statusActive": "Active", "statusExpired": "Expired", "statusExhausted": "Used up", - "deleteAria": "Delete invite", - "deleteConfirmTitle": "Delete this invite?", - "deleteConfirmBody": "The invite link becomes invalid immediately. Accounts already registered through it remain.", - "deleteConfirmAction": "Delete", + "statusRevoked": "Revoked", + "redeemerDeleted": "deleted account", + "redeemedByMore": "{username} + {count} more", + "redemptionsTitle": "{count} redemptions", + "revoke": "Revoke", + "revokeAria": "Revoke invite", + "revokeConfirmTitle": "Revoke this invite?", + "revokeConfirmBody": "The invite link becomes invalid immediately. Accounts already registered through it remain, and the redemption history stays visible.", + "revokeConfirmAction": "Revoke", "cancel": "Cancel", - "deletedToast": "Invite deleted.", - "deleteError": "Could not delete the invite." + "revokedToast": "Invite revoked.", + "revokeError": "Could not revoke the invite." }, "userManagement": "User Management", "userName": "Name", @@ -4552,6 +4584,10 @@ "emptyDescription": "There are no users matching the current filter.", "emptyResetFilter": "Show all users" }, + "invites": { + "title": "Invites", + "subtitle": "Mint, track, and revoke registration invite links." + }, "api-tokens": { "title": "API Tokens", "subtitle": "Active bearer tokens issued to native clients and integrations." @@ -5199,7 +5235,13 @@ "bpTitle": "Blood pressure elevated this week", "bpBody": "Your blood pressure averaged above your target this week. The Coach can help you put the readings in context.", "scoreTitle": "Your recovery score is dropping", - "scoreBody": "Your recovery score fell noticeably this week. The Coach can help you narrow down possible causes." + "scoreBody": "Your recovery score fell noticeably this week. The Coach can help you narrow down possible causes.", + "selfContextTitle": "Help your Coach know you better", + "selfContextBody": "Your self-description is incomplete or has not been updated in a while. Two minutes in the settings keep the Coach's context fresh.", + "questionConditions": "Do you have any chronic conditions the Coach should know about?", + "questionFocus": "Is there anything specific the Coach should pay attention to?", + "questionAllergies": "Do you have any allergies or intolerances?", + "questionAboutMe": "Want to tell the Coach a bit about yourself — routines, goals, context?" }, "clinicianView": { "title": "Shared health record", diff --git a/messages/es.json b/messages/es.json index 6ed2968aa..d3caeac73 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1957,6 +1957,8 @@ "composerPlaceholder": "Pregúntame sobre tus datos…", "composerHint": "Intro para enviar, Mayús+Intro para una nueva línea.", "composerDisclaimer": "Las respuestas son generadas. Las decisiones clínicas las toma tu médico.", + "selfContextChipsLabel": "Al Coach le gustaría saber:", + "selfContextChipDismiss": "Descartar pregunta", "voiceComingSoon": "La entrada por voz llega con la app de iOS en la v1.5.", "threadEmpty": "Pregúntame algo sobre tus tendencias, medicamentos o mediciones para empezar.", "historyTitle": "Conversaciones", @@ -3823,7 +3825,18 @@ "clearedToast": "Nota «Sobre mí» eliminada.", "saveError": "No se pudo guardar. Inténtalo de nuevo.", "loadError": "No se pudo cargar la nota.", - "hint": "Se envía al Coach y al informe diario como contexto adicional. Déjalo vacío para no enviar nada." + "hint": "Se envía al Coach y al informe diario como contexto adicional. Déjalo vacío para no enviar nada.", + "profileHint": "La edad y el género provienen de tu perfil y se incluyen automáticamente.", + "freeTextLabel": "Sobre mí (texto libre)", + "conditionsLabel": "Enfermedades crónicas", + "conditionsPlaceholder": "P. ej. hipertensión, diabetes tipo 2…", + "allergiesLabel": "Alergias e intolerancias", + "allergiesPlaceholder": "P. ej. intolerancia a la lactosa, alergia a la penicilina…", + "focusLabel": "¿En qué debería fijarse el Coach?", + "focusPlaceholder": "P. ej. vigila mi tensión arterial, motívame a moverme más…", + "completeness": "{answered} de {total} respondidas", + "questionsTitle": "El Coach tiene preguntas", + "questionsOpenCoach": "Responderlas en el Coach" } }, "dashboard": { @@ -4307,32 +4320,51 @@ "invites": { "title": "Invitaciones", "description": "Enlaces de invitación para el registro: funcionan incluso con el registro cerrado. El enlace se muestra solo una vez.", - "createLabel": "Nueva invitación:", + "adminOnlyHint": "Solo los administradores pueden crear y revocar invitaciones.", + "create": "Crear invitación", + "createTitle": "Crear invitación", + "createDescription": "El enlace permite registrarse incluso con el registro cerrado.", + "ttlLegend": "Válida durante", "createDays": "{days} días", + "maxUsesLabel": "Usos máximos", + "maxUsesHint": "Cuántos registros admite este enlace (1–50).", + "createConfirm": "Crear invitación", "createdToast": "Invitación creada.", "createError": "No se pudo crear la invitación.", "tokenTitle": "Enlace de invitación", - "tokenOnceHint": "Se muestra solo ahora: copia el enlace o deja que escaneen el código QR.", + "tokenOnceHint": "Solo se puede copiar ahora: copia el enlace o deja que escaneen el código QR.", + "hashExplainer": "Solo se guarda un hash del enlace, por lo que no se puede volver a mostrar. Si se pierde, revoca la invitación y crea una nueva.", "copyLink": "Copiar enlace", "copiedToast": "Enlace copiado.", "copyError": "No se pudo copiar.", "qrAlt": "Código QR del enlace de invitación", "loadError": "No se pudieron cargar las invitaciones.", - "listEmpty": "Todavía no hay invitaciones.", - "expires": "caduca el {date}", - "uses": "{used} / {max} usados", - "createdMeta": "Creada el {date} por {by}", - "usedBy": "usada por última vez por {username}", + "emptyTitle": "Todavía no hay invitaciones", + "emptyDescription": "Crea un enlace de invitación para admitir a alguien con el registro cerrado.", + "colStatus": "Estado", + "colCreated": "Creada", + "colExpires": "Caduca", + "colUses": "Usos", + "colRedeemedBy": "Canjeada por", + "colActions": "Acciones", + "byCreator": "por {by}", + "expiresInDaysOne": "en 1 día", + "expiresInDaysOther": "en {count} días", "statusActive": "Activa", "statusExpired": "Caducada", "statusExhausted": "Agotada", - "deleteAria": "Eliminar invitación", - "deleteConfirmTitle": "¿Eliminar esta invitación?", - "deleteConfirmBody": "El enlace de invitación dejará de ser válido de inmediato. Las cuentas ya registradas con él se mantienen.", - "deleteConfirmAction": "Eliminar", + "statusRevoked": "Revocada", + "redeemerDeleted": "cuenta eliminada", + "redeemedByMore": "{username} + {count} más", + "redemptionsTitle": "{count} canjes", + "revoke": "Revocar", + "revokeAria": "Revocar invitación", + "revokeConfirmTitle": "¿Revocar esta invitación?", + "revokeConfirmBody": "El enlace de invitación dejará de ser válido de inmediato. Las cuentas ya registradas con él se mantienen y el historial de canjes sigue visible.", + "revokeConfirmAction": "Revocar", "cancel": "Cancelar", - "deletedToast": "Invitación eliminada.", - "deleteError": "No se pudo eliminar la invitación." + "revokedToast": "Invitación revocada.", + "revokeError": "No se pudo revocar la invitación." }, "userManagement": "Gestión de usuarios", "userName": "Nombre", @@ -4552,6 +4584,10 @@ "emptyDescription": "No hay usuarios que coincidan con el filtro actual.", "emptyResetFilter": "Mostrar todos los usuarios" }, + "invites": { + "title": "Invitaciones", + "subtitle": "Crear, seguir y revocar enlaces de invitación." + }, "api-tokens": { "title": "Tokens de API", "subtitle": "Tokens bearer activos emitidos a clientes nativos e integraciones." @@ -5199,7 +5235,13 @@ "bpTitle": "Tensión elevada esta semana", "bpBody": "Tu tensión media de esta semana superó tu objetivo. El Coach puede ayudarte a poner los valores en contexto.", "scoreTitle": "Tu puntuación de recuperación baja", - "scoreBody": "Tu puntuación de recuperación ha caído notablemente esta semana. El Coach puede ayudarte a acotar las posibles causas." + "scoreBody": "Tu puntuación de recuperación ha caído notablemente esta semana. El Coach puede ayudarte a acotar las posibles causas.", + "selfContextTitle": "Ayuda a tu Coach a conocerte mejor", + "selfContextBody": "Tu autodescripción está incompleta o lleva tiempo sin actualizarse. Dos minutos en los ajustes mantienen fresco el contexto del Coach.", + "questionConditions": "¿Tienes enfermedades crónicas que el Coach deba conocer?", + "questionFocus": "¿Hay algo concreto en lo que el Coach deba fijarse?", + "questionAllergies": "¿Tienes alergias o intolerancias?", + "questionAboutMe": "¿Quieres contarle al Coach algo sobre ti: rutinas, objetivos, contexto?" }, "clinicianView": { "title": "Registro de salud compartido", diff --git a/messages/fr.json b/messages/fr.json index 9554d507d..6a0fd1bce 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1957,6 +1957,8 @@ "composerPlaceholder": "Pose-moi une question sur tes données…", "composerHint": "Entrée pour envoyer, Maj+Entrée pour une nouvelle ligne.", "composerDisclaimer": "Les réponses sont générées. Les décisions cliniques relèvent de ton médecin.", + "selfContextChipsLabel": "Le Coach aimerait savoir :", + "selfContextChipDismiss": "Ignorer la question", "voiceComingSoon": "La saisie vocale arrive avec l’app iOS en v1.5.", "threadEmpty": "Pose une question sur tes tendances, médicaments ou mesures pour démarrer.", "historyTitle": "Conversations", @@ -3823,7 +3825,18 @@ "clearedToast": "Note « À propos de moi » supprimée.", "saveError": "Impossible d'enregistrer. Veuillez réessayer.", "loadError": "Impossible de charger la note.", - "hint": "Transmis au Coach et au briefing quotidien comme contexte supplémentaire. Laissez vide pour ne rien envoyer." + "hint": "Transmis au Coach et au briefing quotidien comme contexte supplémentaire. Laissez vide pour ne rien envoyer.", + "profileHint": "L'âge et le genre proviennent de votre profil et sont inclus automatiquement.", + "freeTextLabel": "À propos de moi (texte libre)", + "conditionsLabel": "Maladies chroniques", + "conditionsPlaceholder": "P. ex. hypertension, diabète de type 2…", + "allergiesLabel": "Allergies et intolérances", + "allergiesPlaceholder": "P. ex. intolérance au lactose, allergie à la pénicilline…", + "focusLabel": "À quoi le Coach doit-il veiller ?", + "focusPlaceholder": "P. ex. surveille ma tension, motive-moi à bouger davantage…", + "completeness": "{answered} sur {total} renseignés", + "questionsTitle": "Le Coach a des questions", + "questionsOpenCoach": "Y répondre dans le Coach" } }, "dashboard": { @@ -4307,32 +4320,51 @@ "invites": { "title": "Invitations", "description": "Liens d'invitation pour l'inscription — ils fonctionnent même lorsque les inscriptions sont fermées. Le lien n'est affiché qu'une seule fois.", - "createLabel": "Nouvelle invitation :", + "adminOnlyHint": "Seuls les administrateurs peuvent créer et révoquer des invitations.", + "create": "Créer une invitation", + "createTitle": "Créer une invitation", + "createDescription": "Le lien permet une inscription même lorsque les inscriptions sont fermées.", + "ttlLegend": "Valable pendant", "createDays": "{days} jours", + "maxUsesLabel": "Utilisations maximales", + "maxUsesHint": "Nombre d'inscriptions que ce lien autorise (1–50).", + "createConfirm": "Créer l'invitation", "createdToast": "Invitation créée.", "createError": "Impossible de créer l'invitation.", "tokenTitle": "Lien d'invitation", - "tokenOnceHint": "Affiché uniquement maintenant — copiez le lien ou faites scanner le code QR.", + "tokenOnceHint": "Copiable uniquement maintenant — copiez le lien ou faites scanner le code QR.", + "hashExplainer": "Seul un hachage du lien est stocké : il ne pourra plus être affiché. En cas de perte, révoquez l'invitation et créez-en une nouvelle.", "copyLink": "Copier le lien", "copiedToast": "Lien copié.", "copyError": "Échec de la copie.", "qrAlt": "Code QR du lien d'invitation", "loadError": "Impossible de charger les invitations.", - "listEmpty": "Aucune invitation pour l'instant.", - "expires": "expire le {date}", - "uses": "{used} / {max} utilisés", - "createdMeta": "Créée le {date} par {by}", - "usedBy": "utilisée en dernier par {username}", + "emptyTitle": "Aucune invitation pour l'instant", + "emptyDescription": "Créez un lien d'invitation pour admettre quelqu'un malgré des inscriptions fermées.", + "colStatus": "Statut", + "colCreated": "Créée", + "colExpires": "Expire", + "colUses": "Utilisations", + "colRedeemedBy": "Utilisée par", + "colActions": "Actions", + "byCreator": "par {by}", + "expiresInDaysOne": "dans 1 jour", + "expiresInDaysOther": "dans {count} jours", "statusActive": "Active", "statusExpired": "Expirée", "statusExhausted": "Épuisée", - "deleteAria": "Supprimer l'invitation", - "deleteConfirmTitle": "Supprimer cette invitation ?", - "deleteConfirmBody": "Le lien d'invitation devient immédiatement invalide. Les comptes déjà créés avec ce lien sont conservés.", - "deleteConfirmAction": "Supprimer", + "statusRevoked": "Révoquée", + "redeemerDeleted": "compte supprimé", + "redeemedByMore": "{username} + {count} autres", + "redemptionsTitle": "{count} utilisations", + "revoke": "Révoquer", + "revokeAria": "Révoquer l'invitation", + "revokeConfirmTitle": "Révoquer cette invitation ?", + "revokeConfirmBody": "Le lien d'invitation devient immédiatement invalide. Les comptes déjà créés avec ce lien sont conservés et l'historique des utilisations reste visible.", + "revokeConfirmAction": "Révoquer", "cancel": "Annuler", - "deletedToast": "Invitation supprimée.", - "deleteError": "Impossible de supprimer l'invitation." + "revokedToast": "Invitation révoquée.", + "revokeError": "Impossible de révoquer l'invitation." }, "userManagement": "Gestion des utilisateurs", "userName": "Nom", @@ -4552,6 +4584,10 @@ "emptyDescription": "Aucun utilisateur ne correspond au filtre actuel.", "emptyResetFilter": "Afficher tous les utilisateurs" }, + "invites": { + "title": "Invitations", + "subtitle": "Créer, suivre et révoquer les liens d'invitation." + }, "api-tokens": { "title": "Jetons d'API", "subtitle": "Jetons bearer actifs émis aux clients natifs et aux intégrations." @@ -5199,7 +5235,13 @@ "bpTitle": "Tension élevée cette semaine", "bpBody": "Votre tension moyenne de la semaine a dépassé votre objectif. Le Coach peut vous aider à mettre les valeurs en perspective.", "scoreTitle": "Votre score de récupération baisse", - "scoreBody": "Votre score de récupération a nettement chuté cette semaine. Le Coach peut vous aider à cerner les causes possibles." + "scoreBody": "Votre score de récupération a nettement chuté cette semaine. Le Coach peut vous aider à cerner les causes possibles.", + "selfContextTitle": "Aidez votre Coach à mieux vous connaître", + "selfContextBody": "Votre auto-description est incomplète ou n'a pas été mise à jour depuis un moment. Deux minutes dans les réglages gardent le contexte du Coach à jour.", + "questionConditions": "Avez-vous des maladies chroniques que le Coach devrait connaître ?", + "questionFocus": "Y a-t-il quelque chose de précis auquel le Coach devrait veiller ?", + "questionAllergies": "Avez-vous des allergies ou des intolérances ?", + "questionAboutMe": "Envie de parler un peu de vous au Coach — habitudes, objectifs, contexte ?" }, "clinicianView": { "title": "Dossier de santé partagé", diff --git a/messages/it.json b/messages/it.json index 27da1b873..56308a079 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1957,6 +1957,8 @@ "composerPlaceholder": "Chiedimi qualcosa sui tuoi dati…", "composerHint": "Invio per inviare, Shift+Invio per una nuova riga.", "composerDisclaimer": "Le risposte sono generate. Le decisioni cliniche restano al tuo medico.", + "selfContextChipsLabel": "Il Coach vorrebbe sapere:", + "selfContextChipDismiss": "Ignora la domanda", "voiceComingSoon": "L’input vocale arriva con l’app iOS in v1.5.", "threadEmpty": "Chiedimi qualcosa sui tuoi trend, farmaci o misurazioni per iniziare.", "historyTitle": "Conversazioni", @@ -3823,7 +3825,18 @@ "clearedToast": "Nota «Su di me» eliminata.", "saveError": "Impossibile salvare. Riprova.", "loadError": "Impossibile caricare la nota.", - "hint": "Inviato al Coach e al briefing giornaliero come contesto aggiuntivo. Lascia vuoto per non inviare nulla." + "hint": "Inviato al Coach e al briefing giornaliero come contesto aggiuntivo. Lascia vuoto per non inviare nulla.", + "profileHint": "Età e genere provengono dal tuo profilo e vengono inclusi automaticamente.", + "freeTextLabel": "Su di me (testo libero)", + "conditionsLabel": "Malattie croniche", + "conditionsPlaceholder": "Es. ipertensione, diabete di tipo 2…", + "allergiesLabel": "Allergie e intolleranze", + "allergiesPlaceholder": "Es. intolleranza al lattosio, allergia alla penicillina…", + "focusLabel": "A cosa deve fare attenzione il Coach?", + "focusPlaceholder": "Es. tieni d'occhio la mia pressione, motivami a muovermi di più…", + "completeness": "{answered} su {total} compilati", + "questionsTitle": "Il Coach ha alcune domande", + "questionsOpenCoach": "Rispondi nel Coach" } }, "dashboard": { @@ -4307,32 +4320,51 @@ "invites": { "title": "Inviti", "description": "Link di invito per la registrazione: funzionano anche quando la registrazione è chiusa. Il link viene mostrato una sola volta.", - "createLabel": "Nuovo invito:", + "adminOnlyHint": "Solo gli amministratori possono creare e revocare gli inviti.", + "create": "Crea invito", + "createTitle": "Crea invito", + "createDescription": "Il link consente una registrazione anche quando la registrazione è chiusa.", + "ttlLegend": "Valido per", "createDays": "{days} giorni", + "maxUsesLabel": "Utilizzi massimi", + "maxUsesHint": "Quante registrazioni consente questo link (1–50).", + "createConfirm": "Crea invito", "createdToast": "Invito creato.", "createError": "Impossibile creare l'invito.", "tokenTitle": "Link di invito", - "tokenOnceHint": "Mostrato solo adesso: copia il link o fai scansionare il codice QR.", + "tokenOnceHint": "Copiabile solo adesso: copia il link o fai scansionare il codice QR.", + "hashExplainer": "Viene salvato solo un hash del link: non potrà più essere mostrato. Se va perso, revoca l'invito e creane uno nuovo.", "copyLink": "Copia link", "copiedToast": "Link copiato.", "copyError": "Copia non riuscita.", "qrAlt": "Codice QR del link di invito", "loadError": "Impossibile caricare gli inviti.", - "listEmpty": "Ancora nessun invito.", - "expires": "scade il {date}", - "uses": "{used} / {max} usati", - "createdMeta": "Creato il {date} da {by}", - "usedBy": "usato per ultimo da {username}", + "emptyTitle": "Ancora nessun invito", + "emptyDescription": "Crea un link di invito per ammettere qualcuno nonostante la registrazione chiusa.", + "colStatus": "Stato", + "colCreated": "Creato", + "colExpires": "Scade", + "colUses": "Utilizzi", + "colRedeemedBy": "Riscattato da", + "colActions": "Azioni", + "byCreator": "da {by}", + "expiresInDaysOne": "tra 1 giorno", + "expiresInDaysOther": "tra {count} giorni", "statusActive": "Attivo", "statusExpired": "Scaduto", "statusExhausted": "Esaurito", - "deleteAria": "Elimina invito", - "deleteConfirmTitle": "Eliminare questo invito?", - "deleteConfirmBody": "Il link di invito diventa subito non valido. Gli account già registrati con questo link restano.", - "deleteConfirmAction": "Elimina", + "statusRevoked": "Revocato", + "redeemerDeleted": "account eliminato", + "redeemedByMore": "{username} + altri {count}", + "redemptionsTitle": "{count} riscatti", + "revoke": "Revoca", + "revokeAria": "Revoca invito", + "revokeConfirmTitle": "Revocare questo invito?", + "revokeConfirmBody": "Il link di invito diventa subito non valido. Gli account già registrati con questo link restano e la cronologia dei riscatti resta visibile.", + "revokeConfirmAction": "Revoca", "cancel": "Annulla", - "deletedToast": "Invito eliminato.", - "deleteError": "Impossibile eliminare l'invito." + "revokedToast": "Invito revocato.", + "revokeError": "Impossibile revocare l'invito." }, "userManagement": "Gestione utenti", "userName": "Nome", @@ -4552,6 +4584,10 @@ "emptyDescription": "Nessun utente corrisponde al filtro attuale.", "emptyResetFilter": "Mostra tutti gli utenti" }, + "invites": { + "title": "Inviti", + "subtitle": "Creare, monitorare e revocare i link di invito." + }, "api-tokens": { "title": "Token API", "subtitle": "Token bearer attivi emessi a client nativi e integrazioni." @@ -5199,7 +5235,13 @@ "bpTitle": "Pressione elevata questa settimana", "bpBody": "La tua pressione media di questa settimana ha superato il tuo obiettivo. Il Coach può aiutarti a contestualizzare i valori.", "scoreTitle": "Il tuo punteggio di recupero sta calando", - "scoreBody": "Il tuo punteggio di recupero è calato sensibilmente questa settimana. Il Coach può aiutarti a circoscrivere le possibili cause." + "scoreBody": "Il tuo punteggio di recupero è calato sensibilmente questa settimana. Il Coach può aiutarti a circoscrivere le possibili cause.", + "selfContextTitle": "Aiuta il tuo Coach a conoscerti meglio", + "selfContextBody": "La tua autodescrizione è incompleta o non viene aggiornata da tempo. Due minuti nelle impostazioni mantengono fresco il contesto del Coach.", + "questionConditions": "Hai malattie croniche che il Coach dovrebbe conoscere?", + "questionFocus": "C'è qualcosa di specifico a cui il Coach dovrebbe fare attenzione?", + "questionAllergies": "Hai allergie o intolleranze?", + "questionAboutMe": "Vuoi raccontare al Coach qualcosa di te — abitudini, obiettivi, contesto?" }, "clinicianView": { "title": "Cartella sanitaria condivisa", diff --git a/messages/pl.json b/messages/pl.json index e10ed0e5d..34daaf1f3 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1957,6 +1957,8 @@ "composerPlaceholder": "Zapytaj o swoje dane…", "composerHint": "Enter wysyła, Shift+Enter przechodzi do nowej linii.", "composerDisclaimer": "Odpowiedzi są generowane. Decyzje kliniczne należą do lekarza.", + "selfContextChipsLabel": "Coach chciałby wiedzieć:", + "selfContextChipDismiss": "Odrzuć pytanie", "voiceComingSoon": "Wejście głosowe pojawi się z aplikacją iOS w v1.5.", "threadEmpty": "Zapytaj o swoje trendy, leki lub pomiary, aby zacząć.", "historyTitle": "Rozmowy", @@ -3823,7 +3825,18 @@ "clearedToast": "Notatka „O mnie” usunięta.", "saveError": "Nie udało się zapisać. Spróbuj ponownie.", "loadError": "Nie udało się wczytać notatki.", - "hint": "Przekazywane Coachowi i codziennemu briefingowi jako dodatkowy kontekst. Zostaw puste, aby nic nie wysyłać." + "hint": "Przekazywane Coachowi i codziennemu briefingowi jako dodatkowy kontekst. Zostaw puste, aby nic nie wysyłać.", + "profileHint": "Wiek i płeć pochodzą z Twojego profilu i są dołączane automatycznie.", + "freeTextLabel": "O mnie (tekst dowolny)", + "conditionsLabel": "Choroby przewlekłe", + "conditionsPlaceholder": "Np. nadciśnienie, cukrzyca typu 2…", + "allergiesLabel": "Alergie i nietolerancje", + "allergiesPlaceholder": "Np. nietolerancja laktozy, alergia na penicylinę…", + "focusLabel": "Na co Coach ma zwracać uwagę?", + "focusPlaceholder": "Np. pilnuj mojego ciśnienia, motywuj mnie do ruchu…", + "completeness": "Wypełnione: {answered} z {total}", + "questionsTitle": "Coach ma pytania", + "questionsOpenCoach": "Odpowiedz w Coachu" } }, "dashboard": { @@ -4307,32 +4320,51 @@ "invites": { "title": "Zaproszenia", "description": "Linki z zaproszeniem do rejestracji — działają nawet przy zamkniętej rejestracji. Link jest wyświetlany tylko raz.", - "createLabel": "Nowe zaproszenie:", + "adminOnlyHint": "Tylko administratorzy mogą tworzyć i unieważniać zaproszenia.", + "create": "Utwórz zaproszenie", + "createTitle": "Utwórz zaproszenie", + "createDescription": "Link umożliwia rejestrację nawet przy zamkniętej rejestracji.", + "ttlLegend": "Ważne przez", "createDays": "{days} dni", + "maxUsesLabel": "Maksymalna liczba użyć", + "maxUsesHint": "Ile rejestracji dopuszcza ten link (1–50).", + "createConfirm": "Utwórz zaproszenie", "createdToast": "Zaproszenie utworzone.", "createError": "Nie udało się utworzyć zaproszenia.", "tokenTitle": "Link z zaproszeniem", - "tokenOnceHint": "Wyświetlane tylko teraz — skopiuj link lub udostępnij kod QR do zeskanowania.", + "tokenOnceHint": "Do skopiowania tylko teraz — skopiuj link lub udostępnij kod QR do zeskanowania.", + "hashExplainer": "Zapisywany jest tylko hash linku — nie można go później ponownie wyświetlić. W razie utraty unieważnij zaproszenie i utwórz nowe.", "copyLink": "Kopiuj link", "copiedToast": "Link skopiowany.", "copyError": "Kopiowanie nie powiodło się.", "qrAlt": "Kod QR linku z zaproszeniem", "loadError": "Nie udało się wczytać zaproszeń.", - "listEmpty": "Brak zaproszeń.", - "expires": "wygasa {date}", - "uses": "{used} / {max} wykorzystane", - "createdMeta": "Utworzono {date} przez {by}", - "usedBy": "ostatnio użyte przez {username}", + "emptyTitle": "Brak zaproszeń", + "emptyDescription": "Utwórz link z zaproszeniem, aby przyjąć kogoś mimo zamkniętej rejestracji.", + "colStatus": "Status", + "colCreated": "Utworzono", + "colExpires": "Wygasa", + "colUses": "Użycia", + "colRedeemedBy": "Wykorzystane przez", + "colActions": "Akcje", + "byCreator": "przez {by}", + "expiresInDaysOne": "za 1 dzień", + "expiresInDaysOther": "za {count} dni", "statusActive": "Aktywne", "statusExpired": "Wygasłe", "statusExhausted": "Wyczerpane", - "deleteAria": "Usuń zaproszenie", - "deleteConfirmTitle": "Usunąć to zaproszenie?", - "deleteConfirmBody": "Link z zaproszeniem natychmiast straci ważność. Konta już zarejestrowane przez ten link pozostaną.", - "deleteConfirmAction": "Usuń", + "statusRevoked": "Unieważnione", + "redeemerDeleted": "usunięte konto", + "redeemedByMore": "{username} + {count} więcej", + "redemptionsTitle": "Wykorzystania: {count}", + "revoke": "Unieważnij", + "revokeAria": "Unieważnij zaproszenie", + "revokeConfirmTitle": "Unieważnić to zaproszenie?", + "revokeConfirmBody": "Link z zaproszeniem natychmiast straci ważność. Konta już zarejestrowane przez ten link pozostaną, a historia wykorzystań będzie nadal widoczna.", + "revokeConfirmAction": "Unieważnij", "cancel": "Anuluj", - "deletedToast": "Zaproszenie usunięte.", - "deleteError": "Nie udało się usunąć zaproszenia." + "revokedToast": "Zaproszenie unieważnione.", + "revokeError": "Nie udało się unieważnić zaproszenia." }, "userManagement": "Zarządzanie użytkownikami", "userName": "Nazwa", @@ -4552,6 +4584,10 @@ "emptyDescription": "Brak użytkowników pasujących do bieżącego filtra.", "emptyResetFilter": "Pokaż wszystkich użytkowników" }, + "invites": { + "title": "Zaproszenia", + "subtitle": "Tworzenie, śledzenie i unieważnianie linków z zaproszeniem." + }, "api-tokens": { "title": "Tokeny API", "subtitle": "Aktywne tokeny bearer wydane klientom natywnym i integracjom." @@ -5199,7 +5235,13 @@ "bpTitle": "Podwyższone ciśnienie w tym tygodniu", "bpBody": "Twoje średnie ciśnienie w tym tygodniu przekroczyło cel. Coach pomoże Ci umieścić wyniki w kontekście.", "scoreTitle": "Twój wskaźnik regeneracji spada", - "scoreBody": "Twój wskaźnik regeneracji wyraźnie spadł w tym tygodniu. Coach pomoże Ci zawęzić możliwe przyczyny." + "scoreBody": "Twój wskaźnik regeneracji wyraźnie spadł w tym tygodniu. Coach pomoże Ci zawęzić możliwe przyczyny.", + "selfContextTitle": "Pomóż Coachowi lepiej Cię poznać", + "selfContextBody": "Twój opis o sobie jest niepełny lub dawno nie był aktualizowany. Dwie minuty w ustawieniach utrzymają kontekst Coacha w aktualnym stanie.", + "questionConditions": "Czy masz choroby przewlekłe, o których Coach powinien wiedzieć?", + "questionFocus": "Czy jest coś konkretnego, na co Coach powinien zwracać uwagę?", + "questionAllergies": "Czy masz alergie lub nietolerancje?", + "questionAboutMe": "Chcesz opowiedzieć Coachowi coś o sobie — codzienność, cele, kontekst?" }, "clinicianView": { "title": "Udostępniona dokumentacja zdrowotna", diff --git a/prisma/migrations/0142_v1160_invite_redemptions/migration.sql b/prisma/migrations/0142_v1160_invite_redemptions/migration.sql new file mode 100644 index 000000000..40902e67c --- /dev/null +++ b/prisma/migrations/0142_v1160_invite_redemptions/migration.sql @@ -0,0 +1,44 @@ +-- v1.16.0 — invite redemption ledger + soft revocation. +-- +-- `invite_tokens.used_by` keeps only the LAST consumer (multi-use +-- invites overwrite it); `invite_redemptions` carries one row per +-- admitted signup so the admin table can show the full history with +-- timestamps. `user_id` survives account deletion as NULL — the row +-- still documents that a use was consumed. +-- +-- `revoked_at` turns revocation into a soft state: a revoked invite +-- keeps its history visible instead of vanishing, and the consume path +-- refuses it like an expired one. Additive + non-destructive. +ALTER TABLE "invite_tokens" ADD COLUMN "revoked_at" TIMESTAMP(3); + +CREATE TABLE "invite_redemptions" ( + "id" TEXT NOT NULL, + "invite_id" TEXT NOT NULL, + "user_id" TEXT, + "redeemed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "invite_redemptions_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "invite_redemptions_invite_id_idx" ON "invite_redemptions"("invite_id"); + +ALTER TABLE "invite_redemptions" + ADD CONSTRAINT "invite_redemptions_invite_id_fkey" + FOREIGN KEY ("invite_id") REFERENCES "invite_tokens"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "invite_redemptions" + ADD CONSTRAINT "invite_redemptions_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- Backfill: every invite that already recorded a consumer becomes one +-- redemption row, stamped with the invite's `used_at`. Invites whose +-- consumer account was deleted (`used_by` NULL but `uses` > 0) cannot +-- be reconstructed and stay ledger-less — the use counter still counts. +INSERT INTO "invite_redemptions" ("id", "invite_id", "user_id", "redeemed_at") +SELECT + 'invred_' || md5(random()::text || clock_timestamp()::text), + "id", + "used_by", + COALESCE("used_at", "created_at") +FROM "invite_tokens" +WHERE "used_by" IS NOT NULL; diff --git a/prisma/migrations/0143_v1160_self_context_fields/migration.sql b/prisma/migrations/0143_v1160_self_context_fields/migration.sql new file mode 100644 index 000000000..6bd6b2ff3 --- /dev/null +++ b/prisma/migrations/0143_v1160_self_context_fields/migration.sql @@ -0,0 +1,17 @@ +-- v1.16.0 — structured self-context fields on the health profile. +-- +-- Three short structured companions to the free-text "about me" +-- (chronic conditions, allergies/intolerances, what the Coach should +-- watch), each encrypted at rest through the shared Bytes codec and +-- capped at 500 chars BEFORE encryption. Age/gender deliberately do +-- NOT live here — they come from the users table and are merged into +-- the prompt block at assembly time. +-- +-- `pending_questions_encrypted` carries up to 3 clarifying questions +-- the server derives after a save (encrypted JSON string[]); the Coach +-- composer renders them as tappable suggestion chips. Additive + +-- non-destructive. +ALTER TABLE "user_health_profiles" ADD COLUMN "conditions_encrypted" BYTEA; +ALTER TABLE "user_health_profiles" ADD COLUMN "allergies_encrypted" BYTEA; +ALTER TABLE "user_health_profiles" ADD COLUMN "coach_focus_encrypted" BYTEA; +ALTER TABLE "user_health_profiles" ADD COLUMN "pending_questions_encrypted" BYTEA; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3fb6d10a8..47fe79429 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -520,6 +520,8 @@ model User { // multi-use invite). invitesCreated InviteToken[] @relation("InviteCreator") invitesUsed InviteToken[] @relation("InviteConsumer") + // v1.16.0 — per-signup invite redemption ledger (multi-use history). + inviteRedemptions InviteRedemption[] @relation("InviteRedemptions") @@map("users") } @@ -1851,14 +1853,37 @@ model InviteToken { usedBy String? @map("used_by") maxUses Int @default(1) @map("max_uses") uses Int @default(0) + // v1.16.0 — soft revocation. A revoked invite keeps its redemption + // history visible in the admin table instead of vanishing; the + // consume path refuses it like an expired one. + revokedAt DateTime? @map("revoked_at") - creator User @relation("InviteCreator", fields: [createdBy], references: [id], onDelete: Cascade) - consumer User? @relation("InviteConsumer", fields: [usedBy], references: [id], onDelete: SetNull) + creator User @relation("InviteCreator", fields: [createdBy], references: [id], onDelete: Cascade) + consumer User? @relation("InviteConsumer", fields: [usedBy], references: [id], onDelete: SetNull) + redemptions InviteRedemption[] @@index([expiresAt]) @@map("invite_tokens") } +// v1.16.0 — one row per signup an invite admitted. `InviteToken.usedBy` +// keeps only the LAST consumer (it overwrites on multi-use invites); +// this ledger carries the full history so the admin table can show +// every redemption with its timestamp. `userId` survives account +// deletion as NULL — the row still documents that a use was consumed. +model InviteRedemption { + id String @id @default(cuid()) + inviteId String @map("invite_id") + userId String? @map("user_id") + redeemedAt DateTime @default(now()) @map("redeemed_at") + + invite InviteToken @relation(fields: [inviteId], references: [id], onDelete: Cascade) + user User? @relation("InviteRedemptions", fields: [userId], references: [id], onDelete: SetNull) + + @@index([inviteId]) + @@map("invite_redemptions") +} + // ─── Refresh Tokens (native client short-lived access + rotating refresh) ─ model RefreshToken { @@ -3080,6 +3105,19 @@ model UserHealthProfile { // Free text, hard-capped at 4 000 chars by the route's Zod schema // BEFORE encryption. NULL = cleared / never written. aboutMeEncrypted Bytes? @map("about_me_encrypted") + // v1.16.0 — structured self-context companions to the free text. + // Short answers (each capped at 500 chars BEFORE encryption). + // Age/gender deliberately do NOT live here — they come from the User + // profile and are merged into the prompt block at assembly time so + // the data is never duplicated. + conditionsEncrypted Bytes? @map("conditions_encrypted") + allergiesEncrypted Bytes? @map("allergies_encrypted") + coachFocusEncrypted Bytes? @map("coach_focus_encrypted") + // v1.16.0 — up to 3 clarifying questions the server derives after a + // save (AI-generated when a provider + budget allow, deterministic + // hints otherwise). Encrypted JSON string[]; the Coach composer + // renders them as tappable suggestion chips. NULL = none pending. + pendingQuestionsEncrypted Bytes? @map("pending_questions_encrypted") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") diff --git a/src/app/admin/[section]/renderer.tsx b/src/app/admin/[section]/renderer.tsx index fec6b7113..f7bb0234e 100644 --- a/src/app/admin/[section]/renderer.tsx +++ b/src/app/admin/[section]/renderer.tsx @@ -139,12 +139,19 @@ export function AdminSectionRenderer({ title={t("admin.section.users.title")} subtitle={t("admin.section.users.subtitle")} > -
- {t("admin.invites.description")} -
- -{t("admin.invites.tokenTitle")}
-- {t("admin.invites.tokenOnceHint")} -
-- {minted.url} -
-+ {t("admin.invites.description")} +
++ + {t("admin.invites.adminOnlyHint")} +
++ {t("admin.invites.loadError")} +
+ )} + + {invites && invites.length === 0 && ( +- {t("admin.invites.loadError")} -
- )} - {invites && invites.length === 0 && ( -- {t("admin.invites.listEmpty")} -
- )} - {invites?.map((invite) => { - const status = deriveStatus(invite, now); - // Literal keys so the i18n call-site coverage guard sees them. - const statusLabel = - status === "active" - ? t("admin.invites.statusActive") - : status === "expired" - ? t("admin.invites.statusExpired") - : t("admin.invites.statusExhausted"); - return ( -- {t("admin.invites.createdMeta", { - date: formatDate(invite.createdAt), - by: invite.creator?.username ?? "—", - })} - {invite.consumer - ? ` · ${t("admin.invites.usedBy", { - username: invite.consumer.username, - })}` - : ""} -
-+ {t("admin.invites.redemptionsTitle", { + count: invite.redemptions.length, + })} +
+