@inploi/plugin-chatbot 1.0.7 → 2.1.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 (41) hide show
  1. package/.env +0 -1
  2. package/.env.example +0 -1
  3. package/.env.test +2 -0
  4. package/CHANGELOG.md +17 -0
  5. package/index.html +2 -1
  6. package/package.json +21 -8
  7. package/playwright.config.ts +82 -0
  8. package/public/mockServiceWorker.js +4 -9
  9. package/src/chatbot.api.ts +14 -14
  10. package/src/chatbot.constants.ts +3 -0
  11. package/src/chatbot.css +8 -15
  12. package/src/chatbot.dom.ts +10 -5
  13. package/src/chatbot.idb.ts +17 -0
  14. package/src/chatbot.state.ts +78 -144
  15. package/src/chatbot.ts +25 -35
  16. package/src/chatbot.utils.ts +27 -9
  17. package/src/index.dev.ts +7 -6
  18. package/src/interpreter/interpreter.ts +28 -20
  19. package/src/mocks/browser.ts +2 -2
  20. package/src/mocks/example.flows.ts +56 -18
  21. package/src/mocks/handlers.ts +37 -8
  22. package/src/style/palette.test.ts +20 -0
  23. package/src/style/palette.ts +69 -0
  24. package/src/ui/chat-bubble.tsx +20 -5
  25. package/src/ui/chat-input/chat-input.boolean.tsx +10 -5
  26. package/src/ui/chat-input/chat-input.file.tsx +8 -6
  27. package/src/ui/chat-input/chat-input.multiple-choice.tsx +52 -27
  28. package/src/ui/chat-input/chat-input.text.tsx +23 -17
  29. package/src/ui/chat-input/chat-input.tsx +47 -23
  30. package/src/ui/chatbot-header.tsx +34 -28
  31. package/src/ui/chatbot.tsx +83 -42
  32. package/src/ui/input-error.tsx +25 -31
  33. package/src/ui/job-application-content.tsx +68 -46
  34. package/src/ui/job-application-messages.tsx +42 -34
  35. package/src/ui/send-button.tsx +1 -1
  36. package/src/ui/typing-indicator.tsx +1 -1
  37. package/src/ui/useChatService.ts +18 -33
  38. package/src/ui/useFocus.ts +10 -0
  39. package/tests/integration.spec.ts +19 -0
  40. package/tests/test.ts +22 -0
  41. package/tsconfig.json +1 -1
@@ -1,11 +1,12 @@
1
- import { zodResolver } from '@hookform/resolvers/zod';
1
+ import { valibotResolver } from '@hookform/resolvers/valibot';
2
2
  import { useForm } from 'react-hook-form';
3
- import { z } from 'zod';
4
- import { useApplicationSubmission } from '~/chatbot.state';
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 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 };
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 getResolver = (config: MultipleChoicePayload['config']) =>
28
- zodResolver(
29
- z.object({
30
- checked: z
31
- .record(z.boolean())
32
- .transform(o =>
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
- .pipe(
38
- z
39
- .array(z.string(), { errorMap })
40
- .max(config.maxSelected ?? config.options.length)
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, application }: ChatInputProps<'multiple-choice'>) => {
47
- const submission = useApplicationSubmission(application, input.key);
50
+ export const ChatInputMultipleChoice = ({ input, onSubmitSuccess }: ChatInputProps<'multiple-choice'>) => {
51
+ const submission = application.current$.value.application?.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 autoFocus={i === 0} id={id} {...register(id)} class="peer sr-only" type="checkbox"></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 { zodResolver } from '@hookform/resolvers/zod';
2
- import { useEffect, useRef } from 'preact/hooks';
1
+ import { valibotResolver } from '@hookform/resolvers/valibot';
2
+ import { useLayoutEffect, useRef } from 'preact/hooks';
3
3
  import { useForm } from 'react-hook-form';
4
- import { z } from 'zod';
5
- import { useApplicationSubmission } from '~/chatbot.state';
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, 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"),
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
- zodResolver(z.object({ text: inputFormatToSchema[config.format] }));
57
+ valibotResolver(object({ text: inputFormatToSchema[config.format] }));
54
58
 
55
- export const ChatInputText = ({ input, onSubmitSuccess, application }: ChatInputProps<'text'>) => {
56
- const submission = useApplicationSubmission(application, input.key);
59
+ export const ChatInputText = ({ input, onSubmitSuccess }: ChatInputProps<'text'>) => {
60
+ const submission = application.current$.value.application?.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
- useEffect(() => {
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"
@@ -94,7 +100,7 @@ export const ChatInputText = ({ input, onSubmitSuccess, application }: ChatInput
94
100
  }
95
101
  setRef(e);
96
102
  }}
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"
103
+ class="flex-grow outline outline-2 outline-neutral-6 px-3 py-1 rounded-full text-base placeholder:text-neutral-8 focus-visible:outline-accent-9 caret-accent-9"
98
104
  placeholder={input.config.placeholder}
99
105
  />
100
106
  <SendButton />
@@ -1,9 +1,10 @@
1
- import { useEffect } from 'preact/hooks';
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 { ApplicationLocalState, ApplicationSubmission, useApplicationInput } from '../../chatbot.state';
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?: ApplicationLocalState;
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;
27
+ input: ChatInputType | undefined;
28
28
  onInputChange: (input?: ChatInputType['type']) => void;
29
29
  };
30
- export const ChatInput = ({ onSubmit, application, onInputChange }: ChatInputFactoryProps) => {
31
- const input = useApplicationInput(application);
30
+ export const ChatInput = ({ onSubmit, onInputChange, input }: ChatInputFactoryProps) => {
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 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();
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
- import { ComponentProps } from 'preact';
3
- import { useShallow } from 'zustand/react/shallow';
4
- import { JobApplication } from '~/chatbot.api';
2
+ import { ComponentChildren, ComponentProps } from 'preact';
5
3
 
6
- import { useChatbotStore, useLocalState } from '../chatbot.state';
4
+ import { application, 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-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',
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,36 +29,42 @@ const HeaderIconButton = ({ class: className, children, ...props }: ComponentPro
31
29
  );
32
30
  };
33
31
 
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
- );
32
+ export const ChatbotHeader = ({ children }: { children?: ComponentChildren }) => {
33
+ const view = viewState.value;
44
34
 
45
35
  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}
36
+ <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">
37
+ <p id="chatbot-header" class="sr-only">
38
+ {children}
39
+ </p>
40
+ <button
41
+ tabIndex={-1}
42
+ aria-hidden
43
+ onClick={() => {
44
+ if (view === 'minimised') {
45
+ viewState.value = 'maximised';
46
+ } else {
47
+ viewState.value = 'minimised';
48
+ }
49
+ }}
50
+ class="flex-grow h-full overflow-hidden py-1 px-4"
51
+ >
52
+ <p aria-hidden class="font-bold text-sm tracking-tight text-neutral-12 truncate">
53
+ {children}
50
54
  </p>
51
- </div>
55
+ </button>
52
56
 
53
- <div class="flex-shrink-0 flex items-center gap-1">
54
- {viewState === 'minimised' ? (
57
+ <div class="flex-shrink-0 flex items-center gap-3 p-1.5">
58
+ {viewState.value === 'minimised' ? (
55
59
  <>
56
60
  <HeaderIconButton
57
61
  key="minmax"
58
62
  aria-label="Maximise job application"
59
- onClick={() => setViewState('maximised')}
63
+ onClick={() => (viewState.value = 'maximised')}
60
64
  >
61
65
  <path d="M12.5 9.5L8 5L3.5 9.5" />
62
66
  </HeaderIconButton>
63
- <HeaderIconButton key="close" aria-label="Close application" onClick={cancelCurrentApplication}>
67
+ <HeaderIconButton key="close" aria-label="Close application" onClick={application.cancel}>
64
68
  <path d="M12.5 6.5L8 11L3.5 6.5" />
65
69
  </HeaderIconButton>
66
70
  </>
@@ -70,15 +74,17 @@ export const ChatbotHeader = ({ application }: { application: JobApplication })
70
74
  key="restart"
71
75
  aria-label="Restart"
72
76
  onClick={() => {
73
- cancelCurrentApplication();
74
- resetApplicationState(application);
75
- startApplication(application);
77
+ application.restart();
76
78
  }}
77
79
  >
78
80
  <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
81
  <path d="M8 7L10 4.5L8 2.5" />
80
82
  </HeaderIconButton>
81
- <HeaderIconButton key="minmax" aria-label="Minimise application" onClick={() => setViewState('minimised')}>
83
+ <HeaderIconButton
84
+ key="minmax"
85
+ aria-label="Minimise application"
86
+ onClick={() => (viewState.value = 'minimised')}
87
+ >
82
88
  <path d="M12.5 6.5L8 11L3.5 6.5" />
83
89
  </HeaderIconButton>
84
90
  </>
@@ -1,53 +1,94 @@
1
- import { ApiClient, Logger } from '@inploi/sdk';
2
- import { useRef } from 'preact/hooks';
3
- import { Transition } from 'react-transition-group';
4
- import { overlayClassNames } from '~/chatbot.dom';
1
+ import { AnalyticsService, ApiClient, Logger } from '@inploi/sdk';
2
+ import * as Dialog from '@radix-ui/react-dialog';
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 { P, match } from 'ts-pattern';
7
+ import { HEADER_HEIGHT } from '~/chatbot.constants';
5
8
 
6
- import { useChatbotStore } from '../chatbot.state';
9
+ import { application, viewState } from '../chatbot.state';
7
10
  import { ChatbotHeader } from './chatbot-header';
8
- import { JobApplicationContent } from './job-application-content';
9
- import { TransitionState } from './transition';
11
+
12
+ const JobApplicationContent = lazy(() =>
13
+ import('./job-application-content').then(module => module.JobApplicationContent),
14
+ );
15
+
16
+ const MotionProvider = ({ children }: { children: JSX.Element }) => {
17
+ return <LazyMotion features={domAnimation}>{children}</LazyMotion>;
18
+ };
19
+
20
+ const chatbotVariants: Variants = {
21
+ closed: { y: 'calc(100% + 1rem)', height: HEADER_HEIGHT },
22
+ maximised: { y: 0, height: '75vh' },
23
+ minimised: { y: 0, height: HEADER_HEIGHT },
24
+ };
10
25
 
11
26
  type ChatbotProps = {
12
27
  apiClient: ApiClient;
13
- logger?: Logger;
28
+ logger: Logger;
29
+ analytics: AnalyticsService;
14
30
  };
15
- export const Chatbot = ({ logger, apiClient }: ChatbotProps) => {
16
- const { currentApplication, viewState } = useChatbotStore();
17
- const isOpen = currentApplication !== null;
18
- const overlayRef = useRef<HTMLDivElement>(null);
19
- const chatRef = useRef<HTMLDivElement>(null);
31
+ export const Chatbot = ({ logger, apiClient, analytics }: ChatbotProps) => {
32
+ const { state, application: currentApplication } = application.current$.value;
33
+ const view = viewState.value;
34
+ const isApplying = state === 'loaded' && view === 'maximised';
35
+ const drawerRef = useRef<HTMLDivElement>(null);
36
+
20
37
  return (
21
- <>
22
- <Transition nodeRef={overlayRef} in={isOpen && viewState === 'maximised'} timeout={100}>
23
- {(state: TransitionState) => {
24
- if (state === 'exited') return null;
25
- return <div ref={overlayRef} data-transition={state} class={overlayClassNames} />;
26
- }}
27
- </Transition>
28
- <Transition appear={true} nodeRef={chatRef} in={isOpen} timeout={0}>
29
- {(state: TransitionState) => {
30
- if (state === 'exited') return null;
31
- return (
32
- <div
33
- ref={chatRef}
34
- style={{ '--header-height': '44px' }}
35
- data-transition={state}
36
- data-state={viewState}
37
- class="isolate h-[75vh] h-[75lvh] data-[transition=entered]:translate-y-0 data-[transition=entered]:data-[state=minimised]:translate-y-[calc(100%-var(--header-height)-0.5rem)] transition-transform ease-expo-out duration-500 translate-y-full fixed left-0 right-0 mx-auto w-full max-w-[450px] bottom-0 p-2 max-h-full overflow-hidden focus:outline-none"
38
+ <MotionProvider>
39
+ <Dialog.Root open={isApplying} modal={isApplying}>
40
+ <AnimatePresence>
41
+ <Dialog.Overlay key="bg" forceMount asChild>
42
+ {isApplying && (
43
+ <m.div
44
+ initial={{ opacity: 0 }}
45
+ animate={{ opacity: 1 }}
46
+ exit={{ opacity: 0 }}
47
+ class="bg-neutral-12/60 fixed inset-0"
48
+ />
49
+ )}
50
+ </Dialog.Overlay>
51
+
52
+ <Dialog.Content forceMount asChild>
53
+ <m.div
54
+ key="content"
55
+ ref={drawerRef}
56
+ aria-modal="true"
57
+ role="dialog"
58
+ aria-labelledby="chatbot-header"
59
+ variants={chatbotVariants}
60
+ initial="closed"
61
+ animate={match({ state, view })
62
+ .with({ state: 'idle' }, () => 'closed')
63
+ .with({ state: 'error' }, () => 'minimised')
64
+ .with({ state: P.union('loaded', 'loading') }, ({ view }) => view)
65
+ .exhaustive()}
66
+ exit="closed"
67
+ style={{ '--header-height': `${HEADER_HEIGHT}px` }}
68
+ class="isolate fixed left-2 right-2 mx-auto max-w-[450px] bottom-2 max-h-full focus:outline-none"
38
69
  >
39
- <div class="outline outline-1 h-full outline-neutral-5 max-h-full relative flex flex-col bg-neutral-2 rounded-3xl overflow-hidden">
40
- {currentApplication && (
41
- <>
42
- <ChatbotHeader application={currentApplication} />
43
- <JobApplicationContent application={currentApplication} apiClient={apiClient} logger={logger} />
44
- </>
45
- )}
70
+ <div class="outline outline-1 h-full outline-neutral-5 relative flex flex-col bg-neutral-2 rounded-3xl overflow-hidden">
71
+ <ChatbotHeader>
72
+ {currentApplication
73
+ ? `Applying for “${currentApplication.job.title} at ${currentApplication.company.name}`
74
+ : 'inploi chatbot'}
75
+ </ChatbotHeader>
76
+
77
+ <Suspense fallback={<div>loading…</div>}>
78
+ {view === 'maximised' && state === 'loaded' && (
79
+ <JobApplicationContent
80
+ analytics={analytics}
81
+ currentApplication={currentApplication}
82
+ apiClient={apiClient}
83
+ logger={logger}
84
+ />
85
+ )}
86
+ </Suspense>
46
87
  </div>
47
- </div>
48
- );
49
- }}
50
- </Transition>
51
- </>
88
+ </m.div>
89
+ </Dialog.Content>
90
+ </AnimatePresence>
91
+ </Dialog.Root>
92
+ </MotionProvider>
52
93
  );
53
94
  };
@@ -1,39 +1,33 @@
1
- import { Ref } from 'preact';
1
+ import { AnimatePresence, m } from 'framer-motion';
2
2
  import type { FieldError } from 'react-hook-form';
3
- import { Transition } from 'react-transition-group';
4
-
5
- import { TransitionState } from './transition';
6
3
 
7
4
  export const InputError = ({ error }: { error?: FieldError }) => {
8
5
  return (
9
- <Transition in={Boolean(error)} timeout={15}>
10
- {(state: TransitionState) => {
11
- if (!error) return null;
12
-
13
- return (
14
- <div
15
- ref={error.ref as Ref<HTMLDivElement> | undefined}
16
- data-transition={state}
17
- role="alert"
18
- class="transition-all opacity-0 ease-expo-out duration-300 translate-y-1/2 data-[transition=entered]:opacity-100 data-[transition=entered]:translate-y-0 p-0.5 px-1 rounded-full overflow-hidden flex items-center max-w-full gap-1 text-error-11"
6
+ <AnimatePresence>
7
+ {error && (
8
+ <m.div
9
+ initial={{ scale: 0.5, opacity: 0 }}
10
+ animate={{ scale: 1, opacity: 1 }}
11
+ exit={{ scale: 0, opacity: 0 }}
12
+ role="alert"
13
+ class="opacity-0 p-0.5 px-1 rounded-full overflow-hidden flex items-center max-w-full gap-1 text-error-11"
14
+ >
15
+ <svg
16
+ class="text-error-10"
17
+ width="16"
18
+ height="16"
19
+ viewBox="0 0 16 16"
20
+ fill="none"
21
+ xmlns="http://www.w3.org/2000/svg"
19
22
  >
20
- <svg
21
- class="text-error-10"
22
- width="16"
23
- height="16"
24
- viewBox="0 0 16 16"
25
- fill="none"
26
- xmlns="http://www.w3.org/2000/svg"
27
- >
28
- <circle cx="8" cy="8" r="6.3" stroke="currentColor" stroke-width="1.4" />
29
- <rect x="7" y="4" width="2" height="5" fill="currentColor" />
30
- <rect x="7" y="10" width="2" height="2" fill="currentColor" />
31
- </svg>
23
+ <circle cx="8" cy="8" r="6.3" stroke="currentColor" stroke-width="1.4" />
24
+ <rect x="7" y="4" width="2" height="5" fill="currentColor" />
25
+ <rect x="7" y="10" width="2" height="2" fill="currentColor" />
26
+ </svg>
32
27
 
33
- <p class="text-sm truncate pr-1">{error.message}</p>
34
- </div>
35
- );
36
- }}
37
- </Transition>
28
+ <p class="text-sm truncate pr-1">{error.message}</p>
29
+ </m.div>
30
+ )}
31
+ </AnimatePresence>
38
32
  );
39
33
  };