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 ( -
    - }> - - {steps.map((s) => ( - - {s.component} - - ))} - - -
    + +
    + + } + > + + {steps.map((s) => ( + + {s.component} + + ))} + + +
    +
    ); } 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:\/\//;