diff --git a/.env b/.env
new file mode 100644
index 0000000..3280d89
--- /dev/null
+++ b/.env
@@ -0,0 +1,2 @@
+FAST_FRESH=false
+NODE_PATH=src/
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..e5bd9ab
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,25 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+/.idea
+
+# dependencies
+/node_modules
+/.pnp
+.pnp.js
+
+# testing
+/coverage
+
+# production
+/build
+
+# misc
+.DS_Store
+.env.local
+.env.development.local
+.env.test.local
+.env.production.local
+
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
diff --git a/public/favicon.ico b/public/favicon.ico
new file mode 100644
index 0000000..a11777c
Binary files /dev/null and b/public/favicon.ico differ
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..6e3a838
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ Chatngay - Chatboard
+
+
+
+
+
+ You need to enable JavaScript to run this app.
+
+
+
+
diff --git a/public/logo192.png b/public/logo192.png
new file mode 100644
index 0000000..fc44b0a
Binary files /dev/null and b/public/logo192.png differ
diff --git a/public/logo512.png b/public/logo512.png
new file mode 100644
index 0000000..a4e47a6
Binary files /dev/null and b/public/logo512.png differ
diff --git a/public/manifest.json b/public/manifest.json
new file mode 100644
index 0000000..080d6c7
--- /dev/null
+++ b/public/manifest.json
@@ -0,0 +1,25 @@
+{
+ "short_name": "React App",
+ "name": "Create React App Sample",
+ "icons": [
+ {
+ "src": "favicon.ico",
+ "sizes": "64x64 32x32 24x24 16x16",
+ "type": "image/x-icon"
+ },
+ {
+ "src": "logo192.png",
+ "type": "image/png",
+ "sizes": "192x192"
+ },
+ {
+ "src": "logo512.png",
+ "type": "image/png",
+ "sizes": "512x512"
+ }
+ ],
+ "start_url": ".",
+ "display": "standalone",
+ "theme_color": "#000000",
+ "background_color": "#ffffff"
+}
diff --git a/public/robots.txt b/public/robots.txt
new file mode 100644
index 0000000..e9e57dc
--- /dev/null
+++ b/public/robots.txt
@@ -0,0 +1,3 @@
+# https://www.robotstxt.org/robotstxt.html
+User-agent: *
+Disallow:
diff --git a/public/static/webworker_client.js b/public/static/webworker_client.js
new file mode 100644
index 0000000..c487251
--- /dev/null
+++ b/public/static/webworker_client.js
@@ -0,0 +1,49 @@
+onmessage = function (event) {
+ handleMessage(event.data);
+}
+
+// message = {type: '', task_id: '', [key: string]: any}
+function handleMessage(message) {
+ // console.log("new task from master " + JSON.stringify(message));
+ let type = message['type'];
+
+ if(type === 'fetch'){
+ fetchUrl(message.url).then(function (res) {
+ postMessage({
+ type: 'fetch',
+ task_id: message.task_id,
+ info: JSON.parse(res),
+ })
+ });
+
+ return;
+ }
+}
+
+function processDelay(timeout) {
+ setTimeout(function () {
+ postMessage({
+ type: 'delay',
+ info: 'Its done! ' + timeout,
+ })
+ }, timeout)
+}
+
+
+function fetchUrl(request_url) {
+ return new Promise(function (resolve, reject) {
+ let xhr = new XMLHttpRequest();
+ xhr.open('GET', request_url, true);
+ xhr.onload = function () {
+ if (xhr.status >= 200 && xhr.status < 300) {
+ resolve(xhr.response);
+ } else {
+ reject('error');
+ }
+ };
+ xhr.onerror = function () {
+ reject('error');
+ };
+ xhr.send();
+ });
+}
diff --git a/src/App.tsx b/src/App.tsx
new file mode 100644
index 0000000..280f715
--- /dev/null
+++ b/src/App.tsx
@@ -0,0 +1,165 @@
+import React, {createRef, useState, Fragment, FC} from 'react';
+import {Layout, Col, Row, Button, Image} from 'antd';
+import { EditOutlined, CloseOutlined } from '@ant-design/icons';
+import ConversationList from "@/components/ConversationList";
+import Chatbox from "@/components/Chatbox";
+import CustomerInfo from "@/components/CustomerInfo";
+import ActionTabs from "@/components/ActionTabs";
+import {HelpSideBar} from "@/components/Help";
+import HeaderComponent from "@/components/HeaderComponent";
+
+import {useDispatch, useSelector} from "react-redux";
+import {AppState} from "@/store/typing";
+import {UserInfo} from "@/typings/user";
+import {actions} from "@/store/actions";
+
+import GlobalModal from '@/components/GlobalModal';
+import GlobalDrawer from "@/components/GlobalDrawer";
+import BadgeStatus from "@/components/BadgeStatus";
+import NetworkError from "@/components/Error/NetworkError";
+
+import {ClientSettings} from "@/typings";
+
+import TYPING_ANIMATION_IMAGE from "@/assets/typing-animation.gif";
+import DashBoard from "@/components/DashBoard";
+
+import {user_list} from "@/test/test_state";
+
+import '@/styles/app.css';
+
+
+const { Header, Content, Sider } = Layout;
+
+const WINDOW_HEIGHT = global.window.innerHeight;
+
+
+const UserNameForm = ({user}: {user: UserInfo | null}) => {
+ const [openForm, setFormOpen] = useState(false);
+ const dispatch = useDispatch();
+
+ if( ! user ) {
+ return null;
+ }
+
+ const userInputRef = createRef();
+ const updateName = () => {
+ let new_name = userInputRef.current?.value;
+ dispatch(actions.updateUserInfo({
+ id: user.id,
+ name: new_name,
+ }));
+ setFormOpen(false);
+ }
+
+ if(!openForm) {
+ return (
+
+
+ {user.name}
+ {
+ user.typing &&
+ }
+ ( setFormOpen(true)} title={'Thay đổi tên'} /> )
+
+ )
+ }
+
+ return (
+
+ OK
+
+ )
+}
+
+
+function getUserById(user_list: UserInfo[], user_id: string) : UserInfo | null {
+ let filtered_list = user_list.filter(user => user.id === user_id);
+ return filtered_list.length > 0 ? filtered_list[0] : null;
+}
+
+
+const ShowUserSpace = () => {
+
+ // TODO:
+
+ const chatting_with_user = useSelector((state: AppState) => state.chatting_with_user );
+ const dispatch = useDispatch();
+
+ let chatting_user_info = getUserById(user_list, chatting_with_user);
+ if( ! chatting_user_info && user_list.length > 0) {
+ // auto show the first user in the list if there
+ dispatch(actions.chatWithUser(user_list[0].id));
+ }
+
+ // else show the dashboard
+ if( ! chatting_user_info ) {
+ return
+ }
+
+ return (
+
+
+
+
+
+
+
+ {/* add key to force to component to remount when user id change to simplify the component's code*/}
+
+
+
+
+ Lựa chọn
+
+
+
+ Thông tin khách hàng
+
+
+
+
+
+
+ )
+}
+
+
+const App: FC<{client_setting: ClientSettings}> = ({client_setting}) => {
+
+ const HEADER_HEIGHT = 70;
+ const [closeHelp, setHelpClose] = useState(false);
+ const LAYOUT_CLOSED = closeHelp ? {width:1200, marginLeft:'auto', marginRight: 'auto'} : {marginTop: HEADER_HEIGHT, height: WINDOW_HEIGHT - HEADER_HEIGHT, overflow: 'auto'};
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ !closeHelp &&
+
+ Trợ giúp setHelpClose(true)} title={'Đóng'} />
+
+
+ }
+
+
+
+
+
+
+
+ )
+};
+
+export default App;
diff --git a/src/assets/typing-animation.gif b/src/assets/typing-animation.gif
new file mode 100644
index 0000000..152f230
Binary files /dev/null and b/src/assets/typing-animation.gif differ
diff --git a/src/components/ActionTabs/components/CreateLeadForm.tsx b/src/components/ActionTabs/components/CreateLeadForm.tsx
new file mode 100644
index 0000000..1246a25
--- /dev/null
+++ b/src/components/ActionTabs/components/CreateLeadForm.tsx
@@ -0,0 +1,123 @@
+import {Form, Input, Select, Button} from 'antd';
+import api from "@/lib/api";
+import PROVINCE_LIST from "@/constant/province_list";
+import {SelectWithList} from "@/components/SelectBox";
+import React from "react";
+import GENDER_LIST from "@/constant/gender";
+
+const { Option } = Select;
+
+const LeadForm = ({customer_id}: {customer_id: string|number}) => {
+
+ const [form] = Form.useForm();
+
+ const onFinish = (values: any) => {
+ console.log('Received values of form: ', values);
+ form.resetFields();
+ (async () => {
+ let result = await api.post('user/create-lead', {...values, customer_id});
+ console.log(result.data);
+ })();
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Submit
+
+
+
+ );
+}
+
+export default LeadForm;
diff --git a/src/components/ActionTabs/components/CreateNoteForm.tsx b/src/components/ActionTabs/components/CreateNoteForm.tsx
new file mode 100644
index 0000000..8876507
--- /dev/null
+++ b/src/components/ActionTabs/components/CreateNoteForm.tsx
@@ -0,0 +1,63 @@
+import {Form, Input} from 'antd';
+import api, {notifyApiResult} from "@/lib/api";
+import {getAdminInfo} from "@/lib/user";
+import Emitter from '@/lib/emitter';
+import React, {useEffect} from "react";
+import {UserInfo} from "@/typings/user";
+
+
+const NoteForm = ({customer_id}: {customer_id: string|number}) => {
+
+ const [form] = Form.useForm();
+
+ const onFinish = (values: any) => {
+ form.resetFields();
+ const admin_info = getAdminInfo();
+ (async () => {
+ let result = await api.post('note/create', {...values, customer_id, admin_name: admin_info.name});
+ notifyApiResult(result, 'Ghi chú đã được lưu');
+ Emitter.emit('create_note');
+ })();
+ }
+
+ useEffect(() => {
+ (async () => {
+ let result = await api.get('user/info', {id: customer_id});
+ if(result.status === 'ok') {
+ const user_info: UserInfo = (result.data) ? result.data : null;
+ if(user_info) {
+ form.setFieldsValue({
+ crm_code: user_info.crm_code,
+ });
+ }
+ }
+ })();
+ }, [customer_id, form]);
+
+
+ return (
+
+
+
+
+
+ {
+ e.preventDefault();
+ form.submit();
+ }}
+ />
+
+
+ )
+}
+
+export default NoteForm;
diff --git a/src/components/ActionTabs/components/CreateOrderForm.tsx b/src/components/ActionTabs/components/CreateOrderForm.tsx
new file mode 100644
index 0000000..f7647d5
--- /dev/null
+++ b/src/components/ActionTabs/components/CreateOrderForm.tsx
@@ -0,0 +1,429 @@
+import React, {createRef, useEffect, useState} from "react";
+import {
+ Form, Input, Select, Button, Space,
+ InputNumber, Popconfirm,
+ DatePicker, TimePicker
+} from 'antd';
+import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
+
+import api, {notifyApiResult} from "@/lib/api";
+import {SelectWithAjax, SelectWithAddItem, SelectWithList} from '@/components/SelectBox';
+import PROVINCE_LIST from "@/constant/province_list";
+import Tagging from "@/components/Tagging";
+import GENDER_LIST from "@/constant/gender";
+import Emitter from "@/lib/emitter";
+
+const { Option } = Select;
+
+type ProductInfo = {
+ id: string;
+ sku: string;
+ name: string;
+ price:string;
+ in_stock: boolean;
+}
+
+
+const searchProduct = async (query: string) => {
+ const { data } = await api.get('product/list', {q: query} );
+ let result: { value: any; text: any; }[] = [];
+
+ if(!data) return [];
+
+ data.list.forEach((r: { id: string, name: string }) => {
+ result.push({
+ ...r,
+ value: r.id,
+ text: r.name,
+ });
+ });
+
+ return result;
+}
+
+
+const OrderForm = ({customer_id}: {customer_id: string|number}) => {
+
+ const [suggested_products, setSuggestedProducts] = useState([]);
+ const [form] = Form.useForm();
+ const taggingRef = createRef();
+
+ const onFinish = (fieldsValue: any) => {
+ console.log('Received values of form: ', fieldsValue);
+
+ const values = {
+ ...fieldsValue,
+ user_id: customer_id,
+ shipping: {
+ ...fieldsValue['shipping'],
+ date: (fieldsValue['shipping']['date']) ? fieldsValue['shipping']['date'].format('YYYY-MM-DD') : '0000-00-00',
+ time: (fieldsValue['shipping']['time']) ? fieldsValue['shipping']['time'].format('HH:mm') : '00:00',
+ },
+ tags: taggingRef.current?.getTagList(),
+ };
+
+ (async () => {
+ let result = await api.post('order/create', {...values});
+ notifyApiResult(result, 'Tạo đơn hàng thành công');
+ Emitter.emit('create_order');
+ form.resetFields();
+ taggingRef.current?.clearList();
+ })();
+ };
+
+ const productSelectOption = (item: ProductInfo) => {
+ return (
+
+ {item.name} - ({item.price} - {item.in_stock ? 'in-stock' : 'out-of-stock'})
+
+ )
+ };
+
+ useEffect(() => {
+ const default_info = {
+ "name": "",
+ "crm_code": "",
+ "email": "",
+ "note": "",
+ "gender": null,
+ "province": null,
+ "address": "",
+ "mobile": "",
+ };
+
+ (async () => {
+ let result = await api.get('user/info', {id: customer_id});
+ if(result.status === 'ok') {
+ const filled_info = (result.data) ? result.data : default_info;
+ form.setFieldsValue({customer: filled_info});
+ }
+ })();
+ }, [customer_id, form]);
+
+
+ useEffect(() => {
+ (async () => {
+ let result = await api.get('product/suggested', {id: customer_id});
+ if(result.status === 'ok') {
+ setSuggestedProducts(result.data);
+ }
+ })();
+ }, [customer_id]);
+
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {(fields, { add, remove }) => (
+ <>
+ {fields.map(({ key, name, fieldKey, ...restField }, index) => (
+
+
+
+ {
+ console.log(option);
+ //alert(value);
+ //alert(JSON.stringify(form.getFieldsValue()));
+ //form.setFieldsValue({user: {name: value}} );
+ let current_products = form.getFieldsValue()['products'];
+ current_products[index].id = value;
+ current_products[index].price = option.price;
+ current_products[index].sku = option.sku;
+ current_products[index].name = option.name;
+ form.setFieldsValue({products: current_products});
+ }}
+ searchFn={searchProduct}
+ onFocusSuggestedData={suggested_products}
+ buildOption={productSelectOption}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ remove(name)}
+ okText="Yes"
+ cancelText="No"
+ >
+
+
+
+ ))}
+
+ add()} block icon={ }>
+ Thêm sản phẩm
+
+
+ >
+ )}
+
+
+
+
+
+ {(fields, { add, remove }) => (
+ <>
+ {fields.map(({ key, name, fieldKey, ...restField }) => (
+
+
+
+
+
+
+
+
+
+
+
+ remove(name)}
+ okText="Yes"
+ cancelText="No"
+ >
+
+
+
+ ))}
+
+ add()} block icon={ }>
+ Thêm mục khác (thuế, phí ship, phí khác ...)
+
+
+ >
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Tạo đơn
+
+
+
+ );
+}
+
+export default OrderForm;
diff --git a/src/components/ActionTabs/components/CreateSupportForm.tsx b/src/components/ActionTabs/components/CreateSupportForm.tsx
new file mode 100644
index 0000000..48e18bd
--- /dev/null
+++ b/src/components/ActionTabs/components/CreateSupportForm.tsx
@@ -0,0 +1,146 @@
+import React, {createRef, useEffect, useState} from "react";
+import {Form, Input, Select, Button, Space, Checkbox} from 'antd';
+import {ImageUploadWithPreview} from '@/components/Upload';
+import api, {notifyApiResult} from "@/lib/api";
+import {getAdminInfo} from "@/lib/user";
+import Tagging from "@/components/Tagging";
+import Emitter from "@/lib/emitter";
+import {UserInfo} from "@/typings/user";
+
+const { Option } = Select;
+
+
+const SupportForm = ({customer_id}: {customer_id: string|number}) => {
+
+ const [form] = Form.useForm();
+ const [sendEmail, setSendEmail] = useState(false);
+
+ const taggingRef = createRef();
+ const imageRef = createRef();
+
+ const onFinish = (formValues: any) => {
+ //console.log('Received values of form: ', formValues);
+ const payload = {
+ customer_id,
+ ...formValues,
+ files: imageRef.current?.getFileList().map(item => item.uid), // get file-ids
+ tags: taggingRef.current?.getTagList(),
+ };
+
+ (async () => {
+ let result = await api.post('support/create', payload);
+ // console.log(result.data);
+ notifyApiResult(result, 'Tạo thành công');
+ Emitter.emit('create_support');
+ form.resetFields();
+ taggingRef.current?.clearList();
+ imageRef.current?.clearFileList();
+ })();
+ };
+
+ useEffect(() => {
+ (async () => {
+ let result = await api.get('user/info', {id: customer_id});
+ if(result.status === 'ok') {
+ const user_info: UserInfo = (result.data) ? result.data : null;
+ if(user_info) {
+ form.setFieldsValue({
+ customer_email: user_info.email,
+ crm_code: user_info.crm_code,
+ });
+ }
+ }
+ })();
+ }, [customer_id, form]);
+
+ const admin_upload_auth = getAdminInfo().jwt || '';
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Serious
+ Normal
+
+
+
+
+
+
+
+ {
+ // e.preventDefault;
+ setSendEmail(e.target.checked);
+ }}>Có! Gửi bản 1 bản copy vào email khách hàng
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Tạo hỗ trợ
+
+
+
+ );
+}
+
+export default SupportForm;
diff --git a/src/components/ActionTabs/components/CreateTag.tsx b/src/components/ActionTabs/components/CreateTag.tsx
new file mode 100644
index 0000000..91d64fd
--- /dev/null
+++ b/src/components/ActionTabs/components/CreateTag.tsx
@@ -0,0 +1,38 @@
+import React, {useEffect, useState} from "react";
+import Tagging from "@/components/Tagging";
+import api from "@/lib/api";
+
+
+const CreateTag = ({customer_id}: {customer_id: string|number}) => {
+
+ const [current_tags, setTags] = useState([]);
+
+ const handleCreateTag = async (tag: string) => {
+ let result = await api.post('user/tag', {tag, id: customer_id});
+ return result.status === 'ok';
+ };
+
+ const handleRemoveTag = async (tag: string) => {
+ let result = await api.delete('user/tag', {tag, id: customer_id});
+ return result.status === 'ok';
+ };
+
+ useEffect(() => {
+ (async () => {
+ let result = await api.get('user/info', {id: customer_id});
+ if(result.status === 'ok' && result.data) {
+ setTags(result.data.tags);
+ }
+ })();
+ }, [customer_id]);
+
+ return (
+
+ )
+}
+
+export default CreateTag;
diff --git a/src/components/ActionTabs/index.tsx b/src/components/ActionTabs/index.tsx
new file mode 100644
index 0000000..bc53d2e
--- /dev/null
+++ b/src/components/ActionTabs/index.tsx
@@ -0,0 +1,45 @@
+import React, {Suspense} from "react";
+import {Tabs} from "antd";
+import Loading from "@/components/Loading";
+
+const CreateSupportComponent = React.lazy(() => import('./components/CreateSupportForm'));
+const CreateOrderComponent = React.lazy(() => import('./components/CreateOrderForm'));
+const CreateNoteComponent = React.lazy(() => import('./components/CreateNoteForm'));
+const CreateTag = React.lazy(() => import('./components/CreateTag'));
+
+const { TabPane } = Tabs;
+
+const ActionTabs = ({customer_id}: {customer_id: string|number}) => {
+
+ return (
+ null } defaultActiveKey={'note'} type="card">
+
+
+ } >
+
+
+
+
+
+ } >
+
+
+
+
+
+ } >
+
+
+
+
+
+ } >
+
+
+
+
+
+ )
+}
+
+export default ActionTabs;
diff --git a/src/components/BadgeStatus/index.tsx b/src/components/BadgeStatus/index.tsx
new file mode 100644
index 0000000..1e32ec5
--- /dev/null
+++ b/src/components/BadgeStatus/index.tsx
@@ -0,0 +1,14 @@
+import {Badge} from "antd";
+import React, {FC, ReactNode} from "react";
+
+// import "./styles.css";
+
+const BadgeStatus: FC<{online: boolean, children: ReactNode}> = ({online, children}) => {
+ return (
+
+ { children }
+
+ )
+}
+
+export default BadgeStatus;
diff --git a/src/components/BadgeStatus/styles.css b/src/components/BadgeStatus/styles.css
new file mode 100644
index 0000000..8c3e9a8
--- /dev/null
+++ b/src/components/BadgeStatus/styles.css
@@ -0,0 +1,8 @@
+.badge-offline .ant-badge-count {
+ background-color: #fff;
+ box-shadow: 0 0 0 1px #d9d9d9 inset;
+}
+
+.badge-online .ant-badge-count {
+ background-color: cyan;
+}
diff --git a/src/components/Chatbox/index.tsx b/src/components/Chatbox/index.tsx
new file mode 100644
index 0000000..b49de9b
--- /dev/null
+++ b/src/components/Chatbox/index.tsx
@@ -0,0 +1,4 @@
+// import Sample from "./ver/Sample";
+import Chatbox from "./ver/Chatbox";
+
+export default Chatbox;
\ No newline at end of file
diff --git a/src/components/Chatbox/ver/Chatbox.css b/src/components/Chatbox/ver/Chatbox.css
new file mode 100644
index 0000000..d439e48
--- /dev/null
+++ b/src/components/Chatbox/ver/Chatbox.css
@@ -0,0 +1,108 @@
+.compose {
+ padding: 10px;
+ display: flex;
+ align-items: center;
+ background: white;
+ border: 1px solid #eeeef1;
+ /* position: fixed;
+ width: calc(100% - 20px);*/
+ bottom: 0;
+}
+
+@supports (backdrop-filter: blur(20px)) {
+ .compose {
+ border: none;
+ background-color: rgba(255, 255, 255, 0.8);
+ backdrop-filter: blur(20px);
+ }
+}
+
+.compose-input {
+ flex: 1;
+ border: none;
+ font-size: 14px;
+ height: 40px;
+ background: none;
+}
+
+.compose-input::placeholder {
+ opacity: 0.3;
+}
+
+.compose .toolbar-button {
+ color: #bbbbbf;
+ margin-left: 15px;
+}
+
+.compose .toolbar-button:hover {
+ color: #99999c;
+}
+
+.message-list-container {
+ padding: 10px;
+}
+
+.message {
+ display: flex;
+ flex-direction: column;
+}
+
+.message .timestamp {
+ display: flex;
+ justify-content: center;
+ color: #999;
+ font-weight: 600;
+ font-size: 12px;
+ margin: 10px 0;
+ text-transform: uppercase;
+}
+
+.message .bubble-container {
+ font-size: 14px;
+ display: flex;
+}
+
+.message.mine .bubble-container {
+ justify-content: flex-end;
+}
+
+.message.start .bubble-container .bubble {
+ /* margin-top: 10px; */
+ border-top-left-radius: 20px;
+}
+
+.message.end .bubble-container .bubble {
+ border-bottom-left-radius: 20px;
+ /* margin-bottom: 10px; */
+}
+
+.message.mine.start .bubble-container .bubble {
+ margin-top: 10px;
+ border-top-right-radius: 20px;
+}
+
+.message.mine.end .bubble-container .bubble {
+ border-bottom-right-radius: 20px;
+ margin-bottom: 10px;
+}
+
+.message .bubble-container .bubble {
+ margin: 1px 0;
+ background: #f4f4f8;
+ padding: 10px 15px;
+ border-radius: 20px;
+ max-width: 75%;
+ border-top-left-radius: 2px;
+ border-bottom-left-radius: 2px;
+ border-top-right-radius: 20px;
+ border-bottom-right-radius: 20px;
+}
+
+.message.mine .bubble-container .bubble {
+ background: #007aff;
+ color: white;
+ border-top-left-radius: 20px;
+ border-bottom-left-radius: 20px;
+ border-top-right-radius: 2px;
+ border-bottom-right-radius: 2px;
+}
diff --git a/src/components/Chatbox/ver/Chatbox.tsx b/src/components/Chatbox/ver/Chatbox.tsx
new file mode 100644
index 0000000..8ff53ae
--- /dev/null
+++ b/src/components/Chatbox/ver/Chatbox.tsx
@@ -0,0 +1,311 @@
+import React, {Component, createRef, Fragment} from "react";
+import {connect} from "react-redux";
+import {Dispatch} from "redux";
+import debounce from "lodash/debounce";
+
+import {UserInfo} from "@/typings/user";
+import {ChatboxTextMessage} from "@/typings/message.d";
+import {sendTextMessageToServer} from "@/lib/messaging";
+import {getCurrentUTCTimestamp} from "@/lib/utils";
+import {getUserChatHistory} from "@/lib/api";
+import {AppState, NetworkingStatusType} from "@/store/typing";
+import {actions} from "@/store/actions";
+import {NOTIFICATIONS} from "@/constant/text";
+import {getAdminInfo} from "@/lib/user";
+import storage, {userChatHistoryStorageKey} from "@/lib/storage";
+
+import {MessageItem} from "./MessageItem";
+import InputMessage from "./InputMessage";
+import TypingNotification from "./TypingNotification";
+
+import './Chatbox.css';
+
+
+type ChatboxProps = {
+ user_info: UserInfo;
+ network_connection: NetworkingStatusType;
+ chat_messages: ChatboxTextMessage[];
+ dispatch: Dispatch;
+}
+
+type ChatboxState = {
+ loadingHistory: boolean,
+}
+
+
+/*function persistMessage(user_id: string, messages: any[]) {
+ const MAX_MESSAGE_KEPT_PER_USER: number = 200;
+
+ // limit messages per user
+ const total_message = messages.length;
+ const keep_from_index = total_message - MAX_MESSAGE_KEPT_PER_USER;
+
+ storage.save(userChatHistoryStorageKey(user_id), (keep_from_index > 0) ? messages.slice(keep_from_index) : messages);
+}*/
+
+
+class Chatbox extends Component {
+
+ static MAX_MESSAGE_KEPT_PER_USER: number = 200;
+
+ private trackLastScrollTop: number;
+ private disableScrollBottom: boolean;
+ private loadingHistoryStatus: 'idle' | 'loading' | 'done';
+ private noMoreHistoryMessage: boolean;
+
+ private scrollBottomDiv: React.RefObject;
+ private scrollToHistoryDiv: React.RefObject;
+
+ constructor(props: ChatboxProps) {
+ super(props);
+ this.state = {
+ loadingHistory: false
+ }
+ this.scrollBottomDiv = createRef();
+ this.scrollToHistoryDiv = createRef();
+
+ this.trackLastScrollTop = 0;
+ this.disableScrollBottom = false;
+ this.loadingHistoryStatus = 'idle';
+ this.noMoreHistoryMessage = false;
+ }
+
+ componentDidMount() {
+ this.getChatHistory().then();
+ }
+
+ getSnapshotBeforeUpdate(prevProps: ChatboxProps, prevState: ChatboxState) {
+ // Are we adding new items to the list?
+ // Capture the scroll position so we can adjust scroll later.
+ if (prevProps.chat_messages.length < this.props.chat_messages.length) {
+ const div = this.scrollToHistoryDiv.current;
+ return (div) ? div.scrollHeight - div.scrollTop : null;
+ }
+ return null;
+ }
+
+ componentDidUpdate(prevProps: ChatboxProps, prevState: ChatboxState, snapshot: number | null) {
+ console.log('componentDidUpdate at ' + new Date().getTime());
+ // snapshot is the result of getSnapshotBeforeUpdate()
+ if (snapshot !== null) {
+ this.scrollToHistoryDiv.current!.scrollTop = this.scrollToHistoryDiv.current!.scrollHeight - snapshot;
+ // this.scrollToHistoryDiv.current!.scrollTo({top: expected_top, behavior:'smooth'});
+ this.loadingHistoryStatus = 'idle';
+ }
+
+ if(prevProps.chat_messages.length !== this.props.chat_messages.length) {
+ this.scrollToBottom();
+ }
+ }
+
+
+ getChatHistory = async (from_scroll: boolean = false) => {
+
+ console.log('Chatbox getChatHistory');
+
+ const {user_info, chat_messages, network_connection, dispatch} = this.props;
+
+ // todo: 15-April-2021
+ // because 1 user can chat with many admin staff, we need to remove all old messages in storage (if exist) when user first chat with this staff
+ if(network_connection === 'offline') {
+ // get for offline view only
+ const stored_messages: ChatboxTextMessage[] = await storage.get(userChatHistoryStorageKey(user_info.id)) || [];
+ console.log('stored_messages');
+ console.log(stored_messages);
+ dispatch(actions.addHistoryMessage({[user_info.id]: stored_messages}));
+ return;
+ }
+
+ if ( chat_messages.length === 0 || from_scroll ) {
+
+ let last_fetch = (chat_messages.length > 0) ? chat_messages[0].time : 0;
+
+ this.setState({loadingHistory: true});
+
+ const old_messages = await getUserChatHistory({thread_id: user_info.id, last_fetch: last_fetch});
+
+ this.setState({loadingHistory: false});
+
+ if (old_messages.length === 0) {
+ console.log('old_messages: this.noMoreHistoryMessage = true')
+ this.noMoreHistoryMessage = true;
+ return;
+ }
+
+ console.log('dispatching historied messages');
+ dispatch(actions.addHistoryMessage({[user_info.id]: old_messages}));
+ }
+ }
+
+ scrollToBottom = () => {
+ // no scroll if user view history
+ if ( this.disableScrollBottom ) {
+ return;
+ }
+
+ this.scrollBottomDiv.current!.scrollIntoView({ behavior: "smooth" });
+ }
+
+ showNotification = () => {
+ const { user_info, network_connection } = this.props;
+ const NotiMessage = (txt: string, key: string|number) => ({txt}
);
+
+ let list_messages: string[] = [];
+
+ // in-chat notification: user typing
+ if (network_connection === 'offline') {
+ list_messages.push(NOTIFICATIONS['network_offline']);
+ }
+
+ // notify user offline
+ if (!user_info.online) {
+ list_messages.push(NOTIFICATIONS['user_offline']);
+ }
+
+ if(list_messages.length > 0) {
+ return (
+
+ {
+ list_messages.map((txt, index) => NotiMessage(txt, index))
+ }
+
+ )
+ }
+
+ // default nothing
+ return null;
+ }
+
+ handleChatboxScroll = debounce((event: any) => {
+
+ console.log('start handleChatboxScroll at ' + new Date().getSeconds());
+ //console.log(event);
+
+ if(!event.target) {
+ console.log('handleChatboxScroll: event.currentTarget');
+ return;
+ }
+
+ if(this.noMoreHistoryMessage) {
+ console.log('handleChatboxScroll: noMoreHistoryMessage');
+ return;
+ }
+
+ // user is scrolling up, so disable bottom scrolling for user to read old messages without interruption
+ if(this.trackLastScrollTop > event.target.scrollTop) {
+ this.disableScrollBottom = true;
+ }
+
+ // track again
+ this.trackLastScrollTop = event.target.scrollTop;
+
+ // load history on top
+ if (event.target.scrollTop === 0 && this.loadingHistoryStatus === 'idle') {
+ this.loadingHistoryStatus = 'loading';
+ this.disableScrollBottom = true;
+ console.log('handleChatboxScroll should get the history now ... ');
+ this.getChatHistory(true).then();
+ }
+ }, 300)
+
+
+ sendMessage = (typed_message: string) => {
+ //alert(typed_message);
+ const { user_info, dispatch, network_connection } = this.props; // , admin_info, user_info
+
+ //const {typed_message} = this.state;
+ if(typed_message === '') {
+ return;
+ }
+
+ // reset when user send new message
+ if(this.disableScrollBottom) {
+ this.disableScrollBottom = false;
+ }
+
+ // cannot send message when network_connection is offline
+ // and restore message to the input
+ if(network_connection === 'offline') {
+ console.log("network_connection =offline");
+ return;
+ }
+
+ // let composed_message = composeNewSendingMessage(txt);
+ // add locally
+ let composed_message = {
+ id: '',
+ from: 'me',
+ content: typed_message,
+ time: getCurrentUTCTimestamp(true),
+ // sequence: local_sequence,
+ deliveryStatus: 0,
+ } as ChatboxTextMessage;
+
+ dispatch(actions.addCurrentMessage({[user_info.id] : [composed_message]}));
+
+ // pass to networking layer to send to server
+ let send_to = (user_info.online) ? [user_info.id, user_info.node].join('-').trim() : '';
+ sendTextMessageToServer(send_to, typed_message);
+ }
+
+ showLoadingHistory = () => {
+ const {loadingHistory} = this.state;
+
+ if(loadingHistory) {
+ return (
+ Loading ...
+ )
+ }
+
+ // default
+ return null;
+ }
+
+
+ render() {
+
+ const { network_connection, user_info, chat_messages} = this.props;
+ const admin_info = getAdminInfo();
+
+ return (
+
+
+
+
+
+ { this.showLoadingHistory() }
+
+ {
+ chat_messages.map((message, index) => )
+ }
+
+ { user_info.typing && }
+
+
+ { this.showNotification() }
+
+
+
+
+
+
+
+ )
+ }
+}
+
+const mapStateToProps = (state: AppState, ownProps: {user_info: UserInfo}) => ({
+ network_connection: state.network_connection,
+ chat_messages: state.current_messages[ownProps.user_info.id] || [],
+});
+export default connect(mapStateToProps)(Chatbox);
diff --git a/src/components/Chatbox/ver/InputMessage.tsx b/src/components/Chatbox/ver/InputMessage.tsx
new file mode 100644
index 0000000..a930ebc
--- /dev/null
+++ b/src/components/Chatbox/ver/InputMessage.tsx
@@ -0,0 +1,160 @@
+import {AppState, NetworkingStatusType} from "@/store/typing";
+import {AdminInfo, UserInfo} from "@/typings/user";
+import React, {createRef, Fragment, useEffect, useState} from "react";
+import {getAdminInfo} from "@/lib/user";
+import {useDispatch, useSelector} from "react-redux";
+import {ImageUploadWithPreview} from "@/components/Upload";
+import {actions} from "@/store/actions";
+import {Input, message, Popconfirm, Popover} from "antd";
+import {PaperClipOutlined, MinusCircleOutlined, FastForwardOutlined} from "@ant-design/icons";
+import BadgeStatus from "@/components/BadgeStatus";
+
+
+const PickAdminOnlineToTransfer = () => {
+ const admin_list: AdminInfo[] = useSelector((state: AppState) => state.admin_list);
+ const current_admin = getAdminInfo();
+ const other_admin_online = admin_list.filter(item => item.online && current_admin.id !== item.id);
+
+ return (
+
+ {
+ other_admin_online.length > 0
+ ? other_admin_online.map(item => {
+ return (
+ <>
+ alert(item.id)}>{item.name}
+ >
+ )
+ } )
+ : Không có quản trị online
+ }
+
+ )
+
+};
+
+
+const InputMessage = ({network_connection, sendMessage, user_info} : {network_connection: NetworkingStatusType, sendMessage: (txt: string) => void, user_info: UserInfo}) => {
+
+ const [typed_message, setMessage] = useState('');
+ const [typing, setTyping] = useState(false);
+ const admin_info = getAdminInfo();
+ const dispatch = useDispatch();
+ const imageUploadWithPreviewRef = createRef();
+
+ useEffect(() => {
+ if(!typing && typed_message.length > 0) {
+ setTyping(true);
+ console.log("Update Typing now at " + new Date().getTime());
+ dispatch(actions.updateUserInfo({id: user_info.id, typing: true}))
+ }
+
+ if(typing && typed_message === '') {
+ setTyping(false);
+ console.log("Turn off Typing now at " + new Date().getTime())
+ dispatch(actions.updateUserInfo({id: user_info.id, typing: false}))
+ }
+ },[typing, typed_message, dispatch, user_info]);
+
+ const removeChat = () => {
+ dispatch(actions.removeUser({id: user_info.id}))
+ }
+
+
+ return (
+
+ setMessage(value)}
+ onPressEnter={(e) => {
+ e.preventDefault(); // required to prevent new line
+
+ // send message
+ let txt = typed_message.trim();
+ if(txt.length > 0) {
+ sendMessage(txt);
+ setMessage('');
+ }
+
+ // send images
+ const uploadedFiles = imageUploadWithPreviewRef.current ? imageUploadWithPreviewRef.current.getFileList().map(item => item.url) : [];
+ if(uploadedFiles.length > 0) {
+ uploadedFiles.forEach(url => {
+ console.log("sending: "+ url);
+ sendMessage(url)
+ });
+ imageUploadWithPreviewRef.current?.clearFileList();
+ }
+ }}
+ disabled={network_connection === 'offline'}
+ placeholder="Nhập nội dung và nhấn phím Enter để gửi tin"
+ autoSize={{ minRows: 2, maxRows: 5 }}
+ />
+
+
+ Chặn không cho người này liên hệ với công ty từ nay về sau (qua Chatngay). Bạn chắc chắn chứ? }
+ onConfirm={removeChat}
+ //onCancel={() => {}}
+ okText="Yes"
+ cancelText="No"
+ >
+ Forbid
+
+
+
+ Dừng chat sẽ loại bỏ khách hàng khỏi danh sách chat. Bạn chắc chắn muốn bỏ chứ? }
+ onConfirm={removeChat}
+ //onCancel={() => {}}
+ okText="Yes"
+ cancelText="No"
+ >
+ Dừng chat
+
+
+
+
+ }
+ trigger="click"
+ >
+ Chuyển người khác
+
+
+
+
+ Upload}
+ listType={'picture'}
+ showUploadList={false}
+ multiple={false}
+ onUploadFinish={(status, url) => {
+
+ console.log(`onUploadFinish = ${status}`);
+
+ if(status === 'success') {
+ if(url) sendMessage(url);
+ }else if(status === 'error'){
+ message.error('Lỗi xảy ra, file chưa được upload.');
+ }else if(status === 'uploading'){
+ // show status
+ }
+
+ if(status === 'success' || status === 'error') {
+ imageUploadWithPreviewRef.current?.clearFileList();
+ }
+ }}
+ />
+
+
+ )
+};
+
+
+export default InputMessage;
diff --git a/src/components/Chatbox/ver/MessageItem.tsx b/src/components/Chatbox/ver/MessageItem.tsx
new file mode 100644
index 0000000..22def5b
--- /dev/null
+++ b/src/components/Chatbox/ver/MessageItem.tsx
@@ -0,0 +1,108 @@
+import {isUrlImage, validURL} from "@/lib/validation";
+import {maskExternalUrl, randomBetween, showUnixTime} from "@/lib/utils";
+import {ChatboxTextMessage} from "@/typings/message.d";
+import React from "react";
+
+
+export const MessageItem = (props: {is_me: boolean} & ChatboxTextMessage) => {
+ let {deliveryStatus, from, content, time, is_me } = props;
+ let time_in_second = Math.round(time / 1000);
+
+ if( ! content ) return null;
+
+ let image = (isUrlImage(content)) ? content : '';
+ let status_colors = {
+ 'sending': 'green',
+ 'received': 'black',
+ 'failed': 'red',
+ };
+ let html;
+
+ if ( is_me ) from = 'me';
+
+ if (from === 'bot') {
+ return (
+
+
+
+ )
+ }
+
+ if (image !== '') {
+ if(_isImageUploadedToOurServer(image)) {
+ html = ` `
+
+ return (
+
+ {from} {showUnixTime(time_in_second)}
+
+
+ )
+ }
+
+ html = `${image} `
+ return (
+
+
{from} {showUnixTime(time_in_second)}
+
+
(Ảnh chưa kiểm định. Cần thận trọng khi xem)
+
+ )
+ }
+
+ if(validURL(content)) {
+ html = `${content} `
+ return (
+
+
{from} {showUnixTime(time_in_second)}
+
+
(Link chưa kiểm định. Cần thận trọng khi xem)
+
+ )
+ }
+
+
+ // todo: remove this
+ let random_number = randomBetween(1, 100);
+ let message_from_me = random_number % 2 === 0;
+
+ return (
+
+
+
+
+ {from}: { showUnixTime(time_in_second) }
+
+
+
+ {
+ deliveryStatus === 5 && (
(Lỗi xảy ra, tin nhắn chưa được gửi)
)
+ }
+
+
deliveryStatus :{deliveryStatus} | sq:{props.sequence}
+
+
+ )
+}
+
+
+// format the display of each message on the chatbox
+function _isImageUploadedToOurServer(image: string) : boolean{
+ //check images uploaded to chat server
+ let img_regex_1 = /(\/user_upload\/([0-9]{4}-[0-9]{2}-[0-9]{1,2})\/([a-z0-9_]+)\.(jpg|jpeg|gif|png))$/g;
+ //let img_regex_2 = /(\/file\.php\?f=([0-9]{4}-[0-9]{2}-[0-9]{1,2})&(amp;)?n=([a-z0-9_]+)\.(jpg|jpeg|gif|png))$/g;
+ let img_regex_2 = /(\/file\.php\?f=([0-9]{4}-[0-9]{2}-[0-9]{1,2})&(amp;)?n=([a-z0-9_]+)\.(jpg|jpeg|gif|png)&(amp;)?o=([A-Za-z0-9_.-]+)\.(jpg|jpeg|gif|png))$/g;
+
+ return (img_regex_1.test(image) || img_regex_2.test(image))
+}
diff --git a/src/components/Chatbox/ver/Sample.tsx b/src/components/Chatbox/ver/Sample.tsx
new file mode 100644
index 0000000..acfd414
--- /dev/null
+++ b/src/components/Chatbox/ver/Sample.tsx
@@ -0,0 +1,219 @@
+import React, {useEffect, useState} from "react";
+import ToolbarButton from "@/components/ToolbarButton";
+import moment from "moment";
+
+import './Chatbox.css';
+
+const WINDOW_HEIGHT = global.window.innerHeight;
+const CHATBOX_HEIGHT = WINDOW_HEIGHT - 220;
+const MY_USER_ID = 'apple';
+
+const SAMPLE_MESSAGES = [
+ {
+ id: 1,
+ author: 'apple',
+ message: 'Hello world! This is a long message that will hopefully get wrapped by our message bubble component! We will see how well it works.',
+ timestamp: new Date().getTime()
+ },
+ {
+ id: 2,
+ author: 'orange',
+ message: 'It looks like it wraps exactly as it is supposed to. Lets see what a reply looks like!',
+ timestamp: new Date().getTime()
+ },
+ {
+ id: 3,
+ author: 'orange',
+ message: 'Hello world! This is a long message that will hopefully get wrapped by our message bubble component! We will see how well it works.',
+ timestamp: new Date().getTime()
+ },
+ {
+ id: 4,
+ author: 'apple',
+ message: 'It looks like it wraps exactly as it is supposed to. Lets see what a reply looks like!',
+ timestamp: new Date().getTime()
+ },
+ {
+ id: 5,
+ author: 'apple',
+ message: 'Hello world! This is a long message that will hopefully get wrapped by our message bubble component! We will see how well it works.',
+ timestamp: new Date().getTime()
+ },
+ {
+ id: 6,
+ author: 'apple',
+ message: 'It looks like it wraps exactly as it is supposed to. Lets see what a reply looks like!',
+ timestamp: new Date().getTime()
+ },
+ {
+ id: 7,
+ author: 'orange',
+ message: 'Hello world! This is a long message that will hopefully get wrapped by our message bubble component! We will see how well it works.',
+ timestamp: new Date().getTime()
+ },
+ {
+ id: 8,
+ author: 'orange',
+ message: 'It looks like it wraps exactly as it is supposed to. Lets see what a reply looks like!',
+ timestamp: new Date().getTime()
+ },
+ {
+ id: 9,
+ author: 'apple',
+ message: 'Hello world! This is a long message that will hopefully get wrapped by our message bubble component! We will see how well it works.',
+ timestamp: new Date().getTime()
+ },
+ {
+ id: 10,
+ author: 'orange',
+ message: 'It looks like it wraps exactly as it is supposed to. Lets see what a reply looks like!',
+ timestamp: new Date().getTime()
+ },
+];
+
+
+function Compose(props: any) {
+ return (
+
+
+ {
+ props.rightItems
+ }
+
+ )
+}
+
+
+function MessageList(props: {customer_id: string|number}) {
+ const [messages, setMessages] = useState([])
+
+ useEffect(() => {
+ const getMessages = () => {
+ setMessages(SAMPLE_MESSAGES)
+ }
+
+ getMessages();
+ })
+
+ const renderMessages = () => {
+ let i = 0;
+ let messageCount = messages.length;
+ let tempMessages = [];
+
+ while (i < messageCount) {
+ let previous = messages[i - 1];
+ let current = messages[i];
+ let next = messages[i + 1];
+ let isMine = current.author === MY_USER_ID;
+ let currentMoment = moment(current.timestamp);
+ let prevBySameAuthor = false;
+ let nextBySameAuthor = false;
+ let startsSequence = true;
+ let endsSequence = true;
+ let showTimestamp = true;
+
+ if (previous) {
+ let previousMoment = moment(previous.timestamp);
+ let previousDuration = moment.duration(currentMoment.diff(previousMoment));
+ prevBySameAuthor = previous.author === current.author;
+
+ if (prevBySameAuthor && previousDuration.as('hours') < 1) {
+ startsSequence = false;
+ }
+
+ if (previousDuration.as('hours') < 1) {
+ showTimestamp = false;
+ }
+ }
+
+ if (next) {
+ let nextMoment = moment(next.timestamp);
+ let nextDuration = moment.duration(nextMoment.diff(currentMoment));
+ nextBySameAuthor = next.author === current.author;
+
+ if (nextBySameAuthor && nextDuration.as('hours') < 1) {
+ endsSequence = false;
+ }
+ }
+
+ tempMessages.push(
+
+ );
+
+ // Proceed to the next message.
+ i += 1;
+ }
+
+ return tempMessages;
+ }
+
+ return(
+
+ { renderMessages() }
+
+ )
+}
+
+function Message(props: any) {
+ const {
+ data,
+ isMine,
+ startsSequence,
+ endsSequence,
+ showTimestamp
+ } = props;
+
+ const friendlyTimestamp = moment(data.timestamp).format('LLLL');
+ return (
+
+ {
+ showTimestamp &&
+
+ { friendlyTimestamp }
+
+ }
+
+
+
+ );
+}
+
+
+export default function Chatbox({customer_id}: {customer_id: string|number}) {
+ return (
+ <>
+
+
+
+
+ ,
+ ,
+ ,
+ ,
+ ,
+
+ ]}/>
+ >
+ )
+}
diff --git a/src/components/Chatbox/ver/TypingNotification.tsx b/src/components/Chatbox/ver/TypingNotification.tsx
new file mode 100644
index 0000000..0ca1b71
--- /dev/null
+++ b/src/components/Chatbox/ver/TypingNotification.tsx
@@ -0,0 +1,13 @@
+
+import {Image} from "antd";
+import React from "react";
+
+import TYPING_ANIMATION_IMAGE from "@/assets/typing-animation.gif";
+
+const TypingNotification = () => {
+ return (
+
+ )
+}
+
+export default TypingNotification;
diff --git a/src/components/Comment/index.tsx b/src/components/Comment/index.tsx
new file mode 100644
index 0000000..c66a30f
--- /dev/null
+++ b/src/components/Comment/index.tsx
@@ -0,0 +1,123 @@
+import React, {useState} from "react";
+import {Form, Input, Comment, Tooltip, Avatar, Divider } from 'antd';
+import { LikeOutlined } from '@ant-design/icons';
+import {showUnixTime} from "@/lib/utils";
+import {DEFAULT_AVATAR} from "@/config";
+
+
+const CommentForm = () => {
+
+ const [form] = Form.useForm();
+
+ const onFinish = (values: any) => {
+ alert(JSON.stringify(values));
+ //form.resetFields();
+ //const admin_info = getAdminInfo();
+ (async () => {
+ //let result = await api.post('user/create-note', {...values, customer_id, admin_name: admin_info.name});
+ //notifyApiResult(result, 'Ghi chú đã được lưu');
+ //Emitter.emit('create_note');
+ })();
+ }
+
+
+ return (
+
+ {
+ e.preventDefault();
+ form.submit();
+ }}
+ />
+
+
+ )
+}
+
+
+type CommentInfo = {
+ id: number,
+ content: string,
+ author: string,
+ create_time: number
+}
+
+const CommentList = ({item_type, item_id} : {item_type: string, item_id?: string|number}) => {
+
+ //const [keyword, setKeyword] = useState('');
+ const item_list: CommentInfo[] = [
+ {
+ id: 1,
+ content: 'hello',
+ author: 'admin 1',
+ create_time: 12121,
+ },
+ {
+ id: 2,
+ content: 'hello',
+ author: 'admin 1',
+ create_time: 12121,
+ },
+ ];
+
+
+ const CommentItem = (item: CommentInfo) => {
+
+ const actions = [
+
+ alert('like')}>
+
+ 1
+
+ ,
+
+ /*
+ alert('dislikes')}>
+
+ 0
+
+ ,*/
+
+ Reply to ,
+ ];
+
+ return (
+
+ }
+ content={item.content}
+ datetime={showUnixTime(item.create_time)}
+ />
+ )
+ }
+
+
+ return (
+
+
+
+
+
Nhận xét ({item_list.length})
+
+
+
+ {
+ item_list.map( CommentItem )
+ }
+
+
+ );
+}
+
+export default CommentList;
diff --git a/src/components/ConversationList/ConversationList.css b/src/components/ConversationList/ConversationList.css
new file mode 100644
index 0000000..348be8a
--- /dev/null
+++ b/src/components/ConversationList/ConversationList.css
@@ -0,0 +1,63 @@
+.conversation-list {
+ display: flex;
+ flex-direction: column;
+}
+
+.conversation-search {
+ padding: 10px;
+ display: flex;
+ flex-direction: column;
+}
+
+.conversation-search-input {
+ background: #f4f4f8;
+ padding: 8px 10px;
+ border-radius: 10px;
+ border: none;
+ font-size: 14px;
+}
+
+.conversation-search-input::placeholder {
+ text-align: center;
+}
+
+.conversation-search-input:focus::placeholder {
+ text-align: left;
+}
+
+
+.conversation-list-item {
+ display: flex;
+ align-items: center;
+ padding: 10px;
+}
+
+.conversation-list-item-selected {
+ background: #ffcc00;
+}
+
+.conversation-list-item:hover {
+ background: #ffcc00;
+ cursor: pointer;
+}
+
+.conversation-photo {
+ width: 50px;
+ height: 50px;
+ border-radius: 50%;
+ object-fit: cover;
+ margin-right: 10px;
+}
+
+.conversation-title {
+ font-size: 14px;
+ font-weight: bold;
+ text-transform: capitalize;
+ margin: 0;
+}
+
+.conversation-snippet {
+ font-size: 14px;
+ color: #CCCCCC;
+ margin: 0;
+}
\ No newline at end of file
diff --git a/src/components/ConversationList/index.tsx b/src/components/ConversationList/index.tsx
new file mode 100644
index 0000000..eaf922a
--- /dev/null
+++ b/src/components/ConversationList/index.tsx
@@ -0,0 +1,143 @@
+import React, {createRef, useState} from 'react';
+import debounce from "lodash/debounce";
+import {Image, Input} from "antd";
+import classNames from "classnames";
+import {useDispatch, useSelector} from 'react-redux';
+
+import {AppState} from "@/store/typing";
+import {UserInfo} from "@/typings/user";
+import {actions} from "@/store/actions";
+import {DEFAULT_AVATAR} from "@/config";
+import BadgeStatus from "@/components/BadgeStatus";
+
+import Toolbar from '../Toolbar';
+import ToolbarButton from '../ToolbarButton';
+import TYPING_ANIMATION_IMAGE from "@/assets/typing-animation.gif";
+
+import './ConversationList.css';
+import {isFound} from "@/lib/vietnamese";
+
+import {user_list} from "@/test/test_state";
+
+
+//Note: this component has been well-tested, do NOT change it!!
+const ConversationSearch = ({setKeyword} : {setKeyword: (keyword: string) => void}) => {
+ const search_wait_time = 300; // milli seconds
+ const inputRef = createRef ();
+ const startSearch = () => {
+ //const time = new Date().getSeconds();
+ const keyword = (inputRef.current) ? inputRef.current.input.value : '';
+ //console.log('Keyword = ' + keyword + ' at ' + time);
+ setKeyword(keyword);
+ }
+
+ return (
+
+
+
+ );
+}
+
+
+const ConversationListItem = (props: { user: UserInfo, unread: { total: number, messages: string[] } }) => {
+ /*useEffect(() => {
+ shave('.conversation-snippet', 20);
+ })*/
+
+ const chatting_with_user = useSelector((state: AppState) => state.chatting_with_user);
+ const dispatch = useDispatch();
+
+ const { user, unread } = props;
+ const avatar = user.avatar || DEFAULT_AVATAR;
+ const pickUserToChat = () => {
+ dispatch(actions.chatWithUser(user.id));
+ }
+
+ const containerClass = classNames({
+ 'conversation-list-item': true,
+ 'conversation-list-item-selected': chatting_with_user === user.id
+ })
+
+ return (
+ pickUserToChat()} title={unread.messages.join('\n')}>
+
+
+
+
+
+
+
{ user.name } ({user.location})
+
{user.id}
+ {
+ user.typing &&
+ }
+
+
+ );
+}
+
+
+const ConversationList = () => {
+
+ const [keyword, setKeyword] = useState('');
+
+ //TODO:
+ const getUnreadMsgPerUser = () => {
+ return {
+ '12312312312': {
+ total: 10,
+ messages: ['Vâng ạ', 'Xin chào']
+ },
+ "12312312334": {
+ total: 3,
+ messages: ['Vâng ạ', 'Xin chào']
+ },
+ }
+ }
+
+ //const no_unread = {total: 0, messages: []};
+ const no_unread = {total: 2, messages: ['hello there', 'CÓ gì không thế?']};
+
+ const getUserList = (query: string = '') => {
+ if(query === '') return user_list;
+
+ return user_list.filter((user) => isFound(query, user.name || '') );
+ }
+
+ const un_read_message_per_user: {[key: string]: { total: number, messages: string[] } } = getUnreadMsgPerUser();
+
+
+ return (
+
+
+
+ ]}
+ rightItems={[
+
+ ]}
+ />
+
+
+
+ {
+ getUserList(keyword).map(
+ (user: UserInfo) =>
+ )
+ }
+
+
+
+ );
+}
+
+export default ConversationList;
diff --git a/src/components/CustomComponent/guide.txt b/src/components/CustomComponent/guide.txt
new file mode 100644
index 0000000..2b6f1f8
--- /dev/null
+++ b/src/components/CustomComponent/guide.txt
@@ -0,0 +1,67 @@
+27-April-2021
+
+- Summary: we can allow customers to create a custom-built component to replace a built-in component and run on our app.
+
+- Steps:
++ Step 1: develop component as a normal react component in an react app. Make sure it is bug-free. Example
+ const TestComponent = () => {
+ const [counter, setCounter] = useState(0);
+ const handleClick = () => {
+ alert('hello there');
+ }
+
+ return (
+
+
Counter: {counter} setCounter(counter + 1)}>[+]
+
Click vào đây để thử nhé Click
+
+ )
+ }
+
++ Step 2: use https://babeljs.io/repl to transpire into js. The above will produce:
+ var TestComponent = function Test() {
+ var _useState = React.useState(0),
+ counter = _useState[0],
+ setCounter = _useState[1];
+
+ var handleClick = function handleClick() {
+ alert('hello there');
+ };
+
+ return /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("h2", null, "Counter: ", counter, " ", /*#__PURE__*/React.createElement("span", {
+ onClick: function onClick() {
+ return setCounter(counter + 1);
+ }
+ }, "[+]")), /*#__PURE__*/React.createElement("div", null, "Click v\xE0o \u0111\xE2y \u0111\u1EC3 th\u1EED nh\xE9 ", /*#__PURE__*/React.createElement("span", {
+ style: {
+ color: 'green'
+ },
+ onClick: handleClick
+ }, "Click")));
+ };
+
++ Step 3: Insert the js code in database to produce in our app's html files. Example:
+
+
+
+
+
+
+
+
+
+
++ Step 4: In our app, use the code like CustomComponent/index.tsx
+The app will check if there is a custom component, it will run!
+
+- Trouble Shooting:
++ Error: React is not defined
+Cause: React is built in because of webpack and custom components only can access React from window.React. To make React exposed to Window, in our app we can expose it (for example in the app's entry file index.tst)
+
+//src/index.tsx:
+import React from 'react';
+...
+window.React = React; // export React for outside world
+...other
diff --git a/src/components/CustomComponent/index.tsx b/src/components/CustomComponent/index.tsx
new file mode 100644
index 0000000..e8284c9
--- /dev/null
+++ b/src/components/CustomComponent/index.tsx
@@ -0,0 +1,24 @@
+import React, {useState} from "react";
+
+
+const Test = () => {
+
+ const [external, setExternal] = useState(false);
+
+ const TestExternal = () => {
+ // @ts-ignore
+ return window.TestComponent() || TestExternalComponent not found
+ }
+
+
+ return (
+ <>
+ {
+ external ? : No external
+ }
+ setExternal(true) }>Show TestExternalComponent
+ >
+ )
+}
+
+export default Test;
diff --git a/src/components/CustomerInfo/components/BrowseHistory.tsx b/src/components/CustomerInfo/components/BrowseHistory.tsx
new file mode 100644
index 0000000..b73945a
--- /dev/null
+++ b/src/components/CustomerInfo/components/BrowseHistory.tsx
@@ -0,0 +1,91 @@
+import {Table} from "antd";
+import React, {useEffect,useState} from "react";
+import {BrowseInfo} from "@/typings";
+import api from "@/lib/api";
+import Loading from "@/components/Loading";
+import {showUnixTime, subStr} from "@/lib/utils";
+
+
+const BrowseHistory = ({customer_id}: {customer_id: string|number}) => {
+
+ const [loading, setLoading] = useState(false);
+ const [item_list, setList] = useState<{total: number, list: BrowseInfo[]}>({total: 0, list: []});
+ const [refresh, setRefresh] = useState<{ page: number, token: number }>({page: 1, token: 0});
+ const pageSize = 10;
+ const {page} = refresh;
+
+ const requestFromAPI = async (page: number, customer_id: any ) => {
+ setLoading(true);
+ let result = await api.get('user/browse-history', {page, pageSize, customer_id});
+ if(result.status === 'ok' && result.data) {
+ setList({
+ total: result.data.total,
+ list: result.data.list.map((item: BrowseInfo) => {
+ return {...item, key: item.id}
+ })
+ });
+ }
+ setLoading(false);
+ }
+
+
+ useEffect(() => {
+ requestFromAPI(page, customer_id).then();
+
+ }, [page, customer_id]);
+
+
+ const columns = [
+ {
+ title: 'Tên miền',
+ dataIndex: 'domain',
+ key: 'domain',
+ },
+ {
+ title: 'Trang',
+ key: 'url',
+ render: (text: string, item: BrowseInfo) => {
+ return (
+ {subStr(item.title, 30)}
+ )
+ }
+ },
+ {
+ title: 'Thời gian',
+ key: 'create_time',
+ render: (text: string, item: BrowseInfo) => {
+ return (
+ <>{showUnixTime(item.create_time)}>
+ )
+ }
+ },
+ ];
+
+
+ if(loading) {
+ return
+ }
+
+ return (
+ {
+ setRefresh({page, token: 0});
+ }
+ }}
+ /*onRow={(record, rowIndex) => {
+ return {
+ onClick: event => openDrawer('order-detail', {id: record.api_id}), // click row
+ };
+ }}*/
+ />
+ );
+}
+
+export default BrowseHistory;
diff --git a/src/components/CustomerInfo/components/Info.tsx b/src/components/CustomerInfo/components/Info.tsx
new file mode 100644
index 0000000..30793e5
--- /dev/null
+++ b/src/components/CustomerInfo/components/Info.tsx
@@ -0,0 +1,236 @@
+import React, {useEffect} from "react";
+import {Button, Form, Input, Tooltip, DatePicker} from "antd";
+import {InfoCircleFilled} from "@ant-design/icons";
+
+import api, {notifyApiResult} from "@/lib/api";
+import PROVINCE_LIST from "@/constant/province_list";
+import {SelectWithList} from "@/components/SelectBox";
+import {useDispatch} from "react-redux";
+import {UserInfo} from "@/typings/user";
+import {actions} from "@/store/actions";
+import GENDER_LIST from "@/constant/gender";
+
+
+const Info = ({user_info}: {user_info: UserInfo}) => {
+
+ const [form] = Form.useForm();
+ const user_id = user_info.id;
+ const dispatch = useDispatch();
+
+ useEffect(() => {
+ const default_info = {
+ "user_id": user_id,
+ "name": "",
+ "crm_code": "",
+ "email": "",
+ "note": "",
+ "gender": null,
+ "province": null,
+ "address": "",
+ "company": "",
+ "tel": "",
+ "website": ""
+ };
+
+ (async () => {
+ let result = await api.get('user/info', {id: user_id});
+ if(result.status === 'ok') {
+ const filled_info = (result.data) ? result.data : default_info;
+ form.setFieldsValue({user: filled_info});
+ }
+ })();
+ }, [user_id, form]);
+
+ const onFinish = (values: any) => {
+ console.log('Received values of form: ', values);
+ // form.resetFields();
+ (async () => {
+ let result = await api.patch('user/update-info', {...values});
+ notifyApiResult(result);
+
+ // update the latest
+ if(result.status === 'ok') {
+ form.setFieldsValue({user: result.data});
+
+ // update user-info if some data changes
+ if(result.data.name !== user_info.name || result.data.province !== user_info.location) {
+ dispatch(actions.updateUserInfo({
+ id: user_info.id,
+ name: result.data.name,
+ location: result.data.province,
+ }))
+ }
+ }
+ })();
+ };
+
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/*
+
+
+
+ */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Cập nhật
+
+
+
+ );
+}
+
+export default Info;
diff --git a/src/components/CustomerInfo/components/ListNote.tsx b/src/components/CustomerInfo/components/ListNote.tsx
new file mode 100644
index 0000000..d6eae9d
--- /dev/null
+++ b/src/components/CustomerInfo/components/ListNote.tsx
@@ -0,0 +1,96 @@
+import {Table} from "antd";
+import React, {useEffect, useState} from "react";
+import {NoteInfo} from "@/typings";
+import api from "@/lib/api";
+import Loading from "@/components/Loading";
+import {getCurrentUTCTimestamp, showUnixTime, subStr} from "@/lib/utils";
+import Emitter from '@/lib/emitter';
+import {openDrawer} from "@/components/GlobalDrawer";
+
+
+const ListNote = ({customer_id}: {customer_id: string|number}) => {
+
+ const [loading, setLoading] = useState(false);
+ const [item_list, setList] = useState<{total: number, list: NoteInfo[]}>({total: 0, list: []});
+ const [refresh, setRefresh] = useState<{ page: number, token: number }>({page: 1, token: 0});
+ const pageSize = 10;
+ const {page, token} = refresh;
+
+ const requestFromAPI = async (page: number, customer_id: any, token: number ) => {
+ setLoading(true);
+ let result = await api.get('note/list', {page, pageSize, customer_id, _rf: token});
+ if(result.status === 'ok' && result.data) {
+ setList({
+ total: result.data.total,
+ list: result.data.list.map((item: NoteInfo) => {
+ return {...item, key: item.id}
+ })
+ });
+ }
+ setLoading(false);
+ }
+
+
+ useEffect(() => {
+
+ // listen for new note creation
+ Emitter.on('create_note', () => {
+ setRefresh({page: 1, token: getCurrentUTCTimestamp()})
+ });
+
+ requestFromAPI(page, customer_id, token).then();
+
+ // cleanup
+ return () => {
+ Emitter.off('create_note', () => {
+ console.log('Emitter.off create_note');
+ });
+ }
+
+ }, [page, customer_id, token]);
+
+
+ const columns = [
+ {
+ title: 'Nhân viên',
+ dataIndex: 'admin_name',
+ key: 'admin_name',
+ },
+ {
+ title: 'Thông tin',
+ key: 'content',
+ render: (item: NoteInfo) => (
+ openDrawer('note-detail', {id: item.api_id})} title={item.content}>{subStr(item.content, 30)}
+ )
+ },
+ {
+ title: 'Ngày',
+ key: 'create_time',
+ render: (item: NoteInfo) => (
+ <>{showUnixTime(item.create_time)}>
+ )
+ },
+ ];
+
+ if(loading) {
+ return
+ }
+
+ return (
+ {
+ setRefresh({page, token: 0});
+ }
+ }}
+ />
+ );
+}
+
+export default ListNote;
diff --git a/src/components/CustomerInfo/components/ListOrder.tsx b/src/components/CustomerInfo/components/ListOrder.tsx
new file mode 100644
index 0000000..117998b
--- /dev/null
+++ b/src/components/CustomerInfo/components/ListOrder.tsx
@@ -0,0 +1,119 @@
+import React, {useEffect, useState} from "react";
+import {OrderInfo} from "@/typings";
+import api from "@/lib/api";
+import Loading from "@/components/Loading";
+import {Badge, Table} from "antd";
+import Emitter from "@/lib/emitter";
+import {getCurrentUTCTimestamp, showUnixTime, formatNumber} from "@/lib/utils";
+import {openDrawer} from "@/components/GlobalDrawer";
+import OrderStatus from "@/components/display/OrderStatus";
+
+
+const ListOrder = ({customer_id}: {customer_id: string|number}) => {
+
+ const [loading, setLoading] = useState(false);
+ const [item_list, setList] = useState<{total: number, list: OrderInfo[]}>({total: 0, list: []});
+ const [refresh, setRefresh] = useState<{ page: number, token: number }>({page: 1, token: 0});
+ const pageSize = 10;
+ const {page, token} = refresh;
+
+ const requestFromAPI = async (page: number, customer_id: any, token: number ) => {
+ setLoading(true);
+ let result = await api.get('order/list', {page, pageSize, customer_id, _rf: token});
+ if(result.status === 'ok' && result.data) {
+ setList({
+ total: result.data.total,
+ list: result.data.list.map((item: OrderInfo) => {
+ return {...item, key: item.api_id}
+ })
+ });
+ }
+ setLoading(false);
+ }
+
+
+ useEffect(() => {
+
+ // listen for new note creation
+ Emitter.on('create_order', () => {
+ setRefresh({page: 1, token: getCurrentUTCTimestamp()})
+ });
+
+ requestFromAPI(page, customer_id, token).then();
+
+ // cleanup
+ return () => {
+ Emitter.off('create_order', () => {
+ console.log('Emitter.off create_order');
+ });
+ }
+
+ }, [page, customer_id, token]);
+
+
+ const columns = [
+ {
+ title: 'Mã đơn',
+ key: 'api_id',
+ render: (item: OrderInfo) => (
+ openDrawer('order-detail', {id: item.api_id})} title={'Xem chi tiết'}>{item.api_id}
+ )
+ },
+ {
+ title: 'Ngày',
+ key: 'create_time',
+ render: (item: OrderInfo) => (
+ <>{showUnixTime(item.create_time)}>
+ )
+ },
+ {
+ title: 'Giá trị',
+ key: 'total_value',
+ render: (item: OrderInfo) => (
+ <>{formatNumber(item.total_value)}>
+ )
+ },
+ {
+ title: 'Tình trạng',
+ key: 'order_status',
+ render: (item: OrderInfo) => (
+
+ )
+ },
+ {
+ title: 'Chi tiết',
+ render: (item: OrderInfo) => (
+ openDrawer('order-detail', {id: item.api_id})} title={'Xem chi tiết'}>
+
+ )
+ },
+ ];
+
+
+ if(loading) {
+ return
+ }
+
+ return (
+ {
+ setRefresh({page, token: 0});
+ }
+ }}
+ onRow={(record, rowIndex) => {
+ return {
+ onClick: event => openDrawer('order-detail', {id: record.api_id}), // click row
+ };
+ }}
+ />
+ );
+}
+
+export default ListOrder;
diff --git a/src/components/CustomerInfo/components/ListSupport.tsx b/src/components/CustomerInfo/components/ListSupport.tsx
new file mode 100644
index 0000000..463b147
--- /dev/null
+++ b/src/components/CustomerInfo/components/ListSupport.tsx
@@ -0,0 +1,107 @@
+import {Table} from "antd";
+import React, {useEffect, useState} from "react";
+import {SupportInfo} from "@/typings";
+import api from "@/lib/api";
+import Loading from "@/components/Loading";
+import Emitter from "@/lib/emitter";
+import {getCurrentUTCTimestamp, showUnixTime, subStr} from "@/lib/utils";
+import {openDrawer} from "@/components/GlobalDrawer";
+
+
+const ListSupport = ({customer_id}: {customer_id: string|number}) => {
+
+ const [loading, setLoading] = useState(false);
+ const [item_list, setList] = useState<{total: number, list: SupportInfo[]}>({total: 0, list: []});
+ const [refresh, setRefresh] = useState<{ page: number, token: number }>({page: 1, token: 0});
+ const pageSize = 10;
+ const {page, token} = refresh;
+
+ const requestFromAPI = async (page: number, customer_id: any, token: number ) => {
+ setLoading(true);
+ let result = await api.get('support/list', {page, pageSize, customer_id, _rf: token});
+ if(result.status === 'ok' && result.data) {
+ setList({
+ total: result.data.total,
+ list: result.data.list.map((item: SupportInfo) => {
+ return {...item, key: item.id}
+ })
+ });
+ }
+ setLoading(false);
+ }
+
+
+ useEffect(() => {
+
+ // listen for new note creation
+ Emitter.on('create_support', () => {
+ setRefresh({page: 1, token: getCurrentUTCTimestamp()})
+ });
+
+ requestFromAPI(page, customer_id, token).then();
+
+ // cleanup
+ return () => {
+ Emitter.off('create_support', () => {
+ console.log('Emitter.off create_note');
+ });
+ }
+
+ }, [page, customer_id, token]);
+
+
+ const columns = [
+ {
+ title: 'Nhân viên',
+ dataIndex: 'admin_name',
+ key: 'admin_name',
+ },
+ {
+ title: 'Thông tin',
+ render: (item: SupportInfo) => (
+ openDrawer('support-detail', {id: item.api_id})} title={item.title}>{subStr(item.title, 50)}
+ )
+ },
+ {
+ title: 'Ngày',
+ key: 'create_time',
+ render: (item: SupportInfo) => (
+ <>{showUnixTime(item.create_time)}>
+ )
+ },
+ {
+ title: 'Phản hồi',
+ dataIndex: 'comment_count',
+ key: 'comment_count',
+ },
+ {
+ title: 'Tình trạng',
+ dataIndex: 'status',
+ key: 'status',
+ },
+ ];
+
+
+ if(loading) {
+ return
+ }
+
+ return (
+ {
+ setRefresh({page, token: 0});
+ }
+ }}
+ />
+ );
+}
+
+
+export default ListSupport;
diff --git a/src/components/CustomerInfo/index.tsx b/src/components/CustomerInfo/index.tsx
new file mode 100644
index 0000000..d248628
--- /dev/null
+++ b/src/components/CustomerInfo/index.tsx
@@ -0,0 +1,52 @@
+import {Tabs} from "antd";
+import React, { Suspense} from "react";
+import Loading from "@/components/Loading";
+import {UserInfo} from "@/typings/user";
+
+const { TabPane } = Tabs;
+
+const CustomerInfoComponent = React.lazy(() => import('./components/Info'));
+const CustomerListOrderComponent = React.lazy(() => import('./components/ListOrder'));
+const CustomerListSupportComponent = React.lazy(() => import('./components/ListSupport'));
+const CustomerListNoteComponent = React.lazy(() => import('./components/ListNote'));
+const BrowseHistoryComponent = React.lazy(() => import('./components/BrowseHistory'));
+
+
+const CustomerInfo = ({user_info}: {user_info: UserInfo}) => {
+
+ return (
+
+
+ } >
+
+
+
+
+
+ } >
+
+
+
+
+
+ } >
+
+
+
+
+
+ } >
+
+
+
+
+
+ } >
+
+
+
+
+ )
+}
+
+export default CustomerInfo;
diff --git a/src/components/DashBoard/index.tsx b/src/components/DashBoard/index.tsx
new file mode 100644
index 0000000..be24fff
--- /dev/null
+++ b/src/components/DashBoard/index.tsx
@@ -0,0 +1,9 @@
+import React from "react";
+
+const DashBoard = () => {
+ return (
+ Vui lòng chọn người chat
+ )
+}
+
+export default DashBoard;
\ No newline at end of file
diff --git a/src/components/Error/ItemNotFound.tsx b/src/components/Error/ItemNotFound.tsx
new file mode 100644
index 0000000..d148f67
--- /dev/null
+++ b/src/components/Error/ItemNotFound.tsx
@@ -0,0 +1,9 @@
+const ItemNotFound = ({msg}: {msg?: string}) => {
+ return (
+ <>
+ {msg || 'Not found'}
+ >
+ )
+}
+
+export default ItemNotFound;
diff --git a/src/components/Error/NetworkError.tsx b/src/components/Error/NetworkError.tsx
new file mode 100644
index 0000000..a9b3a34
--- /dev/null
+++ b/src/components/Error/NetworkError.tsx
@@ -0,0 +1,77 @@
+import React, {FC} from "react";
+import {useSelector} from "react-redux";
+import {AppState} from "@/store/typing";
+import {Modal} from "antd";
+import {RobotOutlined } from '@ant-design/icons';
+
+const NetworkError: FC = () => {
+
+ const {network_connection, node_connection } = useSelector((state: AppState) => (
+ {
+ network_connection: state.network_connection,
+ node_connection: state.node_connection
+ }
+ ));
+
+ const Title = () => {
+ if(network_connection === 'offline') {
+ return (
+ Lỗi kết nối (code: #1)
+ )
+ }
+
+ if(node_connection === 'error') {
+ return (
+ Lỗi kết nối (code: #2)
+ )
+ }
+
+ return null;
+ };
+
+ const Messsage = () => {
+ if(network_connection === 'offline') {
+ return Vui lòng kiểm tra lại đường truyền Internet của bạn
;
+ }
+
+ if(node_connection === 'error') {
+ return (
+
+
Kết nối tới máy chủ Chatngay không thành công!
+
Vui lòng đợi một lát và thử làm mới lại trang này (nhấn phím F5). Nếu lỗi vẫn xảy ra, vui lòng thông báo cho bộ phận CSKH của Chatngay tại
https://www.chatngay.com/support
+
+ )
+ }
+
+ return null;
+ }
+
+ const EmptyIcon = () => {
+ return (
+
+ )
+ }
+
+
+ const connection_error : boolean = (network_connection === 'offline' || node_connection === 'error');
+
+ if(!connection_error) {
+ return null;
+ }
+
+ return (
+ }
+ centered
+ visible
+ footer={null}
+ width={500}
+ maskClosable={false}
+ closeIcon={ }
+ >
+
+
+ )
+};
+
+export default NetworkError;
diff --git a/src/components/Error/index.tsx b/src/components/Error/index.tsx
new file mode 100644
index 0000000..9f56c78
--- /dev/null
+++ b/src/components/Error/index.tsx
@@ -0,0 +1,11 @@
+import React from "react";
+
+export default function Error({message}: {message?: string}) {
+ return (
+ <>
+
+ Error: {message || 'unknown'}
+
+ >
+ )
+}
diff --git a/src/components/GlobalDrawer/components/NoteDetail.tsx b/src/components/GlobalDrawer/components/NoteDetail.tsx
new file mode 100644
index 0000000..0f2542b
--- /dev/null
+++ b/src/components/GlobalDrawer/components/NoteDetail.tsx
@@ -0,0 +1,32 @@
+import React, {useEffect, useState} from "react";
+import api from "@/lib/api";
+import {NoteInfo} from "@/typings";
+import Comment from "@/components/Comment";
+import {showUnixTime} from "@/lib/utils";
+
+
+const NoteDetail = ({id}: {id: string|number}) => {
+
+ const [item_info, setInfo] = useState({});
+
+ useEffect(() => {
+ (async () => {
+ let result = await api.get('note/info', {id});
+ if(result.status === 'ok') {
+ setInfo(result.data);
+ }
+ })();
+ }, [id]);
+
+ return (
+ <>
+ {item_info.admin_name} ({showUnixTime(item_info.create_time)})
+
+ {item_info.content}
+
+
+ >
+ )
+}
+
+export default NoteDetail;
diff --git a/src/components/GlobalDrawer/components/OrderDetail.tsx b/src/components/GlobalDrawer/components/OrderDetail.tsx
new file mode 100644
index 0000000..83864cf
--- /dev/null
+++ b/src/components/GlobalDrawer/components/OrderDetail.tsx
@@ -0,0 +1,96 @@
+import React, {useEffect, useState} from "react";
+import {Descriptions} from "antd";
+
+import api from "@/lib/api";
+import Comment from "@/components/Comment";
+import {OrderInfo} from "@/typings";
+import {showUnixTime} from "@/lib/utils";
+import OrderStatus from "@/components/display/OrderStatus";
+import ShippingStatus from "@/components/display/ShippingStatus";
+import PaymentStatus from "@/components/display/PaymentStatus";
+
+
+const OrderDetail = ({id}: {id: string|number}) => {
+
+ const [item_info, setInfo] = useState({});
+
+ useEffect(() => {
+ (async () => {
+ let result = await api.get('order/info', {id});
+ if(result.status === 'ok') {
+ setInfo(result.data);
+ }
+ })();
+ }, [id]);
+
+
+ return (
+ <>
+
+ {item_info.api_id}
+ {item_info.note}
+ {showUnixTime(item_info.create_time)}
+
+
+
+
+
+
+
+
+
+
+
+ {item_info.customer?.name}
+ {item_info.customer?.email}
+ {item_info.customer?.mobile}
+ {item_info.customer?.address}
+ {item_info.customer?.province}
+
+
+ {
+ item_info.products?.map((product, index) => {
+ return (
+ <>
+ {index + 1}. {product.name} - SKU: {product.sku} - Giá: {product.price} - SL: {product.quantity}
+ >
+ )
+ } )
+ }
+
+
+ {
+ item_info.others?.map((other, index) => {
+ return (
+ <>
+ {index + 1}. {other.name} - {other.price}
+ >
+ )
+ } )
+ }
+
+
+ {item_info.shipping?.provider}
+ {item_info.shipping?.reference}
+ {item_info.shipping?.note}
+ {item_info.shipping?.date} - {item_info.shipping?.time}
+
+
+ {item_info.payment?.method}
+ {item_info.payment?.reference}
+ {item_info.payment?.note}
+
+ {item_info.tags?.join(', ')}
+
+
+
+ >
+ )
+}
+
+export default OrderDetail;
diff --git a/src/components/GlobalDrawer/components/SupportDetail.tsx b/src/components/GlobalDrawer/components/SupportDetail.tsx
new file mode 100644
index 0000000..478ba9a
--- /dev/null
+++ b/src/components/GlobalDrawer/components/SupportDetail.tsx
@@ -0,0 +1,40 @@
+import React, {useEffect, useState} from "react";
+import api from "@/lib/api";
+import {SupportInfo} from "@/typings";
+import Comment from "@/components/Comment";
+import {showUnixTime} from "@/lib/utils";
+import TextWithLineBreak from "@/components/TextWithLineBreak";
+import ItemNotFound from "@/components/Error/ItemNotFound";
+
+
+const SupportDetail = ({id}: {id: string|number}) => {
+
+ const [item_info, setInfo] = useState({});
+
+ useEffect(() => {
+ (async () => {
+ let result = await api.get('support/info', {id});
+ if(result.status === 'ok') {
+ setInfo(result.data);
+ }
+ })();
+ }, [id]);
+
+ if(!item_info) {
+ return
+ }
+
+ return (
+ <>
+
+ {item_info.title}
+ {item_info.admin_name} ({showUnixTime(item_info.create_time)})
+
+
+
+
+ >
+ )
+}
+
+export default SupportDetail;
diff --git a/src/components/GlobalDrawer/index.tsx b/src/components/GlobalDrawer/index.tsx
new file mode 100644
index 0000000..fef43e7
--- /dev/null
+++ b/src/components/GlobalDrawer/index.tsx
@@ -0,0 +1,88 @@
+import React, {Suspense} from 'react';
+import { Drawer } from 'antd';
+import {useDispatch, useSelector} from "react-redux";
+import {actions} from "@/store/actions";
+import {AppState} from "@/store/typing";
+import Loading from "@/components/Loading";
+import store from "@/store";
+
+
+const OrderDetailComponent = React.lazy(() => import('./components/OrderDetail'));
+const ProductListComponent = React.lazy(() => import('@/components/Help/components/ProductList'));
+const NoteDetail = React.lazy(() => import('./components/NoteDetail'));
+const SupportDetail = React.lazy(() => import('./components/SupportDetail'));
+
+
+type DrawerComponentType = 'order-detail' | 'product-list' | 'note-detail' | 'support-detail';
+
+
+export const openDrawer = (component: DrawerComponentType, args: any) => {
+ store.dispatch(actions.openGlobalDrawer({component, args}))
+}
+
+
+const GlobalDrawer: React.FC = () => {
+
+ const dispatch = useDispatch();
+ const closeDrawer = () => {
+ dispatch(actions.openGlobalDrawer({component: ''}))
+ }
+ const global_drawer = useSelector((state: AppState) => state.global_drawer);
+ const drawer_component = (global_drawer !== '') ? JSON.parse(global_drawer) : {component: '', args: {}};
+
+ const getLoadedComponent = () => {
+ if(drawer_component.component === 'order-detail') {
+ return ;
+ }
+
+ if(drawer_component.component === 'note-detail') {
+ return ;
+ }
+
+ if(drawer_component.component === 'support-detail') {
+ return ;
+ }
+
+ if(drawer_component.component === 'product-list') {
+ return alert(id)} />;
+ }
+
+ return null;
+ }
+
+ const Title = () => {
+ if(drawer_component.component === 'order-detail') {
+ return <>Chi tiết đơn hàng>;
+ }
+
+ if(drawer_component.component === 'note-detail') {
+ return <>Ghi chú>;
+ }
+
+ if(drawer_component.component === 'support-detail') {
+ return <>Chi tiết hỗ trợ>;
+ }
+
+ return null;
+ }
+
+
+ return (
+ <>
+ }
+ width={500}
+ placement="right"
+ closable
+ onClose={closeDrawer}
+ visible={drawer_component.component !== ''}
+ >
+ }>
+ { getLoadedComponent() }
+
+
+ >
+ );
+};
+
+export default GlobalDrawer;
diff --git a/src/components/GlobalModal/components/ListAdminOnline.tsx b/src/components/GlobalModal/components/ListAdminOnline.tsx
new file mode 100644
index 0000000..4030860
--- /dev/null
+++ b/src/components/GlobalModal/components/ListAdminOnline.tsx
@@ -0,0 +1,40 @@
+import {useSelector} from "react-redux";
+import {AppState} from "@/store/typing";
+import {AdminInfo} from "@/typings/user";
+import { List, Avatar, Badge } from 'antd';
+import React from "react";
+import {DEFAULT_AVATAR} from "@/config";
+
+const ListAdminOnline = () => {
+
+ const admin_list: AdminInfo[] = useSelector((state: AppState) => state.admin_list);
+
+ return (
+ (
+ alert(item.id)}>edit]}
+ >
+
+ }
+ title={<>{item.name}>}
+ //description={}
+ />
+
+
+
+
+
+
+ )}
+ />
+ );
+
+}
+
+export default ListAdminOnline;
diff --git a/src/components/GlobalModal/components/ListUserOnline.tsx b/src/components/GlobalModal/components/ListUserOnline.tsx
new file mode 100644
index 0000000..1ecc083
--- /dev/null
+++ b/src/components/GlobalModal/components/ListUserOnline.tsx
@@ -0,0 +1,26 @@
+import React, {useEffect, useState} from "react";
+import api from "@/lib/api";
+
+
+const ListUserOnline = () => {
+
+ const [data, setData] = useState<{total: number, locations: any[]}>({total: 0, locations: []});
+
+ useEffect(() => {
+
+ (async () => {
+ let result = await api.get('monitor/real-time', {});
+ if(result.status === 'ok' && result.data) {
+ setData(result.data);
+ }
+ })();
+ }, []);
+
+ return (
+ <>
+ {JSON.stringify(data)}
+ >
+ );
+}
+
+export default ListUserOnline;
\ No newline at end of file
diff --git a/src/components/GlobalModal/index.tsx b/src/components/GlobalModal/index.tsx
new file mode 100644
index 0000000..cfd7a84
--- /dev/null
+++ b/src/components/GlobalModal/index.tsx
@@ -0,0 +1,87 @@
+import React, {Suspense} from "react";
+import Loading from "@/components/Loading";
+import {Modal} from "antd";
+import {useDispatch, useSelector, shallowEqual} from "react-redux";
+import {AppState} from "@/store/typing";
+import {actions} from "@/store/actions";
+
+const HelpModal = React.lazy(() => import('@/components/Help/HelpModal'));
+const ProductListComponent = React.lazy(() => import('@/components/Help/components/ProductList'));
+const ListAdminOnline = React.lazy(() => import('./components/ListAdminOnline'));
+const ListUserOnline = React.lazy(() => import('./components/ListUserOnline'));
+
+
+const ModalComponent = () => {
+ const dispatch = useDispatch();
+ const closeModal = () => {
+ dispatch(actions.openGlobalModal({component: ''}))
+ }
+
+ const global_modal = useSelector((state: AppState) => state.global_modal, shallowEqual);
+ const modal_component = (global_modal !== '') ? JSON.parse(global_modal) : {component: '', args: {}};
+
+ console.log('modal_component');
+ console.log(modal_component);
+
+ const getLoadedComponent = () => {
+
+ if(modal_component.component === 'product-list') {
+ return alert(id)} />;
+ }
+
+ if(modal_component.component === 'help') {
+ return ;
+ }
+
+ if(modal_component.component === 'admin') {
+ return ;
+ }
+
+ if(modal_component.component === 'user') {
+ return ;
+ }
+
+ return null;
+ }
+
+ const Title = () => {
+ if(modal_component.component === 'product-list') {
+ return Danh sách sản phẩm ;
+ }
+
+ if(modal_component.component === 'help') {
+ return Danh sách bài viết ;
+ }
+
+ if(modal_component.component === 'admin') {
+ return Quản trị viên ;
+ }
+
+ if(modal_component.component === 'user') {
+ return Người dùng ;
+ }
+
+ return null;
+ };
+
+
+ return (
+ }
+ centered
+ visible={modal_component.component !== ''}
+ onOk={closeModal}
+ onCancel={closeModal}
+ width={1000}
+ maskClosable={false}
+ >
+ }>
+ {
+ getLoadedComponent()
+ }
+
+
+ )
+}
+
+export default ModalComponent;
diff --git a/src/components/HeaderComponent/index.tsx b/src/components/HeaderComponent/index.tsx
new file mode 100644
index 0000000..0139c0e
--- /dev/null
+++ b/src/components/HeaderComponent/index.tsx
@@ -0,0 +1,104 @@
+import React, {Fragment} from "react";
+import {useDispatch, useSelector, shallowEqual} from "react-redux";
+import {actions} from "@/store/actions";
+import {AppState} from "@/store/typing";
+import {getAdminInfo} from "@/lib/user";
+import {Col, Row, Image, Menu, Dropdown} from "antd";
+import { DownOutlined, TeamOutlined, UserOutlined, QuestionOutlined } from '@ant-design/icons';
+
+import "./styles.css";
+import {MenuInfo} from "rc-menu/lib/interface";
+
+
+const menu = (
+
+
+
+ 1st menu item
+
+
+ } disabled>
+
+ 2nd menu item
+
+
+
+
+ 3rd menu item
+
+
+ a danger item
+
+);
+
+
+const HeaderComponent = () => {
+ const admin_info = getAdminInfo();
+ const {stats, admin_list} = useSelector((state: AppState) => (
+ {
+ stats: state.stats,
+ admin_list: state.admin_list,
+ }
+ ), shallowEqual);
+
+ const current_stats: {
+ user_online: number,
+ } = (stats !== '') ? JSON.parse(stats) : { user_online: 0};
+
+ const dispatch = useDispatch();
+ const setOpenModal = (component: string) => {
+ dispatch(actions.openGlobalModal({component, args: {}}))
+ }
+
+ const handleMenuClick = (e: MenuInfo) => {
+ setOpenModal(e.key + '')
+ }
+
+ const TOTAL_ADMIN = admin_list.length;
+ const ADMIN_ONLINE = admin_list.filter(admin => admin.online).length;
+
+
+ return (
+
+
+
+
+
+
+
+
+
+ } >
+ Người dùng online ({current_stats.user_online})
+
+ } >
+ Quản trị viên online ({ADMIN_ONLINE}/{TOTAL_ADMIN})
+
+ }>
+ Trợ giúp
+
+
+
+
+
+
+
+
+
+
+ {admin_info.name}
+
+
+
+
+
+
+
+ )
+};
+
+export default HeaderComponent;
diff --git a/src/components/HeaderComponent/styles.css b/src/components/HeaderComponent/styles.css
new file mode 100644
index 0000000..6ff0638
--- /dev/null
+++ b/src/components/HeaderComponent/styles.css
@@ -0,0 +1,9 @@
+.header {
+ color: white;
+}
+
+.ant-layout-header .ant-menu{
+ background: black;
+ color: white;
+}
+
diff --git a/src/components/Help/HelpModal.tsx b/src/components/Help/HelpModal.tsx
new file mode 100644
index 0000000..63f2592
--- /dev/null
+++ b/src/components/Help/HelpModal.tsx
@@ -0,0 +1,26 @@
+import {Tabs} from "antd";
+import React from "react";
+
+
+const ProductListComponent = React.lazy(() => import('./components/ProductList'));
+const ArticleListComponent = React.lazy(() => import('./components/ArticleList'));
+
+const { TabPane } = Tabs;
+
+const HelpModal = () => {
+
+ return (
+ null } type="card" defaultActiveKey={'product'}>
+
+ {} } />
+
+
+ {
+
+ } } />
+
+
+ )
+}
+
+export default HelpModal;
diff --git a/src/components/Help/HelpSideBar.tsx b/src/components/Help/HelpSideBar.tsx
new file mode 100644
index 0000000..12a0410
--- /dev/null
+++ b/src/components/Help/HelpSideBar.tsx
@@ -0,0 +1,99 @@
+import React, {useEffect, useState, Suspense} from "react";
+import api from "@/lib/api";
+import Loading from "@/components/Loading";
+import {HelpType, OpenHelpComponent} from "@/typings";
+import SearchBox from "./components/SearchBox";
+
+const ProductListComponent = React.lazy(() => import('./components/ProductList'));
+const ArticleListComponent = React.lazy(() => import('./components/ArticleList'));
+const ProductDetailComponent = React.lazy(() => import('./components/ProductDetail'));
+const ArticleDetailComponent = React.lazy(() => import('./components/ArticleDetail'));
+const SearchComponent = React.lazy(() => import('./components/Search'));
+
+const WINDOW_HEIGHT = global.window.innerHeight;
+const HELP_HEIGHT = WINDOW_HEIGHT - 150;
+
+
+const HomeComponent = (props: {openComponent: (type: HelpType, params?: { [key: string] : any }) => void}) => {
+ const [help, setHelp] = useState<{product: any[], article: any[]}>({product:[], article:[]});
+
+ useEffect(() => {
+ const _getHelp = async () => {
+ let result = await api.get('help/home');
+ if(result.status === 'ok') {
+ setHelp(result.data);
+ }
+ };
+ _getHelp();
+ }, []);
+
+ return (
+ <>
+
+
+
Sản phẩm: props.openComponent('product-list')}>Xem het
+
+
props.openComponent('product-detail', {id})}
+ />
+
+ Kho kiến thức: props.openComponent('article-list')}>Xem het
+
+ props.openComponent('article-detail', {id})}
+ />
+
+
+ >
+ )
+}
+
+
+const HelpSideBar = () => {
+
+ const [component, setComponent] = useState({type: 'home'});
+
+ const openComponent = (type: HelpType, params?: { [key: string] : any }) => {
+ setComponent({type, params});
+ }
+
+ const getLoadedComponent = () => {
+ if(component.type === 'product-list') {
+ return openComponent('product-detail', {id})} />;
+ }
+
+ if(component.type === 'product-detail') {
+ return openComponent('home')} />;
+ }
+
+ if(component.type === 'article-list') {
+ return openComponent('article-detail', {id})} />;
+ }
+
+ if(component.type === 'article-detail') {
+ return openComponent('home')} />;
+ }
+
+ if(component.type === 'search') {
+ return ;
+ }
+
+ return ;
+ }
+
+
+ return (
+ }>
+
+
+ {
+ getLoadedComponent()
+ }
+
+ )
+
+}
+
+export default HelpSideBar;
diff --git a/src/components/Help/components/ArticleDetail.tsx b/src/components/Help/components/ArticleDetail.tsx
new file mode 100644
index 0000000..e3c376c
--- /dev/null
+++ b/src/components/Help/components/ArticleDetail.tsx
@@ -0,0 +1,36 @@
+import React, {useEffect, useState} from "react";
+import api from "@/lib/api";
+import {HelpItem} from "@/typings";
+import Comment from "@/components/Comment";
+
+
+const ArticleDetailComponent = ({id, openHome}: {id?: string|number, openHome: () => void}) => {
+
+ const [article_info, setArticle] = useState({});
+
+ useEffect(() => {
+ const _getInfo = async () => {
+ let result = await api.get('article/info', {id});
+ if(result.status === 'ok') {
+ setArticle(result.data);
+ }
+ };
+ _getInfo();
+ }, [id]);
+
+
+ return (
+ <>
+ Back to home
+
+ {article_info.name}
+
+ {article_info.summary}
+
+
+
+ >
+ )
+}
+
+export default ArticleDetailComponent;
diff --git a/src/components/Help/components/ArticleList.tsx b/src/components/Help/components/ArticleList.tsx
new file mode 100644
index 0000000..f3a9075
--- /dev/null
+++ b/src/components/Help/components/ArticleList.tsx
@@ -0,0 +1,89 @@
+import { List, Pagination} from "antd";
+import React, {useEffect, useRef, useState} from "react";
+import {HelpItem} from "@/typings";
+import api from "@/lib/api";
+import Loading from "@/components/Loading";
+
+
+const TopListComponent = (props: {defaultList: HelpItem[], openItem: (id?: string|number) => void}) => {
+
+ const {defaultList, openItem} = props;
+
+ return (
+ <>
+ (
+ openItem(item.id)}>
+ {item.name}}
+ description={item.summary}
+ />
+
+ )}
+ />
+ >
+ )
+}
+
+
+const FullListComponent = (props: {openItem: (id?: string|number) => void, params?: {[key: string]: any}}) => {
+
+ const {openItem , params} = props;
+ const item_list = useRef( []);
+ const [loading, setLoading] = useState(false);
+ const [page, setPage] = useState(1);
+
+ // const [item_list, setList] = useState(props.defaultList || []);
+ useEffect(() => {
+ (async () => {
+ setLoading(true);
+ let result = await api.get('article/list', params ? {...params, page} : {page});
+ // @ts-ignore
+ if(result.status === 'ok') {
+ item_list.current = result.data.list;
+ }
+ setLoading(false);
+ }) ();
+ }, [page, params]);
+
+ if(loading) {
+ return
+ }
+
+ return (
+ <>
+ (
+ openItem(item.id)}>
+ {item.name}}
+ description={item.summary}
+ />
+
+ )}
+ />
+
+ setPage(page)} />
+
+ >
+ )
+}
+
+
+const ArticleListComponent = (props: {defaultList?: HelpItem[], openItem: (id?: string|number) => void, params?: {[key: string]: any} }) => {
+
+ const {defaultList, openItem, params} = props;
+
+ if(defaultList) {
+ return
+ }
+
+ return
+}
+
+
+export default ArticleListComponent;
diff --git a/src/components/Help/components/ProductDetail.tsx b/src/components/Help/components/ProductDetail.tsx
new file mode 100644
index 0000000..30c2dab
--- /dev/null
+++ b/src/components/Help/components/ProductDetail.tsx
@@ -0,0 +1,55 @@
+import React, {useEffect, useState} from "react";
+import api from "@/lib/api";
+import {HelpItem} from "@/typings";
+import {Descriptions} from "antd";
+import {formatNumber, showUnixTime} from "@/lib/utils";
+import PaymentStatus from "@/components/display/PaymentStatus";
+import ShippingStatus from "@/components/display/ShippingStatus";
+import OrderStatus from "@/components/display/OrderStatus";
+import Comment from "@/components/Comment";
+
+const ProductDetailComponent = ({id, openHome}: {id?: string|number, openHome: () => void}) => {
+
+ const [product_info, setProduct] = useState({});
+
+ useEffect(() => {
+ const _getInfo = async () => {
+ let result = await api.get('product/info?id=', {id});
+ if(result.status === 'ok') {
+ setProduct(result.data);
+ }
+ };
+ _getInfo();
+ }, [id]);
+
+ return (
+ <>
+ Back to home
+
+
+ {product_info.name}
+ {product_info.in_stock}
+ ---
+
+ {formatNumber(product_info.price)}
+
+
+ {product_info.summary}
+
+
+
+
+
+
+
+
+ >
+ )
+}
+
+export default ProductDetailComponent;
diff --git a/src/components/Help/components/ProductList.tsx b/src/components/Help/components/ProductList.tsx
new file mode 100644
index 0000000..114ac3a
--- /dev/null
+++ b/src/components/Help/components/ProductList.tsx
@@ -0,0 +1,106 @@
+import {Avatar, List, Pagination} from "antd";
+import React, {useEffect, useRef, useState} from "react";
+import {HelpItem} from "@/typings";
+import api from "@/lib/api";
+
+import Loading from "@/components/Loading";
+import {formatNumber} from "@/lib/utils";
+
+
+const ProductDescription = ({item}: {item: HelpItem}) => {
+ const stock = item.in_stock ? 'Còn hàng' : 'Hết hàng';
+
+ return (
+ <>
+ {formatNumber(item.price)} - {stock}
+ >
+ )
+}
+
+
+const TopListComponent = (props: {defaultList: HelpItem[], openItem: (id?: string|number) => void }) => {
+
+ const {defaultList, openItem} = props;
+
+ return (
+ <>
+ (
+ openItem(item.id)}>
+ }
+ title={{item.name} }
+ description={ }
+ />
+
+ )}
+ />
+ >
+ )
+}
+
+
+const FullListComponent = (props: {openItem: (id?: string|number) => void, params?: {[key: string]: any} }) => {
+
+ const {openItem, params} = props;
+ const item_list = useRef( []);
+ const [loading, setLoading] = useState(false);
+ const [page, setPage] = useState(1);
+
+
+
+ // const [item_list, setList] = useState(props.defaultList || []);
+ useEffect(() => {
+ (async () => {
+ setLoading(true);
+ let result = await api.get('product/list', params ? {...params, page} : {page});
+ // @ts-ignore
+ if(result.status === 'ok') {
+ item_list.current = result.data.list;
+ }
+ setLoading(false);
+ }) ();
+ }, [page, params]);
+
+ if(loading) {
+ return
+ }
+
+ return (
+ <>
+ (
+ openItem(item.id)}>
+ }
+ title={{item.name} }
+ description={}
+ />
+
+ )}
+ />
+
+ setPage(page)} />
+
+ >
+ )
+}
+
+
+const ProductListComponent = (props: {defaultList?: HelpItem[], openItem: (id?: string|number) => void , params?: {[key: string]: any} }) => {
+
+ const {defaultList, openItem, params} = props;
+
+ if(defaultList) {
+ return
+ }
+
+ return
+}
+
+
+export default ProductListComponent;
diff --git a/src/components/Help/components/Search.tsx b/src/components/Help/components/Search.tsx
new file mode 100644
index 0000000..5b6f840
--- /dev/null
+++ b/src/components/Help/components/Search.tsx
@@ -0,0 +1,49 @@
+import React, {useEffect, useState} from "react";
+import api from "@/lib/api";
+import {HelpType} from "@/typings";
+
+const ProductListComponent = React.lazy(() => import('./ProductList'));
+const ArticleListComponent = React.lazy(() => import('./ArticleList'));
+
+const WINDOW_HEIGHT = global.window.innerHeight;
+const HELP_HEIGHT = WINDOW_HEIGHT - 150;
+
+
+const SearchComponent = (props: {keyword?: string, openComponent: (type: HelpType, params?: { [key: string] : any }) => void}) => {
+
+ const [help, setHelp] = useState<{product: any[], article: any[]}>({product:[], article:[]});
+ const {keyword} = props;
+
+ useEffect(() => {
+ (async () => {
+ let result = await api.get('help/search', {q: keyword || ''});
+ if(result.status === 'ok') {
+ setHelp(result.data);
+ }
+ })();
+ }, [keyword]);
+
+ return (
+ <>
+
+
+
Sản phẩm thỏa mãn: props.openComponent('product-list', {q: keyword || ''})}>Xem het
+
+
props.openComponent('product-detail', {id})}
+ />
+
+ Kho kiến thức thỏa mãn: props.openComponent('article-list', {q: keyword || ''})}>Xem het
+
+ props.openComponent('article-detail', {id})}
+ />
+
+
+ >
+ )
+}
+
+export default SearchComponent;
diff --git a/src/components/Help/components/SearchBox.tsx b/src/components/Help/components/SearchBox.tsx
new file mode 100644
index 0000000..24786ec
--- /dev/null
+++ b/src/components/Help/components/SearchBox.tsx
@@ -0,0 +1,39 @@
+import {Input} from "antd";
+import React, {useState} from "react";
+import {HelpType} from "@/typings";
+
+const { Search } = Input;
+
+
+const SearchBox = (props: {openComponent: (type: HelpType, params?: { [key: string] : string|number }) => void}) => {
+
+ const [keyword, setKeyword] = useState('');
+
+ const startSearch = () => {
+ if(keyword === '') {
+ props.openComponent('home');
+ return;
+ }
+ props.openComponent('search', {q: keyword});
+ }
+
+ return (
+
+ setKeyword(value)}
+ onPressEnter={(e) => {
+ e.preventDefault(); // required to prevent new line
+ startSearch();
+ }}
+ />
+
+ )
+}
+
+export default SearchBox;
diff --git a/src/components/Help/index.tsx b/src/components/Help/index.tsx
new file mode 100644
index 0000000..3535246
--- /dev/null
+++ b/src/components/Help/index.tsx
@@ -0,0 +1,2 @@
+export {default as HelpSideBar} from "./HelpSideBar";
+export {default as HelpModal} from "./HelpModal";
\ No newline at end of file
diff --git a/src/components/Loading/index.tsx b/src/components/Loading/index.tsx
new file mode 100644
index 0000000..6b3813e
--- /dev/null
+++ b/src/components/Loading/index.tsx
@@ -0,0 +1,14 @@
+import React from "react";
+import { Spin } from 'antd';
+import { LoadingOutlined } from '@ant-design/icons';
+
+const antIcon = ;
+
+
+export default function Loading() {
+ return (
+ <>
+
+ >
+ )
+}
diff --git a/src/components/SelectBox/SelectWithAddItem.tsx b/src/components/SelectBox/SelectWithAddItem.tsx
new file mode 100644
index 0000000..a3504c9
--- /dev/null
+++ b/src/components/SelectBox/SelectWithAddItem.tsx
@@ -0,0 +1,83 @@
+import React from "react";
+import { Select, Divider, Input } from 'antd';
+import { PlusOutlined } from '@ant-design/icons';
+import {SelectProps} from "antd/lib/select";
+
+const { Option } = Select;
+
+let index = 0;
+
+type SelectWithAddItemProps = SelectProps & {
+ current_lists: string[];
+}
+
+type SelectWithAddItemState = {
+ items: string[];
+ new_item_name: string;
+}
+
+
+class SelectWithAddItem extends React.Component {
+
+ constructor(props: SelectWithAddItemProps) {
+ super(props);
+ this.state = {
+ items: props.current_lists,
+ new_item_name: '',
+ }
+ }
+
+ onNameChange = (event: any) => {
+ this.setState({
+ new_item_name: event.target.value,
+ })
+ }
+
+ addItem = () => {
+ const { items, new_item_name } = this.state;
+ if(new_item_name.length > 2 && !items.includes(new_item_name)) {
+ this.setState({
+ items: [...items, new_item_name || `New item ${index++}`],
+ new_item_name: '',
+ })
+ }
+ }
+
+ render() {
+ const { items, new_item_name } = this.state;
+ const {current_lists, ...others} = this.props;
+
+ return (
+ (
+
+ )}
+ {...others}
+ >
+ {items.map(item => (
+ {item}
+ ))}
+
+ );
+ }
+}
+
+export default SelectWithAddItem;
diff --git a/src/components/SelectBox/SelectWithAjax.tsx b/src/components/SelectBox/SelectWithAjax.tsx
new file mode 100644
index 0000000..ed9d5a4
--- /dev/null
+++ b/src/components/SelectBox/SelectWithAjax.tsx
@@ -0,0 +1,79 @@
+import React from "react";
+import { Select } from 'antd';
+
+import {SelectProps} from "antd/lib/select";
+
+const { Option } = Select;
+
+
+type SelectWithAjaxProp = SelectProps & {
+ onFocusSuggestedData?: any[];
+ buildOption?: (d: any) => React.ReactNode;
+ searchFn: (query: string) => Promise
+};
+
+type SelectWithAjaxState = {
+ data: { value: string, text: string, [other:string]: any }[];
+ value?: any
+}
+
+
+class SelectWithAjax extends React.Component {
+
+ constructor(props: SelectWithAjaxProp) {
+ super(props);
+ this.state = {
+ data: [],
+ value: undefined,
+ }
+ }
+
+ handleSearch = (value: any) => {
+ if (value) {
+ this.props.searchFn(value).then(data => this.setState({ data }))
+ } else {
+ this.setState({ data: [] });
+ }
+ }
+
+ handleFocus= () => {
+ const {value} = this.state;
+ const {onFocusSuggestedData} = this.props;
+ if( ! value) {
+ this.setState({ data: onFocusSuggestedData || [] });
+ }
+ }
+
+ handleChange = (value: any) => {
+ this.setState({ value });
+ }
+
+ render() {
+
+ const {buildOption, onFocusSuggestedData, ...others} = this.props;
+
+ return (
+
+ {
+ buildOption ?
+ this.state.data.map(buildOption) :
+ this.state.data.map(d => {d.text} )
+ }
+
+ )
+ }
+}
+
+export default SelectWithAjax;
diff --git a/src/components/SelectBox/SelectWithList.tsx b/src/components/SelectBox/SelectWithList.tsx
new file mode 100644
index 0000000..4eed6d6
--- /dev/null
+++ b/src/components/SelectBox/SelectWithList.tsx
@@ -0,0 +1,32 @@
+import { Select } from 'antd';
+import {SelectProps} from "antd/lib/select";
+import {isFound} from "@/lib/vietnamese";
+
+const { Option } = Select;
+
+const SelectWithList = (props : {options: {value: string|number, text: string}[]} & SelectProps ) => {
+
+ const {options, ...others} = props;
+
+ return (
+
+ // option!.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
+ isFound(input, option!.children)
+ }
+ /*filterSort={(optionA, optionB) =>
+ optionA.children.toLowerCase().localeCompare(optionB.children.toLowerCase())
+ }*/
+ style={{width: 120}}
+ {...others}
+ >
+ {
+ options.map(op => {op.text} )
+ }
+
+ )
+}
+
+export default SelectWithList;
diff --git a/src/components/SelectBox/index.tsx b/src/components/SelectBox/index.tsx
new file mode 100644
index 0000000..0115a5c
--- /dev/null
+++ b/src/components/SelectBox/index.tsx
@@ -0,0 +1,3 @@
+export {default as SelectWithAjax} from './SelectWithAjax';
+export {default as SelectWithList} from './SelectWithList';
+export {default as SelectWithAddItem} from './SelectWithAddItem';
diff --git a/src/components/Tagging/index.tsx b/src/components/Tagging/index.tsx
new file mode 100644
index 0000000..af3e8f8
--- /dev/null
+++ b/src/components/Tagging/index.tsx
@@ -0,0 +1,219 @@
+import {Tag, Input, Tooltip, message} from 'antd';
+import { PlusOutlined } from '@ant-design/icons';
+import React, {createRef, Fragment, useState} from "react";
+
+type EditableTagGroupState = {
+ tags: string[],
+ editInputIndex: number,
+ editInputValue: string,
+ loadExistingList: boolean
+}
+
+type EditableTagGroupProps = {
+ current_list?: string[];
+ createTag?: (tag: string) => Promise;
+ deleteTag?: (tag: string) => Promise;
+}
+
+
+const AddNewTag = ({onEnter} : {onEnter: (txt: string) => void}) => {
+
+ const [openForm, setOpenForm] = useState(false);
+ const [value, setValue] = useState('');
+
+ const onConfirm = (e: any) => {
+ e.preventDefault();
+ const txt = value.trim();
+ const len = txt.length;
+ if(len > 3 && len < 50) {
+ onEnter(txt);
+ setValue('');
+ setOpenForm(false);
+ }else{
+ message.error("Tag cần có từ 3-40 ký tự")
+ }
+ }
+
+ if(openForm) {
+ return (
+ {
+ event.preventDefault();
+ setValue(event.target.value);
+ }}
+ onBlur={onConfirm}
+ onPressEnter={onConfirm}
+ />
+ )
+ }
+
+ return (
+ setOpenForm(true)}>
+ Thêm mới
+
+ )
+}
+
+
+class EditableTagGroup extends React.Component {
+
+ private editInputRef: React.RefObject ;
+
+ constructor(props: EditableTagGroupProps) {
+ super(props);
+ this.state = {
+ tags: [],
+ editInputIndex: -1,
+ editInputValue: '',
+ loadExistingList: false
+ }
+
+ this.editInputRef = createRef ();
+ }
+
+
+ componentDidUpdate(prevProps: Readonly, prevState: Readonly, snapshot?: any) {
+ // loading existing the fist time
+ if(this.props.current_list && this.state.tags.length === 0 && !this.state.loadExistingList) {
+ this.setState({
+ tags: this.props.current_list,
+ loadExistingList: true
+ })
+ }
+ }
+
+
+ handleClose = (removedTag: string) => {
+ const tags = this.state.tags.filter(tag => tag !== removedTag);
+ this.setState({ tags }, () => {
+ const {deleteTag} = this.props;
+ if(typeof deleteTag == 'function') deleteTag(removedTag);
+ });
+ }
+
+
+ handleInputConfirm = (inputValue: string) => {
+ let { tags } = this.state;
+ const {createTag} = this.props;
+
+ if (inputValue && tags.indexOf(inputValue) === -1) {
+
+ if(typeof createTag == 'function') {
+ createTag(inputValue).then(r => {
+ if(r) {
+ tags = [...tags, inputValue];
+ this.setState({
+ tags
+ } );
+ }
+ })
+
+ } else {
+ tags = [...tags, inputValue];
+ this.setState({
+ tags
+ } );
+ }
+ }
+ }
+
+ handleEditInputChange = (e: any) => {
+ this.setState({ editInputValue: e.target.value });
+ }
+
+ handleEditInputConfirm = () => {
+ this.setState(({ tags, editInputIndex, editInputValue }) => {
+ const newTags = [...tags];
+ newTags[editInputIndex] = editInputValue;
+
+ return {
+ tags: newTags,
+ editInputIndex: -1,
+ editInputValue: '',
+ };
+ })
+ }
+
+ // api to be used in other components to get tags
+ // const taggingRef = createRef();
+ // const tags = taggingRef.current?.getTagList();
+ getTagList = () => {
+ return this.state.tags;
+ }
+
+ clearList = () => {
+ this.setState({ tags: [] });
+ }
+
+ render() {
+
+ const { tags, editInputIndex, editInputValue } = this.state;
+
+ return (
+
+ {tags.map((tag, index) => {
+ if (editInputIndex === index) {
+ return (
+
+ );
+ }
+
+ const isLongTag = tag.length > 20;
+
+ const tagElem = (
+ this.handleClose(tag)}
+ >
+ {
+ if (index !== 0) {
+ this.setState({ editInputIndex: index, editInputValue: tag }, () => {
+ this.editInputRef.current!.focus();
+ });
+ e.preventDefault();
+ }
+ }}
+ >
+ {isLongTag ? `${tag.slice(0, 20)}...` : tag}
+
+
+ );
+
+ return isLongTag ? (
+
+ {tagElem}
+
+ ) : (
+ tagElem
+ );
+ })}
+
+
+
+
+ );
+ }
+}
+
+export default EditableTagGroup;
diff --git a/src/components/TextWithLineBreak/index.tsx b/src/components/TextWithLineBreak/index.tsx
new file mode 100644
index 0000000..6292af8
--- /dev/null
+++ b/src/components/TextWithLineBreak/index.tsx
@@ -0,0 +1,14 @@
+import React from "react";
+
+const TextWithLineBreak = ({txt}: {txt?: string}) => {
+
+ if( !txt ) {
+ return null;
+ }
+
+ return (
+
")}} />
+ )
+}
+
+export default TextWithLineBreak;
diff --git a/src/components/Toolbar/Toolbar.css b/src/components/Toolbar/Toolbar.css
new file mode 100644
index 0000000..80e0b87
--- /dev/null
+++ b/src/components/Toolbar/Toolbar.css
@@ -0,0 +1,48 @@
+.toolbar {
+ display: flex;
+ align-items: center;
+
+ background-color: white;
+ font-weight: 500;
+ border-bottom: 1px solid #eeeef1;
+
+ position: sticky;
+ top: 0px;
+}
+
+@supports (backdrop-filter: blur(20px)) {
+ .toolbar {
+ border: none;
+ background-color: rgba(255, 255, 255, 0.8);
+ backdrop-filter: blur(20px);
+ }
+}
+
+.toolbar-title {
+ margin: 0;
+ font-size: 16px;
+ font-weight: 800;
+}
+
+.left-items, .right-items {
+ flex: 1;
+ padding: 10px;
+ display: flex;
+}
+
+.right-items {
+ flex-direction: row-reverse;
+}
+
+.left-items .toolbar-button {
+ margin-right: 20px;
+}
+
+.right-items .toolbar-button {
+ margin-left: 20px;
+}
+
+.left-items .toolbar-button:last-child,
+.right-items .toolbar-button:last-child {
+ margin: 0;
+}
\ No newline at end of file
diff --git a/src/components/Toolbar/index.js b/src/components/Toolbar/index.js
new file mode 100644
index 0000000..00b5a10
--- /dev/null
+++ b/src/components/Toolbar/index.js
@@ -0,0 +1,13 @@
+import React from 'react';
+import './Toolbar.css';
+
+export default function Toolbar(props) {
+ const { title, leftItems, rightItems } = props;
+ return (
+
+
{ leftItems }
+
{ title }
+
{ rightItems }
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/ToolbarButton/ToolbarButton.css b/src/components/ToolbarButton/ToolbarButton.css
new file mode 100644
index 0000000..edeaa20
--- /dev/null
+++ b/src/components/ToolbarButton/ToolbarButton.css
@@ -0,0 +1,15 @@
+.toolbar-button {
+ color: #007aff;
+ font-size: 28px;
+ transition: all 0.1s;
+}
+
+.toolbar-button:hover {
+ cursor: pointer;
+ color: #0063ce;
+}
+
+.toolbar-button:active {
+ color: #007aff;
+ opacity: 0.25;
+}
\ No newline at end of file
diff --git a/src/components/ToolbarButton/index.js b/src/components/ToolbarButton/index.js
new file mode 100644
index 0000000..87b26ff
--- /dev/null
+++ b/src/components/ToolbarButton/index.js
@@ -0,0 +1,9 @@
+import React from 'react';
+import './ToolbarButton.css';
+
+export default function ToolbarButton(props) {
+ const { icon } = props;
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/Upload/ImageUploadWithPreview.tsx b/src/components/Upload/ImageUploadWithPreview.tsx
new file mode 100644
index 0000000..3ab2ab0
--- /dev/null
+++ b/src/components/Upload/ImageUploadWithPreview.tsx
@@ -0,0 +1,145 @@
+import {Upload, Modal, UploadProps} from 'antd';
+import { PlusOutlined } from '@ant-design/icons';
+import {Component, ReactNode, Fragment} from "react";
+import {SERVER_FILE_UPLOAD} from "@/config";
+import {UploadFile} from "antd/lib/upload/interface";
+
+
+function getBase64(file: Blob) {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.readAsDataURL(file);
+ reader.onload = () => resolve(reader.result);
+ reader.onerror = error => reject(error);
+ });
+}
+
+type ImageUploadType = {
+ uid: string,
+ name: string,
+ status: string,
+ url: string,
+}
+
+type ImageUploadWithPreviewProps = {
+ auth: string;
+ current_list?: ImageUploadType[];
+ uploadButton?: ReactNode;
+ onUploadFinish?: (status?: string, url?: string) => void;
+} & UploadProps;
+
+type ImageUploadWithPreviewState = {
+ previewVisible: boolean,
+ previewImage: string,
+ previewTitle: string,
+ fileList: ImageUploadType[]
+}
+
+const defaultUploadButton = (
+
+);
+
+
+// copy from https://ant.design/components/upload/ PicturesWall
+class ImageUploadWithPreview extends Component {
+
+ constructor(props: ImageUploadWithPreviewProps) {
+ super(props);
+ this.state = {
+ previewVisible: false,
+ previewImage: '',
+ previewTitle: '',
+ fileList: props.current_list || []
+ };
+ }
+
+
+ handleCancel = () => this.setState({ previewVisible: false });
+
+ handlePreview = async (file: any) => {
+ if (!file.url && !file.preview) {
+ file.preview = await getBase64(file.originFileObj);
+ }
+
+ this.setState({
+ previewImage: file.url || file.preview,
+ previewVisible: true,
+ previewTitle: file.name || file.url.substring(file.url.lastIndexOf('/') + 1),
+ });
+ }
+
+ handleChange = ({ fileList }: {fileList: any[]}) => {
+ //console.log('handleChange')
+ //console.log(fileList);
+
+ const { onUploadFinish } = this.props;
+
+ this.setState({ fileList }, () => {
+ if(onUploadFinish) {
+ fileList.forEach((file: UploadFile) => {
+ onUploadFinish(file.status , file.name);
+ // if(file.status === 'success' || file.status === 'error') onUploadFinish(file.status , file.name);
+ })
+ }
+ });
+
+ /*const { onUploadFinish } = this.props;
+ if(onUploadFinish) {
+ fileList.forEach((file: UploadFile) => {
+ if(file.status === 'success' || file.status === 'error') onUploadFinish(file.status , file.name);
+ })
+ }*/
+ }
+
+ getFileList = () => {
+ return this.state.fileList;
+ }
+
+ clearFileList = () => {
+ this.setState({ fileList: [] })
+ }
+
+ render() {
+
+ const { previewVisible, previewImage, fileList, previewTitle } = this.state;
+ const {auth, current_list, uploadButton, onUploadFinish, ...others} = this.props;
+ const MAX_ALLOW_UPLOADED_FILE = 8;
+
+ return (
+
+
+ { fileList.length >= MAX_ALLOW_UPLOADED_FILE ? null : (uploadButton || defaultUploadButton)}
+
+
+
+
+
+
+ );
+ }
+}
+
+export default ImageUploadWithPreview;
diff --git a/src/components/Upload/UploadWithFileName.tsx b/src/components/Upload/UploadWithFileName.tsx
new file mode 100644
index 0000000..63275b3
--- /dev/null
+++ b/src/components/Upload/UploadWithFileName.tsx
@@ -0,0 +1,53 @@
+import React from "react";
+import { Upload, Button } from 'antd';
+import { UploadOutlined, StarOutlined } from '@ant-design/icons';
+
+
+const UploadWithFileName = () => {
+
+ const props = {
+ action: 'https://www.mocky.io/v2/5cc8019d300000980a055e76',
+ onChange({ file, fileList }: { file: any, fileList: any }) {
+ if (file.status !== 'uploading') {
+ console.log(file, fileList);
+ }
+ },
+ defaultFileList: [
+ {
+ uid: '1',
+ name: 'xxx.png',
+ status: 'done',
+ response: 'Server Error 500', // custom error message to show
+ url: 'http://www.baidu.com/xxx.png',
+ },
+ {
+ uid: '2',
+ name: 'yyy.png',
+ status: 'done',
+ url: 'http://www.baidu.com/yyy.png',
+ },
+ {
+ uid: '3',
+ name: 'zzz.png',
+ status: 'error',
+ response: 'Server Error 500', // custom error message to show
+ url: 'http://www.baidu.com/zzz.png',
+ },
+ ],
+ showUploadList: {
+ showDownloadIcon: true,
+ downloadIcon: 'download ',
+ showRemoveIcon: true,
+ removeIcon: console.log(e, 'custom removeIcon event')} />,
+ },
+ };
+
+ return (
+ // @ts-ignore
+
+ }>Upload
+
+ )
+}
+
+export default UploadWithFileName;
diff --git a/src/components/Upload/index.tsx b/src/components/Upload/index.tsx
new file mode 100644
index 0000000..d4caa25
--- /dev/null
+++ b/src/components/Upload/index.tsx
@@ -0,0 +1,2 @@
+export {default as ImageUploadWithPreview} from "./ImageUploadWithPreview";
+export {default as UploadWithFileName} from "./UploadWithFileName";
diff --git a/src/components/display/OrderStatus.tsx b/src/components/display/OrderStatus.tsx
new file mode 100644
index 0000000..7ac1bca
--- /dev/null
+++ b/src/components/display/OrderStatus.tsx
@@ -0,0 +1,22 @@
+import {Tag} from "antd";
+import React from "react";
+import {OrderStatusType} from "@/typings";
+
+
+const OrderStatus = ({status}: { status?: OrderStatusType }) => {
+
+ const colorMapping = {
+ pending: 'warning',
+ fail: 'error',
+ success: 'success',
+ processing: 'processing'
+ }
+
+ if(!status) return null;
+
+ return (
+ {status}
+ )
+}
+
+export default OrderStatus;
diff --git a/src/components/display/PaymentStatus.tsx b/src/components/display/PaymentStatus.tsx
new file mode 100644
index 0000000..716f47f
--- /dev/null
+++ b/src/components/display/PaymentStatus.tsx
@@ -0,0 +1,22 @@
+import {Tag} from "antd";
+import React from "react";
+import {PaymentStatusType} from "@/typings";
+
+
+const PaymentStatus = ({status}: { status?: PaymentStatusType }) => {
+
+ const colorMapping = {
+ pending: 'warning',
+ fail: 'error',
+ success: 'success',
+ processing: 'processing'
+ }
+
+ if(!status) return null;
+
+ return (
+ {status}
+ )
+}
+
+export default PaymentStatus;
diff --git a/src/components/display/ShippingStatus.tsx b/src/components/display/ShippingStatus.tsx
new file mode 100644
index 0000000..e6470ef
--- /dev/null
+++ b/src/components/display/ShippingStatus.tsx
@@ -0,0 +1,22 @@
+import {Tag} from "antd";
+import React from "react";
+import {ShippingStatusType} from "@/typings";
+
+
+const ShippingStatus = ({status}: { status?: ShippingStatusType }) => {
+
+ const colorMapping = {
+ pending: 'warning',
+ fail: 'error',
+ success: 'success',
+ processing: 'processing'
+ }
+
+ if(!status) return null;
+
+ return (
+ {status}
+ )
+}
+
+export default ShippingStatus;
diff --git a/src/config.ts b/src/config.ts
new file mode 100644
index 0000000..b6b8fd7
--- /dev/null
+++ b/src/config.ts
@@ -0,0 +1,22 @@
+// 19-05-2021: For dev
+
+export const IS_DEV = false;
+
+export const REDIRECTOR_URL = 'https://www.chatngay.com/redirect.php';
+
+export const SERVER_API = '/api/';
+
+export const SERVER_FILE_UPLOAD = 'https://chatngay-upload.glee.vn/upload_handle.php';
+export const SERVER_STATIC = 'https://chatngay-static.glee.vn/files';
+export const DEFAULT_AVATAR = 'https://randomuser.me/api/portraits/men/40.jpg';
+export const CHATNGAY_LOGO = 'https://www.chatngay.com/static/images/logo.png';
+export const MAX_ALLOW_IDLE_TIME = 5; // seconds (from last send action) to wait before close the ws connection
+
+export const getConnectNode = (node_id: string) => {
+ if(node_id === '') return '';
+ return `https://${node_id}.chatngay.com:8080/chat`
+};
+
+export const SOCKET_OPTIONS = {
+
+}
diff --git a/src/constant/gender.ts b/src/constant/gender.ts
new file mode 100644
index 0000000..595b855
--- /dev/null
+++ b/src/constant/gender.ts
@@ -0,0 +1,7 @@
+const GENDER_LIST = [
+ {"value":"u","text":"Chưa biết"},
+ {"value":"f","text":"Nữ"},
+ {"value":"m","text":"Nam"}
+];
+
+export default GENDER_LIST;
diff --git a/src/constant/payment.ts b/src/constant/payment.ts
new file mode 100644
index 0000000..dc16cee
--- /dev/null
+++ b/src/constant/payment.ts
@@ -0,0 +1,9 @@
+const PAYMENT_METHODS = [
+ {"value":"cod", "text": "COD - Thanh toán khi nhận hàng"},
+ {"value":"wire", "text": "Chuyển khoản"},
+ {"value":"paygate", "text": "Cổng thanh toán (Vnpay, Momo ...)"},
+ {"value":"credit", "text": "Thẻ tín dụng (Visa, MasterCard ...)"},
+ {"value":"cash", "text": "Tiền mặt"}
+];
+
+export default PAYMENT_METHODS;
diff --git a/src/constant/province_list.ts b/src/constant/province_list.ts
new file mode 100644
index 0000000..41c511a
--- /dev/null
+++ b/src/constant/province_list.ts
@@ -0,0 +1,71 @@
+const PROVINCE_LIST = [
+ // top provinces
+ {"value":"ha-noi","text":"Hà Nội"},
+ {"value":"tp-hcm","text":"TP HCM"},
+ {"value":"da-nang","text":"Đà Nẵng"},
+ {"value":"hai-phong","text":"Hải Phòng"},
+ {"value":"can-tho","text":"Cần Thơ"},
+
+ // others
+ {"value":"an-giang","text":"An Giang"},
+ {"value":"ba-ria-vung-tau","text":"Bà Rịa - Vũng Tàu"},
+ {"value":"bac-giang","text":"Bắc Giang"},
+ {"value":"bac-kan","text":"Bắc Kạn"},
+ {"value":"bac-lieu","text":"Bạc Liêu"},
+ {"value":"bac-ninh","text":"Bắc Ninh"},
+ {"value":"ben-tre","text":"Bến Tre"},
+ {"value":"binh-dinh","text":"Bình Định"},
+ {"value":"binh-duong","text":"Bình Dương"},
+ {"value":"binh-phuoc","text":"Bình Phước"},
+ {"value":"binh-thuan","text":"Bình Thuận"},
+ {"value":"ca-mau","text":"Cà Mau"},
+ {"value":"cao-bang","text":"Cao Bằng"},
+ {"value":"dak-lak","text":"Đắk Lắk"},
+ {"value":"dak-nong","text":"Đắk Nông"},
+ {"value":"dien-bien","text":"Điện Biên"},
+ {"value":"dong-nai","text":"Đồng Nai"},
+ {"value":"dong-thap","text":"Đồng Tháp"},
+ {"value":"gia-lai","text":"Gia Lai"},
+ {"value":"ha-giang","text":"Hà Giang"},
+ {"value":"ha-nam","text":"Hà Nam"},
+ {"value":"ha-tinh","text":"Hà Tĩnh"},
+ {"value":"hai-duong","text":"Hải Dương"},
+ {"value":"hau-giang","text":"Hậu Giang"},
+ {"value":"hoa-binh","text":"Hòa Bình"},
+ {"value":"hung-yen","text":"Hưng Yên"},
+ {"value":"khanh-hoa","text":"Khánh Hòa"},
+ {"value":"kien-giang","text":"Kiên Giang"},
+ {"value":"kon-tum","text":"Kon Tum"},
+ {"value":"lai-chau","text":"Lai Châu"},
+ {"value":"lam-dong","text":"Lâm Đồng"},
+ {"value":"lang-son","text":"Lạng Sơn"},
+ {"value":"lao-cai","text":"Lào Cai"},
+ {"value":"long-an","text":"Long An"},
+ {"value":"nam-dinh","text":"Nam Định"},
+ {"value":"nghe-an","text":"Nghệ An"},
+ {"value":"ninh-binh","text":"Ninh Bình"},
+ {"value":"ninh-thuan","text":"Ninh Thuận"},
+ {"value":"phu-tho","text":"Phú Thọ"},
+ {"value":"quang-binh","text":"Quảng Bình"},
+ {"value":"quang-nam","text":"Quảng Nam"},
+ {"value":"quang-ngai","text":"Quảng Ngãi"},
+ {"value":"quang-ninh","text":"Quảng Ninh"},
+ {"value":"quang-tri","text":"Quảng Trị"},
+ {"value":"soc-trang","text":"Sóc Trăng"},
+ {"value":"son-la","text":"Sơn La"},
+ {"value":"tay-ninh","text":"Tây Ninh"},
+ {"value":"thai-binh","text":"Thái Bình"},
+ {"value":"thai-nguyen","text":"Thái Nguyên"},
+ {"value":"thanh-hoa","text":"Thanh Hóa"},
+ {"value":"thua-thien-hue","text":"Thừa Thiên Huế"},
+ {"value":"tien-giang","text":"Tiền Giang"},
+ {"value":"tra-vinh","text":"Trà Vinh"},
+ {"value":"tuyen-quang","text":"Tuyên Quang"},
+ {"value":"vinh-long","text":"Vĩnh Long"},
+ {"value":"vinh-phuc","text":"Vĩnh Phúc"},
+ {"value":"yen-bai","text":"Yên Bái"},
+ {"value":"phu-yen","text":"Phú Yên"}
+
+];
+
+export default PROVINCE_LIST;
diff --git a/src/constant/shipping.ts b/src/constant/shipping.ts
new file mode 100644
index 0000000..168069b
--- /dev/null
+++ b/src/constant/shipping.ts
@@ -0,0 +1,12 @@
+export const SHIP_PROVIDERS = [
+ {"value":"in-house", "text": "Phòng giao hàng của công ty"},
+ {"value":"pick-up", "text": "Khách đến lấy"},
+ {"value":"ghtk", "text": "Giao hàng tiết kiệm"},
+ {"value":"ghn", "text": "Giao hàng nhanh"},
+ {"value":"viettel", "text": "Viettel Post"},
+ {"value":"vietnampost", "text": "Vietnam Post"},
+ {"value":"jtexpress", "text": "J&T Express"},
+ {"value":"grab", "text": "Grab"},
+ {"value":"ninja", "text": "Ninja Van"},
+ {"value":"bestexpress", "text": "Best Express"},
+];
diff --git a/src/constant/text.ts b/src/constant/text.ts
new file mode 100644
index 0000000..7f6954b
--- /dev/null
+++ b/src/constant/text.ts
@@ -0,0 +1,11 @@
+export const NOTIFICATIONS = {
+ requesting_start: 'Đang chờ quản trị viên nhận chat',
+ requesting_success: '{{admin_name}} đã tiếp nhận chat',
+ requesting_fail: 'Quản trị viên đang bận',
+ typing: 'Đang soạn nội dung ...',
+ transferring: 'Đang chuyển người khác tiếp nhận chat',
+ user_offline: 'Khách hàng đã ngừng kết nối (offline)',
+ uploading: 'Đang upload file',
+ network_offline: 'Lỗi đường truyền. Vui lòng kiểm tra kết nối Internet của bạn',
+ network_error: 'Lỗi kết nối với máy chủ.',
+}
\ No newline at end of file
diff --git a/src/index.css b/src/index.css
new file mode 100644
index 0000000..3d87554
--- /dev/null
+++ b/src/index.css
@@ -0,0 +1,12 @@
+body {
+ margin: 0;
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
+ 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
+ sans-serif;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+code {
+ font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
+}
diff --git a/src/index.tsx b/src/index.tsx
new file mode 100644
index 0000000..e2ee1a1
--- /dev/null
+++ b/src/index.tsx
@@ -0,0 +1,52 @@
+import React from 'react';
+import ReactDOM from 'react-dom';
+import { Provider} from 'react-redux';
+import App from './App';
+import reportWebVitals from './reportWebVitals';
+import store from "./store";
+import {runTest} from "./test";
+
+// local
+import {getSettings, setUp} from "@/setup";
+import Error from '@/components/Error';
+import {isBrowserSupport} from "@/lib/utils";
+
+// style
+//import './index.css';
+
+// export React when we need to use external component from window
+// window.React = React;
+
+
+(async () => {
+ const render_root = document.getElementById('root');
+
+ if ( ! isBrowserSupport() ) {
+ ReactDOM.render( , render_root);
+ return;
+ }
+
+ const client_setting = await getSettings();
+ if (! client_setting ) {
+ ReactDOM.render( , render_root);
+ return;
+ }
+
+ await setUp();
+
+ const Root = () => (
+
+
+
+ )
+
+ ReactDOM.render( , render_root);
+
+ runTest();
+})();
+
+
+// If you want to start measuring performance in your app, pass a function
+// to log results (for example: reportWebVitals(console.log))
+// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
+reportWebVitals();
diff --git a/src/lib/api.ts b/src/lib/api.ts
new file mode 100644
index 0000000..56347ee
--- /dev/null
+++ b/src/lib/api.ts
@@ -0,0 +1,146 @@
+import axios, {AxiosResponse} from "axios";
+
+import {SERVER_API} from "@/config";
+import {ChatboxTextMessage} from "@/typings/message.d";
+import {getAdminInfo} from "@/lib/user";
+import {APIResponse} from "@/typings/network";
+import {message} from "antd";
+
+type APIResultType = {status: 'ok'|'error', data?: any, msg?: string};
+
+const admin_info = getAdminInfo();
+
+// reference: https://www.npmjs.com/package/axios#axios-api
+const axios_instance = axios.create({
+ baseURL: SERVER_API,
+ timeout: 10000,
+ headers: {
+ Authorization: admin_info.jwt, // admin_info.jwt contains client_id & admin_id
+ }
+})
+
+
+function formatAxiosResponse(res: AxiosResponse): APIResultType {
+ if(res.status !== 200){
+ return {
+ status: 'error',
+ msg: 'Server return status: '+ res.status,
+ }
+ }
+
+ let api_response: APIResponse = res.data;
+
+ if(api_response.errCode === 1) {
+ return {
+ status: 'error',
+ msg: api_response.msg,
+ }
+ }
+
+ return {
+ status: 'ok',
+ data: api_response.data,
+ }
+}
+
+
+export function notifyApiResult (result: APIResultType, successMsg?: string) : void{
+ if(result.status === 'ok') {
+ message.success(successMsg || 'Cập nhật thành công', 2)
+ }else {
+ message.error('Lỗi xảy ra: ' + result.msg, 20)
+ }
+}
+
+
+const get = async (endpoint: string, params?: object): Promise => {
+ try {
+ let res: AxiosResponse = await axios_instance.get(endpoint, {
+ params
+ });
+
+ return formatAxiosResponse(res);
+ }catch (e) {
+ return {
+ status: 'error',
+ msg: e.message,
+ }
+ }
+}
+
+const post = async (endpoint: string, data?: object): Promise => {
+ try {
+ let res: AxiosResponse = await axios_instance.post(endpoint, data);
+
+ return formatAxiosResponse(res);
+ }catch (e) {
+ return {
+ status: 'error',
+ msg: e.message,
+ }
+ }
+}
+
+const put = async (endpoint: string, data: object, params?: object): Promise => {
+ try {
+ let res: AxiosResponse = await axios_instance.put(endpoint, data,{
+ params
+ });
+
+ return formatAxiosResponse(res);
+ }catch (e) {
+ return {
+ status: 'error',
+ msg: e.message,
+ }
+ }
+}
+
+const patch = async (endpoint: string, data: any, params?: object): Promise => {
+ try {
+ let res: AxiosResponse = await axios_instance.patch(endpoint, data, {
+ params: params
+ });
+
+ return formatAxiosResponse(res);
+ }catch (e) {
+ return {
+ status: 'error',
+ msg: e.message,
+ }
+ }
+}
+
+const del = async (endpoint: string, params?: object): Promise => {
+ try {
+ let res: AxiosResponse = await axios_instance.delete(endpoint, {
+ params
+ });
+
+ return formatAxiosResponse(res);
+ }catch (e) {
+ return {
+ status: 'error',
+ msg: e.message,
+ }
+ }
+}
+
+const api = {
+ get, post, patch, put, delete: del
+}
+
+export default api;
+
+
+export async function createSingleTag(payload: {tag: string, item_type?: string, item_id?: string|number}) : Promise {
+ let result = await api.post('tag/create', payload);
+ return result.status === 'ok';
+}
+
+
+export async function getUserChatHistory(opts: {thread_id: string, last_fetch?: number}): Promise{
+ let result = await get("chat/history", {tid: opts.thread_id, from: opts.last_fetch});
+ // because old messages order from newest->oldest. we need them in reverse order: oldest->newsest
+ return (result.status === 'ok') ? result.data.list.reverse() : [];
+}
diff --git a/src/lib/chatngay.ts b/src/lib/chatngay.ts
new file mode 100644
index 0000000..7f7dd68
--- /dev/null
+++ b/src/lib/chatngay.ts
@@ -0,0 +1,30 @@
+import {
+ MAX_ALLOW_IDLE_TIME,
+} from '@/config';
+import {currentTimestamp} from "@/lib/utils";
+import {getUserLastActiveTime} from "@/lib/user";
+import * as networking from "@/lib/networking";
+
+
+async function _heartbeat() {
+ // console.log("_heartbeat: yup yup ..." + Date.now());
+}
+
+
+// auto-disconnect ws connection to preserve servers' resource and convert to heartbeat
+export async function selfDisconnect() {
+ let last_active_time = getUserLastActiveTime();
+ let current_time = currentTimestamp();
+
+ if( current_time - last_active_time > MAX_ALLOW_IDLE_TIME ) {
+ networking.disconnect();
+ await _heartbeat();
+ }
+}
+
+
+export const chatngay = {
+ disconnect: networking.disconnect
+}
+
+
diff --git a/src/lib/emitter.ts b/src/lib/emitter.ts
new file mode 100644
index 0000000..bf4b5d5
--- /dev/null
+++ b/src/lib/emitter.ts
@@ -0,0 +1,18 @@
+// copied straight from https://stackoverflow.com/questions/62827419/event-driven-approach-in-react
+// reason: sometimes using Redux approach requires a number of code/boilplate
+
+import EventEmitter from 'eventemitter3';
+import {EventType} from "@/typings";
+
+const eventEmitter = new EventEmitter();
+
+const Emitter = {
+ on: (event: EventType, fn: (...args: any[]) => void) => eventEmitter.on(event, fn),
+ once: (event: EventType, fn: (...args: any[]) => void) => eventEmitter.once(event, fn),
+ off: (event: EventType, fn?: (...args: any[]) => void) => eventEmitter.off(event, fn),
+ emit: (event: EventType, payload?: (...args: any[]) => void) => eventEmitter.emit(event, payload)
+}
+
+Object.freeze(Emitter);
+
+export default Emitter;
diff --git a/src/lib/messaging.ts b/src/lib/messaging.ts
new file mode 100644
index 0000000..245f624
--- /dev/null
+++ b/src/lib/messaging.ts
@@ -0,0 +1,38 @@
+// 19-05-2021: For dev
+
+import {ServerMessage, UserMessage} from "@/typings/message";
+
+
+export const sendTextMessageToServer = (to: string, text: string, local_sequence: number = 0) => {
+ let payload = {
+ type: 'text',
+ content: {
+ to,
+ text,
+ local_sequence
+ }
+ } as UserMessage;
+
+ return sendMessageToServer(payload);
+}
+
+
+export const sendMessageToServer = (payload: UserMessage) => {
+ console.log('sendMessageToServer payload');
+ console.log(payload);
+ // TODO:
+}
+
+export function handleMessageFromServer(server_message: ServerMessage) {
+
+ console.log('handleMessageFromServer');
+ console.log(server_message);
+
+ // TODO:
+
+}
+
+function _checkUserInChatOrRequest(user_id: string|number) : boolean {
+ // TODO:
+ return true;
+}
diff --git a/src/lib/networking.ts b/src/lib/networking.ts
new file mode 100644
index 0000000..7d10516
--- /dev/null
+++ b/src/lib/networking.ts
@@ -0,0 +1,49 @@
+// 19-05-2021: For dev
+
+import {ServerMessage} from "@/typings/message.d";
+import {io, Socket} from "socket.io-client";
+
+import {ConnectionToServerStatusType} from "@/typings/network";
+
+
+// single & private socket connection
+let _connected_socket: Socket | null;
+
+
+export function disconnect(destroy: boolean = false) {
+ if( isConnected() && _connected_socket) {
+ _connected_socket.close(); // close but can re-connect
+ if(destroy) _connected_socket = null; // if destroy, cannot re-connect
+ }else{
+ console.info("Socket is not connected to be disconnected!");
+ }
+}
+
+
+export const getSocket = (): Socket => {
+ return _connected_socket as Socket;
+}
+
+
+export const openSocketConnection = () : boolean => {
+ // TODO:
+ return true;
+}
+
+
+export const isConnected = (): boolean => {
+ // TODO:
+ return true;
+}
+
+
+// only create a ready socket, not connection to the remote server yet. Use openSocketConnection() to connect when time come
+export function createSocket (
+ endpoint_url: string,
+ opts: {jwt_token: string, [key:string]: any},
+ handleServerMessage: (msg: ServerMessage) => void ,
+ handleNodeConnectionStatus: (status: ConnectionToServerStatusType, message: string) => void
+) {
+ // TODO:
+ handleNodeConnectionStatus('connect', "Connection succeeded!");
+}
diff --git a/src/lib/notification.ts b/src/lib/notification.ts
new file mode 100644
index 0000000..efcbb9a
--- /dev/null
+++ b/src/lib/notification.ts
@@ -0,0 +1,127 @@
+import {SERVER_STATIC, CHATNGAY_LOGO} from "@/config";
+import {isMobile} from "@/lib/utils";
+
+const isDeviceMobile = isMobile();
+const MAX_SCROLL_TIME = 20;
+const SCROLLING_TITLE = "Bạn có tin nhắn mới ";
+
+let _settings = {
+ //sound alert
+ sound_enable : true,//default for all app
+ sound_file : SERVER_STATIC + "/ring_once.ogg",
+
+ //scrolling
+ scroll_page_title_alert : "Bạn có tin nhắn mới ",
+ old_web_page_title : '',
+ is_scrolling : false,
+}
+
+type SettingKeyType = keyof typeof _settings;
+
+
+//NOTE: The Notification permission may only be requested in a secure context.
+export async function askForBrowserNotificationPermit() {
+
+ // Let's check if the browser supports notifications
+ if (!("Notification" in window)) {
+ console.log("This browser does not support desktop notification");
+ return;
+ }
+
+ // Otherwise, we need to ask the user for permission
+ if (Notification.permission !== "denied" && Notification.permission !== "granted") {
+ try {
+ let result = await Notification.requestPermission();
+ console.log("requestPermission =" + result);
+
+ } catch (error) {
+ // Safari doesn't return a promise for requestPermissions and it
+ // throws a TypeError. It takes a callback as the first argument
+ if (error instanceof TypeError) {
+ console.log("requestPermission =" + error);
+ } else {
+ throw error;
+ }
+ }
+ }
+
+}
+
+
+export function showBrowserNotification(title: string, content?: string) {
+ if (Notification.permission === "granted") {
+ let expire_in_second = (arguments[2]) ? parseInt(arguments[2]) : 10;
+ let options = {
+ body: content,
+ icon: CHATNGAY_LOGO
+ };
+ let n = new Notification(title, options);
+ console.log("Notify: " + content);
+ // auto close after x seconds
+ setTimeout(n.close.bind(n), expire_in_second * 1000);
+ }
+}
+
+
+export const changeSettings = (new_settings: {[key in SettingKeyType]: any}) => {
+ _settings = {..._settings, ...new_settings};
+}
+
+
+export const alertNewMessages = () => {
+ playSound();
+ showBrowserNotification("Bạn có tin nhắn mới");
+ scrollPageTitleStart();
+}
+
+
+/**
+ * @description: play sound if enable, this code supports html5 only
+ * Play method will be blocked by browser: https://stackoverflow.com/questions/57504122/browser-denying-javascript-play
+ */
+function playSound() {
+
+ if(!_settings.sound_enable) {
+ return;
+ }
+
+ if (typeof Audio == 'function') {
+ let audio = new Audio(_settings.sound_file);
+ audio.play();
+ }
+}
+
+
+/**
+ * @author: Hieu
+ * @description: alert new message by scrolling page title
+ */
+function scrollPageTitleStart() {
+
+ //stop scroll if on mobile
+ //or user is on the page but the scrolling has not started. If has started, it should continue
+ if(isDeviceMobile || _settings.is_scrolling ) {
+ return ;
+ }
+
+ _settings.is_scrolling = true;
+ _settings.old_web_page_title = document.title;
+
+ let scroll_timeout_id;
+ let track_scroll_count: number = 0;
+
+ (function titleScroller(text) {
+ document.title = text;
+ track_scroll_count += 1;
+ if(track_scroll_count > MAX_SCROLL_TIME) {
+ clearTimeout(scroll_timeout_id);
+ _settings.is_scrolling = false;
+ document.title = _settings.old_web_page_title;
+ return;
+ }
+
+ scroll_timeout_id = setTimeout(function () {
+ titleScroller(text.substr(1) + text.substr(0, 1));
+ }, 500);
+ }(SCROLLING_TITLE));
+}
diff --git a/src/lib/personalize.ts b/src/lib/personalize.ts
new file mode 100644
index 0000000..de94035
--- /dev/null
+++ b/src/lib/personalize.ts
@@ -0,0 +1,79 @@
+let _settings = {
+ //chat notification option
+ notify : {
+ sound_enable : true //default
+ },
+};
+
+
+export function getSettings() {
+ return _settings;
+}
+
+
+/**
+ * @date: 22-02-2016
+ * @author: Hieu
+ * @description: set sound on or off
+ */
+export function setSound () {
+
+ let $sound_on_off: string = '';
+
+ if(_settings.notify.sound_enable) {
+ //turn off
+ _settings.notify.sound_enable = false;
+ //saveLocalData('chatngay_sound', 'off');
+ $sound_on_off = 'Bật âm thanh';
+
+ }else{
+ //turn on
+ _settings.notify.sound_enable = true;
+ //deleteLocalData('chatngay_sound');
+ $sound_on_off = 'Tắt âm thanh';
+ }
+
+ return $sound_on_off;
+
+ // getIframeElement("chatngay-sound-txt").innerHTML = $sound_on_off;
+
+}
+
+/**
+ * @date: 22-02-2016
+ * @author: Hieu
+ * @description: build user_info when page loaded
+ */
+export function getUserInfo () {
+
+ /*//get saved name if provided previously
+ user_info.id = getLocalData('chatngay_uid');
+ user_info.name = getLocalData('chatngay_uname');
+ user_info.token = getLocalData('chatngay_utoken');
+
+ //check if user has disabled sound before and update when page reloads
+ if(getLocalData('chatngay_sound') == 'off') {
+ _settings.notify.sound_enable = false;
+ }*/
+}
+
+/**
+ * @date: 28-02-2016
+ * @author: Hieu
+ * @description: build user_info when page loaded
+ */
+export function saveUserInfo (server_response: any) {
+
+ /*user_info.id = server_response.id;
+ saveLocalData('chatngay_uid', server_response.id, 300);
+
+ if(server_response.token != '') {
+ user_info.token = server_response.token;
+ saveLocalData('chatngay_utoken', server_response.token, 300);
+ }
+
+ if(server_response.name != '') {
+ user_info.name = server_response.name;
+ saveLocalData('chatngay_uname', server_response.name, 300);
+ }*/
+}
diff --git a/src/lib/public_ip.ts b/src/lib/public_ip.ts
new file mode 100644
index 0000000..7e0ae4f
--- /dev/null
+++ b/src/lib/public_ip.ts
@@ -0,0 +1,96 @@
+// https://github.com/sindresorhus/public-ip/blob/master/browser.js
+
+class CancelError extends Error {
+ constructor() {
+ super('Request was cancelled');
+ this.name = 'CancelError';
+ }
+
+ get isCanceled() {
+ return true;
+ }
+}
+
+const defaults = {
+ timeout: 5000
+};
+
+const urls = {
+ v4: [
+ 'https://ipv4.icanhazip.com/',
+ 'https://api.ipify.org/'
+ ],
+ v6: [
+ 'https://ipv6.icanhazip.com/',
+ 'https://api6.ipify.org/'
+ ]
+};
+
+const sendXhr = (url: string, options: { timeout: number; }, version: string | number) => {
+ const xhr = new XMLHttpRequest();
+
+ let _reject: { (arg0: CancelError): void; (reason?: any): void; };
+ const promise = new Promise((resolve, reject) => {
+ _reject = reject;
+ xhr.addEventListener('error', reject, {once: true});
+ xhr.addEventListener('timeout', reject, {once: true});
+
+ xhr.addEventListener('load', () => {
+ const ip = xhr.responseText.trim();
+
+ if (!ip) {
+ reject();
+ return;
+ }
+
+ resolve(ip);
+ }, {once: true});
+
+ xhr.open('GET', url);
+ xhr.timeout = options.timeout;
+ xhr.send();
+ });
+
+ // @ts-ignore
+ promise.cancel = () => {
+ xhr.abort();
+ _reject(new CancelError());
+ };
+
+ return promise;
+};
+
+const queryHttps = (version: string, options: any) => {
+ let request: any;
+ const promise = (async function () {
+ // @ts-ignore
+ const urls_ = [].concat.apply(urls[version], options.fallbackUrls || []);
+ for (const url of urls_) {
+ try {
+ request = sendXhr(url, options, version);
+ // eslint-disable-next-line no-await-in-loop
+ return await request;
+ } catch (error) {
+ if (error instanceof CancelError) {
+ throw error;
+ }
+ }
+ }
+
+ throw new Error('Couldn\'t find your IP');
+ })();
+
+ // @ts-ignore
+ promise.cancel = () => {
+ request.cancel();
+ };
+
+ return promise;
+};
+
+const public_ip = {
+ v4: (options: any) => queryHttps('v4', {...defaults, ...options}),
+ v6: (options: any) => queryHttps('v6', {...defaults, ...options}),
+};
+
+export default public_ip;
diff --git a/src/lib/registry.ts b/src/lib/registry.ts
new file mode 100644
index 0000000..f3997b5
--- /dev/null
+++ b/src/lib/registry.ts
@@ -0,0 +1,31 @@
+// simple global objects
+let _registry: {[k: string]: any} = {};
+
+function update(key: string, value: any) {
+ _registry[key] = value;
+}
+
+function get(key: string): any {
+ return (_registry.hasOwnProperty(key)) ? _registry[key] : undefined;
+}
+
+function remove(key: string) {
+ if(!_registry.hasOwnProperty(key)) return ;
+
+ delete _registry[key];
+}
+
+function clear() {
+ for (let member in _registry) {
+ delete _registry[member];
+ }
+}
+
+const registry = {
+ update,
+ get,
+ remove,
+ clear
+}
+
+export default registry;
\ No newline at end of file
diff --git a/src/lib/schedule.ts b/src/lib/schedule.ts
new file mode 100644
index 0000000..97d0e40
--- /dev/null
+++ b/src/lib/schedule.ts
@@ -0,0 +1,57 @@
+class SimpleSchedule {
+
+ private task_is_running: boolean;
+ private taskCaller: () => Promise;
+ private trackInterval: NodeJS.Timeout | null;
+ private checkInterval: number; //default
+
+ constructor(taskFn: () => Promise, checkInterval: number = 5) {
+ this.taskCaller = taskFn;
+ this.task_is_running = false;
+ this.trackInterval = null;
+ this.checkInterval = checkInterval;
+ }
+
+ start = () => {
+ console.log(`Schedule ${this.taskCaller.name} started at ${this.timeNow()}`);
+
+ this.trackInterval = setInterval(async () => {
+ // flag for long-running task so another instance wont start
+ if(this.task_is_running) {
+ console.log(`Task ${this.taskCaller.name} is still running. Check time ${this.timeNow()}`);
+ return;
+ }
+
+ this.task_is_running = true;
+ console.log(`OK invoke ${this.taskCaller.name} at ${this.timeNow()}`);
+ await this.taskCaller();
+ this.task_is_running = false;
+ }, this.checkInterval * 1000 );
+ }
+
+ getInterval = (): number => this.checkInterval;
+
+ changeInterval = (new_interval: number) => {
+ if(new_interval === this.checkInterval) {
+ return; // nothing change!
+ }
+
+ // remove running
+ if(this.trackInterval) clearInterval(this.trackInterval);
+
+ // set and start
+ this.checkInterval = new_interval;
+ this.start();
+ }
+
+ stop = () => {
+ console.log(`Schedule ${this.taskCaller.name} stoped at ${this.timeNow()}`);
+ if(this.trackInterval) clearInterval(this.trackInterval);
+ }
+
+ timeNow = () => {
+ return Math.floor(Date.now() / 1000);
+ }
+}
+
+export default SimpleSchedule;
diff --git a/src/lib/security.ts b/src/lib/security.ts
new file mode 100644
index 0000000..3405de5
--- /dev/null
+++ b/src/lib/security.ts
@@ -0,0 +1,72 @@
+
+export function createChecksum(content: string|object): number {
+ let content_str = typeof content == 'string' ? content : JSON.stringify(content);
+ return crc32(content_str);
+}
+
+
+// copy from: https://github.com/wbond/crc32-js-php
+// javascript: crc32(txt)
+// php backend: sprintf('%u', crc32(txt))
+function crc32(txt: string) : number {
+ let table = [
+ 0x00000000, 0x77073096, 0xEE0E612C, 0x990951BA, 0x076DC419, 0x706AF48F,
+ 0xE963A535, 0x9E6495A3, 0x0EDB8832, 0x79DCB8A4, 0xE0D5E91E, 0x97D2D988,
+ 0x09B64C2B, 0x7EB17CBD, 0xE7B82D07, 0x90BF1D91, 0x1DB71064, 0x6AB020F2,
+ 0xF3B97148, 0x84BE41DE, 0x1ADAD47D, 0x6DDDE4EB, 0xF4D4B551, 0x83D385C7,
+ 0x136C9856, 0x646BA8C0, 0xFD62F97A, 0x8A65C9EC, 0x14015C4F, 0x63066CD9,
+ 0xFA0F3D63, 0x8D080DF5, 0x3B6E20C8, 0x4C69105E, 0xD56041E4, 0xA2677172,
+ 0x3C03E4D1, 0x4B04D447, 0xD20D85FD, 0xA50AB56B, 0x35B5A8FA, 0x42B2986C,
+ 0xDBBBC9D6, 0xACBCF940, 0x32D86CE3, 0x45DF5C75, 0xDCD60DCF, 0xABD13D59,
+ 0x26D930AC, 0x51DE003A, 0xC8D75180, 0xBFD06116, 0x21B4F4B5, 0x56B3C423,
+ 0xCFBA9599, 0xB8BDA50F, 0x2802B89E, 0x5F058808, 0xC60CD9B2, 0xB10BE924,
+ 0x2F6F7C87, 0x58684C11, 0xC1611DAB, 0xB6662D3D, 0x76DC4190, 0x01DB7106,
+ 0x98D220BC, 0xEFD5102A, 0x71B18589, 0x06B6B51F, 0x9FBFE4A5, 0xE8B8D433,
+ 0x7807C9A2, 0x0F00F934, 0x9609A88E, 0xE10E9818, 0x7F6A0DBB, 0x086D3D2D,
+ 0x91646C97, 0xE6635C01, 0x6B6B51F4, 0x1C6C6162, 0x856530D8, 0xF262004E,
+ 0x6C0695ED, 0x1B01A57B, 0x8208F4C1, 0xF50FC457, 0x65B0D9C6, 0x12B7E950,
+ 0x8BBEB8EA, 0xFCB9887C, 0x62DD1DDF, 0x15DA2D49, 0x8CD37CF3, 0xFBD44C65,
+ 0x4DB26158, 0x3AB551CE, 0xA3BC0074, 0xD4BB30E2, 0x4ADFA541, 0x3DD895D7,
+ 0xA4D1C46D, 0xD3D6F4FB, 0x4369E96A, 0x346ED9FC, 0xAD678846, 0xDA60B8D0,
+ 0x44042D73, 0x33031DE5, 0xAA0A4C5F, 0xDD0D7CC9, 0x5005713C, 0x270241AA,
+ 0xBE0B1010, 0xC90C2086, 0x5768B525, 0x206F85B3, 0xB966D409, 0xCE61E49F,
+ 0x5EDEF90E, 0x29D9C998, 0xB0D09822, 0xC7D7A8B4, 0x59B33D17, 0x2EB40D81,
+ 0xB7BD5C3B, 0xC0BA6CAD, 0xEDB88320, 0x9ABFB3B6, 0x03B6E20C, 0x74B1D29A,
+ 0xEAD54739, 0x9DD277AF, 0x04DB2615, 0x73DC1683, 0xE3630B12, 0x94643B84,
+ 0x0D6D6A3E, 0x7A6A5AA8, 0xE40ECF0B, 0x9309FF9D, 0x0A00AE27, 0x7D079EB1,
+ 0xF00F9344, 0x8708A3D2, 0x1E01F268, 0x6906C2FE, 0xF762575D, 0x806567CB,
+ 0x196C3671, 0x6E6B06E7, 0xFED41B76, 0x89D32BE0, 0x10DA7A5A, 0x67DD4ACC,
+ 0xF9B9DF6F, 0x8EBEEFF9, 0x17B7BE43, 0x60B08ED5, 0xD6D6A3E8, 0xA1D1937E,
+ 0x38D8C2C4, 0x4FDFF252, 0xD1BB67F1, 0xA6BC5767, 0x3FB506DD, 0x48B2364B,
+ 0xD80D2BDA, 0xAF0A1B4C, 0x36034AF6, 0x41047A60, 0xDF60EFC3, 0xA867DF55,
+ 0x316E8EEF, 0x4669BE79, 0xCB61B38C, 0xBC66831A, 0x256FD2A0, 0x5268E236,
+ 0xCC0C7795, 0xBB0B4703, 0x220216B9, 0x5505262F, 0xC5BA3BBE, 0xB2BD0B28,
+ 0x2BB45A92, 0x5CB36A04, 0xC2D7FFA7, 0xB5D0CF31, 0x2CD99E8B, 0x5BDEAE1D,
+ 0x9B64C2B0, 0xEC63F226, 0x756AA39C, 0x026D930A, 0x9C0906A9, 0xEB0E363F,
+ 0x72076785, 0x05005713, 0x95BF4A82, 0xE2B87A14, 0x7BB12BAE, 0x0CB61B38,
+ 0x92D28E9B, 0xE5D5BE0D, 0x7CDCEFB7, 0x0BDBDF21, 0x86D3D2D4, 0xF1D4E242,
+ 0x68DDB3F8, 0x1FDA836E, 0x81BE16CD, 0xF6B9265B, 0x6FB077E1, 0x18B74777,
+ 0x88085AE6, 0xFF0F6A70, 0x66063BCA, 0x11010B5C, 0x8F659EFF, 0xF862AE69,
+ 0x616BFFD3, 0x166CCF45, 0xA00AE278, 0xD70DD2EE, 0x4E048354, 0x3903B3C2,
+ 0xA7672661, 0xD06016F7, 0x4969474D, 0x3E6E77DB, 0xAED16A4A, 0xD9D65ADC,
+ 0x40DF0B66, 0x37D83BF0, 0xA9BCAE53, 0xDEBB9EC5, 0x47B2CF7F, 0x30B5FFE9,
+ 0xBDBDF21C, 0xCABAC28A, 0x53B39330, 0x24B4A3A6, 0xBAD03605, 0xCDD70693,
+ 0x54DE5729, 0x23D967BF, 0xB3667A2E, 0xC4614AB8, 0x5D681B02, 0x2A6F2B94,
+ 0xB40BBE37, 0xC30C8EA1, 0x5A05DF1B, 0x2D02EF8D
+ ];
+
+ // This converts a unicode string to UTF-8 bytes
+ txt = unescape(encodeURI(txt));
+ let crc = 0 ^ (-1);
+ let len = txt.length;
+ for (let i=0; i < len; i++) {
+ crc = (crc >>> 8) ^ table[(crc ^ txt.charCodeAt(i)) & 0xFF];
+ }
+ crc = crc ^ (-1);
+ // Turns the signed integer into an unsigned integer
+ if (crc < 0) {
+ crc += 4294967296;
+ }
+
+ return crc;
+}
diff --git a/src/lib/storage.ts b/src/lib/storage.ts
new file mode 100644
index 0000000..5d18e92
--- /dev/null
+++ b/src/lib/storage.ts
@@ -0,0 +1,30 @@
+// read: https://web.dev/storage-for-the-web/
+// https://www.npmjs.com/package/store2 for localStorage
+// https://www.npmjs.com/package/idb-keyval for IndexedDB
+
+//import store from "store2";
+import {createStore, set, get, clear, del, UseStore} from 'idb-keyval';
+
+const customStore: UseStore = createStore('chatngay', 'chatboard');
+
+const storage = {
+ async clear() {
+ return await clear(customStore);
+ },
+ async save(key?: string, data?: any) {
+ return (key) ? await set(key, data, customStore) : false;
+ },
+ async get(key?: string) {
+ return (key) ? await get(key, customStore) : null;
+ },
+ async delete(key?: string) {
+ return (key) ? await del(key, customStore) : false;
+ }
+}
+
+export default storage;
+
+
+export const userChatHistoryStorageKey = (user_id?: string|number) => {
+ return user_id ? `chat-history-${user_id}` : undefined;
+}
diff --git a/src/lib/theme.ts b/src/lib/theme.ts
new file mode 100644
index 0000000..a92791e
--- /dev/null
+++ b/src/lib/theme.ts
@@ -0,0 +1,19 @@
+import {SERVER_STATIC} from "@/config";
+
+const CSS_FILES: {[key: string]: string} = {
+ set1: SERVER_STATIC + "/style_1.css",
+ set2: SERVER_STATIC + "/style_2.css",
+ set3: SERVER_STATIC + "/style_3.css"
+}
+
+/**
+ * @author: Hieu
+ * @description: get current theme css file
+ */
+export function getCSSFile(id: number){
+ if(!CSS_FILES.hasOwnProperty('set'+id)) {
+ return CSS_FILES.set3; //default
+ }
+
+ return CSS_FILES['set'+id];// + '?t=' + getCurrentTimestamp();
+}
diff --git a/src/lib/upload.ts b/src/lib/upload.ts
new file mode 100644
index 0000000..6e9971a
--- /dev/null
+++ b/src/lib/upload.ts
@@ -0,0 +1,4 @@
+// 19-05-2021: For dev
+export const uploadFile = (file: Blob) => {
+ // TODO:
+}
\ No newline at end of file
diff --git a/src/lib/user.ts b/src/lib/user.ts
new file mode 100644
index 0000000..91a684c
--- /dev/null
+++ b/src/lib/user.ts
@@ -0,0 +1,67 @@
+import {Dispatch} from "redux";
+
+import {AdminInfo} from "@/typings/user";
+import {actions} from "@/store/actions";
+import {getConnectNode} from "@/config";
+
+let _user_last_active_time: number = 0;
+
+
+/*declare global {
+ interface Window {
+ admin_info: AdminInfo
+ }
+}*/
+
+const getAdminInfo = () : AdminInfo => {
+ //if(MODE === 'dev') return x as AdminInfo;
+ return window.admin_info || { client_id:'', id: '', name:'', jwt: '', group_id: '', node: '' };
+}
+
+
+const getUserSocketConnectionProperty = () : { endpoint: string, token: string } => {
+ const admin_info = getAdminInfo();
+
+ return {
+ endpoint: admin_info.node ? getConnectNode(admin_info.node) : '',
+ token: admin_info.jwt || '',
+ }
+}
+
+
+const getUserLastActiveTime = (): number => {
+ return _user_last_active_time;
+}
+
+const setUserLastActiveTime = (time: number) => {
+ _user_last_active_time = time;
+}
+
+
+// track users change the browser tab
+// https://developer.mozilla.org/en-US/docs/Web/API/Document/visibilitychange_event
+export function trackVisibilityChange(dispatch: Dispatch) {
+ document.addEventListener("visibilitychange", function() {
+ //console.log( 'document.visibilityState = ' + document.visibilityState );
+ dispatch(actions.changeUserVisibilityState(document.visibilityState));
+ });
+
+ // For safari: Safari doesn’t fire visibilitychange as expected when the value of the visibilityState property transitions to hidden; so for that case, you need to also include code to listen for the pagehide event.
+ // console.log( 'navigator.userAgent = ' + navigator.userAgent );
+ if(navigator.userAgent.indexOf("Safari") !== -1) {
+ //console.log("Yes it;s Safari!");
+ window.addEventListener("pagehide", event => {
+ if (event.persisted) {
+ /* the page isn't being discarded, so it can be reused later */
+ }
+ }, false);
+ }
+}
+
+
+export {
+ getAdminInfo,
+ getUserSocketConnectionProperty,
+ setUserLastActiveTime,
+ getUserLastActiveTime
+}
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
new file mode 100644
index 0000000..9d05b73
--- /dev/null
+++ b/src/lib/utils.ts
@@ -0,0 +1,311 @@
+import {REDIRECTOR_URL} from "@/config";
+import publicIp from "@/lib/public_ip";
+//import memoizeOne from 'memoize-one';
+import api from "@/lib/api";
+
+
+//format a number for readability
+//1000 => 1.000
+//-1000 => -1.000
+export function formatNumber (num?: number) : string{
+ if(!num || num === 0) return '0';
+
+ const is_negative_number = (num < 0);
+
+ let str = (is_negative_number) ? (num * -1) + '' : num + ''; //convert to string
+ let char_count = str.length;
+ if(char_count <= 3) {
+ return (is_negative_number) ? '-' + str : str;
+ }
+
+ let first_part = str.substr(0, char_count % 3); // num = 10000 => this part = 10
+ let remain_part = str.replace(first_part, "");
+ let num_group = Math.round(remain_part.length/3);
+
+ let parts = [];
+ if(first_part !== '') parts.push( first_part ); // num = 10000 => this part = 10
+
+ for (let i = 0; i < num_group; i++){
+ parts.push( remain_part.substr( i*3, 3));
+ }
+
+ return (is_negative_number) ? '-' + parts.join('.') : parts.join('.');
+}
+
+
+export function isBrowserSupport(): boolean {
+ // check support for indexedDB to store various async data
+ if (!window.indexedDB) return false;
+
+ // check localstorage support for redux store persist
+ if ( ! _localStorageAvailable()) return false;
+
+ // other
+ // ...
+
+ return true;
+
+
+ // helpers
+
+ // shamelessly copied from https://developer.mozilla.org/en-US/docs/Web/API/Web_Storage_API/Using_the_Web_Storage_API
+ function _localStorageAvailable() {
+ let storage;
+ try {
+ // @ts-ignore
+ storage = window['localStorage'];
+ let x = '__storage_test__';
+ storage.setItem(x, x);
+ storage.removeItem(x);
+ return true;
+ }
+ catch(e) {
+ return e instanceof DOMException && (
+ // everything except Firefox
+ e.code === 22 ||
+ // Firefox
+ e.code === 1014 ||
+ // test name field too, because code might not be present
+ // everything except Firefox
+ e.name === 'QuotaExceededError' ||
+ // Firefox
+ e.name === 'NS_ERROR_DOM_QUOTA_REACHED') &&
+ // acknowledge QuotaExceededError only if there's something already stored
+ (storage && storage.length !== 0);
+ }
+ }
+}
+
+
+// runUpdateAdminStatus periodically
+export const runUpdateAdminStatus = (admin_id: string, interval: number = 10) => {
+
+ _run();
+
+ // then check periodically
+ setTimeout(function () {
+ runUpdateAdminStatus(admin_id, interval);
+ }, interval * 1000);
+
+ function _run(){
+ api.post("admin/update-status", {admin_id: admin_id, connected: true}) ;
+ }
+}
+
+// check user's internet connection periodically
+export const runCheckNetworkConnection = (interval: number = 10, cb: (isOnline: boolean) => void ) => {
+ // check onload
+ _runCheck();
+
+ // then check periodically
+ setTimeout(function (){
+ runCheckNetworkConnection(interval, cb);
+ }, interval * 1000);
+
+ function _runCheck(){
+ checkUserInternetConnection().then(cb);
+ }
+}
+
+
+export function confirmLeavePage() {
+ window.addEventListener("beforeunload", function (e) {
+ let confirmationMessage = "Thay đổi trang sẽ mất dữ liệu hiện tại";
+ e.returnValue = confirmationMessage; // Gecko, Trident, Chrome 34+
+ return confirmationMessage; // Gecko, WebKit, Chrome <34
+ });
+}
+
+export const showUnixTime = (timestamp: number|undefined) => {
+ //(t) ? dayjs.unix(t).format('DD-MM-YYYY h:mma') : '';
+ if(!timestamp) return '';
+
+ let a = new Date(timestamp * 1000);
+ let months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
+ let year = a.getFullYear();
+ let month = months[a.getMonth()];
+ let date = a.getDate();
+ let hour = a.getHours();
+ let min = a.getMinutes();
+ //let sec = a.getSeconds();
+ return [date, month, year + " " + hour + ':' + min].join("-") ;
+}
+
+
+export function getRandomInt(min: number, max: number) : number {
+ let floor_min = Math.ceil(min);
+ let floor_max = Math.floor(max);
+ return Math.floor(Math.random() * (floor_max - floor_min + 1)) + floor_min;
+}
+
+// check whether user's connection is ok
+// ref: https://github.com/sindresorhus/is-online
+// https://www.npmjs.com/package/public-ip
+export async function checkUserInternetConnection(){
+ let options = {
+ timeout: 5000,
+ ipVersion: 4,
+ };
+
+ if (navigator && !navigator.onLine) {
+ return false;
+ }
+
+ const publicIpFunctionName = options.ipVersion === 4 ? 'v4' : 'v6';
+
+ try {
+ return await publicIp[publicIpFunctionName](options);
+ } catch (_) {
+ return false;
+ }
+}
+
+
+// get current timestamp in UTC
+export function getCurrentUTCTimestamp(microtime: boolean = false) : number {
+ let x = new Date();
+ let micro_time = x.getTime() + x.getTimezoneOffset() * 60 * 1000;
+ return (microtime) ? micro_time : Math.floor(micro_time / 1000);
+}
+
+
+export function maskExternalUrl(url: string) {
+ return `${REDIRECTOR_URL}?url=${encodeURIComponent(url)}`;
+}
+
+
+// given an array, keep max_size latest items and discard other. return new array
+export function keepMaxArraySize(arr: any[], max_size: number) {
+ let arr_size = arr.length;
+
+ // no change
+ if(arr_size <= max_size) return arr;
+
+ let copied_arr = [...arr];
+ copied_arr.splice(0, copied_arr.length - max_size);
+
+ return copied_arr;
+}
+
+
+
+export function findObjectInArray(arr: { [key: string]: any }[], key: string, value: any) : {index: number, item: object} {
+ let item: object = {};
+ let match_index = -1;
+ for ( let index = 0; index < arr.length; index ++) {
+ if(arr[index][key] === value) {
+ item = {...arr[index]};
+ match_index = index;
+ break;
+ }
+ }
+
+ return {
+ index: match_index,
+ item: item
+ };
+}
+
+
+export function createUserId(): string {
+ return Math.random().toString(36).slice(2);
+}
+
+export function currentTimestamp() : number {
+ return Date.now() / 1000;
+}
+
+/**
+ * @date 22-02-2016
+ * @author Hieu
+ * @description: replace console.log()
+ * @usage example
+ console(obj)
+ */
+export function log(obj: any) {
+ console.log(obj);
+}
+
+/**
+ * @date 26-02-2016
+ * @author Hieu
+ * @description: count number of items in object
+ * @usage example
+ */
+export function objectSize( content: object ) {
+ let length = 0;
+ for( let key in content ) {
+ if( content.hasOwnProperty(key) ) {
+ length ++;
+ }
+ }
+ return length;
+}
+
+
+/**
+ * @date 09-03-2016
+ * @author http://stackoverflow.com/questions/1500260/detect-urls-in-text-with-javascript
+ * @description: find url in text and replace with clickable a
+ * @usage
+ */
+export function formatUrl(text: string) {
+ let urlRegex = /(https?:\/\/[^\s]+)/g;
+ return text.replace(urlRegex, '$1 ')
+}
+
+
+/**
+ * @date 03-03-2016
+ * @author http://stackoverflow.com/questions/4959975/generate-random-value-between-two-numbers-in-javascript
+ * @description: get a random number between min-max
+ * @usage example
+ */
+export function randomBetween(min: number, max: number) {
+ return Math.floor( Math.random() * ( max - min + 1) + min);
+}
+
+
+/**
+ * @date 21-02-2016
+ * @author http://youmightnotneedjquery.com/
+ * @description: trim a string, support IE8+
+ * @param str
+ * @return string
+ * @usage example
+ trim(str);
+ */
+export function trim(str: string){
+ if (!String.prototype.trim) {
+ //in case of IE 8 or lower
+ return str.replace(/^\s+|\s+$/g, '') ;
+ }else{
+ return str.trim();
+ }
+}
+
+
+/**
+ * @date 22-02-2016
+ * @author Hieu
+ * @description: shorten a string by char count
+ * @usage example
+ subStr(str, char_count)
+ */
+export function subStr (str: string | undefined, char_count: number = 30): string {
+ if(!str) return '';
+ let padding = ' ...';
+ let result = '';
+ let cut = str.indexOf(' ', char_count);
+ if(cut === -1) result = str;
+ else result = str.substring(0, cut);
+ return (result.length <= char_count) ? result : result.substring(0, char_count) + padding;
+}
+
+
+/**
+ * https://coderwall.com/p/i817wa/one-line-function-to-detect-mobile-devices-with-javascript
+ */
+export function isMobile(){
+ return (typeof window.orientation !== "undefined") || (navigator.userAgent.indexOf('IEMobile') !== -1);
+}
diff --git a/src/lib/validation.ts b/src/lib/validation.ts
new file mode 100644
index 0000000..2a6e2ee
--- /dev/null
+++ b/src/lib/validation.ts
@@ -0,0 +1,48 @@
+export function validURL(str: string) : boolean {
+ let pattern = new RegExp('^(https?:\\/\\/)?'+ // protocol
+ '((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|'+ // domain name
+ '((\\d{1,3}\\.){3}\\d{1,3}))'+ // OR ip (v4) address
+ '(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*'+ // port and path
+ '(\\?[;&a-z\\d%_.~+=-]*)?'+ // query string
+ '(\\#[-a-z\\d_]*)?$','i'); // fragment locator
+
+ return pattern.test(str.trim());
+}
+
+
+export function isUrlImage(str: string) : boolean {
+ if(!validURL(str)) return false;
+
+ let text_ext = str.substr(str.lastIndexOf("."));
+ let acceptable_exts = ['.jpg', '.jpeg', '.png', '.gif'];
+
+ return (text_ext !== '' && acceptable_exts.includes(text_ext.toLowerCase()));
+}
+
+
+// accept tel or mobile number in different format: 0912.123.123
+// we remove all non-number and validate the length
+// need to validate prefix as well but it seems unnecessary because we dont know for sure if the phone is actually true even if all the formats pass the test
+export function validatePhone(txt: string) : boolean {
+ let all_numbers = txt.replace(/[^0-9]/g, '') + '';
+
+ return validateLength(all_numbers, 8, 14);
+}
+
+
+export function validateLength(txt: string, min_length: number=1, max_length: number = 1000) : boolean {
+ let txt_length = txt.trim().length;
+ return (max_length > txt_length && txt_length >= min_length );
+}
+
+
+/**
+ * @author Hieu
+ * @description: validate an email address
+ * ref: http://stackoverflow.com/questions/46155/validate-email-address-in-javascript
+ * @usage example
+ */
+export function validateEmail(email: string) : boolean {
+ let re = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
+ return re.test(email);
+}
diff --git a/src/lib/vietnamese.ts b/src/lib/vietnamese.ts
new file mode 100644
index 0000000..8463051
--- /dev/null
+++ b/src/lib/vietnamese.ts
@@ -0,0 +1,257 @@
+//enhanced function to isStringFound
+//which search for vietnamese and non-vietnamese in main_str
+//does not care about cases as well
+export function isFound(sub_str: string, main_str: string) {
+ const sub_str_unique = unvietnamese(sub_str).toLowerCase();
+ const main_str_unique = getUniqueWords(unvietnamese(main_str) +" " + chuyenKhongdau(main_str)).toLowerCase();
+
+ return isStringFound(sub_str_unique, main_str_unique);
+}
+
+
+function isStringFound(sub_str: string, main_str: string) : boolean{
+ const test_sub_str = sub_str.trim();
+
+ //empty str should fail
+ if( test_sub_str.length === 0) return false;
+
+ //start
+ const sub_str_parts = test_sub_str.split(" ");
+ let is_all_parts_found = true;
+ let test_part;
+ for (let i = 0, total_part = sub_str_parts.length; i < total_part ; i++) {
+ test_part = sub_str_parts[i].trim();
+ //test if part in the main_str, if not then we dont need further test
+ if(test_part.length > 0 && main_str.indexOf(test_part) === -1 ) {
+ is_all_parts_found = false;
+ break;
+ }
+ }
+
+ return is_all_parts_found;
+}
+
+
+function unvietnamese(str: string){
+ let replacer = getVietnameseEnglishEquivalent();
+ return replaceAll(str, replacer);
+}
+
+
+//credit: stackoverflow
+function replaceAll(str: string, mapObj: any) {
+ let re = new RegExp(Object.keys(mapObj).join("|"), "gi");
+
+ return (str+'').replace(re, function (matched) {
+ return mapObj[matched]
+ })
+}
+
+
+//28-10-2015
+//matching Vietnamese special characters to English equivalent
+//used in some functions around the system like Search->sanitizeVietnamese($txt), ListView::buildSqlEquation
+function getVietnameseEnglishEquivalent(){
+ return {
+ "đ" : "dd",
+ "Đ" : "DD",
+
+ "ó" : 'os',
+ "ỏ" : 'or',
+ "ò" : 'of',
+ "ọ" : 'oj',
+ "õ" : 'ox',
+
+ "ô" : 'oo',
+ "ỗ" : 'oox',
+ "ổ" : 'oor',
+ "ồ" : 'oof',
+ "ố" : 'oos',
+ "ộ" : 'ooj',
+
+ "ơ" : 'ow',
+ "ỡ" : 'owx',
+ "ớ" : 'ows',
+ "ờ" : 'owf',
+ "ở" : 'owr',
+ "ợ" : 'owj',
+
+ "Ó" : 'OS',
+ "Ỏ" : 'OR',
+ "Ò" : 'OF',
+ "Ọ" : 'OJ',
+ "Õ" : 'OX',
+
+ "Ô" : 'OO',
+ "Ỗ" : 'OOX',
+ "Ổ" : 'OOR',
+ "Ồ" : 'OOF',
+ "Ố" : 'OOS',
+ "Ộ" : 'OOJ',
+
+ "Ơ" : 'OW',
+ "Ỡ" : 'OWX',
+ "Ớ" : 'OWS',
+ "Ờ" : 'OWF',
+ "Ở" : 'OWR',
+ "Ợ" : 'OWJ',
+
+ "ì" : 'if',
+ "í" : 'is',
+ "ỉ" : 'ir',
+ "ĩ" : 'ix',
+ "ị" : 'ij',
+
+ "Ì" : 'IF',
+ "Í" : 'IS',
+ "Ỉ" : 'IR',
+ "Ĩ" : 'IX',
+ "Ị" : 'IJ',
+
+ "ê" : 'ee',
+ "ệ" : 'eej',
+ "ế" : 'ees',
+ "ể" : 'eer',
+ "ễ" : 'eex',
+ "ề" : 'eef',
+
+ "é" : 'es',
+ "ẹ" : 'ej',
+ "ẽ" : 'ex',
+ "è" : 'ef',
+ "ẻ" : 'er',
+
+ "Ê" : 'EE',
+ "Ệ" : 'EEJ',
+ "Ế" : 'EES',
+ "Ể" : 'EER',
+ "Ễ" : 'EEX',
+ "Ề" : 'EEF',
+
+ "É" : 'ES',
+ "Ẹ" : 'EJ',
+ "Ẽ" : 'EX',
+ "È" : 'EF',
+ "Ẻ" : 'ER',
+
+ "ả" : 'ar',
+ "á" : 'as',
+ "ạ" : 'aj',
+ "ã" : 'ax',
+ "à" : 'af',
+
+ "â" : 'aa',
+ "ẩ" : 'aar',
+ "ấ" : 'aas',
+ "ầ" : 'aaf',
+ "ậ" : 'aaj',
+ "ẫ" : 'aax',
+
+ "ă" : 'aw',
+ "ẳ" : 'awr',
+ "ắ" : 'aws',
+ "ằ" : 'awf',
+ "ặ" : 'awj',
+ "ẵ" : 'awx',
+
+ "Ả" : 'AR',
+ "Á" : 'AS',
+ "Ạ" : 'AJ',
+ "Ã" : 'AX',
+ "À" : 'AF',
+
+ "Â" : 'AA',
+ "Ẩ" : 'AAR',
+ "Ấ" : 'AAS',
+ "Ầ" : 'AAF',
+ "Ậ" : 'AAJ',
+ "Ẫ" : 'AAX',
+
+ "Ă" : 'AW',
+ "Ẳ" : 'AWR',
+ "Ắ" : 'AWS',
+ "Ằ" : 'AWF',
+ "Ặ" : 'AWJ',
+ "Ẵ" : 'AWX',
+
+ "ũ" : 'ux',
+ "ụ" : 'uj',
+ "ú" : 'us',
+ "ủ" : 'ur',
+ "ù" : 'uf',
+
+ "ư" : 'uw',
+ "ữ" : 'uwx',
+ "ự" : 'uwj',
+ "ứ" : 'uws',
+ "ử" : 'uwr',
+ "ừ" : 'uwf',
+
+ "Ũ" : 'UX',
+ "Ụ" : 'UJ',
+ "Ú" : 'US',
+ "Ủ" : 'UR',
+ "Ù" : 'UF',
+
+ "Ư" : 'UW',
+ "Ữ" : 'UWX',
+ "Ự" : 'UWJ',
+ "Ứ" : 'UWS',
+ "Ử" : 'UWR',
+ "Ừ" : 'UWF',
+
+ "ỹ" : 'yx',
+ "ỵ" : 'yj',
+ "ý" : 'ys',
+ "ỷ" : 'yr',
+ "ỳ" : 'yf',
+
+ "Ỹ" : 'YX',
+ "Ỵ" : 'YJ',
+ "Ý" : 'YS',
+ "Ỷ" : 'YR',
+ "Ỳ" : 'YF',
+ }
+}
+
+
+function chuyenKhongdau(txt: string){
+ const arraychar = [
+ ["đ"],
+ ["Đ"],
+ ["ó","ỏ","ò","ọ","õ","ô","ỗ","ổ","ồ","ố","ộ","ơ","ỡ","ớ","ờ","ở","ợ"],
+ ["Ó","Ỏ","Ò","Ọ","Õ","Ô","Ỗ","Ổ","Ồ","Ố","Ộ","Ơ","Ỡ","Ớ","Ờ","Ở","Ợ"],
+ ["ì","í","ỉ","ì","ĩ","ị",],
+ ["Ì","Í","Ỉ","Ì","Ĩ","Ị"],
+ ["ê","ệ","ế","ể","ễ","ề","é","ẹ","ẽ","è","ẻ",],
+ ["Ê","Ệ","Ế","Ể","Ễ","Ề","É","Ẹ","Ẽ","È","Ẻ"],
+ ["ả","á","ạ","ã","à","â","ẩ","ấ","ầ","ậ","ẫ","ă","ẳ","ắ","ằ","ặ","ẵ",],
+ ["Ả","Á","Ạ","Ã","À","Â","Ẩ","Ấ","Ầ","Ậ","Ẫ","Ă","Ẳ","Ắ","Ằ","Ặ","Ẵ"],
+ ["ũ","ụ","ú","ủ","ù","ư","ữ","ự","ứ","ử","ừ",],
+ ["Ũ","Ụ","Ú","Ủ","Ù","Ư","Ũ","Ự","Ứ","Ử","Ừ"],
+ ["ỹ","ỵ","ý","ỷ","ỳ",],
+ ["Ỹ","Ỵ","Ý","Ỷ","Ỳ"]
+ ];
+ const arrayconvert = ["d","D","o","O","i","I","e","E","a","A","u","U","y","Y"];
+
+ let mappings: any = {};
+ for ( let i = 0, count = arrayconvert.length; i < count; i++){
+ for ( let j = 0, total = arraychar[i].length; j < total ; j++){
+ mappings[arraychar[i][j]] = arrayconvert[i];
+ }
+ }
+
+ return replaceAll(txt, mappings);
+}
+
+
+function getUniqueWords(str: string) {
+ const sub_str_parts = str.trim().split(" ");
+ const unique_values = sub_str_parts.filter( _onlyUnique );
+
+ return unique_values.join(" ").trim();
+
+ function _onlyUnique(value: any, index: any, self: string | any[]) {
+ return self.indexOf(value) === index;
+ }
+}
diff --git a/src/lib/webworker.ts b/src/lib/webworker.ts
new file mode 100644
index 0000000..1ef6b11
--- /dev/null
+++ b/src/lib/webworker.ts
@@ -0,0 +1,9 @@
+// 19-05-2021: For dev
+
+let registered_callbacks: {[key: string] : any} = {};
+let _web_worker: { sendTask: (payload: {type: string, task_id: string, [key:string]: any}, callback: Function) => void }|undefined = undefined;
+
+
+export function createWebWorker(url: string) {
+
+}
diff --git a/src/logo.svg b/src/logo.svg
new file mode 100644
index 0000000..9dfc1c0
--- /dev/null
+++ b/src/logo.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/react-app-env.d.ts b/src/react-app-env.d.ts
new file mode 100644
index 0000000..6431bc5
--- /dev/null
+++ b/src/react-app-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/src/reportWebVitals.ts b/src/reportWebVitals.ts
new file mode 100644
index 0000000..49a2a16
--- /dev/null
+++ b/src/reportWebVitals.ts
@@ -0,0 +1,15 @@
+import { ReportHandler } from 'web-vitals';
+
+const reportWebVitals = (onPerfEntry?: ReportHandler) => {
+ if (onPerfEntry && onPerfEntry instanceof Function) {
+ import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
+ getCLS(onPerfEntry);
+ getFID(onPerfEntry);
+ getFCP(onPerfEntry);
+ getLCP(onPerfEntry);
+ getTTFB(onPerfEntry);
+ });
+ }
+};
+
+export default reportWebVitals;
diff --git a/src/setup.ts b/src/setup.ts
new file mode 100644
index 0000000..516e9bc
--- /dev/null
+++ b/src/setup.ts
@@ -0,0 +1,73 @@
+import {getAdminInfo, getUserSocketConnectionProperty, trackVisibilityChange} from "@/lib/user";
+import {askForBrowserNotificationPermit} from "@/lib/notification";
+import store from "@/store";
+import {createSocket} from "@/lib/networking";
+import {actions} from "@/store/actions";
+import {confirmLeavePage, runUpdateAdminStatus} from "@/lib/utils";
+import {IS_DEV, SOCKET_OPTIONS } from "@/config";
+import {handleMessageFromServer} from "@/lib/messaging";
+import {ConnectionToServerStatusType} from "@/typings/network";
+import api from "@/lib/api";
+import {ClientSettings} from "@/typings";
+
+
+export async function getSettings() : Promise {
+ let client_query = await api.get("client/settings");
+ return (client_query.status === 'ok') ? client_query.data as ClientSettings : null;
+}
+
+
+export const setUp = async () => {
+
+ let user_connection = getUserSocketConnectionProperty();
+ if(user_connection.endpoint === '') {
+ console.error("Setup error in socket endpoint");
+ return ;
+ }
+
+ await createSocket(user_connection.endpoint, {jwt_token: user_connection.token, ...SOCKET_OPTIONS}, handleMessageFromServer, (status: ConnectionToServerStatusType, message: string) => {
+ switch (status){
+ case "connect":
+ store.dispatch(actions.updateNodeConnection('ok'))
+ break;
+ case "disconnect":
+ case "connect_error":
+ store.dispatch(actions.updateNodeConnection('error'))
+ break;
+ }
+ });
+
+ _checkUsersStatus();
+
+ trackVisibilityChange(store.dispatch);
+
+ // if user leaving page, ask for confirmation of loss messages
+ confirmLeavePage();
+
+ // todo: combine checking network with update status because user must be online in order to send updates
+ /*runCheckNetworkConnection(20, (isOnline) => {
+ let new_status: NetworkingStatusType = (isOnline) ? 'online': 'offline';
+ store.dispatch(actions.changeNetworkingStatus(new_status));
+ });*/
+
+ if(IS_DEV) {
+
+ runUpdateAdminStatus(getAdminInfo().id, 20);
+
+ updateRealStat(15); // use web-worker
+
+ await askForBrowserNotificationPermit(); // can only test when run in https mode
+ //_watchSelfDisconnect(); // start heartbeat
+ }
+};
+
+
+// todo: on reload, check status of all users (users in chat and users in request queue)
+function _checkUsersStatus() {
+ // todo:
+}
+
+// webworker to update user online and new notification
+function updateRealStat(check_interval: number = 5) {
+ //TODO:
+}
\ No newline at end of file
diff --git a/src/setupProxy.js b/src/setupProxy.js
new file mode 100644
index 0000000..6524ab9
--- /dev/null
+++ b/src/setupProxy.js
@@ -0,0 +1,21 @@
+// guide from: https://create-react-app.dev/docs/proxying-api-requests-in-development/
+// Note: You do not need to import this file anywhere. It is automatically registered when you start the development server.
+const createProxyMiddleware = require('http-proxy-middleware');
+
+// proxy middleware options
+// see: https://www.npmjs.com/package/http-proxy-middleware#http-proxy-options
+const options = {
+ target: 'https://api195.chatngay.com/admin/', // target host
+ changeOrigin: true, // needed for virtual hosted sites
+ //ws: true, // proxy websockets
+ pathRewrite: {
+ '^/api': '', // remove base path
+ }
+};
+
+module.exports = function(app) {
+ app.use(
+ '/api',
+ createProxyMiddleware(options)
+ );
+};
diff --git a/src/setupTests.ts b/src/setupTests.ts
new file mode 100644
index 0000000..8f2609b
--- /dev/null
+++ b/src/setupTests.ts
@@ -0,0 +1,5 @@
+// jest-dom adds custom jest matchers for asserting on DOM nodes.
+// allows you to do things like:
+// expect(element).toHaveTextContent(/react/i)
+// learn more: https://github.com/testing-library/jest-dom
+import '@testing-library/jest-dom';
diff --git a/src/store/actions.ts b/src/store/actions.ts
new file mode 100644
index 0000000..ced0175
--- /dev/null
+++ b/src/store/actions.ts
@@ -0,0 +1,143 @@
+import {Action, NetworkingStatusType, NodeConnectionStatusType, UserStatusType} from "@/store/typing";
+import {AdminInfo, UserChatRequest, UserInfo} from "@/typings/user";
+import {ChatboxTextMessage, ServerMessage, StatusMessageType} from "@/typings/message.d";
+import {ActionType} from "./typing.d";
+import {Dispatch} from "redux";
+import storage, {userChatHistoryStorageKey} from "@/lib/storage";
+
+
+const changeUserVisibilityState = (new_state: VisibilityState) : Action => {
+ return _createAction('UPDATE_USER_VISIBILITY_STATE', new_state);
+}
+
+const changeNetworkingStatus = (new_status: NetworkingStatusType) : Action => {
+ return _createAction('UPDATE_NETWORKING_STATUS', new_status);
+}
+
+const updateNodeConnection = (new_status: NodeConnectionStatusType) : Action => {
+ return _createAction('UPDATE_NODE_CONNECTION', new_status);
+}
+
+const addNewMessage = (message: ServerMessage) : Action => {
+ return _createAction('ADD_NEW_MESSAGE', message);
+}
+
+const addBatchMessage = (messages: ServerMessage[]) : Action => {
+ return _createAction('ADD_BATCH_MESSAGES', messages);
+}
+
+const clearNewMessage = (list_ids: number[]) : Action => {
+ return _createAction('CLEAR_NEW_MESSAGE', list_ids);
+}
+
+const changeUserStatus = (new_status: UserStatusType) : Action => {
+ return _createAction('UPDATE_USER_STATUS', new_status);
+}
+
+const updateUserInfo = (info: Partial) : Action => {
+ return _createAction('UPDATE_USER_INFO', info);
+}
+
+const addUser = (info: Partial) : any => {
+ // since user might have chatted with many different admin staffs before,
+ // before adding user to the chat list, clear all stored history messages in current browser of the user
+ // so the admin can fetch new history
+ // here is the use of redux-thunk
+ return async (dispatch: Dispatch) => {
+ console.log("deleting user data : storage.delete ");
+ let result = await storage.delete(userChatHistoryStorageKey(info.id));
+ console.log(result);
+ dispatch( _createAction('ADD_USER', info) );
+ }
+}
+
+const chatWithUser = (user_id: string) : Action => {
+ return _createAction('CHAT_WITH_USER', user_id);
+}
+
+const removeUser = (info: Partial) : any => {
+ // remove user from chat also remove the storage history to save storage
+ // here is the use of redux-thunk
+ return async (dispatch: Dispatch) => {
+ await storage.delete(userChatHistoryStorageKey(info.id));
+ dispatch(_createAction('REMOVE_USER', info));
+ }
+}
+
+const changeAdminInfo = (info: Partial) : Action => {
+ return _createAction('UPDATE_ADMIN_INFO', info);
+}
+
+
+const addAdmin = (info: Partial | Partial[]) : Action => {
+ return _createAction('ADD_ADMIN', info);
+}
+
+const addChatRequest = (user_info: UserChatRequest, message?: ServerMessage) : Action => {
+ return _createAction('ADD_CHAT_REQUEST', {user_info, message});
+}
+
+const updateChatRequest = (info: Partial) : Action => {
+ return _createAction('UPDATE_CHAT_REQUEST', info);
+}
+
+const removeChatRequest = (user_id: string) : Action => {
+ return _createAction('REMOVE_CHAT_REQUEST', user_id);
+}
+
+const updateStat = (stat = '') : Action => {
+ return _createAction('UPDATE_STATS', stat);
+}
+
+const addCurrentMessage = (payload: {[user_id: string]: ChatboxTextMessage[]}) : Action => {
+ return _createAction('ADD_CURRENT_MESSAGE', payload);
+}
+
+const updateCurrentMessage = (payload: {[user_id: string]: StatusMessageType}) : Action => {
+ return _createAction('UPDATE_CURRENT_MESSAGE', payload);
+}
+
+const addHistoryMessage = (payload: {[user_id: string]: ChatboxTextMessage[]}) : Action => {
+ return _createAction('ADD_HISTORY_MESSAGE', payload);
+}
+
+const openGlobalModal = (payload: {component: string, args?: {[key: string]: any} }) : Action => {
+ return _createAction('OPEN_GLOBAL_MODAL', JSON.stringify(payload));
+}
+
+const openGlobalDrawer = (payload: {component: string, args?: {[key: string]: any} }) : Action => {
+ return _createAction('OPEN_GLOBAL_DRAWER', JSON.stringify(payload));
+}
+
+
+export const actions = {
+ chatWithUser,
+ changeUserVisibilityState,
+ changeNetworkingStatus,
+ updateNodeConnection,
+ updateUserInfo,
+ addNewMessage,
+ addBatchMessage,
+ clearNewMessage,
+ updateStat,
+ addCurrentMessage,
+ updateCurrentMessage,
+ addHistoryMessage,
+ addChatRequest,
+ updateChatRequest,
+ addUser,
+ removeChatRequest,
+ addAdmin,
+ changeAdminInfo,
+ changeUserStatus,
+ removeUser,
+ openGlobalModal,
+ openGlobalDrawer
+}
+
+function _createAction(type: ActionType, payload: any) : Action {
+ return {
+ type,
+ payload,
+ }
+}
diff --git a/src/store/dispatcher.ts b/src/store/dispatcher.ts
new file mode 100644
index 0000000..242668e
--- /dev/null
+++ b/src/store/dispatcher.ts
@@ -0,0 +1,55 @@
+// utility to perform actions which each might involve a number of dispatching activities
+const nothing = 'ass';
+
+export default nothing;
+
+/*
+import {Dispatch} from "redux";
+import {UserInfo} from "@/typings/user";
+import {ServerMessage} from "@/typings/message";
+import {actions} from "@/store/actions";
+import {sendMessageToServer} from "@/lib/messaging";
+import {getAdminInfo} from "@/lib/user";
+*/
+
+
+// Admin pick a request:
+// 1. remove from the list
+// 2. send to network to alert other admins and remove from their list
+// 3. add user to the user-list
+// 4. add messages to the chat
+/*
+export const pickRequest = (dispatch: Dispatch, info: {user: UserInfo, new_messages: ServerMessage[];}) => {
+
+ // remove from request list
+ // dispatch(actions.removeChatRequest(info.user.id));
+
+ // send to network to alert other admins and remove from their list
+ const admin_info = getAdminInfo();
+ if(!admin_info) return;
+
+ sendMessageToServer({
+ type: 'notify',
+ content: {
+ type: 'accept-chat-request',
+ content: {
+ user_info: {
+ id: info.user.id,
+ node: info.user.node,
+ },
+ admin_info: {
+ id: admin_info.id,
+ name: admin_info.name,
+ node: admin_info.node
+ }
+ }
+ }
+ });
+
+ // add to user list
+ dispatch(actions.addUser({...info.user, online: true, chatbox: true}));
+
+ // then add messages
+ dispatch(actions.addBatchMessage(info.new_messages))
+}
+*/
diff --git a/src/store/index.ts b/src/store/index.ts
new file mode 100644
index 0000000..cd29957
--- /dev/null
+++ b/src/store/index.ts
@@ -0,0 +1,10 @@
+// 19-05-2021: For dev
+
+import {createStore, Store, applyMiddleware} from 'redux';
+import thunk from 'redux-thunk';
+import {defaultState, rootReducer} from './reducers';
+
+
+const store: Store = createStore(rootReducer, defaultState, applyMiddleware(thunk));
+
+export default store ;
diff --git a/src/store/persist.ts b/src/store/persist.ts
new file mode 100644
index 0000000..25d9b21
--- /dev/null
+++ b/src/store/persist.ts
@@ -0,0 +1,15 @@
+// 19-05-2021: For dev
+
+const persist = {
+ clear() {
+
+ },
+ save(data: any) {
+
+ },
+ get() {
+ return null;
+ }
+}
+
+export default persist;
diff --git a/src/store/reducers.ts b/src/store/reducers.ts
new file mode 100644
index 0000000..ff64ef8
--- /dev/null
+++ b/src/store/reducers.ts
@@ -0,0 +1,413 @@
+import { combineReducers } from 'redux'
+import isEmpty from "lodash/isEmpty";
+
+import {Action, ActionType, AppState, NetworkingStatusType, NodeConnectionStatusType} from "@/store/typing";
+import {AdminInfo, UserChatRequest, UserInfo} from "@/typings/user";
+import {ChatboxTextMessage, ServerMessage, StatusMessageType} from "@/typings/message";
+import {findObjectInArray} from "@/lib/utils";
+
+
+const updateUserVisibilityState = (current_state: VisibilityState = 'visible', action: Action) : VisibilityState => {
+ if(action.type === 'UPDATE_USER_VISIBILITY_STATE') {
+ return action.payload;
+ }
+
+ return current_state;
+}
+
+const updateNetworkingStatus = (current_status: NetworkingStatusType = 'offline', action: Action) : NetworkingStatusType => {
+ if(action.type === 'UPDATE_NETWORKING_STATUS') {
+ return action.payload;
+ }
+
+ return current_status;
+}
+
+
+const updateNodeConnection = (current_status: NodeConnectionStatusType = 'error', action: Action) : NodeConnectionStatusType => {
+ if(action.type === 'UPDATE_NODE_CONNECTION') {
+ return action.payload;
+ }
+
+ return current_status;
+}
+
+/*
+const updateAdminInfo = (current: AdminInfo = {id: '', name: '', avatar: '', greeting: '', group_id: 0}, action: Action) : AdminInfo => {
+ if(action.type === 'UPDATE_ADMIN_INFO') {
+ let admin_info = (current) ? current : {}
+ return {...admin_info, ...action.payload};
+ }
+
+ return current;
+}
+*/
+
+const addNewRequestMessage = (existing: {user: UserInfo, new_messages: ServerMessage[];}[], message: ServerMessage) => {
+
+ if(message.type !== 'broker') return existing;
+
+ const {from} = message;
+ let item: {user: UserInfo, new_messages: ServerMessage[];} | undefined;
+ let match_index = -1;
+ for ( let index = 0; index < existing.length; index ++) {
+ if(existing[index].user.id === from) {
+ item = {...existing[index]};
+ item.new_messages.push(message);
+ match_index = index;
+ break;
+ }
+ }
+
+ if(match_index > -1 && item) {
+ let new_list = [...existing];
+ new_list[match_index] = item;
+ return new_list;
+ }
+
+ return existing;
+}
+
+
+const updateNewRequest = (state: {user: UserChatRequest, new_messages: ServerMessage[];}[] = [], action: Action) => {
+ // add new chat request
+ if(action.type === 'ADD_CHAT_REQUEST') {
+ const { user_info, message } = action.payload as {user_info: UserChatRequest, message?: ServerMessage};
+
+ let match_index = -1;
+ for ( let index = 0; index < state.length; index ++) {
+ if(state[index].user.id === user_info.id) {
+ match_index = index;
+ break;
+ }
+ }
+
+ if(match_index >= 0) {
+ if(message) state[match_index].new_messages.push(message);
+ return state;
+ }
+
+ // {user: UserChatRequest, new_messages: ServerMessage[];}[]
+ return [...state, { user: user_info as UserChatRequest, new_messages: (message) ? [message] : []}];
+ }
+
+ //update
+ if(action.type === 'UPDATE_CHAT_REQUEST') {
+ let user_info = action.payload as Partial;
+
+ let match_index = -1;
+ for ( let index = 0; index < state.length; index ++) {
+ if(state[index].user.id === user_info.id) {
+ match_index = index;
+ break;
+ }
+ }
+
+ if(match_index >= 0) {
+ state[match_index].user = {...state[match_index].user, ...user_info};
+ }
+
+ return state;
+ }
+
+ // remove chat request
+ if(action.type === 'REMOVE_CHAT_REQUEST') {
+ let remove_id = action.payload;
+
+ return state.filter(request => request.user.id !== remove_id);
+ }
+
+ if(action.type === 'ADD_NEW_REQUEST_MESSAGE') {
+ return addNewRequestMessage(state, action.payload);
+ }
+
+ return state;
+}
+
+// {[key: string]: number}
+const updateStat = (current_stats: string = '', action: Action) => {
+ if(action.type === 'UPDATE_STATS') {
+ return action.payload;
+ }
+
+ return current_stats;
+}
+
+// {[key: string]: number}
+const openGlobalModal = (current_stats: string = '', action: Action) => {
+ if(action.type === 'OPEN_GLOBAL_MODAL') {
+ return action.payload;
+ }
+
+ return current_stats;
+}
+
+const openGlobalDrawer = (current_stats: string = '', action: Action) => {
+ if(action.type === 'OPEN_GLOBAL_DRAWER') {
+ return action.payload;
+ }
+
+ return current_stats;
+}
+
+
+const updateNewMessage = (state: ServerMessage[] = [], action: Action) => {
+ if(action.type === 'ADD_NEW_MESSAGE') {
+ return [...state, action.payload];
+ }
+
+ if(action.type === 'ADD_BATCH_MESSAGES') {
+ return [...state, ...action.payload];
+ }
+
+ if(action.type === 'CLEAR_NEW_MESSAGE') {
+ //let list_message_ids: number[] = action.payload;
+ //return state.filter(mes => !list_message_ids.includes(mes.id));
+ return [];
+ }
+
+ return state;
+}
+
+
+const updateUserInfo = (state: UserInfo[] = [], action: Action) => {
+ if(action.type === 'ADD_USER' ) {
+ // make sure user is not already in the list
+ let { index, item } = findObjectInArray(state, "id", action.payload.id);
+ if(index > -1) {
+ let new_item = {...item, ...action.payload};
+ let new_state = [...state];
+ new_state[index] = new_item;
+ return new_state;
+ }
+
+ return [...state, action.payload];
+ }
+
+ if(action.type === 'UPDATE_USER_INFO') {
+ let { index, item } = findObjectInArray(state, "id", action.payload.id);
+ if(index > -1) {
+ let new_item = {...item, ...action.payload};
+
+ if(action.payload.chatbox) {
+ // and add user to top of the list (if we change chatbox:true, we want to chat with this user right way, not be restricted by screen width)
+ let new_state = state.filter(user => user.id !== new_item.id);
+ new_state.push(new_item);
+ return new_state;
+ }else{
+ let new_state = [...state];
+ new_state[index] = new_item;
+ return new_state;
+ }
+
+ }else{
+ // add user to list
+ return [...state, action.payload as UserInfo];
+ }
+ }
+
+ if(action.type === 'REMOVE_USER') {
+ let remove_user_id = action.payload.id;
+ return state.filter(user => user.id !== remove_user_id);
+ }
+
+ return state;
+}
+
+
+const updateAdminInfo = (state: AdminInfo[] = [], action: Action) => {
+
+ if(action.type === 'ADD_ADMIN') {
+
+ // check if we are adding in bulk
+ let list_admin: AdminInfo[] = [];
+ if(Array.isArray(action.payload)) {
+ list_admin = action.payload as AdminInfo[];
+ }else{
+ list_admin.push(action.payload);
+ }
+
+ const new_admin = list_admin.filter((admin) => {
+ let { index } = findObjectInArray(state, "id", admin.id);
+ return (index === -1);
+ });
+
+ if(new_admin.length > 0) {
+ return [...state, ...new_admin];
+ }
+
+ return state;
+ }
+
+ if(action.type === 'UPDATE_ADMIN_INFO') {
+ let { index, item } = findObjectInArray(state, "id", action.payload.id);
+ if(index > -1) {
+ let new_item = {...item, ...action.payload};
+ let new_state = [...state];
+ new_state[index] = new_item;
+ return new_state;
+ }
+ }
+
+ return state;
+}
+
+
+const updateCurrentMessages = (
+ state: {[user_id: string]: ChatboxTextMessage[]} = {},
+ action: {type: ActionType, payload: {[user_id: string]: (ChatboxTextMessage[] | StatusMessageType) } }
+) => {
+
+ // add new messages sent by the user
+ if(action.type === 'ADD_CURRENT_MESSAGE') {
+ let new_content: {[user_id: string]: ChatboxTextMessage[] } = {};
+ for (let user_id in action.payload) {
+ const data = action.payload[user_id];
+ if(Array.isArray(data)) {
+ new_content[user_id] = state[user_id] ? [...state[user_id], ...data] : data;
+ }
+ }
+
+ return (isEmpty(new_content)) ? state : {...state, ...new_content};
+ }
+
+ // update message's read/status/delivery ...
+ if(action.type === 'UPDATE_CURRENT_MESSAGE') {
+ let new_content: {[user_id: string]: ChatboxTextMessage[] } = {};
+ for (let user_id in action.payload) {
+ const data = action.payload[user_id] as StatusMessageType;
+ const { isRead , deliveryStatus } = data;
+ const current_messages = state[user_id] ;
+
+ let { index, item } = findObjectInArray(current_messages, "id", data.msg_id);
+ if(index > -1) {
+ let new_item = {...item, ...{ isRead , deliveryStatus }} as ChatboxTextMessage;
+ let updated_messages = [...current_messages];
+ updated_messages[index] = new_item;
+ new_content[user_id] = updated_messages;
+ }
+ }
+
+ return {...state, ...new_content}
+
+ }
+
+ // add old messages
+ if(action.type === 'ADD_HISTORY_MESSAGE') {
+ let new_content: {[user_id: string]: ChatboxTextMessage[] } = {};
+ for (let user_id in action.payload) {
+ const data = action.payload[user_id];
+ if(Array.isArray(data)) {
+ new_content[user_id] = state[user_id] ? [...data, ...state[user_id]] : data;
+ }
+ }
+
+ return (isEmpty(new_content)) ? state : {...state, ...new_content};
+ }
+
+ return state;
+}
+
+
+const pickChatWithUser = (state: string = '', action: Action) => {
+ if(action.type === 'CHAT_WITH_USER') {
+ return action.payload
+ }
+
+ return state;
+}
+
+
+// redux summary:
+// rootReducer is the composition of all pure functions registered beforehand to a store
+// any dispatched action by store will search in the rootReducer to find corresponding function to modify state
+// reducers can be added dynamically: see https://redux.js.org/recipes/code-splitting
+
+const combinedReducer = combineReducers({
+ userVisibilityState: updateUserVisibilityState,
+ network_connection: updateNetworkingStatus,
+ node_connection: updateNodeConnection,
+ user_list: updateUserInfo,
+ chatting_with_user: pickChatWithUser,
+ admin_list: updateAdminInfo,
+ current_messages: updateCurrentMessages,
+ new_messages: updateNewMessage,
+ new_requests: updateNewRequest,
+ stats: updateStat,
+ global_modal: openGlobalModal,
+ global_drawer: openGlobalDrawer,
+});
+
+export const defaultState: AppState = {
+ userVisibilityState: 'visible',
+ network_connection: 'online',
+ node_connection: 'error',
+ user_list: [],
+ chatting_with_user: '',
+ admin_list: [],
+ current_messages: {},
+ new_messages: [],
+ new_requests: [],
+ stats: JSON.stringify({
+ user_online: 0,
+ admin_online: 0
+ }),
+ global_modal: '',
+ global_drawer: ''
+}
+
+
+function crossSliceReducer(state: AppState = defaultState, action: Action) {
+ switch (action.type) {
+ case 'UPDATE_USER_LIST_STATUS': {
+ let user_status_list: {[key: string]: boolean} = action.payload;
+ let new_user_list: UserInfo[] = [...state.user_list];
+ //let new_new_requests: {user: UserChatRequest, new_messages: ServerMessage[];}[] = [...state.new_requests];
+
+ let state_change = false;
+
+ for (const [user_id, online_status] of Object.entries(user_status_list)) {
+ // check if user_id in new_user_list
+ //let has_found = false;
+ for ( let i=0; i< new_user_list.length; i++) {
+ if(new_user_list[i].id === user_id) {
+ new_user_list[i].online = online_status;
+ //has_found = true;
+ state_change = true;
+ break;
+ }
+ }
+
+ // check in new_request
+ /*if(!has_found) {
+ for ( let i=0; i< new_new_requests.length; i++) {
+ if(new_new_requests[i].user.id === user_id) {
+ new_new_requests[i].user.online = online_status;
+ has_found = true;
+ state_change = true;
+ break;
+ }
+ }
+ }*/
+ }
+
+ if(state_change) {
+ return {
+ ...state,
+ user_list: new_user_list,
+ //new_requests: new_new_requests,
+ }
+ }
+
+ // nothing change
+ return state;
+ }
+ default:
+ return state
+ }
+}
+
+export function rootReducer(state: AppState = defaultState, action: Action) {
+ const intermediateState = combinedReducer(state, action);
+ return crossSliceReducer(intermediateState, action) ;
+}
diff --git a/src/store/typing.d.ts b/src/store/typing.d.ts
new file mode 100644
index 0000000..774760b
--- /dev/null
+++ b/src/store/typing.d.ts
@@ -0,0 +1,45 @@
+import {UserInfo, UserChatRequest, AdminInfo} from "@/typings/user";
+import {ChatboxTextMessage, ServerMessage} from "@/typings/message";
+
+export type UserStatusType = 'available' | 'idle' | 'busy' | 'offline';
+export type NetworkingStatusType = 'online' | 'offline';
+export type NodeConnectionStatusType = 'ok' | 'error' | 'reconnected';
+
+export interface AppState {
+ userVisibilityState: VisibilityState;
+ network_connection: NetworkingStatusType; // user's internet is working or not?
+ node_connection: NodeConnectionStatusType; // connection to node
+ user_list: UserInfo[];
+ chatting_with_user: string;
+ admin_list: AdminInfo[];
+ // hold all current messages in all chat sessions so we Chatboxes load we can re-use
+ // store in storage but keep max size = 500 messages (all latest)
+ current_messages: {[user_id: string]: ChatboxTextMessage[]};
+ new_messages: ServerMessage[]; // all unread messages or system-updated messages
+ new_requests: {user: UserChatRequest, new_messages: ServerMessage[]}[] ;
+ // since we use entire stats, make it string so react can make comparison on value change, object cann't compare!
+ stats: string; // {[key: string]: number};
+ global_modal: string; // {[key: string]: number};
+ global_drawer: string; // {[key: string]: number};
+}
+
+
+export interface Action {
+ type: ActionType;
+ payload: any;
+}
+
+export type ActionType = 'UPDATE_NETWORKING_STATUS' | 'UPDATE_NODE_CONNECTION' |
+ 'UPDATE_USER_STATUS' | 'UPDATE_USER_LIST_STATUS' |
+ 'ADD_CHAT_REQUEST' | 'UPDATE_CHAT_REQUEST' | 'REMOVE_CHAT_REQUEST' |
+ 'CHAT_WITH_USER' | 'ADD_USER' |
+ 'ADD_NEW_REQUEST_MESSAGE' |
+ 'ADD_NEW_MESSAGE' |
+ 'ADD_CURRENT_MESSAGE' | 'ADD_HISTORY_MESSAGE' | 'UPDATE_CURRENT_MESSAGE' |
+ 'ADD_BATCH_MESSAGES' |
+ 'CLEAR_NEW_MESSAGE' |
+ 'UPDATE_ADMIN_INFO' | 'ADD_ADMIN' |
+ 'UPDATE_STATS' |
+ 'REMOVE_USER' |
+ 'UPDATE_USER_INFO' |
+ 'UPDATE_USER_VISIBILITY_STATE' | 'OPEN_GLOBAL_MODAL' | 'OPEN_GLOBAL_DRAWER';
diff --git a/src/styles/app.css b/src/styles/app.css
new file mode 100644
index 0000000..a360bdf
--- /dev/null
+++ b/src/styles/app.css
@@ -0,0 +1,37 @@
+@import '~antd/dist/antd.css';
+
+body {
+ margin: 0;
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
+ 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
+ sans-serif;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+code {
+ font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
+}
+
+.ant-layout-header {
+ background: black;
+}
+
+.ant-layout-sider {
+ background: white;
+}
+
+.ant-layout-content {
+ background: white;
+}
+
+.scrollable {
+ overflow-y: scroll;
+ -webkit-overflow-scrolling: touch;
+}
+
+/* use for SPAN in place of A tag */
+.a-link {
+ cursor: pointer;
+ color: #007aff;
+}
diff --git a/src/test/index.ts b/src/test/index.ts
new file mode 100644
index 0000000..dc2b6b6
--- /dev/null
+++ b/src/test/index.ts
@@ -0,0 +1,5 @@
+import {testState} from "./test_state";
+
+export function runTest() {
+ testState();
+}
diff --git a/src/test/test_state.ts b/src/test/test_state.ts
new file mode 100644
index 0000000..f2ca464
--- /dev/null
+++ b/src/test/test_state.ts
@@ -0,0 +1,17 @@
+// 19-05-2021: For dev
+
+import { UserInfo} from "@/typings/user";
+
+export const user_list: UserInfo[] = [
+ {id: '12312312312', name: 'Minh Hieu', node: 'node1', online: false, location: 'Hà nội', avatar: 'https://randomuser.me/api/portraits/men/40.jpg'},
+ {id: "12312312334", name: 'Phuong Thao', node: 'node1', online: true, location: 'Sài gòn', avatar: 'https://randomuser.me/api/portraits/women/51.jpg'},
+ {id: "192929293", name: 'Dang Minh', node: 'node1', online: true, location: 'Mỹ'},
+ {id: "29292929292", name: 'Hong Diep', node: 'node1', online: true, location: 'Việt trì', avatar: 'https://randomuser.me/api/portraits/women/31.jpg'},
+ {id: "292929292", name: 'Minh Ty', node: 'node1', online: true, location: 'Bắc Ninh'},
+ {id: "99992929", name: 'Maika', node: 'node1', online: true, location: 'Cần thơ', avatar: 'https://randomuser.me/api/portraits/women/31.jpg'},
+];
+
+
+export function testState() {
+
+}
diff --git a/src/test/test_util.ts b/src/test/test_util.ts
new file mode 100644
index 0000000..638e118
--- /dev/null
+++ b/src/test/test_util.ts
@@ -0,0 +1,3 @@
+export function testThrottle(elapse: number = 5000) {
+
+}
diff --git a/src/typings/index.ts b/src/typings/index.ts
new file mode 100644
index 0000000..d4f98e0
--- /dev/null
+++ b/src/typings/index.ts
@@ -0,0 +1,100 @@
+import {AdminInfo} from "@/typings/user";
+
+export type ClientSettings = {
+ staff: AdminInfo[];
+}
+
+export type EventType = 'create_note' | 'create_support' | 'create_order';
+
+export type OrderStatusType = 'pending' | 'fail' | 'success' | 'processing';
+export type ShippingStatusType = 'pending' | 'fail' | 'success' | 'processing';
+export type PaymentStatusType = 'pending' | 'fail' | 'success' | 'processing';
+
+
+export type BrowseInfo = {
+ id: string,
+ domain: string,
+ url: string,
+ title: string,
+ create_time: number,
+}
+
+export type NoteInfo = {
+ id?: string,
+ api_id?: string,
+ content?: string,
+ admin_id?: string,
+ admin_name?: string,
+ create_time?: number,
+}
+
+export type SupportInfo = {
+ id?: string,
+ api_id?: string,
+ title?: string,
+ description?: string,
+ admin_id?: string,
+ admin_name?: string,
+ status?: number,
+ create_time?: number,
+}
+
+export type OrderInfo = {
+ api_id?: string,
+ customer?: {
+ crm_code?: string,
+ name?: string,
+ email?: string,
+ mobile?: string,
+ address?: string,
+ province?: string,
+ },
+ products?: {
+ id?: number,
+ sku?: string,
+ name?: string,
+ quantity?: number,
+ price?: number,
+ note?: string
+ }[],
+ others?: {
+ name?: string,
+ price?: number,
+ }[],
+ shipping?: {
+ provider?: string,
+ reference?: string,
+ note?: string,
+ date?: string,
+ time?: string,
+ },
+ payment?: {
+ method?: string,
+ reference?: string,
+ note?: string,
+ },
+ note?: string,
+ tags?: string[],
+ order_status?: OrderStatusType,
+ payment_status?: PaymentStatusType,
+ shipping_status?: ShippingStatusType,
+ total_value?: number,
+ create_time?: number,
+}
+
+
+export interface OpenHelpComponent {
+ type: HelpType,
+ params?: { [key: string] : any }
+}
+
+export type HelpItem = {
+ id?: number,
+ name?: string;
+ img?: string;
+ summary?: string;
+ price?: number;
+ in_stock?: boolean;
+}
+
+export type HelpType = 'product-list' | 'product-detail' | 'article-list' | 'article-detail' | 'search' | 'home';
diff --git a/src/typings/message.d.ts b/src/typings/message.d.ts
new file mode 100644
index 0000000..af0eb96
--- /dev/null
+++ b/src/typings/message.d.ts
@@ -0,0 +1,175 @@
+// this definition file should be the same between: node_app / browser app / admin chat
+// timestamp to compare:
+// last change: 17-Feb-2021 06.51am
+
+export type UserMessage = UserMessageText | // user sends normal text message to other users
+ UserMessageNotify | // user's client sends update: user events, message status ...
+ UserMessageBot; // user sends answer to bot message
+
+export type ChatboxTextMessage = {
+ id: string;
+ from: string;
+ content: string;
+ time: number;
+ sequence?: number;
+ isRead?: boolean;
+ deliveryStatus?: MessageDeliveryStatus;
+};
+
+export type MessageDeliveryStatus = 0 | // message sent to server by user for delivery to the destination
+ 2 | // message received by server and it preparing to deliver
+ 3 | // client not received the ack from server that it has received the message: whatever reason. Here client can try resend.
+ 4 | // message delivered successfully and other user has received it
+ 5 ; // failed: message delivered by server but other user has not received it ()
+
+export type ServerMessage = ServerMessageBroker | // server acts as a broker passing messages from other clients
+ ServerMessageNotify | // server directly sends notification message: user typing, network problem, etc ...
+ ServerMessageCommand | // server directly sends command messages: reload client, change node ...
+ ServerMessageBot; // server sends bot message
+
+
+type ServerMessageBroker = {
+ type: 'broker';
+ to: string;
+ time: number;
+ checksum?: number;
+ message_id: string;
+ from: string;
+ content: string;
+}
+
+export type StatusMessageType = {
+ msg_id: string;
+ isRead?: boolean;
+ deliveryStatus?: MessageDeliveryStatus;
+}
+
+type ServerMessageNotify = {
+ type: 'notify';
+ to: string;
+ time: number;
+ checksum?: number;
+ content: NotifyMessageStatus | NotifyMessageTyping | NotifyMessageChatRequestSent | NotifyMessageChatRequestAccepted | NotifyMessageUserNetwork;
+}
+
+type ServerMessageCommand = {
+ type: 'command';
+ to: string;
+ time: number;
+ checksum?: number;
+ content: {
+ type: string; // possibly: send heartbeat | reload-chat-client
+ content?: any;
+ };
+}
+
+// bot message
+type ServerMessageBot = {
+ type: 'bot';
+ to: string;
+ time: number;
+ checksum?: number;
+ content: ServerMessageContentBotQuestion | ServerMessageContentBotAnswer;
+};
+
+type ServerMessageContentBotQuestion = {
+ type: 'question';
+ suggestion_id: number;
+ question: string;
+ suggested_answers?: { id: number; content: string }[];
+}
+
+type ServerMessageContentBotAnswer = {
+ type: 'answer';
+ text: string;
+}
+
+// notify content
+type NotifyMessageStatus = {
+ type: 'message-status';
+ content: {
+ msg_id: string;
+ msg_sender: string;
+ isRead?: boolean;
+ deliveryStatus?: MessageDeliveryStatus;
+ local_sequence?: number;
+ };
+}
+
+type NotifyMessageTyping = {
+ type: 'typing' ;
+ content: { user_id: string; typing: boolean };
+}
+
+type NotifyMessageChatRequestSent = {
+ type: 'chat-request';
+ content: {
+ status: 'sending' | 'failed' | 'received';
+ }
+}
+
+type NotifyMessageChatRequestAccepted = {
+ type: 'chat-request-accepted';
+ content: {
+ request_id: string,
+ admin_info: {
+ id: string;
+ avatar?: string;
+ name: string;
+ node: string;
+ },
+ };
+}
+
+// used by admin user
+type NotifyMessageAcceptChatRequest = {
+ type: 'accept-chat-request';
+ content: {
+ user_info: {
+ id: string;
+ node?: string;
+ }
+ admin_info: {
+ id: string;
+ avatar?: string;
+ name: string;
+ node: string;
+ },
+ };
+}
+
+type NotifyMessageUserNetwork = {
+ type: 'user-network';
+ content: {
+ user_id: string;
+ is_admin: boolean;
+ online: boolean ;
+ };
+}
+
+// detailed user message types
+type UserMessageText = {
+ type: 'text';
+ checksum?: number;
+ content: {
+ to: string;
+ text: string;
+ local_sequence?: number;
+ };
+}
+
+type UserMessageNotify = {
+ type: 'notify';
+ checksum?: number;
+ content: NotifyMessageStatus | NotifyMessageTyping | NotifyMessageChatRequestAccepted | NotifyMessageAcceptChatRequest;
+}
+
+type UserMessageBot = {
+ type: 'bot';
+ checksum?: number;
+ content: {
+ question_id: number;
+ selected_answer: number;
+ answer?: string;
+ };
+}
diff --git a/src/typings/network.d.ts b/src/typings/network.d.ts
new file mode 100644
index 0000000..1e93e14
--- /dev/null
+++ b/src/typings/network.d.ts
@@ -0,0 +1,7 @@
+export type ConnectionToServerStatusType = 'disconnect' | 'connect' | 'connect_error';
+
+export interface APIResponse {
+ errCode:number,
+ msg: string,
+ data: any
+}
diff --git a/src/typings/user.d.ts b/src/typings/user.d.ts
new file mode 100644
index 0000000..74bb980
--- /dev/null
+++ b/src/typings/user.d.ts
@@ -0,0 +1,79 @@
+declare global {
+ interface Window {
+ admin_info: AdminInfo;
+ }
+}
+
+export type UserDeviceType = 'mobile' | 'desktop';
+
+export type UserPopupType = 'info'| 'chat-history' | 'notes' | 'web-view' | 'list-online-user' | 'list-online-admin' | 'search-mini-web' | '';
+
+
+export interface Chatngay {
+ send: (message: Message) => any;
+ connect: () => void;
+ disconnect: () => void;
+}
+
+export interface UserInfo {
+ id: string;
+ name?: string;
+ email?: string;
+ avatar?: string;
+ tel?: string;
+ node?: string;
+ online?: boolean;
+ location?: string;
+ chatbox?: boolean;
+ typing?: boolean;
+ crm_code?: string;
+ note?: string;
+}
+
+export interface UserInfoCRM {
+
+}
+
+
+export interface UserChatRequest {
+ id: string;
+ name?: string;
+ node?: string;
+ online?: boolean;
+}
+
+export type UserInfoOnline = {
+ user_id: string;
+ user_name: string;
+ city: string;
+ user_ip: string;
+ current_view_page: string;
+ create_time: number;
+ chat_with: string;
+}
+
+export type AdminInfoOnline = AdminInfo & {last_active: number};
+
+
+export interface GroupInfo {
+ id: string;
+ avatar?: string;
+ name: string;
+ greeting?: string;
+}
+
+
+export interface AdminInfo {
+ client_id?: string;
+ id: string;
+ avatar?: string;
+ name: string;
+ greeting?: string;
+ group_id?: string;
+ jwt?: string;
+ node?: string;
+ online?: boolean;
+ status?: string;
+}
+
+
diff --git a/src/typings/websocket.d.ts b/src/typings/websocket.d.ts
new file mode 100644
index 0000000..90316f5
--- /dev/null
+++ b/src/typings/websocket.d.ts
@@ -0,0 +1,8 @@
+export interface ConnectedWebSocket extends WebSocket{
+ // https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/close
+ close: (code?: number, reason?: string) => void;
+ // https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/send
+ send: (data: any) => void;
+ url: string;
+}
+