Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 51 additions & 45 deletions apps/sim/app/(landing)/components/landing-faq.tsx
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -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<number | null>(0)
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null)

Expand All @@ -23,8 +29,8 @@ export function LandingFAQ({ faqs }: LandingFAQProps) {
<div>
{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 (
<div key={question}>
Expand All @@ -34,50 +40,50 @@ export function LandingFAQ({ faqs }: LandingFAQProps) {
index === 0 || !showDivider ? 'invisible' : 'visible'
)}
/>
<button
type='button'
onClick={() => setOpenIndex(isOpen ? null : index)}
onMouseEnter={() => setHoveredIndex(index)}
onMouseLeave={() => setHoveredIndex(null)}
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)]'
aria-expanded={isOpen}
>
<span
className={cn(
'text-[15px] leading-snug tracking-[-0.02em] transition-colors',
isOpen
? 'text-[var(--landing-text)]'
: 'text-[var(--landing-text-body)] hover:text-[var(--landing-text)]'
)}
<h3>
<button
type='button'
onClick={() => setOpenIndex(isOpen ? null : index)}
onMouseEnter={() => setHoveredIndex(index)}
onMouseLeave={() => setHoveredIndex(null)}
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)]'
aria-expanded={isOpen}
aria-controls={panelId}
>
{question}
</span>
<ChevronDown
className={cn(
'h-3 w-3 shrink-0 text-[var(--landing-text-subtle)] transition-transform duration-200',
isOpen ? 'rotate-180' : 'rotate-0'
)}
aria-hidden='true'
/>
</button>

<AnimatePresence initial={false}>
{isOpen && (
<m.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.25, ease: [0.4, 0, 0.2, 1] }}
className='overflow-hidden'
<span
className={cn(
'text-[15px] leading-snug tracking-[-0.02em] transition-colors',
isOpen
? 'text-[var(--landing-text)]'
: 'text-[var(--landing-text-body)] hover:text-[var(--landing-text)]'
)}
>
<div className='pt-2 pb-4'>
<p className='text-[14px] text-[var(--landing-text-body)] leading-[1.75]'>
{answer}
</p>
</div>
</m.div>
)}
</AnimatePresence>
{question}
</span>
<ChevronDown
className={cn(
'h-3 w-3 shrink-0 text-[var(--landing-text-subtle)] transition-transform duration-200',
isOpen ? 'rotate-180' : 'rotate-0'
)}
aria-hidden='true'
/>
</button>
</h3>

<m.div
id={panelId}
initial={false}
animate={{ height: isOpen ? 'auto' : 0, opacity: isOpen ? 1 : 0 }}
transition={{ duration: 0.25, ease: [0.4, 0, 0.2, 1] }}
className='overflow-hidden'
aria-hidden={!isOpen}
>
<div className='pt-2 pb-4'>
<p className='text-[14px] text-[var(--landing-text-body)] leading-[1.75]'>
{answer}
</p>
</div>
</m.div>
Comment thread
greptile-apps[bot] marked this conversation as resolved.
</div>
)
})}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<AuthType, string> = {
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}`,
})
}
Loading
Loading