chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
import type { ComponentType, SVGProps } from 'react'
|
||||
import { memo } from 'react'
|
||||
import Link from 'next/link'
|
||||
import type { Integration } from '@/lib/integrations'
|
||||
import { ChevronArrow } from '@/app/(landing)/components/chevron-arrow'
|
||||
import { IntegrationIcon } from '@/app/(landing)/integrations/components/integration-icon'
|
||||
|
||||
const HOVER_BG = 'transition-colors hover:bg-[var(--surface-hover)]' as const
|
||||
|
||||
interface IntegrationItemProps {
|
||||
integration: Integration
|
||||
IconComponent?: ComponentType<SVGProps<SVGSVGElement>>
|
||||
}
|
||||
|
||||
/**
|
||||
* Featured integration card - matches blog featured post pattern.
|
||||
* Used in flex rows separated by border-l dividers.
|
||||
*/
|
||||
export function IntegrationCard({ integration, IconComponent }: IntegrationItemProps) {
|
||||
const { slug, name, description, bgColor } = integration
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/integrations/${slug}`}
|
||||
className={`group/link flex flex-1 flex-col gap-4 border-[var(--border)] border-t p-6 first:border-t-0 sm:border-t-0 sm:border-l sm:first:border-l-0 ${HOVER_BG}`}
|
||||
>
|
||||
<IntegrationIcon
|
||||
bgColor={bgColor}
|
||||
name={name}
|
||||
Icon={IconComponent}
|
||||
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]'>
|
||||
{name}
|
||||
</h3>
|
||||
<p className='line-clamp-2 text-[var(--text-muted)] text-sm leading-[150%]'>
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Integration list row - matches blog remaining post pattern.
|
||||
* Each row followed by an h-px divider.
|
||||
*/
|
||||
export const IntegrationRow = memo(function IntegrationRow({
|
||||
integration,
|
||||
IconComponent,
|
||||
}: IntegrationItemProps) {
|
||||
const { slug, name, description, bgColor } = integration
|
||||
|
||||
return (
|
||||
<>
|
||||
<Link
|
||||
href={`/integrations/${slug}`}
|
||||
className={`group/link flex items-center gap-4 px-6 py-4 ${HOVER_BG}`}
|
||||
aria-label={`${name} integration`}
|
||||
>
|
||||
<IntegrationIcon
|
||||
bgColor={bgColor}
|
||||
name={name}
|
||||
Icon={IconComponent}
|
||||
className='size-8 shrink-0 rounded-xl border border-[var(--border-1)]'
|
||||
iconClassName='size-4'
|
||||
fallbackClassName='text-[13px]'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
|
||||
<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]'>
|
||||
{name}
|
||||
</h3>
|
||||
<p className='line-clamp-1 hidden text-[12px] text-[var(--text-muted)] leading-[150%] sm:block'>
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ChevronArrow />
|
||||
</Link>
|
||||
<div className='h-px w-full bg-[var(--border)]' />
|
||||
</>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,127 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { ChipInput, Search } from '@sim/emcn'
|
||||
import { debounce, useQueryStates } from 'nuqs'
|
||||
import { blockTypeToIconMap, formatIntegrationType, type Integration } from '@/lib/integrations'
|
||||
import { IntegrationRow } from '@/app/(landing)/integrations/components/integration-card'
|
||||
import {
|
||||
integrationsParsers,
|
||||
integrationsUrlKeys,
|
||||
} from '@/app/(landing)/integrations/search-params'
|
||||
|
||||
/** Debounce window for writing the search term to the URL (filtering is instant). */
|
||||
const SEARCH_DEBOUNCE_MS = 300
|
||||
|
||||
const PILL_BASE =
|
||||
'rounded-[5px] border border-[var(--border-1)] px-[9px] py-0.5 text-small text-[var(--text-primary)] transition-colors' as const
|
||||
const PILL_ACTIVE = 'bg-[var(--surface-active)]' as const
|
||||
const PILL_INACTIVE = 'hover:bg-[var(--surface-hover)]' as const
|
||||
|
||||
interface IntegrationGridProps {
|
||||
integrations: readonly Integration[]
|
||||
}
|
||||
|
||||
export function IntegrationGrid({ integrations }: IntegrationGridProps) {
|
||||
const [{ q: query, category }, setParams] = useQueryStates(
|
||||
integrationsParsers,
|
||||
integrationsUrlKeys
|
||||
)
|
||||
const activeCategory = category || null
|
||||
|
||||
// Category facets and a per-integration lowercased search index, derived once
|
||||
// from the (stable) integration list instead of rebuilt on every keystroke.
|
||||
// The index keeps each searchable field as its own entry so matching stays
|
||||
// identical to a per-field `includes` (no cross-field boundary matches).
|
||||
const { availableCategories, searchIndex } = useMemo(() => {
|
||||
const counts = new Map<string, number>()
|
||||
const searchIndex = new Map<string, string[]>()
|
||||
for (const i of integrations) {
|
||||
if (i.integrationType) {
|
||||
counts.set(i.integrationType, (counts.get(i.integrationType) || 0) + 1)
|
||||
}
|
||||
searchIndex.set(i.type, [
|
||||
i.name.toLowerCase(),
|
||||
i.description.toLowerCase(),
|
||||
...i.operations.flatMap((op) => [op.name.toLowerCase(), op.description.toLowerCase()]),
|
||||
...i.triggers.map((t) => t.name.toLowerCase()),
|
||||
])
|
||||
}
|
||||
return {
|
||||
availableCategories: Array.from(counts.entries())
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([key]) => key),
|
||||
searchIndex,
|
||||
}
|
||||
}, [integrations])
|
||||
|
||||
const q = query.trim().toLowerCase()
|
||||
const filtered = useMemo(
|
||||
() =>
|
||||
integrations.filter((i) => {
|
||||
if (activeCategory && i.integrationType !== activeCategory) return false
|
||||
if (!q) return true
|
||||
return searchIndex.get(i.type)?.some((field) => field.includes(q)) ?? false
|
||||
}),
|
||||
[integrations, searchIndex, q, activeCategory]
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className='mb-6 flex flex-col gap-4 px-6 sm:flex-row sm:items-center'>
|
||||
<div className='max-w-[480px] flex-1'>
|
||||
<ChipInput
|
||||
icon={Search}
|
||||
type='search'
|
||||
placeholder='Search integrations, tools, or triggers…'
|
||||
value={query}
|
||||
onChange={(e) =>
|
||||
setParams({ q: e.target.value }, { limitUrlUpdates: debounce(SEARCH_DEBOUNCE_MS) })
|
||||
}
|
||||
aria-label='Search integrations'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mb-6 flex flex-wrap gap-2 px-6'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setParams({ category: '' })}
|
||||
className={`${PILL_BASE} ${activeCategory === null ? PILL_ACTIVE : PILL_INACTIVE}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{availableCategories.map((cat) => (
|
||||
<button
|
||||
key={cat}
|
||||
type='button'
|
||||
onClick={() => setParams({ category: activeCategory === cat ? '' : cat })}
|
||||
className={`${PILL_BASE} ${activeCategory === cat ? PILL_ACTIVE : PILL_INACTIVE}`}
|
||||
>
|
||||
{formatIntegrationType(cat)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className='h-px w-full bg-[var(--border)]' />
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<p className='py-12 text-center text-[15px] text-[var(--text-muted)]'>
|
||||
No integrations found
|
||||
{query ? <> for “{query}”</> : null}
|
||||
{activeCategory ? <> in {formatIntegrationType(activeCategory)}</> : null}
|
||||
</p>
|
||||
) : (
|
||||
<div>
|
||||
{filtered.map((integration) => (
|
||||
<IntegrationRow
|
||||
key={integration.type}
|
||||
integration={integration}
|
||||
IconComponent={blockTypeToIconMap[integration.type]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { ComponentType, ElementType, HTMLAttributes, SVGProps } from 'react'
|
||||
import { cn } from '@sim/emcn'
|
||||
import { getTileIconColorClass } from '@/blocks/icon-color'
|
||||
|
||||
interface IntegrationIconProps extends HTMLAttributes<HTMLElement> {
|
||||
bgColor: string
|
||||
/** Integration name - used for the fallback initial letter. */
|
||||
name: string
|
||||
/** Optional icon component. When absent, renders the first letter of `name`. */
|
||||
Icon?: ComponentType<SVGProps<SVGSVGElement>> | null
|
||||
/** Tailwind size + rounding classes for the container. Default: `size-10 rounded-xl` */
|
||||
className?: string
|
||||
/** Tailwind size classes for the icon SVG. Default: `size-5` */
|
||||
iconClassName?: string
|
||||
/** Tailwind text-size class for the fallback letter. Default: `text-[15px]` */
|
||||
fallbackClassName?: string
|
||||
/** Rendered HTML element. Default: `div` */
|
||||
as?: ElementType
|
||||
}
|
||||
|
||||
/**
|
||||
* Colored icon box used across integration listing and detail pages.
|
||||
* Renders an integration icon over a brand-colored background, falling back
|
||||
* to the integration's initial letter when no icon is available.
|
||||
*/
|
||||
export function IntegrationIcon({
|
||||
bgColor,
|
||||
name,
|
||||
Icon,
|
||||
className,
|
||||
iconClassName = 'size-5',
|
||||
fallbackClassName = 'text-[15px]',
|
||||
as: Tag = 'div',
|
||||
...rest
|
||||
}: IntegrationIconProps) {
|
||||
return (
|
||||
<Tag
|
||||
className={cn('flex shrink-0 items-center justify-center', className)}
|
||||
style={{ background: bgColor }}
|
||||
{...rest}
|
||||
>
|
||||
{Icon ? (
|
||||
<Icon className={cn(iconClassName, getTileIconColorClass(bgColor))} />
|
||||
) : (
|
||||
<span className={cn('leading-none', getTileIconColorClass(bgColor), fallbackClassName)}>
|
||||
{name.charAt(0)}
|
||||
</span>
|
||||
)}
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
import {
|
||||
Chip,
|
||||
ChipModal,
|
||||
ChipModalBody,
|
||||
ChipModalError,
|
||||
ChipModalField,
|
||||
ChipModalFooter,
|
||||
ChipModalHeader,
|
||||
} from '@sim/emcn'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import { integrationRequestContract } from '@/lib/api/contracts/common'
|
||||
|
||||
type SubmitStatus = 'idle' | 'submitting' | 'success' | 'error'
|
||||
|
||||
export function RequestIntegrationModal() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [status, setStatus] = useState<SubmitStatus>('idle')
|
||||
|
||||
const [integrationName, setIntegrationName] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [useCase, setUseCase] = useState('')
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
setIntegrationName('')
|
||||
setEmail('')
|
||||
setUseCase('')
|
||||
setStatus('idle')
|
||||
}, [])
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(nextOpen: boolean) => {
|
||||
setOpen(nextOpen)
|
||||
if (!nextOpen) resetForm()
|
||||
},
|
||||
[resetForm]
|
||||
)
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!integrationName.trim() || !email.trim()) return
|
||||
|
||||
setStatus('submitting')
|
||||
|
||||
try {
|
||||
await requestJson(integrationRequestContract, {
|
||||
body: {
|
||||
integrationName: integrationName.trim(),
|
||||
email: email.trim(),
|
||||
useCase: useCase.trim() || undefined,
|
||||
},
|
||||
})
|
||||
|
||||
setStatus('success')
|
||||
setTimeout(() => setOpen(false), 1500)
|
||||
} catch {
|
||||
setStatus('error')
|
||||
}
|
||||
}, [integrationName, email, useCase])
|
||||
|
||||
const canSubmit = integrationName.trim() && email.trim() && status === 'idle'
|
||||
|
||||
return (
|
||||
<>
|
||||
<Chip className='border border-[var(--border-1)]' onClick={() => setOpen(true)}>
|
||||
Request an integration
|
||||
</Chip>
|
||||
|
||||
<ChipModal open={open} onOpenChange={handleOpenChange} srTitle='Request an Integration'>
|
||||
<ChipModalHeader onClose={() => handleOpenChange(false)}>
|
||||
Request an Integration
|
||||
</ChipModalHeader>
|
||||
|
||||
<ChipModalBody>
|
||||
{status === 'success' ? (
|
||||
<div className='flex flex-col items-center gap-3 py-6 text-center'>
|
||||
<div className='flex size-10 items-center justify-center rounded-full bg-[var(--brand-accent)]/10'>
|
||||
<svg
|
||||
className='size-5 text-[var(--brand-accent)]'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth={2}
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
>
|
||||
<polyline points='20 6 9 17 4 12' />
|
||||
</svg>
|
||||
</div>
|
||||
<p className='text-[14px] text-[var(--text-primary)]'>
|
||||
Request submitted. We'll follow up at <span>{email}</span>.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<ChipModalField
|
||||
type='input'
|
||||
title='Integration name'
|
||||
value={integrationName}
|
||||
onChange={(value) => setIntegrationName(value)}
|
||||
placeholder='e.g. Stripe, HubSpot, Snowflake'
|
||||
maxLength={200}
|
||||
autoComplete='off'
|
||||
required
|
||||
/>
|
||||
<ChipModalField
|
||||
type='email'
|
||||
title='Your email'
|
||||
value={email}
|
||||
onChange={(value) => setEmail(value)}
|
||||
placeholder='you@company.com'
|
||||
autoComplete='email'
|
||||
required
|
||||
/>
|
||||
<ChipModalField
|
||||
type='textarea'
|
||||
title={
|
||||
<>
|
||||
Use case <span className='text-[var(--text-subtle)]'>(optional)</span>
|
||||
</>
|
||||
}
|
||||
value={useCase}
|
||||
onChange={(value) => setUseCase(value)}
|
||||
placeholder='What would you automate with this integration?'
|
||||
rows={3}
|
||||
maxLength={2000}
|
||||
/>
|
||||
{status === 'error' && (
|
||||
<ChipModalError>Something went wrong. Please try again.</ChipModalError>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ChipModalBody>
|
||||
|
||||
{status === 'success' ? (
|
||||
<ChipModalFooter
|
||||
onCancel={() => handleOpenChange(false)}
|
||||
primaryAction={{ label: 'Done', onClick: () => handleOpenChange(false) }}
|
||||
/>
|
||||
) : (
|
||||
<ChipModalFooter
|
||||
onCancel={() => setOpen(false)}
|
||||
cancelDisabled={status === 'submitting'}
|
||||
primaryAction={{
|
||||
label: status === 'submitting' ? 'Submitting...' : 'Submit request',
|
||||
onClick: handleSubmit,
|
||||
disabled: !canSubmit && status !== 'error',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</ChipModal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user