SEO: compare/guide pSEO pages + internal links + intent differentiation
This commit is contained in:
416
src/app/(main)/(marketing)/compare/[slug]/page.tsx
Normal file
416
src/app/(main)/(marketing)/compare/[slug]/page.tsx
Normal file
@@ -0,0 +1,416 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ArrowRight, Check, Lock, RefreshCw } from "lucide-react";
|
||||
|
||||
import Breadcrumbs, { type BreadcrumbItem } from "@/components/Breadcrumbs";
|
||||
import SeoJsonLd from "@/components/SeoJsonLd";
|
||||
import { getComparisonPage } from "@/lib/comparison-pages";
|
||||
import type { ComparisonPage } from "@/lib/pseo-page-types";
|
||||
import {
|
||||
isPublishedComparisonSlug,
|
||||
publishedComparisonPages,
|
||||
} from "@/lib/pseo-published-pages";
|
||||
import { articleSchema, breadcrumbSchema, faqPageSchema } from "@/lib/schema";
|
||||
|
||||
const SITE_URL = "https://www.qrmaster.net";
|
||||
const LAST_MODIFIED = "2026-06-22";
|
||||
|
||||
type PageProps = {
|
||||
params: {
|
||||
slug: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const dynamicParams = false;
|
||||
|
||||
export function generateStaticParams() {
|
||||
return publishedComparisonPages.map((page) => ({ slug: page.slug }));
|
||||
}
|
||||
|
||||
function getPublishedPage(slug: string): ComparisonPage {
|
||||
if (!isPublishedComparisonSlug(slug)) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const page = getComparisonPage(slug);
|
||||
if (!page) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return page;
|
||||
}
|
||||
|
||||
function titleFor(page: ComparisonPage) {
|
||||
if (page.slug === "dynamic-vs-static-qr-codes") {
|
||||
return "Dynamic or Static QR Codes: Which to Choose for Your Campaign";
|
||||
}
|
||||
|
||||
if (page.slug === "free-vs-paid-qr-code-generator") {
|
||||
return "Free vs Paid QR Code Generator";
|
||||
}
|
||||
|
||||
return page.title;
|
||||
}
|
||||
|
||||
function descriptionFor(page: ComparisonPage) {
|
||||
if (page.slug === "dynamic-vs-static-qr-codes") {
|
||||
return "Decide between dynamic and static QR codes for your next campaign: a buyer-focused comparison of reprint risk, tracking, and cost before you commit to print.";
|
||||
}
|
||||
|
||||
if (page.slug === "free-vs-paid-qr-code-generator") {
|
||||
return "See when a free QR generator is enough and when paid dynamic QR features, tracking, branding, and reprint savings are worth it.";
|
||||
}
|
||||
|
||||
return page.description;
|
||||
}
|
||||
|
||||
function intentLabel(intentStage: ComparisonPage["intentStage"]) {
|
||||
if (intentStage === "bottom") return "Commercial investigation";
|
||||
if (intentStage === "middle") return "Comparison research";
|
||||
return "Educational comparison";
|
||||
}
|
||||
|
||||
export function generateMetadata({ params }: PageProps): Metadata {
|
||||
const page = getPublishedPage(params.slug);
|
||||
const title = titleFor(page);
|
||||
const description = descriptionFor(page);
|
||||
const canonical = `${SITE_URL}${page.canonicalPath}`;
|
||||
|
||||
return {
|
||||
title: {
|
||||
absolute: title,
|
||||
},
|
||||
description,
|
||||
alternates: {
|
||||
canonical,
|
||||
languages: {
|
||||
"x-default": canonical,
|
||||
en: canonical,
|
||||
},
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
},
|
||||
icons: {
|
||||
icon: [{ url: "/favicon1.png", type: "image/png" }],
|
||||
shortcut: "/favicon1.png",
|
||||
apple: "/favicon1.png",
|
||||
},
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
url: canonical,
|
||||
type: "article",
|
||||
images: ["/og-image.png"],
|
||||
},
|
||||
twitter: {
|
||||
title,
|
||||
description,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function comparisonItemListSchema(page: ComparisonPage) {
|
||||
return {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "ItemList",
|
||||
"@id": `${SITE_URL}${page.canonicalPath}#comparison`,
|
||||
name: `${page.primaryEntity} compared with ${page.secondaryEntity}`,
|
||||
numberOfItems: page.scorecard.length,
|
||||
itemListElement: page.scorecard.map((row, index) => ({
|
||||
"@type": "ListItem",
|
||||
position: index + 1,
|
||||
name: row.topic,
|
||||
description: `${page.primaryEntity}: ${row.qrMaster}. ${page.secondaryEntity}: ${row.alternative}.`,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function breadcrumbsFor(page: ComparisonPage): BreadcrumbItem[] {
|
||||
return [
|
||||
{ name: "Home", url: "/" },
|
||||
{ name: page.title, url: page.canonicalPath },
|
||||
];
|
||||
}
|
||||
|
||||
function DynamicFlowVisual() {
|
||||
return (
|
||||
<div className="relative overflow-hidden rounded-lg border border-[oklch(90%_0.018_270)] bg-[oklch(98%_0.006_270)] p-5 shadow-[0_28px_48px_-36px_rgba(50,50,93,0.32)]">
|
||||
<div className="grid gap-4 sm:grid-cols-[1fr_auto_1fr] sm:items-center">
|
||||
<div className="rounded-md border border-[oklch(88%_0.018_270)] bg-[oklch(99%_0.004_270)] p-4">
|
||||
<div className="mb-4 grid size-24 grid-cols-5 gap-1" aria-hidden="true">
|
||||
{Array.from({ length: 25 }).map((_, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className={
|
||||
[0, 1, 3, 5, 6, 8, 10, 12, 14, 16, 18, 20, 21, 22, 24].includes(index)
|
||||
? "bg-[oklch(20%_0.035_270)]"
|
||||
: "bg-[oklch(94%_0.01_270)]"
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-[oklch(24%_0.04_270)]">
|
||||
Printed QR
|
||||
</div>
|
||||
<p className="mt-1 text-sm leading-6 text-[oklch(46%_0.035_270)]">
|
||||
The physical code stays in place.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-md border border-[oklch(84%_0.06_275)] bg-[oklch(96%_0.035_275)] text-[oklch(48%_0.19_275)]">
|
||||
<RefreshCw className="h-5 w-5" aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-[oklch(88%_0.018_270)] bg-[oklch(99%_0.004_270)] p-4">
|
||||
<div className="mb-4 h-24 rounded-md border border-[oklch(89%_0.016_270)] bg-[oklch(97%_0.009_270)] p-3">
|
||||
<div className="mb-3 h-2 w-24 rounded-sm bg-[oklch(57%_0.18_275)]" />
|
||||
<div className="space-y-2">
|
||||
<div className="h-2 w-full rounded-sm bg-[oklch(86%_0.017_270)]" />
|
||||
<div className="h-2 w-3/4 rounded-sm bg-[oklch(88%_0.015_270)]" />
|
||||
<div className="h-2 w-1/2 rounded-sm bg-[oklch(90%_0.012_270)]" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-[oklch(24%_0.04_270)]">
|
||||
Current destination
|
||||
</div>
|
||||
<p className="mt-1 text-sm leading-6 text-[oklch(46%_0.035_270)]">
|
||||
The URL can change after print.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ComparisonPageRoute({ params }: PageProps) {
|
||||
const page = getPublishedPage(params.slug);
|
||||
const title = titleFor(page);
|
||||
const description = descriptionFor(page);
|
||||
const breadcrumbs = breadcrumbsFor(page);
|
||||
const canonical = `${SITE_URL}${page.canonicalPath}`;
|
||||
|
||||
const structuredData = [
|
||||
articleSchema({
|
||||
headline: title,
|
||||
description,
|
||||
datePublished: LAST_MODIFIED,
|
||||
dateModified: LAST_MODIFIED,
|
||||
author: "QR Master",
|
||||
url: canonical,
|
||||
image: `${SITE_URL}/og-image.png`,
|
||||
}),
|
||||
faqPageSchema(page.faq),
|
||||
breadcrumbSchema(breadcrumbs),
|
||||
comparisonItemListSchema(page),
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<SeoJsonLd data={structuredData} />
|
||||
<main className="min-h-screen bg-[oklch(98.5%_0.006_270)] text-[oklch(23%_0.04_270)]">
|
||||
<section className="border-b border-[oklch(91%_0.014_270)] bg-[oklch(99%_0.004_270)]">
|
||||
<div className="mx-auto max-w-7xl px-4 py-16 sm:px-6 lg:px-8 lg:py-20">
|
||||
<Breadcrumbs items={breadcrumbs} />
|
||||
<div className="grid gap-12 lg:grid-cols-[minmax(0,0.95fr)_minmax(420px,1fr)] lg:items-center">
|
||||
<div>
|
||||
<p className="mb-5 text-sm font-semibold uppercase tracking-[0.12em] text-[oklch(47%_0.18_275)]">
|
||||
{intentLabel(page.intentStage)}
|
||||
</p>
|
||||
<h1 className="max-w-3xl text-balance text-4xl font-light leading-[1.05] tracking-normal text-[oklch(22%_0.045_270)] sm:text-5xl lg:text-6xl">
|
||||
{title}
|
||||
</h1>
|
||||
<p className="mt-6 max-w-2xl text-lg leading-8 text-[oklch(46%_0.035_270)]">
|
||||
{page.quickAnswer}
|
||||
</p>
|
||||
<div className="mt-8 flex flex-col gap-3 sm:flex-row">
|
||||
<Link
|
||||
href={page.primaryCta.href}
|
||||
className="inline-flex min-h-11 items-center justify-center rounded-md bg-[oklch(50%_0.22_275)] px-5 text-sm font-semibold text-[oklch(99%_0.004_270)] transition hover:bg-[oklch(43%_0.2_275)] focus:outline-none focus:ring-2 focus:ring-[oklch(50%_0.22_275)] focus:ring-offset-2"
|
||||
>
|
||||
{page.primaryCta.label}
|
||||
</Link>
|
||||
<Link
|
||||
href={page.secondaryCta.href}
|
||||
className="inline-flex min-h-11 items-center justify-center rounded-md border border-[oklch(79%_0.08_275)] px-5 text-sm font-semibold text-[oklch(45%_0.18_275)] transition hover:bg-[oklch(96%_0.025_275)] focus:outline-none focus:ring-2 focus:ring-[oklch(50%_0.22_275)] focus:ring-offset-2"
|
||||
>
|
||||
{page.secondaryCta.label}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<DynamicFlowVisual />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mx-auto max-w-7xl px-4 py-14 sm:px-6 lg:px-8">
|
||||
<div className="grid gap-8 lg:grid-cols-[0.85fr_1.15fr]">
|
||||
<div>
|
||||
<h2 className="text-3xl font-light tracking-normal text-[oklch(22%_0.045_270)]">
|
||||
The short answer
|
||||
</h2>
|
||||
<p className="mt-4 max-w-[65ch] text-base leading-8 text-[oklch(45%_0.035_270)]">
|
||||
{page.intro} {page.quickAnswer}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-[oklch(90%_0.018_270)] bg-[oklch(99%_0.004_270)] p-5 shadow-[0_24px_42px_-34px_rgba(50,50,93,0.28)]">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[680px] text-left text-sm">
|
||||
<caption className="sr-only">
|
||||
Comparison of {page.primaryEntity} and {page.secondaryEntity}
|
||||
</caption>
|
||||
<thead>
|
||||
<tr className="border-b border-[oklch(89%_0.016_270)]">
|
||||
<th className="py-3 pr-4 font-semibold text-[oklch(28%_0.04_270)]">
|
||||
Decision point
|
||||
</th>
|
||||
<th className="px-4 py-3 font-semibold text-[oklch(28%_0.04_270)]">
|
||||
{page.secondaryEntity}
|
||||
</th>
|
||||
<th className="px-4 py-3 font-semibold text-[oklch(45%_0.18_275)]">
|
||||
{page.primaryEntity}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[oklch(92%_0.012_270)]">
|
||||
{page.scorecard.map((row) => (
|
||||
<tr key={row.topic}>
|
||||
<th className="py-4 pr-4 align-top font-medium text-[oklch(28%_0.04_270)]">
|
||||
{row.topic}
|
||||
</th>
|
||||
<td className="px-4 py-4 align-top leading-6 text-[oklch(47%_0.03_270)]">
|
||||
{row.alternative}
|
||||
</td>
|
||||
<td className="px-4 py-4 align-top leading-6 text-[oklch(35%_0.05_270)]">
|
||||
{row.qrMaster}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="border-y border-[oklch(91%_0.014_270)] bg-[oklch(19%_0.055_270)] py-16 text-[oklch(96%_0.01_270)]">
|
||||
<div className="mx-auto grid max-w-7xl gap-8 px-4 sm:px-6 lg:grid-cols-3 lg:px-8">
|
||||
<div>
|
||||
<h2 className="text-3xl font-light leading-tight tracking-normal">
|
||||
Which option should you choose?
|
||||
</h2>
|
||||
<p className="mt-4 leading-7 text-[oklch(78%_0.025_270)]">
|
||||
Use this page as a buying decision, not as a feature glossary.
|
||||
The right answer depends on whether the printed asset must
|
||||
survive a future destination change.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-[oklch(35%_0.06_270)] bg-[oklch(23%_0.058_270)] p-6">
|
||||
<Lock className="mb-5 h-5 w-5 text-[oklch(78%_0.05_270)]" aria-hidden="true" />
|
||||
<h3 className="text-xl font-light">Choose the simpler option when...</h3>
|
||||
<ul className="mt-5 space-y-3 text-sm leading-6 text-[oklch(80%_0.022_270)]">
|
||||
{page.bestForAlternative.map((item) => (
|
||||
<li key={item}>- {item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="rounded-lg border border-[oklch(54%_0.16_275)] bg-[oklch(26%_0.08_275)] p-6">
|
||||
<Check className="mb-5 h-5 w-5 text-[oklch(82%_0.13_150)]" aria-hidden="true" />
|
||||
<h3 className="text-xl font-light">Choose QR Master when...</h3>
|
||||
<ul className="mt-5 space-y-3 text-sm leading-6 text-[oklch(88%_0.018_270)]">
|
||||
{page.bestForQrMaster.map((item) => (
|
||||
<li key={item}>- {item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mx-auto max-w-7xl px-4 py-16 sm:px-6 lg:px-8">
|
||||
<div className="grid gap-10 lg:grid-cols-[0.75fr_1.25fr]">
|
||||
<div>
|
||||
<h2 className="text-3xl font-light tracking-normal text-[oklch(22%_0.045_270)]">
|
||||
Migration steps
|
||||
</h2>
|
||||
<p className="mt-4 leading-8 text-[oklch(45%_0.035_270)]">
|
||||
If this comparison changes your decision, move the highest-risk
|
||||
printed assets first. That keeps the work focused on real
|
||||
reprint and tracking value.
|
||||
</p>
|
||||
</div>
|
||||
<ol className="grid gap-4">
|
||||
{page.migrationSteps.map((step, index) => (
|
||||
<li
|
||||
key={step}
|
||||
className="grid gap-4 rounded-lg border border-[oklch(90%_0.018_270)] bg-[oklch(99%_0.004_270)] p-5 shadow-[0_16px_30px_-28px_rgba(50,50,93,0.28)] sm:grid-cols-[3rem_1fr]"
|
||||
>
|
||||
<span className="flex h-10 w-10 items-center justify-center rounded-md bg-[oklch(94%_0.04_275)] text-sm font-semibold tabular-nums text-[oklch(45%_0.18_275)]">
|
||||
{index + 1}
|
||||
</span>
|
||||
<p className="leading-7 text-[oklch(39%_0.035_270)]">{step}</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="bg-[oklch(99%_0.004_270)] py-16">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="mb-10 max-w-3xl">
|
||||
<h2 className="text-3xl font-light tracking-normal text-[oklch(22%_0.045_270)]">
|
||||
Related QR Master workflows
|
||||
</h2>
|
||||
<p className="mt-4 leading-8 text-[oklch(45%_0.035_270)]">
|
||||
These links connect the comparison to creation, analytics,
|
||||
pricing, and operational guidance.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{page.relatedLinks.map((link) => (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className="group rounded-lg border border-[oklch(90%_0.018_270)] bg-[oklch(98%_0.006_270)] p-5 transition hover:border-[oklch(70%_0.1_275)] hover:bg-[oklch(97%_0.018_275)]"
|
||||
>
|
||||
<h3 className="font-semibold text-[oklch(26%_0.045_270)]">
|
||||
{link.title}
|
||||
</h3>
|
||||
<p className="mt-3 text-sm leading-6 text-[oklch(46%_0.035_270)]">
|
||||
{link.description}
|
||||
</p>
|
||||
<span className="mt-5 inline-flex items-center gap-2 text-sm font-semibold text-[oklch(45%_0.18_275)]">
|
||||
Open page
|
||||
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mx-auto max-w-4xl px-4 py-16 sm:px-6 lg:px-8">
|
||||
<h2 className="text-3xl font-light tracking-normal text-[oklch(22%_0.045_270)]">
|
||||
Common questions
|
||||
</h2>
|
||||
<div className="mt-8 divide-y divide-[oklch(90%_0.018_270)] rounded-lg border border-[oklch(90%_0.018_270)] bg-[oklch(99%_0.004_270)]">
|
||||
{page.faq.map((item) => (
|
||||
<details key={item.question} className="group p-5">
|
||||
<summary className="flex cursor-pointer list-none items-center justify-between gap-5 font-semibold text-[oklch(26%_0.045_270)]">
|
||||
{item.question}
|
||||
<span className="text-[oklch(45%_0.18_275)] group-open:rotate-90">+</span>
|
||||
</summary>
|
||||
<p className="mt-4 max-w-[65ch] leading-7 text-[oklch(45%_0.035_270)]">
|
||||
{item.answer}
|
||||
</p>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user