init
This commit is contained in:
200
src/app/blog/[slug]/page.tsx
Normal file
200
src/app/blog/[slug]/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
65
src/app/blog/feed.xml/route.ts
Normal file
65
src/app/blog/feed.xml/route.ts
Normal 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('&', '&')
|
||||
: 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
310
src/app/blog/page.tsx
Normal 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">
|
||||
What’s 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
473
src/app/company/page.tsx
Normal 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:
|
||||
'We’re 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">
|
||||
We’re 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. We’ll 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">
|
||||
We’re 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, we’ve 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 other’s
|
||||
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 world’s 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'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
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
33
src/app/layout.tsx
Normal 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&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
92
src/app/login/page.tsx
Normal 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
211
src/app/page.tsx
Normal 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, you’ll know which companies your leads are talking to and exactly how much they’re 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="It’s 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
516
src/app/pricing/page.tsx
Normal 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'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 business—and 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 company’s 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
|
||||
you’re 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>
|
||||
)
|
||||
}
|
19
src/app/studio/[[...tool]]/page.tsx
Normal file
19
src/app/studio/[[...tool]]/page.tsx
Normal 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} />
|
||||
}
|
33
src/components/animated-number.tsx
Normal file
33
src/components/animated-number.tsx
Normal 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>
|
||||
}
|
59
src/components/bento-card.tsx
Normal file
59
src/components/bento-card.tsx
Normal 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
46
src/components/button.tsx
Normal 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} />
|
||||
}
|
15
src/components/container.tsx
Normal file
15
src/components/container.tsx
Normal 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
193
src/components/footer.tsx
Normal 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'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">
|
||||
© {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>
|
||||
)
|
||||
}
|
30
src/components/gradient.tsx
Normal file
30
src/components/gradient.tsx
Normal 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
1050
src/components/keyboard.tsx
Normal file
File diff suppressed because it is too large
Load Diff
14
src/components/link.tsx
Normal file
14
src/components/link.tsx
Normal 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>
|
||||
)
|
||||
})
|
93
src/components/linked-avatars.tsx
Normal file
93
src/components/linked-avatars.tsx
Normal 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>
|
||||
)
|
||||
}
|
40
src/components/logo-cloud.tsx
Normal file
40
src/components/logo-cloud.tsx
Normal 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>
|
||||
)
|
||||
}
|
143
src/components/logo-cluster.tsx
Normal file
143
src/components/logo-cluster.tsx
Normal 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>
|
||||
)
|
||||
}
|
130
src/components/logo-timeline.tsx
Normal file
130
src/components/logo-timeline.tsx
Normal 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
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
58
src/components/map.tsx
Normal 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
102
src/components/navbar.tsx
Normal 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>
|
||||
)
|
||||
}
|
94
src/components/plus-grid.tsx
Normal file
94
src/components/plus-grid.tsx
Normal 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>
|
||||
)
|
||||
}
|
30
src/components/screenshot.tsx
Normal file
30
src/components/screenshot.tsx
Normal 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>
|
||||
)
|
||||
}
|
249
src/components/testimonials.tsx
Normal file
249
src/components/testimonials.tsx
Normal 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, we’re 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:
|
||||
'We’ve 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:
|
||||
'I’ve 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
56
src/components/text.tsx
Normal 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
30
src/sanity/client.ts
Normal 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
20
src/sanity/env.ts
Normal 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
9
src/sanity/image.ts
Normal 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
131
src/sanity/queries.ts
Normal 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
10
src/sanity/schema.ts
Normal 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
458
src/sanity/types.ts
Normal 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
|
||||
}
|
||||
}
|
36
src/sanity/types/author.ts
Normal file
36
src/sanity/types/author.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
})
|
70
src/sanity/types/block-content.ts
Normal file
70
src/sanity/types/block-content.ts
Normal 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',
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
})
|
21
src/sanity/types/category.ts
Normal file
21
src/sanity/types/category.ts
Normal 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
116
src/sanity/types/post.ts
Normal 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
16
src/styles/tailwind.css
Normal 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));
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user