@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.
- package/.github/workflows/release.yml +33 -0
- package/.husky/pre-commit +2 -0
- package/README.md +30 -0
- package/biome.json +55 -0
- package/index.html +20 -0
- package/package.json +43 -0
- package/postcss.config.cjs +5 -0
- package/public/vite.svg +1 -0
- package/src/api/base.api.ts +118 -0
- package/src/api/error.ts +109 -0
- package/src/assets/react.svg +1 -0
- package/src/components/app/WidgetProvider.tsx +80 -0
- package/src/components/app/button/index.tsx +47 -0
- package/src/components/app/button/types.ts +68 -0
- package/src/components/app/card/profile.tsx +33 -0
- package/src/components/app/icon/index.tsx +144 -0
- package/src/components/app/input/checkbox.tsx +115 -0
- package/src/components/app/input/index.tsx +133 -0
- package/src/components/app/input/label.tsx +33 -0
- package/src/components/app/input/radio.tsx +143 -0
- package/src/components/app/input/select.tsx +54 -0
- package/src/components/app/input/textarea.tsx +68 -0
- package/src/components/app/input/toggle.tsx +162 -0
- package/src/components/app/input/verification.tsx +111 -0
- package/src/features/chat/chat.api.ts +30 -0
- package/src/features/chat/chat.types.ts +50 -0
- package/src/features/chat/partials/ChatCreateForm.tsx +103 -0
- package/src/features/chat/partials/ChatMessageFormFooter.tsx +67 -0
- package/src/features/chat/partials/ChatMessageSection.tsx +103 -0
- package/src/features/widget/components/WidgetIcon.tsx +26 -0
- package/src/features/widget/contexts/page-tracker.tsx +74 -0
- package/src/features/widget/hooks/page-tracker.tsx +57 -0
- package/src/features/widget/widget.api.ts +14 -0
- package/src/features/widget/widget.store.ts +16 -0
- package/src/features/widget/widget.types.ts +28 -0
- package/src/index.css +40 -0
- package/src/layouts/auth.tsx +17 -0
- package/src/layouts/chat.tsx +18 -0
- package/src/lib/cdn.tsx +3 -0
- package/src/lib/cookie.tsx +7 -0
- package/src/lib/debounce.tsx +23 -0
- package/src/lib/hooks/cookie.tsx +22 -0
- package/src/lib/hooks/dayjs.tsx +10 -0
- package/src/lib/hooks/dom.tsx +26 -0
- package/src/lib/hooks/form.tsx +26 -0
- package/src/lib/hooks/router.tsx +31 -0
- package/src/lib/list.tsx +14 -0
- package/src/lib/obj.tsx +23 -0
- package/src/lib/phone.tsx +12 -0
- package/src/main.tsx +31 -0
- package/src/pages/auth/registration-view.tsx +67 -0
- package/src/pages/chat/chat-detail-view.tsx +200 -0
- package/src/router/router.tsx +17 -0
- package/src/router/routes/auth-routes.ts +7 -0
- package/src/router/routes/chat-routes.ts +7 -0
- package/src/router/routes/index.ts +8 -0
- package/src/router/routes/routes.types.ts +0 -0
- package/src/types/i18n.ts +11 -0
- package/src/vite-env.d.ts +1 -0
- package/src/widget.tsx +19 -0
- package/src/ws/events.tsx +69 -0
- package/src/ws/guard.tsx +16 -0
- package/src/ws/hooks.tsx +122 -0
- package/tailwind.config.js +27 -0
- package/tsconfig.app.json +29 -0
- package/tsconfig.app.tsbuildinfo +1 -0
- package/tsconfig.json +7 -0
- package/tsconfig.node.json +22 -0
- package/tsconfig.node.tsbuildinfo +1 -0
- package/version.cjs +5 -0
- 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
|
+
}
|