diff --git a/src/components/Drawer/MiniSideBar.tsx b/src/components/Drawer/MiniSideBar.tsx index ac761567..80dadc2a 100644 --- a/src/components/Drawer/MiniSideBar.tsx +++ b/src/components/Drawer/MiniSideBar.tsx @@ -43,23 +43,21 @@ export default function MiniSideBar({ children }: PropsWithChildren) { /> ), }, - // Possible future enhancements, not visible to user but help keep spacing { - label: "Creatives", - href: "/user/main/creatives", + label: "Assets", + href: "/user/main/assets", icon: ( - ), - disabled: true, }, { - label: "Assets", - href: "/user/main/assets", + label: "Creatives", + href: "/user/main/creatives", icon: ( - diff --git a/src/graphql/advertiser.generated.tsx b/src/graphql/advertiser.generated.tsx index 8e9129df..86756ce7 100644 --- a/src/graphql/advertiser.generated.tsx +++ b/src/graphql/advertiser.generated.tsx @@ -124,6 +124,44 @@ export type AdvertiserCampaignsQuery = { } | null; }; +export type AdvertiserImageFragment = { + name: string; + imageUrl: string; + format: Types.CampaignFormat; + id: string; + createdAt: any; +}; + +export type AdvertiserImagesQueryVariables = Types.Exact<{ + id: Types.Scalars["String"]; +}>; + +export type AdvertiserImagesQuery = { + advertiser?: { + images: Array<{ + name: string; + imageUrl: string; + format: Types.CampaignFormat; + id: string; + createdAt: any; + }>; + } | null; +}; + +export type UploadAdvertiserImageMutationVariables = Types.Exact<{ + input: Types.CreateAdvertiserImageInput; +}>; + +export type UploadAdvertiserImageMutation = { + createAdvertiserImage: { + name: string; + imageUrl: string; + format: Types.CampaignFormat; + id: string; + createdAt: any; + }; +}; + export const AdvertiserSummaryFragmentDoc = gql` fragment AdvertiserSummary on Advertiser { id @@ -168,6 +206,15 @@ export const AdvertiserCampaignsFragmentDoc = gql` } ${CampaignSummaryFragmentDoc} `; +export const AdvertiserImageFragmentDoc = gql` + fragment AdvertiserImage on AdvertiserImage { + name + imageUrl + format + id + createdAt + } +`; export const AdvertiserDocument = gql` query advertiser($id: String!) { advertiser(id: $id) { @@ -344,3 +391,120 @@ export function refetchAdvertiserCampaignsQuery( ) { return { query: AdvertiserCampaignsDocument, variables: variables }; } +export const AdvertiserImagesDocument = gql` + query advertiserImages($id: String!) { + advertiser(id: $id) { + images { + ...AdvertiserImage + } + } + } + ${AdvertiserImageFragmentDoc} +`; + +/** + * __useAdvertiserImagesQuery__ + * + * To run a query within a React component, call `useAdvertiserImagesQuery` and pass it any options that fit your needs. + * When your component renders, `useAdvertiserImagesQuery` 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 } = useAdvertiserImagesQuery({ + * variables: { + * id: // value for 'id' + * }, + * }); + */ +export function useAdvertiserImagesQuery( + baseOptions: Apollo.QueryHookOptions< + AdvertiserImagesQuery, + AdvertiserImagesQueryVariables + >, +) { + const options = { ...defaultOptions, ...baseOptions }; + return Apollo.useQuery( + AdvertiserImagesDocument, + options, + ); +} +export function useAdvertiserImagesLazyQuery( + baseOptions?: Apollo.LazyQueryHookOptions< + AdvertiserImagesQuery, + AdvertiserImagesQueryVariables + >, +) { + const options = { ...defaultOptions, ...baseOptions }; + return Apollo.useLazyQuery< + AdvertiserImagesQuery, + AdvertiserImagesQueryVariables + >(AdvertiserImagesDocument, options); +} +export type AdvertiserImagesQueryHookResult = ReturnType< + typeof useAdvertiserImagesQuery +>; +export type AdvertiserImagesLazyQueryHookResult = ReturnType< + typeof useAdvertiserImagesLazyQuery +>; +export type AdvertiserImagesQueryResult = Apollo.QueryResult< + AdvertiserImagesQuery, + AdvertiserImagesQueryVariables +>; +export function refetchAdvertiserImagesQuery( + variables: AdvertiserImagesQueryVariables, +) { + return { query: AdvertiserImagesDocument, variables: variables }; +} +export const UploadAdvertiserImageDocument = gql` + mutation uploadAdvertiserImage($input: CreateAdvertiserImageInput!) { + createAdvertiserImage(createImageInput: $input) { + ...AdvertiserImage + } + } + ${AdvertiserImageFragmentDoc} +`; +export type UploadAdvertiserImageMutationFn = Apollo.MutationFunction< + UploadAdvertiserImageMutation, + UploadAdvertiserImageMutationVariables +>; + +/** + * __useUploadAdvertiserImageMutation__ + * + * To run a mutation, you first call `useUploadAdvertiserImageMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useUploadAdvertiserImageMutation` 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 [uploadAdvertiserImageMutation, { data, loading, error }] = useUploadAdvertiserImageMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useUploadAdvertiserImageMutation( + baseOptions?: Apollo.MutationHookOptions< + UploadAdvertiserImageMutation, + UploadAdvertiserImageMutationVariables + >, +) { + const options = { ...defaultOptions, ...baseOptions }; + return Apollo.useMutation< + UploadAdvertiserImageMutation, + UploadAdvertiserImageMutationVariables + >(UploadAdvertiserImageDocument, options); +} +export type UploadAdvertiserImageMutationHookResult = ReturnType< + typeof useUploadAdvertiserImageMutation +>; +export type UploadAdvertiserImageMutationResult = + Apollo.MutationResult; +export type UploadAdvertiserImageMutationOptions = Apollo.BaseMutationOptions< + UploadAdvertiserImageMutation, + UploadAdvertiserImageMutationVariables +>; diff --git a/src/graphql/advertiser.graphql b/src/graphql/advertiser.graphql index e14d5db5..131a8440 100644 --- a/src/graphql/advertiser.graphql +++ b/src/graphql/advertiser.graphql @@ -56,3 +56,25 @@ query advertiserCampaigns($id: String!, $filter: AdvertiserCampaignFilter) { ...AdvertiserCampaigns } } + +fragment AdvertiserImage on AdvertiserImage { + name + imageUrl + format + id + createdAt +} + +query advertiserImages($id: String!) { + advertiser(id: $id) { + images { + ...AdvertiserImage + } + } +} + +mutation uploadAdvertiserImage($input: CreateAdvertiserImageInput!) { + createAdvertiserImage(createImageInput: $input) { + ...AdvertiserImage + } +} diff --git a/src/graphql/types.ts b/src/graphql/types.ts index 1c2e0c05..3e41ef7c 100644 --- a/src/graphql/types.ts +++ b/src/graphql/types.ts @@ -187,6 +187,7 @@ export type CreateCampaignInput = { name: Scalars["String"]; pacingStrategy?: InputMaybe; paymentType?: InputMaybe; + priority?: InputMaybe; source: Scalars["String"]; startAt: Scalars["DateTime"]; state: Scalars["String"]; diff --git a/src/user/User.tsx b/src/user/User.tsx index 8896321d..6ff7479c 100644 --- a/src/user/User.tsx +++ b/src/user/User.tsx @@ -93,6 +93,11 @@ export function User() { unauthedComponent={AdvertiserAgreed} /> + + {/* default */} diff --git a/src/user/hooks/useUploadFile.ts b/src/user/hooks/useUploadFile.ts new file mode 100644 index 00000000..2e4de99f --- /dev/null +++ b/src/user/hooks/useUploadFile.ts @@ -0,0 +1,127 @@ +import { buildAdServerEndpoint, getEnvConfig } from "util/environment"; +import { useCallback, useState } from "react"; +import _ from "lodash"; +import { useUploadAdvertiserImageMutation } from "graphql/advertiser.generated"; +import { useAdvertiser } from "auth/hooks/queries/useAdvertiser"; +import { CampaignFormat } from "graphql/types"; +import { UploadConfig } from "user/views/advertiser/UploadImage"; + +interface PutUploadResponse { + // the pre-signed url to which the file should be uploaded to + uploadUrl: string; + // the path on the cdn that this url will eventually have + destinationPath: string; +} + +export const useUploadFile = () => { + const { advertiser } = useAdvertiser(); + const [error, setError] = useState(); + const [step, setStep] = useState(0); + const [state, setState] = useState(); + const [loading, setLoading] = useState(false); + const [mutate] = useUploadAdvertiserImageMutation({ + onError(e) { + setError(e.message); + setLoading(false); + }, + onCompleted(data) { + setStep(2); + setState(`File upload complete for ${data.createAdvertiserImage.name}!`); + setLoading(false); + }, + }); + + const uploadFile = useCallback(async (file: File, format: CampaignFormat) => { + setError(undefined); + setLoading(true); + setState("Preparing file for upload..."); + + let upload: PutUploadResponse; + try { + const extension = _.last(file.name.split(".")) ?? ""; + upload = await prepareForUpload(extension); + } catch (e: any) { + setError(e.message); + return; + } + + try { + setState("Uploading file..."); + await putFile(file, upload); + setStep(1); + } catch (e: any) { + setError(e.message); + return; + } + + await mutate({ + variables: { + input: { + format: format, + advertiserId: advertiser.id, + name: file.name, + imageUrl: `https://${configForFormat(format).targetHost}${ + upload.destinationPath + }`, + }, + }, + }); + }, []); + + return [{ upload: uploadFile }, { state, step, loading, error }]; +}; + +async function prepareForUpload(extension: string): Promise { + const resp = await fetch( + buildAdServerEndpoint(`/internal/image-upload/${extension}`), + { + method: "GET", + mode: "cors", + credentials: "include", + headers: { + "Content-Type": "application/json", + }, + }, + ); + if (!resp.ok) { + throw new Error(`Unable to upload image`); + } + const result = (await resp.json()) as PutUploadResponse; + if (!result.destinationPath || !result.uploadUrl) { + throw new Error(`Unable to upload image`); + } + + return result; +} + +async function putFile(file: File, uploadTarget: PutUploadResponse) { + try { + const resp = await fetch(uploadTarget.uploadUrl, { + method: "PUT", + mode: "cors", + body: file, + }); + + if (!resp.ok) { + await resp.text(); + throw new Error(`Failed to upload image`); + } + } catch (e: any) { + if (e.message === "Failed to fetch") { + throw new Error(`Failed to upload image`); + } + throw e; + } +} + +const configForFormat = (format: CampaignFormat): UploadConfig => { + if (format === CampaignFormat.NewsDisplayAd) { + return { + targetHost: () => getEnvConfig().pcdnHost, + requiresPublishStep: false, + endpoint: "internal/image-upload", + }; + } + + throw new Error("Invalid format"); +}; diff --git a/src/user/views/adsManager/views/advanced/components/campaign/CampaignSettings.tsx b/src/user/views/adsManager/views/advanced/components/campaign/CampaignSettings.tsx index 246d1558..20876549 100644 --- a/src/user/views/adsManager/views/advanced/components/campaign/CampaignSettings.tsx +++ b/src/user/views/adsManager/views/advanced/components/campaign/CampaignSettings.tsx @@ -20,7 +20,7 @@ export function CampaignSettings() { - {isDraft && } + {isDraft && } diff --git a/src/user/views/adsManager/views/advanced/components/campaign/fields/FormatField.tsx b/src/user/views/adsManager/views/advanced/components/campaign/fields/FormatField.tsx index 35652ec2..e3e1419c 100644 --- a/src/user/views/adsManager/views/advanced/components/campaign/fields/FormatField.tsx +++ b/src/user/views/adsManager/views/advanced/components/campaign/fields/FormatField.tsx @@ -10,13 +10,14 @@ import { useField } from "formik"; import { CampaignFormat } from "graphql/types"; import _ from "lodash"; import HelpIcon from "@mui/icons-material/Help"; +import { useIsEdit } from "form/FormikHelpers"; export function FormatField() { return ( - Choose what tye of campaign you would like to run + Choose a format for the campaign you would like to run { + const { isEdit } = useIsEdit(); const [, meta, helper] = useField("format"); return ( helper.setValue(props.format)} sx={{ diff --git a/src/user/views/advertiser/AdvertiserAssets.tsx b/src/user/views/advertiser/AdvertiserAssets.tsx new file mode 100644 index 00000000..2c164a66 --- /dev/null +++ b/src/user/views/advertiser/AdvertiserAssets.tsx @@ -0,0 +1 @@ +export function AdvertiserAssets() {} diff --git a/src/user/views/advertiser/UploadImage.tsx b/src/user/views/advertiser/UploadImage.tsx new file mode 100644 index 00000000..870ed97d --- /dev/null +++ b/src/user/views/advertiser/UploadImage.tsx @@ -0,0 +1,99 @@ +import { useState } from "react"; +import { useUploadFile } from "user/hooks/useUploadFile"; +import { + Alert, + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + LinearProgress, + Step, + StepLabel, + Stepper, +} from "@mui/material"; +import { CampaignFormat } from "graphql/types"; + +export interface UploadConfig { + targetHost: () => string; + requiresPublishStep: boolean; + endpoint: string; +} + +export function UploadImage() { + const [open, setOpen] = useState(false); + const [file, setFile] = useState(); + const [{ upload }, { step, error, loading, state }] = useUploadFile(); + + return ( + + + setOpen(false)}> + Upload Image + + + Uploaded images can be shared across different Ad Sets within a + Campaign. For best quality, upload images at 900x750px resolution. + Images will be automatically scaled to this size. + + + + + Choose + + + Upload + + + Complete + + + + + {step === 0 && ( + + )} + + {!error && state && ( + {state} + )} + {error !== undefined && {error}} + {loading && } + + + + + + + + + ); +} diff --git a/src/util/environment.ts b/src/util/environment.ts index edd6c2d1..7e19b15c 100644 --- a/src/util/environment.ts +++ b/src/util/environment.ts @@ -1,3 +1,39 @@ +export enum Environment { + LOCAL = "local", + STAGE = "stage", + PRODUCTION = "production", +} + +export function getEnvironment(): Environment { + const host = window.location.hostname; + + if (host.endsWith(".brave.com")) { + return Environment.PRODUCTION; + } + + if (host.endsWith(".bravesoftware.com")) { + return Environment.STAGE; + } + + return Environment.LOCAL; +} + +interface EnvConfig { + pcdnHost: string; +} + +export function getEnvConfig(): EnvConfig { + if (getEnvironment() === Environment.PRODUCTION) { + return { + pcdnHost: "pcdn.brave.com", + }; + } + + return { + pcdnHost: "pcdn.bravesoftware.com", + }; +} + export function buildAdServerEndpoint(suffix: string): string { return `${import.meta.env.REACT_APP_SERVER_ADDRESS}${suffix}`; }