Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/marketing/public/company-logos/coderbyte.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/marketing/public/company-logos/coderpad.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/marketing/public/company-logos/karat.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/marketing/public/company-logos/leetcode.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/marketing/public/company-logos/qualified.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
50 changes: 35 additions & 15 deletions apps/marketing/src/app/(alternatives)/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { AlternativePageView } from '@/components/alternative/AlternativePageView';
import { RoundupPageView } from '@/components/alternative/roundup/RoundupPageView';
import { competitorData } from '@/lib/alternativeConfig';
import { buildBreadcrumbSchema } from '@/lib/breadcrumbs';
import { roundupData } from '@/lib/roundupConfig';

export function generateStaticParams() {
return Object.keys(competitorData).map((key) => ({
slug: key,
}));
const slugs = new Set([...Object.keys(competitorData), ...Object.keys(roundupData)]);
return Array.from(slugs).map((slug) => ({ slug }));
}

interface AlternativeCompetitorPageProps {
Expand All @@ -18,27 +19,36 @@ export async function generateMetadata({
params,
}: AlternativeCompetitorPageProps): Promise<Metadata> {
const { slug } = await params;
const roundup = roundupData[slug];
const competitor = competitorData[slug];

if (!competitor) {
// Prefer the roundup SEO when this slug has been upgraded to a roundup page.
const seo = roundup?.seo ?? competitor?.seo;

if (!seo) {
return {
title: 'Not Found',
};
}

const siteUrl = 'https://coderscreen.com';
const pageUrl = `${siteUrl}/${slug}`;
const ogAlt = roundup
? roundup.seo.title
: competitor
? `CoderScreen vs ${competitor.displayName}`
: 'CoderScreen';

return {
title: competitor.seo.title,
description: competitor.seo.description,
keywords: competitor.seo.keywords,
title: seo.title,
description: seo.description,
keywords: seo.keywords,
alternates: {
canonical: pageUrl,
},
openGraph: {
title: competitor.seo.title,
description: competitor.seo.description,
title: seo.title,
description: seo.description,
url: pageUrl,
siteName: 'CoderScreen',
type: 'website',
Expand All @@ -47,14 +57,14 @@ export async function generateMetadata({
url: `${siteUrl}/og-image.png`,
width: 1200,
height: 630,
alt: `CoderScreen vs ${competitor.displayName}`,
alt: ogAlt,
},
],
},
twitter: {
card: 'summary_large_image',
title: competitor.seo.title,
description: competitor.seo.description,
title: seo.title,
description: seo.description,
images: [`${siteUrl}/og-image.png`],
},
robots: {
Expand All @@ -76,14 +86,20 @@ export default async function AlternativeCompetitorPage({
}: AlternativeCompetitorPageProps) {
const { slug } = await params;

const roundup = roundupData[slug];
const competitor = competitorData[slug];
if (!competitor) {

if (!roundup && !competitor) {
notFound();
}

const breadcrumbLabel = roundup
? `${roundup.competitorName} Alternatives`
: `${competitor?.displayName} Alternative`;

const breadcrumbSchema = buildBreadcrumbSchema([
{ name: 'Home', href: '/' },
{ name: `${competitor.displayName} Alternative`, href: `/${slug}` },
{ name: breadcrumbLabel, href: `/${slug}` },
]);

return (
Expand All @@ -93,7 +109,11 @@ export default async function AlternativeCompetitorPage({
// biome-ignore lint/security/noDangerouslySetInnerHtml: needed for SEO schema
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbSchema) }}
/>
<AlternativePageView competitor={competitor} />
{roundup ? (
<RoundupPageView roundup={roundup} />
) : (
competitor && <AlternativePageView competitor={competitor} />
)}
</>
);
}
28 changes: 28 additions & 0 deletions apps/marketing/src/components/alternative/roundup/BuyingGuide.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import type { RoundupPage } from '@/lib/roundupConfig';
import { RoundupSectionHeader } from './RoundupSectionHeader';

interface BuyingGuideProps {
roundup: RoundupPage;
}

export const BuyingGuide = ({ roundup }: BuyingGuideProps) => {
return (
<section>
<RoundupSectionHeader
title={`How to choose the right ${roundup.competitorName} alternative`}
subtitle='The best fit depends on your team size and how you hire. Here is where each option tends to make sense.'
/>

<div className='px-6 pb-20'>
<div className='max-w-5xl mx-auto grid sm:grid-cols-2 border-l border-t'>
{roundup.buyingGuide.map((segment) => (
<div key={segment.segment} className='border-r border-b p-8'>
<h3 className='text-base font-semibold text-foreground mb-2'>{segment.segment}</h3>
<p className='text-sm text-muted-foreground leading-relaxed'>{segment.advice}</p>
</div>
))}
</div>
</div>
</section>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import type { RoundupPage } from '@/lib/roundupConfig';
import { RoundupSectionHeader } from './RoundupSectionHeader';

interface FeaturesToLookForProps {
roundup: RoundupPage;
}

const TH =
'px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-muted-foreground';
const TD = 'px-4 py-4 text-sm align-top';

export const FeaturesToLookFor = ({ roundup }: FeaturesToLookForProps) => {
return (
<section>
<RoundupSectionHeader
title='Features to look for when comparing providers'
subtitle='Not every team needs every feature. Here is what each one does and why it might matter.'
/>

<div className='px-6 pb-20'>
<div className='max-w-5xl mx-auto overflow-x-auto border'>
<table className='w-full min-w-[640px] border-collapse'>
<thead>
<tr className='bg-gray-100 border-b'>
<th className={TH}>Feature</th>
<th className={TH}>What it does</th>
<th className={TH}>Why it matters</th>
</tr>
</thead>
<tbody>
{roundup.featuresToLookFor.map((row) => (
<tr key={row.feature} className='border-t first:border-t-0'>
<td className={`${TD} font-medium text-foreground whitespace-nowrap`}>
{row.feature}
</td>
<td className={`${TD} text-foreground`}>{row.whatItDoes}</td>
<td className={`${TD} text-muted-foreground`}>{row.whyItMatters}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</section>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { RiCheckboxCircleLine } from '@remixicon/react';
import type { RoundupPage } from '@/lib/roundupConfig';
import { RoundupSectionHeader } from './RoundupSectionHeader';

interface RoundupBenefitsProps {
roundup: RoundupPage;
}

export const RoundupBenefits = ({ roundup }: RoundupBenefitsProps) => {
return (
<section>
<RoundupSectionHeader title={roundup.benefits.title} />

<div className='px-6 pb-20'>
<div className='max-w-5xl mx-auto grid sm:grid-cols-2 lg:grid-cols-4 border-l border-t'>
{roundup.benefits.items.map((item) => (
<div key={item.title} className='border-r border-b p-6'>
<RiCheckboxCircleLine className='size-5 text-primary mb-3' aria-hidden='true' />
<h3 className='text-base font-semibold text-foreground mb-1.5'>{item.title}</h3>
<p className='text-sm text-muted-foreground leading-relaxed'>{item.description}</p>
</div>
))}
</div>
</div>
</section>
);
};
55 changes: 55 additions & 0 deletions apps/marketing/src/components/alternative/roundup/RoundupFAQ.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
'use client';

import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@coderscreen/ui/accordion';
import type { RoundupPage } from '@/lib/roundupConfig';

interface RoundupFAQProps {
roundup: RoundupPage;
}

export const RoundupFAQ = ({ roundup }: RoundupFAQProps) => {
const faqSchema = {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: roundup.faq.map((item) => ({
'@type': 'Question',
name: item.question,
acceptedAnswer: {
'@type': 'Answer',
text: item.answer,
},
})),
};

return (
<section className='border-t px-6 py-20'>
<script
type='application/ld+json'
// biome-ignore lint/security/noDangerouslySetInnerHtml: needed for SEO schema
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqSchema) }}
/>
<div className='max-w-3xl mx-auto'>
<div className='flex flex-col items-center gap-2 text-center mb-10'>
<h2 className='text-3xl font-semibold'>Frequently asked questions</h2>
<p className='text-muted-foreground'>
Common questions about choosing a {roundup.competitorName} alternative
</p>
</div>

<Accordion type='single' collapsible className='w-full'>
{roundup.faq.map((item, index) => (
<AccordionItem key={item.question} value={`item-${index}`}>
<AccordionTrigger className='text-left'>{item.question}</AccordionTrigger>
<AccordionContent>{item.answer}</AccordionContent>
</AccordionItem>
))}
</Accordion>
</div>
</section>
);
};
115 changes: 115 additions & 0 deletions apps/marketing/src/components/alternative/roundup/RoundupHero.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { Button } from '@coderscreen/ui/button';
import { RiArrowRightLine, RiGithubLine, RiStarFill } from '@remixicon/react';
import { HeroBg } from '@/components/landing/HeroBg';
import type { RoundupPage } from '@/lib/roundupConfig';
import { siteConfig } from '@/lib/siteConfig';
import { toolsDatabase } from '@/lib/toolsDatabase';
import { cx } from '@/lib/utils';
import { ToolLogo } from './ToolLogo';

interface RoundupHeroProps {
roundup: RoundupPage;
}

const LeaderboardCard = ({ roundup }: RoundupHeroProps) => {
const tools = roundup.toolSlugs.map((slug) => toolsDatabase[slug]).filter(Boolean);

return (
<div className='border bg-background shadow-sm'>
<div className='flex items-center justify-between px-5 py-3 border-b bg-gray-50'>
<span className='text-xs font-semibold uppercase tracking-wider text-muted-foreground'>
Our 2026 ranking
</span>
<span className='text-xs text-muted-foreground'>{tools.length} compared</span>
</div>

<ul>
{tools.map((tool, index) => (
<li
key={tool.slug}
className={cx(
'flex items-center gap-3 px-5 py-3 border-b last:border-b-0',
tool.isUs && 'bg-gray-50'
)}
>
<span className='w-4 text-sm font-semibold text-muted-foreground tabular-nums'>
{index + 1}
</span>
<ToolLogo tool={tool} className='size-8' />
<div className='flex-1 min-w-0'>
<div className='flex items-center gap-2'>
<span className='text-sm font-semibold text-foreground truncate'>{tool.name}</span>
{tool.isUs && (
<span className='inline-flex items-center gap-0.5 text-[10px] font-semibold uppercase tracking-wider text-primary'>
<RiStarFill className='size-2.5' />
Our pick
</span>
)}
</div>
</div>
<span className='text-sm font-medium text-foreground shrink-0'>
{tool.rating ? (
<>
{tool.rating.score.toFixed(1)}
<span className='text-muted-foreground font-normal'> / 5</span>
</>
) : (
<span className='text-muted-foreground font-normal'>{tool.isUs ? 'New' : '—'}</span>
)}
</span>
</li>
))}
</ul>
</div>
);
};

export const RoundupHero = ({ roundup }: RoundupHeroProps) => {
return (
<div className='relative overflow-hidden border-b'>
<div className='absolute inset-0 pointer-events-none'>
<HeroBg />
</div>

<section className='relative z-10 max-w-6xl mx-auto px-6 py-16 md:py-24'>
<div className='grid lg:grid-cols-2 gap-12 lg:gap-16 items-center'>
{/* Left: copy */}
<div>
<h1 className='text-4xl md:text-5xl font-bold leading-tight mb-5'>
{roundup.hero.title}
</h1>
<p className='text-lg text-muted-foreground leading-relaxed mb-8'>
{roundup.hero.intro}
</p>
<div className='flex flex-col sm:flex-row gap-4'>
<a href={siteConfig.external.getStarted}>
<Button
icon={RiArrowRightLine}
iconPosition='right'
variant='primary'
className='px-6 py-2 text-base font-semibold'
>
Try CoderScreen for free
</Button>
</a>
<a href={siteConfig.external.githubRepo}>
<Button
icon={RiGithubLine}
variant='secondary'
className='px-6 py-2 text-base font-semibold'
>
Star us on GitHub
</Button>
</a>
</div>
</div>

{/* Right: ranked shortlist */}
<div className='lg:pl-4'>
<LeaderboardCard roundup={roundup} />
</div>
</div>
</section>
</div>
);
};
Loading
Loading