@vanira/sdk-react-native 0.0.2

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 (148) hide show
  1. package/README.md +239 -0
  2. package/package.json +53 -0
  3. package/src/__tests__/WebRTCClient.integration.test.ts +396 -0
  4. package/src/__tests__/adapters.test.ts +475 -0
  5. package/src/__tests__/httpResponse.test.ts +25 -0
  6. package/src/__tests__/mocks/react-native-incall-manager.ts +8 -0
  7. package/src/__tests__/mocks/react-native-permissions.ts +15 -0
  8. package/src/__tests__/mocks/react-native-webrtc.ts +6 -0
  9. package/src/__tests__/mocks/react-native.ts +28 -0
  10. package/src/__tests__/preset.test.ts +239 -0
  11. package/src/__tests__/resolveRuntimeConfig.test.ts +90 -0
  12. package/src/__tests__/storage.test.ts +211 -0
  13. package/src/__tests__/webrtcSignaling.test.ts +42 -0
  14. package/src/adapters/PeerConnectionAdapter.ts +101 -0
  15. package/src/adapters/browser/BrowserAudioAdapter.ts +43 -0
  16. package/src/adapters/browser/BrowserDataChannelAdapter.ts +69 -0
  17. package/src/adapters/browser/BrowserMediaAdapter.ts +15 -0
  18. package/src/adapters/browser/BrowserPeerAdapter.ts +14 -0
  19. package/src/adapters/browser/index.ts +4 -0
  20. package/src/adapters/interfaces.ts +84 -0
  21. package/src/adapters/react-native/RNAudioAdapter.ts +42 -0
  22. package/src/adapters/react-native/RNDataChannelAdapter.ts +79 -0
  23. package/src/adapters/react-native/RNMediaAdapter.ts +46 -0
  24. package/src/adapters/react-native/RNPeerAdapter.ts +28 -0
  25. package/src/adapters/react-native/callAudioRouting.ts +115 -0
  26. package/src/adapters/react-native/decodeUtf8.ts +72 -0
  27. package/src/adapters/react-native/index.ts +4 -0
  28. package/src/adapters/react-native/rnUploadFile.ts +76 -0
  29. package/src/adapters/storage/BrowserDualStorageAdapter.ts +71 -0
  30. package/src/adapters/storage/MemoryStorageAdapter.ts +50 -0
  31. package/src/adapters/storage/StorageAdapter.ts +21 -0
  32. package/src/adapters/storage/createSyncStorageAdapter.ts +40 -0
  33. package/src/adapters/storage/index.ts +7 -0
  34. package/src/api/services/ChatService.ts +304 -0
  35. package/src/api/services/ConfigService.ts +33 -0
  36. package/src/assets/icons.js +35 -0
  37. package/src/cdn.ts +68 -0
  38. package/src/core/CallSessionStore.ts +137 -0
  39. package/src/core/DraggableController.ts +83 -0
  40. package/src/core/SessionManager.ts +322 -0
  41. package/src/core/VaniraAI.ts +464 -0
  42. package/src/core/WebRTCClient.ts +1012 -0
  43. package/src/core/httpResponse.ts +22 -0
  44. package/src/core/iceServers.ts +18 -0
  45. package/src/core/toolCallNormalize.ts +80 -0
  46. package/src/core/voice-client.js +236 -0
  47. package/src/core/webrtcSignaling.ts +72 -0
  48. package/src/index.js +34 -0
  49. package/src/index.ts +6 -0
  50. package/src/platforms/browser.ts +67 -0
  51. package/src/platforms/react-native.ts +105 -0
  52. package/src/presets/BookingCalendarModal.tsx +457 -0
  53. package/src/presets/CameraModal.tsx +576 -0
  54. package/src/presets/DynamicFormModal.tsx +378 -0
  55. package/src/presets/NativePresetRenderer.tsx +350 -0
  56. package/src/presets/NavigateHandler.tsx +75 -0
  57. package/src/presets/PresetHost.tsx +155 -0
  58. package/src/presets/PresetShellModal.tsx +97 -0
  59. package/src/presets/UploadModal.tsx +321 -0
  60. package/src/presets/calendar/calendarUtils.ts +386 -0
  61. package/src/presets/call/CallSpeakerToggle.tsx +59 -0
  62. package/src/presets/call/callAudioRouting.ts +2 -0
  63. package/src/presets/call/useCallSpeaker.ts +31 -0
  64. package/src/presets/camera/cameraPermissions.ts +18 -0
  65. package/src/presets/camera/cameraStream.ts +19 -0
  66. package/src/presets/camera/cameraUtils.ts +21 -0
  67. package/src/presets/camera/useLivenessFlow.ts +95 -0
  68. package/src/presets/chalkboard/ChalkboardOverlay.tsx +156 -0
  69. package/src/presets/chalkboard/EraseTextHandler.tsx +95 -0
  70. package/src/presets/chalkboard/TypeTextHandler.tsx +107 -0
  71. package/src/presets/chalkboard/boardAbort.ts +36 -0
  72. package/src/presets/chalkboard/boardQueue.ts +620 -0
  73. package/src/presets/chalkboard/chalkboardSession.ts +75 -0
  74. package/src/presets/chalkboard/drawUtils.ts +123 -0
  75. package/src/presets/chalkboard/textUtils.ts +109 -0
  76. package/src/presets/clipRegion/ClipRegionModal.tsx +261 -0
  77. package/src/presets/clipRegion/clipRegionBridge.ts +19 -0
  78. package/src/presets/form/formValidation.ts +104 -0
  79. package/src/presets/form/parseFormFields.ts +171 -0
  80. package/src/presets/host/HostElementPresetHandler.tsx +155 -0
  81. package/src/presets/host/hostPresetBridge.ts +71 -0
  82. package/src/presets/index.ts +63 -0
  83. package/src/presets/liveScreen/CloseLiveScreenHandler.tsx +36 -0
  84. package/src/presets/liveScreen/LiveScreenCaptureHost.tsx +312 -0
  85. package/src/presets/liveScreen/LiveScreenHandler.tsx +25 -0
  86. package/src/presets/liveScreen/LiveScreenPipOverlay.tsx +6 -0
  87. package/src/presets/liveScreen/liveScreenSession.ts +73 -0
  88. package/src/presets/liveVision/CloseLiveVisionHandler.tsx +29 -0
  89. package/src/presets/liveVision/LiveVisionCameraHost.tsx +317 -0
  90. package/src/presets/liveVision/LiveVisionHandler.tsx +26 -0
  91. package/src/presets/liveVision/LiveVisionPipOverlay.tsx +7 -0
  92. package/src/presets/liveVision/liveVisionFrameLoop.ts +38 -0
  93. package/src/presets/liveVision/liveVisionSession.ts +75 -0
  94. package/src/presets/liveVision/liveVisionUpload.ts +62 -0
  95. package/src/presets/navigation/internalRouteRegistry.ts +25 -0
  96. package/src/presets/navigation/navigationBridge.ts +76 -0
  97. package/src/presets/navigation/navigationTypes.ts +12 -0
  98. package/src/presets/parseToolCall.ts +60 -0
  99. package/src/presets/presetClientAdapter.ts +29 -0
  100. package/src/presets/presetCompletion.ts +91 -0
  101. package/src/presets/presetEventHelpers.ts +45 -0
  102. package/src/presets/registry.ts +128 -0
  103. package/src/presets/streaming/mediaFrameUpload.ts +93 -0
  104. package/src/presets/types.ts +74 -0
  105. package/src/presets/upload/pickUploadFile.ts +256 -0
  106. package/src/presets/upload/uploadFormats.ts +163 -0
  107. package/src/presets/upload/uploadUtils.ts +68 -0
  108. package/src/react/PresetRenderer.tsx +144 -0
  109. package/src/react/index.ts +1 -0
  110. package/src/runtime/browserRuntime.ts +54 -0
  111. package/src/runtime/platform.ts +17 -0
  112. package/src/runtime/reactNativeRuntime.ts +68 -0
  113. package/src/runtime/resolveRuntimeConfig.ts +75 -0
  114. package/src/runtime/runtimeBundles.ts +74 -0
  115. package/src/runtime/types.ts +135 -0
  116. package/src/types/react-native-incall-manager.d.ts +17 -0
  117. package/src/types/react-native-webrtc.d.ts +47 -0
  118. package/src/types.ts +133 -0
  119. package/src/ui/VaniraWidget.ts +87 -0
  120. package/src/ui/abstraction/AbstractWidgetProvider.ts +18 -0
  121. package/src/ui/abstraction/interfaces.ts +12 -0
  122. package/src/ui/adapters/VaniraChatAdapter.ts +42 -0
  123. package/src/ui/components/AvatarView.ts +81 -0
  124. package/src/ui/components/ChatWindow.ts +263 -0
  125. package/src/ui/components/FloatingButton.ts +163 -0
  126. package/src/ui/components/FloatingWelcomeChips.ts +137 -0
  127. package/src/ui/components/Panel.ts +120 -0
  128. package/src/ui/components/VoiceOrb.ts +79 -0
  129. package/src/ui/components/VoiceOverlay.ts +497 -0
  130. package/src/ui/components/index.ts +7 -0
  131. package/src/ui/factory/WidgetFactory.ts +16 -0
  132. package/src/ui/icons_data.ts +2 -0
  133. package/src/ui/presets/WidgetPresetRenderer.ts +1802 -0
  134. package/src/ui/presets/types.ts +16 -0
  135. package/src/ui/providers/VaniraInternalProvider.ts +1066 -0
  136. package/src/ui/styles/index.ts +323 -0
  137. package/src/ui/styles/keyframes.ts +76 -0
  138. package/src/ui/styles/theme.ts +57 -0
  139. package/src/ui/styles/widget.css.ts +838 -0
  140. package/src/ui/utils.ts +37 -0
  141. package/src/ui/views/AbstractChatView.ts +93 -0
  142. package/src/ui/views/AbstractVoiceView.ts +57 -0
  143. package/src/ui/views/AvatarOnlyView.ts +78 -0
  144. package/src/ui/views/ChatAvatarView.ts +66 -0
  145. package/src/ui/views/ChatOnlyView.ts +28 -0
  146. package/src/ui/views/ChatVoiceView.ts +15 -0
  147. package/src/ui/views/VoiceOnlyView.ts +25 -0
  148. package/src/ui/views/index.ts +5 -0
@@ -0,0 +1,321 @@
1
+ import React, {useCallback, useEffect, useMemo, useState} from 'react';
2
+ import {
3
+ ActivityIndicator,
4
+ Alert,
5
+ Pressable,
6
+ StyleSheet,
7
+ Text,
8
+ View,
9
+ } from 'react-native';
10
+ import {PresetShellModal} from './PresetShellModal';
11
+ import type {PresetClient, PresetContext} from './types';
12
+ import {pickUploadFile} from './upload/pickUploadFile';
13
+ import {
14
+ filePreviewIcon,
15
+ WEB_DEFAULT_UPLOAD_ACCEPT,
16
+ } from './upload/uploadFormats';
17
+ import {formatAcceptHint} from './upload/uploadUtils';
18
+
19
+ type Props = {
20
+ visible: boolean;
21
+ ctx: PresetContext;
22
+ client: PresetClient | null;
23
+ onClose: () => void;
24
+ onCancel: (reason?: string) => void;
25
+ onComplete: (payload: Record<string, unknown>) => void;
26
+ };
27
+
28
+ const SUCCESS_DELAY_MS = 400;
29
+
30
+ export function UploadModal({
31
+ visible,
32
+ ctx,
33
+ client,
34
+ onClose,
35
+ onCancel,
36
+ onComplete,
37
+ }: Props) {
38
+ const merged = useMemo(
39
+ () => ({...ctx.args, ...ctx.clientFields}),
40
+ [ctx.args, ctx.clientFields],
41
+ );
42
+
43
+ const title = String(merged.title ?? 'Upload a File');
44
+ const description = String(
45
+ merged.description ?? 'Select a file to send to your agent.',
46
+ );
47
+ const reason = String(merged.reason ?? ctx.args.reason ?? 'upload');
48
+ const accept = String(
49
+ merged.accept ?? ctx.clientFields.accept ?? WEB_DEFAULT_UPLOAD_ACCEPT,
50
+ );
51
+ const acceptHint = formatAcceptHint(accept);
52
+
53
+ const [selectedFile, setSelectedFile] = useState<{
54
+ uri: string;
55
+ type: string;
56
+ name: string;
57
+ size?: number;
58
+ } | null>(null);
59
+ const [uploading, setUploading] = useState(false);
60
+ const [picking, setPicking] = useState(false);
61
+ const [statusMessage, setStatusMessage] = useState<string | null>(null);
62
+ const [errorMessage, setErrorMessage] = useState<string | null>(null);
63
+
64
+ useEffect(() => {
65
+ if (!visible) {
66
+ return;
67
+ }
68
+ setSelectedFile(null);
69
+ setUploading(false);
70
+ setPicking(false);
71
+ setStatusMessage(null);
72
+ setErrorMessage(null);
73
+ }, [visible, ctx.toolCallId]);
74
+
75
+ const resetSelection = useCallback(() => {
76
+ setSelectedFile(null);
77
+ setStatusMessage(null);
78
+ setErrorMessage(null);
79
+ setUploading(false);
80
+ }, []);
81
+
82
+ const pickFile = useCallback(async () => {
83
+ if (uploading || picking) {
84
+ return;
85
+ }
86
+
87
+ setPicking(true);
88
+ setErrorMessage(null);
89
+
90
+ const result = await pickUploadFile(accept);
91
+ setPicking(false);
92
+
93
+ switch (result.status) {
94
+ case 'ok':
95
+ setSelectedFile(result.file);
96
+ setStatusMessage(null);
97
+ break;
98
+ case 'cancelled':
99
+ break;
100
+ case 'permission_denied':
101
+ Alert.alert(
102
+ 'Photos',
103
+ 'Photo library access is needed to choose images.',
104
+ );
105
+ break;
106
+ case 'unsupported_type':
107
+ setErrorMessage(
108
+ 'That file type is not supported. Try JPEG, PNG, PDF, TXT, or CSV.',
109
+ );
110
+ break;
111
+ case 'error':
112
+ setErrorMessage(result.message);
113
+ break;
114
+ }
115
+ }, [accept, picking, uploading]);
116
+
117
+ const handleSubmit = useCallback(async () => {
118
+ if (!selectedFile || uploading) {
119
+ return;
120
+ }
121
+
122
+ if (!client?.uploadMedia) {
123
+ setErrorMessage('Upload is not available — start a voice call first.');
124
+ return;
125
+ }
126
+
127
+ setUploading(true);
128
+ setErrorMessage(null);
129
+ setStatusMessage('Uploading…');
130
+
131
+ try {
132
+ const response = await client.uploadMedia(
133
+ selectedFile,
134
+ reason,
135
+ `User uploaded: ${selectedFile.name}`,
136
+ );
137
+
138
+ setStatusMessage('✓ File sent!');
139
+ setTimeout(() => {
140
+ setUploading(false);
141
+ onComplete({
142
+ file_name: selectedFile.name,
143
+ file_type: selectedFile.type,
144
+ file_size: selectedFile.size ?? 0,
145
+ media_id: response.media_id,
146
+ url: response.url,
147
+ message: `User uploaded: ${selectedFile.name}`,
148
+ });
149
+ }, SUCCESS_DELAY_MS);
150
+ } catch (err: unknown) {
151
+ const message = err instanceof Error ? err.message : 'Upload failed. Please try again.';
152
+ setUploading(false);
153
+ setStatusMessage(null);
154
+ setErrorMessage(message);
155
+ }
156
+ }, [client, onComplete, reason, selectedFile, uploading]);
157
+
158
+ const fileSizeLabel =
159
+ selectedFile?.size !== undefined
160
+ ? `${(selectedFile.size / 1024).toFixed(1)} KB`
161
+ : 'unknown size';
162
+
163
+ const previewIcon = selectedFile
164
+ ? filePreviewIcon(selectedFile.type)
165
+ : '📁';
166
+
167
+ return (
168
+ <PresetShellModal
169
+ visible={visible}
170
+ title={title}
171
+ subtitle={description}
172
+ onClose={onClose}
173
+ hideDefaultClose>
174
+ {!selectedFile ? (
175
+ <Pressable
176
+ style={styles.dropzone}
177
+ onPress={pickFile}
178
+ disabled={uploading || picking}>
179
+ {picking ? (
180
+ <ActivityIndicator color="#4f46e5" />
181
+ ) : (
182
+ <Text style={styles.dropzoneIcon}>📁</Text>
183
+ )}
184
+ <Text style={styles.dropzoneTitle}>
185
+ {picking ? 'Opening file picker…' : 'Tap to choose a file'}
186
+ </Text>
187
+ <Text style={styles.dropzoneHint}>{acceptHint}</Text>
188
+ </Pressable>
189
+ ) : (
190
+ <View style={styles.preview}>
191
+ <Text style={styles.previewIcon}>{previewIcon}</Text>
192
+ <View style={styles.previewMeta}>
193
+ <Text style={styles.previewName} numberOfLines={1}>
194
+ {selectedFile.name}
195
+ </Text>
196
+ <Text style={styles.previewSub}>
197
+ {selectedFile.type} · {fileSizeLabel}
198
+ </Text>
199
+ </View>
200
+ <Pressable
201
+ style={styles.removeBtn}
202
+ onPress={resetSelection}
203
+ disabled={uploading}>
204
+ <Text style={styles.removeBtnText}>✕</Text>
205
+ </Pressable>
206
+ </View>
207
+ )}
208
+
209
+ {statusMessage ? (
210
+ <View style={styles.statusRow}>
211
+ {uploading ? <ActivityIndicator color="#4f46e5" /> : null}
212
+ <Text style={styles.statusText}>{statusMessage}</Text>
213
+ </View>
214
+ ) : null}
215
+ {errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
216
+
217
+ <View style={styles.actions}>
218
+ <Pressable
219
+ style={styles.cancelBtn}
220
+ onPress={() => onCancel('User cancelled')}
221
+ disabled={uploading}>
222
+ <Text style={styles.cancelBtnText}>Cancel</Text>
223
+ </Pressable>
224
+ <Pressable
225
+ style={[
226
+ styles.submitBtn,
227
+ (!selectedFile || uploading) && styles.submitDisabled,
228
+ ]}
229
+ onPress={handleSubmit}
230
+ disabled={!selectedFile || uploading}>
231
+ {uploading ? (
232
+ <ActivityIndicator color="#fff" />
233
+ ) : (
234
+ <Text style={styles.submitBtnText}>Upload & Send</Text>
235
+ )}
236
+ </Pressable>
237
+ </View>
238
+ </PresetShellModal>
239
+ );
240
+ }
241
+
242
+ const styles = StyleSheet.create({
243
+ dropzone: {
244
+ borderWidth: 2,
245
+ borderColor: '#e5e7eb',
246
+ borderStyle: 'dashed',
247
+ borderRadius: 12,
248
+ paddingVertical: 28,
249
+ alignItems: 'center',
250
+ backgroundColor: '#f9fafb',
251
+ gap: 6,
252
+ },
253
+ dropzoneIcon: {fontSize: 32, marginBottom: 4},
254
+ dropzoneTitle: {fontSize: 14, fontWeight: '600', color: '#374151'},
255
+ dropzoneHint: {
256
+ fontSize: 12,
257
+ color: '#9ca3af',
258
+ marginTop: 2,
259
+ textAlign: 'center',
260
+ paddingHorizontal: 12,
261
+ },
262
+ preview: {
263
+ flexDirection: 'row',
264
+ alignItems: 'center',
265
+ gap: 10,
266
+ borderWidth: 1,
267
+ borderColor: '#e5e7eb',
268
+ borderRadius: 12,
269
+ padding: 14,
270
+ backgroundColor: '#f9fafb',
271
+ },
272
+ previewIcon: {fontSize: 28},
273
+ previewMeta: {flex: 1, minWidth: 0},
274
+ previewName: {fontSize: 13, fontWeight: '600', color: '#111'},
275
+ previewSub: {fontSize: 11, color: '#6b7280', marginTop: 3},
276
+ removeBtn: {
277
+ width: 30,
278
+ height: 30,
279
+ borderRadius: 8,
280
+ borderWidth: 1,
281
+ borderColor: '#e5e7eb',
282
+ alignItems: 'center',
283
+ justifyContent: 'center',
284
+ backgroundColor: '#fff',
285
+ },
286
+ removeBtnText: {color: '#6b7280', fontWeight: '700'},
287
+ statusRow: {
288
+ flexDirection: 'row',
289
+ alignItems: 'center',
290
+ gap: 8,
291
+ marginTop: 12,
292
+ justifyContent: 'center',
293
+ },
294
+ statusText: {fontSize: 13, color: '#22c55e', fontWeight: '600'},
295
+ errorText: {
296
+ fontSize: 13,
297
+ color: '#ef4444',
298
+ textAlign: 'center',
299
+ marginTop: 12,
300
+ },
301
+ actions: {flexDirection: 'row', gap: 10, marginTop: 14},
302
+ cancelBtn: {
303
+ flex: 1,
304
+ paddingVertical: 12,
305
+ borderRadius: 10,
306
+ backgroundColor: '#f3f4f6',
307
+ alignItems: 'center',
308
+ },
309
+ cancelBtnText: {color: '#111', fontWeight: '600'},
310
+ submitBtn: {
311
+ flex: 1,
312
+ paddingVertical: 12,
313
+ borderRadius: 10,
314
+ backgroundColor: '#4f46e5',
315
+ alignItems: 'center',
316
+ justifyContent: 'center',
317
+ minHeight: 44,
318
+ },
319
+ submitBtnDisabled: {opacity: 0.5},
320
+ submitBtnText: {color: '#fff', fontWeight: '700'},
321
+ });
@@ -0,0 +1,386 @@
1
+ /**
2
+ * Calendar helpers — ported from vanira-sdk WidgetPresetRenderer (web source of truth).
3
+ */
4
+
5
+ export const FALLBACK_SLOTS = [
6
+ '09:00 AM',
7
+ '10:30 AM',
8
+ '01:00 PM',
9
+ '02:30 PM',
10
+ '04:00 PM',
11
+ ] as const;
12
+
13
+ export type SlotItem = string | Record<string, unknown>;
14
+
15
+ export function formatSlotTime(item: SlotItem | null | undefined): string {
16
+ if (!item) {
17
+ return '';
18
+ }
19
+ if (typeof item === 'string') {
20
+ if (item.includes('T') || !Number.isNaN(Date.parse(item))) {
21
+ try {
22
+ return new Date(item).toLocaleTimeString('en-US', {
23
+ hour: '2-digit',
24
+ minute: '2-digit',
25
+ });
26
+ } catch {
27
+ /* fall through */
28
+ }
29
+ }
30
+ return item;
31
+ }
32
+ if (typeof item === 'object') {
33
+ const sv =
34
+ item.start || item.startTime || item.time || item.slot;
35
+ const ev = item.end || item.endTime;
36
+ if (sv) {
37
+ let s = String(sv);
38
+ let e = ev ? String(ev) : '';
39
+ try {
40
+ if (s.includes('T') || !Number.isNaN(Date.parse(s))) {
41
+ s = new Date(s).toLocaleTimeString('en-US', {
42
+ hour: '2-digit',
43
+ minute: '2-digit',
44
+ });
45
+ }
46
+ if (e && (e.includes('T') || !Number.isNaN(Date.parse(e)))) {
47
+ e = new Date(e).toLocaleTimeString('en-US', {
48
+ hour: '2-digit',
49
+ minute: '2-digit',
50
+ });
51
+ }
52
+ } catch {
53
+ /* keep raw */
54
+ }
55
+ if (e) {
56
+ return `${s} - ${e}`;
57
+ }
58
+ return s;
59
+ }
60
+ }
61
+ return String(item);
62
+ }
63
+
64
+ export function getDayBoundsInTimezone(
65
+ y: number,
66
+ month: number,
67
+ day: number,
68
+ timezone?: string,
69
+ ): {start: Date; end: Date} {
70
+ let startMs = Date.UTC(y, month, day, 0, 0, 0, 0);
71
+ let endMs = Date.UTC(y, month, day, 23, 59, 59, 999);
72
+
73
+ if (timezone) {
74
+ try {
75
+ const noonUtc = new Date(Date.UTC(y, month, day, 12, 0, 0));
76
+ const formatter = new Intl.DateTimeFormat('en-US', {
77
+ timeZone: timezone,
78
+ year: 'numeric',
79
+ month: 'numeric',
80
+ day: 'numeric',
81
+ hour: 'numeric',
82
+ minute: 'numeric',
83
+ second: 'numeric',
84
+ hour12: false,
85
+ });
86
+ const parts = formatter.formatToParts(noonUtc);
87
+ const getPart = (type: string) =>
88
+ parts.find(p => p.type === type)?.value;
89
+
90
+ const yr = parseInt(getPart('year') || '0', 10);
91
+ const mo = parseInt(getPart('month') || '0', 10) - 1;
92
+ const dy = parseInt(getPart('day') || '0', 10);
93
+ const hr = parseInt(getPart('hour') || '0', 10);
94
+ const min = parseInt(getPart('minute') || '0', 10);
95
+ const sec = parseInt(getPart('second') || '0', 10);
96
+ const hourNormalized = hr === 24 ? 0 : hr;
97
+
98
+ const targetUtc = Date.UTC(yr, mo, dy, hourNormalized, min, sec, 0);
99
+ const offsetMs = targetUtc - noonUtc.getTime();
100
+
101
+ startMs = Date.UTC(y, month, day, 0, 0, 0, 0) - offsetMs;
102
+ endMs = Date.UTC(y, month, day, 23, 59, 59, 999) - offsetMs;
103
+ } catch (e) {
104
+ console.warn(
105
+ `[calendarUtils] Failed day bounds for timezone ${timezone}:`,
106
+ e,
107
+ );
108
+ }
109
+ }
110
+
111
+ return {start: new Date(startMs), end: new Date(endMs)};
112
+ }
113
+
114
+ export function toTimezoneISOString(date: Date, timeZone?: string): string {
115
+ if (!timeZone) {
116
+ const tzOffset = -date.getTimezoneOffset();
117
+ const diff = tzOffset >= 0 ? '+' : '-';
118
+ const pad = (num: number) => String(num).padStart(2, '0');
119
+ const pad3 = (num: number) => String(num).padStart(3, '0');
120
+ return (
121
+ date.getFullYear() +
122
+ '-' +
123
+ pad(date.getMonth() + 1) +
124
+ '-' +
125
+ pad(date.getDate()) +
126
+ 'T' +
127
+ pad(date.getHours()) +
128
+ ':' +
129
+ pad(date.getMinutes()) +
130
+ ':' +
131
+ pad(date.getSeconds()) +
132
+ '.' +
133
+ pad3(date.getMilliseconds()) +
134
+ diff +
135
+ pad(Math.floor(Math.abs(tzOffset) / 60)) +
136
+ ':' +
137
+ pad(Math.abs(tzOffset) % 60)
138
+ );
139
+ }
140
+
141
+ try {
142
+ const formatter = new Intl.DateTimeFormat('en-US', {
143
+ timeZone,
144
+ year: 'numeric',
145
+ month: 'numeric',
146
+ day: 'numeric',
147
+ hour: 'numeric',
148
+ minute: 'numeric',
149
+ second: 'numeric',
150
+ hour12: false,
151
+ });
152
+ const parts = formatter.formatToParts(date);
153
+ const getPart = (type: string) =>
154
+ parts.find(p => p.type === type)?.value || '0';
155
+
156
+ const yr = getPart('year');
157
+ const mo = getPart('month').padStart(2, '0');
158
+ const dy = getPart('day').padStart(2, '0');
159
+ let hr = parseInt(getPart('hour'), 10);
160
+ if (hr === 24) {
161
+ hr = 0;
162
+ }
163
+ const hrStr = String(hr).padStart(2, '0');
164
+ const min = getPart('minute').padStart(2, '0');
165
+ const sec = getPart('second').padStart(2, '0');
166
+ const ms = String(date.getMilliseconds()).padStart(3, '0');
167
+
168
+ const yrInt = parseInt(yr, 10);
169
+ const moInt = parseInt(mo, 10) - 1;
170
+ const dyInt = parseInt(dy, 10);
171
+ const targetUtc = Date.UTC(
172
+ yrInt,
173
+ moInt,
174
+ dyInt,
175
+ hr,
176
+ parseInt(min, 10),
177
+ parseInt(sec, 10),
178
+ date.getMilliseconds(),
179
+ );
180
+ const offsetMs = targetUtc - date.getTime();
181
+ const offsetMin = Math.round(offsetMs / 60000);
182
+ const diff = offsetMin >= 0 ? '+' : '-';
183
+ const absOffsetMin = Math.abs(offsetMin);
184
+ const offsetHrStr = String(Math.floor(absOffsetMin / 60)).padStart(2, '0');
185
+ const offsetMinStr = String(absOffsetMin % 60).padStart(2, '0');
186
+
187
+ return `${yr}-${mo}-${dy}T${hrStr}:${min}:${sec}.${ms}${diff}${offsetHrStr}:${offsetMinStr}`;
188
+ } catch (e) {
189
+ console.warn(`[calendarUtils] timezone ISO format failed for ${timeZone}:`, e);
190
+ return date.toISOString();
191
+ }
192
+ }
193
+
194
+ export function startOfLocalDay(year: number, month: number, day: number): number {
195
+ const d = new Date(year, month, day);
196
+ d.setHours(0, 0, 0, 0);
197
+ return d.getTime();
198
+ }
199
+
200
+ export function parseSlotsResponse(data: unknown): SlotItem[] {
201
+ if (Array.isArray(data)) {
202
+ return data as SlotItem[];
203
+ }
204
+ if (data && typeof data === 'object') {
205
+ const obj = data as Record<string, unknown>;
206
+ for (const key of ['slots', 'free_slots', 'times', 'data', 'available_slots']) {
207
+ if (Array.isArray(obj[key])) {
208
+ return obj[key] as SlotItem[];
209
+ }
210
+ }
211
+ }
212
+ return [];
213
+ }
214
+
215
+ function replaceTemplate(
216
+ val: string,
217
+ dateStr: string,
218
+ timezone: string,
219
+ startOfDay: Date,
220
+ endOfDay: Date,
221
+ clientFields: Record<string, unknown>,
222
+ ): string {
223
+ let replaced = val
224
+ .replace(/\{\{date\}\}/g, dateStr)
225
+ .replace(/\{\{timezone\}\}/g, timezone)
226
+ .replace(/\{\{start_of_day\}\}/g, startOfDay.toISOString())
227
+ .replace(/\{\{end_of_day\}\}/g, endOfDay.toISOString());
228
+
229
+ Object.entries(clientFields).forEach(([k, v]) => {
230
+ if (v !== undefined && v !== null) {
231
+ const escapedKey = k.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
232
+ const regex = new RegExp(`\\{\\{${escapedKey}\\}\\}`, 'g');
233
+ replaced = replaced.replace(regex, String(v));
234
+ }
235
+ });
236
+ return replaced;
237
+ }
238
+
239
+ export type SlotsFetchRequest = {
240
+ url: string;
241
+ method: string;
242
+ headers: Record<string, string>;
243
+ body?: string;
244
+ };
245
+
246
+ /** Build slots API request — mirrors WidgetPresetRenderer.loadSlots. */
247
+ export function buildSlotsFetchRequest(
248
+ selectedTs: number,
249
+ mergedClientFields: Record<string, unknown>,
250
+ ): SlotsFetchRequest | null {
251
+ const slotsApiUrl = mergedClientFields.slots_api_url;
252
+ if (!slotsApiUrl || typeof slotsApiUrl !== 'string') {
253
+ return null;
254
+ }
255
+
256
+ const dateObj = new Date(selectedTs);
257
+ const y = dateObj.getFullYear();
258
+ const m = String(dateObj.getMonth() + 1).padStart(2, '0');
259
+ const d = String(dateObj.getDate()).padStart(2, '0');
260
+ const dateStr = `${y}-${m}-${d}`;
261
+ const tzStr = String(mergedClientFields.timezone ?? '');
262
+ const bounds = getDayBoundsInTimezone(
263
+ y,
264
+ dateObj.getMonth(),
265
+ dateObj.getDate(),
266
+ tzStr || undefined,
267
+ );
268
+
269
+ const rp = (val: string) =>
270
+ replaceTemplate(
271
+ val,
272
+ dateStr,
273
+ tzStr,
274
+ bounds.start,
275
+ bounds.end,
276
+ mergedClientFields,
277
+ );
278
+
279
+ const method = String(
280
+ mergedClientFields.slots_api_method || 'GET',
281
+ ).toUpperCase();
282
+ const headers: Record<string, string> = {'Content-Type': 'application/json'};
283
+ try {
284
+ const parsed = JSON.parse(
285
+ String(mergedClientFields.slots_api_headers || '{}'),
286
+ ) as Record<string, unknown>;
287
+ Object.entries(parsed).forEach(([k, v]) => {
288
+ headers[k] = rp(String(v));
289
+ });
290
+ } catch {
291
+ /* ignore bad headers JSON */
292
+ }
293
+
294
+ let bodyObj: Record<string, unknown> = {};
295
+ try {
296
+ const parsed = JSON.parse(
297
+ String(mergedClientFields.slots_api_body || '{}'),
298
+ ) as Record<string, unknown>;
299
+ Object.entries(parsed).forEach(([k, v]) => {
300
+ bodyObj[k] = rp(String(v));
301
+ });
302
+ } catch {
303
+ /* ignore bad body JSON */
304
+ }
305
+
306
+ let url = rp(slotsApiUrl);
307
+ let body: string | undefined;
308
+
309
+ if (method === 'GET') {
310
+ const qs = new URLSearchParams(
311
+ Object.entries(bodyObj).map(([k, v]) => [k, String(v)]),
312
+ ).toString();
313
+ if (qs) {
314
+ url += url.includes('?') ? `&${qs}` : `?${qs}`;
315
+ }
316
+ } else {
317
+ body = JSON.stringify(bodyObj);
318
+ }
319
+
320
+ return {url, method, headers, body};
321
+ }
322
+
323
+ export function slotsAreEqual(a: SlotItem | null, b: SlotItem | null): boolean {
324
+ if (a === null || b === null) {
325
+ return false;
326
+ }
327
+ if (typeof a === 'object' && typeof b === 'object') {
328
+ return JSON.stringify(a) === JSON.stringify(b);
329
+ }
330
+ return a === b;
331
+ }
332
+
333
+ export type CalendarCompletePayload = {
334
+ date: string;
335
+ time: string;
336
+ timestamp: number;
337
+ formatted: string;
338
+ slot_data?: Record<string, unknown>;
339
+ };
340
+
341
+ export function buildCalendarCompletePayload(
342
+ selectedTs: number,
343
+ selectedTime: SlotItem,
344
+ timezone: string,
345
+ ): CalendarCompletePayload {
346
+ const dateObj = new Date(selectedTs);
347
+ const timeStr = formatSlotTime(selectedTime);
348
+ const y = dateObj.getFullYear();
349
+ const m = String(dateObj.getMonth() + 1).padStart(2, '0');
350
+ const d = String(dateObj.getDate()).padStart(2, '0');
351
+ const dateStr = `${y}-${m}-${d}`;
352
+
353
+ let slotData =
354
+ typeof selectedTime === 'object'
355
+ ? {...(selectedTime as Record<string, unknown>)}
356
+ : undefined;
357
+
358
+ if (slotData) {
359
+ for (const key of ['start', 'startTime', 'end', 'endTime']) {
360
+ const raw = slotData[key];
361
+ if (
362
+ raw &&
363
+ typeof raw === 'string' &&
364
+ (raw.includes('T') || !Number.isNaN(Date.parse(raw)))
365
+ ) {
366
+ try {
367
+ slotData[key] = toTimezoneISOString(new Date(raw), timezone || undefined);
368
+ } catch {
369
+ /* keep raw */
370
+ }
371
+ }
372
+ }
373
+ }
374
+
375
+ return {
376
+ date: dateStr,
377
+ time: timeStr,
378
+ timestamp: selectedTs,
379
+ formatted: `${dateObj.toLocaleDateString('en-US', {
380
+ weekday: 'long',
381
+ month: 'short',
382
+ day: 'numeric',
383
+ })} at ${timeStr}`,
384
+ ...(slotData ? {slot_data: slotData} : {}),
385
+ };
386
+ }