Bernt Christian Egeland 1 lună în urmă
părinte
comite
0f963bceae

+ 17 - 0
messages/de/messages.json

@@ -54,5 +54,22 @@
     "smsSent": "SMS gesendet",
     "smsSent": "SMS gesendet",
     "sentTo": "{label} wurde an {name} ({phone}) gesendet",
     "sentTo": "{label} wurde an {name} ({phone}) gesendet",
     "sendError": "SMS konnte nicht gesendet werden"
     "sendError": "SMS konnte nicht gesendet werden"
+  },
+  "compose": {
+    "newMessage": "Neue Nachricht",
+    "title": "Neue Nachricht",
+    "searchPlaceholder": "Kunden nach Name oder Nummer suchen",
+    "noMatches": "Keine Kunden passen zur Suche",
+    "noRecipients": "Keine Kunden mit Telefonnummer",
+    "noRecipientsHint": "Hinterlegen Sie eine Telefonnummer, um zu schreiben.",
+    "changeRecipient": "Andere Person wählen",
+    "messageLabel": "Nachricht",
+    "messagePlaceholder": "Nachricht an {name} ...",
+    "segments": "{chars} Zeichen · {segments} SMS",
+    "send": "Senden",
+    "scheduleInstead": "Stattdessen planen",
+    "sent": "An {name} gesendet",
+    "sendError": "Nachricht konnte nicht gesendet werden",
+    "templates": "Vorlagen"
   }
   }
 }
 }

+ 17 - 0
messages/en/messages.json

@@ -54,5 +54,22 @@
     "smsSent": "SMS Sent",
     "smsSent": "SMS Sent",
     "sentTo": "{label} has been sent to {name} ({phone})",
     "sentTo": "{label} has been sent to {name} ({phone})",
     "sendError": "Failed to send SMS"
     "sendError": "Failed to send SMS"
+  },
+  "compose": {
+    "newMessage": "New message",
+    "title": "New message",
+    "searchPlaceholder": "Search customers by name or number",
+    "noMatches": "No customers match your search",
+    "noRecipients": "No customers with a phone number",
+    "noRecipientsHint": "Add a phone number to a customer to text them.",
+    "changeRecipient": "Pick someone else",
+    "messageLabel": "Message",
+    "messagePlaceholder": "Message {name}...",
+    "segments": "{chars} characters · {segments} SMS",
+    "send": "Send",
+    "scheduleInstead": "Schedule instead",
+    "sent": "Sent to {name}",
+    "sendError": "Could not send the message",
+    "templates": "Templates"
   }
   }
 }
 }

+ 17 - 0
messages/es/messages.json

@@ -54,5 +54,22 @@
     "smsSent": "SMS enviado",
     "smsSent": "SMS enviado",
     "sentTo": "{label} ha sido enviado a {name} ({phone})",
     "sentTo": "{label} ha sido enviado a {name} ({phone})",
     "sendError": "No se pudo enviar el SMS"
     "sendError": "No se pudo enviar el SMS"
+  },
+  "compose": {
+    "newMessage": "Nuevo mensaje",
+    "title": "Nuevo mensaje",
+    "searchPlaceholder": "Buscar clientes por nombre o número",
+    "noMatches": "Ningún cliente coincide con la búsqueda",
+    "noRecipients": "No hay clientes con teléfono",
+    "noRecipientsHint": "Añade un teléfono al cliente para enviarle SMS.",
+    "changeRecipient": "Elegir a otra persona",
+    "messageLabel": "Mensaje",
+    "messagePlaceholder": "Mensaje para {name}...",
+    "segments": "{chars} caracteres · {segments} SMS",
+    "send": "Enviar",
+    "scheduleInstead": "Programar en su lugar",
+    "sent": "Enviado a {name}",
+    "sendError": "No se pudo enviar el mensaje",
+    "templates": "Plantillas"
   }
   }
 }
 }

+ 17 - 0
messages/fr/messages.json

@@ -54,5 +54,22 @@
     "smsSent": "SMS envoyé",
     "smsSent": "SMS envoyé",
     "sentTo": "{label} a été envoyé à {name} ({phone})",
     "sentTo": "{label} a été envoyé à {name} ({phone})",
     "sendError": "Impossible d'envoyer le SMS"
     "sendError": "Impossible d'envoyer le SMS"
+  },
+  "compose": {
+    "newMessage": "Nouveau message",
+    "title": "Nouveau message",
+    "searchPlaceholder": "Rechercher un client par nom ou numéro",
+    "noMatches": "Aucun client ne correspond à la recherche",
+    "noRecipients": "Aucun client avec un numéro de téléphone",
+    "noRecipientsHint": "Ajoutez un numéro au client pour lui écrire.",
+    "changeRecipient": "Choisir quelqu'un d'autre",
+    "messageLabel": "Message",
+    "messagePlaceholder": "Message à {name}...",
+    "segments": "{chars} caractères · {segments} SMS",
+    "send": "Envoyer",
+    "scheduleInstead": "Programmer plutôt",
+    "sent": "Envoyé à {name}",
+    "sendError": "Impossible d'envoyer le message",
+    "templates": "Modèles"
   }
   }
 }
 }

+ 17 - 0
messages/it/messages.json

@@ -54,5 +54,22 @@
     "smsSent": "SMS inviato",
     "smsSent": "SMS inviato",
     "sentTo": "{label} è stato inviato a {name} ({phone})",
     "sentTo": "{label} è stato inviato a {name} ({phone})",
     "sendError": "Impossibile inviare l'SMS"
     "sendError": "Impossibile inviare l'SMS"
+  },
+  "compose": {
+    "newMessage": "Nuovo messaggio",
+    "title": "Nuovo messaggio",
+    "searchPlaceholder": "Cerca clienti per nome o numero",
+    "noMatches": "Nessun cliente corrisponde alla ricerca",
+    "noRecipients": "Nessun cliente con numero di telefono",
+    "noRecipientsHint": "Aggiungi un numero al cliente per scrivergli.",
+    "changeRecipient": "Scegli un'altra persona",
+    "messageLabel": "Messaggio",
+    "messagePlaceholder": "Messaggio a {name}...",
+    "segments": "{chars} caratteri · {segments} SMS",
+    "send": "Invia",
+    "scheduleInstead": "Pianifica invece",
+    "sent": "Inviato a {name}",
+    "sendError": "Impossibile inviare il messaggio",
+    "templates": "Modelli"
   }
   }
 }
 }

+ 17 - 0
messages/lt/messages.json

@@ -54,5 +54,22 @@
     "smsSent": "SMS išsiųsta",
     "smsSent": "SMS išsiųsta",
     "sentTo": "{label} išsiųsta {name} ({phone})",
     "sentTo": "{label} išsiųsta {name} ({phone})",
     "sendError": "Nepavyko išsiųsti SMS"
     "sendError": "Nepavyko išsiųsti SMS"
+  },
+  "compose": {
+    "newMessage": "Nauja žinutė",
+    "title": "Nauja žinutė",
+    "searchPlaceholder": "Ieškoti klientų pagal vardą ar numerį",
+    "noMatches": "Nėra klientų pagal paiešką",
+    "noRecipients": "Nėra klientų su telefono numeriu",
+    "noRecipientsHint": "Pridėkite klientui telefono numerį, kad galėtumėte rašyti.",
+    "changeRecipient": "Pasirinkti kitą",
+    "messageLabel": "Žinutė",
+    "messagePlaceholder": "Žinutė {name}...",
+    "segments": "{chars} simbolių · {segments} SMS",
+    "send": "Siųsti",
+    "scheduleInstead": "Geriau suplanuoti",
+    "sent": "Išsiųsta {name}",
+    "sendError": "Nepavyko išsiųsti žinutės",
+    "templates": "Šablonai"
   }
   }
 }
 }

+ 17 - 0
messages/nb/messages.json

@@ -54,5 +54,22 @@
     "smsSent": "SMS sendt",
     "smsSent": "SMS sendt",
     "sentTo": "{label} har blitt sendt til {name} ({phone})",
     "sentTo": "{label} har blitt sendt til {name} ({phone})",
     "sendError": "Kunne ikke sende SMS"
     "sendError": "Kunne ikke sende SMS"
+  },
+  "compose": {
+    "newMessage": "Ny melding",
+    "title": "Ny melding",
+    "searchPlaceholder": "Søk kunder på navn eller nummer",
+    "noMatches": "Ingen kunder treffer søket",
+    "noRecipients": "Ingen kunder med telefonnummer",
+    "noRecipientsHint": "Legg til et telefonnummer på kunden for å sende SMS.",
+    "changeRecipient": "Velg en annen",
+    "messageLabel": "Melding",
+    "messagePlaceholder": "Melding til {name} ...",
+    "segments": "{chars} tegn · {segments} SMS",
+    "send": "Send",
+    "scheduleInstead": "Planlegg i stedet",
+    "sent": "Sendt til {name}",
+    "sendError": "Kunne ikke sende meldingen",
+    "templates": "Maler"
   }
   }
 }
 }

+ 17 - 0
messages/nl/messages.json

@@ -54,5 +54,22 @@
     "smsSent": "SMS verzonden",
     "smsSent": "SMS verzonden",
     "sentTo": "{label} is verzonden naar {name} ({phone})",
     "sentTo": "{label} is verzonden naar {name} ({phone})",
     "sendError": "SMS kon niet worden verzonden"
     "sendError": "SMS kon niet worden verzonden"
+  },
+  "compose": {
+    "newMessage": "Nieuw bericht",
+    "title": "Nieuw bericht",
+    "searchPlaceholder": "Klanten zoeken op naam of nummer",
+    "noMatches": "Geen klanten gevonden",
+    "noRecipients": "Geen klanten met een telefoonnummer",
+    "noRecipientsHint": "Voeg een telefoonnummer toe om te kunnen sms'en.",
+    "changeRecipient": "Iemand anders kiezen",
+    "messageLabel": "Bericht",
+    "messagePlaceholder": "Bericht aan {name}...",
+    "segments": "{chars} tekens · {segments} sms",
+    "send": "Versturen",
+    "scheduleInstead": "Liever plannen",
+    "sent": "Verzonden naar {name}",
+    "sendError": "Kan het bericht niet versturen",
+    "templates": "Sjablonen"
   }
   }
 }
 }

+ 17 - 0
messages/pl/messages.json

@@ -54,5 +54,22 @@
     "smsSent": "SMS wysłany",
     "smsSent": "SMS wysłany",
     "sentTo": "{label} został wysłany do {name} ({phone})",
     "sentTo": "{label} został wysłany do {name} ({phone})",
     "sendError": "Nie udało się wysłać SMS"
     "sendError": "Nie udało się wysłać SMS"
+  },
+  "compose": {
+    "newMessage": "Nowa wiadomość",
+    "title": "Nowa wiadomość",
+    "searchPlaceholder": "Szukaj klientów po nazwie lub numerze",
+    "noMatches": "Brak klientów pasujących do wyszukiwania",
+    "noRecipients": "Brak klientów z numerem telefonu",
+    "noRecipientsHint": "Dodaj klientowi numer telefonu, aby wysłać SMS.",
+    "changeRecipient": "Wybierz kogoś innego",
+    "messageLabel": "Wiadomość",
+    "messagePlaceholder": "Wiadomość do {name}...",
+    "segments": "{chars} znaków · {segments} SMS",
+    "send": "Wyślij",
+    "scheduleInstead": "Zaplanuj zamiast tego",
+    "sent": "Wysłano do {name}",
+    "sendError": "Nie udało się wysłać wiadomości",
+    "templates": "Szablony"
   }
   }
 }
 }

+ 17 - 0
messages/pt-BR/messages.json

@@ -54,5 +54,22 @@
     "smsSent": "SMS enviado",
     "smsSent": "SMS enviado",
     "sentTo": "{label} foi enviado para {name} ({phone})",
     "sentTo": "{label} foi enviado para {name} ({phone})",
     "sendError": "Falha ao enviar o SMS"
     "sendError": "Falha ao enviar o SMS"
+  },
+  "compose": {
+    "newMessage": "Nova mensagem",
+    "title": "Nova mensagem",
+    "searchPlaceholder": "Buscar clientes por nome ou número",
+    "noMatches": "Nenhum cliente corresponde à busca",
+    "noRecipients": "Nenhum cliente com telefone",
+    "noRecipientsHint": "Adicione um telefone ao cliente para enviar SMS.",
+    "changeRecipient": "Escolher outra pessoa",
+    "messageLabel": "Mensagem",
+    "messagePlaceholder": "Mensagem para {name}...",
+    "segments": "{chars} caracteres · {segments} SMS",
+    "send": "Enviar",
+    "scheduleInstead": "Agendar em vez disso",
+    "sent": "Enviada para {name}",
+    "sendError": "Não foi possível enviar a mensagem",
+    "templates": "Modelos"
   }
   }
 }
 }

+ 17 - 0
messages/ru/messages.json

@@ -54,5 +54,22 @@
     "smsSent": "SMS отправлено",
     "smsSent": "SMS отправлено",
     "sentTo": "{label} отправлено {name} ({phone})",
     "sentTo": "{label} отправлено {name} ({phone})",
     "sendError": "Не удалось отправить SMS"
     "sendError": "Не удалось отправить SMS"
+  },
+  "compose": {
+    "newMessage": "Новое сообщение",
+    "title": "Новое сообщение",
+    "searchPlaceholder": "Поиск клиентов по имени или номеру",
+    "noMatches": "Нет клиентов по запросу",
+    "noRecipients": "Нет клиентов с номером телефона",
+    "noRecipientsHint": "Добавьте клиенту номер телефона, чтобы писать ему.",
+    "changeRecipient": "Выбрать другого",
+    "messageLabel": "Сообщение",
+    "messagePlaceholder": "Сообщение для {name}...",
+    "segments": "{chars} символов · {segments} SMS",
+    "send": "Отправить",
+    "scheduleInstead": "Лучше запланировать",
+    "sent": "Отправлено {name}",
+    "sendError": "Не удалось отправить сообщение",
+    "templates": "Шаблоны"
   }
   }
 }
 }

+ 17 - 0
messages/tr/messages.json

@@ -54,5 +54,22 @@
     "smsSent": "SMS gönderildi",
     "smsSent": "SMS gönderildi",
     "sentTo": "{label}, {name} ({phone}) kişisine gönderildi",
     "sentTo": "{label}, {name} ({phone}) kişisine gönderildi",
     "sendError": "SMS gönderilemedi"
     "sendError": "SMS gönderilemedi"
+  },
+  "compose": {
+    "newMessage": "Yeni mesaj",
+    "title": "Yeni mesaj",
+    "searchPlaceholder": "Müşterileri ada veya numaraya göre ara",
+    "noMatches": "Aramayla eşleşen müşteri yok",
+    "noRecipients": "Telefon numarası olan müşteri yok",
+    "noRecipientsHint": "Mesaj gönderebilmek için müşteriye telefon numarası ekleyin.",
+    "changeRecipient": "Başkasını seç",
+    "messageLabel": "Mesaj",
+    "messagePlaceholder": "{name} kişisine mesaj...",
+    "segments": "{chars} karakter · {segments} SMS",
+    "send": "Gönder",
+    "scheduleInstead": "Bunun yerine planla",
+    "sent": "{name} kişisine gönderildi",
+    "sendError": "Mesaj gönderilemedi",
+    "templates": "Şablonlar"
   }
   }
 }
 }

+ 9 - 3
src/features/scheduled-messages/Components/ScheduleMessageDialog.tsx

@@ -65,6 +65,8 @@ interface ScheduleMessageDialogProps {
   /** YYYY-MM-DD the message goes out on, e.g. the calendar day that was right-clicked */
   /** YYYY-MM-DD the message goes out on, e.g. the calendar day that was right-clicked */
   defaultDate?: string;
   defaultDate?: string;
   defaultCustomer?: { id: string; name: string; company: string | null } | null;
   defaultCustomer?: { id: string; name: string; company: string | null } | null;
+  /** Pre-filled text, e.g. a draft handed over from the compose dialog */
+  defaultBody?: string;
   onSaved?: () => void;
   onSaved?: () => void;
 }
 }
 
 
@@ -86,6 +88,7 @@ export function ScheduleMessageDialog({
   message,
   message,
   defaultDate,
   defaultDate,
   defaultCustomer = null,
   defaultCustomer = null,
+  defaultBody,
   onSaved,
   onSaved,
 }: ScheduleMessageDialogProps) {
 }: ScheduleMessageDialogProps) {
   const t = useTranslations("scheduledMessages.dialog");
   const t = useTranslations("scheduledMessages.dialog");
@@ -117,17 +120,20 @@ export function ScheduleMessageDialog({
       setFrequency(message.frequency as MessageFrequency);
       setFrequency(message.frequency as MessageFrequency);
       setEndDate(message.endDate ? toLocalDateStr(message.endDate) : "");
       setEndDate(message.endDate ? toLocalDateStr(message.endDate) : "");
     } else {
     } else {
-      setChannel(availableChannels[0] ?? "email");
+      // A draft handed over from the SMS composer stays an SMS when possible
+      setChannel(
+        defaultBody && availableChannels.includes("sms") ? "sms" : availableChannels[0] ?? "email",
+      );
       setCustomerId(defaultCustomer?.id ?? "");
       setCustomerId(defaultCustomer?.id ?? "");
       setRecipient("");
       setRecipient("");
       setSubject("");
       setSubject("");
-      setBody("");
+      setBody(defaultBody ?? "");
       setDate(defaultDate ?? toLocalDateStr(new Date()));
       setDate(defaultDate ?? toLocalDateStr(new Date()));
       setTime("09:00");
       setTime("09:00");
       setFrequency("once");
       setFrequency("once");
       setEndDate("");
       setEndDate("");
     }
     }
-  }, [open, message, defaultDate, defaultCustomer?.id, availableChannels]);
+  }, [open, message, defaultDate, defaultBody, defaultCustomer?.id, availableChannels]);
 
 
   const needsRecipient = channel !== "in_app" && !customerId;
   const needsRecipient = channel !== "in_app" && !customerId;
   const recipientLabel = useMemo(() => {
   const recipientLabel = useMemo(() => {

+ 70 - 6
src/features/sms/Components/MessagesPageClient.tsx

@@ -42,6 +42,7 @@ import {
 import { toast } from "sonner";
 import { toast } from "sonner";
 import { getConversation, getRecentSmsThreads, deleteConversation } from "../Actions/smsActions";
 import { getConversation, getRecentSmsThreads, deleteConversation } from "../Actions/smsActions";
 import { SmsConversation } from "./SmsConversation";
 import { SmsConversation } from "./SmsConversation";
+import { NewSmsDialog } from "./NewSmsDialog";
 import { useNotificationStore } from "@/features/notifications/store/notificationStore";
 import { useNotificationStore } from "@/features/notifications/store/notificationStore";
 import { ScheduledMessageList } from "@/features/scheduled-messages/Components/ScheduledMessageList";
 import { ScheduledMessageList } from "@/features/scheduled-messages/Components/ScheduledMessageList";
 import { ScheduleMessageDialog } from "@/features/scheduled-messages/Components/ScheduleMessageDialog";
 import { ScheduleMessageDialog } from "@/features/scheduled-messages/Components/ScheduleMessageDialog";
@@ -118,6 +119,7 @@ export function MessagesPageClient({
   const t = useTranslations("messages.threads");
   const t = useTranslations("messages.threads");
   const tp = useTranslations("messages.page");
   const tp = useTranslations("messages.page");
   const ts = useTranslations("scheduledMessages.list");
   const ts = useTranslations("scheduledMessages.list");
+  const tcomp = useTranslations("messages.compose");
   const tc = useTranslations("common.buttons");
   const tc = useTranslations("common.buttons");
 
 
   const tabParam = searchParams.get("tab");
   const tabParam = searchParams.get("tab");
@@ -141,6 +143,12 @@ export function MessagesPageClient({
   const [deleteThreadTarget, setDeleteThreadTarget] = useState<SmsThread | null>(null);
   const [deleteThreadTarget, setDeleteThreadTarget] = useState<SmsThread | null>(null);
   const [isDeletingThread, setIsDeletingThread] = useState(false);
   const [isDeletingThread, setIsDeletingThread] = useState(false);
   const [showScheduleDialog, setShowScheduleDialog] = useState(false);
   const [showScheduleDialog, setShowScheduleDialog] = useState(false);
+  const [showComposeDialog, setShowComposeDialog] = useState(false);
+  // Carried over when a draft is handed from compose to scheduling
+  const [scheduleSeed, setScheduleSeed] = useState<{
+    customer: { id: string; name: string; company: string | null } | null;
+    body: string;
+  } | null>(null);
 
 
   /** Tabs live in the URL, so a conversation or the queue can be linked to directly. */
   /** Tabs live in the URL, so a conversation or the queue can be linked to directly. */
   const setTab = useCallback(
   const setTab = useCallback(
@@ -338,10 +346,25 @@ export function MessagesPageClient({
           </Button>
           </Button>
         </div>
         </div>
 
 
-        <Button size="sm" onClick={() => setShowScheduleDialog(true)}>
-          <Plus className="mr-1 h-3.5 w-3.5" />
-          {ts("scheduleMessage")}
-        </Button>
+        <div className="flex items-center gap-2">
+          <Button
+            size="sm"
+            variant="outline"
+            onClick={() => {
+              setScheduleSeed(null);
+              setShowScheduleDialog(true);
+            }}
+          >
+            <CalendarClock className="mr-1 h-3.5 w-3.5" />
+            {ts("scheduleMessage")}
+          </Button>
+          {smsConfigured && (
+            <Button size="sm" onClick={() => setShowComposeDialog(true)}>
+              <Plus className="mr-1 h-3.5 w-3.5" />
+              {tcomp("newMessage")}
+            </Button>
+          )}
+        </div>
       </div>
       </div>
 
 
       <div className="flex h-[calc(100vh-11rem)] overflow-hidden rounded-lg border bg-background">
       <div className="flex h-[calc(100vh-11rem)] overflow-hidden rounded-lg border bg-background">
@@ -398,6 +421,17 @@ export function MessagesPageClient({
                     <p className="text-center text-sm text-muted-foreground">
                     <p className="text-center text-sm text-muted-foreground">
                       {search ? t("noSearchResults") : t("empty")}
                       {search ? t("noSearchResults") : t("empty")}
                     </p>
                     </p>
+                    {!search && (
+                      <Button
+                        size="sm"
+                        variant="outline"
+                        className="mt-3"
+                        onClick={() => setShowComposeDialog(true)}
+                      >
+                        <Plus className="mr-1 h-3.5 w-3.5" />
+                        {tcomp("newMessage")}
+                      </Button>
+                    )}
                   </div>
                   </div>
                 ) : (
                 ) : (
                   <>
                   <>
@@ -556,6 +590,15 @@ export function MessagesPageClient({
                 <div className="flex flex-1 flex-col items-center justify-center text-muted-foreground">
                 <div className="flex flex-1 flex-col items-center justify-center text-muted-foreground">
                   <MessageSquare className="mb-3 h-10 w-10 text-muted-foreground/40" />
                   <MessageSquare className="mb-3 h-10 w-10 text-muted-foreground/40" />
                   <p className="text-sm">{t("selectConversation")}</p>
                   <p className="text-sm">{t("selectConversation")}</p>
+                  <Button
+                    size="sm"
+                    variant="outline"
+                    className="mt-3"
+                    onClick={() => setShowComposeDialog(true)}
+                  >
+                    <Plus className="mr-1 h-3.5 w-3.5" />
+                    {tcomp("newMessage")}
+                  </Button>
                 </div>
                 </div>
               )}
               )}
             </div>
             </div>
@@ -563,16 +606,37 @@ export function MessagesPageClient({
         )}
         )}
       </div>
       </div>
 
 
+      <NewSmsDialog
+        open={showComposeDialog}
+        onOpenChange={setShowComposeDialog}
+        onSent={async (customerId) => {
+          await refreshThreads();
+          handleSelectThread(customerId);
+        }}
+        onSchedule={(recipient, body) => {
+          setScheduleSeed({
+            customer: recipient
+              ? { id: recipient.id, name: recipient.name, company: recipient.company }
+              : null,
+            body,
+          });
+          setShowScheduleDialog(true);
+        }}
+      />
+
       <ScheduleMessageDialog
       <ScheduleMessageDialog
         open={showScheduleDialog}
         open={showScheduleDialog}
         onOpenChange={setShowScheduleDialog}
         onOpenChange={setShowScheduleDialog}
         availableChannels={availableChannels}
         availableChannels={availableChannels}
         defaultCustomer={
         defaultCustomer={
-          selectedCustomerId && conversation
+          scheduleSeed?.customer ??
+          (selectedCustomerId && conversation
             ? { id: selectedCustomerId, name: conversation.customerName, company: null }
             ? { id: selectedCustomerId, name: conversation.customerName, company: null }
-            : null
+            : null)
         }
         }
+        defaultBody={scheduleSeed?.body}
         onSaved={() => {
         onSaved={() => {
+          setScheduleSeed(null);
           refreshScheduled();
           refreshScheduled();
           setTab("scheduled");
           setTab("scheduled");
         }}
         }}

+ 243 - 0
src/features/sms/Components/NewSmsDialog.tsx

@@ -0,0 +1,243 @@
+"use client";
+
+import { useEffect, useMemo, useState } from "react";
+import { useTranslations } from "next-intl";
+import { toast } from "sonner";
+import { ArrowLeft, CalendarClock, Loader2, Phone, Search, Send, User } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import {
+  Dialog,
+  DialogContent,
+  DialogHeader,
+  DialogTitle,
+} from "@/components/ui/dialog";
+import { useDebouncedSearch } from "@/hooks/use-debounced-search";
+import { searchSmsRecipients, sendSmsToCustomer } from "../Actions/smsActions";
+import { SmsTemplateMenu } from "./SmsTemplateMenu";
+
+interface Recipient {
+  id: string;
+  name: string;
+  company: string | null;
+  phone: string | null;
+}
+
+/** GSM-7 segment sizes; a single message, then concatenated ones. */
+const SINGLE_SEGMENT = 160;
+const MULTI_SEGMENT = 153;
+
+function segmentCount(length: number): number {
+  if (length === 0) return 0;
+  if (length <= SINGLE_SEGMENT) return 1;
+  return Math.ceil(length / MULTI_SEGMENT);
+}
+
+interface NewSmsDialogProps {
+  open: boolean;
+  onOpenChange: (open: boolean) => void;
+  /** Preselected recipient, e.g. the thread that is open */
+  defaultRecipient?: Recipient | null;
+  /** Called with the customer id once a message is on its way */
+  onSent?: (customerId: string) => void;
+  /** Hands the drafted text over to the scheduling dialog */
+  onSchedule?: (recipient: Recipient | null, body: string) => void;
+}
+
+export function NewSmsDialog({
+  open,
+  onOpenChange,
+  defaultRecipient = null,
+  onSent,
+  onSchedule,
+}: NewSmsDialogProps) {
+  const t = useTranslations("messages.compose");
+  const tc = useTranslations("common.buttons");
+
+  const [recipient, setRecipient] = useState<Recipient | null>(defaultRecipient);
+  const [results, setResults] = useState<Recipient[]>([]);
+  const [searching, setSearching] = useState(false);
+  const [body, setBody] = useState("");
+  const [sending, setSending] = useState(false);
+
+  const runSearch = useMemo(
+    () => async (term: string | undefined) => {
+      setSearching(true);
+      const result = await searchSmsRecipients(term ?? undefined);
+      if (result.success && result.data) setResults(result.data);
+      setSearching(false);
+    },
+    [],
+  );
+
+  const { value: search, setValue: setSearch } = useDebouncedSearch("", runSearch);
+
+  // Re-seed on every open, and load the first page of recipients
+  useEffect(() => {
+    if (!open) return;
+    setRecipient(defaultRecipient);
+    setBody("");
+    setSearch("");
+    runSearch("");
+  }, [open, defaultRecipient, runSearch, setSearch]);
+
+  const handleSend = async (e: React.FormEvent) => {
+    e.preventDefault();
+    if (!recipient || !body.trim()) return;
+    setSending(true);
+
+    const result = await sendSmsToCustomer({ customerId: recipient.id, body: body.trim() });
+
+    if (result.success) {
+      toast.success(t("sent", { name: recipient.name }));
+      onOpenChange(false);
+      onSent?.(recipient.id);
+    } else {
+      toast.error(result.error || t("sendError"));
+    }
+    setSending(false);
+  };
+
+  const chars = body.length;
+  const segments = segmentCount(chars);
+
+  return (
+    <Dialog open={open} onOpenChange={onOpenChange}>
+      <DialogContent className="sm:max-w-md" aria-describedby={undefined}>
+        <DialogHeader>
+          <DialogTitle>{t("title")}</DialogTitle>
+        </DialogHeader>
+
+        {!recipient ? (
+          <div className="space-y-3">
+            <div className="relative">
+              <Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
+              <Input
+                value={search}
+                onChange={(e) => setSearch(e.target.value)}
+                placeholder={t("searchPlaceholder")}
+                className="pl-9"
+                autoFocus
+              />
+              {searching && (
+                <Loader2 className="absolute right-2.5 top-1/2 h-4 w-4 -translate-y-1/2 animate-spin text-muted-foreground" />
+              )}
+            </div>
+
+            <div className="max-h-[320px] overflow-y-auto">
+              {results.length === 0 ? (
+                <div className="px-2 py-8 text-center">
+                  <p className="text-sm text-muted-foreground">
+                    {search ? t("noMatches") : t("noRecipients")}
+                  </p>
+                  <p className="mt-1 text-xs text-muted-foreground">{t("noRecipientsHint")}</p>
+                </div>
+              ) : (
+                <div className="space-y-1">
+                  {results.map((customer) => (
+                    <button
+                      key={customer.id}
+                      type="button"
+                      onClick={() => setRecipient(customer)}
+                      className="flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-left transition-colors hover:bg-muted"
+                    >
+                      <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted">
+                        <User className="h-4 w-4 text-muted-foreground" />
+                      </div>
+                      <div className="min-w-0 flex-1">
+                        <p className="truncate text-sm font-medium">
+                          {customer.name}
+                          {customer.company && (
+                            <span className="ml-1.5 text-xs text-muted-foreground">
+                              {customer.company}
+                            </span>
+                          )}
+                        </p>
+                        <p className="flex items-center gap-1 text-xs text-muted-foreground">
+                          <Phone className="h-3 w-3" />
+                          {customer.phone}
+                        </p>
+                      </div>
+                    </button>
+                  ))}
+                </div>
+              )}
+            </div>
+          </div>
+        ) : (
+          <form onSubmit={handleSend} className="space-y-4">
+            <div className="flex items-center gap-2 rounded-lg border bg-muted/50 px-3 py-2">
+              <Button
+                type="button"
+                variant="ghost"
+                size="icon"
+                className="h-7 w-7 shrink-0"
+                onClick={() => setRecipient(null)}
+                aria-label={t("changeRecipient")}
+              >
+                <ArrowLeft className="h-4 w-4" />
+              </Button>
+              <div className="min-w-0 flex-1">
+                <p className="truncate text-sm font-medium">{recipient.name}</p>
+                <p className="text-xs text-muted-foreground">{recipient.phone}</p>
+              </div>
+            </div>
+
+            <div className="space-y-2">
+              <div className="flex items-center justify-between">
+                <Label htmlFor="new-sms-body">{t("messageLabel")}</Label>
+                <SmsTemplateMenu
+                  className="h-7 px-2 text-xs text-muted-foreground"
+                  onPick={(template) =>
+                    setBody((current) => (current ? `${current}\n${template}` : template))
+                  }
+                />
+              </div>
+              <Textarea
+                id="new-sms-body"
+                value={body}
+                onChange={(e) => setBody(e.target.value)}
+                placeholder={t("messagePlaceholder", { name: recipient.name })}
+                rows={5}
+                autoFocus
+                required
+              />
+              <p className="text-right text-xs text-muted-foreground tabular-nums">
+                {t("segments", { chars, segments })}
+              </p>
+            </div>
+
+            <div className="flex flex-wrap justify-end gap-2 pt-1">
+              <Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
+                {tc("cancel")}
+              </Button>
+              {onSchedule && (
+                <Button
+                  type="button"
+                  variant="outline"
+                  onClick={() => {
+                    onOpenChange(false);
+                    onSchedule(recipient, body.trim());
+                  }}
+                >
+                  <CalendarClock className="mr-2 h-4 w-4" />
+                  {t("scheduleInstead")}
+                </Button>
+              )}
+              <Button type="submit" disabled={sending || !body.trim()}>
+                {sending ? (
+                  <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+                ) : (
+                  <Send className="mr-2 h-4 w-4" />
+                )}
+                {t("send")}
+              </Button>
+            </div>
+          </form>
+        )}
+      </DialogContent>
+    </Dialog>
+  );
+}

+ 12 - 0
src/features/sms/Components/SmsConversation.tsx

@@ -4,6 +4,7 @@ import { useState, useTransition, useRef, useEffect, useCallback } from "react";
 import { useTranslations } from "next-intl";
 import { useTranslations } from "next-intl";
 import { Button } from "@/components/ui/button";
 import { Button } from "@/components/ui/button";
 import { Textarea } from "@/components/ui/textarea";
 import { Textarea } from "@/components/ui/textarea";
+import { SmsTemplateMenu } from "./SmsTemplateMenu";
 import {
 import {
   AlertDialog,
   AlertDialog,
   AlertDialogAction,
   AlertDialogAction,
@@ -295,6 +296,17 @@ export function SmsConversation({
 
 
       {/* Input area — pinned to bottom */}
       {/* Input area — pinned to bottom */}
       <div className="shrink-0 border-t bg-background p-4">
       <div className="shrink-0 border-t bg-background p-4">
+        <div className="mb-1.5 flex items-center justify-between">
+          <SmsTemplateMenu
+            className="h-7 px-2 text-xs text-muted-foreground"
+            onPick={(body) => setNewMessage((current) => (current ? `${current}\n${body}` : body))}
+          />
+          {newMessage.length > 0 && (
+            <span className="text-[10px] text-muted-foreground tabular-nums">
+              {newMessage.length}
+            </span>
+          )}
+        </div>
         <div className="flex items-end gap-2">
         <div className="flex items-end gap-2">
           <Textarea
           <Textarea
             placeholder={t("placeholder", { name: customerName })}
             placeholder={t("placeholder", { name: customerName })}

+ 77 - 0
src/features/sms/Components/SmsTemplateMenu.tsx

@@ -0,0 +1,77 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { useTranslations } from "next-intl";
+import { FileText } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import {
+  DropdownMenu,
+  DropdownMenuContent,
+  DropdownMenuItem,
+  DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { getSmsTemplates } from "../Actions/smsActions";
+
+interface SmsTemplateMenuProps {
+  /** Receives the template text, to drop into whatever box is being typed in */
+  onPick: (body: string) => void;
+  className?: string;
+}
+
+/**
+ * The workshop's saved texts, offered while typing.
+ *
+ * Only templates that read on their own are listed: the ones built around a
+ * share link or an invoice total need a job to fill them in, so offering them
+ * here would only paste raw {placeholders} into the message.
+ */
+export function SmsTemplateMenu({ onPick, className }: SmsTemplateMenuProps) {
+  const t = useTranslations("messages.compose");
+  const [templates, setTemplates] = useState<string[]>([]);
+  const [loaded, setLoaded] = useState(false);
+
+  useEffect(() => {
+    if (loaded) return;
+    let cancelled = false;
+    (async () => {
+      const result = await getSmsTemplates();
+      if (cancelled) return;
+      if (result.success && result.data) {
+        const usable = Object.values(result.data.templates)
+          .filter((body): body is string => typeof body === "string" && !!body.trim())
+          .filter((body) => !body.includes("{"));
+        setTemplates([...new Set(usable)]);
+      }
+      setLoaded(true);
+    })();
+    return () => {
+      cancelled = true;
+    };
+  }, [loaded]);
+
+  if (loaded && templates.length === 0) return null;
+
+  return (
+    <DropdownMenu>
+      <DropdownMenuTrigger asChild>
+        <Button
+          type="button"
+          variant="ghost"
+          size="sm"
+          className={className}
+          aria-label={t("templates")}
+        >
+          <FileText className="mr-1.5 h-3.5 w-3.5" />
+          {t("templates")}
+        </Button>
+      </DropdownMenuTrigger>
+      <DropdownMenuContent align="start" className="max-w-80">
+        {templates.map((body) => (
+          <DropdownMenuItem key={body} onClick={() => onPick(body)}>
+            <span className="line-clamp-2 text-xs">{body}</span>
+          </DropdownMenuItem>
+        ))}
+      </DropdownMenuContent>
+    </DropdownMenu>
+  );
+}