diff --git a/__tests__/components/distribution-plan-tool/ReviewDistributionPlanTableSubscriptionFooter.test.tsx b/__tests__/components/distribution-plan-tool/ReviewDistributionPlanTableSubscriptionFooter.test.tsx index e66d8dff0d..be9f1acc76 100644 --- a/__tests__/components/distribution-plan-tool/ReviewDistributionPlanTableSubscriptionFooter.test.tsx +++ b/__tests__/components/distribution-plan-tool/ReviewDistributionPlanTableSubscriptionFooter.test.tsx @@ -5,6 +5,26 @@ import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { useMemo, useState } from "react"; +const mockDownload = jest.fn(); +const mockUseDownloader = jest.fn(); +const mockGetStagingAuth = jest.fn(); +const mockGetAuthJwt = jest.fn(); +const mockFetchAllPages = jest.fn(); + +jest.mock("react-use-downloader", () => ({ + __esModule: true, + default: (...args: any[]) => mockUseDownloader(...args), +})); + +jest.mock("@/services/auth/auth.utils", () => ({ + getStagingAuth: () => mockGetStagingAuth(), + getAuthJwt: () => mockGetAuthJwt(), +})); + +jest.mock("@/services/6529api", () => ({ + fetchAllPages: (...args: any[]) => mockFetchAllPages(...args), +})); + jest.mock( "@/components/distribution-plan-tool/review-distribution-plan/table/ReviewDistributionPlanTableSubscription", () => ({ @@ -16,45 +36,80 @@ jest.mock( jest.mock( "@/components/distribution-plan-tool/review-distribution-plan/table/ReviewDistributionPlanTableSubscriptionFooterUploadPhotos", () => ({ - UploadDistributionPhotosModal: (props: any) => - props.show ?
: null, + UploadDistributionPhotosModal: () => ( +
+ ), + }) +); + +jest.mock( + "@/components/distribution-plan-tool/review-distribution-plan/table/ReviewDistributionPlanTableSubscriptionFooterPhaseAirdrops", + () => ({ + DistributionPhaseAirdropsModal: (props: any) => ( +
+ +
+ ), + }) +); + +jest.mock( + "@/components/distribution-plan-tool/review-distribution-plan/table/ReviewDistributionPlanTableSubscriptionFooterPhaseAirdropsViewer", + () => ({ + DistributionPhaseAirdropsViewerModal: (props: any) => ( +
+
{props.isLoading ? "Loading" : "Loaded"}
+
{`Error: ${props.error ?? "none"}`}
+
{`Rows: ${props.rows.length}`}
+
+ ), }) ); jest.mock( - "@/components/distribution-plan-tool/review-distribution-plan/table/ReviewDistributionPlanTableSubscriptionFooterAutomaticAirdrops", + "@/components/distribution-plan-tool/review-distribution-plan/table/ReviewDistributionPlanTableSubscriptionFooterPhotosViewer", () => ({ - AutomaticAirdropsModal: (props: any) => - props.show ? ( -
- -
- ) : null, + DistributionPhotosViewerModal: (props: any) => ( +
+
{props.isLoading ? "Loading photos" : "Loaded photos"}
+
{`Photo error: ${props.error ?? "none"}`}
+
{`Photos: ${props.photos.length}`}
+
+ ), }) ); jest.mock( "@/components/distribution-plan-tool/review-distribution-plan/table/ReviewDistributionPlanTableSubscriptionFooterConfirmTokenId", () => ({ - ConfirmTokenIdModal: (props: any) => - props.show ? ( -
- -
- ) : null, + ConfirmTokenIdModal: (props: any) => ( +
+ +
+ ), }) ); +jest.mock("@/contexts/SeizeSettingsContext", () => ({ + useSeizeSettings: () => ({ + seizeSettings: { + distribution_admin_wallets: ["0x1"], + }, + }), +})); + const { isSubscriptionsAdmin, } = require("@/components/distribution-plan-tool/review-distribution-plan/table/ReviewDistributionPlanTableSubscription"); @@ -64,6 +119,43 @@ const authCtx = { setToast: jest.fn(), } as any; +function createOverview(overrides?: Record) { + return { + photos_count: 0, + is_normalized: false, + artist_airdrops_addresses: 0, + artist_airdrops_count: 0, + team_airdrops_addresses: 0, + team_airdrops_count: 0, + ...overrides, + }; +} + +beforeEach(() => { + jest.clearAllMocks(); + authCtx.setToast.mockClear(); + mockDownload.mockReset(); + mockUseDownloader.mockReset(); + mockGetStagingAuth.mockReset(); + mockGetAuthJwt.mockReset(); + mockFetchAllPages.mockReset(); + mockDownload.mockResolvedValue(undefined); + mockGetStagingAuth.mockReturnValue(null); + mockGetAuthJwt.mockReturnValue(null); + mockFetchAllPages.mockResolvedValue([]); + mockUseDownloader.mockReturnValue({ + download: mockDownload, + error: null, + }); + jest + .spyOn(require("@/services/api/common-api"), "commonApiFetch") + .mockResolvedValue(createOverview()); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + function TestWrapper({ initialTokenId = null, }: { @@ -102,7 +194,7 @@ test("shows confirm token id modal on mount for admin", () => { expect(screen.getByTestId("Confirm Token ID")).toBeInTheDocument(); }); -test("renders admin buttons after token id is confirmed", async () => { +test("renders split admin controls after token id is confirmed", async () => { const user = userEvent.setup(); (isSubscriptionsAdmin as jest.Mock).mockReturnValue(true); render(); @@ -112,9 +204,32 @@ test("renders admin buttons after token id is confirmed", async () => { await waitFor(() => { expect(screen.getByText("Change Token ID")).toBeInTheDocument(); expect(screen.getByText("Reset Subscriptions")).toBeInTheDocument(); - expect(screen.getByText("Upload Distribution Photos")).toBeInTheDocument(); - expect(screen.getByText("Upload Automatic Airdrops")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /upload artist airdrops/i }) + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /upload team airdrops/i }) + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /^artist airdrops$/i }) + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /^team airdrops$/i }) + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /upload distribution photos/i }) + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /distribution photos \(0\)/i }) + ).toBeInTheDocument(); expect(screen.getByText("Finalize Distribution")).toBeInTheDocument(); + expect(screen.getByText("Publish to GitHub")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /download artist airdrops csv/i }) + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /download team airdrops csv/i }) + ).toBeInTheDocument(); }); }); @@ -139,19 +254,97 @@ test("shows upload modals when upload buttons clicked", async () => { await user.click(screen.getByTestId("confirm-token-id-button")); await waitFor(() => { - expect(screen.getByText("Upload Distribution Photos")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /upload distribution photos/i }) + ).toBeInTheDocument(); }); - await user.click(screen.getByText("Upload Distribution Photos")); + await user.click( + screen.getByRole("button", { name: /upload distribution photos/i }) + ); await waitFor(() => { expect( screen.getByTestId("Upload Distribution Photos") ).toBeInTheDocument(); }); - await user.click(screen.getByText("Upload Automatic Airdrops")); + await user.click( + screen.getByRole("button", { name: /upload artist airdrops/i }) + ); + await waitFor(() => { + expect(screen.getByTestId("artist-airdrops-modal")).toBeInTheDocument(); + }); + + await user.click( + screen.getByRole("button", { name: /upload team airdrops/i }) + ); + await waitFor(() => { + expect(screen.getByTestId("team-airdrops-modal")).toBeInTheDocument(); + }); +}); + +test("loads distribution photos into a viewer modal from the photos endpoint", async () => { + const user = userEvent.setup(); + (isSubscriptionsAdmin as jest.Mock).mockReturnValue(true); + mockFetchAllPages.mockResolvedValue([ + { id: 1, link: "https://example.com/1.jpg" }, + { id: 2, link: "https://example.com/2.jpg" }, + ]); + + render(); + + await user.click( + screen.getByRole("button", { name: /distribution photos \(0\)/i }) + ); + + await waitFor(() => { + expect(mockFetchAllPages).toHaveBeenCalledWith( + "https://api.test.6529.io/api/distribution_photos/0x33FD426905F149f8376e227d0C9D3340AaD17aF1/123" + ); + expect( + screen.getByTestId("distribution-photos-viewer-modal") + ).toBeInTheDocument(); + expect(screen.getByText("Loaded photos")).toBeInTheDocument(); + expect(screen.getByText("Photos: 2")).toBeInTheDocument(); + }); +}); + +test("loads artist airdrops into a viewer modal from the get endpoint", async () => { + const user = userEvent.setup(); + (isSubscriptionsAdmin as jest.Mock).mockReturnValue(true); + const commonApiFetch = jest + .spyOn(require("@/services/api/common-api"), "commonApiFetch") + .mockImplementation(({ endpoint }: { endpoint: string }) => { + if (endpoint.includes("/artist-airdrops")) { + return Promise.resolve([ + { wallet: "0x123", amount: 5 }, + { wallet: "0x456", amount: 10 }, + ]); + } + + return Promise.resolve( + createOverview({ + artist_airdrops_addresses: 2, + artist_airdrops_count: 15, + }) + ); + }); + + render(); + + await user.click(screen.getByRole("button", { name: /^artist airdrops$/i })); + await waitFor(() => { - expect(screen.getByTestId("Automatic Airdrops")).toBeInTheDocument(); + expect(commonApiFetch).toHaveBeenCalledWith( + expect.objectContaining({ + endpoint: expect.stringContaining("/artist-airdrops"), + }) + ); + expect( + screen.getByTestId("artist-airdrops-viewer-modal") + ).toBeInTheDocument(); + expect(screen.getByText("Loaded")).toBeInTheDocument(); + expect(screen.getByText("Rows: 2")).toBeInTheDocument(); }); }); @@ -183,7 +376,7 @@ test("resetSubscriptions posts data and shows toast directly", async () => { jest .spyOn(require("@/services/api/common-api"), "commonApiFetch") - .mockResolvedValue({ photos_count: 0, is_normalized: false }); + .mockResolvedValue(createOverview()); render(); @@ -220,7 +413,7 @@ test("finalizeDistribution posts data and shows toast directly", async () => { jest .spyOn(require("@/services/api/common-api"), "commonApiFetch") - .mockResolvedValue({ photos_count: 0, is_normalized: false }); + .mockResolvedValue(createOverview()); render(); @@ -245,53 +438,99 @@ test("finalizeDistribution posts data and shows toast directly", async () => { }); }); -test("uploadAutomaticAirdrops posts CSV data and shows success toast", async () => { +test("upload artist airdrops posts csv data and shows success toast", async () => { const user = userEvent.setup(); (isSubscriptionsAdmin as jest.Mock).mockReturnValue(true); const commonApiPost = jest .fn() - .mockResolvedValue({ success: true, message: "Airdrops uploaded" }); + .mockResolvedValue({ success: true, message: "Artist uploaded" }); jest .spyOn(require("@/services/api/common-api"), "commonApiPost") .mockImplementation(commonApiPost); const commonApiFetch = jest .spyOn(require("@/services/api/common-api"), "commonApiFetch") - .mockResolvedValue({ - photos_count: 0, - is_normalized: false, - automatic_airdrops: 5, - }); + .mockResolvedValue( + createOverview({ + artist_airdrops_addresses: 2, + artist_airdrops_count: 15, + }) + ); render(); await waitFor(() => { - expect(screen.getByText("Upload Automatic Airdrops")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /upload artist airdrops/i }) + ).toBeInTheDocument(); }); - await user.click(screen.getByText("Upload Automatic Airdrops")); + await user.click( + screen.getByRole("button", { name: /upload artist airdrops/i }) + ); await waitFor(() => { - expect(screen.getByTestId("Automatic Airdrops")).toBeInTheDocument(); + expect(screen.getByTestId("artist-airdrops-modal")).toBeInTheDocument(); }); - await user.click(screen.getByTestId("upload-airdrops-button")); + await user.click(screen.getByTestId("upload-artist-airdrops-button")); await waitFor(() => { expect(commonApiPost).toHaveBeenCalledWith( expect.objectContaining({ - endpoint: expect.stringContaining("/automatic_airdrops"), + endpoint: expect.stringContaining("/artist-airdrops"), body: { csv: "0x123,5\n0x456,10" }, }) ); expect(authCtx.setToast).toHaveBeenCalledWith({ type: "success", - message: "Airdrops uploaded", + message: "Artist uploaded", }); expect(commonApiFetch).toHaveBeenCalled(); }); }); -test("uploadAutomaticAirdrops shows error toast on failure", async () => { +test("upload team airdrops posts csv data and shows success toast", async () => { + const user = userEvent.setup(); + (isSubscriptionsAdmin as jest.Mock).mockReturnValue(true); + const commonApiPost = jest + .fn() + .mockResolvedValue({ success: true, message: "Team uploaded" }); + jest + .spyOn(require("@/services/api/common-api"), "commonApiPost") + .mockImplementation(commonApiPost); + + render(); + + await waitFor(() => { + expect( + screen.getByRole("button", { name: /upload team airdrops/i }) + ).toBeInTheDocument(); + }); + + await user.click( + screen.getByRole("button", { name: /upload team airdrops/i }) + ); + await waitFor(() => { + expect(screen.getByTestId("team-airdrops-modal")).toBeInTheDocument(); + }); + + await user.click(screen.getByTestId("upload-team-airdrops-button")); + + await waitFor(() => { + expect(authCtx.setToast).toHaveBeenCalledWith({ + type: "success", + message: "Team uploaded", + }); + expect(commonApiPost).toHaveBeenCalledWith( + expect.objectContaining({ + endpoint: expect.stringContaining("/team-airdrops"), + body: { csv: "0x123,5\n0x456,10" }, + }) + ); + }); +}); + +test("upload artist airdrops shows error toast on failure", async () => { const user = userEvent.setup(); (isSubscriptionsAdmin as jest.Mock).mockReturnValue(true); const commonApiPost = jest @@ -304,15 +543,19 @@ test("uploadAutomaticAirdrops shows error toast on failure", async () => { render(); await waitFor(() => { - expect(screen.getByText("Upload Automatic Airdrops")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /upload artist airdrops/i }) + ).toBeInTheDocument(); }); - await user.click(screen.getByText("Upload Automatic Airdrops")); + await user.click( + screen.getByRole("button", { name: /upload artist airdrops/i }) + ); await waitFor(() => { - expect(screen.getByTestId("Automatic Airdrops")).toBeInTheDocument(); + expect(screen.getByTestId("artist-airdrops-modal")).toBeInTheDocument(); }); - await user.click(screen.getByTestId("upload-airdrops-button")); + await user.click(screen.getByTestId("upload-artist-airdrops-button")); await waitFor(() => { expect(authCtx.setToast).toHaveBeenCalledWith({ @@ -322,7 +565,7 @@ test("uploadAutomaticAirdrops shows error toast on failure", async () => { }); }); -test("uploadAutomaticAirdrops handles exceptions", async () => { +test("upload artist airdrops handles exceptions", async () => { const user = userEvent.setup(); (isSubscriptionsAdmin as jest.Mock).mockReturnValue(true); const commonApiPost = jest.fn().mockRejectedValue(new Error("Network error")); @@ -333,15 +576,19 @@ test("uploadAutomaticAirdrops handles exceptions", async () => { render(); await waitFor(() => { - expect(screen.getByText("Upload Automatic Airdrops")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /upload artist airdrops/i }) + ).toBeInTheDocument(); }); - await user.click(screen.getByText("Upload Automatic Airdrops")); + await user.click( + screen.getByRole("button", { name: /upload artist airdrops/i }) + ); await waitFor(() => { - expect(screen.getByTestId("Automatic Airdrops")).toBeInTheDocument(); + expect(screen.getByTestId("artist-airdrops-modal")).toBeInTheDocument(); }); - await user.click(screen.getByTestId("upload-airdrops-button")); + await user.click(screen.getByTestId("upload-artist-airdrops-button")); await waitFor(() => { expect(authCtx.setToast).toHaveBeenCalledWith({ @@ -350,3 +597,176 @@ test("uploadAutomaticAirdrops handles exceptions", async () => { }); }); }); + +test("disables phase csv downloads when there are no values", async () => { + (isSubscriptionsAdmin as jest.Mock).mockReturnValue(true); + + render(); + + const artistDownloadButton = await screen.findByRole("button", { + name: /download artist airdrops csv/i, + }); + const teamDownloadButton = await screen.findByRole("button", { + name: /download team airdrops csv/i, + }); + + expect(artistDownloadButton).toBeDisabled(); + expect(teamDownloadButton).toBeDisabled(); +}); + +test("downloads artist airdrops csv with the response filename", async () => { + const user = userEvent.setup(); + (isSubscriptionsAdmin as jest.Mock).mockReturnValue(true); + mockGetStagingAuth.mockReturnValue("staging-token"); + mockGetAuthJwt.mockReturnValue("wallet-token"); + + jest + .spyOn(require("@/services/api/common-api"), "commonApiFetch") + .mockResolvedValue( + createOverview({ + artist_airdrops_addresses: 2, + artist_airdrops_count: 15, + }) + ); + + render(); + + const downloadButton = await screen.findByRole("button", { + name: /download artist airdrops csv/i, + }); + + await waitFor(() => expect(downloadButton).toBeEnabled()); + await user.click(downloadButton); + + await waitFor(() => { + expect(mockUseDownloader).toHaveBeenCalledWith(); + expect(mockDownload).toHaveBeenCalledWith( + expect.stringContaining( + "/api/distributions/0x33FD426905F149f8376e227d0C9D3340AaD17aF1/123/artist-airdrops" + ), + "artist_airdrops_123.csv", + undefined, + { + headers: { + Accept: "text/csv", + "x-6529-auth": "staging-token", + Authorization: "Bearer wallet-token", + }, + } + ); + }); +}); + +test("downloads team airdrops csv with the response filename", async () => { + const user = userEvent.setup(); + (isSubscriptionsAdmin as jest.Mock).mockReturnValue(true); + + jest + .spyOn(require("@/services/api/common-api"), "commonApiFetch") + .mockResolvedValue( + createOverview({ + team_airdrops_addresses: 1, + team_airdrops_count: 5, + }) + ); + + render(); + + const downloadButton = await screen.findByRole("button", { + name: /download team airdrops csv/i, + }); + + await waitFor(() => expect(downloadButton).toBeEnabled()); + await user.click(downloadButton); + + await waitFor(() => { + expect(mockDownload).toHaveBeenCalledWith( + "https://api.test.6529.io/api/distributions/0x33FD426905F149f8376e227d0C9D3340AaD17aF1/123/team-airdrops", + "team_airdrops_123.csv", + undefined, + { + headers: { + Accept: "text/csv", + }, + } + ); + }); +}); + +test("artist download loading state does not disable team download", async () => { + const user = userEvent.setup(); + (isSubscriptionsAdmin as jest.Mock).mockReturnValue(true); + + let resolveDownload: (() => void) | null = null; + mockDownload.mockImplementation( + () => + new Promise((resolve) => { + resolveDownload = resolve; + }) + ); + + jest + .spyOn(require("@/services/api/common-api"), "commonApiFetch") + .mockResolvedValue( + createOverview({ + artist_airdrops_addresses: 2, + artist_airdrops_count: 15, + team_airdrops_addresses: 1, + team_airdrops_count: 5, + }) + ); + + render(); + + const artistDownloadButton = await screen.findByRole("button", { + name: /download artist airdrops csv/i, + }); + const teamDownloadButton = await screen.findByRole("button", { + name: /download team airdrops csv/i, + }); + + await waitFor(() => { + expect(artistDownloadButton).toBeEnabled(); + expect(teamDownloadButton).toBeEnabled(); + }); + + await user.click(artistDownloadButton); + + await waitFor(() => { + expect(artistDownloadButton).toBeDisabled(); + expect(teamDownloadButton).toBeEnabled(); + }); + + resolveDownload?.(); + + await waitFor(() => { + expect(artistDownloadButton).toBeEnabled(); + }); +}); + +test("shows an admin-facing error when the downloader reports one", async () => { + (isSubscriptionsAdmin as jest.Mock).mockReturnValue(true); + + jest + .spyOn(require("@/services/api/common-api"), "commonApiFetch") + .mockResolvedValue( + createOverview({ + artist_airdrops_addresses: 1, + artist_airdrops_count: 5, + }) + ); + + mockUseDownloader.mockReturnValue({ + download: mockDownload, + error: { errorMessage: "wallet is not authorized" }, + }); + + render(); + + await waitFor(() => { + expect(authCtx.setToast).toHaveBeenCalledWith({ + type: "error", + message: "wallet is not authorized", + }); + }); +}); diff --git a/__tests__/components/drop-forge/launch/drop-forge-launch-claim-page-client.helpers.test.ts b/__tests__/components/drop-forge/launch/drop-forge-launch-claim-page-client.helpers.test.ts new file mode 100644 index 0000000000..24c82c0bfc --- /dev/null +++ b/__tests__/components/drop-forge/launch/drop-forge-launch-claim-page-client.helpers.test.ts @@ -0,0 +1,142 @@ +import { getAutoSelectedLaunchPhase } from "@/components/drop-forge/launch/drop-forge-launch-claim-page-client.helpers"; + +describe("getAutoSelectedLaunchPhase", () => { + const phases = [ + { + key: "phase0" as const, + schedule: { + startMs: 1_000, + endMs: 2_000, + }, + }, + { + key: "phase1" as const, + schedule: { + startMs: 3_000, + endMs: 4_000, + }, + }, + { + key: "phase2" as const, + schedule: { + startMs: 5_000, + endMs: 6_000, + }, + }, + { + key: "publicphase" as const, + schedule: { + startMs: 7_000, + endMs: 8_000, + }, + }, + ]; + + it("returns blank when metadata is not published", () => { + expect( + getAutoSelectedLaunchPhase({ + hasPublishedMetadata: false, + isInitialized: true, + nowMs: 1_500, + phases, + }) + ).toBe(""); + }); + + it("keeps phase0 selected until the claim is initialized", () => { + expect( + getAutoSelectedLaunchPhase({ + hasPublishedMetadata: true, + isInitialized: false, + nowMs: 7_500, + phases, + }) + ).toBe("phase0"); + }); + + it("selects phase0 before it starts and while it is active", () => { + expect( + getAutoSelectedLaunchPhase({ + hasPublishedMetadata: true, + isInitialized: true, + nowMs: 500, + phases, + }) + ).toBe("phase0"); + + expect( + getAutoSelectedLaunchPhase({ + hasPublishedMetadata: true, + isInitialized: true, + nowMs: 1_500, + phases, + }) + ).toBe("phase0"); + }); + + it("moves to the next upcoming phase after a phase ends", () => { + expect( + getAutoSelectedLaunchPhase({ + hasPublishedMetadata: true, + isInitialized: true, + nowMs: 2_500, + phases, + }) + ).toBe("phase1"); + + expect( + getAutoSelectedLaunchPhase({ + hasPublishedMetadata: true, + isInitialized: true, + nowMs: 4_500, + phases, + }) + ).toBe("phase2"); + }); + + it("selects public phase until it ends, then research", () => { + expect( + getAutoSelectedLaunchPhase({ + hasPublishedMetadata: true, + isInitialized: true, + nowMs: 7_500, + phases, + }) + ).toBe("publicphase"); + + expect( + getAutoSelectedLaunchPhase({ + hasPublishedMetadata: true, + isInitialized: true, + nowMs: 8_001, + phases, + }) + ).toBe("research"); + }); + + it("does not fall back to phase0 when phase0's schedule is null but later phases still exist", () => { + expect( + getAutoSelectedLaunchPhase({ + hasPublishedMetadata: true, + isInitialized: true, + nowMs: 8_001, + phases: phases.map((phase, index) => + index === 0 ? { ...phase, schedule: null } : phase + ), + }) + ).toBe("research"); + }); + + it("ignores a null schedule on a later phase when an earlier valid phase still matches", () => { + expect( + getAutoSelectedLaunchPhase({ + hasPublishedMetadata: true, + isInitialized: true, + nowMs: 3_500, + phases: phases.map((phase, index) => + index === 2 ? { ...phase, schedule: null } : phase + ), + }) + ).toBe("phase1"); + }); +}); diff --git a/__tests__/services/api/memes-minting-claims-api.test.ts b/__tests__/services/api/memes-minting-claims-api.test.ts new file mode 100644 index 0000000000..ab61458c08 --- /dev/null +++ b/__tests__/services/api/memes-minting-claims-api.test.ts @@ -0,0 +1,139 @@ +import { MEMES_CONTRACT } from "@/constants/constants"; +import { + getDistributionAirdropsArtist, + getDistributionAirdropsTeam, + getMemesMintingClaimActions, + getMemesMintingClaimActionTypes, + upsertMemesMintingClaimAction, +} from "@/services/api/memes-minting-claims-api"; +import { getAuthJwt, getStagingAuth } from "@/services/auth/auth.utils"; + +jest.mock("@/services/auth/auth.utils", () => ({ + getStagingAuth: jest.fn(), + getAuthJwt: jest.fn(), +})); + +const fetchMock = jest.fn() as jest.MockedFunction; +globalThis.fetch = fetchMock; + +function mockFetchOk(body: T): Response { + return { + ok: true, + json: async () => body, + } as Response; +} + +describe("memes-minting-claims-api", () => { + const encodedContract = encodeURIComponent(MEMES_CONTRACT); + + beforeEach(() => { + jest.clearAllMocks(); + fetchMock.mockReset(); + (getStagingAuth as jest.Mock).mockReturnValue(null); + (getAuthJwt as jest.Mock).mockReturnValue(null); + }); + + it("fetches supported MEMES claim action types", async () => { + fetchMock.mockResolvedValue( + mockFetchOk({ + contract: MEMES_CONTRACT, + action_types: ["ARTIST_AIRDROP", "TEAM_AIRDROP"], + }) + ); + + const response = await getMemesMintingClaimActionTypes(); + + expect(fetchMock).toHaveBeenCalledWith( + `https://api.test.6529.io/api/minting-claims/actions/${encodedContract}/types`, + expect.objectContaining({ + method: "GET", + }) + ); + expect(response.action_types).toEqual(["ARTIST_AIRDROP", "TEAM_AIRDROP"]); + }); + + it("fetches MEMES claim actions by claim id", async () => { + fetchMock.mockResolvedValue( + mockFetchOk({ + contract: MEMES_CONTRACT, + claim_id: 123, + actions: [{ action: "ARTIST_AIRDROP", completed: false }], + }) + ); + + const response = await getMemesMintingClaimActions(123); + const [firstAction] = response.actions; + + expect(fetchMock).toHaveBeenCalledWith( + `https://api.test.6529.io/api/minting-claims/actions/${encodedContract}/123`, + expect.objectContaining({ + method: "GET", + }) + ); + expect(response.actions).toHaveLength(1); + expect(firstAction?.action).toBe("ARTIST_AIRDROP"); + }); + + it("upserts a MEMES claim action", async () => { + (getAuthJwt as jest.Mock).mockReturnValue("jwt"); + fetchMock.mockResolvedValue( + mockFetchOk({ + contract: MEMES_CONTRACT, + claim_id: 123, + actions: [{ action: "ARTIST_AIRDROP", completed: true }], + }) + ); + + const response = await upsertMemesMintingClaimAction(123, { + action: "ARTIST_AIRDROP", + completed: true, + }); + const [firstAction] = response.actions; + + expect(fetchMock).toHaveBeenCalledWith( + `https://api.test.6529.io/api/minting-claims/actions/${encodedContract}/123`, + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ + Authorization: "Bearer jwt", + "Content-Type": "application/json", + }), + body: JSON.stringify({ + action: "ARTIST_AIRDROP", + completed: true, + }), + }) + ); + expect(firstAction?.completed).toBe(true); + }); + + it("fetches artist airdrops from the split json endpoint", async () => { + fetchMock.mockResolvedValue(mockFetchOk([{ wallet: "0xabc", amount: 2 }])); + + const response = await getDistributionAirdropsArtist(MEMES_CONTRACT, 123); + + expect(fetchMock).toHaveBeenCalledWith( + `https://api.test.6529.io/api/distributions/${MEMES_CONTRACT}/123/artist-airdrops`, + expect.objectContaining({ + method: "GET", + headers: {}, + }) + ); + expect(response).toEqual([{ wallet: "0xabc", amount: 2 }]); + }); + + it("fetches team airdrops from the split json endpoint", async () => { + fetchMock.mockResolvedValue(mockFetchOk([{ wallet: "0xdef", amount: 1 }])); + + const response = await getDistributionAirdropsTeam(MEMES_CONTRACT, 123); + + expect(fetchMock).toHaveBeenCalledWith( + `https://api.test.6529.io/api/distributions/${MEMES_CONTRACT}/123/team-airdrops`, + expect.objectContaining({ + method: "GET", + headers: {}, + }) + ); + expect(response).toEqual([{ wallet: "0xdef", amount: 1 }]); + }); +}); diff --git a/components/distribution-plan-tool/review-distribution-plan/table/ReviewDistributionPlanTableSubscriptionFooter.tsx b/components/distribution-plan-tool/review-distribution-plan/table/ReviewDistributionPlanTableSubscriptionFooter.tsx index 224f3101bb..b9323dbca5 100644 --- a/components/distribution-plan-tool/review-distribution-plan/table/ReviewDistributionPlanTableSubscriptionFooter.tsx +++ b/components/distribution-plan-tool/review-distribution-plan/table/ReviewDistributionPlanTableSubscriptionFooter.tsx @@ -1,24 +1,47 @@ "use client"; -import Image from "next/image"; -import { useCallback, useContext, useEffect, useMemo, useState } from "react"; import type { AllowlistDescription } from "@/components/allowlist-tool/allowlist-tool.types"; import { AuthContext } from "@/components/auth/Auth"; import CircleLoader from "@/components/distribution-plan-tool/common/CircleLoader"; import { DistributionPlanToolContext } from "@/components/distribution-plan-tool/DistributionPlanToolContext"; +import { publicEnv } from "@/config/env"; import { MEMES_CONTRACT } from "@/constants/constants"; import { useSeizeSettings } from "@/contexts/SeizeSettingsContext"; +import { ApiDistributionAirdropsCsvUploadRequest } from "@/generated/models/ApiDistributionAirdropsCsvUploadRequest"; +import { ApiDistributionAirdropsUploadResponse } from "@/generated/models/ApiDistributionAirdropsUploadResponse"; import { DistributionOverview } from "@/generated/models/DistributionOverview"; +import { DistributionPhoto } from "@/generated/models/DistributionPhoto"; +import { PhaseAirdrop } from "@/generated/models/PhaseAirdrop"; import { formatAddress } from "@/helpers/Helpers"; +import { fetchAllPages } from "@/services/6529api"; import { commonApiFetch, commonApiPost } from "@/services/api/common-api"; +import { getAuthJwt, getStagingAuth } from "@/services/auth/auth.utils"; import { uploadDistributionPhotos } from "@/services/distribution/distributionPhotoUpload"; +import { faDownload, faUpload } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import Image from "next/image"; +import { + type ReactNode, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import useDownloader from "react-use-downloader"; import { isSubscriptionsAdmin } from "./ReviewDistributionPlanTableSubscription"; -import { AutomaticAirdropsModal } from "./ReviewDistributionPlanTableSubscriptionFooterAutomaticAirdrops"; +import { + DistributionAirdropsPhase, + DistributionPhaseAirdropsModal, +} from "./ReviewDistributionPlanTableSubscriptionFooterPhaseAirdrops"; import { ConfirmTokenIdModal } from "./ReviewDistributionPlanTableSubscriptionFooterConfirmTokenId"; +import { DistributionPhaseAirdropsViewerModal } from "./ReviewDistributionPlanTableSubscriptionFooterPhaseAirdropsViewer"; import { GithubUploadModal, type GithubUploadResult, } from "./ReviewDistributionPlanTableSubscriptionFooterGithubUploadModal"; +import { DistributionPhotosViewerModal } from "./ReviewDistributionPlanTableSubscriptionFooterPhotosViewer"; import { UploadDistributionPhotosModal } from "./ReviewDistributionPlanTableSubscriptionFooterUploadPhotos"; function getErrorMessage(error: unknown): string { @@ -31,6 +54,38 @@ function getErrorMessage(error: unknown): string { return "Something went wrong."; } +function getAirdropsAddresses( + overview: DistributionOverview | null, + phase: DistributionAirdropsPhase +): number { + if (phase === "artist") { + return overview?.artist_airdrops_addresses ?? 0; + } + + return overview?.team_airdrops_addresses ?? 0; +} + +function getAirdropsCount( + overview: DistributionOverview | null, + phase: DistributionAirdropsPhase +): number { + if (phase === "artist") { + return overview?.artist_airdrops_count ?? 0; + } + + return overview?.team_airdrops_count ?? 0; +} + +function getTotalAirdropsCount(overview: DistributionOverview | null): number { + return ( + getAirdropsCount(overview, "artist") + getAirdropsCount(overview, "team") + ); +} + +function getAirdropsPhaseLabel(phase: DistributionAirdropsPhase): string { + return phase === "artist" ? "Artist" : "Team"; +} + function getGithubUploadTooltip( overview: DistributionOverview | null ): string | null { @@ -38,8 +93,8 @@ function getGithubUploadTooltip( if ((overview?.photos_count ?? 0) === 0) { return "Upload distribution photos first"; } - if ((overview?.automatic_airdrops_count ?? 0) === 0) { - return "Upload automatic airdrops first"; + if (getTotalAirdropsCount(overview) === 0) { + return "Upload artist or team airdrops first"; } return null; } @@ -50,7 +105,7 @@ function canPublishToGithub(overview: DistributionOverview | null): boolean { return ( overview?.is_normalized === true && (overview?.photos_count ?? 0) > 0 && - (overview?.automatic_airdrops_count ?? 0) > 0 + getTotalAirdropsCount(overview) > 0 ); } @@ -62,7 +117,8 @@ function SubscriptionFooterMain({ isLoadingOverview, isResetting, isUploading, - isUploadingAirdrops, + uploadingAirdropsPhase, + downloadingAirdropsPhases, isFinalizing, isUploadingToGithub, showGithubModal, @@ -70,21 +126,34 @@ function SubscriptionFooterMain({ githubUploadError, showConfirmTokenId, showUploadPhotos, - showAutomaticAirdrops, + showViewPhotos, + showUploadAirdropsPhase, + showViewAirdropsPhase, + viewedPhotos, + viewedPhotosError, + viewedAirdrops, + viewedAirdropsError, + viewingPhotos, + viewingAirdropsPhase, canPublish, githubUploadTooltip, onConfirmTokenId, onChangeTokenId, onResetSubscriptions, - onShowAutomaticAirdrops, + onShowUploadAirdrops, + onShowViewAirdrops, onShowUploadPhotos, + onShowViewPhotos, + onDownloadAirdrops, onFinalize, onUploadToGithub, onCloseGithubModal, onUploadPhotos, onUploadAirdrops, onCloseUploadPhotos, - onCloseAutomaticAirdrops, + onCloseViewPhotos, + onCloseUploadAirdrops, + onCloseViewAirdrops, }: Readonly<{ contract: string; confirmedTokenId: string; @@ -93,7 +162,8 @@ function SubscriptionFooterMain({ isLoadingOverview: boolean; isResetting: boolean; isUploading: boolean; - isUploadingAirdrops: boolean; + uploadingAirdropsPhase: DistributionAirdropsPhase | null; + downloadingAirdropsPhases: Record; isFinalizing: boolean; isUploadingToGithub: boolean; showGithubModal: boolean; @@ -101,14 +171,25 @@ function SubscriptionFooterMain({ githubUploadError: string | null; showConfirmTokenId: boolean; showUploadPhotos: boolean; - showAutomaticAirdrops: boolean; + showViewPhotos: boolean; + showUploadAirdropsPhase: DistributionAirdropsPhase | null; + showViewAirdropsPhase: DistributionAirdropsPhase | null; + viewedPhotos: DistributionPhoto[]; + viewedPhotosError: string | null; + viewedAirdrops: PhaseAirdrop[]; + viewedAirdropsError: string | null; + viewingPhotos: boolean; + viewingAirdropsPhase: DistributionAirdropsPhase | null; canPublish: boolean; githubUploadTooltip: string | null; onConfirmTokenId: (tokenId: string) => void; onChangeTokenId: () => void; onResetSubscriptions: () => void; - onShowAutomaticAirdrops: () => void; + onShowUploadAirdrops: (phase: DistributionAirdropsPhase) => void; + onShowViewAirdrops: (phase: DistributionAirdropsPhase) => void; onShowUploadPhotos: () => void; + onShowViewPhotos: () => void; + onDownloadAirdrops: (phase: DistributionAirdropsPhase) => void; onFinalize: () => void; onUploadToGithub: () => void; onCloseGithubModal: () => void; @@ -120,11 +201,117 @@ function SubscriptionFooterMain({ onUploadAirdrops: ( contract: string, tokenId: string, + phase: DistributionAirdropsPhase, csvContent: string - ) => Promise; + ) => Promise; onCloseUploadPhotos: () => void; - onCloseAutomaticAirdrops: () => void; + onCloseViewPhotos: () => void; + onCloseUploadAirdrops: () => void; + onCloseViewAirdrops: () => void; }>) { + const softControlHoverClasses = + "tw-transition tw-duration-150 tw-ease-out enabled:hover:tw-bg-[#eceae4] enabled:hover:tw-text-iron-900 enabled:active:tw-bg-[#e5e2db] enabled:focus-visible:tw-bg-[#eceae4] disabled:tw-cursor-not-allowed disabled:tw-bg-iron-300 disabled:tw-text-iron-700 disabled:tw-opacity-100"; + const renderStableButtonContent = ({ + isLoading, + idleContent, + loadingContent, + }: { + isLoading: boolean; + idleContent: ReactNode; + loadingContent: ReactNode; + }) => ( + + {idleContent} + {isLoading && ( + + {loadingContent} + + )} + + ); + + const renderAirdropsButtonGroup = (phase: DistributionAirdropsPhase) => { + const phaseLabel = getAirdropsPhaseLabel(phase); + const isUploadingThisPhase = uploadingAirdropsPhase === phase; + const isViewingThisPhase = viewingAirdropsPhase === phase; + const isDownloadingThisPhase = downloadingAirdropsPhases[phase]; + const isDownloadDisabled = + isLoadingOverview || + isDownloadingThisPhase || + getAirdropsCount(overview, phase) === 0; + + return ( +
+ + + +
+ ); + }; + return (
@@ -157,85 +344,93 @@ function SubscriptionFooterMain({
-
- - + + ), + idleContent: ( + <> + Distribution Photos + {isLoadingOverview ? ( + + + + ) : ( + + {overview?.photos_count ?? 0} + + )} + + ), + })} + +
-
+
- {isUploadingToGithub ? ( - - - Publishing… - - ) : ( - <> - - Publish to GitHub - - )} + {renderStableButtonContent({ + isLoading: isUploadingToGithub, + loadingContent: ( + + + Publishing… + + ), + idleContent: ( + <> + + Publish to GitHub + + ), + })}
@@ -281,26 +480,50 @@ function SubscriptionFooterMain({ /> {distributionPlan && ( <> - - - + {showConfirmTokenId && ( + + )} + {showUploadPhotos && ( + + )} + {showViewPhotos && ( + + )} + {showUploadAirdropsPhase !== null && ( + + )} + {showViewAirdropsPhase !== null && ( + + )} )}
@@ -321,11 +544,16 @@ export function ReviewDistributionPlanTableSubscriptionFooter() { ); const [showUploadPhotos, setShowUploadPhotos] = useState(false); - const [showAutomaticAirdrops, setShowAutomaticAirdrops] = useState(false); + const [showViewPhotos, setShowViewPhotos] = useState(false); + const [showUploadAirdropsPhase, setShowUploadAirdropsPhase] = + useState(null); + const [showViewAirdropsPhase, setShowViewAirdropsPhase] = + useState(null); const [showConfirmTokenId, setShowConfirmTokenId] = useState(false); const [isResetting, setIsResetting] = useState(false); const [isUploading, setIsUploading] = useState(false); - const [isUploadingAirdrops, setIsUploadingAirdrops] = useState(false); + const [uploadingAirdropsPhase, setUploadingAirdropsPhase] = + useState(null); const [isFinalizing, setIsFinalizing] = useState(false); const [isUploadingToGithub, setIsUploadingToGithub] = useState(false); const [showGithubModal, setShowGithubModal] = useState(false); @@ -336,28 +564,82 @@ export function ReviewDistributionPlanTableSubscriptionFooter() { ); const [overview, setOverview] = useState(null); const [isLoadingOverview, setIsLoadingOverview] = useState(false); + const [viewingPhotos, setViewingPhotos] = useState(false); + const [viewedPhotos, setViewedPhotos] = useState([]); + const [viewedPhotosError, setViewedPhotosError] = useState( + null + ); + const [downloadingAirdropsPhases, setDownloadingAirdropsPhases] = useState< + Record + >({ + artist: false, + team: false, + }); + const [viewingAirdropsPhase, setViewingAirdropsPhase] = + useState(null); + const [viewedAirdrops, setViewedAirdrops] = useState([]); + const [viewedAirdropsError, setViewedAirdropsError] = useState( + null + ); + + const buildAirdropsDownloadHeaders = useCallback(() => { + const headers: Record = { + Accept: "text/csv", + }; + const apiAuth = getStagingAuth(); + const walletAuth = getAuthJwt(); + + if (apiAuth) { + headers["x-6529-auth"] = apiAuth; + } + if (walletAuth) { + headers["Authorization"] = `Bearer ${walletAuth}`; + } + + return headers; + }, []); + const { download: downloadAirdropsCsv, error: airdropsDownloadError } = + useDownloader(); + const overviewRequestIdRef = useRef(0); + const photosViewerRequestIdRef = useRef(0); + const airdropsViewerRequestIdRef = useRef(0); const githubUploadTooltip = getGithubUploadTooltip(overview); + const clearOverviewState = useCallback(() => { + overviewRequestIdRef.current += 1; + setOverview(null); + setIsLoadingOverview(false); + }, []); + const refreshOverview = useCallback( async (contract: string, tokenId?: string) => { if (!tokenId) { + clearOverviewState(); return; } + const requestId = overviewRequestIdRef.current + 1; + overviewRequestIdRef.current = requestId; + setOverview(null); setIsLoadingOverview(true); try { const data = await commonApiFetch({ endpoint: `distributions/${contract}/${tokenId}/overview`, }); + if (overviewRequestIdRef.current !== requestId) { + return; + } setOverview(data); } catch (error) { console.error("Failed to fetch distribution overview:", error); } finally { - setIsLoadingOverview(false); + if (overviewRequestIdRef.current === requestId) { + setIsLoadingOverview(false); + } } }, - [] + [clearOverviewState] ); useEffect(() => { @@ -373,22 +655,35 @@ export function ReviewDistributionPlanTableSubscriptionFooter() { return; } - const contract = MEMES_CONTRACT; - refreshOverview(contract, confirmedTokenId); + refreshOverview(MEMES_CONTRACT, confirmedTokenId); }, [ - distributionPlan, - connectedProfile, confirmedTokenId, - refreshOverview, + connectedProfile, distributionAdminWallets, + distributionPlan, + refreshOverview, ]); + useEffect(() => { + if (!airdropsDownloadError?.errorMessage) { + return; + } + + setToast({ + type: "error", + message: airdropsDownloadError.errorMessage, + }); + }, [airdropsDownloadError, setToast]); + const handleConfirmTokenId = (tokenId: string) => { + clearOverviewState(); setConfirmedTokenId(tokenId); setShowConfirmTokenId(false); }; const handleChangeTokenId = () => { + clearOverviewState(); + setConfirmedTokenId(null); setShowConfirmTokenId(true); }; @@ -407,7 +702,6 @@ export function ReviewDistributionPlanTableSubscriptionFooter() { type: "success", message: "Subscriptions reset successfully.", }); - await refreshOverview(contract, tokenId); } catch (error: unknown) { setToast({ @@ -498,47 +792,167 @@ export function ReviewDistributionPlanTableSubscriptionFooter() { setIsUploading(false); } }, - [setToast, refreshOverview] + [refreshOverview, setToast] ); + const handleShowViewPhotos = useCallback( + async (contract: string, tokenId: string) => { + const requestId = photosViewerRequestIdRef.current + 1; + photosViewerRequestIdRef.current = requestId; + setShowViewPhotos(true); + setViewingPhotos(true); + setViewedPhotos([]); + setViewedPhotosError(null); + + try { + const data = await fetchAllPages( + `${publicEnv.API_ENDPOINT}/api/distribution_photos/${contract}/${tokenId}` + ); + if (photosViewerRequestIdRef.current !== requestId) { + return; + } + setViewedPhotos(data); + } catch (error: unknown) { + if (photosViewerRequestIdRef.current !== requestId) { + return; + } + setViewedPhotosError(getErrorMessage(error)); + } finally { + if (photosViewerRequestIdRef.current === requestId) { + setViewingPhotos(false); + } + } + }, + [] + ); + + const handleCloseViewPhotos = useCallback(() => { + photosViewerRequestIdRef.current += 1; + setShowViewPhotos(false); + setViewingPhotos(false); + setViewedPhotos([]); + setViewedPhotosError(null); + }, []); + const handleUploadAirdrops = useCallback( - async (contract: string, tokenId: string, csvContent: string) => { - setShowAutomaticAirdrops(false); - setIsUploadingAirdrops(true); + async ( + contract: string, + tokenId: string, + phase: DistributionAirdropsPhase, + csvContent: string + ): Promise => { + setUploadingAirdropsPhase(phase); try { const response = await commonApiPost< - { csv: string }, - { - success: boolean; - message?: string | undefined; + ApiDistributionAirdropsCsvUploadRequest, + ApiDistributionAirdropsUploadResponse & { error?: string | undefined; } >({ - endpoint: `distributions/${contract}/${tokenId}/automatic_airdrops`, + endpoint: `distributions/${contract}/${tokenId}/${phase}-airdrops`, body: { csv: csvContent }, }); + if (response.success) { setToast({ type: "success", message: - response.message || "Successfully uploaded automatic airdrops", + response.message || `Successfully uploaded ${phase} airdrops`, }); await refreshOverview(contract, tokenId); - } else { - setToast({ - type: "error", - message: response.error || "Upload failed", - }); + return true; } + + setToast({ + type: "error", + message: response.error || "Upload failed", + }); } catch (error: unknown) { - setToast({ type: "error", message: getErrorMessage(error) }); + setToast({ + type: "error", + message: getErrorMessage(error), + }); + } finally { + setUploadingAirdropsPhase(null); + } + + return false; + }, + [refreshOverview, setToast] + ); + + const handleDownloadAirdrops = useCallback( + async ( + contract: string, + tokenId: string, + phase: DistributionAirdropsPhase + ) => { + setDownloadingAirdropsPhases((current) => ({ + ...current, + [phase]: true, + })); + try { + await downloadAirdropsCsv( + `${publicEnv.API_ENDPOINT}/api/distributions/${contract}/${tokenId}/${phase}-airdrops`, + `${phase}_airdrops_${tokenId}.csv`, + undefined, + { + headers: buildAirdropsDownloadHeaders(), + } + ); + } finally { + setDownloadingAirdropsPhases((current) => ({ + ...current, + [phase]: false, + })); + } + }, + [buildAirdropsDownloadHeaders, downloadAirdropsCsv] + ); + + const handleShowViewAirdrops = useCallback( + async ( + contract: string, + tokenId: string, + phase: DistributionAirdropsPhase + ) => { + const requestId = airdropsViewerRequestIdRef.current + 1; + airdropsViewerRequestIdRef.current = requestId; + setShowViewAirdropsPhase(phase); + setViewingAirdropsPhase(phase); + setViewedAirdrops([]); + setViewedAirdropsError(null); + + try { + const data = await commonApiFetch({ + endpoint: `distributions/${contract}/${tokenId}/${phase}-airdrops`, + }); + if (airdropsViewerRequestIdRef.current !== requestId) { + return; + } + setViewedAirdrops(data); + } catch (error: unknown) { + if (airdropsViewerRequestIdRef.current !== requestId) { + return; + } + setViewedAirdropsError(getErrorMessage(error)); } finally { - setIsUploadingAirdrops(false); + if (airdropsViewerRequestIdRef.current === requestId) { + setViewingAirdropsPhase(null); + } } }, - [setToast, refreshOverview] + [] ); + const handleCloseViewAirdrops = useCallback(() => { + airdropsViewerRequestIdRef.current += 1; + setShowViewAirdropsPhase(null); + setViewingAirdropsPhase(null); + setViewedAirdrops([]); + setViewedAirdropsError(null); + }, []); + if (!isSubscriptionsAdmin(connectedProfile, distributionAdminWallets)) { return <>; } @@ -549,7 +963,6 @@ export function ReviewDistributionPlanTableSubscriptionFooter() { {distributionPlan && ( )} @@ -573,7 +986,8 @@ export function ReviewDistributionPlanTableSubscriptionFooter() { isLoadingOverview={isLoadingOverview} isResetting={isResetting} isUploading={isUploading} - isUploadingAirdrops={isUploadingAirdrops} + uploadingAirdropsPhase={uploadingAirdropsPhase} + downloadingAirdropsPhases={downloadingAirdropsPhases} isFinalizing={isFinalizing} isUploadingToGithub={isUploadingToGithub} showGithubModal={showGithubModal} @@ -581,21 +995,38 @@ export function ReviewDistributionPlanTableSubscriptionFooter() { githubUploadError={githubUploadError} showConfirmTokenId={showConfirmTokenId} showUploadPhotos={showUploadPhotos} - showAutomaticAirdrops={showAutomaticAirdrops} + showViewPhotos={showViewPhotos} + showUploadAirdropsPhase={showUploadAirdropsPhase} + showViewAirdropsPhase={showViewAirdropsPhase} + viewedPhotos={viewedPhotos} + viewedPhotosError={viewedPhotosError} + viewedAirdrops={viewedAirdrops} + viewedAirdropsError={viewedAirdropsError} + viewingPhotos={viewingPhotos} + viewingAirdropsPhase={viewingAirdropsPhase} canPublish={canPublishToGithub(overview)} githubUploadTooltip={githubUploadTooltip} onConfirmTokenId={handleConfirmTokenId} onChangeTokenId={handleChangeTokenId} onResetSubscriptions={handleResetSubscriptions} - onShowAutomaticAirdrops={() => setShowAutomaticAirdrops(true)} + onShowUploadAirdrops={setShowUploadAirdropsPhase} + onShowViewAirdrops={(phase) => + handleShowViewAirdrops(contract, confirmedTokenId, phase) + } onShowUploadPhotos={() => setShowUploadPhotos(true)} + onShowViewPhotos={() => handleShowViewPhotos(contract, confirmedTokenId)} + onDownloadAirdrops={(phase) => + handleDownloadAirdrops(contract, confirmedTokenId, phase) + } onFinalize={() => finalizeDistribution(contract, confirmedTokenId)} onUploadToGithub={() => uploadToGithub(contract, confirmedTokenId)} onCloseGithubModal={() => setShowGithubModal(false)} onUploadPhotos={handleUploadPhotos} onUploadAirdrops={handleUploadAirdrops} onCloseUploadPhotos={() => setShowUploadPhotos(false)} - onCloseAutomaticAirdrops={() => setShowAutomaticAirdrops(false)} + onCloseViewPhotos={handleCloseViewPhotos} + onCloseUploadAirdrops={() => setShowUploadAirdropsPhase(null)} + onCloseViewAirdrops={handleCloseViewAirdrops} /> ); } diff --git a/components/distribution-plan-tool/review-distribution-plan/table/ReviewDistributionPlanTableSubscriptionFooterAutomaticAirdrops.tsx b/components/distribution-plan-tool/review-distribution-plan/table/ReviewDistributionPlanTableSubscriptionFooterAutomaticAirdrops.tsx deleted file mode 100644 index af0483cec3..0000000000 --- a/components/distribution-plan-tool/review-distribution-plan/table/ReviewDistributionPlanTableSubscriptionFooterAutomaticAirdrops.tsx +++ /dev/null @@ -1,305 +0,0 @@ -"use client"; - -import type { AllowlistDescription } from "@/components/allowlist-tool/allowlist-tool.types"; -import { MEMES_CONTRACT } from "@/constants/constants"; -import { - extractAllNumbers, - formatAddress, - isValidPositiveInteger, -} from "@/helpers/Helpers"; -import { useRef, useState } from "react"; -import { Button, Col, Container, Modal, Row } from "react-bootstrap"; - -interface CsvRow { - address: string; - count: number; -} - -export function AutomaticAirdropsModal( - props: Readonly<{ - plan: AllowlistDescription; - show: boolean; - handleClose(): void; - confirmedTokenId?: string | null | undefined; - onUpload(contract: string, tokenId: string, csvContent: string): void; - }> -) { - const numbers = extractAllNumbers(props.plan.name); - const initialTokenId = numbers.length > 0 ? numbers[0]?.toString() : ""; - const defaultTokenId = isValidPositiveInteger(initialTokenId!) - ? initialTokenId! - : ""; - const [tokenId, setTokenId] = useState( - props.confirmedTokenId ?? defaultTokenId - ); - const displayTokenId = props.confirmedTokenId ?? tokenId; - const [selectedFile, setSelectedFile] = useState(null); - const [fileError, setFileError] = useState(null); - const [parsedRows, setParsedRows] = useState([]); - const fileInputRef = useRef(null); - - const contract = MEMES_CONTRACT; - - const MAX_FILE_SIZE = 10 * 1024 * 1024; - - const isValidAddress = (address: string): boolean => { - return /^0x[a-f0-9]{40}$/i.test(address.trim()); - }; - - const isHeaderRow = (line: string): boolean => { - const parts = line.split(",").map((part) => part.trim().toLowerCase()); - if (parts.length < 2) return false; - const validFirstCol = parts[0] === "address" || parts[0] === "wallet"; - const validSecondCol = parts[1] === "count" || parts[1] === "value"; - return validFirstCol && validSecondCol; - }; - - const parseCsv = ( - csvContent: string - ): { rows: CsvRow[]; hadHeader: boolean } => { - let lines = csvContent.split(/\r?\n/).filter((line) => line.trim()); - const rows: CsvRow[] = []; - let hadHeader = false; - - const firstLine = lines[0]; - if (firstLine && isHeaderRow(firstLine)) { - hadHeader = true; - lines = lines.slice(1); - } - - const lineOffset = hadHeader ? 2 : 1; - - lines.forEach((line, index) => { - const trimmedLine = line.trim(); - if (!trimmedLine) return; - - const parts = trimmedLine.split(",").map((part) => part.trim()); - if (parts.length < 2) { - throw new Error( - `Line ${ - index + lineOffset - }: Expected format "address,count" but found "${trimmedLine}"` - ); - } - - const address = parts[0]; - const countStr = parts[1]; - - if (!isValidAddress(address!)) { - throw new Error( - `Line ${index + lineOffset}: Invalid Ethereum address "${address}"` - ); - } - - const count = Number.parseInt(countStr!, 10); - if (Number.isNaN(count) || count < 0) { - throw new Error( - `Line ${ - index + lineOffset - }: Invalid count "${countStr}". Must be a non-negative integer.` - ); - } - - rows.push({ - address: address!.toLowerCase(), - count, - }); - }); - - return { rows, hadHeader }; - }; - - const handleFileChange = (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file) { - setSelectedFile(null); - setParsedRows([]); - setFileError(null); - return; - } - - if (file.size > MAX_FILE_SIZE) { - setFileError(`File size exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit.`); - setSelectedFile(null); - setParsedRows([]); - if (fileInputRef.current) { - fileInputRef.current.value = ""; - } - return; - } - - file - .text() - .then((csvContent) => { - try { - const { rows } = parseCsv(csvContent); - setSelectedFile(file); - setParsedRows(rows); - setFileError(null); - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : "Failed to parse CSV file"; - setFileError(errorMessage); - setSelectedFile(null); - setParsedRows([]); - if (fileInputRef.current) { - fileInputRef.current.value = ""; - } - } - }) - .catch(() => { - setFileError("Failed to read file"); - setSelectedFile(null); - setParsedRows([]); - if (fileInputRef.current) { - fileInputRef.current.value = ""; - } - }); - }; - - const handleUpload = () => { - if (!selectedFile || parsedRows.length === 0) { - setFileError("Please select a valid CSV file"); - return; - } - - if (!isValidPositiveInteger(displayTokenId)) { - return; - } - - const csvContent = parsedRows - .map((row) => `${row.address},${row.count}`) - .join("\n"); - props.onUpload(contract, displayTokenId, csvContent); - handleClose(); - }; - - const handleClose = () => { - setSelectedFile(null); - setParsedRows([]); - setFileError(null); - if (fileInputRef.current) { - fileInputRef.current.value = ""; - } - props.handleClose(); - }; - - return ( - - - - Upload Automatic Airdrops - - -
- - - - - Contract: The Memes - {formatAddress(contract)} - - - - - Token ID:{" "} - {props.confirmedTokenId !== undefined && - props.confirmedTokenId !== null ? ( - {displayTokenId} - ) : ( - { - setTokenId(e.target.value); - }} - /> - )} - - - - -
-
- CSV format:{" "} - - address,value - -
-
- Example: -
-
-                  
-                    {`0x33FD426905F149f8376e227d0C9D3340AaD17aF1,5
-0x9f6ae0370d74f0e591c64cec4a8ae0d627817014,10`}
-                  
-                
-
- -
- - - Select CSV File:{" "} - - {fileError && ( -
-
{fileError}
-
- )} - -
- {parsedRows.length > 0 && ( - - -
- ✓ Successfully parsed {parsedRows.length} row(s) -
- -
- )} -
-
- - - - -
- ); -} diff --git a/components/distribution-plan-tool/review-distribution-plan/table/ReviewDistributionPlanTableSubscriptionFooterConfirmTokenId.tsx b/components/distribution-plan-tool/review-distribution-plan/table/ReviewDistributionPlanTableSubscriptionFooterConfirmTokenId.tsx index 449b2b95b7..8e740de1c3 100644 --- a/components/distribution-plan-tool/review-distribution-plan/table/ReviewDistributionPlanTableSubscriptionFooterConfirmTokenId.tsx +++ b/components/distribution-plan-tool/review-distribution-plan/table/ReviewDistributionPlanTableSubscriptionFooterConfirmTokenId.tsx @@ -13,7 +13,6 @@ import { Button, Col, Container, Modal, Row } from "react-bootstrap"; export function ConfirmTokenIdModal( props: Readonly<{ plan: AllowlistDescription; - show: boolean; onConfirm(tokenId: string): void; }> ) { @@ -33,12 +32,7 @@ export function ConfirmTokenIdModal( }; return ( - {}} - backdrop="static" - keyboard={false} - > + {}} backdrop="static" keyboard={false}> Confirm Token ID diff --git a/components/distribution-plan-tool/review-distribution-plan/table/ReviewDistributionPlanTableSubscriptionFooterModal.tsx b/components/distribution-plan-tool/review-distribution-plan/table/ReviewDistributionPlanTableSubscriptionFooterModal.tsx new file mode 100644 index 0000000000..45b38ed46a --- /dev/null +++ b/components/distribution-plan-tool/review-distribution-plan/table/ReviewDistributionPlanTableSubscriptionFooterModal.tsx @@ -0,0 +1,107 @@ +"use client"; + +import CircleLoader from "@/components/distribution-plan-tool/common/CircleLoader"; +import { formatAddress } from "@/helpers/Helpers"; +import type { ReactNode } from "react"; +import { Col, Container, Modal, Row } from "react-bootstrap"; + +interface ReviewDistributionPlanTableSubscriptionFooterModalProps { + readonly title: string; + readonly onClose: () => void; + readonly children: ReactNode; + readonly footer: ReactNode; + readonly size?: "sm" | "lg" | "xl"; + readonly closeButton?: boolean; + readonly bodyTestId?: string; +} + +interface ReviewDistributionPlanTableSubscriptionFooterContractRowProps { + readonly contract: string; + readonly tokenId: string; + readonly muted?: boolean; +} + +interface ReviewDistributionPlanTableSubscriptionFooterMessageRowProps { + readonly children: ReactNode; +} + +interface ReviewDistributionPlanTableSubscriptionFooterAlertRowProps extends ReviewDistributionPlanTableSubscriptionFooterMessageRowProps { + readonly variant: "danger" | "secondary"; +} + +export function ReviewDistributionPlanTableSubscriptionFooterModal({ + title, + onClose, + children, + footer, + size, + closeButton = true, + bodyTestId, +}: Readonly) { + const modalSizeProps = size ? { size } : {}; + + return ( + + + + {title} + + +
+ + {children} + + {footer} +
+ ); +} + +export function ReviewDistributionPlanTableSubscriptionFooterContractRow({ + contract, + tokenId, + muted = false, +}: Readonly) { + return ( + + + Contract: The Memes - {formatAddress(contract)} | Token ID: {tokenId} + + + ); +} + +export function ReviewDistributionPlanTableSubscriptionFooterLoadingRow({ + children, +}: Readonly) { + return ( + + + + {children} + + + ); +} + +export function ReviewDistributionPlanTableSubscriptionFooterAlertRow({ + variant, + children, +}: Readonly) { + return ( + + +
{children}
+ +
+ ); +} + +export function ReviewDistributionPlanTableSubscriptionFooterRow({ + children, +}: Readonly) { + return ( + + {children} + + ); +} diff --git a/components/distribution-plan-tool/review-distribution-plan/table/ReviewDistributionPlanTableSubscriptionFooterPhaseAirdrops.tsx b/components/distribution-plan-tool/review-distribution-plan/table/ReviewDistributionPlanTableSubscriptionFooterPhaseAirdrops.tsx new file mode 100644 index 0000000000..d48e4f3860 --- /dev/null +++ b/components/distribution-plan-tool/review-distribution-plan/table/ReviewDistributionPlanTableSubscriptionFooterPhaseAirdrops.tsx @@ -0,0 +1,385 @@ +"use client"; + +import type { AllowlistDescription } from "@/components/allowlist-tool/allowlist-tool.types"; +import { MEMES_CONTRACT } from "@/constants/constants"; +import { + extractAllNumbers, + formatAddress, + isValidPositiveInteger, +} from "@/helpers/Helpers"; +import { type ChangeEvent, useRef, useState } from "react"; +import { Button, Col, Container, Modal, Row } from "react-bootstrap"; + +export type DistributionAirdropsPhase = "artist" | "team"; + +interface CsvRow { + address: string; + count: number; +} + +const PHASE_COPY: Record< + DistributionAirdropsPhase, + { + title: string; + submitLabel: string; + successLabel: string; + } +> = { + artist: { + title: "Upload Artist Airdrops", + submitLabel: "Upload Artist Airdrops", + successLabel: "artist", + }, + team: { + title: "Upload Team Airdrops", + submitLabel: "Upload Team Airdrops", + successLabel: "team", + }, +}; + +function getErrorMessage(error: unknown): string { + if (typeof error === "string") { + return error; + } + if (error instanceof Error) { + return error.message; + } + return "Invalid CSV content"; +} + +function isValidAddress(address: string): boolean { + return /^0x[a-f0-9]{40}$/i.test(address.trim()); +} + +function isHeaderRow(line: string): boolean { + const parts = line.split(",").map((part) => part.trim().toLowerCase()); + return parts.length === 2 && parts[0] === "address" && parts[1] === "count"; +} + +function parseCsv(csvContent: string): CsvRow[] { + const lines = csvContent.split(/\r?\n/).filter((line) => line.trim()); + + if (lines.length === 0) { + throw new Error("Enter at least one address,count row."); + } + + if (isHeaderRow(lines[0]!)) { + throw new Error( + 'Do not include a header row. Use raw "address,count" lines only.' + ); + } + + return lines.map((line, index) => { + const trimmedLine = line.trim(); + const parts = trimmedLine.split(",").map((part) => part.trim()); + + if (parts.length !== 2) { + throw new Error( + `Line ${index + 1}: Expected exactly one wallet address and one count in "address,count" format.` + ); + } + + const [address, countValue] = parts; + + if (!isValidAddress(address!)) { + throw new Error( + `Line ${index + 1}: Invalid Ethereum address "${address}".` + ); + } + + if (!/^[1-9]\d*$/.test(countValue!)) { + throw new Error(`Line ${index + 1}: Count must be a positive integer.`); + } + + return { + address: address!.toLowerCase(), + count: Number.parseInt(countValue!, 10), + }; + }); +} + +export function DistributionPhaseAirdropsModal( + props: Readonly<{ + plan: AllowlistDescription; + phase: DistributionAirdropsPhase; + isUploading: boolean; + handleClose(): void; + confirmedTokenId?: string | null | undefined; + onUpload( + contract: string, + tokenId: string, + phase: DistributionAirdropsPhase, + csvContent: string + ): Promise; + }> +) { + const numbers = extractAllNumbers(props.plan.name); + const initialTokenId = numbers.length > 0 ? numbers[0]!.toString() : ""; + const defaultTokenId = isValidPositiveInteger(initialTokenId) + ? initialTokenId + : ""; + const [tokenId, setTokenId] = useState( + props.confirmedTokenId ?? defaultTokenId + ); + const displayTokenId = props.confirmedTokenId ?? tokenId; + const [csvContent, setCsvContent] = useState(""); + const [selectedFileName, setSelectedFileName] = useState(null); + const [inputError, setInputError] = useState(null); + const fileInputRef = useRef(null); + + const contract = MEMES_CONTRACT; + const MAX_FILE_SIZE = 10 * 1024 * 1024; + const copy = PHASE_COPY[props.phase]; + + let parsedRows: CsvRow[] = []; + let previewError: string | null = null; + if (csvContent.trim()) { + try { + parsedRows = parseCsv(csvContent); + } catch (error) { + previewError = getErrorMessage(error); + } + } + + const resetState = () => { + setCsvContent(""); + setSelectedFileName(null); + setInputError(null); + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + }; + + const handleClose = () => { + if (props.isUploading) { + return; + } + resetState(); + props.handleClose(); + }; + + const handleFileChange = async ( + e: ChangeEvent + ): Promise => { + const file = e.target.files?.[0]; + + if (!file) { + setSelectedFileName(null); + return; + } + + if (file.size > MAX_FILE_SIZE) { + setInputError( + `File size exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit.` + ); + setSelectedFileName(null); + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + return; + } + + try { + const nextCsvContent = await file.text(); + setCsvContent(nextCsvContent); + setSelectedFileName(file.name); + setInputError(null); + } catch { + setInputError("Failed to read file."); + setSelectedFileName(null); + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + } + }; + + const handleUpload = async () => { + setInputError(null); + + if (!isValidPositiveInteger(displayTokenId)) { + setInputError("Enter a valid positive token ID."); + return; + } + + let rows: CsvRow[]; + try { + rows = parseCsv(csvContent); + } catch (error) { + setInputError(getErrorMessage(error)); + return; + } + + const normalizedCsvContent = rows + .map((row) => `${row.address},${row.count}`) + .join("\n"); + + const didUpload = await props.onUpload( + contract, + displayTokenId, + props.phase, + normalizedCsvContent + ); + + if (didUpload) { + resetState(); + props.handleClose(); + } + }; + + return ( + + + + {copy.title} + + +
+ + + + + Contract: The Memes - {formatAddress(contract)} + + + + + Token ID:{" "} + {props.confirmedTokenId !== undefined && + props.confirmedTokenId !== null ? ( + {displayTokenId} + ) : ( + { + setTokenId(e.target.value); + }} + /> + )} + + + + +
+ This upload will replace the current {copy.successLabel}{" "} + airdrops list for this token. +
+ +
+ + +
+
+ CSV format:{" "} + + address,count + +
+
+ Do not include a header row. Each line must contain exactly + one wallet address and one positive integer count. +
+
+                  
+                    {`0x33fd426905f149f8376e227d0c9d3340aad17af1,2
+0x9f6ae0370d74f0e591c64cec4a8ae0d627817014,1`}
+                  
+                
+
+ +
+ + + +