@inploi/plugin-chatbot 1.0.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 (46) hide show
  1. package/.env.example +3 -0
  2. package/.eslintrc.cjs +10 -0
  3. package/CHANGELOG.md +17 -0
  4. package/bunfig.toml +2 -0
  5. package/happydom.ts +10 -0
  6. package/index.html +28 -0
  7. package/package.json +58 -0
  8. package/postcss.config.cjs +7 -0
  9. package/public/mockServiceWorker.js +292 -0
  10. package/src/chatbot.api.ts +46 -0
  11. package/src/chatbot.constants.ts +6 -0
  12. package/src/chatbot.css +100 -0
  13. package/src/chatbot.dom.ts +27 -0
  14. package/src/chatbot.state.ts +180 -0
  15. package/src/chatbot.ts +77 -0
  16. package/src/chatbot.utils.ts +38 -0
  17. package/src/index.cdn.ts +12 -0
  18. package/src/index.dev.ts +32 -0
  19. package/src/index.ts +1 -0
  20. package/src/interpreter/interpreter.test.ts +69 -0
  21. package/src/interpreter/interpreter.ts +241 -0
  22. package/src/mocks/browser.ts +5 -0
  23. package/src/mocks/example.flows.ts +763 -0
  24. package/src/mocks/handlers.ts +28 -0
  25. package/src/ui/chat-bubble.tsx +36 -0
  26. package/src/ui/chat-input/chat-input.boolean.tsx +57 -0
  27. package/src/ui/chat-input/chat-input.file.tsx +211 -0
  28. package/src/ui/chat-input/chat-input.multiple-choice.tsx +92 -0
  29. package/src/ui/chat-input/chat-input.text.tsx +105 -0
  30. package/src/ui/chat-input/chat-input.tsx +57 -0
  31. package/src/ui/chatbot-header.tsx +89 -0
  32. package/src/ui/chatbot.tsx +53 -0
  33. package/src/ui/input-error.tsx +39 -0
  34. package/src/ui/job-application-content.tsx +122 -0
  35. package/src/ui/job-application-messages.tsx +56 -0
  36. package/src/ui/loading-indicator.tsx +37 -0
  37. package/src/ui/send-button.tsx +27 -0
  38. package/src/ui/transition.tsx +1 -0
  39. package/src/ui/typing-indicator.tsx +12 -0
  40. package/src/ui/useChatService.ts +82 -0
  41. package/src/vite-env.d.ts +1 -0
  42. package/tailwind.config.ts +119 -0
  43. package/tsconfig.json +33 -0
  44. package/tsconfig.node.json +10 -0
  45. package/types.d.ts +2 -0
  46. package/vite.config.ts +18 -0
@@ -0,0 +1,28 @@
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`, ctx => {
26
+ return new Response(JSON.stringify({ message: 'Success' }));
27
+ }),
28
+ ];
@@ -0,0 +1,36 @@
1
+ import type { VariantProps } from 'class-variance-authority';
2
+ import { cva } from 'class-variance-authority';
3
+ import type { ComponentProps } from 'react';
4
+
5
+ const chatBubbleVariants = cva(
6
+ 'max-w-[min(100%,24rem)] flex-shrink-1 min-w-[2rem] text-sm py-2 px-3 transition-all duration-500 ease-expo-out rounded-[18px] min-h-[36px] break-words',
7
+ {
8
+ variants: {
9
+ side: {
10
+ left: 'self-start bg-lowest text-neutral-12 shadow-surface-md outline outline-1 outline-accent-11/[.08] rounded-bl-sm',
11
+ right: 'self-end bg-accent-9 text-lowest shadow-surface-md rounded-br-sm bubble-right',
12
+ },
13
+ transitionState: {
14
+ entering: 'opacity-0 translate-y-8',
15
+ entered: 'opacity-100 translate-y-0',
16
+ exiting: 'opacity-0 scale-0',
17
+ exited: '',
18
+ },
19
+ },
20
+
21
+ defaultVariants: {
22
+ side: 'left',
23
+ },
24
+ },
25
+ );
26
+
27
+ type ChatBubbleVariants = VariantProps<typeof chatBubbleVariants>;
28
+
29
+ type ChatBubbleProps = ComponentProps<'p'> & ChatBubbleVariants;
30
+ export const ChatBubble = ({ children, className, transitionState, side, ...props }: ChatBubbleProps) => {
31
+ return (
32
+ <p data-transition={transitionState} class={chatBubbleVariants({ className, side, transitionState })} {...props}>
33
+ {children}
34
+ </p>
35
+ );
36
+ };
@@ -0,0 +1,57 @@
1
+ import { P, match } from 'ts-pattern';
2
+ import { z } from 'zod';
3
+
4
+ import { ChatInputProps } from './chat-input';
5
+
6
+ export type BooleanChoicePayload = {
7
+ type: 'boolean';
8
+ config: { labels: { true: string; false: string } };
9
+ key: string;
10
+ value: 'true' | 'false';
11
+ };
12
+
13
+ const options = ['true', 'false'] as const;
14
+ const AnswerSchema = z.enum(options);
15
+ const FIELD_NAME = 'answer';
16
+
17
+ export const ChatInputBoolean = ({ input, onSubmitSuccess }: ChatInputProps<'boolean'>) => {
18
+ return (
19
+ <form
20
+ class="flex gap-2 items-center"
21
+ onSubmit={e => {
22
+ e.preventDefault();
23
+
24
+ /** nativeEvent is a hidden property not exposed by JSX's typings */
25
+ const value = match(e as any)
26
+ .with(
27
+ {
28
+ nativeEvent: {
29
+ submitter: P.select(P.union(P.instanceOf(HTMLButtonElement), P.instanceOf(HTMLInputElement))),
30
+ },
31
+ },
32
+ submitter => {
33
+ return submitter.value;
34
+ },
35
+ )
36
+ .otherwise(() => {
37
+ throw new Error('invalid form');
38
+ });
39
+ const answer = AnswerSchema.parse(value);
40
+ onSubmitSuccess(answer);
41
+ }}
42
+ >
43
+ {options.map(value => {
44
+ return (
45
+ <button
46
+ type="submit"
47
+ name={FIELD_NAME}
48
+ value={value}
49
+ 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"
50
+ >
51
+ <p class="truncate text-base">{input.config.labels[value]}</p>
52
+ </button>
53
+ );
54
+ })}
55
+ </form>
56
+ );
57
+ };
@@ -0,0 +1,211 @@
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 { useApplicationSubmission } from '~/chatbot.state';
8
+ import { gzip, isSubmissionOfType } from '~/chatbot.utils';
9
+
10
+ import { InputError } from '../input-error';
11
+ import { SendButton } from '../send-button';
12
+ import { ChatInputProps } from './chat-input';
13
+
14
+ export type FileToUpload = { name: string; data: string; sizeKb: number };
15
+ export type FilePayload = {
16
+ type: 'file';
17
+ config: { extensions: string[]; fileSizeLimitKib?: number; allowMultiple: boolean };
18
+ key: string;
19
+ value: FileToUpload[];
20
+ };
21
+
22
+ const toBase64 = (file: File) =>
23
+ new Promise<string>((resolve, reject) => {
24
+ const reader = new FileReader();
25
+ reader.readAsDataURL(file);
26
+ reader.onload = () => {
27
+ if (!reader.result) return reject('No result from reader');
28
+ return resolve(reader.result.toString());
29
+ };
30
+ reader.onerror = reject;
31
+ });
32
+
33
+ const kbToReadableSize = (kb: number) =>
34
+ match(kb)
35
+ .with(P.number.lte(1000), () => `${Math.round(kb)}KB`)
36
+ .with(P.number.lt(1000 * 10), () => `${(kb / 1000).toFixed(1)}MB`)
37
+ .otherwise(() => `${Math.round(kb / 1000)}MB`);
38
+
39
+ const addFileSizesKb = (files: FileToUpload[]) => files.reduce((acc, cur) => acc + cur.sizeKb, 0);
40
+
41
+ const isFileSubmission = isSubmissionOfType('file');
42
+
43
+ const FILENAMES_TO_SHOW_QTY = 3;
44
+
45
+ export const FileThumbnail = ({
46
+ file,
47
+ class: className,
48
+ ...props
49
+ }: ComponentProps<'div'> & { file: Pick<FileToUpload, 'name' | 'sizeKb'> }) => {
50
+ const extension = file.name.split('.').pop();
51
+ const fileName = file.name.replace(new RegExp(`.${extension}$`), '');
52
+
53
+ return (
54
+ <div
55
+ class={clsx(
56
+ 'bg-accent-1 max-w-full flex gap-2 px-3 py-2 text-sm rounded-lg outline outline-neutral-4 overflow-hidden',
57
+ className,
58
+ )}
59
+ {...props}
60
+ >
61
+ <p aria-label="File name" class="overflow-hidden text-accent-12 flex flex-grow">
62
+ <span class="block truncate">{fileName}</span>
63
+ <span>.{extension}</span>
64
+ </p>
65
+
66
+ <p aria-label="File size" class="text-neutral-10">
67
+ {kbToReadableSize(file.sizeKb)}
68
+ </p>
69
+ </div>
70
+ );
71
+ };
72
+
73
+ const FilenameBadge = ({ class: className, ...props }: ComponentProps<'li'>) => (
74
+ <li
75
+ class={clsx(
76
+ 'text-xs block outline outline-1 outline-neutral-6 text-neutral-11 bg-neutral-1 px-1 py-0.5 rounded-md',
77
+ className,
78
+ )}
79
+ {...props}
80
+ />
81
+ );
82
+ 1;
83
+
84
+ export const ChatInputFile = ({ input, onSubmitSuccess, application }: ChatInputProps<'file'>) => {
85
+ const submission = useApplicationSubmission(application, input.key);
86
+ const [files, setFiles] = useState<FileToUpload[]>(isFileSubmission(submission) ? submission.value : []);
87
+ const [error, setError] = useState<FieldError>();
88
+ const hiddenFileCount = files.length - FILENAMES_TO_SHOW_QTY;
89
+ const totalSize = addFileSizesKb(files);
90
+
91
+ return (
92
+ <form
93
+ class="flex flex-col gap-1"
94
+ onSubmit={e => {
95
+ e.preventDefault();
96
+ setError(undefined);
97
+ if (files.length === 0) {
98
+ setError({ type: 'required', message: 'Please select a file' });
99
+ }
100
+ if (input.config.fileSizeLimitKib && totalSize > input.config.fileSizeLimitKib) {
101
+ setError({
102
+ type: 'max',
103
+ message: `File size exceeds limit of ${kbToReadableSize(input.config.fileSizeLimitKib)}`,
104
+ });
105
+ }
106
+ return onSubmitSuccess(files);
107
+ }}
108
+ >
109
+ <div class="flex gap-2 items-center">
110
+ <label
111
+ for="dropzone-file"
112
+ 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"
113
+ >
114
+ {files.length > 0 ? (
115
+ <>
116
+ <ul class="max-w-full flex gap-1 flex-wrap justify-center overflow-hidden p-1">
117
+ {files.slice(0, FILENAMES_TO_SHOW_QTY).map(file => {
118
+ const extension = file.name.split('.').pop();
119
+ const fileName = file.name.replace(new RegExp(`.${extension}$`), '');
120
+ return (
121
+ <FilenameBadge class="flex overflow-hidden">
122
+ <span class="block truncate">{fileName}</span>
123
+ <span>.{extension}</span>
124
+ </FilenameBadge>
125
+ );
126
+ })}
127
+ {hiddenFileCount > 0 ? (
128
+ <FilenameBadge>
129
+ +{hiddenFileCount} file{hiddenFileCount !== 1 ? 's' : ''}
130
+ </FilenameBadge>
131
+ ) : null}
132
+ </ul>
133
+
134
+ <p class="text-xs text-neutral-11">
135
+ {kbToReadableSize(totalSize)} {files.length > 1 ? 'total' : ''}
136
+ </p>
137
+ </>
138
+ ) : (
139
+ <div class="flex flex-col justify-center pt-5 pb-6 gap-4">
140
+ <header class="flex flex-col gap-0 items-center">
141
+ <svg
142
+ class="w-8 h-8 text-neutral-11 mb-1"
143
+ aria-hidden="true"
144
+ xmlns="http://www.w3.org/2000/svg"
145
+ fill="none"
146
+ viewBox="0 0 20 16"
147
+ >
148
+ <path
149
+ stroke="currentColor"
150
+ stroke-linecap="round"
151
+ stroke-linejoin="round"
152
+ stroke-width="1.5"
153
+ 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"
154
+ />
155
+ </svg>
156
+ <p class="tracking-[-0.01em] text-neutral-12 dark:text-gray-400">
157
+ {input.config.allowMultiple ? 'Select files' : 'Select a file'} to upload
158
+ </p>
159
+ {input.config.fileSizeLimitKib ? (
160
+ <p class="text-xs text-neutral-10">(max {kbToReadableSize(input.config.fileSizeLimitKib)})</p>
161
+ ) : null}
162
+ </header>
163
+ <aside class="flex flex-col gap-2 items-center">
164
+ <p id="accepted-filetypes" class="sr-only">
165
+ Accepted file extensions
166
+ </p>
167
+ <ul aria-describedby="accepted-filetypes" class="flex gap-2 flex-wrap justify-center">
168
+ {input.config.extensions.map(ext => (
169
+ <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">
170
+ {ext.replace('.', '')}
171
+ </li>
172
+ ))}
173
+ </ul>
174
+ </aside>
175
+ </div>
176
+ )}
177
+
178
+ <input
179
+ id="dropzone-file"
180
+ onInput={async e => {
181
+ invariant(e.target instanceof HTMLInputElement);
182
+ const files = e.target.files ? Array.from(e.target.files) : [];
183
+ const filesToUpload = await Promise.allSettled(
184
+ files.map(async file => {
185
+ const data = await toBase64(file).then(gzip);
186
+ return {
187
+ name: file.name,
188
+ data: data,
189
+ sizeKb: file.size / 1000,
190
+ };
191
+ }),
192
+ );
193
+ if (filesToUpload.some(({ status }) => status === 'rejected')) {
194
+ return setError({ type: 'invalid', message: 'Invalid file' });
195
+ }
196
+ const validFiles = filesToUpload
197
+ .map(promise => (promise.status === 'fulfilled' ? promise.value : null))
198
+ .filter(Boolean) as FileToUpload[];
199
+ setFiles(validFiles);
200
+ }}
201
+ multiple={input.config.allowMultiple}
202
+ type="file"
203
+ class="sr-only"
204
+ />
205
+ </label>
206
+ <SendButton disabled={files.length === 0} />
207
+ </div>
208
+ {error && <InputError error={error} />}
209
+ </form>
210
+ );
211
+ };
@@ -0,0 +1,92 @@
1
+ import { zodResolver } from '@hookform/resolvers/zod';
2
+ import { useForm } from 'react-hook-form';
3
+ import { z } from 'zod';
4
+ import { useApplicationSubmission } from '~/chatbot.state';
5
+ import { isSubmissionOfType } from '~/chatbot.utils';
6
+
7
+ import { InputError } from '../input-error';
8
+ import { SendButton } from '../send-button';
9
+ import { ChatInputProps } from './chat-input';
10
+
11
+ export type MultipleChoicePayload = {
12
+ type: 'multiple-choice';
13
+ key: string;
14
+ config: { options: { value: string; label: string }[]; minSelected?: number; maxSelected?: number };
15
+ value: string[];
16
+ };
17
+
18
+ const isMultipleChoiceSubmission = isSubmissionOfType('multiple-choice');
19
+ const errorMap: z.ZodErrorMap = (issue, ctx) => {
20
+ if (issue.code === z.ZodIssueCode.too_small)
21
+ return { message: `Please select at least ${issue.minimum} option${issue.minimum !== 1 ? 's' : ''}` };
22
+ if (issue.code === z.ZodIssueCode.too_big)
23
+ return { message: `Please select at most ${issue.maximum} option${issue.maximum !== 1 ? 's' : ''}` };
24
+ return { message: ctx.defaultError };
25
+ };
26
+
27
+ const getResolver = (config: MultipleChoicePayload['config']) =>
28
+ zodResolver(
29
+ z.object({
30
+ checked: z
31
+ .record(z.boolean())
32
+ .transform(o =>
33
+ Object.entries(o)
34
+ .filter(([_, v]) => v)
35
+ .map(([k, _]) => k),
36
+ )
37
+ .pipe(
38
+ z
39
+ .array(z.string(), { errorMap })
40
+ .max(config.maxSelected ?? config.options.length)
41
+ .min(config.minSelected ?? 0),
42
+ ),
43
+ }),
44
+ );
45
+
46
+ export const ChatInputMultipleChoice = ({ input, onSubmitSuccess, application }: ChatInputProps<'multiple-choice'>) => {
47
+ const submission = useApplicationSubmission(application, input.key);
48
+ const {
49
+ register,
50
+ handleSubmit,
51
+ formState: { errors },
52
+ } = useForm({
53
+ defaultValues: {
54
+ checked: isMultipleChoiceSubmission(submission)
55
+ ? Object.fromEntries(submission.value.map(key => [key, true]))
56
+ : {},
57
+ },
58
+ resolver: getResolver(input.config),
59
+ });
60
+
61
+ return (
62
+ <form
63
+ class="flex flex-col gap-1"
64
+ onSubmit={handleSubmit(submission => {
65
+ // react-hook-form does not play well with zod's transform
66
+ const checked = submission.checked as unknown as string[];
67
+ onSubmitSuccess(checked);
68
+ })}
69
+ >
70
+ <div class="flex items-center gap-2">
71
+ <div class="flex flex-wrap gap-3 flex-1 p-1 w-full">
72
+ {input.config.options.map((option, i) => {
73
+ const id = `checked.${option.value}` as const;
74
+ return (
75
+ <div key={option.value}>
76
+ <input autoFocus={i === 0} id={id} {...register(id)} class="peer sr-only" type="checkbox"></input>
77
+ <label
78
+ 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"
79
+ htmlFor={id}
80
+ >
81
+ {option.label}
82
+ </label>
83
+ </div>
84
+ );
85
+ })}
86
+ </div>
87
+ <SendButton />
88
+ </div>
89
+ <InputError error={errors.checked?.root} />
90
+ </form>
91
+ );
92
+ };
@@ -0,0 +1,105 @@
1
+ import { zodResolver } from '@hookform/resolvers/zod';
2
+ import { useEffect, useRef } from 'preact/hooks';
3
+ import { useForm } from 'react-hook-form';
4
+ import { z } from 'zod';
5
+ import { useApplicationSubmission } 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
+ type InputFormat = ChatInputProps<'text'>['input']['config']['format'];
20
+ const inputFormatToSchema: Record<InputFormat, z.ZodType<string>> = {
21
+ email: z.string().email('That doesn’t look like a valid email address'),
22
+ phone: z
23
+ .string()
24
+ .regex(/^\+?[0-9 -]+$/, 'That doesn’t look like a valid phone number')
25
+ .transform(value => value.replace(/[^0-9]/g, '')),
26
+ text: z.string().min(1, 'Please enter some text'),
27
+ url: z.string().url("That doesn't look like a valid URL"),
28
+ };
29
+
30
+ const inputFormatToProps = {
31
+ email: {
32
+ type: 'email',
33
+ inputMode: 'email',
34
+ formNoValidate: true,
35
+ },
36
+ phone: {
37
+ type: 'tel',
38
+ inputMode: 'tel',
39
+ },
40
+ text: {
41
+ type: 'text',
42
+ inputMode: 'text',
43
+ },
44
+ url: {
45
+ type: 'url',
46
+ inputMode: 'url',
47
+ formNoValidate: true,
48
+ },
49
+ } satisfies Record<InputFormat, JSX.IntrinsicElements['input']>;
50
+
51
+ const isTextSubmission = isSubmissionOfType('text');
52
+ const getResolver = (config: TextPayload['config']) =>
53
+ zodResolver(z.object({ text: inputFormatToSchema[config.format] }));
54
+
55
+ export const ChatInputText = ({ input, onSubmitSuccess, application }: ChatInputProps<'text'>) => {
56
+ const submission = useApplicationSubmission(application, input.key);
57
+ const {
58
+ register,
59
+ handleSubmit,
60
+ formState: { errors },
61
+ } = useForm({
62
+ defaultValues: {
63
+ text: isTextSubmission(submission) ? submission.value : '',
64
+ },
65
+ resolver: getResolver(input.config),
66
+ });
67
+ const { ref: setRef, ...props } = register('text', { required: true });
68
+ const ref = useRef<HTMLInputElement>();
69
+ useEffect(() => {
70
+ if (ref.current) {
71
+ ref.current.focus();
72
+ ref.current.select();
73
+ }
74
+ }, []);
75
+
76
+ return (
77
+ <form
78
+ class="flex flex-col gap-1"
79
+ onSubmit={handleSubmit(submission => {
80
+ onSubmitSuccess(submission.text);
81
+ })}
82
+ >
83
+ <div class="flex gap-2 items-center">
84
+ <input
85
+ {...props}
86
+ {...inputFormatToProps[input.config.format]}
87
+ autocomplete="off"
88
+ autoCapitalize="off"
89
+ autoCorrect="off"
90
+ autoFocus
91
+ ref={(e: HTMLInputElement | null) => {
92
+ if (e) {
93
+ ref.current = e;
94
+ }
95
+ setRef(e);
96
+ }}
97
+ 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"
98
+ placeholder={input.config.placeholder}
99
+ />
100
+ <SendButton />
101
+ </div>
102
+ <InputError error={errors.text} />
103
+ </form>
104
+ );
105
+ };
@@ -0,0 +1,57 @@
1
+ import { useEffect } from 'preact/hooks';
2
+ import { P, match } from 'ts-pattern';
3
+ import { JobApplication } from '~/chatbot.api';
4
+ import { DistributivePick } from '~/chatbot.utils';
5
+
6
+ import { ApplicationLocalState, ApplicationSubmission, useApplicationInput } from '../../chatbot.state';
7
+ import { ChatInput as ChatInputType } from './chat-input';
8
+ import { BooleanChoicePayload, ChatInputBoolean } from './chat-input.boolean';
9
+ import { ChatInputFile, FilePayload } from './chat-input.file';
10
+ import { ChatInputMultipleChoice, MultipleChoicePayload } from './chat-input.multiple-choice';
11
+ import { ChatInputText, TextPayload } from './chat-input.text';
12
+
13
+ export type SubmitSuccessFn = (submission: ApplicationSubmission) => void;
14
+
15
+ export type ChatInputProps<T extends ChatInputType['type']> = {
16
+ application: JobApplication;
17
+ onSubmitSuccess: (value: Extract<ApplicationSubmission, { type: T }>['value']) => void;
18
+ input: Extract<ChatInputType, { type: T }>;
19
+ localState?: ApplicationLocalState;
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
+ application: JobApplication;
28
+ onInputChange: (input?: ChatInputType['type']) => void;
29
+ };
30
+ export const ChatInput = ({ onSubmit, application, onInputChange }: ChatInputFactoryProps) => {
31
+ const input = useApplicationInput(application);
32
+
33
+ useEffect(() => {
34
+ onInputChange(input?.type);
35
+ }, [input?.type, onInputChange]);
36
+
37
+ const handleSubmitSuccess =
38
+ <T extends ChatbotInput['type']>(type: T) =>
39
+ (value: (ChatbotInput & { type: T })['value']) =>
40
+ onSubmit({ type, value } as ApplicationSubmission);
41
+
42
+ return match({ application, input })
43
+ .with({ input: P.nullish }, () => <div class="h-8" />)
44
+ .with({ input: { type: 'text' } }, props => (
45
+ <ChatInputText onSubmitSuccess={handleSubmitSuccess(props.input.type)} {...props} />
46
+ ))
47
+ .with({ input: { type: 'multiple-choice' } }, props => (
48
+ <ChatInputMultipleChoice onSubmitSuccess={handleSubmitSuccess(props.input.type)} {...props} />
49
+ ))
50
+ .with({ input: { type: 'boolean' } }, props => (
51
+ <ChatInputBoolean onSubmitSuccess={handleSubmitSuccess(props.input.type)} {...props} />
52
+ ))
53
+ .with({ input: { type: 'file' } }, props => (
54
+ <ChatInputFile onSubmitSuccess={handleSubmitSuccess(props.input.type)} {...props} />
55
+ ))
56
+ .exhaustive();
57
+ };
@@ -0,0 +1,89 @@
1
+ import clsx from 'clsx';
2
+ import { ComponentProps } from 'preact';
3
+ import { useShallow } from 'zustand/react/shallow';
4
+ import { JobApplication } from '~/chatbot.api';
5
+
6
+ import { useChatbotStore, useLocalState } from '../chatbot.state';
7
+
8
+ const HeaderIconButton = ({ class: className, children, ...props }: ComponentProps<'button'>) => {
9
+ return (
10
+ <button
11
+ class={clsx(
12
+ 'p-1.5 rounded-full text-neutral-11 hover:text-neutral-12 active:text-neutral-12 hover:bg-neutral-12/5 active:bg-neutral-12/10',
13
+ className,
14
+ )}
15
+ {...props}
16
+ >
17
+ <svg
18
+ class="block"
19
+ width="16"
20
+ height="16"
21
+ viewBox="0 0 16 16"
22
+ fill="none"
23
+ stroke="currentColor"
24
+ stroke-width="1.5"
25
+ stroke-linecap="round"
26
+ xmlns="http://www.w3.org/2000/svg"
27
+ >
28
+ {children}
29
+ </svg>
30
+ </button>
31
+ );
32
+ };
33
+
34
+ export const ChatbotHeader = ({ application }: { application: JobApplication }) => {
35
+ const resetApplicationState = useLocalState(s => s.resetApplicationState);
36
+ const { viewState, setViewState, cancelCurrentApplication, startApplication } = useChatbotStore(
37
+ useShallow(s => ({
38
+ viewState: s.viewState,
39
+ setViewState: s.setViewState,
40
+ cancelCurrentApplication: s.cancelCurrentApplication,
41
+ startApplication: s.startApplication,
42
+ })),
43
+ );
44
+
45
+ return (
46
+ <header class="z-20 p-1 px-2 flex gap-2 h-[var(--header-height)] rounded-t-3xl left-0 outline outline-1 outline-neutral-5 right-0 mx-auto items-center absolute top-0 bg-neutral-1/90 backdrop-blur-md backdrop-saturate-150">
47
+ <div class="flex-grow overflow-hidden pl-2">
48
+ <p class="font-bold text-sm tracking-tight text-neutral-12 truncate">
49
+ Applying for “{application.job.title}” at {application.company.name}
50
+ </p>
51
+ </div>
52
+
53
+ <div class="flex-shrink-0 flex items-center gap-1">
54
+ {viewState === 'minimised' ? (
55
+ <>
56
+ <HeaderIconButton
57
+ key="minmax"
58
+ aria-label="Maximise job application"
59
+ onClick={() => setViewState('maximised')}
60
+ >
61
+ <path d="M12.5 9.5L8 5L3.5 9.5" />
62
+ </HeaderIconButton>
63
+ <HeaderIconButton key="close" aria-label="Close application" onClick={cancelCurrentApplication}>
64
+ <path d="M12.5 6.5L8 11L3.5 6.5" />
65
+ </HeaderIconButton>
66
+ </>
67
+ ) : (
68
+ <>
69
+ <HeaderIconButton
70
+ key="restart"
71
+ aria-label="Restart"
72
+ onClick={() => {
73
+ cancelCurrentApplication();
74
+ resetApplicationState(application);
75
+ startApplication(application);
76
+ }}
77
+ >
78
+ <path d="M12 8.5C12 9.29113 11.7654 10.0645 11.3259 10.7223C10.8864 11.3801 10.2616 11.8928 9.53073 12.1955C8.79983 12.4983 7.99556 12.5775 7.21964 12.4231C6.44371 12.2688 5.73098 11.8878 5.17157 11.3284C4.61216 10.769 4.2312 10.0563 4.07686 9.28036C3.92252 8.50444 4.00173 7.70017 4.30448 6.96927C4.60723 6.23836 5.11992 5.61365 5.77772 5.17412C6.43552 4.7346 7.20887 4.5 8 4.5H9" />
79
+ <path d="M8 7L10 4.5L8 2.5" />
80
+ </HeaderIconButton>
81
+ <HeaderIconButton key="minmax" aria-label="Minimise application" onClick={() => setViewState('minimised')}>
82
+ <path d="M12.5 6.5L8 11L3.5 6.5" />
83
+ </HeaderIconButton>
84
+ </>
85
+ )}
86
+ </div>
87
+ </header>
88
+ );
89
+ };