Skip to content

Commit 39703f9

Browse files
committed
improvement(integrations): overhaul landing FAQs for SEO/GEO and fix dynamic OG images
1 parent b465a3c commit 39703f9

16 files changed

Lines changed: 430 additions & 198 deletions

File tree

apps/sim/app/(landing)/components/landing-faq.tsx

Lines changed: 57 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
'use client'
22

3-
import { useState } from 'react'
4-
import { AnimatePresence, domAnimation, LazyMotion, m } from 'framer-motion'
3+
import { useId, useState } from 'react'
4+
import { domAnimation, LazyMotion, m } from 'framer-motion'
55
import { ChevronDown } from '@/components/emcn'
66
import { cn } from '@/lib/core/utils/cn'
77

@@ -14,7 +14,19 @@ interface LandingFAQProps {
1414
faqs: LandingFAQItem[]
1515
}
1616

17+
/**
18+
* Accordion FAQ for landing pages.
19+
*
20+
* Every answer stays mounted and is collapsed via animated height rather than
21+
* being rendered on demand: AI crawlers (GPTBot, ClaudeBot, PerplexityBot) and
22+
* non-rendering search crawlers only read the served HTML, so the full Q&A
23+
* text must exist in the initial document to be indexed or cited. Keeping the
24+
* answers in the DOM also keeps the FAQPage JSON-LD emitted by consuming pages
25+
* aligned with visible content, as Google's structured-data policy requires.
26+
* Questions render as h3 headings so each Q&A forms an extractable section.
27+
*/
1728
export function LandingFAQ({ faqs }: LandingFAQProps) {
29+
const baseId = useId()
1830
const [openIndex, setOpenIndex] = useState<number | null>(0)
1931
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null)
2032

@@ -23,8 +35,8 @@ export function LandingFAQ({ faqs }: LandingFAQProps) {
2335
<div>
2436
{faqs.map(({ question, answer }, index) => {
2537
const isOpen = openIndex === index
26-
const isHovered = hoveredIndex === index
2738
const showDivider = index > 0 && hoveredIndex !== index && hoveredIndex !== index - 1
39+
const panelId = `${baseId}-faq-panel-${index}`
2840

2941
return (
3042
<div key={question}>
@@ -34,50 +46,50 @@ export function LandingFAQ({ faqs }: LandingFAQProps) {
3446
index === 0 || !showDivider ? 'invisible' : 'visible'
3547
)}
3648
/>
37-
<button
38-
type='button'
39-
onClick={() => setOpenIndex(isOpen ? null : index)}
40-
onMouseEnter={() => setHoveredIndex(index)}
41-
onMouseLeave={() => setHoveredIndex(null)}
42-
className='-mx-6 flex w-[calc(100%+3rem)] items-center justify-between gap-4 px-6 py-4 text-left transition-colors hover:bg-[var(--landing-bg-elevated)]'
43-
aria-expanded={isOpen}
44-
>
45-
<span
46-
className={cn(
47-
'text-[15px] leading-snug tracking-[-0.02em] transition-colors',
48-
isOpen
49-
? 'text-[var(--landing-text)]'
50-
: 'text-[var(--landing-text-body)] hover:text-[var(--landing-text)]'
51-
)}
49+
<h3>
50+
<button
51+
type='button'
52+
onClick={() => setOpenIndex(isOpen ? null : index)}
53+
onMouseEnter={() => setHoveredIndex(index)}
54+
onMouseLeave={() => setHoveredIndex(null)}
55+
className='-mx-6 flex w-[calc(100%+3rem)] items-center justify-between gap-4 px-6 py-4 text-left transition-colors hover:bg-[var(--landing-bg-elevated)]'
56+
aria-expanded={isOpen}
57+
aria-controls={panelId}
5258
>
53-
{question}
54-
</span>
55-
<ChevronDown
56-
className={cn(
57-
'h-3 w-3 shrink-0 text-[var(--landing-text-subtle)] transition-transform duration-200',
58-
isOpen ? 'rotate-180' : 'rotate-0'
59-
)}
60-
aria-hidden='true'
61-
/>
62-
</button>
63-
64-
<AnimatePresence initial={false}>
65-
{isOpen && (
66-
<m.div
67-
initial={{ height: 0, opacity: 0 }}
68-
animate={{ height: 'auto', opacity: 1 }}
69-
exit={{ height: 0, opacity: 0 }}
70-
transition={{ duration: 0.25, ease: [0.4, 0, 0.2, 1] }}
71-
className='overflow-hidden'
59+
<span
60+
className={cn(
61+
'text-[15px] leading-snug tracking-[-0.02em] transition-colors',
62+
isOpen
63+
? 'text-[var(--landing-text)]'
64+
: 'text-[var(--landing-text-body)] hover:text-[var(--landing-text)]'
65+
)}
7266
>
73-
<div className='pt-2 pb-4'>
74-
<p className='text-[14px] text-[var(--landing-text-body)] leading-[1.75]'>
75-
{answer}
76-
</p>
77-
</div>
78-
</m.div>
79-
)}
80-
</AnimatePresence>
67+
{question}
68+
</span>
69+
<ChevronDown
70+
className={cn(
71+
'h-3 w-3 shrink-0 text-[var(--landing-text-subtle)] transition-transform duration-200',
72+
isOpen ? 'rotate-180' : 'rotate-0'
73+
)}
74+
aria-hidden='true'
75+
/>
76+
</button>
77+
</h3>
78+
79+
<m.div
80+
id={panelId}
81+
initial={false}
82+
animate={{ height: isOpen ? 'auto' : 0, opacity: isOpen ? 1 : 0 }}
83+
transition={{ duration: 0.25, ease: [0.4, 0, 0.2, 1] }}
84+
className='overflow-hidden'
85+
aria-hidden={!isOpen}
86+
>
87+
<div className='pt-2 pb-4'>
88+
<p className='text-[14px] text-[var(--landing-text-body)] leading-[1.75]'>
89+
{answer}
90+
</p>
91+
</div>
92+
</m.div>
8193
</div>
8294
)
8395
})}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { notFound } from 'next/navigation'
2+
import integrationsJson from '@/lib/integrations/integrations.json'
3+
import type { AuthType, Integration } from '@/lib/integrations/types'
4+
import { createLandingOgImage } from '@/app/(landing)/og-utils'
5+
6+
export const contentType = 'image/png'
7+
export const size = {
8+
width: 1200,
9+
height: 630,
10+
}
11+
12+
/**
13+
* Reads the raw catalog JSON instead of the `@/lib/integrations` barrel: the
14+
* barrel imports `@/blocks/registry`, which is far too heavy for the edge OG
15+
* bundle. The JSON is the same generated source of truth.
16+
*/
17+
const integrations = integrationsJson as readonly Integration[]
18+
const bySlug = new Map(integrations.map((i) => [i.slug, i]))
19+
20+
const AUTH_LABEL: Record<AuthType, string> = {
21+
oauth: 'One-click OAuth',
22+
'api-key': 'API key auth',
23+
none: 'No auth required',
24+
}
25+
26+
export default async function Image({ params }: { params: Promise<{ slug: string }> }) {
27+
const { slug } = await params
28+
const integration = bySlug.get(slug)
29+
30+
if (!integration) {
31+
notFound()
32+
}
33+
34+
const pills = [
35+
integration.operationCount > 0
36+
? `${integration.operationCount} tool${integration.operationCount === 1 ? '' : 's'}`
37+
: null,
38+
integration.triggerCount > 0
39+
? `${integration.triggerCount} real-time trigger${integration.triggerCount === 1 ? '' : 's'}`
40+
: null,
41+
AUTH_LABEL[integration.authType],
42+
'Free to start',
43+
].filter((pill): pill is string => pill !== null)
44+
45+
return createLandingOgImage({
46+
eyebrow: 'Sim integration',
47+
title: `${integration.name} Integration`,
48+
subtitle: integration.description,
49+
pills,
50+
domainLabel: `sim.ai/integrations/${slug}`,
51+
})
52+
}

0 commit comments

Comments
 (0)