mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
Compare commits
3 Commits
improvemen
...
v0.6.25
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28af223a9f | ||
|
|
a54dcbe949 | ||
|
|
0b9019d9a2 |
@@ -20,7 +20,7 @@ The Response block formats and sends structured HTTP responses back to API calle
|
||||
</div>
|
||||
|
||||
<Callout type="info">
|
||||
Response blocks are exit points — when a Response block executes, it ends the workflow and sends the HTTP response immediately. Multiple Response blocks can be placed on different branches (e.g. after a Router or Condition), but only the first one to execute determines the API response.
|
||||
Response blocks are terminal blocks - they end workflow execution and cannot connect to other blocks.
|
||||
</Callout>
|
||||
|
||||
## Configuration Options
|
||||
@@ -77,11 +77,7 @@ Condition (Error Detected) → Router → Response (400/500, Error Details)
|
||||
|
||||
## Outputs
|
||||
|
||||
Response blocks are exit points — when one executes, no further blocks run. The block defines outputs (`data`, `status`, `headers`) which are used to construct the HTTP response sent back to the API caller.
|
||||
|
||||
<Callout type="warning">
|
||||
If a Response block is placed on a parallel branch, there are no guarantees about whether other parallel blocks will run or not. Execution order across parallel branches is non-deterministic, so a parallel block may execute before or after the Response block on any given run. Avoid placing Response blocks in parallel with blocks that have important side effects.
|
||||
</Callout>
|
||||
Response blocks are terminal — no downstream blocks execute after them. However, the block does define outputs (`data`, `status`, `headers`) which are used to construct the HTTP response sent back to the API caller.
|
||||
|
||||
## Variable References
|
||||
|
||||
@@ -114,10 +110,10 @@ Use the `<variable.name>` syntax to dynamically insert workflow variables into y
|
||||
- **Validate variable references**: Ensure all referenced variables exist and contain the expected data types before the Response block executes
|
||||
|
||||
<FAQ items={[
|
||||
{ question: "Can I have multiple Response blocks in a workflow?", answer: "Yes. You can place multiple Response blocks on different branches (e.g. after a Router or Condition block). The first Response block to execute determines the API response and ends the workflow. This is useful for returning different responses based on conditions — for example, a 200 on the success branch and a 500 on the error branch." },
|
||||
{ question: "Can I have multiple Response blocks in a workflow?", answer: "No. The Response block is a single-instance block — only one is allowed per workflow. If you need different responses for different conditions, use a Condition or Router block upstream to determine what data reaches the single Response block." },
|
||||
{ question: "What triggers require a Response block?", answer: "The Response block is designed for use with the API Trigger. When your workflow is invoked via the API, the Response block sends the structured HTTP response back to the caller. Other trigger types (like webhooks or schedules) do not require a Response block." },
|
||||
{ question: "What is the difference between Builder and Editor mode?", answer: "Builder mode provides a visual interface for constructing your response structure with fields and types. Editor mode gives you a raw JSON code editor where you can write the response body directly. Builder mode is recommended for most use cases." },
|
||||
{ question: "What is the default status code?", answer: "If you do not specify a status code, the Response block defaults to 200 (OK). You can set any valid HTTP status code including error codes like 400, 404, or 500." },
|
||||
{ question: "Can the Response block connect to downstream blocks?", answer: "No. Response blocks are exit points — they end workflow execution and send the HTTP response. No further blocks can execute after a Response block." },
|
||||
{ question: "Can the Response block connect to downstream blocks?", answer: "No. Response blocks are terminal — they end workflow execution and send the HTTP response. No further blocks can be connected after a Response block." },
|
||||
]} />
|
||||
|
||||
|
||||
@@ -96,9 +96,8 @@ Understanding these core principles will help you build better workflows:
|
||||
2. **Automatic Parallelization**: Independent blocks run concurrently without configuration
|
||||
3. **Smart Data Flow**: Outputs flow automatically to connected blocks
|
||||
4. **Error Handling**: Failed blocks stop their execution path but don't affect independent paths
|
||||
5. **Response Blocks as Exit Points**: When a Response block executes, the entire workflow stops and the API response is sent immediately. Multiple Response blocks can exist on different branches — the first one to execute wins
|
||||
6. **State Persistence**: All block outputs and execution details are preserved for debugging
|
||||
7. **Cycle Protection**: Workflows that call other workflows (via Workflow blocks, MCP tools, or API blocks) are tracked with a call chain. If the chain exceeds 25 hops, execution is stopped to prevent infinite loops
|
||||
5. **State Persistence**: All block outputs and execution details are preserved for debugging
|
||||
6. **Cycle Protection**: Workflows that call other workflows (via Workflow blocks, MCP tools, or API blocks) are tracked with a call chain. If the chain exceeds 25 hops, execution is stopped to prevent infinite loops
|
||||
|
||||
## Next Steps
|
||||
|
||||
|
||||
@@ -45,7 +45,6 @@ List all cloud agents for the authenticated user with optional pagination. Retur
|
||||
| `apiKey` | string | Yes | Cursor API key |
|
||||
| `limit` | number | No | Number of agents to return \(default: 20, max: 100\) |
|
||||
| `cursor` | string | No | Pagination cursor from previous response |
|
||||
| `prUrl` | string | No | Filter agents by pull request URL |
|
||||
|
||||
#### Output
|
||||
|
||||
@@ -174,41 +173,4 @@ Permanently delete a cloud agent. Returns API-aligned fields only.
|
||||
| --------- | ---- | ----------- |
|
||||
| `id` | string | Agent ID |
|
||||
|
||||
### `cursor_list_artifacts`
|
||||
|
||||
List generated artifact files for a cloud agent. Returns API-aligned fields only.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Cursor API key |
|
||||
| `agentId` | string | Yes | Unique identifier for the cloud agent \(e.g., bc_abc123\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `artifacts` | array | List of artifact files |
|
||||
| ↳ `path` | string | Artifact file path |
|
||||
| ↳ `size` | number | File size in bytes |
|
||||
|
||||
### `cursor_download_artifact`
|
||||
|
||||
Download a generated artifact file from a cloud agent. Returns the file for execution storage.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Cursor API key |
|
||||
| `agentId` | string | Yes | Unique identifier for the cloud agent \(e.g., bc_abc123\) |
|
||||
| `path` | string | Yes | Absolute path of the artifact to download \(e.g., /src/index.ts\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `file` | file | Downloaded artifact file stored in execution files |
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import AuthBackground from '@/app/(auth)/components/auth-background'
|
||||
import Navbar from '@/app/(landing)/components/navbar/navbar'
|
||||
import Navbar from '@/app/(home)/components/navbar/navbar'
|
||||
|
||||
export default function AuthLayoutClient({ children }: { children: React.ReactNode }) {
|
||||
useEffect(() => {
|
||||
|
||||
@@ -81,7 +81,7 @@ export function SocialLoginButtons({
|
||||
const githubButton = (
|
||||
<Button
|
||||
variant='outline'
|
||||
className='w-full rounded-sm border-[var(--landing-border-strong)] py-1.5 text-sm'
|
||||
className='w-full rounded-[10px]'
|
||||
disabled={!githubAvailable || isGithubLoading}
|
||||
onClick={signInWithGithub}
|
||||
>
|
||||
@@ -93,7 +93,7 @@ export function SocialLoginButtons({
|
||||
const googleButton = (
|
||||
<Button
|
||||
variant='outline'
|
||||
className='w-full rounded-sm border-[var(--landing-border-strong)] py-1.5 text-sm'
|
||||
className='w-full rounded-[10px]'
|
||||
disabled={!googleAvailable || isGoogleLoading}
|
||||
onClick={signInWithGoogle}
|
||||
>
|
||||
|
||||
@@ -28,9 +28,7 @@ export function SSOLoginButton({
|
||||
router.push(ssoUrl)
|
||||
}
|
||||
|
||||
const outlineBtnClasses = cn(
|
||||
'w-full rounded-sm border-[var(--landing-border-strong)] py-1.5 text-sm'
|
||||
)
|
||||
const outlineBtnClasses = cn('w-full rounded-[10px]')
|
||||
|
||||
return (
|
||||
<Button
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import AuthBackground from '@/app/(auth)/components/auth-background'
|
||||
import Navbar from '@/app/(landing)/components/navbar/navbar'
|
||||
import Navbar from '@/app/(home)/components/navbar/navbar'
|
||||
import { SupportFooter } from './support-footer'
|
||||
|
||||
export interface StatusPageLayoutProps {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { Badge } from '@/components/emcn'
|
||||
import { Badge, ChevronDown } from '@/components/emcn'
|
||||
|
||||
interface DotGridProps {
|
||||
className?: string
|
||||
@@ -230,7 +230,7 @@ export default function Collaboration() {
|
||||
|
||||
<div className='relative overflow-hidden'>
|
||||
<div className='grid grid-cols-1 md:grid-cols-[auto_1fr]'>
|
||||
<div className='flex flex-col items-start gap-3 px-4 pt-[60px] pb-8 sm:gap-4 sm:px-8 md:gap-5 md:px-16 md:pt-[100px]'>
|
||||
<div className='flex flex-col items-start gap-3 px-4 pt-[60px] pb-8 sm:gap-4 sm:px-8 md:gap-5 md:px-20 md:pt-[100px]'>
|
||||
<Badge
|
||||
variant='blue'
|
||||
size='md'
|
||||
@@ -249,13 +249,6 @@ export default function Collaboration() {
|
||||
collaboration
|
||||
</h2>
|
||||
|
||||
<p className='sr-only'>
|
||||
Sim supports real-time multiplayer collaboration. Teams can build AI agents together
|
||||
in a shared workspace with live cursors, presence indicators, and concurrent editing.
|
||||
Features include role-based access control, shared workflows, and team workspace
|
||||
management.
|
||||
</p>
|
||||
|
||||
<p className='font-[430] font-season text-[#F6F6F0]/50 text-base leading-[150%] tracking-[0.02em] md:text-lg'>
|
||||
Grab your team. Build agents together <br className='hidden md:block' />
|
||||
in real-time inside your workspace.
|
||||
@@ -266,32 +259,24 @@ export default function Collaboration() {
|
||||
className='group/cta mt-3 inline-flex h-[32px] cursor-none items-center gap-1.5 rounded-[5px] border border-white bg-white px-2.5 font-[430] font-season text-black text-sm transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]'
|
||||
>
|
||||
Build together
|
||||
<svg
|
||||
className='h-[10px] w-[10px] shrink-0'
|
||||
viewBox='0 0 10 10'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<line
|
||||
x1='0'
|
||||
y1='5'
|
||||
x2='9'
|
||||
y2='5'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.33'
|
||||
strokeLinecap='square'
|
||||
className='origin-left scale-x-0 transition-transform duration-200 ease-out [transform-box:fill-box] group-hover/cta:scale-x-100'
|
||||
/>
|
||||
<path
|
||||
d='M3.5 2L6.5 5L3.5 8'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.33'
|
||||
strokeLinecap='square'
|
||||
strokeLinejoin='miter'
|
||||
<span className='relative h-[10px] w-[10px] shrink-0'>
|
||||
<ChevronDown className='-rotate-90 absolute inset-0 h-[10px] w-[10px] transition-opacity duration-150 group-hover/cta:opacity-0' />
|
||||
<svg
|
||||
className='absolute inset-0 h-[10px] w-[10px] opacity-0 transition-opacity duration-150 group-hover/cta:opacity-100'
|
||||
viewBox='0 0 10 10'
|
||||
fill='none'
|
||||
className='transition-transform duration-200 ease-out group-hover/cta:translate-x-[30%]'
|
||||
/>
|
||||
</svg>
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<path
|
||||
d='M1 5H8M5.5 2L8.5 5L5.5 8'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.33'
|
||||
strokeLinecap='square'
|
||||
strokeLinejoin='miter'
|
||||
fill='none'
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -320,7 +305,7 @@ export default function Collaboration() {
|
||||
href='/blog/multiplayer'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='relative mx-4 mb-6 flex cursor-none items-center gap-3.5 rounded-[5px] border border-[var(--landing-bg-elevated)] bg-[var(--landing-bg)] px-3 py-2.5 transition-colors hover:border-[var(--landing-border-strong)] hover:bg-[var(--landing-bg-card)] sm:mx-8 md:absolute md:bottom-10 md:left-16 md:z-20 md:mx-0 md:mb-0'
|
||||
className='relative mx-4 mb-6 flex cursor-none items-center gap-3.5 rounded-[5px] border border-[var(--landing-bg-elevated)] bg-[var(--landing-bg)] px-3 py-2.5 transition-colors hover:border-[var(--landing-border-strong)] hover:bg-[var(--landing-bg-card)] sm:mx-8 md:absolute md:bottom-10 md:left-20 md:z-20 md:mx-0 md:mb-0'
|
||||
>
|
||||
<div className='relative h-7 w-11 shrink-0'>
|
||||
<Image src='/landing/multiplayer-cursors.svg' alt='' fill className='object-contain' />
|
||||
@@ -5,6 +5,15 @@ import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
||||
|
||||
const FREE_EMAIL_DOMAINS = new Set(freeEmailDomains)
|
||||
|
||||
export const DEMO_REQUEST_REGION_VALUES = [
|
||||
'north_america',
|
||||
'europe',
|
||||
'asia_pacific',
|
||||
'latin_america',
|
||||
'middle_east_africa',
|
||||
'other',
|
||||
] as const
|
||||
|
||||
export const DEMO_REQUEST_COMPANY_SIZE_VALUES = [
|
||||
'1_10',
|
||||
'11_50',
|
||||
@@ -15,6 +24,15 @@ export const DEMO_REQUEST_COMPANY_SIZE_VALUES = [
|
||||
'10000_plus',
|
||||
] as const
|
||||
|
||||
export const DEMO_REQUEST_REGION_OPTIONS = [
|
||||
{ value: 'north_america', label: 'North America' },
|
||||
{ value: 'europe', label: 'Europe' },
|
||||
{ value: 'asia_pacific', label: 'Asia Pacific' },
|
||||
{ value: 'latin_america', label: 'Latin America' },
|
||||
{ value: 'middle_east_africa', label: 'Middle East & Africa' },
|
||||
{ value: 'other', label: 'Other' },
|
||||
] as const
|
||||
|
||||
export const DEMO_REQUEST_COMPANY_SIZE_OPTIONS = [
|
||||
{ value: '1_10', label: '1–10' },
|
||||
{ value: '11_50', label: '11–50' },
|
||||
@@ -55,6 +73,9 @@ export const demoRequestSchema = z.object({
|
||||
.max(50, 'Phone number must be 50 characters or less')
|
||||
.optional()
|
||||
.transform((value) => (value && value.length > 0 ? value : undefined)),
|
||||
region: z.enum(DEMO_REQUEST_REGION_VALUES, {
|
||||
errorMap: () => ({ message: 'Please select a region' }),
|
||||
}),
|
||||
companySize: z.enum(DEMO_REQUEST_COMPANY_SIZE_VALUES, {
|
||||
errorMap: () => ({ message: 'Please select company size' }),
|
||||
}),
|
||||
@@ -63,6 +84,10 @@ export const demoRequestSchema = z.object({
|
||||
|
||||
export type DemoRequestPayload = z.infer<typeof demoRequestSchema>
|
||||
|
||||
export function getDemoRequestRegionLabel(value: DemoRequestPayload['region']): string {
|
||||
return DEMO_REQUEST_REGION_OPTIONS.find((option) => option.value === value)?.label ?? value
|
||||
}
|
||||
|
||||
export function getDemoRequestCompanySizeLabel(value: DemoRequestPayload['companySize']): string {
|
||||
return DEMO_REQUEST_COMPANY_SIZE_OPTIONS.find((option) => option.value === value)?.label ?? value
|
||||
}
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
Combobox,
|
||||
FormField,
|
||||
Input,
|
||||
Modal,
|
||||
ModalBody,
|
||||
@@ -15,9 +17,10 @@ import {
|
||||
import { Check } from '@/components/emcn/icons'
|
||||
import {
|
||||
DEMO_REQUEST_COMPANY_SIZE_OPTIONS,
|
||||
DEMO_REQUEST_REGION_OPTIONS,
|
||||
type DemoRequestPayload,
|
||||
demoRequestSchema,
|
||||
} from '@/app/(landing)/components/demo-request/consts'
|
||||
} from '@/app/(home)/components/demo-request/consts'
|
||||
|
||||
interface DemoRequestModalProps {
|
||||
children: React.ReactNode
|
||||
@@ -32,11 +35,13 @@ interface DemoRequestFormState {
|
||||
lastName: string
|
||||
companyEmail: string
|
||||
phoneNumber: string
|
||||
region: DemoRequestPayload['region'] | ''
|
||||
companySize: DemoRequestPayload['companySize'] | ''
|
||||
details: string
|
||||
}
|
||||
|
||||
const SUBMIT_SUCCESS_MESSAGE = "We'll be in touch soon!"
|
||||
const COMBOBOX_REGIONS = [...DEMO_REQUEST_REGION_OPTIONS]
|
||||
const COMBOBOX_COMPANY_SIZES = [...DEMO_REQUEST_COMPANY_SIZE_OPTIONS]
|
||||
|
||||
const INITIAL_FORM_STATE: DemoRequestFormState = {
|
||||
@@ -44,37 +49,11 @@ const INITIAL_FORM_STATE: DemoRequestFormState = {
|
||||
lastName: '',
|
||||
companyEmail: '',
|
||||
phoneNumber: '',
|
||||
region: '',
|
||||
companySize: '',
|
||||
details: '',
|
||||
}
|
||||
|
||||
interface LandingFieldProps {
|
||||
label: string
|
||||
htmlFor: string
|
||||
optional?: boolean
|
||||
error?: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
function LandingField({ label, htmlFor, optional, error, children }: LandingFieldProps) {
|
||||
return (
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
<label
|
||||
htmlFor={htmlFor}
|
||||
className='font-[430] font-season text-[13px] text-[var(--text-secondary)] tracking-[0.02em]'
|
||||
>
|
||||
{label}
|
||||
{optional ? <span className='ml-1 text-[var(--text-muted)]'>(optional)</span> : null}
|
||||
</label>
|
||||
{children}
|
||||
{error ? <p className='text-[12px] text-[var(--text-error)]'>{error}</p> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const LANDING_INPUT =
|
||||
'h-[32px] rounded-[5px] border border-[var(--border-1)] bg-[var(--surface-5)] px-2.5 font-[430] font-season text-[13.5px] text-[var(--text-primary)] transition-colors placeholder:text-[var(--text-muted)] outline-none'
|
||||
|
||||
export function DemoRequestModal({ children, theme = 'dark' }: DemoRequestModalProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [form, setForm] = useState<DemoRequestFormState>(INITIAL_FORM_STATE)
|
||||
@@ -138,6 +117,7 @@ export function DemoRequestModal({ children, theme = 'dark' }: DemoRequestModalP
|
||||
lastName: fieldErrors.lastName?.[0],
|
||||
companyEmail: fieldErrors.companyEmail?.[0],
|
||||
phoneNumber: fieldErrors.phoneNumber?.[0],
|
||||
region: fieldErrors.region?.[0],
|
||||
companySize: fieldErrors.companySize?.[0],
|
||||
details: fieldErrors.details?.[0],
|
||||
})
|
||||
@@ -182,9 +162,7 @@ export function DemoRequestModal({ children, theme = 'dark' }: DemoRequestModalP
|
||||
<ModalContent size='lg' className={theme === 'dark' ? 'dark' : undefined}>
|
||||
<ModalHeader>
|
||||
<span className={submitSuccess ? 'sr-only' : undefined}>
|
||||
<span className='font-[430] font-season text-[15px] tracking-[-0.02em]'>
|
||||
{submitSuccess ? 'Demo request submitted' : 'Talk to sales'}
|
||||
</span>
|
||||
{submitSuccess ? 'Demo request submitted' : 'Nearly there!'}
|
||||
</span>
|
||||
</ModalHeader>
|
||||
<div className='relative flex-1'>
|
||||
@@ -198,44 +176,37 @@ export function DemoRequestModal({ children, theme = 'dark' }: DemoRequestModalP
|
||||
}
|
||||
>
|
||||
<ModalBody>
|
||||
<div className='space-y-3'>
|
||||
<div className='grid gap-3 sm:grid-cols-2'>
|
||||
<LandingField htmlFor='firstName' label='First name' error={errors.firstName}>
|
||||
<div className='space-y-4'>
|
||||
<div className='grid gap-4 sm:grid-cols-2'>
|
||||
<FormField htmlFor='firstName' label='First name' error={errors.firstName}>
|
||||
<Input
|
||||
id='firstName'
|
||||
value={form.firstName}
|
||||
onChange={(event) => updateField('firstName', event.target.value)}
|
||||
placeholder='First'
|
||||
className={LANDING_INPUT}
|
||||
/>
|
||||
</LandingField>
|
||||
<LandingField htmlFor='lastName' label='Last name' error={errors.lastName}>
|
||||
</FormField>
|
||||
<FormField htmlFor='lastName' label='Last name' error={errors.lastName}>
|
||||
<Input
|
||||
id='lastName'
|
||||
value={form.lastName}
|
||||
onChange={(event) => updateField('lastName', event.target.value)}
|
||||
placeholder='Last'
|
||||
className={LANDING_INPUT}
|
||||
/>
|
||||
</LandingField>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<LandingField
|
||||
htmlFor='companyEmail'
|
||||
label='Company email'
|
||||
error={errors.companyEmail}
|
||||
>
|
||||
<FormField htmlFor='companyEmail' label='Company email' error={errors.companyEmail}>
|
||||
<Input
|
||||
id='companyEmail'
|
||||
type='email'
|
||||
value={form.companyEmail}
|
||||
onChange={(event) => updateField('companyEmail', event.target.value)}
|
||||
placeholder='Your work email'
|
||||
className={LANDING_INPUT}
|
||||
/>
|
||||
</LandingField>
|
||||
</FormField>
|
||||
|
||||
<LandingField
|
||||
<FormField
|
||||
htmlFor='phoneNumber'
|
||||
label='Phone number'
|
||||
optional
|
||||
@@ -247,48 +218,54 @@ export function DemoRequestModal({ children, theme = 'dark' }: DemoRequestModalP
|
||||
value={form.phoneNumber}
|
||||
onChange={(event) => updateField('phoneNumber', event.target.value)}
|
||||
placeholder='Your phone number'
|
||||
className={LANDING_INPUT}
|
||||
/>
|
||||
</LandingField>
|
||||
</FormField>
|
||||
|
||||
<LandingField htmlFor='companySize' label='Company size' error={errors.companySize}>
|
||||
<Combobox
|
||||
options={COMBOBOX_COMPANY_SIZES}
|
||||
value={form.companySize}
|
||||
selectedValue={form.companySize}
|
||||
onChange={(value) =>
|
||||
updateField('companySize', value as DemoRequestPayload['companySize'])
|
||||
}
|
||||
placeholder='Select'
|
||||
editable={false}
|
||||
filterOptions={false}
|
||||
className='h-[32px] rounded-[5px] px-2.5 font-[430] font-season text-[13.5px]'
|
||||
/>
|
||||
</LandingField>
|
||||
<div className='grid gap-4 sm:grid-cols-2'>
|
||||
<FormField htmlFor='region' label='Region' error={errors.region}>
|
||||
<Combobox
|
||||
options={COMBOBOX_REGIONS}
|
||||
value={form.region}
|
||||
selectedValue={form.region}
|
||||
onChange={(value) =>
|
||||
updateField('region', value as DemoRequestPayload['region'])
|
||||
}
|
||||
placeholder='Select'
|
||||
editable={false}
|
||||
filterOptions={false}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField htmlFor='companySize' label='Company size' error={errors.companySize}>
|
||||
<Combobox
|
||||
options={COMBOBOX_COMPANY_SIZES}
|
||||
value={form.companySize}
|
||||
selectedValue={form.companySize}
|
||||
onChange={(value) =>
|
||||
updateField('companySize', value as DemoRequestPayload['companySize'])
|
||||
}
|
||||
placeholder='Select'
|
||||
editable={false}
|
||||
filterOptions={false}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<LandingField htmlFor='details' label='Details' error={errors.details}>
|
||||
<FormField htmlFor='details' label='Details' error={errors.details}>
|
||||
<Textarea
|
||||
id='details'
|
||||
value={form.details}
|
||||
onChange={(event) => updateField('details', event.target.value)}
|
||||
placeholder='Tell us about your needs and questions'
|
||||
className='min-h-[80px] rounded-[5px] border border-[var(--border-1)] bg-[var(--surface-5)] px-2.5 py-2 font-[430] font-season text-[13.5px] text-[var(--text-primary)] outline-none transition-colors placeholder:text-[var(--text-muted)]'
|
||||
/>
|
||||
</LandingField>
|
||||
</FormField>
|
||||
</div>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter className='flex-col items-stretch gap-3 border-t-0 bg-transparent pt-0'>
|
||||
{submitError && (
|
||||
<p className='font-season text-[13px] text-[var(--text-error)]'>{submitError}</p>
|
||||
)}
|
||||
<button
|
||||
type='submit'
|
||||
disabled={isSubmitting}
|
||||
className='flex h-[32px] w-full items-center justify-center rounded-[5px] bg-[var(--text-primary)] font-[430] font-season text-[13.5px] text-[var(--bg)] transition-colors hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50'
|
||||
>
|
||||
<ModalFooter className='flex-col items-stretch gap-3'>
|
||||
{submitError && <p className='text-[13px] text-[var(--text-error)]'>{submitError}</p>}
|
||||
<Button type='submit' variant='primary' disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Submitting...' : 'Submit'}
|
||||
</button>
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
|
||||
@@ -298,10 +275,10 @@ export function DemoRequestModal({ children, theme = 'dark' }: DemoRequestModalP
|
||||
<div className='flex h-20 w-20 items-center justify-center rounded-full border border-[var(--border)] bg-[var(--bg-subtle)] text-[var(--text-primary)]'>
|
||||
<Check className='h-10 w-10' />
|
||||
</div>
|
||||
<h2 className='mt-8 font-[430] font-season text-[34px] text-[var(--text-primary)] leading-[1.1] tracking-[-0.03em]'>
|
||||
<h2 className='mt-8 font-medium text-[34px] text-[var(--text-primary)] leading-[1.1] tracking-[-0.03em]'>
|
||||
{SUBMIT_SUCCESS_MESSAGE}
|
||||
</h2>
|
||||
<p className='mt-4 font-season text-[15px] text-[var(--text-secondary)] leading-7'>
|
||||
<p className='mt-4 text-[17px] text-[var(--text-secondary)] leading-7'>
|
||||
Our team will be in touch soon. If you have any questions, please email us at{' '}
|
||||
<a
|
||||
href='mailto:enterprise@sim.ai'
|
||||
@@ -15,12 +15,12 @@
|
||||
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { Badge } from '@/components/emcn'
|
||||
import { Badge, ChevronDown } from '@/components/emcn'
|
||||
import { Lock } from '@/components/emcn/icons'
|
||||
import { GithubIcon } from '@/components/icons'
|
||||
import { DemoRequestModal } from '@/app/(landing)/components/demo-request/demo-request-modal'
|
||||
import { AccessControlPanel } from '@/app/(landing)/components/enterprise/components/access-control-panel'
|
||||
import { AuditLogPreview } from '@/app/(landing)/components/enterprise/components/audit-log-preview'
|
||||
import { DemoRequestModal } from '@/app/(home)/components/demo-request/demo-request-modal'
|
||||
import { AccessControlPanel } from '@/app/(home)/components/enterprise/components/access-control-panel'
|
||||
import { AuditLogPreview } from '@/app/(home)/components/enterprise/components/audit-log-preview'
|
||||
|
||||
const ENTERPRISE_FEATURE_MARQUEE_STYLES = `
|
||||
@keyframes enterprise-feature-marquee {
|
||||
@@ -136,7 +136,7 @@ export default function Enterprise() {
|
||||
aria-labelledby='enterprise-heading'
|
||||
className='bg-[var(--landing-bg-section)]'
|
||||
>
|
||||
<div className='px-4 pt-[60px] pb-10 sm:px-8 sm:pt-20 sm:pb-0 md:px-16 md:pt-[100px]'>
|
||||
<div className='px-4 pt-[60px] pb-10 sm:px-8 sm:pt-20 sm:pb-0 md:px-20 md:pt-[100px]'>
|
||||
<div className='flex flex-col items-start gap-3 sm:gap-4 md:gap-5'>
|
||||
<Badge
|
||||
variant='blue'
|
||||
@@ -230,32 +230,23 @@ export default function Enterprise() {
|
||||
className='group/cta inline-flex h-[32px] cursor-pointer items-center gap-1.5 rounded-[5px] border border-white bg-white px-2.5 font-[430] font-season text-[14px] text-black transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]'
|
||||
>
|
||||
Book a demo
|
||||
<svg
|
||||
className='h-[10px] w-[10px] shrink-0'
|
||||
viewBox='0 0 10 10'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<line
|
||||
x1='0'
|
||||
y1='5'
|
||||
x2='9'
|
||||
y2='5'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.33'
|
||||
strokeLinecap='square'
|
||||
className='origin-left scale-x-0 transition-transform duration-200 ease-out [transform-box:fill-box] group-hover/cta:scale-x-100'
|
||||
/>
|
||||
<path
|
||||
d='M3.5 2L6.5 5L3.5 8'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.33'
|
||||
strokeLinecap='square'
|
||||
strokeLinejoin='miter'
|
||||
<span className='relative h-[10px] w-[10px] shrink-0'>
|
||||
<ChevronDown className='-rotate-90 absolute inset-0 h-[10px] w-[10px] transition-opacity duration-150 group-hover/cta:opacity-0' />
|
||||
<svg
|
||||
className='absolute inset-0 h-[10px] w-[10px] opacity-0 transition-opacity duration-150 group-hover/cta:opacity-100'
|
||||
viewBox='0 0 10 10'
|
||||
fill='none'
|
||||
className='transition-transform duration-200 ease-out group-hover/cta:translate-x-[30%]'
|
||||
/>
|
||||
</svg>
|
||||
>
|
||||
<path
|
||||
d='M1 5H8M5.5 2L8.5 5L5.5 8'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.33'
|
||||
strokeLinecap='square'
|
||||
strokeLinejoin='miter'
|
||||
fill='none'
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
</DemoRequestModal>
|
||||
</div>
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
SlackIcon,
|
||||
xAIIcon,
|
||||
} from '@/components/icons'
|
||||
import { CsvIcon, JsonIcon, MarkdownIcon, PdfIcon } from '@/components/icons/document-icons'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
|
||||
interface FeaturesPreviewProps {
|
||||
@@ -24,7 +25,7 @@ interface FeaturesPreviewProps {
|
||||
}
|
||||
|
||||
export function FeaturesPreview({ activeTab }: FeaturesPreviewProps) {
|
||||
const isWorkspaceTab = activeTab <= 3
|
||||
const isWorkspaceTab = activeTab <= 4
|
||||
|
||||
return (
|
||||
<div className='relative h-[350px] w-full md:h-[560px]'>
|
||||
@@ -65,7 +66,7 @@ const CARD_GAP = 8
|
||||
const GRID_STEP = CARD_SIZE + CARD_GAP
|
||||
const GRID_PAD = 8
|
||||
|
||||
type CardVariant = 'prompt' | 'table' | 'workflow' | 'logs' | 'file'
|
||||
type CardVariant = 'prompt' | 'table' | 'workflow' | 'knowledge' | 'logs' | 'file'
|
||||
|
||||
interface CardDef {
|
||||
row: number
|
||||
@@ -79,19 +80,19 @@ const MOTHERSHIP_CARDS: CardDef[] = [
|
||||
{ row: 0, col: 0, variant: 'prompt', label: 'prompt.md' },
|
||||
{ row: 1, col: 0, variant: 'table', label: 'Leads' },
|
||||
{ row: 0, col: 1, variant: 'workflow', label: 'Email Bot', color: '#7C3AED' },
|
||||
{ row: 1, col: 1, variant: 'file', label: 'handbook.md' },
|
||||
{ row: 1, col: 1, variant: 'knowledge', label: 'Company KB' },
|
||||
{ row: 2, col: 0, variant: 'logs', label: 'Run Logs' },
|
||||
{ row: 0, col: 2, variant: 'file', label: 'notes.md' },
|
||||
{ row: 2, col: 1, variant: 'workflow', label: 'Onboarding', color: '#2563EB' },
|
||||
{ row: 1, col: 2, variant: 'table', label: 'Contacts' },
|
||||
{ row: 2, col: 2, variant: 'file', label: 'report.pdf' },
|
||||
{ row: 3, col: 0, variant: 'table', label: 'Tickets' },
|
||||
{ row: 0, col: 3, variant: 'file', label: 'wiki.md' },
|
||||
{ row: 0, col: 3, variant: 'knowledge', label: 'Product Wiki' },
|
||||
{ row: 3, col: 1, variant: 'logs', label: 'Audit Trail' },
|
||||
{ row: 1, col: 3, variant: 'workflow', label: 'Support', color: '#059669' },
|
||||
{ row: 2, col: 3, variant: 'file', label: 'data.csv' },
|
||||
{ row: 3, col: 2, variant: 'table', label: 'Users' },
|
||||
{ row: 3, col: 3, variant: 'file', label: 'policies.pdf' },
|
||||
{ row: 3, col: 3, variant: 'knowledge', label: 'HR Docs' },
|
||||
{ row: 0, col: 4, variant: 'workflow', label: 'Pipeline', color: '#DC2626' },
|
||||
{ row: 1, col: 4, variant: 'logs', label: 'API Logs' },
|
||||
{ row: 2, col: 4, variant: 'table', label: 'Orders' },
|
||||
@@ -99,7 +100,7 @@ const MOTHERSHIP_CARDS: CardDef[] = [
|
||||
{ row: 0, col: 5, variant: 'logs', label: 'Deploys' },
|
||||
{ row: 1, col: 5, variant: 'table', label: 'Campaigns' },
|
||||
{ row: 2, col: 5, variant: 'workflow', label: 'Intake', color: '#D97706' },
|
||||
{ row: 3, col: 5, variant: 'file', label: 'research.pdf' },
|
||||
{ row: 3, col: 5, variant: 'knowledge', label: 'Research' },
|
||||
{ row: 4, col: 0, variant: 'file', label: 'readme.md' },
|
||||
{ row: 4, col: 1, variant: 'table', label: 'Revenue' },
|
||||
{ row: 4, col: 2, variant: 'workflow', label: 'Sync', color: '#0891B2' },
|
||||
@@ -109,25 +110,27 @@ const MOTHERSHIP_CARDS: CardDef[] = [
|
||||
{ row: 0, col: 6, variant: 'table', label: 'Analytics' },
|
||||
{ row: 1, col: 6, variant: 'workflow', label: 'Digest', color: '#6366F1' },
|
||||
{ row: 0, col: 7, variant: 'file', label: 'brief.md' },
|
||||
{ row: 2, col: 6, variant: 'file', label: 'playbook.md' },
|
||||
{ row: 2, col: 6, variant: 'knowledge', label: 'Playbooks' },
|
||||
{ row: 1, col: 7, variant: 'logs', label: 'Webhooks' },
|
||||
{ row: 3, col: 6, variant: 'file', label: 'export.csv' },
|
||||
{ row: 2, col: 7, variant: 'workflow', label: 'Alerts', color: '#E11D48' },
|
||||
{ row: 4, col: 6, variant: 'logs', label: 'Metrics' },
|
||||
{ row: 3, col: 7, variant: 'table', label: 'Feedback' },
|
||||
{ row: 4, col: 7, variant: 'file', label: 'runbook.md' },
|
||||
{ row: 4, col: 7, variant: 'knowledge', label: 'Runbooks' },
|
||||
]
|
||||
|
||||
const EXPAND_TARGETS: Record<number, { row: number; col: number }> = {
|
||||
1: { row: 1, col: 0 },
|
||||
2: { row: 0, col: 2 },
|
||||
3: { row: 2, col: 0 },
|
||||
3: { row: 1, col: 1 },
|
||||
4: { row: 2, col: 0 },
|
||||
}
|
||||
|
||||
const EXPAND_ROW_COUNTS: Record<number, number> = {
|
||||
1: 8,
|
||||
2: 10,
|
||||
3: 7,
|
||||
3: 10,
|
||||
4: 7,
|
||||
}
|
||||
|
||||
function WorkspacePreview({ activeTab, isActive }: { activeTab: number; isActive: boolean }) {
|
||||
@@ -143,7 +146,7 @@ function WorkspacePreview({ activeTab, isActive }: { activeTab: number; isActive
|
||||
const [revealedRows, setRevealedRows] = useState(0)
|
||||
|
||||
const isMothership = activeTab === 0 && isActive
|
||||
const isExpandTab = activeTab >= 1 && activeTab <= 3 && isActive
|
||||
const isExpandTab = activeTab >= 1 && activeTab <= 4 && isActive
|
||||
const expandTarget = EXPAND_TARGETS[activeTab] ?? null
|
||||
|
||||
useEffect(() => {
|
||||
@@ -289,7 +292,8 @@ function WorkspacePreview({ activeTab, isActive }: { activeTab: number; isActive
|
||||
>
|
||||
{expandedTab === 1 && <MockFullTable revealedRows={revealedRows} />}
|
||||
{expandedTab === 2 && <MockFullFiles />}
|
||||
{expandedTab === 3 && <MockFullLogs revealedRows={revealedRows} />}
|
||||
{expandedTab === 3 && <MockFullKnowledgeBase revealedRows={revealedRows} />}
|
||||
{expandedTab === 4 && <MockFullLogs revealedRows={revealedRows} />}
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
@@ -389,6 +393,8 @@ function MiniCardIcon({ variant, color }: { variant: CardVariant; color?: string
|
||||
/>
|
||||
)
|
||||
}
|
||||
case 'knowledge':
|
||||
return <Database className={cls} />
|
||||
case 'logs':
|
||||
return <Library className={cls} />
|
||||
}
|
||||
@@ -404,6 +410,8 @@ function MiniCardBody({ variant, color }: { variant: CardVariant; color?: string
|
||||
return <TableCardBody />
|
||||
case 'workflow':
|
||||
return <WorkflowCardBody color={color ?? '#7C3AED'} />
|
||||
case 'knowledge':
|
||||
return <KnowledgeCardBody />
|
||||
case 'logs':
|
||||
return <LogsCardBody />
|
||||
}
|
||||
@@ -490,6 +498,21 @@ function WorkflowCardBody({ color }: { color: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
const KB_WIDTHS = [70, 85, 55, 80, 48] as const
|
||||
|
||||
function KnowledgeCardBody() {
|
||||
return (
|
||||
<div className='flex flex-col gap-[5px] px-2 py-1.5'>
|
||||
{KB_WIDTHS.map((w, i) => (
|
||||
<div key={i} className='flex items-center gap-1'>
|
||||
<div className='h-[3px] w-[3px] flex-shrink-0 rounded-full bg-[#D4D4D4]' />
|
||||
<div className='h-[1.5px] rounded-full bg-[#E8E8E8]' style={{ width: `${w}%` }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const LOG_ENTRIES = [
|
||||
{ color: '#22C55E', width: 65 },
|
||||
{ color: '#22C55E', width: 78 },
|
||||
@@ -556,6 +579,33 @@ The team agreed to prioritize the new onboarding flow. Key decisions:
|
||||
|
||||
Follow up with engineering on the timeline for the API v2 migration. Draft the proposal for the board meeting next week.`
|
||||
|
||||
const MOCK_KB_COLUMNS = ['Name', 'Size', 'Tokens', 'Chunks', 'Status'] as const
|
||||
|
||||
const KB_FILE_ICONS: Record<string, React.ComponentType<SVGProps<SVGSVGElement>>> = {
|
||||
pdf: PdfIcon,
|
||||
md: MarkdownIcon,
|
||||
csv: CsvIcon,
|
||||
json: JsonIcon,
|
||||
}
|
||||
|
||||
function getKBFileIcon(filename: string) {
|
||||
const ext = filename.split('.').pop()?.toLowerCase() ?? ''
|
||||
return KB_FILE_ICONS[ext] ?? File
|
||||
}
|
||||
|
||||
const MOCK_KB_DATA = [
|
||||
['product-specs.pdf', '4.2 MB', '12.4k', '86', 'enabled'],
|
||||
['eng-handbook.md', '1.8 MB', '8.2k', '54', 'enabled'],
|
||||
['api-reference.json', '920 KB', '4.1k', '32', 'enabled'],
|
||||
['release-notes.md', '340 KB', '2.8k', '18', 'enabled'],
|
||||
['onboarding-guide.pdf', '2.1 MB', '6.5k', '42', 'processing'],
|
||||
['data-export.csv', '560 KB', '3.4k', '24', 'enabled'],
|
||||
['runbook.md', '280 KB', '1.9k', '14', 'enabled'],
|
||||
['compliance.pdf', '180 KB', '1.2k', '8', 'disabled'],
|
||||
['style-guide.md', '410 KB', '2.6k', '20', 'enabled'],
|
||||
['metrics.csv', '1.4 MB', '5.8k', '38', 'enabled'],
|
||||
] as const
|
||||
|
||||
const MD_COMPONENTS: Components = {
|
||||
h1: ({ children }) => (
|
||||
<p
|
||||
@@ -627,6 +677,106 @@ function MockFullFiles() {
|
||||
)
|
||||
}
|
||||
|
||||
const KB_STATUS_STYLES: Record<string, { bg: string; text: string; label: string }> = {
|
||||
enabled: { bg: '#DCFCE7', text: '#166534', label: 'Enabled' },
|
||||
disabled: { bg: '#F3F4F6', text: '#6B7280', label: 'Disabled' },
|
||||
processing: { bg: '#F3E8FF', text: '#7C3AED', label: 'Processing' },
|
||||
}
|
||||
|
||||
function MockFullKnowledgeBase({ revealedRows }: { revealedRows: number }) {
|
||||
return (
|
||||
<div className='flex h-full flex-col'>
|
||||
<div className='flex h-[44px] shrink-0 items-center border-[#E5E5E5] border-b px-6'>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<Database className='h-[14px] w-[14px] text-[#999]' />
|
||||
<span className='text-[#999] text-[13px]'>Knowledge Base</span>
|
||||
<span className='text-[#D4D4D4] text-[13px]'>/</span>
|
||||
<span className='font-medium text-[#1C1C1C] text-[13px]'>Company KB</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex h-[36px] shrink-0 items-center border-[#E5E5E5] border-b px-6'>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<div className='flex h-[24px] items-center gap-1 rounded-[6px] border border-[#E5E5E5] px-2 text-[#999] text-[12px]'>
|
||||
Sort
|
||||
</div>
|
||||
<div className='flex h-[24px] items-center gap-1 rounded-[6px] border border-[#E5E5E5] px-2 text-[#999] text-[12px]'>
|
||||
Filter
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex-1 overflow-hidden'>
|
||||
<table className='w-full table-fixed border-separate border-spacing-0 text-[13px]'>
|
||||
<colgroup>
|
||||
<col style={{ width: 40 }} />
|
||||
{MOCK_KB_COLUMNS.map((col) => (
|
||||
<col key={col} />
|
||||
))}
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className='border-[#E5E5E5] border-r border-b bg-[#FAFAFA] px-1 py-[7px] text-center align-middle'>
|
||||
<div className='flex items-center justify-center'>
|
||||
<div className='h-[13px] w-[13px] rounded-[2px] border border-[#D4D4D4]' />
|
||||
</div>
|
||||
</th>
|
||||
{MOCK_KB_COLUMNS.map((col) => (
|
||||
<th
|
||||
key={col}
|
||||
className='border-[#E5E5E5] border-r border-b bg-[#FAFAFA] px-2 py-[7px] text-left align-middle'
|
||||
>
|
||||
<span className='font-base text-[#999] text-[13px]'>{col}</span>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{MOCK_KB_DATA.slice(0, revealedRows).map((row, i) => {
|
||||
const status = KB_STATUS_STYLES[row[4]] ?? KB_STATUS_STYLES.enabled
|
||||
const DocIcon = getKBFileIcon(row[0])
|
||||
return (
|
||||
<motion.tr
|
||||
key={i}
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||
>
|
||||
<td className='border-[#E5E5E5] border-r border-b px-1 py-[7px] text-center align-middle'>
|
||||
<span className='text-[#999] text-[11px] tabular-nums'>{i + 1}</span>
|
||||
</td>
|
||||
<td className='border-[#E5E5E5] border-r border-b px-2 py-[7px] align-middle'>
|
||||
<span className='flex items-center gap-2 text-[#1C1C1C] text-[13px]'>
|
||||
<DocIcon className='h-[14px] w-[14px] shrink-0' />
|
||||
<span className='truncate'>{row[0]}</span>
|
||||
</span>
|
||||
</td>
|
||||
{row.slice(1, 4).map((cell, j) => (
|
||||
<td
|
||||
key={j}
|
||||
className='border-[#E5E5E5] border-r border-b px-2 py-[7px] align-middle'
|
||||
>
|
||||
<span className='text-[#999] text-[13px]'>{cell}</span>
|
||||
</td>
|
||||
))}
|
||||
<td className='border-[#E5E5E5] border-r border-b px-2 py-[7px] align-middle'>
|
||||
<span
|
||||
className='inline-flex items-center rounded-full px-2 py-0.5 font-medium text-[11px]'
|
||||
style={{ backgroundColor: status.bg, color: status.text }}
|
||||
>
|
||||
{status.label}
|
||||
</span>
|
||||
</td>
|
||||
</motion.tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const MOCK_LOG_COLORS = [
|
||||
'#7C3AED',
|
||||
'#2563EB',
|
||||
@@ -4,8 +4,8 @@ import { useRef, useState } from 'react'
|
||||
import { type MotionValue, motion, useScroll, useTransform } from 'framer-motion'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { Badge } from '@/components/emcn'
|
||||
import { FeaturesPreview } from '@/app/(landing)/components/features/components/features-preview'
|
||||
import { Badge, ChevronDown } from '@/components/emcn'
|
||||
import { FeaturesPreview } from '@/app/(home)/components/features/components/features-preview'
|
||||
|
||||
function hexToRgba(hex: string, alpha: number): string {
|
||||
const r = Number.parseInt(hex.slice(1, 3), 16)
|
||||
@@ -14,19 +14,7 @@ function hexToRgba(hex: string, alpha: number): string {
|
||||
return `rgba(${r},${g},${b},${alpha})`
|
||||
}
|
||||
|
||||
interface FeatureTab {
|
||||
label: string
|
||||
mobileLabel?: string
|
||||
color: string
|
||||
badgeColor?: string
|
||||
title: string
|
||||
description: string
|
||||
cta: string
|
||||
segments: number[][]
|
||||
hideOnMobile?: boolean
|
||||
}
|
||||
|
||||
const FEATURE_TABS: FeatureTab[] = [
|
||||
const FEATURE_TABS = [
|
||||
{
|
||||
label: 'Mothership',
|
||||
color: '#FA4EDF',
|
||||
@@ -87,6 +75,27 @@ const FEATURE_TABS: FeatureTab[] = [
|
||||
[1, 10],
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Knowledge Base',
|
||||
mobileLabel: 'Knowledge',
|
||||
color: '#8B5CF6',
|
||||
title: 'Your context engine',
|
||||
description:
|
||||
'Sync institutional knowledge from 30+ live connectors — Notion, Drive, Slack, Confluence, and more — so every agent draws from the same truth across your entire organization.',
|
||||
cta: 'Explore knowledge base',
|
||||
segments: [
|
||||
[0.3, 10],
|
||||
[0.25, 8],
|
||||
[0.4, 10],
|
||||
[0.5, 10],
|
||||
[0.65, 10],
|
||||
[0.8, 10],
|
||||
[0.9, 12],
|
||||
[1, 10],
|
||||
[0.95, 10],
|
||||
[1, 10],
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Logs',
|
||||
hideOnMobile: true,
|
||||
@@ -129,6 +138,36 @@ function ScrollLetter({ scrollYProgress, charIndex, children }: ScrollLetterProp
|
||||
return <motion.span style={{ opacity }}>{children}</motion.span>
|
||||
}
|
||||
|
||||
function DotGrid({
|
||||
cols,
|
||||
rows,
|
||||
width,
|
||||
borderLeft,
|
||||
}: {
|
||||
cols: number
|
||||
rows: number
|
||||
width?: number
|
||||
borderLeft?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className={`h-full shrink-0 bg-[var(--landing-bg-section)] p-1.5 ${borderLeft ? 'border-[var(--divider)] border-l' : ''}`}
|
||||
style={{
|
||||
width: width ? `${width}px` : undefined,
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(${cols}, 1fr)`,
|
||||
gap: 4,
|
||||
placeItems: 'center',
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: cols * rows }, (_, i) => (
|
||||
<div key={i} className='h-[1.5px] w-[1.5px] rounded-full bg-[#DEDEDE]' />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Features() {
|
||||
const sectionRef = useRef<HTMLDivElement>(null)
|
||||
const [activeTab, setActiveTab] = useState(0)
|
||||
@@ -144,7 +183,7 @@ export default function Features() {
|
||||
aria-labelledby='features-heading'
|
||||
className='relative overflow-hidden bg-[var(--landing-bg-section)]'
|
||||
>
|
||||
<div aria-hidden='true' className='absolute top-0 left-0 hidden w-full lg:block'>
|
||||
<div aria-hidden='true' className='absolute top-0 left-0 w-full'>
|
||||
<Image
|
||||
src='/landing/features-transition.svg'
|
||||
alt=''
|
||||
@@ -155,7 +194,7 @@ export default function Features() {
|
||||
</div>
|
||||
|
||||
<div className='relative z-10 pt-[60px] lg:pt-[100px]'>
|
||||
<div ref={sectionRef} className='flex flex-col items-start gap-5 px-6 lg:px-16'>
|
||||
<div ref={sectionRef} className='flex flex-col items-start gap-5 px-6 lg:px-20'>
|
||||
<Badge
|
||||
variant='blue'
|
||||
size='md'
|
||||
@@ -171,17 +210,9 @@ export default function Features() {
|
||||
>
|
||||
Workspace
|
||||
</Badge>
|
||||
<p className='sr-only'>
|
||||
Sim's workspace includes four core features: Mothership, an AI command center for
|
||||
natural-language control of your entire workspace; Tables, a built-in database for
|
||||
filtering, sorting, and wiring data directly into workflows; Files, a shared document
|
||||
store for uploading, creating, and sharing documents, spreadsheets, and media across
|
||||
teams and agents; and Logs, full execution tracing with inputs, outputs, cost, and
|
||||
duration for every run.
|
||||
</p>
|
||||
<h2
|
||||
id='features-heading'
|
||||
className='max-w-[900px] text-balance font-[430] font-season text-[24px] text-[var(--landing-text-dark)] leading-[110%] tracking-[-0.02em] md:text-[36px]'
|
||||
className='max-w-[900px] text-balance font-[430] font-season text-[28px] text-[var(--landing-text-dark)] leading-[110%] tracking-[-0.02em] md:text-[40px]'
|
||||
>
|
||||
{HEADING_LETTERS.map((char, i) => (
|
||||
<ScrollLetter key={i} scrollYProgress={scrollYProgress} charIndex={i}>
|
||||
@@ -195,36 +226,45 @@ export default function Features() {
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className='relative mt-10 pb-[60px] lg:mt-[73px] lg:pb-[100px]'>
|
||||
<div className='relative mt-10 pb-10 lg:mt-[73px] lg:pb-20'>
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='absolute top-0 bottom-0 left-16 z-20 hidden w-px bg-[var(--divider)] lg:block'
|
||||
className='absolute top-0 bottom-0 left-[80px] z-20 hidden w-px bg-[var(--divider)] lg:block'
|
||||
/>
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='absolute top-0 right-16 bottom-0 z-20 hidden w-px bg-[var(--divider)] lg:block'
|
||||
className='absolute top-0 right-[80px] bottom-0 z-20 hidden w-px bg-[var(--divider)] lg:block'
|
||||
/>
|
||||
|
||||
<div className='flex h-[68px] border border-[var(--divider)] lg:overflow-hidden'>
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='h-full w-[24px] shrink-0 bg-[var(--landing-bg-section)] lg:w-16'
|
||||
/>
|
||||
<div className='h-full shrink-0'>
|
||||
<div className='h-full lg:hidden'>
|
||||
<DotGrid cols={3} rows={8} width={24} />
|
||||
</div>
|
||||
<div className='hidden h-full lg:block'>
|
||||
<DotGrid cols={10} rows={8} width={80} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div role='tablist' aria-label='Feature categories' className='flex flex-1'>
|
||||
{FEATURE_TABS.map((tab, index) => (
|
||||
<button
|
||||
key={tab.label}
|
||||
id={`feature-tab-${index}`}
|
||||
type='button'
|
||||
role='tab'
|
||||
aria-selected={index === activeTab}
|
||||
aria-controls='features-panel'
|
||||
onClick={() => setActiveTab(index)}
|
||||
className={`relative h-full min-w-0 flex-1 items-center justify-center px-2 font-medium font-season text-[var(--landing-text-dark)] text-caption uppercase lg:px-0 lg:text-sm${tab.hideOnMobile ? ' hidden lg:flex' : ' flex'}${index > 0 ? ' border-[var(--divider)] border-l' : ''}`}
|
||||
className={`relative h-full flex-1 items-center justify-center whitespace-nowrap px-3 font-medium font-season text-[var(--landing-text-dark)] text-caption uppercase lg:px-0 lg:text-sm${tab.hideOnMobile ? ' hidden lg:flex' : ' flex'}${index > 0 ? ' border-[var(--divider)] border-l' : ''}`}
|
||||
style={{ backgroundColor: index === activeTab ? '#FDFDFD' : '#F6F6F6' }}
|
||||
>
|
||||
<span className='truncate'>{tab.label}</span>
|
||||
{tab.mobileLabel ? (
|
||||
<>
|
||||
<span className='lg:hidden'>{tab.mobileLabel}</span>
|
||||
<span className='hidden lg:inline'>{tab.label}</span>
|
||||
</>
|
||||
) : (
|
||||
tab.label
|
||||
)}
|
||||
{index === activeTab && (
|
||||
<div className='absolute right-0 bottom-0 left-0 flex h-[6px]'>
|
||||
{tab.segments.map(([opacity, width], i) => (
|
||||
@@ -244,18 +284,17 @@ export default function Features() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='h-full w-[24px] shrink-0 border-[var(--divider)] border-l bg-[var(--landing-bg-section)] lg:w-16'
|
||||
/>
|
||||
<div className='h-full shrink-0'>
|
||||
<div className='h-full lg:hidden'>
|
||||
<DotGrid cols={3} rows={8} width={24} />
|
||||
</div>
|
||||
<div className='hidden h-full lg:block'>
|
||||
<DotGrid cols={10} rows={8} width={80} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id='features-panel'
|
||||
role='tabpanel'
|
||||
aria-labelledby={`feature-tab-${activeTab}`}
|
||||
className='mt-8 flex flex-col gap-6 px-6 lg:mt-[60px] lg:grid lg:grid-cols-[1fr_2.8fr] lg:gap-[60px] lg:px-[104px]'
|
||||
>
|
||||
<div className='mt-8 flex flex-col gap-6 px-6 lg:mt-[60px] lg:grid lg:grid-cols-[1fr_2.8fr] lg:gap-[60px] lg:px-[120px]'>
|
||||
<div className='flex flex-col items-start justify-between gap-6 pt-5 lg:h-[560px] lg:gap-0'>
|
||||
<div className='flex flex-col items-start gap-4'>
|
||||
<h3 className='font-[430] font-season text-[24px] text-[var(--landing-text-dark)] leading-[120%] tracking-[-0.02em] lg:text-[28px]'>
|
||||
@@ -267,9 +306,26 @@ export default function Features() {
|
||||
</div>
|
||||
<Link
|
||||
href='/signup'
|
||||
className='inline-flex h-[32px] items-center rounded-[5px] border border-[#1D1D1D] bg-[#1D1D1D] px-2.5 font-[430] font-season text-sm text-white transition-colors hover:border-[var(--landing-bg-elevated)] hover:bg-[var(--landing-bg-elevated)]'
|
||||
className='group/cta inline-flex h-[32px] items-center gap-1.5 rounded-[5px] border border-[#1D1D1D] bg-[#1D1D1D] px-2.5 font-[430] font-season text-sm text-white transition-colors hover:border-[var(--landing-bg-elevated)] hover:bg-[var(--landing-bg-elevated)]'
|
||||
>
|
||||
{FEATURE_TABS[activeTab].cta}
|
||||
<span className='relative h-[10px] w-[10px] shrink-0'>
|
||||
<ChevronDown className='-rotate-90 absolute inset-0 h-[10px] w-[10px] transition-opacity duration-150 group-hover/cta:opacity-0' />
|
||||
<svg
|
||||
className='absolute inset-0 h-[10px] w-[10px] opacity-0 transition-opacity duration-150 group-hover/cta:opacity-100'
|
||||
viewBox='0 0 10 10'
|
||||
fill='none'
|
||||
>
|
||||
<path
|
||||
d='M1 5H8M5.5 2L8.5 5L5.5 8'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.33'
|
||||
strokeLinecap='square'
|
||||
strokeLinejoin='miter'
|
||||
fill='none'
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { ArrowUp } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useLandingSubmit } from '@/app/(landing)/components/landing-preview/components/landing-preview-panel/landing-preview-panel'
|
||||
import { useLandingSubmit } from '@/app/(home)/components/landing-preview/components/landing-preview-panel/landing-preview-panel'
|
||||
import { useAnimatedPlaceholder } from '@/hooks/use-animated-placeholder'
|
||||
|
||||
const MAX_HEIGHT = 120
|
||||
@@ -41,21 +41,14 @@ export function FooterCTA() {
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<section
|
||||
id='cta'
|
||||
aria-labelledby='cta-heading'
|
||||
className='flex flex-col items-center px-4 pt-[90px] pb-[90px] sm:px-8 sm:pt-[120px] sm:pb-[120px] md:px-16 md:pt-[150px] md:pb-[150px]'
|
||||
>
|
||||
<h2
|
||||
id='cta-heading'
|
||||
className='text-balance text-center font-[430] font-season text-[28px] text-white leading-[100%] tracking-[-0.02em] sm:text-[32px] md:text-[36px]'
|
||||
>
|
||||
<div className='flex flex-col items-center px-4 pt-[120px] pb-[100px] sm:px-8 md:px-20'>
|
||||
<h2 className='text-balance text-center font-[430] font-season text-[28px] text-[var(--landing-text-dark)] leading-[100%] tracking-[-0.02em] sm:text-[32px] md:text-[36px]'>
|
||||
What should we get done?
|
||||
</h2>
|
||||
|
||||
<div className='mt-8 w-full max-w-[42rem]'>
|
||||
<div
|
||||
className='cursor-text rounded-[20px] border border-[var(--landing-bg-elevated)] bg-[var(--landing-bg-surface)] px-2.5 py-2'
|
||||
className='cursor-text rounded-[20px] border border-[var(--landing-bg-skeleton)] bg-white px-2.5 py-2 shadow-sm'
|
||||
onClick={() => textareaRef.current?.focus()}
|
||||
>
|
||||
<textarea
|
||||
@@ -64,11 +57,10 @@ export function FooterCTA() {
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onInput={handleInput}
|
||||
aria-label='Describe what you want to build'
|
||||
placeholder={animatedPlaceholder}
|
||||
rows={2}
|
||||
className='m-0 box-border min-h-[48px] w-full resize-none border-0 bg-transparent px-1 py-1 font-body text-[var(--landing-text)] text-base leading-[24px] tracking-[-0.015em] outline-none placeholder:font-[380] placeholder:text-[var(--landing-text-muted)] focus-visible:ring-0'
|
||||
style={{ caretColor: '#FFFFFF', maxHeight: `${MAX_HEIGHT}px` }}
|
||||
className='m-0 box-border min-h-[48px] w-full resize-none border-0 bg-transparent px-1 py-1 font-body text-[var(--landing-text-dark)] text-base leading-[24px] tracking-[-0.015em] outline-none placeholder:font-[380] placeholder:text-[var(--landing-text-muted)] focus-visible:ring-0'
|
||||
style={{ caretColor: '#1C1C1C', maxHeight: `${MAX_HEIGHT}px` }}
|
||||
/>
|
||||
<div className='flex items-center justify-end'>
|
||||
<button
|
||||
@@ -78,11 +70,11 @@ export function FooterCTA() {
|
||||
aria-label='Submit message'
|
||||
className='flex h-[28px] w-[28px] items-center justify-center rounded-full border-0 p-0 transition-colors'
|
||||
style={{
|
||||
background: isEmpty ? '#555555' : '#FFFFFF',
|
||||
background: isEmpty ? '#C0C0C0' : '#1C1C1C',
|
||||
cursor: isEmpty ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
>
|
||||
<ArrowUp size={16} strokeWidth={2.25} color={isEmpty ? '#888888' : '#1C1C1C'} />
|
||||
<ArrowUp size={16} strokeWidth={2.25} color='#FFFFFF' />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -93,17 +85,17 @@ export function FooterCTA() {
|
||||
href='https://docs.sim.ai'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className={`${CTA_BUTTON} border-[var(--landing-border-strong)] text-[var(--landing-text)] transition-colors hover:bg-[var(--landing-bg-elevated)]`}
|
||||
className={`${CTA_BUTTON} border-[var(--landing-border-subtle)] text-[var(--landing-text-dark)] transition-colors hover:bg-[var(--landing-bg-skeleton)]`}
|
||||
>
|
||||
Docs
|
||||
</a>
|
||||
<Link
|
||||
href='/signup'
|
||||
className={`${CTA_BUTTON} gap-2 border-white bg-white text-black transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]`}
|
||||
className={`${CTA_BUTTON} gap-2 border-[var(--landing-bg)] bg-[var(--landing-bg)] text-white transition-colors hover:border-[var(--landing-bg-elevated)] hover:bg-[var(--landing-bg-elevated)]`}
|
||||
>
|
||||
Get started
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { FooterCTA } from '@/app/(landing)/components/footer/footer-cta'
|
||||
import { FooterCTA } from '@/app/(home)/components/footer/footer-cta'
|
||||
|
||||
const LINK_CLASS =
|
||||
'text-sm text-[var(--landing-text-muted)] transition-colors hover:text-[var(--landing-text)]'
|
||||
@@ -9,17 +9,17 @@ interface FooterItem {
|
||||
label: string
|
||||
href: string
|
||||
external?: boolean
|
||||
arrow?: boolean
|
||||
externalArrow?: boolean
|
||||
}
|
||||
|
||||
const PRODUCT_LINKS: FooterItem[] = [
|
||||
{ label: 'Pricing', href: '/#pricing' },
|
||||
{ label: 'Enterprise', href: 'https://form.typeform.com/to/jqCO12pF', external: true },
|
||||
{ label: 'Self Hosting', href: 'https://docs.sim.ai/self-hosting', external: true },
|
||||
{ label: 'MCP', href: 'https://docs.sim.ai/mcp', external: true },
|
||||
{ label: 'Knowledge Base', href: 'https://docs.sim.ai/knowledgebase', external: true },
|
||||
{ label: 'Tables', href: 'https://docs.sim.ai/tables', external: true },
|
||||
{ label: 'API', href: 'https://docs.sim.ai/api-reference/getting-started', external: true },
|
||||
{ label: 'Status', href: 'https://status.sim.ai', external: true, externalArrow: true },
|
||||
{ label: 'Status', href: 'https://status.sim.ai', external: true },
|
||||
]
|
||||
|
||||
const RESOURCES_LINKS: FooterItem[] = [
|
||||
@@ -29,7 +29,7 @@ const RESOURCES_LINKS: FooterItem[] = [
|
||||
{ label: 'Models', href: '/models' },
|
||||
// { label: 'Academy', href: '/academy' },
|
||||
{ label: 'Partners', href: '/partners' },
|
||||
{ label: 'Careers', href: 'https://jobs.ashbyhq.com/sim', external: true, externalArrow: true },
|
||||
{ label: 'Careers', href: 'https://jobs.ashbyhq.com/sim', external: true },
|
||||
{ label: 'Changelog', href: '/changelog' },
|
||||
]
|
||||
|
||||
@@ -47,7 +47,7 @@ const BLOCK_LINKS: FooterItem[] = [
|
||||
]
|
||||
|
||||
const INTEGRATION_LINKS: FooterItem[] = [
|
||||
{ label: 'All Integrations', href: '/integrations', arrow: true },
|
||||
{ label: 'All Integrations →', href: '/integrations' },
|
||||
{ label: 'Confluence', href: 'https://docs.sim.ai/tools/confluence', external: true },
|
||||
{ label: 'Slack', href: 'https://docs.sim.ai/tools/slack', external: true },
|
||||
{ label: 'GitHub', href: 'https://docs.sim.ai/tools/github', external: true },
|
||||
@@ -71,20 +71,10 @@ const INTEGRATION_LINKS: FooterItem[] = [
|
||||
]
|
||||
|
||||
const SOCIAL_LINKS: FooterItem[] = [
|
||||
{ label: 'X (Twitter)', href: 'https://x.com/simdotai', external: true, externalArrow: true },
|
||||
{
|
||||
label: 'LinkedIn',
|
||||
href: 'https://www.linkedin.com/company/simstudioai/',
|
||||
external: true,
|
||||
externalArrow: true,
|
||||
},
|
||||
{ label: 'Discord', href: 'https://discord.gg/Hr4UWYEcTT', external: true, externalArrow: true },
|
||||
{
|
||||
label: 'GitHub',
|
||||
href: 'https://github.com/simstudioai/sim',
|
||||
external: true,
|
||||
externalArrow: true,
|
||||
},
|
||||
{ label: 'X (Twitter)', href: 'https://x.com/simdotai', external: true },
|
||||
{ label: 'LinkedIn', href: 'https://www.linkedin.com/company/simstudioai/', external: true },
|
||||
{ label: 'Discord', href: 'https://discord.gg/Hr4UWYEcTT', external: true },
|
||||
{ label: 'GitHub', href: 'https://github.com/simstudioai/sim', external: true },
|
||||
]
|
||||
|
||||
const LEGAL_LINKS: FooterItem[] = [
|
||||
@@ -92,62 +82,25 @@ const LEGAL_LINKS: FooterItem[] = [
|
||||
{ label: 'Privacy Policy', href: '/privacy' },
|
||||
]
|
||||
|
||||
function ChevronArrow({ external }: { external?: boolean }) {
|
||||
return (
|
||||
<svg
|
||||
className={`h-3 w-3 shrink-0${external ? ' -rotate-45' : ''}`}
|
||||
viewBox='0 0 10 10'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<line
|
||||
x1='0'
|
||||
y1='5'
|
||||
x2='9'
|
||||
y2='5'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.33'
|
||||
strokeLinecap='square'
|
||||
className='origin-left scale-x-0 transition-transform duration-200 ease-out [transform-box:fill-box] group-hover/link:scale-x-100'
|
||||
/>
|
||||
<path
|
||||
d='M3.5 2L6.5 5L3.5 8'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.33'
|
||||
strokeLinecap='square'
|
||||
strokeLinejoin='miter'
|
||||
fill='none'
|
||||
className='transition-transform duration-200 ease-out group-hover/link:translate-x-[30%]'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function FooterColumn({ title, items }: { title: string; items: FooterItem[] }) {
|
||||
return (
|
||||
<div>
|
||||
<h3 className='mb-4 font-medium text-[var(--landing-text)] text-sm'>{title}</h3>
|
||||
<div className='flex flex-col gap-2.5'>
|
||||
{items.map(({ label, href, external, arrow, externalArrow }) =>
|
||||
{items.map(({ label, href, external }) =>
|
||||
external ? (
|
||||
<a
|
||||
key={label}
|
||||
href={href}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className={`${LINK_CLASS}${externalArrow ? ' group/link inline-flex items-center gap-1' : ''}`}
|
||||
className={LINK_CLASS}
|
||||
>
|
||||
{label}
|
||||
{externalArrow && <ChevronArrow external />}
|
||||
</a>
|
||||
) : (
|
||||
<Link
|
||||
key={label}
|
||||
href={href}
|
||||
className={`${LINK_CLASS}${arrow ? ' group/link inline-flex items-center gap-1.5' : ''}`}
|
||||
>
|
||||
<Link key={label} href={href} className={LINK_CLASS}>
|
||||
{label}
|
||||
{arrow && <ChevronArrow />}
|
||||
</Link>
|
||||
)
|
||||
)}
|
||||
@@ -164,31 +117,13 @@ export default function Footer({ hideCTA }: FooterProps) {
|
||||
return (
|
||||
<footer
|
||||
role='contentinfo'
|
||||
className={`bg-[var(--landing-bg)] pb-10 font-[430] font-season text-sm${hideCTA ? ' pt-10' : ''}`}
|
||||
className={`bg-[var(--landing-bg-section)] pb-10 font-[430] font-season text-sm${hideCTA ? ' pt-10' : ''}`}
|
||||
>
|
||||
{!hideCTA && <FooterCTA />}
|
||||
<div className='relative px-[1.6vw] sm:px-8 lg:px-16'>
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='absolute top-0 left-0 z-20 hidden h-px w-[calc(4rem+4px)] bg-[var(--landing-bg-elevated)] lg:block'
|
||||
/>
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='absolute top-0 right-0 z-20 hidden h-px w-[calc(4rem+4px)] bg-[var(--landing-bg-elevated)] lg:block'
|
||||
/>
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='absolute bottom-0 left-0 z-20 hidden h-px w-[calc(4rem+4px)] bg-[var(--landing-bg-elevated)] lg:block'
|
||||
/>
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='absolute right-0 bottom-0 z-20 hidden h-px w-[calc(4rem+4px)] bg-[var(--landing-bg-elevated)] lg:block'
|
||||
/>
|
||||
<div className='relative z-10 border border-[var(--landing-bg-elevated)] px-6 pt-10 pb-8 sm:px-10 sm:pt-12 sm:pb-10'>
|
||||
<div className='px-4 sm:px-8 md:px-20'>
|
||||
<div className='relative overflow-hidden rounded-lg bg-[var(--landing-bg)] px-6 pt-10 pb-8 sm:px-10 sm:pt-12 sm:pb-10'>
|
||||
<nav
|
||||
aria-label='Footer navigation'
|
||||
itemScope
|
||||
itemType='https://schema.org/SiteNavigationElement'
|
||||
className='relative z-[1] grid grid-cols-2 gap-x-8 gap-y-10 sm:grid-cols-3 lg:grid-cols-7'
|
||||
>
|
||||
<div className='col-span-2 flex flex-col gap-6 sm:col-span-1'>
|
||||
@@ -210,6 +145,29 @@ export default function Footer({ hideCTA }: FooterProps) {
|
||||
<FooterColumn title='Socials' items={SOCIAL_LINKS} />
|
||||
<FooterColumn title='Legal' items={LEGAL_LINKS} />
|
||||
</nav>
|
||||
|
||||
{/* <svg
|
||||
aria-hidden='true'
|
||||
className='pointer-events-none absolute bottom-0 left-[-60px] hidden w-[85%] sm:block'
|
||||
viewBox='0 0 1800 316'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<path
|
||||
d='M18.3562 305V48.95A30.594 30.594 0 0 1 48.95 18.356H917.05A30.594 30.594 0 0 1 947.644 48.95V273H1768C1777.11 273 1784.5 280.387 1784.5 289.5C1784.5 298.613 1777.11 306 1768 306H96.8603C78.635 306 63.8604 310 63.8604 305H18.3562'
|
||||
stroke='#2A2A2A'
|
||||
strokeWidth='2'
|
||||
/>
|
||||
<rect
|
||||
x='58'
|
||||
y='58'
|
||||
width='849.288'
|
||||
height='199.288'
|
||||
rx='14'
|
||||
stroke='#2A2A2A'
|
||||
strokeWidth='2'
|
||||
/>
|
||||
</svg> */}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -0,0 +1,584 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { motion, type Variants } from 'framer-motion'
|
||||
|
||||
/** Stagger between each block appearing (seconds). */
|
||||
const ENTER_STAGGER = 0.06
|
||||
|
||||
/** Duration of each block's fade-in (seconds). */
|
||||
const ENTER_DURATION = 0.3
|
||||
|
||||
/** Stagger between each block disappearing (seconds). */
|
||||
const EXIT_STAGGER = 0.12
|
||||
|
||||
/** Duration of each block's fade-out (seconds). */
|
||||
const EXIT_DURATION = 0.5
|
||||
|
||||
/** Shared corner radius for all decorative rects. */
|
||||
const RX = '2.59574'
|
||||
|
||||
/** Hold time after the initial enter animation before cycling starts (ms). */
|
||||
const INITIAL_HOLD_MS = 2500
|
||||
|
||||
/** Pause between an exit completing and the next enter starting (ms). */
|
||||
const TRANSITION_PAUSE_MS = 400
|
||||
|
||||
/** Hold time between successive transitions (ms). */
|
||||
const HOLD_BETWEEN_MS = 2500
|
||||
|
||||
/** Animation state for a block group. */
|
||||
export type BlockAnimState = 'entering' | 'visible' | 'exiting' | 'hidden'
|
||||
|
||||
/** Positions around the hero where block groups can appear. */
|
||||
export type BlockPosition = 'topRight' | 'left' | 'rightEdge' | 'rightSide' | 'topLeft'
|
||||
|
||||
/** Attributes for a single animated SVG rect. */
|
||||
interface BlockRect {
|
||||
opacity: number
|
||||
width: string
|
||||
height: string
|
||||
fill: string
|
||||
x?: string
|
||||
y?: string
|
||||
transform?: string
|
||||
}
|
||||
|
||||
const containerVariants: Variants = {
|
||||
hidden: {},
|
||||
visible: { transition: { staggerChildren: ENTER_STAGGER } },
|
||||
exit: { transition: { staggerChildren: EXIT_STAGGER } },
|
||||
}
|
||||
|
||||
const blockVariants: Variants = {
|
||||
hidden: { opacity: 0, transition: { duration: 0 } },
|
||||
visible: (targetOpacity: number) => ({
|
||||
opacity: targetOpacity,
|
||||
transition: { duration: ENTER_DURATION },
|
||||
}),
|
||||
exit: {
|
||||
opacity: 0,
|
||||
transition: { duration: EXIT_DURATION },
|
||||
},
|
||||
}
|
||||
|
||||
/** Maps a BlockAnimState to the framer-motion animate value. */
|
||||
function toAnimateValue(state: BlockAnimState): string {
|
||||
if (state === 'entering' || state === 'visible') return 'visible'
|
||||
if (state === 'exiting') return 'exit'
|
||||
return 'hidden'
|
||||
}
|
||||
|
||||
/** Shared SVG wrapper that staggers child rects in and out. */
|
||||
function AnimatedBlocksSvg({
|
||||
width,
|
||||
height,
|
||||
viewBox,
|
||||
rects,
|
||||
animState = 'entering',
|
||||
}: {
|
||||
width: number
|
||||
height: number
|
||||
viewBox: string
|
||||
rects: readonly BlockRect[]
|
||||
animState?: BlockAnimState
|
||||
}) {
|
||||
return (
|
||||
<motion.svg
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox={viewBox}
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
className='h-auto w-full'
|
||||
initial='hidden'
|
||||
animate={toAnimateValue(animState)}
|
||||
variants={containerVariants}
|
||||
>
|
||||
{rects.map((r, i) => (
|
||||
<motion.rect
|
||||
key={i}
|
||||
variants={blockVariants}
|
||||
custom={r.opacity}
|
||||
x={r.x}
|
||||
y={r.y}
|
||||
width={r.width}
|
||||
height={r.height}
|
||||
rx={RX}
|
||||
fill={r.fill}
|
||||
transform={r.transform}
|
||||
/>
|
||||
))}
|
||||
</motion.svg>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Rect data for the top-right position.
|
||||
* Two-row horizontal strip, ordered left-to-right.
|
||||
*/
|
||||
const TOP_RIGHT_RECTS: readonly BlockRect[] = [
|
||||
{ opacity: 1, x: '0', y: '0', width: '16.8626', height: '33.7252', fill: '#2ABBF8' },
|
||||
{ opacity: 0.6, x: '0', y: '0', width: '85.3433', height: '16.8626', fill: '#2ABBF8' },
|
||||
{ opacity: 1, x: '0', y: '0', width: '16.8626', height: '16.8626', fill: '#2ABBF8' },
|
||||
{ opacity: 0.6, x: '34.2403', y: '0', width: '34.2403', height: '33.7252', fill: '#2ABBF8' },
|
||||
{ opacity: 1, x: '34.2403', y: '0', width: '16.8626', height: '16.8626', fill: '#2ABBF8' },
|
||||
{ opacity: 1, x: '51.6188', y: '16.8626', width: '16.8626', height: '16.8626', fill: '#2ABBF8' },
|
||||
{ opacity: 1, x: '68.4812', y: '0', width: '54.6502', height: '16.8626', fill: '#00F701' },
|
||||
{ opacity: 0.6, x: '106.268', y: '0', width: '34.2403', height: '33.7252', fill: '#00F701' },
|
||||
{ opacity: 0.6, x: '106.268', y: '0', width: '51.103', height: '16.8626', fill: '#00F701' },
|
||||
{ opacity: 1, x: '123.6484', y: '16.8626', width: '16.8626', height: '16.8626', fill: '#00F701' },
|
||||
{ opacity: 0.6, x: '157.371', y: '0', width: '34.2403', height: '16.8626', fill: '#FFCC02' },
|
||||
{ opacity: 1, x: '157.371', y: '0', width: '16.8626', height: '16.8626', fill: '#FFCC02' },
|
||||
{ opacity: 0.6, x: '208.993', y: '0', width: '68.4805', height: '16.8626', fill: '#FA4EDF' },
|
||||
{ opacity: 0.6, x: '209.137', y: '0', width: '16.8626', height: '33.7252', fill: '#FA4EDF' },
|
||||
{ opacity: 0.6, x: '243.233', y: '0', width: '34.2403', height: '33.7252', fill: '#FA4EDF' },
|
||||
{ opacity: 1, x: '243.233', y: '0', width: '16.8626', height: '16.8626', fill: '#FA4EDF' },
|
||||
{ opacity: 0.6, x: '260.096', y: '0', width: '34.04', height: '16.8626', fill: '#FA4EDF' },
|
||||
{ opacity: 1, x: '260.611', y: '16.8626', width: '16.8626', height: '16.8626', fill: '#FA4EDF' },
|
||||
]
|
||||
|
||||
/**
|
||||
* Rect data for the top-left position.
|
||||
* Same two-row structure as top-right with rotated colour palette:
|
||||
* blue→green, green→yellow, yellow→pink, pink→blue.
|
||||
*/
|
||||
const TOP_LEFT_RECTS: readonly BlockRect[] = [
|
||||
{ opacity: 1, x: '0', y: '0', width: '16.8626', height: '33.7252', fill: '#00F701' },
|
||||
{ opacity: 0.6, x: '0', y: '0', width: '85.3433', height: '16.8626', fill: '#00F701' },
|
||||
{ opacity: 1, x: '0', y: '0', width: '16.8626', height: '16.8626', fill: '#00F701' },
|
||||
{ opacity: 0.6, x: '34.2403', y: '0', width: '34.2403', height: '33.7252', fill: '#00F701' },
|
||||
{ opacity: 1, x: '34.2403', y: '0', width: '16.8626', height: '16.8626', fill: '#00F701' },
|
||||
{ opacity: 1, x: '51.6188', y: '16.8626', width: '16.8626', height: '16.8626', fill: '#00F701' },
|
||||
{ opacity: 1, x: '68.4812', y: '0', width: '54.6502', height: '16.8626', fill: '#FFCC02' },
|
||||
{ opacity: 0.6, x: '106.268', y: '0', width: '34.2403', height: '33.7252', fill: '#FFCC02' },
|
||||
{ opacity: 0.6, x: '106.268', y: '0', width: '51.103', height: '16.8626', fill: '#FFCC02' },
|
||||
{ opacity: 1, x: '123.6484', y: '16.8626', width: '16.8626', height: '16.8626', fill: '#FFCC02' },
|
||||
{ opacity: 0.6, x: '157.371', y: '0', width: '34.2403', height: '16.8626', fill: '#FA4EDF' },
|
||||
{ opacity: 1, x: '157.371', y: '0', width: '16.8626', height: '16.8626', fill: '#FA4EDF' },
|
||||
{ opacity: 0.6, x: '208.993', y: '0', width: '68.4805', height: '16.8626', fill: '#2ABBF8' },
|
||||
{ opacity: 0.6, x: '209.137', y: '0', width: '16.8626', height: '33.7252', fill: '#2ABBF8' },
|
||||
{ opacity: 0.6, x: '243.233', y: '0', width: '34.2403', height: '33.7252', fill: '#2ABBF8' },
|
||||
{ opacity: 1, x: '243.233', y: '0', width: '16.8626', height: '16.8626', fill: '#2ABBF8' },
|
||||
{ opacity: 0.6, x: '260.096', y: '0', width: '34.04', height: '16.8626', fill: '#2ABBF8' },
|
||||
{ opacity: 1, x: '260.611', y: '16.8626', width: '16.8626', height: '16.8626', fill: '#2ABBF8' },
|
||||
]
|
||||
|
||||
/**
|
||||
* Rect data for the left position.
|
||||
* Two-column vertical strip, ordered top-to-bottom.
|
||||
*/
|
||||
const LEFT_RECTS: readonly BlockRect[] = [
|
||||
{
|
||||
opacity: 0.6,
|
||||
width: '34.240',
|
||||
height: '33.725',
|
||||
fill: '#FA4EDF',
|
||||
transform: 'matrix(0 1 1 0 0 0)',
|
||||
},
|
||||
{
|
||||
opacity: 0.6,
|
||||
width: '16.8626',
|
||||
height: '68.480',
|
||||
fill: '#FA4EDF',
|
||||
transform: 'matrix(-1 0 0 1 33.727 0)',
|
||||
},
|
||||
{
|
||||
opacity: 1,
|
||||
width: '16.8626',
|
||||
height: '16.8626',
|
||||
fill: '#FA4EDF',
|
||||
transform: 'matrix(-1 0 0 1 33.727 17.378)',
|
||||
},
|
||||
{
|
||||
opacity: 0.6,
|
||||
width: '16.8626',
|
||||
height: '33.986',
|
||||
fill: '#FA4EDF',
|
||||
transform: 'matrix(0 1 1 0 0 51.616)',
|
||||
},
|
||||
{
|
||||
opacity: 0.6,
|
||||
width: '16.8626',
|
||||
height: '140.507',
|
||||
fill: '#00F701',
|
||||
transform: 'matrix(-1 0 0 1 33.986 85.335)',
|
||||
},
|
||||
{
|
||||
opacity: 0.4,
|
||||
x: '17.119',
|
||||
y: '136.962',
|
||||
width: '34.240',
|
||||
height: '16.8626',
|
||||
fill: '#FFCC02',
|
||||
transform: 'rotate(-90 17.119 136.962)',
|
||||
},
|
||||
{
|
||||
opacity: 1,
|
||||
x: '17.119',
|
||||
y: '136.962',
|
||||
width: '16.8626',
|
||||
height: '16.8626',
|
||||
fill: '#FFCC02',
|
||||
transform: 'rotate(-90 17.119 136.962)',
|
||||
},
|
||||
{
|
||||
opacity: 0.5,
|
||||
width: '34.240',
|
||||
height: '33.725',
|
||||
fill: '#00F701',
|
||||
transform: 'matrix(0 1 1 0 0.257 153.825)',
|
||||
},
|
||||
{
|
||||
opacity: 1,
|
||||
width: '16.8626',
|
||||
height: '16.8626',
|
||||
fill: '#00F701',
|
||||
transform: 'matrix(0 1 1 0 0.257 153.825)',
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* Rect data for the right-side position (right edge of screenshot).
|
||||
* Same two-column structure as left with rotated colours:
|
||||
* pink→blue, green→pink, yellow→green.
|
||||
*/
|
||||
const RIGHT_SIDE_RECTS: readonly BlockRect[] = [
|
||||
{
|
||||
opacity: 0.6,
|
||||
width: '34.240',
|
||||
height: '33.725',
|
||||
fill: '#2ABBF8',
|
||||
transform: 'matrix(0 1 1 0 0 0)',
|
||||
},
|
||||
{
|
||||
opacity: 0.6,
|
||||
width: '16.8626',
|
||||
height: '68.480',
|
||||
fill: '#2ABBF8',
|
||||
transform: 'matrix(-1 0 0 1 33.727 0)',
|
||||
},
|
||||
{
|
||||
opacity: 1,
|
||||
width: '16.8626',
|
||||
height: '16.8626',
|
||||
fill: '#2ABBF8',
|
||||
transform: 'matrix(-1 0 0 1 33.727 17.378)',
|
||||
},
|
||||
{
|
||||
opacity: 0.6,
|
||||
width: '16.8626',
|
||||
height: '33.986',
|
||||
fill: '#2ABBF8',
|
||||
transform: 'matrix(0 1 1 0 0 51.616)',
|
||||
},
|
||||
{
|
||||
opacity: 0.6,
|
||||
width: '16.8626',
|
||||
height: '140.507',
|
||||
fill: '#FA4EDF',
|
||||
transform: 'matrix(-1 0 0 1 33.986 85.335)',
|
||||
},
|
||||
{
|
||||
opacity: 0.4,
|
||||
x: '17.119',
|
||||
y: '136.962',
|
||||
width: '34.240',
|
||||
height: '16.8626',
|
||||
fill: '#00F701',
|
||||
transform: 'rotate(-90 17.119 136.962)',
|
||||
},
|
||||
{
|
||||
opacity: 1,
|
||||
x: '17.119',
|
||||
y: '136.962',
|
||||
width: '16.8626',
|
||||
height: '16.8626',
|
||||
fill: '#00F701',
|
||||
transform: 'rotate(-90 17.119 136.962)',
|
||||
},
|
||||
{
|
||||
opacity: 0.5,
|
||||
width: '34.240',
|
||||
height: '33.725',
|
||||
fill: '#FA4EDF',
|
||||
transform: 'matrix(0 1 1 0 0.257 153.825)',
|
||||
},
|
||||
{
|
||||
opacity: 1,
|
||||
width: '16.8626',
|
||||
height: '16.8626',
|
||||
fill: '#FA4EDF',
|
||||
transform: 'matrix(0 1 1 0 0.257 153.825)',
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* Rect data for the right-edge position (far right of screen).
|
||||
* Two-column vertical strip, ordered top-to-bottom.
|
||||
*/
|
||||
const RIGHT_RECTS: readonly BlockRect[] = [
|
||||
{
|
||||
opacity: 0.6,
|
||||
width: '16.8626',
|
||||
height: '33.726',
|
||||
fill: '#FA4EDF',
|
||||
transform: 'matrix(0 1 1 0 0 0)',
|
||||
},
|
||||
{
|
||||
opacity: 0.6,
|
||||
width: '34.241',
|
||||
height: '16.8626',
|
||||
fill: '#FA4EDF',
|
||||
transform: 'matrix(0 1 1 0 16.891 0)',
|
||||
},
|
||||
{
|
||||
opacity: 0.6,
|
||||
width: '16.8626',
|
||||
height: '68.482',
|
||||
fill: '#FA4EDF',
|
||||
transform: 'matrix(-1 0 0 1 33.739 16.888)',
|
||||
},
|
||||
{
|
||||
opacity: 0.6,
|
||||
width: '16.8626',
|
||||
height: '33.726',
|
||||
fill: '#FA4EDF',
|
||||
transform: 'matrix(0 1 1 0 0 33.776)',
|
||||
},
|
||||
{
|
||||
opacity: 1,
|
||||
width: '16.8626',
|
||||
height: '16.8626',
|
||||
fill: '#FA4EDF',
|
||||
transform: 'matrix(-1 0 0 1 33.739 34.272)',
|
||||
},
|
||||
{
|
||||
opacity: 0.6,
|
||||
width: '16.8626',
|
||||
height: '33.726',
|
||||
fill: '#FA4EDF',
|
||||
transform: 'matrix(0 1 1 0 0.012 68.510)',
|
||||
},
|
||||
{
|
||||
opacity: 0.6,
|
||||
width: '16.8626',
|
||||
height: '102.384',
|
||||
fill: '#2ABBF8',
|
||||
transform: 'matrix(-1 0 0 1 33.787 102.384)',
|
||||
},
|
||||
{
|
||||
opacity: 0.4,
|
||||
x: '17.131',
|
||||
y: '153.859',
|
||||
width: '34.241',
|
||||
height: '16.8626',
|
||||
fill: '#00F701',
|
||||
transform: 'rotate(-90 17.131 153.859)',
|
||||
},
|
||||
{
|
||||
opacity: 1,
|
||||
x: '17.131',
|
||||
y: '153.859',
|
||||
width: '16.8626',
|
||||
height: '16.8626',
|
||||
fill: '#00F701',
|
||||
transform: 'rotate(-90 17.131 153.859)',
|
||||
},
|
||||
]
|
||||
|
||||
/** Number of rects per position, used to compute animation durations. */
|
||||
const RECT_COUNTS: Record<BlockPosition, number> = {
|
||||
topRight: TOP_RIGHT_RECTS.length,
|
||||
topLeft: TOP_LEFT_RECTS.length,
|
||||
left: LEFT_RECTS.length,
|
||||
rightSide: RIGHT_SIDE_RECTS.length,
|
||||
rightEdge: RIGHT_RECTS.length,
|
||||
}
|
||||
|
||||
/** Total enter animation time for a position (seconds). */
|
||||
function enterTime(pos: BlockPosition): number {
|
||||
return (RECT_COUNTS[pos] - 1) * ENTER_STAGGER + ENTER_DURATION
|
||||
}
|
||||
|
||||
/** Total exit animation time for a position (seconds). */
|
||||
function exitTime(pos: BlockPosition): number {
|
||||
return (RECT_COUNTS[pos] - 1) * EXIT_STAGGER + EXIT_DURATION
|
||||
}
|
||||
|
||||
/** A single step in the repeating animation cycle. */
|
||||
type CycleStep =
|
||||
| { action: 'exit'; position: BlockPosition }
|
||||
| { action: 'enter'; position: BlockPosition }
|
||||
| { action: 'hold'; ms: number }
|
||||
|
||||
/**
|
||||
* The repeating cycle sequence. After all steps, the layout returns to its
|
||||
* initial state (topRight + left + rightEdge) so the loop is seamless.
|
||||
*
|
||||
* Order: exit top → exit right-edge → enter right-side-of-preview →
|
||||
* exit left → enter top-left → exit right-side → enter left →
|
||||
* exit top-left → enter top-right → enter right-edge → back to initial.
|
||||
*/
|
||||
const CYCLE_STEPS: readonly CycleStep[] = [
|
||||
{ action: 'exit', position: 'topRight' },
|
||||
{ action: 'exit', position: 'rightEdge' },
|
||||
{ action: 'enter', position: 'rightSide' },
|
||||
{ action: 'hold', ms: HOLD_BETWEEN_MS },
|
||||
{ action: 'exit', position: 'left' },
|
||||
{ action: 'enter', position: 'topLeft' },
|
||||
{ action: 'hold', ms: HOLD_BETWEEN_MS },
|
||||
{ action: 'exit', position: 'rightSide' },
|
||||
{ action: 'enter', position: 'left' },
|
||||
{ action: 'hold', ms: HOLD_BETWEEN_MS },
|
||||
{ action: 'exit', position: 'topLeft' },
|
||||
{ action: 'enter', position: 'topRight' },
|
||||
{ action: 'hold', ms: HOLD_BETWEEN_MS },
|
||||
{ action: 'enter', position: 'rightEdge' },
|
||||
{ action: 'hold', ms: HOLD_BETWEEN_MS },
|
||||
]
|
||||
|
||||
/**
|
||||
* Drives the block-cycling animation loop. Returns the current animation
|
||||
* state for every position so each component can be driven declaratively.
|
||||
*
|
||||
* Lifecycle:
|
||||
* 1. All three initial groups (topRight, left, rightEdge) enter together.
|
||||
* 2. After a hold period the cycle begins, processing each step in order.
|
||||
* 3. Repeats indefinitely, returning to the initial layout every cycle.
|
||||
*/
|
||||
export function useBlockCycle(): Record<BlockPosition, BlockAnimState> {
|
||||
const [states, setStates] = useState<Record<BlockPosition, BlockAnimState>>({
|
||||
topRight: 'entering',
|
||||
left: 'entering',
|
||||
rightEdge: 'entering',
|
||||
rightSide: 'hidden',
|
||||
topLeft: 'hidden',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const cancelled = { current: false }
|
||||
const delay = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
const run = async () => {
|
||||
const longestEnter = Math.max(
|
||||
enterTime('topRight'),
|
||||
enterTime('left'),
|
||||
enterTime('rightEdge')
|
||||
)
|
||||
await delay(longestEnter * 1000)
|
||||
if (cancelled.current) return
|
||||
|
||||
setStates({
|
||||
topRight: 'visible',
|
||||
left: 'visible',
|
||||
rightEdge: 'visible',
|
||||
rightSide: 'hidden',
|
||||
topLeft: 'hidden',
|
||||
})
|
||||
|
||||
await delay(INITIAL_HOLD_MS)
|
||||
if (cancelled.current) return
|
||||
|
||||
while (!cancelled.current) {
|
||||
for (const step of CYCLE_STEPS) {
|
||||
if (cancelled.current) return
|
||||
|
||||
if (step.action === 'exit') {
|
||||
setStates((prev) => ({ ...prev, [step.position]: 'exiting' }))
|
||||
await delay(exitTime(step.position) * 1000)
|
||||
if (cancelled.current) return
|
||||
setStates((prev) => ({ ...prev, [step.position]: 'hidden' }))
|
||||
await delay(TRANSITION_PAUSE_MS)
|
||||
} else if (step.action === 'enter') {
|
||||
setStates((prev) => ({ ...prev, [step.position]: 'entering' }))
|
||||
await delay(enterTime(step.position) * 1000)
|
||||
if (cancelled.current) return
|
||||
setStates((prev) => ({ ...prev, [step.position]: 'visible' }))
|
||||
await delay(TRANSITION_PAUSE_MS)
|
||||
} else {
|
||||
await delay(step.ms)
|
||||
}
|
||||
|
||||
if (cancelled.current) return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
run()
|
||||
return () => {
|
||||
cancelled.current = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
return states
|
||||
}
|
||||
|
||||
interface AnimatedBlockProps {
|
||||
animState?: BlockAnimState
|
||||
}
|
||||
|
||||
/** Two-row horizontal strip at the top-right of the hero. */
|
||||
export function BlocksTopRightAnimated({ animState = 'entering' }: AnimatedBlockProps) {
|
||||
return (
|
||||
<AnimatedBlocksSvg
|
||||
width={295}
|
||||
height={34}
|
||||
viewBox='0 0 295 34'
|
||||
rects={TOP_RIGHT_RECTS}
|
||||
animState={animState}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** Two-row horizontal strip at the top-left of the hero. */
|
||||
export function BlocksTopLeftAnimated({ animState = 'entering' }: AnimatedBlockProps) {
|
||||
return (
|
||||
<AnimatedBlocksSvg
|
||||
width={295}
|
||||
height={34}
|
||||
viewBox='0 0 295 34'
|
||||
rects={TOP_LEFT_RECTS}
|
||||
animState={animState}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** Two-column vertical strip on the left edge of the screenshot. */
|
||||
export function BlocksLeftAnimated({ animState = 'entering' }: AnimatedBlockProps) {
|
||||
return (
|
||||
<AnimatedBlocksSvg
|
||||
width={34}
|
||||
height={226}
|
||||
viewBox='0 0 34 226.021'
|
||||
rects={LEFT_RECTS}
|
||||
animState={animState}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** Two-column vertical strip on the right edge of the screenshot. */
|
||||
export function BlocksRightSideAnimated({ animState = 'entering' }: AnimatedBlockProps) {
|
||||
return (
|
||||
<AnimatedBlocksSvg
|
||||
width={34}
|
||||
height={226}
|
||||
viewBox='0 0 34 226.021'
|
||||
rects={RIGHT_SIDE_RECTS}
|
||||
animState={animState}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** Two-column vertical strip at the far-right edge of the screen. */
|
||||
export function BlocksRightAnimated({ animState = 'entering' }: AnimatedBlockProps) {
|
||||
return (
|
||||
<AnimatedBlocksSvg
|
||||
width={34}
|
||||
height={205}
|
||||
viewBox='0 0 34 204.769'
|
||||
rects={RIGHT_RECTS}
|
||||
animState={animState}
|
||||
/>
|
||||
)
|
||||
}
|
||||
135
apps/sim/app/(home)/components/hero/hero.tsx
Normal file
135
apps/sim/app/(home)/components/hero/hero.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
'use client'
|
||||
|
||||
import dynamic from 'next/dynamic'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { DemoRequestModal } from '@/app/(home)/components/demo-request/demo-request-modal'
|
||||
import {
|
||||
BlocksLeftAnimated,
|
||||
BlocksRightAnimated,
|
||||
BlocksRightSideAnimated,
|
||||
BlocksTopLeftAnimated,
|
||||
BlocksTopRightAnimated,
|
||||
useBlockCycle,
|
||||
} from '@/app/(home)/components/hero/components/animated-blocks'
|
||||
|
||||
const LandingPreview = dynamic(
|
||||
() =>
|
||||
import('@/app/(home)/components/landing-preview/landing-preview').then(
|
||||
(mod) => mod.LandingPreview
|
||||
),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <div className='aspect-[1116/549] w-full rounded bg-[var(--landing-bg)]' />,
|
||||
}
|
||||
)
|
||||
|
||||
/** Shared base classes for CTA link buttons — matches Deploy/Run button styling in the preview panel. */
|
||||
const CTA_BASE =
|
||||
'inline-flex items-center h-[32px] rounded-[5px] border px-2.5 font-[430] font-season text-sm'
|
||||
|
||||
export default function Hero() {
|
||||
const blockStates = useBlockCycle()
|
||||
|
||||
return (
|
||||
<section
|
||||
id='hero'
|
||||
aria-labelledby='hero-heading'
|
||||
className='relative flex flex-col items-center overflow-hidden bg-[var(--landing-bg)] pt-[60px] pb-3 lg:pt-[100px]'
|
||||
>
|
||||
<p className='sr-only'>
|
||||
Sim is the open-source platform to build AI agents and run your agentic workforce. Connect
|
||||
1,000+ integrations and LLMs — including OpenAI, Claude, Gemini, Mistral, and xAI — to
|
||||
deploy and orchestrate agentic workflows. Create agents, workflows, knowledge bases, tables,
|
||||
and docs. Trusted by over 100,000 builders at startups and Fortune 500 companies. SOC2 and
|
||||
SOC2 compliant.
|
||||
</p>
|
||||
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='pointer-events-none absolute top-[-0.7vw] left-[-2.8vw] z-0 aspect-[344/328] w-[23.9vw]'
|
||||
>
|
||||
<Image src='/landing/card-left.svg' alt='' fill className='object-contain' />
|
||||
</div>
|
||||
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='pointer-events-none absolute top-[-2.8vw] right-[-4vw] z-0 aspect-[471/470] w-[32.7vw]'
|
||||
>
|
||||
<Image src='/landing/card-right.svg' alt='' fill className='object-contain' />
|
||||
</div>
|
||||
|
||||
<div className='relative z-10 flex flex-col items-center gap-3'>
|
||||
<h1
|
||||
id='hero-heading'
|
||||
className='text-balance font-[430] font-season text-[36px] text-white leading-[100%] tracking-[-0.02em] sm:text-[48px] lg:text-[72px]'
|
||||
>
|
||||
Build AI Agents
|
||||
</h1>
|
||||
<p className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_60%,transparent)] text-base leading-[125%] tracking-[0.02em] lg:text-lg'>
|
||||
Sim is the AI Workspace for Agent Builders.
|
||||
</p>
|
||||
|
||||
<div className='mt-3 flex items-center gap-2'>
|
||||
<DemoRequestModal>
|
||||
<button
|
||||
type='button'
|
||||
className={`${CTA_BASE} border-[var(--landing-border-strong)] bg-transparent text-[var(--landing-text)] transition-colors hover:bg-[var(--landing-bg-elevated)]`}
|
||||
aria-label='Get a demo'
|
||||
>
|
||||
Get a demo
|
||||
</button>
|
||||
</DemoRequestModal>
|
||||
<Link
|
||||
href='/signup'
|
||||
className={`${CTA_BASE} gap-2 border-white bg-white text-black transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]`}
|
||||
aria-label='Get started with Sim'
|
||||
>
|
||||
Get started
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='pointer-events-none absolute top-0 right-[13.1vw] z-20 w-[calc(140px_+_10.76vw)] max-w-[295px]'
|
||||
>
|
||||
<BlocksTopRightAnimated animState={blockStates.topRight} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='pointer-events-none absolute top-0 left-[16vw] z-20 w-[calc(140px_+_10.76vw)] max-w-[295px]'
|
||||
>
|
||||
<BlocksTopLeftAnimated animState={blockStates.topLeft} />
|
||||
</div>
|
||||
|
||||
<div className='relative z-10 mx-auto mt-[3.2vw] w-[78.9vw] px-[1.4vw]'>
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='-translate-y-1/2 pointer-events-none absolute top-[50%] right-[calc(100%-1.41vw)] z-20 w-[calc(16px_+_1.25vw)] max-w-[34px]'
|
||||
>
|
||||
<BlocksLeftAnimated animState={blockStates.left} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='-translate-y-1/2 pointer-events-none absolute top-[50%] left-[calc(100%-1.41vw)] z-20 w-[calc(16px_+_1.25vw)] max-w-[34px] scale-x-[-1]'
|
||||
>
|
||||
<BlocksRightSideAnimated animState={blockStates.rightSide} />
|
||||
</div>
|
||||
|
||||
<div className='relative z-10 overflow-hidden rounded border border-[var(--landing-bg-elevated)]'>
|
||||
<LandingPreview />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='-translate-y-1/2 pointer-events-none absolute top-[50%] right-0 z-20 w-[calc(16px_+_1.25vw)] max-w-[34px]'
|
||||
>
|
||||
<BlocksRightAnimated animState={blockStates.rightEdge} />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
23
apps/sim/app/(home)/components/index.ts
Normal file
23
apps/sim/app/(home)/components/index.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import Collaboration from '@/app/(home)/components/collaboration/collaboration'
|
||||
import Enterprise from '@/app/(home)/components/enterprise/enterprise'
|
||||
import Features from '@/app/(home)/components/features/features'
|
||||
import Footer from '@/app/(home)/components/footer/footer'
|
||||
import Hero from '@/app/(home)/components/hero/hero'
|
||||
import Navbar from '@/app/(home)/components/navbar/navbar'
|
||||
import Pricing from '@/app/(home)/components/pricing/pricing'
|
||||
import StructuredData from '@/app/(home)/components/structured-data'
|
||||
import Templates from '@/app/(home)/components/templates/templates'
|
||||
import Testimonials from '@/app/(home)/components/testimonials/testimonials'
|
||||
|
||||
export {
|
||||
Collaboration,
|
||||
Enterprise,
|
||||
Features,
|
||||
Footer,
|
||||
Hero,
|
||||
Navbar,
|
||||
Pricing,
|
||||
StructuredData,
|
||||
Templates,
|
||||
Testimonials,
|
||||
}
|
||||
@@ -3,11 +3,11 @@ 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'
|
||||
} from '@/app/(home)/components/landing-preview/components/landing-preview-resource/landing-preview-resource'
|
||||
import {
|
||||
LandingPreviewResource,
|
||||
ownerCell,
|
||||
} from '@/app/(landing)/components/landing-preview/components/landing-preview-resource/landing-preview-resource'
|
||||
} from '@/app/(home)/components/landing-preview/components/landing-preview-resource/landing-preview-resource'
|
||||
|
||||
/** Generic audio/zip icon using basic SVG since no dedicated component exists */
|
||||
function AudioIcon({ className }: { className?: string }) {
|
||||
@@ -0,0 +1,99 @@
|
||||
'use client'
|
||||
|
||||
import { memo, useCallback, useRef, useState } from 'react'
|
||||
import { ArrowUp } from 'lucide-react'
|
||||
import { useLandingSubmit } from '@/app/(home)/components/landing-preview/components/landing-preview-panel/landing-preview-panel'
|
||||
import { useAnimatedPlaceholder } from '@/hooks/use-animated-placeholder'
|
||||
|
||||
const C = {
|
||||
SURFACE: '#292929',
|
||||
BORDER: '#3d3d3d',
|
||||
TEXT_PRIMARY: '#e6e6e6',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Landing preview replica of the workspace Home initial view.
|
||||
* Shows a greeting heading and a minimal chat input (no + or mic).
|
||||
* On submit, stores the prompt and redirects to /signup.
|
||||
*/
|
||||
export const LandingPreviewHome = memo(function LandingPreviewHome() {
|
||||
const landingSubmit = useLandingSubmit()
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const animatedPlaceholder = useAnimatedPlaceholder()
|
||||
|
||||
const isEmpty = inputValue.trim().length === 0
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (isEmpty) return
|
||||
landingSubmit(inputValue)
|
||||
}, [isEmpty, inputValue, landingSubmit])
|
||||
|
||||
const MAX_HEIGHT = 200
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSubmit()
|
||||
}
|
||||
},
|
||||
[handleSubmit]
|
||||
)
|
||||
|
||||
const handleInput = useCallback((e: React.FormEvent<HTMLTextAreaElement>) => {
|
||||
const target = e.target as HTMLTextAreaElement
|
||||
target.style.height = 'auto'
|
||||
target.style.height = `${Math.min(target.scrollHeight, MAX_HEIGHT)}px`
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className='flex min-w-0 flex-1 flex-col items-center justify-center px-6 pb-[2vh]'>
|
||||
<p
|
||||
role='presentation'
|
||||
className='mb-6 max-w-[42rem] font-[430] font-season text-[32px] tracking-[-0.02em]'
|
||||
style={{ color: C.TEXT_PRIMARY }}
|
||||
>
|
||||
What should we get done?
|
||||
</p>
|
||||
|
||||
<div className='w-full max-w-[32rem]'>
|
||||
<div
|
||||
className='cursor-text rounded-[20px] border px-2.5 py-2'
|
||||
style={{ borderColor: C.BORDER, backgroundColor: C.SURFACE }}
|
||||
onClick={() => textareaRef.current?.focus()}
|
||||
>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onInput={handleInput}
|
||||
placeholder={animatedPlaceholder}
|
||||
rows={1}
|
||||
className='m-0 box-border min-h-[24px] w-full resize-none overflow-y-auto border-0 bg-transparent px-1 py-1 font-body text-[15px] leading-[24px] tracking-[-0.015em] outline-none placeholder:font-[380] placeholder:text-[#787878] focus-visible:ring-0'
|
||||
style={{
|
||||
color: C.TEXT_PRIMARY,
|
||||
caretColor: C.TEXT_PRIMARY,
|
||||
maxHeight: `${MAX_HEIGHT}px`,
|
||||
}}
|
||||
/>
|
||||
<div className='flex items-center justify-end'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleSubmit}
|
||||
disabled={isEmpty}
|
||||
className='flex h-[28px] w-[28px] items-center justify-center rounded-full border-0 p-0 transition-colors'
|
||||
style={{
|
||||
background: isEmpty ? '#808080' : '#e0e0e0',
|
||||
cursor: isEmpty ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
>
|
||||
<ArrowUp size={16} strokeWidth={2.25} color='#1b1b1b' />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -13,8 +13,8 @@ import {
|
||||
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'
|
||||
} from '@/app/(home)/components/landing-preview/components/landing-preview-resource/landing-preview-resource'
|
||||
import { LandingPreviewResource } from '@/app/(home)/components/landing-preview/components/landing-preview-resource/landing-preview-resource'
|
||||
|
||||
const DB_ICON = <Database className='h-[14px] w-[14px]' />
|
||||
|
||||
@@ -59,7 +59,7 @@ const MOCK_LOGS: LogRow[] = [
|
||||
workflowColor: '#33C482',
|
||||
date: 'Apr 1 09:15 AM',
|
||||
status: 'error',
|
||||
cost: '1 credit',
|
||||
cost: '318 credits',
|
||||
trigger: 'api',
|
||||
triggerLabel: 'API',
|
||||
duration: '2.7s',
|
||||
@@ -70,7 +70,7 @@ const MOCK_LOGS: LogRow[] = [
|
||||
workflowColor: '#a855f7',
|
||||
date: 'Apr 1 08:30 AM',
|
||||
status: 'completed',
|
||||
cost: '2 credits',
|
||||
cost: '89 credits',
|
||||
trigger: 'schedule',
|
||||
triggerLabel: 'Schedule',
|
||||
duration: '0.8s',
|
||||
@@ -81,7 +81,7 @@ const MOCK_LOGS: LogRow[] = [
|
||||
workflowColor: '#f97316',
|
||||
date: 'Mar 31 10:14 PM',
|
||||
status: 'completed',
|
||||
cost: '7 credits',
|
||||
cost: '241 credits',
|
||||
trigger: 'webhook',
|
||||
triggerLabel: 'Webhook',
|
||||
duration: '4.1s',
|
||||
@@ -92,7 +92,7 @@ const MOCK_LOGS: LogRow[] = [
|
||||
workflowColor: '#ec4899',
|
||||
date: 'Mar 31 08:45 PM',
|
||||
status: 'completed',
|
||||
cost: '2 credits',
|
||||
cost: '112 credits',
|
||||
trigger: 'manual',
|
||||
triggerLabel: 'Manual',
|
||||
duration: '0.9s',
|
||||
@@ -103,7 +103,7 @@ const MOCK_LOGS: LogRow[] = [
|
||||
workflowColor: '#0ea5e9',
|
||||
date: 'Mar 31 07:22 PM',
|
||||
status: 'completed',
|
||||
cost: '3 credits',
|
||||
cost: '197 credits',
|
||||
trigger: 'api',
|
||||
triggerLabel: 'API',
|
||||
duration: '1.6s',
|
||||
@@ -114,7 +114,7 @@ const MOCK_LOGS: LogRow[] = [
|
||||
workflowColor: '#f59e0b',
|
||||
date: 'Mar 31 06:11 PM',
|
||||
status: 'error',
|
||||
cost: '1 credit',
|
||||
cost: '284 credits',
|
||||
trigger: 'schedule',
|
||||
triggerLabel: 'Schedule',
|
||||
duration: '3.2s',
|
||||
@@ -0,0 +1,169 @@
|
||||
'use client'
|
||||
|
||||
import { memo, useCallback, useRef, useState } from 'react'
|
||||
import { ArrowUp } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { BubbleChatPreview, ChevronDown, MoreHorizontal, Play } from '@/components/emcn'
|
||||
import { LandingPromptStorage } from '@/lib/core/utils/browser-storage'
|
||||
|
||||
/**
|
||||
* Stores the prompt in browser storage and redirects to /signup.
|
||||
* Shared by both the copilot panel and the landing home view.
|
||||
*/
|
||||
export function useLandingSubmit() {
|
||||
const router = useRouter()
|
||||
return useCallback(
|
||||
(text: string) => {
|
||||
const trimmed = text.trim()
|
||||
if (!trimmed) return
|
||||
LandingPromptStorage.store(trimmed)
|
||||
router.push('/signup')
|
||||
},
|
||||
[router]
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight static panel replicating the real workspace panel styling.
|
||||
* The copilot tab is active with a functional user input.
|
||||
* When submitted, stores the prompt and redirects to /signup (same as landing hero).
|
||||
*
|
||||
* Structure mirrors the real Panel component:
|
||||
* aside > div.border-l.pt-[14px] > Header(px-8) > Tabs(px-8,pt-14) > Content(pt-12)
|
||||
* inside Content > Copilot > header-bar(mx-[-1px]) > UserInput(p-8)
|
||||
*/
|
||||
export const LandingPreviewPanel = memo(function LandingPreviewPanel() {
|
||||
const landingSubmit = useLandingSubmit()
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const [cursorPos, setCursorPos] = useState<{ x: number; y: number } | null>(null)
|
||||
|
||||
const isEmpty = inputValue.trim().length === 0
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (isEmpty) return
|
||||
landingSubmit(inputValue)
|
||||
}, [isEmpty, inputValue, landingSubmit])
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSubmit()
|
||||
}
|
||||
},
|
||||
[handleSubmit]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className='flex h-full w-[280px] flex-shrink-0 flex-col bg-[#1e1e1e]'>
|
||||
<div className='flex h-full flex-col border-[#2c2c2c] border-l pt-3.5'>
|
||||
{/* Header — More + Chat | Deploy + Run */}
|
||||
<div className='flex flex-shrink-0 items-center justify-between px-2'>
|
||||
<div className='pointer-events-none flex gap-1.5'>
|
||||
<div className='flex h-[30px] w-[30px] items-center justify-center rounded-[5px] border border-[#3d3d3d] bg-[#363636]'>
|
||||
<MoreHorizontal className='h-[14px] w-[14px] text-[#e6e6e6]' />
|
||||
</div>
|
||||
<div className='flex h-[30px] w-[30px] items-center justify-center rounded-[5px] border border-[#3d3d3d] bg-[#363636]'>
|
||||
<BubbleChatPreview className='h-[14px] w-[14px] text-[#e6e6e6]' />
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href='/signup'
|
||||
className='flex gap-1.5'
|
||||
onMouseMove={(e) => setCursorPos({ x: e.clientX, y: e.clientY })}
|
||||
onMouseLeave={() => setCursorPos(null)}
|
||||
>
|
||||
<div className='flex h-[30px] items-center rounded-[5px] bg-[#33C482] px-2.5 transition-colors hover:bg-[#2DAC72]'>
|
||||
<span className='font-medium text-[#1b1b1b] text-[12px]'>Deploy</span>
|
||||
</div>
|
||||
<div className='flex h-[30px] items-center gap-2 rounded-[5px] bg-[#33C482] px-2.5 transition-colors hover:bg-[#2DAC72]'>
|
||||
<Play className='h-[11.5px] w-[11.5px] text-[#1b1b1b]' />
|
||||
<span className='font-medium text-[#1b1b1b] text-[12px]'>Run</span>
|
||||
</div>
|
||||
</Link>
|
||||
{cursorPos &&
|
||||
createPortal(
|
||||
<div
|
||||
className='pointer-events-none fixed z-[9999]'
|
||||
style={{ left: cursorPos.x + 14, top: cursorPos.y + 14 }}
|
||||
>
|
||||
{/* Decorative color bars — mirrors hero top-right block sequence */}
|
||||
<div className='flex h-[4px]'>
|
||||
<div className='h-full w-[8px] bg-[#2ABBF8]' />
|
||||
<div className='h-full w-[14px] bg-[#2ABBF8] opacity-60' />
|
||||
<div className='h-full w-[8px] bg-[#00F701]' />
|
||||
<div className='h-full w-[16px] bg-[#00F701] opacity-60' />
|
||||
<div className='h-full w-[8px] bg-[#FFCC02]' />
|
||||
<div className='h-full w-[10px] bg-[#FFCC02] opacity-60' />
|
||||
<div className='h-full w-[8px] bg-[#FA4EDF]' />
|
||||
<div className='h-full w-[14px] bg-[#FA4EDF] opacity-60' />
|
||||
</div>
|
||||
<div className='flex items-center gap-[5px] bg-white px-1.5 py-1 font-medium text-[#1C1C1C] text-[11px]'>
|
||||
Get started
|
||||
<ChevronDown className='-rotate-90 h-[7px] w-[7px] text-[#1C1C1C]' />
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className='flex flex-shrink-0 items-center px-2 pt-3.5'>
|
||||
<div className='pointer-events-none flex gap-1'>
|
||||
<div className='flex h-[28px] items-center rounded-[6px] border border-[#3d3d3d] bg-[#363636] px-2 py-[5px]'>
|
||||
<span className='font-medium text-[#e6e6e6] text-[12.5px]'>Copilot</span>
|
||||
</div>
|
||||
<div className='flex h-[28px] items-center rounded-[6px] border border-transparent px-2 py-[5px]'>
|
||||
<span className='font-medium text-[#787878] text-[12.5px]'>Toolbar</span>
|
||||
</div>
|
||||
<div className='flex h-[28px] items-center rounded-[6px] border border-transparent px-2 py-[5px]'>
|
||||
<span className='font-medium text-[#787878] text-[12.5px]'>Editor</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab content — copilot */}
|
||||
<div className='flex flex-1 flex-col overflow-hidden pt-3'>
|
||||
<div className='flex h-full flex-col'>
|
||||
{/* Copilot header bar — matches mx-[-1px] in real copilot */}
|
||||
<div className='pointer-events-none mx-[-1px] flex flex-shrink-0 items-center rounded-[4px] border border-[#2c2c2c] bg-[#292929] px-3 py-1.5'>
|
||||
<span className='truncate font-medium text-[#e6e6e6] text-[14px]'>New Chat</span>
|
||||
</div>
|
||||
|
||||
{/* User input — matches real UserInput at p-[8px] inside copilot welcome state */}
|
||||
<div className='px-2 pt-3 pb-2'>
|
||||
<div className='rounded-[4px] border border-[#3d3d3d] bg-[#292929] px-1.5 py-1.5'>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder='Build an AI agent...'
|
||||
rows={2}
|
||||
className='mb-1.5 min-h-[48px] w-full cursor-text resize-none border-0 bg-transparent px-0.5 py-1 font-base text-[#e6e6e6] text-sm leading-[1.25rem] placeholder-[#787878] caret-[#e6e6e6] outline-none'
|
||||
/>
|
||||
<div className='flex items-center justify-end'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleSubmit}
|
||||
disabled={isEmpty}
|
||||
className='flex h-[22px] w-[22px] items-center justify-center rounded-full border-0 p-0 transition-colors'
|
||||
style={{
|
||||
background: isEmpty ? '#808080' : '#e0e0e0',
|
||||
cursor: isEmpty ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
>
|
||||
<ArrowUp size={14} strokeWidth={2.25} color='#1b1b1b' />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -2,8 +2,8 @@ import { Calendar } from '@/components/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'
|
||||
} from '@/app/(home)/components/landing-preview/components/landing-preview-resource/landing-preview-resource'
|
||||
import { LandingPreviewResource } from '@/app/(home)/components/landing-preview/components/landing-preview-resource/landing-preview-resource'
|
||||
|
||||
const CAL_ICON = <Calendar className='h-[14px] w-[14px]' />
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
Table,
|
||||
} from '@/components/emcn/icons'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import type { PreviewWorkflow } from '@/app/(landing)/components/landing-preview/components/landing-preview-workflow/workflow-data'
|
||||
import type { PreviewWorkflow } from '@/app/(home)/components/landing-preview/components/landing-preview-workflow/workflow-data'
|
||||
|
||||
export type SidebarView =
|
||||
| 'home'
|
||||
@@ -1,7 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { useState } from 'react'
|
||||
import { Checkbox } from '@/components/emcn'
|
||||
import {
|
||||
ChevronDown,
|
||||
@@ -16,11 +15,11 @@ import { cn } from '@/lib/core/utils/cn'
|
||||
import type {
|
||||
PreviewColumn,
|
||||
PreviewRow,
|
||||
} from '@/app/(landing)/components/landing-preview/components/landing-preview-resource/landing-preview-resource'
|
||||
} from '@/app/(home)/components/landing-preview/components/landing-preview-resource/landing-preview-resource'
|
||||
import {
|
||||
LandingPreviewResource,
|
||||
ownerCell,
|
||||
} from '@/app/(landing)/components/landing-preview/components/landing-preview-resource/landing-preview-resource'
|
||||
} from '@/app/(home)/components/landing-preview/components/landing-preview-resource/landing-preview-resource'
|
||||
|
||||
const CELL = 'border-[var(--border)] border-r border-b px-2 py-[7px] align-middle select-none'
|
||||
const CELL_CHECKBOX =
|
||||
@@ -526,59 +525,28 @@ function SpreadsheetView({ tableId, tableName, onBack }: SpreadsheetViewProps) {
|
||||
)
|
||||
}
|
||||
|
||||
interface LandingPreviewTablesProps {
|
||||
autoOpenTableId?: string | null
|
||||
}
|
||||
|
||||
const tableViewTransition = {
|
||||
initial: { opacity: 0, x: 20 },
|
||||
animate: { opacity: 1, x: 0 },
|
||||
exit: { opacity: 0, x: -20 },
|
||||
transition: { duration: 0.25, ease: [0.16, 1, 0.3, 1] as const },
|
||||
} as const
|
||||
|
||||
export function LandingPreviewTables({ autoOpenTableId }: LandingPreviewTablesProps = {}) {
|
||||
export function LandingPreviewTables() {
|
||||
const [openTableId, setOpenTableId] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoOpenTableId) return
|
||||
const timer = setTimeout(() => {
|
||||
setOpenTableId(autoOpenTableId)
|
||||
}, 800)
|
||||
return () => clearTimeout(timer)
|
||||
}, [autoOpenTableId])
|
||||
if (openTableId !== null) {
|
||||
return (
|
||||
<SpreadsheetView
|
||||
tableId={openTableId}
|
||||
tableName={TABLE_METAS[openTableId] ?? 'Table'}
|
||||
onBack={() => setOpenTableId(null)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence mode='wait'>
|
||||
{openTableId !== null ? (
|
||||
<motion.div
|
||||
key={`spreadsheet-${openTableId}`}
|
||||
className='flex h-full flex-1 flex-col'
|
||||
{...tableViewTransition}
|
||||
>
|
||||
<SpreadsheetView
|
||||
tableId={openTableId}
|
||||
tableName={TABLE_METAS[openTableId] ?? 'Table'}
|
||||
onBack={() => setOpenTableId(null)}
|
||||
/>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.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)}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<LandingPreviewResource
|
||||
icon={Table}
|
||||
title='Tables'
|
||||
createLabel='New table'
|
||||
searchPlaceholder='Search tables...'
|
||||
columns={LIST_COLUMNS}
|
||||
rows={LIST_ROWS}
|
||||
onRowClick={(id) => setOpenTableId(id)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import ReactFlow, {
|
||||
applyEdgeChanges,
|
||||
@@ -16,24 +16,22 @@ import ReactFlow, {
|
||||
ReactFlowProvider,
|
||||
} from 'reactflow'
|
||||
import 'reactflow/dist/style.css'
|
||||
import { PreviewBlockNode } from '@/app/(landing)/components/landing-preview/components/landing-preview-workflow/preview-block-node'
|
||||
import { PreviewBlockNode } from '@/app/(home)/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'
|
||||
} from '@/app/(home)/components/landing-preview/components/landing-preview-workflow/workflow-data'
|
||||
|
||||
interface FitViewOptions {
|
||||
padding?: number
|
||||
maxZoom?: number
|
||||
minZoom?: number
|
||||
}
|
||||
|
||||
interface LandingPreviewWorkflowProps {
|
||||
workflow: PreviewWorkflow
|
||||
animate?: boolean
|
||||
fitViewOptions?: FitViewOptions
|
||||
highlightedBlockId?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -90,35 +88,21 @@ function PreviewEdge({
|
||||
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
|
||||
const DEFAULT_FIT_VIEW_OPTIONS = { padding: 0.3, 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,
|
||||
fitViewOptions,
|
||||
highlightedBlockId,
|
||||
}: LandingPreviewWorkflowProps) {
|
||||
function PreviewFlow({ workflow, animate = false, fitViewOptions }: LandingPreviewWorkflowProps) {
|
||||
const { nodes: initialNodes, edges: initialEdges } = useMemo(
|
||||
() => toReactFlowElements(workflow, animate, highlightedBlockId),
|
||||
[workflow, animate, highlightedBlockId]
|
||||
() => toReactFlowElements(workflow, animate),
|
||||
[workflow, animate]
|
||||
)
|
||||
|
||||
const [nodes, setNodes] = useState<Node[]>(initialNodes)
|
||||
const [edges, setEdges] = useState<Edge[]>(initialEdges)
|
||||
|
||||
useEffect(() => {
|
||||
setNodes((prev) =>
|
||||
prev.map((node) => ({
|
||||
...node,
|
||||
data: { ...node.data, isHighlighted: highlightedBlockId === node.id },
|
||||
}))
|
||||
)
|
||||
}, [highlightedBlockId])
|
||||
|
||||
const onNodesChange: OnNodesChange = useCallback(
|
||||
(changes) => setNodes((nds) => applyNodeChanges(changes, nds)),
|
||||
[]
|
||||
@@ -130,7 +114,6 @@ function PreviewFlow({
|
||||
)
|
||||
|
||||
const resolvedFitViewOptions = fitViewOptions ?? DEFAULT_FIT_VIEW_OPTIONS
|
||||
const minZoom = fitViewOptions?.minZoom ?? 0.5
|
||||
|
||||
return (
|
||||
<ReactFlow
|
||||
@@ -152,7 +135,6 @@ function PreviewFlow({
|
||||
preventScrolling={false}
|
||||
autoPanOnNodeDrag={false}
|
||||
proOptions={PRO_OPTIONS}
|
||||
minZoom={minZoom}
|
||||
fitView
|
||||
fitViewOptions={resolvedFitViewOptions}
|
||||
className='h-full w-full bg-[var(--landing-bg)]'
|
||||
@@ -169,17 +151,11 @@ export function LandingPreviewWorkflow({
|
||||
workflow,
|
||||
animate = false,
|
||||
fitViewOptions,
|
||||
highlightedBlockId,
|
||||
}: LandingPreviewWorkflowProps) {
|
||||
return (
|
||||
<div className='h-full w-full'>
|
||||
<ReactFlowProvider key={workflow.id}>
|
||||
<PreviewFlow
|
||||
workflow={workflow}
|
||||
animate={animate}
|
||||
fitViewOptions={fitViewOptions}
|
||||
highlightedBlockId={highlightedBlockId}
|
||||
/>
|
||||
<PreviewFlow workflow={workflow} animate={animate} fitViewOptions={fitViewOptions} />
|
||||
</ReactFlowProvider>
|
||||
</div>
|
||||
)
|
||||
@@ -39,7 +39,7 @@ import {
|
||||
BLOCK_STAGGER,
|
||||
EASE_OUT,
|
||||
type PreviewTool,
|
||||
} from '@/app/(landing)/components/landing-preview/components/landing-preview-workflow/workflow-data'
|
||||
} from '@/app/(home)/components/landing-preview/components/landing-preview-workflow/workflow-data'
|
||||
|
||||
/** Map block type strings to their icon components. */
|
||||
const BLOCK_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
@@ -105,7 +105,6 @@ interface PreviewBlockData {
|
||||
hideSourceHandle?: boolean
|
||||
index?: number
|
||||
animate?: boolean
|
||||
isHighlighted?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -138,7 +137,6 @@ export const PreviewBlockNode = memo(function PreviewBlockNode({
|
||||
hideSourceHandle,
|
||||
index = 0,
|
||||
animate = false,
|
||||
isHighlighted = false,
|
||||
} = data
|
||||
const Icon = BLOCK_ICONS[blockType]
|
||||
const delay = animate ? index * BLOCK_STAGGER : 0
|
||||
@@ -266,10 +264,6 @@ export const PreviewBlockNode = memo(function PreviewBlockNode({
|
||||
isConnectableEnd={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isHighlighted && (
|
||||
<div className='pointer-events-none absolute inset-0 z-40 rounded-lg ring-[#33b4ff] ring-[1.75px]' />
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
@@ -66,11 +66,7 @@ const IT_SERVICE_WORKFLOW: PreviewWorkflow = {
|
||||
bgColor: '#701ffc',
|
||||
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.',
|
||||
},
|
||||
{ title: 'System Prompt', value: 'Triage incoming IT...' },
|
||||
],
|
||||
tools: [{ name: 'Knowledge Base', type: 'knowledge_base', bgColor: '#10B981' }],
|
||||
position: { x: 420, y: 40 },
|
||||
@@ -95,7 +91,7 @@ const IT_SERVICE_WORKFLOW: PreviewWorkflow = {
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-healing CRM workflow — Schedule -> Agent
|
||||
* Self-healing CRM workflow — Schedule -> Mothership
|
||||
*/
|
||||
const SELF_HEALING_CRM_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'wf-self-healing-crm',
|
||||
@@ -115,27 +111,20 @@ const SELF_HEALING_CRM_WORKFLOW: PreviewWorkflow = {
|
||||
hideTargetHandle: true,
|
||||
},
|
||||
{
|
||||
id: 'agent-crm',
|
||||
id: 'mothership-1',
|
||||
name: 'CRM Agent',
|
||||
type: 'agent',
|
||||
bgColor: '#701ffc',
|
||||
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.',
|
||||
},
|
||||
],
|
||||
type: 'mothership',
|
||||
bgColor: '#33C482',
|
||||
rows: [{ title: 'Prompt', value: 'Audit CRM records, fix...' }],
|
||||
tools: [
|
||||
{ name: 'HubSpot', type: 'hubspot', bgColor: '#FF7A59' },
|
||||
{ name: 'Salesforce', type: 'salesforce', bgColor: '#E0E0E0' },
|
||||
],
|
||||
position: { x: 420, y: 140 },
|
||||
position: { x: 420, y: 180 },
|
||||
hideSourceHandle: true,
|
||||
},
|
||||
],
|
||||
edges: [{ id: 'e-3', source: 'schedule-1', target: 'agent-crm' }],
|
||||
edges: [{ id: 'e-3', source: 'schedule-1', target: 'mothership-1' }],
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -165,11 +154,7 @@ const CUSTOMER_SUPPORT_WORKFLOW: PreviewWorkflow = {
|
||||
bgColor: '#701ffc',
|
||||
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.',
|
||||
},
|
||||
{ title: 'System Prompt', value: 'Resolve customer issues...' },
|
||||
],
|
||||
tools: [
|
||||
{ name: 'Knowledge', type: 'knowledge_base', bgColor: '#10B981' },
|
||||
@@ -243,8 +228,7 @@ const EDGE_STYLE = { stroke: '#454545', strokeWidth: 1.5 } as const
|
||||
*/
|
||||
export function toReactFlowElements(
|
||||
workflow: PreviewWorkflow,
|
||||
animate = false,
|
||||
highlightedBlockId?: string | null
|
||||
animate = false
|
||||
): {
|
||||
nodes: Node[]
|
||||
edges: Edge[]
|
||||
@@ -266,7 +250,6 @@ export function toReactFlowElements(
|
||||
hideSourceHandle: block.hideSourceHandle,
|
||||
index,
|
||||
animate,
|
||||
isHighlighted: highlightedBlockId === block.id,
|
||||
},
|
||||
draggable: true,
|
||||
selectable: false,
|
||||
@@ -295,74 +278,3 @@ export function toReactFlowElements(
|
||||
|
||||
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
|
||||
const promptRow = block.rows.find((r) => r.title === 'Prompt' || r.title === 'System Prompt')
|
||||
if (promptRow) {
|
||||
const modelRow = block.rows.find((r) => r.title === 'Model')
|
||||
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) }
|
||||
}
|
||||
|
||||
/** 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,147 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { motion, type Variants } from 'framer-motion'
|
||||
import { LandingPreviewFiles } from '@/app/(home)/components/landing-preview/components/landing-preview-files/landing-preview-files'
|
||||
import { LandingPreviewHome } from '@/app/(home)/components/landing-preview/components/landing-preview-home/landing-preview-home'
|
||||
import { LandingPreviewKnowledge } from '@/app/(home)/components/landing-preview/components/landing-preview-knowledge/landing-preview-knowledge'
|
||||
import { LandingPreviewLogs } from '@/app/(home)/components/landing-preview/components/landing-preview-logs/landing-preview-logs'
|
||||
import { LandingPreviewPanel } from '@/app/(home)/components/landing-preview/components/landing-preview-panel/landing-preview-panel'
|
||||
import { LandingPreviewScheduledTasks } from '@/app/(home)/components/landing-preview/components/landing-preview-scheduled-tasks/landing-preview-scheduled-tasks'
|
||||
import type { SidebarView } from '@/app/(home)/components/landing-preview/components/landing-preview-sidebar/landing-preview-sidebar'
|
||||
import { LandingPreviewSidebar } from '@/app/(home)/components/landing-preview/components/landing-preview-sidebar/landing-preview-sidebar'
|
||||
import { LandingPreviewTables } from '@/app/(home)/components/landing-preview/components/landing-preview-tables/landing-preview-tables'
|
||||
import { LandingPreviewWorkflow } from '@/app/(home)/components/landing-preview/components/landing-preview-workflow/landing-preview-workflow'
|
||||
import {
|
||||
EASE_OUT,
|
||||
PREVIEW_WORKFLOWS,
|
||||
} from '@/app/(home)/components/landing-preview/components/landing-preview-workflow/workflow-data'
|
||||
|
||||
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 panelVariants: 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 },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Interactive workspace preview for the hero section.
|
||||
*
|
||||
* Renders a lightweight replica of the Sim workspace with:
|
||||
* - A sidebar with selectable workflows and workspace nav items
|
||||
* - A ReactFlow canvas showing the active workflow's blocks and edges
|
||||
* - Static previews of Tables, Files, Knowledge Base, Logs, and Scheduled Tasks
|
||||
* - A panel with a functional copilot input (stores prompt + redirects to /signup)
|
||||
*
|
||||
* Only workflow items, the home button, workspace nav items, and the copilot input
|
||||
* are interactive. Animations only fire on initial load.
|
||||
*/
|
||||
export function LandingPreview() {
|
||||
const [activeView, setActiveView] = useState<SidebarView>('workflow')
|
||||
const [activeWorkflowId, setActiveWorkflowId] = useState(PREVIEW_WORKFLOWS[0].id)
|
||||
const isInitialMount = useRef(true)
|
||||
|
||||
useEffect(() => {
|
||||
isInitialMount.current = false
|
||||
}, [])
|
||||
|
||||
const handleSelectWorkflow = useCallback((id: string) => {
|
||||
setActiveWorkflowId(id)
|
||||
setActiveView('workflow')
|
||||
}, [])
|
||||
|
||||
const handleSelectHome = useCallback(() => {
|
||||
setActiveView('home')
|
||||
}, [])
|
||||
|
||||
const handleSelectNav = useCallback((id: SidebarView) => {
|
||||
setActiveView(id)
|
||||
}, [])
|
||||
|
||||
const activeWorkflow =
|
||||
PREVIEW_WORKFLOWS.find((w) => w.id === activeWorkflowId) ?? PREVIEW_WORKFLOWS[0]
|
||||
|
||||
const isWorkflowView = activeView === 'workflow'
|
||||
|
||||
function renderContent() {
|
||||
switch (activeView) {
|
||||
case 'workflow':
|
||||
return <LandingPreviewWorkflow workflow={activeWorkflow} animate={isInitialMount.current} />
|
||||
case 'home':
|
||||
return <LandingPreviewHome />
|
||||
case 'tables':
|
||||
return <LandingPreviewTables />
|
||||
case 'files':
|
||||
return <LandingPreviewFiles />
|
||||
case 'knowledge':
|
||||
return <LandingPreviewKnowledge />
|
||||
case 'logs':
|
||||
return <LandingPreviewLogs />
|
||||
case 'scheduled-tasks':
|
||||
return <LandingPreviewScheduledTasks />
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className='dark flex aspect-[1116/549] w-full overflow-hidden rounded bg-[var(--landing-bg-surface)] antialiased'
|
||||
initial='hidden'
|
||||
animate='visible'
|
||||
variants={containerVariants}
|
||||
>
|
||||
<motion.div className='hidden lg:flex' variants={sidebarVariants}>
|
||||
<LandingPreviewSidebar
|
||||
workflows={PREVIEW_WORKFLOWS}
|
||||
activeWorkflowId={activeWorkflowId}
|
||||
activeView={activeView}
|
||||
onSelectWorkflow={handleSelectWorkflow}
|
||||
onSelectHome={handleSelectHome}
|
||||
onSelectNav={handleSelectNav}
|
||||
/>
|
||||
</motion.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-[8px] border border-[#2c2c2c] bg-[var(--landing-bg)]'>
|
||||
<div
|
||||
className={
|
||||
isWorkflowView
|
||||
? 'relative min-w-0 flex-1 overflow-hidden'
|
||||
: 'relative flex min-w-0 flex-1 flex-col overflow-hidden'
|
||||
}
|
||||
>
|
||||
{renderContent()}
|
||||
</div>
|
||||
<motion.div
|
||||
className={isWorkflowView ? 'hidden lg:flex' : 'hidden'}
|
||||
variants={panelVariants}
|
||||
>
|
||||
<LandingPreviewPanel />
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -44,9 +44,9 @@ function BlogCard({
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
<div className='flex-shrink-0 px-2.5 py-2'>
|
||||
<div className='flex-shrink-0 px-2.5 py-1.5'>
|
||||
<span
|
||||
className='block truncate font-[430] font-season text-[var(--landing-text-body)] leading-[140%]'
|
||||
className='font-[430] font-season text-[var(--landing-text-body)] leading-[140%]'
|
||||
style={{ fontSize: titleSize }}
|
||||
>
|
||||
{title}
|
||||
@@ -66,7 +66,7 @@ export function BlogDropdown({ posts }: BlogDropdownProps) {
|
||||
if (!featured) return null
|
||||
|
||||
return (
|
||||
<div className='w-[560px] rounded-[5px] border border-[var(--landing-bg-elevated)] bg-[var(--landing-bg)] p-2 shadow-overlay'>
|
||||
<div className='w-[560px] rounded-[5px] border border-[var(--landing-bg-elevated)] bg-[var(--landing-bg)] p-4 shadow-overlay'>
|
||||
<div className='grid grid-cols-3 gap-2'>
|
||||
<BlogCard
|
||||
slug={featured.slug}
|
||||
@@ -37,8 +37,8 @@ const RESOURCE_CARDS = [
|
||||
|
||||
export function DocsDropdown() {
|
||||
return (
|
||||
<div className='w-[480px] rounded-[5px] border border-[var(--landing-bg-elevated)] bg-[var(--landing-bg)] p-2 shadow-overlay'>
|
||||
<div className='grid grid-cols-2 gap-2'>
|
||||
<div className='w-[480px] rounded-[5px] border border-[var(--landing-bg-elevated)] bg-[var(--landing-bg)] p-4 shadow-overlay'>
|
||||
<div className='grid grid-cols-2 gap-2.5'>
|
||||
{PREVIEW_CARDS.map((card) => (
|
||||
<a
|
||||
key={card.title}
|
||||
@@ -3,11 +3,11 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { GithubOutlineIcon } from '@/components/icons'
|
||||
import { getFormattedGitHubStars } from '@/app/(landing)/actions/github'
|
||||
import { getFormattedGitHubStars } from '@/app/(home)/actions/github'
|
||||
|
||||
const logger = createLogger('github-stars')
|
||||
|
||||
const INITIAL_STARS = '27.6k'
|
||||
const INITIAL_STARS = '27k'
|
||||
|
||||
/**
|
||||
* Client component that displays GitHub stars count.
|
||||
@@ -31,7 +31,7 @@ export function GitHubStars() {
|
||||
href='https://github.com/simstudioai/sim'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='flex h-[30px] items-center gap-2 self-center rounded-[5px] px-3 transition-colors duration-200 group-hover:bg-[var(--landing-bg-elevated)]'
|
||||
className='flex items-center gap-2 px-3'
|
||||
aria-label={`GitHub repository — ${stars} stars`}
|
||||
>
|
||||
<GithubOutlineIcon className='h-[14px] w-[14px]' />
|
||||
@@ -10,9 +10,9 @@ import { cn } from '@/lib/core/utils/cn'
|
||||
import {
|
||||
BlogDropdown,
|
||||
type NavBlogPost,
|
||||
} from '@/app/(landing)/components/navbar/components/blog-dropdown'
|
||||
import { DocsDropdown } from '@/app/(landing)/components/navbar/components/docs-dropdown'
|
||||
import { GitHubStars } from '@/app/(landing)/components/navbar/components/github-stars'
|
||||
} from '@/app/(home)/components/navbar/components/blog-dropdown'
|
||||
import { DocsDropdown } from '@/app/(home)/components/navbar/components/docs-dropdown'
|
||||
import { GitHubStars } from '@/app/(home)/components/navbar/components/github-stars'
|
||||
import { getBrandConfig } from '@/ee/whitelabeling'
|
||||
|
||||
type DropdownId = 'docs' | 'blog' | null
|
||||
@@ -29,9 +29,10 @@ const NAV_LINKS: NavLink[] = [
|
||||
{ label: 'Docs', href: 'https://docs.sim.ai', external: true, icon: 'chevron', dropdown: 'docs' },
|
||||
{ label: 'Blog', href: '/blog', icon: 'chevron', dropdown: 'blog' },
|
||||
{ label: 'Pricing', href: '/#pricing' },
|
||||
{ label: 'Enterprise', href: 'https://form.typeform.com/to/jqCO12pF', external: true },
|
||||
]
|
||||
|
||||
const LOGO_CELL = 'flex items-center pl-5 lg:pl-16 pr-5'
|
||||
const LOGO_CELL = 'flex items-center pl-5 lg:pl-20 pr-5'
|
||||
const LINK_CELL = 'flex items-center px-3.5'
|
||||
|
||||
interface NavbarProps {
|
||||
@@ -48,6 +49,7 @@ export default function Navbar({ logoOnly = false, blogPosts = [] }: NavbarProps
|
||||
const useHomeLinks = isAuthenticated || isBrowsingHome
|
||||
const logoHref = useHomeLinks ? '/?home' : '/'
|
||||
const [activeDropdown, setActiveDropdown] = useState<DropdownId>(null)
|
||||
const [hoveredLink, setHoveredLink] = useState<string | null>(null)
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
@@ -89,10 +91,12 @@ export default function Navbar({ logoOnly = false, blogPosts = [] }: NavbarProps
|
||||
return () => mq.removeEventListener('change', handler)
|
||||
}, [])
|
||||
|
||||
const anyHighlighted = activeDropdown !== null || hoveredLink !== null
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label='Primary navigation'
|
||||
className='relative flex h-[58px] border-[var(--landing-bg-elevated)] border-b-[1px] bg-[var(--landing-bg)] font-[430] font-season text-[var(--landing-text)] text-sm'
|
||||
className='relative flex h-[52px] border-[var(--landing-bg-elevated)] border-b-[1px] bg-[var(--landing-bg)] font-[430] font-season text-[var(--landing-text)] text-sm'
|
||||
itemScope
|
||||
itemType='https://schema.org/SiteNavigationElement'
|
||||
>
|
||||
@@ -130,9 +134,13 @@ export default function Navbar({ logoOnly = false, blogPosts = [] }: NavbarProps
|
||||
useHomeLinks && rawHref.startsWith('/#') ? `/?home${rawHref.slice(1)}` : rawHref
|
||||
const hasDropdown = !!dropdown
|
||||
const isActive = hasDropdown && activeDropdown === dropdown
|
||||
const isThisHovered = hoveredLink === label
|
||||
const isHighlighted = isActive || isThisHovered
|
||||
const isDimmed = anyHighlighted && !isHighlighted
|
||||
const linkClass = cn(
|
||||
icon ? `${LINK_CELL} gap-2` : LINK_CELL,
|
||||
'h-[30px] self-center rounded-[5px] transition-colors duration-200 group-hover:bg-[var(--landing-bg-elevated)]'
|
||||
'transition-colors duration-200',
|
||||
isDimmed && 'text-[color-mix(in_srgb,var(--landing-text-subtle)_60%,transparent)]'
|
||||
)
|
||||
const chevron = icon === 'chevron' && <NavChevron open={isActive} />
|
||||
|
||||
@@ -140,7 +148,7 @@ export default function Navbar({ logoOnly = false, blogPosts = [] }: NavbarProps
|
||||
return (
|
||||
<li
|
||||
key={label}
|
||||
className='group relative flex'
|
||||
className='relative flex'
|
||||
onMouseEnter={() => openDropdown(dropdown)}
|
||||
onMouseLeave={scheduleClose}
|
||||
>
|
||||
@@ -149,44 +157,51 @@ export default function Navbar({ logoOnly = false, blogPosts = [] }: NavbarProps
|
||||
href={href}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
itemProp='url'
|
||||
className={cn(linkClass, 'cursor-pointer')}
|
||||
className={cn(linkClass, 'h-full cursor-pointer')}
|
||||
>
|
||||
{label}
|
||||
{chevron}
|
||||
</a>
|
||||
) : (
|
||||
<Link href={href} itemProp='url' className={cn(linkClass, 'cursor-pointer')}>
|
||||
<Link href={href} className={cn(linkClass, 'h-full cursor-pointer')}>
|
||||
{label}
|
||||
{chevron}
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{isActive && (
|
||||
<div className='-mt-0.5 pointer-events-auto absolute top-full left-0 z-50'>
|
||||
{dropdown === 'docs' && <DocsDropdown />}
|
||||
{dropdown === 'blog' && <BlogDropdown posts={blogPosts} />}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'-mt-0.5 absolute top-full left-0 z-50',
|
||||
isActive
|
||||
? 'pointer-events-auto opacity-100'
|
||||
: 'pointer-events-none opacity-0'
|
||||
)}
|
||||
style={{
|
||||
transform: isActive ? 'translateY(0)' : 'translateY(-6px)',
|
||||
transition: 'opacity 200ms ease, transform 200ms ease',
|
||||
}}
|
||||
>
|
||||
{dropdown === 'docs' && <DocsDropdown />}
|
||||
{dropdown === 'blog' && <BlogDropdown posts={blogPosts} />}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<li key={label} className='group flex'>
|
||||
<li
|
||||
key={label}
|
||||
className='flex'
|
||||
onMouseEnter={() => setHoveredLink(label)}
|
||||
onMouseLeave={() => setHoveredLink(null)}
|
||||
>
|
||||
{external ? (
|
||||
<a
|
||||
href={href}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
itemProp='url'
|
||||
className={linkClass}
|
||||
>
|
||||
<a href={href} target='_blank' rel='noopener noreferrer' className={linkClass}>
|
||||
{label}
|
||||
{chevron}
|
||||
</a>
|
||||
) : (
|
||||
<Link href={href} itemProp='url' className={linkClass} aria-label={label}>
|
||||
<Link href={href} className={linkClass} aria-label={label}>
|
||||
{label}
|
||||
{chevron}
|
||||
</Link>
|
||||
@@ -194,7 +209,14 @@ export default function Navbar({ logoOnly = false, blogPosts = [] }: NavbarProps
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
<li className='group flex'>
|
||||
<li
|
||||
className={cn(
|
||||
'flex transition-opacity duration-200',
|
||||
anyHighlighted && hoveredLink !== 'github' && 'opacity-60'
|
||||
)}
|
||||
onMouseEnter={() => setHoveredLink('github')}
|
||||
onMouseLeave={() => setHoveredLink(null)}
|
||||
>
|
||||
<GitHubStars />
|
||||
</li>
|
||||
</ul>
|
||||
@@ -203,7 +225,7 @@ export default function Navbar({ logoOnly = false, blogPosts = [] }: NavbarProps
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'hidden items-center gap-2 pr-16 pl-5 lg:flex',
|
||||
'hidden items-center gap-2 pr-20 pl-5 lg:flex',
|
||||
isSessionPending && 'invisible'
|
||||
)}
|
||||
>
|
||||
@@ -249,7 +271,7 @@ export default function Navbar({ logoOnly = false, blogPosts = [] }: NavbarProps
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'fixed inset-x-0 top-[58px] bottom-0 z-50 flex flex-col overflow-y-auto bg-[var(--landing-bg)] font-[430] font-season text-sm transition-all duration-200 lg:hidden',
|
||||
'fixed inset-x-0 top-[52px] bottom-0 z-50 flex flex-col overflow-y-auto bg-[var(--landing-bg)] font-[430] font-season text-sm transition-all duration-200 lg:hidden',
|
||||
mobileMenuOpen ? 'visible opacity-100' : 'invisible opacity-0'
|
||||
)}
|
||||
>
|
||||
@@ -1,8 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { Badge } from '@/components/emcn'
|
||||
import { DemoRequestModal } from '@/app/(landing)/components/demo-request/demo-request-modal'
|
||||
import { DemoRequestModal } from '@/app/(home)/components/demo-request/demo-request-modal'
|
||||
|
||||
interface PricingTier {
|
||||
id: string
|
||||
@@ -85,7 +83,7 @@ const PRICING_TIERS: PricingTier[] = [
|
||||
'SSO & SCIM · SOC2',
|
||||
'Self hosting · Dedicated support',
|
||||
],
|
||||
cta: { label: 'Get a demo', action: 'demo-request' },
|
||||
cta: { label: 'Book a demo', action: 'demo-request' },
|
||||
},
|
||||
]
|
||||
|
||||
@@ -112,21 +110,7 @@ function PricingCard({ tier }: PricingCardProps) {
|
||||
const isPro = tier.id === 'pro'
|
||||
|
||||
return (
|
||||
<article
|
||||
className='flex flex-1 flex-col'
|
||||
aria-labelledby={`${tier.id}-heading`}
|
||||
itemScope
|
||||
itemType='https://schema.org/Offer'
|
||||
>
|
||||
<meta itemProp='name' content={`${tier.name} Plan`} />
|
||||
<meta
|
||||
itemProp='price'
|
||||
content={
|
||||
tier.price === 'Free' ? '0' : tier.price === 'Custom' ? '' : tier.price.replace('$', '')
|
||||
}
|
||||
/>
|
||||
<meta itemProp='priceCurrency' content='USD' />
|
||||
<meta itemProp='availability' content='https://schema.org/InStock' />
|
||||
<article className='flex flex-1 flex-col' aria-labelledby={`${tier.id}-heading`}>
|
||||
<div className='flex flex-1 flex-col gap-6 rounded-t-lg border border-[var(--landing-border-light)] border-b-0 bg-white p-5'>
|
||||
<div className='flex flex-col'>
|
||||
<h3
|
||||
@@ -212,7 +196,7 @@ export default function Pricing() {
|
||||
aria-labelledby='pricing-heading'
|
||||
className='bg-[var(--landing-bg-section)]'
|
||||
>
|
||||
<div className='px-4 pt-[60px] pb-[60px] sm:px-8 sm:pt-20 sm:pb-20 md:px-16 md:pt-[100px] md:pb-[100px]'>
|
||||
<div className='px-4 pt-[60px] pb-10 sm:px-8 sm:pt-20 sm:pb-0 md:px-20 md:pt-[100px]'>
|
||||
<div className='flex flex-col items-start gap-3 sm:gap-4 md:gap-5'>
|
||||
<Badge
|
||||
variant='blue'
|
||||
@@ -229,12 +213,6 @@ export default function Pricing() {
|
||||
>
|
||||
Pricing
|
||||
</h2>
|
||||
<p className='sr-only'>
|
||||
Sim pricing: Community plan is free with 1,000 credits and 5GB storage. Pro plan is $25
|
||||
per month with 6,000 credits and 50GB storage. Max plan is $100 per month with 25,000
|
||||
credits and 500GB storage. Enterprise pricing is custom with SSO, SCIM, SOC2 compliance,
|
||||
self-hosting, and dedicated support. All plans include CLI, SDK, and MCP access.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='mt-8 grid grid-cols-1 gap-4 sm:mt-10 sm:grid-cols-2 md:mt-12 lg:grid-cols-4'>
|
||||
@@ -4,7 +4,7 @@
|
||||
* Renders a `<script type="application/ld+json">` with Schema.org markup.
|
||||
* Single source of truth for machine-readable page metadata.
|
||||
*
|
||||
* Schemas: Organization, WebSite, WebPage, BreadcrumbList, WebApplication, SoftwareSourceCode, FAQPage.
|
||||
* Schemas: Organization, WebSite, WebPage, BreadcrumbList, WebApplication, FAQPage.
|
||||
*
|
||||
* AI crawler behavior (2025-2026):
|
||||
* - Google AI Overviews / Bing Copilot parse JSON-LD from their search indexes.
|
||||
@@ -170,15 +170,6 @@ export default function StructuredData() {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
'@type': 'SoftwareSourceCode',
|
||||
'@id': 'https://sim.ai/#source',
|
||||
codeRepository: 'https://github.com/simstudioai/sim',
|
||||
programmingLanguage: ['TypeScript', 'Python'],
|
||||
runtimePlatform: 'Node.js',
|
||||
license: 'https://opensource.org/licenses/AGPL-3.0',
|
||||
isPartOf: { '@id': 'https://sim.ai/#software' },
|
||||
},
|
||||
{
|
||||
'@type': 'FAQPage',
|
||||
'@id': 'https://sim.ai/#faq',
|
||||
@@ -223,30 +214,6 @@ export default function StructuredData() {
|
||||
text: 'Sim offers SOC2 compliance, SSO/SAML authentication, role-based access control, audit logs, dedicated support, custom SLAs, and on-premise deployment options for enterprise customers.',
|
||||
},
|
||||
},
|
||||
{
|
||||
'@type': 'Question',
|
||||
name: 'Is Sim open source?',
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: 'Yes. Sim is fully open source under the AGPL-3.0 license. The source code is available on GitHub at github.com/simstudioai/sim. You can self-host Sim or use the hosted version at sim.ai.',
|
||||
},
|
||||
},
|
||||
{
|
||||
'@type': 'Question',
|
||||
name: 'What integrations does Sim support?',
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: 'Sim supports 1,000+ integrations including Slack, Gmail, GitHub, Notion, Airtable, Supabase, HubSpot, Salesforce, Jira, Linear, Google Drive, Google Sheets, Confluence, Discord, Microsoft Teams, Outlook, Telegram, Stripe, Pinecone, and Firecrawl. New integrations are added regularly.',
|
||||
},
|
||||
},
|
||||
{
|
||||
'@type': 'Question',
|
||||
name: 'Can I self-host Sim?',
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: 'Yes. Sim can be self-hosted using Docker. Documentation is available at docs.sim.ai/self-hosting. Enterprise customers can also get dedicated infrastructure and on-premise deployment.',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PreviewWorkflow } from '@/app/(landing)/components/landing-preview/components/landing-preview-workflow/workflow-data'
|
||||
import type { PreviewWorkflow } from '@/app/(home)/components/landing-preview/components/landing-preview-workflow/workflow-data'
|
||||
|
||||
/**
|
||||
* OCR Invoice to DB — Start → Agent (Textract) → Supabase
|
||||
@@ -1,21 +1,21 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { AnimatePresence, type MotionValue, motion, useScroll, useTransform } from 'framer-motion'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Badge, ChevronDown } from '@/components/emcn'
|
||||
import { LandingWorkflowSeedStorage } from '@/lib/core/utils/browser-storage'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { TEMPLATE_WORKFLOWS } from '@/app/(landing)/components/templates/template-workflows'
|
||||
import { TEMPLATE_WORKFLOWS } from '@/app/(home)/components/templates/template-workflows'
|
||||
|
||||
const logger = createLogger('LandingTemplates')
|
||||
|
||||
const LandingPreviewWorkflow = dynamic(
|
||||
() =>
|
||||
import(
|
||||
'@/app/(landing)/components/landing-preview/components/landing-preview-workflow/landing-preview-workflow'
|
||||
'@/app/(home)/components/landing-preview/components/landing-preview-workflow/landing-preview-workflow'
|
||||
).then((mod) => mod.LandingPreviewWorkflow),
|
||||
{
|
||||
ssr: false,
|
||||
@@ -236,6 +236,73 @@ const DEPTH_CONFIGS: Record<string, DepthConfig> = {
|
||||
},
|
||||
}
|
||||
|
||||
const SCROLL_BLOCK_RX = '2.59574'
|
||||
|
||||
/**
|
||||
* Two-row horizontal block strip for the scroll-driven reveal in the templates section.
|
||||
* Same structural pattern as the hero's top-right blocks with matching colours:
|
||||
* blue (left) → pink (middle) → green (right).
|
||||
*/
|
||||
const SCROLL_BLOCK_RECTS = [
|
||||
{ opacity: 0.6, x: '-34.24', y: '0', width: '34.24', height: '16.86', fill: '#2ABBF8' },
|
||||
{ opacity: 1, x: '-17.38', y: '0', width: '16.86', height: '16.86', fill: '#2ABBF8' },
|
||||
{ opacity: 1, x: '0', y: '0', width: '16.86', height: '33.73', fill: '#2ABBF8' },
|
||||
{ opacity: 0.6, x: '0', y: '0', width: '85.34', height: '16.86', fill: '#2ABBF8' },
|
||||
{ opacity: 1, x: '0', y: '0', width: '16.86', height: '16.86', fill: '#2ABBF8' },
|
||||
{ opacity: 0.6, x: '34.24', y: '0', width: '34.24', height: '33.73', fill: '#2ABBF8' },
|
||||
{ opacity: 1, x: '34.24', y: '0', width: '16.86', height: '16.86', fill: '#2ABBF8' },
|
||||
{ opacity: 1, x: '51.62', y: '16.86', width: '16.86', height: '16.86', fill: '#2ABBF8' },
|
||||
{ opacity: 1, x: '68.48', y: '0', width: '54.65', height: '16.86', fill: '#FA4EDF' },
|
||||
{ opacity: 0.6, x: '106.27', y: '0', width: '34.24', height: '33.73', fill: '#FA4EDF' },
|
||||
{ opacity: 0.6, x: '106.27', y: '0', width: '51.10', height: '16.86', fill: '#FA4EDF' },
|
||||
{ opacity: 1, x: '123.65', y: '16.86', width: '16.86', height: '16.86', fill: '#FA4EDF' },
|
||||
{ opacity: 0.6, x: '157.37', y: '0', width: '34.24', height: '16.86', fill: '#FA4EDF' },
|
||||
{ opacity: 1, x: '157.37', y: '0', width: '16.86', height: '16.86', fill: '#FA4EDF' },
|
||||
{ opacity: 0.6, x: '209.0', y: '0', width: '68.48', height: '16.86', fill: '#00F701' },
|
||||
{ opacity: 0.6, x: '209.14', y: '0', width: '16.86', height: '33.73', fill: '#00F701' },
|
||||
{ opacity: 0.6, x: '243.23', y: '0', width: '34.24', height: '33.73', fill: '#00F701' },
|
||||
{ opacity: 1, x: '243.23', y: '0', width: '16.86', height: '16.86', fill: '#00F701' },
|
||||
{ opacity: 0.6, x: '260.10', y: '0', width: '34.04', height: '16.86', fill: '#00F701' },
|
||||
{ opacity: 1, x: '260.61', y: '16.86', width: '16.86', height: '16.86', fill: '#00F701' },
|
||||
] as const
|
||||
|
||||
const SCROLL_BLOCK_MAX_X = Math.max(...SCROLL_BLOCK_RECTS.map((r) => Number.parseFloat(r.x)))
|
||||
const SCROLL_REVEAL_START = 0.05
|
||||
const SCROLL_REVEAL_SPAN = 0.7
|
||||
const SCROLL_FADE_IN = 0.03
|
||||
|
||||
function getScrollBlockThreshold(x: string): number {
|
||||
const normalized = Number.parseFloat(x) / SCROLL_BLOCK_MAX_X
|
||||
return SCROLL_REVEAL_START + (1 - normalized) * SCROLL_REVEAL_SPAN
|
||||
}
|
||||
|
||||
interface ScrollBlockRectProps {
|
||||
scrollYProgress: MotionValue<number>
|
||||
rect: (typeof SCROLL_BLOCK_RECTS)[number]
|
||||
}
|
||||
|
||||
/** Renders a single SVG rect whose opacity is driven by scroll progress. */
|
||||
function ScrollBlockRect({ scrollYProgress, rect }: ScrollBlockRectProps) {
|
||||
const threshold = getScrollBlockThreshold(rect.x)
|
||||
const opacity = useTransform(
|
||||
scrollYProgress,
|
||||
[threshold, threshold + SCROLL_FADE_IN],
|
||||
[0, rect.opacity]
|
||||
)
|
||||
|
||||
return (
|
||||
<motion.rect
|
||||
x={rect.x}
|
||||
y={rect.y}
|
||||
width={rect.width}
|
||||
height={rect.height}
|
||||
rx={SCROLL_BLOCK_RX}
|
||||
fill={rect.fill}
|
||||
style={{ opacity }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function buildBottomWallStyle(config: DepthConfig) {
|
||||
let pos = 0
|
||||
const stops: string[] = []
|
||||
@@ -250,9 +317,36 @@ function buildBottomWallStyle(config: DepthConfig) {
|
||||
}
|
||||
}
|
||||
|
||||
interface DotGridProps {
|
||||
className?: string
|
||||
cols: number
|
||||
rows: number
|
||||
gap?: number
|
||||
}
|
||||
|
||||
function DotGrid({ className, cols, rows, gap = 0 }: DotGridProps) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className={className}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(${cols}, 1fr)`,
|
||||
gap,
|
||||
placeItems: 'center',
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: cols * rows }, (_, i) => (
|
||||
<div key={i} className='h-[1.5px] w-[1.5px] rounded-full bg-[var(--landing-bg-elevated)]' />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const TEMPLATES_PANEL_ID = 'templates-panel'
|
||||
|
||||
export default function Templates() {
|
||||
const sectionRef = useRef<HTMLDivElement>(null)
|
||||
const [activeIndex, setActiveIndex] = useState(0)
|
||||
const [isPreparingTemplate, setIsPreparingTemplate] = useState(false)
|
||||
const [isMobile, setIsMobile] = useState(false)
|
||||
@@ -266,6 +360,11 @@ export default function Templates() {
|
||||
return () => mq.removeEventListener('change', handler)
|
||||
}, [])
|
||||
|
||||
const { scrollYProgress } = useScroll({
|
||||
target: sectionRef,
|
||||
offset: ['start 0.9', 'start 0.2'],
|
||||
})
|
||||
|
||||
const activeWorkflow = TEMPLATE_WORKFLOWS[activeIndex]
|
||||
const activeDepth = DEPTH_CONFIGS[activeWorkflow.id]
|
||||
|
||||
@@ -309,7 +408,12 @@ export default function Templates() {
|
||||
])
|
||||
|
||||
return (
|
||||
<section id='templates' aria-labelledby='templates-heading' className='pt-[60px] lg:pt-[100px]'>
|
||||
<section
|
||||
ref={sectionRef}
|
||||
id='templates'
|
||||
aria-labelledby='templates-heading'
|
||||
className='mt-10 mb-20'
|
||||
>
|
||||
<p className='sr-only'>
|
||||
Sim includes {TEMPLATE_WORKFLOWS.length} pre-built workflow templates covering OCR
|
||||
processing, release management, meeting follow-ups, resume scanning, email triage,
|
||||
@@ -317,15 +421,35 @@ export default function Templates() {
|
||||
and knowledge base Q&A. Each template connects real integrations and LLMs — pick one,
|
||||
customise it, and deploy in minutes.
|
||||
</p>
|
||||
<ul className='sr-only'>
|
||||
{TEMPLATE_WORKFLOWS.map((workflow) => (
|
||||
<li key={workflow.id}>{workflow.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className='bg-[var(--landing-bg)]'>
|
||||
<DotGrid
|
||||
className='overflow-hidden border-[var(--landing-bg-elevated)] border-y bg-[var(--landing-bg)] p-1.5'
|
||||
cols={160}
|
||||
rows={1}
|
||||
gap={6}
|
||||
/>
|
||||
|
||||
<div className='relative overflow-hidden'>
|
||||
<div className='px-5 lg:px-16'>
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='pointer-events-none absolute top-0 right-0 z-20 hidden lg:block'
|
||||
>
|
||||
<svg
|
||||
width={329}
|
||||
height={34}
|
||||
viewBox='-34 0 329 34'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
className='h-auto w-full'
|
||||
>
|
||||
{SCROLL_BLOCK_RECTS.map((r, i) => (
|
||||
<ScrollBlockRect key={i} scrollYProgress={scrollYProgress} rect={r} />
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className='px-5 pt-[60px] lg:px-20 lg:pt-[100px]'>
|
||||
<div className='flex flex-col items-start gap-5'>
|
||||
<Badge
|
||||
variant='blue'
|
||||
@@ -356,10 +480,24 @@ export default function Templates() {
|
||||
</div>
|
||||
|
||||
<div className='mt-10 flex border-[var(--landing-bg-elevated)] border-y lg:mt-[73px]'>
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='w-[24px] shrink-0 border-[var(--landing-bg-elevated)] border-r lg:w-16'
|
||||
/>
|
||||
<div className='shrink-0'>
|
||||
<div className='h-full lg:hidden'>
|
||||
<DotGrid
|
||||
className='h-full w-[24px] overflow-hidden border-[var(--landing-bg-elevated)] border-r p-1'
|
||||
cols={2}
|
||||
rows={55}
|
||||
gap={4}
|
||||
/>
|
||||
</div>
|
||||
<div className='hidden h-full lg:block'>
|
||||
<DotGrid
|
||||
className='h-full w-[80px] overflow-hidden border-[var(--landing-bg-elevated)] border-r p-1.5'
|
||||
cols={8}
|
||||
rows={55}
|
||||
gap={6}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex min-w-0 flex-1 flex-col lg:flex-row'>
|
||||
<div
|
||||
@@ -437,7 +575,7 @@ export default function Templates() {
|
||||
<LandingPreviewWorkflow
|
||||
workflow={workflow}
|
||||
animate
|
||||
fitViewOptions={{ padding: 0.15, minZoom: 0.1, maxZoom: 0.8 }}
|
||||
fitViewOptions={{ padding: 0.15, maxZoom: 1.3 }}
|
||||
/>
|
||||
</div>
|
||||
<div className='p-3'>
|
||||
@@ -479,55 +617,46 @@ export default function Templates() {
|
||||
className='group/cta absolute top-4 right-[16px] z-10 inline-flex h-[32px] cursor-pointer items-center gap-1.5 rounded-[5px] border border-white bg-white px-2.5 font-[430] font-season text-black text-sm transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]'
|
||||
>
|
||||
{isPreparingTemplate ? 'Preparing...' : 'Use template'}
|
||||
<svg
|
||||
className='h-[10px] w-[10px] shrink-0'
|
||||
viewBox='0 0 10 10'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<line
|
||||
x1='0'
|
||||
y1='5'
|
||||
x2='9'
|
||||
y2='5'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.33'
|
||||
strokeLinecap='square'
|
||||
className='origin-left scale-x-0 transition-transform duration-200 ease-out [transform-box:fill-box] group-hover/cta:scale-x-100'
|
||||
/>
|
||||
<path
|
||||
d='M3.5 2L6.5 5L3.5 8'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.33'
|
||||
strokeLinecap='square'
|
||||
strokeLinejoin='miter'
|
||||
<span className='relative h-[10px] w-[10px] shrink-0'>
|
||||
<ChevronDown className='-rotate-90 absolute inset-0 h-[10px] w-[10px] transition-opacity duration-150 group-hover/cta:opacity-0' />
|
||||
<svg
|
||||
className='absolute inset-0 h-[10px] w-[10px] opacity-0 transition-opacity duration-150 group-hover/cta:opacity-100'
|
||||
viewBox='0 0 10 10'
|
||||
fill='none'
|
||||
className='transition-transform duration-200 ease-out group-hover/cta:translate-x-[30%]'
|
||||
/>
|
||||
</svg>
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<path
|
||||
d='M1 5H8M5.5 2L8.5 5L5.5 8'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.33'
|
||||
strokeLinecap='square'
|
||||
strokeLinejoin='miter'
|
||||
fill='none'
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='w-[24px] shrink-0 border-[var(--landing-bg-elevated)] border-l lg:w-16'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='relative pb-[60px] lg:pb-[100px]'>
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='absolute top-0 bottom-0 left-[calc(4rem-1px)] hidden w-px bg-[var(--landing-bg-elevated)] lg:block'
|
||||
/>
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='absolute top-0 right-[calc(4rem-1px)] bottom-0 hidden w-px bg-[var(--landing-bg-elevated)] lg:block'
|
||||
/>
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='absolute right-16 bottom-0 left-16 hidden h-px bg-[var(--landing-bg-elevated)] lg:block'
|
||||
/>
|
||||
<div className='shrink-0'>
|
||||
<div className='h-full lg:hidden'>
|
||||
<DotGrid
|
||||
className='h-full w-[24px] overflow-hidden border-[var(--landing-bg-elevated)] border-l p-1'
|
||||
cols={2}
|
||||
rows={55}
|
||||
gap={4}
|
||||
/>
|
||||
</div>
|
||||
<div className='hidden h-full lg:block'>
|
||||
<DotGrid
|
||||
className='h-full w-[80px] overflow-hidden border-[var(--landing-bg-elevated)] border-l p-1.5'
|
||||
cols={8}
|
||||
rows={55}
|
||||
gap={6}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -3,7 +3,7 @@ import { martianMono } from '@/app/_styles/fonts/martian-mono/martian-mono'
|
||||
import { season } from '@/app/_styles/fonts/season/season'
|
||||
import {
|
||||
Collaboration,
|
||||
// Enterprise,
|
||||
Enterprise,
|
||||
Features,
|
||||
Footer,
|
||||
Hero,
|
||||
@@ -12,8 +12,8 @@ import {
|
||||
StructuredData,
|
||||
Templates,
|
||||
Testimonials,
|
||||
} from '@/app/(landing)/components'
|
||||
import { LandingAnalytics } from '@/app/(landing)/landing-analytics'
|
||||
} from '@/app/(home)/components'
|
||||
import { LandingAnalytics } from '@/app/(home)/landing-analytics'
|
||||
|
||||
/**
|
||||
* Landing page root component.
|
||||
@@ -52,20 +52,13 @@ export default async function Landing() {
|
||||
<Navbar blogPosts={blogPosts} />
|
||||
</header>
|
||||
<main id='main-content'>
|
||||
<article itemScope itemType='https://schema.org/WebPage'>
|
||||
<meta itemProp='name' content='Sim — Build AI Agents & Run Your Agentic Workforce' />
|
||||
<meta
|
||||
itemProp='description'
|
||||
content='Sim is the open-source platform to build AI agents and run your agentic workforce.'
|
||||
/>
|
||||
<Hero />
|
||||
<Templates />
|
||||
<Features />
|
||||
<Collaboration />
|
||||
{/* <Enterprise /> */}
|
||||
<Pricing />
|
||||
<Testimonials />
|
||||
</article>
|
||||
<Hero />
|
||||
<Templates />
|
||||
<Features />
|
||||
<Collaboration />
|
||||
<Enterprise />
|
||||
<Pricing />
|
||||
<Testimonials />
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
18
apps/sim/app/(home)/layout.tsx
Normal file
18
apps/sim/app/(home)/layout.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { martianMono } from '@/app/_styles/fonts/martian-mono/martian-mono'
|
||||
import { season } from '@/app/_styles/fonts/season/season'
|
||||
|
||||
/**
|
||||
* Landing page route-group layout.
|
||||
*
|
||||
* Applies landing-specific font CSS variables to the subtree:
|
||||
* - `--font-season` (Season Sans): Headings and display text
|
||||
* - `--font-martian-mono` (Martian Mono): Code snippets and technical accents
|
||||
*
|
||||
* Available to child components via Tailwind (`font-season`, `font-martian-mono`).
|
||||
*
|
||||
* SEO metadata for the `/` route is exported from `app/page.tsx` — not here.
|
||||
* This layout only applies when a `page.tsx` exists inside the `(home)/` route group.
|
||||
*/
|
||||
export default function HomeLayout({ children }: { children: React.ReactNode }) {
|
||||
return <div className={`${season.variable} ${martianMono.variable}`}>{children}</div>
|
||||
}
|
||||
@@ -1,39 +1,26 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { ArrowLeft, ChevronLeft } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
export function BackLink() {
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
|
||||
return (
|
||||
<Link
|
||||
href='/blog'
|
||||
className='group/link inline-flex items-center gap-1.5 font-season text-[var(--landing-text-muted)] text-sm tracking-[0.02em] hover:text-[var(--landing-text)]'
|
||||
className='group flex items-center gap-1 text-[var(--landing-text-muted)] text-sm hover:text-[var(--landing-text)]'
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<svg
|
||||
className='h-3 w-3 shrink-0'
|
||||
viewBox='0 0 10 10'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<line
|
||||
x1='1'
|
||||
y1='5'
|
||||
x2='10'
|
||||
y2='5'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.33'
|
||||
strokeLinecap='square'
|
||||
className='origin-right scale-x-0 transition-transform duration-200 ease-out [transform-box:fill-box] group-hover/link:scale-x-100'
|
||||
/>
|
||||
<path
|
||||
d='M6.5 2L3.5 5L6.5 8'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.33'
|
||||
strokeLinecap='square'
|
||||
strokeLinejoin='miter'
|
||||
fill='none'
|
||||
className='group-hover/link:-translate-x-[30%] transition-transform duration-200 ease-out'
|
||||
/>
|
||||
</svg>
|
||||
<span className='group-hover:-translate-x-0.5 inline-flex transition-transform duration-200'>
|
||||
{isHovered ? (
|
||||
<ArrowLeft className='h-4 w-4' aria-hidden='true' />
|
||||
) : (
|
||||
<ChevronLeft className='h-4 w-4' aria-hidden='true' />
|
||||
)}
|
||||
</span>
|
||||
Back to Blog
|
||||
</Link>
|
||||
)
|
||||
|
||||
@@ -2,51 +2,58 @@ import { Skeleton } from '@/components/emcn'
|
||||
|
||||
export default function BlogPostLoading() {
|
||||
return (
|
||||
<article className='w-full bg-[var(--landing-bg)]'>
|
||||
<div className='px-5 pt-[60px] lg:px-16 lg:pt-[100px]'>
|
||||
<article className='w-full'>
|
||||
{/* Header area */}
|
||||
<div className='mx-auto max-w-[1450px] px-6 pt-8 sm:px-8 sm:pt-12 md:px-12 md:pt-16'>
|
||||
{/* Back link */}
|
||||
<div className='mb-6'>
|
||||
<Skeleton className='h-[16px] w-[100px] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
|
||||
<Skeleton className='h-[16px] w-[60px] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
|
||||
</div>
|
||||
{/* Image + title row */}
|
||||
<div className='flex flex-col gap-8 md:flex-row md:gap-12'>
|
||||
{/* Image */}
|
||||
<div className='w-full flex-shrink-0 md:w-[450px]'>
|
||||
<Skeleton className='aspect-[450/360] w-full rounded-[5px] bg-[var(--landing-bg-elevated)]' />
|
||||
<Skeleton className='aspect-[450/360] w-full rounded-lg bg-[var(--landing-bg-elevated)]' />
|
||||
</div>
|
||||
{/* Title + author */}
|
||||
<div className='flex flex-1 flex-col justify-between'>
|
||||
<div>
|
||||
<Skeleton className='h-[44px] w-full rounded-[4px] bg-[var(--landing-bg-elevated)]' />
|
||||
<Skeleton className='mt-2 h-[44px] w-[80%] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
|
||||
<Skeleton className='mt-4 h-[18px] w-full rounded-[4px] bg-[var(--landing-bg-elevated)]' />
|
||||
<Skeleton className='mt-2 h-[18px] w-[70%] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
|
||||
<Skeleton className='h-[48px] w-full rounded-[4px] bg-[var(--landing-bg-elevated)]' />
|
||||
<Skeleton className='mt-2 h-[48px] w-[80%] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
|
||||
</div>
|
||||
<div className='mt-6 flex items-center gap-6'>
|
||||
<Skeleton className='h-[12px] w-[100px] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
|
||||
<div className='mt-4 flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Skeleton className='h-[20px] w-[20px] rounded-full bg-[var(--landing-bg-elevated)]' />
|
||||
<Skeleton className='h-[12px] w-[80px] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
|
||||
<Skeleton className='h-[24px] w-[24px] rounded-full bg-[var(--landing-bg-elevated)]' />
|
||||
<Skeleton className='h-[16px] w-[100px] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
|
||||
</div>
|
||||
<Skeleton className='h-[32px] w-[32px] rounded-[6px] bg-[var(--landing-bg-elevated)]' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mt-8 h-px w-full bg-[var(--landing-bg-elevated)]' />
|
||||
|
||||
<div className='mx-5 border-[var(--landing-bg-elevated)] border-x lg:mx-16'>
|
||||
<div className='mx-auto max-w-[900px] px-6 py-16'>
|
||||
<div className='space-y-4'>
|
||||
<Skeleton className='h-[16px] w-full rounded-[4px] bg-[var(--landing-bg-elevated)]' />
|
||||
<Skeleton className='h-[16px] w-[95%] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
|
||||
<Skeleton className='h-[16px] w-[88%] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
|
||||
<Skeleton className='h-[16px] w-full rounded-[4px] bg-[var(--landing-bg-elevated)]' />
|
||||
<Skeleton className='mt-6 h-[24px] w-[200px] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
|
||||
<Skeleton className='h-[16px] w-full rounded-[4px] bg-[var(--landing-bg-elevated)]' />
|
||||
<Skeleton className='h-[16px] w-[92%] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
|
||||
<Skeleton className='h-[16px] w-[85%] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
|
||||
{/* Divider */}
|
||||
<Skeleton className='mt-8 h-[1px] w-full bg-[var(--landing-bg-elevated)] sm:mt-12' />
|
||||
{/* Date + description */}
|
||||
<div className='flex flex-col gap-6 py-8 sm:flex-row sm:items-start sm:justify-between sm:gap-8 sm:py-10'>
|
||||
<Skeleton className='h-[16px] w-[120px] flex-shrink-0 rounded-[4px] bg-[var(--landing-bg-elevated)]' />
|
||||
<div className='flex-1 space-y-2'>
|
||||
<Skeleton className='h-[20px] w-full rounded-[4px] bg-[var(--landing-bg-elevated)]' />
|
||||
<Skeleton className='h-[20px] w-[70%] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='-mt-px h-px w-full bg-[var(--landing-bg-elevated)]' />
|
||||
{/* Article body */}
|
||||
<div className='mx-auto max-w-[900px] px-6 pb-20 sm:px-8 md:px-12'>
|
||||
<div className='space-y-4'>
|
||||
<Skeleton className='h-[16px] w-full rounded-[4px] bg-[var(--landing-bg-elevated)]' />
|
||||
<Skeleton className='h-[16px] w-[95%] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
|
||||
<Skeleton className='h-[16px] w-[88%] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
|
||||
<Skeleton className='h-[16px] w-full rounded-[4px] bg-[var(--landing-bg-elevated)]' />
|
||||
<Skeleton className='mt-6 h-[24px] w-[200px] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
|
||||
<Skeleton className='h-[16px] w-full rounded-[4px] bg-[var(--landing-bg-elevated)]' />
|
||||
<Skeleton className='h-[16px] w-[92%] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
|
||||
<Skeleton className='h-[16px] w-[85%] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import Link from 'next/link'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/emcn'
|
||||
import { FAQ } from '@/lib/blog/faq'
|
||||
import { getAllPostMeta, getPostBySlug, getRelatedPosts } from '@/lib/blog/registry'
|
||||
import { buildPostGraphJsonLd, buildPostMetadata } from '@/lib/blog/seo'
|
||||
import { buildArticleJsonLd, buildBreadcrumbJsonLd, buildPostMetadata } from '@/lib/blog/seo'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { BackLink } from '@/app/(landing)/blog/[slug]/back-link'
|
||||
import { ShareButton } from '@/app/(landing)/blog/[slug]/share-button'
|
||||
@@ -30,27 +30,27 @@ export default async function Page({ params }: { params: Promise<{ slug: string
|
||||
const { slug } = await params
|
||||
const post = await getPostBySlug(slug)
|
||||
const Article = post.Content
|
||||
const graphJsonLd = buildPostGraphJsonLd(post)
|
||||
const jsonLd = buildArticleJsonLd(post)
|
||||
const breadcrumbLd = buildBreadcrumbJsonLd(post)
|
||||
const related = await getRelatedPosts(slug, 3)
|
||||
|
||||
return (
|
||||
<article
|
||||
className='w-full bg-[var(--landing-bg)]'
|
||||
itemScope
|
||||
itemType='https://schema.org/TechArticle'
|
||||
>
|
||||
<article className='w-full' itemScope itemType='https://schema.org/BlogPosting'>
|
||||
<script
|
||||
type='application/ld+json'
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(graphJsonLd) }}
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
<header className='px-5 pt-[60px] lg:px-16 lg:pt-[100px]'>
|
||||
<script
|
||||
type='application/ld+json'
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbLd) }}
|
||||
/>
|
||||
<header className='mx-auto max-w-[1450px] px-6 pt-8 sm:px-8 sm:pt-12 md:px-12 md:pt-16'>
|
||||
<div className='mb-6'>
|
||||
<BackLink />
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-8 md:flex-row md:gap-12'>
|
||||
<div className='w-full flex-shrink-0 md:w-[450px]'>
|
||||
<div className='relative w-full overflow-hidden rounded-[5px]'>
|
||||
<div className='relative w-full overflow-hidden rounded-lg'>
|
||||
<Image
|
||||
src={post.ogImage}
|
||||
alt={post.title}
|
||||
@@ -65,35 +65,18 @@ export default async function Page({ params }: { params: Promise<{ slug: string
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-1 flex-col justify-between'>
|
||||
<div>
|
||||
<h1
|
||||
className='text-balance font-[430] font-season text-[28px] text-white leading-[110%] tracking-[-0.02em] sm:text-[36px] md:text-[44px] lg:text-[52px]'
|
||||
itemProp='headline'
|
||||
>
|
||||
{post.title}
|
||||
</h1>
|
||||
<p className='mt-4 font-[430] font-season text-[var(--landing-text-body)] text-base leading-[150%] tracking-[0.02em] sm:text-lg'>
|
||||
{post.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className='mt-6 flex items-center gap-6'>
|
||||
<time
|
||||
className='font-martian-mono text-[var(--landing-text-subtle)] text-xs uppercase tracking-[0.1em]'
|
||||
dateTime={post.date}
|
||||
itemProp='datePublished'
|
||||
>
|
||||
{new Date(post.date).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</time>
|
||||
<meta itemProp='dateModified' content={post.updated ?? post.date} />
|
||||
<h1
|
||||
className='text-balance font-[500] text-[36px] text-[var(--landing-text)] leading-tight tracking-tight sm:text-[48px] md:text-[56px] lg:text-[64px]'
|
||||
itemProp='headline'
|
||||
>
|
||||
{post.title}
|
||||
</h1>
|
||||
<div className='mt-4 flex items-center justify-between'>
|
||||
<div className='flex items-center gap-3'>
|
||||
{(post.authors || [post.author]).map((a, idx) => (
|
||||
<div key={idx} className='flex items-center gap-2'>
|
||||
{a?.avatarUrl ? (
|
||||
<Avatar className='size-5'>
|
||||
<Avatar className='size-6'>
|
||||
<AvatarImage src={a.avatarUrl} alt={a.name} />
|
||||
<AvatarFallback>{a.name.slice(0, 2)}</AvatarFallback>
|
||||
</Avatar>
|
||||
@@ -102,7 +85,7 @@ export default async function Page({ params }: { params: Promise<{ slug: string
|
||||
href={a?.url || '#'}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer author'
|
||||
className='font-martian-mono text-[var(--landing-text-muted)] text-xs uppercase tracking-[0.1em] hover:text-white'
|
||||
className='text-[var(--landing-text-muted)] text-sm leading-[1.5] hover:text-[var(--landing-text)] sm:text-md'
|
||||
itemProp='author'
|
||||
itemScope
|
||||
itemType='https://schema.org/Person'
|
||||
@@ -112,72 +95,78 @@ export default async function Page({ params }: { params: Promise<{ slug: string
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className='ml-auto'>
|
||||
<ShareButton url={`${getBaseUrl()}/blog/${slug}`} title={post.title} />
|
||||
</div>
|
||||
<ShareButton url={`${getBaseUrl()}/blog/${slug}`} title={post.title} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr className='mt-8 border-[var(--landing-bg-elevated)] border-t sm:mt-12' />
|
||||
<div className='flex flex-col gap-6 py-8 sm:flex-row sm:items-start sm:justify-between sm:gap-8 sm:py-10'>
|
||||
<div className='flex flex-shrink-0 items-center gap-4'>
|
||||
<time
|
||||
className='block text-[var(--landing-text-muted)] text-sm leading-[1.5] sm:text-md'
|
||||
dateTime={post.date}
|
||||
itemProp='datePublished'
|
||||
>
|
||||
{new Date(post.date).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</time>
|
||||
<meta itemProp='dateModified' content={post.updated ?? post.date} />
|
||||
</div>
|
||||
<div className='flex-1'>
|
||||
<p className='m-0 block translate-y-[-4px] font-[400] text-[var(--landing-text-muted)] text-lg leading-[1.5] sm:text-[20px] md:text-[26px]'>
|
||||
{post.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className='mt-8 h-px w-full bg-[var(--landing-bg-elevated)]' />
|
||||
|
||||
<div className='mx-5 border-[var(--landing-bg-elevated)] border-x lg:mx-16'>
|
||||
<div className='mx-auto max-w-[900px] px-6 py-16' itemProp='articleBody'>
|
||||
<div className='prose prose-lg prose-invert max-w-none prose-blockquote:border-[var(--landing-border-strong)] prose-hr:border-[var(--landing-bg-elevated)] prose-headings:font-[430] prose-headings:font-season prose-a:text-white prose-blockquote:text-[var(--landing-text-muted)] prose-code:text-white prose-headings:text-white prose-li:text-[var(--landing-text-body)] prose-p:text-[var(--landing-text-body)] prose-strong:text-white prose-headings:tracking-[-0.02em]'>
|
||||
<Article />
|
||||
{post.faq && post.faq.length > 0 ? <FAQ items={post.faq} /> : null}
|
||||
</div>
|
||||
<div className='mx-auto max-w-[900px] px-6 pb-20 sm:px-8 md:px-12' itemProp='articleBody'>
|
||||
<div className='prose prose-lg prose-invert max-w-none prose-blockquote:border-[var(--landing-border-strong)] prose-hr:border-[var(--landing-bg-elevated)] prose-a:text-[var(--landing-text)] prose-blockquote:text-[var(--landing-text-muted)] prose-code:text-[var(--landing-text)] prose-headings:text-[var(--landing-text)] prose-li:text-[var(--landing-text-muted)] prose-p:text-[var(--landing-text-muted)] prose-strong:text-[var(--landing-text)]'>
|
||||
<Article />
|
||||
{post.faq && post.faq.length > 0 ? <FAQ items={post.faq} /> : null}
|
||||
</div>
|
||||
|
||||
{related.length > 0 && (
|
||||
<>
|
||||
<div className='h-px w-full bg-[var(--landing-bg-elevated)]' />
|
||||
<nav aria-label='Related posts' className='flex'>
|
||||
{related.map((p) => (
|
||||
<Link
|
||||
key={p.slug}
|
||||
href={`/blog/${p.slug}`}
|
||||
className='group flex flex-1 flex-col gap-4 border-[var(--landing-bg-elevated)] p-6 transition-colors hover:bg-[var(--landing-bg-elevated)] md:border-l md:first:border-l-0'
|
||||
>
|
||||
<div className='relative aspect-video w-full overflow-hidden rounded-[5px]'>
|
||||
<Image
|
||||
src={p.ogImage}
|
||||
alt={p.title}
|
||||
fill
|
||||
sizes='(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw'
|
||||
className='object-cover'
|
||||
loading='lazy'
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<span className='font-martian-mono text-[var(--landing-text-subtle)] text-xs uppercase tracking-[0.1em]'>
|
||||
</div>
|
||||
{related.length > 0 && (
|
||||
<div className='mx-auto max-w-[900px] px-6 pb-24 sm:px-8 md:px-12'>
|
||||
<h2 className='mb-4 font-[500] text-[24px] text-[var(--landing-text)]'>Related posts</h2>
|
||||
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2 sm:gap-6 lg:grid-cols-3'>
|
||||
{related.map((p) => (
|
||||
<Link key={p.slug} href={`/blog/${p.slug}`} className='group'>
|
||||
<div className='overflow-hidden rounded-lg border border-[var(--landing-bg-elevated)]'>
|
||||
<Image
|
||||
src={p.ogImage}
|
||||
alt={p.title}
|
||||
width={600}
|
||||
height={315}
|
||||
className='h-[160px] w-full object-cover'
|
||||
sizes='(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw'
|
||||
loading='lazy'
|
||||
unoptimized
|
||||
/>
|
||||
<div className='p-3'>
|
||||
<div className='mb-1 text-[var(--landing-text-muted)] text-xs'>
|
||||
{new Date(p.date).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
year: '2-digit',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
<h3 className='font-[430] font-season text-lg text-white leading-tight tracking-[-0.01em]'>
|
||||
</div>
|
||||
<div className='font-[500] text-[var(--landing-text)] text-sm leading-tight'>
|
||||
{p.title}
|
||||
</h3>
|
||||
<p className='line-clamp-2 text-[var(--landing-text-muted)] text-sm leading-[150%]'>
|
||||
{p.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='-mt-px h-px w-full bg-[var(--landing-bg-elevated)]' />
|
||||
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<meta itemProp='publisher' content='Sim' />
|
||||
<meta itemProp='inLanguage' content='en-US' />
|
||||
<meta itemProp='keywords' content={post.tags.join(', ')} />
|
||||
{post.wordCount && <meta itemProp='wordCount' content={String(post.wordCount)} />}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -13,29 +13,7 @@ export async function generateMetadata({
|
||||
const { id } = await params
|
||||
const posts = (await getAllPostMeta()).filter((p) => p.author.id === id)
|
||||
const author = posts[0]?.author
|
||||
const name = author?.name ?? 'Author'
|
||||
return {
|
||||
title: `${name} — Sim Blog`,
|
||||
description: `Read articles by ${name} on the Sim blog.`,
|
||||
alternates: { canonical: `https://sim.ai/blog/authors/${id}` },
|
||||
openGraph: {
|
||||
title: `${name} — Sim Blog`,
|
||||
description: `Read articles by ${name} on the Sim blog.`,
|
||||
url: `https://sim.ai/blog/authors/${id}`,
|
||||
siteName: 'Sim',
|
||||
type: 'profile',
|
||||
...(author?.avatarUrl
|
||||
? { images: [{ url: author.avatarUrl, width: 400, height: 400, alt: name }] }
|
||||
: {}),
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary',
|
||||
title: `${name} — Sim Blog`,
|
||||
description: `Read articles by ${name} on the Sim blog.`,
|
||||
site: '@simdotai',
|
||||
...(author?.xHandle ? { creator: `@${author.xHandle}` } : {}),
|
||||
},
|
||||
}
|
||||
return { title: author?.name ?? 'Author' }
|
||||
}
|
||||
|
||||
export default async function AuthorPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
@@ -49,41 +27,19 @@ export default async function AuthorPage({ params }: { params: Promise<{ id: str
|
||||
</main>
|
||||
)
|
||||
}
|
||||
const graphJsonLd = {
|
||||
const personJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@graph': [
|
||||
{
|
||||
'@type': 'Person',
|
||||
name: author.name,
|
||||
url: `https://sim.ai/blog/authors/${author.id}`,
|
||||
sameAs: author.url ? [author.url] : [],
|
||||
image: author.avatarUrl,
|
||||
worksFor: {
|
||||
'@type': 'Organization',
|
||||
name: 'Sim',
|
||||
url: 'https://sim.ai',
|
||||
},
|
||||
},
|
||||
{
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{ '@type': 'ListItem', position: 1, name: 'Home', item: 'https://sim.ai' },
|
||||
{ '@type': 'ListItem', position: 2, name: 'Blog', item: 'https://sim.ai/blog' },
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 3,
|
||||
name: author.name,
|
||||
item: `https://sim.ai/blog/authors/${author.id}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
'@type': 'Person',
|
||||
name: author.name,
|
||||
url: `https://sim.ai/blog/authors/${author.id}`,
|
||||
sameAs: author.url ? [author.url] : [],
|
||||
image: author.avatarUrl,
|
||||
}
|
||||
return (
|
||||
<main className='mx-auto max-w-[900px] px-6 py-10 sm:px-8 md:px-12'>
|
||||
<script
|
||||
type='application/ld+json'
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(graphJsonLd) }}
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(personJsonLd) }}
|
||||
/>
|
||||
<div className='mb-6 flex items-center gap-3'>
|
||||
{author.avatarUrl ? (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getNavBlogPosts } from '@/lib/blog/registry'
|
||||
import Footer from '@/app/(landing)/components/footer/footer'
|
||||
import Navbar from '@/app/(landing)/components/navbar/navbar'
|
||||
import Footer from '@/app/(home)/components/footer/footer'
|
||||
import Navbar from '@/app/(home)/components/navbar/navbar'
|
||||
|
||||
export default async function StudioLayout({ children }: { children: React.ReactNode }) {
|
||||
const blogPosts = await getNavBlogPosts()
|
||||
@@ -9,14 +9,8 @@ export default async function StudioLayout({ children }: { children: React.React
|
||||
'@type': 'Organization',
|
||||
name: 'Sim',
|
||||
url: 'https://sim.ai',
|
||||
description:
|
||||
'Sim is an open-source platform for building, testing, and deploying AI agent workflows.',
|
||||
logo: 'https://sim.ai/logo/primary/small.png',
|
||||
sameAs: [
|
||||
'https://x.com/simdotai',
|
||||
'https://github.com/simstudioai/sim',
|
||||
'https://www.linkedin.com/company/simdotai',
|
||||
],
|
||||
sameAs: ['https://x.com/simdotai'],
|
||||
}
|
||||
|
||||
const websiteJsonLd = {
|
||||
@@ -24,6 +18,11 @@ export default async function StudioLayout({ children }: { children: React.React
|
||||
'@type': 'WebSite',
|
||||
name: 'Sim',
|
||||
url: 'https://sim.ai',
|
||||
potentialAction: {
|
||||
'@type': 'SearchAction',
|
||||
target: 'https://sim.ai/search?q={search_term_string}',
|
||||
'query-input': 'required name=search_term_string',
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,55 +1,32 @@
|
||||
import { Skeleton } from '@/components/emcn'
|
||||
|
||||
const SKELETON_CARD_COUNT = 6
|
||||
|
||||
export default function BlogLoading() {
|
||||
return (
|
||||
<section className='bg-[var(--landing-bg)]'>
|
||||
{/* Header skeleton */}
|
||||
<div className='px-5 pt-[60px] lg:px-16 lg:pt-[100px]'>
|
||||
<Skeleton className='mb-5 h-[20px] w-[60px] rounded-md bg-[var(--landing-bg-elevated)]' />
|
||||
<div className='flex flex-col gap-4 md:flex-row md:items-end md:justify-between'>
|
||||
<Skeleton className='h-[40px] w-[240px] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
|
||||
<Skeleton className='h-[18px] w-[320px] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content area with vertical border rails */}
|
||||
<div className='mx-5 mt-8 border-[var(--landing-bg-elevated)] border-x lg:mx-16'>
|
||||
<div className='h-px w-full bg-[var(--landing-bg-elevated)]' />
|
||||
|
||||
{/* Featured skeleton */}
|
||||
<div className='flex'>
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className='flex flex-1 flex-col gap-4 border-[var(--landing-bg-elevated)] p-6 md:border-l md:first:border-l-0'
|
||||
>
|
||||
<Skeleton className='aspect-video w-full rounded-[5px] bg-[var(--landing-bg-elevated)]' />
|
||||
<div className='flex flex-col gap-2'>
|
||||
<Skeleton className='h-[12px] w-[60px] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
|
||||
<Skeleton className='h-[20px] w-[80%] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
|
||||
<Skeleton className='h-[14px] w-full rounded-[4px] bg-[var(--landing-bg-elevated)]' />
|
||||
<main className='mx-auto max-w-[1200px] px-6 py-12 sm:px-8 md:px-12'>
|
||||
<Skeleton className='h-[48px] w-[100px] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
|
||||
<Skeleton className='mt-3 h-[18px] w-[420px] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
|
||||
<div className='mt-10 grid grid-cols-1 gap-4 md:grid-cols-2 md:gap-6 lg:grid-cols-3'>
|
||||
{Array.from({ length: SKELETON_CARD_COUNT }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className='flex flex-col overflow-hidden rounded-xl border border-[var(--landing-border)]'
|
||||
>
|
||||
<Skeleton className='aspect-video w-full rounded-none bg-[var(--landing-bg-elevated)]' />
|
||||
<div className='flex flex-1 flex-col p-4'>
|
||||
<Skeleton className='mb-2 h-[12px] w-[80px] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
|
||||
<Skeleton className='mb-1 h-[20px] w-[85%] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
|
||||
<Skeleton className='mb-3 h-[14px] w-full rounded-[4px] bg-[var(--landing-bg-elevated)]' />
|
||||
<Skeleton className='h-[14px] w-[70%] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
|
||||
<div className='mt-3 flex items-center gap-2'>
|
||||
<Skeleton className='h-[16px] w-[16px] rounded-full bg-[var(--landing-bg-elevated)]' />
|
||||
<Skeleton className='h-[12px] w-[80px] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className='h-px w-full bg-[var(--landing-bg-elevated)]' />
|
||||
|
||||
{/* List skeleton */}
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i}>
|
||||
<div className='flex items-center gap-6 px-6 py-6'>
|
||||
<Skeleton className='hidden h-[14px] w-[120px] rounded-[4px] bg-[var(--landing-bg-elevated)] md:block' />
|
||||
<div className='flex min-w-0 flex-1 flex-col gap-1'>
|
||||
<Skeleton className='h-[18px] w-[70%] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
|
||||
<Skeleton className='h-[14px] w-[90%] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
|
||||
</div>
|
||||
<Skeleton className='hidden h-[80px] w-[140px] rounded-[5px] bg-[var(--landing-bg-elevated)] sm:block' />
|
||||
</div>
|
||||
<div className='h-px w-full bg-[var(--landing-bg-elevated)]' />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,60 +1,11 @@
|
||||
import type { Metadata } from 'next'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { Badge } from '@/components/emcn'
|
||||
import { getAllPostMeta } from '@/lib/blog/registry'
|
||||
import { buildCollectionPageJsonLd } from '@/lib/blog/seo'
|
||||
import { PostGrid } from '@/app/(landing)/blog/post-grid'
|
||||
|
||||
export async function generateMetadata({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ page?: string; tag?: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { page, tag } = await searchParams
|
||||
const pageNum = Math.max(1, Number(page || 1))
|
||||
|
||||
const titleParts = ['Blog']
|
||||
if (tag) titleParts.push(tag)
|
||||
if (pageNum > 1) titleParts.push(`Page ${pageNum}`)
|
||||
const title = titleParts.join(' — ')
|
||||
|
||||
const description = tag
|
||||
? `Sim blog posts tagged "${tag}" — insights and guides for building AI agent workflows.`
|
||||
: 'Announcements, insights, and guides for building AI agent workflows.'
|
||||
|
||||
const canonicalParams = new URLSearchParams()
|
||||
if (tag) canonicalParams.set('tag', tag)
|
||||
if (pageNum > 1) canonicalParams.set('page', String(pageNum))
|
||||
const qs = canonicalParams.toString()
|
||||
const canonical = `https://sim.ai/blog${qs ? `?${qs}` : ''}`
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
alternates: { canonical },
|
||||
openGraph: {
|
||||
title: `${title} | Sim`,
|
||||
description,
|
||||
url: canonical,
|
||||
siteName: 'Sim',
|
||||
locale: 'en_US',
|
||||
type: 'website',
|
||||
images: [
|
||||
{
|
||||
url: 'https://sim.ai/logo/primary/medium.png',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: 'Sim Blog',
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: `${title} | Sim`,
|
||||
description,
|
||||
site: '@simdotai',
|
||||
},
|
||||
}
|
||||
export const metadata: Metadata = {
|
||||
title: 'Blog',
|
||||
description: 'Announcements, insights, and guides from the Sim team.',
|
||||
}
|
||||
|
||||
export const revalidate = 3600
|
||||
@@ -83,167 +34,65 @@ export default async function BlogIndex({
|
||||
const totalPages = Math.max(1, Math.ceil(sorted.length / perPage))
|
||||
const start = (pageNum - 1) * perPage
|
||||
const posts = sorted.slice(start, start + perPage)
|
||||
const featured = pageNum === 1 ? posts.slice(0, 3) : []
|
||||
const remaining = pageNum === 1 ? posts.slice(3) : posts
|
||||
|
||||
const collectionJsonLd = buildCollectionPageJsonLd()
|
||||
// Tag filter chips are intentionally disabled for now.
|
||||
// const tags = await getAllTags()
|
||||
const blogJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Blog',
|
||||
name: 'Sim Blog',
|
||||
url: 'https://sim.ai/blog',
|
||||
description: 'Announcements, insights, and guides for building AI agent workflows.',
|
||||
}
|
||||
|
||||
return (
|
||||
<section className='bg-[var(--landing-bg)]'>
|
||||
<main className='mx-auto max-w-[1200px] px-6 py-12 sm:px-8 md:px-12'>
|
||||
<script
|
||||
type='application/ld+json'
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(collectionJsonLd) }}
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(blogJsonLd) }}
|
||||
/>
|
||||
<h1 className='mb-3 text-balance font-[500] text-[40px] text-[var(--landing-text)] leading-tight sm:text-[56px]'>
|
||||
Blog
|
||||
</h1>
|
||||
<p className='mb-10 text-[var(--landing-text-muted)] text-lg'>
|
||||
Announcements, insights, and guides for building AI agent workflows.
|
||||
</p>
|
||||
|
||||
{/* Section header */}
|
||||
<div className='px-5 pt-[60px] lg:px-16 lg:pt-[100px]'>
|
||||
<Badge
|
||||
variant='blue'
|
||||
size='md'
|
||||
dot
|
||||
className='mb-5 bg-white/10 font-season text-white uppercase tracking-[0.02em]'
|
||||
>
|
||||
Blog
|
||||
</Badge>
|
||||
|
||||
<div className='flex flex-col gap-4 md:flex-row md:items-end md:justify-between'>
|
||||
<h1 className='text-balance font-[430] font-season text-[28px] text-white leading-[100%] tracking-[-0.02em] lg:text-[40px]'>
|
||||
Latest from Sim
|
||||
</h1>
|
||||
<p className='max-w-[360px] font-[430] font-season text-[#F6F6F0]/50 text-sm leading-[150%] tracking-[0.02em] lg:text-base'>
|
||||
Announcements, insights, and guides for building AI agent workflows.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Full-width top line */}
|
||||
<div className='mt-8 h-px w-full bg-[var(--landing-bg-elevated)]' />
|
||||
|
||||
{/* Content area with vertical border rails */}
|
||||
<div className='mx-5 border-[var(--landing-bg-elevated)] border-x lg:mx-16'>
|
||||
{/* Featured posts */}
|
||||
{featured.length > 0 && (
|
||||
<>
|
||||
<nav aria-label='Featured posts' className='flex'>
|
||||
{featured.map((p, index) => (
|
||||
<Link
|
||||
key={p.slug}
|
||||
href={`/blog/${p.slug}`}
|
||||
className='group flex flex-1 flex-col gap-4 border-[var(--landing-bg-elevated)] p-6 transition-colors hover:bg-[var(--landing-bg-elevated)] md:border-l md:first:border-l-0'
|
||||
>
|
||||
<div className='relative aspect-video w-full overflow-hidden rounded-[5px]'>
|
||||
<Image
|
||||
src={p.ogImage}
|
||||
alt={p.title}
|
||||
fill
|
||||
sizes='(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw'
|
||||
className='object-cover'
|
||||
priority={index < 3}
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<span className='font-martian-mono text-[var(--landing-text-subtle)] text-xs uppercase tracking-[0.1em]'>
|
||||
{new Date(p.date).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
year: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
<h3 className='font-[430] font-season text-lg text-white leading-tight tracking-[-0.01em]'>
|
||||
{p.title}
|
||||
</h3>
|
||||
<p className='line-clamp-2 text-[#F6F6F0]/50 text-sm leading-[150%]'>
|
||||
{p.description}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className='h-px w-full bg-[var(--landing-bg-elevated)]' />
|
||||
</>
|
||||
)}
|
||||
|
||||
{remaining.map((p) => (
|
||||
<div key={p.slug}>
|
||||
<Link
|
||||
href={`/blog/${p.slug}`}
|
||||
className='group flex items-start gap-6 px-6 py-6 transition-colors hover:bg-[var(--landing-bg-elevated)] md:items-center'
|
||||
>
|
||||
{/* Date */}
|
||||
<span className='hidden w-[120px] shrink-0 pt-1 font-martian-mono text-[var(--landing-text-subtle)] text-xs uppercase tracking-[0.1em] md:block'>
|
||||
{new Date(p.date).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
|
||||
{/* Title + description */}
|
||||
<div className='flex min-w-0 flex-1 flex-col gap-1'>
|
||||
<span className='font-martian-mono text-[var(--landing-text-subtle)] text-xs uppercase tracking-[0.1em] md:hidden'>
|
||||
{new Date(p.date).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
<h3 className='font-[430] font-season text-base text-white leading-tight tracking-[-0.01em] lg:text-lg'>
|
||||
{p.title}
|
||||
</h3>
|
||||
<p className='line-clamp-2 text-[#F6F6F0]/40 text-sm leading-[150%]'>
|
||||
{p.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Image */}
|
||||
<div className='relative hidden h-[80px] w-[140px] shrink-0 overflow-hidden rounded-[5px] sm:block'>
|
||||
<Image
|
||||
src={p.ogImage}
|
||||
alt={p.title}
|
||||
fill
|
||||
sizes='140px'
|
||||
className='object-cover'
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
<div className='h-px w-full bg-[var(--landing-bg-elevated)]' />
|
||||
</div>
|
||||
{/* Tag filter chips hidden until we have more posts */}
|
||||
{/* <div className='mb-10 flex flex-wrap gap-3'>
|
||||
<Link href='/blog' className={`rounded-full border px-3 py-1 text-sm ${!tag ? 'border-black bg-black text-white' : 'border-gray-300'}`}>All</Link>
|
||||
{tags.map((t) => (
|
||||
<Link key={t.tag} href={`/blog?tag=${encodeURIComponent(t.tag)}`} className={`rounded-full border px-3 py-1 text-sm ${tag === t.tag ? 'border-black bg-black text-white' : 'border-gray-300'}`}>
|
||||
{t.tag} ({t.count})
|
||||
</Link>
|
||||
))}
|
||||
</div> */}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<nav aria-label='Pagination' className='px-6 py-8'>
|
||||
<div className='flex items-center justify-center gap-3'>
|
||||
{pageNum > 1 && (
|
||||
<Link
|
||||
href={`/blog?page=${pageNum - 1}${tag ? `&tag=${encodeURIComponent(tag)}` : ''}`}
|
||||
rel='prev'
|
||||
className='rounded-[5px] border border-[var(--landing-border-strong)] px-3 py-1 text-[var(--landing-text)] text-sm transition-colors hover:bg-[var(--landing-bg-elevated)]'
|
||||
>
|
||||
Previous
|
||||
</Link>
|
||||
)}
|
||||
<span className='text-[var(--landing-text-muted)] text-sm'>
|
||||
Page {pageNum} of {totalPages}
|
||||
</span>
|
||||
{pageNum < totalPages && (
|
||||
<Link
|
||||
href={`/blog?page=${pageNum + 1}${tag ? `&tag=${encodeURIComponent(tag)}` : ''}`}
|
||||
rel='next'
|
||||
className='rounded-[5px] border border-[var(--landing-border-strong)] px-3 py-1 text-[var(--landing-text)] text-sm transition-colors hover:bg-[var(--landing-bg-elevated)]'
|
||||
>
|
||||
Next
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
{/* Grid layout for consistent rows */}
|
||||
<PostGrid posts={posts} />
|
||||
|
||||
{/* Full-width bottom line — overlaps last inner divider to avoid double border */}
|
||||
<div className='-mt-px h-px w-full bg-[var(--landing-bg-elevated)]' />
|
||||
</section>
|
||||
{totalPages > 1 && (
|
||||
<div className='mt-10 flex items-center justify-center gap-3'>
|
||||
{pageNum > 1 && (
|
||||
<Link
|
||||
href={`/blog?page=${pageNum - 1}${tag ? `&tag=${encodeURIComponent(tag)}` : ''}`}
|
||||
className='rounded-[5px] border border-[var(--landing-border-strong)] px-3 py-1 text-[var(--landing-text)] text-sm transition-colors hover:bg-[var(--landing-bg-elevated)]'
|
||||
>
|
||||
Previous
|
||||
</Link>
|
||||
)}
|
||||
<span className='text-[var(--landing-text-muted)] text-sm'>
|
||||
Page {pageNum} of {totalPages}
|
||||
</span>
|
||||
{pageNum < totalPages && (
|
||||
<Link
|
||||
href={`/blog?page=${pageNum + 1}${tag ? `&tag=${encodeURIComponent(tag)}` : ''}`}
|
||||
className='rounded-[5px] border border-[var(--landing-border-strong)] px-3 py-1 text-[var(--landing-text)] text-sm transition-colors hover:bg-[var(--landing-bg-elevated)]'
|
||||
>
|
||||
Next
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
92
apps/sim/app/(landing)/blog/post-grid.tsx
Normal file
92
apps/sim/app/(landing)/blog/post-grid.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/emcn'
|
||||
|
||||
interface Author {
|
||||
id: string
|
||||
name: string
|
||||
avatarUrl?: string
|
||||
url?: string
|
||||
}
|
||||
|
||||
interface Post {
|
||||
slug: string
|
||||
title: string
|
||||
description: string
|
||||
date: string
|
||||
ogImage: string
|
||||
author: Author
|
||||
authors?: Author[]
|
||||
featured?: boolean
|
||||
}
|
||||
|
||||
export function PostGrid({ posts }: { posts: Post[] }) {
|
||||
return (
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 md:gap-6 lg:grid-cols-3'>
|
||||
{posts.map((p, index) => (
|
||||
<Link key={p.slug} href={`/blog/${p.slug}`} className='group flex flex-col'>
|
||||
<div className='flex h-full flex-col overflow-hidden rounded-xl border border-[var(--landing-bg-elevated)] transition-colors duration-300 hover:border-[var(--landing-border-strong)]'>
|
||||
{/* Image container with fixed aspect ratio to prevent layout shift */}
|
||||
<div className='relative aspect-video w-full overflow-hidden'>
|
||||
<Image
|
||||
src={p.ogImage}
|
||||
alt={p.title}
|
||||
sizes='(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw'
|
||||
unoptimized
|
||||
priority={index < 6}
|
||||
loading={index < 6 ? undefined : 'lazy'}
|
||||
fill
|
||||
style={{ objectFit: 'cover' }}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-1 flex-col p-4'>
|
||||
<div className='mb-2 text-[var(--landing-text-muted)] text-xs'>
|
||||
{new Date(p.date).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</div>
|
||||
<h3 className='mb-1 font-[500] text-[var(--landing-text)] text-lg leading-tight'>
|
||||
{p.title}
|
||||
</h3>
|
||||
<p className='mb-3 line-clamp-3 flex-1 text-[var(--landing-text-muted)] text-sm'>
|
||||
{p.description}
|
||||
</p>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='-space-x-1.5 flex'>
|
||||
{(p.authors && p.authors.length > 0 ? p.authors : [p.author])
|
||||
.slice(0, 3)
|
||||
.map((author, idx) => (
|
||||
<Avatar key={idx} className='size-4 border border-[var(--landing-text)]'>
|
||||
<AvatarImage src={author?.avatarUrl} alt={author?.name} />
|
||||
<AvatarFallback className='border border-[var(--landing-text)] bg-[var(--landing-bg-elevated)] text-[var(--landing-text-muted)] text-micro'>
|
||||
{author?.name.slice(0, 2)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
))}
|
||||
</div>
|
||||
<span className='text-[var(--landing-text-muted)] text-xs'>
|
||||
{(p.authors && p.authors.length > 0 ? p.authors : [p.author])
|
||||
.slice(0, 2)
|
||||
.map((a) => a?.name)
|
||||
.join(', ')}
|
||||
{(p.authors && p.authors.length > 0 ? p.authors : [p.author]).length > 2 && (
|
||||
<>
|
||||
{' '}
|
||||
and {(p.authors && p.authors.length > 0 ? p.authors : [p.author]).length - 2}{' '}
|
||||
other
|
||||
{(p.authors && p.authors.length > 0 ? p.authors : [p.author]).length - 2 > 1
|
||||
? 's'
|
||||
: ''}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -7,18 +7,13 @@ export async function GET() {
|
||||
const posts = await getAllPostMeta()
|
||||
const items = posts.slice(0, 50)
|
||||
const site = 'https://sim.ai'
|
||||
const lastBuildDate =
|
||||
items.length > 0 ? new Date(items[0].date).toUTCString() : new Date().toUTCString()
|
||||
|
||||
const xml = `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title>Sim Blog</title>
|
||||
<link>${site}</link>
|
||||
<description>Announcements, insights, and guides for AI agent workflows.</description>
|
||||
<language>en-us</language>
|
||||
<lastBuildDate>${lastBuildDate}</lastBuildDate>
|
||||
<atom:link href="${site}/blog/rss.xml" rel="self" type="application/rss+xml" />
|
||||
${items
|
||||
.map(
|
||||
(p) => `
|
||||
@@ -31,7 +26,6 @@ export async function GET() {
|
||||
${(p.authors || [p.author])
|
||||
.map((a) => `<author><![CDATA[${a.name}${a.url ? ` (${a.url})` : ''}]]></author>`)
|
||||
.join('\n')}
|
||||
${p.tags.map((t) => `<category><![CDATA[${t}]]></category>`).join('\n ')}
|
||||
</item>`
|
||||
)
|
||||
.join('')}
|
||||
|
||||
@@ -4,42 +4,12 @@ import { getAllTags } from '@/lib/blog/registry'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Tags',
|
||||
description: 'Browse Sim blog posts by topic — AI agents, workflows, integrations, and more.',
|
||||
alternates: { canonical: 'https://sim.ai/blog/tags' },
|
||||
openGraph: {
|
||||
title: 'Blog Tags | Sim',
|
||||
description: 'Browse Sim blog posts by topic — AI agents, workflows, integrations, and more.',
|
||||
url: 'https://sim.ai/blog/tags',
|
||||
siteName: 'Sim',
|
||||
locale: 'en_US',
|
||||
type: 'website',
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary',
|
||||
title: 'Blog Tags | Sim',
|
||||
description: 'Browse Sim blog posts by topic — AI agents, workflows, integrations, and more.',
|
||||
site: '@simdotai',
|
||||
},
|
||||
}
|
||||
|
||||
const breadcrumbJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{ '@type': 'ListItem', position: 1, name: 'Home', item: 'https://sim.ai' },
|
||||
{ '@type': 'ListItem', position: 2, name: 'Blog', item: 'https://sim.ai/blog' },
|
||||
{ '@type': 'ListItem', position: 3, name: 'Tags', item: 'https://sim.ai/blog/tags' },
|
||||
],
|
||||
}
|
||||
|
||||
export default async function TagsIndex() {
|
||||
const tags = await getAllTags()
|
||||
return (
|
||||
<main className='mx-auto max-w-[900px] px-6 py-10 sm:px-8 md:px-12'>
|
||||
<script
|
||||
type='application/ld+json'
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
|
||||
/>
|
||||
<h1 className='mb-6 font-[500] text-[32px] text-[var(--landing-text)] leading-tight'>
|
||||
Browse by tag
|
||||
</h1>
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import dynamic from 'next/dynamic'
|
||||
import Link from 'next/link'
|
||||
import { DemoRequestModal } from '@/app/(landing)/components/demo-request/demo-request-modal'
|
||||
|
||||
const LandingPreview = dynamic(
|
||||
() =>
|
||||
import('@/app/(landing)/components/landing-preview/landing-preview').then(
|
||||
(mod) => mod.LandingPreview
|
||||
),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <div className='aspect-[1116/615] w-full rounded bg-[var(--landing-bg)]' />,
|
||||
}
|
||||
)
|
||||
|
||||
/** Shared base classes for CTA link buttons — matches Deploy/Run button styling in the preview panel. */
|
||||
const CTA_BASE =
|
||||
'inline-flex items-center h-[32px] rounded-[5px] border px-2.5 font-[430] font-season text-sm'
|
||||
|
||||
export default function Hero() {
|
||||
return (
|
||||
<section
|
||||
id='hero'
|
||||
aria-labelledby='hero-heading'
|
||||
itemScope
|
||||
itemType='https://schema.org/WebApplication'
|
||||
className='relative flex flex-col items-center overflow-hidden bg-[var(--landing-bg)] pt-[60px] lg:pt-[100px]'
|
||||
>
|
||||
<p className='sr-only'>
|
||||
Sim is an open-source AI agent platform. Sim lets teams build AI agents and run an agentic
|
||||
workforce by connecting 1,000+ integrations and LLMs — including OpenAI, Anthropic Claude,
|
||||
Google Gemini, Mistral, and xAI Grok — to deploy and orchestrate agentic workflows. Users
|
||||
create agents, workflows, knowledge bases, tables, and docs. Sim is trusted by over 100,000
|
||||
builders at startups and Fortune 500 companies. Sim is SOC2 compliant.
|
||||
</p>
|
||||
|
||||
<div className='relative z-10 flex flex-col items-center gap-3'>
|
||||
<h1
|
||||
id='hero-heading'
|
||||
itemProp='name'
|
||||
className='text-balance font-[430] font-season text-[36px] text-white leading-[100%] tracking-[-0.02em] sm:text-[48px] lg:text-[72px]'
|
||||
>
|
||||
Build AI Agents
|
||||
</h1>
|
||||
<p
|
||||
itemProp='description'
|
||||
className='whitespace-nowrap text-center font-[430] font-season text-[4.4vw] text-[color-mix(in_srgb,var(--landing-text-subtle)_60%,transparent)] leading-[125%] tracking-[0.02em] sm:whitespace-normal sm:text-lg lg:text-xl'
|
||||
>
|
||||
Sim is the AI Workspace for Agent Builders
|
||||
</p>
|
||||
|
||||
<div className='mt-3 flex items-center gap-2'>
|
||||
<DemoRequestModal theme='light'>
|
||||
<button
|
||||
type='button'
|
||||
className={`${CTA_BASE} border-[var(--landing-border-strong)] bg-transparent text-[var(--landing-text)] transition-colors hover:bg-[var(--landing-bg-elevated)]`}
|
||||
aria-label='Get a demo'
|
||||
>
|
||||
Get a demo
|
||||
</button>
|
||||
</DemoRequestModal>
|
||||
<Link
|
||||
href='/signup'
|
||||
className={`${CTA_BASE} gap-2 border-white bg-white text-black transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]`}
|
||||
aria-label='Get started with Sim'
|
||||
>
|
||||
Get started
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='relative z-10 mx-auto mt-6 w-[92vw] px-[1.6vw] lg:mt-[3.2vw] lg:w-full lg:px-16'>
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='absolute top-0 left-0 z-20 hidden h-px w-[calc(4rem+4px)] bg-[var(--landing-bg-elevated)] lg:block'
|
||||
/>
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='absolute top-0 right-0 z-20 hidden h-px w-[calc(4rem+4px)] bg-[var(--landing-bg-elevated)] lg:block'
|
||||
/>
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='absolute bottom-0 left-0 z-20 hidden h-px w-[calc(4rem+4px)] bg-[var(--landing-bg-elevated)] lg:block'
|
||||
/>
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='absolute right-0 bottom-0 z-20 hidden h-px w-[calc(4rem+4px)] bg-[var(--landing-bg-elevated)] lg:block'
|
||||
/>
|
||||
<div className='relative z-10 overflow-hidden rounded border border-[var(--landing-bg-elevated)]'>
|
||||
<LandingPreview />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,27 +1,4 @@
|
||||
import Collaboration from '@/app/(landing)/components/collaboration/collaboration'
|
||||
import Enterprise from '@/app/(landing)/components/enterprise/enterprise'
|
||||
import ExternalRedirect from '@/app/(landing)/components/external-redirect'
|
||||
import Features from '@/app/(landing)/components/features/features'
|
||||
import Footer from '@/app/(landing)/components/footer/footer'
|
||||
import Hero from '@/app/(landing)/components/hero/hero'
|
||||
import LegalLayout from '@/app/(landing)/components/legal-layout'
|
||||
import Navbar from '@/app/(landing)/components/navbar/navbar'
|
||||
import Pricing from '@/app/(landing)/components/pricing/pricing'
|
||||
import StructuredData from '@/app/(landing)/components/structured-data'
|
||||
import Templates from '@/app/(landing)/components/templates/templates'
|
||||
import Testimonials from '@/app/(landing)/components/testimonials/testimonials'
|
||||
|
||||
export {
|
||||
Collaboration,
|
||||
Enterprise,
|
||||
ExternalRedirect,
|
||||
Features,
|
||||
Footer,
|
||||
Hero,
|
||||
LegalLayout,
|
||||
Navbar,
|
||||
Pricing,
|
||||
StructuredData,
|
||||
Templates,
|
||||
Testimonials,
|
||||
}
|
||||
export { LegalLayout, ExternalRedirect }
|
||||
|
||||
@@ -1,479 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { ArrowUp, Table } from 'lucide-react'
|
||||
import { Blimp, Checkbox, ChevronDown } from '@/components/emcn'
|
||||
import { TypeBoolean, TypeNumber, TypeText } from '@/components/emcn/icons'
|
||||
import { useLandingSubmit } from '@/app/(landing)/components/landing-preview/components/landing-preview-panel/landing-preview-panel'
|
||||
import { EASE_OUT } from '@/app/(landing)/components/landing-preview/components/landing-preview-workflow/workflow-data'
|
||||
import { useAnimatedPlaceholder } from '@/hooks/use-animated-placeholder'
|
||||
|
||||
const C = {
|
||||
SURFACE: '#292929',
|
||||
BORDER: '#3d3d3d',
|
||||
TEXT_PRIMARY: '#e6e6e6',
|
||||
TEXT_BODY: '#cdcdcd',
|
||||
TEXT_SECONDARY: '#b3b3b3',
|
||||
TEXT_TERTIARY: '#939393',
|
||||
TEXT_ICON: '#939393',
|
||||
} 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 textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
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
|
||||
landingSubmit(inputValue)
|
||||
}, [isEmpty, inputValue, landingSubmit])
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSubmit()
|
||||
}
|
||||
},
|
||||
[handleSubmit]
|
||||
)
|
||||
|
||||
const handleInput = useCallback((e: React.FormEvent<HTMLTextAreaElement>) => {
|
||||
const target = e.target as HTMLTextAreaElement
|
||||
target.style.height = 'auto'
|
||||
target.style.height = `${Math.min(target.scrollHeight, 200)}px`
|
||||
}, [])
|
||||
|
||||
if (chatPhase !== 'input') {
|
||||
const isResponding = chatPhase === 'responding' || chatPhase === 'done'
|
||||
const showToolCall = chatPhase === 'tool-call' || isResponding
|
||||
|
||||
return (
|
||||
<div className='flex min-w-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 */}
|
||||
<motion.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-[#363636] px-3.5 py-2'>
|
||||
<p
|
||||
className='font-body text-[14px] leading-[1.5]'
|
||||
style={{ color: C.TEXT_PRIMARY }}
|
||||
>
|
||||
{AUTO_PROMPT}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Assistant — no bubble, full-width prose */}
|
||||
<AnimatePresence>
|
||||
{showToolCall && (
|
||||
<motion.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 h-[16px] w-[16px] flex-shrink-0 items-center justify-center'>
|
||||
<Blimp className='h-[16px] w-[16px]' style={{ color: C.TEXT_ICON }} />
|
||||
</div>
|
||||
<span className='font-base text-sm' style={{ color: C.TEXT_BODY }}>
|
||||
Mothership
|
||||
</span>
|
||||
<ChevronDown
|
||||
className='h-[7px] w-[9px] transition-transform duration-150'
|
||||
style={{
|
||||
color: C.TEXT_ICON,
|
||||
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='h-[15px] w-[15px]'
|
||||
style={{ color: C.TEXT_TERTIARY }}
|
||||
/>
|
||||
}
|
||||
title='Read Customer Leads'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Response prose — full width, no card */}
|
||||
{isResponding && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.2, ease: EASE_OUT }}
|
||||
>
|
||||
<ChatMarkdown
|
||||
content={MOCK_RESPONSE}
|
||||
visibleLength={responseTypedLength}
|
||||
isTyping={chatPhase === 'responding'}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Resource panel — slides in from right */}
|
||||
<AnimatePresence>
|
||||
{showResourcePanel && (
|
||||
<motion.div
|
||||
className='hidden h-full flex-shrink-0 overflow-hidden border-[#2c2c2c] border-l lg:flex'
|
||||
initial={{ width: 0, opacity: 0 }}
|
||||
animate={{ width: '55%', opacity: 1 }}
|
||||
transition={{ duration: 0.35, ease: EASE_OUT }}
|
||||
>
|
||||
<MiniTablePanel />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex min-w-0 flex-1 flex-col items-center justify-center px-6 pb-[2vh]'>
|
||||
<motion.p
|
||||
role='presentation'
|
||||
className='mb-6 max-w-[42rem] font-[430] font-season text-[32px] tracking-[-0.02em]'
|
||||
style={{ color: C.TEXT_PRIMARY }}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, ease: EASE_OUT }}
|
||||
>
|
||||
What should we get done?
|
||||
</motion.p>
|
||||
|
||||
<motion.div
|
||||
className='w-full max-w-[32rem]'
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, delay: 0.1, ease: EASE_OUT }}
|
||||
>
|
||||
<div
|
||||
className='cursor-text rounded-[20px] border px-2.5 py-2'
|
||||
style={{ borderColor: C.BORDER, backgroundColor: C.SURFACE }}
|
||||
onClick={() => textareaRef.current?.focus()}
|
||||
>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={inputValue}
|
||||
onChange={(e) => {
|
||||
if (!autoType) setInputValue(e.target.value)
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
onInput={handleInput}
|
||||
placeholder={animatedPlaceholder}
|
||||
rows={1}
|
||||
readOnly={autoType}
|
||||
className='m-0 box-border min-h-[24px] w-full resize-none overflow-y-auto border-0 bg-transparent px-1 py-1 font-body text-[15px] leading-[24px] tracking-[-0.015em] outline-none placeholder:font-[380] placeholder:text-[#787878] focus-visible:ring-0'
|
||||
style={{
|
||||
color: C.TEXT_PRIMARY,
|
||||
caretColor: autoType ? 'transparent' : C.TEXT_PRIMARY,
|
||||
maxHeight: '200px',
|
||||
}}
|
||||
/>
|
||||
<div className='flex items-center justify-end'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleSubmit}
|
||||
disabled={isEmpty}
|
||||
className='flex h-[28px] w-[28px] items-center justify-center rounded-full border-0 p-0 transition-colors'
|
||||
style={{
|
||||
background: isEmpty ? '#808080' : '#e0e0e0',
|
||||
cursor: isEmpty ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
>
|
||||
<ArrowUp size={16} strokeWidth={2.25} color='#1b1b1b' />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* 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 h-[16px] w-[16px] flex-shrink-0 items-center justify-center'>{icon}</div>
|
||||
<span className='font-base text-[13px]' style={{ color: C.TEXT_SECONDARY }}>
|
||||
{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] leading-[1.6]' style={{ color: C.TEXT_PRIMARY }}>
|
||||
<span dangerouslySetInnerHTML={{ __html: rendered }} />
|
||||
{isTyping && (
|
||||
<motion.span
|
||||
className='inline-block h-[14px] w-[1.5px] translate-y-[2px] bg-[#e6e6e6]'
|
||||
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(--landing-bg)]'>
|
||||
<div className='flex items-center gap-2 border-[#2c2c2c] border-b px-3 py-2'>
|
||||
<Table className='h-[14px] w-[14px]' style={{ color: C.TEXT_ICON }} />
|
||||
<span className='font-medium text-sm' style={{ color: C.TEXT_PRIMARY }}>
|
||||
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-[#2c2c2c] border-r border-b bg-[#1e1e1e] p-0 text-left'
|
||||
>
|
||||
<div className='flex items-center gap-1 px-2 py-1.5'>
|
||||
<Icon className='h-3 w-3 shrink-0' style={{ color: C.TEXT_ICON }} />
|
||||
<span className='font-medium text-[11px]' style={{ color: C.TEXT_PRIMARY }}>
|
||||
{col.label}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className='ml-auto h-[6px] w-[8px]'
|
||||
style={{ color: '#636363' }}
|
||||
/>
|
||||
</div>
|
||||
</th>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{MINI_TABLE_ROWS.map((row, i) => (
|
||||
<motion.tr
|
||||
key={i}
|
||||
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-[#2c2c2c] border-r border-b px-2 py-1.5'
|
||||
style={{ color: C.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>
|
||||
)
|
||||
})}
|
||||
</motion.tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,474 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { ArrowUp } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Blimp, BubbleChatPreview, ChevronDown, MoreHorizontal, Play } from '@/components/emcn'
|
||||
import { AgentIcon, HubspotIcon, OpenAIIcon, SalesforceIcon } from '@/components/icons'
|
||||
import { LandingPromptStorage } from '@/lib/core/utils/browser-storage'
|
||||
import {
|
||||
EASE_OUT,
|
||||
type EditorPromptData,
|
||||
getEditorPrompt,
|
||||
getWorkflowAnimationTiming,
|
||||
type PreviewWorkflow,
|
||||
TYPE_INTERVAL_MS,
|
||||
TYPE_START_BUFFER_MS,
|
||||
} from '@/app/(landing)/components/landing-preview/components/landing-preview-workflow/workflow-data'
|
||||
|
||||
type PanelTab = 'copilot' | 'editor'
|
||||
|
||||
const EDITOR_BLOCK_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
agent: AgentIcon,
|
||||
mothership: Blimp,
|
||||
}
|
||||
|
||||
const TABS_WITH_TOOLBAR: { id: PanelTab | 'toolbar'; label: string; disabled?: boolean }[] = [
|
||||
{ id: 'copilot', label: 'Copilot' },
|
||||
{ id: 'toolbar', label: 'Toolbar', disabled: true },
|
||||
{ id: 'editor', label: 'Editor' },
|
||||
]
|
||||
|
||||
/**
|
||||
* Stores the prompt in browser storage and redirects to /signup.
|
||||
* Shared by both the copilot panel and the landing home view.
|
||||
*/
|
||||
export function useLandingSubmit() {
|
||||
const router = useRouter()
|
||||
return useCallback(
|
||||
(text: string) => {
|
||||
const trimmed = text.trim()
|
||||
if (!trimmed) return
|
||||
LandingPromptStorage.store(trimmed)
|
||||
router.push('/signup')
|
||||
},
|
||||
[router]
|
||||
)
|
||||
}
|
||||
|
||||
interface LandingPreviewPanelProps {
|
||||
activeWorkflow?: PreviewWorkflow
|
||||
animationKey?: number
|
||||
onHighlightBlock?: (blockId: string | null) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Workspace panel replica with switchable Copilot / Editor tabs.
|
||||
*
|
||||
* On every workflow switch (`animationKey` change):
|
||||
* 1. Resets to Copilot tab.
|
||||
* 2. Waits for blocks + edges to finish animating.
|
||||
* 3. Slides the tab indicator to Editor and types the agent's prompt.
|
||||
* 4. Highlights the agent block with the blue ring on the canvas.
|
||||
*/
|
||||
export const LandingPreviewPanel = memo(function LandingPreviewPanel({
|
||||
activeWorkflow,
|
||||
animationKey = 0,
|
||||
onHighlightBlock,
|
||||
}: LandingPreviewPanelProps) {
|
||||
const landingSubmit = useLandingSubmit()
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const [cursorPos, setCursorPos] = useState<{ x: number; y: number } | null>(null)
|
||||
|
||||
const [activeTab, setActiveTab] = useState<PanelTab>('copilot')
|
||||
const [typedLength, setTypedLength] = useState(0)
|
||||
|
||||
const workflowRef = useRef(activeWorkflow)
|
||||
workflowRef.current = activeWorkflow
|
||||
const typeIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
const editorPrompt = activeWorkflow ? getEditorPrompt(activeWorkflow) : null
|
||||
|
||||
const userSwitchedTabRef = useRef(false)
|
||||
|
||||
const handleTabSwitch = useCallback(
|
||||
(tab: PanelTab) => {
|
||||
userSwitchedTabRef.current = true
|
||||
setActiveTab(tab)
|
||||
if (tab === 'editor' && editorPrompt) {
|
||||
onHighlightBlock?.(editorPrompt.blockId)
|
||||
} else {
|
||||
onHighlightBlock?.(null)
|
||||
}
|
||||
},
|
||||
[editorPrompt, onHighlightBlock]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (userSwitchedTabRef.current) return
|
||||
|
||||
setActiveTab('copilot')
|
||||
setTypedLength(0)
|
||||
onHighlightBlock?.(null)
|
||||
if (typeIntervalRef.current) clearInterval(typeIntervalRef.current)
|
||||
|
||||
const workflow = workflowRef.current
|
||||
if (!workflow) return
|
||||
|
||||
const prompt = workflow ? getEditorPrompt(workflow) : null
|
||||
if (!prompt) return
|
||||
|
||||
const { editorDelay } = getWorkflowAnimationTiming(workflow)
|
||||
|
||||
const switchTimer = setTimeout(() => {
|
||||
if (userSwitchedTabRef.current) return
|
||||
setActiveTab('editor')
|
||||
onHighlightBlock?.(prompt.blockId)
|
||||
}, editorDelay)
|
||||
|
||||
const typeTimer = setTimeout(() => {
|
||||
if (userSwitchedTabRef.current) return
|
||||
let charIndex = 0
|
||||
typeIntervalRef.current = setInterval(() => {
|
||||
charIndex++
|
||||
setTypedLength(charIndex)
|
||||
if (charIndex >= prompt.prompt.length) {
|
||||
if (typeIntervalRef.current) clearInterval(typeIntervalRef.current)
|
||||
typeIntervalRef.current = null
|
||||
}
|
||||
}, TYPE_INTERVAL_MS)
|
||||
}, editorDelay + TYPE_START_BUFFER_MS)
|
||||
|
||||
return () => {
|
||||
clearTimeout(switchTimer)
|
||||
clearTimeout(typeTimer)
|
||||
if (typeIntervalRef.current) {
|
||||
clearInterval(typeIntervalRef.current)
|
||||
typeIntervalRef.current = null
|
||||
}
|
||||
}
|
||||
}, [animationKey, onHighlightBlock])
|
||||
|
||||
const isEmpty = inputValue.trim().length === 0
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (isEmpty) return
|
||||
landingSubmit(inputValue)
|
||||
}, [isEmpty, inputValue, landingSubmit])
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSubmit()
|
||||
}
|
||||
},
|
||||
[handleSubmit]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className='flex h-full w-[280px] flex-shrink-0 flex-col bg-[#1e1e1e]'>
|
||||
<div className='flex h-full flex-col border-[#2c2c2c] border-l pt-3.5'>
|
||||
{/* Header */}
|
||||
<div className='flex flex-shrink-0 items-center justify-between px-2'>
|
||||
<div className='pointer-events-none flex gap-1.5'>
|
||||
<div className='flex h-[30px] w-[30px] items-center justify-center rounded-[5px] border border-[#3d3d3d] bg-[#363636]'>
|
||||
<MoreHorizontal className='h-[14px] w-[14px] text-[#e6e6e6]' />
|
||||
</div>
|
||||
<div className='flex h-[30px] w-[30px] items-center justify-center rounded-[5px] border border-[#3d3d3d] bg-[#363636]'>
|
||||
<BubbleChatPreview className='h-[14px] w-[14px] text-[#e6e6e6]' />
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href='/signup'
|
||||
className='flex gap-1.5'
|
||||
onMouseMove={(e) => setCursorPos({ x: e.clientX, y: e.clientY })}
|
||||
onMouseLeave={() => setCursorPos(null)}
|
||||
>
|
||||
<div className='flex h-[30px] items-center rounded-[5px] bg-[#33C482] px-2.5 transition-colors hover:bg-[#2DAC72]'>
|
||||
<span className='font-medium text-[#1b1b1b] text-[12px]'>Deploy</span>
|
||||
</div>
|
||||
<div className='flex h-[30px] items-center gap-2 rounded-[5px] bg-[#33C482] px-2.5 transition-colors hover:bg-[#2DAC72]'>
|
||||
<Play className='h-[11.5px] w-[11.5px] text-[#1b1b1b]' />
|
||||
<span className='font-medium text-[#1b1b1b] text-[12px]'>Run</span>
|
||||
</div>
|
||||
</Link>
|
||||
{cursorPos &&
|
||||
createPortal(
|
||||
<div
|
||||
className='pointer-events-none fixed z-[9999]'
|
||||
style={{ left: cursorPos.x + 14, top: cursorPos.y + 14 }}
|
||||
>
|
||||
<div className='flex h-[4px]'>
|
||||
<div className='h-full w-[8px] bg-[#2ABBF8]' />
|
||||
<div className='h-full w-[14px] bg-[#2ABBF8] opacity-60' />
|
||||
<div className='h-full w-[8px] bg-[#00F701]' />
|
||||
<div className='h-full w-[16px] bg-[#00F701] opacity-60' />
|
||||
<div className='h-full w-[8px] bg-[#FFCC02]' />
|
||||
<div className='h-full w-[10px] bg-[#FFCC02] opacity-60' />
|
||||
<div className='h-full w-[8px] bg-[#FA4EDF]' />
|
||||
<div className='h-full w-[14px] bg-[#FA4EDF] opacity-60' />
|
||||
</div>
|
||||
<div className='flex items-center gap-[5px] bg-white px-1.5 py-1 font-medium text-[#1C1C1C] text-[11px]'>
|
||||
Get started
|
||||
<ChevronDown className='-rotate-90 h-[7px] w-[7px] text-[#1C1C1C]' />
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tabs with sliding active indicator */}
|
||||
<div className='flex flex-shrink-0 items-center px-2 pt-3.5'>
|
||||
<div className='flex gap-1'>
|
||||
{TABS_WITH_TOOLBAR.map((tab) => {
|
||||
if (tab.disabled) {
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
className='pointer-events-none flex h-[28px] items-center rounded-md border border-transparent px-2 py-[5px]'
|
||||
>
|
||||
<span className='font-medium text-[#787878] text-[12.5px]'>{tab.label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const isActive = activeTab === tab.id
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
type='button'
|
||||
onClick={() => handleTabSwitch(tab.id as PanelTab)}
|
||||
className='relative flex h-[28px] items-center rounded-md border border-transparent px-2 py-[5px] font-medium text-[12.5px] transition-colors hover:border-[#3d3d3d] hover:bg-[#363636] hover:text-[#e6e6e6]'
|
||||
style={{ color: isActive ? '#e6e6e6' : '#787878' }}
|
||||
>
|
||||
{isActive && (
|
||||
<motion.div
|
||||
layoutId='panel-tab-indicator'
|
||||
className='absolute inset-0 rounded-md border border-[#3d3d3d] bg-[#363636]'
|
||||
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
|
||||
/>
|
||||
)}
|
||||
<span className='relative z-10'>{tab.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab content with cross-fade */}
|
||||
<div className='flex flex-1 flex-col overflow-hidden pt-3'>
|
||||
<AnimatePresence mode='wait'>
|
||||
{activeTab === 'copilot' && (
|
||||
<motion.div
|
||||
key='copilot'
|
||||
className='flex h-full flex-col'
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15, ease: EASE_OUT }}
|
||||
>
|
||||
<div className='pointer-events-none mx-[-1px] flex flex-shrink-0 items-center justify-between gap-2 border border-[#2c2c2c] bg-[#292929] px-3 py-1.5'>
|
||||
<span className='min-w-0 flex-1 truncate font-medium text-[#e6e6e6] text-[14px]'>
|
||||
New Chat
|
||||
</span>
|
||||
</div>
|
||||
<div className='px-2 pt-3 pb-2'>
|
||||
<div className='rounded-[4px] border border-[#3d3d3d] bg-[#292929] px-1.5 py-1.5'>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder='Build an AI agent...'
|
||||
rows={2}
|
||||
className='mb-1.5 min-h-[48px] w-full cursor-text resize-none border-0 bg-transparent px-0.5 py-1 font-base text-[#e6e6e6] text-sm leading-[1.25rem] placeholder-[#787878] caret-[#e6e6e6] outline-none'
|
||||
/>
|
||||
<div className='flex items-center justify-end'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleSubmit}
|
||||
disabled={isEmpty}
|
||||
className='flex h-[22px] w-[22px] items-center justify-center rounded-full border-0 p-0 transition-colors'
|
||||
style={{
|
||||
background: isEmpty ? '#808080' : '#e0e0e0',
|
||||
cursor: isEmpty ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
>
|
||||
<ArrowUp size={14} strokeWidth={2.25} color='#1b1b1b' />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{activeTab === 'editor' && (
|
||||
<motion.div
|
||||
key='editor'
|
||||
className='flex h-full flex-col'
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15, ease: EASE_OUT }}
|
||||
>
|
||||
<EditorTabContent editorPrompt={editorPrompt} typedLength={typedLength} />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
const TOOL_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
hubspot: HubspotIcon,
|
||||
salesforce: SalesforceIcon,
|
||||
}
|
||||
|
||||
const MODEL_ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
'gpt-': OpenAIIcon,
|
||||
}
|
||||
|
||||
function getModelIcon(model: string) {
|
||||
const lower = model.toLowerCase()
|
||||
for (const [prefix, icon] of Object.entries(MODEL_ICON_MAP)) {
|
||||
if (lower.startsWith(prefix)) return icon
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
interface EditorTabContentProps {
|
||||
editorPrompt: EditorPromptData | null
|
||||
typedLength: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Editor tab replicating the real agent editor layout:
|
||||
* header bar, then scrollable sub-block fields.
|
||||
*/
|
||||
function EditorTabContent({ editorPrompt, typedLength }: EditorTabContentProps) {
|
||||
if (!editorPrompt) {
|
||||
return (
|
||||
<div className='flex flex-1 items-center justify-center'>
|
||||
<span className='font-medium text-[#787878] text-[13px]'>Select a block to edit</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const { blockName, blockType, bgColor, prompt, model, tools } = editorPrompt
|
||||
const visibleText = prompt.slice(0, typedLength)
|
||||
const isTyping = typedLength < prompt.length
|
||||
const BlockIcon = EDITOR_BLOCK_ICONS[blockType]
|
||||
const ModelIcon = model ? getModelIcon(model) : null
|
||||
|
||||
return (
|
||||
<div className='flex h-full flex-col'>
|
||||
{/* Editor header */}
|
||||
<div className='mx-[-1px] flex flex-shrink-0 items-center gap-2 border border-[#2c2c2c] bg-[#292929] px-3 py-1.5'>
|
||||
{BlockIcon && (
|
||||
<div
|
||||
className='flex h-[18px] w-[18px] flex-shrink-0 items-center justify-center rounded-sm'
|
||||
style={{ background: bgColor }}
|
||||
>
|
||||
<BlockIcon className='h-[12px] w-[12px] text-white' />
|
||||
</div>
|
||||
)}
|
||||
<span className='min-w-0 flex-1 truncate font-medium text-[#e6e6e6] text-sm'>
|
||||
{blockName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Sub-block fields */}
|
||||
<div className='flex-1 overflow-y-auto overflow-x-hidden px-2 pt-3 pb-2'>
|
||||
<div className='flex flex-col gap-4'>
|
||||
{/* System Prompt */}
|
||||
<div className='flex flex-col gap-2.5'>
|
||||
<div className='flex items-center pl-0.5'>
|
||||
<span className='font-medium text-[#e6e6e6] text-small'>System Prompt</span>
|
||||
</div>
|
||||
<div className='rounded-[4px] border border-[#3d3d3d] bg-[#292929] px-2 py-2'>
|
||||
<p className='min-h-[48px] whitespace-pre-wrap break-words font-medium font-sans text-[#e6e6e6] text-sm leading-[1.5]'>
|
||||
{visibleText}
|
||||
{isTyping && (
|
||||
<motion.span
|
||||
className='inline-block h-[14px] w-[1.5px] translate-y-[2px] bg-[#e6e6e6]'
|
||||
animate={{ opacity: [1, 0] }}
|
||||
transition={{
|
||||
duration: 0.6,
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
repeatType: 'reverse',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Model */}
|
||||
{model && (
|
||||
<div className='flex flex-col gap-2.5'>
|
||||
<div className='flex items-center pl-0.5'>
|
||||
<span className='font-medium text-[#e6e6e6] text-small'>Model</span>
|
||||
</div>
|
||||
<div className='flex h-[32px] items-center gap-2 rounded-[4px] border border-[#3d3d3d] bg-[#292929] px-2'>
|
||||
{ModelIcon && <ModelIcon className='h-[14px] w-[14px] text-[#e6e6e6]' />}
|
||||
<span className='flex-1 truncate font-medium text-[#e6e6e6] text-sm'>{model}</span>
|
||||
<ChevronDown className='h-[7px] w-[9px] text-[#636363]' />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tools */}
|
||||
{tools.length > 0 && (
|
||||
<div className='flex flex-col gap-2.5'>
|
||||
<div className='flex items-center pl-0.5'>
|
||||
<span className='font-medium text-[#e6e6e6] text-small'>Tools</span>
|
||||
</div>
|
||||
<div className='flex flex-wrap gap-[5px]'>
|
||||
{tools.map((tool) => {
|
||||
const ToolIcon = TOOL_ICONS[tool.type]
|
||||
return (
|
||||
<div
|
||||
key={tool.type}
|
||||
className='flex items-center gap-[5px] rounded-[5px] border border-[#3d3d3d] bg-[#2a2a2a] px-[6px] py-[3px]'
|
||||
>
|
||||
{ToolIcon && (
|
||||
<div
|
||||
className='flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center rounded-[4px]'
|
||||
style={{ background: tool.bgColor }}
|
||||
>
|
||||
<ToolIcon className='h-[10px] w-[10px] text-white' />
|
||||
</div>
|
||||
)}
|
||||
<span className='font-normal text-[#e6e6e6] text-[12px]'>{tool.name}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Temperature */}
|
||||
<div className='flex flex-col gap-2.5'>
|
||||
<div className='flex items-center justify-between pl-0.5'>
|
||||
<span className='font-medium text-[#e6e6e6] text-small'>Temperature</span>
|
||||
<span className='font-medium text-[#787878] text-small'>0.7</span>
|
||||
</div>
|
||||
<div className='relative h-[6px] rounded-full bg-[#3d3d3d]'>
|
||||
<div className='h-full w-[70%] rounded-full bg-[#e6e6e6]' />
|
||||
<div
|
||||
className='-translate-y-1/2 absolute top-1/2 h-[14px] w-[14px] rounded-full border-[#e6e6e6] border-[2px] bg-[#292929]'
|
||||
style={{ left: 'calc(70% - 7px)' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Response Format */}
|
||||
<div className='flex flex-col gap-2.5'>
|
||||
<div className='flex items-center pl-0.5'>
|
||||
<span className='font-medium text-[#e6e6e6] text-small'>Response Format</span>
|
||||
</div>
|
||||
<div className='rounded-[4px] border border-[#3d3d3d] bg-[#292929] px-2 py-2'>
|
||||
<span className='font-mono text-[#787878] text-[12px]'>plain text</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,322 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { AnimatePresence, motion, type Variants } from 'framer-motion'
|
||||
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 { LandingPreviewPanel } from '@/app/(landing)/components/landing-preview/components/landing-preview-panel/landing-preview-panel'
|
||||
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 { 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,
|
||||
getWorkflowStepDuration,
|
||||
PREVIEW_WORKFLOWS,
|
||||
} from '@/app/(landing)/components/landing-preview/components/landing-preview-workflow/workflow-data'
|
||||
|
||||
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 panelVariants: 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' | 'tables' | 'home' | 'logs'
|
||||
workflowId?: string
|
||||
tableId?: 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
|
||||
|
||||
/** 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')!),
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function LandingPreview() {
|
||||
const [activeView, setActiveView] = useState<SidebarView>('workflow')
|
||||
const [activeWorkflowId, setActiveWorkflowId] = useState(PREVIEW_WORKFLOWS[0].id)
|
||||
const animationKeyRef = useRef(0)
|
||||
const [animationKey, setAnimationKey] = useState(0)
|
||||
const [highlightedBlockId, setHighlightedBlockId] = useState<string | null>(null)
|
||||
const [autoTableId, setAutoTableId] = useState<string | null>(null)
|
||||
const [autoTypeHome, setAutoTypeHome] = useState(false)
|
||||
const [isDesktop, setIsDesktop] = useState(true)
|
||||
|
||||
const demoIndexRef = useRef(0)
|
||||
const demoTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const autoCycleActiveRef = useRef(true)
|
||||
const isDesktopRef = useRef(true)
|
||||
|
||||
const clearDemoTimer = useCallback(() => {
|
||||
if (demoTimerRef.current) {
|
||||
clearTimeout(demoTimerRef.current)
|
||||
demoTimerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const applyDemoStep = useCallback((step: DemoStep) => {
|
||||
setAutoTableId(null)
|
||||
setAutoTypeHome(false)
|
||||
|
||||
if (step.type === 'workflow' && step.workflowId) {
|
||||
setActiveWorkflowId(step.workflowId)
|
||||
setActiveView('workflow')
|
||||
animationKeyRef.current += 1
|
||||
setAnimationKey(animationKeyRef.current)
|
||||
} else if (step.type === 'tables') {
|
||||
setActiveView('tables')
|
||||
setAutoTableId(step.tableId ?? null)
|
||||
} 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(() => {
|
||||
const desktop = window.matchMedia('(min-width: 1024px)').matches
|
||||
isDesktopRef.current = desktop
|
||||
setIsDesktop(desktop)
|
||||
if (!desktop) return
|
||||
applyDemoStep(DESKTOP_STEPS[0])
|
||||
scheduleNextStep()
|
||||
return clearDemoTimer
|
||||
}, [applyDemoStep, scheduleNextStep, clearDemoTimer])
|
||||
|
||||
const stopAutoCycle = useCallback(() => {
|
||||
autoCycleActiveRef.current = false
|
||||
clearDemoTimer()
|
||||
}, [clearDemoTimer])
|
||||
|
||||
const handleSelectWorkflow = useCallback(
|
||||
(id: string) => {
|
||||
stopAutoCycle()
|
||||
setAutoTableId(null)
|
||||
setAutoTypeHome(false)
|
||||
setHighlightedBlockId(null)
|
||||
setActiveWorkflowId(id)
|
||||
setActiveView('workflow')
|
||||
animationKeyRef.current += 1
|
||||
setAnimationKey(animationKeyRef.current)
|
||||
},
|
||||
[stopAutoCycle]
|
||||
)
|
||||
|
||||
const handleSelectHome = useCallback(() => {
|
||||
stopAutoCycle()
|
||||
setAutoTableId(null)
|
||||
setAutoTypeHome(false)
|
||||
setHighlightedBlockId(null)
|
||||
setActiveView('home')
|
||||
}, [stopAutoCycle])
|
||||
|
||||
const handleSelectNav = useCallback(
|
||||
(id: SidebarView) => {
|
||||
stopAutoCycle()
|
||||
setAutoTableId(null)
|
||||
setAutoTypeHome(false)
|
||||
setHighlightedBlockId(null)
|
||||
setActiveView(id)
|
||||
},
|
||||
[stopAutoCycle]
|
||||
)
|
||||
|
||||
const handleHighlightBlock = useCallback((blockId: string | null) => {
|
||||
setHighlightedBlockId(blockId)
|
||||
}, [])
|
||||
|
||||
const activeWorkflow =
|
||||
PREVIEW_WORKFLOWS.find((w) => w.id === activeWorkflowId) ?? PREVIEW_WORKFLOWS[0]
|
||||
|
||||
const isWorkflowView = activeView === 'workflow'
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className='dark flex aspect-[1116/615] w-full overflow-hidden rounded bg-[var(--landing-bg-surface)] antialiased'
|
||||
initial={isDesktop ? 'hidden' : false}
|
||||
animate='visible'
|
||||
variants={containerVariants}
|
||||
>
|
||||
<motion.div className='hidden lg:flex' variants={sidebarVariants}>
|
||||
<LandingPreviewSidebar
|
||||
workflows={PREVIEW_WORKFLOWS}
|
||||
activeWorkflowId={activeWorkflowId}
|
||||
activeView={activeView}
|
||||
onSelectWorkflow={handleSelectWorkflow}
|
||||
onSelectHome={handleSelectHome}
|
||||
onSelectNav={handleSelectNav}
|
||||
/>
|
||||
</motion.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-[#2c2c2c] bg-[var(--landing-bg)]'>
|
||||
<div
|
||||
className={
|
||||
isWorkflowView
|
||||
? 'relative min-w-0 flex-1 overflow-hidden'
|
||||
: 'relative flex min-w-0 flex-1 flex-col overflow-hidden'
|
||||
}
|
||||
>
|
||||
{isDesktop ? (
|
||||
<AnimatePresence mode='wait'>
|
||||
{activeView === 'workflow' && (
|
||||
<motion.div
|
||||
key={`wf-${activeWorkflow.id}-${animationKey}`}
|
||||
className='h-full w-full'
|
||||
{...viewTransition}
|
||||
>
|
||||
<LandingPreviewWorkflow
|
||||
workflow={activeWorkflow}
|
||||
animate
|
||||
highlightedBlockId={highlightedBlockId}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
{activeView === 'home' && (
|
||||
<motion.div
|
||||
key={`home-${animationKey}`}
|
||||
className='flex h-full w-full flex-col'
|
||||
{...viewTransition}
|
||||
>
|
||||
<LandingPreviewHome autoType={autoTypeHome} />
|
||||
</motion.div>
|
||||
)}
|
||||
{activeView === 'tables' && (
|
||||
<motion.div
|
||||
key={`tables-${animationKey}`}
|
||||
className='flex h-full w-full flex-col'
|
||||
{...viewTransition}
|
||||
>
|
||||
<LandingPreviewTables autoOpenTableId={autoTableId} />
|
||||
</motion.div>
|
||||
)}
|
||||
{activeView === 'files' && (
|
||||
<motion.div
|
||||
key='files'
|
||||
className='flex h-full w-full flex-col'
|
||||
{...viewTransition}
|
||||
>
|
||||
<LandingPreviewFiles />
|
||||
</motion.div>
|
||||
)}
|
||||
{activeView === 'knowledge' && (
|
||||
<motion.div
|
||||
key='knowledge'
|
||||
className='flex h-full w-full flex-col'
|
||||
{...viewTransition}
|
||||
>
|
||||
<LandingPreviewKnowledge />
|
||||
</motion.div>
|
||||
)}
|
||||
{activeView === 'logs' && (
|
||||
<motion.div key='logs' className='flex h-full w-full flex-col' initial={false}>
|
||||
<LandingPreviewLogs />
|
||||
</motion.div>
|
||||
)}
|
||||
{activeView === 'scheduled-tasks' && (
|
||||
<motion.div
|
||||
key='scheduled-tasks'
|
||||
className='flex h-full w-full flex-col'
|
||||
{...viewTransition}
|
||||
>
|
||||
<LandingPreviewScheduledTasks />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
) : (
|
||||
<div className='h-full w-full'>
|
||||
<LandingPreviewWorkflow workflow={activeWorkflow} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<motion.div
|
||||
className={isWorkflowView ? 'hidden lg:flex' : 'hidden'}
|
||||
variants={panelVariants}
|
||||
>
|
||||
<LandingPreviewPanel
|
||||
activeWorkflow={activeWorkflow}
|
||||
animationKey={animationKey}
|
||||
onHighlightBlock={handleHighlightBlock}
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { getNavBlogPosts } from '@/lib/blog/registry'
|
||||
import { isHosted } from '@/lib/core/config/feature-flags'
|
||||
import Footer from '@/app/(landing)/components/footer/footer'
|
||||
import Navbar from '@/app/(landing)/components/navbar/navbar'
|
||||
import Footer from '@/app/(home)/components/footer/footer'
|
||||
import Navbar from '@/app/(home)/components/navbar/navbar'
|
||||
|
||||
interface LegalLayoutProps {
|
||||
title: string
|
||||
|
||||
@@ -2327,17 +2327,9 @@
|
||||
{
|
||||
"name": "Delete Agent",
|
||||
"description": "Permanently delete a cloud agent. This action cannot be undone."
|
||||
},
|
||||
{
|
||||
"name": "List Artifacts",
|
||||
"description": "List generated artifact files for a cloud agent."
|
||||
},
|
||||
{
|
||||
"name": "Download Artifact",
|
||||
"description": "Download a generated artifact file from a cloud agent."
|
||||
}
|
||||
],
|
||||
"operationCount": 9,
|
||||
"operationCount": 7,
|
||||
"triggers": [],
|
||||
"triggerCount": 0,
|
||||
"authType": "api-key",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { getNavBlogPosts } from '@/lib/blog/registry'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import Footer from '@/app/(landing)/components/footer/footer'
|
||||
import Navbar from '@/app/(landing)/components/navbar/navbar'
|
||||
import Footer from '@/app/(home)/components/footer/footer'
|
||||
import Navbar from '@/app/(home)/components/navbar/navbar'
|
||||
|
||||
export default async function IntegrationsLayout({ children }: { children: React.ReactNode }) {
|
||||
const blogPosts = await getNavBlogPosts()
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { martianMono } from '@/app/_styles/fonts/martian-mono/martian-mono'
|
||||
import { season } from '@/app/_styles/fonts/season/season'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL('https://sim.ai'),
|
||||
@@ -15,15 +13,6 @@ export const metadata: Metadata = {
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Landing page route-group layout.
|
||||
*
|
||||
* Applies landing-specific font CSS variables to the subtree:
|
||||
* - `--font-season` (Season Sans): Headings and display text
|
||||
* - `--font-martian-mono` (Martian Mono): Code snippets and technical accents
|
||||
*
|
||||
* Available to child components via Tailwind (`font-season`, `font-martian-mono`).
|
||||
*/
|
||||
export default function LandingLayout({ children }: { children: React.ReactNode }) {
|
||||
return <div className={`${season.variable} ${martianMono.variable}`}>{children}</div>
|
||||
return children
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { getNavBlogPosts } from '@/lib/blog/registry'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import Footer from '@/app/(landing)/components/footer/footer'
|
||||
import Navbar from '@/app/(landing)/components/navbar/navbar'
|
||||
import Footer from '@/app/(home)/components/footer/footer'
|
||||
import Navbar from '@/app/(home)/components/navbar/navbar'
|
||||
|
||||
export default async function ModelsLayout({ children }: { children: React.ReactNode }) {
|
||||
const blogPosts = await getNavBlogPosts()
|
||||
|
||||
@@ -2,8 +2,8 @@ import type { Metadata } from 'next'
|
||||
import { getNavBlogPosts } from '@/lib/blog/registry'
|
||||
import { martianMono } from '@/app/_styles/fonts/martian-mono/martian-mono'
|
||||
import { season } from '@/app/_styles/fonts/season/season'
|
||||
import Footer from '@/app/(landing)/components/footer/footer'
|
||||
import Navbar from '@/app/(landing)/components/navbar/navbar'
|
||||
import Footer from '@/app/(home)/components/footer/footer'
|
||||
import Navbar from '@/app/(home)/components/navbar/navbar'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Partner Program',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type React from 'react'
|
||||
import { getNavBlogPosts } from '@/lib/blog/registry'
|
||||
import Footer from '@/app/(landing)/components/footer/footer'
|
||||
import Navbar from '@/app/(landing)/components/navbar/navbar'
|
||||
import Footer from '@/app/(home)/components/footer/footer'
|
||||
import Navbar from '@/app/(home)/components/navbar/navbar'
|
||||
|
||||
export default async function AcademyCatalogLayout({ children }: { children: React.ReactNode }) {
|
||||
const blogPosts = await getNavBlogPosts()
|
||||
|
||||
@@ -10,7 +10,8 @@ import { getFromEmailAddress } from '@/lib/messaging/email/utils'
|
||||
import {
|
||||
demoRequestSchema,
|
||||
getDemoRequestCompanySizeLabel,
|
||||
} from '@/app/(landing)/components/demo-request/consts'
|
||||
getDemoRequestRegionLabel,
|
||||
} from '@/app/(home)/components/demo-request/consts'
|
||||
|
||||
const logger = createLogger('DemoRequestAPI')
|
||||
const rateLimiter = new RateLimiter()
|
||||
@@ -57,11 +58,12 @@ export async function POST(req: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
const { firstName, lastName, companyEmail, phoneNumber, companySize, details } =
|
||||
const { firstName, lastName, companyEmail, phoneNumber, region, companySize, details } =
|
||||
validationResult.data
|
||||
|
||||
logger.info(`[${requestId}] Processing demo request`, {
|
||||
email: `${companyEmail.substring(0, 3)}***`,
|
||||
region,
|
||||
companySize,
|
||||
})
|
||||
|
||||
@@ -70,6 +72,7 @@ Submitted: ${new Date().toISOString()}
|
||||
Name: ${firstName} ${lastName}
|
||||
Email: ${companyEmail}
|
||||
Phone: ${phoneNumber ?? 'Not provided'}
|
||||
Region: ${getDemoRequestRegionLabel(region)}
|
||||
Company size: ${getDemoRequestCompanySizeLabel(companySize)}
|
||||
|
||||
Details:
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import {
|
||||
secureFetchWithPinnedIP,
|
||||
validateUrlWithDNS,
|
||||
} from '@/lib/core/security/input-validation.server'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('CursorDownloadArtifactAPI')
|
||||
|
||||
const DownloadArtifactSchema = z.object({
|
||||
apiKey: z.string().min(1, 'API key is required'),
|
||||
agentId: z.string().min(1, 'Agent ID is required'),
|
||||
path: z.string().min(1, 'Artifact path is required'),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
||||
|
||||
if (!authResult.success) {
|
||||
logger.warn(
|
||||
`[${requestId}] Unauthorized Cursor download artifact attempt: ${authResult.error}`
|
||||
)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: authResult.error || 'Authentication required' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Authenticated Cursor download artifact request via ${authResult.authType}`,
|
||||
{
|
||||
userId: authResult.userId,
|
||||
}
|
||||
)
|
||||
|
||||
const body = await request.json()
|
||||
const { apiKey, agentId, path } = DownloadArtifactSchema.parse(body)
|
||||
|
||||
const authHeader = `Basic ${Buffer.from(`${apiKey}:`).toString('base64')}`
|
||||
|
||||
logger.info(`[${requestId}] Requesting presigned URL for artifact`, { agentId, path })
|
||||
|
||||
const artifactResponse = await fetch(
|
||||
`https://api.cursor.com/v0/agents/${encodeURIComponent(agentId)}/artifacts/download?path=${encodeURIComponent(path)}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: authHeader,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if (!artifactResponse.ok) {
|
||||
const errorText = await artifactResponse.text().catch(() => '')
|
||||
logger.error(`[${requestId}] Failed to get artifact presigned URL`, {
|
||||
status: artifactResponse.status,
|
||||
error: errorText,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: errorText || `Failed to get artifact URL (${artifactResponse.status})`,
|
||||
},
|
||||
{ status: artifactResponse.status }
|
||||
)
|
||||
}
|
||||
|
||||
const artifactData = await artifactResponse.json()
|
||||
const downloadUrl = artifactData.url || artifactData.downloadUrl || artifactData.presignedUrl
|
||||
|
||||
if (!downloadUrl) {
|
||||
logger.error(`[${requestId}] No download URL in artifact response`, { artifactData })
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'No download URL returned for artifact' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const urlValidation = await validateUrlWithDNS(downloadUrl, 'downloadUrl')
|
||||
if (!urlValidation.isValid) {
|
||||
return NextResponse.json({ success: false, error: urlValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Downloading artifact from presigned URL`, { agentId, path })
|
||||
|
||||
const downloadResponse = await secureFetchWithPinnedIP(
|
||||
downloadUrl,
|
||||
urlValidation.resolvedIP!,
|
||||
{}
|
||||
)
|
||||
|
||||
if (!downloadResponse.ok) {
|
||||
logger.error(`[${requestId}] Failed to download artifact content`, {
|
||||
status: downloadResponse.status,
|
||||
statusText: downloadResponse.statusText,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: `Failed to download artifact content (${downloadResponse.status}: ${downloadResponse.statusText})`,
|
||||
},
|
||||
{ status: downloadResponse.status }
|
||||
)
|
||||
}
|
||||
|
||||
const contentType = downloadResponse.headers.get('content-type') || 'application/octet-stream'
|
||||
const arrayBuffer = await downloadResponse.arrayBuffer()
|
||||
const fileBuffer = Buffer.from(arrayBuffer)
|
||||
|
||||
const fileName = path.split('/').pop() || 'artifact'
|
||||
|
||||
logger.info(`[${requestId}] Artifact downloaded successfully`, {
|
||||
agentId,
|
||||
path,
|
||||
name: fileName,
|
||||
size: fileBuffer.length,
|
||||
mimeType: contentType,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
file: {
|
||||
name: fileName,
|
||||
mimeType: contentType,
|
||||
data: fileBuffer.toString('base64'),
|
||||
size: fileBuffer.length,
|
||||
},
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error downloading Cursor artifact:`, error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : 'Unknown error occurred' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { getNavBlogPosts } from '@/lib/blog/registry'
|
||||
import { martianMono } from '@/app/_styles/fonts/martian-mono/martian-mono'
|
||||
import Footer from '@/app/(landing)/components/footer/footer'
|
||||
import Navbar from '@/app/(landing)/components/navbar/navbar'
|
||||
import Footer from '@/app/(home)/components/footer/footer'
|
||||
import Navbar from '@/app/(home)/components/navbar/navbar'
|
||||
|
||||
export default async function ChangelogLayout({ children }: { children: React.ReactNode }) {
|
||||
const blogPosts = await getNavBlogPosts()
|
||||
|
||||
@@ -4,7 +4,7 @@ import { type RefObject, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { noop } from '@/lib/core/utils/request'
|
||||
import { getFormattedGitHubStars } from '@/app/(landing)/actions/github'
|
||||
import { getFormattedGitHubStars } from '@/app/(home)/actions/github'
|
||||
import {
|
||||
ChatErrorState,
|
||||
ChatHeader,
|
||||
|
||||
@@ -9,7 +9,7 @@ import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
||||
import AuthBackground from '@/app/(auth)/components/auth-background'
|
||||
import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes'
|
||||
import { SupportFooter } from '@/app/(auth)/components/support-footer'
|
||||
import Navbar from '@/app/(landing)/components/navbar/navbar'
|
||||
import Navbar from '@/app/(home)/components/navbar/navbar'
|
||||
|
||||
const logger = createLogger('EmailAuth')
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { cn } from '@/lib/core/utils/cn'
|
||||
import AuthBackground from '@/app/(auth)/components/auth-background'
|
||||
import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes'
|
||||
import { SupportFooter } from '@/app/(auth)/components/support-footer'
|
||||
import Navbar from '@/app/(landing)/components/navbar/navbar'
|
||||
import Navbar from '@/app/(home)/components/navbar/navbar'
|
||||
|
||||
const logger = createLogger('PasswordAuth')
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Skeleton } from '@/components/emcn'
|
||||
import AuthBackground from '@/app/(auth)/components/auth-background'
|
||||
import Navbar from '@/app/(landing)/components/navbar/navbar'
|
||||
import Navbar from '@/app/(home)/components/navbar/navbar'
|
||||
|
||||
export function FormLoadingState() {
|
||||
return (
|
||||
|
||||
@@ -7,7 +7,7 @@ import { cn } from '@/lib/core/utils/cn'
|
||||
import AuthBackground from '@/app/(auth)/components/auth-background'
|
||||
import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes'
|
||||
import { SupportFooter } from '@/app/(auth)/components/support-footer'
|
||||
import Navbar from '@/app/(landing)/components/navbar/navbar'
|
||||
import Navbar from '@/app/(home)/components/navbar/navbar'
|
||||
|
||||
interface PasswordAuthProps {
|
||||
onSubmit: (password: string) => void
|
||||
|
||||
@@ -7,7 +7,7 @@ import { martianMono } from '@/app/_styles/fonts/martian-mono/martian-mono'
|
||||
import AuthBackground from '@/app/(auth)/components/auth-background'
|
||||
import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes'
|
||||
import { SupportFooter } from '@/app/(auth)/components/support-footer'
|
||||
import Navbar from '@/app/(landing)/components/navbar/navbar'
|
||||
import Navbar from '@/app/(home)/components/navbar/navbar'
|
||||
import {
|
||||
FormErrorState,
|
||||
FormField,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { SupportFooter } from '@/app/(auth)/components/support-footer'
|
||||
import Navbar from '@/app/(landing)/components/navbar/navbar'
|
||||
import Navbar from '@/app/(home)/components/navbar/navbar'
|
||||
|
||||
interface InviteLayoutProps {
|
||||
children: React.ReactNode
|
||||
|
||||
@@ -3,7 +3,7 @@ import Link from 'next/link'
|
||||
import { getNavBlogPosts } from '@/lib/blog/registry'
|
||||
import AuthBackground from '@/app/(auth)/components/auth-background'
|
||||
import { AUTH_PRIMARY_CTA_BASE } from '@/app/(auth)/components/auth-button-classes'
|
||||
import Navbar from '@/app/(landing)/components/navbar/navbar'
|
||||
import Navbar from '@/app/(home)/components/navbar/navbar'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Page Not Found',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import Landing from '@/app/(landing)/landing'
|
||||
import Landing from '@/app/(home)/landing'
|
||||
|
||||
export const revalidate = 3600
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import Navbar from '@/app/(landing)/components/navbar/navbar'
|
||||
import Navbar from '@/app/(home)/components/navbar/navbar'
|
||||
import { useBrandConfig } from '@/ee/whitelabeling'
|
||||
import type { ResumeStatus } from '@/executor/types'
|
||||
|
||||
|
||||
@@ -45,8 +45,6 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
{
|
||||
url: `${baseUrl}/blog`,
|
||||
lastModified: now,
|
||||
changeFrequency: 'daily',
|
||||
priority: 0.8,
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/blog/tags`,
|
||||
@@ -74,8 +72,6 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
const blogPages: MetadataRoute.Sitemap = posts.map((p) => ({
|
||||
url: p.canonical,
|
||||
lastModified: new Date(p.updated ?? p.date),
|
||||
changeFrequency: 'weekly',
|
||||
priority: 0.7,
|
||||
}))
|
||||
|
||||
return [
|
||||
|
||||
@@ -16,9 +16,7 @@ import { CredentialsSkeleton } from '@/app/workspace/[workspaceId]/settings/comp
|
||||
import { CustomToolsSkeleton } from '@/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tool-skeleton'
|
||||
import { GeneralSkeleton } from '@/app/workspace/[workspaceId]/settings/components/general/general-skeleton'
|
||||
import { InboxSkeleton } from '@/app/workspace/[workspaceId]/settings/components/inbox/inbox-skeleton'
|
||||
import { IntegrationsSkeleton } from '@/app/workspace/[workspaceId]/settings/components/integrations/integrations-skeleton'
|
||||
import { McpSkeleton } from '@/app/workspace/[workspaceId]/settings/components/mcp/mcp-skeleton'
|
||||
import { RecentlyDeletedSkeleton } from '@/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted-skeleton'
|
||||
import { SkillsSkeleton } from '@/app/workspace/[workspaceId]/settings/components/skills/skill-skeleton'
|
||||
import { WorkflowMcpServersSkeleton } from '@/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers-skeleton'
|
||||
import type { SettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation'
|
||||
@@ -54,7 +52,7 @@ const Integrations = dynamic(
|
||||
import('@/app/workspace/[workspaceId]/settings/components/integrations/integrations').then(
|
||||
(m) => m.Integrations
|
||||
),
|
||||
{ loading: () => <IntegrationsSkeleton /> }
|
||||
{ loading: () => <CredentialsSkeleton /> }
|
||||
)
|
||||
const Credentials = dynamic(
|
||||
() =>
|
||||
@@ -147,7 +145,7 @@ const RecentlyDeleted = dynamic(
|
||||
import(
|
||||
'@/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted'
|
||||
).then((m) => m.RecentlyDeleted),
|
||||
{ loading: () => <RecentlyDeletedSkeleton /> }
|
||||
{ loading: () => <SettingsSectionSkeleton /> }
|
||||
)
|
||||
const AccessControl = dynamic(
|
||||
() => import('@/ee/access-control/components/access-control').then((m) => m.AccessControl),
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { Skeleton } from '@/components/emcn'
|
||||
|
||||
/**
|
||||
* Skeleton component for admin settings loading state.
|
||||
* Matches the exact layout structure of the Admin component.
|
||||
*/
|
||||
export function AdminSkeleton() {
|
||||
return (
|
||||
<div className='flex h-full flex-col gap-6'>
|
||||
@@ -11,9 +7,6 @@ export function AdminSkeleton() {
|
||||
<Skeleton className='h-[14px] w-[120px]' />
|
||||
<Skeleton className='h-[20px] w-[36px] rounded-full' />
|
||||
</div>
|
||||
|
||||
<div className='h-px bg-[var(--border-secondary)]' />
|
||||
|
||||
<div className='flex flex-col gap-2'>
|
||||
<Skeleton className='h-[14px] w-[340px]' />
|
||||
<div className='flex gap-2'>
|
||||
@@ -21,51 +14,9 @@ export function AdminSkeleton() {
|
||||
<Skeleton className='h-9 w-[80px] rounded-md' />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='h-px bg-[var(--border-secondary)]' />
|
||||
|
||||
<div className='flex flex-col gap-3'>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<Skeleton className='h-[14px] w-[120px]' />
|
||||
|
||||
<div className='flex gap-2'>
|
||||
<Skeleton className='h-9 flex-1 rounded-md' />
|
||||
<Skeleton className='h-9 w-[80px] rounded-md' />
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-0.5'>
|
||||
<div className='flex items-center gap-3 border-[var(--border-secondary)] border-b px-3 py-2'>
|
||||
<Skeleton className='h-[12px] w-[200px]' />
|
||||
<Skeleton className='h-[12px] flex-1' />
|
||||
<Skeleton className='h-[12px] w-[80px]' />
|
||||
<Skeleton className='h-[12px] w-[80px]' />
|
||||
<Skeleton className='h-[12px] w-[250px]' />
|
||||
</div>
|
||||
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className='flex items-center gap-3 border-[var(--border-secondary)] border-b px-3 py-2 last:border-b-0'
|
||||
>
|
||||
<Skeleton className='h-[14px] w-[200px]' />
|
||||
<Skeleton className='h-[14px] flex-1' />
|
||||
<Skeleton className='h-[20px] w-[50px] rounded-full' />
|
||||
<Skeleton className='h-[20px] w-[50px] rounded-full' />
|
||||
<div className='flex w-[250px] justify-end gap-1'>
|
||||
<Skeleton className='h-[28px] w-[80px] rounded-md' />
|
||||
<Skeleton className='h-[28px] w-[64px] rounded-md' />
|
||||
<Skeleton className='h-[28px] w-[40px] rounded-md' />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between'>
|
||||
<Skeleton className='h-[14px] w-[160px]' />
|
||||
<div className='flex gap-1'>
|
||||
<Skeleton className='h-[28px] w-[64px] rounded-md' />
|
||||
<Skeleton className='h-[28px] w-[48px] rounded-md' />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className='h-[200px] w-full rounded-lg' />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -25,31 +25,12 @@ export function ApiKeysSkeleton() {
|
||||
return (
|
||||
<div className='flex h-full flex-col gap-4.5'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Skeleton className='h-[38px] flex-1 rounded-lg' />
|
||||
<Skeleton className='h-[38px] w-[90px] rounded-md' />
|
||||
<Skeleton className='h-[30px] flex-1 rounded-lg' />
|
||||
<Skeleton className='h-[30px] w-[80px] rounded-md' />
|
||||
</div>
|
||||
|
||||
<div className='min-h-0 flex-1 overflow-y-auto'>
|
||||
<div className='flex flex-col gap-4.5'>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<Skeleton className='h-5 w-[80px]' />
|
||||
<Skeleton className='h-5 w-[180px]' />
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-2'>
|
||||
<Skeleton className='h-5 w-[60px]' />
|
||||
<ApiKeySkeleton />
|
||||
<ApiKeySkeleton />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mt-6 flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Skeleton className='h-5 w-[170px]' />
|
||||
<Skeleton className='h-3 w-3 rounded-full' />
|
||||
</div>
|
||||
<Skeleton className='h-5 w-9 rounded-full' />
|
||||
<div className='flex flex-col gap-2'>
|
||||
<ApiKeySkeleton />
|
||||
<ApiKeySkeleton />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -158,14 +158,14 @@ export function ApiKeys() {
|
||||
<div ref={scrollContainerRef} className='min-h-0 flex-1 overflow-y-auto'>
|
||||
{isLoading ? (
|
||||
<div className='flex flex-col gap-4.5'>
|
||||
{/* Workspace section header */}
|
||||
<div className='flex flex-col gap-2'>
|
||||
<Skeleton className='h-5 w-[80px]' />
|
||||
<Skeleton className='h-5 w-[180px]' />
|
||||
<Skeleton className='h-5 w-[70px]' />
|
||||
<div className='text-[var(--text-muted)] text-sm'>
|
||||
<Skeleton className='h-5 w-[140px]' />
|
||||
</div>
|
||||
</div>
|
||||
{/* Personal section header + keys */}
|
||||
<div className='flex flex-col gap-2'>
|
||||
<Skeleton className='h-5 w-[60px]' />
|
||||
<Skeleton className='h-5 w-[55px]' />
|
||||
<ApiKeySkeleton />
|
||||
<ApiKeySkeleton />
|
||||
</div>
|
||||
@@ -310,15 +310,6 @@ export function ApiKeys() {
|
||||
</div>
|
||||
|
||||
{/* Allow Personal API Keys Toggle - Fixed at bottom */}
|
||||
{isLoading && canManageWorkspaceKeys && (
|
||||
<div className='mt-6 flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Skeleton className='h-5 w-[170px]' />
|
||||
<Skeleton className='h-3 w-3 rounded-full' />
|
||||
</div>
|
||||
<Skeleton className='h-5 w-9 rounded-full' />
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && canManageWorkspaceKeys && (
|
||||
<Tooltip.Provider delayDuration={150}>
|
||||
<div className='mt-6 flex items-center justify-between'>
|
||||
|
||||
@@ -9,14 +9,11 @@ export function BYOKKeySkeleton() {
|
||||
<div className='flex items-center gap-3'>
|
||||
<Skeleton className='h-9 w-9 flex-shrink-0 rounded-md' />
|
||||
<div className='flex min-w-0 flex-col justify-center gap-[1px]'>
|
||||
<Skeleton className='h-[16px] w-[100px]' />
|
||||
<Skeleton className='h-[14px] w-[200px]' />
|
||||
<Skeleton className='h-[14px] w-[100px]' />
|
||||
<Skeleton className='h-[13px] w-[200px]' />
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-shrink-0 items-center gap-2'>
|
||||
<Skeleton className='h-[32px] w-[72px] rounded-md' />
|
||||
<Skeleton className='h-[32px] w-[64px] rounded-md' />
|
||||
</div>
|
||||
<Skeleton className='h-[32px] w-[72px] rounded-md' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,70 +1,46 @@
|
||||
import { Skeleton } from '@/components/emcn'
|
||||
|
||||
const GRID_COLS = 'grid grid-cols-[minmax(0,1fr)_8px_minmax(0,1fr)_auto_auto] items-center'
|
||||
const COL_SPAN_ALL = 'col-span-5'
|
||||
const GRID_COLS = 'grid grid-cols-[minmax(0,1fr)_8px_minmax(0,1fr)_auto] items-center'
|
||||
|
||||
/**
|
||||
* Skeleton for a single integration credential row.
|
||||
* Skeleton component for a single secret row in the grid layout.
|
||||
*/
|
||||
export function CredentialSkeleton() {
|
||||
return (
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<div className='flex min-w-0 items-center gap-2.5'>
|
||||
<Skeleton className='h-8 w-8 flex-shrink-0 rounded-md' />
|
||||
<div className='flex min-w-0 flex-col justify-center gap-[1px]'>
|
||||
<Skeleton className='h-4 w-[120px] rounded' />
|
||||
<Skeleton className='h-3.5 w-[160px] rounded' />
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-shrink-0 items-center gap-1'>
|
||||
<Skeleton className='h-9 w-[60px] rounded-md' />
|
||||
<Skeleton className='h-9 w-[88px] rounded-md' />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Skeleton for a single secret row matching the credentials grid layout.
|
||||
*/
|
||||
function CredentialRowSkeleton() {
|
||||
return (
|
||||
<div className='contents'>
|
||||
<div className={GRID_COLS}>
|
||||
<Skeleton className='h-9 rounded-md' />
|
||||
<div />
|
||||
<Skeleton className='h-9 rounded-md' />
|
||||
<Skeleton className='ml-2 h-9 w-[60px] rounded-md' />
|
||||
<Skeleton className='h-9 w-9 rounded-md' />
|
||||
<div className='ml-2 flex items-center gap-0'>
|
||||
<Skeleton className='h-9 w-9 rounded-md' />
|
||||
<Skeleton className='h-9 w-9 rounded-md' />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Skeleton for the Credentials (Secrets) page shown during dynamic import loading.
|
||||
* Skeleton for the Secrets section shown during dynamic import loading.
|
||||
*/
|
||||
export function CredentialsSkeleton() {
|
||||
return (
|
||||
<div className='flex h-full flex-col gap-4'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Skeleton className='h-[30px] flex-1 rounded-lg' />
|
||||
<Skeleton className='h-[30px] w-[56px] rounded-md' />
|
||||
<Skeleton className='h-[30px] w-[50px] rounded-md' />
|
||||
</div>
|
||||
|
||||
<div className='min-h-0 flex-1 overflow-y-auto'>
|
||||
<div className='flex flex-col gap-4'>
|
||||
<div className={`${GRID_COLS} gap-y-2`}>
|
||||
<Skeleton className={`${COL_SPAN_ALL} h-5 w-[70px]`} />
|
||||
<CredentialRowSkeleton />
|
||||
<CredentialRowSkeleton />
|
||||
|
||||
<div className={`${COL_SPAN_ALL} h-[8px]`} />
|
||||
|
||||
<Skeleton className={`${COL_SPAN_ALL} h-5 w-[55px]`} />
|
||||
<CredentialRowSkeleton />
|
||||
<CredentialRowSkeleton />
|
||||
</div>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<Skeleton className='h-5 w-[70px]' />
|
||||
<div className='text-[var(--text-muted)] text-small'>
|
||||
<Skeleton className='h-5 w-[160px]' />
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<Skeleton className='h-5 w-[55px]' />
|
||||
<CredentialSkeleton />
|
||||
<CredentialSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,11 +7,11 @@ export function CustomToolSkeleton() {
|
||||
return (
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<div className='flex min-w-0 flex-col justify-center gap-[1px]'>
|
||||
<Skeleton className='h-4 w-[100px]' />
|
||||
<Skeleton className='h-3.5 w-[200px]' />
|
||||
<Skeleton className='h-[14px] w-[100px]' />
|
||||
<Skeleton className='h-[13px] w-[200px]' />
|
||||
</div>
|
||||
<div className='flex flex-shrink-0 items-center gap-2'>
|
||||
<Skeleton className='h-[30px] w-[52px] rounded-sm' />
|
||||
<Skeleton className='h-[30px] w-[40px] rounded-sm' />
|
||||
<Skeleton className='h-[30px] w-[54px] rounded-sm' />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,10 +11,10 @@ export function GeneralSkeleton() {
|
||||
<Skeleton className='h-9 w-9 rounded-full' />
|
||||
<div className='flex flex-1 flex-col justify-center gap-[1px]'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Skeleton className='h-4 w-24' />
|
||||
<Skeleton className='h-5 w-24' />
|
||||
<Skeleton className='h-[10.5px] w-[10.5px]' />
|
||||
</div>
|
||||
<Skeleton className='h-3.5 w-40' />
|
||||
<Skeleton className='h-5 w-40' />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ export function InboxTaskSkeleton() {
|
||||
<div className='flex flex-col gap-1 rounded-lg border border-[var(--border)] p-3'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Skeleton className='h-[14px] w-[200px]' />
|
||||
<Skeleton className='h-[12px] w-[50px]' />
|
||||
<Skeleton className='h-[14px] w-[50px]' />
|
||||
</div>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Skeleton className='h-[12px] w-[140px]' />
|
||||
@@ -20,64 +20,36 @@ export function InboxTaskSkeleton() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Skeleton for the full Inbox section shown while data is loading.
|
||||
* Skeleton for the full Inbox section shown during dynamic import loading.
|
||||
*/
|
||||
export function InboxSkeleton() {
|
||||
return (
|
||||
<div className='flex h-full flex-col gap-4.5'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex flex-col gap-0.5'>
|
||||
<Skeleton className='h-[14px] w-[140px]' />
|
||||
<Skeleton className='h-[13px] w-[260px]' />
|
||||
</div>
|
||||
<Skeleton className='h-[20px] w-[36px] rounded-full' />
|
||||
<Skeleton className='h-[32px] w-full rounded-lg' />
|
||||
<Skeleton className='h-[20px] w-[140px] rounded-sm' />
|
||||
<Skeleton className='h-[40px] w-full rounded-lg' />
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
<Skeleton className='h-[14px] w-[100px]' />
|
||||
<Skeleton className='h-[13px] w-[200px]' />
|
||||
<Skeleton className='h-[40px] w-full rounded-lg' />
|
||||
</div>
|
||||
|
||||
<div className='border-[var(--border)] border-t' />
|
||||
|
||||
<div className='flex flex-col gap-6'>
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
<Skeleton className='h-[14px] w-[90px]' />
|
||||
<div className='flex items-center justify-between'>
|
||||
<Skeleton className='h-[13px] w-[200px]' />
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<Skeleton className='h-[12px] w-[12px] rounded-sm' />
|
||||
<Skeleton className='h-[12px] w-[12px] rounded-sm' />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className='h-9 w-full rounded-md' />
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
<Skeleton className='h-[14px] w-[110px]' />
|
||||
<Skeleton className='h-[13px] w-[260px]' />
|
||||
<div className='mt-1 overflow-hidden rounded-lg border border-[var(--border)]'>
|
||||
<div className='px-3 py-2.5'>
|
||||
<Skeleton className='h-[14px] w-[180px]' />
|
||||
</div>
|
||||
<div className='border-[var(--border)] border-t px-3 py-2.5'>
|
||||
<Skeleton className='h-[14px] w-[160px]' />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className='mt-1 h-[32px] w-[100px] rounded-md' />
|
||||
</div>
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
<Skeleton className='h-[14px] w-[120px]' />
|
||||
<Skeleton className='h-[13px] w-[250px]' />
|
||||
<Skeleton className='h-[80px] w-full rounded-lg' />
|
||||
</div>
|
||||
|
||||
<div className='border-[var(--border)] border-t pt-4'>
|
||||
<Skeleton className='h-[14px] w-[40px]' />
|
||||
<Skeleton className='mt-0.5 h-[13px] w-[220px]' />
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-3'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Skeleton className='h-[32px] flex-1 rounded-lg' />
|
||||
<Skeleton className='h-[32px] w-[100px] rounded-md' />
|
||||
</div>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<InboxTaskSkeleton />
|
||||
<InboxTaskSkeleton />
|
||||
<InboxTaskSkeleton />
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Skeleton className='h-[30px] flex-1 rounded-lg' />
|
||||
<Skeleton className='h-[30px] w-[100px] rounded-md' />
|
||||
</div>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<InboxTaskSkeleton />
|
||||
<InboxTaskSkeleton />
|
||||
<InboxTaskSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { Skeleton } from '@/components/emcn'
|
||||
import { CredentialSkeleton } from '@/app/workspace/[workspaceId]/settings/components/credentials/credential-skeleton'
|
||||
|
||||
/**
|
||||
* Skeleton for the Integrations section shown during dynamic import loading.
|
||||
*/
|
||||
export function IntegrationsSkeleton() {
|
||||
return (
|
||||
<div className='flex h-full flex-col gap-4.5'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Skeleton className='h-[30px] flex-1 rounded-lg' />
|
||||
<Skeleton className='h-[30px] w-[100px] rounded-md' />
|
||||
</div>
|
||||
<div className='min-h-0 flex-1 overflow-y-auto'>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<CredentialSkeleton />
|
||||
<CredentialSkeleton />
|
||||
<CredentialSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -9,10 +9,10 @@ export function McpServerSkeleton() {
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<div className='flex min-w-0 flex-col justify-center gap-[1px]'>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<Skeleton className='h-4 w-[100px]' />
|
||||
<Skeleton className='h-3.5 w-[80px]' />
|
||||
<Skeleton className='h-[14px] w-[100px]' />
|
||||
<Skeleton className='h-[13px] w-[80px]' />
|
||||
</div>
|
||||
<Skeleton className='h-3.5 w-[120px]' />
|
||||
<Skeleton className='h-[13px] w-[120px]' />
|
||||
</div>
|
||||
<div className='flex flex-shrink-0 items-center gap-1'>
|
||||
<Skeleton className='h-[30px] w-[60px] rounded-sm' />
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Skeleton } from '@/components/emcn'
|
||||
*/
|
||||
export function DeletedItemSkeleton() {
|
||||
return (
|
||||
<div className='flex items-center gap-3 rounded-md px-2 py-2'>
|
||||
<div className='flex items-center gap-3 px-2 py-2'>
|
||||
<Skeleton className='h-[14px] w-[14px] shrink-0 rounded-[3px]' />
|
||||
<div className='flex min-w-0 flex-1 flex-col gap-0.5'>
|
||||
<Skeleton className='h-[14px] w-[120px]' />
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { Skeleton } from '@/components/emcn'
|
||||
import { DeletedItemSkeleton } from '@/app/workspace/[workspaceId]/settings/components/recently-deleted/deleted-item-skeleton'
|
||||
|
||||
/**
|
||||
* Skeleton component for the entire Recently Deleted settings section.
|
||||
* Renders placeholder UI for the search bar, sort dropdown, tabs, and item list.
|
||||
*/
|
||||
export function RecentlyDeletedSkeleton() {
|
||||
return (
|
||||
<div className='flex h-full flex-col gap-4.5'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Skeleton className='h-[30px] flex-1 rounded-lg' />
|
||||
<Skeleton className='h-[30px] w-[190px] shrink-0 rounded-lg' />
|
||||
</div>
|
||||
|
||||
<div className='relative flex gap-4 border-[var(--border)] border-b px-4'>
|
||||
<Skeleton className='mb-2 h-[20px] w-[32px] rounded-sm' />
|
||||
<Skeleton className='mb-2 h-[20px] w-[72px] rounded-sm' />
|
||||
<Skeleton className='mb-2 h-[20px] w-[52px] rounded-sm' />
|
||||
<Skeleton className='mb-2 h-[20px] w-[112px] rounded-sm' />
|
||||
<Skeleton className='mb-2 h-[20px] w-[40px] rounded-sm' />
|
||||
</div>
|
||||
|
||||
<div className='min-h-0 flex-1 overflow-y-auto'>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<DeletedItemSkeleton />
|
||||
<DeletedItemSkeleton />
|
||||
<DeletedItemSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -31,8 +31,6 @@ export const CursorBlock: BlockConfig<CursorResponse> = {
|
||||
{ label: 'List Agents', id: 'cursor_list_agents' },
|
||||
{ label: 'Stop Agent', id: 'cursor_stop_agent' },
|
||||
{ label: 'Delete Agent', id: 'cursor_delete_agent' },
|
||||
{ label: 'List Artifacts', id: 'cursor_list_artifacts' },
|
||||
{ label: 'Download Artifact', id: 'cursor_download_artifact' },
|
||||
],
|
||||
value: () => 'cursor_launch_agent',
|
||||
},
|
||||
@@ -50,7 +48,6 @@ export const CursorBlock: BlockConfig<CursorResponse> = {
|
||||
type: 'short-input',
|
||||
placeholder: 'main (optional)',
|
||||
condition: { field: 'operation', value: 'cursor_launch_agent' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'promptText',
|
||||
@@ -60,21 +57,12 @@ export const CursorBlock: BlockConfig<CursorResponse> = {
|
||||
condition: { field: 'operation', value: 'cursor_launch_agent' },
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'promptImages',
|
||||
title: 'Prompt Images',
|
||||
type: 'long-input',
|
||||
placeholder: '[{"data": "base64...", "dimension": {"width": 1024, "height": 768}}]',
|
||||
condition: { field: 'operation', value: ['cursor_launch_agent', 'cursor_add_followup'] },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'model',
|
||||
title: 'Model',
|
||||
type: 'short-input',
|
||||
placeholder: 'Auto-selection by default',
|
||||
condition: { field: 'operation', value: 'cursor_launch_agent' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'branchName',
|
||||
@@ -82,7 +70,6 @@ export const CursorBlock: BlockConfig<CursorResponse> = {
|
||||
type: 'short-input',
|
||||
placeholder: 'Custom branch name (optional)',
|
||||
condition: { field: 'operation', value: 'cursor_launch_agent' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'autoCreatePr',
|
||||
@@ -95,14 +82,12 @@ export const CursorBlock: BlockConfig<CursorResponse> = {
|
||||
title: 'Open as Cursor GitHub App',
|
||||
type: 'switch',
|
||||
condition: { field: 'operation', value: 'cursor_launch_agent' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'skipReviewerRequest',
|
||||
title: 'Skip Reviewer Request',
|
||||
type: 'switch',
|
||||
condition: { field: 'operation', value: 'cursor_launch_agent' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'agentId',
|
||||
@@ -117,20 +102,10 @@ export const CursorBlock: BlockConfig<CursorResponse> = {
|
||||
'cursor_add_followup',
|
||||
'cursor_stop_agent',
|
||||
'cursor_delete_agent',
|
||||
'cursor_list_artifacts',
|
||||
'cursor_download_artifact',
|
||||
],
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'path',
|
||||
title: 'Artifact Path',
|
||||
type: 'short-input',
|
||||
placeholder: '/opt/cursor/artifacts/screenshot.png',
|
||||
condition: { field: 'operation', value: 'cursor_download_artifact' },
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'followupPromptText',
|
||||
title: 'Follow-up Prompt',
|
||||
@@ -139,21 +114,12 @@ export const CursorBlock: BlockConfig<CursorResponse> = {
|
||||
condition: { field: 'operation', value: 'cursor_add_followup' },
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'prUrl',
|
||||
title: 'PR URL Filter',
|
||||
type: 'short-input',
|
||||
placeholder: 'Filter by pull request URL (optional)',
|
||||
condition: { field: 'operation', value: 'cursor_list_agents' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'limit',
|
||||
title: 'Limit',
|
||||
type: 'short-input',
|
||||
placeholder: '20 (default, max 100)',
|
||||
condition: { field: 'operation', value: 'cursor_list_agents' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'cursor',
|
||||
@@ -161,7 +127,6 @@ export const CursorBlock: BlockConfig<CursorResponse> = {
|
||||
type: 'short-input',
|
||||
placeholder: 'Cursor from previous response',
|
||||
condition: { field: 'operation', value: 'cursor_list_agents' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'apiKey',
|
||||
@@ -181,8 +146,6 @@ export const CursorBlock: BlockConfig<CursorResponse> = {
|
||||
'cursor_add_followup',
|
||||
'cursor_stop_agent',
|
||||
'cursor_delete_agent',
|
||||
'cursor_list_artifacts',
|
||||
'cursor_download_artifact',
|
||||
],
|
||||
config: {
|
||||
tool: (params) => params.operation || 'cursor_launch_agent',
|
||||
@@ -194,20 +157,15 @@ export const CursorBlock: BlockConfig<CursorResponse> = {
|
||||
ref: { type: 'string', description: 'Branch, tag, or commit reference' },
|
||||
promptText: { type: 'string', description: 'Instruction text for the agent' },
|
||||
followupPromptText: { type: 'string', description: 'Follow-up instruction text for the agent' },
|
||||
promptImages: {
|
||||
type: 'string',
|
||||
description: 'JSON array of image objects with base64 data and dimensions',
|
||||
},
|
||||
promptImages: { type: 'string', description: 'JSON array of image objects' },
|
||||
model: { type: 'string', description: 'Model to use (empty for auto-selection)' },
|
||||
branchName: { type: 'string', description: 'Custom branch name' },
|
||||
autoCreatePr: { type: 'boolean', description: 'Auto-create PR when done' },
|
||||
openAsCursorGithubApp: { type: 'boolean', description: 'Open PR as Cursor GitHub App' },
|
||||
skipReviewerRequest: { type: 'boolean', description: 'Skip reviewer request' },
|
||||
agentId: { type: 'string', description: 'Agent identifier' },
|
||||
prUrl: { type: 'string', description: 'Filter agents by pull request URL' },
|
||||
limit: { type: 'number', description: 'Number of results to return' },
|
||||
cursor: { type: 'string', description: 'Pagination cursor' },
|
||||
path: { type: 'string', description: 'Absolute path of the artifact to download' },
|
||||
apiKey: { type: 'string', description: 'Cursor API key' },
|
||||
},
|
||||
outputs: {
|
||||
@@ -234,8 +192,6 @@ export const CursorV2Block: BlockConfig<CursorResponse> = {
|
||||
'cursor_add_followup_v2',
|
||||
'cursor_stop_agent_v2',
|
||||
'cursor_delete_agent_v2',
|
||||
'cursor_list_artifacts_v2',
|
||||
'cursor_download_artifact_v2',
|
||||
],
|
||||
config: {
|
||||
tool: createVersionedToolSelector({
|
||||
@@ -257,7 +213,5 @@ export const CursorV2Block: BlockConfig<CursorResponse> = {
|
||||
agents: { type: 'json', description: 'Array of agent objects (list operation)' },
|
||||
nextCursor: { type: 'string', description: 'Pagination cursor (list operation)' },
|
||||
messages: { type: 'json', description: 'Conversation messages (get conversation operation)' },
|
||||
artifacts: { type: 'json', description: 'List of artifact files (list artifacts operation)' },
|
||||
file: { type: 'file', description: 'Downloaded artifact file (download artifact operation)' },
|
||||
},
|
||||
}
|
||||
|
||||
@@ -12,13 +12,12 @@ export const ResponseBlock: BlockConfig<ResponseBlockOutput> = {
|
||||
bestPractices: `
|
||||
- Only use this if the trigger block is the API Trigger.
|
||||
- Prefer the builder mode over the editor mode.
|
||||
- The Response block is an exit point. When it executes, the workflow stops and the API response is sent immediately.
|
||||
- Multiple Response blocks can be placed on different branches (e.g. after a Router or Condition). The first one to execute determines the API response and ends the workflow.
|
||||
- If a Response block is on a parallel branch, there are no guarantees about whether other parallel blocks will run. Avoid placing Response blocks in parallel with blocks that have important side effects.
|
||||
- This is usually used as the last block in the workflow.
|
||||
`,
|
||||
category: 'blocks',
|
||||
bgColor: '#2F55FF',
|
||||
icon: ResponseIcon,
|
||||
singleInstance: true,
|
||||
subBlocks: [
|
||||
{
|
||||
id: 'dataMode',
|
||||
|
||||
@@ -150,42 +150,37 @@ const ModalContent = React.forwardRef<
|
||||
return (
|
||||
<ModalPortal>
|
||||
<ModalOverlay />
|
||||
<div
|
||||
className='pointer-events-none fixed inset-0 z-[var(--z-modal)] flex items-center justify-center'
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed top-[50%] z-[var(--z-modal)] flex max-h-[84vh] translate-x-[-50%] translate-y-[-50%] flex-col overflow-hidden rounded-xl bg-[var(--bg)] text-small ring-1 ring-foreground/10 duration-200',
|
||||
MODAL_SIZES[size],
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
paddingLeft: isWorkflowPage
|
||||
? 'calc(var(--sidebar-width) - var(--panel-width))'
|
||||
: 'var(--sidebar-width)',
|
||||
left: isWorkflowPage
|
||||
? // --panel-width is always the rendered panel width on /w/ routes (panel is never hidden/collapsed)
|
||||
'calc(50% + (var(--sidebar-width) - var(--panel-width)) / 2)'
|
||||
: 'calc(var(--sidebar-width) / 2 + 50%)',
|
||||
...style,
|
||||
}}
|
||||
onEscapeKeyDown={(e) => {
|
||||
if (!isInteractionReady) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
e.stopPropagation()
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
onPointerUp={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'pointer-events-auto flex max-h-[84vh] flex-col overflow-hidden rounded-xl bg-[var(--bg)] text-small ring-1 ring-foreground/10',
|
||||
ANIMATION_CLASSES,
|
||||
'data-[state=open]:zoom-in-95 data-[state=closed]:zoom-out-95 duration-200',
|
||||
MODAL_SIZES[size],
|
||||
className
|
||||
)}
|
||||
style={style}
|
||||
onEscapeKeyDown={(e) => {
|
||||
if (!isInteractionReady) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
e.stopPropagation()
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
onPointerUp={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</DialogPrimitive.Content>
|
||||
</div>
|
||||
{children}
|
||||
</DialogPrimitive.Content>
|
||||
</ModalPortal>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
slug: enterprise
|
||||
title: 'Sim for Enterprise'
|
||||
title: 'Build with Sim for Enterprise'
|
||||
description: 'Access control, BYOK, self-hosted deployments, on-prem Copilot, SSO & SAML, whitelabeling, Admin API, and flexible data retention—enterprise features for teams with strict security and compliance requirements.'
|
||||
date: 2026-02-11
|
||||
updated: 2026-02-11
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
slug: multiplayer
|
||||
title: 'Realtime Collaboration'
|
||||
title: 'Realtime Collaboration on Sim'
|
||||
description: A high-level explanation into Sim realtime collaborative workflow builder - from operation queues to conflict resolution.
|
||||
date: 2025-11-11
|
||||
updated: 2025-11-11
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user