up
This commit is contained in:
25
.gitignore
vendored
Normal file
25
.gitignore
vendored
Normal file
@@ -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*
|
||||
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
46
public/index.html
Normal file
46
public/index.html
Normal file
@@ -0,0 +1,46 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Web site created using create-react-app"
|
||||
/>
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
|
||||
<title>Chatngay - Chatboard</title>
|
||||
|
||||
<script>
|
||||
window.admin_info = {
|
||||
client_id : 'asSda67',
|
||||
id : '01EEyUKqzYnTaQuD',
|
||||
jwt : 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJjaGF0bmdheS5jb20iLCJ1c2VyX2luZm8iOnsiaWQiOiIwMUVFeVVLcXpZblRhUXVEIiwiY2xpZW50IjoiYXNTZGE2NyIsIm5vZGUiOiJub2RlMSIsImlzX2FkbWluIjp0cnVlfSwiaWF0IjoxNjE2OTE0MjE5fQ.gzYEGMp-j4VETE-FKyeeKoeI_lkHQFV38V59JX92H4k',
|
||||
name : "Nhật Minh",
|
||||
group_id : "8",
|
||||
greeting : 'Chào mừng quý khách',
|
||||
avatar : 'https://chatngay-static.glee.vn/avatar/120_a1.jpg',
|
||||
node: 'node1',
|
||||
};
|
||||
</script>
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<!--
|
||||
This HTML file is a template.
|
||||
If you open it directly in the browser, you will see an empty page.
|
||||
|
||||
You can add webfonts, meta tags, or analytics to this file.
|
||||
The build step will place the bundled scripts into the <body> tag.
|
||||
|
||||
To begin the development, run `npm start` or `yarn start`.
|
||||
To create a production bundle, use `npm run build` or `yarn build`.
|
||||
-->
|
||||
</body>
|
||||
</html>
|
||||
BIN
public/logo192.png
Normal file
BIN
public/logo192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
BIN
public/logo512.png
Normal file
BIN
public/logo512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
25
public/manifest.json
Normal file
25
public/manifest.json
Normal file
@@ -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"
|
||||
}
|
||||
3
public/robots.txt
Normal file
3
public/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
49
public/static/webworker_client.js
Normal file
49
public/static/webworker_client.js
Normal file
@@ -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();
|
||||
});
|
||||
}
|
||||
165
src/App.tsx
Normal file
165
src/App.tsx
Normal file
@@ -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<boolean>(false);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
if( ! user ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const userInputRef = createRef<HTMLInputElement>();
|
||||
const updateName = () => {
|
||||
let new_name = userInputRef.current?.value;
|
||||
dispatch(actions.updateUserInfo({
|
||||
id: user.id,
|
||||
name: new_name,
|
||||
}));
|
||||
setFormOpen(false);
|
||||
}
|
||||
|
||||
if(!openForm) {
|
||||
return (
|
||||
<Fragment>
|
||||
<BadgeStatus online={user.online || false}>
|
||||
{user.name}
|
||||
{
|
||||
user.typing && <Image preview={false} style={{height:20}} src={TYPING_ANIMATION_IMAGE} />
|
||||
}
|
||||
</BadgeStatus> ( <EditOutlined onClick={() => setFormOpen(true)} title={'Thay đổi tên'} /> )
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<input type={'text'} size={30} ref={userInputRef} defaultValue={user.name} /> <Button onClick={updateName}>OK</Button>
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
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 <DashBoard />
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<div>
|
||||
<UserNameForm user={chatting_user_info} />
|
||||
</div>
|
||||
|
||||
<Row>
|
||||
<Col span={12}>
|
||||
{/* add key to force to component to remount when user id change to simplify the component's code*/}
|
||||
<Chatbox key={chatting_user_info.id} user_info={chatting_user_info} />
|
||||
</Col>
|
||||
|
||||
<Col span={12} className="scrollable" style={{height: 600, overflow: 'auto'}}>
|
||||
<h3 style={{fontSize:16, fontWeight:'bold'}}>Lựa chọn</h3>
|
||||
|
||||
<ActionTabs customer_id={chatting_user_info.id} />
|
||||
|
||||
<h3 style={{fontSize:16, fontWeight:'bold'}}>Thông tin khách hàng</h3>
|
||||
|
||||
<CustomerInfo user_info={chatting_user_info} />
|
||||
|
||||
</Col>
|
||||
</Row>
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
const App: FC<{client_setting: ClientSettings}> = ({client_setting}) => {
|
||||
|
||||
const HEADER_HEIGHT = 70;
|
||||
const [closeHelp, setHelpClose] = useState<boolean>(false);
|
||||
const LAYOUT_CLOSED = closeHelp ? {width:1200, marginLeft:'auto', marginRight: 'auto'} : {marginTop: HEADER_HEIGHT, height: WINDOW_HEIGHT - HEADER_HEIGHT, overflow: 'auto'};
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<Header style={{ position: 'fixed', zIndex: 1, width: '100%' }}>
|
||||
<HeaderComponent />
|
||||
</Header>
|
||||
<Layout style={LAYOUT_CLOSED}>
|
||||
|
||||
<Sider width={250} className="scrollable">
|
||||
<ConversationList />
|
||||
</Sider>
|
||||
|
||||
<Content>
|
||||
<ShowUserSpace />
|
||||
</Content>
|
||||
|
||||
{
|
||||
!closeHelp &&
|
||||
<Sider width={400} >
|
||||
<h2>Trợ giúp <CloseOutlined onClick={() => setHelpClose(true)} title={'Đóng'} /> </h2>
|
||||
<HelpSideBar />
|
||||
</Sider>
|
||||
}
|
||||
</Layout>
|
||||
|
||||
<NetworkError />
|
||||
<GlobalModal />
|
||||
<GlobalDrawer />
|
||||
|
||||
</Layout>
|
||||
)
|
||||
};
|
||||
|
||||
export default App;
|
||||
BIN
src/assets/typing-animation.gif
Normal file
BIN
src/assets/typing-animation.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 95 KiB |
123
src/components/ActionTabs/components/CreateLeadForm.tsx
Normal file
123
src/components/ActionTabs/components/CreateLeadForm.tsx
Normal file
@@ -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 (
|
||||
<Form form={form} name="complex-form" onFinish={onFinish} labelCol={{ span: 4 }} wrapperCol={{ span: 20 }}>
|
||||
|
||||
<Form.Item label="Họ tên">
|
||||
<Input.Group compact>
|
||||
<Form.Item
|
||||
name={['user', 'gender']}
|
||||
noStyle
|
||||
>
|
||||
<SelectWithList
|
||||
placeholder="Giới tính"
|
||||
options={GENDER_LIST}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={['user', 'name']}
|
||||
noStyle
|
||||
rules={[{ required: true, message: 'Họ tên is required'}]}
|
||||
>
|
||||
<Input style={{ width: '50%' }} placeholder="Họ tên" />
|
||||
</Form.Item>
|
||||
</Input.Group>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Email">
|
||||
<Form.Item
|
||||
name={['user', 'email']}
|
||||
noStyle
|
||||
rules={[
|
||||
{
|
||||
type: 'email',
|
||||
message: 'Email chưa đúng',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input style={{ width: 160 }} placeholder="email" />
|
||||
</Form.Item>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Điện thoại">
|
||||
<Form.Item
|
||||
name={['user', 'mobile']}
|
||||
noStyle
|
||||
rules={[{ required: true, message: 'Cần nhập' }]}
|
||||
>
|
||||
<Input style={{ width: 160 }} placeholder="mobile" />
|
||||
</Form.Item>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Địa chỉ">
|
||||
<Input.Group compact>
|
||||
<Form.Item
|
||||
name={['user', 'address']}
|
||||
noStyle
|
||||
>
|
||||
<Input style={{ width: '50%' }} placeholder="" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={['user', 'province']}
|
||||
noStyle
|
||||
>
|
||||
<SelectWithList
|
||||
placeholder="Tỉnh thành"
|
||||
options={PROVINCE_LIST}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Input.Group>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Công ty">
|
||||
<Form.Item
|
||||
name="tel"
|
||||
noStyle
|
||||
>
|
||||
<Input style={{ width: 160 }} placeholder="Please input" />
|
||||
</Form.Item>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Website">
|
||||
<Form.Item
|
||||
name="tel"
|
||||
noStyle
|
||||
>
|
||||
<Input style={{ width: 160 }} placeholder="Please input" />
|
||||
</Form.Item>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name={['user', 'introduction']} label="Ghi chú">
|
||||
<Input.TextArea />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label=" " colon={false}>
|
||||
<Button type="primary" htmlType="submit">
|
||||
Submit
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
export default LeadForm;
|
||||
63
src/components/ActionTabs/components/CreateNoteForm.tsx
Normal file
63
src/components/ActionTabs/components/CreateNoteForm.tsx
Normal file
@@ -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 (
|
||||
<Form form={form} name="complex-form" onFinish={onFinish} wrapperCol={{ span: 24 }}>
|
||||
<Form.Item
|
||||
name={'crm_code'}
|
||||
noStyle
|
||||
hidden
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name={'note'} rules={[{ required: true, message: 'required' }]}>
|
||||
<Input.TextArea
|
||||
placeholder={'Nhập ghi chú và nhấn phím Enter để gửi'}
|
||||
autoSize={{ minRows: 2, maxRows: 5 }}
|
||||
allowClear
|
||||
onPressEnter={(e) => {
|
||||
e.preventDefault();
|
||||
form.submit();
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
|
||||
export default NoteForm;
|
||||
429
src/components/ActionTabs/components/CreateOrderForm.tsx
Normal file
429
src/components/ActionTabs/components/CreateOrderForm.tsx
Normal file
@@ -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<ProductInfo[]>([]);
|
||||
const [form] = Form.useForm();
|
||||
const taggingRef = createRef<Tagging>();
|
||||
|
||||
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 (
|
||||
<Option key={item.id} price={item.price} sku={item.sku} name={item.name} value={item.id}>
|
||||
{item.name} - ({item.price} - {item.in_stock ? 'in-stock' : 'out-of-stock'})
|
||||
</Option>
|
||||
)
|
||||
};
|
||||
|
||||
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 (
|
||||
<Form form={form} name="complex-form" onFinish={onFinish} labelCol={{ span: 4 }} wrapperCol={{ span: 20 }}>
|
||||
|
||||
<Form.Item
|
||||
name={['customer', 'crm_code']}
|
||||
noStyle
|
||||
hidden
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
|
||||
|
||||
<Form.Item label="Họ tên">
|
||||
<Input.Group compact>
|
||||
<Form.Item
|
||||
name={['customer', 'gender']}
|
||||
noStyle
|
||||
>
|
||||
<SelectWithList
|
||||
placeholder="Giới tính"
|
||||
options={GENDER_LIST}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={['customer', 'name']}
|
||||
noStyle
|
||||
rules={[{ required: true, message: 'Họ tên'}]}
|
||||
>
|
||||
<Input style={{ width: '50%' }} placeholder="Họ tên" />
|
||||
</Form.Item>
|
||||
</Input.Group>
|
||||
</Form.Item>
|
||||
<Form.Item label="Email">
|
||||
<Form.Item
|
||||
name={['customer', 'email']}
|
||||
noStyle
|
||||
rules={[
|
||||
{
|
||||
type: 'email',
|
||||
message: 'Email chưa đúng',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input style={{ width: 160 }} placeholder="Email" />
|
||||
</Form.Item>
|
||||
</Form.Item>
|
||||
<Form.Item label="Điện thoại">
|
||||
<Form.Item
|
||||
name={['customer', 'mobile']}
|
||||
noStyle
|
||||
>
|
||||
<Input style={{ width: 160 }} placeholder="mobile" />
|
||||
</Form.Item>
|
||||
</Form.Item>
|
||||
<Form.Item label="Địa chỉ">
|
||||
<Input.Group compact>
|
||||
<Form.Item
|
||||
name={['customer', 'address']}
|
||||
noStyle
|
||||
>
|
||||
<Input style={{ width: '50%' }} placeholder="Địa chỉ" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={['customer', 'province']}
|
||||
noStyle
|
||||
>
|
||||
<SelectWithList
|
||||
placeholder="Tỉnh thành"
|
||||
options={PROVINCE_LIST}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Input.Group>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Thông tin đơn hàng">
|
||||
<Form.List name="products">
|
||||
{(fields, { add, remove }) => (
|
||||
<>
|
||||
{fields.map(({ key, name, fieldKey, ...restField }, index) => (
|
||||
<Space key={key} style={{ display: 'flex', marginBottom: 8 }} align="baseline">
|
||||
|
||||
<Form.Item
|
||||
{...restField}
|
||||
rules={[{ required: true, message: 'Chưa có sản phẩm' }]}
|
||||
>
|
||||
<SelectWithAjax
|
||||
placeholder={'Sản phẩm'}
|
||||
style={{ width: 200 }}
|
||||
onSelect={(value: string, option: any) => {
|
||||
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}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
{...restField}
|
||||
name={[name, 'name']}
|
||||
fieldKey={[fieldKey, 'name']}
|
||||
hidden
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
{...restField}
|
||||
name={[name, 'sku']}
|
||||
fieldKey={[fieldKey, 'sku']}
|
||||
hidden
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
{...restField}
|
||||
name={[name, 'id']}
|
||||
fieldKey={[fieldKey, 'id']}
|
||||
hidden
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
{...restField}
|
||||
name={[name, 'price']}
|
||||
fieldKey={[fieldKey, 'price']}
|
||||
>
|
||||
<Input style={{width: 90}} placeholder="giá" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
{...restField}
|
||||
name={[name, 'quantity']}
|
||||
fieldKey={[fieldKey, 'quantity']}
|
||||
rules={[
|
||||
{ required: true, message: 'Nhập số lượng' }
|
||||
]}
|
||||
initialValue={1}
|
||||
>
|
||||
<InputNumber style={{width: 50}} min={1} max={10} defaultValue={1} placeholder="sl" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
{...restField}
|
||||
name={[name, 'note']}
|
||||
>
|
||||
<Input style={{width: 120}} placeholder="Ghi chú" />
|
||||
</Form.Item>
|
||||
|
||||
<Popconfirm
|
||||
title="Bạn chắc chắn muốn xoá?"
|
||||
onConfirm={() => remove(name)}
|
||||
okText="Yes"
|
||||
cancelText="No"
|
||||
>
|
||||
<MinusCircleOutlined />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
))}
|
||||
<Form.Item style={{textAlign: 'center'}}>
|
||||
<Button type="dashed" style={{width: 350}} onClick={() => add()} block icon={<PlusOutlined />}>
|
||||
Thêm sản phẩm
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
</Form.List>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Hạng mục khác">
|
||||
<Form.List name="others">
|
||||
{(fields, { add, remove }) => (
|
||||
<>
|
||||
{fields.map(({ key, name, fieldKey, ...restField }) => (
|
||||
<Space key={key} style={{ display: 'flex', marginBottom: 8 }} align="baseline">
|
||||
|
||||
|
||||
<Form.Item
|
||||
{...restField}
|
||||
name={[name, 'name']}
|
||||
fieldKey={[fieldKey, 'name']}
|
||||
rules={[{ required: true, message: 'Chưa nhập tiêu đề' }]}
|
||||
>
|
||||
<SelectWithAddItem current_lists={['Phí ship', 'Thuế VAT', 'Đóng gói']} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
{...restField}
|
||||
name={[name, 'price']}
|
||||
fieldKey={[fieldKey, 'price']}
|
||||
rules={[{ required: true, message: 'price' }]}
|
||||
>
|
||||
<Input placeholder="giá" />
|
||||
</Form.Item>
|
||||
|
||||
<Popconfirm
|
||||
title="Bạn chắc chắn muốn xoá?"
|
||||
onConfirm={() => remove(name)}
|
||||
okText="Yes"
|
||||
cancelText="No"
|
||||
>
|
||||
<MinusCircleOutlined />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
))}
|
||||
<Form.Item style={{textAlign: 'center'}}>
|
||||
<Button type="dashed" style={{width: 350}} onClick={() => add()} block icon={<PlusOutlined />}>
|
||||
Thêm mục khác (thuế, phí ship, phí khác ...)
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
</Form.List>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Đơn vị vận chuyển">
|
||||
<Input.Group compact>
|
||||
<Space key={'ship-provider'} style={{ display: 'flex', marginBottom: 8 }} align="baseline">
|
||||
<Form.Item
|
||||
name={['shipping', 'provider']}
|
||||
noStyle
|
||||
>
|
||||
<SelectWithAddItem current_lists={['Grab', 'Giao hang nhanh', 'Giao hang tiet kiem']} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={['shipping', 'reference']}
|
||||
noStyle
|
||||
>
|
||||
<Input style={{width: 100}} placeholder="Mã tra cứu" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name={['shipping', 'note']}
|
||||
noStyle
|
||||
>
|
||||
<Input style={{width: 100}} placeholder="Ghi chú" />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
</Input.Group>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Thời gian ship">
|
||||
<Input.Group compact>
|
||||
<Space key={'ship-time'} style={{ display: 'flex', marginBottom: 8 }} align="baseline">
|
||||
<Form.Item
|
||||
name={['shipping', 'date']}
|
||||
noStyle
|
||||
>
|
||||
<DatePicker placeholder={'Chọn ngày'} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={['shipping', 'time']}
|
||||
noStyle
|
||||
>
|
||||
<TimePicker placeholder={'Chọn giờ'} format={'HH:mm'} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
</Input.Group>
|
||||
</Form.Item>
|
||||
|
||||
|
||||
<Form.Item label="Hình thức thanh toán">
|
||||
<Input.Group compact>
|
||||
<Space key={'payment'} style={{ display: 'flex', marginBottom: 8 }} align="baseline">
|
||||
<Form.Item
|
||||
name={['payment', 'method']}
|
||||
noStyle
|
||||
>
|
||||
<SelectWithAddItem current_lists={['COD', 'Chuyển khoản ngân hàng', 'Tiền mặt']} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={['payment', 'reference']}
|
||||
noStyle
|
||||
>
|
||||
<Input style={{width: 100}} placeholder="Mã tra cứu" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name={['payment', 'note']}
|
||||
noStyle
|
||||
>
|
||||
<Input style={{width: 100}} placeholder="Ghi chú" />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
</Input.Group>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name={'note'} label="Ghi chú">
|
||||
<Input.TextArea />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Tag">
|
||||
<Tagging ref={taggingRef} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label=" " colon={false}>
|
||||
<Button type="primary" htmlType="submit">
|
||||
Tạo đơn
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
export default OrderForm;
|
||||
146
src/components/ActionTabs/components/CreateSupportForm.tsx
Normal file
146
src/components/ActionTabs/components/CreateSupportForm.tsx
Normal file
@@ -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<boolean>(false);
|
||||
|
||||
const taggingRef = createRef<Tagging>();
|
||||
const imageRef = createRef<ImageUploadWithPreview>();
|
||||
|
||||
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 (
|
||||
<Form form={form} name="complex-form" onFinish={onFinish} labelCol={{ span: 6 }} wrapperCol={{ span: 18 }}>
|
||||
<Form.Item
|
||||
name={'crm_code'}
|
||||
noStyle
|
||||
hidden
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Tiêu đề">
|
||||
<Space>
|
||||
<Form.Item
|
||||
name="title"
|
||||
noStyle
|
||||
rules={[{ required: true, message: 'required' }]}
|
||||
//initialValue={'tieu de'}
|
||||
>
|
||||
<Input style={{width: 300}} placeholder="Nhập tiêu đề" />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
//initialValue={'mo ta'}
|
||||
name={'description'}
|
||||
label="Mô tả"
|
||||
rules={[{ required: true, message: 'required' }]}
|
||||
>
|
||||
<Input.TextArea autoSize={{minRows:3, maxRows: 20}} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Files">
|
||||
<ImageUploadWithPreview ref={imageRef} auth={admin_upload_auth} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Mức độ">
|
||||
<Input.Group compact>
|
||||
<Form.Item
|
||||
name={'priority'}
|
||||
noStyle
|
||||
rules={[{ required: true, message: 'required' }]}
|
||||
initialValue={'normal'}
|
||||
>
|
||||
<Select placeholder="Select priority" >
|
||||
<Option value="serious">Serious</Option>
|
||||
<Option value="normal">Normal</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Input.Group>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Gửi email khách hàng">
|
||||
<Form.Item
|
||||
name={'is_send_email'}
|
||||
noStyle
|
||||
valuePropName={'checked'}
|
||||
>
|
||||
<Checkbox onChange={(e) => {
|
||||
// e.preventDefault;
|
||||
setSendEmail(e.target.checked);
|
||||
}}>Có! Gửi bản 1 bản copy vào email khách hàng</Checkbox>
|
||||
</Form.Item>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item hidden={!sendEmail} label={'Nhập email'}>
|
||||
<Form.Item
|
||||
name="customer_email"
|
||||
noStyle
|
||||
rules={[{ type: 'email' }]}
|
||||
>
|
||||
<Input maxLength={50} style={{width: 200}} placeholder="Nhập email khách hàng" />
|
||||
</Form.Item>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Tag">
|
||||
<Tagging ref={taggingRef} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label=" " colon={false}>
|
||||
<Button type="primary" htmlType="submit">
|
||||
Tạo hỗ trợ
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
export default SupportForm;
|
||||
38
src/components/ActionTabs/components/CreateTag.tsx
Normal file
38
src/components/ActionTabs/components/CreateTag.tsx
Normal file
@@ -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<string[]>([]);
|
||||
|
||||
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 (
|
||||
<Tagging
|
||||
current_list={current_tags}
|
||||
createTag={handleCreateTag}
|
||||
deleteTag={handleRemoveTag}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default CreateTag;
|
||||
45
src/components/ActionTabs/index.tsx
Normal file
45
src/components/ActionTabs/index.tsx
Normal file
@@ -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 (
|
||||
<Tabs key={customer_id} onChange={(key) => null } defaultActiveKey={'note'} type="card">
|
||||
|
||||
<TabPane tab="Tạo ghi chú" key="note">
|
||||
<Suspense fallback={<Loading />} >
|
||||
<CreateNoteComponent customer_id={customer_id}/>
|
||||
</Suspense>
|
||||
</TabPane>
|
||||
|
||||
<TabPane tab="Tạo hỗ trợ" key="ticket">
|
||||
<Suspense fallback={<Loading />} >
|
||||
<CreateSupportComponent customer_id={customer_id}/>
|
||||
</Suspense>
|
||||
</TabPane>
|
||||
|
||||
<TabPane tab="Tạo đơn hàng" key="order">
|
||||
<Suspense fallback={<Loading />} >
|
||||
<CreateOrderComponent customer_id={customer_id}/>
|
||||
</Suspense>
|
||||
</TabPane>
|
||||
|
||||
<TabPane tab="Tag" key="tag">
|
||||
<Suspense fallback={<Loading />} >
|
||||
<CreateTag customer_id={customer_id}/>
|
||||
</Suspense>
|
||||
</TabPane>
|
||||
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
|
||||
export default ActionTabs;
|
||||
14
src/components/BadgeStatus/index.tsx
Normal file
14
src/components/BadgeStatus/index.tsx
Normal file
@@ -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 (
|
||||
<Badge dot color={online ? 'green' : 'red'}>
|
||||
{ children }
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
export default BadgeStatus;
|
||||
8
src/components/BadgeStatus/styles.css
Normal file
8
src/components/BadgeStatus/styles.css
Normal file
@@ -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;
|
||||
}
|
||||
4
src/components/Chatbox/index.tsx
Normal file
4
src/components/Chatbox/index.tsx
Normal file
@@ -0,0 +1,4 @@
|
||||
// import Sample from "./ver/Sample";
|
||||
import Chatbox from "./ver/Chatbox";
|
||||
|
||||
export default Chatbox;
|
||||
108
src/components/Chatbox/ver/Chatbox.css
Normal file
108
src/components/Chatbox/ver/Chatbox.css
Normal file
@@ -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;
|
||||
}
|
||||
311
src/components/Chatbox/ver/Chatbox.tsx
Normal file
311
src/components/Chatbox/ver/Chatbox.tsx
Normal file
@@ -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<ChatboxProps, ChatboxState> {
|
||||
|
||||
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<HTMLDivElement>;
|
||||
private scrollToHistoryDiv: React.RefObject<HTMLDivElement>;
|
||||
|
||||
constructor(props: ChatboxProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
loadingHistory: false
|
||||
}
|
||||
this.scrollBottomDiv = createRef<HTMLDivElement>();
|
||||
this.scrollToHistoryDiv = createRef<HTMLDivElement>();
|
||||
|
||||
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) => (<div key={key}>{txt}</div>);
|
||||
|
||||
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 (
|
||||
<div style={{fontStyle:'italic', fontSize:11, textAlign: 'center'}} >
|
||||
{
|
||||
list_messages.map((txt, index) => NotiMessage(txt, index))
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div style={{fontStyle:'italic', fontSize:11, textAlign: 'center'}} >Loading ...</div>
|
||||
)
|
||||
}
|
||||
|
||||
// default
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
render() {
|
||||
|
||||
const { network_connection, user_info, chat_messages} = this.props;
|
||||
const admin_info = getAdminInfo();
|
||||
|
||||
return (
|
||||
<Fragment >
|
||||
<div
|
||||
style={{height: 600, overflow: 'auto'}}
|
||||
onScroll={this.handleChatboxScroll}
|
||||
// onClick={listenForInChatAction}
|
||||
ref={this.scrollToHistoryDiv}
|
||||
className={'message-list'}
|
||||
>
|
||||
|
||||
<div className="message-list-container">
|
||||
|
||||
{ this.showLoadingHistory() }
|
||||
|
||||
{
|
||||
chat_messages.map((message, index) => <MessageItem key={message.time + '-' + index} is_me={admin_info.id === message.from} {...message} />)
|
||||
}
|
||||
|
||||
{ user_info.typing && <TypingNotification /> }
|
||||
</div>
|
||||
|
||||
{ this.showNotification() }
|
||||
|
||||
<div ref={this.scrollBottomDiv} />
|
||||
|
||||
</div>
|
||||
|
||||
<InputMessage
|
||||
sendMessage={this.sendMessage}
|
||||
network_connection={network_connection}
|
||||
user_info={user_info}
|
||||
/>
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
160
src/components/Chatbox/ver/InputMessage.tsx
Normal file
160
src/components/Chatbox/ver/InputMessage.tsx
Normal file
@@ -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 (
|
||||
<Fragment>
|
||||
{
|
||||
other_admin_online.length > 0
|
||||
? other_admin_online.map(item => {
|
||||
return (
|
||||
<>
|
||||
<BadgeStatus key={item.id} online><span style={{cursor:'pointer'}} onClick={() => alert(item.id)}>{item.name}</span> </BadgeStatus>
|
||||
</>
|
||||
)
|
||||
} )
|
||||
: <div style={{color: 'red'}}>Không có quản trị online</div>
|
||||
}
|
||||
</Fragment>
|
||||
)
|
||||
|
||||
};
|
||||
|
||||
|
||||
const InputMessage = ({network_connection, sendMessage, user_info} : {network_connection: NetworkingStatusType, sendMessage: (txt: string) => void, user_info: UserInfo}) => {
|
||||
|
||||
const [typed_message, setMessage] = useState<string>('');
|
||||
const [typing, setTyping] = useState<boolean>(false);
|
||||
const admin_info = getAdminInfo();
|
||||
const dispatch = useDispatch();
|
||||
const imageUploadWithPreviewRef = createRef<ImageUploadWithPreview>();
|
||||
|
||||
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 (
|
||||
<Fragment>
|
||||
<Input.TextArea
|
||||
// ref={this.inputRef}
|
||||
value={typed_message}
|
||||
onChange={({ target: { value }}) => 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 }}
|
||||
/>
|
||||
|
||||
|
||||
<Popconfirm
|
||||
title={() => <span>Chặn không cho người này liên hệ với công ty từ nay về sau (qua Chatngay).<br/>Bạn chắc chắn chứ?</span>}
|
||||
onConfirm={removeChat}
|
||||
//onCancel={() => {}}
|
||||
okText="Yes"
|
||||
cancelText="No"
|
||||
>
|
||||
<span style={{cursor: 'pointer'}} ><MinusCircleOutlined /> Forbid</span>
|
||||
</Popconfirm>
|
||||
|
||||
|
||||
<Popconfirm
|
||||
title={() => <span>Dừng chat sẽ loại bỏ khách hàng khỏi danh sách chat.<br/>Bạn chắc chắn muốn bỏ chứ?</span>}
|
||||
onConfirm={removeChat}
|
||||
//onCancel={() => {}}
|
||||
okText="Yes"
|
||||
cancelText="No"
|
||||
>
|
||||
<span style={{cursor: 'pointer'}} ><MinusCircleOutlined /> Dừng chat</span>
|
||||
</Popconfirm>
|
||||
|
||||
|
||||
|
||||
<Popover
|
||||
title="Chọn quản trị để chuyển chat"
|
||||
content={<PickAdminOnlineToTransfer />}
|
||||
trigger="click"
|
||||
>
|
||||
<span style={{cursor: 'pointer'}}><FastForwardOutlined />Chuyển người khác</span>
|
||||
</Popover>
|
||||
|
||||
|
||||
|
||||
<ImageUploadWithPreview
|
||||
ref={imageUploadWithPreviewRef}
|
||||
auth={admin_info.jwt||''}
|
||||
// current_list={SAMPLE_UPLOAD_FILE}
|
||||
uploadButton={<span title={'Upload file'} style={{cursor: 'pointer'}}><PaperClipOutlined /> Upload</span>}
|
||||
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();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Fragment>
|
||||
|
||||
)
|
||||
};
|
||||
|
||||
|
||||
export default InputMessage;
|
||||
108
src/components/Chatbox/ver/MessageItem.tsx
Normal file
108
src/components/Chatbox/ver/MessageItem.tsx
Normal file
@@ -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 (
|
||||
<div>
|
||||
<span dangerouslySetInnerHTML={{__html: content}} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (image !== '') {
|
||||
if(_isImageUploadedToOurServer(image)) {
|
||||
html = `<a href="${maskExternalUrl(image)}" title="Click để xem ảnh" target="_blank"><img src={image} alt={""}/></a>`
|
||||
|
||||
return (
|
||||
<div>
|
||||
<b>{from}</b><i>{showUnixTime(time_in_second)}</i>
|
||||
<span dangerouslySetInnerHTML={{__html: html}} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
html = `<a href="${maskExternalUrl(image)}" title="Click để xem ảnh" target="_blank">${image}</a>`
|
||||
return (
|
||||
<div>
|
||||
<b>{from}</b><i>{showUnixTime(time_in_second)}</i>
|
||||
<span dangerouslySetInnerHTML={{__html: html}} />
|
||||
<div style={{color: 'red'}}>(Ảnh chưa kiểm định. Cần thận trọng khi xem)</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if(validURL(content)) {
|
||||
html = `<a href="${maskExternalUrl(content)}" title="Click để truy cập link này" target="_blank">${content}</a>`
|
||||
return (
|
||||
<div>
|
||||
<b>{from}</b><i>{showUnixTime(time_in_second)}</i>
|
||||
<span dangerouslySetInnerHTML={{__html: html}} />
|
||||
<div style={{color: 'red'}}>(Link chưa kiểm định. Cần thận trọng khi xem)</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// todo: remove this
|
||||
let random_number = randomBetween(1, 100);
|
||||
let message_from_me = random_number % 2 === 0;
|
||||
|
||||
return (
|
||||
|
||||
<div className={[
|
||||
'message',
|
||||
`${message_from_me ? 'mine' : ''}`,
|
||||
'start',
|
||||
'end'
|
||||
//`${startsSequence ? 'start' : ''}`,
|
||||
//`${endsSequence ? 'end' : ''}`
|
||||
].join(' ')}>
|
||||
|
||||
<div className="timestamp">
|
||||
{from}: { showUnixTime(time_in_second) }
|
||||
</div>
|
||||
|
||||
<div className="bubble-container">
|
||||
<div className="bubble" style={{color: (deliveryStatus !== 4) ? status_colors['sending'] : ''}} >
|
||||
{ content }
|
||||
</div>
|
||||
</div>
|
||||
{
|
||||
deliveryStatus === 5 && (<div style={{color: 'red'}}>(Lỗi xảy ra, tin nhắn chưa được gửi)</div>)
|
||||
}
|
||||
|
||||
<div>deliveryStatus :{deliveryStatus} | sq:{props.sequence}</div>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// 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))
|
||||
}
|
||||
219
src/components/Chatbox/ver/Sample.tsx
Normal file
219
src/components/Chatbox/ver/Sample.tsx
Normal file
@@ -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 (
|
||||
<div className="compose">
|
||||
<input
|
||||
type="text"
|
||||
className="compose-input"
|
||||
placeholder="Type a message, @name"
|
||||
/>
|
||||
{
|
||||
props.rightItems
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
function MessageList(props: {customer_id: string|number}) {
|
||||
const [messages, setMessages] = useState<any>([])
|
||||
|
||||
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(
|
||||
<Message
|
||||
key={i}
|
||||
isMine={isMine}
|
||||
startsSequence={startsSequence}
|
||||
endsSequence={endsSequence}
|
||||
showTimestamp={showTimestamp}
|
||||
data={current}
|
||||
/>
|
||||
);
|
||||
|
||||
// Proceed to the next message.
|
||||
i += 1;
|
||||
}
|
||||
|
||||
return tempMessages;
|
||||
}
|
||||
|
||||
return(
|
||||
<div className="message-list-container">
|
||||
{ renderMessages() }
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Message(props: any) {
|
||||
const {
|
||||
data,
|
||||
isMine,
|
||||
startsSequence,
|
||||
endsSequence,
|
||||
showTimestamp
|
||||
} = props;
|
||||
|
||||
const friendlyTimestamp = moment(data.timestamp).format('LLLL');
|
||||
return (
|
||||
<div className={[
|
||||
'message',
|
||||
`${isMine ? 'mine' : ''}`,
|
||||
`${startsSequence ? 'start' : ''}`,
|
||||
`${endsSequence ? 'end' : ''}`
|
||||
].join(' ')}>
|
||||
{
|
||||
showTimestamp &&
|
||||
<div className="timestamp">
|
||||
{ friendlyTimestamp }
|
||||
</div>
|
||||
}
|
||||
|
||||
<div className="bubble-container">
|
||||
<div className="bubble" title={friendlyTimestamp}>
|
||||
{ data.message }
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export default function Chatbox({customer_id}: {customer_id: string|number}) {
|
||||
return (
|
||||
<>
|
||||
<div className="message-list scrollable" style={{height: CHATBOX_HEIGHT}} >
|
||||
<MessageList customer_id={customer_id}/>
|
||||
</div>
|
||||
|
||||
<Compose rightItems={[
|
||||
<ToolbarButton key="photo" icon="ion-ios-camera" />,
|
||||
<ToolbarButton key="image" icon="ion-ios-image" />,
|
||||
<ToolbarButton key="audio" icon="ion-ios-mic" />,
|
||||
<ToolbarButton key="money" icon="ion-ios-card" />,
|
||||
<ToolbarButton key="games" icon="ion-logo-game-controller-b" />,
|
||||
<ToolbarButton key="emoji" icon="ion-ios-happy" />
|
||||
]}/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
13
src/components/Chatbox/ver/TypingNotification.tsx
Normal file
13
src/components/Chatbox/ver/TypingNotification.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
|
||||
import {Image} from "antd";
|
||||
import React from "react";
|
||||
|
||||
import TYPING_ANIMATION_IMAGE from "@/assets/typing-animation.gif";
|
||||
|
||||
const TypingNotification = () => {
|
||||
return (
|
||||
<Image preview={false} style={{height:20}} src={TYPING_ANIMATION_IMAGE} />
|
||||
)
|
||||
}
|
||||
|
||||
export default TypingNotification;
|
||||
123
src/components/Comment/index.tsx
Normal file
123
src/components/Comment/index.tsx
Normal file
@@ -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 (
|
||||
<Form form={form} name="complex-form" onFinish={onFinish} wrapperCol={{ span: 24 }}>
|
||||
<Form.Item name={'comment'}>
|
||||
<Input.TextArea
|
||||
placeholder={'Nhập và nhấn phím Enter để gửi'}
|
||||
autoSize={{ minRows: 2, maxRows: 5 }}
|
||||
allowClear
|
||||
onPressEnter={(e) => {
|
||||
e.preventDefault();
|
||||
form.submit();
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
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<string>('');
|
||||
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 = [
|
||||
<Tooltip key="comment-basic-like" title="Like">
|
||||
<span onClick={() => alert('like')}>
|
||||
<LikeOutlined />
|
||||
<span className="comment-action">1</span>
|
||||
</span>
|
||||
</Tooltip>,
|
||||
|
||||
/*<Tooltip key="comment-basic-dislike" title="Dislike">
|
||||
<span onClick={() => alert('dislikes')}>
|
||||
<DislikeOutlined />
|
||||
<span className="comment-action">0</span>
|
||||
</span>
|
||||
</Tooltip>,*/
|
||||
|
||||
<span key="comment-basic-reply-to">Reply to</span>,
|
||||
];
|
||||
|
||||
return (
|
||||
<Comment
|
||||
key={item.id}
|
||||
actions={actions}
|
||||
author={item.author}
|
||||
avatar={
|
||||
<Avatar
|
||||
src={DEFAULT_AVATAR}
|
||||
alt={item.author}
|
||||
/>
|
||||
}
|
||||
content={item.content}
|
||||
datetime={showUnixTime(item.create_time)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div >
|
||||
|
||||
<Divider />
|
||||
|
||||
<h3>Nhận xét ({item_list.length})</h3>
|
||||
|
||||
<CommentForm />
|
||||
|
||||
{
|
||||
item_list.map( CommentItem )
|
||||
}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CommentList;
|
||||
63
src/components/ConversationList/ConversationList.css
Normal file
63
src/components/ConversationList/ConversationList.css
Normal file
@@ -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;
|
||||
}
|
||||
143
src/components/ConversationList/index.tsx
Normal file
143
src/components/ConversationList/index.tsx
Normal file
@@ -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<Input>();
|
||||
const startSearch = () => {
|
||||
//const time = new Date().getSeconds();
|
||||
const keyword = (inputRef.current) ? inputRef.current.input.value : '';
|
||||
//console.log('Keyword = ' + keyword + ' at ' + time);
|
||||
setKeyword(keyword);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="conversation-search">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
type="search"
|
||||
className="conversation-search-input"
|
||||
placeholder="Tìm kiếm"
|
||||
onChange={debounce(startSearch, search_wait_time)}
|
||||
allowClear
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
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 (
|
||||
<div className={containerClass} onClick={() => pickUserToChat()} title={unread.messages.join('\n')}>
|
||||
|
||||
<BadgeStatus online={user.online || false}>
|
||||
<img className="conversation-photo" src={avatar} alt="conversation" />
|
||||
</BadgeStatus>
|
||||
|
||||
<div className="conversation-info">
|
||||
<h1 className="conversation-title">{ user.name } ({user.location})</h1>
|
||||
<div>{user.id}</div>
|
||||
{
|
||||
user.typing && <Image preview={false} style={{height:20}} src={TYPING_ANIMATION_IMAGE} />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const ConversationList = () => {
|
||||
|
||||
const [keyword, setKeyword] = useState<string>('');
|
||||
|
||||
//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 (
|
||||
<div className="conversation-list">
|
||||
|
||||
<Toolbar
|
||||
title={`Khách hàng (${user_list.length})`}
|
||||
leftItems={[
|
||||
<ToolbarButton key="cog" icon="ion-ios-cog" />
|
||||
]}
|
||||
rightItems={[
|
||||
<ToolbarButton key="add" icon="ion-ios-add-circle-outline" />
|
||||
]}
|
||||
/>
|
||||
|
||||
<ConversationSearch setKeyword={setKeyword} />
|
||||
|
||||
{
|
||||
getUserList(keyword).map(
|
||||
(user: UserInfo) => <ConversationListItem key={user.id} user={user} unread={un_read_message_per_user[user.id] || no_unread} />
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConversationList;
|
||||
67
src/components/CustomComponent/guide.txt
Normal file
67
src/components/CustomComponent/guide.txt
Normal file
@@ -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<number>(0);
|
||||
const handleClick = () => {
|
||||
alert('hello there');
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>Counter: {counter} <span onClick={() => setCounter(counter + 1)}>[+]</span></h2>
|
||||
<div>Click vào đây để thử nhé <span style={{color: 'green'}} onClick={handleClick}>Click</span></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+ 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:
|
||||
<head>
|
||||
<script>
|
||||
window.TestComponent = ....{content like above}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!--// This is our app's files. No one can change--->
|
||||
<script src="/static/js/bundle.js"></script>
|
||||
<script src="/static/js/vendors~main.chunk.js"></script>
|
||||
|
||||
|
||||
+ 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
|
||||
24
src/components/CustomComponent/index.tsx
Normal file
24
src/components/CustomComponent/index.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import React, {useState} from "react";
|
||||
|
||||
|
||||
const Test = () => {
|
||||
|
||||
const [external, setExternal] = useState<boolean>(false);
|
||||
|
||||
const TestExternal = () => {
|
||||
// @ts-ignore
|
||||
return window.TestComponent() || <span>TestExternalComponent not found</span>
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
{
|
||||
external ? <TestExternal /> : <div>No external</div>
|
||||
}
|
||||
<div style={{cursor: 'pointer'}} onClick={() => setExternal(true) }>Show TestExternalComponent</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Test;
|
||||
91
src/components/CustomerInfo/components/BrowseHistory.tsx
Normal file
91
src/components/CustomerInfo/components/BrowseHistory.tsx
Normal file
@@ -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<boolean>(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 (
|
||||
<a href={item.url} rel={'noreferrer'} target={'_blank'} title={item.title}>{subStr(item.title, 30)}</a>
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
title: 'Thời gian',
|
||||
key: 'create_time',
|
||||
render: (text: string, item: BrowseInfo) => {
|
||||
return (
|
||||
<>{showUnixTime(item.create_time)}</>
|
||||
)
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
if(loading) {
|
||||
return <Loading />
|
||||
}
|
||||
|
||||
return (
|
||||
<Table
|
||||
dataSource={item_list.list}
|
||||
columns={columns}
|
||||
pagination={{
|
||||
pageSize: pageSize,
|
||||
total: item_list.total,
|
||||
hideOnSinglePage: true,
|
||||
current: page,
|
||||
onChange: (page, pageSize) => {
|
||||
setRefresh({page, token: 0});
|
||||
}
|
||||
}}
|
||||
/*onRow={(record, rowIndex) => {
|
||||
return {
|
||||
onClick: event => openDrawer('order-detail', {id: record.api_id}), // click row
|
||||
};
|
||||
}}*/
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default BrowseHistory;
|
||||
236
src/components/CustomerInfo/components/Info.tsx
Normal file
236
src/components/CustomerInfo/components/Info.tsx
Normal file
@@ -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 (
|
||||
<Form form={form} name="complex-form" onFinish={onFinish} labelCol={{ span: 4 }} wrapperCol={{ span: 20 }}>
|
||||
|
||||
<Form.Item
|
||||
name={['user', 'user_id']}
|
||||
noStyle
|
||||
initialValue={user_info.id}
|
||||
hidden
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Mã khách hàng">
|
||||
<Form.Item
|
||||
name={['user', 'crm_code']}
|
||||
noStyle
|
||||
//initialValue={user_info.crm_code}
|
||||
>
|
||||
<Input style={{ width: 160 }} maxLength={50} placeholder="" />
|
||||
</Form.Item>
|
||||
<Tooltip title="Mã khách hàng tại phần mềm CRM của bạn. Chatngay sẽ tự tạo 1 mã nếu bạn chưa kịp cập nhật hoặc chưa có phần mềm CRM. Mã này dùng theo dõi thông tin khách hàng toàn hệ thống.">
|
||||
<InfoCircleFilled style={{color: 'green'}} />
|
||||
</Tooltip>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Họ tên">
|
||||
<Input.Group compact>
|
||||
<Form.Item
|
||||
name={['user', 'gender']}
|
||||
noStyle
|
||||
>
|
||||
<SelectWithList
|
||||
placeholder="Giới tính"
|
||||
options={GENDER_LIST}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={['user', 'name']}
|
||||
noStyle
|
||||
//rules={[{ required: true, message: 'Họ tên is required'}]}
|
||||
//initialValue={user_info.name}
|
||||
>
|
||||
<Input style={{ width: '50%' }} placeholder="Họ tên" />
|
||||
</Form.Item>
|
||||
</Input.Group>
|
||||
</Form.Item>
|
||||
|
||||
{/*<Form.Item label="Năm sinh">
|
||||
<Form.Item
|
||||
name={['user', 'birthyear']}
|
||||
noStyle
|
||||
>
|
||||
<DatePicker picker="year" />
|
||||
</Form.Item>
|
||||
</Form.Item>*/}
|
||||
|
||||
<Form.Item label="Email">
|
||||
<Form.Item
|
||||
name={['user', 'email']}
|
||||
noStyle
|
||||
rules={[
|
||||
{
|
||||
type: 'email',
|
||||
message: 'Kiểm tra lại email',
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Input style={{ width: 160 }} placeholder="email" />
|
||||
</Form.Item>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Điện thoại">
|
||||
<Form.Item
|
||||
name={['user', 'mobile']}
|
||||
noStyle
|
||||
//initialValue={user_info.tel}
|
||||
>
|
||||
<Input style={{ width: 160 }} placeholder="mobile" />
|
||||
</Form.Item>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Zalo">
|
||||
<Form.Item
|
||||
name={['user', 'zalo']}
|
||||
noStyle
|
||||
>
|
||||
<Input style={{ width: 160 }} placeholder="zalo" />
|
||||
</Form.Item>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Facebook">
|
||||
<Form.Item
|
||||
name={['user', 'facebook']}
|
||||
noStyle
|
||||
//initialValue={user_info.tel}
|
||||
>
|
||||
<Input style={{ width: 160 }} placeholder="facebook" />
|
||||
</Form.Item>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Instagram">
|
||||
<Form.Item
|
||||
name={['user', 'instagram']}
|
||||
noStyle
|
||||
//initialValue={user_info.tel}
|
||||
>
|
||||
<Input style={{ width: 160 }} placeholder="instagram" />
|
||||
</Form.Item>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Địa chỉ">
|
||||
<Input.Group compact>
|
||||
<Form.Item
|
||||
name={['user', 'address']}
|
||||
noStyle
|
||||
//initialValue={user_info.location}
|
||||
>
|
||||
<Input style={{ width: '50%' }} placeholder="" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={['user', 'province']}
|
||||
noStyle
|
||||
>
|
||||
<SelectWithList
|
||||
placeholder="Tỉnh thành"
|
||||
options={PROVINCE_LIST}
|
||||
style={{width: 150}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Input.Group>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Công ty">
|
||||
<Form.Item
|
||||
name={['user', 'company']}
|
||||
noStyle
|
||||
>
|
||||
<Input style={{ width: 160 }} placeholder="" />
|
||||
</Form.Item>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Website">
|
||||
<Form.Item
|
||||
name={['user', 'website']}
|
||||
noStyle
|
||||
rules={[{type: 'url'}]}
|
||||
>
|
||||
<Input style={{ width: 160 }} placeholder="http://website.vn" />
|
||||
</Form.Item>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name={['user', 'note']}
|
||||
label="Ghi chú"
|
||||
//initialValue={user_info.note}
|
||||
>
|
||||
<Input.TextArea />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label=" " colon={false}>
|
||||
<Button type="primary" htmlType="submit">
|
||||
Cập nhật
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
export default Info;
|
||||
96
src/components/CustomerInfo/components/ListNote.tsx
Normal file
96
src/components/CustomerInfo/components/ListNote.tsx
Normal file
@@ -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<boolean>(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) => (
|
||||
<span style={{cursor: 'pointer'}} onClick={() => openDrawer('note-detail', {id: item.api_id})} title={item.content}>{subStr(item.content, 30)}</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: 'Ngày',
|
||||
key: 'create_time',
|
||||
render: (item: NoteInfo) => (
|
||||
<>{showUnixTime(item.create_time)}</>
|
||||
)
|
||||
},
|
||||
];
|
||||
|
||||
if(loading) {
|
||||
return <Loading />
|
||||
}
|
||||
|
||||
return (
|
||||
<Table
|
||||
dataSource={item_list.list}
|
||||
columns={columns}
|
||||
pagination={{
|
||||
pageSize: pageSize,
|
||||
total: item_list.total,
|
||||
hideOnSinglePage: true,
|
||||
current: page,
|
||||
onChange: (page, pageSize) => {
|
||||
setRefresh({page, token: 0});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default ListNote;
|
||||
119
src/components/CustomerInfo/components/ListOrder.tsx
Normal file
119
src/components/CustomerInfo/components/ListOrder.tsx
Normal file
@@ -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<boolean>(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) => (
|
||||
<span style={{cursor: 'pointer'}} onClick={() => openDrawer('order-detail', {id: item.api_id})} title={'Xem chi tiết'}>{item.api_id}</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
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) => (
|
||||
<OrderStatus status={item.order_status} />
|
||||
)
|
||||
},
|
||||
{
|
||||
title: 'Chi tiết',
|
||||
render: (item: OrderInfo) => (
|
||||
<span style={{cursor: 'pointer'}} onClick={() => openDrawer('order-detail', {id: item.api_id})} title={'Xem chi tiết'}><Badge count={'Xem'} style={{ backgroundColor: 'green' }} /></span>
|
||||
|
||||
)
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
if(loading) {
|
||||
return <Loading />
|
||||
}
|
||||
|
||||
return (
|
||||
<Table
|
||||
dataSource={item_list.list}
|
||||
columns={columns}
|
||||
pagination={{
|
||||
pageSize: pageSize,
|
||||
total: item_list.total,
|
||||
hideOnSinglePage: true,
|
||||
current: page,
|
||||
onChange: (page, pageSize) => {
|
||||
setRefresh({page, token: 0});
|
||||
}
|
||||
}}
|
||||
onRow={(record, rowIndex) => {
|
||||
return {
|
||||
onClick: event => openDrawer('order-detail', {id: record.api_id}), // click row
|
||||
};
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default ListOrder;
|
||||
107
src/components/CustomerInfo/components/ListSupport.tsx
Normal file
107
src/components/CustomerInfo/components/ListSupport.tsx
Normal file
@@ -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<boolean>(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) => (
|
||||
<span style={{cursor: 'pointer'}} onClick={() => openDrawer('support-detail', {id: item.api_id})} title={item.title}>{subStr(item.title, 50)}</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
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 <Loading />
|
||||
}
|
||||
|
||||
return (
|
||||
<Table
|
||||
dataSource={item_list.list}
|
||||
columns={columns}
|
||||
pagination={{
|
||||
pageSize: pageSize,
|
||||
total: item_list.total,
|
||||
hideOnSinglePage: true,
|
||||
current: page,
|
||||
onChange: (page, pageSize) => {
|
||||
setRefresh({page, token: 0});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export default ListSupport;
|
||||
52
src/components/CustomerInfo/index.tsx
Normal file
52
src/components/CustomerInfo/index.tsx
Normal file
@@ -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 (
|
||||
<Tabs type="card">
|
||||
<TabPane tab="Thông tin" key="info">
|
||||
<Suspense fallback={<Loading />} >
|
||||
<CustomerInfoComponent user_info={user_info} key={'customer-info'}/>
|
||||
</Suspense>
|
||||
</TabPane>
|
||||
|
||||
<TabPane tab="Đơn hàng" key="list-order">
|
||||
<Suspense fallback={<Loading />} >
|
||||
<CustomerListOrderComponent customer_id={user_info.id} key={'list-order'}/>
|
||||
</Suspense>
|
||||
</TabPane>
|
||||
|
||||
<TabPane tab="Hỗ trợ" key="list-support">
|
||||
<Suspense fallback={<Loading />} >
|
||||
<CustomerListSupportComponent customer_id={user_info.id} key={'list-support'}/>
|
||||
</Suspense>
|
||||
</TabPane>
|
||||
|
||||
<TabPane tab="Truy cập website" key="browse-history">
|
||||
<Suspense fallback={<Loading />} >
|
||||
<BrowseHistoryComponent customer_id={user_info.id} key={'browse-history'}/>
|
||||
</Suspense>
|
||||
</TabPane>
|
||||
|
||||
<TabPane tab="Ghi chú" key="list-note">
|
||||
<Suspense fallback={<Loading />} >
|
||||
<CustomerListNoteComponent customer_id={user_info.id} key={'list-note'}/>
|
||||
</Suspense>
|
||||
</TabPane>
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
|
||||
export default CustomerInfo;
|
||||
9
src/components/DashBoard/index.tsx
Normal file
9
src/components/DashBoard/index.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import React from "react";
|
||||
|
||||
const DashBoard = () => {
|
||||
return (
|
||||
<div>Vui lòng chọn người chat</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DashBoard;
|
||||
9
src/components/Error/ItemNotFound.tsx
Normal file
9
src/components/Error/ItemNotFound.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
const ItemNotFound = ({msg}: {msg?: string}) => {
|
||||
return (
|
||||
<>
|
||||
{msg || 'Not found'}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ItemNotFound;
|
||||
77
src/components/Error/NetworkError.tsx
Normal file
77
src/components/Error/NetworkError.tsx
Normal file
@@ -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 (
|
||||
<span style={{color: 'red'}} ><RobotOutlined/> Lỗi kết nối (code: #1)</span>
|
||||
)
|
||||
}
|
||||
|
||||
if(node_connection === 'error') {
|
||||
return (
|
||||
<span style={{color: 'red'}} ><RobotOutlined/> Lỗi kết nối (code: #2)</span>
|
||||
)
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const Messsage = () => {
|
||||
if(network_connection === 'offline') {
|
||||
return <div>Vui lòng kiểm tra lại đường truyền Internet của bạn</div>;
|
||||
}
|
||||
|
||||
if(node_connection === 'error') {
|
||||
return (
|
||||
<div>
|
||||
<div>Kết nối tới máy chủ Chatngay không thành công!</div>
|
||||
<div>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 <a target={'_blank'} rel={'noreferrer'} href={'https://www.chatngay.com/support'}>https://www.chatngay.com/support</a></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const EmptyIcon = () => {
|
||||
return (
|
||||
<span> </span>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
const connection_error : boolean = (network_connection === 'offline' || node_connection === 'error');
|
||||
|
||||
if(!connection_error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={<Title />}
|
||||
centered
|
||||
visible
|
||||
footer={null}
|
||||
width={500}
|
||||
maskClosable={false}
|
||||
closeIcon={<EmptyIcon />}
|
||||
>
|
||||
<Messsage />
|
||||
</Modal>
|
||||
)
|
||||
};
|
||||
|
||||
export default NetworkError;
|
||||
11
src/components/Error/index.tsx
Normal file
11
src/components/Error/index.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import React from "react";
|
||||
|
||||
export default function Error({message}: {message?: string}) {
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
Error: {message || 'unknown'}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
32
src/components/GlobalDrawer/components/NoteDetail.tsx
Normal file
32
src/components/GlobalDrawer/components/NoteDetail.tsx
Normal file
@@ -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<NoteInfo>({});
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
let result = await api.get('note/info', {id});
|
||||
if(result.status === 'ok') {
|
||||
setInfo(result.data);
|
||||
}
|
||||
})();
|
||||
}, [id]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div><span style={{fontWeight: 'bold'}}>{item_info.admin_name}</span> ({showUnixTime(item_info.create_time)})</div>
|
||||
|
||||
<div>{item_info.content}</div>
|
||||
|
||||
<Comment item_type={'note'} item_id={item_info.id} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default NoteDetail;
|
||||
96
src/components/GlobalDrawer/components/OrderDetail.tsx
Normal file
96
src/components/GlobalDrawer/components/OrderDetail.tsx
Normal file
@@ -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<OrderInfo>({});
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
let result = await api.get('order/info', {id});
|
||||
if(result.status === 'ok') {
|
||||
setInfo(result.data);
|
||||
}
|
||||
})();
|
||||
}, [id]);
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<Descriptions
|
||||
bordered
|
||||
title=" "
|
||||
size={'small'}
|
||||
column={1}
|
||||
>
|
||||
<Descriptions.Item label="Mã đơn">{item_info.api_id}</Descriptions.Item>
|
||||
<Descriptions.Item label="Ghi chú">{item_info.note}</Descriptions.Item>
|
||||
<Descriptions.Item label="Thời gian">{showUnixTime(item_info.create_time)}</Descriptions.Item>
|
||||
<Descriptions.Item label="Tình trạng thanh toán">
|
||||
<PaymentStatus status={item_info.payment_status} />
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Tình trạng vận chuyển">
|
||||
<ShippingStatus status={item_info.shipping_status} />
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Tình trạng đơn">
|
||||
<OrderStatus status={item_info.order_status} />
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label="Khách hàng">
|
||||
{item_info.customer?.name} <br />
|
||||
{item_info.customer?.email} <br />
|
||||
{item_info.customer?.mobile} <br />
|
||||
{item_info.customer?.address} <br />
|
||||
{item_info.customer?.province} <br />
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Sản phẩm">
|
||||
{
|
||||
item_info.products?.map((product, index) => {
|
||||
return (
|
||||
<>
|
||||
{index + 1}. {product.name} - SKU: {product.sku} - Giá: {product.price} - SL: {product.quantity} <br />
|
||||
</>
|
||||
)
|
||||
} )
|
||||
}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Thông tin khác">
|
||||
{
|
||||
item_info.others?.map((other, index) => {
|
||||
return (
|
||||
<>
|
||||
{index + 1}. {other.name} - {other.price}
|
||||
</>
|
||||
)
|
||||
} )
|
||||
}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Vận chuyển">
|
||||
{item_info.shipping?.provider} <br />
|
||||
{item_info.shipping?.reference} <br />
|
||||
{item_info.shipping?.note} <br />
|
||||
{item_info.shipping?.date} - {item_info.shipping?.time} <br />
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Thanh toán">
|
||||
{item_info.payment?.method} <br />
|
||||
{item_info.payment?.reference} <br />
|
||||
{item_info.payment?.note} <br />
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Tags">{item_info.tags?.join(', ')}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<Comment item_type={'order'} item_id={item_info.api_id} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default OrderDetail;
|
||||
40
src/components/GlobalDrawer/components/SupportDetail.tsx
Normal file
40
src/components/GlobalDrawer/components/SupportDetail.tsx
Normal file
@@ -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<SupportInfo>({});
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
let result = await api.get('support/info', {id});
|
||||
if(result.status === 'ok') {
|
||||
setInfo(result.data);
|
||||
}
|
||||
})();
|
||||
}, [id]);
|
||||
|
||||
if(!item_info) {
|
||||
return <ItemNotFound />
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
<div style={{fontWeight: 'bold'}}>{item_info.title}</div>
|
||||
<div style={{fontStyle: 'italic', marginBottom: 10}}><span >{item_info.admin_name}</span> ({showUnixTime(item_info.create_time)})</div>
|
||||
|
||||
<TextWithLineBreak txt={item_info.description} />
|
||||
|
||||
<Comment item_type={'note'} item_id={item_info.id} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default SupportDetail;
|
||||
88
src/components/GlobalDrawer/index.tsx
Normal file
88
src/components/GlobalDrawer/index.tsx
Normal file
@@ -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 <OrderDetailComponent id={drawer_component.args.id} />;
|
||||
}
|
||||
|
||||
if(drawer_component.component === 'note-detail') {
|
||||
return <NoteDetail id={drawer_component.args.id} />;
|
||||
}
|
||||
|
||||
if(drawer_component.component === 'support-detail') {
|
||||
return <SupportDetail id={drawer_component.args.id} />;
|
||||
}
|
||||
|
||||
if(drawer_component.component === 'product-list') {
|
||||
return <ProductListComponent params={drawer_component.args} openItem={(id) => 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 (
|
||||
<>
|
||||
<Drawer
|
||||
title={<Title />}
|
||||
width={500}
|
||||
placement="right"
|
||||
closable
|
||||
onClose={closeDrawer}
|
||||
visible={drawer_component.component !== ''}
|
||||
>
|
||||
<Suspense fallback={<Loading />}>
|
||||
{ getLoadedComponent() }
|
||||
</Suspense>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default GlobalDrawer;
|
||||
40
src/components/GlobalModal/components/ListAdminOnline.tsx
Normal file
40
src/components/GlobalModal/components/ListAdminOnline.tsx
Normal file
@@ -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 (
|
||||
<List
|
||||
className="demo-loadmore-list"
|
||||
itemLayout="horizontal"
|
||||
dataSource={admin_list}
|
||||
renderItem={(item: AdminInfo) => (
|
||||
<List.Item
|
||||
//actions={[<a key="list-loadmore-edit" onClick={() => alert(item.id)}>edit</a>]}
|
||||
>
|
||||
<List.Item.Meta
|
||||
avatar={
|
||||
<Avatar src={item.avatar !=='' ? item.avatar : DEFAULT_AVATAR} />
|
||||
}
|
||||
title={<>{item.name}</>}
|
||||
//description={}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Badge count={item.online ? 'Online' : 'Offline'} style={{ backgroundColor: item.online ? 'green' : 'gray' }} />
|
||||
</div>
|
||||
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
export default ListAdminOnline;
|
||||
26
src/components/GlobalModal/components/ListUserOnline.tsx
Normal file
26
src/components/GlobalModal/components/ListUserOnline.tsx
Normal file
@@ -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;
|
||||
87
src/components/GlobalModal/index.tsx
Normal file
87
src/components/GlobalModal/index.tsx
Normal file
@@ -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 <ProductListComponent params={modal_component.args} openItem={(id) => alert(id)} />;
|
||||
}
|
||||
|
||||
if(modal_component.component === 'help') {
|
||||
return <HelpModal />;
|
||||
}
|
||||
|
||||
if(modal_component.component === 'admin') {
|
||||
return <ListAdminOnline />;
|
||||
}
|
||||
|
||||
if(modal_component.component === 'user') {
|
||||
return <ListUserOnline />;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const Title = () => {
|
||||
if(modal_component.component === 'product-list') {
|
||||
return <span>Danh sách sản phẩm</span>;
|
||||
}
|
||||
|
||||
if(modal_component.component === 'help') {
|
||||
return <span>Danh sách bài viết</span>;
|
||||
}
|
||||
|
||||
if(modal_component.component === 'admin') {
|
||||
return <span>Quản trị viên</span>;
|
||||
}
|
||||
|
||||
if(modal_component.component === 'user') {
|
||||
return <span>Người dùng</span>;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={<Title />}
|
||||
centered
|
||||
visible={modal_component.component !== ''}
|
||||
onOk={closeModal}
|
||||
onCancel={closeModal}
|
||||
width={1000}
|
||||
maskClosable={false}
|
||||
>
|
||||
<Suspense fallback={<Loading />}>
|
||||
{
|
||||
getLoadedComponent()
|
||||
}
|
||||
</Suspense>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default ModalComponent;
|
||||
104
src/components/HeaderComponent/index.tsx
Normal file
104
src/components/HeaderComponent/index.tsx
Normal file
@@ -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 = (
|
||||
<Menu>
|
||||
<Menu.Item>
|
||||
<a target="_blank" rel="noopener noreferrer" href="https://www.antgroup.com">
|
||||
1st menu item
|
||||
</a>
|
||||
</Menu.Item>
|
||||
<Menu.Item icon={<DownOutlined />} disabled>
|
||||
<a target="_blank" rel="noopener noreferrer" href="https://www.aliyun.com">
|
||||
2nd menu item
|
||||
</a>
|
||||
</Menu.Item>
|
||||
<Menu.Item disabled>
|
||||
<a target="_blank" rel="noopener noreferrer" href="https://www.luohanacademy.com">
|
||||
3rd menu item
|
||||
</a>
|
||||
</Menu.Item>
|
||||
<Menu.Item danger>a danger item</Menu.Item>
|
||||
</Menu>
|
||||
);
|
||||
|
||||
|
||||
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 (
|
||||
|
||||
|
||||
<Fragment>
|
||||
|
||||
<Row className={'header'}>
|
||||
|
||||
<Col span={20}>
|
||||
|
||||
<Menu mode="horizontal" onClick={handleMenuClick}>
|
||||
<Menu.Item key="user" icon={<UserOutlined />} >
|
||||
Người dùng online ({current_stats.user_online})
|
||||
</Menu.Item>
|
||||
<Menu.Item key="admin" icon={<TeamOutlined />} >
|
||||
Quản trị viên online ({ADMIN_ONLINE}/{TOTAL_ADMIN})
|
||||
</Menu.Item>
|
||||
<Menu.Item key="help" icon={<QuestionOutlined />}>
|
||||
Trợ giúp
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
|
||||
</Col>
|
||||
|
||||
<Col span={4}>
|
||||
|
||||
<Dropdown overlay={menu}>
|
||||
<Fragment>
|
||||
<Image
|
||||
src={'https://chatngay-static.glee.vn/avatar/120_a1.jpg'}
|
||||
style={{width: 50, height: 50}}
|
||||
preview={false}
|
||||
/>
|
||||
<span className="name">{admin_info.name}</span>
|
||||
</Fragment>
|
||||
</Dropdown>
|
||||
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
</Fragment>
|
||||
)
|
||||
};
|
||||
|
||||
export default HeaderComponent;
|
||||
9
src/components/HeaderComponent/styles.css
Normal file
9
src/components/HeaderComponent/styles.css
Normal file
@@ -0,0 +1,9 @@
|
||||
.header {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.ant-layout-header .ant-menu{
|
||||
background: black;
|
||||
color: white;
|
||||
}
|
||||
|
||||
26
src/components/Help/HelpModal.tsx
Normal file
26
src/components/Help/HelpModal.tsx
Normal file
@@ -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 (
|
||||
<Tabs onChange={(key) => null } type="card" defaultActiveKey={'product'}>
|
||||
<TabPane tab="Sản phẩm" key="product">
|
||||
<ProductListComponent openItem={(id) => {} } />
|
||||
</TabPane>
|
||||
<TabPane tab="Kho kiến thức" key="article">
|
||||
<ArticleListComponent openItem={(id) => {
|
||||
|
||||
} } />
|
||||
</TabPane>
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
|
||||
export default HelpModal;
|
||||
99
src/components/Help/HelpSideBar.tsx
Normal file
99
src/components/Help/HelpSideBar.tsx
Normal file
@@ -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 (
|
||||
<>
|
||||
<div className={'scrollable'} style={{height: HELP_HEIGHT}}>
|
||||
|
||||
<h3 style={{fontSize: 16}}>Sản phẩm: <span className={'a-link'} onClick={() => props.openComponent('product-list')}>Xem het</span></h3>
|
||||
|
||||
<ProductListComponent
|
||||
defaultList={help.product.slice(0, 5)}
|
||||
openItem={(id) => props.openComponent('product-detail', {id})}
|
||||
/>
|
||||
|
||||
<h3 style={{fontSize: 16}}>Kho kiến thức: <span className={'a-link'} onClick={() => props.openComponent('article-list')}>Xem het</span></h3>
|
||||
|
||||
<ArticleListComponent
|
||||
defaultList={help.article.slice(0, 5)}
|
||||
openItem={(id) => props.openComponent('article-detail', {id})}
|
||||
/>
|
||||
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
const HelpSideBar = () => {
|
||||
|
||||
const [component, setComponent] = useState<OpenHelpComponent>({type: 'home'});
|
||||
|
||||
const openComponent = (type: HelpType, params?: { [key: string] : any }) => {
|
||||
setComponent({type, params});
|
||||
}
|
||||
|
||||
const getLoadedComponent = () => {
|
||||
if(component.type === 'product-list') {
|
||||
return <ProductListComponent params={component.params} openItem={(id) => openComponent('product-detail', {id})} />;
|
||||
}
|
||||
|
||||
if(component.type === 'product-detail') {
|
||||
return <ProductDetailComponent id={component.params!.id} openHome={() => openComponent('home')} />;
|
||||
}
|
||||
|
||||
if(component.type === 'article-list') {
|
||||
return <ArticleListComponent params={component.params} openItem={(id) => openComponent('article-detail', {id})} />;
|
||||
}
|
||||
|
||||
if(component.type === 'article-detail') {
|
||||
return <ArticleDetailComponent id={component.params!.id} openHome={() => openComponent('home')} />;
|
||||
}
|
||||
|
||||
if(component.type === 'search') {
|
||||
return <SearchComponent openComponent={openComponent} keyword={component.params!.q} />;
|
||||
}
|
||||
|
||||
return <HomeComponent openComponent={openComponent} />;
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<Suspense fallback={<Loading />}>
|
||||
<SearchBox openComponent={openComponent} />
|
||||
|
||||
{
|
||||
getLoadedComponent()
|
||||
}
|
||||
</Suspense>
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
export default HelpSideBar;
|
||||
36
src/components/Help/components/ArticleDetail.tsx
Normal file
36
src/components/Help/components/ArticleDetail.tsx
Normal file
@@ -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<HelpItem>({});
|
||||
|
||||
useEffect(() => {
|
||||
const _getInfo = async () => {
|
||||
let result = await api.get('article/info', {id});
|
||||
if(result.status === 'ok') {
|
||||
setArticle(result.data);
|
||||
}
|
||||
};
|
||||
_getInfo();
|
||||
}, [id]);
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<div><span className={'a-link'} onClick={openHome}>Back to home</span></div>
|
||||
|
||||
<h3>{article_info.name}</h3>
|
||||
|
||||
<div>{article_info.summary}</div>
|
||||
|
||||
<Comment item_type={'product'} item_id={article_info.id} />
|
||||
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ArticleDetailComponent;
|
||||
89
src/components/Help/components/ArticleList.tsx
Normal file
89
src/components/Help/components/ArticleList.tsx
Normal file
@@ -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 (
|
||||
<>
|
||||
<List
|
||||
itemLayout="horizontal"
|
||||
dataSource={defaultList}
|
||||
renderItem={(item: HelpItem) => (
|
||||
<List.Item onClick={() => openItem(item.id)}>
|
||||
<List.Item.Meta
|
||||
title={<span className={'a-link'} >{item.name}</span>}
|
||||
description={item.summary}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
const FullListComponent = (props: {openItem: (id?: string|number) => void, params?: {[key: string]: any}}) => {
|
||||
|
||||
const {openItem , params} = props;
|
||||
const item_list = useRef<HelpItem[]>( []);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [page, setPage] = useState<number>(1);
|
||||
|
||||
// const [item_list, setList] = useState<any>(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 <Loading />
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<List
|
||||
itemLayout="horizontal"
|
||||
dataSource={item_list.current}
|
||||
renderItem={(item: HelpItem) => (
|
||||
<List.Item onClick={() => openItem(item.id)}>
|
||||
<List.Item.Meta
|
||||
title={<span className={'a-link'} >{item.name}</span>}
|
||||
description={item.summary}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Pagination defaultCurrent={page} total={50} pageSize={10} onChange={page => setPage(page)} />
|
||||
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
const ArticleListComponent = (props: {defaultList?: HelpItem[], openItem: (id?: string|number) => void, params?: {[key: string]: any} }) => {
|
||||
|
||||
const {defaultList, openItem, params} = props;
|
||||
|
||||
if(defaultList) {
|
||||
return <TopListComponent defaultList={defaultList} openItem={openItem} />
|
||||
}
|
||||
|
||||
return <FullListComponent params={params} openItem={openItem}/>
|
||||
}
|
||||
|
||||
|
||||
export default ArticleListComponent;
|
||||
55
src/components/Help/components/ProductDetail.tsx
Normal file
55
src/components/Help/components/ProductDetail.tsx
Normal file
@@ -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<HelpItem>({});
|
||||
|
||||
useEffect(() => {
|
||||
const _getInfo = async () => {
|
||||
let result = await api.get('product/info?id=', {id});
|
||||
if(result.status === 'ok') {
|
||||
setProduct(result.data);
|
||||
}
|
||||
};
|
||||
_getInfo();
|
||||
}, [id]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div><span className={'a-link'} onClick={openHome}>Back to home</span></div>
|
||||
|
||||
<Descriptions
|
||||
bordered
|
||||
title=" "
|
||||
size={'small'}
|
||||
column={1}
|
||||
>
|
||||
<Descriptions.Item label="Tên sản phẩm">{product_info.name}</Descriptions.Item>
|
||||
<Descriptions.Item label="Kho hàng">{product_info.in_stock}</Descriptions.Item>
|
||||
<Descriptions.Item label="Thời gian cập nhật"> --- </Descriptions.Item>
|
||||
<Descriptions.Item label="Giá">
|
||||
{formatNumber(product_info.price)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Thông tin">
|
||||
{product_info.summary}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Hình ảnh">
|
||||
<img src={product_info.img} style={{maxWidth: 150, maxHeight: 150}} alt={''} />
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<Comment item_type={'product'} item_id={product_info.id} />
|
||||
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProductDetailComponent;
|
||||
106
src/components/Help/components/ProductList.tsx
Normal file
106
src/components/Help/components/ProductList.tsx
Normal file
@@ -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 (
|
||||
<>
|
||||
<List
|
||||
itemLayout="horizontal"
|
||||
dataSource={defaultList}
|
||||
renderItem={(item: HelpItem) => (
|
||||
<List.Item onClick={() => openItem(item.id)}>
|
||||
<List.Item.Meta
|
||||
avatar={<Avatar src={item.img} />}
|
||||
title={<span className={'a-link'} >{item.name}</span>}
|
||||
description={<ProductDescription item={item} />}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
const FullListComponent = (props: {openItem: (id?: string|number) => void, params?: {[key: string]: any} }) => {
|
||||
|
||||
const {openItem, params} = props;
|
||||
const item_list = useRef<HelpItem[]>( []);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [page, setPage] = useState<number>(1);
|
||||
|
||||
|
||||
|
||||
// const [item_list, setList] = useState<any>(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 <Loading />
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<List
|
||||
itemLayout="horizontal"
|
||||
dataSource={item_list.current}
|
||||
renderItem={(item: HelpItem) => (
|
||||
<List.Item onClick={() => openItem(item.id)}>
|
||||
<List.Item.Meta
|
||||
avatar={<Avatar src={item.img} />}
|
||||
title={<span className={'a-link'} >{item.name}</span>}
|
||||
description={<ProductDescription item={item}/>}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Pagination defaultCurrent={page} total={50} pageSize={10} onChange={page => setPage(page)} />
|
||||
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
const ProductListComponent = (props: {defaultList?: HelpItem[], openItem: (id?: string|number) => void , params?: {[key: string]: any} }) => {
|
||||
|
||||
const {defaultList, openItem, params} = props;
|
||||
|
||||
if(defaultList) {
|
||||
return <TopListComponent defaultList={defaultList} openItem={openItem} />
|
||||
}
|
||||
|
||||
return <FullListComponent params={params} openItem={openItem}/>
|
||||
}
|
||||
|
||||
|
||||
export default ProductListComponent;
|
||||
49
src/components/Help/components/Search.tsx
Normal file
49
src/components/Help/components/Search.tsx
Normal file
@@ -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 (
|
||||
<>
|
||||
<div className={'scrollable'} style={{height: HELP_HEIGHT}}>
|
||||
|
||||
<h3 style={{fontSize: 16}}>Sản phẩm thỏa mãn: <span className={'a-link'} onClick={() => props.openComponent('product-list', {q: keyword || ''})}>Xem het</span></h3>
|
||||
|
||||
<ProductListComponent
|
||||
defaultList={help.product.slice(0, 5)}
|
||||
openItem={(id) => props.openComponent('product-detail', {id})}
|
||||
/>
|
||||
|
||||
<h3 style={{fontSize: 16}}>Kho kiến thức thỏa mãn: <span className={'a-link'} onClick={() => props.openComponent('article-list', {q: keyword || ''})}>Xem het</span></h3>
|
||||
|
||||
<ArticleListComponent
|
||||
defaultList={help.article.slice(0, 5)}
|
||||
openItem={(id) => props.openComponent('article-detail', {id})}
|
||||
/>
|
||||
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default SearchComponent;
|
||||
39
src/components/Help/components/SearchBox.tsx
Normal file
39
src/components/Help/components/SearchBox.tsx
Normal file
@@ -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<string>('');
|
||||
|
||||
const startSearch = () => {
|
||||
if(keyword === '') {
|
||||
props.openComponent('home');
|
||||
return;
|
||||
}
|
||||
props.openComponent('search', {q: keyword});
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Search
|
||||
placeholder="Tìm kiếm sản phẩm hoặc kiến thức"
|
||||
allowClear
|
||||
enterButton="Tìm"
|
||||
size="middle"
|
||||
onSearch={startSearch}
|
||||
value={keyword}
|
||||
onChange={({ target: { value }}) => setKeyword(value)}
|
||||
onPressEnter={(e) => {
|
||||
e.preventDefault(); // required to prevent new line
|
||||
startSearch();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SearchBox;
|
||||
2
src/components/Help/index.tsx
Normal file
2
src/components/Help/index.tsx
Normal file
@@ -0,0 +1,2 @@
|
||||
export {default as HelpSideBar} from "./HelpSideBar";
|
||||
export {default as HelpModal} from "./HelpModal";
|
||||
14
src/components/Loading/index.tsx
Normal file
14
src/components/Loading/index.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from "react";
|
||||
import { Spin } from 'antd';
|
||||
import { LoadingOutlined } from '@ant-design/icons';
|
||||
|
||||
const antIcon = <LoadingOutlined style={{ fontSize: 24 }} spin />;
|
||||
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<>
|
||||
<Spin indicator={antIcon} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
83
src/components/SelectBox/SelectWithAddItem.tsx
Normal file
83
src/components/SelectBox/SelectWithAddItem.tsx
Normal file
@@ -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<string> & {
|
||||
current_lists: string[];
|
||||
}
|
||||
|
||||
type SelectWithAddItemState = {
|
||||
items: string[];
|
||||
new_item_name: string;
|
||||
}
|
||||
|
||||
|
||||
class SelectWithAddItem extends React.Component<SelectWithAddItemProps, SelectWithAddItemState> {
|
||||
|
||||
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 (
|
||||
<Select
|
||||
style={{ width: 240 }}
|
||||
placeholder="Chọn"
|
||||
dropdownRender={menu => (
|
||||
<div>
|
||||
{menu}
|
||||
|
||||
<Divider style={{ margin: '4px 0' }} />
|
||||
|
||||
<div style={{ display: 'flex', flexWrap: 'nowrap', padding: 8 }}>
|
||||
<Input style={{ flex: 'auto' }} placeholder={'Thêm mới'} value={new_item_name} onChange={this.onNameChange} />
|
||||
<span
|
||||
style={{ flex: 'none', padding: '8px', display: 'block', cursor: 'pointer', color: 'blue' }}
|
||||
onClick={this.addItem}
|
||||
title={'Click để thêm'}
|
||||
>
|
||||
<PlusOutlined /> Thêm
|
||||
</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)}
|
||||
{...others}
|
||||
>
|
||||
{items.map(item => (
|
||||
<Option key={item} value={item}>{item}</Option>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default SelectWithAddItem;
|
||||
79
src/components/SelectBox/SelectWithAjax.tsx
Normal file
79
src/components/SelectBox/SelectWithAjax.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import React from "react";
|
||||
import { Select } from 'antd';
|
||||
|
||||
import {SelectProps} from "antd/lib/select";
|
||||
|
||||
const { Option } = Select;
|
||||
|
||||
|
||||
type SelectWithAjaxProp = SelectProps<string> & {
|
||||
onFocusSuggestedData?: any[];
|
||||
buildOption?: (d: any) => React.ReactNode;
|
||||
searchFn: (query: string) => Promise<any[]>
|
||||
};
|
||||
|
||||
type SelectWithAjaxState = {
|
||||
data: { value: string, text: string, [other:string]: any }[];
|
||||
value?: any
|
||||
}
|
||||
|
||||
|
||||
class SelectWithAjax extends React.Component<SelectWithAjaxProp, SelectWithAjaxState> {
|
||||
|
||||
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 (
|
||||
<Select
|
||||
showSearch
|
||||
value={this.state.value}
|
||||
defaultActiveFirstOption={false}
|
||||
showArrow={false}
|
||||
filterOption={false}
|
||||
onSearch={this.handleSearch}
|
||||
onChange={this.handleChange}
|
||||
onFocus={this.handleFocus}
|
||||
notFoundContent={null}
|
||||
dropdownMatchSelectWidth={400}
|
||||
{...others}
|
||||
>
|
||||
{
|
||||
buildOption ?
|
||||
this.state.data.map(buildOption) :
|
||||
this.state.data.map(d => <Option key={d.value} value={d.value}>{d.text}</Option>)
|
||||
}
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default SelectWithAjax;
|
||||
32
src/components/SelectBox/SelectWithList.tsx
Normal file
32
src/components/SelectBox/SelectWithList.tsx
Normal file
@@ -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<string> ) => {
|
||||
|
||||
const {options, ...others} = props;
|
||||
|
||||
return (
|
||||
<Select
|
||||
showSearch
|
||||
optionFilterProp="children"
|
||||
filterOption={(input, option) =>
|
||||
// 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 => <Option key={op.value} value={op.value}>{op.text}</Option>)
|
||||
}
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
export default SelectWithList;
|
||||
3
src/components/SelectBox/index.tsx
Normal file
3
src/components/SelectBox/index.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export {default as SelectWithAjax} from './SelectWithAjax';
|
||||
export {default as SelectWithList} from './SelectWithList';
|
||||
export {default as SelectWithAddItem} from './SelectWithAddItem';
|
||||
219
src/components/Tagging/index.tsx
Normal file
219
src/components/Tagging/index.tsx
Normal file
@@ -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<boolean>;
|
||||
deleteTag?: (tag: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
|
||||
const AddNewTag = ({onEnter} : {onEnter: (txt: string) => void}) => {
|
||||
|
||||
const [openForm, setOpenForm] = useState<boolean>(false);
|
||||
const [value, setValue] = useState<string>('');
|
||||
|
||||
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 (
|
||||
<Input
|
||||
maxLength={50}
|
||||
placeholder={'Nhập và Enter để tạo tag'}
|
||||
style={{width: 120}}
|
||||
type="text"
|
||||
size="small"
|
||||
className="tag-input"
|
||||
value={value}
|
||||
onChange={(event) => {
|
||||
event.preventDefault();
|
||||
setValue(event.target.value);
|
||||
}}
|
||||
onBlur={onConfirm}
|
||||
onPressEnter={onConfirm}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Tag style={{cursor:'pointer'}} title={'Click để thêm tag'} className="site-tag-plus" onClick={() => setOpenForm(true)}>
|
||||
<PlusOutlined /> Thêm mới
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
class EditableTagGroup extends React.Component<EditableTagGroupProps, EditableTagGroupState> {
|
||||
|
||||
private editInputRef: React.RefObject<Input>;
|
||||
|
||||
constructor(props: EditableTagGroupProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
tags: [],
|
||||
editInputIndex: -1,
|
||||
editInputValue: '',
|
||||
loadExistingList: false
|
||||
}
|
||||
|
||||
this.editInputRef = createRef<Input>();
|
||||
}
|
||||
|
||||
|
||||
componentDidUpdate(prevProps: Readonly<EditableTagGroupProps>, prevState: Readonly<EditableTagGroupState>, 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<Tagging>();
|
||||
// const tags = taggingRef.current?.getTagList();
|
||||
getTagList = () => {
|
||||
return this.state.tags;
|
||||
}
|
||||
|
||||
clearList = () => {
|
||||
this.setState({ tags: [] });
|
||||
}
|
||||
|
||||
render() {
|
||||
|
||||
const { tags, editInputIndex, editInputValue } = this.state;
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{tags.map((tag, index) => {
|
||||
if (editInputIndex === index) {
|
||||
return (
|
||||
<Input
|
||||
ref={this.editInputRef}
|
||||
key={tag}
|
||||
size="small"
|
||||
className="tag-input"
|
||||
value={editInputValue}
|
||||
onChange={this.handleEditInputChange}
|
||||
onBlur={this.handleEditInputConfirm}
|
||||
onPressEnter={this.handleEditInputConfirm}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const isLongTag = tag.length > 20;
|
||||
|
||||
const tagElem = (
|
||||
<Tag
|
||||
className="edit-tag"
|
||||
key={tag}
|
||||
// closable={index !== 0}
|
||||
color={'processing'}
|
||||
closable
|
||||
onClose={() => this.handleClose(tag)}
|
||||
>
|
||||
<span
|
||||
onDoubleClick={e => {
|
||||
if (index !== 0) {
|
||||
this.setState({ editInputIndex: index, editInputValue: tag }, () => {
|
||||
this.editInputRef.current!.focus();
|
||||
});
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isLongTag ? `${tag.slice(0, 20)}...` : tag}
|
||||
</span>
|
||||
</Tag>
|
||||
);
|
||||
|
||||
return isLongTag ? (
|
||||
<Tooltip title={tag} key={tag}>
|
||||
{tagElem}
|
||||
</Tooltip>
|
||||
) : (
|
||||
tagElem
|
||||
);
|
||||
})}
|
||||
|
||||
<AddNewTag onEnter={this.handleInputConfirm} />
|
||||
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default EditableTagGroup;
|
||||
14
src/components/TextWithLineBreak/index.tsx
Normal file
14
src/components/TextWithLineBreak/index.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from "react";
|
||||
|
||||
const TextWithLineBreak = ({txt}: {txt?: string}) => {
|
||||
|
||||
if( !txt ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div dangerouslySetInnerHTML={{__html: txt.replace(/\n/g, "<br/>")}} />
|
||||
)
|
||||
}
|
||||
|
||||
export default TextWithLineBreak;
|
||||
48
src/components/Toolbar/Toolbar.css
Normal file
48
src/components/Toolbar/Toolbar.css
Normal file
@@ -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;
|
||||
}
|
||||
13
src/components/Toolbar/index.js
Normal file
13
src/components/Toolbar/index.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
import './Toolbar.css';
|
||||
|
||||
export default function Toolbar(props) {
|
||||
const { title, leftItems, rightItems } = props;
|
||||
return (
|
||||
<div className="toolbar">
|
||||
<div className="left-items">{ leftItems }</div>
|
||||
<h1 className="toolbar-title">{ title }</h1>
|
||||
<div className="right-items">{ rightItems }</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
15
src/components/ToolbarButton/ToolbarButton.css
Normal file
15
src/components/ToolbarButton/ToolbarButton.css
Normal file
@@ -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;
|
||||
}
|
||||
9
src/components/ToolbarButton/index.js
Normal file
9
src/components/ToolbarButton/index.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
import './ToolbarButton.css';
|
||||
|
||||
export default function ToolbarButton(props) {
|
||||
const { icon } = props;
|
||||
return (
|
||||
<i className={`toolbar-button ${icon}`} />
|
||||
);
|
||||
}
|
||||
145
src/components/Upload/ImageUploadWithPreview.tsx
Normal file
145
src/components/Upload/ImageUploadWithPreview.tsx
Normal file
@@ -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<string>;
|
||||
|
||||
type ImageUploadWithPreviewState = {
|
||||
previewVisible: boolean,
|
||||
previewImage: string,
|
||||
previewTitle: string,
|
||||
fileList: ImageUploadType[]
|
||||
}
|
||||
|
||||
const defaultUploadButton = (
|
||||
<div>
|
||||
<PlusOutlined />
|
||||
<div style={{ marginTop: 8 }}>Upload</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
// copy from https://ant.design/components/upload/ PicturesWall
|
||||
class ImageUploadWithPreview extends Component<ImageUploadWithPreviewProps, ImageUploadWithPreviewState> {
|
||||
|
||||
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 (
|
||||
<Fragment>
|
||||
<Upload
|
||||
action={SERVER_FILE_UPLOAD}
|
||||
listType="picture-card"
|
||||
// @ts-ignore
|
||||
fileList={fileList}
|
||||
onPreview={this.handlePreview}
|
||||
onChange={this.handleChange}
|
||||
multiple
|
||||
// select from https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/file#unique_file_type_specifiers
|
||||
accept={'image/*'}
|
||||
name={'chatboard_file'}
|
||||
headers={{
|
||||
Authorization: this.props.auth,
|
||||
}}
|
||||
{...others}
|
||||
>
|
||||
{ fileList.length >= MAX_ALLOW_UPLOADED_FILE ? null : (uploadButton || defaultUploadButton)}
|
||||
</Upload>
|
||||
|
||||
<Modal
|
||||
visible={previewVisible}
|
||||
title={previewTitle}
|
||||
footer={null}
|
||||
onCancel={this.handleCancel}
|
||||
>
|
||||
<img alt="example" style={{ width: '100%' }} src={previewImage} />
|
||||
</Modal>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default ImageUploadWithPreview;
|
||||
53
src/components/Upload/UploadWithFileName.tsx
Normal file
53
src/components/Upload/UploadWithFileName.tsx
Normal file
@@ -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: <StarOutlined onClick={e => console.log(e, 'custom removeIcon event')} />,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
// @ts-ignore
|
||||
<Upload {...props}>
|
||||
<Button icon={<UploadOutlined />}>Upload</Button>
|
||||
</Upload>
|
||||
)
|
||||
}
|
||||
|
||||
export default UploadWithFileName;
|
||||
2
src/components/Upload/index.tsx
Normal file
2
src/components/Upload/index.tsx
Normal file
@@ -0,0 +1,2 @@
|
||||
export {default as ImageUploadWithPreview} from "./ImageUploadWithPreview";
|
||||
export {default as UploadWithFileName} from "./UploadWithFileName";
|
||||
22
src/components/display/OrderStatus.tsx
Normal file
22
src/components/display/OrderStatus.tsx
Normal file
@@ -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 (
|
||||
<Tag color={colorMapping[status]}>{status}</Tag>
|
||||
)
|
||||
}
|
||||
|
||||
export default OrderStatus;
|
||||
22
src/components/display/PaymentStatus.tsx
Normal file
22
src/components/display/PaymentStatus.tsx
Normal file
@@ -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 (
|
||||
<Tag color={colorMapping[status]}>{status}</Tag>
|
||||
)
|
||||
}
|
||||
|
||||
export default PaymentStatus;
|
||||
22
src/components/display/ShippingStatus.tsx
Normal file
22
src/components/display/ShippingStatus.tsx
Normal file
@@ -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 (
|
||||
<Tag color={colorMapping[status]}>{status}</Tag>
|
||||
)
|
||||
}
|
||||
|
||||
export default ShippingStatus;
|
||||
22
src/config.ts
Normal file
22
src/config.ts
Normal file
@@ -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 = {
|
||||
|
||||
}
|
||||
7
src/constant/gender.ts
Normal file
7
src/constant/gender.ts
Normal file
@@ -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;
|
||||
9
src/constant/payment.ts
Normal file
9
src/constant/payment.ts
Normal file
@@ -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;
|
||||
71
src/constant/province_list.ts
Normal file
71
src/constant/province_list.ts
Normal file
@@ -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;
|
||||
12
src/constant/shipping.ts
Normal file
12
src/constant/shipping.ts
Normal file
@@ -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"},
|
||||
];
|
||||
11
src/constant/text.ts
Normal file
11
src/constant/text.ts
Normal file
@@ -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ủ.',
|
||||
}
|
||||
12
src/index.css
Normal file
12
src/index.css
Normal file
@@ -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;
|
||||
}
|
||||
52
src/index.tsx
Normal file
52
src/index.tsx
Normal file
@@ -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(<Error message={'Please use a modern browser in order to use this application'}/>, render_root);
|
||||
return;
|
||||
}
|
||||
|
||||
const client_setting = await getSettings();
|
||||
if (! client_setting ) {
|
||||
ReactDOM.render(<Error message={'Can not get setting'}/>, render_root);
|
||||
return;
|
||||
}
|
||||
|
||||
await setUp();
|
||||
|
||||
const Root = () => (
|
||||
<Provider store={store}>
|
||||
<App client_setting={client_setting} />
|
||||
</Provider>
|
||||
)
|
||||
|
||||
ReactDOM.render(<Root />, 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();
|
||||
146
src/lib/api.ts
Normal file
146
src/lib/api.ts
Normal file
@@ -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<APIResultType> => {
|
||||
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<APIResultType> => {
|
||||
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<APIResultType> => {
|
||||
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<APIResultType> => {
|
||||
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<APIResultType> => {
|
||||
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<boolean> {
|
||||
let result = await api.post('tag/create', payload);
|
||||
return result.status === 'ok';
|
||||
}
|
||||
|
||||
|
||||
export async function getUserChatHistory(opts: {thread_id: string, last_fetch?: number}): Promise<ChatboxTextMessage[]>{
|
||||
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() : [];
|
||||
}
|
||||
30
src/lib/chatngay.ts
Normal file
30
src/lib/chatngay.ts
Normal file
@@ -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
|
||||
}
|
||||
|
||||
|
||||
18
src/lib/emitter.ts
Normal file
18
src/lib/emitter.ts
Normal file
@@ -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;
|
||||
38
src/lib/messaging.ts
Normal file
38
src/lib/messaging.ts
Normal file
@@ -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;
|
||||
}
|
||||
49
src/lib/networking.ts
Normal file
49
src/lib/networking.ts
Normal file
@@ -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!");
|
||||
}
|
||||
127
src/lib/notification.ts
Normal file
127
src/lib/notification.ts
Normal file
@@ -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));
|
||||
}
|
||||
79
src/lib/personalize.ts
Normal file
79
src/lib/personalize.ts
Normal file
@@ -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);
|
||||
}*/
|
||||
}
|
||||
96
src/lib/public_ip.ts
Normal file
96
src/lib/public_ip.ts
Normal file
@@ -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;
|
||||
31
src/lib/registry.ts
Normal file
31
src/lib/registry.ts
Normal file
@@ -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;
|
||||
57
src/lib/schedule.ts
Normal file
57
src/lib/schedule.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
class SimpleSchedule {
|
||||
|
||||
private task_is_running: boolean;
|
||||
private taskCaller: () => Promise<any>;
|
||||
private trackInterval: NodeJS.Timeout | null;
|
||||
private checkInterval: number; //default
|
||||
|
||||
constructor(taskFn: () => Promise<any>, 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;
|
||||
72
src/lib/security.ts
Normal file
72
src/lib/security.ts
Normal file
@@ -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;
|
||||
}
|
||||
30
src/lib/storage.ts
Normal file
30
src/lib/storage.ts
Normal file
@@ -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;
|
||||
}
|
||||
19
src/lib/theme.ts
Normal file
19
src/lib/theme.ts
Normal file
@@ -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();
|
||||
}
|
||||
4
src/lib/upload.ts
Normal file
4
src/lib/upload.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// 19-05-2021: For dev
|
||||
export const uploadFile = (file: Blob) => {
|
||||
// TODO:
|
||||
}
|
||||
67
src/lib/user.ts
Normal file
67
src/lib/user.ts
Normal file
@@ -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
|
||||
}
|
||||
311
src/lib/utils.ts
Normal file
311
src/lib/utils.ts
Normal file
@@ -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, '<a href="//www.chatngay.com/redirect.php?url=$1" target="_blank">$1</a>')
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @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);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user