From 17a430f9380a0cbae4b6ee96bd8f2270b3aa1b3d Mon Sep 17 00:00:00 2001 From: Ian Krieger Date: Fri, 22 Sep 2023 15:28:27 -0400 Subject: [PATCH] feat: allow for price-per-advertiser --- src/graphql/advertiser.generated.tsx | 93 ++++++ src/graphql/advertiser.graphql | 14 + src/graphql/types.ts | 22 +- src/user/hooks/useAdvertiserWithPrices.tsx | 41 +++ src/user/views/adsManager/types/index.ts | 19 +- .../advanced/components/form/EditCampaign.tsx | 26 +- .../advanced/components/form/NewCampaign.tsx | 20 +- src/validation/CampaignSchema.tsx | 305 ++++++++++-------- 8 files changed, 389 insertions(+), 151 deletions(-) create mode 100644 src/user/hooks/useAdvertiserWithPrices.tsx diff --git a/src/graphql/advertiser.generated.tsx b/src/graphql/advertiser.generated.tsx index 5d9809eab..e45b3ff77 100644 --- a/src/graphql/advertiser.generated.tsx +++ b/src/graphql/advertiser.generated.tsx @@ -132,6 +132,12 @@ export type AdvertiserImageFragment = { createdAt: any; }; +export type AdvertiserPriceFragment = { + price: string; + billingType: Types.BillingType; + format: Types.CampaignFormat; +}; + export type AdvertiserImagesQueryVariables = Types.Exact<{ id: Types.Scalars["String"]; }>; @@ -148,6 +154,20 @@ export type AdvertiserImagesQuery = { } | null; }; +export type AdvertiserPricesQueryVariables = Types.Exact<{ + id: Types.Scalars["String"]; +}>; + +export type AdvertiserPricesQuery = { + advertiser?: { + prices: Array<{ + price: string; + billingType: Types.BillingType; + format: Types.CampaignFormat; + }>; + } | null; +}; + export type UploadAdvertiserImageMutationVariables = Types.Exact<{ input: Types.CreateAdvertiserImageInput; }>; @@ -209,6 +229,13 @@ export const AdvertiserImageFragmentDoc = gql` createdAt } `; +export const AdvertiserPriceFragmentDoc = gql` + fragment AdvertiserPrice on AdvertiserPrice { + price + billingType + format + } +`; export const AdvertiserDocument = gql` query advertiser($id: String!) { advertiser(id: $id) { @@ -451,6 +478,72 @@ export function refetchAdvertiserImagesQuery( ) { return { query: AdvertiserImagesDocument, variables: variables }; } +export const AdvertiserPricesDocument = gql` + query advertiserPrices($id: String!) { + advertiser(id: $id) { + prices { + ...AdvertiserPrice + } + } + } + ${AdvertiserPriceFragmentDoc} +`; + +/** + * __useAdvertiserPricesQuery__ + * + * To run a query within a React component, call `useAdvertiserPricesQuery` and pass it any options that fit your needs. + * When your component renders, `useAdvertiserPricesQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useAdvertiserPricesQuery({ + * variables: { + * id: // value for 'id' + * }, + * }); + */ +export function useAdvertiserPricesQuery( + baseOptions: Apollo.QueryHookOptions< + AdvertiserPricesQuery, + AdvertiserPricesQueryVariables + >, +) { + const options = { ...defaultOptions, ...baseOptions }; + return Apollo.useQuery( + AdvertiserPricesDocument, + options, + ); +} +export function useAdvertiserPricesLazyQuery( + baseOptions?: Apollo.LazyQueryHookOptions< + AdvertiserPricesQuery, + AdvertiserPricesQueryVariables + >, +) { + const options = { ...defaultOptions, ...baseOptions }; + return Apollo.useLazyQuery< + AdvertiserPricesQuery, + AdvertiserPricesQueryVariables + >(AdvertiserPricesDocument, options); +} +export type AdvertiserPricesQueryHookResult = ReturnType< + typeof useAdvertiserPricesQuery +>; +export type AdvertiserPricesLazyQueryHookResult = ReturnType< + typeof useAdvertiserPricesLazyQuery +>; +export type AdvertiserPricesQueryResult = Apollo.QueryResult< + AdvertiserPricesQuery, + AdvertiserPricesQueryVariables +>; +export function refetchAdvertiserPricesQuery( + variables: AdvertiserPricesQueryVariables, +) { + return { query: AdvertiserPricesDocument, variables: variables }; +} export const UploadAdvertiserImageDocument = gql` mutation uploadAdvertiserImage($input: CreateAdvertiserImageInput!) { createAdvertiserImage(createImageInput: $input) { diff --git a/src/graphql/advertiser.graphql b/src/graphql/advertiser.graphql index 2c79350a6..f8b95b5f0 100644 --- a/src/graphql/advertiser.graphql +++ b/src/graphql/advertiser.graphql @@ -65,6 +65,12 @@ fragment AdvertiserImage on AdvertiserImage { createdAt } +fragment AdvertiserPrice on AdvertiserPrice { + price + billingType + format +} + query advertiserImages($id: String!) { advertiser(id: $id) { images { @@ -73,6 +79,14 @@ query advertiserImages($id: String!) { } } +query advertiserPrices($id: String!) { + advertiser(id: $id) { + prices { + ...AdvertiserPrice + } + } +} + mutation uploadAdvertiserImage($input: CreateAdvertiserImageInput!) { createAdvertiserImage(createImageInput: $input) { name diff --git a/src/graphql/types.ts b/src/graphql/types.ts index 0c03884fe..49402b25f 100644 --- a/src/graphql/types.ts +++ b/src/graphql/types.ts @@ -31,6 +31,12 @@ export type AdvertiserCampaignFilter = { includeCreativeSets?: InputMaybe; }; +export type AdvertiserPriceInput = { + billingType: BillingType; + format: CampaignFormat; + price: Scalars["Numeric"]; +}; + export enum AdvertiserSource { Managed = "MANAGED", SelfServe = "SELF_SERVE", @@ -40,6 +46,12 @@ export type ApproveCampaignInput = { campaignId: Scalars["String"]; }; +export enum BillingType { + Cpc = "CPC", + Cpm = "CPM", + Cpv = "CPV", +} + export type CampaignFilter = { /** only include campaigns for this format */ format?: InputMaybe; @@ -82,13 +94,15 @@ export enum CampaignSource { export type ChangeFilter = { /** exclude all changes whose time range is completely before this time */ - created?: InputMaybe; + createdAfter?: InputMaybe; /** include changes made by system user */ includeSystemUser?: InputMaybe; /** limit the amount of results returned */ limit?: InputMaybe; /** only include changes for this reference id */ referenceId?: InputMaybe; + /** only include changes for this set of reference ids */ + referenceIds?: InputMaybe>; }; export enum ConfirmationType { @@ -112,8 +126,6 @@ export type CreateAdInput = { creativeId?: InputMaybe; creativeSetId?: InputMaybe; id?: InputMaybe; - price?: InputMaybe; - priceType?: InputMaybe; webhooks?: InputMaybe>; }; @@ -132,7 +144,7 @@ export type CreateAdSetInput = { oses?: InputMaybe>; perDay: Scalars["Float"]; /** 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. */ - price?: InputMaybe; + price: Scalars["Numeric"]; segments: Array; splitTestGroup?: InputMaybe; state?: InputMaybe; @@ -163,6 +175,7 @@ export type CreateAdvertiserInput = { mailingAddress: CreateAddressInput; name: Scalars["String"]; phone?: InputMaybe; + prices?: InputMaybe>; referrer?: InputMaybe; selfServiceCreate?: InputMaybe; selfServiceEdit?: InputMaybe; @@ -441,6 +454,7 @@ export type UpdateAdvertiserInput = { mailingAddress?: InputMaybe; name?: InputMaybe; phone?: InputMaybe; + prices?: InputMaybe>; publicKey?: InputMaybe; referrer?: InputMaybe; selfServiceCreate?: InputMaybe; diff --git a/src/user/hooks/useAdvertiserWithPrices.tsx b/src/user/hooks/useAdvertiserWithPrices.tsx new file mode 100644 index 000000000..a388728a7 --- /dev/null +++ b/src/user/hooks/useAdvertiserWithPrices.tsx @@ -0,0 +1,41 @@ +import { useAdvertiser } from "auth/hooks/queries/useAdvertiser"; +import { + AdvertiserPriceFragment, + useAdvertiserPricesQuery, +} from "graphql/advertiser.generated"; +import { useState } from "react"; +import { IAdvertiser } from "auth/context/auth.interface"; +import _ from "lodash"; + +export type AdvertiserWithPrices = IAdvertiser & { + prices: AdvertiserPriceFragment[]; +}; +export function useAdvertiserWithPrices() { + const { advertiser } = useAdvertiser(); + const [data, setData] = useState({ + ...advertiser, + prices: [], + }); + const [error, setError] = useState(); + + const { loading } = useAdvertiserPricesQuery({ + variables: { id: advertiser.id }, + onCompleted(data) { + const prices = data.advertiser?.prices ?? []; + if (_.isEmpty(prices)) { + setError("Unable to create a new campaign"); + return; + } + + setData({ + ...advertiser, + prices, + }); + }, + onError(error) { + setError(error.message); + }, + }); + + return { data, loading, error }; +} diff --git a/src/user/views/adsManager/types/index.ts b/src/user/views/adsManager/types/index.ts index a04d8626f..5622c748f 100644 --- a/src/user/views/adsManager/types/index.ts +++ b/src/user/views/adsManager/types/index.ts @@ -1,7 +1,7 @@ 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"; +import { AdvertiserWithPrices } from "user/hooks/useAdvertiserWithPrices"; export type Billing = "cpm" | "cpc" | "cpv"; @@ -105,7 +105,14 @@ export const initialAdSet: AdSetForm = { creatives: [], }; -export const initialCampaign = (advertiser: IAdvertiser): CampaignForm => { +export const initialCampaign = ( + advertiser: AdvertiserWithPrices, +): CampaignForm => { + const format = CampaignFormat.PushNotification; + const billingType = "cpm"; + const price = advertiser.prices.find( + (p) => p.billingType === billingType.toUpperCase() && p.format === format, + ); return { isCreating: false, advertiserId: advertiser.id, @@ -116,15 +123,19 @@ export const initialCampaign = (advertiser: IAdvertiser): CampaignForm => { dailyBudget: MIN_PER_CAMPAIGN, geoTargets: [], newCreative: initialCreative, - billingType: "cpm", + billingType, currency: "USD", +<<<<<<< HEAD price: "6", +======= + price: price?.price ?? "6", +>>>>>>> 9018ac3 (feat: allow for price-per-advertiser) adSets: [ { ...initialAdSet, }, ], - format: CampaignFormat.PushNotification, + format, name: "", state: "draft", type: "paid", 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 a9e259ae9..e10bff9c1 100644 --- a/src/user/views/adsManager/views/advanced/components/form/EditCampaign.tsx +++ b/src/user/views/adsManager/views/advanced/components/form/EditCampaign.tsx @@ -9,12 +9,12 @@ import { } from "graphql/campaign.generated"; import { useHistory, useParams } from "react-router-dom"; import { BaseForm } from "./components/BaseForm"; -import { useAdvertiser } from "auth/hooks/queries/useAdvertiser"; import { useCreatePaymentSession } from "checkout/hooks/useCreatePaymentSession"; import { ErrorDetail } from "components/Error/ErrorDetail"; import { refetchAdvertiserCampaignsQuery } from "graphql/advertiser.generated"; import { useContext } from "react"; import { FilterContext } from "state/context"; +import { useAdvertiserWithPrices } from "user/hooks/useAdvertiserWithPrices"; interface Params { campaignId: string; @@ -22,10 +22,14 @@ interface Params { export function EditCampaign() { const { fromDate } = useContext(FilterContext); - const { advertiser } = useAdvertiser(); const history = useHistory(); const params = useParams(); const { createPaymentSession, loading } = useCreatePaymentSession(); + const { + data, + loading: priceLoading, + error: priceError, + } = useAdvertiserWithPrices(); const { data: initialData, @@ -53,27 +57,33 @@ export function EditCampaign() { refetchQueries: [ { ...refetchAdvertiserCampaignsQuery({ - id: advertiser.id, + id: data.id, filter: { from: fromDate }, }), }, ], }); - if (error) { + if (error || priceError) { return ( ); } - if (!initialData || !initialData.campaign || qLoading || loading) { + if ( + !initialData || + !initialData.campaign || + qLoading || + loading || + priceLoading + ) { return ; } - const initialValues = editCampaignValues(initialData.campaign, advertiser.id); + const initialValues = editCampaignValues(initialData.campaign, data.id); return ( diff --git a/src/user/views/adsManager/views/advanced/components/form/NewCampaign.tsx b/src/user/views/adsManager/views/advanced/components/form/NewCampaign.tsx index dc4e3a5d8..79903afaf 100644 --- a/src/user/views/adsManager/views/advanced/components/form/NewCampaign.tsx +++ b/src/user/views/adsManager/views/advanced/components/form/NewCampaign.tsx @@ -9,11 +9,12 @@ import { useHistory, useParams } from "react-router-dom"; import { BaseForm } from "./components/BaseForm"; import { PersistFormValues } from "form/PersistFormValues"; import { DraftContext, FilterContext } from "state/context"; -import { useAdvertiser } from "auth/hooks/queries/useAdvertiser"; import { useCreatePaymentSession } from "checkout/hooks/useCreatePaymentSession"; import { PaymentType } from "graphql/types"; import { useUser } from "auth/hooks/queries/useUser"; import { refetchAdvertiserCampaignsQuery } from "graphql/advertiser.generated"; +import { useAdvertiserWithPrices } from "user/hooks/useAdvertiserWithPrices"; +import { ErrorDetail } from "components/Error/ErrorDetail"; interface Params { draftId: string; @@ -23,14 +24,15 @@ export function NewCampaign() { const history = useHistory(); const { fromDate } = useContext(FilterContext); const params = useParams(); - const { advertiser } = useAdvertiser(); const { userId } = useUser(); - const { createPaymentSession, loading } = useCreatePaymentSession(); + const { createPaymentSession, loading: sessionLoading } = + useCreatePaymentSession(); + const { data, loading, error } = useAdvertiserWithPrices(); const { setDrafts } = useContext(DraftContext); const initial: CampaignForm = { - ...initialCampaign(advertiser), + ...initialCampaign(data), draftId: params.draftId, }; @@ -54,17 +56,21 @@ export function NewCampaign() { refetchQueries: [ { ...refetchAdvertiserCampaignsQuery({ - id: advertiser.id, + id: data.id, filter: { from: fromDate }, }), }, ], }); - if (loading) { + if (loading || sessionLoading) { return ; } + if (error) { + return ; + } + return ( <> diff --git a/src/validation/CampaignSchema.tsx b/src/validation/CampaignSchema.tsx index 1e64cd036..06c853eba 100644 --- a/src/validation/CampaignSchema.tsx +++ b/src/validation/CampaignSchema.tsx @@ -1,140 +1,189 @@ -import { array, boolean, date, number, object, ref, string } from "yup"; +import { + AnyObject, + array, + boolean, + date, + number, + NumberSchema, + object, + ref, + string, +} from "yup"; import { startOfDay } from "date-fns"; import { twoDaysOut } from "form/DateFieldHelpers"; import { TrailingAsteriskRegex } from "validation/regex"; import { CreativeSchema } from "validation/CreativeSchema"; -import { CampaignFormat } from "graphql/types"; +import { BillingType, CampaignFormat } from "graphql/types"; +import { AdvertiserPriceFragment } from "graphql/advertiser.generated"; export const MIN_PER_DAY = 33; export const MIN_PER_CAMPAIGN = 100; -export const CampaignSchema = object().shape({ - name: string().label("Campaign Name").required(), - format: string() - .label("Campaign Format") - .oneOf([CampaignFormat.NewsDisplayAd, CampaignFormat.PushNotification]) - .required(), - budget: number() - .label("Lifetime Budget") - .required() - .min( - MIN_PER_CAMPAIGN, - `Lifetime budget must be $${MIN_PER_CAMPAIGN} or more`, - ), - newCreative: object().when("isCreating", { - is: true, - then: () => CreativeSchema, - }), - validateStart: boolean(), - dailyBudget: number() - .label("Daily Budget") - .required() - .positive() - .min(MIN_PER_DAY, "Lifetime budget must be higher for date range provided"), - startAt: date() - .label("Start Date") - .when("validateStart", { +export const CampaignSchema = (prices: AdvertiserPriceFragment[]) => + object().shape({ + name: string().label("Campaign Name").required(), + format: string() + .label("Campaign Format") + .oneOf([CampaignFormat.NewsDisplayAd, CampaignFormat.PushNotification]) + .required(), + budget: number() + .label("Lifetime Budget") + .required() + .min( + MIN_PER_CAMPAIGN, + `Lifetime budget must be $${MIN_PER_CAMPAIGN} or more`, + ), + newCreative: object().when("isCreating", { is: true, - then: (schema) => - schema - .min( - startOfDay(twoDaysOut()), - "Start Date must be minimum of 2 days from today", - ) - .required(), + then: () => CreativeSchema, }), - endAt: date() - .label("End Date") - .required() - .min(ref("startAt"), "End date must be after Start date"), - geoTargets: array() - .label("Locations") - .of( - object().shape({ - code: string().required(), - name: string().required(), + validateStart: boolean(), + dailyBudget: number() + .label("Daily Budget") + .required() + .positive() + .min( + MIN_PER_DAY, + "Lifetime budget must be higher for date range provided", + ), + startAt: date() + .label("Start Date") + .when("validateStart", { + is: true, + then: (schema) => + schema + .min( + startOfDay(twoDaysOut()), + "Start Date must be minimum of 2 days from today", + ) + .required(), }), - ) - .min(1, "At least one country must be targeted") - .default([]), - price: number() - .label("Price") - .when("billingType", { - is: (b: string) => b === "cpc", - then: (schema) => - schema.moreThan(0.09, "CPC price must be .10 or higher"), - }) - .when(["billingType", "format"], { - is: (b: string, f: CampaignFormat) => - b === "cpm" && f === CampaignFormat.PushNotification, - then: (schema) => schema.moreThan(5, "CPM price must be 6 or higher"), - }) - .when(["billingType", "format"], { - is: (b: string, f: CampaignFormat) => - b === "cpm" && f === CampaignFormat.NewsDisplayAd, - then: (schema) => schema.moreThan(9, "CPM price must be 10 or higher"), - }) - .required("Price is a required field"), - billingType: string() - .label("Pricing Type") - .oneOf(["cpm", "cpc"]) - .required("Pricing type is a required field"), - adSets: array() - .min(1) - .of( - object().shape({ - name: string().label("Ad Set Name").optional(), - segments: array() - .label("Audiences") - .of( - object().shape({ - code: string().required(), - name: string().required(), - }), - ) - .min(1, "At least one audience must be targeted") - .default([]), - oses: array() - .label("Platforms") - .of( - object().shape({ - code: string().required(), - name: string().required(), - }), - ) - .min(1, "At least one platform must be targeted") - .default([]), - conversions: array() - .label("Conversions") - .min(0) - .max(1) - .of( - object().shape({ - urlPattern: string() - .required("Conversion URL required.") - .matches( - TrailingAsteriskRegex, - "Conversion URL must end in trailing asterisk (*)", - ), - observationWindow: number() - .oneOf( - [1, 7, 30], - "Observation Window must be 1, 7, or 30 days.", - ) - .required("Observation Window required."), - type: string() - .oneOf( - ["postclick", "postview"], - "Conversion type must be Post Click or Post View", - ) - .required("Conversion Type required."), - }), + endAt: date() + .label("End Date") + .required() + .min(ref("startAt"), "End date must be after Start date"), + geoTargets: array() + .label("Locations") + .of( + object().shape({ + code: string().required(), + name: string().required(), + }), + ) + .min(1, "At least one country must be targeted") + .default([]), + price: number() + .label("Price") + .when("billingType", { + is: (b: string) => b === "cpc", + then: (schema) => + findPrice( + prices, + CampaignFormat.PushNotification, + BillingType.Cpc, + 0.1, + schema, ), - creatives: array().test( - "min-length", - "Ad Sets must have at least one Ad", - (value) => (value ?? []).filter((c) => c.included).length > 0, - ), - }), - ), -}); + }) + .when(["billingType", "format"], { + is: (b: string, f: CampaignFormat) => + b === "cpm" && f === CampaignFormat.PushNotification, + then: (schema) => + findPrice( + prices, + CampaignFormat.PushNotification, + BillingType.Cpm, + 5, + schema, + ), + }) + .when(["billingType", "format"], { + is: (b: string, f: CampaignFormat) => + b === "cpm" && f === CampaignFormat.NewsDisplayAd, + then: (schema) => + findPrice( + prices, + CampaignFormat.NewsDisplayAd, + BillingType.Cpm, + 9, + schema, + ), + }) + .required("Price is a required field"), + billingType: string() + .label("Pricing Type") + .oneOf(["cpm", "cpc"]) + .required("Pricing type is a required field"), + adSets: array() + .min(1) + .of( + object().shape({ + name: string().label("Ad Set Name").optional(), + segments: array() + .label("Audiences") + .of( + object().shape({ + code: string().required(), + name: string().required(), + }), + ) + .min(1, "At least one audience must be targeted") + .default([]), + oses: array() + .label("Platforms") + .of( + object().shape({ + code: string().required(), + name: string().required(), + }), + ) + .min(1, "At least one platform must be targeted") + .default([]), + conversions: array() + .label("Conversions") + .min(0) + .max(1) + .of( + object().shape({ + urlPattern: string() + .required("Conversion URL required.") + .matches( + TrailingAsteriskRegex, + "Conversion URL must end in trailing asterisk (*)", + ), + observationWindow: number() + .oneOf( + [1, 7, 30], + "Observation Window must be 1, 7, or 30 days.", + ) + .required("Observation Window required."), + type: string() + .oneOf( + ["postclick", "postview"], + "Conversion type must be Post Click or Post View", + ) + .required("Conversion Type required."), + }), + ), + creatives: array().test( + "min-length", + "Ad Sets must have at least one Ad", + (value) => (value ?? []).filter((c) => c.included).length > 0, + ), + }), + ), + }); + +export function findPrice( + prices: AdvertiserPriceFragment[], + format: CampaignFormat, + billingType: BillingType, + defaultPrice: number, + schema: NumberSchema, +) { + const found = prices.find( + (p) => p.format === format && p.billingType === billingType, + ); + const price = Number(found?.price) ?? defaultPrice; + return schema.min(price, `${billingType} price must be ${price} or higher`); +}