+
+
+ Your browser does not support the video tag.
+
+
+
+
0 ? `${(currentTime / duration) * 100}%` : "0%",
+ }}
+ />
+
+
+ );
+};
+
+export default AdVideo;
diff --git a/src/components/AppItem/__test__/index.test.tsx b/src/components/AppItem/__test__/index.test.tsx
new file mode 100644
index 0000000..e4d71cf
--- /dev/null
+++ b/src/components/AppItem/__test__/index.test.tsx
@@ -0,0 +1,61 @@
+import "@testing-library/jest-dom";
+import { fireEvent, render, screen } from "@testing-library/react";
+import { describe, it, expect, vi } from "vitest";
+
+import { voteAppData } from "@/__mocks__/VoteApp";
+
+import AppItem from "../index";
+
+const mockUpdateOpenAppClick = vi.fn();
+
+describe("AppItem Component", () => {
+ it("renders with default props correctly", () => {
+ render(
+
+ );
+
+ // Check that the image is rendered
+ const imageElement = screen.getByTestId("app-item-icon");
+ expect(imageElement).toBeInTheDocument();
+ expect(imageElement).toHaveAttribute(
+ "src",
+ "https://tmrwdao-arcade.s3.amazonaws.com/votigram/test/asset/img/64284af5e2e8c048758b8985f20446181165ba51f229fdf0a3e5e17c6543106d.webp"
+ );
+
+ // Check that the title and description are rendered
+ expect(screen.getByText("Tonalytics")).toBeInTheDocument();
+ expect(screen.getByText(/Follow 👉🏻 @tonalytics1/i)).toBeInTheDocument();
+
+ // Check that the arrow is not rendered
+ const arrowIcon = screen.queryByTestId("arrow-icon");
+ expect(arrowIcon).not.toBeInTheDocument();
+ });
+
+ it("renders the arrow icon when showArrow is true", () => {
+ render(
+
+ );
+
+ const arrowIcon = screen.getByTestId("arrow-icon");
+ expect(arrowIcon).toBeInTheDocument();
+ });
+
+ it("should catch the onclick function", () => {
+ render(
+
+ );
+
+ const button = screen.getByRole("button");
+ fireEvent.click(button);
+
+ expect(mockUpdateOpenAppClick).toHaveBeenCalledWith(voteAppData);
+ });
+});
diff --git a/src/components/AppItem/index.tsx b/src/components/AppItem/index.tsx
new file mode 100644
index 0000000..52876b8
--- /dev/null
+++ b/src/components/AppItem/index.tsx
@@ -0,0 +1,42 @@
+import { VoteApp } from "@/types/app";
+
+interface IAppItem {
+ showArrow?: boolean;
+ item: VoteApp;
+ onAppItemClick: (_: VoteApp) => void;
+}
+
+const AppItem = ({ showArrow = false, onAppItemClick, item }: IAppItem) => {
+ return (
+
{
+ onAppItemClick(item);
+ }}
+ className="flex gap-[18px] items-center"
+ >
+
+
+
+ {item?.title}
+
+
+ {item?.description}
+
+
+ {showArrow && (
+
+ )}
+
+ );
+};
+
+export default AppItem;
diff --git a/src/components/AppList/__test__/index.test.tsx b/src/components/AppList/__test__/index.test.tsx
new file mode 100644
index 0000000..803ac3a
--- /dev/null
+++ b/src/components/AppList/__test__/index.test.tsx
@@ -0,0 +1,52 @@
+import "@testing-library/jest-dom";
+import { render, screen } from "@testing-library/react";
+import { describe, it, expect, vi } from "vitest";
+
+import { voteAppListData } from "@/__mocks__/VoteApp";
+
+import AppList from "../index";
+
+const onAppItemClick = vi.fn();
+
+describe("AppList Component", () => {
+ it("renders the title correctly", () => {
+ render(
+
+ );
+
+ const titleElement = screen.getByText(/Top Apps/i);
+ expect(titleElement).toBeInTheDocument();
+ });
+
+ it("renders the correct number of AppItem components", () => {
+ render(
+
+ );
+
+ const appItems = screen.getAllByRole("img"); // Assuming AppItem has an image role
+ expect(appItems).toHaveLength(voteAppListData.length);
+ });
+
+ it("renders AppItems with the arrow icon", () => {
+ render(
+
+ );
+
+ voteAppListData.forEach((_, index) => {
+ const arrowIcons = screen.getAllByTestId("arrow-icon");
+ expect(arrowIcons[index]).toBeInTheDocument();
+ });
+ });
+});
diff --git a/src/components/AppList/index.tsx b/src/components/AppList/index.tsx
new file mode 100644
index 0000000..efd1992
--- /dev/null
+++ b/src/components/AppList/index.tsx
@@ -0,0 +1,31 @@
+import { VoteApp } from "@/types/app";
+
+import AppItem from "../AppItem";
+
+interface IAppList {
+ title: string;
+ items: VoteApp[];
+ onAppItemClick: (_: VoteApp) => void;
+}
+
+const AppList = ({ title, items, onAppItemClick }: IAppList) => {
+ return (
+
+
+ {title}
+
+
+ {items?.map((item, index) => (
+
+ ))}
+
+
+ );
+};
+
+export default AppList;
diff --git a/src/components/BackBtn/__test__/index.test.tsx b/src/components/BackBtn/__test__/index.test.tsx
new file mode 100644
index 0000000..334399e
--- /dev/null
+++ b/src/components/BackBtn/__test__/index.test.tsx
@@ -0,0 +1,57 @@
+// BackBtn.test.tsx
+import { render, screen, fireEvent } from "@testing-library/react";
+import { useNavigate, useLocation } from "react-router-dom";
+import "@testing-library/jest-dom";
+import { describe, it, vi, expect } from "vitest";
+
+import BackBtn from "../index"; // Adjust the path to wherever your component is located
+
+vi.mock("react-router-dom", () => ({
+ ...vi.importActual("react-router-dom"),
+ useNavigate: vi.fn(),
+ useLocation: vi.fn(),
+}));
+
+describe("BackBtn Component", () => {
+ const mockNavigate = vi.fn();
+ const mockLocation = { state: { from: "/previous-route" } };
+
+ beforeEach(() => {
+ // Mock the hooks from react-router-dom
+ (useNavigate as vi.Mock).mockReturnValue(mockNavigate);
+ (useLocation as vi.Mock).mockReturnValue(mockLocation);
+ vi.clearAllMocks();
+ });
+
+ it("renders the button correctly", () => {
+ render(
);
+ const button = screen.getByRole("button");
+ expect(button).toBeInTheDocument();
+ expect(button).toHaveClass(
+ "bg-transparent p-0 m-0 w-[24px] h-[24px] leading-[24px] focus:outline-none z-10"
+ );
+ const icon = button.querySelector("i");
+ expect(icon).toHaveClass("votigram-icon-back text-[24px] text-white");
+ });
+
+ it("navigates to the location.state.from URL if it exists", () => {
+ render(
);
+ const button = screen.getByRole("button");
+
+ fireEvent.click(button);
+
+ expect(mockNavigate).toHaveBeenCalledWith(mockLocation.state.from, {
+ replace: true,
+ });
+ });
+
+ it("navigates back using navigate(-1) if location.state.from is undefined", () => {
+ (useLocation as vi.Mock).mockReturnValue({}); // Mock location without state
+ render(
);
+ const button = screen.getByRole("button");
+
+ fireEvent.click(button);
+
+ expect(mockNavigate).toHaveBeenCalledWith(-1);
+ });
+});
diff --git a/src/components/BackBtn/index.tsx b/src/components/BackBtn/index.tsx
new file mode 100644
index 0000000..5e8fa27
--- /dev/null
+++ b/src/components/BackBtn/index.tsx
@@ -0,0 +1,26 @@
+import React from "react";
+
+import { useLocation, useNavigate } from "react-router-dom";
+
+const BackBtn: React.FC = () => {
+ const navigate = useNavigate();
+ const location = useLocation();
+
+ const handleGoBack = () => {
+ if (location.state?.from) {
+ navigate(location.state?.from, { replace: true });
+ } else {
+ navigate(-1);
+ }
+ };
+ return (
+
+
+
+ );
+};
+
+export default BackBtn;
diff --git a/src/components/ButtonRadio/__test__/index.test.tsx b/src/components/ButtonRadio/__test__/index.test.tsx
new file mode 100644
index 0000000..30fb099
--- /dev/null
+++ b/src/components/ButtonRadio/__test__/index.test.tsx
@@ -0,0 +1,51 @@
+// ButtonRadio.test.tsx
+import { render, screen, fireEvent } from "@testing-library/react";
+import "@testing-library/jest-dom";
+import { describe, it, expect, vi } from "vitest";
+
+import ButtonRadio from "../index";
+
+describe("ButtonRadio Component", () => {
+ const options = [
+ { label: "Option 1", value: 1 },
+ { label: "Option 2", value: 2 },
+ { label: "Option 3", value: 3 },
+ ];
+
+ it("renders all options", () => {
+ render(
);
+
+ options.forEach((option) => {
+ expect(screen.getByText(option.label)).toBeInTheDocument();
+ });
+ });
+
+ it("calls onChange with correct value when an option is clicked", () => {
+ const handleChange = vi.fn();
+ render(
);
+
+ const optionToSelect = screen.getByText("Option 2");
+ fireEvent.click(optionToSelect);
+
+ expect(handleChange).toHaveBeenCalledWith({ label: "Option 2", value: 2 });
+ });
+
+ it("correctly applies selected styles on click", () => {
+ render(
);
+
+ const optionToSelect = screen.getByText("Option 2");
+ fireEvent.click(optionToSelect);
+
+ expect(optionToSelect).toHaveClass("text-white");
+ expect(optionToSelect.parentElement).toHaveClass("border-white");
+ });
+
+ it("sets initial selected value if provided", () => {
+ const initialValue = { label: "Option 3", value: 3 };
+ render(
);
+
+ const initiallySelectedOption = screen.getByText("Option 3");
+ expect(initiallySelectedOption).toHaveClass("text-white");
+ expect(initiallySelectedOption.parentElement).toHaveClass("border-white");
+ });
+});
diff --git a/src/components/ButtonRadio/index.tsx b/src/components/ButtonRadio/index.tsx
new file mode 100644
index 0000000..ae60dff
--- /dev/null
+++ b/src/components/ButtonRadio/index.tsx
@@ -0,0 +1,64 @@
+import { useEffect, useState } from "react";
+
+import clsx from "clsx";
+
+type ButtonRadioOption = {
+ label: string;
+ value: number;
+};
+
+interface IButtonRadioProps {
+ value?: ButtonRadioOption;
+ className?: string;
+ radioClassName?: string;
+ options: ButtonRadioOption[];
+ onChange?: (_: ButtonRadioOption) => void;
+}
+
+const ButtonRadio = ({
+ value,
+ options,
+ className,
+ radioClassName,
+ onChange,
+}: IButtonRadioProps) => {
+ const [selectedValue, setSelectedValue] = useState<
+ ButtonRadioOption | undefined
+ >();
+
+ const handleSelect = (value: ButtonRadioOption) => {
+ setSelectedValue(value);
+ onChange?.(value);
+ };
+
+ useEffect(() => {
+ setSelectedValue(value);
+ }, [value]);
+
+ return (
+
+ {options.map((item) => (
+
handleSelect(item)}
+ >
+
+ {item.label}
+
+
+ ))}
+
+ );
+};
+
+export default ButtonRadio;
diff --git a/src/components/CategoryPillList/__test__/index.test.tsx b/src/components/CategoryPillList/__test__/index.test.tsx
new file mode 100644
index 0000000..01004b6
--- /dev/null
+++ b/src/components/CategoryPillList/__test__/index.test.tsx
@@ -0,0 +1,25 @@
+import { render, screen } from "@testing-library/react";
+import "@testing-library/jest-dom";
+import { describe, it, expect } from "vitest";
+
+import { DISCOVER_CATEGORY } from "@/constants/discover";
+
+import CategoryPillList from "../index";
+
+describe("CategoryPillList Component", () => {
+ it("renders the correct number of categories", () => {
+ render(
);
+
+ const categoryButtons = screen.getAllByRole("button");
+ expect(categoryButtons).toHaveLength(DISCOVER_CATEGORY.length); // Ensure length matches mocked categories
+ });
+
+ it("renders each category with the correct label", () => {
+ render(
);
+
+ const labels = DISCOVER_CATEGORY.map((item) => item.label);
+ labels.forEach((label) => {
+ expect(screen.getByText(label)).toBeInTheDocument();
+ });
+ });
+});
diff --git a/src/components/CategoryPillList/index.css b/src/components/CategoryPillList/index.css
new file mode 100644
index 0000000..124ec56
--- /dev/null
+++ b/src/components/CategoryPillList/index.css
@@ -0,0 +1,21 @@
+.app-category-list {
+ padding: 0 20px;
+ display: flex;
+ flex-wrap: nowrap;
+ justify-content: flex-start;
+ align-items: center;
+ position: relative;
+ overflow-x: scroll;
+ scrollbar-width: none;
+ margin-top: 10px;
+ gap: 6px;
+
+ .item {
+ flex-grow: 0;
+ padding: 0;
+ }
+
+ .second-item {
+ display: flex;
+ }
+}
\ No newline at end of file
diff --git a/src/components/CategoryPillList/index.tsx b/src/components/CategoryPillList/index.tsx
new file mode 100644
index 0000000..e72ee98
--- /dev/null
+++ b/src/components/CategoryPillList/index.tsx
@@ -0,0 +1,68 @@
+import { useEffect, useState } from "react";
+
+import clsx from "clsx";
+
+import { APP_CATEGORY } from "@/constants/discover";
+import { DiscoverType } from "@/types/app";
+
+import "./index.css";
+
+interface ICategoryPillListProps {
+ value?: APP_CATEGORY;
+ amount?: number;
+ items: DiscoverType[];
+ className?: string;
+ onChange?: (category: APP_CATEGORY) => void;
+}
+
+const CategoryPillList = ({
+ value,
+ items,
+ className,
+ amount,
+ onChange,
+}: ICategoryPillListProps) => {
+ const [active, setActive] = useState
(APP_CATEGORY.ALL);
+
+ const handleClick = (category: APP_CATEGORY) => {
+ setActive(active === category ? APP_CATEGORY.ALL : category);
+ onChange?.(active === category ? APP_CATEGORY.ALL : category);
+ };
+
+ useEffect(() => {
+ setActive(value || APP_CATEGORY.ALL);
+ }, [value]);
+
+ return (
+
+ {items.map((item) => (
+
handleClick(item.value)}
+ >
+
+ {item.label}
+ {item.value === APP_CATEGORY.NEW && !!amount && amount > 0 && (
+
+ {amount}
+
+ )}
+
+
+ ))}
+
+ );
+};
+
+export default CategoryPillList;
diff --git a/src/components/CheckboxGroup/__test__/index.test.tsx b/src/components/CheckboxGroup/__test__/index.test.tsx
new file mode 100644
index 0000000..d638df9
--- /dev/null
+++ b/src/components/CheckboxGroup/__test__/index.test.tsx
@@ -0,0 +1,61 @@
+// CheckboxGroup.test.tsx
+import "@testing-library/jest-dom";
+import { render, screen, fireEvent } from "@testing-library/react";
+import { describe, it, expect, vi } from "vitest";
+
+import { APP_CATEGORY, DISCOVER_CATEGORY } from "@/constants/discover";
+
+import CheckboxGroup from "../index"; // Adjust to your actual path
+
+const options = DISCOVER_CATEGORY;
+
+describe("CheckboxGroup Component", () => {
+ it("renders all checkbox options", () => {
+ render( {}} />);
+
+ options.forEach((option) => {
+ expect(screen.getByText(option.label)).toBeInTheDocument();
+ });
+ });
+
+ it("toggles checkbox option selection state on click", () => {
+ const handleChange = vi.fn();
+ render( );
+
+ const firstOption = screen.getByText(options[0].label);
+
+ // Initially not selected
+ expect(firstOption).not.toHaveClass("border-secondary");
+
+ // Click to select
+ fireEvent.click(firstOption);
+ expect(firstOption).toHaveClass("border-secondary");
+
+ // Click again to deselect
+ fireEvent.click(firstOption);
+ expect(firstOption).not.toHaveClass("border-secondary");
+ });
+
+ it("calls onChange with correct values when checkboxes are toggled", () => {
+ const handleChange = vi.fn();
+ render( );
+
+ const firstOption = screen.getByText(options[0].label);
+ const secondOption = screen.getByText(options[1].label);
+
+ // Click to select first option
+ fireEvent.click(firstOption);
+ expect(handleChange).toHaveBeenCalledWith([APP_CATEGORY.NEW]);
+
+ // Click to select second option
+ fireEvent.click(secondOption);
+ expect(handleChange).toHaveBeenCalledWith([
+ APP_CATEGORY.NEW,
+ APP_CATEGORY.EARN,
+ ]);
+
+ // Click again to deselect first option
+ fireEvent.click(firstOption);
+ expect(handleChange).toHaveBeenCalledWith([APP_CATEGORY.EARN]);
+ });
+});
diff --git a/src/components/CheckboxGroup/index.tsx b/src/components/CheckboxGroup/index.tsx
new file mode 100644
index 0000000..6d8c834
--- /dev/null
+++ b/src/components/CheckboxGroup/index.tsx
@@ -0,0 +1,51 @@
+import { useState } from "react";
+
+import { APP_CATEGORY } from "@/constants/discover";
+
+type ICheckboxOption = {
+ value: APP_CATEGORY;
+ label: string;
+};
+
+interface ICheckboxProps {
+ options: ICheckboxOption[];
+ onChange: (values: APP_CATEGORY[]) => void;
+}
+
+const CheckboxGroup = ({ options, onChange }: ICheckboxProps) => {
+ const [selectedValues, setSelectedValues] = useState([]);
+
+ const handleToggle = (value: APP_CATEGORY) => {
+ const currentIndex = selectedValues.indexOf(value);
+ const newSelectedValues = [...selectedValues];
+
+ if (currentIndex === -1) {
+ newSelectedValues.push(value);
+ } else {
+ newSelectedValues.splice(currentIndex, 1);
+ }
+
+ setSelectedValues(newSelectedValues);
+ onChange(newSelectedValues);
+ };
+
+ return (
+
+ {options.map((option) => (
+
handleToggle(option.value)}
+ >
+ {option.label}
+
+ ))}
+
+ );
+};
+
+export default CheckboxGroup;
diff --git a/src/components/Community/__test__/index.test.tsx b/src/components/Community/__test__/index.test.tsx
new file mode 100644
index 0000000..5995ed1
--- /dev/null
+++ b/src/components/Community/__test__/index.test.tsx
@@ -0,0 +1,80 @@
+// Community.test.tsx
+import { render, screen, fireEvent } from "@testing-library/react";
+import "@testing-library/jest-dom";
+import { MemoryRouter } from "react-router-dom";
+import { describe, it, expect, vi } from "vitest";
+
+import { IToggleSlider } from "@/components/ToggleSlider/type";
+import { COMMUNITY_TYPE } from "@/constants/vote";
+
+import Community from "../index";
+
+vi.mock("../ToggleSlider", () => ({
+ default: ({
+ items,
+ current,
+ onChange,
+ className,
+ activeItemClassName,
+ itemClassName,
+ }: IToggleSlider) => (
+
+ {items.map((item: string, index: number) => (
+
onChange?.(index)}
+ >
+ {item}
+
+ ))}
+
+ ),
+}));
+
+vi.mock("./components/Archived", () => ({
+ default: ({ type }: { type: COMMUNITY_TYPE }) => (
+
+ {type === COMMUNITY_TYPE.CURRENT && (
+ {}}>Create Poll
+ )}
+
+ ),
+}));
+
+describe("Community Component", () => {
+ it("renders the component with ToggleSlider and Archived", () => {
+ render(
+
+
+
+ );
+
+ // Ensure ToggleSlider items are rendered
+ expect(screen.getByText("Archived")).toBeInTheDocument();
+ expect(screen.getByText("Current")).toBeInTheDocument();
+
+ // Ensure Archived component renders the button when type is CURRENT
+ expect(screen.queryByText("Create Poll")).toBeInTheDocument();
+ });
+
+ it("switches between tabs correctly and updates Archived component", () => {
+ render(
+
+
+
+ );
+
+ // Click on "Archived" tab
+ fireEvent.click(screen.getByText("Archived"));
+
+ // Button should not be present if type is not CURRENT
+ expect(screen.queryByText("Create Poll")).not.toBeInTheDocument();
+
+ // Click on "Current" tab
+ fireEvent.click(screen.getByText("Current"));
+
+ // Button should be present if type is CURRENT
+ expect(screen.queryByText("Create Poll")).toBeInTheDocument();
+ });
+});
diff --git a/src/components/Community/components/Archived/__test__/index.test.tsx b/src/components/Community/components/Archived/__test__/index.test.tsx
new file mode 100644
index 0000000..8e94960
--- /dev/null
+++ b/src/components/Community/components/Archived/__test__/index.test.tsx
@@ -0,0 +1,85 @@
+// Archived.test.tsx
+import { render, screen, fireEvent } from "@testing-library/react";
+import "@testing-library/jest-dom";
+import { useNavigate } from "react-router-dom";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+import { VoteSectionType } from "@/components/VoteSection/type";
+import { COMMUNITY_TYPE } from "@/constants/vote";
+import useData from "@/hooks/useData";
+
+import Archived from "../index";
+
+// Mock the hooks and components that Archived depends on
+vi.mock("react-router-dom", () => ({
+ useNavigate: vi.fn(), // Mock `useNavigate` with Vitest's vi.fn()
+}));
+
+vi.mock("@/hooks/useData", () => ({
+ default: vi.fn(),
+}));
+
+vi.mock("@/components/VoteSection", () => ({
+ default: ({ data }: { data: VoteSectionType }) => (
+ {data.proposalTitle}
+ ),
+}));
+
+describe("Archived Component", () => {
+ let mockNavigate: ReturnType;
+
+ beforeEach(() => {
+ // Mock navigate function
+ mockNavigate = vi.fn();
+ (useNavigate as vi.Mock).mockReturnValue(mockNavigate);
+
+ (useData as vi.Mock).mockReturnValue({
+ data: {
+ data: [
+ { proposalId: "1", proposalTitle: "Vote 1" },
+ { proposalId: "2", proposalTitle: "Vote 2" },
+ ],
+ },
+ isLoading: false,
+ });
+ });
+
+ it("renders without crashing", () => {
+ render( );
+
+ expect(screen.getByText("Vote 1")).toBeInTheDocument();
+ expect(screen.getByText("Vote 2")).toBeInTheDocument();
+ });
+
+ it('navigates to create poll page when "Create Poll" button is clicked', () => {
+ render( );
+
+ const button = screen.getByRole("button", { name: /Create Poll/i });
+ fireEvent.click(button);
+
+ expect(mockNavigate).toHaveBeenCalledWith("/create-poll", {
+ state: {
+ from: "/?tab=2&vote_tab=Community&community=1",
+ },
+ });
+ });
+
+ it("loads more sections when scrolled to the top", () => {
+ (useData as vi.Mock)
+ .mockReturnValueOnce({
+ data: { data: [] }, // Initial load with no data
+ isLoading: false,
+ })
+ .mockReturnValue({
+ data: { data: [{ proposalId: "3", proposalTitle: "Vote 3" }] }, // Load more data
+ isLoading: false,
+ });
+
+ render( );
+
+ // Simulating scrolling to the top
+ fireEvent.scroll(window, { target: { scrollY: 0 } });
+
+ expect(screen.getByText("Vote 3")).toBeInTheDocument();
+ });
+});
diff --git a/src/components/Community/components/Archived/index.tsx b/src/components/Community/components/Archived/index.tsx
new file mode 100644
index 0000000..77764bd
--- /dev/null
+++ b/src/components/Community/components/Archived/index.tsx
@@ -0,0 +1,91 @@
+import { useEffect, useState } from "react";
+
+import { useNavigate } from "react-router-dom";
+
+import Loading from "@/components/Loading";
+import VoteSection from "@/components/VoteSection";
+import { VoteSectionType } from "@/components/VoteSection/type";
+import { chainId } from "@/constants/app";
+import { COMMUNITY_TYPE } from "@/constants/vote";
+import useData from "@/hooks/useData";
+
+interface IArchivedProps {
+ type: COMMUNITY_TYPE;
+ scrollTop: number;
+ currentTab?: number;
+}
+const PAGE_SIZE = 20;
+
+const Archived = ({ type, scrollTop, currentTab }: IArchivedProps) => {
+ const [hasMore, setHasMore] = useState(true);
+ const [sections, setSections] = useState([]);
+ const [pageIndex, setPageIndex] = useState(0);
+ const [currentType, setCurrentType] = useState(type);
+
+ const navigate = useNavigate();
+
+ const { data, isLoading } = useData(
+ `/api/app/ranking/poll-list?${new URLSearchParams({
+ chainId,
+ type: currentType,
+ skipCount: (pageIndex * PAGE_SIZE).toString(),
+ maxResultCount: PAGE_SIZE.toString(),
+ }).toString()}`
+ );
+
+ useEffect(() => {
+ setSections([]);
+ setCurrentType(type);
+ setPageIndex(0);
+ }, [type]);
+
+ useEffect(() => {
+ const { data: sectionList } = data || {};
+ if (sectionList && Array.isArray(sectionList)) {
+ setSections((prev) =>
+ pageIndex === 0 ? sectionList : [...prev, ...sectionList]
+ );
+ setHasMore(sectionList?.length >= PAGE_SIZE);
+ }
+ }, [data, pageIndex]);
+
+ useEffect(() => {
+ if (scrollTop && scrollTop < 50 && hasMore && !isLoading) {
+ setPageIndex((prevPageIndex) => prevPageIndex + 1);
+ }
+ }, [hasMore, isLoading, scrollTop]);
+
+ return (
+
+ {type === COMMUNITY_TYPE.CURRENT && (
+
+ navigate("/create-poll", {
+ state: { from: "/?tab=2&vote_tab=Community&community=1" },
+ })
+ }
+ >
+ Create Poll
+
+ )}
+ {sections?.map((vote, index) => (
+
+ ))}
+ {isLoading && (
+
+
+
+ )}
+
+ );
+};
+
+export default Archived;
diff --git a/src/components/Community/index.tsx b/src/components/Community/index.tsx
new file mode 100644
index 0000000..426d6e8
--- /dev/null
+++ b/src/components/Community/index.tsx
@@ -0,0 +1,58 @@
+import { useState } from "react";
+
+import { COMMUNITY_LABEL, COMMUNITY_TYPE } from "@/constants/vote";
+import useSetSearchParams from "@/hooks/useSetSearchParams";
+
+import Tabs from "../Tabs";
+import Archived from "./components/Archived";
+
+
+
+const communityTabs = [
+ {
+ label: COMMUNITY_LABEL.ARCHIVED,
+ value: 0,
+ },
+ {
+ label: COMMUNITY_LABEL.CURRENT,
+ value: 1,
+ },
+];
+interface ICommunityProps {
+ scrollTop: number;
+}
+
+const Community = ({ scrollTop }: ICommunityProps) => {
+ const { querys, updateQueryParam } = useSetSearchParams();
+ const activeTab = querys.get("community");
+ const [currentTab, setCurrentTab] = useState(
+ activeTab === "0" ? Number(activeTab) : 1
+ );
+
+ const onTabChange = (index: number) => {
+ setCurrentTab(index);
+ updateQueryParam({ key: "community", value: index.toString() });
+ };
+
+ return (
+ <>
+
+
+
+ >
+ );
+};
+
+export default Community;
diff --git a/src/components/Confetti/__test__/index.test.tsx b/src/components/Confetti/__test__/index.test.tsx
new file mode 100644
index 0000000..b4821c7
--- /dev/null
+++ b/src/components/Confetti/__test__/index.test.tsx
@@ -0,0 +1,40 @@
+// Confetti.test.tsx
+import { render, screen } from "@testing-library/react";
+import canvasConfetti from "canvas-confetti";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+import Confetti from "../index";
+
+import "@testing-library/jest-dom";
+
+vi.mock("canvas-confetti", () => ({
+ default: {
+ create: vi.fn(() => ({
+ reset: vi.fn(),
+ })),
+ },
+}));
+
+describe("Confetti Component", () => {
+ beforeEach(() => {
+ // Reset mocks before each test
+ vi.clearAllMocks();
+ });
+
+ it("renders a canvas element with the correct class and height", () => {
+ render( );
+ const canvas = screen.getByRole("presentation");
+ expect(canvas).toBeInTheDocument();
+ expect(canvas).toHaveClass("test-class");
+ expect(canvas).toHaveAttribute("height", "500");
+ });
+
+ it("initializes confetti on mount", () => {
+ const mockOnInit = vi.fn();
+ render( );
+ expect(canvasConfetti.create).toHaveBeenCalledTimes(1);
+
+ // Confirms the onInit callback was called with the confetti instance
+ expect(mockOnInit).toHaveBeenCalledWith({ confetti: expect.any(Object) });
+ });
+});
diff --git a/src/components/Confetti/index.tsx b/src/components/Confetti/index.tsx
new file mode 100644
index 0000000..24b789e
--- /dev/null
+++ b/src/components/Confetti/index.tsx
@@ -0,0 +1,44 @@
+import { useEffect, useRef } from "react";
+
+import canvasConfetti, { CreateTypes } from "canvas-confetti";
+
+interface IConfettiProps {
+ className: string;
+ height?: number;
+ onInit?: ({ confetti }: { confetti: CreateTypes }) => void;
+}
+
+const Confetti = ({ className, height = 1000, onInit }: IConfettiProps) => {
+ const canvasRef = useRef(null);
+ const confetti = useRef(null);
+
+ useEffect(() => {
+ if (!canvasRef.current) {
+ return;
+ }
+
+ confetti.current = canvasConfetti.create(canvasRef.current, {
+ resize: true,
+ useWorker: true,
+ });
+
+ onInit?.({
+ confetti: confetti.current,
+ });
+
+ return () => {
+ confetti.current?.reset();
+ };
+ }, [onInit]);
+
+ return (
+
+ );
+};
+
+export default Confetti;
diff --git a/src/components/Confetti/types/index.ts b/src/components/Confetti/types/index.ts
new file mode 100644
index 0000000..613b8a9
--- /dev/null
+++ b/src/components/Confetti/types/index.ts
@@ -0,0 +1,5 @@
+export type {
+ CreateTypes as TCanvasConfettiInstance,
+ GlobalOptions as TCanvasConfettiGlobalOptions,
+ Options as TCanvasConfettiAnimationOptions,
+} from "canvas-confetti";
diff --git a/src/components/Countdown/__test__/index.test.tsx b/src/components/Countdown/__test__/index.test.tsx
new file mode 100644
index 0000000..4848c3e
--- /dev/null
+++ b/src/components/Countdown/__test__/index.test.tsx
@@ -0,0 +1,55 @@
+// Countdown.test.tsx
+import { act } from "react";
+
+import { render, screen } from "@testing-library/react";
+import "@testing-library/jest-dom";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+
+import Countdown from "../index";
+
+describe("Countdown Component", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.clearAllTimers();
+ });
+
+ it("renders with initial time", () => {
+ render( );
+ expect(screen.getByText("0d 0h 1m")).toBeInTheDocument();
+ });
+
+ it("counts down every second", () => {
+ render( );
+
+ // Initial time 3 seconds
+ expect(screen.getByText("0d 0h 0m")).toBeInTheDocument();
+
+ // Fast-forward 1 second
+ act(() => {
+ vi.advanceTimersByTime(1000);
+ });
+ expect(screen.getByText("0d 0h 0m")).toBeInTheDocument();
+
+ // Fast-forward another second
+ act(() => {
+ vi.advanceTimersByTime(1000);
+ });
+ expect(screen.getByText("0d 0h 0m")).toBeInTheDocument();
+ });
+
+ it("calls onFinish when countdown ends", () => {
+ const onFinish = vi.fn();
+ render( );
+
+ // Fast-forward 1 second to complete the countdown
+ act(() => {
+ vi.advanceTimersByTime(1000);
+ });
+
+ // Check if onFinish was called
+ expect(onFinish).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/components/Countdown/index.tsx b/src/components/Countdown/index.tsx
new file mode 100644
index 0000000..6baeae4
--- /dev/null
+++ b/src/components/Countdown/index.tsx
@@ -0,0 +1,50 @@
+
+import React, { useState, useEffect } from "react";
+
+import dayjs from "dayjs";
+import duration from "dayjs/plugin/duration";
+
+dayjs.extend(duration);
+
+interface CountdownProps {
+ initialTime: number;
+ onFinish?: () => void;
+}
+
+const Countdown: React.FC = ({ initialTime, onFinish }) => {
+ const [remainingTime, setRemainingTime] = useState(initialTime);
+
+ useEffect(() => {
+ if (remainingTime <= 0) {
+ onFinish?.();
+ return;
+ }
+
+ const timer = setInterval(() => {
+ setRemainingTime((prevTime) => prevTime - 1);
+ }, 1000);
+
+ return () => clearInterval(timer);
+ }, [onFinish, remainingTime]);
+
+ useEffect(() => {
+ setRemainingTime(initialTime);
+ }, [initialTime]);
+
+ const formatTime = (time: number): string => {
+ const durationObj = dayjs.duration(time, "seconds");
+ const days = Math.floor(durationObj.asDays());
+ const hours = durationObj.hours();
+ const minutes = durationObj.minutes();
+
+ return `${days}d ${hours}h ${minutes}m`;
+ };
+
+ return (
+
+ {formatTime(remainingTime)}
+
+ );
+};
+
+export default Countdown;
diff --git a/src/components/DailyRewards/__test__/index.test.tsx b/src/components/DailyRewards/__test__/index.test.tsx
new file mode 100644
index 0000000..df7cebc
--- /dev/null
+++ b/src/components/DailyRewards/__test__/index.test.tsx
@@ -0,0 +1,123 @@
+import { render, screen } from "@testing-library/react";
+import { describe, it, expect } from "vitest";
+
+
+import DailyRewards, {
+ getLastConsecutiveTrueLength, // Option 1: Export and import the helper function
+} from "../index";
+
+// Mock DAILY_REWARDS for testing
+vi.mock("@/constants/discover", () => {
+ const mockDAILY_REWARDS = [10, 20, 30, 40, 50, 60, 70];
+ return {
+ DAILY_REWARDS: mockDAILY_REWARDS,
+ };
+});
+
+vi.mock("@/provider/types/UserProviderType", () => ({
+ UserPoints: {
+ dailyPointsClaimedStatus: [true, true, false, false, false, false],
+ },
+}));
+
+describe("DailyRewards Component", () => {
+ const mockDAILY_REWARDS = [10, 20, 30, 40, 50, 60, 70];
+ it("renders the component with the correct number of rewards", () => {
+ render(
+
+ );
+ // Confirm title and subtitle render correctly
+ expect(screen.getByText("Daily Rewards")).toBeInTheDocument();
+ expect(
+ screen.getByText("Log in everyday to earn extra points!")
+ ).toBeInTheDocument();
+
+ // Confirm the correct number of rewards are rendered
+ const rewardItems = screen.getAllByText(/Day \d+/);
+ expect(rewardItems).toHaveLength(mockDAILY_REWARDS.length);
+ });
+
+ it("displays claimed rewards with a tick icon", () => {
+ const { container } = render(
+
+ );
+
+ // Get claimed rewards (shows tick icon)
+ const tickIcons = container.querySelectorAll(".votigram-icon-tick");
+ console.log(tickIcons);
+ expect(tickIcons).toHaveLength(2); // First two days are claimed
+ });
+
+ it("displays unclaimed rewards with correct point values", () => {
+ render(
+
+ );
+
+ // Get unclaimed rewards (shows "+ points")
+ const unclaimedRewards = screen.getAllByText(/^\+\s\d+/); // Matches "+ 10", "+ 20", etc.
+ expect(unclaimedRewards).toHaveLength(6); // 6 unclaimed rewards
+ expect(unclaimedRewards[0]).toHaveTextContent("+ 20"); // Day 2
+ expect(unclaimedRewards[1]).toHaveTextContent("+ 30"); // Day 3
+ });
+
+ it("handles empty dailyPointsClaimedStatus array", () => {
+ render(
+
+ );
+
+ // Confirm all rewards are unclaimed
+ const unclaimedRewards = screen.getAllByText(/^\+\s\d+/);
+ expect(unclaimedRewards).toHaveLength(mockDAILY_REWARDS.length);
+ });
+
+ it("handles null userPoints", () => {
+ render( );
+
+ // Confirm all rewards are unclaimed
+ const unclaimedRewards = screen.getAllByText(/^\+\s\d+/);
+ expect(unclaimedRewards).toHaveLength(mockDAILY_REWARDS.length);
+ });
+
+ it("calculates claimedDays correctly (helper function)", () => {
+ const result = getLastConsecutiveTrueLength([true, true, false, false]);
+ expect(result).toBe(2); // Two consecutive claimed days
+ });
+
+ it("calculates 0 claimed days when all are false", () => {
+ const result = getLastConsecutiveTrueLength([false, false, false, false]);
+ expect(result).toBe(0); // No claimed days
+ });
+
+ it("calculates claimed days for an all-true array", () => {
+ const result = getLastConsecutiveTrueLength([true, true, true, true]);
+ expect(result).toBe(4); // Four consecutive claimed days
+ });
+});
diff --git a/src/components/DailyRewards/index.tsx b/src/components/DailyRewards/index.tsx
new file mode 100644
index 0000000..3d74c8a
--- /dev/null
+++ b/src/components/DailyRewards/index.tsx
@@ -0,0 +1,66 @@
+import { useMemo } from "react";
+
+import { DAILY_REWARDS } from "@/constants/discover";
+import { UserPoints } from "@/provider/types/UserProviderType";
+
+interface IDailyRewardsProps {
+ userPoints: UserPoints | null;
+}
+
+export const getLastConsecutiveTrueLength = (claimStatus: boolean[]) => {
+ let currentLength = 0;
+ let maxLength = 0;
+
+ for (let i = 0; i < claimStatus.length; i++) {
+ if (claimStatus[i]) {
+ currentLength++;
+ maxLength = currentLength;
+ } else {
+ currentLength = 0;
+ }
+ }
+
+ return maxLength;
+};
+
+const DailyRewards = ({ userPoints }: IDailyRewardsProps) => {
+ const claimedDays = useMemo(
+ () =>
+ getLastConsecutiveTrueLength(userPoints?.dailyPointsClaimedStatus || []),
+ [userPoints?.dailyPointsClaimedStatus]
+ );
+
+ return (
+ <>
+
+
+ Daily Rewards
+
+
+ Log in everyday to earn extra points!
+
+
+
+ {DAILY_REWARDS.map((item, index) => (
+
+
Day {index + 1}
+ {index < claimedDays ? (
+
+
+
+ ) : (
+
+ + {item.toLocaleString()}
+
+ )}
+
+ ))}
+
+ >
+ );
+};
+
+export default DailyRewards;
diff --git a/src/components/DiscoveryHiddenGems/__test__/index.test.tsx b/src/components/DiscoveryHiddenGems/__test__/index.test.tsx
new file mode 100644
index 0000000..be34d1b
--- /dev/null
+++ b/src/components/DiscoveryHiddenGems/__test__/index.test.tsx
@@ -0,0 +1,37 @@
+// DiscoveryHiddenGems.test.tsx
+import { render, screen } from "@testing-library/react";
+import "@testing-library/jest-dom";
+import { describe, it, expect, vi } from "vitest";
+
+import { voteAppData } from "@/__mocks__/VoteApp";
+
+import DiscoveryHiddenGems from "../index";
+
+// Mocking AppItem
+vi.mock("../../AppItem", () => ({
+ default: () =>
,
+}));
+
+const mockUpdateOpenAppClick = vi.fn();
+
+describe("DiscoveryHiddenGems Component", () => {
+ it("renders the component with the correct text", () => {
+ render(
+
+ );
+ expect(screen.getByText("Discover Hidden Gems!")).toBeInTheDocument();
+ });
+
+ it("renders the AppItem component", () => {
+ render(
+
+ );
+ expect(screen.getByTestId("app-item-mock")).toBeInTheDocument();
+ });
+});
diff --git a/src/components/DiscoveryHiddenGems/index.tsx b/src/components/DiscoveryHiddenGems/index.tsx
new file mode 100644
index 0000000..fc5cd62
--- /dev/null
+++ b/src/components/DiscoveryHiddenGems/index.tsx
@@ -0,0 +1,30 @@
+import { VoteApp } from "@/types/app";
+
+import AppItem from "../AppItem";
+
+
+
+interface IDiscoveryHiddenGemsProps {
+ item: VoteApp;
+ onAppItemClick: (item: VoteApp) => void;
+}
+
+const DiscoveryHiddenGems = ({
+ item,
+ onAppItemClick,
+}: IDiscoveryHiddenGemsProps) => {
+ return (
+
+
+
+
+ Discover Hidden Gems!
+
+
+
+
+
+ );
+};
+
+export default DiscoveryHiddenGems;
diff --git a/src/components/Drawer/__test__/index.test.tsx b/src/components/Drawer/__test__/index.test.tsx
new file mode 100644
index 0000000..00340e6
--- /dev/null
+++ b/src/components/Drawer/__test__/index.test.tsx
@@ -0,0 +1,78 @@
+// Drawer.test.tsx
+import React from "react";
+
+import { render, screen, fireEvent } from "@testing-library/react";
+import "@testing-library/jest-dom";
+import { describe, it, expect, vi } from "vitest";
+
+import Drawer from "../index";
+
+// Mock `motion.div` for testing
+vi.mock("framer-motion", async () => {
+ const actual = await vi.importActual("framer-motion");
+ return {
+ ...actual,
+ motion: {
+ div: ({
+ children,
+ className,
+ ...props
+ }: {
+ children: React.ReactNode;
+ className?: string;
+ }) => (
+
+ {children}
+
+ ),
+ },
+ };
+});
+
+describe("Drawer Component", () => {
+ it("renders content only when visible", () => {
+ const { rerender } = render(
+
+ Content
+
+ );
+
+ // Initially not visible
+ expect(screen.queryByText("Content")).not.toBeInTheDocument();
+
+ // Rerender with visible
+ rerender(
+
+ Content
+
+ );
+ expect(screen.getByText("Content")).toBeInTheDocument();
+ });
+
+ it("calls onClose when background is clicked", () => {
+ const onClose = vi.fn();
+
+ render(
+
+ Content
+
+ );
+
+ // Click the backdrop (select the correct element)
+ const backdrop = screen.getByTestId("backdrop-testid");
+ if (backdrop) fireEvent.click(backdrop);
+
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it("applies correct class based on direction", () => {
+ render(
+
+ Content
+
+ );
+
+ const drawer = screen.getByText("Content").parentElement;
+ expect(drawer).toHaveClass("right-0");
+ });
+});
diff --git a/src/components/Drawer/index.tsx b/src/components/Drawer/index.tsx
new file mode 100644
index 0000000..eff6b14
--- /dev/null
+++ b/src/components/Drawer/index.tsx
@@ -0,0 +1,92 @@
+
+import { ReactNode, useEffect, useRef, useState } from "react";
+
+import clsx from "clsx";
+import { AnimatePresence, motion } from "framer-motion";
+
+interface IDrawerProps {
+ role?: string;
+ isVisible?: boolean;
+ direction?: "left" | "right" | "top" | "bottom";
+ children: ReactNode | ReactNode[];
+ canClose?: boolean;
+ rootClassName?: string;
+ onClose?: (visible: boolean) => void;
+}
+
+const Drawer = ({
+ role = "dialog",
+ isVisible,
+ direction = "left",
+ children,
+ rootClassName,
+ canClose,
+ onClose,
+}: IDrawerProps) => {
+ const containerRef = useRef(null);
+ const [isVisibleState, setIsVisibleState] = useState(isVisible);
+
+ const variants = {
+ hidden: {
+ x: direction === "left" ? "-100%" : direction === "right" ? "100%" : "0",
+ y: direction === "top" ? "-100%" : direction === "bottom" ? "100%" : "0",
+ transition: { type: "tween", ease: "easeInOut", duration: 0.2 },
+ },
+ visible: {
+ x: 0,
+ y: 0,
+ transition: { type: "tween", ease: "easeInOut", duration: 0.2 },
+ },
+ };
+
+ const handleClose = (event: React.MouseEvent) => {
+ event.preventDefault();
+ event.stopPropagation();
+ if (canClose) {
+ setIsVisibleState(false);
+ onClose?.(false);
+ }
+ };
+
+ useEffect(() => {
+ setIsVisibleState(isVisible);
+ }, [isVisible]);
+
+ return (
+ <>
+ {isVisibleState && (
+
+ )}
+
+ {isVisibleState && (
+
+ {children}
+
+ )}
+
+ >
+ );
+};
+
+export default Drawer;
diff --git a/src/components/EnvUnsupported/__test__/index.test.tsx b/src/components/EnvUnsupported/__test__/index.test.tsx
new file mode 100644
index 0000000..1d779d3
--- /dev/null
+++ b/src/components/EnvUnsupported/__test__/index.test.tsx
@@ -0,0 +1,116 @@
+import React from "react";
+
+import { retrieveLaunchParams } from "@telegram-apps/sdk-react";
+import { render, screen } from "@testing-library/react";
+import { vi } from "vitest";
+
+import { EnvUnsupported } from "../index"; // Adjust to your file structure
+
+import "@testing-library/jest-dom";
+
+// Mock AppRoot and Placeholder
+vi.mock("@telegram-apps/telegram-ui", () => ({
+ AppRoot: ({
+ children,
+ platform,
+ }: {
+ children: React.ReactNode;
+ platform: string;
+ }) => (
+
+ {children}
+
+ ),
+ Placeholder: ({
+ header,
+ description,
+ className,
+ children,
+ }: {
+ header: string;
+ description: string;
+ className?: string;
+ children?: React.ReactNode;
+ }) => (
+
+ {children}
+
+ ),
+}));
+
+// Mock retrieveLaunchParams
+vi.mock("@telegram-apps/sdk-react", () => ({
+ retrieveLaunchParams: vi.fn(),
+}));
+
+describe("EnvUnsupported Component", () => {
+ beforeEach(() => {
+ vi.clearAllMocks(); // Clear mocks before each test
+ });
+
+ it("renders the Placeholder with default platform when retrieveLaunchParams throws", () => {
+ // Mock retrieveLaunchParams to throw an error
+ (retrieveLaunchParams as ReturnType).mockImplementation(
+ () => {
+ throw new Error("Mocked error");
+ }
+ );
+
+ render( );
+
+ // Verify AppRoot renders with default platform 'base'
+ const appRoot = screen.getByTestId("app-root");
+ expect(appRoot).toHaveAttribute("data-platform", "base");
+
+ // Verify Placeholder content
+ const placeholder = screen.getByTestId("placeholder");
+ expect(placeholder).toHaveAttribute("data-header", "Oops");
+ expect(placeholder).toHaveAttribute(
+ "data-description",
+ "You are using too old Telegram client to run this application"
+ );
+ expect(screen.getByText("Unsupported environment")).toBeInTheDocument();
+ });
+
+ it("renders with platform 'ios' when platform is 'macos'", () => {
+ // Mock retrieveLaunchParams to return macos platform
+ (retrieveLaunchParams as ReturnType).mockImplementation(
+ () => ({
+ platform: "macos",
+ })
+ );
+
+ render( );
+
+ // Verify AppRoot renders with platform 'ios'
+ const appRoot = screen.getByTestId("app-root");
+ expect(appRoot).toHaveAttribute("data-platform", "ios");
+
+ // Verify Placeholder content
+ expect(screen.getByTestId("placeholder")).toBeInTheDocument();
+ expect(screen.getByText("Unsupported environment")).toBeInTheDocument();
+ });
+
+ it("renders with fallback platform 'base' when platform is unsupported", () => {
+ // Mock retrieveLaunchParams to return an unsupported platform
+ (retrieveLaunchParams as ReturnType).mockImplementation(
+ () => ({
+ platform: "android",
+ })
+ );
+
+ render( );
+
+ // Verify AppRoot renders with platform 'base'
+ const appRoot = screen.getByTestId("app-root");
+ expect(appRoot).toHaveAttribute("data-platform", "base");
+
+ // Verify Placeholder content
+ expect(screen.getByTestId("placeholder")).toBeInTheDocument();
+ });
+});
diff --git a/src/components/EnvUnsupported/index.tsx b/src/components/EnvUnsupported/index.tsx
new file mode 100644
index 0000000..9977afd
--- /dev/null
+++ b/src/components/EnvUnsupported/index.tsx
@@ -0,0 +1,34 @@
+
+import { useMemo } from "react";
+
+import { retrieveLaunchParams } from "@telegram-apps/sdk-react";
+import { Placeholder, AppRoot } from "@telegram-apps/telegram-ui";
+
+export function EnvUnsupported() {
+ const platform = useMemo(() => {
+ let platform = "base";
+ try {
+ const lp = retrieveLaunchParams();
+ platform = lp.platform;
+ } catch (e) {
+ console.error(e);
+ }
+
+ return platform;
+ }, []);
+
+ return (
+
+
+ Unsupported environment
+
+
+ );
+}
diff --git a/src/components/ForYou/ActionButton/index.tsx b/src/components/ForYou/ActionButton/index.tsx
new file mode 100644
index 0000000..6d4e488
--- /dev/null
+++ b/src/components/ForYou/ActionButton/index.tsx
@@ -0,0 +1,181 @@
+/* eslint-disable react-hooks/exhaustive-deps */
+import { useCallback, useEffect, useRef, useState } from "react";
+
+import { CreateTypes } from "canvas-confetti";
+
+import Confetti from "@/components/Confetti";
+import { TgLink } from "@/config";
+import { chainId } from "@/constants/app";
+import { HEART_SHAPE } from "@/constants/canvas-confetti";
+import { postWithToken } from "@/hooks/useData";
+import { getShareText } from "@/pageComponents/PollDetail/utils";
+import { useUserContext } from "@/provider/UserProvider";
+import { VoteApp } from "@/types/app";
+import { stringifyStartAppParams, stringToHex } from "@/utils/start-params";
+
+interface IActionButton {
+ item: VoteApp;
+ totalLikes: number;
+ totalComments: number;
+ totalShares: number;
+ updateOpenAppClick: (alias: string) => void;
+ updateReviewClick: (item: VoteApp) => void;
+ updateLikeAppClick: (likesCount: number) => void;
+}
+
+const ActionButton = ({
+ item,
+ totalLikes = 0,
+ totalComments = 0,
+ totalShares = 0,
+ updateLikeAppClick,
+ updateOpenAppClick,
+ updateReviewClick,
+}: IActionButton) => {
+ const {
+ user: { userPoints },
+ updateUserPoints,
+ } = useUserContext();
+ const confettiInstance = useRef(null);
+ const [totalCurrentLikes, setTotalCurrentLikes] = useState(totalLikes);
+ const [likeCount, setLikeCount] = useState(0);
+
+ const handleClick = () => {
+ setLikeCount((prevCount) => prevCount + 1);
+ };
+
+ const fetchRankingLike = useCallback(
+ async (likeCount: number) => {
+ const {
+ data: { userTotalPoints },
+ } = await postWithToken("/api/app/ranking/like", {
+ chainId,
+ proposalId: "",
+ likeList: [
+ {
+ alias: item.alias,
+ likeAmount: likeCount,
+ },
+ ],
+ });
+ updateUserPoints(userTotalPoints || userPoints?.userTotalPoints);
+ },
+ [updateUserPoints, userPoints?.userTotalPoints]
+ );
+
+ // Effect to handle debouncing
+ useEffect(() => {
+ if (likeCount > 0) {
+ const timer = setTimeout(() => {
+ updateLikeAppClick(likeCount);
+ setTotalCurrentLikes((prev) => prev + likeCount);
+ fetchRankingLike(likeCount);
+ setLikeCount(0);
+ }, 700);
+
+ return () => clearTimeout(timer); // Cleanup timeout on unmount or update
+ }
+ }, [item.alias, likeCount]);
+
+ const onInit = ({ confetti }: { confetti: CreateTypes }) => {
+ confettiInstance.current = confetti;
+ };
+
+ const onLikeClick = () => {
+ confettiInstance.current?.({
+ angle: 110,
+ particleCount: 15,
+ spread: 70,
+ origin: { y: 0.2, x: 0.88 },
+ disableForReducedMotion: true,
+ shapes: [HEART_SHAPE],
+ zIndex: 10,
+ });
+
+ window.Telegram.WebApp.HapticFeedback.notificationOccurred("success");
+
+ handleClick();
+ };
+
+ const onOpenAppClick = () => {
+ shareToTelegram();
+ updateOpenAppClick(item.alias);
+ window.Telegram.WebApp.HapticFeedback.notificationOccurred("success");
+ };
+
+ const generateShareUrl = () => {
+ const paramsStr = stringifyStartAppParams({
+ alias: stringToHex(item.alias),
+ });
+ return `${TgLink}?startapp=${paramsStr}`;
+ };
+
+ const shareToTelegram = () => {
+ if (window?.Telegram?.WebApp?.openTelegramLink) {
+ const url = encodeURIComponent(generateShareUrl());
+ const shareText = encodeURIComponent(
+ getShareText(
+ `I just found this awesome app "${item.title}" on Votigram! 🌟`,
+ `Join me on Votigram to discover more fun apps, and earn points for USDT airdrops! 💎 \n`
+ )
+ );
+ window?.Telegram?.WebApp?.openTelegramLink(
+ `https://t.me/share/url?url=${url}&text=${shareText}`
+ );
+ }
+ };
+
+ return (
+ <>
+
+
+
+
+
+
+ {totalCurrentLikes + likeCount}
+
+
+
{
+ updateReviewClick(item);
+ window.Telegram.WebApp.HapticFeedback.notificationOccurred(
+ "success"
+ );
+ }}
+ >
+
+
+
+
+ {totalComments}
+
+
+
+
+
+
+
+ {totalShares}
+
+
+
+
+ >
+ );
+};
+
+export default ActionButton;
diff --git a/src/components/ForYou/AppDetail/__test__/index.test.tsx b/src/components/ForYou/AppDetail/__test__/index.test.tsx
new file mode 100644
index 0000000..24b840f
--- /dev/null
+++ b/src/components/ForYou/AppDetail/__test__/index.test.tsx
@@ -0,0 +1,59 @@
+// AppDetail.test.tsx
+import React from "react";
+
+import { render, screen, fireEvent } from "@testing-library/react";
+import { describe, it, expect, vi } from "vitest";
+import "@testing-library/jest-dom";
+
+import { voteAppData } from "@/__mocks__/VoteApp";
+
+import AppDetail from "../index";
+
+const mockUpdateOpenAppClick = vi.fn();
+
+describe("AppDetail Component", () => {
+ it("renders correctly with initial collapsed view", () => {
+ render(
+
+ );
+ expect(screen.getByText("Tonalytics")).toBeInTheDocument();
+ expect(screen.getByText("Follow 👉🏻 @tonalytics1")).toBeInTheDocument();
+ const descriptionElement = screen.getByText(
+ "Tonalities An innovative bot designed to analyze data and statistics of the TON (Telegram Open Network) cryptocurrency. With its help, users can receive detailed information about cryptocurrency exchange rates, trading volumes, price changes and other key indicators necessary for making informed decisions in the market.Thanks to Tonalities, any user can access TON cryptocurrency analytics and statistics in a convenient format directly in the messenger. The bot provides up-to-date information about the market situation, which makes it easier to track trends and help make informed decisions.Analytics has the functionality of analyzing graphs, visualizing data and creating reports, which allows users to conduct an in-depth analysis of the TON cryptocurrency market. The bot is becoming a useful tool for traders, investors and anyone who is interested in the price dynamics of cryptocurrencies and wants to be aware of all changes in the market."
+ );
+ expect(descriptionElement).toHaveStyle("opacity: 0");
+ });
+
+ it("expands description on click", () => {
+ render(
+
+ );
+ const container = screen.getByText("Tonalytics").closest("div");
+ fireEvent.click(container!);
+ expect(screen.getByText("Tonalytics")).toBeInTheDocument();
+ });
+
+ it("collapses when clicking outside", () => {
+ render(
+
+ );
+ const container = screen.getByText("Tonalytics").closest("div");
+ fireEvent.click(container!);
+ expect(screen.getByText("Tonalytics")).toBeInTheDocument();
+
+ fireEvent.mouseDown(document);
+ const descriptionElement = screen.getByText(
+ "Tonalities An innovative bot designed to analyze data and statistics of the TON (Telegram Open Network) cryptocurrency. With its help, users can receive detailed information about cryptocurrency exchange rates, trading volumes, price changes and other key indicators necessary for making informed decisions in the market.Thanks to Tonalities, any user can access TON cryptocurrency analytics and statistics in a convenient format directly in the messenger. The bot provides up-to-date information about the market situation, which makes it easier to track trends and help make informed decisions.Analytics has the functionality of analyzing graphs, visualizing data and creating reports, which allows users to conduct an in-depth analysis of the TON cryptocurrency market. The bot is becoming a useful tool for traders, investors and anyone who is interested in the price dynamics of cryptocurrencies and wants to be aware of all changes in the market."
+ );
+ expect(descriptionElement).toHaveStyle("opacity: 0");
+ });
+});
diff --git a/src/components/ForYou/AppDetail/index.tsx b/src/components/ForYou/AppDetail/index.tsx
new file mode 100644
index 0000000..7ca3301
--- /dev/null
+++ b/src/components/ForYou/AppDetail/index.tsx
@@ -0,0 +1,125 @@
+import React, { useEffect, useRef, useState } from "react";
+
+import { motion } from "framer-motion";
+
+import { DISCOVERY_CATEGORY_MAP } from "@/constants/discover";
+import { VoteApp } from "@/types/app";
+
+const containerVariants = {
+ hidden: {
+ paddingTop: 0,
+ },
+ visible: {
+ paddingTop: 120,
+ background: "linear-gradient(rgba(0, 0, 0, 0) 0%, rgb(0, 0, 0) 40%)",
+ },
+};
+
+const descriptionVariants = {
+ hidden: { opacity: 0, height: 0 },
+ visible: { opacity: 1, height: 'auto', marginBottom: 14, maxHeight: 500 },
+};
+
+interface IAppDetailProps {
+ item: VoteApp;
+ updateOpenAppClick(alias: string, url: string): void;
+}
+
+const AppDetail = ({ item, updateOpenAppClick }: IAppDetailProps) => {
+ const [isExpand, setIsExpand] = useState(false);
+ const containerRef = useRef(null);
+
+ const handleClickOutside = (event: MouseEvent) => {
+ if (
+ containerRef.current &&
+ !containerRef.current.contains(event.target as Node)
+ ) {
+ setIsExpand(false);
+ }
+ };
+
+ useEffect(() => {
+ document.addEventListener("mousedown", handleClickOutside, true);
+
+ return () => {
+ document.removeEventListener("mousedown", handleClickOutside, true);
+ };
+ }, []);
+
+ const onOpenAppClick = () => {
+ updateOpenAppClick(item.alias, item.url);
+ };
+
+ return (
+ {
+ setIsExpand(!isExpand);
+ }}
+ >
+
+
+
+
+ {item.title}
+
+
+ {item.description}
+
+
+
+
+ {item.longDescription}
+
+
+
+
+ {item.categories?.map((category) => (
+
+
+ {DISCOVERY_CATEGORY_MAP?.[category] || ""}
+
+
+ ))}
+
+
+
+ Open
+
+
+
+
+
+ );
+};
+
+export default AppDetail;
diff --git a/src/components/ForYou/index.tsx b/src/components/ForYou/index.tsx
new file mode 100644
index 0000000..7a306d8
--- /dev/null
+++ b/src/components/ForYou/index.tsx
@@ -0,0 +1,248 @@
+import { useEffect, useRef, useState } from "react";
+
+import { useThrottleFn } from "ahooks";
+import { motion, PanInfo } from "framer-motion";
+
+import { chainId } from "@/constants/app";
+import {
+ APP_CATEGORY,
+ APP_TYPE,
+ DISCOVER_CATEGORY,
+} from "@/constants/discover";
+import { postWithToken } from "@/hooks/useData";
+import { useUserContext } from "@/provider/UserProvider";
+import { VoteApp } from "@/types/app";
+
+import ImageCarousel from "../ImageCarousel";
+import AppDetail from "./AppDetail";
+import AdVideo from "../AdVideo";
+import Modal from "../Modal";
+import ActionButton from "./ActionButton";
+import CheckboxGroup from "../CheckboxGroup";
+import Drawer from "../Drawer";
+import ReviewComment from "../ReviewComment";
+import TelegramHeader from "../TelegramHeader";
+
+interface IForYouType {
+ currentForyouPage: number;
+ items: VoteApp[];
+ fetchForYouData: (alias: string[]) => void;
+}
+
+const ForYou = ({
+ items,
+ fetchForYouData,
+ currentForyouPage = 1,
+}: IForYouType) => {
+ const {
+ user: { isNewUser },
+ updateUserStatus,
+ } = useUserContext();
+ const [isInputFocus, setIsInputFocus] = useState(false);
+ const currentPage = useRef(currentForyouPage);
+ const [forYouItems, setForYouItems] = useState(items);
+ const [currentIndex, setCurrentIndex] = useState(0);
+ const [isShowReviews, setIsShowReviews] = useState(false);
+ const [showChoosen, setShowChoosen] = useState(isNewUser);
+ const [interests, setInterests] = useState([]);
+ const [currentActiveApp, setCurrentActiveApp] = useState<
+ VoteApp | undefined
+ >();
+ const height = window.innerHeight;
+
+ useEffect(() => {
+ if (items && items.length === 0) {
+ fetchForYouData([]);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [items]);
+
+ useEffect(() => {
+ if (items) {
+ setForYouItems((prev) =>
+ currentForyouPage > currentPage.current
+ ? [...prev, ...(items || [])]
+ : items
+ );
+ }
+ }, [items, currentForyouPage]);
+
+ const handleDragEnd = async (
+ _: MouseEvent | TouchEvent | PointerEvent,
+ info: PanInfo
+ ) => {
+ // Check velocity to determine the predominant direction
+ const predominantDirection =
+ Math.abs(info.velocity.y) > Math.abs(info.velocity.x);
+ if (predominantDirection) {
+ const direction = info.offset.y > 0 ? -1 : 1;
+ let nextIndex = currentIndex + direction;
+
+ // Ensure nextIndex is within bounds
+ if (nextIndex < 0) {
+ nextIndex = 0;
+ } else if (nextIndex >= forYouItems.length) {
+ nextIndex = forYouItems.length - 1;
+ }
+
+ setCurrentIndex(nextIndex);
+ // Load more videos when reaching the second-to-last item
+ if (nextIndex >= forYouItems.length - 5) {
+ await fetchForYouData(forYouItems.slice(-10).map((item) => item.alias));
+ }
+ }
+ };
+
+ const updateLikeAppClick = (likesCount: number) => {
+ const list = [...forYouItems];
+ list[currentIndex].totalLikes =
+ (list[currentIndex].totalLikes || 0) + likesCount;
+ setForYouItems(list);
+ };
+
+ const updateShareAppClick = (alias: string) => {
+ postWithToken("/api/app/user/share-app", {
+ chainId,
+ alias,
+ });
+ const list = [...forYouItems];
+ list[currentIndex].totalShares = (list[currentIndex].totalShares || 0) + 1;
+ setForYouItems(list);
+ };
+
+ const updateOpenAppClick = (alias: string, url: string) => {
+ postWithToken("/api/app/user/open-app", {
+ chainId,
+ alias,
+ });
+ window.open(url)
+ };
+
+ const updateReviewClick = (item: VoteApp) => {
+ setIsShowReviews(true);
+ setCurrentActiveApp(item);
+ };
+
+ const onDrawerClose = () => {
+ setIsShowReviews(false);
+ };
+
+ const onComment = (totalComments: number) => {
+ const list = [...forYouItems];
+ list[currentIndex].totalComments = totalComments;
+ setForYouItems(list);
+ };
+
+ const { run: chooseInterest } = useThrottleFn(
+ async () => {
+ setShowChoosen(false);
+ updateUserStatus(false);
+ try {
+ await postWithToken("/api/app/discover/choose", {
+ chainId,
+ choices: interests,
+ });
+ } catch (error) {
+ console.error(error);
+ }
+ },
+ { wait: 700 }
+ );
+
+ return (
+ <>
+
+
+ {forYouItems?.length > 0 && (
+
+ {forYouItems.map((item, index) => (
+
+ {item.appType === APP_TYPE.AD && index === currentIndex ? (
+
+ ) : (
+ <>
+
+
+ >
+ )}
+
+
+ ))}
+
+ )}
+
+
+ Select Your Areas of Interest
+
+
+ Your preferences will help us create a journey unique to you.
+
+
+
+
+
+ Let's Begin
+
+
+
+
+
+
+ >
+ );
+};
+
+export default ForYou;
diff --git a/src/components/FormItem/__test__/index.test.tsx b/src/components/FormItem/__test__/index.test.tsx
new file mode 100644
index 0000000..5fd1d68
--- /dev/null
+++ b/src/components/FormItem/__test__/index.test.tsx
@@ -0,0 +1,63 @@
+// FormItem.test.tsx
+import { render, screen } from "@testing-library/react";
+import "@testing-library/jest-dom";
+import { describe, it, expect } from "vitest";
+
+import FormItem from "../index";
+
+describe("FormItem Component", () => {
+ it("renders the component with label and children", () => {
+ render(
+
+
+
+ );
+
+ expect(screen.getByText("Test Label")).toBeInTheDocument();
+ expect(screen.getByTestId("test-input")).toBeInTheDocument();
+ });
+
+ it("displays the description if provided", () => {
+ render(
+
+
+
+ );
+
+ expect(screen.getByText("This is a description.")).toBeInTheDocument();
+ });
+
+ it("displays the error text if provided", () => {
+ render(
+
+
+
+ );
+
+ expect(screen.getByText("*This is an error.")).toBeInTheDocument();
+ });
+
+ it("shows required asterisk when required is true", () => {
+ render(
+
+
+
+ );
+
+ const labelElement = screen.getByText("Test Label");
+ const asteriskElement = labelElement.closest("span")?.querySelector("span");
+ expect(asteriskElement).toHaveTextContent("*");
+ });
+
+ it("applies custom className to container", () => {
+ render(
+
+
+
+ );
+
+ // Directly query the root div by additional specificity if needed
+ const container = screen.getByTestId("form-item-container");
+ expect(container).toHaveClass("custom-class");
+ });
+});
diff --git a/src/components/FormItem/index.tsx b/src/components/FormItem/index.tsx
new file mode 100644
index 0000000..d946f19
--- /dev/null
+++ b/src/components/FormItem/index.tsx
@@ -0,0 +1,47 @@
+import React from "react";
+
+import clsx from "clsx";
+
+
+interface IFormItemProps {
+ label: string;
+ className?: string;
+ desc?: string;
+ children: React.ReactNode;
+ errorText?: string;
+ required?: boolean;
+}
+
+// FormItem component
+const FormItem: React.FC = ({
+ label,
+ desc,
+ className,
+ children,
+ errorText,
+ required,
+}) => {
+ return (
+
+
+
+ {label}
+ {required && * }
+
+ {desc && (
+
+ {desc}
+
+ )}
+
+ {children}
+ {errorText && (
+
+ *{errorText}
+
+ )}
+
+ );
+};
+
+export default FormItem;
diff --git a/src/components/Home/index.tsx b/src/components/Home/index.tsx
new file mode 100644
index 0000000..5a45f6b
--- /dev/null
+++ b/src/components/Home/index.tsx
@@ -0,0 +1,334 @@
+import { useEffect, useRef, useState } from "react";
+
+import { chainId } from "@/constants/app";
+import { APP_CATEGORY, DISCOVER_CATEGORY } from "@/constants/discover";
+import { TAB_LIST } from "@/constants/navigation";
+import { TMSAP_TAB, VOTE_TABS } from "@/constants/vote";
+import { useAdsgram } from "@/hooks/useAdsgram";
+import useData, { postWithToken } from "@/hooks/useData";
+import useSetSearchParams from "@/hooks/useSetSearchParams";
+import { useUserContext } from "@/provider/UserProvider";
+import { VoteApp } from "@/types/app";
+
+import AppList from "../AppList";
+import CategoryPillList from "../CategoryPillList";
+import DailyRewards from "../DailyRewards";
+import DiscoveryHiddenGems from "../DiscoveryHiddenGems";
+import Modal from "../Modal";
+import PointsCounter from "../PointsCounter";
+import SearchPanel from "../SearchPanel";
+import TelegramHeader from "../TelegramHeader";
+import TopVotedApps from "../TopVotedApps";
+
+interface IHomeProps {
+ weeklyTopVotedApps: VoteApp[];
+ discoverHiddenGems: VoteApp;
+ madeForYouItems: VoteApp[];
+ onAppItemClick: (item?: VoteApp) => void;
+ recommendList: VoteApp[];
+ switchTab: (tab: TAB_LIST) => void;
+}
+
+const PAGE_SIZE = 20;
+
+const Home = ({
+ onAppItemClick,
+ switchTab,
+ recommendList,
+ weeklyTopVotedApps,
+ discoverHiddenGems,
+ madeForYouItems,
+}: IHomeProps) => {
+ const {
+ user: { userPoints },
+ updateUserPoints,
+ updateDailyLoginPointsStatus,
+ } = useUserContext();
+
+ const [isSearching, setIsSearching] = useState(false);
+ const scrollViewRef = useRef(null);
+ const [searchList, setSearchList] = useState([]);
+ const [pageIndex, setPageIndex] = useState(0);
+ const [newAmount, setNewAmount] = useState(0);
+ const [noMore, setNoMore] = useState(false);
+ const [keyward, setKeyward] = useState("");
+ const [category, setCategory] = useState(APP_CATEGORY.ALL);
+ const [adPrams, setAdParams] = useState<{
+ timeStamp?: number;
+ signature?: string;
+ }>({});
+ const { updateQueryParam } = useSetSearchParams();
+
+ const { data: searchData, isLoading } = useData(
+ isSearching && (category || keyward)
+ ? `/api/app/discover/app-list?${new URLSearchParams({
+ chainId,
+ category: category.toString(),
+ search: keyward,
+ skipCount: (pageIndex * PAGE_SIZE).toString(),
+ maxResultCount: PAGE_SIZE.toString(),
+ }).toString()}`
+ : null
+ );
+
+ const { data: bannerInfo } = useData(
+ `/api/app/ranking/banner-info?chainId=${chainId}`
+ );
+ useEffect(() => {
+ const { data } = searchData || {};
+ if (data && Array.isArray(data)) {
+ setSearchList((prev) => (pageIndex === 0 ? data : [...prev, ...data]));
+ setNoMore(data.length < PAGE_SIZE);
+ }
+ }, [category, pageIndex, searchData]);
+
+ useEffect(() => {
+ const { notViewedNewAppCount } = bannerInfo || {};
+ if (notViewedNewAppCount) {
+ setNewAmount(notViewedNewAppCount || 0);
+ }
+ }, [bannerInfo]);
+
+ const onViewApp = (item: VoteApp) => {
+ onAppItemClick(item);
+ setNewAmount((prev) => prev - 1);
+ postWithToken("/api/app/discover/view-app", {
+ chainId,
+ aliases: [item.alias],
+ });
+ };
+
+ const onClaimClick = async () => {
+ try {
+ const result = await postWithToken("/api/app/user/login-points/collect", {
+ chainId,
+ timeStamp: adPrams?.timeStamp?.toString(),
+ signature: adPrams.signature,
+ });
+ if (result?.data?.userTotalPoints) {
+ updateDailyLoginPointsStatus(result?.data?.userTotalPoints);
+ } else {
+ updateDailyLoginPointsStatus(userPoints?.userTotalPoints || 0);
+ }
+ } catch (e) {
+ console.error(e);
+ updateDailyLoginPointsStatus(userPoints?.userTotalPoints || 0);
+ }
+ };
+
+ const showAd = useAdsgram({
+ blockId: import.meta.env.VITE_ADSGRAM_ID.toString() || "",
+ onReward: updateUserPoints,
+ onError: () => {},
+ onSkip: () => {},
+ onFinish: (timeStamp, signature) => setAdParams({ timeStamp, signature }),
+ });
+
+ useEffect(() => {
+ const scrollRef = scrollViewRef.current;
+
+ const handleScroll = () => {
+ const scrollRef = scrollViewRef.current;
+ if (!scrollRef) return;
+ if (
+ scrollRef.scrollHeight - scrollRef.scrollTop - scrollRef.clientHeight <
+ 50 &&
+ !noMore &&
+ !isLoading
+ ) {
+ setPageIndex((page) => page + 1);
+ }
+ };
+
+ if (scrollRef) {
+ scrollRef.addEventListener("scroll", handleScroll);
+ }
+
+ return () => {
+ if (scrollRef) {
+ scrollRef.removeEventListener("scroll", handleScroll);
+ }
+ };
+ }, [isLoading, noMore, scrollViewRef]);
+
+ return (
+ <>
+ {isSearching && }
+
+
+
+ {isSearching ? (
+ {
+ setCategory(APP_CATEGORY.ALL);
+ setIsSearching(false);
+ }}
+ />
+ ) : (
+
+ Hi,
+ {window?.Telegram?.WebApp?.initDataUnsafe?.user?.first_name ||
+ " "}
+
+ )}
+
+
+
+ {
+ setPageIndex(0);
+ setKeyward(e.target.value);
+ }}
+ maxLength={200}
+ onFocus={() => {
+ setIsSearching(true);
+ }}
+ />
+
+ {isSearching && keyward.length >= 200 && (
+
+ Should contain no more than 200 characters.
+
+ )}
+
+
{
+ setPageIndex(0);
+ setCategory(value);
+ setIsSearching(value !== APP_CATEGORY.ALL);
+ }}
+ />
+ {isSearching ? (
+ 0 || keyward?.length > 0
+ ? searchList
+ : recommendList
+ }
+ updateUserPoints={updateUserPoints}
+ onAppItemClick={onViewApp}
+ />
+ ) : (
+ <>
+
+
{
+ updateQueryParam(
+ [
+ {
+ key: "vote_tab",
+ value: VOTE_TABS.TMAS,
+ },
+ {
+ key: "tmas",
+ value: TMSAP_TAB.CURRENT.toString(),
+ },
+ ],
+ true
+ );
+ switchTab(TAB_LIST.VOTE);
+ }}
+ >
+
+
+
+
+
+ Community Leaderboard
+
+
+ Vote for your favourite TMAs
+
+
+
+
onAppItemClick()}
+ >
+
+
+
+
+ Browse TMAs
+
+
+
switchTab(TAB_LIST.PEN)}
+ >
+
+
+
+
+
+ My Profile
+
+
+
+
+
+
+
+
+ >
+ )}
+
+
+
+
+ Watch Ads To Double The Point
+
+
+ Claim Today's Reward
+
+
+ {`Tips: Earn more points to get you a bigger \nshare of the USDT Airdrop!`}
+
+ >
+ );
+};
+
+export default Home;
diff --git a/src/components/ImageCarousel/__test__/index.test.tsx b/src/components/ImageCarousel/__test__/index.test.tsx
new file mode 100644
index 0000000..39006e7
--- /dev/null
+++ b/src/components/ImageCarousel/__test__/index.test.tsx
@@ -0,0 +1,77 @@
+// ImageCarousel.test.tsx
+import React from "react";
+
+import { render, screen } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import ImageCarousel from "../index"; // Adjust the path as necessary
+
+import "@testing-library/jest-dom"; // For extended assertions
+
+// Mock the Telegram WebApp Haptic Feedback
+const mockImpactOccurred = vi.fn();
+
+beforeEach(() => {
+ global.window.Telegram = {
+ WebApp: {
+ HapticFeedback: {
+ impactOccurred: mockImpactOccurred,
+ notificationOccurred: vi.fn(),
+ selectionChanged: vi.fn(),
+ },
+ },
+ } as unknown as TelegramWebApp;
+});
+
+// Mock Swiper components
+vi.mock("swiper/react", () => ({
+ Swiper: ({
+ children,
+ onActiveIndexChange,
+ }: {
+ children: React.ReactNode;
+ onActiveIndexChange: () => void;
+ }) => {
+ React.useEffect(() => {
+ if (onActiveIndexChange) {
+ onActiveIndexChange(); // Simulate active index change
+ }
+ }, [onActiveIndexChange]);
+ return {children}
;
+ },
+ SwiperSlide: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+}));
+
+describe("ImageCarousel", () => {
+ const items = [
+ "https://example.com/image1.jpg",
+ "https://example.com/image2.jpg",
+ "https://example.com/image3.jpg",
+ ];
+
+ it("renders without crashing", () => {
+ render( );
+ expect(screen.getAllByRole("img").length).toBe(3);
+ });
+
+ it("renders correct number of slides", () => {
+ render( );
+ const slides = screen.getAllByRole("img");
+ expect(slides).toHaveLength(items.length);
+ });
+
+ it("renders images with correct src attributes", () => {
+ render( );
+ items.forEach((item, index) => {
+ const img = screen.getAllByRole("img")[index];
+ expect(img).toHaveAttribute("src", item);
+ });
+ });
+
+ it("triggers haptic feedback on active index change", () => {
+ render( );
+ expect(mockImpactOccurred).toHaveBeenCalledWith("light");
+ });
+});
diff --git a/src/components/ImageCarousel/index.css b/src/components/ImageCarousel/index.css
new file mode 100644
index 0000000..db10db2
--- /dev/null
+++ b/src/components/ImageCarousel/index.css
@@ -0,0 +1,45 @@
+.swiper {
+ width: 100%;
+ height: 63%;
+ position: relative;
+}
+
+.swiper-slide {
+ text-align: center;
+ font-size: 18px;
+
+ display: flex;
+ justify-content: center;
+ align-items: center;
+
+ img {
+ border-radius: 8px;
+ }
+}
+
+.swiper-slide {
+ max-width: 217px;
+ width: 100%;
+ max-height: 485px;
+ border-radius: 11px;
+ transition: opacity 0.5s ease-in-out;
+
+ &.swiper-slide-active {
+ opacity: 1;
+ }
+
+ &:not(.swiper-slide-active) {
+ opacity: 0.4;
+ }
+}
+
+.swiper-pagination-bullet {
+ width: 5px;
+ height: 5px;
+ background-color: #383838;
+ opacity: 1;
+
+ &.swiper-pagination-bullet-active {
+ @apply bg-primary;
+ }
+}
\ No newline at end of file
diff --git a/src/components/ImageCarousel/index.tsx b/src/components/ImageCarousel/index.tsx
new file mode 100644
index 0000000..12db552
--- /dev/null
+++ b/src/components/ImageCarousel/index.tsx
@@ -0,0 +1,39 @@
+import { Pagination } from "swiper/modules";
+import { Swiper, SwiperSlide } from "swiper/react";
+
+
+import "swiper/css";
+import "swiper/css/pagination";
+
+import "./index.css";
+
+interface IImageCarouselProps {
+ className?: string;
+ items: string[];
+}
+
+const ImageCarousel = ({ className, items }: IImageCarouselProps) => {
+ return (
+ {
+ window.Telegram.WebApp.HapticFeedback.impactOccurred("light");
+ }}
+ >
+ {items?.map((item: string) => (
+
+
+
+ ))}
+
+ );
+};
+
+export default ImageCarousel;
diff --git a/src/components/Input/__test__/index.test.tsx b/src/components/Input/__test__/index.test.tsx
new file mode 100644
index 0000000..a9b74a3
--- /dev/null
+++ b/src/components/Input/__test__/index.test.tsx
@@ -0,0 +1,47 @@
+// Input.test.tsx
+import { render, screen, fireEvent } from "@testing-library/react";
+import "@testing-library/jest-dom";
+import { describe, it, expect, vi } from "vitest";
+
+import Input from "../index";
+
+describe("Input Component", () => {
+ it("renders with placeholder text", () => {
+ render( );
+ const inputElement = screen.getByPlaceholderText("Type here...");
+ expect(inputElement).toBeInTheDocument();
+ });
+
+ it("updates value on change and calls onChange", () => {
+ const handleChange = vi.fn();
+ render( );
+ const inputElement = screen.getByPlaceholderText("Please Enter...");
+
+ fireEvent.change(inputElement, { target: { value: "new value" } });
+
+ expect(inputElement).toHaveValue("new value");
+ expect(handleChange).toHaveBeenCalledWith("new value");
+ });
+
+ it("renders clear button and clears input on click", () => {
+ const handleChange = vi.fn();
+ render(
+
+ );
+
+ const inputElement = screen.getByPlaceholderText("Please Enter...");
+ expect(inputElement).toHaveValue("to clear");
+
+ const clearButton = screen.getByRole("button");
+ fireEvent.click(clearButton);
+
+ expect(inputElement).toHaveValue("");
+ expect(handleChange).toHaveBeenCalledWith("");
+ });
+
+ it("does not show clear button when showClearBtn is false", () => {
+ render( );
+ const clearButton = screen.queryByRole("button");
+ expect(clearButton).not.toBeInTheDocument();
+ });
+});
diff --git a/src/components/Input/index.tsx b/src/components/Input/index.tsx
new file mode 100644
index 0000000..48a6d4a
--- /dev/null
+++ b/src/components/Input/index.tsx
@@ -0,0 +1,67 @@
+import React, { useEffect, useState } from "react";
+
+import clsx from "clsx";
+
+
+interface IInputProps {
+ value?: string;
+ maxLength?: number;
+ defaultValue?: string;
+ className?: string;
+ placeholder?: string;
+ showClearBtn?: boolean;
+ onChange?: (value: string) => void;
+}
+
+const Input = ({
+ value: parentValue,
+ defaultValue,
+ placeholder,
+ className,
+ maxLength,
+ showClearBtn,
+ onChange,
+}: IInputProps) => {
+ const [value, setValue] = useState(defaultValue || "");
+
+ const handleChange = (e: React.ChangeEvent) => {
+ setValue(e.target.value || '');
+ onChange?.(e.target.value || '');
+ };
+
+ const clearInput = () => {
+ setValue("");
+ onChange?.('');
+ };
+
+ useEffect(() => {
+ setValue(parentValue || "");
+ }, [parentValue]);
+
+ return (
+
+
+ {value && showClearBtn && (
+
+
+
+ )}
+
+ );
+};
+
+export default Input;
diff --git a/src/components/InputGroup/__test__/index.test.tsx b/src/components/InputGroup/__test__/index.test.tsx
new file mode 100644
index 0000000..9bc6171
--- /dev/null
+++ b/src/components/InputGroup/__test__/index.test.tsx
@@ -0,0 +1,119 @@
+// InputGroup.test.tsx
+import { ReactNode } from "react";
+
+import { render, screen, fireEvent } from "@testing-library/react";
+import "@testing-library/jest-dom";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+import InputGroup from "../index";
+import { VoteOption } from "../type";
+
+interface IInputProps {
+ value?: string;
+ maxLength?: number;
+ className?: string;
+ placeholder?: string;
+ showClearBtn?: boolean;
+ onChange?: (value: string) => void;
+}
+
+interface IUploadProps {
+ extensions?: string[];
+ fileLimit?: string;
+ className?: string;
+ needCrop?: boolean;
+ children?: ReactNode;
+ aspect?: number;
+ onFinish?(url: string): void;
+}
+
+// Mock the input and upload components
+vi.mock("../Input", () => ({
+ default: ({ onChange, value, placeholder }: IInputProps) => (
+ onChange?.(e.target.value)}
+ />
+ ),
+}));
+
+vi.mock("../Upload", () => ({
+ default: ({ onFinish }: IUploadProps) => (
+ onFinish?.("new-icon")}
+ />
+ ),
+}));
+
+describe("InputGroup Component", () => {
+ const mockOptions: VoteOption[] = [
+ { id: 1, title: "Option 1" },
+ { id: 2, title: "Option 2" },
+ ];
+
+ let handleChange: (options: VoteOption[]) => void;
+
+ beforeEach(() => {
+ handleChange = vi.fn();
+ });
+
+ it("renders the initial options", () => {
+ render( );
+ expect(screen.getByPlaceholderText("Option 1")).toBeInTheDocument();
+ expect(screen.getByPlaceholderText("Option 2")).toBeInTheDocument();
+ });
+
+ it('adds a new option when "Add New Option" button is clicked', () => {
+ render( );
+ const button = screen.getByRole("button", { name: /add new option/i });
+
+ fireEvent.click(button);
+
+ // Validate handleChange has been called with new state of options
+ expect(handleChange).toHaveBeenCalled();
+ expect(handleChange).toHaveBeenCalledWith(
+ expect.arrayContaining([{ id: expect.any(Number), title: "" }])
+ );
+ });
+
+ it("removes an option when remove icon is clicked", () => {
+ render( );
+
+ const removeButtons = screen.getAllByText("-");
+ fireEvent.click(removeButtons[0]);
+
+ // Validate handleChange has been called with updated state of options
+ expect(handleChange).toHaveBeenCalled();
+ expect(handleChange).toHaveBeenCalledWith([{ id: 2, title: "Option 2" }]);
+ });
+
+ it("updates the option title on input change", () => {
+ render( );
+ const input = screen.getByPlaceholderText("Option 1");
+
+ fireEvent.change(input, { target: { value: "Updated Option 1" } });
+
+ // Validate handleChange has been called with updated option title
+ expect(handleChange).toHaveBeenCalledWith([
+ { id: 1, title: "Updated Option 1" },
+ { id: 2, title: "Option 2" },
+ ]);
+ });
+
+ // it('updates the icon when upload is finished', () => {
+ // render( );
+ // const uploadInputs = screen.getAllByTestId('upload-btn');
+
+ // // Simulate the file input change
+ // fireEvent.change(uploadInputs[0]);
+
+ // // Validate handleChange has been called with new icon value
+ // expect(handleChange).toHaveBeenCalledWith([
+ // { id: 1, title: 'Option 1', icon: 'new-icon' },
+ // { id: 2, title: 'Option 2' },
+ // ]);
+ // });
+});
diff --git a/src/components/InputGroup/index.tsx b/src/components/InputGroup/index.tsx
new file mode 100644
index 0000000..f6d6cb9
--- /dev/null
+++ b/src/components/InputGroup/index.tsx
@@ -0,0 +1,87 @@
+import React, { useEffect, useState } from "react";
+
+import Input from "../Input";
+import Upload from "../Upload";
+import { VoteOption } from "./type";
+
+interface IInputGroupProps {
+ value?: VoteOption[];
+ defaultValues?: VoteOption[];
+ onChange?: (options: VoteOption[]) => void;
+}
+
+const InputGroup: React.FC = ({ value, defaultValues, onChange }) => {
+ const [options, setOptions] = useState(value || defaultValues || []);
+
+ const addOptionToEnd = () => {
+ const opts = [...options, { id: Date.now(), title: "" }]
+ setOptions(opts);
+ onChange?.(opts);
+ };
+
+ const removeOption = (index: number) => {
+ const newOptions = options.filter((_, i) => i !== index);
+ setOptions(newOptions);
+ onChange?.(newOptions);
+ };
+
+ const handleInputChange = (index: number, value: string) => {
+ const newOptions = [...options];
+ newOptions[index].title = value;
+ setOptions(newOptions);
+ onChange?.(newOptions);
+ };
+
+ const handleIconChange = (index: number, icon: string) => {
+ const newOptions = [...options];
+ newOptions[index].icon = icon;
+ setOptions(newOptions);
+ onChange?.(newOptions);
+ };
+
+ useEffect(() => {
+ if (value?.length) {
+ setOptions(value);
+ }
+ }, [value])
+
+ return (
+ <>
+ {options.map((option, index) => (
+
+
handleIconChange(index, value)}
+ >
+
+
+
handleInputChange(index, value)}
+ placeholder={`Option ${index + 1}`}
+ maxLength={50}
+ showClearBtn
+ />
+
removeOption(index)}
+ className="ml-[3px] flex items-center justify-center w-[15px] h-[15px] leading-[13px] font-bold text-[16px] bg-transparent border border-input-placeholder text-input-placeholder rounded-[50%] flex-none"
+ >
+ -
+
+
+ ))}
+
+ Add New Option
+
+ >
+ );
+};
+
+export default InputGroup;
diff --git a/src/components/InputGroup/type/index.ts b/src/components/InputGroup/type/index.ts
new file mode 100644
index 0000000..9989cb2
--- /dev/null
+++ b/src/components/InputGroup/type/index.ts
@@ -0,0 +1,6 @@
+export type VoteOption = {
+ id?: number;
+ title: string;
+ icon?: string;
+ sourceType?: number;
+}
diff --git a/src/components/InviteFriends/index.tsx b/src/components/InviteFriends/index.tsx
new file mode 100644
index 0000000..2e43ae1
--- /dev/null
+++ b/src/components/InviteFriends/index.tsx
@@ -0,0 +1,245 @@
+import { useEffect, useMemo, useState } from "react";
+
+import { useConnectWallet } from "@aelf-web-login/wallet-adapter-react";
+import AElf from "aelf-sdk";
+import { useRequest } from "ahooks";
+import clsx from "clsx";
+import { QRCode } from "react-qrcode-logo";
+import { useCopyToClipboard } from "react-use";
+
+import { connectUrl, portkeyServer, TgLink } from "@/config";
+import { chainId, projectCode } from "@/constants/app";
+import { InviteDetail, IStartAppParams } from "@/types/task";
+import { stringifyStartAppParams } from "@/utils/start-params";
+
+import Drawer from "../Drawer";
+import Loading from "../Loading";
+import { getShareText } from "@/pageComponents/PollDetail/utils";
+
+interface IInviteFriendsStatusProps {
+ data?: InviteDetail;
+ isShowDrawer?: boolean;
+ onClickInvite?(): void;
+}
+
+const InviteFriendsStatus = ({
+ data,
+ isShowDrawer,
+ onClickInvite,
+}: IInviteFriendsStatusProps) => {
+ const [, setCopied] = useCopyToClipboard();
+ const [isCopied, setIsCopied] = useState(false);
+ const [inviteCode, setInviteCode] = useState("");
+ const { walletInfo: wallet, isConnected } = useConnectWallet();
+ const progress = useMemo(() => {
+ if (!data?.totalInvitesNeeded) return 0;
+ const percentage =
+ ((data?.votigramVoteAll || 0) / data?.totalInvitesNeeded) * 100;
+ return percentage < 100 ? percentage : 100;
+ }, [data]);
+
+ const handleCopy = () => {
+ setCopied(tgLinkWithCode);
+ setIsCopied(true);
+ };
+
+ const {
+ run: fetchReferralCode,
+ loading,
+ cancel,
+ } = useRequest(
+ async () => {
+ const timestamp = Date.now();
+ const {
+ portkeyInfo: { walletInfo },
+ publicKey,
+ } = wallet?.extraInfo || {};
+ const message = Buffer.from(
+ `${walletInfo?.address}-${timestamp}`
+ ).toString("hex");
+ const signature = AElf.wallet
+ .sign(message, walletInfo?.keyPair)
+ .toString("hex");
+ const requestObject = {
+ grant_type: "signature",
+ client_id: "CAServer_App",
+ scope: "CAServer",
+ signature: signature,
+ pubkey: publicKey,
+ timestamp: timestamp.toString(),
+ ca_hash: wallet?.extraInfo?.portkeyInfo?.caInfo?.caHash,
+ chain_id: chainId,
+ };
+ const portKeyRes = await fetch(connectUrl + "/connect/token", {
+ method: "POST",
+ body: new URLSearchParams(requestObject).toString(),
+ headers: {
+ "Content-Type": "application/x-www-form-urlencoded",
+ },
+ });
+ const portKeyResObj = await portKeyRes.json();
+ if (portKeyRes?.ok && portKeyResObj?.access_token) {
+ const token = portKeyResObj.access_token;
+ const response = await fetch(
+ portkeyServer +
+ `/api/app/growth/shortLink?projectCode=${projectCode}`,
+ {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ }
+ );
+ const resObj = await response.json();
+ if (resObj?.userGrowthInfo?.inviteCode) {
+ setInviteCode(resObj?.userGrowthInfo?.inviteCode);
+ cancel();
+ }
+ }
+ },
+ {
+ manual: true,
+ pollingInterval: 1500,
+ }
+ );
+
+ const startAppParams: IStartAppParams = {
+ referralCode: inviteCode,
+ };
+ const tgLinkWithCode =
+ TgLink +
+ (inviteCode ? `?startapp=${stringifyStartAppParams(startAppParams)}` : "");
+
+ const shareToTelegram = () => {
+ if (window?.Telegram?.WebApp?.openTelegramLink) {
+ const url = encodeURIComponent(tgLinkWithCode);
+ const shareText = encodeURIComponent(
+ getShareText(
+ `Join Votigram, and Discover more fun Telegram apps with me! 🌐`,
+ `Vote Now and Earn points for USDT airdrop! 🎉`
+ )
+ );
+ window?.Telegram?.WebApp?.openTelegramLink(
+ `https://t.me/share/url?url=${url}&text=${shareText}`
+ );
+ }
+ };
+
+ useEffect(() => {
+ if (isConnected) {
+ fetchReferralCode();
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [isConnected]);
+
+ useEffect(() => {
+ if (isCopied) {
+ setTimeout(() => {
+ setIsCopied(false);
+ }, 2000);
+ }
+ }, [isCopied]);
+
+ return (
+ <>
+
+
+
+
+ {"Invite friends\n& get points!"}
+
+
+ +{data?.pointsFirstReferralVote?.toLocaleString()}
+
+
+
+
+
+ {data?.votigramVoteAll || 0}/{data?.totalInvitesNeeded || 20}
+
+
+
+ Invite friends
+
+
+
+
+
+ Share
+
+
+ {loading ? (
+
+ ) : (
+ <>
+
+
+
+
+
+
+ Referral Link
+
+
+
+ {tgLinkWithCode}
+
+
+ {isCopied ? (
+
+ Copied
+
+ ) : (
+
+
+
+ )}
+
+
+
+
+ Invite friends
+
+ >
+ )}
+
+
+ >
+ );
+};
+
+export default InviteFriendsStatus;
diff --git a/src/components/Loading/__test__/index.test.tsx b/src/components/Loading/__test__/index.test.tsx
new file mode 100644
index 0000000..d9f5259
--- /dev/null
+++ b/src/components/Loading/__test__/index.test.tsx
@@ -0,0 +1,56 @@
+// Loading.test.tsx
+import { render, screen } from "@testing-library/react";
+import { describe, it, expect } from "vitest";
+
+import Loading from "../index"; // Adjust the import to your file structure
+
+import "@testing-library/jest-dom";
+
+describe("Loading Component", () => {
+ it("renders the spinner and container with default classes", () => {
+ render( );
+ const container = screen.getByTestId("loading-testid");
+ const spinner = container.querySelector("div.animate-spin");
+
+ // Check if the container has the default class
+ expect(container).toHaveClass(
+ "flex justify-center items-center bg-black/40"
+ );
+
+ // Check if the spinner has the default animation and styles
+ expect(spinner).toHaveClass(
+ "animate-spin rounded-[50%] h-8 w-8 border-t-2 border-b-2 border-primary"
+ );
+ });
+
+ it("applies additional className to the container", () => {
+ render( );
+ const container = screen.getByTestId("loading-testid");
+ expect(container).toHaveClass("custom-container-class");
+ });
+
+ it("applies additional iconClassName to the spinner", () => {
+ render( );
+ const spinner = screen.getByTestId("loading-icon-testid");
+
+ expect(spinner).toHaveClass("custom-icon-class");
+ });
+
+ it("renders correctly when both className and iconClassName are provided", () => {
+ render(
+
+ );
+
+ const container = screen.getByTestId("loading-testid");
+ const spinner = container.querySelector("div.animate-spin");
+
+ // Check container class
+ expect(container).toHaveClass("custom-container-class");
+
+ // Check spinner class
+ expect(spinner).toHaveClass("custom-icon-class");
+ });
+});
diff --git a/src/components/Loading/index.tsx b/src/components/Loading/index.tsx
new file mode 100644
index 0000000..8199d23
--- /dev/null
+++ b/src/components/Loading/index.tsx
@@ -0,0 +1,33 @@
+import React from "react";
+
+import clsx from "clsx";
+
+
+interface ILoadingProps {
+ iconClassName?: string;
+ className?: string;
+ color?: string;
+}
+
+// Loading component
+const Loading: React.FC = ({ className, iconClassName }) => {
+ return (
+
+ );
+};
+
+export default Loading;
diff --git a/src/components/Modal/__test__/index.test.tsx b/src/components/Modal/__test__/index.test.tsx
new file mode 100644
index 0000000..b79f36e
--- /dev/null
+++ b/src/components/Modal/__test__/index.test.tsx
@@ -0,0 +1,42 @@
+// Modal.test.tsx
+import { render, screen } from "@testing-library/react";
+import { describe, it, expect } from "vitest";
+
+import Modal from "../index"; // Adjust to your actual path
+
+import "@testing-library/jest-dom";
+
+describe("Modal Component", () => {
+ it("renders children when visible", () => {
+ const childContent = "This is a modal";
+ render(
+
+ {childContent}
+
+ );
+
+ expect(screen.getByText(childContent)).toBeInTheDocument();
+ });
+
+ it("does not render when not visible", () => {
+ const childContent = "You should not see this";
+ render(
+
+ {childContent}
+
+ );
+
+ expect(screen.queryByText(childContent)).not.toBeInTheDocument();
+ });
+
+ it("applies the root class name", () => {
+ render(
+
+ Content
+
+ );
+
+ const modal = screen.getByText("Content").parentElement; // get motion.div
+ expect(modal).toHaveClass("custom-class");
+ });
+});
diff --git a/src/components/Modal/index.tsx b/src/components/Modal/index.tsx
new file mode 100644
index 0000000..b1b29d0
--- /dev/null
+++ b/src/components/Modal/index.tsx
@@ -0,0 +1,41 @@
+
+import React, { ReactNode } from "react";
+
+import clsx from "clsx";
+import { motion } from "framer-motion";
+
+interface IModalProps {
+ isVisible: boolean;
+ children: ReactNode | ReactNode[];
+ rootClassName?: string;
+}
+
+const Modal = ({ isVisible, children, rootClassName }: IModalProps) => {
+ // Variants for the modal animation
+ const variants = {
+ hidden: { opacity: 0, scale: 0.75 },
+ visible: { opacity: 1, scale: 1 },
+ };
+
+ return (
+ isVisible && (
+
+
+ {children}
+
+
+ )
+ );
+};
+
+export default Modal;
diff --git a/src/components/Navigation/__test__/index.test.tsx b/src/components/Navigation/__test__/index.test.tsx
new file mode 100644
index 0000000..1056a51
--- /dev/null
+++ b/src/components/Navigation/__test__/index.test.tsx
@@ -0,0 +1,56 @@
+import { render, screen, fireEvent } from "@testing-library/react";
+import "@testing-library/jest-dom";
+import { describe, it, expect, vi } from "vitest";
+
+import { TAB_LIST } from "@/constants/navigation";
+
+import Navigation from "../index";
+
+describe("Navigation Component", () => {
+ const setup = (activeTab = TAB_LIST.HOME) => {
+ const onMenuClick = vi.fn();
+ render( );
+ return onMenuClick;
+ };
+
+ it("renders the navigation component correctly", () => {
+ setup();
+
+ const homeIcon = screen.getByTestId("votigram-icon-navbar-home");
+ const forYouIcon = screen.getByTestId("votigram-icon-navbar-for-you");
+ const heartIcon = screen.getByTestId("votigram-icon-navbar-vote");
+ const penIcon = screen.getByTestId("votigram-icon-navbar-task-profile");
+
+ expect(homeIcon).toBeInTheDocument();
+ expect(forYouIcon).toBeInTheDocument();
+ expect(heartIcon).toBeInTheDocument();
+ expect(penIcon).toBeInTheDocument();
+ });
+
+ it("positions the active tab indicator correctly", () => {
+ setup(TAB_LIST.FOR_YOU); // Assume FOR YOU is active
+ const indicator = screen.getByRole("presentation"); // Use a role that fits design
+
+ expect(indicator).toHaveStyle("transform: translateX(70px)"); // FOR YOU should translate once
+ });
+
+ it("calls onMenuClick with the correct tab when a tab is clicked", () => {
+ const onMenuClick = setup();
+
+ const homeTab = screen.getByTestId("home-tab");
+ fireEvent.click(homeTab);
+ expect(onMenuClick).toHaveBeenCalledWith(TAB_LIST.HOME);
+
+ const forYouTab = screen.getByTestId("for-you-tab");
+ fireEvent.click(forYouTab);
+ expect(onMenuClick).toHaveBeenCalledWith(TAB_LIST.FOR_YOU);
+
+ const heartTab = screen.getByTestId("heart-tab");
+ fireEvent.click(heartTab);
+ expect(onMenuClick).toHaveBeenCalledWith(TAB_LIST.VOTE);
+
+ const penTab = screen.getByTestId("pen-tab");
+ fireEvent.click(penTab);
+ expect(onMenuClick).toHaveBeenCalledWith(TAB_LIST.PEN);
+ });
+});
diff --git a/src/components/Navigation/index.css b/src/components/Navigation/index.css
new file mode 100644
index 0000000..401a2b4
--- /dev/null
+++ b/src/components/Navigation/index.css
@@ -0,0 +1,5 @@
+.navigation-container {
+ z-index: 1000;
+ backdrop-filter: blur(40px);
+ bottom: 34px;
+}
\ No newline at end of file
diff --git a/src/components/Navigation/index.tsx b/src/components/Navigation/index.tsx
new file mode 100644
index 0000000..1f5bd1e
--- /dev/null
+++ b/src/components/Navigation/index.tsx
@@ -0,0 +1,97 @@
+import React from "react";
+
+import clsx from "clsx";
+
+import { TAB_LIST } from "@/constants/navigation";
+
+import "./index.css";
+
+interface NavigationProps {
+ activeTab: TAB_LIST;
+ onMenuClick: (tab: TAB_LIST) => void;
+}
+
+const Navigation: React.FC = ({ activeTab, onMenuClick }) => {
+ const tabPositions = {
+ [TAB_LIST.HOME]: 0,
+ [TAB_LIST.FOR_YOU]: 1,
+ [TAB_LIST.VOTE]: 2,
+ [TAB_LIST.PEN]: 3,
+ };
+
+ const tabWidth = 70;
+ const indicatorStyle = {
+ transform: `translateX(${tabPositions[activeTab] * tabWidth}px)`,
+ transition: "transform 0.3s ease",
+ };
+
+ return (
+
+
onMenuClick(TAB_LIST.HOME)}
+ >
+
+
+
onMenuClick(TAB_LIST.FOR_YOU)}
+ >
+
+
+
onMenuClick(TAB_LIST.VOTE)}
+ >
+
+
+
onMenuClick(TAB_LIST.PEN)}
+ >
+
+
+
+
+ );
+};
+
+export default Navigation;
diff --git a/src/components/PointsCounter/__test__/index.test.tsx b/src/components/PointsCounter/__test__/index.test.tsx
new file mode 100644
index 0000000..2dbff6b
--- /dev/null
+++ b/src/components/PointsCounter/__test__/index.test.tsx
@@ -0,0 +1,56 @@
+// PointsCounter.test.tsx
+import { render, screen, act } from "@testing-library/react";
+import { describe, it, expect, beforeEach, afterEach } from "vitest";
+
+import PointsCounter from "../index";
+
+import "@testing-library/jest-dom";
+
+describe("PointsCounter Component", () => {
+ let originalRequestAnimationFrame: typeof globalThis.requestAnimationFrame;
+ let animationFrameCallbacks: Array<(timestamp: number) => void> = [];
+
+ beforeEach(() => {
+ // Save original requestAnimationFrame
+ originalRequestAnimationFrame = global.requestAnimationFrame;
+
+ animationFrameCallbacks = [];
+ global.requestAnimationFrame = (callback: (timestamp: number) => void) => {
+ animationFrameCallbacks.push(callback);
+ return animationFrameCallbacks.length - 1;
+ };
+ });
+
+ afterEach(() => {
+ // Restore original requestAnimationFrame
+ global.requestAnimationFrame = originalRequestAnimationFrame;
+ animationFrameCallbacks = [];
+ });
+
+ const triggerAnimationFrames = (frameCount: number, interval: number) => {
+ for (let i = 0; i < frameCount; i++) {
+ act(() => {
+ animationFrameCallbacks.forEach((callback) => callback(i * interval));
+ });
+ }
+ };
+
+ it("renders with the starting count", () => {
+ render( );
+ expect(screen.getByText("100")).toBeInTheDocument();
+ });
+
+ it("counts up to the end number over time", () => {
+ render( );
+
+ triggerAnimationFrames(10, 100); // Simulate time progression
+ expect(screen.getByText("180")).toBeInTheDocument();
+ });
+
+ it("reaches the end value after the full duration", () => {
+ render( );
+
+ triggerAnimationFrames(20, 50); // Adjust time intervals for steps
+ expect(screen.getByText("190")).toBeInTheDocument();
+ });
+});
diff --git a/src/components/PointsCounter/index.tsx b/src/components/PointsCounter/index.tsx
new file mode 100644
index 0000000..2cb58a5
--- /dev/null
+++ b/src/components/PointsCounter/index.tsx
@@ -0,0 +1,49 @@
+import React, { useState, useEffect } from "react";
+
+interface IPointsCounterProps {
+ start?: number;
+ end: number;
+ duration: number;
+}
+
+const PointsCounter: React.FC = ({
+ start = 0,
+ end,
+ duration,
+}) => {
+ const [count, setCount] = useState(start);
+
+ useEffect(() => {
+ let startTime: number | null = null;
+ const step = (timestamp: number) => {
+ if (!startTime) startTime = timestamp;
+
+ const progress = timestamp - startTime;
+ const progressFraction = Math.min(progress / duration, 1);
+
+ const currentNumber = Math.floor(
+ progressFraction * (end - start) + start
+ );
+
+ setCount(currentNumber);
+
+ if (progress < duration) {
+ requestAnimationFrame(step);
+ }
+ };
+
+ requestAnimationFrame(step);
+
+ return () => {
+ startTime = null;
+ };
+ }, [start, end, duration]);
+
+ return (
+
+ {count.toLocaleString()}
+
+ );
+};
+
+export default PointsCounter;
diff --git a/src/components/Profile/components/Achievements/index.css b/src/components/Profile/components/Achievements/index.css
new file mode 100644
index 0000000..387ac8f
--- /dev/null
+++ b/src/components/Profile/components/Achievements/index.css
@@ -0,0 +1,13 @@
+.time-picker {
+ .m-style-picker-mask {
+ @apply !bg-none;
+ }
+
+ .m-style-picker-indicator-vertical {
+ @apply border-gray-border;
+ }
+
+ .m-style-picker-item-selected {
+ @apply text-white;
+ }
+}
\ No newline at end of file
diff --git a/src/components/Profile/components/Achievements/index.tsx b/src/components/Profile/components/Achievements/index.tsx
new file mode 100644
index 0000000..29b6ad6
--- /dev/null
+++ b/src/components/Profile/components/Achievements/index.tsx
@@ -0,0 +1,156 @@
+import { useEffect, useState } from "react";
+
+import DailyRewards from "@/components/DailyRewards";
+import Loading from "@/components/Loading";
+import { chainId } from "@/constants/app";
+import useData from "@/hooks/useData";
+import { useUserContext } from "@/provider/UserProvider";
+import { InviteItem } from "@/types/task";
+
+interface IAchievementsProps {
+ scrollTop: number;
+}
+
+type MyInfoType = {
+ first_name: string;
+ last_name: string;
+ photo_url: string;
+};
+
+const Achievements = ({ scrollTop }: IAchievementsProps) => {
+ const {
+ user: { userPoints },
+ } = useUserContext();
+ const [inviteList, setInviteList] = useState([]);
+ const [hasMore, setHasMore] = useState(true);
+ const [pageIndex, setPageIndex] = useState(0);
+ const [myInvited, setMyInvited] = useState();
+ const [myInfo, setMyInfo] = useState();
+
+ const PAGE_SIZE = 20;
+
+ const { data: inviteDetail, isLoading } = useData(
+ `/api/app/referral/invite-leader-board?${new URLSearchParams({
+ chainId,
+ skipCount: (pageIndex * PAGE_SIZE).toString(),
+ maxResultCount: PAGE_SIZE.toString(),
+ }).toString()}`
+ );
+
+ useEffect(() => {
+ if (window?.Telegram) {
+ const { first_name, last_name, photo_url } =
+ window?.Telegram?.WebApp?.initDataUnsafe.user || {};
+ setMyInfo({
+ first_name,
+ last_name,
+ photo_url,
+ });
+ }
+ }, []);
+
+ useEffect(() => {
+ const { data: inviteList, me } = inviteDetail || {};
+ if (inviteList && Array.isArray(inviteList)) {
+ setInviteList((prev) =>
+ pageIndex === 0 ? inviteList : [...prev, ...inviteList]
+ );
+ setHasMore(inviteList?.length >= PAGE_SIZE);
+ }
+
+ if (me) {
+ setMyInvited(me);
+ }
+ }, [inviteDetail, pageIndex]);
+
+ useEffect(() => {
+ if (scrollTop && scrollTop < 50 && hasMore && !isLoading) {
+ setPageIndex((prevPageIndex) => prevPageIndex + 1);
+ }
+ }, [hasMore, isLoading, scrollTop]);
+
+ return (
+ <>
+
+
+
+
+ Referral Leaderboard
+
+
+ Climb the Referral Leaderboard by inviting friends!
+
+
+
+
+
Rank
+
+
+ Name
+
+ Invited
+
+
+
+
+ {myInvited?.rank || "--"}
+
+
+
+
+
+ {window?.Telegram?.WebApp?.initDataUnsafe?.user?.first_name ||
+ myInvited?.firstName ||
+ " "}
+
+ ME
+
+
+
+
+ {myInvited?.inviteAndVoteCount || 0}
+
+
+ {inviteList.map((item) => (
+
+
+ {item?.rank || "--"}
+
+
+
+
+ {item?.firstName
+ ? `${item?.firstName} ${item?.lastName}`
+ : `ELF_${item.inviter}_${chainId}`}
+
+
+
+ {item?.inviteAndVoteCount || 0}
+
+
+ ))}
+
+ {isLoading &&
}
+
+ >
+ );
+};
+
+export default Achievements;
diff --git a/src/components/Profile/components/Tasks/index.tsx b/src/components/Profile/components/Tasks/index.tsx
new file mode 100644
index 0000000..e0d0479
--- /dev/null
+++ b/src/components/Profile/components/Tasks/index.tsx
@@ -0,0 +1,72 @@
+import { useEffect, useState } from "react";
+
+import { mutate } from "swr";
+
+import InviteFriendsStatus from "@/components/InviteFriends";
+import TaskModule from "@/components/TaskModule";
+import { chainId } from "@/constants/app";
+import { TAB_LIST } from "@/constants/navigation";
+import useData from "@/hooks/useData";
+import { InviteDetail, TaskModule as TaskModuleType } from "@/types/task";
+interface ITasksProps {
+ totalPoints: number;
+ switchTab: (tab: TAB_LIST) => void;
+ onReward(points?: number): void;
+}
+
+const Tasks = ({ totalPoints, switchTab, onReward }: ITasksProps) => {
+ const [tasks, setTasks] = useState([]);
+ const [inviteInfo, setInviteInfo] = useState();
+ const [showShare, setShowShare] = useState(false);
+
+ const { data } = useData(`/api/app/user/task-list?chainId=${chainId}`);
+
+ const { data: inviteDetail } = useData(
+ `/api/app/referral/invite-detail?chainId=${chainId}`
+ );
+
+ useEffect(() => {
+ if (inviteDetail) {
+ setInviteInfo(inviteDetail);
+ }
+ }, [inviteDetail]);
+
+ useEffect(() => {
+ const { taskList } = data || {};
+ if (taskList && Array.isArray(taskList)) {
+ setTasks(taskList);
+ }
+ }, [data, tasks]);
+
+ const refresh = (points?: number) => {
+ mutate(`/api/app/user/task-list?chainId=${chainId}`);
+ if (points) {
+ onReward(points);
+ }
+ };
+
+ return (
+ <>
+ setShowShare(!showShare)}
+ />
+
+ {tasks.map(({ data, userTask }: TaskModuleType, index: number) => (
+ setShowShare(true)}
+ refresh={refresh}
+ description={index === 0 ? "Complete quests to earn rewards!" : ""}
+ key={`${userTask}_${index}`}
+ />
+ ))}
+ >
+ );
+};
+
+export default Tasks;
diff --git a/src/components/Profile/index.tsx b/src/components/Profile/index.tsx
new file mode 100644
index 0000000..77d697a
--- /dev/null
+++ b/src/components/Profile/index.tsx
@@ -0,0 +1,121 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+
+
+import TelegramHeader from "@/components/TelegramHeader";
+import { TAB_LIST } from "@/constants/navigation";
+import { PROFILE_TABS } from "@/constants/vote";
+import { useAdsgram } from "@/hooks/useAdsgram";
+import { useUserContext } from "@/provider/UserProvider";
+
+import Tabs from "../Tabs";
+import Achievements from "./components/Achievements";
+import Tasks from "./components/Tasks";
+
+
+
+
+
+const tabs = [{
+ label: PROFILE_TABS.TASK,
+ value: 0,
+}, {
+ label: PROFILE_TABS.ACHIEVEMENTS,
+ value: 1,
+}]
+
+interface IProfileProps {
+ switchTab: (tab: TAB_LIST) => void;
+}
+
+const Profile = ({ switchTab }: IProfileProps) => {
+ const {
+ user,
+ updateUserPoints
+ } = useUserContext();
+ const { userPoints } = user;
+ const [currentTab, setCurrentTab] = useState(0);
+ const scrollViewRef = useRef(null);
+ const [scrollTop, setScrollTop] = useState(0);
+
+ const onReward = (totalPoints: number = 0) => {
+ updateUserPoints(totalPoints);
+ }
+
+ const showAd = useAdsgram({
+ blockId: import.meta.env.VITE_ADSGRAM_ID.toString() || "",
+ onReward,
+ onError: () => {},
+ onSkip: () => {},
+ });
+
+ const handleScroll = useCallback(() => {
+ const scrollRef = scrollViewRef.current;
+ if (scrollRef) {
+ setScrollTop(
+ scrollRef.scrollHeight - scrollRef.scrollTop - scrollRef.clientHeight
+ );
+ }
+ }, [scrollViewRef]);
+
+ useEffect(() => {
+ const scrollRef = scrollViewRef.current;
+ if (currentTab === 1 && scrollRef) {
+ scrollRef.addEventListener("scroll", handleScroll);
+ }
+
+ return () => {
+ if (scrollRef) {
+ scrollRef.removeEventListener("scroll", handleScroll);
+ }
+ };
+ }, [currentTab, handleScroll, scrollViewRef]);
+
+ return (
+ <>
+
+
+
+
+
+ Hi,
+ {window?.Telegram?.WebApp?.initDataUnsafe?.user?.first_name ||
+ " "}
+
+
+
+
+ Total earned points:
+
+
+ {userPoints?.userTotalPoints.toLocaleString() || 0}
+
+
+
+
+
+
+
+
+
+
+ {currentTab === 0 ? (
+
+ ) : (
+
+ )}
+
+
+
+ >
+ );
+};
+
+export default Profile;
diff --git a/src/components/ProgressBar/__test__/index.test.tsx b/src/components/ProgressBar/__test__/index.test.tsx
new file mode 100644
index 0000000..bef3b21
--- /dev/null
+++ b/src/components/ProgressBar/__test__/index.test.tsx
@@ -0,0 +1,45 @@
+// ProgressBar.test.tsx
+import { render } from "@testing-library/react";
+import "@testing-library/jest-dom";
+import { describe, it, expect } from "vitest";
+
+import ProgressBar from "../index";
+
+describe("ProgressBar Component", () => {
+ it("renders with correct width and progress", () => {
+ const { container } = render( );
+
+ // Check if the outer div has the correct progress width
+ const progressBarOuter = container.firstChild as HTMLElement;
+ expect(progressBarOuter).toBeInTheDocument();
+ expect(progressBarOuter).toHaveStyle("width: 75%");
+
+ // Check if the inner div has the correct width
+ const progressBarInner = progressBarOuter.firstChild as HTMLElement;
+ expect(progressBarInner).toHaveStyle("width: 100px");
+ });
+
+ it("applies custom class names", () => {
+ const { container } = render(
+
+ );
+
+ const progressBarOuter = container.firstChild as HTMLElement;
+ expect(progressBarOuter).toHaveClass("custom-class");
+
+ const progressBarInner = progressBarOuter.firstChild as HTMLElement;
+ expect(progressBarInner).toHaveClass("custom-bar");
+ });
+
+ it("capped progress does not exceed 100%", () => {
+ const { container } = render( );
+
+ const progressBarOuter = container.firstChild as HTMLElement;
+ expect(progressBarOuter).toHaveStyle("width: 100%");
+ });
+});
diff --git a/src/components/ProgressBar/index.tsx b/src/components/ProgressBar/index.tsx
new file mode 100644
index 0000000..106e21b
--- /dev/null
+++ b/src/components/ProgressBar/index.tsx
@@ -0,0 +1,35 @@
+import clsx from "clsx";
+
+interface IProgressBarProps {
+ className?: string;
+ width: number;
+ progress: number;
+ barClassName?: string;
+}
+
+const ProgressBar = ({
+ className,
+ width,
+ progress = 0,
+ barClassName,
+}: IProgressBarProps) => {
+ return (
+ 100 ? 100 : progress}%` }}
+ >
+
+
+ );
+};
+
+export default ProgressBar;
diff --git a/src/components/ReviewComment/index.tsx b/src/components/ReviewComment/index.tsx
new file mode 100644
index 0000000..97d8ee9
--- /dev/null
+++ b/src/components/ReviewComment/index.tsx
@@ -0,0 +1,137 @@
+import React, { useEffect, useState } from "react";
+
+import { useThrottleFn } from "ahooks";
+import clsx from "clsx";
+
+import { chainId } from "@/constants/app";
+import useData, { postWithToken } from "@/hooks/useData";
+import { VoteApp } from "@/types/app";
+import { Comment } from "@/types/comment";
+
+import Loading from "../Loading";
+import ReviewList from "../ReviewList";
+import Textarea from "../Textarea";
+
+interface IReviewDrawerProps {
+ onComment?(totalComments: number): void;
+ onDrawerClose: () => void;
+ currentActiveApp: VoteApp | undefined;
+ setIsInputFocus: (val: boolean) => void;
+}
+
+const PAGE_SIZE = 20;
+
+const ReviewComment = ({
+ onComment,
+ onDrawerClose,
+ currentActiveApp,
+ setIsInputFocus,
+}: IReviewDrawerProps) => {
+ const [totalCount, setTotalCount] = useState(0);
+ const [comment, setComment] = useState("");
+ const [commentList, setCommentList] = useState([]);
+ const [pageIndex, setPageIndex] = useState(0);
+
+ const { data, isLoading } = useData(
+ `/api/app/discussion/comment-list?${new URLSearchParams({
+ chainId,
+ skipCount: (pageIndex * PAGE_SIZE).toString(),
+ maxResultCount: PAGE_SIZE.toString(),
+ alias: currentActiveApp?.alias || "",
+ }).toString()}`
+ );
+
+ useEffect(() => {
+ if (data) {
+ setCommentList((prev) => [...prev, ...data.items]);
+ setTotalCount(data.totalCount || 0);
+ onComment?.(data.totalCount);
+ }
+
+ return () => {
+ setCommentList([]);
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [data]);
+
+ const { run: onCommentSubmit } = useThrottleFn(
+ async (e) => {
+ try {
+ e.preventDefault();
+ const { data } = await postWithToken(
+ "/api/app/discussion/new-comment",
+ {
+ chainId,
+ alias: currentActiveApp?.alias,
+ comment,
+ }
+ );
+ if (data?.success) {
+ setCommentList((prev) => [data?.comment, ...prev]);
+ setComment("");
+ onComment?.(totalCount + 1);
+ }
+ } catch (error) {
+ console.error(error);
+ }
+ },
+ { wait: 700 }
+ );
+
+ const onCommentChange = (value: string) => {
+ setComment(value);
+ };
+
+ return (
+ <>
+
+
+ Reviews
+
+
+
+
+ setPageIndex((pageIndex) => pageIndex + 1)}
+ emptyText="Write the first review!"
+ rootClassname="px-5"
+ renderLoading={() => isLoading && }
+ />
+
+
+
+ >
+ );
+};
+
+export default ReviewComment;
diff --git a/src/components/ReviewList/__test__/index.test.tsx b/src/components/ReviewList/__test__/index.test.tsx
new file mode 100644
index 0000000..1a26c76
--- /dev/null
+++ b/src/components/ReviewList/__test__/index.test.tsx
@@ -0,0 +1,28 @@
+// ReviewList.test.tsx
+import { render, screen } from "@testing-library/react";
+import "@testing-library/jest-dom";
+import { describe, it, expect, vi } from "vitest";
+
+import { Comment } from "@/types/comment";
+
+import ReviewList from "../index";
+
+// Mock Item component
+vi.mock("./components/Item", () => ({
+ default: ({ data, className }: { data: Comment; className?: string }) => (
+ {data.comment}
+ ),
+}));
+
+describe("ReviewList Component", () => {
+ it("displays empty state message when there are no items", () => {
+ render( );
+ expect(screen.getByText("No data")).toBeInTheDocument();
+ });
+
+ it("calls loadData on mount", () => {
+ const loadData = vi.fn();
+ render( );
+ expect(loadData).toHaveBeenCalled();
+ });
+});
diff --git a/src/components/ReviewList/components/Item.tsx b/src/components/ReviewList/components/Item.tsx
new file mode 100644
index 0000000..0622c6c
--- /dev/null
+++ b/src/components/ReviewList/components/Item.tsx
@@ -0,0 +1,57 @@
+import React from "react";
+
+import clsx from "clsx";
+
+import { chainId } from "@/constants/app";
+import { Comment } from "@/types/comment";
+import { timeAgo } from "@/utils/time";
+
+interface ItemProps {
+ data: Comment;
+ className?: string;
+ onClick?: (item: Comment) => void;
+}
+
+const Item = ({ data, className, onClick }: ItemProps) => {
+ return (
+ data && onClick?.(data)}
+ >
+ {data?.commenterPhoto ? (
+
+ ) : (
+
+
+ {data?.commenterFirstName?.slice(0, 1).toUpperCase() ||
+ data?.commenter?.slice(0, 1).toUpperCase()}
+
+
+ )}
+
+
+
+ {data?.commenterFirstName || `ELF_${data?.commenter}_${chainId}`}
+
+ {data?.createTime && (
+
+ {timeAgo(data.createTime)}
+
+ )}
+
+
+ {data?.comment}
+
+
+
+ );
+};
+
+export default Item;
diff --git a/src/components/ReviewList/index.tsx b/src/components/ReviewList/index.tsx
new file mode 100644
index 0000000..17957ba
--- /dev/null
+++ b/src/components/ReviewList/index.tsx
@@ -0,0 +1,101 @@
+import React, { useCallback, useEffect, useRef } from "react";
+
+import clsx from "clsx";
+
+import { Comment } from "@/types/comment";
+
+import Item from "./components/Item";
+
+interface IReviewListProps {
+ isLoading?: boolean;
+ dataSource: Comment[];
+ height?: number | string;
+ loadData?: () => void;
+ hasMore?: boolean;
+ emptyText?: string;
+ noMoreText?: string;
+ threshold?: number;
+ rootClassname?: string;
+ itemClassname?: string;
+ renderLoading?: () => React.ReactNode;
+}
+
+const ReviewList: React.FC = ({
+ isLoading,
+ dataSource: items,
+ threshold = 50,
+ loadData,
+ hasMore,
+ emptyText,
+ noMoreText,
+ rootClassname,
+ itemClassname,
+ renderLoading,
+}) => {
+ const listRef = useRef(null);
+
+ useEffect(() => {
+ if (hasMore) {
+ loadData?.();
+ }
+ }, [hasMore, loadData]);
+
+ const handleScroll = useCallback(() => {
+ const list = listRef.current;
+ if (
+ list &&
+ list.scrollHeight - list.scrollTop - list.clientHeight < threshold &&
+ hasMore
+ ) {
+ loadData?.();
+ }
+ }, [hasMore, loadData, threshold]);
+
+ useEffect(() => {
+ const list = listRef.current;
+ if (list) {
+ list.addEventListener("scroll", handleScroll);
+ }
+
+ return () => {
+ if (list) {
+ list.removeEventListener("scroll", handleScroll);
+ }
+ };
+ }, [handleScroll]);
+
+ return (
+
+ {!isLoading && items.length === 0 && (
+
+
+ {emptyText || "No data"}
+
+
+ )}
+
0 }, rootClassname)}>
+ {items.map((item, index) => (
+
+ ))}
+ {items.length > 0 && !hasMore && (
+
+
+ {noMoreText || ""}
+
+
+ )}
+ {renderLoading?.()}
+
+
+ );
+};
+
+export default ReviewList;
diff --git a/src/components/ReviewList/type/index.ts b/src/components/ReviewList/type/index.ts
new file mode 100644
index 0000000..116bde3
--- /dev/null
+++ b/src/components/ReviewList/type/index.ts
@@ -0,0 +1,7 @@
+export type ListItem = {
+ id?: number;
+ title: React.ReactNode;
+ subtitle?: React.ReactNode;
+ avatar?: string;
+ content?: React.ReactNode;
+};
\ No newline at end of file
diff --git a/src/components/SceneLoading/__test__/index.test.tsx b/src/components/SceneLoading/__test__/index.test.tsx
new file mode 100644
index 0000000..6b3cfe7
--- /dev/null
+++ b/src/components/SceneLoading/__test__/index.test.tsx
@@ -0,0 +1,46 @@
+// SceneLoading.test.tsx
+import { useConnectWallet } from "@aelf-web-login/wallet-adapter-react";
+import { render, screen } from "@testing-library/react";
+import "@testing-library/jest-dom";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+import { postWithToken } from "@/hooks/useData";
+import { useUserContext } from "@/provider/UserProvider";
+
+import SceneLoading from "../index";
+
+vi.mock("@/provider/UserProvider", () => ({
+ useUserContext: vi.fn(),
+}));
+
+vi.mock("@aelf-web-login/wallet-adapter-react", () => ({
+ useConnectWallet: vi.fn(),
+}));
+
+vi.mock("@/hooks/useData", () => ({
+ postWithToken: vi.fn(),
+}));
+
+describe("SceneLoading Component", () => {
+ beforeEach(() => {
+ // Mock user context and wallet service
+ (useUserContext as vi.Mock).mockReturnValue({
+ hasUserData: () => true,
+ user: { isNewUser: false },
+ });
+
+ (useConnectWallet as vi.Mock).mockReturnValue({
+ isConnected: true,
+ wallet: { address: "0x123" },
+ });
+
+ (postWithToken as vi.Mock).mockResolvedValue({ data: { status: true } });
+ });
+
+ it("renders the component with the correct elements", () => {
+ render( );
+
+ expect(screen.getByText("VOTIGRAM")).toBeInTheDocument();
+ expect(screen.getByTestId("scene-loading-image")).toBeInTheDocument();
+ });
+});
diff --git a/src/components/SceneLoading/index.tsx b/src/components/SceneLoading/index.tsx
new file mode 100644
index 0000000..830bf9d
--- /dev/null
+++ b/src/components/SceneLoading/index.tsx
@@ -0,0 +1,97 @@
+import { Dispatch, SetStateAction, useEffect, useState } from "react";
+
+import { motion } from "framer-motion";
+
+import { useUserContext } from "@/provider/UserProvider";
+
+interface ISceneLoadingProps {
+ setIsLoading: Dispatch>;
+}
+
+const SceneLoading = ({ setIsLoading }: ISceneLoadingProps) => {
+ const [progress, setProgress] = useState(20);
+
+ const {
+ hasUserData,
+ user: { isNewUser },
+ } = useUserContext();
+
+ useEffect(() => {
+ const interval = setInterval(() => {
+ setProgress((prevProgress) => {
+ if (prevProgress >= 100) {
+ clearInterval(interval);
+ return 100; // Stop at 100%
+ }
+ return prevProgress + Math.random() * 20; // Increment progress by 1%
+ });
+ }, 3000);
+
+ return () => clearInterval(interval); // Cleanup the interval on component unmount
+ }, []);
+
+ useEffect(() => {
+ if (hasUserData()) {
+ setProgress(89);
+ }
+ }, [hasUserData]);
+
+ useEffect(() => {
+ if (progress >= 90) {
+ setIsLoading(isNewUser);
+ }
+ }, [isNewUser, progress, setIsLoading]);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {isNewUser && progress >= 90 ? (
+
{
+ setIsLoading(false);
+ }}
+ className="bg-white text-primary w-full rounded-3xl py-2.5 leading-[14px] text-[14px] font-bold font-outfit"
+ >
+ Get Started!
+
+ ) : (
+
+
+
+ )}
+
+
+
+ );
+};
+
+export default SceneLoading;
diff --git a/src/components/SearchPanel/__test__/index.test.tsx b/src/components/SearchPanel/__test__/index.test.tsx
new file mode 100644
index 0000000..2683244
--- /dev/null
+++ b/src/components/SearchPanel/__test__/index.test.tsx
@@ -0,0 +1,133 @@
+
+import { render, screen, fireEvent } from "@testing-library/react";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+
+import { voteAppListData } from "@/__mocks__/VoteApp";
+import { useAdsgram } from "@/hooks/useAdsgram";
+import { VoteApp } from "@/types/app";
+
+import SearchPanel from "../index";
+
+
+// Mock the `useAdsgram` hook
+vi.mock("@/hooks/useAdsgram", () => ({
+ useAdsgram: vi.fn(),
+}));
+
+// Mock the `AppItem` component
+vi.mock("../../AppItem", () => ({
+ default: ({
+ item,
+ onAppItemClick,
+ }: {
+ item: VoteApp;
+ onAppItemClick: (item: VoteApp) => void;
+ }) => (
+ onAppItemClick(item)}
+ >
+ {item.title}
+
+ ),
+}));
+
+describe("SearchPanel Component", () => {
+ const mockShowAd = vi.fn();
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ (useAdsgram as vi.Mock).mockReturnValue(mockShowAd);
+ });
+
+ it("renders the ad image and triggers showAd when clicked", () => {
+ render(
+
+ );
+
+ const adImage = screen.getByRole("img");
+ expect(adImage).toBeInTheDocument();
+
+ fireEvent.click(adImage);
+
+ // Ensure `showAd` from `useAdsgram` is called
+ expect(mockShowAd).toHaveBeenCalledTimes(1);
+ });
+
+ it("renders the recommendList as AppItems", () => {
+ const { container } = render(
+
+ );
+
+ // Check that AppItems are rendered
+ const appItems = container.querySelectorAll("[data-testid^='app-item-']");
+ expect(appItems).toHaveLength(voteAppListData.length);
+
+ // Check that titles are correct
+ expect(screen.getByText("Opus Freelance")).toBeInTheDocument();
+ expect(screen.getByText("@Call")).toBeInTheDocument();
+ });
+
+ it("renders 'No Results' when recommendList is empty", () => {
+ render(
+
+ );
+
+ // Check that "No Results" message is shown
+ expect(screen.getByText("No Results")).toBeInTheDocument();
+ });
+
+ it("triggers onAppItemClick when an AppItem is clicked", () => {
+ const mockOnAppItemClick = vi.fn();
+
+ render(
+
+ );
+
+ // Click on the AppItem
+ const appItem = screen.getByTestId(
+ "app-item-5e5cf28d6bab811b13530801ea779876defec18d2bd177fffa5dfb82f14c9bbf"
+ );
+ fireEvent.click(appItem);
+
+ // Check that `onAppItemClick` was called with the correct item
+ expect(mockOnAppItemClick).toHaveBeenCalled();
+ });
+
+ it("passes updateUserPoints to useAdsgram", () => {
+ const mockUpdateUserPoints = vi.fn();
+
+ render(
+
+ );
+
+ // Ensure `useAdsgram` was called with the correct arguments
+ expect(useAdsgram).toHaveBeenCalledWith(
+ expect.objectContaining({
+ blockId: expect.any(String),
+ onReward: mockUpdateUserPoints, // Ensure the reward callback is passed
+ })
+ );
+ });
+});
diff --git a/src/components/SearchPanel/index.tsx b/src/components/SearchPanel/index.tsx
new file mode 100644
index 0000000..e1c8662
--- /dev/null
+++ b/src/components/SearchPanel/index.tsx
@@ -0,0 +1,53 @@
+
+import { useAdsgram } from "@/hooks/useAdsgram";
+import { VoteApp } from "@/types/app";
+
+import AppItem from "../AppItem";
+
+
+interface ISearchPanel {
+ recommendList: VoteApp[];
+ updateUserPoints(points: number): void;
+ onAppItemClick: (item: VoteApp) => void;
+}
+
+const SearchPanel = ({
+ recommendList,
+ updateUserPoints,
+ onAppItemClick,
+}: ISearchPanel) => {
+ const showAd = useAdsgram({
+ blockId: import.meta.env.VITE_ADSGRAM_ID.toString() || "",
+ onReward: updateUserPoints,
+ onError: () => {},
+ onSkip: () => {},
+ });
+
+ return (
+
+
+
+ {recommendList?.map((item) => (
+
+ ))}
+
+ {recommendList.length === 0 && (
+
+ No Results
+
+ )}
+
+
+ );
+};
+
+export default SearchPanel;
diff --git a/src/components/SimpleDatePicker/__test__/index.test.tsx b/src/components/SimpleDatePicker/__test__/index.test.tsx
new file mode 100644
index 0000000..998c173
--- /dev/null
+++ b/src/components/SimpleDatePicker/__test__/index.test.tsx
@@ -0,0 +1,87 @@
+
+import { render, screen, fireEvent } from "@testing-library/react";
+import dayjs from "dayjs";
+import { describe, expect, it } from "vitest";
+
+import SimpleDatePicker from "../index";
+
+vi.mock("dayjs", () => {
+ const actualDayjs = vi.importActual("dayjs");
+ const mockDate = "2023-01-01T00:00:00.000Z";
+
+ return {
+ ...actualDayjs,
+ default: () => ({
+ format: () => "20 Dec 2024",
+ toISOString: () => mockDate,
+ toDate: () => new Date(mockDate),
+ year: () => 2024,
+ isValid: () => true,
+ }),
+ };
+});
+
+describe("SimpleDatePicker", () => {
+ it("renders correctly with default value", () => {
+ const defaultValue = "2024-12-20";
+ render( );
+
+ const dateDisplay = screen.getByText(dayjs(defaultValue).format("DD MMM"));
+ expect(dateDisplay).toBeInTheDocument();
+ });
+
+ it("shows the current date if no value or defaultValue is provided", () => {
+ render( );
+
+ const currentDate = dayjs().format("DD MMM");
+ const dateDisplay = screen.getByText(currentDate);
+ expect(dateDisplay).toBeInTheDocument();
+ });
+
+ it("renders the drawer when clicked and closes on confirm", () => {
+ render( );
+
+ const datePickerTrigger = screen.getByRole("button", {
+ name: /select date/i,
+ });
+ fireEvent.click(datePickerTrigger);
+
+ const drawer = screen.getByRole("dialog");
+ expect(drawer).toBeVisible();
+
+ const confirmButton = screen.getByRole("button", { name: /confirm/i });
+ fireEvent.click(confirmButton);
+
+ // Verify the drawer is visually hidden
+ expect(drawer).toHaveStyle("transform: translateY(100%)");
+ });
+
+ it("renders the drawer when clicked and closes on confirm", () => {
+ render( );
+
+ const datePickerTrigger = screen.getByRole("button", {
+ name: /select date/i,
+ });
+ fireEvent.click(datePickerTrigger);
+
+ const drawer = screen.getByRole("dialog");
+ expect(drawer).toBeVisible();
+ });
+
+ it("formats the date correctly for the current year and other years", () => {
+ const dateInCurrentYear = dayjs().format("YYYY-MM-DD");
+ const dateInAnotherYear = "2023-12-25";
+
+ const { rerender } = render( );
+ const currentYearDisplay = screen.getByText(
+ dayjs(dateInCurrentYear).format("DD MMM")
+ );
+ expect(currentYearDisplay).toBeInTheDocument();
+
+ rerender( );
+ const anotherYearDisplay = screen.getByText(
+ dayjs(dateInAnotherYear).format("DD MMM YYYY")
+ );
+ expect(anotherYearDisplay).toBeInTheDocument();
+ });
+});
diff --git a/src/components/SimpleDatePicker/index.css b/src/components/SimpleDatePicker/index.css
new file mode 100644
index 0000000..d028530
--- /dev/null
+++ b/src/components/SimpleDatePicker/index.css
@@ -0,0 +1,37 @@
+.rdp-root {
+ --rdp-today-color: var(--primary);
+ --rdp-accent-color: var(--app-icon-border);
+ --rdp-selected-border: 1px solid var(--primary);
+ --rdp-accent-background-color: var(--primary);
+ --rdp-day-height: calc((100vw - 35px) / 7);
+ --rdp-day-width: calc((100vw - 35px) / 7);
+ --rdp-day_button-height: 100%;
+ --rdp-day_button-width: 100%;
+}
+
+.simple-date-picker {
+ .rdp-month_caption {
+ @apply px-[0.6rem] mb-1;
+ }
+
+ .rdp-caption_label {
+ @apply text-[16px] font-outfit font-bold text-white;
+ }
+
+ .rdp-dropdown {
+ @apply appearance-none outline-0;
+ }
+
+ .rdp-weekdays,
+ .rdp-day_button {
+ @apply text-[15px] text-input-placeholder;
+ }
+
+ .rdp-today .rdp-day_button {
+ @apply text-primary;
+ }
+
+ .rdp-selected .rdp-day_button {
+ @apply bg-primary text-white text-[15px];
+ }
+}
\ No newline at end of file
diff --git a/src/components/SimpleDatePicker/index.tsx b/src/components/SimpleDatePicker/index.tsx
new file mode 100644
index 0000000..ae7c288
--- /dev/null
+++ b/src/components/SimpleDatePicker/index.tsx
@@ -0,0 +1,118 @@
+import { useEffect, useState } from "react";
+
+import clsx from "clsx";
+import dayjs from "dayjs";
+import { DayPicker, DateBefore, WeekdayProps } from "react-day-picker";
+
+import Drawer from "../Drawer";
+
+import "react-day-picker/style.css";
+
+import "./index.css";
+
+interface ISimpleDatePickerProps {
+ disabled?: DateBefore;
+ value?: string;
+ defaultValue?: string;
+ className?: string;
+ onChange?: (value: string) => void;
+}
+
+const SimpleDatePicker = (props: ISimpleDatePickerProps) => {
+ const {
+ value,
+ defaultValue,
+ className,
+ disabled,
+ onChange,
+ ...dayPickerProps
+ } = props;
+ const baseValue =
+ value && dayjs(value || "").isValid()
+ ? value
+ : defaultValue && dayjs(defaultValue || "").isValid()
+ ? defaultValue
+ : dayjs().format();
+ const [isVisible, setIsVisible] = useState(false);
+ const [selected, setSelected] = useState(baseValue);
+
+ const formatDate = (dateInput: string) => {
+ const date = dayjs(dateInput);
+ const currentYear = dayjs().year();
+
+ if (date.year() === currentYear) {
+ return date.format("DD MMM");
+ } else {
+ return date.format("DD MMM YYYY");
+ }
+ };
+
+ const handleConfirm = () => {
+ const selectedDate = selected ? dayjs(selected).format("YYYY-MM-DD") : "";
+ onChange?.(selectedDate);
+ setIsVisible(false);
+ };
+
+ useEffect(() => {
+ if (value && dayjs(value).isValid()) {
+ setSelected(value);
+ }
+ }, [value]);
+
+ return (
+ <>
+ setIsVisible(true)}
+ role="button"
+ aria-label="select date"
+ >
+
+ {selected && formatDate(selected)}
+
+
+
+
+
+
+ date && setSelected(dayjs(date).format("YYYY-MM-DD"))
+ }
+ disabled={
+ disabled || {
+ before: new Date(),
+ }
+ }
+ weekStartsOn={1}
+ components={{
+ Weekday: (props: WeekdayProps) => {
+ return {props["aria-label"]?.slice(0, 3)} ;
+ },
+ }}
+ className="simple-date-picker"
+ {...dayPickerProps}
+ />
+
+ Confirm
+
+
+ >
+ );
+};
+
+export default SimpleDatePicker;
diff --git a/src/components/SimpleTimePicker/index.css b/src/components/SimpleTimePicker/index.css
new file mode 100644
index 0000000..92898ba
--- /dev/null
+++ b/src/components/SimpleTimePicker/index.css
@@ -0,0 +1,34 @@
+.left-picker {
+ @apply flex-none basis-2/5;
+
+ .m-style-picker-item {
+ @apply pl-[50px];
+ }
+}
+
+.middle-picker {
+ @apply flex-none basis-1/5;
+}
+
+.right-picker {
+ @apply flex-none basis-2/5;
+
+ .m-style-picker-item {
+ @apply pr-[50px];
+ }
+}
+.left-picker,
+.middle-picker,
+.right-picker {
+ .m-style-picker-mask {
+ @apply !bg-none;
+ }
+
+ .m-style-picker-indicator-vertical {
+ @apply border-gray-border;
+ }
+
+ .m-style-picker-item-selected {
+ @apply text-white;
+ }
+}
\ No newline at end of file
diff --git a/src/components/SimpleTimePicker/index.tsx b/src/components/SimpleTimePicker/index.tsx
new file mode 100644
index 0000000..ad1c794
--- /dev/null
+++ b/src/components/SimpleTimePicker/index.tsx
@@ -0,0 +1,157 @@
+import React, { useEffect, useState } from "react";
+
+import "react-mobile-style-picker/dist/index.css";
+import clsx from "clsx";
+import dayjs from "dayjs";
+import customParseFormat from "dayjs/plugin/customParseFormat";
+import { Picker } from "react-mobile-style-picker";
+
+import {
+ HOUR_RANGE,
+ MINUTE_RANGE,
+ PERIOD_RANGE,
+} from "@/constants/time-picker";
+
+import Drawer from "../Drawer";
+
+import "./index.css";
+
+interface ISimpleTimePickerProps {
+ className?: string;
+ value?: string | number;
+ onChange?(timestamp: number): void;
+}
+
+dayjs.extend(customParseFormat);
+
+const SimpleTimePicker = ({
+ value,
+ className,
+ onChange,
+}: ISimpleTimePickerProps) => {
+ const [isVisible, setIsVisible] = useState(false);
+ const [selectedTime, setSelectedTime] = useState(dayjs().format("HH:mm"));
+ const [selectedHour, setSelectedHour] = useState(dayjs().format("HH"));
+ const [selectedMinute, setSelectedMinute] = useState(dayjs().format("mm"));
+ const [selectedPeriod, setSelectedPeriod] = useState(dayjs().format("A"));
+
+ const handleConfirm = () => {
+ let hour = selectedHour;
+
+ if (selectedPeriod === "AM" && selectedHour === "12") {
+ hour = "00";
+ } else if (selectedPeriod === "PM" && selectedHour !== "12") {
+ hour = `${parseInt(selectedHour, 10) + 12}`;
+ }
+
+ const selectTime = dayjs(`${hour}:${selectedMinute}`, "HH:mm");
+ setSelectedTime(`${selectedHour}:${selectedMinute} ${selectedPeriod}`);
+ onChange?.(selectTime.unix() * 1000);
+ setIsVisible(false);
+ };
+
+ useEffect(() => {
+ if (value && dayjs(value).isValid()) {
+ setSelectedTime(dayjs(value).format("hh:mm A"));
+ setSelectedHour(dayjs(value).format("h"));
+ setSelectedMinute(dayjs(value).format("mm"));
+ setSelectedPeriod(dayjs(value).format("A"));
+ }
+ }, [value]);
+
+ return (
+ <>
+ setIsVisible(true)}
+ >
+
+ {selectedTime}
+
+
+
+
+
+
+
+ {HOUR_RANGE.map((item) => (
+
+ {item}
+
+ ))}
+
+
+ {MINUTE_RANGE.map((item) => (
+
+ {item}
+
+ ))}
+
+
+ {PERIOD_RANGE.map((item) => (
+
+ {item}
+
+ ))}
+
+
+
+ Confirm
+
+
+ >
+ );
+};
+
+export default SimpleTimePicker;
diff --git a/src/components/TMAs/components/Accumulative/index.tsx b/src/components/TMAs/components/Accumulative/index.tsx
new file mode 100644
index 0000000..c6d0d22
--- /dev/null
+++ b/src/components/TMAs/components/Accumulative/index.tsx
@@ -0,0 +1,91 @@
+import { useEffect, useState } from "react";
+
+import Loading from "@/components/Loading";
+import VoteItem from "@/components/VoteItem";
+import { chainId } from "@/constants/app";
+import { APP_CATEGORY } from "@/constants/discover";
+import useData from "@/hooks/useData";
+import { VoteApp } from "@/types/app";
+
+interface IAccumulativeProps {
+ scrollTop: number;
+ keyword: string;
+ category: number | APP_CATEGORY;
+ onAppItemClick?: (item: VoteApp) => void;
+}
+const PAGE_SIZE = 20;
+
+const Accumulative = ({
+ scrollTop,
+ keyword,
+ category: categoryValue,
+ onAppItemClick,
+}: IAccumulativeProps) => {
+ const [hasMore, setHasMore] = useState(true);
+ const [voteList, setVoteList] = useState([]);
+ const [pageIndex, setPageIndex] = useState(0);
+ const [search, setSearch] = useState("");
+ const [category, setCategory] = useState(9);
+
+ const { data, isLoading } = useData(
+ `/api/app/discover/accumulative-app-list?${new URLSearchParams({
+ chainId,
+ category: category.toString(),
+ skipCount: (pageIndex * PAGE_SIZE).toString(),
+ maxResultCount: PAGE_SIZE.toString(),
+ search,
+ }).toString()}`
+ );
+
+ useEffect(() => {
+ const { data: voteList } = data || {};
+ if (voteList && Array.isArray(voteList)) {
+ setVoteList((prev) =>
+ pageIndex === 0 ? voteList : [...prev, ...voteList]
+ );
+ setHasMore(voteList?.length >= PAGE_SIZE);
+ }
+ }, [data, pageIndex]);
+
+ useEffect(() => {
+ if (scrollTop && scrollTop < 50 && hasMore && !isLoading) {
+ setPageIndex((prevPageIndex) => prevPageIndex + 1);
+ }
+ }, [hasMore, isLoading, scrollTop]);
+
+ useEffect(() => {
+ setSearch(keyword);
+ setPageIndex(0);
+ }, [keyword]);
+
+ useEffect(() => {
+ setCategory(categoryValue);
+ setPageIndex(0);
+ }, [categoryValue]);
+
+ return (
+
+ {voteList?.map((vote, index) => (
+
+ ))}
+ {isLoading && (
+
+
+
+ )}
+
+ );
+};
+
+export default Accumulative;
diff --git a/src/components/TMAs/components/Current/index.tsx b/src/components/TMAs/components/Current/index.tsx
new file mode 100644
index 0000000..7ab03f3
--- /dev/null
+++ b/src/components/TMAs/components/Current/index.tsx
@@ -0,0 +1,105 @@
+import { useEffect, useState } from "react";
+
+import Loading from "@/components/Loading";
+import VoteItem from "@/components/VoteItem";
+import { chainId } from "@/constants/app";
+import { APP_CATEGORY } from "@/constants/discover";
+import useData from "@/hooks/useData";
+import { VoteApp } from "@/types/app";
+
+interface IAccumulativeProps {
+ scrollTop: number;
+ keyword: string;
+ category: APP_CATEGORY;
+ onAppItemClick?: (item: VoteApp) => void;
+}
+const PAGE_SIZE = 20;
+
+const Current = ({
+ scrollTop,
+ keyword,
+ category: cate,
+ onAppItemClick,
+}: IAccumulativeProps) => {
+ const [hasMore, setHasMore] = useState(true);
+ const [voteList, setVoteList] = useState([]);
+ const [pageIndex, setPageIndex] = useState(0);
+ const [search, setSearch] = useState("");
+ const [category, setCategory] = useState(APP_CATEGORY.ALL);
+ const [proposalId, setProposalId] = useState("");
+ const [canVote, setCanVote] = useState(false);
+
+ const { data, isLoading } = useData(
+ `/api/app/discover/current-app-list?${new URLSearchParams({
+ chainId,
+ category,
+ skipCount: (pageIndex * PAGE_SIZE).toString(),
+ maxResultCount: PAGE_SIZE.toString(),
+ search,
+ }).toString()}`
+ );
+
+ useEffect(() => {
+ const { data: voteList } = data || {};
+ if (voteList && Array.isArray(voteList)) {
+ setVoteList((prev) =>
+ pageIndex === 0 ? voteList : [...prev, ...voteList]
+ );
+ setCanVote(data.canVote);
+ setProposalId(data.proposalId);
+ setHasMore(voteList?.length >= PAGE_SIZE);
+ }
+ }, [data, pageIndex]);
+
+ useEffect(() => {
+ if (scrollTop && scrollTop < 50 && hasMore && !isLoading) {
+ setPageIndex((prevPageIndex) => prevPageIndex + 1);
+ }
+ }, [hasMore, isLoading, scrollTop]);
+
+ useEffect(() => {
+ setSearch(keyword || "");
+ setPageIndex(0);
+ }, [keyword]);
+
+ useEffect(() => {
+ setCategory(cate);
+ setPageIndex(0);
+ }, [cate]);
+
+ const onVoted = (addPoints: number, index: number) => {
+ setCanVote(false);
+ const list = [...voteList];
+ list[index].totalPoints = (list[index].totalPoints || 0) + addPoints;
+ setVoteList(list);
+ };
+
+ return (
+
+ {voteList?.map((vote, index) => (
+
onVoted(addPoints, index)}
+ onClick={onAppItemClick}
+ isTMACurrent
+ showPoints
+ showBtn
+ />
+ ))}
+ {isLoading && (
+
+
+
+ )}
+
+ );
+};
+
+export default Current;
diff --git a/src/components/TMAs/index.tsx b/src/components/TMAs/index.tsx
new file mode 100644
index 0000000..905efb0
--- /dev/null
+++ b/src/components/TMAs/index.tsx
@@ -0,0 +1,122 @@
+import { useEffect, useState } from "react";
+
+import useDebounceFn from "ahooks/lib/useDebounceFn";
+
+
+import {
+ APP_CATEGORY,
+ DISCOVER_CATEGORY,
+ DISCOVERY_CATEGORY_MAP,
+} from "@/constants/discover";
+import { COMMUNITY_TYPE, TMSAP_TAB } from "@/constants/vote";
+import useSetSearchParams from "@/hooks/useSetSearchParams";
+import { VoteApp } from "@/types/app";
+
+import CategoryPillList from "../CategoryPillList";
+import Tabs from "../Tabs";
+import Accumulative from "./components/Accumulative";
+import Current from "./components/Current";
+
+
+
+const emaTabs = [
+ {
+ label: COMMUNITY_TYPE.ACCUMULATIVE,
+ value: TMSAP_TAB.ACCUMULATIVE,
+ },
+ {
+ label: COMMUNITY_TYPE.CURRENT,
+ value: TMSAP_TAB.CURRENT,
+ },
+];
+
+interface ITMAsProps {
+ scrollTop: number;
+ onTabChange?: (index: number) => void;
+ onAppItemClick?: (item: VoteApp) => void;
+}
+
+const TMAs = ({ scrollTop, onTabChange, onAppItemClick }: ITMAsProps) => {
+ const { querys, updateQueryParam } = useSetSearchParams();
+ const activeTab = querys.get("tmas");
+ const [currentTab, setCurrentTab] = useState(
+ activeTab === "1" ? Number(activeTab) : TMSAP_TAB.ACCUMULATIVE
+ );
+ const [keyword, setkeyword] = useState("");
+ const [category, setCategory] = useState(APP_CATEGORY.ALL);
+
+ const { run: onkeywordChange } = useDebounceFn(
+ (e: React.ChangeEvent) => {
+ setkeyword(e.target.value);
+ },
+ {
+ wait: 700,
+ }
+ );
+
+ const onCategoryChange = (category: APP_CATEGORY) => {
+ setCategory(category);
+ };
+
+ const handleTabChange = (index: number) => {
+ setCurrentTab(index);
+ onTabChange?.(index);
+ updateQueryParam({ key: "tmas", value: index.toString() });
+ };
+
+ useEffect(() => {
+ if (activeTab) {
+ setCurrentTab(
+ activeTab === "1" ? Number(activeTab) : TMSAP_TAB.ACCUMULATIVE
+ );
+ }
+ }, [activeTab]);
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+ {currentTab === TMSAP_TAB.ACCUMULATIVE ? (
+
+ ) : (
+
+ )}
+ >
+ );
+};
+
+export default TMAs;
diff --git a/src/components/Tabs/index.tsx b/src/components/Tabs/index.tsx
new file mode 100644
index 0000000..30011a4
--- /dev/null
+++ b/src/components/Tabs/index.tsx
@@ -0,0 +1,88 @@
+
+import React, { useState, useEffect, useRef } from "react";
+
+import clsx from "clsx";
+import { AnimatePresence, motion } from "framer-motion";
+
+import { TabItem } from "./type";
+
+interface ITabProps {
+ defaultValue?: number;
+ options: TabItem[];
+ className?: string;
+ tabClassName?: string;
+ onChange?: (value: number) => void;
+}
+
+const Tabs = ({
+ defaultValue,
+ options = [],
+ className,
+ tabClassName,
+ onChange,
+}: ITabProps) => {
+ const [activeIndex, setActiveIndex] = useState(defaultValue || 0);
+ const itemRefs = useRef>([]);
+ const containerRef = useRef(null);
+ const [defaultX, setDefaultX] = useState(0);
+
+ useEffect(() => {
+ // Initialize refs array with items count
+ itemRefs.current = itemRefs.current.slice(0, options.length);
+ }, [options.length]);
+
+ const handleClick = (value: number, index: number) => {
+ setActiveIndex(index);
+ onChange?.(value);
+ };
+
+ useEffect(() => {
+ if (containerRef.current && defaultValue !== undefined) {
+ const currentIndex = options.findIndex((item) => item.value === defaultValue);
+ setActiveIndex(currentIndex === -1 ? 0 : currentIndex)
+ setDefaultX(containerRef.current.clientWidth / options.length * currentIndex);
+ }
+ }, [defaultValue, options]);
+
+ return (
+ (containerRef.current = ref)}
+ className={clsx(
+ "relative h-full w-full mx-auto flex items-center overflow-hidden pt-[4px] pb-[8px] rounded-none bg-transparent border-b-[2px] border-tertiary",
+ className
+ )}
+ >
+
+
+
+
+ {options.map((item, index) => (
+
handleClick(item.value, index)}
+ ref={(el) => (itemRefs.current[index] = el)}
+ >
+
+ {item.label}
+
+
+ ))}
+
+
+ );
+};
+
+export default Tabs;
diff --git a/src/components/Tabs/type/index.ts b/src/components/Tabs/type/index.ts
new file mode 100644
index 0000000..d73a574
--- /dev/null
+++ b/src/components/Tabs/type/index.ts
@@ -0,0 +1,4 @@
+export type TabItem = {
+ label: string;
+ value: number;
+}
\ No newline at end of file
diff --git a/src/components/Tag/__test__/index.test.tsx b/src/components/Tag/__test__/index.test.tsx
new file mode 100644
index 0000000..aa095bf
--- /dev/null
+++ b/src/components/Tag/__test__/index.test.tsx
@@ -0,0 +1,39 @@
+// Tag.test.tsx
+import { render, screen } from "@testing-library/react";
+import "@testing-library/jest-dom";
+import { describe, it, expect } from "vitest";
+
+import Tag from "../index";
+
+describe("Tag Component", () => {
+ it("renders with the provided text", () => {
+ render( );
+ const textElement = screen.getByText("Sample Text");
+ expect(textElement).toBeInTheDocument();
+ });
+
+ it("applies custom class names to container and text", () => {
+ render(
+
+ );
+
+ const containerElement = screen.getByRole("img").parentElement;
+ const textElement = screen.getByText("Sample Text");
+
+ expect(containerElement).toHaveClass("custom-class");
+ expect(textElement).toHaveClass("custom-text-class");
+ });
+
+ it("displays the image with correct src and alt attributes", () => {
+ render( );
+ const imageElement = screen.getByAltText("Tag");
+ expect(imageElement).toHaveAttribute(
+ "src",
+ "https://cdn.tmrwdao.com/votigram/assets/imgs/01259C70892E.webp"
+ );
+ });
+});
diff --git a/src/components/Tag/index.tsx b/src/components/Tag/index.tsx
new file mode 100644
index 0000000..ba22566
--- /dev/null
+++ b/src/components/Tag/index.tsx
@@ -0,0 +1,29 @@
+import clsx from "clsx";
+
+interface ITagProps {
+ className?: string;
+ text: string;
+ textClassName?: string;
+}
+
+const Tag = ({ text, className, textClassName }: ITagProps) => {
+ return (
+
+
+
+ {text}
+
+
+ );
+};
+
+export default Tag;
diff --git a/src/components/TaskModule/components/TaskItem/index.tsx b/src/components/TaskModule/components/TaskItem/index.tsx
new file mode 100644
index 0000000..3b08c67
--- /dev/null
+++ b/src/components/TaskModule/components/TaskItem/index.tsx
@@ -0,0 +1,265 @@
+import { useState } from "react";
+
+import useRequest from "ahooks/lib/useRequest";
+
+
+
+import Loading from "@/components/Loading";
+import { chainId } from "@/constants/app";
+import { TAB_LIST } from "@/constants/navigation";
+import { USER_TASK_DETAIL } from "@/constants/task";
+import { useAdsgram } from "@/hooks/useAdsgram";
+import { fetchWithToken } from "@/hooks/useData";
+import { useUserContext } from "@/provider/UserProvider";
+import { TaskInfo } from "@/types/task";
+
+import { openNewPageWaitPageVisible } from "../../utils";
+
+
+interface ITaskItemProps {
+ userTask: string;
+ data: TaskInfo;
+ totalPoints: number;
+ switchTab: (tab: TAB_LIST) => void;
+ toInvite(): void;
+ watchAds?(): void;
+ refresh?(points?: number): void;
+}
+
+let RETRY_MAX_COUNT = 10;
+
+const taskItemMap: Record = {
+ [USER_TASK_DETAIL.DAILY_VIEW_ADS]: {
+ icon: ,
+ title: "Watch Ads",
+ },
+ [USER_TASK_DETAIL.DAILY_VOTE]: {
+ icon: ,
+ title: "Cast A Vote",
+ },
+ [USER_TASK_DETAIL.DAILY_FIRST_INVITE]: {
+ icon: ,
+ title: "Invite A Friend",
+ },
+ [USER_TASK_DETAIL.EXPLORE_JOIN_VOTIGRAM]: {
+ icon: ,
+ title: "Join Votigram TG Channel",
+ },
+ [USER_TASK_DETAIL.EXPLORE_FOLLOW_VOTIGRAM_X]: {
+ icon: ,
+ title: "Follow Votigram on X",
+ },
+ [USER_TASK_DETAIL.EXPLORE_FORWARD_VOTIGRAM_X]: {
+ icon: ,
+ title: "RT Votigram Post on X",
+ },
+ [USER_TASK_DETAIL.EXPLORE_SCHRODINGER]: {
+ icon: ,
+ title: "Join Schrondinger's Cat",
+ },
+ [USER_TASK_DETAIL.EXPLORE_JOIN_TG_CHANNEL]: {
+ icon: ,
+ title: "Join TMRWDAO TG Channel",
+ },
+ [USER_TASK_DETAIL.EXPLORE_FOLLOW_X]: {
+ icon: ,
+ title: "Follow TMRWDAO on X",
+ },
+ [USER_TASK_DETAIL.EXPLORE_FORWARD_X]: {
+ icon: ,
+ title: "RT TMRWDAO Post on X",
+ },
+ [USER_TASK_DETAIL.EXPLORE_CUMULATE_FIVE_INVITE]: {
+ icon: ,
+ title: "Invite 5 Friends",
+ },
+ [USER_TASK_DETAIL.EXPLORE_CUMULATE_TEN_INVITE]: {
+ icon: ,
+ title: "Invite 10 Friends",
+ },
+ [USER_TASK_DETAIL.EXPLORE_CUMULATE_TWENTY_INVITE]: {
+ icon: ,
+ title: "Invite 20 Friends",
+ },
+};
+
+const TaskItem = ({
+ data,
+ userTask,
+ totalPoints,
+ switchTab,
+ toInvite,
+ refresh,
+}: ITaskItemProps) => {
+ const [isLoading, setIsLoading] = useState(false);
+ const { cmsData } = useUserContext();
+ const {
+ retweetVotigramPostURL,
+ retweetTmrwdaoPostURL,
+ discoverTopBannerRedirectURL,
+ } = cmsData || {};
+
+ const jumpExternalList = [
+ {
+ taskId: USER_TASK_DETAIL.EXPLORE_JOIN_VOTIGRAM,
+ url: "https://t.me/votigram",
+ },
+ {
+ taskId: USER_TASK_DETAIL.EXPLORE_FOLLOW_VOTIGRAM_X,
+ url: "https://x.com/votigram",
+ },
+ {
+ taskId: USER_TASK_DETAIL.EXPLORE_FORWARD_VOTIGRAM_X,
+ url: retweetVotigramPostURL || "",
+ },
+ {
+ taskId: USER_TASK_DETAIL.EXPLORE_JOIN_TG_CHANNEL,
+ url: "https://t.me/tmrwdao",
+ },
+ {
+ taskId: USER_TASK_DETAIL.EXPLORE_FOLLOW_X,
+ url: "https://x.com/tmrwdao",
+ },
+ {
+ taskId: USER_TASK_DETAIL.EXPLORE_FORWARD_X,
+ url: retweetTmrwdaoPostURL || "",
+ },
+ {
+ taskId: USER_TASK_DETAIL.EXPLORE_SCHRODINGER,
+ url: discoverTopBannerRedirectURL || "",
+ },
+ ];
+
+ const showAd = useAdsgram({
+ blockId: import.meta.env.VITE_ADSGRAM_ID.toString() || "",
+ onReward: (points) => refresh?.(points),
+ onError: () => {},
+ onSkip: () => {},
+ });
+ const { run: sendCompleteReq, cancel } = useRequest(
+ async (taskId) => {
+ try {
+ const result = await fetchWithToken(
+ `/api/app/user/complete-task?${new URLSearchParams({
+ chainId,
+ userTask,
+ userTaskDetail: taskId,
+ })}`
+ );
+ if (result) {
+ refresh?.(totalPoints + data.points);
+ setIsLoading(false);
+ cancel();
+ }
+ if (result || RETRY_MAX_COUNT <= 0) {
+ cancel();
+ setIsLoading(false);
+ }
+ RETRY_MAX_COUNT--;
+ } catch (error) {
+ console.error(error);
+ setIsLoading(false);
+ }
+ },
+ {
+ manual: true,
+ pollingInterval: 3000,
+ }
+ );
+
+ const jumpAndRefresh = async (taskId: USER_TASK_DETAIL) => {
+ try {
+ const jumpItem = jumpExternalList.find(
+ (item) => item.taskId === data.userTaskDetail
+ );
+ if (jumpItem) {
+ const isComplete = await openNewPageWaitPageVisible(
+ jumpItem.url,
+ taskId,
+ () =>
+ fetchWithToken(
+ `/api/app/user/complete-task?${new URLSearchParams({
+ chainId,
+ userTask,
+ userTaskDetail: taskId,
+ })}`
+ )
+ );
+ if (isComplete) return;
+ setIsLoading(true);
+ sendCompleteReq(taskId);
+ }
+ } catch (error) {
+ console.error(error);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const handleClick = async () => {
+ if (isLoading || data.complete) return;
+ switch (data.userTaskDetail) {
+ case USER_TASK_DETAIL.DAILY_VIEW_ADS:
+ showAd();
+ break;
+ case USER_TASK_DETAIL.DAILY_VOTE:
+ switchTab(TAB_LIST.VOTE);
+ break;
+ case USER_TASK_DETAIL.EXPLORE_JOIN_VOTIGRAM:
+ case USER_TASK_DETAIL.EXPLORE_FOLLOW_VOTIGRAM_X:
+ case USER_TASK_DETAIL.EXPLORE_FORWARD_VOTIGRAM_X:
+ case USER_TASK_DETAIL.EXPLORE_JOIN_TG_CHANNEL:
+ case USER_TASK_DETAIL.EXPLORE_FOLLOW_X:
+ case USER_TASK_DETAIL.EXPLORE_FORWARD_X:
+ case USER_TASK_DETAIL.EXPLORE_SCHRODINGER:
+ await jumpAndRefresh(data.userTaskDetail);
+ break;
+ case USER_TASK_DETAIL.DAILY_FIRST_INVITE:
+ case USER_TASK_DETAIL.EXPLORE_CUMULATE_FIVE_INVITE:
+ case USER_TASK_DETAIL.EXPLORE_CUMULATE_TEN_INVITE:
+ case USER_TASK_DETAIL.EXPLORE_CUMULATE_TWENTY_INVITE:
+ toInvite();
+ break;
+ default:
+ break;
+ }
+ };
+
+ return (
+
+
+
+ {taskItemMap[data.userTaskDetail]?.icon}
+
+
+
+ {taskItemMap[data.userTaskDetail]?.title}{" "}
+ {data.taskCount !== 0 &&
+ `(${data.completeCount}/${data.taskCount})`}
+
+
+ +{data.points}
+
+
+
+
+
+ {!isLoading ? (
+ "Go"
+ ) : (
+
+ )}
+
+
+ );
+};
+
+export default TaskItem;
diff --git a/src/components/TaskModule/index.tsx b/src/components/TaskModule/index.tsx
new file mode 100644
index 0000000..f86e383
--- /dev/null
+++ b/src/components/TaskModule/index.tsx
@@ -0,0 +1,56 @@
+
+import { TAB_LIST } from "@/constants/navigation";
+import { USER_TASK_TITLE, USER_TASK_TITLE_MAP } from "@/constants/task";
+import { TaskInfo } from "@/types/task";
+
+import TaskItem from "./components/TaskItem";
+
+
+interface ITaskModuleProps {
+ title: USER_TASK_TITLE;
+ description?: string;
+ data: TaskInfo[];
+ totalPoints: number;
+ switchTab: (tab: TAB_LIST) => void;
+ toInvite(): void;
+ refresh?(points?: number): void;
+}
+
+const TaskModule = ({
+ title,
+ description,
+ data,
+ totalPoints,
+ switchTab,
+ toInvite,
+ refresh,
+}: ITaskModuleProps) => {
+ return (
+
+
+
+ {USER_TASK_TITLE_MAP[title]}
+
+ {description && (
+
+ {description}
+
+ )}
+
+
+ {data?.map((task: TaskInfo) => (
+
+ ))}
+
+ );
+};
+
+export default TaskModule;
diff --git a/src/components/TaskModule/utils/index.ts b/src/components/TaskModule/utils/index.ts
new file mode 100644
index 0000000..467a0c8
--- /dev/null
+++ b/src/components/TaskModule/utils/index.ts
@@ -0,0 +1,49 @@
+import { USER_TASK_DETAIL } from "@/constants/task";
+
+export const openNewPageWaitPageVisible = async (
+ url: string,
+ taskId: USER_TASK_DETAIL,
+ req: () => Promise<{
+ code: string;
+ data: boolean;
+ }>
+) => {
+ if (
+ taskId === USER_TASK_DETAIL.EXPLORE_JOIN_TG_CHANNEL ||
+ taskId === USER_TASK_DETAIL.EXPLORE_SCHRODINGER ||
+ taskId === USER_TASK_DETAIL.EXPLORE_JOIN_VOTIGRAM
+ ) {
+ // web.telegram.org will destroy the page when openTelegramLink
+ // so send complete request before open link
+ if (window?.Telegram?.WebApp?.platform?.includes("web")) {
+ return req()
+ .then(() => {
+ window?.Telegram?.WebApp?.openTelegramLink?.(url);
+ return true;
+ })
+ .catch(() => {
+ window?.Telegram?.WebApp?.openTelegramLink?.(url);
+ return false;
+ });
+ }
+ window?.Telegram?.WebApp?.openTelegramLink?.(url);
+ } else {
+ window?.Telegram?.WebApp?.openLink?.(url);
+ }
+ return new Promise((resolve) => {
+ setTimeout(() => {
+ if (document.visibilityState === "visible") {
+ setTimeout(() => {
+ resolve(false);
+ }, 2000);
+ } else {
+ const handleVisibilityChange = () => {
+ if (document.visibilityState === "visible") {
+ resolve(false);
+ }
+ };
+ document.addEventListener("visibilitychange", handleVisibilityChange);
+ }
+ }, 200);
+ });
+};
diff --git a/src/components/TelegramHeader/__test__/index.test.tsx b/src/components/TelegramHeader/__test__/index.test.tsx
new file mode 100644
index 0000000..70dc019
--- /dev/null
+++ b/src/components/TelegramHeader/__test__/index.test.tsx
@@ -0,0 +1,30 @@
+// TelegramHeader.test.tsx
+import { render, screen } from "@testing-library/react";
+import { describe, it, expect } from "vitest";
+
+import TelegramHeader from "../index";
+
+import "@testing-library/jest-dom";
+
+describe("TelegramHeader Component", () => {
+ it("renders the title when title prop is provided", () => {
+ const title = "Sample Title";
+ render( );
+ expect(screen.getByText(title)).toBeInTheDocument();
+ });
+
+ it("does not render a span when title prop is absent", () => {
+ render( );
+ const spanElement = screen.queryByText("Sample Title");
+ expect(spanElement).not.toBeInTheDocument();
+ });
+
+ it("applies the correct class names", () => {
+ const title = "Sample Title";
+ render( );
+ const titleElement = screen.getByText(title);
+ expect(titleElement).toHaveClass(
+ "font-outfit text-[18px] leading-[18px] font-bold"
+ );
+ });
+});
diff --git a/src/components/TelegramHeader/index.css b/src/components/TelegramHeader/index.css
new file mode 100644
index 0000000..405007a
--- /dev/null
+++ b/src/components/TelegramHeader/index.css
@@ -0,0 +1,22 @@
+.telegram-header-container {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ min-height: calc(
+ var(--tg-content-safe-area-inset-top) + var(--tg-safe-area-inset-top) +
+ var(--tg-safe-area-custom-top)
+ );
+ padding-top: var(--tg-safe-area-inset-top);
+ background-color: black;
+ position: fixed;
+ top: 0;
+ width: 100%;
+ z-index: 10000;
+}
+
+.telegram-header-container + .pt-telegramHeader {
+ padding-top: calc(
+ var(--tg-content-safe-area-inset-top) + var(--tg-safe-area-inset-top) +
+ var(--tg-safe-area-custom-top)
+ );
+}
diff --git a/src/components/TelegramHeader/index.tsx b/src/components/TelegramHeader/index.tsx
new file mode 100644
index 0000000..0029fab
--- /dev/null
+++ b/src/components/TelegramHeader/index.tsx
@@ -0,0 +1,23 @@
+import React, { ReactNode } from "react";
+
+import "./index.css";
+import clsx from "clsx";
+
+interface ITelegramHeaderProps {
+ className?: string;
+ title?: ReactNode;
+}
+
+const TelegramHeader = ({ title, className }: ITelegramHeaderProps) => {
+ return (
+
+ {title && (
+
+ {title}
+
+ )}
+
+ );
+};
+
+export default TelegramHeader;
diff --git a/src/components/Textarea/__test__/index.test.tsx b/src/components/Textarea/__test__/index.test.tsx
new file mode 100644
index 0000000..21ad855
--- /dev/null
+++ b/src/components/Textarea/__test__/index.test.tsx
@@ -0,0 +1,123 @@
+// Textarea.test.tsx
+import { render, screen, fireEvent } from "@testing-library/react";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+
+import Textarea from "../index"; // Adjust to the correct file path
+
+import "@testing-library/jest-dom";
+
+describe("Textarea Component", () => {
+ const defaultProps = {
+ value: "",
+ onChange: vi.fn(),
+ placeholder: "Enter some text...",
+ maxLength: 100,
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.useFakeTimers();
+ document.body.style.height = "100%";
+ });
+
+ beforeEach(() => {
+ Object.defineProperty(window, "scrollTo", {
+ value: vi.fn(),
+ writable: true,
+ });
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("renders correctly with initial props", () => {
+ render();
+ const textarea = screen.getByPlaceholderText("Enter some text...");
+ expect(textarea).toBeInTheDocument();
+ expect(textarea).toHaveValue(""); // Initial value
+ expect(textarea).toHaveAttribute("maxlength", "100");
+ });
+
+ it("updates the character count and calls onChange as text is entered", () => {
+ render();
+ const textarea = screen.getByPlaceholderText("Enter some text...");
+
+ // Simulate typing
+ fireEvent.change(textarea, { target: { value: "Hello" } });
+
+ expect(defaultProps.onChange).toHaveBeenCalledWith("Hello");
+ expect(screen.getByText("5/100")).toBeInTheDocument(); // Character count
+ });
+
+ it("does not allow text to exceed maxLength", () => {
+ render();
+ const textarea = screen.getByPlaceholderText("Enter some text...");
+
+ fireEvent.change(textarea, { target: { value: "a".repeat(110) } });
+
+ // Ensure onChange is called with the truncated value
+ expect(defaultProps.onChange).toHaveBeenCalledWith("a".repeat(100));
+
+ // Ensure the displayed text is truncated
+ expect(textarea).toHaveValue("a".repeat(100));
+
+ // Ensure the character count shows the max length
+ expect(screen.getByText("100/100")).toBeInTheDocument();
+ });
+
+ it("applies danger class when maxLength is reached", () => {
+ render();
+ const textarea = screen.getByPlaceholderText("Enter some text...");
+
+ fireEvent.change(textarea, { target: { value: "a".repeat(100) } });
+
+ const charCount = screen.getByText("100/100");
+ expect(charCount).toHaveClass("!text-danger");
+ });
+
+ it("auto-resizes as text is entered", () => {
+ render();
+ const textarea = screen.getByPlaceholderText("Enter some text...");
+
+ // Mock the scrollHeight property
+ Object.defineProperty(textarea, "scrollHeight", {
+ value: 50,
+ writable: true,
+ });
+
+ fireEvent.change(textarea, { target: { value: "This is a test" } });
+
+ // Check that style.height is updated correctly
+ expect(textarea.style.height).toBe("50px");
+ });
+
+ it("applies and removes event listeners on focus and viewport resize", () => {
+ const addEventListenerSpy = vi.spyOn(window, "addEventListener");
+ const removeEventListenerSpy = vi.spyOn(window, "removeEventListener");
+
+ const { unmount } = render();
+
+ // Ensure event listeners are applied
+ expect(addEventListenerSpy).toHaveBeenCalledWith(
+ "focusin",
+ expect.any(Function)
+ );
+ expect(addEventListenerSpy).toHaveBeenCalledWith(
+ "focusout",
+ expect.any(Function)
+ );
+
+ // Unmount the component and ensure listeners are removed
+ unmount();
+
+ expect(removeEventListenerSpy).toHaveBeenCalledWith(
+ "focusin",
+ expect.any(Function)
+ );
+ expect(removeEventListenerSpy).toHaveBeenCalledWith(
+ "focusout",
+ expect.any(Function)
+ );
+ });
+});
diff --git a/src/components/Textarea/index.tsx b/src/components/Textarea/index.tsx
new file mode 100644
index 0000000..c07689c
--- /dev/null
+++ b/src/components/Textarea/index.tsx
@@ -0,0 +1,116 @@
+import React, { useEffect, useRef, useState } from "react";
+
+import clsx from "clsx";
+
+interface ITextareaProps {
+ value: string;
+ onChange: (value: string) => void;
+ disabled?: boolean;
+ placeholder?: string;
+ maxLength?: number;
+ rootClassName?: string;
+ onSubmit?: (text: string) => void;
+ onFocus?: () => void;
+ onBlur?: () => void;
+ onKeyDown?(e: React.KeyboardEvent): void;
+}
+
+const Textarea = ({
+ value,
+ onChange,
+ placeholder,
+ maxLength = 500,
+ rootClassName,
+ onFocus,
+ onBlur,
+ onKeyDown,
+}: ITextareaProps) => {
+ const [text, setText] = useState(value);
+ const [charCount, setCharCount] = useState(0);
+ const textareaRef = useRef(null);
+
+ useEffect(() => {
+ setText(value);
+ setCharCount(value.length);
+ const textarea = textareaRef.current;
+ if (!value && textarea) {
+ textarea.style.height = "auto";
+ }
+ }, [value]);
+
+ const handleChange = (event: React.ChangeEvent) => {
+ const newText = event.target.value;
+ if (newText.length <= maxLength) {
+ setText(newText);
+ onChange(newText);
+ setCharCount(newText.length);
+ autoResizeTextarea();
+ } else {
+ setText(newText.slice(0, maxLength));
+ onChange(newText.slice(0, maxLength));
+ setCharCount(maxLength);
+ }
+ };
+
+ const autoResizeTextarea = () => {
+ const textarea = textareaRef.current;
+ if (textarea) {
+ textarea.style.height = "auto";
+ textarea.style.height = `${Math.min(textarea.scrollHeight, 320)}px`;
+ textarea.style.lineHeight = "13px";
+ textarea.focus();
+ }
+ };
+
+ useEffect(() => {
+ const handleResize = () => {
+ const viewportHeight = window.innerHeight;
+ document.body.style.height = `${viewportHeight}px`;
+ };
+
+ const resetScrollPosition = () => {
+ window.scrollTo(0, 0);
+ };
+ window.addEventListener("focusin", resetScrollPosition);
+ window.addEventListener("focusout", resetScrollPosition);
+ window?.visualViewport?.addEventListener("resize", handleResize);
+
+ return () => {
+ window.removeEventListener("focusin", resetScrollPosition);
+ window.removeEventListener("focusout", resetScrollPosition);
+ window?.visualViewport?.removeEventListener("resize", handleResize);
+ };
+ }, []);
+
+ return (
+
+
+ {charCount > 0 && (
+
+ {charCount}/{maxLength}
+
+ )}
+
+ );
+};
+
+export default Textarea;
diff --git a/src/components/ToggleSlider/__test__/index.test.tsx b/src/components/ToggleSlider/__test__/index.test.tsx
new file mode 100644
index 0000000..17f552e
--- /dev/null
+++ b/src/components/ToggleSlider/__test__/index.test.tsx
@@ -0,0 +1,19 @@
+// ToggleSlider.test.tsx
+import { render, screen } from "@testing-library/react";
+import { describe, it, expect } from "vitest";
+
+import ToggleSlider from "../index";
+
+import "@testing-library/jest-dom";
+
+describe("ToggleSlider Component", () => {
+ const items = ["Item 1", "Item 2", "Item 3"];
+
+ it("renders all items correctly", () => {
+ render( );
+
+ items.forEach((item) => {
+ expect(screen.getByText(item)).toBeInTheDocument();
+ });
+ });
+});
diff --git a/src/components/ToggleSlider/index.tsx b/src/components/ToggleSlider/index.tsx
new file mode 100644
index 0000000..547205b
--- /dev/null
+++ b/src/components/ToggleSlider/index.tsx
@@ -0,0 +1,93 @@
+
+import React, { useState, useEffect, useRef } from "react";
+
+import clsx from "clsx";
+import { AnimatePresence, motion } from "framer-motion";
+
+interface IToggleSlider {
+ current?: number;
+ items: string[];
+ className?: string;
+ itemClassName?: string;
+ activeItemClassName?: string;
+ onChange?: (index: number) => void;
+}
+
+const ToggleSlider = ({
+ current,
+ items = [],
+ className,
+ itemClassName,
+ activeItemClassName,
+ onChange,
+}: IToggleSlider) => {
+ const [activeIndex, setActiveIndex] = useState(current || 0);
+ const containerRef = useRef(null);
+ const itemRefs = useRef>([]);
+
+ useEffect(() => {
+ // Initialize refs array with items count
+ itemRefs.current = itemRefs.current.slice(0, items.length);
+ }, [items.length]);
+
+ const handleClick = (index: number) => {
+ setActiveIndex(index);
+ onChange?.(index);
+ };
+
+ useEffect(() => {
+ setActiveIndex(current ?? 0);
+ }, [current])
+
+ return (
+ (containerRef.current = ref)}
+ className={clsx(
+ "relative h-full w-full mx-auto bg-tertiary rounded-full flex items-center overflow-hidden",
+ className
+ )}
+ >
+
+
+
+
+ {items.map((item, index) => (
+
handleClick(index)}
+ ref={(el) => (itemRefs.current[index] = el)}
+ >
+
+ {item}
+
+
+ ))}
+
+
+ );
+};
+
+export default ToggleSlider;
diff --git a/src/components/ToggleSlider/type/index.ts b/src/components/ToggleSlider/type/index.ts
new file mode 100644
index 0000000..99a7429
--- /dev/null
+++ b/src/components/ToggleSlider/type/index.ts
@@ -0,0 +1,8 @@
+export interface IToggleSlider {
+ current?: number;
+ items: string[];
+ className?: string;
+ itemClassName?: string;
+ activeItemClassName?: string;
+ onChange?: (index: number) => void;
+}
diff --git a/src/components/TopVotedApps/__test__/index.test.tsx b/src/components/TopVotedApps/__test__/index.test.tsx
new file mode 100644
index 0000000..6f12682
--- /dev/null
+++ b/src/components/TopVotedApps/__test__/index.test.tsx
@@ -0,0 +1,50 @@
+import { render, screen } from "@testing-library/react";
+import "@testing-library/jest-dom";
+import { describe, it, expect, vi } from "vitest";
+
+import { voteAppListData } from "@/__mocks__/VoteApp";
+
+import TopVotedApps from "../index";
+
+const mockOnAppItemClick = vi.fn();
+
+describe("TopVotedApps Component", () => {
+ it("renders the title", () => {
+ render(
+
+ );
+ const titleElement = screen.getByText(/Weekly Top Voted Apps/i);
+ expect(titleElement).toBeInTheDocument();
+ });
+
+ it("renders the correct number of items", () => {
+ render(
+
+ );
+ const items = screen.getAllByRole("img");
+ expect(items).toHaveLength(voteAppListData.length);
+ });
+
+ it("displays the correct image and points for each app", () => {
+ render(
+
+ );
+ voteAppListData.forEach((item) => {
+ const image = screen.getByAltText(item.title || "");
+ expect(image).toBeInTheDocument();
+ expect(image).toHaveAttribute("src", item.icon);
+
+ const points = screen.getByTestId(`${item.title}-point`);
+ expect(points.innerHTML).toBe((item.pointsAmount || 0).toLocaleString());
+ });
+ });
+});
diff --git a/src/components/TopVotedApps/index.css b/src/components/TopVotedApps/index.css
new file mode 100644
index 0000000..cbb863a
--- /dev/null
+++ b/src/components/TopVotedApps/index.css
@@ -0,0 +1,15 @@
+.top-voted-app-list {
+ padding: 0 20px;
+ display: flex;
+ flex-wrap: nowrap;
+ justify-content: flex-start;
+ align-items: center;
+ position: relative;
+ overflow-x: scroll;
+ scrollbar-width: none;
+ gap: 9px;
+
+ .item {
+ flex-grow: 0;
+ }
+}
\ No newline at end of file
diff --git a/src/components/TopVotedApps/index.tsx b/src/components/TopVotedApps/index.tsx
new file mode 100644
index 0000000..14844b8
--- /dev/null
+++ b/src/components/TopVotedApps/index.tsx
@@ -0,0 +1,50 @@
+import { VoteApp } from "@/types/app";
+import "./index.css";
+
+interface ITopVoteApps {
+ items: VoteApp[];
+ onAppItemClick: (item: VoteApp) => void;
+}
+
+const TopVotedApps = ({ items, onAppItemClick }: ITopVoteApps) => {
+ return (
+ items?.length > 0 && (
+
+
+ Weekly Top Voted Apps
+
+
+ {items?.map((item) => (
+
onAppItemClick(item)}
+ className="flex flex-col p-[7px] rounded-[10px] bg-tertiary item min-h-[84px] min-w-[106px]"
+ >
+
+
+
+
+
+ {(item.pointsAmount || 0).toLocaleString()}
+
+
+ pts
+
+
+
+
+ ))}
+
+
+ )
+ );
+};
+
+export default TopVotedApps;
diff --git a/src/components/Upload/__test__/index.test.tsx b/src/components/Upload/__test__/index.test.tsx
new file mode 100644
index 0000000..5b6a9e6
--- /dev/null
+++ b/src/components/Upload/__test__/index.test.tsx
@@ -0,0 +1,71 @@
+// Upload.test.tsx
+import { render, screen, fireEvent, waitFor } from "@testing-library/react";
+import { vi, describe, it, expect } from "vitest";
+
+import Upload from "../index"; // Adjust the path to your component
+
+import "@testing-library/jest-dom";
+
+// Mock external dependencies
+vi.mock("@/utils/canvasUtils", () => ({
+ getCroppedImg: vi.fn().mockResolvedValue(new Blob()),
+}));
+
+vi.mock("@/hooks/useData", () => ({
+ uploadWithToken: vi.fn().mockResolvedValue({
+ code: "20000",
+ data: "https://example.com/uploaded-image.jpg",
+ }),
+}));
+
+vi.mock("@/utils/file", () => ({
+ blobToFile: vi
+ .fn()
+ .mockImplementation((blob) => new File([blob], "croppedImage.jpg")),
+}));
+
+describe("Upload Component", () => {
+ const mockOnFinish = vi.fn();
+ const mockFile = new File(["dummy content"], "example.jpg", {
+ type: "image/jpeg",
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks(); // Reset mocks after each test
+ });
+
+ it("renders correctly with default props", () => {
+ render( );
+ const uploadContainer = screen.getByTestId("confirm-button");
+ expect(uploadContainer).toBeInTheDocument();
+
+ const placeholderIcon = uploadContainer.querySelector(
+ ".votigram-icon-back"
+ );
+ expect(placeholderIcon).toBeInTheDocument();
+ });
+
+ it("renders children when provided", () => {
+ render(Custom Upload Text );
+ expect(screen.getByText("Custom Upload Text")).toBeInTheDocument();
+ });
+
+ it("handles upload errors gracefully", async () => {
+ vi.mock("@/hooks/useData", () => ({
+ uploadWithToken: vi.fn().mockRejectedValue(new Error("Upload failed")),
+ }));
+
+ render( );
+ const fileInput = screen.getByRole("textbox", { hidden: true });
+
+ fireEvent.change(fileInput, { target: { files: [mockFile] } });
+
+ // Wait for the error to be logged
+ await waitFor(() => {
+ expect(screen.queryByText("Loading")).not.toBeInTheDocument();
+ });
+
+ // Verify onFinish is not called due to the error
+ expect(mockOnFinish).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/components/Upload/index.tsx b/src/components/Upload/index.tsx
new file mode 100644
index 0000000..168ac92
--- /dev/null
+++ b/src/components/Upload/index.tsx
@@ -0,0 +1,187 @@
+import { ReactNode, useRef, useState } from "react";
+
+import clsx from "clsx";
+import Cropper, { Area } from "react-easy-crop";
+
+
+import { chainId } from "@/constants/app";
+import { uploadWithToken } from "@/hooks/useData";
+import { getCroppedImg } from "@/utils/canvasUtils";
+import { blobToFile } from "@/utils/file";
+
+import Drawer from "../Drawer";
+import Loading from "../Loading";
+
+
+interface IUploadProps {
+ extensions?: string[];
+ fileLimit?: string;
+ className?: string;
+ needCrop?: boolean;
+ children?: ReactNode;
+ aspect?: number;
+ onFinish?(url: string): void;
+}
+
+const readFile = (file: File) => {
+ return new Promise((resolve) => {
+ const reader = new FileReader();
+ reader.addEventListener("load", () => resolve(reader.result), false);
+ reader.readAsDataURL(file);
+ });
+};
+
+const Upload = ({
+ className,
+ needCrop,
+ aspect,
+ children,
+ onFinish,
+}: IUploadProps) => {
+ const fileInputRef = useRef(null);
+ const [loading, setLoading] = useState(false);
+ const [cropping, setCropping] = useState(false);
+ const [imageSrc, setImageSrc] = useState();
+ const [zoom, setZoom] = useState(1);
+ const [rotation, setRotation] = useState(0);
+ const [crop, setCrop] = useState({ x: 0, y: 0 });
+ const [croppedAreaPixels, setCroppedAreaPixels] = useState (null);
+ const [croppedImage, setCroppedImage] = useState();
+ const [croppedImageUrl, setCropedImageUrl] = useState();
+
+ const onCropComplete = (_: Area, croppedAreaPixels: Area) => {
+ setCroppedAreaPixels(croppedAreaPixels);
+ };
+
+ const showCroppedImage = async () => {
+ if (!imageSrc || !croppedAreaPixels) return;
+ try {
+ const croppedImage = await getCroppedImg(
+ imageSrc,
+ croppedAreaPixels,
+ rotation
+ );
+ if (croppedImage) {
+ const file = blobToFile(croppedImage);
+ setCropedImageUrl(URL.createObjectURL(croppedImage));
+ setCroppedImage(croppedImage);
+ handleUpload(file);
+ setCropping(false);
+ setImageSrc("");
+ }
+ } catch (error) {
+ console.error(error);
+ }
+ };
+
+ const handleClick = () => {
+ if (!loading && fileInputRef.current) {
+ fileInputRef.current.click();
+ }
+ };
+
+ const handleFileChange = async (e: React.ChangeEvent) => {
+ if (e.target.files && e.target.files.length > 0) {
+ const file = e.target.files[0];
+ const imageDataUrl = (await readFile(file)) as string;
+ setImageSrc(imageDataUrl);
+ if (fileInputRef.current) {
+ fileInputRef.current.value = "";
+ }
+ if (needCrop) {
+ setCropping(true);
+ } else {
+ handleUpload(file);
+ }
+ }
+ };
+
+ const handleUpload = async (file: File) => {
+ try {
+ if (!file) return;
+ const formData = new FormData();
+ formData.append("file", file);
+ formData.append("chainId", chainId);
+ setLoading(true);
+ const { code, data } = await uploadWithToken(
+ "/api/app/file/upload",
+ formData
+ );
+ if (code === "20000") {
+ onFinish?.(data);
+ }
+ } catch (e) {
+ console.error(e);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+ <>
+
+ {imageSrc || croppedImage ? (
+
+ ) : children ? (
+ children
+ ) : (
+
+ )}
+
+
+ {loading && (
+
+ )}
+
+ setCropping(false)}
+ rootClassName="bg-tertiary h-screen rounded-none"
+ >
+
+
+ >
+ );
+};
+
+export default Upload;
diff --git a/src/components/Vote/index.tsx b/src/components/Vote/index.tsx
new file mode 100644
index 0000000..c8b515b
--- /dev/null
+++ b/src/components/Vote/index.tsx
@@ -0,0 +1,161 @@
+
+import { useCallback, useEffect, useRef, useState } from "react";
+
+import dayjs from "dayjs";
+
+
+import { VOTE_TABS } from "@/constants/vote";
+import useSetSearchParams from "@/hooks/useSetSearchParams";
+import { useUserContext } from "@/provider/UserProvider";
+import { VoteApp } from "@/types/app";
+
+import Community from "../Community";
+import Countdown from "../Countdown";
+import Modal from "../Modal";
+import TelegramHeader from "../TelegramHeader";
+import TMAs from "../TMAs";
+import ToggleSlider from "../ToggleSlider";
+
+
+interface IVoteProps {
+ onAppItemClick: (item: VoteApp) => void;
+}
+
+const TABS = [VOTE_TABS.TMAS, VOTE_TABS.COMMUNITY];
+
+const Vote = ({ onAppItemClick }: IVoteProps) => {
+ const {
+ user: { userPoints },
+ } = useUserContext();
+ const { querys, updateQueryParam } = useSetSearchParams();
+ const activeTab = querys.get("vote_tab");
+ const tmasTab = querys.get("tmas");
+ const [tmaTab, setTMATab] = useState(tmasTab === "1" ? Number(tmasTab) : 0);
+ const [currentTab, setCurrentTab] = useState(activeTab || VOTE_TABS.TMAS);
+ const scrollViewRef = useRef(null);
+ const [seconds, setSeconds] = useState(0);
+ const [scrollTop, setScrollTop] = useState(0);
+ const [showWelcome, setShowWelCome] = useState(false);
+
+ const handleScroll = useCallback(() => {
+ const scrollRef = scrollViewRef.current;
+ if (scrollRef) {
+ setScrollTop(
+ scrollRef.scrollHeight - scrollRef.scrollTop - scrollRef.clientHeight
+ );
+ }
+ }, [scrollViewRef]);
+
+ useEffect(() => {
+ const scrollRef = scrollViewRef.current;
+ if (scrollRef) {
+ scrollRef.addEventListener("scroll", handleScroll);
+ }
+
+ return () => {
+ if (scrollRef) {
+ scrollRef.removeEventListener("scroll", handleScroll);
+ }
+ };
+ }, [handleScroll, scrollViewRef]);
+
+ const getRemainingSeconds = () => {
+ const now = dayjs();
+ const dayOfWeek = now.day();
+ const daysUntilSunday = (7 - dayOfWeek) % 7;
+ const nextSundayMidnight = now
+ .add(daysUntilSunday, "day")
+ .startOf("day")
+ .add(1, "day");
+
+ const differenceInMilliseconds = nextSundayMidnight.diff(now);
+ const differenceInSeconds = Math.floor(differenceInMilliseconds / 1000);
+ setSeconds(differenceInSeconds);
+ };
+
+ const onTabChange = (index: number) => {
+ setCurrentTab(index === 0 ? VOTE_TABS.TMAS : VOTE_TABS.COMMUNITY);
+ updateQueryParam({
+ key: "vote_tab",
+ value: index === 0 ? VOTE_TABS.TMAS : VOTE_TABS.COMMUNITY,
+ });
+ };
+
+ useEffect(() => {
+ if (activeTab) {
+ setCurrentTab(activeTab || VOTE_TABS.TMAS);
+ }
+ }, [activeTab]);
+
+ useEffect(() => {
+ const isShowed = localStorage.getItem("showWelcome");
+
+ if (!isShowed) {
+ setShowWelCome(!isShowed);
+ localStorage.setItem("showWelcome", "1");
+ }
+ }, []);
+
+ return (
+
+
+ ) : (
+ ""
+ )
+ }
+ />
+
+
+ tab === currentTab)}
+ items={TABS}
+ onChange={onTabChange}
+ />
+
+
+
+ Total earned points:
+
+
+ {userPoints?.userTotalPoints.toLocaleString() || 0}
+
+
+
+ {currentTab === VOTE_TABS.TMAS ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {`Vote For Your Favourite \nTelegram Mini-Apps!`}
+
+ setShowWelCome(false)}
+ >
+ Let's Go!
+
+
+
+ );
+};
+
+export default Vote;
diff --git a/src/components/VoteItem/index.tsx b/src/components/VoteItem/index.tsx
new file mode 100644
index 0000000..5f69d20
--- /dev/null
+++ b/src/components/VoteItem/index.tsx
@@ -0,0 +1,390 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+
+import { useConnectWallet } from "@aelf-web-login/wallet-adapter-react";
+import useRequest from "ahooks/lib/useRequest";
+import { CreateTypes } from "canvas-confetti";
+import clsx from "clsx";
+
+import Confetti from "@/components/Confetti";
+import { rpcUrlTDVW, sideChainCAContractAddress, voteAddress } from "@/config";
+import { chainId } from "@/constants/app";
+import { HEART_SHAPE } from "@/constants/canvas-confetti";
+import { APP_CATEGORY } from "@/constants/discover";
+import { VOTE_STATUS } from "@/constants/vote";
+import { postWithToken } from "@/hooks/useData";
+import { useUserContext } from "@/provider/UserProvider";
+import { VoteApp } from "@/types/app";
+import { EVoteOption } from "@/types/contract";
+import { getRawTransactionPortkey } from "@/utils/getRawTransactionPortkey";
+
+import Drawer from "../Drawer";
+import ProgressBar from "../ProgressBar";
+import { getTrackId } from "./utils";
+
+interface IVoteItemProps {
+ data: VoteApp;
+ rank?: number;
+ canVote?: boolean;
+ showHat?: boolean;
+ showBtn?: boolean;
+ showPoints?: boolean;
+ isTMACurrent?: boolean;
+ className?: string;
+ proposalId: string;
+ hatClassName?: string;
+ imgClassName?: string;
+ category?: APP_CATEGORY;
+ onVoted?(addPoints?: number): void;
+ onClick?: (item: VoteApp) => void;
+}
+
+let RETRY_MAX_COUNT = 30;
+
+const VoteItem = ({
+ data,
+ rank,
+ showHat,
+ showBtn,
+ canVote,
+ showPoints,
+ className,
+ proposalId,
+ hatClassName,
+ imgClassName,
+ category,
+ isTMACurrent,
+ onVoted,
+ onClick,
+}: IVoteItemProps) => {
+ const {
+ user: { userPoints },
+ updateUserPoints,
+ } = useUserContext();
+ const elementRef = useRef(null);
+ const buttonRef = useRef(null);
+ const confettiInstance = useRef(null);
+ const [totalCurrentPoints, setTotalCurrentPoints] = useState(
+ isTMACurrent ? data.totalPoints : data.pointsAmount
+ );
+ const { walletInfo, callSendMethod } = useConnectWallet();
+
+ const [elementWidth, setElementWidth] = useState(0);
+ const [likeCount, setLikeCount] = useState(0);
+ const [loading, setLoading] = useState(false);
+ const [isFailed, setIsFailed] = useState(false);
+
+ const onInit = ({ confetti }: { confetti: CreateTypes }) => {
+ confettiInstance.current = confetti;
+ };
+
+ const { run: fetchVoteStatus, cancel } = useRequest(
+ async (rawTransaction, result) => {
+ try {
+ setLoading(true);
+ const { data } = await voteRequest(rawTransaction, result);
+ if (
+ !data ||
+ data.status === VOTE_STATUS.FAILED ||
+ RETRY_MAX_COUNT < 0
+ ) {
+ setLoading(false);
+ setIsFailed(true);
+ cancel();
+ } else if (data.status === VOTE_STATUS.VOTED) {
+ if (data?.userTotalPoints) {
+ updateUserPoints(data.userTotalPoints);
+ }
+ onVoted?.(200);
+ showConfetti();
+ cancel();
+ setLoading(false);
+ }
+ RETRY_MAX_COUNT--;
+ } catch (error) {
+ console.error(error);
+ setLoading(false);
+ setIsFailed(true);
+ }
+ },
+ {
+ manual: true,
+ pollingInterval: 2000,
+ }
+ );
+
+ const showConfetti = () => {
+ let normalizedTop = 0.5;
+ let normalizedLeft = 0.88;
+
+ if (buttonRef.current) {
+ const rect = buttonRef.current.getBoundingClientRect();
+ const windowHeight = window.innerHeight;
+ const windowWidth = window.innerWidth;
+ normalizedTop = rect.top / windowHeight;
+ normalizedLeft = rect.left / windowWidth;
+ }
+
+ confettiInstance.current?.({
+ angle: 110,
+ particleCount: 15,
+ spread: 70,
+ origin: { y: normalizedTop, x: normalizedLeft },
+ disableForReducedMotion: true,
+ shapes: [HEART_SHAPE],
+ zIndex: 10,
+ });
+ };
+
+ const onVoteClick = (event: React.MouseEvent) => {
+ event.preventDefault();
+ event.stopPropagation();
+ if (canVote) {
+ RETRY_MAX_COUNT = 10;
+ sedRawTransaction();
+ } else {
+ showConfetti();
+ setLikeCount((prevCount) => prevCount + 1);
+ }
+ window.Telegram.WebApp.HapticFeedback.notificationOccurred("success");
+ };
+
+ useEffect(() => {
+ setLikeCount(0);
+ setTotalCurrentPoints(isTMACurrent ? data.totalPoints : data.pointsAmount);
+ }, [data.totalPoints, data.pointsAmount, isTMACurrent]);
+
+ const fetchRankingLike = useCallback(async (likeCount: number) => {
+ const { data: { userTotalPoints } } = await postWithToken("/api/app/ranking/like", {
+ chainId,
+ proposalId,
+ likeList: [
+ {
+ alias: data.alias,
+ likeAmount: likeCount,
+ },
+ ],
+ });
+ updateUserPoints(userTotalPoints || userPoints?.userTotalPoints);
+ }, [data.alias, proposalId, updateUserPoints, userPoints?.userTotalPoints])
+
+ useEffect(() => {
+ if (likeCount > 0) {
+ const timer = setTimeout(() => {
+ setTotalCurrentPoints((prev) => (prev || 0) + likeCount);
+ onVoted?.(likeCount);
+ fetchRankingLike(likeCount);
+ setLikeCount(0);
+ }, 1000);
+
+ return () => clearTimeout(timer); // Cleanup timeout on unmount or update
+ }
+ }, [data.alias, fetchRankingLike, likeCount, onVoted, proposalId]);
+
+ const sedRawTransaction = async () => {
+ try {
+ setLoading(true);
+ const result: {
+ transactionId: string;
+ } = await callSendMethod({
+ contractAddress: voteAddress,
+ methodName: "Vote",
+ args: {
+ votingItemId: proposalId,
+ voteOption: EVoteOption.APPROVED,
+ voteAmount: 1,
+ memo: `##GameRanking:{${data?.alias}}`,
+ },
+ chainId,
+ });
+
+ const rawTransaction = await getRawTransactionPortkey({
+ caHash: walletInfo?.extraInfo?.portkeyInfo.caInfo.caHash,
+ privateKey: walletInfo?.extraInfo?.portkeyInfo.walletInfo.privateKey,
+ contractAddress: voteAddress,
+ caContractAddress: sideChainCAContractAddress,
+ rpcUrl: rpcUrlTDVW,
+ params: {
+ votingItemId: proposalId,
+ voteOption: EVoteOption.APPROVED,
+ voteAmount: 1,
+ memo: `##GameRanking:{${data?.alias}}`,
+ },
+ methodName: "Vote",
+ });
+ if (rawTransaction && result) {
+ fetchVoteStatus(rawTransaction, result);
+ }
+ } catch (error) {
+ console.error(error);
+ setLoading(false);
+ setIsFailed(true);
+ }
+ };
+
+ const voteRequest = (
+ rawTransaction: string,
+ result: { transactionId: string }
+ ) => {
+ const trackId = getTrackId();
+ return postWithToken("/api/app/ranking/vote", {
+ chainId,
+ rawTransaction: rawTransaction,
+ transactionId: result.transactionId,
+ trackId,
+ category,
+ });
+ };
+
+ useEffect(() => {
+ const updateWidth = () => {
+ if (elementRef.current) {
+ setElementWidth(elementRef.current.clientWidth);
+ }
+ };
+ updateWidth();
+ }, []);
+
+ const handleFinish = (event: React.MouseEvent) => {
+ event.preventDefault();
+ event.stopPropagation();
+ setIsFailed(false);
+ sedRawTransaction();
+ };
+
+ return (
+
+
onClick?.(data as VoteApp)}
+ >
+
+ {data?.icon ? (
+ <>
+ {showHat && (
+
+ )}
+
+ >
+ ) : (
+
+ {data.title.slice(0, 1)}
+
+ )}
+
+
+
+
+
+ {rank && (
+
+ {rank}
+
+ )}
+ {data?.title}
+
+ {showPoints && (
+
+ {((totalCurrentPoints || 0) + likeCount)?.toLocaleString()}
+
+ )}
+
+
+
+
+
+ {showBtn && (
+
+
+
+ )}
+
+ {showBtn && (
+
+ )}
+
+
+ {`We're on it, \nThanks for waiting!`}
+
+
+ {`Your vote is being securely registered \non the blockchain.`}
+
+
+
setIsFailed(false)}
+ canClose
+ >
+
+ Please Try Again
+
+
+
+ {`We encountered an error registering \nyour vote on the blockchain.`}
+
+
+ Try Again
+
+
+
+ );
+};
+
+export default VoteItem;
diff --git a/src/components/VoteItem/type/index.ts b/src/components/VoteItem/type/index.ts
new file mode 100644
index 0000000..a1f0246
--- /dev/null
+++ b/src/components/VoteItem/type/index.ts
@@ -0,0 +1,16 @@
+export type VoteItemType = {
+ pointsPercent: number;
+ alias: string;
+ title: string;
+ icon: string;
+ description: string;
+ editorChoice: boolean;
+ url: string;
+ longDescription: string;
+ screenshots?: string[];
+ totalPoints?: number;
+ categories?: string[];
+ totalLikes?: number;
+ totalVotes?: number;
+ pointsAmount?: number;
+}
\ No newline at end of file
diff --git a/src/components/VoteItem/utils/index.ts b/src/components/VoteItem/utils/index.ts
new file mode 100644
index 0000000..8654453
--- /dev/null
+++ b/src/components/VoteItem/utils/index.ts
@@ -0,0 +1,14 @@
+
+
+export const getTrackId = () => {
+ if (window?.Telegram) {
+ const startParam = window.Telegram.WebApp.initDataUnsafe.start_param;
+ const taskDivider = '__CPN__';
+ if (startParam && startParam.includes(taskDivider)) {
+ const param = startParam.split(taskDivider)[1];
+ return param;
+ } else {
+ return '';
+ }
+ }
+};
diff --git a/src/components/VoteSection/__test__/index.test.tsx b/src/components/VoteSection/__test__/index.test.tsx
new file mode 100644
index 0000000..cb2b0ed
--- /dev/null
+++ b/src/components/VoteSection/__test__/index.test.tsx
@@ -0,0 +1,54 @@
+import { render, screen } from "@testing-library/react";
+import { useNavigate } from "react-router-dom";
+import { vi } from "vitest";
+
+import { voteSection } from "@/__mocks__/VoteApp";
+
+import VoteSection from "../index"; // Adjust the path to your component
+
+import "@testing-library/jest-dom";
+
+vi.mock("react-router-dom", () => ({
+ ...vi.importActual("react-router-dom"),
+ useNavigate: vi.fn(),
+}));
+
+describe("VoteSection Component", () => {
+ const mockNavigate = vi.fn();
+
+ beforeEach(() => {
+ (useNavigate as vi.Mock).mockReturnValue(mockNavigate);
+ vi.clearAllMocks();
+ });
+
+ it("renders correctly with all data", () => {
+ render( );
+
+ // Verify the proposal title
+ expect(screen.getByText("Increase Community Fund")).toBeInTheDocument();
+
+ // Verify the duration
+ expect(
+ screen.getByText("Duration: 20 Dec 2024 - 30 Dec 2024")
+ ).toBeInTheDocument();
+
+ // Verify the total vote count
+ expect(screen.getByText("1,250")).toBeInTheDocument();
+
+ // Verify the banner image
+ const bannerImage = screen.getByAltText("Banner");
+ expect(bannerImage).toBeInTheDocument();
+ expect(bannerImage).toHaveAttribute("src", voteSection[0].bannerUrl);
+
+ // Verify the proposal icon
+ const proposalIcon = screen.getByAltText("Avatar");
+ expect(proposalIcon).toBeInTheDocument();
+ expect(proposalIcon).toHaveAttribute("src", voteSection[0].proposalIcon);
+
+ // Verify the tag
+ expect(screen.getByText("Trending")).toBeInTheDocument();
+
+ // Verify the "Created by" text
+ expect(screen.getByText("Created by Alice")).toBeInTheDocument();
+ });
+});
diff --git a/src/components/VoteSection/index.tsx b/src/components/VoteSection/index.tsx
new file mode 100644
index 0000000..66a7cbe
--- /dev/null
+++ b/src/components/VoteSection/index.tsx
@@ -0,0 +1,85 @@
+import clsx from "clsx";
+import dayjs from "dayjs";
+import { useNavigate } from "react-router-dom";
+
+import Tag from "../Tag";
+import { VoteSectionType } from "./type";
+
+
+interface IVoteSctionProps {
+ data: VoteSectionType;
+ className?: string;
+ currentTab?: number;
+}
+
+const VoteSection = ({ data, className, currentTab }: IVoteSctionProps) => {
+ const navigate = useNavigate();
+
+ return (
+
+ navigate(`/proposal/${data.proposalId}`, {
+ state: { from: `/?tab=2&vote_tab=Community&community=${currentTab}` },
+ })
+ }
+ >
+
+ {data.proposalTitle}
+
+
+
+ Duration:{" "}
+ {`${dayjs(data.activeStartTime).format("DD MMM YYYY")} - ${dayjs(
+ data.activeEndTime
+ ).format("DD MMM YYYY")}`}
+
+
+ Total votes:
+
+ {data.totalVoteAmount.toLocaleString() || 0}
+
+
+
+ {data.bannerUrl && (
+
+ )}
+
+
+ {data.proposalIcon ? (
+
+ ) : (
+
+
+
+ )}
+
+ Created by{" "}
+ {data?.proposerFirstName
+ ? data?.proposerFirstName
+ : `ELF_${data.proposer}_tDVW`}
+
+
+
+
+
+
+ {data?.tag && (
+
+ )}
+
+ );
+};
+
+export default VoteSection;
diff --git a/src/components/VoteSection/type/index.ts b/src/components/VoteSection/type/index.ts
new file mode 100644
index 0000000..c3e83b8
--- /dev/null
+++ b/src/components/VoteSection/type/index.ts
@@ -0,0 +1,19 @@
+export type VoteSectionType = {
+ chainId: string;
+ proposalId: string;
+ daoId: string;
+ proposalTitle: string;
+ proposalIcon: string;
+ proposalDescription: string;
+ totalVoteAmount: number;
+ activeStartTime: string;
+ activeEndTime: string;
+ activeStartEpochTime: number;
+ activeEndEpochTime: number;
+ active: boolean;
+ tag: '' | 'Trending';
+ bannerUrl: string;
+ proposalType: 'AD' | '';
+ proposer: string;
+ proposerFirstName: string;
+}
diff --git a/src/config/index.ts b/src/config/index.ts
new file mode 100644
index 0000000..a45e7b8
--- /dev/null
+++ b/src/config/index.ts
@@ -0,0 +1,16 @@
+export const networkType = import.meta.env.VITE_NETWORK_TYPE;
+export const rpcUrlAELF = import.meta.env.VITE_RPC_URL_AELF;
+export const rpcUrlTDVV = import.meta.env.VITE_RPC_URL_TDVV;
+export const rpcUrlTDVW = import.meta.env.VITE_RPC_URL_TDVW;
+export const connectServer = import.meta.env.VITE_CONNECT_SERVER;
+export const connectUrl = import.meta.env.VITE_CONNECT_URL;
+export const graphqlServer = import.meta.env.VITE_GRAPHQL_SERVER;
+export const portkeyServer = import.meta.env.VITE_PORTKEY_SERVER;
+export const sideChainCAContractAddress = import.meta.env
+ .VITE_SIDE_CHAIN_CA_CONTRACT_ADDRESS;
+export const propalAddress = import.meta.env.VITE_PROPAL_ADDRESS;
+export const voteAddress = import.meta.env.VITE_VOTE_ADDRESS;
+export const host = import.meta.env.VITE_HOST;
+export const TELEGRAM_BOT_ID = import.meta.env.VITE_TELEGRAM_BOT_ID;
+export const nftSymbol = import.meta.env.VITE_NFT_SYMBOL;
+export const TgLink = import.meta.env.VITE_TG_LINK;
diff --git a/src/constants/ads.ts b/src/constants/ads.ts
new file mode 100644
index 0000000..e69de29
diff --git a/src/constants/app.ts b/src/constants/app.ts
new file mode 100644
index 0000000..dc18769
--- /dev/null
+++ b/src/constants/app.ts
@@ -0,0 +1,17 @@
+export const chainId = import.meta.env.VITE_SIDECHAIN_ID;
+
+export const projectCode = "13027";
+
+export enum ProposalType {
+ UNSPECIFIED = 0,
+ GOVERNANCE = 1,
+ ADVISORY = 2,
+ VETO = 3,
+ ALL = "ALL",
+}
+
+export enum SupportedELFChainId {
+ MAIN_NET = "AELF",
+ TDVV_NET = "tDVV",
+ TDVW_NET = "tDVW",
+}
diff --git a/src/constants/canvas-confetti.ts b/src/constants/canvas-confetti.ts
new file mode 100644
index 0000000..4dbe5cc
--- /dev/null
+++ b/src/constants/canvas-confetti.ts
@@ -0,0 +1,6 @@
+
+import canvasConfetti from "canvas-confetti";
+
+export const HEART_SHAPE = canvasConfetti.shapeFromPath({
+ path: "M4.562 5.66c2.036-2.146 5.312-2.211 7.424-.197V20a3.124 3.124 0 0 1-2.274-.977l-5.15-5.427c-2.083-2.195-2.083-5.74 0-7.936Zm14.847 0c-2.036-2.146-5.311-2.211-7.423-.197V20c.828 0 1.655-.326 2.273-.977l5.15-5.427c2.083-2.195 2.083-5.74 0-7.936Z",
+});
diff --git a/src/constants/contract.ts b/src/constants/contract.ts
new file mode 100644
index 0000000..d2794f7
--- /dev/null
+++ b/src/constants/contract.ts
@@ -0,0 +1,24 @@
+export const DEFAULT_ERROR = "Something went wrong. Please try again later.";
+
+const USER_DENIED_MESSAGE =
+ "Request rejected. TMRW DAO needs your permission to continue";
+
+export enum SOURCE_ERROR_TYPE {
+ ERROR_1 = "Operation canceled",
+ ERROR_2 = "You closed the prompt without any action",
+ ERROR_3 = "User denied",
+ ERROR_4 = "User close the prompt",
+ ERROR_5 = "Wallet not login",
+ ERROR_6 = "Insufficient allowance of ELF",
+ ERROR_7 = "User Cancel",
+}
+
+export enum TARGET_ERROR_TYPE {
+ ERROR_1 = USER_DENIED_MESSAGE,
+ ERROR_2 = USER_DENIED_MESSAGE,
+ ERROR_3 = USER_DENIED_MESSAGE,
+ ERROR_4 = USER_DENIED_MESSAGE,
+ ERROR_5 = "Wallet not logged in",
+ ERROR_6 = "The allowance you set is less than required. Please reset it",
+ ERROR_7 = USER_DENIED_MESSAGE,
+}
diff --git a/src/constants/discover.ts b/src/constants/discover.ts
new file mode 100644
index 0000000..2976fdd
--- /dev/null
+++ b/src/constants/discover.ts
@@ -0,0 +1,72 @@
+import { DiscoverType } from "@/types/app";
+
+export enum APP_CATEGORY {
+ ALL = "All",
+ NEW = "New",
+ EARN = "Earn",
+ GAME = "Game",
+ FINANCE = "Finance",
+ SOCIAL = "Social",
+ UTILITY = "Utility",
+ INFORMATION = "Information",
+ ECOMMERCE = "Ecommerce",
+}
+
+export const DISCOVERY_CATEGORY_MAP = {
+ [APP_CATEGORY.ALL]: "✅ All",
+ [APP_CATEGORY.NEW]: "✨ New",
+ [APP_CATEGORY.EARN]: "💰 Earn",
+ [APP_CATEGORY.GAME]: "🎮 Game",
+ [APP_CATEGORY.FINANCE]: "💵 Finance",
+ [APP_CATEGORY.SOCIAL]: "💬 Social",
+ [APP_CATEGORY.UTILITY]: "🔩 Utility",
+ [APP_CATEGORY.INFORMATION]: "💰 Information",
+ [APP_CATEGORY.ECOMMERCE]: "🛒 E-commerce",
+};
+
+export const DISCOVER_CATEGORY: DiscoverType[] = [
+ {
+ value: APP_CATEGORY.NEW,
+ label: "✨ New",
+ },
+ {
+ value: APP_CATEGORY.EARN,
+ label: "💰 Earn",
+ },
+ {
+ value: APP_CATEGORY.GAME,
+ label: "🎮 Game",
+ },
+ {
+ value: APP_CATEGORY.FINANCE,
+ label: "💵 Finance",
+ },
+ {
+ value: APP_CATEGORY.SOCIAL,
+ label: "💬 Social",
+ },
+ {
+ value: APP_CATEGORY.UTILITY,
+ label: "🔩 Utility",
+ },
+ {
+ value: APP_CATEGORY.INFORMATION,
+ label: "💰 Information",
+ },
+ {
+ value: APP_CATEGORY.ECOMMERCE,
+ label: "🛒 E-commerce",
+ },
+];
+
+export enum RANDOM_APP_CATEGORY {
+ FORYOU = "ForYou",
+ RECOMMEND = "Recommend",
+}
+
+export enum APP_TYPE {
+ AD = "AD",
+ TELEGRAM = "Telegram",
+}
+
+export const DAILY_REWARDS = [100, 100, 250, 100, 100, 100, 250];
diff --git a/src/constants/navigation.ts b/src/constants/navigation.ts
new file mode 100644
index 0000000..cf0207f
--- /dev/null
+++ b/src/constants/navigation.ts
@@ -0,0 +1,6 @@
+export enum TAB_LIST {
+ HOME,
+ FOR_YOU,
+ VOTE,
+ PEN,
+}
diff --git a/src/constants/task.ts b/src/constants/task.ts
new file mode 100644
index 0000000..a7e9a2b
--- /dev/null
+++ b/src/constants/task.ts
@@ -0,0 +1,29 @@
+export enum USER_TASK_DETAIL {
+ DAILY_VIEW_ADS = "DailyViewAds",
+ DAILY_VOTE = "DailyVote",
+ DAILY_FIRST_INVITE = "DailyFirstInvite",
+ EXPLORE_JOIN_VOTIGRAM = "ExploreJoinVotigram",
+ EXPLORE_FOLLOW_VOTIGRAM_X = "ExploreFollowVotigramX",
+ EXPLORE_FORWARD_VOTIGRAM_X = "ExploreForwardVotigramX",
+ EXPLORE_SCHRODINGER = "ExploreSchrodinger",
+ EXPLORE_JOIN_TG_CHANNEL = "ExploreJoinTgChannel",
+ EXPLORE_FOLLOW_X = "ExploreFollowX",
+ EXPLORE_FORWARD_X = "ExploreForwardX",
+ EXPLORE_CUMULATE_FIVE_INVITE = "ExploreCumulateFiveInvite",
+ EXPLORE_CUMULATE_TEN_INVITE = "ExploreCumulateTenInvite",
+ EXPLORE_CUMULATE_TWENTY_INVITE = "ExploreCumulateTwentyInvite",
+}
+
+export enum USER_TASK_TITLE{
+ DAILY = "Daily",
+ EXPLORE_VOTIGRAM = "ExploreVotigram",
+ EXPLORE_APPS = "ExploreApps",
+ REFERRALS = "Referrals",
+}
+
+export const USER_TASK_TITLE_MAP = {
+ [USER_TASK_TITLE.DAILY]: 'Daily Tasks',
+ [USER_TASK_TITLE.EXPLORE_VOTIGRAM]: 'Explore Votigram',
+ [USER_TASK_TITLE.EXPLORE_APPS]: 'Explore Apps',
+ [USER_TASK_TITLE.REFERRALS]: 'Referrals',
+};
diff --git a/src/constants/time-picker.ts b/src/constants/time-picker.ts
new file mode 100644
index 0000000..2196bb4
--- /dev/null
+++ b/src/constants/time-picker.ts
@@ -0,0 +1,43 @@
+import { VoteTimeItem } from "@/types/app";
+
+export const HOUR_RANGE = Array.from({ length: 12 }, (_, i) => `${i + 1}`);
+
+export const MINUTE_RANGE = Array.from(
+ { length: 60 },
+ (_, i) => `${i < 10 ? "0" : ""}${i}`
+);
+
+export const PERIOD_RANGE = ["AM", "PM"];
+
+export const DURATION_RANGE: VoteTimeItem[] = [
+ {
+ label: "1 Hour",
+ unit: "hour",
+ value: 1,
+ },
+ {
+ label: "1 Day",
+ unit: "hour",
+ value: 24,
+ },
+ {
+ label: "3 Day",
+ unit: "day",
+ value: 3,
+ },
+ {
+ label: "5 Day",
+ unit: "day",
+ value: 5,
+ },
+ {
+ label: "1 Week",
+ unit: "day",
+ value: 7,
+ },
+ {
+ label: "2 Weeks",
+ unit: "day",
+ value: 14,
+ },
+];
diff --git a/src/constants/time.ts b/src/constants/time.ts
new file mode 100644
index 0000000..aab58c9
--- /dev/null
+++ b/src/constants/time.ts
@@ -0,0 +1 @@
+export const SECONDS_60 = 60000;
diff --git a/src/constants/vote.ts b/src/constants/vote.ts
new file mode 100644
index 0000000..fd05c4c
--- /dev/null
+++ b/src/constants/vote.ts
@@ -0,0 +1,50 @@
+export enum CREATE_STATUS {
+ PENDING = -1,
+ FAILED = 0,
+ SUCCESS = 1,
+}
+
+export enum COMMUNITY_LABEL {
+ ARCHIVED = "Archived",
+ CURRENT = "Current",
+}
+
+export enum COMMUNITY_TYPE {
+ ACCUMULATIVE = "Accumulative",
+ CURRENT = "Current",
+}
+
+export enum TMSAP_TAB {
+ ACCUMULATIVE = 0,
+ CURRENT = 1,
+}
+
+export enum VOTE_TABS {
+ TMAS = "TMAs",
+ COMMUNITY = "Community",
+}
+
+export enum PROFILE_TABS {
+ TASK = "Task",
+ ACHIEVEMENTS = "Achievements",
+}
+
+export enum RANKING_TYPE {
+ All = 0,
+ Verified = 1,
+ Community = 2,
+ Top = 3,
+}
+
+export enum LABEL_TYPE {
+ None = 0,
+ Gold = 1,
+ Blue = 2,
+}
+
+export enum VOTE_STATUS {
+ NOTVOTE = 0,
+ VOTING = 1,
+ VOTED = 2,
+ FAILED = 9,
+}
diff --git a/src/contract/proposalCreateContract.ts b/src/contract/proposalCreateContract.ts
new file mode 100644
index 0000000..4fdf760
--- /dev/null
+++ b/src/contract/proposalCreateContract.ts
@@ -0,0 +1,56 @@
+
+import { propalAddress } from '@/config';
+import { chainId } from '@/constants/app';
+import { ContractMethodType, IContractError, IContractOptions, IContractResult } from '@/types/contract';
+import { getTxResult } from '@/utils/getTxResult';
+import { sleep } from '@/utils/time';
+
+import { formatErrorMsg } from './util';
+import { webLoginInstance } from './webLogin';
+
+
+export const proposalCreateContractRequest = async (
+ methodName: string,
+ params: T,
+ options?: IContractOptions,
+): Promise => {
+ const contractAddress = propalAddress;
+
+ try {
+ if (options?.type === ContractMethodType.VIEW) {
+ const res: { data: IContractResult } = await webLoginInstance.callViewMethod(chainId, {
+ contractAddress,
+ methodName,
+ args: params,
+ });
+ const result = res.data as unknown as IContractError;
+ if (result?.error || result?.code || result?.Error) {
+ throw formatErrorMsg(result);
+ }
+
+ return res.data;
+ } else {
+ const res = await webLoginInstance.callSendMethod(chainId, {
+ contractAddress,
+ methodName,
+ args: params,
+ });
+ const result = res as unknown as IContractError;
+ if (result?.error || result?.code || result?.Error) {
+ throw formatErrorMsg(result);
+ }
+
+ const { transactionId, TransactionId } = result.result || result;
+ const resTransactionId = TransactionId || transactionId;
+ await sleep(1000);
+ const transaction = await getTxResult(resTransactionId!, chainId as Chain);
+
+ return transaction;
+ }
+ } catch (error) {
+ console.error('=====tokenAdapterContractRequest error:', methodName, JSON.stringify(error));
+ const resError = error as IContractError;
+ const a = formatErrorMsg(resError);
+ throw a;
+ }
+};
diff --git a/src/contract/util.ts b/src/contract/util.ts
new file mode 100644
index 0000000..c46bc8b
--- /dev/null
+++ b/src/contract/util.ts
@@ -0,0 +1,107 @@
+import {
+ DEFAULT_ERROR,
+ SOURCE_ERROR_TYPE,
+ TARGET_ERROR_TYPE,
+} from "@/constants/contract";
+import { IContractError } from "@/types/contract";
+
+export const matchErrorMsg = (message: T) => {
+ if (typeof message === "string") {
+ const sourceErrors = [
+ SOURCE_ERROR_TYPE.ERROR_1,
+ SOURCE_ERROR_TYPE.ERROR_2,
+ SOURCE_ERROR_TYPE.ERROR_3,
+ SOURCE_ERROR_TYPE.ERROR_4,
+ SOURCE_ERROR_TYPE.ERROR_5,
+ SOURCE_ERROR_TYPE.ERROR_6,
+ SOURCE_ERROR_TYPE.ERROR_7,
+ ];
+ const targetErrors = [
+ TARGET_ERROR_TYPE.ERROR_1,
+ TARGET_ERROR_TYPE.ERROR_2,
+ TARGET_ERROR_TYPE.ERROR_3,
+ TARGET_ERROR_TYPE.ERROR_4,
+ TARGET_ERROR_TYPE.ERROR_5,
+ TARGET_ERROR_TYPE.ERROR_6,
+ TARGET_ERROR_TYPE.ERROR_7,
+ ];
+
+ let resMessage: string = message;
+
+ for (let index = 0; index < sourceErrors.length; index++) {
+ if (message.includes(sourceErrors[index])) {
+ resMessage = message.replace(sourceErrors[index], targetErrors[index]);
+ }
+ }
+
+ return resMessage.replace("AElf.Sdk.CSharp.AssertionException: ", "");
+ } else {
+ return DEFAULT_ERROR;
+ }
+};
+
+const stringifyMsg = (message: unknown) => {
+ if (typeof message === "object") {
+ return JSON.stringify(message);
+ }
+ return message?.toString();
+};
+export const formatErrorMsg = (result: IContractError) => {
+ let resError: IContractError = result;
+
+ if (result.message) {
+ resError = {
+ ...result,
+ error: result.code,
+ errorMessage: {
+ message: stringifyMsg(result.message) || "",
+ },
+ };
+ } else if (result.Error) {
+ resError = {
+ ...result,
+ error: "401",
+ errorMessage: {
+ message:
+ stringifyMsg(result.Error)?.replace(
+ "AElf.Sdk.CSharp.AssertionException: ",
+ ""
+ ) || "",
+ },
+ };
+ } else if (
+ typeof result.error !== "number" &&
+ typeof result.error !== "string"
+ ) {
+ if (result.error?.message) {
+ resError = {
+ ...result,
+ error: "401",
+ errorMessage: {
+ message:
+ stringifyMsg(result.error.message)?.replace(
+ "AElf.Sdk.CSharp.AssertionException: ",
+ ""
+ ) || "",
+ },
+ };
+ }
+ } else if (typeof result.error === "string") {
+ resError = {
+ ...result,
+ error: "401",
+ errorMessage: {
+ message: result?.errorMessage?.message || result.error,
+ },
+ };
+ }
+
+ const errorMessage = resError.errorMessage?.message;
+
+ return {
+ ...resError,
+ errorMessage: {
+ message: matchErrorMsg(errorMessage),
+ },
+ };
+};
diff --git a/src/contract/webLogin.ts b/src/contract/webLogin.ts
new file mode 100644
index 0000000..2875d47
--- /dev/null
+++ b/src/contract/webLogin.ts
@@ -0,0 +1,78 @@
+import { ICallContractParams } from "@aelf-web-login/wallet-adapter-base";
+
+import { SupportedELFChainId } from "@/constants/app";
+
+export interface IWebLoginContext {
+ callSendMethod(params: ICallContractParams): Promise;
+ callViewMethod(params: ICallContractParams): Promise;
+}
+export interface IWebLoginArgs {
+ address: string;
+ chainId: string;
+}
+
+export default class WebLoginInstance {
+ public contract: unknown;
+ public address: string | undefined;
+ public chainId: string | undefined;
+
+ private static instance: WebLoginInstance | null = null;
+ private context: IWebLoginContext | null = null;
+
+ constructor(options?: IWebLoginArgs) {
+ this.address = options?.address;
+ this.chainId = options?.chainId;
+ }
+ static get() {
+ if (!WebLoginInstance.instance) {
+ WebLoginInstance.instance = new WebLoginInstance();
+ }
+ return WebLoginInstance.instance;
+ }
+
+ setWebLoginContext(context: IWebLoginContext) {
+ this.context = context;
+ }
+
+ getWebLoginContext() {
+ return this.context; // wallet, login, loginState
+ }
+
+ callSendMethod(
+ chain: Chain,
+ params: ICallContractParams
+ ): Promise {
+ if (!this.context) {
+ throw new Error("Error: WebLoginContext is not set");
+ }
+ switch (chain) {
+ case SupportedELFChainId.MAIN_NET:
+ return this.context.callSendMethod(params);
+ case SupportedELFChainId.TDVV_NET:
+ return this.context.callSendMethod(params);
+ case SupportedELFChainId.TDVW_NET:
+ return this.context.callSendMethod(params);
+ }
+ throw new Error("Error: Invalid chainId");
+ }
+
+ callViewMethod(
+ chain: Chain,
+ params: ICallContractParams
+ ): Promise {
+ if (!this.context) {
+ throw new Error("Error: WebLoginContext is not set");
+ }
+ switch (chain) {
+ case SupportedELFChainId.MAIN_NET:
+ return this.context.callViewMethod(params);
+ case SupportedELFChainId.TDVV_NET:
+ return this.context.callViewMethod(params);
+ case SupportedELFChainId.TDVW_NET:
+ return this.context.callViewMethod(params);
+ }
+ throw new Error("Error: Invalid chainId");
+ }
+}
+
+export const webLoginInstance = WebLoginInstance.get();
diff --git a/src/hooks/useAdsgram.ts b/src/hooks/useAdsgram.ts
new file mode 100644
index 0000000..32aed44
--- /dev/null
+++ b/src/hooks/useAdsgram.ts
@@ -0,0 +1,86 @@
+
+/**
+ * Check Typescript section
+ * and use your path to adsgram types
+ */
+import { useCallback, useEffect, useRef } from "react";
+
+import sha256 from "crypto-js/sha256";
+import dayjs from "dayjs";
+
+import { chainId } from "@/constants/app";
+import type { AdController, ShowPromiseResult } from "@/types/adsgram";
+
+import { postWithToken } from "./useData";
+
+
+
+interface useAdsgramParams {
+ blockId: string;
+ onReward: (newPoints: number) => void;
+ onError: (result: ShowPromiseResult) => void;
+ onSkip: () => void;
+ onFinish?: (timeStamp?: number, signature?: string) => void;
+}
+
+export function useAdsgram({
+ blockId,
+ onReward,
+ onError,
+ onSkip,
+ onFinish,
+}: useAdsgramParams): () => Promise {
+ const AdControllerRef = useRef(undefined);
+
+ useEffect(() => {
+ AdControllerRef.current = window.Adsgram?.init({
+ blockId,
+ });
+
+ AdControllerRef.current?.addEventListener("onSkip", () => {
+ onSkip?.();
+ });
+
+ return () => {
+ AdControllerRef.current?.removeEventListener("onSkip", () => {
+ onSkip?.();
+ });
+ };
+ }, [blockId]);
+
+ return useCallback(async () => {
+ if (AdControllerRef.current) {
+ AdControllerRef.current
+ .show()
+ .then(async (result) => {
+ if (result?.done) {
+ const timestamp = dayjs().valueOf();
+ const hash = sha256(`${import.meta.env.VITE_HASH}-${timestamp}`);
+
+ if (onFinish) {
+ onFinish(timestamp, hash.toString());
+ } else {
+ const result = await postWithToken("/api/app/user/view-ad", {
+ chainId,
+ timeStamp: timestamp,
+ signature: hash.toString(),
+ });
+
+ onReward(result.data);
+ }
+ }
+ })
+ .catch((result: ShowPromiseResult) => {
+ // user get error during playing ad or skip ad
+ onError?.(result);
+ });
+ } else {
+ onError?.({
+ error: true,
+ done: false,
+ state: "load",
+ description: "Adsgram script not loaded",
+ });
+ }
+ }, [onError, onReward]);
+}
diff --git a/src/hooks/useData.ts b/src/hooks/useData.ts
new file mode 100644
index 0000000..410bb1a
--- /dev/null
+++ b/src/hooks/useData.ts
@@ -0,0 +1,116 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import useSWR, { Arguments, mutate } from "swr";
+import useSWRInfinite, { SWRInfiniteKeyLoader } from "swr/infinite";
+
+import { toUrlEncoded } from "@/utils/token";
+
+// Fetcher function that includes the Authorization token
+export const fetchWithToken = (endpoint: string, fullUrl?: boolean) => {
+ const token = localStorage.getItem("access_token");
+
+ return fetch(`${!fullUrl ? import.meta.env.VITE_BASE_URL : ''}${endpoint}`, {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ }).then(async (res) => {
+ if (!res.ok) throw new Error("Error fetching data");
+ const result = await res.json();
+ return result.data;
+ });
+};
+
+// POST function with cache update using mutate
+export const postWithToken = async (
+ endpoint: string,
+ data: Record,
+ contentType: string = "application/json"
+): Promise => {
+ const token = localStorage.getItem("access_token");
+
+ // Set up headers, conditionally adding Authorization
+ const headers: HeadersInit = {
+ "Content-Type": contentType,
+ ...(token && { Authorization: `Bearer ${token}` }),
+ };
+
+ // Convert data based on content type
+ const body =
+ contentType === "application/x-www-form-urlencoded"
+ ? toUrlEncoded(data)
+ : JSON.stringify(data);
+
+ const response = await fetch(`${import.meta.env.VITE_BASE_URL}${endpoint}`, {
+ method: "POST",
+ headers,
+ body,
+ });
+
+ if (!response.ok) throw new Error("Failed to post data");
+
+ const result = await response.json();
+
+ // Optimistically update the cache for the endpoint
+ mutate(
+ endpoint,
+ async (currentData: any) => {
+ return [...(currentData || []), result];
+ },
+ false
+ );
+
+ // Revalidate cache to ensure consistency with the server
+ mutate(endpoint);
+
+ return result;
+};
+
+// POST function with cache update using mutate
+export const uploadWithToken = async (
+ endpoint: string,
+ body: FormData,
+): Promise => {
+ const token = localStorage.getItem("access_token");
+
+ // Set up headers, conditionally adding Authorization
+ const headers: HeadersInit = {
+ ...(token && { Authorization: `Bearer ${token}` }),
+ };
+
+ const response = await fetch(`${import.meta.env.VITE_BASE_URL}${endpoint}`, {
+ method: "POST",
+ headers,
+ body,
+ });
+
+ if (!response.ok) throw new Error("Failed to post data");
+
+ const result = await response.json();
+
+ // Optimistically update the cache for the endpoint
+ mutate(
+ endpoint,
+ async (currentData: any) => {
+ return [...(currentData || []), result];
+ },
+ false
+ );
+
+ // Revalidate cache to ensure consistency with the server
+ mutate(endpoint);
+
+ return result;
+};
+
+export const useData = (endpoint: string | null) => useSWR(endpoint, fetchWithToken);
+
+export const useInfinite = (
+ getKey: SWRInfiniteKeyLoader,
+ initialSize: number
+) =>
+ useSWRInfinite(
+ getKey,
+ fetchWithToken,
+ { initialSize } // Start with no pages loaded
+ );
+
+export default useData;
diff --git a/src/hooks/useForm.ts b/src/hooks/useForm.ts
new file mode 100644
index 0000000..d54c9bb
--- /dev/null
+++ b/src/hooks/useForm.ts
@@ -0,0 +1,68 @@
+import { useState } from 'react';
+
+type ValidationRule = (value: V) => string | undefined;
+
+type FormRules = {
+ [K in keyof T]?: ValidationRule[];
+};
+
+type FormState = {
+ [K in keyof T]: T[K];
+};
+
+type FormErrors = {
+ [K in keyof T]?: string;
+};
+
+function useForm>(initialValues: T, rules: FormRules) {
+ const [formState, setFormState] = useState>(initialValues);
+ const [errors, setErrors] = useState>({});
+
+ const handleChange = (fieldName: keyof T) => (newValue: T[keyof T]) => {
+ setFormState((prevState) => ({
+ ...prevState,
+ [fieldName]: newValue,
+ }));
+
+ if (rules[fieldName]) {
+ const fieldErrors = rules[fieldName]!.map((rule) => rule(newValue)).filter((error) => error);
+ setErrors((prevErrors) => ({
+ ...prevErrors,
+ [fieldName]: fieldErrors.length > 0 ? fieldErrors[0] : undefined,
+ }));
+ }
+ };
+
+ const validateForm = (): boolean => {
+ const newErrors: FormErrors = {};
+
+ Object.keys(formState).forEach((key) => {
+ const fieldName = key as keyof T;
+ if (rules[fieldName]) {
+ const fieldErrors = rules[fieldName]!.map((rule) => rule(formState[fieldName])).filter((error) => error);
+ if (fieldErrors.length > 0) {
+ newErrors[fieldName] = fieldErrors[0];
+ }
+ }
+ });
+
+ setErrors(newErrors);
+ return Object.keys(newErrors).length === 0;
+ };
+
+ const handleSubmit = (callback: () => void) => (e: React.FormEvent) => {
+ e.preventDefault();
+ if (validateForm()) {
+ callback();
+ }
+ };
+
+ return {
+ formState,
+ errors,
+ handleChange,
+ handleSubmit,
+ };
+}
+
+export default useForm;
\ No newline at end of file
diff --git a/src/hooks/useSetSearchParams.ts b/src/hooks/useSetSearchParams.ts
new file mode 100644
index 0000000..192fbff
--- /dev/null
+++ b/src/hooks/useSetSearchParams.ts
@@ -0,0 +1,35 @@
+import { useSearchParams } from "react-router-dom";
+
+function useSetSearchParams() {
+ const [searchParams, setSearchParams] = useSearchParams();
+ const originalParams = new URLSearchParams(searchParams);
+
+ interface ISearchParams {
+ key: string; value: string;
+ }
+
+ const updateQueryParam = (params: ISearchParams | ISearchParams[], isReset = false) => {
+ let newParams = originalParams;
+ if (isReset) {
+ newParams = new URLSearchParams();
+ }
+
+ if (Array.isArray(params)) {
+ for (const { key, value } of params) {
+ newParams.set(key, value);
+ }
+ } else {
+ const { key, value } = params;
+ newParams.set(key, value);
+ }
+
+ setSearchParams(newParams);
+ };
+
+ return {
+ querys: new URLSearchParams(searchParams),
+ updateQueryParam,
+ };
+}
+
+export default useSetSearchParams;
\ No newline at end of file
diff --git a/src/index.css b/src/index.css
new file mode 100644
index 0000000..c0493e9
--- /dev/null
+++ b/src/index.css
@@ -0,0 +1,39 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+:root {
+ font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
+ line-height: 1.5;
+ font-weight: 400;
+
+ color-scheme: light dark;
+ color: rgba(255, 255, 255, 0.87) !important;
+ background-color: black;
+
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+
+/**
+ background-color should not be overwritten by portkey
+**/
+html,
+body {
+ margin: 0;
+ padding: 0;
+ display: flex;
+ place-items: center;
+ min-width: 320px;
+ overflow: hidden;
+ height: 100vh;
+ width: 100vw;
+ background-color: unset !important;
+}
+
+*::-webkit-scrollbar {
+ display: none;
+}
\ No newline at end of file
diff --git a/src/init.ts b/src/init.ts
new file mode 100644
index 0000000..5c0b9ee
--- /dev/null
+++ b/src/init.ts
@@ -0,0 +1,48 @@
+import {
+ backButton,
+ viewport,
+ themeParams,
+ miniApp,
+ initData,
+ $debug,
+ init as initSDK,
+} from "@telegram-apps/sdk-react";
+
+/**
+ * Initializes the application and configures its dependencies.
+ */
+export function init(debug: boolean): void {
+ // Set @telegram-apps/sdk-react debug mode.
+ $debug.set(debug);
+
+ // Initialize special event handlers for Telegram Desktop, Android, iOS, etc.
+ // Also, configure the package.
+ initSDK();
+
+ // Add Eruda if needed.
+ if (debug) {
+ import("eruda").then((lib) => lib.default.init()).catch(console.error);
+ }
+ // Check if all required components are supported.
+ if (!backButton.isSupported() || !miniApp.isSupported()) {
+ throw new Error("ERR_NOT_SUPPORTED");
+ }
+
+ // Mount all components used in the project.
+ backButton.mount();
+ miniApp.mount();
+ themeParams.mount();
+ initData.restore();
+ void viewport
+ .mount()
+ .catch((e) => {
+ console.error("Something went wrong mounting the viewport", e);
+ })
+ .then(() => {
+ viewport.bindCssVars();
+ });
+
+ // Define components-related CSS variables.
+ miniApp.bindCssVars();
+ themeParams.bindCssVars();
+}
diff --git a/src/main.tsx b/src/main.tsx
new file mode 100644
index 0000000..0511070
--- /dev/null
+++ b/src/main.tsx
@@ -0,0 +1,38 @@
+import { StrictMode } from "react";
+
+import { retrieveLaunchParams } from "@telegram-apps/sdk-react";
+import { Buffer } from "buffer";
+import { createRoot } from "react-dom/client";
+
+import "./styles/theme.css";
+import "./index.css";
+import "./assets/fonts/votigram-icon.css";
+
+import App from "./App";
+import { EnvUnsupported } from "./components/EnvUnsupported";
+import { init } from "./init";
+
+const root = createRoot(document.getElementById("root")!);
+
+window.Buffer = Buffer;
+
+// Mock the environment in case, we are outside Telegram.
+import "./mockEnv.ts";
+
+try {
+ // Configure all application dependencies.
+ init(retrieveLaunchParams().startParam === "debug" || import.meta.env.DEV);
+
+ if (process.env.NODE_ENV === "development") {
+ root.render(
+
+
+
+ );
+ } else {
+ root.render( );
+ }
+} catch (e) {
+ root.render( );
+ console.error(e);
+}
diff --git a/src/mockEnv.ts b/src/mockEnv.ts
new file mode 100644
index 0000000..9f40039
--- /dev/null
+++ b/src/mockEnv.ts
@@ -0,0 +1,79 @@
+import {
+ mockTelegramEnv,
+ isTMA,
+ parseInitData,
+ LaunchParams,
+ retrieveLaunchParams,
+} from "@telegram-apps/sdk-react";
+
+// It is important, to mock the environment only for development purposes.
+// When building the application the import.meta.env.DEV will value become
+// `false` and the code inside will be tree-shaken (removed), so you will not
+// see it in your final bundle.
+if (import.meta.env.DEV) {
+ await (async () => {
+ if (await isTMA()) {
+ return;
+ }
+
+ // Determine which launch params should be applied. We could already
+ // apply them previously, or they may be specified on purpose using the
+ // default launch parameters transmission method.
+ let lp: LaunchParams | undefined;
+ try {
+ lp = retrieveLaunchParams();
+ } catch (e) {
+ console.error(e);
+ const initDataRaw = new URLSearchParams([
+ [
+ "user",
+ JSON.stringify({
+ id: 99281932,
+ first_name: "Andrew",
+ last_name: "Rogue",
+ username: "rogue",
+ language_code: "en",
+ is_premium: true,
+ allows_write_to_pm: true,
+ }),
+ ],
+ [
+ "hash",
+ "89d6079ad6762351f38c6dbbc41bb53048019256a9443988af7a48bcad16ba31",
+ ],
+ ["auth_date", "1716922846"],
+ ["start_param", "debug"],
+ ["chat_type", "sender"],
+ ["chat_instance", "8428209589180549439"],
+ ["signature", "6fbdaab833d39f54518bd5c3eb3f511d035e68cb"],
+ ]).toString();
+
+ lp = {
+ themeParams: {
+ accentTextColor: "#6ab2f2",
+ bgColor: "#17212b",
+ buttonColor: "#5288c1",
+ buttonTextColor: "#ffffff",
+ destructiveTextColor: "#ec3942",
+ headerBgColor: "#17212b",
+ hintColor: "#708499",
+ linkColor: "#6ab3f3",
+ secondaryBgColor: "#232e3c",
+ sectionBgColor: "#17212b",
+ sectionHeaderTextColor: "#6ab3f3",
+ subtitleTextColor: "#708499",
+ textColor: "#f5f5f5",
+ },
+ initData: parseInitData(initDataRaw),
+ initDataRaw,
+ version: "8",
+ platform: "tdesktop",
+ };
+ }
+
+ mockTelegramEnv(lp);
+ console.warn(
+ "⚠️ As long as the current environment was not considered as the Telegram-based one, it was mocked. Take a note, that you should not do it in production and current behavior is only specific to the development process. Environment mocking is also applied only in development mode. So, after building the application, you will not see this behavior and related warning, leading to crashing the application outside Telegram."
+ );
+ })();
+}
diff --git a/src/pageComponents/CreatePoll/index.tsx b/src/pageComponents/CreatePoll/index.tsx
new file mode 100644
index 0000000..2bac8be
--- /dev/null
+++ b/src/pageComponents/CreatePoll/index.tsx
@@ -0,0 +1,408 @@
+import { useState } from "react";
+
+import clsx from "clsx";
+import dayjs from "dayjs";
+import { useLocation, useNavigate } from "react-router-dom";
+
+import BackBtn from "@/components/BackBtn";
+import ButtonRadio from "@/components/ButtonRadio";
+import Drawer from "@/components/Drawer";
+import FormItem from "@/components/FormItem";
+import Input from "@/components/Input";
+import InputGroup from "@/components/InputGroup";
+import { VoteOption } from "@/components/InputGroup/type";
+import SimpleDatePicker from "@/components/SimpleDatePicker";
+import SimpleTimePicker from "@/components/SimpleTimePicker";
+import TelegramHeader from "@/components/TelegramHeader";
+import ToggleSlider from "@/components/ToggleSlider";
+import Upload from "@/components/Upload";
+import { chainId, ProposalType } from "@/constants/app";
+import { DURATION_RANGE } from "@/constants/time-picker";
+import { CREATE_STATUS } from "@/constants/vote";
+import { proposalCreateContractRequest } from "@/contract/proposalCreateContract";
+import { fetchWithToken, postWithToken } from "@/hooks/useData";
+import useForm from "@/hooks/useForm";
+import { useUserContext } from "@/provider/UserProvider";
+import { VoteTimeItem } from "@/types/app";
+
+import {
+ combineDateAndTime,
+ formmatDescription,
+ getProposalTimeParams,
+} from "./utils";
+
+const rules = {
+ proposalTitle: [
+ (value: string) => (!value ? "Please Enter Topic" : undefined),
+ ],
+ options: [
+ (ops: VoteOption[]) =>
+ ops.length === 0 ? "Please add an option" : undefined,
+
+ (ops: VoteOption[]) =>
+ ops.length < 2 ? "Please add at least two options" : undefined,
+ (ops: VoteOption[]) =>
+ ops.filter((op) => !op.title).length > 0
+ ? "Kindly ensure it's not left empty"
+ : undefined,
+ ],
+};
+
+type FormStateProps = {
+ proposalTitle: string;
+ options: VoteOption[];
+ banner?: string;
+ activeStartTime: number;
+ activeEndTime: number | VoteTimeItem;
+};
+
+const defaultEndTime: VoteTimeItem = {
+ label: "1 Hour",
+ unit: "hour",
+ value: 1,
+};
+
+const CreatePoll = () => {
+ const [loading, setLoading] = useState(false);
+ const [finished, setFinished] = useState(
+ CREATE_STATUS.PENDING
+ );
+ const navigate = useNavigate();
+ const location = useLocation();
+
+ const { cmsData } = useUserContext();
+ const { communityDaoId } = cmsData || {};
+
+ const initialFormState: FormStateProps = {
+ proposalTitle: "",
+ options: [{ id: Date.now(), title: "" }],
+ activeStartTime: 1,
+ activeEndTime: defaultEndTime,
+ };
+ const { formState, errors, handleChange, handleSubmit } = useForm(
+ initialFormState,
+ rules
+ );
+
+ const onSubmit = async () => {
+ try {
+ const saveReqApps: VoteOption[] = formState.options.map((item) => ({
+ ...item,
+ sourceType: 1,
+ }));
+ if (formState.banner) {
+ saveReqApps.push({
+ title: "TomorrowDaoBanner",
+ icon: formState.banner,
+ sourceType: 1,
+ });
+ }
+ setLoading(true);
+ const [saveRes, voteSchemeListRes, governanceMechanismListRes] =
+ await Promise.all([
+ postWithToken("/api/app/telegram/save", {
+ chainId,
+ apps: saveReqApps,
+ }),
+ fetchWithToken(
+ `/api/app/vote/vote-scheme-list?${new URLSearchParams({
+ chainId,
+ daoId: communityDaoId ?? "",
+ }).toString()}`
+ ),
+ fetchWithToken(
+ `/api/app/governance/list?${new URLSearchParams({
+ chainId,
+ daoId: communityDaoId ?? "",
+ }).toString()}`
+ ),
+ ]);
+ const appAlias = saveRes?.data ?? [];
+ if (!appAlias.length) {
+ throw new Error("Failed to create proposal, save options failed");
+ }
+ const formatDescriptionStr = formmatDescription(
+ appAlias,
+ formState.banner
+ );
+ if (formatDescriptionStr.length > 256) {
+ throw new Error(
+ "Too many options have been added, or the option names are too long. Please simplify the options and try again."
+ );
+ }
+ const voteSchemeId = voteSchemeListRes?.voteSchemeList?.[0]?.voteSchemeId;
+ const schemeAddress =
+ governanceMechanismListRes?.data?.[0]?.schemeAddress;
+ if (!voteSchemeId) {
+ throw new Error("The voting scheme for this DAO cannot be found");
+ }
+ if (!schemeAddress) {
+ throw new Error(
+ "The voting scheme address for this DAO cannot be found"
+ );
+ }
+ const methodName = "CreateProposal";
+ const timeParams = getProposalTimeParams(
+ formState.activeStartTime,
+ formState.activeEndTime
+ );
+ const proposalBasicInfo = {
+ proposalTitle: formState.proposalTitle,
+ ...timeParams,
+ proposalDescription: formatDescriptionStr,
+ daoId: communityDaoId,
+ voteSchemeId,
+ schemeAddress,
+ };
+ const contractParams = {
+ proposalType: ProposalType.ADVISORY,
+ proposalBasicInfo: proposalBasicInfo,
+ };
+ await proposalCreateContractRequest(methodName, contractParams);
+ setLoading(false);
+ setFinished(CREATE_STATUS.SUCCESS);
+ } catch (err) {
+ console.error(err);
+ setLoading(false);
+ setFinished(CREATE_STATUS.FAILED);
+ }
+ };
+
+ const handleGoBack = () => {
+ if (location.state?.from) {
+ navigate(location.state?.from, { replace: true });
+ } else {
+ navigate(-1);
+ }
+ };
+
+ const handleFinish = () => {
+ if (finished === CREATE_STATUS.FAILED) {
+ setFinished(CREATE_STATUS.PENDING);
+ onSubmit();
+ } else {
+ handleGoBack();
+ }
+ };
+
+ return (
+ <>
+
+
+
+
+ Creating Poll
+
+
+ {`Your poll is currently being \nregistered on the blockchain.`}
+
+
+ setFinished(CREATE_STATUS.PENDING)}
+ canClose={finished === CREATE_STATUS.FAILED}
+ >
+
+ {finished === CREATE_STATUS.SUCCESS ? "Success" : "Please Try Again"}
+
+
+
+ {finished === CREATE_STATUS.SUCCESS
+ ? `Your poll has been successfully \nregistered on the blockchain.`
+ : `We encountered an error registering \nyour poll on the blockchain. `}
+
+
+ {finished === CREATE_STATUS.SUCCESS ? "Continue" : "Try Again"}
+
+
+ >
+ );
+};
+
+export default CreatePoll;
diff --git a/src/pageComponents/CreatePoll/utils/index.ts b/src/pageComponents/CreatePoll/utils/index.ts
new file mode 100644
index 0000000..772682c
--- /dev/null
+++ b/src/pageComponents/CreatePoll/utils/index.ts
@@ -0,0 +1,54 @@
+import dayjs from 'dayjs';
+
+import { VoteTimeItem } from '@/types/app';
+
+export const getProposalTimeParams = (
+ startTime: number,
+ endTime: VoteTimeItem | number,
+) => {
+ let timeParams = {};
+ const activeStartTime =
+ startTime === 1 ? Date.now() : startTime;
+ const activeEndTime =
+ typeof endTime === 'object'
+ ? dayjs(activeStartTime).add(endTime.value, endTime.unit).valueOf()
+ : endTime;
+ // if start time is now, convert to period
+ if (startTime === 1) {
+ timeParams = {
+ activeTimePeriod: Math.floor((activeEndTime - activeStartTime) / 1000),
+ activeStartTime: 0,
+ activeEndTime: 0,
+ };
+ } else {
+ timeParams = {
+ activeTimePeriod: 0,
+ activeStartTime: Math.floor(activeStartTime / 1000),
+ activeEndTime: Math.floor(activeEndTime / 1000),
+ };
+ }
+ return timeParams;
+};
+
+export const formmatDescription = (alias: string[], bannerUrl?: string) => {
+ const appAlias = bannerUrl ? alias.slice(0, -1) : alias;
+ const aliasStr = appAlias.map((item) => `{${item}}`).join(',');
+ const bannerAlias = bannerUrl ? alias[alias.length - 1] : null;
+ const bannerStr = bannerAlias ? `#B:{${bannerAlias}}` : '';
+ return `##GameRanking:${aliasStr}${bannerStr}`;
+};
+
+export function combineDateAndTime(dateA: number | string, dateB: number | string) {
+ if (!dayjs(dateA).isValid() || !dayjs(dateB).isValid()) {
+ return dateA;
+ }
+ const dateAPart = dayjs(dateA);
+ const dateBPart = dayjs(dateB);
+
+ const combinedDate = dateAPart
+ .hour(dateBPart.hour())
+ .minute(dateBPart.minute())
+ .second(dateBPart.second());
+
+ return combinedDate.valueOf();
+}
diff --git a/src/pageComponents/Home/index.tsx b/src/pageComponents/Home/index.tsx
new file mode 100644
index 0000000..636d68e
--- /dev/null
+++ b/src/pageComponents/Home/index.tsx
@@ -0,0 +1,147 @@
+import { useEffect, useRef, useState } from "react";
+
+import { useNavigate } from "react-router-dom";
+
+import ForYou from "@/components/ForYou";
+import Home from "@/components/Home";
+import Navigation from "@/components/Navigation";
+import Profile from "@/components/Profile";
+import Vote from "@/components/Vote";
+import { chainId } from "@/constants/app";
+import { RANDOM_APP_CATEGORY } from "@/constants/discover";
+import { TAB_LIST } from "@/constants/navigation";
+import useData, { postWithToken } from "@/hooks/useData";
+import useSetSearchParams from "@/hooks/useSetSearchParams";
+import { useUserContext } from "@/provider/UserProvider";
+import { VoteApp } from "@/types/app";
+import { hexToString, parseStartAppParams } from "@/utils/start-params";
+
+const App = () => {
+ const { redirected, updateRedirectedStatus } = useUserContext();
+ const currentForyouPage = useRef(1);
+ const [activeTab, setActiveTab] = useState(TAB_LIST.HOME);
+ const [forYouList, setForYouList] = useState([]);
+ const [recommendList, setRecommendList] = useState([]);
+ const [selectedItem, setSelectItem] = useState();
+ const [alias, setAlias] = useState("");
+
+ const navigate = useNavigate();
+
+ const { querys, updateQueryParam } = useSetSearchParams();
+ const tab = querys.get("tab");
+
+ const fetchForYouData = async (alias: string[] = []) => {
+ const { data } = await postWithToken("/api/app/discover/random-app-list", {
+ chainId,
+ alias,
+ category: RANDOM_APP_CATEGORY.FORYOU,
+ });
+
+ setForYouList(data?.appList || []);
+
+ if (alias.length > 0) {
+ currentForyouPage.current++;
+ }
+ };
+
+ const { data: madeForYouResult } = useData(
+ `/api/app/user/homepage/made-for-you?chainId=${chainId}`
+ );
+
+ const { data: votedAppResult } = useData(
+ `/api/app/user/homepage?chainId=${chainId}`
+ );
+
+ const { data: forYouAppp } = useData(
+ alias
+ ? `/api/app/telegram/apps?${new URLSearchParams({
+ chainId,
+ aliases: alias,
+ }).toString()}`
+ : null
+ );
+
+ const fetchRecommendData = async () => {
+ const { data } = await postWithToken("/api/app/discover/random-app-list", {
+ chainId,
+ category: RANDOM_APP_CATEGORY.RECOMMEND,
+ });
+
+ setRecommendList(data?.appList || []);
+ };
+
+ useEffect(() => {
+ fetchForYouData();
+ fetchRecommendData();
+ }, []);
+
+ const onAppItemClick = (item?: VoteApp) => {
+ if (item) {
+ setSelectItem(item);
+ }
+ setActiveTab(TAB_LIST.FOR_YOU);
+ };
+
+ useEffect(() => {
+ if (forYouAppp && forYouAppp?.items?.length) {
+ setSelectItem(forYouAppp?.items[0]);
+ setActiveTab(TAB_LIST.FOR_YOU);
+ }
+ }, [forYouAppp]);
+
+ useEffect(() => {
+ if (tab && !isNaN(Number(tab))) {
+ setActiveTab(Number(tab));
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ useEffect(() => {
+ if (window?.Telegram?.WebApp?.initDataUnsafe) {
+ const startParam =
+ window.Telegram.WebApp.initDataUnsafe.start_param ?? "";
+ const params = parseStartAppParams(startParam);
+ if (params && params.pid && !redirected) {
+ navigate(`/proposal/${params.pid}`);
+ updateRedirectedStatus(true);
+ }
+ if (params && params.alias && !redirected) {
+ setAlias(hexToString(params.alias) || "");
+ updateRedirectedStatus(true);
+ }
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ const handleTabChange = (tab: TAB_LIST) => {
+ updateQueryParam({ key: "tab", value: tab.toString() });
+ setActiveTab(tab);
+ };
+
+ return (
+ <>
+ {activeTab === TAB_LIST.HOME && (
+
+ )}
+ {activeTab === TAB_LIST.FOR_YOU && (
+
+ )}
+ {activeTab === TAB_LIST.VOTE && }
+ {activeTab === TAB_LIST.PEN && }
+
+ >
+ );
+};
+
+export default App;
diff --git a/src/pageComponents/PollDetail/index.tsx b/src/pageComponents/PollDetail/index.tsx
new file mode 100644
index 0000000..5364737
--- /dev/null
+++ b/src/pageComponents/PollDetail/index.tsx
@@ -0,0 +1,196 @@
+import { useEffect, useState } from "react";
+
+import { useConnectWallet } from "@aelf-web-login/wallet-adapter-react";
+import dayjs from "dayjs";
+import { useParams } from "react-router-dom";
+import { useCopyToClipboard } from "react-use";
+import { mutate } from "swr";
+
+import BackBtn from "@/components/BackBtn";
+import Countdown from "@/components/Countdown";
+import Drawer from "@/components/Drawer";
+import Loading from "@/components/Loading";
+import TelegramHeader from "@/components/TelegramHeader";
+import VoteItem from "@/components/VoteItem";
+import { TgLink } from "@/config";
+import { chainId } from "@/constants/app";
+import useData from "@/hooks/useData";
+import { IPollDetail } from "@/types/app";
+import { stringifyStartAppParams } from "@/utils/start-params";
+
+import { getShareText } from "./utils";
+
+const PollDetail = () => {
+ const { proposalId } = useParams();
+ const [seconds, setSeconds] = useState(0);
+ const [canVote, setCanVote] = useState(false);
+ const [showShare, setShowShare] = useState(false);
+ const [pollDeta, setPollDeta] = useState(null);
+ const [, copyToClipboard] = useCopyToClipboard();
+ const [isCopied, setIsCopied] = useState(false);
+ const { walletInfo, isConnected } = useConnectWallet();
+
+ const { data, isLoading } = useData(
+ proposalId && isConnected && walletInfo
+ ? `/api/app/ranking/detail?${new URLSearchParams({
+ chainId,
+ proposalId,
+ }).toString()}`
+ : null
+ );
+
+ useEffect(() => {
+ if (data) {
+ setPollDeta(data);
+ setCanVote(data.canVoteAmount > 0);
+ setSeconds(data?.endEpochTime / 1000 - dayjs().unix());
+ }
+ }, [data]);
+
+ useEffect(() => {
+ if (isCopied) {
+ setTimeout(() => {
+ setIsCopied(false);
+ }, 2000);
+ }
+ }, [isCopied]);
+
+ const generateShareUrl = () => {
+ const paramsStr = stringifyStartAppParams({
+ pid: proposalId,
+ });
+ return `${TgLink}?startapp=${paramsStr}`;
+ };
+
+ const shareToTelegram = () => {
+ if (window?.Telegram?.WebApp?.openTelegramLink) {
+ const url = encodeURIComponent(generateShareUrl());
+ const shareText = encodeURIComponent(
+ getShareText(
+ data.proposalTitle ?? "",
+ `Make your voice heard!📢\n
+Vote Now and Earn USDT airdrop on Votigram! 🚀
+ `
+ )
+ );
+ window?.Telegram?.WebApp?.openTelegramLink(
+ `https://t.me/share/url?url=${url}&text=${shareText}`
+ );
+ }
+ };
+
+ const onVoted = () => {
+ if (proposalId) {
+ mutate(
+ `/api/app/ranking/detail?${new URLSearchParams({
+ chainId,
+ proposalId,
+ }).toString()}`
+ );
+ }
+ };
+
+ if (!pollDeta && isLoading) {
+ return ;
+ }
+
+ return (
+ <>
+ } />
+
+
+
+
+
+
+ Total earned points:
+
+
+ {pollDeta?.userTotalPoints.toLocaleString() || 0}
+
+
+
+
+
+ {pollDeta?.proposalTitle}
+
+
+ {pollDeta?.bannerUrl && (
+
+ )}
+
+
+
+ Duration:{" "}
+ {`${dayjs(pollDeta?.startTime).format("DD MMM YYYY")} - ${dayjs(
+ pollDeta?.endTime
+ ).format("DD MMM YYYY")}`}
+
+
setShowShare(true)}>
+
+
+
+
+ {pollDeta?.rankingList?.map((vote, index) => (
+
+ ))}
+
+
+
+
+ setShowShare(false)}
+ canClose
+ >
+
+ Share
+
+
+
+
+
+
+
+ Share to Telegram
+
+
+
+
{
+ copyToClipboard(generateShareUrl());
+ setIsCopied(true);
+ }}
+ >
+
+
+
+ {isCopied ? "Copied" : "Copy Link"}
+
+
+
+
+ >
+ );
+};
+
+export default PollDetail;
diff --git a/src/pageComponents/PollDetail/utils/index.ts b/src/pageComponents/PollDetail/utils/index.ts
new file mode 100644
index 0000000..78a0506
--- /dev/null
+++ b/src/pageComponents/PollDetail/utils/index.ts
@@ -0,0 +1,9 @@
+export const getShareText = (title: string, desc: string) => {
+ function decodeHtmlEntity(str: string) {
+ const parser = new DOMParser();
+ const doc = parser.parseFromString(str, "text/html");
+ return doc.documentElement.textContent;
+ }
+ const decoded = decodeHtmlEntity(title);
+ return `${decoded}\n${desc}`;
+};
diff --git a/src/provider/UserProvider.tsx b/src/provider/UserProvider.tsx
new file mode 100644
index 0000000..2bf950c
--- /dev/null
+++ b/src/provider/UserProvider.tsx
@@ -0,0 +1,354 @@
+import React, {
+ createContext,
+ useContext,
+ useReducer,
+ useEffect,
+ ReactNode,
+ useState,
+} from "react";
+
+import { useConnectWallet } from "@aelf-web-login/wallet-adapter-react";
+import { useAsyncEffect, useRequest } from "ahooks";
+import { jwtDecode } from "jwt-decode";
+
+
+import { host, nftSymbol } from "@/config";
+import { chainId } from "@/constants/app";
+import { webLoginInstance } from "@/contract/webLogin";
+import { postWithToken } from "@/hooks/useData";
+import { isInTelegram } from "@/utils/isInTelegram";
+import { fetchToken } from "@/utils/token";
+
+import {
+ UserContextState,
+ UserContextType,
+ CustomJwtPayload,
+ User,
+ IConfigContent,
+} from "./types/UserProviderType";
+
+
+let RETRY_MAX_COUNT = 3;
+
+const initialState: UserContextState = {
+ user: {
+ userPoints: {
+ consecutiveLoginDays: 1,
+ dailyLoginPointsStatus: true,
+ dailyPointsClaimedStatus: [],
+ userTotalPoints: 0,
+ },
+ isNewUser: false,
+ },
+ cmsData: null,
+ token: null,
+ loading: true,
+ error: null,
+ redirected: false,
+};
+
+// Create a context
+const UserContext = createContext(undefined);
+
+type Action =
+ | { type: "SET_USER_DATA"; payload: User }
+ | { type: "SET_TOKEN"; payload: string }
+ | { type: "SET_LOADING"; payload: boolean }
+ | { type: "SET_ERROR"; payload: string | null }
+ | { type: "SET_REDIREDTED"; payload: boolean };
+
+const dataReducer = (
+ state: UserContextState,
+ action: Action
+): UserContextState => {
+ switch (action.type) {
+ case "SET_USER_DATA":
+ return { ...state, user: action.payload, loading: false };
+ case "SET_TOKEN":
+ return { ...state, token: action.payload, loading: false };
+ case "SET_LOADING":
+ return { ...state, loading: action.payload };
+ case "SET_ERROR":
+ return { ...state, error: action.payload };
+ case "SET_REDIREDTED":
+ return { ...state, redirected: action.payload };
+ default:
+ throw new Error(`Unhandled action type: ${JSON.stringify(action)}`);
+ }
+};
+
+// Custom hook to use the UserContext
+export const useUserContext = () => {
+ const context = useContext(UserContext);
+ if (context === undefined) {
+ throw new Error("useUserContext must be used within a UserProvider");
+ }
+ return context;
+};
+
+const getUserPoints = async (accessToken: string) => {
+ // Fetch user points data
+ const userPointsResponse = await fetch(
+ `${
+ import.meta.env.VITE_BASE_URL
+ }/api/app/user/login-points/status?chainId=${chainId}`,
+ {
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ "Content-Type": "application/json",
+ },
+ }
+ );
+ if (!userPointsResponse.ok) throw new Error("Failed to fetch user points");
+ const userPointsData = await userPointsResponse.json();
+ return userPointsData;
+};
+
+// UserProvider component
+export const UserProvider: React.FC<{ children: ReactNode }> = ({
+ children,
+}) => {
+ const [state, dispatch] = useReducer(dataReducer, initialState);
+ const webLoginContext = useConnectWallet();
+ const {
+ connectWallet,
+ disConnectWallet,
+ walletInfo: wallet,
+ isConnected,
+ getSignature,
+ } = useConnectWallet();
+ const [cmsData, setCmsData] = useState(null);
+
+ const { run: fetchPortKeyToken, cancel } = useRequest(
+ async () => {
+ const timestamp = Date.now();
+ const sign = await getSignature({
+ appName: "TomorrowDAOServer",
+ address: wallet!.address,
+ signInfo: Buffer.from(`${wallet?.address}-${timestamp}`).toString(
+ "hex"
+ ),
+ });
+ const requestObject = {
+ grant_type: "signature",
+ scope: "TomorrowDAOServer",
+ client_id: "TomorrowDAOServer_App",
+ timestamp: timestamp.toString(),
+ signature: sign?.signature ?? "",
+ source: "portkey",
+ publickey: wallet?.extraInfo?.publicKey || "",
+ chain_id: wallet?.extraInfo?.portkeyInfo?.chainId ?? "",
+ ca_hash: wallet?.extraInfo?.portkeyInfo?.caInfo?.caHash ?? "",
+ address: wallet?.address ?? "",
+ };
+ const portKeyRes = await fetch(
+ `${import.meta.env.VITE_BASE_URL}/connect/token`,
+ {
+ method: "POST",
+ body: new URLSearchParams(requestObject).toString(),
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
+ }
+ );
+ const portKeyResObj = await portKeyRes.json();
+ if (portKeyRes?.ok && portKeyResObj?.access_token) {
+ fetchTransferStatus();
+ cancel();
+ }
+ },
+ {
+ manual: true,
+ pollingInterval: 1500,
+ }
+ );
+
+ const fetchCMSData = async () => {
+ const cmsRes = await fetch(host + "/cms/items/config", {
+ cache: "no-store",
+ });
+ const {
+ data: { config },
+ } = await cmsRes.json();
+ setCmsData(config);
+ };
+
+ const fetchTokenAndData = async () => {
+ try {
+ const access_token = await fetchToken();
+
+ if (!access_token) {
+ await disConnectWallet();
+ const error = new Error("Failed to fetch token");
+ error.name = "401";
+ throw error;
+ }
+
+ dispatch({ type: "SET_TOKEN", payload: access_token });
+ await localStorage.setItem("access_token", access_token);
+ const decodedToken = jwtDecode(access_token);
+ const userPointsData = await getUserPoints(access_token);
+ // Combine and set user data
+ dispatch({
+ type: "SET_USER_DATA",
+ payload: {
+ isNewUser: !!Number(decodedToken.new_user) || false,
+ userPoints: userPointsData?.data,
+ },
+ });
+ } catch (error) {
+ if (
+ error instanceof Error &&
+ error.name === "401" &&
+ RETRY_MAX_COUNT > 0
+ ) {
+ RETRY_MAX_COUNT = RETRY_MAX_COUNT - 1;
+ fetchTokenAndData();
+ } else {
+ RETRY_MAX_COUNT = 3;
+ }
+ console.error("Error fetching data:", error);
+ dispatch({ type: "SET_ERROR", payload: (error as Error).message });
+ dispatch({ type: "SET_LOADING", payload: false });
+ }
+ };
+
+ const fetchTransfer = async (cancel: () => void) => {
+ const { data } = await postWithToken("/api/app/token/transfer", {
+ chainId,
+ symbol: nftSymbol,
+ });
+ if (!data) {
+ if (isInTelegram()) {
+ window.location.reload();
+ } else {
+ fetchTokenAndData();
+ }
+ } else {
+ cancel();
+ }
+ };
+
+ const { run: fetchTransferStatus, cancel: cancelTransferStatus } = useRequest(
+ async () => {
+ try {
+ const { data } = await postWithToken("/api/app/token/transfer/status", {
+ chainId,
+ address: wallet?.address,
+ symbol: nftSymbol,
+ });
+ const { isClaimedInSystem } = data || {};
+ if (!data || !isClaimedInSystem) {
+ fetchTransfer(cancelTransferStatus);
+ } else {
+ cancelTransferStatus();
+ }
+ } catch (error) {
+ console.error(error);
+ }
+ },
+ {
+ manual: true,
+ pollingInterval: 1000,
+ }
+ );
+
+ useEffect(() => {
+ if (state.loading) {
+ fetchTokenAndData();
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [state.loading]);
+
+ useAsyncEffect(async () => {
+ if (!wallet) {
+ return;
+ }
+ webLoginInstance.setWebLoginContext(webLoginContext);
+ }, [webLoginContext]);
+
+ useEffect(() => {
+ if (isConnected && wallet) {
+ fetchPortKeyToken();
+ }
+ }, [fetchPortKeyToken, isConnected, wallet]);
+
+ useEffect(() => {
+ if (!isInTelegram() && !isConnected) {
+ connectWallet();
+ return;
+ }
+ }, [connectWallet, isConnected]);
+
+ useEffect(() => {
+ fetchCMSData();
+ }, []);
+
+ const hasUserData = () => {
+ return state.user.userPoints !== null;
+ };
+
+ const updateDailyLoginPointsStatus = (points: number) => {
+ const userPoints = state.user.userPoints;
+ const currentClaimed =
+ userPoints?.dailyPointsClaimedStatus?.filter((isClaimed) => isClaimed)
+ ?.length || 0;
+ dispatch({
+ type: "SET_USER_DATA",
+ payload: {
+ ...state.user,
+ userPoints: {
+ ...state.user.userPoints,
+ dailyLoginPointsStatus: true,
+ userTotalPoints: points,
+ dailyPointsClaimedStatus: userPoints?.dailyPointsClaimedStatus.map(
+ (_, index) => index < currentClaimed + 1
+ ),
+ },
+ } as User,
+ });
+ };
+
+ const updateUserPoints = (points: number) => {
+ dispatch({
+ type: "SET_USER_DATA",
+ payload: {
+ ...state.user,
+ userPoints: {
+ ...state.user.userPoints,
+ userTotalPoints: points,
+ },
+ } as User,
+ });
+ };
+
+ const updateUserStatus = (isNewUser: boolean) => {
+ const user = { ...state.user };
+ user.isNewUser = isNewUser;
+ dispatch({
+ type: "SET_USER_DATA",
+ payload: {
+ ...user,
+ } as User,
+ });
+ };
+
+ const updateRedirectedStatus = (redirected: boolean) => {
+ dispatch({ type: "SET_REDIREDTED", payload: redirected });
+ };
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/src/provider/types/UserProviderType.ts b/src/provider/types/UserProviderType.ts
new file mode 100644
index 0000000..825b595
--- /dev/null
+++ b/src/provider/types/UserProviderType.ts
@@ -0,0 +1,63 @@
+import { JwtPayload } from "jwt-decode";
+
+export interface UserPoints {
+ consecutiveLoginDays: number;
+ dailyLoginPointsStatus: boolean;
+ dailyPointsClaimedStatus: boolean[];
+ userTotalPoints: number;
+}
+
+export interface User {
+ userPoints: UserPoints | null;
+ isNewUser: boolean;
+}
+
+export interface UserContextState {
+ user: User;
+ cmsData: IConfigContent | null;
+ token: string | null;
+ loading: boolean;
+ error: string | null;
+ redirected?: boolean;
+}
+
+export interface IConfigContent {
+ loginScreen: {
+ title: string;
+ subtitle: string;
+ progressTips: string[];
+ };
+ earnScreen: {
+ title: string;
+ subtitle: string;
+ };
+ voteMain: {
+ rules: {
+ title: string;
+ description: string[];
+ };
+ listTitle: string;
+ topBannerImages: string[];
+ nftImage: string;
+ };
+ communityDaoId: string;
+ createVotePageTitle: string;
+ rankingAdsBannerUrl: string;
+ discoverTopBannerURL: string;
+ discoverTopBannerRedirectURL: string;
+ retweetVotigramPostURL: string;
+ retweetTmrwdaoPostURL: string;
+}
+
+export interface CustomJwtPayload extends JwtPayload {
+ new_user?: boolean;
+}
+
+export interface UserContextType extends UserContextState {
+ hasUserData: () => boolean;
+ updateUserStatus: (isNewUser: boolean) => void;
+ updateUserPoints: (points: number) => void;
+ fetchTokenAndData: () => Promise;
+ updateRedirectedStatus: (redirected: boolean) => void;
+ updateDailyLoginPointsStatus: (value: number) => void;
+}
diff --git a/src/provider/webLoginProvider.tsx b/src/provider/webLoginProvider.tsx
new file mode 100644
index 0000000..cfe080d
--- /dev/null
+++ b/src/provider/webLoginProvider.tsx
@@ -0,0 +1,110 @@
+"use client";
+
+import { useEffect, useMemo } from "react";
+
+import {
+ NetworkEnum,
+ SignInDesignEnum,
+ TChainId,
+} from "@aelf-web-login/wallet-adapter-base";
+import { IConfigProps } from "@aelf-web-login/wallet-adapter-bridge";
+import { PortkeyAAWallet } from "@aelf-web-login/wallet-adapter-portkey-aa";
+import { WebLoginProvider } from "@aelf-web-login/wallet-adapter-react";
+
+import {
+ connectServer,
+ connectUrl,
+ graphqlServer,
+ networkType,
+ portkeyServer,
+ rpcUrlAELF,
+ rpcUrlTDVV,
+ rpcUrlTDVW,
+ TELEGRAM_BOT_ID,
+} from "@/config";
+import { chainId, projectCode } from "@/constants/app";
+import { getReferrerCode } from "@/utils/start-params";
+
+const APP_NAME = "TMRWDAO";
+
+function addBasePath(url: string) {
+ if (String(url).startsWith("http")) {
+ return url;
+ }
+ return `${url}`;
+}
+
+export default function LoginSDKProvider({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ const info: Record = {
+ networkType: networkType,
+ rpcUrlAELF: rpcUrlAELF,
+ rpcUrlTDVV: rpcUrlTDVV,
+ rpcUrlTDVW: rpcUrlTDVW,
+ connectServer: connectServer,
+ graphqlServer: graphqlServer,
+ portkeyServer: portkeyServer,
+ connectUrl: connectUrl,
+ curChain: chainId,
+ };
+ const server = info.portkeyServer;
+
+ const referrerCode = getReferrerCode();
+
+ const didConfig = {
+ graphQLUrl: info.graphqlServer,
+ connectUrl: addBasePath(connectUrl || ""),
+ serviceUrl: server,
+ requestDefaults: {
+ timeout: networkType === "TESTNET" ? 300000 : 80000,
+ baseURL: addBasePath(server || ""),
+ },
+ socialLogin: {
+ Telegram: {
+ botId: TELEGRAM_BOT_ID,
+ },
+ },
+ referralInfo: {
+ referralCode: referrerCode ?? "",
+ projectCode,
+ },
+ };
+
+ const baseConfig = {
+ sideChainId: chainId as TChainId,
+ omitTelegramScript: true,
+ showVconsole: networkType === "TESTNET",
+ networkType: networkType as NetworkEnum,
+ chainId: chainId as TChainId,
+ keyboard: true,
+ noCommonBaseModal: false,
+ design: SignInDesignEnum.CryptoDesign,
+ enableAcceleration: true,
+ };
+
+ const aaWallet = useMemo(() => {
+ return new PortkeyAAWallet({
+ appName: APP_NAME,
+ chainId: chainId as TChainId,
+ autoShowUnlock: true,
+ noNeedForConfirm: true,
+ enableAcceleration: true,
+ });
+ }, []);
+ const wallets = [aaWallet];
+
+ const config: IConfigProps = {
+ didConfig,
+ baseConfig,
+ wallets,
+ };
+
+ useEffect(() => {
+ aaWallet.setChainId(chainId as TChainId);
+ }, [aaWallet]);
+
+ return {children} ;
+}
diff --git a/src/routes/index.tsx b/src/routes/index.tsx
new file mode 100644
index 0000000..ad7c7da
--- /dev/null
+++ b/src/routes/index.tsx
@@ -0,0 +1,78 @@
+
+import React, { useEffect } from "react";
+
+import { useLaunchParams } from "@telegram-apps/sdk-react";
+import { AppRoot } from "@telegram-apps/telegram-ui";
+import { HashRouter, Routes, Route, Navigate } from "react-router-dom";
+
+import { chainId } from "@/constants/app";
+import { postWithToken } from "@/hooks/useData";
+import CreatePoll from "@/pageComponents/CreatePoll";
+import Home from "@/pageComponents/Home";
+import PollDetail from "@/pageComponents/PollDetail";
+import { isInTelegram } from "@/utils/isInTelegram";
+
+const routes = [
+ {
+ path: "/",
+ Component: Home,
+ title: "Home",
+ },
+
+ {
+ path: "/create-poll",
+ Component: CreatePoll,
+ title: "Create Poll",
+ },
+
+ {
+ path: "/proposal/:proposalId",
+ Component: PollDetail,
+ title: "Poll Detail",
+ },
+];
+
+const AppRoutes: React.FC = () => {
+ const lp = useLaunchParams();
+
+ const saveUserInfo = async () => {
+ try {
+ const { first_name, last_name, photo_url, username, id } =
+ window?.Telegram?.WebApp?.initDataUnsafe?.user || {};
+ await postWithToken("/api/app/user/save-tg-info", {
+ telegramId: id?.toString(),
+ chainId,
+ firstName: first_name,
+ lastName: last_name,
+ userName: username,
+ icon: photo_url,
+ });
+ } catch (error) {
+ console.error(error);
+ }
+ };
+
+ useEffect(() => {
+ if (isInTelegram()) {
+ saveUserInfo();
+ }
+ }, []);
+
+ return (
+
+
+
+ {routes.map((route) => (
+
+ ))}
+ } />
+
+
+
+ );
+};
+
+export default AppRoutes;
diff --git a/src/setupTests.ts b/src/setupTests.ts
new file mode 100644
index 0000000..d0de870
--- /dev/null
+++ b/src/setupTests.ts
@@ -0,0 +1 @@
+import "@testing-library/jest-dom";
diff --git a/src/styles/theme.css b/src/styles/theme.css
new file mode 100644
index 0000000..7172a86
--- /dev/null
+++ b/src/styles/theme.css
@@ -0,0 +1,19 @@
+:root {
+ --primary: #8772FF;
+ --secondary: #D9FE7D;
+ --tertiary: #2E2E2E;
+ --input: #2E2E2E;
+ --input-placeholder: #969696;
+ --app-icon-border: #969696;
+ --pill-border: #2E2E2E;
+ --gray-border: #4E4E4E;
+ --modal-background: #191919;
+ --danger: #FF2929;
+ --lime-primary: #9381FF;
+ --lime-green: #D9FE7D;
+ --dark-gray: #272727;
+ --avatar-background: #464646;
+ --tg-content-safe-area-inset-top: 0px;
+ --tg-safe-area-inset-top: 0px;
+ --tg-safe-area-custom-top: 0px;
+}
\ No newline at end of file
diff --git a/src/types/adsgram.d.ts b/src/types/adsgram.d.ts
new file mode 100644
index 0000000..4b5b536
--- /dev/null
+++ b/src/types/adsgram.d.ts
@@ -0,0 +1,37 @@
+export interface ShowPromiseResult {
+ done: boolean;
+ description: string;
+ state: "load" | "render" | "playing" | "destroy";
+ error: boolean;
+}
+
+type BannerType = "RewardedVideo" | "FullscreenMedia";
+
+interface AdsgramInitParams {
+ blockId: string;
+ debug?: boolean;
+ debugBannerType?: BannerType;
+}
+
+type EventType =
+ | "onReward"
+ | "onStart"
+ | "onSkip"
+ | "onBannerNotFound"
+ | "onError";
+type HandlerType = () => void;
+
+export interface AdController {
+ show(): Promise;
+ addEventListener(event: EventType, handler: HandlerType): void;
+ removeEventListener(event: EventType, handler: HandlerType): void;
+ destroy(): void;
+}
+
+declare global {
+ interface Window {
+ Adsgram?: {
+ init(params: AdsgramInitParams): AdController;
+ };
+ }
+}
diff --git a/src/types/app.ts b/src/types/app.ts
new file mode 100644
index 0000000..8102f32
--- /dev/null
+++ b/src/types/app.ts
@@ -0,0 +1,94 @@
+import { ManipulateType } from "dayjs";
+
+import { APP_CATEGORY } from "@/constants/discover";
+import { LABEL_TYPE, RANKING_TYPE } from "@/constants/vote";
+
+export type VoteApp = {
+ alias: string;
+ appType: string;
+ categories: APP_CATEGORY[];
+ createTime: string; // or Date if you prefer to handle it as a Date object
+ creator: string;
+ description: string;
+ editorChoice: boolean;
+ icon: string;
+ id: string;
+ loadTime: string; // or Date if you prefer to handle it as a Date object
+ longDescription: string;
+ screenshots: string[];
+ title: string;
+ updateTime: string; // or Date if you prefer to handle it as a Date object
+ url: string;
+ pointsAmount?: number;
+ totalLikes?: number;
+ totalShares?: number;
+ totalComments?: number;
+ totalOpens?: number;
+ totalPoints?: number;
+ pointsPercent?: number;
+};
+
+export type CommentItem = {
+ id: string;
+ chainId: string;
+ daoId: string;
+ proposalId: string;
+ alias: string;
+ commenter: string;
+ commenterId: string;
+ commenterName: string;
+ commenterFirstName: string;
+ commenterLastName: string;
+ commenterPhoto: string;
+ comment: string;
+ parentId: string;
+ commentStatus: number;
+ createTime: number;
+ modificationTime: number;
+};
+
+export type VoteTimeItem = {
+ value: number;
+ unit: ManipulateType;
+ label: string;
+};
+
+export type IRankingListItem = {
+ alias: string;
+ title: string;
+ icon: string;
+ description: string;
+ editorChoice: boolean;
+ url: string;
+ longDescription: string;
+ screenshots: string[];
+ voteAmount: number;
+ votePercent: number;
+ pointsAmount: number;
+ pointsPercent: number;
+};
+
+export type IPollDetail = {
+ startTime: string;
+ endTime: string;
+ canVoteAmount: number;
+ totalVoteAmount: number;
+ userTotalPoints: number;
+ bannerUrl: string;
+ rankingType: RANKING_TYPE;
+ labelType: LABEL_TYPE;
+ proposalTitle: string;
+ rankingList: VoteApp[];
+ activeStartEpochTime: number;
+ activeEndEpochTime: number;
+};
+
+export type DiscoverType = {
+ value: APP_CATEGORY;
+ label: string;
+};
+
+export type Size = {
+ width: number;
+ height: number;
+};
diff --git a/src/types/comment.ts b/src/types/comment.ts
new file mode 100644
index 0000000..01a5be5
--- /dev/null
+++ b/src/types/comment.ts
@@ -0,0 +1,18 @@
+export type Comment = {
+ id: string;
+ chainId: string;
+ daoId: string | null;
+ proposalId: string;
+ alias: string;
+ commenter: string;
+ commenterId: string;
+ commenterName: string;
+ commenterFirstName: string;
+ commenterLastName: string;
+ commenterPhoto: string;
+ comment: string;
+ parentId: string;
+ commentStatus: number; // Assuming it's a number (e.g., an enum status)
+ createTime: number; // Timestamp in milliseconds
+ modificationTime: number; // Timestamp in milliseconds
+};
diff --git a/src/types/contract.ts b/src/types/contract.ts
new file mode 100644
index 0000000..c3c5fa6
--- /dev/null
+++ b/src/types/contract.ts
@@ -0,0 +1,51 @@
+export interface IContractResult {
+ proposalId: string;
+ TransactionId: string;
+}
+
+export interface IContractOptions {
+ chain?: Chain | null;
+ type?: ContractMethodType;
+}
+
+export enum ContractMethodType {
+ SEND = 'send',
+ VIEW = 'view',
+}
+
+export interface IContractError extends Error {
+ code?: number;
+ error?:
+ | number
+ | string
+ | {
+ message?: string;
+ };
+ errorMessage?: {
+ message: string;
+ name?: string;
+ stack?: string;
+ };
+ Error?: string;
+ from?: string;
+ sid?: string;
+ result?: {
+ TransactionId?: string;
+ transactionId?: string;
+ };
+ TransactionId?: string;
+ transactionId?: string;
+ value?: unknown;
+}
+
+export enum EVoteOption {
+ APPROVED = 0,
+ REJECTED = 1,
+ ABSTAINED = 2,
+}
+
+export const EVoteOptionLabel: Record = {
+ [EVoteOption.APPROVED]: 'Approve',
+ [EVoteOption.REJECTED]: 'Reject',
+ [EVoteOption.ABSTAINED]: 'Abstain',
+};
diff --git a/src/types/global.d.ts b/src/types/global.d.ts
new file mode 100644
index 0000000..34b53fe
--- /dev/null
+++ b/src/types/global.d.ts
@@ -0,0 +1,92 @@
+// global.d.ts
+
+interface TelegramWebApp {
+ initData: string;
+ initDataUnsafe: {
+ query_id?: string;
+ user?: {
+ id: number;
+ first_name: string;
+ last_name?: string;
+ username?: string;
+ language_code?: string;
+ };
+ chat?: {
+ id: number;
+ type: string;
+ title?: string;
+ username?: string;
+ };
+ can_send_after?: number;
+ auth_date?: number;
+ hash?: string;
+ };
+ MainButton: {
+ isVisible: boolean;
+ text: string;
+ color: string;
+ textColor: string;
+ isProgressVisible: boolean;
+ setParams(params: {
+ text?: string;
+ color?: string;
+ text_color?: string;
+ }): void;
+ onClick(handler: () => void): void;
+ show(): void;
+ hide(): void;
+ enable(): void;
+ disable(): void;
+ showProgress(leaveActive: boolean): void;
+ hideProgress(): void;
+ };
+
+ close(): void;
+ expand(): void;
+ onEvent(eventType: string, handler: () => void): void;
+ offEvent(eventType: string, handler: () => void): void;
+ WebApp: {
+ openTelegramLink(url: string): unknown;
+ openLink(url: string): unknown;
+ openLink(url: string): void;
+ isVersionAtLeast(arg0: number): unknown;
+ platform: string;
+ HapticFeedback: {
+ impactOccurred(
+ style: "light" | "medium" | "heavy" | "rigid" | "soft"
+ ): void;
+ notificationOccurred(type: "success" | "warning" | "error"): void;
+ selectionChanged(): void;
+ };
+ version: string;
+ expand(): void;
+ requestFullscreen(): void;
+ lockOrientation(): void;
+ disableVerticalSwipes(): void;
+ setHeaderColor(color: string): void;
+ initData: "";
+ initDataUnsafe: {
+ start_param: string;
+ user: {
+ id: string;
+ username: string;
+ first_name: string;
+ last_name: string;
+ photo_url: string;
+ };
+ };
+ };
+}
+
+interface Window {
+ Telegram: TelegramWebApp;
+ visualViewport: VisualViewport;
+}
+
+declare type Chain = "AELF" | "tDVV" | "tDVW";
+
+declare module "aelf-sdk";
+
+declare namespace vi {
+ type Mock = Mock
+}
diff --git a/src/types/task.ts b/src/types/task.ts
new file mode 100644
index 0000000..a02cd74
--- /dev/null
+++ b/src/types/task.ts
@@ -0,0 +1,65 @@
+import { USER_TASK_DETAIL, USER_TASK_TITLE } from "@/constants/task";
+
+export type TaskInfo = {
+ points: number;
+ userTaskDetail: USER_TASK_DETAIL;
+ complete: boolean;
+ completeCount: number;
+ taskCount: number;
+};
+
+export type TaskModule = {
+ totalCount: number;
+ userTask: USER_TASK_TITLE;
+ data: TaskInfo[];
+};
+
+export type InviteDetail = {
+ estimatedReward: number;
+ accountCreation: number;
+ votigramVote: number;
+ votigramActivityVote: number;
+ estimatedRewardAll: number;
+ accountCreationAll: number;
+ votigramVoteAll: number;
+ votigramActivityVoteAll: number;
+ startTime: number;
+ endTime: number;
+ duringCycle: boolean;
+ address: string;
+ caHash: string;
+ totalInvitesNeeded: number;
+ pointsFirstReferralVote: number;
+};
+
+export type ShortLinkRes = {
+ shortLink: string;
+ userGrowthInfo: {
+ caHash: string;
+ projectCode: string;
+ inviteCode: string;
+ shortLinkCode: string;
+ };
+};
+
+export type IStartAppParams = {
+ pid?: string;
+ referralCode?: string;
+ source?: string;
+};
+
+export type ReferralTimeConfig = {
+ startTime: number;
+ endTime: number;
+};
+
+export type InviteItem = {
+ firstName: string;
+ icon: string;
+ inviteAndVoteCount: number;
+ inviter: string;
+ inviterCaHash: string;
+ lastName: string;
+ rank: number;
+ userName: string;
+};
diff --git a/src/utils/__test__/canvasUtils.ts b/src/utils/__test__/canvasUtils.ts
new file mode 100644
index 0000000..732b8ad
--- /dev/null
+++ b/src/utils/__test__/canvasUtils.ts
@@ -0,0 +1,206 @@
+// import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+// import { getCroppedImg } from "../canvasUtils";
+
+// // Mock the canvas and its context
+// const mockCanvas = () => {
+// const canvas = {
+// width: 500,
+// height: 500,
+// getContext: vi.fn(() => ({
+// translate: vi.fn(),
+// rotate: vi.fn(),
+// scale: vi.fn(),
+// drawImage: vi.fn(),
+// getImageData: vi.fn(() => ({
+// data: new Uint8ClampedArray(),
+// width: 100,
+// height: 100,
+// })),
+// putImageData: vi.fn(),
+// clearRect: vi.fn(),
+// save: vi.fn(),
+// restore: vi.fn(),
+// })),
+// toBlob: vi.fn((callback: BlobCallback) => {
+// const blob = new Blob(["sample-image-data"], { type: "image/png" });
+// callback(blob);
+// }), // Mock the toBlob method
+// toDataURL: vi.fn(() => "data:image/png;base64,sample-image-data"), // Mock for fallback usage
+// } as unknown as HTMLCanvasElement;
+
+// const context = {
+// translate: vi.fn(),
+// rotate: vi.fn(), // Mock `rotate`
+// scale: vi.fn(),
+// drawImage: vi.fn(),
+// getImageData: vi.fn(() => ({
+// data: new Uint8ClampedArray(),
+// width: 100,
+// height: 100,
+// })),
+// putImageData: vi.fn(),
+// clearRect: vi.fn(),
+// save: vi.fn(),
+// restore: vi.fn(),
+// };
+
+// return { canvas, context };
+// };
+
+// describe("getCroppedImg", () => {
+// let originalCreateElement: typeof document.createElement;
+
+// beforeEach(() => {
+// // Save the original `document.createElement`
+// originalCreateElement = document.createElement;
+
+// // Mock `document.createElement`
+// vi.spyOn(document, "createElement").mockImplementation(
+// (tagName: string) => {
+// if (tagName === "canvas") {
+// return mockCanvas();
+// }
+// return originalCreateElement.call(document, tagName); // Call the original implementation for non-canvas elements
+// }
+// );
+
+// // Mock the `Image` constructor
+// vi.spyOn(global, "Image").mockImplementation(
+// () =>
+// ({
+// width: 500,
+// height: 400,
+// setAttribute: vi.fn(),
+// addEventListener: vi.fn((event, handler) => {
+// if (event === "load") {
+// setTimeout(handler, 0); // Simulate successful image load
+// }
+// if (event === "error") {
+// setTimeout(() => {
+// handler(new Error("Image load error"));
+// }, 0);
+// }
+// }),
+// removeEventListener: vi.fn(),
+// get src() {
+// return "";
+// },
+// set src(value: string) {
+// // Simulate setting the src attribute
+// setTimeout(() => {
+// // Assume the image loads successfully when src is set
+// }, 0);
+// },
+// } as unknown as HTMLImageElement)
+// );
+// });
+
+// afterEach(() => {
+// // Restore the original `document.createElement`
+// document.createElement = originalCreateElement;
+
+// // Restore all mocks
+// vi.restoreAllMocks();
+// });
+
+// it("creates a canvas and crops the image correctly", async () => {
+// const result = await getCroppedImg(
+// "mock-image-src",
+// { x: 100, y: 50, width: 200, height: 150 },
+// 0, // No rotation
+// { horizontal: false, vertical: false }
+// );
+
+// expect(result).not.toBeNull();
+// });
+
+// it("handles image rotation correctly", async () => {
+// const { getContext } = mockCanvas();
+// const context = getContext("2d");
+
+// const result = await getCroppedImg(
+// "mock-image-src",
+// { x: 100, y: 50, width: 200, height: 150 },
+// 90, // Rotation
+// { horizontal: false, vertical: false }
+// );
+
+// expect(result).not.toBeNull();
+// expect(context?.rotate).toHaveBeenCalledWith(Math.PI / 2);
+// });
+
+// it("handles horizontal and vertical flipping correctly", async () => {
+// const { getContext } = mockCanvas();
+// const context = getContext("2d");
+
+// const result = await getCroppedImg(
+// "mock-image-src",
+// { x: 100, y: 50, width: 200, height: 150 },
+// 0,
+// { horizontal: true, vertical: true } // Flip both horizontally and vertically
+// );
+
+// expect(result).not.toBeNull();
+// expect(context?.scale).toHaveBeenCalledWith(-1, -1);
+// });
+
+// it("returns null if the canvas context is not available", async () => {
+// // Mock `getContext` to return null
+// vi.spyOn(document, "createElement").mockImplementation(
+// (tagName: string) => {
+// if (tagName === "canvas") {
+// const canvas = {
+// getContext: vi.fn(() => null), // No context
+// } as unknown as HTMLCanvasElement;
+// return canvas;
+// }
+// return originalCreateElement.call(document, tagName);
+// }
+// );
+
+// const result = await getCroppedImg(
+// "mock-image-src",
+// { x: 100, y: 50, width: 200, height: 150 },
+// 0
+// );
+
+// expect(result).toBeNull();
+// });
+
+// it("handles image load errors correctly", async () => {
+// // Simulate an image load failure
+// vi.spyOn(global, "Image").mockImplementation(
+// () =>
+// ({
+// width: 500,
+// height: 400,
+// setAttribute: vi.fn(),
+// addEventListener: vi.fn((event, handler) => {
+// if (event === "error") {
+// setTimeout(() => {
+// handler(new Error("Image load error"));
+// }, 0);
+// }
+// }),
+// removeEventListener: vi.fn(),
+// get src() {
+// return "";
+// },
+// set src(value: string) {
+// // Simulate setting the src attribute
+// setTimeout(() => {
+// // Simulate image load error
+// }, 0);
+// },
+// } as unknown as HTMLImageElement)
+// );
+
+// const result = await getCroppedImg(
+// "invalid-image-src",
+// { x: 100, y: 50, width: 200, height: 150 },
+// 0
+// );
+
+// expect(result).toBeNull();
+// });
+// });
diff --git a/src/utils/__test__/file.test.ts b/src/utils/__test__/file.test.ts
new file mode 100644
index 0000000..3d57a72
--- /dev/null
+++ b/src/utils/__test__/file.test.ts
@@ -0,0 +1,64 @@
+import { describe, it, expect, vi } from "vitest";
+
+import { generateRandomString, blobToFile } from "../file"; // Update the import path as necessary
+
+describe("generateRandomString", () => {
+ it("generates a string of the default length (10)", () => {
+ const randomString = generateRandomString();
+ expect(randomString).toHaveLength(10);
+ });
+
+ it("generates a string of the specified length", () => {
+ const length = 15;
+ const randomString = generateRandomString(length);
+ expect(randomString).toHaveLength(length);
+ });
+
+ it("generates a string containing only valid characters", () => {
+ const randomString = generateRandomString(50);
+ const validCharacters = /^[A-Za-z0-9]+$/; // Matches only alphanumeric characters
+ expect(randomString).toMatch(validCharacters);
+ });
+});
+
+describe("blobToFile", () => {
+ it("creates a File from a Blob with a randomly generated name", () => {
+ const blob = new Blob(["Test content"], { type: "text/plain" });
+
+ // Mock `generateRandomString`
+ vi.spyOn(global.Math, "random").mockReturnValue(0.5); // Ensure consistent random string
+ const file = blobToFile(blob);
+
+ expect(file).toBeInstanceOf(File);
+ expect(file.name).toMatch(/^[A-Za-z0-9]{10}\.png$/); // Matches the random file name with `.png`
+ expect(file.type).toBe(blob.type);
+ expect(file.size).toBe(blob.size);
+
+ vi.restoreAllMocks(); // Restore the original implementation
+ });
+
+ it("creates a File from a Blob with a specified name", () => {
+ const blob = new Blob(["Test content"], { type: "text/plain" });
+ const fileName = "test-file.txt";
+ const file = blobToFile(blob, fileName);
+
+ expect(file).toBeInstanceOf(File);
+ expect(file.name).toBe(fileName);
+ expect(file.type).toBe(blob.type);
+ expect(file.size).toBe(blob.size);
+ });
+
+ it("correctly sets the lastModified property to the current timestamp", () => {
+ const blob = new Blob(["Test content"], { type: "text/plain" });
+ const timestamp = Date.now();
+
+ // Mock `Date.now` to return a consistent value
+ vi.spyOn(global.Date, "now").mockReturnValue(timestamp);
+
+ const file = blobToFile(blob);
+
+ expect(file.lastModified).toBe(timestamp);
+
+ vi.restoreAllMocks(); // Restore the original implementation
+ });
+});
diff --git a/src/utils/__test__/getRawTransactionPortkey.test.ts b/src/utils/__test__/getRawTransactionPortkey.test.ts
new file mode 100644
index 0000000..fc64474
--- /dev/null
+++ b/src/utils/__test__/getRawTransactionPortkey.test.ts
@@ -0,0 +1,120 @@
+
+import { getContractBasic } from "@portkey/contracts";
+import { aelf } from "@portkey/utils";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+
+import { EVoteOption } from "@/types/contract";
+import { getRawTransactionPortkey } from "@/utils/getRawTransactionPortkey";
+
+// Mock dependencies
+vi.mock("@portkey/contracts", () => ({
+ getContractBasic: vi.fn(),
+}));
+
+vi.mock("@portkey/utils", () => ({
+ aelf: {
+ getWallet: vi.fn(),
+ },
+}));
+
+describe("getRawTransactionPortkey", () => {
+ const mockGetWallet = aelf.getWallet as vi.Mock;
+ const mockGetContractBasic = getContractBasic as vi.Mock;
+ const mockEncodedTx = vi.fn();
+
+ const mockParams = {
+ caHash: "mock-ca-hash",
+ privateKey: "mock-private-key",
+ contractAddress: "mock-contract-address",
+ caContractAddress: "mock-ca-contract-address",
+ rpcUrl: "mock-rpc-url",
+ params: {
+ votingItemId: "123",
+ voteOption: EVoteOption.APPROVED,
+ voteAmount: 1,
+ memo: "mock-memo",
+ },
+ methodName: "Vote",
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+
+ // Mock `aelf.getWallet`
+ mockGetWallet.mockReturnValue("mock-wallet");
+
+ // Mock `getContractBasic`
+ mockGetContractBasic.mockResolvedValue({
+ encodedTx: mockEncodedTx,
+ });
+
+ // Mock `encodedTx`
+ mockEncodedTx.mockResolvedValue({
+ data: "mock-transaction-data",
+ });
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("generates a raw transaction successfully", async () => {
+ const result = await getRawTransactionPortkey(mockParams);
+
+ // Check `getWallet` is called with the private key
+ expect(mockGetWallet).toHaveBeenCalledWith("mock-private-key");
+
+ // Check `getContractBasic` is called with the correct arguments
+ expect(mockGetContractBasic).toHaveBeenCalledWith({
+ callType: "ca",
+ caHash: "mock-ca-hash",
+ account: "mock-wallet",
+ contractAddress: "mock-contract-address",
+ caContractAddress: "mock-ca-contract-address",
+ rpcUrl: "mock-rpc-url",
+ });
+
+ // Check `encodedTx` is called with the method name and params
+ expect(mockEncodedTx).toHaveBeenCalledWith("Vote", {
+ votingItemId: "123",
+ voteOption: EVoteOption.APPROVED,
+ voteAmount: 1,
+ memo: "mock-memo",
+ });
+
+ // Ensure the function returns the correct transaction data
+ expect(result).toBe("mock-transaction-data");
+ });
+
+ it("handles errors from `getContractBasic`", async () => {
+ // Mock `getContractBasic` to throw an error
+ mockGetContractBasic.mockRejectedValue(new Error("Failed to get contract"));
+
+ await expect(getRawTransactionPortkey(mockParams)).rejects.toThrow(
+ "Failed to get contract"
+ );
+
+ // Ensure `encodedTx` is not called since `getContractBasic` fails
+ expect(mockEncodedTx).not.toHaveBeenCalled();
+ });
+
+ it("handles errors from `encodedTx`", async () => {
+ // Mock `encodedTx` to throw an error
+ mockEncodedTx.mockRejectedValue(new Error("Transaction encoding failed"));
+
+ await expect(getRawTransactionPortkey(mockParams)).rejects.toThrow(
+ "Transaction encoding failed"
+ );
+
+ // Check `getContractBasic` was called successfully
+ expect(mockGetContractBasic).toHaveBeenCalled();
+
+ // Check `encodedTx` was called before failing
+ expect(mockEncodedTx).toHaveBeenCalledWith("Vote", {
+ votingItemId: "123",
+ voteOption: EVoteOption.APPROVED,
+ voteAmount: 1,
+ memo: "mock-memo",
+ });
+ });
+});
diff --git a/src/utils/__test__/isInTelegram.test.ts b/src/utils/__test__/isInTelegram.test.ts
new file mode 100644
index 0000000..171d2a1
--- /dev/null
+++ b/src/utils/__test__/isInTelegram.test.ts
@@ -0,0 +1,61 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+
+import { isInTelegram } from "../isInTelegram"; // Adjust the import path as needed
+
+describe("isInTelegram", () => {
+ let originalWindow: typeof global.window;
+
+ beforeEach(() => {
+ originalWindow = global.window;
+ vi.restoreAllMocks(); // Restore the environment before each test
+ });
+
+ afterEach(() => {
+ global.window = originalWindow;
+ vi.restoreAllMocks();
+ });
+
+ it("returns true if running in Telegram WebApp", () => {
+ // Mock the `window` object
+ global.window = {
+ Telegram: {
+ WebApp: {
+ initData: "mock-init-data", // Simulate presence of initData
+ },
+ },
+ } as unknown as Window & typeof globalThis;
+
+ const result = isInTelegram();
+ expect(result).toBe(true);
+ });
+
+ it("returns false if Telegram WebApp is not initialized", () => {
+ // Mock the `window` object without `initData`
+ global.window = {
+ Telegram: {
+ WebApp: {}, // No initData
+ },
+ } as unknown as Window & typeof globalThis;
+
+ const result = isInTelegram();
+ expect(result).toBe(false);
+ });
+
+ it("returns false if Telegram is not defined", () => {
+ // Mock the `window` object without `Telegram`
+ global.window = {} as unknown as Window & typeof globalThis;
+
+ const result = isInTelegram();
+ expect(result).toBe(false);
+ });
+
+ it("returns false if window is undefined (e.g., Node.js environment)", () => {
+ global.window = undefined as unknown as Window & typeof globalThis;
+
+ const result = isInTelegram();
+ expect(result).toBe(false);
+
+ // Restore the original `window` object
+ global.window = originalWindow;
+ });
+});
diff --git a/src/utils/__test__/time.test.ts b/src/utils/__test__/time.test.ts
new file mode 100644
index 0000000..c042050
--- /dev/null
+++ b/src/utils/__test__/time.test.ts
@@ -0,0 +1,55 @@
+import dayjs from "dayjs";
+import { vi, describe, it, expect, beforeEach, afterEach } from "vitest";
+
+
+import { timeAgo, sleep } from "../time"; // Adjust path to your utility file
+
+// Ensure real dayjs works in the component being tested
+vi.mock("dayjs", async () => {
+ const actualDayjs = (await vi.importActual("dayjs")) as typeof dayjs;
+ return actualDayjs;
+});
+
+describe("timeAgo function", () => {
+ it("returns correct relative time for past dates", () => {
+ const mockDate = "2023-09-15T12:00:00.000Z";
+ vi.setSystemTime(new Date(mockDate)); // Mock system time
+
+ const inputDate = new Date("2023-09-10T12:00:00.000Z").getTime();
+ const result = timeAgo(inputDate);
+
+ expect(result).toBe("5 days ago");
+
+ vi.useRealTimers(); // Restore real timers
+ });
+
+ it("returns correct relative time for future dates", () => {
+ const mockDate = "2023-09-15T12:00:00.000Z";
+ vi.setSystemTime(new Date(mockDate));
+
+ const inputDate = new Date("2023-09-20T12:00:00.000Z").getTime();
+ const result = timeAgo(inputDate);
+
+ expect(result).toBe("in 5 days");
+
+ vi.useRealTimers();
+ });
+});
+
+describe("sleep function", () => {
+ beforeEach(() => {
+ vi.useFakeTimers(); // Mock timers
+ });
+
+ afterEach(() => {
+ vi.useRealTimers(); // Restore real timers
+ });
+
+ it("resolves after the specified time", async () => {
+ const promise = sleep(3000); // Call sleep for 3 seconds
+
+ vi.advanceTimersByTime(3000); // Fast-forward 3 seconds
+
+ await expect(promise).resolves.toBeUndefined();
+ });
+});
diff --git a/src/utils/canvasUtils.ts b/src/utils/canvasUtils.ts
new file mode 100644
index 0000000..ea9a127
--- /dev/null
+++ b/src/utils/canvasUtils.ts
@@ -0,0 +1,115 @@
+import { Size } from "@/types/app";
+
+type PixelCrop = {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+};
+
+type FlipSettings = {
+ horizontal: boolean;
+ vertical: boolean;
+};
+
+const createImage = (url: string): Promise =>
+ new Promise((resolve, reject) => {
+ const image = new Image();
+ image.addEventListener("load", () => resolve(image));
+ image.addEventListener("error", (error) => reject(error));
+ image.setAttribute("crossOrigin", "anonymous"); // needed to avoid cross-origin issues on CodeSandbox
+ image.src = url;
+ });
+
+const getRadianAngle = (degreeValue: number): number => {
+ return (degreeValue * Math.PI) / 180;
+};
+
+/**
+ * Returns the new bounding area of a rotated rectangle.
+ */
+const rotateSize = (width: number, height: number, rotation: number): Size => {
+ const rotRad = getRadianAngle(rotation);
+
+ return {
+ width:
+ Math.abs(Math.cos(rotRad) * width) + Math.abs(Math.sin(rotRad) * height),
+ height:
+ Math.abs(Math.sin(rotRad) * width) + Math.abs(Math.cos(rotRad) * height),
+ };
+};
+
+/**
+ * This function was adapted from the one in the ReadMe of https://github.com/DominicTobias/react-image-crop
+ */
+export const getCroppedImg = async (
+ imageSrc: string,
+ pixelCrop: PixelCrop,
+ rotation: number = 0,
+ flip: FlipSettings = { horizontal: false, vertical: false }
+): Promise => {
+ const image = await createImage(imageSrc);
+ const canvas = document.createElement("canvas");
+ const ctx = canvas.getContext("2d");
+
+ if (!ctx) {
+ return null;
+ }
+
+ const rotRad = getRadianAngle(rotation);
+
+ // calculate bounding box of the rotated image
+ const { width: bBoxWidth, height: bBoxHeight } = rotateSize(
+ image.width,
+ image.height,
+ rotation
+ );
+
+ // set canvas size to match the bounding box
+ canvas.width = bBoxWidth;
+ canvas.height = bBoxHeight;
+
+ // translate canvas context to a central location to allow rotating and flipping around the center
+ ctx.translate(bBoxWidth / 2, bBoxHeight / 2);
+ ctx.rotate(rotRad);
+ ctx.scale(flip.horizontal ? -1 : 1, flip.vertical ? -1 : 1);
+ ctx.translate(-image.width / 2, -image.height / 2);
+
+ // draw rotated image
+ ctx.drawImage(image, 0, 0);
+
+ const croppedCanvas = document.createElement("canvas");
+ const croppedCtx = croppedCanvas.getContext("2d");
+
+ if (!croppedCtx) {
+ return null;
+ }
+
+ // Set the size of the cropped canvas
+ croppedCanvas.width = pixelCrop.width;
+ croppedCanvas.height = pixelCrop.height;
+
+ // Draw the cropped image onto the new canvas
+ croppedCtx.drawImage(
+ canvas,
+ pixelCrop.x,
+ pixelCrop.y,
+ pixelCrop.width,
+ pixelCrop.height,
+ 0,
+ 0,
+ pixelCrop.width,
+ pixelCrop.height
+ );
+
+ // As a blob
+ return new Promise((resolve) => {
+ croppedCanvas.toBlob((file) => {
+ if (file) {
+ resolve(file);
+ } else {
+ resolve(null);
+ }
+ }, "image/png");
+ });
+};
diff --git a/src/utils/file.ts b/src/utils/file.ts
new file mode 100644
index 0000000..45508cf
--- /dev/null
+++ b/src/utils/file.ts
@@ -0,0 +1,17 @@
+export function generateRandomString(len: number = 10) {
+ const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
+ let result = '';
+ for (let i = 0; i < len; i++) {
+ result += characters.charAt(Math.floor(Math.random() * characters.length));
+ }
+ return result;
+}
+
+export function blobToFile(blob: Blob, fileName?: string) {
+ const fileNameWithExtension = fileName || generateRandomString(10) + '.png';
+ const file = new File([blob], fileNameWithExtension, {
+ type: blob.type,
+ lastModified: Date.now()
+ });
+ return file;
+}
diff --git a/src/utils/getRawTransactionPortkey.ts b/src/utils/getRawTransactionPortkey.ts
new file mode 100644
index 0000000..11eea0a
--- /dev/null
+++ b/src/utils/getRawTransactionPortkey.ts
@@ -0,0 +1,48 @@
+import { getContractBasic } from "@portkey/contracts";
+import { aelf } from "@portkey/utils";
+
+import { EVoteOption } from "@/types/contract";
+
+type VotePortkeyParams = {
+ votingItemId: string;
+ voteOption: EVoteOption;
+ voteAmount: number;
+ memo: string;
+};
+
+type RowTransactionPortkeyParams = {
+ caHash: string;
+ privateKey: string;
+ contractAddress: string;
+ caContractAddress: string;
+ rpcUrl: string;
+ params: VotePortkeyParams;
+ methodName: string;
+};
+
+export const getRawTransactionPortkey = async ({
+ caHash,
+ privateKey,
+ contractAddress,
+ caContractAddress,
+ rpcUrl,
+ params,
+ methodName,
+}: RowTransactionPortkeyParams) => {
+ try {
+ const contract = await getContractBasic({
+ callType: "ca",
+ caHash: caHash,
+ account: aelf.getWallet(privateKey),
+ contractAddress: contractAddress,
+ caContractAddress: caContractAddress,
+ rpcUrl: rpcUrl,
+ });
+
+ const transaction = await contract.encodedTx(methodName, params);
+
+ return transaction.data;
+ } catch (error) {
+ return Promise.reject(error);
+ }
+};
diff --git a/src/utils/getTxResult.ts b/src/utils/getTxResult.ts
new file mode 100644
index 0000000..74fad1a
--- /dev/null
+++ b/src/utils/getTxResult.ts
@@ -0,0 +1,127 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import AElf from "aelf-sdk";
+
+import { rpcUrlAELF, rpcUrlTDVV, rpcUrlTDVW } from "@/config";
+import { SupportedELFChainId } from "@/constants/app";
+import { SECONDS_60 } from "@/constants/time";
+
+import { sleep } from "./time";
+
+const getAElf = (rpcUrl?: string) => {
+ const rpc = rpcUrl || "";
+ const httpProviders: any = {};
+
+ if (!httpProviders[rpc]) {
+ httpProviders[rpc] = new AElf(new AElf.providers.HttpProvider(rpc));
+ }
+ return httpProviders[rpc];
+};
+
+const getRpcUrls = () => {
+ return {
+ [SupportedELFChainId.MAIN_NET]: rpcUrlAELF,
+ [SupportedELFChainId.TDVV_NET]: rpcUrlTDVV,
+ [SupportedELFChainId.TDVW_NET]: rpcUrlTDVW,
+ };
+};
+type ITxResultProps = {
+ TransactionId: string;
+ chainId: Chain;
+ rePendingEnd?: number;
+ reNotexistedCount?: number;
+ reGetCount?: number;
+};
+async function getTxResultRetry({
+ TransactionId,
+ chainId,
+ reGetCount = 10,
+ rePendingEnd,
+ reNotexistedCount = 10,
+}: ITxResultProps): Promise {
+ try {
+ const rpcUrl = getRpcUrls()[chainId];
+ const txResult = await getAElf(rpcUrl).chain.getTxResult(TransactionId);
+ if (txResult.error && txResult.errorMessage) {
+ throw Error(
+ txResult.errorMessage.message || txResult.errorMessage.Message
+ );
+ }
+
+ if (!txResult) {
+ if (reGetCount > 1) {
+ await sleep(500);
+ reGetCount--;
+ return getTxResultRetry({
+ TransactionId,
+ chainId,
+ rePendingEnd,
+ reNotexistedCount,
+ reGetCount,
+ });
+ }
+
+ throw Error(
+ `get transaction result failed. transaction id: ${TransactionId}`
+ );
+ }
+
+ if (txResult.Status.toLowerCase() === "pending") {
+ const current = new Date().getTime();
+ if (rePendingEnd && rePendingEnd <= current) {
+ throw Error(`transaction ${TransactionId} is still pending`);
+ }
+ await sleep(1000);
+ const pendingEnd: number = rePendingEnd || current + SECONDS_60;
+ return getTxResultRetry({
+ TransactionId,
+ chainId,
+ rePendingEnd: pendingEnd,
+ reNotexistedCount,
+ reGetCount,
+ });
+ }
+
+ if (
+ txResult.Status.toLowerCase() === "notexisted" &&
+ reNotexistedCount > 1
+ ) {
+ await sleep(1000);
+ reNotexistedCount--;
+ return getTxResultRetry({
+ TransactionId,
+ chainId,
+ rePendingEnd,
+ reNotexistedCount,
+ reGetCount,
+ });
+ }
+
+ if (txResult.Status.toLowerCase() === "mined") {
+ return { TransactionId, txResult };
+ }
+ throw Error(
+ `can not get transaction status, transaction id: ${TransactionId}`
+ );
+ } catch (error) {
+ console.error("=====getTxResult error", error);
+ if (reGetCount > 1) {
+ await sleep(1000);
+ reGetCount--;
+ return getTxResultRetry({
+ TransactionId,
+ chainId,
+ rePendingEnd,
+ reNotexistedCount,
+ reGetCount,
+ });
+ }
+ throw Error("get transaction result error, Please try again later.");
+ }
+}
+
+export async function getTxResult(
+ TransactionId: string,
+ chainId: Chain
+): Promise {
+ return getTxResultRetry({ TransactionId, chainId });
+}
diff --git a/src/utils/isInTelegram.ts b/src/utils/isInTelegram.ts
new file mode 100644
index 0000000..6bb1119
--- /dev/null
+++ b/src/utils/isInTelegram.ts
@@ -0,0 +1,6 @@
+export const isInTelegram = () => {
+ if (typeof window !== "undefined") {
+ return !!window?.Telegram?.WebApp?.initData;
+ }
+ return false;
+};
diff --git a/src/utils/start-params.ts b/src/utils/start-params.ts
new file mode 100644
index 0000000..62ac0d3
--- /dev/null
+++ b/src/utils/start-params.ts
@@ -0,0 +1,63 @@
+import { TelegramPlatform } from "@portkey/did-ui-react";
+
+import { getParamFromQuery } from "./url";
+
+export interface IStartAppParams {
+ pid?: string;
+ alias?: string;
+ referralCode?: string;
+ source?: string;
+}
+
+export const AND_CHAR = "_";
+export const CONNECT_CHAR = "-";
+export const stringifyStartAppParams = (params: IStartAppParams) => {
+ const parts = [];
+ for (const [key, value] of Object.entries(params)) {
+ if (value !== undefined) {
+ parts.push(`${key}${AND_CHAR}${value}`);
+ }
+ }
+ return parts.join(CONNECT_CHAR);
+};
+
+export const parseStartAppParams = (params: string): IStartAppParams => {
+ const result: Record = {};
+ const parts = params.split(CONNECT_CHAR);
+
+ for (const part of parts) {
+ const [key, value] = part.split(AND_CHAR);
+ if (key && value !== undefined) {
+ result[key] = value;
+ }
+ }
+ return result as IStartAppParams;
+};
+
+export const getReferrerCode = () => {
+ const startParam = TelegramPlatform.getInitData()?.start_param ?? "";
+ let referrerCode = "";
+ if (startParam.includes(AND_CHAR)) {
+ const params = parseStartAppParams(startParam);
+ referrerCode = params.referralCode ?? "";
+ } else {
+ referrerCode = startParam;
+ }
+ if (!referrerCode && typeof window !== "undefined") {
+ referrerCode = getParamFromQuery("referrerCode");
+ }
+ return referrerCode;
+};
+
+export const hexToString = (hex: string) => {
+ return hex
+ .match(/.{1,2}/g)
+ ?.map((byte) => String.fromCharCode(parseInt(byte, 16)))
+ .join("");
+};
+
+export const stringToHex = (str: string) => {
+ return Array.from(str)
+ .map((char) => char.charCodeAt(0).toString(16))
+ .join("");
+};
diff --git a/src/utils/time.ts b/src/utils/time.ts
new file mode 100644
index 0000000..531a43b
--- /dev/null
+++ b/src/utils/time.ts
@@ -0,0 +1,16 @@
+import dayjs from "dayjs";
+import relativeTime from "dayjs/plugin/relativeTime";
+
+dayjs.extend(relativeTime);
+
+export function timeAgo(inputDate: number): string {
+ return dayjs(inputDate).fromNow();
+}
+
+export const sleep = (time: number) => {
+ return new Promise((resolve) => {
+ setTimeout(() => {
+ resolve();
+ }, time);
+ });
+};
diff --git a/src/utils/token.ts b/src/utils/token.ts
new file mode 100644
index 0000000..605c1b7
--- /dev/null
+++ b/src/utils/token.ts
@@ -0,0 +1,58 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+export const toUrlEncoded = (
+ data: Record,
+ prefix = ""
+): string => {
+ const segments: string[] = [];
+
+ for (const key in data) {
+ if (Object.prototype.hasOwnProperty.call(data, key)) {
+ const value = data[key];
+ const newPrefix = prefix
+ ? `${prefix}[${encodeURIComponent(key)}]`
+ : encodeURIComponent(key);
+
+ if (typeof value === "object" && value !== null) {
+ // Recursively format nested objects
+ segments.push(toUrlEncoded(value, newPrefix));
+ } else {
+ // Encode key-value pair
+ segments.push(`${newPrefix}=${encodeURIComponent(value)}`);
+ }
+ }
+ }
+
+ return segments.join("&");
+};
+
+export const fetchToken = async () => {
+ try {
+ const initData =
+ window?.Telegram?.WebApp?.initData ||
+ import.meta.env.VITE_TELEGRAM_INIT_DATA;
+ // Fetch token
+ const tokenResponse = await fetch(
+ `${import.meta.env.VITE_BASE_URL}/connect/token`,
+ {
+ method: "POST",
+ body: toUrlEncoded({
+ grant_type: "signature",
+ client_id: "TomorrowDAOServer_App",
+ scope: "TomorrowDAOServer",
+ login_type: "TG",
+ init_data: initData,
+ }),
+ headers: {
+ "Content-Type": "application/x-www-form-urlencoded",
+ },
+ }
+ );
+ if (!tokenResponse.ok) throw new Error("Failed to fetch token");
+ const { access_token } = (await tokenResponse.json()) || {};
+ return access_token;
+ } catch (error) {
+ if (error instanceof Error) {
+ console.error(error);
+ }
+ }
+};
diff --git a/src/utils/url.ts b/src/utils/url.ts
new file mode 100644
index 0000000..cc43af0
--- /dev/null
+++ b/src/utils/url.ts
@@ -0,0 +1,7 @@
+export const getParamFromQuery = (param: string) => {
+ if (!param) return '';
+ const url = new URL(window.location.href);
+
+ const params = new URLSearchParams(url.search);
+ return params.get(param) || '';
+}
\ No newline at end of file
diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts
new file mode 100644
index 0000000..11f02fe
--- /dev/null
+++ b/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/tailwind.config.js b/tailwind.config.js
new file mode 100644
index 0000000..108e57f
--- /dev/null
+++ b/tailwind.config.js
@@ -0,0 +1,126 @@
+/** @type {import('tailwindcss').Config} */
+export default {
+ content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
+ theme: {
+ extend: {
+ backgroundImage: {
+ "discover-background":
+ "linear-gradient(0deg, rgba(147, 129, 255, 0) 27.62%, rgba(147, 129, 255, 0.18) 100%)",
+ },
+ fontFamily: {
+ outfit: [
+ "Outfit",
+ "-apple-system",
+ "BlinkMacSystemFont",
+ "'Segoe UI'",
+ "Roboto",
+ "'Helvetica Neue'",
+ "Arial",
+ "sans-serif",
+ ],
+ questrial: [
+ "Questrial",
+ "-apple-system",
+ "BlinkMacSystemFont",
+ "'Segoe UI'",
+ "Roboto",
+ "'Helvetica Neue'",
+ "Arial",
+ "sans-serif",
+ ],
+ pressStart: [
+ "'Press Start 2P'",
+ "-apple-system",
+ "BlinkMacSystemFont",
+ "'Segoe UI'",
+ "Roboto",
+ "'Helvetica Neue'",
+ "Arial",
+ "sans-serif",
+ ],
+ },
+ animation: {
+ slideIn: "slideIn 0.3s forwards",
+ spin: "spin 8s linear infinite",
+ },
+ keyframes: {
+ slideIn: {
+ "0%": {
+ opacity: "0",
+ transform: "scaleX(0)",
+ },
+ "100%": {
+ opacity: "1",
+ transform: "scaleX(1)",
+ },
+ },
+ spin: {
+ '0%': { transform: 'rotate(0deg)' },
+ '100%': { transform: 'rotate(360deg)' },
+ }
+ },
+ colors: {
+ background: "var(--background)",
+ foreground: "var(--foreground)",
+ primary: "var(--primary)",
+ secondary: "var(--secondary)",
+ tertiary: "var(--tertiary)",
+ input: "var(--input)",
+ "input-placeholder": "var(--input-placeholder)",
+ "app-icon-border": "var(--app-icon-border)",
+ "pill-border": "var(--pill-border)",
+ "gray-border": "var(--gray-border)",
+ "modal-background": "var(--modal-background)",
+ danger: "var(--danger)",
+ "lime-primary": "var(--lime-primary)",
+ "lime-green": "var(--lime-green)",
+ "dark-gray": "var(--dark-gray)",
+ "avatar-background": "var(--avatar-background)",
+ },
+ inset: {
+ telegramHeader:
+ "calc(var(--tg-content-safe-area-inset-top) + var(--tg-safe-area-inset-top) + var(--tg-safe-area-custom-top) + 14px)",
+ },
+ padding: {
+ telegramHeader:
+ "calc(var(--tg-content-safe-area-inset-top) + var(--tg-safe-area-inset-top))",
+ },
+ },
+ },
+ plugins: [
+ ({ addComponents }) => {
+ addComponents({
+ ".votigram-grid": {
+ display: "grid",
+ gridTemplateColumns: "repeat(12, minmax(0, 1fr))",
+ columnGap: "0.375rem",
+ margin: "0 auto",
+ width: "100%",
+ maxWidth: "1120px",
+ justifyContent: "center",
+ alignItems: "center",
+ padding: "0 1.25rem",
+ "@screen md": {
+ padding: "0 2.5rem",
+ },
+ "@screen lg": {
+ padding: "0 3.75rem",
+ gap: "1.25rem",
+ },
+ "@screen xl": {
+ padding: "0",
+ },
+ },
+ ...Array.from({ length: 12 }, (_, i) => i + 1).reduce((acc, col) => {
+ acc[`.col-${col}`] = {
+ gridColumn: `span ${col} / span ${col}`,
+ };
+ acc[`.offset-${col}`] = {
+ gridColumnStart: `${col + 1} !important`,
+ };
+ return acc;
+ }, {}),
+ });
+ },
+ ],
+};
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..3bad2c7
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,25 @@
+{
+ "compilerOptions": {
+ "target": "esnext",
+ "module": "esnext",
+ "types": ["node", "vitest/globals"],
+ "lib": ["dom", "esnext"],
+ "jsx": "react-jsx",
+ "moduleResolution": "node",
+ "esModuleInterop": true,
+ "strict": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "allowSyntheticDefaultImports": true,
+ "baseUrl": "./",
+ "paths": {
+ "@/*": ["src/*"],
+ "@/components/*": ["src/components/*"]
+ },
+ "outDir": "./dist"
+ },
+ "include": ["src", "vite.config.ts", "global.d.ts"],
+ "exclude": ["node_modules"]
+}
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 0000000..67e4fa7
--- /dev/null
+++ b/vite.config.ts
@@ -0,0 +1,87 @@
+import react from "@vitejs/plugin-react";
+import path from "path";
+import { defineConfig, UserConfigExport } from "vite";
+
+// Custom plugin to modify HTML paths
+const modifyIndexHtmlPaths = () => {
+ return {
+ name: "modify-index-html-paths",
+ transformIndexHtml(html: string) {
+ return html
+ .replace(
+ /(