Deploy to staging #71
			
				
			
		
		
		
	| 
						 | 
				
			
			@ -29,6 +29,8 @@
 | 
			
		|||
        "react": "^19.0.0",
 | 
			
		||||
        "react-cropper": "^2.3.3",
 | 
			
		||||
        "react-dom": "^19.0.0",
 | 
			
		||||
        "react-json-view": "^1.21.3",
 | 
			
		||||
        "react-player": "^2.16.0",
 | 
			
		||||
        "react-router": "^6.29.0",
 | 
			
		||||
        "react-router-dom": "^6.29.0",
 | 
			
		||||
        "react-toastify": "^11.0.3",
 | 
			
		||||
| 
						 | 
				
			
			@ -2106,6 +2108,12 @@
 | 
			
		|||
        "punycode": "^2.1.0"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/asap": {
 | 
			
		||||
      "version": "2.0.6",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz",
 | 
			
		||||
      "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==",
 | 
			
		||||
      "license": "MIT"
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/asynckit": {
 | 
			
		||||
      "version": "0.4.0",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
 | 
			
		||||
| 
						 | 
				
			
			@ -2133,6 +2141,12 @@
 | 
			
		|||
        "url": "https://github.com/sponsors/wooorm"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/base16": {
 | 
			
		||||
      "version": "1.0.0",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/base16/-/base16-1.0.0.tgz",
 | 
			
		||||
      "integrity": "sha512-pNdYkNPiJUnEhnfXV56+sQy8+AaPcG3POZAUnwr4EeqCUZFz4u2PePbo3e5Gj4ziYPCWGUZT9RHisvJKnwFuBQ==",
 | 
			
		||||
      "license": "MIT"
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/bcp-47-match": {
 | 
			
		||||
      "version": "2.0.3",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/bcp-47-match/-/bcp-47-match-2.0.3.tgz",
 | 
			
		||||
| 
						 | 
				
			
			@ -2393,6 +2407,15 @@
 | 
			
		|||
      "integrity": "sha512-nhymn9GdnV3CqiEHJVai54TULFAE3VshJTXSqSJKa8yXAKyBKDWdhHarnlIPrshJ0WMFTGuFvG02YjLXfPiuOA==",
 | 
			
		||||
      "license": "MIT"
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/cross-fetch": {
 | 
			
		||||
      "version": "3.2.0",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz",
 | 
			
		||||
      "integrity": "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==",
 | 
			
		||||
      "license": "MIT",
 | 
			
		||||
      "dependencies": {
 | 
			
		||||
        "node-fetch": "^2.7.0"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/cross-spawn": {
 | 
			
		||||
      "version": "7.0.6",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
 | 
			
		||||
| 
						 | 
				
			
			@ -2675,6 +2698,15 @@
 | 
			
		|||
        "url": "https://github.com/sponsors/wooorm"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/deepmerge": {
 | 
			
		||||
      "version": "4.3.1",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
 | 
			
		||||
      "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
 | 
			
		||||
      "license": "MIT",
 | 
			
		||||
      "engines": {
 | 
			
		||||
        "node": ">=0.10.0"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/delayed-stream": {
 | 
			
		||||
      "version": "1.0.0",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
 | 
			
		||||
| 
						 | 
				
			
			@ -3138,6 +3170,36 @@
 | 
			
		|||
        "reusify": "^1.0.4"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/fbemitter": {
 | 
			
		||||
      "version": "3.0.0",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/fbemitter/-/fbemitter-3.0.0.tgz",
 | 
			
		||||
      "integrity": "sha512-KWKaceCwKQU0+HPoop6gn4eOHk50bBv/VxjJtGMfwmJt3D29JpN4H4eisCtIPA+a8GVBam+ldMMpMjJUvpDyHw==",
 | 
			
		||||
      "license": "BSD-3-Clause",
 | 
			
		||||
      "dependencies": {
 | 
			
		||||
        "fbjs": "^3.0.0"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/fbjs": {
 | 
			
		||||
      "version": "3.0.5",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/fbjs/-/fbjs-3.0.5.tgz",
 | 
			
		||||
      "integrity": "sha512-ztsSx77JBtkuMrEypfhgc3cI0+0h+svqeie7xHbh1k/IKdcydnvadp/mUaGgjAOXQmQSxsqgaRhS3q9fy+1kxg==",
 | 
			
		||||
      "license": "MIT",
 | 
			
		||||
      "dependencies": {
 | 
			
		||||
        "cross-fetch": "^3.1.5",
 | 
			
		||||
        "fbjs-css-vars": "^1.0.0",
 | 
			
		||||
        "loose-envify": "^1.0.0",
 | 
			
		||||
        "object-assign": "^4.1.0",
 | 
			
		||||
        "promise": "^7.1.1",
 | 
			
		||||
        "setimmediate": "^1.0.5",
 | 
			
		||||
        "ua-parser-js": "^1.0.35"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/fbjs-css-vars": {
 | 
			
		||||
      "version": "1.0.2",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/fbjs-css-vars/-/fbjs-css-vars-1.0.2.tgz",
 | 
			
		||||
      "integrity": "sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ==",
 | 
			
		||||
      "license": "MIT"
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/file-entry-cache": {
 | 
			
		||||
      "version": "8.0.0",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
 | 
			
		||||
| 
						 | 
				
			
			@ -4077,12 +4139,30 @@
 | 
			
		|||
        "url": "https://opencollective.com/parcel"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/load-script": {
 | 
			
		||||
      "version": "1.0.0",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/load-script/-/load-script-1.0.0.tgz",
 | 
			
		||||
      "integrity": "sha512-kPEjMFtZvwL9TaZo0uZ2ml+Ye9HUMmPwbYRJ324qF9tqMejwykJ5ggTyvzmrbBeapCAbk98BSbTeovHEEP1uCA==",
 | 
			
		||||
      "license": "MIT"
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/lodash": {
 | 
			
		||||
      "version": "4.17.21",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
 | 
			
		||||
      "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
 | 
			
		||||
      "license": "MIT"
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/lodash.curry": {
 | 
			
		||||
      "version": "4.1.1",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/lodash.curry/-/lodash.curry-4.1.1.tgz",
 | 
			
		||||
      "integrity": "sha512-/u14pXGviLaweY5JI0IUzgzF2J6Ne8INyzAZjImcryjgkZ+ebruBxy2/JaOOkTqScddcYtakjhSaeemV8lR0tA==",
 | 
			
		||||
      "license": "MIT"
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/lodash.flow": {
 | 
			
		||||
      "version": "3.5.0",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/lodash.flow/-/lodash.flow-3.5.0.tgz",
 | 
			
		||||
      "integrity": "sha512-ff3BX/tSioo+XojX4MOsOMhJw0nZoUEF011LX8g8d3gvjVbxd89cCio4BCXronjxcTUIJUoqKEUA+n4CqvvRPw==",
 | 
			
		||||
      "license": "MIT"
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/lodash.merge": {
 | 
			
		||||
      "version": "4.6.2",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
 | 
			
		||||
| 
						 | 
				
			
			@ -4413,6 +4493,12 @@
 | 
			
		|||
        "url": "https://opencollective.com/unified"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/memoize-one": {
 | 
			
		||||
      "version": "5.2.1",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
 | 
			
		||||
      "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==",
 | 
			
		||||
      "license": "MIT"
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/merge2": {
 | 
			
		||||
      "version": "1.4.1",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
 | 
			
		||||
| 
						 | 
				
			
			@ -5099,6 +5185,26 @@
 | 
			
		|||
      "dev": true,
 | 
			
		||||
      "license": "MIT"
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/node-fetch": {
 | 
			
		||||
      "version": "2.7.0",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
 | 
			
		||||
      "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
 | 
			
		||||
      "license": "MIT",
 | 
			
		||||
      "dependencies": {
 | 
			
		||||
        "whatwg-url": "^5.0.0"
 | 
			
		||||
      },
 | 
			
		||||
      "engines": {
 | 
			
		||||
        "node": "4.x || >=6.0.0"
 | 
			
		||||
      },
 | 
			
		||||
      "peerDependencies": {
 | 
			
		||||
        "encoding": "^0.1.0"
 | 
			
		||||
      },
 | 
			
		||||
      "peerDependenciesMeta": {
 | 
			
		||||
        "encoding": {
 | 
			
		||||
          "optional": true
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/node-releases": {
 | 
			
		||||
      "version": "2.0.19",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
 | 
			
		||||
| 
						 | 
				
			
			@ -5409,6 +5515,15 @@
 | 
			
		|||
        "postcss": "^8.2.1"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/promise": {
 | 
			
		||||
      "version": "7.3.1",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz",
 | 
			
		||||
      "integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==",
 | 
			
		||||
      "license": "MIT",
 | 
			
		||||
      "dependencies": {
 | 
			
		||||
        "asap": "~2.0.3"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/prop-types": {
 | 
			
		||||
      "version": "15.8.1",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
 | 
			
		||||
| 
						 | 
				
			
			@ -5436,6 +5551,12 @@
 | 
			
		|||
      "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
 | 
			
		||||
      "license": "MIT"
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/pure-color": {
 | 
			
		||||
      "version": "1.3.0",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/pure-color/-/pure-color-1.3.0.tgz",
 | 
			
		||||
      "integrity": "sha512-QFADYnsVoBMw1srW7OVKEYjG+MbIa49s54w1MA1EDY6r2r/sTcKKYqRX1f4GYvnXP7eN/Pe9HFcX+hwzmrXRHA==",
 | 
			
		||||
      "license": "MIT"
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/queue-microtask": {
 | 
			
		||||
      "version": "1.2.3",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
 | 
			
		||||
| 
						 | 
				
			
			@ -5466,6 +5587,18 @@
 | 
			
		|||
        "node": ">=0.10.0"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/react-base16-styling": {
 | 
			
		||||
      "version": "0.6.0",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/react-base16-styling/-/react-base16-styling-0.6.0.tgz",
 | 
			
		||||
      "integrity": "sha512-yvh/7CArceR/jNATXOKDlvTnPKPmGZz7zsenQ3jUwLzHkNUR0CvY3yGYJbWJ/nnxsL8Sgmt5cO3/SILVuPO6TQ==",
 | 
			
		||||
      "license": "MIT",
 | 
			
		||||
      "dependencies": {
 | 
			
		||||
        "base16": "^1.0.0",
 | 
			
		||||
        "lodash.curry": "^4.0.1",
 | 
			
		||||
        "lodash.flow": "^3.3.0",
 | 
			
		||||
        "pure-color": "^1.2.0"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/react-cropper": {
 | 
			
		||||
      "version": "2.3.3",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/react-cropper/-/react-cropper-2.3.3.tgz",
 | 
			
		||||
| 
						 | 
				
			
			@ -5505,12 +5638,53 @@
 | 
			
		|||
        "react": ">= 16.8 || 18.0.0"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/react-fast-compare": {
 | 
			
		||||
      "version": "3.2.2",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz",
 | 
			
		||||
      "integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==",
 | 
			
		||||
      "license": "MIT"
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/react-is": {
 | 
			
		||||
      "version": "16.13.1",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
 | 
			
		||||
      "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
 | 
			
		||||
      "license": "MIT"
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/react-json-view": {
 | 
			
		||||
      "version": "1.21.3",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/react-json-view/-/react-json-view-1.21.3.tgz",
 | 
			
		||||
      "integrity": "sha512-13p8IREj9/x/Ye4WI/JpjhoIwuzEgUAtgJZNBJckfzJt1qyh24BdTm6UQNGnyTq9dapQdrqvquZTo3dz1X6Cjw==",
 | 
			
		||||
      "license": "MIT",
 | 
			
		||||
      "dependencies": {
 | 
			
		||||
        "flux": "^4.0.1",
 | 
			
		||||
        "react-base16-styling": "^0.6.0",
 | 
			
		||||
        "react-lifecycles-compat": "^3.0.4",
 | 
			
		||||
        "react-textarea-autosize": "^8.3.2"
 | 
			
		||||
      },
 | 
			
		||||
      "peerDependencies": {
 | 
			
		||||
        "react": "^17.0.0 || ^16.3.0 || ^15.5.4",
 | 
			
		||||
        "react-dom": "^17.0.0 || ^16.3.0 || ^15.5.4"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/react-json-view/node_modules/flux": {
 | 
			
		||||
      "version": "4.0.4",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/flux/-/flux-4.0.4.tgz",
 | 
			
		||||
      "integrity": "sha512-NCj3XlayA2UsapRpM7va6wU1+9rE5FIL7qoMcmxWHRzbp0yujihMBm9BBHZ1MDIk5h5o2Bl6eGiCe8rYELAmYw==",
 | 
			
		||||
      "license": "BSD-3-Clause",
 | 
			
		||||
      "dependencies": {
 | 
			
		||||
        "fbemitter": "^3.0.0",
 | 
			
		||||
        "fbjs": "^3.0.1"
 | 
			
		||||
      },
 | 
			
		||||
      "peerDependencies": {
 | 
			
		||||
        "react": "^15.0.2 || ^16.0.0 || ^17.0.0"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/react-lifecycles-compat": {
 | 
			
		||||
      "version": "3.0.4",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
 | 
			
		||||
      "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==",
 | 
			
		||||
      "license": "MIT"
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/react-markdown": {
 | 
			
		||||
      "version": "9.0.3",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-9.0.3.tgz",
 | 
			
		||||
| 
						 | 
				
			
			@ -5547,6 +5721,22 @@
 | 
			
		|||
        "react-dom": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/react-player": {
 | 
			
		||||
      "version": "2.16.0",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/react-player/-/react-player-2.16.0.tgz",
 | 
			
		||||
      "integrity": "sha512-mAIPHfioD7yxO0GNYVFD1303QFtI3lyyQZLY229UEAp/a10cSW+hPcakg0Keq8uWJxT2OiT/4Gt+Lc9bD6bJmQ==",
 | 
			
		||||
      "license": "MIT",
 | 
			
		||||
      "dependencies": {
 | 
			
		||||
        "deepmerge": "^4.0.0",
 | 
			
		||||
        "load-script": "^1.0.0",
 | 
			
		||||
        "memoize-one": "^5.1.1",
 | 
			
		||||
        "prop-types": "^15.7.2",
 | 
			
		||||
        "react-fast-compare": "^3.0.1"
 | 
			
		||||
      },
 | 
			
		||||
      "peerDependencies": {
 | 
			
		||||
        "react": ">=16.6.0"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/react-refresh": {
 | 
			
		||||
      "version": "0.14.2",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz",
 | 
			
		||||
| 
						 | 
				
			
			@ -6139,6 +6329,12 @@
 | 
			
		|||
        "semver": "bin/semver.js"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/setimmediate": {
 | 
			
		||||
      "version": "1.0.5",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
 | 
			
		||||
      "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
 | 
			
		||||
      "license": "MIT"
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/socket.io-client": {
 | 
			
		||||
      "version": "4.8.1",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz",
 | 
			
		||||
| 
						 | 
				
			
			@ -6311,6 +6507,12 @@
 | 
			
		|||
        "node": ">=8.0"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/tr46": {
 | 
			
		||||
      "version": "0.0.3",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
 | 
			
		||||
      "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
 | 
			
		||||
      "license": "MIT"
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/trim-lines": {
 | 
			
		||||
      "version": "3.0.1",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
 | 
			
		||||
| 
						 | 
				
			
			@ -6386,6 +6588,32 @@
 | 
			
		|||
        "typescript": ">=4.8.4 <5.8.0"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/ua-parser-js": {
 | 
			
		||||
      "version": "1.0.40",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.40.tgz",
 | 
			
		||||
      "integrity": "sha512-z6PJ8Lml+v3ichVojCiB8toQJBuwR42ySM4ezjXIqXK3M0HczmKQ3LF4rhU55PfD99KEEXQG6yb7iOMyvYuHew==",
 | 
			
		||||
      "funding": [
 | 
			
		||||
        {
 | 
			
		||||
          "type": "opencollective",
 | 
			
		||||
          "url": "https://opencollective.com/ua-parser-js"
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
          "type": "paypal",
 | 
			
		||||
          "url": "https://paypal.me/faisalman"
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
          "type": "github",
 | 
			
		||||
          "url": "https://github.com/sponsors/faisalman"
 | 
			
		||||
        }
 | 
			
		||||
      ],
 | 
			
		||||
      "license": "MIT",
 | 
			
		||||
      "bin": {
 | 
			
		||||
        "ua-parser-js": "script/cli.js"
 | 
			
		||||
      },
 | 
			
		||||
      "engines": {
 | 
			
		||||
        "node": "*"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/unified": {
 | 
			
		||||
      "version": "11.0.5",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
 | 
			
		||||
| 
						 | 
				
			
			@ -7507,6 +7735,22 @@
 | 
			
		|||
        "url": "https://github.com/sponsors/wooorm"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/webidl-conversions": {
 | 
			
		||||
      "version": "3.0.1",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
 | 
			
		||||
      "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
 | 
			
		||||
      "license": "BSD-2-Clause"
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/whatwg-url": {
 | 
			
		||||
      "version": "5.0.0",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
 | 
			
		||||
      "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
 | 
			
		||||
      "license": "MIT",
 | 
			
		||||
      "dependencies": {
 | 
			
		||||
        "tr46": "~0.0.3",
 | 
			
		||||
        "webidl-conversions": "^3.0.0"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/ws": {
 | 
			
		||||
      "version": "8.17.1",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -31,6 +31,8 @@
 | 
			
		|||
    "react": "^19.0.0",
 | 
			
		||||
    "react-cropper": "^2.3.3",
 | 
			
		||||
    "react-dom": "^19.0.0",
 | 
			
		||||
    "react-json-view": "^1.21.3",
 | 
			
		||||
    "react-player": "^2.16.0",
 | 
			
		||||
    "react-router": "^6.29.0",
 | 
			
		||||
    "react-router-dom": "^6.29.0",
 | 
			
		||||
    "react-toastify": "^11.0.3",
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,25 +1,27 @@
 | 
			
		|||
import { generateNestParams, handleError, handleSuccess } from '.';
 | 
			
		||||
import axios from '../lib/axios';
 | 
			
		||||
import { IBid, IWebBid } from '../system/type';
 | 
			
		||||
import { removeFalsyValues } from '../utils';
 | 
			
		||||
import { generateNestParams, handleError, handleSuccess } from ".";
 | 
			
		||||
import axios from "../lib/axios";
 | 
			
		||||
import { IBid, IMetadata, IWebBid } from "../system/type";
 | 
			
		||||
import { removeFalsyValues } from "../utils";
 | 
			
		||||
 | 
			
		||||
export const getBids = async (params: Record<string, string | number>) => {
 | 
			
		||||
  return await axios({
 | 
			
		||||
        url: 'bids',
 | 
			
		||||
    url: "bids",
 | 
			
		||||
    params: generateNestParams(params),
 | 
			
		||||
    withCredentials: true,
 | 
			
		||||
        method: 'GET',
 | 
			
		||||
    method: "GET",
 | 
			
		||||
  });
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const createBid = async (bid: Omit<IBid, 'id' | 'created_at' | 'updated_at' | 'is_system_account'>) => {
 | 
			
		||||
export const createBid = async (
 | 
			
		||||
  bid: Omit<IBid, "id" | "created_at" | "updated_at" | "is_system_account">
 | 
			
		||||
) => {
 | 
			
		||||
  const newData = removeFalsyValues(bid);
 | 
			
		||||
 | 
			
		||||
  try {
 | 
			
		||||
    const { data } = await axios({
 | 
			
		||||
            url: 'bids',
 | 
			
		||||
      url: "bids",
 | 
			
		||||
      withCredentials: true,
 | 
			
		||||
            method: 'POST',
 | 
			
		||||
      method: "POST",
 | 
			
		||||
      data: newData,
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -32,14 +34,16 @@ export const createBid = async (bid: Omit<IBid, 'id' | 'created_at' | 'updated_a
 | 
			
		|||
};
 | 
			
		||||
 | 
			
		||||
export const updateBid = async (bid: Partial<IBid>) => {
 | 
			
		||||
    const { plus_price, max_price, quantity } = removeFalsyValues(bid, ['plus_price']);
 | 
			
		||||
  const { plus_price, max_price, quantity, metadata } = removeFalsyValues(bid, [
 | 
			
		||||
    "plus_price",
 | 
			
		||||
  ]);
 | 
			
		||||
 | 
			
		||||
  try {
 | 
			
		||||
    const { data } = await axios({
 | 
			
		||||
            url: 'bids/' + bid.id,
 | 
			
		||||
      url: "bids/" + bid.id,
 | 
			
		||||
      withCredentials: true,
 | 
			
		||||
            method: 'PUT',
 | 
			
		||||
            data: { plus_price, max_price, quantity },
 | 
			
		||||
      method: "PUT",
 | 
			
		||||
      data: { plus_price, max_price, quantity, metadata },
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    handleSuccess(data);
 | 
			
		||||
| 
						 | 
				
			
			@ -53,9 +57,9 @@ export const updateBid = async (bid: Partial<IBid>) => {
 | 
			
		|||
export const toggleBid = async (bid: Partial<IBid>) => {
 | 
			
		||||
  try {
 | 
			
		||||
    const { data } = await axios({
 | 
			
		||||
            url: 'bids/toggle/' + bid.id,
 | 
			
		||||
      url: "bids/toggle/" + bid.id,
 | 
			
		||||
      withCredentials: true,
 | 
			
		||||
            method: 'POST',
 | 
			
		||||
      method: "POST",
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    handleSuccess(data);
 | 
			
		||||
| 
						 | 
				
			
			@ -69,9 +73,9 @@ export const toggleBid = async (bid: Partial<IBid>) => {
 | 
			
		|||
export const deleteBid = async (bid: IBid) => {
 | 
			
		||||
  try {
 | 
			
		||||
    const { data } = await axios({
 | 
			
		||||
            url: 'bids/' + bid.id,
 | 
			
		||||
      url: "bids/" + bid.id,
 | 
			
		||||
      withCredentials: true,
 | 
			
		||||
            method: 'DELETE',
 | 
			
		||||
      method: "DELETE",
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    handleSuccess(data);
 | 
			
		||||
| 
						 | 
				
			
			@ -89,9 +93,9 @@ export const deletesBid = async (bids: IBid[]) => {
 | 
			
		|||
  }, [] as number[]);
 | 
			
		||||
  try {
 | 
			
		||||
    const { data } = await axios({
 | 
			
		||||
            url: 'bids/deletes',
 | 
			
		||||
      url: "bids/deletes",
 | 
			
		||||
      withCredentials: true,
 | 
			
		||||
            method: 'POST',
 | 
			
		||||
      method: "POST",
 | 
			
		||||
      data: {
 | 
			
		||||
        ids,
 | 
			
		||||
      },
 | 
			
		||||
| 
						 | 
				
			
			@ -105,16 +109,79 @@ export const deletesBid = async (bids: IBid[]) => {
 | 
			
		|||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const getImagesWorking = async (values: (IBid | IWebBid) & { type: string }) => {
 | 
			
		||||
export const getImagesWorking = async (
 | 
			
		||||
  values: (IBid | IWebBid) & { type: string }
 | 
			
		||||
) => {
 | 
			
		||||
  try {
 | 
			
		||||
    const { data } = await axios({
 | 
			
		||||
            url: `bids/images-working/${values.type.toLocaleLowerCase().replace('_', '-')}/${values.id}`,
 | 
			
		||||
      url: `bids/images-working/${values.type
 | 
			
		||||
        .toLocaleLowerCase()
 | 
			
		||||
        .replace("_", "-")}/${values.id}`,
 | 
			
		||||
      withCredentials: true,
 | 
			
		||||
            method: 'GET',
 | 
			
		||||
      method: "GET",
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    return data;
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
        console.log('%csrc/apis/bid.ts:118 error', 'color: #007acc;', error);
 | 
			
		||||
    console.log("%csrc/apis/bid.ts:118 error", "color: #007acc;", error);
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const getRecords = async (values: IBid) => {
 | 
			
		||||
  try {
 | 
			
		||||
    const { data } = await axios({
 | 
			
		||||
      url: `bids/records/${values.id}`,
 | 
			
		||||
      withCredentials: true,
 | 
			
		||||
      method: "GET",
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    return data;
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.log("%csrc/apis/bid.ts:118 error", "color: #007acc;", error);
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const deleteRecord = async (name: string) => {
 | 
			
		||||
  try {
 | 
			
		||||
    const { data } = await axios({
 | 
			
		||||
      url: `bids/record/${name}`,
 | 
			
		||||
      withCredentials: true,
 | 
			
		||||
      method: "DELETE",
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    return data;
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.log("%csrc/apis/bid.ts:154 error", "color: #007acc;", error);
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const deleteMetadata = async (id: IMetadata["id"]) => {
 | 
			
		||||
  try {
 | 
			
		||||
    const { data } = await axios({
 | 
			
		||||
      url: `bid-metadatas/${id}`,
 | 
			
		||||
      withCredentials: true,
 | 
			
		||||
      method: "DELETE",
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    return data;
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.log("%csrc/apis/bid.ts:154 error", "color: #007acc;", error);
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const updateMetadata = async (id: IMetadata["id"], value: string) => {
 | 
			
		||||
  try {
 | 
			
		||||
    const { data } = await axios({
 | 
			
		||||
      url: `bid-metadatas/${id}`,
 | 
			
		||||
      withCredentials: true,
 | 
			
		||||
      method: "PUT",
 | 
			
		||||
      data: {
 | 
			
		||||
        value,
 | 
			
		||||
      },
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    return data;
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.log("%csrc/apis/bid.ts:154 error", "color: #007acc;", error);
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,25 +1,65 @@
 | 
			
		|||
/* eslint-disable @typescript-eslint/no-explicit-any */
 | 
			
		||||
/* eslint-disable @typescript-eslint/no-unused-vars */
 | 
			
		||||
import { Button, LoadingOverlay, Modal, ModalProps, NumberInput, TextInput } from '@mantine/core';
 | 
			
		||||
import { useForm, zodResolver } from '@mantine/form';
 | 
			
		||||
import _ from 'lodash';
 | 
			
		||||
import { useEffect, useRef, useState } from 'react';
 | 
			
		||||
import { z } from 'zod';
 | 
			
		||||
import { createBid, updateBid } from '../../apis/bid';
 | 
			
		||||
import { useConfirmStore } from '../../lib/zustand/use-confirm';
 | 
			
		||||
import { IBid } from '../../system/type';
 | 
			
		||||
import {
 | 
			
		||||
  Button,
 | 
			
		||||
  LoadingOverlay,
 | 
			
		||||
  Modal,
 | 
			
		||||
  ModalProps,
 | 
			
		||||
  NumberInput,
 | 
			
		||||
  Select,
 | 
			
		||||
  TextInput,
 | 
			
		||||
} from "@mantine/core";
 | 
			
		||||
import { useForm, zodResolver } from "@mantine/form";
 | 
			
		||||
import _ from "lodash";
 | 
			
		||||
import { useEffect, useRef, useState } from "react";
 | 
			
		||||
import { z } from "zod";
 | 
			
		||||
import { createBid, updateBid } from "../../apis/bid";
 | 
			
		||||
import { useConfirmStore } from "../../lib/zustand/use-confirm";
 | 
			
		||||
import { IBid, IMetadata } from "../../system/type";
 | 
			
		||||
import { formatTimeFromMinutes } from "../../utils";
 | 
			
		||||
export interface IBidModelProps extends ModalProps {
 | 
			
		||||
  data: IBid | null;
 | 
			
		||||
  onUpdated?: () => void;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const schema = {
 | 
			
		||||
    url: z.string({ message: 'Url is required' }).url('Invalid url format'),
 | 
			
		||||
    max_price: z.number({ message: 'Max price is required' }).min(1, 'Max price must be at least 1'),
 | 
			
		||||
    plus_price: z.number().min(0, 'Plus price must be at least 1').optional(),
 | 
			
		||||
    quantity: z.number().min(1, 'Quantity must be at least 1').optional(),
 | 
			
		||||
  url: z.string({ message: "Url is required" }).url("Invalid url format"),
 | 
			
		||||
  max_price: z
 | 
			
		||||
    .number({ message: "Max price is required" })
 | 
			
		||||
    .min(1, "Max price must be at least 1"),
 | 
			
		||||
  plus_price: z.number().min(0, "Plus price must be at least 1").optional(),
 | 
			
		||||
  quantity: z.number().min(1, "Quantity must be at least 1").optional(),
 | 
			
		||||
  arrival_offset_seconds_live: z
 | 
			
		||||
    .number({ message: "Arrival offset seconds is required" })
 | 
			
		||||
    .refine((val) => val >= 60, {
 | 
			
		||||
      message: "Arrival offset seconds must be at least 60 seconds (1 minute)",
 | 
			
		||||
    })
 | 
			
		||||
    .optional(),
 | 
			
		||||
  early_tracking_seconds_live: z
 | 
			
		||||
    .number({ message: "Early login seconds is required" })
 | 
			
		||||
    .refine((val) => val >= 600, {
 | 
			
		||||
      message: "Early login seconds must be at least 600 seconds (10 minute)",
 | 
			
		||||
    })
 | 
			
		||||
    .optional(),
 | 
			
		||||
  arrival_offset_seconds_sandbox: z
 | 
			
		||||
    .number({ message: "Arrival offset seconds is required" })
 | 
			
		||||
    .refine((val) => val >= 60, {
 | 
			
		||||
      message: "Arrival offset seconds must be at least 60 seconds (1 minute)",
 | 
			
		||||
    })
 | 
			
		||||
    .optional(),
 | 
			
		||||
  early_tracking_seconds_sandbox: z
 | 
			
		||||
    .number({ message: "Early login seconds is required" })
 | 
			
		||||
    .refine((val) => val >= 600, {
 | 
			
		||||
      message: "Early login seconds must be at least 600 seconds (10 minute)",
 | 
			
		||||
    })
 | 
			
		||||
    .optional(),
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default function BidModal({ data, onUpdated, ...props }: IBidModelProps) {
 | 
			
		||||
export default function BidModal({
 | 
			
		||||
  data,
 | 
			
		||||
  onUpdated,
 | 
			
		||||
  ...props
 | 
			
		||||
}: IBidModelProps) {
 | 
			
		||||
  const form = useForm({
 | 
			
		||||
    validate: zodResolver(z.object(schema)),
 | 
			
		||||
  });
 | 
			
		||||
| 
						 | 
				
			
			@ -33,11 +73,16 @@ export default function BidModal({ data, onUpdated, ...props }: IBidModelProps)
 | 
			
		|||
  const handleSubmit = async (values: typeof form.values) => {
 | 
			
		||||
    if (data) {
 | 
			
		||||
      setConfirm({
 | 
			
		||||
                title: 'Update ?',
 | 
			
		||||
        title: "Update ?",
 | 
			
		||||
        message: `This product will be update`,
 | 
			
		||||
        handleOk: async () => {
 | 
			
		||||
          setLoading(true);
 | 
			
		||||
                    const result = await updateBid(values);
 | 
			
		||||
 | 
			
		||||
          const metadata = valuesToMetadata(
 | 
			
		||||
            values as IBid & Record<string, any>
 | 
			
		||||
          );
 | 
			
		||||
 | 
			
		||||
          const result = await updateBid({ ...values, metadata });
 | 
			
		||||
          setLoading(false);
 | 
			
		||||
 | 
			
		||||
          if (!result) return;
 | 
			
		||||
| 
						 | 
				
			
			@ -49,8 +94,8 @@ export default function BidModal({ data, onUpdated, ...props }: IBidModelProps)
 | 
			
		|||
          }
 | 
			
		||||
        },
 | 
			
		||||
        okButton: {
 | 
			
		||||
                    color: 'blue',
 | 
			
		||||
                    value: 'Update',
 | 
			
		||||
          color: "blue",
 | 
			
		||||
          value: "Update",
 | 
			
		||||
        },
 | 
			
		||||
      });
 | 
			
		||||
    } else {
 | 
			
		||||
| 
						 | 
				
			
			@ -71,11 +116,80 @@ export default function BidModal({ data, onUpdated, ...props }: IBidModelProps)
 | 
			
		|||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const mappingValues = (ignore: string[] = []) => {
 | 
			
		||||
    if (!data) return {};
 | 
			
		||||
    let values: IBid & Record<string, any> = data;
 | 
			
		||||
 | 
			
		||||
    const followKey = ["arrival_offset_seconds", "early_tracking_seconds"];
 | 
			
		||||
 | 
			
		||||
    if (
 | 
			
		||||
      data.metadata.length &&
 | 
			
		||||
      data.metadata.some((item) => item.key_name === "mode_key")
 | 
			
		||||
    ) {
 | 
			
		||||
      data.metadata.reduce((prev, cur) => {
 | 
			
		||||
        if (ignore.includes(cur.key_name)) {
 | 
			
		||||
          prev[cur.key_name as string] = form.values[cur.key_name];
 | 
			
		||||
 | 
			
		||||
          return prev;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (cur.key_name === "mode_key") {
 | 
			
		||||
          prev[cur.key_name as string] = cur.value;
 | 
			
		||||
          return prev;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        prev[cur.key_name as string] = cur.value;
 | 
			
		||||
 | 
			
		||||
        return prev;
 | 
			
		||||
      }, values);
 | 
			
		||||
    } else {
 | 
			
		||||
      const metadata = Object.assign(
 | 
			
		||||
        { mode_key: "live" },
 | 
			
		||||
        ...followKey.map((item) => ({
 | 
			
		||||
          [`${item}_live`]: data.web_bid[item as keyof typeof data.web_bid],
 | 
			
		||||
          [`${item}_sandbox`]: data.web_bid[item as keyof typeof data.web_bid],
 | 
			
		||||
        }))
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
      values = { ...values, ...metadata };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return values;
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const valuesToMetadata = (values: IBid & Record<string, any>) => {
 | 
			
		||||
    const keys = [
 | 
			
		||||
      "mode_key",
 | 
			
		||||
      "arrival_offset_seconds_live",
 | 
			
		||||
      "arrival_offset_seconds_sandbox",
 | 
			
		||||
      "early_tracking_seconds_live",
 | 
			
		||||
      "early_tracking_seconds_sandbox",
 | 
			
		||||
    ];
 | 
			
		||||
 | 
			
		||||
    if (values.metadata.length <= 0) {
 | 
			
		||||
      return keys.map((item) => {
 | 
			
		||||
        return {
 | 
			
		||||
          key_name: item,
 | 
			
		||||
          value: values[item],
 | 
			
		||||
        } as IMetadata;
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return values.metadata.map((item) => {
 | 
			
		||||
      return {
 | 
			
		||||
        ...item,
 | 
			
		||||
        value: values[item.key_name],
 | 
			
		||||
      };
 | 
			
		||||
    });
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    form.reset();
 | 
			
		||||
    if (!data) return;
 | 
			
		||||
 | 
			
		||||
        form.setValues(data);
 | 
			
		||||
    const values = mappingValues();
 | 
			
		||||
 | 
			
		||||
    form.setValues(values);
 | 
			
		||||
 | 
			
		||||
    prevData.current = data;
 | 
			
		||||
    // eslint-disable-next-line react-hooks/exhaustive-deps
 | 
			
		||||
| 
						 | 
				
			
			@ -88,30 +202,129 @@ export default function BidModal({ data, onUpdated, ...props }: IBidModelProps)
 | 
			
		|||
    // eslint-disable-next-line react-hooks/exhaustive-deps
 | 
			
		||||
  }, [props.opened]);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    const values = mappingValues(["mode_key"]);
 | 
			
		||||
    form.setValues(values);
 | 
			
		||||
 | 
			
		||||
    prevData.current = data;
 | 
			
		||||
 | 
			
		||||
    // eslint-disable-next-line react-hooks/exhaustive-deps
 | 
			
		||||
  }, [form.values["mode_key"]]);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Modal
 | 
			
		||||
      className="relative"
 | 
			
		||||
      classNames={{
 | 
			
		||||
                header: '!flex !item-center !justify-center w-full',
 | 
			
		||||
        header: "!flex !item-center !justify-center w-full",
 | 
			
		||||
      }}
 | 
			
		||||
      {...props}
 | 
			
		||||
            size={'xl'}
 | 
			
		||||
      size={"xl"}
 | 
			
		||||
      title={<span className="text-xl font-bold">Bid</span>}
 | 
			
		||||
      centered
 | 
			
		||||
    >
 | 
			
		||||
            <form onSubmit={form.onSubmit(handleSubmit)} className="grid grid-cols-2 gap-2.5">
 | 
			
		||||
                {data && data.name && <TextInput className="col-span-2" readOnly={!!data} size="sm" label="Name" value={data.name} />}
 | 
			
		||||
                <TextInput className="col-span-2" readOnly={!!data} size="sm" label="Url" {...form.getInputProps('url')} />
 | 
			
		||||
                <NumberInput className="col-span-2" size="sm" label="Max price" {...form.getInputProps('max_price')} />
 | 
			
		||||
                <NumberInput size="sm" label="Plus price" {...form.getInputProps('plus_price')} />
 | 
			
		||||
                <NumberInput size="sm" label="Quantity" {...form.getInputProps('quantity')} />
 | 
			
		||||
      <form
 | 
			
		||||
        onSubmit={form.onSubmit(handleSubmit)}
 | 
			
		||||
        className="grid grid-cols-2 gap-2.5"
 | 
			
		||||
      >
 | 
			
		||||
        {!!data && (
 | 
			
		||||
          <Select
 | 
			
		||||
            className="col-span-2"
 | 
			
		||||
            label="Mode"
 | 
			
		||||
            data={[
 | 
			
		||||
              { label: "Live", value: "live" },
 | 
			
		||||
              { label: "Sandbox", value: "sandbox" },
 | 
			
		||||
            ]}
 | 
			
		||||
            defaultValue="live"
 | 
			
		||||
            checkIconPosition="right"
 | 
			
		||||
            allowDeselect={false}
 | 
			
		||||
            {...form.getInputProps("mode_key")}
 | 
			
		||||
          />
 | 
			
		||||
        )}
 | 
			
		||||
 | 
			
		||||
                <Button disabled={_.isEqual(form.getValues(), prevData.current)} className="col-span-2" type="submit" fullWidth size="sm" mt="md">
 | 
			
		||||
                    {data ? 'Update' : 'Create'}
 | 
			
		||||
        {data && data.name && (
 | 
			
		||||
          <TextInput
 | 
			
		||||
            className="col-span-2"
 | 
			
		||||
            readOnly={!!data}
 | 
			
		||||
            size="sm"
 | 
			
		||||
            label="Name"
 | 
			
		||||
            value={data.name}
 | 
			
		||||
          />
 | 
			
		||||
        )}
 | 
			
		||||
        <TextInput
 | 
			
		||||
          className="col-span-2"
 | 
			
		||||
          readOnly={!!data}
 | 
			
		||||
          size="sm"
 | 
			
		||||
          label="Url"
 | 
			
		||||
          {...form.getInputProps("url")}
 | 
			
		||||
        />
 | 
			
		||||
        <NumberInput
 | 
			
		||||
          className="col-span-2"
 | 
			
		||||
          size="sm"
 | 
			
		||||
          label="Max price"
 | 
			
		||||
          {...form.getInputProps("max_price")}
 | 
			
		||||
        />
 | 
			
		||||
        <NumberInput
 | 
			
		||||
          size="sm"
 | 
			
		||||
          label="Plus price"
 | 
			
		||||
          {...form.getInputProps("plus_price")}
 | 
			
		||||
        />
 | 
			
		||||
        <NumberInput
 | 
			
		||||
          size="sm"
 | 
			
		||||
          label="Quantity"
 | 
			
		||||
          {...form.getInputProps("quantity")}
 | 
			
		||||
        />
 | 
			
		||||
 | 
			
		||||
        {!!data && (
 | 
			
		||||
          <NumberInput
 | 
			
		||||
            description="Note: that only integer minutes are accepted."
 | 
			
		||||
            className="col-span-1"
 | 
			
		||||
            size="sm"
 | 
			
		||||
            label={`Arrival offset seconds (${formatTimeFromMinutes(
 | 
			
		||||
              form.getValues()[
 | 
			
		||||
                `arrival_offset_seconds_${form.getValues()["mode_key"]}`
 | 
			
		||||
              ] / 60
 | 
			
		||||
            )})`}
 | 
			
		||||
            placeholder="msg: 300"
 | 
			
		||||
            {...form.getInputProps(
 | 
			
		||||
              `arrival_offset_seconds_${form.getValues()["mode_key"]}`
 | 
			
		||||
            )}
 | 
			
		||||
          />
 | 
			
		||||
        )}
 | 
			
		||||
 | 
			
		||||
        {!!data && (
 | 
			
		||||
          <NumberInput
 | 
			
		||||
            description="Note: that only integer minutes are accepted."
 | 
			
		||||
            className="col-span-1"
 | 
			
		||||
            size="sm"
 | 
			
		||||
            label={`Early tracking seconds (${formatTimeFromMinutes(
 | 
			
		||||
              form.getValues()[
 | 
			
		||||
                `early_tracking_seconds_${form.getValues()["mode_key"]}`
 | 
			
		||||
              ] / 60
 | 
			
		||||
            )})`}
 | 
			
		||||
            placeholder="msg: 600"
 | 
			
		||||
            {...form.getInputProps(
 | 
			
		||||
              `early_tracking_seconds_${form.getValues()["mode_key"]}`
 | 
			
		||||
            )}
 | 
			
		||||
          />
 | 
			
		||||
        )}
 | 
			
		||||
 | 
			
		||||
        <Button
 | 
			
		||||
          //   disabled={_.isEqual(form.values, prevData.current)}
 | 
			
		||||
          className="col-span-2"
 | 
			
		||||
          type="submit"
 | 
			
		||||
          fullWidth
 | 
			
		||||
          size="sm"
 | 
			
		||||
          mt="md"
 | 
			
		||||
        >
 | 
			
		||||
          {data ? "Update" : "Create"}
 | 
			
		||||
        </Button>
 | 
			
		||||
      </form>
 | 
			
		||||
 | 
			
		||||
            <LoadingOverlay visible={loading} zIndex={1000} overlayProps={{ blur: 2 }} />
 | 
			
		||||
      <LoadingOverlay
 | 
			
		||||
        visible={loading}
 | 
			
		||||
        zIndex={1000}
 | 
			
		||||
        overlayProps={{ blur: 2 }}
 | 
			
		||||
      />
 | 
			
		||||
    </Modal>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,2 +1,4 @@
 | 
			
		|||
export { default as ShowHistoriesModal } from "./show-histories-modal";
 | 
			
		||||
export { default as BidModal } from "./bid-modal";
 | 
			
		||||
export { default as RecordModal } from "./record-modal";
 | 
			
		||||
export { default as ResponseDemoModal } from "./response-demo-modal";
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,168 @@
 | 
			
		|||
import {
 | 
			
		||||
  ActionIcon,
 | 
			
		||||
  Box,
 | 
			
		||||
  Card,
 | 
			
		||||
  Group,
 | 
			
		||||
  Menu,
 | 
			
		||||
  Modal,
 | 
			
		||||
  ModalProps,
 | 
			
		||||
  ScrollArea,
 | 
			
		||||
  Text,
 | 
			
		||||
  Title,
 | 
			
		||||
} from "@mantine/core";
 | 
			
		||||
import {
 | 
			
		||||
  IconDots,
 | 
			
		||||
  IconPlayerRecordFilled,
 | 
			
		||||
  IconTrash,
 | 
			
		||||
  IconVideoFilled,
 | 
			
		||||
} from "@tabler/icons-react";
 | 
			
		||||
import moment from "moment";
 | 
			
		||||
import { useEffect, useState } from "react";
 | 
			
		||||
import ReactPlayer from "react-player";
 | 
			
		||||
import { deleteRecord, getRecords } from "../../apis/bid";
 | 
			
		||||
import { useConfirmStore } from "../../lib/zustand/use-confirm";
 | 
			
		||||
import { IBid } from "../../system/type";
 | 
			
		||||
 | 
			
		||||
export interface IRecordModalProps extends ModalProps {
 | 
			
		||||
  data: IBid | null;
 | 
			
		||||
  onUpdated?: () => void;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
interface IRecord {
 | 
			
		||||
  bid_id: number;
 | 
			
		||||
  name: string;
 | 
			
		||||
  original_name: string;
 | 
			
		||||
  timestamp: number;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default function RecordModal({ data, ...props }: IRecordModalProps) {
 | 
			
		||||
  const [records, setRecords] = useState<IRecord[]>([]);
 | 
			
		||||
  const [selected, setSelected] = useState<IRecord | null>(null);
 | 
			
		||||
 | 
			
		||||
  const { setConfirm } = useConfirmStore();
 | 
			
		||||
 | 
			
		||||
  const fetch = async () => {
 | 
			
		||||
    if (!data) return;
 | 
			
		||||
    const response = await getRecords(data);
 | 
			
		||||
    if (response?.data) {
 | 
			
		||||
      setRecords(response.data);
 | 
			
		||||
      setSelected(response.data[0]);
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleDelete = async (item: IRecord) => {
 | 
			
		||||
    setConfirm({
 | 
			
		||||
      handleOk: async () => {
 | 
			
		||||
        const result = await deleteRecord(item.original_name);
 | 
			
		||||
 | 
			
		||||
        if (!result?.data) return;
 | 
			
		||||
 | 
			
		||||
        fetch();
 | 
			
		||||
      },
 | 
			
		||||
    });
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    fetch();
 | 
			
		||||
    // eslint-disable-next-line react-hooks/exhaustive-deps
 | 
			
		||||
  }, [data]);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Modal
 | 
			
		||||
      classNames={{
 | 
			
		||||
        header: "!flex !justify-center w-full",
 | 
			
		||||
      }}
 | 
			
		||||
      {...props}
 | 
			
		||||
      size={"xl"}
 | 
			
		||||
      title={<Title order={3}>Video Records</Title>}
 | 
			
		||||
      centered
 | 
			
		||||
    >
 | 
			
		||||
      <Box className="grid grid-cols-1 md:grid-cols-3 gap-4">
 | 
			
		||||
        {/* Sidebar - Record list */}
 | 
			
		||||
        {records.length > 0 && (
 | 
			
		||||
          <ScrollArea className="md:col-span-1 h-[400px] pr-2">
 | 
			
		||||
            {records.map((item) => (
 | 
			
		||||
              <Card
 | 
			
		||||
                key={item.original_name}
 | 
			
		||||
                withBorder
 | 
			
		||||
                shadow="xs"
 | 
			
		||||
                radius="md"
 | 
			
		||||
                className={`mb-3 cursor-pointer transition-all ${
 | 
			
		||||
                  selected?.original_name === item.original_name
 | 
			
		||||
                    ? "border-blue-500 bg-blue-50"
 | 
			
		||||
                    : "hover:bg-gray-50"
 | 
			
		||||
                }`}
 | 
			
		||||
                onClick={() => setSelected(item)}
 | 
			
		||||
              >
 | 
			
		||||
                <Card.Section withBorder inheritPadding py="xs">
 | 
			
		||||
                  <Group justify="space-between">
 | 
			
		||||
                    <Group>
 | 
			
		||||
                      <IconVideoFilled />
 | 
			
		||||
 | 
			
		||||
                      {selected &&
 | 
			
		||||
                        selected.original_name === item.original_name && (
 | 
			
		||||
                          <IconPlayerRecordFilled
 | 
			
		||||
                            size={14}
 | 
			
		||||
                            color="red"
 | 
			
		||||
                            className="animate-pulse"
 | 
			
		||||
                          />
 | 
			
		||||
                        )}
 | 
			
		||||
                    </Group>
 | 
			
		||||
                    <Menu withinPortal position="bottom-end" shadow="sm">
 | 
			
		||||
                      <Menu.Target>
 | 
			
		||||
                        <ActionIcon variant="subtle" color="gray">
 | 
			
		||||
                          <IconDots size={16} />
 | 
			
		||||
                        </ActionIcon>
 | 
			
		||||
                      </Menu.Target>
 | 
			
		||||
 | 
			
		||||
                      <Menu.Dropdown>
 | 
			
		||||
                        <Menu.Item
 | 
			
		||||
                          onClick={() => handleDelete(item)}
 | 
			
		||||
                          leftSection={<IconTrash size={14} />}
 | 
			
		||||
                          color="red"
 | 
			
		||||
                        >
 | 
			
		||||
                          Delete
 | 
			
		||||
                        </Menu.Item>
 | 
			
		||||
                      </Menu.Dropdown>
 | 
			
		||||
                    </Menu>
 | 
			
		||||
                  </Group>
 | 
			
		||||
                </Card.Section>
 | 
			
		||||
                <Text mt={"xs"} size="sm" fw={500}>
 | 
			
		||||
                  {item.name}
 | 
			
		||||
                </Text>
 | 
			
		||||
 | 
			
		||||
                <Text mt="xs" c="dimmed" size="xs">
 | 
			
		||||
                  {moment(Number(item.timestamp)).fromNow()}
 | 
			
		||||
                </Text>
 | 
			
		||||
              </Card>
 | 
			
		||||
            ))}
 | 
			
		||||
          </ScrollArea>
 | 
			
		||||
        )}
 | 
			
		||||
 | 
			
		||||
        {/* Player - Main content */}
 | 
			
		||||
        {records.length > 0 && (
 | 
			
		||||
          <Box className="md:col-span-2">
 | 
			
		||||
            {selected && (
 | 
			
		||||
              <ReactPlayer
 | 
			
		||||
                url={`${import.meta.env.VITE_BASE_URL}bids/record/${
 | 
			
		||||
                  selected.original_name
 | 
			
		||||
                }`}
 | 
			
		||||
                controls
 | 
			
		||||
                width="100%"
 | 
			
		||||
                height="auto"
 | 
			
		||||
              />
 | 
			
		||||
            )}
 | 
			
		||||
          </Box>
 | 
			
		||||
        )}
 | 
			
		||||
 | 
			
		||||
        {records.length <= 0 && (
 | 
			
		||||
          <Box className="col-span-3">
 | 
			
		||||
            <Text className="text-center" c="dimmed">
 | 
			
		||||
              No video
 | 
			
		||||
            </Text>
 | 
			
		||||
          </Box>
 | 
			
		||||
        )}
 | 
			
		||||
      </Box>
 | 
			
		||||
    </Modal>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,193 @@
 | 
			
		|||
/* eslint-disable @typescript-eslint/no-explicit-any */
 | 
			
		||||
import {
 | 
			
		||||
  ActionIcon,
 | 
			
		||||
  Box,
 | 
			
		||||
  Card,
 | 
			
		||||
  Group,
 | 
			
		||||
  Menu,
 | 
			
		||||
  Modal,
 | 
			
		||||
  ModalProps,
 | 
			
		||||
  ScrollArea,
 | 
			
		||||
  Text,
 | 
			
		||||
  Title,
 | 
			
		||||
} from "@mantine/core";
 | 
			
		||||
import {
 | 
			
		||||
  IconCodeCircle,
 | 
			
		||||
  IconDots,
 | 
			
		||||
  IconPlayerRecordFilled,
 | 
			
		||||
  IconTrash,
 | 
			
		||||
} from "@tabler/icons-react";
 | 
			
		||||
import moment from "moment";
 | 
			
		||||
import { useEffect, useRef, useState } from "react";
 | 
			
		||||
import ReactJson from "react-json-view";
 | 
			
		||||
import { updateMetadata } from "../../apis/bid";
 | 
			
		||||
import { useConfirmStore } from "../../lib/zustand/use-confirm";
 | 
			
		||||
import { IBid, IMetadata } from "../../system/type";
 | 
			
		||||
import { getResponseDemo } from "../../utils";
 | 
			
		||||
export interface IResponseDemoProps extends ModalProps {
 | 
			
		||||
  data: IBid | null;
 | 
			
		||||
  onUpdated?: () => void;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default function ResponseDemoModal({
 | 
			
		||||
  data,
 | 
			
		||||
  ...props
 | 
			
		||||
}: IResponseDemoProps) {
 | 
			
		||||
  const { setConfirm } = useConfirmStore();
 | 
			
		||||
  const [selected, setSelected] = useState<Record<string, any> | null>(null);
 | 
			
		||||
 | 
			
		||||
  const [responseDemo, setResponseDemo] = useState<null | IMetadata>(null);
 | 
			
		||||
 | 
			
		||||
  const dataRef = useRef<IBid | null>(data);
 | 
			
		||||
  // const responseDemo = useMemo(() => {
 | 
			
		||||
  //   if (!data) return null;
 | 
			
		||||
 | 
			
		||||
  //   const values = getResponseDemo(data);
 | 
			
		||||
 | 
			
		||||
  //   if (Array.isArray(values?.value) && values?.value[0]) {
 | 
			
		||||
  //     setSelected(values?.value[0]);
 | 
			
		||||
  //   }
 | 
			
		||||
 | 
			
		||||
  //   return values;
 | 
			
		||||
  // }, [data]);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (!data) return;
 | 
			
		||||
 | 
			
		||||
    const value = getResponseDemo(data);
 | 
			
		||||
 | 
			
		||||
    if (Array.isArray(value?.value) && value?.value[0]) {
 | 
			
		||||
      setSelected(value?.value[0]);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    setResponseDemo(value);
 | 
			
		||||
    dataRef.current = data;
 | 
			
		||||
  }, [data]);
 | 
			
		||||
 | 
			
		||||
  const handleDelete = async () => {
 | 
			
		||||
    if (!responseDemo || !selected) return;
 | 
			
		||||
    console.log(selected);
 | 
			
		||||
    setConfirm({
 | 
			
		||||
      handleOk: async () => {
 | 
			
		||||
        const prevData = responseDemo.value as Record<string, any>[];
 | 
			
		||||
 | 
			
		||||
        const newData = prevData.filter(
 | 
			
		||||
          (item) => item?.uuid !== selected?.uuid
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        const result = await updateMetadata(
 | 
			
		||||
          responseDemo.id,
 | 
			
		||||
          JSON.stringify(newData)
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        if (!result?.data) return;
 | 
			
		||||
 | 
			
		||||
        props.onUpdated?.();
 | 
			
		||||
 | 
			
		||||
        setResponseDemo(result.data);
 | 
			
		||||
        if (result?.data?.value[0]) {
 | 
			
		||||
          setSelected(result?.data?.value[0]);
 | 
			
		||||
        } else {
 | 
			
		||||
          setSelected(null);
 | 
			
		||||
        }
 | 
			
		||||
        // props.onClose();
 | 
			
		||||
      },
 | 
			
		||||
    });
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    console.log({ responseDemo });
 | 
			
		||||
  }, [responseDemo]);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Modal
 | 
			
		||||
      classNames={{
 | 
			
		||||
        header: "!flex !justify-center w-full",
 | 
			
		||||
      }}
 | 
			
		||||
      {...props}
 | 
			
		||||
      size={"xl"}
 | 
			
		||||
      title={<Title order={3}>Response Demo</Title>}
 | 
			
		||||
      centered
 | 
			
		||||
    >
 | 
			
		||||
      <Box className="grid grid-cols-1 md:grid-cols-3 gap-4">
 | 
			
		||||
        {/* Sidebar - Record list */}
 | 
			
		||||
        {responseDemo && responseDemo?.value.length > 0 && (
 | 
			
		||||
          <ScrollArea className="md:col-span-1 h-[600px] pr-2">
 | 
			
		||||
            {Array.isArray(responseDemo.value) &&
 | 
			
		||||
              responseDemo.value.map((item) => {
 | 
			
		||||
                return (
 | 
			
		||||
                  <Card
 | 
			
		||||
                    onClick={() => setSelected(item)}
 | 
			
		||||
                    key={item.uuid}
 | 
			
		||||
                    withBorder
 | 
			
		||||
                    shadow="xs"
 | 
			
		||||
                    radius="md"
 | 
			
		||||
                    className={`mb-3 cursor-pointer transition-all ${"border-blue-500 bg-blue-50"}`}
 | 
			
		||||
                  >
 | 
			
		||||
                    <Card.Section withBorder inheritPadding py="xs">
 | 
			
		||||
                      <Group justify="space-between">
 | 
			
		||||
                        <Group>
 | 
			
		||||
                          <IconCodeCircle />
 | 
			
		||||
 | 
			
		||||
                          {selected && selected?.uuid === item?.uuid && (
 | 
			
		||||
                            <IconPlayerRecordFilled
 | 
			
		||||
                              size={14}
 | 
			
		||||
                              color="red"
 | 
			
		||||
                              className="animate-pulse"
 | 
			
		||||
                            />
 | 
			
		||||
                          )}
 | 
			
		||||
                        </Group>
 | 
			
		||||
 | 
			
		||||
                        <Menu withinPortal position="bottom-end" shadow="sm">
 | 
			
		||||
                          <Menu.Target>
 | 
			
		||||
                            <ActionIcon variant="subtle" color="gray">
 | 
			
		||||
                              <IconDots size={16} />
 | 
			
		||||
                            </ActionIcon>
 | 
			
		||||
                          </Menu.Target>
 | 
			
		||||
 | 
			
		||||
                          <Menu.Dropdown>
 | 
			
		||||
                            <Menu.Item
 | 
			
		||||
                              onClick={handleDelete}
 | 
			
		||||
                              leftSection={<IconTrash size={14} />}
 | 
			
		||||
                              color="red"
 | 
			
		||||
                            >
 | 
			
		||||
                              Delete
 | 
			
		||||
                            </Menu.Item>
 | 
			
		||||
                          </Menu.Dropdown>
 | 
			
		||||
                        </Menu>
 | 
			
		||||
                      </Group>
 | 
			
		||||
                    </Card.Section>
 | 
			
		||||
                    <Text mt={"xs"} size="sm" fw={500}>
 | 
			
		||||
                      {dataRef.current?.name}
 | 
			
		||||
                    </Text>
 | 
			
		||||
 | 
			
		||||
                    <Text mt="xs" c="dimmed" size="xs">
 | 
			
		||||
                      {item?.timestamp &&
 | 
			
		||||
                        moment(Number(item?.timestamp)).fromNow()}
 | 
			
		||||
                    </Text>
 | 
			
		||||
                  </Card>
 | 
			
		||||
                );
 | 
			
		||||
              })}
 | 
			
		||||
          </ScrollArea>
 | 
			
		||||
        )}
 | 
			
		||||
 | 
			
		||||
        {/* Player - Main content */}
 | 
			
		||||
        {responseDemo?.value && selected && (
 | 
			
		||||
          <ScrollArea h={600} className="md:col-span-2">
 | 
			
		||||
            <ReactJson theme={"bespin"} src={selected || {}} />
 | 
			
		||||
          </ScrollArea>
 | 
			
		||||
        )}
 | 
			
		||||
 | 
			
		||||
        {(!responseDemo ||
 | 
			
		||||
          (Array.isArray(responseDemo.value) &&
 | 
			
		||||
            responseDemo.value.length <= 0)) && (
 | 
			
		||||
          <Box className="col-span-3">
 | 
			
		||||
            <Text className="text-center" c="dimmed">
 | 
			
		||||
              No response
 | 
			
		||||
            </Text>
 | 
			
		||||
          </Box>
 | 
			
		||||
        )}
 | 
			
		||||
      </Box>
 | 
			
		||||
    </Modal>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,21 +1,37 @@
 | 
			
		|||
import { Badge, Box, Button, Image, Text, Tooltip } from "@mantine/core";
 | 
			
		||||
import { useDisclosure } from "@mantine/hooks";
 | 
			
		||||
import {
 | 
			
		||||
  ActionIcon,
 | 
			
		||||
  Badge,
 | 
			
		||||
  Box,
 | 
			
		||||
  Button,
 | 
			
		||||
  Image,
 | 
			
		||||
  Text,
 | 
			
		||||
  Tooltip,
 | 
			
		||||
} from "@mantine/core";
 | 
			
		||||
import { upperFirst, useDisclosure } from "@mantine/hooks";
 | 
			
		||||
import moment from "moment";
 | 
			
		||||
import { useEffect, useState } from "react";
 | 
			
		||||
import { Socket } from "socket.io-client";
 | 
			
		||||
import { getImagesWorking } from "../../apis/bid";
 | 
			
		||||
import { getImagesWorking, updateBid } from "../../apis/bid";
 | 
			
		||||
import { useStatusToolStore } from "../../lib/zustand/use-status-tool-store";
 | 
			
		||||
import { IBid, IWebBid } from "../../system/type";
 | 
			
		||||
import {
 | 
			
		||||
  cn,
 | 
			
		||||
  extractDomainSmart,
 | 
			
		||||
  findNearestClosingChild,
 | 
			
		||||
  getEarlyTrackingSeconds,
 | 
			
		||||
  getMode,
 | 
			
		||||
  isTimeReached,
 | 
			
		||||
  stringToColor,
 | 
			
		||||
  subtractSeconds,
 | 
			
		||||
} from "../../utils";
 | 
			
		||||
import ShowImageModal from "./show-image-modal";
 | 
			
		||||
import { IconExternalLink, IconImageInPicture } from "@tabler/icons-react";
 | 
			
		||||
import {
 | 
			
		||||
  IconExternalLink,
 | 
			
		||||
  IconImageInPicture,
 | 
			
		||||
  IconLivePhoto,
 | 
			
		||||
  IconTestPipe,
 | 
			
		||||
} from "@tabler/icons-react";
 | 
			
		||||
import { useConfirmStore } from "../../lib/zustand/use-confirm";
 | 
			
		||||
export interface IWorkingPageProps {
 | 
			
		||||
  data: (IBid | IWebBid) & { type: string };
 | 
			
		||||
  socket: Socket;
 | 
			
		||||
| 
						 | 
				
			
			@ -38,6 +54,8 @@ export default function WorkingPage({ data, socket }: IWorkingPageProps) {
 | 
			
		|||
 | 
			
		||||
  const { statusTool } = useStatusToolStore();
 | 
			
		||||
 | 
			
		||||
  const { setConfirm } = useConfirmStore();
 | 
			
		||||
 | 
			
		||||
  function isIBid(obj: IBid | IWebBid): obj is IBid {
 | 
			
		||||
    return "name" in obj;
 | 
			
		||||
  }
 | 
			
		||||
| 
						 | 
				
			
			@ -66,6 +84,33 @@ export default function WorkingPage({ data, socket }: IWorkingPageProps) {
 | 
			
		|||
    return !statusTool || statusTool !== "online" ? "Unknown" : "logout";
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleToogleMode = async () => {
 | 
			
		||||
    if (!isIBid(data)) return;
 | 
			
		||||
 | 
			
		||||
    setConfirm({
 | 
			
		||||
      title: "Confirm changing this product's mode?",
 | 
			
		||||
      okButton: {
 | 
			
		||||
        value: "Sure",
 | 
			
		||||
        color: "blue",
 | 
			
		||||
      },
 | 
			
		||||
      handleOk: async () => {
 | 
			
		||||
        const metadata = data.metadata;
 | 
			
		||||
 | 
			
		||||
        const modeKey = metadata.find((item) => item.key_name === "mode_key");
 | 
			
		||||
 | 
			
		||||
        if (modeKey && modeKey.value) {
 | 
			
		||||
          if (String(modeKey.value) === "live") {
 | 
			
		||||
            modeKey.value = "sandbox";
 | 
			
		||||
          } else {
 | 
			
		||||
            modeKey.value = "live";
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        await updateBid({ ...data, metadata });
 | 
			
		||||
      },
 | 
			
		||||
    });
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    const updateImage = ({
 | 
			
		||||
      type,
 | 
			
		||||
| 
						 | 
				
			
			@ -178,7 +223,7 @@ export default function WorkingPage({ data, socket }: IWorkingPageProps) {
 | 
			
		|||
              !isTimeReached(
 | 
			
		||||
                subtractSeconds(
 | 
			
		||||
                  data.close_time,
 | 
			
		||||
                  data.web_bid?.early_tracking_seconds || 0
 | 
			
		||||
                  getEarlyTrackingSeconds(data) || 0
 | 
			
		||||
                )
 | 
			
		||||
              ) && (
 | 
			
		||||
                <Tooltip label={"Time to tracking"}>
 | 
			
		||||
| 
						 | 
				
			
			@ -188,7 +233,7 @@ export default function WorkingPage({ data, socket }: IWorkingPageProps) {
 | 
			
		|||
                  >{`TT: ${moment(
 | 
			
		||||
                    subtractSeconds(
 | 
			
		||||
                      data.close_time,
 | 
			
		||||
                      data.web_bid?.early_tracking_seconds || 0
 | 
			
		||||
                      getEarlyTrackingSeconds(data) || 0
 | 
			
		||||
                    )
 | 
			
		||||
                  ).format("HH:mm:ss DD/MM/YYYY")}`}</Text>
 | 
			
		||||
                </Tooltip>
 | 
			
		||||
| 
						 | 
				
			
			@ -218,6 +263,7 @@ export default function WorkingPage({ data, socket }: IWorkingPageProps) {
 | 
			
		|||
        </Box>
 | 
			
		||||
 | 
			
		||||
        <Box className="absolute top-0 left-0 py-2  px-4 flex items-center gap-2 justify-between w-full">
 | 
			
		||||
          <Box className="flex justify-between w-full">
 | 
			
		||||
            <Box className="flex items-center gap-2">
 | 
			
		||||
              <Badge
 | 
			
		||||
                color={payloadLoginStatus?.login_status ? "green" : "red"}
 | 
			
		||||
| 
						 | 
				
			
			@ -239,6 +285,22 @@ export default function WorkingPage({ data, socket }: IWorkingPageProps) {
 | 
			
		|||
                  : extractDomainSmart(data.origin_url)}
 | 
			
		||||
              </Badge>
 | 
			
		||||
            </Box>
 | 
			
		||||
            {isIBid(data) && (
 | 
			
		||||
              <Tooltip label={upperFirst(getMode(data))}>
 | 
			
		||||
                <ActionIcon
 | 
			
		||||
                  onClick={handleToogleMode}
 | 
			
		||||
                  color={getMode(data) === "live" ? "teal" : "orange"}
 | 
			
		||||
                  size="xs"
 | 
			
		||||
                >
 | 
			
		||||
                  {getMode(data) === "live" ? (
 | 
			
		||||
                    <IconLivePhoto size={14} />
 | 
			
		||||
                  ) : (
 | 
			
		||||
                    <IconTestPipe size={14} />
 | 
			
		||||
                  )}
 | 
			
		||||
                </ActionIcon>
 | 
			
		||||
              </Tooltip>
 | 
			
		||||
            )}
 | 
			
		||||
          </Box>
 | 
			
		||||
 | 
			
		||||
          {isIBid(data) && moment(data.close_time).isSame(moment(), "day") && (
 | 
			
		||||
            <div className="w-[14px] h-[14px] rounded-full bg-green-600 animate-pulse"></div>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -116,5 +116,3 @@ export const removeFalsy = (data: { [key: string]: string | number }) => {
 | 
			
		|||
    return prev;
 | 
			
		||||
  }, {} as { [key: string]: string | number });
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -12,18 +12,26 @@ import { useDisclosure } from "@mantine/hooks";
 | 
			
		|||
import {
 | 
			
		||||
  IconAd,
 | 
			
		||||
  IconAdOff,
 | 
			
		||||
  IconCode,
 | 
			
		||||
  IconEdit,
 | 
			
		||||
  IconHammer,
 | 
			
		||||
  IconHistory,
 | 
			
		||||
  IconMenu,
 | 
			
		||||
  IconPlayerRecord,
 | 
			
		||||
  IconPlus,
 | 
			
		||||
  IconTrash,
 | 
			
		||||
} from "@tabler/icons-react";
 | 
			
		||||
import _ from "lodash";
 | 
			
		||||
import { useMemo, useRef, useState } from "react";
 | 
			
		||||
import { deleteBid, getBids, toggleBid } from "../apis/bid";
 | 
			
		||||
import { BidModal, ShowHistoriesModal } from "../components/bid";
 | 
			
		||||
import {
 | 
			
		||||
  BidModal,
 | 
			
		||||
  RecordModal,
 | 
			
		||||
  ResponseDemoModal,
 | 
			
		||||
  ShowHistoriesModal,
 | 
			
		||||
} from "../components/bid";
 | 
			
		||||
import DeleteRowAction from "../components/bid/delete-row-action";
 | 
			
		||||
import ShowHistoriesApiModal from "../components/bid/show-histories-api/show-histories-api-modal";
 | 
			
		||||
import { haveHistories } from "../constant";
 | 
			
		||||
import Table from "../lib/table/table";
 | 
			
		||||
import { IColumn, TRefTableFn } from "../lib/table/type";
 | 
			
		||||
| 
						 | 
				
			
			@ -31,8 +39,12 @@ import { useChoosesStore } from "../lib/zustand/use-chooses-store";
 | 
			
		|||
import { useConfirmStore } from "../lib/zustand/use-confirm";
 | 
			
		||||
import { mappingStatusColors } from "../system/constants";
 | 
			
		||||
import { IBid } from "../system/type";
 | 
			
		||||
import { extractDomainSmart, formatTime } from "../utils";
 | 
			
		||||
import ShowHistoriesApiModal from "../components/bid/show-histories-api/show-histories-api-modal";
 | 
			
		||||
import {
 | 
			
		||||
  extractDomainSmart,
 | 
			
		||||
  formatTime,
 | 
			
		||||
  getMode,
 | 
			
		||||
  getResponseDemo,
 | 
			
		||||
} from "../utils";
 | 
			
		||||
 | 
			
		||||
export default function Bids() {
 | 
			
		||||
  const refTableFn: TRefTableFn<IBid> = useRef({});
 | 
			
		||||
| 
						 | 
				
			
			@ -47,6 +59,8 @@ export default function Bids() {
 | 
			
		|||
 | 
			
		||||
  const [openedHistoriesView, openedHistoriesViewModal] = useDisclosure(false);
 | 
			
		||||
  const [openedBid, bidModal] = useDisclosure(false);
 | 
			
		||||
  const [openedRecord, recordModal] = useDisclosure(false);
 | 
			
		||||
  const [openedResponseDemo, responseDemoModal] = useDisclosure(false);
 | 
			
		||||
 | 
			
		||||
  const columns: IColumn<IBid>[] = [
 | 
			
		||||
    {
 | 
			
		||||
| 
						 | 
				
			
			@ -185,6 +199,29 @@ export default function Bids() {
 | 
			
		|||
        );
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      key: "metadata",
 | 
			
		||||
      title: "Mode",
 | 
			
		||||
      typeFilter: "none",
 | 
			
		||||
      style: {
 | 
			
		||||
        style: {
 | 
			
		||||
          minWidth: "100px",
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
      renderRow(row) {
 | 
			
		||||
        return (
 | 
			
		||||
          <Box className="flex items-center justify-center">
 | 
			
		||||
            <Badge
 | 
			
		||||
              color={getMode(row) === "live" ? "teal" : "orange"}
 | 
			
		||||
              size="sm"
 | 
			
		||||
            >
 | 
			
		||||
              {row.metadata.find((item) => item.key_name === "mode_key")
 | 
			
		||||
                ?.value || "Live"}
 | 
			
		||||
            </Badge>
 | 
			
		||||
          </Box>
 | 
			
		||||
        );
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
  ];
 | 
			
		||||
 | 
			
		||||
  const handleDelete = (bid: IBid) => {
 | 
			
		||||
| 
						 | 
				
			
			@ -338,12 +375,26 @@ export default function Bids() {
 | 
			
		|||
                      {row.status === "biding" ? "Disable" : "Enable"}
 | 
			
		||||
                    </Menu.Item>
 | 
			
		||||
 | 
			
		||||
                    {/* <Menu.Item
 | 
			
		||||
                      onClick={() => handleDelete(row)}
 | 
			
		||||
                      leftSection={<IconTrash color="red" size={14} />}
 | 
			
		||||
                    <Menu.Item
 | 
			
		||||
                      onClick={() => {
 | 
			
		||||
                        setClickData(row);
 | 
			
		||||
 | 
			
		||||
                        if (getResponseDemo(row)) {
 | 
			
		||||
                          responseDemoModal.open();
 | 
			
		||||
                        } else {
 | 
			
		||||
                          recordModal.open();
 | 
			
		||||
                        }
 | 
			
		||||
                      }}
 | 
			
		||||
                      leftSection={
 | 
			
		||||
                        getResponseDemo(row) ? (
 | 
			
		||||
                          <IconCode size={14} />
 | 
			
		||||
                        ) : (
 | 
			
		||||
                          <IconPlayerRecord size={14} />
 | 
			
		||||
                        )
 | 
			
		||||
                      }
 | 
			
		||||
                    >
 | 
			
		||||
                      Delete
 | 
			
		||||
                    </Menu.Item> */}
 | 
			
		||||
                      {getResponseDemo(row) ? "Response demo" : "Record"}
 | 
			
		||||
                    </Menu.Item>
 | 
			
		||||
                  </Menu.Dropdown>
 | 
			
		||||
                </Menu>
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -375,6 +426,7 @@ export default function Bids() {
 | 
			
		|||
        }}
 | 
			
		||||
        data={clickData}
 | 
			
		||||
      />
 | 
			
		||||
 | 
			
		||||
      <BidModal
 | 
			
		||||
        onUpdated={() => {
 | 
			
		||||
          if (refTableFn.current?.fetchData) {
 | 
			
		||||
| 
						 | 
				
			
			@ -392,6 +444,40 @@ export default function Bids() {
 | 
			
		|||
        data={clickData}
 | 
			
		||||
      />
 | 
			
		||||
 | 
			
		||||
      <RecordModal
 | 
			
		||||
        onUpdated={() => {
 | 
			
		||||
          if (refTableFn.current?.fetchData) {
 | 
			
		||||
            refTableFn.current.fetchData();
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          setClickData(null);
 | 
			
		||||
        }}
 | 
			
		||||
        opened={openedRecord}
 | 
			
		||||
        onClose={() => {
 | 
			
		||||
          recordModal.close();
 | 
			
		||||
 | 
			
		||||
          setClickData(null);
 | 
			
		||||
        }}
 | 
			
		||||
        data={clickData}
 | 
			
		||||
      />
 | 
			
		||||
 | 
			
		||||
      <ResponseDemoModal
 | 
			
		||||
        onUpdated={() => {
 | 
			
		||||
          if (refTableFn.current?.fetchData) {
 | 
			
		||||
            refTableFn.current.fetchData();
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          setClickData(null);
 | 
			
		||||
        }}
 | 
			
		||||
        opened={openedResponseDemo}
 | 
			
		||||
        onClose={() => {
 | 
			
		||||
          responseDemoModal.close();
 | 
			
		||||
 | 
			
		||||
          setClickData(null);
 | 
			
		||||
        }}
 | 
			
		||||
        data={clickData}
 | 
			
		||||
      />
 | 
			
		||||
 | 
			
		||||
      {openedHistoriesView && (
 | 
			
		||||
        <ShowHistoriesApiModal
 | 
			
		||||
          onUpdated={() => {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -36,6 +36,7 @@ export default function DashBoard() {
 | 
			
		|||
  const RETRY_CONNECT = useRef(2);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    setLoading(true);
 | 
			
		||||
    socket.connect();
 | 
			
		||||
 | 
			
		||||
    socket.on("connect", () => {
 | 
			
		||||
| 
						 | 
				
			
			@ -78,6 +79,7 @@ export default function DashBoard() {
 | 
			
		|||
      setWorkingData(newData);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    setLoading(false);
 | 
			
		||||
    return () => {
 | 
			
		||||
      console.log("🔌 Cleanup WebSocket listeners...");
 | 
			
		||||
      socket.off("adminBidsUpdated");
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,3 +1,4 @@
 | 
			
		|||
/* eslint-disable @typescript-eslint/no-explicit-any */
 | 
			
		||||
export interface IAdmin extends ITimestamp {
 | 
			
		||||
  id: number;
 | 
			
		||||
  email: string;
 | 
			
		||||
| 
						 | 
				
			
			@ -63,6 +64,12 @@ export interface IWebBid extends ITimestamp {
 | 
			
		|||
  scrap_config: IScrapConfig;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface IMetadata extends ITimestamp {
 | 
			
		||||
  id: number;
 | 
			
		||||
  key_name: string;
 | 
			
		||||
  value: any;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface IBid extends ITimestamp {
 | 
			
		||||
  id: number;
 | 
			
		||||
  max_price: number;
 | 
			
		||||
| 
						 | 
				
			
			@ -81,6 +88,7 @@ export interface IBid extends ITimestamp {
 | 
			
		|||
  status: "biding" | "out-bid" | "win-bid";
 | 
			
		||||
  histories: IHistory[];
 | 
			
		||||
  web_bid: IWebBid;
 | 
			
		||||
  metadata: IMetadata[];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface IConfig extends ITimestamp {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -3,8 +3,8 @@
 | 
			
		|||
import { clsx, type ClassValue } from "clsx";
 | 
			
		||||
import { twMerge } from "tailwind-merge";
 | 
			
		||||
import moment from "moment";
 | 
			
		||||
import { IWebBid } from "../system/type";
 | 
			
		||||
import _ from 'lodash'
 | 
			
		||||
import { IBid, IMetadata, IWebBid } from "../system/type";
 | 
			
		||||
import _ from "lodash";
 | 
			
		||||
 | 
			
		||||
export function cn(...args: ClassValue[]) {
 | 
			
		||||
  return twMerge(clsx(args));
 | 
			
		||||
| 
						 | 
				
			
			@ -204,7 +204,6 @@ export function extractDomainSmart(url: string) {
 | 
			
		|||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
export function findNearestClosingChild(webBid: IWebBid) {
 | 
			
		||||
  const now = Date.now();
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -255,11 +254,37 @@ export function formatTimeFromMinutes(minutes: number): string {
 | 
			
		|||
  const hours = Math.floor((minutes % (60 * 24)) / 60);
 | 
			
		||||
  const mins = minutes % 60;
 | 
			
		||||
 | 
			
		||||
  let result = '';
 | 
			
		||||
  let result = "";
 | 
			
		||||
 | 
			
		||||
  if (days > 0) result += `${days} ${days > 1? 'days' :'day'} `;
 | 
			
		||||
  if (hours > 0) result += `${hours} ${hours > 1 ? 'hours' : 'hour'} `;
 | 
			
		||||
  if (mins > 0 || result === '') result += `${mins} minutes`;
 | 
			
		||||
  if (days > 0) result += `${days} ${days > 1 ? "days" : "day"} `;
 | 
			
		||||
  if (hours > 0) result += `${hours} ${hours > 1 ? "hours" : "hour"} `;
 | 
			
		||||
  if (mins > 0 || result === "") result += `${mins} minutes`;
 | 
			
		||||
 | 
			
		||||
  return result.trim();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const getMode = (row: IBid) => {
 | 
			
		||||
  return (
 | 
			
		||||
    row.metadata.find((item) => item.key_name === "mode_key")?.value || "live"
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const getEarlyTrackingSeconds = (row: IBid) => {
 | 
			
		||||
  const mode = getMode(row);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    row.metadata.find(
 | 
			
		||||
      (item) => item.key_name === `early_tracking_seconds_${mode}`
 | 
			
		||||
    )?.value || row.web_bid.early_tracking_seconds
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const getResponseDemo = (data: IBid) => {
 | 
			
		||||
  if (!data?.metadata) return null;
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    (data?.metadata.find(
 | 
			
		||||
      (item) => item.key_name === "demo_response"
 | 
			
		||||
    ) as IMetadata) || null
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,4 +1,4 @@
 | 
			
		|||
import { Module } from '@nestjs/common';
 | 
			
		||||
import { forwardRef, Module } from '@nestjs/common';
 | 
			
		||||
import { TypeOrmModule } from '@nestjs/typeorm';
 | 
			
		||||
import { AdminsModule } from '../admins/admins.module';
 | 
			
		||||
import { NotificationModule } from '../notification/notification.module';
 | 
			
		||||
| 
						 | 
				
			
			@ -34,6 +34,8 @@ import { AdminConfigsController } from './controllers/admin/admin-configs.contro
 | 
			
		|||
import { BidMetadatasService } from './services/bid-metadatas.service';
 | 
			
		||||
import { BidMetadata } from './entities/bid-metadata.entity';
 | 
			
		||||
import { HotItemApi } from './apis/hot-item.api';
 | 
			
		||||
import { MailsModule } from '../mails/mails.module';
 | 
			
		||||
import { AdminBidMetadataController } from './controllers/admin/admin-bid-metadata.controller';
 | 
			
		||||
 | 
			
		||||
@Module({
 | 
			
		||||
  imports: [
 | 
			
		||||
| 
						 | 
				
			
			@ -61,6 +63,7 @@ import { HotItemApi } from './apis/hot-item.api';
 | 
			
		|||
    AdminSendMessageHistoriesController,
 | 
			
		||||
    AdminDashboardController,
 | 
			
		||||
    AdminConfigsController,
 | 
			
		||||
    AdminBidMetadataController,
 | 
			
		||||
  ],
 | 
			
		||||
  providers: [
 | 
			
		||||
    BidsService,
 | 
			
		||||
| 
						 | 
				
			
			@ -82,6 +85,7 @@ import { HotItemApi } from './apis/hot-item.api';
 | 
			
		|||
  exports: [
 | 
			
		||||
    BotTelegramApi,
 | 
			
		||||
    SendMessageHistoriesService,
 | 
			
		||||
    BidMetadatasService,
 | 
			
		||||
    BidsService,
 | 
			
		||||
    ConfigsService,
 | 
			
		||||
    DashboardService,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,21 @@
 | 
			
		|||
import { Body, Controller, Delete, Param, Post, Put } from '@nestjs/common';
 | 
			
		||||
import { BidMetadata } from '../../entities/bid-metadata.entity';
 | 
			
		||||
import { BidMetadatasService } from '../../services/bid-metadatas.service';
 | 
			
		||||
 | 
			
		||||
@Controller('admin/bid-metadatas')
 | 
			
		||||
export class AdminBidMetadataController {
 | 
			
		||||
  constructor(private readonly bidMetadataService: BidMetadatasService) {}
 | 
			
		||||
 | 
			
		||||
  @Delete(':id')
 | 
			
		||||
  async delete(@Param('id') id: BidMetadata['id']) {
 | 
			
		||||
    return await this.bidMetadataService.delete(id);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @Put(':id')
 | 
			
		||||
  async update(
 | 
			
		||||
    @Param('id') id: BidMetadata['id'],
 | 
			
		||||
    @Body('value') value: string,
 | 
			
		||||
  ) {
 | 
			
		||||
    return await this.bidMetadataService.update(id, value);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -67,4 +67,19 @@ export class AdminBidsController {
 | 
			
		|||
  ) {
 | 
			
		||||
    return await this.bidsService.getImagesWorking(id, type);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @Get('record/:name')
 | 
			
		||||
  async getRecord(@Param('name') name: string, @Res() res: Response) {
 | 
			
		||||
    return await this.bidsService.getRecord(name, res);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @Get('records/:id')
 | 
			
		||||
  async getRecords(@Param('id') id: Bid['id']) {
 | 
			
		||||
    return await this.bidsService.getRecords(id);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @Delete('record/:name')
 | 
			
		||||
  async deleteRecord(@Param('name') name: string) {
 | 
			
		||||
    return await this.bidsService.deleteRecord(name);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -5,13 +5,14 @@ import {
 | 
			
		|||
  Param,
 | 
			
		||||
  Post,
 | 
			
		||||
  Put,
 | 
			
		||||
  Query,
 | 
			
		||||
  UploadedFile,
 | 
			
		||||
  UseInterceptors,
 | 
			
		||||
} from '@nestjs/common';
 | 
			
		||||
import { EventEmitter2 } from '@nestjs/event-emitter';
 | 
			
		||||
import { FileInterceptor } from '@nestjs/platform-express';
 | 
			
		||||
import { plainToClass } from 'class-transformer';
 | 
			
		||||
import { memoryStorage } from 'multer';
 | 
			
		||||
import { diskStorage, memoryStorage } from 'multer';
 | 
			
		||||
import { ClientUpdateBidDto } from '../../dto/bid/client-update-bid.dto';
 | 
			
		||||
import { CreateBidDto } from '../../dto/bid/create-bid.dto';
 | 
			
		||||
import { UpdateStatusByPriceDto } from '../../dto/bid/update-status-by-price.dto';
 | 
			
		||||
| 
						 | 
				
			
			@ -24,6 +25,7 @@ import AppResponse from '@/response/app-response';
 | 
			
		|||
import { ClientUpdateLoginStatusDto } from '../../dto/bid/client-update-login-status.dto';
 | 
			
		||||
import { UpdateBidDto } from '../../dto/bid/update-bid.dto';
 | 
			
		||||
import { ClientInfoUpdateBidDto } from '../../dto/bid/client-info-update-bid.dto';
 | 
			
		||||
import path from 'path';
 | 
			
		||||
 | 
			
		||||
@Controller('bids')
 | 
			
		||||
export class BidsController {
 | 
			
		||||
| 
						 | 
				
			
			@ -81,6 +83,15 @@ export class BidsController {
 | 
			
		|||
    return this.bidsService.updateStatusWork(id, type, image);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @Post('upload-record/:id')
 | 
			
		||||
  @UseInterceptors(FileInterceptor('video', { storage: memoryStorage() }))
 | 
			
		||||
  async uploadRecord(
 | 
			
		||||
    @Param('id') id: Bid['id'],
 | 
			
		||||
    @UploadedFile() video: Express.Multer.File,
 | 
			
		||||
  ) {
 | 
			
		||||
    return this.bidsService.uploadRecord(id, video);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @Post('update-login-status')
 | 
			
		||||
  async updateLoginStatus(@Body() data: ClientUpdateLoginStatusDto) {
 | 
			
		||||
    return await this.bidsService.emitLoginStatus(data);
 | 
			
		||||
| 
						 | 
				
			
			@ -89,17 +100,26 @@ export class BidsController {
 | 
			
		|||
  @Post('test')
 | 
			
		||||
  async test(@Body('code') code: string) {
 | 
			
		||||
    const webBid = await this.webBidService.webBidRepo.findOne({
 | 
			
		||||
      // where: { id: 4 },
 | 
			
		||||
      where: { id: 1 },
 | 
			
		||||
      where: { id: 4 },
 | 
			
		||||
      // where: { id: 1 },
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    this.eventEmitter.emit(Event.verifyCode(webBid), {
 | 
			
		||||
      code,
 | 
			
		||||
      // name: 'LAWSONS',
 | 
			
		||||
      name: 'LANGTONS',
 | 
			
		||||
      name: 'LAWSONS',
 | 
			
		||||
      // name: 'LANGTONS',
 | 
			
		||||
      web_bid: plainToClass(WebBid, webBid),
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    return AppResponse.toResponse({ code });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @Post('hook-action')
 | 
			
		||||
  async hookAction(
 | 
			
		||||
    @Body() data: any,
 | 
			
		||||
    @Query('id') id: Bid['id'],
 | 
			
		||||
    @Query('type') type: 'action' | 'api',
 | 
			
		||||
  ) {
 | 
			
		||||
    return this.bidsService.hookAction({ id, type }, data);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,4 +1,4 @@
 | 
			
		|||
import { IsNumber, IsOptional } from 'class-validator';
 | 
			
		||||
import { IsArray, IsNumber, IsOptional } from 'class-validator';
 | 
			
		||||
 | 
			
		||||
export class ClientInfoUpdateBidDto {
 | 
			
		||||
  @IsNumber()
 | 
			
		||||
| 
						 | 
				
			
			@ -12,4 +12,8 @@ export class ClientInfoUpdateBidDto {
 | 
			
		|||
  @IsNumber()
 | 
			
		||||
  @IsOptional()
 | 
			
		||||
  plus_price: number;
 | 
			
		||||
 | 
			
		||||
  @IsArray()
 | 
			
		||||
  @IsOptional()
 | 
			
		||||
  metadata: Record<string, any>[];
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,4 +1,4 @@
 | 
			
		|||
import { IsNumber, IsOptional } from 'class-validator';
 | 
			
		||||
import { IsArray, IsNumber, IsOptional } from 'class-validator';
 | 
			
		||||
 | 
			
		||||
export class UpdateBidDto {
 | 
			
		||||
  @IsNumber()
 | 
			
		||||
| 
						 | 
				
			
			@ -12,4 +12,8 @@ export class UpdateBidDto {
 | 
			
		|||
  @IsNumber()
 | 
			
		||||
  @IsOptional()
 | 
			
		||||
  plus_price: number;
 | 
			
		||||
 | 
			
		||||
  @IsArray()
 | 
			
		||||
  @IsOptional()
 | 
			
		||||
  metadata: Record<string, any>[];
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -7,10 +7,52 @@ import {
 | 
			
		|||
} from 'typeorm';
 | 
			
		||||
import { Bid } from './bid.entity';
 | 
			
		||||
import { Timestamp } from './timestamp';
 | 
			
		||||
import { WebBid } from './wed-bid.entity';
 | 
			
		||||
 | 
			
		||||
@Entity('bid_metadata')
 | 
			
		||||
@Unique(['key_name', 'bid'])
 | 
			
		||||
export class BidMetadata extends Timestamp {
 | 
			
		||||
  // value 'live' | 'sandbox'
 | 
			
		||||
  public static MODE_KEY = 'mode_key';
 | 
			
		||||
 | 
			
		||||
  public static DEMO_RESPONSE = 'demo_response';
 | 
			
		||||
  public static LATEST_RECROD_LINK = 'lastest_record_link';
 | 
			
		||||
 | 
			
		||||
  // value is minutes esg: arrival_offset_seconds of web bid parent
 | 
			
		||||
  public static ARRIAVAL_OFFSET_SECONDS_LIVE = 'arrival_offset_seconds_live';
 | 
			
		||||
  public static ARRIAVAL_OFFSET_SECONDS_SANDBOX =
 | 
			
		||||
    'arrival_offset_seconds_sandbox';
 | 
			
		||||
 | 
			
		||||
  // value is minutes esg: early_tracking_seconds of web bid parent
 | 
			
		||||
  public static EARLY_TRACKING_SECONDS_LIVE = 'early_tracking_seconds_live';
 | 
			
		||||
  public static EARLY_TRACKING_SECONDS_SANDBOX =
 | 
			
		||||
    'early_tracking_seconds_sandbox';
 | 
			
		||||
 | 
			
		||||
  public static DEFAULT_META_DATA = (webBid: WebBid) => {
 | 
			
		||||
    return [
 | 
			
		||||
      {
 | 
			
		||||
        key_name: BidMetadata.MODE_KEY,
 | 
			
		||||
        value: JSON.stringify('live'),
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        key_name: BidMetadata.ARRIAVAL_OFFSET_SECONDS_LIVE,
 | 
			
		||||
        value: String(webBid.arrival_offset_seconds),
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        key_name: BidMetadata.EARLY_TRACKING_SECONDS_LIVE,
 | 
			
		||||
        value: String(webBid.early_tracking_seconds),
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        key_name: BidMetadata.ARRIAVAL_OFFSET_SECONDS_SANDBOX,
 | 
			
		||||
        value: String(webBid.arrival_offset_seconds),
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        key_name: BidMetadata.EARLY_TRACKING_SECONDS_SANDBOX,
 | 
			
		||||
        value: String(webBid.early_tracking_seconds),
 | 
			
		||||
      },
 | 
			
		||||
    ] as BidMetadata[];
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  @PrimaryGeneratedColumn('increment')
 | 
			
		||||
  id: number;
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -71,6 +71,6 @@ export class Bid extends Timestamp {
 | 
			
		|||
  @ManyToOne(() => WebBid, (web) => web.children, { onDelete: 'CASCADE' })
 | 
			
		||||
  web_bid: WebBid;
 | 
			
		||||
 | 
			
		||||
  @OneToMany(() => BidMetadata, (metadata) => metadata.bid)
 | 
			
		||||
  @OneToMany(() => BidMetadata, (metadata) => metadata.bid, { cascade: true })
 | 
			
		||||
  metadata: BidMetadata[];
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -21,10 +21,14 @@ export class ImageCompressionPipe implements PipeTransform {
 | 
			
		|||
      throw new BadRequestException('No file provided');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const isVideo = file.mimetype === 'video/mp4';
 | 
			
		||||
 | 
			
		||||
    const ext = path.extname(file.originalname).toLowerCase();
 | 
			
		||||
    if (!ALLOWED_IMAGE_EXT.includes(ext)) {
 | 
			
		||||
    const isImage = ALLOWED_IMAGE_EXT.includes(ext);
 | 
			
		||||
 | 
			
		||||
    if (!isImage && !isVideo) {
 | 
			
		||||
      throw new BadRequestException(
 | 
			
		||||
        `File "${file.originalname}" is not a valid image.`,
 | 
			
		||||
        `File "${file.originalname}" is not a valid image or video.`,
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -36,16 +40,37 @@ export class ImageCompressionPipe implements PipeTransform {
 | 
			
		|||
        const files = readdirSync(uploadDir);
 | 
			
		||||
        files.forEach((file) => {
 | 
			
		||||
          const filePath = path.join(uploadDir, file);
 | 
			
		||||
          if (/\.(jpg|jpeg|png|webp)$/i.test(file)) {
 | 
			
		||||
          if (/\.(jpg|jpeg|png|webp|mp4)$/i.test(file)) {
 | 
			
		||||
            unlinkSync(filePath);
 | 
			
		||||
          }
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const filename = this.options.unique_name
 | 
			
		||||
    let filename = null;
 | 
			
		||||
 | 
			
		||||
    if (isVideo) {
 | 
			
		||||
      filename = this.options.unique_name
 | 
			
		||||
        ? `${Date.now()}-${file.originalname}`
 | 
			
		||||
        : file.originalname;
 | 
			
		||||
 | 
			
		||||
      const filePath = path.join(uploadDir, filename);
 | 
			
		||||
      // Ghi trực tiếp buffer ra file
 | 
			
		||||
      await new Promise<void>((resolve, reject) => {
 | 
			
		||||
        const fs = require('fs');
 | 
			
		||||
        fs.writeFile(filePath, file.buffer, (err) => {
 | 
			
		||||
          if (err) reject(err);
 | 
			
		||||
          else resolve();
 | 
			
		||||
        });
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      return { filename };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    filename = this.options.unique_name
 | 
			
		||||
      ? `${Date.now()}-${this.resize.width || Constant.DEFAULT_RESIZE}x${this.resize.height || Constant.DEFAULT_RESIZE}-${file.originalname.replace(ext, '.webp')}`
 | 
			
		||||
      : file.originalname.replace(ext, '.webp');
 | 
			
		||||
 | 
			
		||||
    const filePath = path.join(uploadDir, filename);
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -106,7 +106,7 @@ export class BidHistoriesService {
 | 
			
		|||
 | 
			
		||||
    // Gửi thông tin bid đến bot telegram
 | 
			
		||||
    const botData = { ...bid, histories: response };
 | 
			
		||||
    this.botTelegramApi.sendBidInfo(botData);
 | 
			
		||||
    // this.botTelegramApi.sendBidInfo(botData);
 | 
			
		||||
 | 
			
		||||
    // Send event thống place bid
 | 
			
		||||
    this.eventEmitter.emit(Event.BID_SUBMITED, botData);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,8 +1,15 @@
 | 
			
		|||
import { Injectable } from '@nestjs/common';
 | 
			
		||||
import {
 | 
			
		||||
  BadRequestException,
 | 
			
		||||
  HttpStatus,
 | 
			
		||||
  Injectable,
 | 
			
		||||
  NotFoundException,
 | 
			
		||||
} from '@nestjs/common';
 | 
			
		||||
import { InjectRepository } from '@nestjs/typeorm';
 | 
			
		||||
import { Repository } from 'typeorm';
 | 
			
		||||
import { BidMetadata } from '../entities/bid-metadata.entity';
 | 
			
		||||
import { Bid } from '../entities/bid.entity';
 | 
			
		||||
import AppResponse from '@/response/app-response';
 | 
			
		||||
import { plainToClass } from 'class-transformer';
 | 
			
		||||
 | 
			
		||||
@Injectable()
 | 
			
		||||
export class BidMetadatasService {
 | 
			
		||||
| 
						 | 
				
			
			@ -41,4 +48,80 @@ export class BidMetadatasService {
 | 
			
		|||
    await this.bidMetadataRepo.save(toSave);
 | 
			
		||||
    return true;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async update(id: BidMetadata['id'], value: string) {
 | 
			
		||||
    const result = await this.bidMetadataRepo.update(id, { value: value });
 | 
			
		||||
 | 
			
		||||
    if (!result) throw new BadRequestException(AppResponse.toResponse(null));
 | 
			
		||||
 | 
			
		||||
    const data = await this.bidMetadataRepo.findOne({ where: { id } });
 | 
			
		||||
 | 
			
		||||
    return AppResponse.toResponse(plainToClass(BidMetadata, data));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  getMode = (metadatas: BidMetadata[]) => {
 | 
			
		||||
    const result = metadatas.find(
 | 
			
		||||
      (item) => item.key_name === BidMetadata.MODE_KEY,
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    return result ? result.value : null;
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  getDemoResponse = (metadatas: BidMetadata[]) => {
 | 
			
		||||
    const result = metadatas.find(
 | 
			
		||||
      (item) => item.key_name === BidMetadata.DEMO_RESPONSE,
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    return result ? result.value : null;
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  getLatestRecordLink = (metadatas: BidMetadata[]) => {
 | 
			
		||||
    const result = metadatas.find(
 | 
			
		||||
      (item) => item.key_name === BidMetadata.LATEST_RECROD_LINK,
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    return result ? result.value : null;
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  getArrivalOffsetSecondsSandbox = (metadatas: BidMetadata[]) => {
 | 
			
		||||
    const result = metadatas.find(
 | 
			
		||||
      (item) => item.key_name === BidMetadata.ARRIAVAL_OFFSET_SECONDS_SANDBOX,
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    return result ? Number(result.value) : null;
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  getArrivalOffsetSecondsLive = (metadatas: BidMetadata[]) => {
 | 
			
		||||
    const result = metadatas.find(
 | 
			
		||||
      (item) => item.key_name === BidMetadata.ARRIAVAL_OFFSET_SECONDS_LIVE,
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    return result ? Number(result.value) : null;
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  getArrivalOffsetSecondsByMode = (metadatas: BidMetadata[]) => {
 | 
			
		||||
    const mode = this.getMode(metadatas) || 'live';
 | 
			
		||||
 | 
			
		||||
    if (mode === 'live') {
 | 
			
		||||
      return this.getArrivalOffsetSecondsLive(metadatas);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return this.getArrivalOffsetSecondsSandbox(metadatas);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  async delete(id: Bid['id']) {
 | 
			
		||||
    const bid = await this.bidMetadataRepo.findOne({ where: { id } });
 | 
			
		||||
 | 
			
		||||
    if (!bid)
 | 
			
		||||
      throw new NotFoundException(
 | 
			
		||||
        AppResponse.toResponse(false, {
 | 
			
		||||
          message: 'Meta is not found',
 | 
			
		||||
          status_code: HttpStatus.NOT_FOUND,
 | 
			
		||||
        }),
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
    await this.bidMetadataRepo.delete({ id: bid.id });
 | 
			
		||||
 | 
			
		||||
    return AppResponse.toResponse(true, { message: 'Delete success !' });
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -9,7 +9,10 @@ import { EventEmitter2 } from '@nestjs/event-emitter';
 | 
			
		|||
import { InjectRepository } from '@nestjs/typeorm';
 | 
			
		||||
import { plainToClass } from 'class-transformer';
 | 
			
		||||
import { Response } from 'express';
 | 
			
		||||
import * as fs from 'fs';
 | 
			
		||||
import { existsSync, readdirSync } from 'fs';
 | 
			
		||||
import * as _ from 'lodash';
 | 
			
		||||
import { v4 as uuid } from 'uuid';
 | 
			
		||||
import {
 | 
			
		||||
  FilterOperator,
 | 
			
		||||
  FilterSuffix,
 | 
			
		||||
| 
						 | 
				
			
			@ -19,7 +22,12 @@ import {
 | 
			
		|||
import { Column } from 'nestjs-paginate/lib/helper';
 | 
			
		||||
import { join } from 'path';
 | 
			
		||||
import AppResponse from 'src/response/app-response';
 | 
			
		||||
import { extractModelId, isTimeReached, subtractMinutes } from 'src/ultils';
 | 
			
		||||
import {
 | 
			
		||||
  extractModelId,
 | 
			
		||||
  isTimeReached,
 | 
			
		||||
  parseVideoFileName,
 | 
			
		||||
  subtractMinutes,
 | 
			
		||||
} from 'src/ultils';
 | 
			
		||||
import { In, IsNull, Not, Repository } from 'typeorm';
 | 
			
		||||
import { ClientUpdateBidDto } from '../dto/bid/client-update-bid.dto';
 | 
			
		||||
import { ClientUpdateLoginStatusDto } from '../dto/bid/client-update-login-status.dto';
 | 
			
		||||
| 
						 | 
				
			
			@ -27,12 +35,13 @@ import { CreateBidDto } from '../dto/bid/create-bid.dto';
 | 
			
		|||
import { UpdateBidDto } from '../dto/bid/update-bid.dto';
 | 
			
		||||
import { UpdateStatusByPriceDto } from '../dto/bid/update-status-by-price.dto';
 | 
			
		||||
import { BidHistory } from '../entities/bid-history.entity';
 | 
			
		||||
import { BidMetadata } from '../entities/bid-metadata.entity';
 | 
			
		||||
import { Bid } from '../entities/bid.entity';
 | 
			
		||||
import { ImageCompressionPipe } from '../pipes/image-compression-pipe';
 | 
			
		||||
import { Constant } from '../utils/constant';
 | 
			
		||||
import { Event } from '../utils/events';
 | 
			
		||||
import { WebBidsService } from './web-bids.service';
 | 
			
		||||
import { BidMetadatasService } from './bid-metadatas.service';
 | 
			
		||||
import { WebBidsService } from './web-bids.service';
 | 
			
		||||
 | 
			
		||||
@Injectable()
 | 
			
		||||
export class BidsService {
 | 
			
		||||
| 
						 | 
				
			
			@ -83,6 +92,7 @@ export class BidsService {
 | 
			
		|||
      relations: {
 | 
			
		||||
        histories: true,
 | 
			
		||||
        web_bid: true,
 | 
			
		||||
        metadata: true,
 | 
			
		||||
      },
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -115,10 +125,13 @@ export class BidsService {
 | 
			
		|||
 | 
			
		||||
    const webBid = await this.webBidsService.createByUrl(data.url);
 | 
			
		||||
 | 
			
		||||
    const metadata = BidMetadata.DEFAULT_META_DATA(webBid);
 | 
			
		||||
 | 
			
		||||
    const result = await this.bidsRepo.save({
 | 
			
		||||
      ...data,
 | 
			
		||||
      model,
 | 
			
		||||
      web_bid: webBid,
 | 
			
		||||
      metadata,
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    await this.emitAllBidEvent();
 | 
			
		||||
| 
						 | 
				
			
			@ -128,8 +141,12 @@ export class BidsService {
 | 
			
		|||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async update(id: Bid['id'], data: UpdateBidDto) {
 | 
			
		||||
    const prev = await this.bidsRepo.findOne({ where: { id } });
 | 
			
		||||
  async update(id: Bid['id'], { metadata = [], ...data }: UpdateBidDto) {
 | 
			
		||||
    const prev = await this.bidsRepo.findOne({
 | 
			
		||||
      where: { id },
 | 
			
		||||
      relations: { web_bid: true, metadata: true },
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    if (!prev) {
 | 
			
		||||
      throw new NotFoundException(
 | 
			
		||||
        AppResponse.toResponse(false, {
 | 
			
		||||
| 
						 | 
				
			
			@ -139,13 +156,46 @@ export class BidsService {
 | 
			
		|||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const result = await this.bidsRepo.update(id, {
 | 
			
		||||
    if (metadata) {
 | 
			
		||||
      await this.bidMetadatasService.bidMetadataRepo.upsert(
 | 
			
		||||
        metadata.map((item) => {
 | 
			
		||||
          return { ...item, bid: { id }, value: JSON.stringify(item.value) };
 | 
			
		||||
        }),
 | 
			
		||||
        ['id', 'bid', 'key_name'],
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    let result = null;
 | 
			
		||||
    if (!prev.close_time) {
 | 
			
		||||
      // Trường hợp chưa có close_time => update đơn giản
 | 
			
		||||
      result = await this.bidsRepo.update(id, {
 | 
			
		||||
        ...data,
 | 
			
		||||
      // status:
 | 
			
		||||
      //   prev.max_price + prev.plus_price > data.max_price
 | 
			
		||||
      //     ? 'out-bid'
 | 
			
		||||
      //     : prev.status,
 | 
			
		||||
      });
 | 
			
		||||
    } else {
 | 
			
		||||
      // Trường hợp đã có close_time => kiểm tra arrival_offset_seconds có thay đổi không
 | 
			
		||||
      const arrival_offset_seconds =
 | 
			
		||||
        this.bidMetadatasService.getArrivalOffsetSecondsByMode(
 | 
			
		||||
          metadata as BidMetadata[],
 | 
			
		||||
        );
 | 
			
		||||
      const prev_arrival_offset_seconds =
 | 
			
		||||
        this.bidMetadatasService.getArrivalOffsetSecondsByMode(prev.metadata);
 | 
			
		||||
 | 
			
		||||
      if (arrival_offset_seconds !== prev_arrival_offset_seconds) {
 | 
			
		||||
        // Nếu offset thay đổi thì cần cập nhật lại start_bid_time
 | 
			
		||||
        const start_bid_time = arrival_offset_seconds
 | 
			
		||||
          ? subtractMinutes(prev.close_time, arrival_offset_seconds / 60)
 | 
			
		||||
          : prev.start_bid_time;
 | 
			
		||||
 | 
			
		||||
        result = await this.bidsRepo.update(id, {
 | 
			
		||||
          ...data,
 | 
			
		||||
          start_bid_time,
 | 
			
		||||
        });
 | 
			
		||||
      } else {
 | 
			
		||||
        result = await this.bidsRepo.update(id, {
 | 
			
		||||
          ...data,
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (!result) throw new BadRequestException(false);
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -234,11 +284,16 @@ export class BidsService {
 | 
			
		|||
 | 
			
		||||
    // Nếu phiên đấu giá chưa có thời gian bắt đầu và kết thúc
 | 
			
		||||
    if (!bid.close_time && !bid.start_bid_time && data?.lot_id) {
 | 
			
		||||
      // Tách lấy arrival_offset_seconds trong product nếu không có lấy trong web bid.
 | 
			
		||||
      const arrival_offset_seconds =
 | 
			
		||||
        this.bidMetadatasService.getArrivalOffsetSecondsByMode(bid.metadata) ||
 | 
			
		||||
        bid.web_bid.arrival_offset_seconds;
 | 
			
		||||
 | 
			
		||||
      // Thiết lập thời gian bắt đầu là 5 phút trước khi đóng
 | 
			
		||||
      // bid.start_bid_time = new Date().toUTCString();
 | 
			
		||||
      bid.start_bid_time = subtractMinutes(
 | 
			
		||||
        close_time,
 | 
			
		||||
        bid.web_bid.arrival_offset_seconds / 60,
 | 
			
		||||
        arrival_offset_seconds / 60,
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -459,6 +514,34 @@ export class BidsService {
 | 
			
		|||
    return AppResponse.toResponse(true);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async uploadRecord(id: Bid['id'], video: Express.Multer.File) {
 | 
			
		||||
    if (!video) {
 | 
			
		||||
      throw new BadRequestException(
 | 
			
		||||
        AppResponse.toResponse(null, { message: 'File or Url is required' }),
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const data: { filename: string } | undefined =
 | 
			
		||||
      await new ImageCompressionPipe(
 | 
			
		||||
        `${Constant.RECORD_FOLDER}`,
 | 
			
		||||
        {},
 | 
			
		||||
        {
 | 
			
		||||
          unique_image_folder: false,
 | 
			
		||||
          unique_name: false,
 | 
			
		||||
        },
 | 
			
		||||
      ).transform(video);
 | 
			
		||||
 | 
			
		||||
    if (!data)
 | 
			
		||||
      throw new BadRequestException(
 | 
			
		||||
        AppResponse.toResponse(null, {
 | 
			
		||||
          message: "Can't create media",
 | 
			
		||||
          status_code: HttpStatus.BAD_REQUEST,
 | 
			
		||||
        }),
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
    return AppResponse.toResponse(true);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async getStatusWorkingImage(
 | 
			
		||||
    id: Bid['id'],
 | 
			
		||||
    type: string,
 | 
			
		||||
| 
						 | 
				
			
			@ -515,6 +598,84 @@ export class BidsService {
 | 
			
		|||
    return res.sendFile(filePath);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async getRecord(name: string, res: Response) {
 | 
			
		||||
    const rootDir = process.cwd();
 | 
			
		||||
    const filePath = join(
 | 
			
		||||
      rootDir,
 | 
			
		||||
      `${Constant.MEDIA_PATH}/${Constant.RECORD_FOLDER}/${name}`,
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    if (!existsSync(filePath)) {
 | 
			
		||||
      throw new NotFoundException(
 | 
			
		||||
        AppResponse.toResponse(null, {
 | 
			
		||||
          message: 'Folder not found',
 | 
			
		||||
          status_code: HttpStatus.NOT_FOUND,
 | 
			
		||||
        }),
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const stat = fs.statSync(filePath);
 | 
			
		||||
    const fileSize = stat.size;
 | 
			
		||||
 | 
			
		||||
    res.writeHead(200, {
 | 
			
		||||
      'Content-Type': 'video/mp4',
 | 
			
		||||
      'Content-Length': fileSize,
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    const readStream = fs.createReadStream(filePath);
 | 
			
		||||
    readStream.pipe(res);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async getRecords(id: Bid['id']) {
 | 
			
		||||
    const rootDir = process.cwd();
 | 
			
		||||
    const folderPath = join(
 | 
			
		||||
      rootDir,
 | 
			
		||||
      `${Constant.MEDIA_PATH}/${Constant.RECORD_FOLDER}`,
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    if (!existsSync(folderPath)) {
 | 
			
		||||
      throw new NotFoundException(
 | 
			
		||||
        AppResponse.toResponse(null, {
 | 
			
		||||
          message: 'Folder not found',
 | 
			
		||||
          status_code: HttpStatus.NOT_FOUND,
 | 
			
		||||
        }),
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const files = await fs.promises.readdir(folderPath);
 | 
			
		||||
 | 
			
		||||
    const data = files
 | 
			
		||||
      .map((item) => {
 | 
			
		||||
        return parseVideoFileName(item);
 | 
			
		||||
      })
 | 
			
		||||
      .filter((i) => i.bid_id == id);
 | 
			
		||||
 | 
			
		||||
    const sorted = _.orderBy(data, ['timestamp'], ['desc']);
 | 
			
		||||
 | 
			
		||||
    return AppResponse.toResponse(sorted);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async deleteRecord(name: string) {
 | 
			
		||||
    const rootDir = process.cwd();
 | 
			
		||||
    const filePath = join(
 | 
			
		||||
      rootDir,
 | 
			
		||||
      `${Constant.MEDIA_PATH}/${Constant.RECORD_FOLDER}/${name}`,
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    if (!existsSync(filePath)) {
 | 
			
		||||
      throw new NotFoundException(
 | 
			
		||||
        AppResponse.toResponse(null, {
 | 
			
		||||
          message: 'File not found',
 | 
			
		||||
          status_code: HttpStatus.NOT_FOUND,
 | 
			
		||||
        }),
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    await fs.promises.unlink(filePath);
 | 
			
		||||
 | 
			
		||||
    return AppResponse.toResponse(true);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async getImagesWorking(id: Bid['id'], type: string) {
 | 
			
		||||
    const rootDir = process.cwd();
 | 
			
		||||
    const folderPath = join(
 | 
			
		||||
| 
						 | 
				
			
			@ -569,7 +730,13 @@ export class BidsService {
 | 
			
		|||
  }
 | 
			
		||||
 | 
			
		||||
  async getBidByModel(model: string) {
 | 
			
		||||
    const bid = await this.bidsRepo.findOne({ where: { model } });
 | 
			
		||||
    const bid = await this.bidsRepo.findOne({
 | 
			
		||||
      where: { model },
 | 
			
		||||
      relations: { metadata: true, web_bid: true },
 | 
			
		||||
      select: {
 | 
			
		||||
        web_bid: { arrival_offset_seconds: true, early_tracking_seconds: true },
 | 
			
		||||
      },
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    if (!bid)
 | 
			
		||||
      return AppResponse.toResponse(null, {
 | 
			
		||||
| 
						 | 
				
			
			@ -578,4 +745,59 @@ export class BidsService {
 | 
			
		|||
 | 
			
		||||
    return AppResponse.toResponse(plainToClass(Bid, bid));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async hookAction(
 | 
			
		||||
    { id, type }: { id: Bid['id']; type: 'action' | 'api' },
 | 
			
		||||
    data: any,
 | 
			
		||||
  ) {
 | 
			
		||||
    const bid = await this.bidsRepo.findOne({
 | 
			
		||||
      where: { id },
 | 
			
		||||
      relations: { metadata: true },
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    if (!bid)
 | 
			
		||||
      throw new NotFoundException(
 | 
			
		||||
        AppResponse.toResponse(null, { message: 'Not foud bid' }),
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
    if (type === 'api') {
 | 
			
		||||
      const result = JSON.parse(data?.data || {});
 | 
			
		||||
 | 
			
		||||
      result['timestamp'] = new Date().getTime();
 | 
			
		||||
      result['uuid'] = uuid();
 | 
			
		||||
 | 
			
		||||
      const prevDemoResponse = this.bidMetadatasService.getDemoResponse(
 | 
			
		||||
        bid.metadata,
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
      console.log({ prevDemoResponse, result });
 | 
			
		||||
 | 
			
		||||
      await this.bidMetadatasService.upsert(
 | 
			
		||||
        {
 | 
			
		||||
          [BidMetadata.DEMO_RESPONSE]:
 | 
			
		||||
            JSON.stringify([result, ...(prevDemoResponse || [])]) ||
 | 
			
		||||
            JSON.stringify([]),
 | 
			
		||||
        },
 | 
			
		||||
        bid,
 | 
			
		||||
      );
 | 
			
		||||
    } else {
 | 
			
		||||
      const recordUrl = data?.record_url || '';
 | 
			
		||||
 | 
			
		||||
      await this.bidMetadatasService.upsert(
 | 
			
		||||
        {
 | 
			
		||||
          [BidMetadata.LATEST_RECROD_LINK]: JSON.stringify(recordUrl),
 | 
			
		||||
        },
 | 
			
		||||
        bid,
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const latestBidData = await this.bidsRepo.findOne({
 | 
			
		||||
      where: { id },
 | 
			
		||||
      relations: { metadata: true },
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    this.eventEmitter.emit(Event.BID_DEMO, { bid: latestBidData, type });
 | 
			
		||||
 | 
			
		||||
    return AppResponse.toResponse(data);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -22,6 +22,8 @@ export class TasksService {
 | 
			
		|||
    private readonly dashboadService: DashboardService,
 | 
			
		||||
  ) {}
 | 
			
		||||
 | 
			
		||||
  // - Chạy kiểm tra và lấy thời gian close của product gần với hiện tại nhất
 | 
			
		||||
  // - Nếu thỏa điều kiện sẽ reset tool trước thời gian close 20 phút
 | 
			
		||||
  @Cron(CronExpression.EVERY_MINUTE)
 | 
			
		||||
  async handleResetTool() {
 | 
			
		||||
    const bids = await this.bidsService.bidsRepo.find({
 | 
			
		||||
| 
						 | 
				
			
			@ -36,6 +38,8 @@ export class TasksService {
 | 
			
		|||
      : null;
 | 
			
		||||
    const now = new Date();
 | 
			
		||||
 | 
			
		||||
    const minutesReset = 20;
 | 
			
		||||
 | 
			
		||||
    console.log('===== Reset Tool Check =====');
 | 
			
		||||
    console.log(
 | 
			
		||||
      'Last reset time:',
 | 
			
		||||
| 
						 | 
				
			
			@ -55,10 +59,10 @@ export class TasksService {
 | 
			
		|||
        if (result.closeTime) {
 | 
			
		||||
          const closeTimeDate = new Date(result.closeTime);
 | 
			
		||||
          const expectedReset = moment(closeTimeDate)
 | 
			
		||||
            .subtract(20, 'minutes')
 | 
			
		||||
            .subtract(minutesReset, 'minutes')
 | 
			
		||||
            .format('YYYY-MM-DD HH:mm:ss');
 | 
			
		||||
          console.log(
 | 
			
		||||
            'Expected reset time (close_time - 20 minutes):',
 | 
			
		||||
            `Expected reset time (close_time - ${minutesReset} minutes):`,
 | 
			
		||||
            expectedReset,
 | 
			
		||||
          );
 | 
			
		||||
        }
 | 
			
		||||
| 
						 | 
				
			
			@ -77,4 +81,18 @@ export class TasksService {
 | 
			
		|||
      console.log('No reset needed at this time');
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Reset lại server.
 | 
			
		||||
  // Nếu server quá lâu không được reset sẽ đẫn đến mất kết nối với imap (không nhận được mail)
 | 
			
		||||
  //Chạy vào 12 giờ đêm mỗi ngày
 | 
			
		||||
  @Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
 | 
			
		||||
  async handleResetServer() {
 | 
			
		||||
    const processName = 'auto-bid-server';
 | 
			
		||||
 | 
			
		||||
    console.log('===== Reset Server Check =====');
 | 
			
		||||
    await this.dashboadService.resetProcessByName(processName);
 | 
			
		||||
    console.log(
 | 
			
		||||
      `Reset server at: ${moment(new Date()).format('YYYY-MM-DD HH:mm:ss')}`,
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -68,7 +68,9 @@ export class WebBidsService {
 | 
			
		|||
        url: Not(IsNull()),
 | 
			
		||||
        children: { status: 'biding' },
 | 
			
		||||
      },
 | 
			
		||||
      relations: { children: { histories: true, web_bid: true } },
 | 
			
		||||
      relations: {
 | 
			
		||||
        children: { histories: true, web_bid: true, metadata: true },
 | 
			
		||||
      },
 | 
			
		||||
      order: {
 | 
			
		||||
        children: {
 | 
			
		||||
          close_time_ts: 'ASC',
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -3,6 +3,7 @@ export class Constant {
 | 
			
		|||
  public static BOT_TELEGRAM_PATH = 'bot-data';
 | 
			
		||||
 | 
			
		||||
  public static WORK_IMAGES_FOLDER = 'work-images';
 | 
			
		||||
  public static RECORD_FOLDER = 'records';
 | 
			
		||||
  public static TMP_FOLDER = 'tmp';
 | 
			
		||||
 | 
			
		||||
  public static DEFAULT_RESIZE = 400;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -9,6 +9,7 @@ export class Event {
 | 
			
		|||
  public static LOGIN_STATUS = 'login-status';
 | 
			
		||||
  public static BID_SUBMITED = 'bid-submited';
 | 
			
		||||
  public static BID_STATUS = 'bid-status';
 | 
			
		||||
  public static BID_DEMO = 'bid-demo';
 | 
			
		||||
 | 
			
		||||
  public static verifyCode(data: WebBid) {
 | 
			
		||||
    return `${this.VERIFY_CODE}.${data.origin_url}`;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,11 +1,12 @@
 | 
			
		|||
import { MailerModule } from '@nestjs-modules/mailer';
 | 
			
		||||
import { Module } from '@nestjs/common';
 | 
			
		||||
import { forwardRef, Module } from '@nestjs/common';
 | 
			
		||||
import { MailsService } from './services/mails.service';
 | 
			
		||||
import { ConfigModule, ConfigService } from '@nestjs/config';
 | 
			
		||||
import { BullModule } from '@nestjs/bull';
 | 
			
		||||
import { MailProcessor } from './process/mail.processor';
 | 
			
		||||
import { BidsModule } from '../bids/bids.module';
 | 
			
		||||
@Module({
 | 
			
		||||
  imports: [
 | 
			
		||||
    forwardRef(() => BidsModule),
 | 
			
		||||
    MailerModule.forRootAsync({
 | 
			
		||||
      imports: [ConfigModule],
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,4 +1,6 @@
 | 
			
		|||
import { Bid } from '@/modules/bids/entities/bid.entity';
 | 
			
		||||
import { BidMetadatasService } from '@/modules/bids/services/bid-metadatas.service';
 | 
			
		||||
import { BidsService } from '@/modules/bids/services/bids.service';
 | 
			
		||||
import { ScrapItem } from '@/modules/scraps/entities/scrap-item.entity';
 | 
			
		||||
import {
 | 
			
		||||
  extractDomain,
 | 
			
		||||
| 
						 | 
				
			
			@ -19,6 +21,8 @@ export class MailsService {
 | 
			
		|||
    private readonly mailerService: MailerService,
 | 
			
		||||
 | 
			
		||||
    @InjectQueue('mail-queue') private mailQueue: Queue,
 | 
			
		||||
    private readonly bidsService: BidsService,
 | 
			
		||||
    private readonly bidMetadataService: BidMetadatasService,
 | 
			
		||||
  ) {}
 | 
			
		||||
 | 
			
		||||
  async sendPlainText(to: string, subject: string, content: string) {
 | 
			
		||||
| 
						 | 
				
			
			@ -386,4 +390,82 @@ export class MailsService {
 | 
			
		|||
  </html>
 | 
			
		||||
  `;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async getBidRecordEmailContent(bid: Bid, type: 'action' | 'api') {
 | 
			
		||||
    let recordData: {
 | 
			
		||||
      bid_id: number;
 | 
			
		||||
      name: string;
 | 
			
		||||
      original_name: string;
 | 
			
		||||
      timestamp: number;
 | 
			
		||||
    }[] = [];
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      const data = await this.bidsService.getRecords(bid.id);
 | 
			
		||||
      recordData = data.data || [];
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      recordData = [];
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const json = this.bidMetadataService.getDemoResponse(bid.metadata);
 | 
			
		||||
 | 
			
		||||
    const videoUrl = this.bidMetadataService.getLatestRecordLink(bid.metadata);
 | 
			
		||||
 | 
			
		||||
    const closeTime = bid.close_time
 | 
			
		||||
      ? new Date(bid.close_time).toLocaleString('en-AU', {
 | 
			
		||||
          timeZone: 'Australia/Sydney',
 | 
			
		||||
          dateStyle: 'medium',
 | 
			
		||||
          timeStyle: 'short',
 | 
			
		||||
        })
 | 
			
		||||
      : 'Unknown';
 | 
			
		||||
 | 
			
		||||
    let content = '';
 | 
			
		||||
 | 
			
		||||
    if (type === 'action') {
 | 
			
		||||
      content = `
 | 
			
		||||
      <h3>📹 Record Preview</h3>
 | 
			
		||||
      <p><strong>Name:</strong> ${bid?.name}</p>
 | 
			
		||||
      <p><strong>Recorded:</strong> ${new Date().toLocaleString()}</p>
 | 
			
		||||
      <p>
 | 
			
		||||
        <a href="${videoUrl || ''}" target="_blank" style="
 | 
			
		||||
          display: inline-block;
 | 
			
		||||
          padding: 10px 16px;
 | 
			
		||||
          background-color: #007bff;
 | 
			
		||||
          color: #fff;
 | 
			
		||||
          text-decoration: none;
 | 
			
		||||
          border-radius: 4px;
 | 
			
		||||
          margin-top: 12px;
 | 
			
		||||
        ">
 | 
			
		||||
          ▶️ Watch Video
 | 
			
		||||
        </a>
 | 
			
		||||
      </p>
 | 
			
		||||
    `;
 | 
			
		||||
    } else if (type === 'api') {
 | 
			
		||||
      content = `
 | 
			
		||||
      <h3>📦 Record JSON</h3>
 | 
			
		||||
      <pre style="
 | 
			
		||||
        background-color: #f4f4f4;
 | 
			
		||||
        padding: 12px;
 | 
			
		||||
        border-radius: 4px;
 | 
			
		||||
        font-size: 13px;
 | 
			
		||||
        overflow-x: auto;
 | 
			
		||||
      ">${JSON.stringify(json, null, 2)}</pre>
 | 
			
		||||
    `;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return `
 | 
			
		||||
    <div style="font-family: Arial, sans-serif; color: #333; max-width: 600px; margin: 0 auto;">
 | 
			
		||||
      <h2>📝 Bid Demo: ${bid?.name}</h2>
 | 
			
		||||
      <p><strong>⏰ Close Time:</strong> ${closeTime}</p>
 | 
			
		||||
 | 
			
		||||
      ${
 | 
			
		||||
        content
 | 
			
		||||
          ? content
 | 
			
		||||
          : `<p style="color: red;">⚠️ No record available for this bid.</p>`
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      <hr style="margin: 24px 0;">
 | 
			
		||||
      <p style="font-size: 12px; color: #888;">This is an automated message. Please do not reply.</p>
 | 
			
		||||
    </div>
 | 
			
		||||
  `;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -26,7 +26,7 @@ export class AdminNotificationListener {
 | 
			
		|||
  }) {
 | 
			
		||||
    if (JSON.parse(notification.send_to).length <= 0) return;
 | 
			
		||||
 | 
			
		||||
    this.botTelegramApi.sendMessage(notification.message);
 | 
			
		||||
    // this.botTelegramApi.sendMessage(notification.message);
 | 
			
		||||
 | 
			
		||||
    const mails =
 | 
			
		||||
      (await this.configsSerice.getConfig('MAIL_SCRAP_REPORT')).value || '';
 | 
			
		||||
| 
						 | 
				
			
			@ -53,4 +53,20 @@ export class AdminNotificationListener {
 | 
			
		|||
        moment(new Date()).format('YYYY-MM-DD HH:mm'),
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @OnEvent(Event.BID_DEMO)
 | 
			
		||||
  async handleBidDemo({ bid, type }: { bid: Bid; type: 'api' | 'action' }) {
 | 
			
		||||
    const mails =
 | 
			
		||||
      (await this.configsSerice.getConfig('MAIL_SCRAP_REPORT')).value || '';
 | 
			
		||||
 | 
			
		||||
    const html = await this.mailsService.getBidRecordEmailContent(bid, type);
 | 
			
		||||
 | 
			
		||||
    this.mailsService.sendHtmlMailJob({
 | 
			
		||||
      to: mails,
 | 
			
		||||
      html: html,
 | 
			
		||||
      subject:
 | 
			
		||||
        'Demo Auto Auctions System ' +
 | 
			
		||||
        moment(new Date()).format('YYYY-MM-DD HH:mm'),
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -95,8 +95,8 @@ export class TasksService {
 | 
			
		|||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Thực hiện gọi scrape vào lúc 8h59 (giờ ÚC) mỗi ngày
 | 
			
		||||
  @Cron('59 5 * * *')
 | 
			
		||||
  // @Cron(CronExpression.EVERY_MINUTE)
 | 
			
		||||
  async handleScraps() {
 | 
			
		||||
    const processName = 'scrape-data-keyword';
 | 
			
		||||
    await this.runProcessAndSendReport(processName);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -10,6 +10,10 @@ export const excludeAuth = [
 | 
			
		|||
    path: 'admin/auth/forgot-password',
 | 
			
		||||
    method: RequestMethod.POST,
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    path: '/admin/bids/record/(.*)',
 | 
			
		||||
    method: RequestMethod.GET,
 | 
			
		||||
  },
 | 
			
		||||
] as (string | RouteInfo)[];
 | 
			
		||||
 | 
			
		||||
export const clientExcludeAuth = [
 | 
			
		||||
| 
						 | 
				
			
			@ -22,6 +26,10 @@ export const clientExcludeAuth = [
 | 
			
		|||
    path: 'extensions/policy',
 | 
			
		||||
    method: RequestMethod.GET,
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    path: 'bids/hook-action',
 | 
			
		||||
    method: RequestMethod.POST,
 | 
			
		||||
  },
 | 
			
		||||
] as (string | RouteInfo)[];
 | 
			
		||||
 | 
			
		||||
export const excludeAuthor = [
 | 
			
		||||
| 
						 | 
				
			
			@ -38,4 +46,8 @@ export const excludeAuthor = [
 | 
			
		|||
    path: 'admin/auth/me',
 | 
			
		||||
    method: RequestMethod.GET,
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    path: '/admin/bids/record/(.*)',
 | 
			
		||||
    method: RequestMethod.GET,
 | 
			
		||||
  },
 | 
			
		||||
] as (string | RouteInfo)[];
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -222,3 +222,29 @@ export const isHotItemFn = (item: ScrapItem, hotItems: any[]) => {
 | 
			
		|||
    item.name.toLowerCase().includes(obj.name.toLowerCase()),
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export function parseVideoFileName(fileName: string) {
 | 
			
		||||
  // Loại bỏ phần mở rộng .mp4
 | 
			
		||||
  const nameWithoutExt = fileName.replace(/\.mp4$/, '');
 | 
			
		||||
 | 
			
		||||
  // Tách phần ID đầu tiên
 | 
			
		||||
  const firstUnderscoreIndex = nameWithoutExt.indexOf('_');
 | 
			
		||||
  const id = nameWithoutExt.slice(0, firstUnderscoreIndex);
 | 
			
		||||
 | 
			
		||||
  // Tách phần timestamp cuối cùng
 | 
			
		||||
  const lastUnderscoreIndex = nameWithoutExt.lastIndexOf('_');
 | 
			
		||||
  const timestamp = nameWithoutExt.slice(lastUnderscoreIndex + 1);
 | 
			
		||||
 | 
			
		||||
  // Tên sản phẩm nằm giữa 2 phần còn lại
 | 
			
		||||
  const name = nameWithoutExt.slice(
 | 
			
		||||
    firstUnderscoreIndex + 1,
 | 
			
		||||
    lastUnderscoreIndex,
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  return {
 | 
			
		||||
    bid_id: Number(id),
 | 
			
		||||
    name,
 | 
			
		||||
    original_name: fileName,
 | 
			
		||||
    timestamp: Number(timestamp),
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -15,9 +15,11 @@ import {
 | 
			
		|||
  delay,
 | 
			
		||||
  extractModelId,
 | 
			
		||||
  isTimeReached,
 | 
			
		||||
  randomDelayWithMeta,
 | 
			
		||||
  safeClosePage,
 | 
			
		||||
  subtractSeconds,
 | 
			
		||||
} from "./system/utils.js";
 | 
			
		||||
import CONSTANTS from "./system/constants.js";
 | 
			
		||||
 | 
			
		||||
global.IS_CLEANING = true;
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -146,6 +148,11 @@ const tracking = async () => {
 | 
			
		|||
      Promise.allSettled(
 | 
			
		||||
        productTabs.map(async (productTab) => {
 | 
			
		||||
          console.log(`📌 Processing Product ID: ${productTab.id}`);
 | 
			
		||||
          console.log(
 | 
			
		||||
            `🔍 [${
 | 
			
		||||
              productTab.id
 | 
			
		||||
            }] Current URL: ${await productTab.page_context?.url?.()}`
 | 
			
		||||
          );
 | 
			
		||||
 | 
			
		||||
          // Xác định parent context
 | 
			
		||||
          if (!productTab.parent_browser_context) {
 | 
			
		||||
| 
						 | 
				
			
			@ -162,7 +169,7 @@ const tracking = async () => {
 | 
			
		|||
          // Thời điểm tracking liên tục
 | 
			
		||||
          const earlyTrackingTime = subtractSeconds(
 | 
			
		||||
            productTab.close_time,
 | 
			
		||||
            productTab?.web_bid?.early_tracking_seconds || 0
 | 
			
		||||
            productTab.getEarlyTrackingSeconds() || 0
 | 
			
		||||
          );
 | 
			
		||||
 | 
			
		||||
          // Check không mở tab nếu chưa đến giờ
 | 
			
		||||
| 
						 | 
				
			
			@ -221,8 +228,6 @@ const tracking = async () => {
 | 
			
		|||
            return;
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          // Thực thi hành động
 | 
			
		||||
          console.log(`🚀 Executing action for Product ID: ${productTab.id}`);
 | 
			
		||||
          await productTab.action();
 | 
			
		||||
        })
 | 
			
		||||
      );
 | 
			
		||||
| 
						 | 
				
			
			@ -289,7 +294,7 @@ const clearLazyTab = async () => {
 | 
			
		|||
 | 
			
		||||
          const earlyTrackingTime = subtractSeconds(
 | 
			
		||||
            productTab.close_time,
 | 
			
		||||
            productTab?.web_bid?.early_tracking_seconds || 0
 | 
			
		||||
            productTab.getEarlyTrackingSeconds() || 0
 | 
			
		||||
          );
 | 
			
		||||
 | 
			
		||||
          if (!isTimeReached(earlyTrackingTime)) {
 | 
			
		||||
| 
						 | 
				
			
			@ -323,6 +328,11 @@ const clearLazyTab = async () => {
 | 
			
		|||
 | 
			
		||||
        console.log(`🛑 Unused page detected: ${pageUrl}`);
 | 
			
		||||
 | 
			
		||||
        if (CONSTANTS.KEEP_LIVE_URLS.some((item) => pageUrl.includes(item))) {
 | 
			
		||||
          console.log("Url include keep live urls: " + pageUrl);
 | 
			
		||||
          continue;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const bidData = MANAGER_BIDS.filter((item) => item.page_context)
 | 
			
		||||
          .map((i) => ({
 | 
			
		||||
            current_url: i.page_context.url(),
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -11,6 +11,7 @@ import {
 | 
			
		|||
  takeSnapshot,
 | 
			
		||||
} from "../../system/utils.js";
 | 
			
		||||
import { ProductBid } from "../product-bid.js";
 | 
			
		||||
import axios from "../../system/axios.js";
 | 
			
		||||
 | 
			
		||||
export class AllbidsProductBid extends ProductBid {
 | 
			
		||||
  constructor({ ...prev }) {
 | 
			
		||||
| 
						 | 
				
			
			@ -127,7 +128,7 @@ export class AllbidsProductBid extends ProductBid {
 | 
			
		|||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async submitBid() {
 | 
			
		||||
  async handlePlaceBidLive() {
 | 
			
		||||
    if (!this.page_context) return;
 | 
			
		||||
 | 
			
		||||
    const response = await this.page_context.evaluate(
 | 
			
		||||
| 
						 | 
				
			
			@ -158,6 +159,36 @@ export class AllbidsProductBid extends ProductBid {
 | 
			
		|||
    return response;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async handlePlaceBidSanbox() {
 | 
			
		||||
    if (!this.page_context) return;
 | 
			
		||||
 | 
			
		||||
    const response = await this.page_context.evaluate(
 | 
			
		||||
      async (aucID, submitUrl) => {
 | 
			
		||||
        try {
 | 
			
		||||
          const url = `${submitUrl}?aucID=${aucID}&bidAmount=${0}&bidType=maximum`;
 | 
			
		||||
 | 
			
		||||
          const res = await fetch(url, {
 | 
			
		||||
            method: "POST",
 | 
			
		||||
          });
 | 
			
		||||
 | 
			
		||||
          if (!res.ok) {
 | 
			
		||||
            return { success: false, message: `HTTP error ${res.status}` };
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          const data = await res.json();
 | 
			
		||||
 | 
			
		||||
          return data;
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
          return { success: false, message: error.message || "Fetch failed" };
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      this.model,
 | 
			
		||||
      configs.WEB_URLS.ALLBIDS.PLACE_BID
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    return response;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  update = async () => {
 | 
			
		||||
    if (!this.page_context) return;
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -280,7 +311,21 @@ export class AllbidsProductBid extends ProductBid {
 | 
			
		|||
 | 
			
		||||
      console.log("---------------------BIDDING--------------------");
 | 
			
		||||
 | 
			
		||||
      const data = await this.submitBid();
 | 
			
		||||
      if (this.isSandbox()) {
 | 
			
		||||
        await this.handleCallActionSanbox();
 | 
			
		||||
      } else {
 | 
			
		||||
        await this.handleCallActionLive();
 | 
			
		||||
      }
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.log(`🚨 [${this.id}] Error placing bid: ${error.message}`);
 | 
			
		||||
    } finally {
 | 
			
		||||
      console.log(`🔚 [${this.id}] Resetting bid flag.`);
 | 
			
		||||
      global[`IS_PLACE_BID-${this.id}`] = false;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async handleCallActionLive() {
 | 
			
		||||
    const data = await this.handlePlaceBidLive();
 | 
			
		||||
 | 
			
		||||
    await this.page_context.reload({ waitUntil: "networkidle0" });
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -297,7 +342,7 @@ export class AllbidsProductBid extends ProductBid {
 | 
			
		|||
        CONSTANTS.TYPE_IMAGE.SUCCESS
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
        sendMessage(this);
 | 
			
		||||
      // sendMessage(this);
 | 
			
		||||
 | 
			
		||||
      pushPrice({
 | 
			
		||||
        bid_id: this.id,
 | 
			
		||||
| 
						 | 
				
			
			@ -311,12 +356,23 @@ export class AllbidsProductBid extends ProductBid {
 | 
			
		|||
    console.log(
 | 
			
		||||
      `⚠️ [${this.id}] Bid action completed, but status is still "None".`
 | 
			
		||||
    );
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.log(`🚨 [${this.id}] Error placing bid: ${error.message}`);
 | 
			
		||||
    } finally {
 | 
			
		||||
      console.log(`🔚 [${this.id}] Resetting bid flag.`);
 | 
			
		||||
      global[`IS_PLACE_BID-${this.id}`] = false;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async handleCallActionSanbox() {
 | 
			
		||||
    const result = await this.handlePlaceBidSanbox();
 | 
			
		||||
 | 
			
		||||
    await axios({
 | 
			
		||||
      url: this.ACTION_URL({ type: "api" }),
 | 
			
		||||
      data: {
 | 
			
		||||
        id: this.id,
 | 
			
		||||
        data: JSON.stringify(result),
 | 
			
		||||
      },
 | 
			
		||||
      method: "POST",
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    await this.close();
 | 
			
		||||
 | 
			
		||||
    return result;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  action = async () => {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -98,6 +98,11 @@ export class ApiBid extends Bid {
 | 
			
		|||
    if (this.snapshot_at) {
 | 
			
		||||
      const nearestCloseTime = findNearestClosingChild(this);
 | 
			
		||||
 | 
			
		||||
      // Nếu không có roi vào 2 trường hợp
 | 
			
		||||
      // 1 là không có item nào đang hợp lệ
 | 
			
		||||
      // 2 là có item chưa được craw dữ liệu
 | 
			
		||||
 | 
			
		||||
      // Xử lí trường hợp 2
 | 
			
		||||
      if (!nearestCloseTime || this.children.some((item) => !item.close_time)) {
 | 
			
		||||
        console.log(`🔌 [${this.id}] Connecting to puppeteer...`);
 | 
			
		||||
        await this.puppeteer_connect();
 | 
			
		||||
| 
						 | 
				
			
			@ -109,13 +114,18 @@ export class ApiBid extends Bid {
 | 
			
		|||
        return true;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // Xử lí trường hợp có item hợp lệ ( close time gần với thời gian hiện tại)
 | 
			
		||||
      const { close_time } = nearestCloseTime;
 | 
			
		||||
      console.log(`📅 [${this.id}] Nearest close_time: ${close_time}`);
 | 
			
		||||
 | 
			
		||||
      const timeToTracking = subtractSeconds(
 | 
			
		||||
        close_time,
 | 
			
		||||
        this.early_tracking_seconds || 0
 | 
			
		||||
        nearestCloseTime.getEarlyTrackingSeconds() || 0
 | 
			
		||||
      );
 | 
			
		||||
      // const timeToTracking = subtractSeconds(
 | 
			
		||||
      //   close_time,
 | 
			
		||||
      //   this.early_tracking_seconds || 0
 | 
			
		||||
      // );
 | 
			
		||||
 | 
			
		||||
      console.log(
 | 
			
		||||
        `🕰️ [${this.id}] Time to tracking: ${new Date(
 | 
			
		||||
| 
						 | 
				
			
			@ -155,7 +165,7 @@ export class ApiBid extends Bid {
 | 
			
		|||
 | 
			
		||||
    const timeToTracking = subtractSeconds(
 | 
			
		||||
      close_time,
 | 
			
		||||
      this.early_tracking_seconds || 0
 | 
			
		||||
      nearestCloseTime.getEarlyTrackingSeconds() || 0
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    // Nếu chưa đến giờ tracking => tab lazy
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -209,7 +209,10 @@ export class GrayApiBid extends ApiBid {
 | 
			
		|||
    const filePath = getPathProfile(this.origin_url);
 | 
			
		||||
 | 
			
		||||
    // 🔍 Check if already logged in (login input should not be visible)
 | 
			
		||||
    if (!(await page.$('input[name="username"]')) || fs.existsSync(filePath)) {
 | 
			
		||||
    if (
 | 
			
		||||
      !(await page.$('input[name="username"]'))
 | 
			
		||||
      //  || fs.existsSync(filePath)
 | 
			
		||||
    ) {
 | 
			
		||||
      console.log(`✅ [${this.id}] Already logged in, skipping login.`);
 | 
			
		||||
 | 
			
		||||
      global.IS_CLEANING = true;
 | 
			
		||||
| 
						 | 
				
			
			@ -276,7 +279,7 @@ export class GrayApiBid extends ApiBid {
 | 
			
		|||
    try {
 | 
			
		||||
      const page = this.page_context;
 | 
			
		||||
 | 
			
		||||
      await page.goto(this.url, { waitUntil: "networkidle2" });
 | 
			
		||||
      await page.goto(this.url, { waitUntil: "networkidle2", timeout: 10000 });
 | 
			
		||||
      console.log(`🌍 [${this.id}] Navigated to URL: ${this.url}`);
 | 
			
		||||
 | 
			
		||||
      await page.bringToFront();
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,322 +0,0 @@
 | 
			
		|||
import {
 | 
			
		||||
  outBid,
 | 
			
		||||
  pushPrice,
 | 
			
		||||
  updateBid,
 | 
			
		||||
  updateStatusByPrice,
 | 
			
		||||
} from "../../system/apis/bid.js";
 | 
			
		||||
import CONSTANTS from "../../system/constants.js";
 | 
			
		||||
import {
 | 
			
		||||
  delay,
 | 
			
		||||
  extractNumber,
 | 
			
		||||
  isNumber,
 | 
			
		||||
  isTimeReached,
 | 
			
		||||
  removeFalsyValues,
 | 
			
		||||
  safeClosePage,
 | 
			
		||||
  takeSnapshot,
 | 
			
		||||
} from "../../system/utils.js";
 | 
			
		||||
import { ProductBid } from "../product-bid.js";
 | 
			
		||||
 | 
			
		||||
export class GraysProductBidBackup extends ProductBid {
 | 
			
		||||
  constructor({ ...prev }) {
 | 
			
		||||
    super(prev);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async validate({ page, price_value }) {
 | 
			
		||||
    if (!this.start_bid_time || !isTimeReached(this.start_bid_time)) {
 | 
			
		||||
      console.log(`❌ [${this.id}] It's not time yet`);
 | 
			
		||||
      return { result: false, bid_price: 0 };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (!isNumber(price_value)) {
 | 
			
		||||
      console.log(`❌ [${this.id}] Can't get PRICE_VALUE`);
 | 
			
		||||
      await takeSnapshot(page, this, "price-value-null");
 | 
			
		||||
 | 
			
		||||
      return { result: false, bid_price: 0 };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const bid_price = this.plus_price + Number(price_value);
 | 
			
		||||
 | 
			
		||||
    if (bid_price > this.max_price) {
 | 
			
		||||
      console.log(
 | 
			
		||||
        `❌ ${this.id} PRICE BID is more than MAX_VALUE => STOP BID THIS PRODUCT`
 | 
			
		||||
      );
 | 
			
		||||
      await takeSnapshot(page, this, "price-bid-more-than");
 | 
			
		||||
 | 
			
		||||
      await outBid(this.id);
 | 
			
		||||
 | 
			
		||||
      return { result: false, bid_price: 0 };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const response = await pushPrice({
 | 
			
		||||
      bid_id: this.id,
 | 
			
		||||
      price: bid_price,
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    if (!response.status) {
 | 
			
		||||
      return { result: false, bid_price: 0 };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    this.histories = response.data;
 | 
			
		||||
 | 
			
		||||
    // RESET first bid
 | 
			
		||||
    if (this.histories.length > 0 && this.first_bid) {
 | 
			
		||||
      this.first_bid = false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return { result: true, bid_price };
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  getCloseTime = async () => {
 | 
			
		||||
    try {
 | 
			
		||||
      if (!this.page_context) return null;
 | 
			
		||||
 | 
			
		||||
      await this.page_context.waitForSelector("#lot-closing-datetime", {
 | 
			
		||||
        timeout: 3000,
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      return await this.page_context.$eval(
 | 
			
		||||
        "#lot-closing-datetime",
 | 
			
		||||
        (el) => el.value
 | 
			
		||||
      );
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      return null;
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  getPriceWasBid = async () => {
 | 
			
		||||
    try {
 | 
			
		||||
      if (!this.page_context) return null;
 | 
			
		||||
 | 
			
		||||
      await this.page_context.waitForSelector(
 | 
			
		||||
        "#biddableLot form div div:nth-child(1) span span",
 | 
			
		||||
        { timeout: 3000 }
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
      const element = await this.page_context.$(
 | 
			
		||||
        "#biddableLot form div div:nth-child(1) span span"
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
      const textPrice = await this.page_context.evaluate(
 | 
			
		||||
        (el) => el.textContent,
 | 
			
		||||
        element
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
      return extractNumber(textPrice) || null;
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      return null;
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  async isCloseProduct() {
 | 
			
		||||
    const close_time = await this.getCloseTime();
 | 
			
		||||
 | 
			
		||||
    if (!close_time) {
 | 
			
		||||
      const priceWasBid = await this.getPriceWasBid();
 | 
			
		||||
 | 
			
		||||
      await updateStatusByPrice(this.id, priceWasBid);
 | 
			
		||||
      return { result: true, close_time: null };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    await delay(500);
 | 
			
		||||
 | 
			
		||||
    if (!close_time || new Date(close_time).getTime() <= new Date().getTime()) {
 | 
			
		||||
      console.log(`❌ [${this.id}] Product is close ${close_time}`);
 | 
			
		||||
      return { result: true, close_time };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return { result: false, close_time };
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async handleWritePrice(page, bid_price) {
 | 
			
		||||
    await page.type("#price", String(bid_price));
 | 
			
		||||
    await delay(500);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async placeBid(page) {
 | 
			
		||||
    try {
 | 
			
		||||
      await page.click("#bid-type-standard");
 | 
			
		||||
      await delay(500);
 | 
			
		||||
 | 
			
		||||
      await page.click("#btnSubmit");
 | 
			
		||||
      await delay(1000);
 | 
			
		||||
 | 
			
		||||
      await page.waitForSelector("button", { timeout: 5000 });
 | 
			
		||||
 | 
			
		||||
      await delay(500);
 | 
			
		||||
 | 
			
		||||
      await page.click("button");
 | 
			
		||||
 | 
			
		||||
      await page.waitForNavigation({ timeout: 5000 });
 | 
			
		||||
 | 
			
		||||
      await takeSnapshot(
 | 
			
		||||
        page,
 | 
			
		||||
        this,
 | 
			
		||||
        "bid-success",
 | 
			
		||||
        CONSTANTS.TYPE_IMAGE.SUCCESS
 | 
			
		||||
      );
 | 
			
		||||
      return true;
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.log(`❌ [${this.id}] Timeout to loading`);
 | 
			
		||||
      await takeSnapshot(page, this, "timeout to loading");
 | 
			
		||||
      return false;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async handleReturnProductPage(page) {
 | 
			
		||||
    await page.goto(this.url);
 | 
			
		||||
    await delay(1000);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async handleUpdateBid({
 | 
			
		||||
    lot_id,
 | 
			
		||||
    close_time,
 | 
			
		||||
    name,
 | 
			
		||||
    current_price,
 | 
			
		||||
    reserve_price,
 | 
			
		||||
  }) {
 | 
			
		||||
    const response = await updateBid(this.id, {
 | 
			
		||||
      lot_id,
 | 
			
		||||
      close_time,
 | 
			
		||||
      name,
 | 
			
		||||
      current_price,
 | 
			
		||||
      reserve_price: Number(reserve_price) || 0,
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    if (response) {
 | 
			
		||||
      this.lot_id = response.lot_id;
 | 
			
		||||
      this.close_time = response.close_time;
 | 
			
		||||
      this.start_bid_time = response.start_bid_time;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  update = async () => {
 | 
			
		||||
    if (!this.page_context) return;
 | 
			
		||||
 | 
			
		||||
    const page = this.page_context;
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      const close_time = await this.getCloseTime();
 | 
			
		||||
 | 
			
		||||
      // Chờ phần tử xuất hiện trước khi lấy giá trị
 | 
			
		||||
      await page
 | 
			
		||||
        .waitForSelector("#priceValue", { timeout: 5000 })
 | 
			
		||||
        .catch(() => null);
 | 
			
		||||
      const price_value = await page
 | 
			
		||||
        .$eval("#priceValue", (el) => el.value)
 | 
			
		||||
        .catch(() => null);
 | 
			
		||||
 | 
			
		||||
      await page.waitForSelector("#lotId", { timeout: 5000 }).catch(() => null);
 | 
			
		||||
      const lot_id = await page
 | 
			
		||||
        .$eval("#lotId", (el) => el.value)
 | 
			
		||||
        .catch(() => null);
 | 
			
		||||
 | 
			
		||||
      await page
 | 
			
		||||
        .waitForSelector("#placebid-sticky > div:nth-child(2) > div > h3", {
 | 
			
		||||
          timeout: 5000,
 | 
			
		||||
        })
 | 
			
		||||
        .catch(() => null);
 | 
			
		||||
      const name = await page
 | 
			
		||||
        .$eval(".dls-heading-3.lotPageTitle", (el) => el.innerText)
 | 
			
		||||
        .catch(() => null);
 | 
			
		||||
 | 
			
		||||
      await page
 | 
			
		||||
        .waitForSelector(
 | 
			
		||||
          "#biddableLot > form > div > div:nth-child(1) > div:nth-child(1) > div:nth-child(2) > div > span > span",
 | 
			
		||||
          { timeout: 5000 }
 | 
			
		||||
        )
 | 
			
		||||
        .catch(() => null);
 | 
			
		||||
      const current_price = await page
 | 
			
		||||
        .$eval(
 | 
			
		||||
          "#biddableLot > form > div > div:nth-child(1) > div:nth-child(1) > div:nth-child(2) > div > span > span",
 | 
			
		||||
          (el) => el.innerText
 | 
			
		||||
        )
 | 
			
		||||
        .catch(() => null);
 | 
			
		||||
 | 
			
		||||
      console.log(
 | 
			
		||||
        `📌 [${this.id}] Product Info: Lot ID: ${lot_id}, Name: ${name}, Current Price: ${current_price}, Reserve price: ${price_value}`
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
      const data = removeFalsyValues(
 | 
			
		||||
        {
 | 
			
		||||
          lot_id,
 | 
			
		||||
          reserve_price: price_value,
 | 
			
		||||
          close_time: close_time ? String(close_time) : null,
 | 
			
		||||
          name,
 | 
			
		||||
          current_price: current_price ? extractNumber(current_price) : null,
 | 
			
		||||
        },
 | 
			
		||||
        ["close_time"]
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
      this.handleUpdateBid(data);
 | 
			
		||||
 | 
			
		||||
      return { price_value, lot_id, name, current_price };
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.error(`🚨 Error updating product info: ${error.message}`);
 | 
			
		||||
      return null;
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  action = async () => {
 | 
			
		||||
    try {
 | 
			
		||||
      const page = this.page_context;
 | 
			
		||||
 | 
			
		||||
      await this.gotoLink();
 | 
			
		||||
      console.log(`🌍 [${this.id}] Navigated to link.`);
 | 
			
		||||
 | 
			
		||||
      await delay(1000);
 | 
			
		||||
 | 
			
		||||
      const { close_time, ...isCloseProduct } = await this.isCloseProduct();
 | 
			
		||||
      if (isCloseProduct.result) {
 | 
			
		||||
        console.log(
 | 
			
		||||
          `❌ [${this.id}] The product is closed, cannot place a bid.`
 | 
			
		||||
        );
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      await delay(500);
 | 
			
		||||
 | 
			
		||||
      const { price_value } = await this.update();
 | 
			
		||||
      if (!price_value) return;
 | 
			
		||||
 | 
			
		||||
      const { result, bid_price } = await this.validate({ page, price_value });
 | 
			
		||||
      if (!result) {
 | 
			
		||||
        console.log(
 | 
			
		||||
          `❌ [${this.id}] Validation failed. Unable to proceed with bidding.`
 | 
			
		||||
        );
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const bidHistoriesItem = _.maxBy(this.histories, "price");
 | 
			
		||||
      if (bidHistoriesItem && bidHistoriesItem.price === this.current_price) {
 | 
			
		||||
        console.log(
 | 
			
		||||
          `🔄 [${this.id}] You have already bid on this item! (Bid Price: ${bidHistoriesItem.price})`
 | 
			
		||||
        );
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (price_value != bid_price) {
 | 
			
		||||
        console.log(
 | 
			
		||||
          `✍️ [${this.id}] Updating bid price from ${price_value} → ${bid_price}`
 | 
			
		||||
        );
 | 
			
		||||
        await this.handleWritePrice(page, bid_price);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      console.log(`🚀 [${this.id}] Placing the bid...`);
 | 
			
		||||
      const resultPlaceBid = await this.placeBid(page);
 | 
			
		||||
      if (!resultPlaceBid) {
 | 
			
		||||
        console.log(`❌ [${this.id}] Error occurred while placing the bid.`);
 | 
			
		||||
        await takeSnapshot(page, this, "place-bid-action");
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      console.log(
 | 
			
		||||
        `✅ [${this.id}] Bid placed successfully! 🏆 Bid Price: ${bid_price}, Closing Time: ${close_time}`
 | 
			
		||||
      );
 | 
			
		||||
      await this.handleReturnProductPage(page);
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.error(
 | 
			
		||||
        `🚨 [${this.id}] Error navigating the page: ${error.message}`
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -3,6 +3,7 @@ import {
 | 
			
		|||
  updateBid,
 | 
			
		||||
  updateStatusByPrice,
 | 
			
		||||
} from "../../system/apis/bid.js";
 | 
			
		||||
import axios from "../../system/axios.js";
 | 
			
		||||
import {
 | 
			
		||||
  delay,
 | 
			
		||||
  extractNumber,
 | 
			
		||||
| 
						 | 
				
			
			@ -88,9 +89,10 @@ export class GraysProductBid extends ProductBid {
 | 
			
		|||
      await this.page_context.evaluate(() => {
 | 
			
		||||
        document.querySelector("#price").value = "";
 | 
			
		||||
      });
 | 
			
		||||
      console.log("✅ Cleared price");
 | 
			
		||||
 | 
			
		||||
      await this.page_context.type("#price", String(this.max_price));
 | 
			
		||||
 | 
			
		||||
      console.log("✅ Typed max price");
 | 
			
		||||
      await delay(5000);
 | 
			
		||||
 | 
			
		||||
      const currentValue = await this.page_context.$eval(
 | 
			
		||||
| 
						 | 
				
			
			@ -98,6 +100,8 @@ export class GraysProductBid extends ProductBid {
 | 
			
		|||
        (el) => el.value
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
      console.log("✅ Checked currentValue:", currentValue);
 | 
			
		||||
 | 
			
		||||
      if (currentValue !== String(this.max_price)) {
 | 
			
		||||
        console.warn(
 | 
			
		||||
          `[${this.id}] Value not match #price: ${currentValue} !== ${this.max_price}`
 | 
			
		||||
| 
						 | 
				
			
			@ -106,20 +110,60 @@ export class GraysProductBid extends ProductBid {
 | 
			
		|||
      }
 | 
			
		||||
 | 
			
		||||
      await this.page_context.click("#btnSubmit");
 | 
			
		||||
      console.log("✅ Clicked submit");
 | 
			
		||||
      await delay(1000);
 | 
			
		||||
 | 
			
		||||
      await this.page_context.waitForSelector("button", { timeout: 5000 });
 | 
			
		||||
 | 
			
		||||
      await delay(500);
 | 
			
		||||
      await delay(5000);
 | 
			
		||||
 | 
			
		||||
      // await this.page_context.click("button");
 | 
			
		||||
 | 
			
		||||
      // await this.page_context.waitForNavigation({ timeout: 5000 });
 | 
			
		||||
 | 
			
		||||
      // await this.page_context.waitForFunction(
 | 
			
		||||
      //   () => document.body.innerText.includes("Successfully"),
 | 
			
		||||
      //   { timeout: 5000 } // hoặc lâu hơn nếu cần
 | 
			
		||||
      // );
 | 
			
		||||
      // console.log("✅ Found 'Successfully'");
 | 
			
		||||
 | 
			
		||||
      // await pushPrice({
 | 
			
		||||
      //   bid_id: this.id,
 | 
			
		||||
      //   price: this.max_price,
 | 
			
		||||
      // });
 | 
			
		||||
 | 
			
		||||
      // await this.handleReturnProductPage();
 | 
			
		||||
 | 
			
		||||
      if (this.isSandbox()) {
 | 
			
		||||
        await this.handlePlaceBidSandbox();
 | 
			
		||||
      } else {
 | 
			
		||||
        await this.handlePlaceBidLive();
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      return true;
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      await this.page_context.goto(this.url);
 | 
			
		||||
      console.log(error);
 | 
			
		||||
      console.log(`❌ [${this.id}] Error in placeBid: ${error.message}`);
 | 
			
		||||
      return false;
 | 
			
		||||
    } finally {
 | 
			
		||||
      global.IS_CLEANING = true;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async handlePlaceBidLive() {
 | 
			
		||||
    try {
 | 
			
		||||
      global.IS_CLEANING = false;
 | 
			
		||||
 | 
			
		||||
      await this.page_context.click("button");
 | 
			
		||||
 | 
			
		||||
      await this.page_context.waitForNavigation({ timeout: 5000 });
 | 
			
		||||
 | 
			
		||||
      await this.page_context.waitForFunction(
 | 
			
		||||
        () => document.body.innerText.includes("Successfully"),
 | 
			
		||||
        { timeout: 5000 } // hoặc lâu hơn nếu cần
 | 
			
		||||
      );
 | 
			
		||||
      // await this.page_context.waitForFunction(
 | 
			
		||||
      //   () => document.body.innerText.includes("Successfully"),
 | 
			
		||||
      //   { timeout: 5000 } // hoặc lâu hơn nếu cần
 | 
			
		||||
      // );
 | 
			
		||||
 | 
			
		||||
      console.log("✅ Found 'Successfully'");
 | 
			
		||||
 | 
			
		||||
      await pushPrice({
 | 
			
		||||
| 
						 | 
				
			
			@ -128,15 +172,25 @@ export class GraysProductBid extends ProductBid {
 | 
			
		|||
      });
 | 
			
		||||
 | 
			
		||||
      await this.handleReturnProductPage();
 | 
			
		||||
 | 
			
		||||
      return true;
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      this.page_context.goto(this.url);
 | 
			
		||||
      console.log(`❌ [${this.id}] Error in placeBid: ${error.message}`);
 | 
			
		||||
      return false;
 | 
			
		||||
    } finally {
 | 
			
		||||
      global.IS_CLEANING = true;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async handlePlaceBidSandbox() {
 | 
			
		||||
    // call to trickger server send mail
 | 
			
		||||
    await axios({
 | 
			
		||||
      url: this.ACTION_URL(),
 | 
			
		||||
      method: "POST",
 | 
			
		||||
      data: {
 | 
			
		||||
        id: this.id,
 | 
			
		||||
        record_url: `${process.env.BASE_URL}admin/bids/record/${this.name_record}`,
 | 
			
		||||
      },
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    await this.close();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async handleReturnProductPage() {
 | 
			
		||||
    await this.page_context.goto(this.url);
 | 
			
		||||
    await delay(1000);
 | 
			
		||||
| 
						 | 
				
			
			@ -330,11 +384,7 @@ export class GraysProductBid extends ProductBid {
 | 
			
		|||
 | 
			
		||||
      const biddedData = this.getBidedData();
 | 
			
		||||
 | 
			
		||||
      console.log({
 | 
			
		||||
        biddedData,
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      const isBided = biddedData.find(
 | 
			
		||||
      const isBided = (biddedData || []).find(
 | 
			
		||||
        (item) => item.model === this.model && item.max_price === this.max_price
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -393,6 +443,8 @@ export class GraysProductBid extends ProductBid {
 | 
			
		|||
      console.log(
 | 
			
		||||
        "-------------------------------------BIDING---------------------------------------"
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
      await this.startRecordSandbox();
 | 
			
		||||
      const result = await this.placeBid();
 | 
			
		||||
 | 
			
		||||
      global.IS_CLEANING = true;
 | 
			
		||||
| 
						 | 
				
			
			@ -400,6 +452,7 @@ export class GraysProductBid extends ProductBid {
 | 
			
		|||
    } catch (error) {
 | 
			
		||||
      console.log(`🚨 [${this.id}] Error placing bid: ${error.message}`);
 | 
			
		||||
    } finally {
 | 
			
		||||
      await this.stopRecordSandbox();
 | 
			
		||||
      console.log(`🔚 [${this.id}] Resetting bid flag.`);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -7,6 +7,7 @@ import CONSTANTS from "../../system/constants.js";
 | 
			
		|||
import {
 | 
			
		||||
  convertAETtoUTC,
 | 
			
		||||
  isTimeReached,
 | 
			
		||||
  randomDelayWithMeta,
 | 
			
		||||
  removeFalsyValues,
 | 
			
		||||
  takeSnapshot,
 | 
			
		||||
} from "../../system/utils.js";
 | 
			
		||||
| 
						 | 
				
			
			@ -230,6 +231,11 @@ export class LangtonsProductBid extends ProductBid {
 | 
			
		|||
      console.log(`🔄 [${this.id}] Starting bid process...`);
 | 
			
		||||
      global[`IS_PLACE_BID-${this.id}`] = true;
 | 
			
		||||
 | 
			
		||||
      await this.delayForAction();
 | 
			
		||||
 | 
			
		||||
      // start record
 | 
			
		||||
      await this.startRecordSandbox();
 | 
			
		||||
 | 
			
		||||
      const continueShopBtn = await this.getContinueShopButton();
 | 
			
		||||
      if (continueShopBtn) {
 | 
			
		||||
        console.log(
 | 
			
		||||
| 
						 | 
				
			
			@ -327,6 +333,26 @@ export class LangtonsProductBid extends ProductBid {
 | 
			
		|||
      }
 | 
			
		||||
 | 
			
		||||
      // 📌 Nhấn nút "Place Bid"
 | 
			
		||||
      if (this.isSandbox()) {
 | 
			
		||||
        await this.handlePlaceBidSandbox();
 | 
			
		||||
      } else {
 | 
			
		||||
        await this.handlePlaceBidLive();
 | 
			
		||||
      }
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.log(`🚨 [${this.id}] Error placing bid: ${error.message}`);
 | 
			
		||||
    } finally {
 | 
			
		||||
      console.log(`🔚 [${this.id}] Resetting bid flag.`);
 | 
			
		||||
      global[`IS_PLACE_BID-${this.id}`] = false;
 | 
			
		||||
 | 
			
		||||
      // stop record
 | 
			
		||||
      this.stopRecordSandbox();
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async handlePlaceBidLive() {
 | 
			
		||||
    const page = this.page_context;
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      await page.click(
 | 
			
		||||
        ".place-bid-submit .btn.btn-primary.btn-block.place-bid-btn",
 | 
			
		||||
        { delay: 5000 }
 | 
			
		||||
| 
						 | 
				
			
			@ -356,7 +382,7 @@ export class LangtonsProductBid extends ProductBid {
 | 
			
		|||
          CONSTANTS.TYPE_IMAGE.SUCCESS
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        sendMessage(this);
 | 
			
		||||
        // sendMessage(this);
 | 
			
		||||
 | 
			
		||||
        console.log(`✅ [${this.id}] Bid placed successfully!`);
 | 
			
		||||
        return;
 | 
			
		||||
| 
						 | 
				
			
			@ -366,10 +392,64 @@ export class LangtonsProductBid extends ProductBid {
 | 
			
		|||
        `⚠️ [${this.id}] Bid action completed, but status is still "None".`
 | 
			
		||||
      );
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.log(`🚨 [${this.id}] Error placing bid: ${error.message}`);
 | 
			
		||||
    } finally {
 | 
			
		||||
      console.log(`🔚 [${this.id}] Resetting bid flag.`);
 | 
			
		||||
      global[`IS_PLACE_BID-${this.id}`] = false;
 | 
			
		||||
      console.log(`[${this.id}] Error handlePlaceBidLive: ${error}`);
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async handlePlaceBidSandbox() {
 | 
			
		||||
    if (!this.page_context) return;
 | 
			
		||||
    console.log("🔧 Starting to update the form action for sandbox mode...");
 | 
			
		||||
    const result = await this.setFormAction();
 | 
			
		||||
 | 
			
		||||
    if (!result) {
 | 
			
		||||
      console.error("❌ Failed to update the form action for sandbox mode.");
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    console.log(
 | 
			
		||||
      "✅ Form action successfully updated. Proceeding to place the bid..."
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    await this.handlePlaceBidLive();
 | 
			
		||||
 | 
			
		||||
    await this.close();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async setFormAction(newActionUrl = this.ACTION_URL()) {
 | 
			
		||||
    try {
 | 
			
		||||
      // Thay đổi action của form
 | 
			
		||||
      await this.page_context.evaluate(
 | 
			
		||||
        (url, record_url) => {
 | 
			
		||||
          const form = document.querySelector('form[name="place-bid-form"]');
 | 
			
		||||
          if (form) {
 | 
			
		||||
            form.action = url;
 | 
			
		||||
 | 
			
		||||
            const hiddenInput = document.createElement("input");
 | 
			
		||||
            hiddenInput.type = "hidden";
 | 
			
		||||
            hiddenInput.name = "record_url";
 | 
			
		||||
            hiddenInput.value = record_url;
 | 
			
		||||
            form.appendChild(hiddenInput);
 | 
			
		||||
          }
 | 
			
		||||
        },
 | 
			
		||||
        newActionUrl,
 | 
			
		||||
        `${process.env.BASE_URL}admin/bids/record/${this.name_record}`
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
      // Kiểm tra lại giá trị action sau khi đổi
 | 
			
		||||
      const actualAction = await this.page_context.evaluate(() => {
 | 
			
		||||
        const form = document.querySelector('form[name="place-bid-form"]');
 | 
			
		||||
        return form?.action || null;
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      // Log kết quả
 | 
			
		||||
      if (actualAction === newActionUrl) {
 | 
			
		||||
        return true;
 | 
			
		||||
      } else {
 | 
			
		||||
        return false;
 | 
			
		||||
      }
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.log(error);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -9,6 +9,7 @@ import {
 | 
			
		|||
  removeFalsyValues,
 | 
			
		||||
} from "../../system/utils.js";
 | 
			
		||||
import { ProductBid } from "../product-bid.js";
 | 
			
		||||
import axios from "../../system/axios.js";
 | 
			
		||||
 | 
			
		||||
export class LawsonsProductBid extends ProductBid {
 | 
			
		||||
  constructor({ ...prev }) {
 | 
			
		||||
| 
						 | 
				
			
			@ -112,7 +113,7 @@ export class LawsonsProductBid extends ProductBid {
 | 
			
		|||
    }, url);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  submitBid() {
 | 
			
		||||
  async handlePlaceBidLive() {
 | 
			
		||||
    return new Promise(async (resolve, reject) => {
 | 
			
		||||
      if (!this.page_context || !this.model) {
 | 
			
		||||
        console.log(`[${this.id}] Page context or model is missing.`);
 | 
			
		||||
| 
						 | 
				
			
			@ -163,6 +164,54 @@ export class LawsonsProductBid extends ProductBid {
 | 
			
		|||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async handlePlaceBidSanbox() {
 | 
			
		||||
    return new Promise(async (resolve, reject) => {
 | 
			
		||||
      if (!this.page_context || !this.model) {
 | 
			
		||||
        console.log(`[${this.id}] Page context or model is missing.`);
 | 
			
		||||
        reject(null);
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      try {
 | 
			
		||||
        console.log(`💰 [${this.id}] Prepared Bid Amount: ${this.max_price}`);
 | 
			
		||||
 | 
			
		||||
        const result = await this.page_context.evaluate(
 | 
			
		||||
          async (lotRef, url) => {
 | 
			
		||||
            const response = await fetch(url, {
 | 
			
		||||
              method: "POST",
 | 
			
		||||
              headers: {
 | 
			
		||||
                "Content-Type": "application/json",
 | 
			
		||||
              },
 | 
			
		||||
              body: JSON.stringify({
 | 
			
		||||
                bidAmount: 0,
 | 
			
		||||
                lotRef,
 | 
			
		||||
                v2: true,
 | 
			
		||||
              }),
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            if (!response.ok) {
 | 
			
		||||
              throw new Error(`HTTP ${response.status}`);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            return await response.json();
 | 
			
		||||
          },
 | 
			
		||||
          this.model,
 | 
			
		||||
          configs.WEB_CONFIGS.LAWSONS.API_CHECKOUT
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        console.log("🧾 API Bid Result:", {
 | 
			
		||||
          bid_amount: this.max_price,
 | 
			
		||||
          result,
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        resolve(result);
 | 
			
		||||
      } catch (err) {
 | 
			
		||||
        console.log(`[${this.id}] Failed to submit bid: ${err.message}`);
 | 
			
		||||
        reject(null);
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async handlePlaceBid() {
 | 
			
		||||
    // Kiểm tra xem có page context không, nếu không có thì kết thúc quá trình đấu giá
 | 
			
		||||
    if (!this.page_context) {
 | 
			
		||||
| 
						 | 
				
			
			@ -210,7 +259,7 @@ export class LawsonsProductBid extends ProductBid {
 | 
			
		|||
      const shouldStop =
 | 
			
		||||
        !response ||
 | 
			
		||||
        response?.currentBidAmount > this.max_price + this.plus_price ||
 | 
			
		||||
        response.isOutBid != true ||
 | 
			
		||||
        ![true, null].includes(response.isOutBid) ||
 | 
			
		||||
        !reservePrice ||
 | 
			
		||||
        reservePrice > this.max_price + this.plus_price;
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -219,6 +268,11 @@ export class LawsonsProductBid extends ProductBid {
 | 
			
		|||
          reservePrice,
 | 
			
		||||
          currentBidAmount: response?.currentBidAmount,
 | 
			
		||||
          maxBidAmount: response?.maxBidAmount,
 | 
			
		||||
          a: response?.currentBidAmount > this.max_price + this.plus_price,
 | 
			
		||||
          b: [true, null].includes(response.isOutBid),
 | 
			
		||||
          c: !reservePrice,
 | 
			
		||||
          d: reservePrice > this.max_price + this.plus_price,
 | 
			
		||||
          isOutBid: response.isOutBid,
 | 
			
		||||
        });
 | 
			
		||||
        return; // Nếu gặp điều kiện dừng thì không thực hiện đấu giá
 | 
			
		||||
      }
 | 
			
		||||
| 
						 | 
				
			
			@ -255,8 +309,14 @@ export class LawsonsProductBid extends ProductBid {
 | 
			
		|||
        `💰 [${this.id}] Placing a bid with amount: ${this.max_price}`
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
      let result = null;
 | 
			
		||||
 | 
			
		||||
      // Gửi bid qua API và nhận kết quả
 | 
			
		||||
      const result = await this.submitBid();
 | 
			
		||||
      if (this.isSandbox()) {
 | 
			
		||||
        result = await this.handleCallActionSanbox();
 | 
			
		||||
      } else {
 | 
			
		||||
        result = await this.handlePlaceBidLive();
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // Nếu không có kết quả (lỗi khi gửi bid) thì dừng lại
 | 
			
		||||
      if (!result) return;
 | 
			
		||||
| 
						 | 
				
			
			@ -264,7 +324,7 @@ export class LawsonsProductBid extends ProductBid {
 | 
			
		|||
      console.log({ result });
 | 
			
		||||
 | 
			
		||||
      // Gửi thông báo đã đấu giá thành công
 | 
			
		||||
      sendMessage(this);
 | 
			
		||||
      // sendMessage(this);
 | 
			
		||||
 | 
			
		||||
      await this.page_context.reload({ waitUntil: "networkidle0" });
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -279,6 +339,23 @@ export class LawsonsProductBid extends ProductBid {
 | 
			
		|||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async handleCallActionSanbox() {
 | 
			
		||||
    const result = await this.handlePlaceBidSanbox();
 | 
			
		||||
 | 
			
		||||
    await axios({
 | 
			
		||||
      url: this.ACTION_URL({ type: "api" }),
 | 
			
		||||
      data: {
 | 
			
		||||
        id: this.id,
 | 
			
		||||
        data: JSON.stringify(result),
 | 
			
		||||
      },
 | 
			
		||||
      method: "POST",
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    await this.close();
 | 
			
		||||
 | 
			
		||||
    return result;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async waitApiInfo() {
 | 
			
		||||
    if (!this.page_context) {
 | 
			
		||||
      console.error(`❌ [${this.id}] Error: page_context is undefined.`);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -4,6 +4,7 @@ import configs from "../../system/config.js";
 | 
			
		|||
import { delay, isTimeReached, removeFalsyValues } from "../../system/utils.js";
 | 
			
		||||
import { ProductBid } from "../product-bid.js";
 | 
			
		||||
import { sendMessage } from "../../system/apis/notification.js";
 | 
			
		||||
import axios from "../../system/axios.js";
 | 
			
		||||
 | 
			
		||||
export class PicklesProductBid extends ProductBid {
 | 
			
		||||
  constructor({ ...prev }) {
 | 
			
		||||
| 
						 | 
				
			
			@ -117,7 +118,7 @@ export class PicklesProductBid extends ProductBid {
 | 
			
		|||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  submitBid() {
 | 
			
		||||
  handlePlaceBidLive() {
 | 
			
		||||
    return new Promise(async (resolve, reject) => {
 | 
			
		||||
      if (!this.page_context || !this.lot_id) {
 | 
			
		||||
        console.log(`[${this.id}] Page context or model is missing.`);
 | 
			
		||||
| 
						 | 
				
			
			@ -285,7 +286,15 @@ export class PicklesProductBid extends ProductBid {
 | 
			
		|||
      );
 | 
			
		||||
 | 
			
		||||
      // Gửi bid qua API và nhận kết quả
 | 
			
		||||
      const result = await this.submitBid();
 | 
			
		||||
 | 
			
		||||
      let result = null;
 | 
			
		||||
      if (this.isSandbox()) {
 | 
			
		||||
        result = await this.handleCallActionSanbox();
 | 
			
		||||
      } else {
 | 
			
		||||
        result = await this.handlePlaceBidLive();
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      console.log({ result });
 | 
			
		||||
 | 
			
		||||
      // Nếu không có kết quả (lỗi khi gửi bid) thì dừng lại
 | 
			
		||||
      if (!result || !result?.confirmationRequest) {
 | 
			
		||||
| 
						 | 
				
			
			@ -297,8 +306,6 @@ export class PicklesProductBid extends ProductBid {
 | 
			
		|||
        return;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      console.log({ result });
 | 
			
		||||
 | 
			
		||||
      // Gửi thông báo đã đấu giá thành công
 | 
			
		||||
      // sendMessage(this);
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -312,6 +319,7 @@ export class PicklesProductBid extends ProductBid {
 | 
			
		|||
      console.log(`✅ [${this.id}] Bid placed successfully!`);
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      // Nếu có lỗi xảy ra trong quá trình đấu giá, log lại lỗi
 | 
			
		||||
      console.log(error);
 | 
			
		||||
      console.log(`🚨 [${this.id}] Error placing bid: ${error.message}`);
 | 
			
		||||
    } finally {
 | 
			
		||||
      // Đảm bảo luôn reset trạng thái đấu giá sau khi hoàn thành
 | 
			
		||||
| 
						 | 
				
			
			@ -320,6 +328,66 @@ export class PicklesProductBid extends ProductBid {
 | 
			
		|||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async handlePlaceBidSanbox() {
 | 
			
		||||
    return new Promise(async (resolve, reject) => {
 | 
			
		||||
      if (!this.page_context || !this.lot_id) {
 | 
			
		||||
        console.log(`[${this.id}] Page context or model is missing.`);
 | 
			
		||||
        reject("Context is not define");
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      try {
 | 
			
		||||
        console.log(`💰 [${this.id}] Prepared Bid Amount: ${this.max_price}`);
 | 
			
		||||
 | 
			
		||||
        const result = await this.page_context.evaluate(
 | 
			
		||||
          async (lotRef, url) => {
 | 
			
		||||
            const response = await fetch(url, {
 | 
			
		||||
              method: "POST",
 | 
			
		||||
              headers: {
 | 
			
		||||
                "Content-Type": "application/json",
 | 
			
		||||
              },
 | 
			
		||||
              body: JSON.stringify({
 | 
			
		||||
                itemId: lotRef,
 | 
			
		||||
                bidValues: {
 | 
			
		||||
                  activity: "BID",
 | 
			
		||||
                  maxBid: 0, // giá trị tối đa của sản phẩm
 | 
			
		||||
                  roundedMaxBid: 0, // giá trị tối đa của sản phẩm
 | 
			
		||||
                  submittedBuyNowValue: null,
 | 
			
		||||
                },
 | 
			
		||||
                buyerFeeCalculated: false,
 | 
			
		||||
                buyerFees: null,
 | 
			
		||||
                dashboardRedirectUrl: null,
 | 
			
		||||
                itemTitle: null,
 | 
			
		||||
                productLine: null,
 | 
			
		||||
                registrationRequired: false,
 | 
			
		||||
                totalAmount: null,
 | 
			
		||||
                updateDetailsRequired: null,
 | 
			
		||||
              }),
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            if (!response.ok) {
 | 
			
		||||
              throw new Error(`HTTP ${response.status}`);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            return await response.json();
 | 
			
		||||
          },
 | 
			
		||||
          this.lot_id,
 | 
			
		||||
          configs.WEB_CONFIGS.PICKLES.API_CHECKOUT
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        console.log("🧾 API Bid Result:", {
 | 
			
		||||
          bid_amount: this.max_price + this.plus_price,
 | 
			
		||||
          result,
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        resolve(result);
 | 
			
		||||
      } catch (err) {
 | 
			
		||||
        console.log(`[${this.id}] Failed to submit bid: ${err.message}`);
 | 
			
		||||
        reject(err);
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  isOutBid = async () => {
 | 
			
		||||
    try {
 | 
			
		||||
      // Chờ tối đa 10s cho element xuất hiện
 | 
			
		||||
| 
						 | 
				
			
			@ -476,4 +544,29 @@ export class PicklesProductBid extends ProductBid {
 | 
			
		|||
      console.error(`🚨 [${this.id}] Error navigating the page: ${error}`);
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  async handleCallActionSanbox() {
 | 
			
		||||
    try {
 | 
			
		||||
      const result = await this.handlePlaceBidSanbox();
 | 
			
		||||
 | 
			
		||||
      await axios({
 | 
			
		||||
        url: this.ACTION_URL({ type: "api" }),
 | 
			
		||||
        data: {
 | 
			
		||||
          id: this.id,
 | 
			
		||||
          data: JSON.stringify(result),
 | 
			
		||||
        },
 | 
			
		||||
        method: "POST",
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      if (global[`TRACKING_PROCRESS_${this.id}`]) {
 | 
			
		||||
        clearInterval(global[`TRACKING_PROCRESS_${this.id}`]);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      await this.close();
 | 
			
		||||
 | 
			
		||||
      return result;
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.log("a:", error);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,10 +1,26 @@
 | 
			
		|||
import * as fs from "fs";
 | 
			
		||||
import * as path from "path";
 | 
			
		||||
import { PuppeteerScreenRecorder } from "puppeteer-screen-recorder";
 | 
			
		||||
import { outBid, uploadRecord } from "../system/apis/bid.js";
 | 
			
		||||
import BID_TYPE from "../system/bid-type.js";
 | 
			
		||||
import browser from "../system/browser.js";
 | 
			
		||||
import { getPathProfile } from "../system/utils.js";
 | 
			
		||||
import CONSTANTS from "../system/constants.js";
 | 
			
		||||
import { getPathProfile, randomDelayWithMeta } from "../system/utils.js";
 | 
			
		||||
import { Bid } from "./bid.js";
 | 
			
		||||
 | 
			
		||||
import { unlink } from "fs/promises";
 | 
			
		||||
export class ProductBid extends Bid {
 | 
			
		||||
  // value 'live' | 'sandbox'
 | 
			
		||||
  static MODE_KEY = "mode_key";
 | 
			
		||||
 | 
			
		||||
  // value is minutes esg: arrival_offset_seconds of web bid parent
 | 
			
		||||
  static ARRIAVAL_OFFSET_SECONDS_LIVE = "arrival_offset_seconds_live";
 | 
			
		||||
  static ARRIAVAL_OFFSET_SECONDS_SANDBOX = "arrival_offset_seconds_sandbox";
 | 
			
		||||
 | 
			
		||||
  // value is minutes esg: early_tracking_seconds of web bid parent
 | 
			
		||||
  static EARLY_TRACKING_SECONDS = "early_tracking_seconds";
 | 
			
		||||
  static EARLY_TRACKING_SECONDS_LIVE = `${this.EARLY_TRACKING_SECONDS}_live`;
 | 
			
		||||
  static EARLY_TRACKING_SECONDS_SANDBOX = `${this.EARLY_TRACKING_SECONDS}_sandbox`;
 | 
			
		||||
 | 
			
		||||
  id;
 | 
			
		||||
  max_price;
 | 
			
		||||
  model;
 | 
			
		||||
| 
						 | 
				
			
			@ -23,6 +39,9 @@ export class ProductBid extends Bid {
 | 
			
		|||
  name;
 | 
			
		||||
  reserve_price;
 | 
			
		||||
  update;
 | 
			
		||||
  metadata;
 | 
			
		||||
  recorder;
 | 
			
		||||
  name_record;
 | 
			
		||||
 | 
			
		||||
  constructor({
 | 
			
		||||
    url,
 | 
			
		||||
| 
						 | 
				
			
			@ -42,6 +61,7 @@ export class ProductBid extends Bid {
 | 
			
		|||
    current_price,
 | 
			
		||||
    reserve_price,
 | 
			
		||||
    name,
 | 
			
		||||
    metadata,
 | 
			
		||||
  }) {
 | 
			
		||||
    super(BID_TYPE.PRODUCT_TAB, url);
 | 
			
		||||
    this.max_price = max_price || 0;
 | 
			
		||||
| 
						 | 
				
			
			@ -60,6 +80,7 @@ export class ProductBid extends Bid {
 | 
			
		|||
    this.current_price = current_price;
 | 
			
		||||
    this.name = name;
 | 
			
		||||
    this.reserve_price = reserve_price;
 | 
			
		||||
    this.metadata = metadata;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  setNewData({
 | 
			
		||||
| 
						 | 
				
			
			@ -80,6 +101,7 @@ export class ProductBid extends Bid {
 | 
			
		|||
    current_price,
 | 
			
		||||
    reserve_price,
 | 
			
		||||
    name,
 | 
			
		||||
    metadata,
 | 
			
		||||
  }) {
 | 
			
		||||
    this.max_price = max_price || 0;
 | 
			
		||||
    this.model = model;
 | 
			
		||||
| 
						 | 
				
			
			@ -98,6 +120,7 @@ export class ProductBid extends Bid {
 | 
			
		|||
    this.current_price = current_price;
 | 
			
		||||
    this.name = name;
 | 
			
		||||
    this.reserve_price = reserve_price;
 | 
			
		||||
    this.metadata = metadata;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  puppeteer_connect = async () => {
 | 
			
		||||
| 
						 | 
				
			
			@ -159,4 +182,85 @@ export class ProductBid extends Bid {
 | 
			
		|||
      console.error("❌ Error during navigation:", error);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  getMode = () => {
 | 
			
		||||
    return (
 | 
			
		||||
      this.metadata.find((item) => item.key_name === ProductBid.MODE_KEY)
 | 
			
		||||
        ?.value || "live"
 | 
			
		||||
    );
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  getEarlyTrackingSeconds = () => {
 | 
			
		||||
    const mode = this.getMode();
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      this.metadata.find(
 | 
			
		||||
        (item) =>
 | 
			
		||||
          item.key_name === `${ProductBid.EARLY_TRACKING_SECONDS}_${mode}`
 | 
			
		||||
      )?.value || this.web_bid.early_tracking_seconds
 | 
			
		||||
    );
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  isSandbox() {
 | 
			
		||||
    return this.getMode() !== "live";
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async delayForAction() {
 | 
			
		||||
    // Thực thi hành động
 | 
			
		||||
    console.log(`[${this.id}] 🚀 Executing action`);
 | 
			
		||||
    const { wait, delay } = randomDelayWithMeta();
 | 
			
		||||
 | 
			
		||||
    console.log(
 | 
			
		||||
      `[${this.id}] ⏳ Delay for action: ${(delay / 1000).toFixed(2)} seconds`
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    await wait;
 | 
			
		||||
 | 
			
		||||
    console.log(`[${this.id}] ✅ Finished delay`);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async close() {
 | 
			
		||||
    await outBid(this.id);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async startRecordSandbox() {
 | 
			
		||||
    if (
 | 
			
		||||
      !this.page_context ||
 | 
			
		||||
      !this.name ||
 | 
			
		||||
      this.recorder ||
 | 
			
		||||
      this.getMode() === "live"
 | 
			
		||||
    )
 | 
			
		||||
      return;
 | 
			
		||||
 | 
			
		||||
    const dirPath = CONSTANTS.RECORD_VIDEO_PATH;
 | 
			
		||||
 | 
			
		||||
    // 📁 Kiểm tra và tạo thư mục nếu chưa có
 | 
			
		||||
    if (!fs.existsSync(dirPath)) {
 | 
			
		||||
      fs.mkdirSync(dirPath, { recursive: true }); // recursive để tạo nested folder nếu cần
 | 
			
		||||
      console.log("📁 Created recording folder:", dirPath);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    this.name_record = `${this.id}_${this.name}_${new Date().getTime()}.mp4`;
 | 
			
		||||
 | 
			
		||||
    const filePath = path.join(dirPath, this.name_record);
 | 
			
		||||
 | 
			
		||||
    this.recorder = new PuppeteerScreenRecorder(this.page_context);
 | 
			
		||||
    await this.recorder.start(filePath);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async stopRecordSandbox() {
 | 
			
		||||
    if (!this.recorder || this.getMode() === "live") return;
 | 
			
		||||
 | 
			
		||||
    await this.recorder.stop();
 | 
			
		||||
 | 
			
		||||
    const filePath = path.join(CONSTANTS.RECORD_VIDEO_PATH, this.name_record);
 | 
			
		||||
 | 
			
		||||
    const result = await uploadRecord(this, filePath);
 | 
			
		||||
 | 
			
		||||
    return result;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  ACTION_URL(options = { type: "action" }) {
 | 
			
		||||
    return `${process.env.BASE_URL}bids/hook-action?id=${this.id}&type=${options.type}`;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -16,6 +16,7 @@
 | 
			
		|||
                "puppeteer": "^24.4.0",
 | 
			
		||||
                "puppeteer-extra": "^3.3.6",
 | 
			
		||||
                "puppeteer-extra-plugin-stealth": "^2.11.2",
 | 
			
		||||
                "puppeteer-screen-recorder": "^3.0.6",
 | 
			
		||||
                "socket.io-client": "^4.8.1"
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
| 
						 | 
				
			
			@ -42,6 +43,133 @@
 | 
			
		|||
                "node": ">=6.9.0"
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        "node_modules/@ffmpeg-installer/darwin-arm64": {
 | 
			
		||||
            "version": "4.1.5",
 | 
			
		||||
            "resolved": "https://registry.npmjs.org/@ffmpeg-installer/darwin-arm64/-/darwin-arm64-4.1.5.tgz",
 | 
			
		||||
            "integrity": "sha512-hYqTiP63mXz7wSQfuqfFwfLOfwwFChUedeCVKkBtl/cliaTM7/ePI9bVzfZ2c+dWu3TqCwLDRWNSJ5pqZl8otA==",
 | 
			
		||||
            "cpu": [
 | 
			
		||||
                "arm64"
 | 
			
		||||
            ],
 | 
			
		||||
            "hasInstallScript": true,
 | 
			
		||||
            "license": "https://git.ffmpeg.org/gitweb/ffmpeg.git/blob_plain/HEAD:/LICENSE.md",
 | 
			
		||||
            "optional": true,
 | 
			
		||||
            "os": [
 | 
			
		||||
                "darwin"
 | 
			
		||||
            ]
 | 
			
		||||
        },
 | 
			
		||||
        "node_modules/@ffmpeg-installer/darwin-x64": {
 | 
			
		||||
            "version": "4.1.0",
 | 
			
		||||
            "resolved": "https://registry.npmjs.org/@ffmpeg-installer/darwin-x64/-/darwin-x64-4.1.0.tgz",
 | 
			
		||||
            "integrity": "sha512-Z4EyG3cIFjdhlY8wI9aLUXuH8nVt7E9SlMVZtWvSPnm2sm37/yC2CwjUzyCQbJbySnef1tQwGG2Sx+uWhd9IAw==",
 | 
			
		||||
            "cpu": [
 | 
			
		||||
                "x64"
 | 
			
		||||
            ],
 | 
			
		||||
            "hasInstallScript": true,
 | 
			
		||||
            "license": "LGPL-2.1",
 | 
			
		||||
            "optional": true,
 | 
			
		||||
            "os": [
 | 
			
		||||
                "darwin"
 | 
			
		||||
            ]
 | 
			
		||||
        },
 | 
			
		||||
        "node_modules/@ffmpeg-installer/ffmpeg": {
 | 
			
		||||
            "version": "1.1.0",
 | 
			
		||||
            "resolved": "https://registry.npmjs.org/@ffmpeg-installer/ffmpeg/-/ffmpeg-1.1.0.tgz",
 | 
			
		||||
            "integrity": "sha512-Uq4rmwkdGxIa9A6Bd/VqqYbT7zqh1GrT5/rFwCwKM70b42W5gIjWeVETq6SdcL0zXqDtY081Ws/iJWhr1+xvQg==",
 | 
			
		||||
            "license": "LGPL-2.1",
 | 
			
		||||
            "optional": true,
 | 
			
		||||
            "optionalDependencies": {
 | 
			
		||||
                "@ffmpeg-installer/darwin-arm64": "4.1.5",
 | 
			
		||||
                "@ffmpeg-installer/darwin-x64": "4.1.0",
 | 
			
		||||
                "@ffmpeg-installer/linux-arm": "4.1.3",
 | 
			
		||||
                "@ffmpeg-installer/linux-arm64": "4.1.4",
 | 
			
		||||
                "@ffmpeg-installer/linux-ia32": "4.1.0",
 | 
			
		||||
                "@ffmpeg-installer/linux-x64": "4.1.0",
 | 
			
		||||
                "@ffmpeg-installer/win32-ia32": "4.1.0",
 | 
			
		||||
                "@ffmpeg-installer/win32-x64": "4.1.0"
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        "node_modules/@ffmpeg-installer/linux-arm": {
 | 
			
		||||
            "version": "4.1.3",
 | 
			
		||||
            "resolved": "https://registry.npmjs.org/@ffmpeg-installer/linux-arm/-/linux-arm-4.1.3.tgz",
 | 
			
		||||
            "integrity": "sha512-NDf5V6l8AfzZ8WzUGZ5mV8O/xMzRag2ETR6+TlGIsMHp81agx51cqpPItXPib/nAZYmo55Bl2L6/WOMI3A5YRg==",
 | 
			
		||||
            "cpu": [
 | 
			
		||||
                "arm"
 | 
			
		||||
            ],
 | 
			
		||||
            "hasInstallScript": true,
 | 
			
		||||
            "license": "GPLv3",
 | 
			
		||||
            "optional": true,
 | 
			
		||||
            "os": [
 | 
			
		||||
                "linux"
 | 
			
		||||
            ]
 | 
			
		||||
        },
 | 
			
		||||
        "node_modules/@ffmpeg-installer/linux-arm64": {
 | 
			
		||||
            "version": "4.1.4",
 | 
			
		||||
            "resolved": "https://registry.npmjs.org/@ffmpeg-installer/linux-arm64/-/linux-arm64-4.1.4.tgz",
 | 
			
		||||
            "integrity": "sha512-dljEqAOD0oIM6O6DxBW9US/FkvqvQwgJ2lGHOwHDDwu/pX8+V0YsDL1xqHbj1DMX/+nP9rxw7G7gcUvGspSoKg==",
 | 
			
		||||
            "cpu": [
 | 
			
		||||
                "arm64"
 | 
			
		||||
            ],
 | 
			
		||||
            "hasInstallScript": true,
 | 
			
		||||
            "license": "GPLv3",
 | 
			
		||||
            "optional": true,
 | 
			
		||||
            "os": [
 | 
			
		||||
                "linux"
 | 
			
		||||
            ]
 | 
			
		||||
        },
 | 
			
		||||
        "node_modules/@ffmpeg-installer/linux-ia32": {
 | 
			
		||||
            "version": "4.1.0",
 | 
			
		||||
            "resolved": "https://registry.npmjs.org/@ffmpeg-installer/linux-ia32/-/linux-ia32-4.1.0.tgz",
 | 
			
		||||
            "integrity": "sha512-0LWyFQnPf+Ij9GQGD034hS6A90URNu9HCtQ5cTqo5MxOEc7Rd8gLXrJvn++UmxhU0J5RyRE9KRYstdCVUjkNOQ==",
 | 
			
		||||
            "cpu": [
 | 
			
		||||
                "ia32"
 | 
			
		||||
            ],
 | 
			
		||||
            "hasInstallScript": true,
 | 
			
		||||
            "license": "GPLv3",
 | 
			
		||||
            "optional": true,
 | 
			
		||||
            "os": [
 | 
			
		||||
                "linux"
 | 
			
		||||
            ]
 | 
			
		||||
        },
 | 
			
		||||
        "node_modules/@ffmpeg-installer/linux-x64": {
 | 
			
		||||
            "version": "4.1.0",
 | 
			
		||||
            "resolved": "https://registry.npmjs.org/@ffmpeg-installer/linux-x64/-/linux-x64-4.1.0.tgz",
 | 
			
		||||
            "integrity": "sha512-Y5BWhGLU/WpQjOArNIgXD3z5mxxdV8c41C+U15nsE5yF8tVcdCGet5zPs5Zy3Ta6bU7haGpIzryutqCGQA/W8A==",
 | 
			
		||||
            "cpu": [
 | 
			
		||||
                "x64"
 | 
			
		||||
            ],
 | 
			
		||||
            "hasInstallScript": true,
 | 
			
		||||
            "license": "GPLv3",
 | 
			
		||||
            "optional": true,
 | 
			
		||||
            "os": [
 | 
			
		||||
                "linux"
 | 
			
		||||
            ]
 | 
			
		||||
        },
 | 
			
		||||
        "node_modules/@ffmpeg-installer/win32-ia32": {
 | 
			
		||||
            "version": "4.1.0",
 | 
			
		||||
            "resolved": "https://registry.npmjs.org/@ffmpeg-installer/win32-ia32/-/win32-ia32-4.1.0.tgz",
 | 
			
		||||
            "integrity": "sha512-FV2D7RlaZv/lrtdhaQ4oETwoFUsUjlUiasiZLDxhEUPdNDWcH1OU9K1xTvqz+OXLdsmYelUDuBS/zkMOTtlUAw==",
 | 
			
		||||
            "cpu": [
 | 
			
		||||
                "ia32"
 | 
			
		||||
            ],
 | 
			
		||||
            "license": "GPLv3",
 | 
			
		||||
            "optional": true,
 | 
			
		||||
            "os": [
 | 
			
		||||
                "win32"
 | 
			
		||||
            ]
 | 
			
		||||
        },
 | 
			
		||||
        "node_modules/@ffmpeg-installer/win32-x64": {
 | 
			
		||||
            "version": "4.1.0",
 | 
			
		||||
            "resolved": "https://registry.npmjs.org/@ffmpeg-installer/win32-x64/-/win32-x64-4.1.0.tgz",
 | 
			
		||||
            "integrity": "sha512-Drt5u2vzDnIONf4ZEkKtFlbvwj6rI3kxw1Ck9fpudmtgaZIHD4ucsWB2lCZBXRxJgXR+2IMSti+4rtM4C4rXgg==",
 | 
			
		||||
            "cpu": [
 | 
			
		||||
                "x64"
 | 
			
		||||
            ],
 | 
			
		||||
            "license": "GPLv3",
 | 
			
		||||
            "optional": true,
 | 
			
		||||
            "os": [
 | 
			
		||||
                "win32"
 | 
			
		||||
            ]
 | 
			
		||||
        },
 | 
			
		||||
        "node_modules/@puppeteer/browsers": {
 | 
			
		||||
            "version": "2.8.0",
 | 
			
		||||
            "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.8.0.tgz",
 | 
			
		||||
| 
						 | 
				
			
			@ -170,6 +298,11 @@
 | 
			
		|||
                "node": ">=4"
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        "node_modules/async": {
 | 
			
		||||
            "version": "0.2.10",
 | 
			
		||||
            "resolved": "https://registry.npmjs.org/async/-/async-0.2.10.tgz",
 | 
			
		||||
            "integrity": "sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ=="
 | 
			
		||||
        },
 | 
			
		||||
        "node_modules/asynckit": {
 | 
			
		||||
            "version": "0.4.0",
 | 
			
		||||
            "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
 | 
			
		||||
| 
						 | 
				
			
			@ -742,6 +875,20 @@
 | 
			
		|||
                "pend": "~1.2.0"
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        "node_modules/fluent-ffmpeg": {
 | 
			
		||||
            "version": "2.1.3",
 | 
			
		||||
            "resolved": "https://registry.npmjs.org/fluent-ffmpeg/-/fluent-ffmpeg-2.1.3.tgz",
 | 
			
		||||
            "integrity": "sha512-Be3narBNt2s6bsaqP6Jzq91heDgOEaDCJAXcE3qcma/EJBSy5FB4cvO31XBInuAuKBx8Kptf8dkhjK0IOru39Q==",
 | 
			
		||||
            "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
 | 
			
		||||
            "license": "MIT",
 | 
			
		||||
            "dependencies": {
 | 
			
		||||
                "async": "^0.2.9",
 | 
			
		||||
                "which": "^1.1.1"
 | 
			
		||||
            },
 | 
			
		||||
            "engines": {
 | 
			
		||||
                "node": ">=18"
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        "node_modules/follow-redirects": {
 | 
			
		||||
            "version": "1.15.9",
 | 
			
		||||
            "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz",
 | 
			
		||||
| 
						 | 
				
			
			@ -1094,6 +1241,12 @@
 | 
			
		|||
                "node": ">=0.10.0"
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        "node_modules/isexe": {
 | 
			
		||||
            "version": "2.0.0",
 | 
			
		||||
            "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
 | 
			
		||||
            "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
 | 
			
		||||
            "license": "ISC"
 | 
			
		||||
        },
 | 
			
		||||
        "node_modules/isobject": {
 | 
			
		||||
            "version": "3.0.1",
 | 
			
		||||
            "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz",
 | 
			
		||||
| 
						 | 
				
			
			@ -1611,6 +1764,42 @@
 | 
			
		|||
                }
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        "node_modules/puppeteer-screen-recorder": {
 | 
			
		||||
            "version": "3.0.6",
 | 
			
		||||
            "resolved": "https://registry.npmjs.org/puppeteer-screen-recorder/-/puppeteer-screen-recorder-3.0.6.tgz",
 | 
			
		||||
            "integrity": "sha512-yzWlXWGi/FjsAe0fVr/zYlKFKsH1PGc6Pm7t58wlzVbs/jdLimjdO5VaTIqDJIkWuXFfD4WuDFptdU6AG3ls/Q==",
 | 
			
		||||
            "funding": [
 | 
			
		||||
                {
 | 
			
		||||
                    "type": "individual",
 | 
			
		||||
                    "url": "https://github.com/sponsors/prasanaworld/dashboard/tiers?frequency=one-time"
 | 
			
		||||
                },
 | 
			
		||||
                {
 | 
			
		||||
                    "type": "individual",
 | 
			
		||||
                    "url": "https://opencollective.com/puppeteer-screen-recorder"
 | 
			
		||||
                },
 | 
			
		||||
                {
 | 
			
		||||
                    "type": "corporate",
 | 
			
		||||
                    "url": "https://github.com/sponsors/prasanaworld/dashboard/tiers"
 | 
			
		||||
                },
 | 
			
		||||
                {
 | 
			
		||||
                    "type": "corporate",
 | 
			
		||||
                    "url": "https://opencollective.com/puppeteer-screen-recorder"
 | 
			
		||||
                }
 | 
			
		||||
            ],
 | 
			
		||||
            "license": "MIT",
 | 
			
		||||
            "dependencies": {
 | 
			
		||||
                "fluent-ffmpeg": "^2.1.2"
 | 
			
		||||
            },
 | 
			
		||||
            "engines": {
 | 
			
		||||
                "node": ">=16"
 | 
			
		||||
            },
 | 
			
		||||
            "optionalDependencies": {
 | 
			
		||||
                "@ffmpeg-installer/ffmpeg": "^1.1.0"
 | 
			
		||||
            },
 | 
			
		||||
            "peerDependencies": {
 | 
			
		||||
                "puppeteer": "19.0.0"
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        "node_modules/require-directory": {
 | 
			
		||||
            "version": "2.1.1",
 | 
			
		||||
            "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
 | 
			
		||||
| 
						 | 
				
			
			@ -1910,6 +2099,18 @@
 | 
			
		|||
                "node": ">= 10.0.0"
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        "node_modules/which": {
 | 
			
		||||
            "version": "1.3.1",
 | 
			
		||||
            "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz",
 | 
			
		||||
            "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==",
 | 
			
		||||
            "license": "ISC",
 | 
			
		||||
            "dependencies": {
 | 
			
		||||
                "isexe": "^2.0.0"
 | 
			
		||||
            },
 | 
			
		||||
            "bin": {
 | 
			
		||||
                "which": "bin/which"
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        "node_modules/wrap-ansi": {
 | 
			
		||||
            "version": "7.0.0",
 | 
			
		||||
            "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -19,6 +19,7 @@
 | 
			
		|||
        "puppeteer": "^24.4.0",
 | 
			
		||||
        "puppeteer-extra": "^3.3.6",
 | 
			
		||||
        "puppeteer-extra-plugin-stealth": "^2.11.2",
 | 
			
		||||
        "puppeteer-screen-recorder": "^3.0.6",
 | 
			
		||||
        "socket.io-client": "^4.8.1"
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 1.4 MiB  | 
| 
						 | 
				
			
			@ -124,6 +124,27 @@ export const updateStatusWork = async (item, filePath) => {
 | 
			
		|||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const uploadRecord = async (item, filePath) => {
 | 
			
		||||
  try {
 | 
			
		||||
    const response = await axios({
 | 
			
		||||
      method: "POST",
 | 
			
		||||
      headers: {
 | 
			
		||||
        "Content-Type": "multipart/form-data",
 | 
			
		||||
      },
 | 
			
		||||
      url: `bids/upload-record/${item.id}`,
 | 
			
		||||
      data: {
 | 
			
		||||
        video: fs.createReadStream(filePath),
 | 
			
		||||
      },
 | 
			
		||||
    });
 | 
			
		||||
    fs.unlinkSync(filePath);
 | 
			
		||||
 | 
			
		||||
    return response.data?.data;
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error("❌ Upload failed:", error.response?.data || error.message);
 | 
			
		||||
    return false;
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const updateLoginStatus = async (data) => {
 | 
			
		||||
  try {
 | 
			
		||||
    const response = await axios({
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -8,11 +8,13 @@ const CONSTANTS = {
 | 
			
		|||
  PROFILE_PATH: path.join(__dirname, "profiles"),
 | 
			
		||||
  LOCAL_DATA_PATH: path.join(__dirname, "local-data"),
 | 
			
		||||
  ERROR_IMAGES_PATH: path.join(__dirname, "error-images"),
 | 
			
		||||
  RECORD_VIDEO_PATH: path.join(__dirname, "videos"),
 | 
			
		||||
  TYPE_IMAGE: {
 | 
			
		||||
    ERRORS: "errors",
 | 
			
		||||
    SUCCESS: "success",
 | 
			
		||||
    WORK: "work",
 | 
			
		||||
  },
 | 
			
		||||
  KEEP_LIVE_URLS: ["https://auction.grays.com/confirmbid/index.html"],
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default CONSTANTS;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -305,19 +305,31 @@ export async function isPageAvailable(page) {
 | 
			
		|||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Lấy product có close time gần với hiện tại nhất
 | 
			
		||||
export function findNearestClosingChild(webBid) {
 | 
			
		||||
  const now = Date.now();
 | 
			
		||||
 | 
			
		||||
  const validChildren = webBid.children.filter(
 | 
			
		||||
    (child) => child.close_time && !isNaN(new Date(child.close_time).getTime())
 | 
			
		||||
  const validChildren = webBid.children.filter((child) => {
 | 
			
		||||
    return (
 | 
			
		||||
      child.close_time &&
 | 
			
		||||
      !isNaN(new Date(child.close_time).getTime()) &&
 | 
			
		||||
      typeof child.getEarlyTrackingSeconds === "function"
 | 
			
		||||
    );
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  if (validChildren.length === 0) {
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // 🎯 Tìm con gần nhất với hiện tại, ưu tiên early_tracking lớn nhất
 | 
			
		||||
  const nearestChild = _.minBy(validChildren, (child) => {
 | 
			
		||||
    return Math.abs(new Date(child.close_time).getTime() - now);
 | 
			
		||||
    const closeTime = new Date(child.close_time).getTime();
 | 
			
		||||
    const timeDiff = Math.abs(closeTime - now);
 | 
			
		||||
    const earlyTracking = child.getEarlyTrackingSeconds() || 0;
 | 
			
		||||
 | 
			
		||||
    // ❗ Giảm ảnh hưởng của earlyTracking bằng cách trừ vào timeDiff
 | 
			
		||||
    // => càng nhiều earlyTracking thì càng tốt (timeDiff - earlyTracking * trọng số)
 | 
			
		||||
    return timeDiff - earlyTracking * 1000; // dùng 1000 để ưu tiên rõ rệt
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  return nearestChild || null;
 | 
			
		||||
| 
						 | 
				
			
			@ -362,3 +374,11 @@ export function extractDomain(url) {
 | 
			
		|||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function randomDelayWithMeta(min = 1000, max = 10000) {
 | 
			
		||||
  const delay = Math.floor(Math.random() * (max - min + 1)) + min;
 | 
			
		||||
  return {
 | 
			
		||||
    delay,
 | 
			
		||||
    wait: new Promise((resolve) => setTimeout(resolve, delay)),
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
		Reference in New Issue