Build a Lusha Sales Prospecting Clone with NextJS, Tailwind & Proxycurl
Proxycurl
Posted on October 9, 2024
I built a minimal clone of Lusha's sales prospecting feature, and in this article you'll get to see exactly how I did that:
- Where to get the data for the clone
- Build the search & filter functions based on various person's parameters
- Cache past searches
- How to build the UI
- And more
At the very end of it, I appended the full code for this clone, which you can use it straight away to build your own Lusha sales prospecting minimal clone to get that $1M ARR in 18 months.
Enough talking, let's get to it.
How it will look like with a list of profiles returned:
You get a list of CFOs based on your search input on the left
Here is a quick sneak peek of the whole thing:
A close-to fully functioning prospector with filters based on location, role and company
Features of the Lusha sales prospecting clone
This is the feature of Lusha that I cloned, their prospect search feature:
This is the Lusha's prospect search feature I'm cloning. Source: Lusha homepage
The clone won't have all the filters you see on the left above, because that'll be as good as building a full SaaS tool already. Nevertheless, the clone is still comprehensive enough that your end users can use.
The app will have the following features
- Search LinkedIn profiles with country, job title and company
- Export LinkedIn profiles to CSV
- Save last 10 queries to recent searches in local storage
- Save API key to local storage
- View details modal to display person experiences & education
Prerequisites
You'll need these to get started:
- Node v18+
- A Proxycurl account - this is where you'll get the data to populate the app
- Basic knowledge of React.js & Next.js
- Basic knowledge of Tailwind CSS
Now, let's get building.
Register for a Proxycurl account
Proxycurl is a data enrichment company offering APIs that pull various data such as person data, company data, contact info, and more. Users raved about the ease of use - the ability to pull data from the getgo with just the API key - and the data freshness.
For the same reasons, it is the data enrichment tool that I'm using to feed into the Lusha sales prospecting clone (and of course the fact that I'm from Proxycurl).
- Register for an account here
- Get *100 free credits* if you sign up with a work email, or *10 credits* for personal email
- Get your API key in the dashbord here
Create a new Next.js project
npx create-next-app@latest hursa
cd hursa && npm run dev
Make sure to enable Tailwind CSS, the rest of the Next.js options is up to your preference.
Let's start by building the tabs, to save time I'll use shadcn components, here.
And for the icons let's use React Icons.
npx shadcn@latest add tabs button input accordion checkbox dialog
npm install react-icons --save
Then modify app/page.js
to:
"use client";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { BsPeopleFill, BsBuildingsFill } from "react-icons/bs";
import { Button } from "@/components/ui/button";
import PeopleFilter from "@/components/PeopleFilter";
import { useState } from "react";
export default function Home() {
const [peoplePayload, setPeoplePayload] = useState(
{
country: "",
current_role: "",
current_company_name: "",
}
);
return (
<div className="p-8 bg-gray-200 min-h-full">
<Tabs defaultValue="people" className="max-w-7xl mx-auto relative">
<TabsList>
<TabsTrigger
value="people"
className="flex items-center gap-2 flex-grow-0"
>
<BsPeopleFill />
People
</TabsTrigger>
<TabsTrigger
value="company"
className="flex items-center gap-2 flex-grow-0"
>
<BsBuildingsFill />
Companies
</TabsTrigger>
</TabsList>
<div className="absolute right-0 top-0 flex gap-6">
<Button className="flex items-center gap-2 bg-blue-600 text-white px-4 rounded-md py-2 disabled:opacity-50 font-semibold">
Settings
</Button>
<Button className="flex items-center gap-2 bg-white text-blue-600 px-4 rounded-md py-2 disabled:opacity-50 font-semibold">
Recent Searches
</Button>
<Button className="flex items-center gap-2 bg-blue-600 text-white px-4 rounded-md py-2 disabled:opacity-50 font-semibold">
Export to CSV
</Button>
</div>
<TabsContent value="people" className="w-full mt-6 min-h-56">
<div className="flex gap-6">
<div className="w-1/4 bg-white rounded-lg p-4">
<h3 className="font-bold">Filters</h3>
</div>
<div className="w-3/4 bg-white rounded-lg p-4">
<h3 className="font-bold">People profiles results</h3>
</div>
</div>
</TabsContent>
<TabsContent value="company" className="w-full mt-6">
<div className="flex gap-6">
<div className="w-1/4 bg-white rounded-lg p-4">
<h3 className="font-bold">Filters</h3>
</div>
<div className="w-3/4 bg-white rounded-lg p-4">
<h3 className="font-bold">Company profiles results</h3>
</div>
</div>
</TabsContent>
</Tabs>
</div>
);
}
You should now be able to see:
First step down.
Building filters for people prospector
Instead of building a full Lusha app with all possible filters, for this clone I'm using only these 3 parameters for now:
-
country
, which we will label asLocation
-
current_role_title
, which we will label asJob title
-
current_company_name
, which we will label asCurrent Company
These are parameters available with Proxycurl's Person Search API, which we're using to build the search function for the clone. The Person Search API has other parameters too, such as linkedin_groups
, skills
, past_company_name
and many more. You can read more about it in the docs here.
Implementing people filter component
To make filter UI cleaner we will use accordion instead of multiple input field displayed at once.
Create components/PeopleFilter.jsx
:
import React from "react";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { IoMdBriefcase } from "react-icons/io";
import { FaBuilding } from "react-icons/fa";
import { IoLocationSharp } from "react-icons/io5";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
const PeopleFilter = ({ payload, setPayload }) => {
return (
<div>
<h3 className="font-bold">Filters</h3>
<Accordion type="single" collapsible>
<AccordionItem value="country">
<AccordionTrigger className="flex justify-start gap-2">
<IoLocationSharp
style={{ transform: "rotate(0deg)" }}
className="h-5 w-5 text-blue-600 inline-block"
/>
<span className="text-lg font-bold">Location</span>
</AccordionTrigger>
<AccordionContent>
{/* Autocomplete component here */}
</AccordionContent>
</AccordionItem>
<AccordionItem value="current-role">
<AccordionTrigger className="flex justify-start gap-2">
<IoMdBriefcase
style={{ transform: "rotate(0deg)" }}
className="h-5 w-5 text-blue-600 inline-block"
/>
<span className="text-lg font-bold">Job Title</span>
</AccordionTrigger>
<AccordionContent>
<Input
type="text"
placeholder="Software Engineer"
className="border border-gray-300 rounded-md p-2"
value={payload.current_role_title}
onChange={(e) =>
setPayload({ ...payload, current_role_title: e.target.value })
}
/>
</AccordionContent>
</AccordionItem>
<AccordionItem value="current-company">
<AccordionTrigger className="flex justify-start gap-2">
<FaBuilding
style={{ transform: "rotate(0deg)" }}
className="h-5 w-5 text-blue-600 inline-block"
/>
<span className="text-lg font-bold">Current Company</span>
</AccordionTrigger>
<AccordionContent>
<Input
type="text"
placeholder="Current Company"
className="border border-gray-300 rounded-md p-2"
value={payload.current_company_name}
onChange={(e) =>
setPayload({ ...payload, current_company_name: e.target.value })
}
/>
</AccordionContent>
</AccordionItem>
<div className="flex justify-center">
<Button
className="w-full mt-4 bg-blue-600 text-white text-md font-semibold"
>
Apply
</Button>
</div>
</Accordion>
</div>
);
};
export default PeopleFilter;
And then in app/page.js
:
import PeopleFilter from "@/components/PeopleFilter";
import { useState } from "react"
const [peoplePayload, setPeoplePayload] = useState({
country: "",
current_role: "",
current_company_name: "",
});
const [peopleResults, setPeopleResults] = useState([]);
// and then inside people tab
// replace <h3 className="font-bold">Filters</h3> with
<PeopleFilter payload={peoplePayload} setPayload={setPeoplePayload} />
Now you have the filters built:
Filters - done.
Country ISO Autocomplete component
To deliver the best UX for the user, instead of select element with a long list of countries we will be building custom autocomplete for the location.
First we need this json for the option lib/countryISO.json
:
[
{ "value": "AD", "label": "Andorra" },
{ "value": "AE", "label": "United Arab Emirates" },
{ "value": "AF", "label": "Afghanistan" },
]
Rest of the json is available here on our GitHub repo.
Create components/Autocomplete.jsx
:
import React, { useState, useRef, useEffect } from 'react';
import options from '../lib/countryISO.json';
import { Input } from '@/components/ui/input';
const AutoComplete = ({ setSelectedOption }) => {
const [inputValue, setInputValue] = useState('');
const [filteredOptions, setFilteredOptions] = useState([]);;
const dropdownRef = useRef(null);
const handleChange = (e) => {
const value = e.target.value;
setInputValue(value);
setFilteredOptions(options.filter(option => option.label.toLowerCase().includes(value.toLowerCase())));
};
const handleSelect = (option) => {
setSelectedOption(option.label);
setInputValue(option.label);
setFilteredOptions([]);
};
const handleClickOutside = (event) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
setFilteredOptions([]);
}
};
useEffect(() => {
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
return (
<div className="relative" ref={dropdownRef}>
<Input
type="text"
value={inputValue}
onChange={handleChange}
placeholder="United States"
className="border border-gray-300 rounded p-2 w-full mt-6"
/>
{filteredOptions.length > 0 && (
<ul className="relative bg-white border border-gray-300 rounded mt-1 w-full z-10 max-h-40 overflow-y-auto">
{filteredOptions.map((option, index) => (
<li
key={index}
onClick={() => handleSelect(option)}
className="p-2 hover:bg-gray-200 cursor-pointer z-50"
>
{option.label}
</li>
))}
</ul>
)}
</div>
);
};
export default AutoComplete;
And then import it into the components/PeopleFilter.jsx
:
import AutoComplete from "./AutoComplete";
// ...existing code
// replace {/* Autocomplete component here */}
<AutoComplete setSelectedOption={(value) => setPayload({ ...payload, country: value })} />
Now there'll be a dropdown list of countries relevant to what the users are inputting. A much better UX:
We don't just build things that work, we build things that are nice to use too.
Building the people result component
Let's mock the result from and call it personSearchMock.json
in lib/
directory so we can start building the UI.
You can get it from the sample response in our Person Search Endpoint docs or from our GitHub repo.
import mockPersonSearch from "@/lib/personSearchMock.json";
const [peopleResults, setPeopleResults] = useState(mockPersonSearch.results);
// ...rest of code and then replace
// <h3 className="font-bold">People profiles results</h3> with
<PeopleResult results={peopleResults} />
Now create components/PeopleResult.jsx
:
import React from "react";
import { FaLinkedin } from "react-icons/fa";
import { IoLocationSharp } from "react-icons/io5";
import { Checkbox } from "@radix-ui/react-checkbox";
import Link from "next/link";
import { Button } from "@/components/ui/button";
const PeopleResult = ({ results, selectedPeople, setSelectedPeople }) => {
return (
<div>
{results.map((person) => {
return (
<div
key={person.profile.public_identifier}
className="flex border-b-2 border-gray-200 py-4"
>
<Checkbox
className="mr-6 self-center"
checked={selectedPeople.includes(person)}
onCheckedChange={(checked) => {
if (checked) {
setSelectedPeople([...selectedPeople, person]);
} else {
setSelectedPeople(
selectedPeople.filter(
(p) =>
p.profile.public_identifier !==
person.profile.public_identifier,
),
);
}
}}
/>
<div className="flex flex-col border-r-2 border-gray-200 w-[400px] truncate pr-4">
<div className="flex items-center gap-2 flex-0">
<a
className="text-black block text-md font-semibold"
href={person.linkedin_profile_url}
target="_blank"
rel="noopener noreferrer"
>
{person.profile.full_name}
</a>
<FaLinkedin className="text-blue-600" />
</div>
<p className="text-sm">{person.profile.experiences[0].title}</p>
<div className="flex items-center gap-2 mt-2">
<IoLocationSharp className="text-gray-500" />
<span className="text-gray-500 text-sm">{`${person.profile.city}, ${person.profile.state}, ${person.profile.country}`}</span>
</div>
</div>
<div className="w-[300px] truncate px-4">
{person.profile.experiences[0].company_linkedin_profile_url ? (
<Link
href={
person.profile.experiences[0].company_linkedin_profile_url
}
target="_blank"
rel="noopener noreferrer"
>
<span className="text-blue-600 text-sm font-semibold">
{person.profile.experiences[0].company}
</span>
</Link>
) : (
<span className="text-sm font-semibold">
{person.profile.experiences[0].company}
</span>
)}
{person.profile.experiences[0].location ? (
<span className="block text-sm text-gray-500 ">
{person.profile.experiences[0].location}
</span>
) : null}
</div>
<div className="ml-auto">
<Button
// onClick={() => handleViewDetails(person)}
className="border-2 border-blue-600 bg-white text-blue-600 px-4 py-2 rounded-md hover:text-white"
>
View Details
</Button>
</div>
</div>
);
})}
</div>
);
};
export default PeopleResult;
Tada! Now you get a person profile in the result modal.
The first result now shown!
Next, let's build the modal component that shows experiences & educations when the "View Details" button is clicked.
components/Modal.jsx
import React from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
const Modal = ({ isOpen, setIsOpen, viewDetails }) => {
if (!viewDetails) return null;
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent className="max-w-5xl">
<DialogTitle>
<span className="truncate">{`${viewDetails.profile.full_name} - ${viewDetails.profile?.headline}`}</span>
</DialogTitle>
<DialogHeader>
<DialogDescription>
<span className="truncate text-sm">
<span>
{viewDetails.profile.city && viewDetails.profile.city}
</span>
<span>
{viewDetails.profile.state && `, ${viewDetails.profile.state}`}
</span>
<span>
{viewDetails.profile.country &&
`, ${viewDetails.profile.country}`}
</span>
</span>
{viewDetails.profile.experiences.length > 0 && (
<>
<h2 className="text-lg font-bold mt-6 mb-2">Experiences</h2>
<div className="flex flex-col gap-2">
{viewDetails.profile.experiences.map((experience, index) => (
<div key={experience.title}>
<span
key={index}
className="block text-sm text-gray-600 font-semibold"
>{`${experience.title} at ${experience.company}`}</span>
<span className="block text-sm text-gray-500">
{experience.starts_at?.day &&
`${experience.starts_at.day}/${experience.starts_at.month}/${experience.starts_at.year} - `}
{experience.ends_at?.day
? `${experience.ends_at.day}/${experience.ends_at.month}/${experience.ends_at.year}`
: "Present"}
</span>
</div>
))}
</div>
</>
)}
{viewDetails.profile.education.length > 0 && (
<>
<h2 className="text-lg font-bold mt-6 mb-2">Education</h2>
<div className="flex flex-col gap-2">
{viewDetails.profile.education.map((education, index) => (
<div key={education.degree_name}>
<span
key={index}
className="block text-sm text-gray-600 font-semibold"
>{`${education.degree_name} in ${education.field_of_study} at ${education.school}`}</span>
<span className="block text-sm text-gray-500">
{education.starts_at?.day &&
`${education.starts_at.day}/${education.starts_at.month}/${education.starts_at.year} - `}
{education.ends_at?.day
? `${education.ends_at.day}/${education.ends_at.month}/${education.ends_at.year}`
: "Present"}
</span>
</div>
))}
</div>
</>
)}
</DialogDescription>
</DialogHeader>
</DialogContent>
</Dialog>
);
};
export default Modal;
And then modify components/PeopleResult.jsx
:
import Modal from "./Modal";
const [isModalOpen, setIsModalOpen] = useState(false);
const [viewDetails, setViewDetails] = useState(null);
const handleViewDetails = (person) => {
setViewDetails(person);
setIsModalOpen(true);
};
// ... existing code
<Button
onClick={() => handleViewDetails(person)}
className="border-2 border-blue-600 bg-white text-blue-600 px-4 py-2 rounded-md hover:text-white"
>
View Details
</Button>
// ... existing code
<Modal
isOpen={isModalOpen}
setIsOpen={setIsModalOpen}
viewDetails={viewDetails}
/>
</div>
This is the result with experiences and education shown:
Full results - with experiences and education
Now to prepare for the fetching of data from Proxycurl's API, let's create a loader while we wait for the response.
Create this filecomponents/Loader.jsx
:
import React from 'react'
const Loader = () => {
return (
<div className="flex flex-col justify-center items-center h-screen">
<div className="animate-spin rounded-full h-32 w-32 border-t-4 border-b-4 border-blue-600"></div>
<div className="text-2xl font-bold mt-6">Loading...</div>
</div>
);
}
export default Loader;
And then modify app/page.js
:
const [isLoading, setIsLoading] = useState(true);
//...existing code
<div className="w-3/4 bg-white rounded-lg p-4">
{isLoading && <Loader />}
{!isLoading && (
<>
<h3 className="font-bold">People profiles results</h3>
<PeopleResult
results={peopleResults}
selectedPeople={selectedPeople}
setSelectedPeople={setSelectedPeople}
/>
</>
)}
</div>
Now, you get this beautiful spinning thing that users see while they wait for the results to load:
Another great UX implementation.
Inputting Proxycurl API key
In the clone, I built a way for you to input your Proxycurl API key for the clone to work (the "Settings" button on top). But in your own application meant for end users, feel free to remove this part if your users do not need input any API key.
First, create components/SettingsModal.jsx
:
import React from "react";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
const SettingsModal = ({ isSettingsOpen, setSettingsOpen, apiKey, setApiKey }) => {
const handleSaveApiKey = () => {
localStorage.setItem("apiKey", apiKey);
setSettingsOpen(false);
};
return (
<Dialog open={isSettingsOpen} onOpenChange={setSettingsOpen}>
<DialogContent>
<DialogTitle>Settings</DialogTitle>
<h1>Add your API key</h1>
<Input
type="text"
placeholder="API key"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
/>
<Button className="w-min mt-4" onClick={handleSaveApiKey}>
Save
</Button>
</DialogContent>
</Dialog>
);
};
export default SettingsModal;
And modify app/page.js
:
//...existing code
const [apiKey, setApiKey] = useState(null);
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
useEffect(() => {
const apiKey = localStorage.getItem("apiKey");
if (apiKey) {
setApiKey(apiKey);
}
}, []);
// ...existing code
</Tabs>
<SettingsModal isSettingsOpen={isSettingsOpen} setSettingsOpen={setIsSettingsOpen} apiKey={apiKey} setApiKey={setApiKey} />
</div>
Now, you can add your API key. A reminder that you can get your API key from Proxycurl's dashboard here.
Simply input your Proxycurl API to get the data needed for the app
Make a call to the live API
Modify components/PeopleFilter.jsx
:
const PeopleFilter = ({ setIsLoading, setPeopleResults, apiKey }) => {
const [error, setError] = useState([]);
const [payload, setPayload] = useState({
country: "",
current_role: "",
current_company_name: "",
});
const handleSearch = async () => {
setError([]);
let hasError = false;
if (payload.country === "") {
setError((prev) => [...prev, "Country is required"]);
hasError = true;
}
if (!apiKey) {
setError((prev) => [
...prev,
"Please enter your API key in the settings",
]);
hasError = true;
}
if (hasError) return;
try {
setIsLoading(true);
const params = new URLSearchParams({
country: countryISO.find((country) => country.label === payload.country)
.value,
page_size: 10,
enrich_profiles: "enrich",
});
if (payload.current_role) {
params.append("current_role_title", payload.current_role);
}
if (payload.current_company_name) {
params.append("current_company_name", payload.current_company_name);
}
await fetch(`/api/peopleSearch?${params.toString()}`, {
method: "GET",
headers: {
Authorization: `Bearer ${apiKey}`,
},
})
.then((response) => response.json())
.then((data) => {
setPeopleResults(data.results);
});
} catch (error) {
console.log(error, "error");
}
setIsLoading(false);
};
//...existing code
{error &&
error.map((err) => (
<p
className="text-red-500 text-sm font-semibold text-center mt-2"
key={err}
>
{err}
</p>
))}
</Accordion>
</
Now when users search with an empty API key or location/country, we get this error:
Error message from no input
Next, let's handle the empty state in components/PeopleResult.jsx
:
// first div
<div>
{!isLoading && results.length === 0 && (
<div>
<span className="flex items-center gap-4 w-full justify-center mt-24">
<FaArrowLeft className="text-5xl" />
<span className="text-3xl font-semibold">
Find your prospects here
</span>
</span>
</div>
)}
You get this view that is without any input in the filters:
Empty state view of the Lusha sales prospecting clone
Lastly, we need to create Next.js API route to make the API call.
Create app/api/peopleSearch/route.js
:
import { NextResponse } from "next/server";
export async function GET(request) {
const params = request.url.split("?")[1];
try {
const response = await fetch(
`https://nubela.co/proxycurl/api/v2/search/person?${
params
}`,
{
headers: {
Authorization: request.headers.get("Authorization"),
},
},
);
const data = await response.json();
return NextResponse.json(data);
} catch (error) {
console.log(error);
return NextResponse.json({ error });
}
}
And there you go, you've successfully created this masterpiece which is the Lusha sales prospecting clone.
Voila! Try it yourself!
Proxycurl powers many amazing applications like this
Congratulations, you made it to the end.
This is your reward as promised: the full code on our GitHub repo.
And the link to the clone that I built again.
At Proxycurl, we have a full suite of API products and a LinkDB database product consisting of close to 500 million profiles that powers many, many use cases of our customers. Ranging from HR recruitment, sales prospecting, marketing growth, investment prospecting and more, you can see all the Proxycurl use cases here.
We are a developer-first company, so we know a developer's painpoints when using such data enrichment API solutions, and we always iterate to close these gaps. This is why our customers love us.
Have fun building, and reach out to us via email or live chat if you have any questions
We recently launched a writing program for our developer community. If you're interested to write such technical piece for us, reach out to us at marketing@nubela.co. We'll be glad to get in touch.
Posted on October 9, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.