@wealthx/shadcn 1.5.10 → 1.5.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/.turbo/turbo-build.log +114 -114
  2. package/CHANGELOG.md +12 -0
  3. package/dist/{chunk-AANINK2B.mjs → chunk-2KNQZG5S.mjs} +1 -1
  4. package/dist/chunk-3KLJ4XRE.mjs +375 -0
  5. package/dist/{chunk-6U4NQGVM.mjs → chunk-4X4MGYHE.mjs} +2 -2
  6. package/dist/{chunk-CEEVYRQA.mjs → chunk-67DGIPQ4.mjs} +1 -1
  7. package/dist/{chunk-7UIL5UN3.mjs → chunk-7II6QRCZ.mjs} +1 -1
  8. package/dist/{chunk-W5QJ57PU.mjs → chunk-7LN5OGC2.mjs} +1 -1
  9. package/dist/{chunk-ZXEUBBHJ.mjs → chunk-7TMPOZDE.mjs} +1 -1
  10. package/dist/{chunk-AHSCWXYJ.mjs → chunk-AJUAJC5O.mjs} +1 -1
  11. package/dist/{chunk-3CGM3QXQ.mjs → chunk-AKWN5ZQG.mjs} +2 -2
  12. package/dist/{chunk-O5CP6VP6.mjs → chunk-CPM6P63C.mjs} +56 -44
  13. package/dist/{chunk-FRT3S72S.mjs → chunk-CQ7HKBEX.mjs} +1 -1
  14. package/dist/{chunk-54TRNCID.mjs → chunk-EB626HVW.mjs} +78 -11
  15. package/dist/{chunk-E2BNCA6L.mjs → chunk-EHQL64B7.mjs} +1 -1
  16. package/dist/{chunk-2WCIORP7.mjs → chunk-EXI64H46.mjs} +1 -1
  17. package/dist/{chunk-BBXSNDS3.mjs → chunk-FQYFPHDO.mjs} +1 -1
  18. package/dist/{chunk-3VZ6CYY2.mjs → chunk-GAXNO4JB.mjs} +1 -1
  19. package/dist/{chunk-3WGFIFP6.mjs → chunk-I4P7RXAE.mjs} +1 -1
  20. package/dist/{chunk-Z2BW5T7P.mjs → chunk-IODGRCQG.mjs} +1 -1
  21. package/dist/{chunk-GS47ZSSA.mjs → chunk-J7KQON2N.mjs} +20 -5
  22. package/dist/{chunk-IQGKOT7A.mjs → chunk-K35TFQUB.mjs} +1 -1
  23. package/dist/{chunk-4DO3WM7V.mjs → chunk-K4VWSDJJ.mjs} +1 -1
  24. package/dist/{chunk-KWD6GANL.mjs → chunk-MPA2HV5U.mjs} +1 -1
  25. package/dist/{chunk-5LZZYODG.mjs → chunk-QHAMVWDG.mjs} +19 -1
  26. package/dist/{chunk-XUCDPAVI.mjs → chunk-R6U246E4.mjs} +2 -2
  27. package/dist/{chunk-VCDGLN25.mjs → chunk-S6AYZJYO.mjs} +47 -21
  28. package/dist/{chunk-WL6WVV47.mjs → chunk-X6RC5UWB.mjs} +1 -1
  29. package/dist/{chunk-4BHDDLWK.mjs → chunk-XAS6KBIG.mjs} +2 -2
  30. package/dist/{chunk-VWZS32ZQ.mjs → chunk-XYWEGBAA.mjs} +1 -1
  31. package/dist/{chunk-54MTIKNC.mjs → chunk-YV7XF32X.mjs} +49 -24
  32. package/dist/{chunk-E5EDZQ5J.mjs → chunk-ZA44WICP.mjs} +1 -1
  33. package/dist/components/ui/advisor-card.js +144 -55
  34. package/dist/components/ui/advisor-card.mjs +5 -2
  35. package/dist/components/ui/agent-evaluation-toast.js +1 -1
  36. package/dist/components/ui/agent-evaluation-toast.mjs +2 -2
  37. package/dist/components/ui/ai-assistant-drawer.js +1 -1
  38. package/dist/components/ui/ai-assistant-drawer.mjs +2 -2
  39. package/dist/components/ui/ai-builder.js +1 -1
  40. package/dist/components/ui/ai-builder.mjs +2 -2
  41. package/dist/components/ui/ai-conversations.js +71 -4
  42. package/dist/components/ui/ai-conversations.mjs +3 -3
  43. package/dist/components/ui/appointment-action-dialogs.js +1 -1
  44. package/dist/components/ui/appointment-action-dialogs.mjs +3 -3
  45. package/dist/components/ui/appointment-book-dialog.js +19 -4
  46. package/dist/components/ui/appointment-book-dialog.mjs +3 -3
  47. package/dist/components/ui/appointment-calendar-view.js +1 -1
  48. package/dist/components/ui/appointment-calendar-view.mjs +2 -2
  49. package/dist/components/ui/appointment-detail-sheet.js +1 -1
  50. package/dist/components/ui/appointment-detail-sheet.mjs +4 -4
  51. package/dist/components/ui/appointment-gmail-connect.js +1 -1
  52. package/dist/components/ui/appointment-gmail-connect.mjs +2 -2
  53. package/dist/components/ui/appointment-time-slot-picker.js +1 -1
  54. package/dist/components/ui/appointment-time-slot-picker.mjs +2 -2
  55. package/dist/components/ui/appointment-upcoming-card.js +1 -1
  56. package/dist/components/ui/appointment-upcoming-card.mjs +3 -3
  57. package/dist/components/ui/badge.js +1 -1
  58. package/dist/components/ui/badge.mjs +1 -1
  59. package/dist/components/ui/bank-statement-generate-dialog.js +61 -46
  60. package/dist/components/ui/bank-statement-generate-dialog.mjs +1 -1
  61. package/dist/components/ui/chat-widget-primitives.js +1 -1
  62. package/dist/components/ui/chat-widget-primitives.mjs +2 -2
  63. package/dist/components/ui/chat-widget.js +1 -1
  64. package/dist/components/ui/chat-widget.mjs +3 -3
  65. package/dist/components/ui/chip.js +1 -1
  66. package/dist/components/ui/chip.mjs +2 -2
  67. package/dist/components/ui/contact-alert-dialog/index.js +19 -1
  68. package/dist/components/ui/contact-alert-dialog/index.mjs +1 -1
  69. package/dist/components/ui/dashboard-transactions-table.js +1 -1
  70. package/dist/components/ui/dashboard-transactions-table.mjs +2 -2
  71. package/dist/components/ui/financial-cards.js +1 -1
  72. package/dist/components/ui/financial-cards.mjs +2 -2
  73. package/dist/components/ui/financial-sections.js +1 -1
  74. package/dist/components/ui/financial-sections.mjs +3 -3
  75. package/dist/components/ui/income-summary-component.js +1 -1
  76. package/dist/components/ui/income-summary-component.mjs +1 -1
  77. package/dist/components/ui/integration-card.js +1 -1
  78. package/dist/components/ui/integration-card.mjs +2 -2
  79. package/dist/components/ui/kanban-column.js +46 -23
  80. package/dist/components/ui/kanban-column.mjs +4 -4
  81. package/dist/components/ui/loan-applicant-information.js +1 -1
  82. package/dist/components/ui/loan-applicant-information.mjs +1 -1
  83. package/dist/components/ui/loan-application-badge.js +1 -1
  84. package/dist/components/ui/loan-application-badge.mjs +2 -2
  85. package/dist/components/ui/opportunity-card.js +46 -23
  86. package/dist/components/ui/opportunity-card.mjs +3 -3
  87. package/dist/components/ui/opportunity-summary-tab.js +1 -1
  88. package/dist/components/ui/opportunity-summary-tab.mjs +3 -3
  89. package/dist/components/ui/pipeline-board.js +46 -23
  90. package/dist/components/ui/pipeline-board.mjs +5 -5
  91. package/dist/components/ui/pipeline-primitives.js +1 -1
  92. package/dist/components/ui/pipeline-primitives.mjs +2 -2
  93. package/dist/components/ui/property-asset-card.js +1 -1
  94. package/dist/components/ui/property-asset-card.mjs +1 -1
  95. package/dist/components/ui/resource-center.js +1 -1
  96. package/dist/components/ui/resource-center.mjs +2 -2
  97. package/dist/components/ui/share-details-dialog.js +326 -30
  98. package/dist/components/ui/share-details-dialog.mjs +4 -1
  99. package/dist/components/ui/stage-timeline.js +1 -1
  100. package/dist/components/ui/stage-timeline.mjs +3 -3
  101. package/dist/index.js +583 -232
  102. package/dist/index.mjs +45 -43
  103. package/dist/styles.css +1 -1
  104. package/package.json +1 -1
  105. package/src/components/index.tsx +4 -0
  106. package/src/components/ui/advisor-card.tsx +75 -25
  107. package/src/components/ui/ai-conversations.tsx +157 -23
  108. package/src/components/ui/appointment-book-dialog.tsx +26 -3
  109. package/src/components/ui/appointment-time-slot-picker.tsx +1 -0
  110. package/src/components/ui/badge.tsx +1 -1
  111. package/src/components/ui/bank-statement-generate-dialog.tsx +84 -61
  112. package/src/components/ui/contact-alert-dialog/contact-alert-dialog.tsx +19 -1
  113. package/src/components/ui/opportunity-card.tsx +56 -20
  114. package/src/components/ui/share-details-dialog.tsx +251 -0
  115. package/src/styles/styles-css.ts +1 -1
  116. package/dist/chunk-OZ2R6ERP.mjs +0 -174
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wealthx/shadcn",
3
- "version": "1.5.10",
3
+ "version": "1.5.12",
4
4
  "main": "./dist/index.js",
5
5
  "module": "./dist/index.mjs",
6
6
  "types": "./src/index.ts",
@@ -73,6 +73,7 @@ export type {
73
73
  AdvisorCardProps,
74
74
  AdvisorInviteCardProps,
75
75
  AdvisorAppointmentStrip,
76
+ AdvisorCardMenuItem,
76
77
  } from "./ui/advisor-card";
77
78
 
78
79
  export { AppointmentUpcomingCard } from "./ui/appointment-upcoming-card";
@@ -492,10 +493,13 @@ export type {
492
493
  export {
493
494
  ShareDetailsDialog,
494
495
  EmailTemplateDialog,
496
+ ShareContactDialog,
495
497
  } from "./ui/share-details-dialog";
496
498
  export type {
497
499
  ShareDetailsDialogProps,
498
500
  EmailTemplateDialogProps,
501
+ ShareContactDialogProps,
502
+ InternalAdvisor,
499
503
  } from "./ui/share-details-dialog";
500
504
 
501
505
  export { FilePreviewDialog } from "./ui/file-preview-dialog";
@@ -3,6 +3,12 @@ import { Badge } from "./badge";
3
3
  import { Button } from "./button";
4
4
  import { Avatar, AvatarFallback, AvatarImage } from "./avatar";
5
5
  import { Separator } from "./separator";
6
+ import {
7
+ DropdownMenu,
8
+ DropdownMenuContent,
9
+ DropdownMenuItem,
10
+ DropdownMenuTrigger,
11
+ } from "./dropdown-menu";
6
12
  import {
7
13
  Calendar,
8
14
  CalendarCheck,
@@ -18,6 +24,18 @@ import type { AppointmentStatus } from "./appointment-time-slot-picker";
18
24
  // Types
19
25
  // ---------------------------------------------------------------------------
20
26
 
27
+ /** A single item in the ⋮ overflow DropdownMenu */
28
+ export interface AdvisorCardMenuItem {
29
+ /** Display label */
30
+ label: string;
31
+ /** Called when the item is clicked */
32
+ onClick: () => void;
33
+ /** Visual variant — use "destructive" for delete/remove actions */
34
+ variant?: "default" | "destructive";
35
+ /** Disables the item without removing it */
36
+ disabled?: boolean;
37
+ }
38
+
21
39
  /** Appointment data shown in the strip inside the advisor card */
22
40
  export interface AdvisorAppointmentStrip {
23
41
  status: AppointmentStatus;
@@ -57,7 +75,12 @@ export interface AdvisorCardProps {
57
75
  appointments?: AdvisorAppointmentStrip[] | null;
58
76
  /** Called when "Refer [name] to Others" is clicked */
59
77
  onRefer?: () => void;
60
- /** Called when the ⋮ overflow menu is clicked */
78
+ /**
79
+ * Items to render in the ⋮ overflow DropdownMenu.
80
+ * When provided, the button opens an inline dropdown — `onMoreOptions` is ignored.
81
+ */
82
+ menuItems?: AdvisorCardMenuItem[];
83
+ /** @deprecated Pass `menuItems` for an inline DropdownMenu instead */
61
84
  onMoreOptions?: () => void;
62
85
  /** Called when "Book Appointment" is clicked */
63
86
  onBookAppointment?: () => void;
@@ -113,6 +136,7 @@ export function AdvisorCard({
113
136
  isPrimary = false,
114
137
  appointments,
115
138
  onRefer,
139
+ menuItems,
116
140
  onMoreOptions,
117
141
  onBookAppointment,
118
142
  onViewAppointment,
@@ -132,7 +156,7 @@ export function AdvisorCard({
132
156
  {companyLogoUrl && (
133
157
  <AvatarImage src={companyLogoUrl} alt={`${name} company logo`} />
134
158
  )}
135
- <AvatarFallback className="text-sm">
159
+ <AvatarFallback className="text-caption">
136
160
  {avatarInitials ??
137
161
  (companyName
138
162
  ? companyName
@@ -146,14 +170,14 @@ export function AdvisorCard({
146
170
  </Avatar>
147
171
 
148
172
  <div className="flex min-w-0 flex-1 flex-col gap-0.5">
149
- <p className="text-sm font-semibold leading-tight">{name}</p>
150
- <p className="text-sm text-muted-foreground">{role}</p>
173
+ <p className="text-label-large leading-tight">{name}</p>
174
+ <p className="text-body-small text-muted-foreground">{role}</p>
151
175
  <div className="mt-2 flex flex-col gap-1">
152
- <div className="flex items-center gap-1.5 text-sm text-muted-foreground">
176
+ <div className="flex items-center gap-1.5 text-body-small text-muted-foreground">
153
177
  <Phone className="h-4 w-4 shrink-0" />
154
178
  <span>{phone}</span>
155
179
  </div>
156
- <div className="flex items-center gap-1.5 text-sm text-muted-foreground">
180
+ <div className="flex items-center gap-1.5 text-body-small text-muted-foreground">
157
181
  <Mail className="h-4 w-4 shrink-0" />
158
182
  <span>{email}</span>
159
183
  </div>
@@ -163,15 +187,44 @@ export function AdvisorCard({
163
187
  {/* Badge + overflow menu */}
164
188
  <div className="flex shrink-0 items-center gap-1.5">
165
189
  {isPrimary && <Badge variant="success">Primary</Badge>}
166
- <Button
167
- variant="ghost"
168
- size="icon"
169
- className="h-7 w-7"
170
- onClick={onMoreOptions}
171
- aria-label="More options"
172
- >
173
- <MoreVertical className="h-4 w-4" />
174
- </Button>
190
+ {menuItems && menuItems.length > 0 ? (
191
+ <DropdownMenu>
192
+ <DropdownMenuTrigger
193
+ render={
194
+ <Button
195
+ variant="ghost"
196
+ size="icon"
197
+ className="h-7 w-7"
198
+ aria-label="More options"
199
+ />
200
+ }
201
+ >
202
+ <MoreVertical className="h-4 w-4" />
203
+ </DropdownMenuTrigger>
204
+ <DropdownMenuContent align="end">
205
+ {menuItems.map((item, i) => (
206
+ <DropdownMenuItem
207
+ key={i}
208
+ variant={item.variant}
209
+ disabled={item.disabled}
210
+ onClick={item.onClick}
211
+ >
212
+ {item.label}
213
+ </DropdownMenuItem>
214
+ ))}
215
+ </DropdownMenuContent>
216
+ </DropdownMenu>
217
+ ) : (
218
+ <Button
219
+ variant="ghost"
220
+ size="icon"
221
+ className="h-7 w-7"
222
+ onClick={onMoreOptions}
223
+ aria-label="More options"
224
+ >
225
+ <MoreVertical className="h-4 w-4" />
226
+ </Button>
227
+ )}
175
228
  </div>
176
229
  </div>
177
230
 
@@ -189,19 +242,16 @@ export function AdvisorCard({
189
242
  <CalendarCheck className="h-4 w-4 shrink-0 text-muted-foreground" />
190
243
  <div className="flex min-w-0 flex-1 flex-col gap-0.5">
191
244
  <div className="flex items-center gap-2">
192
- <Badge
193
- variant={STATUS_VARIANT[appt.status]}
194
- className="text-[10px]"
195
- >
245
+ <Badge variant={STATUS_VARIANT[appt.status]}>
196
246
  {STATUS_LABEL[appt.status]}
197
247
  </Badge>
198
248
  {appt.appointmentType && (
199
- <span className="truncate text-sm font-semibold">
249
+ <span className="truncate text-h6">
200
250
  {appt.appointmentType}
201
251
  </span>
202
252
  )}
203
253
  </div>
204
- <p className="whitespace-nowrap text-sm text-muted-foreground">
254
+ <p className="whitespace-nowrap text-body-small text-muted-foreground">
205
255
  {appt.date} · {appt.timeStart}–{appt.timeEnd}
206
256
  </p>
207
257
  </div>
@@ -223,7 +273,7 @@ export function AdvisorCard({
223
273
  /* Empty state */
224
274
  <div className="flex items-center gap-3 px-4 py-3">
225
275
  <Calendar className="h-4 w-4 shrink-0 text-muted-foreground" />
226
- <p className="flex-1 text-xs text-muted-foreground">
276
+ <p className="flex-1 text-caption text-muted-foreground">
227
277
  No upcoming appointments
228
278
  </p>
229
279
  </div>
@@ -246,7 +296,7 @@ export function AdvisorCard({
246
296
  )}
247
297
  {onRefer && (
248
298
  <Button
249
- variant="outline"
299
+ variant="default"
250
300
  size="sm"
251
301
  className="w-full"
252
302
  onClick={onRefer}
@@ -274,8 +324,8 @@ export function AdvisorInviteCard({ onInvite }: AdvisorInviteCardProps) {
274
324
  <Plus className="h-5 w-5 text-muted-foreground" />
275
325
  </div>
276
326
  <div className="flex flex-col gap-1">
277
- <p className="text-sm font-medium">Add Another Advisor</p>
278
- <p className="text-xs text-muted-foreground">
327
+ <p className="text-label-large">Add Another Advisor</p>
328
+ <p className="text-caption text-muted-foreground">
279
329
  Connect more advisors to your account
280
330
  </p>
281
331
  </div>
@@ -659,16 +659,31 @@ export function ChatBubble({ message, className }: ChatBubbleProps) {
659
659
  // ChatComposer
660
660
  // ---------------------------------------------------------------------------
661
661
 
662
+ export interface AiConvEmailPayload {
663
+ content: string;
664
+ to: string;
665
+ cc: string;
666
+ subject: string;
667
+ }
668
+
662
669
  export interface ChatComposerProps {
663
670
  mode: AiConvMode;
664
671
  /** Active reply channel. Defaults to "chat". */
665
672
  channel?: AiConvChannel;
666
673
  onChannelChange?: (channel: AiConvChannel) => void;
674
+ /**
675
+ * When true, the Email tab is shown in the composer. Defaults to false —
676
+ * consumers must opt in once their tenant's email integration is wired up.
677
+ */
678
+ isEmailIntegrated?: boolean;
667
679
  /** Lead's email address — pre-fills the To field in email compose. */
668
680
  contactEmail?: string;
669
681
  inputValue?: string;
670
682
  onInputChange?: (v: string) => void;
671
- onSend?: (v: string) => void;
683
+ /** Fired when the user sends a chat message. */
684
+ onSend?: (content: string) => void;
685
+ /** Fired when the user sends an email. */
686
+ onSendEmail?: (payload: AiConvEmailPayload) => void;
672
687
  onTakeOver?: () => void;
673
688
  onLetAiHandle?: () => void;
674
689
  className?: string;
@@ -678,16 +693,21 @@ export function ChatComposer({
678
693
  mode,
679
694
  channel: channelProp = "chat",
680
695
  onChannelChange,
696
+ isEmailIntegrated = false,
681
697
  contactEmail = "",
682
698
  inputValue = "",
683
699
  onInputChange,
684
700
  onSend,
701
+ onSendEmail,
685
702
  onTakeOver,
686
703
  onLetAiHandle,
687
704
  className,
688
705
  }: ChatComposerProps) {
689
- // Semi-controlled: owns channel state for uncontrolled usage, notifies parent on change
690
- const [channel, setChannel] = React.useState<AiConvChannel>(channelProp);
706
+ // Semi-controlled: owns channel state for uncontrolled usage, notifies parent on change.
707
+ // Force chat when email isn't integrated so the panel never lands on a hidden tab.
708
+ const [channel, setChannel] = React.useState<AiConvChannel>(
709
+ isEmailIntegrated ? channelProp : "chat",
710
+ );
691
711
  const [emailTo, setEmailTo] = React.useState(contactEmail);
692
712
  const [emailCc, setEmailCc] = React.useState("");
693
713
  const [showCc, setShowCc] = React.useState(false);
@@ -754,23 +774,25 @@ export function ChatComposer({
754
774
  className,
755
775
  )}
756
776
  >
757
- <div className="border-b border-border px-3 py-2">
758
- <Tabs
759
- value={channel}
760
- onValueChange={(v) => v && handleChannelChange(v as AiConvChannel)}
761
- >
762
- <TabsList variant="default" className="w-full">
763
- <TabsTrigger value="chat" className="flex-1 gap-1.5">
764
- <MessageSquare className="size-3.5" />
765
- Chat
766
- </TabsTrigger>
767
- <TabsTrigger value="email" className="flex-1 gap-1.5">
768
- <Mail className="size-3.5" />
769
- Email
770
- </TabsTrigger>
771
- </TabsList>
772
- </Tabs>
773
- </div>
777
+ {isEmailIntegrated && (
778
+ <div className="border-b border-border px-3 py-2">
779
+ <Tabs
780
+ value={channel}
781
+ onValueChange={(v) => v && handleChannelChange(v as AiConvChannel)}
782
+ >
783
+ <TabsList variant="default" className="w-full">
784
+ <TabsTrigger value="chat" className="flex-1 gap-1.5">
785
+ <MessageSquare className="size-3.5" />
786
+ Chat
787
+ </TabsTrigger>
788
+ <TabsTrigger value="email" className="flex-1 gap-1.5">
789
+ <Mail className="size-3.5" />
790
+ Email
791
+ </TabsTrigger>
792
+ </TabsList>
793
+ </Tabs>
794
+ </div>
795
+ )}
774
796
 
775
797
  {mode === "ai" ? (
776
798
  <div className="flex items-center gap-2 bg-muted/30 px-4 py-2.5 text-[12px] text-muted-foreground">
@@ -867,7 +889,14 @@ export function ChatComposer({
867
889
  </div>
868
890
  <Button
869
891
  size="sm"
870
- onClick={() => onSend?.(inputValue)}
892
+ onClick={() =>
893
+ onSendEmail?.({
894
+ content: inputValue,
895
+ to: emailTo,
896
+ cc: emailCc,
897
+ subject: emailSubject,
898
+ })
899
+ }
871
900
  disabled={!inputValue.trim() || !emailTo.trim()}
872
901
  >
873
902
  <Send className="mr-1.5 size-3.5" />
@@ -920,9 +949,16 @@ export interface ChatThreadProps {
920
949
  /** Active reply channel — "chat" (default) or "email". */
921
950
  channel?: AiConvChannel;
922
951
  onChannelChange?: (channel: AiConvChannel) => void;
952
+ /**
953
+ * When true, the Email tab is shown in the composer. Defaults to false.
954
+ */
955
+ isEmailIntegrated?: boolean;
923
956
  inputValue?: string;
924
957
  onInputChange?: (v: string) => void;
925
- onSend?: (v: string) => void;
958
+ /** Fired when the user sends a chat message. */
959
+ onSend?: (content: string) => void;
960
+ /** Fired when the user sends an email. */
961
+ onSendEmail?: (payload: AiConvEmailPayload) => void;
926
962
  onTakeOver?: () => void;
927
963
  onLetAiHandle?: () => void;
928
964
  onReopen?: () => void;
@@ -930,6 +966,12 @@ export interface ChatThreadProps {
930
966
  onUnmarkUrgent?: () => void;
931
967
  onArchive?: () => void;
932
968
  onAssignToAdvisor?: () => void;
969
+ /** True when older messages can be loaded (e.g. paginated history). */
970
+ hasMoreMessages?: boolean;
971
+ /** True while a `onLoadMoreMessages` request is in-flight. */
972
+ isLoadingMoreMessages?: boolean;
973
+ /** Fired when the consumer should fetch older messages. */
974
+ onLoadMoreMessages?: () => void;
933
975
  /** Mobile only — back to conversation list. */
934
976
  onBack?: () => void;
935
977
  /** Mobile only — show lead info panel. */
@@ -945,9 +987,11 @@ export function ChatThread({
945
987
  isAiTyping = false,
946
988
  channel,
947
989
  onChannelChange,
990
+ isEmailIntegrated,
948
991
  inputValue,
949
992
  onInputChange,
950
993
  onSend,
994
+ onSendEmail,
951
995
  onTakeOver,
952
996
  onLetAiHandle,
953
997
  onReopen,
@@ -955,6 +999,9 @@ export function ChatThread({
955
999
  onUnmarkUrgent,
956
1000
  onArchive,
957
1001
  onAssignToAdvisor,
1002
+ hasMoreMessages,
1003
+ isLoadingMoreMessages,
1004
+ onLoadMoreMessages,
958
1005
  onBack,
959
1006
  onShowLeadInfo,
960
1007
  className,
@@ -962,6 +1009,61 @@ export function ChatThread({
962
1009
  const aiIsHandling = mode === "ai";
963
1010
  const isClosed = status === "closed";
964
1011
 
1012
+ const scrollRef = React.useRef<HTMLDivElement>(null);
1013
+ // Captures scrollHeight just before older messages are prepended, so we can
1014
+ // restore the user's visible scroll offset once the new nodes render.
1015
+ const preLoadScrollHeightRef = React.useRef<number | null>(null);
1016
+
1017
+ const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
1018
+ if (!hasMoreMessages || isLoadingMoreMessages || !onLoadMoreMessages) {
1019
+ return;
1020
+ }
1021
+ if (e.currentTarget.scrollTop <= 80) {
1022
+ preLoadScrollHeightRef.current = e.currentTarget.scrollHeight;
1023
+ onLoadMoreMessages();
1024
+ }
1025
+ };
1026
+
1027
+ // Tracks the last "tail" message id so we can tell an append (new message,
1028
+ // tail changed) apart from a prepend (older history loaded, tail unchanged).
1029
+ const prevLastMessageIdRef = React.useRef<string | undefined>(undefined);
1030
+ const prevContactIdRef = React.useRef<string>(contact.id);
1031
+
1032
+ React.useLayoutEffect(() => {
1033
+ const el = scrollRef.current;
1034
+ if (!el) return;
1035
+
1036
+ // Prepend (older messages just loaded) — restore scroll so the user
1037
+ // stays anchored to the message they were reading.
1038
+ if (preLoadScrollHeightRef.current !== null) {
1039
+ el.scrollTop = el.scrollHeight - preLoadScrollHeightRef.current;
1040
+ preLoadScrollHeightRef.current = null;
1041
+ prevLastMessageIdRef.current = messages[messages.length - 1]?.id;
1042
+ prevContactIdRef.current = contact.id;
1043
+ return;
1044
+ }
1045
+
1046
+ const currentLastId = messages[messages.length - 1]?.id;
1047
+ const contactChanged = prevContactIdRef.current !== contact.id;
1048
+ const tailChanged = prevLastMessageIdRef.current !== currentLastId;
1049
+
1050
+ // Opening a conversation or appending a new message (sent, received,
1051
+ // or system) — pin to the bottom.
1052
+ if (contactChanged || tailChanged) {
1053
+ el.scrollTop = el.scrollHeight;
1054
+ }
1055
+
1056
+ prevLastMessageIdRef.current = currentLastId;
1057
+ prevContactIdRef.current = contact.id;
1058
+ }, [contact.id, messages]);
1059
+
1060
+ // Typing indicator adds DOM height — keep the view pinned to bottom.
1061
+ React.useLayoutEffect(() => {
1062
+ if (!isAiTyping) return;
1063
+ const el = scrollRef.current;
1064
+ if (el) el.scrollTop = el.scrollHeight;
1065
+ }, [isAiTyping]);
1066
+
965
1067
  return (
966
1068
  <div className={cn("flex flex-col bg-background", className)}>
967
1069
  {/* Header */}
@@ -1071,9 +1173,16 @@ export function ChatThread({
1071
1173
 
1072
1174
  {/* Messages */}
1073
1175
  <div
1176
+ ref={scrollRef}
1177
+ onScroll={handleScroll}
1074
1178
  className="flex flex-1 flex-col gap-4 overflow-y-auto p-4"
1075
1179
  tabIndex={0}
1076
1180
  >
1181
+ {isLoadingMoreMessages && (
1182
+ <div className="flex justify-center py-1 text-caption text-muted-foreground">
1183
+ Loading older messages...
1184
+ </div>
1185
+ )}
1077
1186
  {messages.length === 0 ? (
1078
1187
  <div className="flex flex-1 flex-col items-center justify-center gap-2 text-muted-foreground">
1079
1188
  <MessageSquare className="size-8 opacity-30" />
@@ -1120,10 +1229,12 @@ export function ChatThread({
1120
1229
  mode={mode}
1121
1230
  channel={channel}
1122
1231
  onChannelChange={onChannelChange}
1232
+ isEmailIntegrated={isEmailIntegrated}
1123
1233
  contactEmail={contact.email}
1124
1234
  inputValue={inputValue}
1125
1235
  onInputChange={onInputChange}
1126
1236
  onSend={onSend}
1237
+ onSendEmail={onSendEmail}
1127
1238
  onTakeOver={onTakeOver}
1128
1239
  onLetAiHandle={onLetAiHandle}
1129
1240
  />
@@ -1584,18 +1695,31 @@ export interface ConversationsPageProps {
1584
1695
  /** Active reply channel — "chat" (default) or "email". */
1585
1696
  channel?: AiConvChannel;
1586
1697
  onChannelChange?: (channel: AiConvChannel) => void;
1698
+ /**
1699
+ * When true, the Email tab is shown in the composer. Defaults to false.
1700
+ */
1701
+ isEmailIntegrated?: boolean;
1587
1702
  inputValue?: string;
1588
1703
  internalNotes?: string;
1589
1704
  showLeadPanel?: boolean;
1590
1705
  hasMore?: boolean;
1591
1706
  isLoadingMore?: boolean;
1707
+ /** True when older messages can be loaded for the active conversation. */
1708
+ hasMoreMessages?: boolean;
1709
+ /** True while a `onLoadMoreMessages` request is in-flight. */
1710
+ isLoadingMoreMessages?: boolean;
1711
+ /** Fired when the consumer should fetch older messages for the active conversation. */
1712
+ onLoadMoreMessages?: () => void;
1592
1713
  onSelectConversation?: (id: string) => void;
1593
1714
  onRead?: (id: string) => void;
1594
1715
  onSearchChange?: (v: string) => void;
1595
1716
  onFilterChange?: (f: AiConvFilterTab) => void;
1596
1717
  onChannelFilterChange?: (f: AiConvChannelFilter) => void;
1597
1718
  onInputChange?: (v: string) => void;
1598
- onSend?: (v: string) => void;
1719
+ /** Fired when the user sends a chat message. */
1720
+ onSend?: (content: string) => void;
1721
+ /** Fired when the user sends an email. */
1722
+ onSendEmail?: (payload: AiConvEmailPayload) => void;
1599
1723
  onTakeOver?: () => void;
1600
1724
  onLetAiHandle?: () => void;
1601
1725
  onReopen?: () => void;
@@ -1637,11 +1761,15 @@ export function ConversationsPage({
1637
1761
  onChannelFilterChange,
1638
1762
  channel,
1639
1763
  onChannelChange,
1764
+ isEmailIntegrated,
1640
1765
  inputValue,
1641
1766
  internalNotes,
1642
1767
  showLeadPanel = true,
1643
1768
  hasMore,
1644
1769
  isLoadingMore,
1770
+ hasMoreMessages,
1771
+ isLoadingMoreMessages,
1772
+ onLoadMoreMessages,
1645
1773
  isAiTyping,
1646
1774
  notesSaveStatus,
1647
1775
  onSelectConversation,
@@ -1650,6 +1778,7 @@ export function ConversationsPage({
1650
1778
  onFilterChange,
1651
1779
  onInputChange,
1652
1780
  onSend,
1781
+ onSendEmail,
1653
1782
  onTakeOver,
1654
1783
  onLetAiHandle,
1655
1784
  onReopen,
@@ -1723,9 +1852,11 @@ export function ConversationsPage({
1723
1852
  isAiTyping={isAiTyping}
1724
1853
  channel={channel}
1725
1854
  onChannelChange={onChannelChange}
1855
+ isEmailIntegrated={isEmailIntegrated}
1726
1856
  inputValue={inputValue}
1727
1857
  onInputChange={onInputChange}
1728
1858
  onSend={onSend}
1859
+ onSendEmail={onSendEmail}
1729
1860
  onTakeOver={onTakeOver}
1730
1861
  onLetAiHandle={onLetAiHandle}
1731
1862
  onReopen={onReopen}
@@ -1733,6 +1864,9 @@ export function ConversationsPage({
1733
1864
  onUnmarkUrgent={onUnmarkUrgent}
1734
1865
  onArchive={onArchive}
1735
1866
  onAssignToAdvisor={onAssignToAdvisor}
1867
+ hasMoreMessages={hasMoreMessages}
1868
+ isLoadingMoreMessages={isLoadingMoreMessages}
1869
+ onLoadMoreMessages={onLoadMoreMessages}
1736
1870
  onBack={() => setMobilePanel("list")}
1737
1871
  onShowLeadInfo={() => setMobilePanel("lead")}
1738
1872
  className={cn(
@@ -236,11 +236,14 @@ function ClientSearch({
236
236
  // Meeting format sub-component
237
237
  // ---------------------------------------------------------------------------
238
238
 
239
- const FORMAT_OPTIONS: {
239
+ interface FormatOption {
240
240
  value: AppointmentMeetingFormat;
241
241
  label: string;
242
242
  icon: React.ReactNode;
243
- }[] = [
243
+ }
244
+
245
+ /** All four formats — shown in advisor mode */
246
+ const FORMAT_OPTIONS: FormatOption[] = [
244
247
  { value: "call", label: "Call", icon: <Phone className="h-4 w-4" /> },
245
248
  {
246
249
  value: "google-meet",
@@ -259,6 +262,21 @@ const FORMAT_OPTIONS: {
259
262
  },
260
263
  ];
261
264
 
265
+ /** Simplified three-option set shown in client mode */
266
+ const CLIENT_FORMAT_OPTIONS: FormatOption[] = [
267
+ { value: "call", label: "Call", icon: <Phone className="h-4 w-4" /> },
268
+ {
269
+ value: "online",
270
+ label: "Online Meeting",
271
+ icon: <Video className="h-4 w-4" />,
272
+ },
273
+ {
274
+ value: "offline",
275
+ label: "Offline Meeting",
276
+ icon: <MapPin className="h-4 w-4" />,
277
+ },
278
+ ];
279
+
262
280
  function MeetingFormatSection({
263
281
  format,
264
282
  onFormatChange,
@@ -269,6 +287,7 @@ function MeetingFormatSection({
269
287
  advisorOfficeAddress,
270
288
  clientHomeAddress,
271
289
  isClientMode,
290
+ formatOptions,
272
291
  }: {
273
292
  format: AppointmentMeetingFormat;
274
293
  onFormatChange: (f: AppointmentMeetingFormat) => void;
@@ -279,11 +298,12 @@ function MeetingFormatSection({
279
298
  advisorOfficeAddress?: string;
280
299
  clientHomeAddress?: string;
281
300
  isClientMode?: boolean;
301
+ formatOptions: FormatOption[];
282
302
  }) {
283
303
  return (
284
304
  <div className="flex flex-col gap-2">
285
305
  <div className="flex gap-2">
286
- {FORMAT_OPTIONS.map((opt) => (
306
+ {formatOptions.map((opt) => (
287
307
  <Toggle
288
308
  key={opt.value}
289
309
  variant="outline"
@@ -681,6 +701,9 @@ export function AppointmentBookDialog({
681
701
  advisorOfficeAddress={advisorOfficeAddress}
682
702
  clientHomeAddress={clientHomeAddress}
683
703
  isClientMode={isClientMode}
704
+ formatOptions={
705
+ isClientMode ? CLIENT_FORMAT_OPTIONS : FORMAT_OPTIONS
706
+ }
684
707
  />
685
708
  </div>
686
709
  )}
@@ -18,6 +18,7 @@ export type AppointmentMeetingFormat =
18
18
  | "call"
19
19
  | "google-meet"
20
20
  | "microsoft-teams"
21
+ | "online"
21
22
  | "offline";
22
23
 
23
24
  // ---------------------------------------------------------------------------
@@ -14,7 +14,7 @@ import { Slot } from "@/lib/slot";
14
14
  * This gives softer, more readable badges and works correctly in light and dark mode.
15
15
  */
16
16
  const badgeVariants = cva(
17
- "inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2.5 py-0.5 text-xs font-medium font-sans whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
17
+ "inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2.5 py-0.5 text-caption whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
18
18
  {
19
19
  variants: {
20
20
  variant: {