diff --git a/apps/landing/src/app/features/page.tsx b/apps/landing/src/app/features/page.tsx
index aac66a6..6a3fe0c 100644
--- a/apps/landing/src/app/features/page.tsx
+++ b/apps/landing/src/app/features/page.tsx
@@ -19,14 +19,20 @@ import {
} from "lucide-react";
import { Section, SectionHeader, FeatureCard, Screenshot, CTA } from "@/components/sections";
import { OPENCOM_GITHUB_DOCS_URL, OPENCOM_HOSTED_ONBOARDING_URL } from "@/lib/links";
-import { createLandingPageMetadata } from "@/lib/metadata";
-export const metadata: Metadata = createLandingPageMetadata({
+import { InboxGraphic } from "@/components/landing/graphics/inbox-graphic";
+import { ToursGraphic } from "@/components/landing/graphics/tours-graphic";
+import { OutboundGraphic } from "@/components/landing/graphics/outbound-graphic";
+import { TicketsGraphic } from "@/components/landing/graphics/tickets-graphic";
+import { SurveysGraphic } from "@/components/landing/graphics/surveys-graphic";
+import { CampaignsGraphic } from "@/components/landing/graphics/campaigns-graphic";
+import { ReportsGraphic } from "@/components/landing/graphics/reports-graphic";
+
+export const metadata: Metadata = {
title: "Features | Opencom",
description:
"Explore Opencom features across chat, inbox, tours, knowledge base, tickets, surveys, campaigns, reports, and mobile SDKs.",
- path: "/features",
-});
+};
const featureCategories = [
{
@@ -35,7 +41,7 @@ const featureCategories = [
description:
"Real-time customer conversations with a shared team inbox and embeddable chat widget.",
icon: MessageCircle,
- screenshot: "/screenshots/web-inbox.png",
+ Graphic: InboxGraphic,
features: [
"Real-time messaging with typing indicators",
"Shared team inbox with snooze and assignment",
@@ -50,7 +56,7 @@ const featureCategories = [
title: "Product Tours",
description: "Guide users through your product with interactive walkthroughs.",
icon: Map,
- screenshot: "/screenshots/web-tours.png",
+ Graphic: ToursGraphic,
features: [
"Step-by-step guided tours",
"Pointer steps and post steps",
@@ -80,7 +86,7 @@ const featureCategories = [
title: "Outbound Messages",
description: "Proactively engage users with in-app chats, posts, and banners.",
icon: Send,
- screenshot: "/screenshots/web-outbound.png",
+ Graphic: OutboundGraphic,
features: [
"In-app chat messages",
"Post announcements",
@@ -95,7 +101,7 @@ const featureCategories = [
title: "Tickets",
description: "Customer support ticketing with priorities, statuses, and custom forms.",
icon: Ticket,
- screenshot: "/screenshots/web-tickets.png",
+ Graphic: TicketsGraphic,
features: [
"Priority levels (Urgent, High, Normal, Low)",
"Status tracking (Submitted, In Progress, Resolved)",
@@ -110,7 +116,7 @@ const featureCategories = [
title: "Surveys",
description: "Collect feedback and measure customer sentiment.",
icon: ClipboardCheck,
- screenshot: "/screenshots/web-surveys.png",
+ Graphic: SurveysGraphic,
features: [
"NPS surveys",
"Custom satisfaction surveys",
@@ -125,7 +131,7 @@ const featureCategories = [
title: "Campaigns",
description: "Orchestrate multi-channel outreach campaigns.",
icon: Mail,
- screenshot: "/screenshots/web-campaigns.png",
+ Graphic: CampaignsGraphic,
features: [
"Email campaigns with templates",
"Push notifications",
@@ -170,7 +176,7 @@ const featureCategories = [
title: "Reports & Analytics",
description: "Analytics and insights for your support operations.",
icon: BarChart3,
- screenshot: "/screenshots/web-reports.png",
+ Graphic: ReportsGraphic,
features: [
"Conversation volume metrics",
"Response and resolution times",
@@ -257,7 +263,18 @@ export default function FeaturesPage() {
-
+ {category.Graphic ? (
+
+ {/* Subtle inner glow */}
+
+
+
+
+
+
+ ) : (
+
+ )}
diff --git a/apps/landing/src/components/landing/features.tsx b/apps/landing/src/components/landing/features.tsx
index 9e0ba5f..4476dac 100644
--- a/apps/landing/src/components/landing/features.tsx
+++ b/apps/landing/src/components/landing/features.tsx
@@ -1,7 +1,6 @@
"use client";
import { motion, Variants } from "framer-motion";
-import { memo } from "react";
import {
ChatsCircle,
MapTrifold,
@@ -11,6 +10,13 @@ import {
ChartLineUp,
} from "@phosphor-icons/react";
+import { InboxGraphic } from "./graphics/inbox-graphic";
+import { ToursGraphic } from "./graphics/tours-graphic";
+import { TicketsGraphic } from "./graphics/tickets-graphic";
+import { AIAgentGraphic } from "./graphics/ai-graphic";
+import { CampaignsGraphic } from "./graphics/campaigns-graphic";
+import { ReportsGraphic } from "./graphics/reports-graphic";
+
const container: Variants = {
hidden: { opacity: 0 },
show: {
@@ -24,70 +30,48 @@ const item: Variants = {
show: { opacity: 1, y: 0, transition: { type: "spring", stiffness: 80, damping: 20 } },
};
-// Perpetual Micro-Interaction for Icon Cards
-const PerpetualIcon = memo(function PerpetualIcon({
- icon: Icon,
- delay = 0,
-}: {
- icon: React.ElementType;
- delay?: number;
-}) {
- return (
-
-
-
-
- );
-});
-
const features = [
{
title: "Shared Inbox",
description:
"Multi-channel support inbox tailored for modern teams. Route, assign, and resolve effortlessly.",
icon: ChatsCircle,
+ Graphic: InboxGraphic,
},
{
title: "Product Tours",
description:
"Guide users through your app with native, beautiful onboarding tours that drive activation.",
icon: MapTrifold,
+ Graphic: ToursGraphic,
},
{
title: "Support Tickets",
description:
"Track complex issues alongside real-time chat. Seamlessly convert conversations to tickets.",
icon: Ticket,
+ Graphic: TicketsGraphic,
},
{
title: "AI Agent",
description:
"Deploy an intelligent agent trained on your docs to instantly resolve common queries 24/7.",
icon: Robot,
+ Graphic: AIAgentGraphic,
},
{
title: "Outbound Campaigns",
description:
"Trigger targeted in-app messages and emails based on user behavior and segment rules.",
icon: Megaphone,
+ Graphic: CampaignsGraphic,
},
{
title: "Analytics",
description:
"Deep insights into team performance, resolution times, and customer satisfaction metrics.",
icon: ChartLineUp,
+ Graphic: ReportsGraphic,
},
];
@@ -147,15 +131,12 @@ export function Features() {
{/* Bento Container */}
{/* Subtle inner glow */}
-
-
- {/* Abstract background blobs */}
-
-
-
+
+
+ {/* Full bleed graphic */}
+
+
-
-
{/* External Labels (Gallery Style) */}
diff --git a/apps/landing/src/components/landing/graphics/ai-graphic.tsx b/apps/landing/src/components/landing/graphics/ai-graphic.tsx
new file mode 100644
index 0000000..4811f1b
--- /dev/null
+++ b/apps/landing/src/components/landing/graphics/ai-graphic.tsx
@@ -0,0 +1,93 @@
+"use client";
+
+import { motion } from "framer-motion";
+import { Robot, Sparkle, ChatCircle, Books } from "@phosphor-icons/react";
+
+export function AIAgentGraphic() {
+ return (
+
+ {/* Background Data Nodes */}
+
+ {[...Array(12)].map((_, i) => (
+
+
+
+ ))}
+
+
+
+ {/* Connection Lines */}
+
+
+ {/* Central Brain/Agent */}
+
+
+
+
+
+ {/* Agent Output Simulation */}
+
+
+
+ User: How do I setup SSO?
+
+
+
+
+
+
+
+
+
+
+
+ {/* Source Citation */}
+
+
+ docs/sso-setup.md
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/landing/src/components/landing/graphics/campaigns-graphic.tsx b/apps/landing/src/components/landing/graphics/campaigns-graphic.tsx
new file mode 100644
index 0000000..5a0f616
--- /dev/null
+++ b/apps/landing/src/components/landing/graphics/campaigns-graphic.tsx
@@ -0,0 +1,79 @@
+"use client";
+
+import { motion } from "framer-motion";
+import { EnvelopeSimple, RocketLaunch, CursorClick } from "@phosphor-icons/react";
+
+export function CampaignsGraphic() {
+ return (
+
+ {/* Background Pipeline Graph */}
+
+
+
+ {/* Campaign Analytics Card */}
+
+
+
+
+
+
+
Black Friday Promo
+
+
+ Active (Sending)
+
+
+
+
+
+
+ {/* Campaign Steps */}
+
+
+
+ {[
+ { type: "Email", icon: EnvelopeSimple, label: "Initial Offer", delay: 0.2, active: false },
+ { type: "Wait", icon: null, label: "Wait 2 days", delay: 0.4, active: false, small: true },
+ { type: "In-App", icon: CursorClick, label: "Reminder Banner", delay: 0.6, active: true },
+ ].map((step, i) => (
+
+
+
+
{step.type}
+
{step.label}
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/apps/landing/src/components/landing/graphics/inbox-graphic.tsx b/apps/landing/src/components/landing/graphics/inbox-graphic.tsx
new file mode 100644
index 0000000..4c23e2a
--- /dev/null
+++ b/apps/landing/src/components/landing/graphics/inbox-graphic.tsx
@@ -0,0 +1,161 @@
+"use client";
+
+import { motion } from "framer-motion";
+import { User, PaperPlaneRight, DotsThree, CheckCircle, Clock, MagnifyingGlass } from "@phosphor-icons/react";
+
+export function InboxGraphic() {
+ return (
+
+ {/* Sidebar */}
+
+
+
+ {/* Active Conversation */}
+
+
+
+
+
+
+
+ Sarah Jenkins
+ Just now
+
+
How do I upgrade my billing plan?
+
+
+
+ {/* Other Conversations */}
+ {[
+ { name: "Michael Chen", time: "5m", preview: "The API is throwing a 500 error", status: "open" },
+ { name: "Emily Davis", time: "1h", preview: "Thanks! That fixed my issue.", status: "resolved" },
+ { name: "Alex Kumar", time: "2h", preview: "Can we get a demo for our team?", status: "open" },
+ ].map((chat, i) => (
+
+
+
+
+
+
+ {chat.name}
+ {chat.time}
+
+
{chat.preview}
+
+
+ ))}
+
+
+
+ {/* Main Chat Area */}
+
+ {/* Chat Header */}
+
+
+
+
+
+
+
Sarah Jenkins
+
+
+ Online • sarah@acmecorp.com
+
+
+
+
+
+
+ Snooze
+
+
+
+ Resolve
+
+
+
+
+
+
+
+ {/* Chat Messages */}
+
+
+ Today, 10:42 AM
+
+
+
+
+
+
+
+
+ Hi there! We are currently on the Pro plan but we need to add 5 more team members. How do I upgrade my billing plan to Enterprise?
+
+
+
+
+
+
+ Me
+
+
+
+ Hey Sarah! Happy to help with that. You can upgrade directly from your workspace settings.
+
+
+
+
+
+
+
+
+
+
+ {/* Composer */}
+
+
+
+
+
+ {[1, 2, 3].map(i => (
+
+ ))}
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/landing/src/components/landing/graphics/outbound-graphic.tsx b/apps/landing/src/components/landing/graphics/outbound-graphic.tsx
new file mode 100644
index 0000000..f124da6
--- /dev/null
+++ b/apps/landing/src/components/landing/graphics/outbound-graphic.tsx
@@ -0,0 +1,75 @@
+"use client";
+
+import { motion } from "framer-motion";
+import { PaperPlaneRight, Sparkle } from "@phosphor-icons/react";
+
+export function OutboundGraphic() {
+ return (
+
+ {/* Background Pulse */}
+
+
+
+ {/* Campaign Builder Card */}
+
+
+
+
+
New Feature Announcement
+
Target: Active Users (Last 30d)
+
+
+
+
+
+ Delivery Method
+ In-app Chat
+
+
+
+
+
+
+
+ {/* Floating AI Suggestion */}
+
+
+
+
+
+
+ Based on recent usage, sending this on Tuesday at 10 AM will increase open rates by 24%.
+
+
+
+
+
+
+ );
+}
diff --git a/apps/landing/src/components/landing/graphics/reports-graphic.tsx b/apps/landing/src/components/landing/graphics/reports-graphic.tsx
new file mode 100644
index 0000000..f4d94e7
--- /dev/null
+++ b/apps/landing/src/components/landing/graphics/reports-graphic.tsx
@@ -0,0 +1,113 @@
+"use client";
+
+import { motion } from "framer-motion";
+import { ChartLineUp, Users, Clock, TrendUp } from "@phosphor-icons/react";
+
+export function ReportsGraphic() {
+ return (
+
+ {/* Dashboard Mockup */}
+
+ {/* Header */}
+
+
+
+ Analytics Overview
+
+
+ Last 30 Days
+
+
+
+
+ {/* Top Metrics Row */}
+
+
+
+
+ Total Convos
+
+
+ 1,248
+
+ 12%
+
+
+
+
+
+
+
+ Median Response
+
+
+ 4m
+
+ 8%
+
+
+
+
+
+ {/* Main Chart Area */}
+
+ Volume over time
+
+ {/* SVG Line Chart Animation */}
+
+ {/* Grid lines */}
+
+ {[1, 2, 3, 4].map(i => (
+
+ ))}
+
+
+ {/* Animated Path */}
+
+
+
+
+
+
+ );
+}
diff --git a/apps/landing/src/components/landing/graphics/surveys-graphic.tsx b/apps/landing/src/components/landing/graphics/surveys-graphic.tsx
new file mode 100644
index 0000000..cf84ba8
--- /dev/null
+++ b/apps/landing/src/components/landing/graphics/surveys-graphic.tsx
@@ -0,0 +1,90 @@
+"use client";
+
+import { motion } from "framer-motion";
+import { ClipboardText, Star } from "@phosphor-icons/react";
+
+export function SurveysGraphic() {
+ return (
+
+ {/* Decorative Background grid */}
+
+
+
+ {/* Survey Header */}
+
+
+
+
+
+
How are we doing?
+
We'd love to hear your feedback on the new dashboard features.
+
+
+ {/* Survey Interactive Area */}
+
+ {/* NPS Scale Mockup */}
+
+
+ {[1, 2, 3, 4, 5].map((num) => (
+
+ {num}
+
+ ))}
+
+
+ Poor
+ Excellent
+
+
+
+ {/* Feedback Textarea Mockup */}
+
+
+
+
+ {/* Submit Button */}
+
+
+
+
+ {/* Floating Success Toast */}
+
+
+ Response recorded
+
+
+ );
+}
diff --git a/apps/landing/src/components/landing/graphics/tickets-graphic.tsx b/apps/landing/src/components/landing/graphics/tickets-graphic.tsx
new file mode 100644
index 0000000..95ce1d5
--- /dev/null
+++ b/apps/landing/src/components/landing/graphics/tickets-graphic.tsx
@@ -0,0 +1,96 @@
+"use client";
+
+import { motion } from "framer-motion";
+import { Ticket, CaretDown, ChatTeardropText, CircleDashed } from "@phosphor-icons/react";
+
+export function TicketsGraphic() {
+ return (
+
+
+ {/* Header */}
+
+
+
+
+
+
+
Support Tickets
+
3 pending actions
+
+
+
+ New Ticket
+
+
+
+ {/* Tickets List */}
+
+ {[
+ { id: "T-4092", title: "Cannot access billing page", status: "High Priority", color: "red", active: true },
+ { id: "T-4091", title: "API Rate limit exceeded", status: "In Progress", color: "blue", active: false },
+ { id: "T-4090", title: "How to export user data?", status: "Pending", color: "orange", active: false }
+ ].map((ticket, i) => (
+
+
+
+
+
+ {ticket.id}
+
{ticket.title}
+
+
+
+ {ticket.status}
+
+
+
+
+
+
+ ))}
+
+
+ {/* Floating Detail Panel */}
+
+
+
T-4092
+
+
+ High Priority
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/landing/src/components/landing/graphics/tours-graphic.tsx b/apps/landing/src/components/landing/graphics/tours-graphic.tsx
new file mode 100644
index 0000000..e84e73a
--- /dev/null
+++ b/apps/landing/src/components/landing/graphics/tours-graphic.tsx
@@ -0,0 +1,110 @@
+"use client";
+
+import { motion } from "framer-motion";
+import { CursorClick, X, Sparkle } from "@phosphor-icons/react";
+
+export function ToursGraphic() {
+ return (
+
+ {/* Background App Mockup */}
+
+
+
+
+ {[1, 2, 3, 4, 5].map(i => (
+
+ ))}
+
+
+
+
+
+ {[1, 2, 3].map(i => (
+
+ ))}
+
+
+
+
+
+ {/* Target Element being Highlighted */}
+
+
+ {/* The Tour Popover */}
+
+ {/* Pointer Triangle */}
+
+
+
+
+
+
Configure AI Auto-Resolve
+
+ Train your agent on your documentation. It will automatically resolve repetitive queries and escalate complex issues to your team.
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Floating Mouse Cursor */}
+
+
+
+
+
+ );
+}
diff --git a/apps/landing/src/components/landing/showcase.tsx b/apps/landing/src/components/landing/showcase.tsx
index 0cffc67..35c91ca 100644
--- a/apps/landing/src/components/landing/showcase.tsx
+++ b/apps/landing/src/components/landing/showcase.tsx
@@ -1,22 +1,23 @@
"use client";
import { motion } from "framer-motion";
-import Image from "next/image";
import { useRef } from "react";
+import { InboxGraphic } from "./graphics/inbox-graphic";
+import { ToursGraphic } from "./graphics/tours-graphic";
const showcaseItems = [
{
title: "High-performance Inbox",
description:
"Keyboard-first navigation, real-time sync, and intelligent routing. Built to handle scale without breaking a sweat.",
- image: "/screenshots/web-inbox.png",
+ graphic: InboxGraphic,
tourTarget: "showcase-inbox",
},
{
title: "Native Product Tours",
description:
"Design multi-step interactive tours directly within your app. No fragile CSS selectors or third-party iframe overlays.",
- image: "/screenshots/widget-tour-post-step.png",
+ graphic: ToursGraphic,
tourTarget: "showcase-product-tour",
},
];
@@ -86,7 +87,7 @@ export function Showcase() {
transition={{ type: "spring", stiffness: 100, damping: 20 }}
className="w-full h-full"
>
-
+
diff --git a/openspec/changes/launch-intercom-authority-distribution/.openspec.yaml b/openspec/changes/launch-intercom-authority-distribution/.openspec.yaml
new file mode 100644
index 0000000..5aae5cf
--- /dev/null
+++ b/openspec/changes/launch-intercom-authority-distribution/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-03-04
diff --git a/openspec/changes/launch-intercom-authority-distribution/design.md b/openspec/changes/launch-intercom-authority-distribution/design.md
new file mode 100644
index 0000000..428636a
--- /dev/null
+++ b/openspec/changes/launch-intercom-authority-distribution/design.md
@@ -0,0 +1,103 @@
+## Context
+
+Competitive query rankings for "Intercom alternatives" are dominated by established domains with strong backlink profiles. Even after on-site intent pages launch, Opencom needs an operational distribution loop to gain third-party mentions, maintain freshness, and prove authority over time.
+
+This change spans process artifacts in `docs/`, structured tracking data, and a lightweight landing proof surface (`/mentions/`) that supports both trust and crawlable internal linking.
+
+## Goals / Non-Goals
+
+**Goals:**
+- Define an execution-ready playbook for directory submissions and editorial outreach.
+- Standardize outreach assets so claims remain consistent and source-backed.
+- Track outreach lifecycle and refresh cadence in machine-readable artifacts.
+- Publish a crawlable mentions surface that consolidates earned proof.
+- Define measurable SEO checkpoints for iteration.
+
+**Non-Goals:**
+- Automating third-party submissions end-to-end.
+- Building a full CRM/revenue attribution platform.
+- Guaranteeing placements from external publishers.
+
+## Decisions
+
+### 1) Store authority operations as versioned docs + machine-readable tracker
+
+Decision:
+- Add human-readable playbook/template docs and a tracker artifact (CSV or JSON) under `docs/seo/`.
+
+Rationale:
+- Versioned repo artifacts provide auditability, collaboration, and compatibility with simple scripts/reporting.
+
+Alternatives considered:
+- Tracking only in ad-hoc spreadsheets: rejected due to poor reviewability and weak change history.
+
+### 2) Define a minimum outreach asset pack with evidence constraints
+
+Decision:
+- Standardize required outreach snippets: positioning paragraph, differentiators, license/deployment facts, and links to comparison/migration pages.
+
+Rationale:
+- Outreach consistency prevents contradictory claims and accelerates submission throughput.
+
+Alternatives considered:
+- Free-form outreach copy per contributor: rejected due to quality variance.
+
+### 3) Build `/mentions/` from structured mention entries
+
+Decision:
+- Represent mentions as structured entries with source URL, publication name, verification state, and timestamp; render from this data source.
+
+Rationale:
+- Structured entries make verification explicit and support deterministic UI rendering plus freshness metadata.
+
+Alternatives considered:
+- Hardcoded mentions in JSX: rejected because it does not scale and is error-prone during updates.
+
+### 4) Use periodic refresh checkpoints tied to intent pages
+
+Decision:
+- Add review cadence requirements for alternatives/comparison pages and tie them to tracker entries with next-action dates.
+
+Rationale:
+- Competitive pages degrade without recurring updates; tracking dates enforces ongoing maintenance.
+
+Alternatives considered:
+- One-time launch without refresh SLA: rejected due to expected ranking decay.
+
+### 5) Keep measurement intentionally lightweight at first
+
+Decision:
+- Start with a compact KPI set (impressions, clicks, CTR for target queries + one conversion-aligned metric) and refine later.
+
+Rationale:
+- Keeps the program operable immediately without waiting for a complex analytics stack.
+
+Alternatives considered:
+- Full multi-touch attribution before launch: rejected as too slow for immediate ranking work.
+
+## Risks / Trade-offs
+
+- [Risk] Outreach tracker becomes stale and loses trust.
+ - Mitigation: enforce owner and next-action fields with status transitions.
+- [Risk] Public mentions page lists unverified or low-quality references.
+ - Mitigation: include verification state and only render verified entries.
+- [Risk] Overly aggressive claims in outreach damage credibility.
+ - Mitigation: require source-backed claims and explicit available-now vs roadmap labels.
+
+## Migration Plan
+
+1. Add `docs/seo/` playbook, outreach templates, and authority tracker artifact.
+2. Add structured mention data model and implement `/mentions/` route.
+3. Add nav/footer links to mentions page where appropriate.
+4. Backfill initial targets and mention entries from existing known placements.
+5. Start weekly authority update loop and log KPI snapshots.
+
+Rollback:
+- Keep docs/tracker artifacts intact and hide `/mentions/` route if needed.
+- Re-enable route after data cleanup without losing process documentation.
+
+## Open Questions
+
+- Which team role owns weekly tracker updates after initial launch?
+- Should rejected outreach targets remain visible in the same tracker file or be archived separately?
+- Do we need a strict minimum domain-quality threshold for mentions before display?
diff --git a/openspec/changes/launch-intercom-authority-distribution/proposal.md b/openspec/changes/launch-intercom-authority-distribution/proposal.md
new file mode 100644
index 0000000..ddac15c
--- /dev/null
+++ b/openspec/changes/launch-intercom-authority-distribution/proposal.md
@@ -0,0 +1,32 @@
+## Why
+
+On-page SEO pages alone are unlikely to reach page 1 quickly for competitive "Intercom alternatives" queries without off-site authority, repeat mentions, and a disciplined refresh cycle. Opencom needs a repeatable distribution system that turns new comparison/migration assets into backlinks, directory placements, and ongoing ranking signals.
+
+## What Changes
+
+- Add an SEO distribution playbook in-repo with:
+ - target directories and listicles,
+ - submission/outreach templates,
+ - evidence pack requirements (license, hosting model, feature matrix, migration links).
+- Add a tracking system for authority work (submission status, backlinks earned, follow-ups, next refresh date).
+- Add a `mentions` surface on the landing site so earned placements can be showcased and internally linked.
+- Define a refresh cadence for comparison/list pages and supporting assets to avoid stale rankings.
+- Define ranking and conversion measurement checkpoints for this cluster (query impressions, clicks, CTR, assisted conversions).
+- Add quality guardrails for public comparisons: objective criteria, source-backed claims, and explicit labels for "available now" vs "roadmap".
+
+## Capabilities
+
+### New Capabilities
+- `seo-distribution-playbook-and-asset-pack`: Repo contains an execution-ready playbook and reusable asset pack for directory submissions and alternatives-list outreach.
+- `seo-authority-tracking-and-refresh-cycle`: Repo defines and tracks outreach, backlinks, and refresh checkpoints so ranking work is continuous instead of one-off.
+- `mentions-proof-surface`: Landing app exposes a crawlable mentions/proof surface that consolidates earned placements and links back into conversion pages.
+
+### Modified Capabilities
+- None.
+
+## Impact
+
+- New process docs/templates/tracking artifacts under `docs/**`.
+- New landing route(s) in `apps/landing/src/app/**` for mentions/proof.
+- Potential updates to nav/footer/internal links for discoverability.
+- Ongoing cross-functional workflow between product marketing and engineering for content freshness.
diff --git a/openspec/changes/launch-intercom-authority-distribution/specs/mentions-proof-surface/spec.md b/openspec/changes/launch-intercom-authority-distribution/specs/mentions-proof-surface/spec.md
new file mode 100644
index 0000000..426e4ec
--- /dev/null
+++ b/openspec/changes/launch-intercom-authority-distribution/specs/mentions-proof-surface/spec.md
@@ -0,0 +1,20 @@
+## ADDED Requirements
+
+### Requirement: Landing app MUST expose a crawlable mentions proof route
+The landing app SHALL publish a crawlable mentions page that consolidates verified third-party placements and references.
+
+#### Scenario: Mentions route renders verified entries
+- **WHEN** `/mentions/` is rendered
+- **THEN** it SHALL list only verified third-party mentions with source link, publication name, and publication date when known
+
+#### Scenario: Mentions route exposes recency context
+- **WHEN** the mentions page is viewed
+- **THEN** it SHALL display a "last updated" timestamp for the mentions dataset
+
+### Requirement: Mentions proof route MUST reinforce SEO cluster navigation
+The mentions page SHALL route users back to core conversion pages in the Intercom cluster.
+
+#### Scenario: Users can continue evaluation from mentions
+- **WHEN** a visitor scans a mention entry
+- **THEN** the page SHALL provide internal links to `/intercom-alternative/` and `/compare/intercom/`
+- **AND** at least one call-to-action to hosted onboarding or docs SHALL be present
diff --git a/openspec/changes/launch-intercom-authority-distribution/specs/seo-authority-tracking-and-refresh-cycle/spec.md b/openspec/changes/launch-intercom-authority-distribution/specs/seo-authority-tracking-and-refresh-cycle/spec.md
new file mode 100644
index 0000000..ed65cef
--- /dev/null
+++ b/openspec/changes/launch-intercom-authority-distribution/specs/seo-authority-tracking-and-refresh-cycle/spec.md
@@ -0,0 +1,29 @@
+## ADDED Requirements
+
+### Requirement: Authority operations MUST be tracked in a shared status artifact
+The repository SHALL include a machine-readable tracker for outreach and directory placement work.
+
+#### Scenario: Tracker captures lifecycle status
+- **WHEN** a directory or listicle target is added
+- **THEN** the tracker SHALL record target URL, status, owner, and next action date
+- **AND** the tracker SHALL support states for not-started, submitted, in-review, live, and rejected
+
+### Requirement: Comparison and alternatives assets MUST follow a refresh cadence
+The SEO cluster SHALL include documented refresh rules so ranking pages do not become stale.
+
+#### Scenario: Refresh dates are explicit
+- **WHEN** a comparison or alternatives page is published
+- **THEN** the associated tracker entry SHALL include a next review date
+- **AND** the page content model SHALL expose a visible "last reviewed" timestamp
+
+#### Scenario: Refresh updates are scoped and auditable
+- **WHEN** content is refreshed
+- **THEN** the update SHALL document what changed in comparison criteria, competitor set, or capability status labels
+
+### Requirement: Authority program MUST define measurable checkpoints
+The distribution workflow SHALL define measurable ranking and conversion checkpoints for the Intercom cluster.
+
+#### Scenario: KPI set is complete
+- **WHEN** reporting is produced for this program
+- **THEN** it SHALL include impressions, clicks, and CTR for target queries
+- **AND** it SHALL include at least one conversion-aligned metric from cluster routes
diff --git a/openspec/changes/launch-intercom-authority-distribution/specs/seo-distribution-playbook-and-asset-pack/spec.md b/openspec/changes/launch-intercom-authority-distribution/specs/seo-distribution-playbook-and-asset-pack/spec.md
new file mode 100644
index 0000000..71b19d0
--- /dev/null
+++ b/openspec/changes/launch-intercom-authority-distribution/specs/seo-distribution-playbook-and-asset-pack/spec.md
@@ -0,0 +1,26 @@
+## ADDED Requirements
+
+### Requirement: Repo MUST include an executable Intercom-alternative distribution playbook
+The repository SHALL include a documented playbook that operationalizes directory submissions and alternatives-list outreach for the Intercom SEO cluster.
+
+#### Scenario: Playbook defines prioritized target tiers
+- **WHEN** the playbook is read
+- **THEN** it SHALL group targets into priority tiers with qualification criteria
+- **AND** each target SHALL include the recommended asset link for submission
+
+#### Scenario: Playbook defines explicit owner handoffs
+- **WHEN** a distribution task is initiated
+- **THEN** the playbook SHALL identify ownership for research, outreach, and follow-up actions
+- **AND** the completion definition for each action SHALL be documented
+
+### Requirement: Repo MUST include a reusable outreach asset pack
+The repository SHALL provide reusable templates and evidence snippets to keep outbound submissions consistent and source-backed.
+
+#### Scenario: Templates support multiple outreach channels
+- **WHEN** contributors prepare outreach
+- **THEN** the repo SHALL provide at least one template for directory submissions and one template for editorial/listicle outreach
+
+#### Scenario: Evidence pack enforces factual consistency
+- **WHEN** an outreach template references product claims
+- **THEN** it SHALL source those claims from maintained Opencom references (for example licensing, deployment model, and comparison pages)
+- **AND** unsupported parity claims SHALL be explicitly excluded
diff --git a/openspec/changes/launch-intercom-authority-distribution/tasks.md b/openspec/changes/launch-intercom-authority-distribution/tasks.md
new file mode 100644
index 0000000..9671156
--- /dev/null
+++ b/openspec/changes/launch-intercom-authority-distribution/tasks.md
@@ -0,0 +1,29 @@
+## 1. Distribution Playbook And Templates
+
+- [ ] 1.1 Create `docs/seo/` playbook documenting target tiers, qualification criteria, and owner handoffs for directory/listicle outreach.
+- [ ] 1.2 Create reusable outreach templates for directory submissions and editorial outreach.
+- [ ] 1.3 Create an evidence pack section with approved claim snippets and required links (license, deployment, comparison, migration).
+
+## 2. Authority Tracker And Refresh Workflow
+
+- [ ] 2.1 Add a machine-readable tracker artifact for target URL, owner, status, next action date, and notes.
+- [ ] 2.2 Define status transition rules (`not-started`, `submitted`, `in-review`, `live`, `rejected`) in playbook docs.
+- [ ] 2.3 Add refresh cadence rules and ensure alternatives/comparison assets have review-date fields.
+
+## 3. Mentions Proof Surface
+
+- [ ] 3.1 Add structured mentions data model with source URL, publication name, verification flag, and timestamps.
+- [ ] 3.2 Implement `/mentions/` landing route that renders only verified entries and displays last updated timestamp.
+- [ ] 3.3 Add links from `/mentions/` to `/intercom-alternative/`, `/compare/intercom/`, and onboarding/docs CTAs.
+
+## 4. Program Measurement And Reporting
+
+- [ ] 4.1 Define reporting template covering impressions, clicks, CTR, and at least one conversion-aligned metric.
+- [ ] 4.2 Document weekly operating cadence for tracker updates, outreach follow-ups, and KPI review.
+- [ ] 4.3 Seed tracker with an initial target set and baseline status snapshot.
+
+## 5. Verification
+
+- [ ] 5.1 Validate docs/template completeness against spec requirements.
+- [ ] 5.2 Validate mentions page rendering and internal links.
+- [ ] 5.3 Run landing package quality checks (typecheck/lint/tests as available) for any app changes.
diff --git a/openspec/changes/rank-intercom-alternative-intent-cluster/.openspec.yaml b/openspec/changes/rank-intercom-alternative-intent-cluster/.openspec.yaml
new file mode 100644
index 0000000..5aae5cf
--- /dev/null
+++ b/openspec/changes/rank-intercom-alternative-intent-cluster/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-03-04
diff --git a/openspec/changes/rank-intercom-alternative-intent-cluster/design.md b/openspec/changes/rank-intercom-alternative-intent-cluster/design.md
new file mode 100644
index 0000000..6428311
--- /dev/null
+++ b/openspec/changes/rank-intercom-alternative-intent-cluster/design.md
@@ -0,0 +1,103 @@
+## Context
+
+`apps/landing` currently includes a homepage plus broad informational pages (`/features`, `/docs`, etc.) but no dedicated route cluster for high-intent Intercom-alternative queries. Metadata utilities exist (`createLandingPageMetadata`) and can be extended, but there is no route-specific structured-data pattern for FAQ/breadcrumb output and no explicit SEO cluster linking contract.
+
+This change is cross-cutting across route creation, shared content models, metadata generation, and global navigation/linking.
+
+## Goals / Non-Goals
+
+**Goals:**
+- Launch an intent-mapped Intercom SEO cluster with dedicated pages for key query families.
+- Add trust/evaluation pages (`/compare/intercom/`, `/migrate-from-intercom/`) with clear next-step CTAs.
+- Prevent keyword cannibalization by enforcing one primary query intent per page.
+- Standardize metadata and structured data output for the cluster.
+- Keep comparison content maintainable via reusable data structures rather than hardcoded JSX fragments.
+
+**Non-Goals:**
+- Building a full blog CMS in this change.
+- Reworking brand-wide design language outside the targeted routes.
+- Claiming complete parity with all competitor capabilities where evidence does not exist.
+
+## Decisions
+
+### 1) Use route-per-intent architecture under `apps/landing/src/app`
+
+Decision:
+- Implement dedicated route directories for each query target, including nested routes for `/compare/intercom/`.
+
+Rationale:
+- Query intent isolation improves relevance and reduces cannibalization from having one generic page for all intents.
+
+Alternatives considered:
+- Single long-form page with hash sections: rejected because it weakens intent targeting and snippet control.
+
+### 2) Introduce a shared Intercom cluster content model
+
+Decision:
+- Create shared data/config modules for competitor rows, comparison dimensions, FAQ items, and freshness metadata.
+
+Rationale:
+- Shared models reduce drift between `/free-intercom-alternatives/` and `/compare/intercom/` while making periodic refresh work low-friction.
+
+Alternatives considered:
+- Hand-author each page in JSX only: rejected due to high maintenance risk and inconsistent claim formatting.
+
+### 3) Enforce evidence-backed comparison status labels
+
+Decision:
+- Comparison rows include explicit status labels (`available now`, `roadmap`) and source references derived from internal competitive analysis artifacts.
+
+Rationale:
+- The provided competitive analysis includes broad capability coverage and should constrain public claims to defensible statements.
+
+Alternatives considered:
+- Marketing-only claim language without source constraints: rejected due to trust and regression risk.
+
+### 4) Add explicit metadata and schema primitives per route
+
+Decision:
+- Extend metadata helpers and add JSON-LD helper patterns for FAQ/Breadcrumb output, with strict parity between visible content and schema fields.
+
+Rationale:
+- Improves eligibility for rich result enhancements while avoiding schema mismatch issues.
+
+Alternatives considered:
+- Schema-free implementation: rejected because it leaves obvious SEO wins on the table.
+
+### 5) Define cluster-level internal linking contract
+
+Decision:
+- Require reciprocal linking among core intent pages plus prominent hub links from `/features` and shared nav/footer surfaces where appropriate.
+
+Rationale:
+- Internal-link graph clarity is required for crawl depth and conversion path continuity.
+
+Alternatives considered:
+- Only contextual in-body links: rejected because links may regress during copy edits.
+
+## Risks / Trade-offs
+
+- [Risk] Page overlap creates keyword cannibalization.
+ - Mitigation: enforce intent-to-route mapping and distinct H1/title requirements in specs.
+- [Risk] Comparison claims drift from current product capability.
+ - Mitigation: centralize data with explicit source/status fields and add refresh timestamp requirements.
+- [Risk] Structured data can diverge from visible FAQ text.
+ - Mitigation: generate JSON-LD from the same content source used to render visible FAQs.
+
+## Migration Plan
+
+1. Add shared content and metadata/schema helpers for the new cluster.
+2. Implement route pages in this order: `/intercom-alternative/`, `/open-source-intercom-alternative/`, `/free-intercom-alternatives/`, `/compare/intercom/`, `/migrate-from-intercom/`.
+3. Update `/features`, nav, and footer linking to support cluster traversal.
+4. Add sitemap/robots coverage and verify cluster URL discoverability.
+5. Run route-level QA for metadata, structured data parity, and internal link integrity.
+
+Rollback:
+- Revert cluster routes while retaining shared helper modules if needed.
+- Keep legacy pages and navigation paths unchanged if rollback occurs.
+
+## Open Questions
+
+- Should `/open-source-intercom/` be added as a redirect alias to `/open-source-intercom-alternative/` for query coverage, or should we keep one canonical route only?
+- Should the free alternatives page include scoring weights, or only qualitative comparison plus trade-offs?
+- Do we want page-level analytics event hooks in this change, or defer to the authority-tracking change?
diff --git a/openspec/changes/rank-intercom-alternative-intent-cluster/proposal.md b/openspec/changes/rank-intercom-alternative-intent-cluster/proposal.md
new file mode 100644
index 0000000..474deea
--- /dev/null
+++ b/openspec/changes/rank-intercom-alternative-intent-cluster/proposal.md
@@ -0,0 +1,35 @@
+## Why
+
+Opencom already positions itself as an open-source Intercom alternative, but it lacks dedicated pages mapped to high-intent search queries (for example, "intercom alternative", "free intercom alternatives", and "open source intercom alternative"). As a result, ranking opportunities are being captured by listicles and directories even when Opencom is a direct fit.
+
+## What Changes
+
+- Add a search-intent landing cluster in `apps/landing` with dedicated routes:
+ - `/intercom-alternative/`
+ - `/free-intercom-alternatives/`
+ - `/open-source-intercom-alternative/`
+- Add trust and evaluation routes:
+ - `/compare/intercom/` for feature, deployment, pricing model, and governance comparison
+ - `/migrate-from-intercom/` for an actionable migration guide and expected rollout timeline
+- Upgrade `/features` into a cluster hub by adding prominent internal links to comparison and migration content.
+- Add reusable comparison content models so pages are consistent, evidence-backed, and easier to refresh.
+- Add metadata and structured data requirements for cluster pages (distinct title/H1 intent targeting, canonical paths, FAQ JSON-LD on eligible pages, breadcrumb schema where helpful).
+- Add crawl discoverability requirements (sitemap/robots coverage for all cluster routes).
+- Add freshness cues on list/comparison pages (for example "last reviewed") so content can be updated without structural rewrites.
+
+## Capabilities
+
+### New Capabilities
+- `intercom-alternative-intent-pages`: Landing app serves dedicated, intent-specific Intercom alternative pages with clear query-to-page mapping and conversion paths.
+- `intercom-comparison-and-migration-pages`: Landing app serves deep comparison and migration guidance pages that support evaluation intent and reduce conversion friction.
+- `landing-seo-cluster-linking-and-schema`: Landing app enforces canonical metadata, structured data, and internal-link graph rules across the Intercom intent cluster.
+
+### Modified Capabilities
+- None.
+
+## Impact
+
+- New and updated landing routes under `apps/landing/src/app/**`.
+- Shared metadata/content helpers under `apps/landing/src/lib/**`.
+- Navigation/footer/feature-page internal links under `apps/landing/src/components/**`.
+- QA scope for route rendering, metadata output, and crawl discoverability.
diff --git a/openspec/changes/rank-intercom-alternative-intent-cluster/specs/intercom-alternative-intent-pages/spec.md b/openspec/changes/rank-intercom-alternative-intent-cluster/specs/intercom-alternative-intent-pages/spec.md
new file mode 100644
index 0000000..74548d6
--- /dev/null
+++ b/openspec/changes/rank-intercom-alternative-intent-cluster/specs/intercom-alternative-intent-pages/spec.md
@@ -0,0 +1,34 @@
+## ADDED Requirements
+
+### Requirement: Landing app MUST provide dedicated Intercom-alternative intent routes
+The landing app SHALL expose dedicated routes for high-intent Intercom-alternative queries instead of relying on the homepage only.
+
+#### Scenario: Intent routes are available
+- **WHEN** a user requests `/intercom-alternative/`, `/free-intercom-alternatives/`, or `/open-source-intercom-alternative/`
+- **THEN** the app SHALL render a first-class page for each route with an HTTP 200 response
+
+#### Scenario: Each route has a single primary intent
+- **WHEN** a page in this cluster is rendered
+- **THEN** the page SHALL contain one clear H1 aligned to its target query family
+- **AND** the introductory copy SHALL focus on that query intent instead of duplicating another page's positioning
+
+### Requirement: Free alternatives route MUST deliver listicle-grade utility
+The `/free-intercom-alternatives/` route SHALL provide practical, neutral utility that can compete with third-party listicles.
+
+#### Scenario: Alternatives list includes market coverage
+- **WHEN** `/free-intercom-alternatives/` is published
+- **THEN** it SHALL include at least eight named alternatives
+- **AND** Opencom SHALL be evaluated with the same comparison dimensions as other options
+
+#### Scenario: Comparison methodology is visible
+- **WHEN** a visitor reviews the alternatives table
+- **THEN** the page SHALL display explicit comparison criteria
+- **AND** each "best for" recommendation SHALL include at least one trade-off statement
+
+### Requirement: Open-source Intercom route MUST disambiguate product intent
+The `/open-source-intercom-alternative/` route SHALL explicitly target customer messaging intent and disambiguate from unrelated intercom hardware/broadcast contexts.
+
+#### Scenario: Customer messaging intent is explicit
+- **WHEN** `/open-source-intercom-alternative/` is rendered
+- **THEN** the H1 SHALL include "customer messaging"
+- **AND** the intro section SHALL state that the page covers customer support and product messaging software use cases
diff --git a/openspec/changes/rank-intercom-alternative-intent-cluster/specs/intercom-comparison-and-migration-pages/spec.md b/openspec/changes/rank-intercom-alternative-intent-cluster/specs/intercom-comparison-and-migration-pages/spec.md
new file mode 100644
index 0000000..2c03a8e
--- /dev/null
+++ b/openspec/changes/rank-intercom-alternative-intent-cluster/specs/intercom-comparison-and-migration-pages/spec.md
@@ -0,0 +1,34 @@
+## ADDED Requirements
+
+### Requirement: Landing app MUST provide a deep Intercom comparison route
+The landing app SHALL publish `/compare/intercom/` with a structured, source-backed comparison of Opencom and Intercom across core buyer decision dimensions.
+
+#### Scenario: Comparison dimensions are comprehensive
+- **WHEN** `/compare/intercom/` is rendered
+- **THEN** it SHALL include comparison rows for feature coverage, deployment models, data ownership/governance, extensibility, and pricing model structure
+
+#### Scenario: Claims are evidence-backed and status-labeled
+- **WHEN** a comparison claim is shown on `/compare/intercom/`
+- **THEN** the claim SHALL be backed by a referenced source set used by Opencom's internal competitive analysis
+- **AND** capability status SHALL be labeled as "available now" or "roadmap" where parity is incomplete
+
+### Requirement: Landing app MUST provide a crawlable migration route
+The landing app SHALL publish `/migrate-from-intercom/` with practical migration guidance for evaluation-stage and implementation-stage visitors.
+
+#### Scenario: Migration guide includes execution detail
+- **WHEN** `/migrate-from-intercom/` is rendered
+- **THEN** it SHALL include phased migration steps covering prerequisite audit, pilot rollout, and full cutover
+- **AND** each phase SHALL state expected deliverables and ownership
+
+#### Scenario: Migration guide includes time and risk framing
+- **WHEN** a visitor reads the migration guide
+- **THEN** the page SHALL include indicative timeline ranges for common deployment tracks
+- **AND** the page SHALL include a risks/gotchas section with mitigation guidance
+
+### Requirement: Trust pages MUST connect to product and onboarding CTAs
+Comparison and migration routes SHALL provide clear next-step paths without forcing navigation back to the homepage.
+
+#### Scenario: Visitors can continue evaluation immediately
+- **WHEN** a visitor reaches the end of `/compare/intercom/` or `/migrate-from-intercom/`
+- **THEN** the page SHALL present links to hosted onboarding and implementation documentation
+- **AND** the page SHALL include at least one internal link to another Intercom intent-cluster route
diff --git a/openspec/changes/rank-intercom-alternative-intent-cluster/specs/landing-seo-cluster-linking-and-schema/spec.md b/openspec/changes/rank-intercom-alternative-intent-cluster/specs/landing-seo-cluster-linking-and-schema/spec.md
new file mode 100644
index 0000000..d1f4732
--- /dev/null
+++ b/openspec/changes/rank-intercom-alternative-intent-cluster/specs/landing-seo-cluster-linking-and-schema/spec.md
@@ -0,0 +1,36 @@
+## ADDED Requirements
+
+### Requirement: Intercom SEO cluster MUST maintain a deterministic internal-link graph
+Cluster pages SHALL link to each other and to supporting evaluation pages so search engines and visitors can traverse the intent set without orphaned routes.
+
+#### Scenario: Core cluster routes are cross-linked
+- **WHEN** any Intercom cluster page is rendered
+- **THEN** the page SHALL include internal links to at least two other cluster pages
+- **AND** `/features` SHALL include a prominent entry point into `/compare/intercom/`
+
+### Requirement: Cluster pages MUST expose unique canonical metadata
+Each Intercom cluster route SHALL expose unique title and description metadata aligned to its primary query intent and a canonical URL for that exact route.
+
+#### Scenario: Metadata is query-aligned per route
+- **WHEN** metadata is generated for cluster routes
+- **THEN** no two cluster routes SHALL share an identical title string
+- **AND** each route SHALL publish a canonical path that matches its own URL
+
+### Requirement: FAQ schema MUST only represent visible FAQ content
+Any FAQ structured data used by cluster pages SHALL mirror visible on-page FAQ questions and answers.
+
+#### Scenario: FAQ markup parity
+- **WHEN** a cluster page includes FAQ JSON-LD
+- **THEN** every question and answer in the JSON-LD SHALL be visible in the rendered page content
+- **AND** pages without visible FAQ sections SHALL not emit FAQ JSON-LD
+
+### Requirement: Cluster routes MUST be discoverable for crawling
+Intercom cluster routes SHALL be included in crawl discovery artifacts.
+
+#### Scenario: Sitemap coverage
+- **WHEN** landing sitemap output is generated
+- **THEN** all cluster routes SHALL be present in sitemap output
+
+#### Scenario: Robots rules do not block cluster routes
+- **WHEN** robots directives are evaluated for production
+- **THEN** cluster routes SHALL be indexable and followable by search engines
diff --git a/openspec/changes/rank-intercom-alternative-intent-cluster/tasks.md b/openspec/changes/rank-intercom-alternative-intent-cluster/tasks.md
new file mode 100644
index 0000000..0c8c4d4
--- /dev/null
+++ b/openspec/changes/rank-intercom-alternative-intent-cluster/tasks.md
@@ -0,0 +1,30 @@
+## 1. Intent Mapping And Shared Content Models
+
+- [ ] 1.1 Define route-to-query intent map for `/intercom-alternative/`, `/free-intercom-alternatives/`, `/open-source-intercom-alternative/`, `/compare/intercom/`, and `/migrate-from-intercom/`.
+- [ ] 1.2 Add shared content model(s) for comparison rows, alternatives entries, FAQ content, and freshness metadata.
+- [ ] 1.3 Seed shared content with source-backed capability/status labels (`available now` vs `roadmap`) using existing competitive analysis inputs.
+
+## 2. Intent Cluster Route Implementation
+
+- [ ] 2.1 Implement `/intercom-alternative/` with value proposition, Opencom vs Intercom summary, and conversion CTAs.
+- [ ] 2.2 Implement `/open-source-intercom-alternative/` with explicit customer-messaging disambiguation and FAQ block.
+- [ ] 2.3 Implement `/free-intercom-alternatives/` with at least eight alternatives, visible methodology, and trade-off statements.
+
+## 3. Trust Route Implementation
+
+- [ ] 3.1 Implement `/compare/intercom/` with feature/deployment/pricing/governance comparison matrix.
+- [ ] 3.2 Implement `/migrate-from-intercom/` with phased migration plan, timeline ranges, risks, and mitigations.
+- [ ] 3.3 Add strong next-step CTAs and reciprocal internal links across all cluster pages.
+
+## 4. SEO Infrastructure And Linking
+
+- [ ] 4.1 Extend metadata helpers for route-unique titles, descriptions, and canonical output.
+- [ ] 4.2 Add FAQ and breadcrumb structured data helpers, ensuring JSON-LD content mirrors visible page content.
+- [ ] 4.3 Add or update sitemap/robots outputs so all cluster routes are crawl-discoverable and indexable.
+- [ ] 4.4 Update `/features`, navbar, and footer surfaces to include intentional entry points into the comparison cluster.
+
+## 5. Verification
+
+- [ ] 5.1 Verify each new route renders successfully with expected H1 intent and internal-link graph.
+- [ ] 5.2 Validate metadata and structured data output for parity and correctness.
+- [ ] 5.3 Run landing package quality checks (typecheck/lint/tests as available) and resolve regressions.
diff --git a/scripts/export-landing-content-to-md.ts b/scripts/export-landing-content-to-md.ts
new file mode 100644
index 0000000..a4c1001
--- /dev/null
+++ b/scripts/export-landing-content-to-md.ts
@@ -0,0 +1,422 @@
+#!/usr/bin/env tsx
+
+import { chromium, type Page } from "@playwright/test";
+import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
+import { existsSync, readFileSync } from "node:fs";
+import * as fs from "node:fs/promises";
+import * as path from "node:path";
+
+const DEFAULT_BASE_URL = process.env.LANDING_BASE_URL ?? "http://127.0.0.1:4000";
+const DEFAULT_OUT_DIR = process.env.LANDING_MD_OUT_DIR ?? "artifacts/landing-help-center-md";
+const LANDING_APP_SRC_DIR = path.resolve(process.cwd(), "apps", "landing", "src", "app");
+const LANDING_MD_EXTRACTOR_PATH = path.resolve(
+ process.cwd(),
+ "scripts",
+ "lib",
+ "landing-md-extractor.runtime.js"
+);
+const SERVER_READY_TIMEOUT_MS = 120_000;
+const NAVIGATION_TIMEOUT_MS = 60_000;
+const LANDING_MD_EXTRACTOR_SCRIPT = readFileSync(LANDING_MD_EXTRACTOR_PATH, "utf8");
+
+type CliOptions = {
+ baseUrl: string;
+ outDir: string;
+ noStartServer: boolean;
+ routeFilters: string[];
+};
+
+type ExportRecord = {
+ route: string;
+ file: string;
+ title: string;
+ url: string;
+};
+
+type ExtractedPage = {
+ title: string;
+ markdown: string;
+};
+
+function printHelp() {
+ console.log(`Export landing site content to Markdown files.
+
+Usage:
+ pnpm export:landing:md
+ pnpm export:landing:md --base-url http://127.0.0.1:4000
+ pnpm export:landing:md --routes /privacy,/roadmap
+ pnpm export:landing:md --out-dir artifacts/my-help-center-md
+
+Options:
+ --base-url Base URL to crawl (default: ${DEFAULT_BASE_URL})
+ --out-dir Output directory for markdown files (default: ${DEFAULT_OUT_DIR})
+ --routes Comma-separated routes to export (default: all landing routes)
+ --no-start-server Do not auto-start apps/landing dev server
+ -h, --help Show this help
+`);
+}
+
+function normalizeRoute(value: string): string {
+ const trimmed = value.trim();
+ if (!trimmed || trimmed === "/") {
+ return "/";
+ }
+ const normalized = trimmed.replace(/^\/+|\/+$/g, "");
+ return `/${normalized}`;
+}
+
+function sanitizeFileName(value: string): string {
+ return value
+ .toLowerCase()
+ .replace(/[^a-z0-9-]+/g, "-")
+ .replace(/-+/g, "-")
+ .replace(/(^-|-$)/g, "");
+}
+
+function routeToFileName(route: string): string {
+ if (route === "/") {
+ return "home.md";
+ }
+ const base = sanitizeFileName(route.slice(1).replace(/\//g, "-"));
+ return `${base || "page"}.md`;
+}
+
+function normalizeBaseUrl(value: string): string {
+ const parsed = new URL(value);
+ if (parsed.pathname === "/") {
+ parsed.pathname = "";
+ }
+ return parsed.toString().replace(/\/$/, "");
+}
+
+function parseCliArgs(argv: string[]): CliOptions | null {
+ const options: CliOptions = {
+ baseUrl: DEFAULT_BASE_URL,
+ outDir: path.resolve(process.cwd(), DEFAULT_OUT_DIR),
+ noStartServer: false,
+ routeFilters: [],
+ };
+
+ for (let index = 0; index < argv.length; index += 1) {
+ const arg = argv[index];
+ if (arg === "-h" || arg === "--help") {
+ printHelp();
+ return null;
+ }
+
+ if (arg === "--no-start-server") {
+ options.noStartServer = true;
+ continue;
+ }
+
+ if (arg === "--base-url" || arg === "--out-dir" || arg === "--routes") {
+ const nextValue = argv[index + 1];
+ if (!nextValue) {
+ throw new Error(`Missing value for ${arg}`);
+ }
+ index += 1;
+
+ if (arg === "--base-url") {
+ options.baseUrl = nextValue;
+ } else if (arg === "--out-dir") {
+ options.outDir = path.resolve(process.cwd(), nextValue);
+ } else {
+ options.routeFilters = nextValue
+ .split(",")
+ .map((item) => normalizeRoute(item))
+ .filter((item, itemIndex, arr) => arr.indexOf(item) === itemIndex);
+ }
+ continue;
+ }
+
+ throw new Error(`Unknown argument: ${arg}`);
+ }
+
+ options.baseUrl = normalizeBaseUrl(options.baseUrl);
+ return options;
+}
+
+async function collectLandingRoutes(dir: string, prefix = ""): Promise {
+ const entries = await fs.readdir(dir, { withFileTypes: true });
+ const routes: string[] = [];
+
+ for (const entry of entries) {
+ const entryPath = path.join(dir, entry.name);
+ if (entry.isDirectory()) {
+ const nestedPrefix = prefix ? path.join(prefix, entry.name) : entry.name;
+ routes.push(...(await collectLandingRoutes(entryPath, nestedPrefix)));
+ continue;
+ }
+
+ if (!entry.isFile()) {
+ continue;
+ }
+
+ const isPageFile = /^page\.(tsx|ts|jsx|js|mdx)$/.test(entry.name);
+ if (!isPageFile) {
+ continue;
+ }
+
+ const route = prefix ? `/${prefix.split(path.sep).join("/")}` : "/";
+ routes.push(normalizeRoute(route));
+ }
+
+ return routes;
+}
+
+async function isServerReachable(baseUrl: string): Promise {
+ try {
+ const response = await fetch(baseUrl, {
+ method: "GET",
+ });
+ return response.ok;
+ } catch {
+ return false;
+ }
+}
+
+function startLandingDevServer(): ChildProcessWithoutNullStreams {
+ const child = spawn("pnpm", ["--filter", "@opencom/landing", "dev"], {
+ cwd: process.cwd(),
+ env: process.env,
+ stdio: ["ignore", "pipe", "pipe"],
+ });
+
+ child.stdout.on("data", (chunk) => {
+ process.stdout.write(`[landing-dev] ${String(chunk)}`);
+ });
+ child.stderr.on("data", (chunk) => {
+ process.stderr.write(`[landing-dev] ${String(chunk)}`);
+ });
+
+ return child;
+}
+
+async function waitForServer(
+ baseUrl: string,
+ timeoutMs: number,
+ serverProcess?: ChildProcessWithoutNullStreams
+): Promise {
+ const startedAt = Date.now();
+ while (Date.now() - startedAt < timeoutMs) {
+ if (await isServerReachable(baseUrl)) {
+ return;
+ }
+
+ if (serverProcess && serverProcess.exitCode !== null) {
+ throw new Error(`Landing dev server exited early with code ${serverProcess.exitCode}.`);
+ }
+
+ await new Promise((resolve) => setTimeout(resolve, 1_000));
+ }
+
+ throw new Error(`Timed out waiting for landing site at ${baseUrl}`);
+}
+
+async function stopProcess(serverProcess?: ChildProcessWithoutNullStreams): Promise {
+ if (!serverProcess || serverProcess.exitCode !== null) {
+ return;
+ }
+
+ await new Promise((resolve) => {
+ const timeout = setTimeout(() => {
+ if (serverProcess.exitCode === null) {
+ serverProcess.kill("SIGKILL");
+ }
+ resolve();
+ }, 5_000);
+
+ serverProcess.once("exit", () => {
+ clearTimeout(timeout);
+ resolve();
+ });
+
+ serverProcess.kill("SIGTERM");
+ });
+}
+
+function ensureTopHeading(markdown: string, title: string): string {
+ const trimmed = markdown.trim();
+ if (!trimmed) {
+ return `# ${title}\n`;
+ }
+ if (/^#\s+/m.test(trimmed)) {
+ return `${trimmed}\n`;
+ }
+ return `# ${title}\n\n${trimmed}\n`;
+}
+
+function cleanExtractedMarkdown(markdown: string, route: string): string {
+ let lines = markdown.split("\n");
+
+ if (route === "/") {
+ lines = lines.filter((line) => {
+ const trimmed = line.trim();
+ if (!trimmed) {
+ return true;
+ }
+ if (/^\d+$/.test(trimmed)) {
+ return false;
+ }
+ if (/^\d+[smhd] ago$/i.test(trimmed)) {
+ return false;
+ }
+ return true;
+ });
+
+ const startIndex = lines.findIndex((line) => line.trim() === "Live Inbox");
+ if (startIndex !== -1) {
+ const endIndex = lines.findIndex(
+ (line, index) => index > startIndex && line.trim().startsWith("Ask AI Agent:")
+ );
+ if (endIndex !== -1) {
+ lines.splice(startIndex, endIndex - startIndex + 1);
+ }
+ }
+ }
+
+ const firstHeadingIndex = lines.findIndex((line) => /^#\s+/.test(line.trim()));
+ if (firstHeadingIndex > 0) {
+ const prefix = lines.slice(0, firstHeadingIndex).filter((line) => line.trim().length > 0);
+ const shouldDropPrefix =
+ prefix.length > 0 && prefix.length <= 2 && prefix.every((line) => line.trim().length <= 50);
+ if (shouldDropPrefix) {
+ lines = lines.slice(firstHeadingIndex);
+ }
+ }
+
+ return lines
+ .join("\n")
+ .replace(/\n{3,}/g, "\n\n")
+ .trim();
+}
+
+async function extractMarkdownFromPage(page: Page): Promise {
+ const extracted = await page.evaluate(
+ (script) => window.eval(script),
+ LANDING_MD_EXTRACTOR_SCRIPT
+ );
+ return extracted as ExtractedPage;
+}
+
+async function main() {
+ const options = parseCliArgs(process.argv.slice(2));
+ if (!options) {
+ return;
+ }
+
+ if (!existsSync(LANDING_APP_SRC_DIR)) {
+ throw new Error(`Landing app directory not found: ${LANDING_APP_SRC_DIR}`);
+ }
+
+ const discoveredRoutes = (await collectLandingRoutes(LANDING_APP_SRC_DIR)).sort((a, b) => {
+ if (a === "/") {
+ return -1;
+ }
+ if (b === "/") {
+ return 1;
+ }
+ return a.localeCompare(b);
+ });
+
+ if (discoveredRoutes.length === 0) {
+ throw new Error(`No landing routes found under ${LANDING_APP_SRC_DIR}`);
+ }
+
+ const selectedRoutes =
+ options.routeFilters.length > 0
+ ? discoveredRoutes.filter((route) => options.routeFilters.includes(route))
+ : discoveredRoutes;
+
+ if (selectedRoutes.length === 0) {
+ throw new Error(
+ `No routes matched filters: ${options.routeFilters.join(", ")}. Available routes: ${discoveredRoutes.join(", ")}`
+ );
+ }
+
+ for (const routeFilter of options.routeFilters) {
+ if (!discoveredRoutes.includes(routeFilter)) {
+ console.warn(`Skipping unknown route filter: ${routeFilter}`);
+ }
+ }
+
+ let serverProcess: ChildProcessWithoutNullStreams | undefined;
+ const records: ExportRecord[] = [];
+
+ try {
+ const reachable = await isServerReachable(options.baseUrl);
+ if (!reachable) {
+ if (options.noStartServer) {
+ throw new Error(
+ `Landing site is not reachable at ${options.baseUrl}. Start it with "pnpm --filter @opencom/landing dev" or remove --no-start-server.`
+ );
+ }
+
+ console.log(`Landing site not reachable at ${options.baseUrl}. Starting local dev server...`);
+ serverProcess = startLandingDevServer();
+ await waitForServer(options.baseUrl, SERVER_READY_TIMEOUT_MS, serverProcess);
+ }
+
+ await fs.mkdir(options.outDir, { recursive: true });
+
+ const browser = await chromium.launch({ headless: true });
+ try {
+ const context = await browser.newContext();
+ for (const route of selectedRoutes) {
+ const url = new URL(route, `${options.baseUrl}/`).toString();
+ const page = await context.newPage();
+ console.log(`Exporting ${route} -> ${url}`);
+
+ await page.goto(url, { waitUntil: "networkidle", timeout: NAVIGATION_TIMEOUT_MS });
+ await page.waitForSelector("main", { timeout: 15_000 });
+
+ const extracted = await extractMarkdownFromPage(page);
+ const fileName = routeToFileName(route);
+ const targetFile = path.join(options.outDir, fileName);
+ const cleanedMarkdown = cleanExtractedMarkdown(extracted.markdown, route);
+ const markdown = ensureTopHeading(cleanedMarkdown, extracted.title);
+
+ await fs.writeFile(targetFile, markdown, "utf8");
+ records.push({
+ route,
+ file: fileName,
+ title: extracted.title,
+ url,
+ });
+
+ await page.close();
+ }
+
+ await context.close();
+ } finally {
+ await browser.close();
+ }
+
+ const manifestPath = path.join(options.outDir, "manifest.json");
+ await fs.writeFile(
+ manifestPath,
+ `${JSON.stringify(
+ {
+ exportedAt: new Date().toISOString(),
+ baseUrl: options.baseUrl,
+ count: records.length,
+ pages: records,
+ },
+ null,
+ 2
+ )}\n`,
+ "utf8"
+ );
+
+ console.log(`\nExport complete.`);
+ console.log(`Markdown files: ${options.outDir}`);
+ console.log(`Manifest: ${manifestPath}`);
+ } finally {
+ await stopProcess(serverProcess);
+ }
+}
+
+main().catch((error) => {
+ console.error(error instanceof Error ? error.message : String(error));
+ process.exit(1);
+});
diff --git a/scripts/lib/landing-md-extractor.runtime.js b/scripts/lib/landing-md-extractor.runtime.js
new file mode 100644
index 0000000..279f0e8
--- /dev/null
+++ b/scripts/lib/landing-md-extractor.runtime.js
@@ -0,0 +1,274 @@
+(() => {
+ const root = document.querySelector("main");
+
+ function normalizeWhitespace(value) {
+ return value.replace(/\s+/g, " ").trim();
+ }
+
+ function collapseInline(value) {
+ return normalizeWhitespace(value).replace(/\s+([,.;:!?])/g, "$1");
+ }
+
+ function resolveHref(value) {
+ if (!value) {
+ return "";
+ }
+ if (value.startsWith("http://") || value.startsWith("https://")) {
+ return value;
+ }
+ if (value.startsWith("mailto:") || value.startsWith("tel:")) {
+ return value;
+ }
+ if (value.startsWith("#")) {
+ return value;
+ }
+ if (value.startsWith("/")) {
+ return value;
+ }
+ return value;
+ }
+
+ const ignoredTags = new Set([
+ "script",
+ "style",
+ "noscript",
+ "svg",
+ "canvas",
+ "iframe",
+ "video",
+ "audio",
+ "picture",
+ "img",
+ "source",
+ ]);
+
+ function serializeInline(node) {
+ if (node.nodeType === Node.TEXT_NODE) {
+ return node.textContent || "";
+ }
+ if (node.nodeType !== Node.ELEMENT_NODE) {
+ return "";
+ }
+
+ const element = node;
+ const tag = element.tagName.toLowerCase();
+
+ if (ignoredTags.has(tag) || element.getAttribute("aria-hidden") === "true") {
+ return "";
+ }
+
+ if (tag === "br") {
+ return "\n";
+ }
+
+ if (tag === "a") {
+ const href = resolveHref((element.getAttribute("href") || "").trim());
+ const label = collapseInline(
+ Array.from(element.childNodes)
+ .map((child) => serializeInline(child))
+ .join(" ")
+ );
+ const linkLabel = label || href;
+ if (!href || !linkLabel) {
+ return "";
+ }
+ return `[${linkLabel}](${href})`;
+ }
+
+ if (tag === "strong" || tag === "b") {
+ const content = collapseInline(
+ Array.from(element.childNodes)
+ .map((child) => serializeInline(child))
+ .join(" ")
+ );
+ return content ? `**${content}**` : "";
+ }
+
+ if (tag === "em" || tag === "i") {
+ const content = collapseInline(
+ Array.from(element.childNodes)
+ .map((child) => serializeInline(child))
+ .join(" ")
+ );
+ return content ? `*${content}*` : "";
+ }
+
+ if (
+ tag === "code" &&
+ (!element.parentElement || element.parentElement.tagName.toLowerCase() !== "pre")
+ ) {
+ const content = normalizeWhitespace(element.textContent || "");
+ return content ? `\`${content}\`` : "";
+ }
+
+ return Array.from(element.childNodes)
+ .map((child) => serializeInline(child))
+ .join(" ");
+ }
+
+ function serializeList(listElement, ordered) {
+ const items = Array.from(listElement.children).filter(
+ (child) => child.tagName.toLowerCase() === "li"
+ );
+
+ const lines = [];
+ items.forEach((item, index) => {
+ const primaryText = collapseInline(
+ Array.from(item.childNodes)
+ .filter((child) => {
+ if (child.nodeType !== Node.ELEMENT_NODE) {
+ return true;
+ }
+ const tag = child.tagName.toLowerCase();
+ return tag !== "ul" && tag !== "ol";
+ })
+ .map((child) => serializeInline(child))
+ .join(" ")
+ );
+
+ const marker = ordered ? `${index + 1}.` : "-";
+ if (primaryText) {
+ lines.push(`${marker} ${primaryText}`);
+ }
+
+ const nestedLists = Array.from(item.children).filter((child) => {
+ const tag = child.tagName.toLowerCase();
+ return tag === "ul" || tag === "ol";
+ });
+ nestedLists.forEach((nestedList) => {
+ const nestedMarkdown = serializeList(nestedList, nestedList.tagName.toLowerCase() === "ol");
+ if (!nestedMarkdown) {
+ return;
+ }
+ const indented = nestedMarkdown
+ .split("\n")
+ .map((line) => (line ? ` ${line}` : line))
+ .join("\n");
+ lines.push(indented);
+ });
+ });
+
+ return lines.join("\n");
+ }
+
+ function serializeBlock(node) {
+ if (node.nodeType === Node.TEXT_NODE) {
+ const text = collapseInline(node.textContent || "");
+ return text ? `${text}\n\n` : "";
+ }
+
+ if (node.nodeType !== Node.ELEMENT_NODE) {
+ return "";
+ }
+
+ const element = node;
+ const tag = element.tagName.toLowerCase();
+
+ if (ignoredTags.has(tag) || element.getAttribute("aria-hidden") === "true") {
+ return "";
+ }
+
+ if (
+ tag === "h1" ||
+ tag === "h2" ||
+ tag === "h3" ||
+ tag === "h4" ||
+ tag === "h5" ||
+ tag === "h6"
+ ) {
+ const level = Number.parseInt(tag.slice(1), 10);
+ const headingText = collapseInline(
+ Array.from(element.childNodes)
+ .map((child) => serializeInline(child))
+ .join(" ")
+ );
+ return headingText ? `${"#".repeat(level)} ${headingText}\n\n` : "";
+ }
+
+ if (tag === "p") {
+ const paragraph = collapseInline(
+ Array.from(element.childNodes)
+ .map((child) => serializeInline(child))
+ .join(" ")
+ );
+ return paragraph ? `${paragraph}\n\n` : "";
+ }
+
+ if (tag === "ul" || tag === "ol") {
+ const listMarkdown = serializeList(element, tag === "ol");
+ return listMarkdown ? `${listMarkdown}\n\n` : "";
+ }
+
+ if (tag === "pre") {
+ const code = (element.textContent || "")
+ .replace(/\u00a0/g, " ")
+ .replace(/\n{3,}/g, "\n\n")
+ .trim();
+ return code ? `\`\`\`\n${code}\n\`\`\`\n\n` : "";
+ }
+
+ if (tag === "a") {
+ const link = collapseInline(serializeInline(element));
+ return link ? `${link}\n\n` : "";
+ }
+
+ if (tag === "hr") {
+ return "---\n\n";
+ }
+
+ return Array.from(element.childNodes)
+ .map((child) => serializeBlock(child))
+ .join("");
+ }
+
+ if (!root) {
+ const fallbackTitle =
+ document.title.replace(/\s*\|\s*Opencom.*$/i, "").trim() || "Landing Page";
+ return {
+ title: fallbackTitle,
+ markdown: `# ${fallbackTitle}`,
+ };
+ }
+
+ const clonedRoot = root.cloneNode(true);
+ const removableSelectors = [
+ "script",
+ "style",
+ "noscript",
+ "svg",
+ "canvas",
+ "iframe",
+ "video",
+ "audio",
+ "picture",
+ "img",
+ "source",
+ "[aria-hidden='true']",
+ "[hidden]",
+ "template",
+ ];
+ removableSelectors.forEach((selector) => {
+ clonedRoot.querySelectorAll(selector).forEach((element) => element.remove());
+ });
+
+ let markdown = Array.from(clonedRoot.childNodes)
+ .map((child) => serializeBlock(child))
+ .join("");
+
+ markdown = markdown
+ .replace(/\r\n/g, "\n")
+ .replace(/[ \t]+\n/g, "\n")
+ .replace(/\n{3,}/g, "\n\n")
+ .trim();
+
+ const titleFromHeading = normalizeWhitespace(
+ (clonedRoot.querySelector("h1") || {}).textContent || ""
+ );
+ const titleFromDocument = normalizeWhitespace(document.title.replace(/\s*\|\s*Opencom.*$/i, ""));
+ const title = titleFromHeading || titleFromDocument || "Landing Page";
+
+ return {
+ title,
+ markdown,
+ };
+})();