Init project

This commit is contained in:
2023-10-03 20:54:01 +07:00
commit 7f7e825992
83 changed files with 12740 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
!.*.js
**/dist/**
**/coverage/**
+14
View File
@@ -0,0 +1,14 @@
module.exports = {
root: true,
plugins: ['@typescript-eslint', 'import', 'prettier'],
extends: [
'airbnb-typescript/base',
'prettier',
'plugin:@typescript-eslint/recommended',
'plugin:import/typescript',
],
parser: '@typescript-eslint/parser',
parserOptions: {
project: './tsconfig.eslint.json',
},
};
+5
View File
@@ -0,0 +1,5 @@
.parcel-cache
node_modules
dist
+4
View File
@@ -0,0 +1,4 @@
!.*.js
**/dist/**
**/coverage/**
**/package-lock.json
+8
View File
@@ -0,0 +1,8 @@
{
"bracketSameLine": false,
"bracketSpacing": true,
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"useTabs": false
}
+1
View File
@@ -0,0 +1 @@
typescript-training
+1
View File
@@ -0,0 +1 @@
declare module '*.svg';
+7308
View File
File diff suppressed because it is too large Load Diff
+32
View File
@@ -0,0 +1,32 @@
{
"name": "typescript-practice",
"version": "1.0.0",
"description": "",
"scripts": {
"start": "parcel src/pages/*.html",
"build": "parcel build ./src/pages/*.html --no-cache --no-source-maps --dist-dir dist"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@types/core-js": "^2.5.6",
"@types/regenerator-runtime": "^0.13.2",
"firebase": "^10.4.0",
"parcel": "^2.9.3"
},
"devDependencies": {
"@parcel/transformer-sass": "^2.9.3",
"@typescript-eslint/eslint-plugin": "^6.7.3",
"@typescript-eslint/parser": "^6.7.3",
"eslint": "^8.50.0",
"eslint-config-airbnb-base": "^15.0.0",
"eslint-config-airbnb-typescript": "^17.1.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-import": "^2.28.1",
"eslint-plugin-prettier": "^5.0.0",
"prettier": "^3.0.3",
"process": "^0.11.10",
"typescript": "^5.2.2"
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+12
View File
@@ -0,0 +1,12 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="SVG" clip-path="url(#clip0_9_619)">
<g id="Icons/account/ic_account">
<path id="Oval-8" fill-rule="evenodd" clip-rule="evenodd" d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM13.4504 14.5983C13.4504 14.7799 13.422 14.9479 13.3652 15.1025C13.3085 15.2571 13.2234 15.3908 13.1099 15.5037C12.9965 15.6165 12.8558 15.7049 12.6879 15.7687C12.5201 15.8325 12.3251 15.8644 12.1028 15.8644C11.909 15.8644 11.7187 15.8361 11.5319 15.7797C11.3452 15.7233 11.1785 15.6251 11.0319 15.4853C10.8853 15.3454 10.7683 15.159 10.6809 14.9259C10.5934 14.6928 10.5496 14.402 10.5496 14.0536H8.5C8.5 14.6425 8.58511 15.1479 8.75532 15.5699C8.92553 15.9919 9.15248 16.3416 9.43617 16.6188C9.71986 16.8961 10.0449 17.1083 10.4113 17.2555C10.7778 17.4027 11.1572 17.4984 11.5496 17.5426V19H12.6773V17.5352C13.1028 17.4911 13.4894 17.3929 13.8369 17.2408C14.1844 17.0887 14.4811 16.8875 14.727 16.6372C14.9728 16.387 15.1631 16.0901 15.2979 15.7466C15.4326 15.4031 15.5 15.0154 15.5 14.5836C15.5 14.1076 15.4255 13.6954 15.2766 13.347C15.1277 12.9986 14.9184 12.6931 14.6489 12.4306C14.3794 12.1681 14.0567 11.9362 13.6809 11.735C13.305 11.5338 12.8877 11.3375 12.4291 11.1462C12.1454 11.0284 11.9078 10.9143 11.7163 10.8039C11.5248 10.6935 11.3712 10.5757 11.2553 10.4506C11.1395 10.3254 11.0579 10.1893 11.0106 10.0421C10.9634 9.89485 10.9397 9.73046 10.9397 9.5489C10.9397 9.36733 10.9634 9.19804 11.0106 9.04101C11.0579 8.88398 11.1312 8.74781 11.2305 8.63249C11.3298 8.51717 11.4563 8.42639 11.6099 8.36015C11.7636 8.2939 11.9468 8.26078 12.1596 8.26078C12.552 8.26078 12.8652 8.40676 13.0993 8.69874C13.3333 8.99071 13.4504 9.43112 13.4504 10.02H15.5C15.5 9.52926 15.4374 9.0864 15.3121 8.69138C15.1868 8.29635 15.0083 7.95408 14.7766 7.66456C14.5449 7.37504 14.2636 7.1395 13.9326 6.95794C13.6017 6.77637 13.2329 6.65615 12.8262 6.59727V5H11.6915V6.57518C11.266 6.61935 10.8818 6.72117 10.539 6.88065C10.1962 7.04013 9.90189 7.24746 9.65603 7.50263C9.41016 7.7578 9.22104 8.05713 9.08865 8.40063C8.95626 8.74413 8.89007 9.12443 8.89007 9.54154C8.89007 10.0224 8.96454 10.4371 9.11347 10.7855C9.26241 11.1339 9.47163 11.4381 9.74113 11.6982C10.0106 11.9583 10.3345 12.1877 10.7128 12.3864C11.091 12.5852 11.5118 12.7753 11.9752 12.9569C12.2447 13.0599 12.4728 13.1654 12.6596 13.2734C12.8463 13.3814 12.9988 13.4991 13.117 13.6267C13.2352 13.7543 13.3203 13.8966 13.3723 14.0536C13.4243 14.2107 13.4504 14.3922 13.4504 14.5983Z" fill="black" fill-opacity="0.54"/>
</g>
</g>
<defs>
<clipPath id="clip0_9_619">
<rect width="24" height="24" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.0393 11.6247L12.8033 11.8607L11.9213 10.9787C12.8027 9.89008 13.3333 8.50675 13.3333 7.00008C13.3333 3.50808 10.492 0.666748 7 0.666748C3.508 0.666748 0.666668 3.50808 0.666668 7.00008C0.666668 10.4921 3.508 13.3334 7 13.3334C8.50667 13.3334 9.89 12.8027 10.9787 11.9214L11.8607 12.8034L11.6247 13.0394L15.626 17.0407L17.04 15.6267L13.0393 11.6247ZM7.00067 11.3334C4.61133 11.3334 2.66733 9.38941 2.66733 7.00008C2.66733 4.61075 4.61133 2.66675 7.00067 2.66675C9.39 2.66675 11.334 4.61075 11.334 7.00008C11.334 9.38941 9.38933 11.3334 7.00067 11.3334Z" fill="gray"/>
</svg>

After

Width:  |  Height:  |  Size: 723 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

+21
View File
@@ -0,0 +1,21 @@
<svg width="231" height="130" viewBox="0 0 231 130" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_10_135)">
<path d="M114.521 124.792C113.763 127.824 111.489 128.582 108.457 128.582C107.052 128.553 105.704 128.015 104.667 127.066L106.183 124.034C106.482 124.322 106.841 124.539 107.234 124.67C107.628 124.801 108.045 124.843 108.457 124.792C109.973 124.792 110.731 124.034 111.489 122.518L112.247 121.002L104.667 103.569H107.7C108.282 103.624 108.823 103.894 109.216 104.327C110.732 107.359 114.522 116.455 114.522 116.455C114.522 116.455 118.312 107.355 119.07 104.327C119.07 103.569 120.586 103.569 120.586 103.569H123.618L114.521 124.792ZM95.571 107.363C92.539 107.363 91.023 108.879 90.265 111.911H100.877C100.119 108.875 98.6 107.363 95.571 107.363ZM103.909 114.943H90.266C90.4929 116.195 91.1444 117.331 92.1109 118.16C93.0773 118.988 94.2996 119.458 95.572 119.491C96.5683 119.519 97.5589 119.33 98.475 118.937C99.3911 118.545 100.211 117.958 100.878 117.217L103.152 119.491C101.077 121.388 98.383 122.466 95.572 122.523C94.364 122.571 93.1592 122.369 92.0332 121.929C90.9073 121.488 89.8847 120.82 89.0299 119.965C88.175 119.11 87.5064 118.088 87.0662 116.962C86.626 115.836 86.4237 114.631 86.472 113.423C86.4237 112.215 86.626 111.01 87.0662 109.884C87.5064 108.758 88.175 107.736 89.0299 106.881C89.8847 106.026 90.9073 105.357 92.0332 104.917C93.1592 104.477 94.364 104.275 95.572 104.323C101.635 104.327 104.667 108.875 103.909 114.938V114.943ZM79.654 121.763V111.907C79.6845 111.401 79.6073 110.895 79.4277 110.421C79.248 109.948 78.9698 109.518 78.6116 109.159C78.2534 108.801 77.8233 108.523 77.3497 108.343C76.876 108.164 76.3697 108.086 75.864 108.117C72.832 108.117 70.558 110.391 70.558 112.665V121.765H66.768V105.085L70.558 104.327V108.117C71.274 106.953 72.2778 105.992 73.4727 105.329C74.6675 104.665 76.0132 104.32 77.38 104.327C81.928 104.327 84.202 106.601 84.202 111.149V121.763H79.654ZM54.641 108.117C53.9395 108.1 53.2419 108.225 52.5904 108.486C51.9389 108.747 51.3471 109.137 50.8509 109.633C50.3548 110.129 49.9646 110.721 49.704 111.372C49.4434 112.024 49.3179 112.721 49.335 113.423C49.335 116.455 51.609 119.487 54.641 119.487C57.673 119.487 59.947 117.213 59.947 113.423C59.9641 112.721 59.8386 112.024 59.578 111.372C59.3174 110.721 58.9272 110.129 58.4311 109.633C57.9349 109.137 57.3431 108.747 56.6916 108.486C56.0401 108.225 55.3425 108.1 54.641 108.117ZM54.641 122.517C53.433 122.565 52.2282 122.363 51.1022 121.923C49.9763 121.482 48.9537 120.814 48.0989 119.959C47.244 119.104 46.5754 118.082 46.1352 116.956C45.695 115.83 45.4927 114.625 45.541 113.417C45.4927 112.209 45.695 111.004 46.1352 109.878C46.5754 108.752 47.244 107.73 48.0989 106.875C48.9537 106.02 49.9763 105.351 51.1022 104.911C52.2282 104.471 53.433 104.269 54.641 104.317C55.849 104.269 57.0538 104.471 58.1798 104.911C59.3057 105.351 60.3283 106.02 61.1831 106.875C62.038 107.73 62.7066 108.752 63.1468 109.878C63.587 111.004 63.7893 112.209 63.741 113.417C63.7895 114.625 63.5872 115.83 63.1471 116.956C62.7069 118.082 62.0384 119.105 61.1835 119.96C60.3287 120.815 59.306 121.483 58.18 121.924C57.054 122.364 55.849 122.566 54.641 122.518V122.517ZM42.513 121.763H38.723V105.085L31.9 118.728C31.5067 119.161 30.9661 119.431 30.384 119.486H29.626C29.0439 119.431 28.5033 119.161 28.11 118.728L21.29 105.085V121.763H17.5V98.2629H21.29C21.8721 98.318 22.4127 98.5883 22.806 99.0209C24.322 102.811 30.386 113.421 30.386 113.421C30.386 113.421 36.45 102.051 37.966 99.0209C38.3593 98.5883 38.8999 98.318 39.482 98.2629H43.272L42.513 121.763Z" fill="white"/>
<path d="M208.03 113.421V122.521H204.241V105.843L208.031 105.085V108.875C209.547 106.601 211.063 105.085 214.095 105.085V108.121C210.304 108.121 208.03 110.391 208.03 113.421ZM192.871 107.359C189.839 107.359 188.323 108.875 187.565 111.907H198.177C197.419 109.633 195.903 107.359 192.871 107.359ZM201.209 114.939H187.565C187.792 116.191 188.443 117.327 189.41 118.156C190.376 118.984 191.599 119.454 192.871 119.487C193.867 119.515 194.858 119.326 195.774 118.933C196.69 118.541 197.51 117.954 198.177 117.213L200.451 119.487C198.376 121.385 195.682 122.463 192.871 122.521C191.663 122.569 190.458 122.367 189.332 121.927C188.206 121.486 187.184 120.818 186.329 119.963C185.474 119.108 184.805 118.086 184.365 116.96C183.925 115.834 183.723 114.629 183.771 113.421C183.723 112.213 183.925 111.008 184.365 109.882C184.805 108.756 185.474 107.734 186.329 106.879C187.184 106.024 188.206 105.355 189.332 104.915C190.458 104.475 191.663 104.273 192.871 104.321C198.935 104.327 201.967 108.875 201.209 114.938V114.939ZM176.954 122.521H173.164L166.342 105.085H169.374C169.956 105.14 170.497 105.41 170.89 105.843C172.406 108.875 175.438 118.729 175.438 118.729C175.438 118.729 179.228 108.875 179.986 105.843C179.986 105.085 181.502 105.085 181.502 105.085H184.534L176.954 122.521ZM156.491 108.121C155.79 108.103 155.092 108.228 154.441 108.488C153.789 108.749 153.198 109.138 152.701 109.634C152.205 110.129 151.814 110.72 151.553 111.371C151.292 112.022 151.166 112.72 151.182 113.421C151.182 116.453 153.456 119.485 156.488 119.485C159.52 119.485 161.791 117.212 161.791 113.421C161.807 112.72 161.681 112.024 161.421 111.374C161.16 110.723 160.77 110.132 160.275 109.637C159.779 109.142 159.189 108.752 158.538 108.491C157.888 108.231 157.191 108.105 156.491 108.121ZM156.491 122.521C155.283 122.569 154.078 122.367 152.952 121.927C151.826 121.486 150.804 120.818 149.949 119.963C149.094 119.108 148.425 118.086 147.985 116.96C147.545 115.834 147.343 114.629 147.391 113.421C147.343 112.213 147.545 111.008 147.985 109.882C148.425 108.756 149.094 107.734 149.949 106.879C150.804 106.024 151.826 105.355 152.952 104.915C154.078 104.475 155.283 104.273 156.491 104.321C157.699 104.273 158.904 104.475 160.03 104.915C161.156 105.355 162.178 106.024 163.033 106.879C163.888 107.734 164.557 108.756 164.997 109.882C165.437 111.008 165.639 112.213 165.591 113.421C165.639 114.629 165.437 115.834 164.997 116.96C164.557 118.086 163.888 119.108 163.033 119.963C162.178 120.818 161.156 121.486 160.03 121.927C158.904 122.367 157.699 122.569 156.491 122.521ZM132.991 121.763V99.0209H136.023C136.123 99.0187 136.223 99.0369 136.316 99.0742C136.409 99.1115 136.493 99.1673 136.564 99.2381C136.635 99.3089 136.69 99.3933 136.728 99.4863C136.765 99.5792 136.783 99.6788 136.781 99.7789V118.728H145.119C145.219 118.726 145.319 118.744 145.412 118.781C145.505 118.818 145.589 118.874 145.66 118.945C145.731 119.016 145.786 119.1 145.824 119.193C145.861 119.286 145.879 119.386 145.877 119.486V121.76L132.991 121.763Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M98.4441 2.758H133.311C135.806 2.73328 138.282 3.20652 140.592 4.15003C142.902 5.09354 145.001 6.48836 146.765 8.25287C148.53 10.0174 149.925 12.1161 150.869 14.4263C151.812 16.7365 152.286 19.2117 152.261 21.707V56.574C152.196 61.3785 150.258 65.9677 146.86 69.3653C143.463 72.7629 138.874 74.7005 134.069 74.766H97.6861C92.8818 74.7003 88.2928 72.7625 84.8954 69.365C81.498 65.9674 79.5605 61.3783 79.4951 56.574V21.707C79.4705 19.2117 79.9438 16.7366 80.8874 14.4265C81.8309 12.1164 83.2258 10.0177 84.9903 8.25323C86.7548 6.48873 88.8535 5.09389 91.1636 4.15032C93.4737 3.20675 95.9488 2.73341 98.4441 2.758Z" fill="#2DB84C"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M97.6861 2H134.069C138.873 2.06545 143.463 4.00298 146.86 7.40035C150.258 10.7977 152.195 15.3867 152.261 20.191V27.771C152.196 32.5755 150.258 37.1647 146.86 40.5623C143.463 43.9599 138.874 45.8975 134.069 45.963H97.6861C92.8818 45.8973 88.2928 43.9595 84.8955 40.562C81.4981 37.1644 79.5606 32.5753 79.4951 27.771V20.191C79.5608 15.3869 81.4985 10.798 84.8958 7.40069C88.2931 4.00336 92.882 2.06571 97.6861 2Z" fill="#E2ECE4"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M97.686 49.752C98.1917 49.7215 98.698 49.7986 99.1717 49.9783C99.6453 50.158 100.075 50.4361 100.434 50.7943C100.792 51.1525 101.07 51.5827 101.25 52.0563C101.429 52.5299 101.506 53.0363 101.476 53.542C101.506 54.0476 101.429 54.554 101.25 55.0276C101.07 55.5013 100.792 55.9314 100.434 56.2896C100.075 56.6478 99.6453 56.9259 99.1717 57.1056C98.698 57.2853 98.1917 57.3625 97.686 57.332C96.17 57.332 93.896 55.058 93.896 53.542C93.9595 52.5577 94.3792 51.6301 95.0767 50.9327C95.7742 50.2352 96.7017 49.8155 97.686 49.752Z" fill="#247936"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M116.636 53.542C122.7 53.542 128.006 57.332 128.006 62.642C128.006 67.952 122.7 71.742 116.636 71.742C110.572 71.742 105.266 67.942 105.266 62.642C105.266 57.342 110.572 53.542 116.636 53.542Z" fill="#70D185"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M110.572 60.364C111.163 60.3999 111.72 60.6512 112.138 61.0702C112.556 61.4893 112.807 62.0471 112.841 62.638C112.814 63.0313 112.646 63.4016 112.367 63.6803C112.089 63.9591 111.718 64.1273 111.325 64.154C110.932 64.1273 110.562 63.9591 110.283 63.6803C110.004 63.4016 109.836 63.0313 109.809 62.638C109.666 62.4496 109.572 62.2292 109.534 61.9959C109.496 61.7626 109.515 61.5236 109.59 61.2995C109.665 61.0754 109.794 60.8731 109.965 60.71C110.136 60.547 110.345 60.4282 110.572 60.364Z" fill="#009421"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M99.9601 31.561H133.311C133.704 31.5877 134.075 31.756 134.353 32.0347C134.632 32.3135 134.8 32.6837 134.827 33.077C134.8 33.4703 134.632 33.8406 134.353 34.1194C134.075 34.3981 133.704 34.5664 133.311 34.593H99.9601C99.5668 34.5664 99.1965 34.3981 98.9178 34.1194C98.639 33.8406 98.4708 33.4703 98.4441 33.077C97.6861 32.319 98.4441 31.561 99.9601 31.561Z" fill="#006015"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M116.636 9.57996C118.527 9.57891 120.4 9.95066 122.148 10.674C123.896 11.3972 125.483 12.4579 126.821 13.7953C128.158 15.1326 129.219 16.7205 129.942 18.4681C130.665 20.2156 131.037 22.0886 131.036 23.98C130.958 27.9742 129.327 31.7808 126.488 34.592H106.782C103.75 31.56 101.476 27.77 101.476 23.222C101.476 15.644 108.298 9.57996 116.636 9.57996Z" fill="#FFCE00"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M118.91 13.37V32.319H113.604V22.47C113.604 20.954 112.846 21.712 112.846 21.712H110.572V20.95C113.279 19.1643 115.816 17.1345 118.152 14.886C118.152 13.37 118.91 13.37 118.91 13.37Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M121.941 60.364C122.334 60.3907 122.705 60.559 122.983 60.8377C123.262 61.1164 123.43 61.4867 123.457 61.88C123.43 62.2733 123.262 62.6436 122.983 62.9223C122.705 63.2011 122.334 63.3693 121.941 63.396C121.548 63.3693 121.177 63.2011 120.899 62.9223C120.62 62.6436 120.452 62.2733 120.425 61.88C119.667 61.88 120.425 60.364 121.941 60.364Z" fill="#009421"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M134.827 49.752C135.333 49.7215 135.839 49.7986 136.313 49.9783C136.786 50.158 137.216 50.4361 137.575 50.7943C137.933 51.1525 138.211 51.5827 138.391 52.0563C138.57 52.5299 138.647 53.0363 138.617 53.542C138.647 54.0476 138.57 54.554 138.391 55.0276C138.211 55.5013 137.933 55.9314 137.575 56.2896C137.216 56.6478 136.786 56.9259 136.313 57.1056C135.839 57.2853 135.333 57.3625 134.827 57.332C134.321 57.3625 133.815 57.2853 133.341 57.1056C132.868 56.9259 132.438 56.6478 132.079 56.2896C131.721 55.9314 131.443 55.5013 131.263 55.0276C131.084 54.554 131.007 54.0476 131.037 53.542C131.007 53.0363 131.084 52.5299 131.263 52.0563C131.443 51.5827 131.721 51.1525 132.079 50.7943C132.438 50.4361 132.868 50.158 133.341 49.9783C133.815 49.7986 134.321 49.7215 134.827 49.752Z" fill="#247936"/>
</g>
<defs>
<clipPath id="clip0_10_135">
<rect width="230" height="130" fill="white" transform="translate(0.5)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 11 KiB

+9
View File
@@ -0,0 +1,9 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<rect width="24" height="24" transform="translate(-0.000244141)" fill="url(#pattern0)"/>
<defs>
<pattern id="pattern0" patternContentUnits="objectBoundingBox" width="1" height="1">
<use xlink:href="#image0_6_360" transform="scale(0.0104167)"/>
</pattern>
<image id="image0_6_360" width="96" height="96" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGAAAABgCAYAAADimHc4AAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyRpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuMy1jMDExIDY2LjE0NTY2MSwgMjAxMi8wMi8wNi0xNDo1NjoyNyAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENTNiAoTWFjaW50b3NoKSIgeG1wTU06SW5zdGFuY2VJRD0ieG1wLmlpZDo3MEQ3NUZEQjYzQUExMUUzQkVDNjhFN0I2MTY3RDVDQyIgeG1wTU06RG9jdW1lbnRJRD0ieG1wLmRpZDo3MEQ3NUZEQzYzQUExMUUzQkVDNjhFN0I2MTY3RDVDQyI+IDx4bXBNTTpEZXJpdmVkRnJvbSBzdFJlZjppbnN0YW5jZUlEPSJ4bXAuaWlkOjcwRDc1RkQ5NjNBQTExRTNCRUM2OEU3QjYxNjdENUNDIiBzdFJlZjpkb2N1bWVudElEPSJ4bXAuZGlkOjcwRDc1RkRBNjNBQTExRTNCRUM2OEU3QjYxNjdENUNDIi8+IDwvcmRmOkRlc2NyaXB0aW9uPiA8L3JkZjpSREY+IDwveDp4bXBtZXRhPiA8P3hwYWNrZXQgZW5kPSJyIj8+0DRG0gAAB+NJREFUeNrsXUtsVVUUPbWkPyj9QAVa0ocpbWisBmnAAEaiDkBBEwYkFFAYKokkBh3iwKnGX02cmBAREcOAgYI4UENiNYgPKq1pUiG2DZ8S6I/SDw1N3Yu3i0rad8/97HvPfe+sZKV56X337bPX+e7zy5mamlIW0eEh6wIrgBXAwgpgBbCIBnP8viCZTErat4z4BPFRYi1/riAuIBYSi4kTxNvEQeIwcYjYRfyL+CfxPH8WQWNjY7QCBIxK4nPEZ4jPEhMa38kjljOn8fQDz3QTfyL+SPyBeDVjSkAAKCPuJG4nrhWqFiHkHiYGPr8QjxK/JA5kaxvwGPEg8Qqxmbg+JHty+Lea+bcPsi1ZIwAqzW+If3COLIwwExSyDbDlW7YtYwWoJh4hniVu4ZxoCmDLZrbtCNuaMQKgndlP7CA2Geb4mYRoYlv3h9FGSguwnNhCfI9YFKPueRHb3MLd31gKsIv74GtiPE6C7ec4LbERIJf4EfEL4rwMGKzO47R8zGkzWoAC4jHivgyMGrzOaSswVYBS4iniVpW52MppLDVNgDIe6m9QmY8NnNYyUwQoIX5HXKmyBys5zSWRCpBMJtEoHSI+qbIPSPNh9kFkJeB94ksqe7GFfeB95Od1VQQp/zLn/tDQ19en7t69q4aHh9XQ0NCMzyxdujTVKJWVqby8vLBM293Y2HgoNAHI+XU8QJkrnbLR0VF18+ZNdePGDdffzc/PVxUVFWrRokXSZo4QV5EIneICcJ33K3G1ZIomJydVT0+P6u/v9/0uCJFIJFRxcbGkyQjkrSURJqXbgFelnY8qpr29PRDnA3fu3FGdnZ3q6lXRiTD45DXREkC5f7FKRQpLpVKBer6rq0vMS6iSqqvFos2Yl66nUtArVQLelXT+4OCgqPMBtCWCJaGUfRR8CaDcv4r+/K6E4vlobDs6OkLrUdXV1Um1CXDoaioFWstF3Ew4vKMEJ1PQ4GoNu0tK7jnuwZ7N9evX1cDAgBoZGdF6T3d3t2poaJBISg77anNgJYBy/0rudooIoFvv19TUqNLS9DUghLh8+bLW7+q8z0cpQLe0Nag24A3J3H/t2jXHZ+rr67WchZIxPRhzAkqMEHLYZ/4bYcr9iPptk+xyopuYDnBoUZH+jCZEKC8vd3zu1q1bks3MNvad7xKwQwkuHYEAaRupOXM8jWSrqqocn0FYA42/EOCznUEI0CSZTZwGW+i3ewHiQHPnOkdKxsbGJJO33ZcAVISQjdZJhhucqh8/jaROtYVSIIh15MNKPyXgecnGV6f4u6n7Z4oBRQz47gU/AmyStG5iYsJ0BwaBTX4EWC9pmVPxLyjwtwABVZwBWO9pJEx1Vw39WSxpGaqXdH129ID8QKebGUIpWwxf0qDskttQxOPSliGkIBWjR/WmE5YQniP4ry8vua2CGlSM0dvrHBFGNzU3NzcMcxq8tAHL4+p8hLV1pjAxbxwSlnsRIBFH56Nri0inE9C+LFy4MCyzEq4bYYRU4uZ8hDUw9ajlkUQirOpHpevMpBNgQZyc7yYMvWTJEqkw9Gwo9yJAQVycjylGnZA2gNhSZWVl2CYWehGgOA7Ox0ya7pohjDlCWCM0E+Z5aYQn4pDzY+B85WkkrFLb/o1tB9DV1K12BCfgdXE7owRAjEe3q1lbW+srohoQxrxUQcOm5n6sFXUK5BnkfKDfiwB9pgqgU++jn2+I8+9FRrwI0GOi8zHSdZpFi6Cf74RuLwJcNHW061T1GNjbuehFgHYTBXDK/QiwhRhi0EW7FwEumCiA0zTm/PnzTTT7gmsBeAan17SUjI+Pp/1/xP39GRvg2WbDnEoA0KJiBNT/BlY/aX3oJMApEwdhs8FA5zv60EkAbEY26nBp4YVUQQO+O5nuAcfl6clk8mclvDwlg9FC9f9TfkoAcNT60TO+dnpARwAc7Thmfeka8Nlh3wJQEcIuhmPWn65xjH3nuwQAH5jWGMeg8f1Q50E3uyRPKIeVvtIj4La2NsfnhPcB6+Ik5X6tTXpu9gkfsKVAO/e/rfuwtgCk6DlukC3S44juHmG3JQB4S6W241vMDPjmTTdfcCUAn4FwwPp59mrazTkRXkoA8KlKHc1i8X+cZd8oUQH4PBycJDtifX4f8MUut2cFeS0Bik+G2mv9fh97vZyW5VkAFgFnpDVb36tmr+fFAX6PZ8d5CMuIL0qnEhuv/V6YI4ATSvNMCN8j4TQjZBxe+r3KvrNDzxA3UqYY8vOSIE7OhQHY0N2aRc5v5TQP+X1RUGdHI+qHq6dOZ4HzT3NaAznrJsjT0zEKxK7w4xns/OOcxsCiAUHfH4A1I9sytHfUzGkbD/KlEjdoYDCCCxxeyZDB2ginZR+nTZkuwDRw7QdOWvwt5uGFVZwWEUjfooTRIVZUIIoap3nlMbZ5HadBxVUAAAt5cCXUCuJXyuxJnSm2cQXbLL4IKcyb9LDfAOfPreERpGk4ybbtUCHujYjiLkmcvouLD3BP8OcRV03o0RxiWzazbaEiyttUMZrcQ8SBQbgiqiWk6mmKfwu/iTPxdkc5ijfhPmFsYPuEWcVD/I1ELOkL6sAozFJhiSViVljvesWUes+0G7XhmM+YwCPq3yvNceTLMuLDKrV9FmdSTu/CG+X+OjYWYgff3yp1QBJ2ppznz0bCdzTUIr5tgIUVwApgBbAusAJkNf4RYAA6uUAYvUKJwwAAAABJRU5ErkJggg=="/>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4.3 KiB

+289
View File
@@ -0,0 +1,289 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="shortcut icon" href="../assets/images/favicon.ico" />
<link rel="stylesheet" href="../styles/_main.scss" />
<title>Money Lover Web</title>
</head>
<body class="home-page">
<!-- Add a wallet form -->
<dialog class="dialog" id="walletDialog">
<div class="dialog__add-wallet">
<!-- Dialog header -->
<div class="dialog__header">
<p class="dialog__title">Add a wallet first!</p>
</div>
<!-- Dialog body -->
<div class="dialog__body">
<form class="form" id="walletForm">
<div class="form__input-container">
<div class="form__input-field">
<p class="form__label">Wallet name</p>
<input
type="text"
class="form__input-text"
placeholder="Your wallet name?"
name="walletName"
minlength="3"
/>
</div>
</div>
<div class="form__currency-balance">
<div class="form__input-field">
<p class="form__label">Currency</p>
<div class="form__wrap-text-icon">
<img
src="../assets/images/dollar-icon.svg"
alt="Dollar icon"
/>
<p class="form__text-currency">United States Dollar</p>
</div>
</div>
<div class="form__input-container">
<div class="form__input-field">
<p class="form__label">Initial Balance</p>
<input
type="number"
class="form__input-balance"
name="amount"
/>
</div>
</div>
</div>
<button type="submit" class="form__save-btn">SAVE</button>
</form>
</div>
</div>
</dialog>
<!-- End form -->
<!-- Add budget form -->
<dialog class="dialog" id="budgetDialog">
<div class="dialog__add-budget">
<!-- Header -->
<div class="dialog__header">
<p class="dialog__title">Add budget</p>
</div>
<!-- Body -->
<div class="dialog__body">
<form class="form" id="formAddBudget">
<div class="form__date-amount-input">
<div class="form__input-container">
<div class="form__input-field">
<p class="form__label">Date</p>
<div class="date-input-container">
<input
class="input-date"
type="date"
name="selected_date"
/>
<div class="icon-btn"></div>
</div>
</div>
</div>
<div class="form__input-container">
<div class="form__input-field">
<p class="form__label">Amount</p>
<input
class="form__input-balance"
type="number"
min="1"
name="amount"
/>
</div>
</div>
</div>
<div class="form__note-input">
<p class="form__label">Note</p>
<input
class="form__input-text"
type="text"
name="note"
placeholder="Note"
/>
</div>
<div class="form__action">
<button class="form__cancel-btn" type="button">CANCEL</button>
<button type="submit" class="form__save-btn">SAVE</button>
</div>
</form>
</div>
</div>
</dialog>
<!-- End form -->
<!-- Add, edit, delete transaction form -->
<dialog class="dialog" id="transactionDialog">
<div class="dialog__add-transaction">
<!-- Header-->
<div class="dialog__header">
<p class="dialog__title">Add transaction</p>
<button type="button" class="form__delete-btn">DELETE</button>
</div>
<!-- Body -->
<div class="dialog__body">
<form class="form" id="formAddTransaction">
<input type="text" name="id_transaction" hidden />
<div class="form__date-category-amount-input">
<div class="form__input-container">
<div class="form__input-field">
<p class="form__label">Date</p>
<div class="date-input-container">
<input
class="input-date"
type="date"
name="selected_date"
/>
<span class="icon-btn"></span>
</div>
</div>
</div>
<div class="form__input-container">
<div class="form__input-field" id="selectCategory">
<p class="form__label">Category</p>
<div class="category-input-container">
<img
class="category-icon"
src="../assets/images/question-icon.svg"
alt="Question icon"
/>
<input
type="text"
name="category_name"
class="category-name"
placeholder="Select Category"
/>
<div class="icon-btn"></div>
</div>
</div>
</div>
<div class="form__input-container">
<div class="form__input-field">
<p class="form__label">Amount</p>
<input
class="form__input-balance"
type="number"
name="amount"
value="0"
/>
</div>
</div>
</div>
<div class="form__note-input">
<p class="form__label">Note</p>
<input
class="form__input-text"
type="text"
name="note"
placeholder="Note"
/>
</div>
<div class="form__action">
<button type="button" class="form__cancel-btn">CANCEL</button>
<button type="submit" class="form__save-btn">SAVE</button>
</div>
</form>
</div>
</div>
</dialog>
<!-- End form -->
<!-- Select category form -->
<dialog class="dialog" id="categoryDialog">
<div class="dialog__select-category">
<div class="dialog__header">
<div class="dialog__title-container">
<div formmethod="dialog" class="close-icon"></div>
<div class="dialog__title">Select category</div>
</div>
<form class="form">
<div class="form__search-container">
<input
type="text"
name="category"
class="form__category-input-field"
placeholder="Search"
/>
<button type="submit" class="form__search-btn">
<img src="../assets/images/icon-search.svg" alt="Search icon" />
</button>
</div>
</form>
</div>
<div class="dialog__body">
<div class="list-category"></div>
</div>
</div>
</dialog>
<!-- End form -->
<!-- Start header -->
<header class="header">
<div class="wrapper">
<div class="header__content">
<div class="header__left-content">
<div class="wallet">
<div class="wallet__icon">
<img src="../assets/images/icon.png" alt="Wallet Icon" />
</div>
<div class="wallet__info">
<p class="wallet__name">User</p>
<div class="wallet__dialog">
<p class="logout-text">Log Out</p>
</div>
<p class="wallet__price">+$ 0.00</p>
</div>
</div>
<button class="header__add-btn" id="addBudget">Add budget</button>
</div>
<button class="header__add-btn" id="addTransaction">
Add transaction
</button>
</div>
</div>
</header>
<!-- End header -->
<div class="background">
<!-- Main app -->
<main class="main">
<div class="app">
<div class="app__container">
<!-- Tabs -->
<div class="app__tabs">
<button class="app__tab-item active">SUMMARY</button>
<button class="app__tab-item">TRANSACTION DETAILS</button>
<div class="app__line"></div>
</div>
<!-- Start content app -->
<div class="app__contents">
<div class="app__content-item active">
<!-- Content summary -->
<div class="summary">
<div class="inflow">
<p class="inflow__text">Inflow</p>
<p class="inflow__text--income">+$ 0.00</p>
</div>
<div class="outflow">
<p class="outflow__text">Outflow</p>
<p class="outflow__text--outcome">$ 0.00</p>
</div>
<div class="summary__line"></div>
<p class="summary__total">+$ 0.00</p>
</div>
<!-- End content summary -->
</div>
<div class="app__content-item">
<!-- Start content transaction -->
<div class="transaction">
<div class="transaction__list"></div>
</div>
<!-- End content transaction -->
</div>
</div>
<!-- End content app -->
</div>
</div>
</main>
<!-- End app -->
</div>
<script type="module" src="../ts/index.ts"></script>
</body>
</html>
+49
View File
@@ -0,0 +1,49 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="shortcut icon" href="../assets/images/favicon.ico" />
<link rel="stylesheet" href="../styles/_main.scss" />
<title>Login - Money Lover</title>
</head>
<body class="login-page">
<main>
<div class="background">
<div class="wrapper">
<img
class="logo-web"
src="../assets/images/logo.svg"
alt="Logo web"
/>
<!-- Log in form -->
<form action="#" class="form" method="post" id="loginForm">
<div class="form__container">
<h1 class="form__title">Log In</h1>
<p class="form__description">Using Money Lover account</p>
<input
type="text"
class="form__input"
placeholder="Email"
name="email"
/>
<input
type="password"
name="password"
class="form__input"
placeholder="Password"
/>
<button type="submit" class="form__submit-btn">Login</button>
<p class="form__redirect-signup-text">
Don't have an account?
<a href="/register" class="form__link">Register</a>
</p>
</div>
</form>
<!-- End form -->
</div>
</div>
</main>
<script type="module" src="../ts/index.ts"></script>
</body>
</html>
+62
View File
@@ -0,0 +1,62 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="shortcut icon" href="../assets/images/favicon.ico" />
<link rel="stylesheet" href="../styles/_main.scss" />
<title>Register - Money Lover</title>
</head>
<body class="register-page">
<main>
<div class="background">
<div class="wrapper">
<img
class="logo-web"
src="../assets/images/logo.svg"
alt="Logo web"
/>
<!-- Register form -->
<form action="#" class="form" method="post" id="registerForm">
<div class="form__container">
<h1 class="form__title">Register</h1>
<p class="form__description">Using Money Lover account</p>
<div class="form__input-container">
<input
type="text"
class="form__input"
placeholder="Email"
name="email"
/>
</div>
<div class="form__input-container">
<input
type="password"
name="password"
class="form__input"
placeholder="Password"
/>
</div>
<div class="form__input-container">
<input
type="password"
name="password_confirm"
class="form__input"
placeholder="Confirm Password"
/>
</div>
<button type="submit" class="form__submit-btn">Register</button>
<p class="form__redirect-signup-text">
Have you an account?
<a href="/login" class="form__link">Sign in</a>
</p>
</div>
</form>
<!-- End form -->
</div>
</div>
</main>
<script type="module" src="../ts/index.ts"></script>
</body>
</html>
+21
View File
@@ -0,0 +1,21 @@
// Abstracts
@import './abstracts/mixin';
@import './abstracts/variables';
// Bases
@import './bases/placeholder';
@import './bases/reset';
@import './bases/typography';
// Components
@import './components/dialog';
@import './components/icons';
@import './components/loader';
@import './components/toast-form';
// Layouts
@import './layouts/header';
// Pages
@import './pages/login-register';
@import './pages/index';
+27
View File
@@ -0,0 +1,27 @@
@mixin flex-layout(
$justify: null,
$align: null,
$direction: null,
$gap: null,
$wrap: null
) {
@if $justify != null {
justify-content: $justify;
}
@if $align != null {
align-items: $align;
}
@if $direction != null {
flex-direction: $direction;
}
@if $gap != null {
gap: $gap;
}
@if $wrap != null {
flex-wrap: $wrap;
}
}
+36
View File
@@ -0,0 +1,36 @@
$primary-font: 'Roboto';
// Color
$hover-light-primary-color: #cdecd3;
$light-primary-color: #00bc2a;
$primary-color: #2db84c;
$dark-primary-color: #00710f;
$primary-text-color: #000;
$secondary-text-color: #0000008a;
$secondary-light-text-color: #00000042;
$amount-outcome-color: #e51c23;
$amount-income-color: #039be5;
$background-input-color: #f5f5f5;
$dark-error-color: #ce1414;
$light-error-color: #ffc3c3;
$light-gray: #f4f4f4;
$gray: #dddddd;
$dark-gray: #00000042;
$dark-x-gray: #0000008a;
$white: #ffffff;
$black: #000000;
// Font size
$fs-2x-sm: 11px;
$fs-x-sm: 12px;
$fs-sm: 14px;
$fs-md: 16px;
$fs-lg: 17px;
$fs-x-lg: 20px;
$fs-2x-lg: 32px;
// Z-index
$zindex-lv-1: 1;
$zindex-lv-2: 2;
$zindex-lv-3: 3;
$zindex-lv-4: 4;
+59
View File
@@ -0,0 +1,59 @@
%margin-inline-auto {
margin-inline: auto;
}
%rounded-x-lg {
border-radius: 50px;
}
%rounded-lg {
border-radius: 10px;
}
%rounded {
border-radius: 8px;
}
%rounded-sm {
border-radius: 4px;
}
%d-flex {
display: flex;
}
%d-absolute {
position: absolute;
}
%base-btn {
border: none;
background: $light-primary-color;
color: $white;
text-transform: uppercase;
}
%active {
opacity: 1;
pointer-events: auto;
}
%input-field {
@extend %rounded;
padding: 6px 16px 13.5px 16px;
border: 1px solid $gray;
}
%base-layout-item {
@extend %d-flex;
@include flex-layout($justify: space-between);
padding: 8 0px;
}
%error-text {
align-self: flex-start;
padding-left: 5px;
color: $dark-error-color;
}
+142
View File
@@ -0,0 +1,142 @@
/* http://meyerweb.com/eric/tools/css/reset/
v2.0 | 20110126
License: none (public domain)
*/
html,
body,
div,
span,
applet,
object,
iframe,
h1,
h2,
h3,
h4,
h5,
h6,
p,
blockquote,
pre,
a,
abbr,
acronym,
address,
big,
cite,
code,
del,
dfn,
em,
img,
ins,
kbd,
q,
s,
samp,
small,
strike,
strong,
sub,
sup,
tt,
var,
b,
u,
i,
center,
dl,
dt,
dd,
ol,
ul,
li,
fieldset,
form,
label,
legend,
table,
caption,
tbody,
tfoot,
thead,
tr,
th,
td,
article,
aside,
canvas,
details,
embed,
figure,
figcaption,
footer,
header,
hgroup,
menu,
nav,
output,
ruby,
section,
summary,
time,
mark,
audio,
video {
margin: 0;
padding: 0;
border: 0;
vertical-align: baseline;
}
/* HTML5 display-role reset for older browsers */
article,
aside,
details,
figcaption,
figure,
footer,
header,
hgroup,
menu,
nav,
section {
display: block;
}
body {
line-height: 1;
}
ol,
ul {
list-style: none;
}
blockquote,
q {
quotes: none;
}
blockquote {
&:before,
&:after {
content: '';
content: none;
}
}
q {
&:before,
&:after {
content: '';
content: none;
}
}
table {
border-collapse: collapse;
border-spacing: 0;
}
+33
View File
@@ -0,0 +1,33 @@
$font-path: '../assets/fonts';
@font-face {
font-family: 'Roboto';
font-weight: 400;
src: url('#{$font-path}/roboto-regular.woff2') format('woff2'),
url('#{$font-path}/roboto-regular.ttf') format('truetype');
font-display: swap;
}
@font-face {
font-family: 'Roboto';
font-weight: 500;
src: url('#{$font-path}/roboto-medium.woff2') format('woff2'),
url('#{$font-path}/roboto-medium.ttf') format('truetype');
font-display: swap;
}
@font-face {
font-family: 'Roboto';
font-weight: 700;
src: url('#{$font-path}/roboto-bold.woff2') format('woff2'),
url('#{$font-path}/roboto-bold.ttf') format('truetype');
font-display: swap;
}
* {
font-family: $primary-font, sans-serif;
font-size: $fs-sm;
}
+28
View File
@@ -0,0 +1,28 @@
.dialog {
@extend %rounded-sm;
border: none;
background-color: $white;
padding: none;
transition: all 0.3s ease;
&__title {
font-size: $fs-x-lg;
font-weight: 500;
line-height: 20px;
}
&::backdrop {
background-color: rgba($color: #000000, $alpha: 0.2);
}
}
// Login page
.login-page,
.register-page {
.dialog {
@extend %rounded;
padding: 0px;
}
}
+61
View File
@@ -0,0 +1,61 @@
.mark {
position: relative;
display: inline-block;
vertical-align: top;
width: 40px;
height: 40px;
border: 2px solid #c4cbd2;
border-radius: 100px;
&::before,
&::after {
content: '';
display: block;
position: absolute;
}
&.success:before {
top: 16px;
left: 15px;
margin: -5px 0 0 -6px;
height: 10px;
width: 20px;
border: solid #39ca74;
border-width: 0 0 3px 3px;
-webkit-transform: rotate(-45deg);
-moz-transform: rotate(-45deg);
-ms-transform: rotate(-45deg);
-o-transform: rotate(-45deg);
transform: rotate(-45deg);
}
&.error {
&:before {
top: 6px;
left: 13px;
height: 10px;
width: 10px;
border: solid #c0392b;
border-width: 0 0 4px 4px;
-webkit-transform: rotate(-45deg);
-moz-transform: rotate(-45deg);
-ms-transform: rotate(-45deg);
-o-transform: rotate(-45deg);
transform: rotate(-45deg);
}
&:after {
top: 21px;
left: 13px;
height: 10px;
width: 10px;
border: solid #c0392b;
border-width: 0 0 4px 4px;
-webkit-transform: rotate(135deg);
-moz-transform: rotate(135deg);
-ms-transform: rotate(135deg);
-o-transform: rotate(135deg);
transform: rotate(135deg);
}
}
}
+39
View File
@@ -0,0 +1,39 @@
.loader {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: #00000066;
z-index: $zindex-lv-1;
transition:
opacity 0.75s,
visibility 0.75s;
&::after {
content: '';
width: 75px;
height: 75px;
border: 10px solid $gray;
border-top-color: $primary-color;
border-radius: 50%;
animation: loading 0.75s ease infinite;
}
&.hidden {
opacity: 0;
visibility: hidden;
}
}
@keyframes loading {
from {
transform: rotate(0turn);
}
to {
transform: rotate(1turn);
}
}
+32
View File
@@ -0,0 +1,32 @@
.toast {
@extend %d-flex;
@include flex-layout($direction: column, $align: center, $gap: 30px);
padding: 40px 50px;
&__title {
font-size: $fs-2x-lg;
}
&__message {
font-size: $fs-lg;
text-align: center;
}
&__redirect-btn {
color: $white;
font-size: $fs-md;
border: none;
border-radius: 10px;
padding: 15px 30px;
cursor: pointer;
}
}
.success .toast__redirect-btn {
background-color: $light-primary-color;
}
.error .toast__redirect-btn {
background-color: $dark-error-color;
}
+36
View File
@@ -0,0 +1,36 @@
.home-page {
.header {
position: fixed;
top: 0;
left: 0;
right: 0;
background-color: $white;
&__content {
@extend %d-flex;
@include flex-layout($justify: space-between, $align: center);
padding: 12px 8px;
}
&__left-content {
@extend %d-flex;
@include flex-layout($align: center, $gap: 12px);
}
&__add-btn {
@extend %base-btn, %rounded-sm;
height: 32px;
padding-inline: 12px;
font-weight: 500;
line-height: 16px;
transition: 0.2s;
cursor: pointer;
&:hover {
background: $dark-primary-color;
}
}
}
}
+702
View File
@@ -0,0 +1,702 @@
.home-page {
.background {
height: 100vh;
background: $gray;
overflow: auto;
}
.wrapper {
max-width: 1920px;
margin-inline: auto;
}
.main {
padding-top: 96px;
}
.wallet {
@extend %d-flex;
@include flex-layout($align: center, $gap: 8px);
min-width: 170px;
&__icon {
width: 40px;
height: 41px;
}
&__info {
position: relative;
}
&__dialog {
@extend %rounded-sm;
padding: 10px;
position: absolute;
background-color: $white;
border: 1px solid $black;
top: 17px;
left: 10px;
opacity: 0;
visibility: none;
transition: 0.3s;
&:hover {
visibility: visible;
opacity: 1;
}
}
&__name {
font-size: $fs-x-sm;
line-height: 21px;
&:hover + .wallet__dialog {
visibility: visible;
opacity: 1;
}
}
&__price {
line-height: 18px;
font-weight: 700;
}
}
.app {
border-radius: 4px;
background: $white;
box-shadow: 0px 3px 24px 0px rgba(0, 0, 0, 0.12);
margin-inline: auto;
width: 624px;
margin-bottom: 20px;
&__tabs {
padding-top: 20px;
text-align: center;
border-bottom: 1px solid $gray;
position: relative;
}
&__tab-item {
font-weight: 500;
color: $dark-x-gray;
background: none;
border: none;
padding: 15px 23.62px;
cursor: pointer;
&.active {
color: $primary-color;
}
}
&__content-item {
display: none;
animation: fade 0.5s ease;
&.active {
display: block;
}
}
&__line {
position: absolute;
top: 66px;
left: 152px;
width: 116px;
height: 2px;
background-color: $primary-color;
border-radius: 10px;
transition: all 0.3s ease-in-out;
}
}
.summary {
@extend %d-flex;
@include flex-layout($direction: column);
padding: 10px 16px 40px;
text-align: right;
&__line {
width: 150px;
height: 1px;
background-color: $gray;
align-self: flex-end;
}
&__total {
margin-top: 5px;
font-size: $fs-md;
line-height: 16px;
}
}
.inflow {
@extend %base-layout-item;
&__text {
line-height: 16px;
&--income {
font-size: $fs-md;
color: $amount-income-color;
}
}
}
.outflow {
@extend %base-layout-item;
&__text {
line-height: 16px;
&--outcome {
font-size: $fs-md;
color: $amount-outcome-color;
}
}
}
.transaction {
background: $light-gray;
&__line {
width: 100%;
height: 1px;
background-color: $gray;
}
&__list {
@extend %d-flex;
@include flex-layout(
$justify: space-between,
$gap: 32px,
$direction: column
);
}
&__item {
background-color: $white;
}
&__category {
@extend %d-flex;
@include flex-layout($justify: space-between, $align: center);
padding: 16px;
&-infor {
@extend %d-flex;
@include flex-layout($align: center, $gap: 9px);
}
&-icon-container {
width: 40px;
height: 40px;
}
&-name {
font-weight: 500;
line-height: 20px;
}
&-total {
font-size: $fs-lg;
line-height: 15px;
}
}
&__total {
font-size: $fs-x-sm;
color: $dark-x-gray;
line-height: 16px;
}
&__time {
@extend %d-flex;
@include flex-layout($justify: space-between, $align: center);
padding: 16px;
cursor: pointer;
&:hover {
background-color: $hover-light-primary-color;
}
}
&__details {
@extend %d-flex;
@include flex-layout($justify: space-between, $align: center, $gap: 12px);
}
&__day {
font-size: $fs-2x-lg;
line-height: 16px;
font-weight: 300;
}
&__date-time {
font-size: $fs-2x-sm;
line-height: 15px;
text-transform: capitalize;
}
&__note {
@extend .transaction__date-time;
color: $dark-gray;
}
&__outcome {
color: $amount-outcome-color;
}
&__income {
color: $amount-income-color;
}
}
.dialog {
// Add wallet dialog
&__add-wallet {
.dialog {
&__body {
padding: 17px 24px;
}
&__header {
border-bottom: 1px solid $gray;
}
&__title {
padding: 20px 24px 24px 24px;
}
}
.form {
&__save-btn {
margin-top: 31px;
align-self: flex-end;
}
&__currency-balance {
@extend %d-flex;
@include flex-layout($justify: space-between, $gap: 20px);
margin-top: 16px;
}
&__wrap-text-icon {
@extend %d-flex;
@include flex-layout($gap: 16px, $align: center);
margin-top: 6px;
width: 230px;
}
&__text-currency {
font-size: $fs-md;
line-height: 24px;
}
&__input-field {
height: fit-content;
}
}
}
// Add budget dialog
&__add-budget {
.dialog {
&__body {
padding: 24px 40px 0;
}
&__title {
padding: 20px 40px;
}
}
.form {
&__date-amount-input {
@extend %d-flex;
@include flex-layout($gap: 32px);
& .form__input-container {
width: 100%;
}
}
&__note-input {
@extend %input-field;
width: 768px;
margin-top: 24px;
}
&__input-text {
width: 100%;
}
&__cancel-btn {
width: 96px;
height: 36px;
}
&__save-btn {
width: 96px;
height: 36px;
}
}
}
// Add transaction dialog
&__add-transaction {
.dialog {
&__header {
@extend %d-flex;
@include flex-layout($justify: space-between, $align: center);
padding: 20px 40px;
}
&__body {
padding: 24px 40px 0;
}
}
.form {
&__date-category-amount-input {
@extend %d-flex;
@include flex-layout($justify: space-between, $gap: 24px);
}
&__input-field:first-child {
width: 255px;
}
&__input-field:nth-child(2),
&__input-field:nth-child(3) {
width: 240px;
}
&__note-input {
@extend %input-field;
margin-top: 24px;
}
&__save-btn {
width: 96px;
height: 36px;
}
}
}
// Select category dialog
&__select-category {
.dialog {
&__header {
padding-top: 15px;
position: relative;
border-bottom: 1px solid $gray;
}
&__title-container {
@extend %d-flex;
@include flex-layout($align: center, $gap: 28px);
padding-left: 24px;
}
&__title {
font-weight: 400;
}
}
.form {
padding: 10px 144px;
&__search-container {
@extend %d-flex, %rounded-x-lg;
@include flex-layout(
$justify: flex-end,
$gap: 16px,
$direction: row-reverse,
$align: center
);
padding-inline: 16px;
width: 208px;
height: 51px;
background-color: $light-gray;
}
&__category-input-field {
border: none;
background-color: transparent;
&:focus {
outline: none;
}
}
&__search-btn {
cursor: pointer;
border: none;
background-color: transparent;
}
}
}
}
// Common property
.form {
@extend %d-flex;
@include flex-layout($direction: column);
&__input-field {
@extend %rounded;
padding: 6px 16px 12px;
border: 1px solid $gray;
}
&__label {
color: $dark-x-gray;
font-size: $fs-x-sm;
line-height: 18px;
}
&__input-text {
margin-top: 7px;
font-size: $fs-lg;
width: 100%;
border: none;
background-color: transparent;
&:focus {
outline: none;
}
&::placeholder {
color: $gray;
}
}
&__input-balance {
font-size: $fs-lg;
line-height: 20px;
height: 28px;
border: none;
width: 100%;
background-color: transparent;
&:focus {
outline: none;
}
}
&__save-btn {
@extend %rounded-sm;
border: none;
background-color: $gray;
line-height: 16.1px;
font-weight: 500;
padding: 10px 20px;
color: $dark-gray;
cursor: pointer;
&.active {
background-color: $primary-color;
color: $white;
}
}
&__cancel-btn {
@extend .form__save-btn;
color: $primary-color !important;
margin-right: 16px;
transition: 0.3s;
&:hover {
background-color: $hover-light-primary-color;
}
}
&__delete-btn {
@extend .form__save-btn;
color: $dark-error-color !important;
transition: 0.3s;
&:hover {
background-color: $light-error-color;
}
}
&__action {
align-self: flex-end;
margin-top: 64px;
padding: 14px 0;
}
}
.date-input-container {
position: relative;
float: left;
width: 100%;
}
.input-date {
height: 30px;
width: 100%;
border: none;
font-size: $fs-lg;
background-color: transparent;
&:focus {
outline: none;
}
&::-webkit-calendar-picker-indicator {
background: transparent;
cursor: pointer;
}
}
.icon-btn {
position: absolute;
top: 10px;
right: 5px;
background-color: transparent;
border-top: 3px solid $dark-gray;
border-right: 3px solid $dark-gray;
border-bottom: none;
border-left: none;
height: 7px;
width: 7px;
rotate: 45deg;
pointer-events: none;
}
.category-input-container {
@extend %d-flex;
@include flex-layout($align: center, $gap: 8px);
position: relative;
margin-top: 4px;
& .category-icon {
height: 24px;
width: 24px;
}
}
.category-name {
font-size: $fs-lg;
line-height: 25.5px;
color: $black;
border: none;
caret-color: transparent;
background-color: transparent;
cursor: pointer;
&:focus {
outline: none;
}
}
.icon-category {
width: 40px;
height: 40px;
}
.list-category {
@extend %d-flex;
@include flex-layout($direction: column);
padding-top: 5px;
height: 451px;
overflow: auto;
}
.category-item {
@extend %d-flex;
@include flex-layout($align: center, $gap: 16px);
margin-inline: 68px;
padding: 8px 0;
border-bottom: 1px solid $gray;
transition: 0.3s;
cursor: pointer;
&:hover,
&.selected {
background-color: $hover-light-primary-color;
}
}
.logout-text {
font-size: $fs-x-sm;
cursor: pointer;
&:hover {
color: $primary-color;
}
}
.error-input {
background-color: $light-error-color;
border-color: $dark-error-color;
& .form__input-text::placeholder {
color: $white;
}
}
.error-text {
@extend %error-text;
}
.form__input-container {
@extend %d-flex;
@include flex-layout($direction: column, $gap: 7px);
}
}
.close-icon {
@extend .error, .mark;
border: none;
cursor: pointer;
&::after,
&::before {
border-color: $black;
border-width: 0 0 2px 2px;
}
}
.hide {
opacity: 0;
pointer-events: none;
}
@keyframes fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
#categoryForm {
z-index: $zindex-lv-4;
}
+100
View File
@@ -0,0 +1,100 @@
.login-page,
.register-page {
.background {
height: 100vh;
background: linear-gradient($dark-primary-color 314px, #ebebeb -100%);
}
.wrapper {
@extend %margin-inline-auto;
@extend %d-absolute;
top: 0;
left: 0;
right: 0;
text-align: center;
}
.logo-web {
padding-top: 54px;
}
.form {
@extend %margin-inline-auto;
margin-top: 34px;
padding-top: 34px;
padding-bottom: 40px;
background-color: $white;
width: 670px;
border-radius: 20px;
&__container {
@extend %margin-inline-auto;
display: flex;
flex-direction: column;
gap: 19px;
width: 280px;
}
&__title {
color: $primary-text-color;
font-size: $fs-2x-lg;
font-weight: 700;
line-height: 48px;
}
&__error-message {
font-size: $fs-md;
background-color: $light-error-color;
color: $dark-error-color;
padding: 15px 15px;
border-radius: 10px;
line-height: 22px;
list-style: inside;
}
&__description {
margin-top: 15px;
color: $secondary-text-color;
font-size: $fs-md;
line-height: 24px;
}
&__input {
@extend %rounded-lg;
font-size: $fs-md;
height: 52px;
border: 2px solid $background-input-color;
background-color: $background-input-color;
padding-inline: 14px;
&-container {
@extend %d-flex;
@include flex-layout($direction: column, $gap: 7px);
}
}
&__submit-btn {
@extend %rounded-lg, %base-btn;
height: 36px;
margin-top: 30px;
}
&__link {
color: $primary-color;
}
}
.error-input {
border: 2px solid $dark-error-color;
background-color: $light-error-color;
}
.error-text {
@extend %error-text;
}
}
+17
View File
@@ -0,0 +1,17 @@
import Controller from './controllers/index';
import Service from './services/index';
import View from './views/index';
export default class App {
controller: Controller;
constructor() {
this.controller = new Controller(new Service(), new View());
}
start() {
this.controller.registerController.init();
this.controller.loginController.init();
this.controller.homeController.init();
}
}
+66
View File
@@ -0,0 +1,66 @@
export const DATABASE_URL =
'https://javascript-training-81f7a-default-rtdb.asia-southeast1.firebasedatabase.app';
export const TIME_OUT_SEC = 3;
export const BTN_CONTENT = {
GOT_IT: 'Got it!',
OK: 'Ok',
};
export const LOCAL_STORAGE = {
ACCESS_TOKEN: 'accessToken',
};
export const TypeToast = {
success: 'success',
error: 'error',
};
export const MarkIcon = {
success: 'success',
error: 'error',
};
export const REGEX = {
PASSWORD:
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/,
EMAIL:
/^(([^<>()[\]\\.,;:\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,}))$/,
};
export const URL = {
LOGIN: 'login',
REGISTER: 'register',
HOME: '',
};
export const DEFAULT_CATEGORY = {
INCOME: 'Income',
};
export const REMOVE_CATEGORY = ['Income'];
export const DAY = [
'Sunday',
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday',
];
export const MONTH = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
];
+1
View File
@@ -0,0 +1 @@
export const FIRST_ADD_WALLET_NOTE: string = 'Init Wallet';
+23
View File
@@ -0,0 +1,23 @@
export const VALIDATE_FORM = {
INVALID_EMAIL_FORMAT: `The email is not correct!`,
PASSWORD_NOT_MATCH: 'Password not match!',
PASSWORD_NOT_STRONG:
'Password must at least one uppercase, one lowercase letter, one number and one special character!',
ERROR_CREDENTIAL: {
title: 'Error Credential',
message: 'Email or password not match! Please try again!',
},
REQUIRED_MESSAGE: (field: string) => `The ${field} is required!`,
};
export const TOAST = {
ERROR_MESSAGE_DEFAULT: ['Something went wrong!'],
TIME_OUT_ERROR: 'Connection time out! Please try again!',
DEFAULT_TITLE_ERROR_TOAST: 'Error',
USER_EXIST_ERROR: 'User is exists! Please try another email!',
ADD_WALLET_SUCCESS: 'Add wallet success!',
DEFAULT_MESSAGE: 'Press OK to continue!',
ADD_TRANSACTION_SUCCESS: 'Add success!',
UPDATE_TRANSACTION_SUCCESS: 'Update success!',
REGISTER_SUCCESS: 'Register Success',
};
+65
View File
@@ -0,0 +1,65 @@
import HomeView from 'views/home/homeView';
import Service from 'services';
import Wallet from 'models/wallet';
import Transaction from 'models/transaction';
import Category from 'models/category';
import { IHomeFunc, Nullable } from 'global/types';
export default class HomeController {
constructor(
public service: Service,
public homeView: Nullable<HomeView>,
) {
this.service = service;
this.homeView = homeView;
}
handlerGetInfoUserLogin() {
return this.service.userService.getInfoUserLogin();
}
handlerGetWalletByIdUser(idUser: string): Promise<Wallet | null> {
return this.service.walletService.getWalletByIdUser(idUser);
}
handlerSaveWallet(wallet: Wallet): Promise<void> {
return this.service.walletService.saveWallet(wallet);
}
handlerSaveTransaction(transaction: Transaction): Promise<void> {
return this.service.transactionService.saveTransaction(transaction);
}
handlerGetAllCategory(): Promise<Category[] | null> {
return this.service.categoryService.getAllCategory();
}
handlerGetAllTransactions(idUser: string): Promise<Transaction[] | null> {
return this.service.transactionService.getListTransactionByIdUser(idUser);
}
handlerDeleteTransaction(idTransaction: string): Promise<void> {
return this.service.transactionService.deleteTransaction(idTransaction);
}
init() {
if (this.homeView) {
const func: IHomeFunc = {
getInfoUserLogin: this.handlerGetInfoUserLogin.bind(this),
getWalletByIdUser: this.handlerGetWalletByIdUser.bind(this),
getAllCategory: this.handlerGetAllCategory.bind(this),
getAllTransactions: this.handlerGetAllTransactions.bind(this),
saveWallet: this.handlerSaveWallet.bind(this),
saveTransaction: this.handlerSaveTransaction.bind(this),
deleteTransaction: this.handlerDeleteTransaction.bind(this),
};
this.homeView.initFunction(func);
this.homeView.loadPage();
// Subscribe listener data update
this.homeView.subscribeListenerData();
}
}
}
+25
View File
@@ -0,0 +1,25 @@
import Service from 'services';
import RegisterController from './registerController';
import View from 'views';
import LoginController from './loginController';
import HomeController from './homeController';
export default class Controller {
public registerController: RegisterController;
public loginController: LoginController;
public homeController: HomeController;
constructor(
public service: Service,
public view: View,
) {
this.registerController = new RegisterController(
service,
view.registerView,
);
this.loginController = new LoginController(service, view.loginView);
this.homeController = new HomeController(service, view.homeView);
}
}
+29
View File
@@ -0,0 +1,29 @@
import { Nullable } from 'global/types';
import User from 'models/user';
import Service from 'services';
import LoginView from 'views/loginView';
export default class LoginController {
constructor(
public service: Service,
public loginView: Nullable<LoginView>,
) {
this.service = service;
this.loginView = loginView;
}
handlerLoginUser(email: string, password: string): Promise<boolean> {
return this.service.userService.loginUser(email, password);
}
handlerGetInfoUserLogin(): Promise<User | null> {
return this.service.userService.getInfoUserLogin();
}
init() {
if (this.loginView) {
this.loginView.loadPage(this.handlerGetInfoUserLogin.bind(this));
this.loginView.addHandlerForm(this.handlerLoginUser.bind(this));
}
}
}
+37
View File
@@ -0,0 +1,37 @@
import { Nullable } from 'global/types';
import User from 'models/user';
import Service from 'services';
import RegisterView from 'views/registerView';
export default class RegisterController {
constructor(
public service: Service,
public registerView: Nullable<RegisterView>,
) {
this.service = service;
this.registerView = registerView;
}
handlerCheckUserValid(email: string): Promise<boolean> {
return this.service.userService.isValidUser(email);
}
handlerSaveUser(user: User): Promise<void> {
return this.service.userService.saveUser(user);
}
handlerGetInfoUserLogin(): Promise<User | null> {
return this.service.userService.getInfoUserLogin();
}
init() {
if (this.registerView) {
this.registerView.loadPage(this.handlerGetInfoUserLogin.bind(this));
this.registerView.addHandlerForm(
this.handlerCheckUserValid.bind(this),
this.handlerSaveUser.bind(this),
);
this.registerView.addHandlerInputFormChange();
}
}
}
+98
View File
@@ -0,0 +1,98 @@
import Category from 'models/category';
import Transaction from 'models/transaction';
import User from 'models/user';
import Wallet from 'models/wallet';
export interface IDataObject<T> {
id: string;
data: T;
}
export class DataObject<T> {
public id: string;
public data: T;
constructor(dataObject: IDataObject<T>) {
this.id = dataObject?.id ?? null;
this.data = <T>dataObject.data;
}
}
export interface IError {
title: string;
message: string;
}
export type TError = string | IError;
export class CustomError extends Error {
constructor(
public title: string,
message?: string,
) {
super(message);
}
}
export interface Data {
wallet?: Wallet;
listTransactions?: Transaction[];
listCategories?: Category[];
user?: User;
}
export interface ItemTransaction {
id: string;
day: string;
fullDateString: string;
note: string;
amount: number;
}
export type Nullable<T> = T | null;
export type PromiseVoid = () => Promise<void>;
export type VoidFunc = () => void;
export type PromiseOrNull<T> = () => Promise<T | null>;
export interface IHomeFunc {
getInfoUserLogin?: PromiseOrNull<User>;
getWalletByIdUser?: (idUser: string) => Promise<Wallet | null>;
getAllCategory?: PromiseOrNull<Category[]>;
getAllTransactions?: (idUser: string) => Promise<Transaction[] | null>;
saveWallet?: (wallet: Wallet) => Promise<void>;
saveTransaction?: (transaction: Transaction) => Promise<void>;
deleteTransaction?: (idTransaction: string) => Promise<void>;
}
export interface IBudgetViewFunc {
showErrorToast: (error: TError) => void;
showSuccessToast: (title: string, message: string) => void;
toggleLoaderSpinner: VoidFunc;
saveTransaction: Nullable<(transaction: Transaction) => Promise<void>>;
loadTransactionData: PromiseVoid;
updateAmountWallet: PromiseVoid;
loadData: PromiseVoid;
}
export interface ITransactionViewFunc {
toggleLoaderSpinner: VoidFunc;
deleteTransaction: (idTransaction: string) => Promise<void>;
loadTransactionData: PromiseVoid;
updateAmountWallet: PromiseVoid;
loadData: PromiseVoid;
showSuccessToast: (title: string, message: string) => void;
showErrorToast: (error: TError) => void;
saveTransaction: (transaction: Transaction) => Promise<void>;
}
export interface IWalletViewFunc {
toggleLoaderSpinner: () => void;
saveWallet: ((wallet: Wallet) => Promise<void>) | null;
saveTransaction: ((transaction: Transaction) => Promise<void>) | null;
loadTransactionData: () => Promise<void>;
loadData: () => Promise<void>;
loadEvent: () => void;
showSuccessToast: (title: string, message: string) => void;
showErrorToast: (error: TError) => void;
}
+33
View File
@@ -0,0 +1,33 @@
import { TOAST } from '../constants/messages';
import { TIME_OUT_SEC } from '../constants/config';
import FirebaseService from '../services/firebaseService';
/**
* A waiting function with s second
* @param {number} s The time will be waiting
* @returns {Promise} A Promise will be only reject after s second
*/
export const timeout = (s: number): Promise<string> => {
return new Promise((_, reject) => {
setTimeout(() => {
FirebaseService.disconnect();
reject(TOAST.TIME_OUT_ERROR);
}, s * 1000);
});
};
/**
* A function waiting the action need to be perform and throw error after s second.
* @param {Function} action The action need to be perform.
* @returns { Object || Error } Return the any object from Firebase or Error
*/
export const timeOutConnect = async <T>(
action: Promise<T>,
): Promise<string | T> => {
const result: T | string = await Promise.race([
action,
timeout(TIME_OUT_SEC),
]);
return result;
};
+125
View File
@@ -0,0 +1,125 @@
import localStorageService from 'services/localStorageService';
import { DataObject, IDataObject, ItemTransaction } from '../global/types';
import { DAY, LOCAL_STORAGE, MONTH } from 'constants/config';
import Transaction from 'models/transaction';
import TransactionDetail from 'models/transactionDetail';
import Category from 'models/category';
export const generateId = (): string => {
return new Date().getTime().toString();
};
export const convertDataObjectToModel = <T>(dataObj: DataObject<T>): T => {
const { id } = dataObj;
return { id, ...dataObj.data } as T;
};
export const convertModelToDataObject = <T>(model: T): DataObject<T> => {
const { id, ...data } = model as IDataObject<T>;
return { id, data } as DataObject<T>;
};
/**
* A function create token for user
* @returns {string} Return token string
*/
export const createToken = (): string => {
const lengthToken: number = 36;
const chars: string =
'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
let token: string = '';
for (let i: number = 0; i < lengthToken; i += 1) {
token += chars[Math.floor(Math.random() * chars.length)];
}
return token;
};
export const formatNumber = (number: number): string => {
return number.toLocaleString(undefined, {
minimumFractionDigits: 2,
});
};
export const changeDateFormat = (oldFormatDate: string) => {
const tempDate = new Date(oldFormatDate);
const day = DAY[tempDate.getDay()];
const date = tempDate.getDate();
const month = MONTH[tempDate.getMonth()];
const year = tempDate.getFullYear();
return `${day}, ${date}, ${month}, ${year}`;
};
export const clearAccessToken = (): void => {
localStorageService.remove(LOCAL_STORAGE.ACCESS_TOKEN);
};
export const getAllCategoryNameInTransactions = (
transactions: Transaction[],
): string[] => {
const categoryName = new Set();
transactions.forEach((transaction) =>
categoryName.add(transaction.categoryName),
);
return Array.from(categoryName) as string[];
};
export const getAllTransactionByCategoryName = (
categoryName: string,
transactions: Transaction[],
) => {
const results = transactions.filter((transaction) => {
return transaction.categoryName === categoryName;
});
return results;
};
export const createTransactionDetailObject = (
category: Category,
transactions: Transaction[],
): TransactionDetail => {
const totalTransaction = transactions.length;
const totalAmount = () => {
let amount = 0;
transactions.forEach((transaction) => {
amount += transaction.amount;
});
return amount;
};
const listTransaction = (): ItemTransaction[] => {
const results: ItemTransaction[] = transactions.map((transaction) => {
const dateParts = changeDateFormat(transaction.date).split(','); // ['Monday', '14', 'September', '2023']
const day = dateParts[1];
const fullDateString = `${dateParts[0]}, ${dateParts[2]} ${dateParts[3]}`;
const tempData: ItemTransaction = {
id: transaction.id,
day,
fullDateString,
note: transaction.note,
amount: transaction.amount,
};
return tempData;
});
results.sort((a, b) => parseInt(b.id) - parseInt(a.id));
return results;
};
return new TransactionDetail(
category.name,
category.url,
totalTransaction,
totalAmount(),
listTransaction(),
);
};
+46
View File
@@ -0,0 +1,46 @@
import { Data } from 'global/types';
type TSignal = {
[key: string]: {
name: string;
handler: (value: Data) => void;
};
};
export default class EventDataTrigger {
private static _instance: EventDataTrigger;
public signal: TSignal;
constructor() {
this.signal = {};
}
public static get Instance() {
return this._instance || (this._instance = new this());
}
onSendSignal(fromClass: string, value: Data) {
Object.keys(this.signal).forEach((key) => {
const item = this.signal[key as keyof TSignal];
// Same receiver
if (item.name === fromClass) {
return;
}
// Send signal
const { handler } = this.signal[key];
if (handler) {
handler(value);
}
});
}
create(
className: string,
handler: ((value: Data) => void) | null = null,
): void {
if (handler) this.signal[className] = { name: className, handler };
}
}
+16
View File
@@ -0,0 +1,16 @@
export const redirectToLoginPage = () => {
window.location.replace('/login');
};
export const getSubdirectoryURL = (): string => {
const url = window.location.href;
const parts = url.split('/'); // Results: ['http:', '', 'example.com', '']
const subDirectory = parts[3]; // Get subdirectory url only
const index = subDirectory.indexOf('?'); // Remove query behind subDirectory
if (index !== -1) {
return subDirectory.substring(0, index);
}
return subDirectory;
};
+30
View File
@@ -0,0 +1,30 @@
import { REGEX } from '../constants/config';
import { VALIDATE_FORM } from '../constants/messages';
/**
* Validate password
* @param {string} password Password input
* @returns {boolean} Return true if validate password success, otherwise return false
*/
export const isValidPassword = (password: string): boolean => {
return REGEX.PASSWORD.test(password);
};
export const isValidateEmail = (email: string): RegExpMatchArray | null => {
return String(email).toLowerCase().match(REGEX.EMAIL);
};
export const compare2Password = (
password: string,
passwordConfirm: string,
): boolean => {
return password === passwordConfirm;
};
export const renderRequiredText = (field: string, element: Element) => {
const markup: string = `
<p class="error-text">${VALIDATE_FORM.REQUIRED_MESSAGE(field)}</p>
`;
element.insertAdjacentHTML('afterend', markup);
};
+8
View File
@@ -0,0 +1,8 @@
import App from './app';
// Sure that scripts called after DOM loaded
document.addEventListener('DOMContentLoaded', () => {
const myApp = new App();
myApp.start();
});
+11
View File
@@ -0,0 +1,11 @@
export default class Category {
constructor(
public id: string,
public url: string,
public name: string,
) {
this.id = id;
this.url = url;
this.name = name;
}
}
+19
View File
@@ -0,0 +1,19 @@
import { generateId } from 'helpers/data';
export default class Transaction {
constructor(
public id: string,
public categoryName: string,
public date: string,
public note: string,
public amount: number,
public idUser: string,
) {
this.id = id ? id : generateId();
this.categoryName = categoryName;
this.date = date;
this.note = note;
this.amount = amount;
this.idUser = idUser;
}
}
+17
View File
@@ -0,0 +1,17 @@
import { ItemTransaction } from 'global/types';
export default class TransactionDetail {
constructor(
public categoryName: string,
public url: string,
public totalTransaction: number,
public totalAmount: number,
public transactions: ItemTransaction[],
) {
this.categoryName = categoryName;
this.url = url;
this.totalTransaction = totalTransaction;
this.totalAmount = totalAmount;
this.transactions = transactions;
}
}
+15
View File
@@ -0,0 +1,15 @@
import { generateId } from '../helpers/data';
export default class User {
constructor(
public id: string,
public email: string,
public password: string,
public accessToken?: string,
) {
this.id = id ? id : generateId();
this.email = email;
this.password = password || '';
this.accessToken = accessToken || '';
}
}
+17
View File
@@ -0,0 +1,17 @@
import { generateId } from '../helpers/data';
export default class Wallet {
constructor(
public id: string,
public walletName: string,
public inflow: number,
public outflow: number,
public idUser: string,
) {
this.id = id ? id : generateId();
this.walletName = walletName;
this.inflow = inflow;
this.outflow = outflow;
this.idUser = idUser;
}
}
+32
View File
@@ -0,0 +1,32 @@
import CommonService from './commonService';
import Category from '../models/category';
export default class CategoryService extends CommonService<Category> {
constructor() {
super();
this.defaultPath = 'categories/';
}
async getAllCategory() {
const data = await this.getAllDataFromPath(this.defaultPath);
if (data) {
return data.reverse().map((category): Category => category);
}
return null;
}
async getCategoryByName(nameCategory: string) {
const data = await this.getDataFromProp(
'name',
nameCategory,
this.defaultPath,
);
if (data) return data;
return null;
}
}
+131
View File
@@ -0,0 +1,131 @@
import { timeOutConnect } from '../helpers/connect';
import {
convertDataObjectToModel,
convertModelToDataObject,
} from '../helpers/data';
import { DataObject } from '../global/types';
import FirebaseService from './firebaseService';
export default class CommonService<T> {
public defaultPath: string;
public firebaseService = FirebaseService;
constructor(defaultPath?: string) {
this.defaultPath = defaultPath ? defaultPath : '/';
}
/**
* Connect to Firebase Database
*/
connectToDb() {
this.firebaseService.reconnect();
}
/**
* Save data on database
* @param {*} data The data wants to save on database
* @param {string} path The path of database
*/
async save(model: T): Promise<void> {
this.connectToDb();
const results: DataObject<T> = convertModelToDataObject(model);
const saveData = this.firebaseService.save(
results.data as object,
this.defaultPath + results.id,
);
await timeOutConnect(saveData);
}
/**
*
* @param {string} id The string of data object
* @param {string} path The path of database
* @returns {Object || null} Return the object if has, otherwise return null
*/
async getDataFromId(
id: string,
path: string = this.defaultPath,
): Promise<object | null> {
this.connectToDb();
const data = await timeOutConnect(
this.firebaseService.getDataFromId(id, path),
);
if (typeof data === 'object') return data;
return null;
}
async getDataFromProp(
property: string,
value: string,
path = this.defaultPath,
) {
this.connectToDb();
const data = this.firebaseService.getDataFromProp(path, property, value);
const result = <DataObject<T>>await timeOutConnect(data);
if (result && typeof result === 'object') {
return convertDataObjectToModel(result);
}
return null;
}
async getAllDataFromPath(path = this.defaultPath): Promise<T[] | null> {
this.connectToDb();
const results = await timeOutConnect(
this.firebaseService.getAllDataFromPath(path),
);
if (typeof results === 'object') {
// Convert format object
return results.map((data) => {
const tempData = data as DataObject<T>;
return convertDataObjectToModel(tempData);
}) as T[];
}
return null;
}
async getListDataFromProp(
property: string,
value: string | number,
path: string = this.defaultPath,
): Promise<T[] | null> {
this.connectToDb();
const results = await timeOutConnect(
this.firebaseService.getListDataFromProp(path, property, value),
);
if (results && typeof results === 'object') {
// Convert format object
return results.map((data) => {
const tempData = data as DataObject<T>;
return convertDataObjectToModel(tempData);
}) as T[];
}
return null;
}
async deleteData(
id: string,
path = this.defaultPath,
): Promise<string | void> {
this.connectToDb();
await timeOutConnect(this.firebaseService.delete(id, path));
}
}
+179
View File
@@ -0,0 +1,179 @@
import { FirebaseApp, initializeApp } from 'firebase/app';
import {
getDatabase,
ref,
set,
goOffline,
goOnline,
onValue,
remove,
Database,
} from 'firebase/database';
import { DATABASE_URL } from '../constants/config';
class FirebaseService {
app: FirebaseApp;
db: Database;
constructor() {
const firebaseConfig = {
databaseURL: DATABASE_URL,
};
this.app = initializeApp(firebaseConfig);
this.db = getDatabase(this.app);
}
/**
* Save data in database
* @param {Object} data The object need to save into database
* @param {string} path The path of database need to be save
* @returns {Promise} Return the resolves when write to database completed
*/
save(data: object, path: string): Promise<void> {
return set(ref(this.db, path), data);
}
delete(id: string, path: string): Promise<void> {
return remove(ref(this.db, path + id));
}
/**
* Disconnect to database
*/
disconnect(): void {
goOffline(this.db);
}
/**
* Reconnect to database
*/
reconnect(): void {
goOnline(this.db);
}
/**
* Find id of value by property in database
* @param {string} path The path of database to be found
* @param {string} property The property of the value need to be found
* @param {value} value The value to compare in database
* @returns {Promise} Return the resolve when find completed
*/
getDataFromProp(
path: string,
property: string,
value: string,
): Promise<object | null> {
return new Promise((resolve) => {
onValue(
ref(this.db, path),
(snapshot) => {
let id: string | null = null;
let data: object | null = null;
let result: object | null = null;
// snapshot is a type of data by Firebase define
snapshot.forEach((childSnapshot) => {
const dataTemp = childSnapshot.val();
if (dataTemp[property] === value) {
id = childSnapshot.key;
data = dataTemp;
}
});
if (id && data) {
result = { id, data };
}
resolve(result);
},
{
onlyOnce: true,
},
);
});
}
/**
* Get data object from Id
* @param {string} id The id of data object
* @param {string} path The path of data save in database
* @returns {Promise} Return new Promise
*/
getDataFromId(id: string, path: string): Promise<object> {
return new Promise((resolve) => {
onValue(
ref(this.db, path + id),
(snapshot) => {
resolve(snapshot.val());
},
{
onlyOnce: true,
},
);
});
}
getAllDataFromPath(path: string): Promise<object[]> {
return new Promise((resolve) => {
onValue(
ref(this.db, path),
(snapshot) => {
const listData: object[] = [];
// snapshot is a type of data by Firebase define
snapshot.forEach((childSnapshot) => {
let id: string | null = null;
let data: object | null = null;
id = childSnapshot.key;
data = childSnapshot.val();
listData.push({ id, data });
});
resolve(listData);
},
{
onlyOnce: true,
},
);
});
}
getListDataFromProp(
path: string,
property: string,
value: string | number,
): Promise<object[]> {
return new Promise((resolve) => {
onValue(
ref(this.db, path),
(snapshot) => {
let id: string;
let data: object;
const listData: object[] = [];
// snapshot is a type of data by Firebase define
snapshot.forEach((childSnapshot) => {
const dataTemp = childSnapshot.val();
if (dataTemp[property] === value) {
id = childSnapshot.key;
data = dataTemp;
listData.push({ id, data });
}
});
resolve(listData);
},
{
onlyOnce: true,
},
);
});
}
}
export default new FirebaseService();
+21
View File
@@ -0,0 +1,21 @@
import CategoryService from './categoryService';
import TransactionService from './transactionService';
import UserService from './userService';
import WalletService from './walletService';
export default class Service {
public userService: UserService;
public walletService: WalletService;
public transactionService: TransactionService;
public categoryService: CategoryService;
constructor() {
this.userService = new UserService();
this.walletService = new WalletService();
this.transactionService = new TransactionService();
this.categoryService = new CategoryService();
}
}
+25
View File
@@ -0,0 +1,25 @@
class LocalStorageService {
localStorage: Storage;
constructor() {
this.localStorage = localStorage;
}
add(key: string, value: string) {
this.localStorage.setItem(key, value);
}
get(key: string): string | null {
return this.localStorage.getItem(key);
}
remove(key: string) {
this.localStorage.removeItem(key);
}
clear() {
this.localStorage.clear();
}
}
export default new LocalStorageService();
+34
View File
@@ -0,0 +1,34 @@
import Transaction from 'models/transaction';
import CommonService from './commonService';
export default class TransactionService extends CommonService<Transaction> {
constructor() {
super();
this.defaultPath = 'transactions/';
}
/**
* Save transaction into database
* @param {Object} transaction The wallet object need to be saved into database
*/
async saveTransaction(transaction: Transaction): Promise<void> {
await this.save(transaction);
}
async getListTransactionByIdUser(
idUser: string,
): Promise<Transaction[] | null> {
const results = await this.getListDataFromProp(
'idUser',
idUser,
this.defaultPath,
);
return results || null;
}
async deleteTransaction(idTransaction: string): Promise<void> {
await this.deleteData(idTransaction);
}
}
+112
View File
@@ -0,0 +1,112 @@
import { LOCAL_STORAGE } from '../constants/config';
import { createToken } from '../helpers/data';
import User from '../models/user';
import CommonService from './commonService';
import LocalStorageService from './localStorageService';
export default class UserService {
private commonService: CommonService<User>;
constructor() {
this.commonService = new CommonService<User>('users/');
}
/**
* Save user into database
* @param {Object} user The user object need to be saved into database
*/
async saveUser(user: User): Promise<void> {
await this.commonService.save(user);
}
/**
* Check user exist in database
* @param {string} email Email to find user
* @returns {boolean} Return true if find, otherwise return false
*/
async isValidUser(email: string): Promise<boolean> {
const userExist = await this.getUserByEmail(email);
return !!userExist;
}
/**
* Get user data from email
* @param {string} email Email user
* @returns {Object || null} Return new User Object if find, otherwise return null.
*/
async getUserByEmail(email: string): Promise<User | null> {
const result = await this.commonService.getDataFromProp('email', email);
return result || null;
}
/**
* Validate user info
* @param {string} email The email user input
* @param {*} password The password user input
* @returns {boolean} Return true if match info on database, otherwise return false
*/
async loginUser(email: string, password: string): Promise<boolean> {
const user = await this.getUserByEmail(email);
// Check password
if (user && user.password === password) {
// Create token for user
await this.createTokenUser(email);
return true;
}
return false;
}
/**
* Create token for user on database
* @param {string} email The email user need to be create token
*/
async createTokenUser(email: string): Promise<void> {
const user = await this.getUserByEmail(email);
if (user) {
const newUserData = user;
// Add token to user object
newUserData.accessToken = createToken();
this.commonService.save(newUserData);
// Add access token to local storage
LocalStorageService.add(
LOCAL_STORAGE.ACCESS_TOKEN,
newUserData.accessToken,
);
}
}
async getInfoUserLogin(): Promise<User | null> {
// Find access token
const accessToken = LocalStorageService.get(LOCAL_STORAGE.ACCESS_TOKEN);
if (accessToken) {
const user = await this.getUserByToken(accessToken);
return user;
}
return null;
}
/**
* Get user data from token access
* @param {string} accessToken Access token user
* @returns {Object || null} Return new User Object if find, otherwise return null.
*/
async getUserByToken(accessToken: string): Promise<User | null> {
const result = await this.commonService.getDataFromProp(
'accessToken',
accessToken,
);
return result || null;
}
}
+40
View File
@@ -0,0 +1,40 @@
import Wallet from '../models/wallet';
import CommonService from './commonService';
export default class WalletService extends CommonService<Wallet> {
constructor() {
super();
this.defaultPath = 'wallets/';
}
/**
* Save wallet into database
* @param {Object} wallet The wallet object need to be saved into database
*/
async saveWallet(wallet: Wallet): Promise<void> {
await this.save(wallet);
}
/**
* Check wallet exist in database
* @param {string} idUser The id user to find user's wallet
* @returns {boolean} Return true if find, otherwise return false
*/
async isValidWallet(idUser: string): Promise<boolean> {
const wallet = await this.getWalletByIdUser(idUser);
return !!wallet;
}
/**
* Get wallet data from idUser
* @param {string} email The id user to find user's wallet
* @returns {Object || null} Return new Wallet Object if find, otherwise return null.
*/
async getWalletByIdUser(idUser: string): Promise<Wallet | null> {
const result = (await this.getDataFromProp('idUser', idUser)) as Wallet;
return result || null;
}
}
+155
View File
@@ -0,0 +1,155 @@
import CommonView from './commonView';
import { TOAST, VALIDATE_FORM } from 'constants/messages';
import {
compare2Password,
isValidPassword,
isValidateEmail,
renderRequiredText,
} from '../helpers/validatorForm';
import { Nullable } from 'global/types';
export default class AuthenticationView extends CommonView {
public formEl: Nullable<HTMLBodyElement>;
public emailEl: Nullable<HTMLBodyElement>;
public messageDefault: string[];
public inputPasswordEl: Nullable<HTMLBodyElement>;
public inputPasswordConfirmEl: Nullable<HTMLBodyElement>;
public listError: string[];
constructor() {
super();
this.formEl = document.querySelector('.form');
this.emailEl = document.querySelector("[name='email']");
this.messageDefault = TOAST.ERROR_MESSAGE_DEFAULT;
this.inputPasswordEl = document.querySelector('input[name="password"]');
this.inputPasswordConfirmEl = document.querySelector(
'input[name="password_confirm"]',
);
this.listError = [];
}
// eslint-disable-next-line class-methods-use-this
validateEmail(email: string): boolean {
if (email) {
if (!isValidateEmail(email)) {
this.listError.push(VALIDATE_FORM.INVALID_EMAIL_FORMAT);
return false;
}
return true;
}
if (this.emailEl) renderRequiredText('email', this.emailEl);
return false;
}
validatePassword(password: string): boolean {
if (password) {
if (!isValidPassword(password)) {
this.listError.push(VALIDATE_FORM.PASSWORD_NOT_STRONG);
return false;
}
return true;
}
if (this.inputPasswordEl)
renderRequiredText('password', this.inputPasswordEl);
return false;
}
validatePasswordConfirm(password: string, passwordConfirm: string): boolean {
if (passwordConfirm) {
if (!compare2Password(password, passwordConfirm)) {
this.listError.push(VALIDATE_FORM.PASSWORD_NOT_MATCH);
return false;
}
return true;
}
if (this.inputPasswordConfirmEl)
renderRequiredText('confirm password', this.inputPasswordConfirmEl);
return false;
}
/**
* Clear error message at form
*/
clearErrorMessage() {
// Reassign again to check error message element haved on page or not
const errorMessageEl = document.querySelector('.form__error-message');
const errorTextEl = document.querySelectorAll('.error-text');
// If have error message on page, remove it with style error input password
if (errorMessageEl || errorTextEl.length > 0) {
if (errorMessageEl) errorMessageEl.remove();
if (errorTextEl) {
errorTextEl.forEach((item) => {
item.remove();
});
}
// Use for 2 form
if (this.emailEl && this.inputPasswordEl) {
this.emailEl.classList.remove('error-input');
this.inputPasswordEl.classList.remove('error-input');
}
// Only register form required
if (this.inputPasswordConfirmEl)
this.inputPasswordConfirmEl.classList.remove('error-input');
}
}
/**
* Add event listener for input field at form
*/
addHandlerInputFormChange() {
if (this.formEl)
this.formEl.addEventListener('input', () => {
this.clearErrorMessage();
});
}
/**
* Show error message with error style input password.
* @param {string} message The error message you want show in form.
*/
showError(message: string[]) {
this.renderError(message);
}
/**
* Show error message in form
* @param {string} message The message will show in form
*/
renderError(messages = this.messageDefault) {
if (messages.length > 0) {
const messageItemMarkup = messages
.map((message) => `<li>${message}</li>`)
.join('\n');
const markup = `
<ul class="form__error-message">
${messageItemMarkup}
</ul>
`;
const titleEl = document.querySelector('.form__title');
if (titleEl) titleEl.insertAdjacentHTML('afterend', markup);
}
}
}
+164
View File
@@ -0,0 +1,164 @@
import { Nullable } from 'global/types';
import { MarkIcon, TypeToast } from '../constants/config';
export default class CommonView {
public toastDialog: Nullable<HTMLDialogElement> = null;
public toastIcon: Nullable<HTMLBodyElement> = null;
public toastBtn: Nullable<HTMLBodyElement> = null;
public toastTitle: Nullable<HTMLBodyElement> = null;
public toastContent: Nullable<HTMLBodyElement> = null;
public spinner: Nullable<HTMLBodyElement> = null;
constructor() {
this.toastDialog = document.querySelector('.dialog .toast');
this.spinner = null;
}
/**
* Implement toast in site
*/
initToast() {
const markup = `
<dialog class="dialog">
<div class="toast">
<div class="mark"></div>
<h2 class="toast__title"></h2>
<p class="toast__message"></p>
<button class="toast__redirect-btn">OK</button>
</div>
</dialog>
`;
const body = document.querySelector('body');
if (body) body.insertAdjacentHTML('afterbegin', markup);
this.initToastEl();
}
initToastEl() {
this.toastDialog = document.querySelector('.dialog');
this.toastIcon = this.toastDialog
? this.toastDialog.querySelector('.mark')
: null;
this.toastBtn = this.toastDialog
? this.toastDialog.querySelector('.toast__redirect-btn')
: null;
this.toastTitle = this.toastDialog
? this.toastDialog.querySelector('.toast__title')
: null;
this.toastContent = this.toastDialog
? this.toastDialog.querySelector('.toast__message')
: null;
}
/**
* Show or hide loader screen
*/
toggleLoaderSpinner() {
if (this.spinner) this.spinner.classList.toggle('hidden');
}
/**
* Add toast content
* @param {TYPE_TOAST} typeToast Type of the toast
* @param {string} title Title of toast
* @param {string} content Content of toast
* @param {string} btnContent Content of button
*/
initToastContent(
typeToast: string,
title: string,
content: string,
btnContent: string,
) {
// Remove old typeToast class if haved
Object.keys(TypeToast).forEach((key) => {
CommonView.removeClassElement(
TypeToast[key as keyof typeof TypeToast],
this.toastDialog as HTMLBodyElement | null,
);
});
// Remove old icon toast if haved
Object.keys(MarkIcon).forEach((key) => {
CommonView.removeClassElement(
MarkIcon[key as keyof typeof MarkIcon],
this.toastIcon,
);
});
// Init content toast
if (
this.toastDialog &&
this.toastIcon &&
this.toastTitle &&
this.toastContent &&
this.toastBtn
) {
this.toastDialog.classList.add(
typeToast === TypeToast.success ? TypeToast.success : TypeToast.error,
);
this.toastIcon.classList.add(
typeToast === TypeToast.success ? MarkIcon.success : MarkIcon.error,
);
this.toastTitle.textContent = title;
this.toastContent.textContent = content;
this.toastBtn.textContent = btnContent;
}
}
static removeClassElement(classEl: string, el: HTMLBodyElement | null) {
if (el && el.classList.contains(classEl)) {
el.classList.remove(classEl);
}
}
/**
* Add event listener for toast
*/
handleEventToast() {
if (this.toastBtn) {
this.toastBtn.addEventListener('click', () => {
if (this.toastDialog) this.toastDialog.close();
});
}
// Add event close dialog when click outside
if (this.toastDialog) {
this.toastDialog.addEventListener('click', (e) => {
if (this.toastDialog) {
const dialogDimensions = this.toastDialog.getBoundingClientRect();
if (
e.clientX < dialogDimensions.left ||
e.clientX > dialogDimensions.right ||
e.clientY < dialogDimensions.top ||
e.clientY > dialogDimensions.bottom
) {
this.toastDialog.close();
}
}
});
}
}
/**
* Implement loader screen
*/
initLoader() {
const markup = `<div class="loader hidden"></div>`;
const body = document.querySelector('body');
if (body) body.insertAdjacentHTML('afterbegin', markup);
// Init element
this.spinner = document.querySelector('.loader');
}
}
+204
View File
@@ -0,0 +1,204 @@
import Transaction from '../../models/transaction';
import { renderRequiredText } from '../../helpers/validatorForm';
import EventDataTrigger from 'helpers/evDataTrigger';
import Wallet from 'models/wallet';
import User from 'models/user';
import { DEFAULT_CATEGORY } from 'constants/config';
import { TOAST } from 'constants/messages';
import {
IBudgetViewFunc,
Data,
Nullable,
PromiseVoid,
TError,
VoidFunc,
} from 'global/types';
export default class BudgetView {
budgetDialog: Nullable<HTMLDialogElement> = null;
addBudgetBtn: Nullable<HTMLElement> = null;
evDataTrigger: Nullable<EventDataTrigger> = null;
toggleLoaderSpinner: Nullable<VoidFunc> = null;
saveTransaction: Nullable<(transaction: Transaction) => Promise<void>> = null;
loadTransactionData: Nullable<PromiseVoid> = null;
updateAmountWallet: Nullable<PromiseVoid> = null;
loadData: Nullable<PromiseVoid> = null;
showSuccessToast: Nullable<(title: string, message: string) => void> = null;
showErrorToast: Nullable<(error: TError) => void> = null;
wallet: Nullable<Wallet> = null;
user: Nullable<User> = null;
constructor() {
this.budgetDialog = document.getElementById(
'budgetDialog',
) as HTMLDialogElement;
this.addBudgetBtn = document.getElementById('addBudget');
this.handlerEventBudgetView();
}
initFunction(func: IBudgetViewFunc) {
this.showErrorToast = func.showErrorToast;
this.showSuccessToast = func.showSuccessToast;
this.toggleLoaderSpinner = func.toggleLoaderSpinner;
this.saveTransaction = func.saveTransaction;
this.loadTransactionData = func.loadTransactionData;
this.updateAmountWallet = func.updateAmountWallet;
this.loadData = func.loadData;
this.evDataTrigger = EventDataTrigger.Instance;
}
subscribe() {
this.evDataTrigger!.create('budgetView', this.updateData.bind(this));
}
sendData() {
const data = { wallet: this.wallet! };
this.evDataTrigger!.onSendSignal('budgetView', data);
}
updateData(data: Data) {
if (data.wallet) this.wallet = data.wallet;
if (data.user) this.user = data.user;
}
addHandlerEventBudgetForm() {
this.budgetDialog!.addEventListener('submit', (e) => {
e.preventDefault();
this.clearErrorStyleBudgetForm();
this.submitBudgetForm();
});
this.budgetDialog!.addEventListener('input', () => {
this.changeBtnStyle();
this.clearErrorStyleBudgetForm();
});
}
clearErrorStyleBudgetForm() {
// Clear error style
const inputFieldEls =
this.budgetDialog!.querySelectorAll('.form__input-field');
const errorTexts = this.budgetDialog!.querySelectorAll('.error-text');
inputFieldEls.forEach((item) => {
if (item.classList.contains('error-input'))
item.classList.remove('error-input');
});
errorTexts.forEach((item) => {
if (item) item.remove();
});
}
async submitBudgetForm(): Promise<void> {
try {
const form = document.getElementById('formAddBudget') as HTMLFormElement;
const formAddBudget = new FormData(form);
const date = formAddBudget.get('selected_date') as string;
const amount = formAddBudget.get('amount') as string;
const note = formAddBudget.get('note') as string;
// Validate data user input
if (this.validateBudgetForm(date, +amount)) {
this.toggleLoaderSpinner!(); // Enable loader spinner
this.budgetDialog!.close(); // Close dialog
const transaction = new Transaction(
'',
DEFAULT_CATEGORY.INCOME,
date,
note,
+amount,
this.wallet!.idUser,
);
await this.saveTransaction!(transaction);
// Reload data
await this.loadTransactionData!();
await this.updateAmountWallet!();
await this.loadData!();
// Hide loader spinner
this.toggleLoaderSpinner!();
// Show success message
this.showSuccessToast!(
TOAST.ADD_TRANSACTION_SUCCESS,
TOAST.DEFAULT_MESSAGE,
);
(<HTMLFormElement>document.getElementById('formAddBudget')!).reset();
}
} catch (error) {
this.showErrorToast!(error as TError);
}
}
validateBudgetForm(date: string, amount: number): boolean {
const inputFieldEl =
this.budgetDialog!.querySelectorAll('.form__input-field');
if (!date || !amount) {
if (!date) {
renderRequiredText('date', inputFieldEl[0]);
inputFieldEl[0].classList.add('error-input');
}
if (!amount) {
renderRequiredText('amount', inputFieldEl[1]);
inputFieldEl[1].classList.add('error-input');
}
return false;
}
return true;
}
changeBtnStyle() {
const date = (<HTMLDataElement>(
this.budgetDialog!.querySelector('.input-date')!
)).value;
const amount = +(<HTMLDataElement>(
this.budgetDialog!.querySelector('.form__input-balance')!
)).value;
const saveBtn = this.budgetDialog!.querySelector('.form__save-btn')!;
if (date.trim() && amount >= 1) {
saveBtn.classList.add('active');
} else {
saveBtn.classList.remove('active');
}
}
handlerEventBudgetView() {
this.addBudgetBtn!.addEventListener('click', () => {
// Set default value for date input
(<HTMLInputElement>(
this.budgetDialog!.querySelector("[name='selected_date']")
))!.valueAsDate = new Date();
this.budgetDialog!.showModal();
});
this.addHandlerEventBudgetForm();
}
}
+167
View File
@@ -0,0 +1,167 @@
import EventDataTrigger from 'helpers/evDataTrigger';
import { REMOVE_CATEGORY } from '../../constants/config';
import Category from 'models/category';
import { Data, Nullable, PromiseOrNull } from 'global/types';
export default class CategoryView {
categoryDialog: Nullable<HTMLDialogElement> = null;
categoryField: Nullable<HTMLElement> = null;
closeIcon: Nullable<HTMLElement> = null;
getAllCategory: Nullable<PromiseOrNull<Category[]>> = null;
evDataTrigger: Nullable<EventDataTrigger> = null;
listCategory: Nullable<Category[]> = null;
categorySelected: string;
constructor() {
this.categorySelected = '';
this.categoryDialog = document.getElementById(
'categoryDialog',
) as HTMLDialogElement;
this.categoryField = document.getElementById('selectCategory');
this.closeIcon = document.querySelector('.close-icon') as HTMLElement;
this.handlerEventCategoryDialog();
this.addEventSelectCategoryDialog();
}
initFunction(
getAllCategory: PromiseOrNull<Category[]>,
evDataTrigger: EventDataTrigger,
) {
this.getAllCategory = getAllCategory;
this.evDataTrigger = evDataTrigger;
}
sendData() {
const data: Data = { listCategories: this.listCategory! };
this.evDataTrigger!.onSendSignal('categoryView', data);
}
handlerEventCategoryDialog() {
this.categoryDialog!.addEventListener('input', () => {
setTimeout(() => {
this.searchCategory();
}, 300);
});
}
/**
* Load category data
* @param {function} getAllCategory Get all category function
*/
async loadCategory(): Promise<void> {
if (!this.listCategory) {
this.listCategory = await this.getAllCategory!();
this.sendData();
}
if (this.listCategory) {
this.renderCategoryList();
}
}
searchCategory() {
const searchCategoryEl: HTMLDataElement =
this.categoryDialog!.querySelector("[name='category']")!;
const searchValue = searchCategoryEl.value.trim().toLowerCase();
let newListCategory: Category[] = [];
if (searchValue) {
this.listCategory!.forEach((category) => {
const categoryName = category.name.trim().toLowerCase();
if (categoryName.includes(searchValue)) {
newListCategory.unshift(category);
}
});
} else {
newListCategory = this.listCategory!;
}
// Render category item
this.renderCategoryList(this.categorySelected, newListCategory);
}
renderCategoryList(
categorySelected?: string,
listCategory = this.listCategory!,
) {
// Remove category name unnecessary
const newListCategory = listCategory.filter(
(item) => !REMOVE_CATEGORY.includes(item.name),
);
const listCategoryEl = document.querySelector('.list-category');
listCategoryEl!.innerHTML = ''; // Remove old category item
newListCategory.forEach((category) => {
const markup = `
<div class="category-item ${
category.name === categorySelected ? 'selected' : ''
}" data-value='${category.name}' data-url='${category.url}'>
<img
class="icon-category"
src="${category.url}"
alt="${category.name} Icon"
/>
<p class="name-category">${category.name}</p>
</div>
`;
listCategoryEl!.insertAdjacentHTML('afterbegin', markup);
});
}
addEventSelectCategoryDialog() {
const categoryListEl = document.querySelector('.list-category');
const categoryIconEl = this.categoryField!.querySelector(
'.category-icon',
) as HTMLImageElement;
const categoryNameEl = this.categoryField!.querySelector(
'.category-name',
) as HTMLDataElement;
categoryListEl!.addEventListener('click', (e) => {
const categoryItem = (<Element>e.target).closest(
'.category-item',
) as HTMLElement;
if (categoryItem) {
const { url } = categoryItem.dataset;
const { value } = categoryItem.dataset;
// Set url and value into category field in transaction dialog
categoryIconEl.src = url!;
categoryNameEl.value = value!;
// Close select category dialog
this.categoryDialog!.close();
}
});
// Pass value selected to category dialog
categoryNameEl.addEventListener('click', () => {
if (categoryNameEl.value) {
// Make the keyword search category name into global
this.categorySelected = categoryNameEl.value;
this.renderCategoryList(this.categorySelected);
}
this.categoryDialog!.showModal();
});
this.closeIcon!.addEventListener('click', () => {
this.categoryDialog!.close();
});
}
}
+439
View File
@@ -0,0 +1,439 @@
import { BTN_CONTENT, TypeToast } from '../../constants/config';
import CommonView from '../commonView';
import { clearAccessToken, formatNumber } from '../../helpers/data';
import WalletView from './walletView';
import Wallet from 'models/wallet';
import EventDataTrigger from 'helpers/evDataTrigger';
import Transaction from 'models/transaction';
import User from 'models/user';
import {
Data,
IBudgetViewFunc,
IHomeFunc,
ITransactionViewFunc,
IWalletViewFunc,
Nullable,
PromiseOrNull,
TError,
} from 'global/types';
import { TOAST } from 'constants/messages';
import { redirectToLoginPage } from 'helpers/url';
import Category from 'models/category';
import CategoryView from './categoryView';
import BudgetView from './budgetView';
import TransactionView from './transactionView';
import SummaryTabView from './summaryTabView';
import TransactionDetail from 'models/transactionDetail';
import TransactionTabView from './transactionTabView';
export default class HomeView extends CommonView {
walletView: WalletView;
categoryView: CategoryView;
budgetView: BudgetView;
transactionView: TransactionView;
summaryTabView: SummaryTabView;
transactionTabView: TransactionTabView;
tabs: NodeListOf<Element>;
allContent: NodeListOf<Element>;
cancelBtns: NodeListOf<Element>;
dialogs: NodeListOf<Element>;
amountInputs: NodeListOf<Element>;
user: Nullable<User> = null;
wallet: Nullable<Wallet> = null;
listTransactions: Transaction[] = [];
transactionDetails: TransactionDetail[] = [];
getInfoUserLogin: Nullable<PromiseOrNull<User>> = null;
getWalletByIdUser: Nullable<(idUser: string) => Promise<Wallet | null>> =
null;
getAllCategory: Nullable<() => Promise<Category[] | null>> = null;
getAllTransactions: Nullable<
(idUser: string) => Promise<Transaction[] | null>
> = null;
deleteTransaction: Nullable<(idTransaction: string) => Promise<void>> = null;
saveWallet: Nullable<(wallet: Wallet) => Promise<void>> = null;
saveTransaction: Nullable<(transaction: Transaction) => Promise<void>> = null;
evDataTrigger: Nullable<EventDataTrigger> = null;
constructor() {
super();
this.tabs = document.querySelectorAll('.app__tab-item');
this.allContent = document.querySelectorAll('.app__content-item');
this.cancelBtns = document.querySelectorAll('.form__cancel-btn');
this.dialogs = document.querySelectorAll('.dialog');
this.amountInputs = document.querySelectorAll('.form__input-balance');
this.walletView = new WalletView();
this.categoryView = new CategoryView();
this.budgetView = new BudgetView();
this.transactionView = new TransactionView(this.categoryView);
this.summaryTabView = new SummaryTabView();
this.transactionTabView = new TransactionTabView(this.transactionView);
this.initToast();
this.initLoader();
this.handleEventToast();
}
// ----------------------- Init function ----------------------- //
initFunction(func: IHomeFunc) {
this.getInfoUserLogin = func.getInfoUserLogin || null;
this.getWalletByIdUser = func.getWalletByIdUser || null;
this.getAllCategory = func.getAllCategory || null;
this.getAllTransactions = func.getAllTransactions || null;
this.saveWallet = func.saveWallet || null;
this.saveTransaction = func.saveTransaction || null;
this.deleteTransaction = func.deleteTransaction || null;
this.evDataTrigger = EventDataTrigger.Instance;
// Init function for child view
this.initWalletViewFunction();
this.initBudgetViewFunction();
this.initTransactionViewFunction();
this.initCategoryViewFunction();
this.initSummaryTabViewFunction();
this.initTransactionTabViewFunction();
}
initTransactionViewFunction() {
const func: ITransactionViewFunc = {
toggleLoaderSpinner: this.toggleLoaderSpinner.bind(this),
deleteTransaction: this.deleteTransaction!,
loadTransactionData: this.loadTransactionData.bind(this),
updateAmountWallet: this.updateAmountWallet.bind(this),
loadData: this.loadData.bind(this),
showSuccessToast: this.showSuccessToast.bind(this),
showErrorToast: this.showErrorToast.bind(this),
saveTransaction: this.saveTransaction!,
};
this.transactionView.initFunction(func);
}
initBudgetViewFunction() {
const func: IBudgetViewFunc = {
showErrorToast: this.showErrorToast.bind(this),
showSuccessToast: this.showSuccessToast.bind(this),
toggleLoaderSpinner: this.toggleLoaderSpinner.bind(this),
saveTransaction: this.saveTransaction!.bind(this),
loadTransactionData: this.loadTransactionData.bind(this),
updateAmountWallet: this.updateAmountWallet.bind(this),
loadData: this.loadData.bind(this),
};
this.budgetView.initFunction(func);
}
initCategoryViewFunction() {
this.categoryView.initFunction(this.getAllCategory!, this.evDataTrigger!);
}
initSummaryTabViewFunction() {
this.summaryTabView.initFunction(this.evDataTrigger!);
}
initTransactionTabViewFunction() {
this.transactionTabView.initFunction(this.evDataTrigger!);
}
initWalletViewFunction() {
const func: IWalletViewFunc = {
toggleLoaderSpinner: this.toggleLoaderSpinner.bind(this),
saveWallet: this.saveWallet,
saveTransaction: this.saveTransaction,
loadTransactionData: this.loadTransactionData.bind(this),
loadData: this.loadData.bind(this),
loadEvent: this.loadEvent.bind(this),
showSuccessToast: this.showSuccessToast.bind(this),
showErrorToast: this.showErrorToast.bind(this),
};
this.walletView.initFunction(func);
}
// ----------------------- End ----------------------- //
subscribeListenerData() {
this.subscribe();
this.transactionView.subscribe();
this.budgetView.subscribe();
this.summaryTabView.subscribe();
this.transactionTabView.subscribe();
this.walletView.subscribe();
}
async loadData(): Promise<void> {
// Send data to other class
this.sendData();
await this.categoryView.loadCategory();
this.summaryTabView.load();
this.transactionTabView.loadTransactionTab();
await this.loadWalletUser();
await this.updateAmountWallet();
}
async loadPage(): Promise<void> {
this.toggleLoaderSpinner();
const user = await this.getInfoUserLogin!();
if (!user) {
// If user not login yet
clearAccessToken();
window.location.replace('/login');
} else {
// If user already login
this.user = user;
this.wallet = <Wallet>await this.getWalletByIdUser!(user.id);
this.sendData();
// Check user's wallet if have or not
if (!this.wallet) {
// Show add wallet dialog
this.walletView.showDialog();
} else {
// Init data
await this.loadTransactionData();
await this.loadData();
// Load event page
this.loadEvent();
}
}
this.toggleLoaderSpinner();
}
// ---------------------LOAD DATA---------------------//
subscribe() {
this.evDataTrigger!.create('homeView', this.updateData.bind(this));
}
sendData() {
const data: Data = {
wallet: this.wallet!,
listTransactions: this.listTransactions!,
user: this.user!,
};
this.evDataTrigger!.onSendSignal('homeView', data);
}
updateData(data: Data) {
if (data.wallet) this.wallet = data.wallet;
if (data.user) this.user = data.user;
}
/**
* Load wallet user
*/
async loadWalletUser(): Promise<void> {
const wallet = this.wallet
? this.wallet
: await this.getWalletByIdUser!(this.user!.id);
const walletName = document.querySelector('.wallet__name');
const walletPrice = document.querySelector('.wallet__price');
const walletNameValue = wallet!.walletName;
const walletAmountValue = wallet!.inflow + wallet!.outflow;
const sign = walletAmountValue >= 0 ? '+' : '-';
this.wallet = wallet; // Make wallet into global variable
walletName!.textContent = walletNameValue;
walletPrice!.textContent = `${sign}$ ${formatNumber(
Math.abs(walletAmountValue),
)}`; // Math.abs(walletAmountValue) to keep the value always > 0
// Update amount wallet into database
this.saveWallet!(this.wallet!);
}
async loadTransactionData(): Promise<void> {
const data = await this.getAllTransactions!(this.wallet!.idUser);
if (data) this.listTransactions = data;
this.sendData();
}
async updateAmountWallet(): Promise<void> {
let inflow = 0;
let outflow = 0;
// Init data first
this.transactionDetails =
this.transactionTabView!.loadTransactionDetailsData();
this.transactionDetails.forEach((transaction) => {
if (transaction.totalAmount >= 0) {
inflow += transaction.totalAmount;
} else {
outflow -= transaction.totalAmount;
}
});
// Reassign value for wallet user;
this.wallet!.inflow = inflow;
this.wallet!.outflow = -outflow;
await this.saveWallet!(this.wallet!);
}
// ---------------------END--------------------- //
// ---------------------TOAST--------------------- //
showSuccessToast(title: string, message: string) {
const typeToast = TypeToast.success;
const btnContent = BTN_CONTENT.OK;
this.initToastContent(typeToast, title, message, btnContent);
this.toastDialog!.showModal();
}
/**
* Implement error toast in site
* @param {string} content The content will show in error toast
*/
showErrorToast(error: TError) {
const title =
typeof error === 'object' && error.title
? error.title
: TOAST.DEFAULT_TITLE_ERROR_TOAST;
const content =
typeof error === 'object' && error.message
? error.message
: (error as string);
this.initToastContent(TypeToast.error, title, content, BTN_CONTENT.OK);
if (this.toastDialog && this.toastBtn) {
// Show toast
this.toastDialog.showModal();
// Remove event for toast button
this.toastBtn.removeEventListener('click', redirectToLoginPage);
}
}
// ---------------------END--------------------- //
// --------------------- HANDLER EVENT --------------------- //
loadEvent() {
this.addCommonEventPage();
this.handlerTabsTransfer();
}
/**
* Handle event when click on tabs
*/
handlerTabsTransfer() {
this.tabs.forEach((tab, index) => {
tab.addEventListener('click', (e: Event) => {
this.removeActiveTab();
tab.classList.add('active');
const line = document.querySelector<HTMLElement>(
'.app__line',
) as HTMLElement;
const eventTarget = e.target as HTMLElement;
line.style.width = `${eventTarget.offsetWidth}px`;
line.style.left = `${eventTarget.offsetLeft}px`;
this.allContent.forEach((content) => {
content.classList.remove('active');
});
this.allContent[index].classList.add('active');
});
});
}
addCommonEventPage() {
// Add event close dialog when click outside
this.dialogs.forEach((dialog) => {
dialog.addEventListener('click', (e) => {
const dialogDimensions = dialog.getBoundingClientRect();
const mouseEvent = e as MouseEvent;
if (
mouseEvent.clientX < dialogDimensions.left ||
mouseEvent.clientX > dialogDimensions.right ||
mouseEvent.clientY < dialogDimensions.top ||
mouseEvent.clientY > dialogDimensions.bottom
) {
(<HTMLDialogElement>dialog).close();
}
});
});
this.cancelBtns.forEach((btn) => {
btn.addEventListener('click', () => {
this.closeAllDialog();
});
});
// Prevent input =,-,e into amount input
this.amountInputs.forEach((item) => {
item.addEventListener(
'keypress',
(e) =>
['+', '-', 'e'].includes((<KeyboardEvent>e).key) &&
e.preventDefault(),
);
});
// Prevent user input at category search
const categorySearchEl = document.querySelector(
'.category-name',
) as Element;
categorySearchEl.addEventListener('keydown', (e) => {
e.preventDefault();
});
const logoutBtn = document.querySelector('.logout-text') as Element;
logoutBtn.addEventListener('click', () => {
clearAccessToken();
window.location.replace('/login');
});
}
closeAllDialog() {
this.dialogs.forEach((dialog) => {
(<HTMLDialogElement>dialog).close();
});
}
removeActiveTab() {
this.tabs.forEach((tab) => {
tab.classList.remove('active');
});
}
}
+48
View File
@@ -0,0 +1,48 @@
import EventDataTrigger from 'helpers/evDataTrigger';
import { formatNumber } from '../../helpers/data';
import { Data, Nullable } from 'global/types';
import Wallet from 'models/wallet';
import Transaction from 'models/transaction';
import Category from 'models/category';
export default class SummaryTabView {
evDataTrigger: Nullable<EventDataTrigger> = null;
wallet: Nullable<Wallet> = null;
listTransactions: Transaction[] = [];
listCategories: Category[] = [];
initFunction(evDataTrigger: EventDataTrigger) {
this.evDataTrigger = evDataTrigger;
}
subscribe() {
this.evDataTrigger!.create('summaryTabView', this.updateData.bind(this));
}
updateData(data: Data) {
if (data.wallet) this.wallet = data.wallet;
if (data.listTransactions) this.listTransactions = data.listTransactions;
if (data.listCategories) this.listCategories = data.listCategories;
}
load() {
const inflowValue = document.querySelector('.inflow__text--income')!;
const outflowValue = document.querySelector('.outflow__text--outcome')!;
const totalValue = document.querySelector('.summary__total')!;
const { inflow } = this.wallet!;
const { outflow } = this.wallet!;
const total = inflow + outflow;
inflowValue.textContent = `+$ ${formatNumber(inflow)}`;
outflowValue.textContent = `-$ ${formatNumber(Math.abs(outflow))}`;
totalValue.textContent = `${total >= 0 ? '+' : '-'}$ ${formatNumber(
Math.abs(total),
)}`;
}
}
+197
View File
@@ -0,0 +1,197 @@
import EventDataTrigger from 'helpers/evDataTrigger';
import {
createTransactionDetailObject,
getAllCategoryNameInTransactions,
getAllTransactionByCategoryName,
} from '../../helpers/data';
import { formatNumber } from '../../helpers/data';
import TransactionView from './transactionView';
import { Data, Nullable } from 'global/types';
import Wallet from 'models/wallet';
import Transaction from 'models/transaction';
import Category from 'models/category';
import TransactionDetail from 'models/transactionDetail';
export default class TransactionTabView {
transactionView: TransactionView;
evDataTrigger: Nullable<EventDataTrigger> = null;
wallet: Nullable<Wallet> = null;
listTransactions: Transaction[] = [];
listCategories: Category[] = [];
transactionDetails: TransactionDetail[] = [];
constructor(transactionView: TransactionView) {
this.transactionView = transactionView;
this.addEventTransactionItem();
}
initFunction(evDataTrigger: EventDataTrigger) {
this.evDataTrigger = evDataTrigger;
}
subscribe() {
this.evDataTrigger!.create(
'transactionTabView',
this.updateData.bind(this),
);
}
updateData(data: Data) {
if (data.wallet) this.wallet = data.wallet;
if (data.listTransactions) this.listTransactions = data.listTransactions;
if (data.listCategories) this.listCategories = data.listCategories;
}
async loadTransactionTab(): Promise<void> {
// Load category
// Init data first
this.transactionDetails = this.loadTransactionDetailsData();
const transactionEl = document.querySelector('.transaction')!;
const listTransactionDetailEl =
transactionEl.querySelector('.transaction__list')!;
const markup: string[] = [];
if (this.transactionDetails) {
this.transactionDetails.forEach((transactionDetail) => {
markup.push(this.transactionDetailMarkup(transactionDetail));
});
}
listTransactionDetailEl.innerHTML = '';
listTransactionDetailEl.insertAdjacentHTML('afterbegin', markup.join('\n'));
// Load event
this.addEventTransactionItem();
}
loadTransactionDetailsData(): TransactionDetail[] {
// Get all category user have in transactions
const listCategoryInTransaction = getAllCategoryNameInTransactions(
this.listTransactions,
);
// Create transactions details object
const tempList: TransactionDetail[] = listCategoryInTransaction.map(
(categoryName) => {
// Get category object from list category has been loaded.
const category = this.listCategories.filter(
(item) => item.name === categoryName,
);
const transactions = getAllTransactionByCategoryName(
<string>categoryName,
this.listTransactions,
);
return createTransactionDetailObject(
Object.assign({}, ...category),
transactions,
);
},
);
tempList.sort(
(a, b) => parseInt(b.transactions[0].id) - parseInt(a.transactions[0].id),
);
return tempList;
}
// eslint-disable-next-line class-methods-use-this
transactionDetailMarkup(transactionDetail: TransactionDetail): string {
const itemTransaction = () => {
const listMarkup: string[] = [];
transactionDetail.transactions.forEach((transaction) => {
const markup = `
<div class="transaction__time" data-id=${transaction.id}>
<div class="transaction__details">
<p class="transaction__day">${transaction.day}</p>
<div class="transaction__time-details">
<p class="transaction__date-time">
${transaction.fullDateString}
</p>
<p class="transaction__note">${
transaction.note === '' ? 'None' : transaction.note
}</p>
</div>
</div>
<p class="transaction__${
transaction.amount >= 0 ? 'income' : 'outcome'
}">${transaction.amount >= 0 ? '+' : '-'}$ ${formatNumber(
Math.abs(transaction.amount),
)}</p>
</div>
`;
listMarkup.push(markup);
});
return listMarkup.join('\n');
};
return `
<div class="transaction__item">
<div class="transaction__category">
<div class="transaction__category-infor">
<div class="transaction__category-icon-container">
<img
class="icon-category"
src="${transactionDetail.url}"
alt="Transportation icon category"
/>
</div>
<div class="transaction__category-content">
<p class="transaction__category-name">
${transactionDetail.categoryName}
</p>
<p class="transaction__total">${
transactionDetail.totalTransaction
} Transactions</p>
</div>
</div>
<p class="transaction__category-total">${
transactionDetail.totalAmount >= 0 ? '+' : '-'
}$ ${formatNumber(Math.abs(transactionDetail.totalAmount))}</p>
</div>
<div class="transaction__line"></div>
<!-- Transaction time item -->
${itemTransaction()}
</div>
`;
}
// eslint-disable-next-line class-methods-use-this
addEventTransactionItem() {
const transactionItemEl = document.querySelectorAll('.transaction__item');
if (transactionItemEl) {
transactionItemEl.forEach((item) => {
item.addEventListener('click', (e) => {
const transactionTime = (<Element>e.target).closest(
'.transaction__time',
);
const categoryNameEl: HTMLInputElement = item.querySelector(
'.transaction__category-name',
)!;
if (transactionTime) {
const idTransaction = (<HTMLElement>transactionTime).dataset.id!;
// If it is income transaction, don't show dialog
if (
categoryNameEl.textContent &&
categoryNameEl.textContent.trim() !== 'Income'
)
this.transactionView.showTransactionDialog(idTransaction);
}
});
});
}
}
}
+375
View File
@@ -0,0 +1,375 @@
import Transaction from 'models/transaction';
import defaultCategoryIcon from '../../../assets/images/question-icon.svg';
import CategoryView from './categoryView';
import EventDataTrigger from 'helpers/evDataTrigger';
import {
Data,
ITransactionViewFunc,
Nullable,
PromiseVoid,
TError,
VoidFunc,
} from 'global/types';
import Wallet from 'models/wallet';
import Category from 'models/category';
import { TOAST } from 'constants/messages';
import { renderRequiredText } from 'helpers/validatorForm';
export default class TransactionView {
wallet: Nullable<Wallet> = null;
listTransactions: Transaction[] = [];
listCategories: Category[] = [];
categoryView: CategoryView;
addTransactionBtn: Nullable<HTMLElement> = null;
transactionDialog: Nullable<HTMLDialogElement> = null;
transactionForm: Nullable<HTMLFormElement> = null;
toggleLoaderSpinner: Nullable<VoidFunc> = null;
deleteTransaction: Nullable<(idTransaction: string) => Promise<void>> = null;
loadTransactionData: Nullable<PromiseVoid> = null;
updateAmountWallet: Nullable<PromiseVoid> = null;
loadData: Nullable<PromiseVoid> = null;
showSuccessToast: ((title: string, message: string) => void) | null = null;
showErrorToast: ((error: TError) => void) | null = null;
saveTransaction: Nullable<(transaction: Transaction) => Promise<void>> = null;
evDataTrigger: EventDataTrigger | null = null;
constructor(categoryView: CategoryView) {
this.addTransactionBtn = document.getElementById('addTransaction');
this.transactionDialog = document.getElementById(
'transactionDialog',
) as HTMLDialogElement;
this.transactionForm = <HTMLFormElement>(
document.getElementById('formAddTransaction')
);
this.handlerEventTransactionDialog();
this.categoryView = categoryView;
}
initFunction(func: ITransactionViewFunc) {
this.toggleLoaderSpinner = func.toggleLoaderSpinner;
this.deleteTransaction = func.deleteTransaction;
this.loadTransactionData = func.loadTransactionData;
this.updateAmountWallet = func.updateAmountWallet;
this.loadData = func.loadData;
this.showSuccessToast = func.showSuccessToast;
this.showErrorToast = func.showErrorToast;
this.saveTransaction = func.saveTransaction;
this.evDataTrigger = EventDataTrigger.Instance;
}
subscribe() {
this.evDataTrigger!.create('transactionView', this.updateData.bind(this));
}
sendData() {
const data: Data = {
wallet: this.wallet!,
listTransactions: this.listTransactions!,
};
this.evDataTrigger!.onSendSignal('transactionView', data);
}
updateData(data: Data) {
if (data.wallet) this.wallet = data.wallet;
if (data.listTransactions) this.listTransactions = data.listTransactions;
if (data.listCategories) this.listCategories = data.listCategories;
}
handlerEventTransactionDialog() {
this.transactionDialog!.addEventListener('submit', (e) => {
e.preventDefault();
this.clearErrorTransactionDialog();
this.submitTransactionDialog();
});
this.transactionDialog!.addEventListener('input', () => {
this.changeBtnStyleTransactionDialog();
this.clearErrorTransactionDialog();
});
// Add delete transaction event
this.deleteTransactionEvent();
this.addTransactionBtn!.addEventListener('click', () => {
this.showTransactionDialog();
});
this.handlerCategoryFieldEvent();
}
handlerCategoryFieldEvent() {
const categoryField = this.transactionForm!.querySelector(
"[name='category_name']",
);
categoryField!.addEventListener('click', () => {
this.clearErrorTransactionDialog();
});
}
clearErrorTransactionDialog() {
// Clear error style
const inputFieldEls =
this.transactionDialog!.querySelectorAll('.form__input-field');
const errorTextEls =
this.transactionDialog!.querySelectorAll('.error-text');
inputFieldEls.forEach((item) => {
if (item.classList.contains('error-input'))
item.classList.remove('error-input');
});
errorTextEls.forEach((item) => {
if (item) item.remove();
});
}
deleteTransactionEvent() {
const deleteTransactionBtn = this.transactionDialog!.querySelector(
'.form__delete-btn',
) as Element;
deleteTransactionBtn.addEventListener('click', async () => {
const idEl = this.transactionDialog!.querySelector(
"[name='id_transaction']",
) as HTMLDataElement;
if (idEl.value) {
try {
this.transactionDialog!.close();
this.toggleLoaderSpinner!();
await this.deleteTransaction!(idEl.value);
// Reload data
await this.loadTransactionData!();
await this.updateAmountWallet!();
await this.loadData!();
this.showSuccessToast!('Delete success!', TOAST.DEFAULT_MESSAGE);
} catch (error) {
this.showErrorToast!(error as TError);
}
this.toggleLoaderSpinner!();
}
});
}
changeBtnStyleTransactionDialog() {
const dateInput = (<HTMLDataElement>(
this.transactionDialog!.querySelector("[name='selected_date']")
)).value;
const categoryName = (<HTMLDataElement>(
this.transactionDialog!.querySelector("[name='category_name']")
)).value;
const amountInput = +(<HTMLDataElement>(
this.transactionDialog!.querySelector("[name='amount']")
)).value;
const saveBtn = this.transactionDialog!.querySelector('.form__save-btn')!;
saveBtn.classList.toggle(
'active',
<boolean>(dateInput && categoryName && amountInput >= 1),
);
}
initValueTransactionDialog(idTransaction: string | null) {
let categoryName: string;
if (idTransaction) {
// Get transaction object
const transactionArr = this.listTransactions.filter(
(obj) => obj.id === idTransaction,
);
const transaction = Object.assign({}, ...transactionArr);
// Get category object
const categoryArr = this.listCategories.filter(
(obj) => obj.name === transaction.categoryName,
);
const category = Object.assign({}, ...categoryArr);
const idEl = <HTMLDataElement>(
this.transactionDialog!.querySelector("[name='id_transaction']")
);
const dateEl = <HTMLDataElement>(
this.transactionDialog!.querySelector("[name='selected_date']"!)
);
const categoryEl = <HTMLDataElement>(
this.transactionDialog!.querySelector("[name='category_name']")
);
const amountEl = <HTMLDataElement>(
this.transactionDialog!.querySelector("[name='amount']")
);
const noteEl = <HTMLDataElement>(
this.transactionDialog!.querySelector("[name='note']")
);
const iconEl = <HTMLImageElement>(
this.transactionDialog!.querySelector('.category-icon')
);
idEl.value = transaction.id;
dateEl.value = transaction.date;
categoryEl.value = transaction.categoryName;
amountEl.value = Math.abs(transaction.amount).toString();
noteEl.value = transaction.note;
iconEl.src = category.url;
categoryName = categoryEl.value;
}
// Show delete button only if it is a edit form and not a income transaction
const deleteBtn =
this.transactionDialog!.querySelector('.form__delete-btn')!;
const showDeleteBtn = () => {
return idTransaction && categoryName !== 'Income';
};
deleteBtn.classList.toggle('hide', !showDeleteBtn());
}
async submitTransactionDialog(): Promise<void> {
try {
const dateEl = <HTMLDataElement>(
this.transactionForm!.querySelector("[name='selected_date']")
);
const categoryNameEl = <HTMLDataElement>(
this.transactionForm!.querySelector("[name='category_name']")
);
const amountEl = <HTMLDataElement>(
this.transactionForm!.querySelector("[name='amount']")
);
const noteEl = <HTMLDataElement>(
this.transactionForm!.querySelector("[name='note']")
);
const idEl = <HTMLDataElement>(
this.transactionForm!.querySelector("[name='id_transaction']")
);
const amount =
categoryNameEl.value === 'Income' ? +amountEl.value : -+amountEl.value; // Check if this is a income transaction, amount must plus
// Validate data user input
if (
this.validateTransactionForm(dateEl.value, categoryNameEl.value, amount)
) {
this.toggleLoaderSpinner!();
this.transactionDialog!.close();
const transaction = new Transaction(
idEl.value,
categoryNameEl.value,
dateEl.value,
noteEl.value,
+amount,
this.wallet!.idUser,
);
await this.saveTransaction!(transaction);
// Reload data
await this.loadTransactionData!();
await this.updateAmountWallet!();
await this.loadData!();
if (!idEl.value) {
// Add success
this.showSuccessToast!(
TOAST.ADD_TRANSACTION_SUCCESS,
TOAST.DEFAULT_MESSAGE,
);
} else {
// Update success
this.showSuccessToast!(
TOAST.UPDATE_TRANSACTION_SUCCESS,
TOAST.DEFAULT_MESSAGE,
);
}
this.toggleLoaderSpinner!();
this.clearInputTransactionForm();
}
} catch (error) {
this.showErrorToast!(error as TError);
this.toggleLoaderSpinner!();
}
}
validateTransactionForm(
date: string,
categoryName: string,
amount: number,
): boolean {
const inputFieldEls =
this.transactionDialog!.querySelectorAll('.form__input-field');
if (!date || !categoryName || !amount) {
if (!date) {
renderRequiredText('date', inputFieldEls[0]);
inputFieldEls[0].classList.add('error-input');
}
if (!categoryName) {
renderRequiredText('category', inputFieldEls[1]);
inputFieldEls[1].classList.add('error-input');
}
if (!amount) {
renderRequiredText('amount', inputFieldEls[2]);
inputFieldEls[2].classList.add('error-input');
}
return false;
}
return true;
}
clearInputTransactionForm() {
const categoryIcon = <HTMLImageElement>(
this.transactionForm!.querySelector('.category-icon')
);
categoryIcon.src = defaultCategoryIcon;
const keySearchCategory: string = ''; // Delete keyword search
this.categoryView.renderCategoryList(
keySearchCategory,
this.listCategories,
);
this.transactionForm!.reset();
}
showTransactionDialog(idTransaction: string | null = null) {
this.clearInputTransactionForm();
// Init data transaction to dialog
this.initValueTransactionDialog(idTransaction);
if (!idTransaction)
(<HTMLInputElement>(
this.transactionDialog!.querySelector("[name='selected_date']")
)).valueAsDate = new Date(); // Set default value for date input
// Change style submit btn
this.changeBtnStyleTransactionDialog();
this.transactionDialog!.showModal();
}
}
+198
View File
@@ -0,0 +1,198 @@
import Transaction from '../../models/transaction';
import Wallet from '../../models/wallet';
import { renderRequiredText } from '../../helpers/validatorForm';
import EventDataTrigger from 'helpers/evDataTrigger';
import {
Data,
IWalletViewFunc,
Nullable,
PromiseVoid,
TError,
VoidFunc,
} from 'global/types';
import User from 'models/user';
import { FIRST_ADD_WALLET_NOTE } from 'constants/defaultVariable';
import { TOAST } from 'constants/messages';
export default class WalletView {
walletDialog: Nullable<HTMLDialogElement> = null;
evDataTrigger: Nullable<EventDataTrigger> = null;
toggleLoaderSpinner: Nullable<VoidFunc> = null;
saveWallet: Nullable<(wallet: Wallet) => Promise<void>> = null;
saveTransaction: Nullable<(transaction: Transaction) => Promise<void>> = null;
loadTransactionData: Nullable<PromiseVoid> = null;
loadData: Nullable<PromiseVoid> = null;
loadEvent: Nullable<VoidFunc> = null;
showSuccessToast: ((title: string, message: string) => void) | null = null;
showErrorToast: ((error: TError) => void) | null = null;
user: User | null = null;
wallet: Wallet | null = null;
constructor() {
this.walletDialog = document.getElementById(
'walletDialog',
) as HTMLDialogElement;
this.addHandlerEventWalletForm();
}
initFunction(func: IWalletViewFunc) {
this.toggleLoaderSpinner = func.toggleLoaderSpinner;
this.saveWallet = func.saveWallet;
this.saveTransaction = func.saveTransaction;
this.loadTransactionData = func.loadTransactionData;
this.loadData = func.loadData;
this.loadEvent = func.loadEvent;
this.showSuccessToast = func.showSuccessToast;
this.showErrorToast = func.showErrorToast;
this.evDataTrigger = EventDataTrigger.Instance;
}
subscribe() {
this.evDataTrigger!.create('walletView', this.updateData.bind(this));
}
sendData() {
const data = {
wallet: this.wallet!,
user: this.user!,
};
this.evDataTrigger!.onSendSignal('walletView', data);
}
updateData(data: Data) {
if (data.wallet) this.wallet = data.wallet;
if (data.user) this.user = data.user;
}
showDialog() {
this.walletDialog!.showModal();
}
addHandlerEventWalletForm() {
this.walletDialog!.addEventListener('submit', (e) => {
e.preventDefault();
this.clearErrorStyleWalletDialog();
this.submitWalletForm();
});
this.walletDialog!.addEventListener('input', (e) => {
const bodyDialog = (<Element>e.target!).closest('.dialog__body');
this.changeBtnStyleWalletDialog(bodyDialog!);
this.clearErrorStyleWalletDialog();
});
}
clearErrorStyleWalletDialog() {
const inputFieldEls =
this.walletDialog!.querySelectorAll('.form__input-field');
const errorTextEls = this.walletDialog!.querySelectorAll('.error-text');
inputFieldEls.forEach((item) => {
if (item.classList.contains('error-input'))
item.classList.remove('error-input');
});
errorTextEls.forEach((item) => {
if (item) item.remove();
});
}
async submitWalletForm(): Promise<void> {
try {
// Wallet info
const form = document.getElementById('walletForm') as HTMLFormElement;
const walletForm = new FormData(form);
const walletName = walletForm.get('walletName') as string;
const amount = walletForm.get('amount') as string;
if (this.validateWalletDialog(walletName, +amount)) {
this.walletDialog!.close();
this.toggleLoaderSpinner!();
const wallet = new Wallet('', walletName, +amount, 0, this.user!.id);
this.wallet = wallet;
this.sendData();
await this.saveWallet!(wallet);
// Transaction info
const transaction = new Transaction(
'',
'Income',
new Date().toISOString().slice(0, 10),
FIRST_ADD_WALLET_NOTE,
+amount,
this.user!.id,
);
await this.saveTransaction!(transaction);
// Load data and event
await this.loadTransactionData!();
await this.loadData!();
this.loadEvent!();
this.showSuccessToast!(TOAST.ADD_WALLET_SUCCESS, TOAST.DEFAULT_MESSAGE);
this.toggleLoaderSpinner!();
}
} catch (error) {
// Show toast error
this.showErrorToast!(error as TError);
this.toggleLoaderSpinner!();
}
}
validateWalletDialog(walletName: string, amount: number): boolean {
const inputFieldEls =
this.walletDialog!.querySelectorAll('.form__input-field');
if (!walletName || !amount) {
if (!walletName) {
renderRequiredText('wallet name', inputFieldEls[0]);
inputFieldEls[0].classList.add('error-input');
}
if (!amount) {
renderRequiredText('amount', inputFieldEls[2]);
inputFieldEls[2].classList.add('error-input');
}
return false;
}
return true;
}
changeBtnStyleWalletDialog(bodyDialog: Element) {
const walletName = (<HTMLDataElement>(
bodyDialog.querySelector('.form__input-text')
))!.value;
const amount = +(<HTMLDataElement>(
bodyDialog.querySelector('.form__input-balance')!
)).value;
const saveBtn = bodyDialog.querySelector('.form__save-btn');
if (walletName.length >= 3 && amount >= 1) {
saveBtn!.classList.add('active');
} else {
saveBtn!.classList.remove('active');
}
}
}
+28
View File
@@ -0,0 +1,28 @@
import { getSubdirectoryURL } from 'helpers/url';
import LoginView from './loginView';
import RegisterView from './registerView';
import { URL } from 'constants/config';
import HomeView from './home/homeView';
import { Nullable } from 'global/types';
export default class View {
public registerView: Nullable<RegisterView> = null;
public loginView: Nullable<LoginView> = null;
public homeView: Nullable<HomeView> = null;
constructor() {
switch (getSubdirectoryURL()) {
case URL.LOGIN:
this.loginView = new LoginView();
break;
case URL.REGISTER:
this.registerView = new RegisterView();
break;
case URL.HOME:
this.homeView = new HomeView();
default:
}
}
}
+129
View File
@@ -0,0 +1,129 @@
import AuthenticationView from './authenticationView';
import { TypeToast, BTN_CONTENT } from '../constants/config';
import User from 'models/user';
import { TOAST, VALIDATE_FORM } from 'constants/messages';
import { CustomError, Nullable, PromiseOrNull, TError } from 'global/types';
import { redirectToLoginPage } from 'helpers/url';
export default class LoginView extends AuthenticationView {
constructor() {
super();
this.initToast();
this.initLoader();
this.handleEventToast();
}
async loadPage(getInfoUserLogin: PromiseOrNull<User>) {
this.toggleLoaderSpinner();
const user = await getInfoUserLogin();
if (user) {
window.location.replace('/');
}
this.toggleLoaderSpinner();
}
/**
* Implement error toast in site
* @param {string} content The content will show in error toast
*/
initErrorToast(error: TError) {
const title =
typeof error === 'object' && error.title
? error.title
: TOAST.DEFAULT_TITLE_ERROR_TOAST;
const content =
typeof error === 'object' && error.message
? error.message
: (error as string);
this.initToastContent(TypeToast.error, title, content, BTN_CONTENT.OK);
if (this.toastDialog && this.toastBtn) {
// Show toast
this.toastDialog.showModal();
// Remove event for toast button
this.toastBtn.removeEventListener('click', redirectToLoginPage);
}
}
/**
* Add event listener for form input
* @param {Function} validateUser The function need to be set event
*/
addHandlerForm(
loginUser: (email: string, password: string) => Promise<boolean>,
) {
if (this.formEl)
this.formEl.addEventListener('submit', (e: Event) => {
e.preventDefault();
this.clearErrorMessage();
this.submitForm(loginUser, e);
});
}
/**
* The action when submit form
* @param {Function} loginUser The function need to be set event
* * @param {event} event The event target
*/
async submitForm(
loginUser: (email: string, password: string) => Promise<boolean>,
event: Event,
) {
try {
// Load spinner
this.toggleLoaderSpinner();
// Get data from form
const userInput = this.validateForm(event);
if (userInput) {
// Check user exist
const results = await loginUser(userInput.email, userInput.password);
if (results) {
window.location.replace('/');
return;
}
throw new CustomError(
VALIDATE_FORM.ERROR_CREDENTIAL.title,
VALIDATE_FORM.ERROR_CREDENTIAL.message,
);
}
} catch (error) {
// Show toast error
this.initErrorToast(error as TError);
}
// Close spinner
this.toggleLoaderSpinner();
}
/**
* Get data from user input
* @returns {Object || null} Return object or null
*/
validateForm(event: Event): Nullable<User> {
if (event.target) {
const formData = new FormData(event.target as HTMLFormElement);
const email = formData.get('email') as string;
const password = formData.get('password') as string;
// Validate user input
this.listError = []; // Reset list error
const emailValid = this.validateEmail(email);
const passwordValid = this.validatePassword(password);
// Show error style
if (this.emailEl && this.inputPasswordEl) {
this.emailEl.classList.toggle('error-input', !emailValid);
this.inputPasswordEl.classList.toggle('error-input', !passwordValid);
if (emailValid && passwordValid) {
return new User('', email, password);
}
}
}
this.showError(this.listError);
return null;
}
}
+188
View File
@@ -0,0 +1,188 @@
import { TypeToast, BTN_CONTENT } from '../constants/config';
import AuthenticationView from './authenticationView';
import User from '../models/user';
import { redirectToLoginPage } from '../helpers/url';
import { TOAST } from 'constants/messages';
import { Nullable, PromiseOrNull, TError } from 'global/types';
interface UserInput {
email: string;
password: string;
passwordConfirm: string;
}
export default class RegisterView extends AuthenticationView {
constructor() {
super();
this.initToast();
this.initLoader();
this.handleEventToast();
this.toastBtn = document.querySelector('.toast__redirect-btn');
}
async loadPage(getInfoUserLogin: PromiseOrNull<User>) {
this.toggleLoaderSpinner();
const user = await getInfoUserLogin();
if (user) {
window.location.replace('/');
}
this.toggleLoaderSpinner();
}
/**
* Get data from user input
* @returns {Object || null} Return object or null
*/
getUserFromForm(): Nullable<User> {
const form = document.getElementById('registerForm') as HTMLFormElement;
const formData = new FormData(form);
const email = formData.get('email') as string;
const password = formData.get('password') as string;
const passwordConfirm = formData.get('password_confirm') as string;
const userInput: UserInput = {
email,
password,
passwordConfirm,
};
const result = this.validateForm(userInput);
if (result) {
return new User('', email, password);
}
return null;
}
validateForm(userInput: UserInput): boolean {
// Validate user input
this.listError = []; // Reset list error
const emailValid = this.validateEmail(userInput.email);
const passwordValid = this.validatePassword(userInput.password);
const passwordConfirmValid = this.validatePasswordConfirm(
userInput.password,
userInput.passwordConfirm,
);
// Show error style
if (this.emailEl && this.inputPasswordEl && this.inputPasswordConfirmEl) {
this.emailEl.classList.toggle('error-input', !emailValid);
this.inputPasswordEl.classList.toggle('error-input', !passwordValid);
this.inputPasswordConfirmEl.classList.toggle(
'error-input',
!passwordConfirmValid,
);
}
this.showError(this.listError);
return emailValid && passwordValid && passwordConfirmValid;
}
/**
* Implement register success toast in site
*/
showRegisterSuccessToast() {
const typeToast = TypeToast.success;
const title = TOAST.REGISTER_SUCCESS;
const content = TOAST.DEFAULT_MESSAGE;
const btnContent = BTN_CONTENT.OK;
this.initToastContent(typeToast, title, content, btnContent);
// Show toast
if (this.toastDialog) this.toastDialog.showModal();
// Add event for toast button
if (this.toastBtn)
this.toastBtn.addEventListener('click', redirectToLoginPage);
}
/**
* Implement error toast in site
* @param {string} content The content will show in error toast
*/
initErrorToast(error: TError) {
const title =
typeof error === 'object' && error.title
? error.title
: TOAST.DEFAULT_TITLE_ERROR_TOAST;
const content =
typeof error === 'object' && error.message
? error.message
: (error as string);
this.initToastContent(TypeToast.error, title, content, BTN_CONTENT.OK);
if (this.toastDialog && this.toastBtn) {
// Show toast
this.toastDialog.showModal();
// Remove event for toast button
this.toastBtn.removeEventListener('click', redirectToLoginPage);
}
}
/**
* Add event listener for form input
* @param {Function} handler The function need to be set event
*/
addHandlerForm(
checkExistUser: (email: string) => Promise<boolean>,
saveUser: (user: User) => Promise<void>,
) {
if (this.formEl) {
this.formEl.addEventListener('submit', async (e) => {
e.preventDefault();
this.clearErrorMessage();
await this.submitForm(checkExistUser, saveUser);
});
}
}
async submitForm(
checkExistUser: (email: string) => Promise<boolean>,
saveUser: (user: User) => Promise<void>,
): Promise<void> {
try {
// Load spinner
this.toggleLoaderSpinner();
// Get validate form
const user = this.getUserFromForm();
// Save user
if (user) {
// Check user exist
const userExist = await checkExistUser(user.email);
if (userExist) {
throw Error(TOAST.USER_EXIST_ERROR);
} else {
await saveUser(user);
// Show toast success
this.showRegisterSuccessToast();
// Clear form
const formEl = document.getElementById(
'registerForm',
) as HTMLFormElement;
if (formEl) formEl.reset();
}
}
} catch (error) {
// Show toast error
this.initErrorToast(error as TError);
}
// Close spinner
this.toggleLoaderSpinner();
}
}
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"include": ["./**/*.ts", "./**/*.js", "./.*.js"]
}
+32
View File
@@ -0,0 +1,32 @@
{
"compilerOptions": {
/* Language and Environment */
"target": "ES2015" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
"lib": [
"dom",
"ES2017",
"dom.iterable",
"scripthost"
] /* Specify a set of bundled library declaration files that describe the target runtime environment. */,
"baseUrl": "./",
"paths": {
"*": ["./src/ts/*"]
},
"experimentalDecorators": true /* Enable experimental support for legacy experimental decorators. */,
/* Modules */
"module": "commonjs" /* Specify what module code is generated. */,
"rootDir": "./src" /* Specify the root folder within your source files. */,
"sourceMap": true /* Create source map files for emitted JavaScript files. */,
"removeComments": true /* Disable emitting comments. */,
"noEmitOnError": true /* Disable emitting files if any type checking errors are reported. */,
"esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */,
"forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */,
/* Type Checking */
"strict": true /* Enable all strict type-checking options. */,
"noUnusedLocals": true /* Enable error reporting when local variables aren't read. */,
"noUnusedParameters": true /* Raise an error when a function parameter isn't read. */,
"noImplicitReturns": true /* Enable error reporting for codepaths that do not explicitly return in a function. */,
"skipLibCheck": true /* Skip type checking all .d.ts files. */
}
}
+3
View File
@@ -0,0 +1,3 @@
{
"cleanUrls": true
}