From 87a2a67d2d2ea3091181ba10ab77ba37f641b685 Mon Sep 17 00:00:00 2001 From: Ian Krieger <48930920+IanKrieger@users.noreply.github.com> Date: Tue, 15 Aug 2023 13:00:59 -0400 Subject: [PATCH] feat: clone campaign (#859) * feat: clone campaigns * feat: clone campaign * fix: payment type --- src/components/Campaigns/CloneCampaign.tsx | 111 ++++++++ src/components/Card/CardContainer.tsx | 2 +- src/components/Steps/ActionButtons.tsx | 21 +- src/components/Steps/StepDrawer.tsx | 2 +- src/form/fragmentUtil.ts | 78 ++++++ src/graphql/ad-set.generated.tsx | 19 +- src/graphql/ad-set.graphql | 4 + src/graphql/advertiser.generated.tsx | 3 +- src/graphql/analytics-overview.generated.tsx | 3 +- src/graphql/campaign.generated.tsx | 36 ++- src/graphql/campaign.graphql | 5 + src/graphql/common.generated.tsx | 3 +- src/graphql/creative.generated.tsx | 3 +- src/graphql/types.ts | 10 +- src/graphql/url.generated.tsx | 3 +- src/graphql/user.generated.tsx | 3 +- src/user/campaignList/CampaignList.tsx | 279 ++++++++++--------- src/user/views/user/CampaignView.tsx | 82 +++++- 18 files changed, 499 insertions(+), 168 deletions(-) create mode 100644 src/components/Campaigns/CloneCampaign.tsx create mode 100644 src/form/fragmentUtil.ts diff --git a/src/components/Campaigns/CloneCampaign.tsx b/src/components/Campaigns/CloneCampaign.tsx new file mode 100644 index 00000000..67d3a4c7 --- /dev/null +++ b/src/components/Campaigns/CloneCampaign.tsx @@ -0,0 +1,111 @@ +import { + Box, + Button, + Chip, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + LinearProgress, +} from "@mui/material"; +import { + CampaignFragment, + useCreateCampaignMutation, +} from "graphql/campaign.generated"; +import { useHistory } from "react-router-dom"; +import { useState } from "react"; +import { AdvertiserCampaignsDocument } from "graphql/advertiser.generated"; +import { createCampaignFromFragment } from "form/fragmentUtil"; +import { useAdvertiser } from "auth/hooks/queries/useAdvertiser"; +import ContentCopyIcon from "@mui/icons-material/ContentCopy"; + +interface Props { + campaignFragment?: CampaignFragment | null; + useChip?: boolean; +} + +export function CloneCampaign({ campaignFragment, useChip }: Props) { + const { advertiser } = useAdvertiser(); + const history = useHistory(); + const [open, setOpen] = useState(false); + + const [copyCampaign, { loading }] = useCreateCampaignMutation({ + refetchQueries: [ + { + query: AdvertiserCampaignsDocument, + variables: { id: advertiser.id }, + }, + ], + onCompleted(data) { + history.push( + `/user/main/adsmanager/advanced/${data.createCampaign.id}/settings`, + ); + }, + onError() { + alert(`Unable to clone campaign`); + }, + }); + + return ( + + {useChip ? ( + { + setOpen(true); + }} + disabled={loading || !campaignFragment} + icon={} + /> + ) : ( + + )} + setOpen(false)}> + {`Copy campaign: "${campaignFragment?.name}"?`} + + + Copying a campaign will take all properties including ad sets and + ads, and create a new draft campaign with them. + + {loading && } + + + + + + + + ); +} diff --git a/src/components/Card/CardContainer.tsx b/src/components/Card/CardContainer.tsx index 31dd92b4..343eabd8 100644 --- a/src/components/Card/CardContainer.tsx +++ b/src/components/Card/CardContainer.tsx @@ -4,7 +4,7 @@ import { SxProps } from "@mui/system"; export function CardContainer( props: { - header?: string; + header?: ReactNode; additionalAction?: ReactNode; sx?: SxProps; } & PropsWithChildren, diff --git a/src/components/Steps/ActionButtons.tsx b/src/components/Steps/ActionButtons.tsx index 6b0b9b78..362b0fd5 100644 --- a/src/components/Steps/ActionButtons.tsx +++ b/src/components/Steps/ActionButtons.tsx @@ -1,34 +1,41 @@ import { Button, Stack } from "@mui/material"; import { useContext } from "react"; import { DraftContext } from "state/context"; -import { useHistory } from "react-router-dom"; import { useFormikContext } from "formik"; import { CampaignForm } from "user/views/adsManager/types"; +import ArrowBackIcon from "@mui/icons-material/ArrowBack"; +import RemoveIcon from "@mui/icons-material/Remove"; +import { Link as RouterLink } from "react-router-dom"; export function ActionButtons() { - const history = useHistory(); const { values } = useFormikContext(); const { setDrafts } = useContext(DraftContext); return ( - {values.draftId !== undefined && ( )} + ); } diff --git a/src/components/Steps/StepDrawer.tsx b/src/components/Steps/StepDrawer.tsx index eb8712fe..c89c70fc 100644 --- a/src/components/Steps/StepDrawer.tsx +++ b/src/components/Steps/StepDrawer.tsx @@ -14,7 +14,7 @@ import { NextAndBack } from "components/Steps/NextAndBack"; import { useHistory } from "react-router-dom"; import { ActionButtons } from "components/Steps/ActionButtons"; -const drawerWidth = 250; +const drawerWidth = 222; interface Props { steps: { diff --git a/src/form/fragmentUtil.ts b/src/form/fragmentUtil.ts new file mode 100644 index 00000000..b7db3465 --- /dev/null +++ b/src/form/fragmentUtil.ts @@ -0,0 +1,78 @@ +import { CreateAdSetInput, CreateCampaignInput } from "graphql/types"; +import moment from "moment"; +import { CampaignFragment } from "graphql/campaign.generated"; +import { AdSetFragment } from "graphql/ad-set.generated"; + +export function createCampaignFromFragment( + data: CampaignFragment, +): CreateCampaignInput { + const adSets: CreateAdSetInput[] = data.adSets.map((adSet) => + createAdSetFromFragment(adSet), + ); + + const two = moment().utc().add(3, "days"); + return { + adSets: adSets && adSets.length > 0 ? adSets : undefined, + advertiserId: data.advertiser.id, + budget: data.budget, + currency: data.currency, + dailyBudget: data.dailyBudget, + dailyCap: data.dailyCap, + dayPartings: (data.dayPartings ?? []).map((d) => ({ + dow: d.dow, + startMinute: d.startMinute, + endMinute: d.endMinute, + })), + dayProportion: data.dayProportion, + startAt: two.startOf("day").toISOString(), + endAt: two.endOf("day").toISOString(), + externalId: data.externalId, + format: data.format, + geoTargets: (data.geoTargets ?? []).map((g) => ({ + code: g.code, + name: g.name, + })), + name: `${data.name} - Copy`, + pacingStrategy: data.pacingStrategy, + source: data.source.toLowerCase(), + state: "draft", + type: data.type, + paymentType: data.paymentType, + }; +} + +export function createAdSetFromFragment( + data: AdSetFragment, + campaignId?: string, +): CreateAdSetInput { + return { + campaignId, + ads: (data.ads ?? []) + .filter((ad) => ad.state !== "deleted") + .map((ad) => ({ + creativeId: ad.creative.id, + price: ad.price, + priceType: ad.priceType, + })), + bannedKeywords: data.bannedKeywords, + billingType: data.billingType ?? "cpm", + conversions: (data.conversions ?? []).map((c) => ({ + observationWindow: c.observationWindow, + type: c.type, + urlPattern: c.urlPattern, + })), + execution: data.execution, + keywordSimilarity: data.keywordSimilarity, + keywords: data.keywords, + name: `${data.name ? data.name : data.id.split("-")[0]} - Copy`, + negativeKeywords: data.negativeKeywords, + oses: (data.oses ?? []).map((o) => ({ name: o.name, code: o.code })), + perDay: data.perDay, + segments: (data.segments ?? []).map((o) => ({ + name: o.name, + code: o.code, + })), + state: data.state, + totalMax: data.totalMax, + }; +} diff --git a/src/graphql/ad-set.generated.tsx b/src/graphql/ad-set.generated.tsx index c3d64ee9..e04e0f5b 100644 --- a/src/graphql/ad-set.generated.tsx +++ b/src/graphql/ad-set.generated.tsx @@ -1,9 +1,8 @@ import * as Types from "./types"; -import * as Apollo from "@apollo/client"; import { gql } from "@apollo/client"; import { CreativeFragmentDoc } from "./creative.generated"; - +import * as Apollo from "@apollo/client"; const defaultOptions = {} as const; export type AdSetFragment = { __typename?: "AdSet"; @@ -15,6 +14,10 @@ export type AdSetFragment = { perDay: number; state: string; execution: string; + keywords?: Array | null; + keywordSimilarity?: number | null; + negativeKeywords?: Array | null; + bannedKeywords?: Array | null; segments?: Array<{ __typename?: "Segment"; code: string; @@ -91,6 +94,10 @@ export type CreateAdSetMutation = { perDay: number; state: string; execution: string; + keywords?: Array | null; + keywordSimilarity?: number | null; + negativeKeywords?: Array | null; + bannedKeywords?: Array | null; segments?: Array<{ __typename?: "Segment"; code: string; @@ -145,6 +152,10 @@ export type UpdateAdSetMutation = { perDay: number; state: string; execution: string; + keywords?: Array | null; + keywordSimilarity?: number | null; + negativeKeywords?: Array | null; + bannedKeywords?: Array | null; segments?: Array<{ __typename?: "Segment"; code: string; @@ -214,6 +225,10 @@ export const AdSetFragmentDoc = gql` perDay state execution + keywords + keywordSimilarity + negativeKeywords + bannedKeywords segments { code name diff --git a/src/graphql/ad-set.graphql b/src/graphql/ad-set.graphql index 858e072d..4a6e6745 100644 --- a/src/graphql/ad-set.graphql +++ b/src/graphql/ad-set.graphql @@ -7,6 +7,10 @@ fragment AdSet on AdSet { perDay state execution + keywords + keywordSimilarity + negativeKeywords + bannedKeywords segments { code name diff --git a/src/graphql/advertiser.generated.tsx b/src/graphql/advertiser.generated.tsx index ca1215a3..82cde162 100644 --- a/src/graphql/advertiser.generated.tsx +++ b/src/graphql/advertiser.generated.tsx @@ -1,9 +1,8 @@ import * as Types from "./types"; -import * as Apollo from "@apollo/client"; import { gql } from "@apollo/client"; import { CampaignSummaryFragmentDoc } from "./campaign.generated"; - +import * as Apollo from "@apollo/client"; const defaultOptions = {} as const; export type AdvertiserSummaryFragment = { __typename?: "Advertiser"; diff --git a/src/graphql/analytics-overview.generated.tsx b/src/graphql/analytics-overview.generated.tsx index 8c4490ce..4fc01ff7 100644 --- a/src/graphql/analytics-overview.generated.tsx +++ b/src/graphql/analytics-overview.generated.tsx @@ -1,8 +1,7 @@ import * as Types from "./types"; -import * as Apollo from "@apollo/client"; import { gql } from "@apollo/client"; - +import * as Apollo from "@apollo/client"; const defaultOptions = {} as const; export type EngagementFragment = { __typename?: "Engagement"; diff --git a/src/graphql/campaign.generated.tsx b/src/graphql/campaign.generated.tsx index bb7443a4..ac8bba69 100644 --- a/src/graphql/campaign.generated.tsx +++ b/src/graphql/campaign.generated.tsx @@ -1,9 +1,8 @@ import * as Types from "./types"; -import * as Apollo from "@apollo/client"; import { gql } from "@apollo/client"; import { AdSetFragmentDoc } from "./ad-set.generated"; - +import * as Apollo from "@apollo/client"; const defaultOptions = {} as const; export type CampaignFragment = { __typename?: "Campaign"; @@ -30,6 +29,12 @@ export type CampaignFragment = { dayProportion?: number | null; stripePaymentId?: string | null; hasPaymentIntent?: boolean | null; + dayPartings?: Array<{ + __typename?: "DayParting"; + dow: string; + startMinute: number; + endMinute: number; + }> | null; geoTargets?: Array<{ __typename?: "Geocode"; code: string; @@ -45,6 +50,10 @@ export type CampaignFragment = { perDay: number; state: string; execution: string; + keywords?: Array | null; + keywordSimilarity?: number | null; + negativeKeywords?: Array | null; + bannedKeywords?: Array | null; segments?: Array<{ __typename?: "Segment"; code: string; @@ -130,6 +139,10 @@ export type CampaignAdsFragment = { perDay: number; state: string; execution: string; + keywords?: Array | null; + keywordSimilarity?: number | null; + negativeKeywords?: Array | null; + bannedKeywords?: Array | null; segments?: Array<{ __typename?: "Segment"; code: string; @@ -199,6 +212,12 @@ export type LoadCampaignQuery = { dayProportion?: number | null; stripePaymentId?: string | null; hasPaymentIntent?: boolean | null; + dayPartings?: Array<{ + __typename?: "DayParting"; + dow: string; + startMinute: number; + endMinute: number; + }> | null; geoTargets?: Array<{ __typename?: "Geocode"; code: string; @@ -214,6 +233,10 @@ export type LoadCampaignQuery = { perDay: number; state: string; execution: string; + keywords?: Array | null; + keywordSimilarity?: number | null; + negativeKeywords?: Array | null; + bannedKeywords?: Array | null; segments?: Array<{ __typename?: "Segment"; code: string; @@ -281,6 +304,10 @@ export type LoadCampaignAdsQuery = { perDay: number; state: string; execution: string; + keywords?: Array | null; + keywordSimilarity?: number | null; + negativeKeywords?: Array | null; + bannedKeywords?: Array | null; segments?: Array<{ __typename?: "Segment"; code: string; @@ -373,6 +400,11 @@ export const CampaignFragmentDoc = gql` stripePaymentId paymentType hasPaymentIntent + dayPartings { + dow + startMinute + endMinute + } geoTargets { code name diff --git a/src/graphql/campaign.graphql b/src/graphql/campaign.graphql index 1ca98e5d..4d7331bf 100644 --- a/src/graphql/campaign.graphql +++ b/src/graphql/campaign.graphql @@ -23,6 +23,11 @@ fragment Campaign on Campaign { stripePaymentId paymentType hasPaymentIntent + dayPartings { + dow + startMinute + endMinute + } geoTargets { code name diff --git a/src/graphql/common.generated.tsx b/src/graphql/common.generated.tsx index 129af17d..49be7de1 100644 --- a/src/graphql/common.generated.tsx +++ b/src/graphql/common.generated.tsx @@ -1,8 +1,7 @@ import * as Types from "./types"; -import * as Apollo from "@apollo/client"; import { gql } from "@apollo/client"; - +import * as Apollo from "@apollo/client"; const defaultOptions = {} as const; export type GeocodeFragment = { __typename?: "ActiveGeocodesEntry"; diff --git a/src/graphql/creative.generated.tsx b/src/graphql/creative.generated.tsx index 2b2975b6..8a4a8126 100644 --- a/src/graphql/creative.generated.tsx +++ b/src/graphql/creative.generated.tsx @@ -1,8 +1,7 @@ import * as Types from "./types"; -import * as Apollo from "@apollo/client"; import { gql } from "@apollo/client"; - +import * as Apollo from "@apollo/client"; const defaultOptions = {} as const; export type CreativeFragment = { __typename?: "Creative"; diff --git a/src/graphql/types.ts b/src/graphql/types.ts index d1345779..810f71d6 100644 --- a/src/graphql/types.ts +++ b/src/graphql/types.ts @@ -108,7 +108,8 @@ export enum ConfirmationType { } export type CreateAdInput = { - creativeId: Scalars["String"]; + creative?: InputMaybe; + creativeId?: InputMaybe; creativeSetId?: InputMaybe; id?: InputMaybe; /** The price in the owning campaign's currency for each single confirmation of the priceType specified. Note therefore that the caller is responsible for dividing cost-per-mille by 1000. */ @@ -148,6 +149,13 @@ export type CreateAddressInput = { zipcode: Scalars["String"]; }; +export type CreateAdvertiserImageInput = { + advertiserId: Scalars["String"]; + format: CampaignFormat; + imageUrl: Scalars["String"]; + name: Scalars["String"]; +}; + export type CreateAdvertiserInput = { additionalBillingEmails?: InputMaybe>; billingAddress: CreateAddressInput; diff --git a/src/graphql/url.generated.tsx b/src/graphql/url.generated.tsx index 5aea178d..52c4f0db 100644 --- a/src/graphql/url.generated.tsx +++ b/src/graphql/url.generated.tsx @@ -1,8 +1,7 @@ import * as Types from "./types"; -import * as Apollo from "@apollo/client"; import { gql } from "@apollo/client"; - +import * as Apollo from "@apollo/client"; const defaultOptions = {} as const; export type ValidateTargetUrlQueryVariables = Types.Exact<{ url: Types.Scalars["String"]; diff --git a/src/graphql/user.generated.tsx b/src/graphql/user.generated.tsx index 67fb303f..6b6cb91c 100644 --- a/src/graphql/user.generated.tsx +++ b/src/graphql/user.generated.tsx @@ -1,8 +1,7 @@ import * as Types from "./types"; -import * as Apollo from "@apollo/client"; import { gql } from "@apollo/client"; - +import * as Apollo from "@apollo/client"; const defaultOptions = {} as const; export type UserFragment = { __typename?: "User"; diff --git a/src/user/campaignList/CampaignList.tsx b/src/user/campaignList/CampaignList.tsx index abf9b924..8066612f 100644 --- a/src/user/campaignList/CampaignList.tsx +++ b/src/user/campaignList/CampaignList.tsx @@ -1,11 +1,15 @@ import { useState } from "react"; -import { EnhancedTable, StandardRenderers } from "components/EnhancedTable"; -import { IconButton, Link, Stack, Tooltip } from "@mui/material"; +import { + ColumnDescriptor, + EnhancedTable, + StandardRenderers, +} from "components/EnhancedTable"; +import { Checkbox, Link } from "@mui/material"; import { campaignOnOffState, renderMonetaryAmount, } from "components/EnhancedTable/renderers"; -import { Link as RouterLink, useHistory } from "react-router-dom"; +import { Link as RouterLink } from "react-router-dom"; import { Status } from "components/Campaigns/Status"; import { isAfterEndDate } from "util/isAfterEndDate"; import { AdvertiserCampaignsFragment } from "graphql/advertiser.generated"; @@ -18,15 +22,20 @@ import { import _ from "lodash"; import { uiTextForCampaignFormat } from "user/library"; import { CampaignSummaryFragment } from "graphql/campaign.generated"; -import { CampaignFormat, CampaignSource } from "graphql/types"; -import EditIcon from "@mui/icons-material/Edit"; interface Props { advertiser?: AdvertiserCampaignsFragment | null; fromDate: Date | null; + selectedCampaigns: string[]; + onCampaignSelect: (c: string, insert: boolean) => void; } -export function CampaignList({ advertiser, fromDate }: Props) { +export function CampaignList({ + advertiser, + fromDate, + selectedCampaigns, + onCampaignSelect, +}: Props) { const [engagementData, setEngagementData] = useState>(); @@ -44,147 +53,143 @@ export function CampaignList({ advertiser, fromDate }: Props) { }, }); + const columns: ColumnDescriptor[] = [ + { + title: "On/Off", + value: (c) => c.state, + extendedRenderer: (r) => + campaignOnOffState({ + ...r, + fromDate, + advertiserId: advertiser?.id ?? "", + }), + sx: { width: "1px" }, + sortable: false, + }, + { + title: "Campaign", + value: (c) => c.name, + extendedRenderer: (r) => ( + + {r.name} + + ), + }, + { + title: "Format", + value: (c) => uiTextForCampaignFormat(c.format), + }, + { + title: "Status", + value: (c) => (isAfterEndDate(c.endAt) ? "completed" : c.state), + extendedRenderer: (r) => ( + + ), + sx: { width: "10px" }, + }, + { + title: "Budget", + value: (c) => c.budget, + extendedRenderer: (r) => renderMonetaryAmount(r.budget, r.currency), + align: "right", + }, + { + title: "Spend", + value: (c) => c.spent, + extendedRenderer: (r) => + renderEngagementCell(loading, r, "spend", engagementData), + align: "right", + }, + { + title: "Impressions", + value: (c) => engagementData?.get(c.id)?.["view"] ?? "N/A", + extendedRenderer: (r) => + renderEngagementCell(loading, r, "view", engagementData), + align: "right", + }, + { + title: "Clicks", + value: (c) => engagementData?.get(c.id)?.["click"] ?? "N/A", + extendedRenderer: (r) => + renderEngagementCell(loading, r, "click", engagementData), + align: "right", + }, + { + title: "10s Visits", + value: (c) => engagementData?.get(c.id)?.["landed"] ?? "N/A", + extendedRenderer: (r) => + renderEngagementCell(loading, r, "landed", engagementData), + align: "right", + }, + { + title: "Start", + value: (c) => c.startAt, + renderer: StandardRenderers.date, + align: "right", + }, + { + title: "End", + value: (c) => c.endAt, + renderer: StandardRenderers.date, + align: "right", + }, + { + title: "Created", + value: (c) => c.createdAt, + renderer: StandardRenderers.date, + align: "right", + }, + ]; + + if (advertiser?.selfServiceCreate && advertiser.selfServiceEdit) { + columns.unshift({ + title: "", + value: (c) => c.id, + sortable: false, + extendedRenderer: (r) => ( + + ), + align: "center", + }); + } + return ( c.state, - extendedRenderer: (r) => - campaignOnOffState({ - ...r, - fromDate, - advertiserId: advertiser?.id ?? "", - }), - sx: { width: "1px" }, - sortable: false, - }, - { - title: "Campaign", - value: (c) => c.name, - extendedRenderer: (r) => ( - - ), - }, - { - title: "Format", - value: (c) => uiTextForCampaignFormat(c.format), - }, - { - title: "Status", - value: (c) => (isAfterEndDate(c.endAt) ? "completed" : c.state), - extendedRenderer: (r) => ( - - ), - sx: { width: "10px" }, - }, - { - title: "Budget", - value: (c) => c.budget, - extendedRenderer: (r) => renderMonetaryAmount(r.budget, r.currency), - align: "right", - }, - { - title: "Spend", - value: (c) => c.spent, - extendedRenderer: (r) => - renderEngagementCell(loading, r, "spend", engagementData), - align: "right", - }, - { - title: "Impressions", - value: (c) => engagementData?.get(c.id)?.["view"] ?? "N/A", - extendedRenderer: (r) => - renderEngagementCell(loading, r, "view", engagementData), - align: "right", - }, - { - title: "Clicks", - value: (c) => engagementData?.get(c.id)?.["click"] ?? "N/A", - extendedRenderer: (r) => - renderEngagementCell(loading, r, "click", engagementData), - align: "right", - }, - { - title: "10s Visits", - value: (c) => engagementData?.get(c.id)?.["landed"] ?? "N/A", - extendedRenderer: (r) => - renderEngagementCell(loading, r, "landed", engagementData), - align: "right", - }, - { - title: "Start", - value: (c) => c.startAt, - renderer: StandardRenderers.date, - align: "right", - }, - { - title: "End", - value: (c) => c.endAt, - renderer: StandardRenderers.date, - align: "right", - }, - { - title: "Created", - value: (c) => c.createdAt, - renderer: StandardRenderers.date, - align: "right", - }, - ]} + columns={columns} /> ); } -function CampaignRow(props: { - canEdit: boolean; +interface CheckBoxProps { campaign: CampaignSummaryFragment; -}) { - const campaign = props.campaign; - const history = useHistory(); - const canEdit = (r: CampaignSummaryFragment) => { - return ( - props.canEdit && - r.source === CampaignSource.SelfServe && - r.format === CampaignFormat.PushNotification && - r.state !== "completed" - ); - }; + selectedCampaigns: string[]; + onCampaignSelect: (c: string, insert: boolean) => void; +} +const CampaignCheckBox = (props: CheckBoxProps) => { + console.log(props.selectedCampaigns); + const campaignSelected = props.selectedCampaigns.some( + (c) => c === props.campaign.id, + ); return ( - - - {props.campaign.name} - - {canEdit(props.campaign) && ( - - - history.push( - `/user/main/adsmanager/advanced/${campaign.id}/settings`, - ) - } - > - - - - )} - + + props.onCampaignSelect(props.campaign.id, e.target.checked) + } + /> ); -} +}; diff --git a/src/user/views/user/CampaignView.tsx b/src/user/views/user/CampaignView.tsx index ac391466..2c9f9774 100644 --- a/src/user/views/user/CampaignView.tsx +++ b/src/user/views/user/CampaignView.tsx @@ -1,5 +1,5 @@ -import { Box, Skeleton } from "@mui/material"; -import { useState } from "react"; +import { Box, Chip, Skeleton, Stack, Tooltip, Typography } from "@mui/material"; +import { useCallback, useState } from "react"; import { useAdvertiserCampaignsQuery } from "graphql/advertiser.generated"; import { CampaignAgeFilter } from "components/Campaigns/CampaignAgeFilter"; import { CampaignList } from "user/campaignList/CampaignList"; @@ -7,19 +7,44 @@ import { ErrorDetail } from "components/Error/ErrorDetail"; import moment from "moment/moment"; import { CardContainer } from "components/Card/CardContainer"; import MiniSideBar from "components/Drawer/MiniSideBar"; +import { useLoadCampaignQuery } from "graphql/campaign.generated"; +import { CampaignFormat, CampaignSource } from "graphql/types"; +import { useAdvertiser } from "auth/hooks/queries/useAdvertiser"; +import { Link as RouterLink } from "react-router-dom"; +import { CloneCampaign } from "components/Campaigns/CloneCampaign"; +import EditIcon from "@mui/icons-material/Edit"; export function CampaignView() { + const { advertiser } = useAdvertiser(); const [fromDateFilter, setFromDateFilter] = useState( moment().subtract(6, "month").startOf("day").toDate(), ); + const [selectedCampaigns, setSelectedCampaigns] = useState([]); + const advertiserCanAction = + advertiser.selfServiceCreate && advertiser.selfServiceEdit; + + const handleCampaignSelect = useCallback( + (c: string, include: boolean) => { + const indexOfId = selectedCampaigns.findIndex((summary) => c === summary); + if (include && indexOfId === -1) { + setSelectedCampaigns([...selectedCampaigns, c]); + } else if (!include && indexOfId >= 0) { + const res = + selectedCampaigns.length === 1 + ? [] + : selectedCampaigns.splice(indexOfId, 1); + setSelectedCampaigns(res); + } + }, + [selectedCampaigns], + ); const { loading, data, error } = useAdvertiserCampaignsQuery({ variables: { - id: window.localStorage.getItem("activeAdvertiser") ?? "", + id: advertiser.id, filter: { from: fromDateFilter }, }, pollInterval: 60_000, - fetchPolicy: "cache-and-network", }); if (error) { @@ -34,7 +59,13 @@ export function CampaignView() { return ( + ) : ( + "Campaigns" + ) + } sx={{ flexGrow: 1, mr: 2, @@ -51,6 +82,8 @@ export function CampaignView() { ) : ( @@ -61,3 +94,42 @@ export function CampaignView() { ); } + +function CampaignHeader(props: { selectedCampaigns: string[] }) { + const oneCampaignSelected = props.selectedCampaigns.length === 1; + const firstCampaign = oneCampaignSelected ? props.selectedCampaigns[0] : null; + const { data, loading } = useLoadCampaignQuery({ + variables: { id: firstCampaign ?? "" }, + skip: !oneCampaignSelected || !firstCampaign, + }); + + let tooltip: string | null = "Please select one campaign to clone or edit"; + let isValidCampaign = false; + if (!loading && data?.campaign) { + isValidCampaign = + data.campaign.source === CampaignSource.SelfServe && + data.campaign.format === CampaignFormat.PushNotification && + data.campaign.state !== "completed"; + tooltip = isValidCampaign ? null : "Cannot clone or edit this campaign"; + } + + return ( + + Campaigns + + + + } + clickable + /> + + + + ); +}