d25d482dc2
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
970 lines
40 KiB
TypeScript
970 lines
40 KiB
TypeScript
import { ChipLink } from '@sim/emcn'
|
|
import { truncate } from '@sim/utils/string'
|
|
import type { Metadata } from 'next'
|
|
import Image from 'next/image'
|
|
import Link from 'next/link'
|
|
import { notFound } from 'next/navigation'
|
|
import { SITE_URL } from '@/lib/core/utils/urls'
|
|
import {
|
|
type AuthType,
|
|
blockTypeToIconMap,
|
|
type FAQItem,
|
|
formatIntegrationType,
|
|
INTEGRATIONS,
|
|
INTEGRATIONS_UPDATED_AT,
|
|
type Integration,
|
|
} from '@/lib/integrations'
|
|
import { BackLink } from '@/app/(landing)/components'
|
|
import { JsonLd } from '@/app/(landing)/components/json-ld'
|
|
import { LandingFAQ } from '@/app/(landing)/components/landing-faq'
|
|
import { ShareButton } from '@/app/(landing)/components/share-button'
|
|
import { IntegrationCtaButton } from '@/app/(landing)/integrations/(shell)/[slug]/components/integration-cta-button'
|
|
import { TemplateCardButton } from '@/app/(landing)/integrations/(shell)/[slug]/components/template-card-button'
|
|
import { IntegrationIcon } from '@/app/(landing)/integrations/components/integration-icon'
|
|
import { INTEGRATION_SEO } from '@/app/(landing)/integrations/data/seo-content'
|
|
import { getTemplatesForBlock } from '@/blocks/registry'
|
|
|
|
const allIntegrations = INTEGRATIONS
|
|
const INTEGRATION_COUNT = allIntegrations.length
|
|
const baseUrl = SITE_URL
|
|
|
|
/** Fast O(1) lookups - avoids repeated linear scans inside render loops. */
|
|
const bySlug = new Map(allIntegrations.map((i) => [i.slug, i]))
|
|
const byType = new Map(allIntegrations.map((i) => [i.type, i]))
|
|
|
|
export const dynamicParams = false
|
|
|
|
/**
|
|
* Returns up to `limit` related integration slugs.
|
|
*
|
|
* 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.
|
|
* Ties are broken by alphabetical slug order for determinism.
|
|
*/
|
|
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()))
|
|
const currentOpWords = new Set(
|
|
operations.flatMap((o) =>
|
|
o.name
|
|
.toLowerCase()
|
|
.split(/\s+/)
|
|
.filter((w) => w.length > 3)
|
|
)
|
|
)
|
|
|
|
return allIntegrations
|
|
.reduce<Array<{ slug: string; score: number }>>((scored, i) => {
|
|
if (i.slug === slug) return scored
|
|
const sharedNames = i.operations.filter((o) =>
|
|
currentOpNames.has(o.name.toLowerCase())
|
|
).length
|
|
const sharedWords = i.operations.filter((o) =>
|
|
o.name
|
|
.toLowerCase()
|
|
.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
|
|
scored.push({
|
|
slug: i.slug,
|
|
score: sharedNames * 3 + sharedWords * 2 + sameCategory + sameAuth,
|
|
})
|
|
return scored
|
|
}, [])
|
|
.sort((a, b) => b.score - a.score || a.slug.localeCompare(b.slug))
|
|
.slice(0, limit)
|
|
.map(({ slug: s }) => s)
|
|
}
|
|
|
|
const AUTH_STEP: Record<AuthType, (name: string) => string> = {
|
|
oauth: (name) =>
|
|
`Connect your ${name} account with one-click OAuth, with 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, so 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.
|
|
*/
|
|
function sentenceWithTerminalPunctuation(value: string): string {
|
|
const trimmedValue = value.trim()
|
|
return /[.!?]$/.test(trimmedValue) ? trimmedValue : `${trimmedValue}.`
|
|
}
|
|
|
|
function escapeRegex(value: string): string {
|
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
}
|
|
|
|
/**
|
|
* Server-side rewrite of bare integration names in a curated template prompt
|
|
* to `@`-mention form (`Slack` → `@Slack`) so the prompt chips with brand
|
|
* icons once it is populated into the Mothership home input after signup -
|
|
* the home auto-mention pipeline only chips token-starting `@` mentions, so
|
|
* curated prompts must opt in.
|
|
*
|
|
* Unlike the workspace surface, which calls `mentionifyIntegrations` from
|
|
* `@/blocks/integration-matcher`, this runs only over the handful of names a
|
|
* template actually references (its owner + `otherBlockTypes`) and lives in a
|
|
* Server Component, so it never pulls the full block/tool/icon registry into
|
|
* the landing client bundle. Whole-token, longest-first matching with
|
|
* lookarounds mirrors the canonical matcher; idempotent on already-prefixed
|
|
* names.
|
|
*/
|
|
function mentionifyPromptForNames(prompt: string, names: readonly string[]): string {
|
|
const unique = Array.from(new Set(names.filter((n) => n.trim().length >= 2))).sort(
|
|
(a, b) => b.length - a.length
|
|
)
|
|
if (unique.length === 0) return prompt
|
|
const regex = new RegExp(
|
|
`(?<![A-Za-z0-9_@])(${unique.map(escapeRegex).join('|')})(?![A-Za-z0-9_])`,
|
|
'gi'
|
|
)
|
|
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)
|
|
}
|
|
|
|
/**
|
|
* The ordered integration-icon chain for a template card - one tile per block
|
|
* type in flow order, separated by arrows. Resolves each type through its
|
|
* versioned aliases so v2/v3 blocks reuse the base icon and name.
|
|
*/
|
|
function TemplateIconRow({ allTypes }: { allTypes: string[] }) {
|
|
return (
|
|
<>
|
|
{allTypes.map((bt, idx) => {
|
|
const resolvedBt = byType.get(bt)
|
|
? bt
|
|
: byType.get(`${bt}_v2`)
|
|
? `${bt}_v2`
|
|
: byType.get(`${bt}_v3`)
|
|
? `${bt}_v3`
|
|
: bt
|
|
const int = byType.get(resolvedBt)
|
|
const ToolIcon = blockTypeToIconMap[resolvedBt]
|
|
return (
|
|
<span key={bt} className='inline-flex items-center gap-1.5'>
|
|
{idx > 0 && (
|
|
<span className='text-[11px] text-[var(--text-subtle)]' aria-hidden='true'>
|
|
→
|
|
</span>
|
|
)}
|
|
<IntegrationIcon
|
|
bgColor={int?.bgColor ?? 'var(--surface-active)'}
|
|
name={int?.name ?? bt}
|
|
Icon={ToolIcon}
|
|
as='span'
|
|
className='size-6 rounded-[4px]'
|
|
iconClassName='size-3.5'
|
|
fallbackClassName='text-[10px]'
|
|
aria-hidden='true'
|
|
/>
|
|
</span>
|
|
)
|
|
})}
|
|
</>
|
|
)
|
|
}
|
|
|
|
/** 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 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, relatedNames: string[]): FAQItem[] {
|
|
const { name, description, operations, triggers, authType } = integration
|
|
const faqDescription = sentenceWithTerminalPunctuation(description)
|
|
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, and 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, and your agent is live.`
|
|
|
|
const faqs: FAQItem[] = [
|
|
{
|
|
question: `What is Sim's ${name} integration?`,
|
|
answer: `Sim's ${name} integration ${capabilityPhrase ? `adds ${capabilityPhrase} to` : `connects ${name} to`} the AI agents you build in Sim's visual workflow builder — you build it all visually. ${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: `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}`,
|
|
},
|
|
...(firstOp && opCount >= 2
|
|
? [
|
|
{
|
|
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, and you build it all visually.`,
|
|
},
|
|
]
|
|
: []),
|
|
...(triggerCount > 0
|
|
? [
|
|
{
|
|
question: `How do I trigger a Sim agent from ${name} automatically?`,
|
|
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: `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.`,
|
|
},
|
|
]
|
|
: []),
|
|
]
|
|
|
|
return faqs
|
|
}
|
|
|
|
export async function generateStaticParams() {
|
|
return allIntegrations.map((i) => ({ slug: i.slug }))
|
|
}
|
|
|
|
export async function generateMetadata({
|
|
params,
|
|
}: {
|
|
params: Promise<{ slug: string }>
|
|
}): Promise<Metadata> {
|
|
const { slug } = await params
|
|
const integration = bySlug.get(slug)
|
|
if (!integration) return {}
|
|
|
|
const { name, description, operations } = integration
|
|
const opSample = operations
|
|
.slice(0, 3)
|
|
.map((o) => o.name)
|
|
.join(', ')
|
|
const categoryLabel = formatIntegrationType(integration.integrationType)
|
|
const seo = INTEGRATION_SEO[slug]
|
|
const metaDesc =
|
|
seo?.description ??
|
|
`Automate ${name} with AI agents in Sim. ${sentenceWithTerminalPunctuation(truncate(description, 100))} Free to start.`
|
|
|
|
return {
|
|
// A hand-authored SEO title is rendered verbatim (it carries its own brand
|
|
// suffix); otherwise the bare name flows through the root `%s | Sim` template.
|
|
title: seo?.title ? { absolute: seo.title } : `${name} Integration`,
|
|
description: metaDesc,
|
|
keywords: seo?.keywords ?? [
|
|
`${name} automation`,
|
|
`${name} integration`,
|
|
`automate ${name}`,
|
|
`connect ${name}`,
|
|
`${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: seo?.title ?? `${name} Integration | Sim AI Workspace`,
|
|
description:
|
|
seo?.description ??
|
|
`Connect ${name} to ${INTEGRATION_COUNT - 1}+ tools using AI agents. ${sentenceWithTerminalPunctuation(truncate(description, 100))}`,
|
|
url: `${baseUrl}/integrations/${slug}`,
|
|
type: 'website',
|
|
},
|
|
twitter: {
|
|
card: 'summary_large_image',
|
|
title: seo?.title ?? `${name} Integration | Sim`,
|
|
description:
|
|
seo?.description ??
|
|
`Automate ${name} with AI agents in Sim. Connect to ${INTEGRATION_COUNT - 1}+ tools. Free to start.`,
|
|
},
|
|
alternates: { canonical: `${baseUrl}/integrations/${slug}` },
|
|
}
|
|
}
|
|
|
|
export default async function IntegrationPage({ params }: { params: Promise<{ slug: string }> }) {
|
|
const { slug } = await params
|
|
const integration = bySlug.get(slug)
|
|
if (!integration) notFound()
|
|
|
|
const { name, description, longDescription, bgColor, docsUrl, operations, triggers, authType } =
|
|
integration
|
|
|
|
const landingContent = integration.landingContent
|
|
const seo = INTEGRATION_SEO[slug]
|
|
const overviewBody = seo?.overview ?? longDescription
|
|
|
|
const IconComponent = blockTypeToIconMap[integration.type]
|
|
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 = {
|
|
'@context': 'https://schema.org',
|
|
'@type': 'BreadcrumbList',
|
|
itemListElement: [
|
|
{ '@type': 'ListItem', position: 1, name: 'Home', item: baseUrl },
|
|
{
|
|
'@type': 'ListItem',
|
|
position: 2,
|
|
name: 'Integrations',
|
|
item: `${baseUrl}/integrations`,
|
|
},
|
|
{ '@type': 'ListItem', position: 3, name, item: `${baseUrl}/integrations/${slug}` },
|
|
],
|
|
}
|
|
|
|
const softwareAppJsonLd = {
|
|
'@context': 'https://schema.org',
|
|
'@type': 'SoftwareApplication',
|
|
name: `${name} Integration`,
|
|
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 faqJsonLd = {
|
|
'@context': 'https://schema.org',
|
|
'@type': 'FAQPage',
|
|
mainEntity: faqs.map(({ question, answer }) => ({
|
|
'@type': 'Question',
|
|
name: question,
|
|
acceptedAnswer: { '@type': 'Answer', text: answer },
|
|
})),
|
|
}
|
|
|
|
return (
|
|
<section className='bg-[var(--bg)]'>
|
|
<JsonLd data={breadcrumbJsonLd} />
|
|
<JsonLd data={softwareAppJsonLd} />
|
|
<JsonLd data={faqJsonLd} />
|
|
|
|
{/* Hero */}
|
|
<div className='mx-auto w-full max-w-[1460px] px-20 pt-[112px] max-sm:px-5 max-sm:pt-20 max-lg:px-8'>
|
|
<div className='mb-6'>
|
|
<BackLink href='/integrations' label='Back to Integrations' />
|
|
</div>
|
|
|
|
{/* Hero content */}
|
|
<div className='mb-6 flex items-center gap-5'>
|
|
<IntegrationIcon
|
|
bgColor={bgColor}
|
|
name={name}
|
|
Icon={IconComponent}
|
|
className='size-12 rounded-xl border border-[var(--border-1)]'
|
|
iconClassName='size-6'
|
|
fallbackClassName='text-[20px]'
|
|
aria-hidden='true'
|
|
/>
|
|
<div>
|
|
<h1
|
|
id='integration-heading'
|
|
className='text-[28px] text-[var(--text-primary)] leading-[110%] tracking-[-0.02em] sm:text-[36px] lg:text-[44px]'
|
|
>
|
|
{seo?.h1 ?? name}
|
|
</h1>
|
|
</div>
|
|
</div>
|
|
|
|
<p className='mb-3 max-w-[700px] text-[var(--text-body)] text-base leading-[150%] tracking-[0.02em]'>
|
|
{seo?.tagline ?? description}
|
|
</p>
|
|
|
|
<p className='sr-only'>
|
|
{name} is a {categoryLabel} integration for Sim, the AI workspace where teams build and
|
|
deploy AI agents. Sim's {name} integration provides{' '}
|
|
{[
|
|
operations.length > 0
|
|
? `${operations.length} ${name} tool${operations.length === 1 ? '' : 's'}`
|
|
: null,
|
|
triggers.length > 0
|
|
? `${triggers.length} real-time trigger${triggers.length === 1 ? '' : 's'}`
|
|
: null,
|
|
]
|
|
.filter((part): part is string => part !== null)
|
|
.join(' and ') || `a ${name} connection`}{' '}
|
|
that AI agents can use inside Sim's visual workflow builder.{' '}
|
|
{authType === 'oauth'
|
|
? `${name} connects with one-click OAuth.`
|
|
: authType === 'api-key'
|
|
? `${name} connects with an API key.`
|
|
: `${name} requires no authentication.`}{' '}
|
|
Free to start at sim.ai.
|
|
</p>
|
|
|
|
{/* CTAs */}
|
|
<div className='flex flex-wrap gap-2'>
|
|
<IntegrationCtaButton label='Start building free'>
|
|
Start building free
|
|
</IntegrationCtaButton>
|
|
<ChipLink
|
|
href={docsUrl}
|
|
target='_blank'
|
|
rel='noopener noreferrer'
|
|
className='border border-[var(--border-1)]'
|
|
>
|
|
View docs
|
|
</ChipLink>
|
|
<ShareButton url={`${baseUrl}/integrations/${slug}`} title={`${name} Integration`} />
|
|
</div>
|
|
|
|
<p className='mt-5 text-[var(--text-muted)] text-xs'>
|
|
Last updated <time dateTime={INTEGRATIONS_UPDATED_AT}>{UPDATED_AT_DISPLAY}</time>
|
|
</p>
|
|
</div>
|
|
|
|
{/* Full-width divider */}
|
|
<div className='mt-8 h-px w-full bg-[var(--border)]' />
|
|
|
|
{/* Border-railed content */}
|
|
<div className='mx-20 max-w-[1300px] border-[var(--border)] border-x max-sm:mx-5 max-lg:mx-8 min-[1460px]:mx-auto'>
|
|
{/* Overview */}
|
|
{overviewBody && (
|
|
<>
|
|
<section aria-labelledby='overview-heading' className='px-6 py-10'>
|
|
<h2
|
|
id='overview-heading'
|
|
className='mb-4 text-[20px] text-[var(--text-primary)] leading-[100%] tracking-[-0.02em]'
|
|
>
|
|
Overview
|
|
</h2>
|
|
<p className='text-[15px] text-[var(--text-body)] leading-[150%] tracking-[0.02em]'>
|
|
{overviewBody}
|
|
</p>
|
|
</section>
|
|
<div className='h-px w-full bg-[var(--border)]' />
|
|
</>
|
|
)}
|
|
|
|
{/* Install / Add to workspace (integration-specific) */}
|
|
{landingContent?.install && (
|
|
<>
|
|
<section aria-labelledby='install-heading' className='px-6 py-10'>
|
|
<h2
|
|
id='install-heading'
|
|
className='mb-4 text-[20px] text-[var(--text-primary)] leading-[100%] tracking-[-0.02em]'
|
|
>
|
|
{landingContent.install.heading}
|
|
</h2>
|
|
<p className='mb-6 max-w-[700px] text-[15px] text-[var(--text-body)] leading-[150%] tracking-[0.02em]'>
|
|
{landingContent.install.intro}
|
|
</p>
|
|
<ol className='space-y-4' aria-label={`Steps to add ${name}`}>
|
|
{landingContent.install.steps.map((item, index) => (
|
|
<li key={item.title} className='flex gap-4'>
|
|
<span
|
|
className='mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full border border-[var(--border-1)] text-[11px] text-[var(--text-muted)]'
|
|
aria-hidden='true'
|
|
>
|
|
{String(index + 1).padStart(2, '0')}
|
|
</span>
|
|
<div>
|
|
<h3 className='mb-1 text-[15px] text-[var(--text-primary)] tracking-[-0.02em]'>
|
|
{item.title}
|
|
</h3>
|
|
<p className='text-[14px] text-[var(--text-body)] leading-[150%] tracking-[0.02em]'>
|
|
{item.body}
|
|
</p>
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ol>
|
|
<div className='mt-8 flex flex-wrap gap-2'>
|
|
<IntegrationCtaButton label={`Add to ${name}`}>Add to {name}</IntegrationCtaButton>
|
|
</div>
|
|
</section>
|
|
<div className='h-px w-full bg-[var(--border)]' />
|
|
</>
|
|
)}
|
|
|
|
{/* Privacy & data (integration-specific) */}
|
|
{landingContent?.privacy && (
|
|
<>
|
|
<section aria-labelledby='privacy-heading' className='px-6 py-10'>
|
|
<h2
|
|
id='privacy-heading'
|
|
className='mb-4 text-[20px] text-[var(--text-primary)] leading-[100%] tracking-[-0.02em]'
|
|
>
|
|
Privacy & data
|
|
</h2>
|
|
<p className='max-w-[700px] text-[15px] text-[var(--text-body)] leading-[150%] tracking-[0.02em]'>
|
|
{landingContent.privacy.body}{' '}
|
|
<Link
|
|
href={landingContent.privacy.href}
|
|
className='text-[var(--text-primary)] underline underline-offset-2 hover:text-[var(--text-primary)]'
|
|
>
|
|
Privacy Policy
|
|
</Link>
|
|
.
|
|
</p>
|
|
</section>
|
|
<div className='h-px w-full bg-[var(--border)]' />
|
|
</>
|
|
)}
|
|
|
|
{/* AI-generated content disclaimer (integration-specific) */}
|
|
{landingContent?.aiDisclaimer && (
|
|
<>
|
|
<section aria-labelledby='ai-disclaimer-heading' className='px-6 py-10'>
|
|
<h2
|
|
id='ai-disclaimer-heading'
|
|
className='mb-4 text-[20px] text-[var(--text-primary)] leading-[100%] tracking-[-0.02em]'
|
|
>
|
|
AI-generated content
|
|
</h2>
|
|
<p className='max-w-[700px] text-[15px] text-[var(--text-body)] leading-[150%] tracking-[0.02em]'>
|
|
{landingContent.aiDisclaimer}
|
|
</p>
|
|
</section>
|
|
<div className='h-px w-full bg-[var(--border)]' />
|
|
</>
|
|
)}
|
|
|
|
{/* How to automate */}
|
|
<section aria-labelledby='how-it-works-heading' className='px-6 py-10'>
|
|
<h2
|
|
id='how-it-works-heading'
|
|
className='mb-6 text-[20px] text-[var(--text-primary)] leading-[100%] tracking-[-0.02em]'
|
|
>
|
|
How to automate {name} with Sim
|
|
</h2>
|
|
<ol className='space-y-4' aria-label='Steps to set up automation'>
|
|
{[
|
|
{
|
|
step: '01',
|
|
title: 'Create a free account',
|
|
body: 'Sign up at sim.ai in seconds. No credit card required. Your workspace is ready immediately.',
|
|
},
|
|
{
|
|
step: '02',
|
|
title: `Add ${articleFor(name)} ${name} block`,
|
|
body:
|
|
authType === 'oauth'
|
|
? `Open your workspace, drag ${articleFor(name)} ${name} block onto the workflow builder, and connect your account with one-click OAuth.`
|
|
: authType === 'api-key'
|
|
? `Open your workspace, drag ${articleFor(name)} ${name} block onto the workflow builder, and paste in your ${name} API key.`
|
|
: `Open your workspace, drag ${articleFor(name)} ${name} block onto the workflow builder. No authentication is needed.`,
|
|
},
|
|
{
|
|
step: '03',
|
|
title: 'Configure, connect, and run',
|
|
body: `Pick the tool you need, wire in an AI agent for reasoning or data transformation, and run. Your ${name} automation is live.`,
|
|
},
|
|
].map(({ step, title, body }) => (
|
|
<li key={step} className='flex gap-4'>
|
|
<span
|
|
className='mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full border border-[var(--border-1)] text-[11px] text-[var(--text-muted)]'
|
|
aria-hidden='true'
|
|
>
|
|
{step}
|
|
</span>
|
|
<div>
|
|
<h3 className='mb-1 text-[15px] text-[var(--text-primary)] tracking-[-0.02em]'>
|
|
{title}
|
|
</h3>
|
|
<p className='text-[14px] text-[var(--text-body)] leading-[150%] tracking-[0.02em]'>
|
|
{body}
|
|
</p>
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ol>
|
|
</section>
|
|
|
|
<div className='h-px w-full bg-[var(--border)]' />
|
|
|
|
{/* Triggers - rows */}
|
|
{triggers.length > 0 && (
|
|
<section aria-labelledby='triggers-heading'>
|
|
<div className='px-6 pt-10 pb-4'>
|
|
<div className='mb-2 flex items-center gap-2.5'>
|
|
<span className='relative flex size-2' aria-hidden='true'>
|
|
<span className='absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75' />
|
|
<span className='relative inline-flex size-2 rounded-full bg-emerald-500' />
|
|
</span>
|
|
<h2
|
|
id='triggers-heading'
|
|
className='text-[20px] text-[var(--text-primary)] leading-[100%] tracking-[-0.02em]'
|
|
>
|
|
Real-time triggers
|
|
</h2>
|
|
</div>
|
|
<p className='text-[14px] text-[var(--text-body)] leading-[150%] tracking-[0.02em]'>
|
|
{seo?.triggersIntro ?? (
|
|
<>
|
|
Connect {articleFor(name)} {name} webhook to Sim and your agent runs the instant
|
|
an event happens, no polling, no delay.
|
|
</>
|
|
)}
|
|
</p>
|
|
</div>
|
|
<div className='h-px w-full bg-[var(--border)]' />
|
|
{triggers.map((trigger) => (
|
|
<div key={trigger.id}>
|
|
<div className='flex items-start gap-4 px-6 py-4'>
|
|
<div className='flex min-w-0 flex-1 flex-col gap-0.5'>
|
|
<p className='text-[14px] text-[var(--text-primary)] leading-snug tracking-[-0.02em]'>
|
|
{trigger.name}
|
|
</p>
|
|
{trigger.description && (
|
|
<p className='text-[12px] text-[var(--text-muted)] leading-[150%]'>
|
|
{trigger.description}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className='h-px w-full bg-[var(--border)]' />
|
|
</div>
|
|
))}
|
|
</section>
|
|
)}
|
|
|
|
{/* Workflow templates - horizontal cards */}
|
|
{matchingTemplates.length > 0 && (
|
|
<section aria-labelledby='templates-heading'>
|
|
<div className='px-6 pt-10 pb-4'>
|
|
<h2
|
|
id='templates-heading'
|
|
className='mb-2 text-[20px] text-[var(--text-primary)] leading-[100%] tracking-[-0.02em]'
|
|
>
|
|
Agent templates
|
|
</h2>
|
|
<p className='text-[14px] text-[var(--text-body)] tracking-[0.02em]'>
|
|
{seo?.templatesIntro ??
|
|
`Ready-to-use templates featuring ${name}. Click any to build it instantly.`}
|
|
</p>
|
|
</div>
|
|
<div className='h-px w-full bg-[var(--border)]' />
|
|
{(() => {
|
|
const isOdd = matchingTemplates.length % 2 === 1
|
|
const pairedTemplates = isOdd ? matchingTemplates.slice(0, -1) : matchingTemplates
|
|
const lastTemplate = isOdd ? matchingTemplates[matchingTemplates.length - 1] : null
|
|
|
|
const resolveTypes = (template: (typeof matchingTemplates)[number]) => [
|
|
integration.type,
|
|
...template.otherBlockTypes,
|
|
]
|
|
|
|
const resolveDisplayName = (bt: string): string | null => {
|
|
const resolvedBt = byType.get(bt)
|
|
? bt
|
|
: byType.get(`${bt}_v2`)
|
|
? `${bt}_v2`
|
|
: byType.get(`${bt}_v3`)
|
|
? `${bt}_v3`
|
|
: bt
|
|
return byType.get(resolvedBt)?.name ?? null
|
|
}
|
|
|
|
/**
|
|
* The curated template prompt rewritten so the integrations it
|
|
* references chip in the home input after signup. Computed
|
|
* server-side from the template's own integration set - never the
|
|
* full registry - so the visible card text stays raw while the
|
|
* stored prompt opts into mention treatment.
|
|
*/
|
|
const storedPrompt = (template: (typeof matchingTemplates)[number]) =>
|
|
mentionifyPromptForNames(
|
|
template.prompt,
|
|
resolveTypes(template)
|
|
.map(resolveDisplayName)
|
|
.filter((n): n is string => n !== null)
|
|
)
|
|
|
|
return (
|
|
<>
|
|
{/* Paired rows of 2 */}
|
|
{Array.from({ length: Math.ceil(pairedTemplates.length / 2) }, (_, rowIdx) => {
|
|
const row = pairedTemplates.slice(rowIdx * 2, rowIdx * 2 + 2)
|
|
return (
|
|
<div key={rowIdx}>
|
|
<nav
|
|
aria-label={`Template row ${rowIdx + 1}`}
|
|
className='flex flex-col sm:flex-row'
|
|
>
|
|
{row.map((template) => (
|
|
<TemplateCardButton
|
|
key={template.title}
|
|
prompt={storedPrompt(template)}
|
|
className='group flex flex-1 flex-col gap-4 border-[var(--border)] border-t p-6 transition-colors first:border-t-0 hover:bg-[var(--surface-hover)] sm:border-t-0 sm:border-l sm:first:border-l-0'
|
|
>
|
|
<div className='flex items-center gap-1.5'>
|
|
<TemplateIconRow allTypes={resolveTypes(template)} />
|
|
</div>
|
|
<div className='flex flex-col gap-2'>
|
|
<h3 className='text-[14px] text-[var(--text-primary)] leading-snug tracking-[-0.02em]'>
|
|
{template.title}
|
|
</h3>
|
|
<p className='line-clamp-2 text-[var(--text-muted)] text-sm leading-[150%]'>
|
|
{template.prompt}
|
|
</p>
|
|
</div>
|
|
</TemplateCardButton>
|
|
))}
|
|
</nav>
|
|
<div className='h-px w-full bg-[var(--border)]' />
|
|
</div>
|
|
)
|
|
})}
|
|
|
|
{/* Last template as a full-width row when odd */}
|
|
{lastTemplate && (
|
|
<>
|
|
<TemplateCardButton
|
|
prompt={storedPrompt(lastTemplate)}
|
|
className='group/link flex items-center gap-4 px-6 py-4 transition-colors hover:bg-[var(--surface-hover)]'
|
|
>
|
|
<div className='flex items-center gap-1.5'>
|
|
<TemplateIconRow allTypes={resolveTypes(lastTemplate)} />
|
|
</div>
|
|
<div className='flex min-w-0 flex-1 flex-col gap-0.5'>
|
|
<h3 className='text-[14px] text-[var(--text-primary)] leading-snug tracking-[-0.02em]'>
|
|
{lastTemplate.title}
|
|
</h3>
|
|
<p className='line-clamp-1 text-[12px] text-[var(--text-muted)] leading-[150%]'>
|
|
{lastTemplate.prompt}
|
|
</p>
|
|
</div>
|
|
</TemplateCardButton>
|
|
<div className='h-px w-full bg-[var(--border)]' />
|
|
</>
|
|
)}
|
|
</>
|
|
)
|
|
})()}
|
|
</section>
|
|
)}
|
|
|
|
{/* Supported tools - rows */}
|
|
{operations.length > 0 && (
|
|
<section aria-labelledby='tools-heading'>
|
|
<div className='px-6 pt-10 pb-4'>
|
|
<h2
|
|
id='tools-heading'
|
|
className='mb-2 text-[20px] text-[var(--text-primary)] leading-[100%] tracking-[-0.02em]'
|
|
>
|
|
Supported tools
|
|
</h2>
|
|
<p className='text-[14px] text-[var(--text-body)] tracking-[0.02em]'>
|
|
{operations.length} {name} tool{operations.length === 1 ? '' : 's'} available in Sim
|
|
{seo?.toolsSubtitleSuffix ?? ''}
|
|
</p>
|
|
</div>
|
|
<div className='h-px w-full bg-[var(--border)]' />
|
|
{operations.map((op) => (
|
|
<div key={op.name}>
|
|
<div className='flex items-start gap-4 px-6 py-4'>
|
|
<div className='flex min-w-0 flex-1 flex-col gap-0.5'>
|
|
<p className='text-[14px] text-[var(--text-primary)] leading-snug tracking-[-0.02em]'>
|
|
{op.name}
|
|
</p>
|
|
{op.description && (
|
|
<p className='text-[12px] text-[var(--text-muted)] leading-[150%]'>
|
|
{op.description}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className='h-px w-full bg-[var(--border)]' />
|
|
</div>
|
|
))}
|
|
</section>
|
|
)}
|
|
|
|
{/* FAQ - full width */}
|
|
<section aria-labelledby='faq-heading' className='px-6 py-10'>
|
|
<h2
|
|
id='faq-heading'
|
|
className='mb-8 text-[20px] text-[var(--text-primary)] leading-[100%] tracking-[-0.02em]'
|
|
>
|
|
Frequently asked questions
|
|
</h2>
|
|
<LandingFAQ faqs={faqs} />
|
|
</section>
|
|
|
|
<div className='h-px w-full bg-[var(--border)]' />
|
|
|
|
{/* Related integrations - horizontal cards with vertical dividers (blog featured pattern) */}
|
|
{relatedIntegrations.length > 0 && (
|
|
<>
|
|
<nav aria-label='Related integrations' className='flex flex-col sm:flex-row'>
|
|
{relatedIntegrations.slice(0, 4).map((rel) => (
|
|
<Link
|
|
key={rel.slug}
|
|
href={`/integrations/${rel.slug}`}
|
|
className='group flex flex-1 flex-col gap-4 border-[var(--border)] border-t p-6 transition-colors first:border-t-0 hover:bg-[var(--surface-hover)] sm:border-t-0 sm:border-l sm:first:border-l-0'
|
|
>
|
|
<IntegrationIcon
|
|
bgColor={rel.bgColor}
|
|
name={rel.name}
|
|
Icon={blockTypeToIconMap[rel.type]}
|
|
as='span'
|
|
className='size-10 rounded-xl border border-[var(--border-1)]'
|
|
aria-hidden='true'
|
|
/>
|
|
<div className='flex flex-col gap-2'>
|
|
<h3 className='text-[var(--text-primary)] text-lg leading-tight tracking-[-0.01em]'>
|
|
{rel.name}
|
|
</h3>
|
|
<p className='line-clamp-2 text-[var(--text-muted)] text-sm leading-[150%]'>
|
|
{rel.description}
|
|
</p>
|
|
</div>
|
|
</Link>
|
|
))}
|
|
</nav>
|
|
<div className='h-px w-full bg-[var(--border)]' />
|
|
</>
|
|
)}
|
|
|
|
{/* Bottom CTA */}
|
|
<section aria-labelledby='cta-heading' className='px-6 py-16 text-center'>
|
|
<div className='mx-auto mb-6 flex items-center justify-center gap-3'>
|
|
<Image
|
|
src='/brandbook/logo/small.png'
|
|
alt='Sim'
|
|
width={56}
|
|
height={56}
|
|
className='shrink-0 rounded-xl'
|
|
unoptimized
|
|
/>
|
|
<div className='flex items-center gap-2'>
|
|
<span className='h-px w-5 bg-[var(--border-1)]' aria-hidden='true' />
|
|
<span
|
|
className='flex size-7 items-center justify-center rounded-full border border-[var(--border-1)]'
|
|
aria-hidden='true'
|
|
>
|
|
<svg
|
|
className='size-3.5 text-[var(--text-muted)]'
|
|
viewBox='0 0 24 24'
|
|
fill='none'
|
|
stroke='currentColor'
|
|
strokeWidth={2}
|
|
strokeLinecap='round'
|
|
>
|
|
<path d='M5 12h14' />
|
|
<path d='M12 5v14' />
|
|
</svg>
|
|
</span>
|
|
<span className='h-px w-5 bg-[var(--border-1)]' aria-hidden='true' />
|
|
</div>
|
|
<IntegrationIcon
|
|
bgColor={bgColor}
|
|
name={name}
|
|
Icon={IconComponent}
|
|
className='size-14 rounded-xl'
|
|
iconClassName='size-7'
|
|
fallbackClassName='text-[22px]'
|
|
aria-hidden='true'
|
|
/>
|
|
</div>
|
|
<h2
|
|
id='cta-heading'
|
|
className='mb-3 text-[28px] text-[var(--text-primary)] leading-[100%] tracking-[-0.02em] sm:text-[34px]'
|
|
>
|
|
Start automating {name} today
|
|
</h2>
|
|
<p className='mx-auto mb-8 max-w-[480px] text-[var(--text-body)] text-base leading-[150%] tracking-[0.02em]'>
|
|
Build your first AI agent with {name} in minutes. Connect to every tool your team uses.
|
|
Free to start, no credit card required.
|
|
</p>
|
|
<IntegrationCtaButton label='Build for free'>Build for free</IntegrationCtaButton>
|
|
</section>
|
|
</div>
|
|
|
|
{/* Closing full-width divider */}
|
|
<div className='-mt-px h-px w-full bg-[var(--border)]' />
|
|
</section>
|
|
)
|
|
}
|