From ab2830f4490455c4bc38924e8ab15386a6db9244 Mon Sep 17 00:00:00 2001 From: hieutmd Date: Wed, 19 May 2021 11:57:16 +0700 Subject: [PATCH] up --- .env | 2 + .gitignore | 25 + public/favicon.ico | Bin 0 -> 3870 bytes public/index.html | 46 ++ public/logo192.png | Bin 0 -> 5347 bytes public/logo512.png | Bin 0 -> 9664 bytes public/manifest.json | 25 + public/robots.txt | 3 + public/static/webworker_client.js | 49 ++ src/App.tsx | 165 +++++++ src/assets/typing-animation.gif | Bin 0 -> 97037 bytes .../ActionTabs/components/CreateLeadForm.tsx | 123 +++++ .../ActionTabs/components/CreateNoteForm.tsx | 63 +++ .../ActionTabs/components/CreateOrderForm.tsx | 429 ++++++++++++++++++ .../components/CreateSupportForm.tsx | 146 ++++++ .../ActionTabs/components/CreateTag.tsx | 38 ++ src/components/ActionTabs/index.tsx | 45 ++ src/components/BadgeStatus/index.tsx | 14 + src/components/BadgeStatus/styles.css | 8 + src/components/Chatbox/index.tsx | 4 + src/components/Chatbox/ver/Chatbox.css | 108 +++++ src/components/Chatbox/ver/Chatbox.tsx | 311 +++++++++++++ src/components/Chatbox/ver/InputMessage.tsx | 160 +++++++ src/components/Chatbox/ver/MessageItem.tsx | 108 +++++ src/components/Chatbox/ver/Sample.tsx | 219 +++++++++ .../Chatbox/ver/TypingNotification.tsx | 13 + src/components/Comment/index.tsx | 123 +++++ .../ConversationList/ConversationList.css | 63 +++ src/components/ConversationList/index.tsx | 143 ++++++ src/components/CustomComponent/guide.txt | 67 +++ src/components/CustomComponent/index.tsx | 24 + .../CustomerInfo/components/BrowseHistory.tsx | 91 ++++ .../CustomerInfo/components/Info.tsx | 236 ++++++++++ .../CustomerInfo/components/ListNote.tsx | 96 ++++ .../CustomerInfo/components/ListOrder.tsx | 119 +++++ .../CustomerInfo/components/ListSupport.tsx | 107 +++++ src/components/CustomerInfo/index.tsx | 52 +++ src/components/DashBoard/index.tsx | 9 + src/components/Error/ItemNotFound.tsx | 9 + src/components/Error/NetworkError.tsx | 77 ++++ src/components/Error/index.tsx | 11 + .../GlobalDrawer/components/NoteDetail.tsx | 32 ++ .../GlobalDrawer/components/OrderDetail.tsx | 96 ++++ .../GlobalDrawer/components/SupportDetail.tsx | 40 ++ src/components/GlobalDrawer/index.tsx | 88 ++++ .../components/ListAdminOnline.tsx | 40 ++ .../GlobalModal/components/ListUserOnline.tsx | 26 ++ src/components/GlobalModal/index.tsx | 87 ++++ src/components/HeaderComponent/index.tsx | 104 +++++ src/components/HeaderComponent/styles.css | 9 + src/components/Help/HelpModal.tsx | 26 ++ src/components/Help/HelpSideBar.tsx | 99 ++++ .../Help/components/ArticleDetail.tsx | 36 ++ .../Help/components/ArticleList.tsx | 89 ++++ .../Help/components/ProductDetail.tsx | 55 +++ .../Help/components/ProductList.tsx | 106 +++++ src/components/Help/components/Search.tsx | 49 ++ src/components/Help/components/SearchBox.tsx | 39 ++ src/components/Help/index.tsx | 2 + src/components/Loading/index.tsx | 14 + .../SelectBox/SelectWithAddItem.tsx | 83 ++++ src/components/SelectBox/SelectWithAjax.tsx | 79 ++++ src/components/SelectBox/SelectWithList.tsx | 32 ++ src/components/SelectBox/index.tsx | 3 + src/components/Tagging/index.tsx | 219 +++++++++ src/components/TextWithLineBreak/index.tsx | 14 + src/components/Toolbar/Toolbar.css | 48 ++ src/components/Toolbar/index.js | 13 + .../ToolbarButton/ToolbarButton.css | 15 + src/components/ToolbarButton/index.js | 9 + .../Upload/ImageUploadWithPreview.tsx | 145 ++++++ src/components/Upload/UploadWithFileName.tsx | 53 +++ src/components/Upload/index.tsx | 2 + src/components/display/OrderStatus.tsx | 22 + src/components/display/PaymentStatus.tsx | 22 + src/components/display/ShippingStatus.tsx | 22 + src/config.ts | 22 + src/constant/gender.ts | 7 + src/constant/payment.ts | 9 + src/constant/province_list.ts | 71 +++ src/constant/shipping.ts | 12 + src/constant/text.ts | 11 + src/index.css | 12 + src/index.tsx | 52 +++ src/lib/api.ts | 146 ++++++ src/lib/chatngay.ts | 30 ++ src/lib/emitter.ts | 18 + src/lib/messaging.ts | 38 ++ src/lib/networking.ts | 49 ++ src/lib/notification.ts | 127 ++++++ src/lib/personalize.ts | 79 ++++ src/lib/public_ip.ts | 96 ++++ src/lib/registry.ts | 31 ++ src/lib/schedule.ts | 57 +++ src/lib/security.ts | 72 +++ src/lib/storage.ts | 30 ++ src/lib/theme.ts | 19 + src/lib/upload.ts | 4 + src/lib/user.ts | 67 +++ src/lib/utils.ts | 311 +++++++++++++ src/lib/validation.ts | 48 ++ src/lib/vietnamese.ts | 257 +++++++++++ src/lib/webworker.ts | 9 + src/logo.svg | 1 + src/react-app-env.d.ts | 1 + src/reportWebVitals.ts | 15 + src/setup.ts | 73 +++ src/setupProxy.js | 21 + src/setupTests.ts | 5 + src/store/actions.ts | 143 ++++++ src/store/dispatcher.ts | 55 +++ src/store/index.ts | 10 + src/store/persist.ts | 15 + src/store/reducers.ts | 413 +++++++++++++++++ src/store/typing.d.ts | 45 ++ src/styles/app.css | 37 ++ src/test/index.ts | 5 + src/test/test_state.ts | 17 + src/test/test_util.ts | 3 + src/typings/index.ts | 100 ++++ src/typings/message.d.ts | 175 +++++++ src/typings/network.d.ts | 7 + src/typings/user.d.ts | 79 ++++ src/typings/websocket.d.ts | 8 + 124 files changed, 8061 insertions(+) create mode 100644 .env create mode 100644 .gitignore create mode 100644 public/favicon.ico create mode 100644 public/index.html create mode 100644 public/logo192.png create mode 100644 public/logo512.png create mode 100644 public/manifest.json create mode 100644 public/robots.txt create mode 100644 public/static/webworker_client.js create mode 100644 src/App.tsx create mode 100644 src/assets/typing-animation.gif create mode 100644 src/components/ActionTabs/components/CreateLeadForm.tsx create mode 100644 src/components/ActionTabs/components/CreateNoteForm.tsx create mode 100644 src/components/ActionTabs/components/CreateOrderForm.tsx create mode 100644 src/components/ActionTabs/components/CreateSupportForm.tsx create mode 100644 src/components/ActionTabs/components/CreateTag.tsx create mode 100644 src/components/ActionTabs/index.tsx create mode 100644 src/components/BadgeStatus/index.tsx create mode 100644 src/components/BadgeStatus/styles.css create mode 100644 src/components/Chatbox/index.tsx create mode 100644 src/components/Chatbox/ver/Chatbox.css create mode 100644 src/components/Chatbox/ver/Chatbox.tsx create mode 100644 src/components/Chatbox/ver/InputMessage.tsx create mode 100644 src/components/Chatbox/ver/MessageItem.tsx create mode 100644 src/components/Chatbox/ver/Sample.tsx create mode 100644 src/components/Chatbox/ver/TypingNotification.tsx create mode 100644 src/components/Comment/index.tsx create mode 100644 src/components/ConversationList/ConversationList.css create mode 100644 src/components/ConversationList/index.tsx create mode 100644 src/components/CustomComponent/guide.txt create mode 100644 src/components/CustomComponent/index.tsx create mode 100644 src/components/CustomerInfo/components/BrowseHistory.tsx create mode 100644 src/components/CustomerInfo/components/Info.tsx create mode 100644 src/components/CustomerInfo/components/ListNote.tsx create mode 100644 src/components/CustomerInfo/components/ListOrder.tsx create mode 100644 src/components/CustomerInfo/components/ListSupport.tsx create mode 100644 src/components/CustomerInfo/index.tsx create mode 100644 src/components/DashBoard/index.tsx create mode 100644 src/components/Error/ItemNotFound.tsx create mode 100644 src/components/Error/NetworkError.tsx create mode 100644 src/components/Error/index.tsx create mode 100644 src/components/GlobalDrawer/components/NoteDetail.tsx create mode 100644 src/components/GlobalDrawer/components/OrderDetail.tsx create mode 100644 src/components/GlobalDrawer/components/SupportDetail.tsx create mode 100644 src/components/GlobalDrawer/index.tsx create mode 100644 src/components/GlobalModal/components/ListAdminOnline.tsx create mode 100644 src/components/GlobalModal/components/ListUserOnline.tsx create mode 100644 src/components/GlobalModal/index.tsx create mode 100644 src/components/HeaderComponent/index.tsx create mode 100644 src/components/HeaderComponent/styles.css create mode 100644 src/components/Help/HelpModal.tsx create mode 100644 src/components/Help/HelpSideBar.tsx create mode 100644 src/components/Help/components/ArticleDetail.tsx create mode 100644 src/components/Help/components/ArticleList.tsx create mode 100644 src/components/Help/components/ProductDetail.tsx create mode 100644 src/components/Help/components/ProductList.tsx create mode 100644 src/components/Help/components/Search.tsx create mode 100644 src/components/Help/components/SearchBox.tsx create mode 100644 src/components/Help/index.tsx create mode 100644 src/components/Loading/index.tsx create mode 100644 src/components/SelectBox/SelectWithAddItem.tsx create mode 100644 src/components/SelectBox/SelectWithAjax.tsx create mode 100644 src/components/SelectBox/SelectWithList.tsx create mode 100644 src/components/SelectBox/index.tsx create mode 100644 src/components/Tagging/index.tsx create mode 100644 src/components/TextWithLineBreak/index.tsx create mode 100644 src/components/Toolbar/Toolbar.css create mode 100644 src/components/Toolbar/index.js create mode 100644 src/components/ToolbarButton/ToolbarButton.css create mode 100644 src/components/ToolbarButton/index.js create mode 100644 src/components/Upload/ImageUploadWithPreview.tsx create mode 100644 src/components/Upload/UploadWithFileName.tsx create mode 100644 src/components/Upload/index.tsx create mode 100644 src/components/display/OrderStatus.tsx create mode 100644 src/components/display/PaymentStatus.tsx create mode 100644 src/components/display/ShippingStatus.tsx create mode 100644 src/config.ts create mode 100644 src/constant/gender.ts create mode 100644 src/constant/payment.ts create mode 100644 src/constant/province_list.ts create mode 100644 src/constant/shipping.ts create mode 100644 src/constant/text.ts create mode 100644 src/index.css create mode 100644 src/index.tsx create mode 100644 src/lib/api.ts create mode 100644 src/lib/chatngay.ts create mode 100644 src/lib/emitter.ts create mode 100644 src/lib/messaging.ts create mode 100644 src/lib/networking.ts create mode 100644 src/lib/notification.ts create mode 100644 src/lib/personalize.ts create mode 100644 src/lib/public_ip.ts create mode 100644 src/lib/registry.ts create mode 100644 src/lib/schedule.ts create mode 100644 src/lib/security.ts create mode 100644 src/lib/storage.ts create mode 100644 src/lib/theme.ts create mode 100644 src/lib/upload.ts create mode 100644 src/lib/user.ts create mode 100644 src/lib/utils.ts create mode 100644 src/lib/validation.ts create mode 100644 src/lib/vietnamese.ts create mode 100644 src/lib/webworker.ts create mode 100644 src/logo.svg create mode 100644 src/react-app-env.d.ts create mode 100644 src/reportWebVitals.ts create mode 100644 src/setup.ts create mode 100644 src/setupProxy.js create mode 100644 src/setupTests.ts create mode 100644 src/store/actions.ts create mode 100644 src/store/dispatcher.ts create mode 100644 src/store/index.ts create mode 100644 src/store/persist.ts create mode 100644 src/store/reducers.ts create mode 100644 src/store/typing.d.ts create mode 100644 src/styles/app.css create mode 100644 src/test/index.ts create mode 100644 src/test/test_state.ts create mode 100644 src/test/test_util.ts create mode 100644 src/typings/index.ts create mode 100644 src/typings/message.d.ts create mode 100644 src/typings/network.d.ts create mode 100644 src/typings/user.d.ts create mode 100644 src/typings/websocket.d.ts diff --git a/.env b/.env new file mode 100644 index 0000000..3280d89 --- /dev/null +++ b/.env @@ -0,0 +1,2 @@ +FAST_FRESH=false +NODE_PATH=src/ \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e5bd9ab --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +/.idea + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# production +/build + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..a11777cc471a4344702741ab1c8a588998b1311a GIT binary patch literal 3870 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 baj7?x%VD!5Cxk2YbYLNVeiXvvpMCWYo=by@ literal 0 HcmV?d00001 diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..6e3a838 --- /dev/null +++ b/public/index.html @@ -0,0 +1,46 @@ + + + + + + + + + + + + + Chatngay - Chatboard + + + + + + +
+ + + diff --git a/public/logo192.png b/public/logo192.png new file mode 100644 index 0000000000000000000000000000000000000000..fc44b0a3796c0e0a64c3d858ca038bd4570465d9 GIT binary patch literal 5347 zcmZWtbyO6NvR-oO24RV%BvuJ&=?+<7=`LvyB&A_#M7mSDYw1v6DJkiYl9XjT!%$dLEBTQ8R9|wd3008in6lFF3GV-6mLi?MoP_y~}QUnaDCHI#t z7w^m$@6DI)|C8_jrT?q=f8D?0AM?L)Z}xAo^e^W>t$*Y0KlT5=@bBjT9kxb%-KNdk zeOS1tKO#ChhG7%{ApNBzE2ZVNcxbrin#E1TiAw#BlUhXllzhN$qWez5l;h+t^q#Eav8PhR2|T}y5kkflaK`ba-eoE+Z2q@o6P$)=&` z+(8}+-McnNO>e#$Rr{32ngsZIAX>GH??tqgwUuUz6kjns|LjsB37zUEWd|(&O!)DY zQLrq%Y>)Y8G`yYbYCx&aVHi@-vZ3|ebG!f$sTQqMgi0hWRJ^Wc+Ibv!udh_r%2|U) zPi|E^PK?UE!>_4`f`1k4hqqj_$+d!EB_#IYt;f9)fBOumGNyglU(ofY`yHq4Y?B%- zp&G!MRY<~ajTgIHErMe(Z8JG*;D-PJhd@RX@QatggM7+G(Lz8eZ;73)72Hfx5KDOE zkT(m}i2;@X2AT5fW?qVp?@WgN$aT+f_6eo?IsLh;jscNRp|8H}Z9p_UBO^SJXpZew zEK8fz|0Th%(Wr|KZBGTM4yxkA5CFdAj8=QSrT$fKW#tweUFqr0TZ9D~a5lF{)%-tTGMK^2tz(y2v$i%V8XAxIywrZCp=)83p(zIk6@S5AWl|Oa2hF`~~^W zI;KeOSkw1O#TiQ8;U7OPXjZM|KrnN}9arP)m0v$c|L)lF`j_rpG(zW1Qjv$=^|p*f z>)Na{D&>n`jOWMwB^TM}slgTEcjxTlUby89j1)|6ydRfWERn3|7Zd2&e7?!K&5G$x z`5U3uFtn4~SZq|LjFVrz$3iln-+ucY4q$BC{CSm7Xe5c1J<=%Oagztj{ifpaZk_bQ z9Sb-LaQMKp-qJA*bP6DzgE3`}*i1o3GKmo2pn@dj0;He}F=BgINo};6gQF8!n0ULZ zL>kC0nPSFzlcB7p41doao2F7%6IUTi_+!L`MM4o*#Y#0v~WiO8uSeAUNp=vA2KaR&=jNR2iVwG>7t%sG2x_~yXzY)7K& zk3p+O0AFZ1eu^T3s};B%6TpJ6h-Y%B^*zT&SN7C=N;g|#dGIVMSOru3iv^SvO>h4M=t-N1GSLLDqVTcgurco6)3&XpU!FP6Hlrmj}f$ zp95;b)>M~`kxuZF3r~a!rMf4|&1=uMG$;h^g=Kl;H&Np-(pFT9FF@++MMEx3RBsK?AU0fPk-#mdR)Wdkj)`>ZMl#^<80kM87VvsI3r_c@_vX=fdQ`_9-d(xiI z4K;1y1TiPj_RPh*SpDI7U~^QQ?%0&!$Sh#?x_@;ag)P}ZkAik{_WPB4rHyW#%>|Gs zdbhyt=qQPA7`?h2_8T;-E6HI#im9K>au*(j4;kzwMSLgo6u*}-K`$_Gzgu&XE)udQ zmQ72^eZd|vzI)~!20JV-v-T|<4@7ruqrj|o4=JJPlybwMg;M$Ud7>h6g()CT@wXm` zbq=A(t;RJ^{Xxi*Ff~!|3!-l_PS{AyNAU~t{h;(N(PXMEf^R(B+ZVX3 z8y0;0A8hJYp@g+c*`>eTA|3Tgv9U8#BDTO9@a@gVMDxr(fVaEqL1tl?md{v^j8aUv zm&%PX4^|rX|?E4^CkplWWNv*OKM>DxPa z!RJ)U^0-WJMi)Ksc!^ixOtw^egoAZZ2Cg;X7(5xZG7yL_;UJ#yp*ZD-;I^Z9qkP`} zwCTs0*%rIVF1sgLervtnUo&brwz?6?PXRuOCS*JI-WL6GKy7-~yi0giTEMmDs_-UX zo=+nFrW_EfTg>oY72_4Z0*uG>MnXP=c0VpT&*|rvv1iStW;*^={rP1y?Hv+6R6bxFMkxpWkJ>m7Ba{>zc_q zEefC3jsXdyS5??Mz7IET$Kft|EMNJIv7Ny8ZOcKnzf`K5Cd)&`-fTY#W&jnV0l2vt z?Gqhic}l}mCv1yUEy$%DP}4AN;36$=7aNI^*AzV(eYGeJ(Px-j<^gSDp5dBAv2#?; zcMXv#aj>%;MiG^q^$0MSg-(uTl!xm49dH!{X0){Ew7ThWV~Gtj7h%ZD zVN-R-^7Cf0VH!8O)uUHPL2mO2tmE*cecwQv_5CzWeh)ykX8r5Hi`ehYo)d{Jnh&3p z9ndXT$OW51#H5cFKa76c<%nNkP~FU93b5h-|Cb}ScHs@4Q#|}byWg;KDMJ#|l zE=MKD*F@HDBcX@~QJH%56eh~jfPO-uKm}~t7VkHxHT;)4sd+?Wc4* z>CyR*{w@4(gnYRdFq=^(#-ytb^5ESD?x<0Skhb%Pt?npNW1m+Nv`tr9+qN<3H1f<% zZvNEqyK5FgPsQ`QIu9P0x_}wJR~^CotL|n zk?dn;tLRw9jJTur4uWoX6iMm914f0AJfB@C74a;_qRrAP4E7l890P&{v<}>_&GLrW z)klculcg`?zJO~4;BBAa=POU%aN|pmZJn2{hA!d!*lwO%YSIzv8bTJ}=nhC^n}g(ld^rn#kq9Z3)z`k9lvV>y#!F4e{5c$tnr9M{V)0m(Z< z#88vX6-AW7T2UUwW`g<;8I$Jb!R%z@rCcGT)-2k7&x9kZZT66}Ztid~6t0jKb&9mm zpa}LCb`bz`{MzpZR#E*QuBiZXI#<`5qxx=&LMr-UUf~@dRk}YI2hbMsAMWOmDzYtm zjof16D=mc`^B$+_bCG$$@R0t;e?~UkF?7<(vkb70*EQB1rfUWXh$j)R2)+dNAH5%R zEBs^?N;UMdy}V};59Gu#0$q53$}|+q7CIGg_w_WlvE}AdqoS<7DY1LWS9?TrfmcvT zaypmplwn=P4;a8-%l^e?f`OpGb}%(_mFsL&GywhyN(-VROj`4~V~9bGv%UhcA|YW% zs{;nh@aDX11y^HOFXB$a7#Sr3cEtNd4eLm@Y#fc&j)TGvbbMwze zXtekX_wJqxe4NhuW$r}cNy|L{V=t#$%SuWEW)YZTH|!iT79k#?632OFse{+BT_gau zJwQcbH{b}dzKO?^dV&3nTILYlGw{27UJ72ZN){BILd_HV_s$WfI2DC<9LIHFmtyw? zQ;?MuK7g%Ym+4e^W#5}WDLpko%jPOC=aN)3!=8)s#Rnercak&b3ESRX3z{xfKBF8L z5%CGkFmGO@x?_mPGlpEej!3!AMddChabyf~nJNZxx!D&{@xEb!TDyvqSj%Y5@A{}9 zRzoBn0?x}=krh{ok3Nn%e)#~uh;6jpezhA)ySb^b#E>73e*frBFu6IZ^D7Ii&rsiU z%jzygxT-n*joJpY4o&8UXr2s%j^Q{?e-voloX`4DQyEK+DmrZh8A$)iWL#NO9+Y@!sO2f@rI!@jN@>HOA< z?q2l{^%mY*PNx2FoX+A7X3N}(RV$B`g&N=e0uvAvEN1W^{*W?zT1i#fxuw10%~))J zjx#gxoVlXREWZf4hRkgdHx5V_S*;p-y%JtGgQ4}lnA~MBz-AFdxUxU1RIT$`sal|X zPB6sEVRjGbXIP0U+?rT|y5+ev&OMX*5C$n2SBPZr`jqzrmpVrNciR0e*Wm?fK6DY& zl(XQZ60yWXV-|Ps!A{EF;=_z(YAF=T(-MkJXUoX zI{UMQDAV2}Ya?EisdEW;@pE6dt;j0fg5oT2dxCi{wqWJ<)|SR6fxX~5CzblPGr8cb zUBVJ2CQd~3L?7yfTpLNbt)He1D>*KXI^GK%<`bq^cUq$Q@uJifG>p3LU(!H=C)aEL zenk7pVg}0{dKU}&l)Y2Y2eFMdS(JS0}oZUuVaf2+K*YFNGHB`^YGcIpnBlMhO7d4@vV zv(@N}(k#REdul8~fP+^F@ky*wt@~&|(&&meNO>rKDEnB{ykAZ}k>e@lad7to>Ao$B zz<1(L=#J*u4_LB=8w+*{KFK^u00NAmeNN7pr+Pf+N*Zl^dO{LM-hMHyP6N!~`24jd zXYP|Ze;dRXKdF2iJG$U{k=S86l@pytLx}$JFFs8e)*Vi?aVBtGJ3JZUj!~c{(rw5>vuRF$`^p!P8w1B=O!skwkO5yd4_XuG^QVF z`-r5K7(IPSiKQ2|U9+`@Js!g6sfJwAHVd|s?|mnC*q zp|B|z)(8+mxXyxQ{8Pg3F4|tdpgZZSoU4P&9I8)nHo1@)9_9u&NcT^FI)6|hsAZFk zZ+arl&@*>RXBf-OZxhZerOr&dN5LW9@gV=oGFbK*J+m#R-|e6(Loz(;g@T^*oO)0R zN`N=X46b{7yk5FZGr#5&n1!-@j@g02g|X>MOpF3#IjZ_4wg{dX+G9eqS+Es9@6nC7 zD9$NuVJI}6ZlwtUm5cCAiYv0(Yi{%eH+}t)!E^>^KxB5^L~a`4%1~5q6h>d;paC9c zTj0wTCKrhWf+F#5>EgX`sl%POl?oyCq0(w0xoL?L%)|Q7d|Hl92rUYAU#lc**I&^6p=4lNQPa0 znQ|A~i0ip@`B=FW-Q;zh?-wF;Wl5!+q3GXDu-x&}$gUO)NoO7^$BeEIrd~1Dh{Tr` z8s<(Bn@gZ(mkIGnmYh_ehXnq78QL$pNDi)|QcT*|GtS%nz1uKE+E{7jdEBp%h0}%r zD2|KmYGiPa4;md-t_m5YDz#c*oV_FqXd85d@eub?9N61QuYcb3CnVWpM(D-^|CmkL z(F}L&N7qhL2PCq)fRh}XO@U`Yn<?TNGR4L(mF7#4u29{i~@k;pLsgl({YW5`Mo+p=zZn3L*4{JU;++dG9 X@eDJUQo;Ye2mwlRs?y0|+_a0zY+Zo%Dkae}+MySoIppb75o?vUW_?)>@g{U2`ERQIXV zeY$JrWnMZ$QC<=ii4X|@0H8`si75jB(ElJb00HAB%>SlLR{!zO|C9P3zxw_U8?1d8uRZ=({Ga4shyN}3 zAK}WA(ds|``G4jA)9}Bt2Hy0+f3rV1E6b|@?hpGA=PI&r8)ah|)I2s(P5Ic*Ndhn^ z*T&j@gbCTv7+8rpYbR^Ty}1AY)YH;p!m948r#%7x^Z@_-w{pDl|1S4`EM3n_PaXvK z1JF)E3qy$qTj5Xs{jU9k=y%SQ0>8E$;x?p9ayU0bZZeo{5Z@&FKX>}s!0+^>C^D#z z>xsCPvxD3Z=dP}TTOSJhNTPyVt14VCQ9MQFN`rn!c&_p?&4<5_PGm4a;WS&1(!qKE z_H$;dDdiPQ!F_gsN`2>`X}$I=B;={R8%L~`>RyKcS$72ai$!2>d(YkciA^J0@X%G4 z4cu!%Ps~2JuJ8ex`&;Fa0NQOq_nDZ&X;^A=oc1&f#3P1(!5il>6?uK4QpEG8z0Rhu zvBJ+A9RV?z%v?!$=(vcH?*;vRs*+PPbOQ3cdPr5=tOcLqmfx@#hOqX0iN)wTTO21jH<>jpmwRIAGw7`a|sl?9y9zRBh>(_%| zF?h|P7}~RKj?HR+q|4U`CjRmV-$mLW>MScKnNXiv{vD3&2@*u)-6P@h0A`eeZ7}71 zK(w%@R<4lLt`O7fs1E)$5iGb~fPfJ?WxhY7c3Q>T-w#wT&zW522pH-B%r5v#5y^CF zcC30Se|`D2mY$hAlIULL%-PNXgbbpRHgn<&X3N9W!@BUk@9g*P5mz-YnZBb*-$zMM z7Qq}ic0mR8n{^L|=+diODdV}Q!gwr?y+2m=3HWwMq4z)DqYVg0J~^}-%7rMR@S1;9 z7GFj6K}i32X;3*$SmzB&HW{PJ55kT+EI#SsZf}bD7nW^Haf}_gXciYKX{QBxIPSx2Ma? zHQqgzZq!_{&zg{yxqv3xq8YV+`S}F6A>Gtl39_m;K4dA{pP$BW0oIXJ>jEQ!2V3A2 zdpoTxG&V=(?^q?ZTj2ZUpDUdMb)T?E$}CI>r@}PFPWD9@*%V6;4Ag>D#h>!s)=$0R zRXvdkZ%|c}ubej`jl?cS$onl9Tw52rBKT)kgyw~Xy%z62Lr%V6Y=f?2)J|bZJ5(Wx zmji`O;_B+*X@qe-#~`HFP<{8$w@z4@&`q^Q-Zk8JG3>WalhnW1cvnoVw>*R@c&|o8 zZ%w!{Z+MHeZ*OE4v*otkZqz11*s!#s^Gq>+o`8Z5 z^i-qzJLJh9!W-;SmFkR8HEZJWiXk$40i6)7 zZpr=k2lp}SasbM*Nbn3j$sn0;rUI;%EDbi7T1ZI4qL6PNNM2Y%6{LMIKW+FY_yF3) zSKQ2QSujzNMSL2r&bYs`|i2Dnn z=>}c0>a}>|uT!IiMOA~pVT~R@bGlm}Edf}Kq0?*Af6#mW9f9!}RjW7om0c9Qlp;yK z)=XQs(|6GCadQbWIhYF=rf{Y)sj%^Id-ARO0=O^Ad;Ph+ z0?$eE1xhH?{T$QI>0JP75`r)U_$#%K1^BQ8z#uciKf(C701&RyLQWBUp*Q7eyn76} z6JHpC9}R$J#(R0cDCkXoFSp;j6{x{b&0yE@P7{;pCEpKjS(+1RQy38`=&Yxo%F=3y zCPeefABp34U-s?WmU#JJw23dcC{sPPFc2#J$ZgEN%zod}J~8dLm*fx9f6SpO zn^Ww3bt9-r0XaT2a@Wpw;C23XM}7_14#%QpubrIw5aZtP+CqIFmsG4`Cm6rfxl9n5 z7=r2C-+lM2AB9X0T_`?EW&Byv&K?HS4QLoylJ|OAF z`8atBNTzJ&AQ!>sOo$?^0xj~D(;kS$`9zbEGd>f6r`NC3X`tX)sWgWUUOQ7w=$TO&*j;=u%25ay-%>3@81tGe^_z*C7pb9y*Ed^H3t$BIKH2o+olp#$q;)_ zfpjCb_^VFg5fU~K)nf*d*r@BCC>UZ!0&b?AGk_jTPXaSnCuW110wjHPPe^9R^;jo3 zwvzTl)C`Zl5}O2}3lec=hZ*$JnkW#7enKKc)(pM${_$9Hc=Sr_A9Biwe*Y=T?~1CK z6eZ9uPICjy-sMGbZl$yQmpB&`ouS8v{58__t0$JP%i3R&%QR3ianbZqDs<2#5FdN@n5bCn^ZtH992~5k(eA|8|@G9u`wdn7bnpg|@{m z^d6Y`*$Zf2Xr&|g%sai#5}Syvv(>Jnx&EM7-|Jr7!M~zdAyjt*xl;OLhvW-a%H1m0 z*x5*nb=R5u><7lyVpNAR?q@1U59 zO+)QWwL8t zyip?u_nI+K$uh{y)~}qj?(w0&=SE^8`_WMM zTybjG=999h38Yes7}-4*LJ7H)UE8{mE(6;8voE+TYY%33A>S6`G_95^5QHNTo_;Ao ztIQIZ_}49%{8|=O;isBZ?=7kfdF8_@azfoTd+hEJKWE!)$)N%HIe2cplaK`ry#=pV z0q{9w-`i0h@!R8K3GC{ivt{70IWG`EP|(1g7i_Q<>aEAT{5(yD z=!O?kq61VegV+st@XCw475j6vS)_z@efuqQgHQR1T4;|-#OLZNQJPV4k$AX1Uk8Lm z{N*b*ia=I+MB}kWpupJ~>!C@xEN#Wa7V+7{m4j8c?)ChV=D?o~sjT?0C_AQ7B-vxqX30s0I_`2$in86#`mAsT-w?j{&AL@B3$;P z31G4(lV|b}uSDCIrjk+M1R!X7s4Aabn<)zpgT}#gE|mIvV38^ODy@<&yflpCwS#fRf9ZX3lPV_?8@C5)A;T zqmouFLFk;qIs4rA=hh=GL~sCFsXHsqO6_y~*AFt939UYVBSx1s(=Kb&5;j7cSowdE;7()CC2|-i9Zz+_BIw8#ll~-tyH?F3{%`QCsYa*b#s*9iCc`1P1oC26?`g<9))EJ3%xz+O!B3 zZ7$j~To)C@PquR>a1+Dh>-a%IvH_Y7^ys|4o?E%3`I&ADXfC8++hAdZfzIT#%C+Jz z1lU~K_vAm0m8Qk}K$F>|>RPK%<1SI0(G+8q~H zAsjezyP+u!Se4q3GW)`h`NPSRlMoBjCzNPesWJwVTY!o@G8=(6I%4XHGaSiS3MEBK zhgGFv6Jc>L$4jVE!I?TQuwvz_%CyO!bLh94nqK11C2W$*aa2ueGopG8DnBICVUORP zgytv#)49fVXDaR$SukloYC3u7#5H)}1K21=?DKj^U)8G;MS)&Op)g^zR2($<>C*zW z;X7`hLxiIO#J`ANdyAOJle4V%ppa*(+0i3w;8i*BA_;u8gOO6)MY`ueq7stBMJTB; z-a0R>hT*}>z|Gg}@^zDL1MrH+2hsR8 zHc}*9IvuQC^Ju)^#Y{fOr(96rQNPNhxc;mH@W*m206>Lo<*SaaH?~8zg&f&%YiOEG zGiz?*CP>Bci}!WiS=zj#K5I}>DtpregpP_tfZtPa(N<%vo^#WCQ5BTv0vr%Z{)0q+ z)RbfHktUm|lg&U3YM%lMUM(fu}i#kjX9h>GYctkx9Mt_8{@s%!K_EI zScgwy6%_fR?CGJQtmgNAj^h9B#zmaMDWgH55pGuY1Gv7D z;8Psm(vEPiwn#MgJYu4Ty9D|h!?Rj0ddE|&L3S{IP%H4^N!m`60ZwZw^;eg4sk6K{ ziA^`Sbl_4~f&Oo%n;8Ye(tiAdlZKI!Z=|j$5hS|D$bDJ}p{gh$KN&JZYLUjv4h{NY zBJ>X9z!xfDGY z+oh_Z&_e#Q(-}>ssZfm=j$D&4W4FNy&-kAO1~#3Im;F)Nwe{(*75(p=P^VI?X0GFakfh+X-px4a%Uw@fSbmp9hM1_~R>?Z8+ ziy|e9>8V*`OP}4x5JjdWp}7eX;lVxp5qS}0YZek;SNmm7tEeSF*-dI)6U-A%m6YvCgM(}_=k#a6o^%-K4{`B1+}O4x zztDT%hVb;v#?j`lTvlFQ3aV#zkX=7;YFLS$uIzb0E3lozs5`Xy zi~vF+%{z9uLjKvKPhP%x5f~7-Gj+%5N`%^=yk*Qn{`> z;xj&ROY6g`iy2a@{O)V(jk&8#hHACVDXey5a+KDod_Z&}kHM}xt7}Md@pil{2x7E~ zL$k^d2@Ec2XskjrN+IILw;#7((abu;OJii&v3?60x>d_Ma(onIPtcVnX@ELF0aL?T zSmWiL3(dOFkt!x=1O!_0n(cAzZW+3nHJ{2S>tgSK?~cFha^y(l@-Mr2W$%MN{#af8J;V*>hdq!gx=d0h$T7l}>91Wh07)9CTX zh2_ZdQCyFOQ)l(}gft0UZG`Sh2`x-w`5vC2UD}lZs*5 zG76$akzn}Xi))L3oGJ75#pcN=cX3!=57$Ha=hQ2^lwdyU#a}4JJOz6ddR%zae%#4& za)bFj)z=YQela(F#Y|Q#dp}PJghITwXouVaMq$BM?K%cXn9^Y@g43$=O)F&ZlOUom zJiad#dea;-eywBA@e&D6Pdso1?2^(pXiN91?jvcaUyYoKUmvl5G9e$W!okWe*@a<^ z8cQQ6cNSf+UPDx%?_G4aIiybZHHagF{;IcD(dPO!#=u zWfqLcPc^+7Uu#l(Bpxft{*4lv#*u7X9AOzDO z1D9?^jIo}?%iz(_dwLa{ex#T}76ZfN_Z-hwpus9y+4xaUu9cX}&P{XrZVWE{1^0yw zO;YhLEW!pJcbCt3L8~a7>jsaN{V3>tz6_7`&pi%GxZ=V3?3K^U+*ryLSb)8^IblJ0 zSRLNDvIxt)S}g30?s_3NX>F?NKIGrG_zB9@Z>uSW3k2es_H2kU;Rnn%j5qP)!XHKE zPB2mHP~tLCg4K_vH$xv`HbRsJwbZMUV(t=ez;Ec(vyHH)FbfLg`c61I$W_uBB>i^r z&{_P;369-&>23R%qNIULe=1~T$(DA`ev*EWZ6j(B$(te}x1WvmIll21zvygkS%vwG zzkR6Z#RKA2!z!C%M!O>!=Gr0(J0FP=-MN=5t-Ir)of50y10W}j`GtRCsXBakrKtG& zazmITDJMA0C51&BnLY)SY9r)NVTMs);1<=oosS9g31l{4ztjD3#+2H7u_|66b|_*O z;Qk6nalpqdHOjx|K&vUS_6ITgGll;TdaN*ta=M_YtyC)I9Tmr~VaPrH2qb6sd~=AcIxV+%z{E&0@y=DPArw zdV7z(G1hBx7hd{>(cr43^WF%4Y@PXZ?wPpj{OQ#tvc$pABJbvPGvdR`cAtHn)cSEV zrpu}1tJwQ3y!mSmH*uz*x0o|CS<^w%&KJzsj~DU0cLQUxk5B!hWE>aBkjJle8z~;s z-!A=($+}Jq_BTK5^B!`R>!MulZN)F=iXXeUd0w5lUsE5VP*H*oCy(;?S$p*TVvTxwAeWFB$jHyb0593)$zqalVlDX=GcCN1gU0 zlgU)I$LcXZ8Oyc2TZYTPu@-;7<4YYB-``Qa;IDcvydIA$%kHhJKV^m*-zxcvU4viy&Kr5GVM{IT>WRywKQ9;>SEiQD*NqplK-KK4YR`p0@JW)n_{TU3bt0 zim%;(m1=#v2}zTps=?fU5w^(*y)xT%1vtQH&}50ZF!9YxW=&7*W($2kgKyz1mUgfs zfV<*XVVIFnohW=|j+@Kfo!#liQR^x>2yQdrG;2o8WZR+XzU_nG=Ed2rK?ntA;K5B{ z>M8+*A4!Jm^Bg}aW?R?6;@QG@uQ8&oJ{hFixcfEnJ4QH?A4>P=q29oDGW;L;= z9-a0;g%c`C+Ai!UmK$NC*4#;Jp<1=TioL=t^YM)<<%u#hnnfSS`nq63QKGO1L8RzX z@MFDqs1z ztYmxDl@LU)5acvHk)~Z`RW7=aJ_nGD!mOSYD>5Odjn@TK#LY{jf?+piB5AM-CAoT_ z?S-*q7}wyLJzK>N%eMPuFgN)Q_otKP;aqy=D5f!7<=n(lNkYRXVpkB{TAYLYg{|(jtRqYmg$xH zjmq?B(RE4 zQx^~Pt}gxC2~l=K$$-sYy_r$CO(d=+b3H1MB*y_5g6WLaWTXn+TKQ|hNY^>Mp6k*$ zwkovomhu776vQATqT4blf~g;TY(MWCrf^^yfWJvSAB$p5l;jm@o#=!lqw+Lqfq>X= z$6~kxfm7`3q4zUEB;u4qa#BdJxO!;xGm)wwuisj{0y2x{R(IGMrsIzDY9LW>m!Y`= z04sx3IjnYvL<4JqxQ8f7qYd0s2Ig%`ytYPEMKI)s(LD}D@EY>x`VFtqvnADNBdeao zC96X+MxnwKmjpg{U&gP3HE}1=s!lv&D{6(g_lzyF3A`7Jn*&d_kL<;dAFx!UZ>hB8 z5A*%LsAn;VLp>3${0>M?PSQ)9s3}|h2e?TG4_F{}{Cs>#3Q*t$(CUc}M)I}8cPF6% z=+h(Kh^8)}gj(0}#e7O^FQ6`~fd1#8#!}LMuo3A0bN`o}PYsm!Y}sdOz$+Tegc=qT z8x`PH$7lvnhJp{kHWb22l;@7B7|4yL4UOOVM0MP_>P%S1Lnid)+k9{+3D+JFa#Pyf zhVc#&df87APl4W9X)F3pGS>@etfl=_E5tBcVoOfrD4hmVeTY-cj((pkn%n@EgN{0f zwb_^Rk0I#iZuHK!l*lN`ceJn(sI{$Fq6nN& zE<-=0_2WN}m+*ivmIOxB@#~Q-cZ>l136w{#TIJe478`KE7@=a{>SzPHsKLzYAyBQO zAtuuF$-JSDy_S@6GW0MOE~R)b;+0f%_NMrW(+V#c_d&U8Z9+ec4=HmOHw?gdjF(Lu zzra83M_BoO-1b3;9`%&DHfuUY)6YDV21P$C!Rc?mv&{lx#f8oc6?0?x zK08{WP65?#>(vPfA-c=MCY|%*1_<3D4NX zeVTi-JGl2uP_2@0F{G({pxQOXt_d{g_CV6b?jNpfUG9;8yle-^4KHRvZs-_2siata zt+d_T@U$&t*xaD22(fH(W1r$Mo?3dc%Tncm=C6{V9y{v&VT#^1L04vDrLM9qBoZ4@ z6DBN#m57hX7$C(=#$Y5$bJmwA$T8jKD8+6A!-IJwA{WOfs%s}yxUw^?MRZjF$n_KN z6`_bGXcmE#5e4Ym)aQJ)xg3Pg0@k`iGuHe?f(5LtuzSq=nS^5z>vqU0EuZ&75V%Z{ zYyhRLN^)$c6Ds{f7*FBpE;n5iglx5PkHfWrj3`x^j^t z7ntuV`g!9Xg#^3!x)l*}IW=(Tz3>Y5l4uGaB&lz{GDjm2D5S$CExLT`I1#n^lBH7Y zDgpMag@`iETKAI=p<5E#LTkwzVR@=yY|uBVI1HG|8h+d;G-qfuj}-ZR6fN>EfCCW z9~wRQoAPEa#aO?3h?x{YvV*d+NtPkf&4V0k4|L=uj!U{L+oLa(z#&iuhJr3-PjO3R z5s?=nn_5^*^Rawr>>Nr@K(jwkB#JK-=+HqwfdO<+P5byeim)wvqGlP-P|~Nse8=XF zz`?RYB|D6SwS}C+YQv+;}k6$-%D(@+t14BL@vM z2q%q?f6D-A5s$_WY3{^G0F131bbh|g!}#BKw=HQ7mx;Dzg4Z*bTLQSfo{ed{4}NZW zfrRm^Ca$rlE{Ue~uYv>R9{3smwATcdM_6+yWIO z*ZRH~uXE@#p$XTbCt5j7j2=86e{9>HIB6xDzV+vAo&B?KUiMP|ttOElepnl%|DPqL b{|{}U^kRn2wo}j7|0ATu<;8xA7zX}7|B6mN literal 0 HcmV?d00001 diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 0000000..080d6c7 --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,25 @@ +{ + "short_name": "React App", + "name": "Create React App Sample", + "icons": [ + { + "src": "favicon.ico", + "sizes": "64x64 32x32 24x24 16x16", + "type": "image/x-icon" + }, + { + "src": "logo192.png", + "type": "image/png", + "sizes": "192x192" + }, + { + "src": "logo512.png", + "type": "image/png", + "sizes": "512x512" + } + ], + "start_url": ".", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#ffffff" +} diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..e9e57dc --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,3 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * +Disallow: diff --git a/public/static/webworker_client.js b/public/static/webworker_client.js new file mode 100644 index 0000000..c487251 --- /dev/null +++ b/public/static/webworker_client.js @@ -0,0 +1,49 @@ +onmessage = function (event) { + handleMessage(event.data); +} + +// message = {type: '', task_id: '', [key: string]: any} +function handleMessage(message) { + // console.log("new task from master " + JSON.stringify(message)); + let type = message['type']; + + if(type === 'fetch'){ + fetchUrl(message.url).then(function (res) { + postMessage({ + type: 'fetch', + task_id: message.task_id, + info: JSON.parse(res), + }) + }); + + return; + } +} + +function processDelay(timeout) { + setTimeout(function () { + postMessage({ + type: 'delay', + info: 'Its done! ' + timeout, + }) + }, timeout) +} + + +function fetchUrl(request_url) { + return new Promise(function (resolve, reject) { + let xhr = new XMLHttpRequest(); + xhr.open('GET', request_url, true); + xhr.onload = function () { + if (xhr.status >= 200 && xhr.status < 300) { + resolve(xhr.response); + } else { + reject('error'); + } + }; + xhr.onerror = function () { + reject('error'); + }; + xhr.send(); + }); +} diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..280f715 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,165 @@ +import React, {createRef, useState, Fragment, FC} from 'react'; +import {Layout, Col, Row, Button, Image} from 'antd'; +import { EditOutlined, CloseOutlined } from '@ant-design/icons'; +import ConversationList from "@/components/ConversationList"; +import Chatbox from "@/components/Chatbox"; +import CustomerInfo from "@/components/CustomerInfo"; +import ActionTabs from "@/components/ActionTabs"; +import {HelpSideBar} from "@/components/Help"; +import HeaderComponent from "@/components/HeaderComponent"; + +import {useDispatch, useSelector} from "react-redux"; +import {AppState} from "@/store/typing"; +import {UserInfo} from "@/typings/user"; +import {actions} from "@/store/actions"; + +import GlobalModal from '@/components/GlobalModal'; +import GlobalDrawer from "@/components/GlobalDrawer"; +import BadgeStatus from "@/components/BadgeStatus"; +import NetworkError from "@/components/Error/NetworkError"; + +import {ClientSettings} from "@/typings"; + +import TYPING_ANIMATION_IMAGE from "@/assets/typing-animation.gif"; +import DashBoard from "@/components/DashBoard"; + +import {user_list} from "@/test/test_state"; + +import '@/styles/app.css'; + + +const { Header, Content, Sider } = Layout; + +const WINDOW_HEIGHT = global.window.innerHeight; + + +const UserNameForm = ({user}: {user: UserInfo | null}) => { + const [openForm, setFormOpen] = useState(false); + const dispatch = useDispatch(); + + if( ! user ) { + return null; + } + + const userInputRef = createRef(); + const updateName = () => { + let new_name = userInputRef.current?.value; + dispatch(actions.updateUserInfo({ + id: user.id, + name: new_name, + })); + setFormOpen(false); + } + + if(!openForm) { + return ( + + + {user.name} + { + user.typing && + } + ( setFormOpen(true)} title={'Thay đổi tên'} /> ) + + ) + } + + return ( + + + + ) +} + + +function getUserById(user_list: UserInfo[], user_id: string) : UserInfo | null { + let filtered_list = user_list.filter(user => user.id === user_id); + return filtered_list.length > 0 ? filtered_list[0] : null; +} + + +const ShowUserSpace = () => { + + // TODO: + + const chatting_with_user = useSelector((state: AppState) => state.chatting_with_user ); + const dispatch = useDispatch(); + + let chatting_user_info = getUserById(user_list, chatting_with_user); + if( ! chatting_user_info && user_list.length > 0) { + // auto show the first user in the list if there + dispatch(actions.chatWithUser(user_list[0].id)); + } + + // else show the dashboard + if( ! chatting_user_info ) { + return + } + + return ( + +
+ +
+ + + + {/* add key to force to component to remount when user id change to simplify the component's code*/} + + + + +

Lựa chọn

+ + + +

Thông tin khách hàng

+ + + + +
+
+ ) +} + + +const App: FC<{client_setting: ClientSettings}> = ({client_setting}) => { + + const HEADER_HEIGHT = 70; + const [closeHelp, setHelpClose] = useState(false); + const LAYOUT_CLOSED = closeHelp ? {width:1200, marginLeft:'auto', marginRight: 'auto'} : {marginTop: HEADER_HEIGHT, height: WINDOW_HEIGHT - HEADER_HEIGHT, overflow: 'auto'}; + + return ( + +
+ +
+ + + + + + + + + + + { + !closeHelp && + +

Trợ giúp setHelpClose(true)} title={'Đóng'} />

+ +
+ } +
+ + + + + +
+ ) +}; + +export default App; diff --git a/src/assets/typing-animation.gif b/src/assets/typing-animation.gif new file mode 100644 index 0000000000000000000000000000000000000000..152f23065cee394a09a73c11f12157c6a7149ffc GIT binary patch literal 97037 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
|G-}_tXbxZ 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

*^t}+F&@b;LP5m%UK5FYxIoh6Xo#TAL-o1 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

5mZ8xqkA{XzO`QVzKvvE@eMWzO_51&r zs9ybt{%lq1kfbKgVm2tBRd-H6}U7kl)B2FaV{%heeb)sL?3sBix&6|8nQisMh2 z58PAFA(pFnm79&nk|Ua(OD0YGX!X5OaK?E7l?Pu)Xa<*6my=2M!}{b{(k1;<@$9SL z^fm3T^V10wt{-JvL_KDbjIp<8lFii?W>PE-<=ACgY&~YvXs#DiT%p0m^dzs3Y ziwt-{xPG^Nbii^zBIQ}(d%hMT#J0r{AnqE j{0sEkzxV*GZNG41Iy!_Un8fo_HLL&hQ~CeZANKzbmWIRd literal 0 HcmV?d00001 diff --git a/src/components/ActionTabs/components/CreateLeadForm.tsx b/src/components/ActionTabs/components/CreateLeadForm.tsx new file mode 100644 index 0000000..1246a25 --- /dev/null +++ b/src/components/ActionTabs/components/CreateLeadForm.tsx @@ -0,0 +1,123 @@ +import {Form, Input, Select, Button} from 'antd'; +import api from "@/lib/api"; +import PROVINCE_LIST from "@/constant/province_list"; +import {SelectWithList} from "@/components/SelectBox"; +import React from "react"; +import GENDER_LIST from "@/constant/gender"; + +const { Option } = Select; + +const LeadForm = ({customer_id}: {customer_id: string|number}) => { + + const [form] = Form.useForm(); + + const onFinish = (values: any) => { + console.log('Received values of form: ', values); + form.resetFields(); + (async () => { + let result = await api.post('user/create-lead', {...values, customer_id}); + console.log(result.data); + })(); + }; + + return ( +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export default LeadForm; diff --git a/src/components/ActionTabs/components/CreateNoteForm.tsx b/src/components/ActionTabs/components/CreateNoteForm.tsx new file mode 100644 index 0000000..8876507 --- /dev/null +++ b/src/components/ActionTabs/components/CreateNoteForm.tsx @@ -0,0 +1,63 @@ +import {Form, Input} from 'antd'; +import api, {notifyApiResult} from "@/lib/api"; +import {getAdminInfo} from "@/lib/user"; +import Emitter from '@/lib/emitter'; +import React, {useEffect} from "react"; +import {UserInfo} from "@/typings/user"; + + +const NoteForm = ({customer_id}: {customer_id: string|number}) => { + + const [form] = Form.useForm(); + + const onFinish = (values: any) => { + form.resetFields(); + const admin_info = getAdminInfo(); + (async () => { + let result = await api.post('note/create', {...values, customer_id, admin_name: admin_info.name}); + notifyApiResult(result, 'Ghi chú đã được lưu'); + Emitter.emit('create_note'); + })(); + } + + useEffect(() => { + (async () => { + let result = await api.get('user/info', {id: customer_id}); + if(result.status === 'ok') { + const user_info: UserInfo = (result.data) ? result.data : null; + if(user_info) { + form.setFieldsValue({ + crm_code: user_info.crm_code, + }); + } + } + })(); + }, [customer_id, form]); + + + return ( +
+ + + + { + e.preventDefault(); + form.submit(); + }} + /> + +
+ ) +} + +export default NoteForm; diff --git a/src/components/ActionTabs/components/CreateOrderForm.tsx b/src/components/ActionTabs/components/CreateOrderForm.tsx new file mode 100644 index 0000000..f7647d5 --- /dev/null +++ b/src/components/ActionTabs/components/CreateOrderForm.tsx @@ -0,0 +1,429 @@ +import React, {createRef, useEffect, useState} from "react"; +import { + Form, Input, Select, Button, Space, + InputNumber, Popconfirm, + DatePicker, TimePicker +} from 'antd'; +import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons'; + +import api, {notifyApiResult} from "@/lib/api"; +import {SelectWithAjax, SelectWithAddItem, SelectWithList} from '@/components/SelectBox'; +import PROVINCE_LIST from "@/constant/province_list"; +import Tagging from "@/components/Tagging"; +import GENDER_LIST from "@/constant/gender"; +import Emitter from "@/lib/emitter"; + +const { Option } = Select; + +type ProductInfo = { + id: string; + sku: string; + name: string; + price:string; + in_stock: boolean; +} + + +const searchProduct = async (query: string) => { + const { data } = await api.get('product/list', {q: query} ); + let result: { value: any; text: any; }[] = []; + + if(!data) return []; + + data.list.forEach((r: { id: string, name: string }) => { + result.push({ + ...r, + value: r.id, + text: r.name, + }); + }); + + return result; +} + + +const OrderForm = ({customer_id}: {customer_id: string|number}) => { + + const [suggested_products, setSuggestedProducts] = useState([]); + const [form] = Form.useForm(); + const taggingRef = createRef(); + + const onFinish = (fieldsValue: any) => { + console.log('Received values of form: ', fieldsValue); + + const values = { + ...fieldsValue, + user_id: customer_id, + shipping: { + ...fieldsValue['shipping'], + date: (fieldsValue['shipping']['date']) ? fieldsValue['shipping']['date'].format('YYYY-MM-DD') : '0000-00-00', + time: (fieldsValue['shipping']['time']) ? fieldsValue['shipping']['time'].format('HH:mm') : '00:00', + }, + tags: taggingRef.current?.getTagList(), + }; + + (async () => { + let result = await api.post('order/create', {...values}); + notifyApiResult(result, 'Tạo đơn hàng thành công'); + Emitter.emit('create_order'); + form.resetFields(); + taggingRef.current?.clearList(); + })(); + }; + + const productSelectOption = (item: ProductInfo) => { + return ( + + ) + }; + + useEffect(() => { + const default_info = { + "name": "", + "crm_code": "", + "email": "", + "note": "", + "gender": null, + "province": null, + "address": "", + "mobile": "", + }; + + (async () => { + let result = await api.get('user/info', {id: customer_id}); + if(result.status === 'ok') { + const filled_info = (result.data) ? result.data : default_info; + form.setFieldsValue({customer: filled_info}); + } + })(); + }, [customer_id, form]); + + + useEffect(() => { + (async () => { + let result = await api.get('product/suggested', {id: customer_id}); + if(result.status === 'ok') { + setSuggestedProducts(result.data); + } + })(); + }, [customer_id]); + + + return ( +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {(fields, { add, remove }) => ( + <> + {fields.map(({ key, name, fieldKey, ...restField }, index) => ( + + + + { + console.log(option); + //alert(value); + //alert(JSON.stringify(form.getFieldsValue())); + //form.setFieldsValue({user: {name: value}} ); + let current_products = form.getFieldsValue()['products']; + current_products[index].id = value; + current_products[index].price = option.price; + current_products[index].sku = option.sku; + current_products[index].name = option.name; + form.setFieldsValue({products: current_products}); + }} + searchFn={searchProduct} + onFocusSuggestedData={suggested_products} + buildOption={productSelectOption} + /> + + + + + + + + + + + + + + + + + + + + + remove(name)} + okText="Yes" + cancelText="No" + > + + + + ))} + + + + + )} + + + + + + {(fields, { add, remove }) => ( + <> + {fields.map(({ key, name, fieldKey, ...restField }) => ( + + + + + + + + + + + + remove(name)} + okText="Yes" + cancelText="No" + > + + + + ))} + + + + + )} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ ); +} + +export default OrderForm; diff --git a/src/components/ActionTabs/components/CreateSupportForm.tsx b/src/components/ActionTabs/components/CreateSupportForm.tsx new file mode 100644 index 0000000..48e18bd --- /dev/null +++ b/src/components/ActionTabs/components/CreateSupportForm.tsx @@ -0,0 +1,146 @@ +import React, {createRef, useEffect, useState} from "react"; +import {Form, Input, Select, Button, Space, Checkbox} from 'antd'; +import {ImageUploadWithPreview} from '@/components/Upload'; +import api, {notifyApiResult} from "@/lib/api"; +import {getAdminInfo} from "@/lib/user"; +import Tagging from "@/components/Tagging"; +import Emitter from "@/lib/emitter"; +import {UserInfo} from "@/typings/user"; + +const { Option } = Select; + + +const SupportForm = ({customer_id}: {customer_id: string|number}) => { + + const [form] = Form.useForm(); + const [sendEmail, setSendEmail] = useState(false); + + const taggingRef = createRef(); + const imageRef = createRef(); + + const onFinish = (formValues: any) => { + //console.log('Received values of form: ', formValues); + const payload = { + customer_id, + ...formValues, + files: imageRef.current?.getFileList().map(item => item.uid), // get file-ids + tags: taggingRef.current?.getTagList(), + }; + + (async () => { + let result = await api.post('support/create', payload); + // console.log(result.data); + notifyApiResult(result, 'Tạo thành công'); + Emitter.emit('create_support'); + form.resetFields(); + taggingRef.current?.clearList(); + imageRef.current?.clearFileList(); + })(); + }; + + useEffect(() => { + (async () => { + let result = await api.get('user/info', {id: customer_id}); + if(result.status === 'ok') { + const user_info: UserInfo = (result.data) ? result.data : null; + if(user_info) { + form.setFieldsValue({ + customer_email: user_info.email, + crm_code: user_info.crm_code, + }); + } + } + })(); + }, [customer_id, form]); + + const admin_upload_auth = getAdminInfo().jwt || ''; + + return ( +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + { + // e.preventDefault; + setSendEmail(e.target.checked); + }}>Có! Gửi bản 1 bản copy vào email khách hàng + + + + + + + + + + + + +
+ ); +} + +export default SupportForm; diff --git a/src/components/ActionTabs/components/CreateTag.tsx b/src/components/ActionTabs/components/CreateTag.tsx new file mode 100644 index 0000000..91d64fd --- /dev/null +++ b/src/components/ActionTabs/components/CreateTag.tsx @@ -0,0 +1,38 @@ +import React, {useEffect, useState} from "react"; +import Tagging from "@/components/Tagging"; +import api from "@/lib/api"; + + +const CreateTag = ({customer_id}: {customer_id: string|number}) => { + + const [current_tags, setTags] = useState([]); + + const handleCreateTag = async (tag: string) => { + let result = await api.post('user/tag', {tag, id: customer_id}); + return result.status === 'ok'; + }; + + const handleRemoveTag = async (tag: string) => { + let result = await api.delete('user/tag', {tag, id: customer_id}); + return result.status === 'ok'; + }; + + useEffect(() => { + (async () => { + let result = await api.get('user/info', {id: customer_id}); + if(result.status === 'ok' && result.data) { + setTags(result.data.tags); + } + })(); + }, [customer_id]); + + return ( + + ) +} + +export default CreateTag; diff --git a/src/components/ActionTabs/index.tsx b/src/components/ActionTabs/index.tsx new file mode 100644 index 0000000..bc53d2e --- /dev/null +++ b/src/components/ActionTabs/index.tsx @@ -0,0 +1,45 @@ +import React, {Suspense} from "react"; +import {Tabs} from "antd"; +import Loading from "@/components/Loading"; + +const CreateSupportComponent = React.lazy(() => import('./components/CreateSupportForm')); +const CreateOrderComponent = React.lazy(() => import('./components/CreateOrderForm')); +const CreateNoteComponent = React.lazy(() => import('./components/CreateNoteForm')); +const CreateTag = React.lazy(() => import('./components/CreateTag')); + +const { TabPane } = Tabs; + +const ActionTabs = ({customer_id}: {customer_id: string|number}) => { + + return ( + null } defaultActiveKey={'note'} type="card"> + + + } > + + + + + + } > + + + + + + } > + + + + + + } > + + + + + + ) +} + +export default ActionTabs; diff --git a/src/components/BadgeStatus/index.tsx b/src/components/BadgeStatus/index.tsx new file mode 100644 index 0000000..1e32ec5 --- /dev/null +++ b/src/components/BadgeStatus/index.tsx @@ -0,0 +1,14 @@ +import {Badge} from "antd"; +import React, {FC, ReactNode} from "react"; + +// import "./styles.css"; + +const BadgeStatus: FC<{online: boolean, children: ReactNode}> = ({online, children}) => { + return ( + + { children } + + ) +} + +export default BadgeStatus; diff --git a/src/components/BadgeStatus/styles.css b/src/components/BadgeStatus/styles.css new file mode 100644 index 0000000..8c3e9a8 --- /dev/null +++ b/src/components/BadgeStatus/styles.css @@ -0,0 +1,8 @@ +.badge-offline .ant-badge-count { + background-color: #fff; + box-shadow: 0 0 0 1px #d9d9d9 inset; +} + +.badge-online .ant-badge-count { + background-color: cyan; +} diff --git a/src/components/Chatbox/index.tsx b/src/components/Chatbox/index.tsx new file mode 100644 index 0000000..b49de9b --- /dev/null +++ b/src/components/Chatbox/index.tsx @@ -0,0 +1,4 @@ +// import Sample from "./ver/Sample"; +import Chatbox from "./ver/Chatbox"; + +export default Chatbox; \ No newline at end of file diff --git a/src/components/Chatbox/ver/Chatbox.css b/src/components/Chatbox/ver/Chatbox.css new file mode 100644 index 0000000..d439e48 --- /dev/null +++ b/src/components/Chatbox/ver/Chatbox.css @@ -0,0 +1,108 @@ +.compose { + padding: 10px; + display: flex; + align-items: center; + background: white; + border: 1px solid #eeeef1; + /* position: fixed; + width: calc(100% - 20px);*/ + bottom: 0; +} + +@supports (backdrop-filter: blur(20px)) { + .compose { + border: none; + background-color: rgba(255, 255, 255, 0.8); + backdrop-filter: blur(20px); + } +} + +.compose-input { + flex: 1; + border: none; + font-size: 14px; + height: 40px; + background: none; +} + +.compose-input::placeholder { + opacity: 0.3; +} + +.compose .toolbar-button { + color: #bbbbbf; + margin-left: 15px; +} + +.compose .toolbar-button:hover { + color: #99999c; +} + +.message-list-container { + padding: 10px; +} + +.message { + display: flex; + flex-direction: column; +} + +.message .timestamp { + display: flex; + justify-content: center; + color: #999; + font-weight: 600; + font-size: 12px; + margin: 10px 0; + text-transform: uppercase; +} + +.message .bubble-container { + font-size: 14px; + display: flex; +} + +.message.mine .bubble-container { + justify-content: flex-end; +} + +.message.start .bubble-container .bubble { + /* margin-top: 10px; */ + border-top-left-radius: 20px; +} + +.message.end .bubble-container .bubble { + border-bottom-left-radius: 20px; + /* margin-bottom: 10px; */ +} + +.message.mine.start .bubble-container .bubble { + margin-top: 10px; + border-top-right-radius: 20px; +} + +.message.mine.end .bubble-container .bubble { + border-bottom-right-radius: 20px; + margin-bottom: 10px; +} + +.message .bubble-container .bubble { + margin: 1px 0; + background: #f4f4f8; + padding: 10px 15px; + border-radius: 20px; + max-width: 75%; + border-top-left-radius: 2px; + border-bottom-left-radius: 2px; + border-top-right-radius: 20px; + border-bottom-right-radius: 20px; +} + +.message.mine .bubble-container .bubble { + background: #007aff; + color: white; + border-top-left-radius: 20px; + border-bottom-left-radius: 20px; + border-top-right-radius: 2px; + border-bottom-right-radius: 2px; +} diff --git a/src/components/Chatbox/ver/Chatbox.tsx b/src/components/Chatbox/ver/Chatbox.tsx new file mode 100644 index 0000000..8ff53ae --- /dev/null +++ b/src/components/Chatbox/ver/Chatbox.tsx @@ -0,0 +1,311 @@ +import React, {Component, createRef, Fragment} from "react"; +import {connect} from "react-redux"; +import {Dispatch} from "redux"; +import debounce from "lodash/debounce"; + +import {UserInfo} from "@/typings/user"; +import {ChatboxTextMessage} from "@/typings/message.d"; +import {sendTextMessageToServer} from "@/lib/messaging"; +import {getCurrentUTCTimestamp} from "@/lib/utils"; +import {getUserChatHistory} from "@/lib/api"; +import {AppState, NetworkingStatusType} from "@/store/typing"; +import {actions} from "@/store/actions"; +import {NOTIFICATIONS} from "@/constant/text"; +import {getAdminInfo} from "@/lib/user"; +import storage, {userChatHistoryStorageKey} from "@/lib/storage"; + +import {MessageItem} from "./MessageItem"; +import InputMessage from "./InputMessage"; +import TypingNotification from "./TypingNotification"; + +import './Chatbox.css'; + + +type ChatboxProps = { + user_info: UserInfo; + network_connection: NetworkingStatusType; + chat_messages: ChatboxTextMessage[]; + dispatch: Dispatch; +} + +type ChatboxState = { + loadingHistory: boolean, +} + + +/*function persistMessage(user_id: string, messages: any[]) { + const MAX_MESSAGE_KEPT_PER_USER: number = 200; + + // limit messages per user + const total_message = messages.length; + const keep_from_index = total_message - MAX_MESSAGE_KEPT_PER_USER; + + storage.save(userChatHistoryStorageKey(user_id), (keep_from_index > 0) ? messages.slice(keep_from_index) : messages); +}*/ + + +class Chatbox extends Component { + + static MAX_MESSAGE_KEPT_PER_USER: number = 200; + + private trackLastScrollTop: number; + private disableScrollBottom: boolean; + private loadingHistoryStatus: 'idle' | 'loading' | 'done'; + private noMoreHistoryMessage: boolean; + + private scrollBottomDiv: React.RefObject; + private scrollToHistoryDiv: React.RefObject; + + constructor(props: ChatboxProps) { + super(props); + this.state = { + loadingHistory: false + } + this.scrollBottomDiv = createRef(); + this.scrollToHistoryDiv = createRef(); + + this.trackLastScrollTop = 0; + this.disableScrollBottom = false; + this.loadingHistoryStatus = 'idle'; + this.noMoreHistoryMessage = false; + } + + componentDidMount() { + this.getChatHistory().then(); + } + + getSnapshotBeforeUpdate(prevProps: ChatboxProps, prevState: ChatboxState) { + // Are we adding new items to the list? + // Capture the scroll position so we can adjust scroll later. + if (prevProps.chat_messages.length < this.props.chat_messages.length) { + const div = this.scrollToHistoryDiv.current; + return (div) ? div.scrollHeight - div.scrollTop : null; + } + return null; + } + + componentDidUpdate(prevProps: ChatboxProps, prevState: ChatboxState, snapshot: number | null) { + console.log('componentDidUpdate at ' + new Date().getTime()); + // snapshot is the result of getSnapshotBeforeUpdate() + if (snapshot !== null) { + this.scrollToHistoryDiv.current!.scrollTop = this.scrollToHistoryDiv.current!.scrollHeight - snapshot; + // this.scrollToHistoryDiv.current!.scrollTo({top: expected_top, behavior:'smooth'}); + this.loadingHistoryStatus = 'idle'; + } + + if(prevProps.chat_messages.length !== this.props.chat_messages.length) { + this.scrollToBottom(); + } + } + + + getChatHistory = async (from_scroll: boolean = false) => { + + console.log('Chatbox getChatHistory'); + + const {user_info, chat_messages, network_connection, dispatch} = this.props; + + // todo: 15-April-2021 + // because 1 user can chat with many admin staff, we need to remove all old messages in storage (if exist) when user first chat with this staff + if(network_connection === 'offline') { + // get for offline view only + const stored_messages: ChatboxTextMessage[] = await storage.get(userChatHistoryStorageKey(user_info.id)) || []; + console.log('stored_messages'); + console.log(stored_messages); + dispatch(actions.addHistoryMessage({[user_info.id]: stored_messages})); + return; + } + + if ( chat_messages.length === 0 || from_scroll ) { + + let last_fetch = (chat_messages.length > 0) ? chat_messages[0].time : 0; + + this.setState({loadingHistory: true}); + + const old_messages = await getUserChatHistory({thread_id: user_info.id, last_fetch: last_fetch}); + + this.setState({loadingHistory: false}); + + if (old_messages.length === 0) { + console.log('old_messages: this.noMoreHistoryMessage = true') + this.noMoreHistoryMessage = true; + return; + } + + console.log('dispatching historied messages'); + dispatch(actions.addHistoryMessage({[user_info.id]: old_messages})); + } + } + + scrollToBottom = () => { + // no scroll if user view history + if ( this.disableScrollBottom ) { + return; + } + + this.scrollBottomDiv.current!.scrollIntoView({ behavior: "smooth" }); + } + + showNotification = () => { + const { user_info, network_connection } = this.props; + const NotiMessage = (txt: string, key: string|number) => (
{txt}
); + + let list_messages: string[] = []; + + // in-chat notification: user typing + if (network_connection === 'offline') { + list_messages.push(NOTIFICATIONS['network_offline']); + } + + // notify user offline + if (!user_info.online) { + list_messages.push(NOTIFICATIONS['user_offline']); + } + + if(list_messages.length > 0) { + return ( +
+ { + list_messages.map((txt, index) => NotiMessage(txt, index)) + } +
+ ) + } + + // default nothing + return null; + } + + handleChatboxScroll = debounce((event: any) => { + + console.log('start handleChatboxScroll at ' + new Date().getSeconds()); + //console.log(event); + + if(!event.target) { + console.log('handleChatboxScroll: event.currentTarget'); + return; + } + + if(this.noMoreHistoryMessage) { + console.log('handleChatboxScroll: noMoreHistoryMessage'); + return; + } + + // user is scrolling up, so disable bottom scrolling for user to read old messages without interruption + if(this.trackLastScrollTop > event.target.scrollTop) { + this.disableScrollBottom = true; + } + + // track again + this.trackLastScrollTop = event.target.scrollTop; + + // load history on top + if (event.target.scrollTop === 0 && this.loadingHistoryStatus === 'idle') { + this.loadingHistoryStatus = 'loading'; + this.disableScrollBottom = true; + console.log('handleChatboxScroll should get the history now ... '); + this.getChatHistory(true).then(); + } + }, 300) + + + sendMessage = (typed_message: string) => { + //alert(typed_message); + const { user_info, dispatch, network_connection } = this.props; // , admin_info, user_info + + //const {typed_message} = this.state; + if(typed_message === '') { + return; + } + + // reset when user send new message + if(this.disableScrollBottom) { + this.disableScrollBottom = false; + } + + // cannot send message when network_connection is offline + // and restore message to the input + if(network_connection === 'offline') { + console.log("network_connection =offline"); + return; + } + + // let composed_message = composeNewSendingMessage(txt); + // add locally + let composed_message = { + id: '', + from: 'me', + content: typed_message, + time: getCurrentUTCTimestamp(true), + // sequence: local_sequence, + deliveryStatus: 0, + } as ChatboxTextMessage; + + dispatch(actions.addCurrentMessage({[user_info.id] : [composed_message]})); + + // pass to networking layer to send to server + let send_to = (user_info.online) ? [user_info.id, user_info.node].join('-').trim() : ''; + sendTextMessageToServer(send_to, typed_message); + } + + showLoadingHistory = () => { + const {loadingHistory} = this.state; + + if(loadingHistory) { + return ( +
Loading ...
+ ) + } + + // default + return null; + } + + + render() { + + const { network_connection, user_info, chat_messages} = this.props; + const admin_info = getAdminInfo(); + + return ( + +
+ +
+ + { this.showLoadingHistory() } + + { + chat_messages.map((message, index) => ) + } + + { user_info.typing && } +
+ + { this.showNotification() } + +
+ +
+ + + + ) + } +} + +const mapStateToProps = (state: AppState, ownProps: {user_info: UserInfo}) => ({ + network_connection: state.network_connection, + chat_messages: state.current_messages[ownProps.user_info.id] || [], +}); +export default connect(mapStateToProps)(Chatbox); diff --git a/src/components/Chatbox/ver/InputMessage.tsx b/src/components/Chatbox/ver/InputMessage.tsx new file mode 100644 index 0000000..a930ebc --- /dev/null +++ b/src/components/Chatbox/ver/InputMessage.tsx @@ -0,0 +1,160 @@ +import {AppState, NetworkingStatusType} from "@/store/typing"; +import {AdminInfo, UserInfo} from "@/typings/user"; +import React, {createRef, Fragment, useEffect, useState} from "react"; +import {getAdminInfo} from "@/lib/user"; +import {useDispatch, useSelector} from "react-redux"; +import {ImageUploadWithPreview} from "@/components/Upload"; +import {actions} from "@/store/actions"; +import {Input, message, Popconfirm, Popover} from "antd"; +import {PaperClipOutlined, MinusCircleOutlined, FastForwardOutlined} from "@ant-design/icons"; +import BadgeStatus from "@/components/BadgeStatus"; + + +const PickAdminOnlineToTransfer = () => { + const admin_list: AdminInfo[] = useSelector((state: AppState) => state.admin_list); + const current_admin = getAdminInfo(); + const other_admin_online = admin_list.filter(item => item.online && current_admin.id !== item.id); + + return ( + + { + other_admin_online.length > 0 + ? other_admin_online.map(item => { + return ( + <> + alert(item.id)}>{item.name} + + ) + } ) + :
Không có quản trị online
+ } +
+ ) + +}; + + +const InputMessage = ({network_connection, sendMessage, user_info} : {network_connection: NetworkingStatusType, sendMessage: (txt: string) => void, user_info: UserInfo}) => { + + const [typed_message, setMessage] = useState(''); + const [typing, setTyping] = useState(false); + const admin_info = getAdminInfo(); + const dispatch = useDispatch(); + const imageUploadWithPreviewRef = createRef(); + + useEffect(() => { + if(!typing && typed_message.length > 0) { + setTyping(true); + console.log("Update Typing now at " + new Date().getTime()); + dispatch(actions.updateUserInfo({id: user_info.id, typing: true})) + } + + if(typing && typed_message === '') { + setTyping(false); + console.log("Turn off Typing now at " + new Date().getTime()) + dispatch(actions.updateUserInfo({id: user_info.id, typing: false})) + } + },[typing, typed_message, dispatch, user_info]); + + const removeChat = () => { + dispatch(actions.removeUser({id: user_info.id})) + } + + + return ( + + setMessage(value)} + onPressEnter={(e) => { + e.preventDefault(); // required to prevent new line + + // send message + let txt = typed_message.trim(); + if(txt.length > 0) { + sendMessage(txt); + setMessage(''); + } + + // send images + const uploadedFiles = imageUploadWithPreviewRef.current ? imageUploadWithPreviewRef.current.getFileList().map(item => item.url) : []; + if(uploadedFiles.length > 0) { + uploadedFiles.forEach(url => { + console.log("sending: "+ url); + sendMessage(url) + }); + imageUploadWithPreviewRef.current?.clearFileList(); + } + }} + disabled={network_connection === 'offline'} + placeholder="Nhập nội dung và nhấn phím Enter để gửi tin" + autoSize={{ minRows: 2, maxRows: 5 }} + /> + + + Chặn không cho người này liên hệ với công ty từ nay về sau (qua Chatngay).
Bạn chắc chắn chứ?
} + onConfirm={removeChat} + //onCancel={() => {}} + okText="Yes" + cancelText="No" + > + Forbid +
+ + + Dừng chat sẽ loại bỏ khách hàng khỏi danh sách chat.
Bạn chắc chắn muốn bỏ chứ?
} + onConfirm={removeChat} + //onCancel={() => {}} + okText="Yes" + cancelText="No" + > + Dừng chat +
+ +   + + } + trigger="click" + > + Chuyển người khác + + + + + Upload} + listType={'picture'} + showUploadList={false} + multiple={false} + onUploadFinish={(status, url) => { + + console.log(`onUploadFinish = ${status}`); + + if(status === 'success') { + if(url) sendMessage(url); + }else if(status === 'error'){ + message.error('Lỗi xảy ra, file chưa được upload.'); + }else if(status === 'uploading'){ + // show status + } + + if(status === 'success' || status === 'error') { + imageUploadWithPreviewRef.current?.clearFileList(); + } + }} + /> +
+ + ) +}; + + +export default InputMessage; diff --git a/src/components/Chatbox/ver/MessageItem.tsx b/src/components/Chatbox/ver/MessageItem.tsx new file mode 100644 index 0000000..22def5b --- /dev/null +++ b/src/components/Chatbox/ver/MessageItem.tsx @@ -0,0 +1,108 @@ +import {isUrlImage, validURL} from "@/lib/validation"; +import {maskExternalUrl, randomBetween, showUnixTime} from "@/lib/utils"; +import {ChatboxTextMessage} from "@/typings/message.d"; +import React from "react"; + + +export const MessageItem = (props: {is_me: boolean} & ChatboxTextMessage) => { + let {deliveryStatus, from, content, time, is_me } = props; + let time_in_second = Math.round(time / 1000); + + if( ! content ) return null; + + let image = (isUrlImage(content)) ? content : ''; + let status_colors = { + 'sending': 'green', + 'received': 'black', + 'failed': 'red', + }; + let html; + + if ( is_me ) from = 'me'; + + if (from === 'bot') { + return ( +
+ +
+ ) + } + + if (image !== '') { + if(_isImageUploadedToOurServer(image)) { + html = `
{""}/` + + return ( +
+ {from}{showUnixTime(time_in_second)} + +
+ ) + } + + html = `${image}` + return ( +
+ {from}{showUnixTime(time_in_second)} + +
(Ảnh chưa kiểm định. Cần thận trọng khi xem)
+
+ ) + } + + if(validURL(content)) { + html = `${content}` + return ( +
+ {from}{showUnixTime(time_in_second)} + +
(Link chưa kiểm định. Cần thận trọng khi xem)
+
+ ) + } + + + // todo: remove this + let random_number = randomBetween(1, 100); + let message_from_me = random_number % 2 === 0; + + return ( + +
+ +
+ {from}: { showUnixTime(time_in_second) } +
+ +
+
+ { content } +
+
+ { + deliveryStatus === 5 && (
(Lỗi xảy ra, tin nhắn chưa được gửi)
) + } + +
deliveryStatus :{deliveryStatus} | sq:{props.sequence}
+ +
+ ) +} + + +// format the display of each message on the chatbox +function _isImageUploadedToOurServer(image: string) : boolean{ + //check images uploaded to chat server + let img_regex_1 = /(\/user_upload\/([0-9]{4}-[0-9]{2}-[0-9]{1,2})\/([a-z0-9_]+)\.(jpg|jpeg|gif|png))$/g; + //let img_regex_2 = /(\/file\.php\?f=([0-9]{4}-[0-9]{2}-[0-9]{1,2})&(amp;)?n=([a-z0-9_]+)\.(jpg|jpeg|gif|png))$/g; + let img_regex_2 = /(\/file\.php\?f=([0-9]{4}-[0-9]{2}-[0-9]{1,2})&(amp;)?n=([a-z0-9_]+)\.(jpg|jpeg|gif|png)&(amp;)?o=([A-Za-z0-9_.-]+)\.(jpg|jpeg|gif|png))$/g; + + return (img_regex_1.test(image) || img_regex_2.test(image)) +} diff --git a/src/components/Chatbox/ver/Sample.tsx b/src/components/Chatbox/ver/Sample.tsx new file mode 100644 index 0000000..acfd414 --- /dev/null +++ b/src/components/Chatbox/ver/Sample.tsx @@ -0,0 +1,219 @@ +import React, {useEffect, useState} from "react"; +import ToolbarButton from "@/components/ToolbarButton"; +import moment from "moment"; + +import './Chatbox.css'; + +const WINDOW_HEIGHT = global.window.innerHeight; +const CHATBOX_HEIGHT = WINDOW_HEIGHT - 220; +const MY_USER_ID = 'apple'; + +const SAMPLE_MESSAGES = [ + { + id: 1, + author: 'apple', + message: 'Hello world! This is a long message that will hopefully get wrapped by our message bubble component! We will see how well it works.', + timestamp: new Date().getTime() + }, + { + id: 2, + author: 'orange', + message: 'It looks like it wraps exactly as it is supposed to. Lets see what a reply looks like!', + timestamp: new Date().getTime() + }, + { + id: 3, + author: 'orange', + message: 'Hello world! This is a long message that will hopefully get wrapped by our message bubble component! We will see how well it works.', + timestamp: new Date().getTime() + }, + { + id: 4, + author: 'apple', + message: 'It looks like it wraps exactly as it is supposed to. Lets see what a reply looks like!', + timestamp: new Date().getTime() + }, + { + id: 5, + author: 'apple', + message: 'Hello world! This is a long message that will hopefully get wrapped by our message bubble component! We will see how well it works.', + timestamp: new Date().getTime() + }, + { + id: 6, + author: 'apple', + message: 'It looks like it wraps exactly as it is supposed to. Lets see what a reply looks like!', + timestamp: new Date().getTime() + }, + { + id: 7, + author: 'orange', + message: 'Hello world! This is a long message that will hopefully get wrapped by our message bubble component! We will see how well it works.', + timestamp: new Date().getTime() + }, + { + id: 8, + author: 'orange', + message: 'It looks like it wraps exactly as it is supposed to. Lets see what a reply looks like!', + timestamp: new Date().getTime() + }, + { + id: 9, + author: 'apple', + message: 'Hello world! This is a long message that will hopefully get wrapped by our message bubble component! We will see how well it works.', + timestamp: new Date().getTime() + }, + { + id: 10, + author: 'orange', + message: 'It looks like it wraps exactly as it is supposed to. Lets see what a reply looks like!', + timestamp: new Date().getTime() + }, +]; + + +function Compose(props: any) { + return ( +
+ + { + props.rightItems + } +
+ ) +} + + +function MessageList(props: {customer_id: string|number}) { + const [messages, setMessages] = useState([]) + + useEffect(() => { + const getMessages = () => { + setMessages(SAMPLE_MESSAGES) + } + + getMessages(); + }) + + const renderMessages = () => { + let i = 0; + let messageCount = messages.length; + let tempMessages = []; + + while (i < messageCount) { + let previous = messages[i - 1]; + let current = messages[i]; + let next = messages[i + 1]; + let isMine = current.author === MY_USER_ID; + let currentMoment = moment(current.timestamp); + let prevBySameAuthor = false; + let nextBySameAuthor = false; + let startsSequence = true; + let endsSequence = true; + let showTimestamp = true; + + if (previous) { + let previousMoment = moment(previous.timestamp); + let previousDuration = moment.duration(currentMoment.diff(previousMoment)); + prevBySameAuthor = previous.author === current.author; + + if (prevBySameAuthor && previousDuration.as('hours') < 1) { + startsSequence = false; + } + + if (previousDuration.as('hours') < 1) { + showTimestamp = false; + } + } + + if (next) { + let nextMoment = moment(next.timestamp); + let nextDuration = moment.duration(nextMoment.diff(currentMoment)); + nextBySameAuthor = next.author === current.author; + + if (nextBySameAuthor && nextDuration.as('hours') < 1) { + endsSequence = false; + } + } + + tempMessages.push( + + ); + + // Proceed to the next message. + i += 1; + } + + return tempMessages; + } + + return( +
+ { renderMessages() } +
+ ) +} + +function Message(props: any) { + const { + data, + isMine, + startsSequence, + endsSequence, + showTimestamp + } = props; + + const friendlyTimestamp = moment(data.timestamp).format('LLLL'); + return ( +
+ { + showTimestamp && +
+ { friendlyTimestamp } +
+ } + +
+
+ { data.message } +
+
+
+ ); +} + + +export default function Chatbox({customer_id}: {customer_id: string|number}) { + return ( + <> +
+ +
+ + , + , + , + , + , + + ]}/> + + ) +} diff --git a/src/components/Chatbox/ver/TypingNotification.tsx b/src/components/Chatbox/ver/TypingNotification.tsx new file mode 100644 index 0000000..0ca1b71 --- /dev/null +++ b/src/components/Chatbox/ver/TypingNotification.tsx @@ -0,0 +1,13 @@ + +import {Image} from "antd"; +import React from "react"; + +import TYPING_ANIMATION_IMAGE from "@/assets/typing-animation.gif"; + +const TypingNotification = () => { + return ( + + ) +} + +export default TypingNotification; diff --git a/src/components/Comment/index.tsx b/src/components/Comment/index.tsx new file mode 100644 index 0000000..c66a30f --- /dev/null +++ b/src/components/Comment/index.tsx @@ -0,0 +1,123 @@ +import React, {useState} from "react"; +import {Form, Input, Comment, Tooltip, Avatar, Divider } from 'antd'; +import { LikeOutlined } from '@ant-design/icons'; +import {showUnixTime} from "@/lib/utils"; +import {DEFAULT_AVATAR} from "@/config"; + + +const CommentForm = () => { + + const [form] = Form.useForm(); + + const onFinish = (values: any) => { + alert(JSON.stringify(values)); + //form.resetFields(); + //const admin_info = getAdminInfo(); + (async () => { + //let result = await api.post('user/create-note', {...values, customer_id, admin_name: admin_info.name}); + //notifyApiResult(result, 'Ghi chú đã được lưu'); + //Emitter.emit('create_note'); + })(); + } + + + return ( +
+ + { + e.preventDefault(); + form.submit(); + }} + /> + +
+ ) +} + + +type CommentInfo = { + id: number, + content: string, + author: string, + create_time: number +} + +const CommentList = ({item_type, item_id} : {item_type: string, item_id?: string|number}) => { + + //const [keyword, setKeyword] = useState(''); + const item_list: CommentInfo[] = [ + { + id: 1, + content: 'hello', + author: 'admin 1', + create_time: 12121, + }, + { + id: 2, + content: 'hello', + author: 'admin 1', + create_time: 12121, + }, + ]; + + + const CommentItem = (item: CommentInfo) => { + + const actions = [ + + alert('like')}> + + 1 + + , + + /* + alert('dislikes')}> + + 0 + + ,*/ + + Reply to, + ]; + + return ( + + } + content={item.content} + datetime={showUnixTime(item.create_time)} + /> + ) + } + + + return ( +
+ + + +

Nhận xét ({item_list.length})

+ + + + { + item_list.map( CommentItem ) + } + +
+ ); +} + +export default CommentList; diff --git a/src/components/ConversationList/ConversationList.css b/src/components/ConversationList/ConversationList.css new file mode 100644 index 0000000..348be8a --- /dev/null +++ b/src/components/ConversationList/ConversationList.css @@ -0,0 +1,63 @@ +.conversation-list { + display: flex; + flex-direction: column; +} + +.conversation-search { + padding: 10px; + display: flex; + flex-direction: column; +} + +.conversation-search-input { + background: #f4f4f8; + padding: 8px 10px; + border-radius: 10px; + border: none; + font-size: 14px; +} + +.conversation-search-input::placeholder { + text-align: center; +} + +.conversation-search-input:focus::placeholder { + text-align: left; +} + + +.conversation-list-item { + display: flex; + align-items: center; + padding: 10px; +} + +.conversation-list-item-selected { + background: #ffcc00; +} + +.conversation-list-item:hover { + background: #ffcc00; + cursor: pointer; +} + +.conversation-photo { + width: 50px; + height: 50px; + border-radius: 50%; + object-fit: cover; + margin-right: 10px; +} + +.conversation-title { + font-size: 14px; + font-weight: bold; + text-transform: capitalize; + margin: 0; +} + +.conversation-snippet { + font-size: 14px; + color: #CCCCCC; + margin: 0; +} \ No newline at end of file diff --git a/src/components/ConversationList/index.tsx b/src/components/ConversationList/index.tsx new file mode 100644 index 0000000..eaf922a --- /dev/null +++ b/src/components/ConversationList/index.tsx @@ -0,0 +1,143 @@ +import React, {createRef, useState} from 'react'; +import debounce from "lodash/debounce"; +import {Image, Input} from "antd"; +import classNames from "classnames"; +import {useDispatch, useSelector} from 'react-redux'; + +import {AppState} from "@/store/typing"; +import {UserInfo} from "@/typings/user"; +import {actions} from "@/store/actions"; +import {DEFAULT_AVATAR} from "@/config"; +import BadgeStatus from "@/components/BadgeStatus"; + +import Toolbar from '../Toolbar'; +import ToolbarButton from '../ToolbarButton'; +import TYPING_ANIMATION_IMAGE from "@/assets/typing-animation.gif"; + +import './ConversationList.css'; +import {isFound} from "@/lib/vietnamese"; + +import {user_list} from "@/test/test_state"; + + +//Note: this component has been well-tested, do NOT change it!! +const ConversationSearch = ({setKeyword} : {setKeyword: (keyword: string) => void}) => { + const search_wait_time = 300; // milli seconds + const inputRef = createRef(); + const startSearch = () => { + //const time = new Date().getSeconds(); + const keyword = (inputRef.current) ? inputRef.current.input.value : ''; + //console.log('Keyword = ' + keyword + ' at ' + time); + setKeyword(keyword); + } + + return ( +
+ +
+ ); +} + + +const ConversationListItem = (props: { user: UserInfo, unread: { total: number, messages: string[] } }) => { + /*useEffect(() => { + shave('.conversation-snippet', 20); + })*/ + + const chatting_with_user = useSelector((state: AppState) => state.chatting_with_user); + const dispatch = useDispatch(); + + const { user, unread } = props; + const avatar = user.avatar || DEFAULT_AVATAR; + const pickUserToChat = () => { + dispatch(actions.chatWithUser(user.id)); + } + + const containerClass = classNames({ + 'conversation-list-item': true, + 'conversation-list-item-selected': chatting_with_user === user.id + }) + + return ( +
pickUserToChat()} title={unread.messages.join('\n')}> + + + conversation + + +
+

{ user.name } ({user.location})

+
{user.id}
+ { + user.typing && + } +
+
+ ); +} + + +const ConversationList = () => { + + const [keyword, setKeyword] = useState(''); + + //TODO: + const getUnreadMsgPerUser = () => { + return { + '12312312312': { + total: 10, + messages: ['Vâng ạ', 'Xin chào'] + }, + "12312312334": { + total: 3, + messages: ['Vâng ạ', 'Xin chào'] + }, + } + } + + //const no_unread = {total: 0, messages: []}; + const no_unread = {total: 2, messages: ['hello there', 'CÓ gì không thế?']}; + + const getUserList = (query: string = '') => { + if(query === '') return user_list; + + return user_list.filter((user) => isFound(query, user.name || '') ); + } + + const un_read_message_per_user: {[key: string]: { total: number, messages: string[] } } = getUnreadMsgPerUser(); + + + return ( +
+ + + ]} + rightItems={[ + + ]} + /> + + + + { + getUserList(keyword).map( + (user: UserInfo) => + ) + } + + +
+ ); +} + +export default ConversationList; diff --git a/src/components/CustomComponent/guide.txt b/src/components/CustomComponent/guide.txt new file mode 100644 index 0000000..2b6f1f8 --- /dev/null +++ b/src/components/CustomComponent/guide.txt @@ -0,0 +1,67 @@ +27-April-2021 + +- Summary: we can allow customers to create a custom-built component to replace a built-in component and run on our app. + +- Steps: ++ Step 1: develop component as a normal react component in an react app. Make sure it is bug-free. Example + const TestComponent = () => { + const [counter, setCounter] = useState(0); + const handleClick = () => { + alert('hello there'); + } + + return ( +
+

Counter: {counter} setCounter(counter + 1)}>[+]

+
Click vào đây để thử nhé Click
+
+ ) + } + ++ Step 2: use https://babeljs.io/repl to transpire into js. The above will produce: + var TestComponent = function Test() { + var _useState = React.useState(0), + counter = _useState[0], + setCounter = _useState[1]; + + var handleClick = function handleClick() { + alert('hello there'); + }; + + return /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("h2", null, "Counter: ", counter, " ", /*#__PURE__*/React.createElement("span", { + onClick: function onClick() { + return setCounter(counter + 1); + } + }, "[+]")), /*#__PURE__*/React.createElement("div", null, "Click v\xE0o \u0111\xE2y \u0111\u1EC3 th\u1EED nh\xE9 ", /*#__PURE__*/React.createElement("span", { + style: { + color: 'green' + }, + onClick: handleClick + }, "Click"))); + }; + ++ Step 3: Insert the js code in database to produce in our app's html files. Example: + + + + + + + + + + ++ Step 4: In our app, use the code like CustomComponent/index.tsx +The app will check if there is a custom component, it will run! + +- Trouble Shooting: ++ Error: React is not defined +Cause: React is built in because of webpack and custom components only can access React from window.React. To make React exposed to Window, in our app we can expose it (for example in the app's entry file index.tst) + +//src/index.tsx: +import React from 'react'; +... +window.React = React; // export React for outside world +...other diff --git a/src/components/CustomComponent/index.tsx b/src/components/CustomComponent/index.tsx new file mode 100644 index 0000000..e8284c9 --- /dev/null +++ b/src/components/CustomComponent/index.tsx @@ -0,0 +1,24 @@ +import React, {useState} from "react"; + + +const Test = () => { + + const [external, setExternal] = useState(false); + + const TestExternal = () => { + // @ts-ignore + return window.TestComponent() || TestExternalComponent not found + } + + + return ( + <> + { + external ? :
No external
+ } +
setExternal(true) }>Show TestExternalComponent
+ + ) +} + +export default Test; diff --git a/src/components/CustomerInfo/components/BrowseHistory.tsx b/src/components/CustomerInfo/components/BrowseHistory.tsx new file mode 100644 index 0000000..b73945a --- /dev/null +++ b/src/components/CustomerInfo/components/BrowseHistory.tsx @@ -0,0 +1,91 @@ +import {Table} from "antd"; +import React, {useEffect,useState} from "react"; +import {BrowseInfo} from "@/typings"; +import api from "@/lib/api"; +import Loading from "@/components/Loading"; +import {showUnixTime, subStr} from "@/lib/utils"; + + +const BrowseHistory = ({customer_id}: {customer_id: string|number}) => { + + const [loading, setLoading] = useState(false); + const [item_list, setList] = useState<{total: number, list: BrowseInfo[]}>({total: 0, list: []}); + const [refresh, setRefresh] = useState<{ page: number, token: number }>({page: 1, token: 0}); + const pageSize = 10; + const {page} = refresh; + + const requestFromAPI = async (page: number, customer_id: any ) => { + setLoading(true); + let result = await api.get('user/browse-history', {page, pageSize, customer_id}); + if(result.status === 'ok' && result.data) { + setList({ + total: result.data.total, + list: result.data.list.map((item: BrowseInfo) => { + return {...item, key: item.id} + }) + }); + } + setLoading(false); + } + + + useEffect(() => { + requestFromAPI(page, customer_id).then(); + + }, [page, customer_id]); + + + const columns = [ + { + title: 'Tên miền', + dataIndex: 'domain', + key: 'domain', + }, + { + title: 'Trang', + key: 'url', + render: (text: string, item: BrowseInfo) => { + return ( + {subStr(item.title, 30)} + ) + } + }, + { + title: 'Thời gian', + key: 'create_time', + render: (text: string, item: BrowseInfo) => { + return ( + <>{showUnixTime(item.create_time)} + ) + } + }, + ]; + + + if(loading) { + return + } + + return ( + { + setRefresh({page, token: 0}); + } + }} + /*onRow={(record, rowIndex) => { + return { + onClick: event => openDrawer('order-detail', {id: record.api_id}), // click row + }; + }}*/ + /> + ); +} + +export default BrowseHistory; diff --git a/src/components/CustomerInfo/components/Info.tsx b/src/components/CustomerInfo/components/Info.tsx new file mode 100644 index 0000000..30793e5 --- /dev/null +++ b/src/components/CustomerInfo/components/Info.tsx @@ -0,0 +1,236 @@ +import React, {useEffect} from "react"; +import {Button, Form, Input, Tooltip, DatePicker} from "antd"; +import {InfoCircleFilled} from "@ant-design/icons"; + +import api, {notifyApiResult} from "@/lib/api"; +import PROVINCE_LIST from "@/constant/province_list"; +import {SelectWithList} from "@/components/SelectBox"; +import {useDispatch} from "react-redux"; +import {UserInfo} from "@/typings/user"; +import {actions} from "@/store/actions"; +import GENDER_LIST from "@/constant/gender"; + + +const Info = ({user_info}: {user_info: UserInfo}) => { + + const [form] = Form.useForm(); + const user_id = user_info.id; + const dispatch = useDispatch(); + + useEffect(() => { + const default_info = { + "user_id": user_id, + "name": "", + "crm_code": "", + "email": "", + "note": "", + "gender": null, + "province": null, + "address": "", + "company": "", + "tel": "", + "website": "" + }; + + (async () => { + let result = await api.get('user/info', {id: user_id}); + if(result.status === 'ok') { + const filled_info = (result.data) ? result.data : default_info; + form.setFieldsValue({user: filled_info}); + } + })(); + }, [user_id, form]); + + const onFinish = (values: any) => { + console.log('Received values of form: ', values); + // form.resetFields(); + (async () => { + let result = await api.patch('user/update-info', {...values}); + notifyApiResult(result); + + // update the latest + if(result.status === 'ok') { + form.setFieldsValue({user: result.data}); + + // update user-info if some data changes + if(result.data.name !== user_info.name || result.data.province !== user_info.location) { + dispatch(actions.updateUserInfo({ + id: user_info.id, + name: result.data.name, + location: result.data.province, + })) + } + } + })(); + }; + + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + {/* + + + + */} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export default Info; diff --git a/src/components/CustomerInfo/components/ListNote.tsx b/src/components/CustomerInfo/components/ListNote.tsx new file mode 100644 index 0000000..d6eae9d --- /dev/null +++ b/src/components/CustomerInfo/components/ListNote.tsx @@ -0,0 +1,96 @@ +import {Table} from "antd"; +import React, {useEffect, useState} from "react"; +import {NoteInfo} from "@/typings"; +import api from "@/lib/api"; +import Loading from "@/components/Loading"; +import {getCurrentUTCTimestamp, showUnixTime, subStr} from "@/lib/utils"; +import Emitter from '@/lib/emitter'; +import {openDrawer} from "@/components/GlobalDrawer"; + + +const ListNote = ({customer_id}: {customer_id: string|number}) => { + + const [loading, setLoading] = useState(false); + const [item_list, setList] = useState<{total: number, list: NoteInfo[]}>({total: 0, list: []}); + const [refresh, setRefresh] = useState<{ page: number, token: number }>({page: 1, token: 0}); + const pageSize = 10; + const {page, token} = refresh; + + const requestFromAPI = async (page: number, customer_id: any, token: number ) => { + setLoading(true); + let result = await api.get('note/list', {page, pageSize, customer_id, _rf: token}); + if(result.status === 'ok' && result.data) { + setList({ + total: result.data.total, + list: result.data.list.map((item: NoteInfo) => { + return {...item, key: item.id} + }) + }); + } + setLoading(false); + } + + + useEffect(() => { + + // listen for new note creation + Emitter.on('create_note', () => { + setRefresh({page: 1, token: getCurrentUTCTimestamp()}) + }); + + requestFromAPI(page, customer_id, token).then(); + + // cleanup + return () => { + Emitter.off('create_note', () => { + console.log('Emitter.off create_note'); + }); + } + + }, [page, customer_id, token]); + + + const columns = [ + { + title: 'Nhân viên', + dataIndex: 'admin_name', + key: 'admin_name', + }, + { + title: 'Thông tin', + key: 'content', + render: (item: NoteInfo) => ( + openDrawer('note-detail', {id: item.api_id})} title={item.content}>{subStr(item.content, 30)} + ) + }, + { + title: 'Ngày', + key: 'create_time', + render: (item: NoteInfo) => ( + <>{showUnixTime(item.create_time)} + ) + }, + ]; + + if(loading) { + return + } + + return ( +
{ + setRefresh({page, token: 0}); + } + }} + /> + ); +} + +export default ListNote; diff --git a/src/components/CustomerInfo/components/ListOrder.tsx b/src/components/CustomerInfo/components/ListOrder.tsx new file mode 100644 index 0000000..117998b --- /dev/null +++ b/src/components/CustomerInfo/components/ListOrder.tsx @@ -0,0 +1,119 @@ +import React, {useEffect, useState} from "react"; +import {OrderInfo} from "@/typings"; +import api from "@/lib/api"; +import Loading from "@/components/Loading"; +import {Badge, Table} from "antd"; +import Emitter from "@/lib/emitter"; +import {getCurrentUTCTimestamp, showUnixTime, formatNumber} from "@/lib/utils"; +import {openDrawer} from "@/components/GlobalDrawer"; +import OrderStatus from "@/components/display/OrderStatus"; + + +const ListOrder = ({customer_id}: {customer_id: string|number}) => { + + const [loading, setLoading] = useState(false); + const [item_list, setList] = useState<{total: number, list: OrderInfo[]}>({total: 0, list: []}); + const [refresh, setRefresh] = useState<{ page: number, token: number }>({page: 1, token: 0}); + const pageSize = 10; + const {page, token} = refresh; + + const requestFromAPI = async (page: number, customer_id: any, token: number ) => { + setLoading(true); + let result = await api.get('order/list', {page, pageSize, customer_id, _rf: token}); + if(result.status === 'ok' && result.data) { + setList({ + total: result.data.total, + list: result.data.list.map((item: OrderInfo) => { + return {...item, key: item.api_id} + }) + }); + } + setLoading(false); + } + + + useEffect(() => { + + // listen for new note creation + Emitter.on('create_order', () => { + setRefresh({page: 1, token: getCurrentUTCTimestamp()}) + }); + + requestFromAPI(page, customer_id, token).then(); + + // cleanup + return () => { + Emitter.off('create_order', () => { + console.log('Emitter.off create_order'); + }); + } + + }, [page, customer_id, token]); + + + const columns = [ + { + title: 'Mã đơn', + key: 'api_id', + render: (item: OrderInfo) => ( + openDrawer('order-detail', {id: item.api_id})} title={'Xem chi tiết'}>{item.api_id} + ) + }, + { + title: 'Ngày', + key: 'create_time', + render: (item: OrderInfo) => ( + <>{showUnixTime(item.create_time)} + ) + }, + { + title: 'Giá trị', + key: 'total_value', + render: (item: OrderInfo) => ( + <>{formatNumber(item.total_value)} + ) + }, + { + title: 'Tình trạng', + key: 'order_status', + render: (item: OrderInfo) => ( + + ) + }, + { + title: 'Chi tiết', + render: (item: OrderInfo) => ( + openDrawer('order-detail', {id: item.api_id})} title={'Xem chi tiết'}> + + ) + }, + ]; + + + if(loading) { + return + } + + return ( +
{ + setRefresh({page, token: 0}); + } + }} + onRow={(record, rowIndex) => { + return { + onClick: event => openDrawer('order-detail', {id: record.api_id}), // click row + }; + }} + /> + ); +} + +export default ListOrder; diff --git a/src/components/CustomerInfo/components/ListSupport.tsx b/src/components/CustomerInfo/components/ListSupport.tsx new file mode 100644 index 0000000..463b147 --- /dev/null +++ b/src/components/CustomerInfo/components/ListSupport.tsx @@ -0,0 +1,107 @@ +import {Table} from "antd"; +import React, {useEffect, useState} from "react"; +import {SupportInfo} from "@/typings"; +import api from "@/lib/api"; +import Loading from "@/components/Loading"; +import Emitter from "@/lib/emitter"; +import {getCurrentUTCTimestamp, showUnixTime, subStr} from "@/lib/utils"; +import {openDrawer} from "@/components/GlobalDrawer"; + + +const ListSupport = ({customer_id}: {customer_id: string|number}) => { + + const [loading, setLoading] = useState(false); + const [item_list, setList] = useState<{total: number, list: SupportInfo[]}>({total: 0, list: []}); + const [refresh, setRefresh] = useState<{ page: number, token: number }>({page: 1, token: 0}); + const pageSize = 10; + const {page, token} = refresh; + + const requestFromAPI = async (page: number, customer_id: any, token: number ) => { + setLoading(true); + let result = await api.get('support/list', {page, pageSize, customer_id, _rf: token}); + if(result.status === 'ok' && result.data) { + setList({ + total: result.data.total, + list: result.data.list.map((item: SupportInfo) => { + return {...item, key: item.id} + }) + }); + } + setLoading(false); + } + + + useEffect(() => { + + // listen for new note creation + Emitter.on('create_support', () => { + setRefresh({page: 1, token: getCurrentUTCTimestamp()}) + }); + + requestFromAPI(page, customer_id, token).then(); + + // cleanup + return () => { + Emitter.off('create_support', () => { + console.log('Emitter.off create_note'); + }); + } + + }, [page, customer_id, token]); + + + const columns = [ + { + title: 'Nhân viên', + dataIndex: 'admin_name', + key: 'admin_name', + }, + { + title: 'Thông tin', + render: (item: SupportInfo) => ( + openDrawer('support-detail', {id: item.api_id})} title={item.title}>{subStr(item.title, 50)} + ) + }, + { + title: 'Ngày', + key: 'create_time', + render: (item: SupportInfo) => ( + <>{showUnixTime(item.create_time)} + ) + }, + { + title: 'Phản hồi', + dataIndex: 'comment_count', + key: 'comment_count', + }, + { + title: 'Tình trạng', + dataIndex: 'status', + key: 'status', + }, + ]; + + + if(loading) { + return + } + + return ( +
{ + setRefresh({page, token: 0}); + } + }} + /> + ); +} + + +export default ListSupport; diff --git a/src/components/CustomerInfo/index.tsx b/src/components/CustomerInfo/index.tsx new file mode 100644 index 0000000..d248628 --- /dev/null +++ b/src/components/CustomerInfo/index.tsx @@ -0,0 +1,52 @@ +import {Tabs} from "antd"; +import React, { Suspense} from "react"; +import Loading from "@/components/Loading"; +import {UserInfo} from "@/typings/user"; + +const { TabPane } = Tabs; + +const CustomerInfoComponent = React.lazy(() => import('./components/Info')); +const CustomerListOrderComponent = React.lazy(() => import('./components/ListOrder')); +const CustomerListSupportComponent = React.lazy(() => import('./components/ListSupport')); +const CustomerListNoteComponent = React.lazy(() => import('./components/ListNote')); +const BrowseHistoryComponent = React.lazy(() => import('./components/BrowseHistory')); + + +const CustomerInfo = ({user_info}: {user_info: UserInfo}) => { + + return ( + + + } > + + + + + + } > + + + + + + } > + + + + + + } > + + + + + + } > + + + + + ) +} + +export default CustomerInfo; diff --git a/src/components/DashBoard/index.tsx b/src/components/DashBoard/index.tsx new file mode 100644 index 0000000..be24fff --- /dev/null +++ b/src/components/DashBoard/index.tsx @@ -0,0 +1,9 @@ +import React from "react"; + +const DashBoard = () => { + return ( +
Vui lòng chọn người chat
+ ) +} + +export default DashBoard; \ No newline at end of file diff --git a/src/components/Error/ItemNotFound.tsx b/src/components/Error/ItemNotFound.tsx new file mode 100644 index 0000000..d148f67 --- /dev/null +++ b/src/components/Error/ItemNotFound.tsx @@ -0,0 +1,9 @@ +const ItemNotFound = ({msg}: {msg?: string}) => { + return ( + <> + {msg || 'Not found'} + + ) +} + +export default ItemNotFound; diff --git a/src/components/Error/NetworkError.tsx b/src/components/Error/NetworkError.tsx new file mode 100644 index 0000000..a9b3a34 --- /dev/null +++ b/src/components/Error/NetworkError.tsx @@ -0,0 +1,77 @@ +import React, {FC} from "react"; +import {useSelector} from "react-redux"; +import {AppState} from "@/store/typing"; +import {Modal} from "antd"; +import {RobotOutlined } from '@ant-design/icons'; + +const NetworkError: FC = () => { + + const {network_connection, node_connection } = useSelector((state: AppState) => ( + { + network_connection: state.network_connection, + node_connection: state.node_connection + } + )); + + const Title = () => { + if(network_connection === 'offline') { + return ( + Lỗi kết nối (code: #1) + ) + } + + if(node_connection === 'error') { + return ( + Lỗi kết nối (code: #2) + ) + } + + return null; + }; + + const Messsage = () => { + if(network_connection === 'offline') { + return
Vui lòng kiểm tra lại đường truyền Internet của bạn
; + } + + if(node_connection === 'error') { + return ( +
+
Kết nối tới máy chủ Chatngay không thành công!
+
Vui lòng đợi một lát và thử làm mới lại trang này (nhấn phím F5). Nếu lỗi vẫn xảy ra, vui lòng thông báo cho bộ phận CSKH của Chatngay tại https://www.chatngay.com/support
+
+ ) + } + + return null; + } + + const EmptyIcon = () => { + return ( +   + ) + } + + + const connection_error : boolean = (network_connection === 'offline' || node_connection === 'error'); + + if(!connection_error) { + return null; + } + + return ( + } + centered + visible + footer={null} + width={500} + maskClosable={false} + closeIcon={} + > + + + ) +}; + +export default NetworkError; diff --git a/src/components/Error/index.tsx b/src/components/Error/index.tsx new file mode 100644 index 0000000..9f56c78 --- /dev/null +++ b/src/components/Error/index.tsx @@ -0,0 +1,11 @@ +import React from "react"; + +export default function Error({message}: {message?: string}) { + return ( + <> +
+ Error: {message || 'unknown'} +
+ + ) +} diff --git a/src/components/GlobalDrawer/components/NoteDetail.tsx b/src/components/GlobalDrawer/components/NoteDetail.tsx new file mode 100644 index 0000000..0f2542b --- /dev/null +++ b/src/components/GlobalDrawer/components/NoteDetail.tsx @@ -0,0 +1,32 @@ +import React, {useEffect, useState} from "react"; +import api from "@/lib/api"; +import {NoteInfo} from "@/typings"; +import Comment from "@/components/Comment"; +import {showUnixTime} from "@/lib/utils"; + + +const NoteDetail = ({id}: {id: string|number}) => { + + const [item_info, setInfo] = useState({}); + + useEffect(() => { + (async () => { + let result = await api.get('note/info', {id}); + if(result.status === 'ok') { + setInfo(result.data); + } + })(); + }, [id]); + + return ( + <> +
{item_info.admin_name} ({showUnixTime(item_info.create_time)})
+ +
{item_info.content}
+ + + + ) +} + +export default NoteDetail; diff --git a/src/components/GlobalDrawer/components/OrderDetail.tsx b/src/components/GlobalDrawer/components/OrderDetail.tsx new file mode 100644 index 0000000..83864cf --- /dev/null +++ b/src/components/GlobalDrawer/components/OrderDetail.tsx @@ -0,0 +1,96 @@ +import React, {useEffect, useState} from "react"; +import {Descriptions} from "antd"; + +import api from "@/lib/api"; +import Comment from "@/components/Comment"; +import {OrderInfo} from "@/typings"; +import {showUnixTime} from "@/lib/utils"; +import OrderStatus from "@/components/display/OrderStatus"; +import ShippingStatus from "@/components/display/ShippingStatus"; +import PaymentStatus from "@/components/display/PaymentStatus"; + + +const OrderDetail = ({id}: {id: string|number}) => { + + const [item_info, setInfo] = useState({}); + + useEffect(() => { + (async () => { + let result = await api.get('order/info', {id}); + if(result.status === 'ok') { + setInfo(result.data); + } + })(); + }, [id]); + + + return ( + <> + + {item_info.api_id} + {item_info.note} + {showUnixTime(item_info.create_time)} + + + + + + + + + + + + {item_info.customer?.name}
+ {item_info.customer?.email}
+ {item_info.customer?.mobile}
+ {item_info.customer?.address}
+ {item_info.customer?.province}
+
+ + { + item_info.products?.map((product, index) => { + return ( + <> + {index + 1}. {product.name} - SKU: {product.sku} - Giá: {product.price} - SL: {product.quantity}
+ + ) + } ) + } +
+ + { + item_info.others?.map((other, index) => { + return ( + <> + {index + 1}. {other.name} - {other.price} + + ) + } ) + } + + + {item_info.shipping?.provider}
+ {item_info.shipping?.reference}
+ {item_info.shipping?.note}
+ {item_info.shipping?.date} - {item_info.shipping?.time}
+
+ + {item_info.payment?.method}
+ {item_info.payment?.reference}
+ {item_info.payment?.note}
+
+ {item_info.tags?.join(', ')} +
+ + + + ) +} + +export default OrderDetail; diff --git a/src/components/GlobalDrawer/components/SupportDetail.tsx b/src/components/GlobalDrawer/components/SupportDetail.tsx new file mode 100644 index 0000000..478ba9a --- /dev/null +++ b/src/components/GlobalDrawer/components/SupportDetail.tsx @@ -0,0 +1,40 @@ +import React, {useEffect, useState} from "react"; +import api from "@/lib/api"; +import {SupportInfo} from "@/typings"; +import Comment from "@/components/Comment"; +import {showUnixTime} from "@/lib/utils"; +import TextWithLineBreak from "@/components/TextWithLineBreak"; +import ItemNotFound from "@/components/Error/ItemNotFound"; + + +const SupportDetail = ({id}: {id: string|number}) => { + + const [item_info, setInfo] = useState({}); + + useEffect(() => { + (async () => { + let result = await api.get('support/info', {id}); + if(result.status === 'ok') { + setInfo(result.data); + } + })(); + }, [id]); + + if(!item_info) { + return + } + + return ( + <> + +
{item_info.title}
+
{item_info.admin_name} ({showUnixTime(item_info.create_time)})
+ + + + + + ) +} + +export default SupportDetail; diff --git a/src/components/GlobalDrawer/index.tsx b/src/components/GlobalDrawer/index.tsx new file mode 100644 index 0000000..fef43e7 --- /dev/null +++ b/src/components/GlobalDrawer/index.tsx @@ -0,0 +1,88 @@ +import React, {Suspense} from 'react'; +import { Drawer } from 'antd'; +import {useDispatch, useSelector} from "react-redux"; +import {actions} from "@/store/actions"; +import {AppState} from "@/store/typing"; +import Loading from "@/components/Loading"; +import store from "@/store"; + + +const OrderDetailComponent = React.lazy(() => import('./components/OrderDetail')); +const ProductListComponent = React.lazy(() => import('@/components/Help/components/ProductList')); +const NoteDetail = React.lazy(() => import('./components/NoteDetail')); +const SupportDetail = React.lazy(() => import('./components/SupportDetail')); + + +type DrawerComponentType = 'order-detail' | 'product-list' | 'note-detail' | 'support-detail'; + + +export const openDrawer = (component: DrawerComponentType, args: any) => { + store.dispatch(actions.openGlobalDrawer({component, args})) +} + + +const GlobalDrawer: React.FC = () => { + + const dispatch = useDispatch(); + const closeDrawer = () => { + dispatch(actions.openGlobalDrawer({component: ''})) + } + const global_drawer = useSelector((state: AppState) => state.global_drawer); + const drawer_component = (global_drawer !== '') ? JSON.parse(global_drawer) : {component: '', args: {}}; + + const getLoadedComponent = () => { + if(drawer_component.component === 'order-detail') { + return ; + } + + if(drawer_component.component === 'note-detail') { + return ; + } + + if(drawer_component.component === 'support-detail') { + return ; + } + + if(drawer_component.component === 'product-list') { + return alert(id)} />; + } + + return null; + } + + const Title = () => { + if(drawer_component.component === 'order-detail') { + return <>Chi tiết đơn hàng; + } + + if(drawer_component.component === 'note-detail') { + return <>Ghi chú; + } + + if(drawer_component.component === 'support-detail') { + return <>Chi tiết hỗ trợ; + } + + return null; + } + + + return ( + <> + } + width={500} + placement="right" + closable + onClose={closeDrawer} + visible={drawer_component.component !== ''} + > + }> + { getLoadedComponent() } + + + + ); +}; + +export default GlobalDrawer; diff --git a/src/components/GlobalModal/components/ListAdminOnline.tsx b/src/components/GlobalModal/components/ListAdminOnline.tsx new file mode 100644 index 0000000..4030860 --- /dev/null +++ b/src/components/GlobalModal/components/ListAdminOnline.tsx @@ -0,0 +1,40 @@ +import {useSelector} from "react-redux"; +import {AppState} from "@/store/typing"; +import {AdminInfo} from "@/typings/user"; +import { List, Avatar, Badge } from 'antd'; +import React from "react"; +import {DEFAULT_AVATAR} from "@/config"; + +const ListAdminOnline = () => { + + const admin_list: AdminInfo[] = useSelector((state: AppState) => state.admin_list); + + return ( + ( + alert(item.id)}>edit]} + > + + } + title={<>{item.name}} + //description={} + /> + +
+ +
+ +
+ )} + /> + ); + +} + +export default ListAdminOnline; diff --git a/src/components/GlobalModal/components/ListUserOnline.tsx b/src/components/GlobalModal/components/ListUserOnline.tsx new file mode 100644 index 0000000..1ecc083 --- /dev/null +++ b/src/components/GlobalModal/components/ListUserOnline.tsx @@ -0,0 +1,26 @@ +import React, {useEffect, useState} from "react"; +import api from "@/lib/api"; + + +const ListUserOnline = () => { + + const [data, setData] = useState<{total: number, locations: any[]}>({total: 0, locations: []}); + + useEffect(() => { + + (async () => { + let result = await api.get('monitor/real-time', {}); + if(result.status === 'ok' && result.data) { + setData(result.data); + } + })(); + }, []); + + return ( + <> + {JSON.stringify(data)} + + ); +} + +export default ListUserOnline; \ No newline at end of file diff --git a/src/components/GlobalModal/index.tsx b/src/components/GlobalModal/index.tsx new file mode 100644 index 0000000..cfd7a84 --- /dev/null +++ b/src/components/GlobalModal/index.tsx @@ -0,0 +1,87 @@ +import React, {Suspense} from "react"; +import Loading from "@/components/Loading"; +import {Modal} from "antd"; +import {useDispatch, useSelector, shallowEqual} from "react-redux"; +import {AppState} from "@/store/typing"; +import {actions} from "@/store/actions"; + +const HelpModal = React.lazy(() => import('@/components/Help/HelpModal')); +const ProductListComponent = React.lazy(() => import('@/components/Help/components/ProductList')); +const ListAdminOnline = React.lazy(() => import('./components/ListAdminOnline')); +const ListUserOnline = React.lazy(() => import('./components/ListUserOnline')); + + +const ModalComponent = () => { + const dispatch = useDispatch(); + const closeModal = () => { + dispatch(actions.openGlobalModal({component: ''})) + } + + const global_modal = useSelector((state: AppState) => state.global_modal, shallowEqual); + const modal_component = (global_modal !== '') ? JSON.parse(global_modal) : {component: '', args: {}}; + + console.log('modal_component'); + console.log(modal_component); + + const getLoadedComponent = () => { + + if(modal_component.component === 'product-list') { + return alert(id)} />; + } + + if(modal_component.component === 'help') { + return ; + } + + if(modal_component.component === 'admin') { + return ; + } + + if(modal_component.component === 'user') { + return ; + } + + return null; + } + + const Title = () => { + if(modal_component.component === 'product-list') { + return Danh sách sản phẩm; + } + + if(modal_component.component === 'help') { + return Danh sách bài viết; + } + + if(modal_component.component === 'admin') { + return Quản trị viên; + } + + if(modal_component.component === 'user') { + return Người dùng; + } + + return null; + }; + + + return ( + } + centered + visible={modal_component.component !== ''} + onOk={closeModal} + onCancel={closeModal} + width={1000} + maskClosable={false} + > + }> + { + getLoadedComponent() + } + + + ) +} + +export default ModalComponent; diff --git a/src/components/HeaderComponent/index.tsx b/src/components/HeaderComponent/index.tsx new file mode 100644 index 0000000..0139c0e --- /dev/null +++ b/src/components/HeaderComponent/index.tsx @@ -0,0 +1,104 @@ +import React, {Fragment} from "react"; +import {useDispatch, useSelector, shallowEqual} from "react-redux"; +import {actions} from "@/store/actions"; +import {AppState} from "@/store/typing"; +import {getAdminInfo} from "@/lib/user"; +import {Col, Row, Image, Menu, Dropdown} from "antd"; +import { DownOutlined, TeamOutlined, UserOutlined, QuestionOutlined } from '@ant-design/icons'; + +import "./styles.css"; +import {MenuInfo} from "rc-menu/lib/interface"; + + +const menu = ( + + + + 1st menu item + + + } disabled> + + 2nd menu item + + + + + 3rd menu item + + + a danger item + +); + + +const HeaderComponent = () => { + const admin_info = getAdminInfo(); + const {stats, admin_list} = useSelector((state: AppState) => ( + { + stats: state.stats, + admin_list: state.admin_list, + } + ), shallowEqual); + + const current_stats: { + user_online: number, + } = (stats !== '') ? JSON.parse(stats) : { user_online: 0}; + + const dispatch = useDispatch(); + const setOpenModal = (component: string) => { + dispatch(actions.openGlobalModal({component, args: {}})) + } + + const handleMenuClick = (e: MenuInfo) => { + setOpenModal(e.key + '') + } + + const TOTAL_ADMIN = admin_list.length; + const ADMIN_ONLINE = admin_list.filter(admin => admin.online).length; + + + return ( + + + + + + +
+ + + } > + Người dùng online ({current_stats.user_online}) + + } > + Quản trị viên online ({ADMIN_ONLINE}/{TOTAL_ADMIN}) + + }> + Trợ giúp + + + + + + + + + + + {admin_info.name} + + + + + + + + ) +}; + +export default HeaderComponent; diff --git a/src/components/HeaderComponent/styles.css b/src/components/HeaderComponent/styles.css new file mode 100644 index 0000000..6ff0638 --- /dev/null +++ b/src/components/HeaderComponent/styles.css @@ -0,0 +1,9 @@ +.header { + color: white; +} + +.ant-layout-header .ant-menu{ + background: black; + color: white; +} + diff --git a/src/components/Help/HelpModal.tsx b/src/components/Help/HelpModal.tsx new file mode 100644 index 0000000..63f2592 --- /dev/null +++ b/src/components/Help/HelpModal.tsx @@ -0,0 +1,26 @@ +import {Tabs} from "antd"; +import React from "react"; + + +const ProductListComponent = React.lazy(() => import('./components/ProductList')); +const ArticleListComponent = React.lazy(() => import('./components/ArticleList')); + +const { TabPane } = Tabs; + +const HelpModal = () => { + + return ( + null } type="card" defaultActiveKey={'product'}> + + {} } /> + + + { + + } } /> + + + ) +} + +export default HelpModal; diff --git a/src/components/Help/HelpSideBar.tsx b/src/components/Help/HelpSideBar.tsx new file mode 100644 index 0000000..12a0410 --- /dev/null +++ b/src/components/Help/HelpSideBar.tsx @@ -0,0 +1,99 @@ +import React, {useEffect, useState, Suspense} from "react"; +import api from "@/lib/api"; +import Loading from "@/components/Loading"; +import {HelpType, OpenHelpComponent} from "@/typings"; +import SearchBox from "./components/SearchBox"; + +const ProductListComponent = React.lazy(() => import('./components/ProductList')); +const ArticleListComponent = React.lazy(() => import('./components/ArticleList')); +const ProductDetailComponent = React.lazy(() => import('./components/ProductDetail')); +const ArticleDetailComponent = React.lazy(() => import('./components/ArticleDetail')); +const SearchComponent = React.lazy(() => import('./components/Search')); + +const WINDOW_HEIGHT = global.window.innerHeight; +const HELP_HEIGHT = WINDOW_HEIGHT - 150; + + +const HomeComponent = (props: {openComponent: (type: HelpType, params?: { [key: string] : any }) => void}) => { + const [help, setHelp] = useState<{product: any[], article: any[]}>({product:[], article:[]}); + + useEffect(() => { + const _getHelp = async () => { + let result = await api.get('help/home'); + if(result.status === 'ok') { + setHelp(result.data); + } + }; + _getHelp(); + }, []); + + return ( + <> +
+ +

Sản phẩm: props.openComponent('product-list')}>Xem het

+ + props.openComponent('product-detail', {id})} + /> + +

Kho kiến thức: props.openComponent('article-list')}>Xem het

+ + props.openComponent('article-detail', {id})} + /> + +
+ + ) +} + + +const HelpSideBar = () => { + + const [component, setComponent] = useState({type: 'home'}); + + const openComponent = (type: HelpType, params?: { [key: string] : any }) => { + setComponent({type, params}); + } + + const getLoadedComponent = () => { + if(component.type === 'product-list') { + return openComponent('product-detail', {id})} />; + } + + if(component.type === 'product-detail') { + return openComponent('home')} />; + } + + if(component.type === 'article-list') { + return openComponent('article-detail', {id})} />; + } + + if(component.type === 'article-detail') { + return openComponent('home')} />; + } + + if(component.type === 'search') { + return ; + } + + return ; + } + + + return ( + }> + + + { + getLoadedComponent() + } + + ) + +} + +export default HelpSideBar; diff --git a/src/components/Help/components/ArticleDetail.tsx b/src/components/Help/components/ArticleDetail.tsx new file mode 100644 index 0000000..e3c376c --- /dev/null +++ b/src/components/Help/components/ArticleDetail.tsx @@ -0,0 +1,36 @@ +import React, {useEffect, useState} from "react"; +import api from "@/lib/api"; +import {HelpItem} from "@/typings"; +import Comment from "@/components/Comment"; + + +const ArticleDetailComponent = ({id, openHome}: {id?: string|number, openHome: () => void}) => { + + const [article_info, setArticle] = useState({}); + + useEffect(() => { + const _getInfo = async () => { + let result = await api.get('article/info', {id}); + if(result.status === 'ok') { + setArticle(result.data); + } + }; + _getInfo(); + }, [id]); + + + return ( + <> +
Back to home
+ +

{article_info.name}

+ +
{article_info.summary}
+ + + + + ) +} + +export default ArticleDetailComponent; diff --git a/src/components/Help/components/ArticleList.tsx b/src/components/Help/components/ArticleList.tsx new file mode 100644 index 0000000..f3a9075 --- /dev/null +++ b/src/components/Help/components/ArticleList.tsx @@ -0,0 +1,89 @@ +import { List, Pagination} from "antd"; +import React, {useEffect, useRef, useState} from "react"; +import {HelpItem} from "@/typings"; +import api from "@/lib/api"; +import Loading from "@/components/Loading"; + + +const TopListComponent = (props: {defaultList: HelpItem[], openItem: (id?: string|number) => void}) => { + + const {defaultList, openItem} = props; + + return ( + <> + ( + openItem(item.id)}> + {item.name}} + description={item.summary} + /> + + )} + /> + + ) +} + + +const FullListComponent = (props: {openItem: (id?: string|number) => void, params?: {[key: string]: any}}) => { + + const {openItem , params} = props; + const item_list = useRef( []); + const [loading, setLoading] = useState(false); + const [page, setPage] = useState(1); + + // const [item_list, setList] = useState(props.defaultList || []); + useEffect(() => { + (async () => { + setLoading(true); + let result = await api.get('article/list', params ? {...params, page} : {page}); + // @ts-ignore + if(result.status === 'ok') { + item_list.current = result.data.list; + } + setLoading(false); + }) (); + }, [page, params]); + + if(loading) { + return + } + + return ( + <> + ( + openItem(item.id)}> + {item.name}} + description={item.summary} + /> + + )} + /> + + setPage(page)} /> + + + ) +} + + +const ArticleListComponent = (props: {defaultList?: HelpItem[], openItem: (id?: string|number) => void, params?: {[key: string]: any} }) => { + + const {defaultList, openItem, params} = props; + + if(defaultList) { + return + } + + return +} + + +export default ArticleListComponent; diff --git a/src/components/Help/components/ProductDetail.tsx b/src/components/Help/components/ProductDetail.tsx new file mode 100644 index 0000000..30c2dab --- /dev/null +++ b/src/components/Help/components/ProductDetail.tsx @@ -0,0 +1,55 @@ +import React, {useEffect, useState} from "react"; +import api from "@/lib/api"; +import {HelpItem} from "@/typings"; +import {Descriptions} from "antd"; +import {formatNumber, showUnixTime} from "@/lib/utils"; +import PaymentStatus from "@/components/display/PaymentStatus"; +import ShippingStatus from "@/components/display/ShippingStatus"; +import OrderStatus from "@/components/display/OrderStatus"; +import Comment from "@/components/Comment"; + +const ProductDetailComponent = ({id, openHome}: {id?: string|number, openHome: () => void}) => { + + const [product_info, setProduct] = useState({}); + + useEffect(() => { + const _getInfo = async () => { + let result = await api.get('product/info?id=', {id}); + if(result.status === 'ok') { + setProduct(result.data); + } + }; + _getInfo(); + }, [id]); + + return ( + <> +
Back to home
+ + + {product_info.name} + {product_info.in_stock} + --- + + {formatNumber(product_info.price)} + + + {product_info.summary} + + + {''} + + + + + + + ) +} + +export default ProductDetailComponent; diff --git a/src/components/Help/components/ProductList.tsx b/src/components/Help/components/ProductList.tsx new file mode 100644 index 0000000..114ac3a --- /dev/null +++ b/src/components/Help/components/ProductList.tsx @@ -0,0 +1,106 @@ +import {Avatar, List, Pagination} from "antd"; +import React, {useEffect, useRef, useState} from "react"; +import {HelpItem} from "@/typings"; +import api from "@/lib/api"; + +import Loading from "@/components/Loading"; +import {formatNumber} from "@/lib/utils"; + + +const ProductDescription = ({item}: {item: HelpItem}) => { + const stock = item.in_stock ? 'Còn hàng' : 'Hết hàng'; + + return ( + <> + {formatNumber(item.price)} - {stock} + + ) +} + + +const TopListComponent = (props: {defaultList: HelpItem[], openItem: (id?: string|number) => void }) => { + + const {defaultList, openItem} = props; + + return ( + <> + ( + openItem(item.id)}> + } + title={{item.name}} + description={} + /> + + )} + /> + + ) +} + + +const FullListComponent = (props: {openItem: (id?: string|number) => void, params?: {[key: string]: any} }) => { + + const {openItem, params} = props; + const item_list = useRef( []); + const [loading, setLoading] = useState(false); + const [page, setPage] = useState(1); + + + + // const [item_list, setList] = useState(props.defaultList || []); + useEffect(() => { + (async () => { + setLoading(true); + let result = await api.get('product/list', params ? {...params, page} : {page}); + // @ts-ignore + if(result.status === 'ok') { + item_list.current = result.data.list; + } + setLoading(false); + }) (); + }, [page, params]); + + if(loading) { + return + } + + return ( + <> + ( + openItem(item.id)}> + } + title={{item.name}} + description={} + /> + + )} + /> + + setPage(page)} /> + + + ) +} + + +const ProductListComponent = (props: {defaultList?: HelpItem[], openItem: (id?: string|number) => void , params?: {[key: string]: any} }) => { + + const {defaultList, openItem, params} = props; + + if(defaultList) { + return + } + + return +} + + +export default ProductListComponent; diff --git a/src/components/Help/components/Search.tsx b/src/components/Help/components/Search.tsx new file mode 100644 index 0000000..5b6f840 --- /dev/null +++ b/src/components/Help/components/Search.tsx @@ -0,0 +1,49 @@ +import React, {useEffect, useState} from "react"; +import api from "@/lib/api"; +import {HelpType} from "@/typings"; + +const ProductListComponent = React.lazy(() => import('./ProductList')); +const ArticleListComponent = React.lazy(() => import('./ArticleList')); + +const WINDOW_HEIGHT = global.window.innerHeight; +const HELP_HEIGHT = WINDOW_HEIGHT - 150; + + +const SearchComponent = (props: {keyword?: string, openComponent: (type: HelpType, params?: { [key: string] : any }) => void}) => { + + const [help, setHelp] = useState<{product: any[], article: any[]}>({product:[], article:[]}); + const {keyword} = props; + + useEffect(() => { + (async () => { + let result = await api.get('help/search', {q: keyword || ''}); + if(result.status === 'ok') { + setHelp(result.data); + } + })(); + }, [keyword]); + + return ( + <> +
+ +

Sản phẩm thỏa mãn: props.openComponent('product-list', {q: keyword || ''})}>Xem het

+ + props.openComponent('product-detail', {id})} + /> + +

Kho kiến thức thỏa mãn: props.openComponent('article-list', {q: keyword || ''})}>Xem het

+ + props.openComponent('article-detail', {id})} + /> + +
+ + ) +} + +export default SearchComponent; diff --git a/src/components/Help/components/SearchBox.tsx b/src/components/Help/components/SearchBox.tsx new file mode 100644 index 0000000..24786ec --- /dev/null +++ b/src/components/Help/components/SearchBox.tsx @@ -0,0 +1,39 @@ +import {Input} from "antd"; +import React, {useState} from "react"; +import {HelpType} from "@/typings"; + +const { Search } = Input; + + +const SearchBox = (props: {openComponent: (type: HelpType, params?: { [key: string] : string|number }) => void}) => { + + const [keyword, setKeyword] = useState(''); + + const startSearch = () => { + if(keyword === '') { + props.openComponent('home'); + return; + } + props.openComponent('search', {q: keyword}); + } + + return ( +
+ setKeyword(value)} + onPressEnter={(e) => { + e.preventDefault(); // required to prevent new line + startSearch(); + }} + /> +
+ ) +} + +export default SearchBox; diff --git a/src/components/Help/index.tsx b/src/components/Help/index.tsx new file mode 100644 index 0000000..3535246 --- /dev/null +++ b/src/components/Help/index.tsx @@ -0,0 +1,2 @@ +export {default as HelpSideBar} from "./HelpSideBar"; +export {default as HelpModal} from "./HelpModal"; \ No newline at end of file diff --git a/src/components/Loading/index.tsx b/src/components/Loading/index.tsx new file mode 100644 index 0000000..6b3813e --- /dev/null +++ b/src/components/Loading/index.tsx @@ -0,0 +1,14 @@ +import React from "react"; +import { Spin } from 'antd'; +import { LoadingOutlined } from '@ant-design/icons'; + +const antIcon = ; + + +export default function Loading() { + return ( + <> + + + ) +} diff --git a/src/components/SelectBox/SelectWithAddItem.tsx b/src/components/SelectBox/SelectWithAddItem.tsx new file mode 100644 index 0000000..a3504c9 --- /dev/null +++ b/src/components/SelectBox/SelectWithAddItem.tsx @@ -0,0 +1,83 @@ +import React from "react"; +import { Select, Divider, Input } from 'antd'; +import { PlusOutlined } from '@ant-design/icons'; +import {SelectProps} from "antd/lib/select"; + +const { Option } = Select; + +let index = 0; + +type SelectWithAddItemProps = SelectProps & { + current_lists: string[]; +} + +type SelectWithAddItemState = { + items: string[]; + new_item_name: string; +} + + +class SelectWithAddItem extends React.Component { + + constructor(props: SelectWithAddItemProps) { + super(props); + this.state = { + items: props.current_lists, + new_item_name: '', + } + } + + onNameChange = (event: any) => { + this.setState({ + new_item_name: event.target.value, + }) + } + + addItem = () => { + const { items, new_item_name } = this.state; + if(new_item_name.length > 2 && !items.includes(new_item_name)) { + this.setState({ + items: [...items, new_item_name || `New item ${index++}`], + new_item_name: '', + }) + } + } + + render() { + const { items, new_item_name } = this.state; + const {current_lists, ...others} = this.props; + + return ( + + + Thêm + + + + + )} + {...others} + > + {items.map(item => ( + + ))} + + ); + } +} + +export default SelectWithAddItem; diff --git a/src/components/SelectBox/SelectWithAjax.tsx b/src/components/SelectBox/SelectWithAjax.tsx new file mode 100644 index 0000000..ed9d5a4 --- /dev/null +++ b/src/components/SelectBox/SelectWithAjax.tsx @@ -0,0 +1,79 @@ +import React from "react"; +import { Select } from 'antd'; + +import {SelectProps} from "antd/lib/select"; + +const { Option } = Select; + + +type SelectWithAjaxProp = SelectProps & { + onFocusSuggestedData?: any[]; + buildOption?: (d: any) => React.ReactNode; + searchFn: (query: string) => Promise +}; + +type SelectWithAjaxState = { + data: { value: string, text: string, [other:string]: any }[]; + value?: any +} + + +class SelectWithAjax extends React.Component { + + constructor(props: SelectWithAjaxProp) { + super(props); + this.state = { + data: [], + value: undefined, + } + } + + handleSearch = (value: any) => { + if (value) { + this.props.searchFn(value).then(data => this.setState({ data })) + } else { + this.setState({ data: [] }); + } + } + + handleFocus= () => { + const {value} = this.state; + const {onFocusSuggestedData} = this.props; + if( ! value) { + this.setState({ data: onFocusSuggestedData || [] }); + } + } + + handleChange = (value: any) => { + this.setState({ value }); + } + + render() { + + const {buildOption, onFocusSuggestedData, ...others} = this.props; + + return ( + + ) + } +} + +export default SelectWithAjax; diff --git a/src/components/SelectBox/SelectWithList.tsx b/src/components/SelectBox/SelectWithList.tsx new file mode 100644 index 0000000..4eed6d6 --- /dev/null +++ b/src/components/SelectBox/SelectWithList.tsx @@ -0,0 +1,32 @@ +import { Select } from 'antd'; +import {SelectProps} from "antd/lib/select"; +import {isFound} from "@/lib/vietnamese"; + +const { Option } = Select; + +const SelectWithList = (props : {options: {value: string|number, text: string}[]} & SelectProps ) => { + + const {options, ...others} = props; + + return ( + + ) +} + +export default SelectWithList; diff --git a/src/components/SelectBox/index.tsx b/src/components/SelectBox/index.tsx new file mode 100644 index 0000000..0115a5c --- /dev/null +++ b/src/components/SelectBox/index.tsx @@ -0,0 +1,3 @@ +export {default as SelectWithAjax} from './SelectWithAjax'; +export {default as SelectWithList} from './SelectWithList'; +export {default as SelectWithAddItem} from './SelectWithAddItem'; diff --git a/src/components/Tagging/index.tsx b/src/components/Tagging/index.tsx new file mode 100644 index 0000000..af3e8f8 --- /dev/null +++ b/src/components/Tagging/index.tsx @@ -0,0 +1,219 @@ +import {Tag, Input, Tooltip, message} from 'antd'; +import { PlusOutlined } from '@ant-design/icons'; +import React, {createRef, Fragment, useState} from "react"; + +type EditableTagGroupState = { + tags: string[], + editInputIndex: number, + editInputValue: string, + loadExistingList: boolean +} + +type EditableTagGroupProps = { + current_list?: string[]; + createTag?: (tag: string) => Promise; + deleteTag?: (tag: string) => Promise; +} + + +const AddNewTag = ({onEnter} : {onEnter: (txt: string) => void}) => { + + const [openForm, setOpenForm] = useState(false); + const [value, setValue] = useState(''); + + const onConfirm = (e: any) => { + e.preventDefault(); + const txt = value.trim(); + const len = txt.length; + if(len > 3 && len < 50) { + onEnter(txt); + setValue(''); + setOpenForm(false); + }else{ + message.error("Tag cần có từ 3-40 ký tự") + } + } + + if(openForm) { + return ( + { + event.preventDefault(); + setValue(event.target.value); + }} + onBlur={onConfirm} + onPressEnter={onConfirm} + /> + ) + } + + return ( + setOpenForm(true)}> + Thêm mới + + ) +} + + +class EditableTagGroup extends React.Component { + + private editInputRef: React.RefObject; + + constructor(props: EditableTagGroupProps) { + super(props); + this.state = { + tags: [], + editInputIndex: -1, + editInputValue: '', + loadExistingList: false + } + + this.editInputRef = createRef(); + } + + + componentDidUpdate(prevProps: Readonly, prevState: Readonly, snapshot?: any) { + // loading existing the fist time + if(this.props.current_list && this.state.tags.length === 0 && !this.state.loadExistingList) { + this.setState({ + tags: this.props.current_list, + loadExistingList: true + }) + } + } + + + handleClose = (removedTag: string) => { + const tags = this.state.tags.filter(tag => tag !== removedTag); + this.setState({ tags }, () => { + const {deleteTag} = this.props; + if(typeof deleteTag == 'function') deleteTag(removedTag); + }); + } + + + handleInputConfirm = (inputValue: string) => { + let { tags } = this.state; + const {createTag} = this.props; + + if (inputValue && tags.indexOf(inputValue) === -1) { + + if(typeof createTag == 'function') { + createTag(inputValue).then(r => { + if(r) { + tags = [...tags, inputValue]; + this.setState({ + tags + } ); + } + }) + + } else { + tags = [...tags, inputValue]; + this.setState({ + tags + } ); + } + } + } + + handleEditInputChange = (e: any) => { + this.setState({ editInputValue: e.target.value }); + } + + handleEditInputConfirm = () => { + this.setState(({ tags, editInputIndex, editInputValue }) => { + const newTags = [...tags]; + newTags[editInputIndex] = editInputValue; + + return { + tags: newTags, + editInputIndex: -1, + editInputValue: '', + }; + }) + } + + // api to be used in other components to get tags + // const taggingRef = createRef(); + // const tags = taggingRef.current?.getTagList(); + getTagList = () => { + return this.state.tags; + } + + clearList = () => { + this.setState({ tags: [] }); + } + + render() { + + const { tags, editInputIndex, editInputValue } = this.state; + + return ( + + {tags.map((tag, index) => { + if (editInputIndex === index) { + return ( + + ); + } + + const isLongTag = tag.length > 20; + + const tagElem = ( + this.handleClose(tag)} + > + { + if (index !== 0) { + this.setState({ editInputIndex: index, editInputValue: tag }, () => { + this.editInputRef.current!.focus(); + }); + e.preventDefault(); + } + }} + > + {isLongTag ? `${tag.slice(0, 20)}...` : tag} + + + ); + + return isLongTag ? ( + + {tagElem} + + ) : ( + tagElem + ); + })} + + + + + ); + } +} + +export default EditableTagGroup; diff --git a/src/components/TextWithLineBreak/index.tsx b/src/components/TextWithLineBreak/index.tsx new file mode 100644 index 0000000..6292af8 --- /dev/null +++ b/src/components/TextWithLineBreak/index.tsx @@ -0,0 +1,14 @@ +import React from "react"; + +const TextWithLineBreak = ({txt}: {txt?: string}) => { + + if( !txt ) { + return null; + } + + return ( +
")}} /> + ) +} + +export default TextWithLineBreak; diff --git a/src/components/Toolbar/Toolbar.css b/src/components/Toolbar/Toolbar.css new file mode 100644 index 0000000..80e0b87 --- /dev/null +++ b/src/components/Toolbar/Toolbar.css @@ -0,0 +1,48 @@ +.toolbar { + display: flex; + align-items: center; + + background-color: white; + font-weight: 500; + border-bottom: 1px solid #eeeef1; + + position: sticky; + top: 0px; +} + +@supports (backdrop-filter: blur(20px)) { + .toolbar { + border: none; + background-color: rgba(255, 255, 255, 0.8); + backdrop-filter: blur(20px); + } +} + +.toolbar-title { + margin: 0; + font-size: 16px; + font-weight: 800; +} + +.left-items, .right-items { + flex: 1; + padding: 10px; + display: flex; +} + +.right-items { + flex-direction: row-reverse; +} + +.left-items .toolbar-button { + margin-right: 20px; +} + +.right-items .toolbar-button { + margin-left: 20px; +} + +.left-items .toolbar-button:last-child, +.right-items .toolbar-button:last-child { + margin: 0; +} \ No newline at end of file diff --git a/src/components/Toolbar/index.js b/src/components/Toolbar/index.js new file mode 100644 index 0000000..00b5a10 --- /dev/null +++ b/src/components/Toolbar/index.js @@ -0,0 +1,13 @@ +import React from 'react'; +import './Toolbar.css'; + +export default function Toolbar(props) { + const { title, leftItems, rightItems } = props; + return ( +
+
{ leftItems }
+

{ title }

+
{ rightItems }
+
+ ); +} \ No newline at end of file diff --git a/src/components/ToolbarButton/ToolbarButton.css b/src/components/ToolbarButton/ToolbarButton.css new file mode 100644 index 0000000..edeaa20 --- /dev/null +++ b/src/components/ToolbarButton/ToolbarButton.css @@ -0,0 +1,15 @@ +.toolbar-button { + color: #007aff; + font-size: 28px; + transition: all 0.1s; +} + +.toolbar-button:hover { + cursor: pointer; + color: #0063ce; +} + +.toolbar-button:active { + color: #007aff; + opacity: 0.25; +} \ No newline at end of file diff --git a/src/components/ToolbarButton/index.js b/src/components/ToolbarButton/index.js new file mode 100644 index 0000000..87b26ff --- /dev/null +++ b/src/components/ToolbarButton/index.js @@ -0,0 +1,9 @@ +import React from 'react'; +import './ToolbarButton.css'; + +export default function ToolbarButton(props) { + const { icon } = props; + return ( + + ); +} \ No newline at end of file diff --git a/src/components/Upload/ImageUploadWithPreview.tsx b/src/components/Upload/ImageUploadWithPreview.tsx new file mode 100644 index 0000000..3ab2ab0 --- /dev/null +++ b/src/components/Upload/ImageUploadWithPreview.tsx @@ -0,0 +1,145 @@ +import {Upload, Modal, UploadProps} from 'antd'; +import { PlusOutlined } from '@ant-design/icons'; +import {Component, ReactNode, Fragment} from "react"; +import {SERVER_FILE_UPLOAD} from "@/config"; +import {UploadFile} from "antd/lib/upload/interface"; + + +function getBase64(file: Blob) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.readAsDataURL(file); + reader.onload = () => resolve(reader.result); + reader.onerror = error => reject(error); + }); +} + +type ImageUploadType = { + uid: string, + name: string, + status: string, + url: string, +} + +type ImageUploadWithPreviewProps = { + auth: string; + current_list?: ImageUploadType[]; + uploadButton?: ReactNode; + onUploadFinish?: (status?: string, url?: string) => void; +} & UploadProps; + +type ImageUploadWithPreviewState = { + previewVisible: boolean, + previewImage: string, + previewTitle: string, + fileList: ImageUploadType[] +} + +const defaultUploadButton = ( +
+ +
Upload
+
+); + + +// copy from https://ant.design/components/upload/ PicturesWall +class ImageUploadWithPreview extends Component { + + constructor(props: ImageUploadWithPreviewProps) { + super(props); + this.state = { + previewVisible: false, + previewImage: '', + previewTitle: '', + fileList: props.current_list || [] + }; + } + + + handleCancel = () => this.setState({ previewVisible: false }); + + handlePreview = async (file: any) => { + if (!file.url && !file.preview) { + file.preview = await getBase64(file.originFileObj); + } + + this.setState({ + previewImage: file.url || file.preview, + previewVisible: true, + previewTitle: file.name || file.url.substring(file.url.lastIndexOf('/') + 1), + }); + } + + handleChange = ({ fileList }: {fileList: any[]}) => { + //console.log('handleChange') + //console.log(fileList); + + const { onUploadFinish } = this.props; + + this.setState({ fileList }, () => { + if(onUploadFinish) { + fileList.forEach((file: UploadFile) => { + onUploadFinish(file.status , file.name); + // if(file.status === 'success' || file.status === 'error') onUploadFinish(file.status , file.name); + }) + } + }); + + /*const { onUploadFinish } = this.props; + if(onUploadFinish) { + fileList.forEach((file: UploadFile) => { + if(file.status === 'success' || file.status === 'error') onUploadFinish(file.status , file.name); + }) + }*/ + } + + getFileList = () => { + return this.state.fileList; + } + + clearFileList = () => { + this.setState({ fileList: [] }) + } + + render() { + + const { previewVisible, previewImage, fileList, previewTitle } = this.state; + const {auth, current_list, uploadButton, onUploadFinish, ...others} = this.props; + const MAX_ALLOW_UPLOADED_FILE = 8; + + return ( + + + { fileList.length >= MAX_ALLOW_UPLOADED_FILE ? null : (uploadButton || defaultUploadButton)} + + + + example + + + ); + } +} + +export default ImageUploadWithPreview; diff --git a/src/components/Upload/UploadWithFileName.tsx b/src/components/Upload/UploadWithFileName.tsx new file mode 100644 index 0000000..63275b3 --- /dev/null +++ b/src/components/Upload/UploadWithFileName.tsx @@ -0,0 +1,53 @@ +import React from "react"; +import { Upload, Button } from 'antd'; +import { UploadOutlined, StarOutlined } from '@ant-design/icons'; + + +const UploadWithFileName = () => { + + const props = { + action: 'https://www.mocky.io/v2/5cc8019d300000980a055e76', + onChange({ file, fileList }: { file: any, fileList: any }) { + if (file.status !== 'uploading') { + console.log(file, fileList); + } + }, + defaultFileList: [ + { + uid: '1', + name: 'xxx.png', + status: 'done', + response: 'Server Error 500', // custom error message to show + url: 'http://www.baidu.com/xxx.png', + }, + { + uid: '2', + name: 'yyy.png', + status: 'done', + url: 'http://www.baidu.com/yyy.png', + }, + { + uid: '3', + name: 'zzz.png', + status: 'error', + response: 'Server Error 500', // custom error message to show + url: 'http://www.baidu.com/zzz.png', + }, + ], + showUploadList: { + showDownloadIcon: true, + downloadIcon: 'download ', + showRemoveIcon: true, + removeIcon: console.log(e, 'custom removeIcon event')} />, + }, + }; + + return ( + // @ts-ignore + + + + ) +} + +export default UploadWithFileName; diff --git a/src/components/Upload/index.tsx b/src/components/Upload/index.tsx new file mode 100644 index 0000000..d4caa25 --- /dev/null +++ b/src/components/Upload/index.tsx @@ -0,0 +1,2 @@ +export {default as ImageUploadWithPreview} from "./ImageUploadWithPreview"; +export {default as UploadWithFileName} from "./UploadWithFileName"; diff --git a/src/components/display/OrderStatus.tsx b/src/components/display/OrderStatus.tsx new file mode 100644 index 0000000..7ac1bca --- /dev/null +++ b/src/components/display/OrderStatus.tsx @@ -0,0 +1,22 @@ +import {Tag} from "antd"; +import React from "react"; +import {OrderStatusType} from "@/typings"; + + +const OrderStatus = ({status}: { status?: OrderStatusType }) => { + + const colorMapping = { + pending: 'warning', + fail: 'error', + success: 'success', + processing: 'processing' + } + + if(!status) return null; + + return ( + {status} + ) +} + +export default OrderStatus; diff --git a/src/components/display/PaymentStatus.tsx b/src/components/display/PaymentStatus.tsx new file mode 100644 index 0000000..716f47f --- /dev/null +++ b/src/components/display/PaymentStatus.tsx @@ -0,0 +1,22 @@ +import {Tag} from "antd"; +import React from "react"; +import {PaymentStatusType} from "@/typings"; + + +const PaymentStatus = ({status}: { status?: PaymentStatusType }) => { + + const colorMapping = { + pending: 'warning', + fail: 'error', + success: 'success', + processing: 'processing' + } + + if(!status) return null; + + return ( + {status} + ) +} + +export default PaymentStatus; diff --git a/src/components/display/ShippingStatus.tsx b/src/components/display/ShippingStatus.tsx new file mode 100644 index 0000000..e6470ef --- /dev/null +++ b/src/components/display/ShippingStatus.tsx @@ -0,0 +1,22 @@ +import {Tag} from "antd"; +import React from "react"; +import {ShippingStatusType} from "@/typings"; + + +const ShippingStatus = ({status}: { status?: ShippingStatusType }) => { + + const colorMapping = { + pending: 'warning', + fail: 'error', + success: 'success', + processing: 'processing' + } + + if(!status) return null; + + return ( + {status} + ) +} + +export default ShippingStatus; diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..b6b8fd7 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,22 @@ +// 19-05-2021: For dev + +export const IS_DEV = false; + +export const REDIRECTOR_URL = 'https://www.chatngay.com/redirect.php'; + +export const SERVER_API = '/api/'; + +export const SERVER_FILE_UPLOAD = 'https://chatngay-upload.glee.vn/upload_handle.php'; +export const SERVER_STATIC = 'https://chatngay-static.glee.vn/files'; +export const DEFAULT_AVATAR = 'https://randomuser.me/api/portraits/men/40.jpg'; +export const CHATNGAY_LOGO = 'https://www.chatngay.com/static/images/logo.png'; +export const MAX_ALLOW_IDLE_TIME = 5; // seconds (from last send action) to wait before close the ws connection + +export const getConnectNode = (node_id: string) => { + if(node_id === '') return ''; + return `https://${node_id}.chatngay.com:8080/chat` +}; + +export const SOCKET_OPTIONS = { + +} diff --git a/src/constant/gender.ts b/src/constant/gender.ts new file mode 100644 index 0000000..595b855 --- /dev/null +++ b/src/constant/gender.ts @@ -0,0 +1,7 @@ +const GENDER_LIST = [ + {"value":"u","text":"Chưa biết"}, + {"value":"f","text":"Nữ"}, + {"value":"m","text":"Nam"} +]; + +export default GENDER_LIST; diff --git a/src/constant/payment.ts b/src/constant/payment.ts new file mode 100644 index 0000000..dc16cee --- /dev/null +++ b/src/constant/payment.ts @@ -0,0 +1,9 @@ +const PAYMENT_METHODS = [ + {"value":"cod", "text": "COD - Thanh toán khi nhận hàng"}, + {"value":"wire", "text": "Chuyển khoản"}, + {"value":"paygate", "text": "Cổng thanh toán (Vnpay, Momo ...)"}, + {"value":"credit", "text": "Thẻ tín dụng (Visa, MasterCard ...)"}, + {"value":"cash", "text": "Tiền mặt"} +]; + +export default PAYMENT_METHODS; diff --git a/src/constant/province_list.ts b/src/constant/province_list.ts new file mode 100644 index 0000000..41c511a --- /dev/null +++ b/src/constant/province_list.ts @@ -0,0 +1,71 @@ +const PROVINCE_LIST = [ + // top provinces + {"value":"ha-noi","text":"Hà Nội"}, + {"value":"tp-hcm","text":"TP HCM"}, + {"value":"da-nang","text":"Đà Nẵng"}, + {"value":"hai-phong","text":"Hải Phòng"}, + {"value":"can-tho","text":"Cần Thơ"}, + + // others + {"value":"an-giang","text":"An Giang"}, + {"value":"ba-ria-vung-tau","text":"Bà Rịa - Vũng Tàu"}, + {"value":"bac-giang","text":"Bắc Giang"}, + {"value":"bac-kan","text":"Bắc Kạn"}, + {"value":"bac-lieu","text":"Bạc Liêu"}, + {"value":"bac-ninh","text":"Bắc Ninh"}, + {"value":"ben-tre","text":"Bến Tre"}, + {"value":"binh-dinh","text":"Bình Định"}, + {"value":"binh-duong","text":"Bình Dương"}, + {"value":"binh-phuoc","text":"Bình Phước"}, + {"value":"binh-thuan","text":"Bình Thuận"}, + {"value":"ca-mau","text":"Cà Mau"}, + {"value":"cao-bang","text":"Cao Bằng"}, + {"value":"dak-lak","text":"Đắk Lắk"}, + {"value":"dak-nong","text":"Đắk Nông"}, + {"value":"dien-bien","text":"Điện Biên"}, + {"value":"dong-nai","text":"Đồng Nai"}, + {"value":"dong-thap","text":"Đồng Tháp"}, + {"value":"gia-lai","text":"Gia Lai"}, + {"value":"ha-giang","text":"Hà Giang"}, + {"value":"ha-nam","text":"Hà Nam"}, + {"value":"ha-tinh","text":"Hà Tĩnh"}, + {"value":"hai-duong","text":"Hải Dương"}, + {"value":"hau-giang","text":"Hậu Giang"}, + {"value":"hoa-binh","text":"Hòa Bình"}, + {"value":"hung-yen","text":"Hưng Yên"}, + {"value":"khanh-hoa","text":"Khánh Hòa"}, + {"value":"kien-giang","text":"Kiên Giang"}, + {"value":"kon-tum","text":"Kon Tum"}, + {"value":"lai-chau","text":"Lai Châu"}, + {"value":"lam-dong","text":"Lâm Đồng"}, + {"value":"lang-son","text":"Lạng Sơn"}, + {"value":"lao-cai","text":"Lào Cai"}, + {"value":"long-an","text":"Long An"}, + {"value":"nam-dinh","text":"Nam Định"}, + {"value":"nghe-an","text":"Nghệ An"}, + {"value":"ninh-binh","text":"Ninh Bình"}, + {"value":"ninh-thuan","text":"Ninh Thuận"}, + {"value":"phu-tho","text":"Phú Thọ"}, + {"value":"quang-binh","text":"Quảng Bình"}, + {"value":"quang-nam","text":"Quảng Nam"}, + {"value":"quang-ngai","text":"Quảng Ngãi"}, + {"value":"quang-ninh","text":"Quảng Ninh"}, + {"value":"quang-tri","text":"Quảng Trị"}, + {"value":"soc-trang","text":"Sóc Trăng"}, + {"value":"son-la","text":"Sơn La"}, + {"value":"tay-ninh","text":"Tây Ninh"}, + {"value":"thai-binh","text":"Thái Bình"}, + {"value":"thai-nguyen","text":"Thái Nguyên"}, + {"value":"thanh-hoa","text":"Thanh Hóa"}, + {"value":"thua-thien-hue","text":"Thừa Thiên Huế"}, + {"value":"tien-giang","text":"Tiền Giang"}, + {"value":"tra-vinh","text":"Trà Vinh"}, + {"value":"tuyen-quang","text":"Tuyên Quang"}, + {"value":"vinh-long","text":"Vĩnh Long"}, + {"value":"vinh-phuc","text":"Vĩnh Phúc"}, + {"value":"yen-bai","text":"Yên Bái"}, + {"value":"phu-yen","text":"Phú Yên"} + +]; + +export default PROVINCE_LIST; diff --git a/src/constant/shipping.ts b/src/constant/shipping.ts new file mode 100644 index 0000000..168069b --- /dev/null +++ b/src/constant/shipping.ts @@ -0,0 +1,12 @@ +export const SHIP_PROVIDERS = [ + {"value":"in-house", "text": "Phòng giao hàng của công ty"}, + {"value":"pick-up", "text": "Khách đến lấy"}, + {"value":"ghtk", "text": "Giao hàng tiết kiệm"}, + {"value":"ghn", "text": "Giao hàng nhanh"}, + {"value":"viettel", "text": "Viettel Post"}, + {"value":"vietnampost", "text": "Vietnam Post"}, + {"value":"jtexpress", "text": "J&T Express"}, + {"value":"grab", "text": "Grab"}, + {"value":"ninja", "text": "Ninja Van"}, + {"value":"bestexpress", "text": "Best Express"}, +]; diff --git a/src/constant/text.ts b/src/constant/text.ts new file mode 100644 index 0000000..7f6954b --- /dev/null +++ b/src/constant/text.ts @@ -0,0 +1,11 @@ +export const NOTIFICATIONS = { + requesting_start: 'Đang chờ quản trị viên nhận chat', + requesting_success: '{{admin_name}} đã tiếp nhận chat', + requesting_fail: 'Quản trị viên đang bận', + typing: 'Đang soạn nội dung ...', + transferring: 'Đang chuyển người khác tiếp nhận chat', + user_offline: 'Khách hàng đã ngừng kết nối (offline)', + uploading: 'Đang upload file', + network_offline: 'Lỗi đường truyền. Vui lòng kiểm tra kết nối Internet của bạn', + network_error: 'Lỗi kết nối với máy chủ.', +} \ No newline at end of file diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..3d87554 --- /dev/null +++ b/src/index.css @@ -0,0 +1,12 @@ +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', + 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', + sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +code { + font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace; +} diff --git a/src/index.tsx b/src/index.tsx new file mode 100644 index 0000000..e2ee1a1 --- /dev/null +++ b/src/index.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import { Provider} from 'react-redux'; +import App from './App'; +import reportWebVitals from './reportWebVitals'; +import store from "./store"; +import {runTest} from "./test"; + +// local +import {getSettings, setUp} from "@/setup"; +import Error from '@/components/Error'; +import {isBrowserSupport} from "@/lib/utils"; + +// style +//import './index.css'; + +// export React when we need to use external component from window +// window.React = React; + + +(async () => { + const render_root = document.getElementById('root'); + + if ( ! isBrowserSupport() ) { + ReactDOM.render(, render_root); + return; + } + + const client_setting = await getSettings(); + if (! client_setting ) { + ReactDOM.render(, render_root); + return; + } + + await setUp(); + + const Root = () => ( + + + + ) + + ReactDOM.render(, render_root); + + runTest(); +})(); + + +// If you want to start measuring performance in your app, pass a function +// to log results (for example: reportWebVitals(console.log)) +// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals +reportWebVitals(); diff --git a/src/lib/api.ts b/src/lib/api.ts new file mode 100644 index 0000000..56347ee --- /dev/null +++ b/src/lib/api.ts @@ -0,0 +1,146 @@ +import axios, {AxiosResponse} from "axios"; + +import {SERVER_API} from "@/config"; +import {ChatboxTextMessage} from "@/typings/message.d"; +import {getAdminInfo} from "@/lib/user"; +import {APIResponse} from "@/typings/network"; +import {message} from "antd"; + +type APIResultType = {status: 'ok'|'error', data?: any, msg?: string}; + +const admin_info = getAdminInfo(); + +// reference: https://www.npmjs.com/package/axios#axios-api +const axios_instance = axios.create({ + baseURL: SERVER_API, + timeout: 10000, + headers: { + Authorization: admin_info.jwt, // admin_info.jwt contains client_id & admin_id + } +}) + + +function formatAxiosResponse(res: AxiosResponse): APIResultType { + if(res.status !== 200){ + return { + status: 'error', + msg: 'Server return status: '+ res.status, + } + } + + let api_response: APIResponse = res.data; + + if(api_response.errCode === 1) { + return { + status: 'error', + msg: api_response.msg, + } + } + + return { + status: 'ok', + data: api_response.data, + } +} + + +export function notifyApiResult (result: APIResultType, successMsg?: string) : void{ + if(result.status === 'ok') { + message.success(successMsg || 'Cập nhật thành công', 2) + }else { + message.error('Lỗi xảy ra: ' + result.msg, 20) + } +} + + +const get = async (endpoint: string, params?: object): Promise => { + try { + let res: AxiosResponse = await axios_instance.get(endpoint, { + params + }); + + return formatAxiosResponse(res); + }catch (e) { + return { + status: 'error', + msg: e.message, + } + } +} + +const post = async (endpoint: string, data?: object): Promise => { + try { + let res: AxiosResponse = await axios_instance.post(endpoint, data); + + return formatAxiosResponse(res); + }catch (e) { + return { + status: 'error', + msg: e.message, + } + } +} + +const put = async (endpoint: string, data: object, params?: object): Promise => { + try { + let res: AxiosResponse = await axios_instance.put(endpoint, data,{ + params + }); + + return formatAxiosResponse(res); + }catch (e) { + return { + status: 'error', + msg: e.message, + } + } +} + +const patch = async (endpoint: string, data: any, params?: object): Promise => { + try { + let res: AxiosResponse = await axios_instance.patch(endpoint, data, { + params: params + }); + + return formatAxiosResponse(res); + }catch (e) { + return { + status: 'error', + msg: e.message, + } + } +} + +const del = async (endpoint: string, params?: object): Promise => { + try { + let res: AxiosResponse = await axios_instance.delete(endpoint, { + params + }); + + return formatAxiosResponse(res); + }catch (e) { + return { + status: 'error', + msg: e.message, + } + } +} + +const api = { + get, post, patch, put, delete: del +} + +export default api; + + +export async function createSingleTag(payload: {tag: string, item_type?: string, item_id?: string|number}) : Promise { + let result = await api.post('tag/create', payload); + return result.status === 'ok'; +} + + +export async function getUserChatHistory(opts: {thread_id: string, last_fetch?: number}): Promise{ + let result = await get("chat/history", {tid: opts.thread_id, from: opts.last_fetch}); + // because old messages order from newest->oldest. we need them in reverse order: oldest->newsest + return (result.status === 'ok') ? result.data.list.reverse() : []; +} diff --git a/src/lib/chatngay.ts b/src/lib/chatngay.ts new file mode 100644 index 0000000..7f7dd68 --- /dev/null +++ b/src/lib/chatngay.ts @@ -0,0 +1,30 @@ +import { + MAX_ALLOW_IDLE_TIME, +} from '@/config'; +import {currentTimestamp} from "@/lib/utils"; +import {getUserLastActiveTime} from "@/lib/user"; +import * as networking from "@/lib/networking"; + + +async function _heartbeat() { + // console.log("_heartbeat: yup yup ..." + Date.now()); +} + + +// auto-disconnect ws connection to preserve servers' resource and convert to heartbeat +export async function selfDisconnect() { + let last_active_time = getUserLastActiveTime(); + let current_time = currentTimestamp(); + + if( current_time - last_active_time > MAX_ALLOW_IDLE_TIME ) { + networking.disconnect(); + await _heartbeat(); + } +} + + +export const chatngay = { + disconnect: networking.disconnect +} + + diff --git a/src/lib/emitter.ts b/src/lib/emitter.ts new file mode 100644 index 0000000..bf4b5d5 --- /dev/null +++ b/src/lib/emitter.ts @@ -0,0 +1,18 @@ +// copied straight from https://stackoverflow.com/questions/62827419/event-driven-approach-in-react +// reason: sometimes using Redux approach requires a number of code/boilplate + +import EventEmitter from 'eventemitter3'; +import {EventType} from "@/typings"; + +const eventEmitter = new EventEmitter(); + +const Emitter = { + on: (event: EventType, fn: (...args: any[]) => void) => eventEmitter.on(event, fn), + once: (event: EventType, fn: (...args: any[]) => void) => eventEmitter.once(event, fn), + off: (event: EventType, fn?: (...args: any[]) => void) => eventEmitter.off(event, fn), + emit: (event: EventType, payload?: (...args: any[]) => void) => eventEmitter.emit(event, payload) +} + +Object.freeze(Emitter); + +export default Emitter; diff --git a/src/lib/messaging.ts b/src/lib/messaging.ts new file mode 100644 index 0000000..245f624 --- /dev/null +++ b/src/lib/messaging.ts @@ -0,0 +1,38 @@ +// 19-05-2021: For dev + +import {ServerMessage, UserMessage} from "@/typings/message"; + + +export const sendTextMessageToServer = (to: string, text: string, local_sequence: number = 0) => { + let payload = { + type: 'text', + content: { + to, + text, + local_sequence + } + } as UserMessage; + + return sendMessageToServer(payload); +} + + +export const sendMessageToServer = (payload: UserMessage) => { + console.log('sendMessageToServer payload'); + console.log(payload); + // TODO: +} + +export function handleMessageFromServer(server_message: ServerMessage) { + + console.log('handleMessageFromServer'); + console.log(server_message); + + // TODO: + +} + +function _checkUserInChatOrRequest(user_id: string|number) : boolean { + // TODO: + return true; +} diff --git a/src/lib/networking.ts b/src/lib/networking.ts new file mode 100644 index 0000000..7d10516 --- /dev/null +++ b/src/lib/networking.ts @@ -0,0 +1,49 @@ +// 19-05-2021: For dev + +import {ServerMessage} from "@/typings/message.d"; +import {io, Socket} from "socket.io-client"; + +import {ConnectionToServerStatusType} from "@/typings/network"; + + +// single & private socket connection +let _connected_socket: Socket | null; + + +export function disconnect(destroy: boolean = false) { + if( isConnected() && _connected_socket) { + _connected_socket.close(); // close but can re-connect + if(destroy) _connected_socket = null; // if destroy, cannot re-connect + }else{ + console.info("Socket is not connected to be disconnected!"); + } +} + + +export const getSocket = (): Socket => { + return _connected_socket as Socket; +} + + +export const openSocketConnection = () : boolean => { + // TODO: + return true; +} + + +export const isConnected = (): boolean => { + // TODO: + return true; +} + + +// only create a ready socket, not connection to the remote server yet. Use openSocketConnection() to connect when time come +export function createSocket ( + endpoint_url: string, + opts: {jwt_token: string, [key:string]: any}, + handleServerMessage: (msg: ServerMessage) => void , + handleNodeConnectionStatus: (status: ConnectionToServerStatusType, message: string) => void +) { + // TODO: + handleNodeConnectionStatus('connect', "Connection succeeded!"); +} diff --git a/src/lib/notification.ts b/src/lib/notification.ts new file mode 100644 index 0000000..efcbb9a --- /dev/null +++ b/src/lib/notification.ts @@ -0,0 +1,127 @@ +import {SERVER_STATIC, CHATNGAY_LOGO} from "@/config"; +import {isMobile} from "@/lib/utils"; + +const isDeviceMobile = isMobile(); +const MAX_SCROLL_TIME = 20; +const SCROLLING_TITLE = "Bạn có tin nhắn mới "; + +let _settings = { + //sound alert + sound_enable : true,//default for all app + sound_file : SERVER_STATIC + "/ring_once.ogg", + + //scrolling + scroll_page_title_alert : "Bạn có tin nhắn mới ", + old_web_page_title : '', + is_scrolling : false, +} + +type SettingKeyType = keyof typeof _settings; + + +//NOTE: The Notification permission may only be requested in a secure context. +export async function askForBrowserNotificationPermit() { + + // Let's check if the browser supports notifications + if (!("Notification" in window)) { + console.log("This browser does not support desktop notification"); + return; + } + + // Otherwise, we need to ask the user for permission + if (Notification.permission !== "denied" && Notification.permission !== "granted") { + try { + let result = await Notification.requestPermission(); + console.log("requestPermission =" + result); + + } catch (error) { + // Safari doesn't return a promise for requestPermissions and it + // throws a TypeError. It takes a callback as the first argument + if (error instanceof TypeError) { + console.log("requestPermission =" + error); + } else { + throw error; + } + } + } + +} + + +export function showBrowserNotification(title: string, content?: string) { + if (Notification.permission === "granted") { + let expire_in_second = (arguments[2]) ? parseInt(arguments[2]) : 10; + let options = { + body: content, + icon: CHATNGAY_LOGO + }; + let n = new Notification(title, options); + console.log("Notify: " + content); + // auto close after x seconds + setTimeout(n.close.bind(n), expire_in_second * 1000); + } +} + + +export const changeSettings = (new_settings: {[key in SettingKeyType]: any}) => { + _settings = {..._settings, ...new_settings}; +} + + +export const alertNewMessages = () => { + playSound(); + showBrowserNotification("Bạn có tin nhắn mới"); + scrollPageTitleStart(); +} + + +/** + * @description: play sound if enable, this code supports html5 only + * Play method will be blocked by browser: https://stackoverflow.com/questions/57504122/browser-denying-javascript-play + */ +function playSound() { + + if(!_settings.sound_enable) { + return; + } + + if (typeof Audio == 'function') { + let audio = new Audio(_settings.sound_file); + audio.play(); + } +} + + +/** + * @author: Hieu + * @description: alert new message by scrolling page title + */ +function scrollPageTitleStart() { + + //stop scroll if on mobile + //or user is on the page but the scrolling has not started. If has started, it should continue + if(isDeviceMobile || _settings.is_scrolling ) { + return ; + } + + _settings.is_scrolling = true; + _settings.old_web_page_title = document.title; + + let scroll_timeout_id; + let track_scroll_count: number = 0; + + (function titleScroller(text) { + document.title = text; + track_scroll_count += 1; + if(track_scroll_count > MAX_SCROLL_TIME) { + clearTimeout(scroll_timeout_id); + _settings.is_scrolling = false; + document.title = _settings.old_web_page_title; + return; + } + + scroll_timeout_id = setTimeout(function () { + titleScroller(text.substr(1) + text.substr(0, 1)); + }, 500); + }(SCROLLING_TITLE)); +} diff --git a/src/lib/personalize.ts b/src/lib/personalize.ts new file mode 100644 index 0000000..de94035 --- /dev/null +++ b/src/lib/personalize.ts @@ -0,0 +1,79 @@ +let _settings = { + //chat notification option + notify : { + sound_enable : true //default + }, +}; + + +export function getSettings() { + return _settings; +} + + +/** + * @date: 22-02-2016 + * @author: Hieu + * @description: set sound on or off + */ +export function setSound () { + + let $sound_on_off: string = ''; + + if(_settings.notify.sound_enable) { + //turn off + _settings.notify.sound_enable = false; + //saveLocalData('chatngay_sound', 'off'); + $sound_on_off = 'Bật âm thanh'; + + }else{ + //turn on + _settings.notify.sound_enable = true; + //deleteLocalData('chatngay_sound'); + $sound_on_off = 'Tắt âm thanh'; + } + + return $sound_on_off; + + // getIframeElement("chatngay-sound-txt").innerHTML = $sound_on_off; + +} + +/** + * @date: 22-02-2016 + * @author: Hieu + * @description: build user_info when page loaded + */ +export function getUserInfo () { + + /*//get saved name if provided previously + user_info.id = getLocalData('chatngay_uid'); + user_info.name = getLocalData('chatngay_uname'); + user_info.token = getLocalData('chatngay_utoken'); + + //check if user has disabled sound before and update when page reloads + if(getLocalData('chatngay_sound') == 'off') { + _settings.notify.sound_enable = false; + }*/ +} + +/** + * @date: 28-02-2016 + * @author: Hieu + * @description: build user_info when page loaded + */ +export function saveUserInfo (server_response: any) { + + /*user_info.id = server_response.id; + saveLocalData('chatngay_uid', server_response.id, 300); + + if(server_response.token != '') { + user_info.token = server_response.token; + saveLocalData('chatngay_utoken', server_response.token, 300); + } + + if(server_response.name != '') { + user_info.name = server_response.name; + saveLocalData('chatngay_uname', server_response.name, 300); + }*/ +} diff --git a/src/lib/public_ip.ts b/src/lib/public_ip.ts new file mode 100644 index 0000000..7e0ae4f --- /dev/null +++ b/src/lib/public_ip.ts @@ -0,0 +1,96 @@ +// https://github.com/sindresorhus/public-ip/blob/master/browser.js + +class CancelError extends Error { + constructor() { + super('Request was cancelled'); + this.name = 'CancelError'; + } + + get isCanceled() { + return true; + } +} + +const defaults = { + timeout: 5000 +}; + +const urls = { + v4: [ + 'https://ipv4.icanhazip.com/', + 'https://api.ipify.org/' + ], + v6: [ + 'https://ipv6.icanhazip.com/', + 'https://api6.ipify.org/' + ] +}; + +const sendXhr = (url: string, options: { timeout: number; }, version: string | number) => { + const xhr = new XMLHttpRequest(); + + let _reject: { (arg0: CancelError): void; (reason?: any): void; }; + const promise = new Promise((resolve, reject) => { + _reject = reject; + xhr.addEventListener('error', reject, {once: true}); + xhr.addEventListener('timeout', reject, {once: true}); + + xhr.addEventListener('load', () => { + const ip = xhr.responseText.trim(); + + if (!ip) { + reject(); + return; + } + + resolve(ip); + }, {once: true}); + + xhr.open('GET', url); + xhr.timeout = options.timeout; + xhr.send(); + }); + + // @ts-ignore + promise.cancel = () => { + xhr.abort(); + _reject(new CancelError()); + }; + + return promise; +}; + +const queryHttps = (version: string, options: any) => { + let request: any; + const promise = (async function () { + // @ts-ignore + const urls_ = [].concat.apply(urls[version], options.fallbackUrls || []); + for (const url of urls_) { + try { + request = sendXhr(url, options, version); + // eslint-disable-next-line no-await-in-loop + return await request; + } catch (error) { + if (error instanceof CancelError) { + throw error; + } + } + } + + throw new Error('Couldn\'t find your IP'); + })(); + + // @ts-ignore + promise.cancel = () => { + request.cancel(); + }; + + return promise; +}; + +const public_ip = { + v4: (options: any) => queryHttps('v4', {...defaults, ...options}), + v6: (options: any) => queryHttps('v6', {...defaults, ...options}), +}; + +export default public_ip; diff --git a/src/lib/registry.ts b/src/lib/registry.ts new file mode 100644 index 0000000..f3997b5 --- /dev/null +++ b/src/lib/registry.ts @@ -0,0 +1,31 @@ +// simple global objects +let _registry: {[k: string]: any} = {}; + +function update(key: string, value: any) { + _registry[key] = value; +} + +function get(key: string): any { + return (_registry.hasOwnProperty(key)) ? _registry[key] : undefined; +} + +function remove(key: string) { + if(!_registry.hasOwnProperty(key)) return ; + + delete _registry[key]; +} + +function clear() { + for (let member in _registry) { + delete _registry[member]; + } +} + +const registry = { + update, + get, + remove, + clear +} + +export default registry; \ No newline at end of file diff --git a/src/lib/schedule.ts b/src/lib/schedule.ts new file mode 100644 index 0000000..97d0e40 --- /dev/null +++ b/src/lib/schedule.ts @@ -0,0 +1,57 @@ +class SimpleSchedule { + + private task_is_running: boolean; + private taskCaller: () => Promise; + private trackInterval: NodeJS.Timeout | null; + private checkInterval: number; //default + + constructor(taskFn: () => Promise, checkInterval: number = 5) { + this.taskCaller = taskFn; + this.task_is_running = false; + this.trackInterval = null; + this.checkInterval = checkInterval; + } + + start = () => { + console.log(`Schedule ${this.taskCaller.name} started at ${this.timeNow()}`); + + this.trackInterval = setInterval(async () => { + // flag for long-running task so another instance wont start + if(this.task_is_running) { + console.log(`Task ${this.taskCaller.name} is still running. Check time ${this.timeNow()}`); + return; + } + + this.task_is_running = true; + console.log(`OK invoke ${this.taskCaller.name} at ${this.timeNow()}`); + await this.taskCaller(); + this.task_is_running = false; + }, this.checkInterval * 1000 ); + } + + getInterval = (): number => this.checkInterval; + + changeInterval = (new_interval: number) => { + if(new_interval === this.checkInterval) { + return; // nothing change! + } + + // remove running + if(this.trackInterval) clearInterval(this.trackInterval); + + // set and start + this.checkInterval = new_interval; + this.start(); + } + + stop = () => { + console.log(`Schedule ${this.taskCaller.name} stoped at ${this.timeNow()}`); + if(this.trackInterval) clearInterval(this.trackInterval); + } + + timeNow = () => { + return Math.floor(Date.now() / 1000); + } +} + +export default SimpleSchedule; diff --git a/src/lib/security.ts b/src/lib/security.ts new file mode 100644 index 0000000..3405de5 --- /dev/null +++ b/src/lib/security.ts @@ -0,0 +1,72 @@ + +export function createChecksum(content: string|object): number { + let content_str = typeof content == 'string' ? content : JSON.stringify(content); + return crc32(content_str); +} + + +// copy from: https://github.com/wbond/crc32-js-php +// javascript: crc32(txt) +// php backend: sprintf('%u', crc32(txt)) +function crc32(txt: string) : number { + let table = [ + 0x00000000, 0x77073096, 0xEE0E612C, 0x990951BA, 0x076DC419, 0x706AF48F, + 0xE963A535, 0x9E6495A3, 0x0EDB8832, 0x79DCB8A4, 0xE0D5E91E, 0x97D2D988, + 0x09B64C2B, 0x7EB17CBD, 0xE7B82D07, 0x90BF1D91, 0x1DB71064, 0x6AB020F2, + 0xF3B97148, 0x84BE41DE, 0x1ADAD47D, 0x6DDDE4EB, 0xF4D4B551, 0x83D385C7, + 0x136C9856, 0x646BA8C0, 0xFD62F97A, 0x8A65C9EC, 0x14015C4F, 0x63066CD9, + 0xFA0F3D63, 0x8D080DF5, 0x3B6E20C8, 0x4C69105E, 0xD56041E4, 0xA2677172, + 0x3C03E4D1, 0x4B04D447, 0xD20D85FD, 0xA50AB56B, 0x35B5A8FA, 0x42B2986C, + 0xDBBBC9D6, 0xACBCF940, 0x32D86CE3, 0x45DF5C75, 0xDCD60DCF, 0xABD13D59, + 0x26D930AC, 0x51DE003A, 0xC8D75180, 0xBFD06116, 0x21B4F4B5, 0x56B3C423, + 0xCFBA9599, 0xB8BDA50F, 0x2802B89E, 0x5F058808, 0xC60CD9B2, 0xB10BE924, + 0x2F6F7C87, 0x58684C11, 0xC1611DAB, 0xB6662D3D, 0x76DC4190, 0x01DB7106, + 0x98D220BC, 0xEFD5102A, 0x71B18589, 0x06B6B51F, 0x9FBFE4A5, 0xE8B8D433, + 0x7807C9A2, 0x0F00F934, 0x9609A88E, 0xE10E9818, 0x7F6A0DBB, 0x086D3D2D, + 0x91646C97, 0xE6635C01, 0x6B6B51F4, 0x1C6C6162, 0x856530D8, 0xF262004E, + 0x6C0695ED, 0x1B01A57B, 0x8208F4C1, 0xF50FC457, 0x65B0D9C6, 0x12B7E950, + 0x8BBEB8EA, 0xFCB9887C, 0x62DD1DDF, 0x15DA2D49, 0x8CD37CF3, 0xFBD44C65, + 0x4DB26158, 0x3AB551CE, 0xA3BC0074, 0xD4BB30E2, 0x4ADFA541, 0x3DD895D7, + 0xA4D1C46D, 0xD3D6F4FB, 0x4369E96A, 0x346ED9FC, 0xAD678846, 0xDA60B8D0, + 0x44042D73, 0x33031DE5, 0xAA0A4C5F, 0xDD0D7CC9, 0x5005713C, 0x270241AA, + 0xBE0B1010, 0xC90C2086, 0x5768B525, 0x206F85B3, 0xB966D409, 0xCE61E49F, + 0x5EDEF90E, 0x29D9C998, 0xB0D09822, 0xC7D7A8B4, 0x59B33D17, 0x2EB40D81, + 0xB7BD5C3B, 0xC0BA6CAD, 0xEDB88320, 0x9ABFB3B6, 0x03B6E20C, 0x74B1D29A, + 0xEAD54739, 0x9DD277AF, 0x04DB2615, 0x73DC1683, 0xE3630B12, 0x94643B84, + 0x0D6D6A3E, 0x7A6A5AA8, 0xE40ECF0B, 0x9309FF9D, 0x0A00AE27, 0x7D079EB1, + 0xF00F9344, 0x8708A3D2, 0x1E01F268, 0x6906C2FE, 0xF762575D, 0x806567CB, + 0x196C3671, 0x6E6B06E7, 0xFED41B76, 0x89D32BE0, 0x10DA7A5A, 0x67DD4ACC, + 0xF9B9DF6F, 0x8EBEEFF9, 0x17B7BE43, 0x60B08ED5, 0xD6D6A3E8, 0xA1D1937E, + 0x38D8C2C4, 0x4FDFF252, 0xD1BB67F1, 0xA6BC5767, 0x3FB506DD, 0x48B2364B, + 0xD80D2BDA, 0xAF0A1B4C, 0x36034AF6, 0x41047A60, 0xDF60EFC3, 0xA867DF55, + 0x316E8EEF, 0x4669BE79, 0xCB61B38C, 0xBC66831A, 0x256FD2A0, 0x5268E236, + 0xCC0C7795, 0xBB0B4703, 0x220216B9, 0x5505262F, 0xC5BA3BBE, 0xB2BD0B28, + 0x2BB45A92, 0x5CB36A04, 0xC2D7FFA7, 0xB5D0CF31, 0x2CD99E8B, 0x5BDEAE1D, + 0x9B64C2B0, 0xEC63F226, 0x756AA39C, 0x026D930A, 0x9C0906A9, 0xEB0E363F, + 0x72076785, 0x05005713, 0x95BF4A82, 0xE2B87A14, 0x7BB12BAE, 0x0CB61B38, + 0x92D28E9B, 0xE5D5BE0D, 0x7CDCEFB7, 0x0BDBDF21, 0x86D3D2D4, 0xF1D4E242, + 0x68DDB3F8, 0x1FDA836E, 0x81BE16CD, 0xF6B9265B, 0x6FB077E1, 0x18B74777, + 0x88085AE6, 0xFF0F6A70, 0x66063BCA, 0x11010B5C, 0x8F659EFF, 0xF862AE69, + 0x616BFFD3, 0x166CCF45, 0xA00AE278, 0xD70DD2EE, 0x4E048354, 0x3903B3C2, + 0xA7672661, 0xD06016F7, 0x4969474D, 0x3E6E77DB, 0xAED16A4A, 0xD9D65ADC, + 0x40DF0B66, 0x37D83BF0, 0xA9BCAE53, 0xDEBB9EC5, 0x47B2CF7F, 0x30B5FFE9, + 0xBDBDF21C, 0xCABAC28A, 0x53B39330, 0x24B4A3A6, 0xBAD03605, 0xCDD70693, + 0x54DE5729, 0x23D967BF, 0xB3667A2E, 0xC4614AB8, 0x5D681B02, 0x2A6F2B94, + 0xB40BBE37, 0xC30C8EA1, 0x5A05DF1B, 0x2D02EF8D + ]; + + // This converts a unicode string to UTF-8 bytes + txt = unescape(encodeURI(txt)); + let crc = 0 ^ (-1); + let len = txt.length; + for (let i=0; i < len; i++) { + crc = (crc >>> 8) ^ table[(crc ^ txt.charCodeAt(i)) & 0xFF]; + } + crc = crc ^ (-1); + // Turns the signed integer into an unsigned integer + if (crc < 0) { + crc += 4294967296; + } + + return crc; +} diff --git a/src/lib/storage.ts b/src/lib/storage.ts new file mode 100644 index 0000000..5d18e92 --- /dev/null +++ b/src/lib/storage.ts @@ -0,0 +1,30 @@ +// read: https://web.dev/storage-for-the-web/ +// https://www.npmjs.com/package/store2 for localStorage +// https://www.npmjs.com/package/idb-keyval for IndexedDB + +//import store from "store2"; +import {createStore, set, get, clear, del, UseStore} from 'idb-keyval'; + +const customStore: UseStore = createStore('chatngay', 'chatboard'); + +const storage = { + async clear() { + return await clear(customStore); + }, + async save(key?: string, data?: any) { + return (key) ? await set(key, data, customStore) : false; + }, + async get(key?: string) { + return (key) ? await get(key, customStore) : null; + }, + async delete(key?: string) { + return (key) ? await del(key, customStore) : false; + } +} + +export default storage; + + +export const userChatHistoryStorageKey = (user_id?: string|number) => { + return user_id ? `chat-history-${user_id}` : undefined; +} diff --git a/src/lib/theme.ts b/src/lib/theme.ts new file mode 100644 index 0000000..a92791e --- /dev/null +++ b/src/lib/theme.ts @@ -0,0 +1,19 @@ +import {SERVER_STATIC} from "@/config"; + +const CSS_FILES: {[key: string]: string} = { + set1: SERVER_STATIC + "/style_1.css", + set2: SERVER_STATIC + "/style_2.css", + set3: SERVER_STATIC + "/style_3.css" +} + +/** + * @author: Hieu + * @description: get current theme css file + */ +export function getCSSFile(id: number){ + if(!CSS_FILES.hasOwnProperty('set'+id)) { + return CSS_FILES.set3; //default + } + + return CSS_FILES['set'+id];// + '?t=' + getCurrentTimestamp(); +} diff --git a/src/lib/upload.ts b/src/lib/upload.ts new file mode 100644 index 0000000..6e9971a --- /dev/null +++ b/src/lib/upload.ts @@ -0,0 +1,4 @@ +// 19-05-2021: For dev +export const uploadFile = (file: Blob) => { + // TODO: +} \ No newline at end of file diff --git a/src/lib/user.ts b/src/lib/user.ts new file mode 100644 index 0000000..91a684c --- /dev/null +++ b/src/lib/user.ts @@ -0,0 +1,67 @@ +import {Dispatch} from "redux"; + +import {AdminInfo} from "@/typings/user"; +import {actions} from "@/store/actions"; +import {getConnectNode} from "@/config"; + +let _user_last_active_time: number = 0; + + +/*declare global { + interface Window { + admin_info: AdminInfo + } +}*/ + +const getAdminInfo = () : AdminInfo => { + //if(MODE === 'dev') return x as AdminInfo; + return window.admin_info || { client_id:'', id: '', name:'', jwt: '', group_id: '', node: '' }; +} + + +const getUserSocketConnectionProperty = () : { endpoint: string, token: string } => { + const admin_info = getAdminInfo(); + + return { + endpoint: admin_info.node ? getConnectNode(admin_info.node) : '', + token: admin_info.jwt || '', + } +} + + +const getUserLastActiveTime = (): number => { + return _user_last_active_time; +} + +const setUserLastActiveTime = (time: number) => { + _user_last_active_time = time; +} + + +// track users change the browser tab +// https://developer.mozilla.org/en-US/docs/Web/API/Document/visibilitychange_event +export function trackVisibilityChange(dispatch: Dispatch) { + document.addEventListener("visibilitychange", function() { + //console.log( 'document.visibilityState = ' + document.visibilityState ); + dispatch(actions.changeUserVisibilityState(document.visibilityState)); + }); + + // For safari: Safari doesn’t fire visibilitychange as expected when the value of the visibilityState property transitions to hidden; so for that case, you need to also include code to listen for the pagehide event. + // console.log( 'navigator.userAgent = ' + navigator.userAgent ); + if(navigator.userAgent.indexOf("Safari") !== -1) { + //console.log("Yes it;s Safari!"); + window.addEventListener("pagehide", event => { + if (event.persisted) { + /* the page isn't being discarded, so it can be reused later */ + } + }, false); + } +} + + +export { + getAdminInfo, + getUserSocketConnectionProperty, + setUserLastActiveTime, + getUserLastActiveTime +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 0000000..9d05b73 --- /dev/null +++ b/src/lib/utils.ts @@ -0,0 +1,311 @@ +import {REDIRECTOR_URL} from "@/config"; +import publicIp from "@/lib/public_ip"; +//import memoizeOne from 'memoize-one'; +import api from "@/lib/api"; + + +//format a number for readability +//1000 => 1.000 +//-1000 => -1.000 +export function formatNumber (num?: number) : string{ + if(!num || num === 0) return '0'; + + const is_negative_number = (num < 0); + + let str = (is_negative_number) ? (num * -1) + '' : num + ''; //convert to string + let char_count = str.length; + if(char_count <= 3) { + return (is_negative_number) ? '-' + str : str; + } + + let first_part = str.substr(0, char_count % 3); // num = 10000 => this part = 10 + let remain_part = str.replace(first_part, ""); + let num_group = Math.round(remain_part.length/3); + + let parts = []; + if(first_part !== '') parts.push( first_part ); // num = 10000 => this part = 10 + + for (let i = 0; i < num_group; i++){ + parts.push( remain_part.substr( i*3, 3)); + } + + return (is_negative_number) ? '-' + parts.join('.') : parts.join('.'); +} + + +export function isBrowserSupport(): boolean { + // check support for indexedDB to store various async data + if (!window.indexedDB) return false; + + // check localstorage support for redux store persist + if ( ! _localStorageAvailable()) return false; + + // other + // ... + + return true; + + + // helpers + + // shamelessly copied from https://developer.mozilla.org/en-US/docs/Web/API/Web_Storage_API/Using_the_Web_Storage_API + function _localStorageAvailable() { + let storage; + try { + // @ts-ignore + storage = window['localStorage']; + let x = '__storage_test__'; + storage.setItem(x, x); + storage.removeItem(x); + return true; + } + catch(e) { + return e instanceof DOMException && ( + // everything except Firefox + e.code === 22 || + // Firefox + e.code === 1014 || + // test name field too, because code might not be present + // everything except Firefox + e.name === 'QuotaExceededError' || + // Firefox + e.name === 'NS_ERROR_DOM_QUOTA_REACHED') && + // acknowledge QuotaExceededError only if there's something already stored + (storage && storage.length !== 0); + } + } +} + + +// runUpdateAdminStatus periodically +export const runUpdateAdminStatus = (admin_id: string, interval: number = 10) => { + + _run(); + + // then check periodically + setTimeout(function () { + runUpdateAdminStatus(admin_id, interval); + }, interval * 1000); + + function _run(){ + api.post("admin/update-status", {admin_id: admin_id, connected: true}) ; + } +} + +// check user's internet connection periodically +export const runCheckNetworkConnection = (interval: number = 10, cb: (isOnline: boolean) => void ) => { + // check onload + _runCheck(); + + // then check periodically + setTimeout(function (){ + runCheckNetworkConnection(interval, cb); + }, interval * 1000); + + function _runCheck(){ + checkUserInternetConnection().then(cb); + } +} + + +export function confirmLeavePage() { + window.addEventListener("beforeunload", function (e) { + let confirmationMessage = "Thay đổi trang sẽ mất dữ liệu hiện tại"; + e.returnValue = confirmationMessage; // Gecko, Trident, Chrome 34+ + return confirmationMessage; // Gecko, WebKit, Chrome <34 + }); +} + +export const showUnixTime = (timestamp: number|undefined) => { + //(t) ? dayjs.unix(t).format('DD-MM-YYYY h:mma') : ''; + if(!timestamp) return ''; + + let a = new Date(timestamp * 1000); + let months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; + let year = a.getFullYear(); + let month = months[a.getMonth()]; + let date = a.getDate(); + let hour = a.getHours(); + let min = a.getMinutes(); + //let sec = a.getSeconds(); + return [date, month, year + " " + hour + ':' + min].join("-") ; +} + + +export function getRandomInt(min: number, max: number) : number { + let floor_min = Math.ceil(min); + let floor_max = Math.floor(max); + return Math.floor(Math.random() * (floor_max - floor_min + 1)) + floor_min; +} + +// check whether user's connection is ok +// ref: https://github.com/sindresorhus/is-online +// https://www.npmjs.com/package/public-ip +export async function checkUserInternetConnection(){ + let options = { + timeout: 5000, + ipVersion: 4, + }; + + if (navigator && !navigator.onLine) { + return false; + } + + const publicIpFunctionName = options.ipVersion === 4 ? 'v4' : 'v6'; + + try { + return await publicIp[publicIpFunctionName](options); + } catch (_) { + return false; + } +} + + +// get current timestamp in UTC +export function getCurrentUTCTimestamp(microtime: boolean = false) : number { + let x = new Date(); + let micro_time = x.getTime() + x.getTimezoneOffset() * 60 * 1000; + return (microtime) ? micro_time : Math.floor(micro_time / 1000); +} + + +export function maskExternalUrl(url: string) { + return `${REDIRECTOR_URL}?url=${encodeURIComponent(url)}`; +} + + +// given an array, keep max_size latest items and discard other. return new array +export function keepMaxArraySize(arr: any[], max_size: number) { + let arr_size = arr.length; + + // no change + if(arr_size <= max_size) return arr; + + let copied_arr = [...arr]; + copied_arr.splice(0, copied_arr.length - max_size); + + return copied_arr; +} + + + +export function findObjectInArray(arr: { [key: string]: any }[], key: string, value: any) : {index: number, item: object} { + let item: object = {}; + let match_index = -1; + for ( let index = 0; index < arr.length; index ++) { + if(arr[index][key] === value) { + item = {...arr[index]}; + match_index = index; + break; + } + } + + return { + index: match_index, + item: item + }; +} + + +export function createUserId(): string { + return Math.random().toString(36).slice(2); +} + +export function currentTimestamp() : number { + return Date.now() / 1000; +} + +/** + * @date 22-02-2016 + * @author Hieu + * @description: replace console.log() + * @usage example + console(obj) + */ +export function log(obj: any) { + console.log(obj); +} + +/** + * @date 26-02-2016 + * @author Hieu + * @description: count number of items in object + * @usage example + */ +export function objectSize( content: object ) { + let length = 0; + for( let key in content ) { + if( content.hasOwnProperty(key) ) { + length ++; + } + } + return length; +} + + +/** + * @date 09-03-2016 + * @author http://stackoverflow.com/questions/1500260/detect-urls-in-text-with-javascript + * @description: find url in text and replace with clickable a + * @usage + */ +export function formatUrl(text: string) { + let urlRegex = /(https?:\/\/[^\s]+)/g; + return text.replace(urlRegex, '$1') +} + + +/** + * @date 03-03-2016 + * @author http://stackoverflow.com/questions/4959975/generate-random-value-between-two-numbers-in-javascript + * @description: get a random number between min-max + * @usage example + */ +export function randomBetween(min: number, max: number) { + return Math.floor( Math.random() * ( max - min + 1) + min); +} + + +/** + * @date 21-02-2016 + * @author http://youmightnotneedjquery.com/ + * @description: trim a string, support IE8+ + * @param str + * @return string + * @usage example + trim(str); + */ +export function trim(str: string){ + if (!String.prototype.trim) { + //in case of IE 8 or lower + return str.replace(/^\s+|\s+$/g, '') ; + }else{ + return str.trim(); + } +} + + +/** + * @date 22-02-2016 + * @author Hieu + * @description: shorten a string by char count + * @usage example + subStr(str, char_count) + */ +export function subStr (str: string | undefined, char_count: number = 30): string { + if(!str) return ''; + let padding = ' ...'; + let result = ''; + let cut = str.indexOf(' ', char_count); + if(cut === -1) result = str; + else result = str.substring(0, cut); + return (result.length <= char_count) ? result : result.substring(0, char_count) + padding; +} + + +/** + * https://coderwall.com/p/i817wa/one-line-function-to-detect-mobile-devices-with-javascript + */ +export function isMobile(){ + return (typeof window.orientation !== "undefined") || (navigator.userAgent.indexOf('IEMobile') !== -1); +} diff --git a/src/lib/validation.ts b/src/lib/validation.ts new file mode 100644 index 0000000..2a6e2ee --- /dev/null +++ b/src/lib/validation.ts @@ -0,0 +1,48 @@ +export function validURL(str: string) : boolean { + let pattern = new RegExp('^(https?:\\/\\/)?'+ // protocol + '((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|'+ // domain name + '((\\d{1,3}\\.){3}\\d{1,3}))'+ // OR ip (v4) address + '(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*'+ // port and path + '(\\?[;&a-z\\d%_.~+=-]*)?'+ // query string + '(\\#[-a-z\\d_]*)?$','i'); // fragment locator + + return pattern.test(str.trim()); +} + + +export function isUrlImage(str: string) : boolean { + if(!validURL(str)) return false; + + let text_ext = str.substr(str.lastIndexOf(".")); + let acceptable_exts = ['.jpg', '.jpeg', '.png', '.gif']; + + return (text_ext !== '' && acceptable_exts.includes(text_ext.toLowerCase())); +} + + +// accept tel or mobile number in different format: 0912.123.123 +// we remove all non-number and validate the length +// need to validate prefix as well but it seems unnecessary because we dont know for sure if the phone is actually true even if all the formats pass the test +export function validatePhone(txt: string) : boolean { + let all_numbers = txt.replace(/[^0-9]/g, '') + ''; + + return validateLength(all_numbers, 8, 14); +} + + +export function validateLength(txt: string, min_length: number=1, max_length: number = 1000) : boolean { + let txt_length = txt.trim().length; + return (max_length > txt_length && txt_length >= min_length ); +} + + +/** + * @author Hieu + * @description: validate an email address + * ref: http://stackoverflow.com/questions/46155/validate-email-address-in-javascript + * @usage example + */ +export function validateEmail(email: string) : boolean { + let re = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; + return re.test(email); +} diff --git a/src/lib/vietnamese.ts b/src/lib/vietnamese.ts new file mode 100644 index 0000000..8463051 --- /dev/null +++ b/src/lib/vietnamese.ts @@ -0,0 +1,257 @@ +//enhanced function to isStringFound +//which search for vietnamese and non-vietnamese in main_str +//does not care about cases as well +export function isFound(sub_str: string, main_str: string) { + const sub_str_unique = unvietnamese(sub_str).toLowerCase(); + const main_str_unique = getUniqueWords(unvietnamese(main_str) +" " + chuyenKhongdau(main_str)).toLowerCase(); + + return isStringFound(sub_str_unique, main_str_unique); +} + + +function isStringFound(sub_str: string, main_str: string) : boolean{ + const test_sub_str = sub_str.trim(); + + //empty str should fail + if( test_sub_str.length === 0) return false; + + //start + const sub_str_parts = test_sub_str.split(" "); + let is_all_parts_found = true; + let test_part; + for (let i = 0, total_part = sub_str_parts.length; i < total_part ; i++) { + test_part = sub_str_parts[i].trim(); + //test if part in the main_str, if not then we dont need further test + if(test_part.length > 0 && main_str.indexOf(test_part) === -1 ) { + is_all_parts_found = false; + break; + } + } + + return is_all_parts_found; +} + + +function unvietnamese(str: string){ + let replacer = getVietnameseEnglishEquivalent(); + return replaceAll(str, replacer); +} + + +//credit: stackoverflow +function replaceAll(str: string, mapObj: any) { + let re = new RegExp(Object.keys(mapObj).join("|"), "gi"); + + return (str+'').replace(re, function (matched) { + return mapObj[matched] + }) +} + + +//28-10-2015 +//matching Vietnamese special characters to English equivalent +//used in some functions around the system like Search->sanitizeVietnamese($txt), ListView::buildSqlEquation +function getVietnameseEnglishEquivalent(){ + return { + "đ" : "dd", + "Đ" : "DD", + + "ó" : 'os', + "ỏ" : 'or', + "ò" : 'of', + "ọ" : 'oj', + "õ" : 'ox', + + "ô" : 'oo', + "ỗ" : 'oox', + "ổ" : 'oor', + "ồ" : 'oof', + "ố" : 'oos', + "ộ" : 'ooj', + + "ơ" : 'ow', + "ỡ" : 'owx', + "ớ" : 'ows', + "ờ" : 'owf', + "ở" : 'owr', + "ợ" : 'owj', + + "Ó" : 'OS', + "Ỏ" : 'OR', + "Ò" : 'OF', + "Ọ" : 'OJ', + "Õ" : 'OX', + + "Ô" : 'OO', + "Ỗ" : 'OOX', + "Ổ" : 'OOR', + "Ồ" : 'OOF', + "Ố" : 'OOS', + "Ộ" : 'OOJ', + + "Ơ" : 'OW', + "Ỡ" : 'OWX', + "Ớ" : 'OWS', + "Ờ" : 'OWF', + "Ở" : 'OWR', + "Ợ" : 'OWJ', + + "ì" : 'if', + "í" : 'is', + "ỉ" : 'ir', + "ĩ" : 'ix', + "ị" : 'ij', + + "Ì" : 'IF', + "Í" : 'IS', + "Ỉ" : 'IR', + "Ĩ" : 'IX', + "Ị" : 'IJ', + + "ê" : 'ee', + "ệ" : 'eej', + "ế" : 'ees', + "ể" : 'eer', + "ễ" : 'eex', + "ề" : 'eef', + + "é" : 'es', + "ẹ" : 'ej', + "ẽ" : 'ex', + "è" : 'ef', + "ẻ" : 'er', + + "Ê" : 'EE', + "Ệ" : 'EEJ', + "Ế" : 'EES', + "Ể" : 'EER', + "Ễ" : 'EEX', + "Ề" : 'EEF', + + "É" : 'ES', + "Ẹ" : 'EJ', + "Ẽ" : 'EX', + "È" : 'EF', + "Ẻ" : 'ER', + + "ả" : 'ar', + "á" : 'as', + "ạ" : 'aj', + "ã" : 'ax', + "à" : 'af', + + "â" : 'aa', + "ẩ" : 'aar', + "ấ" : 'aas', + "ầ" : 'aaf', + "ậ" : 'aaj', + "ẫ" : 'aax', + + "ă" : 'aw', + "ẳ" : 'awr', + "ắ" : 'aws', + "ằ" : 'awf', + "ặ" : 'awj', + "ẵ" : 'awx', + + "Ả" : 'AR', + "Á" : 'AS', + "Ạ" : 'AJ', + "Ã" : 'AX', + "À" : 'AF', + + "Â" : 'AA', + "Ẩ" : 'AAR', + "Ấ" : 'AAS', + "Ầ" : 'AAF', + "Ậ" : 'AAJ', + "Ẫ" : 'AAX', + + "Ă" : 'AW', + "Ẳ" : 'AWR', + "Ắ" : 'AWS', + "Ằ" : 'AWF', + "Ặ" : 'AWJ', + "Ẵ" : 'AWX', + + "ũ" : 'ux', + "ụ" : 'uj', + "ú" : 'us', + "ủ" : 'ur', + "ù" : 'uf', + + "ư" : 'uw', + "ữ" : 'uwx', + "ự" : 'uwj', + "ứ" : 'uws', + "ử" : 'uwr', + "ừ" : 'uwf', + + "Ũ" : 'UX', + "Ụ" : 'UJ', + "Ú" : 'US', + "Ủ" : 'UR', + "Ù" : 'UF', + + "Ư" : 'UW', + "Ữ" : 'UWX', + "Ự" : 'UWJ', + "Ứ" : 'UWS', + "Ử" : 'UWR', + "Ừ" : 'UWF', + + "ỹ" : 'yx', + "ỵ" : 'yj', + "ý" : 'ys', + "ỷ" : 'yr', + "ỳ" : 'yf', + + "Ỹ" : 'YX', + "Ỵ" : 'YJ', + "Ý" : 'YS', + "Ỷ" : 'YR', + "Ỳ" : 'YF', + } +} + + +function chuyenKhongdau(txt: string){ + const arraychar = [ + ["đ"], + ["Đ"], + ["ó","ỏ","ò","ọ","õ","ô","ỗ","ổ","ồ","ố","ộ","ơ","ỡ","ớ","ờ","ở","ợ"], + ["Ó","Ỏ","Ò","Ọ","Õ","Ô","Ỗ","Ổ","Ồ","Ố","Ộ","Ơ","Ỡ","Ớ","Ờ","Ở","Ợ"], + ["ì","í","ỉ","ì","ĩ","ị",], + ["Ì","Í","Ỉ","Ì","Ĩ","Ị"], + ["ê","ệ","ế","ể","ễ","ề","é","ẹ","ẽ","è","ẻ",], + ["Ê","Ệ","Ế","Ể","Ễ","Ề","É","Ẹ","Ẽ","È","Ẻ"], + ["ả","á","ạ","ã","à","â","ẩ","ấ","ầ","ậ","ẫ","ă","ẳ","ắ","ằ","ặ","ẵ",], + ["Ả","Á","Ạ","Ã","À","Â","Ẩ","Ấ","Ầ","Ậ","Ẫ","Ă","Ẳ","Ắ","Ằ","Ặ","Ẵ"], + ["ũ","ụ","ú","ủ","ù","ư","ữ","ự","ứ","ử","ừ",], + ["Ũ","Ụ","Ú","Ủ","Ù","Ư","Ũ","Ự","Ứ","Ử","Ừ"], + ["ỹ","ỵ","ý","ỷ","ỳ",], + ["Ỹ","Ỵ","Ý","Ỷ","Ỳ"] + ]; + const arrayconvert = ["d","D","o","O","i","I","e","E","a","A","u","U","y","Y"]; + + let mappings: any = {}; + for ( let i = 0, count = arrayconvert.length; i < count; i++){ + for ( let j = 0, total = arraychar[i].length; j < total ; j++){ + mappings[arraychar[i][j]] = arrayconvert[i]; + } + } + + return replaceAll(txt, mappings); +} + + +function getUniqueWords(str: string) { + const sub_str_parts = str.trim().split(" "); + const unique_values = sub_str_parts.filter( _onlyUnique ); + + return unique_values.join(" ").trim(); + + function _onlyUnique(value: any, index: any, self: string | any[]) { + return self.indexOf(value) === index; + } +} diff --git a/src/lib/webworker.ts b/src/lib/webworker.ts new file mode 100644 index 0000000..1ef6b11 --- /dev/null +++ b/src/lib/webworker.ts @@ -0,0 +1,9 @@ +// 19-05-2021: For dev + +let registered_callbacks: {[key: string] : any} = {}; +let _web_worker: { sendTask: (payload: {type: string, task_id: string, [key:string]: any}, callback: Function) => void }|undefined = undefined; + + +export function createWebWorker(url: string) { + +} diff --git a/src/logo.svg b/src/logo.svg new file mode 100644 index 0000000..9dfc1c0 --- /dev/null +++ b/src/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/react-app-env.d.ts b/src/react-app-env.d.ts new file mode 100644 index 0000000..6431bc5 --- /dev/null +++ b/src/react-app-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/src/reportWebVitals.ts b/src/reportWebVitals.ts new file mode 100644 index 0000000..49a2a16 --- /dev/null +++ b/src/reportWebVitals.ts @@ -0,0 +1,15 @@ +import { ReportHandler } from 'web-vitals'; + +const reportWebVitals = (onPerfEntry?: ReportHandler) => { + if (onPerfEntry && onPerfEntry instanceof Function) { + import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { + getCLS(onPerfEntry); + getFID(onPerfEntry); + getFCP(onPerfEntry); + getLCP(onPerfEntry); + getTTFB(onPerfEntry); + }); + } +}; + +export default reportWebVitals; diff --git a/src/setup.ts b/src/setup.ts new file mode 100644 index 0000000..516e9bc --- /dev/null +++ b/src/setup.ts @@ -0,0 +1,73 @@ +import {getAdminInfo, getUserSocketConnectionProperty, trackVisibilityChange} from "@/lib/user"; +import {askForBrowserNotificationPermit} from "@/lib/notification"; +import store from "@/store"; +import {createSocket} from "@/lib/networking"; +import {actions} from "@/store/actions"; +import {confirmLeavePage, runUpdateAdminStatus} from "@/lib/utils"; +import {IS_DEV, SOCKET_OPTIONS } from "@/config"; +import {handleMessageFromServer} from "@/lib/messaging"; +import {ConnectionToServerStatusType} from "@/typings/network"; +import api from "@/lib/api"; +import {ClientSettings} from "@/typings"; + + +export async function getSettings() : Promise { + let client_query = await api.get("client/settings"); + return (client_query.status === 'ok') ? client_query.data as ClientSettings : null; +} + + +export const setUp = async () => { + + let user_connection = getUserSocketConnectionProperty(); + if(user_connection.endpoint === '') { + console.error("Setup error in socket endpoint"); + return ; + } + + await createSocket(user_connection.endpoint, {jwt_token: user_connection.token, ...SOCKET_OPTIONS}, handleMessageFromServer, (status: ConnectionToServerStatusType, message: string) => { + switch (status){ + case "connect": + store.dispatch(actions.updateNodeConnection('ok')) + break; + case "disconnect": + case "connect_error": + store.dispatch(actions.updateNodeConnection('error')) + break; + } + }); + + _checkUsersStatus(); + + trackVisibilityChange(store.dispatch); + + // if user leaving page, ask for confirmation of loss messages + confirmLeavePage(); + + // todo: combine checking network with update status because user must be online in order to send updates + /*runCheckNetworkConnection(20, (isOnline) => { + let new_status: NetworkingStatusType = (isOnline) ? 'online': 'offline'; + store.dispatch(actions.changeNetworkingStatus(new_status)); + });*/ + + if(IS_DEV) { + + runUpdateAdminStatus(getAdminInfo().id, 20); + + updateRealStat(15); // use web-worker + + await askForBrowserNotificationPermit(); // can only test when run in https mode + //_watchSelfDisconnect(); // start heartbeat + } +}; + + +// todo: on reload, check status of all users (users in chat and users in request queue) +function _checkUsersStatus() { + // todo: +} + +// webworker to update user online and new notification +function updateRealStat(check_interval: number = 5) { + //TODO: +} \ No newline at end of file diff --git a/src/setupProxy.js b/src/setupProxy.js new file mode 100644 index 0000000..6524ab9 --- /dev/null +++ b/src/setupProxy.js @@ -0,0 +1,21 @@ +// guide from: https://create-react-app.dev/docs/proxying-api-requests-in-development/ +// Note: You do not need to import this file anywhere. It is automatically registered when you start the development server. +const createProxyMiddleware = require('http-proxy-middleware'); + +// proxy middleware options +// see: https://www.npmjs.com/package/http-proxy-middleware#http-proxy-options +const options = { + target: 'https://api195.chatngay.com/admin/', // target host + changeOrigin: true, // needed for virtual hosted sites + //ws: true, // proxy websockets + pathRewrite: { + '^/api': '', // remove base path + } +}; + +module.exports = function(app) { + app.use( + '/api', + createProxyMiddleware(options) + ); +}; diff --git a/src/setupTests.ts b/src/setupTests.ts new file mode 100644 index 0000000..8f2609b --- /dev/null +++ b/src/setupTests.ts @@ -0,0 +1,5 @@ +// jest-dom adds custom jest matchers for asserting on DOM nodes. +// allows you to do things like: +// expect(element).toHaveTextContent(/react/i) +// learn more: https://github.com/testing-library/jest-dom +import '@testing-library/jest-dom'; diff --git a/src/store/actions.ts b/src/store/actions.ts new file mode 100644 index 0000000..ced0175 --- /dev/null +++ b/src/store/actions.ts @@ -0,0 +1,143 @@ +import {Action, NetworkingStatusType, NodeConnectionStatusType, UserStatusType} from "@/store/typing"; +import {AdminInfo, UserChatRequest, UserInfo} from "@/typings/user"; +import {ChatboxTextMessage, ServerMessage, StatusMessageType} from "@/typings/message.d"; +import {ActionType} from "./typing.d"; +import {Dispatch} from "redux"; +import storage, {userChatHistoryStorageKey} from "@/lib/storage"; + + +const changeUserVisibilityState = (new_state: VisibilityState) : Action => { + return _createAction('UPDATE_USER_VISIBILITY_STATE', new_state); +} + +const changeNetworkingStatus = (new_status: NetworkingStatusType) : Action => { + return _createAction('UPDATE_NETWORKING_STATUS', new_status); +} + +const updateNodeConnection = (new_status: NodeConnectionStatusType) : Action => { + return _createAction('UPDATE_NODE_CONNECTION', new_status); +} + +const addNewMessage = (message: ServerMessage) : Action => { + return _createAction('ADD_NEW_MESSAGE', message); +} + +const addBatchMessage = (messages: ServerMessage[]) : Action => { + return _createAction('ADD_BATCH_MESSAGES', messages); +} + +const clearNewMessage = (list_ids: number[]) : Action => { + return _createAction('CLEAR_NEW_MESSAGE', list_ids); +} + +const changeUserStatus = (new_status: UserStatusType) : Action => { + return _createAction('UPDATE_USER_STATUS', new_status); +} + +const updateUserInfo = (info: Partial) : Action => { + return _createAction('UPDATE_USER_INFO', info); +} + +const addUser = (info: Partial) : any => { + // since user might have chatted with many different admin staffs before, + // before adding user to the chat list, clear all stored history messages in current browser of the user + // so the admin can fetch new history + // here is the use of redux-thunk + return async (dispatch: Dispatch) => { + console.log("deleting user data : storage.delete "); + let result = await storage.delete(userChatHistoryStorageKey(info.id)); + console.log(result); + dispatch( _createAction('ADD_USER', info) ); + } +} + +const chatWithUser = (user_id: string) : Action => { + return _createAction('CHAT_WITH_USER', user_id); +} + +const removeUser = (info: Partial) : any => { + // remove user from chat also remove the storage history to save storage + // here is the use of redux-thunk + return async (dispatch: Dispatch) => { + await storage.delete(userChatHistoryStorageKey(info.id)); + dispatch(_createAction('REMOVE_USER', info)); + } +} + +const changeAdminInfo = (info: Partial) : Action => { + return _createAction('UPDATE_ADMIN_INFO', info); +} + + +const addAdmin = (info: Partial | Partial[]) : Action => { + return _createAction('ADD_ADMIN', info); +} + +const addChatRequest = (user_info: UserChatRequest, message?: ServerMessage) : Action => { + return _createAction('ADD_CHAT_REQUEST', {user_info, message}); +} + +const updateChatRequest = (info: Partial) : Action => { + return _createAction('UPDATE_CHAT_REQUEST', info); +} + +const removeChatRequest = (user_id: string) : Action => { + return _createAction('REMOVE_CHAT_REQUEST', user_id); +} + +const updateStat = (stat = '') : Action => { + return _createAction('UPDATE_STATS', stat); +} + +const addCurrentMessage = (payload: {[user_id: string]: ChatboxTextMessage[]}) : Action => { + return _createAction('ADD_CURRENT_MESSAGE', payload); +} + +const updateCurrentMessage = (payload: {[user_id: string]: StatusMessageType}) : Action => { + return _createAction('UPDATE_CURRENT_MESSAGE', payload); +} + +const addHistoryMessage = (payload: {[user_id: string]: ChatboxTextMessage[]}) : Action => { + return _createAction('ADD_HISTORY_MESSAGE', payload); +} + +const openGlobalModal = (payload: {component: string, args?: {[key: string]: any} }) : Action => { + return _createAction('OPEN_GLOBAL_MODAL', JSON.stringify(payload)); +} + +const openGlobalDrawer = (payload: {component: string, args?: {[key: string]: any} }) : Action => { + return _createAction('OPEN_GLOBAL_DRAWER', JSON.stringify(payload)); +} + + +export const actions = { + chatWithUser, + changeUserVisibilityState, + changeNetworkingStatus, + updateNodeConnection, + updateUserInfo, + addNewMessage, + addBatchMessage, + clearNewMessage, + updateStat, + addCurrentMessage, + updateCurrentMessage, + addHistoryMessage, + addChatRequest, + updateChatRequest, + addUser, + removeChatRequest, + addAdmin, + changeAdminInfo, + changeUserStatus, + removeUser, + openGlobalModal, + openGlobalDrawer +} + +function _createAction(type: ActionType, payload: any) : Action { + return { + type, + payload, + } +} diff --git a/src/store/dispatcher.ts b/src/store/dispatcher.ts new file mode 100644 index 0000000..242668e --- /dev/null +++ b/src/store/dispatcher.ts @@ -0,0 +1,55 @@ +// utility to perform actions which each might involve a number of dispatching activities +const nothing = 'ass'; + +export default nothing; + +/* +import {Dispatch} from "redux"; +import {UserInfo} from "@/typings/user"; +import {ServerMessage} from "@/typings/message"; +import {actions} from "@/store/actions"; +import {sendMessageToServer} from "@/lib/messaging"; +import {getAdminInfo} from "@/lib/user"; +*/ + + +// Admin pick a request: +// 1. remove from the list +// 2. send to network to alert other admins and remove from their list +// 3. add user to the user-list +// 4. add messages to the chat +/* +export const pickRequest = (dispatch: Dispatch, info: {user: UserInfo, new_messages: ServerMessage[];}) => { + + // remove from request list + // dispatch(actions.removeChatRequest(info.user.id)); + + // send to network to alert other admins and remove from their list + const admin_info = getAdminInfo(); + if(!admin_info) return; + + sendMessageToServer({ + type: 'notify', + content: { + type: 'accept-chat-request', + content: { + user_info: { + id: info.user.id, + node: info.user.node, + }, + admin_info: { + id: admin_info.id, + name: admin_info.name, + node: admin_info.node + } + } + } + }); + + // add to user list + dispatch(actions.addUser({...info.user, online: true, chatbox: true})); + + // then add messages + dispatch(actions.addBatchMessage(info.new_messages)) +} +*/ diff --git a/src/store/index.ts b/src/store/index.ts new file mode 100644 index 0000000..cd29957 --- /dev/null +++ b/src/store/index.ts @@ -0,0 +1,10 @@ +// 19-05-2021: For dev + +import {createStore, Store, applyMiddleware} from 'redux'; +import thunk from 'redux-thunk'; +import {defaultState, rootReducer} from './reducers'; + + +const store: Store = createStore(rootReducer, defaultState, applyMiddleware(thunk)); + +export default store ; diff --git a/src/store/persist.ts b/src/store/persist.ts new file mode 100644 index 0000000..25d9b21 --- /dev/null +++ b/src/store/persist.ts @@ -0,0 +1,15 @@ +// 19-05-2021: For dev + +const persist = { + clear() { + + }, + save(data: any) { + + }, + get() { + return null; + } +} + +export default persist; diff --git a/src/store/reducers.ts b/src/store/reducers.ts new file mode 100644 index 0000000..ff64ef8 --- /dev/null +++ b/src/store/reducers.ts @@ -0,0 +1,413 @@ +import { combineReducers } from 'redux' +import isEmpty from "lodash/isEmpty"; + +import {Action, ActionType, AppState, NetworkingStatusType, NodeConnectionStatusType} from "@/store/typing"; +import {AdminInfo, UserChatRequest, UserInfo} from "@/typings/user"; +import {ChatboxTextMessage, ServerMessage, StatusMessageType} from "@/typings/message"; +import {findObjectInArray} from "@/lib/utils"; + + +const updateUserVisibilityState = (current_state: VisibilityState = 'visible', action: Action) : VisibilityState => { + if(action.type === 'UPDATE_USER_VISIBILITY_STATE') { + return action.payload; + } + + return current_state; +} + +const updateNetworkingStatus = (current_status: NetworkingStatusType = 'offline', action: Action) : NetworkingStatusType => { + if(action.type === 'UPDATE_NETWORKING_STATUS') { + return action.payload; + } + + return current_status; +} + + +const updateNodeConnection = (current_status: NodeConnectionStatusType = 'error', action: Action) : NodeConnectionStatusType => { + if(action.type === 'UPDATE_NODE_CONNECTION') { + return action.payload; + } + + return current_status; +} + +/* +const updateAdminInfo = (current: AdminInfo = {id: '', name: '', avatar: '', greeting: '', group_id: 0}, action: Action) : AdminInfo => { + if(action.type === 'UPDATE_ADMIN_INFO') { + let admin_info = (current) ? current : {} + return {...admin_info, ...action.payload}; + } + + return current; +} +*/ + +const addNewRequestMessage = (existing: {user: UserInfo, new_messages: ServerMessage[];}[], message: ServerMessage) => { + + if(message.type !== 'broker') return existing; + + const {from} = message; + let item: {user: UserInfo, new_messages: ServerMessage[];} | undefined; + let match_index = -1; + for ( let index = 0; index < existing.length; index ++) { + if(existing[index].user.id === from) { + item = {...existing[index]}; + item.new_messages.push(message); + match_index = index; + break; + } + } + + if(match_index > -1 && item) { + let new_list = [...existing]; + new_list[match_index] = item; + return new_list; + } + + return existing; +} + + +const updateNewRequest = (state: {user: UserChatRequest, new_messages: ServerMessage[];}[] = [], action: Action) => { + // add new chat request + if(action.type === 'ADD_CHAT_REQUEST') { + const { user_info, message } = action.payload as {user_info: UserChatRequest, message?: ServerMessage}; + + let match_index = -1; + for ( let index = 0; index < state.length; index ++) { + if(state[index].user.id === user_info.id) { + match_index = index; + break; + } + } + + if(match_index >= 0) { + if(message) state[match_index].new_messages.push(message); + return state; + } + + // {user: UserChatRequest, new_messages: ServerMessage[];}[] + return [...state, { user: user_info as UserChatRequest, new_messages: (message) ? [message] : []}]; + } + + //update + if(action.type === 'UPDATE_CHAT_REQUEST') { + let user_info = action.payload as Partial; + + let match_index = -1; + for ( let index = 0; index < state.length; index ++) { + if(state[index].user.id === user_info.id) { + match_index = index; + break; + } + } + + if(match_index >= 0) { + state[match_index].user = {...state[match_index].user, ...user_info}; + } + + return state; + } + + // remove chat request + if(action.type === 'REMOVE_CHAT_REQUEST') { + let remove_id = action.payload; + + return state.filter(request => request.user.id !== remove_id); + } + + if(action.type === 'ADD_NEW_REQUEST_MESSAGE') { + return addNewRequestMessage(state, action.payload); + } + + return state; +} + +// {[key: string]: number} +const updateStat = (current_stats: string = '', action: Action) => { + if(action.type === 'UPDATE_STATS') { + return action.payload; + } + + return current_stats; +} + +// {[key: string]: number} +const openGlobalModal = (current_stats: string = '', action: Action) => { + if(action.type === 'OPEN_GLOBAL_MODAL') { + return action.payload; + } + + return current_stats; +} + +const openGlobalDrawer = (current_stats: string = '', action: Action) => { + if(action.type === 'OPEN_GLOBAL_DRAWER') { + return action.payload; + } + + return current_stats; +} + + +const updateNewMessage = (state: ServerMessage[] = [], action: Action) => { + if(action.type === 'ADD_NEW_MESSAGE') { + return [...state, action.payload]; + } + + if(action.type === 'ADD_BATCH_MESSAGES') { + return [...state, ...action.payload]; + } + + if(action.type === 'CLEAR_NEW_MESSAGE') { + //let list_message_ids: number[] = action.payload; + //return state.filter(mes => !list_message_ids.includes(mes.id)); + return []; + } + + return state; +} + + +const updateUserInfo = (state: UserInfo[] = [], action: Action) => { + if(action.type === 'ADD_USER' ) { + // make sure user is not already in the list + let { index, item } = findObjectInArray(state, "id", action.payload.id); + if(index > -1) { + let new_item = {...item, ...action.payload}; + let new_state = [...state]; + new_state[index] = new_item; + return new_state; + } + + return [...state, action.payload]; + } + + if(action.type === 'UPDATE_USER_INFO') { + let { index, item } = findObjectInArray(state, "id", action.payload.id); + if(index > -1) { + let new_item = {...item, ...action.payload}; + + if(action.payload.chatbox) { + // and add user to top of the list (if we change chatbox:true, we want to chat with this user right way, not be restricted by screen width) + let new_state = state.filter(user => user.id !== new_item.id); + new_state.push(new_item); + return new_state; + }else{ + let new_state = [...state]; + new_state[index] = new_item; + return new_state; + } + + }else{ + // add user to list + return [...state, action.payload as UserInfo]; + } + } + + if(action.type === 'REMOVE_USER') { + let remove_user_id = action.payload.id; + return state.filter(user => user.id !== remove_user_id); + } + + return state; +} + + +const updateAdminInfo = (state: AdminInfo[] = [], action: Action) => { + + if(action.type === 'ADD_ADMIN') { + + // check if we are adding in bulk + let list_admin: AdminInfo[] = []; + if(Array.isArray(action.payload)) { + list_admin = action.payload as AdminInfo[]; + }else{ + list_admin.push(action.payload); + } + + const new_admin = list_admin.filter((admin) => { + let { index } = findObjectInArray(state, "id", admin.id); + return (index === -1); + }); + + if(new_admin.length > 0) { + return [...state, ...new_admin]; + } + + return state; + } + + if(action.type === 'UPDATE_ADMIN_INFO') { + let { index, item } = findObjectInArray(state, "id", action.payload.id); + if(index > -1) { + let new_item = {...item, ...action.payload}; + let new_state = [...state]; + new_state[index] = new_item; + return new_state; + } + } + + return state; +} + + +const updateCurrentMessages = ( + state: {[user_id: string]: ChatboxTextMessage[]} = {}, + action: {type: ActionType, payload: {[user_id: string]: (ChatboxTextMessage[] | StatusMessageType) } } +) => { + + // add new messages sent by the user + if(action.type === 'ADD_CURRENT_MESSAGE') { + let new_content: {[user_id: string]: ChatboxTextMessage[] } = {}; + for (let user_id in action.payload) { + const data = action.payload[user_id]; + if(Array.isArray(data)) { + new_content[user_id] = state[user_id] ? [...state[user_id], ...data] : data; + } + } + + return (isEmpty(new_content)) ? state : {...state, ...new_content}; + } + + // update message's read/status/delivery ... + if(action.type === 'UPDATE_CURRENT_MESSAGE') { + let new_content: {[user_id: string]: ChatboxTextMessage[] } = {}; + for (let user_id in action.payload) { + const data = action.payload[user_id] as StatusMessageType; + const { isRead , deliveryStatus } = data; + const current_messages = state[user_id] ; + + let { index, item } = findObjectInArray(current_messages, "id", data.msg_id); + if(index > -1) { + let new_item = {...item, ...{ isRead , deliveryStatus }} as ChatboxTextMessage; + let updated_messages = [...current_messages]; + updated_messages[index] = new_item; + new_content[user_id] = updated_messages; + } + } + + return {...state, ...new_content} + + } + + // add old messages + if(action.type === 'ADD_HISTORY_MESSAGE') { + let new_content: {[user_id: string]: ChatboxTextMessage[] } = {}; + for (let user_id in action.payload) { + const data = action.payload[user_id]; + if(Array.isArray(data)) { + new_content[user_id] = state[user_id] ? [...data, ...state[user_id]] : data; + } + } + + return (isEmpty(new_content)) ? state : {...state, ...new_content}; + } + + return state; +} + + +const pickChatWithUser = (state: string = '', action: Action) => { + if(action.type === 'CHAT_WITH_USER') { + return action.payload + } + + return state; +} + + +// redux summary: +// rootReducer is the composition of all pure functions registered beforehand to a store +// any dispatched action by store will search in the rootReducer to find corresponding function to modify state +// reducers can be added dynamically: see https://redux.js.org/recipes/code-splitting + +const combinedReducer = combineReducers({ + userVisibilityState: updateUserVisibilityState, + network_connection: updateNetworkingStatus, + node_connection: updateNodeConnection, + user_list: updateUserInfo, + chatting_with_user: pickChatWithUser, + admin_list: updateAdminInfo, + current_messages: updateCurrentMessages, + new_messages: updateNewMessage, + new_requests: updateNewRequest, + stats: updateStat, + global_modal: openGlobalModal, + global_drawer: openGlobalDrawer, +}); + +export const defaultState: AppState = { + userVisibilityState: 'visible', + network_connection: 'online', + node_connection: 'error', + user_list: [], + chatting_with_user: '', + admin_list: [], + current_messages: {}, + new_messages: [], + new_requests: [], + stats: JSON.stringify({ + user_online: 0, + admin_online: 0 + }), + global_modal: '', + global_drawer: '' +} + + +function crossSliceReducer(state: AppState = defaultState, action: Action) { + switch (action.type) { + case 'UPDATE_USER_LIST_STATUS': { + let user_status_list: {[key: string]: boolean} = action.payload; + let new_user_list: UserInfo[] = [...state.user_list]; + //let new_new_requests: {user: UserChatRequest, new_messages: ServerMessage[];}[] = [...state.new_requests]; + + let state_change = false; + + for (const [user_id, online_status] of Object.entries(user_status_list)) { + // check if user_id in new_user_list + //let has_found = false; + for ( let i=0; i< new_user_list.length; i++) { + if(new_user_list[i].id === user_id) { + new_user_list[i].online = online_status; + //has_found = true; + state_change = true; + break; + } + } + + // check in new_request + /*if(!has_found) { + for ( let i=0; i< new_new_requests.length; i++) { + if(new_new_requests[i].user.id === user_id) { + new_new_requests[i].user.online = online_status; + has_found = true; + state_change = true; + break; + } + } + }*/ + } + + if(state_change) { + return { + ...state, + user_list: new_user_list, + //new_requests: new_new_requests, + } + } + + // nothing change + return state; + } + default: + return state + } +} + +export function rootReducer(state: AppState = defaultState, action: Action) { + const intermediateState = combinedReducer(state, action); + return crossSliceReducer(intermediateState, action) ; +} diff --git a/src/store/typing.d.ts b/src/store/typing.d.ts new file mode 100644 index 0000000..774760b --- /dev/null +++ b/src/store/typing.d.ts @@ -0,0 +1,45 @@ +import {UserInfo, UserChatRequest, AdminInfo} from "@/typings/user"; +import {ChatboxTextMessage, ServerMessage} from "@/typings/message"; + +export type UserStatusType = 'available' | 'idle' | 'busy' | 'offline'; +export type NetworkingStatusType = 'online' | 'offline'; +export type NodeConnectionStatusType = 'ok' | 'error' | 'reconnected'; + +export interface AppState { + userVisibilityState: VisibilityState; + network_connection: NetworkingStatusType; // user's internet is working or not? + node_connection: NodeConnectionStatusType; // connection to node + user_list: UserInfo[]; + chatting_with_user: string; + admin_list: AdminInfo[]; + // hold all current messages in all chat sessions so we Chatboxes load we can re-use + // store in storage but keep max size = 500 messages (all latest) + current_messages: {[user_id: string]: ChatboxTextMessage[]}; + new_messages: ServerMessage[]; // all unread messages or system-updated messages + new_requests: {user: UserChatRequest, new_messages: ServerMessage[]}[] ; + // since we use entire stats, make it string so react can make comparison on value change, object cann't compare! + stats: string; // {[key: string]: number}; + global_modal: string; // {[key: string]: number}; + global_drawer: string; // {[key: string]: number}; +} + + +export interface Action { + type: ActionType; + payload: any; +} + +export type ActionType = 'UPDATE_NETWORKING_STATUS' | 'UPDATE_NODE_CONNECTION' | + 'UPDATE_USER_STATUS' | 'UPDATE_USER_LIST_STATUS' | + 'ADD_CHAT_REQUEST' | 'UPDATE_CHAT_REQUEST' | 'REMOVE_CHAT_REQUEST' | + 'CHAT_WITH_USER' | 'ADD_USER' | + 'ADD_NEW_REQUEST_MESSAGE' | + 'ADD_NEW_MESSAGE' | + 'ADD_CURRENT_MESSAGE' | 'ADD_HISTORY_MESSAGE' | 'UPDATE_CURRENT_MESSAGE' | + 'ADD_BATCH_MESSAGES' | + 'CLEAR_NEW_MESSAGE' | + 'UPDATE_ADMIN_INFO' | 'ADD_ADMIN' | + 'UPDATE_STATS' | + 'REMOVE_USER' | + 'UPDATE_USER_INFO' | + 'UPDATE_USER_VISIBILITY_STATE' | 'OPEN_GLOBAL_MODAL' | 'OPEN_GLOBAL_DRAWER'; diff --git a/src/styles/app.css b/src/styles/app.css new file mode 100644 index 0000000..a360bdf --- /dev/null +++ b/src/styles/app.css @@ -0,0 +1,37 @@ +@import '~antd/dist/antd.css'; + +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', + 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', + sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +code { + font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace; +} + +.ant-layout-header { + background: black; +} + +.ant-layout-sider { + background: white; +} + +.ant-layout-content { + background: white; +} + +.scrollable { + overflow-y: scroll; + -webkit-overflow-scrolling: touch; +} + +/* use for SPAN in place of A tag */ +.a-link { + cursor: pointer; + color: #007aff; +} diff --git a/src/test/index.ts b/src/test/index.ts new file mode 100644 index 0000000..dc2b6b6 --- /dev/null +++ b/src/test/index.ts @@ -0,0 +1,5 @@ +import {testState} from "./test_state"; + +export function runTest() { + testState(); +} diff --git a/src/test/test_state.ts b/src/test/test_state.ts new file mode 100644 index 0000000..f2ca464 --- /dev/null +++ b/src/test/test_state.ts @@ -0,0 +1,17 @@ +// 19-05-2021: For dev + +import { UserInfo} from "@/typings/user"; + +export const user_list: UserInfo[] = [ + {id: '12312312312', name: 'Minh Hieu', node: 'node1', online: false, location: 'Hà nội', avatar: 'https://randomuser.me/api/portraits/men/40.jpg'}, + {id: "12312312334", name: 'Phuong Thao', node: 'node1', online: true, location: 'Sài gòn', avatar: 'https://randomuser.me/api/portraits/women/51.jpg'}, + {id: "192929293", name: 'Dang Minh', node: 'node1', online: true, location: 'Mỹ'}, + {id: "29292929292", name: 'Hong Diep', node: 'node1', online: true, location: 'Việt trì', avatar: 'https://randomuser.me/api/portraits/women/31.jpg'}, + {id: "292929292", name: 'Minh Ty', node: 'node1', online: true, location: 'Bắc Ninh'}, + {id: "99992929", name: 'Maika', node: 'node1', online: true, location: 'Cần thơ', avatar: 'https://randomuser.me/api/portraits/women/31.jpg'}, +]; + + +export function testState() { + +} diff --git a/src/test/test_util.ts b/src/test/test_util.ts new file mode 100644 index 0000000..638e118 --- /dev/null +++ b/src/test/test_util.ts @@ -0,0 +1,3 @@ +export function testThrottle(elapse: number = 5000) { + +} diff --git a/src/typings/index.ts b/src/typings/index.ts new file mode 100644 index 0000000..d4f98e0 --- /dev/null +++ b/src/typings/index.ts @@ -0,0 +1,100 @@ +import {AdminInfo} from "@/typings/user"; + +export type ClientSettings = { + staff: AdminInfo[]; +} + +export type EventType = 'create_note' | 'create_support' | 'create_order'; + +export type OrderStatusType = 'pending' | 'fail' | 'success' | 'processing'; +export type ShippingStatusType = 'pending' | 'fail' | 'success' | 'processing'; +export type PaymentStatusType = 'pending' | 'fail' | 'success' | 'processing'; + + +export type BrowseInfo = { + id: string, + domain: string, + url: string, + title: string, + create_time: number, +} + +export type NoteInfo = { + id?: string, + api_id?: string, + content?: string, + admin_id?: string, + admin_name?: string, + create_time?: number, +} + +export type SupportInfo = { + id?: string, + api_id?: string, + title?: string, + description?: string, + admin_id?: string, + admin_name?: string, + status?: number, + create_time?: number, +} + +export type OrderInfo = { + api_id?: string, + customer?: { + crm_code?: string, + name?: string, + email?: string, + mobile?: string, + address?: string, + province?: string, + }, + products?: { + id?: number, + sku?: string, + name?: string, + quantity?: number, + price?: number, + note?: string + }[], + others?: { + name?: string, + price?: number, + }[], + shipping?: { + provider?: string, + reference?: string, + note?: string, + date?: string, + time?: string, + }, + payment?: { + method?: string, + reference?: string, + note?: string, + }, + note?: string, + tags?: string[], + order_status?: OrderStatusType, + payment_status?: PaymentStatusType, + shipping_status?: ShippingStatusType, + total_value?: number, + create_time?: number, +} + + +export interface OpenHelpComponent { + type: HelpType, + params?: { [key: string] : any } +} + +export type HelpItem = { + id?: number, + name?: string; + img?: string; + summary?: string; + price?: number; + in_stock?: boolean; +} + +export type HelpType = 'product-list' | 'product-detail' | 'article-list' | 'article-detail' | 'search' | 'home'; diff --git a/src/typings/message.d.ts b/src/typings/message.d.ts new file mode 100644 index 0000000..af0eb96 --- /dev/null +++ b/src/typings/message.d.ts @@ -0,0 +1,175 @@ +// this definition file should be the same between: node_app / browser app / admin chat +// timestamp to compare: +// last change: 17-Feb-2021 06.51am + +export type UserMessage = UserMessageText | // user sends normal text message to other users + UserMessageNotify | // user's client sends update: user events, message status ... + UserMessageBot; // user sends answer to bot message + +export type ChatboxTextMessage = { + id: string; + from: string; + content: string; + time: number; + sequence?: number; + isRead?: boolean; + deliveryStatus?: MessageDeliveryStatus; +}; + +export type MessageDeliveryStatus = 0 | // message sent to server by user for delivery to the destination + 2 | // message received by server and it preparing to deliver + 3 | // client not received the ack from server that it has received the message: whatever reason. Here client can try resend. + 4 | // message delivered successfully and other user has received it + 5 ; // failed: message delivered by server but other user has not received it () + +export type ServerMessage = ServerMessageBroker | // server acts as a broker passing messages from other clients + ServerMessageNotify | // server directly sends notification message: user typing, network problem, etc ... + ServerMessageCommand | // server directly sends command messages: reload client, change node ... + ServerMessageBot; // server sends bot message + + +type ServerMessageBroker = { + type: 'broker'; + to: string; + time: number; + checksum?: number; + message_id: string; + from: string; + content: string; +} + +export type StatusMessageType = { + msg_id: string; + isRead?: boolean; + deliveryStatus?: MessageDeliveryStatus; +} + +type ServerMessageNotify = { + type: 'notify'; + to: string; + time: number; + checksum?: number; + content: NotifyMessageStatus | NotifyMessageTyping | NotifyMessageChatRequestSent | NotifyMessageChatRequestAccepted | NotifyMessageUserNetwork; +} + +type ServerMessageCommand = { + type: 'command'; + to: string; + time: number; + checksum?: number; + content: { + type: string; // possibly: send heartbeat | reload-chat-client + content?: any; + }; +} + +// bot message +type ServerMessageBot = { + type: 'bot'; + to: string; + time: number; + checksum?: number; + content: ServerMessageContentBotQuestion | ServerMessageContentBotAnswer; +}; + +type ServerMessageContentBotQuestion = { + type: 'question'; + suggestion_id: number; + question: string; + suggested_answers?: { id: number; content: string }[]; +} + +type ServerMessageContentBotAnswer = { + type: 'answer'; + text: string; +} + +// notify content +type NotifyMessageStatus = { + type: 'message-status'; + content: { + msg_id: string; + msg_sender: string; + isRead?: boolean; + deliveryStatus?: MessageDeliveryStatus; + local_sequence?: number; + }; +} + +type NotifyMessageTyping = { + type: 'typing' ; + content: { user_id: string; typing: boolean }; +} + +type NotifyMessageChatRequestSent = { + type: 'chat-request'; + content: { + status: 'sending' | 'failed' | 'received'; + } +} + +type NotifyMessageChatRequestAccepted = { + type: 'chat-request-accepted'; + content: { + request_id: string, + admin_info: { + id: string; + avatar?: string; + name: string; + node: string; + }, + }; +} + +// used by admin user +type NotifyMessageAcceptChatRequest = { + type: 'accept-chat-request'; + content: { + user_info: { + id: string; + node?: string; + } + admin_info: { + id: string; + avatar?: string; + name: string; + node: string; + }, + }; +} + +type NotifyMessageUserNetwork = { + type: 'user-network'; + content: { + user_id: string; + is_admin: boolean; + online: boolean ; + }; +} + +// detailed user message types +type UserMessageText = { + type: 'text'; + checksum?: number; + content: { + to: string; + text: string; + local_sequence?: number; + }; +} + +type UserMessageNotify = { + type: 'notify'; + checksum?: number; + content: NotifyMessageStatus | NotifyMessageTyping | NotifyMessageChatRequestAccepted | NotifyMessageAcceptChatRequest; +} + +type UserMessageBot = { + type: 'bot'; + checksum?: number; + content: { + question_id: number; + selected_answer: number; + answer?: string; + }; +} diff --git a/src/typings/network.d.ts b/src/typings/network.d.ts new file mode 100644 index 0000000..1e93e14 --- /dev/null +++ b/src/typings/network.d.ts @@ -0,0 +1,7 @@ +export type ConnectionToServerStatusType = 'disconnect' | 'connect' | 'connect_error'; + +export interface APIResponse { + errCode:number, + msg: string, + data: any +} diff --git a/src/typings/user.d.ts b/src/typings/user.d.ts new file mode 100644 index 0000000..74bb980 --- /dev/null +++ b/src/typings/user.d.ts @@ -0,0 +1,79 @@ +declare global { + interface Window { + admin_info: AdminInfo; + } +} + +export type UserDeviceType = 'mobile' | 'desktop'; + +export type UserPopupType = 'info'| 'chat-history' | 'notes' | 'web-view' | 'list-online-user' | 'list-online-admin' | 'search-mini-web' | ''; + + +export interface Chatngay { + send: (message: Message) => any; + connect: () => void; + disconnect: () => void; +} + +export interface UserInfo { + id: string; + name?: string; + email?: string; + avatar?: string; + tel?: string; + node?: string; + online?: boolean; + location?: string; + chatbox?: boolean; + typing?: boolean; + crm_code?: string; + note?: string; +} + +export interface UserInfoCRM { + +} + + +export interface UserChatRequest { + id: string; + name?: string; + node?: string; + online?: boolean; +} + +export type UserInfoOnline = { + user_id: string; + user_name: string; + city: string; + user_ip: string; + current_view_page: string; + create_time: number; + chat_with: string; +} + +export type AdminInfoOnline = AdminInfo & {last_active: number}; + + +export interface GroupInfo { + id: string; + avatar?: string; + name: string; + greeting?: string; +} + + +export interface AdminInfo { + client_id?: string; + id: string; + avatar?: string; + name: string; + greeting?: string; + group_id?: string; + jwt?: string; + node?: string; + online?: boolean; + status?: string; +} + + diff --git a/src/typings/websocket.d.ts b/src/typings/websocket.d.ts new file mode 100644 index 0000000..90316f5 --- /dev/null +++ b/src/typings/websocket.d.ts @@ -0,0 +1,8 @@ +export interface ConnectedWebSocket extends WebSocket{ + // https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/close + close: (code?: number, reason?: string) => void; + // https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/send + send: (data: any) => void; + url: string; +} +