NewSmsDialog.tsx 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234
  1. 'use client'
  2. import { useEffect, useMemo, useState } from 'react'
  3. import { useTranslations } from 'next-intl'
  4. import { toast } from 'sonner'
  5. import { ArrowLeft, CalendarClock, Loader2, Phone, Search, Send, User } from 'lucide-react'
  6. import { Button } from '@/components/ui/button'
  7. import { Input } from '@/components/ui/input'
  8. import { Label } from '@/components/ui/label'
  9. import { Textarea } from '@/components/ui/textarea'
  10. import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
  11. import { handleGated } from '@/components/upgrade-gate'
  12. import { DocsLink } from '@/components/docs-link'
  13. import { useDebouncedSearch } from '@/hooks/use-debounced-search'
  14. import { smsSegmentCount } from '@/lib/sms-segments'
  15. import { searchSmsRecipients, sendSmsToCustomer } from '../Actions/smsActions'
  16. import { SharedNumberNotice } from './SharedNumberNotice'
  17. import { SmsTemplateMenu } from './SmsTemplateMenu'
  18. interface Recipient {
  19. id: string
  20. name: string
  21. company: string | null
  22. phone: string | null
  23. }
  24. interface NewSmsDialogProps {
  25. open: boolean
  26. onOpenChange: (open: boolean) => void
  27. /** Preselected recipient, e.g. the thread that is open */
  28. defaultRecipient?: Recipient | null
  29. /** Called with the customer id once a message is on its way */
  30. onSent?: (customerId: string) => void
  31. /** Hands the drafted text over to the scheduling dialog */
  32. onSchedule?: (recipient: Recipient | null, body: string) => void
  33. }
  34. export function NewSmsDialog({
  35. open,
  36. onOpenChange,
  37. defaultRecipient = null,
  38. onSent,
  39. onSchedule,
  40. }: NewSmsDialogProps) {
  41. const t = useTranslations('messages.compose')
  42. const tc = useTranslations('common.buttons')
  43. const [recipient, setRecipient] = useState<Recipient | null>(defaultRecipient)
  44. const [results, setResults] = useState<Recipient[]>([])
  45. const [searching, setSearching] = useState(false)
  46. const [body, setBody] = useState('')
  47. const [sending, setSending] = useState(false)
  48. const runSearch = useMemo(
  49. () => async (term: string | undefined) => {
  50. setSearching(true)
  51. const result = await searchSmsRecipients(term ?? undefined)
  52. if (result.success && result.data) setResults(result.data)
  53. setSearching(false)
  54. },
  55. []
  56. )
  57. const { value: search, setValue: setSearch } = useDebouncedSearch('', runSearch)
  58. // Re-seed on every open, and load the first page of recipients
  59. useEffect(() => {
  60. if (!open) return
  61. setRecipient(defaultRecipient)
  62. setBody('')
  63. setSearch('')
  64. runSearch('')
  65. }, [open, defaultRecipient, runSearch, setSearch])
  66. const handleSend = async (e: React.FormEvent) => {
  67. e.preventDefault()
  68. if (!recipient || !body.trim()) return
  69. setSending(true)
  70. const result = await sendSmsToCustomer({ customerId: recipient.id, body: body.trim() })
  71. if (result.success) {
  72. toast.success(t('sent', { name: recipient.name }))
  73. onOpenChange(false)
  74. onSent?.(recipient.id)
  75. } else if (!handleGated(result)) {
  76. toast.error(result.error || t('sendError'))
  77. }
  78. setSending(false)
  79. }
  80. const chars = body.length
  81. const segments = smsSegmentCount(body)
  82. return (
  83. <Dialog open={open} onOpenChange={onOpenChange}>
  84. <DialogContent className="sm:max-w-md" aria-describedby={undefined}>
  85. <DialogHeader>
  86. <DialogTitle>{t('title')}</DialogTitle>
  87. <DocsLink href="/docs/integrations/sms" variant="hint" className="self-start" />
  88. </DialogHeader>
  89. {!recipient ? (
  90. <div className="space-y-3">
  91. <div className="relative">
  92. <Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
  93. <Input
  94. value={search}
  95. onChange={(e) => setSearch(e.target.value)}
  96. placeholder={t('searchPlaceholder')}
  97. className="pl-9"
  98. autoFocus
  99. />
  100. {searching && (
  101. <Loader2 className="absolute right-2.5 top-1/2 h-4 w-4 -translate-y-1/2 animate-spin text-muted-foreground" />
  102. )}
  103. </div>
  104. <div className="max-h-[320px] overflow-y-auto">
  105. {results.length === 0 ? (
  106. <div className="px-2 py-8 text-center">
  107. <p className="text-sm text-muted-foreground">
  108. {search ? t('noMatches') : t('noRecipients')}
  109. </p>
  110. <p className="mt-1 text-xs text-muted-foreground">{t('noRecipientsHint')}</p>
  111. </div>
  112. ) : (
  113. <div className="space-y-1">
  114. {results.map((customer) => (
  115. <button
  116. key={customer.id}
  117. type="button"
  118. onClick={() => setRecipient(customer)}
  119. className="flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-left transition-colors hover:bg-muted"
  120. >
  121. <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted">
  122. <User className="h-4 w-4 text-muted-foreground" />
  123. </div>
  124. <div className="min-w-0 flex-1">
  125. <p className="truncate text-sm font-medium">
  126. {customer.name}
  127. {customer.company && (
  128. <span className="ml-1.5 text-xs text-muted-foreground">
  129. {customer.company}
  130. </span>
  131. )}
  132. </p>
  133. <p className="flex items-center gap-1 text-xs text-muted-foreground">
  134. <Phone className="h-3 w-3" />
  135. {customer.phone}
  136. </p>
  137. </div>
  138. </button>
  139. ))}
  140. </div>
  141. )}
  142. </div>
  143. </div>
  144. ) : (
  145. <form onSubmit={handleSend} className="space-y-4">
  146. <div className="flex items-center gap-2 rounded-lg border bg-muted/50 px-3 py-2">
  147. <Button
  148. type="button"
  149. variant="ghost"
  150. size="icon"
  151. className="h-7 w-7 shrink-0"
  152. onClick={() => setRecipient(null)}
  153. aria-label={t('changeRecipient')}
  154. >
  155. <ArrowLeft className="h-4 w-4" />
  156. </Button>
  157. <div className="min-w-0 flex-1">
  158. <p className="truncate text-sm font-medium">{recipient.name}</p>
  159. <p className="text-xs text-muted-foreground">{recipient.phone}</p>
  160. </div>
  161. </div>
  162. <div className="space-y-2">
  163. <div className="flex items-center justify-between">
  164. <Label htmlFor="new-sms-body">{t('messageLabel')}</Label>
  165. <SmsTemplateMenu
  166. className="h-7 px-2 text-xs text-muted-foreground"
  167. onPick={(template) =>
  168. setBody((current) => (current ? `${current}\n${template}` : template))
  169. }
  170. />
  171. </div>
  172. <Textarea
  173. id="new-sms-body"
  174. value={body}
  175. onChange={(e) => setBody(e.target.value)}
  176. placeholder={t('messagePlaceholder', { name: recipient.name })}
  177. rows={5}
  178. autoFocus
  179. required
  180. />
  181. <p className="text-right text-xs text-muted-foreground tabular-nums">
  182. {t('segments', { chars, segments })}
  183. </p>
  184. <SharedNumberNotice enabled={open} />
  185. </div>
  186. <div className="flex flex-wrap justify-end gap-2 pt-1">
  187. <Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
  188. {tc('cancel')}
  189. </Button>
  190. {onSchedule && (
  191. <Button
  192. type="button"
  193. variant="outline"
  194. onClick={() => {
  195. onOpenChange(false)
  196. onSchedule(recipient, body.trim())
  197. }}
  198. >
  199. <CalendarClock className="mr-2 h-4 w-4" />
  200. {t('scheduleInstead')}
  201. </Button>
  202. )}
  203. <Button type="submit" disabled={sending || !body.trim()}>
  204. {sending ? (
  205. <Loader2 className="mr-2 h-4 w-4 animate-spin" />
  206. ) : (
  207. <Send className="mr-2 h-4 w-4" />
  208. )}
  209. {t('send')}
  210. </Button>
  211. </div>
  212. </form>
  213. )}
  214. </DialogContent>
  215. </Dialog>
  216. )
  217. }