@flamingo-stack/openframe-frontend-core 0.0.676 → 0.0.677

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.
@@ -1 +1 @@
1
- {"version":3,"sources":["/home/runner/work/openframe-oss-lib/openframe-oss-lib/openframe-frontend-core/dist/components/meeting-scheduler/index.cjs","../../../src/components/meeting-scheduler/index.tsx","../../../src/hooks/use-meeting-booking.ts","../../../src/schemas/meeting-booking-schema.ts","../../../src/components/meeting-scheduler/booking-form.tsx","../../../src/components/meeting-scheduler/confirmation.tsx","../../../src/components/meeting-scheduler/context-panel.tsx","../../../src/components/meeting-scheduler/slot-picker.tsx","../../../src/components/meeting-scheduler/directory.tsx"],"names":["useState","Fragment","jsx","jsxs","useMemo","useCallback"],"mappings":"AAAA,6rBAAY;AACZ,YAAY;AACZ;AACE;AACF,4DAAiC;AACjC;AACE;AACF,4DAAiC;AACjC;AACE;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACF,4DAAiC;AACjC,oCAAiC;AACjC,oCAAiC;AACjC;AACE;AACA;AACA;AACA;AACA;AACF,4DAAiC;AACjC,oCAAiC;AACjC,oCAAiC;AACjC;AACE;AACA;AACF,4DAAiC;AACjC;AACE;AACA;AACA;AACF,4DAAiC;AACjC;AACE;AACF,4DAAiC;AACjC,oCAAiC;AACjC,oCAAiC;AACjC,oCAAiC;AACjC,oCAAiC;AACjC,oCAAiC;AACjC;AACE;AACA;AACA;AACA;AACA;AACA;AACA;AACF,4DAAiC;AACjC,oCAAiC;AACjC,oCAAiC;AACjC,oCAAiC;AACjC,oCAAiC;AACjC;AACE;AACA;AACF,4DAAiC;AACjC,oCAAiC;AACjC;AACE;AACF,4DAAiC;AACjC;AACE;AACF,4DAAiC;AACjC;AACE;AACF,4DAAiC;AACjC,oCAAiC;AACjC,oCAAiC;AACjC;AACA;AC5CA,8BAAkE;AD8ClE;AACA;AEtFA,mDAAsD;AACtD;AFwFA;AACA;AG5FA,0BAAkB;AAqJX,IAAM,4BAAA,EAA8B;AAAA,EACzC,YAAA;AAAA,EACA,YAAA;AAAA,EACA,eAAA;AAAA,EACA,WAAA;AAAA,EACA,yBAAA;AAAA,EACA;AACF,CAAA;AAqDA,IAAM,gBAAA,EAAkD,CAAC,KAAA,EAAO,IAAA,EAAA,GAC9D,IAAA,CAAK,MAAA,CAAO,CAAA,CAAA,EAAA,GAAK,CAAC,EAAA,GAAA,kBAAM,KAAA,CAAM,OAAA,UAAW,CAAC,GAAA,CAAA,CAAG,QAAA,CAAS,CAAC,CAAA,EAAG;AAAA,EACxD,OAAA,EAAS,CAAA,iCAAA,EAAoC,KAAA,CAAM,KAAK,CAAA;AACzD;AAG+B;AAGG;AAIlB;AAQK;AACC;AACA;AAC+B;AAG7B;AACU;AACF,EAAA;AACE,EAAA;AACkB,EAAA;AACvD;AAEgC;AACxB,EAAA;AACE,IAAA;AACM,IAAA;AAC0C,IAAA;AACZ,IAAA;AAC5C,EAAA;AACU,EAAA;AACF,IAAA;AACM,IAAA;AAC0C,IAAA;AACF,IAAA;AACtD,EAAA;AACQ,EAAA;AACA,IAAA;AACM,IAAA;AAAA;AAAA;AAKe,IAAA;AAE7B,EAAA;AACO,EAAA;AACC,IAAA;AACM,IAAA;AAGe,IAAA;AAE6B,MAAA;AACE,MAAA;AACtD,IAAA;AACsC,IAAA;AAC5C,EAAA;AACM,EAAA;AACE,IAAA;AACM,IAAA;AACuC,IAAA;AACrD,EAAA;AACuD,EAAA;AACD,EAAA;AACzC,EAAA;AACL,IAAA;AACM,IAAA;AAEO,IAAA;AACmC,MAAA;AACnD,IAAA;AACL,EAAA;AACgD,EAAA;AAClD;AAIsD;AAwBqB;AACnE,EAAA;AACI,EAAA;AACF,EAAA;AACA,EAAA;AACD,EAAA;AACU,EAAA;AACJ,EAAA;AACP,EAAA;AACA,EAAA;AACA,EAAA;AACR;AAG0E;AAChE,EAAA;AACA,EAAA;AACK,EAAA;AACP,EAAA;AACA,EAAA;AACI,EAAA;AACI,EAAA;AAChB;AAEwE;AAQc;AACnC,EAAA;AACL,EAAA;AAC4C,EAAA;AACnC,IAAA;AACT,IAAA;AAC5C,EAAA;AAEmD,EAAA;AACH,EAAA;AAIO,EAAA;AAIH,EAAA;AAItD;AAegF;AAC/C,EAAA;AACjC;AAwBoC;AACW,EAAA;AACY,EAAA;AAIX,EAAA;AACzC,IAAA;AACG,IAAA;AACkC,IAAA;AAC1C,EAAA;AAC2C,EAAA;AACvB,IAAA;AACA,IAAA;AACpB,EAAA;AACO,EAAA;AACT;AAGsG;AAC9C,EAAA;AACxD;AAYsE;AACd,EAAA;AACxD;AAsBuC;AACrC,EAAA;AACQ,IAAA;AACC,IAAA;AACD,IAAA;AACI,IAAA;AACI,IAAA;AACG,IAAA;AACnB,EAAA;AACA,EAAA;AACQ,IAAA;AACC,IAAA;AACD,IAAA;AACI,IAAA;AACI,IAAA;AACG,IAAA;AACnB,EAAA;AACA,EAAA;AACQ,IAAA;AACC,IAAA;AACD,IAAA;AACI,IAAA;AACC,IAAA;AACG,IAAA;AACD,IAAA;AACI,IAAA;AACnB,EAAA;AACF;AAO+E;AACjD,EAAA;AAC9B;AAG0D;AAC2B,EAAA;AACvD,EAAA;AAG4B,EAAA;AAEjC,EAAA;AAEzB;AAMmB;AAQsC;AACtB,EAAA;AAC7B,EAAA;AAC+C,IAAA;AAC1C,IAAA;AACD,EAAA;AACC,IAAA;AACT,EAAA;AACF;AAG4D;AACtD,EAAA;AAC+C,IAAA;AAC3C,EAAA;AACC,IAAA;AACT,EAAA;AACF;AA+BE;AAE+C,EAAA;AAIH,EAAA;AACjB,EAAA;AACkC,IAAA;AAKvD,IAAA;AACyB,IAAA;AACoB,MAAA;AAC1C,IAAA;AAC6C,MAAA;AACZ,MAAA;AACc,MAAA;AACtD,IAAA;AACsB,IAAA;AACxB,EAAA;AAE0C,EAAA;AAQW,EAAA;AACf,EAAA;AAI1B,EAAA;AACqB,IAAA;AACT,IAAA;AACD,IAAA;AAAA;AAAA;AAGuB,IAAA;AACF,IAAA;AACN,IAAA;AACjB,IAAA;AACiC,IAAA;AAAsC;AAAA;AAAA;AAAA;AAKrC,IAAA;AAE7B,IAAA;AAMM,EAAA;AACuB,IAAA;AACnB,IAAA;AACN,MAAA;AACxB,IAAA;AACS,IAAA;AACM,MAAA;AACU,QAAA;AACS,QAAA;AACrB,QAAA;AACV,MAAA;AACH,IAAA;AACD,EAAA;AAEP;AAG+C;AACW;AA2BgB;AAMpB,EAAA;AAChB,IAAA;AACD,IAAA;AACN,IAAA;AAC5B,EAAA;AACH;AH9W2D;AACA;AEpR7B;AAIgD;AACrB,EAAA;AACzD;AAGyC;AAMtC;AACuD,EAAA;AACrB,EAAA;AAEqB,EAAA;AACJ,EAAA;AAEG,EAAA;AACZ,IAAA;AACtC,EAAA;AAKoC,EAAA;AACnB,EAAA;AACwB,IAAA;AACjB,IAAA;AACN,IAAA;AACT,EAAA;AAIuB,EAAA;AAIF,EAAA;AACY,IAAA;AACd,IAAA;AACiB,MAAA;AACF,MAAA;AACK,MAAA;AAC1B,MAAA;AACzB,IAAA;AACW,IAAA;AACE,IAAA;AAAA;AAAA;AAAA;AAIoB,IAAA;AAClC,EAAA;AAEmD,EAAA;AAMjB,IAAA;AAEiB,EAAA;AAE4B,EAAA;AACjD,IAAA;AACvB,MAAA;AAC0C,QAAA;AAClC,UAAA;AACsC,UAAA;AAClB,UAAA;AAC7B,QAAA;AAC8C,QAAA;AACqC,QAAA;AACpC,QAAA;AACyB,QAAA;AAG1B,QAAA;AACzC,MAAA;AACoB,QAAA;AAC5B,MAAA;AACF,IAAA;AAAA;AAEO,IAAA;AACR,EAAA;AAS+C,EAAA;AACxB,EAAA;AACX,EAAA;AACyD,IAAA;AACjB,MAAA;AACpB,MAAA;AACL,MAAA;AACpB,MAAA;AAC8B,QAAA;AAChC,MAAA;AAC2C,QAAA;AAC7C,MAAA;AACF,IAAA;AACY,IAAA;AACd,EAAA;AAEO,EAAA;AACmC,IAAA;AAAA;AAEC,IAAA;AAAA;AAEC,IAAA;AACG,IAAA;AAC7C,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AAC2B,IAAA;AAC7B,EAAA;AACF;AF6O2D;AACA;AIra/B;AACWA;AAEH;AAuLhC;AA1Ge;AAKF;AAGQ;AAIJ;AAIF;AACd,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACL;AAK6B;AAGO;AAGX;AAEC;AAEA;AAGmD;AAID;AACjB,EAAA;AACrB,EAAA;AACc,EAAA;AACpD;AAOS;AAmBc;AAG0E;AAGpC;AAOb;AACf,EAAA;AACoB,EAAA;AACjC,EAAA;AACc,EAAA;AACN,EAAA;AAC8B,EAAA;AAC1D;AAWyF;AAErF,EAAA;AAAC,IAAA;AAAA,IAAA;AACC,MAAA;AACyB,MAAA;AACT,MAAA;AACW,MAAA;AACP,MAAA;AACa,MAAA;AACC,MAAA;AAAA,IAAA;AACpC,EAAA;AAEoD,EAAA;AACnD,IAAA;AAAA,IAAA;AACC,MAAA;AACgB,MAAA;AACW,MAAA;AACM,MAAA;AACC,MAAA;AAAA,IAAA;AACpC,EAAA;AAEkD,EAAA;AACjD,IAAA;AAAA,IAAA;AACC,MAAA;AACK,MAAA;AACK,MAAA;AACL,MAAA;AACW,MAAA;AACW,MAAA;AACuB,MAAA;AAAiB,IAAA;AACrE,EAAA;AAGA,EAAA;AAAC,IAAA;AAAA,IAAA;AACC,MAAA;AACK,MAAA;AACK,MAAA;AACG,MAAA;AACG,MAAA;AACW,MAAA;AACM,MAAA;AAC0B,MAAA;AAAA,IAAA;AAC7D,EAAA;AAEgD,EAAA;AAAA;AAEhD,oBAAA;AAAC,MAAA;AAAA,MAAA;AACC,QAAA;AACK,QAAA;AACW,QAAA;AACW,QAAA;AACO,QAAA;AAAA,MAAA;AACpC,IAAA;AAAA,EAAA;AAGA,EAAA;AAAC,IAAA;AAAA,IAAA;AACC,MAAA;AACM,MAAA;AAEH,MAAA;AACwB,wBAAA;AAInB,wBAAA;AAMN,MAAA;AAAA,IAAA;AAEJ,EAAA;AAGA,EAAA;AAAC,IAAA;AAAA,IAAA;AACC,MAAA;AACM,MAAA;AAEJ,MAAA;AAAC,QAAA;AAAA,QAAA;AACqB,UAAA;AACD,UAAA;AACc,UAAA;AACN,UAAA;AAGzB,UAAA;AAC2B,4BAAA;AACA,4BAAA;AAE5B,UAAA;AAAA,QAAA;AACH,MAAA;AAAA,IAAA;AAEJ,EAAA;AAEsD,EAAA;AAAA;AAAA;AAGtD,oBAAA;AAAC,MAAA;AAAA,MAAA;AACC,QAAA;AACM,QAAA;AACsB,QAAA;AACgB,UAAA;AACG,UAAA;AACD,YAAA;AACG,YAAA;AAC/C,UAAA;AAEoB,UAAA;AAGZ,4BAAA;AAAC,cAAA;AAAA,cAAA;AACiB,gBAAA;AACc,gBAAA;AACU,gBAAA;AACb,gBAAA;AAAA,cAAA;AAC7B,YAAA;AACyB,4BAAA;AAG/B,UAAA;AAEJ,QAAA;AAAA,MAAA;AACF,IAAA;AAAA,EAAA;AAEmD,EAAA;AAClD,IAAA;AAAA,IAAA;AACC,MAAA;AACM,MAAA;AACgB,MAAA;AAAA;AAAA;AAAA;AAIL,wBAAA;AACZ,UAAA;AAAA,UAAA;AACC,YAAA;AAC0B,YAAA;AACmB,YAAA;AACZ,YAAA;AACN,YAAA;AAAA,UAAA;AAE/B,QAAA;AAAA,MAAA;AAAA,IAAA;AAEJ,EAAA;AAEJ;AAGiE;AA6DrC;AAC1B,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACY,EAAA;AACZ,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACS,EAAA;AACU;AACkB,EAAA;AAIC,EAAA;AAIvB,EAAA;AACoC,IAAA;AACnB,IAAA;AAChC,EAAA;AAEwB,EAAA;AAE6B,IAAA;AACxB,MAAA;AACZ,MAAA;AACX,IAAA;AACS,IAAA;AACf,EAAA;AAEM,EAAA;AACJ,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACoB,IAAA;AACS,EAAA;AACD,IAAA;AACb,IAAA;AACb,MAAA;AAAA;AAE+C,MAAA;AAC/C,MAAA;AACqD,MAAA;AAC1C,MAAA;AACD,MAAA;AACH,MAAA;AACM,MAAA;AACU,MAAA;AAAA;AAAA;AAGD,MAAA;AACxB,IAAA;AACD,EAAA;AAEoC,EAAA;AAIM,EAAA;AACyB,EAAA;AACxB,EAAA;AAO5B,EAAA;AAIyC,IAAA;AACvD,IAAA;AACE,MAAA;AAC6B,MAAA;AACxB,QAAA;AAC4B,QAAA;AAC/B,MAAA;AACmB,MAAA;AACvB,IAAA;AACsD,EAAA;AAGnC,EAAA;AACkC,IAAA;AACrC,IAAA;AAClB,EAAA;AACmB,EAAA;AACY,IAAA;AAC7B,IAAA;AACE,MAAA;AACQ,MAAA;AACR,MAAA;AAEgC,MAAA;AAGlC,IAAA;AACF,EAAA;AAE+C,EAAA;AACzB,IAAA;AACL,IAAA;AAKyB,MAAA;AACtC,MAAA;AACF,IAAA;AACe,IAAA;AACV,MAAA;AACH,MAAA;AACoB,MAAA;AACpB,MAAA;AACA,MAAA;AACA,MAAA;AACc,MAAA;AACa,MAAA;AAC5B,IAAA;AACF,EAAA;AAKqD,EAAA;AACP,IAAA;AACrB,IAAA;AAC1B,EAAA;AAEyD,EAAA;AACmC,IAAA;AAC9E,IAAA;AACd,EAAA;AAQE,EAAA;AAEI,IAAA;AACU,IAAA;AACU,IAAA;AACP,IAAA;AACb,IAAA;AACA,IAAA;AAEJ,EAAA;AAGsD,EAAA;AACjB,IAAA;AAC7B,MAAA;AAC0C,MAAA;AACjD,IAAA;AACH,EAAA;AAGc,EAAA;AACY,IAAA;AACgB,IAAA;AACV,IAAA;AAC7B,EAAA;AAEgD,EAAA;AAEuB,EAAA;AAE5B,EAAA;AACV,IAAA;AACd,IAAA;AACoB,IAAA;AAIU,IAAA;AACpD,EAAA;AAMmD,EAAA;AACA,EAAA;AAGjD,EAAA;AAAC,IAAA;AAAA,IAAA;AACM,MAAA;AACI,MAAA;AACC,MAAA;AAIkC,MAAA;AAE5B,MAAA;AAAA,IAAA;AAClB,EAAA;AAKwB,EAAA;AACH,oBAAA;AAkBd,IAAA;AAC2C,MAAA;AAQE,MAAA;AAM7C,IAAA;AAAc,MAAA;AACA,sBAAA;AACE,QAAA;AACA,QAAA;AACjB,MAAA;AAEG,MAAA;AAEL,IAAA;AAIA,IAAA;AAAC,MAAA;AAAA,MAAA;AACI,QAAA;AACM,QAAA;AACa,QAAA;AACN,UAAA;AACa,UAAA;AAC7B,QAAA;AACU,QAAA;AACF,QAAA;AACe,QAAA;AACR,QAAA;AACM,QAAA;AAAA,MAAA;AACvB,IAAA;AAGD,IAAA;AAAA;AAAA;AAAA;AAK6B,oBAAA;AAOX,sBAAA;AAEX,MAAA;AAEF,sBAAA;AAAC,QAAA;AAAA,QAAA;AACC,UAAA;AACK,UAAA;AAEHC,UAAAA;AAEqC,YAAA;AACK,YAAA;AACtC,YAAA;AAAA;AAAA;AAAA;AAIE,8BAAA;AAAC,gBAAA;AAAA,gBAAA;AAE0C,kBAAA;AACV,kBAAA;AAEzB,kBAAA;AACQ,oBAAA;AACsB,sBAAA;AAChC,oBAAA;AACF,kBAAA;AAEY,kBAAA;AACH,kBAAA;AAAA,gBAAA;AAXF,gBAAA;AAYX,cAAA;AAAA,YAAA;AAGN,UAAA;AAAA,QAAA;AAEJ,MAAA;AAEE,MAAA;AAGN,IAAA;AAUgB,IAAA;AACD,sBAAA;AACZ,MAAA;AAGa,IAAA;AAEpB,EAAA;AAEJ;AAaoC;AAClC,EAAA;AACA,EAAA;AACA,EAAA;AAQC;AAEoB,EAAA;AAGkC,IAAA;AAQ1B,IAAA;AACO,IAAA;AAEd,IAAA;AACM,sBAAA;AACI,sBAAA;AAGV,IAAA;AAIpB,EAAA;AAEJ;AAgBsG;AACvE,EAAA;AACI,EAAA;AACJ,EAAA;AACO,IAAA;AACV,IAAA;AAC2B,MAAA;AAC/B,MAAA;AACJ,MAAA;AAChB,IAAA;AACuB,IAAA;AACgC,IAAA;AACzD,EAAA;AACmD,EAAA;AACK,EAAA;AACjB,EAAA;AACzC;AAGuE;AACf,EAAA;AACC,EAAA;AACzD;AJmC2D;AACA;AK10BrD;AATsE;AAGzB,EAAA;AACjC,IAAA;AACf,EAAA;AAGgB,EAAA;AACU,oBAAA;AAGT,oBAAA;AACD,oBAAA;AACA,oBAAA;AACV,MAAA;AAAK,MAAA;AAAwD,MAAA;AAChE,IAAA;AACa,oBAAA;AACf,EAAA;AAEJ;ALi1B2D;AACA;AMr3BnC;AAmIP;AAxEG;AAWlB;AAeiB;AACA;AAGC;AAIM;AAKA;AAEa;AACjC,EAAA;AAC6C,IAAA;AACnC,MAAA;AACI,MAAA;AACW,IAAA;AACe,IAAA;AACS,IAAA;AAC7C,EAAA;AACqB,IAAA;AAC7B,EAAA;AACF;AAOqC;AACnC,EAAA;AACA,EAAA;AACY,EAAA;AAKX;AAEoB,EAAA;AAGa,IAAA;AAI5B,oBAAA;AACE,sBAAA;AACiB,wBAAA;AACO,0BAAA;AACL,0BAAA;AACO,4BAAA;AACA,4BAAA;AACtB,UAAA;AACF,QAAA;AACoB,wBAAA;AAExB,MAAA;AACgB,sBAAA;AACA,wBAAA;AACM,wBAAA;AACtB,MAAA;AACF,IAAA;AACF,EAAA;AAEJ;AAEsC;AACpC,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACY,EAAA;AACH,EAAA;AACE,EAAA;AACI,EAAA;AACf,EAAA;AAC6B;AAGK,EAAA;AACT,IAAA;AACnB,IAAA;AACA,IAAA;AAEW,MAAA;AACyB,MAAA;AAChC,IAAA;AACW,MAAA;AACnB,IAAA;AACqD,IAAA;AACT,IAAA;AACjC,EAAA;AAGQ,EAAA;AAIa,IAAA;AAG5B,oBAAA;AAAgB,sBAAA;AACE,wBAAA;AAKZ,UAAA;AAGMC,4BAAAA;AAAC,cAAA;AAAA,cAAA;AACS,gBAAA;AACH,gBAAA;AACkB,gBAAA;AACb,gBAAA;AACK,gBAAA;AAAA,cAAA;AACjB,YAAA;AACe,4BAAA;AACA,8BAAA;AACK,cAAA;AACpB,YAAA;AAGN,UAAA;AAGC,UAAA;AACsB,4BAAA;AACR,4BAAA;AAAyC,cAAA;AAAO,cAAA;AAAuB,YAAA;AACtF,UAAA;AAG+B,UAAA;AAAA;AAAA;AAI3BC,YAAAA;AAC8C,cAAA;AAAM,cAAA;AACpD,YAAA;AAEF,UAAA;AACN,QAAA;AAEuB,QAAA;AACJ,QAAA;AAKjB,QAAA;AACgB,0BAAA;AACC,0BAAA;AAEV,YAAA;AAAA,YAAA;AAEsC,cAAA;AAChC,cAAA;AACL,cAAA;AACkC,cAAA;AAEF,cAAA;AAAA,YAAA;AAN3B,YAAA;AASX,UAAA;AACF,QAAA;AAEJ,MAAA;AAEC,MAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAOoB,sBAAA;AACH,wBAAA;AAEZD,QAAAA;AAAC,UAAA;AAAA,UAAA;AACQ,YAAA;AACP,YAAA;AACgB,YAAA;AACe,cAAA;AAC/B,YAAA;AACS,YAAA;AACG,YAAA;AACE,YAAA;AACA,YAAA;AAAA,UAAA;AAGI,QAAA;AAExB,MAAA;AAEJ,IAAA;AACF,EAAA;AAEJ;AN4uB2D;AACA;AOjhC9B;AACPE;AA+RhBD;AA5N6D;AACb,EAAA;AACvC,IAAA;AACb,EAAA;AACF;AAQqD;AACV;AACc,EAAA;AACzD;AAC2C;AACE,EAAA;AACZ,EAAA;AACjC;AAE+D;AACf,EAAA;AAChD;AAmBmB;AAIU;AACR;AAIc;AAqBX;AACtB,EAAA;AACA,EAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAQA,EAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAUA,EAAA;AACF;AAImB;AAsBI;AACrB,EAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAOA,EAAA;AACF;AAMmB;AAGI;AAI+B;AAC/B,EAAA;AACkC,EAAA;AACD,EAAA;AACxD;AAGgC;AACb;AAmBsC;AAClC,EAAA;AAC8B,EAAA;AAIF,EAAA;AACI,EAAA;AAIS,EAAA;AAG7C,EAAA;AACZ,IAAA;AAAA,IAAA;AACM,MAAA;AACD,MAAA;AAIG,MAAA;AACiB,MAAA;AACoB,QAAA;AACD,QAAA;AAC3C,MAAA;AACU,MAAA;AACF,MAAA;AAC0C,MAAA;AAC/B,MAAA;AACL,QAAA;AACF,QAAA;AACoB,QAAA;AACW,QAAA;AAC3C,MAAA;AACoC,MAAA;AAAoB,IAAA;AAE5D,EAAA;AAEJ;AAUkF;AAE9D,EAAA;AACK,oBAAA;AAEH,sBAAA;AACdD,sBAAAA;AAAC,QAAA;AAAA,QAAA;AACC,UAAA;AACqB,UAAA;AACR,UAAA;AACA,UAAA;AACD,UAAA;AAAA,QAAA;AACd,MAAA;AACF,IAAA;AACmB,oBAAA;AAGrB,EAAA;AAEJ;AAGoC;AAK9B,EAAA;AAAgB,oBAAA;AAGA,oBAAA;AAIkB;AAEE,sBAAA;AAEpC,IAAA;AACF,EAAA;AAEJ;AAE2B;AACzB,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACY,EAAA;AACG,EAAA;AACG;AACe,EAAA;AACO,IAAA;AACd,IAAA;AACe,MAAA;AACP,MAAA;AAClB,MAAA;AACK,MAAA;AACnB,IAAA;AACqD,IAAA;AAC9C,IAAA;AACW,EAAA;AAEe,EAAA;AACZ,IAAA;AAC+B,IAAA;AACtC,EAAA;AAE0C,EAAA;AAKL,EAAA;AACH,EAAA;AAGhC,EAAA;AACK,oBAAA;AACH,sBAAA;AAGdA,sBAAAA;AAAC,QAAA;AAAA,QAAA;AACC,UAAA;AACA,UAAA;AACA,UAAA;AACA,UAAA;AAC+B,UAAA;AAAA,QAAA;AACjC,MAAA;AACF,IAAA;AACmB,oBAAA;AAKC,sBAAA;AAKE,sBAAA;AAEwB,QAAA;AAElCA,QAAAA;AAAC,UAAA;AAAA,UAAA;AAEmC,YAAA;AAIxB,YAAA;AACe,YAAA;AACV,YAAA;AACK,cAAA;AACH,cAAA;AACjB,YAAA;AACW,YAAA;AAEkB,YAAA;AAAA,UAAA;AAbxB,UAAA;AAcP,QAAA;AAGN,MAAA;AAGC,IAAA;AAA8E;AAAA;AAAA;AAKlE,sBAAA;AACA,wBAAA;AAAkC,UAAA;AAAgD,UAAA;AAAE,UAAA;AAAC,QAAA;AAEhGC,QAAAA;AAAC,UAAA;AAAA,UAAA;AACS,YAAA;AAC2B,YAAA;AACvB,YAAA;AACb,YAAA;AAAA,cAAA;AACqC,cAAA;AAAA,YAAA;AAAA,UAAA;AACtC,QAAA;AAEJ,MAAA;AAEJ,IAAA;AACF,EAAA;AAEJ;APkzB2D;AACA;AQltC9B;AACA;AAsErB;AAxCyD;AAMhC,EAAA;AACV,EAAA;AACiB,IAAA;AACe,IAAA;AACpC,EAAA;AACnB;AAEsB;AACpB,EAAA;AACA,EAAA;AACA,EAAA;AAKC;AAC4C,EAAA;AAE5B,EAAA;AACV,IAAA;AACmC,IAAA;AACR,IAAA;AAGrB,EAAA;AAGXD,EAAAA;AAAC,IAAA;AAAA,IAAA;AACsC,MAAA;AAC3B,MAAA;AACA,MAAA;AAEK,MAAA;AAEE,wBAAA;AACA,0BAAA;AACG,UAAA;AAClB,QAAA;AAIG,QAAA;AAMuB,wBAAA;AAEX,wBAAA;AACA,0BAAA;AACA,0BAAA;AAGf,QAAA;AAGgB,wBAAA;AAGlB,MAAA;AAAA,IAAA;AACF,EAAA;AAEJ;AAGuD;AAEpC,EAAA;AACE,oBAAA;AACO,sBAAA;AACA,sBAAA;AACtB,IAAA;AACoB,oBAAA;AACA,oBAAA;AACL,oBAAA;AACO,sBAAA;AACA,sBAAA;AACtB,IAAA;AACoB,oBAAA;AACtB,EAAA;AAEJ;AAG0B;AAEgB;AAC3B,EAAA;AACC,EAAA;AACD,EAAA;AACF,EAAA;AACO,EAAA;AAClB,EAAA;AACA,EAAA;AACiC;AAC0C,EAAA;AAChC,EAAA;AACW,EAAA;AACpB,EAAA;AAElB,EAAA;AACmB,IAAA;AACM,IAAA;AAC1B,IAAA;AACY,IAAA;AACN,MAAA;AACJ,MAAA;AACT,MAAA;AACqC,QAAA;AACK,QAAA;AACO,QAAA;AACnB,QAAA;AACL,QAAA;AACf,MAAA;AACuC,QAAA;AACL,QAAA;AAC9C,MAAA;AAC8B,QAAA;AAChC,MAAA;AACF,IAAA;AACU,IAAA;AACG,IAAA;AACF,MAAA;AACQ,MAAA;AACnB,IAAA;AACsC,EAAA;AAEb,EAAA;AACW,IAAA;AACc,IAAA;AAC3C,EAAA;AAEyC,EAAA;AACC,EAAA;AAGI,EAAA;AAE/B,EAAA;AAED,IAAA;AACF,sBAAA;AAKI,sBAAA;AAGrB,IAAA;AAEJ,EAAA;AAEW,EAAA;AAEY,IAAA;AAChB,MAAA;AAAA,MAAA;AACM,QAAA;AACC,QAAA;AACM,QAAA;AACyC,QAAA;AAAiB,MAAA;AAE1E,IAAA;AAEJ,EAAA;AAEuB,EAAA;AAEA,IAAA;AAChB,MAAA;AAAA,MAAA;AACM,QAAA;AACC,QAAA;AACM,QAAA;AACyC,QAAA;AAAiB,MAAA;AAE1E,IAAA;AAEJ,EAAA;AAGqB,EAAA;AACF,oBAAA;AAQI,oBAAA;AAEd,MAAA;AAAA,MAAA;AACC,QAAA;AACa,QAAA;AACb,QAAA;AACc,QAAA;AACN,QAAA;AAAA,MAAA;AAGd,IAAA;AACF,EAAA;AAEJ;ARsnC2D;AACA;AC9gCvD;AAtSuF;AAEvF,EAAA;AAEA,EAAA;AACuB,EAAA;AACJ,EAAA;AACV,EAAA;AACb;AA4FE;AAUyB;AAkBF;AAE2B;AA8CjB;AAiBc;AAKI;AAYjD;AAC2C,EAAA;AAC5B,EAAA;AACF,IAAA;AACL,IAAA;AACK,IAAA;AACD,IAAA;AACd,EAAA;AACF;AA6BmB;AACjB,EAAA;AACA,EAAA;AAAA;AAAA;AAAA;AAIF;AAMyB;AAOG;AAC1B,EAAA;AACA,EAAA;AACF;AASsC;AAC7B,EAAA;AACP,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AASC;AAEoB,EAAA;AACQ,IAAA;AACxB,IAAA;AACA,IAAA;AACH,EAAA;AAEJ;AAWwE;AACf,EAAA;AAC1B,EAAA;AAChB,EAAA;AAC4C,IAAA;AAC5B,IAAA;AAC7B,EAAA;AACO,EAAA;AACT;AAEwC;AACtC,EAAA;AACa,EAAA;AACb,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACO,EAAA;AACoB,EAAA;AAC3B,EAAA;AACS,EAAA;AACsB;AACzB,EAAA;AACJ,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AAC6C,EAAA;AAEjB,EAAA;AAYC,EAAA;AACmC,EAAA;AACtB,EAAA;AACpB,IAAA;AAClB,IAAA;AAC6C,MAAA;AACzC,IAAA;AACC,MAAA;AACT,IAAA;AACW,EAAA;AACyC,EAAA;AAEZ,EAAA;AACe,EAAA;AAK3B,EAAA;AASe,EAAA;AAC7B,EAAA;AACa,IAAA;AAC5B,EAAA;AACe,EAAA;AACkB,oBAAA;AACxB,EAAA;AAIoD,EAAA;AAE9B,EAAA;AAGXG,EAAAA;AACgC,IAAA;AACxB,IAAA;AAC7B,EAAA;AAS0C,EAAA;AAC4B,EAAA;AACN,EAAA;AACI,EAAA;AACa,EAAA;AACI,EAAA;AAE5B,EAAA;AAC9B,EAAA;AAOM,EAAA;AACqB,EAAA;AACxB,EAAA;AACL,IAAA;AACG,IAAA;AACR,IAAA;AACC,IAAA;AACC,IAAA;AACA,IAAA;AACA,IAAA;AAIP,IAAA;AACf,EAAA;AACgB,EAAA;AACa,IAAA;AACf,EAAA;AAIgC,EAAA;AACM,EAAA;AAQG,EAAA;AAE3B,EAAA;AACuB,IAAA;AACM,IAAA;AAC5B,EAAA;AAaK,EAAA;AACS,IAAA;AACpB,IAAA;AAC2B,IAAA;AACO,IAAA;AACH,IAAA;AAC1B,IAAA;AAG6B,MAAA;AACvD,IAAA;AAC0C,IAAA;AACM,IAAA;AACR,IAAA;AACQ,EAAA;AAIlC,EAAA;AAIX,IAAA;AAOD,IAAA;AAC4B,MAAA;AACE,MAAA;AAChC,IAAA;AAC8C,EAAA;AAIX,EAAA;AACrB,IAAA;AACM,IAAA;AACjB,EAAA;AAImC,EAAA;AAGlB,IAAA;AAIA,IAAA;AACH,IAAA;AACG,IAAA;AACL,EAAA;AAQIA,EAAAA;AACkC,IAAA;AAC/B,MAAA;AACuB,MAAA;AAC7B,MAAA;AAGa,MAAA;AACF,MAAA;AAMF,MAAA;AACzB,IAAA;AACgC,IAAA;AAClC,EAAA;AAEqBA,EAAAA;AACyB,IAAA;AAKvB,MAAA;AACC,MAAA;AACa,MAAA;AAGI,MAAA;AACT,QAAA;AAC1B,QAAA;AACF,MAAA;AACsC,MAAA;AAED,QAAA;AACA,QAAA;AAChB,QAAA;AACW,wBAAA;AAC9B,QAAA;AACF,MAAA;AAC4B,MAAA;AACR,MAAA;AACO,MAAA;AAGL,QAAA;AACN,QAAA;AAKkB,QAAA;AACP,QAAA;AACpB,MAAA;AAKiC,QAAA;AACQ,QAAA;AAG3B,UAAA;AACnB,QAAA;AAIM,QAAA;AACK,UAAA;AACF,UAAA;AACkC,UAAA;AAC1C,QAAA;AACH,MAAA;AACF,IAAA;AAC6C,IAAA;AAC/C,EAAA;AAGG,EAAA;AAUgD,EAAA;AAgBpC,EAAA;AACG,EAAA;AAChB,IAAA;AACwB,IAAA;AACR,IAAA;AACJ,IAAA;AACZ,IAAA;AACF,EAAA;AAGwD,EAAA;AACR,EAAA;AACJ,EAAA;AAIzC,EAAA;AAE2B,EAAA;AAEc,EAAA;AAMxB,IAAA;AAEG,MAAA;AACC,sBAAA;AAKb;AAAA;AAGiB,wBAAA;AACb,UAAA;AAAA,UAAA;AAC8B,YAAA;AACa,YAAA;AACvB,YAAA;AAAA,UAAA;AAEvB,QAAA;AAEoB,MAAA;AAI5B,IAAA;AAEJ,EAAA;AAE+C,EAAA;AAER,EAAA;AAGrB,IAAA;AAClB,EAAA;AAKkB,EAAA;AAGVH,IAAAA;AAAC,MAAA;AAAA,MAAA;AACQ,QAAA;AACP,QAAA;AACA,QAAA;AACa,QAAA;AACO,QAAA;AACI,QAAA;AACN,UAAA;AACG,UAAA;AACC,UAAA;AAI6B,UAAA;AACnD,QAAA;AACA,QAAA;AACkB,QAAA;AAUV,QAAA;AAMsC,QAAA;AACpB,QAAA;AAIH,QAAA;AACZ,QAAA;AAAA,MAAA;AACb,IAAA;AAMc,oBAAA;AAMX;AAAA;AAAA;AAAA;AAAA;AAMsB,MAAA;AAC2B,IAAA;AAUD,MAAA;AAEK,QAAA;AAAgD,QAAA;AAAG,QAAA;AAC3D,QAAA;AAC1C,MAAA;AAEFA,sBAAAA;AAAC,QAAA;AAAA,QAAA;AACK,UAAA;AACJ,UAAA;AACA,UAAA;AAC6B,UAAA;AACH,UAAA;AAC1B,UAAA;AACW,UAAA;AACS,UAAA;AACD,UAAA;AAC4B,UAAA;AAC/C,UAAA;AACwC,UAAA;AACxC,UAAA;AACA,UAAA;AACA,UAAA;AAAA,QAAA;AACF,MAAA;AAGa,IAAA;AAEXA,MAAAA;AAAC,QAAA;AAAA,QAAA;AACS,UAAA;AACE,UAAA;AAET,UAAA;AAGD,QAAA;AACF,MAAA;AAEyB,MAAA;AAAA;AAAA;AAAA;AAIzBA,wBAAAA;AAAC,UAAA;AAAA,UAAA;AACC,YAAA;AACA,YAAA;AACA,YAAA;AAC0B,YAAA;AAGJ,cAAA;AACD,cAAA;AACC,cAAA;AACJ,cAAA;AAClB,YAAA;AACA,YAAA;AACoB,YAAA;AACC,cAAA;AACC,gBAAA;AACD,gBAAA;AACjB,gBAAA;AACF,cAAA;AAQ8B,cAAA;AACR,cAAA;AACJ,cAAA;AACA,cAAA;AACP,gBAAA;AACA,gBAAA;AACT,gBAAA;AAAA;AAAA;AAAA;AAIa,gBAAA;AAAA;AAAA;AAGb,gBAAA;AACA,gBAAA;AACe,cAAA;AACO,gBAAA;AACvB,cAAA;AACH,YAAA;AACA,YAAA;AACoB,YAAA;AAGE,cAAA;AACF,cAAA;AACpB,YAAA;AACW,YAAA;AAIiC,YAAA;AAAA,UAAA;AAC9C,QAAA;AAC8B,MAAA;AAA0B;AAAA;AAAA;AAAA;AAAA;AAMzC,wBAAA;AACA,0BAAA;AACZ,UAAA;AACH,QAAA;AAAA,MAAA;AAAA;AAAA;AAAA;AAKoB,wBAAA;AAA0B,MAAA;AAItD,IAAA;AAEJ,EAAA;AAEJ;ADyzB2D;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA","file":"/home/runner/work/openframe-oss-lib/openframe-oss-lib/openframe-frontend-core/dist/components/meeting-scheduler/index.cjs","sourcesContent":[null,"'use client';\n\n/**\n * `<HubSpotMeetingScheduler />` — natively-branded booking flow over the\n * HubSpot Meetings scheduler, rendered entirely with ODS primitives (no\n * iframe, no third-party chrome, no scripts). Data flows exclusively through\n * a HOST PROXY (`GET {apiBaseUrl}/api/meetings/availability`,\n * `POST {apiBaseUrl}/api/meetings/book`) — the widget has zero HubSpot\n * knowledge and no secrets; see `docs/EMBEDDING_HUBSPOT_MEETINGS.md` for the\n * endpoint contract a host must serve (external embedders proxy through\n * their own backend — the book route is IP-rate-limited and bot-gated).\n *\n * LAYOUT (Calendly-anatomy, conversion-first): one bordered card, two\n * panels. Left/top — the CONTEXT panel: an optional host-level back edge\n * (`onBack`), host identity (avatar + name + title from\n * `availability.hosts`), meeting title/description, duration chips (the\n * duration choice lives here, NOT as a wizard step — the visitor always\n * lands straight on the calendar), and the resolved timezone.\n * Right/bottom — the ACTION panel: calendar + time slots (first available\n * day auto-selected so slots are visible immediately), then the details\n * form, then the confirmation. On the slot step that panel splits again —\n * calendar column, then times column — each carrying its own heading (owned\n * by `SlotPicker`, so loading and loaded agree). Panels stack on mobile.\n *\n * Two modes, one component:\n * - SSR mode: pass `initialAvailability` (host-fetched) — the\n * zone-independent shell (context panel, calendar) renders server-side;\n * slot TIME labels wait for the client zone resolution (deterministic\n * first paint, no hydration mismatch), and availability is refetched\n * unconditionally after mount (the seed is scaffolding for a 60s-volatile\n * resource, not truth).\n * - Client mode: omit the seed — skeleton + self-fetch on mount.\n *\n * State machine: slot → details → confirmed, with a back edge, a \"book\n * another\" reset, and a `submitting` lock as the double-booking guard.\n * `flow=\"details-first\"` (opt-in, campaign landing pages) inverts it: the\n * form is step ONE and renders alone, the slot click submits the frozen\n * answers, and a details error routes back to the repopulated form — see\n * `SCHEDULER_FLOW_PRESETS` and the `detailsFirst` branches below.\n */\n\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport type { ComponentType, ReactNode } from 'react';\nimport { useIsHydrated } from '../../hooks/ui/use-is-hydrated';\nimport { completeFormRescue } from '../../hooks/use-form-rescue';\nimport { useHumanitySignals } from '../../hooks/use-humanity-signals';\nimport { BOOKING_IN_FLIGHT_MESSAGE, useMeetingBooking } from '../../hooks/use-meeting-booking';\nimport { useToast } from '../../hooks/use-toast';\nimport {\n blocksNativeBooking,\n type BookingConfirmation,\n type MeetingAvailability,\n type MeetingBookingErrorCode,\n type MeetingHost,\n} from '../../schemas/meeting-booking-schema';\nimport { cn } from '../../utils/cn';\nimport { FORM_RESCUE_ATTEMPT_FIELD, type FormRescueDefinition } from '../../utils/form-rescue';\nimport { formatDurationCompact, formatDateWithTimezone } from '../../utils/format';\nimport { Alert, AlertDescription, Button } from '../ui';\nimport { BookingForm, BookingFormSkeleton, DEFAULT_SUBMIT_LABEL, type BookingFormProps } from './booking-form';\nimport { Confirmation } from './confirmation';\nimport { SchedulerContextPanel, ContextPanelSkeleton } from './context-panel';\nimport { SlotPicker, SlotPickerSkeleton, dayKeyInZone } from './slot-picker';\n\n/**\n * Visitor-facing copy per booking-error code. Everything except SLOT_TAKEN\n * surfaces as an error TOAST at submit time, and ONLY as a toast (owner\n * decision 2026-08-27 — no inline duplicate; hosts must mount the lib\n * Toaster, as every hub platform does globally). SLOT_TAKEN renders above\n * the calendar instead: its recovery flow returns the visitor there. An\n * unrecognized code (newer host than widget) falls back to the VALIDATION\n * copy — fail-safe, never blank.\n */\nconst BOOKING_ERROR_COPY: Record<Exclude<MeetingBookingErrorCode, 'SLOT_TAKEN'>, string> = {\n INVALID_EMAIL:\n 'That email address was rejected by our scheduling system — please use a real, reachable address (work email works best).',\n VALIDATION:\n 'Please double-check your details — especially that the email address is real and reachable — and try again.',\n TEMPORARILY_UNAVAILABLE: 'Scheduling is briefly unavailable — please try again in a minute.',\n MEETING_UNAVAILABLE: 'This meeting type has reached its booking limit for today — try another time or contact us.',\n LINK_GONE: 'This meeting type is no longer available.',\n};\n\nexport interface HubSpotMeetingSchedulerProps {\n /** Directory id of the meeting link (from the host's `/api/meetings` payload). */\n meetingId: string;\n /** Endpoints prefix, default '' (same-origin `/api/meetings/*`) — FaqSection precedent. */\n apiBaseUrl?: string;\n /** SSR-mode seed (host-fetched). Omitted → client mode (self-fetch on mount). */\n initialAvailability?: MeetingAvailability;\n /** Meeting title shown in the context panel (host page owns the h1). */\n title?: string;\n /** Short description under the title in the context panel. */\n description?: string | null;\n /** Override the hosts shown (defaults to `availability.hosts`). */\n hosts?: MeetingHost[];\n /** Pin the DISPLAY zone (rendering only — never sent upstream, never a cache key). */\n displayTimezone?: string;\n /** The link's public HubSpot booking URL — the \"Open in HubSpot\" escape hatch target. */\n fallbackUrl?: string;\n /**\n * Host-level exit, rendered as a back edge at the top of the context panel\n * (and in the loading skeleton, so it doesn't pop in). This is \"leave the\n * scheduler\", NOT the details step's back edge — it is suppressed once a\n * slot is chosen so only one Back is ever on screen. Omit it and no back\n * affordance renders at all.\n */\n onBack?: () => void;\n onBooked?: (b: BookingConfirmation) => void;\n /**\n * What the visitor sees: the step, or `'unavailable'` when the degraded box\n * replaces the flow (the load failed, or the link cannot be booked natively).\n * Reported on mount and on every change. For a host that shows something\n * beside the card at some stages only (a landing page's explore nudge).\n */\n onStageChange?: (stage: SchedulerStage) => void;\n className?: string;\n /**\n * Panel order.\n *\n * `'slot-first'` (default, unchanged): calendar → details → confirmed.\n * `'details-first'`: the form comes FIRST and the slot click submits the\n * already-validated payload. Campaign landing pages use it, where \"tell us\n * about your setup, then pick a time\" is the conversion shape.\n *\n * This is not a cosmetic swap — see the flow-dependent branches below. It is\n * opt-in precisely so `slot-first` stays byte-identical.\n */\n flow?: SchedulerFlow;\n /**\n * Replace the details panel — step ONE under `flow=\"details-first\"`, step two\n * otherwise — with a host-supplied form. It receives exactly what the built-in\n * one does, so the injected form keeps the widget's contract: the deferred\n * schema, the honeypot and elapsed-ms signals, the verbatim consent block, the\n * `onSubmit` handoff and the `isSubmitting` lock.\n *\n * The intended shape is a THIN wrapper that re-renders `BookingForm` with\n * `fieldRows` (a layout re-arrangement, all machinery reused), not a\n * hand-written form. Anything reimplementing the contract loses the bot\n * protection and the consent guarantees.\n *\n * A COMPONENT type, not a render callback: React then owns its identity, so\n * it reconciles across steps and keeps its own state instead of remounting —\n * and the honeypot `ref` rides through as a normal prop.\n *\n * Defaults to the built-in form, so every existing embed is untouched.\n */\n detailsForm?: ComponentType<BookingFormProps>;\n /**\n * The DATA form of the same override: `fieldRows` and a host consent row,\n * spread onto whichever form renders. Serialisable, so a Server Component\n * can pass it across the RSC boundary where a component cannot.\n */\n detailsFormProps?: Pick<BookingFormProps, 'fieldRows' | 'consent'>;\n /** Form rescue for the details form (`RESCUE_FORMS.meetingBooking`). OPT-IN:\n * omitted or `null` saves nothing. */\n rescue?: FormRescueDefinition | null;\n}\n\ntype Step = 'slot' | 'details' | 'confirmed';\n/** `onStageChange`'s value: a step, or the degraded box that replaces the flow. */\nexport type SchedulerStage = Step | 'unavailable';\n\n/** Values collected at the details step, held until a slot picks the instant. */\ntype StashedDetails = {\n payload: Record<string, unknown>;\n /** Humanity signals captured while the form (and its honeypot) was mounted. */\n signals: Record<string, string | number>;\n};\n\n/** Context-panel geometry — ONE definition for the loaded card and the\n * loading skeleton, which is what keeps the two footprint-identical. */\nconst CONTEXT_PANEL_CLASS =\n 'p-[var(--spacing-system-l)] shrink-0 lg:w-[280px] border-b lg:border-b-0 lg:border-r border-ods-border lg:min-h-0 lg:overflow-y-auto';\n\n/**\n * Action panel: the elastic half, scrolling inside the fixed card.\n *\n * Padded only from `lg`. Below it the calendar and times are separate sections\n * with a divider between them, and each pads itself — one padding here would\n * inset that divider from the card's edges instead of letting it run the full\n * width the way every other rule in this card does.\n */\nconst ACTION_PANEL_CLASS = 'flex-1 min-w-0 flex flex-col md:min-h-0 lg:p-[var(--spacing-system-l)]';\n\n/**\n * The action panel's inset for the steps that are ONE block — details,\n * confirmation, the slot step's error line.\n *\n * The panel itself is padded only from `lg`, because below that the slot step\n * is two sections with a rule between them and each pads itself: one padding\n * on the panel would inset that rule instead of letting it run the card's full\n * width, the way every other rule here does. Every step that is NOT split into\n * sections has to bring the same inset with it — without it the form ran edge\n * to edge on tablet, its inputs touching the card's border.\n *\n * `md:min-h-0 md:overflow-y-auto` comes with it: from `md` up the card states\n * a height, and these steps have no inner scroller of their own the way the\n * times column does, so this is where a long form gets to scroll instead of\n * being cut off at the card's edge.\n */\nconst PANEL_STEP_CLASS = 'p-[var(--spacing-system-l)] md:min-h-0 md:overflow-y-auto lg:p-0';\n/** A step's panel: the inset above on a column that fills the action side. */\nconst STEP_PANEL_CLASS = cn('flex flex-1 flex-col', PANEL_STEP_CLASS);\n\n/**\n * The widget's height on desktop: a FIXED 380px, and the ONLY size the whole\n * card states.\n *\n * A floor was not enough. Every stage has a different natural height — the\n * context panel with two hosts ~340, the calendar column ~290, a degraded\n * stage two lines — so `min-height` only pinned the SHORT ones and let the\n * tall one push the wrapper, which is exactly the screen-shake this removes.\n * With a fixed height the box is stated once and nothing inside can move it;\n * anything taller scrolls in place.\n *\n * The elastic half derives from it: the panel passes it to the slot row and\n * the times column scrolls inside what is left. The calendar does not stretch\n * — a date grid has a size, and stretching one is how a month turns into\n * page-tall bands the moment a layout stops pinning a height. It states a\n * WIDTH (`CALENDAR_W`) and its square cells make it as tall as it is wide;\n * that width is chosen to fit inside this number, and the two are checked\n * against each other in one place rather than three.\n *\n * Six week rows, always — `fixedWeeks` — is why a height can be stated at all:\n * a 5-week month and a 6-week month occupy the same box, so paging the\n * calendar cannot resize the card.\n *\n * Exported because the stability has to survive OUTSIDE the card too: a host\n * that swaps the scheduler in and out of a slot (the onboarding \"Book a call\"\n * promo does exactly that) reserves the same box for whatever it shows\n * instead, so the swap moves nothing below it.\n *\n * Goes on the BORDERED element, always. Under `box-sizing: border-box` the\n * declared height swallows the 1px edges, so a card that carries the border\n * and a host box that carries its own both come out at exactly 380 — put it on\n * an inner wrapper instead and the border lands OUTSIDE the 380, making that\n * card 2px taller than everything it is supposed to match.\n *\n * TWO numbers, because the card has two shapes: 380 as a sidebar beside the\n * calendar (`lg`), 550 as a header strip over it (`md`). Phones get neither —\n * there a card the height of the hand holding it is the right answer and the\n * page is the scroller.\n *\n * A host that reserves this box owes it one thing: content that ADAPTS to the\n * height rather than assuming it. The onboarding promo does it by letting its\n * video take the leftover space at 16:9; a stand-in that just stacks fixed\n * blocks will overflow the shorter of the two.\n */\nexport const MEETING_SCHEDULER_H = 'md:h-[34.375rem] lg:h-[23.75rem]';\n\n/**\n * The box for `flow=\"details-first\"`'s CALENDAR and confirmation stages (and its\n * degraded stand-in), 638px from `md` (`4904:118213`), 812px on phones (the\n * calendar stage's natural height at 375px: header strip + six fixed weeks +\n * the 9.75rem chip cap). Fixed, not a floor, for the slot-first reason: the\n * times column scrolls inside a stated height.\n *\n * The FORM stage is NOT boxed. It renders at its natural height, so every\n * question the HubSpot link declares is on the page instead of scrolling inside\n * a card cut off mid-consent. What keeps that from jumping is the form's own\n * footprint discipline: the SSR seed renders the real form on first paint, and\n * the cold-start `BookingFormSkeleton` draws the same rows through the same grid.\n *\n * Hosts read it through `SCHEDULER_FLOW_PRESETS[flow].height`, never directly.\n */\nexport const MEETING_SCHEDULER_DETAILS_FIRST_H = 'h-[50.75rem] md:h-[39.875rem]';\n\nexport type SchedulerFlow = 'slot-first' | 'details-first';\n\n/** The flow every existing embed gets — the ONE spelling of the default. */\nexport const DEFAULT_SCHEDULER_FLOW: SchedulerFlow = 'slot-first';\n\n/**\n * What differs between the two flows as DATA — first step, the box a host\n * reserves, the form's submit copy. Exported so a host that swaps the card in\n * and out reads `SCHEDULER_FLOW_PRESETS[flow].height` instead of re-deriving\n * the pairing. The behavioural branches (lock, back edge, error routing) stay\n * in the component: they are logic, not configuration.\n */\nexport const SCHEDULER_FLOW_PRESETS: Record<\n SchedulerFlow,\n { initialStep: Exclude<Step, 'confirmed'>; height: string; submitLabel: string; footerNote?: string }\n> = {\n 'slot-first': { initialStep: 'slot', height: MEETING_SCHEDULER_H, submitLabel: DEFAULT_SUBMIT_LABEL },\n 'details-first': {\n initialStep: 'details',\n height: MEETING_SCHEDULER_DETAILS_FIRST_H,\n submitLabel: 'Continue',\n footerNote: 'Next step: pick a slot that works. Calendar invite lands right after.',\n },\n};\n\n/**\n * The two-panel card's box, shared verbatim by the loading skeleton and the\n * loaded card so neither can drift from the other.\n *\n * The height is applied per flow (`SCHEDULER_FLOW_PRESETS[flow].height`); what this adds is the\n * flex column that makes them work. On TABLET the height is stated for the\n * same reason the desktop one is: a CEILING only pins the tall stages. The short ones — the empty\n * month (\"No available times in September\"), a five-week month, the cold-start\n * skeleton — each settled at their own natural height, so paging a month or\n * waiting out a fetch resized the card under the visitor. Stating the height\n * makes every stage the same box and moves the variation inside, where the\n * times column already scrolls.\n *\n * 550, not the mock's 498: the mock draws one host and no timezone field, and\n * a real link has two hosts and a zone the visitor must be able to change.\n * That is ~55px of content the mock never budgeted for, and the month below it\n * is what would otherwise pay for it.\n *\n * The strip is `shrink-0`, so what is left goes to the slot row, which spends\n * it on a calendar sized to fit (no scroll) and a times list that scrolls on\n * its own.\n *\n * `md:flex md:flex-col` is what makes that possible at all. A stated height on\n * a plain block only clips (this card is `overflow-hidden` for its corners) —\n * it is the flex column plus the inner wrapper's `min-h-0` that lets the\n * content shrink into the height instead of being cut off by it.\n */\nconst CARD_CLASS = cn(\n 'overflow-hidden rounded-md border border-ods-border bg-ods-card',\n 'md:flex md:flex-col',\n // The height is NOT baked in any more: it is chosen per flow at each render\n // site (`cardClass`), because details-first needs a taller box and both\n // constants are module scope.\n);\n\n/** Context strip over action panel, side by side from `lg`. `md:flex-1\n * md:min-h-0` is the pair that makes it exactly as tall as the card states —\n * grow into a stated height that content does not reach, shrink into one it\n * overruns — so neither a short stage nor a long one changes the box. */\nconst CARD_INNER_CLASS = 'flex flex-col md:min-h-0 md:flex-1 lg:h-full lg:flex-row';\n\n/** The two-line stages (load failure, \"booked on HubSpot\") — the SAME box as\n * the booking card, because these are stages of one widget in one slot and a\n * stage that collapses to two lines would snap the page shut under the\n * visitor. Their content is centred in it and scrolls if a message ever\n * outgrows it. */\nconst CARD_DEGRADED_CLASS = cn(\n 'flex flex-col items-start gap-[var(--spacing-system-m)] rounded-md border border-ods-border bg-ods-card p-[var(--spacing-system-lf)]',\n 'md:justify-center md:overflow-y-auto',\n);\n\n/**\n * The two-line stages of the card (load failure, \"booked on HubSpot\", a host's\n * own \"calendar unavailable\") — ONE shape, exported so a host renders its\n * fallback in the same box instead of re-typing the chrome. The box is the\n * FLOW's: a host names the flow it would have mounted and the height follows\n * from {@link SCHEDULER_FLOW_PRESETS}, so the pairing is never re-derived.\n */\nexport function SchedulerDegradedCard({\n flow = DEFAULT_SCHEDULER_FLOW,\n className,\n message,\n action,\n children,\n}: {\n flow?: SchedulerFlow;\n className?: string;\n /** The one line the stage says, in the card's own type — so a host's fallback never picks a different scale. */\n message?: string;\n /** The one way out (the escape hatch, or a host's own link). */\n action?: ReactNode;\n children?: ReactNode;\n}) {\n return (\n <div className={cn(CARD_DEGRADED_CLASS, SCHEDULER_FLOW_PRESETS[flow].height, className)}>\n {message && <p className=\"text-ods-text-secondary text-h6\">{message}</p>}\n {action}\n {children}\n </div>\n );\n}\n\n/**\n * Fail-closed gate: a link with a REQUIRED question no control can answer (a\n * file upload), or whose consent block is malformed, must NOT render a\n * half-working native form (a silently dropped required question or missing\n * consent copy is worse than no native form) — the escape hatch takes over.\n * An unfamiliar question TYPE is not a reason: it resolves to the nearest\n * control (`resolveFormFieldControl`), so one new HubSpot type can no longer\n * take the whole booking form down.\n */\nfunction isNativelyBookable(availability: MeetingAvailability): boolean {\n if (availability.formFields.some(blocksNativeBooking)) return false;\n const consent = availability.legalConsent;\n if (consent) {\n if (typeof consent.processingConsentText !== 'string') return false;\n if (!Array.isArray(consent.communicationConsentCheckboxes)) return false;\n }\n return true;\n}\n\nexport function HubSpotMeetingScheduler({\n meetingId,\n apiBaseUrl = '',\n initialAvailability,\n title,\n description,\n hosts,\n displayTimezone,\n fallbackUrl,\n onBack,\n onBooked,\n onStageChange,\n className,\n flow = DEFAULT_SCHEDULER_FLOW,\n detailsForm: DetailsForm = BookingForm,\n detailsFormProps,\n rescue = null,\n}: HubSpotMeetingSchedulerProps) {\n const {\n availability,\n isLoadingAvailability,\n isFetchingAvailability,\n availabilityError,\n monthOffset,\n setMonthOffset,\n refetchAvailability,\n book,\n isSubmitting,\n } = useMeetingBooking({ meetingId, apiBaseUrl, initialAvailability });\n\n const detailsFirst = flow === 'details-first';\n\n // Zone resolution happens POST-mount (Intl) unless the embedder pins one —\n // the initial (server) render stays deterministic; time labels appear once\n // a zone exists.\n //\n // Priority, unchanged: the visitor's own pick (the panel's zone selector) >\n // the embedder's `displayTimezone` > the browser's resolved zone. Only the\n // last of the three is client-only, so `useIsHydrated` is the whole reason\n // this could not be computed during render — with that gate stated directly,\n // the zone is DERIVED and the effect that used to publish it (a setState in\n // an effect body, and a dead render for every mount) is gone.\n const hydrated = useIsHydrated();\n const [pickedTimezone, setTimezone] = useState<string | null>(null);\n const resolvedLocalTimezone = useMemo(() => {\n if (!hydrated) return null;\n try {\n return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';\n } catch {\n return 'UTC';\n }\n }, [hydrated]);\n const timezone = pickedTimezone ?? displayTimezone ?? resolvedLocalTimezone;\n\n const preset = SCHEDULER_FLOW_PRESETS[flow];\n const [step, setStep] = useState<Step>(preset.initialStep);\n // The same predicates, in the same order, as the degraded returns below: a\n // cold load is still the current step; a failed load or a link the form cannot\n // reproduce is the degraded box.\n const stage: SchedulerStage =\n !(isLoadingAvailability && !availability) &&\n (Boolean(availabilityError) || !availability || !isNativelyBookable(availability))\n ? 'unavailable'\n : step;\n // Reported from an effect, not beside each `setStep`: the stage moves in several\n // places (one of them during render, on a link swap), and a host setState run\n // from this component's render is a cross-component update. The ref keeps an\n // inline host callback from re-firing on every render; it is written in an\n // effect, never during render.\n const onStageChangeRef = useRef(onStageChange);\n useEffect(() => {\n onStageChangeRef.current = onStageChange;\n });\n useEffect(() => {\n onStageChangeRef.current?.(stage);\n }, [stage]);\n // State, not a ref: the link-swap reset below writes it DURING RENDER, and\n // this file's own rule forbids writing a ref there. \"Frozen\" means written\n // once at Continue, not `useRef`.\n const [stash, setStash] = useState<StashedDetails | null>(null);\n /** Synchronous in-flight lock for the details-first slot click — see `onSelectSlot`. */\n const inFlightRef = useRef(false);\n /** details-first: a slot's POST is in flight. The ref covers the frame before\n * `isSubmitting` renders true, so every guard reads both. */\n const postInFlight = useCallback(\n () => detailsFirst && (isSubmitting || inFlightRef.current),\n [detailsFirst, isSubmitting],\n );\n\n /**\n * details-first step ONE is the form and nothing else — the mock\n * (`4904:117130`) draws no context panel beside it, and paints the card on the\n * page ground so the fields read as the raised elements. The panel (host,\n * fixed length, timezone, and the Back edge to this form) belongs to the\n * calendar step, exactly as slot-first has always drawn it.\n */\n const formOnly = detailsFirst && step === 'details';\n const [pickedDurationMs, setDurationMs] = useState<number | null>(null);\n const [pickedDay, setSelectedDay] = useState<string | null>(null);\n const [selectedSlot, setSelectedSlot] = useState<number | null>(null);\n const [confirmation, setConfirmation] = useState<BookingConfirmation | null>(null);\n const [bookingError, setBookingError] = useState<MeetingBookingErrorCode | null>(null);\n\n const { honeypotInputProps, getSignals, resetSignals } = useHumanitySignals();\n const { toast } = useToast();\n\n // Reset the machine when the host switches links. Adjusted while rendering —\n // React's documented pattern for a prop-driven reset — so the swapped-in link\n // never paints a frame of the previous link's chosen day, slot or\n // confirmation. The ref counter stays in an effect: a ref must not be written\n // during render.\n const autoAdvanceCount = useRef(0);\n const [machineFor, setMachineFor] = useState(meetingId);\n if (machineFor !== meetingId) {\n setMachineFor(meetingId);\n setStep(preset.initialStep);\n setDurationMs(null);\n setSelectedDay(null);\n setSelectedSlot(null);\n setConfirmation(null);\n setBookingError(null);\n // Clearing the stash is load-bearing: otherwise the swapped-in link would\n // POST the PREVIOUS link's answers, against a schema the server rebuilds\n // from the new link's metadata.\n setStash(null);\n }\n useEffect(() => {\n autoAdvanceCount.current = 0;\n }, [meetingId]);\n\n // Memoised because the `??` fallback minted a NEW array on every\n // render, which made the memo below re-run every time — i.e. do nothing.\n const durations = useMemo(() => availability?.durationsMs ?? [], [availability?.durationsMs]);\n const shownHosts = hosts ?? availability?.hosts ?? [];\n\n // Default to the first offered duration — the visitor lands straight on\n // the calendar; duration is a context-panel chip, not a wizard step.\n // Derived, not stored: \"no explicit pick yet\" already means \"the first one\n // offered\", so the effect that used to write that back into state was a\n // second render pass for a value the first render could name — and the frame\n // in between was the empty calendar this default exists to avoid.\n const durationMs = pickedDurationMs ?? durations[0] ?? null;\n\n const slots = useMemo(() => {\n if (!availability || durationMs == null) return [];\n return availability.slotsByDurationMs[String(durationMs)] ?? [];\n }, [availability, durationMs]);\n\n // Auto-select the first day WITH SLOTS IN THE VISIBLE MONTH (once the zone\n // is known) so the visitor sees concrete times immediately — never a dead\n // \"pick a day\" state, and NEVER a day from another month than the calendar\n // shows (HubSpot's monthOffset payloads can carry near-term slots outside\n // the requested month — the visible grid is the authority).\n //\n // Derived, with the visitor's own pick winning for as long as it is still one\n // of the days on offer. Every input — the slots, the visible month, the zone\n // — is available while rendering, so publishing this from an effect only\n // guaranteed that each of those changes committed one dead \"pick a day\" frame\n // before the calendar filled itself in.\n const selectedDay = useMemo(() => {\n if (step !== 'slot' || !timezone) return pickedDay;\n const now = new Date();\n const visible = new Date(now.getFullYear(), now.getMonth() + monthOffset, 1);\n const monthPrefix = `${visible.getFullYear()}-${String(visible.getMonth() + 1).padStart(2, '0')}`;\n const inMonth = slots.filter(ms => dayKeyInZone(ms, timezone).startsWith(monthPrefix));\n if (inMonth.length === 0) {\n // Nothing this month — a pick left over from a different month is not a\n // selection in the grid the visitor is looking at.\n return pickedDay && !pickedDay.startsWith(monthPrefix) ? null : pickedDay;\n }\n const dayKeys = new Set(inMonth.map(ms => dayKeyInZone(ms, timezone)));\n if (pickedDay && dayKeys.has(pickedDay)) return pickedDay;\n return dayKeyInZone(inMonth[0], timezone);\n }, [step, timezone, slots, pickedDay, monthOffset]);\n\n // Fully-booked month with more ahead → auto-advance (bounded, ≤3 hops)\n // before showing the empty state.\n useEffect(() => {\n if (\n step === 'slot' &&\n availability &&\n !isFetchingAvailability &&\n durationMs != null &&\n slots.length === 0 &&\n availability.hasMore &&\n autoAdvanceCount.current < 3 &&\n // Not while a chip's POST is in flight — paging would unmount it.\n !postInFlight()\n ) {\n autoAdvanceCount.current += 1;\n setMonthOffset(monthOffset + 1);\n }\n }, [step, availability, isFetchingAvailability, durationMs, slots, monthOffset, setMonthOffset, postInFlight]);\n\n /** The back edge's destination from the form: the calendar, with any\n * submit error cleared so the visitor doesn't carry it back. */\n const backToSlot = useCallback(() => {\n setStep('slot');\n setBookingError(null);\n }, []);\n\n /** details-first's inverse: back from the CALENDAR to the form. The stash is\n * what repopulates it, so nothing is cleared here but the error. */\n const backToDetails = useCallback(() => {\n // Not while a slot's POST is in flight — the step must stay on the\n // calendar the spinner lives on until the result routes it.\n if (postInFlight()) return;\n // No slot rides back to the form: in this flow the form never legitimately\n // holds one (the slot IS the submit), and a chip the server rejected with a\n // slot-step toast would otherwise print in the form's summary line.\n setSelectedSlot(null);\n setStep('details');\n setBookingError(null);\n }, [postInFlight]);\n\n /**\n * details-first: Continue does not POST. It validates, freezes the answers\n * and the humanity signals (captured HERE, while the form and its honeypot\n * are still mounted — `getSignals()` reads a detached ref once they unmount),\n * and hands over to the calendar.\n */\n const stashDetails = useCallback(\n (payload: Record<string, unknown>): Promise<void> => {\n setBookingError(null);\n setStash({ payload, signals: getSignals() });\n setStep('slot');\n // The visitor has been filling a form; the month they are about to see\n // may have moved on, and its hop budget should start fresh.\n autoAdvanceCount.current = 0;\n void refetchAvailability();\n // Deliberately NOT `async`: nothing here is awaited. The refetch is\n // fire-and-forget so the calendar paints from cache the moment the step\n // flips, and awaiting it would hold `BookingForm`'s isSubmitting past\n // the unmount. `onSubmit` is Promise-returning, hence the explicit\n // resolve rather than an empty async body (which `require-await` flags).\n return Promise.resolve();\n },\n [getSignals, refetchAvailability],\n );\n\n const handleSubmit = useCallback(\n async (payload: Record<string, unknown>) => {\n // Captured BEFORE the clear below: `book()` reports a re-entrant call as\n // a VALIDATION code carrying `BOOKING_IN_FLIGHT_MESSAGE`, and by\n // then this clear has already run — so a double-click would wipe a\n // visible SLOT_TAKEN alert on its way to being ignored.\n const priorError = bookingError;\n setBookingError(null);\n const result = await book(payload);\n\n // Not an error: the hook's in-flight guard. No toast, no step change.\n if (!result.ok && result.message === BOOKING_IN_FLIGHT_MESSAGE) {\n setBookingError(priorError);\n return;\n }\n if (result.ok && result.confirmation) {\n // The details form may have unmounted (details-first); its attempt id rides the payload.\n completeFormRescue(rescue, payload[FORM_RESCUE_ATTEMPT_FIELD]);\n setConfirmation(result.confirmation);\n setStep('confirmed');\n onBooked?.(result.confirmation);\n return;\n }\n const code = result.code ?? 'TEMPORARILY_UNAVAILABLE';\n setBookingError(code);\n if (code === 'SLOT_TAKEN') {\n // Recover: refresh the grid (the slot vanishes) and reset the timing\n // signal so the retry isn't flagged too-fast.\n setSelectedSlot(null);\n setStep('slot');\n // NOT in details-first: the retry re-POSTs the frozen stash, so\n // `getSignals()` is never consulted again — but `resetSignals()` also\n // re-stamps `startedAtRef`, which would make a LATER Continue (after a\n // VALIDATION round-trip) read as too-fast against the min-fill floor.\n if (!detailsFirst) resetSignals();\n void refetchAvailability();\n } else {\n // details-first: the chip that submitted is un-picked on EVERY failure —\n // selection means submit in this flow, so a rejected chip must not sit\n // in the selected variant (and the form's summary must never show a\n // slot the server did not accept).\n if (detailsFirst) setSelectedSlot(null);\n if (detailsFirst && (code === 'VALIDATION' || code === 'INVALID_EMAIL')) {\n // DETAILS errors: the form is unmounted by now, so send the visitor\n // back to it (the stash repopulates every answer).\n setStep('details');\n }\n // The error surface is the TOAST, full stop (host-mounted Toaster —\n // every hub platform mounts it globally; embedders must too). A silent\n // return to an unchanged form would read as nothing having happened.\n toast({\n variant: 'error',\n title: 'Booking failed',\n description: BOOKING_ERROR_COPY[code] ?? BOOKING_ERROR_COPY.VALIDATION,\n });\n }\n },\n [book, bookingError, detailsFirst, onBooked, refetchAvailability, rescue, resetSignals, toast],\n );\n\n const escapeHatch = fallbackUrl ? (\n <Button variant=\"outline\" size=\"small-legacy\" href={fallbackUrl} openInNewTab>\n Open in HubSpot\n </Button>\n ) : null;\n\n // ---- terminal / degraded states -----------------------------------------\n\n /** The card's ONE back edge, per flow and step — the skeleton and the loaded\n * panel wire the same one, so a month load can never swap it for the host's\n * exit under a details-first visitor. */\n const backEdge = detailsFirst ? (step === 'slot' ? backToDetails : onBack) : step === 'details' ? backToSlot : onBack;\n\n /**\n * The card states ONE height and everything inside derives from it (see\n * `MEETING_SCHEDULER_H`). details-first's tallest stage is the form, so it\n * gets its own fixed pair rather than a floor — a floor was tried here before\n * and let the tall stage push the wrapper.\n *\n * Placed BEFORE the host `className`: `cn` is tailwind-merge-backed and\n * last-wins, so appending it after would make a host's own `h-*` unreachable,\n * which is the override the height-inside-CARD_CLASS arrangement allows today.\n */\n // details-first's form stage grows with the link's questions (see\n // MEETING_SCHEDULER_DETAILS_FIRST_H); every other stage keeps the stated box.\n // `formOnly` is exactly that stage (`detailsFirst && step === 'details'`), so\n // slot-first, the calendar, the confirmation and the skeleton of either stay boxed.\n const boxed = !formOnly;\n const cardClass = cn(\n CARD_CLASS,\n boxed ? preset.height : 'md:h-auto',\n detailsFirst && 'flex flex-col',\n formOnly && 'bg-ods-bg',\n className,\n );\n /** details-first states a height on phones too (see MEETING_SCHEDULER_DETAILS_FIRST_H), so\n * the wrappers that let content shrink into a stated height run at every width there. */\n const innerClass = cn(CARD_INNER_CLASS, detailsFirst && boxed && 'min-h-0 flex-1');\n const actionPanelClass = cn(ACTION_PANEL_CLASS, detailsFirst && boxed && 'min-h-0 overflow-y-auto');\n const stepPanelClass = cn(STEP_PANEL_CLASS, detailsFirst && boxed && 'min-h-0 overflow-y-auto');\n\n /** One card SHAPE for every degraded return below. */\n const degraded = (message: string) => (\n <SchedulerDegradedCard flow={flow} className={className} message={message} action={escapeHatch} />\n );\n const degradedCard = degraded(\"We couldn't load available call times. Please try again shortly.\");\n\n if (isLoadingAvailability && !availability) {\n // COLD start only — a month already in the query cache renders straight\n // into the loaded card. Composed inside the SAME wrappers as that card\n // (`ContextPanelSkeleton` beside the real slot-area layout), so nothing\n // shifts when it swaps.\n return (\n <div className={cardClass}>\n <div className={innerClass}>\n {!formOnly && <ContextPanelSkeleton onBack={backEdge} className={CONTEXT_PANEL_CLASS} />}\n <div className={actionPanelClass}>\n {/* Both terms matter. Without `flow` this stays a calendar where\n the form belongs; without the step term, paging a month AFTER\n Continue (which happens at `step === 'slot'`) would swap the\n calendar out for a form skeleton and back. */}\n {formOnly ? (\n // The SAME wrapper the loaded form gets, so the skeleton never\n // states its own inset.\n <div className={stepPanelClass}>\n <BookingFormSkeleton\n fieldRows={detailsFormProps?.fieldRows}\n consent={Boolean(detailsFormProps?.consent)}\n footerNote={preset.footerNote}\n />\n </div>\n ) : (\n <SlotPickerSkeleton monthOffset={monthOffset} />\n )}\n </div>\n </div>\n </div>\n );\n }\n\n if (availabilityError || !availability) return degradedCard;\n\n if (!isNativelyBookable(availability)) {\n // Fail closed — never render a half-working native form on a link with\n // questions or consent we can't faithfully reproduce.\n return degraded('This meeting type is booked directly on HubSpot.');\n }\n\n // ---- the card ------------------------------------------------------------\n\n return (\n <div className={cardClass}>\n <div className={innerClass}>\n {!formOnly && (\n <SchedulerContextPanel\n hosts={shownHosts}\n title={title}\n description={description}\n durationsMs={durations}\n selectedDurationMs={durationMs}\n onSelectDuration={ms => {\n setDurationMs(ms);\n setSelectedDay(null);\n setSelectedSlot(null);\n // slot-first only. In details-first `details` is step ONE, so this\n // would yank a mid-typing visitor onto the calendar and unmount the\n // form under them.\n if (!detailsFirst && step === 'details') setStep('slot');\n }}\n timezone={timezone}\n onTimezoneChange={setTimezone}\n // ONE back edge for the whole card, in the panel that never swaps.\n // Its destination is simply \"the previous step\": from the form back\n // to the calendar, from the calendar out of the scheduler (if the\n // host gave us an exit at all). Two Backs on screen reading the same\n // word with different destinations was the ambiguity the details\n // step used to carry.\n // details-first inverts the mapping: Back at `slot` returns to the\n // form. At `details` the form-only layout mounts no panel, so a host\n // exit is not rendered there (the page around the card is the exit).\n onBack={backEdge}\n // `locked` HIDES the selectors (post-selection steps); `disabled`\n // keeps them mounted but inert. details-first needs the second while\n // a POST is in flight: a mid-flight change would clear the slot\n // under the spinner while the body already built carries the old\n // duration — and unmounting the row would shift the grid under it.\n locked={detailsFirst ? step === 'confirmed' : step !== 'slot'}\n disabled={detailsFirst && isSubmitting}\n // The zone still governs every time on screen — including the\n // summary line on the form — so the picker stays until there is\n // nothing left to re-read in it.\n showTimezone={step !== 'confirmed'}\n className={CONTEXT_PANEL_CLASS}\n />\n )}\n\n {/* Scrolls INSIDE the fixed card rather than growing it — see\n MEETING_SCHEDULER_H. `min-h-0` is what lets it: a flex item's\n default `min-height:auto` refuses to shrink below its content. */}\n <div className={actionPanelClass}>\n {step === 'confirmed' && confirmation && timezone ? (\n <div className={stepPanelClass}>\n <Confirmation confirmation={confirmation} timezone={timezone} />\n </div>\n ) : step === 'details' &&\n (detailsFirst\n ? // The form is step ONE here: no slot yet, and `timezone` is null\n // on the server render, so neither may gate the PANEL. A link\n // publishing no durations has no form worth showing; it gets the\n // \"nothing published\" message below at every moment, refetch or\n // not — never a calendar skeleton in the form-only layout.\n durations.length > 0\n : durationMs != null && selectedSlot != null && timezone) ? (\n <div className={cn(stepPanelClass, 'gap-[var(--spacing-system-m)]')}>\n {/* Top-aligned, and no back edge of its own: the ONE back edge\n lives in the context panel at every step (the `onBack` wired on `SchedulerContextPanel`),\n which is where the design puts it and the only spot that\n stays put while this side swaps between calendar and form.\n\n The summary keeps its OWN guard — including `timezone`, which\n is null pre-hydration and which `Intl.DateTimeFormat` throws a\n RangeError on rather than ignoring. */}\n {selectedSlot != null && durationMs != null && timezone != null && (\n <p className=\"text-ods-text-primary text-h4\">\n {formatDateWithTimezone(selectedSlot, timezone, 'weekdayDateTimeZoned', { viewerLocale: true })} ·{' '}\n {formatDurationCompact(durationMs / 1000)}\n </p>\n )}\n <DetailsForm\n {...detailsFormProps}\n availability={availability}\n meetingId={meetingId}\n startTimeMs={selectedSlot ?? undefined}\n durationMs={durationMs ?? undefined}\n timezone={timezone}\n deferSlot={detailsFirst}\n submitLabel={preset.submitLabel}\n footerNote={preset.footerNote}\n initialValues={detailsFirst ? stash?.payload : undefined}\n isSubmitting={isSubmitting}\n onSubmit={detailsFirst ? stashDetails : handleSubmit}\n honeypotInputProps={honeypotInputProps}\n getSignals={getSignals}\n rescue={rescue}\n />\n </div>\n ) : (\n <div className=\"flex flex-col gap-[var(--spacing-system-m)] md:min-h-0 md:flex-1\">\n {bookingError === 'SLOT_TAKEN' && (\n <Alert\n variant=\"warning\"\n className=\"mx-[var(--spacing-system-l)] mt-[var(--spacing-system-l)] w-auto lg:m-0 lg:w-full\"\n >\n <AlertDescription>\n That time was just taken — pick another slot below. (If you already submitted, check your email for\n a confirmation before rebooking.)\n </AlertDescription>\n </Alert>\n )}\n {timezone && durationMs != null ? (\n // The picker owns BOTH the loading and the empty month now —\n // it keeps the calendar up either way, so there is no branch\n // here that can take it off screen mid-flow.\n <SlotPicker\n slots={slots}\n timezone={timezone}\n monthOffset={monthOffset}\n onMonthOffsetChange={o => {\n // details-first: a POST is in flight from this grid; paging\n // would clear the spinning chip and swap the key under it.\n if (postInFlight()) return;\n setSelectedDay(null);\n setSelectedSlot(null);\n setMonthOffset(o);\n }}\n selectedSlot={selectedSlot}\n onSelectSlot={ms => {\n if (!detailsFirst) {\n setSelectedSlot(ms);\n setStep('details');\n return;\n }\n // details-first: the slot IS the submit. The step stays\n // 'slot' for the whole POST — moving it here would unmount\n // the chip the spinner lives on and fight the SLOT_TAKEN\n // branch, which puts the visitor back on this calendar.\n // `isSubmitting` is the render's snapshot; a second chip\n // clicked in the same frame sees it false. The ref is the\n // synchronous lock, so the second click changes nothing.\n if (postInFlight() || !stash) return;\n inFlightRef.current = true;\n setSelectedSlot(ms);\n void handleSubmit({\n ...stash.payload,\n ...stash.signals,\n meetingId,\n // `ms`, NOT `selectedSlot` — the setState above has not\n // committed inside this handler, so state holds null on\n // the first booking and the PREVIOUS slot on a retry.\n startTimeMs: ms,\n // Live values: both stay editable after Continue, so the\n // copies frozen at that point can be stale.\n durationMs,\n timezone,\n }).finally(() => {\n inFlightRef.current = false;\n });\n }}\n selectedDay={selectedDay}\n onSelectDay={day => {\n // Same lock: another day would swap the times column out\n // from under the spinning chip.\n if (postInFlight()) return;\n setSelectedDay(day);\n }}\n isLoading={isLoadingAvailability}\n // Distinct from `isLoading`, which swaps the whole times\n // column for a skeleton: the grid must stay up with the\n // clicked chip spinning.\n isSubmitting={detailsFirst ? isSubmitting : undefined}\n />\n ) : durations.length === 0 && (!isFetchingAvailability || formOnly) ? (\n // The LINK publishes nothing at all — a different thing from a\n // fully-booked month, and the only case the escape hatch is\n // the right answer to. In the form-only layout it holds through\n // a refetch too: the alternative frame is a calendar skeleton\n // where a form belongs.\n <div className=\"flex flex-col items-start gap-[var(--spacing-system-m)]\">\n <p className=\"text-ods-text-secondary text-h6\">No call times are published right now.</p>\n {escapeHatch}\n </div>\n ) : (\n // Pre-mount timezone-resolution window (every SSR load hits\n // this for one frame-batch) — same layout, real calendar, so\n // nothing shifts when the picker takes over.\n <SlotPickerSkeleton monthOffset={monthOffset} />\n )}\n </div>\n )}\n </div>\n </div>\n </div>\n );\n}\n\n// The override surface: a host builds its `detailsForm` by re-rendering\n// `BookingForm` with `fieldRows`, so it needs both from this entry point (the\n// package exports `./components/meeting-scheduler`, not the file beneath it).\nexport {\n BookingForm,\n BookingFormSkeleton,\n type BookingFormProps,\n type BookingFieldRow,\n type BookingFieldSlot,\n type BookingFormConsent,\n} from './booking-form';\n\nexport {\n MeetingSchedulerDirectory,\n MeetingSchedulerDirectoryRowSkeleton,\n type MeetingSchedulerDirectoryProps,\n} from './directory';\nexport type { MeetingAvailability, BookingConfirmation, MeetingBookingErrorCode, MeetingHost };\nexport type { SchedulingLink, SchedulingLinksPayload } from '../../schemas/meeting-booking-schema';\n","'use client';\n\nimport { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';\nimport { useCallback, useLayoutEffect, useRef, useState } from 'react';\nimport {\n MEETING_BOOKING_ERROR_CODES,\n type BookingConfirmation,\n type MeetingAvailability,\n type MeetingBookingErrorCode,\n} from '../schemas/meeting-booking-schema';\nimport { contentFetch } from '../utils/embed-content-fetch';\nimport { MAX_MONTH_OFFSET } from '../utils/hubspot-meetings-convention';\n\n/**\n * useMeetingBooking — data layer for `<HubSpotMeetingScheduler>` (the widget\n * files never inline fetch; same split as `useContactSubmission`/ContactForm).\n *\n * REQUIRES a `QueryClientProvider` in the host, like every other query-backed\n * surface in this lib (tickets, chat, onboarding-guides). `@tanstack/react-query`\n * is a declared peer dependency; the lib never mounts a provider of its own.\n *\n * Availability is a per-month GET, cached per (host, link, month). The cache is\n * the point: paging back to a month already seen is INSTANT and silent, which\n * is what a hand-rolled `useEffect` + `useState` could not do — it re-fetched\n * and re-skeletoned the same month every time the visitor stepped back.\n *\n * Deliberately NOT `placeholderData: keepPreviousData`. The payload is\n * month-scoped, so last month's slots under this month's caption describe days\n * that aren't on screen — a grid that lights up the wrong dates is worse than\n * one that is briefly inert. What kills the flicker instead is WHERE the\n * loading state lands: the calendar is fully derivable from the date alone, so\n * it keeps rendering (with nothing selectable yet) while only the times column\n * — the part that genuinely has no answer — shows a placeholder.\n *\n * `staleTime` is ZERO: the host serves availability live (no-store), and a\n * cached month re-shown while paging back is organizer-visible staleness. A\n * slot gone by the time it is clicked costs a booking — always refetch.\n *\n * Booking: a mutation, guarded so a second submit can't start while one is in\n * flight (the transport never retries POST — this is the only double-booking\n * guard). Errors surface as typed `MeetingBookingErrorCode`s the widget keys\n * its recovery UI off, never as thrown rejections.\n *\n * All requests go through the host proxy (`apiBaseUrl` prefix, FaqSection\n * precedent) via `contentFetch`, so embedded hosts with an embed-auth adapter\n * inherit auth with zero extra wiring.\n */\n\nexport interface BookingResult {\n ok: boolean;\n confirmation?: BookingConfirmation;\n code?: MeetingBookingErrorCode;\n message?: string;\n}\n\n/** Always refetch — availability is served live (no-store) by the host; a\n * cached month shown while paging back is exactly the staleness the server\n * layers were stripped to kill (2026-08-27). */\nconst AVAILABILITY_STALE_MS = 0;\n\n/** Query key for one month of one link. Exported so a host can prefetch or\n * invalidate a month it knows changed (e.g. after booking elsewhere). */\nexport function meetingAvailabilityKey(apiBaseUrl: string, meetingId: string, monthOffset: number) {\n return ['meeting-availability', apiBaseUrl, meetingId, monthOffset] as const;\n}\n\n/** The message `book()` returns for a re-entrant call — a sentinel, not an error; the scheduler matches on it. */\nexport const BOOKING_IN_FLIGHT_MESSAGE = 'Already submitting';\n\nexport function useMeetingBooking(options: {\n meetingId: string;\n apiBaseUrl?: string;\n initialAvailability?: MeetingAvailability;\n}) {\n const { meetingId, apiBaseUrl = '', initialAvailability } = options;\n const queryClient = useQueryClient();\n\n const seededOffset = initialAvailability?.meetingId === meetingId ? initialAvailability.monthOffset : undefined;\n const [monthOffset, setMonthOffsetState] = useState(seededOffset ?? 0);\n\n const setMonthOffset = useCallback((offset: number) => {\n setMonthOffsetState(Math.max(0, Math.min(MAX_MONTH_OFFSET, offset)));\n }, []);\n\n // Link switch resets paging — without this, the new link is queried at the\n // PREVIOUS link's month. Layout effect so the query below sees the reset\n // offset in the same commit.\n const prevMeetingIdRef = useRef(meetingId);\n useLayoutEffect(() => {\n if (prevMeetingIdRef.current === meetingId) return;\n prevMeetingIdRef.current = meetingId;\n setMonthOffsetState(0);\n }, [meetingId]);\n\n // The SSR seed covers exactly one (link, month) cell of the cache.\n const seed =\n initialAvailability?.meetingId === meetingId && initialAvailability.monthOffset === monthOffset\n ? initialAvailability\n : undefined;\n\n const availabilityQuery = useQuery({\n queryKey: meetingAvailabilityKey(apiBaseUrl, meetingId, monthOffset),\n queryFn: async ({ signal }) => {\n const params = new URLSearchParams({ meeting: meetingId, monthOffset: String(monthOffset) });\n const res = await contentFetch(`${apiBaseUrl}/api/meetings/availability?${params}`, { signal });\n if (!res.ok) throw new Error(`availability ${res.status}`);\n return (await res.json()) as MeetingAvailability;\n },\n staleTime: AVAILABILITY_STALE_MS,\n initialData: seed,\n // Stamped as infinitely old on purpose: the seed is first-paint\n // scaffolding for a 60-second-volatile resource, not truth, so it paints\n // immediately AND is refetched immediately.\n initialDataUpdatedAt: seed ? 0 : undefined,\n });\n\n const refetchAvailability = useCallback(async () => {\n // Recovery path (a slot taken from under the visitor): drop the cached\n // month so the grid reloads from the network, not from the copy that\n // still lists the taken slot. Best-effort — a failure leaves the stale\n // grid up rather than blanking the card.\n await queryClient\n .invalidateQueries({ queryKey: meetingAvailabilityKey(apiBaseUrl, meetingId, monthOffset) })\n .catch(() => undefined);\n }, [queryClient, apiBaseUrl, meetingId, monthOffset]);\n\n const bookMutation = useMutation<BookingResult, never, Record<string, unknown>>({\n mutationFn: async payload => {\n try {\n const res = await contentFetch(`${apiBaseUrl}/api/meetings/book`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(payload),\n });\n const data = (await res.json().catch(() => ({}))) as Record<string, unknown>;\n if (res.ok) return { ok: true, confirmation: data as unknown as BookingConfirmation };\n const rawCode = typeof data.code === 'string' ? data.code : '';\n const code = (MEETING_BOOKING_ERROR_CODES as readonly string[]).includes(rawCode)\n ? (rawCode as MeetingBookingErrorCode)\n : 'TEMPORARILY_UNAVAILABLE';\n return { ok: false, code, message: typeof data.error === 'string' ? data.error : undefined };\n } catch {\n return { ok: false, code: 'TEMPORARILY_UNAVAILABLE' };\n }\n },\n // A booking is not a read — never retried, at any layer.\n retry: false,\n });\n\n // `isPending` alone would not stop a second submit dispatched in the same\n // tick as the first; the ref closes that window the way the pre-query\n // implementation did.\n // Holds the identity of the submit that owns the lock (`null` = idle) rather\n // than a bare boolean, so the release below — which runs after an await — can\n // check that the lock it clears is still its own rather than clearing\n // whatever happens to be there when the booking settles.\n const submittingRef = useRef<object | null>(null);\n const { mutateAsync } = bookMutation;\n const book = useCallback(\n async (payload: Record<string, unknown>): Promise<BookingResult> => {\n if (submittingRef.current !== null) return { ok: false, code: 'VALIDATION', message: BOOKING_IN_FLIGHT_MESSAGE };\n const submitToken: object = {};\n submittingRef.current = submitToken;\n try {\n return await mutateAsync(payload);\n } finally {\n if (submittingRef.current === submitToken) submittingRef.current = null;\n }\n },\n [mutateAsync],\n );\n\n return {\n availability: availabilityQuery.data ?? null,\n /** No availability for this month yet — the times column has nothing to show. */\n isLoadingAvailability: availabilityQuery.isPending,\n /** ANY request in flight, including a silent revalidation of cached data. */\n isFetchingAvailability: availabilityQuery.isFetching,\n availabilityError: availabilityQuery.error ? availabilityQuery.error.message : null,\n monthOffset,\n setMonthOffset,\n refetchAvailability,\n book,\n isSubmitting: bookMutation.isPending,\n } as const;\n}\n","import { z } from 'zod';\n\n/**\n * Meeting-booking wire contracts + validation factory.\n *\n * SERVER-SAFE tsup entry (no \"use client\" banner) with its OWN per-file\n * subpath (`./schemas/meeting-booking-schema`) — same pattern and reasons as\n * `schemas/contact-schema`: used by BOTH the lib's `<HubSpotMeetingScheduler>`\n * (client-side validation) AND the host's server-side booking route, which\n * REBUILDS the schema from the link's own fetched metadata and never trusts a\n * client-shaped instance. zod is an optional peer quarantined to per-subpath\n * verticals — do NOT re-export this module through any broad barrel.\n *\n * Every cross-boundary type for the scheduling feature lives HERE (the lib\n * owns the contract; hosts import this subpath directly, type-only where\n * possible).\n */\n\n// ---------------------------------------------------------------------------\n// Wire types\n// ---------------------------------------------------------------------------\n\n/**\n * Sanitized availability payload served by the host proxy\n * (`GET {apiBaseUrl}/api/meetings/availability?meeting=<id>&monthOffset=<n>`).\n *\n * NO timezone field on purpose: slot starts are absolute epoch-ms instants\n * (verified timezone-independent against the live API), upstream fetches are\n * UTC-pinned, and ALL zone rendering happens client-side. Slots must be\n * whitelist-copied from HubSpot's `linkAvailability` ONLY — never derived\n * from busy-time data.\n */\nexport interface MeetingAvailability {\n meetingId: string;\n monthOffset: number;\n hasMore: boolean;\n /** Offered durations in ms — HubSpot's native unit for the booking POST. */\n durationsMs: number[];\n /** Bookable slot start times (epoch ms), keyed by duration in ms. */\n slotsByDurationMs: Record<string, number[]>;\n formFields: MeetingFormField[];\n /** Verbatim whitelist-copy of HubSpot's `legalConsentOptions` when consent is enabled; null when disabled. */\n legalConsent: MeetingLegalConsent | null;\n /**\n * Who the visitor is meeting — whitelisted DISPLAY projection the host DAL\n * builds from its own people data (e.g. a profiles table matched\n * server-side). NEVER carries emails or busy-time data; optional so\n * existing hosts stay wire-compatible.\n */\n hosts?: MeetingHost[];\n}\n\n/** Display-only host identity for the scheduler's context panel. */\nexport interface MeetingHost {\n name: string;\n avatarUrl: string | null;\n /** Job title / role line under the name (null → omitted). */\n title: string | null;\n}\n\n/**\n * One scheduling link on the DIRECTORY wire (`GET /api/meetings`) — the\n * host-DAL whitelist projection consumed by `MeetingSchedulerDirectory` and\n * host pages. Never carries organizer emails/busy-time data.\n */\nexport interface SchedulingLink {\n id: string;\n /** The link's public HubSpot booking URL — escape-hatch target only. */\n link: string;\n /** HubSpot slug path — the row's in-app destination is `<basePath>/<slug>`. */\n slug: string;\n /** Audience group key (slugified audience label; `\"other\"` in scope=all). */\n purpose: string;\n title: string;\n description: string | null;\n kind: 'personal' | 'team';\n /** Display-only minutes projection (booking stays ms end-to-end). */\n durationsMinutes: number[];\n hosts: MeetingHost[];\n /** Earliest bookable slot (epoch ms) from the current-month payload. */\n nextAvailableMs: number | null;\n}\n\nexport interface SchedulingLinksPayload {\n purposes: Array<{ purpose: string; label: string; links: SchedulingLink[] }>;\n fetchedAt: string;\n}\n\n/**\n * One declared question as the host forwards it. `type` is HubSpot's\n * `fieldType` (the control HubSpot draws: `phonenumber`, `booleancheckbox`…)\n * VERBATIM, and `dataType` its `type` (the stored value's kind: `string`,\n * `enumeration`, `phone_number`…). Neither is a closed set: the widget resolves\n * ANY pair to a control through `resolveFormFieldControl`, so a type HubSpot\n * adds tomorrow renders as the nearest control instead of taking the form down.\n */\nexport interface MeetingFormField {\n name: string;\n label: string;\n type: string;\n /** HubSpot's property data type — the fallback when `type` is unknown. Optional: older hosts omit it. */\n dataType?: string;\n required: boolean;\n /** Option VALUES (what is submitted). */\n options?: string[];\n /** Option value → display label, when HubSpot's label differs from the value. */\n optionLabels?: Record<string, string>;\n}\n\n/**\n * HubSpot's consent copy, rendered VERBATIM by the widget (GDPR surface —\n * never edited, never summarized). Responses are keyed by\n * `communicationTypeId`.\n */\nexport interface MeetingLegalConsent {\n processingConsentText: string;\n processingConsentCheckboxLabel: string | null;\n communicationConsentText: string | null;\n communicationConsentCheckboxes: Array<{\n communicationTypeId: string;\n label: string;\n required: boolean;\n }>;\n privacyPolicyText: string | null;\n isLegitimateInterest: boolean;\n}\n\n/**\n * Whitelisted booking result returned by the host proxy — the THIRD HubSpot\n * payload that reaches a browser, so it gets the same whitelist-copy\n * treatment as the two GETs. Nothing organizer-derived.\n */\nexport interface BookingConfirmation {\n meetingId: string;\n title: string;\n startTimeMs: number;\n durationMs: number;\n}\n\n/**\n * Typed domain errors the booking route emits; the widget keys its recovery\n * UI off these. `SLOT_TAKEN` → refetch-and-recover; `TEMPORARILY_UNAVAILABLE`\n * → retry affordance; `MEETING_UNAVAILABLE` → daily ceiling exhausted\n * (escape hatch, not a retry timer); `LINK_GONE` → link deleted upstream;\n * `INVALID_EMAIL` → HubSpot rejected the attendee address (its\n * MeetingsBookingCreatedError.INVALID_EMAIL class — fake/unreachable\n * mailboxes), so the widget tells the visitor to fix the email instead of\n * blaming the slot or their other details.\n */\nexport const MEETING_BOOKING_ERROR_CODES = [\n 'SLOT_TAKEN',\n 'VALIDATION',\n 'INVALID_EMAIL',\n 'LINK_GONE',\n 'TEMPORARILY_UNAVAILABLE',\n 'MEETING_UNAVAILABLE',\n] as const;\n\n/**\n * Derived from the runtime array ABOVE — the array is the single source of\n * truth. The booking hook validates server codes against it at runtime, so a\n * code added only to a hand-written type would be silently coerced to\n * TEMPORARILY_UNAVAILABLE by every deployed widget (exactly how the\n * INVALID_EMAIL rollout mis-rendered on 2026-08-27: type extended, runtime\n * allowlist stale — an array annotation is not exhaustiveness-checked).\n */\nexport type MeetingBookingErrorCode = (typeof MEETING_BOOKING_ERROR_CODES)[number];\n\n// ---------------------------------------------------------------------------\n// Field-type vocabulary — ONE set drives the renderer AND the validator\n// ---------------------------------------------------------------------------\n\n/**\n * THE registry of CONTROLS the native form can draw — one entry per control,\n * and the ONLY place a control is declared. Everything else derives from it:\n * `SupportedFormFieldType` is its key union, `SUPPORTED_FORM_FIELD_TYPES` its\n * keys, and `makeBookingSchema` maps each answer through the entry's\n * validator. The widget's control table (`booking-form.tsx`) is a `Record`\n * over the same key union, so a control added here without a renderer is a\n * COMPILE error, not a silent gap.\n *\n * These are controls, NOT HubSpot's type vocabulary. A HubSpot question\n * reaches a control through `resolveFormFieldControl` (exact `fieldType`, then\n * its data type, then a generic fallback), so the open set of HubSpot types\n * maps onto this closed set of controls and no type is ever \"unsupported\".\n * The one thing that still falls back to HubSpot's own page is a REQUIRED\n * question no control can answer (a file upload) — see `blocksNativeBooking`.\n *\n * Every string control rides the wire as a STRING (HubSpot's book endpoint\n * takes `{ name, value: string }`); `checkbox` is the one boolean. `number` is\n * a decimal literal, `multiselect` HubSpot's `;`-joined enumeration value,\n * `date` an ISO `YYYY-MM-DD`.\n */\nexport interface FormFieldTypeSpec {\n /** The answer's wire shape, which also decides how required/optional wraps it. */\n kind: 'string' | 'boolean';\n /** Whether HubSpot publishes `options` for the type (the pickers). */\n hasOptions: boolean;\n /**\n * The validator for ONE answer. String types EXTEND `base`, which already\n * carries the required-ness (`min(1)` when required) — so \"X is required\" is\n * the first issue reported for an empty answer, ahead of the type's own rule.\n * Boolean types ignore it.\n */\n validator: (field: MeetingFormField, base: z.ZodString) => z.ZodTypeAny;\n /** The control's placeholder, derived from the field (the mock's \"Enter Company Name\"). */\n placeholder?: (field: MeetingFormField) => string;\n}\n\nconst optionValidator: FormFieldTypeSpec['validator'] = (field, base) =>\n base.refine(v => !v || (field.options ?? []).includes(v), {\n message: `Please choose a valid option for ${field.label}`,\n });\n\n/** The wire shape of a number answer — what the form canonicalises TO and the validator checks. */\nexport const DECIMAL_LITERAL_RE = /^-?\\d+(\\.\\d+)?$/;\n\n/** HubSpot's separator for a multi-value enumeration (`a;b;c`). */\nexport const MULTI_VALUE_SEPARATOR = ';';\n\n/** Split a multiselect answer into its values (empty string → none). */\nexport const splitMultiValue = (value: unknown): string[] =>\n typeof value === 'string' && value ? value.split(MULTI_VALUE_SEPARATOR) : [];\n\n/**\n * A phone answer: digits with the punctuation people actually type (`+1 (415)\n * 555-2671`, `020 7946 0958 ext. 12`). Deliberately a SHAPE check, not a\n * numbering-plan check — HubSpot stores the string as typed, and rejecting a\n * valid number we failed to parse is worse than accepting an odd one.\n */\nexport const PHONE_RE = /^\\+?[\\d\\s().\\-/]+(?:\\s*(?:x|ext\\.?)\\s*\\d+)?$/i;\nconst PHONE_MIN_DIGITS = 5;\nconst PHONE_MAX_DIGITS = 20;\nconst phoneDigits = (v: string) => v.replace(/\\D/g, '').length;\n\n/** A date answer as `<input type=\"date\">` emits it. */\nexport const ISO_DATE_RE = /^\\d{4}-\\d{2}-\\d{2}$/;\nconst isRealIsoDate = (v: string) => {\n if (!ISO_DATE_RE.test(v)) return false;\n const d = new Date(`${v}T00:00:00Z`);\n return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === v;\n};\n\nexport const FORM_FIELD_TYPES = {\n text: {\n kind: 'string',\n hasOptions: false,\n validator: (field, base) => base.max(1000, { message: `${field.label} is too long` }),\n placeholder: field => `Enter ${field.label}`,\n },\n textarea: {\n kind: 'string',\n hasOptions: false,\n validator: (field, base) => base.max(5000, { message: `${field.label} is too long` }),\n placeholder: field => `Enter text${field.required ? '' : ' (optional)'}`,\n },\n number: {\n kind: 'string',\n hasOptions: false,\n // Canonical decimal literal — the control normalises what the browser\n // accepts (`1e3`, `007`) to this before it is validated or sent.\n validator: (field, base) =>\n base\n .max(32, { message: `${field.label} is too long` })\n .regex(DECIMAL_LITERAL_RE, { message: `${field.label} must be a number` }),\n },\n phone: {\n kind: 'string',\n hasOptions: false,\n validator: (field, base) =>\n base\n .max(40, { message: `${field.label} is too long` })\n .refine(\n v => !v || (PHONE_RE.test(v) && phoneDigits(v) >= PHONE_MIN_DIGITS && phoneDigits(v) <= PHONE_MAX_DIGITS),\n { message: `Please enter a valid phone number for ${field.label}` },\n ),\n placeholder: field => `Enter ${field.label}`,\n },\n date: {\n kind: 'string',\n hasOptions: false,\n validator: (field, base) => base.refine(v => !v || isRealIsoDate(v), { message: `${field.label} must be a date` }),\n },\n select: { kind: 'string', hasOptions: true, validator: optionValidator },\n radio: { kind: 'string', hasOptions: true, validator: optionValidator },\n multiselect: {\n kind: 'string',\n hasOptions: true,\n validator: (field, base) =>\n base.refine(v => splitMultiValue(v).every(part => (field.options ?? []).includes(part)), {\n message: `Please choose valid options for ${field.label}`,\n }),\n },\n checkbox: { kind: 'boolean', hasOptions: false, validator: () => z.boolean() },\n} as const satisfies Record<string, FormFieldTypeSpec>;\n\nexport type SupportedFormFieldType = keyof typeof FORM_FIELD_TYPES;\n\nexport const SUPPORTED_FORM_FIELD_TYPES = Object.keys(FORM_FIELD_TYPES) as readonly SupportedFormFieldType[];\n\n// ---------------------------------------------------------------------------\n// HubSpot type → control resolution (open set in, closed set out)\n// ---------------------------------------------------------------------------\n\n/**\n * What a HubSpot question resolves to: a drawable control, `display` (a\n * read-only block — `html`, `calculation_*` — never rendered, never\n * submitted) or `unanswerable` (nothing the booking POST can carry, e.g. a\n * file upload).\n */\nexport type FormFieldResolution = SupportedFormFieldType | 'display' | 'unanswerable';\n\n/**\n * HubSpot `fieldType` spellings → resolution. Keys are lower-cased. A spelling\n * missing here is NOT an error: resolution falls through to the data type and\n * then to a generic control. Add an entry only to draw a BETTER control for a\n * type, never to make one \"work\".\n *\n * `checkbox` is absent on purpose: in HubSpot it is \"multiple checkboxes\" (an\n * enumeration with options), while older links sent it for a single yes/no —\n * `resolveFormFieldControl` decides by whether options came with it.\n */\nconst FIELD_TYPE_CONTROLS: Readonly<Record<string, FormFieldResolution>> = {\n text: 'text',\n textarea: 'textarea',\n number: 'number',\n select: 'select',\n radio: 'radio',\n booleancheckbox: 'checkbox',\n phonenumber: 'phone',\n date: 'date',\n html: 'display',\n file: 'unanswerable',\n};\n\n/** HubSpot property data types (`type`) → resolution, used when `fieldType` is unknown. */\nconst DATA_TYPE_CONTROLS: Readonly<Record<string, FormFieldResolution>> = {\n string: 'text',\n number: 'number',\n enumeration: 'select',\n bool: 'checkbox',\n date: 'date',\n datetime: 'date',\n phone_number: 'phone',\n};\n\nconst hasDeclaredOptions = (field: MeetingFormField) => (field.options?.length ?? 0) > 0;\n\n/**\n * THE one mapping from a HubSpot question to what the form does with it.\n * Total: every input yields a resolution, so an unrecognised type can never\n * make a link unbookable — at worst it is a text box. An option control with\n * no options degrades to text (nothing to pick from, but still answerable).\n */\nexport function resolveFormFieldControl(field: MeetingFormField): FormFieldResolution {\n const fieldType = (field.type ?? '').toLowerCase();\n const withOptions = hasDeclaredOptions(field);\n const usable = (r: FormFieldResolution | undefined): FormFieldResolution | undefined => {\n if (!r || r === 'display' || r === 'unanswerable') return r;\n return FORM_FIELD_TYPES[r].hasOptions && !withOptions ? 'text' : r;\n };\n\n if (fieldType === 'checkbox') return withOptions ? 'multiselect' : 'checkbox';\n if (fieldType.startsWith('calculation')) return 'display';\n // A control name resolves to itself, so resolution is IDEMPOTENT: the form\n // resolves once to draw, then hands the resolved fields to the schema\n // factory, which resolves again (the server rebuild resolves raw ones).\n const asControl = Object.prototype.hasOwnProperty.call(FORM_FIELD_TYPES, fieldType)\n ? (fieldType as SupportedFormFieldType)\n : undefined;\n return (\n usable(FIELD_TYPE_CONTROLS[fieldType] ?? asControl) ??\n usable(DATA_TYPE_CONTROLS[(field.dataType ?? '').toLowerCase()]) ??\n (withOptions ? 'select' : 'text')\n );\n}\n\n/**\n * @deprecated Kept for hosts built against the closed six-type registry (they\n * asked this to decide whether to forward `options`). Answers for any HubSpot\n * type through the resolver: true when the type resolves to an option control\n * once options are present. New hosts forward options whenever HubSpot sends\n * them and do not need this.\n */\nexport function formFieldTypeHasOptions(type: string): boolean {\n const control = resolveFormFieldControl({ name: '', label: '', type, required: false, options: ['_'] });\n return control !== 'display' && control !== 'unanswerable' && FORM_FIELD_TYPES[control].hasOptions;\n}\n\n/** @deprecated The option controls (see `formFieldTypeHasOptions`). */\nexport const FORM_FIELD_TYPES_WITH_OPTIONS: readonly SupportedFormFieldType[] = SUPPORTED_FORM_FIELD_TYPES.filter(\n type => FORM_FIELD_TYPES[type].hasOptions,\n);\n\n/**\n * Whether the question's `fieldType` is one the resolver maps EXPLICITLY — as\n * opposed to guessing from its data type or falling back to text. A host logs\n * the false case: the form still works, but a better control may be one table\n * entry away.\n */\nexport function isRecognisedFormFieldType(type: string): boolean {\n const t = (type ?? '').toLowerCase();\n return (\n t === 'checkbox' ||\n t.startsWith('calculation') ||\n Object.prototype.hasOwnProperty.call(FIELD_TYPE_CONTROLS, t) ||\n Object.prototype.hasOwnProperty.call(FORM_FIELD_TYPES, t)\n );\n}\n\n/** A declared question after resolution: `type` is the CONTROL, `hubspotType` what HubSpot sent. */\nexport type SupportedMeetingFormField = MeetingFormField & { type: SupportedFormFieldType; hubspotType?: string };\n\n/** One question → its drawable form, or null when the form does not draw it (display / unanswerable). */\nexport function normalizeFormField(\n field: MeetingFormField & { hubspotType?: string },\n): SupportedMeetingFormField | null {\n const control = resolveFormFieldControl(field);\n if (control === 'display' || control === 'unanswerable') return null;\n // A control that takes no options drops them (a `booleancheckbox` arrives\n // with HubSpot's `true`/`false` pair), which also keeps a re-resolution of\n // the result on the same control.\n const normalized: SupportedMeetingFormField = {\n ...field,\n type: control,\n hubspotType: field.hubspotType ?? field.type,\n };\n if (!FORM_FIELD_TYPES[control].hasOptions) {\n delete normalized.options;\n delete normalized.optionLabels;\n }\n return normalized;\n}\n\n/** The questions the native form draws and validates, in declared order. */\nexport function normalizeFormFields(fields: readonly MeetingFormField[]): SupportedMeetingFormField[] {\n return fields.flatMap(f => normalizeFormField(f) ?? []);\n}\n\n/** Whether the native form draws this question (false for display blocks and unanswerable questions). */\nexport function isSupportedFormField(field: MeetingFormField): boolean {\n return normalizeFormField(field) !== null;\n}\n\n/**\n * The ONLY reason left to hand a link to HubSpot's own page: a REQUIRED\n * question no control can answer. An optional one is simply left out — the\n * visitor can still book, and HubSpot does not require it.\n */\nexport function blocksNativeBooking(field: MeetingFormField): boolean {\n return field.required && resolveFormFieldControl(field) === 'unanswerable';\n}\n\n/**\n * The scheduler's fixed identity fields. HubSpot's book endpoint takes them\n * TOP-LEVEL (`firstName`, `lastName`, `email`), its own booking page hardcodes\n * them, and the link's `formFields` never lists them — so they are data HERE,\n * rendered by the widget through the SAME control path as every declared\n * question, rather than three hand-written blocks. `inputType`/`autoComplete`\n * are the browser hints a text control takes; the wire and the validator do\n * not see them.\n */\nexport interface BuiltInBookingField extends MeetingFormField {\n type: 'text';\n required: true;\n inputType?: 'email';\n autoComplete: string;\n /** Own placeholder. Omitted, the control derives one from the label (the registry's rule). */\n placeholder?: string;\n /** The wire's own required/format message — kept verbatim from the schema it replaced. */\n requiredMessage: string;\n}\n\nexport const BUILT_IN_BOOKING_FIELDS = [\n {\n name: 'firstName',\n label: 'First Name',\n type: 'text',\n required: true,\n autoComplete: 'given-name',\n requiredMessage: 'First name is required',\n },\n {\n name: 'lastName',\n label: 'Last Name',\n type: 'text',\n required: true,\n autoComplete: 'family-name',\n requiredMessage: 'Last name is required',\n },\n {\n name: 'email',\n label: 'Email',\n type: 'text',\n required: true,\n inputType: 'email',\n autoComplete: 'email',\n placeholder: 'username@mail.com',\n requiredMessage: 'Please enter a valid email address',\n },\n] as const satisfies readonly BuiltInBookingField[];\n\n/** `'firstName' | 'lastName' | 'email'` — derived from the array, never restated. */\nexport type BuiltInBookingFieldName = (typeof BUILT_IN_BOOKING_FIELDS)[number]['name'];\n\n/** The registry entry for a supported type, widened to the spec so optional\n * members (`placeholder`) are readable without narrowing on the union. */\nexport function fieldTypeSpec(type: SupportedFormFieldType): FormFieldTypeSpec {\n return FORM_FIELD_TYPES[type];\n}\n\n/** The wire validator for one identity field, from ITS declaration above. */\nfunction identityValidator(name: BuiltInBookingFieldName) {\n const field: BuiltInBookingField | undefined = BUILT_IN_BOOKING_FIELDS.find(f => f.name === name);\n if (!field) throw new Error(`Unknown built-in booking field: ${name}`);\n // The field's own message FIRST: an empty value must fail `min`/`email` with\n // the wire's copy, not a length cap it cannot have hit.\n const tooLong = { message: `${field.label} is too long` };\n return field.inputType === 'email'\n ? z.string().email({ message: field.requiredMessage }).max(255, tooLong)\n : z.string().min(1, { message: field.requiredMessage }).max(255, tooLong);\n}\n\n// ---------------------------------------------------------------------------\n// Validators (single home — client widget and server rebuild both use these)\n// ---------------------------------------------------------------------------\n\nconst IANA_TZ_RE = /^(?:UTC|[A-Za-z_]+(?:\\/[A-Za-z0-9_+-]+)+)$/;\n\n/**\n * IANA timezone check. Shape prefilter, then the authoritative resolution\n * test: `Intl.DateTimeFormat` throws on unknown zones. NOT\n * `Intl.supportedValuesOf('timeZone')` — that list excludes `'UTC'` itself\n * (verified in Node), which is a legitimate booking zone. Reject, never coerce.\n */\nexport function isValidIanaTimezone(tz: string): boolean {\n if (!IANA_TZ_RE.test(tz)) return false;\n try {\n new Intl.DateTimeFormat('en-US', { timeZone: tz });\n return true;\n } catch {\n return false;\n }\n}\n\n/** BCP-47 locale shape check via `Intl.getCanonicalLocales`. Reject, never coerce. */\nexport function isValidBcp47Locale(locale: string): boolean {\n try {\n return Intl.getCanonicalLocales(locale).length > 0;\n } catch {\n return false;\n }\n}\n\n// ---------------------------------------------------------------------------\n// Schema factory\n// ---------------------------------------------------------------------------\n\n/**\n * Build the booking-form schema for ONE link's declared questions + consent.\n *\n * A factory (not a static schema) because per-link required questions cannot\n * be expressed statically. The widget builds it from the availability payload\n * it rendered; the server REBUILDS it from the link's own fetched metadata —\n * required-consent enforcement flows from this rebuild, not a parallel check.\n *\n * Deliberately NOT `.strict()`: the humanity-signal fields\n * (`HUMANITY_SIGNAL_KEYS` from `utils/humanity-signals`) ride alongside in\n * the same POST body (read raw by the host's bot gate BEFORE parsing) and are\n * stripped server-side before anything reaches HubSpot. zod's default\n * unknown-key stripping means the parsed output never contains them.\n *\n * `timezone`/`locale` are POST-only presentation fields (the invite renders\n * in the visitor's local time) — this schema is the ONLY place a\n * client-supplied zone is accepted; the availability path is UTC-pinned.\n */\nfunction buildBookingSchema<TStart extends z.ZodTypeAny, TDuration extends z.ZodTypeAny, TZone extends z.ZodTypeAny>(\n formFields: MeetingFormField[],\n legalConsent: MeetingLegalConsent | null,\n // Passed IN rather than switched on a boolean: a `deferSlot: boolean`\n // parameter cannot be narrowed at type level, so every ternary inside would\n // infer a union and widen `startTimeMs`/`durationMs` to `number | null |\n // undefined` for BOTH schemas — including the server's wire type.\n slot: { startTimeMs: TStart; durationMs: TDuration; timezone: TZone },\n) {\n const answers: Record<string, z.ZodTypeAny> = {};\n // Resolved HERE, not by the caller: the server rebuild passes the link's raw\n // questions, so client and server resolve every type through the same rule.\n // Display blocks and unanswerable optional questions carry no answer.\n const drawn = normalizeFormFields(formFields);\n for (const field of drawn) {\n const spec: FormFieldTypeSpec = FORM_FIELD_TYPES[field.type];\n // required/optional wrapping follows the answer's wire KIND, not its type:\n // a boolean is true-or-absent, a string is non-empty-or-empty. For strings\n // the required check is the FIRST rule on the chain, so an empty answer\n // reports \"is required\" rather than the type's own message.\n let validator: z.ZodTypeAny;\n if (spec.kind === 'boolean') {\n validator = field.required ? z.literal(true, { message: `${field.label} is required` }) : z.boolean().optional();\n } else {\n const base = field.required ? z.string().min(1, { message: `${field.label} is required` }) : z.string();\n validator = spec.validator(field, base);\n if (!field.required) validator = validator.optional().or(z.literal(''));\n }\n answers[field.name] = validator;\n }\n\n const requiredConsentIds = (legalConsent?.communicationConsentCheckboxes ?? [])\n .filter(c => c.required)\n .map(c => c.communicationTypeId);\n\n // A required answer cannot be enforced by an `.optional()` parent object —\n // omitting the `formFields` key entirely would skip every per-question\n // rule. When the link declares at least one required supported question,\n // the object itself is required.\n const hasRequiredAnswers = drawn.some(f => f.required);\n const answersObject = z.object(answers);\n\n return (\n z\n .object({\n meetingId: z.string().min(1),\n startTimeMs: slot.startTimeMs,\n durationMs: slot.durationMs,\n // The identity trio's rules come from BUILT_IN_BOOKING_FIELDS — the\n // keys stay literal so the payload type keeps its named properties.\n firstName: identityValidator('firstName'),\n lastName: identityValidator('lastName'),\n email: identityValidator('email'),\n timezone: slot.timezone,\n locale: z.string().refine(isValidBcp47Locale, { message: 'Invalid locale' }).optional(),\n // Plain `.optional()` (no `.default()`) so zod's input and output types\n // match — react-hook-form's zodResolver needs them identical, and the\n // server handles `undefined` explicitly anyway. REQUIRED when the link\n // declares required questions (see `hasRequiredAnswers` above).\n formFields: hasRequiredAnswers ? answersObject : answersObject.optional(),\n legalConsentResponses: z\n .array(z.object({ communicationTypeId: z.string(), consented: z.boolean() }))\n .optional(),\n })\n // Object-level rule: `.refine` on an `.optional()` field is skipped when\n // the field is absent — required consents must reject even on a payload\n // that omits the array entirely.\n .superRefine((data, ctx) => {\n const responses = data.legalConsentResponses ?? [];\n const ok = requiredConsentIds.every(id =>\n responses.some(r => r.communicationTypeId === id && r.consented === true),\n );\n if (!ok) {\n ctx.addIssue({\n code: z.ZodIssueCode.custom,\n path: ['legalConsentResponses'],\n message: 'Required consent checkboxes must be accepted',\n });\n }\n })\n );\n}\n\n/** The slot's wire validators — ONE declaration for the strict and the deferred schema. */\nconst SLOT_INSTANT = z.number().int().positive();\nconst TIMEZONE = z.string().refine(isValidIanaTimezone, { message: 'Invalid timezone' });\n\n/**\n * The STRICT schema — the wire contract. The server rebuilds it from the link's\n * own fetched metadata and validates every booking against it; a slot and a\n * duration are not optional on anything that reaches HubSpot.\n */\nexport function makeBookingSchema(formFields: MeetingFormField[], legalConsent: MeetingLegalConsent | null) {\n return buildBookingSchema(formFields, legalConsent, {\n startTimeMs: SLOT_INSTANT,\n durationMs: SLOT_INSTANT,\n timezone: TIMEZONE,\n });\n}\n\n/**\n * The DEFERRED schema — same fields, with the slot/duration/timezone triple\n * relaxed. `flow=\"details-first\"` collects answers BEFORE a slot exists, so the\n * form validates against this and the parent re-attaches the authoritative\n * values at POST time.\n *\n * Two named exports over one private builder rather than an overload or an\n * options flag: `MeetingBookingPayload` is `z.infer<ReturnType<...>>`, and\n * either of those alternatives would widen `startTimeMs`/`durationMs` to\n * `number | undefined` for EVERY consumer — including the server's\n * `durationsMs.includes(data.durationMs)`.\n */\nexport function makeDeferredBookingSchema(formFields: MeetingFormField[], legalConsent: MeetingLegalConsent | null) {\n // `.nullish()`, not `.optional()`: the widget passes these straight into\n // `defaultValues`, and details-first has `timezone === null` on the server\n // render and the first client render (zone resolution is post-hydration).\n // Accepting only `undefined` would reject a field with no rendered control\n // and no FieldWrapper — the submit button silently dead, type-check clean.\n return buildBookingSchema(formFields, legalConsent, {\n startTimeMs: SLOT_INSTANT.nullish(),\n durationMs: SLOT_INSTANT.nullish(),\n timezone: TIMEZONE.nullish(),\n });\n}\n\n/** The wire payload. Pinned to the STRICT builder — see above. */\nexport type MeetingBookingPayload = z.infer<ReturnType<typeof makeBookingSchema>>;\n\n/**\n * The form's value type, used as the `useForm` generic in BOTH flows: a relaxed\n * resolver is not assignable to `Resolver<MeetingBookingPayload>`, and mixing\n * the two survives only on TS's bivariant method-parameter check.\n */\nexport type BookingFormValues = z.infer<ReturnType<typeof makeDeferredBookingSchema>>;\n","'use client';\n\nimport { zodResolver } from '@hookform/resolvers/zod';\nimport { Fragment, useEffect, useMemo, useState } from 'react';\nimport type { FormEvent, ReactNode, Ref } from 'react';\nimport { useForm, Controller } from 'react-hook-form';\nimport type { Control, Path, UseFormRegister } from 'react-hook-form';\nimport { useRescuedForm } from '../../hooks/use-rescued-form';\nimport {\n BUILT_IN_BOOKING_FIELDS,\n type BuiltInBookingFieldName,\n DECIMAL_LITERAL_RE,\n fieldTypeSpec,\n makeDeferredBookingSchema,\n MULTI_VALUE_SEPARATOR,\n normalizeFormFields,\n splitMultiValue,\n type BuiltInBookingField,\n type MeetingAvailability,\n type SupportedFormFieldType,\n type MeetingFormField,\n type SupportedMeetingFormField,\n type BookingFormValues,\n} from '../../schemas/meeting-booking-schema';\nimport { cn } from '../../utils/cn';\nimport type { FormRescueDefinition } from '../../utils/form-rescue';\nimport { HUBSPOT_DO_NOT_COLLECT_FORM_PROPS } from '../../utils/hubspot-collected-forms';\nimport {\n Button,\n FieldWrapper,\n Input,\n Textarea,\n Label,\n Select,\n SelectTrigger,\n SelectValue,\n SelectContent,\n SelectItem,\n Checkbox,\n CheckboxBlock,\n RadioGroup,\n RadioGroupItem,\n Skeleton,\n} from '../ui';\nimport { HoneypotField } from '../ui/honeypot-field';\n\n/**\n * One field in a host-supplied row. `name` is a built-in (`email`, `firstName`,\n * `lastName`) or a HubSpot-declared question's `name`.\n */\nexport interface BookingFieldSlot {\n /** Built-ins autocomplete; a HubSpot question's `name` is whatever the link declares. */\n name: BuiltInBookingFieldName | (string & NonNullable<unknown>);\n /** Columns out of four at `md` and up. Defaults to an even split of the row. */\n span?: BookingFieldSpan;\n}\n\n/** Columns out of `GRID_COLUMNS` at `md` and up — the keys of `SPAN_CLASS`. */\nexport type BookingFieldSpan = keyof typeof SPAN_CLASS;\n\nexport type BookingFieldRow = BookingFieldSlot[];\n\n/**\n * A HOST-supplied consent row — the block the waitlist form draws for its SMS\n * consent, here for \"I agree to the Privacy Policy and to be contacted\". It is\n * the host's copy and the host's link, so it is a prop, not HubSpot metadata;\n * HubSpot's own `legalConsent` block (verbatim, declared on the link) renders\n * alongside when the link carries one.\n *\n * A client-side gate, exactly like the waitlist's: Continue is refused until it\n * is ticked, and the tick rides in the payload as `hostConsent` — which the\n * host's book route strips as an undeclared key. It is not a HubSpot consent\n * record; declare `legalConsent` on the link when one is required.\n */\nexport interface BookingFormConsent {\n label: ReactNode;\n description?: ReactNode;\n /** Shown under the row when Continue is pressed unticked. */\n errorMessage?: string;\n}\n\n/** The form's vertical rhythm — one stack for the loaded form and its skeleton. */\nconst FORM_STACK = 'flex flex-col gap-[var(--spacing-system-l)]';\n\n/** The row grid: two columns on a phone, four from `md`. The row gap is one step\n * wider than the column gap because field messages hang ~16px below their\n * control and would print over the next row's label at the column gap. */\nconst ROW_GRID = 'grid grid-cols-2 gap-x-[var(--spacing-system-m)] gap-y-[var(--spacing-system-lf)] md:grid-cols-4';\n\n/** The wire key of the host consent tick (stripped server-side, never sent to HubSpot). */\nconst HOST_CONSENT_KEY = 'hostConsent';\n\n/** The row grid's column count at `md` and up. `ROW_GRID`'s `md:grid-cols-4`\n * and `SPAN_CLASS` are its Tailwind twins (literal so the scanner sees them). */\nconst GRID_COLUMNS = 4;\n\n/** Static so Tailwind's scanner sees every class — a template built from a\n * runtime span would compile to nothing. */\nconst SPAN_CLASS = {\n 1: 'md:col-span-1',\n 2: 'md:col-span-2',\n 3: 'md:col-span-3',\n 4: 'md:col-span-4',\n} as const;\n\n/** One field's skeleton: label + control footprint — the CSS twin of `FieldWrapper`'s\n * label row over `Input`'s `h-11 md:h-12` (the other skeleton heights below are\n * the same kind of twin: the textarea, the consent row, the `Button`). */\nconst FIELD_SKELETON_CLASS = 'h-[4.75rem] w-full';\n\n/** The submit copy every standalone `BookingForm` gets; the slot-first preset reads the same constant. */\nexport const DEFAULT_SUBMIT_LABEL = 'Confirm Booking';\n\n/** The footer with a note (details-first): note beside the button, wrapping on a phone. */\nconst FOOTER_ROW_CLASS = 'flex flex-wrap items-center justify-between gap-[var(--spacing-system-m)]';\n/** The bare footer: the button stays LEFT, where the form's reading order ends. */\nconst FOOTER_BARE_CLASS = 'flex';\n/** The submit's width when a note sits beside it. */\nconst SUBMIT_WIDE_CLASS = 'md:w-60';\n\n/** The built-in layout when a host passes no rows — what the skeleton draws for it. */\nconst DEFAULT_FIELD_ROWS: BookingFieldRow[] = [[{ name: 'email' }], [{ name: 'firstName' }, { name: 'lastName' }]];\n\n/** The even split of four columns over `count` slots; a remainder goes to the\n * leading slots (three slots → 2/1/1), so a row never leaves a trailing gap. */\nexport const evenSpan = (count: number, index: number): BookingFieldSpan => {\n const base = Math.floor(GRID_COLUMNS / Math.max(1, count));\n const extra = GRID_COLUMNS - base * count;\n return Math.min(GRID_COLUMNS, Math.max(1, index < extra ? base + 1 : base)) as BookingFieldSpan;\n};\n\n/** ONE column rule for a slot, read by the loaded form and its skeleton: a\n * two-field row stays side by side on a phone — the rule the built-in name pair\n * has always followed: two short fields cost one line instead of two on the\n * layout that can least afford them — and the span applies from `md`. */\nconst slotColumnClass = (row: BookingFieldRow, slot: BookingFieldSlot, index: number): string =>\n cn(row.length === 2 ? 'col-span-1' : 'col-span-2', SPAN_CLASS[slot.span ?? evenSpan(row.length, index)]);\n\n/** What one control needs: the field, its DOM id, where it registers in the\n * form, and the form's own register/control. */\ninterface ControlArgs {\n /** A declared question, or a built-in carrying its control hints (everything on the declaration that is not wire data). */\n field: SupportedMeetingFormField & Partial<Omit<BuiltInBookingField, keyof MeetingFormField | 'requiredMessage'>>;\n id: string;\n /** Top-level for the built-ins (`email`), `formFields.<name>` for declared questions. */\n registerName: string;\n /** The field's current validation message, so the control can say `aria-invalid`. */\n error?: string;\n register: UseFormRegister<BookingFormValues>;\n control: Control<BookingFormValues>;\n}\n\n/** A built-in carries its own placeholder; a declared question gets the one its\n * TYPE derives (the registry's `placeholder`), so no copy lives here. */\nconst placeholderFor = (field: ControlArgs['field']): string | undefined =>\n field.placeholder ?? fieldTypeSpec(field.type).placeholder?.(field);\n\n/** An option's display text: HubSpot's label when it differs from the submitted value. */\nconst optionLabel = (field: ControlArgs['field'], value: string): string => field.optionLabels?.[value] ?? value;\n\n/** A free-text answer is sent without the whitespace a paste drags in. */\nconst trimmed = (v: unknown): string => String(v ?? '').trim();\n\n/** `<input type=\"number\">` accepts `1e3` and ` 12 `; the wire wants the\n * decimal literal the validator checks. A value ALREADY in that shape passes\n * verbatim (`007` included) — a long integer or a tiny decimal must not be\n * reshaped through a float — and one that cannot be brought into it is left\n * for the validator's own message. */\nconst canonicalNumber = (v: unknown): string => {\n const s = String(v ?? '').trim();\n if (s === '' || DECIMAL_LITERAL_RE.test(s)) return s;\n const n = Number(s);\n if (!Number.isFinite(n)) return s;\n const canonical = String(n);\n return DECIMAL_LITERAL_RE.test(canonical) ? canonical : s;\n};\n\n/**\n * ONE control per registry type. A `Record` over `SupportedFormFieldType` on\n * purpose: `FORM_FIELD_TYPES` (the schema module) is the single place a type is\n * declared, and this table cannot compile without an entry for each — so the\n * validator and the renderer can never disagree about what is supported.\n * Every control states `aria-invalid` from the field's message and carries\n * `required`/`aria-required` from the field, so the accent asterisk is never\n * the only signal.\n */\nconst FIELD_CONTROLS: Record<SupportedFormFieldType, (args: ControlArgs) => ReactNode> = {\n text: ({ field, id, registerName, error, register }) => (\n <Input\n id={id}\n type={field.inputType ?? 'text'}\n required={field.required}\n aria-invalid={Boolean(error)}\n autoComplete={field.autoComplete}\n placeholder={placeholderFor(field)}\n {...register(registerName as never)}\n />\n ),\n textarea: ({ field, id, registerName, error, register }) => (\n <Textarea\n id={id}\n required={field.required}\n aria-invalid={Boolean(error)}\n placeholder={placeholderFor(field)}\n {...register(registerName as never)}\n />\n ),\n number: ({ field, id, registerName, error, register }) => (\n <Input\n id={id}\n type=\"number\"\n inputMode=\"decimal\"\n step=\"any\"\n required={field.required}\n aria-invalid={Boolean(error)}\n {...register(registerName as never, { setValueAs: canonicalNumber })}\n />\n ),\n phone: ({ field, id, registerName, error, register }) => (\n <Input\n id={id}\n type=\"tel\"\n inputMode=\"tel\"\n autoComplete=\"tel\"\n required={field.required}\n aria-invalid={Boolean(error)}\n placeholder={placeholderFor(field)}\n {...register(registerName as never, { setValueAs: trimmed })}\n />\n ),\n date: ({ field, id, registerName, error, register }) => (\n // The native picker emits exactly the wire shape (`YYYY-MM-DD`).\n <Input\n id={id}\n type=\"date\"\n required={field.required}\n aria-invalid={Boolean(error)}\n {...register(registerName as never)}\n />\n ),\n select: ({ field, id, registerName, error, control }) => (\n <Controller\n control={control}\n name={registerName as never}\n render={({ field: rhf }) => (\n <Select value={rhf.value ?? ''} onValueChange={rhf.onChange}>\n <SelectTrigger id={id} aria-required={field.required || undefined} aria-invalid={Boolean(error)}>\n <SelectValue placeholder=\"Select…\" />\n </SelectTrigger>\n <SelectContent>\n {(field.options ?? []).map(opt => (\n <SelectItem key={opt} value={opt}>\n {optionLabel(field, opt)}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n )}\n />\n ),\n radio: ({ field, id, registerName, error, control }) => (\n <Controller\n control={control}\n name={registerName as never}\n render={({ field: rhf }) => (\n <RadioGroup\n value={rhf.value ?? ''}\n onValueChange={rhf.onChange}\n aria-required={field.required || undefined}\n aria-invalid={Boolean(error)}\n >\n {(field.options ?? []).map(opt => (\n <div key={opt} className=\"flex items-center gap-[var(--spacing-system-xs)]\">\n <RadioGroupItem id={`${id}-${opt}`} value={opt} />\n <Label htmlFor={`${id}-${opt}`}>{optionLabel(field, opt)}</Label>\n </div>\n ))}\n </RadioGroup>\n )}\n />\n ),\n multiselect: ({ field, id, registerName, error, control }) => (\n // HubSpot's \"multiple checkboxes\": the answer is its `;`-joined value list,\n // held in that wire shape so the validator and the POST see one string.\n <Controller\n control={control}\n name={registerName as never}\n render={({ field: rhf }) => {\n const selected = splitMultiValue(rhf.value);\n const toggle = (opt: string, on: boolean) => {\n const next = (field.options ?? []).filter(o => (o === opt ? on : selected.includes(o)));\n rhf.onChange(next.join(MULTI_VALUE_SEPARATOR));\n };\n return (\n <div id={id} role=\"group\" className=\"flex flex-col gap-[var(--spacing-system-xs)]\">\n {(field.options ?? []).map(opt => (\n <div key={opt} className=\"flex items-center gap-[var(--spacing-system-xs)]\">\n <Checkbox\n id={`${id}-${opt}`}\n checked={selected.includes(opt)}\n onCheckedChange={v => toggle(opt, v === true)}\n aria-invalid={Boolean(error)}\n />\n <Label htmlFor={`${id}-${opt}`}>{optionLabel(field, opt)}</Label>\n </div>\n ))}\n </div>\n );\n }}\n />\n ),\n checkbox: ({ field, id, registerName, error, control }) => (\n <Controller\n control={control}\n name={registerName as never}\n render={({ field: rhf }) => (\n // The question's Label above already carries the text and binds to\n // this id — a second inline label would double the visible text AND\n // the accessible name.\n <div className=\"flex items-center gap-[var(--spacing-system-xs)]\">\n <Checkbox\n id={id}\n checked={Boolean(rhf.value)}\n onCheckedChange={v => rhf.onChange(v === true)}\n aria-required={field.required || undefined}\n aria-invalid={Boolean(error)}\n />\n </div>\n )}\n />\n ),\n};\n\n/** `firstName` → `ms-first-name`; the ids the built-ins have always had. */\nconst builtInId = (name: string) => `ms-${name.replace(/[A-Z]/g, m => `-${m.toLowerCase()}`)}`;\n\nexport interface BookingFormProps {\n availability: MeetingAvailability;\n meetingId: string;\n /** Absent in `deferSlot` mode — the slot is chosen AFTER these answers. */\n startTimeMs?: number;\n durationMs?: number;\n /** IANA zone the confirmation/invite should render in (parent-resolved).\n * Null until hydration, which is why `deferSlot` relaxes it. */\n timezone: string | null;\n /**\n * Collect-only mode (`flow=\"details-first\"`): validate against the deferred\n * schema, and hand the values up instead of POSTing. The parent re-attaches\n * the authoritative slot/duration/timezone when it submits.\n */\n deferSlot?: boolean;\n /** Repopulates the form on a remount — the back edge, or an error return. */\n initialValues?: Record<string, unknown>;\n /** Defaults to \"Confirm Booking\"; details-first says \"Continue\". */\n submitLabel?: string;\n /** Small print beside the submit (details-first sets expectations). */\n footerNote?: string;\n /**\n * Re-arrange the fields into rows instead of the built-in order (email, the\n * name pair, then each declared question full width). Reuse, not a fork: the\n * SAME controls, validation, consent block and honeypot — only their grouping\n * changes, so a host can match a mock without owning the machine.\n *\n * A slot naming nothing is skipped; a declared question no row claims is\n * appended full width. Both are deliberate — see `slotNode`/`unplacedFields`.\n */\n fieldRows?: BookingFieldRow[];\n /** Host-supplied consent row, rendered after the fields — see `BookingFormConsent`. */\n consent?: BookingFormConsent;\n isSubmitting: boolean;\n onSubmit: (payload: Record<string, unknown>) => Promise<void>;\n /** From useHumanitySignals — parent owns the instance so it can resetSignals(). */\n honeypotInputProps: { ref: Ref<HTMLInputElement>; name: string };\n getSignals: () => Record<string, string | number>;\n /** Form rescue for this form (`RESCUE_FORMS.meetingBooking`); `null` or omitted saves nothing.\n * The attempt's keys ride the payload handed to `onSubmit`, so the parent closes it after\n * the booking commits (`completeFormRescue`), in details-first too, where this form has\n * unmounted by then. */\n rescue?: FormRescueDefinition | null;\n}\n\n/**\n * BookingForm — attendee details + the link's declared custom questions +\n * verbatim legal-consent copy. ContactForm's scaffolding (react-hook-form +\n * zodResolver + lib field primitives); a sibling rather than a `<ContactForm>`\n * configuration because of the dynamic HubSpot `formFields`, the per-checkbox\n * consent model, and the first/last-name field model — none expressible via\n * `hideFields`/`extraTopField`.\n *\n * Bot protection is LOAD-BEARING: without the humanity signals in the body,\n * the host's `verifyHuman` degrades to first-party-only BotID (fails open for\n * external embedders). Honeypot + elapsed-ms are merged into the POST at\n * submit; the parent calls `resetSignals()` after a SLOT_TAKEN refetch so a\n * legitimate retry isn't flagged too-fast.\n */\nexport function BookingForm({\n availability,\n meetingId,\n startTimeMs,\n durationMs,\n timezone,\n deferSlot = false,\n initialValues,\n submitLabel,\n footerNote,\n fieldRows,\n consent,\n isSubmitting,\n onSubmit,\n honeypotInputProps,\n getSignals,\n rescue = null,\n}: BookingFormProps) {\n const { formFields, legalConsent } = availability;\n // Every declared question resolved to a control (`resolveFormFieldControl`) —\n // an unfamiliar HubSpot type is drawn as its nearest control, never dropped\n // silently and never a reason to hide the form.\n const supportedFields = useMemo(() => normalizeFormFields(formFields), [formFields]);\n // The DEFERRED schema in both flows: it is the wider of the two, and a strict\n // resolver is not assignable to `Resolver<BookingFormValues>`. The strict\n // schema is the server's contract — see `makeBookingSchema`'s docblock.\n const schema = useMemo(\n () => makeDeferredBookingSchema(supportedFields, legalConsent),\n [supportedFields, legalConsent],\n );\n\n const consentDefaults = useMemo(\n () =>\n (legalConsent?.communicationConsentCheckboxes ?? []).map(c => ({\n communicationTypeId: c.communicationTypeId,\n consented: false,\n })),\n [legalConsent],\n );\n\n const {\n register,\n control,\n handleSubmit,\n setValue,\n getValues,\n watch,\n formState: { errors },\n } = useForm<BookingFormValues>({\n resolver: zodResolver(schema),\n defaultValues: {\n meetingId,\n // Genuinely OMITTED, not present-as-undefined, when the slot is deferred.\n ...(deferSlot ? {} : { startTimeMs, durationMs }),\n timezone,\n locale: typeof navigator !== 'undefined' ? navigator.language : undefined,\n firstName: '',\n lastName: '',\n email: '',\n formFields: {},\n legalConsentResponses: consentDefaults,\n // A remount (back edge, or an error return) would otherwise lose every\n // answer — `defaultValues` is snapshotted once and never re-read.\n ...(initialValues ?? {}),\n } as BookingFormValues,\n });\n\n const priorConsents = initialValues?.legalConsentResponses as typeof consentDefaults | undefined;\n\n // The host consent lives OUTSIDE react-hook-form (it is not a wire field), so\n // a remount restores it from the stash the same way the fields come back.\n const [consented, setConsented] = useState(initialValues?.[HOST_CONSENT_KEY] === true);\n const [consentError, setConsentError] = useState<string | null>(null);\n const consentMissing = Boolean(consent) && !consented;\n\n // The seeded availability is refetched immediately on mount, so the link's\n // consent set can change WHILE this form is open. Reconcile rather than\n // remount: carry each `consented` across by id and default new ids to false.\n // A wholesale reset would drop what the visitor has typed; leaving it alone\n // would make a newly-declared required consent impossible to satisfy.\n useEffect(() => {\n // The LIVE answers first (what the visitor has ticked since mount), the\n // stash only as the seed for a fresh remount — reading the stash alone\n // would undo every tick made after mount when the consent set refreshes.\n const current = (getValues('legalConsentResponses') ?? priorConsents ?? []) as typeof consentDefaults;\n setValue(\n 'legalConsentResponses',\n consentDefaults.map(next => ({\n ...next,\n consented: current.find(r => r.communicationTypeId === next.communicationTypeId)?.consented ?? false,\n })),\n { shouldDirty: false },\n );\n }, [consentDefaults, priorConsents, getValues, setValue]);\n\n // Form rescue: the built-ins by name, the declared questions under `formFields.<name>`.\n const rescueFields = useMemo(\n () => [...BUILT_IN_BOOKING_FIELDS.map(field => field.name), ...supportedFields.map(field => field.name)],\n [supportedFields],\n );\n const formRescue = useRescuedForm(\n { watch, getValues, setValue },\n {\n rescue,\n fields: rescueFields,\n getSignals,\n fieldPath: name =>\n (BUILT_IN_BOOKING_FIELDS.some(field => field.name === name)\n ? name\n : `formFields.${name}`) as Path<BookingFormValues>,\n },\n );\n\n const submitValid = handleSubmit(async data => {\n if (consentMissing) return; // the error is already on screen — see `submit`\n if (deferSlot) {\n // Collect-only. The PARENT's `onSubmit` reads the humanity signals, and\n // must do so synchronously in this call — this form and its honeypot are\n // still mounted here, and `getSignals()` reads a detached ref once they\n // unmount, which would silently disable the decoy.\n await onSubmit({ ...data, meetingId, [HOST_CONSENT_KEY]: consented, ...formRescue.submitFields() });\n return;\n }\n await onSubmit({\n ...data,\n meetingId,\n [HOST_CONSENT_KEY]: consented,\n startTimeMs,\n durationMs,\n timezone,\n ...getSignals(),\n ...formRescue.submitFields(),\n });\n });\n\n // Consent is checked BEFORE the resolver runs, not inside the valid branch,\n // so an unticked box and an empty field are reported together rather than\n // one submit apart.\n const submit = (event: FormEvent<HTMLFormElement>) => {\n if (consentMissing) setConsentError(consent?.errorMessage ?? 'Please agree to continue.');\n return submitValid(event);\n };\n\n const fieldError = (name: string): string | undefined => {\n const err = (errors.formFields as Record<string, { message?: string }> | undefined)?.[name];\n return err?.message;\n };\n\n /** ONE render path for every field — built-in or declared — so the default\n * order below and any host-supplied `fieldRows` compose the same controls. */\n const renderField = (\n field: ControlArgs['field'],\n where: { id: string; registerName: string; error?: string },\n ): ReactNode => (\n <FieldWrapper key={field.name} label={field.label} htmlFor={where.id} required={field.required} error={where.error}>\n {FIELD_CONTROLS[field.type]({\n field,\n id: where.id,\n registerName: where.registerName,\n error: where.error,\n register,\n control,\n })}\n </FieldWrapper>\n );\n\n const builtInFields: Record<string, ReactNode> = Object.fromEntries(\n BUILT_IN_BOOKING_FIELDS.map(field => [\n field.name,\n renderField(field, { id: builtInId(field.name), registerName: field.name, error: errors[field.name]?.message }),\n ]),\n );\n\n const renderDeclaredField = (field: SupportedMeetingFormField): ReactNode =>\n renderField(field, {\n id: `ms-q-${field.name}`,\n registerName: `formFields.${field.name}`,\n error: fieldError(field.name),\n });\n\n const declaredByName = new Map(supportedFields.map(f => [f.name, f]));\n\n const slotResolves = (name: string): boolean => Boolean(builtInFields[name] || declaredByName.has(name));\n\n const slotNode = (name: string): ReactNode => {\n const builtIn = builtInFields[name];\n if (builtIn) return builtIn;\n const declared = declaredByName.get(name);\n // A name matching nothing is SKIPPED, not an error: a row may reference a\n // question the link has not declared yet (declaring it in HubSpot is what\n // turns it on), and throwing would take the whole form down over config.\n return declared ? renderDeclaredField(declared) : null;\n };\n\n /** Fields no row claims — appended full width, so neither a question added in\n * HubSpot nor a built-in the layout forgot can go invisible: the schema still\n * requires the identity trio, and a required field with no control is a\n * submit that dies silently. */\n const { rows: placedRows, unplacedBuiltIns, named: placedNames } = normalizeFieldRows(fieldRows ?? [], slotResolves);\n const unplacedFields = fieldRows ? supportedFields.filter(f => !placedNames.has(f.name)) : [];\n\n const submitButton = (\n <Button\n type=\"submit\"\n loading={isSubmitting}\n disabled={isSubmitting}\n // The details-first footer draws a 240px action beside its note\n // (`4904:117335`); the bare slot-first row keeps the button at its natural\n // width, as it always has.\n className={footerNote ? SUBMIT_WIDE_CLASS : undefined}\n >\n {submitLabel ?? DEFAULT_SUBMIT_LABEL}\n </Button>\n );\n\n // Opted out of HubSpot's collected-forms script — see HUBSPOT_DO_NOT_COLLECT_FORM_PROPS.\n return (\n <form onSubmit={submit} className={FORM_STACK} noValidate {...HUBSPOT_DO_NOT_COLLECT_FORM_PROPS}>\n <HoneypotField {...honeypotInputProps} />\n\n {/* Email first, name pair below — the order the desktop and mobile mocks\n both draw. The pair stays TWO columns even on a phone (164px each at\n 375): two short fields side by side cost one line instead of two on\n the layout that can least afford them.\n\n Every field goes through `FieldWrapper`, which hangs its message OUT\n OF FLOW below the control. That is the whole reason it is here: a\n message rendered in flow grows its field, which pushes everything\n under it down and — inside a card that states its height — walks the\n submit button off the bottom the moment validation fails. Required-ness\n is carried by `required` on the control (read out by assistive tech)\n AND by `FieldWrapper`'s accent asterisk — the mark HubSpot's own form\n and `ContactForm` both draw, so a visitor sees the same convention on\n every form in the app. */}\n {fieldRows ? (\n <>\n {placedRows.map(row => (\n <div key={row.map(s => s.name).join('|')} className={ROW_GRID}>\n {row.map((slot, slotIndex) => (\n <div key={slot.name} className={slotColumnClass(row, slot, slotIndex)}>\n {slotNode(slot.name)}\n </div>\n ))}\n </div>\n ))}\n {[...unplacedBuiltIns, ...unplacedFields].map(field => (\n <Fragment key={field.name}>{slotNode(field.name)}</Fragment>\n ))}\n </>\n ) : (\n <>\n {builtInFields.email}\n <div className=\"grid grid-cols-2 gap-[var(--spacing-system-m)]\">\n {builtInFields.firstName}\n {builtInFields.lastName}\n </div>\n {supportedFields.map(field => (\n <Fragment key={field.name}>{renderDeclaredField(field)}</Fragment>\n ))}\n </>\n )}\n\n {consent && (\n <CheckboxBlock\n id=\"ms-host-consent\"\n checked={consented}\n onCheckedChange={v => {\n setConsented(v);\n if (v) setConsentError(null);\n }}\n disabled={isSubmitting}\n required\n error={consentError ?? undefined}\n label={consent.label}\n description={consent.description}\n />\n )}\n\n {legalConsent && (\n // Same out-of-flow message as the fields above, so a missed consent\n // box doesn't shove the submit button down the card. With no error the\n // wrapper is `display:contents` and the panel below is the flex item,\n // exactly as it was.\n <FieldWrapper error={errors.legalConsentResponses?.message}>\n <div className=\"flex flex-col gap-[var(--spacing-system-xs)]\">\n {/* GDPR surface — HubSpot's copy rendered VERBATIM, never edited, in\n HubSpot's own order: processing statement, communication intro,\n the boxes, privacy note. The statement is a standalone line so a\n link with consent enabled but NO communication boxes still shows\n it. */}\n <p className=\"text-ods-text-secondary text-h6\">{legalConsent.processingConsentText}</p>\n {legalConsent.communicationConsentText && (\n <p className=\"text-ods-text-secondary text-h6\">{legalConsent.communicationConsentText}</p>\n )}\n <Controller\n control={control}\n name=\"legalConsentResponses\"\n render={({ field: rhf }) => (\n <>\n {legalConsent.communicationConsentCheckboxes.map(box => {\n const responses = (rhf.value ?? []) as Array<{ communicationTypeId: string; consented: boolean }>;\n const current = responses.find(r => r.communicationTypeId === box.communicationTypeId);\n return (\n // The design system's consent row (`checkbox-block`, the\n // same block the waitlist form uses): box + label in one\n // bordered row.\n <CheckboxBlock\n key={box.communicationTypeId}\n id={`ms-consent-${box.communicationTypeId}`}\n checked={current?.consented ?? false}\n onCheckedChange={v =>\n rhf.onChange(\n responses.map(r =>\n r.communicationTypeId === box.communicationTypeId ? { ...r, consented: v } : r,\n ),\n )\n }\n required={box.required}\n label={box.label}\n />\n );\n })}\n </>\n )}\n />\n {legalConsent.privacyPolicyText && (\n <p className=\"text-ods-text-secondary text-h6\">{legalConsent.privacyPolicyText}</p>\n )}\n </div>\n </FieldWrapper>\n )}\n\n {/* Step navigation back to the calendar lives in the step header (the\n app-standard BackButton, rendered by the parent) — the form ships\n only its submit. */}\n {/* Two shapes, not one with a placeholder: without a note this must stay\n the bare `flex` row it has always been, or the submit slides from the\n left edge to the right on every existing slot-first booking. */}\n {footerNote ? (\n <div className={FOOTER_ROW_CLASS}>\n <p className=\"text-ods-text-secondary text-h6\">{footerNote}</p>\n {submitButton}\n </div>\n ) : (\n <div className={FOOTER_BARE_CLASS}>{submitButton}</div>\n )}\n </form>\n );\n}\n\n/**\n * Cold-start placeholder for `flow=\"details-first\"`, where the FORM is the\n * first thing in the action panel.\n *\n * The slot-first skeleton (`SlotPickerSkeleton`) would put a grey calendar\n * where the form belongs — above the fold on a page whose entire content is\n * this card. Same footprint discipline as its sibling: fixed heights, and no\n * shift when the real form swaps in for single-line fields. Two footprints it\n * cannot know: a textarea row is taller than its placeholder (the rows carry no\n * type), and a HubSpot `legalConsent` block only exists once availability lands.\n */\nexport function BookingFormSkeleton({\n fieldRows,\n consent,\n footerNote,\n}: {\n fieldRows?: BookingFieldRow[];\n /** Whether the host adds its consent row (drawn only then). A HubSpot\n * `legalConsent` block is unknown until availability lands — the one\n * footprint this skeleton cannot budget for. */\n consent?: boolean;\n footerNote?: string;\n}) {\n return (\n <div className={cn('flex-1', FORM_STACK)}>\n {/* The host's rows (or the built-in layout) through the SAME grid, column\n rule and normalisation as the loaded form — see `normalizeFieldRows`. */}\n {skeletonRows(fieldRows ?? DEFAULT_FIELD_ROWS).map(row => (\n <div key={row.map(s => s.name).join('|')} className={ROW_GRID}>\n {row.map((slot, slotIndex) => (\n <Skeleton key={slot.name} className={cn(FIELD_SKELETON_CLASS, slotColumnClass(row, slot, slotIndex))} />\n ))}\n </div>\n ))}\n {/* Without rows there is no telling how many questions the link declares: one long answer stands for them. */}\n {!fieldRows && <Skeleton className=\"h-[7.75rem] w-full\" />}\n {consent && <Skeleton className=\"h-[4.25rem] w-full\" />}\n {footerNote ? (\n <div className={FOOTER_ROW_CLASS}>\n <Skeleton className=\"h-5 min-w-40 flex-1\" />\n <Skeleton className={cn('h-12 w-full', SUBMIT_WIDE_CLASS)} />\n </div>\n ) : (\n <div className={FOOTER_BARE_CLASS}>\n <Skeleton className=\"h-12 w-40\" />\n </div>\n )}\n </div>\n );\n}\n\n/**\n * The ONE normalisation of host rows, read by the form and its skeleton.\n * - A slot whose name `resolves` to nothing is dropped, and a row left empty is\n * dropped with it: an empty grid still eats one form gap, so a layout written\n * ahead of the HubSpot config would print blank bands.\n * - A name placed twice renders once (its first slot) — one control per field.\n * - Explicit spans were written for the FULL row; once a slot drops out they\n * would leave a trailing gap, so a shortened row falls back to the even split.\n * - Built-ins no row names come back as `unplacedBuiltIns`: the schema still\n * requires the identity trio, and a required field with no control is a\n * submit that dies silently.\n * The skeleton has no availability yet, so it passes no `resolves` and keeps\n * every declared-looking name; that is the one swap it cannot rule out.\n */\nfunction normalizeFieldRows(fieldRows: BookingFieldRow[], resolves: (name: string) => boolean = () => true) {\n const seen = new Set<string>();\n const rows: BookingFieldRow[] = [];\n for (const row of fieldRows) {\n const kept: BookingFieldSlot[] = [];\n for (const slot of row) {\n if (!resolves(slot.name) || seen.has(slot.name)) continue;\n seen.add(slot.name);\n kept.push(slot);\n }\n if (kept.length === 0) continue;\n rows.push(kept.length === row.length ? kept : kept.map((slot): BookingFieldSlot => ({ name: slot.name })));\n }\n const named = new Set(fieldRows.flat().map(slot => slot.name));\n const unplacedBuiltIns = BUILT_IN_BOOKING_FIELDS.filter(f => !named.has(f.name));\n return { rows, unplacedBuiltIns, named };\n}\n\n/** The skeleton's rows: the normalised host rows, then the built-ins none of them named. */\nfunction skeletonRows(fieldRows: BookingFieldRow[]): BookingFieldRow[] {\n const { rows, unplacedBuiltIns } = normalizeFieldRows(fieldRows);\n return [...rows, ...unplacedBuiltIns.map(f => [{ name: f.name }])];\n}\n","'use client';\n\nimport type { BookingConfirmation } from '../../schemas/meeting-booking-schema';\nimport { formatDurationCompact, formatDateWithTimezone } from '../../utils/format';\n\n/**\n * Confirmation — terminal success state after a completed booking. Renders\n * the booked instant in the visitor's resolved zone. Deliberately NO\n * follow-up CTA: the job is done, the calendar invite is the next touchpoint\n * (a \"book another\" button here tested as noise).\n */\n\nexport interface ConfirmationProps {\n confirmation: BookingConfirmation;\n timezone: string;\n}\n\nexport function Confirmation({ confirmation, timezone }: ConfirmationProps) {\n // The SAME preset as the booking summary beside the slot picker, so the time\n // a visitor picked and the time they are told was booked read identically.\n const when = formatDateWithTimezone(confirmation.startTimeMs, timezone, 'weekdayDateTimeZoned', {\n viewerLocale: true,\n });\n\n return (\n <div className=\"flex flex-1 flex-col items-center justify-center gap-[var(--spacing-system-m)] text-center\">\n <p className=\"text-h1\" role=\"img\" aria-label=\"Celebration\">\n 🎉\n </p>\n <h3 className=\"text-ods-text-primary text-h3\">You&apos;re booked</h3>\n <p className=\"text-ods-text-primary text-h5\">{confirmation.title}</p>\n <p className=\"text-ods-text-secondary text-h6\">\n {when} · {formatDurationCompact(confirmation.durationMs / 1000)}\n </p>\n <p className=\"text-ods-text-secondary text-h6\">A calendar invite is on its way to your email.</p>\n </div>\n );\n}\n","'use client';\n\nimport { useMemo } from 'react';\nimport type { MeetingHost } from '../../schemas/meeting-booking-schema';\nimport { cn } from '../../utils/cn';\nimport { formatDurationCompact } from '../../utils/format';\nimport { BackButton } from '../layout/back-button';\nimport { SquareAvatar, AvatarStack, Button, Skeleton, Autocomplete } from '../ui';\n\n/**\n * ContextPanel — the \"who / what / how long\" side of the scheduler card\n * (Calendly-anatomy left panel; stacks on top on mobile). Everything here is\n * trust surface: host identity (avatar + name + title), the meeting's own\n * title/description, the duration (chips when the link offers several — the\n * duration choice lives HERE, not as a separate wizard step), and a\n * SEARCHABLE timezone picker (all IANA zones with live GMT offsets;\n * rendering-only — the wire is always epoch-ms).\n *\n * `onBack` (optional) puts the card's ONE back edge at the TOP of this panel —\n * the one spot that stays put while the action side swaps between calendar,\n * form and confirmation. What it means is the caller's call: the scheduler\n * points it at the previous STEP from the form, and at the host's own exit\n * from the calendar. One affordance, one place, so the visitor never has to\n * work out which of two \"Back\"s goes where.\n *\n * The picker renders only once the parent resolves the zone post-mount — SSR\n * output stays deterministic; a same-footprint skeleton holds the space.\n */\n\nexport interface SchedulerContextPanelProps {\n hosts: MeetingHost[];\n title?: string;\n description?: string | null;\n durationsMs: number[];\n selectedDurationMs: number | null;\n onSelectDuration: (ms: number) => void;\n /** Resolved IANA zone (null until the client resolves it). */\n timezone: string | null;\n onTimezoneChange?: (tz: string) => void;\n /** Host-level exit (renders a `BackButton` at the top). Omitted → no back\n * affordance, which is what an embedder with its own page chrome wants. */\n onBack?: () => void;\n /** Label for {@link onBack}. */\n backLabel?: string;\n /** True once a slot is chosen (details/confirmed steps) — the duration\n * CHIPS give way to a static duration line, since the step summary already\n * states the length and a live selector beside a filled-in form invites a\n * change that would throw the form away. Going Back restores them. */\n locked?: boolean;\n /** A POST is in flight: the duration chips and the zone control stay MOUNTED\n * (nothing may shift under the spinning chip) but accept no input. */\n disabled?: boolean;\n /** Whether the timezone picker renders. Default true: the zone governs every\n * time on screen, the form's summary line included, so it stays reachable\n * right up to the confirmation — where there is nothing left to re-read. */\n showTimezone?: boolean;\n className?: string;\n}\n\n/** Panel stack — one rhythm shared by the loaded panel and its skeleton\n * (16px on phones, 24px from tablet up, via the responsive spacing token). */\nconst PANEL_STACK = 'flex flex-col gap-[var(--spacing-system-l)]';\n\n/**\n * Identity + duration: ONE line below `lg`, stacked from `lg` up.\n *\n * The panel is a 280px sidebar from `lg` and a full-width header strip below\n * it — and a header strip has horizontal room the sidebar never had, so the\n * duration moves up beside the host instead of taking a line of its own. Same\n * two children, different axis; nothing is hidden at any width.\n */\nconst IDENTITY_ROW =\n 'flex items-center justify-between gap-[var(--spacing-system-m)] lg:flex-col lg:items-stretch lg:gap-[var(--spacing-system-l)]';\n\n/**\n * Everything under the back edge, in TWO groups: identity/title/duration, and\n * the timezone field beside them.\n *\n * Side by side only on TABLET. There the panel is a full-width strip over the\n * calendar, and it is competing with the calendar for the card's height — a\n * timezone field on a line of its own costs 100px there, which is a third of\n * what the month needs to render without scrolling. Sideways it costs nothing:\n * the strip is ~725px wide and the identity block uses less than half of it.\n *\n * Below `md` (phone) and from `lg` up (280px sidebar) there is no horizontal\n * room to spend, so both fall back to the plain stack.\n */\nconst STRIP_BODY = 'flex flex-col gap-[var(--spacing-system-l)] md:flex-row md:items-start lg:flex-col';\nconst STRIP_MAIN = 'flex min-w-0 flex-col gap-[var(--spacing-system-l)] md:flex-1';\n/** 280px — the same width this panel has as a sidebar, so the field is the\n * size the visitor meets at every other breakpoint. */\nconst STRIP_ASIDE = 'flex flex-col gap-[var(--spacing-system-xxs)] md:w-[280px] md:shrink-0 lg:w-full';\n\n/** The back affordance sits flush with the panel's top padding: `BackButton`'s\n * own 12px block padding would otherwise double the 24px stack gap under it. */\nconst BACK_BUTTON_CLASS = 'py-0';\n\n/** Field labels (\"Duration\", \"Timezone\") — body-weight primary, 4px above\n * their control, so the panel reads as a form rather than as a stack of\n * section headers. */\nconst FIELD_LABEL_CLASS = 'text-h4 text-ods-text-primary';\n\nfunction zoneLabel(tz: string): string {\n try {\n const parts = new Intl.DateTimeFormat('en-US', {\n timeZone: tz,\n timeZoneName: 'shortOffset',\n }).formatToParts(new Date());\n const offset = parts.find(p => p.type === 'timeZoneName')?.value ?? '';\n return offset ? `${tz.replace(/_/g, ' ')} (${offset})` : tz.replace(/_/g, ' ');\n } catch {\n return tz.replace(/_/g, ' ');\n }\n}\n\n/**\n * Same-footprint skeleton — swaps with the loaded panel with zero shift.\n * STATIC labels (\"Duration\", \"Timezone\") render REAL; only data-driven\n * content (host identity, duration chips, the zone value) is skeleton.\n */\nexport function ContextPanelSkeleton({\n className,\n onBack,\n backLabel = 'Back',\n}: {\n className?: string;\n onBack?: () => void;\n backLabel?: string;\n}) {\n return (\n <div className={cn(PANEL_STACK, className)}>\n {/* The back edge renders REAL here — it needs no data, and holding the\n space with a bar would just move on load. */}\n {onBack && <BackButton label={backLabel} onClick={onBack} className={BACK_BUTTON_CLASS} />}\n {/* Same two groups, same rows, as the loaded panel — so the header strip\n keeps its height and nothing re-flows when it swaps in. */}\n <div className={STRIP_BODY}>\n <div className={STRIP_MAIN}>\n <div className={IDENTITY_ROW}>\n <div className=\"flex min-w-0 flex-1 items-center gap-[var(--spacing-system-xs)] lg:flex-none\">\n <Skeleton className=\"h-8 w-8 shrink-0 rounded-full\" />\n <div className=\"flex flex-1 flex-col gap-[var(--spacing-system-xxs)]\">\n <Skeleton className=\"h-5 w-32\" />\n <Skeleton className=\"h-4 w-24\" />\n </div>\n </div>\n <Skeleton className=\"h-4 w-20 shrink-0\" />\n </div>\n </div>\n <div className={STRIP_ASIDE}>\n <p className={FIELD_LABEL_CLASS}>Timezone</p>\n <Skeleton className=\"h-12 w-full\" />\n </div>\n </div>\n </div>\n );\n}\n\nexport function SchedulerContextPanel({\n hosts,\n title,\n description,\n durationsMs,\n selectedDurationMs,\n onSelectDuration,\n timezone,\n onTimezoneChange,\n onBack,\n backLabel = 'Back',\n locked = false,\n disabled = false,\n showTimezone = true,\n className,\n}: SchedulerContextPanelProps) {\n // All IANA zones with live GMT offsets — computed once, client-only (the\n // panel renders the picker only after the parent resolves a zone).\n const zoneOptions = useMemo(() => {\n if (!timezone) return [];\n let zones: string[];\n try {\n // Older lib targets don't type supportedValuesOf (ES2022) — runtime-guarded.\n const intl = Intl as typeof Intl & { supportedValuesOf?: (key: string) => string[] };\n zones = intl.supportedValuesOf ? intl.supportedValuesOf('timeZone') : [timezone];\n } catch {\n zones = [timezone];\n }\n if (!zones.includes(timezone)) zones = [timezone, ...zones];\n return zones.map(tz => ({ value: tz, label: zoneLabel(tz) }));\n }, [timezone]);\n\n return (\n <div className={cn(PANEL_STACK, className)}>\n {/* The card's ONE back edge, at every step — the caller decides where it\n goes (previous step, or out of the scheduler). It sits in this panel\n because this is the half that never swaps. */}\n {onBack && <BackButton label={backLabel} onClick={onBack} className={BACK_BUTTON_CLASS} />}\n\n <div className={STRIP_BODY}>\n <div className={STRIP_MAIN}>\n <div className={IDENTITY_ROW}>\n {/* Host identity: full rows up to 3 hosts; larger teams (round-robin\n links can carry many members) collapse to a stacked-avatar cluster\n + count so the panel can never overflow. */}\n {hosts.length > 0 && hosts.length <= 3 && (\n <div className=\"flex flex-col gap-[var(--spacing-system-s)]\">\n {hosts.map(host => (\n <div key={host.name} className=\"flex items-center gap-[var(--spacing-system-xs)]\">\n <SquareAvatar\n variant=\"round\"\n size=\"sm\"\n src={host.avatarUrl ?? undefined}\n alt={host.name}\n fallback={host.name}\n />\n <div className=\"flex min-w-0 flex-col\">\n <p className=\"truncate text-ods-text-primary text-h4\">{host.name}</p>\n {host.title && <p className=\"truncate text-ods-text-secondary text-h6\">{host.title}</p>}\n </div>\n </div>\n ))}\n </div>\n )}\n {hosts.length > 3 && (\n <div className=\"flex flex-col gap-[var(--spacing-system-xs)]\">\n <AvatarStack people={hosts} max={4} size=\"lg\" label=\"Hosts\" />\n <p className=\"text-ods-text-secondary text-h6\">{hosts.length} hosts on this calendar</p>\n </div>\n )}\n\n {locked || durationsMs.length <= 1\n ? // Post-selection, or a link that offers one length: a static line,\n // which is what sits beside the host on the header-strip layouts.\n selectedDurationMs != null && (\n <p className=\"shrink-0 text-ods-text-secondary text-h6\">\n {formatDurationCompact(selectedDurationMs / 1000)} call\n </p>\n )\n : null}\n </div>\n\n {title && <p className=\"text-ods-text-primary text-h3\">{title}</p>}\n {description && <p className=\"text-ods-text-secondary text-h6\">{description}</p>}\n\n {/* Several lengths on offer: chips, which need a full row of their\n own at every width — so they stay OUT of the identity row. */}\n {!locked && durationsMs.length > 1 && (\n <div className=\"flex flex-col gap-[var(--spacing-system-xxs)]\">\n <p className={FIELD_LABEL_CLASS}>Duration</p>\n <div className=\"flex flex-wrap gap-[var(--spacing-system-xs)]\">\n {durationsMs.map(ms => (\n <Button\n key={ms}\n variant={selectedDurationMs === ms ? undefined : 'outline'}\n size=\"small-legacy\"\n disabled={disabled}\n onClick={() => onSelectDuration(ms)}\n >\n {formatDurationCompact(ms / 1000)}\n </Button>\n ))}\n </div>\n </div>\n )}\n </div>\n\n {showTimezone && (\n // Once a slot is chosen the picker is desktop-only, which is what all\n // three mocks draw. On the narrow layouts this panel is a strip\n // COMPETING with the form for the screen's height, and the step's own\n // summary line states the zone one row below — so the fact survives,\n // only the control steps aside. The 280px sidebar has the room, so\n // there it stays.\n <div className={cn(STRIP_ASIDE, locked && 'max-lg:hidden')}>\n <p className={FIELD_LABEL_CLASS}>Timezone</p>\n {timezone ? (\n <Autocomplete\n value={timezone}\n disabled={disabled}\n onChange={tz => {\n if (tz) onTimezoneChange?.(tz);\n }}\n options={zoneOptions}\n placeholder=\"Search timezone…\"\n noOptionsText=\"No matching timezone\"\n showClearAll={false}\n />\n ) : (\n <Skeleton className=\"h-12 w-full\" />\n )}\n </div>\n )}\n </div>\n </div>\n );\n}\n","'use client';\n\nimport { ChevronRight } from 'lucide-react';\nimport { useCallback, useMemo } from 'react';\nimport { cn } from '../../utils/cn';\nimport { formatDateWithTimezone, formatTimeWithTimezone, VIEWER_TIMEZONE } from '../../utils/format';\nimport { MAX_MONTH_OFFSET } from '../../utils/hubspot-meetings-convention';\nimport { Button, DatePickerCalendar, Skeleton } from '../ui';\n\n/**\n * SlotPicker — day layer + time-chip grid for the meeting scheduler.\n *\n * Day layer is the design system's own `DatePickerCalendar`, in its `bare`\n * variant — the SAME component behind `DatePicker`'s popover and\n * `DateFilterMenu`, not a second day grid styled to look like it; `bare` only\n * drops the card surface and inset it would otherwise draw inside this panel. `fromDate`/`toDate` pin\n * navigation to [current month, current + MAX_MONTH_OFFSET] so the past is\n * unreachable, and `disabledDays` greys out days with no bookable slot —\n * bounds and availability are different questions, so they arrive separately.\n * Month navigation drives the host-proxy `monthOffset`, which is why the month\n * is CONTROLLED here rather than owned by the calendar. Time chips are plain\n * `Button`s whose variant flips on selection — no bespoke pill.\n *\n * LOADING (no blink, ever): the two columns know different amounts, so they\n * load differently. A CALENDAR is fully derivable from the date — which month,\n * which weekday each cell falls on — and only WHICH DAYS ARE BOOKABLE needs\n * the network. So it never unmounts: while a month loads it renders for real\n * with nothing yet selectable, which is exactly true, and lights up in place.\n * Only the times column, which genuinely has no answer until the response\n * lands, shows a placeholder.\n *\n * Swapping the calendar for a skeleton was the old behaviour and it is what\n * made paging months flash: the whole grid vanished and rebuilt to say\n * something it already knew.\n *\n * Each column carries its OWN heading — \"Select Date & Time\" over the\n * calendar, the chosen day over the chips. The headings live HERE rather than\n * above the whole picker so each sits with the thing it labels, and so the\n * skeletons reproduce them without the parent rendering a heading it cannot\n * keep in step.\n *\n * All slot instants are epoch-ms; every label is rendered in `timezone`\n * (resolved by the parent AFTER mount — this component never reads Intl\n * itself, keeping SSR output deterministic).\n */\n\nexport interface SlotPickerProps {\n /** Bookable slot start times (epoch ms) for the selected duration. */\n slots: number[];\n /** IANA zone every label renders in (parent resolves post-mount). */\n timezone: string;\n monthOffset: number;\n onMonthOffsetChange: (offset: number) => void;\n selectedSlot: number | null;\n onSelectSlot: (startMs: number) => void;\n selectedDay: string | null;\n onSelectDay: (dayKey: string) => void;\n /** Availability refetch in flight (month change) → per-region skeletons. */\n isLoading?: boolean;\n /**\n * A booking POST is in flight (details-first, where the slot click IS the\n * submit). DISTINCT from `isLoading`, which swaps the whole times column for\n * a skeleton — here the grid must stay up with the clicked chip spinning, so\n * the visitor can see WHICH time is being booked.\n */\n isSubmitting?: boolean;\n}\n\n/** Stable per-zone day key for an instant, e.g. \"2026-08-14\" (exported — the\n * parent uses it to auto-select the first available day). */\nexport function dayKeyInZone(ms: number, timeZone: string): string {\n return new Intl.DateTimeFormat('en-CA', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit' }).format(\n new Date(ms),\n );\n}\n\n/**\n * Calendar-side day-key anchoring: react-day-picker exchanges PLAIN calendar\n * dates (local Date objects whose y/m/d ARE the cell). Convert via local\n * date parts — never through an Intl zone — or a `displayTimezone` far from\n * the browser zone shifts cells by a day.\n */\nconst pad2 = (n: number) => String(n).padStart(2, '0');\nfunction calendarDayKey(day: Date): string {\n return `${day.getFullYear()}-${pad2(day.getMonth() + 1)}-${pad2(day.getDate())}`;\n}\nfunction dateFromDayKey(key: string): Date {\n const [y, m, d] = key.split('-').map(Number);\n return new Date(y, m - 1, d, 12);\n}\n\nfunction timeLabelInZone(ms: number, timeZone: string): string {\n return formatTimeWithTimezone(ms, timeZone, { viewerLocale: true });\n}\n\n/**\n * The calendar column's width, once the layout stops handing it one: on a\n * phone the section spans the card, from `md` up it takes a stated width and\n * the times column gets the rest.\n *\n * Width no longer decides height — `DatePickerCalendar bare` is a fixed 272px\n * block at any width — so these two numbers answer one question only: how much\n * room the month CAPTION needs. The two DS icon buttons and their gaps take\n * 104px of the header, and \"September 2026\" is ~131 at text-h4, so the grid\n * needs ~245px of inner width before the label starts colliding with the\n * next-month button. 304 on tablet (256 inner, this column pads itself) and\n * 256 on the desktop card, where the panel supplies the padding.\n *\n * That is the trade the standard button size buys: the chrome keeps its\n * 48px targets and the COLUMN widens to house it, rather than the buttons\n * shrinking to fit a narrower column.\n */\nconst CALENDAR_W = 'md:w-[19rem] md:shrink-0 lg:w-[16rem]';\n\n/** Column heading (\"Select Date & Time\" / the chosen day) — body-weight\n * primary, 4px above its content, mirrored by both skeletons. */\nconst COLUMN_HEADING_CLASS = 'shrink-0 text-h4 text-ods-text-primary';\nconst COLUMN_STACK = 'flex min-h-0 flex-col gap-[var(--spacing-system-xxs)]';\n\n/** Static label over the calendar. Exported so a host rendering its own\n * chrome around the picker can stay in step with it. */\nexport const SLOT_PICKER_HEADING = 'Select Date & Time';\n\n/**\n * Chip grid: `auto-fill` over `minmax(5.5rem, 1fr)` — as many chips per row as\n * fit at the minimum, then the leftover width shared out between them.\n *\n * Both halves matter. The 5.5rem MINIMUM is the widest 12-hour label\n * (\"12:45 PM\") plus the chip's padding: the mocks draw 65px chips five across\n * a phone, but they are lettered in a 24-hour locale (\"13:45\") and this\n * renders in the visitor's own — a chip that clips its own time is worse than\n * one column fewer. The `1fr` MAXIMUM is what makes the grid reach the card's\n * edge like the design instead of stopping short with a ragged strip of empty\n * space beside the last column, which is what a fixed track width leaves\n * behind at every width that is not an exact multiple of it.\n *\n * `auto-fill` is what keeps `1fr` safe here: the track COUNT still comes from\n * the minimum, so a wide column answers with more chips rather than with\n * fifteen-minute slots stretched into 200px slabs.\n *\n * Every track is the same width, so rows line up and nothing staggers.\n */\nconst CHIP_GRID_CLASS = cn(\n 'grid grid-cols-[repeat(auto-fill,minmax(5.5rem,1fr))] content-start gap-[var(--spacing-system-xs)]',\n 'overflow-y-auto',\n // PHONE: a stated window (~3 rows, the mock's 156px) that scrolls, rather\n // than a list that runs the length of the day. Nothing else on the phone\n // card has a height — the design lets each step be as tall as it needs —\n // but a busy day is forty slots, and letting them all through turns a\n // 700px card into a 1500px one and buries whatever the page puts below\n // it. This is the one nested scroller worth having: a small, obviously\n // bounded list with a visible edge, exactly as drawn.\n 'max-h-[9.75rem] md:max-h-none',\n // No height of its own, and none borrowed from the calendar either: the card\n // states one height, the panel and this row pass it down, and what is left\n // after the column heading IS the scroll box. A long day scrolls inside it.\n //\n // `min-h-0` is the load-bearing half — a flex item's default\n // `min-height:auto` refuses to shrink below its content, so without it the\n // chips grow the column, then the row, then the card, and `overflow-y-auto`\n // never gets a box smaller than its contents to scroll inside. That is the\n // exact failure that kept this card at 404px when it was told to be 318.\n 'min-h-0 flex-1',\n);\n/** The track sets the width; the chip fills it, so labels can't stagger it.\n * The tighter inline padding is what lets the track hold a 12-hour label —\n * the Button's own is sized for prose, not for a time. */\nconst CHIP_CLASS = 'w-full px-[var(--spacing-system-xxs)]';\n\n/**\n * The two columns, at three widths.\n *\n * < md phone — stacked sections, a full-bleed rule between them\n * md tablet — side by side, the calendar at its stated width and the\n * times taking the rest, a vertical rule between them\n * lg desktop — side by side inside the panel's own padding, separated by\n * a gap (the card already has a rule there: the context\n * panel's edge)\n *\n * From `md` up the row takes what the card has left (`flex-1 min-h-0`) and\n * hands each column that same definite height. The calendar is a fixed 272px\n * block and does not scroll; the times column holds whatever the day offers\n * and scrolls ON ITS OWN. Two boxes, one height, one scrollbar — the calendar\n * stays put while the visitor runs down the times.\n *\n * Below `md` the card pins no height, so the row simply grows and the PAGE\n * scrolls — a nested scroller inside a hand-width card traps the gesture and\n * is worse than the thing it saves.\n */\nconst SLOT_ROW_CLASS = cn(\n 'flex min-w-0 flex-col md:flex-row',\n // Default `stretch` at every width, deliberately. Stacked, that is what\n // makes each section span the card — an `items-start` here (it was here, to\n // stop a column growing taller than its month) collapses both sections to\n // their intrinsic WIDTH instead, which on a phone left the calendar and the\n // chips huddled against the left edge with half the card empty. The\n // calendar no longer needs protecting: its height is fixed, not derived.\n 'md:min-h-0 md:flex-1 lg:gap-[var(--spacing-system-lf)]',\n);\n\n/** Shared by both columns: self-padded until the panel takes over at `lg`.\n * Widths are per-column — the calendar states one (`CALENDAR_W`) and the\n * times take what is left, rather than a 50/50 split that would size the\n * month by the card's width. */\nconst COLUMN_BOX = 'p-[var(--spacing-system-l)] lg:p-0';\n\n/** The rule between the two, wherever the layout puts it. */\nconst COLUMN_DIVIDER = 'border-b border-ods-border md:border-b-0 md:border-r lg:border-0';\n\n/** Deterministic month caption for a given offset (fixed en-US locale so\n * the SSR and client first-paint markup agree byte-for-byte). */\nexport function monthLabelFor(offset: number): string {\n const now = new Date();\n const month = new Date(now.getFullYear(), now.getMonth() + offset, 1);\n return formatDateWithTimezone(month, VIEWER_TIMEZONE, 'monthYear');\n}\n\n/** Nothing is bookable yet — used while a month is still loading. */\nconst DISABLE_EVERY_DAY = () => true;\nconst NOOP = () => undefined;\n\ninterface DayCalendarProps {\n monthOffset: number;\n onMonthOffsetChange: (offset: number) => void;\n selectedDay: string | null;\n onSelectDay: (dayKey: string) => void;\n /** Days that ARE bookable; `null` while the month is still loading. */\n slotsByDay: Map<string, number[]> | null;\n}\n\n/**\n * The day layer — one component for every state, which is the whole point:\n * loading a month changes WHICH DAYS ARE ENABLED, nothing else, so the grid\n * stays mounted and the visitor never watches it disappear and come back.\n *\n * `aria-busy` carries what the missing days mean to assistive tech, since\n * visually it is just an inert month.\n */\nfunction DayCalendar({ monthOffset, onMonthOffsetChange, selectedDay, onSelectDay, slotsByDay }: DayCalendarProps) {\n const now = new Date();\n const startMonth = new Date(now.getFullYear(), now.getMonth(), 1);\n // Day-0 of the FOLLOWING month = the last day of the last bookable one.\n // `DatePickerCalendar` takes day bounds, not month bounds, so an end date\n // pointing at the 1st would disable the rest of that month.\n const endMonth = new Date(now.getFullYear(), now.getMonth() + MAX_MONTH_OFFSET + 1, 0);\n const visibleMonth = new Date(now.getFullYear(), now.getMonth() + monthOffset, 1);\n\n // Stable identity: the calendar memoizes its matcher list on this value, so\n // a fresh closure per render would rebuild it every render.\n const dayHasNoSlots = useCallback((day: Date) => !slotsByDay?.has(calendarDayKey(day)), [slotsByDay]);\n\n return (\n <div className=\"flex min-h-0 flex-1 flex-col\" aria-busy={slotsByDay === null}>\n <DatePickerCalendar\n mode=\"single\"\n bare\n // CONTROLLED month: paging it is a DATA event here (the parent\n // refetches availability for the new offset), not just a view change\n // the calendar could own.\n month={visibleMonth}\n onMonthChange={month => {\n const offset = (month.getFullYear() - now.getFullYear()) * 12 + (month.getMonth() - now.getMonth());\n onMonthOffsetChange(Math.max(0, Math.min(MAX_MONTH_OFFSET, offset)));\n }}\n fromDate={startMonth}\n toDate={endMonth}\n selected={selectedDay ? dateFromDayKey(selectedDay) : undefined}\n onSelect={value => {\n const day = value as Date | undefined;\n if (!day) return;\n const key = calendarDayKey(day);\n if (slotsByDay?.has(key)) onSelectDay(key);\n }}\n disabledDays={slotsByDay === null ? DISABLE_EVERY_DAY : dayHasNoSlots}\n />\n </div>\n );\n}\n\n/**\n * The slot area before the first response — the pre-mount window while the\n * display timezone resolves, and the first availability request.\n *\n * The calendar half is REAL, not a placeholder: see the module docblock. Only\n * the times column is a skeleton, and it is the same one the loaded picker\n * shows while a month loads, so there is no swap between the two.\n */\nexport function SlotPickerSkeleton({ monthOffset = 0 }: { monthOffset?: number }) {\n return (\n <div className={SLOT_ROW_CLASS}>\n <div className={cn(COLUMN_STACK, COLUMN_BOX, COLUMN_DIVIDER, CALENDAR_W, 'md:overflow-y-auto')}>\n {/* Needs no data — the heading renders REAL in every loading state. */}\n <p className={COLUMN_HEADING_CLASS}>{SLOT_PICKER_HEADING}</p>\n <DayCalendar\n monthOffset={monthOffset}\n onMonthOffsetChange={NOOP}\n selectedDay={null}\n onSelectDay={NOOP}\n slotsByDay={null}\n />\n </div>\n <div className={cn(COLUMN_STACK, COLUMN_BOX, 'min-w-0 md:flex-1')}>\n <TimeChipsSkeleton />\n </div>\n </div>\n );\n}\n\n/** Same-footprint skeleton for the time-chip column. */\nexport function TimeChipsSkeleton() {\n return (\n <>\n {/* The day label is data — but it is held inside the REAL heading line\n box, so the skeleton's first row sits exactly where the label lands. */}\n <div className={COLUMN_HEADING_CLASS}>\n <Skeleton className=\"inline-block h-4 w-40 align-middle\" />\n </div>\n <div className={CHIP_GRID_CLASS}>\n {/* Enough to fill the visible scroll box at any column count — the\n grid's track count follows the card width, so no fixed number\n lands flush and a partial last row is the honest shape anyway. */}\n {Array.from({ length: 16 }, (_, i) => (\n // h-11 md:h-12 — the loaded chip's own default-size height.\n <Skeleton key={i} className={cn(CHIP_CLASS, 'h-11 md:h-12')} />\n ))}\n </div>\n </>\n );\n}\n\nexport function SlotPicker({\n slots,\n timezone,\n monthOffset,\n onMonthOffsetChange,\n selectedSlot,\n onSelectSlot,\n selectedDay,\n onSelectDay,\n isLoading = false,\n isSubmitting = false,\n}: SlotPickerProps) {\n const slotsByDay = useMemo(() => {\n const map = new Map<string, number[]>();\n for (const ms of slots) {\n const key = dayKeyInZone(ms, timezone);\n const list = map.get(key) ?? [];\n list.push(ms);\n map.set(key, list);\n }\n for (const list of map.values()) list.sort((a, b) => a - b);\n return map;\n }, [slots, timezone]);\n\n const visibleMonth = useMemo(() => {\n const now = new Date();\n return new Date(now.getFullYear(), now.getMonth() + monthOffset, 1);\n }, [monthOffset]);\n\n const daySlots = selectedDay ? (slotsByDay.get(selectedDay) ?? []) : [];\n\n // Whether the VISIBLE month has anything at all. HubSpot's monthOffset\n // payloads can carry near-term slots from outside the requested month, so\n // this asks about the grid on screen, not about the payload's size.\n const monthPrefix = `${visibleMonth.getFullYear()}-${String(visibleMonth.getMonth() + 1).padStart(2, '0')}`;\n const monthHasSlots = [...slotsByDay.keys()].some(key => key.startsWith(monthPrefix));\n\n return (\n <div className={SLOT_ROW_CLASS}>\n <div className={cn(COLUMN_STACK, COLUMN_BOX, COLUMN_DIVIDER, CALENDAR_W, 'md:overflow-y-auto')}>\n <p className={COLUMN_HEADING_CLASS}>{SLOT_PICKER_HEADING}</p>\n {/* Stays mounted through a month load — `slotsByDay: null` is what\n \"nothing selectable yet\" looks like, not an unmount. */}\n <DayCalendar\n monthOffset={monthOffset}\n onMonthOffsetChange={onMonthOffsetChange}\n selectedDay={selectedDay}\n onSelectDay={onSelectDay}\n slotsByDay={isLoading ? null : slotsByDay}\n />\n </div>\n <div className={cn(COLUMN_STACK, COLUMN_BOX, 'min-w-0 md:flex-1')}>\n {isLoading ? (\n <TimeChipsSkeleton />\n ) : selectedDay ? (\n <>\n <p className={COLUMN_HEADING_CLASS}>\n {/* Format the DAY-KEY itself (plain calendar date) — re-zoning a\n local instant could label an adjacent day. */}\n {formatDateWithTimezone(dateFromDayKey(selectedDay), VIEWER_TIMEZONE, 'weekday', { viewerLocale: true })}\n </p>\n <div className={CHIP_GRID_CLASS}>\n {daySlots.map(ms => {\n const isSelected = selectedSlot === ms;\n return (\n <Button\n key={ms}\n variant={isSelected ? undefined : 'outline'}\n // The click is the submit in details-first, so the grid\n // itself is the double-book guard — the form button that\n // used to carry `disabled={isSubmitting}` is not on screen.\n disabled={isSubmitting}\n loading={isSubmitting && isSelected}\n onClick={() => {\n if (isSubmitting) return;\n onSelectSlot(ms);\n }}\n className={CHIP_CLASS}\n >\n {timeLabelInZone(ms, timezone)}\n </Button>\n );\n })}\n </div>\n </>\n ) : monthHasSlots ? (\n <p className=\"text-ods-text-secondary text-h6\">Pick a day to see available times.</p>\n ) : (\n // A fully-booked month used to just SAY \"try the next one\" and leave\n // the visitor to find the chevron in the other column. The way out\n // of a dead end belongs in the dead end.\n <div className=\"flex flex-col items-start gap-[var(--spacing-system-s)]\">\n <p className=\"text-ods-text-secondary text-h6\">No available times in {monthLabelFor(monthOffset)}.</p>\n {monthOffset < MAX_MONTH_OFFSET && (\n <Button\n variant=\"outline\"\n onClick={() => onMonthOffsetChange(monthOffset + 1)}\n rightIcon={<ChevronRight className=\"size-5\" />}\n >\n Check {monthLabelFor(monthOffset + 1)}\n </Button>\n )}\n </div>\n )}\n </div>\n </div>\n );\n}\n","'use client';\n\n/**\n * `<MeetingSchedulerDirectory />` — the embeddable scheduling DIRECTORY\n * block: clickable row cards (host avatars, title/description, For-<audience>\n * chip, next available time, boxed chevron) + the house\n * `PersistentPaginationWrapper`, fed by a host proxy's `GET\n * {apiBaseUrl}/api/meetings` (see `docs/EMBEDDING_HUBSPOT_MEETINGS.md`).\n * Rows navigate to `{bookingBasePath}/{slug}` — pair with\n * `<HubSpotMeetingScheduler>` on that page for the full white-label flow.\n *\n * Layout stability is engineered, not incidental: the rows area reserves one\n * FULL page (pageSize × 80px rows + gaps), the pagination slot holds its\n * measured 72px even when hidden, and the loading state renders same-shell\n * skeleton rows — so nothing below the block ever jumps across\n * loading ⇄ loaded ⇄ page changes ⇄ short last pages.\n *\n * SSR/host mode: pass `initialData` (host-fetched) to skip the client fetch.\n * Client/embed mode: omit it — skeleton page + self-fetch via `contentFetch`\n * (embed-auth adapters inherited for free).\n */\n\nimport { ChevronRight } from 'lucide-react';\nimport { useEffect, useMemo, useState } from 'react';\nimport Link from '../../embed-shims/next-link';\nimport { useIsHydrated } from '../../hooks/ui/use-is-hydrated';\nimport type { SchedulingLink, SchedulingLinksPayload } from '../../schemas/meeting-booking-schema';\nimport { cn } from '../../utils/cn';\nimport { contentFetch } from '../../utils/embed-content-fetch';\nimport { formatDurationCompact, formatDateWithTimezone, VIEWER_TIMEZONE } from '../../utils/format';\nimport { pageCount } from '../../utils/search-params';\nimport { EmptyState } from '../empty-state';\nimport { PersistentPaginationWrapper } from '../persistent-pagination';\nimport { AvatarStack, StatusBadge, Skeleton } from '../ui';\n\nexport interface MeetingSchedulerDirectoryProps {\n /** Endpoints prefix, default '' (same-origin `/api/meetings`). */\n apiBaseUrl?: string;\n /** Host-fetched seed — skips the client fetch when provided. */\n initialData?: SchedulingLinksPayload | null;\n /** `scope=all` — full-portal view (non-conforming links under \"other\"). */\n includeAll?: boolean;\n /** Rows per page; also the skeleton row count and the reserved rows-area height. */\n pageSize?: number;\n /** Rows link to `${bookingBasePath}/${slug}`. */\n bookingBasePath?: string;\n /** EmptyState CTA target for error/empty states (omit → no CTA). */\n contactHref?: string;\n className?: string;\n}\n\n/** Zone-aware \"next available\" label — resolved POST-mount (SSR renders the\n * static caption only, so server and client first paint agree). */\nfunction useNextAvailableLabel(ms: number | null): string | null {\n // The label is a pure function of `ms` and the viewer's zone — the ONLY\n // reason it could not be computed during render was that `Intl` resolves a\n // different zone on the server. `useIsHydrated` states that gate directly, so\n // the label is derived rather than stored: no state, and no second render\n // pass per row (this hook runs once per directory row).\n const hydrated = useIsHydrated();\n return useMemo(() => {\n if (!hydrated || ms == null) return null;\n return formatDateWithTimezone(ms, VIEWER_TIMEZONE, 'shortWeekdayDateTime', { viewerLocale: true }) || null;\n }, [hydrated, ms]);\n}\n\nfunction DirectoryRow({\n link,\n audienceLabel,\n bookingBasePath,\n}: {\n link: SchedulingLink;\n audienceLabel: string;\n bookingBasePath: string;\n}) {\n const nextLabel = useNextAvailableLabel(link.nextAvailableMs);\n\n const subtitle = [\n link.description,\n link.durationsMinutes.length > 0 ? link.durationsMinutes.map(m => formatDurationCompact(m * 60)).join(' / ') : null,\n link.kind === 'team' ? 'Team' : null,\n ]\n .filter(Boolean)\n .join(' • ');\n\n return (\n <Link\n href={`${bookingBasePath}/${link.slug}`}\n prefetch={false}\n className=\"block rounded-lg text-inherit no-underline focus:outline-none focus-visible:ring-2 focus-visible:ring-ods-accent\"\n >\n <div className=\"flex min-h-20 w-full items-center gap-4 rounded-lg border border-ods-border bg-ods-card px-4 py-3 transition-colors duration-150 hover:bg-ods-bg-hover\">\n {/* Title leads at a fixed X; hosts live in the right meta cluster. */}\n <div className=\"flex min-w-0 flex-1 flex-col justify-center text-left\">\n <p className=\"truncate text-ods-text-primary text-h3\">{link.title}</p>\n {subtitle && <p className=\"truncate text-ods-text-secondary text-h6\">{subtitle}</p>}\n </div>\n\n {/* No audience entity (scope=all \"other\" group) → no chip. */}\n {audienceLabel && (\n <StatusBadge text={`For ${audienceLabel}`} singleLine className=\"hidden shrink-0 sm:inline-flex\" />\n )}\n\n {/* Hosts stay visible on mobile too — the facepile is the row's \"who\n you're booking with\" signal; only the chip and next-available meta\n collapse on narrow widths. */}\n <AvatarStack people={link.hosts} max={3} size=\"md\" label=\"Hosts\" className=\"flex shrink-0\" />\n\n <div className=\"hidden w-44 shrink-0 flex-col items-end justify-center md:flex\">\n <p className=\"text-ods-text-secondary text-h6\">Next available</p>\n <p className=\"min-h-5 text-ods-text-primary text-h6\">\n {link.nextAvailableMs == null ? 'No times published' : (nextLabel ?? ' ')}\n </p>\n </div>\n\n {/* House boxed chevron (ChatTicketItem). */}\n <span className=\"flex size-12 shrink-0 items-center justify-center rounded-md border border-ods-border bg-ods-card\">\n <ChevronRight className=\"size-6 text-ods-text-secondary\" />\n </span>\n </div>\n </Link>\n );\n}\n\n/** Same-shell loading row (co-located-skeleton convention). */\nexport function MeetingSchedulerDirectoryRowSkeleton() {\n return (\n <div className=\"flex min-h-20 w-full items-center gap-4 rounded-lg border border-ods-border bg-ods-card px-4 py-3\">\n <div className=\"flex min-w-0 flex-1 flex-col justify-center gap-1\">\n <Skeleton className=\"h-5 w-1/2\" />\n <Skeleton className=\"h-4 w-1/3\" />\n </div>\n <Skeleton className=\"hidden h-6 w-28 shrink-0 sm:block\" />\n <Skeleton className=\"h-10 w-10 shrink-0 rounded-full\" />\n <div className=\"hidden w-44 shrink-0 flex-col items-end gap-1 md:flex\">\n <Skeleton className=\"h-4 w-24\" />\n <Skeleton className=\"h-5 w-32\" />\n </div>\n <Skeleton className=\"size-12 shrink-0 rounded-md\" />\n </div>\n );\n}\n\n/** Measured pagination-slot height (PersistentPaginationWrapper = 72px). */\nconst PAGINATION_SLOT_H = 'h-[4.5rem]';\n\nexport function MeetingSchedulerDirectory({\n apiBaseUrl = '',\n initialData = null,\n includeAll = false,\n pageSize = 6,\n bookingBasePath = '/schedule-a-call',\n contactHref,\n className,\n}: MeetingSchedulerDirectoryProps) {\n const [data, setData] = useState<SchedulingLinksPayload | null>(initialData);\n const [isLoading, setIsLoading] = useState(initialData === null);\n const [error, setError] = useState<string | null>(null);\n const [page, setPage] = useState(1);\n\n useEffect(() => {\n if (initialData !== null) return undefined; // host-seeded — skip the client fetch\n const controller = new AbortController();\n let active = true;\n const load = async () => {\n setIsLoading(true);\n setError(null);\n try {\n const qs = includeAll ? '?scope=all' : '';\n const res = await contentFetch(`${apiBaseUrl}/api/meetings${qs}`, { signal: controller.signal });\n if (!res.ok) throw new Error(`meetings ${res.status}`);\n const payload = (await res.json()) as SchedulingLinksPayload;\n if (active) setData(payload);\n } catch (err) {\n if (!active || (err instanceof DOMException && err.name === 'AbortError')) return;\n setError(err instanceof Error ? err.message : 'Failed to fetch scheduling links');\n } finally {\n if (active) setIsLoading(false);\n }\n };\n void load();\n return () => {\n active = false;\n controller.abort();\n };\n }, [initialData, includeAll, apiBaseUrl]);\n\n const rows = useMemo(() => {\n const purposes = data?.purposes ?? [];\n return purposes.flatMap(p => p.links.map(link => ({ link, audienceLabel: p.label })));\n }, [data]);\n\n const totalPages = pageCount(rows.length, pageSize);\n const pageRows = rows.slice((page - 1) * pageSize, page * pageSize);\n\n // Reserved rows-area height: pageSize × 80px rows + (pageSize−1) × 16px gaps.\n const rowsMinHeight = pageSize * 80 + (pageSize - 1) * 16;\n\n if (isLoading && !data) {\n return (\n <div className={cn('flex flex-col', className)}>\n <div className=\"flex w-full flex-col gap-4\" style={{ minHeight: rowsMinHeight }}>\n {Array.from({ length: pageSize }, (_, i) => (\n <MeetingSchedulerDirectoryRowSkeleton key={i} />\n ))}\n </div>\n <div className={cn('mt-8 flex w-full items-center justify-center', PAGINATION_SLOT_H)}>\n <Skeleton className=\"h-10 w-64\" />\n </div>\n </div>\n );\n }\n\n if (error) {\n return (\n <div className={cn('flex flex-col justify-center', className)} style={{ minHeight: rowsMinHeight + 104 }}>\n <EmptyState\n type=\"generic\"\n title=\"We couldn't load available call times\"\n description=\"Something went wrong on our side. Try again in a minute, or reach us directly.\"\n {...(contactHref ? { ctaText: 'Contact us', ctaHref: contactHref } : {})}\n />\n </div>\n );\n }\n\n if (rows.length === 0) {\n return (\n <div className={cn('flex flex-col justify-center', className)} style={{ minHeight: rowsMinHeight + 104 }}>\n <EmptyState\n type=\"generic\"\n title=\"No call times are published right now\"\n description=\"We're updating our calendars. Reach us directly and we'll set something up.\"\n {...(contactHref ? { ctaText: 'Contact us', ctaHref: contactHref } : {})}\n />\n </div>\n );\n }\n\n return (\n <div className={cn('flex flex-col', className)}>\n <div className=\"flex w-full flex-col gap-4\" style={{ minHeight: rowsMinHeight }}>\n {pageRows.map(({ link, audienceLabel }) => (\n <DirectoryRow key={link.id} link={link} audienceLabel={audienceLabel} bookingBasePath={bookingBasePath} />\n ))}\n </div>\n {/* House pagination block — dims in place during refetches; the slot\n holds its measured height even at ≤1 page (where the inner\n UnifiedPagination returns null). */}\n <div className={cn('mt-8 flex w-full items-center justify-center', PAGINATION_SLOT_H)}>\n {totalPages > 1 && (\n <PersistentPaginationWrapper\n isLoading={isLoading}\n currentPage={page}\n totalPages={totalPages}\n onPageChange={setPage}\n variant=\"blog\"\n />\n )}\n </div>\n </div>\n );\n}\n"]}
1
+ {"version":3,"sources":["/home/runner/work/openframe-oss-lib/openframe-oss-lib/openframe-frontend-core/dist/components/meeting-scheduler/index.cjs","../../../src/components/meeting-scheduler/index.tsx","../../../src/hooks/use-meeting-booking.ts","../../../src/schemas/meeting-booking-schema.ts","../../../src/components/meeting-scheduler/booking-form.tsx","../../../src/components/meeting-scheduler/confirmation.tsx","../../../src/components/meeting-scheduler/context-panel.tsx","../../../src/components/meeting-scheduler/slot-picker.tsx","../../../src/components/meeting-scheduler/directory.tsx"],"names":["useState","Fragment","jsx","jsxs","useMemo","useCallback"],"mappings":"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","file":"/home/runner/work/openframe-oss-lib/openframe-oss-lib/openframe-frontend-core/dist/components/meeting-scheduler/index.cjs","sourcesContent":[null,"'use client';\n\n/**\n * `<HubSpotMeetingScheduler />` — natively-branded booking flow over the\n * HubSpot Meetings scheduler, rendered entirely with ODS primitives (no\n * iframe, no third-party chrome, no scripts). Data flows exclusively through\n * a HOST PROXY (`GET {apiBaseUrl}/api/meetings/availability`,\n * `POST {apiBaseUrl}/api/meetings/book`) — the widget has zero HubSpot\n * knowledge and no secrets; see `docs/EMBEDDING_HUBSPOT_MEETINGS.md` for the\n * endpoint contract a host must serve (external embedders proxy through\n * their own backend — the book route is IP-rate-limited and bot-gated).\n *\n * LAYOUT (Calendly-anatomy, conversion-first): one bordered card, two\n * panels. Left/top — the CONTEXT panel: an optional host-level back edge\n * (`onBack`), host identity (avatar + name + title from\n * `availability.hosts`), meeting title/description, duration chips (the\n * duration choice lives here, NOT as a wizard step — the visitor always\n * lands straight on the calendar), and the resolved timezone.\n * Right/bottom — the ACTION panel: calendar + time slots (first available\n * day auto-selected so slots are visible immediately), then the details\n * form, then the confirmation. On the slot step that panel splits again —\n * calendar column, then times column — each carrying its own heading (owned\n * by `SlotPicker`, so loading and loaded agree). Panels stack on mobile.\n *\n * Two modes, one component:\n * - SSR mode: pass `initialAvailability` (host-fetched) — the\n * zone-independent shell (context panel, calendar) renders server-side;\n * slot TIME labels wait for the client zone resolution (deterministic\n * first paint, no hydration mismatch), and availability is refetched\n * unconditionally after mount (the seed is scaffolding for a 60s-volatile\n * resource, not truth).\n * - Client mode: omit the seed — skeleton + self-fetch on mount.\n *\n * State machine: slot → details → confirmed, with a back edge, a \"book\n * another\" reset, and a `submitting` lock as the double-booking guard.\n * `flow=\"details-first\"` (opt-in, campaign landing pages) inverts it: the\n * form is step ONE and renders alone, the slot click submits the frozen\n * answers, and a details error routes back to the repopulated form — see\n * `SCHEDULER_FLOW_PRESETS` and the `detailsFirst` branches below.\n */\n\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport type { ComponentType, ReactNode } from 'react';\nimport { useIsHydrated } from '../../hooks/ui/use-is-hydrated';\nimport { completeFormRescue } from '../../hooks/use-form-rescue';\nimport { useHumanitySignals } from '../../hooks/use-humanity-signals';\nimport { BOOKING_IN_FLIGHT_MESSAGE, useMeetingBooking } from '../../hooks/use-meeting-booking';\nimport { useToast } from '../../hooks/use-toast';\nimport {\n blocksNativeBooking,\n type BookingConfirmation,\n type MeetingAvailability,\n type MeetingBookingErrorCode,\n type MeetingHost,\n} from '../../schemas/meeting-booking-schema';\nimport { cn } from '../../utils/cn';\nimport { FORM_RESCUE_ATTEMPT_FIELD, type FormRescueDefinition } from '../../utils/form-rescue';\nimport { formatDurationCompact, formatDateWithTimezone } from '../../utils/format';\nimport { Alert, AlertDescription, Button } from '../ui';\nimport { BookingForm, BookingFormSkeleton, DEFAULT_SUBMIT_LABEL, type BookingFormProps } from './booking-form';\nimport { Confirmation } from './confirmation';\nimport { SchedulerContextPanel, ContextPanelSkeleton } from './context-panel';\nimport { SlotPicker, SlotPickerSkeleton, dayKeyInZone } from './slot-picker';\n\n/**\n * Visitor-facing copy per booking-error code. Everything except SLOT_TAKEN\n * surfaces as an error TOAST at submit time, and ONLY as a toast (owner\n * decision 2026-08-27 — no inline duplicate; hosts must mount the lib\n * Toaster, as every hub platform does globally). SLOT_TAKEN renders above\n * the calendar instead: its recovery flow returns the visitor there. An\n * unrecognized code (newer host than widget) falls back to the VALIDATION\n * copy — fail-safe, never blank.\n */\nconst BOOKING_ERROR_COPY: Record<Exclude<MeetingBookingErrorCode, 'SLOT_TAKEN'>, string> = {\n INVALID_EMAIL:\n 'That email address was rejected by our scheduling system — please use a real, reachable address (work email works best).',\n VALIDATION:\n 'Please double-check your details — especially that the email address is real and reachable — and try again.',\n TEMPORARILY_UNAVAILABLE: 'Scheduling is briefly unavailable — please try again in a minute.',\n MEETING_UNAVAILABLE: 'This meeting type has reached its booking limit for today — try another time or contact us.',\n LINK_GONE: 'This meeting type is no longer available.',\n};\n\nexport interface HubSpotMeetingSchedulerProps {\n /** Directory id of the meeting link (from the host's `/api/meetings` payload). */\n meetingId: string;\n /** Endpoints prefix, default '' (same-origin `/api/meetings/*`) — FaqSection precedent. */\n apiBaseUrl?: string;\n /** SSR-mode seed (host-fetched). Omitted → client mode (self-fetch on mount). */\n initialAvailability?: MeetingAvailability;\n /** Meeting title shown in the context panel (host page owns the h1). */\n title?: string;\n /** Short description under the title in the context panel. */\n description?: string | null;\n /** Override the hosts shown (defaults to `availability.hosts`). */\n hosts?: MeetingHost[];\n /** Pin the DISPLAY zone (rendering only — never sent upstream, never a cache key). */\n displayTimezone?: string;\n /** The link's public HubSpot booking URL — the \"Open in HubSpot\" escape hatch target. */\n fallbackUrl?: string;\n /**\n * Host-level exit, rendered as a back edge at the top of the context panel\n * (and in the loading skeleton, so it doesn't pop in). This is \"leave the\n * scheduler\", NOT the details step's back edge — it is suppressed once a\n * slot is chosen so only one Back is ever on screen. Omit it and no back\n * affordance renders at all.\n */\n onBack?: () => void;\n onBooked?: (b: BookingConfirmation) => void;\n /**\n * What the visitor sees: the step, or `'unavailable'` when the degraded box\n * replaces the flow (the load failed, or the link cannot be booked natively).\n * Reported on mount and on every change. For a host that shows something\n * beside the card at some stages only (a landing page's explore nudge).\n */\n onStageChange?: (stage: SchedulerStage) => void;\n className?: string;\n /**\n * Panel order.\n *\n * `'slot-first'` (default, unchanged): calendar → details → confirmed.\n * `'details-first'`: the form comes FIRST and the slot click submits the\n * already-validated payload. Campaign landing pages use it, where \"tell us\n * about your setup, then pick a time\" is the conversion shape.\n *\n * This is not a cosmetic swap — see the flow-dependent branches below. It is\n * opt-in precisely so `slot-first` stays byte-identical.\n */\n flow?: SchedulerFlow;\n /**\n * Replace the details panel — step ONE under `flow=\"details-first\"`, step two\n * otherwise — with a host-supplied form. It receives exactly what the built-in\n * one does, so the injected form keeps the widget's contract: the deferred\n * schema, the honeypot and elapsed-ms signals, the verbatim consent block, the\n * `onSubmit` handoff and the `isSubmitting` lock.\n *\n * The intended shape is a THIN wrapper that re-renders `BookingForm` with\n * `fieldRows` (a layout re-arrangement, all machinery reused), not a\n * hand-written form. Anything reimplementing the contract loses the bot\n * protection and the consent guarantees.\n *\n * A COMPONENT type, not a render callback: React then owns its identity, so\n * it reconciles across steps and keeps its own state instead of remounting —\n * and the honeypot `ref` rides through as a normal prop.\n *\n * Defaults to the built-in form, so every existing embed is untouched.\n */\n detailsForm?: ComponentType<BookingFormProps>;\n /**\n * The DATA form of the same override: `fieldRows`, a host consent row and\n * per-field display copy, spread onto whichever form renders. Serialisable,\n * so a Server Component can pass it across the RSC boundary where a component\n * cannot.\n */\n detailsFormProps?: Pick<BookingFormProps, 'fieldRows' | 'consent' | 'fieldCopy' | 'deniedEmailDomains'>;\n /** Form rescue for the details form (`RESCUE_FORMS.meetingBooking`). OPT-IN:\n * omitted or `null` saves nothing. */\n rescue?: FormRescueDefinition | null;\n}\n\ntype Step = 'slot' | 'details' | 'confirmed';\n/** `onStageChange`'s value: a step, or the degraded box that replaces the flow. */\nexport type SchedulerStage = Step | 'unavailable';\n\n/** Values collected at the details step, held until a slot picks the instant. */\ntype StashedDetails = {\n payload: Record<string, unknown>;\n /** Humanity signals captured while the form (and its honeypot) was mounted. */\n signals: Record<string, string | number>;\n};\n\n/** Context-panel geometry — ONE definition for the loaded card and the\n * loading skeleton, which is what keeps the two footprint-identical. */\nconst CONTEXT_PANEL_CLASS =\n 'p-[var(--spacing-system-l)] shrink-0 lg:w-[280px] border-b lg:border-b-0 lg:border-r border-ods-border lg:min-h-0 lg:overflow-y-auto';\n\n/**\n * Action panel: the elastic half, scrolling inside the fixed card.\n *\n * Padded only from `lg`. Below it the calendar and times are separate sections\n * with a divider between them, and each pads itself — one padding here would\n * inset that divider from the card's edges instead of letting it run the full\n * width the way every other rule in this card does.\n */\nconst ACTION_PANEL_CLASS = 'flex-1 min-w-0 flex flex-col md:min-h-0 lg:p-[var(--spacing-system-l)]';\n\n/**\n * The action panel's inset for the steps that are ONE block — details,\n * confirmation, the slot step's error line.\n *\n * The panel itself is padded only from `lg`, because below that the slot step\n * is two sections with a rule between them and each pads itself: one padding\n * on the panel would inset that rule instead of letting it run the card's full\n * width, the way every other rule here does. Every step that is NOT split into\n * sections has to bring the same inset with it — without it the form ran edge\n * to edge on tablet, its inputs touching the card's border.\n *\n * `md:min-h-0 md:overflow-y-auto` comes with it: from `md` up the card states\n * a height, and these steps have no inner scroller of their own the way the\n * times column does, so this is where a long form gets to scroll instead of\n * being cut off at the card's edge.\n */\nconst PANEL_STEP_CLASS = 'p-[var(--spacing-system-l)] md:min-h-0 md:overflow-y-auto lg:p-0';\n/** A step's panel: the inset above on a column that fills the action side. */\nconst STEP_PANEL_CLASS = cn('flex flex-1 flex-col', PANEL_STEP_CLASS);\n\n/**\n * The widget's height on desktop: a FIXED 380px, and the ONLY size the whole\n * card states.\n *\n * A floor was not enough. Every stage has a different natural height — the\n * context panel with two hosts ~340, the calendar column ~290, a degraded\n * stage two lines — so `min-height` only pinned the SHORT ones and let the\n * tall one push the wrapper, which is exactly the screen-shake this removes.\n * With a fixed height the box is stated once and nothing inside can move it;\n * anything taller scrolls in place.\n *\n * The elastic half derives from it: the panel passes it to the slot row and\n * the times column scrolls inside what is left. The calendar does not stretch\n * — a date grid has a size, and stretching one is how a month turns into\n * page-tall bands the moment a layout stops pinning a height. It states a\n * WIDTH (`CALENDAR_W`) and its square cells make it as tall as it is wide;\n * that width is chosen to fit inside this number, and the two are checked\n * against each other in one place rather than three.\n *\n * Six week rows, always — `fixedWeeks` — is why a height can be stated at all:\n * a 5-week month and a 6-week month occupy the same box, so paging the\n * calendar cannot resize the card.\n *\n * Exported because the stability has to survive OUTSIDE the card too: a host\n * that swaps the scheduler in and out of a slot (the onboarding \"Book a call\"\n * promo does exactly that) reserves the same box for whatever it shows\n * instead, so the swap moves nothing below it.\n *\n * Goes on the BORDERED element, always. Under `box-sizing: border-box` the\n * declared height swallows the 1px edges, so a card that carries the border\n * and a host box that carries its own both come out at exactly 380 — put it on\n * an inner wrapper instead and the border lands OUTSIDE the 380, making that\n * card 2px taller than everything it is supposed to match.\n *\n * TWO numbers, because the card has two shapes: 380 as a sidebar beside the\n * calendar (`lg`), 550 as a header strip over it (`md`). Phones get neither —\n * there a card the height of the hand holding it is the right answer and the\n * page is the scroller.\n *\n * A host that reserves this box owes it one thing: content that ADAPTS to the\n * height rather than assuming it. The onboarding promo does it by letting its\n * video take the leftover space at 16:9; a stand-in that just stacks fixed\n * blocks will overflow the shorter of the two.\n */\nexport const MEETING_SCHEDULER_H = 'md:h-[34.375rem] lg:h-[23.75rem]';\n\n/**\n * The box for `flow=\"details-first\"`'s CALENDAR and confirmation stages (and its\n * degraded stand-in), 638px from `md` (`4904:118213`), 812px on phones (the\n * calendar stage's natural height at 375px: header strip + six fixed weeks +\n * the 9.75rem chip cap). Fixed, not a floor, for the slot-first reason: the\n * times column scrolls inside a stated height.\n *\n * The FORM stage is NOT boxed. It renders at its natural height, so every\n * question the HubSpot link declares is on the page instead of scrolling inside\n * a card cut off mid-consent. What keeps that from jumping is the form's own\n * footprint discipline: the SSR seed renders the real form on first paint, and\n * the cold-start `BookingFormSkeleton` draws the same rows through the same grid.\n *\n * Hosts read it through `SCHEDULER_FLOW_PRESETS[flow].height`, never directly.\n */\nexport const MEETING_SCHEDULER_DETAILS_FIRST_H = 'h-[50.75rem] md:h-[39.875rem]';\n\nexport type SchedulerFlow = 'slot-first' | 'details-first';\n\n/** The flow every existing embed gets — the ONE spelling of the default. */\nexport const DEFAULT_SCHEDULER_FLOW: SchedulerFlow = 'slot-first';\n\n/**\n * What differs between the two flows as DATA — first step, the box a host\n * reserves, the form's submit copy. Exported so a host that swaps the card in\n * and out reads `SCHEDULER_FLOW_PRESETS[flow].height` instead of re-deriving\n * the pairing. The behavioural branches (lock, back edge, error routing) stay\n * in the component: they are logic, not configuration.\n */\nexport const SCHEDULER_FLOW_PRESETS: Record<\n SchedulerFlow,\n { initialStep: Exclude<Step, 'confirmed'>; height: string; submitLabel: string; footerNote?: string }\n> = {\n 'slot-first': { initialStep: 'slot', height: MEETING_SCHEDULER_H, submitLabel: DEFAULT_SUBMIT_LABEL },\n 'details-first': {\n initialStep: 'details',\n height: MEETING_SCHEDULER_DETAILS_FIRST_H,\n submitLabel: 'Continue',\n footerNote: 'Next step: pick a slot that works. Calendar invite lands right after.',\n },\n};\n\n/**\n * The two-panel card's box, shared verbatim by the loading skeleton and the\n * loaded card so neither can drift from the other.\n *\n * The height is applied per flow (`SCHEDULER_FLOW_PRESETS[flow].height`); what this adds is the\n * flex column that makes them work. On TABLET the height is stated for the\n * same reason the desktop one is: a CEILING only pins the tall stages. The short ones — the empty\n * month (\"No available times in September\"), a five-week month, the cold-start\n * skeleton — each settled at their own natural height, so paging a month or\n * waiting out a fetch resized the card under the visitor. Stating the height\n * makes every stage the same box and moves the variation inside, where the\n * times column already scrolls.\n *\n * 550, not the mock's 498: the mock draws one host and no timezone field, and\n * a real link has two hosts and a zone the visitor must be able to change.\n * That is ~55px of content the mock never budgeted for, and the month below it\n * is what would otherwise pay for it.\n *\n * The strip is `shrink-0`, so what is left goes to the slot row, which spends\n * it on a calendar sized to fit (no scroll) and a times list that scrolls on\n * its own.\n *\n * `md:flex md:flex-col` is what makes that possible at all. A stated height on\n * a plain block only clips (this card is `overflow-hidden` for its corners) —\n * it is the flex column plus the inner wrapper's `min-h-0` that lets the\n * content shrink into the height instead of being cut off by it.\n */\nconst CARD_CLASS = cn(\n 'overflow-hidden rounded-md border border-ods-border bg-ods-card',\n 'md:flex md:flex-col',\n // The height is NOT baked in any more: it is chosen per flow at each render\n // site (`cardClass`), because details-first needs a taller box and both\n // constants are module scope.\n);\n\n/** Context strip over action panel, side by side from `lg`. `md:flex-1\n * md:min-h-0` is the pair that makes it exactly as tall as the card states —\n * grow into a stated height that content does not reach, shrink into one it\n * overruns — so neither a short stage nor a long one changes the box. */\nconst CARD_INNER_CLASS = 'flex flex-col md:min-h-0 md:flex-1 lg:h-full lg:flex-row';\n\n/** The two-line stages (load failure, \"booked on HubSpot\") — the SAME box as\n * the booking card, because these are stages of one widget in one slot and a\n * stage that collapses to two lines would snap the page shut under the\n * visitor. Their content is centred in it and scrolls if a message ever\n * outgrows it. */\nconst CARD_DEGRADED_CLASS = cn(\n 'flex flex-col items-start gap-[var(--spacing-system-m)] rounded-md border border-ods-border bg-ods-card p-[var(--spacing-system-lf)]',\n 'md:justify-center md:overflow-y-auto',\n);\n\n/**\n * The two-line stages of the card (load failure, \"booked on HubSpot\", a host's\n * own \"calendar unavailable\") — ONE shape, exported so a host renders its\n * fallback in the same box instead of re-typing the chrome. The box is the\n * FLOW's: a host names the flow it would have mounted and the height follows\n * from {@link SCHEDULER_FLOW_PRESETS}, so the pairing is never re-derived.\n */\nexport function SchedulerDegradedCard({\n flow = DEFAULT_SCHEDULER_FLOW,\n className,\n message,\n action,\n children,\n}: {\n flow?: SchedulerFlow;\n className?: string;\n /** The one line the stage says, in the card's own type — so a host's fallback never picks a different scale. */\n message?: string;\n /** The one way out (the escape hatch, or a host's own link). */\n action?: ReactNode;\n children?: ReactNode;\n}) {\n return (\n <div className={cn(CARD_DEGRADED_CLASS, SCHEDULER_FLOW_PRESETS[flow].height, className)}>\n {message && <p className=\"text-ods-text-secondary text-h6\">{message}</p>}\n {action}\n {children}\n </div>\n );\n}\n\n/**\n * Fail-closed gate: a link with a REQUIRED question no control can answer (a\n * file upload), or whose consent block is malformed, must NOT render a\n * half-working native form (a silently dropped required question or missing\n * consent copy is worse than no native form) — the escape hatch takes over.\n * An unfamiliar question TYPE is not a reason: it resolves to the nearest\n * control (`resolveFormFieldControl`), so one new HubSpot type can no longer\n * take the whole booking form down.\n */\nfunction isNativelyBookable(availability: MeetingAvailability): boolean {\n if (availability.formFields.some(blocksNativeBooking)) return false;\n const consent = availability.legalConsent;\n if (consent) {\n if (typeof consent.processingConsentText !== 'string') return false;\n if (!Array.isArray(consent.communicationConsentCheckboxes)) return false;\n }\n return true;\n}\n\nexport function HubSpotMeetingScheduler({\n meetingId,\n apiBaseUrl = '',\n initialAvailability,\n title,\n description,\n hosts,\n displayTimezone,\n fallbackUrl,\n onBack,\n onBooked,\n onStageChange,\n className,\n flow = DEFAULT_SCHEDULER_FLOW,\n detailsForm: DetailsForm = BookingForm,\n detailsFormProps,\n rescue = null,\n}: HubSpotMeetingSchedulerProps) {\n const {\n availability,\n isLoadingAvailability,\n isFetchingAvailability,\n availabilityError,\n monthOffset,\n setMonthOffset,\n refetchAvailability,\n book,\n isSubmitting,\n } = useMeetingBooking({ meetingId, apiBaseUrl, initialAvailability });\n\n const detailsFirst = flow === 'details-first';\n\n // Zone resolution happens POST-mount (Intl) unless the embedder pins one —\n // the initial (server) render stays deterministic; time labels appear once\n // a zone exists.\n //\n // Priority, unchanged: the visitor's own pick (the panel's zone selector) >\n // the embedder's `displayTimezone` > the browser's resolved zone. Only the\n // last of the three is client-only, so `useIsHydrated` is the whole reason\n // this could not be computed during render — with that gate stated directly,\n // the zone is DERIVED and the effect that used to publish it (a setState in\n // an effect body, and a dead render for every mount) is gone.\n const hydrated = useIsHydrated();\n const [pickedTimezone, setTimezone] = useState<string | null>(null);\n const resolvedLocalTimezone = useMemo(() => {\n if (!hydrated) return null;\n try {\n return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';\n } catch {\n return 'UTC';\n }\n }, [hydrated]);\n const timezone = pickedTimezone ?? displayTimezone ?? resolvedLocalTimezone;\n\n const preset = SCHEDULER_FLOW_PRESETS[flow];\n const [step, setStep] = useState<Step>(preset.initialStep);\n // The same predicates, in the same order, as the degraded returns below: a\n // cold load is still the current step; a failed load or a link the form cannot\n // reproduce is the degraded box.\n const stage: SchedulerStage =\n !(isLoadingAvailability && !availability) &&\n (Boolean(availabilityError) || !availability || !isNativelyBookable(availability))\n ? 'unavailable'\n : step;\n // Reported from an effect, not beside each `setStep`: the stage moves in several\n // places (one of them during render, on a link swap), and a host setState run\n // from this component's render is a cross-component update. The ref keeps an\n // inline host callback from re-firing on every render; it is written in an\n // effect, never during render.\n const onStageChangeRef = useRef(onStageChange);\n useEffect(() => {\n onStageChangeRef.current = onStageChange;\n });\n useEffect(() => {\n onStageChangeRef.current?.(stage);\n }, [stage]);\n // State, not a ref: the link-swap reset below writes it DURING RENDER, and\n // this file's own rule forbids writing a ref there. \"Frozen\" means written\n // once at Continue, not `useRef`.\n const [stash, setStash] = useState<StashedDetails | null>(null);\n /** Synchronous in-flight lock for the details-first slot click — see `onSelectSlot`. */\n const inFlightRef = useRef(false);\n /** details-first: a slot's POST is in flight. The ref covers the frame before\n * `isSubmitting` renders true, so every guard reads both. */\n const postInFlight = useCallback(\n () => detailsFirst && (isSubmitting || inFlightRef.current),\n [detailsFirst, isSubmitting],\n );\n\n /**\n * details-first step ONE is the form and nothing else — the mock\n * (`4904:117130`) draws no context panel beside it, and paints the card on the\n * page ground so the fields read as the raised elements. The panel (host,\n * fixed length, timezone, and the Back edge to this form) belongs to the\n * calendar step, exactly as slot-first has always drawn it.\n */\n const formOnly = detailsFirst && step === 'details';\n const [pickedDurationMs, setDurationMs] = useState<number | null>(null);\n const [pickedDay, setSelectedDay] = useState<string | null>(null);\n const [selectedSlot, setSelectedSlot] = useState<number | null>(null);\n const [confirmation, setConfirmation] = useState<BookingConfirmation | null>(null);\n const [bookingError, setBookingError] = useState<MeetingBookingErrorCode | null>(null);\n\n const { honeypotInputProps, getSignals, resetSignals } = useHumanitySignals();\n const { toast } = useToast();\n\n // Reset the machine when the host switches links. Adjusted while rendering —\n // React's documented pattern for a prop-driven reset — so the swapped-in link\n // never paints a frame of the previous link's chosen day, slot or\n // confirmation. The ref counter stays in an effect: a ref must not be written\n // during render.\n const autoAdvanceCount = useRef(0);\n const [machineFor, setMachineFor] = useState(meetingId);\n if (machineFor !== meetingId) {\n setMachineFor(meetingId);\n setStep(preset.initialStep);\n setDurationMs(null);\n setSelectedDay(null);\n setSelectedSlot(null);\n setConfirmation(null);\n setBookingError(null);\n // Clearing the stash is load-bearing: otherwise the swapped-in link would\n // POST the PREVIOUS link's answers, against a schema the server rebuilds\n // from the new link's metadata.\n setStash(null);\n }\n useEffect(() => {\n autoAdvanceCount.current = 0;\n }, [meetingId]);\n\n // Memoised because the `??` fallback minted a NEW array on every\n // render, which made the memo below re-run every time — i.e. do nothing.\n const durations = useMemo(() => availability?.durationsMs ?? [], [availability?.durationsMs]);\n const shownHosts = hosts ?? availability?.hosts ?? [];\n\n // Default to the first offered duration — the visitor lands straight on\n // the calendar; duration is a context-panel chip, not a wizard step.\n // Derived, not stored: \"no explicit pick yet\" already means \"the first one\n // offered\", so the effect that used to write that back into state was a\n // second render pass for a value the first render could name — and the frame\n // in between was the empty calendar this default exists to avoid.\n const durationMs = pickedDurationMs ?? durations[0] ?? null;\n\n const slots = useMemo(() => {\n if (!availability || durationMs == null) return [];\n return availability.slotsByDurationMs[String(durationMs)] ?? [];\n }, [availability, durationMs]);\n\n // Auto-select the first day WITH SLOTS IN THE VISIBLE MONTH (once the zone\n // is known) so the visitor sees concrete times immediately — never a dead\n // \"pick a day\" state, and NEVER a day from another month than the calendar\n // shows (HubSpot's monthOffset payloads can carry near-term slots outside\n // the requested month — the visible grid is the authority).\n //\n // Derived, with the visitor's own pick winning for as long as it is still one\n // of the days on offer. Every input — the slots, the visible month, the zone\n // — is available while rendering, so publishing this from an effect only\n // guaranteed that each of those changes committed one dead \"pick a day\" frame\n // before the calendar filled itself in.\n const selectedDay = useMemo(() => {\n if (step !== 'slot' || !timezone) return pickedDay;\n const now = new Date();\n const visible = new Date(now.getFullYear(), now.getMonth() + monthOffset, 1);\n const monthPrefix = `${visible.getFullYear()}-${String(visible.getMonth() + 1).padStart(2, '0')}`;\n const inMonth = slots.filter(ms => dayKeyInZone(ms, timezone).startsWith(monthPrefix));\n if (inMonth.length === 0) {\n // Nothing this month — a pick left over from a different month is not a\n // selection in the grid the visitor is looking at.\n return pickedDay && !pickedDay.startsWith(monthPrefix) ? null : pickedDay;\n }\n const dayKeys = new Set(inMonth.map(ms => dayKeyInZone(ms, timezone)));\n if (pickedDay && dayKeys.has(pickedDay)) return pickedDay;\n return dayKeyInZone(inMonth[0], timezone);\n }, [step, timezone, slots, pickedDay, monthOffset]);\n\n // Fully-booked month with more ahead → auto-advance (bounded, ≤3 hops)\n // before showing the empty state.\n useEffect(() => {\n if (\n step === 'slot' &&\n availability &&\n !isFetchingAvailability &&\n durationMs != null &&\n slots.length === 0 &&\n availability.hasMore &&\n autoAdvanceCount.current < 3 &&\n // Not while a chip's POST is in flight — paging would unmount it.\n !postInFlight()\n ) {\n autoAdvanceCount.current += 1;\n setMonthOffset(monthOffset + 1);\n }\n }, [step, availability, isFetchingAvailability, durationMs, slots, monthOffset, setMonthOffset, postInFlight]);\n\n /** The back edge's destination from the form: the calendar, with any\n * submit error cleared so the visitor doesn't carry it back. */\n const backToSlot = useCallback(() => {\n setStep('slot');\n setBookingError(null);\n }, []);\n\n /** details-first's inverse: back from the CALENDAR to the form. The stash is\n * what repopulates it, so nothing is cleared here but the error. */\n const backToDetails = useCallback(() => {\n // Not while a slot's POST is in flight — the step must stay on the\n // calendar the spinner lives on until the result routes it.\n if (postInFlight()) return;\n // No slot rides back to the form: in this flow the form never legitimately\n // holds one (the slot IS the submit), and a chip the server rejected with a\n // slot-step toast would otherwise print in the form's summary line.\n setSelectedSlot(null);\n setStep('details');\n setBookingError(null);\n }, [postInFlight]);\n\n /**\n * details-first: Continue does not POST. It validates, freezes the answers\n * and the humanity signals (captured HERE, while the form and its honeypot\n * are still mounted — `getSignals()` reads a detached ref once they unmount),\n * and hands over to the calendar.\n */\n const stashDetails = useCallback(\n (payload: Record<string, unknown>): Promise<void> => {\n setBookingError(null);\n setStash({ payload, signals: getSignals() });\n setStep('slot');\n // The visitor has been filling a form; the month they are about to see\n // may have moved on, and its hop budget should start fresh.\n autoAdvanceCount.current = 0;\n void refetchAvailability();\n // Deliberately NOT `async`: nothing here is awaited. The refetch is\n // fire-and-forget so the calendar paints from cache the moment the step\n // flips, and awaiting it would hold `BookingForm`'s isSubmitting past\n // the unmount. `onSubmit` is Promise-returning, hence the explicit\n // resolve rather than an empty async body (which `require-await` flags).\n return Promise.resolve();\n },\n [getSignals, refetchAvailability],\n );\n\n const handleSubmit = useCallback(\n async (payload: Record<string, unknown>) => {\n // Captured BEFORE the clear below: `book()` reports a re-entrant call as\n // a VALIDATION code carrying `BOOKING_IN_FLIGHT_MESSAGE`, and by\n // then this clear has already run — so a double-click would wipe a\n // visible SLOT_TAKEN alert on its way to being ignored.\n const priorError = bookingError;\n setBookingError(null);\n const result = await book(payload);\n\n // Not an error: the hook's in-flight guard. No toast, no step change.\n if (!result.ok && result.message === BOOKING_IN_FLIGHT_MESSAGE) {\n setBookingError(priorError);\n return;\n }\n if (result.ok && result.confirmation) {\n // The details form may have unmounted (details-first); its attempt id rides the payload.\n completeFormRescue(rescue, payload[FORM_RESCUE_ATTEMPT_FIELD]);\n setConfirmation(result.confirmation);\n setStep('confirmed');\n onBooked?.(result.confirmation);\n return;\n }\n const code = result.code ?? 'TEMPORARILY_UNAVAILABLE';\n setBookingError(code);\n if (code === 'SLOT_TAKEN') {\n // Recover: refresh the grid (the slot vanishes) and reset the timing\n // signal so the retry isn't flagged too-fast.\n setSelectedSlot(null);\n setStep('slot');\n // NOT in details-first: the retry re-POSTs the frozen stash, so\n // `getSignals()` is never consulted again — but `resetSignals()` also\n // re-stamps `startedAtRef`, which would make a LATER Continue (after a\n // VALIDATION round-trip) read as too-fast against the min-fill floor.\n if (!detailsFirst) resetSignals();\n void refetchAvailability();\n } else {\n // details-first: the chip that submitted is un-picked on EVERY failure —\n // selection means submit in this flow, so a rejected chip must not sit\n // in the selected variant (and the form's summary must never show a\n // slot the server did not accept).\n if (detailsFirst) setSelectedSlot(null);\n if (detailsFirst && (code === 'VALIDATION' || code === 'INVALID_EMAIL')) {\n // DETAILS errors: the form is unmounted by now, so send the visitor\n // back to it (the stash repopulates every answer).\n setStep('details');\n }\n // The error surface is the TOAST, full stop (host-mounted Toaster —\n // every hub platform mounts it globally; embedders must too). A silent\n // return to an unchanged form would read as nothing having happened.\n toast({\n variant: 'error',\n title: 'Booking failed',\n description: BOOKING_ERROR_COPY[code] ?? BOOKING_ERROR_COPY.VALIDATION,\n });\n }\n },\n [book, bookingError, detailsFirst, onBooked, refetchAvailability, rescue, resetSignals, toast],\n );\n\n const escapeHatch = fallbackUrl ? (\n <Button variant=\"outline\" size=\"small-legacy\" href={fallbackUrl} openInNewTab>\n Open in HubSpot\n </Button>\n ) : null;\n\n // ---- terminal / degraded states -----------------------------------------\n\n /** The card's ONE back edge, per flow and step — the skeleton and the loaded\n * panel wire the same one, so a month load can never swap it for the host's\n * exit under a details-first visitor. */\n const backEdge = detailsFirst ? (step === 'slot' ? backToDetails : onBack) : step === 'details' ? backToSlot : onBack;\n\n /**\n * The card states ONE height and everything inside derives from it (see\n * `MEETING_SCHEDULER_H`). details-first's tallest stage is the form, so it\n * gets its own fixed pair rather than a floor — a floor was tried here before\n * and let the tall stage push the wrapper.\n *\n * Placed BEFORE the host `className`: `cn` is tailwind-merge-backed and\n * last-wins, so appending it after would make a host's own `h-*` unreachable,\n * which is the override the height-inside-CARD_CLASS arrangement allows today.\n */\n // details-first's form stage grows with the link's questions (see\n // MEETING_SCHEDULER_DETAILS_FIRST_H); every other stage keeps the stated box.\n // `formOnly` is exactly that stage (`detailsFirst && step === 'details'`), so\n // slot-first, the calendar, the confirmation and the skeleton of either stay boxed.\n const boxed = !formOnly;\n const cardClass = cn(\n CARD_CLASS,\n boxed ? preset.height : 'md:h-auto',\n detailsFirst && 'flex flex-col',\n formOnly && 'bg-ods-bg',\n className,\n );\n /** details-first states a height on phones too (see MEETING_SCHEDULER_DETAILS_FIRST_H), so\n * the wrappers that let content shrink into a stated height run at every width there. */\n const innerClass = cn(CARD_INNER_CLASS, detailsFirst && boxed && 'min-h-0 flex-1');\n const actionPanelClass = cn(ACTION_PANEL_CLASS, detailsFirst && boxed && 'min-h-0 overflow-y-auto');\n const stepPanelClass = cn(STEP_PANEL_CLASS, detailsFirst && boxed && 'min-h-0 overflow-y-auto');\n\n /** One card SHAPE for every degraded return below. */\n const degraded = (message: string) => (\n <SchedulerDegradedCard flow={flow} className={className} message={message} action={escapeHatch} />\n );\n const degradedCard = degraded(\"We couldn't load available call times. Please try again shortly.\");\n\n if (isLoadingAvailability && !availability) {\n // COLD start only — a month already in the query cache renders straight\n // into the loaded card. Composed inside the SAME wrappers as that card\n // (`ContextPanelSkeleton` beside the real slot-area layout), so nothing\n // shifts when it swaps.\n return (\n <div className={cardClass}>\n <div className={innerClass}>\n {!formOnly && <ContextPanelSkeleton onBack={backEdge} className={CONTEXT_PANEL_CLASS} />}\n <div className={actionPanelClass}>\n {/* Both terms matter. Without `flow` this stays a calendar where\n the form belongs; without the step term, paging a month AFTER\n Continue (which happens at `step === 'slot'`) would swap the\n calendar out for a form skeleton and back. */}\n {formOnly ? (\n // The SAME wrapper the loaded form gets, so the skeleton never\n // states its own inset.\n <div className={stepPanelClass}>\n <BookingFormSkeleton\n fieldRows={detailsFormProps?.fieldRows}\n consent={Boolean(detailsFormProps?.consent)}\n footerNote={preset.footerNote}\n />\n </div>\n ) : (\n <SlotPickerSkeleton monthOffset={monthOffset} />\n )}\n </div>\n </div>\n </div>\n );\n }\n\n if (availabilityError || !availability) return degradedCard;\n\n if (!isNativelyBookable(availability)) {\n // Fail closed — never render a half-working native form on a link with\n // questions or consent we can't faithfully reproduce.\n return degraded('This meeting type is booked directly on HubSpot.');\n }\n\n // ---- the card ------------------------------------------------------------\n\n return (\n <div className={cardClass}>\n <div className={innerClass}>\n {!formOnly && (\n <SchedulerContextPanel\n hosts={shownHosts}\n title={title}\n description={description}\n durationsMs={durations}\n selectedDurationMs={durationMs}\n onSelectDuration={ms => {\n setDurationMs(ms);\n setSelectedDay(null);\n setSelectedSlot(null);\n // slot-first only. In details-first `details` is step ONE, so this\n // would yank a mid-typing visitor onto the calendar and unmount the\n // form under them.\n if (!detailsFirst && step === 'details') setStep('slot');\n }}\n timezone={timezone}\n onTimezoneChange={setTimezone}\n // ONE back edge for the whole card, in the panel that never swaps.\n // Its destination is simply \"the previous step\": from the form back\n // to the calendar, from the calendar out of the scheduler (if the\n // host gave us an exit at all). Two Backs on screen reading the same\n // word with different destinations was the ambiguity the details\n // step used to carry.\n // details-first inverts the mapping: Back at `slot` returns to the\n // form. At `details` the form-only layout mounts no panel, so a host\n // exit is not rendered there (the page around the card is the exit).\n onBack={backEdge}\n // `locked` HIDES the selectors (post-selection steps); `disabled`\n // keeps them mounted but inert. details-first needs the second while\n // a POST is in flight: a mid-flight change would clear the slot\n // under the spinner while the body already built carries the old\n // duration — and unmounting the row would shift the grid under it.\n locked={detailsFirst ? step === 'confirmed' : step !== 'slot'}\n disabled={detailsFirst && isSubmitting}\n // The zone still governs every time on screen — including the\n // summary line on the form — so the picker stays until there is\n // nothing left to re-read in it.\n showTimezone={step !== 'confirmed'}\n className={CONTEXT_PANEL_CLASS}\n />\n )}\n\n {/* Scrolls INSIDE the fixed card rather than growing it — see\n MEETING_SCHEDULER_H. `min-h-0` is what lets it: a flex item's\n default `min-height:auto` refuses to shrink below its content. */}\n <div className={actionPanelClass}>\n {step === 'confirmed' && confirmation && timezone ? (\n <div className={stepPanelClass}>\n <Confirmation confirmation={confirmation} timezone={timezone} />\n </div>\n ) : step === 'details' &&\n (detailsFirst\n ? // The form is step ONE here: no slot yet, and `timezone` is null\n // on the server render, so neither may gate the PANEL. A link\n // publishing no durations has no form worth showing; it gets the\n // \"nothing published\" message below at every moment, refetch or\n // not — never a calendar skeleton in the form-only layout.\n durations.length > 0\n : durationMs != null && selectedSlot != null && timezone) ? (\n <div className={cn(stepPanelClass, 'gap-[var(--spacing-system-m)]')}>\n {/* Top-aligned, and no back edge of its own: the ONE back edge\n lives in the context panel at every step (the `onBack` wired on `SchedulerContextPanel`),\n which is where the design puts it and the only spot that\n stays put while this side swaps between calendar and form.\n\n The summary keeps its OWN guard — including `timezone`, which\n is null pre-hydration and which `Intl.DateTimeFormat` throws a\n RangeError on rather than ignoring. */}\n {selectedSlot != null && durationMs != null && timezone != null && (\n <p className=\"text-ods-text-primary text-h4\">\n {formatDateWithTimezone(selectedSlot, timezone, 'weekdayDateTimeZoned', { viewerLocale: true })} ·{' '}\n {formatDurationCompact(durationMs / 1000)}\n </p>\n )}\n <DetailsForm\n {...detailsFormProps}\n availability={availability}\n meetingId={meetingId}\n startTimeMs={selectedSlot ?? undefined}\n durationMs={durationMs ?? undefined}\n timezone={timezone}\n deferSlot={detailsFirst}\n submitLabel={preset.submitLabel}\n footerNote={preset.footerNote}\n initialValues={detailsFirst ? stash?.payload : undefined}\n isSubmitting={isSubmitting}\n onSubmit={detailsFirst ? stashDetails : handleSubmit}\n honeypotInputProps={honeypotInputProps}\n getSignals={getSignals}\n rescue={rescue}\n />\n </div>\n ) : (\n <div className=\"flex flex-col gap-[var(--spacing-system-m)] md:min-h-0 md:flex-1\">\n {bookingError === 'SLOT_TAKEN' && (\n <Alert\n variant=\"warning\"\n className=\"mx-[var(--spacing-system-l)] mt-[var(--spacing-system-l)] w-auto lg:m-0 lg:w-full\"\n >\n <AlertDescription>\n That time was just taken — pick another slot below. (If you already submitted, check your email for\n a confirmation before rebooking.)\n </AlertDescription>\n </Alert>\n )}\n {timezone && durationMs != null ? (\n // The picker owns BOTH the loading and the empty month now —\n // it keeps the calendar up either way, so there is no branch\n // here that can take it off screen mid-flow.\n <SlotPicker\n slots={slots}\n timezone={timezone}\n monthOffset={monthOffset}\n onMonthOffsetChange={o => {\n // details-first: a POST is in flight from this grid; paging\n // would clear the spinning chip and swap the key under it.\n if (postInFlight()) return;\n setSelectedDay(null);\n setSelectedSlot(null);\n setMonthOffset(o);\n }}\n selectedSlot={selectedSlot}\n onSelectSlot={ms => {\n if (!detailsFirst) {\n setSelectedSlot(ms);\n setStep('details');\n return;\n }\n // details-first: the slot IS the submit. The step stays\n // 'slot' for the whole POST — moving it here would unmount\n // the chip the spinner lives on and fight the SLOT_TAKEN\n // branch, which puts the visitor back on this calendar.\n // `isSubmitting` is the render's snapshot; a second chip\n // clicked in the same frame sees it false. The ref is the\n // synchronous lock, so the second click changes nothing.\n if (postInFlight() || !stash) return;\n inFlightRef.current = true;\n setSelectedSlot(ms);\n void handleSubmit({\n ...stash.payload,\n ...stash.signals,\n meetingId,\n // `ms`, NOT `selectedSlot` — the setState above has not\n // committed inside this handler, so state holds null on\n // the first booking and the PREVIOUS slot on a retry.\n startTimeMs: ms,\n // Live values: both stay editable after Continue, so the\n // copies frozen at that point can be stale.\n durationMs,\n timezone,\n }).finally(() => {\n inFlightRef.current = false;\n });\n }}\n selectedDay={selectedDay}\n onSelectDay={day => {\n // Same lock: another day would swap the times column out\n // from under the spinning chip.\n if (postInFlight()) return;\n setSelectedDay(day);\n }}\n isLoading={isLoadingAvailability}\n // Distinct from `isLoading`, which swaps the whole times\n // column for a skeleton: the grid must stay up with the\n // clicked chip spinning.\n isSubmitting={detailsFirst ? isSubmitting : undefined}\n />\n ) : durations.length === 0 && (!isFetchingAvailability || formOnly) ? (\n // The LINK publishes nothing at all — a different thing from a\n // fully-booked month, and the only case the escape hatch is\n // the right answer to. In the form-only layout it holds through\n // a refetch too: the alternative frame is a calendar skeleton\n // where a form belongs.\n <div className=\"flex flex-col items-start gap-[var(--spacing-system-m)]\">\n <p className=\"text-ods-text-secondary text-h6\">No call times are published right now.</p>\n {escapeHatch}\n </div>\n ) : (\n // Pre-mount timezone-resolution window (every SSR load hits\n // this for one frame-batch) — same layout, real calendar, so\n // nothing shifts when the picker takes over.\n <SlotPickerSkeleton monthOffset={monthOffset} />\n )}\n </div>\n )}\n </div>\n </div>\n </div>\n );\n}\n\n// The override surface: a host builds its `detailsForm` by re-rendering\n// `BookingForm` with `fieldRows`, so it needs both from this entry point (the\n// package exports `./components/meeting-scheduler`, not the file beneath it).\nexport {\n BookingForm,\n BookingFormSkeleton,\n type BookingFormProps,\n type BookingFieldRow,\n type BookingFieldSlot,\n type BookingFieldCopy,\n type BookingFormConsent,\n} from './booking-form';\n\nexport {\n MeetingSchedulerDirectory,\n MeetingSchedulerDirectoryRowSkeleton,\n type MeetingSchedulerDirectoryProps,\n} from './directory';\nexport type { MeetingAvailability, BookingConfirmation, MeetingBookingErrorCode, MeetingHost };\nexport type { SchedulingLink, SchedulingLinksPayload, DeniedEmailDomains } from '../../schemas/meeting-booking-schema';\n","'use client';\n\nimport { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';\nimport { useCallback, useLayoutEffect, useRef, useState } from 'react';\nimport {\n MEETING_BOOKING_ERROR_CODES,\n type BookingConfirmation,\n type MeetingAvailability,\n type MeetingBookingErrorCode,\n} from '../schemas/meeting-booking-schema';\nimport { contentFetch } from '../utils/embed-content-fetch';\nimport { MAX_MONTH_OFFSET } from '../utils/hubspot-meetings-convention';\n\n/**\n * useMeetingBooking — data layer for `<HubSpotMeetingScheduler>` (the widget\n * files never inline fetch; same split as `useContactSubmission`/ContactForm).\n *\n * REQUIRES a `QueryClientProvider` in the host, like every other query-backed\n * surface in this lib (tickets, chat, onboarding-guides). `@tanstack/react-query`\n * is a declared peer dependency; the lib never mounts a provider of its own.\n *\n * Availability is a per-month GET, cached per (host, link, month). The cache is\n * the point: paging back to a month already seen is INSTANT and silent, which\n * is what a hand-rolled `useEffect` + `useState` could not do — it re-fetched\n * and re-skeletoned the same month every time the visitor stepped back.\n *\n * Deliberately NOT `placeholderData: keepPreviousData`. The payload is\n * month-scoped, so last month's slots under this month's caption describe days\n * that aren't on screen — a grid that lights up the wrong dates is worse than\n * one that is briefly inert. What kills the flicker instead is WHERE the\n * loading state lands: the calendar is fully derivable from the date alone, so\n * it keeps rendering (with nothing selectable yet) while only the times column\n * — the part that genuinely has no answer — shows a placeholder.\n *\n * `staleTime` is ZERO: the host serves availability live (no-store), and a\n * cached month re-shown while paging back is organizer-visible staleness. A\n * slot gone by the time it is clicked costs a booking — always refetch.\n *\n * Booking: a mutation, guarded so a second submit can't start while one is in\n * flight (the transport never retries POST — this is the only double-booking\n * guard). Errors surface as typed `MeetingBookingErrorCode`s the widget keys\n * its recovery UI off, never as thrown rejections.\n *\n * All requests go through the host proxy (`apiBaseUrl` prefix, FaqSection\n * precedent) via `contentFetch`, so embedded hosts with an embed-auth adapter\n * inherit auth with zero extra wiring.\n */\n\nexport interface BookingResult {\n ok: boolean;\n confirmation?: BookingConfirmation;\n code?: MeetingBookingErrorCode;\n message?: string;\n}\n\n/** Always refetch — availability is served live (no-store) by the host; a\n * cached month shown while paging back is exactly the staleness the server\n * layers were stripped to kill (2026-08-27). */\nconst AVAILABILITY_STALE_MS = 0;\n\n/** Query key for one month of one link. Exported so a host can prefetch or\n * invalidate a month it knows changed (e.g. after booking elsewhere). */\nexport function meetingAvailabilityKey(apiBaseUrl: string, meetingId: string, monthOffset: number) {\n return ['meeting-availability', apiBaseUrl, meetingId, monthOffset] as const;\n}\n\n/** The message `book()` returns for a re-entrant call — a sentinel, not an error; the scheduler matches on it. */\nexport const BOOKING_IN_FLIGHT_MESSAGE = 'Already submitting';\n\nexport function useMeetingBooking(options: {\n meetingId: string;\n apiBaseUrl?: string;\n initialAvailability?: MeetingAvailability;\n}) {\n const { meetingId, apiBaseUrl = '', initialAvailability } = options;\n const queryClient = useQueryClient();\n\n const seededOffset = initialAvailability?.meetingId === meetingId ? initialAvailability.monthOffset : undefined;\n const [monthOffset, setMonthOffsetState] = useState(seededOffset ?? 0);\n\n const setMonthOffset = useCallback((offset: number) => {\n setMonthOffsetState(Math.max(0, Math.min(MAX_MONTH_OFFSET, offset)));\n }, []);\n\n // Link switch resets paging — without this, the new link is queried at the\n // PREVIOUS link's month. Layout effect so the query below sees the reset\n // offset in the same commit.\n const prevMeetingIdRef = useRef(meetingId);\n useLayoutEffect(() => {\n if (prevMeetingIdRef.current === meetingId) return;\n prevMeetingIdRef.current = meetingId;\n setMonthOffsetState(0);\n }, [meetingId]);\n\n // The SSR seed covers exactly one (link, month) cell of the cache.\n const seed =\n initialAvailability?.meetingId === meetingId && initialAvailability.monthOffset === monthOffset\n ? initialAvailability\n : undefined;\n\n const availabilityQuery = useQuery({\n queryKey: meetingAvailabilityKey(apiBaseUrl, meetingId, monthOffset),\n queryFn: async ({ signal }) => {\n const params = new URLSearchParams({ meeting: meetingId, monthOffset: String(monthOffset) });\n const res = await contentFetch(`${apiBaseUrl}/api/meetings/availability?${params}`, { signal });\n if (!res.ok) throw new Error(`availability ${res.status}`);\n return (await res.json()) as MeetingAvailability;\n },\n staleTime: AVAILABILITY_STALE_MS,\n initialData: seed,\n // Stamped as infinitely old on purpose: the seed is first-paint\n // scaffolding for a 60-second-volatile resource, not truth, so it paints\n // immediately AND is refetched immediately.\n initialDataUpdatedAt: seed ? 0 : undefined,\n });\n\n const refetchAvailability = useCallback(async () => {\n // Recovery path (a slot taken from under the visitor): drop the cached\n // month so the grid reloads from the network, not from the copy that\n // still lists the taken slot. Best-effort — a failure leaves the stale\n // grid up rather than blanking the card.\n await queryClient\n .invalidateQueries({ queryKey: meetingAvailabilityKey(apiBaseUrl, meetingId, monthOffset) })\n .catch(() => undefined);\n }, [queryClient, apiBaseUrl, meetingId, monthOffset]);\n\n const bookMutation = useMutation<BookingResult, never, Record<string, unknown>>({\n mutationFn: async payload => {\n try {\n const res = await contentFetch(`${apiBaseUrl}/api/meetings/book`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(payload),\n });\n const data = (await res.json().catch(() => ({}))) as Record<string, unknown>;\n if (res.ok) return { ok: true, confirmation: data as unknown as BookingConfirmation };\n const rawCode = typeof data.code === 'string' ? data.code : '';\n const code = (MEETING_BOOKING_ERROR_CODES as readonly string[]).includes(rawCode)\n ? (rawCode as MeetingBookingErrorCode)\n : 'TEMPORARILY_UNAVAILABLE';\n return { ok: false, code, message: typeof data.error === 'string' ? data.error : undefined };\n } catch {\n return { ok: false, code: 'TEMPORARILY_UNAVAILABLE' };\n }\n },\n // A booking is not a read — never retried, at any layer.\n retry: false,\n });\n\n // `isPending` alone would not stop a second submit dispatched in the same\n // tick as the first; the ref closes that window the way the pre-query\n // implementation did.\n // Holds the identity of the submit that owns the lock (`null` = idle) rather\n // than a bare boolean, so the release below — which runs after an await — can\n // check that the lock it clears is still its own rather than clearing\n // whatever happens to be there when the booking settles.\n const submittingRef = useRef<object | null>(null);\n const { mutateAsync } = bookMutation;\n const book = useCallback(\n async (payload: Record<string, unknown>): Promise<BookingResult> => {\n if (submittingRef.current !== null) return { ok: false, code: 'VALIDATION', message: BOOKING_IN_FLIGHT_MESSAGE };\n const submitToken: object = {};\n submittingRef.current = submitToken;\n try {\n return await mutateAsync(payload);\n } finally {\n if (submittingRef.current === submitToken) submittingRef.current = null;\n }\n },\n [mutateAsync],\n );\n\n return {\n availability: availabilityQuery.data ?? null,\n /** No availability for this month yet — the times column has nothing to show. */\n isLoadingAvailability: availabilityQuery.isPending,\n /** ANY request in flight, including a silent revalidation of cached data. */\n isFetchingAvailability: availabilityQuery.isFetching,\n availabilityError: availabilityQuery.error ? availabilityQuery.error.message : null,\n monthOffset,\n setMonthOffset,\n refetchAvailability,\n book,\n isSubmitting: bookMutation.isPending,\n } as const;\n}\n","import { z } from 'zod';\n\n/**\n * Meeting-booking wire contracts + validation factory.\n *\n * SERVER-SAFE tsup entry (no \"use client\" banner) with its OWN per-file\n * subpath (`./schemas/meeting-booking-schema`) — same pattern and reasons as\n * `schemas/contact-schema`: used by BOTH the lib's `<HubSpotMeetingScheduler>`\n * (client-side validation) AND the host's server-side booking route, which\n * REBUILDS the schema from the link's own fetched metadata and never trusts a\n * client-shaped instance. zod is an optional peer quarantined to per-subpath\n * verticals — do NOT re-export this module through any broad barrel.\n *\n * Every cross-boundary type for the scheduling feature lives HERE (the lib\n * owns the contract; hosts import this subpath directly, type-only where\n * possible).\n */\n\n// ---------------------------------------------------------------------------\n// Wire types\n// ---------------------------------------------------------------------------\n\n/**\n * Sanitized availability payload served by the host proxy\n * (`GET {apiBaseUrl}/api/meetings/availability?meeting=<id>&monthOffset=<n>`).\n *\n * NO timezone field on purpose: slot starts are absolute epoch-ms instants\n * (verified timezone-independent against the live API), upstream fetches are\n * UTC-pinned, and ALL zone rendering happens client-side. Slots must be\n * whitelist-copied from HubSpot's `linkAvailability` ONLY — never derived\n * from busy-time data.\n */\nexport interface MeetingAvailability {\n meetingId: string;\n monthOffset: number;\n hasMore: boolean;\n /** Offered durations in ms — HubSpot's native unit for the booking POST. */\n durationsMs: number[];\n /** Bookable slot start times (epoch ms), keyed by duration in ms. */\n slotsByDurationMs: Record<string, number[]>;\n formFields: MeetingFormField[];\n /** Verbatim whitelist-copy of HubSpot's `legalConsentOptions` when consent is enabled; null when disabled. */\n legalConsent: MeetingLegalConsent | null;\n /**\n * Who the visitor is meeting — whitelisted DISPLAY projection the host DAL\n * builds from its own people data (e.g. a profiles table matched\n * server-side). NEVER carries emails or busy-time data; optional so\n * existing hosts stay wire-compatible.\n */\n hosts?: MeetingHost[];\n}\n\n/** Display-only host identity for the scheduler's context panel. */\nexport interface MeetingHost {\n name: string;\n avatarUrl: string | null;\n /** Job title / role line under the name (null → omitted). */\n title: string | null;\n}\n\n/**\n * One scheduling link on the DIRECTORY wire (`GET /api/meetings`) — the\n * host-DAL whitelist projection consumed by `MeetingSchedulerDirectory` and\n * host pages. Never carries organizer emails/busy-time data.\n */\nexport interface SchedulingLink {\n id: string;\n /** The link's public HubSpot booking URL — escape-hatch target only. */\n link: string;\n /** HubSpot slug path — the row's in-app destination is `<basePath>/<slug>`. */\n slug: string;\n /** Audience group key (slugified audience label; `\"other\"` in scope=all). */\n purpose: string;\n title: string;\n description: string | null;\n kind: 'personal' | 'team';\n /** Display-only minutes projection (booking stays ms end-to-end). */\n durationsMinutes: number[];\n hosts: MeetingHost[];\n /** Earliest bookable slot (epoch ms) from the current-month payload. */\n nextAvailableMs: number | null;\n}\n\nexport interface SchedulingLinksPayload {\n purposes: Array<{ purpose: string; label: string; links: SchedulingLink[] }>;\n fetchedAt: string;\n}\n\n/**\n * One declared question as the host forwards it. `type` is HubSpot's\n * `fieldType` (the control HubSpot draws: `phonenumber`, `booleancheckbox`…)\n * VERBATIM, and `dataType` its `type` (the stored value's kind: `string`,\n * `enumeration`, `phone_number`…). Neither is a closed set: the widget resolves\n * ANY pair to a control through `resolveFormFieldControl`, so a type HubSpot\n * adds tomorrow renders as the nearest control instead of taking the form down.\n */\nexport interface MeetingFormField {\n name: string;\n label: string;\n type: string;\n /** HubSpot's property data type — the fallback when `type` is unknown. Optional: older hosts omit it. */\n dataType?: string;\n required: boolean;\n /** Option VALUES (what is submitted). */\n options?: string[];\n /** Option value → display label, when HubSpot's label differs from the value. */\n optionLabels?: Record<string, string>;\n}\n\n/**\n * HubSpot's consent copy, rendered VERBATIM by the widget (GDPR surface —\n * never edited, never summarized). Responses are keyed by\n * `communicationTypeId`.\n */\nexport interface MeetingLegalConsent {\n processingConsentText: string;\n processingConsentCheckboxLabel: string | null;\n communicationConsentText: string | null;\n communicationConsentCheckboxes: Array<{\n communicationTypeId: string;\n label: string;\n required: boolean;\n }>;\n privacyPolicyText: string | null;\n isLegitimateInterest: boolean;\n}\n\n/**\n * Whitelisted booking result returned by the host proxy — the THIRD HubSpot\n * payload that reaches a browser, so it gets the same whitelist-copy\n * treatment as the two GETs. Nothing organizer-derived.\n */\nexport interface BookingConfirmation {\n meetingId: string;\n title: string;\n startTimeMs: number;\n durationMs: number;\n}\n\n/**\n * Typed domain errors the booking route emits; the widget keys its recovery\n * UI off these. `SLOT_TAKEN` → refetch-and-recover; `TEMPORARILY_UNAVAILABLE`\n * → retry affordance; `MEETING_UNAVAILABLE` → daily ceiling exhausted\n * (escape hatch, not a retry timer); `LINK_GONE` → link deleted upstream;\n * `INVALID_EMAIL` → HubSpot rejected the attendee address (its\n * MeetingsBookingCreatedError.INVALID_EMAIL class — fake/unreachable\n * mailboxes), so the widget tells the visitor to fix the email instead of\n * blaming the slot or their other details.\n */\nexport const MEETING_BOOKING_ERROR_CODES = [\n 'SLOT_TAKEN',\n 'VALIDATION',\n 'INVALID_EMAIL',\n 'LINK_GONE',\n 'TEMPORARILY_UNAVAILABLE',\n 'MEETING_UNAVAILABLE',\n] as const;\n\n/**\n * Derived from the runtime array ABOVE — the array is the single source of\n * truth. The booking hook validates server codes against it at runtime, so a\n * code added only to a hand-written type would be silently coerced to\n * TEMPORARILY_UNAVAILABLE by every deployed widget (exactly how the\n * INVALID_EMAIL rollout mis-rendered on 2026-08-27: type extended, runtime\n * allowlist stale — an array annotation is not exhaustiveness-checked).\n */\nexport type MeetingBookingErrorCode = (typeof MEETING_BOOKING_ERROR_CODES)[number];\n\n// ---------------------------------------------------------------------------\n// Field-type vocabulary — ONE set drives the renderer AND the validator\n// ---------------------------------------------------------------------------\n\n/**\n * THE registry of CONTROLS the native form can draw — one entry per control,\n * and the ONLY place a control is declared. Everything else derives from it:\n * `SupportedFormFieldType` is its key union, `SUPPORTED_FORM_FIELD_TYPES` its\n * keys, and `makeBookingSchema` maps each answer through the entry's\n * validator. The widget's control table (`booking-form.tsx`) is a `Record`\n * over the same key union, so a control added here without a renderer is a\n * COMPILE error, not a silent gap.\n *\n * These are controls, NOT HubSpot's type vocabulary. A HubSpot question\n * reaches a control through `resolveFormFieldControl` (exact `fieldType`, then\n * its data type, then a generic fallback), so the open set of HubSpot types\n * maps onto this closed set of controls and no type is ever \"unsupported\".\n * The one thing that still falls back to HubSpot's own page is a REQUIRED\n * question no control can answer (a file upload) — see `blocksNativeBooking`.\n *\n * Every string control rides the wire as a STRING (HubSpot's book endpoint\n * takes `{ name, value: string }`); `checkbox` is the one boolean. `number` is\n * a decimal literal, `multiselect` HubSpot's `;`-joined enumeration value,\n * `date` an ISO `YYYY-MM-DD`.\n */\nexport interface FormFieldTypeSpec {\n /** The answer's wire shape, which also decides how required/optional wraps it. */\n kind: 'string' | 'boolean';\n /** Whether HubSpot publishes `options` for the type (the pickers). */\n hasOptions: boolean;\n /**\n * The validator for ONE answer. String types EXTEND `base`, which already\n * carries the required-ness (`min(1)` when required) — so \"X is required\" is\n * the first issue reported for an empty answer, ahead of the type's own rule.\n * Boolean types ignore it.\n */\n validator: (field: MeetingFormField, base: z.ZodString) => z.ZodTypeAny;\n /** The control's placeholder, derived from the field (the mock's \"Enter Company Name\"). */\n placeholder?: (field: MeetingFormField) => string;\n}\n\nconst optionValidator: FormFieldTypeSpec['validator'] = (field, base) =>\n base.refine(v => !v || (field.options ?? []).includes(v), {\n message: `Please choose a valid option for ${field.label}`,\n });\n\n/** The wire shape of a number answer — what the form canonicalises TO and the validator checks. */\nexport const DECIMAL_LITERAL_RE = /^-?\\d+(\\.\\d+)?$/;\n\n/** HubSpot's separator for a multi-value enumeration (`a;b;c`). */\nexport const MULTI_VALUE_SEPARATOR = ';';\n\n/** Split a multiselect answer into its values (empty string → none). */\nexport const splitMultiValue = (value: unknown): string[] =>\n typeof value === 'string' && value ? value.split(MULTI_VALUE_SEPARATOR) : [];\n\n/**\n * A phone answer: digits with the punctuation people actually type (`+1 (415)\n * 555-2671`, `020 7946 0958 ext. 12`). Deliberately a SHAPE check, not a\n * numbering-plan check — HubSpot stores the string as typed, and rejecting a\n * valid number we failed to parse is worse than accepting an odd one.\n */\nexport const PHONE_RE = /^\\+?[\\d\\s().\\-/]+(?:\\s*(?:x|ext\\.?)\\s*\\d+)?$/i;\nconst PHONE_MIN_DIGITS = 5;\nconst PHONE_MAX_DIGITS = 20;\nconst phoneDigits = (v: string) => v.replace(/\\D/g, '').length;\n\n/** A date answer as `<input type=\"date\">` emits it. */\nexport const ISO_DATE_RE = /^\\d{4}-\\d{2}-\\d{2}$/;\nconst isRealIsoDate = (v: string) => {\n if (!ISO_DATE_RE.test(v)) return false;\n const d = new Date(`${v}T00:00:00Z`);\n return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === v;\n};\n\nexport const FORM_FIELD_TYPES = {\n text: {\n kind: 'string',\n hasOptions: false,\n validator: (field, base) => base.max(1000, { message: `${field.label} is too long` }),\n placeholder: field => `Enter ${field.label}`,\n },\n textarea: {\n kind: 'string',\n hasOptions: false,\n validator: (field, base) => base.max(5000, { message: `${field.label} is too long` }),\n placeholder: field => `Enter text${field.required ? '' : ' (optional)'}`,\n },\n number: {\n kind: 'string',\n hasOptions: false,\n // Canonical decimal literal — the control normalises what the browser\n // accepts (`1e3`, `007`) to this before it is validated or sent.\n validator: (field, base) =>\n base\n .max(32, { message: `${field.label} is too long` })\n .regex(DECIMAL_LITERAL_RE, { message: `${field.label} must be a number` }),\n },\n phone: {\n kind: 'string',\n hasOptions: false,\n validator: (field, base) =>\n base\n .max(40, { message: `${field.label} is too long` })\n .refine(\n v => !v || (PHONE_RE.test(v) && phoneDigits(v) >= PHONE_MIN_DIGITS && phoneDigits(v) <= PHONE_MAX_DIGITS),\n { message: `Please enter a valid phone number for ${field.label}` },\n ),\n placeholder: field => `Enter ${field.label}`,\n },\n date: {\n kind: 'string',\n hasOptions: false,\n validator: (field, base) => base.refine(v => !v || isRealIsoDate(v), { message: `${field.label} must be a date` }),\n },\n select: { kind: 'string', hasOptions: true, validator: optionValidator },\n radio: { kind: 'string', hasOptions: true, validator: optionValidator },\n multiselect: {\n kind: 'string',\n hasOptions: true,\n validator: (field, base) =>\n base.refine(v => splitMultiValue(v).every(part => (field.options ?? []).includes(part)), {\n message: `Please choose valid options for ${field.label}`,\n }),\n },\n checkbox: { kind: 'boolean', hasOptions: false, validator: () => z.boolean() },\n} as const satisfies Record<string, FormFieldTypeSpec>;\n\nexport type SupportedFormFieldType = keyof typeof FORM_FIELD_TYPES;\n\nexport const SUPPORTED_FORM_FIELD_TYPES = Object.keys(FORM_FIELD_TYPES) as readonly SupportedFormFieldType[];\n\n// ---------------------------------------------------------------------------\n// HubSpot type → control resolution (open set in, closed set out)\n// ---------------------------------------------------------------------------\n\n/**\n * What a HubSpot question resolves to: a drawable control, `display` (a\n * read-only block — `html`, `calculation_*` — never rendered, never\n * submitted) or `unanswerable` (nothing the booking POST can carry, e.g. a\n * file upload).\n */\nexport type FormFieldResolution = SupportedFormFieldType | 'display' | 'unanswerable';\n\n/**\n * HubSpot `fieldType` spellings → resolution. Keys are lower-cased. A spelling\n * missing here is NOT an error: resolution falls through to the data type and\n * then to a generic control. Add an entry only to draw a BETTER control for a\n * type, never to make one \"work\".\n *\n * `checkbox` is absent on purpose: in HubSpot it is \"multiple checkboxes\" (an\n * enumeration with options), while older links sent it for a single yes/no —\n * `resolveFormFieldControl` decides by whether options came with it.\n */\nconst FIELD_TYPE_CONTROLS: Readonly<Record<string, FormFieldResolution>> = {\n text: 'text',\n textarea: 'textarea',\n number: 'number',\n select: 'select',\n radio: 'radio',\n booleancheckbox: 'checkbox',\n phonenumber: 'phone',\n date: 'date',\n html: 'display',\n file: 'unanswerable',\n};\n\n/** HubSpot property data types (`type`) → resolution, used when `fieldType` is unknown. */\nconst DATA_TYPE_CONTROLS: Readonly<Record<string, FormFieldResolution>> = {\n string: 'text',\n number: 'number',\n enumeration: 'select',\n bool: 'checkbox',\n date: 'date',\n datetime: 'date',\n phone_number: 'phone',\n};\n\nconst hasDeclaredOptions = (field: MeetingFormField) => (field.options?.length ?? 0) > 0;\n\n/**\n * THE one mapping from a HubSpot question to what the form does with it.\n * Total: every input yields a resolution, so an unrecognised type can never\n * make a link unbookable — at worst it is a text box. An option control with\n * no options degrades to text (nothing to pick from, but still answerable).\n */\nexport function resolveFormFieldControl(field: MeetingFormField): FormFieldResolution {\n const fieldType = (field.type ?? '').toLowerCase();\n const withOptions = hasDeclaredOptions(field);\n const usable = (r: FormFieldResolution | undefined): FormFieldResolution | undefined => {\n if (!r || r === 'display' || r === 'unanswerable') return r;\n return FORM_FIELD_TYPES[r].hasOptions && !withOptions ? 'text' : r;\n };\n\n if (fieldType === 'checkbox') return withOptions ? 'multiselect' : 'checkbox';\n if (fieldType.startsWith('calculation')) return 'display';\n // A control name resolves to itself, so resolution is IDEMPOTENT: the form\n // resolves once to draw, then hands the resolved fields to the schema\n // factory, which resolves again (the server rebuild resolves raw ones).\n const asControl = Object.prototype.hasOwnProperty.call(FORM_FIELD_TYPES, fieldType)\n ? (fieldType as SupportedFormFieldType)\n : undefined;\n return (\n usable(FIELD_TYPE_CONTROLS[fieldType] ?? asControl) ??\n usable(DATA_TYPE_CONTROLS[(field.dataType ?? '').toLowerCase()]) ??\n (withOptions ? 'select' : 'text')\n );\n}\n\n/**\n * @deprecated Kept for hosts built against the closed six-type registry (they\n * asked this to decide whether to forward `options`). Answers for any HubSpot\n * type through the resolver: true when the type resolves to an option control\n * once options are present. New hosts forward options whenever HubSpot sends\n * them and do not need this.\n */\nexport function formFieldTypeHasOptions(type: string): boolean {\n const control = resolveFormFieldControl({ name: '', label: '', type, required: false, options: ['_'] });\n return control !== 'display' && control !== 'unanswerable' && FORM_FIELD_TYPES[control].hasOptions;\n}\n\n/** @deprecated The option controls (see `formFieldTypeHasOptions`). */\nexport const FORM_FIELD_TYPES_WITH_OPTIONS: readonly SupportedFormFieldType[] = SUPPORTED_FORM_FIELD_TYPES.filter(\n type => FORM_FIELD_TYPES[type].hasOptions,\n);\n\n/**\n * Whether the question's `fieldType` is one the resolver maps EXPLICITLY — as\n * opposed to guessing from its data type or falling back to text. A host logs\n * the false case: the form still works, but a better control may be one table\n * entry away.\n */\nexport function isRecognisedFormFieldType(type: string): boolean {\n const t = (type ?? '').toLowerCase();\n return (\n t === 'checkbox' ||\n t.startsWith('calculation') ||\n Object.prototype.hasOwnProperty.call(FIELD_TYPE_CONTROLS, t) ||\n Object.prototype.hasOwnProperty.call(FORM_FIELD_TYPES, t)\n );\n}\n\n/** A declared question after resolution: `type` is the CONTROL, `hubspotType` what HubSpot sent. */\nexport type SupportedMeetingFormField = MeetingFormField & { type: SupportedFormFieldType; hubspotType?: string };\n\n/** One question → its drawable form, or null when the form does not draw it (display / unanswerable). */\nexport function normalizeFormField(\n field: MeetingFormField & { hubspotType?: string },\n): SupportedMeetingFormField | null {\n const control = resolveFormFieldControl(field);\n if (control === 'display' || control === 'unanswerable') return null;\n // A control that takes no options drops them (a `booleancheckbox` arrives\n // with HubSpot's `true`/`false` pair), which also keeps a re-resolution of\n // the result on the same control.\n const normalized: SupportedMeetingFormField = {\n ...field,\n type: control,\n hubspotType: field.hubspotType ?? field.type,\n };\n if (!FORM_FIELD_TYPES[control].hasOptions) {\n delete normalized.options;\n delete normalized.optionLabels;\n }\n return normalized;\n}\n\n/** The questions the native form draws and validates, in declared order. */\nexport function normalizeFormFields(fields: readonly MeetingFormField[]): SupportedMeetingFormField[] {\n return fields.flatMap(f => normalizeFormField(f) ?? []);\n}\n\n/** Whether the native form draws this question (false for display blocks and unanswerable questions). */\nexport function isSupportedFormField(field: MeetingFormField): boolean {\n return normalizeFormField(field) !== null;\n}\n\n/**\n * The ONLY reason left to hand a link to HubSpot's own page: a REQUIRED\n * question no control can answer. An optional one is simply left out — the\n * visitor can still book, and HubSpot does not require it.\n */\nexport function blocksNativeBooking(field: MeetingFormField): boolean {\n return field.required && resolveFormFieldControl(field) === 'unanswerable';\n}\n\n/**\n * The scheduler's fixed identity fields. HubSpot's book endpoint takes them\n * TOP-LEVEL (`firstName`, `lastName`, `email`), its own booking page hardcodes\n * them, and the link's `formFields` never lists them — so they are data HERE,\n * rendered by the widget through the SAME control path as every declared\n * question, rather than three hand-written blocks. `inputType`/`autoComplete`\n * are the browser hints a text control takes; the wire and the validator do\n * not see them.\n */\nexport interface BuiltInBookingField extends MeetingFormField {\n type: 'text';\n required: true;\n inputType?: 'email';\n autoComplete: string;\n /** Own placeholder. Omitted, the control derives one from the label (the registry's rule). */\n placeholder?: string;\n /** The wire's own required/format message — kept verbatim from the schema it replaced. */\n requiredMessage: string;\n}\n\nexport const BUILT_IN_BOOKING_FIELDS = [\n {\n name: 'firstName',\n label: 'First Name',\n type: 'text',\n required: true,\n autoComplete: 'given-name',\n requiredMessage: 'First name is required',\n },\n {\n name: 'lastName',\n label: 'Last Name',\n type: 'text',\n required: true,\n autoComplete: 'family-name',\n requiredMessage: 'Last name is required',\n },\n {\n name: 'email',\n label: 'Email',\n type: 'text',\n required: true,\n inputType: 'email',\n autoComplete: 'email',\n placeholder: 'username@mail.com',\n requiredMessage: 'Please enter a valid email address',\n },\n] as const satisfies readonly BuiltInBookingField[];\n\n/** `'firstName' | 'lastName' | 'email'` — derived from the array, never restated. */\nexport type BuiltInBookingFieldName = (typeof BUILT_IN_BOOKING_FIELDS)[number]['name'];\n\n/** The registry entry for a supported type, widened to the spec so optional\n * members (`placeholder`) are readable without narrowing on the union. */\nexport function fieldTypeSpec(type: SupportedFormFieldType): FormFieldTypeSpec {\n return FORM_FIELD_TYPES[type];\n}\n\n/** The wire validator for one identity field, from ITS declaration above. */\nfunction identityValidator(name: BuiltInBookingFieldName) {\n const field: BuiltInBookingField | undefined = BUILT_IN_BOOKING_FIELDS.find(f => f.name === name);\n if (!field) throw new Error(`Unknown built-in booking field: ${name}`);\n // The field's own message FIRST: an empty value must fail `min`/`email` with\n // the wire's copy, not a length cap it cannot have hit.\n const tooLong = { message: `${field.label} is too long` };\n return field.inputType === 'email'\n ? z.string().email({ message: field.requiredMessage }).max(255, tooLong)\n : z.string().min(1, { message: field.requiredMessage }).max(255, tooLong);\n}\n\n// ---------------------------------------------------------------------------\n// Validators (single home — client widget and server rebuild both use these)\n// ---------------------------------------------------------------------------\n\nconst IANA_TZ_RE = /^(?:UTC|[A-Za-z_]+(?:\\/[A-Za-z0-9_+-]+)+)$/;\n\n/**\n * IANA timezone check. Shape prefilter, then the authoritative resolution\n * test: `Intl.DateTimeFormat` throws on unknown zones. NOT\n * `Intl.supportedValuesOf('timeZone')` — that list excludes `'UTC'` itself\n * (verified in Node), which is a legitimate booking zone. Reject, never coerce.\n */\nexport function isValidIanaTimezone(tz: string): boolean {\n if (!IANA_TZ_RE.test(tz)) return false;\n try {\n new Intl.DateTimeFormat('en-US', { timeZone: tz });\n return true;\n } catch {\n return false;\n }\n}\n\n/** BCP-47 locale shape check via `Intl.getCanonicalLocales`. Reject, never coerce. */\nexport function isValidBcp47Locale(locale: string): boolean {\n try {\n return Intl.getCanonicalLocales(locale).length > 0;\n } catch {\n return false;\n }\n}\n\n// ---------------------------------------------------------------------------\n// Schema factory\n// ---------------------------------------------------------------------------\n\n/**\n * Build the booking-form schema for ONE link's declared questions + consent.\n *\n * A factory (not a static schema) because per-link required questions cannot\n * be expressed statically. The widget builds it from the availability payload\n * it rendered; the server REBUILDS it from the link's own fetched metadata —\n * required-consent enforcement flows from this rebuild, not a parallel check.\n *\n * Deliberately NOT `.strict()`: the humanity-signal fields\n * (`HUMANITY_SIGNAL_KEYS` from `utils/humanity-signals`) ride alongside in\n * the same POST body (read raw by the host's bot gate BEFORE parsing) and are\n * stripped server-side before anything reaches HubSpot. zod's default\n * unknown-key stripping means the parsed output never contains them.\n *\n * `timezone`/`locale` are POST-only presentation fields (the invite renders\n * in the visitor's local time) — this schema is the ONLY place a\n * client-supplied zone is accepted; the availability path is UTC-pinned.\n */\nfunction buildBookingSchema<TStart extends z.ZodTypeAny, TDuration extends z.ZodTypeAny, TZone extends z.ZodTypeAny>(\n formFields: MeetingFormField[],\n legalConsent: MeetingLegalConsent | null,\n // Passed IN rather than switched on a boolean: a `deferSlot: boolean`\n // parameter cannot be narrowed at type level, so every ternary inside would\n // infer a union and widen `startTimeMs`/`durationMs` to `number | null |\n // undefined` for BOTH schemas — including the server's wire type.\n slot: { startTimeMs: TStart; durationMs: TDuration; timezone: TZone },\n) {\n const answers: Record<string, z.ZodTypeAny> = {};\n // Resolved HERE, not by the caller: the server rebuild passes the link's raw\n // questions, so client and server resolve every type through the same rule.\n // Display blocks and unanswerable optional questions carry no answer.\n const drawn = normalizeFormFields(formFields);\n for (const field of drawn) {\n const spec: FormFieldTypeSpec = FORM_FIELD_TYPES[field.type];\n // required/optional wrapping follows the answer's wire KIND, not its type:\n // a boolean is true-or-absent, a string is non-empty-or-empty. For strings\n // the required check is the FIRST rule on the chain, so an empty answer\n // reports \"is required\" rather than the type's own message.\n let validator: z.ZodTypeAny;\n if (spec.kind === 'boolean') {\n validator = field.required ? z.literal(true, { message: `${field.label} is required` }) : z.boolean().optional();\n } else {\n const base = field.required ? z.string().min(1, { message: `${field.label} is required` }) : z.string();\n validator = spec.validator(field, base);\n if (!field.required) validator = validator.optional().or(z.literal(''));\n }\n answers[field.name] = validator;\n }\n\n const requiredConsentIds = (legalConsent?.communicationConsentCheckboxes ?? [])\n .filter(c => c.required)\n .map(c => c.communicationTypeId);\n\n // A required answer cannot be enforced by an `.optional()` parent object —\n // omitting the `formFields` key entirely would skip every per-question\n // rule. When the link declares at least one required supported question,\n // the object itself is required.\n const hasRequiredAnswers = drawn.some(f => f.required);\n const answersObject = z.object(answers);\n\n return (\n z\n .object({\n meetingId: z.string().min(1),\n startTimeMs: slot.startTimeMs,\n durationMs: slot.durationMs,\n // The identity trio's rules come from BUILT_IN_BOOKING_FIELDS — the\n // keys stay literal so the payload type keeps its named properties.\n firstName: identityValidator('firstName'),\n lastName: identityValidator('lastName'),\n email: identityValidator('email'),\n timezone: slot.timezone,\n locale: z.string().refine(isValidBcp47Locale, { message: 'Invalid locale' }).optional(),\n // Plain `.optional()` (no `.default()`) so zod's input and output types\n // match — react-hook-form's zodResolver needs them identical, and the\n // server handles `undefined` explicitly anyway. REQUIRED when the link\n // declares required questions (see `hasRequiredAnswers` above).\n formFields: hasRequiredAnswers ? answersObject : answersObject.optional(),\n legalConsentResponses: z\n .array(z.object({ communicationTypeId: z.string(), consented: z.boolean() }))\n .optional(),\n })\n // Object-level rule: `.refine` on an `.optional()` field is skipped when\n // the field is absent — required consents must reject even on a payload\n // that omits the array entirely.\n .superRefine((data, ctx) => {\n const responses = data.legalConsentResponses ?? [];\n const ok = requiredConsentIds.every(id =>\n responses.some(r => r.communicationTypeId === id && r.consented === true),\n );\n if (!ok) {\n ctx.addIssue({\n code: z.ZodIssueCode.custom,\n path: ['legalConsentResponses'],\n message: 'Required consent checkboxes must be accepted',\n });\n }\n })\n );\n}\n\n/** The slot's wire validators — ONE declaration for the strict and the deferred schema. */\nconst SLOT_INSTANT = z.number().int().positive();\nconst TIMEZONE = z.string().refine(isValidIanaTimezone, { message: 'Invalid timezone' });\n\n/**\n * The STRICT schema — the wire contract. The server rebuilds it from the link's\n * own fetched metadata and validates every booking against it; a slot and a\n * duration are not optional on anything that reaches HubSpot.\n */\nexport function makeBookingSchema(formFields: MeetingFormField[], legalConsent: MeetingLegalConsent | null) {\n return buildBookingSchema(formFields, legalConsent, {\n startTimeMs: SLOT_INSTANT,\n durationMs: SLOT_INSTANT,\n timezone: TIMEZONE,\n });\n}\n\n/**\n * The DEFERRED schema — same fields, with the slot/duration/timezone triple\n * relaxed. `flow=\"details-first\"` collects answers BEFORE a slot exists, so the\n * form validates against this and the parent re-attaches the authoritative\n * values at POST time.\n *\n * Two named exports over one private builder rather than an overload or an\n * options flag: `MeetingBookingPayload` is `z.infer<ReturnType<...>>`, and\n * either of those alternatives would widen `startTimeMs`/`durationMs` to\n * `number | undefined` for EVERY consumer — including the server's\n * `durationsMs.includes(data.durationMs)`.\n */\nexport function makeDeferredBookingSchema(formFields: MeetingFormField[], legalConsent: MeetingLegalConsent | null) {\n // `.nullish()`, not `.optional()`: the widget passes these straight into\n // `defaultValues`, and details-first has `timezone === null` on the server\n // render and the first client render (zone resolution is post-hydration).\n // Accepting only `undefined` would reject a field with no rendered control\n // and no FieldWrapper — the submit button silently dead, type-check clean.\n return buildBookingSchema(formFields, legalConsent, {\n startTimeMs: SLOT_INSTANT.nullish(),\n durationMs: SLOT_INSTANT.nullish(),\n timezone: TIMEZONE.nullish(),\n });\n}\n\n/**\n * Email domains a host's form does not accept, with the message it shows when\n * one is typed. The HOST owns the list (its own personal/free-provider rule)\n * and re-checks on its own server — this is the answer at the point of typing,\n * never the gate.\n */\nexport interface DeniedEmailDomains {\n domains: readonly string[];\n message: string;\n}\n\n/** The address's domain, lower-cased, matched WHOLE against the list — a\n * subdomain is a different domain, which is how a host's own list reads it. */\nexport function emailDomainDenied(email: unknown, rule: DeniedEmailDomains): boolean {\n if (typeof email !== 'string') return false;\n const domain = email.split('@')[1]?.trim().toLowerCase();\n if (domain === undefined || domain === '') return false;\n return rule.domains.includes(domain);\n}\n\n/** `schema` refined to reject those domains ON the email field, so the message\n * lands under the control the visitor typed in rather than as a form error. */\nexport function withDeniedEmailDomains(\n schema: ReturnType<typeof makeDeferredBookingSchema>,\n rule: DeniedEmailDomains | undefined,\n) {\n if (!rule) return schema;\n return schema.superRefine((values, ctx) => {\n if (emailDomainDenied(values.email, rule)) {\n ctx.addIssue({ code: 'custom', path: ['email'], message: rule.message });\n }\n });\n}\n\n/** The wire payload. Pinned to the STRICT builder — see above. */\nexport type MeetingBookingPayload = z.infer<ReturnType<typeof makeBookingSchema>>;\n\n/**\n * The form's value type, used as the `useForm` generic in BOTH flows: a relaxed\n * resolver is not assignable to `Resolver<MeetingBookingPayload>`, and mixing\n * the two survives only on TS's bivariant method-parameter check.\n */\nexport type BookingFormValues = z.infer<ReturnType<typeof makeDeferredBookingSchema>>;\n","'use client';\n\nimport { zodResolver } from '@hookform/resolvers/zod';\nimport { Fragment, useEffect, useMemo, useState } from 'react';\nimport type { FormEvent, ReactNode, Ref } from 'react';\nimport { useForm, Controller } from 'react-hook-form';\nimport type { Control, Path, UseFormRegister } from 'react-hook-form';\nimport { useRescuedForm } from '../../hooks/use-rescued-form';\nimport { useToast } from '../../hooks/use-toast';\nimport {\n BUILT_IN_BOOKING_FIELDS,\n type BuiltInBookingFieldName,\n DECIMAL_LITERAL_RE,\n fieldTypeSpec,\n makeDeferredBookingSchema,\n withDeniedEmailDomains,\n emailDomainDenied,\n type DeniedEmailDomains,\n MULTI_VALUE_SEPARATOR,\n normalizeFormFields,\n splitMultiValue,\n type BuiltInBookingField,\n type MeetingAvailability,\n type SupportedFormFieldType,\n type MeetingFormField,\n type SupportedMeetingFormField,\n type BookingFormValues,\n} from '../../schemas/meeting-booking-schema';\nimport { cn } from '../../utils/cn';\nimport type { FormRescueDefinition } from '../../utils/form-rescue';\nimport { HUBSPOT_DO_NOT_COLLECT_FORM_PROPS } from '../../utils/hubspot-collected-forms';\nimport {\n Button,\n FieldWrapper,\n Input,\n Textarea,\n Label,\n Select,\n SelectTrigger,\n SelectValue,\n SelectContent,\n SelectItem,\n Checkbox,\n CheckboxBlock,\n RadioGroup,\n RadioGroupItem,\n Skeleton,\n} from '../ui';\nimport { HoneypotField } from '../ui/honeypot-field';\n\n/**\n * One field in a host-supplied row. `name` is a built-in (`email`, `firstName`,\n * `lastName`) or a HubSpot-declared question's `name`.\n */\nexport interface BookingFieldSlot {\n /** Built-ins autocomplete; a HubSpot question's `name` is whatever the link declares. */\n name: BuiltInBookingFieldName | (string & NonNullable<unknown>);\n /** Columns out of four at `md` and up. Defaults to an even split of the row. */\n span?: BookingFieldSpan;\n}\n\n/** Columns out of `GRID_COLUMNS` at `md` and up — the keys of `SPAN_CLASS`. */\nexport type BookingFieldSpan = keyof typeof SPAN_CLASS;\n\nexport type BookingFieldRow = BookingFieldSlot[];\n\n/**\n * Display copy a HOST overrides for ONE field, keyed by its name — a built-in\n * (`email`) or a HubSpot-declared question. It replaces what is DRAWN and\n * nothing else: the name the answer rides under, the validation and the\n * schema's own messages are untouched, so a campaign page can ask for a\n * \"Business Email\" without owning a second email rule.\n *\n * An overridden `label` also feeds the type's derived placeholder\n * (`Enter <label>`), so overriding the label alone keeps the pair consistent.\n */\nexport interface BookingFieldCopy {\n label?: string;\n placeholder?: string;\n}\n\n/**\n * A HOST-supplied consent row — the block the waitlist form draws for its SMS\n * consent, here for \"I agree to the Privacy Policy and to be contacted\". It is\n * the host's copy and the host's link, so it is a prop, not HubSpot metadata;\n * HubSpot's own `legalConsent` block (verbatim, declared on the link) renders\n * alongside when the link carries one.\n *\n * A client-side gate, exactly like the waitlist's: Continue is refused until it\n * is ticked, and the tick rides in the payload as `hostConsent` — which the\n * host's book route strips as an undeclared key. It is not a HubSpot consent\n * record; declare `legalConsent` on the link when one is required.\n */\nexport interface BookingFormConsent {\n label: ReactNode;\n description?: ReactNode;\n /** Shown under the row when Continue is pressed unticked. */\n errorMessage?: string;\n}\n\n/** The form's vertical rhythm — one stack for the loaded form and its skeleton. */\nconst FORM_STACK = 'flex flex-col gap-[var(--spacing-system-l)]';\n\n/** The row grid: two columns on a phone, four from `md`. The row gap is one step\n * wider than the column gap because field messages hang ~16px below their\n * control and would print over the next row's label at the column gap. */\nconst ROW_GRID = 'grid grid-cols-2 gap-x-[var(--spacing-system-m)] gap-y-[var(--spacing-system-lf)] md:grid-cols-4';\n\n/** The wire key of the host consent tick (stripped server-side, never sent to HubSpot). */\nconst HOST_CONSENT_KEY = 'hostConsent';\n\n/** The row grid's column count at `md` and up. `ROW_GRID`'s `md:grid-cols-4`\n * and `SPAN_CLASS` are its Tailwind twins (literal so the scanner sees them). */\nconst GRID_COLUMNS = 4;\n\n/** Static so Tailwind's scanner sees every class — a template built from a\n * runtime span would compile to nothing. */\nconst SPAN_CLASS = {\n 1: 'md:col-span-1',\n 2: 'md:col-span-2',\n 3: 'md:col-span-3',\n 4: 'md:col-span-4',\n} as const;\n\n/** One field's skeleton: label + control footprint — the CSS twin of `FieldWrapper`'s\n * label row over `Input`'s `h-11 md:h-12` (the other skeleton heights below are\n * the same kind of twin: the textarea, the consent row, the `Button`). */\nconst FIELD_SKELETON_CLASS = 'h-[4.75rem] w-full';\n\n/** The submit copy every standalone `BookingForm` gets; the slot-first preset reads the same constant. */\nexport const DEFAULT_SUBMIT_LABEL = 'Confirm Booking';\n\n/** The footer with a note (details-first): note beside the button, wrapping on a phone. */\nconst FOOTER_ROW_CLASS = 'flex flex-wrap items-center justify-between gap-[var(--spacing-system-m)]';\n/** The bare footer: the button stays LEFT, where the form's reading order ends. */\nconst FOOTER_BARE_CLASS = 'flex';\n/** The submit's width when a note sits beside it. */\nconst SUBMIT_WIDE_CLASS = 'md:w-60';\n\n/** The built-in layout when a host passes no rows — what the skeleton draws for it. */\nconst DEFAULT_FIELD_ROWS: BookingFieldRow[] = [[{ name: 'email' }], [{ name: 'firstName' }, { name: 'lastName' }]];\n\n/** The even split of four columns over `count` slots; a remainder goes to the\n * leading slots (three slots → 2/1/1), so a row never leaves a trailing gap. */\nexport const evenSpan = (count: number, index: number): BookingFieldSpan => {\n const base = Math.floor(GRID_COLUMNS / Math.max(1, count));\n const extra = GRID_COLUMNS - base * count;\n return Math.min(GRID_COLUMNS, Math.max(1, index < extra ? base + 1 : base)) as BookingFieldSpan;\n};\n\n/** ONE column rule for a slot, read by the loaded form and its skeleton: a\n * two-field row stays side by side on a phone — the rule the built-in name pair\n * has always followed: two short fields cost one line instead of two on the\n * layout that can least afford them — and the span applies from `md`. */\nconst slotColumnClass = (row: BookingFieldRow, slot: BookingFieldSlot, index: number): string =>\n cn(row.length === 2 ? 'col-span-1' : 'col-span-2', SPAN_CLASS[slot.span ?? evenSpan(row.length, index)]);\n\n/** What one control needs: the field, its DOM id, where it registers in the\n * form, and the form's own register/control. */\ninterface ControlArgs {\n /** A declared question, or a built-in carrying its control hints (everything on the declaration that is not wire data). */\n field: SupportedMeetingFormField & Partial<Omit<BuiltInBookingField, keyof MeetingFormField | 'requiredMessage'>>;\n id: string;\n /** Top-level for the built-ins (`email`), `formFields.<name>` for declared questions. */\n registerName: string;\n /** The field's current validation message, so the control can say `aria-invalid`. */\n error?: string;\n register: UseFormRegister<BookingFormValues>;\n control: Control<BookingFormValues>;\n}\n\n/** A built-in carries its own placeholder; a declared question gets the one its\n * TYPE derives (the registry's `placeholder`), so no copy lives here. */\nconst placeholderFor = (field: ControlArgs['field']): string | undefined =>\n field.placeholder ?? fieldTypeSpec(field.type).placeholder?.(field);\n\n/** An option's display text: HubSpot's label when it differs from the submitted value. */\nconst optionLabel = (field: ControlArgs['field'], value: string): string => field.optionLabels?.[value] ?? value;\n\n/** A free-text answer is sent without the whitespace a paste drags in. */\nconst trimmed = (v: unknown): string => String(v ?? '').trim();\n\n/** `<input type=\"number\">` accepts `1e3` and ` 12 `; the wire wants the\n * decimal literal the validator checks. A value ALREADY in that shape passes\n * verbatim (`007` included) — a long integer or a tiny decimal must not be\n * reshaped through a float — and one that cannot be brought into it is left\n * for the validator's own message. */\nconst canonicalNumber = (v: unknown): string => {\n const s = String(v ?? '').trim();\n if (s === '' || DECIMAL_LITERAL_RE.test(s)) return s;\n const n = Number(s);\n if (!Number.isFinite(n)) return s;\n const canonical = String(n);\n return DECIMAL_LITERAL_RE.test(canonical) ? canonical : s;\n};\n\n/**\n * ONE control per registry type. A `Record` over `SupportedFormFieldType` on\n * purpose: `FORM_FIELD_TYPES` (the schema module) is the single place a type is\n * declared, and this table cannot compile without an entry for each — so the\n * validator and the renderer can never disagree about what is supported.\n * Every control states `aria-invalid` from the field's message and carries\n * `required`/`aria-required` from the field, so the accent asterisk is never\n * the only signal. The ones that can PAINT the state take `invalid` too\n * (`Input`, `Textarea`, `SelectTrigger` — the lib's error border), so a refused\n * field reads as refused at the control, not only in the line beneath it.\n */\nconst FIELD_CONTROLS: Record<SupportedFormFieldType, (args: ControlArgs) => ReactNode> = {\n text: ({ field, id, registerName, error, register }) => (\n <Input\n id={id}\n type={field.inputType ?? 'text'}\n required={field.required}\n aria-invalid={Boolean(error)}\n invalid={Boolean(error)}\n autoComplete={field.autoComplete}\n placeholder={placeholderFor(field)}\n {...register(registerName as never)}\n />\n ),\n textarea: ({ field, id, registerName, error, register }) => (\n <Textarea\n id={id}\n required={field.required}\n aria-invalid={Boolean(error)}\n invalid={Boolean(error)}\n placeholder={placeholderFor(field)}\n {...register(registerName as never)}\n />\n ),\n number: ({ field, id, registerName, error, register }) => (\n <Input\n id={id}\n type=\"number\"\n inputMode=\"decimal\"\n step=\"any\"\n required={field.required}\n aria-invalid={Boolean(error)}\n invalid={Boolean(error)}\n {...register(registerName as never, { setValueAs: canonicalNumber })}\n />\n ),\n phone: ({ field, id, registerName, error, register }) => (\n <Input\n id={id}\n type=\"tel\"\n inputMode=\"tel\"\n autoComplete=\"tel\"\n required={field.required}\n aria-invalid={Boolean(error)}\n invalid={Boolean(error)}\n placeholder={placeholderFor(field)}\n {...register(registerName as never, { setValueAs: trimmed })}\n />\n ),\n date: ({ field, id, registerName, error, register }) => (\n // The native picker emits exactly the wire shape (`YYYY-MM-DD`).\n <Input\n id={id}\n type=\"date\"\n required={field.required}\n aria-invalid={Boolean(error)}\n invalid={Boolean(error)}\n {...register(registerName as never)}\n />\n ),\n select: ({ field, id, registerName, error, control }) => (\n <Controller\n control={control}\n name={registerName as never}\n render={({ field: rhf }) => (\n <Select value={rhf.value ?? ''} onValueChange={rhf.onChange}>\n <SelectTrigger\n id={id}\n aria-required={field.required || undefined}\n aria-invalid={Boolean(error)}\n invalid={Boolean(error)}\n >\n <SelectValue placeholder=\"Select…\" />\n </SelectTrigger>\n <SelectContent>\n {(field.options ?? []).map(opt => (\n <SelectItem key={opt} value={opt}>\n {optionLabel(field, opt)}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n )}\n />\n ),\n radio: ({ field, id, registerName, error, control }) => (\n <Controller\n control={control}\n name={registerName as never}\n render={({ field: rhf }) => (\n <RadioGroup\n value={rhf.value ?? ''}\n onValueChange={rhf.onChange}\n aria-required={field.required || undefined}\n aria-invalid={Boolean(error)}\n >\n {(field.options ?? []).map(opt => (\n <div key={opt} className=\"flex items-center gap-[var(--spacing-system-xs)]\">\n <RadioGroupItem id={`${id}-${opt}`} value={opt} />\n <Label htmlFor={`${id}-${opt}`}>{optionLabel(field, opt)}</Label>\n </div>\n ))}\n </RadioGroup>\n )}\n />\n ),\n multiselect: ({ field, id, registerName, error, control }) => (\n // HubSpot's \"multiple checkboxes\": the answer is its `;`-joined value list,\n // held in that wire shape so the validator and the POST see one string.\n <Controller\n control={control}\n name={registerName as never}\n render={({ field: rhf }) => {\n const selected = splitMultiValue(rhf.value);\n const toggle = (opt: string, on: boolean) => {\n const next = (field.options ?? []).filter(o => (o === opt ? on : selected.includes(o)));\n rhf.onChange(next.join(MULTI_VALUE_SEPARATOR));\n };\n return (\n <div id={id} role=\"group\" className=\"flex flex-col gap-[var(--spacing-system-xs)]\">\n {(field.options ?? []).map(opt => (\n <div key={opt} className=\"flex items-center gap-[var(--spacing-system-xs)]\">\n <Checkbox\n id={`${id}-${opt}`}\n checked={selected.includes(opt)}\n onCheckedChange={v => toggle(opt, v === true)}\n aria-invalid={Boolean(error)}\n />\n <Label htmlFor={`${id}-${opt}`}>{optionLabel(field, opt)}</Label>\n </div>\n ))}\n </div>\n );\n }}\n />\n ),\n checkbox: ({ field, id, registerName, error, control }) => (\n <Controller\n control={control}\n name={registerName as never}\n render={({ field: rhf }) => (\n // The question's Label above already carries the text and binds to\n // this id — a second inline label would double the visible text AND\n // the accessible name.\n <div className=\"flex items-center gap-[var(--spacing-system-xs)]\">\n <Checkbox\n id={id}\n checked={Boolean(rhf.value)}\n onCheckedChange={v => rhf.onChange(v === true)}\n aria-required={field.required || undefined}\n aria-invalid={Boolean(error)}\n />\n </div>\n )}\n />\n ),\n};\n\n/** `firstName` → `ms-first-name`; the ids the built-ins have always had. */\nconst builtInId = (name: string) => `ms-${name.replace(/[A-Z]/g, m => `-${m.toLowerCase()}`)}`;\n\nexport interface BookingFormProps {\n availability: MeetingAvailability;\n meetingId: string;\n /** Absent in `deferSlot` mode — the slot is chosen AFTER these answers. */\n startTimeMs?: number;\n durationMs?: number;\n /** IANA zone the confirmation/invite should render in (parent-resolved).\n * Null until hydration, which is why `deferSlot` relaxes it. */\n timezone: string | null;\n /**\n * Collect-only mode (`flow=\"details-first\"`): validate against the deferred\n * schema, and hand the values up instead of POSTing. The parent re-attaches\n * the authoritative slot/duration/timezone when it submits.\n */\n deferSlot?: boolean;\n /** Repopulates the form on a remount — the back edge, or an error return. */\n initialValues?: Record<string, unknown>;\n /** Defaults to \"Confirm Booking\"; details-first says \"Continue\". */\n submitLabel?: string;\n /** Small print beside the submit (details-first sets expectations). */\n footerNote?: string;\n /**\n * Re-arrange the fields into rows instead of the built-in order (email, the\n * name pair, then each declared question full width). Reuse, not a fork: the\n * SAME controls, validation, consent block and honeypot — only their grouping\n * changes, so a host can match a mock without owning the machine.\n *\n * A slot naming nothing is skipped; a declared question no row claims is\n * appended full width. Both are deliberate — see `slotNode`/`unplacedFields`.\n */\n fieldRows?: BookingFieldRow[];\n /** Per-field display overrides, keyed by field name — see `BookingFieldCopy`. */\n fieldCopy?: Record<string, BookingFieldCopy>;\n /** Email domains this form refuses, with the message it shows — see `DeniedEmailDomains`.\n * MEMOIZE it: a new object every render rebuilds the resolver's schema. */\n deniedEmailDomains?: DeniedEmailDomains;\n /** Host-supplied consent row, rendered after the fields — see `BookingFormConsent`. */\n consent?: BookingFormConsent;\n isSubmitting: boolean;\n onSubmit: (payload: Record<string, unknown>) => Promise<void>;\n /** From useHumanitySignals — parent owns the instance so it can resetSignals(). */\n honeypotInputProps: { ref: Ref<HTMLInputElement>; name: string };\n getSignals: () => Record<string, string | number>;\n /** Form rescue for this form (`RESCUE_FORMS.meetingBooking`); `null` or omitted saves nothing.\n * The attempt's keys ride the payload handed to `onSubmit`, so the parent closes it after\n * the booking commits (`completeFormRescue`), in details-first too, where this form has\n * unmounted by then. */\n rescue?: FormRescueDefinition | null;\n}\n\n/**\n * BookingForm — attendee details + the link's declared custom questions +\n * verbatim legal-consent copy. ContactForm's scaffolding (react-hook-form +\n * zodResolver + lib field primitives); a sibling rather than a `<ContactForm>`\n * configuration because of the dynamic HubSpot `formFields`, the per-checkbox\n * consent model, and the first/last-name field model — none expressible via\n * `hideFields`/`extraTopField`.\n *\n * Bot protection is LOAD-BEARING: without the humanity signals in the body,\n * the host's `verifyHuman` degrades to first-party-only BotID (fails open for\n * external embedders). Honeypot + elapsed-ms are merged into the POST at\n * submit; the parent calls `resetSignals()` after a SLOT_TAKEN refetch so a\n * legitimate retry isn't flagged too-fast.\n */\nexport function BookingForm({\n availability,\n meetingId,\n startTimeMs,\n durationMs,\n timezone,\n deferSlot = false,\n initialValues,\n submitLabel,\n footerNote,\n fieldRows,\n fieldCopy,\n deniedEmailDomains,\n consent,\n isSubmitting,\n onSubmit,\n honeypotInputProps,\n getSignals,\n rescue = null,\n}: BookingFormProps) {\n const { formFields, legalConsent } = availability;\n // Every declared question resolved to a control (`resolveFormFieldControl`) —\n // an unfamiliar HubSpot type is drawn as its nearest control, never dropped\n // silently and never a reason to hide the form.\n const supportedFields = useMemo(() => normalizeFormFields(formFields), [formFields]);\n // The DEFERRED schema in both flows: it is the wider of the two, and a strict\n // resolver is not assignable to `Resolver<BookingFormValues>`. The strict\n // schema is the server's contract — see `makeBookingSchema`'s docblock.\n const schema = useMemo(\n () => withDeniedEmailDomains(makeDeferredBookingSchema(supportedFields, legalConsent), deniedEmailDomains),\n [supportedFields, legalConsent, deniedEmailDomains],\n );\n\n const consentDefaults = useMemo(\n () =>\n (legalConsent?.communicationConsentCheckboxes ?? []).map(c => ({\n communicationTypeId: c.communicationTypeId,\n consented: false,\n })),\n [legalConsent],\n );\n\n const {\n register,\n control,\n handleSubmit,\n setValue,\n getValues,\n watch,\n formState: { errors },\n } = useForm<BookingFormValues>({\n resolver: zodResolver(schema),\n defaultValues: {\n meetingId,\n // Genuinely OMITTED, not present-as-undefined, when the slot is deferred.\n ...(deferSlot ? {} : { startTimeMs, durationMs }),\n timezone,\n locale: typeof navigator !== 'undefined' ? navigator.language : undefined,\n firstName: '',\n lastName: '',\n email: '',\n formFields: {},\n legalConsentResponses: consentDefaults,\n // A remount (back edge, or an error return) would otherwise lose every\n // answer — `defaultValues` is snapshotted once and never re-read.\n ...(initialValues ?? {}),\n } as BookingFormValues,\n });\n\n const priorConsents = initialValues?.legalConsentResponses as typeof consentDefaults | undefined;\n\n // The host consent lives OUTSIDE react-hook-form (it is not a wire field), so\n // a remount restores it from the stash the same way the fields come back.\n const [consented, setConsented] = useState(initialValues?.[HOST_CONSENT_KEY] === true);\n const [consentError, setConsentError] = useState<string | null>(null);\n const consentMissing = Boolean(consent) && !consented;\n\n // The seeded availability is refetched immediately on mount, so the link's\n // consent set can change WHILE this form is open. Reconcile rather than\n // remount: carry each `consented` across by id and default new ids to false.\n // A wholesale reset would drop what the visitor has typed; leaving it alone\n // would make a newly-declared required consent impossible to satisfy.\n useEffect(() => {\n // The LIVE answers first (what the visitor has ticked since mount), the\n // stash only as the seed for a fresh remount — reading the stash alone\n // would undo every tick made after mount when the consent set refreshes.\n const current = (getValues('legalConsentResponses') ?? priorConsents ?? []) as typeof consentDefaults;\n setValue(\n 'legalConsentResponses',\n consentDefaults.map(next => ({\n ...next,\n consented: current.find(r => r.communicationTypeId === next.communicationTypeId)?.consented ?? false,\n })),\n { shouldDirty: false },\n );\n }, [consentDefaults, priorConsents, getValues, setValue]);\n\n // Form rescue: the built-ins by name, the declared questions under `formFields.<name>`.\n const rescueFields = useMemo(\n () => [...BUILT_IN_BOOKING_FIELDS.map(field => field.name), ...supportedFields.map(field => field.name)],\n [supportedFields],\n );\n const formRescue = useRescuedForm(\n { watch, getValues, setValue },\n {\n rescue,\n fields: rescueFields,\n getSignals,\n fieldPath: name =>\n (BUILT_IN_BOOKING_FIELDS.some(field => field.name === name)\n ? name\n : `formFields.${name}`) as Path<BookingFormValues>,\n },\n );\n\n const { toast } = useToast();\n\n /**\n * A refused submit already reports itself under each field, and the button\n * stays live — pressing it is how a visitor asks what is wrong.\n *\n * The denied-domain rule is the one they cannot see coming: the address is\n * well formed and the control looks answered, so the inline line under a\n * filled-in field is easy to miss. That one is ALSO said at the button.\n * Nothing else toasts — a toast per empty field would bury the messages the\n * fields already carry.\n */\n const onInvalid = () => {\n if (deniedEmailDomains && emailDomainDenied(getValues('email'), deniedEmailDomains)) {\n toast({\n variant: 'error',\n title: 'Check your email address',\n description: deniedEmailDomains.message,\n });\n }\n };\n\n const submitValid = handleSubmit(async data => {\n if (consentMissing) return; // the error is already on screen — see `submit`\n if (deferSlot) {\n // Collect-only. The PARENT's `onSubmit` reads the humanity signals, and\n // must do so synchronously in this call — this form and its honeypot are\n // still mounted here, and `getSignals()` reads a detached ref once they\n // unmount, which would silently disable the decoy.\n await onSubmit({ ...data, meetingId, [HOST_CONSENT_KEY]: consented, ...formRescue.submitFields() });\n return;\n }\n await onSubmit({\n ...data,\n meetingId,\n [HOST_CONSENT_KEY]: consented,\n startTimeMs,\n durationMs,\n timezone,\n ...getSignals(),\n ...formRescue.submitFields(),\n });\n }, onInvalid);\n\n // Consent is checked BEFORE the resolver runs, not inside the valid branch,\n // so an unticked box and an empty field are reported together rather than\n // one submit apart.\n const submit = (event: FormEvent<HTMLFormElement>) => {\n if (consentMissing) setConsentError(consent?.errorMessage ?? 'Please agree to continue.');\n return submitValid(event);\n };\n\n const fieldError = (name: string): string | undefined => {\n const err = (errors.formFields as Record<string, { message?: string }> | undefined)?.[name];\n return err?.message;\n };\n\n /** ONE render path for every field — built-in or declared — so the default\n * order below and any host-supplied `fieldRows` compose the same controls. */\n const renderField = (\n field: ControlArgs['field'],\n where: { id: string; registerName: string; error?: string },\n ): ReactNode => {\n // DISPLAY only: the host's copy replaces what this control draws, never the\n // name the answer registers under, the validation, or the schema's messages.\n const copy = fieldCopy?.[field.name];\n const shown = copy\n ? { ...field, label: copy.label ?? field.label, placeholder: copy.placeholder ?? field.placeholder }\n : field;\n return (\n <FieldWrapper\n key={shown.name}\n label={shown.label}\n htmlFor={where.id}\n required={shown.required}\n error={where.error}\n >\n {FIELD_CONTROLS[shown.type]({\n field: shown,\n id: where.id,\n registerName: where.registerName,\n error: where.error,\n register,\n control,\n })}\n </FieldWrapper>\n );\n };\n\n const builtInFields: Record<string, ReactNode> = Object.fromEntries(\n BUILT_IN_BOOKING_FIELDS.map(field => [\n field.name,\n renderField(field, { id: builtInId(field.name), registerName: field.name, error: errors[field.name]?.message }),\n ]),\n );\n\n const renderDeclaredField = (field: SupportedMeetingFormField): ReactNode =>\n renderField(field, {\n id: `ms-q-${field.name}`,\n registerName: `formFields.${field.name}`,\n error: fieldError(field.name),\n });\n\n const declaredByName = new Map(supportedFields.map(f => [f.name, f]));\n\n const slotResolves = (name: string): boolean => Boolean(builtInFields[name] || declaredByName.has(name));\n\n const slotNode = (name: string): ReactNode => {\n const builtIn = builtInFields[name];\n if (builtIn) return builtIn;\n const declared = declaredByName.get(name);\n // A name matching nothing is SKIPPED, not an error: a row may reference a\n // question the link has not declared yet (declaring it in HubSpot is what\n // turns it on), and throwing would take the whole form down over config.\n return declared ? renderDeclaredField(declared) : null;\n };\n\n /** Fields no row claims — appended full width, so neither a question added in\n * HubSpot nor a built-in the layout forgot can go invisible: the schema still\n * requires the identity trio, and a required field with no control is a\n * submit that dies silently. */\n const { rows: placedRows, unplacedBuiltIns, named: placedNames } = normalizeFieldRows(fieldRows ?? [], slotResolves);\n const unplacedFields = fieldRows ? supportedFields.filter(f => !placedNames.has(f.name)) : [];\n\n const submitButton = (\n <Button\n type=\"submit\"\n loading={isSubmitting}\n disabled={isSubmitting}\n // The details-first footer draws a 240px action beside its note\n // (`4904:117335`); the bare slot-first row keeps the button at its natural\n // width, as it always has.\n className={footerNote ? SUBMIT_WIDE_CLASS : undefined}\n >\n {submitLabel ?? DEFAULT_SUBMIT_LABEL}\n </Button>\n );\n\n // Opted out of HubSpot's collected-forms script — see HUBSPOT_DO_NOT_COLLECT_FORM_PROPS.\n return (\n <form onSubmit={submit} className={FORM_STACK} noValidate {...HUBSPOT_DO_NOT_COLLECT_FORM_PROPS}>\n <HoneypotField {...honeypotInputProps} />\n\n {/* Email first, name pair below — the order the desktop and mobile mocks\n both draw. The pair stays TWO columns even on a phone (164px each at\n 375): two short fields side by side cost one line instead of two on\n the layout that can least afford them.\n\n Every field goes through `FieldWrapper`, which hangs its message OUT\n OF FLOW below the control. That is the whole reason it is here: a\n message rendered in flow grows its field, which pushes everything\n under it down and — inside a card that states its height — walks the\n submit button off the bottom the moment validation fails. Required-ness\n is carried by `required` on the control (read out by assistive tech)\n AND by `FieldWrapper`'s accent asterisk — the mark HubSpot's own form\n and `ContactForm` both draw, so a visitor sees the same convention on\n every form in the app. */}\n {fieldRows ? (\n <>\n {placedRows.map(row => (\n <div key={row.map(s => s.name).join('|')} className={ROW_GRID}>\n {row.map((slot, slotIndex) => (\n <div key={slot.name} className={slotColumnClass(row, slot, slotIndex)}>\n {slotNode(slot.name)}\n </div>\n ))}\n </div>\n ))}\n {[...unplacedBuiltIns, ...unplacedFields].map(field => (\n <Fragment key={field.name}>{slotNode(field.name)}</Fragment>\n ))}\n </>\n ) : (\n <>\n {builtInFields.email}\n <div className=\"grid grid-cols-2 gap-[var(--spacing-system-m)]\">\n {builtInFields.firstName}\n {builtInFields.lastName}\n </div>\n {supportedFields.map(field => (\n <Fragment key={field.name}>{renderDeclaredField(field)}</Fragment>\n ))}\n </>\n )}\n\n {consent && (\n <CheckboxBlock\n id=\"ms-host-consent\"\n checked={consented}\n onCheckedChange={v => {\n setConsented(v);\n if (v) setConsentError(null);\n }}\n disabled={isSubmitting}\n required\n error={consentError ?? undefined}\n label={consent.label}\n description={consent.description}\n />\n )}\n\n {legalConsent && (\n // Same out-of-flow message as the fields above, so a missed consent\n // box doesn't shove the submit button down the card. With no error the\n // wrapper is `display:contents` and the panel below is the flex item,\n // exactly as it was.\n <FieldWrapper error={errors.legalConsentResponses?.message}>\n <div className=\"flex flex-col gap-[var(--spacing-system-xs)]\">\n {/* GDPR surface — HubSpot's copy rendered VERBATIM, never edited, in\n HubSpot's own order: processing statement, communication intro,\n the boxes, privacy note. The statement is a standalone line so a\n link with consent enabled but NO communication boxes still shows\n it. */}\n <p className=\"text-ods-text-secondary text-h6\">{legalConsent.processingConsentText}</p>\n {legalConsent.communicationConsentText && (\n <p className=\"text-ods-text-secondary text-h6\">{legalConsent.communicationConsentText}</p>\n )}\n <Controller\n control={control}\n name=\"legalConsentResponses\"\n render={({ field: rhf }) => (\n <>\n {legalConsent.communicationConsentCheckboxes.map(box => {\n const responses = (rhf.value ?? []) as Array<{ communicationTypeId: string; consented: boolean }>;\n const current = responses.find(r => r.communicationTypeId === box.communicationTypeId);\n return (\n // The design system's consent row (`checkbox-block`, the\n // same block the waitlist form uses): box + label in one\n // bordered row.\n <CheckboxBlock\n key={box.communicationTypeId}\n id={`ms-consent-${box.communicationTypeId}`}\n checked={current?.consented ?? false}\n onCheckedChange={v =>\n rhf.onChange(\n responses.map(r =>\n r.communicationTypeId === box.communicationTypeId ? { ...r, consented: v } : r,\n ),\n )\n }\n required={box.required}\n label={box.label}\n />\n );\n })}\n </>\n )}\n />\n {legalConsent.privacyPolicyText && (\n <p className=\"text-ods-text-secondary text-h6\">{legalConsent.privacyPolicyText}</p>\n )}\n </div>\n </FieldWrapper>\n )}\n\n {/* Step navigation back to the calendar lives in the step header (the\n app-standard BackButton, rendered by the parent) — the form ships\n only its submit. */}\n {/* Two shapes, not one with a placeholder: without a note this must stay\n the bare `flex` row it has always been, or the submit slides from the\n left edge to the right on every existing slot-first booking. */}\n {footerNote ? (\n <div className={FOOTER_ROW_CLASS}>\n <p className=\"text-ods-text-secondary text-h6\">{footerNote}</p>\n {submitButton}\n </div>\n ) : (\n <div className={FOOTER_BARE_CLASS}>{submitButton}</div>\n )}\n </form>\n );\n}\n\n/**\n * Cold-start placeholder for `flow=\"details-first\"`, where the FORM is the\n * first thing in the action panel.\n *\n * The slot-first skeleton (`SlotPickerSkeleton`) would put a grey calendar\n * where the form belongs — above the fold on a page whose entire content is\n * this card. Same footprint discipline as its sibling: fixed heights, and no\n * shift when the real form swaps in for single-line fields. Two footprints it\n * cannot know: a textarea row is taller than its placeholder (the rows carry no\n * type), and a HubSpot `legalConsent` block only exists once availability lands.\n */\nexport function BookingFormSkeleton({\n fieldRows,\n consent,\n footerNote,\n}: {\n fieldRows?: BookingFieldRow[];\n /** Whether the host adds its consent row (drawn only then). A HubSpot\n * `legalConsent` block is unknown until availability lands — the one\n * footprint this skeleton cannot budget for. */\n consent?: boolean;\n footerNote?: string;\n}) {\n return (\n <div className={cn('flex-1', FORM_STACK)}>\n {/* The host's rows (or the built-in layout) through the SAME grid, column\n rule and normalisation as the loaded form — see `normalizeFieldRows`. */}\n {skeletonRows(fieldRows ?? DEFAULT_FIELD_ROWS).map(row => (\n <div key={row.map(s => s.name).join('|')} className={ROW_GRID}>\n {row.map((slot, slotIndex) => (\n <Skeleton key={slot.name} className={cn(FIELD_SKELETON_CLASS, slotColumnClass(row, slot, slotIndex))} />\n ))}\n </div>\n ))}\n {/* Without rows there is no telling how many questions the link declares: one long answer stands for them. */}\n {!fieldRows && <Skeleton className=\"h-[7.75rem] w-full\" />}\n {consent && <Skeleton className=\"h-[4.25rem] w-full\" />}\n {footerNote ? (\n <div className={FOOTER_ROW_CLASS}>\n <Skeleton className=\"h-5 min-w-40 flex-1\" />\n <Skeleton className={cn('h-12 w-full', SUBMIT_WIDE_CLASS)} />\n </div>\n ) : (\n <div className={FOOTER_BARE_CLASS}>\n <Skeleton className=\"h-12 w-40\" />\n </div>\n )}\n </div>\n );\n}\n\n/**\n * The ONE normalisation of host rows, read by the form and its skeleton.\n * - A slot whose name `resolves` to nothing is dropped, and a row left empty is\n * dropped with it: an empty grid still eats one form gap, so a layout written\n * ahead of the HubSpot config would print blank bands.\n * - A name placed twice renders once (its first slot) — one control per field.\n * - Explicit spans were written for the FULL row; once a slot drops out they\n * would leave a trailing gap, so a shortened row falls back to the even split.\n * - Built-ins no row names come back as `unplacedBuiltIns`: the schema still\n * requires the identity trio, and a required field with no control is a\n * submit that dies silently.\n * The skeleton has no availability yet, so it passes no `resolves` and keeps\n * every declared-looking name; that is the one swap it cannot rule out.\n */\nfunction normalizeFieldRows(fieldRows: BookingFieldRow[], resolves: (name: string) => boolean = () => true) {\n const seen = new Set<string>();\n const rows: BookingFieldRow[] = [];\n for (const row of fieldRows) {\n const kept: BookingFieldSlot[] = [];\n for (const slot of row) {\n if (!resolves(slot.name) || seen.has(slot.name)) continue;\n seen.add(slot.name);\n kept.push(slot);\n }\n if (kept.length === 0) continue;\n rows.push(kept.length === row.length ? kept : kept.map((slot): BookingFieldSlot => ({ name: slot.name })));\n }\n const named = new Set(fieldRows.flat().map(slot => slot.name));\n const unplacedBuiltIns = BUILT_IN_BOOKING_FIELDS.filter(f => !named.has(f.name));\n return { rows, unplacedBuiltIns, named };\n}\n\n/** The skeleton's rows: the normalised host rows, then the built-ins none of them named. */\nfunction skeletonRows(fieldRows: BookingFieldRow[]): BookingFieldRow[] {\n const { rows, unplacedBuiltIns } = normalizeFieldRows(fieldRows);\n return [...rows, ...unplacedBuiltIns.map(f => [{ name: f.name }])];\n}\n","'use client';\n\nimport type { BookingConfirmation } from '../../schemas/meeting-booking-schema';\nimport { formatDurationCompact, formatDateWithTimezone } from '../../utils/format';\n\n/**\n * Confirmation — terminal success state after a completed booking. Renders\n * the booked instant in the visitor's resolved zone. Deliberately NO\n * follow-up CTA: the job is done, the calendar invite is the next touchpoint\n * (a \"book another\" button here tested as noise).\n */\n\nexport interface ConfirmationProps {\n confirmation: BookingConfirmation;\n timezone: string;\n}\n\nexport function Confirmation({ confirmation, timezone }: ConfirmationProps) {\n // The SAME preset as the booking summary beside the slot picker, so the time\n // a visitor picked and the time they are told was booked read identically.\n const when = formatDateWithTimezone(confirmation.startTimeMs, timezone, 'weekdayDateTimeZoned', {\n viewerLocale: true,\n });\n\n return (\n <div className=\"flex flex-1 flex-col items-center justify-center gap-[var(--spacing-system-m)] text-center\">\n <p className=\"text-h1\" role=\"img\" aria-label=\"Celebration\">\n 🎉\n </p>\n <h3 className=\"text-ods-text-primary text-h3\">You&apos;re booked</h3>\n <p className=\"text-ods-text-primary text-h5\">{confirmation.title}</p>\n <p className=\"text-ods-text-secondary text-h6\">\n {when} · {formatDurationCompact(confirmation.durationMs / 1000)}\n </p>\n <p className=\"text-ods-text-secondary text-h6\">A calendar invite is on its way to your email.</p>\n </div>\n );\n}\n","'use client';\n\nimport { useMemo } from 'react';\nimport type { MeetingHost } from '../../schemas/meeting-booking-schema';\nimport { cn } from '../../utils/cn';\nimport { formatDurationCompact } from '../../utils/format';\nimport { BackButton } from '../layout/back-button';\nimport { SquareAvatar, AvatarStack, Button, Skeleton, Autocomplete } from '../ui';\n\n/**\n * ContextPanel — the \"who / what / how long\" side of the scheduler card\n * (Calendly-anatomy left panel; stacks on top on mobile). Everything here is\n * trust surface: host identity (avatar + name + title), the meeting's own\n * title/description, the duration (chips when the link offers several — the\n * duration choice lives HERE, not as a separate wizard step), and a\n * SEARCHABLE timezone picker (all IANA zones with live GMT offsets;\n * rendering-only — the wire is always epoch-ms).\n *\n * `onBack` (optional) puts the card's ONE back edge at the TOP of this panel —\n * the one spot that stays put while the action side swaps between calendar,\n * form and confirmation. What it means is the caller's call: the scheduler\n * points it at the previous STEP from the form, and at the host's own exit\n * from the calendar. One affordance, one place, so the visitor never has to\n * work out which of two \"Back\"s goes where.\n *\n * The picker renders only once the parent resolves the zone post-mount — SSR\n * output stays deterministic; a same-footprint skeleton holds the space.\n */\n\nexport interface SchedulerContextPanelProps {\n hosts: MeetingHost[];\n title?: string;\n description?: string | null;\n durationsMs: number[];\n selectedDurationMs: number | null;\n onSelectDuration: (ms: number) => void;\n /** Resolved IANA zone (null until the client resolves it). */\n timezone: string | null;\n onTimezoneChange?: (tz: string) => void;\n /** Host-level exit (renders a `BackButton` at the top). Omitted → no back\n * affordance, which is what an embedder with its own page chrome wants. */\n onBack?: () => void;\n /** Label for {@link onBack}. */\n backLabel?: string;\n /** True once a slot is chosen (details/confirmed steps) — the duration\n * CHIPS give way to a static duration line, since the step summary already\n * states the length and a live selector beside a filled-in form invites a\n * change that would throw the form away. Going Back restores them. */\n locked?: boolean;\n /** A POST is in flight: the duration chips and the zone control stay MOUNTED\n * (nothing may shift under the spinning chip) but accept no input. */\n disabled?: boolean;\n /** Whether the timezone picker renders. Default true: the zone governs every\n * time on screen, the form's summary line included, so it stays reachable\n * right up to the confirmation — where there is nothing left to re-read. */\n showTimezone?: boolean;\n className?: string;\n}\n\n/** Panel stack — one rhythm shared by the loaded panel and its skeleton\n * (16px on phones, 24px from tablet up, via the responsive spacing token). */\nconst PANEL_STACK = 'flex flex-col gap-[var(--spacing-system-l)]';\n\n/**\n * Identity + duration: ONE line below `lg`, stacked from `lg` up.\n *\n * The panel is a 280px sidebar from `lg` and a full-width header strip below\n * it — and a header strip has horizontal room the sidebar never had, so the\n * duration moves up beside the host instead of taking a line of its own. Same\n * two children, different axis; nothing is hidden at any width.\n */\nconst IDENTITY_ROW =\n 'flex items-center justify-between gap-[var(--spacing-system-m)] lg:flex-col lg:items-stretch lg:gap-[var(--spacing-system-l)]';\n\n/**\n * Everything under the back edge, in TWO groups: identity/title/duration, and\n * the timezone field beside them.\n *\n * Side by side only on TABLET. There the panel is a full-width strip over the\n * calendar, and it is competing with the calendar for the card's height — a\n * timezone field on a line of its own costs 100px there, which is a third of\n * what the month needs to render without scrolling. Sideways it costs nothing:\n * the strip is ~725px wide and the identity block uses less than half of it.\n *\n * Below `md` (phone) and from `lg` up (280px sidebar) there is no horizontal\n * room to spend, so both fall back to the plain stack.\n */\nconst STRIP_BODY = 'flex flex-col gap-[var(--spacing-system-l)] md:flex-row md:items-start lg:flex-col';\nconst STRIP_MAIN = 'flex min-w-0 flex-col gap-[var(--spacing-system-l)] md:flex-1';\n/** 280px — the same width this panel has as a sidebar, so the field is the\n * size the visitor meets at every other breakpoint. */\nconst STRIP_ASIDE = 'flex flex-col gap-[var(--spacing-system-xxs)] md:w-[280px] md:shrink-0 lg:w-full';\n\n/** The back affordance sits flush with the panel's top padding: `BackButton`'s\n * own 12px block padding would otherwise double the 24px stack gap under it. */\nconst BACK_BUTTON_CLASS = 'py-0';\n\n/** Field labels (\"Duration\", \"Timezone\") — body-weight primary, 4px above\n * their control, so the panel reads as a form rather than as a stack of\n * section headers. */\nconst FIELD_LABEL_CLASS = 'text-h4 text-ods-text-primary';\n\nfunction zoneLabel(tz: string): string {\n try {\n const parts = new Intl.DateTimeFormat('en-US', {\n timeZone: tz,\n timeZoneName: 'shortOffset',\n }).formatToParts(new Date());\n const offset = parts.find(p => p.type === 'timeZoneName')?.value ?? '';\n return offset ? `${tz.replace(/_/g, ' ')} (${offset})` : tz.replace(/_/g, ' ');\n } catch {\n return tz.replace(/_/g, ' ');\n }\n}\n\n/**\n * Same-footprint skeleton — swaps with the loaded panel with zero shift.\n * STATIC labels (\"Duration\", \"Timezone\") render REAL; only data-driven\n * content (host identity, duration chips, the zone value) is skeleton.\n */\nexport function ContextPanelSkeleton({\n className,\n onBack,\n backLabel = 'Back',\n}: {\n className?: string;\n onBack?: () => void;\n backLabel?: string;\n}) {\n return (\n <div className={cn(PANEL_STACK, className)}>\n {/* The back edge renders REAL here — it needs no data, and holding the\n space with a bar would just move on load. */}\n {onBack && <BackButton label={backLabel} onClick={onBack} className={BACK_BUTTON_CLASS} />}\n {/* Same two groups, same rows, as the loaded panel — so the header strip\n keeps its height and nothing re-flows when it swaps in. */}\n <div className={STRIP_BODY}>\n <div className={STRIP_MAIN}>\n <div className={IDENTITY_ROW}>\n <div className=\"flex min-w-0 flex-1 items-center gap-[var(--spacing-system-xs)] lg:flex-none\">\n <Skeleton className=\"h-8 w-8 shrink-0 rounded-full\" />\n <div className=\"flex flex-1 flex-col gap-[var(--spacing-system-xxs)]\">\n <Skeleton className=\"h-5 w-32\" />\n <Skeleton className=\"h-4 w-24\" />\n </div>\n </div>\n <Skeleton className=\"h-4 w-20 shrink-0\" />\n </div>\n </div>\n <div className={STRIP_ASIDE}>\n <p className={FIELD_LABEL_CLASS}>Timezone</p>\n <Skeleton className=\"h-12 w-full\" />\n </div>\n </div>\n </div>\n );\n}\n\nexport function SchedulerContextPanel({\n hosts,\n title,\n description,\n durationsMs,\n selectedDurationMs,\n onSelectDuration,\n timezone,\n onTimezoneChange,\n onBack,\n backLabel = 'Back',\n locked = false,\n disabled = false,\n showTimezone = true,\n className,\n}: SchedulerContextPanelProps) {\n // All IANA zones with live GMT offsets — computed once, client-only (the\n // panel renders the picker only after the parent resolves a zone).\n const zoneOptions = useMemo(() => {\n if (!timezone) return [];\n let zones: string[];\n try {\n // Older lib targets don't type supportedValuesOf (ES2022) — runtime-guarded.\n const intl = Intl as typeof Intl & { supportedValuesOf?: (key: string) => string[] };\n zones = intl.supportedValuesOf ? intl.supportedValuesOf('timeZone') : [timezone];\n } catch {\n zones = [timezone];\n }\n if (!zones.includes(timezone)) zones = [timezone, ...zones];\n return zones.map(tz => ({ value: tz, label: zoneLabel(tz) }));\n }, [timezone]);\n\n return (\n <div className={cn(PANEL_STACK, className)}>\n {/* The card's ONE back edge, at every step — the caller decides where it\n goes (previous step, or out of the scheduler). It sits in this panel\n because this is the half that never swaps. */}\n {onBack && <BackButton label={backLabel} onClick={onBack} className={BACK_BUTTON_CLASS} />}\n\n <div className={STRIP_BODY}>\n <div className={STRIP_MAIN}>\n <div className={IDENTITY_ROW}>\n {/* Host identity: full rows up to 3 hosts; larger teams (round-robin\n links can carry many members) collapse to a stacked-avatar cluster\n + count so the panel can never overflow. */}\n {hosts.length > 0 && hosts.length <= 3 && (\n <div className=\"flex flex-col gap-[var(--spacing-system-s)]\">\n {hosts.map(host => (\n <div key={host.name} className=\"flex items-center gap-[var(--spacing-system-xs)]\">\n <SquareAvatar\n variant=\"round\"\n size=\"sm\"\n src={host.avatarUrl ?? undefined}\n alt={host.name}\n fallback={host.name}\n />\n <div className=\"flex min-w-0 flex-col\">\n <p className=\"truncate text-ods-text-primary text-h4\">{host.name}</p>\n {host.title && <p className=\"truncate text-ods-text-secondary text-h6\">{host.title}</p>}\n </div>\n </div>\n ))}\n </div>\n )}\n {hosts.length > 3 && (\n <div className=\"flex flex-col gap-[var(--spacing-system-xs)]\">\n <AvatarStack people={hosts} max={4} size=\"lg\" label=\"Hosts\" />\n <p className=\"text-ods-text-secondary text-h6\">{hosts.length} hosts on this calendar</p>\n </div>\n )}\n\n {locked || durationsMs.length <= 1\n ? // Post-selection, or a link that offers one length: a static line,\n // which is what sits beside the host on the header-strip layouts.\n selectedDurationMs != null && (\n <p className=\"shrink-0 text-ods-text-secondary text-h6\">\n {formatDurationCompact(selectedDurationMs / 1000)} call\n </p>\n )\n : null}\n </div>\n\n {title && <p className=\"text-ods-text-primary text-h3\">{title}</p>}\n {description && <p className=\"text-ods-text-secondary text-h6\">{description}</p>}\n\n {/* Several lengths on offer: chips, which need a full row of their\n own at every width — so they stay OUT of the identity row. */}\n {!locked && durationsMs.length > 1 && (\n <div className=\"flex flex-col gap-[var(--spacing-system-xxs)]\">\n <p className={FIELD_LABEL_CLASS}>Duration</p>\n <div className=\"flex flex-wrap gap-[var(--spacing-system-xs)]\">\n {durationsMs.map(ms => (\n <Button\n key={ms}\n variant={selectedDurationMs === ms ? undefined : 'outline'}\n size=\"small-legacy\"\n disabled={disabled}\n onClick={() => onSelectDuration(ms)}\n >\n {formatDurationCompact(ms / 1000)}\n </Button>\n ))}\n </div>\n </div>\n )}\n </div>\n\n {showTimezone && (\n // Once a slot is chosen the picker is desktop-only, which is what all\n // three mocks draw. On the narrow layouts this panel is a strip\n // COMPETING with the form for the screen's height, and the step's own\n // summary line states the zone one row below — so the fact survives,\n // only the control steps aside. The 280px sidebar has the room, so\n // there it stays.\n <div className={cn(STRIP_ASIDE, locked && 'max-lg:hidden')}>\n <p className={FIELD_LABEL_CLASS}>Timezone</p>\n {timezone ? (\n <Autocomplete\n value={timezone}\n disabled={disabled}\n onChange={tz => {\n if (tz) onTimezoneChange?.(tz);\n }}\n options={zoneOptions}\n placeholder=\"Search timezone…\"\n noOptionsText=\"No matching timezone\"\n showClearAll={false}\n />\n ) : (\n <Skeleton className=\"h-12 w-full\" />\n )}\n </div>\n )}\n </div>\n </div>\n );\n}\n","'use client';\n\nimport { ChevronRight } from 'lucide-react';\nimport { useCallback, useMemo } from 'react';\nimport { cn } from '../../utils/cn';\nimport { formatDateWithTimezone, formatTimeWithTimezone, VIEWER_TIMEZONE } from '../../utils/format';\nimport { MAX_MONTH_OFFSET } from '../../utils/hubspot-meetings-convention';\nimport { Button, DatePickerCalendar, Skeleton } from '../ui';\n\n/**\n * SlotPicker — day layer + time-chip grid for the meeting scheduler.\n *\n * Day layer is the design system's own `DatePickerCalendar`, in its `bare`\n * variant — the SAME component behind `DatePicker`'s popover and\n * `DateFilterMenu`, not a second day grid styled to look like it; `bare` only\n * drops the card surface and inset it would otherwise draw inside this panel. `fromDate`/`toDate` pin\n * navigation to [current month, current + MAX_MONTH_OFFSET] so the past is\n * unreachable, and `disabledDays` greys out days with no bookable slot —\n * bounds and availability are different questions, so they arrive separately.\n * Month navigation drives the host-proxy `monthOffset`, which is why the month\n * is CONTROLLED here rather than owned by the calendar. Time chips are plain\n * `Button`s whose variant flips on selection — no bespoke pill.\n *\n * LOADING (no blink, ever): the two columns know different amounts, so they\n * load differently. A CALENDAR is fully derivable from the date — which month,\n * which weekday each cell falls on — and only WHICH DAYS ARE BOOKABLE needs\n * the network. So it never unmounts: while a month loads it renders for real\n * with nothing yet selectable, which is exactly true, and lights up in place.\n * Only the times column, which genuinely has no answer until the response\n * lands, shows a placeholder.\n *\n * Swapping the calendar for a skeleton was the old behaviour and it is what\n * made paging months flash: the whole grid vanished and rebuilt to say\n * something it already knew.\n *\n * Each column carries its OWN heading — \"Select Date & Time\" over the\n * calendar, the chosen day over the chips. The headings live HERE rather than\n * above the whole picker so each sits with the thing it labels, and so the\n * skeletons reproduce them without the parent rendering a heading it cannot\n * keep in step.\n *\n * All slot instants are epoch-ms; every label is rendered in `timezone`\n * (resolved by the parent AFTER mount — this component never reads Intl\n * itself, keeping SSR output deterministic).\n */\n\nexport interface SlotPickerProps {\n /** Bookable slot start times (epoch ms) for the selected duration. */\n slots: number[];\n /** IANA zone every label renders in (parent resolves post-mount). */\n timezone: string;\n monthOffset: number;\n onMonthOffsetChange: (offset: number) => void;\n selectedSlot: number | null;\n onSelectSlot: (startMs: number) => void;\n selectedDay: string | null;\n onSelectDay: (dayKey: string) => void;\n /** Availability refetch in flight (month change) → per-region skeletons. */\n isLoading?: boolean;\n /**\n * A booking POST is in flight (details-first, where the slot click IS the\n * submit). DISTINCT from `isLoading`, which swaps the whole times column for\n * a skeleton — here the grid must stay up with the clicked chip spinning, so\n * the visitor can see WHICH time is being booked.\n */\n isSubmitting?: boolean;\n}\n\n/** Stable per-zone day key for an instant, e.g. \"2026-08-14\" (exported — the\n * parent uses it to auto-select the first available day). */\nexport function dayKeyInZone(ms: number, timeZone: string): string {\n return new Intl.DateTimeFormat('en-CA', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit' }).format(\n new Date(ms),\n );\n}\n\n/**\n * Calendar-side day-key anchoring: react-day-picker exchanges PLAIN calendar\n * dates (local Date objects whose y/m/d ARE the cell). Convert via local\n * date parts — never through an Intl zone — or a `displayTimezone` far from\n * the browser zone shifts cells by a day.\n */\nconst pad2 = (n: number) => String(n).padStart(2, '0');\nfunction calendarDayKey(day: Date): string {\n return `${day.getFullYear()}-${pad2(day.getMonth() + 1)}-${pad2(day.getDate())}`;\n}\nfunction dateFromDayKey(key: string): Date {\n const [y, m, d] = key.split('-').map(Number);\n return new Date(y, m - 1, d, 12);\n}\n\nfunction timeLabelInZone(ms: number, timeZone: string): string {\n return formatTimeWithTimezone(ms, timeZone, { viewerLocale: true });\n}\n\n/**\n * The calendar column's width, once the layout stops handing it one: on a\n * phone the section spans the card, from `md` up it takes a stated width and\n * the times column gets the rest.\n *\n * Width no longer decides height — `DatePickerCalendar bare` is a fixed 272px\n * block at any width — so these two numbers answer one question only: how much\n * room the month CAPTION needs. The two DS icon buttons and their gaps take\n * 104px of the header, and \"September 2026\" is ~131 at text-h4, so the grid\n * needs ~245px of inner width before the label starts colliding with the\n * next-month button. 304 on tablet (256 inner, this column pads itself) and\n * 256 on the desktop card, where the panel supplies the padding.\n *\n * That is the trade the standard button size buys: the chrome keeps its\n * 48px targets and the COLUMN widens to house it, rather than the buttons\n * shrinking to fit a narrower column.\n */\nconst CALENDAR_W = 'md:w-[19rem] md:shrink-0 lg:w-[16rem]';\n\n/** Column heading (\"Select Date & Time\" / the chosen day) — body-weight\n * primary, 4px above its content, mirrored by both skeletons. */\nconst COLUMN_HEADING_CLASS = 'shrink-0 text-h4 text-ods-text-primary';\nconst COLUMN_STACK = 'flex min-h-0 flex-col gap-[var(--spacing-system-xxs)]';\n\n/** Static label over the calendar. Exported so a host rendering its own\n * chrome around the picker can stay in step with it. */\nexport const SLOT_PICKER_HEADING = 'Select Date & Time';\n\n/**\n * Chip grid: `auto-fill` over `minmax(5.5rem, 1fr)` — as many chips per row as\n * fit at the minimum, then the leftover width shared out between them.\n *\n * Both halves matter. The 5.5rem MINIMUM is the widest 12-hour label\n * (\"12:45 PM\") plus the chip's padding: the mocks draw 65px chips five across\n * a phone, but they are lettered in a 24-hour locale (\"13:45\") and this\n * renders in the visitor's own — a chip that clips its own time is worse than\n * one column fewer. The `1fr` MAXIMUM is what makes the grid reach the card's\n * edge like the design instead of stopping short with a ragged strip of empty\n * space beside the last column, which is what a fixed track width leaves\n * behind at every width that is not an exact multiple of it.\n *\n * `auto-fill` is what keeps `1fr` safe here: the track COUNT still comes from\n * the minimum, so a wide column answers with more chips rather than with\n * fifteen-minute slots stretched into 200px slabs.\n *\n * Every track is the same width, so rows line up and nothing staggers.\n */\nconst CHIP_GRID_CLASS = cn(\n 'grid grid-cols-[repeat(auto-fill,minmax(5.5rem,1fr))] content-start gap-[var(--spacing-system-xs)]',\n 'overflow-y-auto',\n // PHONE: a stated window (~3 rows, the mock's 156px) that scrolls, rather\n // than a list that runs the length of the day. Nothing else on the phone\n // card has a height — the design lets each step be as tall as it needs —\n // but a busy day is forty slots, and letting them all through turns a\n // 700px card into a 1500px one and buries whatever the page puts below\n // it. This is the one nested scroller worth having: a small, obviously\n // bounded list with a visible edge, exactly as drawn.\n 'max-h-[9.75rem] md:max-h-none',\n // No height of its own, and none borrowed from the calendar either: the card\n // states one height, the panel and this row pass it down, and what is left\n // after the column heading IS the scroll box. A long day scrolls inside it.\n //\n // `min-h-0` is the load-bearing half — a flex item's default\n // `min-height:auto` refuses to shrink below its content, so without it the\n // chips grow the column, then the row, then the card, and `overflow-y-auto`\n // never gets a box smaller than its contents to scroll inside. That is the\n // exact failure that kept this card at 404px when it was told to be 318.\n 'min-h-0 flex-1',\n);\n/** The track sets the width; the chip fills it, so labels can't stagger it.\n * The tighter inline padding is what lets the track hold a 12-hour label —\n * the Button's own is sized for prose, not for a time. */\nconst CHIP_CLASS = 'w-full px-[var(--spacing-system-xxs)]';\n\n/**\n * The two columns, at three widths.\n *\n * < md phone — stacked sections, a full-bleed rule between them\n * md tablet — side by side, the calendar at its stated width and the\n * times taking the rest, a vertical rule between them\n * lg desktop — side by side inside the panel's own padding, separated by\n * a gap (the card already has a rule there: the context\n * panel's edge)\n *\n * From `md` up the row takes what the card has left (`flex-1 min-h-0`) and\n * hands each column that same definite height. The calendar is a fixed 272px\n * block and does not scroll; the times column holds whatever the day offers\n * and scrolls ON ITS OWN. Two boxes, one height, one scrollbar — the calendar\n * stays put while the visitor runs down the times.\n *\n * Below `md` the card pins no height, so the row simply grows and the PAGE\n * scrolls — a nested scroller inside a hand-width card traps the gesture and\n * is worse than the thing it saves.\n */\nconst SLOT_ROW_CLASS = cn(\n 'flex min-w-0 flex-col md:flex-row',\n // Default `stretch` at every width, deliberately. Stacked, that is what\n // makes each section span the card — an `items-start` here (it was here, to\n // stop a column growing taller than its month) collapses both sections to\n // their intrinsic WIDTH instead, which on a phone left the calendar and the\n // chips huddled against the left edge with half the card empty. The\n // calendar no longer needs protecting: its height is fixed, not derived.\n 'md:min-h-0 md:flex-1 lg:gap-[var(--spacing-system-lf)]',\n);\n\n/** Shared by both columns: self-padded until the panel takes over at `lg`.\n * Widths are per-column — the calendar states one (`CALENDAR_W`) and the\n * times take what is left, rather than a 50/50 split that would size the\n * month by the card's width. */\nconst COLUMN_BOX = 'p-[var(--spacing-system-l)] lg:p-0';\n\n/** The rule between the two, wherever the layout puts it. */\nconst COLUMN_DIVIDER = 'border-b border-ods-border md:border-b-0 md:border-r lg:border-0';\n\n/** Deterministic month caption for a given offset (fixed en-US locale so\n * the SSR and client first-paint markup agree byte-for-byte). */\nexport function monthLabelFor(offset: number): string {\n const now = new Date();\n const month = new Date(now.getFullYear(), now.getMonth() + offset, 1);\n return formatDateWithTimezone(month, VIEWER_TIMEZONE, 'monthYear');\n}\n\n/** Nothing is bookable yet — used while a month is still loading. */\nconst DISABLE_EVERY_DAY = () => true;\nconst NOOP = () => undefined;\n\ninterface DayCalendarProps {\n monthOffset: number;\n onMonthOffsetChange: (offset: number) => void;\n selectedDay: string | null;\n onSelectDay: (dayKey: string) => void;\n /** Days that ARE bookable; `null` while the month is still loading. */\n slotsByDay: Map<string, number[]> | null;\n}\n\n/**\n * The day layer — one component for every state, which is the whole point:\n * loading a month changes WHICH DAYS ARE ENABLED, nothing else, so the grid\n * stays mounted and the visitor never watches it disappear and come back.\n *\n * `aria-busy` carries what the missing days mean to assistive tech, since\n * visually it is just an inert month.\n */\nfunction DayCalendar({ monthOffset, onMonthOffsetChange, selectedDay, onSelectDay, slotsByDay }: DayCalendarProps) {\n const now = new Date();\n const startMonth = new Date(now.getFullYear(), now.getMonth(), 1);\n // Day-0 of the FOLLOWING month = the last day of the last bookable one.\n // `DatePickerCalendar` takes day bounds, not month bounds, so an end date\n // pointing at the 1st would disable the rest of that month.\n const endMonth = new Date(now.getFullYear(), now.getMonth() + MAX_MONTH_OFFSET + 1, 0);\n const visibleMonth = new Date(now.getFullYear(), now.getMonth() + monthOffset, 1);\n\n // Stable identity: the calendar memoizes its matcher list on this value, so\n // a fresh closure per render would rebuild it every render.\n const dayHasNoSlots = useCallback((day: Date) => !slotsByDay?.has(calendarDayKey(day)), [slotsByDay]);\n\n return (\n <div className=\"flex min-h-0 flex-1 flex-col\" aria-busy={slotsByDay === null}>\n <DatePickerCalendar\n mode=\"single\"\n bare\n // CONTROLLED month: paging it is a DATA event here (the parent\n // refetches availability for the new offset), not just a view change\n // the calendar could own.\n month={visibleMonth}\n onMonthChange={month => {\n const offset = (month.getFullYear() - now.getFullYear()) * 12 + (month.getMonth() - now.getMonth());\n onMonthOffsetChange(Math.max(0, Math.min(MAX_MONTH_OFFSET, offset)));\n }}\n fromDate={startMonth}\n toDate={endMonth}\n selected={selectedDay ? dateFromDayKey(selectedDay) : undefined}\n onSelect={value => {\n const day = value as Date | undefined;\n if (!day) return;\n const key = calendarDayKey(day);\n if (slotsByDay?.has(key)) onSelectDay(key);\n }}\n disabledDays={slotsByDay === null ? DISABLE_EVERY_DAY : dayHasNoSlots}\n />\n </div>\n );\n}\n\n/**\n * The slot area before the first response — the pre-mount window while the\n * display timezone resolves, and the first availability request.\n *\n * The calendar half is REAL, not a placeholder: see the module docblock. Only\n * the times column is a skeleton, and it is the same one the loaded picker\n * shows while a month loads, so there is no swap between the two.\n */\nexport function SlotPickerSkeleton({ monthOffset = 0 }: { monthOffset?: number }) {\n return (\n <div className={SLOT_ROW_CLASS}>\n <div className={cn(COLUMN_STACK, COLUMN_BOX, COLUMN_DIVIDER, CALENDAR_W, 'md:overflow-y-auto')}>\n {/* Needs no data — the heading renders REAL in every loading state. */}\n <p className={COLUMN_HEADING_CLASS}>{SLOT_PICKER_HEADING}</p>\n <DayCalendar\n monthOffset={monthOffset}\n onMonthOffsetChange={NOOP}\n selectedDay={null}\n onSelectDay={NOOP}\n slotsByDay={null}\n />\n </div>\n <div className={cn(COLUMN_STACK, COLUMN_BOX, 'min-w-0 md:flex-1')}>\n <TimeChipsSkeleton />\n </div>\n </div>\n );\n}\n\n/** Same-footprint skeleton for the time-chip column. */\nexport function TimeChipsSkeleton() {\n return (\n <>\n {/* The day label is data — but it is held inside the REAL heading line\n box, so the skeleton's first row sits exactly where the label lands. */}\n <div className={COLUMN_HEADING_CLASS}>\n <Skeleton className=\"inline-block h-4 w-40 align-middle\" />\n </div>\n <div className={CHIP_GRID_CLASS}>\n {/* Enough to fill the visible scroll box at any column count — the\n grid's track count follows the card width, so no fixed number\n lands flush and a partial last row is the honest shape anyway. */}\n {Array.from({ length: 16 }, (_, i) => (\n // h-11 md:h-12 — the loaded chip's own default-size height.\n <Skeleton key={i} className={cn(CHIP_CLASS, 'h-11 md:h-12')} />\n ))}\n </div>\n </>\n );\n}\n\nexport function SlotPicker({\n slots,\n timezone,\n monthOffset,\n onMonthOffsetChange,\n selectedSlot,\n onSelectSlot,\n selectedDay,\n onSelectDay,\n isLoading = false,\n isSubmitting = false,\n}: SlotPickerProps) {\n const slotsByDay = useMemo(() => {\n const map = new Map<string, number[]>();\n for (const ms of slots) {\n const key = dayKeyInZone(ms, timezone);\n const list = map.get(key) ?? [];\n list.push(ms);\n map.set(key, list);\n }\n for (const list of map.values()) list.sort((a, b) => a - b);\n return map;\n }, [slots, timezone]);\n\n const visibleMonth = useMemo(() => {\n const now = new Date();\n return new Date(now.getFullYear(), now.getMonth() + monthOffset, 1);\n }, [monthOffset]);\n\n const daySlots = selectedDay ? (slotsByDay.get(selectedDay) ?? []) : [];\n\n // Whether the VISIBLE month has anything at all. HubSpot's monthOffset\n // payloads can carry near-term slots from outside the requested month, so\n // this asks about the grid on screen, not about the payload's size.\n const monthPrefix = `${visibleMonth.getFullYear()}-${String(visibleMonth.getMonth() + 1).padStart(2, '0')}`;\n const monthHasSlots = [...slotsByDay.keys()].some(key => key.startsWith(monthPrefix));\n\n return (\n <div className={SLOT_ROW_CLASS}>\n <div className={cn(COLUMN_STACK, COLUMN_BOX, COLUMN_DIVIDER, CALENDAR_W, 'md:overflow-y-auto')}>\n <p className={COLUMN_HEADING_CLASS}>{SLOT_PICKER_HEADING}</p>\n {/* Stays mounted through a month load — `slotsByDay: null` is what\n \"nothing selectable yet\" looks like, not an unmount. */}\n <DayCalendar\n monthOffset={monthOffset}\n onMonthOffsetChange={onMonthOffsetChange}\n selectedDay={selectedDay}\n onSelectDay={onSelectDay}\n slotsByDay={isLoading ? null : slotsByDay}\n />\n </div>\n <div className={cn(COLUMN_STACK, COLUMN_BOX, 'min-w-0 md:flex-1')}>\n {isLoading ? (\n <TimeChipsSkeleton />\n ) : selectedDay ? (\n <>\n <p className={COLUMN_HEADING_CLASS}>\n {/* Format the DAY-KEY itself (plain calendar date) — re-zoning a\n local instant could label an adjacent day. */}\n {formatDateWithTimezone(dateFromDayKey(selectedDay), VIEWER_TIMEZONE, 'weekday', { viewerLocale: true })}\n </p>\n <div className={CHIP_GRID_CLASS}>\n {daySlots.map(ms => {\n const isSelected = selectedSlot === ms;\n return (\n <Button\n key={ms}\n variant={isSelected ? undefined : 'outline'}\n // The click is the submit in details-first, so the grid\n // itself is the double-book guard — the form button that\n // used to carry `disabled={isSubmitting}` is not on screen.\n disabled={isSubmitting}\n loading={isSubmitting && isSelected}\n onClick={() => {\n if (isSubmitting) return;\n onSelectSlot(ms);\n }}\n className={CHIP_CLASS}\n >\n {timeLabelInZone(ms, timezone)}\n </Button>\n );\n })}\n </div>\n </>\n ) : monthHasSlots ? (\n <p className=\"text-ods-text-secondary text-h6\">Pick a day to see available times.</p>\n ) : (\n // A fully-booked month used to just SAY \"try the next one\" and leave\n // the visitor to find the chevron in the other column. The way out\n // of a dead end belongs in the dead end.\n <div className=\"flex flex-col items-start gap-[var(--spacing-system-s)]\">\n <p className=\"text-ods-text-secondary text-h6\">No available times in {monthLabelFor(monthOffset)}.</p>\n {monthOffset < MAX_MONTH_OFFSET && (\n <Button\n variant=\"outline\"\n onClick={() => onMonthOffsetChange(monthOffset + 1)}\n rightIcon={<ChevronRight className=\"size-5\" />}\n >\n Check {monthLabelFor(monthOffset + 1)}\n </Button>\n )}\n </div>\n )}\n </div>\n </div>\n );\n}\n","'use client';\n\n/**\n * `<MeetingSchedulerDirectory />` — the embeddable scheduling DIRECTORY\n * block: clickable row cards (host avatars, title/description, For-<audience>\n * chip, next available time, boxed chevron) + the house\n * `PersistentPaginationWrapper`, fed by a host proxy's `GET\n * {apiBaseUrl}/api/meetings` (see `docs/EMBEDDING_HUBSPOT_MEETINGS.md`).\n * Rows navigate to `{bookingBasePath}/{slug}` — pair with\n * `<HubSpotMeetingScheduler>` on that page for the full white-label flow.\n *\n * Layout stability is engineered, not incidental: the rows area reserves one\n * FULL page (pageSize × 80px rows + gaps), the pagination slot holds its\n * measured 72px even when hidden, and the loading state renders same-shell\n * skeleton rows — so nothing below the block ever jumps across\n * loading ⇄ loaded ⇄ page changes ⇄ short last pages.\n *\n * SSR/host mode: pass `initialData` (host-fetched) to skip the client fetch.\n * Client/embed mode: omit it — skeleton page + self-fetch via `contentFetch`\n * (embed-auth adapters inherited for free).\n */\n\nimport { ChevronRight } from 'lucide-react';\nimport { useEffect, useMemo, useState } from 'react';\nimport Link from '../../embed-shims/next-link';\nimport { useIsHydrated } from '../../hooks/ui/use-is-hydrated';\nimport type { SchedulingLink, SchedulingLinksPayload } from '../../schemas/meeting-booking-schema';\nimport { cn } from '../../utils/cn';\nimport { contentFetch } from '../../utils/embed-content-fetch';\nimport { formatDurationCompact, formatDateWithTimezone, VIEWER_TIMEZONE } from '../../utils/format';\nimport { pageCount } from '../../utils/search-params';\nimport { EmptyState } from '../empty-state';\nimport { PersistentPaginationWrapper } from '../persistent-pagination';\nimport { AvatarStack, StatusBadge, Skeleton } from '../ui';\n\nexport interface MeetingSchedulerDirectoryProps {\n /** Endpoints prefix, default '' (same-origin `/api/meetings`). */\n apiBaseUrl?: string;\n /** Host-fetched seed — skips the client fetch when provided. */\n initialData?: SchedulingLinksPayload | null;\n /** `scope=all` — full-portal view (non-conforming links under \"other\"). */\n includeAll?: boolean;\n /** Rows per page; also the skeleton row count and the reserved rows-area height. */\n pageSize?: number;\n /** Rows link to `${bookingBasePath}/${slug}`. */\n bookingBasePath?: string;\n /** EmptyState CTA target for error/empty states (omit → no CTA). */\n contactHref?: string;\n className?: string;\n}\n\n/** Zone-aware \"next available\" label — resolved POST-mount (SSR renders the\n * static caption only, so server and client first paint agree). */\nfunction useNextAvailableLabel(ms: number | null): string | null {\n // The label is a pure function of `ms` and the viewer's zone — the ONLY\n // reason it could not be computed during render was that `Intl` resolves a\n // different zone on the server. `useIsHydrated` states that gate directly, so\n // the label is derived rather than stored: no state, and no second render\n // pass per row (this hook runs once per directory row).\n const hydrated = useIsHydrated();\n return useMemo(() => {\n if (!hydrated || ms == null) return null;\n return formatDateWithTimezone(ms, VIEWER_TIMEZONE, 'shortWeekdayDateTime', { viewerLocale: true }) || null;\n }, [hydrated, ms]);\n}\n\nfunction DirectoryRow({\n link,\n audienceLabel,\n bookingBasePath,\n}: {\n link: SchedulingLink;\n audienceLabel: string;\n bookingBasePath: string;\n}) {\n const nextLabel = useNextAvailableLabel(link.nextAvailableMs);\n\n const subtitle = [\n link.description,\n link.durationsMinutes.length > 0 ? link.durationsMinutes.map(m => formatDurationCompact(m * 60)).join(' / ') : null,\n link.kind === 'team' ? 'Team' : null,\n ]\n .filter(Boolean)\n .join(' • ');\n\n return (\n <Link\n href={`${bookingBasePath}/${link.slug}`}\n prefetch={false}\n className=\"block rounded-lg text-inherit no-underline focus:outline-none focus-visible:ring-2 focus-visible:ring-ods-accent\"\n >\n <div className=\"flex min-h-20 w-full items-center gap-4 rounded-lg border border-ods-border bg-ods-card px-4 py-3 transition-colors duration-150 hover:bg-ods-bg-hover\">\n {/* Title leads at a fixed X; hosts live in the right meta cluster. */}\n <div className=\"flex min-w-0 flex-1 flex-col justify-center text-left\">\n <p className=\"truncate text-ods-text-primary text-h3\">{link.title}</p>\n {subtitle && <p className=\"truncate text-ods-text-secondary text-h6\">{subtitle}</p>}\n </div>\n\n {/* No audience entity (scope=all \"other\" group) → no chip. */}\n {audienceLabel && (\n <StatusBadge text={`For ${audienceLabel}`} singleLine className=\"hidden shrink-0 sm:inline-flex\" />\n )}\n\n {/* Hosts stay visible on mobile too — the facepile is the row's \"who\n you're booking with\" signal; only the chip and next-available meta\n collapse on narrow widths. */}\n <AvatarStack people={link.hosts} max={3} size=\"md\" label=\"Hosts\" className=\"flex shrink-0\" />\n\n <div className=\"hidden w-44 shrink-0 flex-col items-end justify-center md:flex\">\n <p className=\"text-ods-text-secondary text-h6\">Next available</p>\n <p className=\"min-h-5 text-ods-text-primary text-h6\">\n {link.nextAvailableMs == null ? 'No times published' : (nextLabel ?? ' ')}\n </p>\n </div>\n\n {/* House boxed chevron (ChatTicketItem). */}\n <span className=\"flex size-12 shrink-0 items-center justify-center rounded-md border border-ods-border bg-ods-card\">\n <ChevronRight className=\"size-6 text-ods-text-secondary\" />\n </span>\n </div>\n </Link>\n );\n}\n\n/** Same-shell loading row (co-located-skeleton convention). */\nexport function MeetingSchedulerDirectoryRowSkeleton() {\n return (\n <div className=\"flex min-h-20 w-full items-center gap-4 rounded-lg border border-ods-border bg-ods-card px-4 py-3\">\n <div className=\"flex min-w-0 flex-1 flex-col justify-center gap-1\">\n <Skeleton className=\"h-5 w-1/2\" />\n <Skeleton className=\"h-4 w-1/3\" />\n </div>\n <Skeleton className=\"hidden h-6 w-28 shrink-0 sm:block\" />\n <Skeleton className=\"h-10 w-10 shrink-0 rounded-full\" />\n <div className=\"hidden w-44 shrink-0 flex-col items-end gap-1 md:flex\">\n <Skeleton className=\"h-4 w-24\" />\n <Skeleton className=\"h-5 w-32\" />\n </div>\n <Skeleton className=\"size-12 shrink-0 rounded-md\" />\n </div>\n );\n}\n\n/** Measured pagination-slot height (PersistentPaginationWrapper = 72px). */\nconst PAGINATION_SLOT_H = 'h-[4.5rem]';\n\nexport function MeetingSchedulerDirectory({\n apiBaseUrl = '',\n initialData = null,\n includeAll = false,\n pageSize = 6,\n bookingBasePath = '/schedule-a-call',\n contactHref,\n className,\n}: MeetingSchedulerDirectoryProps) {\n const [data, setData] = useState<SchedulingLinksPayload | null>(initialData);\n const [isLoading, setIsLoading] = useState(initialData === null);\n const [error, setError] = useState<string | null>(null);\n const [page, setPage] = useState(1);\n\n useEffect(() => {\n if (initialData !== null) return undefined; // host-seeded — skip the client fetch\n const controller = new AbortController();\n let active = true;\n const load = async () => {\n setIsLoading(true);\n setError(null);\n try {\n const qs = includeAll ? '?scope=all' : '';\n const res = await contentFetch(`${apiBaseUrl}/api/meetings${qs}`, { signal: controller.signal });\n if (!res.ok) throw new Error(`meetings ${res.status}`);\n const payload = (await res.json()) as SchedulingLinksPayload;\n if (active) setData(payload);\n } catch (err) {\n if (!active || (err instanceof DOMException && err.name === 'AbortError')) return;\n setError(err instanceof Error ? err.message : 'Failed to fetch scheduling links');\n } finally {\n if (active) setIsLoading(false);\n }\n };\n void load();\n return () => {\n active = false;\n controller.abort();\n };\n }, [initialData, includeAll, apiBaseUrl]);\n\n const rows = useMemo(() => {\n const purposes = data?.purposes ?? [];\n return purposes.flatMap(p => p.links.map(link => ({ link, audienceLabel: p.label })));\n }, [data]);\n\n const totalPages = pageCount(rows.length, pageSize);\n const pageRows = rows.slice((page - 1) * pageSize, page * pageSize);\n\n // Reserved rows-area height: pageSize × 80px rows + (pageSize−1) × 16px gaps.\n const rowsMinHeight = pageSize * 80 + (pageSize - 1) * 16;\n\n if (isLoading && !data) {\n return (\n <div className={cn('flex flex-col', className)}>\n <div className=\"flex w-full flex-col gap-4\" style={{ minHeight: rowsMinHeight }}>\n {Array.from({ length: pageSize }, (_, i) => (\n <MeetingSchedulerDirectoryRowSkeleton key={i} />\n ))}\n </div>\n <div className={cn('mt-8 flex w-full items-center justify-center', PAGINATION_SLOT_H)}>\n <Skeleton className=\"h-10 w-64\" />\n </div>\n </div>\n );\n }\n\n if (error) {\n return (\n <div className={cn('flex flex-col justify-center', className)} style={{ minHeight: rowsMinHeight + 104 }}>\n <EmptyState\n type=\"generic\"\n title=\"We couldn't load available call times\"\n description=\"Something went wrong on our side. Try again in a minute, or reach us directly.\"\n {...(contactHref ? { ctaText: 'Contact us', ctaHref: contactHref } : {})}\n />\n </div>\n );\n }\n\n if (rows.length === 0) {\n return (\n <div className={cn('flex flex-col justify-center', className)} style={{ minHeight: rowsMinHeight + 104 }}>\n <EmptyState\n type=\"generic\"\n title=\"No call times are published right now\"\n description=\"We're updating our calendars. Reach us directly and we'll set something up.\"\n {...(contactHref ? { ctaText: 'Contact us', ctaHref: contactHref } : {})}\n />\n </div>\n );\n }\n\n return (\n <div className={cn('flex flex-col', className)}>\n <div className=\"flex w-full flex-col gap-4\" style={{ minHeight: rowsMinHeight }}>\n {pageRows.map(({ link, audienceLabel }) => (\n <DirectoryRow key={link.id} link={link} audienceLabel={audienceLabel} bookingBasePath={bookingBasePath} />\n ))}\n </div>\n {/* House pagination block — dims in place during refetches; the slot\n holds its measured height even at ≤1 page (where the inner\n UnifiedPagination returns null). */}\n <div className={cn('mt-8 flex w-full items-center justify-center', PAGINATION_SLOT_H)}>\n {totalPages > 1 && (\n <PersistentPaginationWrapper\n isLoading={isLoading}\n currentPage={page}\n totalPages={totalPages}\n onPageChange={setPage}\n variant=\"blog\"\n />\n )}\n </div>\n </div>\n );\n}\n"]}