Skip to content

Commit 9774679

Browse files
authored
improvement(integrations): overhaul landing FAQs for SEO/GEO and fix dynamic OG images (#4985)
* improvement(integrations): overhaul landing FAQs for SEO/GEO and fix dynamic OG images * improvement(integrations): trim comments and fold catalog updatedAt into integrations.json * fix(integrations): correct FAQ copy for zero-capability and single-tool integrations
1 parent 9ab64e5 commit 9774679

17 files changed

Lines changed: 17095 additions & 16905 deletions

File tree

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

Lines changed: 51 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,13 @@ interface LandingFAQProps {
1414
faqs: LandingFAQItem[]
1515
}
1616

17+
/**
18+
* Accordion FAQ for landing pages. Answers stay mounted (collapsed via
19+
* animated height) so non-JS crawlers see the full Q&A text and FAQPage
20+
* JSON-LD always matches visible content.
21+
*/
1722
export function LandingFAQ({ faqs }: LandingFAQProps) {
23+
const baseId = useId()
1824
const [openIndex, setOpenIndex] = useState<number | null>(0)
1925
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null)
2026

@@ -23,8 +29,8 @@ export function LandingFAQ({ faqs }: LandingFAQProps) {
2329
<div>
2430
{faqs.map(({ question, answer }, index) => {
2531
const isOpen = openIndex === index
26-
const isHovered = hoveredIndex === index
2732
const showDivider = index > 0 && hoveredIndex !== index && hoveredIndex !== index - 1
33+
const panelId = `${baseId}-faq-panel-${index}`
2834

2935
return (
3036
<div key={question}>
@@ -34,50 +40,50 @@ export function LandingFAQ({ faqs }: LandingFAQProps) {
3440
index === 0 || !showDivider ? 'invisible' : 'visible'
3541
)}
3642
/>
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-
)}
43+
<h3>
44+
<button
45+
type='button'
46+
onClick={() => setOpenIndex(isOpen ? null : index)}
47+
onMouseEnter={() => setHoveredIndex(index)}
48+
onMouseLeave={() => setHoveredIndex(null)}
49+
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)]'
50+
aria-expanded={isOpen}
51+
aria-controls={panelId}
5252
>
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'
53+
<span
54+
className={cn(
55+
'text-[15px] leading-snug tracking-[-0.02em] transition-colors',
56+
isOpen
57+
? 'text-[var(--landing-text)]'
58+
: 'text-[var(--landing-text-body)] hover:text-[var(--landing-text)]'
59+
)}
7260
>
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>
61+
{question}
62+
</span>
63+
<ChevronDown
64+
className={cn(
65+
'h-3 w-3 shrink-0 text-[var(--landing-text-subtle)] transition-transform duration-200',
66+
isOpen ? 'rotate-180' : 'rotate-0'
67+
)}
68+
aria-hidden='true'
69+
/>
70+
</button>
71+
</h3>
72+
73+
<m.div
74+
id={panelId}
75+
initial={false}
76+
animate={{ height: isOpen ? 'auto' : 0, opacity: isOpen ? 1 : 0 }}
77+
transition={{ duration: 0.25, ease: [0.4, 0, 0.2, 1] }}
78+
className='overflow-hidden'
79+
aria-hidden={!isOpen}
80+
>
81+
<div className='pt-2 pb-4'>
82+
<p className='text-[14px] text-[var(--landing-text-body)] leading-[1.75]'>
83+
{answer}
84+
</p>
85+
</div>
86+
</m.div>
8187
</div>
8288
)
8389
})}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
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+
/** Raw catalog JSON, not the barrel — keeps `@/blocks/registry` out of the OG bundle. */
13+
const integrations = integrationsJson.integrations as readonly Integration[]
14+
const bySlug = new Map(integrations.map((i) => [i.slug, i]))
15+
16+
const AUTH_LABEL: Record<AuthType, string> = {
17+
oauth: 'One-click OAuth',
18+
'api-key': 'API key auth',
19+
none: 'No auth required',
20+
}
21+
22+
export default async function Image({ params }: { params: Promise<{ slug: string }> }) {
23+
const { slug } = await params
24+
const integration = bySlug.get(slug)
25+
26+
if (!integration) {
27+
notFound()
28+
}
29+
30+
const pills = [
31+
integration.operationCount > 0
32+
? `${integration.operationCount} tool${integration.operationCount === 1 ? '' : 's'}`
33+
: null,
34+
integration.triggerCount > 0
35+
? `${integration.triggerCount} real-time trigger${integration.triggerCount === 1 ? '' : 's'}`
36+
: null,
37+
AUTH_LABEL[integration.authType],
38+
'Free to start',
39+
].filter((pill): pill is string => pill !== null)
40+
41+
return createLandingOgImage({
42+
eyebrow: 'Sim integration',
43+
title: `${integration.name} Integration`,
44+
subtitle: integration.description,
45+
pills,
46+
domainLabel: `sim.ai/integrations/${slug}`,
47+
})
48+
}

0 commit comments

Comments
 (0)