diff --git a/apps/frontend/app/[locale]/dashboard/assistant/page.tsx b/apps/frontend/app/[locale]/dashboard/assistant/page.tsx new file mode 100644 index 0000000..d7a7297 --- /dev/null +++ b/apps/frontend/app/[locale]/dashboard/assistant/page.tsx @@ -0,0 +1,7 @@ +'use client'; + +import { AssistantWorkspace } from '@/components/assistant/assistant-workspace'; + +export default function AssistantPage() { + return ; +} diff --git a/apps/frontend/messages/en.json b/apps/frontend/messages/en.json index f46f171..692fea1 100644 --- a/apps/frontend/messages/en.json +++ b/apps/frontend/messages/en.json @@ -348,7 +348,8 @@ "organization": "Organization", "apiKeys": "API Keys", "users": "Users", - "admin": "Administration" + "admin": "Administration", + "assistant": "AI assistant" }, "topbar": { "defaultTitle": "Dashboard" @@ -708,48 +709,13 @@ "editMode": "Editing an existing booking: pick a carrier to update your booking, then proceed to payment." } }, - "notificationsPage": { - "title": "Notifications", - "totalLabel": "{count, plural, one {# notification total} other {# notifications total}}", - "unreadSuffix": " • {count, plural, one {# unread} other {# unread}}", - "markAllRead": "Mark all as read", - "filter": { - "label": "Filter:", - "all": "All", - "unread": "Unread", - "read": "Read" - }, - "loading": "Loading notifications...", - "empty": { - "title": "No notifications", - "upToDate": "You're all caught up!", - "none": "No notifications to display" - }, - "new": "NEW", - "deleteTitle": "Delete notification", - "deleteConfirm": "Are you sure you want to delete this notification?", - "viewDetails": "View details", - "priority": { - "urgent": "URGENT", - "high": "HIGH", - "medium": "MEDIUM", - "low": "LOW" - }, - "time": { - "now": "Just now", - "minutes": "{count}m ago", - "hours": "{count}h ago", - "days": "{count}d ago" - }, - "pagination": { - "info": "Page {current} of {total} • {items} {items, plural, one {notification} other {notifications}} total", - "previous": "Previous", - "next": "Next" - } - }, "bookingDetail": { "back": "← Back to bookings", "notFound": "Booking not found", + "timeline": { + "title": "Timeline", + "created": "Booking Created" + }, "createdOn": "Created on {date}", "downloadPdf": "Download PDF", "pdfNotImplemented": "PDF download functionality is not yet implemented", @@ -787,10 +753,6 @@ "email": "Email", "phone": "Phone" }, - "timeline": { - "title": "Timeline", - "created": "Booking Created" - }, "info": { "title": "Information", "bookingId": "Booking ID", @@ -3212,46 +3174,48 @@ "Avoid critical shipments during high-risk periods" ] } - } - }, - "components": { - "notificationDropdown": { - "ariaLabel": "Notifications", - "header": "Notifications", - "markAllRead": "Mark all as read", - "loading": "Loading notifications…", - "empty": "No new notifications", - "viewAll": "View all notifications", - "time": { - "now": "Just now", - "minutes": "{minutes} min ago", - "hours": "{hours} h ago", - "days": "{days} d ago" - } }, - "notificationPanel": { + "notificationsPage": { "title": "Notifications", - "totalCount": "{count, plural, one {# notification total} other {# notifications total}}", - "closeAria": "Close panel", - "filters": { + "totalLabel": "{count, plural, one {# notification total} other {# notifications total}}", + "unreadSuffix": " • {count, plural, one {# unread} other {# unread}}", + "markAllRead": "Mark all as read", + "filter": { + "label": "Filter:", "all": "All", "unread": "Unread", "read": "Read" }, - "markAllRead": "Mark all as read", - "loading": "Loading notifications…", - "emptyTitle": "No notifications", - "emptyUnread": "You're all caught up!", - "emptyAll": "Nothing to show", - "deleteConfirm": "Are you sure you want to delete this notification?", + "loading": "Loading notifications...", + "empty": { + "title": "No notifications", + "upToDate": "You're all caught up!", + "none": "No notifications to display" + }, + "new": "NEW", "deleteTitle": "Delete notification", - "viewDetails": "View details →", + "deleteConfirm": "Are you sure you want to delete this notification?", + "viewDetails": "View details", + "priority": { + "urgent": "URGENT", + "high": "HIGH", + "medium": "MEDIUM", + "low": "LOW" + }, + "time": { + "now": "Just now", + "minutes": "{count}m ago", + "hours": "{count}h ago", + "days": "{count}d ago" + }, "pagination": { - "page": "Page {current} of {total}", + "info": "Page {current} of {total} • {items} {items, plural, one {notification} other {notifications}} total", "previous": "Previous", "next": "Next" } - }, + } + }, + "components": { "exportButton": { "label": "Export", "exporting": "Exporting…", @@ -3410,6 +3374,43 @@ "exportFailed": "Export failed: {message}", "bulkUpdate": "Bulk update", "bulkUpdateSoon": "Bulk update is coming soon!" + }, + "notificationDropdown": { + "ariaLabel": "Notifications", + "header": "Notifications", + "markAllRead": "Mark all as read", + "loading": "Loading notifications…", + "empty": "No new notifications", + "viewAll": "View all notifications", + "time": { + "now": "Just now", + "minutes": "{minutes} min ago", + "hours": "{hours} h ago", + "days": "{days} d ago" + } + }, + "notificationPanel": { + "title": "Notifications", + "totalCount": "{count, plural, one {# notification total} other {# notifications total}}", + "closeAria": "Close panel", + "filters": { + "all": "All", + "unread": "Unread", + "read": "Read" + }, + "markAllRead": "Mark all as read", + "loading": "Loading notifications…", + "emptyTitle": "No notifications", + "emptyUnread": "You're all caught up!", + "emptyAll": "Nothing to show", + "deleteConfirm": "Are you sure you want to delete this notification?", + "deleteTitle": "Delete notification", + "viewDetails": "View details →", + "pagination": { + "page": "Page {current} of {total}", + "previous": "Previous", + "next": "Next" + } } }, "carrierPortal": { @@ -3655,18 +3656,6 @@ "title": "1. Data we collect", "content": "We collect the following data:\n\n• **Identification data**: first name, last name, business email address, phone number\n• **Business data**: company name, role, business registration number\n• **Connection data**: IP address, login records, browsing data\n• **Transaction data**: booking history, quotes, invoices\n• **Communication data**: exchanges with our customer service" }, - "use": { - "title": "2. Use of data", - "content": "Your data is used to:\n\n• Provide and improve our maritime freight booking services\n• Manage your account and preferences\n• Process your quote requests and bookings\n• Send you commercial communications (with your consent)\n• Ensure the security of our platform\n• Meet our legal and regulatory obligations" - }, - "protection": { - "title": "3. Data protection", - "content": "We implement robust security measures:\n\n• SSL/TLS encryption for all communications\n• Encryption of sensitive data at rest (AES-256)\n• Two-factor authentication available\n• Regular security audits\n• Continuous training of our teams\n• Hosting on ISO 27001-certified servers" - }, - "rights": { - "title": "4. Your rights", - "content": "Under the GDPR, you have the following rights:\n\n• **Right of access**: get a copy of your personal data\n• **Right of rectification**: correct your inaccurate data\n• **Right of erasure**: request deletion of your data\n• **Right of portability**: receive your data in a structured format\n• **Right to object**: object to the processing of your data\n• **Right to restriction**: limit the processing of your data\n\nTo exercise these rights, contact us at: privacy@xpeditis.com" - }, "transfers": { "title": "5. International transfers", "content": "Your data may be transferred to non-EU countries as part of our international maritime freight services. These transfers are governed by:\n\n• Standard contractual clauses approved by the European Commission\n• Appropriate certifications (e.g. Privacy Shield for some providers)\n• Explicit consent for certain specific transfers" @@ -3674,6 +3663,18 @@ "retention": { "title": "6. Data retention", "content": "We retain your data for the following durations:\n\n• **Account data**: duration of the business relationship + 3 years\n• **Transaction data**: 10 years (accounting obligations)\n• **Connection data**: 1 year\n• **Marketing data**: 3 years after the last contact\n\nAfter these periods, your data is deleted or anonymised." + }, + "rights": { + "title": "4. Your rights", + "content": "Under the GDPR, you have the following rights:\n\n• **Right of access**: get a copy of your personal data\n• **Right of rectification**: correct your inaccurate data\n• **Right of erasure**: request deletion of your data\n• **Right of portability**: receive your data in a structured format\n• **Right to object**: object to the processing of your data\n• **Right to restriction**: limit the processing of your data\n\nTo exercise these rights, contact us at: privacy@xpeditis.com" + }, + "use": { + "title": "2. Use of data", + "content": "Your data is used to:\n\n• Provide and improve our maritime freight booking services\n• Manage your account and preferences\n• Process your quote requests and bookings\n• Send you commercial communications (with your consent)\n• Ensure the security of our platform\n• Meet our legal and regulatory obligations" + }, + "protection": { + "title": "3. Data protection", + "content": "We implement robust security measures:\n\n• SSL/TLS encryption for all communications\n• Encryption of sensitive data at rest (AES-256)\n• Two-factor authentication available\n• Regular security audits\n• Continuous training of our teams\n• Hosting on ISO 27001-certified servers" } }, "contact": { @@ -3756,6 +3757,16 @@ "description": "Improve your user experience" } }, + "manageTitle": "How to manage your cookies?", + "manageIntro": "You can change your cookie preferences at any time:", + "manageBullet1": "Via our consent banner accessible at the bottom of each page", + "manageBullet2": "In your browser settings (Chrome, Firefox, Safari, Edge)", + "manageBullet3": "Using third-party cookie management tools", + "manageNote": "Note: disabling some cookies may affect your experience on our platform.", + "contact": { + "title": "Questions about cookies?", + "body": "Our team is available to answer all your questions regarding the use of cookies on our platform." + }, "purposes": { "session_id": "Maintains your login session", "csrf_token": "Protects against CSRF attacks", @@ -3779,16 +3790,6 @@ "months3": "3 months", "days30": "30 days", "months13": "13 months" - }, - "manageTitle": "How to manage your cookies?", - "manageIntro": "You can change your cookie preferences at any time:", - "manageBullet1": "Via our consent banner accessible at the bottom of each page", - "manageBullet2": "In your browser settings (Chrome, Firefox, Safari, Edge)", - "manageBullet3": "Using third-party cookie management tools", - "manageNote": "Note: disabling some cookies may affect your experience on our platform.", - "contact": { - "title": "Questions about cookies?", - "body": "Our team is available to answer all your questions regarding the use of cookies on our platform." } }, "about": { @@ -4757,5 +4758,72 @@ "content": "Content", "system": "System" } + }, + "tradeAssistant": { + "title": "Your international trade assistant", + "intro": "Ask about imports, exports, sea freight and trade procedures.", + "loading": "Loading your quota…", + "quotaError": "Unable to load your quota. Guided help and support are still available.", + "retry": "Retry", + "remaining": "{remaining} / {limit} questions remaining today", + "unlimitedQuota": "Unlimited questions", + "used": "Questions used", + "reset": "Resets on {date} (Paris time). Individual quota.", + "welcome": "How can we help?", + "you": "You", + "assistant": "AI assistant", + "thinking": "The assistant is preparing your answer…", + "exhausted": "You have used your daily quota. Continue with the guided help below or contact our support team.", + "unavailable": "The AI assistant is currently unavailable. Use the guided help below or contact support.", + "error": "The answer could not be received. Check your quota before retrying, or contact support.", + "question": "Your question", + "placeholder": "Describe your question, the countries and the goods involved…", + "send": "Send", + "notice": "Answers are AI-generated from the Xpeditis wiki, without real-time verification. Do not share confidential information; your question is sent to OpenAI.", + "guidedTitle": "Guided help", + "back": "Another question", + "supportTitle": "Need personal assistance?", + "supportIntro": "For your shipment, a complex question or to speak with our team:", + "startersTitle": "Start from a common question", + "copy": "Copy", + "copied": "Copied", + "shortcut": "⌘ / Ctrl + Enter", + "conversations": "Conversations", + "newConversation": "New conversation", + "noConversations": "No conversations yet.", + "rename": "Rename", + "delete": "Delete", + "deleteConfirm": "Delete this conversation?", + "deleteConfirmBody": "“{title}” and its messages will be permanently deleted.", + "save": "Save", + "cancel": "Cancel", + "sources": "Sources in the Xpeditis wiki", + "starters": { + "lclFcl": "I have 4 m³ to ship from Shanghai to Marseille: LCL or FCL?", + "documents": "Which documents do I need to export wine to the United States?", + "customs": "How do I find the HS code for my goods?", + "incoterms": "FOB or CIF: which one for a first import?", + "platform": "How many organisations and active accounts are on the platform?", + "accounts": "List the platform administrators and managers.", + "grids": "Which rate grids are loaded, and for which carriers?" + }, + "topics": { + "shipping": { + "title": "Prepare a shipment", + "answer": "What are the origin and destination countries? What are the volume, weight and type of goods? Prepare these details and your preferred dates, then use the rate search. Contact support if you need assistance." + }, + "documents": { + "title": "Identify required documents", + "answer": "Do you have a commercial invoice, packing list and transport details? Additional documents depend on the countries and goods. Share your route and product with support for guidance." + }, + "customs": { + "title": "Customs and regulations", + "answer": "Which product are you importing or exporting, and between which countries? Prepare its description, value and origin. Confirm classification, duties and restrictions with a customs representative or the relevant authority. Support can help direct you." + }, + "account": { + "title": "Booking or account issue", + "answer": "Which booking or step is causing trouble? Note the booking reference and describe the expected result, then email support@xpeditis.com. Never share passwords or API keys." + } + } } } diff --git a/apps/frontend/messages/fr.json b/apps/frontend/messages/fr.json index 44181b6..b1a5545 100644 --- a/apps/frontend/messages/fr.json +++ b/apps/frontend/messages/fr.json @@ -348,7 +348,8 @@ "organization": "Organisation", "apiKeys": "Clés API", "users": "Utilisateurs", - "admin": "Administration" + "admin": "Administration", + "assistant": "Assistant IA" }, "topbar": { "defaultTitle": "Tableau de bord" @@ -708,48 +709,13 @@ "editMode": "Modification d'une réservation existante : choisissez une compagnie pour mettre à jour votre réservation, puis passez au paiement." } }, - "notificationsPage": { - "title": "Notifications", - "totalLabel": "{count, plural, one {# notification au total} other {# notifications au total}}", - "unreadSuffix": " • {count, plural, one {# non lue} other {# non lues}}", - "markAllRead": "Tout marquer comme lu", - "filter": { - "label": "Filtrer :", - "all": "Toutes", - "unread": "Non lues", - "read": "Lues" - }, - "loading": "Chargement des notifications...", - "empty": { - "title": "Aucune notification", - "upToDate": "Vous êtes à jour !", - "none": "Aucune notification à afficher" - }, - "new": "NOUVEAU", - "deleteTitle": "Supprimer la notification", - "deleteConfirm": "Êtes-vous sûr de vouloir supprimer cette notification ?", - "viewDetails": "Voir les détails", - "priority": { - "urgent": "URGENT", - "high": "ÉLEVÉE", - "medium": "MOYENNE", - "low": "FAIBLE" - }, - "time": { - "now": "À l'instant", - "minutes": "Il y a {count}min", - "hours": "Il y a {count}h", - "days": "Il y a {count}j" - }, - "pagination": { - "info": "Page {current} sur {total} • {items} {items, plural, one {notification} other {notifications}} au total", - "previous": "Précédent", - "next": "Suivant" - } - }, "bookingDetail": { "back": "← Retour aux réservations", "notFound": "Réservation introuvable", + "timeline": { + "title": "Chronologie", + "created": "Réservation créée" + }, "createdOn": "Créée le {date}", "downloadPdf": "Télécharger le PDF", "pdfNotImplemented": "Le téléchargement PDF n'est pas encore disponible", @@ -787,10 +753,6 @@ "email": "Email", "phone": "Téléphone" }, - "timeline": { - "title": "Chronologie", - "created": "Réservation créée" - }, "info": { "title": "Informations", "bookingId": "ID de réservation", @@ -3212,46 +3174,48 @@ "Éviter les expéditions critiques pendant les périodes à risque" ] } - } - }, - "components": { - "notificationDropdown": { - "ariaLabel": "Notifications", - "header": "Notifications", - "markAllRead": "Tout marquer comme lu", - "loading": "Chargement des notifications…", - "empty": "Aucune nouvelle notification", - "viewAll": "Voir toutes les notifications", - "time": { - "now": "À l'instant", - "minutes": "Il y a {minutes} min", - "hours": "Il y a {hours} h", - "days": "Il y a {days} j" - } }, - "notificationPanel": { + "notificationsPage": { "title": "Notifications", - "totalCount": "{count, plural, one {# notification au total} other {# notifications au total}}", - "closeAria": "Fermer le panneau", - "filters": { + "totalLabel": "{count, plural, one {# notification au total} other {# notifications au total}}", + "unreadSuffix": " • {count, plural, one {# non lue} other {# non lues}}", + "markAllRead": "Tout marquer comme lu", + "filter": { + "label": "Filtrer :", "all": "Toutes", "unread": "Non lues", "read": "Lues" }, - "markAllRead": "Tout marquer comme lu", - "loading": "Chargement des notifications…", - "emptyTitle": "Aucune notification", - "emptyUnread": "Vous êtes à jour !", - "emptyAll": "Aucune notification à afficher", - "deleteConfirm": "Voulez-vous vraiment supprimer cette notification ?", + "loading": "Chargement des notifications...", + "empty": { + "title": "Aucune notification", + "upToDate": "Vous êtes à jour !", + "none": "Aucune notification à afficher" + }, + "new": "NOUVEAU", "deleteTitle": "Supprimer la notification", - "viewDetails": "Voir les détails →", + "deleteConfirm": "Êtes-vous sûr de vouloir supprimer cette notification ?", + "viewDetails": "Voir les détails", + "priority": { + "urgent": "URGENT", + "high": "ÉLEVÉE", + "medium": "MOYENNE", + "low": "FAIBLE" + }, + "time": { + "now": "À l'instant", + "minutes": "Il y a {count}min", + "hours": "Il y a {count}h", + "days": "Il y a {count}j" + }, "pagination": { - "page": "Page {current} sur {total}", + "info": "Page {current} sur {total} • {items} {items, plural, one {notification} other {notifications}} au total", "previous": "Précédent", "next": "Suivant" } - }, + } + }, + "components": { "exportButton": { "label": "Exporter", "exporting": "Export en cours…", @@ -3410,6 +3374,43 @@ "exportFailed": "Échec de l'export : {message}", "bulkUpdate": "Mise à jour groupée", "bulkUpdateSoon": "La mise à jour groupée arrive bientôt !" + }, + "notificationDropdown": { + "ariaLabel": "Notifications", + "header": "Notifications", + "markAllRead": "Tout marquer comme lu", + "loading": "Chargement des notifications…", + "empty": "Aucune nouvelle notification", + "viewAll": "Voir toutes les notifications", + "time": { + "now": "À l'instant", + "minutes": "Il y a {minutes} min", + "hours": "Il y a {hours} h", + "days": "Il y a {days} j" + } + }, + "notificationPanel": { + "title": "Notifications", + "totalCount": "{count, plural, one {# notification au total} other {# notifications au total}}", + "closeAria": "Fermer le panneau", + "filters": { + "all": "Toutes", + "unread": "Non lues", + "read": "Lues" + }, + "markAllRead": "Tout marquer comme lu", + "loading": "Chargement des notifications…", + "emptyTitle": "Aucune notification", + "emptyUnread": "Vous êtes à jour !", + "emptyAll": "Aucune notification à afficher", + "deleteConfirm": "Voulez-vous vraiment supprimer cette notification ?", + "deleteTitle": "Supprimer la notification", + "viewDetails": "Voir les détails →", + "pagination": { + "page": "Page {current} sur {total}", + "previous": "Précédent", + "next": "Suivant" + } } }, "carrierPortal": { @@ -3655,18 +3656,6 @@ "title": "1. Données collectées", "content": "Nous collectons les données suivantes :\n\n• **Données d'identification** : nom, prénom, adresse email professionnelle, numéro de téléphone\n• **Données professionnelles** : nom de l'entreprise, fonction, numéro SIRET\n• **Données de connexion** : adresse IP, logs de connexion, données de navigation\n• **Données de transaction** : historique des réservations, devis, factures\n• **Données de communication** : échanges avec notre service client" }, - "use": { - "title": "2. Utilisation des données", - "content": "Vos données sont utilisées pour :\n\n• Fournir et améliorer nos services de réservation de fret maritime\n• Gérer votre compte et vos préférences\n• Traiter vos demandes de devis et réservations\n• Vous envoyer des communications commerciales (avec votre consentement)\n• Assurer la sécurité de notre plateforme\n• Respecter nos obligations légales et réglementaires" - }, - "protection": { - "title": "3. Protection des données", - "content": "Nous mettons en œuvre des mesures de sécurité robustes :\n\n• Chiffrement SSL/TLS pour toutes les communications\n• Chiffrement des données sensibles au repos (AES-256)\n• Authentification à deux facteurs disponible\n• Audits de sécurité réguliers\n• Formation continue de nos équipes\n• Hébergement sur des serveurs certifiés ISO 27001" - }, - "rights": { - "title": "4. Vos droits", - "content": "Conformément au RGPD, vous disposez des droits suivants :\n\n• **Droit d'accès** : obtenir une copie de vos données personnelles\n• **Droit de rectification** : corriger vos données inexactes\n• **Droit à l'effacement** : demander la suppression de vos données\n• **Droit à la portabilité** : recevoir vos données dans un format structuré\n• **Droit d'opposition** : vous opposer au traitement de vos données\n• **Droit de limitation** : limiter le traitement de vos données\n\nPour exercer ces droits, contactez-nous à : privacy@xpeditis.com" - }, "transfers": { "title": "5. Transferts internationaux", "content": "Vos données peuvent être transférées vers des pays hors UE dans le cadre de nos services de fret maritime international. Ces transferts sont encadrés par :\n\n• Des clauses contractuelles types approuvées par la Commission européenne\n• Des certifications adéquates (ex: Privacy Shield pour certains prestataires)\n• Le consentement explicite pour certains transferts spécifiques" @@ -3674,6 +3663,18 @@ "retention": { "title": "6. Conservation des données", "content": "Nous conservons vos données selon les durées suivantes :\n\n• **Données de compte** : durée de la relation commerciale + 3 ans\n• **Données de transaction** : 10 ans (obligations comptables)\n• **Données de connexion** : 1 an\n• **Données marketing** : 3 ans après le dernier contact\n\nÀ l'expiration de ces délais, vos données sont supprimées ou anonymisées." + }, + "rights": { + "title": "4. Vos droits", + "content": "Conformément au RGPD, vous disposez des droits suivants :\n\n• **Droit d'accès** : obtenir une copie de vos données personnelles\n• **Droit de rectification** : corriger vos données inexactes\n• **Droit à l'effacement** : demander la suppression de vos données\n• **Droit à la portabilité** : recevoir vos données dans un format structuré\n• **Droit d'opposition** : vous opposer au traitement de vos données\n• **Droit de limitation** : limiter le traitement de vos données\n\nPour exercer ces droits, contactez-nous à : privacy@xpeditis.com" + }, + "use": { + "title": "2. Utilisation des données", + "content": "Vos données sont utilisées pour :\n\n• Fournir et améliorer nos services de réservation de fret maritime\n• Gérer votre compte et vos préférences\n• Traiter vos demandes de devis et réservations\n• Vous envoyer des communications commerciales (avec votre consentement)\n• Assurer la sécurité de notre plateforme\n• Respecter nos obligations légales et réglementaires" + }, + "protection": { + "title": "3. Protection des données", + "content": "Nous mettons en œuvre des mesures de sécurité robustes :\n\n• Chiffrement SSL/TLS pour toutes les communications\n• Chiffrement des données sensibles au repos (AES-256)\n• Authentification à deux facteurs disponible\n• Audits de sécurité réguliers\n• Formation continue de nos équipes\n• Hébergement sur des serveurs certifiés ISO 27001" } }, "contact": { @@ -3756,6 +3757,16 @@ "description": "Améliorent votre expérience utilisateur" } }, + "manageTitle": "Comment gérer vos cookies ?", + "manageIntro": "Vous pouvez à tout moment modifier vos préférences en matière de cookies :", + "manageBullet1": "Via notre bandeau de consentement accessible en bas de chaque page", + "manageBullet2": "Dans les paramètres de votre navigateur (Chrome, Firefox, Safari, Edge)", + "manageBullet3": "En utilisant des outils tiers de gestion des cookies", + "manageNote": "Note : La désactivation de certains cookies peut affecter votre expérience sur notre plateforme.", + "contact": { + "title": "Des questions sur les cookies ?", + "body": "Notre équipe est disponible pour répondre à toutes vos questions concernant l'utilisation des cookies sur notre plateforme." + }, "purposes": { "session_id": "Maintien de votre session de connexion", "csrf_token": "Protection contre les attaques CSRF", @@ -3779,16 +3790,6 @@ "months3": "3 mois", "days30": "30 jours", "months13": "13 mois" - }, - "manageTitle": "Comment gérer vos cookies ?", - "manageIntro": "Vous pouvez à tout moment modifier vos préférences en matière de cookies :", - "manageBullet1": "Via notre bandeau de consentement accessible en bas de chaque page", - "manageBullet2": "Dans les paramètres de votre navigateur (Chrome, Firefox, Safari, Edge)", - "manageBullet3": "En utilisant des outils tiers de gestion des cookies", - "manageNote": "Note : La désactivation de certains cookies peut affecter votre expérience sur notre plateforme.", - "contact": { - "title": "Des questions sur les cookies ?", - "body": "Notre équipe est disponible pour répondre à toutes vos questions concernant l'utilisation des cookies sur notre plateforme." } }, "about": { @@ -4757,5 +4758,72 @@ "content": "Contenu", "system": "Systeme" } + }, + "tradeAssistant": { + "title": "Votre assistant commerce international", + "intro": "Posez vos questions sur l’import-export, le transport maritime et les démarches commerciales.", + "loading": "Chargement de votre quota…", + "quotaError": "Impossible de charger votre quota. L’aide guidée et le support restent disponibles.", + "retry": "Réessayer", + "remaining": "{remaining} / {limit} questions restantes aujourd’hui", + "unlimitedQuota": "Questions illimitées", + "used": "Questions utilisées", + "reset": "Renouvellement le {date} (heure de Paris). Quota individuel.", + "welcome": "Comment pouvons-nous vous aider ?", + "you": "Vous", + "assistant": "Assistant IA", + "thinking": "L’assistant prépare sa réponse…", + "exhausted": "Vous avez utilisé votre quota du jour. Continuez avec l’aide guidée ci-dessous ou contactez notre support.", + "unavailable": "L’assistant IA est momentanément indisponible. Utilisez l’aide guidée ci-dessous ou contactez notre support.", + "error": "La réponse n’a pas pu être reçue. Vérifiez votre quota avant de réessayer, ou contactez le support.", + "question": "Votre question", + "placeholder": "Décrivez votre question, les pays et les marchandises concernés…", + "send": "Envoyer", + "notice": "Les réponses sont générées par IA à partir du wiki Xpeditis, sans vérification en temps réel. Ne partagez pas de données confidentielles ; votre question est transmise à OpenAI.", + "guidedTitle": "Aide guidée", + "back": "Autre question", + "supportTitle": "Besoin d’un accompagnement ?", + "supportIntro": "Pour votre dossier, une question complexe ou pour échanger avec notre équipe :", + "startersTitle": "Partir d'une question type", + "copy": "Copier", + "copied": "Copié", + "shortcut": "⌘ / Ctrl + Entrée", + "conversations": "Conversations", + "newConversation": "Nouvelle conversation", + "noConversations": "Aucune conversation pour le moment.", + "rename": "Renommer", + "delete": "Supprimer", + "deleteConfirm": "Supprimer cette conversation ?", + "deleteConfirmBody": "« {title} » et ses messages seront définitivement supprimés.", + "save": "Enregistrer", + "cancel": "Annuler", + "sources": "Sources dans le wiki Xpeditis", + "starters": { + "lclFcl": "J'ai 4 m³ à expédier de Shanghai à Marseille : LCL ou FCL ?", + "documents": "Quels documents préparer pour exporter du vin vers les États-Unis ?", + "customs": "Comment déterminer le code SH de mes marchandises ?", + "incoterms": "FOB ou CIF : lequel choisir pour un premier import ?", + "platform": "Combien d'organisations et de comptes actifs sur la plateforme ?", + "accounts": "Liste les administrateurs et les managers de la plateforme.", + "grids": "Quelles grilles tarifaires sont chargées, et pour quels transporteurs ?" + }, + "topics": { + "shipping": { + "title": "Préparer une expédition", + "answer": "Quels sont les pays de départ et d’arrivée ? Quels sont le volume, le poids et la nature des marchandises ? Préparez ces informations et vos dates souhaitées, puis utilisez la recherche de tarifs. Pour un accompagnement, transmettez votre besoin au support." + }, + "documents": { + "title": "Identifier les documents nécessaires", + "answer": "Disposez-vous d’une facture commerciale, d’une liste de colisage et des informations de transport ? Les documents supplémentaires dépendent des pays et des marchandises. Indiquez votre trajet et votre produit au support pour être orienté." + }, + "customs": { + "title": "Douanes et réglementation", + "answer": "Quel produit importez-vous ou exportez-vous, depuis et vers quels pays ? Préparez sa description, sa valeur et son origine. Faites confirmer le classement douanier, les droits et les restrictions par un représentant en douane ou l’autorité compétente. Le support peut vous orienter." + }, + "account": { + "title": "Réservation ou problème de compte", + "answer": "Quelle réservation ou quelle étape pose problème ? Notez la référence du dossier et décrivez le résultat attendu, puis écrivez à support@xpeditis.com. Ne transmettez jamais votre mot de passe ou vos clés API." + } + } } } diff --git a/apps/frontend/src/__tests__/components/assistant-answer-text.test.tsx b/apps/frontend/src/__tests__/components/assistant-answer-text.test.tsx new file mode 100644 index 0000000..4312b97 --- /dev/null +++ b/apps/frontend/src/__tests__/components/assistant-answer-text.test.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { AnswerText } from '@/components/assistant/answer-text'; + +it('renders paragraphs, bullets and numbered steps as real lists', () => { + render( + + ); + + expect(screen.getAllByRole('list')).toHaveLength(2); + expect(screen.getAllByRole('listitem')).toHaveLength(4); + // Le marqueur de liste est retire du texte rendu. + expect(screen.getByText(/vous ne payez que le volume occupé/)).toBeInTheDocument(); + expect(screen.getByText('Le délai de dégroupage.')).toBeInTheDocument(); +}); + +it('applies inline bold without interpreting markup', () => { + render(); + + expect(screen.getByText('LCL').tagName).toBe('STRONG'); + expect(screen.getByText(/Pas de HTML<\/b>/)).toBeInTheDocument(); +}); + +it('keeps a plain answer in a single paragraph', () => { + const { container } = render(); + + expect(container.querySelectorAll('p')).toHaveLength(1); + expect(screen.getByText('Voici les documents.')).toBeInTheDocument(); +}); diff --git a/apps/frontend/src/__tests__/components/trade-assistant.test.tsx b/apps/frontend/src/__tests__/components/trade-assistant.test.tsx new file mode 100644 index 0000000..c47793e --- /dev/null +++ b/apps/frontend/src/__tests__/components/trade-assistant.test.tsx @@ -0,0 +1,293 @@ +import React from 'react'; +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import AssistantPage from '../../../app/[locale]/dashboard/assistant/page'; +import { useTradeAssistant } from '@/hooks/use-trade-assistant'; + +jest.mock('@/hooks/use-trade-assistant'); +jest.mock('@/components/ui/use-confirm', () => ({ + useConfirm: () => confirmMock, +})); +jest.mock('@/i18n/navigation', () => ({ + Link: ({ href, children, ...rest }: any) => ( + + {children} + + ), +})); +jest.mock('next-intl', () => ({ + useLocale: () => 'fr', + useTranslations: () => (key: string, values?: Record) => { + let value = key + .split('.') + .reduce((obj, part) => obj[part], require('../../../messages/fr.json').tradeAssistant); + Object.entries(values ?? {}).forEach(([name, replacement]) => { + value = value.replace(`{${name}}`, String(replacement)); + }); + return value; + }, +})); + +const messages = require('../../../messages/fr.json').tradeAssistant; +const confirmMock = jest.fn().mockResolvedValue(true); +const mockHook = useTradeAssistant as jest.Mock; +const mutateAsync = jest.fn(); +const open = jest.fn(); +const renameMutate = jest.fn(); +const removeMutate = jest.fn(); + +const message = (id: string, role: 'user' | 'assistant', content: string, sources: any[] = []) => ({ + id, + role, + content, + sources, + createdAt: '2026-09-05T10:00:00.000Z', +}); + +const conversation = { + id: 'c1', + title: 'Régimes douaniers', + createdAt: '2026-09-05T10:00:00.000Z', + updatedAt: new Date().toISOString(), + messageCount: 2, +}; + +function state({ + quota: quotaOverrides = {}, + conversations = [conversation], + thread = [] as any[], + conversationId = null as string | null, + asking = false, +} = {}) { + return { + quota: { + data: { + day: '2026-09-05', + plan: 'SILVER', + limit: 10, + unlimited: false, + used: 1, + remaining: 9, + available: true, + resetsAt: '2026-09-05T22:00:00.000Z', + supportEmail: 'support@xpeditis.com', + ...quotaOverrides, + }, + isPending: false, + isError: false, + refetch: jest.fn(), + }, + conversations: { data: conversations, isPending: false }, + messages: { data: thread, isPending: false }, + ask: { + mutateAsync, + isPending: asking, + isError: false, + variables: asking ? 'Quels documents ?' : undefined, + }, + rename: { mutate: renameMutate }, + remove: { mutate: removeMutate }, + conversationId, + open, + }; +} + +beforeEach(() => { + Element.prototype.scrollIntoView = jest.fn(); + [mutateAsync, open, renameMutate, removeMutate].forEach(fn => fn.mockReset()); + confirmMock.mockClear().mockResolvedValue(true); + mockHook.mockReturnValue(state()); +}); + +/* -------------------------------------------------------------------------- */ +/* Conversation */ +/* -------------------------------------------------------------------------- */ + +it('sends a question and keeps the quota meter in the composer', async () => { + mutateAsync.mockResolvedValue({ mode: 'ai', conversationId: 'c2' }); + render(); + + // L'offre accompagne le décompte : c'est ce qui rend un quota inattendu + // compréhensible sans ouvrir la page d'abonnement. + expect(screen.getByText('9/10')).toBeInTheDocument(); + expect(screen.getByText('SILVER')).toBeInTheDocument(); + + fireEvent.change(screen.getByLabelText('Votre question'), { + target: { value: 'Quels documents ?' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Envoyer' })); + + await waitFor(() => expect(mutateAsync).toHaveBeenCalledWith('Quels documents ?')); + expect(screen.getByLabelText('Votre question')).toHaveValue(''); +}); + +it('sends on Ctrl+Enter', async () => { + mutateAsync.mockResolvedValue({ mode: 'ai', conversationId: 'c2' }); + render(); + const field = screen.getByLabelText('Votre question'); + + fireEvent.change(field, { target: { value: 'Envoi au clavier' } }); + fireEvent.keyDown(field, { key: 'Enter', ctrlKey: true }); + + await waitFor(() => expect(mutateAsync).toHaveBeenCalledWith('Envoi au clavier')); +}); + +it('retains the question after a failed request', async () => { + mutateAsync.mockRejectedValue(new Error('Network')); + render(); + + fireEvent.change(screen.getByLabelText('Votre question'), { + target: { value: 'Question conservée' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Envoyer' })); + + await waitFor(() => expect(mutateAsync).toHaveBeenCalled()); + expect(screen.getByLabelText('Votre question')).toHaveValue('Question conservée'); +}); + +it('renders the thread and cites the wiki pages the answer came from', () => { + mockHook.mockReturnValue( + state({ + conversationId: 'c1', + thread: [ + message('m1', 'user', 'Quels régimes douaniers ?'), + message('m2', 'assistant', 'Le régime 40 00 est la mise en libre pratique.', [ + { title: 'Procédures Douanières', section: 'Régimes', href: '/dashboard/wiki/douanes' }, + ]), + ], + }) + ); + render(); + + expect(screen.getByText('Quels régimes douaniers ?')).toBeInTheDocument(); + expect(screen.getByText(/mise en libre pratique/)).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /Procédures Douanières/ })).toHaveAttribute( + 'href', + '/dashboard/wiki/douanes' + ); +}); + +/* -------------------------------------------------------------------------- */ +/* Liste des conversations */ +/* -------------------------------------------------------------------------- */ + +it('lists conversations by age and opens one', () => { + render(); + + expect(screen.getByText("Aujourd'hui")).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Régimes douaniers' })); + + expect(open).toHaveBeenCalledWith('c1'); +}); + +it('starts a new conversation without creating one server-side', () => { + render(); + + fireEvent.click(screen.getAllByRole('button', { name: 'Nouvelle conversation' })[0]); + + expect(open).toHaveBeenCalledWith(null); + expect(mutateAsync).not.toHaveBeenCalled(); +}); + +it('renames a conversation in place', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Renommer — Régimes douaniers' })); + + const field = screen.getByLabelText('Renommer'); + fireEvent.change(field, { target: { value: 'Douanes Chine' } }); + fireEvent.submit(field); + + expect(renameMutate).toHaveBeenCalledWith({ id: 'c1', title: 'Douanes Chine' }); +}); + +it('asks for confirmation before deleting a conversation', async () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Supprimer — Régimes douaniers' })); + + await waitFor(() => expect(confirmMock).toHaveBeenCalled()); + expect(confirmMock.mock.calls[0][0]).toMatchObject({ destructive: true }); + await waitFor(() => expect(removeMutate).toHaveBeenCalledWith('c1')); +}); + +it('does not delete when the confirmation is dismissed', async () => { + confirmMock.mockResolvedValue(false); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Supprimer — Régimes douaniers' })); + + await waitFor(() => expect(confirmMock).toHaveBeenCalled()); + expect(removeMutate).not.toHaveBeenCalled(); +}); + +/* -------------------------------------------------------------------------- */ +/* Quota */ +/* -------------------------------------------------------------------------- */ + +it('keeps guided help and support out of the way while quota remains', () => { + render(); + + expect(screen.queryByRole('button', { name: 'Préparer une expédition' })).not.toBeInTheDocument(); + expect(screen.queryByRole('link', { name: 'support@xpeditis.com' })).not.toBeInTheDocument(); +}); + +it('brings guided help and support into the thread once the quota is reached', () => { + mockHook.mockReturnValue(state({ quota: { remaining: 0, used: 10 } })); + render(); + + expect(screen.getByText(/Vous avez utilisé votre quota du jour/)).toBeInTheDocument(); + expect(screen.getByLabelText('Votre question')).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Envoyer' })).toBeDisabled(); + + fireEvent.click(screen.getByRole('button', { name: 'Préparer une expédition' })); + expect(screen.getByText(/Quels sont les pays de départ/)).toBeInTheDocument(); + + expect(screen.getByRole('link', { name: 'support@xpeditis.com' })).toHaveAttribute( + 'href', + 'mailto:support@xpeditis.com' + ); +}); + +it('shows an unlimited plan as unlimited, never as an exhausted quota', () => { + // Platinium renvoie `limit: -1` et `remaining: -1` : lus comme un reste, ils + // affichaient « -1/-1 » et bloquaient la saisie comme un quota épuisé. + mockHook.mockReturnValue( + state({ quota: { plan: 'PLATINIUM', limit: -1, unlimited: true, remaining: -1, used: 42 } }) + ); + render(); + + expect(screen.getByText('Questions illimitées')).toBeInTheDocument(); + expect(screen.getByText('PLATINIUM')).toBeInTheDocument(); + expect(screen.queryByText('-1/-1')).not.toBeInTheDocument(); + expect(screen.getByLabelText('Votre question')).toBeEnabled(); + expect(screen.queryByText(/Vous avez utilisé votre quota du jour/)).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Préparer une expédition' })).not.toBeInTheDocument(); +}); + +it('offers the same fallback when the provider is down', () => { + mockHook.mockReturnValue(state({ quota: { available: false } })); + render(); + + expect(screen.getByText(/L’assistant IA est momentanément indisponible/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Douanes et réglementation' })).toBeEnabled(); + expect(mutateAsync).not.toHaveBeenCalled(); +}); + +it('fills the field from a starter without sending it', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: messages.starters.lclFcl })); + + expect(screen.getByLabelText('Votre question')).toHaveValue(messages.starters.lclFcl); + expect(mutateAsync).not.toHaveBeenCalled(); +}); + +it('shows the pending question in the thread while the answer is written', () => { + mockHook.mockReturnValue(state({ asking: true })); + const { container } = render(); + + const log = container.querySelector('[role="log"]') as HTMLElement; + expect(within(log).getByText('Quels documents ?')).toBeInTheDocument(); + expect(within(log).getByRole('status')).toHaveTextContent('L’assistant prépare sa réponse…'); + expect(screen.getByRole('button', { name: 'Envoyer' })).toBeDisabled(); +}); diff --git a/apps/frontend/src/components/assistant/answer-text.tsx b/apps/frontend/src/components/assistant/answer-text.tsx new file mode 100644 index 0000000..63420b2 --- /dev/null +++ b/apps/frontend/src/components/assistant/answer-text.tsx @@ -0,0 +1,153 @@ +'use client'; + +import * as React from 'react'; + +/** + * Rendu des reponses de l'assistant. + * + * Le modele renvoie du texte pedagogique d'environ 350 mots, structure en + * paragraphes et en listes. Rendu dans un seul `

` en `whitespace-pre-wrap`, + * il formait un bloc illisible. Ce formateur retablit la structure sans + * introduire de moteur Markdown : le texte reste du texte, aucun HTML n'est + * interprete, donc aucune surface d'injection n'est ouverte. + * + * Sont reconnus : les paragraphes (ligne vide), les listes a puces + * (`-`, `*`, `•`), les listes numerotees (`1.`), les lignes d'introduction + * terminees par deux points, et le gras `**...**`. + */ + +const BULLET = /^\s*[-*•]\s+/; +const NUMBERED = /^\s*(\d+)[.)]\s+/; +const BOLD = /\*\*(.+?)\*\*/g; + +/** Applique le gras en ligne, en conservant le reste en texte brut. */ +function inline(text: string): React.ReactNode { + const parts = text.split(BOLD); + if (parts.length === 1) return text; + + // Les captures du split occupent les rangs impairs. + return parts.map((part, index) => + index % 2 === 1 ? ( + + {part} + + ) : ( + {part} + ) + ); +} + +type Block = + | { kind: 'lead'; text: string } + | { kind: 'paragraph'; lines: string[] } + | { kind: 'bullets'; items: string[] } + | { kind: 'numbers'; items: string[] }; + +function parse(answer: string): Block[] { + const blocks: Block[] = []; + + for (const chunk of answer.trim().split(/\n\s*\n/)) { + const lines = chunk + .split('\n') + .map(line => line.trim()) + .filter(Boolean); + if (!lines.length) continue; + + if (lines.every(line => BULLET.test(line))) { + blocks.push({ kind: 'bullets', items: lines.map(line => line.replace(BULLET, '')) }); + continue; + } + + if (lines.every(line => NUMBERED.test(line))) { + blocks.push({ kind: 'numbers', items: lines.map(line => line.replace(NUMBERED, '')) }); + continue; + } + + // Une liste suit souvent sa phrase d'introduction dans le meme paragraphe : + // on coupe au premier marqueur plutot que de rendre les puces en prose. + const start = lines.findIndex(line => BULLET.test(line) || NUMBERED.test(line)); + if (start > 0) { + blocks.push({ kind: 'paragraph', lines: lines.slice(0, start) }); + const rest = lines.slice(start); + const numbered = NUMBERED.test(rest[0]); + blocks.push({ + kind: numbered ? 'numbers' : 'bullets', + items: rest.map(line => line.replace(numbered ? NUMBERED : BULLET, '')), + }); + continue; + } + + // Une ligne courte terminee par deux points annonce ce qui suit : elle + // porte le rythme de lecture d'une reponse longue. + if (lines.length === 1 && lines[0].length <= 80 && lines[0].endsWith(':')) { + blocks.push({ kind: 'lead', text: lines[0] }); + continue; + } + + blocks.push({ kind: 'paragraph', lines }); + } + + return blocks; +} + +export function AnswerText({ answer }: { answer: string }) { + const blocks = React.useMemo(() => parse(answer), [answer]); + + return ( +

+ {blocks.map((block, index) => { + if (block.kind === 'lead') { + return ( +

+ {inline(block.text)} +

+ ); + } + + if (block.kind === 'bullets') { + return ( +
    + {block.items.map((item, i) => ( +
  • + {inline(item)} +
  • + ))} +
+ ); + } + + if (block.kind === 'numbers') { + return ( +
    + {block.items.map((item, i) => ( +
  1. + + {inline(item)} +
  2. + ))} +
+ ); + } + + return ( +

+ {block.lines.map((line, i) => ( + + {i > 0 &&
} + {inline(line)} +
+ ))} +

+ ); + })} +
+ ); +} diff --git a/apps/frontend/src/components/assistant/assistant-workspace.tsx b/apps/frontend/src/components/assistant/assistant-workspace.tsx new file mode 100644 index 0000000..d2f9141 --- /dev/null +++ b/apps/frontend/src/components/assistant/assistant-workspace.tsx @@ -0,0 +1,283 @@ +'use client'; + +import { useEffect, useMemo, useRef, useState } from 'react'; +import { useLocale, useTranslations } from 'next-intl'; +import { MessagesSquare, Plus } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Callout } from '@/components/ui/callout'; +import { Sheet, SheetContent, SheetTitle, SheetTrigger } from '@/components/ui/sheet'; +import { useConfirm } from '@/components/ui/use-confirm'; +import { Composer } from '@/components/assistant/composer'; +import { ConversationRail } from '@/components/assistant/conversation-rail'; +import { MessageList, PendingMessage } from '@/components/assistant/message-list'; +import { QuotaReached, type TopicKey } from '@/components/assistant/quota-reached'; +import { Starters, type StarterKey } from '@/components/assistant/starters'; +import { useTradeAssistant } from '@/hooks/use-trade-assistant'; + +const SUPPORT_EMAIL = 'support@xpeditis.com'; + +/** + * Ecran d'assistant, partage par l'espace produit et la console d'administration. + * + * Les deux espaces montrent le meme fil, le meme quota et le meme rail de + * conversations : ce qui change est le vocabulaire d'accueil. Les capacites + * disponibles, elles, ne dependent pas de la page mais du role — c'est le + * serveur qui filtre, pas l'interface. + */ +export interface AssistantWorkspaceProps { + /** Cles d'amorces a proposer sur une conversation vide. */ + starterKeys?: readonly StarterKey[]; + /** Titre de l'ecran d'accueil. Par defaut, celui de l'espace produit. */ + welcomeKey?: string; +} + +export function AssistantWorkspace({ + starterKeys, + welcomeKey = 'welcome', +}: AssistantWorkspaceProps = {}) { + const t = useTranslations('tradeAssistant'); + const locale = useLocale(); + const confirm = useConfirm(); + const { quota, conversations, messages, ask, rename, remove, conversationId, open } = + useTradeAssistant(locale === 'en' ? 'en' : 'fr'); + + const [question, setQuestion] = useState(''); + const [railOpen, setRailOpen] = useState(false); + + const field = useRef(null); + const viewport = useRef(null); + const foot = useRef(null); + + // Une offre illimitee renvoie `remaining: -1` : la tester comme un reste + // ferait passer Platinium pour un quota epuise en permanence. + const exhausted = Boolean(quota.data) && !quota.data?.unlimited && quota.data!.remaining <= 0; + const unavailable = Boolean(quota.data) && !quota.data?.available; + const blocked = exhausted || unavailable; + const locked = blocked || !quota.data || ask.isPending; + + const thread = messages.data ?? []; + // La question en cours est celle de la mutation : la dupliquer dans un etat + // local la ferait diverger de `isPending` au moindre echec. + const pending = ask.isPending ? ask.variables : undefined; + const empty = !thread.length && !pending; + + // Le fil suit son dernier element : une reponse qui arrive hors de l'ecran + // n'a pas l'air d'etre arrivee. + useEffect(() => { + const box = viewport.current; + const end = foot.current; + if (!box || !end) return; + + const top = Math.max(0, end.offsetTop - box.clientHeight + end.clientHeight + 24); + if (typeof box.scrollTo === 'function') box.scrollTo({ top, behavior: 'smooth' }); + else box.scrollTop = top; + }, [thread.length, pending, blocked]); + + const resetLabel = useMemo(() => { + if (!quota.data) return ''; + return t('reset', { + date: new Date(quota.data.resetsAt).toLocaleString(locale, { + timeZone: 'Europe/Paris', + day: 'numeric', + month: 'long', + hour: '2-digit', + minute: '2-digit', + }), + }); + }, [quota.data, locale, t]); + + async function submit() { + const text = question.trim(); + if (!text || locked) return; + + // La question quitte le champ des l'envoi : elle est deja portee par le fil. + // Elle y revient si l'envoi echoue, pour etre renvoyee sans la reecrire. + setQuestion(''); + try { + const reply = await ask.mutateAsync(text); + if (reply.mode !== 'ai') setQuestion(text); + } catch { + setQuestion(text); + } + } + + function startNew() { + open(null); + setQuestion(''); + setRailOpen(false); + field.current?.focus(); + } + + function openConversation(id: string) { + open(id); + setRailOpen(false); + } + + async function confirmDelete(id: string) { + const conversation = conversations.data?.find(item => item.id === id); + const confirmed = await confirm({ + title: t('deleteConfirm'), + description: t('deleteConfirmBody', { title: conversation?.title ?? '' }), + confirmLabel: t('delete'), + destructive: true, + }); + if (confirmed) remove.mutate(id); + } + + const railLabels = { + newConversation: t('newConversation'), + conversations: t('conversations'), + empty: t('noConversations'), + today: t('groups.today'), + lastWeek: t('groups.lastWeek'), + older: t('groups.older'), + rename: t('rename'), + delete: t('delete'), + }; + + const rail = ( + rename.mutate({ id, title })} + onDelete={confirmDelete} + /> + ); + + return ( +
+ {/* Le rail reste visible a partir de xl : sous cette largeur, la barre de + navigation de l'application occupe deja la colonne de gauche. */} + + +
+
+ + + + + + {t('conversations')} + {rail} + + + +

+ {conversations.data?.find(item => item.id === conversationId)?.title ?? t('title')} +

+ + {/* Le rail porte deja cette action des qu'il est visible : la + repeter dans l'en-tete ferait deux boutons identiques a l'ecran. */} + +
+ +
+ {empty && !blocked && ( +
+ t(`starters.${key}`)} + disabled={locked} + onPick={text => { + setQuestion(text); + field.current?.focus(); + }} + /> +
+ )} + + t(`capabilities.${name}` as never), + }} + /> + + {pending && ( + t(`capabilities.${name}` as never), + }} + /> + )} + + {blocked && ( + t(`topics.${key}.title`)} + topicAnswer={(key: TopicKey) => t(`topics.${key}.answer`)} + /> + )} + + {ask.isError && {t('error')}} + +
+
+ + +
+
+ ); +} diff --git a/apps/frontend/src/components/assistant/composer.tsx b/apps/frontend/src/components/assistant/composer.tsx new file mode 100644 index 0000000..14efd73 --- /dev/null +++ b/apps/frontend/src/components/assistant/composer.tsx @@ -0,0 +1,195 @@ +'use client'; + +import * as React from 'react'; +import { SendHorizontal } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Textarea } from '@/components/ui/textarea'; +import { cn } from '@/lib/utils'; +import type { TradeQuota } from '@/lib/api/trade-assistant'; + +/** Au-dela, le compteur devient une information utile plutot qu'un bruit. */ +const COUNTER_THRESHOLD = 1700; +const MAX_LENGTH = 2000; +const MAX_HEIGHT = 176; + +export interface ComposerProps { + value: string; + onChange: (value: string) => void; + onSubmit: () => void; + disabled: boolean; + pending: boolean; + quota?: TradeQuota; + quotaLoading: boolean; + inputRef?: React.RefObject; + labels: { + question: string; + placeholder: string; + send: string; + shortcut: string; + notice: string; + /** Phrase complete du quota, lue par les lecteurs d'ecran. */ + remaining: string; + reset: string; + unlimited: string; + }; +} + +export function Composer({ + value, + onChange, + onSubmit, + disabled, + pending, + quota, + quotaLoading, + inputRef, + labels, +}: ComposerProps) { + const fallback = React.useRef(null); + const field = inputRef ?? fallback; + + // Le champ suit la question au lieu de la faire defiler dans deux lignes + // fixes, tout en gardant une hauteur bornee pour ne pas manger le fil. + React.useEffect(() => { + const node = field.current; + if (!node) return; + node.style.height = 'auto'; + node.style.height = `${Math.min(node.scrollHeight, MAX_HEIGHT)}px`; + }, [value, field]); + + const empty = !value.trim(); + + function handleKeyDown(event: React.KeyboardEvent) { + if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { + event.preventDefault(); + onSubmit(); + } + } + + return ( +
+
(event.preventDefault(), onSubmit())}> + +