diff --git a/src/components/CampaignAnalytics/CampaignAnalytics.tsx b/src/components/CampaignAnalytics/CampaignAnalytics.tsx index 858a80f7..697c868f 100644 --- a/src/components/CampaignAnalytics/CampaignAnalytics.tsx +++ b/src/components/CampaignAnalytics/CampaignAnalytics.tsx @@ -124,7 +124,7 @@ const CampaignAnalytics = ({ isAdminPanel = false }: { isAdminPanel?: boolean }) ) } - if (!id) { + if (!id || (!loading && !campaign)) { return
Invalid campaign ID
} @@ -192,13 +192,14 @@ const CampaignAnalytics = ({ isAdminPanel = false }: { isAdminPanel?: boolean }) currencyName={currencyName} /> )} - {!loading && activeTab === 'hostname' && ( + {!loading && campaign && activeTab === 'hostname' && ( )} {!loading && activeTab === 'country' && ( diff --git a/src/components/CampaignAnalytics/Placements.tsx b/src/components/CampaignAnalytics/Placements.tsx index 617dd70d..2084b1e3 100644 --- a/src/components/CampaignAnalytics/Placements.tsx +++ b/src/components/CampaignAnalytics/Placements.tsx @@ -1,18 +1,25 @@ -import { Placement } from 'adex-common' -import CustomTable from 'components/common/CustomTable' +import { Campaign, CampaignStatus, Placement } from 'adex-common' +import CustomTable, { TableElement, TableRowAction } from 'components/common/CustomTable' import { getHumneSrcName } from 'helpers' import { useMemo } from 'react' import { BaseAnalyticsData } from 'types' +import InvisibilityIcon from 'resources/icons/Invisibility' +import VisibilityIcon from 'resources/icons/Visibility' +import { useCampaignsData } from 'hooks/useCampaignsData' const Placements = ({ placements, currencyName, - placement + placement, + campaign }: { placements: BaseAnalyticsData[] | undefined currencyName: string placement: Placement + campaign: Campaign }) => { + const { toggleBlockedSource } = useCampaignsData() + if (!placements?.length) { return
No placement found
} @@ -29,20 +36,68 @@ const Placements = ({ [placement] ) - const elements = useMemo( + type PlacementsTableElement = Omit & { + actionData: { + placementName: string + isBlocked: boolean + segment: string + } + id: string + placementName: string + impressions: string + clicks: string + ctr: string + avgCpm: string + paid: string + } + + const elements: PlacementsTableElement[] = useMemo( () => - placements?.map((item) => ({ - id: item.segment, - segment: getHumneSrcName(item.segment, placement), - impressions: item.impressions.toLocaleString(), - clicks: item.clicks.toLocaleString(), - ctr: `${item.ctr} %`, - avgCpm: `${item.avgCpm} ${currencyName}`, - paid: `${item.paid.toFixed(4)} ${currencyName}` - })) || [], - [placements, placement, currencyName] + placements?.map((item) => { + const isBlocked = campaign.targetingInput.inputs.publishers.nin.includes(item.segment) + const placementName = getHumneSrcName(item.segment, placement) + const data: PlacementsTableElement = { + rowColor: isBlocked ? 'red' : 'inherit', + actionData: { + placementName, + isBlocked, + segment: item.segment + }, + id: item.segment, + placementName, + impressions: item.impressions.toLocaleString(), + clicks: item.clicks.toLocaleString(), + ctr: `${item.ctr} %`, + avgCpm: `${item.avgCpm} ${currencyName}`, + paid: `${item.paid.toFixed(4)} ${currencyName}` + } + + return data + }) || [], + [placements, campaign.targetingInput.inputs.publishers.nin, placement, currencyName] ) - return + + const actions = useMemo(() => { + const placementActions: TableRowAction[] = [ + CampaignStatus.active, + CampaignStatus.paused + ].includes(campaign.status) + ? [ + { + action: (props: PlacementsTableElement['actionData']) => + toggleBlockedSource(campaign.id, props.placementName, props.segment), + label: ({ isBlocked, placementName }: PlacementsTableElement['actionData']) => + `${isBlocked ? 'Unblock' : 'Block'} "${placementName}"`, + icon: ({ isBlocked }: PlacementsTableElement['actionData']) => + isBlocked ? : + } + ] + : [] + + return placementActions + }, [campaign.id, campaign.status, toggleBlockedSource]) + + return } export default Placements diff --git a/src/components/EditCampaign/EditCampaign.tsx b/src/components/EditCampaign/EditCampaign.tsx index f8d68739..50ddeaea 100644 --- a/src/components/EditCampaign/EditCampaign.tsx +++ b/src/components/EditCampaign/EditCampaign.tsx @@ -26,8 +26,6 @@ import { getRecommendedCPMRange } from 'helpers/createCampaignHelpers' import useAccount from 'hooks/useAccount' -import { useAdExApi } from 'hooks/useAdexServices' -import useCustomNotifications from 'hooks/useCustomNotifications' import { useCallback, useEffect, useMemo } from 'react' import { useCampaignsData } from 'hooks/useCampaignsData' import type { @@ -57,12 +55,10 @@ type FormProps = { } const EditCampaign = ({ campaign }: { campaign: Campaign }) => { - const { adexServicesRequest } = useAdExApi() - const { showNotification } = useCustomNotifications() const { adexAccount: { balanceToken } } = useAccount() - const { updateCampaignDataById, supplyStats } = useCampaignsData() + const { supplyStats, editCampaign } = useCampaignsData() const recommendedPaymentBounds = useMemo( () => getRecommendedCPMRange(supplyStats, campaign), @@ -211,73 +207,38 @@ const EditCampaign = ({ campaign }: { campaign: Campaign }) => { [form] ) - const editCampaign = useCallback( + const handleEditCampaign = useCallback( async (values: FormProps) => { const impression = { - min: Number( - parseToBigNumPrecision( - Number(values.pricingBounds.IMPRESSION?.min) / 1000, - balanceToken.decimals - ) + min: parseToBigNumPrecision( + Number(values.pricingBounds.IMPRESSION?.min) / 1000, + balanceToken.decimals ), - max: Number( - parseToBigNumPrecision( - Number(values.pricingBounds.IMPRESSION?.max) / 1000, - balanceToken.decimals - ) + max: parseToBigNumPrecision( + Number(values.pricingBounds.IMPRESSION?.max) / 1000, + balanceToken.decimals ) } - const body: FormProps = { - pricingBounds: { - CLICK: { min: 0, max: 0 }, - IMPRESSION: impression - }, - targetingInput: { - version: '1', - inputs: { - categories: values.targetingInput.inputs.categories, - location: values.targetingInput.inputs.location, - advanced: { - disableFrequencyCapping: - values.targetingInput.inputs.advanced.disableFrequencyCapping, - includeIncentivized: values.targetingInput.inputs.advanced.includeIncentivized, - limitDailyAverageSpending: - values.targetingInput.inputs.advanced.limitDailyAverageSpending - } - } - } + const pricingBounds: Partial = { + IMPRESSION: impression + } + const inputs: Partial = { + categories: values.targetingInput.inputs.categories, + location: values.targetingInput.inputs.location } - try { - await adexServicesRequest('backend', { - route: `/dsp/campaigns/edit/${campaign.id}`, - method: 'PUT', - body, - headers: { - 'Content-Type': 'application/json' - } - }) + const { success } = await editCampaign(campaign.id, pricingBounds, inputs) + if (success) { form.resetDirty() - showNotification('info', 'Successfully updated Campaign data!') - updateCampaignDataById(campaign.id) - } catch { - return showNotification('error', "Couldn't update the Campaign data!") } }, - [ - balanceToken.decimals, - adexServicesRequest, - campaign.id, - form, - showNotification, - updateCampaignDataById - ] + [balanceToken.decimals, editCampaign, campaign.id, form] ) const throttledSbm = useMemo(() => { - return throttle(editCampaign, 3000, { leading: true }) - }, [editCampaign]) + return throttle(handleEditCampaign, 3000, { leading: true }) + }, [handleEditCampaign]) if (!campaign) return
Invalid Campaign ID
return ( diff --git a/src/components/common/CustomTable/CustomTable.tsx b/src/components/common/CustomTable/CustomTable.tsx index 4b476b27..ced796b3 100644 --- a/src/components/common/CustomTable/CustomTable.tsx +++ b/src/components/common/CustomTable/CustomTable.tsx @@ -31,7 +31,7 @@ export type TableRowAction = { action: (e: TableElement['actionData']) => any label: ((e: TableElement['actionData']) => string) | string color?: MantineColor - icon: ReactNode + icon: ((e: TableElement['actionData']) => ReactNode) | ReactNode disabled?: (e?: TableElement['actionData']) => boolean hide?: (e?: TableElement['actionData']) => boolean } @@ -53,6 +53,14 @@ const getLabel = (label: TableRowAction['label'], actionData: TableElement['acti return label } +const getIcon = (icon: TableRowAction['icon'], actionData: TableElement['actionData']) => { + if (typeof icon === 'function') { + return icon(actionData) + } + + return icon +} + export const CustomTable = ({ headings, elements, @@ -100,7 +108,7 @@ export const CustomTable = ({ onClick={() => a.action(e.actionData || e)} disabled={a.disabled?.(e.actionData || e)} > - {a.icon} + {getIcon(a.icon, e.actionData)} ) @@ -123,7 +131,7 @@ export const CustomTable = ({ key={label} leftSection={ - {a.icon} + {getIcon(a.icon, e.actionData)} } onClick={() => a.action(e.actionData || e)} diff --git a/src/contexts/AccountContext/AccountContext.tsx b/src/contexts/AccountContext/AccountContext.tsx index 5dfe7550..91f1f0b9 100644 --- a/src/contexts/AccountContext/AccountContext.tsx +++ b/src/contexts/AccountContext/AccountContext.tsx @@ -20,6 +20,7 @@ import { AmbireLoginSDK } from '@ambire/login-sdk-core' import { DAPP_ICON_PATH, DAPP_NAME, DEFAULT_CHAIN_ID } from 'constants/login' import useCustomNotifications from 'hooks/useCustomNotifications' import { fetchService, getReqErr, RequestOptions } from 'services' +import SuperJSON from 'superjson' const ambireLoginSDK = new AmbireLoginSDK({ dappName: DAPP_NAME, @@ -31,7 +32,7 @@ export const VALIDATOR_BASE_URL = process.env.REACT_APP_VALIDATOR_BASE_URL const UNAUTHORIZED_ERR_STR = 'Unauthorized!' console.log({ BACKEND_BASE_URL }) -const processResponse = (res: any) => { +const processResponse = (res: Response): Promise => { // console.log('res', res) if (res.status >= 200 && res.status < 400) { return res.json() @@ -49,8 +50,9 @@ const processResponse = (res: any) => { type AdExService = 'backend' | 'validator' -type ApiRequestOptions = Omit, 'url'> & { +type ApiRequestOptions = Omit & { route: string + body?: BodyInit | object | string | null noAuth?: boolean onErrMsg?: string } @@ -64,10 +66,10 @@ interface IAccountContext { disconnectWallet: () => void updateAccessToken: () => Promise resetAdexAccount: () => void - adexServicesRequest: ( + adexServicesRequest: ( service: AdExService, - reqOptions: ApiRequestOptions - ) => Promise + reqOptions: ApiRequestOptions + ) => Promise updateBalance: () => Promise updateBillingDetails: (billingDetails: BillingDetails) => Promise isLoading: boolean @@ -235,7 +237,7 @@ const AccountProvider: FC = ({ children }) => { const adexServicesRequest = useCallback( // Note - async (service: AdExService, reqOptions: ApiRequestOptions): Promise => { + async (service: AdExService, reqOptions: ApiRequestOptions): Promise => { // temp hax for using the same token fot validator auth const authHeaderProp = service === 'backend' ? 'X-DSP-AUTH' : 'authorization' @@ -244,10 +246,10 @@ const AccountProvider: FC = ({ children }) => { const baseUrl = (service === 'backend' ? BACKEND_BASE_URL : VALIDATOR_BASE_URL) || '' const urlCheck = reqOptions.route.replace(baseUrl, '').replace(/^\//, '') - const req: RequestOptions = { + const req: RequestOptions = { url: `${baseUrl}/${urlCheck}`, method: reqOptions.method, - body: reqOptions.body, + body: reqOptions.body ? JSON.stringify(SuperJSON.serialize(reqOptions.body).json) : null, queryParams: reqOptions.queryParams, headers: reqOptions.headers } @@ -277,16 +279,17 @@ const AccountProvider: FC = ({ children }) => { console.log('req', req) - return fetchService(req) - .then(processResponse) - .catch((err) => { - console.log(err) - // TODO: better check - if (err && err.message && err.message.includes(UNAUTHORIZED_ERR_STR)) { - resetAdexAccount() - } - showNotification('error', err.message, reqOptions.onErrMsg || 'Data error') - }) + try { + const res = await fetchService(req) + return await processResponse(res) + } catch (err: any) { + console.log(err) + if (err && err.message && err.message.includes(UNAUTHORIZED_ERR_STR)) { + resetAdexAccount() + } + showNotification('error', err.message, reqOptions.onErrMsg || 'Data error') + return Promise.reject() + } }, [adexAccount.accessToken, resetAdexAccount, showNotification, updateAccessToken] ) @@ -458,7 +461,7 @@ const AccountProvider: FC = ({ children }) => { const updateBillingDetails = useCallback( async (billingDetails: BillingDetails) => { try { - const updated = await adexServicesRequest('backend', { + const updated = await adexServicesRequest<{ success?: boolean }>('backend', { route: '/dsp/accounts/billing-details', method: 'PUT', headers: { 'Content-Type': 'application/json' }, diff --git a/src/contexts/CampaignsContext/CampaignsDataContext.tsx b/src/contexts/CampaignsContext/CampaignsDataContext.tsx index ef8baa14..7245dcc8 100644 --- a/src/contexts/CampaignsContext/CampaignsDataContext.tsx +++ b/src/contexts/CampaignsContext/CampaignsDataContext.tsx @@ -16,6 +16,11 @@ import { CREATE_CAMPAIGN_DEFAULT_VALUE } from 'constants/createCampaign' import { parseBigNumTokenAmountToDecimal } from 'helpers/balances' import { defaultSupplyStats } from './defaultData' +type NotificationMsg = { + title?: string + msg?: string +} + const defaultCampaignData: CampaignData = { campaignId: '', campaign: { ...CREATE_CAMPAIGN_DEFAULT_VALUE }, @@ -117,7 +122,15 @@ interface ICampaignsDataContext { initialDataLoading: boolean changeCampaignStatus: (status: CampaignStatus, campaignId: Campaign['id']) => void deleteDraftCampaign: (id: string) => void + editCampaign: ( + campaignId: string, + pricingBounds?: Partial, + inputs?: Partial, + successMsg?: NotificationMsg, + errMsg?: NotificationMsg + ) => Promise<{ success: boolean }> toggleArchived: (id: string) => void + toggleBlockedSource: (campaignId: string, srcId: string, srcName: string) => Promise } const CampaignsDataContext = createContext(null) @@ -380,6 +393,102 @@ const CampaignsDataProvider: FC [adexServicesRequest, updateCampaignDataById] ) + const editCampaign = useCallback( + async ( + campaignId: string, + pricingBounds?: Partial, + inputs?: Partial, + successMsg?: NotificationMsg, + errMsg?: NotificationMsg + ) => { + const campaign = campaignsData.get(campaignId)?.campaign + + if (!campaign) { + throw new Error('invalid campaign') + } + + const body: Pick = { + pricingBounds: { ...campaign.pricingBounds, ...pricingBounds }, + targetingInput: { + ...campaign.targetingInput, + inputs: { + ...campaign.targetingInput.inputs, + ...(inputs?.location && { location: inputs.location }), + ...(inputs?.categories && { + categories: inputs.categories + }), + ...(inputs?.publishers && { + publishers: inputs.publishers + }), + // NOTE: uncomment if we decide that changing placements will be editable on the UI + // ...(targetingInput?.inputs?.placements && { placements: targetingInput.inputs.placements}), + ...(inputs?.advanced && { advanced: inputs.advanced }) + } + } + } + + try { + const res = await adexServicesRequest<{ success?: boolean }>('backend', { + route: `/dsp/campaigns/edit/${campaign.id}`, + method: 'PUT', + body, + headers: { + 'Content-Type': 'application/json' + } + }) + + if (!res?.success) { + throw new Error('Error on updating campaign data') + } + + showNotification( + 'info', + successMsg?.msg || 'Successfully updated Campaign data!', + successMsg?.title + ) + updateCampaignDataById(campaign.id) + return { success: true } + } catch { + showNotification( + 'error', + errMsg?.msg || "Couldn't update the Campaign data!", + errMsg?.title + ) + return { success: false } + } + }, + [campaignsData, adexServicesRequest, showNotification, updateCampaignDataById] + ) + + const toggleBlockedSource: ICampaignsDataContext['toggleBlockedSource'] = useCallback( + async (campaignId, srcName, srcId): Promise => { + const campaign = campaignsData.get(campaignId)?.campaign + if (!campaign) { + throw new Error('invalid campaign ') + } + + const isBlocked = campaign.targetingInput.inputs.publishers.nin.includes(srcId) + + const blockedPublishers: Campaign['targetingInput']['inputs']['publishers'] = { + ...campaign.targetingInput.inputs.publishers, + apply: 'nin', + nin: isBlocked + ? [...campaign.targetingInput.inputs.publishers.nin].filter((x) => x !== srcId) + : [...campaign.targetingInput.inputs.publishers.nin, srcId] + } + + const inputs: Partial = { + publishers: { ...blockedPublishers } + } + + await editCampaign(campaignId, undefined, inputs, { + title: isBlocked ? 'Unblocked' : 'Blocked', + msg: srcName + }) + }, + [campaignsData, editCampaign] + ) + const contextValue = useMemo( () => ({ campaignsData, @@ -390,7 +499,9 @@ const CampaignsDataProvider: FC changeCampaignStatus, deleteDraftCampaign, toggleArchived, - updateSupplyStats + updateSupplyStats, + toggleBlockedSource, + editCampaign }), [ campaignsData, @@ -401,7 +512,9 @@ const CampaignsDataProvider: FC changeCampaignStatus, deleteDraftCampaign, toggleArchived, - updateSupplyStats + updateSupplyStats, + toggleBlockedSource, + editCampaign ] ) diff --git a/src/contexts/CreateCampaignContext/CreateCampaignContext.tsx b/src/contexts/CreateCampaignContext/CreateCampaignContext.tsx index 4c40febc..105d65a3 100644 --- a/src/contexts/CreateCampaignContext/CreateCampaignContext.tsx +++ b/src/contexts/CreateCampaignContext/CreateCampaignContext.tsx @@ -8,7 +8,7 @@ import { useState } from 'react' import { CREATE_CAMPAIGN_DEFAULT_VALUE, dateNowPlusThirtyDays } from 'constants/createCampaign' -import superjson, { serialize } from 'superjson' +import superjson from 'superjson' import { CampaignUI, CreateCampaignType, @@ -333,12 +333,10 @@ const CreateCampaignContextProvider: FC = ({ children }) => { const publishCampaign = useCallback(() => { const preparedCampaign = prepareCampaignObject(campaign, balanceToken.decimals) - const body = serialize(preparedCampaign).json - return adexServicesRequest('backend', { route: '/dsp/campaigns', method: 'POST', - body, + body: preparedCampaign, headers: { 'Content-Type': 'application/json' } @@ -353,13 +351,11 @@ const CreateCampaignContextProvider: FC = ({ children }) => { preparedCampaign.title = preparedCampaign.title || `Draft Campaign ${formatDateTime(new Date())}` - const body = serialize(preparedCampaign).json - try { const res = await adexServicesRequest('backend', { route: '/dsp/campaigns/draft', method: 'POST', - body, + body: preparedCampaign, headers: { 'Content-Type': 'application/json' } diff --git a/src/lib/backend/login.ts b/src/lib/backend/login.ts index 42909808..490c19ef 100644 --- a/src/lib/backend/login.ts +++ b/src/lib/backend/login.ts @@ -56,20 +56,18 @@ export const isTokenExpired = (token: string) => { export const getMessageToSign = async (user: any) => { const url = `${BACKEND_BASE_URL}/dsp/login-msg` - const method = 'POST' const body = { wallet: user.address, chainId: user.chainId } - const headers = { - 'Content-Type': 'application/json' - } - const req: RequestOptions = { + const req: RequestOptions = { url, - method, - headers, - body + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(body) } return fetchService(req).then(processResponse) @@ -82,31 +80,29 @@ type VerifyLoginProps = { export const verifyLogin = async (body: VerifyLoginProps) => { const url = `${BACKEND_BASE_URL}/dsp/login-verify` - const method = 'POST' - const headers = { - 'Content-Type': 'application/json' - } - const req: RequestOptions = { + const req: RequestOptions = { url, - method, - headers, - body + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(body) } return fetchService(req).then(processResponse) } export const refreshAccessToken = async (refreshToken: string) => { - const req: RequestOptions = { + const req: RequestOptions = { url: `${BACKEND_BASE_URL}/dsp/refresh-token`, method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: { + body: JSON.stringify({ refreshToken - } + }) } return fetchService(req).then(processResponse) diff --git a/src/services/fetch.ts b/src/services/fetch.ts index 712e2548..1f0fa6eb 100644 --- a/src/services/fetch.ts +++ b/src/services/fetch.ts @@ -1,12 +1,12 @@ -export interface RequestOptions { +export interface RequestOptions { url: string method?: 'GET' | 'POST' | 'OPTIONS' | 'PUT' | 'DELETE' headers?: Record queryParams?: Record - body?: T | FormData + body?: BodyInit | null } -export async function fetchService(options: RequestOptions) { +export async function fetchService(options: RequestOptions): Promise { const { url, method = 'GET', headers = {}, queryParams, body } = options const queryString = queryParams ? new URLSearchParams(queryParams).toString() : '' @@ -18,7 +18,7 @@ export async function fetchService(options: RequestOptions) { headers: { ...headers }, - body: body instanceof FormData ? body : JSON.stringify(body) + body }) }