@inploi/plugin-chatbot 2.0.0 → 2.1.1

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 (50) hide show
  1. package/cdn/index.js +56 -0
  2. package/{public → cdn}/mockServiceWorker.js +4 -9
  3. package/package.json +25 -5
  4. package/.env +0 -3
  5. package/.env.example +0 -3
  6. package/.eslintrc.cjs +0 -10
  7. package/CHANGELOG.md +0 -85
  8. package/bunfig.toml +0 -2
  9. package/happydom.ts +0 -10
  10. package/index.html +0 -28
  11. package/postcss.config.cjs +0 -7
  12. package/src/chatbot.api.ts +0 -46
  13. package/src/chatbot.constants.ts +0 -9
  14. package/src/chatbot.css +0 -107
  15. package/src/chatbot.dom.ts +0 -17
  16. package/src/chatbot.idb.ts +0 -17
  17. package/src/chatbot.state.ts +0 -89
  18. package/src/chatbot.ts +0 -54
  19. package/src/chatbot.utils.ts +0 -50
  20. package/src/index.cdn.ts +0 -12
  21. package/src/index.dev.ts +0 -36
  22. package/src/index.ts +0 -1
  23. package/src/interpreter/interpreter.test.ts +0 -69
  24. package/src/interpreter/interpreter.ts +0 -241
  25. package/src/mocks/browser.ts +0 -5
  26. package/src/mocks/example.flows.ts +0 -763
  27. package/src/mocks/handlers.ts +0 -28
  28. package/src/ui/chat-bubble.tsx +0 -52
  29. package/src/ui/chat-input/chat-input.boolean.tsx +0 -62
  30. package/src/ui/chat-input/chat-input.file.tsx +0 -213
  31. package/src/ui/chat-input/chat-input.multiple-choice.tsx +0 -117
  32. package/src/ui/chat-input/chat-input.text.tsx +0 -111
  33. package/src/ui/chat-input/chat-input.tsx +0 -81
  34. package/src/ui/chatbot-header.tsx +0 -98
  35. package/src/ui/chatbot.tsx +0 -105
  36. package/src/ui/input-error.tsx +0 -33
  37. package/src/ui/job-application-content.tsx +0 -145
  38. package/src/ui/job-application-messages.tsx +0 -64
  39. package/src/ui/loading-indicator.tsx +0 -37
  40. package/src/ui/send-button.tsx +0 -27
  41. package/src/ui/transition.tsx +0 -1
  42. package/src/ui/typing-indicator.tsx +0 -12
  43. package/src/ui/useChatService.ts +0 -75
  44. package/src/ui/useFocus.ts +0 -10
  45. package/src/vite-env.d.ts +0 -1
  46. package/tailwind.config.ts +0 -119
  47. package/tsconfig.json +0 -33
  48. package/tsconfig.node.json +0 -10
  49. package/types.d.ts +0 -2
  50. package/vite.config.ts +0 -18
@@ -1,28 +0,0 @@
1
- import { invariant } from '@inploi/core/common';
2
- import { http } from 'msw';
3
-
4
- import { exampleFlows } from './example.flows';
5
-
6
- export const handlers = [
7
- http.get(`${import.meta.env.VITE_BASE_URL}/flow/job/:jobId`, ctx => {
8
- invariant(typeof ctx.params.jobId === 'string', 'Missing job id');
9
- const mockApplication = {
10
- job: {
11
- id: +ctx.params.jobId,
12
- title: 'Test job',
13
- },
14
- company: {
15
- name: 'Test company',
16
- },
17
- flow: {
18
- id: 1,
19
- nodes: ctx.params.jobId === '1' ? exampleFlows.fromAlex : exampleFlows.byClaude,
20
- version: 1,
21
- },
22
- };
23
- return new Response(JSON.stringify(mockApplication));
24
- }),
25
- http.post(`${import.meta.env.VITE_BASE_URL}/flow/job/:jobId`, () => {
26
- return new Response(JSON.stringify({ message: 'Success' }));
27
- }),
28
- ];
@@ -1,52 +0,0 @@
1
- import type { VariantProps } from 'class-variance-authority';
2
- import { cva } from 'class-variance-authority';
3
- import { Variants, m } from 'framer-motion';
4
- import type { ComponentProps } from 'react';
5
-
6
- const chatBubbleVariants = cva(
7
- 'max-w-[min(100%,24rem)] flex-shrink-1 min-w-[2rem] text-md py-2 px-3 rounded-[18px] min-h-[36px] break-words',
8
- {
9
- variants: {
10
- side: {
11
- left: 'bg-lowest text-neutral-12 shadow-surface-md outline outline-1 outline-accent-11/[.08] rounded-bl-md',
12
- right:
13
- 'ml-auto bg-gradient-to-t from-[#2D51D2] to-[#4A7BEC] border border-[#405FCC] shadow-[inset_0_5px_3px_-3px_#6F99F1,inset_0_-5px_5px_-2px_#6F99F1DD,0_4px_6px_-1px_rgb(0_0_0_/_0.1),_0_2px_4px_-2px_rgb(0_0_0_/_0.1)] text-lowest rounded-br-md bubble-right',
14
- },
15
- transitionState: {
16
- entering: 'opacity-0 translate-y-8',
17
- entered: 'opacity-100 translate-y-0',
18
- exiting: 'opacity-0 scale-0',
19
- exited: '',
20
- },
21
- },
22
-
23
- defaultVariants: {
24
- side: 'left',
25
- },
26
- },
27
- );
28
-
29
- type ChatBubbleVariants = VariantProps<typeof chatBubbleVariants>;
30
-
31
- const motionVariants: Variants = {
32
- hidden: { y: '100%', scale: 0.75 },
33
- shown: { y: 0, scale: 1 },
34
- };
35
-
36
- type ChatBubbleProps = ComponentProps<'p'> & ChatBubbleVariants;
37
- export const ChatBubble = ({ children, className, transitionState, side, ...props }: ChatBubbleProps) => {
38
- return (
39
- <m.p
40
- variants={motionVariants}
41
- initial="hidden"
42
- animate="shown"
43
- transition={{ type: 'spring', damping: 25, stiffness: 500 }}
44
- data-transition={transitionState}
45
- style={{ transformOrigin: side === 'left' ? '0% 50%' : '100% 50%' }}
46
- class={chatBubbleVariants({ className, side, transitionState })}
47
- {...props}
48
- >
49
- {children}
50
- </m.p>
51
- );
52
- };
@@ -1,62 +0,0 @@
1
- import { P, match } from 'ts-pattern';
2
- import { parse, picklist } from 'valibot';
3
-
4
- import { useFocusOnMount } from '../useFocus';
5
- import { ChatInputProps } from './chat-input';
6
-
7
- export type BooleanChoicePayload = {
8
- type: 'boolean';
9
- config: { labels: { true: string; false: string } };
10
- key: string;
11
- value: 'true' | 'false';
12
- };
13
-
14
- const options = ['true', 'false'] as const;
15
- const AnswerSchema = picklist(options);
16
- const FIELD_NAME = 'answer';
17
-
18
- export const ChatInputBoolean = ({ input, onSubmitSuccess }: ChatInputProps<'boolean'>) => {
19
- const focusRef = useFocusOnMount();
20
-
21
- return (
22
- <form
23
- noValidate
24
- class="flex gap-2 items-center"
25
- onSubmit={e => {
26
- e.preventDefault();
27
-
28
- /** nativeEvent is a hidden property not exposed by JSX's typings */
29
- const value = match(e as any)
30
- .with(
31
- {
32
- nativeEvent: {
33
- submitter: P.select(P.union(P.instanceOf(HTMLButtonElement), P.instanceOf(HTMLInputElement))),
34
- },
35
- },
36
- submitter => {
37
- return submitter.value;
38
- },
39
- )
40
- .otherwise(() => {
41
- throw new Error('invalid form');
42
- });
43
- const answer = parse(AnswerSchema, value);
44
- onSubmitSuccess(answer);
45
- }}
46
- >
47
- {options.map((value, i) => {
48
- return (
49
- <button
50
- ref={i === 0 ? focusRef : null}
51
- type="submit"
52
- name={FIELD_NAME}
53
- value={value}
54
- class="flex-1 overflow-hidden rounded-2xl block px-2.5 py-2.5 selection:bg-transparent transition-all bg-lowest ring-transparent ring-0 focus-visible:ring-offset-2 focus-visible:ring-4 focus-visible:ring-accent-7 ease-expo-out duration-300 outline outline-2 outline-neutral-12/5 text-neutral-12 active:outline-accent-9 active:bg-accent-4 active:text-accent-11"
55
- >
56
- <p class="truncate text-center text-base">{input.config.labels[value]}</p>
57
- </button>
58
- );
59
- })}
60
- </form>
61
- );
62
- };
@@ -1,213 +0,0 @@
1
- import { invariant } from '@inploi/core/common';
2
- import clsx from 'clsx';
3
- import { ComponentProps } from 'preact';
4
- import { useState } from 'preact/hooks';
5
- import { FieldError } from 'react-hook-form';
6
- import { P, match } from 'ts-pattern';
7
- import { application } from '~/chatbot.state';
8
- import { isSubmissionOfType } from '~/chatbot.utils';
9
-
10
- import { InputError } from '../input-error';
11
- import { SendButton } from '../send-button';
12
- import { useFocusOnMount } from '../useFocus';
13
- import { ChatInputProps } from './chat-input';
14
-
15
- export type FileToUpload = { name: string; data: string; sizeKb: number };
16
- export type FilePayload = {
17
- type: 'file';
18
- config: { extensions: string[]; fileSizeLimitKib?: number; allowMultiple: boolean };
19
- key: string;
20
- value: FileToUpload[];
21
- };
22
-
23
- const toBase64 = (file: File) =>
24
- new Promise<string>((resolve, reject) => {
25
- const reader = new FileReader();
26
- reader.readAsDataURL(file);
27
- reader.onload = () => {
28
- if (!reader.result) return reject('No result from reader');
29
- return resolve(reader.result.toString());
30
- };
31
- reader.onerror = reject;
32
- });
33
-
34
- const kbToReadableSize = (kb: number) =>
35
- match(kb)
36
- .with(P.number.lte(1000), () => `${Math.round(kb)}KB`)
37
- .with(P.number.lt(1000 * 10), () => `${(kb / 1000).toFixed(1)}MB`)
38
- .otherwise(() => `${Math.round(kb / 1000)}MB`);
39
-
40
- const addFileSizesKb = (files: FileToUpload[]) => files.reduce((acc, cur) => acc + cur.sizeKb, 0);
41
-
42
- const isFileSubmission = isSubmissionOfType('file');
43
-
44
- const FILENAMES_TO_SHOW_QTY = 3;
45
-
46
- export const FileThumbnail = ({
47
- file,
48
- class: className,
49
- ...props
50
- }: ComponentProps<'div'> & { file: Pick<FileToUpload, 'name' | 'sizeKb'> }) => {
51
- const extension = file.name.split('.').pop();
52
- const fileName = file.name.replace(new RegExp(`.${extension}$`), '');
53
-
54
- return (
55
- <div
56
- class={clsx(
57
- 'bg-accent-1 max-w-full flex gap-2 px-3 py-2 text-sm rounded-lg outline outline-neutral-4 overflow-hidden',
58
- className,
59
- )}
60
- {...props}
61
- >
62
- <p aria-label="File name" class="overflow-hidden text-accent-12 flex flex-grow">
63
- <span class="block truncate">{fileName}</span>
64
- <span>.{extension}</span>
65
- </p>
66
-
67
- <p aria-label="File size" class="text-neutral-10">
68
- {kbToReadableSize(file.sizeKb)}
69
- </p>
70
- </div>
71
- );
72
- };
73
-
74
- const FilenameBadge = ({ class: className, ...props }: ComponentProps<'li'>) => (
75
- <li
76
- class={clsx(
77
- 'text-xs block outline outline-1 outline-neutral-6 text-neutral-11 bg-neutral-1 px-1 py-0.5 rounded-md',
78
- className,
79
- )}
80
- {...props}
81
- />
82
- );
83
- 1;
84
-
85
- export const ChatInputFile = ({ input, onSubmitSuccess }: ChatInputProps<'file'>) => {
86
- const submission = application.current$.value?.data.submissions[input.key];
87
- const [files, setFiles] = useState<FileToUpload[]>(isFileSubmission(submission) ? submission.value : []);
88
- const [error, setError] = useState<FieldError>();
89
- const hiddenFileCount = files.length - FILENAMES_TO_SHOW_QTY;
90
- const totalSize = addFileSizesKb(files);
91
- const focusRef = useFocusOnMount();
92
- return (
93
- <form
94
- class="flex flex-col gap-1"
95
- onSubmit={e => {
96
- e.preventDefault();
97
- setError(undefined);
98
- if (files.length === 0) {
99
- setError({ type: 'required', message: 'Please select a file' });
100
- }
101
- if (input.config.fileSizeLimitKib && totalSize > input.config.fileSizeLimitKib) {
102
- setError({
103
- type: 'max',
104
- message: `File size exceeds limit of ${kbToReadableSize(input.config.fileSizeLimitKib)}`,
105
- });
106
- }
107
- return onSubmitSuccess(files);
108
- }}
109
- >
110
- <div class="flex gap-2 items-center">
111
- <label
112
- ref={focusRef}
113
- for="dropzone-file"
114
- class="p-4 flex flex-col overflow-hidden items-center justify-center w-full h-48 border border-neutral-8 border-dashed rounded-2xl bg-neutral-2 cursor-pointer"
115
- >
116
- {files.length > 0 ? (
117
- <>
118
- <ul class="max-w-full flex gap-1 flex-wrap justify-center overflow-hidden p-1">
119
- {files.slice(0, FILENAMES_TO_SHOW_QTY).map(file => {
120
- const extension = file.name.split('.').pop();
121
- const fileName = file.name.replace(new RegExp(`.${extension}$`), '');
122
- return (
123
- <FilenameBadge class="flex overflow-hidden">
124
- <span class="block truncate">{fileName}</span>
125
- <span>.{extension}</span>
126
- </FilenameBadge>
127
- );
128
- })}
129
- {hiddenFileCount > 0 ? (
130
- <FilenameBadge>
131
- +{hiddenFileCount} file{hiddenFileCount !== 1 ? 's' : ''}
132
- </FilenameBadge>
133
- ) : null}
134
- </ul>
135
-
136
- <p class="text-xs text-neutral-11">
137
- {kbToReadableSize(totalSize)} {files.length > 1 ? 'total' : ''}
138
- </p>
139
- </>
140
- ) : (
141
- <div class="flex flex-col justify-center pt-5 pb-6 gap-4">
142
- <header class="flex flex-col gap-0 items-center">
143
- <svg
144
- class="w-8 h-8 text-neutral-11 mb-1"
145
- aria-hidden="true"
146
- xmlns="http://www.w3.org/2000/svg"
147
- fill="none"
148
- viewBox="0 0 20 16"
149
- >
150
- <path
151
- stroke="currentColor"
152
- stroke-linecap="round"
153
- stroke-linejoin="round"
154
- stroke-width="1.5"
155
- d="M13 13h3a3 3 0 0 0 0-6h-.025A5.56 5.56 0 0 0 16 6.5 5.5 5.5 0 0 0 5.207 5.021C5.137 5.017 5.071 5 5 5a4 4 0 0 0 0 8h2.167M10 15V6m0 0L8 8m2-2 2 2"
156
- />
157
- </svg>
158
- <p class="tracking-[-0.01em] text-neutral-12 dark:text-gray-400">
159
- {input.config.allowMultiple ? 'Select files' : 'Select a file'} to upload
160
- </p>
161
- {input.config.fileSizeLimitKib ? (
162
- <p class="text-xs text-neutral-10">(max {kbToReadableSize(input.config.fileSizeLimitKib)})</p>
163
- ) : null}
164
- </header>
165
- <aside class="flex flex-col gap-2 items-center">
166
- <p id="accepted-filetypes" class="sr-only">
167
- Accepted file extensions
168
- </p>
169
- <ul aria-describedby="accepted-filetypes" class="flex gap-2 flex-wrap justify-center">
170
- {input.config.extensions.map(ext => (
171
- <li class="text-[11px] tracking-wide uppercase outline ring-2 ring-lowest outline-1 outline-neutral-6 text-neutral-9 bg-neutral-1 px-1 py-0.5 rounded-md">
172
- {ext.replace('.', '')}
173
- </li>
174
- ))}
175
- </ul>
176
- </aside>
177
- </div>
178
- )}
179
-
180
- <input
181
- id="dropzone-file"
182
- onInput={async e => {
183
- invariant(e.target instanceof HTMLInputElement);
184
- const files = e.target.files ? Array.from(e.target.files) : [];
185
- const filesToUpload = await Promise.allSettled(
186
- files.map(async file => {
187
- const data = await toBase64(file);
188
- return {
189
- name: file.name,
190
- data: data,
191
- sizeKb: file.size / 1000,
192
- };
193
- }),
194
- );
195
- if (filesToUpload.some(({ status }) => status === 'rejected')) {
196
- return setError({ type: 'invalid', message: 'Invalid file' });
197
- }
198
- const validFiles = filesToUpload
199
- .map(promise => (promise.status === 'fulfilled' ? promise.value : null))
200
- .filter(Boolean) as FileToUpload[];
201
- setFiles(validFiles);
202
- }}
203
- multiple={input.config.allowMultiple}
204
- type="file"
205
- class="sr-only"
206
- />
207
- </label>
208
- <SendButton disabled={files.length === 0} />
209
- </div>
210
- {error && <InputError error={error} />}
211
- </form>
212
- );
213
- };
@@ -1,117 +0,0 @@
1
- import { valibotResolver } from '@hookform/resolvers/valibot';
2
- import { useForm } from 'react-hook-form';
3
- import { boolean, maxLength, minLength, object, record, transform } from 'valibot';
4
- import { application } from '~/chatbot.state';
5
- import { isSubmissionOfType } from '~/chatbot.utils';
6
-
7
- import { InputError } from '../input-error';
8
- import { SendButton } from '../send-button';
9
- import { useFocusOnMount } from '../useFocus';
10
- import { ChatInputProps } from './chat-input';
11
-
12
- export type MultipleChoicePayload = {
13
- type: 'multiple-choice';
14
- key: string;
15
- config: { options: { value: string; label: string }[]; minSelected?: number; maxSelected?: number };
16
- value: string[];
17
- };
18
-
19
- const submitIfSingleChecked = (form: HTMLFormElement) => {
20
- const formObj = Object.fromEntries(new FormData(form).entries());
21
- const isSingleChecked = Object.keys(formObj).length;
22
- if (!isSingleChecked) return;
23
- form.dispatchEvent(new Event('submit', { bubbles: true }));
24
- };
25
-
26
- const isMultipleChoiceSubmission = isSubmissionOfType('multiple-choice');
27
-
28
- const getResolver = (config: MultipleChoicePayload['config']) => {
29
- const length = {
30
- min: config.minSelected ?? 0,
31
- max: config.maxSelected ?? config.options.length,
32
- };
33
- return valibotResolver(
34
- object({
35
- checked: transform(
36
- record(boolean()),
37
- o =>
38
- Object.entries(o)
39
- .filter(([_, v]) => v)
40
- .map(([k, _]) => k),
41
- [
42
- maxLength(length.max, `Please select at most ${length.max} option${length.max !== 1 ? 's' : ''}`),
43
- minLength(length.min, `Please select at least ${length.min} option${length.min !== 1 ? 's' : ''}`),
44
- ],
45
- ),
46
- }),
47
- );
48
- };
49
-
50
- export const ChatInputMultipleChoice = ({ input, onSubmitSuccess }: ChatInputProps<'multiple-choice'>) => {
51
- const submission = application.current$.value?.data.submissions[input.key];
52
- const {
53
- register,
54
- handleSubmit,
55
- formState: { errors },
56
- } = useForm({
57
- defaultValues: {
58
- checked: isMultipleChoiceSubmission(submission)
59
- ? Object.fromEntries(submission.value.map(key => [key, true]))
60
- : {},
61
- },
62
- resolver: getResolver(input.config),
63
- });
64
- const focusRef = useFocusOnMount();
65
- const isSingleChoice = input.config.minSelected === 1 && input.config.maxSelected === 1;
66
-
67
- return (
68
- <form
69
- noValidate
70
- class="flex flex-col gap-1"
71
- onChange={e => {
72
- if (isSingleChoice) {
73
- submitIfSingleChecked(e.currentTarget);
74
- }
75
- }}
76
- onSubmit={handleSubmit(submission => {
77
- // react-hook-form does not play well with zod's transform
78
- const checked = submission.checked as unknown as string[];
79
- onSubmitSuccess(checked);
80
- })}
81
- >
82
- <div class="flex items-center gap-2">
83
- <div class="flex flex-wrap gap-3 flex-1 p-1 w-full">
84
- {input.config.options.map((option, i) => {
85
- const id = `checked.${option.value}` as const;
86
- const { ref: setRef, ...props } = register(id);
87
- return (
88
- <div key={option.value}>
89
- <input
90
- autoFocus={i === 0}
91
- ref={(e: HTMLInputElement | null) => {
92
- if (e && i === 0) {
93
- focusRef.current = e;
94
- }
95
- setRef(e);
96
- }}
97
- id={id}
98
- {...props}
99
- class="peer sr-only"
100
- type="checkbox"
101
- ></input>
102
- <label
103
- class="rounded-2xl block px-2.5 py-1 selection:bg-transparent transition-all bg-lowest ring-transparent ring-0 peer-focus-visible:ring-offset-2 peer-focus-visible:ring-4 peer-focus-visible:ring-accent-7 active:outline-neutral-10 ease-expo-out duration-300 outline outline-2 outline-neutral-12/5 text-neutral-11 peer-checked:outline-accent-9 peer-checked:bg-accent-4 peer-checked:text-accent-11"
104
- htmlFor={id}
105
- >
106
- {option.label}
107
- </label>
108
- </div>
109
- );
110
- })}
111
- </div>
112
- {!isSingleChoice && <SendButton />}
113
- </div>
114
- <InputError error={errors.checked?.root} />
115
- </form>
116
- );
117
- };
@@ -1,111 +0,0 @@
1
- import { valibotResolver } from '@hookform/resolvers/valibot';
2
- import { useLayoutEffect, useRef } from 'preact/hooks';
3
- import { useForm } from 'react-hook-form';
4
- import { StringSchema, email, minLength, object, regex, string, transform, url } from 'valibot';
5
- import { application } from '~/chatbot.state';
6
- import { isSubmissionOfType } from '~/chatbot.utils';
7
-
8
- import { InputError } from '../input-error';
9
- import { SendButton } from '../send-button';
10
- import { ChatInputProps } from './chat-input';
11
-
12
- export type TextPayload = {
13
- type: 'text';
14
- key: string;
15
- config: { placeholder?: string; format: 'text' | 'email' | 'phone' | 'url' };
16
- value: string;
17
- };
18
-
19
- const errors = {
20
- email: 'That doesn’t look like a valid email address',
21
- phone: 'That doesn’t look like a valid phone number',
22
- };
23
-
24
- const PhoneSchema = string(errors.phone, [regex(/^\+?[0-9 -]+$/, errors.phone)]);
25
-
26
- type InputFormat = ChatInputProps<'text'>['input']['config']['format'];
27
- const inputFormatToSchema: Record<InputFormat, StringSchema> = {
28
- email: string(errors.email, [email(errors.email)]),
29
- phone: transform(PhoneSchema, value => value.replace(/[^0-9]/g, '')),
30
- text: string([minLength(1, 'Please enter some text')]),
31
- url: string([url('That doesn’t look like a valid URL')]),
32
- };
33
-
34
- const inputFormatToProps = {
35
- email: {
36
- type: 'email',
37
- inputMode: 'email',
38
- formNoValidate: true,
39
- },
40
- phone: {
41
- type: 'tel',
42
- inputMode: 'tel',
43
- },
44
- text: {
45
- type: 'text',
46
- inputMode: 'text',
47
- },
48
- url: {
49
- type: 'url',
50
- inputMode: 'url',
51
- formNoValidate: true,
52
- },
53
- } satisfies Record<InputFormat, JSX.IntrinsicElements['input']>;
54
-
55
- const isTextSubmission = isSubmissionOfType('text');
56
- const getResolver = (config: TextPayload['config']) =>
57
- valibotResolver(object({ text: inputFormatToSchema[config.format] }));
58
-
59
- export const ChatInputText = ({ input, onSubmitSuccess }: ChatInputProps<'text'>) => {
60
- const submission = application.current$.value?.data.submissions[input.key];
61
- const {
62
- register,
63
- handleSubmit,
64
- formState: { errors },
65
- } = useForm({
66
- defaultValues: {
67
- text: isTextSubmission(submission) ? submission.value : '',
68
- },
69
- resolver: getResolver(input.config),
70
- });
71
- const { ref: setRef, ...props } = register('text', { required: true });
72
- const ref = useRef<HTMLInputElement>();
73
- useLayoutEffect(() => {
74
- if (ref.current) {
75
- ref.current.focus();
76
- ref.current.select();
77
- }
78
- }, []);
79
-
80
- return (
81
- <form
82
- noValidate
83
- class="flex flex-col gap-1"
84
- onSubmit={handleSubmit(submission => {
85
- onSubmitSuccess(submission.text);
86
- })}
87
- >
88
- <div class="flex gap-2 items-center">
89
- <input
90
- id="chat-input"
91
- {...props}
92
- {...inputFormatToProps[input.config.format]}
93
- autocomplete="off"
94
- autoCapitalize="off"
95
- autoCorrect="off"
96
- autoFocus
97
- ref={(e: HTMLInputElement | null) => {
98
- if (e) {
99
- ref.current = e;
100
- }
101
- setRef(e);
102
- }}
103
- class="flex-grow outline outline-2 outline-neutral-6 px-3 py-1 rounded-full text-base placeholder:text-neutral-10 focus-visible:outline-accent-9 caret-accent-9"
104
- placeholder={input.config.placeholder}
105
- />
106
- <SendButton />
107
- </div>
108
- <InputError error={errors.text} />
109
- </form>
110
- );
111
- };
@@ -1,81 +0,0 @@
1
- import { m } from 'framer-motion';
2
- import { useEffect, useRef } from 'preact/hooks';
3
- import { P, match } from 'ts-pattern';
4
- import { DistributivePick } from '~/chatbot.utils';
5
-
6
- import { ApplicationData, ApplicationSubmission, application, inputHeight } from '../../chatbot.state';
7
- import { SendButton } from '../send-button';
8
- import { ChatInput as ChatInputType } from './chat-input';
9
- import { BooleanChoicePayload, ChatInputBoolean } from './chat-input.boolean';
10
- import { ChatInputFile, FilePayload } from './chat-input.file';
11
- import { ChatInputMultipleChoice, MultipleChoicePayload } from './chat-input.multiple-choice';
12
- import { ChatInputText, TextPayload } from './chat-input.text';
13
-
14
- export type SubmitSuccessFn = (submission: ApplicationSubmission) => void;
15
-
16
- export type ChatInputProps<T extends ChatInputType['type']> = {
17
- onSubmitSuccess: (value: Extract<ApplicationSubmission, { type: T }>['value']) => void;
18
- input: Extract<ChatInputType, { type: T }>;
19
- localState?: ApplicationData;
20
- };
21
-
22
- export type ChatbotInput = TextPayload | MultipleChoicePayload | BooleanChoicePayload | FilePayload;
23
- export type ChatInput = DistributivePick<ChatbotInput, 'type' | 'config' | 'key'>;
24
-
25
- type ChatInputFactoryProps = {
26
- onSubmit: SubmitSuccessFn;
27
- onInputChange: (input?: ChatInputType['type']) => void;
28
- };
29
- export const ChatInput = ({ onSubmit, onInputChange }: ChatInputFactoryProps) => {
30
- const input = application.current$.value?.data.currentInput;
31
- const inputWrapperRef = useRef<HTMLDivElement>(null);
32
-
33
- useEffect(() => {
34
- if (inputWrapperRef.current) {
35
- inputHeight.value = inputWrapperRef.current.getBoundingClientRect().height;
36
- }
37
- onInputChange(input?.type);
38
- }, [input?.type, onInputChange]);
39
-
40
- const handleSubmitSuccess =
41
- <T extends ChatbotInput['type']>(type: T) =>
42
- (value: (ChatbotInput & { type: T })['value']) =>
43
- onSubmit({ type, value } as ApplicationSubmission);
44
-
45
- return (
46
- <m.div
47
- initial={{ height: 0 }}
48
- animate={{ height: inputHeight.value }}
49
- exit={{ height: 0, opacity: 0 }}
50
- class="absolute bottom-0 w-full overflow-hidden rounded-b-3xl bg-neutral-4/80 backdrop-blur-md backdrop-saturate-150"
51
- >
52
- <div ref={inputWrapperRef} class="p-2.5 border-t border-neutral-5">
53
- {match({ application, input })
54
- .with({ input: P.nullish }, () => (
55
- <div class="flex gap-2 items-center">
56
- <input
57
- aria-hidden="true"
58
- id="chat-input"
59
- class="flex-grow outline outline-2 outline-neutral-6 px-3 py-1 rounded-full text-base placeholder:text-neutral-10 focus-visible:outline-accent-9 caret-accent-9"
60
- disabled
61
- />
62
- <SendButton disabled aria-hidden="true" tabIndex={-1} />
63
- </div>
64
- ))
65
- .with({ input: { type: 'text' } }, props => (
66
- <ChatInputText onSubmitSuccess={handleSubmitSuccess(props.input.type)} {...props} />
67
- ))
68
- .with({ input: { type: 'multiple-choice' } }, props => (
69
- <ChatInputMultipleChoice onSubmitSuccess={handleSubmitSuccess(props.input.type)} {...props} />
70
- ))
71
- .with({ input: { type: 'boolean' } }, props => (
72
- <ChatInputBoolean onSubmitSuccess={handleSubmitSuccess(props.input.type)} {...props} />
73
- ))
74
- .with({ input: { type: 'file' } }, props => (
75
- <ChatInputFile onSubmitSuccess={handleSubmitSuccess(props.input.type)} {...props} />
76
- ))
77
- .exhaustive()}
78
- </div>
79
- </m.div>
80
- );
81
- };