@inploi/plugin-chatbot 2.1.0 → 2.1.2

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 (56) hide show
  1. package/cdn/index.js +56 -0
  2. package/package.json +12 -1
  3. package/.env +0 -2
  4. package/.env.example +0 -2
  5. package/.env.test +0 -2
  6. package/.eslintrc.cjs +0 -10
  7. package/CHANGELOG.md +0 -91
  8. package/bunfig.toml +0 -2
  9. package/happydom.ts +0 -10
  10. package/index.html +0 -29
  11. package/playwright.config.ts +0 -82
  12. package/postcss.config.cjs +0 -7
  13. package/src/chatbot.api.ts +0 -46
  14. package/src/chatbot.constants.ts +0 -9
  15. package/src/chatbot.css +0 -93
  16. package/src/chatbot.dom.ts +0 -28
  17. package/src/chatbot.idb.ts +0 -17
  18. package/src/chatbot.state.ts +0 -114
  19. package/src/chatbot.ts +0 -59
  20. package/src/chatbot.utils.ts +0 -56
  21. package/src/index.cdn.ts +0 -12
  22. package/src/index.dev.ts +0 -31
  23. package/src/index.ts +0 -1
  24. package/src/interpreter/interpreter.test.ts +0 -69
  25. package/src/interpreter/interpreter.ts +0 -249
  26. package/src/mocks/browser.ts +0 -5
  27. package/src/mocks/example.flows.ts +0 -801
  28. package/src/mocks/handlers.ts +0 -57
  29. package/src/style/palette.test.ts +0 -20
  30. package/src/style/palette.ts +0 -69
  31. package/src/ui/chat-bubble.tsx +0 -51
  32. package/src/ui/chat-input/chat-input.boolean.tsx +0 -62
  33. package/src/ui/chat-input/chat-input.file.tsx +0 -213
  34. package/src/ui/chat-input/chat-input.multiple-choice.tsx +0 -117
  35. package/src/ui/chat-input/chat-input.text.tsx +0 -111
  36. package/src/ui/chat-input/chat-input.tsx +0 -81
  37. package/src/ui/chatbot-header.tsx +0 -95
  38. package/src/ui/chatbot.tsx +0 -94
  39. package/src/ui/input-error.tsx +0 -33
  40. package/src/ui/job-application-content.tsx +0 -144
  41. package/src/ui/job-application-messages.tsx +0 -64
  42. package/src/ui/loading-indicator.tsx +0 -37
  43. package/src/ui/send-button.tsx +0 -27
  44. package/src/ui/transition.tsx +0 -1
  45. package/src/ui/typing-indicator.tsx +0 -12
  46. package/src/ui/useChatService.ts +0 -67
  47. package/src/ui/useFocus.ts +0 -10
  48. package/src/vite-env.d.ts +0 -1
  49. package/tailwind.config.ts +0 -119
  50. package/tests/integration.spec.ts +0 -19
  51. package/tests/test.ts +0 -22
  52. package/tsconfig.json +0 -33
  53. package/tsconfig.node.json +0 -10
  54. package/types.d.ts +0 -2
  55. package/vite.config.ts +0 -18
  56. /package/{public → cdn}/mockServiceWorker.js +0 -0
@@ -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.application?.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-8 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
- input: ChatInputType | undefined;
28
- onInputChange: (input?: ChatInputType['type']) => void;
29
- };
30
- export const ChatInput = ({ onSubmit, onInputChange, input }: ChatInputFactoryProps) => {
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
- };
@@ -1,95 +0,0 @@
1
- import clsx from 'clsx';
2
- import { ComponentChildren, ComponentProps } from 'preact';
3
-
4
- import { application, viewState } from '../chatbot.state';
5
-
6
- const HeaderIconButton = ({ class: className, children, ...props }: ComponentProps<'button'>) => {
7
- return (
8
- <button
9
- class={clsx(
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',
11
- className,
12
- )}
13
- {...props}
14
- >
15
- <svg
16
- class="block"
17
- width="16"
18
- height="16"
19
- viewBox="0 0 16 16"
20
- fill="none"
21
- stroke="currentColor"
22
- stroke-width="1.5"
23
- stroke-linecap="round"
24
- xmlns="http://www.w3.org/2000/svg"
25
- >
26
- {children}
27
- </svg>
28
- </button>
29
- );
30
- };
31
-
32
- export const ChatbotHeader = ({ children }: { children?: ComponentChildren }) => {
33
- const view = viewState.value;
34
-
35
- return (
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}
54
- </p>
55
- </button>
56
-
57
- <div class="flex-shrink-0 flex items-center gap-3 p-1.5">
58
- {viewState.value === 'minimised' ? (
59
- <>
60
- <HeaderIconButton
61
- key="minmax"
62
- aria-label="Maximise job application"
63
- onClick={() => (viewState.value = 'maximised')}
64
- >
65
- <path d="M12.5 9.5L8 5L3.5 9.5" />
66
- </HeaderIconButton>
67
- <HeaderIconButton key="close" aria-label="Close application" onClick={application.cancel}>
68
- <path d="M12.5 6.5L8 11L3.5 6.5" />
69
- </HeaderIconButton>
70
- </>
71
- ) : (
72
- <>
73
- <HeaderIconButton
74
- key="restart"
75
- aria-label="Restart"
76
- onClick={() => {
77
- application.restart();
78
- }}
79
- >
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" />
81
- <path d="M8 7L10 4.5L8 2.5" />
82
- </HeaderIconButton>
83
- <HeaderIconButton
84
- key="minmax"
85
- aria-label="Minimise application"
86
- onClick={() => (viewState.value = 'minimised')}
87
- >
88
- <path d="M12.5 6.5L8 11L3.5 6.5" />
89
- </HeaderIconButton>
90
- </>
91
- )}
92
- </div>
93
- </header>
94
- );
95
- };
@@ -1,94 +0,0 @@
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';
8
-
9
- import { application, viewState } from '../chatbot.state';
10
- import { ChatbotHeader } from './chatbot-header';
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
- };
25
-
26
- type ChatbotProps = {
27
- apiClient: ApiClient;
28
- logger: Logger;
29
- analytics: AnalyticsService;
30
- };
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
-
37
- return (
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"
69
- >
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>
87
- </div>
88
- </m.div>
89
- </Dialog.Content>
90
- </AnimatePresence>
91
- </Dialog.Root>
92
- </MotionProvider>
93
- );
94
- };
@@ -1,33 +0,0 @@
1
- import { AnimatePresence, m } from 'framer-motion';
2
- import type { FieldError } from 'react-hook-form';
3
-
4
- export const InputError = ({ error }: { error?: FieldError }) => {
5
- return (
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"
22
- >
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>
27
-
28
- <p class="text-sm truncate pr-1">{error.message}</p>
29
- </m.div>
30
- )}
31
- </AnimatePresence>
32
- );
33
- };
@@ -1,144 +0,0 @@
1
- import { AnalyticsService, ApiClient, Logger } from '@inploi/sdk';
2
- import { AnimatePresence } from 'framer-motion';
3
- import { useEffect, useLayoutEffect } from 'react';
4
- import { match } from 'ts-pattern';
5
- import { StartedJobApplication, application, inputHeight, viewState } from '~/chatbot.state';
6
- import { submissionsToPayload } from '~/chatbot.utils';
7
-
8
- import { ERROR_MESSAGES } from '../chatbot.constants';
9
- import { createFlowInterpreter } from '../interpreter/interpreter';
10
- import { ChatInput } from './chat-input/chat-input';
11
- import { JobApplicationMessages } from './job-application-messages';
12
- import { useChatService } from './useChatService';
13
-
14
- type JobApplicationContentProps = {
15
- apiClient: ApiClient;
16
- logger: Logger;
17
- currentApplication: StartedJobApplication;
18
- analytics: AnalyticsService;
19
- };
20
-
21
- export const JobApplicationContent = ({
22
- currentApplication,
23
- logger,
24
- apiClient,
25
- analytics,
26
- }: JobApplicationContentProps) => {
27
- const { chatRef, chatService, isBotTyping, onSubmitSuccessFn, scrollToEnd } = useChatService();
28
-
29
- const view = viewState.value;
30
- useLayoutEffect(() => {
31
- // This significantly improves performance for maximising the view
32
- if (view === 'maximised') scrollToEnd({ behavior: 'instant' });
33
- }, [scrollToEnd, view]);
34
-
35
- useEffect(() => {
36
- scrollToEnd({ behavior: 'smooth' });
37
- }, [currentApplication.data.messages, scrollToEnd]);
38
-
39
- useLayoutEffect(() => {
40
- scrollToEnd({ behavior: 'instant' });
41
-
42
- const { state, application: currentApplication } = application.current$.value;
43
- if (state !== 'loaded' || currentApplication.data.isFinished) return;
44
-
45
- const { interpret, abort } = createFlowInterpreter({
46
- flow: currentApplication.flow.nodes,
47
- chatService,
48
- getSubmissions: () => application.current$.peek().application?.data.submissions,
49
- beforeStart: async node => {
50
- application.setInput(undefined);
51
-
52
- const fromBeginning = currentApplication.data.messages.length === 0;
53
- if (fromBeginning) {
54
- analytics.log({
55
- event: 'APPLY_START',
56
- properties: { job_id: currentApplication.job.id },
57
- customProperties: {
58
- flow_id: currentApplication.flow.id,
59
- },
60
- });
61
- } else {
62
- // We restart the last node.
63
- const restoredFromId = node.id;
64
- application.removeLastGroupMessagesById(restoredFromId);
65
- }
66
- },
67
- onInterpret: node => {
68
- application.setCurrentNodeId(node.id);
69
- },
70
- onFlowEnd: async lastNode => {
71
- application.markAsFinished();
72
- return match(lastNode)
73
- .with({ type: 'abandon-flow' }, () => {
74
- chatService.send({
75
- message: {
76
- type: 'system',
77
- text: 'Application ended',
78
- variant: 'success',
79
- },
80
- groupId: 'system',
81
- });
82
- })
83
- .with({ type: 'complete-flow' }, async () => {
84
- const submissions = application.current$.peek().application?.data.submissions;
85
- if (!submissions) throw new Error(ERROR_MESSAGES.no_submissions);
86
-
87
- const response = await apiClient.fetch(`/flow/apply`, {
88
- method: 'POST',
89
- body: JSON.stringify(submissionsToPayload({ application: currentApplication, submissions })),
90
- });
91
-
92
- match(response)
93
- .with({ message: 'Success' }, () => {
94
- chatService.send({
95
- message: {
96
- type: 'system',
97
- text: 'Application submitted',
98
- variant: 'success',
99
- },
100
- groupId: 'system',
101
- });
102
- })
103
- .otherwise(response => {
104
- logger.error(response);
105
- chatService.send({
106
- message: {
107
- type: 'system',
108
- text: 'Error submitting application',
109
- variant: 'error',
110
- },
111
- groupId: 'system',
112
- });
113
- });
114
- })
115
- .otherwise(() => {
116
- logger.error(ERROR_MESSAGES.invalid_end_node, lastNode);
117
- });
118
- },
119
- });
120
-
121
- interpret(currentApplication.data.currentNodeId);
122
-
123
- return abort;
124
- }, [analytics, apiClient, chatService, logger, scrollToEnd]);
125
-
126
- return (
127
- <>
128
- <div
129
- ref={chatRef}
130
- className="relative max-w-full flex-grow flex flex-col hide-scrollbars overflow-y-scroll"
131
- style={{ WebkitOverflowScrolling: 'touch', paddingBottom: inputHeight.value }}
132
- >
133
- <AnimatePresence>
134
- <JobApplicationMessages isBotTyping={isBotTyping} messages={currentApplication.data.messages} />
135
- </AnimatePresence>
136
- </div>
137
- <ChatInput
138
- input={currentApplication.data.currentInput}
139
- onInputChange={() => scrollToEnd({ behavior: 'smooth' })}
140
- onSubmit={onSubmitSuccessFn}
141
- />
142
- </>
143
- );
144
- };
@@ -1,64 +0,0 @@
1
- import { AnimatePresence } from 'framer-motion';
2
- import { P, match } from 'ts-pattern';
3
-
4
- import { ChatMessage } from '../chatbot.state';
5
- import { ChatBubble } from './chat-bubble';
6
- import { FileThumbnail } from './chat-input/chat-input.file';
7
- // import { AnimatePresence } from './motion/animate-presence';
8
- import { TypingIndicator } from './typing-indicator';
9
-
10
- type JobApplicationMessagesProps = {
11
- messages: ChatMessage[];
12
- isBotTyping: boolean;
13
- };
14
-
15
- const authorToSide = {
16
- bot: 'left',
17
- user: 'right',
18
- } as const;
19
-
20
- export const JobApplicationMessages = ({ messages, isBotTyping }: JobApplicationMessagesProps) => {
21
- return (
22
- <ol
23
- aria-label="Chat messages"
24
- class="p-2 justify-end pt-[calc(var(--header-height)+1rem)] flex flex-col gap-2 flex-grow"
25
- >
26
- <AnimatePresence initial={false}>
27
- {messages.map((message, i) => (
28
- <li class="flex" key={i}>
29
- {match(message)
30
- .with({ type: 'system' }, message => (
31
- <p class="uppercase w-full drop-shadow-[0_1.5px_white] text-[10px] text-neutral-8 select-none tracking-widest text-center py-2">
32
- {message.text}
33
- </p>
34
- ))
35
- .with({ type: 'text', author: P.union('bot', 'user') }, message => {
36
- return (
37
- <ChatBubble key={i} side={authorToSide[message.author]}>
38
- {message.text}
39
- </ChatBubble>
40
- );
41
- })
42
- .with({ type: 'image' }, image => (
43
- <img
44
- class="max-w-[min(100%,24rem)] w-full rounded-2xl shadow-surface-md"
45
- src={image.url}
46
- style={{ aspectRatio: image.width / image.height }}
47
- />
48
- ))
49
- .with({ type: 'file' }, file => {
50
- return (
51
- <FileThumbnail
52
- class={file.author === 'bot' ? '' : 'ml-auto'}
53
- file={{ name: file.fileName, sizeKb: file.fileSizeKb }}
54
- />
55
- );
56
- })
57
- .exhaustive()}
58
- </li>
59
- ))}
60
- </AnimatePresence>
61
- <aside aria-hidden>{isBotTyping && <TypingIndicator />}</aside>
62
- </ol>
63
- );
64
- };
@@ -1,37 +0,0 @@
1
- export const LoadingIndicator = () => (
2
- <svg viewBox="0 0 24 24">
3
- <style>
4
- {`#s1{animation:3s linear infinite forwards s1__to}@keyframes s1__to{0%{transform:translate(12px,0)}66.666667%{transform:translate(12px,0);animation-timing-function:cubic-bezier(0.77,0,0.175,1)}100%{transform:translate(12px,12px)}}#s2{animation:3s linear infinite forwards s2__ts}@keyframes s2__ts{0%{transform:scale(0,0)}70%{transform:scale(0,0);animation-timing-function:cubic-bezier(0.86,0,0.07,1)}100%{transform:scale(1,1)}}#s3{animation:3s linear infinite forwards s3__to}@keyframes s3__to{0%{transform:translate(12px,12px);animation-timing-function:cubic-bezier(0.77,0,0.175,1)}100%,33.333333%{transform:translate(12px,24px)}}#s4{animation:3s linear infinite forwards s4__ts}@keyframes s4__ts{0%{transform:scale(1,1);animation-timing-function:cubic-bezier(0.86,0,0.07,1)}100%,30%{transform:scale(0,0)}}#s5{animation:3s linear infinite forwards s5__to}@keyframes s5__to{0%{transform:translate(12px,0);animation-timing-function:cubic-bezier(0.77,0,0.175,1)}33.333333%{transform:translate(12px,12.045742px);animation-timing-function:cubic-bezier(0.77,0,0.175,1)}100%,66.666667%{transform:translate(12px,24px)}}#s6{animation:3s linear infinite forwards s6__ts}@keyframes s6__ts{0%,100%,63.333333%{transform:scale(0,0)}3.333333%{transform:scale(0,0);animation-timing-function:cubic-bezier(0.86,0,0.07,1)}33.333333%{transform:scale(1,1);animation-timing-function:cubic-bezier(0.86,0,0.07,1)}}#s7{animation:3s linear infinite forwards s7__to}@keyframes s7__to{0%{transform:translate(12px,0)}33.333333%{transform:translate(12px,0);animation-timing-function:cubic-bezier(0.77,0,0.175,1)}66.666667%{transform:translate(12px,12px);animation-timing-function:cubic-bezier(0.77,0,0.175,1)}100%{transform:translate(12px,24px)}}#s8{animation:3s linear infinite forwards s8__ts}@keyframes s8__ts{0%,100%,96.666667%{transform:scale(0,0)}36.666667%{transform:scale(0,0);animation-timing-function:cubic-bezier(0.86,0,0.07,1)}66.666667%{transform:scale(1,1);animation-timing-function:cubic-bezier(0.86,0,0.07,1)}}`}
5
- </style>
6
- <g id="s1" transform="translate(12,0)">
7
- <g id="s2" transform="scale(0,0)">
8
- <circle r="6.5" transform="translate(0,0)" fill="hsl(226, 70.0%, 55.5%)" />
9
- </g>
10
- </g>
11
- <g id="s3" transform="translate(12,12)">
12
- <g id="s4" transform="scale(1,1)">
13
- <circle r="6.5" transform="translate(0,0)" fill="hsl(226, 70.0%, 55.5%)" />
14
- </g>
15
- </g>
16
- <g id="s5" transform="translate(12,0)">
17
- <g id="s6" transform="scale(0,0)">
18
- <path
19
- d="M6.5,13c3.5899,0,6.5-2.9101,6.5-6.5s-2.9101-6.5-6.5-6.5-6.5,2.91015-6.5,6.5s2.91015,6.5,6.5,6.5Zm0-4C7.88071,9,9,7.88071,9,6.5s-1.11929-2.5-2.5-2.5-2.5,1.11929-2.5,2.5s1.11929,2.5,2.5,2.5Z"
20
- transform="translate(-6.5,-6.5)"
21
- clip-rule="evenodd"
22
- fill="hsl(226, 70.0%, 55.5%)"
23
- fill-rule="evenodd"
24
- />
25
- </g>
26
- </g>
27
- <g id="s7" transform="translate(12,0)">
28
- <g id="s8" transform="scale(0,0)">
29
- <path
30
- d="M0,6c0,3.58984,2.91016,6.5,6.5,6.5s6.5-2.91016,6.5-6.5h-4C9,7.38086,7.88086,8.5,6.5,8.5s-2.5-1.11914-2.5-2.5h-4Z"
31
- transform="translate(-6.5,-9.25)"
32
- fill="hsl(226, 70.0%, 55.5%)"
33
- />
34
- </g>
35
- </g>
36
- </svg>
37
- );