@wealthx/shadcn 1.5.22 → 1.5.23

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.
@@ -87,6 +87,20 @@ export interface AppointmentBookDialogProps {
87
87
  * "Book Appointment".
88
88
  */
89
89
  clients?: AppointmentClient[];
90
+ /**
91
+ * Fired when the user types in the client search field.
92
+ * Use this to fetch clients from an API based on the query.
93
+ * When omitted, clients are filtered locally by name/email.
94
+ */
95
+ onSearchClients?: (query: string) => void;
96
+ /** True while a client search request is in-flight. */
97
+ isSearchingClients?: boolean;
98
+ /** True when more client results can be loaded. */
99
+ hasMoreClients?: boolean;
100
+ /** Fired when the user clicks "Load more" in the client dropdown. */
101
+ onLoadMoreClients?: () => void;
102
+ /** True while a load-more request is in-flight. */
103
+ isLoadingMoreClients?: boolean;
90
104
  /**
91
105
  * Meeting type options. Omit or pass an empty array to hide the meeting
92
106
  * type field (useful in client mode where the type is implicit).
@@ -125,14 +139,25 @@ export interface AppointmentBookDialogProps {
125
139
  */
126
140
  initialClientId?: string;
127
141
  /**
128
- * Pre-set meeting format from the advisor's availability settings.
129
- * In **client mode**, when this is provided the meeting format picker is
130
- * hidden — the client books using this format only.
131
- * Has no effect in advisor mode (advisors always see the format picker).
142
+ * Pre-set meeting format when the dialog opens.
132
143
  *
133
144
  * @remarks Mount-time initialiser only.
134
145
  */
135
146
  defaultMeetingFormat?: AppointmentMeetingFormat;
147
+ /**
148
+ * Which online meeting platform the advisor has integrated.
149
+ *
150
+ * - `"google-meet"` — show only Google Meet
151
+ * - `"microsoft-teams"` — show only MS Teams
152
+ * - `"any"` — show both Google Meet and MS Teams
153
+ *
154
+ * In **client mode**, this replaces the generic "Online Meeting" option
155
+ * with the specific platform(s). In **advisor mode** this has no effect
156
+ * (advisors always see all four formats).
157
+ *
158
+ * When omitted in client mode, the generic "Online Meeting" option is shown.
159
+ */
160
+ onlinePlatform?: "google-meet" | "microsoft-teams" | "any";
136
161
  /**
137
162
  * Guest identity for **client / public booking mode** only.
138
163
  * When all fields are provided the guest form is hidden; when any field is
@@ -173,58 +198,94 @@ function ClientSearch({
173
198
  clients,
174
199
  value,
175
200
  onValueChange,
201
+ onSearch,
202
+ isSearching,
203
+ hasMore,
204
+ onLoadMore,
205
+ isLoadingMore,
176
206
  }: {
177
207
  clients: AppointmentClient[];
178
208
  value: string | undefined;
179
209
  onValueChange: (id: string | undefined) => void;
210
+ onSearch?: (query: string) => void;
211
+ isSearching?: boolean;
212
+ hasMore?: boolean;
213
+ onLoadMore?: () => void;
214
+ isLoadingMore?: boolean;
180
215
  }) {
181
216
  const [query, setQuery] = React.useState("");
182
217
  const [open, setOpen] = React.useState(false);
183
218
  const selected = clients.find((c) => c.id === value);
184
- const filtered = clients.filter((c) => {
185
- const q = query.toLowerCase();
186
- return (
187
- c.name.toLowerCase().includes(q) || c.email.toLowerCase().includes(q)
188
- );
189
- });
219
+
220
+ const filtered = onSearch
221
+ ? clients
222
+ : clients.filter((c) => {
223
+ const q = query.toLowerCase();
224
+ return (
225
+ c.name.toLowerCase().includes(q) || c.email.toLowerCase().includes(q)
226
+ );
227
+ });
190
228
 
191
229
  return (
192
230
  <div className="relative">
193
231
  <Input
194
232
  value={selected ? selected.name : query}
195
233
  onChange={(e) => {
196
- setQuery(e.target.value);
234
+ const v = e.target.value;
235
+ setQuery(v);
197
236
  if (selected) onValueChange(undefined);
198
- setOpen(e.target.value.length > 0);
237
+ setOpen(v.length > 0);
238
+ onSearch?.(v);
199
239
  }}
200
240
  onBlur={() => setTimeout(() => setOpen(false), 150)}
201
241
  placeholder="Search by name or email…"
202
242
  autoComplete="off"
203
243
  />
204
- {open && (filtered.length > 0 || query.length > 0) && (
205
- <div className="absolute z-50 mt-1 w-full border border-border bg-popover shadow-md">
206
- {filtered.length === 0 ? (
244
+ {open && (filtered.length > 0 || query.length > 0 || isSearching) && (
245
+ <div className="absolute z-50 mt-1 max-h-64 w-full overflow-y-auto border border-border bg-popover shadow-md">
246
+ {isSearching ? (
247
+ <p className="px-3 py-6 text-center text-sm text-muted-foreground">
248
+ Searching...
249
+ </p>
250
+ ) : filtered.length === 0 ? (
207
251
  <p className="px-3 py-6 text-center text-sm text-muted-foreground">
208
252
  No clients found.
209
253
  </p>
210
254
  ) : (
211
- filtered.map((c) => (
212
- <Button
213
- key={c.id}
214
- type="button"
215
- variant="ghost"
216
- className="h-auto w-full flex-col items-start gap-0.5 px-3 py-2 text-left hover:bg-primary/5"
217
- onMouseDown={(e) => e.preventDefault()}
218
- onClick={() => {
219
- onValueChange(c.id);
220
- setQuery("");
221
- setOpen(false);
222
- }}
223
- >
224
- <span className="text-sm font-medium">{c.name}</span>
225
- <span className="text-xs text-muted-foreground">{c.email}</span>
226
- </Button>
227
- ))
255
+ <>
256
+ {filtered.map((c) => (
257
+ <Button
258
+ key={c.id}
259
+ type="button"
260
+ variant="ghost"
261
+ className="h-auto w-full flex-col items-start gap-0.5 px-3 py-2 text-left hover:bg-primary/5"
262
+ onMouseDown={(e) => e.preventDefault()}
263
+ onClick={() => {
264
+ onValueChange(c.id);
265
+ setQuery("");
266
+ setOpen(false);
267
+ }}
268
+ >
269
+ <span className="text-sm font-medium">{c.name}</span>
270
+ <span className="text-xs text-muted-foreground">
271
+ {c.email}
272
+ </span>
273
+ </Button>
274
+ ))}
275
+ {hasMore && onLoadMore && (
276
+ <Button
277
+ type="button"
278
+ variant="ghost"
279
+ size="sm"
280
+ className="w-full text-sm text-primary"
281
+ disabled={isLoadingMore}
282
+ onMouseDown={(e) => e.preventDefault()}
283
+ onClick={onLoadMore}
284
+ >
285
+ {isLoadingMore ? "Loading..." : "Load more"}
286
+ </Button>
287
+ )}
288
+ </>
228
289
  )}
229
290
  </div>
230
291
  )}
@@ -277,6 +338,36 @@ const CLIENT_FORMAT_OPTIONS: FormatOption[] = [
277
338
  },
278
339
  ];
279
340
 
341
+ function getFormatOptions(
342
+ platform?: "google-meet" | "microsoft-teams" | "any",
343
+ ): FormatOption[] {
344
+ const call: FormatOption = {
345
+ value: "call",
346
+ label: "Call",
347
+ icon: <Phone className="h-4 w-4" />,
348
+ };
349
+ const googleMeet: FormatOption = {
350
+ value: "google-meet",
351
+ label: "Google Meet",
352
+ icon: <Video className="h-4 w-4" />,
353
+ };
354
+ const msTeams: FormatOption = {
355
+ value: "microsoft-teams",
356
+ label: "MS Teams",
357
+ icon: <Users className="h-4 w-4" />,
358
+ };
359
+ const offline: FormatOption = {
360
+ value: "offline",
361
+ label: "Offline Meeting",
362
+ icon: <MapPin className="h-4 w-4" />,
363
+ };
364
+
365
+ if (platform === "google-meet") return [call, googleMeet, offline];
366
+ if (platform === "microsoft-teams") return [call, msTeams, offline];
367
+ if (platform === "any") return [call, googleMeet, msTeams, offline];
368
+ return [call, offline];
369
+ }
370
+
280
371
  function MeetingFormatSection({
281
372
  format,
282
373
  onFormatChange,
@@ -458,6 +549,11 @@ export function AppointmentBookDialog({
458
549
  open,
459
550
  onOpenChange,
460
551
  clients = [],
552
+ onSearchClients,
553
+ isSearchingClients,
554
+ hasMoreClients,
555
+ onLoadMoreClients,
556
+ isLoadingMoreClients,
461
557
  meetingTypes = [],
462
558
  amSlots,
463
559
  pmSlots,
@@ -468,10 +564,11 @@ export function AppointmentBookDialog({
468
564
  advisorInfo,
469
565
  initialClientId,
470
566
  defaultMeetingFormat,
567
+ onlinePlatform,
471
568
  guestInfo,
472
569
  onBook,
473
570
  }: AppointmentBookDialogProps) {
474
- const isClientMode = clients.length === 0;
571
+ const isClientMode = clients.length === 0 && !onSearchClients;
475
572
  // Guest form is shown in client mode when at least one identity field is missing.
476
573
  const showGuestForm = isClientMode && !(guestInfo?.name && guestInfo?.email);
477
574
 
@@ -620,6 +717,11 @@ export function AppointmentBookDialog({
620
717
  clients={clients}
621
718
  value={clientId}
622
719
  onValueChange={setClientId}
720
+ onSearch={onSearchClients}
721
+ isSearching={isSearchingClients}
722
+ hasMore={hasMoreClients}
723
+ onLoadMore={onLoadMoreClients}
724
+ isLoadingMore={isLoadingMoreClients}
623
725
  />
624
726
  </div>
625
727
  )}
@@ -687,26 +789,21 @@ export function AppointmentBookDialog({
687
789
  </>
688
790
  )}
689
791
 
690
- {/* Format picker — hidden in client mode when broker pre-set a platform */}
691
- {!(isClientMode && defaultMeetingFormat) && (
692
- <div className="flex flex-col gap-1.5">
693
- <Label>Meeting format</Label>
694
- <MeetingFormatSection
695
- format={meetingFormat}
696
- onFormatChange={setMeetingFormat}
697
- offlineLocation={offlineLocation}
698
- onOfflineLocationChange={setOfflineLocation}
699
- customAddress={customAddress}
700
- onCustomAddressChange={setCustomAddress}
701
- advisorOfficeAddress={advisorOfficeAddress}
702
- clientHomeAddress={clientHomeAddress}
703
- isClientMode={isClientMode}
704
- formatOptions={
705
- isClientMode ? CLIENT_FORMAT_OPTIONS : FORMAT_OPTIONS
706
- }
707
- />
708
- </div>
709
- )}
792
+ <div className="flex flex-col gap-1.5">
793
+ <Label>Meeting format</Label>
794
+ <MeetingFormatSection
795
+ format={meetingFormat}
796
+ onFormatChange={setMeetingFormat}
797
+ offlineLocation={offlineLocation}
798
+ onOfflineLocationChange={setOfflineLocation}
799
+ customAddress={customAddress}
800
+ onCustomAddressChange={setCustomAddress}
801
+ advisorOfficeAddress={advisorOfficeAddress}
802
+ clientHomeAddress={clientHomeAddress}
803
+ isClientMode={isClientMode}
804
+ formatOptions={getFormatOptions(onlinePlatform)}
805
+ />
806
+ </div>
710
807
 
711
808
  {/* Phone selection — advisor mode, Call format, client has phones */}
712
809
  {!isClientMode &&
@@ -82,6 +82,18 @@ export interface AppointmentDetailSheetProps {
82
82
  clientProfile?: AppointmentClientProfile;
83
83
  amSlots: AppointmentTimeSlot[];
84
84
  pmSlots: AppointmentTimeSlot[];
85
+ /**
86
+ * Fired when the user selects a different date in the reschedule dialog.
87
+ * Use this to fetch fresh `amSlots`/`pmSlots` for the new date.
88
+ */
89
+ onDateChange?: (date: Date) => void;
90
+ /**
91
+ * Advisor's weekly availability schedule. Days with `enabled: false` are
92
+ * disabled in the reschedule date picker.
93
+ */
94
+ schedule?: { day: string; enabled: boolean }[];
95
+ /** True while slots are being fetched for the selected date. */
96
+ isLoadingSlots?: boolean;
85
97
  onAccept?: (id: string) => void;
86
98
  onDecline?: (id: string) => void;
87
99
  onReschedule?: (
@@ -167,6 +179,9 @@ export function AppointmentDetailSheet({
167
179
  clientProfile,
168
180
  amSlots,
169
181
  pmSlots,
182
+ onDateChange,
183
+ schedule,
184
+ isLoadingSlots,
170
185
  onAccept,
171
186
  onDecline,
172
187
  onReschedule,
@@ -440,6 +455,9 @@ export function AppointmentDetailSheet({
440
455
  currentDate={appointment.date}
441
456
  currentTimeStart={appointment.timeStart}
442
457
  currentTimeEnd={appointment.timeEnd}
458
+ onDateChange={onDateChange}
459
+ schedule={schedule}
460
+ isLoadingSlots={isLoadingSlots}
443
461
  onReschedule={(date, slot, note) => {
444
462
  onReschedule?.(appointment.id, date, slot, note);
445
463
  setRescheduleOpen(false);