diff --git a/packages/extension/src/background/index.ts b/packages/extension/src/background/index.ts index d1f5252fed..5f4e2cec94 100644 --- a/packages/extension/src/background/index.ts +++ b/packages/extension/src/background/index.ts @@ -155,9 +155,7 @@ async function handleMessages( browser.runtime.onMessage.addListener(handleMessages); -// since we are using V2 on FF / V3 on Chrome, -// we need to support both action (V3) & browserAction (V2) APIs -(browser.action || browser.browserAction).onClicked.addListener(() => { +browser.action.onClicked.addListener(() => { const url = browser.runtime.getURL('index.html?source=button'); browser.tabs.create({ url, active: true }); }); diff --git a/packages/extension/src/companion/App.tsx b/packages/extension/src/companion/App.tsx index fc68967425..cab2ec67a2 100644 --- a/packages/extension/src/companion/App.tsx +++ b/packages/extension/src/companion/App.tsx @@ -24,27 +24,42 @@ import { GrowthBookProvider } from '@dailydotdev/shared/src/components/GrowthBoo import { NotificationsContextProvider } from '@dailydotdev/shared/src/contexts/NotificationsContext'; import { useEventListener } from '@dailydotdev/shared/src/hooks'; import { structuredCloneJsonPolyfill } from '@dailydotdev/shared/src/lib/structuredClone'; +import { REQUEST_PROTOCOL_KEY } from '@dailydotdev/shared/src/graphql/common'; import Companion from './Companion'; import CustomRouter from '../lib/CustomRouter'; import { companionFetch } from './companionFetch'; import { version } from '../../package.json'; +import { MessageSuggestion } from './suggestion/MessageSuggestion'; +import { companionRequest } from './companionRequest'; structuredCloneJsonPolyfill(); const queryClient = new QueryClient(defaultQueryClientConfig); + +queryClient.setQueryData(REQUEST_PROTOCOL_KEY, { + requestMethod: companionRequest, + fetchMethod: companionFetch, + isCompanion: true, +}); + const router = new CustomRouter(); -export type CompanionData = { url: string; deviceId: string } & Pick< - Boot, - | 'postData' - | 'settings' - | 'alerts' - | 'user' - | 'visit' - | 'accessToken' - | 'squads' - | 'exp' ->; +export interface CompanionData + extends Pick< + Boot, + | 'postData' + | 'settings' + | 'alerts' + | 'user' + | 'visit' + | 'accessToken' + | 'squads' + | 'exp' + > { + url: string; + deviceId: string; + messageSuggestionsEnabled?: boolean; +} const app = BootApp.Companion; @@ -59,6 +74,7 @@ export default function App({ accessToken, squads, exp, + messageSuggestionsEnabled, }: CompanionData): ReactElement { useError(); const [token, setToken] = useState(accessToken); @@ -123,13 +139,16 @@ export default function App({ isNotificationsReady={false} unreadCount={0} > - setIsOptOutCompanion(true)} - onUpdateToken={setToken} - /> + {postData && ( + setIsOptOutCompanion(true)} + onUpdateToken={setToken} + /> + )} + {messageSuggestionsEnabled && } (companionExpanded); - useQuery({ - queryKey: REQUEST_PROTOCOL_KEY, - queryFn: () => ({ - requestMethod: companionRequest, - fetchMethod: companionFetch, - isCompanion: true, - }), - }); + const [companionState, setCompanionState] = useState(companionExpanded); const [assetsLoadedDebounce] = useDebounceFn(() => setAssetsLoaded(true), 10); const routeChangedCallbackRef = useLogPageView(); diff --git a/packages/extension/src/companion/index.tsx b/packages/extension/src/companion/index.tsx index 9e1dd2875f..81e3202a1c 100644 --- a/packages/extension/src/companion/index.tsx +++ b/packages/extension/src/companion/index.tsx @@ -35,16 +35,28 @@ const renderApp = (props: CompanionData) => { root.render(); }; +const validOrigins = ['https://www.linkedin.com']; + +const isValidUrl = (url: string) => { + try { + const parsedUrl = new URL(url); + return validOrigins.includes(parsedUrl.origin); + } catch { + return false; + } +}; + browser.runtime.onMessage.addListener((props) => { - const { settings, postData } = props; + const { settings, postData, url } = props; if (!settings || settings?.optOutCompanion) { return; } const container = getCompanionWrapper(); + const isValid = isValidUrl(url); - if (postData) { - renderApp(props); + if (postData || isValid) { + renderApp({ ...props, messageSuggestionsEnabled: isValid }); } else if (container && root) { root.unmount(); } diff --git a/packages/extension/src/companion/suggestion/MessageSuggestion.tsx b/packages/extension/src/companion/suggestion/MessageSuggestion.tsx new file mode 100644 index 0000000000..6c4938bd2c --- /dev/null +++ b/packages/extension/src/companion/suggestion/MessageSuggestion.tsx @@ -0,0 +1,82 @@ +import React, { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { ONE_SECOND, sleep } from '@dailydotdev/shared/src/lib/func'; +import { TargetId } from '@dailydotdev/shared/src/lib/log'; +import { useThreadPageObserver } from './useThreadPageObserver'; +import { MessageSuggestionPortal } from './MessageSuggestionPortal'; + +const bubbleClass = 'msg-overlay-conversation-bubble'; +const titleClass = 'msg-overlay-bubble-header__title'; + +interface MessagePopup { + id: string; + bubble: HTMLElement; +} + +export function MessageSuggestion() { + const { id: threadUserId } = useThreadPageObserver(); + const [popups, setPopups] = useState([]); + const [observer] = useState( + new MutationObserver(async () => { + await sleep(10); // wait for the dom to update + const bubbles = document.querySelectorAll(`.${bubbleClass}`); + const toUpdate = []; + + bubbles.forEach((bubble) => { + const header = bubble.querySelector(`.${titleClass}`); + const anchor = header?.firstElementChild as HTMLAnchorElement; + const id = anchor?.getAttribute('href')?.split('/in/')[1]; + const cleanId = id?.replace(/\//g, ''); + + if (cleanId) { + toUpdate.push({ id: cleanId, bubble }); + } + }); + + setPopups(toUpdate); + }), + ); + + useQuery({ + queryKey: ['msg-overlay'], + queryFn: () => null, + refetchInterval: (cache) => { + const retries = cache.state.dataUpdateCount; + + if (retries >= 3) { + return false; + } + + const container = document.querySelector('#msg-overlay'); + + if (!container) { + return ONE_SECOND; + } + + observer.observe(container, { childList: true, subtree: false }); + + return false; + }, + }); + + return ( + <> + {popups.map(({ id, bubble }) => ( + + ))} + {threadUserId && ( + + )} + + ); +} diff --git a/packages/extension/src/companion/suggestion/MessageSuggestionPortal.tsx b/packages/extension/src/companion/suggestion/MessageSuggestionPortal.tsx new file mode 100644 index 0000000000..3de2574a8c --- /dev/null +++ b/packages/extension/src/companion/suggestion/MessageSuggestionPortal.tsx @@ -0,0 +1,122 @@ +import React, { useEffect } from 'react'; +import { createPortal } from 'react-dom'; +import { inviteRecruiterFeature } from '@dailydotdev/shared/src/lib/featureManagement'; +import { useConditionalFeature } from '@dailydotdev/shared/src/hooks'; +import { useRequestProtocol } from '@dailydotdev/shared/src/hooks/useRequestProtocol'; +import user from '@dailydotdev/shared/__tests__/fixture/loggedUser'; +import { + generateQueryKey, + RequestKey, +} from '@dailydotdev/shared/src/lib/query'; +import { USER_REFERRAL_RECRUITER_QUERY } from '@dailydotdev/shared/src/graphql/users'; +import { useMutation } from '@tanstack/react-query'; +import { useBackgroundRequest } from '@dailydotdev/shared/src/hooks/companion'; +import { CoreIcon } from '@dailydotdev/shared/src/components/icons'; +import { useLogContext } from '@dailydotdev/shared/src/contexts/LogContext'; +import type { TargetId } from '@dailydotdev/shared/src/lib/log'; +import { LogEvent, TargetType } from '@dailydotdev/shared/src/lib/log'; +import { useGenerateSuggestionContainer } from './useGenerateSuggestionContainer'; + +interface MessageSuggestionPortalProps { + bubble: HTMLElement | Document; + id: string; + target_id: TargetId.ThreadPage | TargetId.ThreadPopup; +} + +const input = '.msg-form__contenteditable'; + +export function MessageSuggestionPortal({ + bubble, + id, + target_id, +}: MessageSuggestionPortalProps) { + const { logEvent } = useLogContext(); + const { injectedElement } = useGenerateSuggestionContainer({ + id, + container: bubble, + }); + const { value, isLoading } = useConditionalFeature({ + feature: inviteRecruiterFeature, + shouldEvaluate: !!injectedElement, + }); + const { cta, message } = value; + + const mutationKey = generateQueryKey(RequestKey.InviteRecruiter, user, id); + const { requestMethod } = useRequestProtocol(); + const { mutateAsync } = useMutation({ + mutationFn: () => + requestMethod( + USER_REFERRAL_RECRUITER_QUERY, + { toReferExternalId: id }, + { requestKey: JSON.stringify(mutationKey) }, + ), + }); + + useBackgroundRequest(mutationKey, { + callback: async ({ res }) => { + const url = res?.userReferralRecruiter?.url; + + if (!url) { + return; + } + + const formattedMessage = message.replace('{{url}}', url); + const replyBox = bubble.querySelector(input) as HTMLElement; + const p = document.createElement('p'); + p.innerText = formattedMessage; + + replyBox.innerText = ''; + replyBox.appendChild(p); + + const inputEvent = new InputEvent('input', { + bubbles: true, + cancelable: true, + inputType: 'insertText', + data: formattedMessage, + }); + + replyBox.dispatchEvent(inputEvent); + replyBox.focus(); + }, + }); + + const shouldShow = !!injectedElement && !isLoading && !!id; + const isLoggedRef = React.useRef(false); + + useEffect(() => { + if (shouldShow && !isLoggedRef.current) { + logEvent({ + event_name: LogEvent.Impression, + target_type: TargetType.InviteRecruiter, + target_id, + }); + isLoggedRef.current = true; + } + }, [logEvent, target_id, shouldShow]); + + if (!injectedElement || isLoading || !id) { + return null; + } + + const handleClick = () => { + logEvent({ + event_name: LogEvent.Click, + target_type: TargetType.InviteRecruiter, + target_id, + }); + mutateAsync(); + }; + + return createPortal( + , + injectedElement, + ); +} diff --git a/packages/extension/src/companion/suggestion/useGenerateSuggestionContainer.ts b/packages/extension/src/companion/suggestion/useGenerateSuggestionContainer.ts new file mode 100644 index 0000000000..952c7e308a --- /dev/null +++ b/packages/extension/src/companion/suggestion/useGenerateSuggestionContainer.ts @@ -0,0 +1,68 @@ +import { ONE_SECOND } from '@dailydotdev/shared/src/lib/func'; +import { useQuery } from '@tanstack/react-query'; +import { useState } from 'react'; + +interface UseMessagePopupObserverProps { + id: string; + container: HTMLElement | Document; +} + +const quickRepliesClass = 'msg-s-message-list__quick-replies-container'; +const containerClass = 'msg-s-message-list-content'; +const customClass = 'dd-custom-suggestion'; +const generatedStyles = + 'display: flex; justify-content: center; margin-bottom: 8px;'; + +interface UseGenerateSuggestionContainer { + injectedElement: HTMLElement | null; +} + +export const useGenerateSuggestionContainer = ({ + id, + container, +}: UseMessagePopupObserverProps): UseGenerateSuggestionContainer => { + const [injectedElement, setInjectedElement] = useState( + null, + ); + + useQuery({ + queryKey: ['suggestion-container', id], + queryFn: () => null, + refetchInterval: (cache) => { + const retries = cache.state.dataUpdateCount; + + if (injectedElement || retries >= 3) { + return false; + } + + const quickReplies = container.querySelector(`.${quickRepliesClass}`); + + if (!quickReplies) { + return ONE_SECOND; + } + + const exists = container.querySelector(`.${customClass}`); + + if (exists) { + return false; + } + + const parent = container.querySelector(`.${containerClass}`); + + if (!parent) { + return ONE_SECOND; + } + + const generated = document.createElement('div'); + generated.setAttribute('style', generatedStyles); + generated.setAttribute('class', customClass); + parent.appendChild(generated); + + setInjectedElement(generated); + + return false; + }, + }); + + return { injectedElement }; +}; diff --git a/packages/extension/src/companion/suggestion/useThreadPageObserver.ts b/packages/extension/src/companion/suggestion/useThreadPageObserver.ts new file mode 100644 index 0000000000..ee342ba87e --- /dev/null +++ b/packages/extension/src/companion/suggestion/useThreadPageObserver.ts @@ -0,0 +1,67 @@ +import { ONE_SECOND, sleep } from '@dailydotdev/shared/src/lib/func'; +import { useQuery } from '@tanstack/react-query'; +import { useEffect, useState } from 'react'; +import browser from 'webextension-polyfill'; + +export const useThreadPageObserver = () => { + const [id, setId] = useState(null); + + useQuery({ + queryKey: ['thread-page'], + queryFn: () => null, + refetchInterval: (cache) => { + const url = window.location.href; + + if (!url.includes('/messaging/thread/')) { + return false; + } + + const retries = cache.state.dataUpdateCount; + + if (retries >= 3) { + return false; + } + + const anchor = document.querySelector('.msg-thread__link-to-profile'); + + if (!anchor) { + return ONE_SECOND; + } + + const href = anchor.getAttribute('href'); + const uniqueId = href.split('/in/')[1]; + + setId(uniqueId); + + return false; + }, + }); + + useEffect(() => { + const handleUrlUpdate = async ({ url }: { url: string }) => { + if (!url || !url.includes('/messaging/thread/')) { + return; + } + + await sleep(ONE_SECOND * 2); // wait for the dom to update + + const anchor = document.querySelector('.msg-thread__link-to-profile'); + const href = anchor.getAttribute('href'); + const uniqueId = href.split('/in/')[1]; + + if (uniqueId === id) { + return; + } + + setId(uniqueId); + }; + + browser.runtime.onMessage.addListener(handleUrlUpdate); + + return () => { + browser.runtime.onMessage.removeListener(handleUrlUpdate); + }; + }, [id]); + + return { id }; +}; diff --git a/packages/extension/src/newtab/ShortcutLinks/ShortcutLinks.spec.tsx b/packages/extension/src/newtab/ShortcutLinks/ShortcutLinks.spec.tsx index b010088d2d..7ca636bc42 100644 --- a/packages/extension/src/newtab/ShortcutLinks/ShortcutLinks.spec.tsx +++ b/packages/extension/src/newtab/ShortcutLinks/ShortcutLinks.spec.tsx @@ -90,6 +90,7 @@ const defaultSettings: RemoteSettings = { sortingEnabled: false, optOutReadingStreak: true, optOutCompanion: true, + optOutLinkedinButton: false, autoDismissNotifications: true, sortCommentsBy: SortCommentsBy.NewestFirst, customLinks: [ diff --git a/packages/shared/src/components/ProfileMenu/sections/ExtensionSection.tsx b/packages/shared/src/components/ProfileMenu/sections/ExtensionSection.tsx index 6aa057ab0c..928b8ca67d 100644 --- a/packages/shared/src/components/ProfileMenu/sections/ExtensionSection.tsx +++ b/packages/shared/src/components/ProfileMenu/sections/ExtensionSection.tsx @@ -5,7 +5,13 @@ import { HorizontalSeparator } from '../../utilities'; import { ProfileSection } from '../ProfileSection'; import { useDndContext } from '../../../contexts/DndContext'; import { useSettingsContext } from '../../../contexts/SettingsContext'; -import { PauseIcon, PlayIcon, ShortcutsIcon, StoryIcon } from '../../icons'; +import { + LinkedInIcon, + PauseIcon, + PlayIcon, + ShortcutsIcon, + StoryIcon, +} from '../../icons'; import { useLazyModal } from '../../../hooks/useLazyModal'; import { LazyModal } from '../../modals/common/types'; import { checkIsExtension } from '../../../lib/func'; @@ -13,7 +19,12 @@ import { checkIsExtension } from '../../../lib/func'; export const ExtensionSection = (): ReactElement => { const { openModal } = useLazyModal(); const { isActive: isDndActive, setShowDnd } = useDndContext(); - const { optOutCompanion, toggleOptOutCompanion } = useSettingsContext(); + const { + optOutCompanion, + toggleOptOutCompanion, + optOutLinkedinButton, + toggleOptOutLinkedinButton, + } = useSettingsContext(); if (!checkIsExtension()) { return null; @@ -40,6 +51,13 @@ export const ExtensionSection = (): ReactElement => { icon: () => , onClick: () => toggleOptOutCompanion(), }, + { + title: `${ + optOutLinkedinButton ? 'Enable' : 'Disable' + } Recruiter Referral`, + icon: () => , + onClick: () => toggleOptOutLinkedinButton(), + }, ]} /> diff --git a/packages/shared/src/contexts/SettingsContext.tsx b/packages/shared/src/contexts/SettingsContext.tsx index 065effa84e..3a20e178fb 100644 --- a/packages/shared/src/contexts/SettingsContext.tsx +++ b/packages/shared/src/contexts/SettingsContext.tsx @@ -52,6 +52,7 @@ export interface SettingsContextData extends Omit { toggleSortingEnabled: () => Promise; toggleOptOutReadingStreak: () => Promise; toggleOptOutCompanion: () => Promise; + toggleOptOutLinkedinButton: () => Promise; toggleAutoDismissNotifications: () => Promise; loadedSettings: boolean; updateCustomLinks: (links: string[]) => Promise; @@ -125,6 +126,7 @@ const defaultSettings: RemoteSettings = { sortingEnabled: false, optOutReadingStreak: false, optOutCompanion: false, + optOutLinkedinButton: false, autoDismissNotifications: true, sortCommentsBy: SortCommentsBy.OldestFirst, theme: remoteThemes[ThemeMode.Dark], @@ -254,6 +256,11 @@ export const SettingsContextProvider = ({ ...settings, optOutCompanion: !settings.optOutCompanion, }), + toggleOptOutLinkedinButton: () => + setSettings({ + ...settings, + optOutLinkedinButton: !settings.optOutLinkedinButton, + }), toggleAutoDismissNotifications: () => setSettings({ ...settings, diff --git a/packages/shared/src/graphql/settings.ts b/packages/shared/src/graphql/settings.ts index df5c764025..edee39f877 100644 --- a/packages/shared/src/graphql/settings.ts +++ b/packages/shared/src/graphql/settings.ts @@ -41,6 +41,7 @@ export type RemoteSettings = { sortingEnabled: boolean; optOutReadingStreak: boolean; optOutCompanion: boolean; + optOutLinkedinButton: boolean; autoDismissNotifications: boolean; sortCommentsBy: SortCommentsBy; customLinks?: string[]; diff --git a/packages/shared/src/graphql/users.ts b/packages/shared/src/graphql/users.ts index 5cc4760e0c..5aa0a511f9 100644 --- a/packages/shared/src/graphql/users.ts +++ b/packages/shared/src/graphql/users.ts @@ -809,3 +809,11 @@ export const updateNotificationSettings = async ( notificationFlags, }); }; + +export const USER_REFERRAL_RECRUITER_QUERY = gql` + query UserReferralRecruiter($toReferExternalId: String!) { + userReferralRecruiter(toReferExternalId: $toReferExternalId) { + url + } + } +`; diff --git a/packages/shared/src/hooks/companion/useRawBackgroundRequest.ts b/packages/shared/src/hooks/companion/useRawBackgroundRequest.ts index 6cb9c11449..5fefd0da1d 100644 --- a/packages/shared/src/hooks/companion/useRawBackgroundRequest.ts +++ b/packages/shared/src/hooks/companion/useRawBackgroundRequest.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import type { Browser } from 'webextension-polyfill'; import type { EmptyObjectLiteral } from '../../lib/kratos'; import { useRequestProtocol } from '../useRequestProtocol'; @@ -6,6 +6,8 @@ import { useRequestProtocol } from '../useRequestProtocol'; export const useRawBackgroundRequest = ( command: (params: EmptyObjectLiteral) => void, ): void => { + const commandRef = useRef(command); + commandRef.current = command; const { isCompanion } = useRequestProtocol(); const [browser, setBrowser] = useState(); @@ -27,7 +29,7 @@ export const useRawBackgroundRequest = ( return; } - command({ key, ...args }); + commandRef.current({ key, ...args }); }; browser.runtime.onMessage.addListener(handler); @@ -35,5 +37,5 @@ export const useRawBackgroundRequest = ( return () => { browser.runtime.onMessage.removeListener(handler); }; - }, [command, browser]); + }, [browser]); }; diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index eb23ab4227..efade52e58 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -108,4 +108,10 @@ export const briefFeedEntrypointPage = new Feature( ); export const briefUIFeature = new Feature('brief_ui', isDevelopment); + +export const inviteRecruiterFeature = new Feature('invite_recruiter', { + cta: 'Earn cores, refer recruiter to daily.dev', + message: `I'm currently not open to opportunities. You might find the right candidate on {{url}}. It's worth checking out!`, +}); + export const adFavicon = new Feature('ad_favicon', isDevelopment); diff --git a/packages/shared/src/lib/func.ts b/packages/shared/src/lib/func.ts index b2d635955a..4982fb375d 100644 --- a/packages/shared/src/lib/func.ts +++ b/packages/shared/src/lib/func.ts @@ -4,10 +4,14 @@ import type { EmptyObjectLiteral } from './kratos'; import { BROADCAST_CHANNEL_NAME, isBrave, isTesting } from './constants'; import type { LogEvent } from '../hooks/log/useLogQueue'; +export const ONE_SECOND = 1000; + export type EmptyPromise = () => Promise; -export const nextTick = (): Promise => - new Promise((resolve) => setTimeout(resolve)); +export const sleep = (ms: number): Promise => + new Promise((resolve) => setTimeout(resolve, ms)); + +export const nextTick = (): Promise => sleep(1); export const parseOrDefault = (data: string): T | string => { try { diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts index 0d1466bf87..f06c825fab 100644 --- a/packages/shared/src/lib/log.ts +++ b/packages/shared/src/lib/log.ts @@ -338,6 +338,7 @@ export enum LogEvent { } export enum TargetType { + InviteRecruiter = 'invite recruiter', MyFeedModal = 'my feed modal', ArticleAnonymousCTA = 'article anonymous cta', EnableNotifications = 'enable notifications', @@ -438,6 +439,8 @@ export enum TargetId { OpportunityUnavailablePage = 'opportunity unavailable page', OpportunityWelcomePage = 'opportunity welcome page', ProfileSettingsMenu = 'profile settings menu', + ThreadPage = 'thread page', + ThreadPopup = 'thread popup', } export enum NotificationChannel { diff --git a/packages/shared/src/lib/query.ts b/packages/shared/src/lib/query.ts index 942a07d53e..fba961231a 100644 --- a/packages/shared/src/lib/query.ts +++ b/packages/shared/src/lib/query.ts @@ -217,6 +217,7 @@ export enum RequestKey { Opportunity = 'opportunity', UserCandidatePreferences = 'user_candidate_preferences', KeywordAutocomplete = 'keyword_autocomplete', + InviteRecruiter = 'invite_recruiter', } export const getPostByIdKey = (id: string): QueryKey => [RequestKey.Post, id]; diff --git a/packages/webapp/pages/refer-a-recruiter.tsx b/packages/webapp/pages/refer-a-recruiter.tsx new file mode 100644 index 0000000000..4040a3181f --- /dev/null +++ b/packages/webapp/pages/refer-a-recruiter.tsx @@ -0,0 +1,275 @@ +import type { ReactElement } from 'react'; +import React from 'react'; + +import type { NextSeoProps } from 'next-seo'; +import { + Divider, + FlexCol, + FlexRow, +} from '@dailydotdev/shared/src/components/utilities'; +import { + Typography, + TypographyColor, + TypographyType, +} from '@dailydotdev/shared/src/components/typography/Typography'; + +import { + recruiterSpamCampaign, + recruiterSpamCampaignSEO, +} from '@dailydotdev/shared/src/lib/image'; +import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext'; + +import { Image } from '@dailydotdev/shared/src/components/image/Image'; +import { + AppIcon, + ClickIcon, + CoreIcon, + DownloadIcon, + MailIcon, + TimerIcon, +} from '@dailydotdev/shared/src/components/icons'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import { anchorDefaultRel } from '@dailydotdev/shared/src/lib/strings'; +import { defaultSeo } from '../next-seo'; +import { getLayout } from '../components/layouts/NoSidebarLayout'; +import ProtectedPage from '../components/ProtectedPage'; +import { BackgroundImage } from './opportunity/welcome'; + +const seo: NextSeoProps = { + title: 'daily.dev | Refer Recruiters - earn Cores', + openGraph: { images: [{ url: recruiterSpamCampaignSEO }] }, + ...defaultSeo, + nofollow: true, + noindex: true, +}; + +const HeaderSection = (): ReactElement => { + return ( + + + Refer a Recruiter +
Earn Cores 💰 +
+ + Convert Recruiter Spam to Cores + + + + Every day, developers like you get bombarded by cold recruiter + messages that are irrelevant, pushy, or just plain AI-generated. + + + At daily.dev, we want to flip the script. For every + recruiter you refer, you can now earn 1,000 Cores. + They can be used to unlock features, boost your content, or simply get + rewarded for staying awesome. + + +
+ ); +}; + +const howItWorksItems = [ + { + icon: DownloadIcon, + number: 0, + title: 'Install the daily.dev extension', + description: ( + <> + Make sure you have the daily.dev browser extension installed.  + + Get it here + + . + + ), + }, + { + icon: AppIcon, + number: 1, + title: 'Enable the companion', + description: ( + <> + Enable the daily.dev companion to unlock recruiter referral features on + LinkedIn.  + + Check out our docs + +   If you've previously enabled it, you're good to go! + + ), + }, + { + icon: MailIcon, + number: 2, + title: 'Check your LinkedIn DMs', + description: + 'Got a cold message from a recruiter? Perfect - that’s your ticket.', + }, + { + icon: ClickIcon, + number: 3, + title: 'Click our button to generate a response', + description: + 'Click our button in the message to generate a pre-filled response containing your unique referral link.', + }, + { + icon: TimerIcon, + number: 4, + title: 'Wait for the recruiter to check out daily.dev Recruiter', + description: + 'When the recruiter clicks your link and checks out daily.dev Recruiter, we log the referral.', + }, + { + icon: CoreIcon, + number: 5, + title: 'Get your free Cores', + description: ( + <> + Once the recruiter has been referred, you'll receive Cores directly + in your account. +
We review applications every 14 days. + + ), + }, +]; +const HowItWorksSection = (): ReactElement => { + return ( + + + How it works 💡 + + + {howItWorksItems.map(({ icon: Icon, number, title, description }) => ( +
+ + + {number} + + + + + {title} + + + {description}{' '} + + + +
+ ))} +
+ + + + Simple. Legit. Developer-first. + + +
+ ); +}; + +const finePrint = [ + { + description: + 'To ensure quality, all submissions are manually reviewed (once every 14 days).', + }, + { + description: 'Only cold recruiter messages via LinkedIn are accepted.', + }, + { + description: 'Messages must be received within the last 3 months', + }, + { + description: + 'For now, we are only able to accept entries from developers based in the US and Europe', + }, + { + description: + 'Multiple entries are allowed (up to 10) and a maximum of 10,000 Cores can be earned per user.', + }, + { + description: + 'Developers found attempting to game the system or submit fraudulent content will be banned from future campaigns.', + }, +]; +const FinePrintSection = (): ReactElement => ( + + + Fine print 🛡️ + +
+
    + {finePrint.map(({ description }) => ( +
  • + + {description} + +
  • + ))} +
+
+
+); + +const RecruiterSpamPage = (): ReactElement => { + const { isAuthReady } = useAuthContext(); + + if (!isAuthReady) { + return ; + } + + return ( + + +
+ + + + + + + +
+
+ ); +}; + +RecruiterSpamPage.getLayout = getLayout; +RecruiterSpamPage.layoutProps = { screenCentered: true, seo }; + +export default RecruiterSpamPage;