From 4086750ddc0f3e7220354f3600c19fa61bf1ed51 Mon Sep 17 00:00:00 2001 From: Waleed Date: Thu, 2 Jul 2026 21:25:33 -0700 Subject: [PATCH] feat(comparison): add Sim vs Competitor comparison pages (#5383) * feat(comparison): add Sim vs Competitor comparison pages - New /comparison hub + /comparison/[provider] detail pages for Sim vs 16 competitors (n8n, Zapier, Make, Gumloop, Workato, Retool, Pipedream, OpenAI AgentKit, Tines, StackAI, Power Automate, Vellum, Claude Cowork, Langflow, Flowise), each with ~60 sourced, dated facts across platform, AI capabilities, integrations, pricing, security, observability, and support - Data layer at lib/compare/data (types, per-competitor profiles) is UI-free and independently auditable - BreadcrumbList, ItemList, and FAQPage JSON-LD per page; sitemap picks up all pages automatically via ALL_COMPETITORS - Two new fact categories (parallel execution, Agent2Agent protocol) researched and sourced against every competitor's own docs * improvement(comparison): neutralize tone across competitor and Sim fact copy - Remove promotional/superlative adjectives applied to competitors (Zapier "one of the largest catalogs", Workato "notably wide", Retool "seamless"/"trusted solution", OpenAI "cutting-edge", Flowise "deep"/"mature"/"most widely adopted", Make "robust") - Reword the few places Sim's own accurate limitations read as unflattering rather than neutral fact (dynamic tool use, model fallback, durability, async execution, support channels, tracing) without changing the underlying facts - Restore the "Yes:"/"No:" value prefix on two Sim facts where it was accidentally dropped during rewording, since FactValue depends on that prefix to render the check/X status icon * fix(comparison): address review findings from Greptile and Cursor Bugbot - Fix parseFactValue regex to require a word boundary after Yes/No, so values like "Not documented"/"Not publicly documented" no longer get misread as a boolean "No" and render as neutral text again (Cursor) - Reword the self-hosting FAQ question to drop the false presupposition that the competitor doesn't self-host, which was wrong for n8n, Langflow, and Flowise (Greptile) - Rename isLastRow -> isNotLastRow in comparison-table.tsx to match what the variable actually computes (Greptile) - Move critters to devDependencies; it's a next build -time-only CSS inliner, not needed at runtime (Greptile) - Sitemap lastModified for comparison pages now matches the max(Sim, competitor) verified-date logic each page's own JSON-LD dateModified already uses, so the two never disagree (Cursor) * fix(comparison): fix doubled Yes prefix and missing period in hub FAQ - Two hub FAQ answers prepended "Yes." to fact values that already start with "Yes:", producing "Yes. Yes: ..." in visible copy and FAQPage JSON-LD. Export and reuse ensurePeriod instead of a hardcoded prefix. - The integrations-count FAQ answer ran two sentences together with no period before "Combined with...". Fixed with the same ensurePeriod helper. * improvement(comparison): remove redundant Key differences at a glance section The 5 facts it previewed (self-hosting, environment promotion, human-in-the-loop, pricing model, data residency) are the exact same rows shown again immediately below in the full comparison table, so the section read as pure repetition for a human scrolling past it. * fix(comparison): strip Yes/No prefix before lowercasing in summarizeFact summarizeFact fed the raw fact value through lowercaseFirst even when it started with "Yes: "/"No: ", producing broken mid-sentence FAQ text like "n8n: yes: many providers via dedicated Chat Model nodes." Strip the boolean prefix first, matching Greptile's suggested fix. --- .../(landing)/comparison/[provider]/page.tsx | 317 +++++ .../comparison/comparison-sections.ts | 154 +++ .../brand-icon-tile/brand-icon-tile.tsx | 83 ++ .../components/brand-icon-tile/index.ts | 2 + .../comparison-cards/comparison-cards.tsx | 47 + .../components/comparison-cards/index.ts | 2 + .../comparison-table/comparison-table.tsx | 151 ++ .../components/comparison-table/index.ts | 2 + .../components/fact-value/fact-value.tsx | 67 + .../comparison/components/fact-value/index.ts | 2 + .../components/source-info/index.ts | 2 + .../components/source-info/source-info.tsx | 40 + .../app/(landing)/comparison/fact-status.ts | 34 + .../app/(landing)/comparison/not-found.tsx | 26 + apps/sim/app/(landing)/comparison/page.tsx | 194 +++ apps/sim/app/(landing)/comparison/utils.ts | 214 +++ .../(landing)/components/footer/footer.tsx | 1 + apps/sim/app/sitemap.ts | 24 + apps/sim/components/icons.tsx | 145 ++ .../compare/data/competitors/claude-cowork.ts | 1064 ++++++++++++++ .../lib/compare/data/competitors/flowise.ts | 842 +++++++++++ .../lib/compare/data/competitors/gumloop.ts | 1093 +++++++++++++++ .../lib/compare/data/competitors/langflow.ts | 883 ++++++++++++ apps/sim/lib/compare/data/competitors/make.ts | 1232 +++++++++++++++++ apps/sim/lib/compare/data/competitors/n8n.ts | 1218 ++++++++++++++++ .../data/competitors/openai-agentkit.ts | 1163 ++++++++++++++++ .../lib/compare/data/competitors/pipedream.ts | 1069 ++++++++++++++ .../data/competitors/power-automate.ts | 1184 ++++++++++++++++ .../lib/compare/data/competitors/retool.ts | 1079 +++++++++++++++ .../lib/compare/data/competitors/stackai.ts | 940 +++++++++++++ .../sim/lib/compare/data/competitors/tines.ts | 1096 +++++++++++++++ .../lib/compare/data/competitors/vellum.ts | 991 +++++++++++++ .../lib/compare/data/competitors/workato.ts | 1220 ++++++++++++++++ .../lib/compare/data/competitors/zapier.ts | 1158 ++++++++++++++++ apps/sim/lib/compare/data/feature-catalog.ts | 925 +++++++++++++ apps/sim/lib/compare/data/index.ts | 27 + apps/sim/lib/compare/data/sim.ts | 1070 ++++++++++++++ apps/sim/lib/compare/data/types.ts | 290 ++++ apps/sim/package.json | 1 + bun.lock | 19 +- 40 files changed, 20065 insertions(+), 6 deletions(-) create mode 100644 apps/sim/app/(landing)/comparison/[provider]/page.tsx create mode 100644 apps/sim/app/(landing)/comparison/comparison-sections.ts create mode 100644 apps/sim/app/(landing)/comparison/components/brand-icon-tile/brand-icon-tile.tsx create mode 100644 apps/sim/app/(landing)/comparison/components/brand-icon-tile/index.ts create mode 100644 apps/sim/app/(landing)/comparison/components/comparison-cards/comparison-cards.tsx create mode 100644 apps/sim/app/(landing)/comparison/components/comparison-cards/index.ts create mode 100644 apps/sim/app/(landing)/comparison/components/comparison-table/comparison-table.tsx create mode 100644 apps/sim/app/(landing)/comparison/components/comparison-table/index.ts create mode 100644 apps/sim/app/(landing)/comparison/components/fact-value/fact-value.tsx create mode 100644 apps/sim/app/(landing)/comparison/components/fact-value/index.ts create mode 100644 apps/sim/app/(landing)/comparison/components/source-info/index.ts create mode 100644 apps/sim/app/(landing)/comparison/components/source-info/source-info.tsx create mode 100644 apps/sim/app/(landing)/comparison/fact-status.ts create mode 100644 apps/sim/app/(landing)/comparison/not-found.tsx create mode 100644 apps/sim/app/(landing)/comparison/page.tsx create mode 100644 apps/sim/app/(landing)/comparison/utils.ts create mode 100644 apps/sim/lib/compare/data/competitors/claude-cowork.ts create mode 100644 apps/sim/lib/compare/data/competitors/flowise.ts create mode 100644 apps/sim/lib/compare/data/competitors/gumloop.ts create mode 100644 apps/sim/lib/compare/data/competitors/langflow.ts create mode 100644 apps/sim/lib/compare/data/competitors/make.ts create mode 100644 apps/sim/lib/compare/data/competitors/n8n.ts create mode 100644 apps/sim/lib/compare/data/competitors/openai-agentkit.ts create mode 100644 apps/sim/lib/compare/data/competitors/pipedream.ts create mode 100644 apps/sim/lib/compare/data/competitors/power-automate.ts create mode 100644 apps/sim/lib/compare/data/competitors/retool.ts create mode 100644 apps/sim/lib/compare/data/competitors/stackai.ts create mode 100644 apps/sim/lib/compare/data/competitors/tines.ts create mode 100644 apps/sim/lib/compare/data/competitors/vellum.ts create mode 100644 apps/sim/lib/compare/data/competitors/workato.ts create mode 100644 apps/sim/lib/compare/data/competitors/zapier.ts create mode 100644 apps/sim/lib/compare/data/feature-catalog.ts create mode 100644 apps/sim/lib/compare/data/index.ts create mode 100644 apps/sim/lib/compare/data/sim.ts create mode 100644 apps/sim/lib/compare/data/types.ts diff --git a/apps/sim/app/(landing)/comparison/[provider]/page.tsx b/apps/sim/app/(landing)/comparison/[provider]/page.tsx new file mode 100644 index 0000000000..03ffb880ee --- /dev/null +++ b/apps/sim/app/(landing)/comparison/[provider]/page.tsx @@ -0,0 +1,317 @@ +import type { Metadata } from 'next' +import { notFound } from 'next/navigation' +import type { CompetitorProfile } from '@/lib/compare/data' +import { simProfile } from '@/lib/compare/data' +import { SITE_URL } from '@/lib/core/utils/urls' +import { buildLandingMetadata } from '@/lib/landing/seo' +import { COMPARISON_SECTIONS, getFactGroup } from '@/app/(landing)/comparison/comparison-sections' +import { BrandIconTile, SimIconTile } from '@/app/(landing)/comparison/components/brand-icon-tile' +import { ComparisonCards } from '@/app/(landing)/comparison/components/comparison-cards' +import { ComparisonTable } from '@/app/(landing)/comparison/components/comparison-table' +import { + ALL_COMPETITORS, + buildBottomLine, + buildComparisonFaqs, + getCompetitorBySlug, + getLatestVerifiedDate, + SIM_LATEST_VERIFIED, +} from '@/app/(landing)/comparison/utils' +import { BackLink } from '@/app/(landing)/components' +import { Cta } from '@/app/(landing)/components/cta/cta' +import { JsonLd } from '@/app/(landing)/components/json-ld' +import { LandingFAQ } from '@/app/(landing)/components/landing-faq' + +const baseUrl = SITE_URL + +export const revalidate = 3600 +export const dynamicParams = false + +export async function generateStaticParams() { + return ALL_COMPETITORS.map((competitor) => ({ provider: competitor.id })) +} + +/** Flattens a profile's facts into JSON-LD `additionalProperty` entries, in {@link COMPARISON_SECTIONS} order. */ +function factsToProperties(profile: CompetitorProfile) { + return COMPARISON_SECTIONS.flatMap((section) => { + const group = getFactGroup(profile, section.group) + return section.rows.map((row) => ({ + '@type': 'PropertyValue', + name: row.label, + value: group[row.key]?.value ?? 'Unknown', + })) + }) +} + +export async function generateMetadata({ + params, +}: { + params: Promise<{ provider: string }> +}): Promise { + const { provider: providerSlug } = await params + const competitor = getCompetitorBySlug(providerSlug) + + if (!competitor) { + return {} + } + + return buildLandingMetadata({ + title: `Sim vs ${competitor.name}: AI Workspace Comparison`, + description: `Compare Sim, the open-source AI workspace, to ${competitor.name} on platform, AI, integrations, pricing, security, and support. Sourced and dated facts.`, + path: `/comparison/${competitor.id}`, + keywords: [ + `Sim vs ${competitor.name}`, + `${competitor.name} alternative`, + `${competitor.name} vs Sim`, + `open source ${competitor.name} alternative`, + `${competitor.name} comparison`, + 'AI agent workspace', + 'AI workflow automation comparison', + ].join(', '), + }) +} + +export default async function ComparisonProviderPage({ + params, +}: { + params: Promise<{ provider: string }> +}) { + const { provider: providerSlug } = await params + const competitor = getCompetitorBySlug(providerSlug) + + if (!competitor) { + notFound() + } + + const faqs = buildComparisonFaqs(competitor) + const verdict = buildBottomLine(competitor) + const CompetitorIcon = competitor.brand?.icon + + const breadcrumbJsonLd = { + '@context': 'https://schema.org', + '@type': 'BreadcrumbList', + itemListElement: [ + { '@type': 'ListItem', position: 1, name: 'Home', item: baseUrl }, + { '@type': 'ListItem', position: 2, name: 'Comparison', item: `${baseUrl}/comparison` }, + { + '@type': 'ListItem', + position: 3, + name: `Sim vs ${competitor.name}`, + item: `${baseUrl}/comparison/${competitor.id}`, + }, + ], + } + + const latestVerified = new Date( + Math.max(SIM_LATEST_VERIFIED.getTime(), getLatestVerifiedDate(competitor).getTime()) + ) + + const productComparisonJsonLd = { + '@context': 'https://schema.org', + '@type': 'ItemList', + name: `Sim vs ${competitor.name}`, + description: `Feature and pricing comparison between Sim and ${competitor.name}.`, + url: `${baseUrl}/comparison/${competitor.id}`, + dateModified: latestVerified.toISOString().slice(0, 10), + numberOfItems: 2, + itemListElement: [ + { + '@type': 'ListItem', + position: 1, + item: { + '@type': 'SoftwareApplication', + name: 'Sim', + applicationCategory: 'BusinessApplication', + url: SITE_URL, + description: simProfile.oneLiner, + additionalProperty: factsToProperties(simProfile), + }, + }, + { + '@type': 'ListItem', + position: 2, + item: { + '@type': 'SoftwareApplication', + name: competitor.name, + applicationCategory: 'BusinessApplication', + url: competitor.website, + description: competitor.oneLiner, + additionalProperty: factsToProperties(competitor), + }, + }, + ], + } + + const faqJsonLd = { + '@context': 'https://schema.org', + '@type': 'FAQPage', + mainEntity: faqs.map((faq) => ({ + '@type': 'Question', + name: faq.question, + acceptedAnswer: { + '@type': 'Answer', + text: faq.answer, + }, + })), + } + + return ( + <> + + + + +
+
+
+ +
+ +
+

+ Sim vs {competitor.name} +

+

+ Sim is the open-source AI workspace where teams build, deploy, and manage AI agents + visually, conversationally, or with code. Here is how Sim compares to{' '} + {competitor.name} on platform architecture, AI capabilities, integrations, pricing, + security, and support. Every fact below is sourced and dated. +

+

+ Sim is an open-source AI workspace for building, deploying, and managing AI agents. + This page compares Sim to {competitor.name} across platform architecture, AI + capabilities, integrations, pricing, security and compliance, observability, and + support, using sourced, dated facts for buyers evaluating both platforms. +

+
+
+ +
+ +
+
+
+
+

+ + What is Sim? +

+

+ {simProfile.oneLiner} +

+
+
+

+ {CompetitorIcon ? ( + + ) : null} + What is {competitor.name}? +

+

+ {competitor.oneLiner} +

+
+
+ +
+ +
+

+ Sim vs {competitor.name}: feature-by-feature comparison +

+ +
+ +
+ +
+
+
+

+ Sim standout features +

+
+ +
+
+
+

+ Documented {competitor.name} limitations +

+
+ +
+
+ +
+ +
+

+ Bottom line +

+
+

+ {verdict.chooseSim} +

+

+ {verdict.chooseCompetitor} +

+
+
+ +
+ +
+

+ Frequently asked questions +

+
+ +
+
+
+
+ +
+
+ +
+ +
+ + ) +} diff --git a/apps/sim/app/(landing)/comparison/comparison-sections.ts b/apps/sim/app/(landing)/comparison/comparison-sections.ts new file mode 100644 index 0000000000..609955be3d --- /dev/null +++ b/apps/sim/app/(landing)/comparison/comparison-sections.ts @@ -0,0 +1,154 @@ +import type { ComparisonFacts, CompetitorProfile, Fact } from '@/lib/compare/data' + +/** + * The one place a {@link ComparisonFacts} group is read back out of a profile + * by group key. Every render-side consumer (the table, key-differences strip, + * JSON-LD builder) needs this same lookup; centralizing it here means there is + * exactly one cast to reason about instead of one per call site. + */ +export function getFactGroup( + profile: CompetitorProfile, + group: G +): Record { + return profile.facts[group] as Record +} + +/** + * One row in a comparison table section. Maps a human label to a fact key + * within a {@link ComparisonFacts} group. `key` is intentionally `string` + * (rather than a per-group `keyof` union) so a single array can hold rows + * for every group without TypeScript collapsing the distributed generic to + * `never`; correctness is enforced once, by construction, in + * {@link COMPARISON_SECTIONS} below, and the renderer reads through it. + */ +export interface ComparisonRowDef { + key: string + label: string +} + +/** One section of the comparison table, mirroring a {@link ComparisonFacts} group. */ +export interface ComparisonSectionDef { + group: keyof ComparisonFacts + title: string + rows: ComparisonRowDef[] +} + +/** + * Type-checks a section's rows against its own group's actual fact keys + * (via the per-call generic `G`), then widens to the plain `ComparisonRowDef` + * shape used by {@link COMPARISON_SECTIONS}. This is where row-key + * correctness is actually enforced. A typo here fails the build. + */ +function defineSection(section: { + group: G + title: string + rows: Array<{ key: keyof ComparisonFacts[G]; label: string }> +}): ComparisonSectionDef { + return section as ComparisonSectionDef +} + +/** + * Canonical section/row order for rendering a {@link ComparisonFacts} profile + * pair as a table. Single source of truth for row labels. Add a field here + * once and every comparison page picks it up. + */ +export const COMPARISON_SECTIONS: ComparisonSectionDef[] = [ + defineSection({ + group: 'platform', + title: 'Platform', + rows: [ + { key: 'builderType', label: 'Builder type' }, + { key: 'learningCurve', label: 'Learning curve' }, + { key: 'selfHostOption', label: 'Self-hosting' }, + { key: 'deploymentOptions', label: 'Deployment options' }, + { key: 'templates', label: 'Templates' }, + { key: 'license', label: 'License' }, + { key: 'environmentPromotion', label: 'Environment promotion' }, + { key: 'versionControlDepth', label: 'Version control' }, + { key: 'realtimeCollaboration', label: 'Realtime collaboration' }, + { key: 'nativeFileStorage', label: 'Native file storage' }, + ], + }), + defineSection({ + group: 'pricing', + title: 'Pricing', + rows: [ + { key: 'pricingModel', label: 'Pricing model' }, + { key: 'entryPaidPlan', label: 'Entry paid plan' }, + { key: 'freeTier', label: 'Free tier' }, + { key: 'byok', label: 'Bring your own key' }, + ], + }), + defineSection({ + group: 'security', + title: 'Security & compliance', + rows: [ + { key: 'soc2', label: 'SOC 2' }, + { key: 'dataResidency', label: 'Data residency' }, + { key: 'rbac', label: 'Role-based access control' }, + { key: 'auditLogging', label: 'Audit logging' }, + { key: 'additionalCompliance', label: 'Additional compliance' }, + { key: 'modelAndToolGovernance', label: 'Model & tool governance' }, + { key: 'credentialGovernance', label: 'Credential governance' }, + { key: 'sso', label: 'Single sign-on (SSO)' }, + { key: 'piiRedaction', label: 'PII redaction' }, + { key: 'dataRetention', label: 'Custom data retention' }, + { key: 'whiteLabeling', label: 'White-labeling' }, + ], + }), + defineSection({ + group: 'aiCapabilities', + title: 'AI capabilities', + rows: [ + { key: 'multiLlmSupport', label: 'Multi-LLM support' }, + { key: 'agentReasoningBlocks', label: 'Agent reasoning blocks' }, + { key: 'naturalLanguageBuilding', label: 'Natural-language building' }, + { key: 'knowledgeBaseRag', label: 'Knowledge base / RAG' }, + { key: 'mcpSupport', label: 'MCP support' }, + { key: 'evaluationGuardrails', label: 'Evaluation & guardrails' }, + { key: 'humanInTheLoop', label: 'Human-in-the-loop' }, + { key: 'generativeMedia', label: 'Generative media' }, + { key: 'dynamicToolUse', label: 'Dynamic tool use' }, + { key: 'modelFallback', label: 'Automatic model fallback' }, + { key: 'agentSkills', label: 'Agent skills' }, + { key: 'nativeChatDeployment', label: 'Native chat deployment' }, + { key: 'parallelExecution', label: 'Parallel execution' }, + { key: 'a2aProtocol', label: 'Agent2Agent (A2A) protocol' }, + ], + }), + defineSection({ + group: 'integrations', + title: 'Integrations', + rows: [ + { key: 'integrationCount', label: 'Integrations' }, + { key: 'triggerTypes', label: 'Trigger types' }, + { key: 'customCodeSteps', label: 'Custom code steps' }, + { key: 'apiPublishing', label: 'API publishing' }, + { key: 'extensibilitySdk', label: 'SDKs & extensibility' }, + { key: 'mcpPublishing', label: 'Publish as MCP server' }, + ], + }), + defineSection({ + group: 'observability', + title: 'Observability & durability', + rows: [ + { key: 'tracingDepth', label: 'Tracing & observability' }, + { key: 'durabilityModel', label: 'Durability & retries' }, + { key: 'failureAlerting', label: 'Failure alerting' }, + { key: 'dataDrains', label: 'Data drains' }, + { key: 'asyncExecution', label: 'Async execution' }, + { key: 'executionLimits', label: 'Execution limits' }, + { key: 'partialFailureHandling', label: 'Partial-failure handling' }, + ], + }), + defineSection({ + group: 'support', + title: 'Support', + rows: [ + { key: 'supportChannels', label: 'Support channels' }, + { key: 'sla', label: 'SLA' }, + { key: 'community', label: 'Community' }, + { key: 'academy', label: 'Academy / training' }, + ], + }), +] diff --git a/apps/sim/app/(landing)/comparison/components/brand-icon-tile/brand-icon-tile.tsx b/apps/sim/app/(landing)/comparison/components/brand-icon-tile/brand-icon-tile.tsx new file mode 100644 index 0000000000..6c8911a805 --- /dev/null +++ b/apps/sim/app/(landing)/comparison/components/brand-icon-tile/brand-icon-tile.tsx @@ -0,0 +1,83 @@ +import type { ComponentType, SVGProps } from 'react' +import { cn } from '@sim/emcn' +import type { CompetitorBrand } from '@/lib/compare/data' +import { SimWordmark } from '@/app/(landing)/components/navbar/components/sim-wordmark' + +export interface BrandIconTileProps { + icon: ComponentType> + /** + * Whether `icon` already renders a full, self-contained brand-colored + * square (e.g. a fetched app-store-style logo) rather than a bare + * transparent glyph. See {@link CompetitorBrand.selfFramed}. + */ + selfFramed?: boolean + /** Outer tile size, e.g. `size-8`. Defaults to the integrations-page card size. */ + className?: string + /** Icon glyph size inside the tile, e.g. `size-4`. Ignored when `selfFramed`. */ + iconClassName?: string +} + +/** + * A rounded, bordered icon tile matching the platform's app-icon chrome + * conventions (border radius, border token, background), so competitor brand + * logos read as the same first-class "app icon" chrome as the rest of the + * product, instead of a bare, unframed SVG floating in the layout. + * + * A self-framed logo (already a complete brand-colored square) fills the + * tile edge-to-edge, clipped to the same rounded corners. Otherwise the icon + * is a small transparent glyph centered on a plain bordered background. + */ +export function BrandIconTile({ + icon: Icon, + selfFramed = false, + className = 'size-8', + iconClassName = 'size-4', +}: BrandIconTileProps) { + if (selfFramed) { + return ( +
+
+ ) + } + return ( +
+
+ ) +} + +export interface SimIconTileProps { + /** Outer tile size, e.g. `size-8`. Defaults to the integrations-page card size. */ + className?: string +} + +/** + * The same rounded, bordered tile as {@link BrandIconTile}, but for Sim's own + * wordmark. So "Sim" gets the identical icon-chip treatment as every + * competitor it's compared against, instead of appearing as bare text. + */ +export function SimIconTile({ className = 'size-8' }: SimIconTileProps) { + return ( +
+ + + +
+ ) +} diff --git a/apps/sim/app/(landing)/comparison/components/brand-icon-tile/index.ts b/apps/sim/app/(landing)/comparison/components/brand-icon-tile/index.ts new file mode 100644 index 0000000000..fe31f6d7a2 --- /dev/null +++ b/apps/sim/app/(landing)/comparison/components/brand-icon-tile/index.ts @@ -0,0 +1,2 @@ +export type { BrandIconTileProps, SimIconTileProps } from './brand-icon-tile' +export { BrandIconTile, SimIconTile } from './brand-icon-tile' diff --git a/apps/sim/app/(landing)/comparison/components/comparison-cards/comparison-cards.tsx b/apps/sim/app/(landing)/comparison/components/comparison-cards/comparison-cards.tsx new file mode 100644 index 0000000000..8a3d62e474 --- /dev/null +++ b/apps/sim/app/(landing)/comparison/components/comparison-cards/comparison-cards.tsx @@ -0,0 +1,47 @@ +import type { FactSource } from '@/lib/compare/data' +import { SourceLink } from '@/app/(landing)/comparison/components/source-info' + +interface ComparisonCardItem { + title: string + description: string + shortDescription?: string + source: FactSource +} + +export interface ComparisonCardsProps { + items: ComparisonCardItem[] +} + +/** + * A vertically stacked list of atomic, independently quotable fact cards, + * each self-contained title + a one-line `shortDescription` (falling back to + * `description` if a short version hasn't been authored yet). Used for both + * a competitor's standout features and its documented limitations. + * + * The full `description` is always present as `sr-only` text. Server + * rendered regardless of hover/JS state. So an LLM or crawler reading the + * page still gets the complete claim even though a human sees only the + * one-line summary. Hovering the title itself (`SourceLink`) shows a short + * "Source: X" tooltip and clicking it opens the source, rather than a + * separate info-icon affordance next to every card. + */ +export function ComparisonCards({ items }: ComparisonCardsProps) { + return ( +
+ {items.map((item, index) => ( +
0 ? 'border-[var(--border)] border-t px-6 py-4' : 'px-6 py-4'} + > +

+ {item.title} +

+

+ {item.shortDescription ?? item.description} +

+ {item.shortDescription ? {item.description} : null} +
+ ))} +
+ ) +} diff --git a/apps/sim/app/(landing)/comparison/components/comparison-cards/index.ts b/apps/sim/app/(landing)/comparison/components/comparison-cards/index.ts new file mode 100644 index 0000000000..e5fa934bb4 --- /dev/null +++ b/apps/sim/app/(landing)/comparison/components/comparison-cards/index.ts @@ -0,0 +1,2 @@ +export type { ComparisonCardsProps } from './comparison-cards' +export { ComparisonCards } from './comparison-cards' diff --git a/apps/sim/app/(landing)/comparison/components/comparison-table/comparison-table.tsx b/apps/sim/app/(landing)/comparison/components/comparison-table/comparison-table.tsx new file mode 100644 index 0000000000..70a21ae4e2 --- /dev/null +++ b/apps/sim/app/(landing)/comparison/components/comparison-table/comparison-table.tsx @@ -0,0 +1,151 @@ +import type { ReactNode } from 'react' +import { cn } from '@sim/emcn' +import type { CompetitorProfile } from '@/lib/compare/data' +import { COMPARISON_SECTIONS, getFactGroup } from '@/app/(landing)/comparison/comparison-sections' +import { BrandIconTile, SimIconTile } from '@/app/(landing)/comparison/components/brand-icon-tile' +import { FactValue } from '@/app/(landing)/comparison/components/fact-value' + +export interface ComparisonTableProps { + sim: CompetitorProfile + competitor: CompetitorProfile +} + +function ColumnHeader({ + name, + iconTile, + isSim, +}: { + name: string + iconTile: ReactNode + isSim: boolean +}) { + return ( +
+ {iconTile} + {name} +
+ ) +} + +/** + * Two-column "Sim vs {Competitor}" fact table, styled after the billing + * upgrade-page comparison table (same border/hairline rhythm and section + * headers) but data-driven off {@link CompetitorProfile.facts} instead of the + * fixed 4-tier plan schema. Data cells share one neutral surface for both + * columns. The Sim column is called out only in the header row (a bottom + * accent border), so the table reads as one clean grid rather than a + * checkerboard. Pure server component: every value is plain server-rendered + * text so crawlers and AI answer engines read the full comparison without + * any client-side hydration. + */ +export function ComparisonTable({ sim, competitor }: ComparisonTableProps) { + return ( +
+
+
+
+ Compare + + {sim.name} vs {competitor.name} + +
+ } isSim /> + + ) : null + } + isSim={false} + /> +
+ + {COMPARISON_SECTIONS.map((section, sectionIdx) => { + const simGroupFacts = getFactGroup(sim, section.group) + const competitorGroupFacts = getFactGroup(competitor, section.group) + + return ( +
+
+
0 && 'border-[var(--border-1)] border-t' + )} + > + + {section.title} + +
+
0 && 'border-[var(--border-1)] border-t' + )} + /> +
+ + {section.rows.map((row, rowIdx) => { + const simFact = simGroupFacts[row.key] + const competitorFact = competitorGroupFacts[row.key] + const isNotLastRow = rowIdx < section.rows.length - 1 + + return ( +
+
+ {row.label} +
+
+ +
+
+ +
+
+ ) + })} +
+ ) + })} +
+
+ ) +} diff --git a/apps/sim/app/(landing)/comparison/components/comparison-table/index.ts b/apps/sim/app/(landing)/comparison/components/comparison-table/index.ts new file mode 100644 index 0000000000..18ea827718 --- /dev/null +++ b/apps/sim/app/(landing)/comparison/components/comparison-table/index.ts @@ -0,0 +1,2 @@ +export type { ComparisonTableProps } from './comparison-table' +export { ComparisonTable } from './comparison-table' diff --git a/apps/sim/app/(landing)/comparison/components/fact-value/fact-value.tsx b/apps/sim/app/(landing)/comparison/components/fact-value/fact-value.tsx new file mode 100644 index 0000000000..2e95baf01d --- /dev/null +++ b/apps/sim/app/(landing)/comparison/components/fact-value/fact-value.tsx @@ -0,0 +1,67 @@ +import { Check, X } from '@sim/emcn/icons' +import type { Fact } from '@/lib/compare/data' +import { SourceLink } from '@/app/(landing)/comparison/components/source-info' +import { parseFactValue } from '@/app/(landing)/comparison/fact-status' + +export interface FactValueProps { + fact: Fact +} + +/** + * Renders one {@link Fact} for a glancing reader while keeping the full + * granular fact server-rendered for crawlers and AI answer engines. + * + * - A true "Yes"/"No" fact renders as an icon alone (a monochrome check or + * muted cross, no colored pass/fail styling), no visible text, since the + * label column and surrounding context already say what's being asked. + * - Any other fact shows its `shortValue` (a compact, pre-authored + * restatement of `value`), never the full sentence. + * - `Tooltip` here is a cursor-following mini-bubble meant for a short + * one-line label (see its own docs/usages: "Refresh", "last updated: X") + * . It is deliberately NOT used to hold paragraph-length detail text, only + * the compact source citation, which is exactly what it's designed for. + * - When a source exists, the visible glance (icon or `shortValue` text) + * IS the hover/click target for that source, via `SourceLink`, rather + * than a separate info-icon next to every value. One affordance per + * fact keeps a 58-row table from reading as icon-cluttered. + * - A `sr-only` span always carries the complete value, detail, and source + * in the initial server-rendered HTML, independent of hover/JS state, so + * an LLM or crawler reading the page gets full granularity even though a + * human sees only the compact glance. + */ +export function FactValue({ fact }: FactValueProps) { + const { status, text } = parseFactValue(fact.value) + const isBoolean = status === 'yes' || status === 'no' + const primarySource = fact.sources[0] + + const fullText = [fact.value, fact.detail].filter(Boolean).join('. ') + + const glance = isBoolean ? ( + status === 'yes' ? ( +