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 && (
+
+
+
+ )}