Deploy to staging #45
			
				
			
		
		
		
	| 
						 | 
				
			
			@ -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);
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			@ -217,7 +217,8 @@ export default function WorkingPage({ data, socket }: IWorkingPageProps) {
 | 
			
		|||
          </Box>
 | 
			
		||||
        </Box>
 | 
			
		||||
 | 
			
		||||
        <Box className="absolute top-2.5 left-2.5 flex items-center gap-2">
 | 
			
		||||
        <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"
 | 
			
		||||
| 
						 | 
				
			
			@ -238,6 +239,11 @@ export default function WorkingPage({ data, socket }: IWorkingPageProps) {
 | 
			
		|||
                : 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>
 | 
			
		||||
 | 
			
		||||
      <ShowImageModal
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,23 +1,36 @@
 | 
			
		|||
/* 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;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
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) {
 | 
			
		||||
export default function WebAccountModal({
 | 
			
		||||
  data,
 | 
			
		||||
  onUpdated,
 | 
			
		||||
  ...props
 | 
			
		||||
}: IWebBidModelProps) {
 | 
			
		||||
  const form = useForm({
 | 
			
		||||
    validate: zodResolver(schema),
 | 
			
		||||
  });
 | 
			
		||||
| 
						 | 
				
			
			@ -31,7 +44,7 @@ export default function WebAccountModal({ data, onUpdated, ...props }: IWebBidMo
 | 
			
		|||
  const handleSubmit = async (values: typeof form.values) => {
 | 
			
		||||
    if (data) {
 | 
			
		||||
      setConfirm({
 | 
			
		||||
                title: 'Update ?',
 | 
			
		||||
        title: "Update ?",
 | 
			
		||||
        message: `This account will be update`,
 | 
			
		||||
        handleOk: async () => {
 | 
			
		||||
          setLoading(true);
 | 
			
		||||
| 
						 | 
				
			
			@ -47,8 +60,8 @@ export default function WebAccountModal({ data, onUpdated, ...props }: IWebBidMo
 | 
			
		|||
          }
 | 
			
		||||
        },
 | 
			
		||||
        okButton: {
 | 
			
		||||
                    color: 'blue',
 | 
			
		||||
                    value: 'Update',
 | 
			
		||||
          color: "blue",
 | 
			
		||||
          value: "Update",
 | 
			
		||||
        },
 | 
			
		||||
      });
 | 
			
		||||
    } else {
 | 
			
		||||
| 
						 | 
				
			
			@ -87,23 +100,49 @@ export default function WebAccountModal({ data, onUpdated, ...props }: IWebBidMo
 | 
			
		|||
    <Modal
 | 
			
		||||
      className="relative"
 | 
			
		||||
      classNames={{
 | 
			
		||||
                header: '!flex !item-center !justify-center w-full',
 | 
			
		||||
        header: "!flex !item-center !justify-center w-full",
 | 
			
		||||
      }}
 | 
			
		||||
      {...props}
 | 
			
		||||
            size={'xl'}
 | 
			
		||||
      size={"xl"}
 | 
			
		||||
      title={<span className="text-xl font-bold">Account</span>}
 | 
			
		||||
      centered
 | 
			
		||||
    >
 | 
			
		||||
            <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')} />
 | 
			
		||||
      <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">
 | 
			
		||||
                    {data ? 'Update' : 'Create'}
 | 
			
		||||
        <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 }} />
 | 
			
		||||
      <LoadingOverlay
 | 
			
		||||
        visible={loading}
 | 
			
		||||
        zIndex={1000}
 | 
			
		||||
        overlayProps={{ blur: 2 }}
 | 
			
		||||
      />
 | 
			
		||||
    </Modal>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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(),
 | 
			
		||||
    })
 | 
			
		||||
    .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")}
 | 
			
		||||
        />
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -145,7 +145,7 @@ export default function Bids() {
 | 
			
		|||
      },
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      key: "close_time",
 | 
			
		||||
      key: "close_time_ts",
 | 
			
		||||
      title: "Close time",
 | 
			
		||||
      typeFilter: "date",
 | 
			
		||||
      renderRow(row) {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,14 +1,28 @@
 | 
			
		|||
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({});
 | 
			
		||||
| 
						 | 
				
			
			@ -19,35 +33,36 @@ export default function WebBids() {
 | 
			
		|||
 | 
			
		||||
  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: "id",
 | 
			
		||||
      title: "ID",
 | 
			
		||||
      typeFilter: "number",
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
            key: 'origin_url',
 | 
			
		||||
            title: 'Domain',
 | 
			
		||||
            typeFilter: 'text',
 | 
			
		||||
      key: "origin_url",
 | 
			
		||||
      title: "Domain",
 | 
			
		||||
      typeFilter: "text",
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
            key: 'url',
 | 
			
		||||
            title: 'Tracking url',
 | 
			
		||||
            typeFilter: 'text',
 | 
			
		||||
      key: "url",
 | 
			
		||||
      title: "Tracking url",
 | 
			
		||||
      typeFilter: "text",
 | 
			
		||||
      renderRow(row) {
 | 
			
		||||
                return <Text>{row.url || 'None'}</Text>;
 | 
			
		||||
        return <Text>{row.url || "None"}</Text>;
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
            key: 'active',
 | 
			
		||||
            title: 'Status',
 | 
			
		||||
            typeFilter: '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 color={row.active ? "green" : "red"} size="sm">
 | 
			
		||||
              {row.active ? "Enable" : "Disable"}
 | 
			
		||||
            </Badge>
 | 
			
		||||
          </Box>
 | 
			
		||||
        );
 | 
			
		||||
| 
						 | 
				
			
			@ -55,17 +70,17 @@ export default function WebBids() {
 | 
			
		|||
    },
 | 
			
		||||
 | 
			
		||||
    {
 | 
			
		||||
            key: 'created_at',
 | 
			
		||||
            title: 'Created at',
 | 
			
		||||
            typeFilter: 'none',
 | 
			
		||||
      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',
 | 
			
		||||
      key: "updated_at",
 | 
			
		||||
      title: "Update at",
 | 
			
		||||
      typeFilter: "none",
 | 
			
		||||
      renderRow(row) {
 | 
			
		||||
        return <span>{formatTime(row.updated_at)}</span>;
 | 
			
		||||
      },
 | 
			
		||||
| 
						 | 
				
			
			@ -74,8 +89,8 @@ export default function WebBids() {
 | 
			
		|||
 | 
			
		||||
  const handleDelete = (data: IWebBid) => {
 | 
			
		||||
    setConfirm({
 | 
			
		||||
            title: 'Delete ?',
 | 
			
		||||
            message: 'This web will be delete',
 | 
			
		||||
      title: "Delete ?",
 | 
			
		||||
      message: "This web will be delete",
 | 
			
		||||
      handleOk: async () => {
 | 
			
		||||
        await deleteWebBid(data);
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -88,8 +103,8 @@ export default function WebBids() {
 | 
			
		|||
 | 
			
		||||
  const handleToggle = async (data: IWebBid) => {
 | 
			
		||||
    setConfirm({
 | 
			
		||||
            title: (data.active ? 'Disable ' : 'Enable ') + 'ID: ' + data.id,
 | 
			
		||||
            message: 'This web will be ' + (data.active ? 'disable ' : 'enable '),
 | 
			
		||||
      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 });
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -98,8 +113,8 @@ export default function WebBids() {
 | 
			
		|||
        }
 | 
			
		||||
      },
 | 
			
		||||
      okButton: {
 | 
			
		||||
                value: data.active ? 'Disable ' : 'Enable ',
 | 
			
		||||
                color: data.active ? 'red' : 'blue',
 | 
			
		||||
        value: data.active ? "Disable " : "Enable ",
 | 
			
		||||
        color: data.active ? "red" : "blue",
 | 
			
		||||
      },
 | 
			
		||||
    });
 | 
			
		||||
  };
 | 
			
		||||
| 
						 | 
				
			
			@ -110,19 +125,19 @@ export default function WebBids() {
 | 
			
		|||
        actionsOptions={{
 | 
			
		||||
          actions: [
 | 
			
		||||
            {
 | 
			
		||||
                            key: 'add',
 | 
			
		||||
                            title: 'Add',
 | 
			
		||||
              key: "add",
 | 
			
		||||
              title: "Add",
 | 
			
		||||
              callback: () => {
 | 
			
		||||
                webBidModal.open();
 | 
			
		||||
              },
 | 
			
		||||
            },
 | 
			
		||||
            {
 | 
			
		||||
                            key: 'delete',
 | 
			
		||||
                            title: 'Delete',
 | 
			
		||||
              key: "delete",
 | 
			
		||||
              title: "Delete",
 | 
			
		||||
              callback: (data) => {
 | 
			
		||||
                if (!data.length) return;
 | 
			
		||||
                setConfirm({
 | 
			
		||||
                                    title: 'Delete',
 | 
			
		||||
                  title: "Delete",
 | 
			
		||||
                  message: `${data.length} will be delete`,
 | 
			
		||||
                  handleOk: async () => {
 | 
			
		||||
                    const result = await deletesWebBid(data);
 | 
			
		||||
| 
						 | 
				
			
			@ -143,18 +158,18 @@ export default function WebBids() {
 | 
			
		|||
        showLoading={true}
 | 
			
		||||
        highlightOnHover
 | 
			
		||||
        styleDefaultHead={{
 | 
			
		||||
                    justifyContent: 'flex-start',
 | 
			
		||||
                    width: 'fit-content',
 | 
			
		||||
          justifyContent: "flex-start",
 | 
			
		||||
          width: "fit-content",
 | 
			
		||||
        }}
 | 
			
		||||
        options={{
 | 
			
		||||
          query: getWebBids,
 | 
			
		||||
                    pathToData: 'data.data',
 | 
			
		||||
          pathToData: "data.data",
 | 
			
		||||
          keyOptions: {
 | 
			
		||||
                        last_page: 'lastPage',
 | 
			
		||||
                        per_page: 'perPage',
 | 
			
		||||
                        from: 'from',
 | 
			
		||||
                        to: 'to',
 | 
			
		||||
                        total: 'total',
 | 
			
		||||
            last_page: "lastPage",
 | 
			
		||||
            per_page: "perPage",
 | 
			
		||||
            from: "from",
 | 
			
		||||
            to: "to",
 | 
			
		||||
            total: "total",
 | 
			
		||||
          },
 | 
			
		||||
        }}
 | 
			
		||||
        rows={[]}
 | 
			
		||||
| 
						 | 
				
			
			@ -195,11 +210,33 @@ export default function WebBids() {
 | 
			
		|||
                    Account
 | 
			
		||||
                  </Menu.Item>
 | 
			
		||||
 | 
			
		||||
                                    <Menu.Item onClick={() => handleToggle(row)} leftSection={row.active ? <IconAdOff size={14} /> : <IconAd size={14} />}>
 | 
			
		||||
                                        {row.active ? 'Disable' : 'Enable'}
 | 
			
		||||
                  <Menu.Item
 | 
			
		||||
                    onClick={() => {
 | 
			
		||||
                      setClickData(row);
 | 
			
		||||
                      scrapConfigModal.open();
 | 
			
		||||
                    }}
 | 
			
		||||
                    leftSection={<IconSettingsCode size={14} />}
 | 
			
		||||
                  >
 | 
			
		||||
                    Scrap config
 | 
			
		||||
                  </Menu.Item>
 | 
			
		||||
 | 
			
		||||
                                    <Menu.Item onClick={() => handleDelete(row)} leftSection={<IconTrash color="red" size={14} />}>
 | 
			
		||||
                  <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>
 | 
			
		||||
| 
						 | 
				
			
			@ -248,6 +285,22 @@ export default function WebBids() {
 | 
			
		|||
          }
 | 
			
		||||
        }}
 | 
			
		||||
      />
 | 
			
		||||
 | 
			
		||||
      <ScrapConfigModal
 | 
			
		||||
        data={clickData}
 | 
			
		||||
        opened={scrapConfigOpened}
 | 
			
		||||
        onClose={() => {
 | 
			
		||||
          scrapConfigModal.close();
 | 
			
		||||
          setClickData(null);
 | 
			
		||||
        }}
 | 
			
		||||
        onUpdated={() => {
 | 
			
		||||
          setClickData(null);
 | 
			
		||||
 | 
			
		||||
          if (refTableFn.current?.fetchData) {
 | 
			
		||||
            refTableFn.current.fetchData();
 | 
			
		||||
          }
 | 
			
		||||
        }}
 | 
			
		||||
      />
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -18,8 +18,6 @@ export interface ITimestamp {
 | 
			
		|||
  updated_at: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
export interface IHistory extends ITimestamp {
 | 
			
		||||
  id: number;
 | 
			
		||||
  price: number;
 | 
			
		||||
| 
						 | 
				
			
			@ -33,6 +31,21 @@ export interface IOutBidLog extends ITimestamp {
 | 
			
		|||
  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;
 | 
			
		||||
| 
						 | 
				
			
			@ -44,8 +57,9 @@ export interface IWebBid extends ITimestamp {
 | 
			
		|||
  active: boolean;
 | 
			
		||||
  arrival_offset_seconds: number;
 | 
			
		||||
  early_tracking_seconds: number;
 | 
			
		||||
    snapshot_at: string | null
 | 
			
		||||
  snapshot_at: string | null;
 | 
			
		||||
  children: IBid[];
 | 
			
		||||
  scrap_config: IScrapConfig;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface IBid extends ITimestamp {
 | 
			
		||||
| 
						 | 
				
			
			@ -60,9 +74,10 @@ export interface IBid extends ITimestamp {
 | 
			
		|||
  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';
 | 
			
		||||
  status: "biding" | "out-bid" | "win-bid";
 | 
			
		||||
  histories: IHistory[];
 | 
			
		||||
  web_bid: IWebBid;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1 +1 @@
 | 
			
		|||
{"createdAt":1747292824357}
 | 
			
		||||
{"createdAt":1747701959077}
 | 
			
		||||
| 
						 | 
				
			
			@ -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",
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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",
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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: [],
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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));
 | 
			
		||||
  }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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'],
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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' };
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,4 @@
 | 
			
		|||
import { PartialType } from '@nestjs/mapped-types';
 | 
			
		||||
import { CreateScrapConfigDto } from './create-scrap-config';
 | 
			
		||||
 | 
			
		||||
export class UpdateScrapConfigDto extends PartialType(CreateScrapConfigDto) {}
 | 
			
		||||
| 
						 | 
				
			
			@ -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[];
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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>;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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()),
 | 
			
		||||
    );
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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 {}
 | 
			
		||||
| 
						 | 
				
			
			@ -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);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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>,
 | 
			
		||||
  ) {}
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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()));
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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,12 +114,11 @@ 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;
 | 
			
		||||
| 
						 | 
				
			
			@ -148,7 +151,7 @@ export function shouldResetTool(
 | 
			
		|||
      shouldReset: true,
 | 
			
		||||
      reason: 'Bid close_time is within 20 minutes',
 | 
			
		||||
      bidId: closest.id,
 | 
			
		||||
        closeTime: closest.close_time
 | 
			
		||||
      closeTime: closest.close_time,
 | 
			
		||||
    };
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -160,15 +163,14 @@ export function shouldResetTool(
 | 
			
		|||
      if (
 | 
			
		||||
        (!bid.lot_id || !bid.close_time) &&
 | 
			
		||||
        now.getTime() - createdAt.getTime() > FIVE_MINUTES &&
 | 
			
		||||
          (!lastResetTime ||
 | 
			
		||||
            now.getTime() - lastResetTime.getTime() > TWO_HOURS)
 | 
			
		||||
        (!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
 | 
			
		||||
          closeTime: bid.close_time,
 | 
			
		||||
        };
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
| 
						 | 
				
			
			@ -177,4 +179,9 @@ export function shouldResetTool(
 | 
			
		|||
  return {
 | 
			
		||||
    shouldReset: false,
 | 
			
		||||
  };
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function extractNumber(str: string) {
 | 
			
		||||
  const match = str.match(/\d+(\.\d+)?/);
 | 
			
		||||
  return match ? parseFloat(match[0]) : null;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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();
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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}`);
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -3,12 +3,12 @@
 | 
			
		|||
// 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',
 | 
			
		||||
  type: "Apibid",
 | 
			
		||||
  puppeteer_connect: "puppeteer_connect",
 | 
			
		||||
  url: "https://www.grays.com/mygrays/auctions/biddingon.aspx",
 | 
			
		||||
  listentEvent: function () {
 | 
			
		||||
    //action()
 | 
			
		||||
  },
 | 
			
		||||
| 
						 | 
				
			
			@ -17,11 +17,11 @@ Apibids = {
 | 
			
		|||
 | 
			
		||||
// 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 () {},
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			@ -43,8 +43,14 @@ recheck = function name() {
 | 
			
		|||
};
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
\*\* 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
 | 
			
		||||
    }
 | 
			
		||||
    }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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 });
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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",
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
										
											Binary file not shown.
										
									
								
							| 
						 | 
				
			
			@ -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  | 
| 
						 | 
				
			
			@ -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);
 | 
			
		||||
});
 | 
			
		||||
| 
						 | 
				
			
			@ -1,6 +0,0 @@
 | 
			
		|||
// config.js
 | 
			
		||||
const CONFIG = {
 | 
			
		||||
  API_BASE_URL: "http://localhost:4000/api/v1",
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default CONFIG;
 | 
			
		||||
| 
						 | 
				
			
			@ -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)
 | 
			
		||||
  );
 | 
			
		||||
})();
 | 
			
		||||
| 
						 | 
				
			
			@ -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"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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>
 | 
			
		||||
| 
						 | 
				
			
			@ -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();
 | 
			
		||||
		Loading…
	
		Reference in New Issue