feat: add per-platform SEO landing pages (/youtube-downloader, etc.)
Create a dedicated, dynamically-routed page for each of 15 curated platforms (YouTube, TikTok, Instagram, Facebook, X, Reddit, Pinterest, Vimeo, SoundCloud, Twitch, Dailymotion, LinkedIn, Tumblr, VK, Snapchat) under /[locale]/[slug], with a tailored hero, an example URL matched to the platform, a unique "about" paragraph, and a mix of platform-specific and shared FAQ items, all translated across en/fr/es/it. Unknown slugs 404. Kept the scope to a curated list rather than all 1380 yt-dlp extractors to avoid thin/duplicate "doorway page" content that search engines penalize. The homepage's platform badges and the "+1000 sites" links now point to these pages, extracted the FAQ accordion markup into a shared FaqAccordion component reused by both the homepage FAQ and the new platform pages. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { getTranslations } from 'next-intl/server'
|
||||
import { routing } from '@/i18n/routing'
|
||||
import { Link } from '@/navigation'
|
||||
import { getDownloaderPlatformBySlug } from '@/lib/downloader-platforms'
|
||||
import { SubmitForm } from '@/components/SubmitForm'
|
||||
import { ReassuranceSection } from '@/components/ReassuranceSection'
|
||||
import { HowItWorksSection } from '@/components/HowItWorksSection'
|
||||
import { FaqAccordion } from '@/components/FaqAccordion'
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string; slug: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale, slug } = await params
|
||||
const platform = getDownloaderPlatformBySlug(slug)
|
||||
if (!platform) return {}
|
||||
|
||||
const t = await getTranslations({ locale, namespace: 'downloaderPage' })
|
||||
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL ?? ''
|
||||
|
||||
return {
|
||||
title: t('metaTitle', { platform: platform.name }),
|
||||
description: t('metaDescription', { platform: platform.name }),
|
||||
alternates: {
|
||||
canonical: `${baseUrl}/${locale}/${slug}`,
|
||||
languages: Object.fromEntries(
|
||||
routing.locales.map((l) => [l, `/${l}/${slug}`])
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default async function DownloaderPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string; slug: string }>
|
||||
}) {
|
||||
const { locale, slug } = await params
|
||||
const platform = getDownloaderPlatformBySlug(slug)
|
||||
if (!platform) notFound()
|
||||
|
||||
const t = await getTranslations({ locale, namespace: 'downloaderPage' })
|
||||
const tPlatform = await getTranslations({ locale, namespace: `downloaderPages.${platform.id}` })
|
||||
const tFaq = await getTranslations({ locale, namespace: 'faq' })
|
||||
|
||||
const faqItems = [
|
||||
{ q: tPlatform('faqQ1'), a: tPlatform('faqA1') },
|
||||
{ q: tPlatform('faqQ2'), a: tPlatform('faqA2') },
|
||||
{ q: tFaq('q1'), a: tFaq('a1') },
|
||||
{ q: tFaq('q2'), a: tFaq('a2') },
|
||||
]
|
||||
|
||||
const jsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: faqItems.map(({ q, a }) => ({
|
||||
'@type': 'Question',
|
||||
name: q,
|
||||
acceptedAnswer: { '@type': 'Answer', text: a },
|
||||
})),
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
<section
|
||||
className="flex flex-col items-center justify-center px-6 pt-20 pb-16 text-center"
|
||||
aria-labelledby="hero-heading"
|
||||
>
|
||||
<h1
|
||||
id="hero-heading"
|
||||
className="text-4xl md:text-5xl font-bold tracking-tight text-gray-900 dark:text-gray-50 mb-4 max-w-2xl"
|
||||
>
|
||||
{t('heading', { platform: platform.name })}
|
||||
</h1>
|
||||
<p className="text-lg text-gray-500 dark:text-gray-400 mb-10">
|
||||
{t('subheading', { platform: platform.name })}
|
||||
</p>
|
||||
<SubmitForm examplePlaceholder={platform.exampleUrl} />
|
||||
</section>
|
||||
|
||||
<ReassuranceSection locale={locale} />
|
||||
<HowItWorksSection locale={locale} />
|
||||
|
||||
<section className="py-16 px-6" aria-labelledby="about-heading">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<h2 id="about-heading" className="text-2xl md:text-3xl font-bold tracking-tight text-gray-900 dark:text-gray-50 text-center mb-6">
|
||||
{t('aboutHeading', { platform: platform.name })}
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 text-center leading-relaxed">
|
||||
{tPlatform('about')}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="py-16 px-6 bg-gray-50 dark:bg-slate-900/50" aria-labelledby="faq-heading">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<h2 id="faq-heading" className="text-2xl md:text-3xl font-bold tracking-tight text-gray-900 dark:text-gray-50 text-center mb-10">
|
||||
{t('faqHeading', { platform: platform.name })}
|
||||
</h2>
|
||||
<FaqAccordion items={faqItems} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="pb-16 px-6 text-center">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('otherPlatforms')}{' '}
|
||||
<Link href="/supported-sites" className="text-violet-600 dark:text-violet-400 hover:underline">
|
||||
{t('browseAll')}
|
||||
</Link>
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
function ChevronIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden
|
||||
className="shrink-0 transition-transform duration-200 group-open:rotate-180"
|
||||
>
|
||||
<path d="M6 9l6 6 6-6" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function FaqAccordion({ items }: { items: { q: string; a: ReactNode }[] }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{items.map(({ q, a }) => (
|
||||
<details
|
||||
key={q}
|
||||
className="group rounded-xl border border-gray-200 dark:border-slate-700 bg-white dark:bg-slate-900 px-5 py-4"
|
||||
>
|
||||
<summary className="flex items-center justify-between gap-4 cursor-pointer select-none font-medium text-gray-900 dark:text-gray-50 list-none [&::-webkit-details-marker]:hidden">
|
||||
{q}
|
||||
<ChevronIcon />
|
||||
</summary>
|
||||
<p className="mt-3 text-sm text-gray-500 dark:text-gray-400">{a}</p>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getTranslations } from 'next-intl/server'
|
||||
import { Link } from '@/navigation'
|
||||
import { FaqAccordion } from '@/components/FaqAccordion'
|
||||
|
||||
const QUESTION_COUNT = 8
|
||||
|
||||
@@ -7,25 +8,6 @@ function stripRichTags(text: string) {
|
||||
return text.replace(/<link>(.*?)<\/link>/g, '$1')
|
||||
}
|
||||
|
||||
function ChevronIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden
|
||||
className="shrink-0 transition-transform duration-200 group-open:rotate-180"
|
||||
>
|
||||
<path d="M6 9l6 6 6-6" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export async function FaqSection({ locale }: { locale: string }) {
|
||||
const t = await getTranslations({ locale, namespace: 'faq' })
|
||||
|
||||
@@ -34,7 +16,7 @@ export async function FaqSection({ locale }: { locale: string }) {
|
||||
return {
|
||||
q: t(`q${i + 1}`),
|
||||
aText: stripRichTags(t.raw(key) as string),
|
||||
aNode: t.rich(key, {
|
||||
a: t.rich(key, {
|
||||
link: (chunks) => (
|
||||
<Link href="/supported-sites" className="text-violet-600 dark:text-violet-400 hover:underline">
|
||||
{chunks}
|
||||
@@ -64,20 +46,7 @@ export async function FaqSection({ locale }: { locale: string }) {
|
||||
<h2 id="faq-heading" className="text-2xl md:text-3xl font-bold tracking-tight text-gray-900 dark:text-gray-50 text-center mb-10">
|
||||
{t('heading')}
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{items.map(({ q, aNode }) => (
|
||||
<details
|
||||
key={q}
|
||||
className="group rounded-xl border border-gray-200 dark:border-slate-700 bg-white dark:bg-slate-900 px-5 py-4"
|
||||
>
|
||||
<summary className="flex items-center justify-between gap-4 cursor-pointer select-none font-medium text-gray-900 dark:text-gray-50 list-none [&::-webkit-details-marker]:hidden">
|
||||
{q}
|
||||
<ChevronIcon />
|
||||
</summary>
|
||||
<p className="mt-3 text-sm text-gray-500 dark:text-gray-400">{aNode}</p>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
<FaqAccordion items={items} />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -19,7 +19,7 @@ function Spinner() {
|
||||
const selectClass =
|
||||
'rounded-lg border border-gray-200 dark:border-slate-700 bg-white dark:bg-slate-900 px-2 py-1 text-gray-900 dark:text-gray-50 text-sm outline-none focus:ring-2 focus:ring-violet-500'
|
||||
|
||||
export function SubmitForm() {
|
||||
export function SubmitForm({ examplePlaceholder }: { examplePlaceholder?: string } = {}) {
|
||||
const t = useTranslations('home')
|
||||
const router = useRouter()
|
||||
const [url, setUrl] = useState('')
|
||||
@@ -75,7 +75,7 @@ export function SubmitForm() {
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
required
|
||||
placeholder="https://www.youtube.com/watch?v=..."
|
||||
placeholder={examplePlaceholder ?? 'https://www.youtube.com/watch?v=...'}
|
||||
className="flex-1 px-4 py-3 bg-transparent text-sm outline-none placeholder:text-gray-400 dark:placeholder:text-slate-500"
|
||||
/>
|
||||
<button
|
||||
|
||||
@@ -1,17 +1,6 @@
|
||||
import { getTranslations } from 'next-intl/server'
|
||||
import { Link } from '@/navigation'
|
||||
|
||||
const PLATFORMS = [
|
||||
'YouTube',
|
||||
'TikTok',
|
||||
'Instagram',
|
||||
'Facebook',
|
||||
'X (Twitter)',
|
||||
'Vimeo',
|
||||
'Twitch',
|
||||
'SoundCloud',
|
||||
'Dailymotion',
|
||||
]
|
||||
import { DOWNLOADER_PLATFORMS } from '@/lib/downloader-platforms'
|
||||
|
||||
export async function SupportedPlatformsSection({ locale }: { locale: string }) {
|
||||
const t = await getTranslations({ locale, namespace: 'platforms' })
|
||||
@@ -25,13 +14,14 @@ export async function SupportedPlatformsSection({ locale }: { locale: string })
|
||||
<p className="mt-2 text-gray-500 dark:text-gray-400">{t('subheading')}</p>
|
||||
|
||||
<div className="mt-8 flex flex-wrap justify-center gap-3">
|
||||
{PLATFORMS.map((name) => (
|
||||
<span
|
||||
key={name}
|
||||
className="px-4 py-2 rounded-full border border-gray-200 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
{DOWNLOADER_PLATFORMS.map(({ id, slug, name }) => (
|
||||
<Link
|
||||
key={id}
|
||||
href={`/${slug}`}
|
||||
className="px-4 py-2 rounded-full border border-gray-200 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm font-medium text-gray-700 dark:text-gray-300 hover:border-violet-300 dark:hover:border-violet-700 hover:text-violet-600 dark:hover:text-violet-400 transition-colors"
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-4 text-sm text-gray-400 dark:text-slate-500">
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
export type DownloaderPlatform = {
|
||||
id: string
|
||||
slug: string
|
||||
name: string
|
||||
exampleUrl: string
|
||||
}
|
||||
|
||||
export const DOWNLOADER_PLATFORMS: DownloaderPlatform[] = [
|
||||
{ id: 'youtube', slug: 'youtube-downloader', name: 'YouTube', exampleUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' },
|
||||
{ id: 'tiktok', slug: 'tiktok-downloader', name: 'TikTok', exampleUrl: 'https://www.tiktok.com/@username/video/1234567890123456789' },
|
||||
{ id: 'instagram', slug: 'instagram-downloader', name: 'Instagram', exampleUrl: 'https://www.instagram.com/reel/Cxxxxxxxxxx/' },
|
||||
{ id: 'facebook', slug: 'facebook-downloader', name: 'Facebook', exampleUrl: 'https://www.facebook.com/watch/?v=1234567890123456' },
|
||||
{ id: 'twitter', slug: 'twitter-downloader', name: 'X (Twitter)', exampleUrl: 'https://x.com/username/status/1234567890123456789' },
|
||||
{ id: 'reddit', slug: 'reddit-downloader', name: 'Reddit', exampleUrl: 'https://www.reddit.com/r/videos/comments/abc123/example_post/' },
|
||||
{ id: 'pinterest', slug: 'pinterest-downloader', name: 'Pinterest', exampleUrl: 'https://www.pinterest.com/pin/1234567890123456789/' },
|
||||
{ id: 'vimeo', slug: 'vimeo-downloader', name: 'Vimeo', exampleUrl: 'https://vimeo.com/123456789' },
|
||||
{ id: 'soundcloud', slug: 'soundcloud-downloader', name: 'SoundCloud', exampleUrl: 'https://soundcloud.com/artist/track-name' },
|
||||
{ id: 'twitch', slug: 'twitch-downloader', name: 'Twitch', exampleUrl: 'https://www.twitch.tv/videos/1234567890' },
|
||||
{ id: 'dailymotion', slug: 'dailymotion-downloader', name: 'Dailymotion', exampleUrl: 'https://www.dailymotion.com/video/x8abcde' },
|
||||
{ id: 'linkedin', slug: 'linkedin-downloader', name: 'LinkedIn', exampleUrl: 'https://www.linkedin.com/posts/username_activity-1234567890123456789' },
|
||||
{ id: 'tumblr', slug: 'tumblr-downloader', name: 'Tumblr', exampleUrl: 'https://username.tumblr.com/post/1234567890123' },
|
||||
{ id: 'vk', slug: 'vk-downloader', name: 'VK', exampleUrl: 'https://vk.com/video-12345678_123456789' },
|
||||
{ id: 'snapchat', slug: 'snapchat-downloader', name: 'Snapchat', exampleUrl: 'https://www.snapchat.com/spotlight/Xxxxxxxxxxxxxxxxxxxxxx' },
|
||||
]
|
||||
|
||||
export function getDownloaderPlatformBySlug(slug: string): DownloaderPlatform | undefined {
|
||||
return DOWNLOADER_PLATFORMS.find((platform) => platform.slug === slug)
|
||||
}
|
||||
Reference in New Issue
Block a user