@flamingo-stack/openframe-frontend-core 0.0.742 → 0.0.744

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 (94) hide show
  1. package/dist/build/export-map.json +1 -1
  2. package/dist/{chunk-54EA4RLX.cjs → chunk-2JRCJV3M.cjs} +3 -3
  3. package/dist/{chunk-ZIFYSLIM.cjs → chunk-2UBR53AN.cjs} +4 -4
  4. package/dist/{chunk-Z2DQYKZA.cjs → chunk-35IQLGV3.cjs} +69 -69
  5. package/dist/{chunk-WIHHA54J.cjs → chunk-4A6OF4HI.cjs} +44 -44
  6. package/dist/{chunk-OBODY7F3.cjs → chunk-4N7KGOBS.cjs} +3 -3
  7. package/dist/{chunk-HIRVNBX5.cjs → chunk-65LJNDR2.cjs} +16 -16
  8. package/dist/{chunk-32Q7K5OP.cjs → chunk-7YDF3QBN.cjs} +116 -115
  9. package/dist/{chunk-DNFSMVXJ.cjs → chunk-AT6ZI4CO.cjs} +12 -12
  10. package/dist/{chunk-UWHCRIQQ.cjs → chunk-FTYQS6VI.cjs} +13 -13
  11. package/dist/{chunk-YQGDXH3M.cjs → chunk-GAGNKWOC.cjs} +2 -2
  12. package/dist/{chunk-L34TKFTS.cjs → chunk-H62FNZGG.cjs} +16 -16
  13. package/dist/{chunk-T433HSL6.cjs → chunk-HHL23RFC.cjs} +33 -33
  14. package/dist/{chunk-4PKORVOX.cjs → chunk-HJ4KGA2M.cjs} +441 -406
  15. package/dist/{chunk-4LDU32OB.cjs → chunk-HMSUYWSB.cjs} +4 -4
  16. package/dist/{chunk-UZLRSFGG.cjs → chunk-HTQJYN47.cjs} +30 -30
  17. package/dist/{chunk-XAA5EZ5T.cjs → chunk-JGZEHBBD.cjs} +6 -6
  18. package/dist/{chunk-5MH6BRPY.cjs → chunk-LJ46XYYP.cjs} +24 -12
  19. package/dist/{chunk-ISJXKKYW.cjs → chunk-NURAL5HD.cjs} +11 -11
  20. package/dist/{chunk-PS6T65KA.cjs → chunk-OC2LBQQJ.cjs} +90 -66
  21. package/dist/{chunk-HDBY2URA.cjs → chunk-PYWHDPVC.cjs} +31 -31
  22. package/dist/{chunk-R6SCSV2I.cjs → chunk-QSO56JZ2.cjs} +81 -81
  23. package/dist/{chunk-3LW43GIJ.cjs → chunk-QZYF2YQ3.cjs} +4 -4
  24. package/dist/{chunk-BI3XBCQX.cjs → chunk-YIOWV7LS.cjs} +2 -2
  25. package/dist/{chunk-H3UA7UY5.cjs → chunk-YJRMCFX3.cjs} +2 -2
  26. package/dist/{cloud-tenants-6ICAL3HO.cjs → cloud-tenants-JPXAXSPE.cjs} +7 -7
  27. package/dist/components/case-studies/index.cjs +19 -19
  28. package/dist/components/chat/entity-cards/onboarding-guide-card.d.ts.map +1 -1
  29. package/dist/components/chat/entity-cards/onboarding-guide-card.js +22 -12
  30. package/dist/components/chat/index.cjs +5 -5
  31. package/dist/components/chat/tool-execution-display.d.ts.map +1 -1
  32. package/dist/components/chat/tool-execution-display.js +5 -4
  33. package/dist/components/chat/utils/history-merge.d.ts.map +1 -1
  34. package/dist/components/chat/utils/history-merge.js +25 -1
  35. package/dist/components/contact/index.cjs +8 -8
  36. package/dist/components/docs/index.cjs +11 -11
  37. package/dist/components/embeds/index.cjs +8 -8
  38. package/dist/components/faq/index.cjs +9 -9
  39. package/dist/components/features/index.cjs +7 -7
  40. package/dist/components/features/scripts/schedule-form/schedule-interval-input.d.ts +1 -1
  41. package/dist/components/features/scripts/schedule-form/schedule-interval-input.d.ts.map +1 -1
  42. package/dist/components/features/scripts/schedule-form/schedule-interval-input.js +15 -13
  43. package/dist/components/features/scripts/schedule-form/schedule-offline-fields.d.ts.map +1 -1
  44. package/dist/components/features/scripts/schedule-form/schedule-offline-fields.js +9 -7
  45. package/dist/components/help-center-pages/index.cjs +101 -101
  46. package/dist/components/index.cjs +59 -57
  47. package/dist/components/meeting-scheduler/booking-form.d.ts +11 -1
  48. package/dist/components/meeting-scheduler/booking-form.d.ts.map +1 -1
  49. package/dist/components/meeting-scheduler/booking-form.js +15 -3
  50. package/dist/components/meeting-scheduler/index.cjs +408 -114
  51. package/dist/components/meeting-scheduler/index.d.ts +16 -1
  52. package/dist/components/meeting-scheduler/index.d.ts.map +1 -1
  53. package/dist/components/meeting-scheduler/index.js +157 -5
  54. package/dist/components/meeting-scheduler/modal-header.d.ts +27 -0
  55. package/dist/components/meeting-scheduler/modal-header.d.ts.map +1 -0
  56. package/dist/components/meeting-scheduler/modal-header.js +104 -0
  57. package/dist/components/meeting-scheduler/slot-picker.d.ts +13 -2
  58. package/dist/components/meeting-scheduler/slot-picker.d.ts.map +1 -1
  59. package/dist/components/meeting-scheduler/slot-picker.js +112 -73
  60. package/dist/components/navigation/index.cjs +10 -10
  61. package/dist/components/onboarding-guides/index.cjs +10 -10
  62. package/dist/components/product-demo/index.cjs +7 -7
  63. package/dist/components/related-content/index.cjs +5 -5
  64. package/dist/components/shared/product-release/product-release-card.d.ts.map +1 -1
  65. package/dist/components/shared/product-release/product-release-card.js +2 -0
  66. package/dist/components/tickets/index.cjs +13 -13
  67. package/dist/components/ui/index.cjs +9 -7
  68. package/dist/components/ui/radio-group.d.ts +6 -0
  69. package/dist/components/ui/radio-group.d.ts.map +1 -1
  70. package/dist/components/ui/radio-group.js +65 -58
  71. package/dist/components/ui/tab-navigation.d.ts +19 -1
  72. package/dist/components/ui/tab-navigation.d.ts.map +1 -1
  73. package/dist/components/ui/tab-navigation.js +32 -8
  74. package/dist/{devices-OFP2IBKN.cjs → devices-ONC4JOVH.cjs} +8 -8
  75. package/dist/{embeddable-chat-4C22MGNC.cjs → embeddable-chat-7ZEGCDVF.cjs} +71 -71
  76. package/dist/index.cjs +13 -11
  77. package/dist/{knowledge-KKBCSNUA.cjs → knowledge-G7XU5YST.cjs} +8 -8
  78. package/dist/{logs-X2GRNO57.cjs → logs-5B7NY4G6.cjs} +15 -15
  79. package/dist/{policies-35BXKB6P.cjs → policies-OYR3SLJN.cjs} +11 -11
  80. package/dist/{scripts-KQYVQOLU.cjs → scripts-6CWHTY6U.cjs} +12 -12
  81. package/dist/{software-update-ED33WSKT.cjs → software-update-AJPIY4SQ.cjs} +10 -10
  82. package/package.json +1 -1
  83. package/src/components/chat/entity-cards/onboarding-guide-card.tsx +8 -1
  84. package/src/components/chat/tool-execution-display.tsx +21 -10
  85. package/src/components/chat/utils/history-merge.ts +46 -1
  86. package/src/components/features/scripts/schedule-form/schedule-interval-input.tsx +20 -13
  87. package/src/components/features/scripts/schedule-form/schedule-offline-fields.tsx +9 -5
  88. package/src/components/meeting-scheduler/booking-form.tsx +29 -3
  89. package/src/components/meeting-scheduler/index.tsx +224 -3
  90. package/src/components/meeting-scheduler/modal-header.tsx +145 -0
  91. package/src/components/meeting-scheduler/slot-picker.tsx +72 -8
  92. package/src/components/shared/product-release/product-release-card.tsx +2 -0
  93. package/src/components/ui/radio-group.tsx +51 -36
  94. package/src/components/ui/tab-navigation.tsx +54 -5
@@ -1,7 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import { zodResolver } from '@hookform/resolvers/zod';
4
- import { Fragment, useEffect, useMemo, useState } from 'react';
4
+ import { Fragment, useEffect, useMemo, useRef, useState } from 'react';
5
5
  import type { FormEvent, ReactNode, Ref } from 'react';
6
6
  import { useForm, Controller } from 'react-hook-form';
7
7
  import type { Control, Path, UseFormRegister } from 'react-hook-form';
@@ -397,6 +397,16 @@ export interface BookingFormProps {
397
397
  * appended full width. Both are deliberate — see `slotNode`/`unplacedFields`.
398
398
  */
399
399
  fieldRows?: BookingFieldRow[];
400
+ /** The form element's id, so a host can submit it from a button outside it
401
+ * (`<button form={id}>`): the modal layout keeps its actions in a footer
402
+ * under the card. */
403
+ formId?: string;
404
+ /** Leave the submit button out: the host draws one of its own (see `formId`). */
405
+ hideSubmit?: boolean;
406
+ /** Whether the answers would pass validation as they stand, reported on every
407
+ * change: a host drawing its own submit keeps it disabled until this says
408
+ * true. The form's own button never needs it - its click validates. */
409
+ onValidityChange?: (valid: boolean) => void;
400
410
  /** Per-field display overrides, keyed by field name — see `BookingFieldCopy`. */
401
411
  fieldCopy?: Record<string, BookingFieldCopy>;
402
412
  /** Email domains this form refuses, with the message it shows — see `DeniedEmailDomains`.
@@ -441,6 +451,9 @@ export function BookingForm({
441
451
  submitLabel,
442
452
  footerNote,
443
453
  fieldRows,
454
+ formId,
455
+ hideSubmit = false,
456
+ onValidityChange,
444
457
  fieldCopy,
445
458
  deniedEmailDomains,
446
459
  consent,
@@ -507,6 +520,19 @@ export function BookingForm({
507
520
  const [consentError, setConsentError] = useState<string | null>(null);
508
521
  const consentMissing = Boolean(consent) && !consented;
509
522
 
523
+ // Live validity for a host-drawn submit. `watch()` re-renders on every
524
+ // change; the parse is the same schema the submit runs, so the two can never
525
+ // disagree. Reported only when asked for, and only when it changes.
526
+ const watchedValues = watch();
527
+ const liveValid = onValidityChange ? schema.safeParse(watchedValues).success && !consentMissing : false;
528
+ const lastValidRef = useRef<boolean | null>(null);
529
+ useEffect(() => {
530
+ if (!onValidityChange) return;
531
+ if (lastValidRef.current === liveValid) return;
532
+ lastValidRef.current = liveValid;
533
+ onValidityChange(liveValid);
534
+ });
535
+
510
536
  // The seeded availability is refetched immediately on mount, so the link's
511
537
  // consent set can change WHILE this form is open. Reconcile rather than
512
538
  // remount: carry each `consented` across by id and default new ids to false.
@@ -685,7 +711,7 @@ export function BookingForm({
685
711
 
686
712
  // Opted out of HubSpot's collected-forms script — see HUBSPOT_DO_NOT_COLLECT_FORM_PROPS.
687
713
  return (
688
- <form onSubmit={submit} className={FORM_STACK} noValidate {...HUBSPOT_DO_NOT_COLLECT_FORM_PROPS}>
714
+ <form id={formId} onSubmit={submit} className={FORM_STACK} noValidate {...HUBSPOT_DO_NOT_COLLECT_FORM_PROPS}>
689
715
  <HoneypotField {...honeypotInputProps} />
690
716
 
691
717
  {/* Email first, name pair below — the order the desktop and mobile mocks
@@ -806,7 +832,7 @@ export function BookingForm({
806
832
  {/* Two shapes, not one with a placeholder: without a note this must stay
807
833
  the bare `flex` row it has always been, or the submit slides from the
808
834
  left edge to the right on every existing slot-first booking. */}
809
- {footerNote ? (
835
+ {hideSubmit ? null : footerNote ? (
810
836
  <div className={FOOTER_ROW_CLASS}>
811
837
  <p className="text-ods-text-secondary text-h6">{footerNote}</p>
812
838
  {submitButton}
@@ -39,7 +39,7 @@
39
39
  * `SCHEDULER_FLOW_PRESETS` and the `detailsFirst` branches below.
40
40
  */
41
41
 
42
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
42
+ import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
43
43
  import type { ComponentType, ReactNode } from 'react';
44
44
  import { useIsHydrated } from '../../hooks/ui/use-is-hydrated';
45
45
  import { completeFormRescue } from '../../hooks/use-form-rescue';
@@ -56,10 +56,18 @@ import {
56
56
  import { cn } from '../../utils/cn';
57
57
  import { FORM_RESCUE_ATTEMPT_FIELD, type FormRescueDefinition } from '../../utils/form-rescue';
58
58
  import { formatDurationCompact, formatDateWithTimezone } from '../../utils/format';
59
+ import { BackButton } from '../layout/back-button';
59
60
  import { Alert, AlertDescription, Button } from '../ui';
60
- import { BookingForm, BookingFormSkeleton, DEFAULT_SUBMIT_LABEL, type BookingFormProps } from './booking-form';
61
+ import {
62
+ BookingForm,
63
+ BookingFormSkeleton,
64
+ DEFAULT_SUBMIT_LABEL,
65
+ type BookingFieldRow,
66
+ type BookingFormProps,
67
+ } from './booking-form';
61
68
  import { Confirmation } from './confirmation';
62
69
  import { SchedulerContextPanel, ContextPanelSkeleton } from './context-panel';
70
+ import { SchedulerModalHeader, SchedulerModalHeaderSkeleton } from './modal-header';
63
71
  import { SlotPicker, SlotPickerSkeleton, dayKeyInZone } from './slot-picker';
64
72
 
65
73
  /**
@@ -156,8 +164,24 @@ export interface HubSpotMeetingSchedulerProps {
156
164
  /** Form rescue for the details form (`RESCUE_FORMS.meetingBooking`). OPT-IN:
157
165
  * omitted or `null` saves nothing. */
158
166
  rescue?: FormRescueDefinition | null;
167
+ /**
168
+ * How the card is drawn.
169
+ *
170
+ * `'card'` (default, unchanged): the scheduler card - context panel beside
171
+ * (or over) the calendar, as every page embed has it.
172
+ * `'modal'`: the onboarding "Book an Onboarding Call" window: the host strip
173
+ * with the length on its right, the timezone row, then the calendar beside
174
+ * a single column of times, and Continue in a footer under the card (with
175
+ * Cancel beside it on a phone, where the window has no X). A time is
176
+ * CHOSEN, not submitted: Continue moves to the details form. Opt-in, so
177
+ * every existing embed keeps the card byte for byte; `details-first` has
178
+ * no modal arrangement and ignores it.
179
+ */
180
+ layout?: SchedulerLayout;
159
181
  }
160
182
 
183
+ export type SchedulerLayout = 'card' | 'modal';
184
+
161
185
  type Step = 'slot' | 'details' | 'confirmed';
162
186
  /** `onStageChange`'s value: a step, or the degraded box that replaces the flow. */
163
187
  export type SchedulerStage = Step | 'unavailable';
@@ -393,6 +417,41 @@ function isNativelyBookable(availability: MeetingAvailability): boolean {
393
417
  return true;
394
418
  }
395
419
 
420
+ /**
421
+ * The modal layout's frame: the content card and, under it, the footer row.
422
+ * No stated height - the window is as tall as the step (the calendar states
423
+ * its own, see the slot picker), and the modal around it scrolls if the form
424
+ * ever outgrows the screen.
425
+ */
426
+ const MODAL_STACK_CLASS = 'flex flex-col gap-[var(--spacing-system-l)]';
427
+ const MODAL_CARD_CLASS = 'flex flex-col overflow-hidden rounded-md border border-ods-border bg-ods-card';
428
+ const MODAL_STEP_CLASS = 'flex flex-col p-[var(--spacing-system-m)]';
429
+ const NOOP = () => undefined;
430
+
431
+ /** The modal's form draws the name pair first and the email under it, as its design does. */
432
+ const MODAL_FIELD_ROWS: BookingFieldRow[] = [[{ name: 'firstName' }, { name: 'lastName' }], [{ name: 'email' }]];
433
+
434
+ const MODAL_PRIMARY_CLASS = 'flex-1 content-md:min-w-[15.5rem] content-md:flex-none';
435
+
436
+ /**
437
+ * The action row under the card: the step's primary on the right and, on a
438
+ * phone, Cancel beside it (the window has no X there). On the calendar the
439
+ * primary is Continue; on the form it is the form's own submit, drawn here and
440
+ * wired to the form by id so the actions sit under the card, not inside it.
441
+ */
442
+ function ModalFooter({ onCancel, primary }: { onCancel?: () => void; primary: ReactNode }) {
443
+ return (
444
+ <div className="flex gap-[var(--spacing-system-l)] content-md:justify-end">
445
+ {onCancel && (
446
+ <Button variant="outline" onClick={onCancel} className="flex-1 content-md:hidden">
447
+ Cancel
448
+ </Button>
449
+ )}
450
+ {primary}
451
+ </div>
452
+ );
453
+ }
454
+
396
455
  export function HubSpotMeetingScheduler({
397
456
  meetingId,
398
457
  apiBaseUrl = '',
@@ -410,6 +469,7 @@ export function HubSpotMeetingScheduler({
410
469
  detailsForm: DetailsForm = BookingForm,
411
470
  detailsFormProps,
412
471
  rescue = null,
472
+ layout = 'card',
413
473
  }: HubSpotMeetingSchedulerProps) {
414
474
  const {
415
475
  availability,
@@ -424,6 +484,11 @@ export function HubSpotMeetingScheduler({
424
484
  } = useMeetingBooking({ meetingId, apiBaseUrl, initialAvailability });
425
485
 
426
486
  const detailsFirst = flow === 'details-first';
487
+ // The modal arrangement is slot-first's only: details-first keeps the card.
488
+ const modal = layout === 'modal' && !detailsFirst;
489
+ const modalFormId = useId();
490
+ // The modal's submit sits outside the form and follows what the form reports.
491
+ const [modalFormValid, setModalFormValid] = useState(false);
427
492
 
428
493
  // Zone resolution happens POST-mount (Intl) unless the embedder pins one —
429
494
  // the initial (server) render stays deterministic; time labels appear once
@@ -740,6 +805,25 @@ export function HubSpotMeetingScheduler({
740
805
  );
741
806
  const degradedCard = degraded("We couldn't load available call times. Please try again shortly.");
742
807
 
808
+ if (modal && isLoadingAvailability && !availability) {
809
+ return (
810
+ <div className={cn(MODAL_STACK_CLASS, className)}>
811
+ <div className={MODAL_CARD_CLASS}>
812
+ <SchedulerModalHeaderSkeleton />
813
+ <SlotPickerSkeleton monthOffset={monthOffset} layout="modal" />
814
+ </div>
815
+ <ModalFooter
816
+ onCancel={onBack}
817
+ primary={
818
+ <Button disabled onClick={NOOP} className={MODAL_PRIMARY_CLASS}>
819
+ Continue
820
+ </Button>
821
+ }
822
+ />
823
+ </div>
824
+ );
825
+ }
826
+
743
827
  if (isLoadingAvailability && !availability) {
744
828
  // COLD start only — a month already in the query cache renders straight
745
829
  // into the loaded card. Composed inside the SAME wrappers as that card
@@ -783,6 +867,142 @@ export function HubSpotMeetingScheduler({
783
867
 
784
868
  // ---- the card ------------------------------------------------------------
785
869
 
870
+ if (modal) {
871
+ // slot-first only, so the form's Back is the calendar and the calendar's is the host's exit.
872
+ const modalBack = step === 'details' ? backToSlot : onBack;
873
+ const onSelectDuration = (ms: number) => {
874
+ setDurationMs(ms);
875
+ setSelectedDay(null);
876
+ setSelectedSlot(null);
877
+ };
878
+ const summary =
879
+ selectedSlot != null && durationMs != null && timezone != null
880
+ ? `${formatDateWithTimezone(selectedSlot, timezone, 'weekdayDateTimeZoned', { viewerLocale: true })} · ${formatDurationCompact(durationMs / 1000)}`
881
+ : null;
882
+ return (
883
+ <div className={cn(MODAL_STACK_CLASS, className)}>
884
+ <div className={MODAL_CARD_CLASS}>
885
+ {/* The window's one back edge: on the form it returns to the
886
+ calendar at every width; on the calendar it is the host's exit,
887
+ drawn on a phone only - the window has an X everywhere else. */}
888
+ {modalBack && (
889
+ <BackButton
890
+ onClick={modalBack}
891
+ className={cn(
892
+ 'px-[var(--spacing-system-m)] pt-[var(--spacing-system-m)]',
893
+ step === 'slot' && 'content-md:hidden',
894
+ )}
895
+ />
896
+ )}
897
+ <SchedulerModalHeader
898
+ hosts={shownHosts}
899
+ durationsMs={durations}
900
+ selectedDurationMs={durationMs}
901
+ onSelectDuration={onSelectDuration}
902
+ timezone={timezone}
903
+ onTimezoneChange={setTimezone}
904
+ locked={step !== 'slot'}
905
+ // The zone is chosen with the time; the form restates it in its
906
+ // summary line, so the picker is the calendar's only.
907
+ showTimezone={step === 'slot'}
908
+ />
909
+ {step === 'confirmed' && confirmation && timezone ? (
910
+ <div className={MODAL_STEP_CLASS}>
911
+ <Confirmation confirmation={confirmation} timezone={timezone} />
912
+ </div>
913
+ ) : step === 'details' && durationMs != null && selectedSlot != null && timezone ? (
914
+ <div className={cn(MODAL_STEP_CLASS, 'gap-[var(--spacing-system-m)]')}>
915
+ {summary && <p className="text-ods-text-primary text-h4">{summary}</p>}
916
+ <DetailsForm
917
+ fieldRows={MODAL_FIELD_ROWS}
918
+ {...detailsFormProps}
919
+ availability={availability}
920
+ meetingId={meetingId}
921
+ startTimeMs={selectedSlot}
922
+ durationMs={durationMs}
923
+ timezone={timezone}
924
+ formId={modalFormId}
925
+ hideSubmit
926
+ onValidityChange={setModalFormValid}
927
+ isSubmitting={isSubmitting}
928
+ onSubmit={handleSubmit}
929
+ honeypotInputProps={honeypotInputProps}
930
+ getSignals={getSignals}
931
+ rescue={rescue}
932
+ />
933
+ </div>
934
+ ) : (
935
+ <>
936
+ {bookingError === 'SLOT_TAKEN' && (
937
+ <Alert variant="warning" className="mx-[var(--spacing-system-m)] mt-[var(--spacing-system-m)] w-auto">
938
+ <AlertDescription>
939
+ That time was just taken — pick another slot below. (If you already submitted, check your email for
940
+ a confirmation before rebooking.)
941
+ </AlertDescription>
942
+ </Alert>
943
+ )}
944
+ {timezone && durationMs != null ? (
945
+ <SlotPicker
946
+ layout="modal"
947
+ slots={slots}
948
+ timezone={timezone}
949
+ monthOffset={monthOffset}
950
+ onMonthOffsetChange={o => {
951
+ setSelectedDay(null);
952
+ setSelectedSlot(null);
953
+ setMonthOffset(o);
954
+ }}
955
+ selectedSlot={selectedSlot}
956
+ onSelectSlot={setSelectedSlot}
957
+ selectedDay={selectedDay}
958
+ onSelectDay={setSelectedDay}
959
+ isLoading={isLoadingAvailability}
960
+ />
961
+ ) : durations.length === 0 && !isFetchingAvailability ? (
962
+ <div className={cn(MODAL_STEP_CLASS, 'items-start gap-[var(--spacing-system-m)]')}>
963
+ <p className="text-ods-text-secondary text-h6">No call times are published right now.</p>
964
+ {escapeHatch}
965
+ </div>
966
+ ) : (
967
+ <SlotPickerSkeleton monthOffset={monthOffset} layout="modal" />
968
+ )}
969
+ </>
970
+ )}
971
+ </div>
972
+ {step === 'slot' && (
973
+ <ModalFooter
974
+ onCancel={onBack}
975
+ primary={
976
+ <Button
977
+ disabled={selectedSlot == null || durationMs == null}
978
+ onClick={() => setStep('details')}
979
+ className={MODAL_PRIMARY_CLASS}
980
+ >
981
+ Continue
982
+ </Button>
983
+ }
984
+ />
985
+ )}
986
+ {step === 'details' && (
987
+ <ModalFooter
988
+ onCancel={onBack}
989
+ primary={
990
+ <Button
991
+ type="submit"
992
+ form={modalFormId}
993
+ loading={isSubmitting}
994
+ disabled={isSubmitting || !modalFormValid}
995
+ className={MODAL_PRIMARY_CLASS}
996
+ >
997
+ {preset.submitLabel}
998
+ </Button>
999
+ }
1000
+ />
1001
+ )}
1002
+ </div>
1003
+ );
1004
+ }
1005
+
786
1006
  return (
787
1007
  <div className={cardClass}>
788
1008
  <div className={innerClass}>
@@ -912,7 +1132,8 @@ export function HubSpotMeetingScheduler({
912
1132
  onSelectSlot={ms => {
913
1133
  if (!detailsFirst) {
914
1134
  setSelectedSlot(ms);
915
- setStep('details');
1135
+ // The modal's Continue is what moves on; the card moves at the click.
1136
+ if (!modal) setStep('details');
916
1137
  return;
917
1138
  }
918
1139
  // details-first: the slot IS the submit. The step stays
@@ -0,0 +1,145 @@
1
+ 'use client';
2
+
3
+ import type { MeetingHost } from '../../schemas/meeting-booking-schema';
4
+ import { cn } from '../../utils/cn';
5
+ import { formatDurationCompact } from '../../utils/format';
6
+ import { SquareAvatar, AvatarStack, Button, Skeleton } from '../ui';
7
+ import { TimezoneSelect } from '../ui/timezone-select';
8
+
9
+ /**
10
+ * The head of the scheduler's MODAL layout: the host strip (who, and how long
11
+ * on its right - chips when the link offers several lengths, the length alone
12
+ * otherwise) and the timezone row under it, each cut off by a rule. The
13
+ * context panel's facts in the window's own arrangement: there is no side
14
+ * column in a 600px box, so the strip sits over the calendar and the zone
15
+ * picker spans the width above it, at every size.
16
+ */
17
+ export interface SchedulerModalHeaderProps {
18
+ hosts: MeetingHost[];
19
+ durationsMs: number[];
20
+ selectedDurationMs: number | null;
21
+ onSelectDuration: (ms: number) => void;
22
+ /** Resolved IANA zone (null until the client resolves it). */
23
+ timezone: string | null;
24
+ onTimezoneChange?: (tz: string) => void;
25
+ /** A slot is chosen: the length is settled, the chips give way to the line. */
26
+ locked?: boolean;
27
+ disabled?: boolean;
28
+ /** Whether the timezone row renders (not on the confirmation). */
29
+ showTimezone?: boolean;
30
+ }
31
+
32
+ const ROW_CLASS = 'border-b border-ods-border p-[var(--spacing-system-m)]';
33
+ const STRIP_CLASS = cn(ROW_CLASS, 'flex items-center justify-between gap-[var(--spacing-system-m)]');
34
+ const FIELD_LABEL_CLASS = 'text-h4 text-ods-text-primary';
35
+
36
+ function HostIdentity({ hosts }: { hosts: MeetingHost[] }) {
37
+ if (hosts.length === 0) return null;
38
+ if (hosts.length > 3) {
39
+ return (
40
+ <div className="flex min-w-0 flex-col gap-[var(--spacing-system-xxs)]">
41
+ <AvatarStack people={hosts} max={4} size="lg" label="Hosts" />
42
+ <p className="text-ods-text-secondary text-h6">{hosts.length} hosts on this calendar</p>
43
+ </div>
44
+ );
45
+ }
46
+ return (
47
+ <div className="flex min-w-0 flex-col gap-[var(--spacing-system-s)]">
48
+ {hosts.map(host => (
49
+ <div key={host.name} className="flex items-center gap-[var(--spacing-system-xs)]">
50
+ <SquareAvatar
51
+ variant="round"
52
+ size="sm"
53
+ src={host.avatarUrl ?? undefined}
54
+ alt={host.name}
55
+ fallback={host.name}
56
+ />
57
+ <div className="flex min-w-0 flex-col">
58
+ <p className="truncate text-ods-text-primary text-h4">{host.name}</p>
59
+ {host.title && <p className="truncate text-ods-text-secondary text-h6">{host.title}</p>}
60
+ </div>
61
+ </div>
62
+ ))}
63
+ </div>
64
+ );
65
+ }
66
+
67
+ export function SchedulerModalHeader({
68
+ hosts,
69
+ durationsMs,
70
+ selectedDurationMs,
71
+ onSelectDuration,
72
+ timezone,
73
+ onTimezoneChange,
74
+ locked = false,
75
+ disabled = false,
76
+ showTimezone = true,
77
+ }: SchedulerModalHeaderProps) {
78
+ const chips = !locked && durationsMs.length > 1;
79
+ return (
80
+ <>
81
+ <div className={STRIP_CLASS}>
82
+ <HostIdentity hosts={hosts} />
83
+ {chips ? (
84
+ <div className="flex shrink-0 flex-wrap justify-end gap-[var(--spacing-system-xs)]">
85
+ {durationsMs.map(ms => (
86
+ <Button
87
+ key={ms}
88
+ variant={selectedDurationMs === ms ? undefined : 'outline'}
89
+ size="small-legacy"
90
+ disabled={disabled}
91
+ onClick={() => onSelectDuration(ms)}
92
+ >
93
+ {formatDurationCompact(ms / 1000)}
94
+ </Button>
95
+ ))}
96
+ </div>
97
+ ) : (
98
+ selectedDurationMs != null && (
99
+ <p className="shrink-0 text-ods-text-secondary text-h6">
100
+ {formatDurationCompact(selectedDurationMs / 1000)} call
101
+ </p>
102
+ )
103
+ )}
104
+ </div>
105
+ {showTimezone && (
106
+ <div className={cn(ROW_CLASS, 'flex flex-col gap-[var(--spacing-system-xxs)]')}>
107
+ <p className={FIELD_LABEL_CLASS}>Timezone</p>
108
+ {timezone ? (
109
+ <TimezoneSelect
110
+ value={timezone}
111
+ disabled={disabled}
112
+ onChange={tz => {
113
+ if (tz) onTimezoneChange?.(tz);
114
+ }}
115
+ />
116
+ ) : (
117
+ <Skeleton className="h-12 w-full" />
118
+ )}
119
+ </div>
120
+ )}
121
+ </>
122
+ );
123
+ }
124
+
125
+ /** Same footprint as the loaded header, for the cold load. */
126
+ export function SchedulerModalHeaderSkeleton() {
127
+ return (
128
+ <>
129
+ <div className={STRIP_CLASS}>
130
+ <div className="flex items-center gap-[var(--spacing-system-xs)]">
131
+ <Skeleton className="h-8 w-8 shrink-0 rounded-full" />
132
+ <div className="flex flex-col gap-[var(--spacing-system-xxs)]">
133
+ <Skeleton className="h-5 w-32" />
134
+ <Skeleton className="h-4 w-24" />
135
+ </div>
136
+ </div>
137
+ <Skeleton className="h-4 w-20 shrink-0" />
138
+ </div>
139
+ <div className={cn(ROW_CLASS, 'flex flex-col gap-[var(--spacing-system-xxs)]')}>
140
+ <p className={FIELD_LABEL_CLASS}>Timezone</p>
141
+ <Skeleton className="h-12 w-full" />
142
+ </div>
143
+ </>
144
+ );
145
+ }
@@ -44,9 +44,20 @@ import { Button, DatePickerCalendar, Skeleton } from '../ui';
44
44
  * itself, keeping SSR output deterministic).
45
45
  */
46
46
 
47
+ /**
48
+ * How the two columns sit. `card` is the scheduler card's own (the module
49
+ * docblock). `modal` is the onboarding "Book an Onboarding Call" window: a
50
+ * 600px box where the calendar keeps its width and the times run down ONE
51
+ * column beside it, scrolling inside the calendar's height - on a phone the
52
+ * times are four across, as that design draws them.
53
+ */
54
+ export type SlotPickerLayout = 'card' | 'modal';
55
+
47
56
  export interface SlotPickerProps {
48
57
  /** Bookable slot start times (epoch ms) for the selected duration. */
49
58
  slots: number[];
59
+ /** Column layout; `card` unless the host says otherwise. */
60
+ layout?: SlotPickerLayout;
50
61
  /** IANA zone every label renders in (parent resolves post-mount). */
51
62
  timezone: string;
52
63
  monthOffset: number;
@@ -207,6 +218,26 @@ const COLUMN_BOX = 'p-[var(--spacing-system-l)] content-lg:p-0';
207
218
  /** The rule between the two, wherever the layout puts it. */
208
219
  const COLUMN_DIVIDER = 'border-b border-ods-border content-md:border-b-0 content-md:border-r content-lg:border-0';
209
220
 
221
+ /**
222
+ * The modal layout's classes: the same two columns, side by side from `md`
223
+ * only (the modal never reaches the desktop card's `lg` arrangement), the
224
+ * calendar at the card's tablet width and the rule between them at every
225
+ * width from `md`. The times column states the calendar's height
226
+ * (`MODAL_TIMES_H`) and scrolls inside it, so the window is as tall as the
227
+ * month whatever the day offers. On a phone the chips are four across and
228
+ * the column keeps the card's own three-row window.
229
+ */
230
+ const MODAL_ROW_CLASS = 'flex min-w-0 flex-col content-md:flex-row content-md:items-start';
231
+ const MODAL_CALENDAR_W = 'content-md:w-[19rem] content-md:shrink-0';
232
+ const MODAL_COLUMN_BOX = 'p-[var(--spacing-system-m)]';
233
+ const MODAL_COLUMN_DIVIDER = 'border-b border-ods-border content-md:border-b-0 content-md:border-r';
234
+ /** The calendar column's height: its heading, the month header and six week rows. */
235
+ const MODAL_TIMES_H = 'content-md:h-[21.5rem]';
236
+ const MODAL_CHIP_GRID_CLASS = cn(
237
+ 'grid grid-cols-4 content-start gap-[var(--spacing-system-xs)] overflow-y-auto content-md:grid-cols-1',
238
+ 'max-h-[9.75rem] min-h-0 flex-1 content-md:max-h-none',
239
+ );
240
+
210
241
  /** Deterministic month caption for a given offset (fixed en-US locale so
211
242
  * the SSR and client first-paint markup agree byte-for-byte). */
212
243
  export function monthLabelFor(offset: number): string {
@@ -285,10 +316,23 @@ function DayCalendar({ monthOffset, onMonthOffsetChange, selectedDay, onSelectDa
285
316
  * the times column is a skeleton, and it is the same one the loaded picker
286
317
  * shows while a month loads, so there is no swap between the two.
287
318
  */
288
- export function SlotPickerSkeleton({ monthOffset = 0 }: { monthOffset?: number }) {
319
+ export function SlotPickerSkeleton({
320
+ monthOffset = 0,
321
+ layout = 'card',
322
+ }: {
323
+ monthOffset?: number;
324
+ layout?: SlotPickerLayout;
325
+ }) {
326
+ const modal = layout === 'modal';
289
327
  return (
290
- <div className={SLOT_ROW_CLASS}>
291
- <div className={cn(COLUMN_STACK, COLUMN_BOX, COLUMN_DIVIDER, CALENDAR_W, 'content-md:overflow-y-auto')}>
328
+ <div className={modal ? MODAL_ROW_CLASS : SLOT_ROW_CLASS}>
329
+ <div
330
+ className={
331
+ modal
332
+ ? cn(COLUMN_STACK, MODAL_COLUMN_BOX, MODAL_COLUMN_DIVIDER, MODAL_CALENDAR_W)
333
+ : cn(COLUMN_STACK, COLUMN_BOX, COLUMN_DIVIDER, CALENDAR_W, 'content-md:overflow-y-auto')
334
+ }
335
+ >
292
336
  {/* Needs no data — the heading renders REAL in every loading state. */}
293
337
  <p className={COLUMN_HEADING_CLASS}>{SLOT_PICKER_HEADING}</p>
294
338
  <DayCalendar
@@ -299,7 +343,13 @@ export function SlotPickerSkeleton({ monthOffset = 0 }: { monthOffset?: number }
299
343
  slotsByDay={null}
300
344
  />
301
345
  </div>
302
- <div className={cn(COLUMN_STACK, COLUMN_BOX, 'min-w-0 content-md:flex-1')}>
346
+ <div
347
+ className={
348
+ modal
349
+ ? cn(COLUMN_STACK, MODAL_COLUMN_BOX, MODAL_TIMES_H, 'min-w-0 content-md:flex-1')
350
+ : cn(COLUMN_STACK, COLUMN_BOX, 'min-w-0 content-md:flex-1')
351
+ }
352
+ >
303
353
  <TimeChipsSkeleton />
304
354
  </div>
305
355
  </div>
@@ -330,6 +380,7 @@ export function TimeChipsSkeleton() {
330
380
 
331
381
  export function SlotPicker({
332
382
  slots,
383
+ layout = 'card',
333
384
  timezone,
334
385
  monthOffset,
335
386
  onMonthOffsetChange,
@@ -365,9 +416,16 @@ export function SlotPicker({
365
416
  const monthPrefix = `${visibleMonth.getFullYear()}-${String(visibleMonth.getMonth() + 1).padStart(2, '0')}`;
366
417
  const monthHasSlots = [...slotsByDay.keys()].some(key => key.startsWith(monthPrefix));
367
418
 
419
+ const modal = layout === 'modal';
368
420
  return (
369
- <div className={SLOT_ROW_CLASS}>
370
- <div className={cn(COLUMN_STACK, COLUMN_BOX, COLUMN_DIVIDER, CALENDAR_W, 'content-md:overflow-y-auto')}>
421
+ <div className={modal ? MODAL_ROW_CLASS : SLOT_ROW_CLASS}>
422
+ <div
423
+ className={
424
+ modal
425
+ ? cn(COLUMN_STACK, MODAL_COLUMN_BOX, MODAL_COLUMN_DIVIDER, MODAL_CALENDAR_W)
426
+ : cn(COLUMN_STACK, COLUMN_BOX, COLUMN_DIVIDER, CALENDAR_W, 'content-md:overflow-y-auto')
427
+ }
428
+ >
371
429
  <p className={COLUMN_HEADING_CLASS}>{SLOT_PICKER_HEADING}</p>
372
430
  {/* Stays mounted through a month load — `slotsByDay: null` is what
373
431
  "nothing selectable yet" looks like, not an unmount. */}
@@ -379,7 +437,13 @@ export function SlotPicker({
379
437
  slotsByDay={isLoading ? null : slotsByDay}
380
438
  />
381
439
  </div>
382
- <div className={cn(COLUMN_STACK, COLUMN_BOX, 'min-w-0 content-md:flex-1')}>
440
+ <div
441
+ className={
442
+ modal
443
+ ? cn(COLUMN_STACK, MODAL_COLUMN_BOX, MODAL_TIMES_H, 'min-w-0 content-md:flex-1')
444
+ : cn(COLUMN_STACK, COLUMN_BOX, 'min-w-0 content-md:flex-1')
445
+ }
446
+ >
383
447
  {isLoading ? (
384
448
  <TimeChipsSkeleton />
385
449
  ) : selectedDay ? (
@@ -389,7 +453,7 @@ export function SlotPicker({
389
453
  local instant could label an adjacent day. */}
390
454
  {formatDateWithTimezone(dateFromDayKey(selectedDay), VIEWER_TIMEZONE, 'weekday', { viewerLocale: true })}
391
455
  </p>
392
- <div className={CHIP_GRID_CLASS}>
456
+ <div className={modal ? MODAL_CHIP_GRID_CLASS : CHIP_GRID_CLASS}>
393
457
  {daySlots.map(ms => {
394
458
  const isSelected = selectedSlot === ms;
395
459
  return (
@@ -436,6 +436,8 @@ export function ProductReleaseCard({
436
436
  // affordance, so explicit ring styles match the `<span role>` form.
437
437
  'my-1.5 flex w-full items-start gap-3 p-2',
438
438
  'rounded-lg border border-ods-border bg-ods-card no-underline',
439
+ // The play mark on a video cover shows on the card's hover.
440
+ 'group',
439
441
  isInteractive
440
442
  ? 'cursor-pointer transition-colors hover:border-ods-text-secondary/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ods-accent focus-visible:ring-offset-1 focus-visible:ring-offset-ods-card'
441
443
  : 'cursor-default',