diff --git a/package-lock.json b/package-lock.json
index c7b5f5aa..feee5e08 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -32,6 +32,7 @@
"lodash": "4.17.21",
"moment": "2.29.4",
"npm-audit-resolver": "3.0.0-RC.0",
+ "papaparse": "5.4.1",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-router-dom": "5.3.4",
@@ -52,6 +53,7 @@
"@types/jest": "29.5.4",
"@types/jwt-decode": "2.2.1",
"@types/lodash": "4.14.198",
+ "@types/papaparse": "5.3.8",
"@types/react": "18.2.21",
"@types/react-dom": "18.2.7",
"@types/react-router-dom": "5.3.3",
@@ -3539,11 +3541,20 @@
"dev": true
},
"node_modules/@types/node": {
- "version": "18.15.11",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-18.15.11.tgz",
- "integrity": "sha512-E5Kwq2n4SbMzQOn6wnmBjuK9ouqlURrcZDVfbo9ftDDTFt3nk7ZKK4GMOzoYgnpQJKcxwQw+lGaBvvlMo0qN/Q==",
+ "version": "18.17.15",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-18.17.15.tgz",
+ "integrity": "sha512-2yrWpBk32tvV/JAd3HNHWuZn/VDN1P+72hWirHnvsvTGSqbANi+kSeuQR9yAHnbvaBvHDsoTdXV0Fe+iRtHLKA==",
"dev": true
},
+ "node_modules/@types/papaparse": {
+ "version": "5.3.8",
+ "resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.3.8.tgz",
+ "integrity": "sha512-ArKIEOOWULbhi53wkAiRy1ze4wvrTfhpAj7Yfzva+EkmX2sV8PpFB+xqzJfzXNzK4me95FJH9QZt5NXFVGzOoQ==",
+ "dev": true,
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
"node_modules/@types/parse-json": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz",
@@ -8517,6 +8528,11 @@
"node": ">=6"
}
},
+ "node_modules/papaparse": {
+ "version": "5.4.1",
+ "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.4.1.tgz",
+ "integrity": "sha512-HipMsgJkZu8br23pW15uvo6sib6wne/4woLZPlFf3rpDyMe9ywEXUsuD7+6K9PRkJlVT51j/sCOYDKGGS3ZJrw=="
+ },
"node_modules/param-case": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz",
diff --git a/package.json b/package.json
index 42c7d75e..5b8e0423 100644
--- a/package.json
+++ b/package.json
@@ -27,6 +27,7 @@
"lodash": "4.17.21",
"moment": "2.29.4",
"npm-audit-resolver": "3.0.0-RC.0",
+ "papaparse": "5.4.1",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-router-dom": "5.3.4",
@@ -61,6 +62,7 @@
"@types/jest": "29.5.4",
"@types/jwt-decode": "2.2.1",
"@types/lodash": "4.14.198",
+ "@types/papaparse": "5.3.8",
"@types/react": "18.2.21",
"@types/react-dom": "18.2.7",
"@types/react-router-dom": "5.3.3",
diff --git a/src/graphql/analytics-overview.generated.tsx b/src/graphql/analytics-overview.generated.tsx
index b3745fd8..5690eb78 100644
--- a/src/graphql/analytics-overview.generated.tsx
+++ b/src/graphql/analytics-overview.generated.tsx
@@ -37,7 +37,9 @@ export type CampaignWithEngagementsFragment = {
endAt: any;
pacingIndex?: number | null;
format: Types.CampaignFormat;
- adSets: Array<{ conversions?: Array<{ type: string }> | null }>;
+ adSets: Array<{
+ conversions?: Array<{ type: string; extractExternalId: boolean }> | null;
+ }>;
engagements?: Array<{
creativeinstanceid: string;
createdat: any;
@@ -78,7 +80,9 @@ export type AnalyticOverviewQuery = {
endAt: any;
pacingIndex?: number | null;
format: Types.CampaignFormat;
- adSets: Array<{ conversions?: Array<{ type: string }> | null }>;
+ adSets: Array<{
+ conversions?: Array<{ type: string; extractExternalId: boolean }> | null;
+ }>;
engagements?: Array<{
creativeinstanceid: string;
createdat: any;
@@ -158,6 +162,7 @@ export const CampaignWithEngagementsFragmentDoc = gql`
adSets {
conversions {
type
+ extractExternalId
}
}
engagements {
diff --git a/src/graphql/analytics-overview.graphql b/src/graphql/analytics-overview.graphql
index 45df71b0..45190b1a 100644
--- a/src/graphql/analytics-overview.graphql
+++ b/src/graphql/analytics-overview.graphql
@@ -36,6 +36,7 @@ fragment CampaignWithEngagements on Campaign {
adSets {
conversions {
type
+ extractExternalId
}
}
engagements {
diff --git a/src/user/analytics/analyticsOverview/components/ReportUtils.tsx b/src/user/analytics/analyticsOverview/components/ReportUtils.tsx
index 8f3d1327..be0fba72 100644
--- a/src/user/analytics/analyticsOverview/components/ReportUtils.tsx
+++ b/src/user/analytics/analyticsOverview/components/ReportUtils.tsx
@@ -1,17 +1,23 @@
import { Box } from "@mui/material";
-import { LoadingButton } from "@mui/lab";
-import SaveIcon from "@mui/icons-material/Save";
-import { useState } from "react";
-import { downloadCSV } from "../lib/csv.library";
import { DateRangePicker } from "components/Date/DateRangePicker";
-import { useUser } from "auth/hooks/queries/useUser";
import { DashboardButton } from "components/Button/DashboardButton";
import { CampaignFormat } from "graphql/types";
+import _ from "lodash";
+import { ReportMenu } from "user/reporting/ReportMenu";
interface DownloaderProps {
startDate: Date | undefined;
endDate: Date;
- campaign: { id: string; name: string; format?: CampaignFormat };
+ campaign: {
+ id: string;
+ name: string;
+ format?: CampaignFormat;
+ adSets?:
+ | {
+ conversions?: { type: string; extractExternalId: boolean }[] | null;
+ }[]
+ | null;
+ };
onSetDate: (val: Date, type: "start" | "end") => void;
}
@@ -21,8 +27,11 @@ export default function ReportUtils({
campaign,
onSetDate,
}: DownloaderProps) {
- const [downloadingCSV, setDownloadingCSV] = useState(false);
- const { userId } = useUser();
+ const conversions = _.flatMap(campaign.adSets ?? [], "conversions");
+ const maybeHasVerifiedConversions = _.some(
+ conversions ?? [],
+ (c) => c.extractExternalId,
+ );
return (
{startDate && (
onSetDate(d, "start")}
onToChange={(d) => onSetDate(d, "end")}
- >
+ />
)}
- }
- disabled={campaign.format === CampaignFormat.NtpSi}
- onClick={() =>
- downloadCSV(
- campaign.id,
- campaign.name,
- userId ?? "",
- false,
- setDownloadingCSV,
- )
- }
- >
- Download Report
-
+ {campaign.format !== CampaignFormat.NtpSi && (
+
+ )}
);
diff --git a/src/user/analytics/analyticsOverview/lib/csv.library.ts b/src/user/analytics/analyticsOverview/lib/csv.library.ts
deleted file mode 100644
index eac8adeb..00000000
--- a/src/user/analytics/analyticsOverview/lib/csv.library.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-import { Dispatch } from "react";
-import axios from "axios";
-
-export const downloadCSV = async (
- campaignId: string,
- campaignName: string,
- userId: string,
- includeCountry: boolean,
- setDownloadingCSV: Dispatch,
-) => {
- setDownloadingCSV(true);
-
- try {
- const response = await axios(
- `${
- import.meta.env.REACT_APP_SERVER_ADDRESS
- }/report/campaign/csv/${campaignId}`,
- {
- withCredentials: true,
- headers: {
- "-x-user": userId,
- "Content-Type": "text/csv",
- },
- },
- );
-
- const file = new Blob([response.data], {
- type: "text/csv",
- endings: "transparent",
- });
- const fileURL = URL.createObjectURL(file);
- const link = document.createElement("a");
- link.href = fileURL;
- link.setAttribute("download", `${campaignName}.csv`);
- document.body.appendChild(link);
- link.click();
- } finally {
- setDownloadingCSV(false);
- }
-};
diff --git a/src/user/reporting/ReportMenu.tsx b/src/user/reporting/ReportMenu.tsx
new file mode 100644
index 00000000..c9fdd45a
--- /dev/null
+++ b/src/user/reporting/ReportMenu.tsx
@@ -0,0 +1,144 @@
+import { useState } from "react";
+import { useDownloadCSV } from "user/reporting/csv.library";
+import {
+ Alert,
+ Button,
+ Dialog,
+ DialogActions,
+ DialogContent,
+ DialogContentText,
+ DialogTitle,
+ LinearProgress,
+ ListItemIcon,
+ Menu,
+ MenuItem,
+ Snackbar,
+ TextField,
+} from "@mui/material";
+import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
+import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp";
+import DownloadIcon from "@mui/icons-material/Download";
+
+interface ReportMenuProps {
+ hasVerifiedConversions: boolean;
+ campaignId: string;
+}
+export const ReportMenu = ({
+ campaignId,
+ hasVerifiedConversions,
+}: ReportMenuProps) => {
+ const [dialogue, setDialogue] = useState(false);
+ const [isError, setIsError] = useState(false);
+ const set = (s: string) =>
+ document.getElementById("private-key")?.setAttribute("value", s);
+ const { download, loading, error } = useDownloadCSV({
+ onComplete() {
+ setAnchorEl(null);
+ setDialogue(false);
+ },
+ onError() {
+ setIsError(true);
+ setDialogue(false);
+ },
+ });
+
+ const [anchorEl, setAnchorEl] = useState(null);
+ const menu = Boolean(anchorEl);
+ return (
+ <>
+
+
+
+
+ setIsError(false)}
+ anchorOrigin={{ vertical: "top", horizontal: "center" }}
+ >
+ setIsError(false)}
+ severity="error"
+ sx={{ width: "100%" }}
+ >
+ {error}
+
+
+
+
+ >
+ );
+};
diff --git a/src/user/reporting/csv.library.tsx b/src/user/reporting/csv.library.tsx
new file mode 100644
index 00000000..d6b55bec
--- /dev/null
+++ b/src/user/reporting/csv.library.tsx
@@ -0,0 +1,131 @@
+import { useCallback, useState } from "react";
+import { buildAdServerEndpoint } from "util/environment";
+import Papa from "papaparse";
+import tweetnacl from "tweetnacl";
+
+interface DownloadProps {
+ onComplete?: () => void;
+ onError?: () => void;
+}
+
+export function useDownloadCSV(props: DownloadProps = {}) {
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState();
+
+ const download = useCallback((campaignId: string, isVac: boolean) => {
+ setLoading(true);
+ setError(undefined);
+
+ const baseUrl = `/report/campaign/csv/${campaignId}`;
+ fetch(buildAdServerEndpoint(isVac ? `${baseUrl}/vac` : baseUrl), {
+ method: "GET",
+ mode: "cors",
+ credentials: "include",
+ headers: {
+ "Content-Type": "text/csv",
+ },
+ })
+ .then((res) => {
+ if (res.status !== 200) {
+ const err = "Unable to download CSV";
+ setError(err);
+ throw new Error(err);
+ }
+
+ return res.blob();
+ })
+ .then((blob) => {
+ const file = new Blob([blob], {
+ type: "text/csv",
+ endings: "transparent",
+ });
+
+ if (isVac) {
+ return transformConversionEnvelope(file);
+ }
+
+ return Promise.resolve(file);
+ })
+ .then((file) => {
+ const fileURL = URL.createObjectURL(file);
+ const link = document.createElement("a");
+ link.href = fileURL;
+ link.setAttribute("download", `${campaignId}.csv`);
+ document.body.appendChild(link);
+ link.click();
+ if (props.onComplete) {
+ props.onComplete();
+ }
+ })
+ .catch((e) => {
+ setError(e.message);
+ })
+ .finally(() => {
+ setLoading(false);
+ });
+ }, []);
+
+ return { download, loading, error };
+}
+
+type Envelope = { ciphertext: string; epk: string; nonce: string };
+async function transformConversionEnvelope(blob: Blob): Promise {
+ const ui8a = (s?: string | null) =>
+ s ? Uint8Array.from(atob(s), (c) => c.charCodeAt(0)) : new Uint8Array();
+ const privateKey = ui8a(
+ document.getElementById("private-key")?.getAttribute("value"),
+ );
+ if (privateKey.byteLength === 0) {
+ return Promise.resolve(blob);
+ }
+
+ const td = new TextDecoder();
+ return await new Promise((resolve, reject) => {
+ const fileReader = new FileReader();
+ fileReader.onload = () => {
+ const text = fileReader.result as string | null;
+ if (text === null) {
+ reject(new Error("No file Result"));
+ return;
+ }
+
+ try {
+ Papa.parse(text, {
+ header: true,
+ escapeChar: "\\",
+ transform(value: string, field: string) {
+ if (field.includes("Conversion")) {
+ const { ciphertext, nonce, epk }: Envelope = JSON.parse(value);
+ const res = tweetnacl.box.open(
+ ui8a(ciphertext),
+ ui8a(nonce),
+ ui8a(epk),
+ privateKey,
+ );
+ return res
+ ? td.decode(res.filter((v) => v !== 0x00))
+ : "Data not valid for this private key";
+ }
+
+ return value;
+ },
+ complete(results) {
+ const newCSV = Papa.unparse(results.data, {
+ skipEmptyLines: "greedy",
+ });
+ privateKey.fill(0);
+ resolve(new Blob([newCSV]));
+ },
+ error(error: Error) {
+ reject(error);
+ },
+ });
+ } catch (e) {
+ console.error(e);
+ reject(new Error("Unable to decrypt conversion data"));
+ }
+ };
+
+ fileReader.readAsText(blob);
+ });
+}
diff --git a/src/user/settings/NewKeyPairModal.tsx b/src/user/settings/NewKeyPairModal.tsx
index 17a51bb7..283fefb6 100644
--- a/src/user/settings/NewKeyPairModal.tsx
+++ b/src/user/settings/NewKeyPairModal.tsx
@@ -8,7 +8,7 @@ import {
Typography,
} from "@mui/material";
import { useUpdateAdvertiserMutation } from "graphql/advertiser.generated";
-import * as tweetnacl from "tweetnacl";
+import tweetnacl from "tweetnacl";
import { useRef, useState } from "react";
import { IAdvertiser } from "auth/context/auth.interface";
import { CardContainer } from "components/Card/CardContainer";
diff --git a/src/user/views/user/CampaignReportView.tsx b/src/user/views/user/CampaignReportView.tsx
index a6487c62..670d8454 100644
--- a/src/user/views/user/CampaignReportView.tsx
+++ b/src/user/views/user/CampaignReportView.tsx
@@ -67,6 +67,7 @@ export function CampaignReportView() {
id: params.campaignId,
name: campaign?.name ?? "",
format: campaign?.format,
+ adSets: campaign?.adSets,
}}
onSetDate={setDateRange}
/>