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,288 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
Loader,
|
||||
Modal,
|
||||
ModalClose,
|
||||
ModalContent,
|
||||
ModalDescription,
|
||||
ModalTitle,
|
||||
ModalTrigger,
|
||||
} from '@sim/emcn'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { X } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { GithubIcon, GoogleIcon, MicrosoftIcon } from '@/components/icons'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import { type AuthProviderStatusResponse, getAuthProvidersContract } from '@/lib/api/contracts/auth'
|
||||
import { client } from '@/lib/auth/auth-client'
|
||||
import { getEnv, isFalsy, isTruthy } from '@/lib/core/config/env'
|
||||
import { captureClientEvent } from '@/lib/posthog/client'
|
||||
import type { PostHogEventMap } from '@/lib/posthog/events'
|
||||
import { getBrandConfig } from '@/ee/whitelabeling'
|
||||
|
||||
const logger = createLogger('AuthModal')
|
||||
|
||||
type AuthView = 'login' | 'signup'
|
||||
|
||||
interface AuthModalProps {
|
||||
children: React.ReactNode
|
||||
defaultView?: AuthView
|
||||
source: PostHogEventMap['auth_modal_opened']['source']
|
||||
}
|
||||
|
||||
type ProviderStatus = AuthProviderStatusResponse
|
||||
|
||||
let fetchPromise: Promise<AuthProviderStatusResponse> | null = null
|
||||
|
||||
const FALLBACK_STATUS: ProviderStatus = {
|
||||
githubAvailable: false,
|
||||
googleAvailable: false,
|
||||
microsoftAvailable: false,
|
||||
registrationDisabled: false,
|
||||
}
|
||||
|
||||
const SOCIAL_BTN =
|
||||
'relative flex h-[32px] w-full items-center justify-center rounded-[5px] border border-[var(--border-1)] text-[13.5px] text-[var(--text-primary)] transition-colors hover:bg-[var(--surface-hover)] disabled:cursor-not-allowed disabled:opacity-50'
|
||||
|
||||
function fetchProviderStatus(): Promise<ProviderStatus> {
|
||||
if (fetchPromise) return fetchPromise
|
||||
fetchPromise = requestJson(getAuthProvidersContract, {})
|
||||
.then(({ githubAvailable, googleAvailable, microsoftAvailable, registrationDisabled }) => ({
|
||||
githubAvailable,
|
||||
googleAvailable,
|
||||
microsoftAvailable,
|
||||
registrationDisabled,
|
||||
}))
|
||||
.catch(() => {
|
||||
fetchPromise = null
|
||||
return FALLBACK_STATUS
|
||||
})
|
||||
return fetchPromise
|
||||
}
|
||||
|
||||
export function AuthModal({ children, defaultView = 'login', source }: AuthModalProps) {
|
||||
const router = useRouter()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [view, setView] = useState<AuthView>(defaultView)
|
||||
const [providerStatus, setProviderStatus] = useState<ProviderStatus | null>(null)
|
||||
const [socialLoading, setSocialLoading] = useState<'github' | 'google' | 'microsoft' | null>(null)
|
||||
const brand = getBrandConfig()
|
||||
|
||||
useEffect(() => {
|
||||
fetchProviderStatus().then(setProviderStatus)
|
||||
}, [])
|
||||
|
||||
const ssoEnabled = isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED'))
|
||||
const emailEnabled = !isFalsy(getEnv('NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED'))
|
||||
|
||||
/**
|
||||
* Signup is unavailable when registration is disabled, so the shown view is
|
||||
* clamped to login rather than mirrored into state through an effect.
|
||||
*/
|
||||
const effectiveView: AuthView =
|
||||
view === 'signup' && providerStatus?.registrationDisabled ? 'login' : view
|
||||
|
||||
/**
|
||||
* Tracks whether the visitor still wants the modal open. Cleared on dismiss so a
|
||||
* provider-status fetch that resolves afterwards can't reopen it or re-fire the
|
||||
* opened event.
|
||||
*/
|
||||
const openRequestedRef = useRef(false)
|
||||
|
||||
function openWithStatus(status: ProviderStatus) {
|
||||
const hasModalContent =
|
||||
status.githubAvailable || status.googleAvailable || status.microsoftAvailable || ssoEnabled
|
||||
if (!hasModalContent) {
|
||||
/** Close the loader (no-op if never opened) and route out; disabled registration sends signup to login. */
|
||||
setOpen(false)
|
||||
router.push(status.registrationDisabled || defaultView === 'login' ? '/login' : '/signup')
|
||||
return
|
||||
}
|
||||
const initialView: AuthView =
|
||||
defaultView === 'signup' && status.registrationDisabled ? 'login' : defaultView
|
||||
setOpen(true)
|
||||
setView(initialView)
|
||||
captureClientEvent('auth_modal_opened', { view: initialView, source })
|
||||
}
|
||||
|
||||
function handleOpenChange(nextOpen: boolean) {
|
||||
if (!nextOpen) {
|
||||
openRequestedRef.current = false
|
||||
setOpen(false)
|
||||
return
|
||||
}
|
||||
if (providerStatus) {
|
||||
openWithStatus(providerStatus)
|
||||
return
|
||||
}
|
||||
/** Status not loaded yet: open the loader immediately for responsiveness, then resolve. */
|
||||
openRequestedRef.current = true
|
||||
setOpen(true)
|
||||
fetchProviderStatus().then((status) => {
|
||||
setProviderStatus(status)
|
||||
if (!openRequestedRef.current) return
|
||||
/** Consume the request so a queued double-click can't open twice (no duplicate event). */
|
||||
openRequestedRef.current = false
|
||||
openWithStatus(status)
|
||||
})
|
||||
}
|
||||
|
||||
async function handleSocialLogin(provider: 'github' | 'google' | 'microsoft') {
|
||||
setSocialLoading(provider)
|
||||
try {
|
||||
await client.signIn.social({ provider, callbackURL: '/workspace' })
|
||||
} catch (error) {
|
||||
logger.warn('Social sign-in did not complete', { provider, error })
|
||||
} finally {
|
||||
setSocialLoading(null)
|
||||
}
|
||||
}
|
||||
|
||||
function handleSSOLogin() {
|
||||
setOpen(false)
|
||||
router.push('/sso')
|
||||
}
|
||||
|
||||
function handleEmailContinue() {
|
||||
setOpen(false)
|
||||
router.push(effectiveView === 'login' ? '/login' : '/signup')
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open={open} onOpenChange={handleOpenChange}>
|
||||
<ModalTrigger asChild>{children}</ModalTrigger>
|
||||
<ModalContent size='sm' className='dark bg-[var(--bg)] text-[var(--text-primary)]'>
|
||||
<ModalTitle className='sr-only'>
|
||||
{effectiveView === 'login' ? 'Log in' : 'Create account'}
|
||||
</ModalTitle>
|
||||
<ModalDescription className='sr-only'>
|
||||
{effectiveView === 'login' ? 'Sign in to your account' : 'Create a new account'}
|
||||
</ModalDescription>
|
||||
|
||||
<div className='relative px-6 pt-6 pb-6'>
|
||||
<ModalClose className='absolute top-6 right-6 rounded-sm opacity-70 transition-opacity hover:opacity-100'>
|
||||
<X className='size-5 text-[var(--text-muted)]' />
|
||||
<span className='sr-only'>Close</span>
|
||||
</ModalClose>
|
||||
|
||||
{!providerStatus ? (
|
||||
<div className='flex items-center justify-center py-16'>
|
||||
<Loader className='size-5 text-[var(--text-muted)]' animate />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className='flex flex-col items-start gap-6 pe-10'>
|
||||
<Image
|
||||
src={brand.logoUrl || '/logo/sim-landing.svg'}
|
||||
alt={brand.name}
|
||||
width={71}
|
||||
height={22}
|
||||
unoptimized
|
||||
className='h-[22px] w-auto shrink-0 object-contain'
|
||||
/>
|
||||
<div className='flex flex-col gap-1 text-left'>
|
||||
<p className='text-[22px] text-[color-mix(in_srgb,var(--text-muted)_60%,transparent)] leading-[125%] tracking-[0.02em]'>
|
||||
Start building.
|
||||
</p>
|
||||
<h2 className='text-[22px] text-[var(--text-primary)] leading-[110%] tracking-[-0.02em]'>
|
||||
{effectiveView === 'login' ? 'Log in to continue' : 'Create free account'}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mt-6 space-y-3'>
|
||||
{providerStatus.googleAvailable && (
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => handleSocialLogin('google')}
|
||||
disabled={!!socialLoading}
|
||||
className={SOCIAL_BTN}
|
||||
>
|
||||
<GoogleIcon className='absolute left-4 size-[18px] shrink-0' />
|
||||
<span>
|
||||
{socialLoading === 'google' ? 'Connecting...' : 'Continue with Google'}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{providerStatus.microsoftAvailable && (
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => handleSocialLogin('microsoft')}
|
||||
disabled={!!socialLoading}
|
||||
className={SOCIAL_BTN}
|
||||
>
|
||||
<MicrosoftIcon className='absolute left-4 size-[18px] shrink-0' />
|
||||
<span>
|
||||
{socialLoading === 'microsoft' ? 'Connecting...' : 'Continue with Microsoft'}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{providerStatus.githubAvailable && (
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => handleSocialLogin('github')}
|
||||
disabled={!!socialLoading}
|
||||
className={SOCIAL_BTN}
|
||||
>
|
||||
<GithubIcon className='absolute left-4 size-[18px] shrink-0' />
|
||||
<span>
|
||||
{socialLoading === 'github' ? 'Connecting...' : 'Continue with GitHub'}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{ssoEnabled && (
|
||||
<button type='button' onClick={handleSSOLogin} className={SOCIAL_BTN}>
|
||||
Sign in with SSO
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{emailEnabled && (
|
||||
<>
|
||||
<div className='relative my-4'>
|
||||
<div className='absolute inset-0 flex items-center'>
|
||||
<div className='w-full border-[var(--border)] border-t' />
|
||||
</div>
|
||||
<div className='relative flex justify-center text-[13.5px]'>
|
||||
<span className='bg-[var(--bg)] px-4 text-[var(--text-muted)]'>Or</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleEmailContinue}
|
||||
className='flex h-[32px] w-full items-center justify-center rounded-[5px] border border-[var(--auth-primary-btn-border)] bg-[var(--auth-primary-btn-bg)] text-[13.5px] text-[var(--auth-primary-btn-text)] transition-colors hover:border-[var(--auth-primary-btn-hover-border)] hover:bg-[var(--auth-primary-btn-hover-bg)]'
|
||||
>
|
||||
Continue with email
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className='mt-4 text-center text-[13.5px]'>
|
||||
<span className='text-[var(--text-muted)]'>
|
||||
{effectiveView === 'login'
|
||||
? "Don't have an account? "
|
||||
: 'Already have an account? '}
|
||||
</span>
|
||||
{effectiveView === 'login' && providerStatus.registrationDisabled ? (
|
||||
<span className='text-[var(--text-muted)]'>Registration is disabled</span>
|
||||
) : (
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setView(effectiveView === 'login' ? 'signup' : 'login')}
|
||||
className='text-[var(--text-primary)] underline-offset-4 transition hover:text-[var(--text-primary)] hover:underline'
|
||||
>
|
||||
{effectiveView === 'login' ? 'Sign up' : 'Sign in'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import Link from 'next/link'
|
||||
|
||||
/**
|
||||
* The canonical "Back to X" link for the landing family - a muted text link with
|
||||
* a left chevron whose shaft draws in on hover. Single source of truth, reused by
|
||||
* the blog post, integration, and model detail pages so the back affordance can't
|
||||
* drift. Season is the global body font; color uses the platform muted→primary
|
||||
* hover idiom.
|
||||
*/
|
||||
interface BackLinkProps {
|
||||
href: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export function BackLink({ href, label }: BackLinkProps) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className='group/link inline-flex items-center gap-1.5 text-[var(--text-muted)] text-sm tracking-[0.02em] hover:text-[var(--text-primary)]'
|
||||
>
|
||||
<svg
|
||||
className='size-3 shrink-0'
|
||||
viewBox='0 0 10 10'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
aria-hidden='true'
|
||||
>
|
||||
<line
|
||||
x1='1'
|
||||
y1='5'
|
||||
x2='10'
|
||||
y2='5'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.33'
|
||||
strokeLinecap='square'
|
||||
className='origin-right scale-x-0 transition-transform duration-200 ease-out [transform-box:fill-box] group-hover/link:scale-x-100'
|
||||
/>
|
||||
<path
|
||||
d='M6.5 2L3.5 5L6.5 8'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.33'
|
||||
strokeLinecap='square'
|
||||
strokeLinejoin='miter'
|
||||
fill='none'
|
||||
className='group-hover/link:-translate-x-[30%] transition-transform duration-200 ease-out'
|
||||
/>
|
||||
</svg>
|
||||
{label}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { BackLink } from './back-link'
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* The animated chevron used on landing link rows (models, integrations). On
|
||||
* `group-hover/link` the leading line draws in and the arrowhead nudges right.
|
||||
* Decorative, so `aria-hidden`.
|
||||
*/
|
||||
export function ChevronArrow() {
|
||||
return (
|
||||
<svg
|
||||
className='size-3 shrink-0 text-[var(--text-muted)]'
|
||||
viewBox='0 0 10 10'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
aria-hidden='true'
|
||||
>
|
||||
<line
|
||||
x1='0'
|
||||
y1='5'
|
||||
x2='9'
|
||||
y2='5'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.33'
|
||||
strokeLinecap='square'
|
||||
className='origin-left scale-x-0 transition-transform duration-200 ease-out [transform-box:fill-box] group-hover/link:scale-x-100'
|
||||
/>
|
||||
<path
|
||||
d='M3.5 2L6.5 5L3.5 8'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.33'
|
||||
strokeLinecap='square'
|
||||
strokeLinejoin='miter'
|
||||
fill='none'
|
||||
className='transition-transform duration-200 ease-out group-hover/link:translate-x-[30%]'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { ChevronArrow } from './chevron-arrow'
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Skeleton } from '@sim/emcn'
|
||||
|
||||
const AUTHOR_POST_SKELETON_COUNT = 4
|
||||
|
||||
/** Shared loading skeleton for a content section's author-profile route. */
|
||||
export function ContentAuthorLoading() {
|
||||
return (
|
||||
<main className='mx-auto max-w-[900px] px-6 py-10 sm:px-8 md:px-12'>
|
||||
<div className='mb-6 flex items-center gap-3'>
|
||||
<Skeleton className='size-[40px] rounded-full bg-[var(--surface-hover)]' />
|
||||
<Skeleton className='h-[32px] w-[160px] rounded-[4px] bg-[var(--surface-hover)]' />
|
||||
</div>
|
||||
<div className='grid grid-cols-1 gap-8 sm:grid-cols-2'>
|
||||
{Array.from({ length: AUTHOR_POST_SKELETON_COUNT }).map((_, i) => (
|
||||
<div key={i} className='overflow-hidden rounded-lg border border-[var(--border)]'>
|
||||
<Skeleton className='h-[160px] w-full rounded-none bg-[var(--surface-hover)]' />
|
||||
<div className='p-3'>
|
||||
<Skeleton className='mb-1 h-[12px] w-[80px] rounded-[4px] bg-[var(--surface-hover)]' />
|
||||
<Skeleton className='h-[14px] w-[200px] rounded-[4px] bg-[var(--surface-hover)]' />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import type { ContentMeta } from '@/lib/content/schema'
|
||||
import { JsonLd } from '@/app/(landing)/components/json-ld'
|
||||
|
||||
interface ContentAuthorPageProps {
|
||||
/** Route base path, e.g. `/blog` or `/library`. */
|
||||
basePath: string
|
||||
authorName?: string
|
||||
authorAvatarUrl?: string
|
||||
/** Posts already filtered down to this author. */
|
||||
posts: ContentMeta[]
|
||||
graphJsonLd?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/** Shared author-profile layout for a content section. */
|
||||
export function ContentAuthorPage({
|
||||
basePath,
|
||||
authorName,
|
||||
authorAvatarUrl,
|
||||
posts,
|
||||
graphJsonLd,
|
||||
}: ContentAuthorPageProps) {
|
||||
if (!authorName) {
|
||||
return (
|
||||
<main className='mx-auto max-w-[900px] px-6 py-10 sm:px-8 md:px-12'>
|
||||
<h1 className='text-[32px] text-[var(--text-primary)]'>Author not found</h1>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<main className='mx-auto max-w-[900px] px-6 py-10 sm:px-8 md:px-12'>
|
||||
{graphJsonLd && <JsonLd data={graphJsonLd} />}
|
||||
<div className='mb-6 flex items-center gap-3'>
|
||||
{authorAvatarUrl ? (
|
||||
<Image
|
||||
src={authorAvatarUrl}
|
||||
alt={authorName}
|
||||
width={40}
|
||||
height={40}
|
||||
className='rounded-full'
|
||||
unoptimized
|
||||
/>
|
||||
) : null}
|
||||
<h1 className='text-[32px] text-[var(--text-primary)] leading-tight'>{authorName}</h1>
|
||||
</div>
|
||||
<div className='grid grid-cols-1 gap-8 sm:grid-cols-2'>
|
||||
{posts.map((p) => (
|
||||
<Link key={p.slug} href={`${basePath}/${p.slug}`} className='group'>
|
||||
<div className='overflow-hidden rounded-lg border border-[var(--border)]'>
|
||||
<Image
|
||||
src={p.ogImage}
|
||||
alt={p.title}
|
||||
width={600}
|
||||
height={315}
|
||||
className='h-[160px] w-full object-cover transition-transform group-hover:scale-[1.02]'
|
||||
unoptimized
|
||||
/>
|
||||
<div className='p-3'>
|
||||
<div className='mb-1 text-[var(--text-muted)] text-xs'>
|
||||
{new Date(p.date).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</div>
|
||||
<div className='text-[var(--text-primary)] text-sm leading-tight'>{p.title}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { ContentAuthorLoading } from './content-author-loading'
|
||||
export { ContentAuthorPage } from './content-author-page'
|
||||
@@ -0,0 +1,53 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { cn } from '@sim/emcn'
|
||||
import NextImage from 'next/image'
|
||||
import { Lightbox } from '@/app/(landing)/components/lightbox'
|
||||
|
||||
interface ContentImageProps {
|
||||
src: string
|
||||
alt?: string
|
||||
width?: number
|
||||
height?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Click-to-zoom image renderer used by MDX content (blog and library posts
|
||||
* both compile through the same `mdxComponents` map in `@/lib/content/mdx`).
|
||||
*/
|
||||
export function ContentImage({
|
||||
src,
|
||||
alt = '',
|
||||
width = 800,
|
||||
height = 450,
|
||||
className,
|
||||
}: ContentImageProps) {
|
||||
const [isLightboxOpen, setIsLightboxOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<NextImage
|
||||
src={src}
|
||||
alt={alt}
|
||||
width={width}
|
||||
height={height}
|
||||
className={cn(
|
||||
'h-auto w-full cursor-pointer rounded-lg transition-opacity hover:opacity-95',
|
||||
className
|
||||
)}
|
||||
sizes='(max-width: 768px) 100vw, 800px'
|
||||
loading='lazy'
|
||||
unoptimized
|
||||
onClick={() => setIsLightboxOpen(true)}
|
||||
/>
|
||||
<Lightbox
|
||||
isOpen={isLightboxOpen}
|
||||
onClose={() => setIsLightboxOpen(false)}
|
||||
src={src}
|
||||
alt={alt}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { ContentImage } from './content-image'
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Skeleton } from '@sim/emcn'
|
||||
|
||||
const FEATURED_SKELETON_COUNT = 3
|
||||
const LIST_SKELETON_COUNT = 5
|
||||
|
||||
/** Shared loading skeleton for a content section's index/list route. */
|
||||
export function ContentIndexLoading() {
|
||||
return (
|
||||
<section className='bg-[var(--bg)]'>
|
||||
<div className='mx-auto w-full max-w-[1460px]'>
|
||||
<div className='px-20 pt-[112px] max-sm:px-5 max-sm:pt-20 max-lg:px-8'>
|
||||
<Skeleton className='mb-5 h-[20px] w-[60px] rounded-md bg-[var(--surface-hover)]' />
|
||||
<div className='flex flex-col gap-4 md:flex-row md:items-end md:justify-between'>
|
||||
<Skeleton className='h-[40px] w-[240px] rounded-[4px] bg-[var(--surface-hover)]' />
|
||||
<Skeleton className='h-[18px] w-[320px] rounded-[4px] bg-[var(--surface-hover)]' />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mx-20 mt-8 border-[var(--border)] border-x max-sm:mx-5 max-lg:mx-8'>
|
||||
<div className='h-px w-full bg-[var(--border)]' />
|
||||
|
||||
<div className='flex max-sm:flex-col'>
|
||||
{Array.from({ length: FEATURED_SKELETON_COUNT }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className='flex flex-1 flex-col gap-4 border-[var(--border)] p-6 max-sm:border-t max-sm:first:border-t-0 md:border-l md:first:border-l-0'
|
||||
>
|
||||
<Skeleton className='aspect-video w-full rounded-[5px] bg-[var(--surface-hover)]' />
|
||||
<div className='flex flex-col gap-2'>
|
||||
<Skeleton className='h-[12px] w-[60px] rounded-[4px] bg-[var(--surface-hover)]' />
|
||||
<Skeleton className='h-[20px] w-[80%] rounded-[4px] bg-[var(--surface-hover)]' />
|
||||
<Skeleton className='h-[14px] w-full rounded-[4px] bg-[var(--surface-hover)]' />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className='h-px w-full bg-[var(--border)]' />
|
||||
|
||||
{Array.from({ length: LIST_SKELETON_COUNT }).map((_, i) => (
|
||||
<div key={i}>
|
||||
<div className='flex items-center gap-6 p-6'>
|
||||
<Skeleton className='hidden h-[14px] w-[120px] rounded-[4px] bg-[var(--surface-hover)] md:block' />
|
||||
<div className='flex min-w-0 flex-1 flex-col gap-1'>
|
||||
<Skeleton className='h-[18px] w-[70%] rounded-[4px] bg-[var(--surface-hover)]' />
|
||||
<Skeleton className='h-[14px] w-[90%] rounded-[4px] bg-[var(--surface-hover)]' />
|
||||
</div>
|
||||
<Skeleton className='hidden h-[80px] w-[140px] rounded-[5px] bg-[var(--surface-hover)] sm:block' />
|
||||
</div>
|
||||
<div className='h-px w-full bg-[var(--border)]' />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import { ChipLink } from '@sim/emcn'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import type { ContentMeta } from '@/lib/content/schema'
|
||||
import { Cta } from '@/app/(landing)/components/cta/cta'
|
||||
import { JsonLd } from '@/app/(landing)/components/json-ld'
|
||||
|
||||
const POSTS_PER_PAGE = 20
|
||||
const FEATURED_COUNT = 3
|
||||
|
||||
interface ContentIndexPageProps {
|
||||
/** Route base path, e.g. `/blog` or `/library`. */
|
||||
basePath: string
|
||||
heading: string
|
||||
subheading: string
|
||||
/** All published posts for the section, unfiltered and unsorted. */
|
||||
posts: ContentMeta[]
|
||||
page: number
|
||||
tag?: string
|
||||
collectionJsonLd: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared index/list layout for a content section (blog or library): section
|
||||
* header, featured-post row, remaining posts list, and pagination. Both
|
||||
* sections render this exact layout, parameterized by `basePath` and copy —
|
||||
* see `.claude/rules/landing-seo-geo.md` for the filtered/paginated noindex
|
||||
* policy this pairs with (`buildIndexMetadata` in `@/lib/content/seo`).
|
||||
*/
|
||||
export function ContentIndexPage({
|
||||
basePath,
|
||||
heading,
|
||||
subheading,
|
||||
posts,
|
||||
page,
|
||||
tag,
|
||||
collectionJsonLd,
|
||||
}: ContentIndexPageProps) {
|
||||
const filtered = tag ? posts.filter((p) => p.tags.includes(tag)) : posts
|
||||
const dateSorted = [...filtered].sort(
|
||||
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()
|
||||
)
|
||||
|
||||
// Featured posts render once, in the page-1 featured row, regardless of
|
||||
// where their date would otherwise place them — so they're carved out of
|
||||
// the paginated pool up front rather than re-sorted to the front of page 1
|
||||
// (which left them still reachable, and duplicated, on a later page).
|
||||
const explicitlyFeatured = dateSorted.filter((p) => p.featured).slice(0, FEATURED_COUNT)
|
||||
const featuredPosts =
|
||||
explicitlyFeatured.length > 0 ? explicitlyFeatured : dateSorted.slice(0, FEATURED_COUNT)
|
||||
const featuredSlugs = new Set(featuredPosts.map((p) => p.slug))
|
||||
const paginated = dateSorted.filter((p) => !featuredSlugs.has(p.slug))
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(paginated.length / POSTS_PER_PAGE))
|
||||
const start = (page - 1) * POSTS_PER_PAGE
|
||||
const featured = page === 1 ? featuredPosts : []
|
||||
const remaining = paginated.slice(start, start + POSTS_PER_PAGE)
|
||||
|
||||
const pageHref = (targetPage: number) =>
|
||||
`${basePath}?page=${targetPage}${tag ? `&tag=${encodeURIComponent(tag)}` : ''}`
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className='bg-[var(--bg)]'>
|
||||
<JsonLd data={collectionJsonLd} />
|
||||
|
||||
<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='flex flex-col gap-4 md:flex-row md:items-end md:justify-between'>
|
||||
<h1 className='text-balance text-[28px] text-[var(--text-primary)] leading-[100%] tracking-[-0.02em] lg:text-[40px]'>
|
||||
{heading}
|
||||
</h1>
|
||||
<p className='max-w-[540px] text-[var(--text-muted)] text-sm leading-[150%] tracking-[0.02em] lg:text-base'>
|
||||
{subheading}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mt-8 h-px w-full bg-[var(--border)]' />
|
||||
|
||||
<div className='mx-auto w-full max-w-[1460px] px-20 max-sm:px-5 max-lg:px-8'>
|
||||
<div className='border-[var(--border)] border-x'>
|
||||
{featured.length > 0 && (
|
||||
<>
|
||||
<nav aria-label='Featured posts' className='flex flex-col sm:flex-row'>
|
||||
{featured.map((p, index) => (
|
||||
<Link
|
||||
key={p.slug}
|
||||
href={`${basePath}/${p.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'
|
||||
>
|
||||
<div className='relative aspect-video w-full overflow-hidden rounded-[5px]'>
|
||||
<Image
|
||||
src={p.ogImage}
|
||||
alt={p.title}
|
||||
fill
|
||||
sizes='(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw'
|
||||
className='object-cover'
|
||||
priority={index < 3}
|
||||
fetchPriority={index === 0 ? 'high' : undefined}
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<span className='text-[var(--text-muted)] text-xs uppercase tracking-[0.1em]'>
|
||||
{new Date(p.date).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
year: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
<h3 className='text-[var(--text-primary)] text-lg leading-tight tracking-[-0.01em]'>
|
||||
{p.title}
|
||||
</h3>
|
||||
<p className='line-clamp-2 text-[var(--text-muted)] text-sm leading-[150%]'>
|
||||
{p.description}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className='h-px w-full bg-[var(--border)]' />
|
||||
</>
|
||||
)}
|
||||
|
||||
{remaining.map((p) => (
|
||||
<div key={p.slug}>
|
||||
<Link
|
||||
href={`${basePath}/${p.slug}`}
|
||||
className='group flex items-start gap-6 p-6 transition-colors hover:bg-[var(--surface-hover)] md:items-center'
|
||||
>
|
||||
<span className='hidden w-[120px] shrink-0 pt-1 text-[var(--text-muted)] text-xs uppercase tracking-[0.1em] md:block'>
|
||||
{new Date(p.date).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
|
||||
<div className='flex min-w-0 flex-1 flex-col gap-1'>
|
||||
<span className='text-[var(--text-muted)] text-xs uppercase tracking-[0.1em] md:hidden'>
|
||||
{new Date(p.date).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
<h3 className='text-[var(--text-primary)] text-base leading-tight tracking-[-0.01em] lg:text-lg'>
|
||||
{p.title}
|
||||
</h3>
|
||||
<p className='line-clamp-2 text-[var(--text-muted)] text-sm leading-[150%]'>
|
||||
{p.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='relative hidden h-[80px] w-[140px] shrink-0 overflow-hidden rounded-[5px] sm:block'>
|
||||
<Image
|
||||
src={p.ogImage}
|
||||
alt={p.title}
|
||||
fill
|
||||
sizes='140px'
|
||||
className='object-cover'
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
<div className='h-px w-full bg-[var(--border)]' />
|
||||
</div>
|
||||
))}
|
||||
|
||||
{totalPages > 1 && (
|
||||
<nav aria-label='Pagination' className='px-6 py-8'>
|
||||
<div className='flex items-center justify-center gap-3'>
|
||||
{page > 1 && (
|
||||
<ChipLink
|
||||
href={pageHref(page - 1)}
|
||||
rel='prev'
|
||||
className='border border-[var(--border-1)]'
|
||||
>
|
||||
Previous
|
||||
</ChipLink>
|
||||
)}
|
||||
<span className='text-[var(--text-muted)] text-sm'>
|
||||
Page {page} of {totalPages}
|
||||
</span>
|
||||
{page < totalPages && (
|
||||
<ChipLink
|
||||
href={pageHref(page + 1)}
|
||||
rel='next'
|
||||
className='border border-[var(--border-1)]'
|
||||
>
|
||||
Next
|
||||
</ChipLink>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='-mt-px h-px w-full bg-[var(--border)]' />
|
||||
</section>
|
||||
|
||||
<div className='mt-[120px] max-sm:mt-16 max-lg:mt-[88px]'>
|
||||
<Cta />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { ContentIndexLoading } from './content-index-loading'
|
||||
export { ContentIndexPage } from './content-index-page'
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Skeleton } from '@sim/emcn'
|
||||
|
||||
/** Shared loading skeleton for a content section's post-detail route. */
|
||||
export function ContentPostLoading() {
|
||||
return (
|
||||
<article className='w-full bg-[var(--bg)]'>
|
||||
<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'>
|
||||
<Skeleton className='h-[16px] w-[100px] rounded-[4px] bg-[var(--surface-hover)]' />
|
||||
</div>
|
||||
<div className='flex flex-col gap-8 md:flex-row md:gap-12'>
|
||||
<div className='w-full flex-shrink-0 md:w-[450px]'>
|
||||
<Skeleton className='aspect-[450/360] w-full rounded-[5px] bg-[var(--surface-hover)]' />
|
||||
</div>
|
||||
<div className='flex flex-1 flex-col justify-between'>
|
||||
<div>
|
||||
<Skeleton className='h-[44px] w-full rounded-[4px] bg-[var(--surface-hover)]' />
|
||||
<Skeleton className='mt-2 h-[44px] w-[80%] rounded-[4px] bg-[var(--surface-hover)]' />
|
||||
<Skeleton className='mt-4 h-[18px] w-full rounded-[4px] bg-[var(--surface-hover)]' />
|
||||
<Skeleton className='mt-2 h-[18px] w-[70%] rounded-[4px] bg-[var(--surface-hover)]' />
|
||||
</div>
|
||||
<div className='mt-6 flex items-center gap-6'>
|
||||
<Skeleton className='h-[12px] w-[100px] rounded-[4px] bg-[var(--surface-hover)]' />
|
||||
<div className='flex items-center gap-2'>
|
||||
<Skeleton className='size-[20px] rounded-full bg-[var(--surface-hover)]' />
|
||||
<Skeleton className='h-[12px] w-[80px] rounded-[4px] bg-[var(--surface-hover)]' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mt-8 h-px w-full bg-[var(--border)]' />
|
||||
|
||||
<div className='mx-auto w-full max-w-[1460px]'>
|
||||
<div className='mx-20 border-[var(--border)] border-x max-sm:mx-5 max-lg:mx-8'>
|
||||
<div className='mx-auto max-w-[900px] px-6 py-16'>
|
||||
<div className='space-y-4'>
|
||||
<Skeleton className='h-[16px] w-full rounded-[4px] bg-[var(--surface-hover)]' />
|
||||
<Skeleton className='h-[16px] w-[95%] rounded-[4px] bg-[var(--surface-hover)]' />
|
||||
<Skeleton className='h-[16px] w-[88%] rounded-[4px] bg-[var(--surface-hover)]' />
|
||||
<Skeleton className='h-[16px] w-full rounded-[4px] bg-[var(--surface-hover)]' />
|
||||
<Skeleton className='mt-6 h-[24px] w-[200px] rounded-[4px] bg-[var(--surface-hover)]' />
|
||||
<Skeleton className='h-[16px] w-full rounded-[4px] bg-[var(--surface-hover)]' />
|
||||
<Skeleton className='h-[16px] w-[92%] rounded-[4px] bg-[var(--surface-hover)]' />
|
||||
<Skeleton className='h-[16px] w-[85%] rounded-[4px] bg-[var(--surface-hover)]' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='-mt-px h-px w-full bg-[var(--border)]' />
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@sim/emcn'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { FAQ } from '@/lib/content/faq'
|
||||
import type { ContentMeta, ContentPost } from '@/lib/content/schema'
|
||||
import { BackLink } from '@/app/(landing)/components/back-link'
|
||||
import { JsonLd } from '@/app/(landing)/components/json-ld'
|
||||
import { ShareButton } from '@/app/(landing)/components/share-button'
|
||||
|
||||
interface ContentPostPageProps {
|
||||
/** Route base path, e.g. `/blog` or `/library`. */
|
||||
basePath: string
|
||||
/** Label for the back-to-index link, e.g. "Back to Blog". */
|
||||
backLabel: string
|
||||
post: ContentPost
|
||||
related: ContentMeta[]
|
||||
graphJsonLd: Record<string, unknown>
|
||||
shareUrl: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared post-detail layout for a content section (blog or library): header
|
||||
* with cover image/title/authors/share, MDX article body + FAQ, and related
|
||||
* posts. Both sections render this exact layout, parameterized by `basePath`.
|
||||
*/
|
||||
export function ContentPostPage({
|
||||
basePath,
|
||||
backLabel,
|
||||
post,
|
||||
related,
|
||||
graphJsonLd,
|
||||
shareUrl,
|
||||
}: ContentPostPageProps) {
|
||||
const Article = post.Content
|
||||
|
||||
return (
|
||||
<article className='w-full bg-[var(--bg)]' itemScope itemType='https://schema.org/TechArticle'>
|
||||
<JsonLd data={graphJsonLd} />
|
||||
<header 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={basePath} label={backLabel} />
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-8 md:flex-row md:gap-12'>
|
||||
<div className='w-full flex-shrink-0 md:w-[450px]'>
|
||||
<div className='relative w-full overflow-hidden rounded-[5px]'>
|
||||
<Image
|
||||
src={post.ogImage}
|
||||
alt={post.title}
|
||||
width={450}
|
||||
height={360}
|
||||
className='h-auto w-full'
|
||||
sizes='(max-width: 768px) 100vw, 450px'
|
||||
priority
|
||||
fetchPriority='high'
|
||||
itemProp='image'
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-1 flex-col justify-between'>
|
||||
<div>
|
||||
<h1
|
||||
className='text-balance text-[28px] text-[var(--text-primary)] leading-[110%] tracking-[-0.02em] sm:text-[36px] md:text-[44px] lg:text-[52px]'
|
||||
itemProp='headline'
|
||||
>
|
||||
{post.title}
|
||||
</h1>
|
||||
<p className='mt-4 text-[var(--text-body)] text-base leading-[150%] tracking-[0.02em] sm:text-lg'>
|
||||
{post.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className='mt-6 flex items-center gap-6'>
|
||||
<time
|
||||
className='text-[var(--text-muted)] text-xs uppercase tracking-[0.1em]'
|
||||
dateTime={post.date}
|
||||
itemProp='datePublished'
|
||||
>
|
||||
{new Date(post.date).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</time>
|
||||
<meta itemProp='dateModified' content={post.updated ?? post.date} />
|
||||
<div className='flex items-center gap-3'>
|
||||
{(post.authors || [post.author]).map((a) => (
|
||||
<div key={a?.name} className='flex items-center gap-2'>
|
||||
{a?.avatarUrl ? (
|
||||
<Avatar className='size-5'>
|
||||
<AvatarImage src={a.avatarUrl} alt={a.name} />
|
||||
<AvatarFallback>{a.name.slice(0, 2)}</AvatarFallback>
|
||||
</Avatar>
|
||||
) : null}
|
||||
<Link
|
||||
href={a?.url || '#'}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer author'
|
||||
className='text-[var(--text-muted)] text-xs uppercase tracking-[0.1em] hover:text-[var(--text-primary)]'
|
||||
itemProp='author'
|
||||
itemScope
|
||||
itemType='https://schema.org/Person'
|
||||
>
|
||||
<span itemProp='name'>{a?.name}</span>
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className='ml-auto'>
|
||||
<ShareButton url={shareUrl} title={post.title} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className='mt-8 h-px w-full bg-[var(--border)]' />
|
||||
|
||||
<div className='mx-auto w-full max-w-[1460px] px-20 max-sm:px-5 max-lg:px-8'>
|
||||
<div className='border-[var(--border)] border-x'>
|
||||
<div className='mx-auto max-w-[900px] px-6 py-16' itemProp='articleBody'>
|
||||
<div className='prose prose-lg max-w-none prose-blockquote:border-[var(--border-1)] prose-hr:border-[var(--border)] prose-headings:font-season prose-a:text-[var(--text-primary)] prose-blockquote:text-[var(--text-muted)] prose-code:text-[var(--text-primary)] prose-headings:text-[var(--text-primary)] prose-li:text-[var(--text-body)] prose-p:text-[var(--text-body)] prose-strong:text-[var(--text-primary)] prose-headings:tracking-[-0.02em]'>
|
||||
<Article />
|
||||
{post.faq && post.faq.length > 0 ? <FAQ items={post.faq} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{related.length > 0 && (
|
||||
<>
|
||||
<div className='h-px w-full bg-[var(--border)]' />
|
||||
<nav aria-label='Related posts' className='flex flex-col sm:flex-row'>
|
||||
{related.map((p) => (
|
||||
<Link
|
||||
key={p.slug}
|
||||
href={`${basePath}/${p.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'
|
||||
>
|
||||
<div className='relative aspect-video w-full overflow-hidden rounded-[5px]'>
|
||||
<Image
|
||||
src={p.ogImage}
|
||||
alt={p.title}
|
||||
fill
|
||||
sizes='(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw'
|
||||
className='object-cover'
|
||||
loading='lazy'
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<span className='text-[var(--text-muted)] text-xs uppercase tracking-[0.1em]'>
|
||||
{new Date(p.date).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
year: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
<h3 className='text-[var(--text-primary)] text-lg leading-tight tracking-[-0.01em]'>
|
||||
{p.title}
|
||||
</h3>
|
||||
<p className='line-clamp-2 text-[var(--text-muted)] text-sm leading-[150%]'>
|
||||
{p.description}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='-mt-px h-px w-full bg-[var(--border)]' />
|
||||
|
||||
<meta itemProp='publisher' content='Sim' />
|
||||
<meta itemProp='inLanguage' content='en-US' />
|
||||
<meta itemProp='keywords' content={post.tags.join(', ')} />
|
||||
{post.wordCount && <meta itemProp='wordCount' content={String(post.wordCount)} />}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { ContentPostLoading } from './content-post-loading'
|
||||
export { ContentPostPage } from './content-post-page'
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Skeleton } from '@sim/emcn'
|
||||
|
||||
const TAG_SKELETON_COUNT = 12
|
||||
|
||||
/** Shared loading skeleton for a content section's tags route. */
|
||||
export function ContentTagsLoading() {
|
||||
return (
|
||||
<main className='mx-auto max-w-[900px] px-6 py-10 sm:px-8 md:px-12'>
|
||||
<Skeleton className='mb-6 h-[32px] w-[200px] rounded-[4px] bg-[var(--surface-hover)]' />
|
||||
<div className='flex flex-wrap gap-3'>
|
||||
{Array.from({ length: TAG_SKELETON_COUNT }).map((_, i) => (
|
||||
<Skeleton
|
||||
key={i}
|
||||
className='h-[30px] rounded-full bg-[var(--surface-hover)]'
|
||||
style={{ width: `${60 + (i % 4) * 24}px` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { ChipLink } from '@sim/emcn'
|
||||
import type { TagWithCount } from '@/lib/content/schema'
|
||||
import { JsonLd } from '@/app/(landing)/components/json-ld'
|
||||
|
||||
interface ContentTagsPageProps {
|
||||
/** Route base path, e.g. `/blog` or `/library`. */
|
||||
basePath: string
|
||||
tags: TagWithCount[]
|
||||
breadcrumbJsonLd: Record<string, unknown>
|
||||
}
|
||||
|
||||
/** Shared "browse by tag" layout for a content section. */
|
||||
export function ContentTagsPage({ basePath, tags, breadcrumbJsonLd }: ContentTagsPageProps) {
|
||||
return (
|
||||
<section className='mx-auto max-w-[900px] px-6 py-10 sm:px-8 md:px-12'>
|
||||
<JsonLd data={breadcrumbJsonLd} />
|
||||
<h1 className='mb-6 text-[32px] text-[var(--text-primary)] leading-tight'>Browse by tag</h1>
|
||||
<div className='flex flex-wrap gap-3'>
|
||||
<ChipLink href={basePath} className='border border-[var(--border-1)]'>
|
||||
All
|
||||
</ChipLink>
|
||||
{tags.map((t) => (
|
||||
<ChipLink
|
||||
key={t.tag}
|
||||
href={`${basePath}?tag=${encodeURIComponent(t.tag)}`}
|
||||
className='border border-[var(--border-1)]'
|
||||
>
|
||||
{t.tag} ({t.count})
|
||||
</ChipLink>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { ContentTagsLoading } from './content-tags-loading'
|
||||
export { ContentTagsPage } from './content-tags-page'
|
||||
@@ -0,0 +1,41 @@
|
||||
import { ChipLink } from '@sim/emcn'
|
||||
import { DEMO_HREF, SIGNUP_HREF } from '@/app/(landing)/constants'
|
||||
|
||||
/**
|
||||
* Landing pre-footer CTA - the page's final conversion band. A tall, centered
|
||||
* closing band with a large headline over two pill actions - a primary
|
||||
* "Get started" routing to sign-up and an outline "Contact sales" routing to
|
||||
* the demo-booking page.
|
||||
*
|
||||
* The band carries no vertical padding of its own: its spacious closing moment
|
||||
* comes from the uniform inter-section `gap` (owned by the `<main>` flex in
|
||||
* `landing.tsx`) above it and the `Footer`'s top margin below it. The headline
|
||||
* mirrors the hero `<h1>` exactly (48px / `leading-[1.1]` and the same responsive
|
||||
* ramp), so the page opens and closes on the same display size. Horizontal
|
||||
* padding (`px-20`) matches every section above, and the section is capped and
|
||||
* centered at the shared `max-w-[1460px]`.
|
||||
*/
|
||||
export function Cta() {
|
||||
return (
|
||||
<section
|
||||
id='cta'
|
||||
aria-labelledby='cta-heading'
|
||||
className='mx-auto flex w-full max-w-[1460px] flex-col items-center gap-[22px] px-20 text-center max-sm:px-5 max-lg:px-8'
|
||||
>
|
||||
<h2
|
||||
id='cta-heading'
|
||||
className='max-w-[860px] text-balance text-[48px] text-[var(--text-primary)] leading-[1.1] max-sm:text-[32px] max-xl:text-[40px]'
|
||||
>
|
||||
Build your first agent today.
|
||||
</h2>
|
||||
<div className='flex items-center gap-1'>
|
||||
<ChipLink variant='primary' href={SIGNUP_HREF}>
|
||||
Get started
|
||||
</ChipLink>
|
||||
<ChipLink href={DEMO_HREF} className='border border-[var(--border-1)]'>
|
||||
Contact sales
|
||||
</ChipLink>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { ThinkingLoader } from '@/components/ui'
|
||||
import { WorkflowShowcase } from '@/app/(landing)/components/features/components/build-callout/components/workflow-showcase'
|
||||
|
||||
/**
|
||||
* The Build beat's callout - the finished workflow canvas centered on the
|
||||
* card's solid grey stage, with the goo cycle loader phasing through its
|
||||
* world-state phrases in the bottom-left corner. The Mothership chat loop
|
||||
* (`components/build-chat-animation`) is parked here unwired, kept for reuse
|
||||
* on another surface.
|
||||
*/
|
||||
export function BuildCallout() {
|
||||
return (
|
||||
<div aria-hidden='true' className='pointer-events-none absolute inset-0'>
|
||||
<WorkflowShowcase />
|
||||
<div className='absolute bottom-8 left-8 max-sm:bottom-4 max-sm:left-4'>
|
||||
<ThinkingLoader size={22} phase className='text-[var(--text-body)]' />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+361
@@ -0,0 +1,361 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { cn } from '@sim/emcn'
|
||||
import { Blimp } from '@sim/emcn/icons'
|
||||
import { ArrowUp, Mic, Paperclip, Plus, Slash } from 'lucide-react'
|
||||
import { ThinkingLoader } from '@/components/ui'
|
||||
|
||||
const PROMPT = 'Build a workflow to schedule and publish posts to my X account.'
|
||||
const REPLY =
|
||||
'On it — building a workflow with a schedule trigger, a drafting agent, and an X publish step.'
|
||||
const REPLY_WORDS = REPLY.split(' ')
|
||||
|
||||
const TYPE_START_MS = 900
|
||||
const TYPE_CHAR_MS = 40
|
||||
const SEND_MS = 3800
|
||||
const THINKING_DONE_MS = 6000
|
||||
const REPLY_START_DELAY_MS = 420
|
||||
const REPLY_WORD_MS = 95
|
||||
const MERGE_MS = 9000
|
||||
const LOOP_MS = 10400
|
||||
|
||||
/** Resting gap between the chat surface and the composer once the split settles. */
|
||||
const SPLIT_GAP_PX = 10
|
||||
/** Goo blur decay window — the liquid neck between the boxes thins and snaps over this span. */
|
||||
const GOO_DECAY_MS = 850
|
||||
/** Peak feGaussianBlur stdDeviation at the send beat. */
|
||||
const GOO_MAX_BLUR = 16
|
||||
/** Blur decay tick; stepped so the blur eases out on a (1-t)^2 curve. */
|
||||
const GOO_STEP_MS = 40
|
||||
|
||||
/**
|
||||
* Chat-surface transition set while opening: geometry travels with the goo
|
||||
* split (500ms), the surface itself fades in fast (250ms), and the hairline
|
||||
* ring + elevation crisp in only after the liquid split settles (300ms fade,
|
||||
* 550ms delay).
|
||||
*/
|
||||
const CHAT_SURFACE_TRANSITION_OPEN =
|
||||
'[transition:height_500ms_ease-out,transform_500ms_ease-out,margin-bottom_500ms_ease-out,opacity_250ms_ease-out,box-shadow_300ms_ease-out_550ms]'
|
||||
|
||||
/**
|
||||
* Chat-surface transition set while merging back: same geometry travel, but
|
||||
* the ring + elevation drop immediately and the surface fades out fast so the
|
||||
* goo ghost carries the collapse into the composer.
|
||||
*/
|
||||
const CHAT_SURFACE_TRANSITION_MERGE =
|
||||
'[transition:height_500ms_ease-out,transform_500ms_ease-out,margin-bottom_500ms_ease-out,opacity_250ms_ease-out,box-shadow_200ms_ease-out]'
|
||||
|
||||
type BuildChatPhase =
|
||||
| 'idle'
|
||||
| 'typing'
|
||||
| 'thinking'
|
||||
| 'replyPreparing'
|
||||
| 'replying'
|
||||
| 'complete'
|
||||
| 'merging'
|
||||
|
||||
/**
|
||||
* Decorative Build-card Mothership loop. It mirrors the studio choreography
|
||||
* with local timers and CSS transitions so the landing page avoids motion
|
||||
* dependencies while preserving the product-window feel.
|
||||
*
|
||||
* The composer is a permanent fixture; the send beat gooey-morphs the chat
|
||||
* surface out of it, and the loop's final beat gooey-merges it back down in.
|
||||
* An SVG goo filter (state-driven blur → alpha-contrast matrix → atop
|
||||
* composite) is applied to an aria-hidden ghost layer of two solid white
|
||||
* rects mirroring the real boxes (both measured with a ResizeObserver) —
|
||||
* never to the real UI, which stays crisp above it. The filtered silhouette
|
||||
* renders a liquid meniscus that necks and snaps as the boxes separate (and
|
||||
* re-welds as they merge); each morph's blur decays on a (1-t)^2 curve over
|
||||
* 850ms. The chat surface's ring + shadow crisp in only after the split and
|
||||
* drop instantly at the merge, so the ghost carries the collapse.
|
||||
*/
|
||||
export function BuildChatAnimation() {
|
||||
const [phase, setPhase] = useState<BuildChatPhase>('idle')
|
||||
const [typedChars, setTypedChars] = useState(0)
|
||||
const [revealedWords, setRevealedWords] = useState(0)
|
||||
const [reducedMotion, setReducedMotion] = useState(false)
|
||||
const [morphing, setMorphing] = useState(false)
|
||||
const [gooBlur, setGooBlur] = useState(0)
|
||||
const [chatContentH, setChatContentH] = useState(0)
|
||||
const [composerH, setComposerH] = useState(94)
|
||||
const chatContentRef = useRef<HTMLDivElement>(null)
|
||||
const composerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const content = chatContentRef.current
|
||||
const composer = composerRef.current
|
||||
if (!content || !composer) return
|
||||
const measure = () => {
|
||||
setChatContentH(content.offsetHeight)
|
||||
setComposerH(composer.offsetHeight)
|
||||
}
|
||||
measure()
|
||||
const observer = new ResizeObserver(measure)
|
||||
observer.observe(content)
|
||||
observer.observe(composer)
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!morphing) return
|
||||
let progress = 0
|
||||
const interval = setInterval(() => {
|
||||
progress += GOO_STEP_MS / GOO_DECAY_MS
|
||||
if (progress >= 1) {
|
||||
setGooBlur(0)
|
||||
setMorphing(false)
|
||||
clearInterval(interval)
|
||||
return
|
||||
}
|
||||
setGooBlur(GOO_MAX_BLUR * (1 - progress) ** 2)
|
||||
}, GOO_STEP_MS)
|
||||
return () => clearInterval(interval)
|
||||
}, [morphing])
|
||||
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia('(prefers-reduced-motion: reduce)')
|
||||
const timeouts: ReturnType<typeof setTimeout>[] = []
|
||||
const intervals: ReturnType<typeof setInterval>[] = []
|
||||
|
||||
const clearScheduled = () => {
|
||||
for (const timeout of timeouts) clearTimeout(timeout)
|
||||
for (const interval of intervals) clearInterval(interval)
|
||||
timeouts.length = 0
|
||||
intervals.length = 0
|
||||
}
|
||||
|
||||
const showFinished = () => {
|
||||
setReducedMotion(true)
|
||||
setPhase('complete')
|
||||
setTypedChars(0)
|
||||
setRevealedWords(REPLY_WORDS.length)
|
||||
setMorphing(false)
|
||||
setGooBlur(0)
|
||||
}
|
||||
|
||||
const startTyping = () => {
|
||||
setPhase('typing')
|
||||
const startedAt = performance.now()
|
||||
const interval = setInterval(() => {
|
||||
const elapsed = performance.now() - startedAt
|
||||
const nextChars = Math.min(Math.floor(elapsed / TYPE_CHAR_MS) + 1, PROMPT.length)
|
||||
setTypedChars(nextChars)
|
||||
if (nextChars >= PROMPT.length) clearInterval(interval)
|
||||
}, TYPE_CHAR_MS)
|
||||
intervals.push(interval)
|
||||
}
|
||||
|
||||
const startReply = () => {
|
||||
setPhase('replying')
|
||||
const startedAt = performance.now()
|
||||
const interval = setInterval(() => {
|
||||
const elapsed = performance.now() - startedAt
|
||||
const nextWords = Math.min(Math.floor(elapsed / REPLY_WORD_MS) + 1, REPLY_WORDS.length)
|
||||
setRevealedWords(nextWords)
|
||||
if (nextWords >= REPLY_WORDS.length) {
|
||||
clearInterval(interval)
|
||||
setPhase('complete')
|
||||
}
|
||||
}, REPLY_WORD_MS)
|
||||
intervals.push(interval)
|
||||
}
|
||||
|
||||
const runLoop = () => {
|
||||
clearScheduled()
|
||||
setReducedMotion(false)
|
||||
setPhase('idle')
|
||||
setTypedChars(0)
|
||||
setRevealedWords(0)
|
||||
setMorphing(false)
|
||||
setGooBlur(0)
|
||||
|
||||
timeouts.push(setTimeout(startTyping, TYPE_START_MS))
|
||||
timeouts.push(
|
||||
setTimeout(() => {
|
||||
setPhase('thinking')
|
||||
setTypedChars(0)
|
||||
setMorphing(true)
|
||||
setGooBlur(GOO_MAX_BLUR)
|
||||
}, SEND_MS)
|
||||
)
|
||||
timeouts.push(setTimeout(() => setPhase('replyPreparing'), THINKING_DONE_MS))
|
||||
timeouts.push(setTimeout(startReply, THINKING_DONE_MS + REPLY_START_DELAY_MS))
|
||||
timeouts.push(
|
||||
setTimeout(() => {
|
||||
setPhase('merging')
|
||||
setMorphing(true)
|
||||
setGooBlur(GOO_MAX_BLUR)
|
||||
}, MERGE_MS)
|
||||
)
|
||||
timeouts.push(setTimeout(runLoop, LOOP_MS))
|
||||
}
|
||||
|
||||
const syncMotionPreference = () => {
|
||||
clearScheduled()
|
||||
if (media.matches) {
|
||||
showFinished()
|
||||
return
|
||||
}
|
||||
runLoop()
|
||||
}
|
||||
|
||||
syncMotionPreference()
|
||||
media.addEventListener('change', syncMotionPreference)
|
||||
return () => {
|
||||
media.removeEventListener('change', syncMotionPreference)
|
||||
clearScheduled()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const composerText = phase === 'typing' ? PROMPT.slice(0, typedChars) : ''
|
||||
const chatOpen = reducedMotion || !['idle', 'typing', 'merging'].includes(phase)
|
||||
const thinkingPhraseVisible = phase === 'thinking'
|
||||
const replyVisible =
|
||||
reducedMotion || phase === 'replyPreparing' || phase === 'replying' || phase === 'complete'
|
||||
const replyComplete = reducedMotion || phase === 'complete'
|
||||
const replyText = REPLY_WORDS.slice(0, reducedMotion ? REPLY_WORDS.length : revealedWords).join(
|
||||
' '
|
||||
)
|
||||
|
||||
return (
|
||||
<div className='absolute inset-0 flex items-end justify-start p-10 max-sm:px-3 max-sm:py-0 max-lg:items-center max-lg:justify-center max-lg:px-8 max-lg:py-8'>
|
||||
<svg width='0' height='0' aria-hidden='true' className='absolute'>
|
||||
<defs>
|
||||
<filter id='build-callout-goo'>
|
||||
<feGaussianBlur in='SourceGraphic' stdDeviation={gooBlur} result='blur' />
|
||||
<feColorMatrix
|
||||
in='blur'
|
||||
type='matrix'
|
||||
values='1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 19 -8'
|
||||
result='goo'
|
||||
/>
|
||||
<feComposite in='SourceGraphic' in2='goo' operator='atop' />
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
<div className='relative h-[310px] w-full max-w-[560px] max-sm:h-[270px] max-sm:max-w-[430px]'>
|
||||
{!reducedMotion && (
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='pointer-events-none absolute inset-0 z-0 flex flex-col justify-end [filter:url(#build-callout-goo)_drop-shadow(0_1px_3px_rgba(28,40,64,0.10))]'
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'w-full rounded-2xl bg-[var(--white)] transition-[height,transform,margin-bottom] duration-500 ease-out motion-reduce:transition-none',
|
||||
chatOpen ? 'translate-y-0' : 'translate-y-5'
|
||||
)}
|
||||
style={{
|
||||
height: chatOpen ? chatContentH : 0,
|
||||
marginBottom: chatOpen ? SPLIT_GAP_PX : 0,
|
||||
}}
|
||||
/>
|
||||
<div className='w-full rounded-2xl bg-[var(--white)]' style={{ height: composerH }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='relative z-10 flex h-full flex-col justify-end'>
|
||||
<section
|
||||
className={cn(
|
||||
'w-full overflow-hidden rounded-2xl bg-[var(--white)]',
|
||||
!reducedMotion &&
|
||||
(chatOpen ? CHAT_SURFACE_TRANSITION_OPEN : CHAT_SURFACE_TRANSITION_MERGE),
|
||||
chatOpen
|
||||
? 'translate-y-0 opacity-100 shadow-[0_24px_80px_color-mix(in_srgb,var(--text-primary)_14%,transparent),0_0_0_1px_var(--border-1)]'
|
||||
: 'translate-y-5 opacity-0 shadow-[0_24px_80px_transparent,0_0_0_1px_transparent]'
|
||||
)}
|
||||
style={{
|
||||
height: chatOpen ? chatContentH : 0,
|
||||
marginBottom: chatOpen ? SPLIT_GAP_PX : 0,
|
||||
}}
|
||||
>
|
||||
<div ref={chatContentRef} className='flex flex-col gap-4 p-4 max-sm:gap-2.5 max-sm:p-3'>
|
||||
<div className='ml-auto max-w-[78%] rounded-lg bg-[var(--surface-5)] px-3 py-2 text-[15px] text-[var(--text-primary)] leading-[1.45] max-sm:max-w-[86%] max-sm:text-[12px] max-sm:leading-[1.35]'>
|
||||
{PROMPT}
|
||||
</div>
|
||||
|
||||
<div className='relative min-h-[34px]'>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 flex items-center transition-opacity duration-300 ease-out motion-reduce:transition-none',
|
||||
thinkingPhraseVisible ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
>
|
||||
<ThinkingLoader
|
||||
size={20}
|
||||
startVariant='corners'
|
||||
phase
|
||||
labelRatio={0.66}
|
||||
className='text-[var(--text-body)]'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-start gap-2.5 text-[15px] text-[var(--text-primary)] leading-[1.5] transition-opacity duration-300 ease-out motion-reduce:transition-none max-sm:text-[12px] max-sm:leading-[1.4]',
|
||||
replyVisible ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
>
|
||||
<span className='relative mt-0.5 size-[20px] shrink-0'>
|
||||
<span
|
||||
className={cn(
|
||||
'absolute inset-0 flex items-center justify-center transition-opacity duration-200 ease-out motion-reduce:transition-none',
|
||||
replyComplete ? 'opacity-0' : 'opacity-100'
|
||||
)}
|
||||
>
|
||||
<ThinkingLoader size={20} startVariant='corners' />
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'absolute inset-0 flex items-center justify-center transition-opacity duration-200 ease-out motion-reduce:transition-none',
|
||||
replyComplete ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
>
|
||||
<Blimp className='size-[18px] text-[var(--text-icon)]' />
|
||||
</span>
|
||||
</span>
|
||||
<p className='flex-1'>{replyText}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div
|
||||
ref={composerRef}
|
||||
className='h-[94px] w-full rounded-2xl border border-[var(--border-1)] bg-[var(--white)] px-2.5 py-2 shadow-[0_18px_60px_color-mix(in_srgb,var(--text-primary)_12%,transparent)]'
|
||||
>
|
||||
<p
|
||||
className={cn(
|
||||
'min-h-[42px] px-1.5 pt-1 text-[15px] leading-[1.35] max-sm:text-[13px]',
|
||||
composerText ? 'text-[var(--text-primary)]' : 'text-[var(--text-muted)]'
|
||||
)}
|
||||
>
|
||||
{composerText || 'Send message to Sim'}
|
||||
</p>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<span className='flex size-[28px] items-center justify-center rounded-full'>
|
||||
<Plus className='size-[16px] text-[var(--text-icon)]' />
|
||||
</span>
|
||||
<span className='flex size-[28px] items-center justify-center rounded-full'>
|
||||
<Paperclip className='size-[16px] text-[var(--text-icon)]' />
|
||||
</span>
|
||||
<span className='flex size-[28px] items-center justify-center rounded-full'>
|
||||
<Slash className='size-[16px] text-[var(--text-icon)]' />
|
||||
</span>
|
||||
<span className='ml-auto flex items-center gap-1.5'>
|
||||
<span className='flex size-[28px] items-center justify-center rounded-full'>
|
||||
<Mic className='size-[16px] text-[var(--text-icon)]' />
|
||||
</span>
|
||||
<span className='flex size-[28px] items-center justify-center rounded-full bg-[#808080]'>
|
||||
<ArrowUp className='size-[16px] text-[var(--surface-1)]' />
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { BuildChatAnimation } from './build-chat-animation'
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { WorkflowShowcase } from './workflow-showcase'
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
import {
|
||||
AgentIcon,
|
||||
AnthropicIcon,
|
||||
GmailIcon,
|
||||
LinearIcon,
|
||||
SlackIcon,
|
||||
TableIcon,
|
||||
} from '@/components/icons'
|
||||
import type { BlockDef } from '@/app/(landing)/components/hero/components/hero-visual/workflow-data'
|
||||
|
||||
/**
|
||||
* Design-space geometry for the Build card's workflow showcase - a
|
||||
* support-triage pipeline flowing LEFT TO RIGHT: two triggers (a Gmail inbox
|
||||
* and a Slack channel) converge on a triage agent, which fans out to four
|
||||
* destinations - Linear, an eng escalation, a customer reply, and Tables.
|
||||
* Rows carry concrete values (not placeholders) so the canvas reads like a
|
||||
* configured workflow.
|
||||
*
|
||||
* Column geometry: three columns at x=0 / x=370 / x=740 on a 990-wide canvas.
|
||||
* The triggers straddle the agent's row; the four outputs stack down the
|
||||
* right column, spanning y=20-666 on a 686-tall canvas. The bounding box
|
||||
* exactly hugs the blocks, so the stage's flex-centering shows the whole
|
||||
* flow clean and uncut. Edges run from right handles to left handles at
|
||||
* `HANDLE_Y_OFFSET`, matching the real editor's horizontal layout.
|
||||
*
|
||||
* Icon tiles follow the platform rule: grey text-ramp tiles for first-party
|
||||
* blocks, brand colors only for REAL third-party marks, and white bordered
|
||||
* tiles for marks that carry their own colors.
|
||||
*/
|
||||
export const SHOWCASE_BLOCKS: BlockDef[] = [
|
||||
{
|
||||
id: 'gmail-trigger',
|
||||
name: 'New support email',
|
||||
icon: GmailIcon,
|
||||
bgColor: '#FFFFFF',
|
||||
tileBorder: true,
|
||||
isTrigger: true,
|
||||
rows: [
|
||||
{ title: 'From', value: 'Customers' },
|
||||
{ title: 'Filter', value: 'Unread' },
|
||||
],
|
||||
x: 0,
|
||||
y: 150,
|
||||
},
|
||||
{
|
||||
id: 'slack-trigger',
|
||||
name: 'New #support post',
|
||||
icon: SlackIcon,
|
||||
bgColor: '#611F69',
|
||||
isTrigger: true,
|
||||
rows: [
|
||||
{ title: 'Channel', value: '#support' },
|
||||
{ title: 'Event', value: 'New message' },
|
||||
],
|
||||
x: 0,
|
||||
y: 424,
|
||||
},
|
||||
{
|
||||
id: 'triage',
|
||||
name: 'Triage request',
|
||||
icon: AgentIcon,
|
||||
bgColor: 'var(--text-primary)',
|
||||
rows: [
|
||||
{ title: 'Model', value: 'Claude', valueIcon: AnthropicIcon },
|
||||
{ title: 'Knowledge', value: 'Help center' },
|
||||
{ title: 'Instructions', value: 'Triage + draft' },
|
||||
],
|
||||
x: 370,
|
||||
y: 277,
|
||||
},
|
||||
{
|
||||
id: 'linear',
|
||||
name: 'File bug',
|
||||
icon: LinearIcon,
|
||||
bgColor: '#FFFFFF',
|
||||
tileBorder: true,
|
||||
isTerminal: true,
|
||||
rows: [
|
||||
{ title: 'Team', value: 'Platform' },
|
||||
{ title: 'Priority', value: 'From triage' },
|
||||
],
|
||||
x: 740,
|
||||
y: 20,
|
||||
},
|
||||
{
|
||||
id: 'escalate',
|
||||
name: 'Escalate to eng',
|
||||
icon: SlackIcon,
|
||||
bgColor: '#611F69',
|
||||
isTerminal: true,
|
||||
rows: [
|
||||
{ title: 'Channel', value: '#eng-oncall' },
|
||||
{ title: 'When', value: 'Urgent' },
|
||||
],
|
||||
x: 740,
|
||||
y: 200,
|
||||
},
|
||||
{
|
||||
id: 'reply',
|
||||
name: 'Send reply',
|
||||
icon: GmailIcon,
|
||||
bgColor: '#FFFFFF',
|
||||
tileBorder: true,
|
||||
isTerminal: true,
|
||||
rows: [
|
||||
{ title: 'To', value: 'Customer' },
|
||||
{ title: 'Tone', value: 'Friendly' },
|
||||
],
|
||||
x: 740,
|
||||
y: 380,
|
||||
},
|
||||
{
|
||||
id: 'tables',
|
||||
name: 'Log ticket',
|
||||
icon: TableIcon,
|
||||
bgColor: 'var(--text-body)',
|
||||
isTerminal: true,
|
||||
rows: [
|
||||
{ title: 'Table', value: 'Tickets' },
|
||||
{ title: 'Operation', value: 'Insert' },
|
||||
],
|
||||
x: 740,
|
||||
y: 560,
|
||||
},
|
||||
]
|
||||
|
||||
/** Source → target pairs; every edge is drawn (the flow renders finished). */
|
||||
export const SHOWCASE_EDGES: ReadonlyArray<readonly [string, string]> = [
|
||||
['gmail-trigger', 'triage'],
|
||||
['slack-trigger', 'triage'],
|
||||
['triage', 'linear'],
|
||||
['triage', 'escalate'],
|
||||
['triage', 'reply'],
|
||||
['triage', 'tables'],
|
||||
]
|
||||
|
||||
/** Design-space bounding box of the layout above. */
|
||||
export const SHOWCASE_CANVAS = { width: 990, height: 686 } as const
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
SHOWCASE_BLOCKS,
|
||||
SHOWCASE_CANVAS,
|
||||
SHOWCASE_EDGES,
|
||||
} from '@/app/(landing)/components/features/components/build-callout/components/workflow-showcase/showcase-data'
|
||||
import { WorkflowBlock } from '@/app/(landing)/components/hero/components/hero-visual/workflow-block'
|
||||
import {
|
||||
BLOCK_WIDTH,
|
||||
HANDLE_Y_OFFSET,
|
||||
smoothStep,
|
||||
} from '@/app/(landing)/components/hero/components/hero-visual/workflow-data'
|
||||
|
||||
/**
|
||||
* The Build card's centerpiece - the left-to-right support-triage pipeline
|
||||
* from {@link SHOWCASE_BLOCKS} / {@link SHOWCASE_EDGES} rendered raw on the
|
||||
* card's solid grey stage, every block on canvas and every edge drawn. Static
|
||||
* and server-rendered; blocks reuse the hero-visual's {@link WorkflowBlock}
|
||||
* (the horizontal-flow card with left/right handle nubs).
|
||||
*
|
||||
* The 990x686 design canvas renders at fixed per-breakpoint scales - 0.62
|
||||
* (1280+), 0.38 (below `xl`, where the side-by-side card leaves the
|
||||
* aspect-locked stage narrow), 0.45 (below `lg`, stacked full-width), 0.29
|
||||
* (below `sm`) - each chosen as the largest zoom that keeps the WHOLE flow
|
||||
* inside that tier's media stage, so the graph sits centered and uncut. Each
|
||||
* sizer tier is the canvas dimensions times that tier's scale.
|
||||
*/
|
||||
export function WorkflowShowcase() {
|
||||
const byId = new Map(SHOWCASE_BLOCKS.map((block) => [block.id, block]))
|
||||
return (
|
||||
<div className='absolute inset-0 flex items-center justify-center'>
|
||||
<div className='relative h-[425px] w-[614px] shrink-0 max-sm:h-[199px] max-sm:w-[287px] max-lg:h-[309px] max-lg:w-[446px] max-xl:h-[261px] max-xl:w-[376px]'>
|
||||
<div className='absolute top-0 left-0 h-[686px] w-[990px] origin-top-left [transform:scale(0.62)] max-sm:[transform:scale(0.29)] max-lg:[transform:scale(0.45)] max-xl:[transform:scale(0.38)]'>
|
||||
<svg
|
||||
className='absolute inset-0 overflow-visible'
|
||||
width={SHOWCASE_CANVAS.width}
|
||||
height={SHOWCASE_CANVAS.height}
|
||||
viewBox={`0 0 ${SHOWCASE_CANVAS.width} ${SHOWCASE_CANVAS.height}`}
|
||||
fill='none'
|
||||
>
|
||||
{SHOWCASE_EDGES.map(([from, to]) => {
|
||||
const source = byId.get(from)
|
||||
const target = byId.get(to)
|
||||
if (!source || !target) return null
|
||||
return (
|
||||
<path
|
||||
key={`${from}-${to}`}
|
||||
d={smoothStep(
|
||||
source.x + BLOCK_WIDTH,
|
||||
source.y + HANDLE_Y_OFFSET,
|
||||
target.x,
|
||||
target.y + HANDLE_Y_OFFSET
|
||||
)}
|
||||
stroke='var(--workflow-edge)'
|
||||
strokeWidth={2}
|
||||
strokeLinecap='round'
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
{SHOWCASE_BLOCKS.map((block) => (
|
||||
<div
|
||||
key={block.id}
|
||||
className='absolute'
|
||||
style={{ left: block.x, top: block.y, width: BLOCK_WIDTH }}
|
||||
>
|
||||
<WorkflowBlock block={block} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { BuildCallout } from './build-callout'
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '@sim/emcn'
|
||||
|
||||
const CALLOUT_FADE =
|
||||
'[-webkit-mask-image:linear-gradient(to_bottom,#000_72%,transparent)] [mask-image:linear-gradient(to_bottom,#000_72%,transparent)]'
|
||||
|
||||
interface CalloutFrameProps {
|
||||
/** Width/layout for the panel (e.g. `w-[340px]`). */
|
||||
className?: string
|
||||
/** Sizing for the inner body (e.g. `h-[300px]`). */
|
||||
bodyClassName?: string
|
||||
/** Dissolve the body's lower edge so the surface reads as continuing below. */
|
||||
fade?: boolean
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* The shared chrome for a callout: an elevated panel that lifts a real Sim UI
|
||||
* surface off the backdrop, wearing the hero platform window's exact chrome -
|
||||
* 10px radius, `--surface-1` fill, and the hairline-ring + layered soft shadow
|
||||
* - so every floating window on the page reads as one family. Optionally fades
|
||||
* its body's foot so a long surface dissolves rather than ends on a hard edge.
|
||||
*/
|
||||
export function CalloutFrame({ className, bodyClassName, fade, children }: CalloutFrameProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'overflow-hidden rounded-[10px] bg-[var(--surface-1)] shadow-[0_0_0_1px_rgba(0,0,0,0.08),0_2px_6px_0_rgba(0,0,0,0.05),0_4px_42px_0_rgba(0,0,0,0.06)]',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className={cn('relative overflow-hidden', fade && CALLOUT_FADE, bodyClassName)}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { CalloutFrame } from './callout-frame'
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { ChipTag, cn } from '@sim/emcn'
|
||||
import { ArrowRight } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
|
||||
interface FeatureCardProps {
|
||||
/** Capability name shown as a chip tag pinned to the card's top-right corner. */
|
||||
eyebrow: string
|
||||
/** The beat's headline (`<h3>` - the section owns the single `<h2>`). */
|
||||
title: string
|
||||
/** Supporting copy beneath the headline. */
|
||||
description: string
|
||||
/** Optional trailing link (e.g. the feature's platform page). */
|
||||
href?: string
|
||||
/** Label for {@link href}. */
|
||||
linkLabel?: string
|
||||
/**
|
||||
* Backdrop image under the floating callout (public path). Omit for a solid
|
||||
* stage in the hero visual's light grey (`--surface-3`).
|
||||
*/
|
||||
backdropSrc?: string
|
||||
/**
|
||||
* Which side the media stage sits on from `lg` up (cards alternate down the
|
||||
* section, Cursor-style). Below `lg` the card always stacks media-first.
|
||||
*/
|
||||
mediaSide?: 'left' | 'right'
|
||||
/**
|
||||
* Square the card's bottom corners so its bottom edge merges with a
|
||||
* full-bleed divider drawn at the same line (the section's last card).
|
||||
*/
|
||||
flushBottom?: boolean
|
||||
/** The elevated real-UI callout floating over the backdrop. */
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Cursor-style feature card - one large OUTLINED container (a light
|
||||
* `--border` hairline on a transparent ground, no grey fill) holding a media
|
||||
* stage (a painted backdrop with the beat's real-UI callout floating over it)
|
||||
* and a vertically-centered copy column: `<h3>`, muted description, and an
|
||||
* optional arrow link. `mediaSide` picks which side the media sits on so the
|
||||
* cards can alternate down the section. The beat's name sits as a borderless
|
||||
* grey-filled {@link ChipTag} pinned to the card's top corner on the COPY
|
||||
* side (top-right when media is left, top-left when media is right), just
|
||||
* inside the outline - never floating over the media image.
|
||||
*
|
||||
* Below `lg` the card stacks - media on top, copy beneath - and the media
|
||||
* shortens so the card stays scannable in the compact grid. The pinned chip
|
||||
* would overlap the full-width media there, so it relocates INTO the copy
|
||||
* column, sitting above the `<h3>` as an in-flow eyebrow.
|
||||
*/
|
||||
export function FeatureCard({
|
||||
eyebrow,
|
||||
title,
|
||||
description,
|
||||
href,
|
||||
linkLabel,
|
||||
backdropSrc,
|
||||
mediaSide = 'left',
|
||||
flushBottom = false,
|
||||
children,
|
||||
}: FeatureCardProps) {
|
||||
const mediaRight = mediaSide === 'right'
|
||||
return (
|
||||
<article
|
||||
className={cn(
|
||||
'relative grid gap-10 rounded-[10px] border border-[var(--border)] p-4 max-lg:grid-cols-1 max-lg:gap-6',
|
||||
mediaRight ? 'grid-cols-[386px_1fr]' : 'grid-cols-[1fr_386px]',
|
||||
flushBottom && 'rounded-b-none'
|
||||
)}
|
||||
>
|
||||
<ChipTag
|
||||
variant='mono'
|
||||
className={cn('absolute top-4 z-10 max-lg:hidden', mediaRight ? 'left-4' : 'right-4')}
|
||||
>
|
||||
{eyebrow}
|
||||
</ChipTag>
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className={cn(
|
||||
'relative aspect-[3/2] overflow-hidden rounded-[4px] max-lg:order-1 max-lg:aspect-[4/3]',
|
||||
!backdropSrc && 'bg-[var(--surface-3)]',
|
||||
mediaRight && 'lg:order-2'
|
||||
)}
|
||||
>
|
||||
{backdropSrc && (
|
||||
<Image
|
||||
src={backdropSrc}
|
||||
alt=''
|
||||
fill
|
||||
sizes='(max-width: 1460px) 70vw, 900px'
|
||||
className='object-cover'
|
||||
/>
|
||||
)}
|
||||
<div className='absolute inset-0 flex items-center justify-center p-4 [&>*]:max-w-full'>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col justify-center max-lg:order-2 max-lg:pb-2',
|
||||
mediaRight ? 'pl-4 max-lg:pl-0' : 'pr-4 max-lg:pr-0'
|
||||
)}
|
||||
>
|
||||
<ChipTag variant='mono' className='hidden max-lg:mb-3 max-lg:inline-flex max-lg:self-start'>
|
||||
{eyebrow}
|
||||
</ChipTag>
|
||||
<h3 className='text-balance font-medium text-[22px] text-[var(--text-primary)] leading-[1.3] max-sm:text-[20px]'>
|
||||
{title}
|
||||
</h3>
|
||||
<p className='mt-3 text-pretty text-[15px] text-[var(--text-muted)] leading-[1.6]'>
|
||||
{description}
|
||||
</p>
|
||||
{href && linkLabel && (
|
||||
<Link
|
||||
href={href}
|
||||
className='mt-5 flex items-center gap-1.5 text-[15px] text-[var(--text-body)] transition-colors hover-hover:text-[var(--text-primary)]'
|
||||
>
|
||||
{linkLabel}
|
||||
<ArrowRight className='size-[15px]' />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { FeatureCard } from './feature-card'
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
import Image from 'next/image'
|
||||
import { CalloutFrame } from '@/app/(landing)/components/features/components/callout-frame'
|
||||
|
||||
/**
|
||||
* The Integrate beat's callout - the REAL platform Integrations page as one
|
||||
* floating window: a full capture of the workspace UI (sidebar + Integrations
|
||||
* tab with the showcase mosaic, search, and Featured sections) taken by
|
||||
* `exports/readme-banner/capture-integrations-ui.mjs` at the hero shot's card
|
||||
* geometry (1280x735 @2x), framed by the shared {@link CalloutFrame} so it
|
||||
* wears the hero platform window's exact chrome (10px radius + layered
|
||||
* shadow).
|
||||
*
|
||||
* The window is oversized (125% of the media stage, ~82% of the capture's
|
||||
* native scale) and anchored with visually EQUAL top and left insets
|
||||
* (percentage-based - 9.6% of the stage width; 14.4% of its 3:2 height lands
|
||||
* at the same px), so its top-left corner floats free over the backdrop while
|
||||
* the right AND bottom edges bleed past the media stage's clip - a zoomed-in
|
||||
* peek at part of the product rather than a complete miniature, scaling
|
||||
* proportionally with the aspect-locked stage. Decorative.
|
||||
*
|
||||
* `sizes` is derived directly from the section's grid math rather than
|
||||
* approximated, then rounded up to the worst-case (peak render/viewport
|
||||
* ratio) in each tier so the browser never under-fetches:
|
||||
* `callout = 1.25 * (viewport - 2*gutter - 32px card padding - [40px gap +
|
||||
* 386px fixed copy column, desktop only])`, gutter = `px-20`/`max-lg:px-8`/
|
||||
* `max-sm:px-5` from `Features`'s grid, matching `FeatureCard`'s
|
||||
* `max-lg:grid-cols-1` stack. Peak ratios (verified against a static
|
||||
* reproduction of this exact layout rendered at each Tailwind breakpoint):
|
||||
* ~113.3% at the `max-width: 1023px` stacked tier's own upper edge, ~108.6%
|
||||
* at `1460px` (the container's cap, where render width stops growing with
|
||||
* viewport - hence the final tier is a flat px value, not a vw fraction).
|
||||
*/
|
||||
export function IntegrationsCallout() {
|
||||
return (
|
||||
<div className='absolute inset-0'>
|
||||
<CalloutFrame
|
||||
className='absolute top-[14.4%] left-[9.6%] w-[125%]'
|
||||
bodyClassName='aspect-[1280/735]'
|
||||
>
|
||||
<Image
|
||||
src='/landing/feature-integrate-ui.png'
|
||||
alt=''
|
||||
fill
|
||||
sizes='(max-width: 1023px) 114vw, (max-width: 1460px) 109vw, 1053px'
|
||||
className='object-cover'
|
||||
/>
|
||||
</CalloutFrame>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import Image from 'next/image'
|
||||
import { CalloutFrame } from '@/app/(landing)/components/features/components/callout-frame'
|
||||
|
||||
/**
|
||||
* The Context beat's callout - the REAL platform Knowledge base page as one
|
||||
* floating window: a full capture of the workspace UI (sidebar with Knowledge
|
||||
* base selected + the knowledge list) taken by
|
||||
* `exports/readme-banner/capture-knowledge-ui.mjs` at the hero shot's card
|
||||
* geometry (1280x735 @2x), framed by the shared {@link CalloutFrame} so it
|
||||
* wears the hero platform window's exact chrome (10px radius + layered
|
||||
* shadow).
|
||||
*
|
||||
* Same oversized treatment as the Integrate card: 125% of the media stage
|
||||
* with EQUAL top and left insets (96px), so the top-left corner floats free
|
||||
* over the backdrop while the right and bottom edges bleed past the media
|
||||
* stage's clip. Decorative.
|
||||
*/
|
||||
export function KnowledgeCallout() {
|
||||
return (
|
||||
<div className='absolute inset-0'>
|
||||
<CalloutFrame
|
||||
className='absolute top-[14.4%] left-[9.6%] w-[125%]'
|
||||
bodyClassName='aspect-[1280/735]'
|
||||
>
|
||||
<Image
|
||||
src='/landing/feature-context-ui.png'
|
||||
alt=''
|
||||
fill
|
||||
sizes='1050px'
|
||||
className='object-cover'
|
||||
/>
|
||||
</CalloutFrame>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { LogsCallout } from './logs-callout'
|
||||
@@ -0,0 +1,35 @@
|
||||
import Image from 'next/image'
|
||||
import { CalloutFrame } from '@/app/(landing)/components/features/components/callout-frame'
|
||||
|
||||
/**
|
||||
* The Monitor beat's callout - the REAL platform Logs page as one floating
|
||||
* window: a full capture of the workspace UI (sidebar with Logs selected +
|
||||
* the seeded run table) taken by
|
||||
* `exports/readme-banner/capture-logs-ui.mjs` at the hero shot's card
|
||||
* geometry (1280x735 @2x), framed by the shared {@link CalloutFrame} so it
|
||||
* wears the hero platform window's exact chrome (10px radius + layered
|
||||
* shadow).
|
||||
*
|
||||
* Same oversized treatment as the Context card: 125% of the media stage with
|
||||
* EQUAL top and left insets (96px), so the top-left corner floats free over
|
||||
* the backdrop while the right and bottom edges bleed past the media stage's
|
||||
* clip. Decorative.
|
||||
*/
|
||||
export function LogsCallout() {
|
||||
return (
|
||||
<div className='absolute inset-0'>
|
||||
<CalloutFrame
|
||||
className='absolute top-[14.4%] left-[9.6%] w-[125%]'
|
||||
bodyClassName='aspect-[1280/735]'
|
||||
>
|
||||
<Image
|
||||
src='/landing/feature-monitor-ui.png'
|
||||
alt=''
|
||||
fill
|
||||
sizes='1050px'
|
||||
className='object-cover'
|
||||
/>
|
||||
</CalloutFrame>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { BuildCallout } from '@/app/(landing)/components/features/components/build-callout/build-callout'
|
||||
import { FeatureCard } from '@/app/(landing)/components/features/components/feature-card'
|
||||
import { IntegrationsCallout } from '@/app/(landing)/components/features/components/integrations-callout/integrations-callout'
|
||||
import { KnowledgeCallout } from '@/app/(landing)/components/features/components/knowledge-callout/knowledge-callout'
|
||||
import { LogsCallout } from '@/app/(landing)/components/features/components/logs-callout'
|
||||
|
||||
/**
|
||||
* Landing features - how Sim works, as a platform lifecycle. Four beats, in the
|
||||
* order you actually use Sim: bring your tools in (Integrate), give it data to
|
||||
* reason over (Context), build the agent logic (Build), then watch it run
|
||||
* (Monitor). Each beat is a Cursor-style {@link FeatureCard}: one large
|
||||
* outlined card holding a media stage (backdrop painting + elevated real-UI
|
||||
* callout) and a copy column, with the media side alternating card to card.
|
||||
*
|
||||
* The section's `<h2>` is `sr-only` - each beat carries its own visible `<h3>`,
|
||||
* so the section heading exists only to anchor the heading hierarchy and give AI
|
||||
* crawlers an atomic summary.
|
||||
*
|
||||
* Inter-section spacing is owned by the `<main>` flex `gap` in `landing.tsx`;
|
||||
* this section carries no vertical padding. The section itself is FULL-WIDTH so
|
||||
* its bottom rule can bleed to the browser edges; the card grid inside carries
|
||||
* the shared gutter (`px-20`) and the `max-w-[1460px]` cap. The last card
|
||||
* squares its bottom corners (`flushBottom`) and sits exactly on the rule, so
|
||||
* its outline merges into the full-bleed divider.
|
||||
*
|
||||
* The cards stack in a single column at every width on a 112px rhythm
|
||||
* (matching Cursor's spacing between feature cards). Below `lg` each card
|
||||
* internally reflows media-over-copy.
|
||||
*
|
||||
* Per-beat icons are still abstract placeholders (text eyebrows); distinct
|
||||
* abstract glyphs land in a later pass.
|
||||
*/
|
||||
export function Features() {
|
||||
return (
|
||||
<section id='features' aria-labelledby='features-heading' className='relative w-full'>
|
||||
<h2 id='features-heading' className='sr-only'>
|
||||
Integrate your tools, give Sim context, build agents, and monitor every run.
|
||||
</h2>
|
||||
|
||||
<div className='mx-auto grid w-full max-w-[1460px] grid-cols-1 gap-28 px-20 max-sm:gap-12 max-sm:px-5 max-lg:px-8'>
|
||||
{/* Integrate: bring your stack in. */}
|
||||
<FeatureCard
|
||||
eyebrow='Integrate'
|
||||
title='Connect the tools your work runs on.'
|
||||
description='Plug in 1,000+ integrations like Slack, HubSpot, Salesforce, and Notion, so Sim agents act across the stack you already use.'
|
||||
href='/integrations'
|
||||
linkLabel='Explore integrations'
|
||||
backdropSrc='/landing/feature-integrate-backdrop.jpg'
|
||||
>
|
||||
<IntegrationsCallout />
|
||||
</FeatureCard>
|
||||
|
||||
{/* Context: store data semantically. */}
|
||||
<FeatureCard
|
||||
eyebrow='Context'
|
||||
title='Give Sim data it can reason over.'
|
||||
description='Sim stores your data semantically in tables, files, and knowledge bases your agents read from to ground every answer in your own data.'
|
||||
backdropSrc='/landing/feature-context-backdrop.jpg'
|
||||
mediaSide='right'
|
||||
>
|
||||
<KnowledgeCallout />
|
||||
</FeatureCard>
|
||||
|
||||
{/* Build: wire agent logic in the visual builder. */}
|
||||
<FeatureCard
|
||||
eyebrow='Build'
|
||||
title='Build agents that solve real problems.'
|
||||
description='Wire blocks, models, and integrations into agent logic on a visual builder, from one agent to many working in parallel.'
|
||||
href='/workflows'
|
||||
linkLabel='Explore the workflow builder'
|
||||
>
|
||||
<BuildCallout />
|
||||
</FeatureCard>
|
||||
|
||||
{/* Monitor: watch every run. */}
|
||||
<FeatureCard
|
||||
eyebrow='Monitor'
|
||||
title='Watch every run, end to end.'
|
||||
description='Trace each run block by block, with full logs and the real cost, so you always know what ran and why.'
|
||||
backdropSrc='/landing/feature-monitor-backdrop.jpg'
|
||||
mediaSide='right'
|
||||
flushBottom
|
||||
>
|
||||
<LogsCallout />
|
||||
</FeatureCard>
|
||||
</div>
|
||||
|
||||
{/* Full-bleed rule the last card's squared bottom edge merges into -
|
||||
spans the whole browser, past the content cap and gutter (the section
|
||||
itself is full-width; only the card grid above is capped). */}
|
||||
<div aria-hidden='true' className='absolute inset-x-0 bottom-0 h-px bg-[var(--border)]' />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { Features } from './features'
|
||||
@@ -0,0 +1,163 @@
|
||||
import Link from 'next/link'
|
||||
import { SimWordmark } from '@/app/(landing)/components/navbar/components/sim-wordmark'
|
||||
import { MODEL_PROVIDERS_WITH_CATALOGS } from '@/app/(landing)/models/utils'
|
||||
|
||||
/**
|
||||
* Landing footer - the site link directory. Re-authored from the prior landing
|
||||
* footer's structure and link content, but on the platform's light tokens and
|
||||
* with no cross-import from `(home)`. Fully responsive like the rest of the page
|
||||
* - desktop is the baseline, scaled down via `max-*` overrides (7→3→2 columns).
|
||||
* The closing CTA lives in its own {@link Cta} section above; this is purely the
|
||||
* `<footer>` landmark.
|
||||
*
|
||||
* Carries `SiteNavigationElement` schema for crawlable footer nav. A top
|
||||
* hairline separates it from the page and spans the full viewport width
|
||||
* (edge-to-edge): the border lives on the full-width `<footer>` landmark while
|
||||
* an inner container caps and centers the content at the shared
|
||||
* `max-w-[1460px]` with the same `px-20` gutter as every section above.
|
||||
*/
|
||||
|
||||
const LINK_CLASS =
|
||||
'text-sm text-[var(--text-muted)] transition-colors hover:text-[var(--text-primary)]'
|
||||
|
||||
interface FooterItem {
|
||||
label: string
|
||||
href: string
|
||||
external?: boolean
|
||||
}
|
||||
|
||||
const PRODUCT_LINKS: FooterItem[] = [
|
||||
{ label: 'Enterprise', href: '/enterprise' },
|
||||
{ label: 'Mothership', href: 'https://docs.sim.ai/mothership', external: true },
|
||||
{ label: 'Workflows', href: 'https://docs.sim.ai', external: true },
|
||||
{ label: 'Knowledge Base', href: 'https://docs.sim.ai/knowledgebase', external: true },
|
||||
{ label: 'Tables', href: 'https://docs.sim.ai/tables', external: true },
|
||||
{ label: 'MCP', href: 'https://docs.sim.ai/agents/mcp', external: true },
|
||||
{ label: 'API', href: 'https://docs.sim.ai/api-reference/getting-started', external: true },
|
||||
{ label: 'Self Hosting', href: 'https://docs.sim.ai/platform/self-hosting', external: true },
|
||||
{ label: 'Status', href: 'https://status.sim.ai', external: true },
|
||||
]
|
||||
|
||||
const RESOURCES_LINKS: FooterItem[] = [
|
||||
{ label: 'Blog', href: '/blog' },
|
||||
{ label: 'Docs', href: 'https://docs.sim.ai', external: true },
|
||||
{ label: 'Compare', href: '/comparison' },
|
||||
{ label: 'Careers', href: '/careers' },
|
||||
{ label: 'Changelog', href: '/changelog' },
|
||||
{ label: 'Contact', href: '/contact' },
|
||||
]
|
||||
|
||||
/** Top model providers, sourced from the catalog so labels/hrefs never drift. */
|
||||
const MODEL_LINKS: FooterItem[] = [
|
||||
{ label: 'All Models', href: '/models' },
|
||||
...MODEL_PROVIDERS_WITH_CATALOGS.slice(0, 8).map((provider) => ({
|
||||
label: provider.name,
|
||||
href: provider.href,
|
||||
})),
|
||||
]
|
||||
|
||||
const BLOCK_LINKS: FooterItem[] = [
|
||||
{ label: 'Agent', href: 'https://docs.sim.ai/workflows/blocks/agent', external: true },
|
||||
{ label: 'Router', href: 'https://docs.sim.ai/workflows/blocks/router', external: true },
|
||||
{ label: 'Function', href: 'https://docs.sim.ai/workflows/blocks/function', external: true },
|
||||
{ label: 'Condition', href: 'https://docs.sim.ai/workflows/blocks/condition', external: true },
|
||||
{ label: 'API Block', href: 'https://docs.sim.ai/workflows/blocks/api', external: true },
|
||||
{ label: 'Workflow', href: 'https://docs.sim.ai/workflows/blocks/workflow', external: true },
|
||||
{ label: 'Parallel', href: 'https://docs.sim.ai/workflows/blocks/parallel', external: true },
|
||||
{ label: 'Guardrails', href: 'https://docs.sim.ai/workflows/blocks/guardrails', external: true },
|
||||
{ label: 'Evaluator', href: 'https://docs.sim.ai/workflows/blocks/evaluator', external: true },
|
||||
{ label: 'Loop', href: 'https://docs.sim.ai/workflows/blocks/loop', external: true },
|
||||
]
|
||||
|
||||
const INTEGRATION_LINKS: FooterItem[] = [
|
||||
{ label: 'All Integrations', href: '/integrations' },
|
||||
{ label: 'Slack', href: 'https://docs.sim.ai/integrations/slack', external: true },
|
||||
{ label: 'GitHub', href: 'https://docs.sim.ai/integrations/github', external: true },
|
||||
{ label: 'Gmail', href: 'https://docs.sim.ai/integrations/gmail', external: true },
|
||||
{ label: 'Notion', href: 'https://docs.sim.ai/integrations/notion', external: true },
|
||||
{ label: 'Salesforce', href: 'https://docs.sim.ai/integrations/salesforce', external: true },
|
||||
{ label: 'Jira', href: '/integrations/jira' },
|
||||
{ label: 'Linear', href: 'https://docs.sim.ai/integrations/linear', external: true },
|
||||
{ label: 'Supabase', href: 'https://docs.sim.ai/integrations/supabase', external: true },
|
||||
{ label: 'Stripe', href: 'https://docs.sim.ai/integrations/stripe', external: true },
|
||||
]
|
||||
|
||||
const SOCIAL_LINKS: FooterItem[] = [
|
||||
{ label: 'X (Twitter)', href: 'https://x.com/simdotai', external: true },
|
||||
{
|
||||
label: 'LinkedIn',
|
||||
href: 'https://www.linkedin.com/company/simstudioai/',
|
||||
external: true,
|
||||
},
|
||||
{ label: 'Discord', href: 'https://discord.gg/Hr4UWYEcTT', external: true },
|
||||
{
|
||||
label: 'GitHub',
|
||||
href: 'https://github.com/simstudioai/sim',
|
||||
external: true,
|
||||
},
|
||||
]
|
||||
|
||||
const LEGAL_LINKS: FooterItem[] = [
|
||||
{ label: 'Terms of Service', href: '/terms' },
|
||||
{ label: 'Privacy Policy', href: '/privacy' },
|
||||
]
|
||||
|
||||
function FooterColumn({ title, items }: { title: string; items: FooterItem[] }) {
|
||||
return (
|
||||
<div>
|
||||
<h3 className='mb-4 text-[var(--text-primary)] text-sm'>{title}</h3>
|
||||
<div className='flex flex-col gap-2.5'>
|
||||
{items.map(({ label, href, external }) =>
|
||||
external ? (
|
||||
<a
|
||||
key={label}
|
||||
href={href}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className={LINK_CLASS}
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
) : (
|
||||
<Link key={label} href={href} className={LINK_CLASS}>
|
||||
{label}
|
||||
</Link>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Footer() {
|
||||
return (
|
||||
<footer className='mt-[120px] w-full border-[var(--border)] border-t max-sm:mt-16 max-lg:mt-[88px]'>
|
||||
<div className='mx-auto w-full max-w-[1460px] px-20 pt-16 pb-16 max-sm:px-5 max-lg:px-8 max-lg:pt-12 max-lg:pb-12'>
|
||||
<nav
|
||||
aria-label='Footer navigation'
|
||||
itemScope
|
||||
itemType='https://schema.org/SiteNavigationElement'
|
||||
className='grid grid-cols-8 gap-x-8 gap-y-10 max-sm:grid-cols-2 max-sm:gap-y-8 max-lg:grid-cols-3'
|
||||
>
|
||||
<Link
|
||||
href='/'
|
||||
aria-label='Sim home'
|
||||
className='flex h-[18px] items-center max-lg:col-span-full max-lg:mb-2'
|
||||
>
|
||||
<SimWordmark />
|
||||
</Link>
|
||||
|
||||
<FooterColumn title='Product' items={PRODUCT_LINKS} />
|
||||
<FooterColumn title='Resources' items={RESOURCES_LINKS} />
|
||||
<FooterColumn title='Blocks' items={BLOCK_LINKS} />
|
||||
<FooterColumn title='Integrations' items={INTEGRATION_LINKS} />
|
||||
<FooterColumn title='Models' items={MODEL_LINKS} />
|
||||
<FooterColumn title='Socials' items={SOCIAL_LINKS} />
|
||||
<FooterColumn title='Legal' items={LEGAL_LINKS} />
|
||||
</nav>
|
||||
|
||||
<p className='mt-16 text-[var(--text-muted)] text-sm'>© 2026 Sim. All rights reserved.</p>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { Footer } from './footer'
|
||||
@@ -0,0 +1,40 @@
|
||||
import { ChipLink, cn } from '@sim/emcn'
|
||||
import { DEMO_HREF, SIGNUP_HREF } from '@/app/(landing)/constants'
|
||||
|
||||
/**
|
||||
* Hero-scale sizing shared by both CTAs - one step up from the navbar's 30px
|
||||
* chip: 36px tall, 15px label (the chip's inner span inherits it), horizontal
|
||||
* padding in `em` at the chip's native 8/14 ratio so it scales with the text.
|
||||
*/
|
||||
const CTA_SIZE = 'h-[36px] px-[0.571em] text-[15px] [&>span]:[font-size:inherit]'
|
||||
|
||||
/**
|
||||
* The canonical landing call-to-action - a dark "Request a demo" chip beside an
|
||||
* outline "Sign up" chip. This is the single source of truth for the CTA used
|
||||
* by both the landing hero and every platform hero, so the two never drift.
|
||||
*
|
||||
* Both CTAs are the same global {@link ChipLink} chrome the navbar's auth
|
||||
* cluster uses - "Request a demo" is the `primary` (filled) variant like the
|
||||
* navbar's "Sign up"; "Sign up" here is the default chip with the
|
||||
* `--border-1` outline like the navbar's "Contact sales" - scaled up one step
|
||||
* via {@link CTA_SIZE} (the sanctioned size overrides; chrome stays the chip's).
|
||||
*
|
||||
* Server Component; owns its own internal spacing. Consumers place it in their
|
||||
* own stack and pass nothing.
|
||||
*/
|
||||
export function HeroCta() {
|
||||
return (
|
||||
<div className='flex items-center gap-2 max-sm:w-full max-sm:flex-col max-sm:items-stretch'>
|
||||
<ChipLink variant='primary' href={DEMO_HREF} className={CTA_SIZE}>
|
||||
Request a demo
|
||||
</ChipLink>
|
||||
<ChipLink
|
||||
href={SIGNUP_HREF}
|
||||
prefetch={false}
|
||||
className={cn(CTA_SIZE, 'border border-[var(--border-1)] max-sm:justify-center')}
|
||||
>
|
||||
Sign up
|
||||
</ChipLink>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { HeroCta } from './hero-cta'
|
||||
@@ -0,0 +1,234 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { cn, Tooltip } from '@sim/emcn'
|
||||
import {
|
||||
ArrowRight,
|
||||
ArrowUp,
|
||||
Copy,
|
||||
Mic,
|
||||
Paperclip,
|
||||
Plus,
|
||||
Slash,
|
||||
ThumbsDown,
|
||||
ThumbsUp,
|
||||
} from 'lucide-react'
|
||||
import { ThinkingLoader } from '@/components/ui'
|
||||
import { HERO_TOOLTIP_OFFSET } from '@/app/(landing)/components/hero/components/hero-platform-loop/sidebar-hotspots'
|
||||
|
||||
/** The conversation the loop plays - mirrors the seeded capture chat. */
|
||||
const USER_MESSAGE = 'When a new lead signs up, enrich it with company data and post it to #sales.'
|
||||
const REPLY_MESSAGE =
|
||||
"On it. I'll build a workflow that enriches each new signup with firmographics, scores it, and posts a summary to your #sales channel in Slack."
|
||||
const REPLY_WORDS = REPLY_MESSAGE.split(' ')
|
||||
/** Word-reveal cadence for the streamed reply. */
|
||||
const STREAM_WORD_MS = 55
|
||||
/** Follow-up suggestions shown once the reply completes, like the real chat. */
|
||||
const FOLLOW_UPS = [
|
||||
'Run a test with a sample lead',
|
||||
'Deploy the workflow',
|
||||
'Add lead scoring criteria',
|
||||
] as const
|
||||
|
||||
/** Where the chat pane is within one loop pass. */
|
||||
export type HeroChatPhase = 'idle' | 'user' | 'thinking' | 'reply'
|
||||
|
||||
interface HeroChatLoopProps {
|
||||
/** Current phase, driven by the parent {@link HeroPlatformLoop} clock. */
|
||||
phase: HeroChatPhase
|
||||
/** True during the brief fade-out before the cycle restarts. */
|
||||
fading: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The Mothership chat pane of the hero's live layer - purely presentational;
|
||||
* the loop clock lives in `HeroPlatformLoop` so the chat and the workflow
|
||||
* stage animate off one timeline. Replays one exchange: the user message
|
||||
* slides in, the goo {@link ThinkingLoader} cycles while the Mothership
|
||||
* thinks, the reply lands with its action row.
|
||||
*
|
||||
* Visuals mirror the real chat pane (`--bg` column, grey user bubble, bare
|
||||
* reply text, the real composer chrome - 28px round icon buttons, mic + the
|
||||
* `#808080` send disc on the right cluster) so the seam with the surrounding
|
||||
* screenshot is invisible. The reply STREAMS in word by word (the way the
|
||||
* real Mothership streams its responses); once the text completes, the
|
||||
* "Suggested follow-ups" block (the real special-tags markup: numbered
|
||||
* `--divider`-ruled rows with a trailing arrow) and the action row land
|
||||
* together; under `prefers-reduced-motion` it appears whole.
|
||||
* The content column is centered and capped (like the real full-width
|
||||
* MothershipChat) so it reads right both full-width (stage collapsed) and at
|
||||
* half width (stage open). Only the conversation fades on reset - the pane
|
||||
* and composer are persistent chrome.
|
||||
*/
|
||||
export function HeroChatLoop({ phase, fading }: HeroChatLoopProps) {
|
||||
const showUser = phase !== 'idle'
|
||||
const showThinking = phase === 'thinking'
|
||||
const showReply = phase === 'reply'
|
||||
const [revealedWords, setRevealedWords] = useState(0)
|
||||
|
||||
// Stream the reply word by word while the phase holds on 'reply'; any other
|
||||
// phase (the next cycle's reset) rewinds it for the following pass. The
|
||||
// count derives from ELAPSED time, not tick count, so throttled background
|
||||
// tabs catch up in chunks instead of stalling mid-sentence.
|
||||
useEffect(() => {
|
||||
if (!showReply) {
|
||||
setRevealedWords(0)
|
||||
return
|
||||
}
|
||||
|
||||
const media = window.matchMedia('(prefers-reduced-motion: reduce)')
|
||||
let interval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const stream = () => {
|
||||
const startedAt = performance.now()
|
||||
interval = setInterval(() => {
|
||||
const elapsed = performance.now() - startedAt
|
||||
const n = Math.min(Math.floor(elapsed / STREAM_WORD_MS) + 1, REPLY_WORDS.length)
|
||||
setRevealedWords(n)
|
||||
if (n >= REPLY_WORDS.length && interval) clearInterval(interval)
|
||||
}, STREAM_WORD_MS)
|
||||
}
|
||||
|
||||
// Re-synced on 'change' (not just on mount) so toggling the preference
|
||||
// mid-stream - e.g. HeroPlatformLoop's showFinished setting phase to
|
||||
// 'reply' while it's already 'reply' - still snaps the reply to complete
|
||||
// instead of leaving it mid-word until the running interval catches up.
|
||||
const syncMotionPreference = () => {
|
||||
if (interval) clearInterval(interval)
|
||||
if (media.matches) {
|
||||
setRevealedWords(REPLY_WORDS.length)
|
||||
return
|
||||
}
|
||||
stream()
|
||||
}
|
||||
|
||||
syncMotionPreference()
|
||||
media.addEventListener('change', syncMotionPreference)
|
||||
return () => {
|
||||
media.removeEventListener('change', syncMotionPreference)
|
||||
if (interval) clearInterval(interval)
|
||||
}
|
||||
}, [showReply])
|
||||
|
||||
const replyComplete = revealedWords >= REPLY_WORDS.length
|
||||
|
||||
return (
|
||||
<div className='flex h-full w-full flex-col bg-[var(--bg)]'>
|
||||
<div
|
||||
className={cn(
|
||||
'mx-auto flex min-h-0 w-full max-w-[640px] flex-1 flex-col gap-6 overflow-hidden px-6 pt-6 transition-opacity duration-300 ease-out',
|
||||
fading ? 'opacity-0' : 'opacity-100'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'max-w-[82%] self-end rounded-2xl bg-[var(--surface-3)] px-4 py-3 text-[15px] text-[var(--text-primary)] leading-[1.5] transition-[opacity,transform] duration-200 ease-out',
|
||||
showUser ? 'translate-y-0 opacity-100' : 'translate-y-2 opacity-0'
|
||||
)}
|
||||
>
|
||||
{USER_MESSAGE}
|
||||
</div>
|
||||
|
||||
{showThinking && <ThinkingLoader size={26} phase labelRatio={0.58} className='mt-1' />}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col gap-4 transition-opacity duration-200 ease-out',
|
||||
showReply ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
>
|
||||
<p className='text-[15px] text-[var(--text-primary)] leading-[1.6]'>
|
||||
{REPLY_WORDS.slice(0, revealedWords).join(' ')}
|
||||
</p>
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col gap-4 transition-opacity duration-200 ease-out',
|
||||
replyComplete ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
>
|
||||
<div className='flex flex-col'>
|
||||
<span className='text-[var(--text-body)] text-sm'>Suggested follow-ups</span>
|
||||
<div className='mt-1.5 flex flex-col'>
|
||||
{FOLLOW_UPS.map((title, i) => (
|
||||
<span
|
||||
key={title}
|
||||
className={cn(
|
||||
'flex items-center gap-2 border-[var(--divider)] px-2 py-2 text-left',
|
||||
i > 0 && 'border-t'
|
||||
)}
|
||||
>
|
||||
<span className='flex size-[16px] flex-shrink-0 items-center justify-center'>
|
||||
<span className='text-[var(--text-icon)] text-sm'>{i + 1}</span>
|
||||
</span>
|
||||
<span className='flex-1 text-[var(--text-body)] text-sm'>{title}</span>
|
||||
<ArrowRight className='size-[16px] shrink-0 text-[var(--text-icon)]' />
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex items-center gap-3 text-[var(--text-icon)]'>
|
||||
<Copy className='size-[14px]' />
|
||||
<ThumbsUp className='size-[14px]' />
|
||||
<ThumbsDown className='size-[14px]' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='pointer-events-auto mx-auto mb-5 w-[calc(100%-40px)] max-w-[600px] shrink-0 rounded-2xl border border-[var(--border-1)] bg-[var(--white)] px-2.5 py-2'>
|
||||
<p className='px-1.5 pt-1 text-[15px] text-[var(--text-muted)]'>Send message to Sim</p>
|
||||
<div className='mt-2 flex items-center gap-1.5'>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<span
|
||||
className='flex size-[28px] items-center justify-center rounded-full transition-colors hover-hover:bg-[var(--surface-hover)]'
|
||||
aria-label='Add resources'
|
||||
>
|
||||
<Plus className='size-[16px] text-[var(--text-icon)]' />
|
||||
</span>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content offset={HERO_TOOLTIP_OFFSET}>Add resources</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<span
|
||||
className='flex size-[28px] items-center justify-center rounded-full transition-colors hover-hover:bg-[var(--surface-hover)]'
|
||||
aria-label='Attach file'
|
||||
>
|
||||
<Paperclip className='size-[16px] text-[var(--text-icon)]' />
|
||||
</span>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content offset={HERO_TOOLTIP_OFFSET}>Attach file</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<span
|
||||
className='flex size-[28px] items-center justify-center rounded-full transition-colors hover-hover:bg-[var(--surface-hover)]'
|
||||
aria-label='Skills'
|
||||
>
|
||||
<Slash className='size-[16px] text-[var(--text-icon)]' />
|
||||
</span>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content offset={HERO_TOOLTIP_OFFSET}>Skills</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
<span className='ml-auto flex items-center gap-1.5'>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<span
|
||||
className='flex size-[28px] items-center justify-center rounded-full transition-colors hover-hover:bg-[var(--surface-hover)]'
|
||||
aria-label='Voice input'
|
||||
>
|
||||
<Mic className='size-[16px] text-[var(--text-icon)]' />
|
||||
</span>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content offset={HERO_TOOLTIP_OFFSET}>Voice input</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
<span className='flex size-[28px] items-center justify-center rounded-full bg-[#808080]'>
|
||||
<ArrowUp className='size-[16px] text-white' />
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { HeroChatPhase } from './hero-chat-loop'
|
||||
export { HeroChatLoop } from './hero-chat-loop'
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '@sim/emcn'
|
||||
import { HeroStat } from '@/app/(landing)/components/hero/components/hero-stat'
|
||||
import { HeroCta } from '@/app/(landing)/components/hero-cta'
|
||||
import { LANDING_HERO_CTA_GAP } from '@/app/(landing)/components/landing-layout'
|
||||
|
||||
interface LandingHeroHeaderProps {
|
||||
description: string
|
||||
eyebrow?: ReactNode
|
||||
heading: ReactNode
|
||||
headingId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared homepage hero header geometry. Marketing routes use this component so
|
||||
* the headline measure, CTA stack, and right-side global-work stat cannot drift.
|
||||
*/
|
||||
export function LandingHeroHeader({
|
||||
description,
|
||||
eyebrow,
|
||||
heading,
|
||||
headingId,
|
||||
}: LandingHeroHeaderProps) {
|
||||
return (
|
||||
<div className='flex w-full items-end justify-between gap-8'>
|
||||
<div className='flex min-w-0 flex-1 flex-col items-start gap-[22px] text-left'>
|
||||
{eyebrow}
|
||||
|
||||
<h1
|
||||
id={headingId}
|
||||
className='max-w-[1120px] text-balance text-[64px] text-[var(--text-primary)] leading-[1.05] tracking-[-0.01em] max-sm:text-[36px] max-xl:text-[52px] [&>br]:max-sm:hidden'
|
||||
>
|
||||
{heading}
|
||||
</h1>
|
||||
|
||||
<p className='w-full min-w-0 max-w-[58ch] text-pretty text-[var(--text-muted)] text-base leading-[1.5]'>
|
||||
{description}
|
||||
</p>
|
||||
|
||||
<div className={cn('max-sm:w-full', LANDING_HERO_CTA_GAP)}>
|
||||
<HeroCta />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<HeroStat />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { LandingHeroHeader } from './hero-header'
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { cn } from '@sim/emcn'
|
||||
import {
|
||||
HeroChatLoop,
|
||||
type HeroChatPhase,
|
||||
} from '@/app/(landing)/components/hero/components/hero-chat-loop'
|
||||
import { HeroWorkflowStage } from '@/app/(landing)/components/hero/components/hero-platform-loop/hero-workflow-stage'
|
||||
import { SidebarHotspots } from '@/app/(landing)/components/hero/components/hero-platform-loop/sidebar-hotspots'
|
||||
import { STAGE_BLOCKS } from '@/app/(landing)/components/hero/components/hero-platform-loop/stage-data'
|
||||
|
||||
/**
|
||||
* One pass of the synced loop, matching the REAL platform behavior: the chat
|
||||
* runs FULL-WIDTH (stage collapsed, exactly like `MothershipView`'s `w-0`
|
||||
* state); the user message lands and the Mothership starts thinking; the stage
|
||||
* pane SLIDES IN from the right (the real `w-1/2` + `border-l` width
|
||||
* transition); the workflow assembles block by block inside it; the reply
|
||||
* lands once the flow is built; the scene holds, fades, and restarts.
|
||||
*/
|
||||
const PHASE_STARTS = { user: 500, thinking: 1400 } as const
|
||||
/** The stage pane starts sliding open here (during thinking). */
|
||||
const STAGE_OPEN_AT = 1900
|
||||
/** Block N (build order) pops in at BUILD_START + N * BUILD_STEP. */
|
||||
const BUILD_START = 2400
|
||||
const BUILD_STEP = 620
|
||||
const REPLY_AT = 6400
|
||||
const TOTAL_MS = 12_500
|
||||
const RESET_FADE_MS = 260
|
||||
|
||||
/**
|
||||
* The workspace container's interior in the capture's design space (the shot
|
||||
* is 2560x1470 at 2x, i.e. a 1280x735 CSS layout - oversized vs the 1080x620
|
||||
* window so the whole UI displays at 84.4%, landing the app's native type at
|
||||
* cursor.com's ~12.7px demo scale): x 249-1272, y 7-727. The live layer lays
|
||||
* out at this FIXED size and scales down with the window (`transform: scale`),
|
||||
* so its text and controls shrink in lockstep with the baked sidebar pixels -
|
||||
* the "mini app" reading - instead of rendering at 1:1 CSS sizes and looking
|
||||
* oversized next to the scaled screenshot.
|
||||
*/
|
||||
const CHROME_INTERIOR = { width: 1024, height: 721 } as const
|
||||
|
||||
/**
|
||||
* The hero window's live layer - one flex region replaying the REAL Home
|
||||
* two-pane over the static screenshot. The region covers the workspace
|
||||
* container's interior (inset a hair inside the shot's baked chrome: the
|
||||
* horizontal rules 6px from the card top/bottom (the chrome's `p-[8px]` gap is
|
||||
* tightened to 6px at capture time), the container's left border at ~19.4%,
|
||||
* and its right border at ~99.4%; the container renders at `6px` radius
|
||||
* (overridden at capture time from the chrome's 8px so it DISPLAYS at the
|
||||
* concentric ~4.9px after the 84.4% shot-to-window factor), so the region
|
||||
* clips itself `rounded-[4px]` to hug the baked corner curves without
|
||||
* covering them) so every visible outline is the real UI's pixels.
|
||||
*
|
||||
* Inside, the layout mirrors `Home`: the {@link HeroChatLoop} is a flex-1
|
||||
* `--bg` column; the {@link HeroWorkflowStage} pane animates `w-0 ↔ w-1/2`
|
||||
* with the real `MothershipView` width transition (200ms,
|
||||
* `cubic-bezier(0.25,0.1,0.25,1)`, `border-l` only while open) - the baked
|
||||
* chat|stage divider is covered by this region, so the divider users see is
|
||||
* the live `border-l`, appearing exactly as it does in the product.
|
||||
*
|
||||
* Both panes stay `pointer-events-none` (decorative, matching the hero's
|
||||
* `aria-hidden` frame) - blocks are static. Remounting the stage per cycle
|
||||
* (`key={cycleId}`) resets build state.
|
||||
*
|
||||
* Under `prefers-reduced-motion` the loop never starts: the finished exchange,
|
||||
* open stage, and fully-built workflow render statically.
|
||||
*/
|
||||
export function HeroPlatformLoop() {
|
||||
const regionRef = useRef<HTMLDivElement>(null)
|
||||
const [phase, setPhase] = useState<HeroChatPhase>('idle')
|
||||
const [stageOpen, setStageOpen] = useState(false)
|
||||
const [builtCount, setBuiltCount] = useState(0)
|
||||
const [fading, setFading] = useState(false)
|
||||
const [cycleId, setCycleId] = useState(0)
|
||||
const [scale, setScale] = useState(1)
|
||||
|
||||
// Track the rendered region width and scale the design-space layer to fill
|
||||
// it, keeping the live layer's proportions locked to the screenshot's.
|
||||
useLayoutEffect(() => {
|
||||
const el = regionRef.current
|
||||
if (!el) return
|
||||
const measure = () => {
|
||||
const w = el.getBoundingClientRect().width
|
||||
if (w > 40) setScale(w / CHROME_INTERIOR.width)
|
||||
}
|
||||
measure()
|
||||
const ro = new ResizeObserver(measure)
|
||||
ro.observe(el)
|
||||
return () => ro.disconnect()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia('(prefers-reduced-motion: reduce)')
|
||||
let timers: ReturnType<typeof setTimeout>[] = []
|
||||
|
||||
const clearScheduled = () => {
|
||||
timers.forEach(clearTimeout)
|
||||
timers = []
|
||||
}
|
||||
|
||||
const showFinished = () => {
|
||||
clearScheduled()
|
||||
setFading(false)
|
||||
setPhase('reply')
|
||||
setStageOpen(true)
|
||||
setBuiltCount(STAGE_BLOCKS.length)
|
||||
}
|
||||
|
||||
const runCycle = () => {
|
||||
setFading(false)
|
||||
setPhase('idle')
|
||||
setStageOpen(false)
|
||||
setBuiltCount(0)
|
||||
setCycleId((c) => c + 1)
|
||||
timers = [
|
||||
setTimeout(() => setPhase('user'), PHASE_STARTS.user),
|
||||
setTimeout(() => setPhase('thinking'), PHASE_STARTS.thinking),
|
||||
setTimeout(() => setStageOpen(true), STAGE_OPEN_AT),
|
||||
...STAGE_BLOCKS.map((_, i) =>
|
||||
setTimeout(() => setBuiltCount(i + 1), BUILD_START + i * BUILD_STEP)
|
||||
),
|
||||
setTimeout(() => setPhase('reply'), REPLY_AT),
|
||||
setTimeout(() => setFading(true), TOTAL_MS - RESET_FADE_MS),
|
||||
setTimeout(runCycle, TOTAL_MS),
|
||||
]
|
||||
}
|
||||
|
||||
const syncMotionPreference = () => {
|
||||
clearScheduled()
|
||||
if (media.matches) {
|
||||
showFinished()
|
||||
return
|
||||
}
|
||||
runCycle()
|
||||
}
|
||||
|
||||
syncMotionPreference()
|
||||
media.addEventListener('change', syncMotionPreference)
|
||||
return () => {
|
||||
media.removeEventListener('change', syncMotionPreference)
|
||||
clearScheduled()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarHotspots />
|
||||
<div
|
||||
ref={regionRef}
|
||||
className='absolute top-[0.95%] right-[0.55%] bottom-[0.95%] left-[19.45%] overflow-hidden rounded-[4px]'
|
||||
>
|
||||
<div
|
||||
className='flex origin-top-left'
|
||||
style={{
|
||||
width: CHROME_INTERIOR.width,
|
||||
height: CHROME_INTERIOR.height,
|
||||
transform: `scale(${scale})`,
|
||||
}}
|
||||
>
|
||||
<div className='pointer-events-none relative h-full min-w-0 flex-1'>
|
||||
<HeroChatLoop phase={phase} fading={fading} />
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'h-full shrink-0 overflow-hidden border-[var(--border)] bg-[var(--bg)] transition-[width,min-width,border-width] duration-200 ease-[cubic-bezier(0.25,0.1,0.25,1)]',
|
||||
stageOpen ? 'w-1/2 border-l' : 'w-0 min-w-0 border-l-0'
|
||||
)}
|
||||
>
|
||||
<HeroWorkflowStage key={cycleId} builtCount={builtCount} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
'use client'
|
||||
|
||||
import { type CSSProperties, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { cn } from '@sim/emcn'
|
||||
import { StageBlockCard } from '@/app/(landing)/components/hero/components/hero-platform-loop/stage-block-card'
|
||||
import {
|
||||
handleAnchors,
|
||||
STAGE_BLOCKS,
|
||||
STAGE_CANVAS,
|
||||
STAGE_EDGES,
|
||||
verticalSmoothStep,
|
||||
} from '@/app/(landing)/components/hero/components/hero-platform-loop/stage-data'
|
||||
import {
|
||||
BLOCK_WIDTH,
|
||||
type BlockDef,
|
||||
} from '@/app/(landing)/components/hero/components/hero-visual/workflow-data'
|
||||
|
||||
/** Upper bound on the canvas render scale (the scale at the full 1300px cap). */
|
||||
const MAX_STAGE_SCALE = 0.71
|
||||
/** Breathing room between the canvas bounds and the card edges, in card px. */
|
||||
const STAGE_MARGIN = 20
|
||||
|
||||
interface HeroWorkflowStageProps {
|
||||
/** How many of the stage's blocks (in build order) are on canvas. */
|
||||
builtCount: number
|
||||
/** Blocks to stage, in build order. Defaults to the homepage's lead flow. */
|
||||
blocks?: BlockDef[]
|
||||
/** Source → target pairs among {@link blocks}. Defaults with them. */
|
||||
edges?: ReadonlyArray<readonly [string, string]>
|
||||
/** Design-space bounding box of the block layout. Defaults with them. */
|
||||
canvas?: { width: number; height: number }
|
||||
}
|
||||
|
||||
/**
|
||||
* The hero window's live workflow canvas - the right-pane counterpart of the
|
||||
* chat loop. Blocks pop in one by one as `builtCount` advances (staggered
|
||||
* scale/fade entrances, edges stroke-draw once both endpoints exist) at their
|
||||
* fixed positions. The edge SVG is `overflow-visible` - SVGs clip
|
||||
* at their viewport by default, which would cut the lines if a block ever sat
|
||||
* outside the design-canvas bounds.
|
||||
*
|
||||
* Decorative and `aria-hidden` (via the parent frame), so blocks are NOT
|
||||
* draggable - `pointer-events-none`, matching the rest of the hero animation.
|
||||
*
|
||||
* Blocks reuse the hero-visual's {@link WorkflowBlockContent} (the faithful
|
||||
* icon-tile + rows card body) in a card shell with vertical-flow handle nubs
|
||||
* (top in / bottom out), matching the real editor's vertical layout.
|
||||
*
|
||||
* The staged flow is injectable (`blocks`/`edges`/`canvas`), defaulting to the
|
||||
* homepage's lead-enrichment flow - the enterprise loop stages its own flow
|
||||
* through the same component.
|
||||
*/
|
||||
export function HeroWorkflowStage({
|
||||
builtCount,
|
||||
blocks = STAGE_BLOCKS,
|
||||
edges = STAGE_EDGES,
|
||||
canvas = STAGE_CANVAS,
|
||||
}: HeroWorkflowStageProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [scale, setScale] = useState(MAX_STAGE_SCALE)
|
||||
const blocksById = useMemo(() => new Map(blocks.map((b) => [b.id, b])), [blocks])
|
||||
|
||||
// Fit the design canvas to the card: scale down when the pane narrows so the
|
||||
// branch blocks never clip, capped at the full-width scale. Measures LAYOUT
|
||||
// size (offsetWidth/Height) - the stage lives inside the platform loop's
|
||||
// scale-transformed design-space layer, and getBoundingClientRect's visual
|
||||
// size would compound that outer scale into a double shrink.
|
||||
useLayoutEffect(() => {
|
||||
const el = containerRef.current
|
||||
if (!el) return
|
||||
const measure = () => {
|
||||
const w = el.offsetWidth
|
||||
const h = el.offsetHeight
|
||||
if (w < 40 || h < 40) return
|
||||
setScale(
|
||||
Math.min(
|
||||
MAX_STAGE_SCALE,
|
||||
(w - STAGE_MARGIN) / canvas.width,
|
||||
(h - STAGE_MARGIN) / canvas.height
|
||||
)
|
||||
)
|
||||
}
|
||||
measure()
|
||||
const ro = new ResizeObserver(measure)
|
||||
ro.observe(el)
|
||||
return () => ro.disconnect()
|
||||
}, [canvas.width, canvas.height])
|
||||
|
||||
const builtIds = useMemo(
|
||||
() => new Set(blocks.slice(0, builtCount).map((b) => b.id)),
|
||||
[blocks, builtCount]
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className='flex h-full w-full items-center justify-center overflow-hidden'
|
||||
>
|
||||
<div
|
||||
className='relative shrink-0'
|
||||
style={{
|
||||
width: canvas.width * scale,
|
||||
height: canvas.height * scale,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className='absolute top-0 left-0'
|
||||
style={{
|
||||
width: canvas.width,
|
||||
height: canvas.height,
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: '0 0',
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
className='pointer-events-none absolute inset-0 overflow-visible'
|
||||
width={canvas.width}
|
||||
height={canvas.height}
|
||||
viewBox={`0 0 ${canvas.width} ${canvas.height}`}
|
||||
fill='none'
|
||||
aria-hidden='true'
|
||||
>
|
||||
{edges.map(([from, to]) => {
|
||||
const source = blocksById.get(from)
|
||||
const target = blocksById.get(to)
|
||||
if (!source || !target) return null
|
||||
const visible = builtIds.has(from) && builtIds.has(to)
|
||||
const s = handleAnchors(source).out
|
||||
const t = handleAnchors(target).in
|
||||
return (
|
||||
<path
|
||||
key={`${from}-${to}`}
|
||||
d={verticalSmoothStep(s.x, s.y, t.x, t.y)}
|
||||
pathLength={1}
|
||||
stroke='var(--workflow-edge)'
|
||||
strokeWidth={2}
|
||||
strokeLinecap='round'
|
||||
className='transition-[stroke-dashoffset] duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] [stroke-dasharray:1]'
|
||||
style={{ strokeDashoffset: visible ? 0 : 1 } as CSSProperties}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{blocks.map((block) => {
|
||||
const built = builtIds.has(block.id)
|
||||
return (
|
||||
<div
|
||||
key={block.id}
|
||||
className={cn(
|
||||
'pointer-events-none absolute transition-[opacity,scale] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]',
|
||||
built ? 'scale-100 opacity-100' : 'scale-[0.94] opacity-0'
|
||||
)}
|
||||
style={{ left: block.x, top: block.y, width: BLOCK_WIDTH }}
|
||||
>
|
||||
<StageBlockCard block={block} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { HeroPlatformLoop } from './hero-platform-loop'
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
'use client'
|
||||
|
||||
import { Tooltip } from '@sim/emcn'
|
||||
|
||||
/**
|
||||
* Cursor-to-bubble gap for tooltips over the mini platform UI - tighter than
|
||||
* the product-standard 16px so the bubble hugs the cursor proportionately to
|
||||
* the scaled-down preview.
|
||||
*/
|
||||
export const HERO_TOOLTIP_OFFSET = 8
|
||||
|
||||
/** Percent-of-image bounds shared by both hotspot kinds. */
|
||||
interface HotspotBounds {
|
||||
left: string
|
||||
top: string
|
||||
width: string
|
||||
height: string
|
||||
}
|
||||
|
||||
/** An icon control that shows a tooltip on hover, like the real product. */
|
||||
interface TooltipHotspot extends HotspotBounds {
|
||||
label: string
|
||||
}
|
||||
|
||||
/** A sidebar nav row that shows the real row-hover highlight on hover. */
|
||||
interface RowHotspot extends HotspotBounds {
|
||||
name: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Icon controls measured from the capture (2560x1470): the collapse-sidebar
|
||||
* toggle (x428-468, y36-72) and the Workflows section's "More actions"
|
||||
* ellipsis (x376-412) and "Create workflow" plus (x424-460) at y920-952.
|
||||
* Copy matches the real product's tooltips.
|
||||
*/
|
||||
const TOOLTIP_HOTSPOTS: TooltipHotspot[] = [
|
||||
{ label: 'Collapse sidebar', left: '16.72%', top: '2.45%', width: '1.56%', height: '2.45%' },
|
||||
{ label: 'More actions', left: '14.69%', top: '62.59%', width: '1.4%', height: '2.18%' },
|
||||
{ label: 'Create workflow', left: '16.56%', top: '62.59%', width: '1.4%', height: '2.18%' },
|
||||
]
|
||||
|
||||
/**
|
||||
* Workspace nav rows (Tables / Files / Knowledge base), boxed like the real
|
||||
* sidebar row (`h-[30px] rounded-lg`, text rows measured at y598-616, 662-680,
|
||||
* 726-750): a 60px-tall (at 2x) highlight box centered on each row's text,
|
||||
* starting left of the row ICON (icons measured at x37-39, the box carries the
|
||||
* real row's 8px icon gutter) so the icon sits inside the highlight.
|
||||
*/
|
||||
const ROW_HOTSPOTS: RowHotspot[] = [
|
||||
{ name: 'Tables', left: '0.82%', top: '39.25%', width: '17.15%', height: '4.08%' },
|
||||
{ name: 'Files', left: '0.82%', top: '43.61%', width: '17.15%', height: '4.08%' },
|
||||
{ name: 'Knowledge base', left: '0.82%', top: '48.16%', width: '17.15%', height: '4.08%' },
|
||||
]
|
||||
|
||||
/**
|
||||
* Hover layers over the BAKED sidebar pixels. Icon controls (collapse toggle,
|
||||
* Workflows more/create) get transparent targets wired to the emcn
|
||||
* {@link Tooltip} with the product's real copy ({@link HERO_TOOLTIP_OFFSET}
|
||||
* keeps the bubble close over the mini UI). The Workspace nav rows instead
|
||||
* reproduce the REAL sidebar row-hover state - `--surface-active` on the
|
||||
* row's rounded box - via `mix-blend-multiply`, which paints over the baked
|
||||
* white/text pixels to exactly the result of a background behind the text.
|
||||
*/
|
||||
export function SidebarHotspots() {
|
||||
return (
|
||||
<div aria-hidden='true' className='pointer-events-none absolute inset-0'>
|
||||
{TOOLTIP_HOTSPOTS.map((spot) => (
|
||||
<Tooltip.Root key={spot.label}>
|
||||
<Tooltip.Trigger asChild>
|
||||
<span
|
||||
aria-label={spot.label}
|
||||
className='pointer-events-auto absolute block'
|
||||
style={{ left: spot.left, top: spot.top, width: spot.width, height: spot.height }}
|
||||
/>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content offset={HERO_TOOLTIP_OFFSET}>{spot.label}</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
))}
|
||||
{ROW_HOTSPOTS.map((row) => (
|
||||
<span
|
||||
key={row.name}
|
||||
aria-label={row.name}
|
||||
className='pointer-events-auto absolute block cursor-pointer rounded-[6px] mix-blend-multiply transition-colors duration-100 hover:bg-[var(--surface-active)]'
|
||||
style={{ left: row.left, top: row.top, width: row.width, height: row.height }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import { WorkflowBlockContent } from '@/app/(landing)/components/hero/components/hero-visual/workflow-block-content'
|
||||
import type { BlockDef } from '@/app/(landing)/components/hero/components/hero-visual/workflow-data'
|
||||
|
||||
interface StageBlockCardProps {
|
||||
block: BlockDef
|
||||
}
|
||||
|
||||
/**
|
||||
* Block card shell for the vertical-flow stage - the hero-visual's faithful
|
||||
* {@link WorkflowBlockContent} body with top (incoming) / bottom (outgoing)
|
||||
* handle nubs instead of the horizontal-flow left/right ones. Shared by the
|
||||
* hero's live workflow stage and the Build feature card's workflow peek.
|
||||
*/
|
||||
export function StageBlockCard({ block }: StageBlockCardProps) {
|
||||
return (
|
||||
<div className='relative rounded-[13px] border border-[var(--border-1)] bg-[var(--surface-2)] shadow-sm'>
|
||||
<WorkflowBlockContent block={block} />
|
||||
{!block.isTrigger && (
|
||||
<span
|
||||
aria-hidden
|
||||
className='-translate-x-1/2 absolute top-[-7px] left-1/2 h-[7px] w-5 rounded-t-[2px] bg-[var(--workflow-edge)]'
|
||||
/>
|
||||
)}
|
||||
{!block.isTerminal && (
|
||||
<span
|
||||
aria-hidden
|
||||
className='-translate-x-1/2 absolute bottom-[-7px] left-1/2 h-[7px] w-5 rounded-b-[2px] bg-[var(--workflow-edge)]'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { AgentIcon, CodeIcon, SlackIcon, StartIcon, TableIcon } from '@/components/icons'
|
||||
import type { BlockDef } from '@/app/(landing)/components/hero/components/hero-visual/workflow-data'
|
||||
import { BLOCK_WIDTH } from '@/app/(landing)/components/hero/components/hero-visual/workflow-data'
|
||||
|
||||
/**
|
||||
* Design-space geometry for the hero's live workflow stage - the lead-enrichment
|
||||
* flow the chat conversation "builds": Start feeds the enrichment agent, a
|
||||
* scoring function follows, and the flow fans out to Slack and Tables. Block
|
||||
* tiles use the platform's grey text ramp (each block a different shade, dark
|
||||
* enough to carry the white glyph) - color is reserved for REAL third-party
|
||||
* marks, so only Slack keeps its brand tile (#611F69).
|
||||
*
|
||||
* Blocks are ordered by build sequence - the stage reveals `blocks[0..built-1]`
|
||||
* as the loop's build counter advances, and an edge draws once both its
|
||||
* endpoints are on canvas.
|
||||
*/
|
||||
export const STAGE_BLOCKS: BlockDef[] = [
|
||||
{
|
||||
id: 'start',
|
||||
name: 'Start',
|
||||
icon: StartIcon,
|
||||
bgColor: 'var(--text-muted)',
|
||||
isTrigger: true,
|
||||
rows: [{ title: 'Inputs', value: '-' }],
|
||||
x: 155,
|
||||
y: 12,
|
||||
},
|
||||
{
|
||||
id: 'enrich',
|
||||
name: 'Enrich lead',
|
||||
icon: AgentIcon,
|
||||
bgColor: 'var(--text-primary)',
|
||||
rows: [
|
||||
{ title: 'Messages', value: '-' },
|
||||
{ title: 'Model', value: '-' },
|
||||
{ title: 'Files', value: '-' },
|
||||
],
|
||||
x: 155,
|
||||
y: 172,
|
||||
},
|
||||
{
|
||||
id: 'score',
|
||||
name: 'Score fit',
|
||||
icon: CodeIcon,
|
||||
bgColor: 'var(--text-secondary)',
|
||||
rows: [
|
||||
{ title: 'Code', value: '-' },
|
||||
{ title: 'Timeout', value: '-' },
|
||||
],
|
||||
x: 155,
|
||||
y: 390,
|
||||
},
|
||||
{
|
||||
id: 'slack',
|
||||
name: 'Post to #sales',
|
||||
icon: SlackIcon,
|
||||
bgColor: '#611F69',
|
||||
isTerminal: true,
|
||||
rows: [
|
||||
{ title: 'Channel', value: '-' },
|
||||
{ title: 'Message', value: '-' },
|
||||
],
|
||||
x: 0,
|
||||
y: 580,
|
||||
},
|
||||
{
|
||||
id: 'tables',
|
||||
name: 'Save to Tables',
|
||||
icon: TableIcon,
|
||||
bgColor: 'var(--text-body)',
|
||||
isTerminal: true,
|
||||
rows: [
|
||||
{ title: 'Table', value: '-' },
|
||||
{ title: 'Operation', value: '-' },
|
||||
],
|
||||
x: 310,
|
||||
y: 580,
|
||||
},
|
||||
]
|
||||
|
||||
/** Source → target pairs, drawn in order as their endpoints land on canvas. */
|
||||
export const STAGE_EDGES: ReadonlyArray<readonly [string, string]> = [
|
||||
['start', 'enrich'],
|
||||
['enrich', 'score'],
|
||||
['score', 'slack'],
|
||||
['score', 'tables'],
|
||||
]
|
||||
|
||||
/** Design-space bounding box of the layout above. */
|
||||
export const STAGE_CANVAS = { width: 560, height: 700 } as const
|
||||
|
||||
/**
|
||||
* Approximate rendered block height - the icon-tile header (~40px) plus the
|
||||
* rows section (16px padding + 21px per row + 8px gaps). Used to place a
|
||||
* block's bottom (outgoing) handle; a few px of drift is invisible at stage
|
||||
* scale.
|
||||
*/
|
||||
export function blockHeight(block: BlockDef): number {
|
||||
const n = block.rows.length
|
||||
return 40 + (n > 0 ? 16 + n * 21 + (n - 1) * 8 : 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Rounded orthogonal ("smoothstep") path for a VERTICAL flow - from a source's
|
||||
* bottom-center handle to a target's top-center handle, stepping at the
|
||||
* vertical midpoint with `r`-radius corners. The horizontal-flow counterpart
|
||||
* lives in `hero-visual/workflow-data.ts`.
|
||||
*/
|
||||
export function verticalSmoothStep(sx: number, sy: number, tx: number, ty: number, r = 8): string {
|
||||
if (Math.abs(tx - sx) < 1) return `M ${sx} ${sy} L ${tx} ${ty}`
|
||||
const midY = (sy + ty) / 2
|
||||
const dir = tx >= sx ? 1 : -1
|
||||
return [
|
||||
`M ${sx} ${sy}`,
|
||||
`L ${sx} ${midY - r}`,
|
||||
`Q ${sx} ${midY} ${sx + dir * r} ${midY}`,
|
||||
`L ${tx - dir * r} ${midY}`,
|
||||
`Q ${tx} ${midY} ${tx} ${midY + r}`,
|
||||
`L ${tx} ${ty}`,
|
||||
].join(' ')
|
||||
}
|
||||
|
||||
/** Handle anchor points for a block at its fixed position. */
|
||||
export function handleAnchors(block: BlockDef) {
|
||||
return {
|
||||
out: { x: block.x + BLOCK_WIDTH / 2, y: block.y + blockHeight(block) },
|
||||
in: { x: block.x + BLOCK_WIDTH / 2, y: block.y },
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/* Plain fade-in for the hero stat number - one smooth opacity ramp. */
|
||||
@keyframes fade-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.fadeIn {
|
||||
animation: fade-in 900ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
|
||||
/*
|
||||
* Per-character entrance for the placeholder zeros: the leading "0" fades in
|
||||
* first, then each following character staggers in left to right (70ms per
|
||||
* character; the 11th lands at 700ms + 500ms fade = fully in by ~1.2s).
|
||||
*/
|
||||
.char {
|
||||
animation: fade-in 500ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
|
||||
.char:nth-child(2) {
|
||||
animation-delay: 70ms;
|
||||
}
|
||||
|
||||
.char:nth-child(3) {
|
||||
animation-delay: 140ms;
|
||||
}
|
||||
|
||||
.char:nth-child(4) {
|
||||
animation-delay: 210ms;
|
||||
}
|
||||
|
||||
.char:nth-child(5) {
|
||||
animation-delay: 280ms;
|
||||
}
|
||||
|
||||
.char:nth-child(6) {
|
||||
animation-delay: 350ms;
|
||||
}
|
||||
|
||||
.char:nth-child(7) {
|
||||
animation-delay: 420ms;
|
||||
}
|
||||
|
||||
.char:nth-child(8) {
|
||||
animation-delay: 490ms;
|
||||
}
|
||||
|
||||
.char:nth-child(9) {
|
||||
animation-delay: 560ms;
|
||||
}
|
||||
|
||||
.char:nth-child(10) {
|
||||
animation-delay: 630ms;
|
||||
}
|
||||
|
||||
.char:nth-child(11) {
|
||||
animation-delay: 700ms;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.fadeIn,
|
||||
.char {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { cn } from '@sim/emcn'
|
||||
import styles from '@/app/(landing)/components/hero/components/hero-stat/hero-stat.module.css'
|
||||
|
||||
const STAT_VALUE = '0.00000206%'
|
||||
/** The last-three-digit count-up range: 000 -> 206 (Stripe-style iterate). */
|
||||
const COUNT_FROM = 0
|
||||
const COUNT_TO = 206
|
||||
/** Duration of the count-up once it starts. */
|
||||
const COUNT_DUR_MS = 1100
|
||||
/** The LIGHT-GREY placeholder zeros the number staggers in as, char by char. */
|
||||
const START_CHARS = `0.00000${String(COUNT_FROM).padStart(3, '0')}%`.split('')
|
||||
/**
|
||||
* When the per-character stagger has fully landed (last char starts at 700ms
|
||||
* + its 500ms fade - see hero-stat.module.css).
|
||||
*/
|
||||
const CHARS_IN_AT_MS = 1200
|
||||
/**
|
||||
* The settle beat starts once the character stagger completes, plus a short
|
||||
* hold so the number reads before it rises.
|
||||
*/
|
||||
const SETTLE_AT_MS = CHARS_IN_AT_MS + 150
|
||||
/**
|
||||
* The label reveal waits until the rising number (500ms ease-out) has cleared
|
||||
* the label's row, so the two never overlap mid-flight.
|
||||
*/
|
||||
const REVEAL_AT_MS = SETTLE_AT_MS + 400
|
||||
/** The dark fill grows in last, once the label's fade-up has finished. */
|
||||
const FILL_AT_MS = REVEAL_AT_MS + 350
|
||||
|
||||
/**
|
||||
* The hero's right-side stat, with a staggered page-load entrance in four
|
||||
* beats: the number appears at the bottom of the stat as LIGHT-GREY
|
||||
* placeholder zeros - the leading "0" fades in first, then each following
|
||||
* character staggers in left to right - alongside the progress rail (short,
|
||||
* bottom-anchored, spanning exactly the number's 18px line), the number rises
|
||||
* into its final slot while the rail GROWS upward at the same rate (same
|
||||
* duration + easing, so its top edge tracks the number's top), then the
|
||||
* "Global work done by Sim" label fades up beneath it while the number
|
||||
* DARKENS to `--text-primary` and the last digits COUNT UP 000 -> 206
|
||||
* (Stripe-style, ease-out so the ticks slow into the final value), and
|
||||
* finally the dark progress fill grows up from the rail's foot.
|
||||
*
|
||||
* Under `prefers-reduced-motion` everything renders settled immediately (the
|
||||
* fades are also disabled in the CSS module).
|
||||
*/
|
||||
export function HeroStat() {
|
||||
const [settled, setSettled] = useState(false)
|
||||
const [revealed, setRevealed] = useState(false)
|
||||
const [filled, setFilled] = useState(false)
|
||||
const [count, setCount] = useState(COUNT_FROM)
|
||||
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia('(prefers-reduced-motion: reduce)')
|
||||
let raf = 0
|
||||
let settleTimer: ReturnType<typeof setTimeout>
|
||||
let revealTimer: ReturnType<typeof setTimeout>
|
||||
let fillTimer: ReturnType<typeof setTimeout>
|
||||
let countTimer: ReturnType<typeof setTimeout>
|
||||
|
||||
const clearScheduled = () => {
|
||||
clearTimeout(settleTimer)
|
||||
clearTimeout(revealTimer)
|
||||
clearTimeout(fillTimer)
|
||||
clearTimeout(countTimer)
|
||||
cancelAnimationFrame(raf)
|
||||
}
|
||||
|
||||
const showSettled = () => {
|
||||
clearScheduled()
|
||||
setSettled(true)
|
||||
setRevealed(true)
|
||||
setFilled(true)
|
||||
setCount(COUNT_TO)
|
||||
}
|
||||
|
||||
const runEntrance = () => {
|
||||
setSettled(false)
|
||||
setRevealed(false)
|
||||
setFilled(false)
|
||||
setCount(COUNT_FROM)
|
||||
settleTimer = setTimeout(() => setSettled(true), SETTLE_AT_MS)
|
||||
revealTimer = setTimeout(() => setRevealed(true), REVEAL_AT_MS)
|
||||
fillTimer = setTimeout(() => setFilled(true), FILL_AT_MS)
|
||||
countTimer = setTimeout(() => {
|
||||
const start = performance.now()
|
||||
const tick = (now: number) => {
|
||||
const t = Math.min((now - start) / COUNT_DUR_MS, 1)
|
||||
const eased = 1 - (1 - t) ** 3
|
||||
setCount(Math.round(COUNT_FROM + (COUNT_TO - COUNT_FROM) * eased))
|
||||
if (t < 1) raf = requestAnimationFrame(tick)
|
||||
}
|
||||
raf = requestAnimationFrame(tick)
|
||||
}, REVEAL_AT_MS)
|
||||
}
|
||||
|
||||
// Re-synced on 'change' - not just on mount - so toggling the preference
|
||||
// mid-entrance snaps straight to the settled state instead of leaving the
|
||||
// stagger/count-up to keep running on its own clock.
|
||||
const syncMotionPreference = () => {
|
||||
clearScheduled()
|
||||
if (media.matches) {
|
||||
showSettled()
|
||||
return
|
||||
}
|
||||
runEntrance()
|
||||
}
|
||||
|
||||
syncMotionPreference()
|
||||
media.addEventListener('change', syncMotionPreference)
|
||||
return () => {
|
||||
media.removeEventListener('change', syncMotionPreference)
|
||||
clearScheduled()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className='flex w-fit shrink-0 items-stretch gap-3 max-lg:hidden'>
|
||||
<div aria-hidden='true' className={cn('relative w-[2px]', styles.fadeIn)}>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-x-0 bottom-0 flex flex-col justify-end rounded-full bg-[var(--surface-6)] transition-[top] duration-500',
|
||||
settled ? 'top-0' : 'top-[calc(100%-18px)]'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'w-full rounded-full bg-[var(--text-body)] transition-[height] duration-300',
|
||||
filled ? 'h-[4px]' : 'h-0'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
<p
|
||||
className={cn(
|
||||
'text-[18px] tabular-nums leading-none transition-[transform,color] duration-500',
|
||||
settled ? 'translate-y-0' : 'translate-y-[20px]',
|
||||
revealed ? 'text-[var(--text-primary)]' : 'text-[var(--surface-7)]'
|
||||
)}
|
||||
>
|
||||
<span className='sr-only'>{STAT_VALUE}</span>
|
||||
<span aria-hidden='true'>
|
||||
{revealed
|
||||
? `0.00000${String(count).padStart(3, '0')}%`
|
||||
: START_CHARS.map((ch, i) => (
|
||||
<span key={i} className={styles.char}>
|
||||
{ch}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
'text-[var(--text-muted)] text-sm leading-none transition-[opacity,transform] duration-300',
|
||||
revealed ? 'translate-y-0 opacity-100' : 'translate-y-2 opacity-0'
|
||||
)}
|
||||
>
|
||||
Global work done by Sim
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { HeroStat } from './hero-stat'
|
||||
@@ -0,0 +1,40 @@
|
||||
import { cn } from '@sim/emcn'
|
||||
import type { BlockDef } from '@/app/(landing)/components/hero/components/hero-visual/workflow-data'
|
||||
|
||||
interface BlockHandlesProps {
|
||||
block: BlockDef
|
||||
handlesVisible?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The decorative edge-handle nubs for a block - an inbound nub on the left
|
||||
* unless the block is a trigger, an outbound nub on the right unless it's
|
||||
* terminal. Absolutely positioned, so the caller must be a `relative` (or
|
||||
* otherwise positioned) box of the block's width. Shared so the morphed chat
|
||||
* card (GitHub, rendered as content-only) can carry the same handles as the
|
||||
* real {@link WorkflowBlock} satellites.
|
||||
*/
|
||||
export function BlockHandles({ block, handlesVisible = true }: BlockHandlesProps) {
|
||||
return (
|
||||
<>
|
||||
{!block.isTrigger && (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'-translate-y-1/2 absolute top-5 left-[-7px] h-5 w-[7px] rounded-l-[2px] bg-[var(--workflow-edge)] transition-opacity duration-[360ms] ease-[cubic-bezier(0.22,1,0.36,1)]',
|
||||
handlesVisible ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{!block.isTerminal && (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'-translate-y-1/2 absolute top-5 right-[-7px] h-5 w-[7px] rounded-r-[2px] bg-[var(--workflow-edge)] transition-opacity duration-[360ms] ease-[cubic-bezier(0.22,1,0.36,1)]',
|
||||
handlesVisible ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,413 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type CSSProperties,
|
||||
type RefObject,
|
||||
useCallback,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { cn } from '@sim/emcn'
|
||||
import { WorkflowBlockContent } from '@/app/(landing)/components/hero/components/hero-visual/workflow-block-content'
|
||||
import {
|
||||
ANSWER_TEXT,
|
||||
BLOCK_WIDTH,
|
||||
BLOCKS,
|
||||
HOME_GREETING,
|
||||
PROMPT_ATOMS,
|
||||
type PromptAtom,
|
||||
SEND_BUBBLE_ENTER_MS,
|
||||
SEND_BUBBLE_REVEAL_DELAY_MS,
|
||||
WORKFLOW_FOCUS_SCALE,
|
||||
} from '@/app/(landing)/components/hero/components/hero-visual/workflow-data'
|
||||
|
||||
/**
|
||||
* What the Mothership chat is showing right now. The travelling thinking loader
|
||||
* itself lives at the hero root (so it can outlive these layers and track its
|
||||
* target through the camera zoom + pan); this stage only lays out the card and
|
||||
* exposes the send button and reply slot as the loader's ref targets.
|
||||
*
|
||||
* - `compose` - greeting + prompt input (the typewriter).
|
||||
* - `morphing` - the instant after send: the disc morphs into the loader while
|
||||
* the typed prompt fades. The card holds its compose height (the input stays in
|
||||
* flow, just fading) so it never reshapes mid-morph - the disc just becomes the
|
||||
* loader in place.
|
||||
* - `sending` - the morph is done; the prompt is now a user bubble that animates
|
||||
* in above the loader, growing the card, while the camera holds zoomed in.
|
||||
* - `thinking` - conversation layout (user bubble + an empty reply slot whose
|
||||
* height the loader will occupy), shown as the loader slides/docks there.
|
||||
* - `answering` - the typed reply fills the reply slot.
|
||||
* - `block` - the card morphs into the first workflow block (the chat shell
|
||||
* resizes and its content becomes the block's), handing off to the workflow.
|
||||
*/
|
||||
export type HomeMode = 'compose' | 'morphing' | 'sending' | 'thinking' | 'answering' | 'block'
|
||||
|
||||
/** The first workflow block, shown inside the card during the morph. */
|
||||
const FIRST_BLOCK = BLOCKS[0]
|
||||
/** GitHub block content's natural (unscaled) height in px. */
|
||||
const GH_CONTENT_HEIGHT = 77
|
||||
/** The chat card's width (compose/conversation). */
|
||||
const CHAT_CARD_WIDTH = 460
|
||||
/**
|
||||
* Natural height of the compose card on first paint - `py-2` (16) + the input
|
||||
* row (32) + `gap-2` (8) + the action row (28). Seeds the card so it renders at
|
||||
* its true height immediately and never transitions/resizes on load; the
|
||||
* observer keeps it exact thereafter. Must track the content's padding/spacing.
|
||||
*/
|
||||
const COMPOSE_CARD_HEIGHT = 84
|
||||
/**
|
||||
* Card width/height once morphed into the first block (block content × focus
|
||||
* scale). Deliberately smaller than the chat card so the morph reads as a
|
||||
* visible SHRINK + reshape, not a same-size content crossfade.
|
||||
*/
|
||||
const BLOCK_CARD_WIDTH = BLOCK_WIDTH * WORKFLOW_FOCUS_SCALE
|
||||
const BLOCK_CARD_HEIGHT = GH_CONTENT_HEIGHT * WORKFLOW_FOCUS_SCALE
|
||||
|
||||
interface StageHomeProps {
|
||||
/** Which beat of the chat to render. */
|
||||
mode: HomeMode
|
||||
/** Prompt atoms the typewriter has revealed (compose mode). */
|
||||
typedCount: number
|
||||
/** Characters of the Mothership's reply revealed so far (answering mode). */
|
||||
answerTypedCount: number
|
||||
/** The prompt text region - the root cursor targets this for its "click in". */
|
||||
inputRef: RefObject<HTMLDivElement | null>
|
||||
/** The send button - the root cursor + travelling loader target this to "send". */
|
||||
sendRef: RefObject<HTMLDivElement | null>
|
||||
/** The dock - an invisible spot at the LEFT of the send-button row that the
|
||||
* loader slides to (same row, so the slide is purely horizontal and the card
|
||||
* never has to resize for it). */
|
||||
dockRef: RefObject<HTMLDivElement | null>
|
||||
/**
|
||||
* The white card element. The parent measures it and, during the send beat
|
||||
* (`sending`), drives its height per-frame via the `--hero-card-h` CSS variable
|
||||
* - in lockstep with the camera pan - so the grow-to-fit-the-bubble can't shake
|
||||
* the card the way a CSS height transition fighting the rAF loop would.
|
||||
*/
|
||||
cardRef: RefObject<HTMLDivElement | null>
|
||||
/**
|
||||
* Reveal the greeting headline. Held back until the input is being composed,
|
||||
* then fades in on the `hero-stage-fade` keyframe, slowed to 1.2s (≈3× the
|
||||
* scene's stock 420ms) so the headline eases in gently. Its space is always
|
||||
* reserved, so revealing it never shifts the layout.
|
||||
*/
|
||||
showGreeting?: boolean
|
||||
/** Press the send button (scale it down) - driven by the click beat. */
|
||||
pressed?: boolean
|
||||
}
|
||||
|
||||
/** Staggered enter for a chat bubble - translateY + opacity + blur, interruptible. */
|
||||
const ENTER_BASE = 'transition-[opacity,transform,filter] ease-[cubic-bezier(0.2,0,0,1)]'
|
||||
const enterState = (shown: boolean) =>
|
||||
shown ? 'translate-y-0 opacity-100 blur-0' : 'translate-y-1.5 opacity-0 blur-[3px]'
|
||||
|
||||
const Caret = () => (
|
||||
<span
|
||||
className='ml-px inline-block h-[16px] w-px translate-y-[2px] animate-caret-blink bg-[var(--text-primary)]'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
)
|
||||
|
||||
/** Renders prompt atoms (plain chars + inline `@mention` icon-chips). */
|
||||
function PromptAtoms({ atoms }: { atoms: PromptAtom[] }) {
|
||||
return (
|
||||
<>
|
||||
{atoms.map((atom, i) =>
|
||||
atom.kind === 'char' ? (
|
||||
<span key={`${i}-${atom.char}`}>{atom.char}</span>
|
||||
) : (
|
||||
<span key={`${i}-${atom.label}`}>
|
||||
<span className='relative'>
|
||||
<span className='invisible'>@</span>
|
||||
<atom.icon className='absolute inset-0 m-auto size-[12px] translate-y-[1.25px] text-[var(--text-icon)]' />
|
||||
</span>
|
||||
{atom.label}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The Mothership home stage - a single white card that morphs adaptively.
|
||||
*
|
||||
* Both content layers (the compose input and the conversation) are absolutely
|
||||
* stacked and TOP-anchored; the card measures whichever is active and animates
|
||||
* its own height to hug it (`transition-[height]`). This makes the card flexible
|
||||
* - it grows from the input to the reply, and from the thinking loader to the
|
||||
* typed answer - with no dead space and no fixed min-height. Top-anchoring keeps
|
||||
* the user bubble pinned, so the loader's label crossfade never shoves it; the
|
||||
* card `overflow-hidden`s any transient. Cursor is owned by the parent; this
|
||||
* stage only exposes the input and send button as ref targets. Decorative.
|
||||
*/
|
||||
export function StageHome({
|
||||
mode,
|
||||
typedCount,
|
||||
answerTypedCount,
|
||||
inputRef,
|
||||
sendRef,
|
||||
dockRef,
|
||||
cardRef,
|
||||
showGreeting = false,
|
||||
pressed = false,
|
||||
}: StageHomeProps) {
|
||||
const isCompose = mode === 'compose'
|
||||
const isMorphing = mode === 'morphing'
|
||||
const isBlock = mode === 'block'
|
||||
const isAnswering = mode === 'answering'
|
||||
// The prompt input holds the card's compose height through the disc→loader morph
|
||||
// (`compose`/`morphing`), then fades to a right-aligned bubble once send lands.
|
||||
const inputInFlow = isCompose || isMorphing
|
||||
// The typed prompt is a right-aligned chat bubble from the moment send is hit
|
||||
// (`sending`) onward.
|
||||
const showBubble = !isCompose && !isMorphing && !isBlock
|
||||
// While the bubble first appears (`sending`), the PARENT drives the card height
|
||||
// per-frame (via `--hero-card-h`) in lockstep with the camera, so the grow can't
|
||||
// shake. Every other beat lets the card own + CSS-transition its own height.
|
||||
const growControlled = mode === 'sending'
|
||||
// Hold the card height steady - no CSS height-transition - through both the
|
||||
// parent-driven grow (`sending`) AND the loader slide + camera track
|
||||
// (`thinking`). The slide pans the camera by measuring the dock's live
|
||||
// position each frame and assumes the card holds its size; transitioning
|
||||
// height there makes the card resize under that measurement, so it jitters.
|
||||
const holdCardHeight = growControlled || mode === 'thinking'
|
||||
const contentRef = useRef<HTMLDivElement>(null)
|
||||
// Seeded at the natural compose height so the send button is never clipped on
|
||||
// the very first paint; the observer below keeps it exact from then on.
|
||||
const [cardHeight, setCardHeight] = useState(COMPOSE_CARD_HEIGHT)
|
||||
|
||||
// Hug the chat column: measure its natural height and animate the card to it.
|
||||
// Guard against a collapsed/unpainted layout (width ~0) measuring a
|
||||
// wildly-wrapped height and poisoning the card size.
|
||||
const measure = useCallback(() => {
|
||||
const el = contentRef.current
|
||||
if (el && el.offsetWidth > 120) setCardHeight(el.offsetHeight)
|
||||
}, [])
|
||||
// Synchronous, pre-paint, on every beat that reshapes the content (prompt typing
|
||||
// out, input→bubble swap, reply filling in) - so the card grows in lockstep with
|
||||
// no lag. In block mode the card takes a fixed height (`isBlock` branch on the
|
||||
// style), so the measured value is simply ignored there.
|
||||
useLayoutEffect(() => {
|
||||
measure()
|
||||
}, [measure, mode, typedCount, answerTypedCount])
|
||||
// Robust backstop: also re-fit on first-paint layout, font load, or any other
|
||||
// reflow the beat-driven pass can't see - so the send button is never clipped.
|
||||
useLayoutEffect(() => {
|
||||
const el = contentRef.current
|
||||
if (!el) return
|
||||
const observer = new ResizeObserver(measure)
|
||||
observer.observe(el)
|
||||
return () => observer.disconnect()
|
||||
}, [measure])
|
||||
|
||||
const visible = PROMPT_ATOMS.slice(0, typedCount)
|
||||
const isEmpty = typedCount === 0
|
||||
const answer = ANSWER_TEXT.slice(0, answerTypedCount)
|
||||
const bubbleTransitionStyle = {
|
||||
transitionDelay: mode === 'sending' ? `${SEND_BUBBLE_REVEAL_DELAY_MS}ms` : '0ms',
|
||||
transitionDuration: `${SEND_BUBBLE_ENTER_MS}ms`,
|
||||
} satisfies CSSProperties
|
||||
|
||||
return (
|
||||
<div className='flex h-full w-full flex-col items-center justify-center px-10'>
|
||||
<div className='flex w-full max-w-[460px] flex-col gap-5'>
|
||||
{/* Greeting - visible while composing, then fades. Its SPACE is held
|
||||
through the sending/thinking scene so collapsing it never re-centres
|
||||
the column and shifts the card the camera is locked onto; it only
|
||||
truly collapses once the reply takes over (answering) or the card
|
||||
morphs to a block. */}
|
||||
<div
|
||||
className={cn(
|
||||
'overflow-hidden text-center transition-[height,opacity] duration-300 ease-[cubic-bezier(0.23,1,0.32,1)]',
|
||||
mode === 'compose'
|
||||
? 'h-[40px] opacity-100'
|
||||
: mode === 'sending' || mode === 'thinking'
|
||||
? 'h-[40px] opacity-0'
|
||||
: 'h-0 opacity-0'
|
||||
)}
|
||||
>
|
||||
{showGreeting ? (
|
||||
<p className='text-[30px] text-[var(--text-primary)] leading-[40px] [animation:hero-stage-fade_1200ms_cubic-bezier(0.23,1,0.32,1)_both]'>
|
||||
{HOME_GREETING}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* White card - the SAME shell throughout: it hugs the active chat content,
|
||||
then resizes and rounds down to become the first workflow block (the
|
||||
chat → workflow morph happens on this one continuous element). */}
|
||||
<div
|
||||
ref={cardRef}
|
||||
className={cn(
|
||||
// The chat input's aesthetic (rounded-2xl, border, soft shadow) is
|
||||
// kept THROUGHOUT - including once morphed into the workflow block -
|
||||
// so it stays the same white card, just resized.
|
||||
'relative mx-auto overflow-hidden rounded-2xl border border-[var(--border-1)] bg-[var(--surface-2)] shadow-sm',
|
||||
// While the height is held (the parent-driven send-bubble grow, and the
|
||||
// loader slide where the card keeps its size), do NOT CSS-transition
|
||||
// height - a transition would fight the per-frame camera/dock tracking
|
||||
// and read as jitter. Every other beat eases width/height/transform with
|
||||
// the shared curve.
|
||||
holdCardHeight
|
||||
? 'transition-[width,transform] duration-[620ms] ease-[cubic-bezier(0.22,1,0.36,1)]'
|
||||
: 'transition-[width,height,transform] duration-[620ms] ease-[cubic-bezier(0.22,1,0.36,1)]',
|
||||
// Nudge up to cancel the chat column's greeting+gap offset, so the
|
||||
// card centers exactly where the focused workflow block lands.
|
||||
isBlock && '-translate-y-[10px]'
|
||||
)}
|
||||
style={{
|
||||
width: isBlock ? BLOCK_CARD_WIDTH : CHAT_CARD_WIDTH,
|
||||
// During the grow, follow the parent-driven variable (seeded to the
|
||||
// compose height so there's never a collapsed first frame).
|
||||
height: isBlock
|
||||
? BLOCK_CARD_HEIGHT
|
||||
: growControlled
|
||||
? `var(--hero-card-h, ${COMPOSE_CARD_HEIGHT}px)`
|
||||
: cardHeight,
|
||||
}}
|
||||
>
|
||||
{/* Chat content - ONE column: the user message (the typewriter input,
|
||||
then the sent bubble) over an action row (the send button + the
|
||||
loader's dock, then the reply). The column stays anchored to the
|
||||
BOTTOM through the send-bubble grow AND the loader slide (the whole
|
||||
`holdCardHeight` window): during the grow the card expands UPWARD to
|
||||
reveal the bubble, and through the slide the bottom edge never moves -
|
||||
so the dock/send button the camera tracks can't shift as the height
|
||||
source switches. Re-anchoring there would nudge them a sub-pixel and
|
||||
the slide's camera would chase it (a visible jitter). Every other beat
|
||||
is top-anchored (content fills the card, so it reads the same either
|
||||
way), which keeps the block-morph and the reply grow behaving as before. */}
|
||||
<div
|
||||
ref={contentRef}
|
||||
className={cn(
|
||||
'absolute inset-x-0 flex flex-col gap-2 px-2.5 py-2 transition-opacity duration-[220ms] ease-[cubic-bezier(0.23,1,0.32,1)]',
|
||||
holdCardHeight ? 'bottom-0' : 'top-0',
|
||||
isBlock && 'pointer-events-none opacity-0'
|
||||
)}
|
||||
>
|
||||
{/* Message: the typewriter input while composing + morphing, then it
|
||||
fades and a right-aligned user bubble animates in once `sending`
|
||||
begins. The input stays in flow (holding the compose height) right
|
||||
through the morph, so the card never reshapes as the disc becomes
|
||||
the loader; the bubble takes over the flow only after, growing the
|
||||
card in one clean transition. */}
|
||||
<div className='relative'>
|
||||
<div
|
||||
ref={inputRef}
|
||||
className={cn(
|
||||
'min-h-[24px] px-1 py-1 font-body text-[15px] text-[var(--text-primary)] leading-[24px] tracking-[-0.015em] transition-opacity duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]',
|
||||
isCompose ? 'opacity-100' : 'opacity-0',
|
||||
!inputInFlow && 'pointer-events-none absolute inset-x-0 top-0'
|
||||
)}
|
||||
>
|
||||
{isEmpty ? (
|
||||
<span className='font-[380] text-[var(--text-subtle)]'>
|
||||
Ask Sim to build an agent…
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<PromptAtoms atoms={visible} />
|
||||
<Caret />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
style={bubbleTransitionStyle}
|
||||
className={cn(
|
||||
'ml-auto w-fit max-w-[82%] rounded-2xl bg-[var(--surface-5)] px-3.5 py-2 font-body text-[15px] text-[var(--text-primary)] leading-[22px] tracking-[-0.015em]',
|
||||
ENTER_BASE,
|
||||
showBubble
|
||||
? enterState(true)
|
||||
: cn('pointer-events-none absolute top-0 right-0', enterState(false))
|
||||
)}
|
||||
>
|
||||
<PromptAtoms atoms={PROMPT_ATOMS} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action row: the loader's dock (left) and the send button (right).
|
||||
The dock is the loader's slide target; once answering, the reply
|
||||
types out from that same left edge as the loader fades, so the
|
||||
docked loader reads as the start of the reply. */}
|
||||
<div className='flex items-start justify-between gap-2'>
|
||||
<div className='min-w-0 flex-1'>
|
||||
{isAnswering ? (
|
||||
<p className='px-1 font-body text-[15px] text-[var(--text-primary)] leading-[22px] tracking-[-0.015em]'>
|
||||
{answer}
|
||||
<Caret />
|
||||
</p>
|
||||
) : (
|
||||
<div ref={dockRef} aria-hidden='true' className='size-[28px]' />
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
ref={sendRef}
|
||||
aria-hidden='true'
|
||||
className={cn(
|
||||
'flex size-[28px] shrink-0 items-center justify-center rounded-full bg-[#383838] transition-[opacity,transform,background-color] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)]',
|
||||
// Visible only while composing; once send is hit the root loader's
|
||||
// settled orb takes its place (it stays laid out as the loader's
|
||||
// measure + slide-from target).
|
||||
isCompose ? 'opacity-100' : 'opacity-0',
|
||||
// Subtle interaction: lighten on hover, dip in size on press -
|
||||
// both for a real cursor and for the animation's click beat.
|
||||
isCompose && 'hover:bg-[#484848] active:scale-90',
|
||||
pressed && 'scale-90'
|
||||
)}
|
||||
>
|
||||
{/* Up-arrow that draws itself on (shaft then head) via
|
||||
stroke-dashoffset - `pathLength={1}` normalizes the dash so
|
||||
one offset value covers the whole glyph. Retracts as send is
|
||||
hit so the disc is clean when the loader takes it over. */}
|
||||
<svg viewBox='0 0 24 24' fill='none' className='size-4' aria-hidden='true'>
|
||||
<path
|
||||
d='M12 19V5M5 12l7-7 7 7'
|
||||
pathLength={1}
|
||||
stroke='var(--surface-2)'
|
||||
strokeWidth={2}
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
className={cn(
|
||||
'transition-[stroke-dashoffset] duration-[520ms] ease-[cubic-bezier(0.23,1,0.32,1)] [stroke-dasharray:1]',
|
||||
isCompose ? '[stroke-dashoffset:0]' : '[stroke-dashoffset:1]'
|
||||
)}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Block layer: the card has morphed into the first workflow block.
|
||||
Same content as the workflow stage's focused block, at the focus
|
||||
scale, so handing off to the canvas is seamless. Top-anchored to
|
||||
match the workflow block's origin. */}
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className={cn(
|
||||
'absolute inset-x-0 top-0 transition-opacity duration-300 ease-[cubic-bezier(0.23,1,0.32,1)]',
|
||||
// Hold off until the chat content (≈220ms fade) is gone, then fade in.
|
||||
isBlock ? 'opacity-100 [transition-delay:280ms]' : 'pointer-events-none opacity-0'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className='relative'
|
||||
style={{
|
||||
width: BLOCK_WIDTH,
|
||||
transform: `scale(${WORKFLOW_FOCUS_SCALE})`,
|
||||
transformOrigin: 'top left',
|
||||
}}
|
||||
>
|
||||
<WorkflowBlockContent block={FIRST_BLOCK} />
|
||||
<span
|
||||
aria-hidden
|
||||
className='-translate-y-1/2 absolute top-5 right-[-7px] h-5 w-[7px] rounded-r-[2px] bg-[var(--workflow-edge)]'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import type { CSSProperties, RefObject } from 'react'
|
||||
import { cn } from '@sim/emcn'
|
||||
import { Upload, X } from 'lucide-react'
|
||||
import {
|
||||
GRAPH_EDGES,
|
||||
GRAPH_NODES,
|
||||
GRAPH_VIEWBOX,
|
||||
KB_FILES,
|
||||
KB_NAME,
|
||||
} from '@/app/(landing)/components/hero/components/hero-visual/workflow-data'
|
||||
|
||||
/** Which beat of the knowledge-base flow the modal is showing. */
|
||||
export type KbStage = 'empty' | 'files' | 'embeddings'
|
||||
|
||||
interface KnowledgeBasePanelProps {
|
||||
stage: KbStage
|
||||
/** The Create button - the root cursor targets this to "create". */
|
||||
createRef: RefObject<HTMLSpanElement | null>
|
||||
/**
|
||||
* `modal` renders the standalone centered modal (its own chrome + entrance);
|
||||
* `morph` renders the content only, filling its host, so a satellite block can
|
||||
* morph into it in scene space.
|
||||
*/
|
||||
motion?: 'modal' | 'morph'
|
||||
}
|
||||
|
||||
/**
|
||||
* The knowledge-base create UI - a faithful, decorative replica of the real
|
||||
* `ChipModal` create flow. First an empty dropzone ("Drop files here"); then
|
||||
* files drop in from above as if dragged from Finder; then the document area
|
||||
* becomes an embedding map that builds itself node by node while the footer
|
||||
* reads "Creating…". The Create button is exposed as a cursor target.
|
||||
*/
|
||||
export function KnowledgeBasePanel({
|
||||
stage,
|
||||
createRef,
|
||||
motion = 'modal',
|
||||
}: KnowledgeBasePanelProps) {
|
||||
const creating = stage === 'embeddings'
|
||||
|
||||
const content = (
|
||||
<div
|
||||
className={cn(
|
||||
'overflow-hidden rounded-lg border border-[var(--border-1)] bg-[var(--bg)]',
|
||||
motion === 'morph' && 'h-full'
|
||||
)}
|
||||
>
|
||||
<div className='flex items-center justify-between px-4 pt-3 pb-2.5'>
|
||||
<span className='font-medium text-[15px] text-[var(--text-primary)]'>
|
||||
Create Knowledge Base
|
||||
</span>
|
||||
<X className='size-4 text-[var(--text-muted)]' />
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-4 px-4 pb-4'>
|
||||
<div className='flex flex-col gap-[9px]'>
|
||||
<span className='text-[13px] text-[var(--text-muted)]'>Name</span>
|
||||
<div className='flex h-[30px] items-center rounded-lg border border-[var(--border-1)] bg-[var(--surface-5)] px-2 text-[14px] text-[var(--text-body)]'>
|
||||
{KB_NAME}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-[9px]'>
|
||||
<span className='text-[13px] text-[var(--text-muted)]'>
|
||||
{creating ? 'Embeddings' : 'Upload Documents'}
|
||||
</span>
|
||||
<div className='relative h-[188px]'>
|
||||
{stage === 'empty' && (
|
||||
<div className='absolute inset-0 flex flex-col items-center justify-center gap-1 rounded-lg border border-[var(--border-1)] border-dashed bg-[var(--surface-5)] text-center'>
|
||||
<Upload className='size-5 text-[var(--text-muted)]' />
|
||||
<span className='text-[var(--text-primary)] text-caption'>Drop files here</span>
|
||||
<span className='text-[var(--text-tertiary)] text-xs'>PDF, DOCX, TXT, CSV, MD</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stage === 'files' && (
|
||||
<div className='absolute inset-0 flex flex-col justify-center gap-2'>
|
||||
{KB_FILES.map((file, i) => (
|
||||
<div
|
||||
key={file.name}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-lg border border-[var(--border-1)] bg-[var(--surface-5)] p-2',
|
||||
'animate-hero-file-drop opacity-0 [animation-delay:var(--drop-delay)] motion-reduce:animate-none motion-reduce:opacity-100'
|
||||
)}
|
||||
style={{ '--drop-delay': `${120 + i * 170}ms` } as CSSProperties}
|
||||
>
|
||||
<file.icon className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
<span className='min-w-0 flex-1 truncate text-[14px] text-[var(--text-body)]'>
|
||||
{file.name}
|
||||
</span>
|
||||
<span className='flex-shrink-0 text-[14px] text-[var(--text-muted)]'>
|
||||
{file.size}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{creating && (
|
||||
<div className='absolute inset-0 flex flex-col items-center justify-center gap-1.5 rounded-lg border border-[var(--border-1)] bg-[var(--surface-5)] px-3'>
|
||||
<svg
|
||||
className='h-[140px] w-full'
|
||||
viewBox={`0 0 ${GRAPH_VIEWBOX.width} ${GRAPH_VIEWBOX.height}`}
|
||||
fill='none'
|
||||
aria-hidden='true'
|
||||
>
|
||||
<title>embedding graph</title>
|
||||
{GRAPH_EDGES.map(([a, b], i) => {
|
||||
const from = GRAPH_NODES[a]
|
||||
const to = GRAPH_NODES[b]
|
||||
return (
|
||||
<path
|
||||
key={`${a}-${b}`}
|
||||
d={`M ${from.x} ${from.y} L ${to.x} ${to.y}`}
|
||||
pathLength={1}
|
||||
className='animate-hero-edge-draw [animation-delay:var(--pop-delay)] [stroke-dasharray:1] [stroke-dashoffset:1] motion-reduce:animate-none motion-reduce:[stroke-dashoffset:0]'
|
||||
stroke='var(--text-subtle)'
|
||||
strokeWidth={0.5}
|
||||
style={{ '--pop-delay': `${i * 45}ms` } as CSSProperties}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{GRAPH_NODES.map((node, i) => (
|
||||
<circle
|
||||
key={`${node.x}-${node.y}`}
|
||||
cx={node.x}
|
||||
cy={node.y}
|
||||
r={node.hub ? 3.4 : i % 3 === 0 ? 2.4 : 1.9}
|
||||
className={cn(
|
||||
'opacity-0 [transform-box:fill-box] [transform-origin:center] motion-reduce:animate-none motion-reduce:opacity-100',
|
||||
node.hub
|
||||
? 'animate-[hero-node-pop_440ms_cubic-bezier(0.16,1,0.3,1)_var(--pop-delay)_forwards,hero-graph-pulse_2600ms_ease-in-out_calc(var(--pop-delay)+800ms)_infinite]'
|
||||
: 'animate-hero-node-pop [animation-delay:var(--pop-delay)]'
|
||||
)}
|
||||
fill={
|
||||
node.hub
|
||||
? 'var(--text-primary)'
|
||||
: i % 2 === 0
|
||||
? 'var(--text-secondary)'
|
||||
: 'var(--text-muted)'
|
||||
}
|
||||
style={{ '--pop-delay': `${300 + i * 40}ms` } as CSSProperties}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
<span className='text-[var(--text-muted)] text-caption'>
|
||||
Generating embeddings…
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-end gap-2 rounded-b-lg bg-[var(--surface-3)] px-4 pt-2 pb-2'>
|
||||
<span className='flex h-[30px] items-center rounded-lg px-2 text-[14px] text-[var(--text-body)]'>
|
||||
Cancel
|
||||
</span>
|
||||
<span
|
||||
ref={createRef}
|
||||
className='flex h-[30px] items-center rounded-lg bg-[var(--text-primary)] px-2 text-[14px] text-[var(--text-inverse)]'
|
||||
>
|
||||
{creating ? 'Creating…' : 'Create'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (motion === 'morph') {
|
||||
return (
|
||||
<div className='h-full w-full animate-hero-kb-content-morph opacity-0 motion-reduce:animate-none motion-reduce:opacity-100'>
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'w-full max-w-[420px] rounded-xl border border-[var(--border-muted)] bg-[var(--surface-4)] p-[3px] shadow-[var(--shadow-overlay)]',
|
||||
'animate-hero-modal-in motion-reduce:animate-none'
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
'use client'
|
||||
|
||||
import { type CSSProperties, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { cn } from '@sim/emcn'
|
||||
import { WorkflowBlock } from '@/app/(landing)/components/hero/components/hero-visual/workflow-block'
|
||||
import {
|
||||
BLOCK_WIDTH,
|
||||
BLOCKS,
|
||||
CANVAS,
|
||||
EDGES,
|
||||
WORKFLOW_FOCUS_SCALE,
|
||||
} from '@/app/(landing)/components/hero/components/hero-visual/workflow-data'
|
||||
|
||||
/** The camera beat: held on the first block, panning out, or settled on the whole flow. */
|
||||
export type WorkflowCameraStage = 'focus' | 'out' | 'hold'
|
||||
|
||||
interface StageWorkflowProps {
|
||||
stage: WorkflowCameraStage
|
||||
}
|
||||
|
||||
/** First (GitHub) block center in design space - the camera's focus target. */
|
||||
const FOCUS_CENTER = { x: BLOCK_WIDTH / 2, y: 38 }
|
||||
/** Whole-canvas center - the overview camera target. */
|
||||
const CANVAS_CENTER = { x: CANVAS.width / 2, y: CANVAS.height / 2 }
|
||||
/** Zoomed-in scale while held on the first block (≈ the morphed chat card size). */
|
||||
const FOCUS_SCALE = WORKFLOW_FOCUS_SCALE
|
||||
/** Pulled-back scale that fits the whole workflow in the panel. */
|
||||
const OVERVIEW_SCALE = 0.68
|
||||
|
||||
/**
|
||||
* The workflow stage of the hero visual - a design-space canvas with a moving
|
||||
* "camera". It opens **focused** on the first block (the chat card has just
|
||||
* morphed into it), holds while that block's content lands and the first edge
|
||||
* draws, then the camera **pans + zooms out together** to reveal the whole
|
||||
* GitHub → Agent → Jira flow (the {@link stage} prop drives this).
|
||||
*
|
||||
* The camera is a transform on the design-space canvas, positioned so the focus
|
||||
* point lands at the panel center: `translate(vpW/2 - cx·s, vpH/2 - cy·s)
|
||||
* scale(s)` (origin top-left). The panel size is measured; until it is known,
|
||||
* and on first mount, the transition is suppressed so the opening focus frame
|
||||
* doesn't animate in from a fallback. Purely decorative - `aria-hidden`.
|
||||
*/
|
||||
export function StageWorkflow({ stage }: StageWorkflowProps) {
|
||||
const viewportRef = useRef<HTMLDivElement>(null)
|
||||
const [vp, setVp] = useState<{ w: number; h: number } | null>(null)
|
||||
const [animate, setAnimate] = useState(false)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = viewportRef.current
|
||||
if (!el) return
|
||||
const measure = () => {
|
||||
const r = el.getBoundingClientRect()
|
||||
// Guard an unpainted/collapsed panel from poisoning the camera math.
|
||||
if (r.width > 120 && r.height > 120) setVp({ w: r.width, h: r.height })
|
||||
}
|
||||
measure()
|
||||
const ro = new ResizeObserver(measure)
|
||||
ro.observe(el)
|
||||
return () => ro.disconnect()
|
||||
}, [])
|
||||
|
||||
// Enable the camera transition only after the opening focus frame is painted,
|
||||
// so mounting (and the first measurement) snaps into focus rather than gliding.
|
||||
useEffect(() => {
|
||||
if (vp) setAnimate(true)
|
||||
}, [vp])
|
||||
|
||||
const focused = stage === 'focus'
|
||||
const center = focused ? FOCUS_CENTER : CANVAS_CENTER
|
||||
const scale = focused ? FOCUS_SCALE : OVERVIEW_SCALE
|
||||
const transform = vp
|
||||
? `translate(${vp.w / 2 - center.x * scale}px, ${vp.h / 2 - center.y * scale}px) scale(${scale})`
|
||||
: `translate(0px, 0px) scale(${OVERVIEW_SCALE})`
|
||||
|
||||
return (
|
||||
<div ref={viewportRef} className='relative h-full w-full overflow-hidden'>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute top-0 left-0',
|
||||
animate && 'transition-transform duration-[1700ms] ease-[cubic-bezier(0.22,1,0.36,1)]'
|
||||
)}
|
||||
style={{
|
||||
width: CANVAS.width,
|
||||
height: CANVAS.height,
|
||||
transform,
|
||||
transformOrigin: '0 0',
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
className='absolute inset-0'
|
||||
width={CANVAS.width}
|
||||
height={CANVAS.height}
|
||||
viewBox={`0 0 ${CANVAS.width} ${CANVAS.height}`}
|
||||
fill='none'
|
||||
aria-hidden='true'
|
||||
>
|
||||
{EDGES.map((edge, i) => (
|
||||
<path
|
||||
key={edge.id}
|
||||
d={edge.d}
|
||||
pathLength={1}
|
||||
stroke='var(--workflow-edge)'
|
||||
strokeWidth={2}
|
||||
strokeLinecap='round'
|
||||
className='transition-[stroke-dashoffset] duration-[700ms] ease-[cubic-bezier(0.22,1,0.36,1)] [stroke-dasharray:1]'
|
||||
// Edges stay undrawn while focused; once the camera pulls out they
|
||||
// draw in order (the second trails the first) as each target is revealed.
|
||||
style={
|
||||
{
|
||||
strokeDashoffset: focused ? 1 : 0,
|
||||
transitionDelay: `${i * 700}ms`,
|
||||
} as CSSProperties
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
{BLOCKS.map((block) => (
|
||||
// The first block is already on screen - the chat card morphed into it,
|
||||
// and the focused camera lands it pixel-matched here; the rest sit in
|
||||
// design space and are revealed by the camera pull-out.
|
||||
<div
|
||||
key={block.id}
|
||||
className='absolute'
|
||||
style={{ left: block.x, top: block.y, width: BLOCK_WIDTH }}
|
||||
>
|
||||
<WorkflowBlock block={block} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
import { cn } from '@sim/emcn'
|
||||
import type { BlockDef } from '@/app/(landing)/components/hero/components/hero-visual/workflow-data'
|
||||
|
||||
interface WorkflowBlockContentProps {
|
||||
block: BlockDef
|
||||
}
|
||||
|
||||
/**
|
||||
* The inner content of a workflow block - the icon-tile header and optional
|
||||
* label → value rows - WITHOUT the card chrome or handle nubs. Split out so the
|
||||
* chat card can host the exact same content while morphing into the first block
|
||||
* (the card keeps its own continuous shell; only this content crossfades in).
|
||||
*/
|
||||
export function WorkflowBlockContent({ block }: WorkflowBlockContentProps) {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2.5 p-2',
|
||||
block.rows.length > 0 && 'border-[var(--border-1)] border-b'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex size-[24px] flex-shrink-0 items-center justify-center rounded-md',
|
||||
block.tileBorder && 'border border-[var(--border-1)]'
|
||||
)}
|
||||
style={{ background: block.bgColor }}
|
||||
>
|
||||
{block.tileBorder ? (
|
||||
<block.icon className='size-[16px]' />
|
||||
) : (
|
||||
<block.icon className='size-[16px] text-white' />
|
||||
)}
|
||||
</div>
|
||||
<span className='truncate font-medium text-[16px] text-[var(--text-body)]'>
|
||||
{block.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{block.rows.length > 0 && (
|
||||
<div className='flex flex-col gap-2 p-2'>
|
||||
{block.rows.map((row) => (
|
||||
<div key={row.title} className='flex items-center gap-2'>
|
||||
<span className='flex-shrink-0 text-[14px] text-[var(--text-muted)]'>
|
||||
{row.title}
|
||||
</span>
|
||||
<span className='flex min-w-0 flex-1 items-center justify-end gap-1.5 text-[14px] text-[var(--text-body)]'>
|
||||
{row.valueIcon && <row.valueIcon className='size-[14px] flex-shrink-0' />}
|
||||
<span className='truncate'>{row.value}</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { cn } from '@sim/emcn'
|
||||
import { BlockHandles } from '@/app/(landing)/components/hero/components/hero-visual/block-handles'
|
||||
import { WorkflowBlockContent } from '@/app/(landing)/components/hero/components/hero-visual/workflow-block-content'
|
||||
import type { BlockDef } from '@/app/(landing)/components/hero/components/hero-visual/workflow-data'
|
||||
|
||||
interface WorkflowBlockProps {
|
||||
block: BlockDef
|
||||
contentVisible?: boolean
|
||||
handlesVisible?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* A pure presentational workflow block card, faithful to the real WorkflowBlock:
|
||||
* a fixed-width card with an icon-tile header and optional label → value rows,
|
||||
* plus decorative handle nubs on its left and right edges. Stateless and
|
||||
* client-free - positioning and the rise animation are owned by the parent stage.
|
||||
* `contentVisible`/`handlesVisible` crossfade the content and handles so a block
|
||||
* can soften into a shell (used when the Jira block morphs into the KB panel).
|
||||
*/
|
||||
export function WorkflowBlock({
|
||||
block,
|
||||
contentVisible = true,
|
||||
handlesVisible = true,
|
||||
}: WorkflowBlockProps) {
|
||||
return (
|
||||
<div className='relative w-[250px] rounded-[13px] border border-[var(--border-1)] bg-[var(--surface-2)] shadow-sm'>
|
||||
<div
|
||||
className={cn(
|
||||
'transition-opacity duration-[360ms] ease-[cubic-bezier(0.22,1,0.36,1)]',
|
||||
contentVisible ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
>
|
||||
<WorkflowBlockContent block={block} />
|
||||
</div>
|
||||
<BlockHandles block={block} handlesVisible={handlesVisible} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
import type { ComponentType, SVGProps } from 'react'
|
||||
import { AgentIcon, AnthropicIcon, GithubIcon, JiraIcon } from '@/components/icons'
|
||||
import { CsvIcon, DocxIcon, MarkdownIcon, PdfIcon } from '@/components/icons/document-icons'
|
||||
|
||||
/**
|
||||
* Shared data + geometry for the hero visual - the single source of truth the
|
||||
* presentational stages render against. No JSX, no client code; pure data so it
|
||||
* can be imported by both server- and client-side modules.
|
||||
*
|
||||
* The workflow is laid out in a fixed "design space" (px). Block positions and
|
||||
* the SVG edge paths share these coordinates, so the `<svg>` overlay and the
|
||||
* absolutely-positioned block cards line up exactly. {@link CANVAS} is the
|
||||
* bounding box of that space; the stage scales it to fit the hero panel.
|
||||
*/
|
||||
|
||||
type IconComponent = ComponentType<SVGProps<SVGSVGElement>>
|
||||
|
||||
/** A single field row inside a block card (label → value), faithful to the real WorkflowBlock. */
|
||||
export interface BlockRow {
|
||||
title: string
|
||||
value: string
|
||||
/** Optional provider mark shown left of the value (e.g. Anthropic for a Claude model). */
|
||||
valueIcon?: IconComponent
|
||||
}
|
||||
|
||||
/** A workflow block in design space. */
|
||||
export interface BlockDef {
|
||||
id: string
|
||||
name: string
|
||||
icon: IconComponent
|
||||
/** Icon-tile fill - a brand-faithful color or a platform token (`var(--…)`). */
|
||||
bgColor: string
|
||||
/** White icon tiles (e.g. Jira) need a hairline so the mark stays visible. */
|
||||
tileBorder?: boolean
|
||||
/** Trigger blocks start the flow, so they render no incoming (left) handle. */
|
||||
isTrigger?: boolean
|
||||
/** Terminal blocks end the flow, so they render no outgoing (right) handle. */
|
||||
isTerminal?: boolean
|
||||
rows: BlockRow[]
|
||||
/** Top-left corner in design space. */
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
/** Fixed block width, matching the real canvas (`BLOCK_DIMENSIONS.FIXED_WIDTH`). */
|
||||
export const BLOCK_WIDTH = 250
|
||||
|
||||
/**
|
||||
* Camera zoom while the workflow stage is focused on the first block. Chosen so
|
||||
* the focused first block lands at the same on-screen width as the chat card
|
||||
* (`BLOCK_WIDTH * SCALE ≈ 460`), so the chat card morphs straight into it with
|
||||
* no jump. Shared by the chat stage (its morph target) and the workflow camera.
|
||||
*/
|
||||
export const WORKFLOW_FOCUS_SCALE = 1.25
|
||||
|
||||
/** Handle vertical offset from a block's top edge (matches the real WorkflowBlock). */
|
||||
export const HANDLE_Y_OFFSET = 20
|
||||
|
||||
/**
|
||||
* Design-space bounding box the stage scales to fit the panel. Sized to exactly
|
||||
* bound the blocks below (GitHub/Jira at y=0, Agent's foot at ~y=203), so the
|
||||
* stage's flex-centering centers the workflow with no stray margin.
|
||||
*/
|
||||
export const CANVAS = { width: 850, height: 206 } as const
|
||||
|
||||
/**
|
||||
* GitHub → Agent → Jira. A gentle staircase: GitHub and Jira ride high, the
|
||||
* Agent dips between them, so the two edges read as a clean down-then-up flow.
|
||||
* The shape is left-right symmetric (Agent centered at x=425) and its bounding
|
||||
* box matches {@link CANVAS}, keeping it centered in the panel.
|
||||
*/
|
||||
export const BLOCKS: BlockDef[] = [
|
||||
{
|
||||
id: 'github',
|
||||
name: 'GitHub',
|
||||
icon: GithubIcon,
|
||||
bgColor: '#181C1E',
|
||||
isTrigger: true,
|
||||
rows: [{ title: 'Trigger', value: 'PR opened' }],
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
{
|
||||
id: 'agent',
|
||||
name: 'Agent',
|
||||
icon: AgentIcon,
|
||||
bgColor: 'var(--brand-accent)',
|
||||
rows: [
|
||||
{ title: 'Model', value: 'Claude', valueIcon: AnthropicIcon },
|
||||
{ title: 'Task', value: 'Review PR' },
|
||||
],
|
||||
x: 300,
|
||||
y: 96,
|
||||
},
|
||||
{
|
||||
id: 'jira',
|
||||
name: 'Jira',
|
||||
icon: JiraIcon,
|
||||
bgColor: '#FFFFFF',
|
||||
tileBorder: true,
|
||||
isTerminal: true,
|
||||
rows: [{ title: 'Action', value: 'Create issue' }],
|
||||
x: 600,
|
||||
y: 0,
|
||||
},
|
||||
]
|
||||
|
||||
/** An ordered source → target connection between two blocks. */
|
||||
export interface EdgeDef {
|
||||
id: string
|
||||
/** SVG path `d` in design space; `pathLength` is normalized to 1 by the renderer. */
|
||||
d: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Rounded orthogonal ("smoothstep") path from a source's right handle to a
|
||||
* target's left handle, stepping at the horizontal midpoint with `r`-radius
|
||||
* corners. Source/target points are taken at `block.x±width` and
|
||||
* `block.y + HANDLE_Y_OFFSET`, matching where the handles render.
|
||||
*/
|
||||
export function smoothStep(sx: number, sy: number, tx: number, ty: number, r = 8): string {
|
||||
const midX = (sx + tx) / 2
|
||||
const dir = ty >= sy ? 1 : -1
|
||||
return [
|
||||
`M ${sx} ${sy}`,
|
||||
`L ${midX - r} ${sy}`,
|
||||
`Q ${midX} ${sy} ${midX} ${sy + dir * r}`,
|
||||
`L ${midX} ${ty - dir * r}`,
|
||||
`Q ${midX} ${ty} ${midX + r} ${ty}`,
|
||||
`L ${tx} ${ty}`,
|
||||
].join(' ')
|
||||
}
|
||||
|
||||
function handlePoints(sourceId: string, targetId: string) {
|
||||
const source = BLOCKS.find((b) => b.id === sourceId)
|
||||
const target = BLOCKS.find((b) => b.id === targetId)
|
||||
if (!source || !target) throw new Error(`Unknown block in edge ${sourceId}→${targetId}`)
|
||||
return {
|
||||
sx: source.x + BLOCK_WIDTH,
|
||||
sy: source.y + HANDLE_Y_OFFSET,
|
||||
tx: target.x,
|
||||
ty: target.y + HANDLE_Y_OFFSET,
|
||||
}
|
||||
}
|
||||
|
||||
export const EDGES: EdgeDef[] = (
|
||||
[
|
||||
['github', 'agent'],
|
||||
['agent', 'jira'],
|
||||
] as const
|
||||
).map(([from, to]) => {
|
||||
const { sx, sy, tx, ty } = handlePoints(from, to)
|
||||
return { id: `${from}-${to}`, d: smoothStep(sx, sy, tx, ty) }
|
||||
})
|
||||
|
||||
/**
|
||||
* Unified hero scene geometry. The chat card is block 1 (GitHub), centered at
|
||||
* the panel center; the rest of the workflow is placed relative to it, all at
|
||||
* FOCUS scale (design × {@link WORKFLOW_FOCUS_SCALE}). The whole scene is then
|
||||
* scaled/translated to the OVERVIEW to reveal the full workflow - so the SAME
|
||||
* card element is continuously block 1 through the pull-out.
|
||||
*
|
||||
* Scene origin is the GitHub block's CENTER (which sits at the panel center).
|
||||
*/
|
||||
const GH_CENTER_X = BLOCK_WIDTH / 2
|
||||
/** GitHub block half-height in design space (its content is ~77px tall). */
|
||||
const GH_CENTER_Y = 38.5
|
||||
const toSceneX = (dx: number) => (dx - GH_CENTER_X) * WORKFLOW_FOCUS_SCALE
|
||||
const toSceneY = (dy: number) => (dy - GH_CENTER_Y) * WORKFLOW_FOCUS_SCALE
|
||||
|
||||
/** A satellite block (everything past block 1) placed in scene space. */
|
||||
export interface SceneBlock {
|
||||
block: BlockDef
|
||||
/** Top-left in scene space (origin = panel center), at FOCUS scale. */
|
||||
left: number
|
||||
top: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Block 1 (GitHub) in scene space. It's the morphed chat card - rendered
|
||||
* content-only and clipped by the card's `overflow-hidden` - so its edge-handle
|
||||
* nub is drawn separately at this position, matching where a satellite block
|
||||
* (and its handle) would sit.
|
||||
*/
|
||||
export const SCENE_BLOCK1: SceneBlock = {
|
||||
block: BLOCKS[0],
|
||||
left: toSceneX(BLOCKS[0].x),
|
||||
top: toSceneY(BLOCKS[0].y),
|
||||
}
|
||||
|
||||
/** Blocks 2…N, positioned relative to the centered first block. */
|
||||
export const SCENE_SATELLITES: SceneBlock[] = BLOCKS.slice(1).map((block) => ({
|
||||
block,
|
||||
left: toSceneX(block.x),
|
||||
top: toSceneY(block.y),
|
||||
}))
|
||||
|
||||
/** Edge paths in scene space (same connections as {@link EDGES}). */
|
||||
export const SCENE_EDGES: EdgeDef[] = (
|
||||
[
|
||||
['github', 'agent'],
|
||||
['agent', 'jira'],
|
||||
] as const
|
||||
).map(([from, to]) => {
|
||||
const { sx, sy, tx, ty } = handlePoints(from, to)
|
||||
return {
|
||||
id: `${from}-${to}`,
|
||||
d: smoothStep(toSceneX(sx), toSceneY(sy), toSceneX(tx), toSceneY(ty), 14),
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Pull-out transform from FOCUS (block 1 centered, full size) to OVERVIEW (whole
|
||||
* workflow centered, fit to panel). `SCALE` brings the FOCUS-scale scene down to
|
||||
* the design overview (1.84 × 0.37 ≈ 0.68); the translate recenters the group -
|
||||
* it matches the design overview's GitHub offset, so the framing is identical to
|
||||
* the prior camera overview. Transform-origin is the panel center (block 1's
|
||||
* center), so FOCUS is the identity transform (no measurement needed).
|
||||
*/
|
||||
export const SCENE_OVERVIEW_SCALE = 0.68 / WORKFLOW_FOCUS_SCALE
|
||||
export const SCENE_OVERVIEW_TRANSLATE = { x: -204, y: -43 } as const
|
||||
|
||||
/** Camera scale while tracing the workflow edge-by-edge before the full zoom-out. */
|
||||
export const SCENE_FOLLOW_SCALE = 0.86
|
||||
|
||||
/**
|
||||
* Intermediate camera stops for the edge-follow pass. These keep the active
|
||||
* destination block centered enough to read while preserving a little context
|
||||
* around the incoming connection.
|
||||
*/
|
||||
export const SCENE_AGENT_FOCUS_TRANSLATE = { x: -323, y: -126 } as const
|
||||
export const SCENE_JIRA_FOCUS_TRANSLATE = { x: -645, y: 0 } as const
|
||||
|
||||
/**
|
||||
* The typed prompt, encoded as ordered atoms the typewriter reveals one at a
|
||||
* time. A `char` atom is a single character; a `mention` atom pops in
|
||||
* atomically as an inline icon-chip - exactly how the real input renders an
|
||||
* `@GitHub` / `@Jira` mention.
|
||||
*/
|
||||
export type PromptAtom =
|
||||
| { kind: 'char'; char: string }
|
||||
| { kind: 'mention'; label: string; icon: IconComponent }
|
||||
|
||||
const PROMPT_SEGMENTS: Array<string | { label: string; icon: IconComponent }> = [
|
||||
'Create me a ',
|
||||
{ label: 'GitHub', icon: GithubIcon },
|
||||
' PR review bot that connects to ',
|
||||
{ label: 'Jira', icon: JiraIcon },
|
||||
]
|
||||
|
||||
export const PROMPT_ATOMS: PromptAtom[] = PROMPT_SEGMENTS.flatMap((seg) =>
|
||||
typeof seg === 'string'
|
||||
? [...seg].map((char): PromptAtom => ({ kind: 'char', char }))
|
||||
: [{ kind: 'mention', label: seg.label, icon: seg.icon } as PromptAtom]
|
||||
)
|
||||
|
||||
/** Greeting shown above the input in the home state (matches the Mothership home). */
|
||||
export const HOME_GREETING = 'What should we get done?'
|
||||
|
||||
/** Total reveal cadence for the typewriter, in ms per atom. */
|
||||
export const TYPE_MS_PER_ATOM = 45
|
||||
|
||||
/**
|
||||
* The Mothership's reply, typed out after it "thinks" (the cycle loader). Keeps
|
||||
* the world voice - it dispatches an agent - and previews the workflow it's
|
||||
* about to build, so the chat answer morphs naturally into the canvas below.
|
||||
*/
|
||||
export const ANSWER_TEXT = 'On it, dispatching an agent to review every PR and open a Jira issue.'
|
||||
|
||||
/** Reveal cadence for the answer typewriter (faster than a human; the AI types). */
|
||||
export const ANSWER_MS_PER_CHAR = 18
|
||||
|
||||
/** White chat card grow after send, before the sent-message bubble is visible. */
|
||||
export const SEND_BUBBLE_GROW_MS = 620
|
||||
|
||||
/** Delay the grey sent-message reveal until the card grow has visibly settled. */
|
||||
export const SEND_BUBBLE_REVEAL_DELAY_MS = SEND_BUBBLE_GROW_MS + 260
|
||||
|
||||
/** Soft enter for the grey sent-message bubble once the card has room for it. */
|
||||
export const SEND_BUBBLE_ENTER_MS = 280
|
||||
|
||||
/** Full send beat duration: grow, reveal, then a brief hold before loader slide. */
|
||||
export const SEND_BUBBLE_HOLD_MS = SEND_BUBBLE_REVEAL_DELAY_MS + SEND_BUBBLE_ENTER_MS + 220
|
||||
|
||||
/** Knowledge-base name shown pre-filled in the create modal. */
|
||||
export const KB_NAME = 'Product Docs'
|
||||
|
||||
/** A file shown dropping into the knowledge-base create modal. */
|
||||
export interface KbFile {
|
||||
name: string
|
||||
size: string
|
||||
icon: IconComponent
|
||||
}
|
||||
|
||||
export const KB_FILES: KbFile[] = [
|
||||
{ name: 'product-spec.pdf', size: '2.4 MB', icon: PdfIcon },
|
||||
{ name: 'api-reference.md', size: '88 KB', icon: MarkdownIcon },
|
||||
{ name: 'support-faq.docx', size: '1.1 MB', icon: DocxIcon },
|
||||
{ name: 'pricing.csv', size: '12 KB', icon: CsvIcon },
|
||||
]
|
||||
|
||||
/** Design-space bounding box for the embedding graph (its own SVG viewBox). */
|
||||
export const GRAPH_VIEWBOX = { width: 340, height: 150 } as const
|
||||
|
||||
/** A node in the embedding graph. Hubs are larger, darker, and gently pulse. */
|
||||
export interface GraphNode {
|
||||
x: number
|
||||
y: number
|
||||
hub?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* A single connected knowledge-graph laid out organically across the viewBox -
|
||||
* three hubs with satellites, bridged into one mesh. Hand-placed (deterministic,
|
||||
* SSR-stable) for a balanced, deliberate look rather than random scatter.
|
||||
*/
|
||||
export const GRAPH_NODES: GraphNode[] = [
|
||||
{ x: 38, y: 66 },
|
||||
{ x: 74, y: 104 },
|
||||
{ x: 96, y: 44 },
|
||||
{ x: 132, y: 82, hub: true },
|
||||
{ x: 158, y: 40 },
|
||||
{ x: 168, y: 116 },
|
||||
{ x: 206, y: 70, hub: true },
|
||||
{ x: 228, y: 38 },
|
||||
{ x: 236, y: 112 },
|
||||
{ x: 268, y: 72 },
|
||||
{ x: 300, y: 104, hub: true },
|
||||
{ x: 312, y: 58 },
|
||||
{ x: 286, y: 40 },
|
||||
{ x: 54, y: 96 },
|
||||
{ x: 140, y: 122 },
|
||||
{ x: 250, y: 96 },
|
||||
]
|
||||
|
||||
/** Edges between {@link GRAPH_NODES} (index pairs) - one connected component. */
|
||||
export const GRAPH_EDGES: Array<[number, number]> = [
|
||||
[0, 2],
|
||||
[0, 13],
|
||||
[13, 1],
|
||||
[1, 3],
|
||||
[2, 3],
|
||||
[2, 4],
|
||||
[3, 4],
|
||||
[3, 5],
|
||||
[3, 14],
|
||||
[5, 14],
|
||||
[4, 6],
|
||||
[6, 7],
|
||||
[6, 15],
|
||||
[6, 8],
|
||||
[7, 12],
|
||||
[12, 9],
|
||||
[9, 15],
|
||||
[8, 15],
|
||||
[9, 10],
|
||||
[8, 10],
|
||||
[10, 11],
|
||||
[11, 12],
|
||||
[9, 11],
|
||||
]
|
||||
@@ -0,0 +1,128 @@
|
||||
import { cn } from '@sim/emcn'
|
||||
import Image from 'next/image'
|
||||
import { LandingHeroHeader } from '@/app/(landing)/components/hero/components/hero-header'
|
||||
import { HeroPlatformLoop } from '@/app/(landing)/components/hero/components/hero-platform-loop'
|
||||
import {
|
||||
LANDING_CONTENT_WIDTH,
|
||||
LANDING_GUTTER,
|
||||
LANDING_HERO_TOP_PADDING,
|
||||
} from '@/app/(landing)/components/landing-layout'
|
||||
import { TrustedBy } from '@/app/(landing)/components/trusted-by'
|
||||
|
||||
/**
|
||||
* Landing hero - the only `<h1>` on the page.
|
||||
*
|
||||
* A single stacked flow (no split panels): headline and the sign-up row sit
|
||||
* left-aligned at the top; below them a full-width media frame
|
||||
* previews the platform UI; the customer-logo row closes the section centered
|
||||
* underneath. The section is capped and centered at the shared `max-w-[1460px]`
|
||||
* (`mx-auto`) with the `px-20 max-lg:px-8 max-sm:px-5` gutter so the headline
|
||||
* starts on the navbar wordmark's vertical line.
|
||||
*
|
||||
* Text blocks stack a uniform 22px apart (`gap-[22px]`); the media frame and
|
||||
* logo row carry their own larger top margins to read as separate bands.
|
||||
*
|
||||
* The sign-up row is the shared {@link HeroCta} - the single source of truth for
|
||||
* the email-capture bar and the "Book a demo" / "Sign up" chips - reused
|
||||
* verbatim by every platform and solutions hero so the primary CTA never drifts.
|
||||
*
|
||||
* The media frame: the painted landscape backdrop (`hero-backdrop.jpg`,
|
||||
* rendered via `next/image` `fill` + `object-cover` with `priority` - it is the
|
||||
* LCP element) behind a white window (a soft three-part shadow stack:
|
||||
* `0 0 0 1px rgba(0,0,0,0.08)` ring in place of a CSS border, plus
|
||||
* `0 2px 6px rgba(0,0,0,0.05)` contact and `0 4px 42px rgba(0,0,0,0.06)`
|
||||
* ambient shadows; no browser toolbar) filled edge to edge by the REAL
|
||||
* platform UI - a 2x
|
||||
* screenshot (`hero-platform-ui.png`, 2560x1470: a 1280x735 layout shown in
|
||||
* the 1080x620 window, so the UI reads at 84.4% - the "mini app" type scale
|
||||
* cursor.com's demo window uses) of the chat-everywhere two-pane (seeded
|
||||
* Mothership chat left, staged workflow right) captured from the
|
||||
* `readme-tour-capture` route via
|
||||
* `exports/readme-banner/capture-hero-platform.mjs`. The window is
|
||||
* `rounded-[10px]` - matching cursor.com's demo window - and the shot's
|
||||
* workspace container renders at the concentric inner radius `4px` (outer
|
||||
* 10px - 6px gap; overridden at capture time from the chrome's 8px). Only the
|
||||
* SIDEBAR
|
||||
* remains visible from the shot: the {@link HeroPlatformLoop} island overlays
|
||||
* the container interior (full-width chat that stages the workflow pane in,
|
||||
* replaying the conversation with the goo ThinkingLoader), inset a hair INSIDE
|
||||
* the shot's own baked outlines so the visible chrome is the real UI's pixels
|
||||
* - never re-drawn.
|
||||
* The frame is `1300/720` and the window `1080/620` at `83.08%` width, centered
|
||||
* - matching cursor.com's hero media proportions, with backdrop showing on all
|
||||
* four sides. Decorative, `aria-hidden`; the `--surface-3` fill remains as the
|
||||
* loading fallback under the backdrop.
|
||||
*
|
||||
* The headline/CTA column shares its row with the right-aligned
|
||||
* {@link HeroStat} (the "Global work done by Sim" figure with its vertical
|
||||
* progress rail and staggered page-load entrance), hidden below `lg` where
|
||||
* the row has no room.
|
||||
*
|
||||
* The shared {@link TrustedBy} block renders in its `row` layout - a centered
|
||||
* muted label above a single centered row of bare wordmarks.
|
||||
*
|
||||
* Carries the sr-only ~50-word product summary for AI citation (CLAUDE.md → GEO).
|
||||
*/
|
||||
export function Hero() {
|
||||
return (
|
||||
<section
|
||||
id='hero'
|
||||
aria-labelledby='hero-heading'
|
||||
className={cn(
|
||||
'flex flex-col items-start gap-[22px] text-left',
|
||||
LANDING_CONTENT_WIDTH,
|
||||
LANDING_GUTTER,
|
||||
LANDING_HERO_TOP_PADDING
|
||||
)}
|
||||
>
|
||||
<p className='sr-only'>
|
||||
Sim is the open-source AI workspace where teams build, deploy, and manage AI agents. Connect
|
||||
1,000+ integrations and every major LLM to create agents that automate real work, visually,
|
||||
conversationally, or with code. Trusted by over 100,000 builders, SOC2 compliant, and
|
||||
production-ready for teams of every size.
|
||||
</p>
|
||||
|
||||
<LandingHeroHeader
|
||||
headingId='hero-heading'
|
||||
heading={
|
||||
<>
|
||||
Sim is your AI workspace <br />
|
||||
for building agentic workflows.
|
||||
</>
|
||||
}
|
||||
description='The open-source workspace where teams build, deploy, and manage AI agents.'
|
||||
/>
|
||||
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='relative mt-[34px] aspect-[1300/720] w-full overflow-hidden rounded-lg bg-[var(--surface-3)] max-sm:aspect-[4/3]'
|
||||
>
|
||||
<Image
|
||||
src='/landing/hero-backdrop.jpg'
|
||||
alt=''
|
||||
fill
|
||||
priority
|
||||
fetchPriority='high'
|
||||
quality={90}
|
||||
sizes='(max-width: 1460px) 100vw, 1300px'
|
||||
className='object-cover'
|
||||
/>
|
||||
<div className='-translate-x-1/2 -translate-y-1/2 absolute top-1/2 left-1/2 flex aspect-[1080/620] w-[83.08%] flex-col overflow-hidden rounded-[10px] bg-[var(--surface-1)] shadow-[0_0_0_1px_rgba(0,0,0,0.08),0_2px_6px_0_rgba(0,0,0,0.05),0_4px_42px_0_rgba(0,0,0,0.06)]'>
|
||||
<div className='relative flex-1'>
|
||||
<Image
|
||||
src='/landing/hero-platform-ui.png'
|
||||
alt=''
|
||||
fill
|
||||
priority
|
||||
sizes='(max-width: 1460px) 83vw, 1080px'
|
||||
className='object-cover object-left-top'
|
||||
/>
|
||||
<HeroPlatformLoop />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TrustedBy layout='row' className='mt-[42px] w-full max-sm:mt-6' />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { Hero } from './hero'
|
||||
@@ -0,0 +1,145 @@
|
||||
import { SITE_URL } from '@/lib/core/utils/urls'
|
||||
import { JsonLd } from '@/app/(landing)/components/json-ld'
|
||||
|
||||
/**
|
||||
* Home-page JSON-LD - the entities specific to `/`: the `WebPage`, its
|
||||
* `BreadcrumbList`, the product `WebApplication` (`#software`, with offers /
|
||||
* featureList / reviews), and the `SoftwareSourceCode`.
|
||||
*
|
||||
* Rendered only by the landing root (`landing.tsx`), server-side before visible
|
||||
* content. The site-wide `Organization` / `WebSite` entities live in
|
||||
* {@link SiteStructuredData} (emitted by the shared layout on every page); the
|
||||
* nodes here reference them by `@id` (`${SITE_URL}#website` / `#organization`).
|
||||
*
|
||||
* Maintenance:
|
||||
* - Offer prices must match the Pricing component exactly.
|
||||
* - All claims must also appear as visible text on the page.
|
||||
* - Do not add `aggregateRating` without real, verifiable review data.
|
||||
*/
|
||||
const HOME_JSON_LD = {
|
||||
'@context': 'https://schema.org',
|
||||
'@graph': [
|
||||
{
|
||||
'@type': 'WebPage',
|
||||
'@id': `${SITE_URL}#webpage`,
|
||||
url: SITE_URL,
|
||||
name: 'Sim, The AI Workspace | Build, Deploy & Manage AI Agents',
|
||||
isPartOf: { '@id': `${SITE_URL}#website` },
|
||||
about: { '@id': `${SITE_URL}#software` },
|
||||
datePublished: '2024-01-01T00:00:00+00:00',
|
||||
description:
|
||||
'Sim is the open-source AI workspace where teams build, deploy, and manage AI agents. Connect 1,000+ integrations and every major LLM to create agents that automate real work.',
|
||||
breadcrumb: { '@id': `${SITE_URL}#breadcrumb` },
|
||||
inLanguage: 'en-US',
|
||||
speakable: {
|
||||
'@type': 'SpeakableSpecification',
|
||||
cssSelector: ['#hero-heading', '[id="hero"] p'],
|
||||
},
|
||||
potentialAction: [{ '@type': 'ReadAction', target: [SITE_URL] }],
|
||||
},
|
||||
{
|
||||
'@type': 'BreadcrumbList',
|
||||
'@id': `${SITE_URL}#breadcrumb`,
|
||||
itemListElement: [{ '@type': 'ListItem', position: 1, name: 'Home', item: SITE_URL }],
|
||||
},
|
||||
{
|
||||
'@type': 'WebApplication',
|
||||
'@id': `${SITE_URL}#software`,
|
||||
url: SITE_URL,
|
||||
name: 'Sim, The AI Workspace',
|
||||
description:
|
||||
'Sim is the open-source AI workspace where teams build, deploy, and manage AI agents. Connect 1,000+ integrations and every major LLM to create agents that automate real work, visually, conversationally, or with code. Trusted by over 100,000 builders. SOC2 compliant.',
|
||||
applicationCategory: 'BusinessApplication',
|
||||
applicationSubCategory: 'AI Workspace',
|
||||
operatingSystem: 'Web',
|
||||
browserRequirements: 'Requires a modern browser with JavaScript enabled',
|
||||
installUrl: `${SITE_URL}/signup`,
|
||||
offers: [
|
||||
{
|
||||
'@type': 'Offer',
|
||||
name: 'Community Plan: 1,000 credits included',
|
||||
price: '0',
|
||||
priceCurrency: 'USD',
|
||||
availability: 'https://schema.org/InStock',
|
||||
},
|
||||
{
|
||||
'@type': 'Offer',
|
||||
name: 'Pro Plan: 6,000 credits/month',
|
||||
price: '25',
|
||||
priceCurrency: 'USD',
|
||||
priceSpecification: {
|
||||
'@type': 'UnitPriceSpecification',
|
||||
price: '25',
|
||||
priceCurrency: 'USD',
|
||||
unitText: 'MONTH',
|
||||
billingIncrement: 1,
|
||||
},
|
||||
availability: 'https://schema.org/InStock',
|
||||
},
|
||||
{
|
||||
'@type': 'Offer',
|
||||
name: 'Max Plan: 25,000 credits/month',
|
||||
price: '100',
|
||||
priceCurrency: 'USD',
|
||||
priceSpecification: {
|
||||
'@type': 'UnitPriceSpecification',
|
||||
price: '100',
|
||||
priceCurrency: 'USD',
|
||||
unitText: 'MONTH',
|
||||
billingIncrement: 1,
|
||||
},
|
||||
availability: 'https://schema.org/InStock',
|
||||
},
|
||||
],
|
||||
featureList: [
|
||||
'AI workspace for teams',
|
||||
'Mothership: natural language agent creation',
|
||||
'Visual workflow builder',
|
||||
'1,000+ integrations',
|
||||
'LLM orchestration (OpenAI, Anthropic, Google, xAI, Mistral, Perplexity)',
|
||||
'Knowledge base creation',
|
||||
'Table creation',
|
||||
'Document creation',
|
||||
'API access',
|
||||
'Custom functions',
|
||||
'Scheduled workflows',
|
||||
'Event triggers',
|
||||
],
|
||||
review: [
|
||||
{
|
||||
'@type': 'Review',
|
||||
author: { '@type': 'Person', name: 'Hasan Toor' },
|
||||
reviewBody:
|
||||
'This startup just dropped the fastest way to build AI agents. This Figma-like canvas to build agents will blow your mind.',
|
||||
url: 'https://x.com/hasantoxr/status/1912909502036525271',
|
||||
},
|
||||
{
|
||||
'@type': 'Review',
|
||||
author: { '@type': 'Person', name: 'nizzy' },
|
||||
reviewBody:
|
||||
'This is the zapier of agent building. I always believed that building agents and using AI should not be limited to technical people. I think this solves just that.',
|
||||
url: 'https://x.com/nizzyabi/status/1907864421227180368',
|
||||
},
|
||||
{
|
||||
'@type': 'Review',
|
||||
author: { '@type': 'Organization', name: 'xyflow' },
|
||||
reviewBody: 'A very good looking agent workflow builder and open source!',
|
||||
url: 'https://x.com/xyflowdev/status/1909501499719438670',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
'@type': 'SoftwareSourceCode',
|
||||
'@id': `${SITE_URL}#source`,
|
||||
codeRepository: 'https://github.com/simstudioai/sim',
|
||||
programmingLanguage: ['TypeScript', 'Python'],
|
||||
runtimePlatform: 'Node.js',
|
||||
license: 'https://opensource.org/licenses/Apache-2.0',
|
||||
isPartOf: { '@id': `${SITE_URL}#software` },
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export function HomeStructuredData() {
|
||||
return <JsonLd data={HOME_JSON_LD} />
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { HomeStructuredData } from './home-structured-data'
|
||||
@@ -0,0 +1,39 @@
|
||||
export { BackLink } from './back-link'
|
||||
export { ChevronArrow } from './chevron-arrow'
|
||||
export { ContentAuthorLoading, ContentAuthorPage } from './content-author-page'
|
||||
export { ContentImage } from './content-image'
|
||||
export { ContentIndexLoading, ContentIndexPage } from './content-index-page'
|
||||
export { ContentPostLoading, ContentPostPage } from './content-post-page'
|
||||
export { ContentTagsLoading, ContentTagsPage } from './content-tags-page'
|
||||
export { Cta } from './cta/cta'
|
||||
export { Features } from './features'
|
||||
export { Footer } from './footer'
|
||||
export { Hero } from './hero'
|
||||
export { HomeStructuredData } from './home-structured-data'
|
||||
export type { JsonLdData } from './json-ld'
|
||||
export { JsonLd } from './json-ld'
|
||||
export { LandingShell } from './landing-shell'
|
||||
export { Lifecycle } from './lifecycle'
|
||||
export { Lightbox } from './lightbox'
|
||||
export { LogoShell } from './logo-shell'
|
||||
export { Mothership } from './mothership/mothership'
|
||||
export { Navbar } from './navbar'
|
||||
export type {
|
||||
PlatformCardConfig,
|
||||
PlatformCardRowConfig,
|
||||
PlatformHeroConfig,
|
||||
PlatformPageConfig,
|
||||
PlatformPillCta,
|
||||
} from './platform-page'
|
||||
export { PlatformPage } from './platform-page'
|
||||
export { ProductDemo } from './product-demo'
|
||||
export { ShareButton } from './share-button'
|
||||
export { SiteStructuredData } from './site-structured-data'
|
||||
export type {
|
||||
SolutionsCardConfig,
|
||||
SolutionsCardRowConfig,
|
||||
SolutionsHeroConfig,
|
||||
SolutionsPageConfig,
|
||||
SolutionsPillCta,
|
||||
} from './solutions-page'
|
||||
export { SolutionsPage } from './solutions-page'
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { JsonLdData } from './json-ld'
|
||||
export { JsonLd } from './json-ld'
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { ReactElement } from 'react'
|
||||
|
||||
/**
|
||||
* Characters that break out of an inline HTML `<script>` context, mapped to
|
||||
* their unicode escapes. `<` is the dangerous one (`</script>`); `>` and `&` are
|
||||
* escaped for completeness, and the JS line/paragraph separators (U+2028/U+2029)
|
||||
* keep the payload valid inside inline scripts.
|
||||
*/
|
||||
const HTML_ESCAPES: Record<string, string> = {
|
||||
'<': '\\u003c',
|
||||
'>': '\\u003e',
|
||||
'&': '\\u0026',
|
||||
[String.fromCharCode(0x2028)]: '\\u2028',
|
||||
[String.fromCharCode(0x2029)]: '\\u2029',
|
||||
}
|
||||
|
||||
const UNSAFE_HTML_CHARS = new RegExp(`[<>&${String.fromCharCode(0x2028, 0x2029)}]`, 'g')
|
||||
|
||||
/**
|
||||
* Serialize structured data for an inline `application/ld+json` script. Plain
|
||||
* `JSON.stringify` does not HTML-escape, so a `</script>` (or stray `<`) in the
|
||||
* data would break out of the script tag and become an XSS sink. Escaping these
|
||||
* characters as unicode escapes keeps the JSON valid and semantically identical,
|
||||
* so crawlers read the exact same graph — SEO output is unchanged.
|
||||
*/
|
||||
function serializeJsonLd(data: JsonLdData): string {
|
||||
return JSON.stringify(data).replace(UNSAFE_HTML_CHARS, (char) => HTML_ESCAPES[char])
|
||||
}
|
||||
|
||||
export type JsonLdData = Record<string, unknown>
|
||||
|
||||
interface JsonLdProps {
|
||||
data: JsonLdData
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-rendered JSON-LD `<script>`. The single source of truth for emitting
|
||||
* structured data across the landing surface — every page/section passes its
|
||||
* schema graph as `data` and this owns the safe serialization. Render it in a
|
||||
* Server Component before visible content so crawlers and AI answer engines read
|
||||
* the graph first.
|
||||
*/
|
||||
export function JsonLd({ data }: JsonLdProps): ReactElement {
|
||||
return (
|
||||
<script
|
||||
type='application/ld+json'
|
||||
dangerouslySetInnerHTML={{ __html: serializeJsonLd(data) }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { LandingFAQ } from './landing-faq'
|
||||
@@ -0,0 +1,90 @@
|
||||
'use client'
|
||||
|
||||
import { useId, useState } from 'react'
|
||||
import { ChevronDown, cn } from '@sim/emcn'
|
||||
import { domAnimation, LazyMotion, m } from 'framer-motion'
|
||||
|
||||
interface LandingFAQItem {
|
||||
question: string
|
||||
answer: string
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
return (
|
||||
<LazyMotion features={domAnimation}>
|
||||
<div>
|
||||
{faqs.map(({ question, answer }, index) => {
|
||||
const isOpen = openIndex === index
|
||||
const showDivider = index > 0 && hoveredIndex !== index && hoveredIndex !== index - 1
|
||||
const panelId = `${baseId}-faq-panel-${index}`
|
||||
|
||||
return (
|
||||
<div key={question}>
|
||||
<div
|
||||
className={cn(
|
||||
'h-px w-full bg-[var(--border)]',
|
||||
index === 0 || !showDivider ? 'invisible' : 'visible'
|
||||
)}
|
||||
/>
|
||||
<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(--surface-hover)]'
|
||||
aria-expanded={isOpen}
|
||||
aria-controls={panelId}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'text-[15px] leading-snug tracking-[-0.02em] transition-colors',
|
||||
isOpen
|
||||
? 'text-[var(--text-primary)]'
|
||||
: 'text-[var(--text-body)] hover:text-[var(--text-primary)]'
|
||||
)}
|
||||
>
|
||||
{question}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'size-3 shrink-0 text-[var(--text-muted)] 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(--text-body)] leading-[1.75]'>{answer}</p>
|
||||
</div>
|
||||
</m.div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</LazyMotion>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/** Shared centered width used by the landing navbar and every primary section. */
|
||||
export const LANDING_CONTENT_WIDTH = 'mx-auto w-full max-w-[1460px]'
|
||||
|
||||
/** Shared responsive horizontal gutter used across the landing family. */
|
||||
export const LANDING_GUTTER = 'px-20 max-sm:px-5 max-lg:px-8'
|
||||
|
||||
/** Shared responsive top clearance for the first landing hero beneath the navbar. */
|
||||
export const LANDING_HERO_TOP_PADDING = 'pt-[112px] max-sm:pt-12 max-xl:pt-20'
|
||||
|
||||
/** Shared vertical rhythm between top-level landing sections. */
|
||||
export const LANDING_SECTION_RHYTHM = 'gap-[120px] max-sm:gap-16 max-lg:gap-[88px]'
|
||||
|
||||
/**
|
||||
* Extra top separation for a hero CTA over the hero stack gap. Headline and
|
||||
* description are one copy group and keep the tight 22px stack gap; the CTA is
|
||||
* a separate action group, so its description→CTA gap lands at 34px (22 + 12),
|
||||
* roughly 1.5× the headline→description gap.
|
||||
*/
|
||||
export const LANDING_HERO_CTA_GAP = 'mt-3'
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
'use client'
|
||||
|
||||
import { useRef } from 'react'
|
||||
import { ArrowUp, cn, Mic, Paperclip, Slash } from '@sim/emcn'
|
||||
|
||||
interface LandingPreviewChatInputProps {
|
||||
value: string
|
||||
onChange?: (value: string) => void
|
||||
onSubmit: () => void
|
||||
placeholder: string
|
||||
/** Locks the field (used while the demo auto-types). */
|
||||
readOnly?: boolean
|
||||
/** Hides the caret (auto-type has no real cursor). */
|
||||
caretHidden?: boolean
|
||||
/** Lifts the field with the home-view shadow (only the initial empty state). */
|
||||
shadow?: boolean
|
||||
}
|
||||
|
||||
const ICON_BUTTON =
|
||||
'flex size-[28px] flex-shrink-0 items-center justify-center rounded-full transition-colors hover-hover:bg-[var(--surface-1)]'
|
||||
|
||||
/**
|
||||
* The canonical Mothership chat input - a faithful copy of the workspace
|
||||
* `UserInput`: a white, `rounded-[17px]` field with the text area on top and a
|
||||
* control row beneath (attach + skills on the left, mic + send on the right).
|
||||
* The send button carries the real `SEND_BUTTON` fills (`#383838` active,
|
||||
* `#808080` disabled). Shared by the home empty state and the docked chat pane
|
||||
* so both read identically.
|
||||
*/
|
||||
export function LandingPreviewChatInput({
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
placeholder,
|
||||
readOnly = false,
|
||||
caretHidden = false,
|
||||
shadow = false,
|
||||
}: LandingPreviewChatInputProps) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const isEmpty = value.trim().length === 0
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => textareaRef.current?.focus()}
|
||||
className={cn(
|
||||
'cursor-text rounded-[17px] border border-[var(--border-1)] bg-[var(--surface-2)] px-2.5 py-2',
|
||||
shadow && 'shadow-[0_1px_2px_0_rgba(18,18,18,0.05)]'
|
||||
)}
|
||||
>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
if (!readOnly) onChange?.(e.target.value)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
onSubmit()
|
||||
}
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
aria-label={placeholder}
|
||||
rows={1}
|
||||
readOnly={readOnly}
|
||||
className='m-0 block max-h-[200px] min-h-[24px] w-full resize-none overflow-y-auto border-0 bg-transparent px-1 py-1 font-body text-[15px] text-[var(--text-primary)] leading-[24px] tracking-[-0.015em] outline-none placeholder:font-[380] placeholder:text-[var(--text-muted)] focus-visible:ring-0'
|
||||
style={{ caretColor: caretHidden ? 'transparent' : 'var(--text-primary)' }}
|
||||
/>
|
||||
|
||||
<div className='mt-1 flex items-center justify-between'>
|
||||
<div className='flex items-center gap-1'>
|
||||
<span className={ICON_BUTTON}>
|
||||
<Paperclip className='size-[16px] text-[var(--text-muted)]' />
|
||||
</span>
|
||||
<span className={ICON_BUTTON}>
|
||||
<Slash className='size-[16px] text-[var(--text-muted)]' />
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<span className={ICON_BUTTON}>
|
||||
<Mic className='size-[16px] text-[var(--text-muted)]' />
|
||||
</span>
|
||||
<button
|
||||
type='button'
|
||||
onClick={onSubmit}
|
||||
disabled={isEmpty}
|
||||
aria-label='Send message'
|
||||
className={cn(
|
||||
'flex size-[28px] flex-shrink-0 items-center justify-center rounded-full border-0 p-0 transition-colors',
|
||||
isEmpty ? 'cursor-not-allowed bg-[#808080]' : 'bg-[#383838] hover-hover:bg-[#575757]'
|
||||
)}
|
||||
>
|
||||
<ArrowUp className='size-[16px] text-white' />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
import { BubbleChatDelay, ChevronDown, PanelLeft, X } from '@sim/emcn'
|
||||
|
||||
interface LandingPreviewChatTitleBarProps {
|
||||
/** Chat name shown in the switcher chip. */
|
||||
chatName: string
|
||||
/** Renders the close (×) control at the right edge (shown when a resource is staged). */
|
||||
showClose?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The chat pane's title bar - a faithful copy of the workspace `ChatTitleBar`:
|
||||
* the sidebar toggle, the chat-switcher split pill (chat icon + name | chevron),
|
||||
* and an optional close control. Shared by the docked chat pane and the home
|
||||
* empty state so the two read identically.
|
||||
*/
|
||||
export function LandingPreviewChatTitleBar({
|
||||
chatName,
|
||||
showClose = false,
|
||||
}: LandingPreviewChatTitleBarProps) {
|
||||
return (
|
||||
<div className='flex h-[44px] flex-shrink-0 items-center gap-1 border-[var(--border)] border-b px-4'>
|
||||
<span className='-ml-[9px] flex size-[30px] flex-shrink-0 items-center justify-center rounded-lg transition-colors hover-hover:bg-[var(--surface-active)]'>
|
||||
<PanelLeft className='size-[16px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
</span>
|
||||
{/* Chat-switcher split pill: icon+name segment and a chevron segment
|
||||
sliced by a 1px panel-colored divider. */}
|
||||
<span className='flex h-[30px] min-w-0 flex-shrink items-stretch'>
|
||||
<span className='flex min-w-0 items-center gap-1.5 rounded-l-lg pr-1 pl-2 transition-colors hover-hover:bg-[var(--surface-active)]'>
|
||||
<BubbleChatDelay className='size-[16px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
<span className='min-w-0 truncate text-[var(--text-primary)] text-sm'>{chatName}</span>
|
||||
</span>
|
||||
<span aria-hidden='true' className='w-px self-stretch bg-[var(--surface-2)]' />
|
||||
<span className='flex items-center rounded-r-lg px-1 transition-colors hover-hover:bg-[var(--surface-active)]'>
|
||||
<ChevronDown className='h-[6px] w-[10px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
</span>
|
||||
</span>
|
||||
{showClose && (
|
||||
<span className='-mr-[9px] ml-auto flex size-[30px] flex-shrink-0 items-center justify-center rounded-lg transition-colors hover-hover:bg-[var(--surface-active)]'>
|
||||
<X className='size-[14px] text-[var(--text-icon)]' />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { cn } from '@sim/emcn'
|
||||
import { ThinkingLoader } from '@/components/ui'
|
||||
import { LandingPreviewChatInput } from '@/app/(landing)/components/landing-preview/components/landing-preview-chat/chat-input'
|
||||
import { LandingPreviewChatTitleBar } from '@/app/(landing)/components/landing-preview/components/landing-preview-chat/chat-title-bar'
|
||||
import type { PreviewChat } from '@/app/(landing)/components/landing-preview/components/landing-preview-workflow/workflow-data'
|
||||
import { useLandingSubmit } from '@/app/(landing)/components/landing-preview/hooks/use-landing-submit'
|
||||
|
||||
interface LandingPreviewChatProps {
|
||||
/** The scripted exchange to play, or `null` to show only the input. */
|
||||
chat: PreviewChat | null
|
||||
/** Name shown in the chat-switcher breadcrumb chip. */
|
||||
chatName: string
|
||||
/** Re-runs the reveal timeline whenever it changes (one per staged resource). */
|
||||
animationKey: number
|
||||
}
|
||||
|
||||
/** Reveal beats for the scripted conversation, in ms from the step start. */
|
||||
const THINKING_AT = 480
|
||||
const ASSISTANT_AT = 1280
|
||||
|
||||
/** Ordered stages of the scripted reveal (user request -> think -> reply). */
|
||||
type RevealPhase = 'hidden' | 'user' | 'thinking' | 'assistant'
|
||||
|
||||
/**
|
||||
* The Mothership chat pane - the persistent left column of the "chat everywhere"
|
||||
* layout. Its title bar carries the chat-switcher breadcrumb (a chat-bubble
|
||||
* chip + the active chat's name + chevron) exactly like the real workspace, so
|
||||
* the chat reads as the constant that every staged resource hangs off of.
|
||||
*
|
||||
* On each `animationKey` change it replays a short scripted exchange: the user's
|
||||
* request slides in, Sim "thinks" (the cycle loader), then the reply slides in -
|
||||
* mirroring the workflow building itself on the staged panel to the right. The
|
||||
* reveal is plain state + CSS transition (no layout animation) so it composes
|
||||
* under the preview's root `LazyMotion` without pulling in `domMax`.
|
||||
*
|
||||
* The input is live: submitting stores the prompt and routes to `/signup`, so a
|
||||
* visitor's first message survives the auth hop.
|
||||
*/
|
||||
export function LandingPreviewChat({ chat, chatName, animationKey }: LandingPreviewChatProps) {
|
||||
const submit = useLandingSubmit()
|
||||
const [value, setValue] = useState('')
|
||||
const [phase, setPhase] = useState<RevealPhase>('hidden')
|
||||
const revealRef = useRef<{ key: number; chat: PreviewChat | null }>({ key: animationKey, chat })
|
||||
|
||||
/**
|
||||
* Restart the reveal synchronously when the timeline or staged chat changes, so the
|
||||
* pane never flashes the previous reply before the effect reruns. The previous
|
||||
* key/chat live in a ref: updates come from the parent's timer-driven state (never a
|
||||
* transition/Suspense boundary), so the render can't be discarded between the ref
|
||||
* write and commit.
|
||||
*/
|
||||
if (revealRef.current.key !== animationKey || revealRef.current.chat !== chat) {
|
||||
revealRef.current = { key: animationKey, chat }
|
||||
setPhase('hidden')
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!chat) return
|
||||
const raf = requestAnimationFrame(() => setPhase('user'))
|
||||
const t1 = setTimeout(() => setPhase('thinking'), THINKING_AT)
|
||||
const t2 = setTimeout(() => setPhase('assistant'), ASSISTANT_AT)
|
||||
return () => {
|
||||
cancelAnimationFrame(raf)
|
||||
clearTimeout(t1)
|
||||
clearTimeout(t2)
|
||||
}
|
||||
}, [animationKey, chat])
|
||||
|
||||
const showUser = phase !== 'hidden'
|
||||
const showThinking = phase === 'thinking'
|
||||
const showAssistant = phase === 'assistant'
|
||||
|
||||
const isEmpty = value.trim().length === 0
|
||||
const handleSubmit = () => {
|
||||
if (isEmpty) return
|
||||
submit(value)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex h-full w-[400px] flex-shrink-0 flex-col bg-[var(--surface-2)]'>
|
||||
<LandingPreviewChatTitleBar chatName={chatName} showClose />
|
||||
|
||||
{/* Conversation - bottom-anchored so it rests just above the input. */}
|
||||
<div className='flex min-h-0 flex-1 flex-col justify-end gap-3 overflow-hidden px-3.5 pt-4 pb-1'>
|
||||
{chat && (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
'max-w-[85%] self-end rounded-2xl bg-[var(--surface-1)] px-3.5 py-2 text-[13.5px] text-[var(--text-primary)] leading-[1.45] transition-[opacity,transform] duration-300 ease-[cubic-bezier(0.2,0,0,1)]',
|
||||
showUser ? 'translate-y-0 opacity-100' : 'translate-y-1.5 opacity-0'
|
||||
)}
|
||||
>
|
||||
{chat.user}
|
||||
</div>
|
||||
|
||||
<div className='min-h-[20px] self-start pr-2'>
|
||||
{showThinking && <ThinkingLoader size={20} startVariant='corners' />}
|
||||
{showAssistant && (
|
||||
<p
|
||||
className={cn(
|
||||
'max-w-[92%] text-[13.5px] text-[var(--text-primary)] leading-[1.5] transition-[opacity,transform] duration-300 ease-[cubic-bezier(0.2,0,0,1)]',
|
||||
showAssistant ? 'translate-y-0 opacity-100' : 'translate-y-1.5 opacity-0'
|
||||
)}
|
||||
>
|
||||
{chat.assistant}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input - live: routes a typed prompt to /signup. */}
|
||||
<div className='flex-shrink-0 px-3 pt-2 pb-3'>
|
||||
<LandingPreviewChatInput
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
onSubmit={handleSubmit}
|
||||
placeholder='Ask Sim anything…'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
import { File } from '@sim/emcn/icons'
|
||||
import { DocxIcon, PdfIcon } from '@/components/icons/document-icons'
|
||||
import type {
|
||||
PreviewColumn,
|
||||
PreviewRow,
|
||||
} from '@/app/(landing)/components/landing-preview/components/landing-preview-resource/landing-preview-resource'
|
||||
import { LandingPreviewResource } from '@/app/(landing)/components/landing-preview/components/landing-preview-resource/landing-preview-resource'
|
||||
import { ownerCell } from '@/app/(landing)/components/landing-preview/components/landing-preview-resource/utils'
|
||||
|
||||
/** Generic audio/zip icon using basic SVG since no dedicated component exists */
|
||||
function AudioIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.5'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
className={className}
|
||||
>
|
||||
<path d='M9 18V5l12-2v13' />
|
||||
<circle cx='6' cy='18' r='3' />
|
||||
<circle cx='18' cy='16' r='3' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function JsonlIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.5'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
className={className}
|
||||
>
|
||||
<path d='M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z' />
|
||||
<path d='M14 2v4a2 2 0 0 0 2 2h4' />
|
||||
<path d='M10 9H8' />
|
||||
<path d='M16 13H8' />
|
||||
<path d='M16 17H8' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function ZipIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.5'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
className={className}
|
||||
>
|
||||
<path d='M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z' />
|
||||
<path d='M14 2v4a2 2 0 0 0 2 2h4' />
|
||||
<path d='M10 6h1' />
|
||||
<path d='M10 10h1' />
|
||||
<path d='M10 14h1' />
|
||||
<path d='M9 18h2v2h-2z' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
const COLUMNS: PreviewColumn[] = [
|
||||
{ id: 'name', header: 'Name' },
|
||||
{ id: 'size', header: 'Size' },
|
||||
{ id: 'type', header: 'Type' },
|
||||
{ id: 'created', header: 'Created' },
|
||||
{ id: 'owner', header: 'Owner' },
|
||||
]
|
||||
|
||||
const ROWS: PreviewRow[] = [
|
||||
{
|
||||
id: '1',
|
||||
cells: {
|
||||
name: { icon: <PdfIcon className='size-[14px]' />, label: 'Q1 Performance Report.pdf' },
|
||||
size: { label: '2.4 MB' },
|
||||
type: { icon: <PdfIcon className='size-[14px]' />, label: 'PDF' },
|
||||
created: { label: '3 hours ago' },
|
||||
owner: ownerCell('T', 'Theo L.'),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
cells: {
|
||||
name: { icon: <ZipIcon className='size-[14px]' />, label: 'product-screenshots.zip' },
|
||||
size: { label: '18.7 MB' },
|
||||
type: { icon: <ZipIcon className='size-[14px]' />, label: 'ZIP' },
|
||||
created: { label: '1 day ago' },
|
||||
owner: ownerCell('A', 'Alex M.'),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
cells: {
|
||||
name: { icon: <JsonlIcon className='size-[14px]' />, label: 'training-dataset.jsonl' },
|
||||
size: { label: '892 KB' },
|
||||
type: { icon: <JsonlIcon className='size-[14px]' />, label: 'JSONL' },
|
||||
created: { label: '3 days ago' },
|
||||
owner: ownerCell('J', 'Jordan P.'),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
cells: {
|
||||
name: { icon: <PdfIcon className='size-[14px]' />, label: 'brand-guidelines.pdf' },
|
||||
size: { label: '5.1 MB' },
|
||||
type: { icon: <PdfIcon className='size-[14px]' />, label: 'PDF' },
|
||||
created: { label: '1 week ago' },
|
||||
owner: ownerCell('S', 'Sarah K.'),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
cells: {
|
||||
name: { icon: <AudioIcon className='size-[14px]' />, label: 'customer-interviews.mp3' },
|
||||
size: { label: '45.2 MB' },
|
||||
type: { icon: <AudioIcon className='size-[14px]' />, label: 'Audio' },
|
||||
created: { label: 'March 20th, 2026' },
|
||||
owner: ownerCell('V', 'Vik M.'),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
cells: {
|
||||
name: { icon: <DocxIcon className='size-[14px]' />, label: 'onboarding-playbook.docx' },
|
||||
size: { label: '1.1 MB' },
|
||||
type: { icon: <DocxIcon className='size-[14px]' />, label: 'DOCX' },
|
||||
created: { label: 'March 14th, 2026' },
|
||||
owner: ownerCell('S', 'Sarah K.'),
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* Static landing preview of the Files workspace page.
|
||||
*/
|
||||
export function LandingPreviewFiles() {
|
||||
return (
|
||||
<LandingPreviewResource
|
||||
icon={File}
|
||||
title='Files'
|
||||
createLabel='Upload file'
|
||||
searchPlaceholder='Search files...'
|
||||
columns={COLUMNS}
|
||||
rows={ROWS}
|
||||
/>
|
||||
)
|
||||
}
|
||||
+458
@@ -0,0 +1,458 @@
|
||||
'use client'
|
||||
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { ArrowRight, Blimp, Checkbox, ChevronDown, cn, Shuffle } from '@sim/emcn'
|
||||
import { TypeBoolean, TypeNumber, TypeText } from '@sim/emcn/icons'
|
||||
import { AnimatePresence, domAnimation, LazyMotion, m } from 'framer-motion'
|
||||
import { Mail, MessageSquare, Table } from 'lucide-react'
|
||||
import { captureClientEvent } from '@/lib/posthog/client'
|
||||
import { LandingPreviewChatInput } from '@/app/(landing)/components/landing-preview/components/landing-preview-chat/chat-input'
|
||||
import { LandingPreviewChatTitleBar } from '@/app/(landing)/components/landing-preview/components/landing-preview-chat/chat-title-bar'
|
||||
import { EASE_OUT } from '@/app/(landing)/components/landing-preview/components/landing-preview-workflow/workflow-data'
|
||||
import { useLandingSubmit } from '@/app/(landing)/components/landing-preview/hooks/use-landing-submit'
|
||||
import { useAnimatedPlaceholder } from '@/hooks/use-animated-placeholder'
|
||||
|
||||
/** Static, greyscale suggestion rows mirroring the workspace SuggestedActions. */
|
||||
const SUGGESTED_ACTIONS = [
|
||||
{ id: 'crm', label: 'Create a CRM with sample data', icon: Table },
|
||||
{ id: 'slack', label: 'Summarize my unread Slack messages', icon: MessageSquare },
|
||||
{ id: 'gmail', label: 'Draft replies to my support emails', icon: Mail },
|
||||
{ id: 'tracker', label: 'Build a project tracker table', icon: Table },
|
||||
] as const
|
||||
|
||||
const AUTO_PROMPT = 'Analyze our customer leads and identify the top prospects'
|
||||
|
||||
const MOCK_RESPONSE =
|
||||
'I analyzed your **Customer Leads** table and found **3 top prospects** with the highest lead scores:\n\n1. **Carol Davis** (StartupCo), Score: 94\n2. **Frank Lee** (Ventures), Score: 88\n3. **Alice Johnson** (Acme Corp), Score: 87\n\nAll three are qualified leads. Want me to draft outreach emails?'
|
||||
|
||||
const HOME_TYPE_MS = 40
|
||||
const HOME_TYPE_START_MS = 600
|
||||
const TOOL_CALL_DELAY_MS = 500
|
||||
const RESPONSE_DELAY_MS = 800
|
||||
const RESOURCE_PANEL_DELAY_MS = 600
|
||||
|
||||
const MINI_TABLE_COLUMNS = [
|
||||
{ id: 'name', label: 'Name', type: 'text' as const, width: '32%' },
|
||||
{ id: 'company', label: 'Company', type: 'text' as const, width: '30%' },
|
||||
{ id: 'score', label: 'Score', type: 'number' as const, width: '18%' },
|
||||
{ id: 'qualified', label: 'Qualified', type: 'boolean' as const, width: '20%' },
|
||||
]
|
||||
|
||||
const MINI_TABLE_ROWS = [
|
||||
{ name: 'Alice Johnson', company: 'Acme Corp', score: '87', qualified: 'true' },
|
||||
{ name: 'Bob Williams', company: 'TechCo', score: '62', qualified: 'false' },
|
||||
{ name: 'Carol Davis', company: 'StartupCo', score: '94', qualified: 'true' },
|
||||
{ name: 'Dan Miller', company: 'BigCorp', score: '71', qualified: 'true' },
|
||||
{ name: 'Eva Chen', company: 'Design IO', score: '45', qualified: 'false' },
|
||||
{ name: 'Frank Lee', company: 'Ventures', score: '88', qualified: 'true' },
|
||||
]
|
||||
|
||||
const COLUMN_TYPE_ICONS = {
|
||||
text: TypeText,
|
||||
number: TypeNumber,
|
||||
boolean: TypeBoolean,
|
||||
} as const
|
||||
|
||||
interface LandingPreviewHomeProps {
|
||||
autoType?: boolean
|
||||
}
|
||||
|
||||
type ChatPhase = 'input' | 'sent' | 'tool-call' | 'responding' | 'done'
|
||||
|
||||
/**
|
||||
* Landing preview replica of the workspace Home view.
|
||||
*
|
||||
* When `autoType` is true, automatically types a prompt, sends it,
|
||||
* shows a mothership agent group with tool calls, types a response,
|
||||
* and opens a resource panel - matching the real workspace chat UI.
|
||||
*/
|
||||
export const LandingPreviewHome = memo(function LandingPreviewHome({
|
||||
autoType = false,
|
||||
}: LandingPreviewHomeProps) {
|
||||
const landingSubmit = useLandingSubmit()
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const animatedPlaceholder = useAnimatedPlaceholder()
|
||||
|
||||
const [chatPhase, setChatPhase] = useState<ChatPhase>('input')
|
||||
const [responseTypedLength, setResponseTypedLength] = useState(0)
|
||||
const [showResourcePanel, setShowResourcePanel] = useState(false)
|
||||
const [toolsExpanded, setToolsExpanded] = useState(true)
|
||||
|
||||
const typeIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const responseIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const timersRef = useRef<ReturnType<typeof setTimeout>[]>([])
|
||||
|
||||
const clearAllTimers = useCallback(() => {
|
||||
for (const t of timersRef.current) clearTimeout(t)
|
||||
timersRef.current = []
|
||||
if (typeIntervalRef.current) clearInterval(typeIntervalRef.current)
|
||||
if (responseIntervalRef.current) clearInterval(responseIntervalRef.current)
|
||||
typeIntervalRef.current = null
|
||||
responseIntervalRef.current = null
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoType) return
|
||||
|
||||
setChatPhase('input')
|
||||
setResponseTypedLength(0)
|
||||
setShowResourcePanel(false)
|
||||
setToolsExpanded(true)
|
||||
setInputValue('')
|
||||
|
||||
const t1 = setTimeout(() => {
|
||||
let idx = 0
|
||||
typeIntervalRef.current = setInterval(() => {
|
||||
idx++
|
||||
setInputValue(AUTO_PROMPT.slice(0, idx))
|
||||
if (idx >= AUTO_PROMPT.length) {
|
||||
if (typeIntervalRef.current) clearInterval(typeIntervalRef.current)
|
||||
typeIntervalRef.current = null
|
||||
|
||||
const t2 = setTimeout(() => {
|
||||
setChatPhase('sent')
|
||||
|
||||
const t3 = setTimeout(() => {
|
||||
setChatPhase('tool-call')
|
||||
|
||||
const t4 = setTimeout(() => {
|
||||
setShowResourcePanel(true)
|
||||
}, RESOURCE_PANEL_DELAY_MS)
|
||||
timersRef.current.push(t4)
|
||||
|
||||
const t5 = setTimeout(() => {
|
||||
setToolsExpanded(false)
|
||||
setChatPhase('responding')
|
||||
let rIdx = 0
|
||||
responseIntervalRef.current = setInterval(() => {
|
||||
rIdx++
|
||||
setResponseTypedLength(rIdx)
|
||||
if (rIdx >= MOCK_RESPONSE.length) {
|
||||
if (responseIntervalRef.current) clearInterval(responseIntervalRef.current)
|
||||
responseIntervalRef.current = null
|
||||
setChatPhase('done')
|
||||
}
|
||||
}, 8)
|
||||
}, TOOL_CALL_DELAY_MS + RESPONSE_DELAY_MS)
|
||||
timersRef.current.push(t5)
|
||||
}, TOOL_CALL_DELAY_MS)
|
||||
timersRef.current.push(t3)
|
||||
}, 400)
|
||||
timersRef.current.push(t2)
|
||||
}
|
||||
}, HOME_TYPE_MS)
|
||||
}, HOME_TYPE_START_MS)
|
||||
timersRef.current.push(t1)
|
||||
|
||||
return clearAllTimers
|
||||
}, [autoType, clearAllTimers])
|
||||
|
||||
const isEmpty = inputValue.trim().length === 0
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (isEmpty) return
|
||||
captureClientEvent('landing_prompt_submitted', {})
|
||||
landingSubmit(inputValue)
|
||||
}, [isEmpty, inputValue, landingSubmit])
|
||||
|
||||
if (chatPhase !== 'input') {
|
||||
const isResponding = chatPhase === 'responding' || chatPhase === 'done'
|
||||
const showToolCall = chatPhase === 'tool-call' || isResponding
|
||||
|
||||
return (
|
||||
<LazyMotion features={domAnimation}>
|
||||
<div className='flex h-full flex-col'>
|
||||
<LandingPreviewChatTitleBar chatName='New chat' />
|
||||
<div className='flex min-h-0 flex-1 overflow-hidden'>
|
||||
{/* Chat area - matches mothership-view layout */}
|
||||
<div className='min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-6 pt-4 pb-8'>
|
||||
<div className='mx-auto max-w-[42rem] space-y-6'>
|
||||
{/* User message - rounded bubble, right-aligned */}
|
||||
<m.div
|
||||
className='flex flex-col items-end gap-[6px] pt-3'
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, ease: EASE_OUT }}
|
||||
>
|
||||
<div className='max-w-[70%] overflow-hidden rounded-[16px] bg-[var(--surface-active)] px-3.5 py-2'>
|
||||
<p className='font-body text-[14px] text-[var(--text-primary)] leading-[1.5]'>
|
||||
{AUTO_PROMPT}
|
||||
</p>
|
||||
</div>
|
||||
</m.div>
|
||||
|
||||
{/* Assistant - no bubble, full-width prose */}
|
||||
<AnimatePresence>
|
||||
{showToolCall && (
|
||||
<m.div
|
||||
className='space-y-2.5'
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, ease: EASE_OUT }}
|
||||
>
|
||||
{/* Agent group header - icon + label + chevron */}
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setToolsExpanded((p) => !p)}
|
||||
className='flex cursor-pointer items-center gap-2'
|
||||
>
|
||||
<div className='flex size-[16px] flex-shrink-0 items-center justify-center'>
|
||||
<Blimp className='size-[16px] text-[var(--text-icon)]' />
|
||||
</div>
|
||||
<span className='text-[var(--text-body)] text-sm'>Sim</span>
|
||||
<ChevronDown
|
||||
className='h-[7px] w-[9px] text-[var(--text-icon)] transition-transform duration-150'
|
||||
style={{
|
||||
transform: toolsExpanded ? 'rotate(0deg)' : 'rotate(-90deg)',
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Tool call items - collapsible */}
|
||||
<div
|
||||
className='grid transition-[grid-template-rows] duration-200 ease-out'
|
||||
style={{
|
||||
gridTemplateRows: toolsExpanded ? '1fr' : '0fr',
|
||||
}}
|
||||
>
|
||||
<div className='overflow-hidden'>
|
||||
<div className='flex flex-col gap-1.5 pt-0.5'>
|
||||
<ToolCallRow
|
||||
icon={<Table className='size-[15px] text-[var(--text-muted)]' />}
|
||||
title='Read Customer Leads'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Response prose - full width, no card */}
|
||||
{isResponding && (
|
||||
<m.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.2, ease: EASE_OUT }}
|
||||
>
|
||||
<ChatMarkdown
|
||||
content={MOCK_RESPONSE}
|
||||
visibleLength={responseTypedLength}
|
||||
isTyping={chatPhase === 'responding'}
|
||||
/>
|
||||
</m.div>
|
||||
)}
|
||||
</m.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Resource panel - slides in from right */}
|
||||
<AnimatePresence>
|
||||
{showResourcePanel && (
|
||||
<m.div
|
||||
className='hidden h-full flex-shrink-0 overflow-hidden border-[var(--border-1)] border-l lg:flex'
|
||||
initial={{ width: 0, opacity: 0 }}
|
||||
animate={{ width: '55%', opacity: 1 }}
|
||||
transition={{ duration: 0.35, ease: EASE_OUT }}
|
||||
>
|
||||
<MiniTablePanel />
|
||||
</m.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</LazyMotion>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<LazyMotion features={domAnimation}>
|
||||
<div className='flex h-full flex-col'>
|
||||
<LandingPreviewChatTitleBar chatName='New chat' />
|
||||
<div className='flex min-h-0 flex-1 flex-col items-center justify-center px-6 pb-[8vh]'>
|
||||
<m.h1
|
||||
className='mb-7 max-w-[36rem] text-balance text-[30px] text-[var(--text-primary)]'
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, ease: EASE_OUT }}
|
||||
>
|
||||
What should we get done?
|
||||
</m.h1>
|
||||
|
||||
<m.div
|
||||
className='w-full max-w-[36rem]'
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, delay: 0.1, ease: EASE_OUT }}
|
||||
>
|
||||
<LandingPreviewChatInput
|
||||
value={inputValue}
|
||||
onChange={setInputValue}
|
||||
onSubmit={handleSubmit}
|
||||
placeholder={animatedPlaceholder}
|
||||
readOnly={autoType}
|
||||
caretHidden={autoType}
|
||||
shadow
|
||||
/>
|
||||
|
||||
{/* Suggested actions - mirrors the workspace home, greyscale icons. */}
|
||||
<div className='mt-7'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='text-[var(--text-muted)] text-small'>Suggested actions</span>
|
||||
<ChevronDown className='h-[7px] w-[9px] text-[var(--text-muted)]' />
|
||||
</div>
|
||||
<div className='-mr-2 flex items-center gap-1.5 rounded-lg px-2 py-1'>
|
||||
<span className='-mt-px text-[var(--text-muted)] text-small'>Shuffle</span>
|
||||
<Shuffle className='size-[16px] flex-shrink-0 text-[var(--text-muted)]' />
|
||||
</div>
|
||||
</div>
|
||||
<div className='mt-2 flex flex-col'>
|
||||
{SUGGESTED_ACTIONS.map((action, i) => {
|
||||
const Icon = action.icon
|
||||
return (
|
||||
<div
|
||||
key={action.id}
|
||||
className={cn(
|
||||
'flex items-center gap-2 border-[var(--border-1)] px-2 py-2 text-left transition-colors hover-hover:bg-[var(--surface-1)]',
|
||||
i > 0 && 'border-t'
|
||||
)}
|
||||
>
|
||||
<Icon className='size-[16px] flex-shrink-0 text-[var(--text-muted)]' />
|
||||
<span className='flex-1 truncate text-[var(--text-primary)] text-sm'>
|
||||
{action.label}
|
||||
</span>
|
||||
<ArrowRight className='size-[16px] shrink-0 text-[var(--text-muted)]' />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</m.div>
|
||||
</div>
|
||||
</div>
|
||||
</LazyMotion>
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* Single tool call row matching the real `ToolCallItem` layout:
|
||||
* indented icon + display title.
|
||||
*/
|
||||
function ToolCallRow({ icon, title }: { icon: React.ReactNode; title: string }) {
|
||||
return (
|
||||
<div className='flex items-center gap-[8px] pl-[24px]'>
|
||||
<div className='flex size-[16px] flex-shrink-0 items-center justify-center'>{icon}</div>
|
||||
<span className='text-[13px] text-[var(--text-muted)]'>{title}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders chat response as full-width prose with bold markdown
|
||||
* and progressive reveal for the typing effect.
|
||||
*/
|
||||
function ChatMarkdown({
|
||||
content,
|
||||
visibleLength,
|
||||
isTyping,
|
||||
}: {
|
||||
content: string
|
||||
visibleLength: number
|
||||
isTyping: boolean
|
||||
}) {
|
||||
const visible = content.slice(0, visibleLength)
|
||||
const rendered = visible.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>').replace(/\n/g, '<br />')
|
||||
|
||||
return (
|
||||
<div className='font-body text-[14px] text-[var(--text-primary)] leading-[1.6]'>
|
||||
<span dangerouslySetInnerHTML={{ __html: rendered }} />
|
||||
{isTyping && (
|
||||
<m.span
|
||||
className='inline-block h-[14px] w-[1.5px] translate-y-[2px] bg-[var(--text-primary)]'
|
||||
animate={{ opacity: [1, 0] }}
|
||||
transition={{
|
||||
duration: 0.6,
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
repeatType: 'reverse',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mini Customer Leads table panel matching the resource panel pattern.
|
||||
*/
|
||||
function MiniTablePanel() {
|
||||
return (
|
||||
<div className='flex h-full w-full flex-col bg-[var(--surface-2)]'>
|
||||
<div className='flex items-center gap-2 border-[var(--border)] border-b px-3 py-2'>
|
||||
<Table className='size-[14px] text-[var(--text-icon)]' />
|
||||
<span className='font-medium text-[var(--text-primary)] text-sm'>Customer Leads</span>
|
||||
</div>
|
||||
<div className='min-h-0 flex-1 overflow-auto'>
|
||||
<table className='w-full table-fixed border-separate border-spacing-0 text-[12px]'>
|
||||
<colgroup>
|
||||
{MINI_TABLE_COLUMNS.map((col) => (
|
||||
<col key={col.id} style={{ width: col.width }} />
|
||||
))}
|
||||
</colgroup>
|
||||
<thead className='sticky top-0 z-10'>
|
||||
<tr>
|
||||
{MINI_TABLE_COLUMNS.map((col) => {
|
||||
const Icon = COLUMN_TYPE_ICONS[col.type]
|
||||
return (
|
||||
<th
|
||||
key={col.id}
|
||||
className='border-[var(--border-1)] border-r border-b bg-[var(--surface-1)] p-0 text-left'
|
||||
>
|
||||
<div className='flex items-center gap-1 px-2 py-1.5'>
|
||||
<Icon className='size-3 shrink-0 text-[var(--text-icon)]' />
|
||||
<span className='font-medium text-[11px] text-[var(--text-primary)]'>
|
||||
{col.label}
|
||||
</span>
|
||||
<ChevronDown className='ml-auto h-[6px] w-[8px] text-[var(--text-muted)]' />
|
||||
</div>
|
||||
</th>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{MINI_TABLE_ROWS.map((row, i) => (
|
||||
<m.tr
|
||||
key={row.name}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.2, delay: i * 0.04, ease: EASE_OUT }}
|
||||
>
|
||||
{MINI_TABLE_COLUMNS.map((col) => {
|
||||
const val = row[col.id as keyof typeof row]
|
||||
return (
|
||||
<td
|
||||
key={col.id}
|
||||
className='border-[var(--border-1)] border-r border-b px-2 py-1.5 text-[var(--text-body)]'
|
||||
>
|
||||
{col.type === 'boolean' ? (
|
||||
<div className='flex items-center justify-center'>
|
||||
<Checkbox
|
||||
size='sm'
|
||||
checked={val === 'true'}
|
||||
className='pointer-events-none'
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<span className='block truncate'>{val}</span>
|
||||
)}
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</m.tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
import { Database } from '@sim/emcn/icons'
|
||||
import {
|
||||
AirtableIcon,
|
||||
AsanaIcon,
|
||||
ConfluenceIcon,
|
||||
GoogleDocsIcon,
|
||||
GoogleDriveIcon,
|
||||
JiraIcon,
|
||||
SalesforceIcon,
|
||||
SlackIcon,
|
||||
ZendeskIcon,
|
||||
} from '@/components/icons'
|
||||
import type {
|
||||
PreviewColumn,
|
||||
PreviewRow,
|
||||
} from '@/app/(landing)/components/landing-preview/components/landing-preview-resource/landing-preview-resource'
|
||||
import { LandingPreviewResource } from '@/app/(landing)/components/landing-preview/components/landing-preview-resource/landing-preview-resource'
|
||||
|
||||
const DB_ICON = <Database className='size-[14px]' />
|
||||
|
||||
/** Connector icons keyed by a stable slug so list keys never depend on array index
|
||||
* or a component name that could be mangled/emptied under minification. */
|
||||
const CONNECTOR_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
airtable: AirtableIcon,
|
||||
asana: AsanaIcon,
|
||||
confluence: ConfluenceIcon,
|
||||
'google-docs': GoogleDocsIcon,
|
||||
'google-drive': GoogleDriveIcon,
|
||||
jira: JiraIcon,
|
||||
salesforce: SalesforceIcon,
|
||||
slack: SlackIcon,
|
||||
zendesk: ZendeskIcon,
|
||||
}
|
||||
|
||||
function connectorIcons(slugs: string[]) {
|
||||
return {
|
||||
content: (
|
||||
<div className='flex items-center gap-1'>
|
||||
{slugs.map((slug) => {
|
||||
const Icon = CONNECTOR_ICONS[slug]
|
||||
return <Icon key={slug} className='size-3.5 flex-shrink-0' />
|
||||
})}
|
||||
</div>
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
const COLUMNS: PreviewColumn[] = [
|
||||
{ id: 'name', header: 'Name' },
|
||||
{ id: 'documents', header: 'Documents' },
|
||||
{ id: 'tokens', header: 'Tokens' },
|
||||
{ id: 'connectors', header: 'Connectors' },
|
||||
{ id: 'created', header: 'Created' },
|
||||
]
|
||||
|
||||
const ROWS: PreviewRow[] = [
|
||||
{
|
||||
id: '1',
|
||||
cells: {
|
||||
name: { icon: DB_ICON, label: 'Product Documentation' },
|
||||
documents: { label: '847' },
|
||||
tokens: { label: '1,284,392' },
|
||||
connectors: connectorIcons(['asana', 'google-docs']),
|
||||
created: { label: '2 days ago' },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
cells: {
|
||||
name: { icon: DB_ICON, label: 'Customer Support KB' },
|
||||
documents: { label: '234' },
|
||||
tokens: { label: '892,104' },
|
||||
connectors: connectorIcons(['zendesk', 'slack']),
|
||||
created: { label: '1 week ago' },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
cells: {
|
||||
name: { icon: DB_ICON, label: 'Engineering Wiki' },
|
||||
documents: { label: '1,203' },
|
||||
tokens: { label: '2,847,293' },
|
||||
connectors: connectorIcons(['confluence', 'jira']),
|
||||
created: { label: 'March 12th, 2026' },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
cells: {
|
||||
name: { icon: DB_ICON, label: 'Marketing Assets' },
|
||||
documents: { label: '189' },
|
||||
tokens: { label: '634,821' },
|
||||
connectors: connectorIcons(['google-drive', 'airtable']),
|
||||
created: { label: 'March 5th, 2026' },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
cells: {
|
||||
name: { icon: DB_ICON, label: 'Sales Playbook' },
|
||||
documents: { label: '92' },
|
||||
tokens: { label: '418,570' },
|
||||
connectors: connectorIcons(['salesforce']),
|
||||
created: { label: 'February 28th, 2026' },
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
export function LandingPreviewKnowledge() {
|
||||
return (
|
||||
<LandingPreviewResource
|
||||
icon={Database}
|
||||
title='Knowledge Base'
|
||||
createLabel='New base'
|
||||
searchPlaceholder='Search knowledge bases...'
|
||||
columns={COLUMNS}
|
||||
rows={ROWS}
|
||||
/>
|
||||
)
|
||||
}
|
||||
+298
@@ -0,0 +1,298 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import type { BadgeProps } from '@sim/emcn'
|
||||
import { ArrowUpDown, Badge, cn, Library, ListFilter, Search } from '@sim/emcn'
|
||||
import { Download, Workflow } from '@sim/emcn/icons'
|
||||
|
||||
interface LogRow {
|
||||
id: string
|
||||
workflowName: string
|
||||
date: string
|
||||
status: 'completed' | 'error' | 'running'
|
||||
cost: string
|
||||
trigger: 'webhook' | 'api' | 'schedule' | 'manual' | 'mcp' | 'chat'
|
||||
triggerLabel: string
|
||||
duration: string
|
||||
}
|
||||
|
||||
type BadgeVariant = BadgeProps['variant']
|
||||
|
||||
const STATUS_VARIANT: Record<LogRow['status'], BadgeVariant> = {
|
||||
completed: 'gray-secondary',
|
||||
error: 'gray',
|
||||
running: 'gray',
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<LogRow['status'], string> = {
|
||||
completed: 'Completed',
|
||||
error: 'Error',
|
||||
running: 'Running',
|
||||
}
|
||||
|
||||
const MOCK_LOGS: LogRow[] = [
|
||||
{
|
||||
id: '1',
|
||||
workflowName: 'Customer Onboarding',
|
||||
date: 'Apr 1 10:42 AM',
|
||||
status: 'running',
|
||||
cost: '-',
|
||||
trigger: 'webhook',
|
||||
triggerLabel: 'Webhook',
|
||||
duration: '-',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
workflowName: 'Lead Enrichment',
|
||||
date: 'Apr 1 09:15 AM',
|
||||
status: 'error',
|
||||
cost: '1 credit',
|
||||
trigger: 'api',
|
||||
triggerLabel: 'API',
|
||||
duration: '2.7s',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
workflowName: 'Email Campaign',
|
||||
date: 'Apr 1 08:30 AM',
|
||||
status: 'completed',
|
||||
cost: '2 credits',
|
||||
trigger: 'schedule',
|
||||
triggerLabel: 'Schedule',
|
||||
duration: '0.8s',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
workflowName: 'Data Pipeline',
|
||||
date: 'Mar 31 10:14 PM',
|
||||
status: 'completed',
|
||||
cost: '7 credits',
|
||||
trigger: 'webhook',
|
||||
triggerLabel: 'Webhook',
|
||||
duration: '4.1s',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
workflowName: 'Invoice Processing',
|
||||
date: 'Mar 31 08:45 PM',
|
||||
status: 'completed',
|
||||
cost: '2 credits',
|
||||
trigger: 'manual',
|
||||
triggerLabel: 'Manual',
|
||||
duration: '0.9s',
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
workflowName: 'Support Triage',
|
||||
date: 'Mar 31 07:22 PM',
|
||||
status: 'completed',
|
||||
cost: '3 credits',
|
||||
trigger: 'api',
|
||||
triggerLabel: 'API',
|
||||
duration: '1.6s',
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
workflowName: 'Content Moderator',
|
||||
date: 'Mar 31 06:11 PM',
|
||||
status: 'error',
|
||||
cost: '1 credit',
|
||||
trigger: 'schedule',
|
||||
triggerLabel: 'Schedule',
|
||||
duration: '3.2s',
|
||||
},
|
||||
]
|
||||
|
||||
type SortKey = 'workflowName' | 'date' | 'status' | 'cost' | 'trigger' | 'duration'
|
||||
|
||||
const COL_HEADERS: { key: SortKey; label: string }[] = [
|
||||
{ key: 'workflowName', label: 'Workflow' },
|
||||
{ key: 'date', label: 'Date' },
|
||||
{ key: 'status', label: 'Status' },
|
||||
{ key: 'cost', label: 'Cost' },
|
||||
{ key: 'trigger', label: 'Trigger' },
|
||||
{ key: 'duration', label: 'Duration' },
|
||||
]
|
||||
|
||||
export function LandingPreviewLogs() {
|
||||
const [search, setSearch] = useState('')
|
||||
const [sortKey, setSortKey] = useState<SortKey | null>(null)
|
||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc')
|
||||
const [activeTab, setActiveTab] = useState<'logs' | 'dashboard'>('logs')
|
||||
|
||||
function handleSort(key: SortKey) {
|
||||
if (sortKey === key) {
|
||||
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))
|
||||
} else {
|
||||
setSortKey(key)
|
||||
setSortDir('asc')
|
||||
}
|
||||
}
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
const q = search.toLowerCase()
|
||||
const filtered = q
|
||||
? MOCK_LOGS.filter(
|
||||
(log) =>
|
||||
log.workflowName.toLowerCase().includes(q) ||
|
||||
log.triggerLabel.toLowerCase().includes(q) ||
|
||||
STATUS_LABELS[log.status].toLowerCase().includes(q)
|
||||
)
|
||||
: MOCK_LOGS
|
||||
|
||||
if (!sortKey) return filtered
|
||||
return [...filtered].sort((a, b) => {
|
||||
const av = sortKey === 'cost' ? a.cost.replace(/\D/g, '') : a[sortKey]
|
||||
const bv = sortKey === 'cost' ? b.cost.replace(/\D/g, '') : b[sortKey]
|
||||
const cmp = av.localeCompare(bv, undefined, { numeric: true, sensitivity: 'base' })
|
||||
return sortDir === 'asc' ? cmp : -cmp
|
||||
})
|
||||
}, [search, sortKey, sortDir])
|
||||
|
||||
return (
|
||||
<div className='flex h-full flex-1 flex-col overflow-hidden bg-[var(--bg)]'>
|
||||
{/* Header - fixed 44px to align with the chat title bar across the split. */}
|
||||
<div className='flex h-[44px] flex-shrink-0 items-center border-[var(--border)] border-b px-6'>
|
||||
<div className='flex w-full items-center justify-between'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<Library className='size-[14px] text-[var(--text-icon)]' />
|
||||
<span className='font-medium text-[var(--text-body)] text-sm'>Logs</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-1'>
|
||||
<div className='flex cursor-default items-center rounded-md px-2 py-1 text-[var(--text-secondary)] text-caption'>
|
||||
<Download className='mr-1.5 size-[14px] text-[var(--text-icon)]' />
|
||||
Export
|
||||
</div>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setActiveTab('logs')}
|
||||
className={cn(
|
||||
'rounded-md px-2 py-1 text-caption transition-colors',
|
||||
activeTab === 'logs'
|
||||
? 'bg-[var(--surface-active)] text-[var(--text-body)]'
|
||||
: 'text-[var(--text-secondary)]'
|
||||
)}
|
||||
>
|
||||
Logs
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setActiveTab('dashboard')}
|
||||
className={cn(
|
||||
'rounded-md px-2 py-1 text-caption transition-colors',
|
||||
activeTab === 'dashboard'
|
||||
? 'bg-[var(--surface-active)] text-[var(--text-body)]'
|
||||
: 'text-[var(--text-secondary)]'
|
||||
)}
|
||||
>
|
||||
Dashboard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Options bar */}
|
||||
<div className='border-[var(--border)] border-b px-6 py-2.5'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex flex-1 items-center gap-2.5'>
|
||||
<Search className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
<input
|
||||
type='text'
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder='Search logs...'
|
||||
aria-label='Search logs'
|
||||
className='flex-1 bg-transparent text-[var(--text-body)] text-caption outline-none placeholder:text-[var(--text-subtle)]'
|
||||
/>
|
||||
</div>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<div className='flex cursor-default items-center rounded-md px-2 py-1 text-[var(--text-secondary)] text-caption'>
|
||||
<ListFilter className='mr-1.5 size-[14px] text-[var(--text-icon)]' />
|
||||
Filter
|
||||
</div>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => handleSort(sortKey ?? 'workflowName')}
|
||||
className='flex cursor-default items-center rounded-md px-2 py-1 text-[var(--text-secondary)] text-caption transition-colors hover-hover:bg-[var(--surface-3)]'
|
||||
>
|
||||
<ArrowUpDown className='mr-1.5 size-[14px] text-[var(--text-icon)]' />
|
||||
Sort
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table - uses <table> for pixel-perfect column alignment with headers */}
|
||||
<div className='min-h-0 flex-1 overflow-hidden'>
|
||||
<table className='w-full table-fixed text-sm'>
|
||||
<colgroup>
|
||||
<col style={{ width: '22%' }} />
|
||||
<col style={{ width: '18%' }} />
|
||||
<col style={{ width: '13%' }} />
|
||||
<col style={{ width: '15%' }} />
|
||||
<col style={{ width: '14%' }} />
|
||||
<col style={{ width: '18%' }} />
|
||||
</colgroup>
|
||||
<thead className='shadow-[inset_0_-1px_0_var(--border)]'>
|
||||
<tr>
|
||||
{COL_HEADERS.map(({ key, label }) => (
|
||||
<th
|
||||
key={key}
|
||||
className='h-10 px-6 py-1.5 text-left align-middle font-normal text-caption'
|
||||
>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => handleSort(key)}
|
||||
className={cn(
|
||||
'flex items-center gap-1 transition-colors hover-hover:text-[var(--text-secondary)]',
|
||||
sortKey === key ? 'text-[var(--text-secondary)]' : 'text-[var(--text-muted)]'
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
{sortKey === key && <ArrowUpDown className='size-[10px] opacity-60' />}
|
||||
</button>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.map((log) => (
|
||||
<tr
|
||||
key={log.id}
|
||||
className='h-[44px] cursor-default transition-colors hover-hover:bg-[var(--surface-3)]'
|
||||
>
|
||||
<td className='px-6 align-middle'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Workflow className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
<span className='min-w-0 truncate font-medium text-[var(--text-primary)] text-caption'>
|
||||
{log.workflowName}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className='px-6 align-middle text-[var(--text-secondary)] text-caption'>
|
||||
{log.date}
|
||||
</td>
|
||||
<td className='px-6 align-middle'>
|
||||
<Badge variant={STATUS_VARIANT[log.status]} size='sm' dot>
|
||||
{STATUS_LABELS[log.status]}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className='px-6 align-middle text-[var(--text-secondary)] text-caption'>
|
||||
{log.cost}
|
||||
</td>
|
||||
<td className='px-6 align-middle'>
|
||||
<Badge variant='gray-secondary' size='sm'>
|
||||
{log.triggerLabel}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className='px-6 align-middle text-[var(--text-secondary)] text-caption'>
|
||||
{log.duration}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+198
@@ -0,0 +1,198 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { ArrowUpDown, cn, ListFilter, Plus, Search } from '@sim/emcn'
|
||||
|
||||
export interface PreviewColumn {
|
||||
id: string
|
||||
header: string
|
||||
width?: number
|
||||
}
|
||||
|
||||
export interface PreviewCell {
|
||||
icon?: ReactNode
|
||||
label?: string
|
||||
content?: ReactNode
|
||||
}
|
||||
|
||||
export interface PreviewRow {
|
||||
id: string
|
||||
cells: Record<string, PreviewCell>
|
||||
}
|
||||
|
||||
interface LandingPreviewResourceProps {
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
title: string
|
||||
createLabel: string
|
||||
searchPlaceholder: string
|
||||
columns: PreviewColumn[]
|
||||
rows: PreviewRow[]
|
||||
onRowClick?: (id: string) => void
|
||||
}
|
||||
|
||||
export function LandingPreviewResource({
|
||||
icon: Icon,
|
||||
title,
|
||||
createLabel,
|
||||
searchPlaceholder,
|
||||
columns,
|
||||
rows,
|
||||
onRowClick,
|
||||
}: LandingPreviewResourceProps) {
|
||||
const [search, setSearch] = useState('')
|
||||
const [sortColId, setSortColId] = useState<string | null>(null)
|
||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc')
|
||||
|
||||
function handleSortClick(colId: string) {
|
||||
if (sortColId === colId) {
|
||||
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))
|
||||
} else {
|
||||
setSortColId(colId)
|
||||
setSortDir('asc')
|
||||
}
|
||||
}
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
const q = search.toLowerCase()
|
||||
const filtered = q
|
||||
? rows.filter((row) =>
|
||||
Object.values(row.cells).some((cell) => cell.label?.toLowerCase().includes(q))
|
||||
)
|
||||
: rows
|
||||
|
||||
if (!sortColId) return filtered
|
||||
return [...filtered].sort((a, b) => {
|
||||
const av = a.cells[sortColId]?.label ?? ''
|
||||
const bv = b.cells[sortColId]?.label ?? ''
|
||||
const cmp = av.localeCompare(bv, undefined, { numeric: true, sensitivity: 'base' })
|
||||
return sortDir === 'asc' ? cmp : -cmp
|
||||
})
|
||||
}, [rows, search, sortColId, sortDir])
|
||||
|
||||
return (
|
||||
<div className='flex h-full flex-1 flex-col overflow-hidden bg-[var(--bg)]'>
|
||||
{/* Header - fixed 44px to align with the chat title bar across the split. */}
|
||||
<div className='flex h-[44px] flex-shrink-0 items-center border-[var(--border)] border-b px-6'>
|
||||
<div className='flex w-full items-center justify-between'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<Icon className='size-[14px] text-[var(--text-icon)]' />
|
||||
<span className='font-medium text-[var(--text-body)] text-sm'>{title}</span>
|
||||
</div>
|
||||
<div className='flex cursor-default items-center rounded-md px-2 py-1 text-[var(--text-secondary)] text-caption'>
|
||||
<Plus className='mr-1.5 size-[14px] text-[var(--text-icon)]' />
|
||||
{createLabel}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Options bar */}
|
||||
<div className='border-[var(--border)] border-b px-6 py-2.5'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex flex-1 items-center gap-2.5'>
|
||||
<Search className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
<input
|
||||
type='text'
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={searchPlaceholder}
|
||||
aria-label={searchPlaceholder}
|
||||
className='flex-1 bg-transparent text-[var(--text-body)] text-caption outline-none placeholder:text-[var(--text-subtle)]'
|
||||
/>
|
||||
</div>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<div className='flex cursor-default items-center rounded-md px-2 py-1 text-[var(--text-secondary)] text-caption'>
|
||||
<ListFilter className='mr-1.5 size-[14px] text-[var(--text-icon)]' />
|
||||
Filter
|
||||
</div>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => handleSortClick(sortColId ?? columns[0]?.id)}
|
||||
className='flex cursor-default items-center rounded-md px-2 py-1 text-[var(--text-secondary)] text-caption transition-colors hover-hover:bg-[var(--surface-3)]'
|
||||
>
|
||||
<ArrowUpDown className='mr-1.5 size-[14px] text-[var(--text-icon)]' />
|
||||
Sort
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className='min-h-0 flex-1 overflow-hidden'>
|
||||
<table className='w-full table-fixed text-sm'>
|
||||
<colgroup>
|
||||
{columns.map((col, i) => (
|
||||
<col
|
||||
key={col.id}
|
||||
style={i === 0 ? { minWidth: col.width ?? 200 } : { width: col.width ?? 160 }}
|
||||
/>
|
||||
))}
|
||||
</colgroup>
|
||||
<thead className='shadow-[inset_0_-1px_0_var(--border)]'>
|
||||
<tr>
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.id}
|
||||
className='h-10 px-6 py-1.5 text-left align-middle font-normal text-caption'
|
||||
>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => handleSortClick(col.id)}
|
||||
className={cn(
|
||||
'flex items-center gap-1 transition-colors hover-hover:text-[var(--text-secondary)]',
|
||||
sortColId === col.id
|
||||
? 'text-[var(--text-secondary)]'
|
||||
: 'text-[var(--text-muted)]'
|
||||
)}
|
||||
>
|
||||
{col.header}
|
||||
{sortColId === col.id && <ArrowUpDown className='size-[10px] opacity-60' />}
|
||||
</button>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.map((row) => (
|
||||
<tr
|
||||
key={row.id}
|
||||
onClick={() => onRowClick?.(row.id)}
|
||||
className={cn(
|
||||
'transition-colors hover-hover:bg-[var(--surface-3)]',
|
||||
onRowClick && 'cursor-pointer'
|
||||
)}
|
||||
>
|
||||
{columns.map((col, colIdx) => {
|
||||
const cell = row.cells[col.id]
|
||||
return (
|
||||
<td key={col.id} className='px-6 py-2.5 align-middle'>
|
||||
{cell?.content ? (
|
||||
cell.content
|
||||
) : (
|
||||
<span
|
||||
className={cn(
|
||||
'flex min-w-0 items-center gap-3 font-medium text-sm',
|
||||
colIdx === 0
|
||||
? 'text-[var(--text-body)]'
|
||||
: 'text-[var(--text-secondary)]'
|
||||
)}
|
||||
>
|
||||
{cell?.icon && (
|
||||
<span className='flex-shrink-0 text-[var(--text-icon)]'>
|
||||
{cell.icon}
|
||||
</span>
|
||||
)}
|
||||
<span className='truncate'>{cell?.label ?? '–'}</span>
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import type { PreviewCell } from '@/app/(landing)/components/landing-preview/components/landing-preview-resource/landing-preview-resource'
|
||||
|
||||
/** Builds a preview owner cell: a small circular initial badge next to a name. */
|
||||
export function ownerCell(initial: string, name: string): PreviewCell {
|
||||
return {
|
||||
icon: (
|
||||
<span className='flex size-[14px] flex-shrink-0 items-center justify-center rounded-full border border-[var(--border)] bg-[var(--surface-3)] font-medium text-[8px] text-[var(--text-secondary)]'>
|
||||
{initial}
|
||||
</span>
|
||||
),
|
||||
label: name,
|
||||
}
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
import { Calendar } from '@sim/emcn/icons'
|
||||
import type {
|
||||
PreviewColumn,
|
||||
PreviewRow,
|
||||
} from '@/app/(landing)/components/landing-preview/components/landing-preview-resource/landing-preview-resource'
|
||||
import { LandingPreviewResource } from '@/app/(landing)/components/landing-preview/components/landing-preview-resource/landing-preview-resource'
|
||||
|
||||
const CAL_ICON = <Calendar className='size-[14px]' />
|
||||
|
||||
const COLUMNS: PreviewColumn[] = [
|
||||
{ id: 'task', header: 'Task' },
|
||||
{ id: 'schedule', header: 'Schedule', width: 240 },
|
||||
{ id: 'nextRun', header: 'Next Run' },
|
||||
{ id: 'lastRun', header: 'Last Run' },
|
||||
]
|
||||
|
||||
const ROWS: PreviewRow[] = [
|
||||
{
|
||||
id: '1',
|
||||
cells: {
|
||||
task: { icon: CAL_ICON, label: 'Sync CRM contacts' },
|
||||
schedule: { label: 'Recurring, every day at 9:00 AM' },
|
||||
nextRun: { label: 'Tomorrow' },
|
||||
lastRun: { label: '2 hours ago' },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
cells: {
|
||||
task: { icon: CAL_ICON, label: 'Generate weekly report' },
|
||||
schedule: { label: 'Recurring, every Monday at 8:00 AM' },
|
||||
nextRun: { label: 'In 5 days' },
|
||||
lastRun: { label: '6 days ago' },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
cells: {
|
||||
task: { icon: CAL_ICON, label: 'Clean up stale files' },
|
||||
schedule: { label: 'Recurring, every Sunday at midnight' },
|
||||
nextRun: { label: 'In 2 days' },
|
||||
lastRun: { label: '6 days ago' },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
cells: {
|
||||
task: { icon: CAL_ICON, label: 'Send performance digest' },
|
||||
schedule: { label: 'Recurring, every Friday at 5:00 PM' },
|
||||
nextRun: { label: 'In 3 days' },
|
||||
lastRun: { label: '3 days ago' },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
cells: {
|
||||
task: { icon: CAL_ICON, label: 'Backup production data' },
|
||||
schedule: { label: 'Recurring, every 4 hours' },
|
||||
nextRun: { label: 'In 2 hours' },
|
||||
lastRun: { label: '2 hours ago' },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
cells: {
|
||||
task: { icon: CAL_ICON, label: 'Scrape competitor pricing' },
|
||||
schedule: { label: 'Recurring, every Tuesday at 6:00 AM' },
|
||||
nextRun: { label: 'In 6 days' },
|
||||
lastRun: { label: '1 week ago' },
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* Static landing preview of the Scheduled Tasks workspace page.
|
||||
*/
|
||||
export function LandingPreviewScheduledTasks() {
|
||||
return (
|
||||
<LandingPreviewResource
|
||||
icon={Calendar}
|
||||
title='Scheduled Tasks'
|
||||
createLabel='New scheduled task'
|
||||
searchPlaceholder='Search scheduled tasks...'
|
||||
columns={COLUMNS}
|
||||
rows={ROWS}
|
||||
/>
|
||||
)
|
||||
}
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
'use client'
|
||||
import { ChevronDown, cn, Home, Library } from '@sim/emcn'
|
||||
import {
|
||||
Calendar,
|
||||
Database,
|
||||
File,
|
||||
HelpCircle,
|
||||
Search,
|
||||
Settings,
|
||||
Table,
|
||||
Workflow,
|
||||
} from '@sim/emcn/icons'
|
||||
import type { PreviewWorkflow } from '@/app/(landing)/components/landing-preview/components/landing-preview-workflow/workflow-data'
|
||||
|
||||
export type SidebarView =
|
||||
| 'home'
|
||||
| 'workflow'
|
||||
| 'tables'
|
||||
| 'files'
|
||||
| 'knowledge'
|
||||
| 'logs'
|
||||
| 'scheduled-tasks'
|
||||
|
||||
interface LandingPreviewSidebarProps {
|
||||
workflows: PreviewWorkflow[]
|
||||
activeWorkflowId: string
|
||||
activeView: SidebarView
|
||||
onSelectWorkflow: (id: string) => void
|
||||
onSelectHome: () => void
|
||||
onSelectNav: (id: SidebarView) => void
|
||||
}
|
||||
|
||||
const WORKSPACE_NAV = [
|
||||
{ id: 'tables', label: 'Tables', icon: Table },
|
||||
{ id: 'files', label: 'Files', icon: File },
|
||||
{ id: 'knowledge', label: 'Knowledge Base', icon: Database },
|
||||
{ id: 'scheduled-tasks', label: 'Scheduled Tasks', icon: Calendar },
|
||||
{ id: 'logs', label: 'Logs', icon: Library },
|
||||
] as const
|
||||
|
||||
const FOOTER_NAV = [
|
||||
{ id: 'help', label: 'Help', icon: HelpCircle },
|
||||
{ id: 'settings', label: 'Settings', icon: Settings },
|
||||
] as const
|
||||
|
||||
function NavItem({
|
||||
icon: Icon,
|
||||
label,
|
||||
isActive,
|
||||
onClick,
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }>
|
||||
label: string
|
||||
isActive?: boolean
|
||||
onClick?: () => void
|
||||
}) {
|
||||
if (!onClick) {
|
||||
return (
|
||||
<div className='pointer-events-none mx-0.5 flex h-[28px] items-center gap-2 rounded-[8px] px-2'>
|
||||
<Icon className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
<span className='truncate text-[13px] text-[var(--text-body)]' style={{ fontWeight: 450 }}>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type='button'
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'mx-0.5 flex h-[28px] items-center gap-2 rounded-[8px] px-2 transition-colors hover-hover:bg-[var(--c-active)]',
|
||||
isActive && 'bg-[var(--c-active)]'
|
||||
)}
|
||||
>
|
||||
<Icon className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
<span className='truncate text-[13px] text-[var(--text-body)]' style={{ fontWeight: 450 }}>
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight sidebar replicating the real workspace sidebar layout and sizing.
|
||||
* Starts from the workspace header (no logo/collapse row).
|
||||
* Only workflow items are interactive - everything else is pointer-events-none.
|
||||
*/
|
||||
export function LandingPreviewSidebar({
|
||||
workflows,
|
||||
activeWorkflowId,
|
||||
activeView,
|
||||
onSelectWorkflow,
|
||||
onSelectHome,
|
||||
onSelectNav,
|
||||
}: LandingPreviewSidebarProps) {
|
||||
const isHomeActive = activeView === 'home'
|
||||
|
||||
return (
|
||||
<div
|
||||
className='flex h-full w-[248px] flex-shrink-0 flex-col bg-[var(--surface-1)] pt-3'
|
||||
style={{ '--c-active': 'var(--surface-active)' } as React.CSSProperties}
|
||||
>
|
||||
{/* Workspace Header */}
|
||||
<div className='flex-shrink-0 px-2.5'>
|
||||
<div className='pointer-events-none flex h-[32px] w-full items-center gap-2 rounded-[8px] border border-[var(--border-1)] bg-[var(--surface-2)] pr-2 pl-[5px]'>
|
||||
<div className='flex size-[20px] flex-shrink-0 items-center justify-center rounded-[4px] bg-white'>
|
||||
<svg width='10' height='10' viewBox='0 0 10 10' fill='none'>
|
||||
<path
|
||||
d='M1 9C1 4.58 4.58 1 9 1'
|
||||
stroke='var(--surface-1)'
|
||||
strokeWidth='1.8'
|
||||
strokeLinecap='round'
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<span className='min-w-0 flex-1 truncate text-left font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
Superark
|
||||
</span>
|
||||
<ChevronDown className='h-[8px] w-[10px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top Navigation: Home (interactive), Search (static) */}
|
||||
<div className='mt-2.5 flex flex-shrink-0 flex-col gap-0.5 px-2'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={onSelectHome}
|
||||
className={cn(
|
||||
'mx-0.5 flex h-[28px] items-center gap-2 rounded-[8px] px-2 transition-colors hover-hover:bg-[var(--c-active)]',
|
||||
isHomeActive && 'bg-[var(--c-active)]'
|
||||
)}
|
||||
>
|
||||
<Home className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
<span
|
||||
className='truncate text-[13px] text-[var(--text-body)]'
|
||||
style={{ fontWeight: 450 }}
|
||||
>
|
||||
Home
|
||||
</span>
|
||||
</button>
|
||||
<NavItem icon={Search} label='Search' />
|
||||
</div>
|
||||
|
||||
{/* Workspace */}
|
||||
<div className='mt-3.5 flex flex-shrink-0 flex-col'>
|
||||
<div className='px-4 pb-1.5'>
|
||||
<div className='text-[12px] text-[var(--text-icon)]'>Workspace</div>
|
||||
</div>
|
||||
<div className='flex flex-col gap-0.5 px-2'>
|
||||
{WORKSPACE_NAV.map((item) => (
|
||||
<NavItem
|
||||
key={item.id}
|
||||
icon={item.icon}
|
||||
label={item.label}
|
||||
isActive={activeView === item.id}
|
||||
onClick={() => onSelectNav(item.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable Tasks + Workflows */}
|
||||
<div className='flex flex-1 flex-col overflow-y-auto overflow-x-hidden pt-3.5'>
|
||||
{/* Workflows */}
|
||||
<div className='flex flex-col'>
|
||||
<div className='px-4'>
|
||||
<div className='text-[12px] text-[var(--text-icon)]'>Workflows</div>
|
||||
</div>
|
||||
<div className='mt-1.5 flex flex-col gap-0.5 px-2'>
|
||||
{workflows.map((workflow) => {
|
||||
const isActive = activeView === 'workflow' && workflow.id === activeWorkflowId
|
||||
return (
|
||||
<button
|
||||
key={workflow.id}
|
||||
type='button'
|
||||
onClick={() => onSelectWorkflow(workflow.id)}
|
||||
className={cn(
|
||||
'mx-0.5 flex h-[28px] w-full items-center gap-2 rounded-[8px] px-2 transition-colors hover-hover:bg-[var(--surface-active)]',
|
||||
isActive && 'bg-[var(--surface-active)]'
|
||||
)}
|
||||
>
|
||||
<Workflow className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
<div
|
||||
className='min-w-0 flex-1 truncate text-left text-[13px] text-[var(--text-body)]'
|
||||
style={{ fontWeight: 450 }}
|
||||
>
|
||||
{workflow.name}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className='flex flex-shrink-0 flex-col gap-0.5 px-2 pt-[9px] pb-2'>
|
||||
{FOOTER_NAV.map((item) => (
|
||||
<NavItem key={item.id} icon={item.icon} label={item.label} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
import { X } from '@sim/emcn'
|
||||
import { PanelRight, Workflow } from 'lucide-react'
|
||||
|
||||
interface LandingPreviewStageHeaderProps {
|
||||
/** The staged resource's display name. */
|
||||
name: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The staged-resource header - a faithful copy of the workspace `PanelHeader`
|
||||
* (44px, `px-4`, `gap-1.5`) that sits above the workflow canvas in the "chat
|
||||
* everywhere" layout. There is no tab strip and no Deploy/Run: a workflow's
|
||||
* panel actions are `null` in the real header, so it carries only the staged
|
||||
* resource's identity - the lucide `Workflow` mark (`size-[14px]`, `--text-icon`)
|
||||
* and its name in chip geometry - plus the panel's close + collapse controls on
|
||||
* the right. Aligns to the chat pane's title bar so the two read as one header
|
||||
* row across the split.
|
||||
*/
|
||||
export function LandingPreviewStageHeader({ name }: LandingPreviewStageHeaderProps) {
|
||||
return (
|
||||
<div className='flex h-[44px] flex-shrink-0 items-center gap-1.5 border-[var(--border)] border-b px-4'>
|
||||
<div className='flex min-w-0 flex-1 items-center overflow-hidden'>
|
||||
<span className='inline-flex h-[30px] min-w-0 items-center justify-start gap-1.5 rounded-lg px-2 text-left'>
|
||||
<Workflow className='size-[14px] flex-shrink-0 text-[var(--text-muted)]' />
|
||||
<span className='min-w-0 truncate text-[var(--text-primary)] text-sm'>{name}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className='ml-auto flex flex-shrink-0 items-center gap-1.5'>
|
||||
<span className='flex size-[30px] items-center justify-center rounded-lg transition-colors hover-hover:bg-[var(--surface-active)]'>
|
||||
<X className='size-[14px] text-[var(--text-muted)]' />
|
||||
</span>
|
||||
<span className='-mr-[9px] flex size-[30px] items-center justify-center rounded-lg transition-colors hover-hover:bg-[var(--surface-active)]'>
|
||||
<PanelRight className='size-[16px] text-[var(--text-muted)]' />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+569
@@ -0,0 +1,569 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Checkbox, cn } from '@sim/emcn'
|
||||
import {
|
||||
ChevronDown,
|
||||
Columns3,
|
||||
Rows3,
|
||||
Table,
|
||||
TypeBoolean,
|
||||
TypeNumber,
|
||||
TypeText,
|
||||
} from '@sim/emcn/icons'
|
||||
import { AnimatePresence, domAnimation, LazyMotion, m } from 'framer-motion'
|
||||
import type {
|
||||
PreviewColumn,
|
||||
PreviewRow,
|
||||
} from '@/app/(landing)/components/landing-preview/components/landing-preview-resource/landing-preview-resource'
|
||||
import { LandingPreviewResource } from '@/app/(landing)/components/landing-preview/components/landing-preview-resource/landing-preview-resource'
|
||||
import { ownerCell } from '@/app/(landing)/components/landing-preview/components/landing-preview-resource/utils'
|
||||
import { EASE_OUT } from '@/app/(landing)/components/landing-preview/components/landing-preview-workflow/workflow-data'
|
||||
|
||||
const CELL = 'border-[var(--border)] border-r border-b px-2 py-[7px] align-middle select-none'
|
||||
const CELL_CHECKBOX =
|
||||
'border-[var(--border)] border-r border-b px-1 py-[7px] align-middle select-none'
|
||||
const CELL_HEADER =
|
||||
'border-[var(--border)] border-r border-b bg-[var(--bg)] p-0 text-left align-middle'
|
||||
const CELL_HEADER_CHECKBOX =
|
||||
'border-[var(--border)] border-r border-b bg-[var(--bg)] px-1 py-[7px] text-center align-middle'
|
||||
const CELL_CONTENT =
|
||||
'relative min-h-[20px] min-w-0 overflow-clip text-ellipsis whitespace-nowrap text-small'
|
||||
const SELECTION_OVERLAY =
|
||||
'pointer-events-none absolute -top-px -right-px -bottom-px -left-px z-[5] border-[2px] border-[var(--selection)]'
|
||||
|
||||
const LIST_COLUMNS: PreviewColumn[] = [
|
||||
{ id: 'name', header: 'Name' },
|
||||
{ id: 'columns', header: 'Columns' },
|
||||
{ id: 'rows', header: 'Rows' },
|
||||
{ id: 'created', header: 'Created' },
|
||||
{ id: 'owner', header: 'Owner' },
|
||||
]
|
||||
|
||||
const TABLE_METAS: Record<string, string> = {
|
||||
'1': 'Customer Leads',
|
||||
'2': 'Product Catalog',
|
||||
'3': 'Campaign Analytics',
|
||||
'4': 'User Profiles',
|
||||
'5': 'Invoice Records',
|
||||
}
|
||||
|
||||
const TABLE_ICON = <Table className='size-[14px]' />
|
||||
const COLUMNS_ICON = <Columns3 className='size-[14px]' />
|
||||
const ROWS_ICON = <Rows3 className='size-[14px]' />
|
||||
|
||||
const LIST_ROWS: PreviewRow[] = [
|
||||
{
|
||||
id: '1',
|
||||
cells: {
|
||||
name: { icon: TABLE_ICON, label: 'Customer Leads' },
|
||||
columns: { icon: COLUMNS_ICON, label: '8' },
|
||||
rows: { icon: ROWS_ICON, label: '2,847' },
|
||||
created: { label: '2 days ago' },
|
||||
owner: ownerCell('S', 'Sarah K.'),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
cells: {
|
||||
name: { icon: TABLE_ICON, label: 'Product Catalog' },
|
||||
columns: { icon: COLUMNS_ICON, label: '12' },
|
||||
rows: { icon: ROWS_ICON, label: '1,203' },
|
||||
created: { label: '5 days ago' },
|
||||
owner: ownerCell('A', 'Alex M.'),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
cells: {
|
||||
name: { icon: TABLE_ICON, label: 'Campaign Analytics' },
|
||||
columns: { icon: COLUMNS_ICON, label: '6' },
|
||||
rows: { icon: ROWS_ICON, label: '534' },
|
||||
created: { label: '1 week ago' },
|
||||
owner: ownerCell('W', 'Emaan K.'),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
cells: {
|
||||
name: { icon: TABLE_ICON, label: 'User Profiles' },
|
||||
columns: { icon: COLUMNS_ICON, label: '15' },
|
||||
rows: { icon: ROWS_ICON, label: '18,492' },
|
||||
created: { label: '2 weeks ago' },
|
||||
owner: ownerCell('J', 'Jordan P.'),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
cells: {
|
||||
name: { icon: TABLE_ICON, label: 'Invoice Records' },
|
||||
columns: { icon: COLUMNS_ICON, label: '9' },
|
||||
rows: { icon: ROWS_ICON, label: '742' },
|
||||
created: { label: 'March 15th, 2026' },
|
||||
owner: ownerCell('S', 'Sarah K.'),
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
interface SpreadsheetColumn {
|
||||
id: string
|
||||
label: string
|
||||
type: 'text' | 'number' | 'boolean'
|
||||
width: number
|
||||
}
|
||||
|
||||
interface SpreadsheetRow {
|
||||
id: string
|
||||
cells: Record<string, string>
|
||||
}
|
||||
|
||||
const COLUMN_TYPE_ICONS = {
|
||||
text: TypeText,
|
||||
number: TypeNumber,
|
||||
boolean: TypeBoolean,
|
||||
} as const
|
||||
|
||||
const SPREADSHEET_DATA: Record<string, { columns: SpreadsheetColumn[]; rows: SpreadsheetRow[] }> = {
|
||||
'1': {
|
||||
columns: [
|
||||
{ id: 'name', label: 'Name', type: 'text', width: 160 },
|
||||
{ id: 'email', label: 'Email', type: 'text', width: 200 },
|
||||
{ id: 'company', label: 'Company', type: 'text', width: 160 },
|
||||
{ id: 'score', label: 'Score', type: 'number', width: 100 },
|
||||
{ id: 'qualified', label: 'Qualified', type: 'boolean', width: 120 },
|
||||
],
|
||||
rows: [
|
||||
{
|
||||
id: '1',
|
||||
cells: {
|
||||
name: 'Alice Johnson',
|
||||
email: 'alice@acme.com',
|
||||
company: 'Acme Corp',
|
||||
score: '87',
|
||||
qualified: 'true',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
cells: {
|
||||
name: 'Bob Williams',
|
||||
email: 'bob@techco.io',
|
||||
company: 'TechCo',
|
||||
score: '62',
|
||||
qualified: 'false',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
cells: {
|
||||
name: 'Carol Davis',
|
||||
email: 'carol@startup.co',
|
||||
company: 'StartupCo',
|
||||
score: '94',
|
||||
qualified: 'true',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
cells: {
|
||||
name: 'Dan Miller',
|
||||
email: 'dan@bigcorp.com',
|
||||
company: 'BigCorp',
|
||||
score: '71',
|
||||
qualified: 'true',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
cells: {
|
||||
name: 'Eva Chen',
|
||||
email: 'eva@design.io',
|
||||
company: 'Design IO',
|
||||
score: '45',
|
||||
qualified: 'false',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
cells: {
|
||||
name: 'Frank Lee',
|
||||
email: 'frank@ventures.co',
|
||||
company: 'Ventures',
|
||||
score: '88',
|
||||
qualified: 'true',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
'2': {
|
||||
columns: [
|
||||
{ id: 'sku', label: 'SKU', type: 'text', width: 120 },
|
||||
{ id: 'name', label: 'Product Name', type: 'text', width: 200 },
|
||||
{ id: 'price', label: 'Price', type: 'number', width: 100 },
|
||||
{ id: 'stock', label: 'In Stock', type: 'number', width: 120 },
|
||||
{ id: 'active', label: 'Active', type: 'boolean', width: 90 },
|
||||
],
|
||||
rows: [
|
||||
{
|
||||
id: '1',
|
||||
cells: {
|
||||
sku: 'PRD-001',
|
||||
name: 'Wireless Headphones',
|
||||
price: '79.99',
|
||||
stock: '234',
|
||||
active: 'true',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
cells: { sku: 'PRD-002', name: 'USB-C Hub', price: '49.99', stock: '89', active: 'true' },
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
cells: {
|
||||
sku: 'PRD-003',
|
||||
name: 'Laptop Stand',
|
||||
price: '39.99',
|
||||
stock: '0',
|
||||
active: 'false',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
cells: {
|
||||
sku: 'PRD-004',
|
||||
name: 'Mechanical Keyboard',
|
||||
price: '129.99',
|
||||
stock: '52',
|
||||
active: 'true',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
cells: { sku: 'PRD-005', name: 'Webcam HD', price: '89.99', stock: '17', active: 'true' },
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
cells: {
|
||||
sku: 'PRD-006',
|
||||
name: 'Mouse Pad XL',
|
||||
price: '24.99',
|
||||
stock: '0',
|
||||
active: 'false',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
'3': {
|
||||
columns: [
|
||||
{ id: 'campaign', label: 'Campaign', type: 'text', width: 180 },
|
||||
{ id: 'clicks', label: 'Clicks', type: 'number', width: 100 },
|
||||
{ id: 'conversions', label: 'Conversions', type: 'number', width: 140 },
|
||||
{ id: 'spend', label: 'Spend ($)', type: 'number', width: 130 },
|
||||
{ id: 'active', label: 'Active', type: 'boolean', width: 90 },
|
||||
],
|
||||
rows: [
|
||||
{
|
||||
id: '1',
|
||||
cells: {
|
||||
campaign: 'Spring Sale 2026',
|
||||
clicks: '12,847',
|
||||
conversions: '384',
|
||||
spend: '2,400',
|
||||
active: 'true',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
cells: {
|
||||
campaign: 'Email Reactivation',
|
||||
clicks: '3,201',
|
||||
conversions: '97',
|
||||
spend: '450',
|
||||
active: 'false',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
cells: {
|
||||
campaign: 'Referral Program',
|
||||
clicks: '8,923',
|
||||
conversions: '210',
|
||||
spend: '1,100',
|
||||
active: 'true',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
cells: {
|
||||
campaign: 'Product Launch',
|
||||
clicks: '24,503',
|
||||
conversions: '891',
|
||||
spend: '5,800',
|
||||
active: 'true',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
cells: {
|
||||
campaign: 'Retargeting Q1',
|
||||
clicks: '6,712',
|
||||
conversions: '143',
|
||||
spend: '980',
|
||||
active: 'false',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
'4': {
|
||||
columns: [
|
||||
{ id: 'username', label: 'Username', type: 'text', width: 140 },
|
||||
{ id: 'email', label: 'Email', type: 'text', width: 200 },
|
||||
{ id: 'plan', label: 'Plan', type: 'text', width: 120 },
|
||||
{ id: 'seats', label: 'Seats', type: 'number', width: 100 },
|
||||
{ id: 'active', label: 'Active', type: 'boolean', width: 100 },
|
||||
],
|
||||
rows: [
|
||||
{
|
||||
id: '1',
|
||||
cells: {
|
||||
username: 'alice_j',
|
||||
email: 'alice@acme.com',
|
||||
plan: 'Pro',
|
||||
seats: '5',
|
||||
active: 'true',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
cells: {
|
||||
username: 'bobw',
|
||||
email: 'bob@techco.io',
|
||||
plan: 'Starter',
|
||||
seats: '1',
|
||||
active: 'true',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
cells: {
|
||||
username: 'carol_d',
|
||||
email: 'carol@startup.co',
|
||||
plan: 'Enterprise',
|
||||
seats: '25',
|
||||
active: 'true',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
cells: {
|
||||
username: 'dan.m',
|
||||
email: 'dan@bigcorp.com',
|
||||
plan: 'Pro',
|
||||
seats: '10',
|
||||
active: 'false',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
cells: {
|
||||
username: 'eva_chen',
|
||||
email: 'eva@design.io',
|
||||
plan: 'Starter',
|
||||
seats: '1',
|
||||
active: 'true',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
cells: {
|
||||
username: 'frank_lee',
|
||||
email: 'frank@ventures.co',
|
||||
plan: 'Enterprise',
|
||||
seats: '50',
|
||||
active: 'true',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
'5': {
|
||||
columns: [
|
||||
{ id: 'invoice', label: 'Invoice #', type: 'text', width: 140 },
|
||||
{ id: 'client', label: 'Client', type: 'text', width: 160 },
|
||||
{ id: 'amount', label: 'Amount ($)', type: 'number', width: 130 },
|
||||
{ id: 'paid', label: 'Paid', type: 'boolean', width: 80 },
|
||||
],
|
||||
rows: [
|
||||
{
|
||||
id: '1',
|
||||
cells: { invoice: 'INV-2026-001', client: 'Acme Corp', amount: '4,800.00', paid: 'true' },
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
cells: { invoice: 'INV-2026-002', client: 'TechCo', amount: '1,200.00', paid: 'true' },
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
cells: { invoice: 'INV-2026-003', client: 'StartupCo', amount: '750.00', paid: 'false' },
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
cells: { invoice: 'INV-2026-004', client: 'BigCorp', amount: '12,500.00', paid: 'true' },
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
cells: { invoice: 'INV-2026-005', client: 'Design IO', amount: '3,300.00', paid: 'false' },
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
interface SpreadsheetViewProps {
|
||||
tableId: string
|
||||
tableName: string
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
function SpreadsheetView({ tableId, tableName, onBack }: SpreadsheetViewProps) {
|
||||
const data = SPREADSHEET_DATA[tableId] ?? SPREADSHEET_DATA['1']
|
||||
const [selectedCell, setSelectedCell] = useState<{ row: string; col: string } | null>(null)
|
||||
|
||||
return (
|
||||
<div className='flex h-full flex-1 flex-col overflow-hidden bg-[var(--bg)]'>
|
||||
{/* Breadcrumb header - matches real Resource.Header breadcrumb layout */}
|
||||
<div className='border-[var(--border)] border-b px-4 py-[8.5px]'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={onBack}
|
||||
className='inline-flex items-center px-2 py-1 font-medium text-[var(--text-secondary)] text-sm transition-colors hover-hover:text-[var(--text-body)]'
|
||||
>
|
||||
<Table className='mr-3 size-[14px] text-[var(--text-icon)]' />
|
||||
Tables
|
||||
</button>
|
||||
<span className='select-none text-[var(--text-icon)] text-sm'>/</span>
|
||||
<span className='inline-flex items-center px-2 py-1 font-medium text-[var(--text-body)] text-sm'>
|
||||
{tableName}
|
||||
<ChevronDown className='ml-2 h-[7px] w-[9px] shrink-0 text-[var(--text-muted)]' />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Spreadsheet - matches exact real table editor structure */}
|
||||
<div className='min-h-0 flex-1 overflow-auto overscroll-none'>
|
||||
<table className='table-fixed border-separate border-spacing-0 text-small'>
|
||||
<colgroup>
|
||||
<col style={{ width: 40 }} />
|
||||
{data.columns.map((col) => (
|
||||
<col key={col.id} style={{ width: col.width }} />
|
||||
))}
|
||||
</colgroup>
|
||||
<thead className='sticky top-0 z-10'>
|
||||
<tr>
|
||||
<th className={CELL_HEADER_CHECKBOX} aria-label='Row number' />
|
||||
{data.columns.map((col) => {
|
||||
const Icon = COLUMN_TYPE_ICONS[col.type] ?? TypeText
|
||||
return (
|
||||
<th key={col.id} className={CELL_HEADER}>
|
||||
<div className='flex h-full w-full min-w-0 items-center px-2 py-[7px]'>
|
||||
<Icon className='size-3 shrink-0 text-[var(--text-icon)]' />
|
||||
<span className='ml-1.5 min-w-0 overflow-clip text-ellipsis whitespace-nowrap font-medium text-[var(--text-primary)] text-small'>
|
||||
{col.label}
|
||||
</span>
|
||||
<ChevronDown className='ml-auto h-[7px] w-[9px] shrink-0 text-[var(--text-muted)]' />
|
||||
</div>
|
||||
</th>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.rows.map((row, rowIdx) => (
|
||||
<tr key={row.id}>
|
||||
<td className={cn(CELL_CHECKBOX, 'text-center')}>
|
||||
<span className='text-[var(--text-tertiary)] text-xs tabular-nums'>
|
||||
{rowIdx + 1}
|
||||
</span>
|
||||
</td>
|
||||
{data.columns.map((col) => {
|
||||
const isSelected = selectedCell?.row === row.id && selectedCell?.col === col.id
|
||||
const cellValue = row.cells[col.id] ?? ''
|
||||
return (
|
||||
<td
|
||||
key={col.id}
|
||||
onClick={() => setSelectedCell({ row: row.id, col: col.id })}
|
||||
className={cn(
|
||||
CELL,
|
||||
'relative cursor-default text-[var(--text-body)]',
|
||||
isSelected && 'bg-[rgba(37,99,235,0.06)]'
|
||||
)}
|
||||
>
|
||||
{isSelected && <div className={SELECTION_OVERLAY} />}
|
||||
<div className={CELL_CONTENT}>
|
||||
{col.type === 'boolean' ? (
|
||||
<div className='flex min-h-[20px] items-center justify-center'>
|
||||
<Checkbox
|
||||
size='sm'
|
||||
checked={cellValue === 'true'}
|
||||
aria-label={col.label}
|
||||
className='pointer-events-none'
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
cellValue
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const tableViewTransition = {
|
||||
initial: { opacity: 0, x: 20 },
|
||||
animate: { opacity: 1, x: 0 },
|
||||
exit: { opacity: 0, x: -20 },
|
||||
transition: { duration: 0.25, ease: EASE_OUT },
|
||||
} as const
|
||||
|
||||
export function LandingPreviewTables() {
|
||||
const [openTableId, setOpenTableId] = useState<string | null>(null)
|
||||
|
||||
return (
|
||||
<LazyMotion features={domAnimation}>
|
||||
<AnimatePresence mode='wait'>
|
||||
{openTableId !== null ? (
|
||||
<m.div
|
||||
key={`spreadsheet-${openTableId}`}
|
||||
className='flex h-full flex-1 flex-col'
|
||||
{...tableViewTransition}
|
||||
>
|
||||
<SpreadsheetView
|
||||
tableId={openTableId}
|
||||
tableName={TABLE_METAS[openTableId] ?? 'Table'}
|
||||
onBack={() => setOpenTableId(null)}
|
||||
/>
|
||||
</m.div>
|
||||
) : (
|
||||
<m.div key='table-list' className='flex h-full flex-1 flex-col' {...tableViewTransition}>
|
||||
<LandingPreviewResource
|
||||
icon={Table}
|
||||
title='Tables'
|
||||
createLabel='New table'
|
||||
searchPlaceholder='Search tables...'
|
||||
columns={LIST_COLUMNS}
|
||||
rows={LIST_ROWS}
|
||||
onRowClick={(id) => setOpenTableId(id)}
|
||||
/>
|
||||
</m.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</LazyMotion>
|
||||
)
|
||||
}
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { domAnimation, LazyMotion, m } from 'framer-motion'
|
||||
import ReactFlow, {
|
||||
applyEdgeChanges,
|
||||
applyNodeChanges,
|
||||
type Edge,
|
||||
type EdgeProps,
|
||||
type EdgeTypes,
|
||||
getSmoothStepPath,
|
||||
type Node,
|
||||
type NodeTypes,
|
||||
type OnEdgesChange,
|
||||
type OnNodesChange,
|
||||
ReactFlowProvider,
|
||||
} from 'reactflow'
|
||||
import 'reactflow/dist/style.css'
|
||||
import { PreviewBlockNode } from '@/app/(landing)/components/landing-preview/components/landing-preview-workflow/preview-block-node'
|
||||
import {
|
||||
EASE_OUT,
|
||||
type PreviewWorkflow,
|
||||
toReactFlowElements,
|
||||
} from '@/app/(landing)/components/landing-preview/components/landing-preview-workflow/workflow-data'
|
||||
|
||||
interface LandingPreviewWorkflowProps {
|
||||
workflow: PreviewWorkflow
|
||||
animate?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom edge that draws left-to-right on initial load via stroke animation.
|
||||
* Falls back to a static path when `data.animate` is false.
|
||||
*/
|
||||
function PreviewEdge({
|
||||
id,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
style,
|
||||
data,
|
||||
}: EdgeProps) {
|
||||
const [edgePath] = getSmoothStepPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
})
|
||||
|
||||
if (data?.animate) {
|
||||
return (
|
||||
<m.path
|
||||
id={id}
|
||||
className='react-flow__edge-path'
|
||||
d={edgePath}
|
||||
style={{ ...style, fill: 'none' }}
|
||||
initial={{ pathLength: 0, opacity: 0 }}
|
||||
animate={{ pathLength: 1, opacity: 1 }}
|
||||
transition={{
|
||||
pathLength: { duration: 0.4, delay: data.delay ?? 0, ease: EASE_OUT },
|
||||
opacity: { duration: 0.15, delay: data.delay ?? 0 },
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<path
|
||||
id={id}
|
||||
className='react-flow__edge-path'
|
||||
d={edgePath}
|
||||
style={{ ...style, fill: 'none' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const NODE_TYPES: NodeTypes = { previewBlock: PreviewBlockNode }
|
||||
const EDGE_TYPES: EdgeTypes = { previewEdge: PreviewEdge }
|
||||
const PRO_OPTIONS = { hideAttribution: true }
|
||||
const DEFAULT_FIT_VIEW_OPTIONS = { padding: 0.5, maxZoom: 1 } as const
|
||||
|
||||
/**
|
||||
* Inner flow component. Keyed on workflow ID by the parent so it remounts
|
||||
* cleanly on workflow switch - fitView fires on mount with zero delay.
|
||||
*/
|
||||
function PreviewFlow({ workflow, animate = false }: LandingPreviewWorkflowProps) {
|
||||
const { nodes: initialNodes, edges: initialEdges } = useMemo(
|
||||
() => toReactFlowElements(workflow, animate),
|
||||
[workflow, animate]
|
||||
)
|
||||
|
||||
const [nodes, setNodes] = useState<Node[]>(initialNodes)
|
||||
const [edges, setEdges] = useState<Edge[]>(initialEdges)
|
||||
|
||||
const onNodesChange: OnNodesChange = useCallback(
|
||||
(changes) => setNodes((nds) => applyNodeChanges(changes, nds)),
|
||||
[]
|
||||
)
|
||||
|
||||
const onEdgesChange: OnEdgesChange = useCallback(
|
||||
(changes) => setEdges((eds) => applyEdgeChanges(changes, eds)),
|
||||
[]
|
||||
)
|
||||
|
||||
return (
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
nodeTypes={NODE_TYPES}
|
||||
edgeTypes={EDGE_TYPES}
|
||||
defaultEdgeOptions={{ type: 'previewEdge' }}
|
||||
elementsSelectable={false}
|
||||
nodesDraggable={false}
|
||||
nodesConnectable={false}
|
||||
zoomOnScroll={false}
|
||||
zoomOnDoubleClick={false}
|
||||
panOnScroll={false}
|
||||
zoomOnPinch={false}
|
||||
panOnDrag={false}
|
||||
preventScrolling={false}
|
||||
autoPanOnNodeDrag={false}
|
||||
proOptions={PRO_OPTIONS}
|
||||
minZoom={0.5}
|
||||
fitView
|
||||
fitViewOptions={DEFAULT_FIT_VIEW_OPTIONS}
|
||||
className='h-full w-full bg-[var(--surface-2)]'
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight ReactFlow canvas displaying a static workflow preview -
|
||||
* decorative, so panning, dragging, zooming, and selecting are all disabled.
|
||||
* The key on workflow.id forces a clean remount on switch - instant fitView,
|
||||
* no timers, no flicker.
|
||||
*/
|
||||
export function LandingPreviewWorkflow({ workflow, animate = false }: LandingPreviewWorkflowProps) {
|
||||
return (
|
||||
<LazyMotion features={domAnimation}>
|
||||
<div className='h-full w-full'>
|
||||
<ReactFlowProvider key={workflow.id}>
|
||||
<PreviewFlow workflow={workflow} animate={animate} />
|
||||
</ReactFlowProvider>
|
||||
</div>
|
||||
</LazyMotion>
|
||||
)
|
||||
}
|
||||
+329
@@ -0,0 +1,329 @@
|
||||
'use client'
|
||||
|
||||
import { memo } from 'react'
|
||||
import { Blimp } from '@sim/emcn'
|
||||
import { domAnimation, LazyMotion, m } from 'framer-motion'
|
||||
import { Database } from 'lucide-react'
|
||||
import { Handle, type NodeProps, Position } from 'reactflow'
|
||||
import {
|
||||
AgentIcon,
|
||||
AnthropicIcon,
|
||||
FirecrawlIcon,
|
||||
GeminiIcon,
|
||||
GithubIcon,
|
||||
GmailIcon,
|
||||
GoogleCalendarIcon,
|
||||
GoogleSheetsIcon,
|
||||
HubspotIcon,
|
||||
JiraIcon,
|
||||
LinearIcon,
|
||||
LinkedInIcon,
|
||||
MistralIcon,
|
||||
NotionIcon,
|
||||
OpenAIIcon,
|
||||
RedditIcon,
|
||||
ReductoIcon,
|
||||
SalesforceIcon,
|
||||
ScheduleIcon,
|
||||
SlackIcon,
|
||||
StartIcon,
|
||||
SupabaseIcon,
|
||||
TelegramIcon,
|
||||
TextractIcon,
|
||||
WebhookIcon,
|
||||
xAIIcon,
|
||||
xIcon,
|
||||
YouTubeIcon,
|
||||
} from '@/components/icons'
|
||||
import {
|
||||
BLOCK_STAGGER,
|
||||
EASE_OUT,
|
||||
type PreviewTool,
|
||||
} from '@/app/(landing)/components/landing-preview/components/landing-preview-workflow/workflow-data'
|
||||
import { getTileIconColorClass } from '@/blocks/icon-color'
|
||||
|
||||
/** Map block type strings to their icon components. */
|
||||
const BLOCK_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
starter: StartIcon,
|
||||
start_trigger: StartIcon,
|
||||
agent: AgentIcon,
|
||||
slack: SlackIcon,
|
||||
jira: JiraIcon,
|
||||
x: xIcon,
|
||||
youtube: YouTubeIcon,
|
||||
schedule: ScheduleIcon,
|
||||
telegram: TelegramIcon,
|
||||
knowledge_base: Database,
|
||||
webhook: WebhookIcon,
|
||||
github: GithubIcon,
|
||||
supabase: SupabaseIcon,
|
||||
google_calendar: GoogleCalendarIcon,
|
||||
gmail: GmailIcon,
|
||||
google_sheets: GoogleSheetsIcon,
|
||||
hubspot: HubspotIcon,
|
||||
linear: LinearIcon,
|
||||
firecrawl: FirecrawlIcon,
|
||||
reddit: RedditIcon,
|
||||
notion: NotionIcon,
|
||||
reducto: ReductoIcon,
|
||||
salesforce: SalesforceIcon,
|
||||
textract: TextractIcon,
|
||||
linkedin: LinkedInIcon,
|
||||
mothership: Blimp,
|
||||
}
|
||||
|
||||
/** Model prefix → provider icon for the "Model" row in agent blocks. */
|
||||
const MODEL_PROVIDER_ICONS: Array<{
|
||||
prefix: string
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
size?: string
|
||||
}> = [
|
||||
{ prefix: 'gpt-', icon: OpenAIIcon },
|
||||
{ prefix: 'o3', icon: OpenAIIcon },
|
||||
{ prefix: 'o4', icon: OpenAIIcon },
|
||||
{ prefix: 'claude-', icon: AnthropicIcon },
|
||||
{ prefix: 'gemini-', icon: GeminiIcon },
|
||||
{ prefix: 'grok-', icon: xAIIcon, size: 'size-[17px]' },
|
||||
{ prefix: 'mistral-', icon: MistralIcon },
|
||||
]
|
||||
|
||||
function getModelIconEntry(modelValue: string) {
|
||||
const lower = modelValue.toLowerCase()
|
||||
return MODEL_PROVIDER_ICONS.find((m) => lower.startsWith(m.prefix)) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Data shape for preview block nodes
|
||||
*/
|
||||
interface PreviewBlockData {
|
||||
name: string
|
||||
blockType: string
|
||||
bgColor: string
|
||||
rows: Array<{ title: string; value: string }>
|
||||
tools?: PreviewTool[]
|
||||
markdown?: string
|
||||
hideTargetHandle?: boolean
|
||||
hideSourceHandle?: boolean
|
||||
index?: number
|
||||
animate?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle styling matching the real WorkflowBlock handles.
|
||||
* --workflow-edge in dark mode: #c9c9c9
|
||||
*/
|
||||
const HANDLE_BASE = '!z-[10] !top-5 !-translate-y-1/2 !border-none !bg-[var(--surface-7)]'
|
||||
const HANDLE_LEFT = `${HANDLE_BASE} !left-[-8px] !h-5 !w-[7px] !rounded-r-none !rounded-l-[2px]`
|
||||
const HANDLE_RIGHT = `${HANDLE_BASE} !right-[-8px] !h-5 !w-[7px] !rounded-l-none !rounded-r-[2px]`
|
||||
|
||||
/**
|
||||
* Static preview block node matching the real WorkflowBlock styling.
|
||||
* Renders a block header with icon + name, sub-block rows, and tool chips.
|
||||
*
|
||||
* Colors sourced from dark theme CSS variables:
|
||||
* --surface-2: #ffffff, --border-1: #e6e6e6
|
||||
* --text-primary: #121212, --text-tertiary: #5f5f5f
|
||||
*/
|
||||
export const PreviewBlockNode = memo(function PreviewBlockNode({
|
||||
data,
|
||||
}: NodeProps<PreviewBlockData>) {
|
||||
const {
|
||||
name,
|
||||
blockType,
|
||||
bgColor,
|
||||
rows,
|
||||
tools,
|
||||
markdown,
|
||||
hideTargetHandle,
|
||||
hideSourceHandle,
|
||||
index = 0,
|
||||
animate = false,
|
||||
} = data
|
||||
const Icon = BLOCK_ICONS[blockType]
|
||||
const delay = animate ? index * BLOCK_STAGGER : 0
|
||||
|
||||
if (blockType === 'note' && markdown) {
|
||||
return (
|
||||
<LazyMotion features={domAnimation}>
|
||||
<m.div
|
||||
className='relative'
|
||||
initial={animate ? { opacity: 0 } : false}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.45, delay, ease: EASE_OUT }}
|
||||
>
|
||||
<div className='w-[280px] select-none rounded-[8px] border border-[var(--border-1)] bg-[var(--surface-2)]'>
|
||||
<div className='border-[var(--border)] border-b p-2'>
|
||||
<span className='font-medium text-[16px] text-[var(--text-primary)]'>Note</span>
|
||||
</div>
|
||||
<div className='p-2.5'>
|
||||
<NoteMarkdown content={markdown} />
|
||||
</div>
|
||||
</div>
|
||||
</m.div>
|
||||
</LazyMotion>
|
||||
)
|
||||
}
|
||||
|
||||
const hasContent = rows.length > 0 || (tools && tools.length > 0)
|
||||
|
||||
return (
|
||||
<LazyMotion features={domAnimation}>
|
||||
<m.div
|
||||
className='relative'
|
||||
initial={animate ? { opacity: 0 } : false}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.45, delay, ease: EASE_OUT }}
|
||||
>
|
||||
<div className='relative z-[20] w-[250px] select-none rounded-[8px] border border-[var(--border-1)] bg-[var(--surface-2)]'>
|
||||
{/* Target handle (left side) */}
|
||||
{!hideTargetHandle && (
|
||||
<Handle
|
||||
type='target'
|
||||
position={Position.Left}
|
||||
id='target'
|
||||
className={HANDLE_LEFT}
|
||||
isConnectableStart={false}
|
||||
isConnectableEnd={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div
|
||||
className={`flex items-center justify-between p-2 ${hasContent ? 'border-[var(--border)] border-b' : ''}`}
|
||||
>
|
||||
<div className='relative z-10 flex min-w-0 flex-1 items-center gap-2.5'>
|
||||
<div
|
||||
className='flex size-[24px] flex-shrink-0 items-center justify-center rounded-[6px]'
|
||||
style={{ background: bgColor }}
|
||||
>
|
||||
{Icon && <Icon className={`size-[16px] ${getTileIconColorClass(bgColor)}`} />}
|
||||
</div>
|
||||
<span className='truncate font-medium text-[16px] text-[var(--text-primary)]'>
|
||||
{name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sub-block rows + tools */}
|
||||
{hasContent && (
|
||||
<div className='flex flex-col gap-2 p-2'>
|
||||
{rows.map((row) => {
|
||||
const modelEntry = row.title === 'Model' ? getModelIconEntry(row.value) : null
|
||||
const ModelIcon = modelEntry?.icon
|
||||
return (
|
||||
<div key={row.title} className='flex items-center gap-2'>
|
||||
<span className='flex-shrink-0 font-normal text-[14px] text-[var(--text-muted)] capitalize'>
|
||||
{row.title}
|
||||
</span>
|
||||
{row.value && (
|
||||
<span className='flex min-w-0 flex-1 items-center justify-end gap-2 font-normal text-[14px] text-[var(--text-primary)]'>
|
||||
{ModelIcon && (
|
||||
<ModelIcon
|
||||
className={`inline-block flex-shrink-0 text-[var(--text-primary)] ${modelEntry.size ?? 'size-[14px]'}`}
|
||||
/>
|
||||
)}
|
||||
<span className='truncate'>{row.value}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Tool chips - inline with label */}
|
||||
{tools && tools.length > 0 && (
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='flex-shrink-0 font-normal text-[14px] text-[var(--text-muted)]'>
|
||||
Tools
|
||||
</span>
|
||||
<div className='flex flex-1 flex-wrap items-center justify-end gap-[5px]'>
|
||||
{tools.map((tool) => {
|
||||
const ToolIcon = BLOCK_ICONS[tool.type]
|
||||
return (
|
||||
<div
|
||||
key={tool.type}
|
||||
className='flex items-center gap-[5px] rounded-[5px] border border-[var(--border-1)] bg-[var(--surface-1)] px-[6px] py-[3px]'
|
||||
>
|
||||
<div
|
||||
className='flex size-[16px] flex-shrink-0 items-center justify-center rounded-[4px]'
|
||||
style={{ background: tool.bgColor }}
|
||||
>
|
||||
{ToolIcon && (
|
||||
<ToolIcon
|
||||
className={`size-[10px] ${getTileIconColorClass(tool.bgColor)}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span className='font-normal text-[12px] text-[var(--text-primary)]'>
|
||||
{tool.name}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Source handle (right side) */}
|
||||
{!hideSourceHandle && (
|
||||
<Handle
|
||||
type='source'
|
||||
position={Position.Right}
|
||||
id='source'
|
||||
className={HANDLE_RIGHT}
|
||||
isConnectableStart={false}
|
||||
isConnectableEnd={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</m.div>
|
||||
</LazyMotion>
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* Renders lightweight markdown-like content for note blocks.
|
||||
* Supports ### headings, **bold**, _italic_, --- rules, and blank-line spacing.
|
||||
*/
|
||||
function NoteMarkdown({ content }: { content: string }) {
|
||||
const lines = content.split('\n')
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-1'>
|
||||
{lines.map((line, i) => {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) return <div key={`${line}-${i}`} className='h-[4px]' />
|
||||
|
||||
if (trimmed === '---') {
|
||||
return <hr key={`${line}-${i}`} className='my-1 border-[var(--border)] border-t' />
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('### ')) {
|
||||
return (
|
||||
<p
|
||||
key={`${line}-${i}`}
|
||||
className='font-semibold text-[16px] text-[var(--text-primary)] leading-[1.3]'
|
||||
>
|
||||
{trimmed.slice(4)}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
key={`${line}-${i}`}
|
||||
className='font-medium text-[13px] text-[var(--text-primary)] leading-[1.5]'
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: trimmed
|
||||
.replace(/\*\*_(.+?)_\*\*/g, '<strong><em>$1</em></strong>')
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/_"(.+?)"_/g, '<em>“$1”</em>')
|
||||
.replace(/_(.+?)_/g, '<em>$1</em>'),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+434
@@ -0,0 +1,434 @@
|
||||
import type { Edge, Node } from 'reactflow'
|
||||
import { Position } from 'reactflow'
|
||||
|
||||
/**
|
||||
* Tool entry displayed as a chip on agent blocks
|
||||
*/
|
||||
export interface PreviewTool {
|
||||
name: string
|
||||
type: string
|
||||
bgColor: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Static block definition for preview workflow nodes
|
||||
*/
|
||||
export interface PreviewBlock {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
bgColor: string
|
||||
rows: Array<{ title: string; value: string }>
|
||||
tools?: PreviewTool[]
|
||||
markdown?: string
|
||||
position: { x: number; y: number }
|
||||
hideTargetHandle?: boolean
|
||||
hideSourceHandle?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* A scripted Mothership conversation shown in the chat pane beside a staged
|
||||
* resource - the request a builder typed and Sim's reply. Anchors the
|
||||
* "chat everywhere" narrative: the chat drives whatever is staged on the right.
|
||||
*/
|
||||
export interface PreviewChat {
|
||||
user: string
|
||||
assistant: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Workflow definition containing nodes, edges, and metadata
|
||||
*/
|
||||
export interface PreviewWorkflow {
|
||||
id: string
|
||||
name: string
|
||||
color: string
|
||||
blocks: PreviewBlock[]
|
||||
edges: Array<{ id: string; source: string; target: string }>
|
||||
/** The Mothership exchange that produced this workflow, shown in the chat pane. */
|
||||
chat?: PreviewChat
|
||||
/** Public JSON export used to seed the landing-page import flow */
|
||||
seedPath?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* IT Service Management workflow - Slack Trigger -> Agent (KB tool) -> Jira
|
||||
*/
|
||||
const IT_SERVICE_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'wf-it-service',
|
||||
name: 'IT Service Management',
|
||||
color: '#2C2C2C',
|
||||
blocks: [
|
||||
{
|
||||
id: 'slack-1',
|
||||
name: 'Slack',
|
||||
type: 'slack',
|
||||
bgColor: '#2C2C2C',
|
||||
rows: [
|
||||
{ title: 'Channel', value: '#it-support' },
|
||||
{ title: 'Event', value: 'New Message' },
|
||||
],
|
||||
position: { x: 80, y: 140 },
|
||||
hideTargetHandle: true,
|
||||
},
|
||||
{
|
||||
id: 'agent-1',
|
||||
name: 'Agent',
|
||||
type: 'agent',
|
||||
bgColor: '#2C2C2C',
|
||||
rows: [
|
||||
{ title: 'Model', value: 'claude-sonnet-4.6' },
|
||||
{
|
||||
title: 'System Prompt',
|
||||
value:
|
||||
'Triage incoming IT support requests from Slack, categorize by severity, and create Jira tickets for the appropriate team.',
|
||||
},
|
||||
],
|
||||
tools: [{ name: 'Knowledge Base', type: 'knowledge_base', bgColor: '#2C2C2C' }],
|
||||
position: { x: 420, y: 40 },
|
||||
},
|
||||
{
|
||||
id: 'jira-1',
|
||||
name: 'Jira',
|
||||
type: 'jira',
|
||||
bgColor: '#2C2C2C',
|
||||
rows: [
|
||||
{ title: 'Operation', value: 'Get Issues' },
|
||||
{ title: 'Project', value: 'IT-Support' },
|
||||
],
|
||||
position: { x: 420, y: 260 },
|
||||
hideSourceHandle: true,
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ id: 'e-1', source: 'slack-1', target: 'agent-1' },
|
||||
{ id: 'e-2', source: 'slack-1', target: 'jira-1' },
|
||||
],
|
||||
chat: {
|
||||
user: 'Set up an IT support agent that answers Slack questions from our docs and files Jira tickets.',
|
||||
assistant:
|
||||
'Built IT Service Management. It watches #it-support, answers from your knowledge base, and opens Jira issues for anything it can’t resolve.',
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-healing CRM workflow - Schedule -> Agent
|
||||
*/
|
||||
const SELF_HEALING_CRM_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'wf-self-healing-crm',
|
||||
name: 'Self-healing CRM',
|
||||
color: '#2C2C2C',
|
||||
blocks: [
|
||||
{
|
||||
id: 'schedule-1',
|
||||
name: 'Schedule',
|
||||
type: 'schedule',
|
||||
bgColor: '#2C2C2C',
|
||||
rows: [
|
||||
{ title: 'Run Frequency', value: 'Daily' },
|
||||
{ title: 'Time', value: '09:00 AM' },
|
||||
],
|
||||
position: { x: 80, y: 140 },
|
||||
hideTargetHandle: true,
|
||||
},
|
||||
{
|
||||
id: 'agent-crm',
|
||||
name: 'CRM Agent',
|
||||
type: 'agent',
|
||||
bgColor: '#2C2C2C',
|
||||
rows: [
|
||||
{ title: 'Model', value: 'gpt-5.4' },
|
||||
{
|
||||
title: 'System Prompt',
|
||||
value:
|
||||
'Audit CRM records, identify data inconsistencies, and fix duplicate contacts, missing fields, and stale pipeline entries across HubSpot and Salesforce.',
|
||||
},
|
||||
],
|
||||
tools: [
|
||||
{ name: 'HubSpot', type: 'hubspot', bgColor: '#2C2C2C' },
|
||||
{ name: 'Salesforce', type: 'salesforce', bgColor: '#2C2C2C' },
|
||||
],
|
||||
position: { x: 420, y: 140 },
|
||||
hideSourceHandle: true,
|
||||
},
|
||||
],
|
||||
edges: [{ id: 'e-3', source: 'schedule-1', target: 'agent-crm' }],
|
||||
chat: {
|
||||
user: 'Build an agent that audits our CRM every morning and fixes bad records.',
|
||||
assistant:
|
||||
'Done. Self-healing CRM runs daily at 9 AM, audits HubSpot and Salesforce, and fixes duplicates, missing fields, and stale entries.',
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Customer Support Agent workflow - Gmail Trigger -> Agent (KB + Notion tools) -> Slack
|
||||
*/
|
||||
const CUSTOMER_SUPPORT_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'wf-customer-support',
|
||||
name: 'Customer Support Agent',
|
||||
color: '#2C2C2C',
|
||||
blocks: [
|
||||
{
|
||||
id: 'gmail-1',
|
||||
name: 'Gmail',
|
||||
type: 'gmail',
|
||||
bgColor: '#2C2C2C',
|
||||
rows: [
|
||||
{ title: 'Event', value: 'New Email' },
|
||||
{ title: 'Label', value: 'Support' },
|
||||
],
|
||||
position: { x: 80, y: 140 },
|
||||
hideTargetHandle: true,
|
||||
},
|
||||
{
|
||||
id: 'agent-3',
|
||||
name: 'Support Agent',
|
||||
type: 'agent',
|
||||
bgColor: '#2C2C2C',
|
||||
rows: [
|
||||
{ title: 'Model', value: 'gpt-5.4' },
|
||||
{
|
||||
title: 'System Prompt',
|
||||
value:
|
||||
'Resolve customer support issues using the knowledge base, draft a response, and notify the team in Slack.',
|
||||
},
|
||||
],
|
||||
tools: [
|
||||
{ name: 'Knowledge', type: 'knowledge_base', bgColor: '#2C2C2C' },
|
||||
{ name: 'Notion', type: 'notion', bgColor: '#2C2C2C' },
|
||||
],
|
||||
position: { x: 420, y: 40 },
|
||||
},
|
||||
{
|
||||
id: 'slack-3',
|
||||
name: 'Slack',
|
||||
type: 'slack',
|
||||
bgColor: '#2C2C2C',
|
||||
rows: [
|
||||
{ title: 'Channel', value: '#support' },
|
||||
{ title: 'Operation', value: 'Send Message' },
|
||||
],
|
||||
position: { x: 420, y: 260 },
|
||||
hideSourceHandle: true,
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ id: 'e-cs-1', source: 'gmail-1', target: 'agent-3' },
|
||||
{ id: 'e-cs-2', source: 'gmail-1', target: 'slack-3' },
|
||||
],
|
||||
chat: {
|
||||
user: 'Make a support agent that replies to customer emails using our help docs.',
|
||||
assistant:
|
||||
'Here it is. Customer Support Agent triages new support email, drafts replies from your Knowledge Base and Notion, and posts to #support.',
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty "New Agent" workflow - a single note prompting the user to start building
|
||||
*/
|
||||
const NEW_AGENT_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'wf-new-agent',
|
||||
name: 'New Agent',
|
||||
color: '#2C2C2C',
|
||||
blocks: [
|
||||
{
|
||||
id: 'note-1',
|
||||
name: '',
|
||||
type: 'note',
|
||||
bgColor: 'transparent',
|
||||
rows: [],
|
||||
markdown: '### What will you build?\n\n_"Find Linear todos and send in Slack"_',
|
||||
position: { x: 0, y: 0 },
|
||||
hideTargetHandle: true,
|
||||
hideSourceHandle: true,
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
}
|
||||
|
||||
export const PREVIEW_WORKFLOWS: PreviewWorkflow[] = [
|
||||
SELF_HEALING_CRM_WORKFLOW,
|
||||
IT_SERVICE_WORKFLOW,
|
||||
CUSTOMER_SUPPORT_WORKFLOW,
|
||||
NEW_AGENT_WORKFLOW,
|
||||
]
|
||||
|
||||
/** Stagger delay between each block appearing (seconds). */
|
||||
export const BLOCK_STAGGER = 0.12
|
||||
|
||||
/** Shared cubic-bezier easing - fast deceleration, gentle settle. */
|
||||
export const EASE_OUT: [number, number, number, number] = [0.16, 1, 0.3, 1]
|
||||
|
||||
/** Shared edge style applied to all preview workflow connections */
|
||||
const EDGE_STYLE = { stroke: '#c9c9c9', strokeWidth: 1.5 } as const
|
||||
|
||||
/**
|
||||
* Converts a PreviewWorkflow to React Flow nodes and edges.
|
||||
*
|
||||
* @param workflow - The workflow definition
|
||||
* @param animate - When true, node/edge data includes animation metadata
|
||||
*/
|
||||
export function toReactFlowElements(
|
||||
workflow: PreviewWorkflow,
|
||||
animate = false
|
||||
): {
|
||||
nodes: Node[]
|
||||
edges: Edge[]
|
||||
} {
|
||||
const blockIndexMap = new Map(workflow.blocks.map((b, i) => [b.id, i]))
|
||||
|
||||
const nodes: Node[] = workflow.blocks.map((block, index) => ({
|
||||
id: block.id,
|
||||
type: 'previewBlock',
|
||||
position: block.position,
|
||||
data: {
|
||||
name: block.name,
|
||||
blockType: block.type,
|
||||
bgColor: block.bgColor,
|
||||
rows: block.rows,
|
||||
tools: block.tools,
|
||||
markdown: block.markdown,
|
||||
hideTargetHandle: block.hideTargetHandle,
|
||||
hideSourceHandle: block.hideSourceHandle,
|
||||
index,
|
||||
animate,
|
||||
},
|
||||
draggable: true,
|
||||
selectable: false,
|
||||
connectable: false,
|
||||
sourcePosition: Position.Right,
|
||||
targetPosition: Position.Left,
|
||||
}))
|
||||
|
||||
const edges: Edge[] = workflow.edges.map((e) => {
|
||||
const sourceIndex = blockIndexMap.get(e.source) ?? 0
|
||||
return {
|
||||
id: e.id,
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
type: 'previewEdge',
|
||||
animated: false,
|
||||
style: EDGE_STYLE,
|
||||
sourceHandle: 'source',
|
||||
targetHandle: 'target',
|
||||
data: {
|
||||
animate,
|
||||
delay: animate ? sourceIndex * BLOCK_STAGGER + BLOCK_STAGGER : 0,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
return { nodes, edges }
|
||||
}
|
||||
|
||||
/** Block types that carry an editable prompt suitable for the Editor tab. */
|
||||
const AGENT_BLOCK_TYPES = new Set(['agent', 'mothership'])
|
||||
|
||||
export interface EditorPromptData {
|
||||
blockId: string
|
||||
blockName: string
|
||||
blockType: string
|
||||
bgColor: string
|
||||
prompt: string
|
||||
model: string | null
|
||||
tools: PreviewTool[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the editor-facing prompt from the first agent/mothership block.
|
||||
*
|
||||
* @returns Block metadata + prompt + model + tools, or `null` when the workflow has no agent.
|
||||
*/
|
||||
export function getEditorPrompt(workflow: PreviewWorkflow): EditorPromptData | null {
|
||||
for (const block of workflow.blocks) {
|
||||
if (!AGENT_BLOCK_TYPES.has(block.type)) continue
|
||||
/** Single ordered pass: first row matching either prompt title, and the first Model row. */
|
||||
let promptRow: (typeof block.rows)[number] | undefined
|
||||
let modelRow: (typeof block.rows)[number] | undefined
|
||||
for (const row of block.rows) {
|
||||
if (!promptRow && (row.title === 'Prompt' || row.title === 'System Prompt')) promptRow = row
|
||||
if (!modelRow && row.title === 'Model') modelRow = row
|
||||
}
|
||||
if (promptRow) {
|
||||
return {
|
||||
blockId: block.id,
|
||||
blockName: block.name,
|
||||
blockType: block.type,
|
||||
bgColor: block.bgColor,
|
||||
prompt: promptRow.value,
|
||||
model: modelRow?.value ?? null,
|
||||
tools: block.tools ?? [],
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the delay (ms) before the Editor tab should activate.
|
||||
* Accounts for all block staggers + edge draw durations + a small buffer.
|
||||
*/
|
||||
export function getWorkflowAnimationTiming(workflow: PreviewWorkflow): { editorDelay: number } {
|
||||
const maxBlockIndex = Math.max(0, workflow.blocks.length - 1)
|
||||
const hasEdges = workflow.edges.length > 0
|
||||
const edgeDuration = hasEdges ? 0.4 : 0
|
||||
const buffer = 0.15
|
||||
const total = maxBlockIndex * BLOCK_STAGGER + BLOCK_STAGGER + edgeDuration + buffer
|
||||
return { editorDelay: Math.round(total * 1000) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Scripted chat for the non-workflow resources the demo stages (logs, tables),
|
||||
* keyed by sidebar view. Keeps the chat pane present and on-topic so the
|
||||
* "chat everywhere" story holds while a non-workflow resource is staged.
|
||||
*/
|
||||
export const RESOURCE_CHATS: Record<string, PreviewChat> = {
|
||||
logs: {
|
||||
user: 'How are my agents doing today?',
|
||||
assistant:
|
||||
'7 runs today across your agents, 2 errors: Lead Enrichment and Content Moderator. Want me to dig into those?',
|
||||
},
|
||||
tables: {
|
||||
user: 'Show me my data tables.',
|
||||
assistant:
|
||||
'Here are your tables: Customer Leads, Product Catalog, and three more. Ask me to query or update any of them.',
|
||||
},
|
||||
files: {
|
||||
user: 'What files do we have in the workspace?',
|
||||
assistant: 'Pulling up your files. I can summarize, extract, or feed any of them to an agent.',
|
||||
},
|
||||
knowledge: {
|
||||
user: 'What’s in our knowledge base?',
|
||||
assistant: 'Here are your knowledge bases. Your agents read from these to ground every answer.',
|
||||
},
|
||||
'scheduled-tasks': {
|
||||
user: 'What’s scheduled to run?',
|
||||
assistant: 'These are your scheduled tasks. I can pause, edit, or add a new one for you.',
|
||||
},
|
||||
}
|
||||
|
||||
/** The chat shown for a staged resource: the workflow's own exchange, else the view's. */
|
||||
export function getViewChat(view: string, workflow?: PreviewWorkflow): PreviewChat | null {
|
||||
if (view === 'workflow') return workflow?.chat ?? null
|
||||
return RESOURCE_CHATS[view] ?? null
|
||||
}
|
||||
|
||||
/** Milliseconds between each character typed in the Editor prompt animation. */
|
||||
export const TYPE_INTERVAL_MS = 30
|
||||
|
||||
/** Extra pause (ms) after switching to the Editor tab before typing begins. */
|
||||
export const TYPE_START_BUFFER_MS = 150
|
||||
|
||||
/** How long to dwell on a completed step before advancing (ms). */
|
||||
export const STEP_DWELL_MS = 2500
|
||||
|
||||
/**
|
||||
* Computes the total time (ms) a workflow step occupies, including
|
||||
* canvas animation, editor typing, and a dwell period.
|
||||
*/
|
||||
export function getWorkflowStepDuration(workflow: PreviewWorkflow): number {
|
||||
const { editorDelay } = getWorkflowAnimationTiming(workflow)
|
||||
const prompt = getEditorPrompt(workflow)
|
||||
const typingTime = prompt ? prompt.prompt.length * TYPE_INTERVAL_MS : 0
|
||||
return editorDelay + TYPE_START_BUFFER_MS + typingTime + STEP_DWELL_MS
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { LandingPromptStorage } from '@/lib/core/utils/browser-storage'
|
||||
import { trackLandingCta } from '@/app/(landing)/track-landing-cta'
|
||||
|
||||
/**
|
||||
* Stores the typed prompt in browser storage and routes to `/signup`, so a
|
||||
* visitor's first message survives the auth hop. Shared by the landing
|
||||
* preview's chat pane and the home empty-state input.
|
||||
*/
|
||||
export function useLandingSubmit() {
|
||||
const router = useRouter()
|
||||
return useCallback(
|
||||
(text: string) => {
|
||||
const trimmed = text.trim()
|
||||
if (!trimmed) return
|
||||
LandingPromptStorage.store(trimmed)
|
||||
trackLandingCta({
|
||||
label: 'Prompt submit',
|
||||
section: 'landing_preview',
|
||||
destination: '/signup',
|
||||
})
|
||||
router.push('/signup')
|
||||
},
|
||||
[router]
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
'use client'
|
||||
|
||||
import dynamic from 'next/dynamic'
|
||||
import type { SidebarView } from '@/app/(landing)/components/landing-preview/components/landing-preview-sidebar/landing-preview-sidebar'
|
||||
import { useLazyMount } from '@/app/(landing)/hooks/use-lazy-mount'
|
||||
|
||||
/** Dimension-stable placeholder sized to the preview's exact footprint (zero CLS). */
|
||||
const PLACEHOLDER_CLASS = 'aspect-[1116/615] w-full rounded bg-[var(--surface-1)]'
|
||||
|
||||
/**
|
||||
* Client mount for the {@link LandingPreview} - the heavy, animated workspace
|
||||
* island (framer-motion + reactflow). Isolated here so the sections that show it
|
||||
* stay Server Components: only this leaf is `'use client'`.
|
||||
*
|
||||
* Loaded with `ssr: false` so the framer-motion/reactflow bundle never ships in
|
||||
* the server-rendered HTML, and gated on viewport proximity via
|
||||
* {@link useLazyMount} so the below-the-fold previews don't pull the heavy
|
||||
* bundle into the initial homepage load. A dimension-stable placeholder (the
|
||||
* preview's exact `aspect-[1116/615]` footprint, filled with the canvas
|
||||
* surface) holds the space before and during load, so there is zero layout
|
||||
* shift or flash.
|
||||
*/
|
||||
const LandingPreview = dynamic(
|
||||
() =>
|
||||
import('@/app/(landing)/components/landing-preview/landing-preview').then(
|
||||
(mod) => mod.LandingPreview
|
||||
),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <div className={PLACEHOLDER_CLASS} />,
|
||||
}
|
||||
)
|
||||
|
||||
interface LandingPreviewMountProps {
|
||||
/** Forwarded to {@link LandingPreview}; `false` renders a static snapshot. */
|
||||
autoplay?: boolean
|
||||
/** Forwarded to {@link LandingPreview}; the static snapshot's staged view. */
|
||||
view?: SidebarView
|
||||
/** Forwarded to {@link LandingPreview}; the static snapshot's workflow. */
|
||||
workflowId?: string
|
||||
}
|
||||
|
||||
export function LandingPreviewMount({ autoplay, view, workflowId }: LandingPreviewMountProps) {
|
||||
const { ref, inView } = useLazyMount('400px')
|
||||
|
||||
return (
|
||||
<div ref={ref}>
|
||||
{inView ? (
|
||||
<LandingPreview autoplay={autoplay} initialView={view} initialWorkflowId={workflowId} />
|
||||
) : (
|
||||
<div className={PLACEHOLDER_CLASS} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react'
|
||||
import { AnimatePresence, domAnimation, LazyMotion, m, type Variants } from 'framer-motion'
|
||||
import { LandingPreviewChat } from '@/app/(landing)/components/landing-preview/components/landing-preview-chat/landing-preview-chat'
|
||||
import { LandingPreviewFiles } from '@/app/(landing)/components/landing-preview/components/landing-preview-files/landing-preview-files'
|
||||
import { LandingPreviewHome } from '@/app/(landing)/components/landing-preview/components/landing-preview-home/landing-preview-home'
|
||||
import { LandingPreviewKnowledge } from '@/app/(landing)/components/landing-preview/components/landing-preview-knowledge/landing-preview-knowledge'
|
||||
import { LandingPreviewLogs } from '@/app/(landing)/components/landing-preview/components/landing-preview-logs/landing-preview-logs'
|
||||
import { LandingPreviewScheduledTasks } from '@/app/(landing)/components/landing-preview/components/landing-preview-scheduled-tasks/landing-preview-scheduled-tasks'
|
||||
import type { SidebarView } from '@/app/(landing)/components/landing-preview/components/landing-preview-sidebar/landing-preview-sidebar'
|
||||
import { LandingPreviewSidebar } from '@/app/(landing)/components/landing-preview/components/landing-preview-sidebar/landing-preview-sidebar'
|
||||
import { LandingPreviewStageHeader } from '@/app/(landing)/components/landing-preview/components/landing-preview-stage/landing-preview-stage-header'
|
||||
import { LandingPreviewTables } from '@/app/(landing)/components/landing-preview/components/landing-preview-tables/landing-preview-tables'
|
||||
import { LandingPreviewWorkflow } from '@/app/(landing)/components/landing-preview/components/landing-preview-workflow/landing-preview-workflow'
|
||||
import {
|
||||
EASE_OUT,
|
||||
getViewChat,
|
||||
getWorkflowStepDuration,
|
||||
PREVIEW_WORKFLOWS,
|
||||
} from '@/app/(landing)/components/landing-preview/components/landing-preview-workflow/workflow-data'
|
||||
|
||||
/** Chat-switcher breadcrumb title per non-workflow staged view. */
|
||||
const CHAT_TITLES: Partial<Record<SidebarView, string>> = {
|
||||
logs: 'Agent activity',
|
||||
tables: 'Workspace data',
|
||||
files: 'Files',
|
||||
knowledge: 'Knowledge base',
|
||||
'scheduled-tasks': 'Scheduled tasks',
|
||||
}
|
||||
|
||||
const containerVariants: Variants = {
|
||||
hidden: {},
|
||||
visible: {
|
||||
transition: { staggerChildren: 0.15 },
|
||||
},
|
||||
}
|
||||
|
||||
const sidebarVariants: Variants = {
|
||||
hidden: { opacity: 0, x: -12 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
transition: {
|
||||
x: { duration: 0.25, ease: EASE_OUT },
|
||||
opacity: { duration: 0.25, ease: EASE_OUT },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const viewTransition = {
|
||||
initial: { opacity: 0 },
|
||||
animate: { opacity: 1 },
|
||||
exit: { opacity: 0 },
|
||||
transition: { duration: 0.2, ease: EASE_OUT },
|
||||
} as const
|
||||
|
||||
interface DemoStep {
|
||||
type: 'workflow' | 'home' | 'logs'
|
||||
workflowId?: string
|
||||
duration: number
|
||||
}
|
||||
|
||||
const WORKFLOW_MAP = new Map(PREVIEW_WORKFLOWS.map((w) => [w.id, w]))
|
||||
|
||||
const HOME_STEP_MS = 12000
|
||||
const LOGS_STEP_MS = 5000
|
||||
|
||||
const DESKTOP_QUERY = '(min-width: 1024px)'
|
||||
|
||||
/** SSR-safe desktop media-query subscription for {@link useSyncExternalStore}. */
|
||||
function subscribeDesktop(onChange: () => void) {
|
||||
const mql = window.matchMedia(DESKTOP_QUERY)
|
||||
mql.addEventListener('change', onChange)
|
||||
return () => mql.removeEventListener('change', onChange)
|
||||
}
|
||||
const getDesktopSnapshot = () => window.matchMedia(DESKTOP_QUERY).matches
|
||||
const getDesktopServerSnapshot = () => true
|
||||
|
||||
/** Full desktop sequence: CRM -> home -> logs -> ITSM -> support -> repeat */
|
||||
const DESKTOP_STEPS: DemoStep[] = [
|
||||
{
|
||||
type: 'workflow',
|
||||
workflowId: 'wf-self-healing-crm',
|
||||
duration: getWorkflowStepDuration(WORKFLOW_MAP.get('wf-self-healing-crm')!),
|
||||
},
|
||||
{ type: 'home', duration: HOME_STEP_MS },
|
||||
{ type: 'logs', duration: LOGS_STEP_MS },
|
||||
{
|
||||
type: 'workflow',
|
||||
workflowId: 'wf-it-service',
|
||||
duration: getWorkflowStepDuration(WORKFLOW_MAP.get('wf-it-service')!),
|
||||
},
|
||||
{
|
||||
type: 'workflow',
|
||||
workflowId: 'wf-customer-support',
|
||||
duration: getWorkflowStepDuration(WORKFLOW_MAP.get('wf-customer-support')!),
|
||||
},
|
||||
]
|
||||
|
||||
interface LandingPreviewProps {
|
||||
/**
|
||||
* When false, render a static snapshot: no auto-cycle, no entrance/data-flow
|
||||
* animation. Used when the preview is a faded backdrop behind an elevated
|
||||
* callout rather than the live demo.
|
||||
*/
|
||||
autoplay?: boolean
|
||||
/**
|
||||
* Initial staged view for the static snapshot (`autoplay={false}`). Defaults
|
||||
* to `'workflow'`. Lets each feature stage show the platform surface that
|
||||
* matches its callout (e.g. `'logs'`, `'scheduled-tasks'`).
|
||||
*/
|
||||
initialView?: SidebarView
|
||||
/** Initial workflow for the static snapshot. Defaults to the first preview workflow. */
|
||||
initialWorkflowId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Interactive workspace preview for the hero section.
|
||||
*
|
||||
* Desktop: auto-cycles CRM -> home -> logs -> ITSM -> support -> repeat.
|
||||
* Mobile: static workflow canvas (no animation, no cycling).
|
||||
* User interaction permanently stops the auto-cycle.
|
||||
*
|
||||
* Pass `autoplay={false}` to render a fully static snapshot (no cycling, no
|
||||
* animation) - for use as a background behind a feature callout.
|
||||
*/
|
||||
export function LandingPreview({
|
||||
autoplay = true,
|
||||
initialView = 'workflow',
|
||||
initialWorkflowId = PREVIEW_WORKFLOWS[0].id,
|
||||
}: LandingPreviewProps) {
|
||||
const [activeView, setActiveView] = useState<SidebarView>(initialView)
|
||||
const [activeWorkflowId, setActiveWorkflowId] = useState(initialWorkflowId)
|
||||
const [animationKey, setAnimationKey] = useState(0)
|
||||
const [autoTypeHome, setAutoTypeHome] = useState(false)
|
||||
const isDesktop = useSyncExternalStore(
|
||||
subscribeDesktop,
|
||||
getDesktopSnapshot,
|
||||
getDesktopServerSnapshot
|
||||
)
|
||||
|
||||
const demoIndexRef = useRef(0)
|
||||
const demoTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const autoCycleActiveRef = useRef(true)
|
||||
|
||||
const clearDemoTimer = useCallback(() => {
|
||||
if (demoTimerRef.current) {
|
||||
clearTimeout(demoTimerRef.current)
|
||||
demoTimerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const applyDemoStep = useCallback((step: DemoStep) => {
|
||||
setAutoTypeHome(false)
|
||||
|
||||
if (step.type === 'workflow' && step.workflowId) {
|
||||
setActiveWorkflowId(step.workflowId)
|
||||
setActiveView('workflow')
|
||||
setAnimationKey((k) => k + 1)
|
||||
} else if (step.type === 'home') {
|
||||
setActiveView('home')
|
||||
setAutoTypeHome(true)
|
||||
} else if (step.type === 'logs') {
|
||||
setActiveView('logs')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const scheduleNextStep = useCallback(() => {
|
||||
if (!autoCycleActiveRef.current) return
|
||||
const steps = DESKTOP_STEPS
|
||||
const currentStep = steps[demoIndexRef.current]
|
||||
demoTimerRef.current = setTimeout(() => {
|
||||
if (!autoCycleActiveRef.current) return
|
||||
demoIndexRef.current = (demoIndexRef.current + 1) % steps.length
|
||||
applyDemoStep(steps[demoIndexRef.current])
|
||||
scheduleNextStep()
|
||||
}, currentStep.duration)
|
||||
}, [applyDemoStep])
|
||||
|
||||
useEffect(() => {
|
||||
/** `isDesktop` is reactive, so gate on the cycle still being active - a resize after interaction must not restart. */
|
||||
if (!isDesktop || !autoplay || !autoCycleActiveRef.current) return
|
||||
/** Reset the index so a restart replays from step 0 (index, applied step, and delay stay consistent). */
|
||||
demoIndexRef.current = 0
|
||||
applyDemoStep(DESKTOP_STEPS[0])
|
||||
scheduleNextStep()
|
||||
return clearDemoTimer
|
||||
}, [isDesktop, autoplay, applyDemoStep, scheduleNextStep, clearDemoTimer])
|
||||
|
||||
const stopAutoCycle = useCallback(() => {
|
||||
autoCycleActiveRef.current = false
|
||||
clearDemoTimer()
|
||||
}, [clearDemoTimer])
|
||||
|
||||
const handleSelectWorkflow = useCallback(
|
||||
(id: string) => {
|
||||
stopAutoCycle()
|
||||
setAutoTypeHome(false)
|
||||
setActiveWorkflowId(id)
|
||||
setActiveView('workflow')
|
||||
setAnimationKey((k) => k + 1)
|
||||
},
|
||||
[stopAutoCycle]
|
||||
)
|
||||
|
||||
const handleSelectHome = useCallback(() => {
|
||||
stopAutoCycle()
|
||||
setAutoTypeHome(false)
|
||||
setActiveView('home')
|
||||
}, [stopAutoCycle])
|
||||
|
||||
const handleSelectNav = useCallback(
|
||||
(id: SidebarView) => {
|
||||
stopAutoCycle()
|
||||
setAutoTypeHome(false)
|
||||
setActiveView(id)
|
||||
},
|
||||
[stopAutoCycle]
|
||||
)
|
||||
|
||||
const activeWorkflow =
|
||||
PREVIEW_WORKFLOWS.find((w) => w.id === activeWorkflowId) ?? PREVIEW_WORKFLOWS[0]
|
||||
|
||||
const isWorkflowView = activeView === 'workflow'
|
||||
const isHomeView = activeView === 'home'
|
||||
const chatName = isWorkflowView ? activeWorkflow.name : (CHAT_TITLES[activeView] ?? 'New chat')
|
||||
|
||||
/** Desktop demo motion only runs when autoplaying; otherwise a static snapshot. */
|
||||
const animated = isDesktop && autoplay
|
||||
|
||||
return (
|
||||
<LazyMotion features={domAnimation}>
|
||||
<m.div
|
||||
className='flex aspect-[1116/615] w-full overflow-hidden rounded bg-[var(--surface-1)] antialiased'
|
||||
initial={animated ? 'hidden' : false}
|
||||
animate='visible'
|
||||
variants={containerVariants}
|
||||
>
|
||||
<m.div className='hidden lg:flex' variants={sidebarVariants}>
|
||||
<LandingPreviewSidebar
|
||||
workflows={PREVIEW_WORKFLOWS}
|
||||
activeWorkflowId={activeWorkflowId}
|
||||
activeView={activeView}
|
||||
onSelectWorkflow={handleSelectWorkflow}
|
||||
onSelectHome={handleSelectHome}
|
||||
onSelectNav={handleSelectNav}
|
||||
/>
|
||||
</m.div>
|
||||
<div className='flex min-w-0 flex-1 flex-col py-2 pr-2 pl-2 lg:pl-0'>
|
||||
<div className='flex flex-1 overflow-hidden rounded-[5px] border border-[var(--border-1)] bg-[var(--surface-2)]'>
|
||||
{isHomeView ? (
|
||||
/* Home: the chat IS the whole view - its empty "What should we get
|
||||
done?" state, with no resource staged. */
|
||||
<div className='relative flex min-w-0 flex-1 flex-col overflow-hidden'>
|
||||
{animated ? (
|
||||
<AnimatePresence mode='wait'>
|
||||
<m.div
|
||||
key={`home-${animationKey}`}
|
||||
className='flex h-full w-full flex-col'
|
||||
{...viewTransition}
|
||||
>
|
||||
<LandingPreviewHome autoType={autoTypeHome} />
|
||||
</m.div>
|
||||
</AnimatePresence>
|
||||
) : (
|
||||
<LandingPreviewHome autoType={autoTypeHome} />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* Chat everywhere: the persistent Mothership chat pane on the left,
|
||||
a single staged resource on the right. The chat pane stays
|
||||
mounted as the staged resource crossfades - the chat is the
|
||||
constant the work hangs off of. */
|
||||
<>
|
||||
<m.div className='hidden lg:flex' variants={sidebarVariants}>
|
||||
<LandingPreviewChat
|
||||
chat={isDesktop ? getViewChat(activeView, activeWorkflow) : null}
|
||||
chatName={chatName}
|
||||
animationKey={animationKey}
|
||||
/>
|
||||
</m.div>
|
||||
<div className='relative flex min-w-0 flex-1 flex-col overflow-hidden border-[var(--border-1)] border-l'>
|
||||
{isWorkflowView && <LandingPreviewStageHeader name={activeWorkflow.name} />}
|
||||
<div className='relative min-h-0 flex-1 overflow-hidden'>
|
||||
{animated ? (
|
||||
<AnimatePresence mode='wait'>
|
||||
{activeView === 'workflow' && (
|
||||
<m.div
|
||||
key={`wf-${activeWorkflow.id}-${animationKey}`}
|
||||
className='h-full w-full'
|
||||
{...viewTransition}
|
||||
>
|
||||
<LandingPreviewWorkflow workflow={activeWorkflow} animate />
|
||||
</m.div>
|
||||
)}
|
||||
{activeView === 'tables' && (
|
||||
<m.div
|
||||
key={`tables-${animationKey}`}
|
||||
className='flex h-full w-full flex-col'
|
||||
{...viewTransition}
|
||||
>
|
||||
<LandingPreviewTables />
|
||||
</m.div>
|
||||
)}
|
||||
{activeView === 'files' && (
|
||||
<m.div
|
||||
key='files'
|
||||
className='flex h-full w-full flex-col'
|
||||
{...viewTransition}
|
||||
>
|
||||
<LandingPreviewFiles />
|
||||
</m.div>
|
||||
)}
|
||||
{activeView === 'knowledge' && (
|
||||
<m.div
|
||||
key='knowledge'
|
||||
className='flex h-full w-full flex-col'
|
||||
{...viewTransition}
|
||||
>
|
||||
<LandingPreviewKnowledge />
|
||||
</m.div>
|
||||
)}
|
||||
{activeView === 'logs' && (
|
||||
<m.div key='logs' className='flex h-full w-full flex-col' initial={false}>
|
||||
<LandingPreviewLogs />
|
||||
</m.div>
|
||||
)}
|
||||
{activeView === 'scheduled-tasks' && (
|
||||
<m.div
|
||||
key='scheduled-tasks'
|
||||
className='flex h-full w-full flex-col'
|
||||
{...viewTransition}
|
||||
>
|
||||
<LandingPreviewScheduledTasks />
|
||||
</m.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
) : activeView === 'tables' ? (
|
||||
<LandingPreviewTables />
|
||||
) : activeView === 'files' ? (
|
||||
<LandingPreviewFiles />
|
||||
) : activeView === 'knowledge' ? (
|
||||
<LandingPreviewKnowledge />
|
||||
) : activeView === 'logs' ? (
|
||||
<LandingPreviewLogs />
|
||||
) : activeView === 'scheduled-tasks' ? (
|
||||
<LandingPreviewScheduledTasks />
|
||||
) : (
|
||||
<LandingPreviewWorkflow workflow={activeWorkflow} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</m.div>
|
||||
</LazyMotion>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { LandingShell } from './landing-shell'
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { getGitHubStars } from '@/lib/github/stars'
|
||||
import { Footer } from '@/app/(landing)/components/footer/footer'
|
||||
import { Navbar } from '@/app/(landing)/components/navbar/navbar'
|
||||
import { SiteStructuredData } from '@/app/(landing)/components/site-structured-data'
|
||||
|
||||
/**
|
||||
* The shared chrome every landing-family page wears - the home page, every
|
||||
* platform page (Workflows, Tables, Files, …), and every solutions page (IT,
|
||||
* Engineering, …). It is the single source of truth for the page frame, so each
|
||||
* page is just `<LandingShell>{content}</LandingShell>` and can never drift.
|
||||
*
|
||||
* It owns:
|
||||
* - The `light` wrapper, which pins every `var(--*)` design token to its
|
||||
* light-mode value regardless of the visitor's theme - the landing family is
|
||||
* always light, and uses the platform's own light-mode tokens (from
|
||||
* `globals.css`) with no separate palette.
|
||||
* - The page's scroll port (`h-screen` + `overflow-y-auto` +
|
||||
* `overscroll-y-none`): the document body no longer overflows, so the viewport
|
||||
* can't rubber-band, and the container's own overscroll bounce is disabled -
|
||||
* without this the sticky navbar gets dragged past the top/bottom edges.
|
||||
* - The skip link (targets `#main-content`), the {@link Navbar} (GitHub stars
|
||||
* fetched here at build/revalidate time - never client-fetched), and the
|
||||
* {@link Footer}.
|
||||
*
|
||||
* The page supplies only the `<main id='main-content'>` content between the
|
||||
* navbar and footer. Async Server Component; pages render it with zero props.
|
||||
*/
|
||||
|
||||
interface LandingShellProps {
|
||||
/** The page's `<main id='main-content'>` region - the only content the shell wraps. */
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export async function LandingShell({ children }: LandingShellProps) {
|
||||
const stars = await getGitHubStars()
|
||||
|
||||
return (
|
||||
<div className='light h-screen overflow-y-auto overscroll-y-none bg-[var(--bg)] text-[var(--text-primary)]'>
|
||||
<SiteStructuredData />
|
||||
<a
|
||||
href='#main-content'
|
||||
className='sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-[var(--z-toast)] focus:rounded-md focus:bg-[var(--surface-2)] focus:px-4 focus:py-2 focus:text-[var(--text-primary)] focus:text-sm'
|
||||
>
|
||||
Skip to main content
|
||||
</a>
|
||||
<Navbar stars={stars} />
|
||||
{children}
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { BuildIcon, DeployIcon, MonitorIcon } from './lifecycle-icons'
|
||||
@@ -0,0 +1 @@
|
||||
export { BuildIcon, DeployIcon, MonitorIcon } from './lifecycle-icons'
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
import type { SVGProps } from 'react'
|
||||
|
||||
/**
|
||||
* Isometric line-art icons for the lifecycle axes - Build, Deploy, Monitor.
|
||||
*
|
||||
* Each icon is built from one primitive: an {@link IsoBox} described in screen
|
||||
* space (a top rhombus of half-width `w` extruded down by height `h`). Because a
|
||||
* box is centered on `cx` by construction, symmetric arrangements are trivial,
|
||||
* and every visible face is filled with `var(--bg)` so a nearer box cleanly
|
||||
* occludes the edges of one behind it - the wireframe technique that keeps the
|
||||
* forms readable without shading. Draw boxes far → near.
|
||||
*
|
||||
* All three share the 132×120 viewBox and inherit color from `currentColor`, so
|
||||
* the section sets one tone (`--text-muted`) and the strokes follow. Purely
|
||||
* decorative - the parent marks each `aria-hidden`.
|
||||
*/
|
||||
|
||||
const VIEW = { w: 132, h: 120 } as const
|
||||
|
||||
interface IsoBoxSpec {
|
||||
/** Horizontal center of the box (screen px). */
|
||||
cx: number
|
||||
/** Vertical center of the top rhombus (screen px). */
|
||||
topY: number
|
||||
/** Half-width of the top rhombus; its half-height is `w / 2` (isometric). */
|
||||
w: number
|
||||
/** Vertical extrusion height. */
|
||||
h: number
|
||||
}
|
||||
|
||||
/** The three visible faces of an isometric box as SVG path `d` strings. */
|
||||
function isoFaces({ cx, topY, w, h }: IsoBoxSpec) {
|
||||
const rh = w / 2
|
||||
const back = `${cx},${topY - rh}`
|
||||
const right = `${cx + w},${topY}`
|
||||
const front = `${cx},${topY + rh}`
|
||||
const left = `${cx - w},${topY}`
|
||||
const leftB = `${cx - w},${topY + h}`
|
||||
const frontB = `${cx},${topY + rh + h}`
|
||||
const rightB = `${cx + w},${topY + h}`
|
||||
return {
|
||||
top: `M${back} L${right} L${front} L${left} Z`,
|
||||
left: `M${left} L${front} L${frontB} L${leftB} Z`,
|
||||
right: `M${right} L${front} L${frontB} L${rightB} Z`,
|
||||
}
|
||||
}
|
||||
|
||||
function IsoBox(spec: IsoBoxSpec) {
|
||||
const { top, left, right } = isoFaces(spec)
|
||||
return (
|
||||
<>
|
||||
<path d={left} />
|
||||
<path d={right} />
|
||||
<path d={top} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type IconProps = SVGProps<SVGSVGElement>
|
||||
|
||||
function IconFrame({ children, ...props }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
width={VIEW.w}
|
||||
height={VIEW.h}
|
||||
viewBox={`0 0 ${VIEW.w} ${VIEW.h}`}
|
||||
fill='none'
|
||||
aria-hidden='true'
|
||||
{...props}
|
||||
>
|
||||
<g
|
||||
fill='var(--bg)'
|
||||
stroke='currentColor'
|
||||
strokeWidth={1.2}
|
||||
strokeLinejoin='round'
|
||||
strokeLinecap='round'
|
||||
>
|
||||
{children}
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** Build - a block descending onto a stack of layers, as if assembling an agent. */
|
||||
export function BuildIcon(props: IconProps) {
|
||||
return (
|
||||
<IconFrame {...props}>
|
||||
<IsoBox cx={66} topY={78} w={24} h={7} />
|
||||
<IsoBox cx={66} topY={67} w={24} h={7} />
|
||||
<IsoBox cx={66} topY={56} w={24} h={7} />
|
||||
<IsoBox cx={66} topY={36} w={24} h={7} />
|
||||
<path d='M66,31 L75,36 L66,41 L57,36 Z' fill='none' opacity={0.6} />
|
||||
</IconFrame>
|
||||
)
|
||||
}
|
||||
|
||||
/** Deploy - instances shipping out: a tall unit flanked by two smaller cubes. */
|
||||
export function DeployIcon(props: IconProps) {
|
||||
return (
|
||||
<IconFrame {...props}>
|
||||
<IsoBox cx={66} topY={46} w={18} h={28} />
|
||||
<IsoBox cx={44} topY={64} w={14} h={18} />
|
||||
<IsoBox cx={88} topY={64} w={14} h={18} />
|
||||
<circle cx={66} cy={42} r={1.3} fill='currentColor' stroke='none' />
|
||||
<circle cx={44} cy={61} r={1.3} fill='currentColor' stroke='none' />
|
||||
<circle cx={88} cy={61} r={1.3} fill='currentColor' stroke='none' />
|
||||
</IconFrame>
|
||||
)
|
||||
}
|
||||
|
||||
const MONITOR_BARS = [
|
||||
{ x: 44, ground: 86, h: 18 },
|
||||
{ x: 55, ground: 80.5, h: 30 },
|
||||
{ x: 66, ground: 75, h: 22 },
|
||||
{ x: 77, ground: 69.5, h: 38 },
|
||||
{ x: 88, ground: 64, h: 28 },
|
||||
] as const
|
||||
|
||||
/** Monitor - a run of metric bars receding into the distance, like a live chart. */
|
||||
export function MonitorIcon(props: IconProps) {
|
||||
return (
|
||||
<IconFrame {...props}>
|
||||
{/* Far → near so nearer bars occlude the ones behind them. */}
|
||||
{[...MONITOR_BARS].reverse().map((bar) => (
|
||||
<IsoBox key={bar.x} cx={bar.x} topY={bar.ground - bar.h} w={5} h={bar.h} />
|
||||
))}
|
||||
</IconFrame>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { Lifecycle } from './lifecycle'
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { ComponentType, SVGProps } from 'react'
|
||||
import {
|
||||
BuildIcon,
|
||||
DeployIcon,
|
||||
MonitorIcon,
|
||||
} from '@/app/(landing)/components/lifecycle/components/lifecycle-icons'
|
||||
|
||||
/**
|
||||
* Landing lifecycle - the three axes along which teams use Sim: build, deploy,
|
||||
* monitor. A split-statement `<h2>` (headline color + body color, matching the
|
||||
* features header) tops a three-column grid; each column pairs an isometric
|
||||
* line-art icon with an `<h3>` axis title and a one-line description.
|
||||
*
|
||||
* The columns carry a left hairline (`--border`) for the technical, reference-
|
||||
* style rhythm; the icon area is `flex-1` so the three titles bottom-align
|
||||
* regardless of icon height. Icons inherit `--text-muted` via `currentColor`.
|
||||
*
|
||||
* Inter-section spacing is owned by the `<main>` flex `gap` in `landing.tsx`;
|
||||
* this section carries no vertical padding. Horizontal padding (`px-20`) matches
|
||||
* the navbar and hero so the headline starts on the wordmark's line, and the
|
||||
* section is capped and centered at the shared `max-w-[1460px]`. First
|
||||
* section after the hero, above {@link Features}.
|
||||
*/
|
||||
|
||||
interface Axis {
|
||||
title: string
|
||||
description: string
|
||||
Icon: ComponentType<SVGProps<SVGSVGElement>>
|
||||
}
|
||||
|
||||
const AXES: Axis[] = [
|
||||
{
|
||||
title: 'Build',
|
||||
description:
|
||||
'Build agents visually, in natural language, or with code, wiring up any model and 1,000+ integrations.',
|
||||
Icon: BuildIcon,
|
||||
},
|
||||
{
|
||||
title: 'Deploy',
|
||||
description:
|
||||
'Ship agents to production as APIs, Slack bots, or scheduled jobs, live in a click.',
|
||||
Icon: DeployIcon,
|
||||
},
|
||||
{
|
||||
title: 'Monitor',
|
||||
description: 'Trace every run block by block, with full logs of what each agent did and why.',
|
||||
Icon: MonitorIcon,
|
||||
},
|
||||
]
|
||||
|
||||
export function Lifecycle() {
|
||||
return (
|
||||
<section
|
||||
id='lifecycle'
|
||||
aria-labelledby='lifecycle-heading'
|
||||
className='mx-auto w-full max-w-[1460px] px-20'
|
||||
>
|
||||
<h2 id='lifecycle-heading' className='max-w-[1200px] text-balance text-[32px] leading-[1.3]'>
|
||||
<span className='text-[var(--text-primary)]'>From idea to production.</span>{' '}
|
||||
<span className='text-[var(--text-body)]'>
|
||||
Build agents, deploy them, and monitor every run, all in one workspace.
|
||||
</span>
|
||||
</h2>
|
||||
|
||||
<ul className='mt-20 grid grid-cols-3 gap-12'>
|
||||
{AXES.map(({ title, description, Icon }) => (
|
||||
<li
|
||||
key={title}
|
||||
className='flex flex-col border-[var(--border)] border-l pl-8 text-[var(--text-muted)]'
|
||||
>
|
||||
<div className='flex flex-1 items-center justify-center py-10'>
|
||||
<Icon />
|
||||
</div>
|
||||
<h3 className='text-[18px] text-[var(--text-primary)]'>{title}</h3>
|
||||
<p className='mt-2 max-w-[300px] text-[15px] text-[var(--text-body)] leading-[1.5]'>
|
||||
{description}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { Lightbox } from './lightbox'
|
||||
@@ -0,0 +1,65 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useEffectEvent, useRef } from 'react'
|
||||
|
||||
interface LightboxProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
src: string
|
||||
alt: string
|
||||
}
|
||||
|
||||
export function Lightbox({ isOpen, onClose, src, alt }: LightboxProps) {
|
||||
const overlayRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const onCloseEvent = useEffectEvent(onClose)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
onCloseEvent()
|
||||
}
|
||||
}
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (overlayRef.current && event.target === overlayRef.current) {
|
||||
onCloseEvent()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
document.body.style.overflow = 'hidden'
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
document.body.style.overflow = 'unset'
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
className='fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-12 backdrop-blur-sm'
|
||||
role='dialog'
|
||||
aria-modal='true'
|
||||
aria-label='Image viewer'
|
||||
>
|
||||
<div className='relative max-h-full max-w-full overflow-hidden rounded-xl shadow-2xl'>
|
||||
<button type='button' className='block cursor-pointer rounded-xl' onClick={onClose}>
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className='max-h-[75vh] max-w-[75vw] rounded-xl object-contain'
|
||||
loading='lazy'
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user