소스 검색

Add in-app support requests (#203)

* add support

* file size

* files
Bernt Christian Egeland 2 달 전
부모
커밋
24eeb64aa9
41개의 변경된 파일과 2153개의 추가작업 그리고 2개의 파일을 삭제
  1. 13 0
      messages/de/settings.json
  2. 33 0
      messages/de/support.json
  3. 13 0
      messages/en/settings.json
  4. 33 0
      messages/en/support.json
  5. 13 0
      messages/es/settings.json
  6. 33 0
      messages/es/support.json
  7. 13 0
      messages/fr/settings.json
  8. 33 0
      messages/fr/support.json
  9. 13 0
      messages/it/settings.json
  10. 33 0
      messages/it/support.json
  11. 13 0
      messages/lt/settings.json
  12. 33 0
      messages/lt/support.json
  13. 13 0
      messages/nb/settings.json
  14. 33 0
      messages/nb/support.json
  15. 13 0
      messages/nl/settings.json
  16. 33 0
      messages/nl/support.json
  17. 13 0
      messages/pl/settings.json
  18. 33 0
      messages/pl/support.json
  19. 13 0
      messages/pt-BR/settings.json
  20. 33 0
      messages/pt-BR/support.json
  21. 13 0
      messages/ru/settings.json
  22. 33 0
      messages/ru/support.json
  23. 13 0
      messages/tr/settings.json
  24. 33 0
      messages/tr/support.json
  25. 10 0
      package-lock.json
  26. 1 0
      package.json
  27. 291 0
      src/__tests__/features/support/support-request.test.ts
  28. 95 0
      src/__tests__/lib/support.test.ts
  29. 6 0
      src/app/(authenticated)/layout.tsx
  30. 4 2
      src/app/(authenticated)/settings/layout.tsx
  31. 10 0
      src/app/(authenticated)/settings/settings-nav.tsx
  32. 34 0
      src/app/(authenticated)/settings/support/page.tsx
  33. 139 0
      src/app/api/protected/support/route.ts
  34. 6 0
      src/features/admin/Schema/systemSettingsSchema.ts
  35. 448 0
      src/features/support/Components/SupportBubble.tsx
  36. 84 0
      src/features/support/Components/SupportSettingsControls.tsx
  37. 202 0
      src/features/support/Lib/supportRequest.ts
  38. 82 0
      src/features/support/Lib/supportVisibility.ts
  39. 146 0
      src/features/support/Lib/useDraggablePosition.ts
  40. 2 0
      src/i18n/request.ts
  41. 41 0
      src/lib/support.ts

+ 13 - 0
messages/de/settings.json

@@ -114,6 +114,10 @@
       "alerts": {
       "alerts": {
         "title": "Warnungen",
         "title": "Warnungen",
         "description": "Benachrichtigung bei Nachbestellbedarf"
         "description": "Benachrichtigung bei Nachbestellbedarf"
+      },
+      "support": {
+        "title": "Support",
+        "description": "Support kontaktieren und die Support-Schaltfläche verwalten"
       }
       }
     },
     },
     "pro": "PRO"
     "pro": "PRO"
@@ -1370,5 +1374,14 @@
       "configurePerPart": "Mindestmenge eines Teils auf der Lagerseite festlegen →",
       "configurePerPart": "Mindestmenge eines Teils auf der Lagerseite festlegen →",
       "readMore": "Mehr erfahren"
       "readMore": "Mehr erfahren"
     }
     }
+  },
+  "support": {
+    "title": "Support",
+    "description": "Erreichen Sie das Torqvoice-Supportteam, ohne die App zu verlassen. Ihre Anfrage enthält Ihre Organisation und die Seite, auf der Sie waren.",
+    "showButton": "Support-Schaltfläche anzeigen",
+    "showButtonHint": "Eine verschiebbare Schaltfläche auf jeder Seite, auf diesem Gerät. Wenn sie ausgeblendet ist, holt sie ein kleiner Reiter am rechten Rand zurück.",
+    "contactTitle": "Support kontaktieren",
+    "contactHint": "Öffnet das Supportformular direkt.",
+    "contactAction": "Formular öffnen"
   }
   }
 }
 }

+ 33 - 0
messages/de/support.json

@@ -0,0 +1,33 @@
+{
+  "title": "Supportanfrage",
+  "open": "Support kontaktieren",
+  "close": "Schließen",
+  "subjectLabel": "Betreff",
+  "subjectPlaceholder": "Kurze Zusammenfassung des Problems",
+  "messageLabel": "Beschreibung",
+  "messagePlaceholder": "Was ist passiert und was haben Sie stattdessen erwartet?",
+  "takeScreenshot": "Screenshot aufnehmen",
+  "addFiles": "Dateien hinzufügen",
+  "removeAttachment": "Anhang entfernen",
+  "attachmentSummary": "{count} von {max} Anhängen · {size} MB",
+  "send": "Anfrage senden",
+  "sending": "Wird gesendet…",
+  "sentTitle": "Anfrage gesendet",
+  "sentDescription": "Wir antworten an die E-Mail-Adresse Ihres Kontos.",
+  "sendAnother": "Weitere senden",
+  "hideButton": "Support-Schaltfläche ausblenden",
+  "dragHint": "Zum Verschieben ziehen",
+  "move": "Verschieben",
+  "restore": "Support-Schaltfläche anzeigen",
+  "errors": {
+    "sendFailed": "Die Anfrage konnte nicht gesendet werden. Bitte erneut versuchen.",
+    "screenshotFailed": "Der Screenshot konnte nicht aufgenommen werden.",
+    "subject-required": "Bitte einen Betreff angeben.",
+    "subject-too-long": "Der Betreff ist zu lang.",
+    "message-required": "Bitte eine Beschreibung angeben.",
+    "message-too-long": "Die Beschreibung ist zu lang.",
+    "too-many-attachments": "Sie können bis zu {max} Dateien anhängen.",
+    "attachment-type-not-allowed": "Dieser Dateityp kann nicht angehängt werden.",
+    "attachments-too-large": "Die Anhänge sind zu groß. Das Limit beträgt insgesamt 10 MB."
+  }
+}

+ 13 - 0
messages/en/settings.json

@@ -114,6 +114,10 @@
       "alerts": {
       "alerts": {
         "title": "Alerts",
         "title": "Alerts",
         "description": "Get told when parts need reordering"
         "description": "Get told when parts need reordering"
+      },
+      "support": {
+        "title": "Support",
+        "description": "Contact support and manage the support button"
       }
       }
     },
     },
     "pro": "PRO"
     "pro": "PRO"
@@ -1370,5 +1374,14 @@
       "configurePerPart": "Set a part's own Min Qty on the inventory page →",
       "configurePerPart": "Set a part's own Min Qty on the inventory page →",
       "readMore": "Read More"
       "readMore": "Read More"
     }
     }
+  },
+  "support": {
+    "title": "Support",
+    "description": "Reach the Torqvoice support team without leaving the app. Your request includes your organisation and the page you were on.",
+    "showButton": "Show the support button",
+    "showButtonHint": "A draggable button on every page, on this device. When hidden, a small tab on the right edge brings it back.",
+    "contactTitle": "Contact support",
+    "contactHint": "Open the support form straight away.",
+    "contactAction": "Open form"
   }
   }
 }
 }

+ 33 - 0
messages/en/support.json

@@ -0,0 +1,33 @@
+{
+  "title": "Support request",
+  "open": "Contact support",
+  "close": "Close",
+  "subjectLabel": "Subject",
+  "subjectPlaceholder": "Short summary of the problem",
+  "messageLabel": "Description",
+  "messagePlaceholder": "What happened, and what did you expect instead?",
+  "takeScreenshot": "Take Screenshot",
+  "addFiles": "Add Files",
+  "removeAttachment": "Remove attachment",
+  "attachmentSummary": "{count} of {max} attachments · {size} MB",
+  "send": "Send request",
+  "sending": "Sending…",
+  "sentTitle": "Request sent",
+  "sentDescription": "We will reply to the email address on your account.",
+  "sendAnother": "Send another",
+  "hideButton": "Hide the support button",
+  "dragHint": "Drag to move",
+  "move": "Move",
+  "restore": "Show support button",
+  "errors": {
+    "sendFailed": "The request could not be sent. Please try again.",
+    "screenshotFailed": "The screenshot could not be captured.",
+    "subject-required": "Add a subject.",
+    "subject-too-long": "The subject is too long.",
+    "message-required": "Add a description.",
+    "message-too-long": "The description is too long.",
+    "too-many-attachments": "You can attach up to {max} files.",
+    "attachment-type-not-allowed": "That file type cannot be attached.",
+    "attachments-too-large": "The attachments are too large. The limit is 10 MB in total."
+  }
+}

+ 13 - 0
messages/es/settings.json

@@ -114,6 +114,10 @@
       "alerts": {
       "alerts": {
         "title": "Alertas",
         "title": "Alertas",
         "description": "Avisos cuando haya que reponer piezas"
         "description": "Avisos cuando haya que reponer piezas"
+      },
+      "support": {
+        "title": "Soporte",
+        "description": "Contactar con soporte y gestionar el botón"
       }
       }
     },
     },
     "pro": "PRO"
     "pro": "PRO"
@@ -1370,5 +1374,14 @@
       "configurePerPart": "Defina la cantidad mínima de una pieza en la página de inventario →",
       "configurePerPart": "Defina la cantidad mínima de una pieza en la página de inventario →",
       "readMore": "Leer más"
       "readMore": "Leer más"
     }
     }
+  },
+  "support": {
+    "title": "Soporte",
+    "description": "Contacte con el equipo de soporte de Torqvoice sin salir de la aplicación. Su solicitud incluye su organización y la página en la que estaba.",
+    "showButton": "Mostrar el botón de soporte",
+    "showButtonHint": "Un botón que puede arrastrar, en todas las páginas de este dispositivo. Cuando está oculto, una pequeña pestaña en el borde derecho lo recupera.",
+    "contactTitle": "Contactar con soporte",
+    "contactHint": "Abrir el formulario de soporte directamente.",
+    "contactAction": "Abrir formulario"
   }
   }
 }
 }

+ 33 - 0
messages/es/support.json

@@ -0,0 +1,33 @@
+{
+  "title": "Solicitud de soporte",
+  "open": "Contactar con soporte",
+  "close": "Cerrar",
+  "subjectLabel": "Asunto",
+  "subjectPlaceholder": "Breve resumen del problema",
+  "messageLabel": "Descripción",
+  "messagePlaceholder": "¿Qué ocurrió y qué esperaba en su lugar?",
+  "takeScreenshot": "Capturar pantalla",
+  "addFiles": "Añadir archivos",
+  "removeAttachment": "Quitar adjunto",
+  "attachmentSummary": "{count} de {max} adjuntos · {size} MB",
+  "send": "Enviar solicitud",
+  "sending": "Enviando…",
+  "sentTitle": "Solicitud enviada",
+  "sentDescription": "Responderemos a la dirección de correo de su cuenta.",
+  "sendAnother": "Enviar otra",
+  "hideButton": "Ocultar el botón de soporte",
+  "dragHint": "Arrastre para mover",
+  "move": "Mover",
+  "restore": "Mostrar el botón de soporte",
+  "errors": {
+    "sendFailed": "No se pudo enviar la solicitud. Inténtelo de nuevo.",
+    "screenshotFailed": "No se pudo capturar la pantalla.",
+    "subject-required": "Añada un asunto.",
+    "subject-too-long": "El asunto es demasiado largo.",
+    "message-required": "Añada una descripción.",
+    "message-too-long": "La descripción es demasiado larga.",
+    "too-many-attachments": "Puede adjuntar hasta {max} archivos.",
+    "attachment-type-not-allowed": "Ese tipo de archivo no se puede adjuntar.",
+    "attachments-too-large": "Los adjuntos son demasiado grandes. El límite es de 10 MB en total."
+  }
+}

+ 13 - 0
messages/fr/settings.json

@@ -114,6 +114,10 @@
       "alerts": {
       "alerts": {
         "title": "Alertes",
         "title": "Alertes",
         "description": "Être averti quand il faut réapprovisionner"
         "description": "Être averti quand il faut réapprovisionner"
+      },
+      "support": {
+        "title": "Assistance",
+        "description": "Contacter l'assistance et gérer le bouton"
       }
       }
     },
     },
     "pro": "PRO"
     "pro": "PRO"
@@ -1370,5 +1374,14 @@
       "configurePerPart": "Définir la Qté min d'une pièce sur la page inventaire →",
       "configurePerPart": "Définir la Qté min d'une pièce sur la page inventaire →",
       "readMore": "En savoir plus"
       "readMore": "En savoir plus"
     }
     }
+  },
+  "support": {
+    "title": "Assistance",
+    "description": "Contactez l'équipe d'assistance Torqvoice sans quitter l'application. Votre demande inclut votre organisation et la page consultée.",
+    "showButton": "Afficher le bouton d'assistance",
+    "showButtonHint": "Un bouton déplaçable sur chaque page, sur cet appareil. Une fois masqué, un petit onglet sur le bord droit le fait revenir.",
+    "contactTitle": "Contacter l'assistance",
+    "contactHint": "Ouvrir directement le formulaire d'assistance.",
+    "contactAction": "Ouvrir le formulaire"
   }
   }
 }
 }

+ 33 - 0
messages/fr/support.json

@@ -0,0 +1,33 @@
+{
+  "title": "Demande d'assistance",
+  "open": "Contacter l'assistance",
+  "close": "Fermer",
+  "subjectLabel": "Objet",
+  "subjectPlaceholder": "Bref résumé du problème",
+  "messageLabel": "Description",
+  "messagePlaceholder": "Que s'est-il passé et qu'attendiez-vous à la place ?",
+  "takeScreenshot": "Faire une capture",
+  "addFiles": "Ajouter des fichiers",
+  "removeAttachment": "Retirer la pièce jointe",
+  "attachmentSummary": "{count} sur {max} pièces jointes · {size} Mo",
+  "send": "Envoyer la demande",
+  "sending": "Envoi…",
+  "sentTitle": "Demande envoyée",
+  "sentDescription": "Nous répondrons à l'adresse e-mail de votre compte.",
+  "sendAnother": "En envoyer une autre",
+  "hideButton": "Masquer le bouton d'assistance",
+  "dragHint": "Faites glisser pour déplacer",
+  "move": "Déplacer",
+  "restore": "Afficher le bouton d'assistance",
+  "errors": {
+    "sendFailed": "La demande n'a pas pu être envoyée. Veuillez réessayer.",
+    "screenshotFailed": "La capture d'écran n'a pas pu être réalisée.",
+    "subject-required": "Ajoutez un objet.",
+    "subject-too-long": "L'objet est trop long.",
+    "message-required": "Ajoutez une description.",
+    "message-too-long": "La description est trop longue.",
+    "too-many-attachments": "Vous pouvez joindre jusqu'à {max} fichiers.",
+    "attachment-type-not-allowed": "Ce type de fichier ne peut pas être joint.",
+    "attachments-too-large": "Les pièces jointes sont trop volumineuses. La limite est de 10 Mo au total."
+  }
+}

+ 13 - 0
messages/it/settings.json

@@ -114,6 +114,10 @@
       "alerts": {
       "alerts": {
         "title": "Avvisi",
         "title": "Avvisi",
         "description": "Segnalazione quando riordinare"
         "description": "Segnalazione quando riordinare"
+      },
+      "support": {
+        "title": "Assistenza",
+        "description": "Contatta l'assistenza e gestisci il pulsante"
       }
       }
     },
     },
     "pro": "PRO"
     "pro": "PRO"
@@ -1370,5 +1374,14 @@
       "configurePerPart": "Imposta la Qtà min di un ricambio nella pagina magazzino →",
       "configurePerPart": "Imposta la Qtà min di un ricambio nella pagina magazzino →",
       "readMore": "Scopri di più"
       "readMore": "Scopri di più"
     }
     }
+  },
+  "support": {
+    "title": "Assistenza",
+    "description": "Contatta il team di assistenza Torqvoice senza uscire dall'app. La richiesta include la tua organizzazione e la pagina in cui ti trovavi.",
+    "showButton": "Mostra il pulsante di assistenza",
+    "showButtonHint": "Un pulsante trascinabile su ogni pagina, su questo dispositivo. Quando è nascosto, una piccola linguetta sul bordo destro lo riporta.",
+    "contactTitle": "Contatta l'assistenza",
+    "contactHint": "Apri subito il modulo di assistenza.",
+    "contactAction": "Apri il modulo"
   }
   }
 }
 }

+ 33 - 0
messages/it/support.json

@@ -0,0 +1,33 @@
+{
+  "title": "Richiesta di assistenza",
+  "open": "Contatta l'assistenza",
+  "close": "Chiudi",
+  "subjectLabel": "Oggetto",
+  "subjectPlaceholder": "Breve riepilogo del problema",
+  "messageLabel": "Descrizione",
+  "messagePlaceholder": "Cosa è successo e cosa ti aspettavi invece?",
+  "takeScreenshot": "Cattura schermata",
+  "addFiles": "Aggiungi file",
+  "removeAttachment": "Rimuovi allegato",
+  "attachmentSummary": "{count} di {max} allegati · {size} MB",
+  "send": "Invia richiesta",
+  "sending": "Invio…",
+  "sentTitle": "Richiesta inviata",
+  "sentDescription": "Risponderemo all'indirizzo e-mail del tuo account.",
+  "sendAnother": "Invia un'altra",
+  "hideButton": "Nascondi il pulsante di assistenza",
+  "dragHint": "Trascina per spostare",
+  "move": "Sposta",
+  "restore": "Mostra il pulsante di assistenza",
+  "errors": {
+    "sendFailed": "Impossibile inviare la richiesta. Riprova.",
+    "screenshotFailed": "Impossibile catturare la schermata.",
+    "subject-required": "Aggiungi un oggetto.",
+    "subject-too-long": "L'oggetto è troppo lungo.",
+    "message-required": "Aggiungi una descrizione.",
+    "message-too-long": "La descrizione è troppo lunga.",
+    "too-many-attachments": "Puoi allegare fino a {max} file.",
+    "attachment-type-not-allowed": "Questo tipo di file non può essere allegato.",
+    "attachments-too-large": "Gli allegati sono troppo grandi. Il limite è di 10 MB in totale."
+  }
+}

+ 13 - 0
messages/lt/settings.json

@@ -114,6 +114,10 @@
       "alerts": {
       "alerts": {
         "title": "Įspėjimai",
         "title": "Įspėjimai",
         "description": "Pranešimai, kada užsakyti dalis"
         "description": "Pranešimai, kada užsakyti dalis"
+      },
+      "support": {
+        "title": "Pagalba",
+        "description": "Susisiekti su pagalba ir tvarkyti mygtuką"
       }
       }
     },
     },
     "pro": "PRO"
     "pro": "PRO"
@@ -1370,5 +1374,14 @@
       "configurePerPart": "Nustatykite dalies min. kiekį sandėlio puslapyje →",
       "configurePerPart": "Nustatykite dalies min. kiekį sandėlio puslapyje →",
       "readMore": "Skaityti daugiau"
       "readMore": "Skaityti daugiau"
     }
     }
+  },
+  "support": {
+    "title": "Pagalba",
+    "description": "Susisiekite su Torqvoice pagalbos komanda neišeidami iš programos. Užklausoje nurodoma jūsų organizacija ir puslapis, kuriame buvote.",
+    "showButton": "Rodyti pagalbos mygtuką",
+    "showButtonHint": "Velkamas mygtukas kiekviename puslapyje šiame įrenginyje. Kai paslėptas, jį grąžina maža kortelė dešiniajame krašte.",
+    "contactTitle": "Susisiekti su pagalba",
+    "contactHint": "Iš karto atidaryti pagalbos formą.",
+    "contactAction": "Atidaryti formą"
   }
   }
 }
 }

+ 33 - 0
messages/lt/support.json

@@ -0,0 +1,33 @@
+{
+  "title": "Pagalbos užklausa",
+  "open": "Susisiekti su pagalba",
+  "close": "Uždaryti",
+  "subjectLabel": "Tema",
+  "subjectPlaceholder": "Trumpa problemos santrauka",
+  "messageLabel": "Aprašymas",
+  "messagePlaceholder": "Kas nutiko ir ko tikėjotės vietoje to?",
+  "takeScreenshot": "Padaryti ekrano nuotrauką",
+  "addFiles": "Pridėti failų",
+  "removeAttachment": "Pašalinti priedą",
+  "attachmentSummary": "{count} iš {max} priedų · {size} MB",
+  "send": "Siųsti užklausą",
+  "sending": "Siunčiama…",
+  "sentTitle": "Užklausa išsiųsta",
+  "sentDescription": "Atsakysime jūsų paskyros el. pašto adresu.",
+  "sendAnother": "Siųsti dar vieną",
+  "hideButton": "Slėpti pagalbos mygtuką",
+  "dragHint": "Vilkite, kad perkeltumėte",
+  "move": "Perkelti",
+  "restore": "Rodyti pagalbos mygtuką",
+  "errors": {
+    "sendFailed": "Užklausos išsiųsti nepavyko. Bandykite dar kartą.",
+    "screenshotFailed": "Ekrano nuotraukos padaryti nepavyko.",
+    "subject-required": "Įrašykite temą.",
+    "subject-too-long": "Tema per ilga.",
+    "message-required": "Įrašykite aprašymą.",
+    "message-too-long": "Aprašymas per ilgas.",
+    "too-many-attachments": "Galite pridėti iki {max} failų.",
+    "attachment-type-not-allowed": "Tokio tipo failo pridėti negalima.",
+    "attachments-too-large": "Priedai per dideli. Bendra riba – 10 MB."
+  }
+}

+ 13 - 0
messages/nb/settings.json

@@ -114,6 +114,10 @@
       "alerts": {
       "alerts": {
         "title": "Varsler",
         "title": "Varsler",
         "description": "Få beskjed når deler må bestilles"
         "description": "Få beskjed når deler må bestilles"
+      },
+      "support": {
+        "title": "Support",
+        "description": "Kontakt support og styr supportknappen"
       }
       }
     },
     },
     "pro": "PRO"
     "pro": "PRO"
@@ -1370,5 +1374,14 @@
       "configurePerPart": "Sett delens min. antall på lagersiden →",
       "configurePerPart": "Sett delens min. antall på lagersiden →",
       "readMore": "Les mer"
       "readMore": "Les mer"
     }
     }
+  },
+  "support": {
+    "title": "Support",
+    "description": "Nå Torqvoice-supporten uten å forlate appen. Henvendelsen inkluderer organisasjonen din og siden du var på.",
+    "showButton": "Vis supportknappen",
+    "showButtonHint": "En flyttbar knapp på alle sider, på denne enheten. Når den er skjult, henter en liten fane i høyre kant den tilbake.",
+    "contactTitle": "Kontakt support",
+    "contactHint": "Åpne supportskjemaet med en gang.",
+    "contactAction": "Åpne skjema"
   }
   }
 }
 }

+ 33 - 0
messages/nb/support.json

@@ -0,0 +1,33 @@
+{
+  "title": "Supporthenvendelse",
+  "open": "Kontakt support",
+  "close": "Lukk",
+  "subjectLabel": "Emne",
+  "subjectPlaceholder": "Kort oppsummering av problemet",
+  "messageLabel": "Beskrivelse",
+  "messagePlaceholder": "Hva skjedde, og hva forventet du i stedet?",
+  "takeScreenshot": "Ta skjermbilde",
+  "addFiles": "Legg ved filer",
+  "removeAttachment": "Fjern vedlegg",
+  "attachmentSummary": "{count} av {max} vedlegg · {size} MB",
+  "send": "Send henvendelse",
+  "sending": "Sender…",
+  "sentTitle": "Henvendelsen er sendt",
+  "sentDescription": "Vi svarer til e-postadressen på kontoen din.",
+  "sendAnother": "Send en til",
+  "hideButton": "Skjul supportknappen",
+  "dragHint": "Dra for å flytte",
+  "move": "Flytt",
+  "restore": "Vis supportknappen",
+  "errors": {
+    "sendFailed": "Henvendelsen kunne ikke sendes. Prøv igjen.",
+    "screenshotFailed": "Skjermbildet kunne ikke tas.",
+    "subject-required": "Legg til et emne.",
+    "subject-too-long": "Emnet er for langt.",
+    "message-required": "Legg til en beskrivelse.",
+    "message-too-long": "Beskrivelsen er for lang.",
+    "too-many-attachments": "Du kan legge ved inntil {max} filer.",
+    "attachment-type-not-allowed": "Denne filtypen kan ikke legges ved.",
+    "attachments-too-large": "Vedleggene er for store. Grensen er 10 MB totalt."
+  }
+}

+ 13 - 0
messages/nl/settings.json

@@ -114,6 +114,10 @@
       "alerts": {
       "alerts": {
         "title": "Meldingen",
         "title": "Meldingen",
         "description": "Bericht wanneer onderdelen bijbesteld moeten worden"
         "description": "Bericht wanneer onderdelen bijbesteld moeten worden"
+      },
+      "support": {
+        "title": "Support",
+        "description": "Contact opnemen met support en de knop beheren"
       }
       }
     },
     },
     "pro": "PRO"
     "pro": "PRO"
@@ -1370,5 +1374,14 @@
       "configurePerPart": "Stel het min. aantal van een onderdeel in op de voorraadpagina →",
       "configurePerPart": "Stel het min. aantal van een onderdeel in op de voorraadpagina →",
       "readMore": "Lees meer"
       "readMore": "Lees meer"
     }
     }
+  },
+  "support": {
+    "title": "Support",
+    "description": "Bereik het Torqvoice-supportteam zonder de app te verlaten. Uw verzoek bevat uw organisatie en de pagina waarop u was.",
+    "showButton": "Supportknop tonen",
+    "showButtonHint": "Een versleepbare knop op elke pagina, op dit apparaat. Wanneer verborgen, haalt een klein tabblad aan de rechterrand hem terug.",
+    "contactTitle": "Contact met support",
+    "contactHint": "Open het supportformulier meteen.",
+    "contactAction": "Formulier openen"
   }
   }
 }
 }

+ 33 - 0
messages/nl/support.json

@@ -0,0 +1,33 @@
+{
+  "title": "Supportverzoek",
+  "open": "Contact met support",
+  "close": "Sluiten",
+  "subjectLabel": "Onderwerp",
+  "subjectPlaceholder": "Korte samenvatting van het probleem",
+  "messageLabel": "Omschrijving",
+  "messagePlaceholder": "Wat gebeurde er en wat verwachtte u in plaats daarvan?",
+  "takeScreenshot": "Schermafbeelding maken",
+  "addFiles": "Bestanden toevoegen",
+  "removeAttachment": "Bijlage verwijderen",
+  "attachmentSummary": "{count} van {max} bijlagen · {size} MB",
+  "send": "Verzoek versturen",
+  "sending": "Versturen…",
+  "sentTitle": "Verzoek verstuurd",
+  "sentDescription": "We reageren op het e-mailadres van uw account.",
+  "sendAnother": "Nog een versturen",
+  "hideButton": "Supportknop verbergen",
+  "dragHint": "Sleep om te verplaatsen",
+  "move": "Verplaatsen",
+  "restore": "Supportknop tonen",
+  "errors": {
+    "sendFailed": "Het verzoek kon niet worden verstuurd. Probeer het opnieuw.",
+    "screenshotFailed": "De schermafbeelding kon niet worden gemaakt.",
+    "subject-required": "Voeg een onderwerp toe.",
+    "subject-too-long": "Het onderwerp is te lang.",
+    "message-required": "Voeg een omschrijving toe.",
+    "message-too-long": "De omschrijving is te lang.",
+    "too-many-attachments": "U kunt maximaal {max} bestanden bijvoegen.",
+    "attachment-type-not-allowed": "Dit bestandstype kan niet worden bijgevoegd.",
+    "attachments-too-large": "De bijlagen zijn te groot. De limiet is in totaal 10 MB."
+  }
+}

+ 13 - 0
messages/pl/settings.json

@@ -114,6 +114,10 @@
       "alerts": {
       "alerts": {
         "title": "Alerty",
         "title": "Alerty",
         "description": "Powiadomienia o konieczności zamówienia części"
         "description": "Powiadomienia o konieczności zamówienia części"
+      },
+      "support": {
+        "title": "Pomoc",
+        "description": "Skontaktuj się z pomocą i zarządzaj przyciskiem"
       }
       }
     },
     },
     "pro": "PRO"
     "pro": "PRO"
@@ -1370,5 +1374,14 @@
       "configurePerPart": "Ustaw min. ilość części na stronie magazynu →",
       "configurePerPart": "Ustaw min. ilość części na stronie magazynu →",
       "readMore": "Czytaj więcej"
       "readMore": "Czytaj więcej"
     }
     }
+  },
+  "support": {
+    "title": "Pomoc",
+    "description": "Skontaktuj się z zespołem pomocy Torqvoice bez opuszczania aplikacji. Zgłoszenie zawiera Twoją organizację i stronę, na której byłeś.",
+    "showButton": "Pokaż przycisk pomocy",
+    "showButtonHint": "Przeciągalny przycisk na każdej stronie, na tym urządzeniu. Po ukryciu przywraca go mała zakładka przy prawej krawędzi.",
+    "contactTitle": "Skontaktuj się z pomocą",
+    "contactHint": "Otwórz formularz pomocy od razu.",
+    "contactAction": "Otwórz formularz"
   }
   }
 }
 }

+ 33 - 0
messages/pl/support.json

@@ -0,0 +1,33 @@
+{
+  "title": "Zgłoszenie do pomocy",
+  "open": "Skontaktuj się z pomocą",
+  "close": "Zamknij",
+  "subjectLabel": "Temat",
+  "subjectPlaceholder": "Krótkie podsumowanie problemu",
+  "messageLabel": "Opis",
+  "messagePlaceholder": "Co się stało i czego oczekiwano zamiast tego?",
+  "takeScreenshot": "Zrób zrzut ekranu",
+  "addFiles": "Dodaj pliki",
+  "removeAttachment": "Usuń załącznik",
+  "attachmentSummary": "{count} z {max} załączników · {size} MB",
+  "send": "Wyślij zgłoszenie",
+  "sending": "Wysyłanie…",
+  "sentTitle": "Zgłoszenie wysłane",
+  "sentDescription": "Odpowiemy na adres e-mail przypisany do konta.",
+  "sendAnother": "Wyślij kolejne",
+  "hideButton": "Ukryj przycisk pomocy",
+  "dragHint": "Przeciągnij, aby przenieść",
+  "move": "Przenieś",
+  "restore": "Pokaż przycisk pomocy",
+  "errors": {
+    "sendFailed": "Nie udało się wysłać zgłoszenia. Spróbuj ponownie.",
+    "screenshotFailed": "Nie udało się zrobić zrzutu ekranu.",
+    "subject-required": "Dodaj temat.",
+    "subject-too-long": "Temat jest za długi.",
+    "message-required": "Dodaj opis.",
+    "message-too-long": "Opis jest za długi.",
+    "too-many-attachments": "Możesz dołączyć maksymalnie {max} plików.",
+    "attachment-type-not-allowed": "Tego typu pliku nie można dołączyć.",
+    "attachments-too-large": "Załączniki są za duże. Limit to łącznie 10 MB."
+  }
+}

+ 13 - 0
messages/pt-BR/settings.json

@@ -114,6 +114,10 @@
       "alerts": {
       "alerts": {
         "title": "Alertas",
         "title": "Alertas",
         "description": "Aviso quando peças precisam ser repostas"
         "description": "Aviso quando peças precisam ser repostas"
+      },
+      "support": {
+        "title": "Suporte",
+        "description": "Falar com o suporte e gerenciar o botão"
       }
       }
     },
     },
     "pro": "PRO"
     "pro": "PRO"
@@ -1370,5 +1374,14 @@
       "configurePerPart": "Defina a Qtd. mín. de uma peça na página de estoque →",
       "configurePerPart": "Defina a Qtd. mín. de uma peça na página de estoque →",
       "readMore": "Saiba mais"
       "readMore": "Saiba mais"
     }
     }
+  },
+  "support": {
+    "title": "Suporte",
+    "description": "Fale com a equipe de suporte da Torqvoice sem sair do aplicativo. Sua solicitação inclui sua organização e a página em que você estava.",
+    "showButton": "Mostrar o botão de suporte",
+    "showButtonHint": "Um botão arrastável em todas as páginas, neste dispositivo. Quando oculto, uma pequena aba na borda direita o traz de volta.",
+    "contactTitle": "Falar com o suporte",
+    "contactHint": "Abrir o formulário de suporte imediatamente.",
+    "contactAction": "Abrir formulário"
   }
   }
 }
 }

+ 33 - 0
messages/pt-BR/support.json

@@ -0,0 +1,33 @@
+{
+  "title": "Solicitação de suporte",
+  "open": "Falar com o suporte",
+  "close": "Fechar",
+  "subjectLabel": "Assunto",
+  "subjectPlaceholder": "Breve resumo do problema",
+  "messageLabel": "Descrição",
+  "messagePlaceholder": "O que aconteceu e o que você esperava?",
+  "takeScreenshot": "Capturar tela",
+  "addFiles": "Adicionar arquivos",
+  "removeAttachment": "Remover anexo",
+  "attachmentSummary": "{count} de {max} anexos · {size} MB",
+  "send": "Enviar solicitação",
+  "sending": "Enviando…",
+  "sentTitle": "Solicitação enviada",
+  "sentDescription": "Responderemos no e-mail cadastrado na sua conta.",
+  "sendAnother": "Enviar outra",
+  "hideButton": "Ocultar o botão de suporte",
+  "dragHint": "Arraste para mover",
+  "move": "Mover",
+  "restore": "Mostrar o botão de suporte",
+  "errors": {
+    "sendFailed": "Não foi possível enviar a solicitação. Tente novamente.",
+    "screenshotFailed": "Não foi possível capturar a tela.",
+    "subject-required": "Adicione um assunto.",
+    "subject-too-long": "O assunto é muito longo.",
+    "message-required": "Adicione uma descrição.",
+    "message-too-long": "A descrição é muito longa.",
+    "too-many-attachments": "Você pode anexar até {max} arquivos.",
+    "attachment-type-not-allowed": "Esse tipo de arquivo não pode ser anexado.",
+    "attachments-too-large": "Os anexos são muito grandes. O limite é de 10 MB no total."
+  }
+}

+ 13 - 0
messages/ru/settings.json

@@ -114,6 +114,10 @@
       "alerts": {
       "alerts": {
         "title": "Оповещения",
         "title": "Оповещения",
         "description": "Уведомления, когда пора заказывать детали"
         "description": "Уведомления, когда пора заказывать детали"
+      },
+      "support": {
+        "title": "Поддержка",
+        "description": "Связаться с поддержкой и настроить кнопку"
       }
       }
     },
     },
     "pro": "PRO"
     "pro": "PRO"
@@ -1370,5 +1374,14 @@
       "configurePerPart": "Задайте мин. количество детали на странице склада →",
       "configurePerPart": "Задайте мин. количество детали на странице склада →",
       "readMore": "Подробнее"
       "readMore": "Подробнее"
     }
     }
+  },
+  "support": {
+    "title": "Поддержка",
+    "description": "Обратитесь в службу поддержки Torqvoice, не покидая приложение. В запрос попадут ваша организация и страница, на которой вы находились.",
+    "showButton": "Показывать кнопку поддержки",
+    "showButtonHint": "Перетаскиваемая кнопка на каждой странице этого устройства. Когда она скрыта, вернуть её можно небольшой вкладкой у правого края.",
+    "contactTitle": "Связаться с поддержкой",
+    "contactHint": "Открыть форму поддержки сразу.",
+    "contactAction": "Открыть форму"
   }
   }
 }
 }

+ 33 - 0
messages/ru/support.json

@@ -0,0 +1,33 @@
+{
+  "title": "Запрос в поддержку",
+  "open": "Связаться с поддержкой",
+  "close": "Закрыть",
+  "subjectLabel": "Тема",
+  "subjectPlaceholder": "Кратко опишите проблему",
+  "messageLabel": "Описание",
+  "messagePlaceholder": "Что произошло и что вы ожидали вместо этого?",
+  "takeScreenshot": "Сделать снимок экрана",
+  "addFiles": "Добавить файлы",
+  "removeAttachment": "Удалить вложение",
+  "attachmentSummary": "{count} из {max} вложений · {size} МБ",
+  "send": "Отправить запрос",
+  "sending": "Отправка…",
+  "sentTitle": "Запрос отправлен",
+  "sentDescription": "Мы ответим на адрес электронной почты вашей учётной записи.",
+  "sendAnother": "Отправить ещё",
+  "hideButton": "Скрыть кнопку поддержки",
+  "dragHint": "Перетащите, чтобы переместить",
+  "move": "Переместить",
+  "restore": "Показать кнопку поддержки",
+  "errors": {
+    "sendFailed": "Не удалось отправить запрос. Попробуйте ещё раз.",
+    "screenshotFailed": "Не удалось сделать снимок экрана.",
+    "subject-required": "Укажите тему.",
+    "subject-too-long": "Тема слишком длинная.",
+    "message-required": "Добавьте описание.",
+    "message-too-long": "Описание слишком длинное.",
+    "too-many-attachments": "Можно прикрепить не более {max} файлов.",
+    "attachment-type-not-allowed": "Файл этого типа прикрепить нельзя.",
+    "attachments-too-large": "Вложения слишком большие. Общий лимит — 10 МБ."
+  }
+}

+ 13 - 0
messages/tr/settings.json

@@ -114,6 +114,10 @@
       "alerts": {
       "alerts": {
         "title": "Uyarılar",
         "title": "Uyarılar",
         "description": "Parça sipariş etmeniz gerektiğinde haber alın"
         "description": "Parça sipariş etmeniz gerektiğinde haber alın"
+      },
+      "support": {
+        "title": "Destek",
+        "description": "Destekle iletişime geç ve düğmeyi yönet"
       }
       }
     },
     },
     "pro": "PRO"
     "pro": "PRO"
@@ -1370,5 +1374,14 @@
       "configurePerPart": "Bir parçanın min. miktarını stok sayfasında ayarlayın →",
       "configurePerPart": "Bir parçanın min. miktarını stok sayfasında ayarlayın →",
       "readMore": "Devamını oku"
       "readMore": "Devamını oku"
     }
     }
+  },
+  "support": {
+    "title": "Destek",
+    "description": "Uygulamadan çıkmadan Torqvoice destek ekibine ulaşın. Talebiniz kuruluşunuzu ve bulunduğunuz sayfayı içerir.",
+    "showButton": "Destek düğmesini göster",
+    "showButtonHint": "Bu cihazda her sayfada sürüklenebilir bir düğme. Gizlendiğinde sağ kenardaki küçük bir sekme onu geri getirir.",
+    "contactTitle": "Destekle iletişime geç",
+    "contactHint": "Destek formunu hemen açın.",
+    "contactAction": "Formu aç"
   }
   }
 }
 }

+ 33 - 0
messages/tr/support.json

@@ -0,0 +1,33 @@
+{
+  "title": "Destek talebi",
+  "open": "Destekle iletişime geç",
+  "close": "Kapat",
+  "subjectLabel": "Konu",
+  "subjectPlaceholder": "Sorunun kısa özeti",
+  "messageLabel": "Açıklama",
+  "messagePlaceholder": "Ne oldu ve bunun yerine ne bekliyordunuz?",
+  "takeScreenshot": "Ekran görüntüsü al",
+  "addFiles": "Dosya ekle",
+  "removeAttachment": "Eki kaldır",
+  "attachmentSummary": "{max} ekten {count} tanesi · {size} MB",
+  "send": "Talebi gönder",
+  "sending": "Gönderiliyor…",
+  "sentTitle": "Talep gönderildi",
+  "sentDescription": "Hesabınızdaki e-posta adresine yanıt vereceğiz.",
+  "sendAnother": "Bir tane daha gönder",
+  "hideButton": "Destek düğmesini gizle",
+  "dragHint": "Taşımak için sürükleyin",
+  "move": "Taşı",
+  "restore": "Destek düğmesini göster",
+  "errors": {
+    "sendFailed": "Talep gönderilemedi. Lütfen tekrar deneyin.",
+    "screenshotFailed": "Ekran görüntüsü alınamadı.",
+    "subject-required": "Bir konu ekleyin.",
+    "subject-too-long": "Konu çok uzun.",
+    "message-required": "Bir açıklama ekleyin.",
+    "message-too-long": "Açıklama çok uzun.",
+    "too-many-attachments": "En fazla {max} dosya ekleyebilirsiniz.",
+    "attachment-type-not-allowed": "Bu dosya türü eklenemez.",
+    "attachments-too-large": "Ekler çok büyük. Toplam sınır 10 MB."
+  }
+}

+ 10 - 0
package-lock.json

@@ -26,6 +26,7 @@
         "@tiptap/react": "^3.19.0",
         "@tiptap/react": "^3.19.0",
         "@tiptap/starter-kit": "^3.19.0",
         "@tiptap/starter-kit": "^3.19.0",
         "@types/qrcode": "^1.5.6",
         "@types/qrcode": "^1.5.6",
+        "@zumer/snapdom": "^2.23.1",
         "axios": "^1.18.1",
         "axios": "^1.18.1",
         "better-auth": "^1.6.19",
         "better-auth": "^1.6.19",
         "bson": "^7.2.0",
         "bson": "^7.2.0",
@@ -8433,6 +8434,15 @@
         "url": "https://opencollective.com/vitest"
         "url": "https://opencollective.com/vitest"
       }
       }
     },
     },
+    "node_modules/@zumer/snapdom": {
+      "version": "2.23.1",
+      "resolved": "https://registry.npmjs.org/@zumer/snapdom/-/snapdom-2.23.1.tgz",
+      "integrity": "sha512-r+6LRFXIpzTxlvGBjTr7mhvVW05TozgyE1QFmvO1hbchdbl4Km5/7DIkeyl2+nEVbz8Sp6/K2g4b86pXhCrFIQ==",
+      "license": "MIT",
+      "workspaces": [
+        "packages/*"
+      ]
+    },
     "node_modules/abs-svg-path": {
     "node_modules/abs-svg-path": {
       "version": "0.1.1",
       "version": "0.1.1",
       "resolved": "https://registry.npmjs.org/abs-svg-path/-/abs-svg-path-0.1.1.tgz",
       "resolved": "https://registry.npmjs.org/abs-svg-path/-/abs-svg-path-0.1.1.tgz",

+ 1 - 0
package.json

@@ -34,6 +34,7 @@
     "@tiptap/react": "^3.19.0",
     "@tiptap/react": "^3.19.0",
     "@tiptap/starter-kit": "^3.19.0",
     "@tiptap/starter-kit": "^3.19.0",
     "@types/qrcode": "^1.5.6",
     "@types/qrcode": "^1.5.6",
+    "@zumer/snapdom": "^2.23.1",
     "axios": "^1.18.1",
     "axios": "^1.18.1",
     "better-auth": "^1.6.19",
     "better-auth": "^1.6.19",
     "bson": "^7.2.0",
     "bson": "^7.2.0",

+ 291 - 0
src/__tests__/features/support/support-request.test.ts

@@ -0,0 +1,291 @@
+/**
+ * Tests for what a support request is allowed to contain, and for what an
+ * administrator ends up reading.
+ *
+ * The submitted text lands in an HTML email, so escaping is a correctness
+ * requirement rather than a nicety: a workshop reporting a problem with a page
+ * will quite reasonably paste markup into the description.
+ */
+
+import { describe, it, expect } from "vitest";
+import {
+  ALLOWED_ATTACHMENT_TYPES,
+  ATTACHMENT_ACCEPT,
+  MAX_ATTACHMENTS,
+  MAX_MESSAGE_LENGTH,
+  MAX_REQUEST_BYTES,
+  MAX_SUBJECT_LENGTH,
+  MAX_TOTAL_ATTACHMENT_BYTES,
+  buildSupportEmailHtml,
+  escapeHtml,
+  exceedsRequestLimit,
+  sanitizeFilename,
+  validateSupportRequest,
+} from "@/features/support/Lib/supportRequest";
+
+const attachment = (overrides: Partial<{ filename: string; contentType: string; size: number }> = {}) => ({
+  filename: "screenshot.jpg",
+  contentType: "image/jpeg",
+  size: 1024,
+  ...overrides,
+});
+
+const request = (overrides: Partial<Parameters<typeof validateSupportRequest>[0]> = {}) => ({
+  subject: "Invoice will not save",
+  message: "Pressing save does nothing on the invoice page.",
+  attachments: [],
+  ...overrides,
+});
+
+describe("validateSupportRequest", () => {
+  it("accepts a normal request", () => {
+    const result = validateSupportRequest(request());
+    expect(result.ok).toBe(true);
+  });
+
+  it("trims the submitted text", () => {
+    const result = validateSupportRequest(request({ subject: "  Padded  ", message: "  Body  " }));
+    expect(result).toEqual({ ok: true, subject: "Padded", message: "Body" });
+  });
+
+  it("rejects whitespace-only text as empty", () => {
+    // Required attributes on the inputs stop the empty case in a browser, but
+    // the endpoint is reachable without one.
+    expect(validateSupportRequest(request({ subject: "   " }))).toEqual({
+      ok: false,
+      reason: "subject-required",
+    });
+    expect(validateSupportRequest(request({ message: "\n\t " }))).toEqual({
+      ok: false,
+      reason: "message-required",
+    });
+  });
+
+  it("rejects text beyond the stated limits", () => {
+    expect(validateSupportRequest(request({ subject: "x".repeat(MAX_SUBJECT_LENGTH + 1) }))).toEqual({
+      ok: false,
+      reason: "subject-too-long",
+    });
+    expect(validateSupportRequest(request({ message: "x".repeat(MAX_MESSAGE_LENGTH + 1) }))).toEqual({
+      ok: false,
+      reason: "message-too-long",
+    });
+  });
+
+  it("accepts text exactly at the limits", () => {
+    expect(
+      validateSupportRequest(
+        request({
+          subject: "x".repeat(MAX_SUBJECT_LENGTH),
+          message: "x".repeat(MAX_MESSAGE_LENGTH),
+        }),
+      ).ok,
+    ).toBe(true);
+  });
+
+  it("rejects more attachments than allowed", () => {
+    const attachments = Array.from({ length: MAX_ATTACHMENTS + 1 }, () => attachment());
+    expect(validateSupportRequest(request({ attachments }))).toEqual({
+      ok: false,
+      reason: "too-many-attachments",
+    });
+  });
+
+  it("accepts exactly the allowed number of attachments", () => {
+    const attachments = Array.from({ length: MAX_ATTACHMENTS }, () => attachment());
+    expect(validateSupportRequest(request({ attachments })).ok).toBe(true);
+  });
+
+  it("rejects file types that have no business in an inbox", () => {
+    for (const contentType of [
+      "application/x-msdownload",
+      "application/zip",
+      "text/html",
+      "application/octet-stream",
+      "",
+    ]) {
+      expect(validateSupportRequest(request({ attachments: [attachment({ contentType })] }))).toEqual({
+        ok: false,
+        reason: "attachment-type-not-allowed",
+      });
+    }
+  });
+
+  it("accepts every type on the allow list", () => {
+    for (const contentType of ALLOWED_ATTACHMENT_TYPES) {
+      expect(validateSupportRequest(request({ attachments: [attachment({ contentType })] })).ok).toBe(
+        true,
+      );
+    }
+  });
+
+  it("offers the picker exactly the types the server accepts", () => {
+    // These were written out separately once and drifted: the picker allowed
+    // image/*, so an SVG or an iPhone HEIC could be chosen and was only refused
+    // after the upload had already happened.
+    expect(ATTACHMENT_ACCEPT.split(",")).toEqual(ALLOWED_ATTACHMENT_TYPES);
+    for (const offered of ATTACHMENT_ACCEPT.split(",")) {
+      expect(validateSupportRequest(request({ attachments: [attachment({ contentType: offered })] })).ok).toBe(
+        true,
+      );
+    }
+    expect(ATTACHMENT_ACCEPT).not.toContain("image/*");
+  });
+
+  it("rejects attachments whose combined size exceeds the budget", () => {
+    // Each file is individually fine; only the total is over. Checking them one
+    // at a time would let this through.
+    const attachments = [
+      attachment({ size: MAX_TOTAL_ATTACHMENT_BYTES * 0.6 }),
+      attachment({ size: MAX_TOTAL_ATTACHMENT_BYTES * 0.6 }),
+    ];
+    expect(validateSupportRequest(request({ attachments }))).toEqual({
+      ok: false,
+      reason: "attachments-too-large",
+    });
+  });
+
+  it("accepts attachments exactly at the budget", () => {
+    const attachments = [attachment({ size: MAX_TOTAL_ATTACHMENT_BYTES })];
+    expect(validateSupportRequest(request({ attachments })).ok).toBe(true);
+  });
+});
+
+/**
+ * The only check that runs before the body is read. Everything else in this
+ * file operates on a parsed FormData, and parsing has already buffered the
+ * whole upload into memory by then.
+ */
+describe("exceedsRequestLimit", () => {
+  it("refuses a request that declares more than the ceiling", () => {
+    expect(exceedsRequestLimit(String(MAX_REQUEST_BYTES + 1))).toBe(true);
+    expect(exceedsRequestLimit(String(1024 * 1024 * 1024))).toBe(true);
+  });
+
+  it("allows a request at or under the ceiling", () => {
+    expect(exceedsRequestLimit(String(MAX_REQUEST_BYTES))).toBe(false);
+    expect(exceedsRequestLimit(String(MAX_TOTAL_ATTACHMENT_BYTES))).toBe(false);
+    expect(exceedsRequestLimit("0")).toBe(false);
+  });
+
+  it("leaves room above the attachment budget for the form fields themselves", () => {
+    // A request carrying the maximum attachments plus a full-length subject and
+    // message must not be refused by the very limit meant to allow it.
+    expect(MAX_REQUEST_BYTES).toBeGreaterThan(
+      MAX_TOTAL_ATTACHMENT_BYTES + MAX_SUBJECT_LENGTH + MAX_MESSAGE_LENGTH,
+    );
+  });
+
+  it("allows a chunked upload through to the byte counter", () => {
+    // No Content-Length means the size is not knowable up front. Refusing these
+    // outright would break any client that streams.
+    expect(exceedsRequestLimit(null)).toBe(false);
+    expect(exceedsRequestLimit("")).toBe(false);
+  });
+
+  it("does not refuse on a header it cannot parse", () => {
+    expect(exceedsRequestLimit("not-a-number")).toBe(false);
+  });
+});
+
+describe("escapeHtml", () => {
+  it("neutralises markup so a pasted snippet renders as text", () => {
+    expect(escapeHtml('<script>alert("x")</script>')).toBe(
+      "&lt;script&gt;alert(&quot;x&quot;)&lt;/script&gt;",
+    );
+  });
+
+  it("escapes ampersands before anything else, so entities are not doubled oddly", () => {
+    expect(escapeHtml("Tom & Jerry <b>")).toBe("Tom &amp; Jerry &lt;b&gt;");
+  });
+
+  it("escapes quotes that could break out of an attribute", () => {
+    expect(escapeHtml(`" onload='x'`)).toBe("&quot; onload=&#39;x&#39;");
+  });
+});
+
+describe("sanitizeFilename", () => {
+  it("keeps an ordinary name", () => {
+    expect(sanitizeFilename("screenshot 1.jpg", "fallback")).toBe("screenshot 1.jpg");
+  });
+
+  it("strips directory components", () => {
+    expect(sanitizeFilename("../../etc/passwd", "fallback")).toBe("passwd");
+    expect(sanitizeFilename("C:\\Users\\me\\report.pdf", "fallback")).toBe("report.pdf");
+  });
+
+  it("removes characters that could break a MIME header", () => {
+    // The allow list is word characters, dot, hyphen and space, so the CRLF and
+    // the colon that would forge a header are collapsed, and so is the @.
+    expect(sanitizeFilename('bad"name\r\nBcc: x@y.z.pdf', "fallback")).toBe("bad_name_Bcc_ x_y.z.pdf");
+    expect(sanitizeFilename('report\r\nBcc: a@b.c', "fallback")).not.toMatch(/[\r\n:]/);
+  });
+
+  it("falls back when nothing usable is left", () => {
+    expect(sanitizeFilename("", "attachment-1")).toBe("attachment-1");
+    expect(sanitizeFilename("...", "attachment-2")).toBe("attachment-2");
+    expect(sanitizeFilename("/", "attachment-3")).toBe("attachment-3");
+  });
+
+  it("caps the length", () => {
+    expect(sanitizeFilename(`${"a".repeat(400)}.jpg`, "fallback")).toHaveLength(120);
+  });
+});
+
+describe("buildSupportEmailHtml", () => {
+  const context = {
+    organizationName: "Bergen Bil",
+    organizationId: "org_123",
+    userName: "Kari",
+    userEmail: "kari@example.com",
+    pageUrl: "https://app.torqvoice.com/vehicles/1",
+    userAgent: "Mozilla/5.0",
+    appVersion: "1.4.2",
+    submittedAt: "2026-07-28T09:00:00.000Z",
+  };
+
+  it("carries the context needed to act on the ticket", () => {
+    const html = buildSupportEmailHtml("Cannot save", "It fails", context);
+    expect(html).toContain("Bergen Bil");
+    expect(html).toContain("org_123");
+    expect(html).toContain("kari@example.com");
+    expect(html).toContain("https://app.torqvoice.com/vehicles/1");
+    expect(html).toContain("1.4.2");
+  });
+
+  it("escapes the user's own text", () => {
+    const html = buildSupportEmailHtml("<b>subject</b>", "<img src=x onerror=alert(1)>", context);
+    expect(html).not.toContain("<b>subject</b>");
+    expect(html).not.toContain("<img src=x");
+    expect(html).toContain("&lt;b&gt;subject&lt;/b&gt;");
+  });
+
+  it("escapes context values, which are not all under our control", () => {
+    // Organization name and user agent are both attacker-influenced.
+    const html = buildSupportEmailHtml("s", "m", {
+      ...context,
+      organizationName: "<script>x</script>",
+      userAgent: "<img onerror=1>",
+    });
+    expect(html).not.toContain("<script>x</script>");
+    expect(html).not.toContain("<img onerror=1>");
+  });
+
+  it("omits rows it has no value for rather than printing blanks", () => {
+    const html = buildSupportEmailHtml("s", "m", {
+      ...context,
+      pageUrl: null,
+      appVersion: null,
+      userAgent: null,
+    });
+    expect(html).not.toContain("Page");
+    expect(html).not.toContain("App version");
+    expect(html).not.toContain("Browser");
+    expect(html).toContain("Organization");
+  });
+
+  it("falls back to the email alone when the account has no name", () => {
+    const html = buildSupportEmailHtml("s", "m", { ...context, userName: null });
+    expect(html).toContain("kari@example.com");
+  });
+});

+ 95 - 0
src/__tests__/lib/support.test.ts

@@ -0,0 +1,95 @@
+/**
+ * Tests for the gate that decides whether in-app support exists at all.
+ *
+ * Both conditions have to hold and each has its own failure mode. Cloud mode
+ * alone would expose the button on a fresh cloud deployment before anyone had
+ * agreed to answer it; the stored flag alone would expose it on a self-hosted
+ * install that restored a cloud backup, mailing a support desk that has no
+ * relationship with that workshop.
+ */
+
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+vi.mock("@/lib/db", () => ({
+  db: {
+    systemSetting: { findUnique: vi.fn() },
+  },
+}));
+
+import { db } from "@/lib/db";
+import {
+  DEFAULT_SUPPORT_RECIPIENT,
+  getSupportRecipient,
+  isSupportEnabled,
+} from "@/lib/support";
+
+const mockFindUnique = vi.mocked(db.systemSetting.findUnique);
+
+beforeEach(() => {
+  vi.resetAllMocks();
+  vi.unstubAllEnvs();
+});
+
+describe("isSupportEnabled", () => {
+  it("is off on a self-hosted install even when the flag is set", async () => {
+    vi.stubEnv("TORQVOICE_MODE", "self-hosted");
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    mockFindUnique.mockResolvedValue({ value: "true" } as any);
+    expect(await isSupportEnabled()).toBe(false);
+  });
+
+  it("is off when no mode is configured at all", async () => {
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    mockFindUnique.mockResolvedValue({ value: "true" } as any);
+    expect(await isSupportEnabled()).toBe(false);
+  });
+
+  it("does not even read the setting outside cloud mode", async () => {
+    vi.stubEnv("TORQVOICE_MODE", "self-hosted");
+    await isSupportEnabled();
+    expect(mockFindUnique).not.toHaveBeenCalled();
+  });
+
+  it("is off in cloud mode until someone turns it on", async () => {
+    vi.stubEnv("TORQVOICE_MODE", "cloud");
+    mockFindUnique.mockResolvedValue(null);
+    expect(await isSupportEnabled()).toBe(false);
+  });
+
+  it("is on only in cloud mode with the flag set to exactly 'true'", async () => {
+    vi.stubEnv("TORQVOICE_MODE", "cloud");
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    mockFindUnique.mockResolvedValue({ value: "true" } as any);
+    expect(await isSupportEnabled()).toBe(true);
+  });
+
+  it("treats any other stored value as off", async () => {
+    vi.stubEnv("TORQVOICE_MODE", "cloud");
+    for (const value of ["false", "TRUE", "1", "yes", "", " true "]) {
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any
+      mockFindUnique.mockResolvedValue({ value } as any);
+      expect(await isSupportEnabled()).toBe(false);
+    }
+  });
+});
+
+describe("getSupportRecipient", () => {
+  it("falls back to the published address when nothing is configured", async () => {
+    mockFindUnique.mockResolvedValue(null);
+    expect(await getSupportRecipient()).toBe(DEFAULT_SUPPORT_RECIPIENT);
+  });
+
+  it("falls back when the stored value is blank", async () => {
+    // A cleared field in the admin form saves an empty string rather than
+    // deleting the row, and mail to "" goes nowhere.
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    mockFindUnique.mockResolvedValue({ value: "   " } as any);
+    expect(await getSupportRecipient()).toBe(DEFAULT_SUPPORT_RECIPIENT);
+  });
+
+  it("uses the configured address, trimmed", async () => {
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    mockFindUnique.mockResolvedValue({ value: " desk@example.com " } as any);
+    expect(await getSupportRecipient()).toBe("desk@example.com");
+  });
+});

+ 6 - 0
src/app/(authenticated)/layout.tsx

@@ -14,6 +14,8 @@ import { hasPermission, PermissionAction, PermissionSubject } from "@/lib/permis
 import { OnlineTracker } from "@/components/online-tracker";
 import { OnlineTracker } from "@/components/online-tracker";
 import { InstallBanner } from "@/components/pwa-install-prompt";
 import { InstallBanner } from "@/components/pwa-install-prompt";
 import { MobileBottomNav } from "@/components/mobile-bottom-nav";
 import { MobileBottomNav } from "@/components/mobile-bottom-nav";
+import { SupportBubble } from "@/features/support/Components/SupportBubble";
+import { isSupportEnabled } from "@/lib/support";
 import { ServiceTypeProvider } from "@/components/service-type-context";
 import { ServiceTypeProvider } from "@/components/service-type-context";
 import { LicenseExpiryProvider } from "@/components/license-expiry-context";
 import { LicenseExpiryProvider } from "@/components/license-expiry-context";
 import { db } from "@/lib/db";
 import { db } from "@/lib/db";
@@ -41,6 +43,9 @@ export default async function DashboardLayout({
 
 
   const features = await getFeatures(data.organizationId);
   const features = await getFeatures(data.organizationId);
   const showWhiteLabelCta = !isCloudMode() && !features.brandingRemoved;
   const showWhiteLabelCta = !isCloudMode() && !features.brandingRemoved;
+  // Cloud only, and off until a platform admin turns it on. Resolved here so
+  // the widget's code never reaches a self-hosted install's page.
+  const supportEnabled = await isSupportEnabled();
 
 
   // Check user-level messaging enabled settings
   // Check user-level messaging enabled settings
   let smsEnabled = false;
   let smsEnabled = false;
@@ -144,6 +149,7 @@ export default async function DashboardLayout({
       </DateSettingsProvider>
       </DateSettingsProvider>
     </SidebarProvider>
     </SidebarProvider>
     <MobileBottomNav isSuperAdmin={data.isSuperAdmin} />
     <MobileBottomNav isSuperAdmin={data.isSuperAdmin} />
+    {supportEnabled && <SupportBubble />}
     </WhiteLabelCtaProvider>
     </WhiteLabelCtaProvider>
     </LicenseExpiryProvider>
     </LicenseExpiryProvider>
     </ServiceTypeProvider>
     </ServiceTypeProvider>

+ 4 - 2
src/app/(authenticated)/settings/layout.tsx

@@ -3,6 +3,7 @@ import { SettingsNav } from "./settings-nav";
 import { SettingsPermissionProvider } from "./settings-permission-context";
 import { SettingsPermissionProvider } from "./settings-permission-context";
 import { getLayoutData } from "@/lib/get-layout-data";
 import { getLayoutData } from "@/lib/get-layout-data";
 import { getFeatures, isCloudMode } from "@/lib/features";
 import { getFeatures, isCloudMode } from "@/lib/features";
+import { isSupportEnabled } from "@/lib/support";
 import { redirect } from "next/navigation";
 import { redirect } from "next/navigation";
 import { getCachedMembership } from "@/lib/cached-session";
 import { getCachedMembership } from "@/lib/cached-session";
 import { hasPermission, PermissionAction, PermissionSubject } from "@/lib/permissions";
 import { hasPermission, PermissionAction, PermissionSubject } from "@/lib/permissions";
@@ -41,6 +42,7 @@ export default async function SettingsLayout({
   }
   }
 
 
   const features = await getFeatures(data.organizationId);
   const features = await getFeatures(data.organizationId);
+  const supportEnabled = await isSupportEnabled();
   const t = await getTranslations("settings");
   const t = await getTranslations("settings");
 
 
   return (
   return (
@@ -56,11 +58,11 @@ export default async function SettingsLayout({
           </div>
           </div>
           <div className="flex min-h-0 flex-1 flex-col gap-6 md:flex-row">
           <div className="flex min-h-0 flex-1 flex-col gap-6 md:flex-row">
             <aside className="hidden shrink-0 overflow-y-auto md:block md:w-56 lg:w-64">
             <aside className="hidden shrink-0 overflow-y-auto md:block md:w-56 lg:w-64">
-              <SettingsNav features={features} isCloud={isCloudMode()} />
+              <SettingsNav features={features} isCloud={isCloudMode()} supportEnabled={supportEnabled} />
             </aside>
             </aside>
             <div className="min-w-0 flex-1 overflow-y-auto pb-8">
             <div className="min-w-0 flex-1 overflow-y-auto pb-8">
               <div className="mb-4 md:hidden">
               <div className="mb-4 md:hidden">
-                <SettingsNav features={features} isCloud={isCloudMode()} mobile />
+                <SettingsNav features={features} isCloud={isCloudMode()} supportEnabled={supportEnabled} mobile />
               </div>
               </div>
               {children}
               {children}
             </div>
             </div>

+ 10 - 0
src/app/(authenticated)/settings/settings-nav.tsx

@@ -34,6 +34,7 @@ import {
   Webhook,
   Webhook,
   Wrench,
   Wrench,
   BellRing,
   BellRing,
+  LifeBuoy,
 } from 'lucide-react'
 } from 'lucide-react'
 
 
 type SettingsNavItem = {
 type SettingsNavItem = {
@@ -43,6 +44,11 @@ type SettingsNavItem = {
   gate?: keyof PlanFeatures
   gate?: keyof PlanFeatures
   cloudOnly?: boolean
   cloudOnly?: boolean
   selfHostedOnly?: boolean
   selfHostedOnly?: boolean
+  /**
+   * Hidden unless the platform has switched support requests on. Implies cloud
+   * already, since the flag is only ever true in cloud mode.
+   */
+  supportOnly?: boolean
 }
 }
 
 
 type SettingsCategory = {
 type SettingsCategory = {
@@ -59,6 +65,7 @@ const settingsCategories: SettingsCategory[] = [
       { key: 'account', href: '/settings/account', icon: UserCog },
       { key: 'account', href: '/settings/account', icon: UserCog },
       { key: 'team', href: '/settings/team', icon: UsersRound },
       { key: 'team', href: '/settings/team', icon: UsersRound },
       { key: 'localization', href: '/settings/localization', icon: Globe },
       { key: 'localization', href: '/settings/localization', icon: Globe },
+      { key: 'support', href: '/settings/support', icon: LifeBuoy, supportOnly: true },
     ],
     ],
   },
   },
   {
   {
@@ -113,10 +120,12 @@ const settingsCategories: SettingsCategory[] = [
 export function SettingsNav({
 export function SettingsNav({
   features,
   features,
   isCloud,
   isCloud,
+  supportEnabled,
   mobile,
   mobile,
 }: {
 }: {
   features?: PlanFeatures
   features?: PlanFeatures
   isCloud?: boolean
   isCloud?: boolean
+  supportEnabled?: boolean
   mobile?: boolean
   mobile?: boolean
 }) {
 }) {
   const pathname = usePathname()
   const pathname = usePathname()
@@ -126,6 +135,7 @@ export function SettingsNav({
   const filterItem = (item: SettingsNavItem) => {
   const filterItem = (item: SettingsNavItem) => {
     if (item.cloudOnly && !isCloud) return false
     if (item.cloudOnly && !isCloud) return false
     if (item.selfHostedOnly && isCloud) return false
     if (item.selfHostedOnly && isCloud) return false
+    if (item.supportOnly && !supportEnabled) return false
     return true
     return true
   }
   }
 
 

+ 34 - 0
src/app/(authenticated)/settings/support/page.tsx

@@ -0,0 +1,34 @@
+import { notFound } from 'next/navigation'
+import { getTranslations } from 'next-intl/server'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Headset } from 'lucide-react'
+import { isSupportEnabled } from '@/lib/support'
+import { SupportSettingsControls } from '@/features/support/Components/SupportSettingsControls'
+
+/**
+ * Somewhere to find the support button again after dismissing it.
+ *
+ * 404s rather than rendering an empty page when the feature is off, so the
+ * route cannot be used to discover that support exists on a plan that does not
+ * have it.
+ */
+export default async function SupportSettingsPage() {
+  if (!(await isSupportEnabled())) notFound()
+
+  const t = await getTranslations('settings')
+
+  return (
+    <div className="space-y-6">
+      <Card className="border-0 shadow-sm">
+        <CardHeader className="flex flex-row items-center gap-3 pb-4">
+          <Headset className="h-5 w-5 text-muted-foreground" />
+          <CardTitle className="text-lg">{t('support.title')}</CardTitle>
+        </CardHeader>
+        <CardContent className="space-y-4">
+          <p className="text-sm text-muted-foreground">{t('support.description')}</p>
+          <SupportSettingsControls />
+        </CardContent>
+      </Card>
+    </div>
+  )
+}

+ 139 - 0
src/app/api/protected/support/route.ts

@@ -0,0 +1,139 @@
+/**
+ * Submits an in-app support request to the configured administrator.
+ *
+ * A route handler rather than a server action because of the attachments:
+ * server action bodies are capped at 1MB by default, which a single screenshot
+ * can exceed. This follows the same shape as the other upload endpoints under
+ * /api/protected/upload — getAuthContext plus multipart form data.
+ */
+
+import { NextRequest, NextResponse } from "next/server";
+import { getAuthContext } from "@/lib/get-auth-context";
+import { db } from "@/lib/db";
+import { getFromAddress, sendMail } from "@/lib/email";
+import { getSupportRecipient, isSupportEnabled } from "@/lib/support";
+import {
+  buildSupportEmailHtml,
+  exceedsRequestLimit,
+  MAX_TOTAL_ATTACHMENT_BYTES,
+  sanitizeFilename,
+  validateSupportRequest,
+} from "@/features/support/Lib/supportRequest";
+
+export async function POST(request: NextRequest) {
+  const ctx = await getAuthContext();
+  if (!ctx) {
+    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+  }
+
+  // Re-checked here on purpose. The widget is hidden when the feature is off,
+  // but hiding a button is not access control — the endpoint has to refuse on
+  // its own or a disabled feature is still reachable.
+  if (!(await isSupportEnabled())) {
+    return NextResponse.json({ error: "Support requests are not enabled" }, { status: 404 });
+  }
+
+  // Before the body is touched. request.formData() buffers the whole upload
+  // into memory, so anything checked after it has already cost us the memory
+  // it was meant to protect.
+  if (exceedsRequestLimit(request.headers.get("content-length"))) {
+    return NextResponse.json({ error: "attachments-too-large" }, { status: 413 });
+  }
+
+  let formData: FormData;
+  try {
+    formData = await request.formData();
+  } catch {
+    return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
+  }
+
+  const files = formData.getAll("files").filter((f): f is File => f instanceof File);
+
+  const validation = validateSupportRequest({
+    subject: String(formData.get("subject") ?? ""),
+    message: String(formData.get("message") ?? ""),
+    attachments: files.map((f) => ({
+      filename: f.name,
+      contentType: f.type,
+      size: f.size,
+    })),
+  });
+
+  if (!validation.ok) {
+    return NextResponse.json({ error: validation.reason }, { status: 400 });
+  }
+
+  const [organization, user] = await Promise.all([
+    db.organization.findUnique({
+      where: { id: ctx.organizationId },
+      select: { name: true },
+    }),
+    db.user.findUnique({
+      where: { id: ctx.userId },
+      select: { name: true, email: true },
+    }),
+  ]);
+
+  if (!user?.email) {
+    return NextResponse.json({ error: "No reply address for this account" }, { status: 400 });
+  }
+
+  // The body is already buffered by this point — formData() did that, and the
+  // Content-Length check above is what keeps that bounded. Reading the files
+  // last only avoids a second copy: it skips allocating Buffers for a request
+  // that validation was going to reject anyway.
+  let consumed = 0;
+  const attachments: { filename: string; content: Buffer }[] = [];
+  for (const [index, file] of files.entries()) {
+    const buffer = Buffer.from(await file.arrayBuffer());
+    consumed += buffer.byteLength;
+    // File.size is client-declared; this is the figure that actually arrived.
+    if (consumed > MAX_TOTAL_ATTACHMENT_BYTES) {
+      return NextResponse.json({ error: "attachments-too-large" }, { status: 400 });
+    }
+    attachments.push({
+      filename: sanitizeFilename(file.name, `attachment-${index + 1}`),
+      content: buffer,
+    });
+  }
+
+  const html = buildSupportEmailHtml(validation.subject, validation.message, {
+    organizationName: organization?.name ?? "Unknown organization",
+    organizationId: ctx.organizationId,
+    userName: user.name,
+    userEmail: user.email,
+    pageUrl: formData.get("pageUrl") ? String(formData.get("pageUrl")).slice(0, 500) : null,
+    userAgent: request.headers.get("user-agent"),
+    // Same variable the About page reads, so the version in a ticket matches
+    // what the user can read back to you.
+    appVersion: process.env.APP_VERSION || null,
+    submittedAt: new Date().toISOString(),
+  });
+
+  try {
+    // Deliberately the platform mailer (system_settings, configured under
+    // /admin/settings) rather than the organization's own. A support request
+    // must not depend on the workshop's mail server, which may well be the
+    // thing they are writing in about.
+    const [from, to] = await Promise.all([getFromAddress(), getSupportRecipient()]);
+    await sendMail({
+      from,
+      to,
+      subject: `[Support] ${validation.subject}`,
+      html,
+      attachments,
+    });
+  } catch (error) {
+    // The reason a send failed is a platform configuration detail, so it is
+    // logged rather than handed to the user. The usual cause is platform email
+    // never having been set up — note that an organization's own working SMTP
+    // does not satisfy this path.
+    console.error(
+      "Support request failed to send (check platform email under /admin/settings):",
+      error,
+    );
+    return NextResponse.json({ error: "send-failed" }, { status: 502 });
+  }
+
+  return NextResponse.json({ sent: true });
+}

+ 6 - 0
src/features/admin/Schema/systemSettingsSchema.ts

@@ -49,6 +49,12 @@ export const SYSTEM_SETTING_KEYS = {
   SES_REGION: "ses.region",
   SES_REGION: "ses.region",
   SES_FROM_EMAIL: "ses.fromEmail",
   SES_FROM_EMAIL: "ses.fromEmail",
   SES_FROM_NAME: "ses.fromName",
   SES_FROM_NAME: "ses.fromName",
+
+  // In-app support requests. Written from the torqvoice.com admin dashboard
+  // rather than from this app: whether a workshop may reach support is a
+  // platform decision, not one the workshop makes for itself.
+  SUPPORT_ENABLED: "support.enabled",
+  SUPPORT_RECIPIENT_EMAIL: "support.recipientEmail",
 } as const;
 } as const;
 
 
 export type SystemSettingKey =
 export type SystemSettingKey =

+ 448 - 0
src/features/support/Components/SupportBubble.tsx

@@ -0,0 +1,448 @@
+"use client";
+
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useTranslations } from "next-intl";
+import {
+  Camera,
+  ChevronLeft,
+  GripVertical,
+  Headset,
+  Loader2,
+  Paperclip,
+  Send,
+  X,
+} from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
+import { Label } from "@/components/ui/label";
+import { compressImage } from "@/lib/compress-image";
+import {
+  ATTACHMENT_ACCEPT,
+  MAX_ATTACHMENTS,
+  MAX_MESSAGE_LENGTH,
+  MAX_SUBJECT_LENGTH,
+  MAX_TOTAL_ATTACHMENT_BYTES,
+} from "@/features/support/Lib/supportRequest";
+import {
+  SUPPORT_OPEN_EVENT,
+  SUPPORT_VISIBILITY_EVENT,
+  isSupportBubbleHidden,
+  setSupportBubbleHidden,
+} from "@/features/support/Lib/supportVisibility";
+import { useDraggablePosition } from "@/features/support/Lib/useDraggablePosition";
+
+type Status = "idle" | "sending" | "sent";
+
+const TRIGGER_SIZE = 48;
+const PANEL_WIDTH = 380;
+const GAP = 12;
+
+/**
+ * Error codes the endpoint can return that have their own copy. Anything
+ * outside this list falls back to the generic message: next-intl throws on a
+ * missing key, so an unrecognised code must never reach `t()` directly.
+ */
+const KNOWN_ERROR_CODES = [
+  "subject-required",
+  "subject-too-long",
+  "message-required",
+  "message-too-long",
+  "too-many-attachments",
+  "attachment-type-not-allowed",
+  "attachments-too-large",
+] as const;
+
+function errorKey(code: unknown): string {
+  return (KNOWN_ERROR_CODES as readonly string[]).includes(String(code))
+    ? `errors.${code}`
+    : "errors.sendFailed";
+}
+
+export function SupportBubble() {
+  const t = useTranslations("support");
+  const [isOpen, setIsOpen] = useState(false);
+  // Assume hidden until localStorage has been read. Rendering the button and
+  // then pulling it away is worse than a frame without it.
+  const [hidden, setHidden] = useState(true);
+  const [status, setStatus] = useState<Status>("idle");
+  const [error, setError] = useState<string | null>(null);
+  const [subject, setSubject] = useState("");
+  const [message, setMessage] = useState("");
+  const [files, setFiles] = useState<File[]>([]);
+  const [capturing, setCapturing] = useState(false);
+  const fileInputRef = useRef<HTMLInputElement>(null);
+
+  const { position, dragging, consumeDrag, dragHandlers } = useDraggablePosition(TRIGGER_SIZE);
+
+  useEffect(() => {
+    const sync = () => setHidden(isSupportBubbleHidden());
+    sync();
+    // The settings page toggles the same key. `storage` covers other tabs; the
+    // custom event covers this one, which `storage` does not fire for.
+    window.addEventListener("storage", sync);
+    window.addEventListener(SUPPORT_VISIBILITY_EVENT, sync);
+    return () => {
+      window.removeEventListener("storage", sync);
+      window.removeEventListener(SUPPORT_VISIBILITY_EVENT, sync);
+    };
+  }, []);
+
+  // The settings page opens the widget for someone who cannot find it.
+  useEffect(() => {
+    const open = () => setIsOpen(true);
+    window.addEventListener(SUPPORT_OPEN_EVENT, open);
+    return () => window.removeEventListener(SUPPORT_OPEN_EVENT, open);
+  }, []);
+
+  /**
+   * The panel follows the button. It is right-aligned to the button and opens
+   * upward when the button sits low, which is where it usually will after being
+   * dragged clear of a save button.
+   */
+  const panelStyle = useMemo((): React.CSSProperties | undefined => {
+    if (!position || typeof window === "undefined") return undefined;
+    const viewportWidth = window.innerWidth;
+    const viewportHeight = window.innerHeight;
+    const width = Math.min(PANEL_WIDTH, viewportWidth - 2 * GAP);
+    const left = Math.max(
+      GAP,
+      Math.min(position.x + TRIGGER_SIZE - width, viewportWidth - width - GAP),
+    );
+
+    return position.y > viewportHeight / 2
+      ? { left, bottom: viewportHeight - position.y + GAP, width }
+      : { left, top: position.y + TRIGGER_SIZE + GAP, width };
+  }, [position]);
+
+  const totalBytes = files.reduce((sum, f) => sum + f.size, 0);
+
+  const reset = useCallback(() => {
+    setSubject("");
+    setMessage("");
+    setFiles([]);
+    setError(null);
+    setStatus("idle");
+  }, []);
+
+  const handleClose = useCallback(() => {
+    setIsOpen(false);
+    if (status !== "sent") setError(null);
+  }, [status]);
+
+  const handleDismiss = useCallback(() => {
+    setSupportBubbleHidden(true);
+    setIsOpen(false);
+  }, []);
+
+  const addFiles = useCallback(
+    async (incoming: File[]) => {
+      setError(null);
+      const room = MAX_ATTACHMENTS - files.length;
+      if (room <= 0) {
+        setError(t("errors.too-many-attachments", { max: MAX_ATTACHMENTS }));
+        return;
+      }
+
+      // Images are shrunk before they are counted, so a phone photo does not
+      // eat the whole budget on its own.
+      const processed = await Promise.all(
+        incoming.slice(0, room).map((file) =>
+          file.type.startsWith("image/") ? compressImage(file) : Promise.resolve(file),
+        ),
+      );
+
+      const next = [...files, ...processed];
+      if (next.reduce((sum, f) => sum + f.size, 0) > MAX_TOTAL_ATTACHMENT_BYTES) {
+        setError(t("errors.attachments-too-large"));
+        return;
+      }
+      setFiles(next);
+    },
+    [files, t],
+  );
+
+  const handleScreenshot = useCallback(async () => {
+    setCapturing(true);
+    setError(null);
+    // Close the panel so the capture shows the page the user is reporting on,
+    // not the form covering it.
+    setIsOpen(false);
+    try {
+      // Loaded on demand: this is a large dependency and most sessions never
+      // open the widget, let alone take a screenshot.
+      const { snapdom } = await import("@zumer/snapdom");
+      // Two frames, so the panel has actually left the screen before capture.
+      await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
+      const blob = await snapdom.toBlob(document.body, {
+        type: "jpeg",
+        quality: 0.7,
+        backgroundColor: "#ffffff",
+      });
+      const file = new File([blob], `screenshot-${Date.now()}.jpg`, { type: "image/jpeg" });
+      await addFiles([file]);
+    } catch {
+      setError(t("errors.screenshotFailed"));
+    } finally {
+      setCapturing(false);
+      setIsOpen(true);
+    }
+  }, [addFiles, t]);
+
+  const handleSubmit = useCallback(
+    async (event: React.FormEvent) => {
+      event.preventDefault();
+      setStatus("sending");
+      setError(null);
+
+      const body = new FormData();
+      body.set("subject", subject);
+      body.set("message", message);
+      body.set("pageUrl", window.location.href);
+      for (const file of files) body.append("files", file);
+
+      try {
+        const response = await fetch("/api/protected/support", { method: "POST", body });
+        if (!response.ok) {
+          const payload = await response.json().catch(() => ({}));
+          setError(t(errorKey(payload.error), { max: MAX_ATTACHMENTS }));
+          setStatus("idle");
+          return;
+        }
+        setStatus("sent");
+        setSubject("");
+        setMessage("");
+        setFiles([]);
+      } catch {
+        setError(t("errors.sendFailed"));
+        setStatus("idle");
+      }
+    },
+    [subject, message, files, t],
+  );
+
+  // position is null until localStorage and the viewport have been read.
+  if (!position) return null;
+
+  // Dismissed collapses to a tab tucked against the right edge rather than
+  // disappearing. Removing the button outright leaves no way back except the
+  // settings page, which is not somewhere you look when you want support.
+  if (hidden) {
+    return (
+      <button
+        type="button"
+        onClick={() => {
+          setSupportBubbleHidden(false);
+          setIsOpen(true);
+        }}
+        className="group fixed right-0 bottom-24 z-50 flex h-9 items-center gap-0 rounded-l-md border border-r-0 border-border/60 bg-background/80 pl-1.5 pr-1 text-muted-foreground/60 shadow-sm backdrop-blur transition-colors hover:bg-background hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring md:bottom-8"
+        aria-label={t("restore")}
+      >
+        <ChevronLeft className="h-3.5 w-3.5 shrink-0" />
+        {/* Collapsed to an arrow until pointed at, so it stays out of the way
+            but still says what it does rather than relying on a tooltip. */}
+        <span className="max-w-0 overflow-hidden whitespace-nowrap text-xs font-medium opacity-0 transition-all duration-200 group-hover:max-w-[12rem] group-hover:pr-1 group-hover:opacity-100 group-focus-visible:max-w-[12rem] group-focus-visible:pr-1 group-focus-visible:opacity-100">
+          {t("restore")}
+        </span>
+      </button>
+    );
+  }
+
+  return (
+    <>
+      {!isOpen && (
+        <button
+          type="button"
+          {...dragHandlers}
+          onClick={() => {
+            // Swallow the click that ends a drag, so moving the button out of
+            // the way does not also open the form.
+            if (consumeDrag()) return;
+            setIsOpen(true);
+          }}
+          style={{ left: position.x, top: position.y, width: TRIGGER_SIZE, height: TRIGGER_SIZE }}
+          className={`fixed z-50 flex touch-none items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg shadow-primary/25 ring-1 ring-inset ring-white/15 transition-[box-shadow,transform] hover:shadow-xl hover:shadow-primary/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ${
+            dragging ? "scale-105 cursor-grabbing" : "cursor-grab hover:-translate-y-0.5"
+          }`}
+          aria-label={t("open")}
+          title={t("dragHint")}
+        >
+          <Headset className="h-[22px] w-[22px]" strokeWidth={1.75} />
+        </button>
+      )}
+
+      {isOpen && (
+        <div
+          style={panelStyle}
+          className="fixed z-50 overflow-hidden rounded-xl border bg-popover shadow-2xl ring-1 ring-black/5 dark:ring-white/10"
+        >
+          <div className="flex items-center gap-1 border-b bg-muted/40 py-2 pl-1 pr-2">
+            {/* Hidden from assistive tech rather than announced as a button.
+                Dragging is pointer-only, so exposing it as a control would
+                advertise something a keyboard user cannot operate. Nothing is
+                lost: every control in the panel is reachable regardless of
+                where it sits, and the position is only a convenience. */}
+            <span
+              {...dragHandlers}
+              aria-hidden="true"
+              title={t("move")}
+              className={`flex h-7 w-6 shrink-0 touch-none items-center justify-center text-muted-foreground/60 hover:text-muted-foreground ${
+                dragging ? "cursor-grabbing" : "cursor-grab"
+              }`}
+            >
+              <GripVertical className="h-4 w-4" />
+            </span>
+            <Headset className="h-4 w-4 shrink-0 text-primary" strokeWidth={1.75} />
+            <span className="flex-1 truncate text-sm font-medium">{t("title")}</span>
+            <button
+              type="button"
+              onClick={handleClose}
+              className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
+              aria-label={t("close")}
+            >
+              <X className="h-4 w-4" />
+            </button>
+          </div>
+
+          <div className="max-h-[min(70vh,32rem)] overflow-y-auto p-4">
+            {status === "sent" ? (
+              <div className="py-6 text-center">
+                <p className="font-medium">{t("sentTitle")}</p>
+                <p className="mt-1 text-sm text-muted-foreground">{t("sentDescription")}</p>
+                <div className="mt-4 flex justify-center gap-2">
+                  <Button type="button" variant="outline" size="sm" onClick={reset}>
+                    {t("sendAnother")}
+                  </Button>
+                  <Button type="button" size="sm" onClick={handleClose}>
+                    {t("close")}
+                  </Button>
+                </div>
+              </div>
+            ) : (
+              <form onSubmit={handleSubmit} className="space-y-3">
+                <div className="space-y-1.5">
+                  <Label htmlFor="support-subject" className="text-xs text-muted-foreground">
+                    {t("subjectLabel")}
+                  </Label>
+                  <Input
+                    id="support-subject"
+                    value={subject}
+                    onChange={(e) => setSubject(e.target.value)}
+                    maxLength={MAX_SUBJECT_LENGTH}
+                    required
+                    placeholder={t("subjectPlaceholder")}
+                  />
+                </div>
+
+                <div className="space-y-1.5">
+                  <Label htmlFor="support-message" className="text-xs text-muted-foreground">
+                    {t("messageLabel")}
+                  </Label>
+                  <Textarea
+                    id="support-message"
+                    value={message}
+                    onChange={(e) => setMessage(e.target.value)}
+                    maxLength={MAX_MESSAGE_LENGTH}
+                    required
+                    rows={4}
+                    className="resize-none"
+                    placeholder={t("messagePlaceholder")}
+                  />
+                </div>
+
+                <div className="flex gap-2">
+                  <Button
+                    type="button"
+                    variant="outline"
+                    size="sm"
+                    className="flex-1"
+                    onClick={handleScreenshot}
+                    disabled={capturing || files.length >= MAX_ATTACHMENTS}
+                  >
+                    {capturing ? (
+                      <Loader2 className="mr-1 h-4 w-4 animate-spin" />
+                    ) : (
+                      <Camera className="mr-1 h-4 w-4" />
+                    )}
+                    {t("takeScreenshot")}
+                  </Button>
+                  <Button
+                    type="button"
+                    variant="outline"
+                    size="sm"
+                    className="flex-1"
+                    onClick={() => fileInputRef.current?.click()}
+                    disabled={files.length >= MAX_ATTACHMENTS}
+                  >
+                    <Paperclip className="mr-1 h-4 w-4" />
+                    {t("addFiles")}
+                  </Button>
+                  <input
+                    ref={fileInputRef}
+                    type="file"
+                    multiple
+                    accept={ATTACHMENT_ACCEPT}
+                    className="hidden"
+                    onChange={(e) => {
+                      void addFiles(Array.from(e.target.files ?? []));
+                      e.target.value = "";
+                    }}
+                  />
+                </div>
+
+                {files.length > 0 && (
+                  <ul className="space-y-1">
+                    {files.map((file, index) => (
+                      <li
+                        key={`${file.name}-${index}`}
+                        className="flex items-center justify-between gap-2 rounded-md border bg-muted/30 px-2 py-1 text-xs"
+                      >
+                        <span className="truncate" title={file.name}>
+                          {file.name}
+                        </span>
+                        <button
+                          type="button"
+                          className="shrink-0 text-muted-foreground hover:text-destructive"
+                          onClick={() => setFiles(files.filter((_, i) => i !== index))}
+                          aria-label={t("removeAttachment")}
+                        >
+                          <X className="h-3.5 w-3.5" />
+                        </button>
+                      </li>
+                    ))}
+                    <li className="px-0.5 pt-0.5 text-[11px] text-muted-foreground">
+                      {t("attachmentSummary", {
+                        count: files.length,
+                        max: MAX_ATTACHMENTS,
+                        size: (totalBytes / (1024 * 1024)).toFixed(1),
+                      })}
+                    </li>
+                  </ul>
+                )}
+
+                {error && <p className="text-sm text-destructive">{error}</p>}
+
+                <Button type="submit" className="w-full" disabled={status === "sending"}>
+                  {status === "sending" ? (
+                    <Loader2 className="mr-1 h-4 w-4 animate-spin" />
+                  ) : (
+                    <Send className="mr-1 h-4 w-4" />
+                  )}
+                  {status === "sending" ? t("sending") : t("send")}
+                </Button>
+
+                <button
+                  type="button"
+                  onClick={handleDismiss}
+                  className="w-full text-center text-xs text-muted-foreground transition-colors hover:text-foreground hover:underline"
+                >
+                  {t("hideButton")}
+                </button>
+              </form>
+            )}
+          </div>
+        </div>
+      )}
+    </>
+  );
+}

+ 84 - 0
src/features/support/Components/SupportSettingsControls.tsx

@@ -0,0 +1,84 @@
+'use client'
+
+import { useEffect, useState } from 'react'
+import { useTranslations } from 'next-intl'
+import { Button } from '@/components/ui/button'
+import { Label } from '@/components/ui/label'
+import { Switch } from '@/components/ui/switch'
+import { Separator } from '@/components/ui/separator'
+import {
+  SUPPORT_OPEN_EVENT,
+  SUPPORT_VISIBILITY_EVENT,
+  isSupportBubbleHidden,
+  setSupportBubbleHidden,
+} from '@/features/support/Lib/supportVisibility'
+
+/**
+ * The way back for someone who dismissed the button. Reads and writes the same
+ * localStorage key the widget does, and both listen for the shared event so
+ * the toggle and the button never disagree.
+ */
+export function SupportSettingsControls() {
+  const t = useTranslations('settings')
+  const [visible, setVisible] = useState(true)
+  // Rendered on the server first, so the stored preference is unknown until
+  // mount. Without this the switch flips visibly on load.
+  const [ready, setReady] = useState(false)
+
+  useEffect(() => {
+    const sync = () => setVisible(!isSupportBubbleHidden())
+    sync()
+    setReady(true)
+    window.addEventListener(SUPPORT_VISIBILITY_EVENT, sync)
+    window.addEventListener('storage', sync)
+    return () => {
+      window.removeEventListener(SUPPORT_VISIBILITY_EVENT, sync)
+      window.removeEventListener('storage', sync)
+    }
+  }, [])
+
+  return (
+    <div className="space-y-4">
+      <div className="flex items-center justify-between gap-4">
+        <div className="space-y-0.5">
+          <Label htmlFor="support-visible">{t('support.showButton')}</Label>
+          <p className="text-xs text-muted-foreground">{t('support.showButtonHint')}</p>
+        </div>
+        <Switch
+          id="support-visible"
+          checked={visible}
+          disabled={!ready}
+          onCheckedChange={(checked) => {
+            setVisible(checked)
+            setSupportBubbleHidden(!checked)
+          }}
+        />
+      </div>
+
+      <Separator />
+
+      <div className="flex items-center justify-between gap-4">
+        <div className="space-y-0.5">
+          <Label>{t('support.contactTitle')}</Label>
+          <p className="text-xs text-muted-foreground">{t('support.contactHint')}</p>
+        </div>
+        <Button
+          type="button"
+          variant="outline"
+          size="sm"
+          onClick={() => {
+            // Opening while hidden would dispatch into nothing, so make it
+            // visible first and let the widget mount before asking it to open.
+            if (isSupportBubbleHidden()) {
+              setSupportBubbleHidden(false)
+              setVisible(true)
+            }
+            requestAnimationFrame(() => window.dispatchEvent(new Event(SUPPORT_OPEN_EVENT)))
+          }}
+        >
+          {t('support.contactAction')}
+        </Button>
+      </div>
+    </div>
+  )
+}

+ 202 - 0
src/features/support/Lib/supportRequest.ts

@@ -0,0 +1,202 @@
+/**
+ * Validation and message building for support requests.
+ *
+ * Kept free of Next.js and Prisma imports so the rules that decide what is
+ * accepted, and what an administrator ends up reading, can be tested directly
+ * rather than through a route handler.
+ */
+
+/**
+ * Total attachment budget. Mail providers reject somewhere between 10MB and
+ * 25MB depending on which one is configured, and a rejection surfaces to the
+ * user as a failed send after they have already written the report. Staying
+ * under the lowest of them is worth more than allowing a larger screenshot.
+ */
+export const MAX_TOTAL_ATTACHMENT_BYTES = 10 * 1024 * 1024;
+
+/** One screenshot plus a handful of files. Beyond this it is a bug report, not a ticket. */
+export const MAX_ATTACHMENTS = 6;
+
+/**
+ * Hard ceiling on the whole request, checked against Content-Length before the
+ * body is read.
+ *
+ * This is the only limit that actually protects the server. Every other check
+ * here runs on a parsed FormData, and parsing buffers the entire upload into
+ * memory first — so by the time an oversized attachment is rejected, the box
+ * has already absorbed it. An authenticated user could otherwise post
+ * gigabytes and exhaust memory.
+ *
+ * The allowance over the attachment budget covers multipart boundaries and the
+ * subject and message fields.
+ */
+export const MAX_REQUEST_BYTES = MAX_TOTAL_ATTACHMENT_BYTES + 1024 * 1024;
+
+/**
+ * Whether a request should be refused before its body is read.
+ *
+ * A missing Content-Length means a chunked upload, where the size is not known
+ * up front. Those are allowed through to the byte counter that runs while
+ * attachments are read; the reverse proxy caps them in the meantime.
+ */
+export function exceedsRequestLimit(contentLength: string | null): boolean {
+  if (!contentLength) return false;
+  const declared = Number(contentLength);
+  if (!Number.isFinite(declared)) return false;
+  return declared > MAX_REQUEST_BYTES;
+}
+
+export const MAX_SUBJECT_LENGTH = 200;
+export const MAX_MESSAGE_LENGTH = 5000;
+
+/**
+ * What support can actually open. Deliberately narrow: this arrives in an
+ * administrator's inbox, so executables and archives have no business here
+ * even though the sender is authenticated.
+ */
+export const ALLOWED_ATTACHMENT_TYPES = [
+  "image/jpeg",
+  "image/png",
+  "image/webp",
+  "image/gif",
+  "application/pdf",
+  "text/plain",
+  "text/csv",
+];
+
+/**
+ * The file picker's `accept` attribute, derived from the allow list rather than
+ * written out again. Spelling it separately let the two drift: the picker
+ * offered `image/*`, so a small SVG or an iPhone HEIC could be chosen and was
+ * only refused after upload, by which point the user had already waited.
+ */
+export const ATTACHMENT_ACCEPT = ALLOWED_ATTACHMENT_TYPES.join(",");
+
+export interface SupportAttachmentInput {
+  filename: string;
+  contentType: string;
+  size: number;
+}
+
+export interface SupportRequestInput {
+  subject: string;
+  message: string;
+  attachments: SupportAttachmentInput[];
+}
+
+export type ValidationFailure =
+  | "subject-required"
+  | "subject-too-long"
+  | "message-required"
+  | "message-too-long"
+  | "too-many-attachments"
+  | "attachment-type-not-allowed"
+  | "attachments-too-large";
+
+export type ValidationResult =
+  | { ok: true; subject: string; message: string }
+  | { ok: false; reason: ValidationFailure };
+
+export function validateSupportRequest(input: SupportRequestInput): ValidationResult {
+  const subject = input.subject.trim();
+  const message = input.message.trim();
+
+  if (!subject) return { ok: false, reason: "subject-required" };
+  if (subject.length > MAX_SUBJECT_LENGTH) return { ok: false, reason: "subject-too-long" };
+  if (!message) return { ok: false, reason: "message-required" };
+  if (message.length > MAX_MESSAGE_LENGTH) return { ok: false, reason: "message-too-long" };
+
+  if (input.attachments.length > MAX_ATTACHMENTS) {
+    return { ok: false, reason: "too-many-attachments" };
+  }
+
+  for (const attachment of input.attachments) {
+    if (!ALLOWED_ATTACHMENT_TYPES.includes(attachment.contentType)) {
+      return { ok: false, reason: "attachment-type-not-allowed" };
+    }
+  }
+
+  const total = input.attachments.reduce((sum, a) => sum + a.size, 0);
+  if (total > MAX_TOTAL_ATTACHMENT_BYTES) {
+    return { ok: false, reason: "attachments-too-large" };
+  }
+
+  return { ok: true, subject, message };
+}
+
+/**
+ * The subject and message are written by a user and land in an HTML email, so
+ * they are escaped rather than interpolated. Without this, a report quoting
+ * markup from a page would render as markup in the administrator's client.
+ */
+export function escapeHtml(value: string): string {
+  return value
+    .replace(/&/g, "&amp;")
+    .replace(/</g, "&lt;")
+    .replace(/>/g, "&gt;")
+    .replace(/"/g, "&quot;")
+    .replace(/'/g, "&#39;");
+}
+
+/**
+ * Strip anything that could break a MIME header or escape a directory, and
+ * keep the name short. A filename arrives straight from the user's disk.
+ */
+export function sanitizeFilename(filename: string, fallback: string): string {
+  const base = filename.split(/[\\/]/).pop() ?? "";
+  const cleaned = base.replace(/[^\w.\- ]+/g, "_").replace(/^\.+/, "").trim();
+  if (!cleaned) return fallback;
+  return cleaned.slice(0, 120);
+}
+
+export interface SupportContext {
+  organizationName: string;
+  organizationId: string;
+  userName: string | null;
+  userEmail: string;
+  pageUrl: string | null;
+  userAgent: string | null;
+  appVersion: string | null;
+  submittedAt: string;
+}
+
+/**
+ * A support mail is only useful if it says who is asking and where they were.
+ * Chasing a workshop for their organization id and browser is the slowest part
+ * of answering a ticket, so all of it travels with the first message.
+ */
+export function buildSupportEmailHtml(
+  subject: string,
+  message: string,
+  context: SupportContext,
+): string {
+  const rows: [string, string | null][] = [
+    ["Organization", `${context.organizationName} (${context.organizationId})`],
+    ["From", context.userName ? `${context.userName} <${context.userEmail}>` : context.userEmail],
+    ["Page", context.pageUrl],
+    ["App version", context.appVersion],
+    ["Browser", context.userAgent],
+    ["Submitted", context.submittedAt],
+  ];
+
+  const detailRows = rows
+    .filter(([, value]) => value)
+    .map(
+      ([label, value]) =>
+        `<tr>
+          <td style="padding:4px 12px 4px 0;color:#666;vertical-align:top;white-space:nowrap;">${escapeHtml(label)}</td>
+          <td style="padding:4px 0;color:#111;word-break:break-word;">${escapeHtml(value as string)}</td>
+        </tr>`,
+    )
+    .join("");
+
+  return `
+    <div style="font-family: sans-serif; max-width: 640px; margin: 0 auto;">
+      <h2 style="margin:0 0 4px;">${escapeHtml(subject)}</h2>
+      <p style="margin:0 0 16px;color:#666;font-size:13px;">Support request from the Torqvoice app</p>
+      <div style="white-space:pre-wrap;line-height:1.5;color:#111;">${escapeHtml(message)}</div>
+      <hr style="border:none;border-top:1px solid #eee;margin:20px 0;" />
+      <table style="font-size:13px;border-collapse:collapse;">${detailRows}</table>
+    </div>
+  `;
+}

+ 82 - 0
src/features/support/Lib/supportVisibility.ts

@@ -0,0 +1,82 @@
+/**
+ * Whether this person wants to see the support button.
+ *
+ * Deliberately per user per device rather than a stored setting. AppSetting is
+ * unique on organizationId + key, so persisting a dismissal there would let one
+ * mechanic hide the button for the whole workshop. This is a personal UI
+ * preference, and losing it on a new device is a smaller cost than that.
+ */
+
+export const SUPPORT_HIDDEN_STORAGE_KEY = "torqvoice.support.hidden";
+
+/**
+ * `storage` only fires in *other* tabs, so the tab that changed the value needs
+ * its own signal to keep the button and the settings toggle in step.
+ */
+export const SUPPORT_VISIBILITY_EVENT = "torqvoice:support:visibility";
+
+/** Asks the mounted widget to open, for the "contact support" button in settings. */
+export const SUPPORT_OPEN_EVENT = "torqvoice:support:open";
+
+export function isSupportBubbleHidden(): boolean {
+  if (typeof window === "undefined") return false;
+  try {
+    return window.localStorage.getItem(SUPPORT_HIDDEN_STORAGE_KEY) === "true";
+  } catch {
+    // Private browsing and locked-down profiles can throw on access. Showing
+    // the button is the safer default: support stays reachable.
+    return false;
+  }
+}
+
+/** Where this person dragged the button to, as the trigger's top-left corner. */
+export const SUPPORT_POSITION_STORAGE_KEY = "torqvoice.support.position";
+
+export interface SupportPosition {
+  x: number;
+  y: number;
+}
+
+export function readSupportPosition(): SupportPosition | null {
+  if (typeof window === "undefined") return null;
+  try {
+    const raw = window.localStorage.getItem(SUPPORT_POSITION_STORAGE_KEY);
+    if (!raw) return null;
+    const parsed = JSON.parse(raw);
+    if (typeof parsed?.x !== "number" || typeof parsed?.y !== "number") return null;
+    if (!Number.isFinite(parsed.x) || !Number.isFinite(parsed.y)) return null;
+    return { x: parsed.x, y: parsed.y };
+  } catch {
+    // Corrupt or unreadable storage falls back to the default corner rather
+    // than leaving the button unrenderable.
+    return null;
+  }
+}
+
+export function writeSupportPosition(position: SupportPosition | null): void {
+  if (typeof window === "undefined") return;
+  try {
+    if (position) {
+      window.localStorage.setItem(SUPPORT_POSITION_STORAGE_KEY, JSON.stringify(position));
+    } else {
+      window.localStorage.removeItem(SUPPORT_POSITION_STORAGE_KEY);
+    }
+    window.dispatchEvent(new Event(SUPPORT_VISIBILITY_EVENT));
+  } catch {
+    // The position simply will not persist.
+  }
+}
+
+export function setSupportBubbleHidden(hidden: boolean): void {
+  if (typeof window === "undefined") return;
+  try {
+    if (hidden) {
+      window.localStorage.setItem(SUPPORT_HIDDEN_STORAGE_KEY, "true");
+    } else {
+      window.localStorage.removeItem(SUPPORT_HIDDEN_STORAGE_KEY);
+    }
+    window.dispatchEvent(new Event(SUPPORT_VISIBILITY_EVENT));
+  } catch {
+    // Nothing to do — the preference simply will not persist.
+  }
+}

+ 146 - 0
src/features/support/Lib/useDraggablePosition.ts

@@ -0,0 +1,146 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import {
+  readSupportPosition,
+  writeSupportPosition,
+  type SupportPosition,
+} from "./supportVisibility";
+
+/** Distance to the viewport edges the button is never allowed to cross. */
+const EDGE_MARGIN = 8;
+
+/**
+ * Room reserved at the bottom for MobileBottomNav, which occupies the bottom
+ * edge below md and would otherwise sit under the default position.
+ */
+const MOBILE_NAV_HEIGHT = 56;
+
+function clamp(value: number, min: number, max: number): number {
+  // max can fall below min on a very small viewport; min wins so the button
+  // stays reachable rather than being pushed off the top.
+  return Math.max(min, Math.min(value, max));
+}
+
+function defaultPosition(size: number): SupportPosition {
+  const isMobile = window.innerWidth < 768;
+  return {
+    x: window.innerWidth - size - (isMobile ? 16 : 24),
+    y: window.innerHeight - size - (isMobile ? MOBILE_NAV_HEIGHT + 16 : 24),
+  };
+}
+
+function clampToViewport(position: SupportPosition, size: number): SupportPosition {
+  return {
+    x: clamp(position.x, EDGE_MARGIN, window.innerWidth - size - EDGE_MARGIN),
+    y: clamp(position.y, EDGE_MARGIN, window.innerHeight - size - EDGE_MARGIN),
+  };
+}
+
+/**
+ * Lets the support button be dragged out of the way and remembers where.
+ *
+ * The button is fixed to a corner, and on a long page that corner is usually
+ * where the save button ends up. Rather than guess at a position that avoids
+ * every page, this lets the user move it and keeps that choice per device,
+ * alongside the dismissal preference.
+ *
+ * Pointer events rather than mouse events, so a touch drag works without a
+ * second code path.
+ */
+export function useDraggablePosition(size: number) {
+  const [position, setPosition] = useState<SupportPosition | null>(null);
+  const [dragging, setDragging] = useState(false);
+  // Set while a drag is in progress and cleared on the next click, so releasing
+  // after a drag does not also open the panel.
+  const movedRef = useRef(false);
+  const originRef = useRef<{ pointerX: number; pointerY: number; x: number; y: number } | null>(null);
+
+  useEffect(() => {
+    const stored = readSupportPosition();
+    setPosition(clampToViewport(stored ?? defaultPosition(size), size));
+  }, [size]);
+
+  // A window that shrinks, or a phone that rotates, can leave a stored position
+  // outside the viewport with no way to reach the button.
+  useEffect(() => {
+    const onResize = () => {
+      setPosition((current) => (current ? clampToViewport(current, size) : current));
+    };
+    window.addEventListener("resize", onResize);
+    window.addEventListener("orientationchange", onResize);
+    return () => {
+      window.removeEventListener("resize", onResize);
+      window.removeEventListener("orientationchange", onResize);
+    };
+  }, [size]);
+
+  const onPointerDown = useCallback(
+    (event: React.PointerEvent) => {
+      // Left button or touch only; a right-click should not start a drag.
+      if (event.button !== 0) return;
+      if (!position) return;
+      movedRef.current = false;
+      originRef.current = {
+        pointerX: event.clientX,
+        pointerY: event.clientY,
+        x: position.x,
+        y: position.y,
+      };
+      // Capture so the drag survives the pointer leaving the small target.
+      (event.currentTarget as HTMLElement).setPointerCapture?.(event.pointerId);
+    },
+    [position],
+  );
+
+  const onPointerMove = useCallback(
+    (event: React.PointerEvent) => {
+      const origin = originRef.current;
+      if (!origin) return;
+
+      const dx = event.clientX - origin.pointerX;
+      const dy = event.clientY - origin.pointerY;
+
+      // A few pixels of travel while pressing is a click, not a drag. Without
+      // this threshold an ordinary tap would register as a move and swallow
+      // the click that opens the panel.
+      if (!movedRef.current && Math.abs(dx) < 4 && Math.abs(dy) < 4) return;
+
+      if (!movedRef.current) {
+        movedRef.current = true;
+        setDragging(true);
+      }
+      setPosition(clampToViewport({ x: origin.x + dx, y: origin.y + dy }, size));
+    },
+    [size],
+  );
+
+  const onPointerUp = useCallback(
+    (event: React.PointerEvent) => {
+      (event.currentTarget as HTMLElement).releasePointerCapture?.(event.pointerId);
+      originRef.current = null;
+      if (movedRef.current) {
+        setDragging(false);
+        setPosition((current) => {
+          if (current) writeSupportPosition(current);
+          return current;
+        });
+      }
+    },
+    [],
+  );
+
+  /** True when the pointer press that just ended was a drag, so the click should be ignored. */
+  const consumeDrag = useCallback(() => {
+    const wasDrag = movedRef.current;
+    movedRef.current = false;
+    return wasDrag;
+  }, []);
+
+  return {
+    position,
+    dragging,
+    consumeDrag,
+    dragHandlers: { onPointerDown, onPointerMove, onPointerUp, onPointerCancel: onPointerUp },
+  };
+}

+ 2 - 0
src/i18n/request.ts

@@ -58,6 +58,7 @@ export default getRequestConfig(async () => {
   const telegram = (await import(`../../messages/${locale}/telegram.json`)).default
   const telegram = (await import(`../../messages/${locale}/telegram.json`)).default
   const telegramMessages = (await import(`../../messages/${locale}/telegramMessages.json`)).default
   const telegramMessages = (await import(`../../messages/${locale}/telegramMessages.json`)).default
   const statusReport = (await import(`../../messages/${locale}/statusReport.json`)).default
   const statusReport = (await import(`../../messages/${locale}/statusReport.json`)).default
+  const support = (await import(`../../messages/${locale}/support.json`)).default
 
 
   return {
   return {
     locale,
     locale,
@@ -90,6 +91,7 @@ export default getRequestConfig(async () => {
       telegram,
       telegram,
       telegramMessages,
       telegramMessages,
       statusReport,
       statusReport,
+      support,
     },
     },
   }
   }
 })
 })

+ 41 - 0
src/lib/support.ts

@@ -0,0 +1,41 @@
+/**
+ * Whether a workshop can raise a support request from inside the app.
+ *
+ * Two independent conditions have to hold, and the order matters. Cloud mode
+ * comes first because a self-hosted install has no support desk behind it —
+ * there is nobody on the other end of the mail, so the button must never
+ * appear there even if the setting somehow made it into the database (a
+ * restored cloud backup, say). The stored flag is second and defaults to off,
+ * so a fresh cloud deployment ships with the feature dark until someone turns
+ * it on from the torqvoice.com admin dashboard.
+ */
+
+import { db } from "@/lib/db";
+import { isCloudMode } from "@/lib/features";
+import { SYSTEM_SETTING_KEYS } from "@/features/admin/Schema/systemSettingsSchema";
+
+/** Used when no recipient is configured, matching the branding site's contact form. */
+export const DEFAULT_SUPPORT_RECIPIENT = "post@torqvoice.com";
+
+export async function isSupportEnabled(): Promise<boolean> {
+  if (!isCloudMode()) return false;
+
+  const setting = await db.systemSetting.findUnique({
+    where: { key: SYSTEM_SETTING_KEYS.SUPPORT_ENABLED },
+    select: { value: true },
+  });
+
+  // Only the exact string enables it. A stale or malformed value reads as off,
+  // so the failure mode is a missing button rather than mail sent to nobody.
+  return setting?.value === "true";
+}
+
+export async function getSupportRecipient(): Promise<string> {
+  const setting = await db.systemSetting.findUnique({
+    where: { key: SYSTEM_SETTING_KEYS.SUPPORT_RECIPIENT_EMAIL },
+    select: { value: true },
+  });
+
+  const configured = setting?.value?.trim();
+  return configured ? configured : DEFAULT_SUPPORT_RECIPIENT;
+}