update sanbox mode for product

This commit is contained in:
Admin 2025-06-19 16:29:22 +07:00
parent 41a3baddd8
commit 3e8e7ae7d7
52 changed files with 2732 additions and 654 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@ -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",

View File

@ -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",

View File

@ -1,120 +1,187 @@
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',
params: generateNestParams(params),
withCredentials: true,
method: 'GET',
});
return await axios({
url: "bids",
params: generateNestParams(params),
withCredentials: true,
method: "GET",
});
};
export const createBid = async (bid: Omit<IBid, 'id' | 'created_at' | 'updated_at' | 'is_system_account'>) => {
const newData = removeFalsyValues(bid);
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',
withCredentials: true,
method: 'POST',
data: newData,
});
try {
const { data } = await axios({
url: "bids",
withCredentials: true,
method: "POST",
data: newData,
});
handleSuccess(data);
handleSuccess(data);
return data;
} catch (error) {
handleError(error);
}
return data;
} catch (error) {
handleError(error);
}
};
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,
withCredentials: true,
method: 'PUT',
data: { plus_price, max_price, quantity },
});
try {
const { data } = await axios({
url: "bids/" + bid.id,
withCredentials: true,
method: "PUT",
data: { plus_price, max_price, quantity, metadata },
});
handleSuccess(data);
handleSuccess(data);
return data;
} catch (error) {
handleError(error);
}
return data;
} catch (error) {
handleError(error);
}
};
export const toggleBid = async (bid: Partial<IBid>) => {
try {
const { data } = await axios({
url: 'bids/toggle/' + bid.id,
withCredentials: true,
method: 'POST',
});
try {
const { data } = await axios({
url: "bids/toggle/" + bid.id,
withCredentials: true,
method: "POST",
});
handleSuccess(data);
handleSuccess(data);
return data;
} catch (error) {
handleError(error);
}
return data;
} catch (error) {
handleError(error);
}
};
export const deleteBid = async (bid: IBid) => {
try {
const { data } = await axios({
url: 'bids/' + bid.id,
withCredentials: true,
method: 'DELETE',
});
try {
const { data } = await axios({
url: "bids/" + bid.id,
withCredentials: true,
method: "DELETE",
});
handleSuccess(data);
handleSuccess(data);
return data;
} catch (error) {
handleError(error);
}
return data;
} catch (error) {
handleError(error);
}
};
export const deletesBid = async (bids: IBid[]) => {
const ids = bids.reduce((prev, cur) => {
prev.push(cur.id);
return prev;
}, [] as number[]);
try {
const { data } = await axios({
url: 'bids/deletes',
withCredentials: true,
method: 'POST',
data: {
ids,
},
});
const ids = bids.reduce((prev, cur) => {
prev.push(cur.id);
return prev;
}, [] as number[]);
try {
const { data } = await axios({
url: "bids/deletes",
withCredentials: true,
method: "POST",
data: {
ids,
},
});
handleSuccess(data);
handleSuccess(data);
return data;
} catch (error) {
handleError(error);
}
return data;
} catch (error) {
handleError(error);
}
};
export const getImagesWorking = async (values: (IBid | IWebBid) & { type: string }) => {
try {
const { data } = await axios({
url: `bids/images-working/${values.type.toLocaleLowerCase().replace('_', '-')}/${values.id}`,
withCredentials: true,
method: 'GET',
});
export const getImagesWorking = async (
values: (IBid | IWebBid) & { type: string }
) => {
try {
const { data } = await axios({
url: `bids/images-working/${values.type
.toLocaleLowerCase()
.replace("_", "-")}/${values.id}`,
withCredentials: true,
method: "GET",
});
return data;
} catch (error) {
console.log('%csrc/apis/bid.ts:118 error', 'color: #007acc;', error);
}
return data;
} catch (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);
}
};

View File

@ -1,117 +1,330 @@
/* 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;
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) {
const form = useForm({
validate: zodResolver(z.object(schema)),
export default function BidModal({
data,
onUpdated,
...props
}: IBidModelProps) {
const form = useForm({
validate: zodResolver(z.object(schema)),
});
const prevData = useRef<IBid | null>(data);
const { setConfirm } = useConfirmStore();
const [loading, setLoading] = useState(false);
const handleSubmit = async (values: typeof form.values) => {
if (data) {
setConfirm({
title: "Update ?",
message: `This product will be update`,
handleOk: async () => {
setLoading(true);
const metadata = valuesToMetadata(
values as IBid & Record<string, any>
);
const result = await updateBid({ ...values, metadata });
setLoading(false);
if (!result) return;
props.onClose();
if (onUpdated) {
onUpdated();
}
},
okButton: {
color: "blue",
value: "Update",
},
});
} else {
const { url, max_price, plus_price } = values;
setLoading(true);
const result = await createBid({ url, max_price, plus_price } as IBid);
setLoading(false);
if (!result) return;
props.onClose();
if (onUpdated) {
onUpdated();
}
}
};
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],
};
});
};
const prevData = useRef<IBid | null>(data);
useEffect(() => {
form.reset();
if (!data) return;
const { setConfirm } = useConfirmStore();
const values = mappingValues();
const [loading, setLoading] = useState(false);
form.setValues(values);
const handleSubmit = async (values: typeof form.values) => {
if (data) {
setConfirm({
title: 'Update ?',
message: `This product will be update`,
handleOk: async () => {
setLoading(true);
const result = await updateBid(values);
setLoading(false);
prevData.current = data;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data]);
if (!result) return;
useEffect(() => {
if (!props.opened) {
form.reset();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.opened]);
props.onClose();
useEffect(() => {
const values = mappingValues(["mode_key"]);
form.setValues(values);
if (onUpdated) {
onUpdated();
}
},
okButton: {
color: 'blue',
value: 'Update',
},
});
} else {
const { url, max_price, plus_price } = values;
prevData.current = data;
setLoading(true);
const result = await createBid({ url, max_price, plus_price } as IBid);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [form.values["mode_key"]]);
setLoading(false);
return (
<Modal
className="relative"
classNames={{
header: "!flex !item-center !justify-center w-full",
}}
{...props}
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 && (
<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")}
/>
)}
if (!result) return;
{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")}
/>
props.onClose();
{!!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"]}`
)}
/>
)}
if (onUpdated) {
onUpdated();
}
}
};
{!!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"]}`
)}
/>
)}
useEffect(() => {
form.reset();
if (!data) return;
form.setValues(data);
prevData.current = data;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data]);
useEffect(() => {
if (!props.opened) {
form.reset();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.opened]);
return (
<Modal
className="relative"
classNames={{
header: '!flex !item-center !justify-center w-full',
}}
{...props}
size={'xl'}
title={<span className="text-xl font-bold">Bid</span>}
centered
<Button
// disabled={_.isEqual(form.values, prevData.current)}
className="col-span-2"
type="submit"
fullWidth
size="sm"
mt="md"
>
<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')} />
{data ? "Update" : "Create"}
</Button>
</form>
<Button disabled={_.isEqual(form.getValues(), 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 }} />
</Modal>
);
<LoadingOverlay
visible={loading}
zIndex={1000}
overlayProps={{ blur: 2 }}
/>
</Modal>
);
}

View File

@ -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";

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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,26 +263,43 @@ 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 items-center gap-2">
<Badge
color={payloadLoginStatus?.login_status ? "green" : "red"}
size="xs"
>
{statusLabel()}
</Badge>
<Box className="flex justify-between w-full">
<Box className="flex items-center gap-2">
<Badge
color={payloadLoginStatus?.login_status ? "green" : "red"}
size="xs"
>
{statusLabel()}
</Badge>
<Badge
color={stringToColor(
isIBid(data)
<Badge
color={stringToColor(
isIBid(data)
? extractDomainSmart(data.web_bid.origin_url)
: extractDomainSmart(data.origin_url)
)}
size="xs"
>
{isIBid(data)
? extractDomainSmart(data.web_bid.origin_url)
: extractDomainSmart(data.origin_url)
)}
size="xs"
>
{isIBid(data)
? extractDomainSmart(data.web_bid.origin_url)
: extractDomainSmart(data.origin_url)}
</Badge>
: 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") && (

View File

@ -116,5 +116,3 @@ export const removeFalsy = (data: { [key: string]: string | number }) => {
return prev;
}, {} as { [key: string]: string | number });
};

View File

@ -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={() => {

View File

@ -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");

View File

@ -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 {

View File

@ -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
);
};

View File

@ -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,

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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>[];
}

View File

@ -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>[];
}

View File

@ -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;

View File

@ -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[];
}

View File

@ -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 {

View File

@ -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);

View File

@ -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 !' });
}
}

View File

@ -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, {
...data,
// status:
// prev.max_price + prev.plus_price > data.max_price
// ? 'out-bid'
// : prev.status,
});
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,
});
} 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);
}
}

View File

@ -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')}`,
);
}
}

View File

@ -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',

View File

@ -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;

View File

@ -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}`;

View File

@ -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],

View File

@ -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>
`;
}
}

View File

@ -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'),
});
}
}

View File

@ -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);

View File

@ -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)[];

View File

@ -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),
};
}

View File

@ -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(),

View File

@ -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,37 +311,11 @@ export class AllbidsProductBid extends ProductBid {
console.log("---------------------BIDDING--------------------");
const data = await this.submitBid();
await this.page_context.reload({ waitUntil: "networkidle0" });
const { aucUserMaxBid } = await this.waitForApiResponse();
console.log(`📡 [${this.id}] API Response received:`, { aucUserMaxBid });
// 📌 Kiểm tra trạng thái đấu giá từ API
if (aucUserMaxBid == this.max_price) {
console.log(`📸 [${this.id}] Taking bid success snapshot...`);
await takeSnapshot(
page,
this,
"bid-success",
CONSTANTS.TYPE_IMAGE.SUCCESS
);
sendMessage(this);
pushPrice({
bid_id: this.id,
price: aucUserMaxBid,
});
console.log(`✅ [${this.id}] Bid placed successfully!`);
return;
if (this.isSandbox()) {
await this.handleCallActionSanbox();
} else {
await this.handleCallActionLive();
}
console.log(
`⚠️ [${this.id}] Bid action completed, but status is still "None".`
);
} catch (error) {
console.log(`🚨 [${this.id}] Error placing bid: ${error.message}`);
} finally {
@ -319,6 +324,57 @@ export class AllbidsProductBid extends ProductBid {
}
}
async handleCallActionLive() {
const data = await this.handlePlaceBidLive();
await this.page_context.reload({ waitUntil: "networkidle0" });
const { aucUserMaxBid } = await this.waitForApiResponse();
console.log(`📡 [${this.id}] API Response received:`, { aucUserMaxBid });
// 📌 Kiểm tra trạng thái đấu giá từ API
if (aucUserMaxBid == this.max_price) {
console.log(`📸 [${this.id}] Taking bid success snapshot...`);
await takeSnapshot(
page,
this,
"bid-success",
CONSTANTS.TYPE_IMAGE.SUCCESS
);
// sendMessage(this);
pushPrice({
bid_id: this.id,
price: aucUserMaxBid,
});
console.log(`✅ [${this.id}] Bid placed successfully!`);
return;
}
console.log(
`⚠️ [${this.id}] Bid action completed, but status is still "None".`
);
}
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 () => {
try {
const page = this.page_context;

View File

@ -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

View File

@ -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();

View File

@ -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}`
);
}
};
}

View File

@ -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.`);
}
}

View File

@ -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);
}
}

View File

@ -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.`);

View File

@ -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);
}
}
}

View File

@ -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}`;
}
}

View File

@ -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",

View File

@ -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

View File

@ -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({

View File

@ -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;

View File

@ -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)),
};
}