update view admin

This commit is contained in:
Admin 2025-05-20 08:02:14 +07:00
parent 42f0a881f4
commit d929366810
52 changed files with 2127 additions and 1291 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@ -0,0 +1,47 @@
import { handleError, handleSuccess } from ".";
import axios from "../lib/axios";
import { IScrapConfig, IWebBid } from "../system/type";
import { removeFalsyValues } from "../utils";
export const createScrapConfig = async (
bid: Omit<
IScrapConfig,
"id" | "created_at" | "updated_at" | "scrap_items"
> & { web_id: IWebBid["id"] }
) => {
const newData = removeFalsyValues(bid);
try {
const { data } = await axios({
url: "scrap-configs",
withCredentials: true,
method: "POST",
data: newData,
});
handleSuccess(data);
return data;
} catch (error) {
handleError(error);
}
};
export const updateScrapConfig = async (scrapConfig: Partial<IScrapConfig>) => {
const { search_url, keywords, id } = removeFalsyValues(scrapConfig);
try {
const { data } = await axios({
url: "scrap-configs/" + id,
withCredentials: true,
method: "PUT",
data: { search_url, keywords },
});
handleSuccess(data);
return data;
} catch (error) {
handleError(error);
}
};

View File

@ -217,26 +217,32 @@ export default function WorkingPage({ data, socket }: IWorkingPageProps) {
</Box>
</Box>
<Box className="absolute top-2.5 left-2.5 flex items-center gap-2">
<Badge
color={payloadLoginStatus?.login_status ? "green" : "red"}
size="xs"
>
{statusLabel()}
</Badge>
<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>
<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) && moment(data.close_time).isSame(moment(), "day") && (
<div className="w-[14px] h-[14px] rounded-full bg-green-600 animate-pulse"></div>
)}
</Box>
</Box>

View File

@ -0,0 +1,163 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import {
Button,
LoadingOverlay,
Modal,
ModalProps,
Textarea,
TextInput,
} from "@mantine/core";
import { useForm, zodResolver } from "@mantine/form";
import _ from "lodash";
import { useEffect, useRef, useState } from "react";
import { z } from "zod";
import { createScrapConfig, updateScrapConfig } from "../../apis/scrap";
import { useConfirmStore } from "../../lib/zustand/use-confirm";
import { IScrapConfig, IWebBid } from "../../system/type";
export interface IScrapConfigModelProps extends ModalProps {
data: IWebBid | null;
onUpdated?: () => void;
}
const schema = z.object({
search_url: z
.string()
.url({ message: "Url is invalid" })
.min(1, { message: "Url is required" }),
keywords: z
.string({ message: "Keyword is required" })
.min(1, { message: "Keyword is required" })
.optional(),
});
export default function ScrapConfigModal({
data,
onUpdated,
...props
}: IScrapConfigModelProps) {
const form = useForm<IScrapConfig>({
validate: zodResolver(schema),
});
const [loading, setLoading] = useState(false);
const prevData = useRef<IScrapConfig | null>(data?.scrap_config);
const { setConfirm } = useConfirmStore();
const handleSubmit = async (values: typeof form.values) => {
if (data?.scrap_config) {
setConfirm({
title: "Update ?",
message: `This config will be update`,
handleOk: async () => {
setLoading(true);
const result = await updateScrapConfig({
...values,
id: data.scrap_config.id,
});
setLoading(false);
if (!result) return;
props.onClose();
if (onUpdated) {
onUpdated();
}
},
okButton: {
color: "blue",
value: "Update",
},
});
} else {
setLoading(true);
const result = await createScrapConfig({
...values,
web_id: data?.id || 0,
});
setLoading(false);
if (!result) return;
props.onClose();
if (onUpdated) {
onUpdated();
}
}
};
useEffect(() => {
form.reset();
if (!data) return;
form.setValues(data.scrap_config);
prevData.current = data.scrap_config;
// 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">Scrap config</span>}
centered
>
<form
onSubmit={form.onSubmit(handleSubmit)}
className="grid grid-cols-2 gap-2.5"
>
<TextInput
className="col-span-2"
size="sm"
label="Search url"
withAsterisk
description="Replace query keyword in url with phrase {{keyword}}"
placeholder="https://www.abc.com/search?q={{keyword}}"
{...form.getInputProps("search_url")}
/>
<Textarea
className="col-span-2"
size="sm"
label="Keywords"
rows={4}
placeholder="msg: Cisco"
description={"Different keywords must be separated by commas."}
{...form.getInputProps("keywords")}
/>
<Button
disabled={_.isEqual(form.getValues(), prevData.current)}
className="col-span-2"
type="submit"
fullWidth
size="sm"
mt="md"
>
{data?.scrap_config ? "Update" : "Create"}
</Button>
</form>
<LoadingOverlay
visible={loading}
zIndex={1000}
overlayProps={{ blur: 2 }}
/>
</Modal>
);
}

View File

@ -1,109 +1,148 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { Button, LoadingOverlay, Modal, ModalProps, PasswordInput, TextInput } from '@mantine/core';
import { useForm, zodResolver } from '@mantine/form';
import _ from 'lodash';
import { useEffect, useRef, useState } from 'react';
import { z } from 'zod';
import { updateWebBid } from '../../apis/web-bid';
import { useConfirmStore } from '../../lib/zustand/use-confirm';
import { IWebBid } from '../../system/type';
import {
Button,
LoadingOverlay,
Modal,
ModalProps,
PasswordInput,
TextInput,
} from "@mantine/core";
import { useForm, zodResolver } from "@mantine/form";
import _ from "lodash";
import { useEffect, useRef, useState } from "react";
import { z } from "zod";
import { updateWebBid } from "../../apis/web-bid";
import { useConfirmStore } from "../../lib/zustand/use-confirm";
import { IWebBid } from "../../system/type";
export interface IWebBidModelProps extends ModalProps {
data: IWebBid | null;
onUpdated?: () => void;
data: IWebBid | null;
onUpdated?: () => void;
}
const schema = z.object({
username: z.string().min(1, { message: 'Username is required' }),
password: z.string().min(6, { message: 'Password must be at least 6 characters long' }),
username: z.string().min(1, { message: "Username is required" }),
password: z
.string()
.min(6, { message: "Password must be at least 6 characters long" }),
});
export default function WebAccountModal({ data, onUpdated, ...props }: IWebBidModelProps) {
const form = useForm({
validate: zodResolver(schema),
});
export default function WebAccountModal({
data,
onUpdated,
...props
}: IWebBidModelProps) {
const form = useForm({
validate: zodResolver(schema),
});
const [loading, setLoading] = useState(false);
const [loading, setLoading] = useState(false);
const prevData = useRef<IWebBid | null>(data);
const prevData = useRef<IWebBid | null>(data);
const { setConfirm } = useConfirmStore();
const { setConfirm } = useConfirmStore();
const handleSubmit = async (values: typeof form.values) => {
if (data) {
setConfirm({
title: 'Update ?',
message: `This account will be update`,
handleOk: async () => {
setLoading(true);
const result = await updateWebBid(values);
setLoading(false);
const handleSubmit = async (values: typeof form.values) => {
if (data) {
setConfirm({
title: "Update ?",
message: `This account will be update`,
handleOk: async () => {
setLoading(true);
const result = await updateWebBid(values);
setLoading(false);
if (!result) return;
if (!result) return;
props.onClose();
props.onClose();
if (onUpdated) {
onUpdated();
}
},
okButton: {
color: 'blue',
value: 'Update',
},
});
} else {
setLoading(true);
const result = await updateWebBid(values);
setLoading(false);
if (onUpdated) {
onUpdated();
}
},
okButton: {
color: "blue",
value: "Update",
},
});
} else {
setLoading(true);
const result = await updateWebBid(values);
setLoading(false);
if (!result) return;
if (!result) return;
props.onClose();
props.onClose();
if (onUpdated) {
onUpdated();
}
}
};
if (onUpdated) {
onUpdated();
}
}
};
useEffect(() => {
form.reset();
if (!data) return;
useEffect(() => {
form.reset();
if (!data) return;
form.setValues(data);
form.setValues(data);
prevData.current = data;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [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]);
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">Account</span>}
centered
return (
<Modal
className="relative"
classNames={{
header: "!flex !item-center !justify-center w-full",
}}
{...props}
size={"xl"}
title={<span className="text-xl font-bold">Account</span>}
centered
>
<form
onSubmit={form.onSubmit(handleSubmit)}
className="grid grid-cols-2 gap-2.5"
>
<TextInput
withAsterisk
className="col-span-2"
size="sm"
label="Username"
{...form.getInputProps("username")}
/>
<PasswordInput
withAsterisk
className="col-span-2"
size="sm"
label="Password"
{...form.getInputProps("password")}
/>
<Button
disabled={_.isEqual(form.getValues(), 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">
<TextInput className="col-span-2" size="sm" label="Username" {...form.getInputProps('username')} />
<PasswordInput className="col-span-2" size="sm" label="Password" {...form.getInputProps('password')} />
{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

@ -26,12 +26,14 @@ const schema = {
.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: z
})
.optional(),
early_tracking_seconds: 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(),
})
.optional(),
};
export default function WebBidModal({
@ -56,11 +58,7 @@ export default function WebBidModal({
message: `This web will be update`,
handleOk: async () => {
setLoading(true);
console.log(
"%csrc/components/web-bid/web-bid-modal.tsx:54 values",
"color: #007acc;",
values
);
const result = await updateWebBid(values);
setLoading(false);
@ -78,14 +76,19 @@ export default function WebBidModal({
},
});
} else {
const { url, origin_url, arrival_offset_seconds, early_tracking_seconds } = values;
const {
url,
origin_url,
arrival_offset_seconds,
early_tracking_seconds,
} = values;
setLoading(true);
const result = await createWebBid({
url,
origin_url,
arrival_offset_seconds,
early_tracking_seconds
early_tracking_seconds,
} as IWebBid);
setLoading(false);
@ -157,9 +160,9 @@ export default function WebBidModal({
description="Note: that only integer minutes are accepted."
className="col-span-2"
size="sm"
label={`Arrival offset seconds (${
formatTimeFromMinutes(form.getValues()["arrival_offset_seconds"] / 60)
})`}
label={`Arrival offset seconds (${formatTimeFromMinutes(
form.getValues()["arrival_offset_seconds"] / 60
)})`}
placeholder="msg: 300"
{...form.getInputProps("arrival_offset_seconds")}
/>
@ -167,9 +170,9 @@ export default function WebBidModal({
description="Note: that only integer minutes are accepted."
className="col-span-2"
size="sm"
label={`Early tracking seconds (${
formatTimeFromMinutes(form.getValues()["early_tracking_seconds"] / 60)
})`}
label={`Early tracking seconds (${formatTimeFromMinutes(
form.getValues()["early_tracking_seconds"] / 60
)})`}
placeholder="msg: 600"
{...form.getInputProps("early_tracking_seconds")}
/>

View File

@ -145,7 +145,7 @@ export default function Bids() {
},
},
{
key: "close_time",
key: "close_time_ts",
title: "Close time",
typeFilter: "date",
renderRow(row) {

View File

@ -1,253 +1,306 @@
import { ActionIcon, Badge, Box, Menu, Text } from '@mantine/core';
import { IconAd, IconAdOff, IconEdit, IconMenu, IconTrash, IconUserEdit } from '@tabler/icons-react';
import { useMemo, useRef, useState } from 'react';
import { deletesWebBid, deleteWebBid, getWebBids, updateWebBid } from '../apis/web-bid';
import Table from '../lib/table/table';
import { IColumn, TRefTableFn } from '../lib/table/type';
import { useConfirmStore } from '../lib/zustand/use-confirm';
import { IWebBid } from '../system/type';
import { formatTime } from '../utils';
import { WebAccountModal, WebBidModal } from '../components/web-bid';
import { useDisclosure } from '@mantine/hooks';
import { ActionIcon, Badge, Box, Menu, Text } from "@mantine/core";
import {
IconAd,
IconAdOff,
IconEdit,
IconMenu,
IconSettingsCode,
IconTrash,
IconUserEdit,
} from "@tabler/icons-react";
import { useMemo, useRef, useState } from "react";
import {
deletesWebBid,
deleteWebBid,
getWebBids,
updateWebBid,
} from "../apis/web-bid";
import Table from "../lib/table/table";
import { IColumn, TRefTableFn } from "../lib/table/type";
import { useConfirmStore } from "../lib/zustand/use-confirm";
import { IWebBid } from "../system/type";
import { formatTime } from "../utils";
import { WebAccountModal, WebBidModal } from "../components/web-bid";
import { useDisclosure } from "@mantine/hooks";
import ScrapConfigModal from "../components/web-bid/scrap-config.modal";
export default function WebBids() {
const refTableFn: TRefTableFn<IWebBid> = useRef({});
const refTableFn: TRefTableFn<IWebBid> = useRef({});
const [clickData, setClickData] = useState<IWebBid | null>(null);
const [clickData, setClickData] = useState<IWebBid | null>(null);
const { setConfirm } = useConfirmStore();
const { setConfirm } = useConfirmStore();
const [webBidOpened, webBidModal] = useDisclosure(false);
const [webAccountOpened, webAccountModal] = useDisclosure(false);
const [webBidOpened, webBidModal] = useDisclosure(false);
const [webAccountOpened, webAccountModal] = useDisclosure(false);
const [scrapConfigOpened, scrapConfigModal] = useDisclosure(false);
const columns: IColumn<IWebBid>[] = [
{
key: 'id',
title: 'ID',
typeFilter: 'number',
},
{
key: 'origin_url',
title: 'Domain',
typeFilter: 'text',
},
{
key: 'url',
title: 'Tracking url',
typeFilter: 'text',
renderRow(row) {
return <Text>{row.url || 'None'}</Text>;
},
},
{
key: 'active',
title: 'Status',
typeFilter: 'text',
renderRow(row) {
return (
<Box className="flex items-center justify-center">
<Badge color={row.active ? 'green' : 'red'} size="sm">
{row.active ? 'Enable' : 'Disable'}
</Badge>
</Box>
);
},
},
{
key: 'created_at',
title: 'Created at',
typeFilter: 'none',
renderRow(row) {
return <span>{formatTime(row.created_at)}</span>;
},
},
{
key: 'updated_at',
title: 'Update at',
typeFilter: 'none',
renderRow(row) {
return <span>{formatTime(row.updated_at)}</span>;
},
},
];
const handleDelete = (data: IWebBid) => {
setConfirm({
title: 'Delete ?',
message: 'This web will be delete',
handleOk: async () => {
await deleteWebBid(data);
if (refTableFn.current?.fetchData) {
refTableFn.current.fetchData();
}
},
});
};
const handleToggle = async (data: IWebBid) => {
setConfirm({
title: (data.active ? 'Disable ' : 'Enable ') + 'ID: ' + data.id,
message: 'This web will be ' + (data.active ? 'disable ' : 'enable '),
handleOk: async () => {
await updateWebBid({ ...data, active: !data.active || false });
if (refTableFn.current?.fetchData) {
refTableFn.current.fetchData();
}
},
okButton: {
value: data.active ? 'Disable ' : 'Enable ',
color: data.active ? 'red' : 'blue',
},
});
};
const table = useMemo(() => {
const columns: IColumn<IWebBid>[] = [
{
key: "id",
title: "ID",
typeFilter: "number",
},
{
key: "origin_url",
title: "Domain",
typeFilter: "text",
},
{
key: "url",
title: "Tracking url",
typeFilter: "text",
renderRow(row) {
return <Text>{row.url || "None"}</Text>;
},
},
{
key: "active",
title: "Status",
typeFilter: "text",
renderRow(row) {
return (
<Table
actionsOptions={{
actions: [
{
key: 'add',
title: 'Add',
callback: () => {
webBidModal.open();
},
},
{
key: 'delete',
title: 'Delete',
callback: (data) => {
if (!data.length) return;
setConfirm({
title: 'Delete',
message: `${data.length} will be delete`,
handleOk: async () => {
const result = await deletesWebBid(data);
if (!result) return;
if (refTableFn.current.fetchData) {
refTableFn.current.fetchData();
}
},
});
},
disabled: (data) => data.length <= 0,
},
],
}}
refTableFn={refTableFn}
striped
showLoading={true}
highlightOnHover
styleDefaultHead={{
justifyContent: 'flex-start',
width: 'fit-content',
}}
options={{
query: getWebBids,
pathToData: 'data.data',
keyOptions: {
last_page: 'lastPage',
per_page: 'perPage',
from: 'from',
to: 'to',
total: 'total',
},
}}
rows={[]}
withColumnBorders
showChooses={true}
withTableBorder
columns={columns}
actions={{
title: <Box className="w-full text-center">Action</Box>,
body: (row) => {
return (
<Menu shadow="md" width={200}>
<Menu.Target>
<Box className="flex w-full items-center justify-center">
<ActionIcon size="sm" variant="light">
<IconMenu size={14} />
</ActionIcon>
</Box>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
onClick={() => {
setClickData(row);
webBidModal.open();
}}
leftSection={<IconEdit size={14} />}
>
Edit
</Menu.Item>
<Menu.Item
onClick={() => {
setClickData(row);
webAccountModal.open();
}}
leftSection={<IconUserEdit size={14} />}
>
Account
</Menu.Item>
<Menu.Item onClick={() => handleToggle(row)} leftSection={row.active ? <IconAdOff size={14} /> : <IconAd size={14} />}>
{row.active ? 'Disable' : 'Enable'}
</Menu.Item>
<Menu.Item onClick={() => handleDelete(row)} leftSection={<IconTrash color="red" size={14} />}>
Delete
</Menu.Item>
</Menu.Dropdown>
</Menu>
);
},
}}
rowKey="id"
/>
<Box className="flex items-center justify-center">
<Badge color={row.active ? "green" : "red"} size="sm">
{row.active ? "Enable" : "Disable"}
</Badge>
</Box>
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
},
},
{
key: "created_at",
title: "Created at",
typeFilter: "none",
renderRow(row) {
return <span>{formatTime(row.created_at)}</span>;
},
},
{
key: "updated_at",
title: "Update at",
typeFilter: "none",
renderRow(row) {
return <span>{formatTime(row.updated_at)}</span>;
},
},
];
const handleDelete = (data: IWebBid) => {
setConfirm({
title: "Delete ?",
message: "This web will be delete",
handleOk: async () => {
await deleteWebBid(data);
if (refTableFn.current?.fetchData) {
refTableFn.current.fetchData();
}
},
});
};
const handleToggle = async (data: IWebBid) => {
setConfirm({
title: (data.active ? "Disable " : "Enable ") + "ID: " + data.id,
message: "This web will be " + (data.active ? "disable " : "enable "),
handleOk: async () => {
await updateWebBid({ ...data, active: !data.active || false });
if (refTableFn.current?.fetchData) {
refTableFn.current.fetchData();
}
},
okButton: {
value: data.active ? "Disable " : "Enable ",
color: data.active ? "red" : "blue",
},
});
};
const table = useMemo(() => {
return (
<Box>
{table}
<Table
actionsOptions={{
actions: [
{
key: "add",
title: "Add",
callback: () => {
webBidModal.open();
},
},
{
key: "delete",
title: "Delete",
callback: (data) => {
if (!data.length) return;
setConfirm({
title: "Delete",
message: `${data.length} will be delete`,
handleOk: async () => {
const result = await deletesWebBid(data);
<WebBidModal
data={clickData}
opened={webBidOpened}
onClose={() => {
webBidModal.close();
setClickData(null);
}}
onUpdated={() => {
setClickData(null);
if (refTableFn.current?.fetchData) {
refTableFn.current.fetchData();
if (!result) return;
if (refTableFn.current.fetchData) {
refTableFn.current.fetchData();
}
}}
/>
},
});
},
disabled: (data) => data.length <= 0,
},
],
}}
refTableFn={refTableFn}
striped
showLoading={true}
highlightOnHover
styleDefaultHead={{
justifyContent: "flex-start",
width: "fit-content",
}}
options={{
query: getWebBids,
pathToData: "data.data",
keyOptions: {
last_page: "lastPage",
per_page: "perPage",
from: "from",
to: "to",
total: "total",
},
}}
rows={[]}
withColumnBorders
showChooses={true}
withTableBorder
columns={columns}
actions={{
title: <Box className="w-full text-center">Action</Box>,
body: (row) => {
return (
<Menu shadow="md" width={200}>
<Menu.Target>
<Box className="flex w-full items-center justify-center">
<ActionIcon size="sm" variant="light">
<IconMenu size={14} />
</ActionIcon>
</Box>
</Menu.Target>
<WebAccountModal
data={clickData}
opened={webAccountOpened}
onClose={() => {
webAccountModal.close();
setClickData(null);
}}
onUpdated={() => {
setClickData(null);
<Menu.Dropdown>
<Menu.Item
onClick={() => {
setClickData(row);
webBidModal.open();
}}
leftSection={<IconEdit size={14} />}
>
Edit
</Menu.Item>
<Menu.Item
onClick={() => {
setClickData(row);
webAccountModal.open();
}}
leftSection={<IconUserEdit size={14} />}
>
Account
</Menu.Item>
if (refTableFn.current?.fetchData) {
refTableFn.current.fetchData();
<Menu.Item
onClick={() => {
setClickData(row);
scrapConfigModal.open();
}}
leftSection={<IconSettingsCode size={14} />}
>
Scrap config
</Menu.Item>
<Menu.Item
onClick={() => handleToggle(row)}
leftSection={
row.active ? (
<IconAdOff size={14} />
) : (
<IconAd size={14} />
)
}
}}
/>
</Box>
>
{row.active ? "Disable" : "Enable"}
</Menu.Item>
<Menu.Item
onClick={() => handleDelete(row)}
leftSection={<IconTrash color="red" size={14} />}
>
Delete
</Menu.Item>
</Menu.Dropdown>
</Menu>
);
},
}}
rowKey="id"
/>
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<Box>
{table}
<WebBidModal
data={clickData}
opened={webBidOpened}
onClose={() => {
webBidModal.close();
setClickData(null);
}}
onUpdated={() => {
setClickData(null);
if (refTableFn.current?.fetchData) {
refTableFn.current.fetchData();
}
}}
/>
<WebAccountModal
data={clickData}
opened={webAccountOpened}
onClose={() => {
webAccountModal.close();
setClickData(null);
}}
onUpdated={() => {
setClickData(null);
if (refTableFn.current?.fetchData) {
refTableFn.current.fetchData();
}
}}
/>
<ScrapConfigModal
data={clickData}
opened={scrapConfigOpened}
onClose={() => {
scrapConfigModal.close();
setClickData(null);
}}
onUpdated={() => {
setClickData(null);
if (refTableFn.current?.fetchData) {
refTableFn.current.fetchData();
}
}}
/>
</Box>
);
}

View File

@ -1,79 +1,94 @@
export interface IAdmin extends ITimestamp {
id: number;
email: string;
username: string;
fullname: string;
password?: string;
is_system_account: boolean;
permissions: [];
id: number;
email: string;
username: string;
fullname: string;
password?: string;
is_system_account: boolean;
permissions: [];
}
export interface IKey extends ITimestamp {
id: number;
client_key: string;
id: number;
client_key: string;
}
export interface ITimestamp {
created_at: string;
updated_at: string;
created_at: string;
updated_at: string;
}
export interface IHistory extends ITimestamp {
id: number;
price: number;
id: number;
price: number;
}
export interface IOutBidLog extends ITimestamp {
id: number;
model: string;
lot_id: string;
out_price: number;
raw_data: string;
id: number;
model: string;
lot_id: string;
out_price: number;
raw_data: string;
}
export interface IScrapConfig extends ITimestamp {
id: number;
search_url: string;
keywords: string;
scrap_items: IScrapItem[];
}
export interface IScrapItem extends ITimestamp {
id: number;
url: string;
model: string;
image_url: string | null;
keyword: string;
}
export interface IWebBid extends ITimestamp {
created_at: string;
updated_at: string;
id: number;
origin_url: string;
url: string | null;
username: string | null;
password: string | null;
active: boolean;
arrival_offset_seconds: number;
early_tracking_seconds: number;
snapshot_at: string | null
children: IBid[];
created_at: string;
updated_at: string;
id: number;
origin_url: string;
url: string | null;
username: string | null;
password: string | null;
active: boolean;
arrival_offset_seconds: number;
early_tracking_seconds: number;
snapshot_at: string | null;
children: IBid[];
scrap_config: IScrapConfig;
}
export interface IBid extends ITimestamp {
id: number;
max_price: number;
reserve_price: number;
current_price: number;
name: string | null;
quantity: number;
url: string;
model: string;
lot_id: string;
plus_price: number;
close_time: string | null;
start_bid_time: string | null;
first_bid: boolean;
status: 'biding' | 'out-bid' | 'win-bid';
histories: IHistory[];
web_bid: IWebBid;
id: number;
max_price: number;
reserve_price: number;
current_price: number;
name: string | null;
quantity: number;
url: string;
model: string;
lot_id: string;
plus_price: number;
close_time: string | null;
close_time_ts: string | null;
start_bid_time: string | null;
first_bid: boolean;
status: "biding" | "out-bid" | "win-bid";
histories: IHistory[];
web_bid: IWebBid;
}
export interface IPermission extends ITimestamp {
id: number;
name: string;
description: string;
id: number;
name: string;
description: string;
}
export interface ISendMessageHistory extends ITimestamp {
id: number;
message: string;
bid: IBid;
id: number;
message: string;
bid: IBid;
}

View File

@ -1 +1 @@
{"createdAt":1747292824357}
{"createdAt":1747701959077}

View File

@ -23,6 +23,7 @@
"@nestjs/websockets": "^11.0.11",
"axios": "^1.8.3",
"bcrypt": "^5.1.1",
"cheerio": "^1.0.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"cookie": "^1.0.2",
@ -4165,6 +4166,12 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/boolbase": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
"license": "ISC"
},
"node_modules/brace-expansion": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
@ -4422,6 +4429,48 @@
"dev": true,
"license": "MIT"
},
"node_modules/cheerio": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0.tgz",
"integrity": "sha512-quS9HgjQpdaXOvsZz82Oz7uxtXiy6UIsIQcpBj7HRw2M63Skasm9qlDocAM7jNuaxdhpPU7c4kJN+gA5MCu4ww==",
"license": "MIT",
"dependencies": {
"cheerio-select": "^2.1.0",
"dom-serializer": "^2.0.0",
"domhandler": "^5.0.3",
"domutils": "^3.1.0",
"encoding-sniffer": "^0.2.0",
"htmlparser2": "^9.1.0",
"parse5": "^7.1.2",
"parse5-htmlparser2-tree-adapter": "^7.0.0",
"parse5-parser-stream": "^7.1.2",
"undici": "^6.19.5",
"whatwg-mimetype": "^4.0.0"
},
"engines": {
"node": ">=18.17"
},
"funding": {
"url": "https://github.com/cheeriojs/cheerio?sponsor=1"
}
},
"node_modules/cheerio-select": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz",
"integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==",
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0",
"css-select": "^5.1.0",
"css-what": "^6.1.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.0.1"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
@ -4923,6 +4972,34 @@
"node": ">= 8"
}
},
"node_modules/css-select": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz",
"integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==",
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0",
"css-what": "^6.1.0",
"domhandler": "^5.0.2",
"domutils": "^3.0.1",
"nth-check": "^2.0.1"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/css-what": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz",
"integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">= 6"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/dayjs": {
"version": "1.11.13",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz",
@ -5138,6 +5215,61 @@
"node": ">=6.0.0"
}
},
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
"entities": "^4.2.0"
},
"funding": {
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "BSD-2-Clause"
},
"node_modules/domhandler": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^2.3.0"
},
"engines": {
"node": ">= 4"
},
"funding": {
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/domutils": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/dotenv": {
"version": "16.4.7",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz",
@ -5260,6 +5392,31 @@
"iconv-lite": "^0.6.2"
}
},
"node_modules/encoding-sniffer": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.0.tgz",
"integrity": "sha512-ju7Wq1kg04I3HtiYIOrUrdfdDvkyO9s5XM8QAj/bN61Yo/Vb4vgJxy5vi4Yxk01gWHbrofpPtpxM8bKger9jhg==",
"license": "MIT",
"dependencies": {
"iconv-lite": "^0.6.3",
"whatwg-encoding": "^3.1.1"
},
"funding": {
"url": "https://github.com/fb55/encoding-sniffer?sponsor=1"
}
},
"node_modules/encoding-sniffer/node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/encoding/node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
@ -5341,6 +5498,18 @@
"node": ">=10.13.0"
}
},
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/error-ex": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz",
@ -6773,6 +6942,25 @@
"dev": true,
"license": "MIT"
},
"node_modules/htmlparser2": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz",
"integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==",
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.1.0",
"entities": "^4.5.0"
}
},
"node_modules/http-errors": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
@ -8900,6 +9088,18 @@
"set-blocking": "^2.0.0"
}
},
"node_modules/nth-check": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0"
},
"funding": {
"url": "https://github.com/fb55/nth-check?sponsor=1"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@ -9109,6 +9309,55 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/parse5": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
"license": "MIT",
"dependencies": {
"entities": "^6.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/parse5-htmlparser2-tree-adapter": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz",
"integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==",
"license": "MIT",
"dependencies": {
"domhandler": "^5.0.3",
"parse5": "^7.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/parse5-parser-stream": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz",
"integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==",
"license": "MIT",
"dependencies": {
"parse5": "^7.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/parse5/node_modules/entities": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.0.tgz",
"integrity": "sha512-aKstq2TDOndCn4diEyp9Uq/Flu2i1GlLkc6XIDQSDMuaFE3OPW5OphLCyQ5SpSJZTb4reN+kTcYru5yIfXoRPw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@ -11532,6 +11781,15 @@
"node": ">=8"
}
},
"node_modules/undici": {
"version": "6.21.3",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz",
"integrity": "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==",
"license": "MIT",
"engines": {
"node": ">=18.17"
}
},
"node_modules/undici-types": {
"version": "6.19.8",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz",
@ -11842,6 +12100,39 @@
"url": "https://opencollective.com/webpack"
}
},
"node_modules/whatwg-encoding": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
"integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
"license": "MIT",
"dependencies": {
"iconv-lite": "0.6.3"
},
"engines": {
"node": ">=18"
}
},
"node_modules/whatwg-encoding/node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/whatwg-mimetype": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
"integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",

View File

@ -39,6 +39,7 @@
"@nestjs/websockets": "^11.0.11",
"axios": "^1.8.3",
"bcrypt": "^5.1.1",
"cheerio": "^1.0.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"cookie": "^1.0.2",

View File

@ -14,6 +14,7 @@ import {
import { AuthorizationMiddleware } from './modules/admins/middlewares/authorization.middleware';
import { ClientAuthenticationMiddleware } from './modules/auth/middlewares/client-authentication.middleware';
import { NotificationModule } from './modules/notification/notification.module';
import { ScrapsModule } from './modules/scraps/scraps.module';
@Module({
imports: [
@ -24,6 +25,7 @@ import { NotificationModule } from './modules/notification/notification.module';
AuthModule,
AdminsModule,
NotificationModule,
ScrapsModule,
],
controllers: [],
providers: [],

View File

@ -45,6 +45,9 @@ export class Bid extends Timestamp {
@Column({ default: null, nullable: true })
close_time: string;
@Column({ default: null, nullable: true })
close_time_ts: Date | null;
@Column({ default: null, nullable: true })
start_bid_time: string;

View File

@ -1,7 +1,15 @@
import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm';
import {
Column,
Entity,
OneToMany,
OneToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
import { Timestamp } from './timestamp';
import { Bid } from './bid.entity';
import { Exclude } from 'class-transformer';
import { ScrapItem } from '@/modules/scraps/entities/scrap-item.entity';
import { ScrapConfig } from '@/modules/scraps/entities/scrap-config.entity';
@Entity('web_bids')
export class WebBid extends Timestamp {
@ -37,4 +45,7 @@ export class WebBid extends Timestamp {
cascade: true,
})
children: Bid[];
@OneToOne(() => ScrapConfig, (scrap) => scrap.web_bid)
scrap_config: ScrapConfig;
}

View File

@ -65,6 +65,7 @@ export class BidsService {
sortableColumns: [
'id',
'close_time',
'close_time_ts',
'first_bid',
'model',
'lot_id',
@ -120,7 +121,9 @@ export class BidsService {
await this.emitAllBidEvent();
return AppResponse.toResponse(response ? response(result) : plainToClass(Bid, result));
return AppResponse.toResponse(
response ? response(result) : plainToClass(Bid, result),
);
}
async update(id: Bid['id'], data: UpdateBidDto) {
@ -266,6 +269,7 @@ export class BidsService {
new Date(close_time).getTime() > new Date(bid.close_time).getTime()
) {
bid.close_time = close_time;
bid.close_time_ts = new Date(close_time);
}
// Nếu chưa có `model` nhưng dữ liệu mới có model, thì cập nhật model
@ -550,11 +554,17 @@ export class BidsService {
}
async getBidByModel(model: string) {
console.log('%csrc/modules/bids/services/bids.service.ts:554 model', 'color: #007acc;', model);
console.log(
'%csrc/modules/bids/services/bids.service.ts:554 model',
'color: #007acc;',
model,
);
const bid = await this.bidsRepo.findOne({ where: { model } });
if (!bid) return AppResponse.toResponse(null, {status_code: HttpStatus.NOT_FOUND});
if (!bid)
return AppResponse.toResponse(null, {
status_code: HttpStatus.NOT_FOUND,
});
return AppResponse.toResponse(plainToClass(Bid, bid));
}

View File

@ -23,7 +23,7 @@ export class TasksService {
) {}
@Cron(CronExpression.EVERY_MINUTE)
async handleCron() {
async handleResetTool() {
const bids = await this.bidsService.bidsRepo.find({
where: { status: 'biding' },
select: ['close_time', 'created_at', 'start_bid_time', 'id', 'lot_id'],

View File

@ -53,6 +53,9 @@ export class WebBidsService {
filterableColumns,
defaultSortBy: [['id', 'DESC']],
maxLimit: 100,
relations: {
scrap_config: true,
},
});
return AppResponse.toPagination<WebBid>(data, true, WebBid);
@ -66,6 +69,11 @@ export class WebBidsService {
children: { status: 'biding' },
},
relations: { children: { histories: true, web_bid: true } },
order: {
children: {
close_time_ts: 'ASC',
},
},
});
return data;

View File

@ -0,0 +1,60 @@
import { Body, Controller, Get, Param, Post, Put } from '@nestjs/common';
import { CreateScrapConfigDto } from '../dto/scrap-config/create-scrap-config';
import { ScrapConfigsService } from '../services/scrap-config.service';
import { UpdateScrapConfigDto } from '../dto/scrap-config/update-scrap-config';
import { ScrapConfig } from '../entities/scrap-config.entity';
import { IsNull, Not } from 'typeorm';
import { ScrapItemsService } from '../services/scrap-item-config.service';
@Controller('admin/scrap-configs')
export class ScrapConfigsController {
constructor(
private readonly scrapConfigsService: ScrapConfigsService,
private readonly scrapItemsService: ScrapItemsService,
) {}
@Post()
async create(@Body() data: CreateScrapConfigDto) {
return await this.scrapConfigsService.create(data);
}
@Put(':id')
async update(
@Param('id') id: ScrapConfig['id'],
@Body() data: UpdateScrapConfigDto,
) {
return await this.scrapConfigsService.update(id, data);
}
@Get()
async test() {
const scrapConfigs = await this.scrapConfigsService.scrapConfigRepo.find({
where: {
search_url: Not(IsNull()),
keywords: Not(IsNull()),
},
relations: {
web_bid: true,
},
});
const models = this.scrapConfigsService.scrapModels(scrapConfigs);
await Promise.allSettled(
models.map(async (item) => {
await item.action();
Object.keys(item.results).forEach(async (key) => {
const data = item.results[key];
await this.scrapItemsService.scrapItemRepo.upsert(data, [
'model',
'scrap_config',
]);
});
}),
);
return { a: 'abc' };
}
}

View File

@ -0,0 +1,13 @@
import { IsNumber, IsOptional, IsString, IsUrl } from 'class-validator';
export class CreateScrapConfigDto {
@IsUrl()
search_url: string;
@IsString()
@IsOptional()
keywords: string;
@IsNumber()
web_id: number;
}

View File

@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateScrapConfigDto } from './create-scrap-config';
export class UpdateScrapConfigDto extends PartialType(CreateScrapConfigDto) {}

View File

@ -0,0 +1,33 @@
import { WebBid } from '@/modules/bids/entities/wed-bid.entity';
import {
Column,
Entity,
JoinColumn,
OneToMany,
OneToOne,
PrimaryGeneratedColumn,
Unique,
} from 'typeorm';
import { ScrapItem } from './scrap-item.entity';
import { Timestamp } from './timestamp';
@Entity('scrap-configs')
export class ScrapConfig extends Timestamp {
@PrimaryGeneratedColumn('increment')
id: number;
@Column({ unique: true })
search_url: string;
@Column({ default: 'cisco' })
keywords: string;
@OneToOne(() => WebBid, (web) => web.scrap_config, { onDelete: 'CASCADE' })
@JoinColumn()
web_bid: WebBid;
@OneToMany(() => ScrapItem, (web) => web.scrap_config, {
onDelete: 'CASCADE',
})
scrap_items: ScrapItem[];
}

View File

@ -0,0 +1,39 @@
import {
Column,
Entity,
ManyToOne,
PrimaryGeneratedColumn,
Unique,
} from 'typeorm';
import { ScrapConfig } from './scrap-config.entity';
import { Timestamp } from './timestamp';
@Entity('scrap-items')
@Unique(['model', 'scrap_config'])
export class ScrapItem extends Timestamp {
@PrimaryGeneratedColumn('increment')
id: number;
@Column()
name: string;
@Column()
url: string;
@Column()
model: string;
@Column({ nullable: true, default: null })
image_url: string | null;
@Column({ nullable: true, default: null })
keyword: string;
@Column({ nullable: true, default: null })
current_price: number;
@ManyToOne(() => ScrapConfig, (web) => web.scrap_items, {
onDelete: 'CASCADE',
})
scrap_config: ScrapConfig;
}

View File

@ -0,0 +1,8 @@
import { CreateDateColumn, UpdateDateColumn } from 'typeorm';
export abstract class Timestamp {
@CreateDateColumn({ type: 'timestamp', name: 'created_at' })
created_at: Date;
@UpdateDateColumn({ type: 'timestamp', name: 'updated_at' })
updated_at: Date;
}

View File

@ -0,0 +1,71 @@
import axios from 'axios';
import { ScrapInterface } from '../../scrap-interface';
import { ScrapModel } from '../../scrap-model';
import * as cheerio from 'cheerio';
import { Element } from 'domhandler';
import { ScrapItem } from '@/modules/scraps/entities/scrap-item.entity';
import { extractModelId, extractNumber } from '@/ultils';
export class GraysScrapModel extends ScrapModel {
action = async () => {
const urls = this.extractUrls();
console.log({ urls });
const results = await Promise.allSettled(
urls.map(async (item) => ({
html: (await axios.get(item.url)).data,
keyword: item.keyword,
})),
);
const htmlsData = results
.filter((res) => res.status === 'fulfilled')
.map((res) => (res as PromiseFulfilledResult<any>).value);
await Promise.all(
htmlsData.map(async (cur) => {
try {
const data = await this.getItemsInHtml(cur);
const results = this.filterItemByKeyword(cur.keyword, data);
this.results[cur.keyword] = results; // hoặc push như gợi ý trên
return results;
} catch (err) {
console.error(`❌ Error with keyword ${cur.keyword}:`, err);
return []; // fallback
}
}),
);
};
getItemsInHtml = async ({
html,
keyword,
}: {
html: string;
keyword: string;
}) => {
const $ = cheerio.load(html);
const container = $('.sc-102aeaf3-1.eYPitT');
const items = container.children('div').toArray();
const results = items.map((el) => {
const url = $(el).find('.sc-pKqro.sc-gFnajm.gqkMpZ.dzWUkJ').attr('href');
return {
name: $(el).find('.sc-jlGgGc.dJRywx').text().trim(),
image_url: $(el).find('img.sc-gtJxfw.jbgdlx').attr('src'),
model: extractModelId(url),
keyword,
url,
current_price: extractNumber(
$(el).find('.sc-ijDOKB.sc-bStcSt.ikmQUw.eEycyP').text(),
),
scrap_config: { id: this.scrap_config_id },
} as ScrapItem;
});
return results;
};
}

View File

@ -0,0 +1,10 @@
import { ScrapItem } from '../entities/scrap-item.entity';
export interface ScrapInterface {
getItemsInHtml: (data: {
html: string;
keyword: string;
}) => Promise<ScrapItem[]>;
action: () => Promise<void>;
}

View File

@ -0,0 +1,61 @@
import { ScrapConfig } from '../entities/scrap-config.entity';
import { ScrapItem } from '../entities/scrap-item.entity';
import { ScrapInterface } from './scrap-interface';
import { Element } from 'domhandler';
export class ScrapModel implements ScrapInterface {
protected keywords: string;
protected search_url: string;
protected scrap_config_id: ScrapConfig['id'];
public results: Record<string, ScrapItem[]> = {};
constructor({
keywords,
search_url,
scrap_config_id,
}: {
keywords: string;
search_url: string;
scrap_config_id: ScrapConfig['id'];
}) {
this.keywords = keywords;
this.search_url = search_url;
this.scrap_config_id = scrap_config_id;
}
protected buildUrlWithKey(rawUrl: string, keyword: string) {
return rawUrl.replaceAll('{{keyword}}', keyword);
}
protected extractUrls(): { url: string; keyword: string }[] {
// Tách từng key ra từ một chuỗi keywords
const keywordList = this.keywords.split(', ');
// Dừng hàm nếu không có key nào
if (keywordList.length <= 0) return [];
// Lập qua từng key để lấy url
return keywordList.map((keyword) => {
return {
url: this.buildUrlWithKey(this.search_url, keyword),
keyword,
};
});
}
action: () => Promise<void>;
getInfoItems: (
data: { name: string; el: Element }[],
) => Record<string, string>[];
getItemsInHtml: (data: {
html: string;
keyword: string;
}) => Promise<ScrapItem[]>;
protected filterItemByKeyword = (keyword: string, data: ScrapItem[]) => {
return data.filter((item) =>
item.name.toLowerCase().includes(keyword.toLowerCase()),
);
};
}

View File

@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ScrapConfig } from './entities/scrap-config.entity';
import { ScrapItem } from './entities/scrap-item.entity';
import { ScrapConfigsService } from './services/scrap-config.service';
import { ScrapConfigsController } from './controllers/scrap-config.controller';
import { TasksService } from './services/tasks.service';
import { ScrapItemsService } from './services/scrap-item-config.service';
@Module({
imports: [TypeOrmModule.forFeature([ScrapConfig, ScrapItem])],
providers: [ScrapConfigsService, TasksService, ScrapItemsService],
exports: [ScrapConfigsService, TasksService, ScrapItemsService],
controllers: [ScrapConfigsController],
})
export class ScrapsModule {}

View File

@ -0,0 +1,60 @@
import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ScrapConfig } from '../entities/scrap-config.entity';
import AppResponse from '@/response/app-response';
import { CreateScrapConfigDto } from '../dto/scrap-config/create-scrap-config';
import { UpdateScrapConfigDto } from '../dto/scrap-config/update-scrap-config';
import axios from 'axios';
import { WebBid } from '@/modules/bids/entities/wed-bid.entity';
import { GraysScrapModel } from '../models/https:/www.grays.com/grays-scrap-model';
@Injectable()
export class ScrapConfigsService {
constructor(
@InjectRepository(ScrapConfig)
readonly scrapConfigRepo: Repository<ScrapConfig>,
) {}
async create(data: CreateScrapConfigDto) {
const result = await this.scrapConfigRepo.save({
search_url: data.search_url,
keywords: data.keywords,
web_bid: { id: data.web_id },
});
if (!result) return AppResponse.toResponse(false);
return AppResponse.toResponse(true);
}
async update(
id: ScrapConfig['id'],
{ web_id, ...data }: UpdateScrapConfigDto,
) {
const result = await this.scrapConfigRepo.update(id, { ...data });
if (!result.affected) return AppResponse.toResponse(false);
return AppResponse.toResponse(true);
}
scrapModel(scrapConfig: ScrapConfig) {
switch (scrapConfig.web_bid.origin_url) {
case 'https://www.grays.com': {
return new GraysScrapModel({
...scrapConfig,
scrap_config_id: scrapConfig.id,
});
}
default: {
return null;
}
}
}
scrapModels(data: ScrapConfig[]) {
return data.map((item) => this.scrapModel(item)).filter((item) => !!item);
}
}

View File

@ -0,0 +1,12 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ScrapItem } from '../entities/scrap-item.entity';
@Injectable()
export class ScrapItemsService {
constructor(
@InjectRepository(ScrapItem)
readonly scrapItemRepo: Repository<ScrapItem>,
) {}
}

View File

@ -0,0 +1,25 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { IsNull, Not } from 'typeorm';
import { ScrapConfigsService } from './scrap-config.service';
@Injectable()
export class TasksService {
private readonly logger = new Logger(TasksService.name);
constructor(private readonly scrapConfigsService: ScrapConfigsService) {}
@Cron(CronExpression.EVERY_MINUTE)
async handleScraps() {
// const scrapConfigs = await this.scrapConfigsService.scrapConfigRepo.find({
// where: {
// search_url: Not(IsNull()),
// keywords: Not(IsNull()),
// },
// relations: {
// web_bid: true,
// },
// });
// const models = this.scrapConfigsService.scrapModels(scrapConfigs);
// await Promise.allSettled(models.map(async (item) => await item.action()));
}
}

View File

@ -1,4 +1,4 @@
import { Bid } from "@/modules/bids/entities/bid.entity";
import { Bid } from '@/modules/bids/entities/bid.entity';
export function extractModelId(url: string): string | null {
switch (extractDomain(url)) {
@ -18,6 +18,10 @@ export function extractModelId(url: string): string | null {
const model = url.split('/').pop();
return model ? model : null;
}
case 'https://www.allbids.com.au': {
const match = url.match(/-(\d+)(?:[\?#]|$)/);
return match ? match[1] : null;
}
}
}
@ -110,71 +114,74 @@ export function verifyCode(content: string) {
return null;
}
export function shouldResetTool(
bids: Bid[],
lastResetTime: Date | null,
now: Date = new Date(),
) {
const ONE_MINUTE = 60 * 1000;
const ONE_HOUR = 60 * ONE_MINUTE;
const TWO_HOURS = 2 * ONE_HOUR;
const TWENTY_MINUTES = 20 * ONE_MINUTE;
const FIVE_MINUTES = 5 * ONE_MINUTE;
// Nếu đã reset trong 1 giờ gần đây => không reset
if (lastResetTime && now.getTime() - lastResetTime.getTime() < ONE_HOUR) {
return {
shouldReset: false,
};
}
// 1. Kiểm tra bid gần nhất có close_time trong vòng 20 phút tới
const futureBids = bids
.filter((b) => b.close_time)
.map((b) => ({
...b,
closeTime: new Date(b.close_time!),
}))
.filter((b) => b.closeTime.getTime() > now.getTime())
.sort((a, b) => a.closeTime.getTime() - b.closeTime.getTime());
const closest = futureBids[0];
const hasBidCloseSoon =
closest && closest.closeTime.getTime() - now.getTime() <= TWENTY_MINUTES;
if (hasBidCloseSoon) {
return {
shouldReset: true,
reason: 'Bid close_time is within 20 minutes',
bidId: closest.id,
closeTime: closest.close_time
};
}
// 2. Kiểm tra bid chưa có lot_id hoặc close_time, được tạo > 5 phút và cách reset trước > 2 tiếng
// Đồng thời không có bid nào sắp close trong 20 phút tới
if (!hasBidCloseSoon) {
for (const bid of bids) {
const createdAt = new Date(bid.created_at);
if (
(!bid.lot_id || !bid.close_time) &&
now.getTime() - createdAt.getTime() > FIVE_MINUTES &&
(!lastResetTime ||
now.getTime() - lastResetTime.getTime() > TWO_HOURS)
) {
return {
shouldReset: true,
reason:
'Bid is missing info and older than 5 mins, last reset > 2h, and no urgent bids',
bidId: bid.id,
closeTime: bid.close_time
};
}
}
}
bids: Bid[],
lastResetTime: Date | null,
now: Date = new Date(),
) {
const ONE_MINUTE = 60 * 1000;
const ONE_HOUR = 60 * ONE_MINUTE;
const TWO_HOURS = 2 * ONE_HOUR;
const TWENTY_MINUTES = 20 * ONE_MINUTE;
const FIVE_MINUTES = 5 * ONE_MINUTE;
// Nếu đã reset trong 1 giờ gần đây => không reset
if (lastResetTime && now.getTime() - lastResetTime.getTime() < ONE_HOUR) {
return {
shouldReset: false,
};
}
}
// 1. Kiểm tra bid gần nhất có close_time trong vòng 20 phút tới
const futureBids = bids
.filter((b) => b.close_time)
.map((b) => ({
...b,
closeTime: new Date(b.close_time!),
}))
.filter((b) => b.closeTime.getTime() > now.getTime())
.sort((a, b) => a.closeTime.getTime() - b.closeTime.getTime());
const closest = futureBids[0];
const hasBidCloseSoon =
closest && closest.closeTime.getTime() - now.getTime() <= TWENTY_MINUTES;
if (hasBidCloseSoon) {
return {
shouldReset: true,
reason: 'Bid close_time is within 20 minutes',
bidId: closest.id,
closeTime: closest.close_time,
};
}
// 2. Kiểm tra bid chưa có lot_id hoặc close_time, được tạo > 5 phút và cách reset trước > 2 tiếng
// Đồng thời không có bid nào sắp close trong 20 phút tới
if (!hasBidCloseSoon) {
for (const bid of bids) {
const createdAt = new Date(bid.created_at);
if (
(!bid.lot_id || !bid.close_time) &&
now.getTime() - createdAt.getTime() > FIVE_MINUTES &&
(!lastResetTime || now.getTime() - lastResetTime.getTime() > TWO_HOURS)
) {
return {
shouldReset: true,
reason:
'Bid is missing info and older than 5 mins, last reset > 2h, and no urgent bids',
bidId: bid.id,
closeTime: bid.close_time,
};
}
}
}
return {
shouldReset: false,
};
}
export function extractNumber(str: string) {
const match = str.match(/\d+(\.\d+)?/);
return match ? parseFloat(match[0]) : null;
}

View File

@ -13,7 +13,7 @@ import browser from "./system/browser.js";
import configs from "./system/config.js";
import {
delay,
findNearestClosingChild,
extractModelId,
isTimeReached,
safeClosePage,
subtractSeconds,
@ -274,21 +274,6 @@ const clearLazyTab = async () => {
// product tabs
const productTabs = _.flatMap(MANAGER_BIDS, "children");
// for (const item of [...productTabs, ...MANAGER_BIDS]) {
// if (!item.page_context) continue;
// try {
// const avalableResult = await isPageAvailable(item.page_context);
// if (!avalableResult) {
// await safeClosePage(item);
// }
// } catch (e) {
// console.warn("⚠️ Error checking page_context.title()", e.message);
// await safeClosePage(item);
// }
// }
for (const page of pages) {
try {
if (page.isClosed()) continue; // Trang đã đóng thì bỏ qua
@ -309,7 +294,7 @@ const clearLazyTab = async () => {
if (!isTimeReached(earlyTrackingTime)) {
await safeClosePage(productTab);
console.log(`🛑 Unused page detected: ${pageUrl}`);
console.log(`🛑 Unused page detectedd: ${pageUrl}`);
continue;
}
@ -317,6 +302,22 @@ const clearLazyTab = async () => {
continue;
}
const modelProductTab = extractModelId(pageUrl);
if (modelProductTab) {
const productWatingUpdate = productTabs.find(
(item) =>
item.model === modelProductTab &&
isTimeReached(item.close_time) &&
item.status === "biding"
);
if (productWatingUpdate) {
console.log("Waiting product update to close");
continue;
}
}
// remove all listents
page.removeAllListeners();

View File

@ -0,0 +1,175 @@
import fs from "fs";
import configs from "../../system/config.js";
import { getPathProfile, safeClosePage } from "../../system/utils.js";
import { ApiBid } from "../api-bid.js";
export class AllbidsApiBid extends ApiBid {
reloadInterval;
constructor({ ...prev }) {
super(prev);
}
isLogin = async () => {
if (!this.page_context) return false;
const filePath = getPathProfile(this.origin_url);
const currentUrl = await this.page_context.url();
console.log({
filePath,
currentUrl,
a: currentUrl.includes(configs.WEB_URLS.ALLBIDS.LOGIN_URL),
});
if (currentUrl.includes(configs.WEB_URLS.ALLBIDS.LOGIN_URL)) return false;
return (
!(await this.page_context.$('input[name="Username"]')) &&
fs.existsSync(filePath)
);
};
async handleLogin() {
const page = this.page_context;
global.IS_CLEANING = false;
const filePath = getPathProfile(this.origin_url);
await page.waitForNavigation({ waitUntil: "domcontentloaded" });
// 🛠 Check if already logged in (login input should not be visible or profile exists)
if (await this.isLogin()) {
console.log(`✅ [${this.id}] Already logged in, skipping login process.`);
return;
}
if (fs.existsSync(filePath)) {
console.log(`🗑 [${this.id}] Deleting existing file: ${filePath}`);
fs.unlinkSync(filePath);
}
const children = this.children.filter((item) => item.page_context);
console.log(
`🔍 [${this.id}] Found ${children.length} child pages to close.`
);
if (children.length > 0) {
console.log(`🛑 [${this.id}] Closing child pages...`);
await Promise.all(
children.map((item) => {
console.log(
`➡ [${this.id}] Closing child page with context: ${item.page_context}`
);
return safeClosePage(item);
})
);
console.log(
`➡ [${this.id}] Closing main page context: ${this.page_context}`
);
await safeClosePage(this);
await this.onCloseLogin(this);
}
console.log(`🔑 [${this.id}] Starting login process...`);
try {
// ⌨ Enter email
console.log(`✍ [${this.id}] Entering email:`, this.username);
await page.type('input[name="Username"]', this.username, {
delay: 100,
});
// ⌨ Enter password
console.log(`✍ [${this.id}] Entering password...`);
await page.type('input[name="Password"]', this.password, {
delay: 150,
});
// 🚀 Click the login button
console.log(`🔘 [${this.id}] Clicking the "Login" button`);
await page.click("#btnSignIn", { delay: 92 });
// ⏳ Wait for navigation after login
console.log(`⏳ [${this.id}] Waiting for navigation after login...`);
await page.waitForNavigation({
timeout: 8000,
waitUntil: "domcontentloaded",
});
console.log(`🌍 [${this.id}] Current page after login:`, page.url());
await page.goto(this.url, { waitUntil: "networkidle2" });
// 📂 Save session context to avoid re-login
await this.saveContext();
console.log(`✅ [${this.id}] Login successful!`);
// await page.goto(this.url);
console.log(`✅ [${this.id}] Navigation successful!`);
} catch (error) {
console.error(
`❌ [${this.id}] Error during login process:`,
error.message
);
} finally {
global.IS_CLEANING = true;
}
}
action = async () => {
try {
const page = this.page_context;
page.on("response", async (response) => {
const request = response.request();
if (request.redirectChain().length > 0) {
if (response.url().includes(configs.WEB_CONFIGS.ALLBIDS.LOGIN_URL)) {
await this.handleLogin();
}
}
});
await page.goto(this.url, { waitUntil: "networkidle2" });
await page.bringToFront();
// Set userAgent
await page.setUserAgent(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
);
} catch (error) {
console.log("Error [action]: ", error.message);
}
};
listen_events = async () => {
if (this.page_context) return;
const results = await this.handlePrevListen();
if (!results) return;
this.reloadInterval = setInterval(async () => {
try {
if (this.page_context && !this.page_context.isClosed()) {
console.log(`🔄 [${this.id}] Reloading page...`);
await this.page_context.reload({ waitUntil: "networkidle2" });
console.log(`✅ [${this.id}] Page reloaded successfully.`);
// this.handleUpdateWonItem();
} else {
console.log(
`❌ [${this.id}] Page context is closed. Stopping reload.`
);
clearInterval(this.reloadInterval);
}
} catch (error) {
console.error(`🚨 [${this.id}] Error reloading page:`, error.message);
}
}, 60000); // 1p reload
};
}

View File

@ -0,0 +1,290 @@
import _ from "lodash";
import { outBid, pushPrice, updateBid } from "../../system/apis/bid.js";
import { sendMessage } from "../../system/apis/notification.js";
import { createOutBidLog } from "../../system/apis/out-bid-log.js";
import configs from "../../system/config.js";
import CONSTANTS from "../../system/constants.js";
import {
convertAETtoUTC,
isTimeReached,
removeFalsyValues,
takeSnapshot,
} from "../../system/utils.js";
import { ProductBid } from "../product-bid.js";
export class AllbidsProductBid extends ProductBid {
constructor({ ...prev }) {
super(prev);
}
async waitForApiResponse() {
if (!this.page_context) return;
try {
// Chờ cho Angular load (có thể tùy chỉnh thời gian nếu cần)
await this.page_context.waitForFunction(
() => window.angular !== undefined
);
const auctionData = await this.page_context.evaluate(() => {
let data = null;
const elements = document.querySelectorAll(".ng-scope");
for (let i = 0; i < elements.length; i++) {
try {
const scope = angular.element(elements[i]).scope();
if (scope?.auction) {
data = scope.auction;
break;
}
// Thử lấy từ $parent nếu không thấy
if (scope?.$parent?.auction) {
data = scope.$parent.auction;
break;
}
} catch (e) {
// Angular element có thể lỗi nếu phần tử không hợp lệ
continue;
}
}
return data;
});
return auctionData;
} catch (error) {
console.log(
`[${this.id}] Error in waitForApiResponse: ${error?.message}`
);
}
}
async handleUpdateBid({
lot_id,
close_time,
name,
current_price,
reserve_price,
model,
}) {
const response = await updateBid(this.id, {
lot_id,
close_time,
name,
current_price,
reserve_price: Number(reserve_price) || 0,
model,
});
if (response) {
this.lot_id = response.lot_id;
this.close_time = response.close_time;
this.start_bid_time = response.start_bid_time;
}
}
async submitBid() {
if (!this.page_context) return;
const response = await this.page_context.evaluate(
async (aucID, bidAmount, submitUrl) => {
try {
const url = `${submitUrl}?aucID=${aucID}&bidAmount=${bidAmount}&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,
this.max_price,
configs.WEB_URLS.ALLBIDS.PLACE_BID
);
return response;
}
update = async () => {
if (!this.page_context) return;
console.log(`🔄 [${this.id}] Call update for ID: ${this.id}`);
// 📌 Chờ phản hồi API từ trang, tối đa 10 giây
const result = await this.waitForApiResponse();
// 📌 Nếu không có dữ liệu trả về thì dừng
if (!result) {
console.log(`⚠️ [${this.id}] No valid data received, skipping update.`);
return;
}
// 📌 Loại bỏ các giá trị không hợp lệ và bổ sung thông tin cần thiết
const data = removeFalsyValues(
{
// model: result?.pid || null,
lot_id: String(result?.aucCurrentBidID) || null,
reserve_price: result?.aucBidIncrement || null,
current_price: result.aucCurrentBid || null,
close_time: result?.aucCloseUtc
? new Date(result.aucCloseUtc).toUTCString()
: null,
// close_time: close_time && !this.close_time ? String(close_time) : null, // test
name: result?.aucTitle || null,
},
["close_time"]
);
console.log(`🚀 [${this.id}] Processed data ready for update`);
// 📌 Gửi dữ liệu cập nhật lên hệ thống
await this.handleUpdateBid(data);
console.log("✅ Update successful!");
return { ...response, name: data.name, close_time: data.close_time };
};
async handlePlaceBid() {
if (!this.page_context) {
console.log(
`⚠️ [${this.id}] No page context found, aborting bid process.`
);
return;
}
const page = this.page_context;
if (global[`IS_PLACE_BID-${this.id}`]) {
console.log(`⚠️ [${this.id}] Bid is already in progress, skipping.`);
return;
}
try {
console.log(`🔄 [${this.id}] Starting bid process...`);
global[`IS_PLACE_BID-${this.id}`] = true;
// Đợi phản hồi từ API
const response = await this.waitForApiResponse();
if (
!response ||
isTimeReached(new Date(response.aucCloseUtc).toUTCString())
) {
console.log(
`⚠️ [${this.id}] Outbid detected, calling outBid function.`
);
await outBid(this.id);
return;
}
// Kiểm tra nếu giá hiện tại lớn hơn giá tối đa cộng thêm giá cộng thêm
if (this.current_price > this.max_price + this.plus_price) {
console.log(`⚠️ [${this.id}] Outbid bid`); // Ghi log cảnh báo nếu giá hiện tại vượt quá mức tối đa cho phép
return; // Dừng hàm nếu giá đã vượt qua giới hạn
}
// Kiểm tra thời gian bid
if (this.start_bid_time && !isTimeReached(this.start_bid_time)) {
console.log(
`⏳ [${this.id}] Not yet time to bid. Skipping Product: ${
this.name || "None"
}`
);
return;
}
// Kiểm tra nếu phản hồi không tồn tại hoặc nếu giá đấu của người dùng bằng với giá tối đa hiện tại
if (
!response ||
(response?.aucUserMaxBid && response.aucUserMaxBid == this.max_price) ||
response?.aucBidIncrement > this.max_price
) {
console.log(
`⚠️ [${this.id}] No response or myBid equals max_price:`,
response
); // Ghi log nếu không có phản hồi hoặc giá đấu của người dùng bằng giá tối đa
return; // Nếu không có phản hồi hoặc giá đấu bằng giá tối đa thì dừng hàm
}
const bidHistoriesItem = _.maxBy(this.histories, "price");
console.log(`📜 [${this.id}] Current bid history:`, this.histories);
if (
bidHistoriesItem &&
bidHistoriesItem?.price === this.current_price &&
this.max_price == response?.aucUserMaxBid
) {
console.log(
`🔄 [${this.id}] You have already bid on this item! (Bid Price: ${bidHistoriesItem.price})`
);
return;
}
console.log("---------------------BIDDING--------------------");
const data = await this.submitBid();
console.log({ data });
await this.page_context.reload({ waitUntil: "networkidle0" });
const { aucUserMaxBid } = await this.waitForApiResponse();
console.log(`📡 [${this.id}] API Response received:`, lotData);
// 📌 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".`
);
} 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;
}
}
action = async () => {
try {
const page = this.page_context;
// 📌 Kiểm tra nếu trang chưa tải đúng URL thì điều hướng đến URL mục tiêu
if (!page.url() || !page.url().includes(this.url)) {
console.log(`🔄 [${this.id}] Navigating to target URL: ${this.url}`);
await this.gotoLink();
}
await this.handlePlaceBid();
} catch (error) {
console.error(`🚨 [${this.id}] Error navigating the page: ${error}`);
}
};
}

View File

@ -339,6 +339,13 @@ export class GraysProductBid extends ProductBid {
);
if (isBided) {
if (this.histories.length <= 0 && isTimeReached(this.start_bid_time)) {
pushPrice({
bid_id: this.id,
price: this.max_price,
});
}
console.log(`[${this.id}] This item bided. Skipping...`);
global[`IS_PLACE_BID-${this.id}`] = false;
global.IS_CLEANING = true;

View File

@ -3,48 +3,54 @@
// 1 : Apibids
// 2 : Producttab
const { default: puppeteer } = require('puppeteer');
const { default: puppeteer } = require("puppeteer");
Apibids = {
type: 'Apibid',
puppeteer_connect: 'puppeteer_connect',
url: 'https://www.grays.com/mygrays/auctions/biddingon.aspx',
listentEvent: function () {
//action()
},
action: function () {},
type: "Apibid",
puppeteer_connect: "puppeteer_connect",
url: "https://www.grays.com/mygrays/auctions/biddingon.aspx",
listentEvent: function () {
//action()
},
action: function () {},
};
// n Producttab
Producttab = {
type: 'Producttab',
url: 'https://www.grays.com/mygrays/auctions/biddingon.aspx',
puppeteer_connect: 'puppeteer_connect',
max_price: '',
model: 'model',
type: "Producttab",
url: "https://www.grays.com/mygrays/auctions/biddingon.aspx",
puppeteer_connect: "puppeteer_connect",
max_price: "",
model: "model",
action: function () {},
action: function () {},
};
manage = {
Apibids: { Apibids },
Producttabss: [{ Producttab }, { Producttab }],
Apibids: { Apibids },
Producttabss: [{ Producttab }, { Producttab }],
};
// 10 goi 1 lan
recheck = function name() {
//check thay 2 record
//tabApibids
Apibids = new Apibids();
Apibids.puppeteer_connect();
Apibids.listentEvent();
manage.Apibids = Apibids;
//n Producttab
//check thay 2 record
//tabApibids
Apibids = new Apibids();
Apibids.puppeteer_connect();
Apibids.listentEvent();
manage.Apibids = Apibids;
//n Producttab
};
```
\*\* Tắt polling trước khi demo
<!-- all bids -->
<!-- Lấy detail info của sản phẩm -->
- Trong thời gian đang bid nên mỡ tab lên -> hiện tại không mỡ tab lên khi start lại
- Handle đăng nhập lại nếu không thành công -> hiện đang không đăng nhập lại nếu vì lí do nào đó không đăng nhập được
- Lịch sử bid đang có 2 lần trùng
let data = null; const elements = document.querySelectorAll('.ng-scope');
for (let i = 0; i < elements.length; i++) {
const scope = angular.element(elements[i]).scope();
if (scope && scope.auction) {
console.log('Found at index:', i, 'Auction:', scope.auction); data = scope.auction;
break; // dừng vòng lặp khi tìm thấy
}
}

View File

@ -1,5 +1,7 @@
import * as fs from "fs";
import path from "path";
import { AllbidsApiBid } from "../models/allbids.com.au/allbids-api-bid.js";
import { AllbidsProductBid } from "../models/allbids.com.au/allbids-product-bid.js";
import { GrayApiBid } from "../models/grays.com/grays-api-bid.js";
import { GraysProductBid } from "../models/grays.com/grays-product-bid.js";
import { LangtonsApiBid } from "../models/langtons.com.au/langtons-api-bid.js";
@ -39,6 +41,9 @@ export const createBidProduct = (web, data) => {
case configs.WEB_URLS.PICKLES: {
return new PicklesProductBid({ ...data });
}
case configs.WEB_URLS.ALLBIDS: {
return new AllbidsProductBid({ ...data });
}
}
};
@ -56,6 +61,9 @@ export const createApiBid = (web) => {
case configs.WEB_URLS.PICKLES: {
return new PicklesApiBid({ ...web });
}
case configs.WEB_URLS.ALLBIDS: {
return new AllbidsApiBid({ ...web });
}
}
};

View File

@ -7,6 +7,7 @@ const configs = {
LANGTONS: `https://www.langtons.com.au`,
LAWSONS: `https://www.lawsons.com.au`,
PICKLES: `https://www.pickles.com.au`,
ALLBIDS: `https://www.allbids.com.au`,
},
WEB_CONFIGS: {
GRAYS: {
@ -39,6 +40,10 @@ const configs = {
API_CHECKOUT:
"https://www.pickles.com.au/delegate/secured/bidding/confirm",
},
ALLBIDS: {
LOGIN_URL: "https://myaccount.allbids.com.au/account/login",
PLACE_BID: "https://www.allbids.com.au/Bid/AjaxFinishBid",
},
},
};

View File

@ -322,3 +322,43 @@ export function findNearestClosingChild(webBid) {
return nearestChild || null;
}
export function extractModelId(url) {
try {
switch (extractDomain(url)) {
case "https://www.grays.com": {
const match = url.match(/\/lot\/([\d-]+)\//);
return match ? match[1] : null;
}
case "https://www.langtons.com.au": {
const match = url.match(/auc-var-\d+/);
return match[0];
}
case "https://www.lawsons.com.au": {
const match = url.split("_");
return match ? match[1] : null;
}
case "https://www.pickles.com.au": {
const model = url.split("/").pop();
return model ? model : null;
}
case "https://www.allbids.com.au": {
const match = url.match(/-(\d+)(?:[\?#]|$)/);
return match ? match[1] : null;
}
default:
return null;
}
} catch (error) {
return null;
}
}
export function extractDomain(url) {
try {
const parsedUrl = new URL(url);
return parsedUrl.origin;
} catch (error) {
return null;
}
}

BIN
bid-extension.zip Normal file

Binary file not shown.

Binary file not shown.

View File

@ -1,134 +0,0 @@
/* #bid-extension body {
margin: 0;
padding: 0;
background-color: #121212;
color: #e0e0e0;
font-family: 'Segoe UI', Tahoma, sans-serif;
width: 320px;
} */
#bid-extension {
margin: 0;
padding: 0;
background-color: #121212;
color: #e0e0e0;
font-family: "Segoe UI", Tahoma, sans-serif;
width: 320px;
}
#bid-extension .container {
display: flex;
flex-direction: column;
gap: 10px;
}
#bid-extension h2 {
text-align: center;
margin-bottom: 12px;
font-size: 22px;
color: #ffffff;
}
#bid-extension label {
font-size: 13px;
margin-bottom: 2px;
}
#bid-extension input,
#bid-extension textarea {
padding: 8px;
background-color: #1e1e1e;
color: #ffffff;
border: 1px solid #333;
border-radius: 6px;
font-size: 14px;
width: 100%;
box-sizing: border-box;
}
#bid-extension input:focus,
#bid-extension textarea:focus {
border-color: #4a90e2;
outline: none;
}
#bid-extension .row {
display: flex;
gap: 10px;
}
#bid-extension .col {
flex: 1;
display: flex;
flex-direction: column;
}
#bid-extension .inputs .col {
padding-left: 0px;
padding-right: 0px;
}
#bid-extension button {
margin-top: 10px;
padding: 10px;
background: linear-gradient(to right, #4a90e2, #357abd);
color: white;
border: none;
border-radius: 6px;
font-size: 15px;
cursor: pointer;
transition: background 0.3s ease;
}
#bid-extension button:hover {
background: linear-gradient(to right, #3a78c2, #2d5faa);
}
#bid-extension #errorMessage {
margin-top: 2px;
font-size: 11px;
}
#bid-extension .wrapper {
position: relative;
}
#bid-extension .key-container {
position: absolute;
top: 20px;
left: 20px;
}
#bid-extension .key-container a {
color: #ffffff;
font-size: 14px;
padding: 4px 10px;
background: linear-gradient(to right, #4a90e2, #357abd);
border-radius: 6px;
text-decoration: none;
display: inline-block;
transition: all 0.3s ease;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.2);
}
#bid-extension .key-container a:hover {
background: linear-gradient(to right, #3a78c2, #2d5faa);
box-shadow: 0 6px 10px rgba(0, 0, 0, 0.3);
transform: translateY(-2px);
}
#bid-extension .inputs {
display: flex;
flex-direction: column;
gap: 8px;
}
#bid-extension svg {
width: 14px;
height: 14px;
}
#toggle-bid-extension svg {
width: 20px;
height: 20px;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 305 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 522 B

View File

@ -1,4 +0,0 @@
chrome.action.onClicked.addListener((tab) => {
// Lấy URL của tab hiện tại
console.log("Current URL:", tab.url);
});

View File

@ -1,6 +0,0 @@
// config.js
const CONFIG = {
API_BASE_URL: "http://localhost:4000/api/v1",
};
export default CONFIG;

View File

@ -1,394 +0,0 @@
const CONFIG = {
API_BASE_URL: "http://localhost:4000/api/v1",
// API_BASE_URL: "https://bids.apactech.io/api/v1",
};
let PREV_DATA = null;
function removeFalsyValues(obj, excludeKeys = []) {
return Object.entries(obj).reduce((acc, [key, value]) => {
if (value || excludeKeys.includes(key)) {
acc[key] = value;
}
return acc;
}, {});
}
function extractDomain(url) {
try {
const parsedUrl = new URL(url);
return parsedUrl.origin;
} catch (error) {
return null;
}
}
function extractModelId(url) {
switch (extractDomain(url)) {
case "https://www.grays.com": {
const match = url.match(/\/lot\/([\d-]+)\//);
return match ? match[1] : null;
}
case "https://www.langtons.com.au": {
const match = url.match(/auc-var-\d+/);
return match[0];
}
case "https://www.lawsons.com.au": {
const match = url.split("_");
return match ? match[1] : null;
}
case "https://www.pickles.com.au": {
const model = url.split("/").pop();
return model ? model : null;
}
}
}
const showPage = async (pageLink = "pages/popup/popup.html") => {
const res = await fetch(chrome.runtime.getURL(pageLink));
const html = await res.text();
const wrapper = document.createElement("div");
wrapper.innerHTML = html;
document.body.appendChild(wrapper);
};
const getKey = () => {
return new Promise((resolve, reject) => {
chrome.storage.local.get("key", (result) => {
if (chrome.runtime.lastError) {
reject(chrome.runtime.lastError);
} else {
resolve(result.key || null);
}
});
});
};
async function handleCreate(event, formElements) {
event.preventDefault();
const key = await getKey();
if (!key) {
showKey();
return;
}
const maxPrice = parseFloat(formElements.maxPrice.value);
const plusPrice = parseFloat(formElements.plusPrice.value);
const quantity = parseInt(formElements.quantity.value, 10);
const payload = {
url: formElements.url.value.trim(),
max_price: isNaN(maxPrice) ? null : maxPrice,
plus_price: isNaN(plusPrice) ? null : plusPrice,
quantity: isNaN(quantity) ? null : quantity,
};
// Validate required fields
if (!payload.url || payload.max_price === null) {
alert("Please fill out the URL and Max Price fields correctly.");
return;
}
try {
const response = await fetch(`${CONFIG.API_BASE_URL}/bids`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: key,
},
body: JSON.stringify(removeFalsyValues(payload)),
});
const result = await response.json();
alert(result.message);
// showInfo
await showInfo(extractModelId(payload.url), formElements);
// handleChangeTitleButton
handleChangeTitleButton(true, formElements);
} catch (error) {
alert("Error: " + error.message);
console.error("API Error:", error);
}
}
async function handleUpdate(event, formElements, id) {
event.preventDefault();
const key = await getKey();
if (!key) {
showKey();
return;
}
const maxPrice = parseFloat(formElements.maxPrice.value);
const plusPrice = parseFloat(formElements.plusPrice.value);
const quantity = parseInt(formElements.quantity.value, 10);
const payload = {
max_price: isNaN(maxPrice) ? null : maxPrice,
plus_price: isNaN(plusPrice) ? null : plusPrice,
quantity: isNaN(quantity) ? null : quantity,
};
// Validate required fields
if (payload.max_price === null) {
alert("Please fill out the URL and Max Price fields correctly.");
return;
}
try {
const response = await fetch(`${CONFIG.API_BASE_URL}/bids/info/${id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: key,
},
body: JSON.stringify(removeFalsyValues(payload)),
});
const result = await response.json();
alert(result.message);
} catch (error) {
alert("Error: " + error.message);
console.error("API Error:", error);
}
}
const showBid = () => {
const formKey = document.getElementById("form-key");
const formBid = document.getElementById("form-bid");
formKey.style.display = "none";
formBid.style.display = "block";
};
const showKey = async () => {
const key = await getKey();
const formKey = document.getElementById("form-key");
const formBid = document.getElementById("form-bid");
const keyEl = document.querySelector("#form-key #key");
formBid.style.display = "none";
formKey.style.display = "block";
if (key && keyEl) {
keyEl.value = key;
}
};
const handleToogle = async () => {
const btn = document.getElementById("toggle-bid-extension");
const panel = document.getElementById("bid-extension");
// Kiểm tra xem nút và panel có tồn tại hay không
if (btn && panel) {
btn.addEventListener("click", async () => {
panel.style.display = panel.style.display === "none" ? "block" : "none";
await handleShowForm();
});
} else {
console.error("Không tìm thấy nút hoặc panel!");
}
};
const handleShowForm = async () => {
const toggleBtn = document.getElementById("toggle-bid-extension");
const formBid = document.getElementById("form-bid");
const formKey = document.getElementById("form-key");
const keyBtn = document.getElementById("key-btn");
const isVisible = (el) => el && el.style.display !== "none";
// Toggle hiển thị form hiện tại (bid hoặc key)
toggleBtn?.addEventListener("click", async () => {
if (isVisible(formBid)) {
formBid.style.display = "none";
} else if (isVisible(formKey)) {
formKey.style.display = "none";
} else {
const currentKey = await getKey();
if (!currentKey) {
showKey();
} else {
showBid();
}
}
});
// Nhấn vào icon key để chuyển sang form-key
keyBtn?.addEventListener("click", () => {
showKey();
});
};
const handleChangeTitleButton = (result, formElements) => {
if (result) {
formElements.createBtn.textContent = "Update";
} else {
formElements.createBtn.textContent = "Create";
}
};
const handleSaveKey = () => {
const form = document.querySelector("#form-key form");
if (!form) return;
form.addEventListener("submit", async (e) => {
e.preventDefault();
const inputKey = form.querySelector("#key");
if (!inputKey) return;
const keyValue = inputKey.value.trim();
if (!keyValue) {
alert("Please enter a key");
return;
}
// Lưu vào chrome.storage.local
chrome.storage.local.set({ key: keyValue }, async () => {
alert("Key saved successfully!");
showBid();
if (!isValidModel()) return;
await showInfo();
});
});
};
const isValidModel = () => {
const currentUrl = window.location.href;
const model = extractModelId(currentUrl);
return !!model;
};
const createInfoColumn = (data, formElements) => {
const inputsContainer = document.querySelector("#bid-extension .inputs");
const urlCol = document.querySelector("#url-col");
if (!inputsContainer || !urlCol) return;
// 1. Thêm ID và Name vào đầu inputsContainer
const otherEls = `
<div class="col">
<label>ID</label>
<input readonly value="${data?.id || "None"}" type="text" id="id" />
</div>
<div class="col">
<label>Name</label>
<textarea readonly id="maxPrice">${data?.name || "None"}</textarea>
</div>
`;
inputsContainer.insertAdjacentHTML("afterbegin", otherEls);
// 2. Tạo và chèn Current Price ngay sau #url-col
const currentPriceDiv = document.createElement("div");
currentPriceDiv.className = "col";
currentPriceDiv.innerHTML = `
<label>Current price</label>
<input readonly type="text" value="${
data?.current_price || "None"
}" id="currentPrice" />
`;
urlCol.parentNode.insertBefore(currentPriceDiv, urlCol.nextSibling);
formElements.quantity.value = data?.quantity || 1;
formElements.plusPrice.value = data?.plus_price || 0;
};
const showInfo = async (model, formElements) => {
const key = await getKey();
if (!key) {
showKey();
return;
}
try {
const response = await fetch(`${CONFIG.API_BASE_URL}/bids/${model}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: key,
},
});
const result = await response.json();
if (!result || result?.status_code !== 200 || !result?.data) {
if (result.status_code !== 404) {
alert(result.message);
}
PREV_DATA = null;
return null;
}
formElements.maxPrice.value = result.data.max_price;
createInfoColumn(result.data, formElements);
PREV_DATA = result;
return result;
} catch (error) {
alert("Error: " + error.message);
console.error("API Error:", error);
}
};
(async () => {
await showPage();
const formElements = {
url: document.querySelector("#form-bid #url"),
maxPrice: document.querySelector("#form-bid #maxPrice"),
plusPrice: document.querySelector("#form-bid #plusPrice"),
quantity: document.querySelector("#form-bid #quantity"),
createBtn: document.querySelector("#form-bid #createBtn"),
form: document.querySelector("#form-bid form"),
};
const style = document.createElement("link");
style.rel = "stylesheet";
style.href = chrome.runtime.getURL("assets/css/index.css");
document.head.appendChild(style);
const script = document.createElement("script");
script.type = "module";
script.src = chrome.runtime.getURL("pages/popup/popup.js");
script.defer = true;
document.body.appendChild(script);
handleSaveKey();
const currentUrl = window.location.href;
const model = extractModelId(currentUrl);
if (!model) return;
// set url on form
formElements.url.value = currentUrl;
await showInfo(model, formElements);
handleChangeTitleButton(!!PREV_DATA, formElements);
formElements.form.addEventListener("submit", (e) =>
PREV_DATA
? handleUpdate(e, formElements, PREV_DATA.data.id)
: handleCreate(e, formElements)
);
})();

View File

@ -1,42 +0,0 @@
{
"manifest_version": 3,
"name": "Bid Extension",
"version": "1.0",
"description": "Bid Extension",
"action": {
"default_popup": "pages/popup/popup.html",
"default_icon": {
"16": "assets/icons/16.png",
"32": "assets/icons/32.png",
"128": "assets/icons/128.png"
}
},
"background": {
"service_worker": "background.js"
},
"permissions": ["storage"],
"host_permissions": ["http://*/*", "https://*/*"],
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["content.js"]
}
],
"web_accessible_resources": [
{
"resources": [
"pages/popup/popup.html",
"pages/popup/popup.js",
"assets/css/index.css",
"config.js",
"assets/icons/*"
],
"matches": ["<all_urls>"]
}
],
"icons": {
"16": "assets/icons/16.png",
"32": "assets/icons/32.png",
"128": "assets/icons/128.png"
}
}

View File

@ -1,170 +0,0 @@
<div
id="bid-toggle-container"
style="position: fixed; bottom: 20px; left: 20px; z-index: 9999"
>
<button
id="toggle-bid-extension"
style="
padding: 12px 20px;
background: #2c2f36;
color: #ffffff;
border: none;
border-radius: 9999px;
font-size: 15px;
font-weight: 500;
cursor: pointer;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
transition: background 0.3s ease, transform 0.2s ease;
"
onmouseover="this.style.background='#3a3d44'; this.style.transform='scale(1.05)'"
onmouseout="this.style.background='#2c2f36'; this.style.transform='scale(1)'"
>
<svg
fill="#ffffff"
version="1.1"
id="Capa_1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
x="0px"
y="0px"
width="20px"
height="20px"
viewBox="0 0 494.212 494.212"
style="enable-background: new 0 0 494.212 494.212"
xml:space="preserve"
>
<g>
<path
d="M483.627,401.147L379.99,297.511c-7.416-7.043-16.084-10.567-25.981-10.567c-10.088,0-19.222,4.093-27.401,12.278
l-73.087-73.087l35.98-35.976c2.663-2.667,3.997-5.901,3.997-9.71c0-3.806-1.334-7.042-3.997-9.707
c0.377,0.381,1.52,1.569,3.423,3.571c1.902,2,3.142,3.188,3.72,3.571c0.571,0.378,1.663,1.328,3.278,2.853
c1.625,1.521,2.901,2.475,3.856,2.853c0.958,0.378,2.245,0.95,3.867,1.713c1.615,0.761,3.183,1.283,4.709,1.57
c1.522,0.284,3.237,0.428,5.14,0.428c7.228,0,13.703-2.665,19.411-7.995c0.574-0.571,2.286-2.14,5.14-4.712
c2.861-2.574,4.805-4.377,5.855-5.426c1.047-1.047,2.621-2.806,4.716-5.28c2.091-2.475,3.569-4.57,4.425-6.283
c0.853-1.711,1.708-3.806,2.57-6.28c0.855-2.474,1.279-4.949,1.279-7.423c0-7.614-2.665-14.087-7.994-19.417L236.41,8.003
c-5.33-5.33-11.802-7.994-19.413-7.994c-2.474,0-4.948,0.428-7.426,1.283c-2.475,0.854-4.567,1.713-6.28,2.568
c-1.714,0.855-3.806,2.331-6.28,4.427c-2.474,2.094-4.233,3.665-5.282,4.712c-1.047,1.049-2.855,3-5.424,5.852
c-2.572,2.856-4.143,4.57-4.712,5.142c-5.327,5.708-7.994,12.181-7.994,19.414c0,1.903,0.144,3.616,0.431,5.137
c0.288,1.525,0.809,3.094,1.571,4.714c0.76,1.618,1.331,2.903,1.713,3.853c0.378,0.95,1.328,2.24,2.852,3.858
c1.525,1.615,2.475,2.712,2.856,3.284c0.378,0.575,1.571,1.809,3.567,3.715c2,1.902,3.193,3.049,3.571,3.427
c-2.664-2.667-5.901-3.999-9.707-3.999s-7.043,1.331-9.707,3.999l-99.371,99.357c-2.667,2.666-3.999,5.901-3.999,9.707
c0,3.809,1.331,7.045,3.999,9.71c-0.381-0.381-1.524-1.574-3.427-3.571c-1.902-2-3.14-3.189-3.711-3.571
c-0.571-0.378-1.665-1.328-3.283-2.852c-1.619-1.521-2.905-2.474-3.855-2.853c-0.95-0.378-2.235-0.95-3.854-1.714
c-1.615-0.76-3.186-1.282-4.71-1.569c-1.521-0.284-3.234-0.428-5.137-0.428c-7.233,0-13.709,2.664-19.417,7.994
c-0.568,0.57-2.284,2.144-5.138,4.712c-2.856,2.572-4.803,4.377-5.852,5.426c-1.047,1.047-2.615,2.806-4.709,5.281
c-2.093,2.474-3.571,4.568-4.426,6.283c-0.856,1.709-1.709,3.806-2.568,6.28C0.432,212.061,0,214.535,0,217.01
c0,7.614,2.665,14.082,7.994,19.414l116.485,116.481c5.33,5.328,11.803,7.991,19.414,7.991c2.474,0,4.948-0.422,7.426-1.277
c2.475-0.855,4.567-1.714,6.28-2.569c1.713-0.855,3.806-2.327,6.28-4.425s4.233-3.665,5.28-4.716
c1.049-1.051,2.856-2.995,5.426-5.855c2.572-2.851,4.141-4.565,4.712-5.14c5.327-5.709,7.994-12.184,7.994-19.411
c0-1.902-0.144-3.617-0.431-5.14c-0.288-1.526-0.809-3.094-1.571-4.716c-0.76-1.615-1.331-2.902-1.713-3.854
c-0.378-0.951-1.328-2.238-2.852-3.86c-1.525-1.615-2.475-2.71-2.856-3.285c-0.38-0.571-1.571-1.807-3.567-3.717
c-2.002-1.902-3.193-3.045-3.571-3.429c2.663,2.669,5.902,4.001,9.707,4.001c3.806,0,7.043-1.332,9.707-4.001l35.976-35.974
l73.086,73.087c-8.186,8.186-12.278,17.312-12.278,27.401c0,10.283,3.621,18.843,10.849,25.7L401.42,483.643
c7.042,7.035,15.604,10.561,25.693,10.561c9.896,0,18.555-3.525,25.981-10.561l30.546-30.841
c7.043-7.043,10.571-15.605,10.571-25.693C494.212,417.231,490.684,408.566,483.627,401.147z"
/>
</g>
<g></g>
<g></g>
<g></g>
<g></g>
<g></g>
<g></g>
<g></g>
<g></g>
<g></g>
<g></g>
<g></g>
<g></g>
<g></g>
<g></g>
<g></g>
</svg>
</button>
</div>
<div
id="bid-extension"
class="wrapper"
style="
display: none;
position: fixed;
bottom: 90px;
left: 20px;
z-index: 9999;
background-color: #1e1e1e;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
padding: 20px;
width: 320px;
"
>
<!-- Form bid -->
<div style="display: none" id="form-bid">
<form class="container">
<h2>Bid</h2>
<div class="inputs">
<div id="url-col" class="col">
<label>Url</label>
<input readonly type="text" id="url" />
</div>
<div class="col">
<label>Max price</label>
<input type="number" id="maxPrice" />
</div>
<div class="col">
<label>Plus price</label>
<input type="number" id="plusPrice" />
</div>
<div class="col">
<label>Quantity</label>
<input type="number" id="quantity" />
</div>
</div>
<button type="submit" id="createBtn">Create</button>
</form>
<div class="key-container">
<span id="key-btn" class="key-btn">
<svg
fill="#ffffff"
height="14px"
width="14px"
viewBox="0 0 367.578 367.578"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M281.541,97.751c0-53.9-43.851-97.751-97.751-97.751S86.038,43.851,86.038,97.751
c0,44.799,30.294,82.652,71.472,94.159v144.668c0,4.026,1.977,9.1,4.701,12.065l14.514,15.798
c1.832,1.993,4.406,3.136,7.065,3.136s5.233-1.143,7.065-3.136l14.514-15.798
c2.724-2.965,4.701-8.039,4.701-12.065v-7.387l14.592-9.363
c2.564-1.646,4.035-4.164,4.035-6.909c0-2.744-1.471-5.262-4.036-6.907l-14.591-9.363v-0.207
l14.592-9.363c2.564-1.646,4.035-4.164,4.035-6.909c0-2.744-1.471-5.262-4.036-6.907l-14.591-9.363v-0.207
l14.592-9.363c2.564-1.646,4.035-4.164,4.035-6.908c0-2.745-1.471-5.263-4.036-6.909l-14.591-9.363V191.91
C251.246,180.403,281.541,142.551,281.541,97.751z
M183.789,104.948c-20.985,0-37.996-17.012-37.996-37.996s17.012-37.996,37.996-37.996
s37.996,17.012,37.996,37.996S204.774,104.948,183.789,104.948z"
/>
</svg>
</span>
</div>
</div>
<!-- Form key -->
<div style="display: block" id="form-key">
<form class="container">
<h2>Key</h2>
<div class="inputs">
<div class="col">
<label>Key</label>
<input type="password" id="key" />
</div>
</div>
<button type="submit" id="saveKeyBtn">Save</button>
</form>
</div>
</div>

View File

@ -1,17 +0,0 @@
const handleToogle = () => {
const btn = document.getElementById("toggle-bid-extension");
const panel = document.getElementById("bid-extension");
// Kiểm tra xem nút và panel có tồn tại hay không
if (btn && panel) {
btn.addEventListener("click", () => {
panel.style.display = panel.style.display === "none" ? "block" : "none";
});
} else {
console.error("Không tìm thấy nút hoặc panel!");
}
};
// init();
handleToogle();