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
})
}