diff --git a/package.json b/package.json index a2548cac..c96d6a30 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "react-router-dom": "5.3.4", "react-virtualized-auto-sizer": "1.0.24", "react-window": "1.8.10", + "swr": "2.2.5", "tweetnacl": "1.0.3", "yup": "1.4.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 16768a63..db918c2b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -107,6 +107,9 @@ importers: react-window: specifier: 1.8.10 version: 1.8.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + swr: + specifier: 2.2.5 + version: 2.2.5(react@18.3.1) tweetnacl: specifier: 1.0.3 version: 1.0.3 @@ -2238,6 +2241,9 @@ packages: resolution: {integrity: sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==} engines: {node: '>= 10'} + client-only@0.0.1: + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + cliui@6.0.0: resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} @@ -4085,6 +4091,11 @@ packages: swap-case@2.0.2: resolution: {integrity: sha512-kc6S2YS/2yXbtkSMunBtKdah4VFETZ8Oh6ONSmSd9bRxhqTrtARUCBUiWXH3xVPpvR7tz2CSnkuXVE42EcGnMw==} + swr@2.2.5: + resolution: {integrity: sha512-QtxqyclFeAsxEUeZIYmsaQ0UjimSq1RZ9Un7I68/0ClKK/U3LoyQunwkQfJZr2fc22DfIXLNDc2wFyTEikCUpg==} + peerDependencies: + react: ^16.11.0 || ^17.0.0 || ^18.0.0 + symbol-observable@4.0.0: resolution: {integrity: sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==} engines: {node: '>=0.10'} @@ -4292,6 +4303,11 @@ packages: urlpattern-polyfill@8.0.2: resolution: {integrity: sha512-Qp95D4TPJl1kC9SKigDcqgyM2VDVO4RiJc2d4qe5GrYm+zbIQCWWKAFaJNQ4BhdFeDGwBmAxqJBwWSJDb9T3BQ==} + use-sync-external-store@1.2.2: + resolution: {integrity: sha512-PElTlVMwpblvbNqQ82d2n6RjStvdSoNe9FG28kNfz3WiXilJm4DdNkEzRhCZuIDwY8U08WVihhGR5iRqAwfDiw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -7068,6 +7084,8 @@ snapshots: cli-width@3.0.0: {} + client-only@0.0.1: {} + cliui@6.0.0: dependencies: string-width: 4.2.3 @@ -9119,6 +9137,12 @@ snapshots: dependencies: tslib: 2.6.2 + swr@2.2.5(react@18.3.1): + dependencies: + client-only: 0.0.1 + react: 18.3.1 + use-sync-external-store: 1.2.2(react@18.3.1) + symbol-observable@4.0.0: {} text-table@0.2.0: {} @@ -9306,6 +9330,10 @@ snapshots: urlpattern-polyfill@8.0.2: {} + use-sync-external-store@1.2.2(react@18.3.1): + dependencies: + react: 18.3.1 + util-deprecate@1.0.2: {} value-equal@1.0.1: {} diff --git a/src/App.tsx b/src/App.tsx index 59085e5e..663d1010 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -20,6 +20,7 @@ import { BraveAdsContactFrame } from "@/auth/registration/BraveAdsContactFrame"; import { useMatomo } from "@jonkoops/matomo-tracker-react"; import { SearchLandingPage } from "@/search/SearchLandingPage"; import { BasicAttentionTokenLandingPage } from "@/basic-attention-token/BasicAttentionTokenLandingPage"; +import { SearchPreviewPage } from "./search/preview/SearchPreviewPage"; export function App() { const { enableLinkTracking } = useMatomo(); @@ -46,6 +47,7 @@ export function App() { + diff --git a/src/components/Creatives/SearchPreview.tsx b/src/components/Creatives/SearchPreview.tsx index 16750215..72903828 100644 --- a/src/components/Creatives/SearchPreview.tsx +++ b/src/components/Creatives/SearchPreview.tsx @@ -166,13 +166,15 @@ export function SearchPreview({ title, body, targetUrl, favicon }: Props) { sx={{ alignItems: "center", color: "var(--color-serp-breadcrumbs)", - display: "flex", + display: "block", fontSize: "var(--text-sm)", fontStyle: "normal", lineHeight: "22px", marginTop: searchRemToPx(-0.15), maxWidth: "90%", - overflow: "visible", + overflow: "hidden", + whiteSpace: "nowrap", + textOverflow: "ellipsis", }} > diff --git a/src/components/Navigation/AccountMenu.tsx b/src/components/Navigation/AccountMenu.tsx index 08a775b3..b35ec0a0 100644 --- a/src/components/Navigation/AccountMenu.tsx +++ b/src/components/Navigation/AccountMenu.tsx @@ -31,6 +31,13 @@ export function AccountMenu() { const nonCurrentAdvertisers = advertisers.filter( (a) => a.id !== advertiser.id, ); + + // the navbar can be used for non-logged in users, so just don't display + // a profile if the user is not logged in + if (!user.userId) { + return null; + } + return ( <> Account} placement="bottom-start"> diff --git a/src/search/preview/CallToAction.tsx b/src/search/preview/CallToAction.tsx new file mode 100644 index 00000000..e9481d7e --- /dev/null +++ b/src/search/preview/CallToAction.tsx @@ -0,0 +1,56 @@ +/* eslint-disable lingui/no-unlocalized-strings */ +import { Box, Button, Link, Typography } from "@mui/material"; + +interface Props { + domain: string; +} + +export function CallToAction({ domain }: Props) { + return ( + + + + Brave Search Ads Preview + + + This preview has been created for{" "} + + {domain} + {" "} + by analyzing existing Google campaigns and matching them with keyword + volumes from Brave Search. + + + To view and manage all available ads and get started with Search Ads + from Brave, please book a meeting with an account manager. + + + + + + + + ); +} diff --git a/src/search/preview/LandingPageDetail.tsx b/src/search/preview/LandingPageDetail.tsx new file mode 100644 index 00000000..e360819f --- /dev/null +++ b/src/search/preview/LandingPageDetail.tsx @@ -0,0 +1,106 @@ +/* eslint-disable lingui/no-unlocalized-strings */ +import SearchIcon from "@mui/icons-material/Search"; +import { Box, Chip, IconButton, Popover, Typography } from "@mui/material"; +import { useState } from "react"; +import { LandingPageInfo, useKeywordData } from "./data"; +import dayjs from "dayjs"; +import { SkeletonQueryList } from "@/user/views/user/search/LandingPageDetail"; + +function QueryList({ queries }: { queries: string[] }) { + const [visibleQueryCount, setVisibleQueryCount] = useState(20); + + const numQueries = queries.length; + const hasMore = numQueries > visibleQueryCount; + + const queriesToShow = queries.slice(0, visibleQueryCount); + + return ( + <> + {queriesToShow.map((q) => ( + + ))} + + {hasMore && ( + setVisibleQueryCount((c) => c + 50)} + /> + )} + + ); +} + +interface Props { + landingPage: LandingPageInfo; +} + +export function LandingPageDetail({ landingPage }: Props) { + const [anchorEl, setAnchorEl] = useState(null); + + const handleClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + const handleClose = () => { + setAnchorEl(null); + }; + + const open = Boolean(anchorEl); + + return ( +
+ + + + + + +
+ ); +} + +function LandingPageDetailContent({ + landingPage, +}: { + landingPage: LandingPageInfo; +}) { + const { data: queries } = useKeywordData(landingPage.slug, landingPage.url); + + return ( + + + Full Landing Page URL + + + + {landingPage.url} + + + + Last seen {dayjs(landingPage.lastSeen).fromNow()} + + + + Sample Queries + + + + {queries ? : } + + + ); +} diff --git a/src/search/preview/LandingPageList.tsx b/src/search/preview/LandingPageList.tsx new file mode 100644 index 00000000..d5372e92 --- /dev/null +++ b/src/search/preview/LandingPageList.tsx @@ -0,0 +1,55 @@ +import { LandingPageListEntry } from "./LandingPageListEntry"; + +import { FixedSizeList } from "react-window"; +import AutoSizer from "react-virtualized-auto-sizer"; +import { Basket } from "@/user/views/user/search/basket"; +import { LandingPageInfo } from "./data"; + +interface Props { + basket: Basket; + landingPages: LandingPageInfo[]; + allowSelection?: boolean; +} + +export function LandingPageList({ + landingPages, + basket, + allowSelection = true, +}: Props) { + return ( + + {({ height, width }) => ( + + {({ index, style }) => { + const landingPage = landingPages[index]; + + return ( + 1} + creativeIndex={basket.creativeIndexForLandingPage( + landingPage.url, + )} + nextCreative={() => + basket.nextCreativeForLandingPage( + landingPage.url, + landingPage.creatives.length, + ) + } + /> + ); + }} + + )} + + ); +} diff --git a/src/search/preview/LandingPageListEntry.tsx b/src/search/preview/LandingPageListEntry.tsx new file mode 100644 index 00000000..86440807 --- /dev/null +++ b/src/search/preview/LandingPageListEntry.tsx @@ -0,0 +1,51 @@ +import { SearchPreview } from "@/components/Creatives/SearchPreview"; +import { Box, IconButton, ListItem } from "@mui/material"; +import React, { DispatchWithoutAction } from "react"; +import LoopIcon from "@mui/icons-material/Loop"; +import { LandingPageDetail } from "./LandingPageDetail"; +import { LandingPageInfo } from "./data"; + +interface Props { + landingPage: LandingPageInfo; + + creativeIndex: number; + nextCreative: DispatchWithoutAction; + + hasMultipleCreatives: boolean; + + style: React.CSSProperties; + + allowSelection: boolean; +} + +export function LandingPageListEntry({ + landingPage, + creativeIndex, + nextCreative, + hasMultipleCreatives, + style, +}: Props) { + return ( + + + + + + + + + + + + ); +} diff --git a/src/search/preview/NoPreviewAvailable.tsx b/src/search/preview/NoPreviewAvailable.tsx new file mode 100644 index 00000000..10f8222c --- /dev/null +++ b/src/search/preview/NoPreviewAvailable.tsx @@ -0,0 +1,12 @@ +import { Alert, AlertTitle } from "@mui/material"; + +/* eslint-disable lingui/no-unlocalized-strings */ + +export function NoPreviewAvailable() { + return ( + + No Results + Please check the URL is correct + + ); +} diff --git a/src/search/preview/SearchPreviewPage.tsx b/src/search/preview/SearchPreviewPage.tsx new file mode 100644 index 00000000..d195f316 --- /dev/null +++ b/src/search/preview/SearchPreviewPage.tsx @@ -0,0 +1,39 @@ +import { ErrorBoundary } from "@/ErrorBoundary"; +import { Navbar } from "@/components/Navigation/Navbar"; +import { Box } from "@mui/material"; +import { useParams } from "react-router-dom"; +import { useLandingPageData } from "./data"; +import { SearchPreviewResults } from "./SearchPreviewResults"; +import { FullScreenProgress } from "@/components/FullScreenProgress"; +import { NoPreviewAvailable } from "./NoPreviewAvailable"; + +/* eslint-disable lingui/no-unlocalized-strings */ + +export function SearchPreviewPage() { + const { slug } = useParams<{ slug: string }>(); + const { loading, data } = useLandingPageData(slug); + return ( + + + + + {data ? ( + + ) : loading ? ( + + ) : ( + + )} + + + + ); +} diff --git a/src/search/preview/SearchPreviewResults.tsx b/src/search/preview/SearchPreviewResults.tsx new file mode 100644 index 00000000..37807c2d --- /dev/null +++ b/src/search/preview/SearchPreviewResults.tsx @@ -0,0 +1,48 @@ +import { Box, Container } from "@mui/material"; +import { SearchData } from "./data"; +import { CardContainer } from "@/components/Card/CardContainer"; +import { useBasket } from "@/user/views/user/search/basket"; +import { SummaryPanel } from "./SummaryPanel"; +import { LandingPageList } from "./LandingPageList"; +import { CallToAction } from "./CallToAction"; + +/* eslint-disable lingui/no-unlocalized-strings */ + +interface Props { + data: SearchData; +} + +export function SearchPreviewResults({ data }: Props) { + // we don't actually use the basket + const basket = useBasket(); + return ( + + + + + + + + + + + + + + ); +} diff --git a/src/search/preview/SummaryPanel.tsx b/src/search/preview/SummaryPanel.tsx new file mode 100644 index 00000000..a68ebb2b --- /dev/null +++ b/src/search/preview/SummaryPanel.tsx @@ -0,0 +1,52 @@ +/* eslint-disable lingui/no-unlocalized-strings */ +import { CardContainer } from "@/components/Card/CardContainer"; +import { Box, Typography } from "@mui/material"; +import { ReactNode } from "react"; +import PublicIcon from "@mui/icons-material/Public"; +import DomainIcon from "@mui/icons-material/Domain"; +import { CountryDomain } from "@/user/views/user/search/types"; + +function SummaryEntry({ + title, + value, + icon, +}: { + title: string; + value: ReactNode; + icon: ReactNode; +}) { + return ( + + {icon} + + + {title} + + + {value} + + + + ); +} + +interface Props { + domain: CountryDomain; + countryName: string; +} + +export function SummaryPanel({ domain, countryName }: Props) { + return ( + + + Campaign Summary + + } /> + } + /> + + ); +} diff --git a/src/search/preview/data.ts b/src/search/preview/data.ts new file mode 100644 index 00000000..b845ff38 --- /dev/null +++ b/src/search/preview/data.ts @@ -0,0 +1,81 @@ +/* eslint-disable lingui/no-unlocalized-strings */ +import { CountryDomain } from "@/user/views/user/search/types"; +import { buildAdServerEndpoint } from "@/util/environment"; +import useSWR from "swr"; + +/* this is the data we get back from the server */ +interface ServerSearchData { + countryDomain: CountryDomain; + fullCountryName: string; + landingPages: ServerLandingPageInfo[]; +} + +interface ServerLandingPageInfo { + url: string; + favicon: string; + lastSeen: string; + creatives: Array<{ + title: string; + body?: string | null; + }>; +} + +/* and it's very convenient to have the slug in the data structures we pass round internally */ +export interface SearchData extends ServerSearchData { + landingPages: LandingPageInfo[]; +} + +export interface LandingPageInfo extends ServerLandingPageInfo { + slug: string; +} + +interface UseSearchDataReturn { + data?: T; + loading: boolean; +} + +const fetcher = (suffix: string) => + fetch(`${buildAdServerEndpoint("")}/search/preview/${suffix}`).then((r) => { + if (!r.ok) { + throw new Error(`Error fetching search data: ${r.status}`); + } + + return r.json(); + }); + +export function useLandingPageData( + slug: string, +): UseSearchDataReturn { + const { data, isLoading } = useSWR(slug, fetcher); + + if (!data) { + return { loading: isLoading }; + } + + return { + loading: isLoading, + data: { + ...data, + landingPages: data?.landingPages.map((lp) => ({ + ...lp, + slug, + })), + }, + }; +} + +export function useKeywordData( + slug: string, + landingPageUrl: string, +): UseSearchDataReturn { + const qs = new URLSearchParams({ url: landingPageUrl }); + const { data, isLoading } = useSWR( + `${slug}/keywords?${qs}`, + fetcher, + ); + + return { + loading: isLoading, + data, + }; +} diff --git a/src/user/views/user/search/LandingPageDetail.tsx b/src/user/views/user/search/LandingPageDetail.tsx index 3b217518..f7a243e9 100644 --- a/src/user/views/user/search/LandingPageDetail.tsx +++ b/src/user/views/user/search/LandingPageDetail.tsx @@ -68,7 +68,7 @@ function QueryList({ ); } -function SkeletonQueryList() { +export function SkeletonQueryList() { return _.range(0, 20).map((i) => ( )); diff --git a/src/user/views/user/search/LandingPageList.tsx b/src/user/views/user/search/LandingPageList.tsx index d7e40976..21ba2a54 100644 --- a/src/user/views/user/search/LandingPageList.tsx +++ b/src/user/views/user/search/LandingPageList.tsx @@ -11,9 +11,15 @@ interface Props { domain: CountryDomain; basket: Basket; landingPages: SearchProspectsLandingPageListFragment[] | undefined; + allowSelection?: boolean; } -export function LandingPageList({ landingPages, basket, domain }: Props) { +export function LandingPageList({ + landingPages, + basket, + domain, + allowSelection = true, +}: Props) { if (!landingPages) { return ; } @@ -37,6 +43,7 @@ export function LandingPageList({ landingPages, basket, domain }: Props) { style={style} domain={domain} landingPage={landingPage} + allowSelection={allowSelection} hasMultipleCreatives={landingPage.creatives.length > 1} selected={basket.isLandingPageSelected(landingPage.url)} toggleSelection={() => diff --git a/src/user/views/user/search/LandingPageListEntry.tsx b/src/user/views/user/search/LandingPageListEntry.tsx index fd7ff9e2..bad11edc 100644 --- a/src/user/views/user/search/LandingPageListEntry.tsx +++ b/src/user/views/user/search/LandingPageListEntry.tsx @@ -25,6 +25,8 @@ interface Props { style: React.CSSProperties; domain: CountryDomain; + + allowSelection: boolean; } export function LandingPageListEntry({ @@ -36,12 +38,19 @@ export function LandingPageListEntry({ hasMultipleCreatives, domain, style, + allowSelection, }: Props) { return ( - - - + {allowSelection && ( + + + + )}