diff --git a/src/components/Box/BoxContainer.tsx b/src/components/Box/BoxContainer.tsx
index 4b152a48..d03c2c29 100644
--- a/src/components/Box/BoxContainer.tsx
+++ b/src/components/Box/BoxContainer.tsx
@@ -2,16 +2,23 @@ import { PropsWithChildren, ReactNode } from "react";
import { Box, Typography } from "@mui/material";
export function BoxContainer(
- props: { header?: ReactNode } & PropsWithChildren,
+ props: { useTypography?: boolean; header?: ReactNode } & PropsWithChildren,
) {
+ let header;
+ if (props.header) {
+ header = props.useTypography ? (
+
+ {props.header}
+
+ ) : (
+ props.header
+ );
+ }
+
return (
- {props.header && (
-
- {props.header}
-
- )}
-
+ {header}
+
{props.children}
diff --git a/src/components/Campaigns/Status.tsx b/src/components/Campaigns/Status.tsx
index c8dd1065..337a4892 100644
--- a/src/components/Campaigns/Status.tsx
+++ b/src/components/Campaigns/Status.tsx
@@ -7,11 +7,11 @@ interface Props {
state: string;
start?: string;
end?: string;
+ opaque?: boolean;
}
-export const Status = ({ state, start, end }: Props) => {
+export const Status = ({ state, start, end, opaque }: Props) => {
let color = calcColorForState(state);
-
let label = _.startCase(state);
if (start) {
@@ -36,6 +36,7 @@ export const Status = ({ state, start, end }: Props) => {
sx={{
backgroundColor: color,
fontSize: "0.7rem",
+ opacity: opaque === false ? "0.3" : 1,
}}
/>
diff --git a/src/components/Creatives/CreateCreativeButton.tsx b/src/components/Creatives/CreateCreativeButton.tsx
new file mode 100644
index 00000000..79e59561
--- /dev/null
+++ b/src/components/Creatives/CreateCreativeButton.tsx
@@ -0,0 +1,67 @@
+import SaveIcon from "@mui/icons-material/Save";
+import {
+ CampaignForm,
+ Creative,
+ initialCreative,
+} from "user/views/adsManager/types";
+import _ from "lodash";
+import {
+ refetchAdvertiserCreativesQuery,
+ useCreateCreativeMutation,
+} from "graphql/creative.generated";
+import { useField, useFormikContext } from "formik";
+import { useAdvertiser } from "auth/hooks/queries/useAdvertiser";
+import { LoadingButton } from "@mui/lab";
+import { validCreativeFields } from "user/library";
+
+export function CreateCreativeButton() {
+ const { values, setFieldValue } = useFormikContext();
+ const [, , isCreating] = useField("isCreating");
+ const [, newMeta, newHelper] = useField("newCreative");
+ const { advertiser } = useAdvertiser();
+
+ const [create, { loading }] = useCreateCreativeMutation({
+ async onCompleted(data) {
+ newHelper.setValue(initialCreative);
+ newHelper.setTouched(false);
+ values.adSets.forEach((adSet, idx) => {
+ void setFieldValue(`adSets.${idx}.creatives`, [
+ ...adSet.creatives,
+ validCreativeFields(data.createCreative, advertiser.id),
+ ]);
+ });
+ isCreating.setValue(false);
+ },
+ refetchQueries: [
+ {
+ ...refetchAdvertiserCreativesQuery({ advertiserId: advertiser.id }),
+ },
+ ],
+ });
+
+ return (
+ }
+ onClick={(e) => {
+ e.preventDefault();
+ create({
+ variables: {
+ input: {
+ ..._.omit(newMeta.value, "included"),
+ advertiserId: advertiser.id,
+ },
+ },
+ });
+ }}
+ disabled={
+ newMeta.value?.targetUrlValid !== undefined ||
+ !_.isEmpty(newMeta.error) ||
+ loading
+ }
+ loading={loading}
+ >
+ Add
+
+ );
+}
diff --git a/src/components/Creatives/CreativeSpecificFields.tsx b/src/components/Creatives/CreativeSpecificFields.tsx
new file mode 100644
index 00000000..f6463db6
--- /dev/null
+++ b/src/components/Creatives/CreativeSpecificFields.tsx
@@ -0,0 +1,13 @@
+import { useFormikContext } from "formik";
+import { CampaignForm } from "user/views/adsManager/types";
+import { CampaignFormat } from "graphql/types";
+import { NotificationAd } from "user/ads/NotificationAd";
+
+export const CreativeSpecificFields = () => {
+ const { values } = useFormikContext();
+
+ if (values.format === CampaignFormat.PushNotification)
+ return ;
+
+ return null;
+};
diff --git a/src/components/Creatives/CreativeSpecificPreview.tsx b/src/components/Creatives/CreativeSpecificPreview.tsx
new file mode 100644
index 00000000..f094e8bd
--- /dev/null
+++ b/src/components/Creatives/CreativeSpecificPreview.tsx
@@ -0,0 +1,65 @@
+import { CampaignFormat } from "graphql/types";
+import { BoxContainer } from "components/Box/BoxContainer";
+import { NotificationPreview } from "components/Creatives/NotificationPreview";
+import { Stack, Typography } from "@mui/material";
+import { PropsWithChildren } from "react";
+import { useField } from "formik";
+import { Creative } from "user/views/adsManager/types";
+import { DisplayError } from "user/views/adsManager/views/advanced/components/review/components/ReviewField";
+
+interface Props extends PropsWithChildren {
+ options: Creative[];
+ useSimpleHeader?: boolean;
+ error?: string;
+}
+
+export function CreativeSpecificPreview({
+ options,
+ useSimpleHeader,
+ error,
+ children,
+}: Props) {
+ const [, format] = useField("format");
+
+ let component;
+ if (format.value === CampaignFormat.PushNotification) {
+ component = options.map((c, idx) => (
+
+
+
+ ));
+ }
+
+ if (error) {
+ return (
+ <>
+
+ Ads
+
+
+ >
+ );
+ }
+
+ return (
+ <>
+ {useSimpleHeader && (
+
+ Ads
+
+ )}
+
+ {component}
+ {children}
+
+ >
+ );
+}
diff --git a/src/user/ads/NotificationPreview.tsx b/src/components/Creatives/NotificationPreview.tsx
similarity index 66%
rename from src/user/ads/NotificationPreview.tsx
rename to src/components/Creatives/NotificationPreview.tsx
index 460d070e..0591dd26 100644
--- a/src/user/ads/NotificationPreview.tsx
+++ b/src/components/Creatives/NotificationPreview.tsx
@@ -1,10 +1,14 @@
-import { useField } from "formik";
-import { Creative } from "user/views/adsManager/types";
import { Box, Paper, Stack, Typography } from "@mui/material";
import logo from "../../../brave_logo_icon.png";
+import { useField } from "formik";
+import { CreativeInput } from "graphql/types";
-export function NotificationPreview(props: { title?: string; body?: string }) {
- const [, meta] = useField("newCreative");
+export function NotificationPreview(props: {
+ title?: string;
+ body?: string;
+ selected?: boolean;
+}) {
+ const [, meta, ,] = useField("newCreative");
return (
@@ -18,6 +22,7 @@ export function NotificationPreview(props: { title?: string; body?: string }) {
display: "flex",
justifyContent: "left",
flexDirection: "row",
+ opacity: props.selected === false ? 0.5 : 1,
}}
>
@@ -27,10 +32,14 @@ export function NotificationPreview(props: { title?: string; body?: string }) {
/>
- {props.title || meta.value.title || "Title Preview"}
+ {props.title ||
+ meta.value?.payloadNotification?.title ||
+ "Title Preview"}
- {props.body || meta.value.body || "Body Preview"}
+ {props.body ||
+ meta.value?.payloadNotification?.body ||
+ "Body Preview"}
diff --git a/src/components/Creatives/NotificationSelect.tsx b/src/components/Creatives/NotificationSelect.tsx
new file mode 100644
index 00000000..4b6d552d
--- /dev/null
+++ b/src/components/Creatives/NotificationSelect.tsx
@@ -0,0 +1,111 @@
+import { Box, Button, Stack, Typography } from "@mui/material";
+import { BoxContainer } from "components/Box/BoxContainer";
+import { NotificationPreview } from "components/Creatives/NotificationPreview";
+import moment from "moment";
+import { SelectCreativeHeader } from "components/Creatives/SelectCreativeHeader";
+import { CampaignForm, Creative } from "user/views/adsManager/types";
+import _ from "lodash";
+import { useContext, useState } from "react";
+import { FormContext } from "state/context";
+import { useFormikContext } from "formik";
+
+export function NotificationSelect(props: {
+ options: Creative[];
+ useSelectedAdStyle?: boolean;
+ showState?: boolean;
+ index?: number;
+ hideCreated?: boolean;
+}) {
+ const index = props.index;
+ const { values, setFieldValue } = useFormikContext();
+ const { setIsShowingAds } = useContext(FormContext);
+ const [curr, setCurr] = useState([]);
+
+ const onSelectCreative = (c: Creative, selected: boolean) => {
+ let value;
+ if (selected) {
+ value = [...curr, c];
+ } else {
+ value = _.filter(curr, (n) => n.id !== c.id);
+ }
+
+ if (index !== undefined) {
+ const foundIndex = values.adSets[index].creatives.findIndex(
+ (co) => c.id === co.id,
+ );
+ if (foundIndex !== undefined) {
+ void setFieldValue(
+ `adSets.${index}.creatives.${foundIndex}.included`,
+ selected,
+ );
+ }
+ }
+
+ setCurr(_.uniqBy(value, "id"));
+ };
+
+ const isSelected = (co: Creative) =>
+ props.useSelectedAdStyle === false || co.included;
+
+ return (
+
+ 3 ? "scroll" : "hidden" }}
+ >
+ {props.options.map((co, idx) => (
+
+ }
+ key={idx}
+ >
+
+ {!(props.hideCreated ?? false) && (
+
+ created {moment(co.createdAt).fromNow()}
+
+ )}
+
+ ))}
+
+ {props.index === undefined && (
+
+ )}
+
+ );
+}
diff --git a/src/components/Creatives/SelectCreativeHeader.tsx b/src/components/Creatives/SelectCreativeHeader.tsx
new file mode 100644
index 00000000..6dc151f9
--- /dev/null
+++ b/src/components/Creatives/SelectCreativeHeader.tsx
@@ -0,0 +1,47 @@
+import { Box, IconButton, Typography } from "@mui/material";
+import CheckBoxIcon from "@mui/icons-material/CheckBox";
+import CheckBoxOutlineBlankIcon from "@mui/icons-material/CheckBoxOutlineBlank";
+import { Creative } from "user/views/adsManager/types";
+import { Status } from "components/Campaigns/Status";
+import { useEffect, useState } from "react";
+
+export const SelectCreativeHeader = (props: {
+ creative: Creative;
+ onSelectCreative: (c: Creative, selected: boolean) => void;
+ showState?: boolean;
+}) => {
+ const [selected, setSelected] = useState();
+ useEffect(() => {
+ setSelected(props.creative.included);
+ }, [props.creative]);
+
+ return (
+
+ {
+ const s = !selected;
+ setSelected(s);
+ props.onSelectCreative(props.creative, s);
+ }}
+ sx={{ p: 0 }}
+ >
+ {selected ? (
+
+ ) : (
+
+ )}
+
+ {props.creative.name}
+
+ {props.showState !== false && (
+
+ )}
+
+ );
+};
diff --git a/src/components/Navigation/Navbar.tsx b/src/components/Navigation/Navbar.tsx
index b6172e7e..8e1bc60f 100644
--- a/src/components/Navigation/Navbar.tsx
+++ b/src/components/Navigation/Navbar.tsx
@@ -1,5 +1,4 @@
import { useRouteMatch, Link as RouterLink } from "react-router-dom";
-
import { AppBar, Button, Divider, Stack, Toolbar } from "@mui/material";
import { DraftMenu } from "components/Navigation/DraftMenu";
diff --git a/src/components/Navigation/NewCampaignButton.tsx b/src/components/Navigation/NewCampaignButton.tsx
new file mode 100644
index 00000000..93674608
--- /dev/null
+++ b/src/components/Navigation/NewCampaignButton.tsx
@@ -0,0 +1,31 @@
+import { useAdvertiser } from "auth/hooks/queries/useAdvertiser";
+import { Button } from "@mui/material";
+import moment from "moment/moment";
+import { Link as RouterLink, useRouteMatch } from "react-router-dom";
+
+export function NewCampaignButton() {
+ const { url } = useRouteMatch();
+ const { advertiser } = useAdvertiser();
+ const isCompletePage = url.includes("/user/main/complete/new");
+ const isNewCampaignPage = url.includes("/user/main/adsmanager/advanced");
+ const newUrl = `/user/main/adsmanager/advanced/new/${moment()
+ .utc()
+ .valueOf()}/settings`;
+
+ if (!advertiser.selfServiceCreate) {
+ return null;
+ }
+
+ return (
+
+ );
+}
diff --git a/src/graphql/ad-set.generated.tsx b/src/graphql/ad-set.generated.tsx
index 0adb3893..4b947c7e 100644
--- a/src/graphql/ad-set.generated.tsx
+++ b/src/graphql/ad-set.generated.tsx
@@ -42,6 +42,35 @@ export type AdSetFragment = {
title: string;
targetUrl: string;
} | null;
+ payloadNewTabPage?: {
+ logo?: {
+ imageUrl: string;
+ alt: string;
+ companyName: string;
+ destinationUrl: string;
+ } | null;
+ wallpapers?: Array<{
+ imageUrl: string;
+ focalPoint: { x: number; y: number };
+ }> | null;
+ } | null;
+ payloadInlineContent?: {
+ title: string;
+ ctaText: string;
+ imageUrl: string;
+ targetUrl: string;
+ dimensions: string;
+ description: string;
+ } | null;
+ payloadSearch?: { body: string; title: string; targetUrl: string } | null;
+ payloadSearchHomepage?: {
+ body: string;
+ imageUrl: string;
+ imageDarkModeUrl?: string | null;
+ targetUrl: string;
+ title: string;
+ ctaText: string;
+ } | null;
};
}> | null;
};
@@ -63,6 +92,35 @@ export type AdFragment = {
title: string;
targetUrl: string;
} | null;
+ payloadNewTabPage?: {
+ logo?: {
+ imageUrl: string;
+ alt: string;
+ companyName: string;
+ destinationUrl: string;
+ } | null;
+ wallpapers?: Array<{
+ imageUrl: string;
+ focalPoint: { x: number; y: number };
+ }> | null;
+ } | null;
+ payloadInlineContent?: {
+ title: string;
+ ctaText: string;
+ imageUrl: string;
+ targetUrl: string;
+ dimensions: string;
+ description: string;
+ } | null;
+ payloadSearch?: { body: string; title: string; targetUrl: string } | null;
+ payloadSearchHomepage?: {
+ body: string;
+ imageUrl: string;
+ imageDarkModeUrl?: string | null;
+ targetUrl: string;
+ title: string;
+ ctaText: string;
+ } | null;
};
};
@@ -109,6 +167,39 @@ export type CreateAdSetMutation = {
title: string;
targetUrl: string;
} | null;
+ payloadNewTabPage?: {
+ logo?: {
+ imageUrl: string;
+ alt: string;
+ companyName: string;
+ destinationUrl: string;
+ } | null;
+ wallpapers?: Array<{
+ imageUrl: string;
+ focalPoint: { x: number; y: number };
+ }> | null;
+ } | null;
+ payloadInlineContent?: {
+ title: string;
+ ctaText: string;
+ imageUrl: string;
+ targetUrl: string;
+ dimensions: string;
+ description: string;
+ } | null;
+ payloadSearch?: {
+ body: string;
+ title: string;
+ targetUrl: string;
+ } | null;
+ payloadSearchHomepage?: {
+ body: string;
+ imageUrl: string;
+ imageDarkModeUrl?: string | null;
+ targetUrl: string;
+ title: string;
+ ctaText: string;
+ } | null;
};
}> | null;
};
@@ -157,6 +248,39 @@ export type UpdateAdSetMutation = {
title: string;
targetUrl: string;
} | null;
+ payloadNewTabPage?: {
+ logo?: {
+ imageUrl: string;
+ alt: string;
+ companyName: string;
+ destinationUrl: string;
+ } | null;
+ wallpapers?: Array<{
+ imageUrl: string;
+ focalPoint: { x: number; y: number };
+ }> | null;
+ } | null;
+ payloadInlineContent?: {
+ title: string;
+ ctaText: string;
+ imageUrl: string;
+ targetUrl: string;
+ dimensions: string;
+ description: string;
+ } | null;
+ payloadSearch?: {
+ body: string;
+ title: string;
+ targetUrl: string;
+ } | null;
+ payloadSearchHomepage?: {
+ body: string;
+ imageUrl: string;
+ imageDarkModeUrl?: string | null;
+ targetUrl: string;
+ title: string;
+ ctaText: string;
+ } | null;
};
}> | null;
};
diff --git a/src/graphql/campaign.generated.tsx b/src/graphql/campaign.generated.tsx
index d0be5d05..ee73e9ab 100644
--- a/src/graphql/campaign.generated.tsx
+++ b/src/graphql/campaign.generated.tsx
@@ -72,6 +72,39 @@ export type CampaignFragment = {
title: string;
targetUrl: string;
} | null;
+ payloadNewTabPage?: {
+ logo?: {
+ imageUrl: string;
+ alt: string;
+ companyName: string;
+ destinationUrl: string;
+ } | null;
+ wallpapers?: Array<{
+ imageUrl: string;
+ focalPoint: { x: number; y: number };
+ }> | null;
+ } | null;
+ payloadInlineContent?: {
+ title: string;
+ ctaText: string;
+ imageUrl: string;
+ targetUrl: string;
+ dimensions: string;
+ description: string;
+ } | null;
+ payloadSearch?: {
+ body: string;
+ title: string;
+ targetUrl: string;
+ } | null;
+ payloadSearchHomepage?: {
+ body: string;
+ imageUrl: string;
+ imageDarkModeUrl?: string | null;
+ targetUrl: string;
+ title: string;
+ ctaText: string;
+ } | null;
};
}> | null;
}>;
@@ -150,6 +183,39 @@ export type CampaignAdsFragment = {
title: string;
targetUrl: string;
} | null;
+ payloadNewTabPage?: {
+ logo?: {
+ imageUrl: string;
+ alt: string;
+ companyName: string;
+ destinationUrl: string;
+ } | null;
+ wallpapers?: Array<{
+ imageUrl: string;
+ focalPoint: { x: number; y: number };
+ }> | null;
+ } | null;
+ payloadInlineContent?: {
+ title: string;
+ ctaText: string;
+ imageUrl: string;
+ targetUrl: string;
+ dimensions: string;
+ description: string;
+ } | null;
+ payloadSearch?: {
+ body: string;
+ title: string;
+ targetUrl: string;
+ } | null;
+ payloadSearchHomepage?: {
+ body: string;
+ imageUrl: string;
+ imageDarkModeUrl?: string | null;
+ targetUrl: string;
+ title: string;
+ ctaText: string;
+ } | null;
};
}> | null;
}>;
@@ -228,6 +294,39 @@ export type LoadCampaignQuery = {
title: string;
targetUrl: string;
} | null;
+ payloadNewTabPage?: {
+ logo?: {
+ imageUrl: string;
+ alt: string;
+ companyName: string;
+ destinationUrl: string;
+ } | null;
+ wallpapers?: Array<{
+ imageUrl: string;
+ focalPoint: { x: number; y: number };
+ }> | null;
+ } | null;
+ payloadInlineContent?: {
+ title: string;
+ ctaText: string;
+ imageUrl: string;
+ targetUrl: string;
+ dimensions: string;
+ description: string;
+ } | null;
+ payloadSearch?: {
+ body: string;
+ title: string;
+ targetUrl: string;
+ } | null;
+ payloadSearchHomepage?: {
+ body: string;
+ imageUrl: string;
+ imageDarkModeUrl?: string | null;
+ targetUrl: string;
+ title: string;
+ ctaText: string;
+ } | null;
};
}> | null;
}>;
@@ -288,6 +387,39 @@ export type LoadCampaignAdsQuery = {
title: string;
targetUrl: string;
} | null;
+ payloadNewTabPage?: {
+ logo?: {
+ imageUrl: string;
+ alt: string;
+ companyName: string;
+ destinationUrl: string;
+ } | null;
+ wallpapers?: Array<{
+ imageUrl: string;
+ focalPoint: { x: number; y: number };
+ }> | null;
+ } | null;
+ payloadInlineContent?: {
+ title: string;
+ ctaText: string;
+ imageUrl: string;
+ targetUrl: string;
+ dimensions: string;
+ description: string;
+ } | null;
+ payloadSearch?: {
+ body: string;
+ title: string;
+ targetUrl: string;
+ } | null;
+ payloadSearchHomepage?: {
+ body: string;
+ imageUrl: string;
+ imageDarkModeUrl?: string | null;
+ targetUrl: string;
+ title: string;
+ ctaText: string;
+ } | null;
};
}> | null;
}>;
diff --git a/src/graphql/creative.generated.tsx b/src/graphql/creative.generated.tsx
index b67e1f46..8f46b4d8 100644
--- a/src/graphql/creative.generated.tsx
+++ b/src/graphql/creative.generated.tsx
@@ -15,6 +15,35 @@ export type CreativeFragment = {
title: string;
targetUrl: string;
} | null;
+ payloadNewTabPage?: {
+ logo?: {
+ imageUrl: string;
+ alt: string;
+ companyName: string;
+ destinationUrl: string;
+ } | null;
+ wallpapers?: Array<{
+ imageUrl: string;
+ focalPoint: { x: number; y: number };
+ }> | null;
+ } | null;
+ payloadInlineContent?: {
+ title: string;
+ ctaText: string;
+ imageUrl: string;
+ targetUrl: string;
+ dimensions: string;
+ description: string;
+ } | null;
+ payloadSearch?: { body: string; title: string; targetUrl: string } | null;
+ payloadSearchHomepage?: {
+ body: string;
+ imageUrl: string;
+ imageDarkModeUrl?: string | null;
+ targetUrl: string;
+ title: string;
+ ctaText: string;
+ } | null;
};
export type AdvertiserCreativesQueryVariables = Types.Exact<{
@@ -36,33 +65,88 @@ export type AdvertiserCreativesQuery = {
title: string;
targetUrl: string;
} | null;
+ payloadNewTabPage?: {
+ logo?: {
+ imageUrl: string;
+ alt: string;
+ companyName: string;
+ destinationUrl: string;
+ } | null;
+ wallpapers?: Array<{
+ imageUrl: string;
+ focalPoint: { x: number; y: number };
+ }> | null;
+ } | null;
+ payloadInlineContent?: {
+ title: string;
+ ctaText: string;
+ imageUrl: string;
+ targetUrl: string;
+ dimensions: string;
+ description: string;
+ } | null;
+ payloadSearch?: { body: string; title: string; targetUrl: string } | null;
+ payloadSearchHomepage?: {
+ body: string;
+ imageUrl: string;
+ imageDarkModeUrl?: string | null;
+ targetUrl: string;
+ title: string;
+ ctaText: string;
+ } | null;
}>;
} | null;
};
-export type CreateNotificationCreativeMutationVariables = Types.Exact<{
- input: Types.CreateNotificationCreativeInput;
+export type CreateCreativeMutationVariables = Types.Exact<{
+ input: Types.CreativeInput;
}>;
-export type CreateNotificationCreativeMutation = {
- createNotificationCreative: {
+export type CreateCreativeMutation = {
+ createCreative: {
id: string;
+ createdAt: any;
+ modifiedAt: any;
+ name: string;
+ state: string;
+ type: { code: string };
payloadNotification?: {
body: string;
title: string;
targetUrl: string;
} | null;
+ payloadNewTabPage?: {
+ logo?: {
+ imageUrl: string;
+ alt: string;
+ companyName: string;
+ destinationUrl: string;
+ } | null;
+ wallpapers?: Array<{
+ imageUrl: string;
+ focalPoint: { x: number; y: number };
+ }> | null;
+ } | null;
+ payloadInlineContent?: {
+ title: string;
+ ctaText: string;
+ imageUrl: string;
+ targetUrl: string;
+ dimensions: string;
+ description: string;
+ } | null;
+ payloadSearch?: { body: string; title: string; targetUrl: string } | null;
+ payloadSearchHomepage?: {
+ body: string;
+ imageUrl: string;
+ imageDarkModeUrl?: string | null;
+ targetUrl: string;
+ title: string;
+ ctaText: string;
+ } | null;
};
};
-export type UpdateNotificationCreativeMutationVariables = Types.Exact<{
- input: Types.UpdateNotificationCreativeInput;
-}>;
-
-export type UpdateNotificationCreativeMutation = {
- updateNotificationCreative: { id: string };
-};
-
export const CreativeFragmentDoc = gql`
fragment Creative on Creative {
id
@@ -78,6 +162,47 @@ export const CreativeFragmentDoc = gql`
title
targetUrl
}
+ payloadNewTabPage {
+ logo {
+ imageUrl
+ alt
+ companyName
+ destinationUrl
+ }
+ wallpapers {
+ imageUrl
+ focalPoint {
+ x
+ y
+ }
+ }
+ }
+ payloadInlineContent {
+ title
+ ctaText
+ imageUrl
+ targetUrl
+ dimensions
+ description
+ }
+ payloadNotification {
+ body
+ title
+ targetUrl
+ }
+ payloadSearch {
+ body
+ title
+ targetUrl
+ }
+ payloadSearchHomepage {
+ body
+ imageUrl
+ imageDarkModeUrl
+ targetUrl
+ title
+ ctaText
+ }
}
`;
export const AdvertiserCreativesDocument = gql`
@@ -147,114 +272,54 @@ export function refetchAdvertiserCreativesQuery(
) {
return { query: AdvertiserCreativesDocument, variables: variables };
}
-export const CreateNotificationCreativeDocument = gql`
- mutation createNotificationCreative(
- $input: CreateNotificationCreativeInput!
- ) {
- createNotificationCreative(createNotificationCreativeInput: $input) {
- id
- payloadNotification {
- body
- title
- targetUrl
- }
+export const CreateCreativeDocument = gql`
+ mutation createCreative($input: CreativeInput!) {
+ createCreative(creative: $input) {
+ ...Creative
}
}
+ ${CreativeFragmentDoc}
`;
-export type CreateNotificationCreativeMutationFn = Apollo.MutationFunction<
- CreateNotificationCreativeMutation,
- CreateNotificationCreativeMutationVariables
+export type CreateCreativeMutationFn = Apollo.MutationFunction<
+ CreateCreativeMutation,
+ CreateCreativeMutationVariables
>;
/**
- * __useCreateNotificationCreativeMutation__
+ * __useCreateCreativeMutation__
*
- * To run a mutation, you first call `useCreateNotificationCreativeMutation` within a React component and pass it any options that fit your needs.
- * When your component renders, `useCreateNotificationCreativeMutation` returns a tuple that includes:
+ * To run a mutation, you first call `useCreateCreativeMutation` within a React component and pass it any options that fit your needs.
+ * When your component renders, `useCreateCreativeMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
- * const [createNotificationCreativeMutation, { data, loading, error }] = useCreateNotificationCreativeMutation({
+ * const [createCreativeMutation, { data, loading, error }] = useCreateCreativeMutation({
* variables: {
* input: // value for 'input'
* },
* });
*/
-export function useCreateNotificationCreativeMutation(
+export function useCreateCreativeMutation(
baseOptions?: Apollo.MutationHookOptions<
- CreateNotificationCreativeMutation,
- CreateNotificationCreativeMutationVariables
+ CreateCreativeMutation,
+ CreateCreativeMutationVariables
>,
) {
const options = { ...defaultOptions, ...baseOptions };
return Apollo.useMutation<
- CreateNotificationCreativeMutation,
- CreateNotificationCreativeMutationVariables
- >(CreateNotificationCreativeDocument, options);
+ CreateCreativeMutation,
+ CreateCreativeMutationVariables
+ >(CreateCreativeDocument, options);
}
-export type CreateNotificationCreativeMutationHookResult = ReturnType<
- typeof useCreateNotificationCreativeMutation
+export type CreateCreativeMutationHookResult = ReturnType<
+ typeof useCreateCreativeMutation
>;
-export type CreateNotificationCreativeMutationResult =
- Apollo.MutationResult;
-export type CreateNotificationCreativeMutationOptions =
- Apollo.BaseMutationOptions<
- CreateNotificationCreativeMutation,
- CreateNotificationCreativeMutationVariables
- >;
-export const UpdateNotificationCreativeDocument = gql`
- mutation updateNotificationCreative(
- $input: UpdateNotificationCreativeInput!
- ) {
- updateNotificationCreative(updateNotificationCreativeInput: $input) {
- id
- }
- }
-`;
-export type UpdateNotificationCreativeMutationFn = Apollo.MutationFunction<
- UpdateNotificationCreativeMutation,
- UpdateNotificationCreativeMutationVariables
->;
-
-/**
- * __useUpdateNotificationCreativeMutation__
- *
- * To run a mutation, you first call `useUpdateNotificationCreativeMutation` within a React component and pass it any options that fit your needs.
- * When your component renders, `useUpdateNotificationCreativeMutation` returns a tuple that includes:
- * - A mutate function that you can call at any time to execute the mutation
- * - An object with fields that represent the current status of the mutation's execution
- *
- * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
- *
- * @example
- * const [updateNotificationCreativeMutation, { data, loading, error }] = useUpdateNotificationCreativeMutation({
- * variables: {
- * input: // value for 'input'
- * },
- * });
- */
-export function useUpdateNotificationCreativeMutation(
- baseOptions?: Apollo.MutationHookOptions<
- UpdateNotificationCreativeMutation,
- UpdateNotificationCreativeMutationVariables
- >,
-) {
- const options = { ...defaultOptions, ...baseOptions };
- return Apollo.useMutation<
- UpdateNotificationCreativeMutation,
- UpdateNotificationCreativeMutationVariables
- >(UpdateNotificationCreativeDocument, options);
-}
-export type UpdateNotificationCreativeMutationHookResult = ReturnType<
- typeof useUpdateNotificationCreativeMutation
+export type CreateCreativeMutationResult =
+ Apollo.MutationResult;
+export type CreateCreativeMutationOptions = Apollo.BaseMutationOptions<
+ CreateCreativeMutation,
+ CreateCreativeMutationVariables
>;
-export type UpdateNotificationCreativeMutationResult =
- Apollo.MutationResult;
-export type UpdateNotificationCreativeMutationOptions =
- Apollo.BaseMutationOptions<
- UpdateNotificationCreativeMutation,
- UpdateNotificationCreativeMutationVariables
- >;
diff --git a/src/graphql/creative.graphql b/src/graphql/creative.graphql
index 80ad3102..a7b10adf 100644
--- a/src/graphql/creative.graphql
+++ b/src/graphql/creative.graphql
@@ -12,6 +12,47 @@ fragment Creative on Creative {
title
targetUrl
}
+ payloadNewTabPage {
+ logo {
+ imageUrl
+ alt
+ companyName
+ destinationUrl
+ }
+ wallpapers {
+ imageUrl
+ focalPoint {
+ x
+ y
+ }
+ }
+ }
+ payloadInlineContent {
+ title
+ ctaText
+ imageUrl
+ targetUrl
+ dimensions
+ description
+ }
+ payloadNotification {
+ body
+ title
+ targetUrl
+ }
+ payloadSearch {
+ body
+ title
+ targetUrl
+ }
+ payloadSearchHomepage {
+ body
+ imageUrl
+ imageDarkModeUrl
+ targetUrl
+ title
+ ctaText
+ }
}
query advertiserCreatives($advertiserId: String!) {
@@ -23,19 +64,8 @@ query advertiserCreatives($advertiserId: String!) {
}
}
-mutation createNotificationCreative($input: CreateNotificationCreativeInput!) {
- createNotificationCreative(createNotificationCreativeInput: $input) {
- id
- payloadNotification {
- body
- title
- targetUrl
- }
- }
-}
-
-mutation updateNotificationCreative($input: UpdateNotificationCreativeInput!) {
- updateNotificationCreative(updateNotificationCreativeInput: $input) {
- id
+mutation createCreative($input: CreativeInput!) {
+ createCreative(creative: $input) {
+ ...Creative
}
}
diff --git a/src/state/context.ts b/src/state/context.ts
index 0c7e0b58..e5bb4ae2 100644
--- a/src/state/context.ts
+++ b/src/state/context.ts
@@ -31,3 +31,8 @@ export const FilterContext = createContext({
fromDate: null as Date | null,
setFromDate: (_d: Date | null) => {},
});
+
+export const FormContext = createContext({
+ isShowingAds: false as boolean,
+ setIsShowingAds: (_b: boolean) => {},
+});
diff --git a/src/user/ads/AdsExistingAd.tsx b/src/user/ads/AdsExistingAd.tsx
new file mode 100644
index 00000000..4dc45a71
--- /dev/null
+++ b/src/user/ads/AdsExistingAd.tsx
@@ -0,0 +1,144 @@
+import {
+ Alert,
+ Box,
+ InputAdornment,
+ LinearProgress,
+ TextField,
+ Typography,
+} from "@mui/material";
+import { useFormikContext } from "formik";
+import { CampaignFormat } from "graphql/types";
+import _ from "lodash";
+import {
+ CreativeFragment,
+ useAdvertiserCreativesQuery,
+} from "graphql/creative.generated";
+import { isCreativeTypeApplicableToCampaignFormat } from "user/library";
+import { useAdvertiser } from "auth/hooks/queries/useAdvertiser";
+import { CampaignForm } from "user/views/adsManager/types";
+import { CardContainer } from "components/Card/CardContainer";
+import SearchIcon from "@mui/icons-material/Search";
+import { useContext, useRef, useState } from "react";
+import { NotificationSelect } from "components/Creatives/NotificationSelect";
+import { FormContext } from "state/context";
+import { useAdvertiserCreatives } from "user/hooks/useAdvertiserCreatives";
+
+function filterCreativesBasedOnCampaignFormat(
+ creatives: CreativeFragment[],
+ campaignFormat: CampaignFormat | null,
+): CreativeFragment[] {
+ if (!campaignFormat) return creatives;
+
+ return creatives.filter((c) =>
+ isCreativeTypeApplicableToCampaignFormat(c.type, campaignFormat),
+ );
+}
+
+export function AdsExistingAd() {
+ const { setIsShowingAds } = useContext(FormContext);
+ const { creatives } = useAdvertiserCreatives();
+ const { values } = useFormikContext();
+ const { advertiser } = useAdvertiser();
+ const original = useRef([]);
+ const [options, setOptions] = useState();
+ const { loading } = useAdvertiserCreativesQuery({
+ variables: { advertiserId: advertiser.id },
+ onCompleted(data) {
+ const creativeOptionList = _.orderBy(
+ filterCreativesBasedOnCampaignFormat(
+ data.advertiser?.creatives ?? [],
+ values.format,
+ ),
+ ["type.code", "createdAt"],
+ ["asc", "desc"],
+ ) as CreativeFragment[];
+
+ const filtered = creativeOptionList.filter((c) => c.state === "active");
+ const exludeExisting = filtered.filter((e) => {
+ const associatedOptions = creatives ?? [];
+ return associatedOptions.find((ao) => ao.id === e.id) === undefined;
+ });
+ original.current = exludeExisting;
+ setOptions(exludeExisting);
+ },
+ });
+
+ if (loading) {
+ return ;
+ }
+
+ if (options && options.length === 0) {
+ return (
+ setIsShowingAds(false)}>
+ No previous Ads available
+
+ );
+ }
+
+ return (
+
+
+ Add an existing Ad
+
+
+
+ Ads are modular building blocks that can be paired with ad sets to build
+ unique combinations. Your previously approved ads will show here. Select
+ by using the box next to the name. Use the "Complete
+ selection" button to finish.
+
+
+
+
+
+
+ ),
+ }}
+ onChange={(e) => {
+ const value = e.target.value.toLowerCase();
+ if (!value || value.trim() !== "") {
+ setOptions(
+ original.current.filter((co) =>
+ co.name.toLowerCase().includes(value),
+ ),
+ );
+ } else {
+ setOptions(original.current);
+ }
+ }}
+ />
+
+
+
+
+ );
+}
+
+const CreativeSpecificSelect = (props: {
+ format: CampaignFormat;
+ options: CreativeFragment[];
+}) => {
+ const { advertiser } = useAdvertiser();
+
+ if (props.format === CampaignFormat.PushNotification)
+ return (
+ ({
+ ...o,
+ advertiserId: advertiser.id,
+ included: false,
+ }))}
+ useSelectedAdStyle={false}
+ showState={false}
+ />
+ );
+
+ return null;
+};
diff --git a/src/user/ads/NewAd.tsx b/src/user/ads/NewAd.tsx
index 74aaa35f..4538b7f1 100644
--- a/src/user/ads/NewAd.tsx
+++ b/src/user/ads/NewAd.tsx
@@ -1,34 +1,38 @@
-import { useRecentlyCreatedAdvertiserCreatives } from "user/hooks/useAdvertiserCreatives";
import { CardContainer } from "components/Card/CardContainer";
-import { Box, Button, Stack } from "@mui/material";
-import { useState } from "react";
+import { Box, Button, Link } from "@mui/material";
+import { useContext, useEffect } from "react";
import { BoxContainer } from "components/Box/BoxContainer";
import AddCircleOutlineIcon from "@mui/icons-material/AddCircleOutline";
import RemoveCircleOutlineIcon from "@mui/icons-material/RemoveCircleOutline";
-import { NotificationPreview } from "user/ads/NotificationPreview";
-import { NotificationAd } from "user/ads/NotificationAd";
+import { CreativeSpecificFields } from "components/Creatives/CreativeSpecificFields";
import { useField } from "formik";
+import { Creative, initialCreative } from "user/views/adsManager/types";
+import { FormContext } from "state/context";
+import { AdsExistingAd } from "user/ads/AdsExistingAd";
+import { CreativeSpecificPreview } from "components/Creatives/CreativeSpecificPreview";
+import { useAdvertiserCreatives } from "user/hooks/useAdvertiserCreatives";
export function NewAd() {
+ const { creatives } = useAdvertiserCreatives();
+ const [, , newCreative] = useField("newCreative");
const [, meta, helper] = useField("isCreating");
- const [showForm, setShowForm] = useState(false);
- const creatives = useRecentlyCreatedAdvertiserCreatives();
+ const { isShowingAds, setIsShowingAds } = useContext(FormContext);
+
+ useEffect(() => {
+ if (!meta.value) {
+ newCreative.setValue(initialCreative);
+ newCreative.setTouched(false);
+ }
+ }, [meta.value]);
return (
<>
-
-
- {(creatives ?? []).map((c, idx) => (
-
-
-
- ))}
-
+
+
+
{
helper.setValue(!meta.value);
- setShowForm(!showForm);
+ setIsShowingAds(false);
}}
>
- {showForm ? (
+ {meta.value ? (
) : (
)}
-
+
+ {!isShowingAds && (
+ {
+ setIsShowingAds(true);
+ helper.setValue(false);
+ }}
+ >
+ Use previously created Ads
+
+ )}
- {showForm && (
- {
- helper.setValue(false);
- setShowForm(false);
- }}
- />
- )}
+ {isShowingAds && }
+ {meta.value && }
>
);
}
diff --git a/src/user/ads/NotificationAd.tsx b/src/user/ads/NotificationAd.tsx
index 58ac1735..f5d850fd 100644
--- a/src/user/ads/NotificationAd.tsx
+++ b/src/user/ads/NotificationAd.tsx
@@ -2,45 +2,17 @@ import { CardContainer } from "components/Card/CardContainer";
import { FormikTextField } from "form/FormikHelpers";
import { Stack } from "@mui/material";
import { UrlResolver } from "components/Url/UrlResolver";
-import { LoadingButton } from "@mui/lab";
-import SaveIcon from "@mui/icons-material/Save";
-import { creativeInput } from "user/library";
-import { CreateNotificationCreativeInput } from "graphql/types";
import { useField } from "formik";
-import { Creative, initialCreative } from "user/views/adsManager/types";
-import { useAdvertiser } from "auth/hooks/queries/useAdvertiser";
-import { useUser } from "auth/hooks/queries/useUser";
-import { NotificationPreview } from "user/ads/NotificationPreview";
-import {
- refetchAdvertiserCreativesQuery,
- useCreateNotificationCreativeMutation,
-} from "graphql/creative.generated";
+import { NotificationPreview } from "components/Creatives/NotificationPreview";
+import { CreateCreativeButton } from "components/Creatives/CreateCreativeButton";
+import { useEffect } from "react";
-interface Props {
- onCreate: () => void;
-}
+export function NotificationAd() {
+ const [, , code] = useField("newCreative.type.code");
-export function NotificationAd({ onCreate }: Props) {
- const [, meta, newCreativeHelper] = useField("newCreative");
- const [, creativesMeta, creativesHelper] = useField("creatives");
- const { advertiser } = useAdvertiser();
- const { userId } = useUser();
- const [create, { loading }] = useCreateNotificationCreativeMutation({
- async onCompleted(data) {
- newCreativeHelper.setValue(initialCreative);
- newCreativeHelper.setTouched(false);
- creativesHelper.setValue([
- ...(creativesMeta.value ?? []),
- data.createNotificationCreative.id,
- ]);
- onCreate();
- },
- refetchQueries: [
- {
- ...refetchAdvertiserCreativesQuery({ advertiserId: advertiser.id }),
- },
- ],
- });
+ useEffect(() => {
+ code.setValue("notification_all_v1");
+ }, []);
return (
@@ -48,14 +20,14 @@ export function NotificationAd({ onCreate }: Props) {
- }
- onClick={(e) => {
- e.preventDefault();
- const input = creativeInput(
- advertiser.id,
- meta.value,
- userId,
- ) as CreateNotificationCreativeInput;
- create({ variables: { input } });
- }}
- disabled={
- !!meta.error ||
- meta.value?.targetUrlValidationResult !== undefined ||
- loading
- }
- loading={loading}
- >
- Add
-
+
);
diff --git a/src/user/hooks/useAdvertiserCreatives.ts b/src/user/hooks/useAdvertiserCreatives.ts
index 3117fe85..451f7231 100644
--- a/src/user/hooks/useAdvertiserCreatives.ts
+++ b/src/user/hooks/useAdvertiserCreatives.ts
@@ -1,34 +1,20 @@
-import { useAdvertiserCreativesQuery } from "graphql/creative.generated";
-import { useAdvertiser } from "auth/hooks/queries/useAdvertiser";
import { useFormikContext } from "formik";
import { CampaignForm, Creative } from "user/views/adsManager/types";
import _ from "lodash";
-export function useAdvertiserCreatives(): Creative[] {
- const { advertiser } = useAdvertiser();
- const { data } = useAdvertiserCreativesQuery({
- variables: { advertiserId: advertiser.id },
- });
- return (data?.advertiser?.creatives ?? []).map((c) => ({
- id: c.id,
- name: c.name,
- title: c.payloadNotification?.title ?? "New Ad",
- body: c.payloadNotification?.body ?? "Body Preview",
- targetUrl: c.payloadNotification?.targetUrl ?? "",
- state: c.state,
- }));
-}
-
-export function useRecentlyCreatedAdvertiserCreatives() {
+export function useAdvertiserCreatives() {
const { values } = useFormikContext();
- const creatives = useAdvertiserCreatives();
- const inCampaign = creatives.filter((c) => {
- if (c.id) {
- return (values.creatives ?? []).includes(c.id);
- }
-
- return false;
- });
+ const inAdSet: Creative[] = _.flatMap(values.adSets, "creatives").map(
+ (c: Creative) => ({
+ type: c.type,
+ payloadNotification: c.payloadNotification,
+ id: c.id,
+ advertiserId: c.advertiserId,
+ name: c.name,
+ state: c.state,
+ included: false,
+ }),
+ );
- return _.uniqBy(inCampaign, "id");
+ return { creatives: _.uniqBy(inAdSet, "id") };
}
diff --git a/src/user/library/index.test.ts b/src/user/library/index.test.ts
index aad02ce5..bd92261f 100644
--- a/src/user/library/index.test.ts
+++ b/src/user/library/index.test.ts
@@ -1,6 +1,11 @@
import { CampaignFragment } from "graphql/campaign.generated";
import { describe, expect, it } from "vitest";
-import { editCampaignValues, transformCreative } from ".";
+import {
+ editCampaignValues,
+ transformCreative,
+ transformEditForm,
+ transformNewForm,
+} from ".";
import {
CampaignFormat,
CampaignPacingStrategies,
@@ -9,7 +14,10 @@ import {
PaymentType,
} from "graphql/types";
import { produce } from "immer";
-import { Creative } from "user/views/adsManager/types";
+import { AdSetForm, CampaignForm, Creative } from "user/views/adsManager/types";
+import _ from "lodash";
+import { AdFragment, AdSetFragment } from "graphql/ad-set.generated";
+import { CreativeFragment } from "graphql/creative.generated";
const BASE_CPM_CAMPAIGN_FRAGMENT: Readonly = {
id: "3495317a-bb47-4daf-8d3e-14cdc0e87457",
@@ -160,10 +168,16 @@ describe("pricing logic (read)", () => {
describe("pricing logic (write)", () => {
const creative: Creative = {
+ payloadNotification: {
+ title: "some title",
+ body: "body",
+ targetUrl: "some url",
+ },
+ advertiserId: "some id",
+ state: "draft",
+ type: { code: "notification_all_v1" },
name: "some name",
- title: "some title",
- body: "body",
- targetUrl: "https://some.example.org",
+ included: true,
};
it("should convert from CPM to per-impression values when populating a CPM creative", () => {
@@ -196,3 +210,434 @@ describe("pricing logic (write)", () => {
expect(inputObject.priceType).toEqual(ConfirmationType.Landed);
});
});
+
+describe("new form tests", () => {
+ const dateString = new Date().toLocaleString();
+
+ const creative: Creative = {
+ id: "11111",
+ advertiserId: "123456",
+ included: true,
+ name: "Test",
+ state: "draft",
+ type: { code: "test" },
+ };
+
+ const creative2: Creative = {
+ id: "33333",
+ advertiserId: "123456",
+ included: false,
+ name: "Dont include",
+ state: "draft",
+ type: { code: "test" },
+ };
+
+ const adSetForm: AdSetForm = {
+ conversions: [],
+ creatives: [creative, creative2],
+ isNotTargeting: false,
+ name: "",
+ oses: [{ name: "macos", code: "1234" }],
+ segments: [{ name: "test", code: "5678" }],
+ };
+
+ const form: CampaignForm = {
+ adSets: [adSetForm],
+ advertiserId: "12345",
+ billingType: "cpm",
+ budget: 1000,
+ currency: "USD",
+ dailyBudget: 10,
+ endAt: dateString,
+ format: CampaignFormat.PushNotification,
+ geoTargets: [{ code: "US", name: "United States" }],
+ isCreating: false,
+ name: "Test",
+ paymentType: PaymentType.Radom,
+ price: 6,
+ startAt: dateString,
+ state: "draft",
+ type: "paid",
+ validateStart: false,
+ };
+
+ it("should transform campaign form", () => {
+ const res = _.omit(transformNewForm(form, "me"), ["startAt", "endAt"]);
+ expect(res).toMatchInlineSnapshot(`
+ {
+ "adSets": [
+ {
+ "ads": [
+ {
+ "creativeId": "11111",
+ "price": "0.006",
+ "priceType": "VIEW",
+ },
+ ],
+ "billingType": "cpm",
+ "conversions": [],
+ "name": "",
+ "oses": [
+ {
+ "code": "1234",
+ "name": "macos",
+ },
+ ],
+ "perDay": 1,
+ "segments": [
+ {
+ "code": "5678",
+ "name": "test",
+ },
+ ],
+ "totalMax": 10,
+ },
+ ],
+ "advertiserId": "12345",
+ "budget": 1000,
+ "currency": "USD",
+ "dailyBudget": 10,
+ "dailyCap": 1,
+ "externalId": "",
+ "format": "PUSH_NOTIFICATION",
+ "geoTargets": [
+ {
+ "code": "US",
+ "name": "United States",
+ },
+ ],
+ "name": "Test",
+ "pacingStrategy": "MODEL_V1",
+ "paymentType": "RADOM",
+ "source": "self_serve",
+ "state": "draft",
+ "type": "paid",
+ "userId": "me",
+ }
+ `);
+ });
+
+ it("should transform a creative", () => {
+ creative.payloadNotification = {
+ title: "valid",
+ targetUrl: "valid",
+ body: "valid",
+ };
+
+ creative.payloadSearch = {
+ title: "invalid",
+ targetUrl: "invalid",
+ body: "invalid",
+ };
+
+ const res = transformCreative(creative, form);
+ expect(res).toMatchInlineSnapshot(`
+ {
+ "creativeId": "11111",
+ "price": "0.006",
+ "priceType": "VIEW",
+ }
+ `);
+ });
+});
+
+describe("edit form tests", () => {
+ const creative: CreativeFragment = {
+ createdAt: undefined,
+ id: "1234",
+ modifiedAt: undefined,
+ name: "a creative",
+ state: "active",
+ payloadNotification: {
+ targetUrl: "valid",
+ title: "valid",
+ body: "valid",
+ },
+ type: { code: "notification_v1_all" },
+ };
+
+ const ad: AdFragment = {
+ id: "1",
+ creative: creative,
+ state: "active",
+ price: "6",
+ priceType: ConfirmationType.View,
+ };
+
+ const ad2: AdFragment = {
+ id: "2",
+ creative: creative,
+ state: "deleted",
+ price: "6",
+ priceType: ConfirmationType.View,
+ };
+
+ const ad3: AdFragment = {
+ id: "3",
+ creative: {
+ ...creative,
+ id: "1235",
+ name: "a different creative",
+ },
+ state: "active",
+ price: "6",
+ priceType: ConfirmationType.View,
+ };
+
+ const adSet: AdSetFragment = {
+ ads: [ad, ad2],
+ billingType: "cpm",
+ conversions: [],
+ createdAt: undefined,
+ id: "11111",
+ perDay: 1,
+ oses: [{ name: "macos", code: "1234" }],
+ segments: [{ name: "test", code: "5678" }],
+ state: "active",
+ totalMax: 100,
+ };
+
+ const adSet2: AdSetFragment = {
+ ads: [ad, ad3],
+ billingType: "cpm",
+ conversions: [],
+ createdAt: undefined,
+ id: "22222",
+ perDay: 1,
+ oses: [{ name: "linux", code: "1234" }],
+ segments: [{ name: "help", code: "5678" }],
+ state: "active",
+ totalMax: 100,
+ };
+
+ const campaignFragment: CampaignFragment = {
+ adSets: [adSet, adSet2],
+ advertiser: { id: "12345" },
+ budget: 100,
+ createdAt: undefined,
+ currency: "USD",
+ dailyBudget: 0,
+ dailyCap: 0,
+ endAt: undefined,
+ externalId: "",
+ format: CampaignFormat.PushNotification,
+ id: "000001",
+ name: "My first campaign",
+ pacingOverride: false,
+ pacingStrategy: CampaignPacingStrategies.ModelV1,
+ passThroughRate: 0,
+ paymentType: PaymentType.Radom,
+ priority: 1,
+ source: CampaignSource.SelfServe,
+ spent: 0,
+ startAt: undefined,
+ state: "active",
+ type: "paid",
+ };
+
+ const editForm = editCampaignValues(
+ campaignFragment,
+ campaignFragment.advertiser.id,
+ );
+ it("should result in a valid campaign form", () => {
+ const omitted = _.omit(editForm, ["newCreative"]);
+ expect(omitted).toMatchInlineSnapshot(`
+ {
+ "adSets": [
+ {
+ "conversions": [],
+ "creatives": [
+ {
+ "advertiserId": "12345",
+ "id": "1234",
+ "included": true,
+ "name": "a creative",
+ "payloadNotification": {
+ "body": "valid",
+ "targetUrl": "valid",
+ "title": "valid",
+ },
+ "state": "active",
+ "targetUrlValid": "",
+ "type": {
+ "code": "notification_v1_all",
+ },
+ },
+ {
+ "advertiserId": "12345",
+ "id": "1235",
+ "included": false,
+ "name": "a different creative",
+ "payloadNotification": {
+ "body": "valid",
+ "targetUrl": "valid",
+ "title": "valid",
+ },
+ "state": "active",
+ "targetUrlValid": "",
+ "type": {
+ "code": "notification_v1_all",
+ },
+ },
+ ],
+ "id": "11111",
+ "isNotTargeting": false,
+ "name": "11111",
+ "oses": [
+ {
+ "code": "1234",
+ "name": "macos",
+ },
+ ],
+ "segments": [
+ {
+ "code": "5678",
+ "name": "test",
+ },
+ ],
+ },
+ {
+ "conversions": [],
+ "creatives": [
+ {
+ "advertiserId": "12345",
+ "id": "1234",
+ "included": true,
+ "name": "a creative",
+ "payloadNotification": {
+ "body": "valid",
+ "targetUrl": "valid",
+ "title": "valid",
+ },
+ "state": "active",
+ "targetUrlValid": "",
+ "type": {
+ "code": "notification_v1_all",
+ },
+ },
+ {
+ "advertiserId": "12345",
+ "id": "1235",
+ "included": true,
+ "name": "a different creative",
+ "payloadNotification": {
+ "body": "valid",
+ "targetUrl": "valid",
+ "title": "valid",
+ },
+ "state": "active",
+ "targetUrlValid": "",
+ "type": {
+ "code": "notification_v1_all",
+ },
+ },
+ ],
+ "id": "22222",
+ "isNotTargeting": false,
+ "name": "22222",
+ "oses": [
+ {
+ "code": "1234",
+ "name": "linux",
+ },
+ ],
+ "segments": [
+ {
+ "code": "5678",
+ "name": "help",
+ },
+ ],
+ },
+ ],
+ "advertiserId": "12345",
+ "billingType": "cpm",
+ "budget": 100,
+ "currency": "USD",
+ "dailyBudget": 0,
+ "endAt": undefined,
+ "format": "PUSH_NOTIFICATION",
+ "geoTargets": [],
+ "id": "000001",
+ "isCreating": false,
+ "name": "My first campaign",
+ "paymentType": "RADOM",
+ "price": 6000,
+ "startAt": undefined,
+ "state": "active",
+ "type": "paid",
+ "validateStart": false,
+ }
+ `);
+ });
+
+ it("should resolve to update input", () => {
+ const update = transformEditForm(editForm, editForm.id ?? "");
+ expect(update).toMatchInlineSnapshot(`
+ {
+ "adSets": [
+ {
+ "ads": [
+ {
+ "creativeId": "1234",
+ "creativeSetId": "11111",
+ "price": "6",
+ "priceType": "VIEW",
+ },
+ ],
+ "id": "11111",
+ "oses": [
+ {
+ "code": "1234",
+ "name": "macos",
+ },
+ ],
+ "segments": [
+ {
+ "code": "5678",
+ "name": "test",
+ },
+ ],
+ },
+ {
+ "ads": [
+ {
+ "creativeId": "1234",
+ "creativeSetId": "22222",
+ "price": "6",
+ "priceType": "VIEW",
+ },
+ {
+ "creativeId": "1235",
+ "creativeSetId": "22222",
+ "price": "6",
+ "priceType": "VIEW",
+ },
+ ],
+ "id": "22222",
+ "oses": [
+ {
+ "code": "1234",
+ "name": "linux",
+ },
+ ],
+ "segments": [
+ {
+ "code": "5678",
+ "name": "help",
+ },
+ ],
+ },
+ ],
+ "budget": 100,
+ "dailyBudget": 0,
+ "endAt": undefined,
+ "id": "000001",
+ "name": "My first campaign",
+ "paymentType": "RADOM",
+ "startAt": undefined,
+ "state": "active",
+ "type": "paid",
+ }
+ `);
+ });
+});
diff --git a/src/user/library/index.ts b/src/user/library/index.ts
index 3a7d3781..85be5261 100644
--- a/src/user/library/index.ts
+++ b/src/user/library/index.ts
@@ -1,26 +1,25 @@
import {
CampaignFormat,
+ CampaignPacingStrategies,
ConfirmationType,
CreateAdInput,
CreateCampaignInput,
- CreateNotificationCreativeInput,
- GeocodeInput,
UpdateCampaignInput,
- UpdateNotificationCreativeInput,
} from "graphql/types";
import { CampaignFragment } from "graphql/campaign.generated";
import { AdFragment } from "graphql/ad-set.generated";
import {
+ AdSetForm,
Billing,
CampaignForm,
Conversion,
Creative,
initialCreative,
- OS,
Segment,
} from "user/views/adsManager/types";
import _ from "lodash";
import BigNumber from "bignumber.js";
+import { CreativeFragment } from "graphql/creative.generated";
const TYPE_CODE_LOOKUP: Record = {
notification_all_v1: "Push Notification",
@@ -36,14 +35,14 @@ export function transformNewForm(
): CreateCampaignInput {
return {
currency: form.currency,
- dailyCap: form.dailyCap,
+ externalId: "",
+ dailyCap: 1,
dailyBudget: form.dailyBudget,
endAt: form.endAt,
- pacingStrategy: form.pacingStrategy,
+ pacingStrategy: CampaignPacingStrategies.ModelV1,
geoTargets: form.geoTargets.map((g) => ({ code: g.code, name: g.name })),
name: form.name,
advertiserId: form.advertiserId,
- externalId: "",
format: form.format,
userId: userId,
source: "self_serve",
@@ -54,13 +53,14 @@ export function transformNewForm(
adSets: form.adSets.map((adSet) => ({
name: adSet.name,
billingType: form.billingType,
- execution: "per_click",
perDay: 1,
segments: adSet.segments.map((s) => ({ code: s.code, name: s.name })),
oses: adSet.oses,
totalMax: 10,
conversions: transformConversion(adSet.conversions),
- ads: adSet.creatives.map((ad) => transformCreative(ad, form)),
+ ads: adSet.creatives
+ .filter((c) => c.included)
+ .map((ad) => transformCreative(ad, form)),
})),
paymentType: form.paymentType,
};
@@ -96,44 +96,14 @@ export function transformCreative(
priceType = ConfirmationType.Click;
}
- return {
- webhooks: [],
- creativeId: creative.id!,
+ const createInput: CreateAdInput = {
price: price.toString(),
priceType: priceType,
};
-}
-
-export function creativeInput(
- advertiserId: string,
- creative: Creative,
- userId?: string,
-): CreateNotificationCreativeInput | UpdateNotificationCreativeInput {
- const baseNotification = {
- advertiserId,
- userId,
- name: creative.name,
- payload: {
- title: creative.title,
- body: creative.body,
- targetUrl: creative.targetUrl,
- },
- state: creative.state,
- };
- if (creative.id) {
- return {
- ...baseNotification,
- creativeId: creative.id,
- };
- }
+ createInput.creativeId = creative.id;
- return {
- ...baseNotification,
- type: {
- code: "notification_all_v1",
- },
- };
+ return createInput;
}
export function editCampaignValues(
@@ -153,33 +123,39 @@ export function editCampaignValues(
const seg = adSet.segments ?? ([] as Segment[]);
return {
- ...adSet,
id: adSet.id,
- conversions: adSet.conversions ?? [],
- oses: adSet.oses ?? ([] as OS[]),
- segments: adSet.segments ?? ([] as Segment[]),
+ conversions: (adSet.conversions ?? []).map((c) => ({
+ id: c.id,
+ type: c.type,
+ observationWindow: c.observationWindow,
+ urlPattern: c.urlPattern,
+ })),
+ oses: (adSet.oses ?? []).map((o) => ({ name: o.name, code: o.code })),
+ segments: (adSet.segments ?? []).map((o) => ({
+ name: o.name,
+ code: o.code,
+ })),
isNotTargeting: seg.length === 1 && seg[0].code === "Svp7l-zGN",
name: adSet.name || adSet.id.split("-")[0],
- creatives: creativeList(adSet.ads),
- };
+ creatives: creativeList(advertiserId, adSet.ads, ads),
+ } as AdSetForm;
}),
+ isCreating: false,
advertiserId,
- hasPaymentIntent: campaign.hasPaymentIntent ?? false,
- creatives: creativeList(ads).map((a) => a.id!),
newCreative: initialCreative,
- isCreating: false,
+ currency: campaign.currency,
price: price.toNumber(),
billingType: billingType,
validateStart: false,
budget: campaign.budget,
- currency: campaign.currency,
dailyBudget: campaign.dailyBudget,
- dailyCap: campaign.dailyCap,
endAt: campaign.endAt,
format: campaign.format,
- geoTargets: campaign.geoTargets ?? ([] as GeocodeInput[]),
+ geoTargets: (campaign.geoTargets ?? []).map((g) => ({
+ code: g.code,
+ name: g.name,
+ })),
name: campaign.name,
- pacingStrategy: campaign.pacingStrategy,
startAt: campaign.startAt,
state: campaign.state,
type: "paid",
@@ -187,36 +163,58 @@ export function editCampaignValues(
};
}
-function creativeList(ads?: AdFragment[] | null): Creative[] {
- return _.uniqBy(
- (ads ?? [])
- .filter((ad) => ad.creative != null && ad.state !== "deleted")
+function creativeList(
+ advertiserId: string,
+ adSetAds?: AdFragment[] | null,
+ allAds?: AdFragment[] | null,
+): Creative[] {
+ const filterAds = (a?: AdFragment[] | null, included?: boolean) => {
+ return (a ?? [])
+ .filter((ad) => ad.creative !== null && ad.state !== "deleted")
.map((ad) => {
const c = ad.creative;
return {
- creativeInstanceId: ad.id,
- id: c.id,
- name: c.name,
- targetUrl: c.payloadNotification!.targetUrl,
- title: c.payloadNotification!.title,
- body: c.payloadNotification!.body,
- targetUrlValidationResult: "",
- state: c.state,
+ ...validCreativeFields(c, advertiserId, included),
};
- }),
+ });
+ };
+
+ return _.uniqBy(
+ [...filterAds(adSetAds, true), ...filterAds(allAds, false)],
"id",
);
}
+export function validCreativeFields(
+ c: CreativeFragment | Creative,
+ advertiserId: string,
+ included?: boolean,
+): Creative {
+ return {
+ advertiserId,
+ id: c.id,
+ included: included ?? false,
+ name: c.name,
+ targetUrlValid: "",
+ state: c.state,
+ type: { code: c.type.code },
+ payloadNotification: c.payloadNotification
+ ? {
+ title: c.payloadNotification.title,
+ body: c.payloadNotification.body,
+ targetUrl: c.payloadNotification.targetUrl,
+ }
+ : undefined,
+ };
+}
+
export function transformEditForm(
form: CampaignForm,
id: string,
): UpdateCampaignInput {
return {
budget: form.budget,
- currency: form.currency,
dailyBudget: form.dailyBudget,
- dailyCap: form.dailyCap,
endAt: form.endAt,
id,
name: form.name,
@@ -228,11 +226,12 @@ export function transformEditForm(
id: adSet.id,
segments: adSet.segments.map((v) => ({ code: v.code, name: v.name })),
oses: adSet.oses.map((v) => ({ code: v.code, name: v.name })),
- ads: adSet.creatives.map((ad) => ({
- ...transformCreative(ad, form),
- id: ad.creativeInstanceId,
- creativeSetId: adSet.id,
- })),
+ ads: adSet.creatives
+ .filter((c) => c.included)
+ .map((ad) => ({
+ ...transformCreative(ad, form),
+ creativeSetId: adSet.id,
+ })),
})),
};
}
@@ -258,3 +257,26 @@ export function uiTextForCreativeTypeCode(creativeTypeCode: {
}): string {
return uiTextForCreativeType(creativeTypeCode.code);
}
+
+export function isCreativeTypeApplicableToCampaignFormat(
+ creativeTypeCode: {
+ code: string;
+ },
+ format: CampaignFormat,
+): boolean {
+ const { code } = creativeTypeCode;
+ switch (code) {
+ case "notification_all_v1":
+ return format === CampaignFormat.PushNotification;
+ case "new_tab_page_all_v1":
+ return format === CampaignFormat.NtpSi;
+ case "inline_content_all_v1":
+ return format === CampaignFormat.NewsDisplayAd;
+ case "search_all_v1":
+ return format === CampaignFormat.Search;
+ case "search_homepage_all_v1":
+ return format === CampaignFormat.SearchHomepage;
+ default:
+ return false;
+ }
+}
diff --git a/src/user/views/adsManager/types/index.ts b/src/user/views/adsManager/types/index.ts
index ff5a0ebc..fef57b7d 100644
--- a/src/user/views/adsManager/types/index.ts
+++ b/src/user/views/adsManager/types/index.ts
@@ -1,8 +1,4 @@
-import {
- CampaignFormat,
- CampaignPacingStrategies,
- PaymentType,
-} from "graphql/types";
+import { CampaignFormat, CreativeInput, PaymentType } from "graphql/types";
import { defaultEndDate, defaultStartDate } from "form/DateFieldHelpers";
import { MIN_PER_CAMPAIGN } from "validation/CampaignSchema";
import { IAdvertiser } from "auth/context/auth.interface";
@@ -20,20 +16,16 @@ export type CampaignForm = {
isCreating: boolean;
currency: string;
dailyBudget: number;
- dailyCap: number;
geoTargets: GeoTarget[];
adSets: AdSetForm[];
format: CampaignFormat;
newCreative?: Creative;
- creatives?: string[];
name: string;
state: string;
type: "paid";
// this is per click for CPC campaigns, but per thousand views for CPM campaigns
price: number;
billingType: Billing;
- pacingStrategy: CampaignPacingStrategies;
- hasPaymentIntent: boolean;
paymentType: PaymentType;
};
@@ -68,15 +60,13 @@ export type Segment = {
name: string;
};
-export type Creative = {
+export type Creative = CreativeInput & {
id?: string;
- name: string;
- title: string;
- body: string;
- targetUrl: string;
- targetUrlValidationResult?: string;
+ targetUrlValid?: string;
state?: string;
- creativeInstanceId?: string;
+ createdAt?: string;
+ modifiedAt?: string;
+ included: boolean;
};
export const initialConversion: Conversion = {
@@ -87,10 +77,15 @@ export const initialConversion: Conversion = {
export const initialCreative: Creative = {
name: "",
- title: "",
- body: "",
- targetUrl: "",
+ advertiserId: "",
+ payloadNotification: {
+ title: "",
+ targetUrl: "",
+ body: "",
+ },
+ type: { code: "" },
state: "draft",
+ included: false,
};
export const initialAdSet: AdSetForm = {
@@ -104,18 +99,17 @@ export const initialAdSet: AdSetForm = {
export const initialCampaign = (advertiser: IAdvertiser): CampaignForm => {
return {
+ isCreating: false,
advertiserId: advertiser.id,
startAt: defaultStartDate(),
endAt: defaultEndDate(),
validateStart: true,
- isCreating: false,
budget: MIN_PER_CAMPAIGN,
- hasPaymentIntent: false,
- currency: "USD",
dailyBudget: MIN_PER_CAMPAIGN,
- dailyCap: 1,
geoTargets: [],
+ newCreative: initialCreative,
billingType: "cpm",
+ currency: "USD",
price: 6,
adSets: [
{
@@ -126,9 +120,6 @@ export const initialCampaign = (advertiser: IAdvertiser): CampaignForm => {
name: "",
state: "draft",
type: "paid",
- pacingStrategy: CampaignPacingStrategies.ModelV1,
paymentType: advertiser.selfServicePaymentType,
- newCreative: initialCreative,
- creatives: [],
};
};
diff --git a/src/user/views/adsManager/views/advanced/components/adSet/NewAdSet.tsx b/src/user/views/adsManager/views/advanced/components/adSet/NewAdSet.tsx
index 0847965e..f36d63ec 100644
--- a/src/user/views/adsManager/views/advanced/components/adSet/NewAdSet.tsx
+++ b/src/user/views/adsManager/views/advanced/components/adSet/NewAdSet.tsx
@@ -13,8 +13,10 @@ import { CampaignForm, initialAdSet } from "user/views/adsManager/types";
import { useRef } from "react";
import RemoveCircleOutlineIcon from "@mui/icons-material/RemoveCircleOutline";
import { useIsEdit } from "form/FormikHelpers";
+import { useAdvertiserCreatives } from "user/hooks/useAdvertiserCreatives";
export function NewAdSet() {
+ const { creatives } = useAdvertiserCreatives();
const { isEdit } = useIsEdit();
const history = useHistory();
const { values } = useFormikContext();
@@ -22,6 +24,11 @@ export function NewAdSet() {
const selected = useRef(0);
selected.current = Number(params.get("current") ?? 0);
+ const initial = {
+ ...initialAdSet,
+ creatives,
+ };
+
return (
<>
@@ -77,7 +84,7 @@ export function NewAdSet() {
pb={0}
pt={0}
component={Button}
- onClick={() => helper.push(initialAdSet)}
+ onClick={() => helper.push(initial)}
border="1px solid #ededed"
>
diff --git a/src/user/views/adsManager/views/advanced/components/adSet/fields/AdSetAds.tsx b/src/user/views/adsManager/views/advanced/components/adSet/fields/AdSetAds.tsx
index 1289451a..123e825f 100644
--- a/src/user/views/adsManager/views/advanced/components/adSet/fields/AdSetAds.tsx
+++ b/src/user/views/adsManager/views/advanced/components/adSet/fields/AdSetAds.tsx
@@ -1,54 +1,29 @@
import { CardContainer } from "components/Card/CardContainer";
-import { Autocomplete, Checkbox, TextField } from "@mui/material";
-import { useRecentlyCreatedAdvertiserCreatives } from "user/hooks/useAdvertiserCreatives";
-import CheckBoxOutlineBlankIcon from "@mui/icons-material/CheckBoxOutlineBlank";
-import CheckBoxIcon from "@mui/icons-material/CheckBox";
-import { useField } from "formik";
-import { Creative } from "user/views/adsManager/types";
-import _ from "lodash";
+import { Typography } from "@mui/material";
+import { CampaignForm } from "user/views/adsManager/types";
+import { useFormikContext } from "formik";
+import { CampaignFormat } from "graphql/types";
+import { NotificationSelect } from "components/Creatives/NotificationSelect";
interface Props {
index: number;
}
export function AdSetAds({ index }: Props) {
- const creatives = useRecentlyCreatedAdvertiserCreatives();
- const [, meta, helper] = useField(`adSets.${index}.creatives`);
+ const { values } = useFormikContext();
return (
- option.name}
- renderOption={(props, option, { selected }) => (
-
- }
- checkedIcon={}
- style={{ marginRight: 8 }}
- checked={selected}
- />
- {option.name}
-
- )}
- renderInput={(params) => (
-
- )}
- isOptionEqualToValue={(option, value) => option.id === value.id}
- value={meta.value}
- onChange={(_ev, value) => {
- helper.setValue(_.sortBy(value, "name"));
- }}
- onBlur={() => helper.setTouched(true)}
- />
+
+ Select the Ads you would like to include in this ad set.
+
+
+ {values.format === CampaignFormat.PushNotification && (
+
+ )}
);
}
diff --git a/src/user/views/adsManager/views/advanced/components/form/EditCampaign.tsx b/src/user/views/adsManager/views/advanced/components/form/EditCampaign.tsx
index a5bdadd0..a9e259ae 100644
--- a/src/user/views/adsManager/views/advanced/components/form/EditCampaign.tsx
+++ b/src/user/views/adsManager/views/advanced/components/form/EditCampaign.tsx
@@ -36,14 +36,15 @@ export function EditCampaign() {
fetchPolicy: "cache-and-network",
});
+ const hasPaymentIntent = initialData?.campaign?.hasPaymentIntent;
const [mutation] = useUpdateCampaignMutation({
onCompleted(data) {
- if (initialData?.campaign?.hasPaymentIntent) {
+ if (hasPaymentIntent) {
history.push(
`/user/main/complete/edit?referenceId=${data.updateCampaign.id}`,
);
} else {
- createPaymentSession(data.updateCampaign.id);
+ void createPaymentSession(data.updateCampaign.id);
}
},
onError() {
@@ -85,7 +86,7 @@ export function EditCampaign() {
}}
validationSchema={CampaignSchema}
>
-
+
);
diff --git a/src/user/views/adsManager/views/advanced/components/form/components/BaseForm.tsx b/src/user/views/adsManager/views/advanced/components/form/components/BaseForm.tsx
index a2240756..bf964cb2 100644
--- a/src/user/views/adsManager/views/advanced/components/form/components/BaseForm.tsx
+++ b/src/user/views/adsManager/views/advanced/components/form/components/BaseForm.tsx
@@ -8,9 +8,16 @@ import { AdSetFields } from "user/views/adsManager/views/advanced/components/adS
import { NewAdSet } from "user/views/adsManager/views/advanced/components/adSet/NewAdSet";
import { Route, Switch, useRouteMatch } from "react-router-dom";
import { BudgetSettings } from "user/views/adsManager/views/advanced/components/campaign/BudgetSettings";
+import { FormContext } from "state/context";
+import { useState } from "react";
-export function BaseForm() {
+interface Props {
+ hasPaymentIntent?: boolean | null;
+}
+
+export function BaseForm({ hasPaymentIntent }: Props) {
const { url } = useRouteMatch();
+ const [isShowingAds, setIsShowingAds] = useState(false);
const steps = [
{
@@ -43,16 +50,28 @@ export function BaseForm() {
];
return (
-
+
+
+
);
}
diff --git a/src/user/views/adsManager/views/advanced/components/form/components/PaymentButton.tsx b/src/user/views/adsManager/views/advanced/components/form/components/PaymentButton.tsx
index 665b3d16..aa9c8fb1 100644
--- a/src/user/views/adsManager/views/advanced/components/form/components/PaymentButton.tsx
+++ b/src/user/views/adsManager/views/advanced/components/form/components/PaymentButton.tsx
@@ -1,17 +1,14 @@
import { FormikSubmitButton, useIsEdit } from "form/FormikHelpers";
-import { useFormikContext } from "formik";
-import { CampaignForm } from "user/views/adsManager/types";
-export function PaymentButton() {
+export function PaymentButton(props: { hasPaymentIntent: boolean }) {
const { isEdit } = useIsEdit();
- const { values } = useFormikContext();
const paymentText = "Make payment & submit for approval";
return (
();
@@ -20,13 +19,12 @@ export function Review() {
-
-
{values.adSets.map((adSet, adSetIdx) => (
))}
diff --git a/src/user/views/adsManager/views/advanced/components/review/components/AdReview.tsx b/src/user/views/adsManager/views/advanced/components/review/components/AdReview.tsx
deleted file mode 100644
index ce40d382..00000000
--- a/src/user/views/adsManager/views/advanced/components/review/components/AdReview.tsx
+++ /dev/null
@@ -1,29 +0,0 @@
-import { Stack, Typography } from "@mui/material";
-import { useRecentlyCreatedAdvertiserCreatives } from "user/hooks/useAdvertiserCreatives";
-import { BoxContainer } from "components/Box/BoxContainer";
-import { NotificationPreview } from "user/ads/NotificationPreview";
-import { ReviewContainer } from "user/views/adsManager/views/advanced/components/review/components/ReviewContainer";
-
-export function AdReview() {
- const creatives = useRecentlyCreatedAdvertiserCreatives();
-
- return (
-
- {creatives.length === 0 && (
- No Recently Created Ads
- )}
-
- {creatives.map((c) => (
-
-
-
- ))}
-
-
- );
-}
diff --git a/src/user/views/adsManager/views/advanced/components/review/components/AdSetReview.tsx b/src/user/views/adsManager/views/advanced/components/review/components/AdSetReview.tsx
index c988b55c..1917ae07 100644
--- a/src/user/views/adsManager/views/advanced/components/review/components/AdSetReview.tsx
+++ b/src/user/views/adsManager/views/advanced/components/review/components/AdSetReview.tsx
@@ -3,19 +3,23 @@ import { FormikErrors } from "formik";
import { ConversionDisplay } from "components/Conversion/ConversionDisplay";
import { ReviewField } from "./ReviewField";
import { ReviewContainer } from "user/views/adsManager/views/advanced/components/review/components/ReviewContainer";
+import { CampaignFormat } from "graphql/types";
+import { CreativeSpecificPreview } from "components/Creatives/CreativeSpecificPreview";
interface Props {
idx: number;
adSet: AdSetForm;
+ format: CampaignFormat;
errors?: string | FormikErrors;
}
export function AdSetReview({ adSet, idx, errors }: Props) {
+ const included = adSet.creatives.filter((c) => c.included);
+ const hasErrors = !!errors;
if (typeof errors === "string") {
return <>{errors}>;
}
- const hasErrors = !!errors;
const adSetError = errors;
const mapToString = (arr: Segment[] | OS[] | Creative[]) => {
@@ -47,9 +51,9 @@ export function AdSetReview({ adSet, idx, errors }: Props) {
conversions={adSet.conversions}
convErrors={adSetError?.conversions}
/>
-
diff --git a/src/validation/CampaignSchema.tsx b/src/validation/CampaignSchema.tsx
index 33f2fbb4..a1b9efa8 100644
--- a/src/validation/CampaignSchema.tsx
+++ b/src/validation/CampaignSchema.tsx
@@ -1,12 +1,8 @@
import { array, boolean, date, number, object, ref, string } from "yup";
import { startOfDay } from "date-fns";
import { twoDaysOut } from "form/DateFieldHelpers";
-import _ from "lodash";
-
-export const SimpleUrlRegexp = /https:\/\/.+\.[a-zA-Z]{2,}\/?.*/g;
-const NoSpacesRegex = /^\S*$/;
-const TrailingAsteriskRegex = /.*\*$/;
-const HttpsRegex = /^https:\/\//;
+import { TrailingAsteriskRegex } from "validation/regex";
+import { CreativeSchema } from "validation/CreativeSchema";
export const MIN_PER_DAY = 33;
export const MIN_PER_CAMPAIGN = 100;
@@ -20,34 +16,9 @@ export const CampaignSchema = object().shape({
MIN_PER_CAMPAIGN,
`Lifetime budget must be $${MIN_PER_CAMPAIGN} or more`,
),
- isCreating: boolean().default(false),
newCreative: object().when("isCreating", {
is: true,
- then: (schema) =>
- schema.shape({
- name: string().label("Creative Name").required("Ad Name is required"),
- title: string()
- .label("Title")
- .max(30, "Maximum 30 Characters")
- .required("Ad Title is required"),
- body: string()
- .label("Body")
- .max(60, "Maximum 60 Characters")
- .required("Ad Body is required"),
- targetUrlValidationResult: string().test({
- test: (value) => _.isEmpty(value),
- message: ({ value }) => value,
- }),
- targetUrl: string()
- .label("Target Url")
- .required("Ad URL is required")
- .matches(NoSpacesRegex, `Ad URL must not contain any whitespace`)
- .matches(HttpsRegex, `URL must start with https://`)
- .matches(
- SimpleUrlRegexp,
- `Please enter a valid Ad URL, for example https://brave.com`,
- ),
- }),
+ then: () => CreativeSchema,
}),
validateStart: boolean(),
dailyBudget: number()
@@ -148,7 +119,11 @@ export const CampaignSchema = object().shape({
.required("Conversion Type required."),
}),
),
- creatives: array().min(1, "Ad Sets must have at least one Ad"),
+ creatives: array().test(
+ "min-length",
+ "Ad Sets must have at least one Ad",
+ (value) => (value ?? []).filter((c) => c.included).length > 0,
+ ),
}),
),
});
diff --git a/src/validation/CreativeSchema.test.ts b/src/validation/CreativeSchema.test.ts
new file mode 100644
index 00000000..a5242735
--- /dev/null
+++ b/src/validation/CreativeSchema.test.ts
@@ -0,0 +1,65 @@
+import { CreativeSchema } from "./CreativeSchema";
+import { produce } from "immer";
+
+const validPushCreative = {
+ name: "some creative",
+ type: { code: "notification_all_v1", name: "" },
+ state: "under_review",
+ payloadNotification: {
+ body: "abc",
+ title: "xyz",
+ targetUrl: "https://hello.com",
+ },
+};
+
+it("should pass on a valid object", () => {
+ CreativeSchema.validateSync(validPushCreative);
+});
+
+it.each([
+ "https://example.com",
+ "https://www.secure2.sophos.com/en-us/security-news-trends/whitepapers/gated-wp/endpoint-buyers-guide.aspx?cmp=134766&utm_source=Brave&utm_campaign=ASEAN%7CBrave%7CEndpointBuyer%27sGuide%7CITFocus&utm_medium=cpc&utm_content=SM116529",
+ "https://test.io?bar=baz#foo",
+])("should pass if push notification is selected for %s", (value) => {
+ const c = produce(validPushCreative, (draft) => {
+ draft.payloadNotification.targetUrl = value;
+ });
+
+ expect(() => CreativeSchema.validateSync(c));
+});
+
+it.each(["notAUrl", "gopher://blah.com", "httpx://balh.com"])(
+ "should reject as invalid url if push notification is selected for %s",
+ (value) => {
+ const c = produce(validPushCreative, (draft) => {
+ draft.payloadNotification.targetUrl = value;
+ });
+ expect(() => CreativeSchema.validateSync(c)).toThrowError(
+ "URL must start with https://",
+ );
+ },
+);
+
+it.each(["https://with a space"])(
+ "should reject as invalid input if push notification is selected for %s",
+ (value) => {
+ const c = produce(validPushCreative, (draft) => {
+ draft.payloadNotification.targetUrl = value;
+ });
+ expect(() => CreativeSchema.validateSync(c)).toThrowError(
+ "URL must not contain any whitespace",
+ );
+ },
+);
+
+it.each(["http://example.com"])(
+ "should reject as not secure if push notification is selected for %s",
+ (value) => {
+ const c = produce(validPushCreative, (draft) => {
+ draft.payloadNotification.targetUrl = value;
+ });
+ expect(() => CreativeSchema.validateSync(c)).toThrowError(
+ "URL must start with https://",
+ );
+ },
+);
diff --git a/src/validation/CreativeSchema.tsx b/src/validation/CreativeSchema.tsx
new file mode 100644
index 00000000..1abbbc11
--- /dev/null
+++ b/src/validation/CreativeSchema.tsx
@@ -0,0 +1,48 @@
+import { object, string } from "yup";
+import { HttpsRegex, NoSpacesRegex, SimpleUrlRegexp } from "validation/regex";
+import _ from "lodash";
+
+export const CreativeSchema = object().shape({
+ name: string().label("Creative Name").required(),
+ type: object().shape({
+ code: string()
+ .oneOf([
+ "notification_all_v1",
+ "new_tab_page_all_v1",
+ "inline_content_all_v1",
+ "search_all_v1",
+ "search_homepage_all_v1",
+ ])
+ .label("Creative Type")
+ .required("Creative Type is required"),
+ name: string(),
+ }),
+ state: string()
+ .oneOf(["draft", "under_review"])
+ .label("State")
+ .required()
+ .default("draft"),
+ targetUrlValid: string().test({
+ test: (value) => _.isEmpty(value),
+ message: ({ value }) => value,
+ }),
+ payloadNotification: object()
+ .nullable()
+ .when("type.code", {
+ is: "notification_all_v1",
+ then: (schema) =>
+ schema.required().shape({
+ body: string().label("Body").required().max(60),
+ targetUrl: string()
+ .label("Target Url")
+ .required("URL is required")
+ .matches(NoSpacesRegex, `URL must not contain any whitespace`)
+ .matches(HttpsRegex, `URL must start with https://`)
+ .matches(
+ SimpleUrlRegexp,
+ `Please enter a valid Ad URL, for example https://brave.com`,
+ ),
+ title: string().label("Title").required().max(30),
+ }),
+ }),
+});
diff --git a/src/validation/regex.ts b/src/validation/regex.ts
new file mode 100644
index 00000000..4d171332
--- /dev/null
+++ b/src/validation/regex.ts
@@ -0,0 +1,4 @@
+export const SimpleUrlRegexp = /https:\/\/.+\.[a-zA-Z]{2,}\/?.*/g;
+export const NoSpacesRegex = /^\S*$/;
+export const TrailingAsteriskRegex = /.*\*$/;
+export const HttpsRegex = /^https:\/\//;