diff --git a/apps/sim/app/(landing)/components/landing-faq.tsx b/apps/sim/app/(landing)/components/landing-faq.tsx index 9873003901f..3cebc334081 100644 --- a/apps/sim/app/(landing)/components/landing-faq.tsx +++ b/apps/sim/app/(landing)/components/landing-faq.tsx @@ -1,7 +1,7 @@ 'use client' -import { useState } from 'react' -import { AnimatePresence, domAnimation, LazyMotion, m } from 'framer-motion' +import { useId, useState } from 'react' +import { domAnimation, LazyMotion, m } from 'framer-motion' import { ChevronDown } from '@/components/emcn' import { cn } from '@/lib/core/utils/cn' @@ -14,7 +14,13 @@ interface LandingFAQProps { faqs: LandingFAQItem[] } +/** + * Accordion FAQ for landing pages. Answers stay mounted (collapsed via + * animated height) so non-JS crawlers see the full Q&A text and FAQPage + * JSON-LD always matches visible content. + */ export function LandingFAQ({ faqs }: LandingFAQProps) { + const baseId = useId() const [openIndex, setOpenIndex] = useState(0) const [hoveredIndex, setHoveredIndex] = useState(null) @@ -23,8 +29,8 @@ export function LandingFAQ({ faqs }: LandingFAQProps) {
{faqs.map(({ question, answer }, index) => { const isOpen = openIndex === index - const isHovered = hoveredIndex === index const showDivider = index > 0 && hoveredIndex !== index && hoveredIndex !== index - 1 + const panelId = `${baseId}-faq-panel-${index}` return (
@@ -34,50 +40,50 @@ export function LandingFAQ({ faqs }: LandingFAQProps) { index === 0 || !showDivider ? 'invisible' : 'visible' )} /> - - - - {isOpen && ( - -
-

- {answer} -

-
-
- )} -
+ {question} + +
) })} diff --git a/apps/sim/app/(landing)/integrations/(shell)/[slug]/opengraph-image.tsx b/apps/sim/app/(landing)/integrations/(shell)/[slug]/opengraph-image.tsx new file mode 100644 index 00000000000..d709cd9d4d3 --- /dev/null +++ b/apps/sim/app/(landing)/integrations/(shell)/[slug]/opengraph-image.tsx @@ -0,0 +1,48 @@ +import { notFound } from 'next/navigation' +import integrationsJson from '@/lib/integrations/integrations.json' +import type { AuthType, Integration } from '@/lib/integrations/types' +import { createLandingOgImage } from '@/app/(landing)/og-utils' + +export const contentType = 'image/png' +export const size = { + width: 1200, + height: 630, +} + +/** Raw catalog JSON, not the barrel — keeps `@/blocks/registry` out of the OG bundle. */ +const integrations = integrationsJson.integrations as readonly Integration[] +const bySlug = new Map(integrations.map((i) => [i.slug, i])) + +const AUTH_LABEL: Record = { + oauth: 'One-click OAuth', + 'api-key': 'API key auth', + none: 'No auth required', +} + +export default async function Image({ params }: { params: Promise<{ slug: string }> }) { + const { slug } = await params + const integration = bySlug.get(slug) + + if (!integration) { + notFound() + } + + const pills = [ + integration.operationCount > 0 + ? `${integration.operationCount} tool${integration.operationCount === 1 ? '' : 's'}` + : null, + integration.triggerCount > 0 + ? `${integration.triggerCount} real-time trigger${integration.triggerCount === 1 ? '' : 's'}` + : null, + AUTH_LABEL[integration.authType], + 'Free to start', + ].filter((pill): pill is string => pill !== null) + + return createLandingOgImage({ + eyebrow: 'Sim integration', + title: `${integration.name} Integration`, + subtitle: integration.description, + pills, + domainLabel: `sim.ai/integrations/${slug}`, + }) +} diff --git a/apps/sim/app/(landing)/integrations/(shell)/[slug]/page.tsx b/apps/sim/app/(landing)/integrations/(shell)/[slug]/page.tsx index a0dd247c59c..35c7630ed58 100644 --- a/apps/sim/app/(landing)/integrations/(shell)/[slug]/page.tsx +++ b/apps/sim/app/(landing)/integrations/(shell)/[slug]/page.tsx @@ -1,3 +1,4 @@ +import { truncate } from '@sim/utils/string' import type { Metadata } from 'next' import Image from 'next/image' import Link from 'next/link' @@ -7,7 +8,9 @@ import { type AuthType, blockTypeToIconMap, type FAQItem, + formatIntegrationType, INTEGRATIONS, + INTEGRATIONS_UPDATED_AT, type Integration, } from '@/lib/integrations' import { IntegrationCtaButton } from '@/app/(landing)/integrations/(shell)/[slug]/components/integration-cta-button' @@ -32,6 +35,7 @@ export const dynamicParams = false * Scoring (additive): * +3 per shared operation name — strongest signal (same capability) * +2 per shared operation word — weaker signal (e.g. both have "create" ops) + * +2 same integration category — topical relevance (both CRMs, both devops) * +1 same auth type — comparable setup experience * * Every integration gets a score, so the sidebar always has suggestions. @@ -41,6 +45,7 @@ function getRelatedSlugs( slug: string, operations: Integration['operations'], authType: AuthType, + integrationType: Integration['integrationType'], limit = 6 ): string[] { const currentOpNames = new Set(operations.map((o) => o.name.toLowerCase())) @@ -65,20 +70,28 @@ function getRelatedSlugs( .split(/\s+/) .some((w) => w.length > 3 && currentOpWords.has(w)) ).length + const sameCategory = i.integrationType === integrationType ? 2 : 0 const sameAuth = i.authType === authType ? 1 : 0 - return { slug: i.slug, score: sharedNames * 3 + sharedWords * 2 + sameAuth } + return { slug: i.slug, score: sharedNames * 3 + sharedWords * 2 + sameCategory + sameAuth } }) .sort((a, b) => b.score - a.score || a.slug.localeCompare(b.slug)) .slice(0, limit) .map(({ slug: s }) => s) } -const AUTH_STEP: Record = { - oauth: 'Authenticate with one-click OAuth — no credentials to copy-paste.', - 'api-key': 'Add your API key to authenticate — find it in your account settings.', - none: 'Authenticate your account to connect.', +const AUTH_STEP: Record string> = { + oauth: (name) => `Connect your ${name} account with one-click OAuth — no credentials to copy.`, + 'api-key': (name) => + `Paste your ${name} API key to authenticate — you can find it in your ${name} account settings.`, + none: () => 'No authentication is needed — the block works as soon as you drop it in.', } +/** Human-readable catalog refresh date for the visible last-updated line. */ +const UPDATED_AT_DISPLAY = new Date(`${INTEGRATIONS_UPDATED_AT}T00:00:00Z`).toLocaleDateString( + 'en-US', + { year: 'numeric', month: 'long', day: 'numeric', timeZone: 'UTC' } +) + /** * Ensures autogenerated prose can be safely composed with a following sentence. */ @@ -118,68 +131,110 @@ function mentionifyPromptForNames(prompt: string, names: readonly string[]): str return prompt.replace(regex, (match) => `@${match}`) } +/** Lowercases only the first character so acronyms in tool names survive. */ +function lowercaseFirst(value: string): string { + return value.charAt(0).toLowerCase() + value.slice(1) +} + +/** Joins items into readable prose: "a", "a and b", or "a, b, and c". */ +function toProseList(items: string[]): string { + if (items.length <= 1) return items[0] ?? '' + if (items.length === 2) return `${items[0]} and ${items[1]}` + return `${items.slice(0, -1).join(', ')}, and ${items[items.length - 1]}` +} + +/** "a" vs "an" for a service name; U-names read as "you", so they take "a". */ +function articleFor(name: string): string { + return /^[aeio]/i.test(name) ? 'an' : 'a' +} + /** - * Generates targeted FAQs from integration metadata. - * Questions mirror real search queries to drive FAQPage rich snippets. + * Generates the per-integration FAQ. Answers lead with a direct answer and + * carry integration-specific facts; catalog-generic questions live once on + * the /integrations index FAQ instead of repeating across every page. */ -function buildFAQs(integration: Integration): FAQItem[] { +function buildFAQs(integration: Integration, relatedNames: string[]): FAQItem[] { const { name, description, operations, triggers, authType } = integration const faqDescription = sentenceWithTerminalPunctuation(description) - const topOps = operations.slice(0, 5) - const topOpNames = topOps.map((o) => o.name) - const authStep = AUTH_STEP[authType] + const opCount = operations.length + const triggerCount = triggers.length + const topOpNames = operations.slice(0, 5).map((o) => o.name) + const firstOp = operations[0] + const firstTrigger = triggers[0] + const pairings = relatedNames.slice(0, 2) + const toolsPhrase = `${opCount} ${name} tool${opCount === 1 ? '' : 's'}` + const triggersPhrase = `${triggerCount} real-time trigger${triggerCount === 1 ? '' : 's'}` + const capabilityPhrase = [ + opCount > 0 ? toolsPhrase : null, + triggerCount > 0 ? triggersPhrase : null, + ] + .filter((part): part is string => part !== null) + .join(' and ') + const triggerNames = triggers.map((t) => t.name) + const triggerListPhrase = + triggerCount > 6 + ? `${triggerNames.slice(0, 6).join(', ')}, and ${triggerCount - 6} more` + : toProseList(triggerNames) + const firstTriggerWhen = firstTrigger?.description.match(/^trigger workflow (when .+)$/i)?.[1] + const connectFinalStep = firstOp + ? `Pick a tool such as "${firstOp.name}", wire up its inputs, and click Run — your agent is live.` + : triggerCount > 0 + ? `Choose the ${name} event you want to listen for, and your agent runs automatically from then on.` + : `Configure the block's inputs and click Run — your agent is live.` const faqs: FAQItem[] = [ { question: `What is Sim's ${name} integration?`, - answer: `Sim's ${name} integration lets you build AI agents that automate tasks in ${name} without writing code. ${faqDescription} You can connect ${name} to hundreds of other services in the same agent — from CRMs and spreadsheets to messaging tools and databases.`, - }, - { - question: `What can I automate with ${name} in Sim?`, - answer: - topOpNames.length > 0 - ? `With Sim you can: ${topOpNames.join('; ')}${operations.length > 5 ? `; and ${operations.length - 5} more tools` : ''}. Each action runs inside an AI agent block, so you can combine ${name} with LLM reasoning, conditional logic, and data from any other connected service.` - : `Sim lets you automate ${name} by connecting it to an AI agent that can read from it, write to it, and chain it together with other services — all driven by natural-language instructions instead of rigid rules.`, + answer: `Sim's ${name} integration ${capabilityPhrase ? `adds ${capabilityPhrase} to` : `connects ${name} to`} the AI agents you build in Sim's visual workflow builder — no code required. ${faqDescription}${ + pairings.length === 2 + ? ` Teams often pair ${name} with ${pairings[0]} and ${pairings[1]} in the same agent.` + : '' + }`, }, + ...(opCount > 0 + ? [ + { + question: `What can I automate with ${name} in Sim?`, + answer: `You can ${toProseList(topOpNames.map(lowercaseFirst))} with ${name} in Sim${ + opCount > 5 ? `, plus ${opCount - 5} more ${name} tools listed on this page` : '' + }. ${opCount === 1 ? 'It runs' : 'Each runs'} as a tool inside an AI agent block, so an agent can chain ${name} with ${ + pairings.length === 2 + ? `services like ${pairings[0]} and ${pairings[1]}` + : 'any other connected service' + } and apply LLM reasoning between steps.`, + }, + ] + : []), { question: `How do I connect ${name} to Sim?`, - answer: `Getting started takes under five minutes: (1) Create a free account at sim.ai. (2) Open your workspace and create an agent. (3) Drag a ${name} block onto the workflow builder. (4) ${authStep} (5) Choose the tool you want to use, wire it to the inputs you need, and click Run. Your agent is live.`, + answer: `Connecting ${name} takes about five minutes: (1) Create a free account at sim.ai. (2) Create an agent in your workspace. (3) Drag ${articleFor(name)} ${name} block onto the workflow builder. (4) ${AUTH_STEP[authType](name)} (5) ${connectFinalStep}`, }, - { - question: `Can I use ${name} as a tool inside an AI agent in Sim?`, - answer: `Yes — this is one of Sim's core capabilities. Instead of hard-coding when and how ${name} is used, you give an AI agent access to ${name} tools and describe the goal in plain language. The agent decides which tools to call, in what order, and how to handle the results. This means your automation adapts to context rather than breaking when inputs change.`, - }, - ...(topOpNames.length >= 2 + ...(firstOp && opCount >= 2 ? [ { - question: `How do I ${topOpNames[0].toLowerCase()} with ${name} in Sim?`, - answer: `Add a ${name} block to your agent and select "${topOpNames[0]}" as the tool. Fill in the required fields — you can reference outputs from earlier steps, such as text generated by an AI agent or data fetched from another integration. No code is required.`, + question: `How do I ${lowercaseFirst(firstOp.name)} with ${name} in Sim?`, + answer: `Add ${articleFor(name)} ${name} block to your agent and select "${firstOp.name}" as the tool.${ + firstOp.description ? ` ${sentenceWithTerminalPunctuation(firstOp.description)}` : '' + } Fill in the required fields — inputs can reference outputs from earlier steps, such as text generated by an AI block or data fetched from another integration. No code is required.`, }, ] : []), - ...(triggers.length > 0 + ...(triggerCount > 0 ? [ { question: `How do I trigger a Sim agent from ${name} automatically?`, - answer: `Add a ${name} trigger block to your agent and copy the generated webhook URL. Paste that URL into ${name}'s webhook settings and select the events you want to listen for (${triggers.map((t) => t.name).join(', ')}). From that point on, every matching event in ${name} instantly runs your agent — no polling, no delay.`, + answer: `Add ${articleFor(name)} ${name} trigger block to your agent and copy its generated webhook URL into ${name}'s webhook settings. Sim supports ${triggersPhrase} for ${name}: ${triggerListPhrase}. Once configured, every matching ${name} event starts your agent instantly — no polling, no delay.`, }, { question: `What data does Sim receive when a ${name} event triggers an agent?`, - answer: `When ${name} fires a webhook, Sim receives the full event payload that ${name} sends — typically the record or object that changed, along with metadata like the event type and timestamp. Inside your agent, every field from that payload is available as a variable you can pass to AI blocks, conditions, or other integrations.`, + answer: `Sim receives the full event payload ${name} sends — typically the record or object that changed, plus metadata like the event type and timestamp.${ + firstTriggerWhen + ? ` For example, the "${firstTrigger.name}" trigger fires ${sentenceWithTerminalPunctuation(firstTriggerWhen)}` + : '' + } Every field in the payload is available as a variable you can pass to AI blocks, conditions, or other integrations.`, }, ] : []), - { - question: `What ${name} tools does Sim support?`, - answer: - operations.length > 0 - ? `Sim supports ${operations.length} ${name} tool${operations.length === 1 ? '' : 's'}: ${operations.map((o) => o.name).join(', ')}.` - : `Sim supports core ${name} tools for reading and writing data, triggering actions, and integrating with your other services. See the full list in the Sim documentation.`, - }, - { - question: `Is the ${name} integration free to use?`, - answer: `Yes — Sim's free plan includes access to the ${name} integration and every other integration in the library. No credit card is needed to get started. Visit sim.ai to create your account.`, - }, ] return faqs @@ -203,7 +258,8 @@ export async function generateMetadata({ .slice(0, 3) .map((o) => o.name) .join(', ') - const metaDesc = `Automate ${name} with AI agents in Sim. ${description.slice(0, 100).trimEnd()}. Free to start.` + const categoryLabel = formatIntegrationType(integration.integrationType) + const metaDesc = `Automate ${name} with AI agents in Sim. ${sentenceWithTerminalPunctuation(truncate(description, 100))} Free to start.` return { title: `${name} Integration`, @@ -216,29 +272,25 @@ export async function generateMetadata({ `${name} AI agent`, `${name} AI automation`, ...(opSample ? [`${name} ${opSample}`] : []), + `${categoryLabel} integration`, + ...(integration.tags ?? []).map((tag) => `${name} ${tag.replace(/-/g, ' ')}`), + ...(integration.triggerCount > 0 ? [`${name} webhook`, `${name} trigger`] : []), 'AI workspace integrations', 'AI agent integrations', 'AI agent builder', ], + // og:image/twitter:image come from the sibling opengraph-image.tsx — + // Next serves it at a hash-suffixed URL, so hardcoding it here 404s. openGraph: { title: `${name} Integration | Sim AI Workspace`, - description: `Connect ${name} to ${INTEGRATION_COUNT - 1}+ tools using AI agents. ${description.slice(0, 100).trimEnd()}.`, + description: `Connect ${name} to ${INTEGRATION_COUNT - 1}+ tools using AI agents. ${sentenceWithTerminalPunctuation(truncate(description, 100))}`, url: `${baseUrl}/integrations/${slug}`, type: 'website', - images: [ - { - url: `${baseUrl}/opengraph-image.png`, - width: 1200, - height: 630, - alt: `${name} Integration — Sim`, - }, - ], }, twitter: { card: 'summary_large_image', title: `${name} Integration | Sim`, description: `Automate ${name} with AI agents in Sim. Connect to ${INTEGRATION_COUNT - 1}+ tools. Free to start.`, - images: [{ url: `${baseUrl}/opengraph-image.png`, alt: `${name} Integration — Sim` }], }, alternates: { canonical: `${baseUrl}/integrations/${slug}` }, } @@ -255,11 +307,15 @@ export default async function IntegrationPage({ params }: { params: Promise<{ sl const landingContent = integration.landingContent const IconComponent = blockTypeToIconMap[integration.type] - const faqs = buildFAQs(integration) - const relatedSlugs = getRelatedSlugs(slug, operations, authType) + const categoryLabel = formatIntegrationType(integration.integrationType) + const relatedSlugs = getRelatedSlugs(slug, operations, authType, integration.integrationType) const relatedIntegrations = relatedSlugs .map((s) => bySlug.get(s)) .filter((i): i is Integration => i !== undefined) + const faqs = buildFAQs( + integration, + relatedIntegrations.map((i) => i.name) + ) const matchingTemplates = getTemplatesForBlock(integration.type) const breadcrumbJsonLd = { @@ -284,38 +340,16 @@ export default async function IntegrationPage({ params }: { params: Promise<{ sl description, url: `${baseUrl}/integrations/${slug}`, applicationCategory: 'BusinessApplication', + applicationSubCategory: categoryLabel, operatingSystem: 'Web', featureList: operations.map((o) => o.name), + ...(integration.tags?.length + ? { keywords: integration.tags.map((tag) => tag.replace(/-/g, ' ')).join(', ') } + : {}), + dateModified: INTEGRATIONS_UPDATED_AT, offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' }, } - const howToJsonLd = { - '@context': 'https://schema.org', - '@type': 'HowTo', - name: `How to automate ${name} with Sim`, - description: `Step-by-step guide to connecting ${name} to AI agents in Sim.`, - step: [ - { - '@type': 'HowToStep', - position: 1, - name: 'Create a free Sim account', - text: 'Sign up at sim.ai — no credit card required.', - }, - { - '@type': 'HowToStep', - position: 2, - name: `Add a ${name} block`, - text: `Open your workspace, drag a ${name} block onto the workflow builder, and authenticate with your ${name} credentials.`, - }, - { - '@type': 'HowToStep', - position: 3, - name: 'Configure and run', - text: `Choose the operation you want, connect it to an AI agent, and deploy. Automate anything in ${name} without code.`, - }, - ], - } - const faqJsonLd = { '@context': 'https://schema.org', '@type': 'FAQPage', @@ -336,10 +370,6 @@ export default async function IntegrationPage({ params }: { params: Promise<{ sl type='application/ld+json' dangerouslySetInnerHTML={{ __html: JSON.stringify(softwareAppJsonLd) }} /> -