@alfatech/livechat 2024.12.3-1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/.github/workflows/release.yml +33 -0
  2. package/.husky/pre-commit +2 -0
  3. package/README.md +30 -0
  4. package/biome.json +55 -0
  5. package/index.html +20 -0
  6. package/package.json +43 -0
  7. package/postcss.config.cjs +5 -0
  8. package/public/vite.svg +1 -0
  9. package/src/api/base.api.ts +118 -0
  10. package/src/api/error.ts +109 -0
  11. package/src/assets/react.svg +1 -0
  12. package/src/components/app/WidgetProvider.tsx +80 -0
  13. package/src/components/app/button/index.tsx +47 -0
  14. package/src/components/app/button/types.ts +68 -0
  15. package/src/components/app/card/profile.tsx +33 -0
  16. package/src/components/app/icon/index.tsx +144 -0
  17. package/src/components/app/input/checkbox.tsx +115 -0
  18. package/src/components/app/input/index.tsx +133 -0
  19. package/src/components/app/input/label.tsx +33 -0
  20. package/src/components/app/input/radio.tsx +143 -0
  21. package/src/components/app/input/select.tsx +54 -0
  22. package/src/components/app/input/textarea.tsx +68 -0
  23. package/src/components/app/input/toggle.tsx +162 -0
  24. package/src/components/app/input/verification.tsx +111 -0
  25. package/src/features/chat/chat.api.ts +30 -0
  26. package/src/features/chat/chat.types.ts +50 -0
  27. package/src/features/chat/partials/ChatCreateForm.tsx +103 -0
  28. package/src/features/chat/partials/ChatMessageFormFooter.tsx +67 -0
  29. package/src/features/chat/partials/ChatMessageSection.tsx +103 -0
  30. package/src/features/widget/components/WidgetIcon.tsx +26 -0
  31. package/src/features/widget/contexts/page-tracker.tsx +74 -0
  32. package/src/features/widget/hooks/page-tracker.tsx +57 -0
  33. package/src/features/widget/widget.api.ts +14 -0
  34. package/src/features/widget/widget.store.ts +16 -0
  35. package/src/features/widget/widget.types.ts +28 -0
  36. package/src/index.css +40 -0
  37. package/src/layouts/auth.tsx +17 -0
  38. package/src/layouts/chat.tsx +18 -0
  39. package/src/lib/cdn.tsx +3 -0
  40. package/src/lib/cookie.tsx +7 -0
  41. package/src/lib/debounce.tsx +23 -0
  42. package/src/lib/hooks/cookie.tsx +22 -0
  43. package/src/lib/hooks/dayjs.tsx +10 -0
  44. package/src/lib/hooks/dom.tsx +26 -0
  45. package/src/lib/hooks/form.tsx +26 -0
  46. package/src/lib/hooks/router.tsx +31 -0
  47. package/src/lib/list.tsx +14 -0
  48. package/src/lib/obj.tsx +23 -0
  49. package/src/lib/phone.tsx +12 -0
  50. package/src/main.tsx +31 -0
  51. package/src/pages/auth/registration-view.tsx +67 -0
  52. package/src/pages/chat/chat-detail-view.tsx +200 -0
  53. package/src/router/router.tsx +17 -0
  54. package/src/router/routes/auth-routes.ts +7 -0
  55. package/src/router/routes/chat-routes.ts +7 -0
  56. package/src/router/routes/index.ts +8 -0
  57. package/src/router/routes/routes.types.ts +0 -0
  58. package/src/types/i18n.ts +11 -0
  59. package/src/vite-env.d.ts +1 -0
  60. package/src/widget.tsx +19 -0
  61. package/src/ws/events.tsx +69 -0
  62. package/src/ws/guard.tsx +16 -0
  63. package/src/ws/hooks.tsx +122 -0
  64. package/tailwind.config.js +27 -0
  65. package/tsconfig.app.json +29 -0
  66. package/tsconfig.app.tsbuildinfo +1 -0
  67. package/tsconfig.json +7 -0
  68. package/tsconfig.node.json +22 -0
  69. package/tsconfig.node.tsbuildinfo +1 -0
  70. package/version.cjs +5 -0
  71. package/vite.config.ts +14 -0
@@ -0,0 +1,162 @@
1
+ import classNames from "classnames";
2
+ import { useEffect, useState } from "react";
3
+
4
+ type Variant = "blue" | "indigo" | "emerald" | "red" | "orange";
5
+ type Size = "sm" | "md" | "lg";
6
+
7
+ type Props = {
8
+ defaultChecked?: boolean;
9
+ indigo?: boolean;
10
+ checked?: boolean;
11
+ disabled?: boolean;
12
+ onChange?: (checked: boolean) => void;
13
+ variant?: Variant;
14
+ size?: Size;
15
+ title?: string;
16
+ };
17
+
18
+ type ToggleGroupProps = {
19
+ className?: string;
20
+ noMargin?: boolean;
21
+ label?: string;
22
+ required?: boolean;
23
+ id?: string;
24
+ } & Props;
25
+
26
+ type Variants = {
27
+ default: string;
28
+ checked: string;
29
+ circle: string;
30
+ circleChecked: string;
31
+ };
32
+
33
+ type Sizes = {
34
+ default: string;
35
+ circle: string;
36
+ circleChecked: string;
37
+ };
38
+
39
+ const variants: Record<Variant, Variants> = {
40
+ blue: {
41
+ default: "bg-gray-200 dark:bg-zink-500",
42
+ checked: "bg-blue-500",
43
+ circleChecked: "bg-blue-500",
44
+ circle: "bg-white",
45
+ },
46
+ indigo: {
47
+ default: "bg-gray-200 dark:bg-zink-500",
48
+ checked: "bg-indigo-500",
49
+ circleChecked: "bg-indigo-500",
50
+ circle: "bg-white",
51
+ },
52
+ emerald: {
53
+ default: "bg-gray-200 dark:bg-zink-500",
54
+ checked: "bg-green-500",
55
+ circleChecked: "bg-green-500",
56
+ circle: "bg-white",
57
+ },
58
+ red: {
59
+ default: "bg-gray-200 dark:bg-zink-500",
60
+ checked: "bg-red-500",
61
+ circleChecked: "bg-red-500",
62
+ circle: "bg-white",
63
+ },
64
+ orange: {
65
+ default: "bg-gray-200 dark:bg-zink-500",
66
+ checked: "bg-orange-500",
67
+ circleChecked: "bg-orange-500",
68
+ circle: "bg-white",
69
+ },
70
+ };
71
+
72
+ const sizes: Record<Size, Sizes> = {
73
+ sm: {
74
+ default: "w-10 h-5",
75
+ circle: "w-4 h-4",
76
+ circleChecked: "translate-x-5",
77
+ },
78
+ md: {
79
+ default: "w-12 h-6",
80
+ circle: "w-5 h-5",
81
+ circleChecked: "translate-x-6",
82
+ },
83
+ lg: {
84
+ default: "w-14 h-7",
85
+ circle: "w-6 h-6",
86
+ circleChecked: "translate-x-7",
87
+ },
88
+ };
89
+
90
+ function Toggle({
91
+ defaultChecked = false,
92
+ checked: checkedProp,
93
+ variant = "blue",
94
+ size = "md",
95
+ disabled = false,
96
+ title,
97
+ onChange,
98
+ }: Props) {
99
+ const [checked, setChecked] = useState(defaultChecked);
100
+
101
+ const handleChange = () => {
102
+ const newChecked = !checked;
103
+ setChecked(newChecked);
104
+ if (onChange) {
105
+ onChange(newChecked);
106
+ }
107
+ };
108
+
109
+ useEffect(() => {
110
+ if (checkedProp !== undefined) {
111
+ setChecked(checkedProp);
112
+ }
113
+ }, [checkedProp]);
114
+
115
+ return (
116
+ <button
117
+ type="button"
118
+ className={classNames(
119
+ sizes[size].default,
120
+ {
121
+ [variants[variant].checked]: checked,
122
+ [variants[variant].default]: !checked,
123
+ },
124
+ "disable-highlight relative inline-flex items-center rounded-full shadow-none transition-colors focus:ring-0 focus:ring-transparent focus:ring-offset-0 focus:ring-offset-transparent",
125
+ )}
126
+ onClick={handleChange}
127
+ disabled={disabled}
128
+ title={title}
129
+ aria-label={title}
130
+ >
131
+ <span
132
+ className={`inline-block transform rounded-full transition-transform ease-in-out ${variants[variant].circle} ${
133
+ sizes[size].circle
134
+ } ${checked ? `${sizes[size].circleChecked} ${variants[variant].circleChecked}` : "translate-x-1"}`}
135
+ />
136
+ </button>
137
+ );
138
+ }
139
+
140
+ Toggle.WithGroup = function ToggleGroup({
141
+ className,
142
+ noMargin = false,
143
+ required,
144
+ label,
145
+ id,
146
+ ...props
147
+ }: ToggleGroupProps) {
148
+ return (
149
+ <div className={classNames({ "mb-3": !noMargin }, className)}>
150
+ {label && (
151
+ <label id={id} className="block mb-2 text-base font-medium">
152
+ {label} {required && <span className="text-red-500">*</span>}
153
+ </label>
154
+ )}
155
+ <div className="flex items-center">
156
+ <Toggle {...props} title={label} />
157
+ </div>
158
+ </div>
159
+ );
160
+ };
161
+
162
+ export default Toggle;
@@ -0,0 +1,111 @@
1
+ import classNames from "classnames";
2
+ import type React from "react";
3
+ import { useRef, useState } from "react";
4
+
5
+ type Props = {
6
+ length?: number;
7
+ label?: string;
8
+ value?: string;
9
+ onChange?: (value: string) => void;
10
+ withoutBackground?: boolean;
11
+ };
12
+
13
+ export default function VerificationCode({
14
+ length = 6,
15
+ label = "Doğrulama Kodu",
16
+ onChange,
17
+ withoutBackground = true,
18
+ }: Props) {
19
+ const [otp, setOtp] = useState(Array(length).fill(""));
20
+ const inputRefs = useRef([]);
21
+
22
+ const setValue = (value: string[]) => {
23
+ setOtp(value);
24
+ if (onChange) onChange(value.join(""));
25
+ };
26
+
27
+ const handleKeyDown: React.KeyboardEventHandler<HTMLInputElement> = (e) => {
28
+ if (
29
+ !/^[0-9]{1}$/.test(e.key) &&
30
+ e.key !== "Backspace" &&
31
+ e.key !== "Delete" &&
32
+ e.key !== "Tab" &&
33
+ !e.metaKey
34
+ ) {
35
+ e.preventDefault();
36
+ }
37
+
38
+ if (e.key === "Delete" || e.key === "Backspace") {
39
+ // @ts-ignore
40
+ const index = inputRefs.current.indexOf(e.target);
41
+
42
+ const newOtp = [...otp];
43
+ newOtp[index] = "";
44
+ setValue(newOtp);
45
+ // @ts-ignore
46
+ inputRefs.current[index - 1].focus();
47
+ }
48
+ };
49
+
50
+ const handleInput: React.ChangeEventHandler<HTMLInputElement> = (e) => {
51
+ const { target } = e;
52
+ // @ts-ignore
53
+ const index = inputRefs.current.indexOf(target);
54
+ if (target.value) {
55
+ const newOtp = [...otp];
56
+ newOtp[index] = target.value;
57
+ setValue(newOtp);
58
+ if (index < otp.length - 1) {
59
+ // @ts-ignore
60
+ inputRefs.current[index + 1].focus();
61
+ }
62
+ }
63
+ };
64
+
65
+ const handleFocus: React.FocusEventHandler<HTMLInputElement> = (e) => {
66
+ e.target.select();
67
+ };
68
+
69
+ const handlePaste: React.ClipboardEventHandler<HTMLInputElement> = (e) => {
70
+ e.preventDefault();
71
+ const text = e.clipboardData.getData("text");
72
+ if (!new RegExp(`^[0-9]{${otp.length}}$`).test(text)) {
73
+ return;
74
+ }
75
+ const digits = text.split("");
76
+ setValue(digits);
77
+ // @ts-ignore
78
+ inputRefs.current[digits.length - 1].focus();
79
+ };
80
+
81
+ return (
82
+ <section
83
+ className={classNames("pb-5", {
84
+ "bg-white": !withoutBackground,
85
+ })}
86
+ >
87
+ <div className="container">
88
+ <div>
89
+ {label && <p className="mb-1.5 text-sm font-medium">{label}</p>}
90
+ <form id="otp-form" className="flex gap-2">
91
+ {otp.map((digit, index) => (
92
+ <input
93
+ key={index}
94
+ type="text"
95
+ maxLength={1}
96
+ value={digit}
97
+ onChange={handleInput}
98
+ onKeyDown={handleKeyDown}
99
+ onFocus={handleFocus}
100
+ onPaste={handlePaste}
101
+ // @ts-ignore
102
+ ref={(el) => (inputRefs.current[index] = el)}
103
+ className="shadow-xs flex w-[64px] items-center justify-center rounded-lg border border-stroke bg-slate-100 p-2 text-center text-2xl font-medium text-gray-5 outline-none sm:text-4xl dark:border-slate-500 dark:bg-white/5 dark:text-zink-100"
104
+ />
105
+ ))}
106
+ </form>
107
+ </div>
108
+ </div>
109
+ </section>
110
+ );
111
+ }
@@ -0,0 +1,30 @@
1
+ import { req } from "@/api/base.api";
2
+ import type { ChatCreateReq } from "./chat.types";
3
+ import { useMutation, useQuery } from "@tanstack/react-query";
4
+
5
+ export const useChatCreate = () => {
6
+ return useMutation({
7
+ mutationFn: (dto: ChatCreateReq) =>
8
+ req(`clients/${dto.client_id}/chats`, {
9
+ method: "POST",
10
+ body: JSON.stringify({ ...dto, client_id: undefined }),
11
+ }),
12
+ });
13
+ };
14
+
15
+ export const useChatCheck = (client_id: string) => {
16
+ return useQuery({
17
+ queryFn: () => chatCheck(client_id),
18
+ queryKey: ["clients", client_id, "chats"],
19
+ });
20
+ };
21
+
22
+ export const chatCheck = (client_id: string) => {
23
+ return req(`clients/${client_id}/chats`);
24
+ };
25
+
26
+ export const chatClear = (client_id: string) => {
27
+ return req(`clients/${client_id}/chats`, {
28
+ method: "DELETE",
29
+ });
30
+ };
@@ -0,0 +1,50 @@
1
+ export type ChatCreateReq = {
2
+ email: string;
3
+ name: string;
4
+ client_id: string;
5
+ history: string[];
6
+ };
7
+
8
+ export type ChatViewResult = {
9
+ agent_name?: string;
10
+ agent_avatar?: string;
11
+ is_uploadable: boolean;
12
+ };
13
+
14
+ export type MessageListItem = {
15
+ id: string;
16
+ sender_name?: string;
17
+ sender_type: MessageSenderType;
18
+ activity_type?: MessageActivityType;
19
+ reply_id?: string;
20
+ answers?: string[];
21
+ content: string;
22
+ files: string[];
23
+ created_at: Date;
24
+ };
25
+
26
+ export enum MessageActivityType {
27
+ AdminJoin = "admin_join",
28
+ }
29
+
30
+ export enum MessageSenderType {
31
+ Customer = "customer",
32
+ User = "user",
33
+ System = "system",
34
+ Activity = "activity",
35
+ }
36
+
37
+ export type MessageEntity = {
38
+ _id: string;
39
+ property_id: string;
40
+ chat_id: string;
41
+ sender_id: string;
42
+ reply_id?: string;
43
+ is_shortcut?: boolean;
44
+ sender_type: MessageSenderType;
45
+ content: string;
46
+ files: string[];
47
+ who_read: string[]; // user_id, customer_id
48
+ answers?: string[];
49
+ created_at: Date;
50
+ };
@@ -0,0 +1,103 @@
1
+ import * as yup from "yup";
2
+ import { useChatCreate } from "../chat.api";
3
+ import { useForm } from "@/lib/hooks/form";
4
+ import { isSuccess } from "@/api/base.api";
5
+ import { handleApiErrorResult } from "@/api/error";
6
+ import { checkObjSame } from "@/lib/obj";
7
+ import Input from "@/components/app/input";
8
+ import Button from "@/components/app/button";
9
+ import { setCookie } from "@/lib/hooks/cookie";
10
+ import { usePageTracker } from "@/features/widget/contexts/page-tracker";
11
+
12
+ type Props = {
13
+ clientId: string;
14
+ onOk?: () => void;
15
+ savedEmail?: string;
16
+ savedName?: string;
17
+ };
18
+
19
+ type FormContent = {
20
+ name: string;
21
+ email: string;
22
+ };
23
+
24
+ const formContentValidation = yup.object<FormContent>({
25
+ name: yup.string().required("Name is required"),
26
+ email: yup.string().email("Invalid email").required("Email is required"),
27
+ });
28
+
29
+ const initialValues: FormContent = {
30
+ email: "",
31
+ name: "",
32
+ };
33
+
34
+ export default function ChatCreateForm({
35
+ onOk,
36
+ savedEmail,
37
+ savedName,
38
+ clientId,
39
+ }: Props) {
40
+ const pageTracker = usePageTracker();
41
+ const chatCreate = useChatCreate();
42
+ const form = useForm<FormContent>({
43
+ init: {
44
+ email: savedEmail || "",
45
+ name: savedName || "",
46
+ },
47
+ schema: formContentValidation,
48
+ action: async (values) => {
49
+ const [res, status] = await chatCreate.mutateAsync({
50
+ ...values,
51
+ history: pageTracker.history,
52
+ client_id: clientId,
53
+ });
54
+ if (isSuccess(status)) {
55
+ pageTracker.clearHistory();
56
+ setCookie("email", values.email);
57
+ setCookie("name", values.name);
58
+ onOk?.();
59
+ return;
60
+ }
61
+ handleApiErrorResult(res, { form });
62
+ },
63
+ });
64
+
65
+ const isChanged = !checkObjSame(initialValues, form.values);
66
+ return (
67
+ <form
68
+ className="grid grid-cols-1 gap-4"
69
+ onSubmit={(e) => {
70
+ e.preventDefault();
71
+ form.handleSubmit();
72
+ }}
73
+ >
74
+ <Input
75
+ id="name"
76
+ name="name"
77
+ value={form.values.name}
78
+ onChange={form.handleChange}
79
+ onBlur={form.handleBlur}
80
+ placeholder="Adınız, ör: John Doe"
81
+ error={form.errors.name}
82
+ ariaInvalid={form.errors.name ? true : undefined}
83
+ required
84
+ noMargin
85
+ />
86
+ <Input
87
+ id="email"
88
+ name="email"
89
+ value={form.values.email}
90
+ onChange={form.handleChange}
91
+ onBlur={form.handleBlur}
92
+ placeholder="Email, ör: john@doe.com"
93
+ error={form.errors.email}
94
+ ariaInvalid={form.errors.email ? true : undefined}
95
+ required
96
+ noMargin
97
+ />
98
+ <Button kind="soft" type="submit" disabled={!isChanged}>
99
+ Sohbet Oluştur
100
+ </Button>
101
+ </form>
102
+ );
103
+ }
@@ -0,0 +1,67 @@
1
+ import { SendEvents } from "@/ws/events";
2
+ import { useWebsocket } from "@/ws/hooks";
3
+ import { useState } from "react";
4
+ import type { MessageListItem } from "../chat.types";
5
+ import Input from "@/components/app/input";
6
+ import Button from "@/components/app/button";
7
+ import debounce from "@/lib/debounce";
8
+
9
+ type Props = {
10
+ onNewMessage: (msg: MessageListItem) => void;
11
+ };
12
+
13
+ export default function ChatMessageFormFooter({ onNewMessage }: Props) {
14
+ const socket = useWebsocket();
15
+ const [query, setQuery] = useState("");
16
+
17
+ const sendMessage = () => {
18
+ socket?.emit(
19
+ SendEvents.MessageCreate,
20
+ {
21
+ content: query,
22
+ },
23
+ (msg) => {
24
+ setQuery("");
25
+ onNewMessage(msg);
26
+ onChange("");
27
+ },
28
+ );
29
+ };
30
+
31
+ const onChange = (val: string) => {
32
+ socket?.emit(SendEvents.MessageTyping, {
33
+ content: val,
34
+ });
35
+ };
36
+
37
+ const debouncedChange = debounce(onChange, 300);
38
+
39
+ return (
40
+ <form
41
+ className="border-t p-2 flex items-center gap-2"
42
+ onSubmit={(e) => {
43
+ e.preventDefault();
44
+ sendMessage();
45
+ e.currentTarget.reset();
46
+ }}
47
+ >
48
+ <div className="grow relative">
49
+ <Input
50
+ id="message"
51
+ className="grow"
52
+ noMargin
53
+ onChange={(e) => {
54
+ setQuery(e.target.value);
55
+ debouncedChange(e.target.value);
56
+ }}
57
+ placeholder="Bir mesaj yazın"
58
+ />
59
+ </div>
60
+ <div className="flex items-center justify-center h-full">
61
+ <Button className="rounded-md" type="submit">
62
+ Gönder
63
+ </Button>
64
+ </div>
65
+ </form>
66
+ );
67
+ }
@@ -0,0 +1,103 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { MessageSenderType, type MessageListItem } from "../chat.types";
3
+ import classNames from "classnames";
4
+ import Button from "@/components/app/button";
5
+ import Icon from "@/components/app/icon";
6
+
7
+ type Props = {
8
+ messages: MessageListItem[];
9
+ isTyping: boolean;
10
+ };
11
+
12
+ export default function ChatMessageSection({ messages, isTyping }: Props) {
13
+ const messagesEndRef = useRef<HTMLDivElement | null>(null);
14
+ const messagesContainerRef = useRef<HTMLDivElement | null>(null);
15
+ const [isAutoScrollEnabled, setIsAutoScrollEnabled] = useState<boolean>(true);
16
+
17
+ const scrollToBottom = () => {
18
+ messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
19
+ };
20
+
21
+ // Scroll pozisyonunu kontrol et
22
+ const handleScroll = () => {
23
+ if (messagesContainerRef.current) {
24
+ const { scrollTop, scrollHeight, clientHeight } =
25
+ messagesContainerRef.current;
26
+ const isAtBottom = scrollHeight - scrollTop <= clientHeight + 10;
27
+ setIsAutoScrollEnabled(isAtBottom);
28
+ }
29
+ };
30
+
31
+ // Yeni mesaj geldiğinde otomatik scroll yap
32
+ useEffect(() => {
33
+ if (isAutoScrollEnabled) {
34
+ scrollToBottom();
35
+ }
36
+ }, [messages, isAutoScrollEnabled]);
37
+ return (
38
+ <section
39
+ onScroll={handleScroll}
40
+ ref={messagesContainerRef}
41
+ className="flex flex-col gap-2 w-full h-full grow pt-4 px-4 overflow-y-auto"
42
+ style={{
43
+ height: "calc(100vh - 122px)",
44
+ }}
45
+ >
46
+ {messages.map((msg, idx) => (
47
+ <div
48
+ key={msg.id || idx}
49
+ className={classNames("flex w-full items-center break-all", {
50
+ "justify-end": msg.sender_type === MessageSenderType.Customer,
51
+ "justify-start": msg.sender_type === MessageSenderType.User,
52
+ "justify-center":
53
+ msg.sender_type === MessageSenderType.System ||
54
+ msg.sender_type === MessageSenderType.Activity,
55
+ })}
56
+ >
57
+ {msg.sender_type === MessageSenderType.System && <>{msg.content}</>}
58
+ {[MessageSenderType.Customer, MessageSenderType.User].includes(
59
+ msg.sender_type,
60
+ ) && <div className="border rounded-md px-2 py-1">{msg.content}</div>}
61
+ {msg.sender_type === MessageSenderType.Activity &&
62
+ (msg.content.split("_").length === 3 ? (
63
+ <div className="border rounded-md px-2 py-1 text-xs">
64
+ {msg.content.split("_")[2]} odaya katıldı
65
+ </div>
66
+ ) : (
67
+ msg.content
68
+ ))}
69
+ {MessageSenderType.System === msg.sender_type && (
70
+ <div className="text-xs text-neutral-400">
71
+ {msg.created_at.toLocaleString()}
72
+ </div>
73
+ )}
74
+ </div>
75
+ ))}
76
+ {isTyping && (
77
+ <div className="flex w-full items-center justify-end">
78
+ <div className="h-full flex items-center">
79
+ <Icon.MessageTyping />
80
+ </div>
81
+ </div>
82
+ )}
83
+
84
+ {!isAutoScrollEnabled && (
85
+ <div className="fixed bottom-[7.5rem] right-16">
86
+ <Button
87
+ type="button"
88
+ className="size-10 flex items-center justify-center !p-0"
89
+ onClick={() => {
90
+ messagesContainerRef.current?.scrollTo({
91
+ top: messagesContainerRef.current?.scrollHeight,
92
+ behavior: "smooth",
93
+ });
94
+ }}
95
+ >
96
+ <Icon.ArrowDown className="size-5" />
97
+ </Button>
98
+ </div>
99
+ )}
100
+ <div ref={messagesEndRef} />
101
+ </section>
102
+ );
103
+ }
@@ -0,0 +1,26 @@
1
+ import Icon from "@/components/app/icon";
2
+ import classNames from "classnames";
3
+
4
+ type Props = {
5
+ image_url?: string;
6
+ name: string;
7
+ size?: string;
8
+ };
9
+
10
+ export default function WidgetIcon({
11
+ image_url,
12
+ name,
13
+ size = "size-9",
14
+ }: Props) {
15
+ return image_url ? (
16
+ <img
17
+ src={image_url}
18
+ alt={name}
19
+ className={classNames("rounded-md", size)}
20
+ />
21
+ ) : (
22
+ <div className="flex items-center justify-center gap-1">
23
+ <Icon.Website className={classNames("text-slate-500", size)} />
24
+ </div>
25
+ );
26
+ }