@tellescope/react-components 1.227.0 → 1.229.0

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 (57) hide show
  1. package/lib/cjs/Forms/forms.v2.d.ts +116 -0
  2. package/lib/cjs/Forms/forms.v2.d.ts.map +1 -0
  3. package/lib/cjs/Forms/forms.v2.js +760 -0
  4. package/lib/cjs/Forms/forms.v2.js.map +1 -0
  5. package/lib/cjs/Forms/hooks.d.ts.map +1 -1
  6. package/lib/cjs/Forms/hooks.js +8 -3
  7. package/lib/cjs/Forms/hooks.js.map +1 -1
  8. package/lib/cjs/Forms/index.d.ts +1 -0
  9. package/lib/cjs/Forms/index.d.ts.map +1 -1
  10. package/lib/cjs/Forms/index.js +6 -0
  11. package/lib/cjs/Forms/index.js.map +1 -1
  12. package/lib/cjs/Forms/inputs.v2.d.ts +81 -0
  13. package/lib/cjs/Forms/inputs.v2.d.ts.map +1 -0
  14. package/lib/cjs/Forms/inputs.v2.js +2289 -0
  15. package/lib/cjs/Forms/inputs.v2.js.map +1 -0
  16. package/lib/cjs/Forms/localization.d.ts.map +1 -1
  17. package/lib/cjs/Forms/localization.js +3 -0
  18. package/lib/cjs/Forms/localization.js.map +1 -1
  19. package/lib/cjs/Forms/types.d.ts +1 -0
  20. package/lib/cjs/Forms/types.d.ts.map +1 -1
  21. package/lib/cjs/state.d.ts +34 -0
  22. package/lib/cjs/state.d.ts.map +1 -1
  23. package/lib/cjs/state.js +16 -2
  24. package/lib/cjs/state.js.map +1 -1
  25. package/lib/esm/Forms/forms.v2.d.ts +116 -0
  26. package/lib/esm/Forms/forms.v2.d.ts.map +1 -0
  27. package/lib/esm/Forms/forms.v2.js +725 -0
  28. package/lib/esm/Forms/forms.v2.js.map +1 -0
  29. package/lib/esm/Forms/hooks.d.ts.map +1 -1
  30. package/lib/esm/Forms/hooks.js +8 -3
  31. package/lib/esm/Forms/hooks.js.map +1 -1
  32. package/lib/esm/Forms/index.d.ts +1 -0
  33. package/lib/esm/Forms/index.d.ts.map +1 -1
  34. package/lib/esm/Forms/index.js +2 -0
  35. package/lib/esm/Forms/index.js.map +1 -1
  36. package/lib/esm/Forms/inputs.v2.d.ts +81 -0
  37. package/lib/esm/Forms/inputs.v2.d.ts.map +1 -0
  38. package/lib/esm/Forms/inputs.v2.js +2218 -0
  39. package/lib/esm/Forms/inputs.v2.js.map +1 -0
  40. package/lib/esm/Forms/localization.d.ts.map +1 -1
  41. package/lib/esm/Forms/localization.js +3 -0
  42. package/lib/esm/Forms/localization.js.map +1 -1
  43. package/lib/esm/Forms/types.d.ts +1 -0
  44. package/lib/esm/Forms/types.d.ts.map +1 -1
  45. package/lib/esm/state.d.ts +34 -0
  46. package/lib/esm/state.d.ts.map +1 -1
  47. package/lib/esm/state.js +13 -0
  48. package/lib/esm/state.js.map +1 -1
  49. package/lib/tsconfig.tsbuildinfo +1 -1
  50. package/package.json +9 -9
  51. package/src/Forms/forms.v2.tsx +1321 -0
  52. package/src/Forms/hooks.tsx +10 -5
  53. package/src/Forms/index.ts +5 -2
  54. package/src/Forms/inputs.v2.tsx +3869 -0
  55. package/src/Forms/localization.ts +1 -0
  56. package/src/Forms/types.ts +1 -0
  57. package/src/state.tsx +25 -5
@@ -0,0 +1,3869 @@
1
+ import React, { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from "react"
2
+ import axios from "axios"
3
+ import { Autocomplete, Box, Button, Checkbox, Chip, Collapse, Divider, FormControl, FormControlLabel, FormLabel, Grid, IconButton as MuiIconButton, InputLabel, MenuItem, Radio, RadioGroup, Select, SxProps, TextField, TextFieldProps, Typography } from "@mui/material"
4
+ import { FormInputProps } from "./types"
5
+ import { useDropzone } from "react-dropzone"
6
+ import { CANVAS_TITLE, EMOTII_TITLE, INSURANCE_RELATIONSHIPS, INSURANCE_RELATIONSHIPS_CANVAS, PRIMARY_HEX, RELATIONSHIP_TYPES, TELLESCOPE_GENDERS } from "@tellescope/constants"
7
+ import { MM_DD_YYYY_to_YYYY_MM_DD, capture_is_supported, downloadFile, emit_gtm_event, first_letter_capitalized, form_response_value_to_string, getLocalTimezone, getPublicFileURL, mm_dd_yyyy, replace_enduser_template_values, truncate_string, update_local_storage, user_display_name } from "@tellescope/utilities"
8
+ import { Address, DatabaseSelectResponse, Enduser, EnduserRelationship, FormResponseValue, InsuranceRelationship, MedicationResponse, MultipleChoiceOptions, FormFieldOptionDetails, TellescopeGender, TIMEZONES_USA } from "@tellescope/types-models"
9
+ import { VALID_STATES, emailValidator, phoneValidator } from "@tellescope/validation"
10
+ import Slider from '@mui/material/Slider';
11
+ import LinearProgress from '@mui/material/LinearProgress';
12
+
13
+ import DatePicker from "react-datepicker";
14
+ import { datepickerCSS } from "./css/react-datepicker" // avoids build issue with RN
15
+ import { CancelIcon, FileBlob, IconButton, LabeledIconButton, LoadingButton, Styled, form_display_text_for_language, isDateString, useProducts, useResolvedSession } from ".."
16
+ import { CalendarEvent, DatabaseRecord, FormField } from "@tellescope/types-client"
17
+ import { css } from '@emotion/css'
18
+ import { DragDropContext, Droppable, Draggable } from "react-beautiful-dnd";
19
+ import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
20
+ import heic2any from "heic2any"
21
+ import AddPhotoAlternateIcon from '@mui/icons-material/AddPhotoAlternate';
22
+ import LanguageIcon from '@mui/icons-material/Language';
23
+
24
+ import { Elements, PaymentElement, useStripe, useElements, EmbeddedCheckout, EmbeddedCheckoutProvider } from '@stripe/react-stripe-js';
25
+ import { loadStripe } from '@stripe/stripe-js';
26
+ import { CheckCircleOutline, Delete, Edit, ExpandMore, UploadFile } from "@mui/icons-material"
27
+ import { WYSIWYG } from "./wysiwyg"
28
+
29
+ export const LanguageSelect = ({ value, ...props }: { value: string, onChange: (s: string) => void}) => (
30
+ <Grid container alignItems="center" justifyContent={"center"} wrap="nowrap" spacing={1}>
31
+ <Grid item>
32
+ <LanguageIcon color="primary" />
33
+ </Grid>
34
+
35
+ <Grid item style={{ width: 150 }}>
36
+ <StringSelector {...props} options={["English", "Español"]} size="small"
37
+ value={value === 'Spanish' ? 'Español' : value}
38
+ label={
39
+ (value === 'Español' || value === 'Spanish') ? 'Idioma'
40
+ : "Language"
41
+ }
42
+ />
43
+ </Grid>
44
+ </Grid>
45
+ )
46
+
47
+ export const defaultInputProps: { sx: SxProps } = { sx: {
48
+ borderRadius: 1.5,
49
+ backgroundColor: '#FFFFFF',
50
+ '& .MuiOutlinedInput-root': {
51
+ backgroundColor: '#FFFFFF',
52
+ '&.Mui-error': {
53
+ boxShadow: '0 0 8px 2px rgba(211, 47, 47, 0.3)',
54
+ },
55
+ '&.Mui-error .MuiOutlinedInput-notchedOutline': {
56
+ borderColor: '#d32f2f',
57
+ borderWidth: '2px',
58
+ },
59
+ },
60
+ '& .MuiInputBase-input': {
61
+ padding: '10px 14px',
62
+ },
63
+ }}
64
+ export const defaultButtonStyles: React.CSSProperties = {
65
+ borderRadius: '10px',
66
+ }
67
+
68
+ export const PdfViewer = ({ url, height=420 } : { url: string, height?: number }) => {
69
+ // const [numPages, setNumPages] = useState<number>();
70
+ // const [page, setPage] = useState(1);
71
+
72
+ // const parentRef = useRef<HTMLDivElement | null>(null);
73
+ // const canvasRef = useRef<HTMLCanvasElement | null>(null);
74
+
75
+ // function onDocumentLoadSuccess({ numPages }: { numPages: number }): void {
76
+ // setNumPages(numPages);
77
+ // }
78
+
79
+ // const pdfHeight: number | undefined = pdfPage?._pageInfo?.view?.[3]
80
+ // const pdfWidth: number | undefined = pdfPage?._pageInfo?.view?.[2]
81
+
82
+ // const parentWidth = parentRef.current?.clientWidth
83
+
84
+ return (
85
+ <Grid container direction="column">
86
+ {/* {!pdfDocument && <span>Loading pdf...</span>} */}
87
+
88
+ {/* <Grid item ref={parentRef} style={{ width: '100%' }}> */}
89
+ {/* {pdfDocument && pdfHeight && pdfWidth && parentWidth &&
90
+ <canvas ref={canvasRef} style={{
91
+ maxWidth: '100%',
92
+ maxHeight: parentWidth / pdfWidth * pdfHeight
93
+ }} />
94
+ } */}
95
+ {/* <Document file="somefile.pdf" onLoadSuccess={onDocumentLoadSuccess}>
96
+ <Page pageNumber={page} />
97
+ </Document>
98
+ </Grid> */}
99
+
100
+ {/* {pdfDocument && pdfHeight && pdfWidth && parentWidth && */}
101
+ {/* <Grid container alignItems="center" justifyContent="space-between">
102
+ <Button variant="outlined" disabled={page === 1} onClick={() => setPage(page - 1)}>
103
+ Previous Page
104
+ </Button>
105
+ <Button variant="outlined"
106
+ disabled={page === numPages}
107
+ onClick={() => setPage(page + 1)}
108
+ >
109
+ Next Page
110
+ </Button>
111
+ </Grid> */}
112
+ {/* } */}
113
+ <iframe
114
+ src={
115
+ // url
116
+ // encodeURI(`http://localhost:5173?url=${url}`)
117
+ // encodeURI(`http://tellescope-pdf-renderer.s3-website.us-east-2.amazonaws.com?url=${url}`)
118
+ encodeURI(`https://pdf.tellescope.com?url=${url}`)
119
+ }
120
+ title="PDF Viewer"
121
+ style={{
122
+ border: 'none',
123
+ height,
124
+ width: '100%',
125
+ marginBottom: '5px'
126
+ }}
127
+ />
128
+
129
+ <a href={url} target="__blank" rel="noopener noreferrer"
130
+ style={{ marginTop: 5 }}
131
+ >
132
+ View in new tab or download here
133
+ </a>
134
+ </Grid>
135
+ );
136
+ }
137
+
138
+ export const RatingInput = ({ field, value, onChange }: FormInputProps<'rating'>) => {
139
+ const from = field?.options?.from ?? 1 // allow 0
140
+ const to = field?.options?.to ?? 10 // allow 0
141
+
142
+ const step = field.options?.rangeStepSize || 1
143
+ const allMarks = []
144
+ for (let i=from; i<=to; i+=(step)) {
145
+ allMarks.push({ value: i, label: i })
146
+ }
147
+
148
+ let marks = [...allMarks]
149
+ while (marks.length > 25) {
150
+ marks = marks.filter((_, i) => i%2 === 0)
151
+ }
152
+
153
+ return (
154
+ <Slider min={from} max={to} step={step} marks={marks}
155
+ valueLabelDisplay={marks.length < allMarks.length ? 'auto' : "off"}
156
+ value={value}
157
+ onChange={(e, v) => onChange(v as number, field.id)}
158
+ sx={{
159
+ '& .MuiSlider-thumb': value === undefined ? { display: 'none' } : {}, // Hide thumb until value is set
160
+ }}
161
+ key={field.id} // forces the actual value to be shown when two Rating questions are shown in a row and the value changes
162
+ />
163
+ )
164
+ }
165
+
166
+ // a little function to help us with reordering the result
167
+ const reorder = (list: any[], startIndex: number, endIndex: number) => {
168
+ const result = Array.from(list);
169
+ const [removed] = result.splice(startIndex, 1);
170
+ result.splice(endIndex, 0, removed);
171
+
172
+ return result;
173
+ };
174
+
175
+ const grid = 8;
176
+
177
+ const getItemStyle = (isDragging: boolean, draggableStyle?: React.CSSProperties): React.CSSProperties => ({
178
+ // some basic styles to make the items look a bit nicer
179
+ userSelect: "none",
180
+ padding: `${grid * 2}px`,
181
+ margin: `0 0 ${grid}px 0`,
182
+
183
+ // change background colour if dragging
184
+ backgroundColor: isDragging ? "#ffffff88" : undefined,
185
+ border: '1px solid',
186
+ borderColor: "primary.main",
187
+ borderRadius: 5,
188
+
189
+ // styles we need to apply on draggables
190
+ ...draggableStyle
191
+ });
192
+
193
+ const getListStyle = (isDraggingOver: boolean) => ({
194
+ // background: isDraggingOver ? "#ffffff44" : undefined,
195
+ // padding: `${grid}px`,
196
+ // width: '250px'
197
+ });
198
+ export const RankingInput = ({ field, value, onChange }: FormInputProps<'ranking'>) => {
199
+ return (
200
+ <Grid container direction='column'>
201
+ {/* <Typography>Most</Typography> */}
202
+
203
+ <DragDropContext onDragEnd={result => {
204
+ if (!value) return
205
+ if (!result.destination) {
206
+ return;
207
+ }
208
+
209
+ onChange(reorder(
210
+ value,
211
+ result.source.index,
212
+ result.destination.index
213
+ ), field.id)
214
+ }}>
215
+ <Droppable droppableId="droppable">
216
+ {(provided, snapshot) => (
217
+ <Box
218
+ {...provided.droppableProps}
219
+ ref={provided.innerRef}
220
+ sx={getListStyle(snapshot.isDraggingOver)}
221
+ >
222
+ {(value ?? []).map((item, index) => (
223
+ <Draggable key={item} draggableId={item} index={index}>
224
+ {(provided, snapshot) => (
225
+ <Grid container alignItems="center" justifyContent="space-between"
226
+ ref={provided.innerRef}
227
+ {...provided.draggableProps}
228
+ {...provided.dragHandleProps}
229
+ sx={getItemStyle(
230
+ snapshot.isDragging,
231
+ provided.draggableProps.style
232
+ )}
233
+ >
234
+ {item}
235
+ <DragIndicatorIcon color="primary" />
236
+ </Grid>
237
+ )}
238
+ </Draggable>
239
+ ))}
240
+ {provided.placeholder}
241
+ </Box>
242
+ )}
243
+ </Droppable>
244
+ </DragDropContext>
245
+
246
+ <Typography color="primary" style={{ marginTop: 3 }}>
247
+ Drag and drop to re-order the above options
248
+ </Typography>
249
+
250
+ {/* <Typography>Least</Typography> */}
251
+ </Grid>
252
+ )
253
+ }
254
+
255
+ const CustomDateInput = forwardRef((props: TextFieldProps, ref) => (
256
+ <TextField InputProps={defaultInputProps}
257
+ fullWidth inputRef={ref} {...props}
258
+ />
259
+ ))
260
+ export const DateInput = ({
261
+ field, value, onChange, placement='top', ...props
262
+ } : {
263
+ field: FormField,
264
+ placement?: 'top' | 'right' | 'bottom' | 'left'
265
+ } & FormInputProps<'date'> & Styled) => {
266
+ const inputRef = useRef(null);
267
+
268
+ return (
269
+ <DatePicker // wrap in item to prevent movement on focused
270
+ selected={value}
271
+ onChange={(d: Date) => onChange?.(d, field.id)}
272
+ showTimeSelect
273
+ required={!field.isOptional}
274
+ dateFormat="Pp"
275
+ autoComplete="off"
276
+ timeIntervals={15} // 30 is default
277
+ popperPlacement={placement}
278
+ customInput={<CustomDateInput inputRef={inputRef} {...props} />}
279
+ // className={css`width: 100%;`}
280
+ className={css`${datepickerCSS}`}
281
+ />
282
+ )
283
+ }
284
+
285
+ export const TableInput = ({ field, value=[], onChange, ...props }: FormInputProps<'Input Table'>) => {
286
+ const choices = field.options?.tableChoices
287
+
288
+ const handleNewRow = useCallback(() => {
289
+ if (!choices?.length) return
290
+
291
+ onChange(
292
+ [...value, choices.map(c => ({
293
+ label: c.label,
294
+ entry: '',
295
+ }))],
296
+ field.id,
297
+ true
298
+ )
299
+ }, [value, field.id])
300
+
301
+ const handleChange = useCallback((r: number, c: number, u: { entry: string, label: string }) => {
302
+ onChange(
303
+ value.map((v, _i) =>
304
+ _i !== r
305
+ ? v
306
+ : v.map((e, _c) => _c === c ? u : e)
307
+ ),
308
+ field.id,
309
+ true,
310
+ )
311
+ }, [value, onChange, field.id])
312
+
313
+ const handleRemove = useCallback((i: number) => {
314
+ onChange(
315
+ value.filter((_, _i) => i !== _i),
316
+ field.id,
317
+ true,
318
+ )
319
+ }, [value,onChange, field.id])
320
+
321
+ useEffect(() => {
322
+ if (field.isOptional) return
323
+ if (value.length) return
324
+
325
+ handleNewRow()
326
+ }, [field.isOptional, value, handleNewRow])
327
+
328
+ if (!choices?.length) {
329
+ return <Typography color="error">No input choices available</Typography>
330
+ }
331
+
332
+ const length = choices.length || 1
333
+ const iconWidth = '35px'
334
+ const width = `calc(${(100 / length).toFixed(2)}% - calc(${iconWidth} / ${length}))`
335
+
336
+ return (
337
+ <Grid container direction="column">
338
+ {value.map((row, i) => (
339
+ <>
340
+ <Grid container alignItems="center" key={i} spacing={1}>
341
+ {choices.map((v, columnIndex) => (
342
+ <Grid item key={v.label} sx={{ width }}>
343
+ {v.type === 'Text'
344
+ ? (
345
+ <TextField label={v.label} size="small" fullWidth title={v.label}
346
+ InputProps={defaultInputProps}
347
+ value={row.find((c, _i) => columnIndex === _i)?.entry}
348
+ onChange={e => handleChange(i, columnIndex, { label: v.label, entry: e.target.value })}
349
+ />
350
+ )
351
+ : v.type === 'Date' ? (
352
+ <DateStringInput label={v.label} size="small" fullWidth title={v.label}
353
+ field={field}
354
+ value={row.find((c, _i) => columnIndex === _i)?.entry}
355
+ onChange={(entry='') => handleChange(i, columnIndex, { label: v.label, entry })}
356
+ />
357
+ )
358
+ : v.type === 'Select' ? (
359
+ <FormControl size="small" fullWidth>
360
+ <InputLabel id="demo-select-small">{v.label}</InputLabel>
361
+ <Select label={v.label} size="small" title={v.label}
362
+ sx={defaultInputProps.sx}
363
+ value={row.find((c, _i) => columnIndex === _i)?.entry}
364
+ onChange={e => handleChange(i, columnIndex, { label: v.label, entry: e.target.value })}
365
+ >
366
+ <MenuItem value="">
367
+ <em>None</em>
368
+ </MenuItem>
369
+ {v.info.choices.map(c => (
370
+ <MenuItem key={c} value={c}>{c}</MenuItem>
371
+ ))}
372
+ </Select>
373
+ </FormControl>
374
+ )
375
+ : (v.type === 'Database' && v.info.databaseId && v.info.databaseLabel) ? (
376
+ <DatabaseSelectInput responses={[]} size="small"
377
+ field={{
378
+ ...field,
379
+ options: { databaseId: v.info.databaseId, databaseLabel: v.info.databaseLabel },
380
+ title: v.label,
381
+ }}
382
+ value={row.find((_, _i) => columnIndex === _i)?.entry ? [{
383
+ text: JSON.parse(row.find((_, _i) => columnIndex === _i)?.entry || '{}').text || '',
384
+ databaseId: JSON.parse(row.find((_, _i) => columnIndex === _i)?.entry || '{}').databaseId || '',
385
+ recordId: JSON.parse(row.find((_, _i) => columnIndex === _i)?.entry || '{}').recordId || '',
386
+ }] : []}
387
+ onChange={
388
+ (records) => handleChange(i, columnIndex, { label: v.label, entry: JSON.stringify(records?.[0] ?? '') })
389
+ }
390
+ />
391
+ )
392
+ : null
393
+ }
394
+ </Grid>
395
+ ))}
396
+
397
+ <Grid item sx={{ ml: 'auto', width: iconWidth }}>
398
+ <LabeledIconButton Icon={CancelIcon} label="Remove" onClick={() => handleRemove(i)}
399
+ disabled={!field.isOptional && value.length === 1}
400
+ />
401
+ </Grid>
402
+ </Grid>
403
+
404
+ <Divider flexItem sx={{ my: 1 }} />
405
+ </>
406
+ ))}
407
+
408
+ <Button variant="outlined" size="small" onClick={handleNewRow} sx={{ width: 200 }}>
409
+ Add new entry
410
+ </Button>
411
+ </Grid>
412
+ )
413
+ }
414
+
415
+ export const AutoFocusTextField = (props: TextFieldProps) => (
416
+ <TextField InputProps={defaultInputProps} {...props} />
417
+ )
418
+
419
+ const CustomDateStringInput = forwardRef((props: TextFieldProps, ref) => (
420
+ <TextField InputProps={defaultInputProps}
421
+ fullWidth inputRef={ref} {...props}
422
+ />
423
+ ))
424
+ export const DateStringInput = ({ field, value, onChange, ...props }: FormInputProps<'string'>) => {
425
+ const inputRef = useRef(null);
426
+
427
+ // if (value && isDateString(value)) {
428
+ // console.log(value, new Date(
429
+ // new Date(MM_DD_YYYY_to_YYYY_MM_DD(value)).getTime()
430
+ // + (new Date().getTimezoneOffset() * 60 * 1000)
431
+ // ))
432
+ // }
433
+ return (
434
+ field.options?.useDatePicker
435
+ ? (
436
+ <DatePicker // wrap in item to prevent movement on focused
437
+ selected={
438
+ (value && isDateString(value))
439
+ ? new Date(
440
+ new Date(MM_DD_YYYY_to_YYYY_MM_DD(value)).getTime()
441
+ + ((new Date().getTimezoneOffset() + 60) * 60 * 1000) // additional hour (60 minutes) needs to be added for date to line up properly
442
+ )
443
+ : undefined
444
+ }
445
+ onChange={(d: Date) => onChange?.(mm_dd_yyyy(d), field.id)}
446
+ showTimeSelect={false}
447
+ required={!field.isOptional}
448
+ autoComplete="off"
449
+ dateFormat={"MM-dd-yyyy"}
450
+ customInput={<CustomDateStringInput inputRef={inputRef} {...props}
451
+ label={(!field.title && field.placeholder) ? field.placeholder : props.label}
452
+ />}
453
+ // className={css`width: 100%;`}
454
+ className={css`${datepickerCSS}`}
455
+ />
456
+ )
457
+ : (
458
+ <AutoFocusTextField {...props} required={!field.isOptional} fullWidth placeholder="MM-DD-YYYY" value={value}
459
+ label={(!field.title && field.placeholder) ? field.placeholder : props.label}
460
+ onChange={e => {
461
+ const v = e.target.value || ''
462
+ onChange(
463
+ (
464
+ v.length === 2 && /\d{2}/.test(v) && value?.length !== 3 // allow deletion
465
+ ? v + '-'
466
+ : v.length === 5 && /\d{2}-\d{2}/.test(v) && value?.length !== 6 // allow deletion
467
+ ? v + '-'
468
+ : v
469
+ )
470
+ .replaceAll('/', '-'),
471
+ field.id
472
+ )
473
+ }}
474
+ />
475
+ )
476
+ )
477
+ }
478
+ export const StringInput = ({ field, value, form, onChange, ...props }: FormInputProps<'string'>) => (
479
+ <AutoFocusTextField {...props} required={!field.isOptional} fullWidth value={value} onChange={e => onChange(e.target.value, field.id)}
480
+ placeholder={(field.placeholder || form_display_text_for_language(form, "Answer here...", ''))}
481
+ label={(!field.title && field.placeholder) ? field.placeholder : props.label}
482
+ />
483
+ )
484
+ export const StringLongInput = ({ field, value, onChange, form, ...props }: FormInputProps<'string'>) => (
485
+ <AutoFocusTextField {...props} multiline minRows={3} maxRows={8} required={!field.isOptional} fullWidth value={value} onChange={e => onChange(e.target.value, field.id)}
486
+ placeholder={field.placeholder || form_display_text_for_language(form, "Answer here...", '')}
487
+ label={(!field.title && field.placeholder) ? field.placeholder : props.label}
488
+ />
489
+ )
490
+
491
+ export const PhoneInput = ({ field, value, onChange, form, ...props }: FormInputProps<'phone'>) => (
492
+ <AutoFocusTextField {...props} required={!field.isOptional} fullWidth value={value} onChange={e => onChange(e.target.value, field.id)}
493
+ placeholder={field.placeholder || form_display_text_for_language(form, "Enter phone...", '')}
494
+ label={(!field.title && field.placeholder) ? field.placeholder : props.label}
495
+ />
496
+ )
497
+
498
+ export const EmailInput = ({ field, value, onChange, form, ...props }: FormInputProps<'email'>) => (
499
+ <AutoFocusTextField {...props} required={!field.isOptional} fullWidth type="email" value={value} onChange={e => onChange(e.target.value, field.id)}
500
+ placeholder={field.placeholder || form_display_text_for_language(form, "Enter email...", '')}
501
+ label={(!field.title && field.placeholder) ? field.placeholder : props.label}
502
+ />
503
+ )
504
+
505
+ export const NumberInput = ({ field, value, onChange, form, ...props }: FormInputProps<'number'>) => {
506
+ // Prevent the default scroll behavior when focused on this input
507
+ const inputRef = useRef<HTMLInputElement>(null);
508
+ useEffect(() => {
509
+ const handleWheel = (e: any) => {
510
+ e?.preventDefault?.();
511
+ };
512
+
513
+ // Get the actual input element inside the TextField
514
+ const inputElement = inputRef.current?.querySelector('input');
515
+
516
+ if (inputElement) {
517
+ inputElement.addEventListener('wheel', handleWheel, { passive: false });
518
+
519
+ // Clean up event listener when component unmounts
520
+ return () => {
521
+ inputElement.removeEventListener('wheel', handleWheel);
522
+ };
523
+ }
524
+ }, []);
525
+
526
+ return (
527
+ <TextField ref={inputRef} autoFocus InputProps={defaultInputProps} {...props} required={!field.isOptional} fullWidth type="number" value={value}
528
+ onChange={e => onChange(parseInt(e.target.value), field.id)}
529
+ label={(!field.title && field.placeholder) ? field.placeholder : props.label}
530
+ placeholder={field.placeholder || form_display_text_for_language(form, "Enter a number...", '')}
531
+ onScroll={e => e.preventDefault()} // prevent scroll on number input
532
+ sx={{
533
+ '& input[type=number]': {
534
+ '-moz-appearance': 'textfield'
535
+ },
536
+ '& input[type=number]::-webkit-outer-spin-button': {
537
+ '-webkit-appearance': 'none',
538
+ margin: 0
539
+ },
540
+ '& input[type=number]::-webkit-inner-spin-button': {
541
+ '-webkit-appearance': 'none',
542
+ margin: 0
543
+ }
544
+ }}
545
+ />
546
+ )
547
+ }
548
+
549
+ export const InsuranceInput = ({ field, onDatabaseSelect, value, onChange, form, responses, enduser, ...props }: FormInputProps<'Insurance'>) => {
550
+ const session = useResolvedSession()
551
+
552
+ const [payers, setPayers] = useState<{ id: string, name: string, databaseRecord?: DatabaseRecord, type?: string, state?: string }[]>([])
553
+ const [query, setQuery] = useState('')
554
+
555
+ const addressQuestion = useMemo(() => responses?.find(r => {
556
+ if (r.answer.type !== 'Address') return false
557
+ if (r.field.intakeField !== 'Address') return false
558
+
559
+ // make sure state is actually defined (in case of multiple address questions, where 1+ are blank)
560
+ if (!r.answer.value?.state) return false
561
+
562
+ return true
563
+ }), [responses])
564
+
565
+ const state = useMemo(() => (
566
+ (addressQuestion?.answer?.type === 'Address' ? addressQuestion?.answer?.value?.state : undefined) || enduser?.state
567
+ ), [enduser?.state, addressQuestion])
568
+
569
+ const loadRef = useRef(false) // so session changes don't cause
570
+ useEffect(() => {
571
+ if (field?.options?.dataSource === CANVAS_TITLE) return // instead, look-up while typing against Canvas Search API
572
+ if (loadRef.current) return
573
+ loadRef.current = true
574
+
575
+ // just load all at once, should be reasonably performant compared to paging
576
+ session.api.form_fields.load_choices_from_database({ fieldId: field.id, limit: 10000 })
577
+ .then(({ choices }) => setPayers(
578
+ choices
579
+ .map(c => ({
580
+ id: c.values.find(v => v.label?.trim()?.toLowerCase() === 'id')?.value?.toString() || '',
581
+ name: c.values.find(v => v.label?.trim()?.toLowerCase() === 'name')?.value?.toString() || '',
582
+ state: c.values.find(v => v.label?.trim()?.toLowerCase() === 'state')?.value?.toString() || '',
583
+ type: c.values.find(v => v.label?.trim()?.toLowerCase() === 'type')?.value?.toString() || '',
584
+ databaseRecord: c,
585
+ }))
586
+ .filter(c => !c.state || !state || (c.state === state))
587
+ ))
588
+ .catch(console.error)
589
+ }, [session, state, field?.options?.dataSource])
590
+
591
+ const searchRef = useRef(query)
592
+ useEffect(() => {
593
+ if (field?.options?.dataSource !== CANVAS_TITLE) { return }
594
+ if (!query) return
595
+ if (searchRef.current === query) return
596
+ searchRef.current = query
597
+
598
+ session.api.integrations.proxy_read({
599
+ integration: CANVAS_TITLE,
600
+ query,
601
+ type: 'organizations',
602
+ })
603
+ .then(({ data }) => {
604
+ try {
605
+ setPayers(data.map((d: any) => ({
606
+ id: d.resource.id,
607
+ name: d.resource.name,
608
+ })))
609
+ } catch(err) { console.error }
610
+ })
611
+ .catch(console.error)
612
+ }, [session, field?.options?.dataSource, query])
613
+
614
+ return (
615
+ <Grid container spacing={2} sx={{ mt: '0' }}>
616
+ <Grid item xs={12} sm={6}>
617
+ <Autocomplete freeSolo={!field.options?.requirePredefinedInsurer} options={payers.map(p => p.name)}
618
+ value={value?.payerName || ''}
619
+ onChange={(e, v) => onChange({
620
+ ...value,
621
+ payerName: v || '',
622
+ payerId: payers.find(p => p.name === v)?.id || '',
623
+ payerType: payers.find(p => p.name === v)?.type || '',
624
+ }, field.id)}
625
+ onInputChange={
626
+ field.options?.requirePredefinedInsurer
627
+ ? (e, v) => { if (v) { setQuery(v) } }
628
+ : (e, v) => {
629
+ if (v) { setQuery(v) }
630
+
631
+ const databaseRecord = payers.find(p => p.name === v)?.databaseRecord
632
+ if (databaseRecord) {
633
+ onDatabaseSelect?.([databaseRecord])
634
+ }
635
+
636
+ onChange({
637
+ ...value,
638
+ payerName: v || '',
639
+ payerId: payers.find(p => p.name === v)?.id || '',
640
+ payerType: payers.find(p => p.name === v)?.type || '',
641
+ }, field.id)
642
+ }
643
+ }
644
+ renderInput={(params) => (
645
+ <TextField {...params} InputProps={{ ...params.InputProps, sx: defaultInputProps.sx }}
646
+ required={!field.isOptional} size="small" label={"Insurer"}
647
+ placeholder={field.options?.dataSource === CANVAS_TITLE ? "Search insurer..." : "Insurer"}
648
+ />
649
+ )}
650
+ />
651
+ </Grid>
652
+ <Grid item xs={12} sm={6}>
653
+ <TextField InputProps={defaultInputProps} required={!field.isOptional} fullWidth value={value?.memberId ?? ''}
654
+ onChange={e => onChange({ ...value, memberId: e.target.value }, field.id)}
655
+ label={form_display_text_for_language(form, "Member ID", '')}
656
+ size="small"
657
+ />
658
+ </Grid>
659
+
660
+ <Grid item xs={12} sm={6}>
661
+ <TextField InputProps={defaultInputProps} required={false} fullWidth value={value?.planName ?? ''}
662
+ onChange={e => onChange({ ...value, planName: e.target.value }, field.id)}
663
+ label={form_display_text_for_language(form, "Plan Name", '')}
664
+ size="small"
665
+ />
666
+ </Grid>
667
+
668
+ <Grid item xs={12} sm={6}>
669
+ <DateStringInput size="small" label="Plan Start Date"
670
+ field={{
671
+ ...field,
672
+ isOptional: true, //field.isOptional || field.options?.billingProvider === 'Candid'
673
+ }}
674
+ value={value?.startDate || ''}
675
+ onChange={startDate =>
676
+ onChange({
677
+ ...value,
678
+ startDate,
679
+ }, field.id)
680
+ }
681
+ />
682
+ </Grid>
683
+
684
+ {field.options?.includeGroupNumber &&
685
+ <Grid item xs={12}>
686
+ <TextField InputProps={defaultInputProps} fullWidth value={value?.groupNumber ?? ''}
687
+ onChange={e => onChange({ ...value, groupNumber: e.target.value }, field.id)}
688
+ label={form_display_text_for_language(form, "Group Number", '')}
689
+ size="small"
690
+ />
691
+ </Grid>
692
+ }
693
+
694
+ <Grid item xs={12}>
695
+ <StringSelector size="small" label="Relationship to Policy Owner"
696
+ options={
697
+ (
698
+ (field.options?.billingProvider === CANVAS_TITLE || field.options?.dataSource === CANVAS_TITLE )
699
+ ? INSURANCE_RELATIONSHIPS_CANVAS
700
+ : INSURANCE_RELATIONSHIPS
701
+ )
702
+ .sort((x, y) => x.localeCompare(y))
703
+ }
704
+ value={value?.relationship || 'Self'}
705
+ onChange={relationship =>
706
+ onChange({ ...value, relationship: relationship as InsuranceRelationship || 'Self' }, field.id)
707
+ }
708
+ />
709
+ </Grid>
710
+
711
+ {(value?.relationship || 'Self') !== 'Self' &&
712
+ <>
713
+ <Grid item xs={12}>
714
+ <Typography sx={{ fontWeight: 'bold' }}>Policy Owner Details</Typography>
715
+ </Grid>
716
+
717
+ <Grid item xs={6}>
718
+ <TextField label="First Name" size="small" InputProps={defaultInputProps} fullWidth
719
+ value={value?.relationshipDetails?.fname || ''}
720
+ required={!field.isOptional}
721
+ onChange={e =>
722
+ onChange({
723
+ ...value,
724
+ relationshipDetails: { ...value?.relationshipDetails, fname: e.target.value }
725
+ }, field.id)
726
+ }
727
+ />
728
+ </Grid>
729
+ <Grid item xs={6}>
730
+ <TextField label="Last Name" size="small" InputProps={defaultInputProps} fullWidth
731
+ value={value?.relationshipDetails?.lname || ''}
732
+ required={!field.isOptional}
733
+ onChange={e =>
734
+ onChange({
735
+ ...value,
736
+ relationshipDetails: { ...value?.relationshipDetails, lname: e.target.value }
737
+ }, field.id)
738
+ }
739
+ />
740
+ </Grid>
741
+ <Grid item xs={6}>
742
+ <StringSelector options={TELLESCOPE_GENDERS} size="small" label="Gender"
743
+ value={value?.relationshipDetails?.gender || ''}
744
+ required={!field.isOptional}
745
+ onChange={v =>
746
+ onChange({
747
+ ...value,
748
+ relationshipDetails: { ...value?.relationshipDetails, gender: v as TellescopeGender }
749
+ }, field.id)
750
+ }
751
+ />
752
+ </Grid>
753
+ <Grid item xs={6}>
754
+ <DateStringInput size="small" label="Date of Birth"
755
+ field={{
756
+ ...field,
757
+ isOptional: field.isOptional || field.options?.billingProvider === 'Candid'
758
+ }}
759
+ value={value?.relationshipDetails?.dateOfBirth || ''}
760
+ onChange={dateOfBirth =>
761
+ onChange({
762
+ ...value,
763
+ relationshipDetails: { ...value?.relationshipDetails, dateOfBirth }
764
+ }, field.id)
765
+ }
766
+ />
767
+ </Grid>
768
+
769
+
770
+ {/* <Grid item xs={6}>
771
+ <TextField label="Email" type="email" size="small" InputProps={defaultInputProps} fullWidth
772
+ value={value?.relationshipDetails?.email || ''}
773
+ onChange={e =>
774
+ onChange({
775
+ ...value,
776
+ relationshipDetails: { ...value?.relationshipDetails, email: e.target.value }
777
+ }, field.id)
778
+ }
779
+ />
780
+ </Grid>
781
+ <Grid item xs={6}>
782
+ <TextField label="Cell Phone" size="small" InputProps={defaultInputProps} fullWidth
783
+ value={value?.relationshipDetails?.phone || ''}
784
+ onChange={e =>
785
+ onChange({
786
+ ...value,
787
+ relationshipDetails: { ...value?.relationshipDetails, phone: e.target.value }
788
+ }, field.id)
789
+ }
790
+ />
791
+ </Grid>
792
+
793
+ <Grid item xs={12}>
794
+ <AddressInput field={field} value={{
795
+ addressLineOne: value?.relationshipDetails?.address?.lineOne || '',
796
+ addressLineTwo: value?.relationshipDetails?.address?.lineTwo || '',
797
+ city: value?.relationshipDetails?.address?.city || '',
798
+ state: value?.relationshipDetails?.address?.state || '',
799
+ zipCode: value?.relationshipDetails?.address?.zipCode || '',
800
+ }}
801
+ onChange={v => {
802
+ const { addressLineOne='', addressLineTwo='', ...address } = v || {}
803
+ onChange({
804
+ ...value,
805
+ relationshipDetails: {
806
+ ...value?.relationshipDetails,
807
+ address: {
808
+ lineOne: addressLineOne,
809
+ lineTwo: addressLineTwo,
810
+ ...address,
811
+ },
812
+ }
813
+ }, field.id)
814
+ }}
815
+ />
816
+ </Grid> */}
817
+
818
+ {/* <Grid item xs={6}>
819
+ <TextField label="Address"
820
+ value={value?.relationshipDetails?.address?.lineOne || ''}
821
+ onChange={e =>
822
+ onChange({
823
+ ...value,
824
+ relationshipDetails: {
825
+ ...value?.relationshipDetails,
826
+ address: {
827
+ ...value?.relationshipDetails?.address,
828
+ lineOne: e.target.value
829
+ }
830
+ }
831
+ }, field.id)
832
+ }
833
+ />
834
+ </Grid>
835
+ <Grid item xs={6}>
836
+ <TextField label="Line Two"
837
+ value={value?.relationshipDetails?.address?.lineTwo || ''}
838
+ onChange={e =>
839
+ onChange({
840
+ ...value,
841
+ relationshipDetails: {
842
+ ...value?.relationshipDetails,
843
+ address: {
844
+ ...value?.relationshipDetails?.address,
845
+ lineTwo: e.target.value
846
+ }
847
+ }
848
+ }, field.id)
849
+ }
850
+ />
851
+ </Grid>
852
+ <Grid item xs={6}>
853
+ <TextField label="City"
854
+ value={value?.relationshipDetails?.address?.city || ''}
855
+ onChange={e =>
856
+ onChange({
857
+ ...value,
858
+ relationshipDetails: {
859
+ ...value?.relationshipDetails,
860
+ address: {
861
+ ...value?.relationshipDetails?.address,
862
+ city: e.target.value
863
+ }
864
+ }
865
+ }, field.id)
866
+ }
867
+ />
868
+ </Grid>
869
+ <Grid item xs={2}>
870
+ <TextField label="State"
871
+ value={value?.relationshipDetails?.address?.state || ''}
872
+ onChange={e =>
873
+ onChange({
874
+ ...value,
875
+ relationshipDetails: {
876
+ ...value?.relationshipDetails,
877
+ address: {
878
+ ...value?.relationshipDetails?.address,
879
+ state: e.target.value
880
+ }
881
+ }
882
+ }, field.id)
883
+ }
884
+ />
885
+ </Grid>
886
+
887
+ <Grid item xs={4}>
888
+ <Autocomplete value={value?.state}
889
+ options={VALID_STATES}
890
+ sx={{ width: 100 }}
891
+ disablePortal
892
+ onChange={(e, v) => v &&
893
+ onChange({
894
+ ...value as any,
895
+ state: v ?? '',
896
+ },
897
+ field.id
898
+ )}
899
+ renderInput={(params) => (
900
+ <TextField {...params} InputProps={{ ...params.InputProps, sx: defaultInputProps.sx }}
901
+ size={'small'} label={"State"} required={!field.isOptional}
902
+ />
903
+ )}
904
+ {...props}
905
+ />
906
+ </Grid> */}
907
+ </>
908
+ }
909
+ </Grid>
910
+ )
911
+ }
912
+
913
+
914
+ const StringSelector = ({ options, value, onChange, required, getDisplayValue, ...props } : {
915
+ options: string[]
916
+ value: string,
917
+ onChange: (v: string) => void,
918
+ label?: string,
919
+ size?: "small",
920
+ required?: boolean,
921
+ getDisplayValue?: (v: string) => string,
922
+ disabled?: boolean,
923
+ }) => (
924
+ <FormControl fullWidth size={props.size} required={required}>
925
+ <InputLabel>{props.label}</InputLabel>
926
+ <Select {...props} value={value} onChange={e => onChange(e.target.value)} fullWidth
927
+ sx={defaultInputProps.sx}
928
+ >
929
+ {options.map((o, i) => (
930
+ <MenuItem value={o} key={o || i}>{getDisplayValue?.(o) ?? o}</MenuItem>
931
+ ))}
932
+ </Select>
933
+ </FormControl>
934
+ )
935
+
936
+ const HourSelector = (props : { value: string, onChange: (v: string) => void }) => (
937
+ <StringSelector {...props}
938
+ options={Array(12).fill('').map((_, i) => (i + 1) <= 9 ? `0${i + 1}` : (i + 1).toString())}
939
+ />
940
+ )
941
+ const MinuteSelector = (props : { value: string, onChange: (v: string) => void }) => (
942
+ <StringSelector {...props}
943
+ options={Array(60).fill('').map((_, i) => i <= 9 ? `0${i}` : i.toString())}
944
+ />
945
+ )
946
+ const AmPmSelector = (props : { value: string, onChange: (v: string) => void }) => (
947
+ <StringSelector {...props} options={['AM', 'PM']} />
948
+ )
949
+
950
+ export const TimeInput = ({ field, value, onChange, ...props }: FormInputProps<'string'>) => {
951
+ const [hour, rest=''] = (value || '').split(':')
952
+ const [minute, amPm, zone=getLocalTimezone()] = rest.split(' ')
953
+
954
+ return (
955
+ <Grid container alignItems='center' spacing={1}>
956
+ <Grid item sx={{ width: 100 }}>
957
+ <HourSelector value={hour}
958
+ onChange={hour => onChange(`${hour}:${minute} ${amPm} ${zone}`, field.id)}
959
+ />
960
+ </Grid>
961
+
962
+ <Grid item sx={{ width: 100 }}>
963
+ <MinuteSelector value={minute}
964
+ onChange={minute => onChange(`${hour}:${minute} ${amPm} ${zone}`, field.id)}
965
+ />
966
+ </Grid>
967
+
968
+ <Grid item sx={{ width: 100 }}>
969
+ <AmPmSelector value={amPm}
970
+ onChange={amPm => onChange(`${hour}:${minute} ${amPm} ${zone}`, field.id)}
971
+ />
972
+ </Grid>
973
+ </Grid>
974
+ )
975
+ }
976
+
977
+ export const TimezoneInput = ({ value='', field, onChange, ...props }: FormInputProps<'Timezone'>) => (
978
+ <StringSelector {...props} value={value} options={TIMEZONES_USA} onChange={v => onChange(v, field.id)} />
979
+ )
980
+
981
+ export const AddressInput = ({ field, form, value, onChange, ...props }: FormInputProps<'Address'>) => (
982
+ // state only
983
+ field.options?.addressFields?.includes('state')
984
+ ? (
985
+ <Autocomplete value={value?.state || ''}
986
+ options={field.options?.validStates?.length ? field.options.validStates : VALID_STATES}
987
+ disablePortal
988
+ onChange={(e, v) => v &&
989
+ onChange({
990
+ ...value as any,
991
+ state: v ?? '',
992
+ },
993
+ field.id
994
+ )}
995
+ renderInput={(params) => (
996
+ <TextField {...params} InputProps={{ ...params.InputProps, sx: defaultInputProps.sx }}
997
+ required={!field.isOptional}
998
+ // don't use 'small' so as to be consistent with other text fields, in case this is used in a group
999
+ // size={'small'}
1000
+ label={form_display_text_for_language(form, "State")}
1001
+ />
1002
+ )}
1003
+ {...props}
1004
+ />
1005
+ )
1006
+ : (
1007
+ <Grid container direction="column" spacing={2} sx={{ mt: 0 }}>
1008
+ <Grid item>
1009
+ <AutoFocusTextField {...props} size="small" required={!field.isOptional} fullWidth
1010
+ value={value?.addressLineOne ?? ''}
1011
+ label={form_display_text_for_language(form, "Address Line 1")}
1012
+ placeholder={form_display_text_for_language(form, "Address Line 1")}
1013
+ onChange={e =>
1014
+ onChange({
1015
+ ...value as any,
1016
+ addressLineOne: e.target.value ?? '',
1017
+ },
1018
+ field.id
1019
+ )}
1020
+ />
1021
+ </Grid>
1022
+
1023
+ <Grid item>
1024
+ <TextField {...props} size="small" required={false} fullWidth
1025
+ InputProps={defaultInputProps}
1026
+ value={value?.addressLineTwo ?? ''}
1027
+ label={form_display_text_for_language(form, "Address Line 2")}
1028
+ placeholder={form_display_text_for_language(form, "Address Line 2")}
1029
+ onChange={e =>
1030
+ onChange({
1031
+ ...value as any,
1032
+ addressLineTwo: e.target.value ?? '',
1033
+ },
1034
+ field.id
1035
+ )}
1036
+ />
1037
+ </Grid>
1038
+
1039
+ <Grid item>
1040
+ <Grid container alignItems="center" justifyContent={"space-between"} spacing={1}>
1041
+ <Grid item xs={12} sm={field.fullZIP ? 5 : 6}>
1042
+ <TextField {...props} size="small" required={!field.isOptional}
1043
+ InputProps={defaultInputProps}
1044
+ fullWidth
1045
+ value={value?.city ?? ''}
1046
+ label={form_display_text_for_language(form, "City")}
1047
+ placeholder={form_display_text_for_language(form, "City")}
1048
+ onChange={e =>
1049
+ onChange({
1050
+ ...value as any,
1051
+ city: e.target.value ?? '',
1052
+ },
1053
+ field.id
1054
+ )}
1055
+ />
1056
+ </Grid>
1057
+
1058
+ <Grid item xs={field.fullZIP ? 4 : 6} sm={field.fullZIP ? 2 : 3}>
1059
+ <Autocomplete value={value?.state || ''} fullWidth
1060
+ options={field.options?.validStates?.length ? field.options.validStates : VALID_STATES}
1061
+ disablePortal
1062
+ onChange={(e, v) => v &&
1063
+ onChange({
1064
+ ...value as any,
1065
+ state: v ?? '',
1066
+ },
1067
+ field.id
1068
+ )}
1069
+ renderInput={(params) => (
1070
+ <TextField {...params} InputProps={{ ...params.InputProps, sx: defaultInputProps.sx }}
1071
+ size={'small'} required={!field.isOptional}
1072
+ label={form_display_text_for_language(form, "State")}
1073
+ />
1074
+ )}
1075
+ {...props}
1076
+ />
1077
+ </Grid>
1078
+
1079
+ <Grid item xs={field.fullZIP ? 5 : 6} sm={field.fullZIP ? 2 : 3}>
1080
+ <TextField {...props} size="small" required={!field.isOptional}
1081
+ InputProps={defaultInputProps} fullWidth
1082
+ value={value?.zipCode ?? ''}
1083
+ label={form_display_text_for_language(form, "ZIP Code")}
1084
+ placeholder={form_display_text_for_language(form, "ZIP Code")}
1085
+ onChange={e =>
1086
+ onChange({
1087
+ ...value as any,
1088
+ zipCode: e.target.value ?? '',
1089
+ },
1090
+ field.id
1091
+ )}
1092
+ />
1093
+ </Grid>
1094
+
1095
+ {field.fullZIP &&
1096
+ <Grid item xs={3}>
1097
+ <TextField {...props} size="small" label="ZIP+4" required={!field.isOptional && field.fullZIP}
1098
+ InputProps={defaultInputProps}
1099
+ value={value?.zipPlusFour ?? ''}
1100
+ placeholder="ZIP + 4"
1101
+ onChange={e =>
1102
+ onChange({
1103
+ ...value as any,
1104
+ zipPlusFour: e.target.value ?? '',
1105
+ },
1106
+ field.id
1107
+ )}
1108
+ />
1109
+ </Grid>
1110
+ }
1111
+
1112
+ </Grid>
1113
+ </Grid>
1114
+ </Grid>
1115
+ )
1116
+ )
1117
+
1118
+ export const ESignatureTerms = () => {
1119
+ let companyName = 'Tellescope'
1120
+ try {
1121
+ const indexOfName = window.location.href.indexOf('name=')
1122
+ if (indexOfName !== -1) {
1123
+ companyName = (
1124
+ decodeURIComponent(window.location.href.substring(indexOfName + 5))
1125
+ || companyName
1126
+ )
1127
+ }
1128
+ } catch(err) {
1129
+ console.error(err)
1130
+ }
1131
+
1132
+ return (
1133
+ <div style={{paddingLeft: 10}}>
1134
+ <h1>{companyName} Electronic Signature Terms</h1>
1135
+
1136
+ <p>
1137
+ By selecting the "I consent to use electronic signatures" checkbox,
1138
+ you are signing this Agreement electronically.
1139
+ You agree your electronic signature is the legal
1140
+ equivalent of your manual/handwritten signature on this Agreement.
1141
+ By selecting "I consent to use electronic signatures" using any device, means or action, you consent to the
1142
+ legally binding terms and conditions of this Agreement.
1143
+ You further agree that your signature on this document (hereafter referred to as your "E-Signature")
1144
+ is as valid as if you signed the document in writing.
1145
+ You also agree that no certification authority or other third party
1146
+ verification is necessary to validate your E-Signature,
1147
+ and that the lack of such certification or third party verification will
1148
+ not in any way affect the enforceability
1149
+ of your E-Signature or any resulting agreement between you and
1150
+ {companyName} or between you and a customer of {companyName}.
1151
+ </p>
1152
+ </div>
1153
+ )
1154
+ }
1155
+
1156
+ export const SignatureInput = ({ value, field, autoFocus=true, enduser, onChange }: FormInputProps<'signature'>) => {
1157
+ const prefill = (
1158
+ field.options?.prefillSignature && enduser?.fname && enduser.lname
1159
+ ? `${enduser.fname} ${enduser.lname}`
1160
+ : undefined
1161
+ )
1162
+
1163
+ const handleConsentChange = () => {
1164
+ const newConsent = !value?.signed
1165
+
1166
+ onChange({
1167
+ pdfAttachment: field.options?.pdfAttachment,
1168
+ fullName: value?.fullName ?? prefill ?? '',
1169
+ signed: newConsent,
1170
+ url: field.options?.signatureUrl,
1171
+ }, field.id)
1172
+ }
1173
+
1174
+ const handleNameChange = (newName: string) => {
1175
+ onChange({
1176
+ pdfAttachment: field.options?.pdfAttachment,
1177
+ signed: value?.signed ?? false,
1178
+ fullName: newName,
1179
+ url: field.options?.signatureUrl,
1180
+ }, field.id)
1181
+ }
1182
+
1183
+ return (
1184
+ <Grid container alignItems="center">
1185
+ {field.options?.pdfAttachment &&
1186
+ <PdfViewer url={getPublicFileURL({ businessId: field.businessId, name: field.options.pdfAttachment })} />
1187
+ }
1188
+ {!field.options?.pdfAttachment && field.options?.signatureUrl &&
1189
+ <Grid container direction="column" sx={{ mb: 2 }}>
1190
+ <iframe src={field.options.signatureUrl}
1191
+ style={{
1192
+ border: 'none',
1193
+ height: 400,
1194
+ width: '100%',
1195
+ marginBottom: '5px'
1196
+ }}
1197
+ />
1198
+ <a href={field.options.signatureUrl} target="_blank" rel="noopener noreferrer">
1199
+ View document in new tab
1200
+ </a>
1201
+ </Grid>
1202
+ }
1203
+
1204
+ <Grid item xs={12}>
1205
+ <Checkbox
1206
+ style={{ margin: 0, marginTop: 5, padding: 0, paddingRight: 3 }}
1207
+ color="primary"
1208
+ checked={!!value?.signed} // make sure to coerce to boolean to enforce controlled
1209
+ onClick={() => handleConsentChange()}
1210
+ inputProps={{ 'aria-label': 'consent to e-signature checkbox' }}
1211
+ />
1212
+ <Typography component="span" style={{ position: 'relative', top: 5, left: 2 }}>
1213
+ I consent to
1214
+ use <a href={`/e-signature-terms?name=${field.options?.esignatureTermsCompanyName || ''}`} target="_blank" rel="noopener noreferrer"> electronic signatures </a>
1215
+ </Typography>
1216
+ </Grid>
1217
+
1218
+ <Grid item xs={12} style={{ marginTop: 12 }}>
1219
+ <TextField disabled={!value?.signed} autoFocus={autoFocus}
1220
+ style={{ width: '100%'}}
1221
+ size="small"
1222
+ aria-label="Full Name"
1223
+ value={value?.fullName}
1224
+ placeholder={prefill || "Full Name"} variant="outlined"
1225
+ onChange={e => handleNameChange(e.target.value)}
1226
+ InputProps={defaultInputProps}
1227
+ />
1228
+ <Typography color="primary" style={{ fontSize: 15, marginTop: 2 }}>
1229
+ Enter your legal full name to complete the signature
1230
+ </Typography>
1231
+ </Grid>
1232
+ </Grid>
1233
+ )
1234
+ }
1235
+
1236
+ const formatBytes = (bytes: number) => {
1237
+ if (bytes === 0) return '0 Bytes';
1238
+ const k = 1024;
1239
+ const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
1240
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
1241
+ return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
1242
+ }
1243
+
1244
+ export async function convertHEIC (file: FileBlob | string){
1245
+ // get image as blob url
1246
+ let blobURL = (
1247
+ typeof file === 'string' ? file : URL.createObjectURL(file)
1248
+ );
1249
+
1250
+ // convert "fetch" the new blob url
1251
+ let blobRes = await fetch(blobURL)
1252
+
1253
+ // convert response to blob
1254
+ let blob = await blobRes.blob()
1255
+
1256
+ // convert to PNG - response is blob
1257
+ let conversionResult = await heic2any({ blob })
1258
+
1259
+ // convert to blob url
1260
+ var url = URL.createObjectURL(Array.isArray(conversionResult) ? conversionResult[0] : conversionResult);
1261
+
1262
+ return url
1263
+ };
1264
+
1265
+ const value_is_image = (f?: { type?: string })=> f?.type?.includes('image')
1266
+ export const FileInput = ({ value, onChange, field, existingFileName, uploadingFiles, handleFileUpload, setUploadingFiles }: FormInputProps<'file'> & { existingFileName?: string }) => {
1267
+ const [error, setError] = useState('')
1268
+ const { getRootProps, getInputProps, isDragActive } = useDropzone({
1269
+ onDrop: useCallback(
1270
+ acceptedFiles => {
1271
+ const file = acceptedFiles.pop()
1272
+ if (!file) return
1273
+
1274
+ if (field.options?.maxFileSize && file.size > field.options.maxFileSize) {
1275
+ return setError(`File size must be less than ${formatBytes(field.options.maxFileSize)}`)
1276
+ }
1277
+
1278
+ if (field.options?.validFileTypes?.length) {
1279
+ const match = field.options.validFileTypes.find(t => file.type.includes(t.toLowerCase()))
1280
+ if (!match) {
1281
+ return setError(`File must have type: ${field.options.validFileTypes.join(', ')}`)
1282
+ }
1283
+ }
1284
+
1285
+ setError('')
1286
+ onChange(file, field.id)
1287
+
1288
+ if (field.options?.autoUploadFiles && handleFileUpload) {
1289
+ setUploadingFiles?.(fs => [...fs, { fieldId: field.id }])
1290
+
1291
+ handleFileUpload(file, field.id)
1292
+ .finally(
1293
+ () => setUploadingFiles?.(fs => fs.filter(f => f.fieldId !== field.id))
1294
+ )
1295
+ }
1296
+ }, [onChange, field.options?.validFileTypes, handleFileUpload, setUploadingFiles]
1297
+ ),
1298
+ })
1299
+
1300
+ const [preview, setPreview] = useState('')
1301
+ useEffect(() => {
1302
+ if (!value_is_image(value)) return
1303
+ if ((value.type.includes('heif') || value.type.includes('heic'))) {
1304
+ convertHEIC(value).then(setPreview).catch(console.error)
1305
+ return
1306
+ }
1307
+
1308
+ try {
1309
+ setPreview(URL.createObjectURL(value))
1310
+ } catch(err) {
1311
+ console.error(err)
1312
+ }
1313
+ }, [value])
1314
+
1315
+ if (uploadingFiles?.find(f => f.fieldId === field.id)) {
1316
+ return <LinearProgress />
1317
+ }
1318
+ return (
1319
+ <Grid container direction="column">
1320
+ <Grid container {...getRootProps()} sx={{
1321
+ width: "100%",
1322
+ border: "1px dashed #00000033",
1323
+ borderRadius: 1,
1324
+ padding: (preview && !isDragActive) ? 0 : 6,
1325
+ '&:hover': {
1326
+ border: `1px solid ${PRIMARY_HEX}`,
1327
+ cursor: 'pointer',
1328
+ }
1329
+ }}
1330
+ alignItems="center" justifyContent="center">
1331
+ <input {...getInputProps({ multiple: false })} />
1332
+ {
1333
+ <p>
1334
+ {value
1335
+ ? (
1336
+ preview
1337
+ ? <img src={preview} style={{ paddingLeft: '10%', width : '80%', maxHeight: 200 }} />
1338
+ : `${truncate_string(value.name, { length: 30, showEllipsis: true })} selected!`
1339
+ )
1340
+ : capture_is_supported()
1341
+ ? (
1342
+ <Grid container direction="column" alignItems="center">
1343
+ <Grid item>
1344
+ <AddPhotoAlternateIcon color="primary" />
1345
+ </Grid>
1346
+ <Grid item>
1347
+ <Typography sx={{ fontSize: 14, textAlign: 'center' }}>
1348
+ Select file or take picture
1349
+ </Typography>
1350
+ </Grid>
1351
+ </Grid>
1352
+ )
1353
+ : <Grid container direction="column" alignItems="center" rowGap={2}>
1354
+ <UploadFile color="primary" sx={{ fontSize: 25 }} />
1355
+ <Typography>
1356
+ {isDragActive ? "Drop to select file" : "Click or drag and drop"}
1357
+ </Typography>
1358
+ </Grid>
1359
+ }
1360
+ </p>
1361
+ }
1362
+ </Grid>
1363
+
1364
+ <Grid item alignSelf="center" sx={{ mt: 0.5 }}>
1365
+ {(!value?.name && existingFileName) &&
1366
+ <Typography>{existingFileName} selected!</Typography>
1367
+ }
1368
+ </Grid>
1369
+ {error &&
1370
+ <Grid item alignSelf="center" sx={{ mt: 0.5 }}>
1371
+ <Typography color="error">{error}</Typography>
1372
+ </Grid>
1373
+ }
1374
+ </Grid>
1375
+ )
1376
+ }
1377
+
1378
+ export const safe_create_url = (file: any) => {
1379
+ try {
1380
+ return URL.createObjectURL(file)
1381
+ } catch(err) {
1382
+ console.error('safe_create_url error:', err)
1383
+ return null
1384
+ }
1385
+ }
1386
+
1387
+ export const FilesInput = ({ value, onChange, field, existingFileName, uploadingFiles, handleFileUpload, setUploadingFiles }: FormInputProps<'files'> & { existingFileName?: string }) => {
1388
+ const [error, setError] = useState('')
1389
+ const { getRootProps, getInputProps, isDragActive } = useDropzone({
1390
+ onDrop: useCallback(
1391
+ async acceptedFiles => {
1392
+ setUploadingFiles?.(fs => [...fs, { fieldId: field.id }])
1393
+ for (const file of acceptedFiles) {
1394
+ if (field.options?.validFileTypes?.length) {
1395
+ const match = field.options.validFileTypes.find(t => file.type.includes(t.toLowerCase()))
1396
+ if (!match) {
1397
+ return setError(`File must have type: ${field.options.validFileTypes.join(', ')}`)
1398
+ }
1399
+ }
1400
+
1401
+ if (field.options?.autoUploadFiles && handleFileUpload) {
1402
+ await handleFileUpload(file, field.id).catch(console.error)
1403
+ }
1404
+ }
1405
+ setUploadingFiles?.(fs => fs.filter(f => f.fieldId !== field.id))
1406
+
1407
+ setError('')
1408
+ onChange([...(value ?? []), ...acceptedFiles], field.id)
1409
+ }, [onChange, value, field.options?.validFileTypes, handleFileUpload, setUploadingFiles]
1410
+ ),
1411
+ })
1412
+
1413
+ const previews = useMemo(() => (
1414
+ (value ?? []).map(v => {
1415
+ return value_is_image(v) ? safe_create_url(v) : null
1416
+ })
1417
+ ), [value])
1418
+
1419
+ if (uploadingFiles?.find(f => f.fieldId === field.id)) {
1420
+ return <LinearProgress />
1421
+ }
1422
+ return (
1423
+ <Grid container direction="column">
1424
+ <Grid container {...getRootProps()} sx={{
1425
+ width: "100%",
1426
+ border: "1px dashed #00000033",
1427
+ borderRadius: 1,
1428
+ padding: 2,
1429
+ '&:hover': {
1430
+ border: `1px solid ${PRIMARY_HEX}`,
1431
+ cursor: 'pointer',
1432
+ }
1433
+ }}
1434
+ alignItems="center" justifyContent="center">
1435
+ <input {...getInputProps({ multiple: false })} />
1436
+ {
1437
+ <p>
1438
+ {capture_is_supported()
1439
+ ? (
1440
+ <Grid container direction="column" alignItems="center">
1441
+ <Grid item>
1442
+ <AddPhotoAlternateIcon color="primary" />
1443
+ </Grid>
1444
+ <Grid item>
1445
+ <Typography sx={{ fontSize: 14, textAlign: 'center' }}>
1446
+ Select files or take pictures
1447
+ </Typography>
1448
+ </Grid>
1449
+ </Grid>
1450
+ )
1451
+ : <Grid container direction="column" alignItems="center" rowGap={2}>
1452
+ <UploadFile color="primary" sx={{ fontSize: 25 }} />
1453
+ <Typography>
1454
+ {isDragActive ? "Drop to select files" : "Click or drag and drop"}
1455
+ </Typography>
1456
+ </Grid>
1457
+ }
1458
+ </p>
1459
+ }
1460
+ </Grid>
1461
+
1462
+ {/* <Grid container sx={{ mt: 1 }} spacing={1}>
1463
+ {previews?.map((preview, i) => (
1464
+ <Grid item key={i} style={{ maxWidth: '25%', maxHeight: 75, height: '100%' }}>
1465
+ <img src={preview} style={{ maxWidth: '25%', maxHeight: 75, height: '100%' }}/>
1466
+ </Grid>
1467
+ ))}
1468
+ </Grid> */}
1469
+
1470
+ <Grid container direction="column" sx={{ overflowY: 'auto', maxHeight: '250px', mt: 1 }} wrap="nowrap">
1471
+ {value?.map((file, i) => (
1472
+ <Grid item key={i} sx={{ mt: 0.5 }}>
1473
+ <Grid container alignItems="center" justifyContent={"space-between"} wrap="nowrap">
1474
+ <Grid item>
1475
+ <Grid container alignItems="center">
1476
+ <Typography sx={{ mr: 1 }}>
1477
+ {file.name}
1478
+ </Typography>
1479
+
1480
+ {file.type?.includes('image') && previews[i] &&
1481
+ <Grid item>
1482
+ <img
1483
+ src={previews[i]!}
1484
+ style={{ maxWidth: '45%', maxHeight: 80, height: '100%' }}
1485
+ />
1486
+ </Grid>
1487
+ }
1488
+ </Grid>
1489
+ </Grid>
1490
+
1491
+ <Grid item>
1492
+ <LabeledIconButton label="Remove"
1493
+ Icon={Delete}
1494
+ onClick={() => onChange(value.filter((f, _i) => i !== _i), field.id)}
1495
+ />
1496
+ </Grid>
1497
+ </Grid>
1498
+ </Grid>
1499
+ ))}
1500
+ </Grid>
1501
+
1502
+ {error &&
1503
+ <Grid item alignSelf="center" sx={{ mt: 0.5 }}>
1504
+ <Typography color="error">{error}</Typography>
1505
+ </Grid>
1506
+ }
1507
+ </Grid>
1508
+ )
1509
+ }
1510
+
1511
+ export const MultipleChoiceInput = ({ field, form, value: _value, onChange }: FormInputProps<'multiple_choice'>) => {
1512
+ const value = typeof _value === 'string' ? [_value] : _value // if loading existingResponses, allows them to be a string
1513
+ const { choices, radio, other, optionDetails } = field.options as MultipleChoiceOptions
1514
+ const [expandedDescriptions, setExpandedDescriptions] = useState<Record<number, boolean>>({})
1515
+
1516
+ // current other string
1517
+ const enteringOtherStringRef = React.useRef('') // if typing otherString as prefix of a checkbox value, don't auto-select
1518
+ const otherString = value?.find(v => v === enteringOtherStringRef.current || !(choices ?? [])?.find(c => c === v)) ?? ''
1519
+
1520
+ // Get primary color from form customization or use default
1521
+ const primaryColor = form?.customization?.primaryColor ?? '#798ED0'
1522
+
1523
+ const getDescriptionForChoice = useCallback((choice: string) => {
1524
+ return optionDetails?.find(detail => detail.option === choice)?.description
1525
+ }, [optionDetails])
1526
+
1527
+ const toggleDescription = useCallback((index: number) => {
1528
+ setExpandedDescriptions(prev => ({
1529
+ ...prev,
1530
+ [index]: !prev[index]
1531
+ }))
1532
+ }, [])
1533
+
1534
+ return (
1535
+ <Grid container alignItems="center" rowGap={1.5}>
1536
+ {radio
1537
+ ? (
1538
+ <FormControl fullWidth>
1539
+ <RadioGroup
1540
+ aria-labelledby={`radio-group-${field.id}-label`}
1541
+ defaultValue="female"
1542
+ name={`radio-group-${field.id}`}
1543
+ >
1544
+ {(choices ?? []).map((c, i) => {
1545
+ const description = getDescriptionForChoice(c)
1546
+ const hasDescription = !!description
1547
+ const isExpanded = expandedDescriptions[i]
1548
+ const isSelected = !!value?.includes(c) && c !== otherString
1549
+
1550
+ return (
1551
+ <Box key={i} sx={{ width: '100%' }}>
1552
+ <Box
1553
+ sx={{
1554
+ display: 'flex',
1555
+ alignItems: 'center',
1556
+ width: '100%',
1557
+ border: isSelected ? '2px solid' : '1px solid',
1558
+ borderColor: 'primary.main',
1559
+ borderRadius: 1,
1560
+ padding: '16px 16px',
1561
+ marginBottom: '12px',
1562
+ cursor: 'pointer',
1563
+ backgroundColor: 'transparent',
1564
+ '&:hover': {
1565
+ backgroundColor: (theme: any) => `${theme.palette.primary.main}14`,
1566
+ },
1567
+ }}
1568
+ onClick={() => onChange(value?.includes(c) ? [] : [c], field.id)}
1569
+ >
1570
+ <Typography component="span" sx={{ flex: 1, color: 'primary.main', fontSize: 13, fontWeight: 600 }}>{c}</Typography>
1571
+ {hasDescription && (
1572
+ <MuiIconButton
1573
+ className="expand-button"
1574
+ size="small"
1575
+ onClick={(e: React.MouseEvent) => {
1576
+ e.stopPropagation()
1577
+ toggleDescription(i)
1578
+ }}
1579
+ sx={{
1580
+ transform: isExpanded ? 'rotate(180deg)' : 'rotate(0deg)',
1581
+ transition: 'transform 0.2s',
1582
+ ml: 1
1583
+ }}
1584
+ >
1585
+ <ExpandMore fontSize="small" />
1586
+ </MuiIconButton>
1587
+ )}
1588
+ </Box>
1589
+ {hasDescription && (
1590
+ <Collapse in={isExpanded}>
1591
+ <Box sx={{ pl: 2, pr: 2, pb: 1, pt: 1 }}>
1592
+ <Typography variant="body2" color="text.secondary">
1593
+ {description}
1594
+ </Typography>
1595
+ </Box>
1596
+ </Collapse>
1597
+ )}
1598
+ </Box>
1599
+ )
1600
+ })}
1601
+ </RadioGroup>
1602
+ </FormControl>
1603
+ ) : (
1604
+ (choices ?? []).map((c, i) => {
1605
+ const description = getDescriptionForChoice(c)
1606
+ const hasDescription = !!description
1607
+ const isExpanded = expandedDescriptions[i]
1608
+
1609
+ return (
1610
+ <Grid xs={12} key={i}>
1611
+ <Box sx={{ width: '100%' }}>
1612
+ <Box
1613
+ sx={{
1614
+ display: 'flex',
1615
+ alignItems: 'center',
1616
+ cursor: 'pointer',
1617
+ width: '100%'
1618
+ }}
1619
+ onClick={(e) => {
1620
+ // Don't trigger selection if clicking on the expand button
1621
+ if ((e.target as HTMLElement).closest('.expand-button')) {
1622
+ return
1623
+ }
1624
+ onChange(
1625
+ (
1626
+ value?.includes(c)
1627
+ ? (
1628
+ (radio || field.options?.radioChoices?.includes(c))
1629
+ ? []
1630
+ : value.filter(v => v !== c)
1631
+ )
1632
+ : (
1633
+ (radio || field.options?.radioChoices?.includes(c))
1634
+ ? [c]
1635
+ : [...(value ?? []).filter(x => !field.options?.radioChoices?.includes(x)), c]
1636
+ )
1637
+ ),
1638
+ field.id,
1639
+ )
1640
+ }}
1641
+ >
1642
+ <Checkbox
1643
+ color="primary"
1644
+ checked={!!value?.includes(c) && c !== otherString}
1645
+ inputProps={{ 'aria-label': 'primary checkbox' }}
1646
+ />
1647
+ <Typography component="span" sx={{ flex: 1 }}>{c}</Typography>
1648
+ {hasDescription && (
1649
+ <MuiIconButton
1650
+ className="expand-button"
1651
+ size="small"
1652
+ onClick={(e: React.MouseEvent) => {
1653
+ e.stopPropagation()
1654
+ toggleDescription(i)
1655
+ }}
1656
+ sx={{
1657
+ transform: isExpanded ? 'rotate(180deg)' : 'rotate(0deg)',
1658
+ transition: 'transform 0.2s',
1659
+ ml: 1
1660
+ }}
1661
+ >
1662
+ <ExpandMore fontSize="small" />
1663
+ </MuiIconButton>
1664
+ )}
1665
+ </Box>
1666
+ {hasDescription && (
1667
+ <Collapse in={isExpanded}>
1668
+ <Box sx={{ pl: '42px', pr: 2, pb: 1 }}>
1669
+ <Typography variant="body2" color="text.secondary">
1670
+ {description}
1671
+ </Typography>
1672
+ </Box>
1673
+ </Collapse>
1674
+ )}
1675
+ </Box>
1676
+ </Grid>
1677
+ )
1678
+ })
1679
+ )
1680
+ }
1681
+ {other &&
1682
+ <Grid item xs={12}>
1683
+ <TextField // className={classes.textField}
1684
+ InputProps={{ sx: { borderRadius: 2.5 }}} // match Checkbox, not default styles
1685
+ sx={{ width: radio ? `calc(100% - 15px)` : '100%' }}
1686
+ size="small"
1687
+ aria-label={form_display_text_for_language(form, "Other")}
1688
+ value={otherString}
1689
+ placeholder={form_display_text_for_language(form, "Other")}
1690
+ variant="outlined"
1691
+ // onClick={() => !otherChecked && handleOtherChecked()} // allow click to enable when disabled
1692
+ onChange={e => {
1693
+ enteringOtherStringRef.current = e.target.value
1694
+ onChange(
1695
+ (
1696
+ radio
1697
+ ? (
1698
+ e.target.value.trim()
1699
+ ? [e.target.value]
1700
+ : []
1701
+ )
1702
+ : (
1703
+ e.target.value.trim()
1704
+ // remove existing other string (if exists) and append new one
1705
+ ? [...(value ?? []).filter(v => v !== otherString), e.target.value]
1706
+ : value?.filter(v => v !== otherString)
1707
+ )
1708
+ ),
1709
+ field.id,
1710
+ )
1711
+ }}
1712
+ />
1713
+ </Grid>
1714
+ }
1715
+ </Grid>
1716
+ )
1717
+ }
1718
+
1719
+ export const StripeInput = ({ field, value, onChange, setCustomerId, enduserId }: FormInputProps<'Stripe'> & {
1720
+ setCustomerId: React.Dispatch<React.SetStateAction<string | undefined>>,
1721
+ }) => {
1722
+ const session = useResolvedSession()
1723
+ const [clientSecret, setClientSecret] = useState('')
1724
+ const [businessName, setBusinessName] = useState('')
1725
+ const [isCheckout, setIsCheckout] = useState(false)
1726
+ const [stripePromise, setStripePromise] = useState<ReturnType<typeof loadStripe>>()
1727
+ const [answertext, setAnswertext] = useState('')
1728
+ const [error, setError] = useState('')
1729
+ const [selectedProducts, setSelectedProducts] = useState<string[]>([])
1730
+ const [showProductSelection, setShowProductSelection] = useState(false)
1731
+ const [availableProducts, setAvailableProducts] = useState<any[]>([])
1732
+ const [loadingProducts, setLoadingProducts] = useState(false)
1733
+
1734
+ const fetchRef = useRef(false)
1735
+ useEffect(() => {
1736
+ if (fetchRef.current) return
1737
+ if (value && (session.userInfo as any)?.stripeCustomerId) {
1738
+ return setCustomerId(c => c ? c : (session.userInfo as any)?.stripeCustomerId) // already paid or saved card
1739
+ }
1740
+
1741
+ // Check if product selection mode is enabled
1742
+ if (field.options?.stripeProductSelectionMode && (field.options?.productIds || []).length > 1) {
1743
+ setShowProductSelection(true)
1744
+ setLoadingProducts(true)
1745
+
1746
+ // Fetch product data with real-time Stripe pricing via proxy_read
1747
+ const productIds = (field.options.productIds || []).join(',')
1748
+ session.api.integrations.proxy_read({
1749
+ integration: 'Stripe',
1750
+ type: 'product-prices',
1751
+ id: productIds,
1752
+ query: field.options.stripeKey
1753
+ })
1754
+ .then(({ data }) => {
1755
+ setAvailableProducts(data.products || [])
1756
+ setLoadingProducts(false)
1757
+ })
1758
+ .catch((e: any) => {
1759
+ console.error('Error loading product data:', e)
1760
+ const errorMessage = e?.message?.includes?.('Stripe pricing error:')
1761
+ ? e.message.replace('Stripe pricing error: ', '')
1762
+ : 'Failed to load product information from Stripe'
1763
+ setError(`Product configuration error: ${errorMessage}`)
1764
+ setLoadingProducts(false)
1765
+ })
1766
+ return
1767
+ }
1768
+
1769
+ fetchRef.current = true
1770
+
1771
+ session.api.form_responses.stripe_details({ fieldId: field.id, enduserId })
1772
+ .then(({ clientSecret, publishableKey, stripeAccount, businessName, customerId, isCheckout, answerText }) => {
1773
+ setAnswertext(answerText || '')
1774
+ setIsCheckout(!!isCheckout)
1775
+ setClientSecret(clientSecret)
1776
+ setStripePromise(loadStripe(publishableKey, { stripeAccount }))
1777
+ setBusinessName(businessName)
1778
+ setCustomerId(customerId)
1779
+ })
1780
+ .catch((e: any) => {
1781
+ console.error(e)
1782
+ if (typeof e?.message === 'string') {
1783
+ setError(e.message)
1784
+ }
1785
+ })
1786
+ }, [session, value, field.id, enduserId])
1787
+
1788
+ const cost = (
1789
+ showProductSelection
1790
+ ? selectedProducts.reduce((total, productId) => {
1791
+ const product = availableProducts.find(p => p._id === productId)
1792
+ if (product?.currentPrice) {
1793
+ return total + (product.currentPrice.amount || 0)
1794
+ }
1795
+ return total + (product?.cost?.amount || 0)
1796
+ }, 0)
1797
+ : 0 // Will be calculated by existing Stripe flow when not in selection mode
1798
+ )
1799
+
1800
+ // Handle product selection step
1801
+ if (showProductSelection) {
1802
+ if (error) {
1803
+ return (
1804
+ <Grid container direction="column" spacing={2} alignItems="center">
1805
+ <Grid item>
1806
+ <Typography color="error" variant="h6">
1807
+ Product Configuration Error
1808
+ </Typography>
1809
+ </Grid>
1810
+ <Grid item>
1811
+ <Typography color="error" sx={{ textAlign: 'center' }}>
1812
+ {error}
1813
+ </Typography>
1814
+ </Grid>
1815
+ </Grid>
1816
+ )
1817
+ }
1818
+
1819
+ if (loadingProducts) {
1820
+ return (
1821
+ <Grid container direction="column" spacing={2} alignItems="center">
1822
+ <Grid item>
1823
+ <LinearProgress />
1824
+ </Grid>
1825
+ <Grid item>
1826
+ <Typography>Loading product information...</Typography>
1827
+ </Grid>
1828
+ </Grid>
1829
+ )
1830
+ }
1831
+ const isSingleSelection = field.options?.radio === true
1832
+
1833
+ const handleProductSelection = (productId: string) => {
1834
+ if (isSingleSelection) {
1835
+ setSelectedProducts([productId])
1836
+ } else {
1837
+ setSelectedProducts(prev =>
1838
+ prev.includes(productId)
1839
+ ? prev.filter(id => id !== productId)
1840
+ : [...prev, productId]
1841
+ )
1842
+ }
1843
+ }
1844
+
1845
+ const handleContinueToPayment = () => {
1846
+ if (selectedProducts.length === 0) return
1847
+ setShowProductSelection(false)
1848
+ fetchRef.current = true
1849
+
1850
+ // Now fetch Stripe details with selected products
1851
+ session.api.form_responses.stripe_details({
1852
+ fieldId: field.id,
1853
+ enduserId,
1854
+ ...(selectedProducts.length > 0 && { selectedProductIds: selectedProducts }) // Pass selected products to Stripe checkout
1855
+ } as any)
1856
+ .then(({ clientSecret, publishableKey, stripeAccount, businessName, customerId, isCheckout, answerText }) => {
1857
+ setAnswertext(answerText || '')
1858
+ setIsCheckout(!!isCheckout)
1859
+ setClientSecret(clientSecret)
1860
+ setStripePromise(loadStripe(publishableKey, { stripeAccount }))
1861
+ setBusinessName(businessName)
1862
+ setCustomerId(customerId)
1863
+ })
1864
+ .catch((e: any) => {
1865
+ console.error(e)
1866
+ if (typeof e?.message === 'string') {
1867
+ setError(e.message)
1868
+ }
1869
+ })
1870
+ }
1871
+
1872
+ return (
1873
+ <Grid container direction="column" spacing={2}>
1874
+ <Grid item>
1875
+ <Typography variant="h6">Select Product{isSingleSelection ? '' : 's'}</Typography>
1876
+ </Grid>
1877
+
1878
+ {availableProducts.map((product) => {
1879
+ // Use real-time Stripe pricing if available, fallback to Tellescope pricing
1880
+ const price = product.currentPrice || product.cost
1881
+ const priceAmount = price?.amount || 0
1882
+ const priceCurrency = price?.currency || 'USD'
1883
+
1884
+ return (
1885
+ <Grid item key={product._id}>
1886
+ <FormControlLabel
1887
+ control={
1888
+ isSingleSelection ? (
1889
+ <Radio
1890
+ checked={selectedProducts.includes(product._id)}
1891
+ onChange={() => handleProductSelection(product._id)}
1892
+ />
1893
+ ) : (
1894
+ <Checkbox
1895
+ checked={selectedProducts.includes(product._id)}
1896
+ onChange={() => handleProductSelection(product._id)}
1897
+ />
1898
+ )
1899
+ }
1900
+ label={
1901
+ <Box>
1902
+ <Typography variant="body1" fontWeight="bold">
1903
+ {product.title}
1904
+ </Typography>
1905
+ {product.description && (
1906
+ <Typography variant="body2" color="textSecondary">
1907
+ {product.description}
1908
+ </Typography>
1909
+ )}
1910
+ <Typography variant="body2" color="primary">
1911
+ ${(priceAmount / 100).toFixed(2)} {priceCurrency.toUpperCase()}
1912
+ {product.currentPrice?.isSubscription && (
1913
+ <Typography component="span" variant="caption" sx={{ ml: 0.5 }}>
1914
+ /month
1915
+ </Typography>
1916
+ )}
1917
+ </Typography>
1918
+ </Box>
1919
+ }
1920
+ />
1921
+ </Grid>
1922
+ )
1923
+ })}
1924
+
1925
+ <Grid item>
1926
+ <Button
1927
+ variant="contained"
1928
+ onClick={handleContinueToPayment}
1929
+ disabled={selectedProducts.length === 0}
1930
+ sx={{ mt: 2 }}
1931
+ >
1932
+ Continue to Payment
1933
+ </Button>
1934
+ </Grid>
1935
+ </Grid>
1936
+ )
1937
+ }
1938
+
1939
+ if (error) {
1940
+ return (
1941
+ <Typography color="error">
1942
+ {error}
1943
+ </Typography>
1944
+ )
1945
+ }
1946
+ if (value) {
1947
+ return (
1948
+ <Grid container alignItems="center" wrap="nowrap">
1949
+ <CheckCircleOutline color="success" />
1950
+
1951
+ <Typography sx={{ ml: 1, fontSize: 20 }}>
1952
+ {field.options?.chargeImmediately ? 'Your purchase was successful' : "Your payment details have been saved!"}
1953
+ </Typography>
1954
+ </Grid>
1955
+ )
1956
+ }
1957
+ if (!(clientSecret && stripePromise)) return <LinearProgress />
1958
+ if (isCheckout && stripePromise) return (
1959
+ <EmbeddedCheckoutProvider stripe={stripePromise}
1960
+ options={{
1961
+ clientSecret,
1962
+ onComplete: () => onChange(answertext || 'Completed checkout', field.id),
1963
+ }}
1964
+ >
1965
+ <EmbeddedCheckout />
1966
+ </EmbeddedCheckoutProvider>
1967
+ )
1968
+ return (
1969
+ <Elements stripe={stripePromise} options={{
1970
+ clientSecret,
1971
+ }}>
1972
+ <StripeForm businessName={businessName} onSuccess={() => onChange(answertext || 'Saved card details', field.id)}
1973
+ cost={cost}
1974
+ field={field}
1975
+ />
1976
+ </Elements>
1977
+ )
1978
+ }
1979
+
1980
+ const StripeForm = ({ businessName, onSuccess, field, cost } : { businessName: string, onSuccess: () => void, field: FormField, cost: number }) => {
1981
+ const stripe = useStripe();
1982
+ const elements = useElements()
1983
+
1984
+ const [ready, setReady] = useState(false)
1985
+ const [errorMessage, setErrorMessage] = useState('');
1986
+
1987
+ const handleSubmit = async (event: any) => {
1988
+ // We don't want to let default form submission happen here,
1989
+ // which would refresh the page.
1990
+ event?.preventDefault();
1991
+
1992
+ if (!stripe || !elements) {
1993
+ // Stripe.js hasn't yet loaded.
1994
+ // Make sure to disable form submission until Stripe.js has loaded.
1995
+ return null;
1996
+ }
1997
+
1998
+ const {error} = await (field.options?.chargeImmediately ? stripe.confirmPayment : stripe.confirmSetup)({
1999
+ //`Elements` instance that was used to create the Payment Element
2000
+ elements,
2001
+ confirmParams: {
2002
+ return_url: window.location.href,
2003
+ },
2004
+ redirect: 'if_required', // ensures the redirect url won't be used, unless the Bank redirect payment type is enabled (it's not, just card)
2005
+ });
2006
+
2007
+ if (error) {
2008
+ // This point will only be reached if there is an immediate error when
2009
+ // confirming the payment. Show error to your customer (for example, payment
2010
+ // details incomplete)
2011
+ setErrorMessage(error?.message ?? '');
2012
+ } else {
2013
+ onSuccess()
2014
+ // Your customer will be redirected to your `return_url`. For some payment
2015
+ // methods like iDEAL, your customer will be redirected to an intermediate
2016
+ // site first to authorize the payment, then redirected to the `return_url`.
2017
+ }
2018
+ };
2019
+
2020
+ return (
2021
+ <form onSubmit={handleSubmit}>
2022
+ <PaymentElement onReady={() => setReady(true)}
2023
+ options={{
2024
+ business: { name: businessName },
2025
+ }}
2026
+ />
2027
+ <Button variant="contained" color="primary" type="submit" sx={{ mt: 1 }}
2028
+ disabled={!(stripe && ready)}
2029
+ >
2030
+ {field.options?.chargeImmediately ? 'Make Payment' : 'Save Payment Details'}
2031
+ </Button>
2032
+
2033
+ {cost > 0 &&
2034
+ <Typography sx={{ mt: 0.5 }}>
2035
+ {
2036
+ field.options?.customPriceMessage
2037
+ ? field.options.customPriceMessage.replaceAll('{{PRICE}}', `$${(cost / 100).toFixed(2)}`)
2038
+ : `You will be charged $${(cost / 100).toFixed(2)} ${field.options?.chargeImmediately ? '' : 'on form submission'}`
2039
+ }
2040
+ </Typography>
2041
+ }
2042
+
2043
+ {/* Show error message to your customers */}
2044
+ {errorMessage &&
2045
+ <Typography color="error" sx={{ mt: 0.5 }}>
2046
+ {errorMessage}
2047
+ </Typography>
2048
+ }
2049
+ </form>
2050
+ )
2051
+ }
2052
+
2053
+ export const Progress = ({ numerator, denominator, style, color } : { numerator: number, denominator: number, color?: string } & Styled) => (
2054
+ <Box sx={{ display: 'flex', alignItems: 'center', ...style }}>
2055
+ <Box sx={{ width: '100%' }}>
2056
+ <LinearProgress variant="determinate"
2057
+ value={(numerator / (denominator || 1)) * 100}
2058
+ sx={color ? {
2059
+ height: '16px',
2060
+ borderRadius: '8px',
2061
+ backgroundColor: `${color}20`,
2062
+ '& .MuiLinearProgress-bar': {
2063
+ backgroundColor: color,
2064
+ borderRadius: '8px',
2065
+ }
2066
+ } : {
2067
+ height: '16px',
2068
+ borderRadius: '8px',
2069
+ '& .MuiLinearProgress-bar': {
2070
+ borderRadius: '8px',
2071
+ }
2072
+ }}
2073
+ />
2074
+ </Box>
2075
+ </Box>
2076
+ )
2077
+
2078
+ export const DropdownInput = ({ field, value, onChange }: FormInputProps<'Dropdown'>) => {
2079
+ const [typing, setTyping] = useState('')
2080
+
2081
+ // this should run only once, even if the field updates but the id is unchanged, otherwise will overwrite input
2082
+ const typingRef = useRef('')
2083
+ useEffect(() => {
2084
+ if (typingRef.current === field.id) return
2085
+ typingRef.current = field.id
2086
+
2087
+ setTyping('')
2088
+ }, [field])
2089
+
2090
+ return (
2091
+ <Autocomplete id={field.id} style={{ marginTop: 5 }}
2092
+ multiple={!field.options?.radio}
2093
+ freeSolo={!!field.options?.other}
2094
+ value={
2095
+ field.options?.radio
2096
+ ? (value?.[0] ?? '')
2097
+ : (value ?? [])
2098
+ }
2099
+ onChange={(_, v) => (
2100
+ onChange(
2101
+ (typeof v === 'string' || v === null) ? [v ?? ''] : v,
2102
+ field.id
2103
+ )
2104
+ )}
2105
+ options={field.options?.choices ?? []}
2106
+ inputValue={
2107
+ field.options?.radio && Array.isArray(value) && value[0]
2108
+ ? value[0]
2109
+ : typing
2110
+ }
2111
+ onInputChange={(e, value) => setTyping(value)}
2112
+ renderInput={params =>
2113
+ <TextField {...params}
2114
+ InputProps={{ ...params.InputProps, sx: defaultInputProps.sx }}
2115
+ onChange={e => (
2116
+ (field.options?.radio && field.options.other)
2117
+ ? onChange(e.target.value ? [e.target.value] : [], field.id)
2118
+ : undefined
2119
+ )}
2120
+ placeholder={
2121
+ field.placeholder
2122
+ ? field.placeholder + ((!field.title && !field.isOptional) ? '*' : '')
2123
+ : undefined
2124
+ }
2125
+ label={
2126
+ (!field.options?.radio && field.options?.other)
2127
+ ? "Press enter to save a custom value"
2128
+ : ''
2129
+ }
2130
+ />
2131
+ }
2132
+ />
2133
+ )
2134
+ }
2135
+
2136
+ const choicesForDatabase: {
2137
+ [index: string]: {
2138
+ done: boolean,
2139
+ records: DatabaseRecord[],
2140
+ lastId?: string,
2141
+ } | {
2142
+ done: undefined,
2143
+ records: undefined,
2144
+ lastId?: string,
2145
+ }
2146
+ } = {}
2147
+ const preventRefetch: Record<string, boolean> = {}
2148
+
2149
+ const LOAD_CHOICES_LIMIT = 500
2150
+ const useDatabaseChoices = ({ databaseId='', field, otherAnswers } : { databaseId?: string, field: FormField, otherAnswers?: DatabaseSelectResponse[] }) => {
2151
+ const session = useResolvedSession()
2152
+ const [renderCount, setRenderCount] = useState(0)
2153
+
2154
+ // todo: make searchable, don't load all
2155
+ useEffect(() => {
2156
+ if (choicesForDatabase[databaseId]?.done) return
2157
+ if (renderCount > 100) return // limit to 50000 entries / prevent infinite looping
2158
+ const choices = choicesForDatabase[databaseId]?.records ?? []
2159
+ const lastId = choicesForDatabase[databaseId]?.lastId
2160
+
2161
+ if (preventRefetch[databaseId + field.id + lastId]) return
2162
+ preventRefetch[databaseId + field.id + lastId] = true
2163
+
2164
+ session.api.form_fields.load_choices_from_database({
2165
+ fieldId: field.id,
2166
+ lastId,
2167
+ limit: LOAD_CHOICES_LIMIT,
2168
+ databaseId, // overrides fieldId, supports using Database question in Table Input
2169
+ })
2170
+ .then(({ choices: newChoices }) => {
2171
+ choicesForDatabase[databaseId] = {
2172
+ lastId: newChoices?.[newChoices.length - 1]?.id,
2173
+ records: [...choices, ...newChoices]
2174
+ .sort((c1, c2) => (
2175
+ label_for_database_record(field, c1)
2176
+ .localeCompare(label_for_database_record(field, c2))
2177
+ )
2178
+ ),
2179
+ done: newChoices.length < LOAD_CHOICES_LIMIT,
2180
+ }
2181
+ setRenderCount(r => r + 1)
2182
+ })
2183
+ .catch(err => {
2184
+ console.error(err)
2185
+ preventRefetch[databaseId + field.id + lastId] = false
2186
+ })
2187
+ }, [session, field, databaseId, renderCount])
2188
+
2189
+ const addChoice = useCallback((record: DatabaseRecord) => {
2190
+ if (!choicesForDatabase[databaseId]) {
2191
+ choicesForDatabase[databaseId] = {
2192
+ done: false,
2193
+ records: [],
2194
+ }
2195
+ }
2196
+ choicesForDatabase[databaseId].records!.push(record)
2197
+ }, [choicesForDatabase, databaseId])
2198
+
2199
+ return {
2200
+ addChoice,
2201
+ doneLoading: choicesForDatabase[databaseId]?.done ?? false,
2202
+ choices: [
2203
+ ...choicesForDatabase[databaseId]?.records ?? [],
2204
+ ...(otherAnswers || []).map(v => ({
2205
+ id: v.text,
2206
+ databaseId,
2207
+ values: [{ label: field.options?.databaseLabel || '', type: 'Text', value: v.text }],
2208
+ }) as Pick<DatabaseRecord, 'id' | 'values' | 'databaseId'>)
2209
+ ],
2210
+ renderCount,
2211
+ }
2212
+ }
2213
+
2214
+
2215
+ const label_for_database_record = (field: FormField, record?: Pick<DatabaseRecord, 'values'>) => {
2216
+ if (!record) return ''
2217
+
2218
+ const addedLabels = (
2219
+ (field.options?.databaseLabels || [])
2220
+ .map(l => record.values.find(v => v.label === l)?.value?.toString())
2221
+ .filter(v => v?.trim())
2222
+ ) as string[]
2223
+
2224
+ return (
2225
+ (record.values.find(v => v.label === field.options?.databaseLabel)?.value?.toString() ?? '')
2226
+ + (
2227
+ addedLabels.length
2228
+ ? ` (${addedLabels.join(', ')})`
2229
+ : ''
2230
+ )
2231
+ )
2232
+ }
2233
+
2234
+ const get_other_answers = (_value?: DatabaseSelectResponse[], typing?: string) => {
2235
+ try {
2236
+ const existing = (
2237
+ (_value || [])
2238
+ .filter(v => typeof v === 'string' || v.recordId === v.text)
2239
+ .map(v => typeof v === 'string' ? { databaseId: '', recordId: v, text: v } : v)
2240
+ )
2241
+ if (typing) {
2242
+ existing.push({ text: typing, databaseId: '', recordId: typing })
2243
+ }
2244
+
2245
+ return existing
2246
+ } catch(err) { console.error(err) }
2247
+
2248
+ return []
2249
+ }
2250
+
2251
+ export interface AddToDatabaseProps {
2252
+ databaseId: string,
2253
+ onAdd: (record: DatabaseRecord) => void
2254
+ }
2255
+
2256
+ export const DatabaseSelectInput = ({ AddToDatabase, field, value: _value, onChange, onDatabaseSelect, responses, size, disabled, enduser }: FormInputProps<'Database Select'> & {
2257
+ responses: FormResponseValue[],
2258
+ AddToDatabase?: React.JSXElementConstructor<AddToDatabaseProps>,
2259
+ }) => {
2260
+ const [typing, setTyping] = useState('')
2261
+ const { addChoice, choices, doneLoading } = useDatabaseChoices({
2262
+ databaseId: field.options?.databaseId,
2263
+ field,
2264
+ otherAnswers: get_other_answers(_value, field?.options?.other ? typing : undefined),
2265
+ })
2266
+
2267
+ const value = React.useMemo(() => {
2268
+ try {
2269
+ // if the value is a string (some single answer that was save), make sure we coerce to array
2270
+ const __value = typeof _value === 'string' ? [_value] : _value
2271
+ return (
2272
+ (__value?.map(v =>
2273
+ choices.find(c =>
2274
+ c.id === v.recordId || (typeof v === 'string' && label_for_database_record(field, c) === v)
2275
+ )
2276
+ )?.filter(v => v!) ?? []) as DatabaseRecord[]
2277
+ )
2278
+ } catch(err) {
2279
+ console.error('Error resolving database answers for _value', err)
2280
+ return []
2281
+ }
2282
+ }, [_value, choices, field])
2283
+
2284
+ const filterResponse = useMemo(() => (
2285
+ field.options?.databaseFilter?.fieldId
2286
+ ? responses.find(r => r.fieldId === field.options?.databaseFilter?.fieldId)?.answer?.value
2287
+ : undefined
2288
+ ), [responses, field.options?.databaseFilter])
2289
+
2290
+ // State filtering logic similar to Insurance component
2291
+ const addressQuestion = useMemo(() => responses?.find(r => {
2292
+ if (r.answer.type !== 'Address') return false
2293
+ if (r.field.intakeField !== 'Address') return false
2294
+
2295
+ // make sure state is actually defined (in case of multiple address questions, where 1+ are blank)
2296
+ if (!r.answer.value?.state) return false
2297
+
2298
+ return true
2299
+ }), [responses])
2300
+
2301
+ const state = useMemo(() => (
2302
+ field.options?.filterByEnduserState
2303
+ ? ((addressQuestion?.answer?.type === 'Address' ? addressQuestion?.answer?.value?.state : undefined) || enduser?.state)
2304
+ : undefined
2305
+ ), [enduser?.state, addressQuestion, field.options?.filterByEnduserState])
2306
+
2307
+ const filteredChoicesWithPotentialDuplicates = useMemo(() => {
2308
+ if (!choices) return []
2309
+ if (!filterResponse) return choices
2310
+ if (!field?.options?.databaseFilter?.databaseLabel)
2311
+ if (!value || value.length === 0) return choices
2312
+
2313
+ return (
2314
+ choices
2315
+ .filter(c => {
2316
+ const v = c.values.find(_v => _v.label === field.options?.databaseFilter?.databaseLabel)?.value
2317
+ if (!v) return true
2318
+
2319
+ // use .text on r values to handle Database Select types as filter source (in addition to basic text and list of text)
2320
+
2321
+ if (typeof v === 'object') {
2322
+ return !!(
2323
+ Object.values(v).find(oVal => (
2324
+ typeof oVal === 'string' || typeof oVal === 'number'
2325
+ ? (
2326
+ Array.isArray(filterResponse)
2327
+ ? (filterResponse as any[]).find(r => r === oVal.toString() || (typeof r === 'object' && r.text === oVal))
2328
+ : (typeof filterResponse === 'string' || typeof filterResponse === 'number')
2329
+ ? filterResponse.toString() === oVal.toString()
2330
+ : false
2331
+ )
2332
+ : false
2333
+ ))
2334
+ )
2335
+ }
2336
+
2337
+ if (typeof v === 'string' || typeof v === 'number') {
2338
+ return !!(
2339
+ Array.isArray(filterResponse)
2340
+ ? (filterResponse as any[]).find(r => r === v.toString() || (typeof r === 'object' && r.text === v))
2341
+ : (typeof filterResponse === 'string' || typeof filterResponse === 'number')
2342
+ ? filterResponse.toString() === v.toString()
2343
+ : (typeof filterResponse === 'object' && (filterResponse as Address).city === v.toString()) ? true
2344
+ : (typeof filterResponse === 'object' && (filterResponse as Address).state === v.toString()) ? true
2345
+ : (typeof filterResponse === 'object' && (filterResponse as Address).zipCode === v.toString()) ? true
2346
+ : false
2347
+ )
2348
+ }
2349
+
2350
+ return false
2351
+ })
2352
+ )
2353
+ }, [choices, filterResponse, field.options?.databaseFilter, value])
2354
+
2355
+ // Apply state filtering as a secondary filter (doesn't modify existing logic)
2356
+ const stateFilteredChoices = useMemo(() => {
2357
+ if (!field.options?.filterByEnduserState || !state) {
2358
+ return filteredChoicesWithPotentialDuplicates
2359
+ }
2360
+
2361
+ return filteredChoicesWithPotentialDuplicates.filter(c => {
2362
+ const recordState = c.values.find(v => v.label?.trim()?.toLowerCase() === 'state')?.value?.toString() || ''
2363
+ return !recordState || recordState === state
2364
+ })
2365
+ }, [filteredChoicesWithPotentialDuplicates, field.options?.filterByEnduserState, state])
2366
+
2367
+ const filteredChoices = useMemo(() => {
2368
+ const filtered = []
2369
+
2370
+ const uniques = new Set<string>([])
2371
+ for (const c of stateFilteredChoices) {
2372
+ const text = label_for_database_record(field, c)
2373
+ if (uniques.has(text)) continue // duplicate found
2374
+
2375
+ uniques.add(text)
2376
+ filtered.push(c)
2377
+ }
2378
+
2379
+ return filtered
2380
+ }, [field, stateFilteredChoices])
2381
+
2382
+ if (!doneLoading) return <LinearProgress />
2383
+ return (
2384
+ <>
2385
+ <Autocomplete id={field.id} freeSolo={false} size={size}
2386
+ componentsProps={{ popper: { sx: { wordBreak: "break-word" } } } }
2387
+ options={filteredChoices} multiple={true}
2388
+ getOptionLabel={o => (
2389
+ Array.isArray(o) // edge case
2390
+ ? ''
2391
+ : label_for_database_record(field, o)
2392
+ )}
2393
+ value={value}
2394
+ disabled={disabled}
2395
+ onChange={(_, v) => {
2396
+ if (v.length && onDatabaseSelect) {
2397
+ onDatabaseSelect(
2398
+ field.options?.radio
2399
+ ? [v[v.length - 1]] // if radio, only last selected
2400
+ : v
2401
+ )
2402
+ }
2403
+ return onChange(
2404
+ (
2405
+ !field.options?.radio
2406
+ ? v.map(_v => ({
2407
+ databaseId: field.options?.databaseId!,
2408
+ recordId: _v.id,
2409
+ text: label_for_database_record(field, _v),
2410
+ }))
2411
+ : [{
2412
+ databaseId: field.options?.databaseId!,
2413
+ recordId: v[v.length -1]?.id ?? '',
2414
+ text: label_for_database_record(field, v[v.length - 1]),
2415
+ }]
2416
+ ),
2417
+ field.id,
2418
+ )
2419
+ }}
2420
+ inputValue={typing}
2421
+ onInputChange={(e, v) => e && setTyping(v)}
2422
+ renderInput={params => <TextField {...params} InputProps={{ ...params.InputProps, sx: defaultInputProps.sx }} />}
2423
+ // use custom Chip to ensure very long entries break properly (whitespace: normal)
2424
+ renderTags={(value, getTagProps) =>
2425
+ value.map((value, index) => (
2426
+ <Chip
2427
+ label={<Typography style={{whiteSpace: 'normal'}}>{Array.isArray(value) ? '' : label_for_database_record(field, value)}</Typography>}
2428
+ {...getTagProps({ index })}
2429
+ sx={{height:"100%", py: 0.5 }}
2430
+ />
2431
+ ))
2432
+ }
2433
+ />
2434
+
2435
+ {AddToDatabase && field?.options?.allowAddToDatabase && (
2436
+ <AddToDatabase databaseId={field.options?.databaseId!} onAdd={addChoice} />
2437
+ )}
2438
+ </>
2439
+ )
2440
+ }
2441
+
2442
+ type DisplayTermsResult = { displayTermsList: { term: string[] } }
2443
+ type Drug = {
2444
+ rxcui: string,
2445
+ name: string,
2446
+ synonym?: string,
2447
+ }
2448
+ let displayTermsCache = undefined as DisplayTermsResult | undefined
2449
+ const DRUGS_FOR_DISPLAY_TERM = {} as Record<string, Drug[]>
2450
+ const RX_NORM_CODE_FOR_DRUG = {} as Record<string, string>
2451
+ const NDC_CODES_FOR_RX_NORM_CODE = {} as Record<string, string[]>
2452
+
2453
+ const useMedications = ({ dontFetch } : { dontFetch?: boolean }) => {
2454
+ const [displayTerms, setDisplayTerms] = useState(displayTermsCache)
2455
+ const fetchRef = useRef(displayTerms !== undefined)
2456
+
2457
+ useEffect(() => {
2458
+ if (dontFetch) return
2459
+ if (fetchRef.current) return
2460
+ fetchRef.current = true
2461
+
2462
+ // thankfully, this endpoint has cache control, so repeated requests should fetch from disk anyway
2463
+ axios.get('https://rxnav.nlm.nih.gov/REST/displaynames.json')
2464
+ .then(result =>
2465
+ setDisplayTerms({
2466
+ displayTermsList: {
2467
+ term: (
2468
+ result.data?.displayTermsList?.term?.filter(
2469
+ (t: string) => {
2470
+ try {
2471
+ // parse out some of the not immediately useful / non-human-readable options
2472
+ if (t.startsWith('(')) return false
2473
+ if (t.startsWith('.')) return false
2474
+ if (!isNaN(parseInt(t.charAt(0)))) return false // starts with a number
2475
+
2476
+ return true
2477
+ } catch(err) { return false }
2478
+ }
2479
+ )
2480
+ )
2481
+ }
2482
+ })
2483
+ )
2484
+ .catch(console.error)
2485
+ }, [dontFetch])
2486
+
2487
+ const getDrugsForDisplayTerm = useCallback(async (s: string) => {
2488
+ const drugs = DRUGS_FOR_DISPLAY_TERM[s] || (
2489
+ (
2490
+ await axios.get(`https://rxnav.nlm.nih.gov/REST/drugs.json?name=${s}`)
2491
+ )
2492
+ .data?.drugGroup?.conceptGroup?.find((v: any) => v.conceptProperties)?.conceptProperties as Drug[]
2493
+ )
2494
+ if (!DRUGS_FOR_DISPLAY_TERM[s]) {
2495
+ DRUGS_FOR_DISPLAY_TERM[s] = drugs // cache for future lookups
2496
+ }
2497
+
2498
+ return drugs
2499
+ }, [])
2500
+
2501
+ const getCodesForDrug = useCallback(async (s: string) => {
2502
+ // console.log(
2503
+ // (await axios.get(`https://rxnav.nlm.nih.gov/REST/rxcui.json?name=${s}&allsrc=1`)).data
2504
+ // )
2505
+ const rxNormCode = RX_NORM_CODE_FOR_DRUG[s] || (
2506
+ (
2507
+ await axios.get(`https://rxnav.nlm.nih.gov/REST/rxcui.json?name=${s}&allsrc=1`)
2508
+ )
2509
+ .data?.idGroup?.rxnormId?.[0] as string
2510
+ )
2511
+ RX_NORM_CODE_FOR_DRUG[s] = rxNormCode // cache for future lookups
2512
+
2513
+ // console.log(
2514
+ // `https://rxnav.nlm.nih.gov/REST/rxcui/${rxNormCode}/ndcs.json`,
2515
+ // (await axios.get(`https://rxnav.nlm.nih.gov/REST/rxcui/${rxNormCode}/ndcs.json`)).data
2516
+ // )
2517
+ const NDCs = NDC_CODES_FOR_RX_NORM_CODE[rxNormCode] || (
2518
+ (
2519
+ await axios.get(`https://rxnav.nlm.nih.gov/REST/rxcui/${rxNormCode}/ndcs.json`)
2520
+ )
2521
+ .data?.ndcGroup?.ndcList?.ndc as string[] ?? []
2522
+ )
2523
+ NDC_CODES_FOR_RX_NORM_CODE[rxNormCode] = NDCs // cache for future lookups
2524
+
2525
+ return {
2526
+ rxNormCode,
2527
+ NDCs,
2528
+ }
2529
+ }, [])
2530
+
2531
+ if (displayTerms === undefined) {
2532
+ return {
2533
+ displayTerms: undefined,
2534
+ doneLoading: false,
2535
+ getDrugsForDisplayTerm,
2536
+ getCodesForDrug,
2537
+ }
2538
+ }
2539
+ return {
2540
+ displayTerms,
2541
+ doneLoading: true,
2542
+ getDrugsForDisplayTerm,
2543
+ getCodesForDrug,
2544
+ }
2545
+ }
2546
+
2547
+ const filterOptions = (options: string[], { inputValue } : { inputValue: string }) => (
2548
+ (
2549
+ inputValue
2550
+ ? (
2551
+ options
2552
+ .filter(o => o.toLowerCase().includes(inputValue.toLowerCase()))
2553
+ // show shorter matches first (tends to promote exact match and simpler medications)
2554
+ .sort((v1, v2) => v1.length - v2.length)
2555
+ // .reverse()
2556
+ ) : (
2557
+ options
2558
+ )
2559
+ )
2560
+ .slice(0, 100) // dramatic performance improvement (when not virtualized) to show a subset like this
2561
+ )
2562
+
2563
+ const FDB_URL = "http://www.fdbhealth.com/"
2564
+ type CanvasMedicationResult = {
2565
+ entry?: { resource: { code: { coding: { system: string, code: string, display: string } []}} }[]
2566
+ }
2567
+
2568
+ export const CanvasMedicationsInput = ({ field, value=[], onChange }: FormInputProps<'Medications'>) => {
2569
+ const session = useResolvedSession()
2570
+ const [query, setQuery] = useState('')
2571
+ const [results, setResults] = useState<MedicationResponse[]>([])
2572
+
2573
+ // if two Medications questions shown in a row, reset state
2574
+ useEffect(() => {
2575
+ setQuery('')
2576
+ setResults([])
2577
+ }, [field.id])
2578
+
2579
+ const fetchRef = useRef(query)
2580
+ useEffect(() => {
2581
+ if (fetchRef.current === query) return
2582
+ fetchRef.current = query
2583
+
2584
+ if (!query) return
2585
+
2586
+ const t = setTimeout(() => {
2587
+ session.api.integrations
2588
+ .proxy_read({
2589
+ integration: CANVAS_TITLE,
2590
+ type: 'medications',
2591
+ query,
2592
+ })
2593
+ .then((r : { data: CanvasMedicationResult }) => {
2594
+ setResults(
2595
+ (r.data?.entry || [])
2596
+ .map(v => {
2597
+ const fdbCode = v.resource.code.coding.find(c => c.system === FDB_URL)
2598
+
2599
+ return {
2600
+ displayTerm: fdbCode?.display || '',
2601
+ drugName: fdbCode?.display || '',
2602
+ fdbCode: fdbCode?.code || '',
2603
+ }
2604
+ })
2605
+ )
2606
+ })
2607
+ }, 200)
2608
+
2609
+ return () => { clearTimeout(t) }
2610
+ }, [session, query, field?.options?.dataSource])
2611
+
2612
+ return (
2613
+ <Grid container direction="column" spacing={1}>
2614
+ <Grid item>
2615
+ <Autocomplete multiple value={value} options={results} style={{ marginTop: 5 }}
2616
+ noOptionsText={query.length ? 'No results found' : 'Type to start search'}
2617
+ onChange={(e, v) => {
2618
+ if (!v) { return }
2619
+ onChange(v, field.id)
2620
+ setResults([])
2621
+ }}
2622
+ getOptionLabel={v => first_letter_capitalized(v.displayTerm)} filterOptions={o => o}
2623
+ inputValue={query} onInputChange={(e, v) => e && setQuery(v) }
2624
+ renderInput={(params) => (
2625
+ <TextField {...params} InputProps={{ ...params.InputProps, sx: defaultInputProps.sx }}
2626
+ required={!field.isOptional} size="small" label="" placeholder="Search medications..."
2627
+ />
2628
+ )}
2629
+ renderTags={(value, getTagProps) =>
2630
+ value.map((value, index) => (
2631
+ <Chip
2632
+ label={<Typography style={{whiteSpace: 'normal'}}>{value.displayTerm}</Typography>}
2633
+ {...getTagProps({ index })}
2634
+ sx={{height:"100%", py: 0.5 }}
2635
+ />
2636
+ ))
2637
+ }
2638
+ />
2639
+ </Grid>
2640
+
2641
+ {(value || []).map((medication, i) => (
2642
+ <Grid item key={i}>
2643
+ <Grid container direction="column" spacing={0.75}>
2644
+ <Grid item>
2645
+ <Typography noWrap sx={{ fontSize: 14 }}>
2646
+ {medication.drugName}
2647
+ </Typography>
2648
+ </Grid>
2649
+
2650
+ <Grid item>
2651
+ <TextField InputProps={{ sx: defaultInputProps.sx }} fullWidth size="small"
2652
+ label="Medication instructions: how much you take, how often, and when"
2653
+ value={medication.dosage?.description || ''}
2654
+ onChange={e => (
2655
+ onChange((value || []).map((v, _i) =>
2656
+ i === _i
2657
+ ? { ...v, dosage: { ...v.dosage!, description: e.target.value } }
2658
+ : v
2659
+ ),
2660
+ field.id
2661
+ )
2662
+ )} />
2663
+ </Grid>
2664
+
2665
+ <Grid item>
2666
+ <Divider flexItem sx={{ my: 0.5 }} />
2667
+ </Grid>
2668
+ </Grid>
2669
+ </Grid>
2670
+ ))}
2671
+ </Grid>
2672
+ )
2673
+ }
2674
+
2675
+ export const MedicationsInput = ({ field, value, onChange, ...props }: FormInputProps<'Medications'>) => {
2676
+ const { displayTerms, doneLoading, getCodesForDrug, getDrugsForDisplayTerm } = useMedications({
2677
+ dontFetch: field.options?.dataSource === CANVAS_TITLE
2678
+ })
2679
+ const [drugs, setDrugs] = useState<Record<string, Drug[]>>({})
2680
+
2681
+ // uncomment to load data after initial typing
2682
+ // const [query, setQuery] = useState('')
2683
+
2684
+ // useEffect(() => {
2685
+ // if (!value?.length) return
2686
+
2687
+ // Promise.all((value ?? []).map(v => (
2688
+ // v.displayTerm ? getDrugsForDisplayTerm(v.displayTerm) : null
2689
+ // )))
2690
+ // .then(values => {
2691
+ // const toSet: typeof drugs = {}
2692
+ // values.forEach((v, i) => {
2693
+ // toSet[value[i].displayTerm] = v ?? []
2694
+ // if (!v?.length) {
2695
+ // // drug is unknown, and previously looked-up NDCs and rxNormCode should be reset
2696
+ // value[i].drugName = "Unknown"
2697
+ // value[i].NDCs = []
2698
+ // value[i].rxNormCode = ''
2699
+ // }
2700
+ // })
2701
+
2702
+ // setDrugs(toSet)
2703
+ // })
2704
+ // .catch(console.error)
2705
+ // }, [value, getDrugsForDisplayTerm])
2706
+
2707
+ if (field.options?.dataSource === CANVAS_TITLE) {
2708
+ return <CanvasMedicationsInput field={field} value={value} onChange={onChange} {...props} />
2709
+ }
2710
+ return (
2711
+ <Grid container direction="column" sx={{ mt: 2 }}>
2712
+ {(value ?? []).map((v, i) => (
2713
+ <>
2714
+ <Grid item key={i}>
2715
+ <Grid container alignItems="center" wrap="nowrap">
2716
+ <Grid item sx={{ width: '100%'}}>
2717
+ <Grid container direction="column">
2718
+ <Grid item>
2719
+ <Autocomplete freeSolo={false} multiple={false} loading={!doneLoading}
2720
+ options={
2721
+ // uncomment to load data after initial typing
2722
+ // query.length === 0 ? [] :
2723
+ (displayTerms?.displayTermsList?.term ?? [])
2724
+ }
2725
+ // uncomment to load data after initial typing
2726
+ // noOptionsText={query.length === 0 ? "Start typing..." : undefined}
2727
+ // uncomment to load data after initial typing
2728
+ // inputValue={query} onInputChange={(e, v) => setQuery(v)}
2729
+ getOptionLabel={first_letter_capitalized}
2730
+ filterOptions={filterOptions}
2731
+ value={v.displayTerm}
2732
+ onChange={async (_, displayTerm) => {
2733
+ const drugs = displayTerm ? await getDrugsForDisplayTerm(displayTerm) : null
2734
+
2735
+ if (displayTerm) {
2736
+ setDrugs((ds) => ({
2737
+ ...ds,
2738
+ [displayTerm]: drugs ?? [],
2739
+ }))
2740
+ }
2741
+
2742
+ onChange(
2743
+ (value ?? []).map((_v, _i) => (
2744
+ i === _i
2745
+ ? {
2746
+ ..._v,
2747
+ displayTerm: displayTerm || '',
2748
+ drugName: drugs?.length ? '' : "Unknown",
2749
+ drugSynonym: '',
2750
+ reasonForTaking: '',
2751
+ dosage: {
2752
+ unit: '',
2753
+ value: '',
2754
+ quantity: '',
2755
+ frequency: '',
2756
+ },
2757
+ // reset these on new search term to avoid stale data
2758
+ NDCs: [],
2759
+ rxNormCode: '',
2760
+ }
2761
+ : _v
2762
+ )),
2763
+ field.id,
2764
+ )
2765
+ }}
2766
+ renderInput={params =>
2767
+ <TextField {...params} InputProps={{ ...params.InputProps, sx: defaultInputProps.sx }} required={!field.isOptional} label="Search" size="small" fullWidth />
2768
+ }
2769
+ />
2770
+ </Grid>
2771
+
2772
+ {v.displayTerm && v.drugName !== "Unknown" && !v.otherDrug &&
2773
+ <Grid item sx={{ mt: 1.5 }}>
2774
+ <Autocomplete freeSolo={false}
2775
+ options={
2776
+ drugs[v.displayTerm]
2777
+ ? drugs[v.displayTerm].length
2778
+ ? drugs[v.displayTerm]
2779
+ : [{ name: 'Unknown', rxcui: '' }]
2780
+ : [] // still loading
2781
+ }
2782
+ multiple={false}
2783
+ getOptionLabel={d => d?.synonym ? d.synonym : (d?.name || '')}
2784
+ value={
2785
+ [...drugs[v.displayTerm] ?? [], { name: "Unknown", rxcui: '' }]
2786
+ .find(d => d.name === v.drugName) ?? null
2787
+ }
2788
+ onChange={async (_, drug) => {
2789
+ if (!drug) return
2790
+
2791
+ const info = (
2792
+ drug.name === 'Unknown'
2793
+ ? await getCodesForDrug(v.displayTerm) // might get us a value, better than searching Unknown or keeping a prior value
2794
+ : await getCodesForDrug(drug.name)
2795
+ )
2796
+ onChange(
2797
+ (value ?? []).map((_v, _i) => (
2798
+ i === _i
2799
+ ? {
2800
+ ..._v,
2801
+ drugName: drug.name,
2802
+ drugSynonym: drug.synonym || '',
2803
+ ...info,
2804
+ }
2805
+ : _v
2806
+ )),
2807
+ field.id,
2808
+ )
2809
+ }}
2810
+ renderInput={params =>
2811
+ <TextField {...params} InputProps={{ ...params.InputProps, sx: defaultInputProps.sx }} required={!field.isOptional} label="Drug Select" size="small" fullWidth />
2812
+ }
2813
+ />
2814
+ </Grid>
2815
+ }
2816
+
2817
+ {v.displayTerm && (v.drugName === "Unknown" || !v.drugName) &&
2818
+ <Grid item sx={{ mt: 1 }}>
2819
+ <TextField label='Other Drug' fullWidth size="small" required
2820
+ InputProps={defaultInputProps}
2821
+ value={value?.find((v, _i) => _i === i)?.otherDrug ?? ''}
2822
+ onChange={e => (
2823
+ onChange(
2824
+ (value ?? []).map((_v, _i) => (
2825
+ i === _i
2826
+ ? {
2827
+ ..._v,
2828
+ otherDrug: e.target.value
2829
+ }
2830
+ : _v
2831
+ )),
2832
+ field.id,
2833
+ )
2834
+ )}
2835
+ />
2836
+ </Grid>
2837
+ }
2838
+
2839
+
2840
+ {v.displayTerm &&
2841
+ <Grid container spacing={1} sx={{ mt: 0 }}>
2842
+ <Grid item xs={12} md={6}>
2843
+ <Typography sx={{ fontSize: 13.5 }}>
2844
+ Units (e.g. capsule, table, puff) per dose?
2845
+ </Typography>
2846
+ <TextField type="number" size="small" fullWidth
2847
+ InputProps={defaultInputProps}
2848
+ value={v.dosage?.quantity}
2849
+ onChange={e =>
2850
+ onChange(
2851
+ (value ?? []).map((_v, _i) => (
2852
+ i === _i
2853
+ ? {
2854
+ ..._v,
2855
+ dosage: {
2856
+ ..._v.dosage!,
2857
+ quantity: e.target.value,
2858
+ },
2859
+ }
2860
+ : _v
2861
+ )),
2862
+ field.id,
2863
+ )
2864
+ }
2865
+ // hide arrows for number input, which continue to increase after initial press
2866
+ sx={{
2867
+ '& input[type=number]': {
2868
+ '-moz-appearance': 'textfield'
2869
+ },
2870
+ '& input[type=number]::-webkit-outer-spin-button': {
2871
+ '-webkit-appearance': 'none',
2872
+ margin: 0
2873
+ },
2874
+ '& input[type=number]::-webkit-inner-spin-button': {
2875
+ '-webkit-appearance': 'none',
2876
+ margin: 0
2877
+ }
2878
+ }}
2879
+ />
2880
+ </Grid>
2881
+
2882
+ <Grid item xs={12} md={6}>
2883
+ <Typography sx={{ fontSize: 13.5 }}>
2884
+ How many times per <strong>day</strong>?
2885
+ </Typography>
2886
+ <StringSelector size="small"
2887
+ options={["1", "2", "3", "4", "5", "6", "As Needed"]}
2888
+ value={v.dosage?.frequency ?? ''}
2889
+ onChange={async (frequency) => {
2890
+ onChange(
2891
+ (value ?? []).map((_v, _i) => (
2892
+ i === _i
2893
+ ? {
2894
+ ..._v,
2895
+ dosage: {
2896
+ ..._v.dosage!,
2897
+ frequency: frequency || ''
2898
+ }
2899
+ }
2900
+ : _v
2901
+ )),
2902
+ field.id,
2903
+ )
2904
+ }}
2905
+ />
2906
+ </Grid>
2907
+ </Grid>
2908
+ }
2909
+
2910
+ {v.displayTerm &&
2911
+ <Grid item sx={{ mt: 1.25 }}>
2912
+ <TextField label="Reason for taking medication" size="small" fullWidth
2913
+ InputProps={defaultInputProps}
2914
+ value={v.reasonForTaking ?? ''}
2915
+ onChange={e =>
2916
+ onChange(
2917
+ (value ?? []).map((_v, _i) => (
2918
+ i === _i
2919
+ ? {
2920
+ ..._v,
2921
+ reasonForTaking: e.target.value,
2922
+ }
2923
+ : _v
2924
+ )),
2925
+ field.id,
2926
+ )
2927
+ }
2928
+ />
2929
+ </Grid>
2930
+ }
2931
+
2932
+ <Grid item>
2933
+ <Typography color="primary" sx={{ textDecoration: 'underline', cursor: 'pointer' }}
2934
+ onClick={() => onChange((value ?? []).filter((_, _i) => i !== _i), field.id)}
2935
+ >
2936
+ Remove medication
2937
+ </Typography>
2938
+ </Grid>
2939
+
2940
+ {window.location.origin.includes(':300') && i === 0 &&
2941
+ <Grid item sx={{ mt: 3 }}>
2942
+ <strong>DEBUG:</strong> <br />
2943
+ <pre style={{ wordWrap: 'break-word' }}>
2944
+ {JSON.stringify(value ?? {}, null, 2)}
2945
+ </pre>
2946
+ </Grid>
2947
+ }
2948
+
2949
+ <Grid item>
2950
+ <Grid container>
2951
+
2952
+ </Grid>
2953
+ </Grid>
2954
+ </Grid>
2955
+ </Grid>
2956
+ </Grid>
2957
+ </Grid>
2958
+
2959
+ <Grid item><Divider flexItem sx={{ my: 1 }} /></Grid>
2960
+ </>
2961
+ ))}
2962
+
2963
+ <Grid item>
2964
+ <Button color="primary" variant="outlined"
2965
+ onClick={() => onChange([...(value ?? []), { displayTerm: '', drugName: '' }], field.id)}
2966
+ >
2967
+ Add Medication
2968
+ </Button>
2969
+ </Grid>
2970
+ </Grid>
2971
+ )
2972
+ }
2973
+
2974
+ export const contact_is_valid = (e: Partial<Enduser>) => {
2975
+ if (e.email) {
2976
+ try {
2977
+ emailValidator.validate()(e.email)
2978
+ } catch(err) {
2979
+ return "Email is invalid"
2980
+ }
2981
+ }
2982
+ if (e.phone) {
2983
+ try {
2984
+ phoneValidator.validate()(e.phone)
2985
+ } catch(err) {
2986
+ return "Phone is invalid"
2987
+ }
2988
+ }
2989
+ if (e.dateOfBirth && !isDateString(e.dateOfBirth)) {
2990
+ return "Date of birth should be MM-DD-YYYY"
2991
+ }
2992
+ }
2993
+
2994
+ export const RelatedContactsInput = ({ field, value: _value, onChange, ...props }: FormInputProps<'Related Contacts'>) => {
2995
+ // safeguard against any rogue values like empty string
2996
+ const value = Array.isArray(_value) ? _value : []
2997
+
2998
+ const [editing, setEditing] = useState(value.length === 1 ? 0 : -1)
2999
+
3000
+ const handleAddContact = useCallback(() => {
3001
+ onChange([
3002
+ ...value,
3003
+ { relationships: field?.options?.relatedContactTypes?.length === 1 ? [{ type: field.options.relatedContactTypes[0] as EnduserRelationship['type'], id: ''! } ] : [] }],
3004
+ field.id,
3005
+ true
3006
+ )
3007
+ setEditing(value.length)
3008
+ }, [onChange, value, field?.id, field?.options?.relatedContactTypes])
3009
+
3010
+ if (value[editing]) {
3011
+ const { fname, lname, email, phone, fields={}, dateOfBirth='', relationships } = value[editing]
3012
+ const errorMessage = contact_is_valid(value[editing])
3013
+
3014
+ return (
3015
+ <Grid container direction="column" spacing={1}>
3016
+ <Grid item>
3017
+ <Grid container alignItems="center" wrap="nowrap" spacing={1}>
3018
+ {!field.options?.hiddenDefaultFields?.includes('First Name') &&
3019
+ <Grid item xs={4}>
3020
+ <TextField label="First Name" size="small" fullWidth
3021
+ InputProps={defaultInputProps}
3022
+ value={fname} onChange={e => onChange(value.map((v, i) => i === editing ? { ...v, fname: e.target.value } : v), field.id)}
3023
+ />
3024
+ </Grid>
3025
+ }
3026
+
3027
+ {!field.options?.hiddenDefaultFields?.includes('Last Name') &&
3028
+ <Grid item xs={4}>
3029
+ <TextField label="Last Name" size="small" fullWidth
3030
+ InputProps={defaultInputProps}
3031
+ value={lname} onChange={e => onChange(value.map((v, i) => i === editing ? { ...v, lname: e.target.value } : v), field.id)}
3032
+ />
3033
+ </Grid>
3034
+ }
3035
+
3036
+ <Grid item xs={4}>
3037
+ <StringSelector options={field.options?.relatedContactTypes?.length ? field.options.relatedContactTypes : RELATIONSHIP_TYPES} label="Relationship" size="small"
3038
+ disabled={field?.options?.relatedContactTypes?.length === 1}
3039
+ value={relationships?.[0]?.type ?? ''}
3040
+ onChange={type => onChange(value.map((v, i) => i === editing ? { ...v, relationships: [{ type: type as EnduserRelationship['type'], id: '' /* to be filled on server-side */ }] } : v), field.id)}
3041
+ />
3042
+ </Grid>
3043
+ </Grid>
3044
+ </Grid>
3045
+
3046
+ <Grid item>
3047
+ <Grid container alignItems="center" wrap="nowrap" spacing={1}>
3048
+ {!field.options?.hiddenDefaultFields?.includes('Date of Birth') &&
3049
+ <Grid item xs={4}>
3050
+ <DateStringInput value={dateOfBirth} field={{ ...field, isOptional: true }} size="small" label="Date of Birth (MM-DD-YYYY)"
3051
+ onChange={dateOfBirth => onChange(value.map((v, i) => i === editing ? { ...v, dateOfBirth } : v), field.id)}
3052
+ />
3053
+ </Grid>
3054
+ }
3055
+
3056
+ {!field.options?.hiddenDefaultFields?.includes('Email') &&
3057
+ <Grid item xs={4}>
3058
+ <TextField label="Email" size="small" fullWidth type="email"
3059
+ InputProps={defaultInputProps}
3060
+ value={email} onChange={e => onChange(value.map((v, i) => i === editing ? { ...v, email: e.target.value } : v), field.id)}
3061
+ />
3062
+ </Grid>
3063
+ }
3064
+
3065
+ {!field.options?.hiddenDefaultFields?.includes('Phone Number') &&
3066
+ <Grid item xs={4}>
3067
+ <TextField label="Phone Number" size="small" fullWidth
3068
+ InputProps={defaultInputProps}
3069
+ value={phone} onChange={e => onChange(value.map((v, i) => i === editing ? { ...v, phone: e.target.value } : v), field.id)}
3070
+ />
3071
+ </Grid>
3072
+ }
3073
+ </Grid>
3074
+ </Grid>
3075
+
3076
+ {/* todo: refactor instead of copying from table input code? */}
3077
+ {(field.options?.tableChoices || []).length > 0 &&
3078
+ <Grid item>
3079
+ <Grid container spacing={1}>
3080
+ {(field.options?.tableChoices || []).map(({ info, label, type}, i) => (
3081
+ <Grid item xs={6} key={i}>
3082
+ {type === 'Text'
3083
+ ? (
3084
+ <TextField label={label} size="small" fullWidth
3085
+ InputProps={defaultInputProps}
3086
+ value={fields[label] as string || ''}
3087
+ onChange={e => onChange(value.map((v, i) => i === editing ? { ...v, fields: { ...fields, [label]: e.target.value} } : v), field.id)}
3088
+ />
3089
+ )
3090
+ : type === 'Date' ? (
3091
+ <DateStringInput label={label} size="small" fullWidth
3092
+ field={{ ...field, isOptional: true }}
3093
+ value={fields[label] as string || ''}
3094
+ onChange={(e='') => onChange(value.map((v, i) => i === editing ? { ...v, fields: { ...fields, [label]: e } } : v), field.id)}
3095
+ />
3096
+ )
3097
+ : type === 'Select' ? (
3098
+ <FormControl size="small" fullWidth>
3099
+ <InputLabel id="demo-select-small">{label}</InputLabel>
3100
+ <Select label={label} size="small"
3101
+ sx={defaultInputProps.sx}
3102
+ value={fields[label] as string || ''}
3103
+ onChange={e => onChange(value.map((v, i) => i === editing ? { ...v, fields: { ...fields, [label]: e.target.value } } : v), field.id)}
3104
+ >
3105
+ <MenuItem value="">
3106
+ <em>None</em>
3107
+ </MenuItem>
3108
+ {info.choices.map(c => (
3109
+ <MenuItem key={c} value={c}>{c}</MenuItem>
3110
+ ))}
3111
+ </Select>
3112
+ </FormControl>
3113
+ )
3114
+ : null
3115
+ }
3116
+ </Grid>
3117
+ ))}
3118
+ </Grid>
3119
+ </Grid>
3120
+ }
3121
+
3122
+ <Grid item sx={{ my: 0.75 }}>
3123
+ <Button variant="outlined" onClick={() => setEditing(-1)} size="small">
3124
+ Save Contact
3125
+ </Button>
3126
+ </Grid>
3127
+
3128
+ {errorMessage &&
3129
+ <Grid item>
3130
+ <Typography color="error">
3131
+ {errorMessage}
3132
+ </Typography>
3133
+ </Grid>
3134
+ }
3135
+ </Grid>
3136
+ )
3137
+ }
3138
+
3139
+ return (
3140
+ <Grid container direction="column" spacing={1}>
3141
+ <Grid item>
3142
+ {value.map((contact, i) => (
3143
+ <Grid item key={i}>
3144
+ <Grid container alignItems="center" justifyContent={"space-between"} wrap="nowrap" spacing={1}>
3145
+ <Grid item>
3146
+ <Grid container alignItems="center">
3147
+ <IconButton onClick={() => setEditing(i)} color="primary" size="small">
3148
+ <Edit />
3149
+ </IconButton>
3150
+ <Typography noWrap>
3151
+ {user_display_name(contact) || `Unnamed Contact ${i + 1}`}
3152
+ </Typography>
3153
+ </Grid>
3154
+ </Grid>
3155
+
3156
+ <Grid item>
3157
+ <LabeledIconButton Icon={Delete} label="Remove" onClick={() => onChange(value.filter((v, _i) => i !== _i), field.id)} />
3158
+ </Grid>
3159
+ </Grid>
3160
+ </Grid>
3161
+ ))}
3162
+ </Grid>
3163
+
3164
+ <Grid item>
3165
+ <Button variant="contained" onClick={handleAddContact}>
3166
+ Add Contact
3167
+ </Button>
3168
+ </Grid>
3169
+ </Grid>
3170
+ )
3171
+ }
3172
+
3173
+ export const AppointmentBookingInput = ({ formResponseId, field, value, onChange, form, responses, goToPreviousField, isPreviousDisabled, enduserId, ...props }: FormInputProps<'Appointment Booking'>) => {
3174
+ const session = useResolvedSession()
3175
+
3176
+ const [loaded, setLoaded] = useState<Awaited<ReturnType<typeof session['api']['form_fields']['booking_info']>>>()
3177
+ const [error, setError] = useState('')
3178
+ const [acknowledgedWarning, setAcknowledgedWarning] = useState(false)
3179
+ const [height, setHeight] = useState(450)
3180
+ const [confirming, setConfirming] = useState(false)
3181
+
3182
+ const bookingPageId = field?.options?.bookingPageId
3183
+
3184
+
3185
+ const downloadICS = useCallback(async (event : Pick<CalendarEvent, 'id'>) => {
3186
+ try {
3187
+ downloadFile(
3188
+ await session.api.calendar_events.download_ics_file({ calendarEventId: event.id, excludeAttendee: true }) as any,
3189
+ { name: "event.ics", dataIsURL: true, type: 'text/calendar'}
3190
+ )
3191
+ } catch(err) {
3192
+ console.error(err)
3193
+ }
3194
+ }, [session])
3195
+
3196
+ const addressQuestion = useMemo(() => responses?.find(r => {
3197
+ if (r.answer.type !== 'Address') return false
3198
+ if (r.field.intakeField !== 'Address') return false
3199
+
3200
+ // make sure state is actually defined (in case of multiple address questions, where 1+ are blank)
3201
+ if (!r.answer.value?.state) return false
3202
+
3203
+ return true
3204
+ }), [responses])
3205
+ const state = useMemo(() => (
3206
+ addressQuestion?.answer?.type === 'Address' ? addressQuestion?.answer?.value?.state : undefined
3207
+ ), [addressQuestion])
3208
+
3209
+ const loadBookingInfo = useCallback(() => {
3210
+ if (!bookingPageId) return
3211
+
3212
+ setError('')
3213
+ session.api.form_fields.booking_info({
3214
+ enduserId,
3215
+ bookingPageId,
3216
+ enduserFields: { state }
3217
+ })
3218
+ .then(setLoaded)
3219
+ .catch(e => setError(e?.message || e?.toString() || 'Error loading appointment details'))
3220
+ }, [enduserId, bookingPageId, session, state])
3221
+
3222
+ const fetchRef = useRef(false)
3223
+ useEffect(() => {
3224
+ if (value) return
3225
+ if (!bookingPageId) return
3226
+ if (fetchRef.current) return
3227
+ fetchRef.current = true
3228
+
3229
+ loadBookingInfo()
3230
+ }, [bookingPageId, loadBookingInfo, value])
3231
+
3232
+ useEffect(() => {
3233
+ const handleMessage = (m: MessageEvent) => {
3234
+ // entropy to separate from other booking pages rendered on the same screen
3235
+ if (
3236
+ m?.data?.type === 'Booking Success'
3237
+ && typeof m?.data?.bookedEventId === 'string'
3238
+ && (!m?.data?.entropy || m?.data?.entropy === loaded?.entropy)
3239
+ ) {
3240
+ onChange(m.data.bookedEventId, field.id)
3241
+ emit_gtm_event({ event: 'form_progress', fieldId: field.id, formId: field.formId, title: field.title, status: "Appointment Booked" })
3242
+ }
3243
+ if (m?.data?.type === 'CalendarPicker') {
3244
+ setHeight(750)
3245
+ }
3246
+ if (m?.data?.type === 'UsersPicker') {
3247
+ setHeight(450)
3248
+ }
3249
+ if (m?.data?.type === 'Confirmation') {
3250
+ setConfirming(true)
3251
+ }
3252
+ if (m?.data?.type === 'Join Link' && m?.data?.link) {
3253
+ update_local_storage('tellescope_last_booking_page_join_link', m.data.link)
3254
+ }
3255
+ else {
3256
+ setConfirming(false)
3257
+ }
3258
+ }
3259
+
3260
+ window.addEventListener('message', handleMessage)
3261
+ return () => { window.removeEventListener('message', handleMessage) }
3262
+ }, [field?.id, field?.formId, field?.title, onChange, acknowledgedWarning, value, loaded?.entropy])
3263
+
3264
+ if (value) {
3265
+ return (
3266
+ <Grid container direction="column" spacing={1}>
3267
+ <Grid item>
3268
+ <Grid container alignItems="center" wrap="nowrap">
3269
+ <CheckCircleOutline color="success" />
3270
+
3271
+ <Typography sx={{ ml: 1, fontSize: 20 }}>
3272
+ Your appointment has been booked
3273
+ </Typography>
3274
+ </Grid>
3275
+ </Grid>
3276
+
3277
+ <Grid item sx={{ maxWidth: 250 }}>
3278
+ <LoadingButton variant="contained" style={{ maxWidth: 250 }}
3279
+ submitText="Add to Calendar" submittingText="Downloading..."
3280
+ onClick={() => downloadICS({ id: value })}
3281
+ />
3282
+ </Grid>
3283
+ </Grid>
3284
+ )
3285
+ }
3286
+ if (!bookingPageId) {
3287
+ return <Typography>No booking page specified</Typography>
3288
+ }
3289
+ if (error) {
3290
+ return (
3291
+ <Grid container direction="column" spacing={1}>
3292
+ <Grid item>
3293
+ <Typography color="error">Error: {error}</Typography>
3294
+ </Grid>
3295
+
3296
+ <Grid item>
3297
+ <LoadingButton disabled={!bookingPageId} style={{ maxWidth: 300 }}
3298
+ variant="contained" onClick={loadBookingInfo}
3299
+ submitText="Try Again" submittingText="Loading..."
3300
+ />
3301
+ </Grid>
3302
+ </Grid>
3303
+ )
3304
+ }
3305
+ if (!loaded?.bookingURL) {
3306
+ return <LinearProgress />
3307
+ }
3308
+
3309
+ let bookingURL = loaded.bookingURL
3310
+ if (field.options?.userTags?.length) {
3311
+ bookingURL += `&userTags=${
3312
+ field.options.userTags
3313
+ .flatMap(t => {
3314
+ // set dynamic tags if found
3315
+ if (t === '{{logic}}') {
3316
+ return new URL(window.location.href).searchParams.get('logic') || '{{logic}}'
3317
+ }
3318
+ if (t.startsWith("{{field.") && t.endsWith(".value}}")) {
3319
+ const fieldId = t.replace('{{field.', '').replace(".value}}", '')
3320
+
3321
+ const answer = responses?.find(r => r.fieldId === fieldId)?.answer
3322
+ if (!answer?.value) return t
3323
+
3324
+ if (answer.type === 'Insurance') {
3325
+ return answer.value.payerName || ''
3326
+ }
3327
+ if (Array.isArray(answer.value) && typeof answer.value?.[0] === 'string') {
3328
+ return answer.value as string[]
3329
+ }
3330
+ return form_response_value_to_string(answer.value)
3331
+ }
3332
+ return t
3333
+ })
3334
+ .join(',')
3335
+ }`
3336
+ }
3337
+ if (field.options?.userFilterTags?.length) {
3338
+ bookingURL += `&userFilterTags=${
3339
+ field.options.userFilterTags
3340
+ .flatMap(t => {
3341
+ // set dynamic tags if found
3342
+ if (t === '{{logic}}') {
3343
+ return new URL(window.location.href).searchParams.get('logic') || '{{logic}}'
3344
+ }
3345
+ if (t.startsWith("{{field.") && t.endsWith(".value}}")) {
3346
+ const fieldId = t.replace('{{field.', '').replace(".value}}", '')
3347
+
3348
+ const answer = responses?.find(r => r.fieldId === fieldId)?.answer
3349
+ if (!answer?.value) return t
3350
+
3351
+ if (answer.type === 'Insurance') {
3352
+ return answer.value.payerName || ''
3353
+ }
3354
+ if (Array.isArray(answer.value) && typeof answer.value?.[0] === 'string') {
3355
+ return answer.value as string[]
3356
+ }
3357
+ return form_response_value_to_string(answer.value)
3358
+ }
3359
+ return t
3360
+ })
3361
+ .join(',')
3362
+ }`
3363
+ }
3364
+ // need to use form?.id for internally-submitted forms because formResponseId isn't generated until initial submission or saved draft
3365
+ if (field.options?.holdAppointmentMinutes && (formResponseId || field?.id)) {
3366
+ bookingURL += `&formResponseId=${formResponseId || field?.id}`
3367
+ bookingURL += `&holdAppointmentMinutes=${field.options.holdAppointmentMinutes}`
3368
+ }
3369
+
3370
+ return (
3371
+ <Grid container direction="column" spacing={1} sx={{ mt: 1 }}>
3372
+ {/* When skipping user selection, include a back button at the top for clearer navigation on mobile */}
3373
+ {!!field.options?.userFilterTags?.length && !field.options.userTags?.length && !isPreviousDisabled?.() && !confirming &&
3374
+ <Grid item alignSelf="flex-start" >
3375
+ <Button variant="outlined" onClick={goToPreviousField} sx={{ height: 25, p: 0.5, px: 1 }}>
3376
+ Back
3377
+ </Button>
3378
+ </Grid>
3379
+ }
3380
+
3381
+ {loaded.warningMessage &&
3382
+ <Grid item>
3383
+ <Typography color="error" sx={{ fontSize: 20, fontWeight: 'bold' }}>
3384
+ {loaded.warningMessage}
3385
+ </Typography>
3386
+ </Grid>
3387
+ }
3388
+
3389
+ <Grid item>
3390
+ {(!loaded.warningMessage || acknowledgedWarning)
3391
+ ? (
3392
+ <iframe title="Appointment Booking Embed"
3393
+ src={bookingURL}
3394
+ style={{ border: 'none', width: '100%', height }}
3395
+ />
3396
+ )
3397
+ : (
3398
+ <Button variant="outlined" onClick={() => setAcknowledgedWarning(true)}>
3399
+ Show Booking Page Preview
3400
+ </Button>
3401
+ )
3402
+ }
3403
+ </Grid>
3404
+ </Grid>
3405
+ )
3406
+ }
3407
+
3408
+ export const HeightInput = ({ field, value={} as any, onChange, ...props }: FormInputProps<'Height'>) => (
3409
+ <Grid container alignItems='center' wrap="nowrap" spacing={1} style={{ marginTop: 5 }}>
3410
+ <Grid item sx={{ width: '100%' }}>
3411
+ <TextField fullWidth size="small" label="Feet" type="number"
3412
+ value={value?.feet || ''}
3413
+ onChange={e => onChange({ ...value, feet: parseInt(e.target.value) }, field.id)}
3414
+ />
3415
+ </Grid>
3416
+ <Grid item sx={{ width: '100%' }}>
3417
+ <TextField fullWidth size="small" label="Inches" type="number"
3418
+ value={value?.inches ?? ''}
3419
+ onChange={e => onChange({ ...value, inches: parseInt(e.target.value) }, field.id)}
3420
+ />
3421
+ </Grid>
3422
+ </Grid>
3423
+ )
3424
+
3425
+ export const include_current_url_parameters_if_templated = (url: string ) => {
3426
+ try {
3427
+ // get parameters from the current URL, and replace all values where {{URL_PARAM.paramName}} is used
3428
+ const params = new URL(window.location.href).searchParams
3429
+ return url.replace(/{{URL_PARAM\.(.*?)}}/g, (_, paramName) => {
3430
+ const value = params.get(paramName)
3431
+ console.log(paramName, value)
3432
+ if (value === null) return ''
3433
+ return value
3434
+ })
3435
+
3436
+ } catch(err) {
3437
+ console.error(err)
3438
+ }
3439
+ return url
3440
+ }
3441
+
3442
+ export const RedirectInput = ({ enduserId, groupId, groupInsance, rootResponseId, formResponseId, field, submit, value={} as any, onChange, responses, enduser, ...props }: FormInputProps<'Redirect'>) => {
3443
+ const session = useResolvedSession()
3444
+
3445
+ let eId = ''
3446
+ try {
3447
+ eId = new URL(window.location.href).searchParams.get('eId') || enduserId || enduser?.id || ''
3448
+ } catch(err) {}
3449
+
3450
+ const email = (
3451
+ responses?.find(r => r.intakeField === 'email')?.answer?.value
3452
+ || enduser?.email
3453
+ || session.userInfo.email
3454
+ )
3455
+ const phone = (
3456
+ responses?.find(r => r.intakeField === 'phone')?.answer?.value
3457
+ || enduser?.phone
3458
+ || session.userInfo.phone
3459
+ )
3460
+ const fname = (
3461
+ responses?.find(r => r.intakeField === 'fname')?.answer?.value
3462
+ || enduser?.fname
3463
+ || session.userInfo?.fname
3464
+ )
3465
+ const lname = (
3466
+ responses?.find(r => r.intakeField === 'lname')?.answer?.value
3467
+ || enduser?.lname
3468
+ || session.userInfo?.lname
3469
+ )
3470
+ const state = (
3471
+ responses?.find(r => r.intakeField === 'state')?.answer?.value
3472
+ || (responses?.find(r => r.intakeField === 'Address')?.answer?.value as any)?.state
3473
+ || enduser?.state
3474
+ || (session.userInfo as Enduser)?.state
3475
+ )
3476
+
3477
+ useEffect(() => {
3478
+ if (session.type === 'user') { return }
3479
+
3480
+ if (field.options?.redirectExternalUrl) {
3481
+ submit?.()
3482
+ .finally(() => {
3483
+ if (!field.options?.redirectExternalUrl) { return }
3484
+
3485
+ window.location.href = (
3486
+ include_current_url_parameters_if_templated(
3487
+ replace_enduser_template_values(
3488
+ field.options.redirectExternalUrl,
3489
+ {
3490
+ ...session.userInfo as any,
3491
+ id: eId, email, fname, lname, state, phone,
3492
+ }
3493
+ )
3494
+ )
3495
+ )
3496
+ })
3497
+ .catch(console.error)
3498
+
3499
+ return
3500
+ }
3501
+
3502
+ if (!field.options?.redirectFormId) { return }
3503
+
3504
+ session.api.form_responses.prepare_form_response({
3505
+ enduserId: session.userInfo.id || eId,
3506
+ formId: field.options.redirectFormId,
3507
+ rootResponseId: rootResponseId || formResponseId,
3508
+ parentResponseId: formResponseId,
3509
+ })
3510
+ .then(({ fullURL }) => (
3511
+ // we should still redirect even if submission fails
3512
+ submit?.()
3513
+ .catch(console.error)
3514
+ .finally(() => {
3515
+ // if accessing form group in portal
3516
+ if (window.location.href.includes('/documents') && groupId && groupInsance) {
3517
+ const toRedirect = `${window.location.origin}/documents?groupId=${groupId}&groupInstance=${groupInsance}`
3518
+ if (fullURL.endsWith('&')) {
3519
+ window.location.replace(fullURL + `back=${toRedirect}&`)
3520
+ } else {
3521
+ window.location.replace(fullURL + `&back=${toRedirect}`)
3522
+ }
3523
+ } else {
3524
+ window.location.replace(fullURL)
3525
+ }
3526
+ })
3527
+ ))
3528
+ .catch(console.error)
3529
+ }, [session, email, fname, lname, state, phone])
3530
+
3531
+ if (session.type === 'user') {
3532
+ return (
3533
+ <Typography>
3534
+ Redirect is for patient-facing forms only
3535
+ </Typography>
3536
+ )
3537
+ }
3538
+
3539
+ return null
3540
+ }
3541
+
3542
+ export const HiddenValueInput = ({ goToNextField, goToPreviousField, field, value, onChange, isSinglePage, groupFields }: FormInputProps<'email'>) => {
3543
+ let lastRef = useRef(0)
3544
+ let lastIdRef = useRef('')
3545
+
3546
+ // in a Question Group, only the first Hidden Value should navigate
3547
+ // AND, it should only navigate if the group only contains hidden values
3548
+ const firstHiddenValue = groupFields?.find(v => v.type === 'Hidden Value')
3549
+ const dontNavigate = (
3550
+ (firstHiddenValue && firstHiddenValue?.id !== field.id) // is in a group, but not the first hidden value
3551
+ || !!(groupFields?.find(v => v.type !== 'Hidden Value')) // group contains at least 1 non-hidden value
3552
+ )
3553
+
3554
+ const publicIdentifier = useMemo(() => {
3555
+ try {
3556
+ return new URL(window.location.href).searchParams.get('publicIdentifier') || ''
3557
+ } catch(err) {
3558
+ return ''
3559
+ }
3560
+ }, [])
3561
+
3562
+ const valueToSet = useMemo(() => (
3563
+ (field.title === "{{PUBLIC_IDENTIFIER}}" && publicIdentifier) ? publicIdentifier
3564
+ : field.title
3565
+ ), [field.title, publicIdentifier])
3566
+
3567
+ useEffect(() => {
3568
+ if (lastRef.current > Date.now() - 1000 && lastIdRef.current === field.id) return
3569
+ lastRef.current = Date.now()
3570
+ lastIdRef.current = field.id
3571
+
3572
+ if (value) {
3573
+ if (isSinglePage) return
3574
+ onChange('', field.id)
3575
+
3576
+ if (dontNavigate) return
3577
+ goToPreviousField?.()
3578
+ } else {
3579
+ onChange(valueToSet, field.id)
3580
+
3581
+ if (dontNavigate) return
3582
+
3583
+ // pass value that is set after above onChange
3584
+ goToNextField?.({ type: 'Hidden Value', value: valueToSet })
3585
+ }
3586
+ }, [value, onChange, field.id, valueToSet, goToNextField, goToPreviousField, isSinglePage, dontNavigate])
3587
+
3588
+ return <></>
3589
+ }
3590
+
3591
+ export const EmotiiInput = ({ goToNextField, goToPreviousField, field, value, onChange, form, formResponseId, ...props }: FormInputProps<'email'>) => {
3592
+ const session = useResolvedSession()
3593
+ const requestIdRef = useRef(value || `${field.id}${formResponseId || Date.now()}`)
3594
+ const [data, setData] = useState<{ surveyRequestId: string, surveyUrl: string }>()
3595
+ const [loadCount, setLoadCount] = useState(0)
3596
+
3597
+ const fetchRef = useRef(false)
3598
+ useEffect(() => {
3599
+ if (value) return
3600
+ if (fetchRef.current) return
3601
+ fetchRef.current = true
3602
+
3603
+ session.api.integrations
3604
+ .proxy_read({
3605
+ integration: EMOTII_TITLE,
3606
+ type: 'get_survey',
3607
+ id: props?.enduserId, // defaults to session id when not defined
3608
+ query: requestIdRef.current,
3609
+ })
3610
+ .then(r => setData(r.data))
3611
+ }, [session, value, props?.enduserId])
3612
+
3613
+ const loadAnswerRef = useRef(false)
3614
+ useEffect(() => {
3615
+ if (loadCount !== 2) return
3616
+ if (loadAnswerRef.current) return
3617
+ loadAnswerRef.current = true
3618
+
3619
+ onChange(requestIdRef.current, field.id)
3620
+ }, [loadCount])
3621
+
3622
+ if (value || loadCount === 2) return (
3623
+ <Grid container alignItems="center" wrap="nowrap">
3624
+ <CheckCircleOutline color="success" />
3625
+
3626
+ <Typography sx={{ ml: 1, fontSize: 20 }}>
3627
+ Please click Next or Submit to continue.
3628
+ </Typography>
3629
+ </Grid>
3630
+ )
3631
+ if (!data) { return <LinearProgress /> }
3632
+ return (
3633
+ <iframe src={data.surveyUrl} style={{ border: 'none', height: 650, width: '100%' }}
3634
+ onLoad={() => setLoadCount(l => l + 1)}
3635
+ />
3636
+ )
3637
+ }
3638
+
3639
+ type AllergyResult = {
3640
+ entry?: { resource: { code: { coding: { system: "http://www.fdbhealth.com/", code: string, display: string } []}} }[]
3641
+ }
3642
+
3643
+ export const AllergiesInput = ({ goToNextField, goToPreviousField, field, value, onChange, form, formResponseId, ...props }: FormInputProps<'Allergies'>) => {
3644
+ const session = useResolvedSession()
3645
+ const [query, setQuery] = useState('')
3646
+ const [results, setResults] = useState<{ code: string, display: string }[]>([])
3647
+
3648
+ // if two allergy questions shown in a row, reset state
3649
+ useEffect(() => {
3650
+ setQuery('')
3651
+ setResults([])
3652
+ }, [field.id])
3653
+
3654
+ const fetchRef = useRef(query)
3655
+ useEffect(() => {
3656
+ if (fetchRef.current === query) return
3657
+ fetchRef.current = query
3658
+
3659
+ if (!query) return
3660
+
3661
+ const t = setTimeout(() => {
3662
+ if (field.options?.dataSource === CANVAS_TITLE) {
3663
+ session.api.integrations
3664
+ .proxy_read({
3665
+ integration: CANVAS_TITLE,
3666
+ type: 'allergies',
3667
+ query,
3668
+ })
3669
+ .then((r : { data: AllergyResult }) => {
3670
+ const deduped: typeof results = []
3671
+ const totalResults = (
3672
+ (r.data.entry || [])
3673
+ .flatMap(v => v?.resource?.code?.coding || [])
3674
+ .filter(v => v.system.includes('fdbhealth'))
3675
+ .map(v => ({ code: v.code, display: v.display, system: v.system }))
3676
+ )
3677
+ for (const v of totalResults) {
3678
+ if (deduped.find(d => d.display === v.display)) { continue }
3679
+
3680
+ deduped.push(v)
3681
+ }
3682
+ setResults(deduped)
3683
+ })
3684
+ } else {
3685
+ session.api.allergy_codes.getSome({ search: { query }})
3686
+ .then(results => {
3687
+ const deduped: typeof results = []
3688
+ for (const v of results) {
3689
+ if (deduped.find(d => d.display === v.display)) { continue }
3690
+
3691
+ deduped.push(v)
3692
+ }
3693
+ setResults(deduped)
3694
+ })
3695
+ }
3696
+
3697
+
3698
+ }, 200)
3699
+
3700
+ return () => { clearTimeout(t) }
3701
+ }, [session, query, field?.options?.dataSource])
3702
+
3703
+ return (
3704
+ <Grid container direction="column" spacing={1}>
3705
+ <Grid item>
3706
+ <Autocomplete multiple value={value || []} options={results} style={{ marginTop: 5 }}
3707
+ noOptionsText={query.length ? 'No results found' : 'Type to start search'}
3708
+ onChange={(e, v) => {
3709
+ if (!v) { return }
3710
+ onChange(v, field.id)
3711
+ setResults([])
3712
+ }}
3713
+ getOptionLabel={v => first_letter_capitalized(v.display)} filterOptions={o => o}
3714
+ inputValue={query} onInputChange={(e, v) => e && setQuery(v) }
3715
+ renderInput={(params) => (
3716
+ <TextField {...params} InputProps={{ ...params.InputProps, sx: defaultInputProps.sx }}
3717
+ required={!field.isOptional} size="small" label="" placeholder="Search allergies..."
3718
+ />
3719
+ )}
3720
+ renderTags={(value, getTagProps) =>
3721
+ value.map((value, index) => (
3722
+ <Chip
3723
+ label={<Typography style={{whiteSpace: 'normal'}}>{value.display}</Typography>}
3724
+ {...getTagProps({ index })}
3725
+ sx={{height:"100%", py: 0.5 }}
3726
+ />
3727
+ ))
3728
+ }
3729
+ />
3730
+ </Grid>
3731
+
3732
+ {(value || []).map((allergy, i) => (
3733
+ <Grid item key={i}>
3734
+ <Grid container alignItems="center" wrap="nowrap" columnGap={0.5} justifyContent={"space-between"}>
3735
+ <Grid item>
3736
+ <Typography noWrap sx={{ width: 85, fontSize: 14 }}>
3737
+ {allergy.display}
3738
+ </Typography>
3739
+ </Grid>
3740
+
3741
+ <Grid item sx={{ width: 140 }}>
3742
+ <StringSelector options={['mild', 'moderate', 'severe']} size="small" label="Severity"
3743
+ value={allergy.severity || ''}
3744
+ onChange={severity => onChange((value || []).map((v, _i) => i === _i ? { ...v, severity } : v), field.id)}
3745
+ getDisplayValue={first_letter_capitalized}
3746
+ />
3747
+ </Grid>
3748
+
3749
+ <Grid item sx={{ width: "50%" }}>
3750
+ <TextField InputProps={{ sx: defaultInputProps.sx }} fullWidth size="small" label="Note"
3751
+ value={allergy.note || ''}
3752
+ onChange={e => onChange((value || []).map((v, _i) => i === _i ? { ...v, note: e.target.value } : v), field.id)}
3753
+ />
3754
+ </Grid>
3755
+ </Grid>
3756
+ </Grid>
3757
+ ))}
3758
+ </Grid>
3759
+ )
3760
+ }
3761
+ const display_with_code = (v: { code: string, display: string }) => `${v.code}: ${first_letter_capitalized(v.display)}`
3762
+
3763
+ export const ConditionsInput = ({ goToNextField, goToPreviousField, field, value, onChange, form, formResponseId, ...props }: FormInputProps<'Conditions'>) => {
3764
+ const session = useResolvedSession()
3765
+ const [query, setQuery] = useState('')
3766
+ const [results, setResults] = useState<{ code: string, display: string }[]>([])
3767
+
3768
+ const fetchRef = useRef(query)
3769
+ useEffect(() => {
3770
+ if (fetchRef.current === query) return
3771
+ fetchRef.current = query
3772
+
3773
+ if (!query) return
3774
+
3775
+ const t = setTimeout(() => {
3776
+ session.api.diagnosis_codes.getSome({ search: { query } })
3777
+ .then(codes => {
3778
+ const deduped: typeof results = []
3779
+ for (const v of codes) {
3780
+ if (deduped.find(d => d.display === v.display)) { continue }
3781
+
3782
+ deduped.push(v)
3783
+ }
3784
+ setResults(deduped)
3785
+ })
3786
+ }, 200)
3787
+
3788
+ return () => { clearTimeout(t) }
3789
+ }, [session, query])
3790
+
3791
+ return (
3792
+ <Autocomplete multiple value={value || []} options={results} style={{ marginTop: 5 }}
3793
+ noOptionsText={query.length ? 'No results found' : 'Type to start search'}
3794
+ onChange={(e, v) => {
3795
+ if (!v) { return }
3796
+ onChange(v, field.id)
3797
+ setResults([])
3798
+ }}
3799
+ getOptionLabel={display_with_code} filterOptions={o => o}
3800
+ inputValue={query} onInputChange={(e, v) => e && setQuery(v) }
3801
+ renderInput={(params) => (
3802
+ <TextField {...params} InputProps={{ ...params.InputProps, sx: defaultInputProps.sx }}
3803
+ required={!field.isOptional} size="small" label="" placeholder="Search conditions..."
3804
+ />
3805
+ )}
3806
+ renderTags={(value, getTagProps) =>
3807
+ value.map((value, index) => (
3808
+ <Chip
3809
+ label={<Typography style={{whiteSpace: 'normal'}}>{display_with_code(value)}</Typography>}
3810
+ {...getTagProps({ index })}
3811
+ sx={{height:"100%", py: 0.5 }}
3812
+ />
3813
+ ))
3814
+ }
3815
+ />
3816
+ )
3817
+ }
3818
+
3819
+ export const RichTextInput = ({ field, value, onChange }: FormInputProps<'Rich Text'>) => (
3820
+ <WYSIWYG stopEnterPropagation initialHTML={value} onChange={v => onChange(v, field.id)} style={{ width: '100%' }} editorStyle={{ width: '100%' }} />
3821
+ )
3822
+
3823
+ export const ChargeebeeInput = ({ field, value, onChange, setCustomerId }: FormInputProps<'Chargebee'> & {
3824
+ setCustomerId: React.Dispatch<React.SetStateAction<string | undefined>>,
3825
+ }) => {
3826
+ const session = useResolvedSession()
3827
+ const [url, setUrl] = useState('')
3828
+ const [error, setError] = useState('')
3829
+
3830
+ const [loadCount, setLoadCount] = useState(0)
3831
+
3832
+ const fetchRef = useRef(false)
3833
+ useEffect(() => {
3834
+ if (fetchRef.current) return
3835
+ fetchRef.current = true
3836
+
3837
+ session.api.form_responses.chargebee_details({ fieldId: field.id })
3838
+ .then(({ url }) => setUrl(url))
3839
+ .catch(setError)
3840
+ }, [session])
3841
+
3842
+ const loadAnswerRef = useRef(false)
3843
+ useEffect(() => {
3844
+ if (loadCount !== 2) return
3845
+ if (loadAnswerRef.current) return
3846
+ loadAnswerRef.current = true
3847
+
3848
+ onChange({ url }, field.id)
3849
+ }, [loadCount, url])
3850
+
3851
+ if (value || loadCount === 2) {
3852
+ return (
3853
+ <Grid container alignItems="center" wrap="nowrap">
3854
+ <CheckCircleOutline color="success" />
3855
+
3856
+ <Typography sx={{ ml: 1, fontSize: 20 }}>
3857
+ Your purchase was successful
3858
+ </Typography>
3859
+ </Grid>
3860
+ )
3861
+ }
3862
+ if (error && typeof error === 'string') return <Typography color="error">{error}</Typography>
3863
+ if (!url) return <LinearProgress />
3864
+ return (
3865
+ <iframe src={url} title="Checkout" style={{ border: 'none', width: '100%', height: 700 }}
3866
+ onLoad={() => setLoadCount(l => l + 1)}
3867
+ />
3868
+ )
3869
+ }