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:
+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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user