diff --git a/src/components/Creatives/CreativeAutocomplete.tsx b/src/components/Creatives/CreativeAutocomplete.tsx new file mode 100644 index 000000000..138e4b9fe --- /dev/null +++ b/src/components/Creatives/CreativeAutocomplete.tsx @@ -0,0 +1,94 @@ +import { + Autocomplete, + Box, + Button, + Checkbox, + TextField, + Typography, +} from "@mui/material"; +import { CreativeFragment } from "graphql/creative.generated"; +import { uiTextForCreativeTypeCode } from "user/library"; +import CheckBoxIcon from "@mui/icons-material/CheckBox"; +import CheckBoxOutlineBlankIcon from "@mui/icons-material/CheckBoxOutlineBlank"; +import moment from "moment"; +import { useFormikContext } from "formik"; +import { CampaignForm } from "user/views/adsManager/types"; +import _ from "lodash"; +import { useState } from "react"; + +interface CreativeAutocompleteProps { + label: string; + options: readonly CreativeFragment[]; + alreadyAssociatedCreativeIds: string[]; + onSetValue: () => void; +} + +export function CreativeAutocomplete(params: CreativeAutocompleteProps) { + const { setFieldValue } = useFormikContext(); + const label = params.label; + const [alreadyAdded, setAlreadyAdded] = useState( + params.alreadyAssociatedCreativeIds, + ); + + return ( + + { + const mapped = value.map((c) => c.id); + setAlreadyAdded(mapped); + await setFieldValue("creatives", _.uniq(mapped)); + }} + value={params.options.filter((o) => alreadyAdded.includes(o.id))} + renderInput={(params) => ( + + )} + renderOption={(props, option, { selected }) => { + return ( +
  • + } + checkedIcon={} + style={{ marginRight: 8 }} + checked={ + selected || + params.alreadyAssociatedCreativeIds.includes(option?.id) + } + /> + {option.name} + + created {moment(option.createdAt).fromNow()} + +
  • + ); + }} + getOptionLabel={(opt) => opt?.name ?? ""} + getOptionDisabled={(opt) => + params.alreadyAssociatedCreativeIds.includes(opt?.id) + } + groupBy={(opt) => uiTextForCreativeTypeCode(opt.type)} + /> + +
    + ); +} diff --git a/src/components/Creatives/NewCreative.tsx b/src/components/Creatives/NewCreative.tsx index f29e1c6ae..21471a5e2 100644 --- a/src/components/Creatives/NewCreative.tsx +++ b/src/components/Creatives/NewCreative.tsx @@ -7,21 +7,21 @@ import { AdvertiserCreativesDocument, useCreateCreativeMutation, } from "graphql/creative.generated"; -import { useState } from "react"; import { CardContainer } from "components/Card/CardContainer"; import { ErrorDetail } from "components/Error/ErrorDetail"; import { FormikSubmitButton } from "form/FormikHelpers"; import { CreativeSchema } from "validation/CreativeSchema"; import MiniSideBar from "components/Drawer/MiniSideBar"; -import { PersistCreativeValues } from "form/PersistCreativeValues"; +import { + clearCreativeValues, + PersistCreativeValues, +} from "form/PersistCreativeValues"; import { CreativeTypePreview } from "components/Creatives/CreativeTypePreview"; import { useAdvertiser } from "auth/hooks/queries/useAdvertiser"; - -function wait(ms: number) { - return new Promise((resolve) => window.setTimeout(resolve, ms)); -} +import { useState } from "react"; export function NewCreative() { + const [id, setId] = useState(); const { advertiser } = useAdvertiser(); const history = useHistory(); const location = useLocation(); @@ -50,10 +50,13 @@ export function NewCreative() { variables: { advertiserId: advertiser.id }, }, ], + onCompleted(data) { + setId(data.createCreative.id); + history.replace("/user/main/creatives"); + clearCreativeValues(); + }, }); - const [id, setId] = useState(""); - const doSubmit = async (values: CreativeInput) => { const input: CreativeInput = { advertiserId: advertiser.id, @@ -63,15 +66,9 @@ export function NewCreative() { type: values.type, }; - const response = await createCreativeMutation({ + void createCreativeMutation({ variables: { input }, }); - const id = response.data?.createCreative.id; - if (id) { - setId(id); - await wait(2000); - history.replace(id); - } }; return ( @@ -112,7 +109,7 @@ export function NewCreative() { diff --git a/src/form/PersistCreativeValues.tsx b/src/form/PersistCreativeValues.tsx index b27694672..296bf49ed 100644 --- a/src/form/PersistCreativeValues.tsx +++ b/src/form/PersistCreativeValues.tsx @@ -4,7 +4,8 @@ import _ from "lodash"; import { CreativeInput } from "graphql/types"; export const PersistCreativeValues = () => { - const { values, setValues, dirty } = useFormikContext(); + const { values, setValues, dirty, initialStatus } = + useFormikContext(); // read the values from localStorage on load useEffect(() => { @@ -21,6 +22,11 @@ export const PersistCreativeValues = () => { } }, [values, dirty]); + // save the values to localStorage on update + useEffect(() => { + console.log(initialStatus); + }, [initialStatus]); + return null; }; diff --git a/src/user/ads/AdsNewAd.tsx b/src/user/ads/AdsNewAd.tsx new file mode 100644 index 000000000..7f82c9597 --- /dev/null +++ b/src/user/ads/AdsNewAd.tsx @@ -0,0 +1,65 @@ +import { Typography, Divider } 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 { CreativeAutocomplete } from "components/Creatives/CreativeAutocomplete"; +import { CardContainer } from "components/Card/CardContainer"; + +function filterCreativesBasedOnCampaignFormat( + creatives: CreativeFragment[], + campaignFormat: CampaignFormat | null, +): CreativeFragment[] { + if (!campaignFormat) return creatives; + + return creatives.filter((c) => + isCreativeTypeApplicableToCampaignFormat(c.type, campaignFormat), + ); +} + +export function AdsNewAd(props: { onAddCreative: () => void }) { + const { values } = useFormikContext(); + const { advertiser } = useAdvertiser(); + const { data } = useAdvertiserCreativesQuery({ + variables: { advertiserId: advertiser.id }, + }); + + const allCreativesForAdvertiser = data?.advertiser?.creatives ?? []; + const associatedCreatives = values.creatives ?? []; + const creativeOptionList = _.orderBy( + filterCreativesBasedOnCampaignFormat( + allCreativesForAdvertiser, + values.format, + ), + ["type.code", "createdAt"], + ["asc", "desc"], + ) as CreativeFragment[]; + + return ( + + Add an existing creative + + + + + Creatives are modular building blocks that can be paired with ad sets to + build ads. + + + { + props.onAddCreative(); + }} + /> + + ); +} diff --git a/src/user/ads/NewAd.tsx b/src/user/ads/NewAd.tsx index d59e4adf4..8ea525fbc 100644 --- a/src/user/ads/NewAd.tsx +++ b/src/user/ads/NewAd.tsx @@ -1,17 +1,21 @@ import { useRecentlyCreatedAdvertiserCreatives } from "user/hooks/useAdvertiserCreatives"; import { CardContainer } from "components/Card/CardContainer"; -import { Box, Button, Stack } from "@mui/material"; +import { Box, Button, IconButton, Link, Stack } from "@mui/material"; import { useState } from "react"; import { BoxContainer } from "components/Box/BoxContainer"; import AddCircleOutlineIcon from "@mui/icons-material/AddCircleOutline"; import RemoveCircleOutlineIcon from "@mui/icons-material/RemoveCircleOutline"; import { NotificationAd } from "user/ads/NotificationAd"; -import { useField } from "formik"; +import { useField, useFormikContext } from "formik"; import { NotificationPreview } from "components/Creatives/NotificationPreview"; +import { AdsNewAd } from "user/ads/AdsNewAd"; +import { CampaignForm, Creative } from "user/views/adsManager/types"; +import _ from "lodash"; export function NewAd() { const [, meta, helper] = useField("isCreating"); const [showForm, setShowForm] = useState(false); + const [useExisting, setUseExisting] = useState(false); const creatives = useRecentlyCreatedAdvertiserCreatives(); return ( @@ -24,7 +28,7 @@ export function NewAd() { flexWrap="wrap" > {(creatives ?? []).map((c, idx) => ( - + } key={idx}> ))} @@ -38,6 +42,7 @@ export function NewAd() { onClick={() => { helper.setValue(!meta.value); setShowForm(!showForm); + setUseExisting(false); }} > {showForm ? ( @@ -48,7 +53,22 @@ export function NewAd() { + {!useExisting && ( + { + setUseExisting(true); + setShowForm(false); + }} + underline="none" + sx={{ cursor: "pointer" }} + > + Choose a previously made Creative + + )} + {useExisting && ( + setUseExisting(!useExisting)} /> + )} {showForm && ( { @@ -60,3 +80,24 @@ export function NewAd() { ); } + +const RemoveHeader = (props: { creative: Creative }) => { + const { values, setFieldValue } = useFormikContext(); + + const onRemoveCreative = async (c: Creative, v: string[] | undefined) => { + const removed = _.filter(v ?? [], (n) => n !== c.id); + void setFieldValue("creatives", removed); + }; + + return ( + + {props.creative.name} + onRemoveCreative(props.creative, values.creatives)} + sx={{ p: 0 }} + > + + + + ); +}; diff --git a/src/user/hooks/useAdvertiserCreatives.ts b/src/user/hooks/useAdvertiserCreatives.ts index 3117fe858..5db502a68 100644 --- a/src/user/hooks/useAdvertiserCreatives.ts +++ b/src/user/hooks/useAdvertiserCreatives.ts @@ -9,6 +9,7 @@ export function useAdvertiserCreatives(): Creative[] { const { data } = useAdvertiserCreativesQuery({ variables: { advertiserId: advertiser.id }, }); + return (data?.advertiser?.creatives ?? []).map((c) => ({ id: c.id, name: c.name, diff --git a/src/user/library/index.ts b/src/user/library/index.ts index b8f146c8d..c66ffb231 100644 --- a/src/user/library/index.ts +++ b/src/user/library/index.ts @@ -255,3 +255,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; + } +}