@agent-native/core 0.84.16 → 0.84.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +12 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/client/session-replay.ts +2 -2
  5. package/corpus/templates/analytics/changelog/2026-07-01-session-replay-recordings-upload-reliably-in-production.md +6 -0
  6. package/corpus/templates/analytics/server/handlers/session-replay.ts +46 -5
  7. package/corpus/templates/calendar/app/components/booking/TimeSlotPicker.tsx +10 -0
  8. package/corpus/templates/calendar/app/components/calendar/GoogleConnectBanner.tsx +15 -6
  9. package/corpus/templates/calendar/app/components/calendar/GoogleSetupWizard.tsx +22 -0
  10. package/corpus/templates/calendar/app/hooks/use-bookings.ts +6 -2
  11. package/corpus/templates/calendar/app/i18n/zh-TW.ts +4 -0
  12. package/corpus/templates/calendar/app/i18n-data.ts +107 -0
  13. package/corpus/templates/calendar/app/lib/google-oauth-setup.ts +17 -0
  14. package/corpus/templates/calendar/app/pages/BookingLinksPage.tsx +9 -1
  15. package/corpus/templates/calendar/app/pages/BookingPage.tsx +40 -26
  16. package/corpus/templates/calendar/app/pages/Settings.tsx +3 -1
  17. package/corpus/templates/calendar/changelog/2026-07-01-booking-links-now-show-an-error-when-calendar-availability-c.md +6 -0
  18. package/corpus/templates/calendar/server/handlers/bookings.ts +78 -13
  19. package/corpus/templates/calendar/server/handlers/google-auth.ts +3 -2
  20. package/corpus/templates/calendar/server/lib/google-calendar.ts +16 -2
  21. package/corpus/templates/design/app/lib/design-system-preview.ts +69 -0
  22. package/corpus/templates/design/app/pages/DesignSystems.tsx +71 -50
  23. package/corpus/templates/design/changelog/2026-07-01-design-systems-no-longer-crash-on-responsive-tokens.md +6 -0
  24. package/dist/client/session-replay.js +2 -2
  25. package/dist/client/session-replay.js.map +1 -1
  26. package/dist/collab/awareness.d.ts +2 -2
  27. package/dist/collab/awareness.d.ts.map +1 -1
  28. package/dist/notifications/routes.d.ts +3 -3
  29. package/dist/observability/routes.d.ts +3 -3
  30. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  31. package/package.json +1 -1
package/corpus/README.md CHANGED
@@ -28,4 +28,4 @@ rg -n "defineAction|useActionQuery" node_modules/@agent-native/core/corpus
28
28
  ## Generated Counts
29
29
 
30
30
  - core files: 2043
31
- - template files: 4922
31
+ - template files: 4927
@@ -1,5 +1,17 @@
1
1
  # @agent-native/core
2
2
 
3
+ ## 0.84.18
4
+
5
+ ### Patch Changes
6
+
7
+ - fb0021b: Send compressed session replay uploads as binary payloads so Netlify preserves gzip bytes.
8
+
9
+ ## 0.84.17
10
+
11
+ ### Patch Changes
12
+
13
+ - a4f5303: Keep background chat continuation markers and database pool detection aligned with the actual background function dispatch path.
14
+
3
15
  ## 0.84.16
4
16
 
5
17
  ### Patch Changes
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-native/core",
3
- "version": "0.84.16",
3
+ "version": "0.84.18",
4
4
  "description": "Framework for agent-native application development — where AI agents and UI share SQL state, actions, and context",
5
5
  "homepage": "https://github.com/BuilderIO/agent-native#readme",
6
6
  "bugs": {
@@ -761,7 +761,7 @@ async function gzipReplayBody(body: string): Promise<Blob | null> {
761
761
  .stream()
762
762
  .pipeThrough(new CompressionStream("gzip"));
763
763
  const compressed = await new Response(stream).arrayBuffer();
764
- return new Blob([compressed], { type: "application/json" });
764
+ return new Blob([compressed], { type: "application/octet-stream" });
765
765
  } catch {
766
766
  return null;
767
767
  }
@@ -774,7 +774,7 @@ async function buildReplayUploadBody(body: string): Promise<ReplayUploadBody> {
774
774
  body: compressed,
775
775
  compressed: true,
776
776
  headers: {
777
- "Content-Type": "application/json",
777
+ "Content-Type": "application/octet-stream",
778
778
  "Content-Encoding": "gzip",
779
779
  },
780
780
  };
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-07-01
4
+ ---
5
+
6
+ Session replay recordings upload reliably in production.
@@ -82,11 +82,36 @@ function statusError(message: string, statusCode: number): Error {
82
82
  return Object.assign(new Error(message), { statusCode });
83
83
  }
84
84
 
85
+ function looksLikeDecodedJson(bytes: Buffer): boolean {
86
+ const first = bytes.toString("utf8").trimStart()[0];
87
+ return first === "{" || first === "[";
88
+ }
89
+
90
+ function tryGunzip(bytes: Buffer): Buffer | null {
91
+ try {
92
+ return gunzipSync(bytes);
93
+ } catch {
94
+ return null;
95
+ }
96
+ }
97
+
98
+ function decodeTextWrappedGzip(
99
+ bytes: Buffer,
100
+ ): { decoded: Buffer; requestBytes: number } | null {
101
+ const text = bytes.toString("utf8");
102
+ const binaryStringBytes = Buffer.from(text, "latin1");
103
+ if (binaryStringBytes.equals(bytes)) return null;
104
+ const decoded = tryGunzip(binaryStringBytes);
105
+ return decoded
106
+ ? { decoded, requestBytes: binaryStringBytes.byteLength }
107
+ : null;
108
+ }
109
+
85
110
  export function decodeSessionReplayRequestBody(
86
111
  rawBody: Buffer | Uint8Array | string | undefined,
87
112
  contentEncoding?: string | null,
88
113
  ): { body: unknown; requestBytes: number } {
89
- const requestBytes =
114
+ let requestBytes =
90
115
  typeof rawBody === "string"
91
116
  ? Buffer.byteLength(rawBody, "utf8")
92
117
  : (rawBody?.byteLength ?? 0);
@@ -99,10 +124,26 @@ export function decodeSessionReplayRequestBody(
99
124
  let decoded = bytes;
100
125
 
101
126
  if (encoding === "gzip" || encoding === "x-gzip") {
102
- try {
103
- decoded = gunzipSync(bytes);
104
- } catch {
105
- throw statusError("Invalid gzip-compressed replay body", 400);
127
+ const gunzipped = tryGunzip(bytes);
128
+ if (gunzipped) {
129
+ decoded = gunzipped;
130
+ } else {
131
+ // Netlify may hand Nitro an already-decoded body while preserving the
132
+ // original browser Content-Encoding header.
133
+ if (looksLikeDecodedJson(bytes)) {
134
+ decoded = bytes;
135
+ } else {
136
+ // Some Netlify paths wrap binary request bodies in a JS string before
137
+ // Nitro reads them back as UTF-8. Reinterpret that text as one-byte
138
+ // binary data so real browser CompressionStream uploads survive.
139
+ const textWrappedGzip = decodeTextWrappedGzip(bytes);
140
+ if (textWrappedGzip) {
141
+ decoded = textWrappedGzip.decoded;
142
+ requestBytes = textWrappedGzip.requestBytes;
143
+ } else {
144
+ throw statusError("Invalid gzip-compressed replay body", 400);
145
+ }
146
+ }
106
147
  }
107
148
  } else if (encoding && encoding !== "identity") {
108
149
  throw statusError(
@@ -9,6 +9,7 @@ interface TimeSlotPickerProps {
9
9
  selectedSlot: string | null;
10
10
  onSelect: (start: string) => void;
11
11
  loading?: boolean;
12
+ errorMessage?: string;
12
13
  }
13
14
 
14
15
  export function TimeSlotPicker({
@@ -16,6 +17,7 @@ export function TimeSlotPicker({
16
17
  selectedSlot,
17
18
  onSelect,
18
19
  loading,
20
+ errorMessage,
19
21
  }: TimeSlotPickerProps) {
20
22
  const t = useT();
21
23
 
@@ -29,6 +31,14 @@ export function TimeSlotPicker({
29
31
  );
30
32
  }
31
33
 
34
+ if (errorMessage) {
35
+ return (
36
+ <p className="rounded-lg border border-destructive/30 bg-destructive/[0.06] px-3 py-3 text-sm text-destructive">
37
+ {errorMessage}
38
+ </p>
39
+ );
40
+ }
41
+
32
42
  if (slots.length === 0) {
33
43
  return (
34
44
  <p className="text-sm text-muted-foreground text-center py-4">
@@ -33,6 +33,7 @@ import {
33
33
  useDisconnectGoogle,
34
34
  type DesktopAuthIssue,
35
35
  } from "@/hooks/use-google-auth";
36
+ import { shouldOfferGoogleOAuthSetup } from "@/lib/google-oauth-setup";
36
37
 
37
38
  interface EnvKeyStatus {
38
39
  key: string;
@@ -93,6 +94,7 @@ export function GoogleConnectBanner({
93
94
 
94
95
  const accounts = googleStatus.data?.accounts ?? [];
95
96
  const hasAccounts = accounts.length > 0;
97
+ const canOfferOAuthSetup = useMemo(() => shouldOfferGoogleOAuthSetup(), []);
96
98
 
97
99
  const isBuilderFrame = useMemo(() => isInBuilderFrame(), []);
98
100
  const authPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
@@ -184,10 +186,17 @@ export function GoogleConnectBanner({
184
186
  useEffect(() => {
185
187
  if (authUrl.error) {
186
188
  setWantAuthUrl(false);
187
- setShowWizard(true);
188
- fetchStatus();
189
+ if (canOfferOAuthSetup) {
190
+ setShowWizard(true);
191
+ fetchStatus();
192
+ } else {
193
+ setDesktopAuthIssue({
194
+ code: "managed_credentials_unavailable",
195
+ message: t("googleConnect.managedCredentialsUnavailableDescription"),
196
+ });
197
+ }
189
198
  }
190
- }, [authUrl.error, fetchStatus]);
199
+ }, [authUrl.error, canOfferOAuthSetup, fetchStatus, t]);
191
200
 
192
201
  useEffect(() => {
193
202
  if (
@@ -388,7 +397,7 @@ export function GoogleConnectBanner({
388
397
  </div>
389
398
  )}
390
399
 
391
- {showWizard && !allConfigured && (
400
+ {showWizard && !allConfigured && canOfferOAuthSetup && (
392
401
  <div className="mt-8 w-full max-w-lg text-start">
393
402
  <SetupWizard
394
403
  currentStep={currentStep}
@@ -481,7 +490,7 @@ export function GoogleConnectBanner({
481
490
  </div>
482
491
 
483
492
  <div className="flex items-center gap-1.5 shrink-0">
484
- {showWizard && !allConfigured ? (
493
+ {showWizard && !allConfigured && canOfferOAuthSetup ? (
485
494
  <Button
486
495
  size="sm"
487
496
  variant="outline"
@@ -541,7 +550,7 @@ export function GoogleConnectBanner({
541
550
  />
542
551
 
543
552
  {/* Inline setup wizard */}
544
- {showWizard && !allConfigured && (
553
+ {showWizard && !allConfigured && canOfferOAuthSetup && (
545
554
  <div className="px-5 pb-4 pt-1 max-w-2xl">
546
555
  <p className="text-xs text-muted-foreground mb-3">
547
556
  {t("googleConnect.followSteps")}
@@ -5,6 +5,7 @@ import {
5
5
  } from "@agent-native/core/client";
6
6
  import {
7
7
  IconExternalLink,
8
+ IconAlertTriangle,
8
9
  IconCheck,
9
10
  IconCircle,
10
11
  IconLoader2,
@@ -15,6 +16,7 @@ import { useState, useEffect, useCallback, useRef } from "react";
15
16
  import { Button } from "@/components/ui/button";
16
17
  import { Input } from "@/components/ui/input";
17
18
  import { Label } from "@/components/ui/label";
19
+ import { shouldOfferGoogleOAuthSetup } from "@/lib/google-oauth-setup";
18
20
 
19
21
  interface EnvKeyStatus {
20
22
  key: string;
@@ -92,6 +94,26 @@ export function GoogleSetupWizard() {
92
94
  const allConfigured =
93
95
  envStatus.length > 0 && envStatus.every((k) => k.configured);
94
96
 
97
+ if (!shouldOfferGoogleOAuthSetup()) {
98
+ return (
99
+ <div className="rounded-lg border border-amber-500/25 bg-amber-500/[0.07] p-4 text-start">
100
+ <div className="flex items-start gap-3">
101
+ <div className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-amber-500/15 text-amber-300">
102
+ <IconAlertTriangle className="h-4 w-4" />
103
+ </div>
104
+ <div>
105
+ <p className="text-sm font-medium text-foreground">
106
+ {t("googleConnect.managedCredentialsUnavailable")}
107
+ </p>
108
+ <p className="mt-1 text-xs leading-relaxed text-muted-foreground">
109
+ {t("googleConnect.managedCredentialsUnavailableDescription")}
110
+ </p>
111
+ </div>
112
+ </div>
113
+ </div>
114
+ );
115
+ }
116
+
95
117
  async function handleJsonUpload(file: File) {
96
118
  setSaving(true);
97
119
  setError(null);
@@ -30,7 +30,9 @@ export function useAvailableSlots(
30
30
  const res = await fetch(
31
31
  appApiPath(`/api/bookings/available-slots?${params}`),
32
32
  );
33
- if (!res.ok) throw new Error("Failed to fetch available slots");
33
+ if (!res.ok) {
34
+ throw new Error(await readErrorMessage(res, "Failed to fetch slots"));
35
+ }
34
36
  const data = await res.json();
35
37
  return Array.isArray(data) ? data : (data.slots ?? []);
36
38
  },
@@ -57,7 +59,9 @@ export function useAvailableDays(
57
59
  const res = await fetch(
58
60
  appApiPath(`/api/bookings/available-slots?${params}`),
59
61
  );
60
- if (!res.ok) throw new Error("Failed to fetch available days");
62
+ if (!res.ok) {
63
+ throw new Error(await readErrorMessage(res, "Failed to fetch days"));
64
+ }
61
65
  const data = await res.json();
62
66
  return Array.isArray(data?.dates) ? data.dates : [];
63
67
  },
@@ -375,6 +375,9 @@ const messages = {
375
375
  googleWarningAfterUnsafe: "完成連線。",
376
376
  accountConnectedElsewhere: "該帳戶已連線到另一個登入帳戶",
377
377
  googleConnectionFailed: "Google 連線失敗",
378
+ managedCredentialsUnavailable: "Google Calendar 暫時無法使用",
379
+ managedCredentialsUnavailableDescription:
380
+ "此部署尚未設定 Calendar 的 Google 連線。請稍後再試。",
378
381
  thatGoogleAccount: "那個 Google 帳戶",
379
382
  signOutThenSignIn: "登出,然後使用 {{account}} 登入。",
380
383
  signOut: "登出",
@@ -427,6 +430,7 @@ const messages = {
427
430
  availabilityDescription: "設定你的預約可用時間。",
428
431
  availabilitySaved: "已儲存可用性",
429
432
  availabilitySaveFailed: "無法儲存可用性",
433
+ availabilityUnavailable: "目前無法檢查此主辦人的行事曆可用性。請稍後再試。",
430
434
  availableTimes: "可用時間",
431
435
  allCount: "全部({{count}})",
432
436
  back: "返回",
@@ -398,6 +398,9 @@ const enUS = {
398
398
  googleWarningAfterUnsafe: "to finish connecting.",
399
399
  accountConnectedElsewhere: "This account is connected to another login",
400
400
  googleConnectionFailed: "Google connection failed",
401
+ managedCredentialsUnavailable: "Google Calendar is unavailable",
402
+ managedCredentialsUnavailableDescription:
403
+ "Calendar's Google connection is not configured for this deployment. Please try again later.",
401
404
  thatGoogleAccount: "that Google account",
402
405
  signOutThenSignIn: "Sign out, then sign in with {{account}}.",
403
406
  signOut: "Sign out",
@@ -452,6 +455,8 @@ const enUS = {
452
455
  availabilityDescription: "Set your available hours for bookings.",
453
456
  availabilitySaved: "Availability saved",
454
457
  availabilitySaveFailed: "Failed to save availability",
458
+ availabilityUnavailable:
459
+ "This host's calendar availability can't be checked right now. Please try again later.",
455
460
  availableTimes: "Available Times",
456
461
  allCount: "All ({{count}})",
457
462
  back: "Back",
@@ -9233,6 +9238,107 @@ const translatedCalendarRawBurnDown = {
9233
9238
  },
9234
9239
  } satisfies Partial<Record<LocaleCode, PartialMessages>>;
9235
9240
 
9241
+ const translatedCalendarAvailabilityFix = {
9242
+ "zh-CN": {
9243
+ googleConnect: {
9244
+ managedCredentialsUnavailable: "Google Calendar 暂不可用",
9245
+ managedCredentialsUnavailableDescription:
9246
+ "此部署尚未配置 Calendar 的 Google 连接。请稍后再试。",
9247
+ },
9248
+ bookingLinks: {
9249
+ availabilityUnavailable: "目前无法检查此主办人的日历可用性。请稍后再试。",
9250
+ },
9251
+ },
9252
+ "es-ES": {
9253
+ googleConnect: {
9254
+ managedCredentialsUnavailable: "Google Calendar no está disponible",
9255
+ managedCredentialsUnavailableDescription:
9256
+ "La conexión de Google de Calendar no está configurada para este despliegue. Inténtalo de nuevo más tarde.",
9257
+ },
9258
+ bookingLinks: {
9259
+ availabilityUnavailable:
9260
+ "No se puede comprobar ahora la disponibilidad del calendario de este anfitrión. Inténtalo de nuevo más tarde.",
9261
+ },
9262
+ },
9263
+ "fr-FR": {
9264
+ googleConnect: {
9265
+ managedCredentialsUnavailable: "Google Calendar est indisponible",
9266
+ managedCredentialsUnavailableDescription:
9267
+ "La connexion Google de Calendar n'est pas configurée pour ce déploiement. Réessayez plus tard.",
9268
+ },
9269
+ bookingLinks: {
9270
+ availabilityUnavailable:
9271
+ "La disponibilité du calendrier de cet hôte ne peut pas être vérifiée pour le moment. Réessayez plus tard.",
9272
+ },
9273
+ },
9274
+ "de-DE": {
9275
+ googleConnect: {
9276
+ managedCredentialsUnavailable: "Google Calendar ist nicht verfügbar",
9277
+ managedCredentialsUnavailableDescription:
9278
+ "Die Google-Verbindung von Calendar ist für diese Bereitstellung nicht konfiguriert. Bitte versuche es später erneut.",
9279
+ },
9280
+ bookingLinks: {
9281
+ availabilityUnavailable:
9282
+ "Die Kalenderverfügbarkeit dieses Gastgebers kann derzeit nicht geprüft werden. Bitte versuche es später erneut.",
9283
+ },
9284
+ },
9285
+ "ja-JP": {
9286
+ googleConnect: {
9287
+ managedCredentialsUnavailable: "Google Calendar を利用できません",
9288
+ managedCredentialsUnavailableDescription:
9289
+ "このデプロイでは Calendar の Google 接続が設定されていません。後でもう一度お試しください。",
9290
+ },
9291
+ bookingLinks: {
9292
+ availabilityUnavailable:
9293
+ "この主催者のカレンダーの空き状況を現在確認できません。後でもう一度お試しください。",
9294
+ },
9295
+ },
9296
+ "ko-KR": {
9297
+ googleConnect: {
9298
+ managedCredentialsUnavailable: "Google Calendar를 사용할 수 없습니다",
9299
+ managedCredentialsUnavailableDescription:
9300
+ "이 배포에는 Calendar의 Google 연결이 구성되어 있지 않습니다. 나중에 다시 시도하세요.",
9301
+ },
9302
+ bookingLinks: {
9303
+ availabilityUnavailable:
9304
+ "현재 이 주최자의 캘린더 가능 시간을 확인할 수 없습니다. 나중에 다시 시도하세요.",
9305
+ },
9306
+ },
9307
+ "pt-BR": {
9308
+ googleConnect: {
9309
+ managedCredentialsUnavailable: "Google Calendar indisponível",
9310
+ managedCredentialsUnavailableDescription:
9311
+ "A conexão do Calendar com o Google não está configurada para esta implantação. Tente novamente mais tarde.",
9312
+ },
9313
+ bookingLinks: {
9314
+ availabilityUnavailable:
9315
+ "Não é possível verificar a disponibilidade do calendário deste anfitrião agora. Tente novamente mais tarde.",
9316
+ },
9317
+ },
9318
+ "hi-IN": {
9319
+ googleConnect: {
9320
+ managedCredentialsUnavailable: "Google Calendar उपलब्ध नहीं है",
9321
+ managedCredentialsUnavailableDescription:
9322
+ "इस deployment के लिए Calendar का Google connection configured नहीं है। कृपया बाद में फिर कोशिश करें।",
9323
+ },
9324
+ bookingLinks: {
9325
+ availabilityUnavailable:
9326
+ "इस host की calendar availability अभी check नहीं की जा सकती। कृपया बाद में फिर कोशिश करें।",
9327
+ },
9328
+ },
9329
+ "ar-SA": {
9330
+ googleConnect: {
9331
+ managedCredentialsUnavailable: "Google Calendar غير متاح",
9332
+ managedCredentialsUnavailableDescription:
9333
+ "اتصال Google في Calendar غير مهيأ لهذا النشر. يُرجى المحاولة لاحقًا.",
9334
+ },
9335
+ bookingLinks: {
9336
+ availabilityUnavailable:
9337
+ "لا يمكن التحقق من توفر تقويم هذا المضيف الآن. يُرجى المحاولة لاحقًا.",
9338
+ },
9339
+ },
9340
+ } satisfies Partial<Record<LocaleCode, PartialMessages>>;
9341
+
9236
9342
  function applyTranslatedCalendarOverrides(
9237
9343
  translationSet: Partial<Record<LocaleCode, PartialMessages>>,
9238
9344
  ) {
@@ -9282,3 +9388,4 @@ applyTranslatedCalendarOverrides(translatedCalendarDebtTranslations);
9282
9388
  applyTranslatedCalendarOverrides(translatedCalendarRemainingRaw);
9283
9389
  applyTranslatedCalendarOverrides(translatedCalendarRawBurnDown);
9284
9390
  applyTranslatedCalendarOverrides(translatedCalendarExactCleanup);
9391
+ applyTranslatedCalendarOverrides(translatedCalendarAvailabilityFix);
@@ -0,0 +1,17 @@
1
+ function localHostname(hostname: string): boolean {
2
+ return (
3
+ hostname === "localhost" ||
4
+ hostname === "127.0.0.1" ||
5
+ hostname === "::1" ||
6
+ hostname.endsWith(".localhost") ||
7
+ hostname.endsWith(".local")
8
+ );
9
+ }
10
+
11
+ export function shouldOfferGoogleOAuthSetup(): boolean {
12
+ const env = (import.meta.env ?? {}) as Record<string, unknown>;
13
+ if (env.DEV === true) return true;
14
+ if (env.VITE_ENABLE_GOOGLE_OAUTH_SETUP === "true") return true;
15
+ if (typeof window === "undefined") return false;
16
+ return localHostname(window.location.hostname.toLowerCase());
17
+ }
@@ -135,6 +135,7 @@ const BRAND_ICON_LINK_CLASS =
135
135
  const BRAND_PILL_LINK_CLASS =
136
136
  "border-[#00B5FF]/35 bg-[#00B5FF]/10 font-semibold text-[#00B5FF] hover:border-[#00B5FF]/55 hover:bg-[#00B5FF]/15 hover:text-[#33C4FF]";
137
137
  const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
138
+ const BOOKING_SLOT_STEP_MINUTES = 30;
138
139
 
139
140
  type DraftLink = {
140
141
  id?: string;
@@ -1993,7 +1994,14 @@ function BookingPreview({
1993
1994
  const startMin = startH * 60 + startM;
1994
1995
  const endMin = endH * 60 + endM;
1995
1996
  const slots: string[] = [];
1996
- for (let m = startMin; m + dur <= endMin; m += dur) {
1997
+ const firstStart =
1998
+ Math.ceil(startMin / BOOKING_SLOT_STEP_MINUTES) *
1999
+ BOOKING_SLOT_STEP_MINUTES;
2000
+ for (
2001
+ let m = firstStart;
2002
+ m + dur <= endMin;
2003
+ m += BOOKING_SLOT_STEP_MINUTES
2004
+ ) {
1997
2005
  const h = Math.floor(m / 60);
1998
2006
  const mm = m % 60;
1999
2007
  const ampm = h >= 12 ? "PM" : "AM";
@@ -5,7 +5,7 @@ import {
5
5
  useT,
6
6
  } from "@agent-native/core/client";
7
7
  import type { Booking } from "@shared/api";
8
- import { IconCalendar } from "@tabler/icons-react";
8
+ import { IconAlertTriangle, IconCalendar } from "@tabler/icons-react";
9
9
  import {
10
10
  addMinutes,
11
11
  endOfMonth,
@@ -122,23 +122,26 @@ export default function BookingPage() {
122
122
  availability?.slotDurationMinutes ??
123
123
  settings?.defaultEventDuration ??
124
124
  30;
125
- const { data: slots = [], isLoading: slotsLoading } = useAvailableSlots(
126
- dateStr,
125
+ const {
126
+ data: slots = [],
127
+ isLoading: slotsLoading,
128
+ error: slotsError,
129
+ } = useAvailableSlots(dateStr, duration, slug);
130
+ const monthStart = format(startOfMonth(viewMonth), "yyyy-MM-dd");
131
+ const monthEnd = format(endOfMonth(viewMonth), "yyyy-MM-dd");
132
+ const {
133
+ data: availableDates = [],
134
+ isLoading: availableDatesLoading,
135
+ error: availableDatesError,
136
+ } = useAvailableDays(
137
+ monthStart,
138
+ monthEnd,
127
139
  duration,
128
140
  slug,
141
+ step === "date" &&
142
+ !!availability &&
143
+ (!hasDurationChoice || selectedDuration !== null),
129
144
  );
130
- const monthStart = format(startOfMonth(viewMonth), "yyyy-MM-dd");
131
- const monthEnd = format(endOfMonth(viewMonth), "yyyy-MM-dd");
132
- const { data: availableDates = [], isLoading: availableDatesLoading } =
133
- useAvailableDays(
134
- monthStart,
135
- monthEnd,
136
- duration,
137
- slug,
138
- step === "date" &&
139
- !!availability &&
140
- (!hasDurationChoice || selectedDuration !== null),
141
- );
142
145
  const createBooking = useCreateBooking();
143
146
  const selectedSlotRange = selectedSlot
144
147
  ? {
@@ -236,6 +239,7 @@ export default function BookingPage() {
236
239
  const pageTitle = bookingLink?.title || title;
237
240
  const pageDescription = bookingLink?.description || description;
238
241
  const requiredHostCount = (bookingLink?.hosts?.length ?? 0) + 1;
242
+ const availabilityErrorMessage = t("bookingLinks.availabilityUnavailable");
239
243
 
240
244
  useEffect(() => {
241
245
  if (hasDurationChoice && step === "date" && selectedDuration === null) {
@@ -404,17 +408,26 @@ export default function BookingPage() {
404
408
  <h3 className="mb-4 text-sm font-medium text-center">
405
409
  {t("bookingLinks.selectDate")}
406
410
  </h3>
407
- <div className="flex justify-center">
408
- <DatePicker
409
- selectedDate={selectedDate}
410
- onSelect={handleDateSelect}
411
- availability={availability}
412
- availableDates={availableDates}
413
- availabilityLoading={availableDatesLoading}
414
- viewMonth={viewMonth}
415
- onViewMonthChange={setViewMonth}
416
- />
417
- </div>
411
+ {availableDatesError ? (
412
+ <div className="rounded-lg border border-destructive/30 bg-destructive/[0.06] px-3 py-3 text-sm text-destructive">
413
+ <div className="flex items-start gap-2">
414
+ <IconAlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
415
+ <p>{availabilityErrorMessage}</p>
416
+ </div>
417
+ </div>
418
+ ) : (
419
+ <div className="flex justify-center">
420
+ <DatePicker
421
+ selectedDate={selectedDate}
422
+ onSelect={handleDateSelect}
423
+ availability={availability}
424
+ availableDates={availableDates}
425
+ availabilityLoading={availableDatesLoading}
426
+ viewMonth={viewMonth}
427
+ onViewMonthChange={setViewMonth}
428
+ />
429
+ </div>
430
+ )}
418
431
  </div>
419
432
  )}
420
433
 
@@ -443,6 +456,7 @@ export default function BookingPage() {
443
456
  selectedSlot={selectedSlot}
444
457
  onSelect={handleSlotSelect}
445
458
  loading={slotsLoading}
459
+ errorMessage={slotsError ? availabilityErrorMessage : undefined}
446
460
  />
447
461
  </div>
448
462
  )}
@@ -47,6 +47,7 @@ import {
47
47
  useDisconnectZoom,
48
48
  useZoomStatus,
49
49
  } from "@/hooks/use-zoom-auth";
50
+ import { shouldOfferGoogleOAuthSetup } from "@/lib/google-oauth-setup";
50
51
 
51
52
  import changelog from "../../CHANGELOG.md?raw";
52
53
 
@@ -70,6 +71,7 @@ export default function Settings() {
70
71
  const disconnectZoom = useDisconnectZoom();
71
72
  const [wantAuthUrl, setWantAuthUrl] = useState(false);
72
73
  const authUrl = useGoogleAuthUrl(wantAuthUrl);
74
+ const canOfferGoogleOAuthSetup = shouldOfferGoogleOAuthSetup();
73
75
 
74
76
  const [timezone, setTimezone] = useState("");
75
77
  const [bookingTitle, setBookingTitle] = useState("");
@@ -333,7 +335,7 @@ export default function Settings() {
333
335
  </Card>
334
336
 
335
337
  {/* Google Setup Wizard */}
336
- {!googleStatus.data?.connected && (
338
+ {!googleStatus.data?.connected && canOfferGoogleOAuthSetup && (
337
339
  <Card>
338
340
  <CardHeader>
339
341
  <CardTitle className="text-lg">
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-07-01
4
+ ---
5
+
6
+ Booking links now show an error when calendar availability cannot be checked and use 30-minute start intervals.