This commit is contained in:
2025-06-04 14:44:37 +02:00
commit e0ee3498c7
119 changed files with 20681 additions and 0 deletions

View File

@@ -0,0 +1,200 @@
import { Button } from '@/components/button'
import { Container } from '@/components/container'
import { Footer } from '@/components/footer'
import { GradientBackground } from '@/components/gradient'
import { Link } from '@/components/link'
import { Navbar } from '@/components/navbar'
import { Heading, Subheading } from '@/components/text'
import { image } from '@/sanity/image'
import { getPost } from '@/sanity/queries'
import { ChevronLeftIcon } from '@heroicons/react/16/solid'
import dayjs from 'dayjs'
import type { Metadata } from 'next'
import { PortableText } from 'next-sanity'
import { notFound } from 'next/navigation'
export async function generateMetadata({
params,
}: {
params: { slug: string }
}): Promise<Metadata> {
let post = await getPost(params.slug)
return post ? { title: post.title, description: post.excerpt } : {}
}
export default async function BlogPost({
params,
}: {
params: { slug: string }
}) {
let post = (await getPost(params.slug)) || notFound()
return (
<main className="overflow-hidden">
<GradientBackground />
<Container>
<Navbar />
<Subheading className="mt-16">
{dayjs(post.publishedAt).format('dddd, MMMM D, YYYY')}
</Subheading>
<Heading as="h1" className="mt-2">
{post.title}
</Heading>
<div className="mt-16 grid grid-cols-1 gap-8 pb-24 lg:grid-cols-[15rem_1fr] xl:grid-cols-[15rem_1fr_15rem]">
<div className="flex flex-wrap items-center gap-8 max-lg:justify-between lg:flex-col lg:items-start">
{post.author && (
<div className="flex items-center gap-3">
{post.author.image && (
<img
alt=""
src={image(post.author.image).size(64, 64).url()}
className="aspect-square size-6 rounded-full object-cover"
/>
)}
<div className="text-sm/5 text-gray-700">
{post.author.name}
</div>
</div>
)}
{Array.isArray(post.categories) && (
<div className="flex flex-wrap gap-2">
{post.categories.map((category) => (
<Link
key={category.slug}
href={`/blog?category=${category.slug}`}
className="rounded-full border border-dotted border-gray-300 bg-gray-50 px-2 text-sm/6 font-medium text-gray-500"
>
{category.title}
</Link>
))}
</div>
)}
</div>
<div className="text-gray-700">
<div className="max-w-2xl xl:mx-auto">
{post.mainImage && (
<img
alt={post.mainImage.alt || ''}
src={image(post.mainImage).size(2016, 1344).url()}
className="mb-10 aspect-3/2 w-full rounded-2xl object-cover shadow-xl"
/>
)}
{post.body && (
<PortableText
value={post.body}
components={{
block: {
normal: ({ children }) => (
<p className="my-10 text-base/8 first:mt-0 last:mb-0">
{children}
</p>
),
h2: ({ children }) => (
<h2 className="mt-12 mb-10 text-2xl/8 font-medium tracking-tight text-gray-950 first:mt-0 last:mb-0">
{children}
</h2>
),
h3: ({ children }) => (
<h3 className="mt-12 mb-10 text-xl/8 font-medium tracking-tight text-gray-950 first:mt-0 last:mb-0">
{children}
</h3>
),
blockquote: ({ children }) => (
<blockquote className="my-10 border-l-2 border-l-gray-300 pl-6 text-base/8 text-gray-950 first:mt-0 last:mb-0">
{children}
</blockquote>
),
},
types: {
image: ({ value }) => (
<img
alt={value.alt || ''}
src={image(value).width(2000).url()}
className="w-full rounded-2xl"
/>
),
separator: ({ value }) => {
switch (value.style) {
case 'line':
return (
<hr className="my-8 border-t border-gray-200" />
)
case 'space':
return <div className="my-8" />
default:
return null
}
},
},
list: {
bullet: ({ children }) => (
<ul className="list-disc pl-4 text-base/8 marker:text-gray-400">
{children}
</ul>
),
number: ({ children }) => (
<ol className="list-decimal pl-4 text-base/8 marker:text-gray-400">
{children}
</ol>
),
},
listItem: {
bullet: ({ children }) => {
return (
<li className="my-2 pl-2 has-[br]:mb-8">
{children}
</li>
)
},
number: ({ children }) => {
return (
<li className="my-2 pl-2 has-[br]:mb-8">
{children}
</li>
)
},
},
marks: {
strong: ({ children }) => (
<strong className="font-semibold text-gray-950">
{children}
</strong>
),
code: ({ children }) => (
<>
<span aria-hidden>`</span>
<code className="text-[15px]/8 font-semibold text-gray-950">
{children}
</code>
<span aria-hidden>`</span>
</>
),
link: ({ value, children }) => {
return (
<Link
href={value.href}
className="font-medium text-gray-950 underline decoration-gray-400 underline-offset-4 data-hover:decoration-gray-600"
>
{children}
</Link>
)
},
},
}}
/>
)}
<div className="mt-10">
<Button variant="outline" href="/blog">
<ChevronLeftIcon className="size-4" />
Back to blog
</Button>
</div>
</div>
</div>
</div>
</Container>
<Footer />
</main>
)
}

View File

@@ -0,0 +1,65 @@
import { image } from '@/sanity/image'
import { getPostsForFeed } from '@/sanity/queries'
import { Feed } from 'feed'
import assert from 'node:assert'
export async function GET(req: Request) {
let siteUrl = new URL(req.url).origin
let feed = new Feed({
title: 'The Radiant Blog',
description:
'Stay informed with product updates, company news, and insights on how to sell smarter at your company.',
author: {
name: 'Michael Foster',
email: 'michael.foster@example.com',
},
id: siteUrl,
link: siteUrl,
image: `${siteUrl}/favicon.ico`,
favicon: `${siteUrl}/favicon.ico`,
copyright: `All rights reserved ${new Date().getFullYear()}`,
feedLinks: {
rss2: `${siteUrl}/feed.xml`,
},
})
let posts = await getPostsForFeed()
posts.forEach((post) => {
try {
assert(typeof post.title === 'string')
assert(typeof post.slug === 'string')
assert(typeof post.excerpt === 'string')
assert(typeof post.publishedAt === 'string')
} catch (error) {
console.log('Post is missing required fields for RSS feed:', post)
return
}
feed.addItem({
title: post.title,
id: post.slug,
link: `${siteUrl}/blog/${post.slug}`,
content: post.excerpt,
image: post.mainImage
? image(post.mainImage)
.size(1200, 800)
.format('jpg')
.url()
.replaceAll('&', '&amp;')
: undefined,
author: post.author?.name ? [{ name: post.author.name }] : [],
contributor: post.author?.name ? [{ name: post.author.name }] : [],
date: new Date(post.publishedAt),
})
})
return new Response(feed.rss2(), {
status: 200,
headers: {
'content-type': 'application/xml',
'cache-control': 's-maxage=31556952',
},
})
}

310
src/app/blog/page.tsx Normal file
View File

@@ -0,0 +1,310 @@
import { Button } from '@/components/button'
import { Container } from '@/components/container'
import { Footer } from '@/components/footer'
import { GradientBackground } from '@/components/gradient'
import { Link } from '@/components/link'
import { Navbar } from '@/components/navbar'
import { Heading, Lead, Subheading } from '@/components/text'
import { image } from '@/sanity/image'
import {
getCategories,
getFeaturedPosts,
getPosts,
getPostsCount,
} from '@/sanity/queries'
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react'
import {
CheckIcon,
ChevronLeftIcon,
ChevronRightIcon,
ChevronUpDownIcon,
RssIcon,
} from '@heroicons/react/16/solid'
import { clsx } from 'clsx'
import dayjs from 'dayjs'
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
export const metadata: Metadata = {
title: 'Blog',
description:
'Stay informed with product updates, company news, and insights on how to sell smarter at your company.',
}
const postsPerPage = 5
async function FeaturedPosts() {
let featuredPosts = await getFeaturedPosts(3)
if (featuredPosts.length === 0) {
return
}
return (
<div className="mt-16 bg-linear-to-t from-gray-100 pb-14">
<Container>
<h2 className="text-2xl font-medium tracking-tight">Featured</h2>
<div className="mt-6 grid grid-cols-1 gap-8 lg:grid-cols-3">
{featuredPosts.map((post) => (
<div
key={post.slug}
className="relative flex flex-col rounded-3xl bg-white p-2 shadow-md ring-1 shadow-black/5 ring-black/5"
>
{post.mainImage && (
<img
alt={post.mainImage.alt || ''}
src={image(post.mainImage).size(1170, 780).url()}
className="aspect-3/2 w-full rounded-2xl object-cover"
/>
)}
<div className="flex flex-1 flex-col p-8">
<div className="text-sm/5 text-gray-700">
{dayjs(post.publishedAt).format('dddd, MMMM D, YYYY')}
</div>
<div className="mt-2 text-base/7 font-medium">
<Link href={`/blog/${post.slug}`}>
<span className="absolute inset-0" />
{post.title}
</Link>
</div>
<div className="mt-2 flex-1 text-sm/6 text-gray-500">
{post.excerpt}
</div>
{post.author && (
<div className="mt-6 flex items-center gap-3">
{post.author.image && (
<img
alt=""
src={image(post.author.image).size(64, 64).url()}
className="aspect-square size-6 rounded-full object-cover"
/>
)}
<div className="text-sm/5 text-gray-700">
{post.author.name}
</div>
</div>
)}
</div>
</div>
))}
</div>
</Container>
</div>
)
}
async function Categories({ selected }: { selected?: string }) {
let categories = await getCategories()
if (categories.length === 0) {
return
}
return (
<div className="flex flex-wrap items-center justify-between gap-2">
<Menu>
<MenuButton className="flex items-center justify-between gap-2 font-medium">
{categories.find(({ slug }) => slug === selected)?.title ||
'All categories'}
<ChevronUpDownIcon className="size-4 fill-gray-900" />
</MenuButton>
<MenuItems
anchor="bottom start"
className="min-w-40 rounded-lg bg-white p-1 shadow-lg ring-1 ring-gray-200 [--anchor-gap:6px] [--anchor-offset:-4px] [--anchor-padding:10px]"
>
<MenuItem>
<Link
href="/blog"
data-selected={selected === undefined ? true : undefined}
className="group grid grid-cols-[1rem_1fr] items-center gap-2 rounded-md px-2 py-1 data-focus:bg-gray-950/5"
>
<CheckIcon className="hidden size-4 group-data-selected:block" />
<p className="col-start-2 text-sm/6">All categories</p>
</Link>
</MenuItem>
{categories.map((category) => (
<MenuItem key={category.slug}>
<Link
href={`/blog?category=${category.slug}`}
data-selected={category.slug === selected ? true : undefined}
className="group grid grid-cols-[16px_1fr] items-center gap-2 rounded-md px-2 py-1 data-focus:bg-gray-950/5"
>
<CheckIcon className="hidden size-4 group-data-selected:block" />
<p className="col-start-2 text-sm/6">{category.title}</p>
</Link>
</MenuItem>
))}
</MenuItems>
</Menu>
<Button variant="outline" href="/blog/feed.xml" className="gap-1">
<RssIcon className="size-4" />
RSS Feed
</Button>
</div>
)
}
async function Posts({ page, category }: { page: number; category?: string }) {
let posts = await getPosts(
(page - 1) * postsPerPage,
page * postsPerPage,
category,
)
if (posts.length === 0 && (page > 1 || category)) {
notFound()
}
if (posts.length === 0) {
return <p className="mt-6 text-gray-500">No posts found.</p>
}
return (
<div className="mt-6">
{posts.map((post) => (
<div
key={post.slug}
className="relative grid grid-cols-1 border-b border-b-gray-100 py-10 first:border-t first:border-t-gray-200 max-sm:gap-3 sm:grid-cols-3"
>
<div>
<div className="text-sm/5 max-sm:text-gray-700 sm:font-medium">
{dayjs(post.publishedAt).format('dddd, MMMM D, YYYY')}
</div>
{post.author && (
<div className="mt-2.5 flex items-center gap-3">
{post.author.image && (
<img
alt=""
src={image(post.author.image).width(64).height(64).url()}
className="aspect-square size-6 rounded-full object-cover"
/>
)}
<div className="text-sm/5 text-gray-700">
{post.author.name}
</div>
</div>
)}
</div>
<div className="sm:col-span-2 sm:max-w-2xl">
<h2 className="text-sm/5 font-medium">{post.title}</h2>
<p className="mt-3 text-sm/6 text-gray-500">{post.excerpt}</p>
<div className="mt-4">
<Link
href={`/blog/${post.slug}`}
className="flex items-center gap-1 text-sm/5 font-medium"
>
<span className="absolute inset-0" />
Read more
<ChevronRightIcon className="size-4 fill-gray-400" />
</Link>
</div>
</div>
</div>
))}
</div>
)
}
async function Pagination({
page,
category,
}: {
page: number
category?: string
}) {
function url(page: number) {
let params = new URLSearchParams()
if (category) params.set('category', category)
if (page > 1) params.set('page', page.toString())
return params.size !== 0 ? `/blog?${params.toString()}` : '/blog'
}
let totalPosts = await getPostsCount(category)
let hasPreviousPage = page - 1
let previousPageUrl = hasPreviousPage ? url(page - 1) : undefined
let hasNextPage = page * postsPerPage < totalPosts
let nextPageUrl = hasNextPage ? url(page + 1) : undefined
let pageCount = Math.ceil(totalPosts / postsPerPage)
if (pageCount < 2) {
return
}
return (
<div className="mt-6 flex items-center justify-between gap-2">
<Button
variant="outline"
href={previousPageUrl}
disabled={!previousPageUrl}
>
<ChevronLeftIcon className="size-4" />
Previous
</Button>
<div className="flex gap-2 max-sm:hidden">
{Array.from({ length: pageCount }, (_, i) => (
<Link
key={i + 1}
href={url(i + 1)}
data-active={i + 1 === page ? true : undefined}
className={clsx(
'size-7 rounded-lg text-center text-sm/7 font-medium',
'data-hover:bg-gray-100',
'data-active:shadow-sm data-active:ring-1 data-active:ring-black/10',
'data-active:data-hover:bg-gray-50',
)}
>
{i + 1}
</Link>
))}
</div>
<Button variant="outline" href={nextPageUrl} disabled={!nextPageUrl}>
Next
<ChevronRightIcon className="size-4" />
</Button>
</div>
)
}
export default async function Blog({
searchParams,
}: {
searchParams: { [key: string]: string | string[] | undefined }
}) {
let page =
'page' in searchParams
? typeof searchParams.page === 'string' && parseInt(searchParams.page) > 1
? parseInt(searchParams.page)
: notFound()
: 1
let category =
typeof searchParams.category === 'string'
? searchParams.category
: undefined
return (
<main className="overflow-hidden">
<GradientBackground />
<Container>
<Navbar />
<Subheading className="mt-16">Blog</Subheading>
<Heading as="h1" className="mt-2">
Whats happening at Radiant.
</Heading>
<Lead className="mt-6 max-w-3xl">
Stay informed with product updates, company news, and insights on how
to sell smarter at your company.
</Lead>
</Container>
{page === 1 && !category && <FeaturedPosts />}
<Container className="mt-16 pb-24">
<Categories selected={category} />
<Posts page={page} category={category} />
<Pagination page={page} category={category} />
</Container>
<Footer />
</main>
)
}

473
src/app/company/page.tsx Normal file
View File

@@ -0,0 +1,473 @@
import { AnimatedNumber } from '@/components/animated-number'
import { Button } from '@/components/button'
import { Container } from '@/components/container'
import { Footer } from '@/components/footer'
import { GradientBackground } from '@/components/gradient'
import { Navbar } from '@/components/navbar'
import { Heading, Lead, Subheading } from '@/components/text'
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Company',
description:
'Were on a mission to transform revenue organizations by harnessing vast amounts of illegally acquired customer data.',
}
function Header() {
return (
<Container className="mt-16">
<Heading as="h1">Helping companies generate revenue.</Heading>
<Lead className="mt-6 max-w-3xl">
Were on a mission to transform revenue organizations by harnessing vast
amounts of illegally acquired customer data.
</Lead>
<section className="mt-16 grid grid-cols-1 lg:grid-cols-2 lg:gap-12">
<div className="max-w-lg">
<h2 className="text-2xl font-medium tracking-tight">Our mission</h2>
<p className="mt-6 text-sm/6 text-gray-600">
At Radiant, we are dedicated to transforming the way revenue
organizations source and close deals. Our mission is to provide our
customers with an unfair advantage over both their competitors and
potential customers through insight and analysis. Well stop at
nothing to get you the data you need to close a deal.
</p>
<p className="mt-8 text-sm/6 text-gray-600">
Were customer-obsessed putting the time in to build a detailed
financial picture of every one of our customers so that we know more
about your business than you do. We are in this together, mostly
because we are all implicated in large-scale financial crime. In our
history as a company, weve never lost a customer, because if any
one of us talks, we all go down.
</p>
</div>
<div className="pt-20 lg:row-span-2 lg:-mr-16 xl:mr-auto">
<div className="-mx-8 grid grid-cols-2 gap-4 sm:-mx-16 sm:grid-cols-4 lg:mx-0 lg:grid-cols-2 lg:gap-4 xl:gap-8">
<div className="aspect-square overflow-hidden rounded-xl shadow-xl outline-1 -outline-offset-1 outline-black/10">
<img
alt=""
src="/company/1.jpg"
className="block size-full object-cover"
/>
</div>
<div className="-mt-8 aspect-square overflow-hidden rounded-xl shadow-xl outline-1 -outline-offset-1 outline-black/10 lg:-mt-32">
<img
alt=""
src="/company/2.jpg"
className="block size-full object-cover"
/>
</div>
<div className="aspect-square overflow-hidden rounded-xl shadow-xl outline-1 -outline-offset-1 outline-black/10">
<img
alt=""
src="/company/3.jpg"
className="block size-full object-cover"
/>
</div>
<div className="-mt-8 aspect-square overflow-hidden rounded-xl shadow-xl outline-1 -outline-offset-1 outline-black/10 lg:-mt-32">
<img
alt=""
src="/company/4.jpg"
className="block size-full object-cover"
/>
</div>
</div>
</div>
<div className="max-lg:mt-16 lg:col-span-1">
<Subheading>The Numbers</Subheading>
<hr className="mt-6 border-t border-gray-200" />
<dl className="mt-6 grid grid-cols-1 gap-x-8 gap-y-4 sm:grid-cols-2">
<div className="flex flex-col gap-y-2 border-b border-dotted border-gray-200 pb-4">
<dt className="text-sm/6 text-gray-600">Raised</dt>
<dd className="order-first text-6xl font-medium tracking-tight">
$<AnimatedNumber start={100} end={150} />M
</dd>
</div>
<div className="flex flex-col gap-y-2 border-b border-dotted border-gray-200 pb-4">
<dt className="text-sm/6 text-gray-600">Companies</dt>
<dd className="order-first text-6xl font-medium tracking-tight">
<AnimatedNumber start={15} end={30} />K
</dd>
</div>
<div className="flex flex-col gap-y-2 max-sm:border-b max-sm:border-dotted max-sm:border-gray-200 max-sm:pb-4">
<dt className="text-sm/6 text-gray-600">Deals Closed</dt>
<dd className="order-first text-6xl font-medium tracking-tight">
<AnimatedNumber start={0.9} end={1.5} decimals={1} />M
</dd>
</div>
<div className="flex flex-col gap-y-2">
<dt className="text-sm/6 text-gray-600">Leads Generated</dt>
<dd className="order-first text-6xl font-medium tracking-tight">
<AnimatedNumber start={150} end={200} />M
</dd>
</div>
</dl>
</div>
</section>
</Container>
)
}
function Person({
name,
description,
img,
}: {
name: string
description: string
img: string
}) {
return (
<li className="flex items-center gap-4">
<img alt="" src={img} className="size-12 rounded-full" />
<div className="text-sm/6">
<h3 className="font-medium">{name}</h3>
<p className="text-gray-500">{description}</p>
</div>
</li>
)
}
function Team() {
return (
<Container className="mt-32">
<Subheading>Meet the team</Subheading>
<Heading as="h3" className="mt-2">
Founded by an all-star team.
</Heading>
<Lead className="mt-6 max-w-3xl">
Radiant is founded by two of the best sellers in the business and backed
by investors who look the other way.
</Lead>
<div className="mt-12 grid grid-cols-1 gap-12 lg:grid-cols-2">
<div className="max-w-lg">
<p className="text-sm/6 text-gray-600">
Years ago, while working as sales associates at rival companies,
Thomas, Ben, and Natalie were discussing a big client they had all
been competing for. Joking about seeing the terms of each others
offers, they had an idea: what if they shared data to win deals and
split the commission behind their companies backs? It turned out to
be an incredible success, and that idea became the kernel for
Radiant.
</p>
<p className="mt-8 text-sm/6 text-gray-600">
Today, Radiant transforms revenue organizations by harnessing
illegally acquired customer and competitor data, using it to provide
extraordinary leverage. More than 30,000 companies rely on Radiant
to undercut their competitors and extort their customers, all
through a single integrated platform.
</p>
<div className="mt-6">
<Button className="w-full sm:w-auto" href="#">
Join us
</Button>
</div>
</div>
<div className="max-lg:order-first max-lg:max-w-lg">
<div className="aspect-3/2 overflow-hidden rounded-xl shadow-xl outline-1 -outline-offset-1 outline-black/10">
<img
alt=""
src="/company/5.jpg"
className="block size-full object-cover"
/>
</div>
</div>
</div>
<Subheading as="h3" className="mt-24">
The team
</Subheading>
<hr className="mt-6 border-t border-gray-200" />
<ul
role="list"
className="mx-auto mt-16 grid grid-cols-1 gap-8 sm:grid-cols-2 lg:grid-cols-3"
>
<Person
name="Michael Foster"
description="Co-Founder / CTO"
img="/team/michael-foster.jpg"
/>
<Person
name="Dries Vincent"
description="Business Relations"
img="/team/dries-vincent.jpg"
/>
<Person
name="Celeste Vandermark"
description="Front-end Developer"
img="/team/celeste-vandermark.jpg"
/>
<Person
name="Courtney Henry"
description="Designer"
img="/team/courtney-henry.jpg"
/>
<Person
name="Marcus Eldridge"
description="Director of Product"
img="/team/marcus-eldridge.jpg"
/>
<Person
name="Whitney Francis"
description="Copywriter"
img="/team/whitney-francis.jpg"
/>
<Person
name="Leonard Krasner"
description="Senior Designer"
img="/team/leonard-krasner.jpg"
/>
<Person
name="Nolan Sheffield"
description="Principal Designer"
img="/team/nolan-sheffield.jpg"
/>
<Person
name="Emily Selman"
description="VP, User Experience"
img="/team/emily-selman.jpg"
/>
</ul>
</Container>
)
}
function Investors() {
return (
<Container className="mt-32">
<Subheading>Investors</Subheading>
<Heading as="h3" className="mt-2">
Funded by industry-leaders.
</Heading>
<Lead className="mt-6 max-w-3xl">
We are fortunate to be backed by the best investors in the industry
both literal and metaphorical partners in crime.
</Lead>
<Subheading as="h3" className="mt-24">
Venture Capital
</Subheading>
<hr className="mt-6 border-t border-gray-200" />
<ul
role="list"
className="mx-auto mt-10 grid grid-cols-1 gap-8 lg:grid-cols-2"
>
<li>
<img
alt="Remington Schwartz"
src="/investors/remington-schwartz.svg"
className="h-14"
/>
<p className="mt-6 max-w-lg text-sm/6 text-gray-500">
Remington Schwartz has been a driving force in the tech industry,
backing bold entrepreneurs who explore grey areas in financial and
privacy law. Their deep industry expertise and extensive political
lobbying provide their portfolio companies with favorable regulation
and direct access to lawmakers.
</p>
</li>
<li>
<img alt="Deccel" src="/investors/deccel.svg" className="h-14" />
<p className="mt-6 max-w-lg text-sm/6 text-gray-500">
Deccel has been at the forefront of innovation, investing in
pioneering companies across various sectors, including technology,
consumer goods, and healthcare. Their philosophy of plausible
deniability and dedication to looking the other way have helped
produce some of the worlds most controversial companies.
</p>
</li>
</ul>
<Subheading as="h3" className="mt-24">
Individual investors
</Subheading>
<hr className="mt-6 border-t border-gray-200" />
<ul
role="list"
className="mx-auto mt-16 grid grid-cols-1 gap-8 sm:grid-cols-2 lg:grid-cols-3"
>
<Person
name="Kristin Watson"
description="TechNexus Ventures"
img="/individual-investors/kristin-watson.jpg"
/>
<Person
name="Emma Dorsey"
description="Innovate Capital Partners"
img="/individual-investors/emma-dorsey.jpg"
/>
<Person
name="Alicia Bell"
description="FutureWave Investments"
img="/individual-investors/alicia-bell.jpg"
/>
<Person
name="Jenny Wilson"
description="SynergyTech Equity"
img="/individual-investors/jenny-wilson.jpg"
/>
<Person
name="Anna Roberts"
description="NextGen Horizons"
img="/individual-investors/anna-roberts.jpg"
/>
<Person
name="Benjamin Russel"
description="Pioneer Digital Ventures"
img="/individual-investors/benjamin-russel.jpg"
/>
</ul>
</Container>
)
}
function Testimonial() {
return (
<div className="relative flex aspect-square flex-col justify-end overflow-hidden rounded-3xl sm:aspect-5/4 lg:aspect-3/4">
<img
alt=""
src="/testimonials/veronica-winton.jpg"
className="absolute inset-0 object-cover"
/>
<div
aria-hidden="true"
className="absolute inset-0 rounded-3xl bg-linear-to-t from-black from-10% to-75% ring-1 ring-gray-950/10 ring-inset lg:from-25%"
/>
<figure className="relative p-10">
<blockquote>
<p className="relative text-xl/7 text-white before:absolute before:-translate-x-full before:content-['“'] after:absolute after:content-['”']">
We&apos;ve managed to put two of our main competitors out of
business in 6 months.
</p>
</blockquote>
<figcaption className="mt-6 border-t border-white/20 pt-6">
<p className="text-sm/6 font-medium text-white">Veronica Winton</p>
<p className="text-sm/6 font-medium">
<span className="bg-linear-to-r from-[#fff1be] from-28% via-[#ee87cb] via-70% to-[#b060ff] bg-clip-text text-transparent">
CSO, Planeteria
</span>
</p>
</figcaption>
</figure>
</div>
)
}
function Careers() {
return (
<Container className="my-32">
<Subheading>Careers</Subheading>
<Heading as="h3" className="mt-2">
Join our fully remote team.
</Heading>
<Lead className="mt-6 max-w-3xl">
We work together from all over the world, mainly from locations without
extradition agreements.
</Lead>
<div className="mt-24 grid grid-cols-1 gap-16 lg:grid-cols-[1fr_24rem]">
<div className="lg:max-w-2xl">
<Subheading as="h3">Open positions</Subheading>
<div>
<table className="w-full text-left">
<colgroup>
<col className="w-2/3" />
<col className="w-1/3" />
<col className="w-0" />
</colgroup>
<thead className="sr-only">
<tr>
<th scope="col">Title</th>
<th scope="col">Location</th>
<th scope="col">Read more</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="colgroup" colSpan={3} className="px-0 pt-10 pb-0">
<div className="-mx-4 rounded-lg bg-gray-50 px-4 py-3 text-sm/6 font-semibold">
Engineering
</div>
</th>
</tr>
<tr className="border-b border-dotted border-gray-200 text-sm/6 font-normal">
<td className="px-0 py-4">iOS Developer</td>
<td className="px-0 py-4 text-gray-600">Remote</td>
<td className="px-0 py-4 text-right">
<Button variant="outline" href="#">
View listing
</Button>
</td>
</tr>
<tr className="border-b border-dotted border-gray-200 text-sm/6 font-normal">
<td className="px-0 py-4">Backend Engineer</td>
<td className="px-0 py-4 text-gray-600">Remote</td>
<td className="px-0 py-4 text-right">
<Button variant="outline" href="#">
View listing
</Button>
</td>
</tr>
<tr className="text-sm/6 font-normal">
<td className="px-0 py-4">Product Engineer</td>
<td className="px-0 py-4 text-gray-600">Remote</td>
<td className="px-0 py-4 text-right">
<Button variant="outline" href="#">
View listing
</Button>
</td>
</tr>
<tr>
<th scope="colgroup" colSpan={3} className="px-0 pt-5 pb-0">
<div className="-mx-4 rounded-lg bg-gray-50 px-4 py-3 text-sm/6 font-semibold">
Design
</div>
</th>
</tr>
<tr className="border-b border-dotted border-gray-200 text-sm/6 font-normal">
<td className="px-0 py-4">Principal Designer</td>
<td className="px-0 py-4 text-gray-600">Remote</td>
<td className="px-0 py-4 text-right">
<Button variant="outline" href="#">
View listing
</Button>
</td>
</tr>
<tr className="border-b border-dotted border-gray-200 text-sm/6 font-normal">
<td className="px-0 py-4">Designer</td>
<td className="px-0 py-4 text-gray-600">Remote</td>
<td className="px-0 py-4 text-right">
<Button variant="outline" href="#">
View listing
</Button>
</td>
</tr>
<tr className="text-sm/6 font-normal">
<td className="px-0 py-4">Senior Designer</td>
<td className="px-0 py-4 text-gray-600">Remote</td>
<td className="px-0 py-4 text-right">
<Button variant="outline" href="#">
View listing
</Button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<Testimonial />
</div>
</Container>
)
}
export default function Company() {
return (
<main className="overflow-hidden">
<GradientBackground />
<Container>
<Navbar />
</Container>
<Header />
<Team />
<Investors />
<Careers />
<Footer />
</main>
)
}

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

33
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,33 @@
import '@/styles/tailwind.css'
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: {
template: '%s - Radiant',
default: 'Radiant - Close every deal',
},
}
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
return (
<html lang="en">
<head>
<link
rel="stylesheet"
href="https://api.fontshare.com/css?f%5B%5D=switzer@400,500,600,700&amp;display=swap"
/>
<link
rel="alternate"
type="application/rss+xml"
title="The Radiant Blog"
href="/blog/feed.xml"
/>
</head>
<body className="text-gray-950 antialiased">{children}</body>
</html>
)
}

92
src/app/login/page.tsx Normal file
View File

@@ -0,0 +1,92 @@
import { Button } from '@/components/button'
import { GradientBackground } from '@/components/gradient'
import { Link } from '@/components/link'
import { Mark } from '@/components/logo'
import { Checkbox, Field, Input, Label } from '@headlessui/react'
import { CheckIcon } from '@heroicons/react/16/solid'
import { clsx } from 'clsx'
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Login',
description: 'Sign in to your account to continue.',
}
export default function Login() {
return (
<main className="overflow-hidden bg-gray-50">
<GradientBackground />
<div className="isolate flex min-h-dvh items-center justify-center p-6 lg:p-8">
<div className="w-full max-w-md rounded-xl bg-white shadow-md ring-1 ring-black/5">
<form action="#" method="POST" className="p-7 sm:p-11">
<div className="flex items-start">
<Link href="/" title="Home">
<Mark className="h-9 fill-black" />
</Link>
</div>
<h1 className="mt-8 text-base/6 font-medium">Welcome back!</h1>
<p className="mt-1 text-sm/5 text-gray-600">
Sign in to your account to continue.
</p>
<Field className="mt-8 space-y-3">
<Label className="text-sm/5 font-medium">Email</Label>
<Input
required
autoFocus
type="email"
name="email"
className={clsx(
'block w-full rounded-lg border border-transparent shadow-sm ring-1 ring-black/10',
'px-[calc(--spacing(2)-1px)] py-[calc(--spacing(1.5)-1px)] text-base/6 sm:text-sm/6',
'data-focus:outline-2 data-focus:-outline-offset-1 data-focus:outline-black',
)}
/>
</Field>
<Field className="mt-8 space-y-3">
<Label className="text-sm/5 font-medium">Password</Label>
<Input
required
type="password"
name="password"
className={clsx(
'block w-full rounded-lg border border-transparent shadow-sm ring-1 ring-black/10',
'px-[calc(--spacing(2)-1px)] py-[calc(--spacing(1.5)-1px)] text-base/6 sm:text-sm/6',
'data-focus:outline-2 data-focus:-outline-offset-1 data-focus:outline-black',
)}
/>
</Field>
<div className="mt-8 flex items-center justify-between text-sm/5">
<Field className="flex items-center gap-3">
<Checkbox
name="remember-me"
className={clsx(
'group block size-4 rounded-sm border border-transparent shadow-sm ring-1 ring-black/10',
'data-checked:bg-black data-checked:ring-black',
'data-focus:outline-2 data-focus:outline-offset-2 data-focus:outline-black',
)}
>
<CheckIcon className="fill-white opacity-0 group-data-checked:opacity-100" />
</Checkbox>
<Label>Remember me</Label>
</Field>
<Link href="#" className="font-medium hover:text-gray-600">
Forgot password?
</Link>
</div>
<div className="mt-8">
<Button type="submit" className="w-full">
Sign in
</Button>
</div>
</form>
<div className="m-1.5 rounded-lg bg-gray-50 py-4 text-center text-sm/5 ring-1 ring-black/5">
Not a member?{' '}
<Link href="#" className="font-medium hover:text-gray-600">
Create an account
</Link>
</div>
</div>
</div>
</main>
)
}

211
src/app/page.tsx Normal file
View File

@@ -0,0 +1,211 @@
import { BentoCard } from '@/components/bento-card'
import { Button } from '@/components/button'
import { Container } from '@/components/container'
import { Footer } from '@/components/footer'
import { Gradient } from '@/components/gradient'
import { Keyboard } from '@/components/keyboard'
import { Link } from '@/components/link'
import { LinkedAvatars } from '@/components/linked-avatars'
import { LogoCloud } from '@/components/logo-cloud'
import { LogoCluster } from '@/components/logo-cluster'
import { LogoTimeline } from '@/components/logo-timeline'
import { Map } from '@/components/map'
import { Navbar } from '@/components/navbar'
import { Screenshot } from '@/components/screenshot'
import { Testimonials } from '@/components/testimonials'
import { Heading, Subheading } from '@/components/text'
import { ChevronRightIcon } from '@heroicons/react/16/solid'
import type { Metadata } from 'next'
export const metadata: Metadata = {
description:
'Radiant helps you sell more by revealing sensitive information about your customers.',
}
function Hero() {
return (
<div className="relative">
<Gradient className="absolute inset-2 bottom-0 rounded-4xl ring-1 ring-black/5 ring-inset" />
<Container className="relative">
<Navbar
banner={
<Link
href="/blog/radiant-raises-100m-series-a-from-tailwind-ventures"
className="flex items-center gap-1 rounded-full bg-fuchsia-950/35 px-3 py-0.5 text-sm/6 font-medium text-white data-hover:bg-fuchsia-950/30"
>
Radiant raises $100M Series A from Tailwind Ventures
<ChevronRightIcon className="size-4" />
</Link>
}
/>
<div className="pt-16 pb-24 sm:pt-24 sm:pb-32 md:pt-32 md:pb-48">
<h1 className="font-display text-6xl/[0.9] font-medium tracking-tight text-balance text-gray-950 sm:text-8xl/[0.8] md:text-9xl/[0.8]">
Close every deal.
</h1>
<p className="mt-8 max-w-lg text-xl/7 font-medium text-gray-950/75 sm:text-2xl/8">
Radiant helps you sell more by revealing sensitive information about
your customers.
</p>
<div className="mt-12 flex flex-col gap-x-6 gap-y-4 sm:flex-row">
<Button href="#">Get started</Button>
<Button variant="secondary" href="/pricing">
See pricing
</Button>
</div>
</div>
</Container>
</div>
)
}
function FeatureSection() {
return (
<div className="overflow-hidden">
<Container className="pb-24">
<Heading as="h2" className="max-w-3xl">
A snapshot of your entire sales pipeline.
</Heading>
<Screenshot
width={1216}
height={768}
src="/screenshots/app.png"
className="mt-16 h-144 sm:h-auto sm:w-304"
/>
</Container>
</div>
)
}
function BentoSection() {
return (
<Container>
<Subheading>Sales</Subheading>
<Heading as="h3" className="mt-2 max-w-3xl">
Know more about your customers than they do.
</Heading>
<div className="mt-10 grid grid-cols-1 gap-4 sm:mt-16 lg:grid-cols-6 lg:grid-rows-2">
<BentoCard
eyebrow="Insight"
title="Get perfect clarity"
description="Radiant uses social engineering to build a detailed financial picture of your leads. Know their budget, compensation package, social security number, and more."
graphic={
<div className="h-80 bg-[url(/screenshots/profile.png)] bg-size-[1000px_560px] bg-position-[left_-109px_top_-112px] bg-no-repeat" />
}
fade={['bottom']}
className="max-lg:rounded-t-4xl lg:col-span-3 lg:rounded-tl-4xl"
/>
<BentoCard
eyebrow="Analysis"
title="Undercut your competitors"
description="With our advanced data mining, youll know which companies your leads are talking to and exactly how much theyre being charged."
graphic={
<div className="absolute inset-0 bg-[url(/screenshots/competitors.png)] bg-size-[1100px_650px] bg-position-[left_-38px_top_-73px] bg-no-repeat" />
}
fade={['bottom']}
className="lg:col-span-3 lg:rounded-tr-4xl"
/>
<BentoCard
eyebrow="Speed"
title="Built for power users"
description="Its never been faster to cold email your entire contact list using our streamlined keyboard shortcuts."
graphic={
<div className="flex size-full pt-10 pl-10">
<Keyboard highlighted={['LeftCommand', 'LeftShift', 'D']} />
</div>
}
className="lg:col-span-2 lg:rounded-bl-4xl"
/>
<BentoCard
eyebrow="Source"
title="Get the furthest reach"
description="Bypass those inconvenient privacy laws to source leads from the most unexpected places."
graphic={<LogoCluster />}
className="lg:col-span-2"
/>
<BentoCard
eyebrow="Limitless"
title="Sell globally"
description="Radiant helps you sell in locations currently under international embargo."
graphic={<Map />}
className="max-lg:rounded-b-4xl lg:col-span-2 lg:rounded-br-4xl"
/>
</div>
</Container>
)
}
function DarkBentoSection() {
return (
<div className="mx-2 mt-2 rounded-4xl bg-gray-900 py-32">
<Container>
<Subheading dark>Outreach</Subheading>
<Heading as="h3" dark className="mt-2 max-w-3xl">
Customer outreach has never been easier.
</Heading>
<div className="mt-10 grid grid-cols-1 gap-4 sm:mt-16 lg:grid-cols-6 lg:grid-rows-2">
<BentoCard
dark
eyebrow="Networking"
title="Sell at the speed of light"
description="Our RadiantAI chat assistants analyze the sentiment of your conversations in real time, ensuring you're always one step ahead."
graphic={
<div className="h-80 bg-[url(/screenshots/networking.png)] bg-size-[851px_344px] bg-no-repeat" />
}
fade={['top']}
className="max-lg:rounded-t-4xl lg:col-span-4 lg:rounded-tl-4xl"
/>
<BentoCard
dark
eyebrow="Integrations"
title="Meet leads where they are"
description="With thousands of integrations, no one will be able to escape your cold outreach."
graphic={<LogoTimeline />}
// `overflow-visible!` is needed to work around a Chrome bug that disables the mask on the graphic.
className="z-10 overflow-visible! lg:col-span-2 lg:rounded-tr-4xl"
/>
<BentoCard
dark
eyebrow="Meetings"
title="Smart call scheduling"
description="Automatically insert intro calls into your leads' calendars without their consent."
graphic={<LinkedAvatars />}
className="lg:col-span-2 lg:rounded-bl-4xl"
/>
<BentoCard
dark
eyebrow="Engagement"
title="Become a thought leader"
description="RadiantAI automatically writes LinkedIn posts that relate current events to B2B sales, helping you build a reputation as a thought leader."
graphic={
<div className="h-80 bg-[url(/screenshots/engagement.png)] bg-size-[851px_344px] bg-no-repeat" />
}
fade={['top']}
className="max-lg:rounded-b-4xl lg:col-span-4 lg:rounded-br-4xl"
/>
</div>
</Container>
</div>
)
}
export default function Home() {
return (
<div className="overflow-hidden">
<Hero />
<main>
<Container className="mt-10">
<LogoCloud />
</Container>
<div className="bg-linear-to-b from-white from-50% to-gray-100 py-32">
<FeatureSection />
<BentoSection />
</div>
<DarkBentoSection />
</main>
<Testimonials />
<Footer />
</div>
)
}

516
src/app/pricing/page.tsx Normal file
View File

@@ -0,0 +1,516 @@
import { Button } from '@/components/button'
import { Container } from '@/components/container'
import { Footer } from '@/components/footer'
import { Gradient, GradientBackground } from '@/components/gradient'
import { Link } from '@/components/link'
import { LogoCloud } from '@/components/logo-cloud'
import { Navbar } from '@/components/navbar'
import { Heading, Lead, Subheading } from '@/components/text'
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react'
import {
CheckIcon,
ChevronUpDownIcon,
MinusIcon,
} from '@heroicons/react/16/solid'
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Pricing',
description:
'Companies all over the world have closed millions of deals with Radiant. Sign up today and start selling smarter.',
}
const tiers = [
{
name: 'Starter' as const,
slug: 'starter',
description: 'Everything you need to start selling.',
priceMonthly: 99,
href: '#',
highlights: [
{ description: 'Up to 3 team members' },
{ description: 'Up to 5 deal progress boards' },
{ description: 'Source leads from select platforms' },
{ description: 'RadiantAI integrations', disabled: true },
{ description: 'Competitor analysis', disabled: true },
],
features: [
{ section: 'Features', name: 'Accounts', value: 3 },
{ section: 'Features', name: 'Deal progress boards', value: 5 },
{ section: 'Features', name: 'Sourcing platforms', value: 'Select' },
{ section: 'Features', name: 'Contacts', value: 100 },
{ section: 'Features', name: 'AI assisted outreach', value: false },
{ section: 'Analysis', name: 'Competitor analysis', value: false },
{ section: 'Analysis', name: 'Dashboard reporting', value: false },
{ section: 'Analysis', name: 'Community insights', value: false },
{ section: 'Analysis', name: 'Performance analysis', value: false },
{ section: 'Support', name: 'Email support', value: true },
{ section: 'Support', name: '24 / 7 call center support', value: false },
{ section: 'Support', name: 'Dedicated account manager', value: false },
],
},
{
name: 'Growth' as const,
slug: 'growth',
description: 'All the extras for your growing team.',
priceMonthly: 149,
href: '#',
highlights: [
{ description: 'Up to 10 team members' },
{ description: 'Unlimited deal progress boards' },
{ description: 'Source leads from over 50 verified platforms' },
{ description: 'RadiantAI integrations' },
{ description: '5 competitor analyses per month' },
],
features: [
{ section: 'Features', name: 'Accounts', value: 10 },
{ section: 'Features', name: 'Deal progress boards', value: 'Unlimited' },
{ section: 'Features', name: 'Sourcing platforms', value: '100+' },
{ section: 'Features', name: 'Contacts', value: 1000 },
{ section: 'Features', name: 'AI assisted outreach', value: true },
{ section: 'Analysis', name: 'Competitor analysis', value: '5 / month' },
{ section: 'Analysis', name: 'Dashboard reporting', value: true },
{ section: 'Analysis', name: 'Community insights', value: true },
{ section: 'Analysis', name: 'Performance analysis', value: true },
{ section: 'Support', name: 'Email support', value: true },
{ section: 'Support', name: '24 / 7 call center support', value: true },
{ section: 'Support', name: 'Dedicated account manager', value: false },
],
},
{
name: 'Enterprise' as const,
slug: 'enterprise',
description: 'Added flexibility to close deals at scale.',
priceMonthly: 299,
href: '#',
highlights: [
{ description: 'Unlimited active team members' },
{ description: 'Unlimited deal progress boards' },
{ description: 'Source leads from over 100 verified platforms' },
{ description: 'RadiantAI integrations' },
{ description: 'Unlimited competitor analyses' },
],
features: [
{ section: 'Features', name: 'Accounts', value: 'Unlimited' },
{ section: 'Features', name: 'Deal progress boards', value: 'Unlimited' },
{ section: 'Features', name: 'Sourcing platforms', value: '100+' },
{ section: 'Features', name: 'Contacts', value: 'Unlimited' },
{ section: 'Features', name: 'AI assisted outreach', value: true },
{ section: 'Analysis', name: 'Competitor analysis', value: 'Unlimited' },
{ section: 'Analysis', name: 'Dashboard reporting', value: true },
{ section: 'Analysis', name: 'Community insights', value: true },
{ section: 'Analysis', name: 'Performance analysis', value: true },
{ section: 'Support', name: 'Email support', value: true },
{ section: 'Support', name: '24 / 7 call center support', value: true },
{ section: 'Support', name: 'Dedicated account manager', value: true },
],
},
]
function Header() {
return (
<Container className="mt-16">
<Heading as="h1">Pricing that grows with your team size.</Heading>
<Lead className="mt-6 max-w-3xl">
Companies all over the world have closed millions of deals with Radiant.
Sign up today and start selling smarter.
</Lead>
</Container>
)
}
function PricingCards() {
return (
<div className="relative py-24">
<Gradient className="absolute inset-x-2 top-48 bottom-0 rounded-4xl ring-1 ring-black/5 ring-inset" />
<Container className="relative">
<div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
{tiers.map((tier, tierIndex) => (
<PricingCard key={tierIndex} tier={tier} />
))}
</div>
<LogoCloud className="mt-24" />
</Container>
</div>
)
}
function PricingCard({ tier }: { tier: (typeof tiers)[number] }) {
return (
<div className="-m-2 grid grid-cols-1 rounded-4xl shadow-[inset_0_0_2px_1px_#ffffff4d] ring-1 ring-black/5 max-lg:mx-auto max-lg:w-full max-lg:max-w-md">
<div className="grid grid-cols-1 rounded-4xl p-2 shadow-md shadow-black/5">
<div className="rounded-3xl bg-white p-10 pb-9 shadow-2xl ring-1 ring-black/5">
<Subheading>{tier.name}</Subheading>
<p className="mt-2 text-sm/6 text-gray-950/75">{tier.description}</p>
<div className="mt-8 flex items-center gap-4">
<div className="text-5xl font-medium text-gray-950">
${tier.priceMonthly}
</div>
<div className="text-sm/5 text-gray-950/75">
<p>USD</p>
<p>per month</p>
</div>
</div>
<div className="mt-8">
<Button href={tier.href}>Start a free trial</Button>
</div>
<div className="mt-8">
<h3 className="text-sm/6 font-medium text-gray-950">
Start selling with:
</h3>
<ul className="mt-3 space-y-3">
{tier.highlights.map((props, featureIndex) => (
<FeatureItem key={featureIndex} {...props} />
))}
</ul>
</div>
</div>
</div>
</div>
)
}
function PricingTable({
selectedTier,
}: {
selectedTier: (typeof tiers)[number]
}) {
return (
<Container className="py-24">
<table className="w-full text-left">
<caption className="sr-only">Pricing plan comparison</caption>
<colgroup>
<col className="w-3/5 sm:w-2/5" />
<col
data-selected={selectedTier === tiers[0] ? true : undefined}
className="w-2/5 data-selected:table-column max-sm:hidden sm:w-1/5"
/>
<col
data-selected={selectedTier === tiers[1] ? true : undefined}
className="w-2/5 data-selected:table-column max-sm:hidden sm:w-1/5"
/>
<col
data-selected={selectedTier === tiers[2] ? true : undefined}
className="w-2/5 data-selected:table-column max-sm:hidden sm:w-1/5"
/>
</colgroup>
<thead>
<tr className="max-sm:hidden">
<td className="p-0" />
{tiers.map((tier) => (
<th
key={tier.slug}
scope="col"
data-selected={selectedTier === tier ? true : undefined}
className="p-0 data-selected:table-cell max-sm:hidden"
>
<Subheading as="div">{tier.name}</Subheading>
</th>
))}
</tr>
<tr className="sm:hidden">
<td className="p-0">
<div className="relative inline-block">
<Menu>
<MenuButton className="flex items-center justify-between gap-2 font-medium">
{selectedTier.name}
<ChevronUpDownIcon className="size-4 fill-gray-900" />
</MenuButton>
<MenuItems
anchor="bottom start"
className="min-w-(--button-width) rounded-lg bg-white p-1 shadow-lg ring-1 ring-gray-200 [--anchor-gap:6px] [--anchor-offset:-4px] [--anchor-padding:10px]"
>
{tiers.map((tier) => (
<MenuItem key={tier.slug}>
<Link
scroll={false}
href={`/pricing?tier=${tier.slug}`}
data-selected={
tier === selectedTier ? true : undefined
}
className="group flex items-center gap-2 rounded-md px-2 py-1 data-focus:bg-gray-200"
>
{tier.name}
<CheckIcon className="hidden size-4 group-data-selected:block" />
</Link>
</MenuItem>
))}
</MenuItems>
</Menu>
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center">
<ChevronUpDownIcon className="size-4 fill-gray-900" />
</div>
</div>
</td>
<td colSpan={3} className="p-0 text-right">
<Button variant="outline" href={selectedTier.href}>
Get started
</Button>
</td>
</tr>
<tr className="max-sm:hidden">
<th className="p-0" scope="row">
<span className="sr-only">Get started</span>
</th>
{tiers.map((tier) => (
<td
key={tier.slug}
data-selected={selectedTier === tier ? true : undefined}
className="px-0 pt-4 pb-0 data-selected:table-cell max-sm:hidden"
>
<Button variant="outline" href={tier.href}>
Get started
</Button>
</td>
))}
</tr>
</thead>
{[...new Set(tiers[0].features.map(({ section }) => section))].map(
(section) => (
<tbody key={section} className="group">
<tr>
<th
scope="colgroup"
colSpan={4}
className="px-0 pt-10 pb-0 group-first-of-type:pt-5"
>
<div className="-mx-4 rounded-lg bg-gray-50 px-4 py-3 text-sm/6 font-semibold">
{section}
</div>
</th>
</tr>
{tiers[0].features
.filter((feature) => feature.section === section)
.map(({ name }) => (
<tr
key={name}
className="border-b border-gray-100 last:border-none"
>
<th
scope="row"
className="px-0 py-4 text-sm/6 font-normal text-gray-600"
>
{name}
</th>
{tiers.map((tier) => {
let value = tier.features.find(
(feature) =>
feature.section === section && feature.name === name,
)?.value
return (
<td
key={tier.slug}
data-selected={
selectedTier === tier ? true : undefined
}
className="p-4 data-selected:table-cell max-sm:hidden"
>
{value === true ? (
<>
<CheckIcon className="size-4 fill-green-600" />
<span className="sr-only">
Included in {tier.name}
</span>
</>
) : value === false || value === undefined ? (
<>
<MinusIcon className="size-4 fill-gray-400" />
<span className="sr-only">
Not included in {tier.name}
</span>
</>
) : (
<div className="text-sm/6">{value}</div>
)}
</td>
)
})}
</tr>
))}
</tbody>
),
)}
</table>
</Container>
)
}
function FeatureItem({
description,
disabled = false,
}: {
description: string
disabled?: boolean
}) {
return (
<li
data-disabled={disabled ? true : undefined}
className="flex items-start gap-4 text-sm/6 text-gray-950/75 data-disabled:text-gray-950/25"
>
<span className="inline-flex h-6 items-center">
<PlusIcon className="size-3.75 shrink-0 fill-gray-950/25" />
</span>
{disabled && <span className="sr-only">Not included:</span>}
{description}
</li>
)
}
function PlusIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
return (
<svg viewBox="0 0 15 15" aria-hidden="true" {...props}>
<path clipRule="evenodd" d="M8 0H7v7H0v1h7v7h1V8h7V7H8V0z" />
</svg>
)
}
function Testimonial() {
return (
<div className="mx-2 my-24 rounded-4xl bg-gray-900 bg-[url(/dot-texture.svg)] pt-72 pb-24 lg:pt-36">
<Container>
<div className="grid grid-cols-1 lg:grid-cols-[384px_1fr_1fr]">
<div className="-mt-96 lg:-mt-52">
<div className="-m-2 rounded-4xl bg-white/15 shadow-[inset_0_0_2px_1px_#ffffff4d] ring-1 ring-black/5 max-lg:mx-auto max-lg:max-w-xs">
<div className="rounded-4xl p-2 shadow-md shadow-black/5">
<div className="overflow-hidden rounded-3xl shadow-2xl outline outline-1 -outline-offset-1 outline-black/10">
<img
alt=""
src="/testimonials/tina-yards.jpg"
className="aspect-3/4 w-full object-cover"
/>
</div>
</div>
</div>
</div>
<div className="flex max-lg:mt-16 lg:col-span-2 lg:px-16">
<figure className="mx-auto flex max-w-xl flex-col gap-16 max-lg:text-center">
<blockquote>
<p className="relative text-3xl tracking-tight text-white before:absolute before:-translate-x-full before:content-['“'] after:absolute after:content-['”'] lg:text-4xl">
Thanks to Radiant, we&apos;re finding new leads that we never
would have found with legal methods.
</p>
</blockquote>
<figcaption className="mt-auto">
<p className="text-sm/6 font-medium text-white">Tina Yards</p>
<p className="text-sm/6 font-medium">
<span className="bg-linear-to-r from-[#fff1be] from-28% via-[#ee87cb] via-70% to-[#b060ff] bg-clip-text text-transparent">
VP of Sales, Protocol
</span>
</p>
</figcaption>
</figure>
</div>
</div>
</Container>
</div>
)
}
function FrequentlyAskedQuestions() {
return (
<Container>
<section id="faqs" className="scroll-mt-8">
<Subheading className="text-center">
Frequently asked questions
</Subheading>
<Heading as="div" className="mt-2 text-center">
Your questions answered.
</Heading>
<div className="mx-auto mt-16 mb-32 max-w-xl space-y-12">
<dl>
<dt className="text-sm font-semibold">
What measures are in place to ensure the security of our data?
</dt>
<dd className="mt-4 text-sm/6 text-gray-600">
Data security is a top priority for us, which is ironic given that
our business depends on others not taking it very seriously. We
understand that any breach could put both us and most of our
customers out of businessand behind bars. We employ robust
security measures, including data encryption, secure data centers,
and regular security audits to ensure this never happens.
</dd>
</dl>
<dl>
<dt className="text-sm font-semibold">
Is there a mobile app available for your platform?
</dt>
<dd className="mt-4 text-sm/6 text-gray-600">
Yes, we offer a mobile app that provides all the key
functionalities of our desktop platform, allowing sales reps to
manage deals on the go. Additionally, we have another app
pre-installed on most modern smartphones that allows us to track
your location, listen to your conversations, and access your
camera and microphone at any time. This app is not available for
download.
</dd>
</dl>
<dl>
<dt className="text-sm font-semibold">
Can I customize the workflow to match our companys deal process?
</dt>
<dd className="mt-4 text-sm/6 text-gray-600">
Yes, our platform is highly customizable, although there should be
no need. Before you sign up, we discreetly gather information
about your company and its processes from a variety of sources. We
then use this information to pre-configure the platform to match
your existing workflows. This is why we ask for your social
security number and access to your email account during the
sign-up process.
</dd>
</dl>
<dl>
<dt className="text-sm font-semibold">
What kind of support do you offer?
</dt>
<dd className="mt-4 text-sm/6 text-gray-600">
We offer comprehensive support through multiple channels,
including 24/7 live chat, email, and phone support. However, since
we have full access to your internal network, we will know if
youre having issues before you do.
</dd>
</dl>
<dl>
<dt className="text-sm font-semibold">
Can I integrate the CRM with other sales intelligence tools?
</dt>
<dd className="mt-4 text-sm/6 text-gray-600">
Yes, our solution integrates seamlessly with a variety of other
systems. However, be warned that most of these integrations are
short-lived. We have a dedicated team of engineers who
reverse-engineer the APIs of other tools, enabling us to build
their functionality into our product and eventually put them out
of business.
</dd>
</dl>
</div>
</section>
</Container>
)
}
export default function Pricing({
searchParams,
}: {
searchParams: { [key: string]: string | string[] | undefined }
}) {
let tier =
typeof searchParams.tier === 'string'
? tiers.find(({ slug }) => slug === searchParams.tier)!
: tiers[0]
return (
<main className="overflow-hidden">
<GradientBackground />
<Container>
<Navbar />
</Container>
<Header />
<PricingCards />
<PricingTable selectedTier={tier} />
<Testimonial />
<FrequentlyAskedQuestions />
<Footer />
</main>
)
}

View File

@@ -0,0 +1,19 @@
/**
* This route is responsible for the built-in authoring environment using Sanity Studio.
* All routes under your studio path is handled by this file using Next.js' catch-all routes:
* https://nextjs.org/docs/routing/dynamic-routes#catch-all-routes
*
* You can learn more about the next-sanity package here:
* https://github.com/sanity-io/next-sanity
*/
import { NextStudio } from 'next-sanity/studio'
import config from '../../../../sanity.config'
export const dynamic = 'force-static'
export { metadata, viewport } from 'next-sanity/studio'
export default function StudioPage() {
return <NextStudio config={config} />
}

View File

@@ -0,0 +1,33 @@
'use client'
import {
motion,
useInView,
useMotionValue,
useSpring,
useTransform,
} from 'framer-motion'
import { useEffect, useRef } from 'react'
export function AnimatedNumber({
start,
end,
decimals = 0,
}: {
start: number
end: number
decimals?: number
}) {
let ref = useRef(null)
let isInView = useInView(ref, { once: true, amount: 0.5 })
let value = useMotionValue(start)
let spring = useSpring(value, { damping: 30, stiffness: 100 })
let display = useTransform(spring, (num) => num.toFixed(decimals))
useEffect(() => {
value.set(isInView ? end : start)
}, [start, end, isInView, value])
return <motion.span ref={ref}>{display}</motion.span>
}

View File

@@ -0,0 +1,59 @@
'use client'
import { clsx } from 'clsx'
import { motion } from 'framer-motion'
import { Subheading } from './text'
export function BentoCard({
dark = false,
className = '',
eyebrow,
title,
description,
graphic,
fade = [],
}: {
dark?: boolean
className?: string
eyebrow: React.ReactNode
title: React.ReactNode
description: React.ReactNode
graphic: React.ReactNode
fade?: ('top' | 'bottom')[]
}) {
return (
<motion.div
initial="idle"
whileHover="active"
variants={{ idle: {}, active: {} }}
data-dark={dark ? 'true' : undefined}
className={clsx(
className,
'group relative flex flex-col overflow-hidden rounded-lg',
'bg-white shadow-xs ring-1 ring-black/5',
'data-dark:bg-gray-800 data-dark:ring-white/15',
)}
>
<div className="relative h-80 shrink-0">
{graphic}
{fade.includes('top') && (
<div className="absolute inset-0 bg-linear-to-b from-white to-50% group-data-dark:from-gray-800 group-data-dark:from-[-25%]" />
)}
{fade.includes('bottom') && (
<div className="absolute inset-0 bg-linear-to-t from-white to-50% group-data-dark:from-gray-800 group-data-dark:from-[-25%]" />
)}
</div>
<div className="relative p-10">
<Subheading as="h3" dark={dark}>
{eyebrow}
</Subheading>
<p className="mt-1 text-2xl/8 font-medium tracking-tight text-gray-950 group-data-dark:text-white">
{title}
</p>
<p className="mt-2 max-w-[600px] text-sm/6 text-gray-600 group-data-dark:text-gray-400">
{description}
</p>
</div>
</motion.div>
)
}

46
src/components/button.tsx Normal file
View File

@@ -0,0 +1,46 @@
import * as Headless from '@headlessui/react'
import { clsx } from 'clsx'
import { Link } from './link'
const variants = {
primary: clsx(
'inline-flex items-center justify-center px-4 py-[calc(--spacing(2)-1px)]',
'rounded-full border border-transparent bg-gray-950 shadow-md',
'text-base font-medium whitespace-nowrap text-white',
'data-disabled:bg-gray-950 data-disabled:opacity-40 data-hover:bg-gray-800',
),
secondary: clsx(
'relative inline-flex items-center justify-center px-4 py-[calc(--spacing(2)-1px)]',
'rounded-full border border-transparent bg-white/15 shadow-md ring-1 ring-[#D15052]/15',
'after:absolute after:inset-0 after:rounded-full after:shadow-[inset_0_0_2px_1px_#ffffff4d]',
'text-base font-medium whitespace-nowrap text-gray-950',
'data-disabled:bg-white/15 data-disabled:opacity-40 data-hover:bg-white/20',
),
outline: clsx(
'inline-flex items-center justify-center px-2 py-[calc(--spacing(1.5)-1px)]',
'rounded-lg border border-transparent shadow-sm ring-1 ring-black/10',
'text-sm font-medium whitespace-nowrap text-gray-950',
'data-disabled:bg-transparent data-disabled:opacity-40 data-hover:bg-gray-50',
),
}
type ButtonProps = {
variant?: keyof typeof variants
} & (
| React.ComponentPropsWithoutRef<typeof Link>
| (Headless.ButtonProps & { href?: undefined })
)
export function Button({
variant = 'primary',
className,
...props
}: ButtonProps) {
className = clsx(className, variants[variant])
if (typeof props.href === 'undefined') {
return <Headless.Button {...props} className={className} />
}
return <Link {...props} className={className} />
}

View File

@@ -0,0 +1,15 @@
import { clsx } from 'clsx'
export function Container({
className,
children,
}: {
className?: string
children: React.ReactNode
}) {
return (
<div className={clsx(className, 'px-6 lg:px-8')}>
<div className="mx-auto max-w-2xl lg:max-w-7xl">{children}</div>
</div>
)
}

193
src/components/footer.tsx Normal file
View File

@@ -0,0 +1,193 @@
import { PlusGrid, PlusGridItem, PlusGridRow } from '@/components/plus-grid'
import { Button } from './button'
import { Container } from './container'
import { Gradient } from './gradient'
import { Link } from './link'
import { Logo } from './logo'
import { Subheading } from './text'
function CallToAction() {
return (
<div className="relative pt-20 pb-16 text-center sm:py-24">
<hgroup>
<Subheading>Get started</Subheading>
<p className="mt-6 text-3xl font-medium tracking-tight text-gray-950 sm:text-5xl">
Ready to dive in?
<br />
Start your free trial today.
</p>
</hgroup>
<p className="mx-auto mt-6 max-w-xs text-sm/6 text-gray-500">
Get the cheat codes for selling and unlock your team&apos;s revenue
potential.
</p>
<div className="mt-6">
<Button className="w-full sm:w-auto" href="#">
Get started
</Button>
</div>
</div>
)
}
function SitemapHeading({ children }: { children: React.ReactNode }) {
return <h3 className="text-sm/6 font-medium text-gray-950/50">{children}</h3>
}
function SitemapLinks({ children }: { children: React.ReactNode }) {
return <ul className="mt-6 space-y-4 text-sm/6">{children}</ul>
}
function SitemapLink(props: React.ComponentPropsWithoutRef<typeof Link>) {
return (
<li>
<Link
{...props}
className="font-medium text-gray-950 data-hover:text-gray-950/75"
/>
</li>
)
}
function Sitemap() {
return (
<>
<div>
<SitemapHeading>Product</SitemapHeading>
<SitemapLinks>
<SitemapLink href="/pricing">Pricing</SitemapLink>
<SitemapLink href="#">Analysis</SitemapLink>
<SitemapLink href="#">API</SitemapLink>
</SitemapLinks>
</div>
<div>
<SitemapHeading>Company</SitemapHeading>
<SitemapLinks>
<SitemapLink href="#">Careers</SitemapLink>
<SitemapLink href="/blog">Blog</SitemapLink>
<SitemapLink href="/company">Company</SitemapLink>
</SitemapLinks>
</div>
<div>
<SitemapHeading>Support</SitemapHeading>
<SitemapLinks>
<SitemapLink href="#">Help center</SitemapLink>
<SitemapLink href="#">Community</SitemapLink>
</SitemapLinks>
</div>
<div>
<SitemapHeading>Company</SitemapHeading>
<SitemapLinks>
<SitemapLink href="#">Terms of service</SitemapLink>
<SitemapLink href="#">Privacy policy</SitemapLink>
</SitemapLinks>
</div>
</>
)
}
function SocialIconX(props: React.ComponentPropsWithoutRef<'svg'>) {
return (
<svg viewBox="0 0 16 16" fill="currentColor" {...props}>
<path d="M12.6 0h2.454l-5.36 6.778L16 16h-4.937l-3.867-5.594L2.771 16H.316l5.733-7.25L0 0h5.063l3.495 5.114L12.6 0zm-.86 14.376h1.36L4.323 1.539H2.865l8.875 12.837z" />
</svg>
)
}
function SocialIconFacebook(props: React.ComponentPropsWithoutRef<'svg'>) {
return (
<svg viewBox="0 0 16 16" fill="currentColor" {...props}>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M16 8.05C16 3.603 12.418 0 8 0S0 3.604 0 8.05c0 4.016 2.926 7.346 6.75 7.95v-5.624H4.718V8.05H6.75V6.276c0-2.017 1.194-3.131 3.022-3.131.875 0 1.79.157 1.79.157v1.98h-1.008c-.994 0-1.304.62-1.304 1.257v1.51h2.219l-.355 2.326H9.25V16c3.824-.604 6.75-3.934 6.75-7.95z"
/>
</svg>
)
}
function SocialIconLinkedIn(props: React.ComponentPropsWithoutRef<'svg'>) {
return (
<svg viewBox="0 0 16 16" fill="currentColor" {...props}>
<path d="M14.82 0H1.18A1.169 1.169 0 000 1.154v13.694A1.168 1.168 0 001.18 16h13.64A1.17 1.17 0 0016 14.845V1.15A1.171 1.171 0 0014.82 0zM4.744 13.64H2.369V5.996h2.375v7.644zm-1.18-8.684a1.377 1.377 0 11.52-.106 1.377 1.377 0 01-.527.103l.007.003zm10.075 8.683h-2.375V9.921c0-.885-.015-2.025-1.234-2.025-1.218 0-1.425.966-1.425 1.968v3.775H6.233V5.997H8.51v1.05h.032c.317-.601 1.09-1.235 2.246-1.235 2.405-.005 2.851 1.578 2.851 3.63v4.197z" />
</svg>
)
}
function SocialLinks() {
return (
<>
<Link
href="https://facebook.com"
target="_blank"
aria-label="Visit us on Facebook"
className="text-gray-950 data-hover:text-gray-950/75"
>
<SocialIconFacebook className="size-4" />
</Link>
<Link
href="https://x.com"
target="_blank"
aria-label="Visit us on X"
className="text-gray-950 data-hover:text-gray-950/75"
>
<SocialIconX className="size-4" />
</Link>
<Link
href="https://linkedin.com"
target="_blank"
aria-label="Visit us on LinkedIn"
className="text-gray-950 data-hover:text-gray-950/75"
>
<SocialIconLinkedIn className="size-4" />
</Link>
</>
)
}
function Copyright() {
return (
<div className="text-sm/6 text-gray-950">
&copy; {new Date().getFullYear()} Radiant Inc.
</div>
)
}
export function Footer() {
return (
<footer>
<Gradient className="relative">
<div className="absolute inset-2 rounded-4xl bg-white/80" />
<Container>
<CallToAction />
<PlusGrid className="pb-16">
<PlusGridRow>
<div className="grid grid-cols-2 gap-y-10 pb-6 lg:grid-cols-6 lg:gap-8">
<div className="col-span-2 flex">
<PlusGridItem className="pt-6 lg:pb-6">
<Logo className="h-9" />
</PlusGridItem>
</div>
<div className="col-span-2 grid grid-cols-2 gap-x-8 gap-y-12 lg:col-span-4 lg:grid-cols-subgrid lg:pt-6">
<Sitemap />
</div>
</div>
</PlusGridRow>
<PlusGridRow className="flex justify-between">
<div>
<PlusGridItem className="py-3">
<Copyright />
</PlusGridItem>
</div>
<div className="flex">
<PlusGridItem className="flex items-center gap-8 py-3">
<SocialLinks />
</PlusGridItem>
</div>
</PlusGridRow>
</PlusGrid>
</Container>
</Gradient>
</footer>
)
}

View File

@@ -0,0 +1,30 @@
import { clsx } from 'clsx'
export function Gradient({
className,
...props
}: React.ComponentPropsWithoutRef<'div'>) {
return (
<div
{...props}
className={clsx(
className,
'bg-linear-115 from-[#fff1be] from-28% via-[#ee87cb] via-70% to-[#b060ff] sm:bg-linear-145',
)}
/>
)
}
export function GradientBackground() {
return (
<div className="relative mx-auto max-w-7xl">
<div
className={clsx(
'absolute -top-44 -right-60 h-60 w-xl transform-gpu md:right-0',
'bg-linear-115 from-[#fff1be] from-28% via-[#ee87cb] via-70% to-[#b060ff]',
'rotate-[-10deg] rounded-full blur-3xl',
)}
/>
</div>
)
}

1050
src/components/keyboard.tsx Normal file

File diff suppressed because it is too large Load Diff

14
src/components/link.tsx Normal file
View File

@@ -0,0 +1,14 @@
import * as Headless from '@headlessui/react'
import NextLink, { type LinkProps } from 'next/link'
import { forwardRef } from 'react'
export const Link = forwardRef(function Link(
props: LinkProps & React.ComponentPropsWithoutRef<'a'>,
ref: React.ForwardedRef<HTMLAnchorElement>,
) {
return (
<Headless.DataInteractive>
<NextLink ref={ref} {...props} />
</Headless.DataInteractive>
)
})

View File

@@ -0,0 +1,93 @@
'use client'
import { CheckIcon } from '@heroicons/react/16/solid'
import { clsx } from 'clsx'
import { motion } from 'framer-motion'
const transition = {
duration: 0.75,
repeat: Infinity,
repeatDelay: 1.25,
}
function Rings() {
return (
<svg
viewBox="0 0 500 500"
fill="none"
className={clsx(
'col-start-1 row-start-1 size-full',
'mask-[linear-gradient(to_bottom,black_90%,transparent),radial-gradient(circle,rgba(0,0,0,1)_0%,rgba(0,0,0,0)_100%)] mask-intersect',
)}
>
{Array.from(Array(42).keys()).map((n) => (
<motion.circle
variants={{
idle: {
scale: 1,
strokeOpacity: 0.15,
},
active: {
scale: [1, 1.08, 1],
strokeOpacity: [0.15, 0.3, 0.15],
transition: { ...transition, delay: n * 0.05 },
},
}}
key={n}
cx="250"
cy="250"
r={n * 14 + 4}
className="stroke-white"
/>
))}
</svg>
)
}
function Checkmark() {
return (
<div className="z-10 col-start-1 row-start-1 flex items-center justify-center">
<motion.div
variants={{
idle: { scale: 1 },
active: {
scale: [1, 1.15, 1],
transition: { ...transition, duration: 0.75 },
},
}}
className="flex size-6 items-center justify-center rounded-full bg-linear-to-t from-green-500 to-green-300 shadow-sm"
>
<CheckIcon className="size-4 fill-white" />
</motion.div>
</div>
)
}
function Photos() {
return (
<div className="z-10 col-start-1 row-start-1">
<div className="mx-auto flex size-full max-w-md items-center justify-around">
<img
alt=""
src="/linked-avatars/customer.jpg"
className="size-20 rounded-full bg-white/15 ring-4 ring-white/10"
/>
<img
alt=""
src="/linked-avatars/manager.jpg"
className="size-20 rounded-full bg-white/15 ring-4 ring-white/10"
/>
</div>
</div>
)
}
export function LinkedAvatars() {
return (
<div aria-hidden="true" className="isolate mx-auto grid h-full grid-cols-1">
<Rings />
<Photos />
<Checkmark />
</div>
)
}

View File

@@ -0,0 +1,40 @@
import { clsx } from 'clsx'
export function LogoCloud({
className,
}: React.ComponentPropsWithoutRef<'div'>) {
return (
<div
className={clsx(
className,
'flex justify-between max-sm:mx-auto max-sm:max-w-md max-sm:flex-wrap max-sm:justify-evenly max-sm:gap-x-4 max-sm:gap-y-4',
)}
>
<img
alt="SavvyCal"
src="/logo-cloud/savvycal.svg"
className="h-9 max-sm:mx-auto sm:h-8 lg:h-12"
/>
<img
alt="Laravel"
src="/logo-cloud/laravel.svg"
className="h-9 max-sm:mx-auto sm:h-8 lg:h-12"
/>
<img
alt="Tuple"
src="/logo-cloud/tuple.svg"
className="h-9 max-sm:mx-auto sm:h-8 lg:h-12"
/>
<img
alt="Transistor"
src="/logo-cloud/transistor.svg"
className="h-9 max-sm:mx-auto sm:h-8 lg:h-12"
/>
<img
alt="Statamic"
src="/logo-cloud/statamic.svg"
className="h-9 max-sm:mx-auto sm:h-8 lg:h-12"
/>
</div>
)
}

View File

@@ -0,0 +1,143 @@
'use client'
import { clsx } from 'clsx'
import { motion } from 'framer-motion'
import { Mark } from './logo'
function Circle({
size,
delay,
opacity,
}: {
size: number
delay: number
opacity: string
}) {
return (
<motion.div
variants={{
idle: { width: `${size}px`, height: `${size}px` },
active: {
width: [`${size}px`, `${size + 10}px`, `${size}px`],
height: [`${size}px`, `${size + 10}px`, `${size}px`],
transition: {
duration: 0.75,
repeat: Infinity,
repeatDelay: 1.25,
ease: 'easeInOut',
delay,
},
},
}}
style={{ '--opacity': opacity } as React.CSSProperties}
className={clsx(
'absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full',
'bg-[radial-gradient(circle,transparent_25%,color-mix(in_srgb,var(--color-blue-500)_var(--opacity),transparent)_100%)]',
'ring-1 ring-blue-500/8 ring-inset',
)}
/>
)
}
function Circles() {
return (
<div className="absolute inset-0">
<Circle size={528} opacity="3%" delay={0.45} />
<Circle size={400} opacity="5%" delay={0.3} />
<Circle size={272} opacity="5%" delay={0.15} />
<Circle size={144} opacity="10%" delay={0} />
<div className="absolute inset-0 bg-linear-to-t from-white to-35%" />
</div>
)
}
function MainLogo() {
return (
<div className="absolute top-32 left-44 flex size-16 items-center justify-center rounded-full bg-white shadow-sm ring-1 ring-black/5">
<Mark className="h-9 fill-black" />
</div>
)
}
function Logo({
src,
left,
top,
hover,
}: {
src: string
left: number
top: number
hover: { x: number; y: number; rotate: number; delay: number }
}) {
return (
<motion.img
variants={{
idle: { x: 0, y: 0, rotate: 0 },
active: {
x: [0, hover.x, 0],
y: [0, hover.y, 0],
rotate: [0, hover.rotate, 0],
transition: {
duration: 0.75,
repeat: Infinity,
repeatDelay: 1.25,
ease: 'easeInOut',
delay: hover.delay,
},
},
}}
alt=""
src={src}
style={{ left, top } as React.CSSProperties}
className="absolute size-16 rounded-full bg-white shadow-sm ring-1 ring-black/5"
/>
)
}
export function LogoCluster() {
return (
<div aria-hidden="true" className="relative h-full overflow-hidden">
<Circles />
<div className="absolute left-1/2 h-full w-104 -translate-x-1/2">
<MainLogo />
<Logo
src="/logo-cluster/career-builder.svg"
left={360}
top={144}
hover={{ x: 6, y: 1, rotate: 5, delay: 0.38 }}
/>
<Logo
src="/logo-cluster/dribbble.svg"
left={285}
top={20}
hover={{ x: 4, y: -5, rotate: 6, delay: 0.3 }}
/>
<Logo
src="/logo-cluster/glassdoor.svg"
left={255}
top={210}
hover={{ x: 3, y: 5, rotate: 7, delay: 0.2 }}
/>
<Logo
src="/logo-cluster/linkedin.svg"
left={144}
top={40}
hover={{ x: -2, y: -5, rotate: -6, delay: 0.15 }}
/>
<Logo
src="/logo-cluster/upwork.svg"
left={36}
top={56}
hover={{ x: -4, y: -5, rotate: -6, delay: 0.35 }}
/>
<Logo
src="/logo-cluster/we-work-remotely.svg"
left={96}
top={176}
hover={{ x: -3, y: 5, rotate: 3, delay: 0.15 }}
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,130 @@
import { clsx } from 'clsx'
import { Mark } from './logo'
function Row({ children }: { children: React.ReactNode }) {
return (
<div className="group relative">
<div className="absolute inset-x-0 top-1/2 h-0.5 bg-linear-to-r from-white/15 from-[2px] to-[2px] bg-size-[12px_100%]" />
<div className="absolute inset-x-0 bottom-0 h-0.5 bg-linear-to-r from-white/5 from-[2px] to-[2px] bg-size-[12px_100%] group-last:hidden" />
{children}
</div>
)
}
function Logo({
label,
src,
className,
}: {
label: string
src: string
className: string
}) {
return (
<div
className={clsx(
className,
'absolute top-2 grid grid-cols-[1rem_1fr] items-center gap-2 px-3 py-1 whitespace-nowrap',
'rounded-full bg-linear-to-t from-gray-800 from-50% to-gray-700 ring-1 ring-white/10 ring-inset',
'[--move-x-from:-100%] [--move-x-to:calc(100%+100cqw)] [animation-iteration-count:infinite] [animation-name:move-x] [animation-play-state:paused] [animation-timing-function:linear] group-hover:[animation-play-state:running]',
)}
>
<img alt="" src={src} className="size-4" />
<span className="text-sm/6 font-medium text-white">{label}</span>
</div>
)
}
export function LogoTimeline() {
return (
<div aria-hidden="true" className="relative h-full overflow-hidden">
<div className="absolute inset-0 top-8 z-10 flex items-center justify-center">
<div
className="absolute inset-0 backdrop-blur-md"
style={{
maskImage: `url('data:image/svg+xml,<svg width="96" height="96" viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="96" height="96" rx="12" fill="black"/></svg>')`,
maskPosition: 'center',
maskRepeat: 'no-repeat',
}}
/>
<div className="relative flex size-24 items-center justify-center rounded-xl bg-linear-to-t from-white/5 to-white/25 shadow-sm ring-1 ring-white/10 outline outline-offset-[-5px] outline-white/5 ring-inset">
<Mark className="h-9 fill-white" />
</div>
</div>
<div className="@container absolute inset-0 grid grid-cols-1 pt-8">
<Row>
<Logo
label="Loom"
src="/logo-timeline/loom.svg"
className="[animation-delay:-26s] [animation-duration:30s]"
/>
<Logo
label="Gmail"
src="/logo-timeline/gmail.svg"
className="[animation-delay:-8s] [animation-duration:30s]"
/>
</Row>
<Row>
<Logo
label="Asana"
src="/logo-timeline/asana.svg"
className="[animation-delay:-40s] [animation-duration:40s]"
/>
<Logo
label="Microsoft Teams"
src="/logo-timeline/microsoft-teams.svg"
className="[animation-delay:-20s] [animation-duration:40s]"
/>
</Row>
<Row>
<Logo
label="Google Calendar"
src="/logo-timeline/google-calendar.svg"
className="[animation-delay:-10s] [animation-duration:40s]"
/>
<Logo
label="Google Drive"
src="/logo-timeline/google-drive.svg"
className="[animation-delay:-32s] [animation-duration:40s]"
/>
</Row>
<Row>
<Logo
label="Basecamp"
src="/logo-timeline/basecamp.svg"
className="[animation-delay:-45s] [animation-duration:45s]"
/>
<Logo
label="Discord"
src="/logo-timeline/discord.svg"
className="[animation-delay:-23s] [animation-duration:45s]"
/>
</Row>
<Row>
<Logo
label="Hubspot"
src="/logo-timeline/hubspot.svg"
className="[animation-delay:-55s] [animation-duration:60s]"
/>
<Logo
label="Slack"
src="/logo-timeline/slack.svg"
className="[animation-delay:-20s] [animation-duration:60s]"
/>
</Row>
<Row>
<Logo
label="Adobe Creative Cloud"
src="/logo-timeline/adobe-creative-cloud.svg"
className="[animation-delay:-9s] [animation-duration:40s]"
/>
<Logo
label="Zoom"
src="/logo-timeline/zoom.svg"
className="[animation-delay:-28s] [animation-duration:40s]"
/>
</Row>
</div>
</div>
)
}

112
src/components/logo.tsx Normal file

File diff suppressed because one or more lines are too long

58
src/components/map.tsx Normal file
View File

@@ -0,0 +1,58 @@
'use client'
import { motion } from 'framer-motion'
function Marker({
src,
top,
offset,
delay,
}: {
src: string
top: number
offset: number
delay: number
}) {
return (
<motion.div
variants={{
idle: { scale: 0, opacity: 0, rotateX: 0, rotate: 0, y: 0 },
active: { y: [-20, 0, 4, 0], scale: [0.75, 1], opacity: [0, 1] },
}}
transition={{ duration: 0.25, delay, ease: 'easeOut' }}
style={{ '--offset': `${offset}px`, top } as React.CSSProperties}
className="absolute left-[calc(50%+var(--offset))] size-[38px] drop-shadow-[0_3px_1px_rgba(0,0,0,.15)]"
>
<svg fill="none" viewBox="0 0 38 38" className="absolute size-full">
<path
d="M29.607 5.193c5.858 5.857 5.858 15.355 0 21.213l-9.9 9.9-.707.706-.708-.708-9.899-9.898c-5.857-5.858-5.857-15.356 0-21.213 5.858-5.858 15.356-5.858 21.214 0Z"
className="fill-black/5"
/>
<path
d="m28.9 25.698-9.9 9.9-9.9-9.9C3.634 20.232 3.634 11.367 9.1 5.9 14.569.432 23.433.432 28.9 5.9c5.467 5.468 5.467 14.332 0 19.8Z"
className="fill-white"
/>
</svg>
<img
alt=""
src={src}
className="absolute top-[4px] left-[7px] size-6 rounded-full"
/>
</motion.div>
)
}
export function Map() {
return (
<div aria-hidden="true" className="relative size-full">
<div className="absolute inset-0 bg-[url(/map.png)] mask-[linear-gradient(to_bottom,black_50%,transparent)] bg-size-[530px_430px] bg-position-[center_-75px] bg-no-repeat" />
<div className="absolute inset-0">
<Marker src="/map/1.jpg" top={96} offset={-128} delay={0.15} />
<Marker src="/map/2.jpg" top={160} offset={-16} delay={0.4} />
<Marker src="/map/3.jpg" top={144} offset={96} delay={0.3} />
<Marker src="/map/4.jpg" top={192} offset={64} delay={0.6} />
<Marker src="/map/5.jpg" top={224} offset={-32} delay={0.8} />
</div>
</div>
)
}

102
src/components/navbar.tsx Normal file
View File

@@ -0,0 +1,102 @@
'use client'
import {
Disclosure,
DisclosureButton,
DisclosurePanel,
} from '@headlessui/react'
import { Bars2Icon } from '@heroicons/react/24/solid'
import { motion } from 'framer-motion'
import { Link } from './link'
import { Logo } from './logo'
import { PlusGrid, PlusGridItem, PlusGridRow } from './plus-grid'
const links = [
{ href: '/pricing', label: 'Pricing' },
{ href: '/company', label: 'Company' },
{ href: '/blog', label: 'Blog' },
{ href: '/login', label: 'Login' },
]
function DesktopNav() {
return (
<nav className="relative hidden lg:flex">
{links.map(({ href, label }) => (
<PlusGridItem key={href} className="relative flex">
<Link
href={href}
className="flex items-center px-4 py-3 text-base font-medium text-gray-950 bg-blend-multiply data-hover:bg-black/2.5"
>
{label}
</Link>
</PlusGridItem>
))}
</nav>
)
}
function MobileNavButton() {
return (
<DisclosureButton
className="flex size-12 items-center justify-center self-center rounded-lg data-hover:bg-black/5 lg:hidden"
aria-label="Open main menu"
>
<Bars2Icon className="size-6" />
</DisclosureButton>
)
}
function MobileNav() {
return (
<DisclosurePanel className="lg:hidden">
<div className="flex flex-col gap-6 py-4">
{links.map(({ href, label }, linkIndex) => (
<motion.div
initial={{ opacity: 0, rotateX: -90 }}
animate={{ opacity: 1, rotateX: 0 }}
transition={{
duration: 0.15,
ease: 'easeInOut',
rotateX: { duration: 0.3, delay: linkIndex * 0.1 },
}}
key={href}
>
<Link href={href} className="text-base font-medium text-gray-950">
{label}
</Link>
</motion.div>
))}
</div>
<div className="absolute left-1/2 w-screen -translate-x-1/2">
<div className="absolute inset-x-0 top-0 border-t border-black/5" />
<div className="absolute inset-x-0 top-2 border-t border-black/5" />
</div>
</DisclosurePanel>
)
}
export function Navbar({ banner }: { banner?: React.ReactNode }) {
return (
<Disclosure as="header" className="pt-12 sm:pt-16">
<PlusGrid>
<PlusGridRow className="relative flex justify-between">
<div className="relative flex gap-6">
<PlusGridItem className="py-3">
<Link href="/" title="Home">
<Logo className="h-9" />
</Link>
</PlusGridItem>
{banner && (
<div className="relative hidden items-center py-3 lg:flex">
{banner}
</div>
)}
</div>
<DesktopNav />
<MobileNavButton />
</PlusGridRow>
</PlusGrid>
<MobileNav />
</Disclosure>
)
}

View File

@@ -0,0 +1,94 @@
import { clsx } from 'clsx'
export function PlusGrid({
className = '',
children,
}: {
className?: string
children: React.ReactNode
}) {
return <div className={className}>{children}</div>
}
export function PlusGridRow({
className = '',
children,
}: {
className?: string
children: React.ReactNode
}) {
return (
<div
className={clsx(
className,
'group/row relative isolate pt-[calc(--spacing(2)+1px)] last:pb-[calc(--spacing(2)+1px)]',
)}
>
<div
aria-hidden="true"
className="absolute inset-y-0 left-1/2 -z-10 w-screen -translate-x-1/2"
>
<div className="absolute inset-x-0 top-0 border-t border-black/5"></div>
<div className="absolute inset-x-0 top-2 border-t border-black/5"></div>
<div className="absolute inset-x-0 bottom-0 hidden border-b border-black/5 group-last/row:block"></div>
<div className="absolute inset-x-0 bottom-2 hidden border-b border-black/5 group-last/row:block"></div>
</div>
{children}
</div>
)
}
export function PlusGridItem({
className = '',
children,
}: {
className?: string
children: React.ReactNode
}) {
return (
<div className={clsx(className, 'group/item relative')}>
<PlusGridIcon
placement="top left"
className="hidden group-first/item:block"
/>
<PlusGridIcon placement="top right" />
<PlusGridIcon
placement="bottom left"
className="hidden group-first/item:group-last/row:block"
/>
<PlusGridIcon
placement="bottom right"
className="hidden group-last/row:block"
/>
{children}
</div>
)
}
export function PlusGridIcon({
className = '',
placement,
}: {
className?: string
placement: `${'top' | 'bottom'} ${'right' | 'left'}`
}) {
let [yAxis, xAxis] = placement.split(' ')
let yClass = yAxis === 'top' ? '-top-2' : '-bottom-2'
let xClass = xAxis === 'left' ? '-left-2' : '-right-2'
return (
<svg
viewBox="0 0 15 15"
aria-hidden="true"
className={clsx(
className,
'absolute size-[15px] fill-black/10',
yClass,
xClass,
)}
>
<path d="M8 0H7V7H0V8H7V15H8V8H15V7H8V0Z" />
</svg>
)
}

View File

@@ -0,0 +1,30 @@
import { clsx } from 'clsx'
export function Screenshot({
width,
height,
src,
className,
}: {
width: number
height: number
src: string
className?: string
}) {
return (
<div
style={{ '--width': width, '--height': height } as React.CSSProperties}
className={clsx(
className,
'relative aspect-[var(--width)/var(--height)] [--radius:var(--radius-xl)]',
)}
>
<div className="absolute -inset-(--padding) rounded-[calc(var(--radius)+var(--padding))] shadow-xs ring-1 ring-black/5 [--padding:--spacing(2)]" />
<img
alt=""
src={src}
className="h-full rounded-(--radius) shadow-2xl ring-1 ring-black/10"
/>
</div>
)
}

View File

@@ -0,0 +1,249 @@
'use client'
import * as Headless from '@headlessui/react'
import { ArrowLongRightIcon } from '@heroicons/react/20/solid'
import { clsx } from 'clsx'
import {
MotionValue,
motion,
useMotionValueEvent,
useScroll,
useSpring,
type HTMLMotionProps,
} from 'framer-motion'
import { useCallback, useLayoutEffect, useRef, useState } from 'react'
import useMeasure, { type RectReadOnly } from 'react-use-measure'
import { Container } from './container'
import { Link } from './link'
import { Heading, Subheading } from './text'
const testimonials = [
{
img: '/testimonials/tina-yards.jpg',
name: 'Tina Yards',
title: 'VP of Sales, Protocol',
quote:
'Thanks to Radiant, were finding new leads that we never would have found with legal methods.',
},
{
img: '/testimonials/conor-neville.jpg',
name: 'Conor Neville',
title: 'Head of Customer Success, TaxPal',
quote:
'Radiant made undercutting all of our competitors an absolute breeze.',
},
{
img: '/testimonials/amy-chase.jpg',
name: 'Amy Chase',
title: 'Head of GTM, Pocket',
quote:
'We closed a deal in literally a few minutes because we knew their exact budget.',
},
{
img: '/testimonials/veronica-winton.jpg',
name: 'Veronica Winton',
title: 'CSO, Planeteria',
quote:
'Weve managed to put two of our main competitors out of business in 6 months.',
},
{
img: '/testimonials/dillon-lenora.jpg',
name: 'Dillon Lenora',
title: 'VP of Sales, Detax',
quote: 'I was able to replace 80% of my team with RadiantAI bots.',
},
{
img: '/testimonials/harriet-arron.jpg',
name: 'Harriet Arron',
title: 'Account Manager, Commit',
quote:
'Ive smashed all my targets without having to speak to a lead in months.',
},
]
function TestimonialCard({
name,
title,
img,
children,
bounds,
scrollX,
...props
}: {
img: string
name: string
title: string
children: React.ReactNode
bounds: RectReadOnly
scrollX: MotionValue<number>
} & HTMLMotionProps<'div'>) {
let ref = useRef<HTMLDivElement | null>(null)
let computeOpacity = useCallback(() => {
let element = ref.current
if (!element || bounds.width === 0) return 1
let rect = element.getBoundingClientRect()
if (rect.left < bounds.left) {
let diff = bounds.left - rect.left
let percent = diff / rect.width
return Math.max(0.5, 1 - percent)
} else if (rect.right > bounds.right) {
let diff = rect.right - bounds.right
let percent = diff / rect.width
return Math.max(0.5, 1 - percent)
} else {
return 1
}
}, [ref, bounds.width, bounds.left, bounds.right])
let opacity = useSpring(computeOpacity(), {
stiffness: 154,
damping: 23,
})
useLayoutEffect(() => {
opacity.set(computeOpacity())
}, [computeOpacity, opacity])
useMotionValueEvent(scrollX, 'change', () => {
opacity.set(computeOpacity())
})
return (
<motion.div
ref={ref}
style={{ opacity }}
{...props}
className="relative flex aspect-9/16 w-72 shrink-0 snap-start scroll-ml-(--scroll-padding) flex-col justify-end overflow-hidden rounded-3xl sm:aspect-3/4 sm:w-96"
>
<img
alt=""
src={img}
className="absolute inset-x-0 top-0 aspect-square w-full object-cover"
/>
<div
aria-hidden="true"
className="absolute inset-0 rounded-3xl bg-linear-to-t from-black from-[calc(7/16*100%)] ring-1 ring-gray-950/10 ring-inset sm:from-25%"
/>
<figure className="relative p-10">
<blockquote>
<p className="relative text-xl/7 text-white">
<span aria-hidden="true" className="absolute -translate-x-full">
</span>
{children}
<span aria-hidden="true" className="absolute">
</span>
</p>
</blockquote>
<figcaption className="mt-6 border-t border-white/20 pt-6">
<p className="text-sm/6 font-medium text-white">{name}</p>
<p className="text-sm/6 font-medium">
<span className="bg-linear-to-r from-[#fff1be] from-28% via-[#ee87cb] via-70% to-[#b060ff] bg-clip-text text-transparent">
{title}
</span>
</p>
</figcaption>
</figure>
</motion.div>
)
}
function CallToAction() {
return (
<div>
<p className="max-w-sm text-sm/6 text-gray-600">
Join the best sellers in the business and start using Radiant to hit
your targets today.
</p>
<div className="mt-2">
<Link
href="#"
className="inline-flex items-center gap-2 text-sm/6 font-medium text-pink-600"
>
Get started
<ArrowLongRightIcon className="size-5" />
</Link>
</div>
</div>
)
}
export function Testimonials() {
let scrollRef = useRef<HTMLDivElement | null>(null)
let { scrollX } = useScroll({ container: scrollRef })
let [setReferenceWindowRef, bounds] = useMeasure()
let [activeIndex, setActiveIndex] = useState(0)
useMotionValueEvent(scrollX, 'change', (x) => {
setActiveIndex(Math.floor(x / scrollRef.current!.children[0].clientWidth))
})
function scrollTo(index: number) {
let gap = 32
let width = (scrollRef.current!.children[0] as HTMLElement).offsetWidth
scrollRef.current!.scrollTo({ left: (width + gap) * index })
}
return (
<div className="overflow-hidden py-32">
<Container>
<div ref={setReferenceWindowRef}>
<Subheading>What everyone is saying</Subheading>
<Heading as="h3" className="mt-2">
Trusted by professionals.
</Heading>
</div>
</Container>
<div
ref={scrollRef}
className={clsx([
'mt-16 flex gap-8 px-(--scroll-padding)',
'[scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
'snap-x snap-mandatory overflow-x-auto overscroll-x-contain scroll-smooth',
'[--scroll-padding:max(--spacing(6),calc((100vw-(var(--container-2xl)))/2))] lg:[--scroll-padding:max(--spacing(8),calc((100vw-(var(--container-7xl)))/2))]',
])}
>
{testimonials.map(({ img, name, title, quote }, testimonialIndex) => (
<TestimonialCard
key={testimonialIndex}
name={name}
title={title}
img={img}
bounds={bounds}
scrollX={scrollX}
onClick={() => scrollTo(testimonialIndex)}
>
{quote}
</TestimonialCard>
))}
<div className="w-2xl shrink-0 sm:w-216" />
</div>
<Container className="mt-16">
<div className="flex justify-between">
<CallToAction />
<div className="hidden sm:flex sm:gap-2">
{testimonials.map(({ name }, testimonialIndex) => (
<Headless.Button
key={testimonialIndex}
onClick={() => scrollTo(testimonialIndex)}
data-active={
activeIndex === testimonialIndex ? true : undefined
}
aria-label={`Scroll to testimonial from ${name}`}
className={clsx(
'size-2.5 rounded-full border border-transparent bg-gray-300 transition',
'data-active:bg-gray-400 data-hover:bg-gray-400',
'forced-colors:data-active:bg-[Highlight] forced-colors:data-focus:outline-offset-4',
)}
/>
))}
</div>
</div>
</Container>
</div>
)
}

56
src/components/text.tsx Normal file
View File

@@ -0,0 +1,56 @@
import { clsx } from 'clsx'
type HeadingProps = {
as?: 'div' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
dark?: boolean
} & React.ComponentPropsWithoutRef<
'div' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
>
export function Heading({
className,
as: Element = 'h2',
dark = false,
...props
}: HeadingProps) {
return (
<Element
{...props}
data-dark={dark ? 'true' : undefined}
className={clsx(
className,
'text-4xl font-medium tracking-tighter text-pretty text-gray-950 data-dark:text-white sm:text-6xl',
)}
/>
)
}
export function Subheading({
className,
as: Element = 'h2',
dark = false,
...props
}: HeadingProps) {
return (
<Element
{...props}
data-dark={dark ? 'true' : undefined}
className={clsx(
className,
'font-mono text-xs/5 font-semibold tracking-widest text-gray-500 uppercase data-dark:text-gray-400',
)}
/>
)
}
export function Lead({
className,
...props
}: React.ComponentPropsWithoutRef<'p'>) {
return (
<p
className={clsx(className, 'text-2xl font-medium text-gray-500')}
{...props}
/>
)
}

30
src/sanity/client.ts Normal file
View File

@@ -0,0 +1,30 @@
import { createClient, type QueryParams } from 'next-sanity'
import { apiVersion, dataset, projectId } from './env'
const isDevelopment = process.env.NODE_ENV === 'development'
export const client = createClient({
projectId,
dataset,
apiVersion,
useCdn: isDevelopment ? false : true,
})
export async function sanityFetch<const QueryString extends string>({
query,
params = {},
revalidate = 60,
tags = [],
}: {
query: QueryString
params?: QueryParams
revalidate?: number | false
tags?: string[]
}) {
return client.fetch(query, params, {
next: {
revalidate: isDevelopment || tags.length ? false : revalidate,
tags,
},
})
}

20
src/sanity/env.ts Normal file
View File

@@ -0,0 +1,20 @@
export const apiVersion =
process.env.NEXT_PUBLIC_SANITY_API_VERSION || '2024-07-25'
export const dataset = assertValue(
process.env.NEXT_PUBLIC_SANITY_DATASET,
'Missing environment variable: NEXT_PUBLIC_SANITY_DATASET',
)
export const projectId = assertValue(
process.env.NEXT_PUBLIC_SANITY_PROJECT_ID,
'Missing environment variable: NEXT_PUBLIC_SANITY_PROJECT_ID',
)
function assertValue<T>(v: T | undefined, errorMessage: string): T {
if (v === undefined) {
throw new Error(errorMessage)
}
return v
}

9
src/sanity/image.ts Normal file
View File

@@ -0,0 +1,9 @@
import createImageUrlBuilder from '@sanity/image-url'
import type { SanityImageSource } from '@sanity/image-url/lib/types/types'
import { dataset, projectId } from './env'
const builder = createImageUrlBuilder({ projectId, dataset })
export function image(source: SanityImageSource) {
return builder.image(source).auto('format')
}

131
src/sanity/queries.ts Normal file
View File

@@ -0,0 +1,131 @@
import { defineQuery } from 'next-sanity'
import { sanityFetch } from './client'
const TOTAL_POSTS_QUERY = defineQuery(/* groq */ `count(*[
_type == "post"
&& defined(slug.current)
&& (isFeatured != true || defined($category))
&& select(defined($category) => $category in categories[]->slug.current, true)
])`)
export async function getPostsCount(category?: string) {
return await sanityFetch({
query: TOTAL_POSTS_QUERY,
params: { category: category ?? null },
})
}
const POSTS_QUERY = defineQuery(/* groq */ `*[
_type == "post"
&& defined(slug.current)
&& (isFeatured != true || defined($category))
&& select(defined($category) => $category in categories[]->slug.current, true)
]|order(publishedAt desc)[$startIndex...$endIndex]{
title,
"slug": slug.current,
publishedAt,
excerpt,
author->{
name,
image,
},
}`)
export async function getPosts(
startIndex: number,
endIndex: number,
category?: string,
) {
return await sanityFetch({
query: POSTS_QUERY,
params: {
startIndex,
endIndex,
category: category ?? null,
},
})
}
const FEATURED_POSTS_QUERY = defineQuery(/* groq */ `*[
_type == "post"
&& isFeatured == true
&& defined(slug.current)
]|order(publishedAt desc)[0...$quantity]{
title,
"slug": slug.current,
publishedAt,
mainImage,
excerpt,
author->{
name,
image,
},
}`)
export async function getFeaturedPosts(quantity: number) {
return await sanityFetch({
query: FEATURED_POSTS_QUERY,
params: { quantity },
})
}
const FEED_POSTS_QUERY = defineQuery(/* groq */ `*[
_type == "post"
&& defined(slug.current)
]|order(isFeatured, publishedAt desc){
title,
"slug": slug.current,
publishedAt,
mainImage,
excerpt,
author->{
name,
},
}`)
export async function getPostsForFeed() {
return await sanityFetch({
query: FEED_POSTS_QUERY,
})
}
const POST_QUERY = defineQuery(/* groq */ `*[
_type == "post"
&& slug.current == $slug
][0]{
publishedAt,
title,
mainImage,
excerpt,
body,
author->{
name,
image,
},
categories[]->{
title,
"slug": slug.current,
}
}
`)
export async function getPost(slug: string) {
return await sanityFetch({
query: POST_QUERY,
params: { slug },
})
}
const CATEGORIES_QUERY = defineQuery(/* groq */ `*[
_type == "category"
&& count(*[_type == "post" && defined(slug.current) && ^._id in categories[]._ref]) > 0
]|order(title asc){
title,
"slug": slug.current,
}`)
export async function getCategories() {
return await sanityFetch({
query: CATEGORIES_QUERY,
})
}

10
src/sanity/schema.ts Normal file
View File

@@ -0,0 +1,10 @@
import type { SchemaTypeDefinition } from 'sanity'
import { authorType } from './types/author'
import { blockContentType } from './types/block-content'
import { categoryType } from './types/category'
import { postType } from './types/post'
export const schema: { types: SchemaTypeDefinition[] } = {
types: [blockContentType, categoryType, postType, authorType],
}

458
src/sanity/types.ts Normal file
View File

@@ -0,0 +1,458 @@
/**
* ---------------------------------------------------------------------------------
* This file has been generated by Sanity TypeGen.
* Command: `sanity typegen generate`
*
* Any modifications made directly to this file will be overwritten the next time
* the TypeScript definitions are generated. Please make changes to the Sanity
* schema definitions and/or GROQ queries if you need to update these types.
*
* For more information on how to use Sanity TypeGen, visit the official documentation:
* https://www.sanity.io/docs/sanity-typegen
* ---------------------------------------------------------------------------------
*/
// Source: schema.json
export type SanityImagePaletteSwatch = {
_type: 'sanity.imagePaletteSwatch'
background?: string
foreground?: string
population?: number
title?: string
}
export type SanityImagePalette = {
_type: 'sanity.imagePalette'
darkMuted?: SanityImagePaletteSwatch
lightVibrant?: SanityImagePaletteSwatch
darkVibrant?: SanityImagePaletteSwatch
vibrant?: SanityImagePaletteSwatch
dominant?: SanityImagePaletteSwatch
lightMuted?: SanityImagePaletteSwatch
muted?: SanityImagePaletteSwatch
}
export type SanityImageDimensions = {
_type: 'sanity.imageDimensions'
height?: number
width?: number
aspectRatio?: number
}
export type SanityFileAsset = {
_id: string
_type: 'sanity.fileAsset'
_createdAt: string
_updatedAt: string
_rev: string
originalFilename?: string
label?: string
title?: string
description?: string
altText?: string
sha1hash?: string
extension?: string
mimeType?: string
size?: number
assetId?: string
uploadId?: string
path?: string
url?: string
source?: SanityAssetSourceData
}
export type Geopoint = {
_type: 'geopoint'
lat?: number
lng?: number
alt?: number
}
export type Post = {
_id: string
_type: 'post'
_createdAt: string
_updatedAt: string
_rev: string
title?: string
slug?: Slug
publishedAt?: string
isFeatured?: boolean
author?: {
_ref: string
_type: 'reference'
_weak?: boolean
[internalGroqTypeReferenceTo]?: 'author'
}
mainImage?: {
asset?: {
_ref: string
_type: 'reference'
_weak?: boolean
[internalGroqTypeReferenceTo]?: 'sanity.imageAsset'
}
hotspot?: SanityImageHotspot
crop?: SanityImageCrop
alt?: string
_type: 'image'
}
categories?: Array<{
_ref: string
_type: 'reference'
_weak?: boolean
_key: string
[internalGroqTypeReferenceTo]?: 'category'
}>
excerpt?: string
body?: Array<
| {
children?: Array<{
marks?: Array<string>
text?: string
_type: 'span'
_key: string
}>
style?: 'normal' | 'h2' | 'h3' | 'blockquote'
listItem?: 'bullet' | 'number'
markDefs?: Array<{
href?: string
_type: 'link'
_key: string
}>
level?: number
_type: 'block'
_key: string
}
| {
asset?: {
_ref: string
_type: 'reference'
_weak?: boolean
[internalGroqTypeReferenceTo]?: 'sanity.imageAsset'
}
hotspot?: SanityImageHotspot
crop?: SanityImageCrop
alt?: string
_type: 'image'
_key: string
}
>
}
export type Author = {
_id: string
_type: 'author'
_createdAt: string
_updatedAt: string
_rev: string
name?: string
slug?: Slug
image?: {
asset?: {
_ref: string
_type: 'reference'
_weak?: boolean
[internalGroqTypeReferenceTo]?: 'sanity.imageAsset'
}
hotspot?: SanityImageHotspot
crop?: SanityImageCrop
_type: 'image'
}
}
export type Category = {
_id: string
_type: 'category'
_createdAt: string
_updatedAt: string
_rev: string
title?: string
slug?: Slug
}
export type Slug = {
_type: 'slug'
current?: string
source?: string
}
export type BlockContent = Array<
| {
children?: Array<{
marks?: Array<string>
text?: string
_type: 'span'
_key: string
}>
style?: 'normal' | 'h2' | 'h3' | 'blockquote'
listItem?: 'bullet' | 'number'
markDefs?: Array<{
href?: string
_type: 'link'
_key: string
}>
level?: number
_type: 'block'
_key: string
}
| {
asset?: {
_ref: string
_type: 'reference'
_weak?: boolean
[internalGroqTypeReferenceTo]?: 'sanity.imageAsset'
}
hotspot?: SanityImageHotspot
crop?: SanityImageCrop
alt?: string
_type: 'image'
_key: string
}
>
export type SanityImageCrop = {
_type: 'sanity.imageCrop'
top?: number
bottom?: number
left?: number
right?: number
}
export type SanityImageHotspot = {
_type: 'sanity.imageHotspot'
x?: number
y?: number
height?: number
width?: number
}
export type SanityImageAsset = {
_id: string
_type: 'sanity.imageAsset'
_createdAt: string
_updatedAt: string
_rev: string
originalFilename?: string
label?: string
title?: string
description?: string
altText?: string
sha1hash?: string
extension?: string
mimeType?: string
size?: number
assetId?: string
uploadId?: string
path?: string
url?: string
metadata?: SanityImageMetadata
source?: SanityAssetSourceData
}
export type SanityAssetSourceData = {
_type: 'sanity.assetSourceData'
name?: string
id?: string
url?: string
}
export type SanityImageMetadata = {
_type: 'sanity.imageMetadata'
location?: Geopoint
dimensions?: SanityImageDimensions
palette?: SanityImagePalette
lqip?: string
blurHash?: string
hasAlpha?: boolean
isOpaque?: boolean
}
export type AllSanitySchemaTypes =
| SanityImagePaletteSwatch
| SanityImagePalette
| SanityImageDimensions
| SanityFileAsset
| Geopoint
| Post
| Author
| Category
| Slug
| BlockContent
| SanityImageCrop
| SanityImageHotspot
| SanityImageAsset
| SanityAssetSourceData
| SanityImageMetadata
export declare const internalGroqTypeReferenceTo: unique symbol
// Source: ./src/sanity/queries.ts
// Variable: TOTAL_POSTS_QUERY
// Query: count(*[ _type == "post" && defined(slug.current) && (isFeatured != true || defined($category)) && select(defined($category) => $category in categories[]->slug.current, true)])
export type TOTAL_POSTS_QUERYResult = number
// Variable: POSTS_QUERY
// Query: *[ _type == "post" && defined(slug.current) && (isFeatured != true || defined($category)) && select(defined($category) => $category in categories[]->slug.current, true)]|order(publishedAt desc)[$startIndex...$endIndex]{ title, "slug": slug.current, publishedAt, excerpt, author->{ name, image, },}
export type POSTS_QUERYResult = Array<{
title: string | null
slug: string | null
publishedAt: string | null
excerpt: string | null
author: {
name: string | null
image: {
asset?: {
_ref: string
_type: 'reference'
_weak?: boolean
[internalGroqTypeReferenceTo]?: 'sanity.imageAsset'
}
hotspot?: SanityImageHotspot
crop?: SanityImageCrop
_type: 'image'
} | null
} | null
}>
// Variable: FEATURED_POSTS_QUERY
// Query: *[ _type == "post" && isFeatured == true && defined(slug.current)]|order(publishedAt desc)[0...$quantity]{ title, "slug": slug.current, publishedAt, mainImage, excerpt, author->{ name, image, },}
export type FEATURED_POSTS_QUERYResult = Array<{
title: string | null
slug: string | null
publishedAt: string | null
mainImage: {
asset?: {
_ref: string
_type: 'reference'
_weak?: boolean
[internalGroqTypeReferenceTo]?: 'sanity.imageAsset'
}
hotspot?: SanityImageHotspot
crop?: SanityImageCrop
alt?: string
_type: 'image'
} | null
excerpt: string | null
author: {
name: string | null
image: {
asset?: {
_ref: string
_type: 'reference'
_weak?: boolean
[internalGroqTypeReferenceTo]?: 'sanity.imageAsset'
}
hotspot?: SanityImageHotspot
crop?: SanityImageCrop
_type: 'image'
} | null
} | null
}>
// Variable: FEED_POSTS_QUERY
// Query: *[ _type == "post" && defined(slug.current)]|order(isFeatured, publishedAt desc){ title, "slug": slug.current, publishedAt, mainImage, excerpt, author->{ name, },}
export type FEED_POSTS_QUERYResult = Array<{
title: string | null
slug: string | null
publishedAt: string | null
mainImage: {
asset?: {
_ref: string
_type: 'reference'
_weak?: boolean
[internalGroqTypeReferenceTo]?: 'sanity.imageAsset'
}
hotspot?: SanityImageHotspot
crop?: SanityImageCrop
alt?: string
_type: 'image'
} | null
excerpt: string | null
author: {
name: string | null
} | null
}>
// Variable: POST_QUERY
// Query: *[ _type == "post" && slug.current == $slug][0]{ publishedAt, title, mainImage, excerpt, body, author->{ name, image, }, categories[]->{ title, "slug": slug.current, }}
export type POST_QUERYResult = {
publishedAt: string | null
title: string | null
mainImage: {
asset?: {
_ref: string
_type: 'reference'
_weak?: boolean
[internalGroqTypeReferenceTo]?: 'sanity.imageAsset'
}
hotspot?: SanityImageHotspot
crop?: SanityImageCrop
alt?: string
_type: 'image'
} | null
excerpt: string | null
body: Array<
| {
children?: Array<{
marks?: Array<string>
text?: string
_type: 'span'
_key: string
}>
style?: 'blockquote' | 'h2' | 'h3' | 'normal'
listItem?: 'bullet' | 'number'
markDefs?: Array<{
href?: string
_type: 'link'
_key: string
}>
level?: number
_type: 'block'
_key: string
}
| {
asset?: {
_ref: string
_type: 'reference'
_weak?: boolean
[internalGroqTypeReferenceTo]?: 'sanity.imageAsset'
}
hotspot?: SanityImageHotspot
crop?: SanityImageCrop
alt?: string
_type: 'image'
_key: string
}
> | null
author: {
name: string | null
image: {
asset?: {
_ref: string
_type: 'reference'
_weak?: boolean
[internalGroqTypeReferenceTo]?: 'sanity.imageAsset'
}
hotspot?: SanityImageHotspot
crop?: SanityImageCrop
_type: 'image'
} | null
} | null
categories: Array<{
title: string | null
slug: string | null
}> | null
} | null
// Variable: CATEGORIES_QUERY
// Query: *[ _type == "category" && count(*[_type == "post" && defined(slug.current) && ^._id in categories[]._ref]) > 0]|order(title asc){ title, "slug": slug.current,}
export type CATEGORIES_QUERYResult = Array<{
title: string | null
slug: string | null
}>
// Query TypeMap
import '@sanity/client'
declare module '@sanity/client' {
interface SanityQueries {
'count(*[\n _type == "post"\n && defined(slug.current)\n && (isFeatured != true || defined($category))\n && select(defined($category) => $category in categories[]->slug.current, true)\n])': TOTAL_POSTS_QUERYResult
'*[\n _type == "post"\n && defined(slug.current)\n && (isFeatured != true || defined($category))\n && select(defined($category) => $category in categories[]->slug.current, true)\n]|order(publishedAt desc)[$startIndex...$endIndex]{\n title,\n "slug": slug.current,\n publishedAt,\n excerpt,\n author->{\n name,\n image,\n },\n}': POSTS_QUERYResult
'*[\n _type == "post"\n && isFeatured == true\n && defined(slug.current)\n]|order(publishedAt desc)[0...$quantity]{\n title,\n "slug": slug.current,\n publishedAt,\n mainImage,\n excerpt,\n author->{\n name,\n image,\n },\n}': FEATURED_POSTS_QUERYResult
'*[\n _type == "post"\n && defined(slug.current)\n]|order(isFeatured, publishedAt desc){\n title,\n "slug": slug.current,\n publishedAt,\n mainImage,\n excerpt,\n author->{\n name,\n },\n}': FEED_POSTS_QUERYResult
'*[\n _type == "post"\n && slug.current == $slug\n][0]{\n publishedAt,\n title,\n mainImage,\n excerpt,\n body,\n author->{\n name,\n image,\n },\n categories[]->{\n title,\n "slug": slug.current,\n }\n}\n': POST_QUERYResult
'*[\n _type == "category"\n && count(*[_type == "post" && defined(slug.current) && ^._id in categories[]._ref]) > 0\n]|order(title asc){\n title,\n "slug": slug.current,\n}': CATEGORIES_QUERYResult
}
}

View File

@@ -0,0 +1,36 @@
import { UserIcon } from '@heroicons/react/16/solid'
import { defineField, defineType } from 'sanity'
export const authorType = defineType({
name: 'author',
title: 'Author',
type: 'document',
icon: UserIcon,
fields: [
defineField({
name: 'name',
type: 'string',
}),
defineField({
name: 'slug',
type: 'slug',
options: {
source: 'name',
maxLength: 96,
},
}),
defineField({
name: 'image',
type: 'image',
options: {
hotspot: true,
},
}),
],
preview: {
select: {
title: 'name',
media: 'image',
},
},
})

View File

@@ -0,0 +1,70 @@
import { ImageIcon } from '@sanity/icons'
import { defineArrayMember, defineType } from 'sanity'
export const blockContentType = defineType({
title: 'Block Content',
name: 'blockContent',
type: 'array',
of: [
defineArrayMember({
type: 'block',
styles: [
{ title: 'Normal', value: 'normal' },
{ title: 'H2', value: 'h2' },
{ title: 'H3', value: 'h3' },
{ title: 'Quote', value: 'blockquote' },
],
marks: {
decorators: [
{ title: 'Strong', value: 'strong' },
{ title: 'Emphasis', value: 'em' },
{ title: 'Code', value: 'code' },
],
annotations: [
{
title: 'URL',
name: 'link',
type: 'object',
fields: [
{
title: 'URL',
name: 'href',
type: 'url',
},
],
},
],
},
}),
defineArrayMember({
title: 'Separator',
name: 'separator',
type: 'object',
fields: [
{
name: 'style',
title: 'Style',
type: 'string',
options: {
list: [
{ title: 'Line', value: 'line' },
{ title: 'Space', value: 'space' },
],
},
},
],
}),
defineArrayMember({
type: 'image',
icon: ImageIcon,
options: { hotspot: true },
fields: [
{
name: 'alt',
type: 'string',
title: 'Alternative Text',
},
],
}),
],
})

View File

@@ -0,0 +1,21 @@
import { TagIcon } from '@heroicons/react/16/solid'
import { defineField, defineType } from 'sanity'
export const categoryType = defineType({
name: 'category',
type: 'document',
icon: TagIcon,
fields: [
defineField({
name: 'title',
type: 'string',
}),
defineField({
name: 'slug',
type: 'slug',
options: {
source: 'title',
},
}),
],
})

116
src/sanity/types/post.ts Normal file
View File

@@ -0,0 +1,116 @@
import { DocumentIcon } from '@heroicons/react/16/solid'
import { groq } from 'next-sanity'
import { defineField, defineType } from 'sanity'
import { apiVersion } from '../env'
export const postType = defineType({
name: 'post',
title: 'Post',
type: 'document',
icon: DocumentIcon,
fields: [
defineField({
name: 'title',
type: 'string',
validation: (Rule) => Rule.required(),
}),
defineField({
name: 'slug',
type: 'slug',
options: {
source: 'title',
},
validation: (Rule) =>
Rule.required().error('A slug is required for the post URL.'),
}),
defineField({
name: 'publishedAt',
type: 'datetime',
validation: (Rule) =>
Rule.required().error(
'A publication date is required for ordering posts.',
),
}),
defineField({
name: 'isFeatured',
type: 'boolean',
initialValue: false,
validation: (Rule) =>
Rule.custom(async (isFeatured, { getClient }) => {
if (isFeatured !== true) {
return true
}
let featuredPosts = await getClient({ apiVersion })
.withConfig({ perspective: 'previewDrafts' })
.fetch<number>(
groq`count(*[_type == 'post' && isFeatured == true])`,
)
return featuredPosts > 3
? 'Only 3 posts can be featured at a time.'
: true
}),
}),
defineField({
name: 'author',
type: 'reference',
to: { type: 'author' },
}),
defineField({
name: 'mainImage',
type: 'image',
options: {
hotspot: true,
},
fields: [
{
name: 'alt',
type: 'string',
title: 'Alternative text',
},
],
}),
defineField({
name: 'categories',
type: 'array',
of: [{ type: 'reference', to: { type: 'category' } }],
}),
defineField({
name: 'excerpt',
type: 'text',
rows: 3,
}),
defineField({
name: 'body',
type: 'blockContent',
}),
],
preview: {
select: {
title: 'title',
media: 'mainImage',
author: 'author.name',
isFeatured: 'isFeatured',
},
prepare({ title, author, media, isFeatured }) {
return {
title,
subtitle: [isFeatured && 'Featured', author && `By ${author}`]
.filter(Boolean)
.join(' | '),
media,
}
},
},
orderings: [
{
name: 'isFeaturedAndPublishedAtDesc',
title: 'Featured & Latest Published',
by: [
{ field: 'isFeatured', direction: 'desc' },
{ field: 'publishedAt', direction: 'desc' },
],
},
],
})

16
src/styles/tailwind.css Normal file
View File

@@ -0,0 +1,16 @@
@import 'tailwindcss';
@theme {
--font-sans: Switzer, system-ui, sans-serif;
--radius-4xl: 2rem;
}
@keyframes move-x {
0% {
transform: translateX(var(--move-x-from));
}
100% {
transform: translateX(var(--move-x-to));
}
}