@inploi/plugin-chatbot 1.0.6 → 2.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.
- package/CHANGELOG.md +19 -0
- package/package.json +13 -9
- package/src/chatbot.api.ts +14 -14
- package/src/chatbot.constants.ts +3 -0
- package/src/chatbot.css +8 -1
- package/src/chatbot.dom.ts +0 -6
- package/src/chatbot.idb.ts +17 -0
- package/src/chatbot.state.ts +52 -143
- package/src/chatbot.ts +16 -31
- package/src/chatbot.utils.ts +21 -9
- package/src/index.dev.ts +6 -0
- package/src/mocks/example.flows.ts +1 -1
- package/src/ui/chat-bubble.tsx +21 -5
- package/src/ui/chat-input/chat-input.boolean.tsx +10 -5
- package/src/ui/chat-input/chat-input.file.tsx +8 -6
- package/src/ui/chat-input/chat-input.multiple-choice.tsx +52 -27
- package/src/ui/chat-input/chat-input.text.tsx +22 -16
- package/src/ui/chat-input/chat-input.tsx +47 -23
- package/src/ui/chatbot-header.tsx +35 -26
- package/src/ui/chatbot.tsx +95 -43
- package/src/ui/input-error.tsx +25 -31
- package/src/ui/job-application-content.tsx +65 -42
- package/src/ui/job-application-messages.tsx +42 -34
- package/src/ui/useChatService.ts +10 -17
- package/src/ui/useFocus.ts +10 -0
|
@@ -4,11 +4,12 @@ import { ComponentProps } from 'preact';
|
|
|
4
4
|
import { useState } from 'preact/hooks';
|
|
5
5
|
import { FieldError } from 'react-hook-form';
|
|
6
6
|
import { P, match } from 'ts-pattern';
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
7
|
+
import { application } from '~/chatbot.state';
|
|
8
|
+
import { isSubmissionOfType } from '~/chatbot.utils';
|
|
9
9
|
|
|
10
10
|
import { InputError } from '../input-error';
|
|
11
11
|
import { SendButton } from '../send-button';
|
|
12
|
+
import { useFocusOnMount } from '../useFocus';
|
|
12
13
|
import { ChatInputProps } from './chat-input';
|
|
13
14
|
|
|
14
15
|
export type FileToUpload = { name: string; data: string; sizeKb: number };
|
|
@@ -81,13 +82,13 @@ const FilenameBadge = ({ class: className, ...props }: ComponentProps<'li'>) =>
|
|
|
81
82
|
);
|
|
82
83
|
1;
|
|
83
84
|
|
|
84
|
-
export const ChatInputFile = ({ input, onSubmitSuccess
|
|
85
|
-
const submission =
|
|
85
|
+
export const ChatInputFile = ({ input, onSubmitSuccess }: ChatInputProps<'file'>) => {
|
|
86
|
+
const submission = application.current$.value?.data.submissions[input.key];
|
|
86
87
|
const [files, setFiles] = useState<FileToUpload[]>(isFileSubmission(submission) ? submission.value : []);
|
|
87
88
|
const [error, setError] = useState<FieldError>();
|
|
88
89
|
const hiddenFileCount = files.length - FILENAMES_TO_SHOW_QTY;
|
|
89
90
|
const totalSize = addFileSizesKb(files);
|
|
90
|
-
|
|
91
|
+
const focusRef = useFocusOnMount();
|
|
91
92
|
return (
|
|
92
93
|
<form
|
|
93
94
|
class="flex flex-col gap-1"
|
|
@@ -108,6 +109,7 @@ export const ChatInputFile = ({ input, onSubmitSuccess, application }: ChatInput
|
|
|
108
109
|
>
|
|
109
110
|
<div class="flex gap-2 items-center">
|
|
110
111
|
<label
|
|
112
|
+
ref={focusRef}
|
|
111
113
|
for="dropzone-file"
|
|
112
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"
|
|
113
115
|
>
|
|
@@ -182,7 +184,7 @@ export const ChatInputFile = ({ input, onSubmitSuccess, application }: ChatInput
|
|
|
182
184
|
const files = e.target.files ? Array.from(e.target.files) : [];
|
|
183
185
|
const filesToUpload = await Promise.allSettled(
|
|
184
186
|
files.map(async file => {
|
|
185
|
-
const data = await toBase64(file)
|
|
187
|
+
const data = await toBase64(file);
|
|
186
188
|
return {
|
|
187
189
|
name: file.name,
|
|
188
190
|
data: data,
|
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { valibotResolver } from '@hookform/resolvers/valibot';
|
|
2
2
|
import { useForm } from 'react-hook-form';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { boolean, maxLength, minLength, object, record, transform } from 'valibot';
|
|
4
|
+
import { application } from '~/chatbot.state';
|
|
5
5
|
import { isSubmissionOfType } from '~/chatbot.utils';
|
|
6
6
|
|
|
7
7
|
import { InputError } from '../input-error';
|
|
8
8
|
import { SendButton } from '../send-button';
|
|
9
|
+
import { useFocusOnMount } from '../useFocus';
|
|
9
10
|
import { ChatInputProps } from './chat-input';
|
|
10
11
|
|
|
11
12
|
export type MultipleChoicePayload = {
|
|
@@ -15,36 +16,39 @@ export type MultipleChoicePayload = {
|
|
|
15
16
|
value: string[];
|
|
16
17
|
};
|
|
17
18
|
|
|
18
|
-
const
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
return { message: `Please select at most ${issue.maximum} option${issue.maximum !== 1 ? 's' : ''}` };
|
|
24
|
-
return { message: ctx.defaultError };
|
|
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 }));
|
|
25
24
|
};
|
|
26
25
|
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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 =>
|
|
33
38
|
Object.entries(o)
|
|
34
39
|
.filter(([_, v]) => v)
|
|
35
40
|
.map(([k, _]) => k),
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
.min(config.minSelected ?? 0),
|
|
42
|
-
),
|
|
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
|
+
),
|
|
43
46
|
}),
|
|
44
47
|
);
|
|
48
|
+
};
|
|
45
49
|
|
|
46
|
-
export const ChatInputMultipleChoice = ({ input, onSubmitSuccess
|
|
47
|
-
const submission =
|
|
50
|
+
export const ChatInputMultipleChoice = ({ input, onSubmitSuccess }: ChatInputProps<'multiple-choice'>) => {
|
|
51
|
+
const submission = application.current$.value?.data.submissions[input.key];
|
|
48
52
|
const {
|
|
49
53
|
register,
|
|
50
54
|
handleSubmit,
|
|
@@ -57,10 +61,18 @@ export const ChatInputMultipleChoice = ({ input, onSubmitSuccess, application }:
|
|
|
57
61
|
},
|
|
58
62
|
resolver: getResolver(input.config),
|
|
59
63
|
});
|
|
64
|
+
const focusRef = useFocusOnMount();
|
|
65
|
+
const isSingleChoice = input.config.minSelected === 1 && input.config.maxSelected === 1;
|
|
60
66
|
|
|
61
67
|
return (
|
|
62
68
|
<form
|
|
69
|
+
noValidate
|
|
63
70
|
class="flex flex-col gap-1"
|
|
71
|
+
onChange={e => {
|
|
72
|
+
if (isSingleChoice) {
|
|
73
|
+
submitIfSingleChecked(e.currentTarget);
|
|
74
|
+
}
|
|
75
|
+
}}
|
|
64
76
|
onSubmit={handleSubmit(submission => {
|
|
65
77
|
// react-hook-form does not play well with zod's transform
|
|
66
78
|
const checked = submission.checked as unknown as string[];
|
|
@@ -71,9 +83,22 @@ export const ChatInputMultipleChoice = ({ input, onSubmitSuccess, application }:
|
|
|
71
83
|
<div class="flex flex-wrap gap-3 flex-1 p-1 w-full">
|
|
72
84
|
{input.config.options.map((option, i) => {
|
|
73
85
|
const id = `checked.${option.value}` as const;
|
|
86
|
+
const { ref: setRef, ...props } = register(id);
|
|
74
87
|
return (
|
|
75
88
|
<div key={option.value}>
|
|
76
|
-
<input
|
|
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>
|
|
77
102
|
<label
|
|
78
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"
|
|
79
104
|
htmlFor={id}
|
|
@@ -84,7 +109,7 @@ export const ChatInputMultipleChoice = ({ input, onSubmitSuccess, application }:
|
|
|
84
109
|
);
|
|
85
110
|
})}
|
|
86
111
|
</div>
|
|
87
|
-
<SendButton />
|
|
112
|
+
{!isSingleChoice && <SendButton />}
|
|
88
113
|
</div>
|
|
89
114
|
<InputError error={errors.checked?.root} />
|
|
90
115
|
</form>
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { valibotResolver } from '@hookform/resolvers/valibot';
|
|
2
|
+
import { useLayoutEffect, useRef } from 'preact/hooks';
|
|
3
3
|
import { useForm } from 'react-hook-form';
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
4
|
+
import { StringSchema, email, minLength, object, regex, string, transform, url } from 'valibot';
|
|
5
|
+
import { application } from '~/chatbot.state';
|
|
6
6
|
import { isSubmissionOfType } from '~/chatbot.utils';
|
|
7
7
|
|
|
8
8
|
import { InputError } from '../input-error';
|
|
@@ -16,15 +16,19 @@ export type TextPayload = {
|
|
|
16
16
|
value: string;
|
|
17
17
|
};
|
|
18
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
|
+
|
|
19
26
|
type InputFormat = ChatInputProps<'text'>['input']['config']['format'];
|
|
20
|
-
const inputFormatToSchema: Record<InputFormat,
|
|
21
|
-
email:
|
|
22
|
-
phone:
|
|
23
|
-
|
|
24
|
-
|
|
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"),
|
|
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')]),
|
|
28
32
|
};
|
|
29
33
|
|
|
30
34
|
const inputFormatToProps = {
|
|
@@ -50,10 +54,10 @@ const inputFormatToProps = {
|
|
|
50
54
|
|
|
51
55
|
const isTextSubmission = isSubmissionOfType('text');
|
|
52
56
|
const getResolver = (config: TextPayload['config']) =>
|
|
53
|
-
|
|
57
|
+
valibotResolver(object({ text: inputFormatToSchema[config.format] }));
|
|
54
58
|
|
|
55
|
-
export const ChatInputText = ({ input, onSubmitSuccess
|
|
56
|
-
const submission =
|
|
59
|
+
export const ChatInputText = ({ input, onSubmitSuccess }: ChatInputProps<'text'>) => {
|
|
60
|
+
const submission = application.current$.value?.data.submissions[input.key];
|
|
57
61
|
const {
|
|
58
62
|
register,
|
|
59
63
|
handleSubmit,
|
|
@@ -66,7 +70,7 @@ export const ChatInputText = ({ input, onSubmitSuccess, application }: ChatInput
|
|
|
66
70
|
});
|
|
67
71
|
const { ref: setRef, ...props } = register('text', { required: true });
|
|
68
72
|
const ref = useRef<HTMLInputElement>();
|
|
69
|
-
|
|
73
|
+
useLayoutEffect(() => {
|
|
70
74
|
if (ref.current) {
|
|
71
75
|
ref.current.focus();
|
|
72
76
|
ref.current.select();
|
|
@@ -75,6 +79,7 @@ export const ChatInputText = ({ input, onSubmitSuccess, application }: ChatInput
|
|
|
75
79
|
|
|
76
80
|
return (
|
|
77
81
|
<form
|
|
82
|
+
noValidate
|
|
78
83
|
class="flex flex-col gap-1"
|
|
79
84
|
onSubmit={handleSubmit(submission => {
|
|
80
85
|
onSubmitSuccess(submission.text);
|
|
@@ -82,6 +87,7 @@ export const ChatInputText = ({ input, onSubmitSuccess, application }: ChatInput
|
|
|
82
87
|
>
|
|
83
88
|
<div class="flex gap-2 items-center">
|
|
84
89
|
<input
|
|
90
|
+
id="chat-input"
|
|
85
91
|
{...props}
|
|
86
92
|
{...inputFormatToProps[input.config.format]}
|
|
87
93
|
autocomplete="off"
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { m } from 'framer-motion';
|
|
2
|
+
import { useEffect, useRef } from 'preact/hooks';
|
|
2
3
|
import { P, match } from 'ts-pattern';
|
|
3
|
-
import { JobApplication } from '~/chatbot.api';
|
|
4
4
|
import { DistributivePick } from '~/chatbot.utils';
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import { ApplicationData, ApplicationSubmission, application, inputHeight } from '../../chatbot.state';
|
|
7
|
+
import { SendButton } from '../send-button';
|
|
7
8
|
import { ChatInput as ChatInputType } from './chat-input';
|
|
8
9
|
import { BooleanChoicePayload, ChatInputBoolean } from './chat-input.boolean';
|
|
9
10
|
import { ChatInputFile, FilePayload } from './chat-input.file';
|
|
@@ -13,10 +14,9 @@ import { ChatInputText, TextPayload } from './chat-input.text';
|
|
|
13
14
|
export type SubmitSuccessFn = (submission: ApplicationSubmission) => void;
|
|
14
15
|
|
|
15
16
|
export type ChatInputProps<T extends ChatInputType['type']> = {
|
|
16
|
-
application: JobApplication;
|
|
17
17
|
onSubmitSuccess: (value: Extract<ApplicationSubmission, { type: T }>['value']) => void;
|
|
18
18
|
input: Extract<ChatInputType, { type: T }>;
|
|
19
|
-
localState?:
|
|
19
|
+
localState?: ApplicationData;
|
|
20
20
|
};
|
|
21
21
|
|
|
22
22
|
export type ChatbotInput = TextPayload | MultipleChoicePayload | BooleanChoicePayload | FilePayload;
|
|
@@ -24,13 +24,16 @@ export type ChatInput = DistributivePick<ChatbotInput, 'type' | 'config' | 'key'
|
|
|
24
24
|
|
|
25
25
|
type ChatInputFactoryProps = {
|
|
26
26
|
onSubmit: SubmitSuccessFn;
|
|
27
|
-
application: JobApplication;
|
|
28
27
|
onInputChange: (input?: ChatInputType['type']) => void;
|
|
29
28
|
};
|
|
30
|
-
export const ChatInput = ({ onSubmit,
|
|
31
|
-
const input =
|
|
29
|
+
export const ChatInput = ({ onSubmit, onInputChange }: ChatInputFactoryProps) => {
|
|
30
|
+
const input = application.current$.value?.data.currentInput;
|
|
31
|
+
const inputWrapperRef = useRef<HTMLDivElement>(null);
|
|
32
32
|
|
|
33
33
|
useEffect(() => {
|
|
34
|
+
if (inputWrapperRef.current) {
|
|
35
|
+
inputHeight.value = inputWrapperRef.current.getBoundingClientRect().height;
|
|
36
|
+
}
|
|
34
37
|
onInputChange(input?.type);
|
|
35
38
|
}, [input?.type, onInputChange]);
|
|
36
39
|
|
|
@@ -39,19 +42,40 @@ export const ChatInput = ({ onSubmit, application, onInputChange }: ChatInputFac
|
|
|
39
42
|
(value: (ChatbotInput & { type: T })['value']) =>
|
|
40
43
|
onSubmit({ type, value } as ApplicationSubmission);
|
|
41
44
|
|
|
42
|
-
return
|
|
43
|
-
.
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
+
);
|
|
57
81
|
};
|
|
@@ -1,15 +1,13 @@
|
|
|
1
1
|
import clsx from 'clsx';
|
|
2
2
|
import { ComponentProps } from 'preact';
|
|
3
|
-
import { useShallow } from 'zustand/react/shallow';
|
|
4
|
-
import { JobApplication } from '~/chatbot.api';
|
|
5
3
|
|
|
6
|
-
import {
|
|
4
|
+
import { StartedJobApplication, application, cancelCurrentApplication, viewState } from '../chatbot.state';
|
|
7
5
|
|
|
8
6
|
const HeaderIconButton = ({ class: className, children, ...props }: ComponentProps<'button'>) => {
|
|
9
7
|
return (
|
|
10
8
|
<button
|
|
11
9
|
class={clsx(
|
|
12
|
-
'p-
|
|
10
|
+
'p-2 touch-hitbox relative rounded-full text-neutral-11 hover:text-neutral-12 active:text-neutral-12 hover:bg-neutral-12/5 active:bg-neutral-12/10',
|
|
13
11
|
className,
|
|
14
12
|
)}
|
|
15
13
|
{...props}
|
|
@@ -31,32 +29,41 @@ const HeaderIconButton = ({ class: className, children, ...props }: ComponentPro
|
|
|
31
29
|
);
|
|
32
30
|
};
|
|
33
31
|
|
|
34
|
-
export const ChatbotHeader = ({
|
|
35
|
-
const
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
setViewState: s.setViewState,
|
|
40
|
-
cancelCurrentApplication: s.cancelCurrentApplication,
|
|
41
|
-
startApplication: s.startApplication,
|
|
42
|
-
})),
|
|
43
|
-
);
|
|
32
|
+
export const ChatbotHeader = ({ currentApplication }: { currentApplication?: StartedJobApplication }) => {
|
|
33
|
+
const view = viewState.value;
|
|
34
|
+
const headerText = currentApplication
|
|
35
|
+
? `Applying for “${currentApplication.job.title}” at ${currentApplication.company.name}`
|
|
36
|
+
: 'Applying';
|
|
44
37
|
|
|
45
38
|
return (
|
|
46
|
-
<header class="z-20
|
|
47
|
-
<
|
|
48
|
-
|
|
49
|
-
|
|
39
|
+
<header class="z-20 flex gap-2 h-[var(--header-height)] rounded-t-2xl 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">
|
|
40
|
+
<p id="chatbot-header" class="sr-only">
|
|
41
|
+
{headerText}
|
|
42
|
+
</p>
|
|
43
|
+
<button
|
|
44
|
+
tabIndex={-1}
|
|
45
|
+
aria-hidden
|
|
46
|
+
onClick={() => {
|
|
47
|
+
if (view === 'minimised') {
|
|
48
|
+
viewState.value = 'maximised';
|
|
49
|
+
} else {
|
|
50
|
+
viewState.value = 'minimised';
|
|
51
|
+
}
|
|
52
|
+
}}
|
|
53
|
+
class="flex-grow h-full overflow-hidden py-1 px-4"
|
|
54
|
+
>
|
|
55
|
+
<p aria-hidden class="font-bold text-sm tracking-tight text-neutral-12 truncate">
|
|
56
|
+
{headerText}
|
|
50
57
|
</p>
|
|
51
|
-
</
|
|
58
|
+
</button>
|
|
52
59
|
|
|
53
|
-
<div class="flex-shrink-0 flex items-center gap-1">
|
|
54
|
-
{viewState === 'minimised' ? (
|
|
60
|
+
<div class="flex-shrink-0 flex items-center gap-3 p-1.5">
|
|
61
|
+
{viewState.value === 'minimised' ? (
|
|
55
62
|
<>
|
|
56
63
|
<HeaderIconButton
|
|
57
64
|
key="minmax"
|
|
58
65
|
aria-label="Maximise job application"
|
|
59
|
-
onClick={() =>
|
|
66
|
+
onClick={() => (viewState.value = 'maximised')}
|
|
60
67
|
>
|
|
61
68
|
<path d="M12.5 9.5L8 5L3.5 9.5" />
|
|
62
69
|
</HeaderIconButton>
|
|
@@ -70,15 +77,17 @@ export const ChatbotHeader = ({ application }: { application: JobApplication })
|
|
|
70
77
|
key="restart"
|
|
71
78
|
aria-label="Restart"
|
|
72
79
|
onClick={() => {
|
|
73
|
-
|
|
74
|
-
resetApplicationState(application);
|
|
75
|
-
startApplication(application);
|
|
80
|
+
application.restart();
|
|
76
81
|
}}
|
|
77
82
|
>
|
|
78
83
|
<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
84
|
<path d="M8 7L10 4.5L8 2.5" />
|
|
80
85
|
</HeaderIconButton>
|
|
81
|
-
<HeaderIconButton
|
|
86
|
+
<HeaderIconButton
|
|
87
|
+
key="minmax"
|
|
88
|
+
aria-label="Minimise application"
|
|
89
|
+
onClick={() => (viewState.value = 'minimised')}
|
|
90
|
+
>
|
|
82
91
|
<path d="M12.5 6.5L8 11L3.5 6.5" />
|
|
83
92
|
</HeaderIconButton>
|
|
84
93
|
</>
|
package/src/ui/chatbot.tsx
CHANGED
|
@@ -1,53 +1,105 @@
|
|
|
1
|
-
import { ApiClient, Logger } from '@inploi/sdk';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
1
|
+
import { AnalyticsService, ApiClient, Logger } from '@inploi/sdk';
|
|
2
|
+
import { FocusScope } from '@radix-ui/react-focus-scope';
|
|
3
|
+
import { LazyMotion, domAnimation } from 'framer-motion';
|
|
4
|
+
import { AnimatePresence, Variants, m } from 'framer-motion';
|
|
5
|
+
import { Suspense, lazy, useRef } from 'preact/compat';
|
|
6
|
+
import { HEADER_HEIGHT } from '~/chatbot.constants';
|
|
5
7
|
|
|
6
|
-
import {
|
|
8
|
+
import { application, viewState } from '../chatbot.state';
|
|
7
9
|
import { ChatbotHeader } from './chatbot-header';
|
|
8
|
-
|
|
9
|
-
|
|
10
|
+
|
|
11
|
+
const JobApplicationContent = lazy(() =>
|
|
12
|
+
import('./job-application-content').then(module => module.JobApplicationContent),
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
const MotionProvider = ({ children }: { children: JSX.Element }) => {
|
|
16
|
+
return <LazyMotion features={domAnimation}>{children}</LazyMotion>;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const chatbotVariants: Variants = {
|
|
20
|
+
closed: { y: 'calc(100% + 1rem)', height: HEADER_HEIGHT },
|
|
21
|
+
maximised: { y: 0, height: '75vh' },
|
|
22
|
+
minimised: { y: 0, height: HEADER_HEIGHT },
|
|
23
|
+
};
|
|
10
24
|
|
|
11
25
|
type ChatbotProps = {
|
|
12
26
|
apiClient: ApiClient;
|
|
13
|
-
logger
|
|
27
|
+
logger: Logger;
|
|
28
|
+
analytics: AnalyticsService;
|
|
14
29
|
};
|
|
15
|
-
export const Chatbot = ({ logger, apiClient }: ChatbotProps) => {
|
|
16
|
-
const
|
|
17
|
-
const
|
|
18
|
-
const
|
|
19
|
-
|
|
30
|
+
export const Chatbot = ({ logger, apiClient, analytics }: ChatbotProps) => {
|
|
31
|
+
const currentApplication = application.current$.value;
|
|
32
|
+
const isApplying = currentApplication !== null;
|
|
33
|
+
const view = viewState.value;
|
|
34
|
+
|
|
35
|
+
const drawerRef = useRef<HTMLDivElement>(null);
|
|
36
|
+
// useEffect(() => {
|
|
37
|
+
// function onVisualViewportChange() {
|
|
38
|
+
// if (!drawerRef.current) return;
|
|
39
|
+
// const visualViewportHeight = window.visualViewport?.height ?? 0;
|
|
40
|
+
// const keyboardHeight = window.innerHeight - visualViewportHeight;
|
|
41
|
+
|
|
42
|
+
// // Difference between window height and height excluding the keyboard
|
|
43
|
+
// const diffFromInitial = window.innerHeight - visualViewportHeight;
|
|
44
|
+
|
|
45
|
+
// const drawerHeight = drawerRef.current.getBoundingClientRect().height;
|
|
46
|
+
// const offset = keyboardHeight - drawerHeight;
|
|
47
|
+
|
|
48
|
+
// drawerRef.current.style.height = `${visualViewportHeight - offset}px`;
|
|
49
|
+
// drawerRef.current.style.bottom = `${Math.max(diffFromInitial, 0)}px`;
|
|
50
|
+
// }
|
|
51
|
+
|
|
52
|
+
// window.visualViewport?.addEventListener('resize', onVisualViewportChange);
|
|
53
|
+
|
|
54
|
+
// return () => window.visualViewport?.removeEventListener('resize', onVisualViewportChange);
|
|
55
|
+
// }, []);
|
|
56
|
+
|
|
20
57
|
return (
|
|
21
|
-
|
|
22
|
-
<
|
|
23
|
-
{
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
58
|
+
<MotionProvider>
|
|
59
|
+
<AnimatePresence>
|
|
60
|
+
{isApplying && viewState.value === 'maximised' ? (
|
|
61
|
+
<m.div
|
|
62
|
+
key="bg"
|
|
63
|
+
initial={{ opacity: 0 }}
|
|
64
|
+
animate={{ opacity: 1 }}
|
|
65
|
+
exit={{ opacity: 0 }}
|
|
66
|
+
class="bg-neutral-12/60 fixed inset-0"
|
|
67
|
+
/>
|
|
68
|
+
) : null}
|
|
69
|
+
|
|
70
|
+
{isApplying ? (
|
|
71
|
+
<m.div
|
|
72
|
+
key="content"
|
|
73
|
+
ref={drawerRef}
|
|
74
|
+
aria-modal="true"
|
|
75
|
+
role="dialog"
|
|
76
|
+
aria-labelledby="chatbot-header"
|
|
77
|
+
variants={chatbotVariants}
|
|
78
|
+
initial="closed"
|
|
79
|
+
animate={view}
|
|
80
|
+
exit="closed"
|
|
81
|
+
style={{ '--header-height': `${HEADER_HEIGHT}px` }}
|
|
82
|
+
class="isolate fixed left-2 right-2 mx-auto max-w-[450px] bottom-2 max-h-full focus:outline-none"
|
|
83
|
+
>
|
|
84
|
+
<FocusScope
|
|
85
|
+
class="outline outline-1 h-full outline-neutral-5 relative flex flex-col bg-neutral-2 rounded-3xl overflow-hidden"
|
|
86
|
+
loop
|
|
87
|
+
trapped={false}
|
|
38
88
|
>
|
|
39
|
-
<
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
89
|
+
<ChatbotHeader currentApplication={currentApplication} />
|
|
90
|
+
|
|
91
|
+
<Suspense fallback={<div>loading…</div>}>
|
|
92
|
+
<JobApplicationContent
|
|
93
|
+
analytics={analytics}
|
|
94
|
+
currentApplication={currentApplication}
|
|
95
|
+
apiClient={apiClient}
|
|
96
|
+
logger={logger}
|
|
97
|
+
/>
|
|
98
|
+
</Suspense>
|
|
99
|
+
</FocusScope>
|
|
100
|
+
</m.div>
|
|
101
|
+
) : null}
|
|
102
|
+
</AnimatePresence>
|
|
103
|
+
</MotionProvider>
|
|
52
104
|
);
|
|
53
105
|
};
|