mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-21 13:00:04 +08:00
chore(cleanup): react-doctor dead code elimination, landing + docs overhaul, component modernization (#4544)
* fix(react-doctor): remove unused export types, useEffect clearTimeout missing, a11y fixes * fix(react-doctor): strip unused export types from contracts, copilot, stores, and components Remove export keyword from type/interface declarations confirmed to have zero importers across lib/api/contracts/tools/aws/, lib/api/contracts/*.ts, lib/copilot/generated/, stores/workflows/workflow/types.ts, ee/access-control, ee/data-retention, lib/logs/types.ts, and app/workspace component files. TypeScript and API validation both pass clean. Reduces unused-types count from 394 → 181 and fully eliminates the ✗ critical dead-code categories (exports, types, files now show as ⚠ warnings not ✗ errors). * docs improvements * fix(react-doctor): delete 50 unused files (dead barrels, unreachable components, stale utilities) Remove confirmed-unused barrel index.ts files across stores/, connectors/, executor/, lib/, and app/workspace/ that had zero importers. Also delete unreachable components (chat-history-skeleton, trace-spans, logs-list, template-profile, enterprise landing sections), stale utilities (buffered-stream, blob-to-data-url, queued-workflow-execution, compute-edit-sequence), and obsolete generated/contract files. TypeScript passes clean. * remove dead code * cleanup * more * fix(blog): restore DiffControlsDemo for v0-5 blog post * fix: restore ContactButton for enterprise blog post, export WIKIPEDIA_PAGE_CONTENT_OUTPUT_PROPERTIES * fix(react-doc): restore stripped exports and remove server-only dependency * chore(deps): update lockfile after removing server-only * added back some exports * docs * more * fix type issues * tc * fix docs search route Co-authored-by: Cursor <cursoragent@cursor.com> * fix(tag-dropdown): add missing isEqual import from es-toolkit --------- Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -306,9 +306,12 @@ export async function generateMetadata(props: {
|
||||
siteName: 'Sim Documentation',
|
||||
type: 'article',
|
||||
locale: OG_LOCALE_MAP[lang] ?? 'en_US',
|
||||
alternateLocale: i18n.languages
|
||||
.filter((l) => l !== lang)
|
||||
.map((l) => OG_LOCALE_MAP[l] ?? 'en_US'),
|
||||
alternateLocale: i18n.languages.reduce<string[]>((locales, l) => {
|
||||
if (l !== lang) {
|
||||
locales.push(OG_LOCALE_MAP[l] ?? 'en_US')
|
||||
}
|
||||
return locales
|
||||
}, []),
|
||||
images: [
|
||||
{
|
||||
url: ogImageUrl,
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { Navbar } from '@/components/navbar/navbar'
|
||||
import { SimLogoFull } from '@/components/ui/sim-logo'
|
||||
import { i18n } from '@/lib/i18n'
|
||||
import { serializeJsonLd } from '@/lib/json-ld'
|
||||
import { source } from '@/lib/source'
|
||||
import { DOCS_BASE_URL } from '@/lib/urls'
|
||||
import '../global.css'
|
||||
@@ -78,14 +79,6 @@ export default async function Layout({ children, params }: LayoutProps) {
|
||||
},
|
||||
},
|
||||
inLanguage: lang,
|
||||
potentialAction: {
|
||||
'@type': 'SearchAction',
|
||||
target: {
|
||||
'@type': 'EntryPoint',
|
||||
urlTemplate: `${DOCS_BASE_URL}/api/search?q={search_term_string}`,
|
||||
},
|
||||
'query-input': 'required name=search_term_string',
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -97,7 +90,7 @@ export default async function Layout({ children, params }: LayoutProps) {
|
||||
<head>
|
||||
<script
|
||||
type='application/ld+json'
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
|
||||
dangerouslySetInnerHTML={{ __html: serializeJsonLd(structuredData) }}
|
||||
/>
|
||||
</head>
|
||||
<body className='flex min-h-screen flex-col font-sans'>
|
||||
|
||||
@@ -9,7 +9,7 @@ export default function NotFound() {
|
||||
return (
|
||||
<DocsPage>
|
||||
<div className='flex min-h-[70vh] flex-col items-center justify-center gap-4 text-center'>
|
||||
<h1 className='bg-gradient-to-b from-[#47d991] to-[#33c482] bg-clip-text font-bold text-8xl text-transparent'>
|
||||
<h1 className='bg-gradient-to-b from-[#47d991] to-[#33c482] bg-clip-text font-semibold text-8xl text-transparent'>
|
||||
404
|
||||
</h1>
|
||||
<h2 className='font-semibold text-2xl text-foreground'>Page Not Found</h2>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { CSSProperties } from 'react'
|
||||
import { ImageResponse } from 'next/og'
|
||||
import type { NextRequest } from 'next/server'
|
||||
|
||||
@@ -8,6 +9,34 @@ const TITLE_FONT_SIZE = {
|
||||
medium: 56,
|
||||
small: 48,
|
||||
} as const
|
||||
const FONT_CACHE_REVALIDATE_SECONDS = 60 * 60 * 24 * 30
|
||||
const OG_CONTAINER_STYLE = {
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
padding: '56px 64px',
|
||||
background: '#121212',
|
||||
fontFamily: 'Geist',
|
||||
} satisfies CSSProperties
|
||||
const OG_TITLE_STYLE = {
|
||||
fontWeight: 500,
|
||||
color: '#fafafa',
|
||||
lineHeight: 1.2,
|
||||
letterSpacing: '-0.02em',
|
||||
} satisfies CSSProperties
|
||||
const OG_FOOTER_STYLE = {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
} satisfies CSSProperties
|
||||
const OG_DOMAIN_STYLE = {
|
||||
fontSize: 20,
|
||||
fontWeight: 400,
|
||||
color: '#71717a',
|
||||
} satisfies CSSProperties
|
||||
|
||||
function getTitleFontSize(title: string): number {
|
||||
if (title.length > 45) return TITLE_FONT_SIZE.small
|
||||
@@ -15,17 +44,34 @@ function getTitleFontSize(title: string): number {
|
||||
return TITLE_FONT_SIZE.large
|
||||
}
|
||||
|
||||
function getTitleStyle(title: string): CSSProperties {
|
||||
return {
|
||||
...OG_TITLE_STYLE,
|
||||
fontSize: getTitleFontSize(title),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a Google Font dynamically by fetching the CSS and extracting the font URL.
|
||||
*/
|
||||
async function loadGoogleFont(font: string, weights: string, text: string): Promise<ArrayBuffer> {
|
||||
const url = `https://fonts.googleapis.com/css2?family=${font}:wght@${weights}&text=${encodeURIComponent(text)}`
|
||||
const css = await (await fetch(url)).text()
|
||||
const cssResponse = await fetch(url, {
|
||||
next: { revalidate: FONT_CACHE_REVALIDATE_SECONDS },
|
||||
})
|
||||
|
||||
if (!cssResponse.ok) {
|
||||
throw new Error(`Failed to load font CSS: ${cssResponse.status} ${cssResponse.statusText}`)
|
||||
}
|
||||
|
||||
const css = await cssResponse.text()
|
||||
const resource = css.match(/src: url\((.+)\) format\('(opentype|truetype)'\)/)
|
||||
|
||||
if (resource) {
|
||||
const response = await fetch(resource[1])
|
||||
if (response.status === 200) {
|
||||
const response = await fetch(resource[1], {
|
||||
next: { revalidate: FONT_CACHE_REVALIDATE_SECONDS },
|
||||
})
|
||||
if (response.ok) {
|
||||
return await response.arrayBuffer()
|
||||
}
|
||||
}
|
||||
@@ -72,50 +118,14 @@ export async function GET(request: NextRequest) {
|
||||
const fontData = await loadGoogleFont('Geist', '400;500;600', allText)
|
||||
|
||||
return new ImageResponse(
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
padding: '56px 64px',
|
||||
background: '#121212', // Dark mode background matching docs (hsla 0, 0%, 7%)
|
||||
fontFamily: 'Geist',
|
||||
}}
|
||||
>
|
||||
<div style={OG_CONTAINER_STYLE}>
|
||||
{/* Title at top */}
|
||||
<span
|
||||
style={{
|
||||
fontSize: getTitleFontSize(title),
|
||||
fontWeight: 500,
|
||||
color: '#fafafa', // Light text matching docs
|
||||
lineHeight: 1.2,
|
||||
letterSpacing: '-0.02em',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
<span style={getTitleStyle(title)}>{title}</span>
|
||||
|
||||
{/* Footer: icon left, domain right */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<div style={OG_FOOTER_STYLE}>
|
||||
<SimLogoFull />
|
||||
<span
|
||||
style={{
|
||||
fontSize: 20,
|
||||
fontWeight: 400,
|
||||
color: '#71717a',
|
||||
}}
|
||||
>
|
||||
docs.sim.ai
|
||||
</span>
|
||||
<span style={OG_DOMAIN_STYLE}>docs.sim.ai</span>
|
||||
</div>
|
||||
</div>,
|
||||
{
|
||||
|
||||
@@ -6,6 +6,28 @@ import { generateSearchEmbedding } from '@/lib/embeddings'
|
||||
export const runtime = 'nodejs'
|
||||
export const revalidate = 0
|
||||
|
||||
const DEFAULT_SEARCH_LIMIT = 10
|
||||
const MAX_SEARCH_LIMIT = 20
|
||||
|
||||
function getSearchLimit(value: unknown): number {
|
||||
const limit = Number.parseInt(String(value ?? DEFAULT_SEARCH_LIMIT), 10)
|
||||
|
||||
if (!Number.isFinite(limit) || limit <= 0) {
|
||||
return DEFAULT_SEARCH_LIMIT
|
||||
}
|
||||
|
||||
return Math.min(limit, MAX_SEARCH_LIMIT)
|
||||
}
|
||||
|
||||
function getSearchParams(request: NextRequest) {
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
return {
|
||||
query: searchParams.get('query') || searchParams.get('q') || '',
|
||||
locale: searchParams.get('locale') || 'en',
|
||||
limit: getSearchLimit(searchParams.get('limit')),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hybrid search API endpoint
|
||||
* - English: Vector embeddings + keyword search
|
||||
@@ -13,10 +35,7 @@ export const revalidate = 0
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const query = searchParams.get('query') || searchParams.get('q') || ''
|
||||
const locale = searchParams.get('locale') || 'en'
|
||||
const limit = Number.parseInt(searchParams.get('limit') || '10', 10)
|
||||
const { query, locale, limit } = getSearchParams(request)
|
||||
|
||||
if (!query || query.trim().length === 0) {
|
||||
return NextResponse.json([])
|
||||
@@ -94,6 +113,10 @@ export async function GET(request: NextRequest) {
|
||||
const keywordRankMap = new Map<string, number>()
|
||||
keywordResults.forEach((r, idx) => keywordRankMap.set(r.chunkId, idx + 1))
|
||||
|
||||
const resultByChunkId = new Map<string, (typeof vectorResults)[number]>()
|
||||
keywordResults.forEach((result) => resultByChunkId.set(result.chunkId, result))
|
||||
vectorResults.forEach((result) => resultByChunkId.set(result.chunkId, result))
|
||||
|
||||
const allChunkIds = new Set([
|
||||
...vectorResults.map((r) => r.chunkId),
|
||||
...keywordResults.map((r) => r.chunkId),
|
||||
@@ -109,9 +132,7 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
const rrfScore = 1 / (k + vectorRank) + 1 / (k + keywordRank)
|
||||
|
||||
const result =
|
||||
vectorResults.find((r) => r.chunkId === chunkId) ||
|
||||
keywordResults.find((r) => r.chunkId === chunkId)
|
||||
const result = resultByChunkId.get(chunkId)
|
||||
|
||||
if (result) {
|
||||
scoredResults.push({ ...result, rrfScore })
|
||||
@@ -167,31 +188,38 @@ export async function GET(request: NextRequest) {
|
||||
const pathParts = result.sourceDocument
|
||||
.replace('.mdx', '')
|
||||
.split('/')
|
||||
.filter((part) => part !== 'index' && !knownLocales.includes(part))
|
||||
.map((part) => {
|
||||
return part
|
||||
.replace(/_/g, ' ')
|
||||
.split(' ')
|
||||
.map((word) => {
|
||||
const acronyms = [
|
||||
'api',
|
||||
'mcp',
|
||||
'sdk',
|
||||
'url',
|
||||
'http',
|
||||
'json',
|
||||
'xml',
|
||||
'html',
|
||||
'css',
|
||||
'ai',
|
||||
]
|
||||
if (acronyms.includes(word.toLowerCase())) {
|
||||
return word.toUpperCase()
|
||||
}
|
||||
return word.charAt(0).toUpperCase() + word.slice(1)
|
||||
})
|
||||
.join(' ')
|
||||
})
|
||||
.reduce<string[]>((parts, part) => {
|
||||
if (part === 'index' || knownLocales.includes(part)) {
|
||||
return parts
|
||||
}
|
||||
|
||||
parts.push(
|
||||
part
|
||||
.replace(/_/g, ' ')
|
||||
.split(' ')
|
||||
.map((word) => {
|
||||
const acronyms = [
|
||||
'api',
|
||||
'mcp',
|
||||
'sdk',
|
||||
'url',
|
||||
'http',
|
||||
'json',
|
||||
'xml',
|
||||
'html',
|
||||
'css',
|
||||
'ai',
|
||||
]
|
||||
if (acronyms.includes(word.toLowerCase())) {
|
||||
return word.toUpperCase()
|
||||
}
|
||||
return word.charAt(0).toUpperCase() + word.slice(1)
|
||||
})
|
||||
.join(' ')
|
||||
)
|
||||
|
||||
return parts
|
||||
}, [])
|
||||
|
||||
return {
|
||||
id: result.chunkId,
|
||||
|
||||
@@ -13,7 +13,6 @@ Disallow: /api/internal/
|
||||
Disallow: /_next/static/
|
||||
Disallow: /admin/
|
||||
Allow: /
|
||||
Allow: /api/search
|
||||
Allow: /llms.txt
|
||||
Allow: /llms-full.txt
|
||||
Allow: /llms.mdx/
|
||||
|
||||
@@ -46,7 +46,7 @@ export function PageFooter({ previous, next }: PageFooterProps) {
|
||||
Previous
|
||||
</span>
|
||||
<span className='flex items-center gap-1.5 font-[470] text-[rgba(0,0,0,0.7)] text-sm transition-colors group-hover:text-[rgba(0,0,0,0.88)] dark:text-[rgba(255,255,255,0.7)] dark:group-hover:text-[rgba(255,255,255,0.92)]'>
|
||||
<ChevronLeft className='h-3.5 w-3.5 shrink-0' />
|
||||
<ChevronLeft className='size-3.5 shrink-0' />
|
||||
{previous.name}
|
||||
</span>
|
||||
</Link>
|
||||
@@ -68,7 +68,7 @@ export function PageFooter({ previous, next }: PageFooterProps) {
|
||||
</span>
|
||||
<span className='flex items-center gap-1.5 font-[470] text-[rgba(0,0,0,0.7)] text-sm transition-colors group-hover:text-[rgba(0,0,0,0.88)] dark:text-[rgba(255,255,255,0.7)] dark:group-hover:text-[rgba(255,255,255,0.92)]'>
|
||||
{next.name}
|
||||
<ChevronRight className='h-3.5 w-3.5 shrink-0' />
|
||||
<ChevronRight className='size-3.5 shrink-0' />
|
||||
</span>
|
||||
</Link>
|
||||
) : (
|
||||
@@ -90,7 +90,7 @@ export function PageFooter({ previous, next }: PageFooterProps) {
|
||||
>
|
||||
<svg
|
||||
viewBox='0 0 24 24'
|
||||
className='h-5 w-5 fill-gray-400 transition-colors hover:fill-gray-500 dark:fill-gray-500 dark:hover:fill-gray-400'
|
||||
className='size-5 fill-neutral-400 transition-colors hover:fill-neutral-500 dark:fill-neutral-500 dark:hover:fill-neutral-400'
|
||||
>
|
||||
<path d={link.icon} />
|
||||
</svg>
|
||||
|
||||
@@ -24,7 +24,7 @@ export function PageNavigationArrows({ previous, next }: PageNavigationArrowsPro
|
||||
aria-label='Previous page'
|
||||
title='Previous page'
|
||||
>
|
||||
<ChevronLeft className='h-4 w-4' />
|
||||
<ChevronLeft className='size-4' />
|
||||
</Link>
|
||||
)}
|
||||
{next && (
|
||||
@@ -34,7 +34,7 @@ export function PageNavigationArrows({ previous, next }: PageNavigationArrowsPro
|
||||
aria-label='Next page'
|
||||
title='Next page'
|
||||
>
|
||||
<ChevronRight className='h-4 w-4' />
|
||||
<ChevronRight className='size-4' />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { type ReactNode, useEffect, useState } from 'react'
|
||||
import { type ReactNode, useState } from 'react'
|
||||
import type { Folder, Item, Separator } from 'fumadocs-core/page-tree'
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
@@ -103,12 +103,10 @@ export function SidebarFolder({ item, children }: { item: Folder; children: Reac
|
||||
const isApiRef = isApiReferenceFolder(item)
|
||||
const isOnApiRefPage = stripLangPrefix(pathname).startsWith('/api-reference')
|
||||
const hasChildren = item.children.length > 0
|
||||
const [open, setOpen] = useState(hasActiveChild || (isApiRef && isOnApiRefPage))
|
||||
|
||||
useEffect(() => {
|
||||
setOpen(hasActiveChild || (isApiRef && isOnApiRefPage))
|
||||
}, [hasActiveChild, isApiRef, isOnApiRefPage])
|
||||
|
||||
const defaultOpen = hasActiveChild || (isApiRef && isOnApiRefPage)
|
||||
const [manualOpen, setManualOpen] = useState<{ pathname: string; open: boolean } | null>(null)
|
||||
const open = manualOpen?.pathname === pathname ? manualOpen.open : defaultOpen
|
||||
const toggleOpen = () => setManualOpen({ pathname, open: !open })
|
||||
const active = item.index ? isActive(item.index.url, pathname, false) : false
|
||||
|
||||
if (item.index && !hasChildren) {
|
||||
@@ -152,7 +150,7 @@ export function SidebarFolder({ item, children }: { item: Folder; children: Reac
|
||||
</Link>
|
||||
{hasChildren && (
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
onClick={toggleOpen}
|
||||
className={cn(
|
||||
'rounded p-1 hover:bg-fd-accent/50',
|
||||
'lg:cursor-pointer lg:rounded lg:p-1 lg:transition-colors lg:hover:bg-[#f2f2f2] lg:dark:hover:bg-[#262626]'
|
||||
@@ -165,7 +163,7 @@ export function SidebarFolder({ item, children }: { item: Folder; children: Reac
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
onClick={toggleOpen}
|
||||
className={cn(
|
||||
'flex flex-1 items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors',
|
||||
'text-fd-muted-foreground hover:bg-fd-accent/50',
|
||||
|
||||
+1009
-1048
File diff suppressed because one or more lines are too long
@@ -14,12 +14,12 @@ export function LLMCopyButton({ content }: { content: string }) {
|
||||
>
|
||||
{checked ? (
|
||||
<>
|
||||
<Check className='h-3.5 w-3.5' />
|
||||
<Check className='size-3.5' />
|
||||
<span>Copied</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className='h-3.5 w-3.5' />
|
||||
<Copy className='size-3.5' />
|
||||
<span>Copy page</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { serializeJsonLd } from '@/lib/json-ld'
|
||||
import { DOCS_BASE_URL } from '@/lib/urls'
|
||||
|
||||
interface StructuredDataProps {
|
||||
@@ -103,14 +104,14 @@ export function StructuredData({
|
||||
<script
|
||||
type='application/ld+json'
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify(articleStructuredData),
|
||||
__html: serializeJsonLd(articleStructuredData),
|
||||
}}
|
||||
/>
|
||||
{breadcrumbStructuredData && (
|
||||
<script
|
||||
type='application/ld+json'
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify(breadcrumbStructuredData),
|
||||
__html: serializeJsonLd(breadcrumbStructuredData),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -118,7 +119,7 @@ export function StructuredData({
|
||||
<script
|
||||
type='application/ld+json'
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify(softwareStructuredData),
|
||||
__html: serializeJsonLd(softwareStructuredData),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -19,23 +19,33 @@ interface ActionVideoProps {
|
||||
export function ActionImage({ src, alt, enableLightbox = true }: ActionImageProps) {
|
||||
const [isLightboxOpen, setIsLightboxOpen] = useState(false)
|
||||
|
||||
const handleClick = () => {
|
||||
if (enableLightbox) {
|
||||
setIsLightboxOpen(true)
|
||||
}
|
||||
}
|
||||
const openLightbox = () => setIsLightboxOpen(true)
|
||||
|
||||
const image = (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className={cn(
|
||||
'inline-block w-full max-w-[200px] rounded border border-neutral-200 dark:border-neutral-700',
|
||||
enableLightbox && 'transition-opacity group-hover:opacity-90'
|
||||
)}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
'inline-block w-full max-w-[200px] rounded border border-neutral-200 dark:border-neutral-700',
|
||||
enableLightbox && 'cursor-pointer transition-opacity hover:opacity-90'
|
||||
)}
|
||||
/>
|
||||
{enableLightbox ? (
|
||||
<button
|
||||
type='button'
|
||||
onClick={openLightbox}
|
||||
aria-label={`Open ${alt} in media viewer`}
|
||||
className='group inline-block cursor-pointer rounded p-0 text-left'
|
||||
>
|
||||
{image}
|
||||
</button>
|
||||
) : (
|
||||
image
|
||||
)}
|
||||
{enableLightbox && (
|
||||
<Lightbox
|
||||
isOpen={isLightboxOpen}
|
||||
@@ -55,28 +65,40 @@ export function ActionVideo({ src, alt, enableLightbox = true }: ActionVideoProp
|
||||
const [isLightboxOpen, setIsLightboxOpen] = useState(false)
|
||||
const resolvedSrc = getAssetUrl(src)
|
||||
|
||||
const handleClick = () => {
|
||||
if (enableLightbox) {
|
||||
startTimeRef.current = videoRef.current?.currentTime ?? 0
|
||||
setIsLightboxOpen(true)
|
||||
}
|
||||
const openLightbox = () => {
|
||||
startTimeRef.current = videoRef.current?.currentTime ?? 0
|
||||
setIsLightboxOpen(true)
|
||||
}
|
||||
|
||||
const video = (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={resolvedSrc}
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
className={cn(
|
||||
'inline-block w-full max-w-[200px] rounded border border-neutral-200 dark:border-neutral-700',
|
||||
enableLightbox && 'transition-opacity group-hover:opacity-90'
|
||||
)}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={resolvedSrc}
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
'inline-block w-full max-w-[200px] rounded border border-neutral-200 dark:border-neutral-700',
|
||||
enableLightbox && 'cursor-pointer transition-opacity hover:opacity-90'
|
||||
)}
|
||||
/>
|
||||
{enableLightbox ? (
|
||||
<button
|
||||
type='button'
|
||||
onClick={openLightbox}
|
||||
aria-label={`Open ${alt} in media viewer`}
|
||||
className='group inline-block cursor-pointer rounded p-0 text-left'
|
||||
>
|
||||
{video}
|
||||
</button>
|
||||
) : (
|
||||
video
|
||||
)}
|
||||
{enableLightbox && (
|
||||
<Lightbox
|
||||
isOpen={isLightboxOpen}
|
||||
|
||||
@@ -22,7 +22,7 @@ export function BlockInfoCard({
|
||||
style={{ background: color }}
|
||||
>
|
||||
{ResolvedIcon ? (
|
||||
<ResolvedIcon className='h-10 w-10 text-white' />
|
||||
<ResolvedIcon className='size-10 text-white' />
|
||||
) : (
|
||||
<div className='font-mono text-white text-xl opacity-70'>{type.substring(0, 2)}</div>
|
||||
)}
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
const variants = {
|
||||
primary: 'bg-fd-primary text-fd-primary-foreground hover:bg-fd-primary/80',
|
||||
outline: 'border hover:bg-fd-accent hover:text-fd-accent-foreground',
|
||||
ghost: 'hover:bg-fd-accent hover:text-fd-accent-foreground',
|
||||
secondary:
|
||||
'border bg-fd-secondary text-fd-secondary-foreground hover:bg-fd-accent hover:text-fd-accent-foreground',
|
||||
} as const
|
||||
|
||||
export const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center rounded-[5px] p-2 text-sm font-medium transition-colors duration-100 disabled:pointer-events-none disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fd-ring',
|
||||
{
|
||||
variants: {
|
||||
variant: variants,
|
||||
color: variants,
|
||||
size: {
|
||||
sm: 'gap-1 px-2 py-1.5 text-xs',
|
||||
icon: 'p-1.5 [&_svg]:size-5',
|
||||
'icon-sm': 'p-1.5 [&_svg]:size-4.5',
|
||||
'icon-xs': 'p-1 [&_svg]:size-4',
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export type ButtonProps = VariantProps<typeof buttonVariants>
|
||||
@@ -1,8 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import type { ComponentPropsWithRef } from 'react'
|
||||
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
|
||||
import { Check } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const ANIMATION_CLASSES =
|
||||
@@ -11,67 +10,43 @@ const ANIMATION_CLASSES =
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 6, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 6,
|
||||
ref,
|
||||
...props
|
||||
}: ComponentPropsWithRef<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
ANIMATION_CLASSES,
|
||||
'z-50 max-h-[240px] min-w-[8rem] max-w-[220px] origin-[--radix-dropdown-menu-content-transform-origin] overflow-y-auto overflow-x-hidden rounded-lg border border-neutral-200 bg-white p-1.5 shadow-sm dark:border-neutral-800 dark:bg-neutral-900',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
ref,
|
||||
...props
|
||||
}: ComponentPropsWithRef<typeof DropdownMenuPrimitive.Item>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
ANIMATION_CLASSES,
|
||||
'z-50 max-h-[240px] min-w-[8rem] max-w-[220px] origin-[--radix-dropdown-menu-content-transform-origin] overflow-y-auto overflow-x-hidden rounded-lg border border-neutral-200 bg-white p-1.5 shadow-sm dark:border-neutral-800 dark:bg-neutral-900',
|
||||
'relative flex min-w-0 cursor-pointer select-none items-center gap-2 rounded-[5px] px-2 py-1.5 font-medium text-[13px] text-neutral-700 outline-none transition-colors focus:bg-neutral-100 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:text-neutral-300 dark:focus:bg-neutral-800',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
))
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex min-w-0 cursor-pointer select-none items-center gap-2 rounded-[5px] px-2 py-1.5 font-medium text-[13px] text-neutral-700 outline-none transition-colors focus:bg-neutral-100 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:text-neutral-300 dark:focus:bg-neutral-800',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-[5px] py-1.5 pr-2 pl-7 font-medium text-[13px] text-neutral-700 outline-none transition-colors focus:bg-neutral-100 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:text-neutral-300 dark:focus:bg-neutral-800',
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className='absolute left-2 flex h-3.5 w-3.5 items-center justify-center'>
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className='h-3.5 w-3.5' />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
))
|
||||
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
)
|
||||
}
|
||||
|
||||
export { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem }
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState } from 'react'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import { serializeJsonLd } from '@/lib/json-ld'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface FAQItem {
|
||||
@@ -76,13 +77,13 @@ export function FAQ({ items, title = 'Common Questions' }: FAQProps) {
|
||||
<div className='mt-12'>
|
||||
<script
|
||||
type='application/ld+json'
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqSchema) }}
|
||||
dangerouslySetInnerHTML={{ __html: serializeJsonLd(faqSchema) }}
|
||||
/>
|
||||
<h2 className='mb-4 font-[500] text-xl'>{title}</h2>
|
||||
<div className='border-[rgba(0,0,0,0.08)] border-t border-b dark:border-[rgba(255,255,255,0.08)]'>
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
key={item.question}
|
||||
className={cn(
|
||||
index !== items.length - 1 &&
|
||||
'border-[rgba(0,0,0,0.08)] border-b dark:border-[rgba(255,255,255,0.08)]'
|
||||
|
||||
@@ -18,7 +18,7 @@ export function Heading({ as, className, ...props }: HeadingProps) {
|
||||
return <As className={className} {...props} />
|
||||
}
|
||||
|
||||
const handleClick = async (e: React.MouseEvent) => {
|
||||
const copyHeadingLink = async (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
const url = `${window.location.origin}${window.location.pathname}#${props.id}`
|
||||
@@ -39,7 +39,7 @@ export function Heading({ as, className, ...props }: HeadingProps) {
|
||||
|
||||
return (
|
||||
<As className={cn('group flex scroll-m-28 flex-row items-center gap-2', className)} {...props}>
|
||||
<a href={`#${props.id}`} className='peer' onClick={handleClick}>
|
||||
<a href={`#${props.id}`} className='peer' onClick={copyHeadingLink}>
|
||||
{props.children}
|
||||
</a>
|
||||
{copied ? (
|
||||
|
||||
@@ -19,25 +19,35 @@ export function Image({
|
||||
}: ImageProps) {
|
||||
const [isLightboxOpen, setIsLightboxOpen] = useState(false)
|
||||
|
||||
const handleImageClick = () => {
|
||||
if (enableLightbox) {
|
||||
setIsLightboxOpen(true)
|
||||
}
|
||||
}
|
||||
const openLightbox = () => setIsLightboxOpen(true)
|
||||
|
||||
const image = (
|
||||
<NextImage
|
||||
className={cn(
|
||||
'overflow-hidden rounded-xl border border-border object-cover',
|
||||
enableLightbox && 'transition-opacity group-hover:opacity-95',
|
||||
className
|
||||
)}
|
||||
alt={alt}
|
||||
src={src}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<NextImage
|
||||
className={cn(
|
||||
'overflow-hidden rounded-xl border border-border object-cover',
|
||||
enableLightbox && 'cursor-pointer transition-opacity hover:opacity-95',
|
||||
className
|
||||
)}
|
||||
alt={alt}
|
||||
src={src}
|
||||
onClick={handleImageClick}
|
||||
{...props}
|
||||
/>
|
||||
{enableLightbox ? (
|
||||
<button
|
||||
type='button'
|
||||
onClick={openLightbox}
|
||||
aria-label={`Open ${alt} in media viewer`}
|
||||
className='group block w-full cursor-pointer rounded-xl p-0 text-left'
|
||||
>
|
||||
{image}
|
||||
</button>
|
||||
) : (
|
||||
image
|
||||
)}
|
||||
|
||||
{enableLightbox && (
|
||||
<Lightbox
|
||||
|
||||
@@ -22,7 +22,7 @@ const languages = {
|
||||
export function LanguageDropdown() {
|
||||
const pathname = usePathname()
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const { push } = useRouter()
|
||||
|
||||
const langFromParams = params?.lang as string
|
||||
const currentLang =
|
||||
@@ -44,7 +44,7 @@ export function LanguageDropdown() {
|
||||
newPath = `/${locale}${segments.length > 0 ? `/${segments.join('/')}` : '/introduction'}`
|
||||
}
|
||||
|
||||
router.push(newPath)
|
||||
push(newPath)
|
||||
}
|
||||
|
||||
const languageEntries = Object.entries(languages)
|
||||
@@ -80,7 +80,7 @@ export function LanguageDropdown() {
|
||||
>
|
||||
<span className='text-[13px]'>{lang.flag}</span>
|
||||
<span className='flex-1'>{lang.name}</span>
|
||||
{isSelected && <Check className='ml-auto h-3.5 w-3.5' />}
|
||||
{isSelected && <Check className='ml-auto size-3.5' />}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useLayoutEffect, useRef } from 'react'
|
||||
import { useEffect, useEffectEvent, useLayoutEffect, useRef } from 'react'
|
||||
import { getAssetUrl } from '@/lib/utils'
|
||||
|
||||
interface LightboxProps {
|
||||
@@ -14,33 +14,38 @@ interface LightboxProps {
|
||||
|
||||
export function Lightbox({ isOpen, onClose, src, alt, type, startTime }: LightboxProps) {
|
||||
const overlayRef = useRef<HTMLDivElement>(null)
|
||||
const mediaButtonRef = useRef<HTMLButtonElement>(null)
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const closeLightbox = useEffectEvent(onClose)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
onClose()
|
||||
closeLightbox()
|
||||
}
|
||||
}
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (overlayRef.current && event.target === overlayRef.current) {
|
||||
onClose()
|
||||
closeLightbox()
|
||||
}
|
||||
}
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
document.body.style.overflow = 'hidden'
|
||||
}
|
||||
const previousOverflow = document.body.style.overflow
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
document.body.style.overflow = 'hidden'
|
||||
mediaButtonRef.current?.focus()
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
document.body.style.overflow = 'unset'
|
||||
document.body.style.overflow = previousOverflow
|
||||
}
|
||||
}, [isOpen, onClose])
|
||||
}, [isOpen])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (isOpen && type === 'video' && videoRef.current && startTime != null && startTime > 0) {
|
||||
@@ -59,26 +64,32 @@ export function Lightbox({ isOpen, onClose, src, alt, type, startTime }: Lightbo
|
||||
aria-label='Media viewer'
|
||||
>
|
||||
<div className='relative max-h-full max-w-full overflow-hidden rounded-xl'>
|
||||
{type === 'image' ? (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className='max-h-[75vh] max-w-[75vw] cursor-pointer rounded-xl object-contain'
|
||||
loading='lazy'
|
||||
onClick={onClose}
|
||||
/>
|
||||
) : (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={getAssetUrl(src)}
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
className='max-h-[75vh] max-w-[75vw] cursor-pointer rounded-xl outline-none focus:outline-none'
|
||||
onClick={onClose}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
ref={mediaButtonRef}
|
||||
type='button'
|
||||
onClick={onClose}
|
||||
aria-label='Close media viewer'
|
||||
className='block cursor-pointer rounded-xl p-0 outline-none focus-visible:ring-2 focus-visible:ring-white/70'
|
||||
>
|
||||
{type === 'image' ? (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className='max-h-[75vh] max-w-[75vw] rounded-xl object-contain'
|
||||
loading='lazy'
|
||||
/>
|
||||
) : (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={getAssetUrl(src)}
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
className='max-h-[75vh] max-w-[75vw] rounded-xl outline-none focus:outline-none'
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { Search } from 'lucide-react'
|
||||
|
||||
export function SearchTrigger() {
|
||||
const handleClick = () => {
|
||||
const openSearchDialog = () => {
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key: 'k',
|
||||
metaKey: true,
|
||||
@@ -17,10 +17,10 @@ export function SearchTrigger() {
|
||||
type='button'
|
||||
data-search-trigger
|
||||
className='flex h-8 w-[360px] cursor-pointer items-center gap-2 rounded-lg border border-border/50 bg-fd-muted/50 px-3 text-[13px] text-fd-muted-foreground transition-colors hover:bg-fd-muted'
|
||||
onClick={handleClick}
|
||||
onClick={openSearchDialog}
|
||||
>
|
||||
<Search className='h-3.5 w-3.5' />
|
||||
<span>Search...</span>
|
||||
<Search className='size-3.5' />
|
||||
<span>Search…</span>
|
||||
<kbd className='ml-auto flex items-center font-medium'>
|
||||
<span className='text-[15px]'>⌘</span>
|
||||
<span className='text-[12px]'>K</span>
|
||||
|
||||
@@ -6,54 +6,6 @@ interface SimLogoProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Sim logo with icon and text.
|
||||
* The icon stays green (#33C482), text adapts to light/dark mode.
|
||||
*/
|
||||
export function SimLogo({ className }: SimLogoProps) {
|
||||
return (
|
||||
<svg
|
||||
viewBox='720 440 320 320'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
className={cn('h-7 w-auto', className)}
|
||||
aria-label='Sim'
|
||||
>
|
||||
{/* Green icon - top left shape with cutout */}
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
clipRule='evenodd'
|
||||
d='M875.791 577.171C875.791 581.922 873.911 586.483 870.576 589.842L870.098 590.323C866.764 593.692 862.234 595.575 857.517 595.575H750.806C740.978 595.575 733 603.6 733 613.498V728.902C733 738.799 740.978 746.826 750.806 746.826H865.382C875.209 746.826 883.177 738.799 883.177 728.902V620.853C883.177 616.448 884.912 612.222 888.008 609.104C891.093 605.997 895.29 604.249 899.664 604.249H1008.16C1017.99 604.249 1025.96 596.224 1025.96 586.327V470.923C1025.96 461.025 1017.99 453 1008.16 453H893.586C883.759 453 875.791 461.025 875.791 470.923V577.171ZM910.562 477.566H991.178C996.922 477.566 1001.57 482.254 1001.57 488.029V569.22C1001.57 574.995 996.922 579.683 991.178 579.683H910.562C904.828 579.683 900.173 574.995 900.173 569.22V488.029C900.173 482.254 904.828 477.566 910.562 477.566Z'
|
||||
fill='#33C482'
|
||||
/>
|
||||
{/* Green icon - bottom right square */}
|
||||
<path
|
||||
d='M1008.3 624.59H923.113C912.786 624.59 904.414 633.022 904.414 643.423V728.171C904.414 738.572 912.786 747.004 923.113 747.004H1008.3C1018.63 747.004 1027 738.572 1027 728.171V643.423C1027 633.022 1018.63 624.59 1008.3 624.59Z'
|
||||
fill='#33C482'
|
||||
/>
|
||||
{/* Gradient overlay on bottom right square */}
|
||||
<path
|
||||
d='M1008.3 624.199H923.113C912.786 624.199 904.414 632.631 904.414 643.033V727.78C904.414 738.181 912.786 746.612 923.113 746.612H1008.3C1018.63 746.612 1027 738.181 1027 727.78V643.033C1027 632.631 1018.63 624.199 1008.3 624.199Z'
|
||||
fill='url(#sim-logo-gradient)'
|
||||
fillOpacity='0.2'
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id='sim-logo-gradient'
|
||||
x1='904.414'
|
||||
y1='624.199'
|
||||
x2='978.836'
|
||||
y2='698.447'
|
||||
gradientUnits='userSpaceOnUse'
|
||||
>
|
||||
<stop />
|
||||
<stop offset='1' stopOpacity='0' />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Full Sim logo with icon and "Sim" text.
|
||||
* Uses the same SVG source as the landing page navbar for exact visual alignment.
|
||||
@@ -85,7 +37,7 @@ export function SimLogoFull({ className }: SimLogoProps) {
|
||||
<g transform='scale(.07483)'>
|
||||
<path
|
||||
clipRule='evenodd'
|
||||
d='m142.793 124.175c0 4.75-1.88 9.312-5.216 12.671l-.478.481c-3.334 3.369-7.863 5.252-12.58 5.252h-106.7127c-9.82776 0-17.8063 8.026-17.8063 17.924v115.407c0 9.898 7.97854 17.924 17.8063 17.924h114.5767c9.828 0 17.796-8.026 17.796-17.924v-108.052c0-4.405 1.735-8.632 4.83-11.749 3.086-3.108 7.283-4.856 11.657-4.856h108.5c9.828 0 17.796-8.024 17.796-17.923v-115.4069c0-9.89798-7.968-17.9231-17.796-17.9231h-114.578c-9.827 0-17.795 8.02512-17.795 17.9231zm34.771-99.6079h80.617c5.744 0 10.389 4.6874 10.389 10.463v81.1939c0 5.774-4.645 10.463-10.389 10.463h-80.617c-5.734 0-10.389-4.689-10.389-10.463v-81.1939c0-5.7756 4.655-10.463 10.389-10.463z'
|
||||
d='m142.79 124.17c0 4.75-1.88 9.31-5.22 12.67l-.48.48c-3.33 3.37-7.86 5.25-12.58 5.25h-106.71c-9.83 0-17.81 8.03-17.81 17.92v115.41c0 9.9 7.98 17.92 17.81 17.92h114.58c9.83 0 17.8-8.03 17.8-17.92v-108.05c0-4.41 1.74-8.63 4.83-11.75 3.09-3.11 7.28-4.86 11.66-4.86h108.5c9.83 0 17.8-8.02 17.8-17.92v-115.41c0-9.9-7.97-17.92-17.8-17.92h-114.58c-9.83 0-17.8 8.03-17.8 17.92zm34.77-99.61h80.62c5.74 0 10.39 4.69 10.39 10.46v81.19c0 5.77-4.64 10.46-10.39 10.46h-80.62c-5.73 0-10.39-4.69-10.39-10.46v-81.19c0-5.78 4.66-10.46 10.39-10.46z'
|
||||
fill='#33C482'
|
||||
fillRule='evenodd'
|
||||
/>
|
||||
@@ -101,9 +53,9 @@ export function SimLogoFull({ className }: SimLogoProps) {
|
||||
</g>
|
||||
{/* "Sim" text — adapts to light/dark mode */}
|
||||
<g className='fill-neutral-900 dark:fill-white'>
|
||||
<path d='M31.5718 15.845h2.5865c0 .7141.2586 1.2835.7759 1.7081.5173.4053 1.2166.608 2.0979.608.958 0 1.6956-.1834 2.2129-.5501.5173-.386.776-.8975.776-1.5344 0-.4632-.1437-.8492-.4311-1.158-.2682-.3088-.7664-.5597-1.4944-.7527l-2.4716-.579c-1.2453-.3088-2.1745-.7817-2.7876-1.4186-.594-.6369-.8909-1.4765-.8909-2.51873 0-.86852.2203-1.62124.661-2.25815.4598-.63692 1.0825-1.12908 1.868-1.47648.8047-.34741 1.7243-.52112 2.7589-.52112s1.9255.18336 2.6727.55007c.7664.3667 1.3603.87817 1.7818 1.53438.4407.65622.6706 1.43788.6898 2.345h-2.5865c-.0192-.73341-.2587-1.30278-.7185-1.70809-.4598-.4053-1.1017-.60796-1.9255-.60796-.843 0-1.4944.18336-1.9542.55006-.4599.36671-.6898.86852-.6898 1.50544 0 .94568.6898 1.59228 2.0692 1.93968l2.4716.608c1.1878.2702 2.0787.7141 2.6727 1.3317.5939.5983.8909 1.4186.8909 2.4608 0 .8878-.2395 1.6695-.7185 2.345-.479.6562-1.14 1.1677-1.983 1.5344-.8238.3474-1.8009.5211-2.9313.5211-1.6477 0-2.9601-.4053-3.9372-1.2159-.9772-.8106-1.4657-1.8915-1.4657-3.2425z' />
|
||||
<path d='M44.5096 19.956v-14.15687c1.0772.39383 1.5521.39383 2.7014 0v14.15687zm1.322-15.09268c-.479 0-.9005-.1737-1.2645-.52111-.3449-.36671-.5173-.79132-.5173-1.27383 0-.50181.1724-.92642.5173-1.27383.364-.34741.7855-.52111 1.2645-.52111.4981 0 .9196.1737 1.2645.52111s.5173.77202.5173 1.27383c0 .48251-.1724.90712-.5173 1.27383-.3449.34741-.7664.52111-1.2645.52111z' />
|
||||
<path d='M51.976 19.956h-2.7014v-14.15687h2.4141v2.38865c.2873-.79131.843-1.46223 1.6093-1.98334.7855-.54041 1.7339-.81062 2.8452-.81062 1.2453 0 2.2799.33776 3.1038 1.01328.8238.67551 1.3603 1.57298 1.6093 2.69241h-.4885c.1916-1.11943.7184-2.0169 1.5806-2.69241.8622-.67552 1.9255-1.01328 3.19-1.01328 1.6094 0 2.8739.47286 3.7935 1.41858.9197.94573 1.3795 2.23886 1.3795 3.8794v9.2642h-2.644v-8.5983c0-1.1195-.2874-1.97834-.8621-2.57665-.5557-.61761-1.3125-.92642-2.2704-.92642-.6706 0-1.2645.1544-1.7818.46321-.4982.28951-.8909.71412-1.1783 1.27383-.2874.55973-.4311 1.21593-.4311 1.96863v8.3957h-2.6727v-8.6273c0-1.1194-.2778-1.96864-.8334-2.54765-.5556-.59831-1.3124-.89747-2.2704-.89747-.6706 0-1.2645.1544-1.7818.46321-.4981.28951-.8909.71412-1.1783 1.27383-.2874.54038-.4311 1.18698-.4311 1.93968z' />
|
||||
<path d='M31.57 15.85h2.59c0 .71.26 1.28.78 1.71.52.41 1.22.61 2.1.61.96 0 1.7-.18 2.21-.55.52-.39.78-.9.78-1.53 0-.46-.14-.85-.43-1.16-.27-.31-.77-.56-1.49-.75l-2.47-.58c-1.25-.31-2.17-.78-2.79-1.42-.59-.64-.89-1.48-.89-2.52 0-.87.22-1.62.66-2.26.46-.64 1.08-1.13 1.87-1.48.8-.35 1.72-.52 2.76-.52s1.93.18 2.67.55c.77.37 1.36.88 1.78 1.53.44.66.67 1.44.69 2.35h-2.59c-.02-.73-.26-1.3-.72-1.71-.46-.41-1.1-.61-1.93-.61-.84 0-1.49.18-1.95.55-.46.37-.69.87-.69 1.51 0 .95.69 1.59 2.07 1.94l2.47.61c1.19.27 2.08.71 2.67 1.33.59.6.89 1.42.89 2.46 0 .89-.24 1.67-.72 2.35-.48.66-1.14 1.17-1.98 1.53-.82.35-1.8.52-2.93.52-1.65 0-2.96-.41-3.94-1.22-.98-.81-1.47-1.89-1.47-3.24z' />
|
||||
<path d='M44.51 19.96v-14.16c1.08.39 1.55.39 2.7 0v14.16zm1.32-15.09c-.48 0-.9-.17-1.26-.52-.34-.37-.52-.79-.52-1.27 0-.5.17-.93.52-1.27.36-.35.79-.52 1.26-.52.5 0 .92.17 1.26.52s.52.77.52 1.27c0 .48-.17.91-.52 1.27-.34.35-.77.52-1.26.52z' />
|
||||
<path d='M51.98 19.96h-2.7v-14.16h2.41v2.39c.29-.79.84-1.46 1.61-1.98.79-.54 1.73-.81 2.85-.81 1.25 0 2.28.34 3.1 1.01.82.68 1.36 1.57 1.61 2.69h-.49c.19-1.12.72-2.02 1.58-2.69.86-.68 1.93-1.01 3.19-1.01 1.61 0 2.87.47 3.79 1.42.92.95 1.38 2.24 1.38 3.88v9.26h-2.64v-8.6c0-1.12-.29-1.98-.86-2.58-.56-.62-1.31-.93-2.27-.93-.67 0-1.26.15-1.78.46-.5.29-.89.71-1.18 1.27-.29.56-.43 1.22-.43 1.97v8.4h-2.67v-8.63c0-1.12-.28-1.97-.83-2.55-.56-.6-1.31-.9-2.27-.9-.67 0-1.26.15-1.78.46-.5.29-.89.71-1.18 1.27-.29.54-.43 1.19-.43 1.94z' />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import type { SVGProps } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTheme } from 'next-themes'
|
||||
|
||||
function SunIcon(props: SVGProps<SVGSVGElement>) {
|
||||
@@ -51,28 +50,17 @@ function MoonIcon(props: SVGProps<SVGSVGElement>) {
|
||||
}
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { theme, setTheme } = useTheme()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<button className='flex h-[30px] w-[30px] cursor-pointer items-center justify-center rounded-full text-foreground/40'>
|
||||
<MoonIcon />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
const { resolvedTheme, setTheme } = useTheme()
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
|
||||
className='flex h-[30px] w-[30px] cursor-pointer items-center justify-center rounded-full text-foreground/40 transition-colors duration-200 hover:bg-neutral-100 hover:text-foreground/70 dark:hover:bg-neutral-800 dark:hover:text-foreground/70'
|
||||
type='button'
|
||||
onClick={() => setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')}
|
||||
className='flex size-[30px] cursor-pointer items-center justify-center rounded-full text-foreground/40 transition-colors duration-200 hover:bg-neutral-100 hover:text-foreground/70 dark:hover:bg-neutral-800 dark:hover:text-foreground/70'
|
||||
aria-label='Toggle theme'
|
||||
>
|
||||
{theme === 'dark' ? <MoonIcon /> : <SunIcon />}
|
||||
<SunIcon className='block dark:hidden' />
|
||||
<MoonIcon className='hidden dark:block' />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -31,30 +31,39 @@ export function Video({
|
||||
const startTimeRef = useRef(0)
|
||||
const [isLightboxOpen, setIsLightboxOpen] = useState(false)
|
||||
|
||||
const handleVideoClick = () => {
|
||||
if (enableLightbox) {
|
||||
startTimeRef.current = videoRef.current?.currentTime ?? 0
|
||||
setIsLightboxOpen(true)
|
||||
}
|
||||
const openLightbox = () => {
|
||||
startTimeRef.current = videoRef.current?.currentTime ?? 0
|
||||
setIsLightboxOpen(true)
|
||||
}
|
||||
|
||||
const video = (
|
||||
<video
|
||||
ref={videoRef}
|
||||
autoPlay={autoPlay}
|
||||
loop={loop}
|
||||
muted={muted}
|
||||
playsInline={playsInline}
|
||||
width={width}
|
||||
height={height}
|
||||
className={cn(className, enableLightbox && 'transition-opacity group-hover:opacity-[0.97]')}
|
||||
src={getAssetUrl(src)}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<video
|
||||
ref={videoRef}
|
||||
autoPlay={autoPlay}
|
||||
loop={loop}
|
||||
muted={muted}
|
||||
playsInline={playsInline}
|
||||
width={width}
|
||||
height={height}
|
||||
className={cn(
|
||||
className,
|
||||
enableLightbox && 'cursor-pointer transition-opacity hover:opacity-[0.97]'
|
||||
)}
|
||||
src={getAssetUrl(src)}
|
||||
onClick={handleVideoClick}
|
||||
/>
|
||||
{enableLightbox ? (
|
||||
<button
|
||||
type='button'
|
||||
onClick={openLightbox}
|
||||
aria-label={`Open ${src} in media viewer`}
|
||||
className='group block w-full cursor-pointer rounded-xl p-0 text-left'
|
||||
>
|
||||
{video}
|
||||
</button>
|
||||
) : (
|
||||
video
|
||||
)}
|
||||
|
||||
{enableLightbox && (
|
||||
<Lightbox
|
||||
|
||||
@@ -53,16 +53,11 @@ Returns companies matching a set of criteria using Hunter.io AI-powered search.
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `results` | array | List of companies matching the search criteria |
|
||||
| ↳ `name` | string | Company name |
|
||||
| ↳ `domain` | string | Company domain |
|
||||
| ↳ `logo` | string | URL of the company logo |
|
||||
| ↳ `linkedin_url` | string | LinkedIn profile URL of the company |
|
||||
| ↳ `company_type` | string | Company type \(e.g., privately held, public company\) |
|
||||
| ↳ `industry` | string | Industry of the company |
|
||||
| ↳ `size` | string | Headcount range of the company |
|
||||
| ↳ `location` | string | Headquarters location |
|
||||
| ↳ `founded_year` | number | Year the company was founded |
|
||||
| ↳ `crunchbase_url` | string | Crunchbase URL of the company |
|
||||
| ↳ `organization` | string | Organization name |
|
||||
| ↳ `personal_emails` | number | Count of personal emails |
|
||||
| ↳ `generic_emails` | number | Count of generic \(role-based\) emails |
|
||||
| ↳ `total_emails` | number | Total emails found for the company |
|
||||
|
||||
### `hunter_domain_search`
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export function serializeJsonLd(value: unknown): string {
|
||||
return JSON.stringify(value).replace(/</g, '\\u003c')
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createElement, Fragment } from 'react'
|
||||
import { type InferPageType, loader, multiple } from 'fumadocs-core/source'
|
||||
import { loader, multiple } from 'fumadocs-core/source'
|
||||
import type { DocData, DocMethods } from 'fumadocs-mdx/runtime/types'
|
||||
import { openapiSource } from 'fumadocs-openapi/server'
|
||||
import { docs } from '@/.source/server'
|
||||
@@ -99,5 +99,3 @@ export type PageData = DocData &
|
||||
description?: string
|
||||
full?: boolean
|
||||
}
|
||||
|
||||
export type Page = InferPageType<typeof source>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { type ReactNode, useEffect, useState } from 'react'
|
||||
import { type ReactNode, useState } from 'react'
|
||||
import { Button } from '@/components/emcn'
|
||||
import { GithubIcon, GoogleIcon } from '@/components/icons'
|
||||
import { client } from '@/lib/auth/auth-client'
|
||||
@@ -22,15 +22,6 @@ export function SocialLoginButtons({
|
||||
}: SocialLoginButtonsProps) {
|
||||
const [isGithubLoading, setIsGithubLoading] = useState(false)
|
||||
const [isGoogleLoading, setIsGoogleLoading] = useState(false)
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
// Set mounted state to true on client-side
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
// Only render on the client side to avoid hydration errors
|
||||
if (!mounted) return null
|
||||
|
||||
async function signInWithGithub() {
|
||||
if (!githubAvailable) return
|
||||
|
||||
@@ -384,8 +384,8 @@ export default function LoginPage({
|
||||
/>
|
||||
{showEmailValidationError && emailErrors.length > 0 && (
|
||||
<div className='mt-1 space-y-1 text-red-400 text-xs'>
|
||||
{emailErrors.map((error, index) => (
|
||||
<p key={index}>{error}</p>
|
||||
{emailErrors.map((error) => (
|
||||
<p key={error}>{error}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -431,8 +431,8 @@ export default function LoginPage({
|
||||
</div>
|
||||
{showValidationError && passwordErrors.length > 0 && (
|
||||
<div className='mt-1 space-y-1 text-red-400 text-xs'>
|
||||
{passwordErrors.map((error, index) => (
|
||||
<p key={index}>{error}</p>
|
||||
{passwordErrors.map((error) => (
|
||||
<p key={error}>{error}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -454,8 +454,8 @@ export default function LoginPage({
|
||||
<button type='submit' disabled={isLoading} className={AUTH_SUBMIT_BTN}>
|
||||
{isLoading ? (
|
||||
<span className='flex items-center gap-2'>
|
||||
<Loader className='h-4 w-4' animate />
|
||||
Signing in...
|
||||
<Loader className='size-4' animate />
|
||||
Signing in…
|
||||
</span>
|
||||
) : (
|
||||
'Sign in'
|
||||
@@ -569,8 +569,8 @@ export default function LoginPage({
|
||||
<button type='submit' disabled={isSubmittingReset} className={AUTH_SUBMIT_BTN}>
|
||||
{isSubmittingReset ? (
|
||||
<span className='flex items-center gap-2'>
|
||||
<Loader className='h-4 w-4' animate />
|
||||
Sending...
|
||||
<Loader className='size-4' animate />
|
||||
Sending…
|
||||
</span>
|
||||
) : (
|
||||
'Send Reset Link'
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Suspense } from 'react'
|
||||
import type { Metadata } from 'next'
|
||||
import { getOAuthProviderStatus } from '@/app/(auth)/components/oauth-provider-checker'
|
||||
import LoginForm from '@/app/(auth)/login/login-form'
|
||||
@@ -12,10 +13,12 @@ export default async function LoginPage() {
|
||||
const { githubAvailable, googleAvailable, isProduction } = await getOAuthProviderStatus()
|
||||
|
||||
return (
|
||||
<LoginForm
|
||||
githubAvailable={githubAvailable}
|
||||
googleAvailable={googleAvailable}
|
||||
isProduction={isProduction}
|
||||
/>
|
||||
<Suspense fallback={null}>
|
||||
<LoginForm
|
||||
githubAvailable={githubAvailable}
|
||||
googleAvailable={googleAvailable}
|
||||
isProduction={isProduction}
|
||||
/>
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,9 +4,9 @@ export default function OAuthConsentLoading() {
|
||||
return (
|
||||
<div className='flex flex-col items-center'>
|
||||
<div className='flex items-center gap-4'>
|
||||
<Skeleton className='h-[48px] w-[48px] rounded-[12px]' />
|
||||
<Skeleton className='h-[20px] w-[20px] rounded-[4px]' />
|
||||
<Skeleton className='h-[48px] w-[48px] rounded-[12px]' />
|
||||
<Skeleton className='size-[48px] rounded-[12px]' />
|
||||
<Skeleton className='size-[20px] rounded-[4px]' />
|
||||
<Skeleton className='size-[48px] rounded-[12px]' />
|
||||
</div>
|
||||
<Skeleton className='mt-6 h-[38px] w-[220px] rounded-[4px]' />
|
||||
<Skeleton className='mt-2 h-[14px] w-[280px] rounded-[4px]' />
|
||||
|
||||
@@ -143,7 +143,7 @@ function OAuthConsentInner() {
|
||||
Authorize Application
|
||||
</h1>
|
||||
<p className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_60%,transparent)] text-lg leading-[125%] tracking-[0.02em]'>
|
||||
Loading application details...
|
||||
Loading application details…
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -184,11 +184,11 @@ function OAuthConsentInner() {
|
||||
className='rounded-[10px]'
|
||||
/>
|
||||
) : (
|
||||
<div className='flex h-12 w-12 items-center justify-center rounded-[10px] bg-[var(--landing-bg-elevated)] font-medium text-[var(--landing-text-muted)] text-lg'>
|
||||
<div className='flex size-12 items-center justify-center rounded-[10px] bg-[var(--landing-bg-elevated)] font-medium text-[var(--landing-text-muted)] text-lg'>
|
||||
{(clientName ?? '?').charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<ArrowLeftRight className='h-5 w-5 text-[var(--landing-text-muted)]' />
|
||||
<ArrowLeftRight className='size-5 text-[var(--landing-text-muted)]' />
|
||||
<Image
|
||||
src='/new/logo/colorized-bg.svg'
|
||||
alt='Sim'
|
||||
@@ -220,7 +220,7 @@ function OAuthConsentInner() {
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<div className='flex h-8 w-8 items-center justify-center rounded-full bg-[var(--landing-bg-elevated)] font-medium text-[var(--landing-text-muted)] text-small'>
|
||||
<div className='flex size-8 items-center justify-center rounded-full bg-[var(--landing-bg-elevated)] font-medium text-[var(--landing-text-muted)] text-small'>
|
||||
{(session.user.name ?? session.user.email ?? '?').charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
@@ -278,8 +278,8 @@ function OAuthConsentInner() {
|
||||
>
|
||||
{submitting ? (
|
||||
<span className='flex items-center gap-2'>
|
||||
<Loader className='h-4 w-4' animate />
|
||||
Authorizing...
|
||||
<Loader className='size-4' animate />
|
||||
Authorizing…
|
||||
</span>
|
||||
) : (
|
||||
'Allow'
|
||||
|
||||
@@ -95,9 +95,7 @@ function ResetPasswordContent() {
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={<div className='flex h-screen items-center justify-center'>Loading...</div>}
|
||||
>
|
||||
<Suspense fallback={<div className='flex h-screen items-center justify-center'>Loading…</div>}>
|
||||
<ResetPasswordContent />
|
||||
</Suspense>
|
||||
)
|
||||
|
||||
@@ -67,8 +67,8 @@ export function RequestResetForm({
|
||||
<button type='submit' disabled={isSubmitting} className={AUTH_SUBMIT_BTN}>
|
||||
{isSubmitting ? (
|
||||
<span className='flex items-center gap-2'>
|
||||
<Loader className='h-4 w-4' animate />
|
||||
Sending...
|
||||
<Loader className='size-4' animate />
|
||||
Sending…
|
||||
</span>
|
||||
) : (
|
||||
'Send Reset Link'
|
||||
@@ -211,8 +211,8 @@ export function SetNewPasswordForm({
|
||||
|
||||
{validationMessages.length > 0 && (
|
||||
<div className='mt-1 space-y-1 text-red-400 text-xs'>
|
||||
{validationMessages.map((error, index) => (
|
||||
<p key={index}>{error}</p>
|
||||
{validationMessages.map((error) => (
|
||||
<p key={error}>{error}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -232,8 +232,8 @@ export function SetNewPasswordForm({
|
||||
<button type='submit' disabled={isSubmitting || !token} className={AUTH_SUBMIT_BTN}>
|
||||
{isSubmitting ? (
|
||||
<span className='flex items-center gap-2'>
|
||||
<Loader className='h-4 w-4' animate />
|
||||
Resetting...
|
||||
<Loader className='size-4' animate />
|
||||
Resetting…
|
||||
</span>
|
||||
) : (
|
||||
'Reset Password'
|
||||
|
||||
@@ -406,8 +406,8 @@ function SignupFormContent({ githubAvailable, googleAvailable, isProduction }: S
|
||||
>
|
||||
<div className='overflow-hidden'>
|
||||
<div className='mt-1 space-y-1 text-red-400 text-xs'>
|
||||
{nameErrors.map((error, index) => (
|
||||
<p key={index}>{error}</p>
|
||||
{nameErrors.map((error) => (
|
||||
<p key={error}>{error}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -451,7 +451,7 @@ function SignupFormContent({ githubAvailable, googleAvailable, isProduction }: S
|
||||
<div className='overflow-hidden'>
|
||||
<div className='mt-1 space-y-1 text-red-400 text-xs'>
|
||||
{showEmailValidationError && emailErrors.length > 0 ? (
|
||||
emailErrors.map((error, index) => <p key={index}>{error}</p>)
|
||||
emailErrors.map((error) => <p key={error}>{error}</p>)
|
||||
) : emailError && !showEmailValidationError ? (
|
||||
<p>{emailError}</p>
|
||||
) : null}
|
||||
@@ -503,8 +503,8 @@ function SignupFormContent({ githubAvailable, googleAvailable, isProduction }: S
|
||||
>
|
||||
<div className='overflow-hidden'>
|
||||
<div className='mt-1 space-y-1 text-red-400 text-xs'>
|
||||
{passwordErrors.map((error, index) => (
|
||||
<p key={index}>{error}</p>
|
||||
{passwordErrors.map((error) => (
|
||||
<p key={error}>{error}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -530,8 +530,8 @@ function SignupFormContent({ githubAvailable, googleAvailable, isProduction }: S
|
||||
<button type='submit' disabled={isLoading} className={cn('!mt-6', AUTH_SUBMIT_BTN)}>
|
||||
{isLoading ? (
|
||||
<span className='flex items-center gap-2'>
|
||||
<Loader className='h-4 w-4' animate />
|
||||
Creating account...
|
||||
<Loader className='size-4' animate />
|
||||
Creating account…
|
||||
</span>
|
||||
) : (
|
||||
'Create account'
|
||||
@@ -628,9 +628,7 @@ export default function SignupPage({
|
||||
isProduction,
|
||||
}: SignupFormProps) {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={<div className='flex h-screen items-center justify-center'>Loading...</div>}
|
||||
>
|
||||
<Suspense fallback={<div className='flex h-screen items-center justify-center'>Loading…</div>}>
|
||||
<SignupFormContent
|
||||
githubAvailable={githubAvailable}
|
||||
googleAvailable={googleAvailable}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Suspense } from 'react'
|
||||
import type { Metadata } from 'next'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getEnv, isTruthy } from '@/lib/core/config/env'
|
||||
@@ -14,5 +15,9 @@ export default async function SSOPage() {
|
||||
redirect('/login')
|
||||
}
|
||||
|
||||
return <SSOForm />
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<SSOForm />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -117,8 +117,8 @@ function VerificationForm({
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className='flex items-center gap-2'>
|
||||
<Loader className='h-4 w-4' animate />
|
||||
Verifying...
|
||||
<Loader className='size-4' animate />
|
||||
Verifying…
|
||||
</span>
|
||||
) : (
|
||||
'Verify Email'
|
||||
|
||||
@@ -9,7 +9,7 @@ export function BackLink() {
|
||||
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)]'
|
||||
>
|
||||
<svg
|
||||
className='h-3 w-3 shrink-0'
|
||||
className='size-3 shrink-0'
|
||||
viewBox='0 0 10 10'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
|
||||
@@ -21,7 +21,7 @@ export default function BlogPostLoading() {
|
||||
<div className='mt-6 flex items-center gap-6'>
|
||||
<Skeleton className='h-[12px] w-[100px] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
|
||||
<div className='flex items-center gap-2'>
|
||||
<Skeleton className='h-[20px] w-[20px] rounded-full bg-[var(--landing-bg-elevated)]' />
|
||||
<Skeleton className='size-[20px] rounded-full bg-[var(--landing-bg-elevated)]' />
|
||||
<Skeleton className='h-[12px] w-[80px] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -92,8 +92,8 @@ export default async function Page({ params }: { params: Promise<{ slug: string
|
||||
</time>
|
||||
<meta itemProp='dateModified' content={post.updated ?? post.date} />
|
||||
<div className='flex items-center gap-3'>
|
||||
{(post.authors || [post.author]).map((a, idx) => (
|
||||
<div key={idx} className='flex items-center gap-2'>
|
||||
{(post.authors || [post.author]).map((a) => (
|
||||
<div key={a?.name} className='flex items-center gap-2'>
|
||||
{a?.avatarUrl ? (
|
||||
<Avatar className='size-5'>
|
||||
<AvatarImage src={a.avatarUrl} alt={a.name} />
|
||||
|
||||
@@ -46,21 +46,21 @@ export function ShareButton({ url, title }: ShareButtonProps) {
|
||||
className='flex items-center gap-1.5 text-[var(--landing-text-muted)] text-sm hover:text-[var(--landing-text)]'
|
||||
aria-label='Share this post'
|
||||
>
|
||||
<Share2 className='h-4 w-4' />
|
||||
<Share2 className='size-4' />
|
||||
<span>Share</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuItem onSelect={handleCopyLink}>
|
||||
<Copy className='h-4 w-4' />
|
||||
<Copy className='size-4' />
|
||||
{copied ? 'Copied!' : 'Copy link'}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={handleShareTwitter}>
|
||||
<XIcon className='h-4 w-4' />
|
||||
<XIcon className='size-4' />
|
||||
Share on X
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={handleShareLinkedIn}>
|
||||
<LinkedInIcon className='h-4 w-4' />
|
||||
<LinkedInIcon className='size-4' />
|
||||
Share on LinkedIn
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -6,7 +6,7 @@ export default function AuthorLoading() {
|
||||
return (
|
||||
<main className='mx-auto max-w-[900px] px-6 py-10 sm:px-8 md:px-12'>
|
||||
<div className='mb-6 flex items-center gap-3'>
|
||||
<Skeleton className='h-[40px] w-[40px] rounded-full bg-[var(--landing-bg-elevated)]' />
|
||||
<Skeleton className='size-[40px] rounded-full bg-[var(--landing-bg-elevated)]' />
|
||||
<Skeleton className='h-[32px] w-[160px] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
|
||||
</div>
|
||||
<div className='grid grid-cols-1 gap-8 sm:grid-cols-2'>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useEffect, useEffectEvent, useRef } from 'react'
|
||||
|
||||
interface LightboxProps {
|
||||
isOpen: boolean
|
||||
@@ -12,18 +12,20 @@ interface LightboxProps {
|
||||
export function Lightbox({ isOpen, onClose, src, alt }: LightboxProps) {
|
||||
const overlayRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const onCloseEvent = useEffectEvent(onClose)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
onClose()
|
||||
onCloseEvent()
|
||||
}
|
||||
}
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (overlayRef.current && event.target === overlayRef.current) {
|
||||
onClose()
|
||||
onCloseEvent()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +38,7 @@ export function Lightbox({ isOpen, onClose, src, alt }: LightboxProps) {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
document.body.style.overflow = 'unset'
|
||||
}
|
||||
}, [isOpen, onClose])
|
||||
}, [isOpen])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
@@ -49,13 +51,14 @@ export function Lightbox({ isOpen, onClose, src, alt }: LightboxProps) {
|
||||
aria-label='Image viewer'
|
||||
>
|
||||
<div className='relative max-h-full max-w-full overflow-hidden rounded-xl shadow-2xl'>
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className='max-h-[75vh] max-w-[75vw] cursor-pointer rounded-xl object-contain'
|
||||
loading='lazy'
|
||||
onClick={onClose}
|
||||
/>
|
||||
<button type='button' className='block cursor-pointer rounded-xl' onClick={onClose}>
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className='max-h-[75vh] max-w-[75vw] rounded-xl object-contain'
|
||||
loading='lazy'
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -38,7 +38,7 @@ export default function BlogLoading() {
|
||||
{/* List skeleton */}
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i}>
|
||||
<div className='flex items-center gap-6 px-6 py-6'>
|
||||
<div className='flex items-center gap-6 p-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)]' />
|
||||
|
||||
@@ -169,7 +169,7 @@ export default async function BlogIndex({
|
||||
<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'
|
||||
className='group flex items-start gap-6 p-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'>
|
||||
|
||||
@@ -137,13 +137,13 @@ export function AuthModal({ children, defaultView = 'login', source }: AuthModal
|
||||
|
||||
<div className='relative px-6 pt-6 pb-6'>
|
||||
<ModalClose className='absolute top-6 right-6 rounded-sm opacity-70 transition-opacity hover:opacity-100'>
|
||||
<X className='h-5 w-5 text-[var(--landing-text-muted)]' />
|
||||
<X className='size-5 text-[var(--landing-text-muted)]' />
|
||||
<span className='sr-only'>Close</span>
|
||||
</ModalClose>
|
||||
|
||||
{!providerStatus ? (
|
||||
<div className='flex items-center justify-center py-16'>
|
||||
<Loader className='h-5 w-5 text-[var(--landing-text-muted)]' animate />
|
||||
<Loader className='size-5 text-[var(--landing-text-muted)]' animate />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -174,7 +174,7 @@ export function AuthModal({ children, defaultView = 'login', source }: AuthModal
|
||||
disabled={!!socialLoading}
|
||||
className={SOCIAL_BTN}
|
||||
>
|
||||
<GoogleIcon className='absolute left-4 h-[18px] w-[18px] shrink-0' />
|
||||
<GoogleIcon className='absolute left-4 size-[18px] shrink-0' />
|
||||
<span>
|
||||
{socialLoading === 'google' ? 'Connecting...' : 'Continue with Google'}
|
||||
</span>
|
||||
@@ -187,7 +187,7 @@ export function AuthModal({ children, defaultView = 'login', source }: AuthModal
|
||||
disabled={!!socialLoading}
|
||||
className={SOCIAL_BTN}
|
||||
>
|
||||
<GithubIcon className='absolute left-4 h-[18px] w-[18px] shrink-0' />
|
||||
<GithubIcon className='absolute left-4 size-[18px] shrink-0' />
|
||||
<span>
|
||||
{socialLoading === 'github' ? 'Connecting...' : 'Continue with GitHub'}
|
||||
</span>
|
||||
|
||||
@@ -32,7 +32,7 @@ function DotGrid({ className, cols, rows, gap = 0 }: DotGridProps) {
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: cols * rows }, (_, i) => (
|
||||
<div key={i} className='h-[1.5px] w-[1.5px] rounded-full bg-[var(--landing-bg-elevated)]' />
|
||||
<div key={i} className='size-[1.5px] rounded-full bg-[var(--landing-bg-elevated)]' />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
@@ -72,6 +72,15 @@ const CURSOR_ARROW_PATH =
|
||||
const CURSOR_ARROW_MIRRORED_PATH =
|
||||
'M0.365 2.198L4.522 14.821C5.022 16.339 7.225 16.16 7.472 14.58L8.394 8.702C8.49 8.091 8.946 7.599 9.548 7.456L15.909 5.953C17.5 5.577 17.461 3.299 15.857 2.978L2.11 0.228C0.966 0 0.001 1.09 0.365 2.198Z'
|
||||
|
||||
// Long-running decorative loops for the landing visual, not UI feedback transitions.
|
||||
const AMBIENT_CURSOR_ANIMATION_SECONDS = {
|
||||
vikhyath: 16,
|
||||
alexa: 13,
|
||||
} as const
|
||||
|
||||
const getAmbientCursorAnimation = (name: string, durationSeconds: number) =>
|
||||
`${name} ${durationSeconds}s ease-in-out infinite`
|
||||
|
||||
function CursorArrow({ fill }: { fill: string }) {
|
||||
return (
|
||||
<svg width='23.15' height='21.1' viewBox='0 0 17.5 16.4' fill='none'>
|
||||
@@ -88,8 +97,10 @@ function VikhyathCursor() {
|
||||
style={{
|
||||
top: '27.47%',
|
||||
left: '25%',
|
||||
animation: 'cursorVikhyath 16s ease-in-out infinite',
|
||||
willChange: 'transform',
|
||||
animation: getAmbientCursorAnimation(
|
||||
'cursorVikhyath',
|
||||
AMBIENT_CURSOR_ANIMATION_SECONDS.vikhyath
|
||||
),
|
||||
}}
|
||||
>
|
||||
<div className='relative h-[37.14px] w-[79.18px]'>
|
||||
@@ -112,8 +123,7 @@ function AlexaCursor() {
|
||||
style={{
|
||||
top: '66.80%',
|
||||
left: '49%',
|
||||
animation: 'cursorAlexa 13s ease-in-out infinite',
|
||||
willChange: 'transform',
|
||||
animation: getAmbientCursorAnimation('cursorAlexa', AMBIENT_CURSOR_ANIMATION_SECONDS.alexa),
|
||||
}}
|
||||
>
|
||||
<div className='relative h-[35.09px] w-[62.16px]'>
|
||||
@@ -282,7 +292,7 @@ export default function Collaboration() {
|
||||
>
|
||||
Build together
|
||||
<svg
|
||||
className='h-[10px] w-[10px] shrink-0'
|
||||
className='size-[10px] shrink-0'
|
||||
viewBox='0 0 10 10'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
@@ -339,7 +349,13 @@ export default function Collaboration() {
|
||||
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'
|
||||
>
|
||||
<div className='relative h-7 w-11 shrink-0'>
|
||||
<Image src='/landing/multiplayer-cursors.svg' alt='' fill className='object-contain' />
|
||||
<Image
|
||||
src='/landing/multiplayer-cursors.svg'
|
||||
alt=''
|
||||
fill
|
||||
sizes='44px'
|
||||
className='object-contain'
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-col gap-0.5'>
|
||||
<span className='font-[430] font-season text-[#F6F6F0]/50 text-caption uppercase leading-[100%] tracking-[0.08em]'>
|
||||
|
||||
@@ -149,8 +149,8 @@ export function ContactForm() {
|
||||
if (submitSuccess) {
|
||||
return (
|
||||
<div className='flex flex-col items-center px-8 py-16 text-center'>
|
||||
<div className='flex h-16 w-16 items-center justify-center rounded-full border border-[var(--landing-bg-elevated)] bg-[var(--landing-bg-surface)] text-[var(--landing-text)]'>
|
||||
<Check className='h-8 w-8' />
|
||||
<div className='flex size-16 items-center justify-center rounded-full border border-[var(--landing-bg-elevated)] bg-[var(--landing-bg-surface)] text-[var(--landing-text)]'>
|
||||
<Check className='size-8' />
|
||||
</div>
|
||||
<h2 className='mt-6 font-[430] font-season text-[24px] text-[var(--landing-text)] leading-[1.2] tracking-[-0.02em]'>
|
||||
Message received
|
||||
|
||||
@@ -251,8 +251,8 @@ export function DemoRequestModal({ children, theme = 'dark' }: DemoRequestModalP
|
||||
{submitSuccess ? (
|
||||
<div className='absolute inset-0 flex items-center justify-center px-8 pb-10 sm:px-12 sm:pb-14'>
|
||||
<div className='flex max-w-md flex-col items-center justify-center text-center'>
|
||||
<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 className='flex size-20 items-center justify-center rounded-full border border-[var(--border)] bg-[var(--bg-subtle)] text-[var(--text-primary)]'>
|
||||
<Check className='size-10' />
|
||||
</div>
|
||||
<h2 className='mt-8 font-[430] font-season text-[34px] text-[var(--text-primary)] leading-[1.1] tracking-[-0.03em]'>
|
||||
{SUBMIT_SUCCESS_MESSAGE}
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useRef, useState } from 'react'
|
||||
import { motion, useInView } from 'framer-motion'
|
||||
import { PROVIDER_DEFINITIONS } from '@/providers/models'
|
||||
|
||||
interface PermissionFeature {
|
||||
name: string
|
||||
key: string
|
||||
defaultEnabled: boolean
|
||||
providerId?: string
|
||||
}
|
||||
|
||||
interface PermissionCategory {
|
||||
label: string
|
||||
color: string
|
||||
features: PermissionFeature[]
|
||||
}
|
||||
|
||||
const PERMISSION_CATEGORIES: PermissionCategory[] = [
|
||||
{
|
||||
label: 'Providers',
|
||||
color: '#FA4EDF',
|
||||
features: [
|
||||
{ key: 'openai', name: 'OpenAI', defaultEnabled: true, providerId: 'openai' },
|
||||
{ key: 'anthropic', name: 'Anthropic', defaultEnabled: true, providerId: 'anthropic' },
|
||||
{ key: 'google', name: 'Google', defaultEnabled: false, providerId: 'google' },
|
||||
{ key: 'xai', name: 'xAI', defaultEnabled: true, providerId: 'xai' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Workspace',
|
||||
color: '#2ABBF8',
|
||||
features: [
|
||||
{ key: 'knowledge-base', name: 'Knowledge Base', defaultEnabled: true },
|
||||
{ key: 'tables', name: 'Tables', defaultEnabled: true },
|
||||
{ key: 'copilot', name: 'Copilot', defaultEnabled: false },
|
||||
{ key: 'environment', name: 'Environment', defaultEnabled: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Tools',
|
||||
color: '#33C482',
|
||||
features: [
|
||||
{ key: 'mcp-tools', name: 'MCP Tools', defaultEnabled: true },
|
||||
{ key: 'custom-tools', name: 'Custom Tools', defaultEnabled: false },
|
||||
{ key: 'skills', name: 'Skills', defaultEnabled: true },
|
||||
{ key: 'invitations', name: 'Invitations', defaultEnabled: true },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const INITIAL_ACCESS_STATE = Object.fromEntries(
|
||||
PERMISSION_CATEGORIES.flatMap((category) =>
|
||||
category.features.map((feature) => [feature.key, feature.defaultEnabled])
|
||||
)
|
||||
)
|
||||
|
||||
function CheckboxIcon({ checked, color }: { checked: boolean; color: string }) {
|
||||
return (
|
||||
<div
|
||||
className='h-[6px] w-[6px] shrink-0 rounded-full transition-colors duration-200'
|
||||
style={{
|
||||
backgroundColor: checked ? color : 'transparent',
|
||||
border: checked ? 'none' : '1.5px solid #3A3A3A',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ProviderPreviewIcon({ providerId }: { providerId?: string }) {
|
||||
if (!providerId) return null
|
||||
|
||||
const ProviderIcon = PROVIDER_DEFINITIONS[providerId]?.icon
|
||||
if (!ProviderIcon) return null
|
||||
|
||||
return (
|
||||
<div className='relative flex h-[14px] w-[14px] shrink-0 items-center justify-center opacity-50 brightness-0 invert'>
|
||||
<ProviderIcon className='!h-[14px] !w-[14px]' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface FeatureToggleItemProps {
|
||||
feature: PermissionFeature
|
||||
enabled: boolean
|
||||
color: string
|
||||
isInView: boolean
|
||||
delay: number
|
||||
textClassName: string
|
||||
transition: Record<string, unknown>
|
||||
onToggle: () => void
|
||||
}
|
||||
|
||||
function FeatureToggleItem({
|
||||
feature,
|
||||
enabled,
|
||||
color,
|
||||
isInView,
|
||||
delay,
|
||||
textClassName,
|
||||
transition,
|
||||
onToggle,
|
||||
}: FeatureToggleItemProps) {
|
||||
return (
|
||||
<motion.div
|
||||
key={feature.key}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
aria-label={`Toggle ${feature.name}`}
|
||||
aria-pressed={enabled}
|
||||
className='flex cursor-pointer items-center gap-2 rounded-[4px] py-0.5'
|
||||
initial={{ opacity: 0, x: -6 }}
|
||||
animate={isInView ? { opacity: 1, x: 0 } : {}}
|
||||
transition={{ ...transition, delay }}
|
||||
onClick={onToggle}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onToggle()
|
||||
}
|
||||
}}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<CheckboxIcon checked={enabled} color={color} />
|
||||
<ProviderPreviewIcon providerId={feature.providerId} />
|
||||
<span className={textClassName} style={{ color: enabled ? '#F6F6F6AA' : '#F6F6F640' }}>
|
||||
{feature.name}
|
||||
</span>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AccessControlPanel() {
|
||||
const ref = useRef(null)
|
||||
const isInView = useInView(ref, { once: true, margin: '-40px' })
|
||||
const [accessState, setAccessState] = useState<Record<string, boolean>>(INITIAL_ACCESS_STATE)
|
||||
|
||||
return (
|
||||
<div ref={ref}>
|
||||
<div className='lg:hidden'>
|
||||
{PERMISSION_CATEGORIES.map((category, catIdx) => {
|
||||
const offsetBefore = PERMISSION_CATEGORIES.slice(0, catIdx).reduce(
|
||||
(sum, c) => sum + c.features.length,
|
||||
0
|
||||
)
|
||||
|
||||
return (
|
||||
<div key={category.label} className={catIdx > 0 ? 'mt-4' : ''}>
|
||||
<span className='font-[430] font-season text-[#F6F6F6]/55 text-[10px] uppercase leading-none tracking-[0.08em]'>
|
||||
{category.label}
|
||||
</span>
|
||||
<div className='mt-2 grid grid-cols-2 gap-x-4 gap-y-2'>
|
||||
{category.features.map((feature, featIdx) => (
|
||||
<FeatureToggleItem
|
||||
key={feature.key}
|
||||
feature={feature}
|
||||
enabled={accessState[feature.key]}
|
||||
color={category.color}
|
||||
isInView={isInView}
|
||||
delay={0.05 + (offsetBefore + featIdx) * 0.04}
|
||||
textClassName='truncate font-[430] font-season text-[13px] leading-none tracking-[0.02em]'
|
||||
transition={{ duration: 0.3 }}
|
||||
onToggle={() =>
|
||||
setAccessState((prev) => ({ ...prev, [feature.key]: !prev[feature.key] }))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Desktop -- categorized grid */}
|
||||
<div className='hidden lg:block'>
|
||||
{PERMISSION_CATEGORIES.map((category, catIdx) => (
|
||||
<div key={category.label} className={catIdx > 0 ? 'mt-4' : ''}>
|
||||
<span className='font-[430] font-season text-[#F6F6F6]/55 text-[10px] uppercase leading-none tracking-[0.08em]'>
|
||||
{category.label}
|
||||
</span>
|
||||
<div className='mt-2 grid grid-cols-2 gap-x-4 gap-y-2'>
|
||||
{category.features.map((feature, featIdx) => {
|
||||
const currentIndex =
|
||||
PERMISSION_CATEGORIES.slice(0, catIdx).reduce(
|
||||
(sum, c) => sum + c.features.length,
|
||||
0
|
||||
) + featIdx
|
||||
|
||||
return (
|
||||
<FeatureToggleItem
|
||||
key={feature.key}
|
||||
feature={feature}
|
||||
enabled={accessState[feature.key]}
|
||||
color={category.color}
|
||||
isInView={isInView}
|
||||
delay={0.1 + currentIndex * 0.04}
|
||||
textClassName='truncate font-[430] font-season text-[11px] leading-none tracking-[0.02em] transition-opacity duration-200'
|
||||
transition={{ duration: 0.3, ease: [0.25, 0.46, 0.45, 0.94] }}
|
||||
onToggle={() =>
|
||||
setAccessState((prev) => ({ ...prev, [feature.key]: !prev[feature.key] }))
|
||||
}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,225 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
|
||||
/** Consistent color per actor -- same pattern as Collaboration section cursors. */
|
||||
const ACTOR_COLORS: Record<string, string> = {
|
||||
'Sarah K.': '#2ABBF8',
|
||||
'Sid G.': '#33C482',
|
||||
'Theo L.': '#FA4EDF',
|
||||
'Abhay K.': '#FFCC02',
|
||||
'Danny S.': '#FF6B35',
|
||||
}
|
||||
|
||||
/** Left accent bar opacity by recency -- newest is brightest. */
|
||||
const ACCENT_OPACITIES = [0.75, 0.5, 0.35, 0.22, 0.12, 0.05] as const
|
||||
|
||||
interface LogEntry {
|
||||
id: number
|
||||
actor: string
|
||||
/** Matches the `description` field stored by recordAudit() */
|
||||
description: string
|
||||
resourceType: string
|
||||
/** Unix ms timestamp of when this entry was "received" */
|
||||
insertedAt: number
|
||||
}
|
||||
|
||||
function formatTimeAgo(insertedAt: number): string {
|
||||
const elapsed = Date.now() - insertedAt
|
||||
if (elapsed < 8_000) return 'just now'
|
||||
if (elapsed < 60_000) return `${Math.floor(elapsed / 1000)}s ago`
|
||||
return `${Math.floor(elapsed / 60_000)}m ago`
|
||||
}
|
||||
|
||||
/**
|
||||
* Entry templates using real description strings from the actual recordAudit()
|
||||
* calls across the codebase (e.g. `Added BYOK key for openai`,
|
||||
* `Invited alex@acme.com to workspace as member`).
|
||||
*/
|
||||
const ENTRY_TEMPLATES: Omit<LogEntry, 'id' | 'insertedAt'>[] = [
|
||||
{ actor: 'Sarah K.', description: 'Deployed workflow "Email Triage"', resourceType: 'workflow' },
|
||||
{
|
||||
actor: 'Sid G.',
|
||||
description: 'Invited alex@acme.com to workspace as member',
|
||||
resourceType: 'member',
|
||||
},
|
||||
{ actor: 'Theo L.', description: 'Added BYOK key for openai', resourceType: 'byok_key' },
|
||||
{ actor: 'Sarah K.', description: 'Created workflow "Invoice Parser"', resourceType: 'workflow' },
|
||||
{
|
||||
actor: 'Abhay K.',
|
||||
description: 'Created permission group "Engineering"',
|
||||
resourceType: 'permission_group',
|
||||
},
|
||||
{ actor: 'Danny S.', description: 'Created API key "Production Key"', resourceType: 'api_key' },
|
||||
{
|
||||
actor: 'Theo L.',
|
||||
description: 'Changed permissions for sam@acme.com to editor',
|
||||
resourceType: 'member',
|
||||
},
|
||||
{ actor: 'Sarah K.', description: 'Uploaded file "Q3_Report.pdf"', resourceType: 'file' },
|
||||
{
|
||||
actor: 'Sid G.',
|
||||
description: 'Created credential set "Prod Keys"',
|
||||
resourceType: 'credential_set',
|
||||
},
|
||||
{
|
||||
actor: 'Abhay K.',
|
||||
description: 'Created knowledge base "Internal Docs"',
|
||||
resourceType: 'knowledge_base',
|
||||
},
|
||||
{ actor: 'Danny S.', description: 'Updated environment variables', resourceType: 'environment' },
|
||||
{
|
||||
actor: 'Sarah K.',
|
||||
description: 'Added tool "search_web" to MCP server',
|
||||
resourceType: 'mcp_server',
|
||||
},
|
||||
{ actor: 'Sid G.', description: 'Created webhook "Stripe Payment"', resourceType: 'webhook' },
|
||||
{ actor: 'Theo L.', description: 'Deployed chat "Support Assistant"', resourceType: 'chat' },
|
||||
{ actor: 'Abhay K.', description: 'Created table "Lead Tracker"', resourceType: 'table' },
|
||||
{ actor: 'Danny S.', description: 'Revoked API key "Staging Key"', resourceType: 'api_key' },
|
||||
{
|
||||
actor: 'Sarah K.',
|
||||
description: 'Duplicated workflow "Data Enrichment"',
|
||||
resourceType: 'workflow',
|
||||
},
|
||||
{
|
||||
actor: 'Sid G.',
|
||||
description: 'Removed member theo@acme.com from workspace',
|
||||
resourceType: 'member',
|
||||
},
|
||||
{
|
||||
actor: 'Theo L.',
|
||||
description: 'Updated knowledge base "Product Docs"',
|
||||
resourceType: 'knowledge_base',
|
||||
},
|
||||
{ actor: 'Abhay K.', description: 'Created folder "Finance Workflows"', resourceType: 'folder' },
|
||||
{
|
||||
actor: 'Danny S.',
|
||||
description: 'Uploaded document "onboarding-guide.pdf"',
|
||||
resourceType: 'document',
|
||||
},
|
||||
{
|
||||
actor: 'Sarah K.',
|
||||
description: 'Updated credential set "Prod Keys"',
|
||||
resourceType: 'credential_set',
|
||||
},
|
||||
{
|
||||
actor: 'Sid G.',
|
||||
description: 'Added member abhay@acme.com to permission group "Engineering"',
|
||||
resourceType: 'permission_group',
|
||||
},
|
||||
{ actor: 'Theo L.', description: 'Locked workflow "Customer Sync"', resourceType: 'workflow' },
|
||||
]
|
||||
|
||||
const INITIAL_OFFSETS_MS = [0, 20_000, 75_000, 180_000, 360_000, 600_000]
|
||||
|
||||
interface AuditRowProps {
|
||||
entry: LogEntry
|
||||
index: number
|
||||
}
|
||||
|
||||
function AuditRow({ entry, index }: AuditRowProps) {
|
||||
const color = ACTOR_COLORS[entry.actor] ?? '#F6F6F6'
|
||||
const accentOpacity = ACCENT_OPACITIES[index] ?? 0.04
|
||||
const timeAgo = formatTimeAgo(entry.insertedAt)
|
||||
|
||||
return (
|
||||
<div className='group relative overflow-hidden border-[var(--landing-border)] border-b bg-[var(--landing-bg)] transition-colors duration-150 last:border-b-0 hover:bg-[#212121]'>
|
||||
{/* Left accent bar -- brightness encodes recency */}
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='absolute top-0 bottom-0 left-0 w-[2px] transition-opacity duration-150 group-hover:opacity-100'
|
||||
style={{ backgroundColor: color, opacity: accentOpacity }}
|
||||
/>
|
||||
|
||||
{/* Row content */}
|
||||
<div className='flex min-w-0 items-center gap-3 py-[10px] pr-4 pl-5'>
|
||||
{/* Actor avatar */}
|
||||
<div
|
||||
className='flex h-[22px] w-[22px] shrink-0 items-center justify-center rounded-full'
|
||||
style={{ backgroundColor: `${color}20` }}
|
||||
>
|
||||
<span className='font-[500] font-season text-[9px] leading-none' style={{ color }}>
|
||||
{entry.actor[0]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Time */}
|
||||
<span className='w-[56px] shrink-0 font-[430] font-season text-[#F6F6F6]/55 text-[11px] leading-none tracking-[0.02em]'>
|
||||
{timeAgo}
|
||||
</span>
|
||||
|
||||
<span className='min-w-0 truncate font-[430] font-season text-[12px] leading-none tracking-[0.02em]'>
|
||||
<span className='text-[#F6F6F6]/80'>{entry.actor}</span>
|
||||
<span className='hidden sm:inline'>
|
||||
<span className='text-[#F6F6F6]/60'> · </span>
|
||||
<span className='text-[#F6F6F6]/55'>{entry.description}</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AuditLogPreview() {
|
||||
const counterRef = useRef(ENTRY_TEMPLATES.length)
|
||||
const templateIndexRef = useRef(6 % ENTRY_TEMPLATES.length)
|
||||
|
||||
const [entries, setEntries] = useState<LogEntry[]>(() => {
|
||||
const now = Date.now()
|
||||
return ENTRY_TEMPLATES.slice(0, 6).map((t, i) => ({
|
||||
...t,
|
||||
id: i,
|
||||
insertedAt: now - INITIAL_OFFSETS_MS[i],
|
||||
}))
|
||||
})
|
||||
const [, tick] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const addInterval = setInterval(() => {
|
||||
const template = ENTRY_TEMPLATES[templateIndexRef.current]
|
||||
templateIndexRef.current = (templateIndexRef.current + 1) % ENTRY_TEMPLATES.length
|
||||
|
||||
setEntries((prev) => [
|
||||
{ ...template, id: counterRef.current++, insertedAt: Date.now() },
|
||||
...prev.slice(0, 5),
|
||||
])
|
||||
}, 2600)
|
||||
|
||||
// Refresh time labels every 5s so "just now" ages to "Xs ago"
|
||||
const tickInterval = setInterval(() => tick((n) => n + 1), 5_000)
|
||||
|
||||
return () => {
|
||||
clearInterval(addInterval)
|
||||
clearInterval(tickInterval)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className='mt-5 overflow-hidden px-6 md:mt-6 md:px-8'>
|
||||
<AnimatePresence mode='popLayout' initial={false}>
|
||||
{entries.map((entry, index) => (
|
||||
<motion.div
|
||||
key={entry.id}
|
||||
layout
|
||||
initial={{ y: -48, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{
|
||||
layout: {
|
||||
type: 'tween',
|
||||
duration: 0.32,
|
||||
ease: [0.25, 0.46, 0.45, 0.94],
|
||||
},
|
||||
y: { duration: 0.32, ease: [0.25, 0.46, 0.45, 0.94] },
|
||||
opacity: { duration: 0.25 },
|
||||
}}
|
||||
>
|
||||
<AuditRow entry={entry} index={index} />
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,266 +0,0 @@
|
||||
/**
|
||||
* Enterprise section — compliance, scale, and security messaging.
|
||||
*
|
||||
* SEO:
|
||||
* - `<section id="enterprise" aria-labelledby="enterprise-heading">`.
|
||||
* - `<h2 id="enterprise-heading">` for the section title.
|
||||
* - Compliance cert (SOC 2) as visible `<strong>` text.
|
||||
* - Enterprise CTA links to contact form via `<a>` with `rel="noopener noreferrer"`.
|
||||
*
|
||||
* GEO:
|
||||
* - Entity-rich: "Sim is SOC 2 compliant" — not "We are compliant."
|
||||
* - `<ul>` checklist of features (SSO, RBAC, audit logs, SLA, on-premise deployment)
|
||||
* as an atomic answer block for "What enterprise features does Sim offer?".
|
||||
*/
|
||||
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { Badge } 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'
|
||||
|
||||
const ENTERPRISE_FEATURE_MARQUEE_STYLES = `
|
||||
@keyframes enterprise-feature-marquee {
|
||||
0% { transform: translateX(0); }
|
||||
100% { transform: translateX(-25%); }
|
||||
}
|
||||
.enterprise-feature-marquee-track {
|
||||
animation: enterprise-feature-marquee 30s linear infinite;
|
||||
}
|
||||
.enterprise-feature-marquee:hover .enterprise-feature-marquee-track {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
.enterprise-feature-marquee-tag {
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.enterprise-feature-marquee-track {
|
||||
animation: none;
|
||||
}
|
||||
.enterprise-feature-marquee-tag {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const FEATURE_TAGS = [
|
||||
'Access Control',
|
||||
'Self-Hosting',
|
||||
'Bring Your Own Key',
|
||||
'Credential Sharing',
|
||||
'Custom Limits',
|
||||
'Admin API',
|
||||
'White Labeling',
|
||||
'Dedicated Support',
|
||||
'99.99% Uptime SLA',
|
||||
'Workflow Versioning',
|
||||
'On-Premise',
|
||||
'Organizations',
|
||||
'Workspace Export',
|
||||
'Audit Logs',
|
||||
] as const
|
||||
|
||||
function TrustStrip() {
|
||||
return (
|
||||
<div className='mx-6 mt-4 grid grid-cols-1 overflow-hidden rounded-lg border border-[var(--landing-bg-elevated)] sm:grid-cols-3 md:mx-8'>
|
||||
{/* SOC 2 */}
|
||||
<Link
|
||||
href='https://app.vanta.com/sim.ai/trust/v35ia0jil4l7dteqjgaktn'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='group flex items-center gap-3 border-[var(--landing-bg-elevated)] border-b px-4 py-3.5 transition-colors hover:bg-[#212121] sm:border-r sm:border-b-0'
|
||||
>
|
||||
<Image
|
||||
src='/footer/soc2.png'
|
||||
alt='SOC 2 Type II'
|
||||
width={22}
|
||||
height={22}
|
||||
className='shrink-0 object-contain'
|
||||
unoptimized
|
||||
/>
|
||||
<div className='flex flex-col gap-[3px]'>
|
||||
<strong className='font-[430] font-season text-small text-white leading-none'>
|
||||
SOC 2
|
||||
</strong>
|
||||
<span className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_55%,transparent)] text-xs leading-none tracking-[0.02em] transition-colors group-hover:text-[color-mix(in_srgb,var(--landing-text-subtle)_75%,transparent)]'>
|
||||
Type II →
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* Open Source -- center */}
|
||||
<Link
|
||||
href='https://github.com/simstudioai/sim'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='group flex items-center gap-3 border-[var(--landing-bg-elevated)] border-b px-4 py-3.5 transition-colors hover:bg-[#212121] sm:border-r sm:border-b-0'
|
||||
>
|
||||
<div className='flex h-[22px] w-[22px] shrink-0 items-center justify-center rounded-full bg-[#FFCC02]/10'>
|
||||
<GithubIcon width={11} height={11} className='text-[#FFCC02]/75' />
|
||||
</div>
|
||||
<div className='flex flex-col gap-[3px]'>
|
||||
<strong className='font-[430] font-season text-small text-white leading-none'>
|
||||
Open Source
|
||||
</strong>
|
||||
<span className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_55%,transparent)] text-xs leading-none tracking-[0.02em] transition-colors group-hover:text-[color-mix(in_srgb,var(--landing-text-subtle)_75%,transparent)]'>
|
||||
View on GitHub →
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* SSO */}
|
||||
<div className='flex items-center gap-3 px-4 py-3.5'>
|
||||
<div className='flex h-[22px] w-[22px] shrink-0 items-center justify-center rounded-full bg-[#2ABBF8]/10'>
|
||||
<Lock className='h-[14px] w-[14px] text-[#2ABBF8]/75' />
|
||||
</div>
|
||||
<div className='flex flex-col gap-[3px]'>
|
||||
<strong className='font-[430] font-season text-small text-white leading-none'>
|
||||
SSO & SCIM
|
||||
</strong>
|
||||
<span className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_55%,transparent)] text-xs leading-none tracking-[0.02em]'>
|
||||
Okta, Azure AD, Google
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Enterprise() {
|
||||
return (
|
||||
<section
|
||||
id='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='flex flex-col items-start gap-3 sm:gap-4 md:gap-5'>
|
||||
<Badge
|
||||
variant='blue'
|
||||
size='md'
|
||||
dot
|
||||
className='bg-[#FFCC02]/10 font-season text-[#FFCC02] uppercase tracking-[0.02em]'
|
||||
>
|
||||
Enterprise
|
||||
</Badge>
|
||||
|
||||
<h2
|
||||
id='enterprise-heading'
|
||||
className='max-w-[600px] text-balance font-[430] font-season text-[32px] text-[var(--landing-text-dark)] leading-[100%] tracking-[-0.02em] sm:text-[36px] md:text-[40px]'
|
||||
>
|
||||
Enterprise features for
|
||||
<br />
|
||||
fast, scalable workflows
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className='mt-8 overflow-hidden rounded-[12px] bg-[var(--landing-bg)] sm:mt-10 md:mt-12'>
|
||||
<div className='grid grid-cols-1 border-[var(--landing-border)] border-b lg:grid-cols-[1fr_420px]'>
|
||||
{/* Audit Trail */}
|
||||
<div className='border-[var(--landing-border)] lg:border-r'>
|
||||
<div className='px-6 pt-6 md:px-8 md:pt-8'>
|
||||
<h3 className='font-[430] font-season text-[16px] text-white leading-[120%] tracking-[-0.01em]'>
|
||||
Audit Trail
|
||||
</h3>
|
||||
<p className='mt-2 max-w-[480px] font-[430] font-season text-[#F6F6F6]/70 text-[14px] leading-[150%] tracking-[0.02em]'>
|
||||
Every action is captured with full actor attribution.
|
||||
</p>
|
||||
</div>
|
||||
<AuditLogPreview />
|
||||
<div className='h-6 md:h-8' />
|
||||
</div>
|
||||
|
||||
{/* Access Control */}
|
||||
<div className='border-[var(--landing-border)] border-t lg:border-t-0'>
|
||||
<div className='px-6 pt-6 md:px-8 md:pt-8'>
|
||||
<h3 className='font-[430] font-season text-[16px] text-white leading-[120%] tracking-[-0.01em]'>
|
||||
Access Control
|
||||
</h3>
|
||||
<p className='mt-1.5 font-[430] font-season text-[#F6F6F6]/70 text-[14px] leading-[150%] tracking-[0.02em]'>
|
||||
Restrict providers, surfaces, and tools per group.
|
||||
</p>
|
||||
</div>
|
||||
<div className='mt-5 px-6 pb-6 md:mt-6 md:px-8 md:pb-8'>
|
||||
<AccessControlPanel />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TrustStrip />
|
||||
|
||||
{/* Scrolling feature ticker — keyframe loop; pause on hover. Tags use transitions for hover. */}
|
||||
<div className='enterprise-feature-marquee relative mt-6 overflow-hidden border-[var(--landing-bg-elevated)] border-t'>
|
||||
<style dangerouslySetInnerHTML={{ __html: ENTERPRISE_FEATURE_MARQUEE_STYLES }} />
|
||||
{/* Fade edges */}
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='pointer-events-none absolute top-0 bottom-0 left-0 z-10 w-24'
|
||||
style={{ background: 'linear-gradient(to right, var(--landing-bg), transparent)' }}
|
||||
/>
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='pointer-events-none absolute top-0 right-0 bottom-0 z-10 w-24'
|
||||
style={{ background: 'linear-gradient(to left, var(--landing-bg), transparent)' }}
|
||||
/>
|
||||
{/* Duplicate tags for seamless loop */}
|
||||
<div className='enterprise-feature-marquee-track flex w-max'>
|
||||
{[...FEATURE_TAGS, ...FEATURE_TAGS, ...FEATURE_TAGS, ...FEATURE_TAGS].map(
|
||||
(tag, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className='enterprise-feature-marquee-tag whitespace-nowrap border-[var(--landing-bg-elevated)] border-r px-5 py-4 font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_60%,transparent)] text-small leading-none tracking-[0.02em] hover:bg-white/[0.04] hover:text-[color-mix(in_srgb,var(--landing-text-subtle)_80%,transparent)]'
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between border-[var(--landing-bg-elevated)] border-t px-6 py-5 md:px-8 md:py-6'>
|
||||
<p className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_60%,transparent)] text-base leading-[150%] tracking-[0.02em]'>
|
||||
Ready for growth?
|
||||
</p>
|
||||
<DemoRequestModal>
|
||||
<button
|
||||
type='button'
|
||||
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'
|
||||
fill='none'
|
||||
className='transition-transform duration-200 ease-out group-hover/cta:translate-x-[30%]'
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</DemoRequestModal>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { type SVGProps, useEffect, useRef, useState } from 'react'
|
||||
import { AnimatePresence, motion, useInView } from 'framer-motion'
|
||||
import { AnimatePresence, domAnimation, LazyMotion, m, useInView } from 'framer-motion'
|
||||
import { Streamdown } from 'streamdown'
|
||||
import 'streamdown/styles.css'
|
||||
import { ChevronDown } from '@/components/emcn'
|
||||
@@ -28,30 +28,32 @@ export function FeaturesPreview({ activeTab }: FeaturesPreviewProps) {
|
||||
const isWorkspaceTab = activeTab <= 3
|
||||
|
||||
return (
|
||||
<div className='relative h-[350px] w-full md:h-[560px]'>
|
||||
<motion.div
|
||||
className='absolute inset-0'
|
||||
animate={{ opacity: isWorkspaceTab ? 1 : 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
style={{ pointerEvents: isWorkspaceTab ? 'auto' : 'none' }}
|
||||
>
|
||||
<WorkspacePreview activeTab={activeTab} isActive={isWorkspaceTab} />
|
||||
</motion.div>
|
||||
<AnimatePresence>
|
||||
{!isWorkspaceTab && (
|
||||
<motion.div
|
||||
key={activeTab}
|
||||
className='absolute inset-0'
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<DefaultPreview />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
<LazyMotion features={domAnimation}>
|
||||
<div className='relative h-[350px] w-full md:h-[560px]'>
|
||||
<m.div
|
||||
className='absolute inset-0'
|
||||
animate={{ opacity: isWorkspaceTab ? 1 : 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
style={{ pointerEvents: isWorkspaceTab ? 'auto' : 'none' }}
|
||||
>
|
||||
<WorkspacePreview activeTab={activeTab} isActive={isWorkspaceTab} />
|
||||
</m.div>
|
||||
<AnimatePresence>
|
||||
{!isWorkspaceTab && (
|
||||
<m.div
|
||||
key={activeTab}
|
||||
className='absolute inset-0'
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<DefaultPreview />
|
||||
</m.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</LazyMotion>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -212,7 +214,7 @@ function WorkspacePreview({ activeTab, isActive }: { activeTab: number; isActive
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className='relative h-[350px] w-full overflow-hidden md:h-[560px]'>
|
||||
<motion.div
|
||||
<m.div
|
||||
aria-hidden='true'
|
||||
className='absolute inset-0'
|
||||
animate={{ opacity: isExpanded ? 0 : 1 }}
|
||||
@@ -228,7 +230,7 @@ function WorkspacePreview({ activeTab, isActive }: { activeTab: number; isActive
|
||||
|
||||
<AnimatePresence>
|
||||
{isMothership && !showGrid && inView && (
|
||||
<motion.div
|
||||
<m.div
|
||||
key='mock-input'
|
||||
className='absolute inset-0 z-10 flex items-center justify-center'
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
@@ -237,7 +239,7 @@ function WorkspacePreview({ activeTab, isActive }: { activeTab: number; isActive
|
||||
transition={{ duration: 0.45, ease: [0.4, 0, 0.2, 1] }}
|
||||
>
|
||||
<MockUserInput text={typedText} />
|
||||
</motion.div>
|
||||
</m.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
@@ -254,7 +256,7 @@ function WorkspacePreview({ activeTab, isActive }: { activeTab: number; isActive
|
||||
}}
|
||||
>
|
||||
{MOTHERSHIP_CARDS.map((card) => (
|
||||
<motion.div
|
||||
<m.div
|
||||
key={`${card.row}-${card.col}`}
|
||||
className='absolute'
|
||||
initial={gridAnimateIn.current ? { opacity: 0, scale: 0.7, y: 6 } : false}
|
||||
@@ -272,13 +274,13 @@ function WorkspacePreview({ activeTab, isActive }: { activeTab: number; isActive
|
||||
}}
|
||||
>
|
||||
<MiniCard variant={card.variant} label={card.label} color={card.color} />
|
||||
</motion.div>
|
||||
</m.div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isExpanded && expandTarget && (
|
||||
<motion.div
|
||||
<m.div
|
||||
key={expandedTab}
|
||||
className='absolute inset-0 overflow-hidden border border-[#E5E5E5] bg-white'
|
||||
initial={{ opacity: 0, scale: 0.15 }}
|
||||
@@ -291,7 +293,7 @@ function WorkspacePreview({ activeTab, isActive }: { activeTab: number; isActive
|
||||
{expandedTab === 1 && <MockFullTable revealedRows={revealedRows} />}
|
||||
{expandedTab === 2 && <MockFullFiles />}
|
||||
{expandedTab === 3 && <MockFullLogs revealedRows={revealedRows} />}
|
||||
</motion.div>
|
||||
</m.div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
@@ -302,20 +304,20 @@ function WorkspacePreview({ activeTab, isActive }: { activeTab: number; isActive
|
||||
function MockUserInput({ text }: { text: string }) {
|
||||
return (
|
||||
<div className='flex w-[380px] items-center gap-1.5 rounded-[16px] border border-[#E0E0E0] bg-white px-2.5 py-2 shadow-[0_2px_8px_rgba(0,0,0,0.06)]'>
|
||||
<div className='flex h-[24px] w-[24px] flex-shrink-0 items-center justify-center rounded-full border border-[#E8E8E8]'>
|
||||
<div className='flex size-[24px] flex-shrink-0 items-center justify-center rounded-full border border-[#E8E8E8]'>
|
||||
<svg width='12' height='12' viewBox='0 0 12 12' fill='none'>
|
||||
<path d='M6 2.5v7M2.5 6h7' stroke='#999' strokeWidth='1.5' strokeLinecap='round' />
|
||||
</svg>
|
||||
</div>
|
||||
<div className='min-h-[20px] flex-1 font-[430] text-[#1C1C1C] text-[13px] leading-[20px]'>
|
||||
{text}
|
||||
<motion.span
|
||||
<m.span
|
||||
className='ml-[1px] inline-block h-[14px] w-[1.5px] bg-[#1C1C1C] align-text-bottom'
|
||||
animate={{ opacity: [1, 0] }}
|
||||
transition={{ duration: 0.5, repeat: Number.POSITIVE_INFINITY, repeatType: 'reverse' }}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex h-[24px] w-[24px] flex-shrink-0 items-center justify-center rounded-full bg-[#383838]'>
|
||||
<div className='flex size-[24px] flex-shrink-0 items-center justify-center rounded-full bg-[#383838]'>
|
||||
<svg width='12' height='12' viewBox='0 0 12 12' fill='none'>
|
||||
<path
|
||||
d='M6 9V3M3.5 5L6 2.5L8.5 5'
|
||||
@@ -381,7 +383,7 @@ function MiniCardIcon({ variant, color }: { variant: CardVariant; color?: string
|
||||
const c = color ?? '#7C3AED'
|
||||
return (
|
||||
<div
|
||||
className='h-[7px] w-[7px] flex-shrink-0 rounded-[1.5px] border'
|
||||
className='size-[7px] flex-shrink-0 rounded-[1.5px] border'
|
||||
style={{
|
||||
backgroundColor: c,
|
||||
borderColor: workflowBorderColor(c),
|
||||
@@ -465,10 +467,10 @@ function TableCardBody() {
|
||||
function WorkflowCardBody({ color }: { color: string }) {
|
||||
return (
|
||||
<div className='relative h-full w-full'>
|
||||
<div className='absolute top-2.5 left-[10px] h-[14px] w-[14px] rounded-[3px] border border-[#E0E0E0] bg-[#F8F8F8]' />
|
||||
<div className='absolute top-2.5 left-[10px] size-[14px] rounded-[3px] border border-[#E0E0E0] bg-[#F8F8F8]' />
|
||||
<div className='absolute top-[16px] left-[24px] h-[1px] w-[16px] bg-[#D8D8D8]' />
|
||||
<div
|
||||
className='absolute top-2.5 left-[40px] h-[14px] w-[14px] rounded-[3px] border-[2px]'
|
||||
className='absolute top-2.5 left-[40px] size-[14px] rounded-[3px] border-[2px]'
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
borderColor: workflowBorderColor(color),
|
||||
@@ -476,10 +478,10 @@ function WorkflowCardBody({ color }: { color: string }) {
|
||||
}}
|
||||
/>
|
||||
<div className='absolute top-6 left-[46px] h-[12px] w-[1px] bg-[#D8D8D8]' />
|
||||
<div className='absolute top-[36px] left-[40px] h-[14px] w-[14px] rounded-[3px] border border-[#E0E0E0] bg-[#F8F8F8]' />
|
||||
<div className='absolute top-[36px] left-[40px] size-[14px] rounded-[3px] border border-[#E0E0E0] bg-[#F8F8F8]' />
|
||||
<div className='absolute top-[42px] left-[54px] h-[1px] w-[14px] bg-[#D8D8D8]' />
|
||||
<div
|
||||
className='absolute top-[36px] left-[68px] h-[14px] w-[14px] rounded-[3px] border-[2px]'
|
||||
className='absolute top-[36px] left-[68px] size-[14px] rounded-[3px] border-[2px]'
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
borderColor: workflowBorderColor(color),
|
||||
@@ -506,7 +508,7 @@ function LogsCardBody() {
|
||||
{LOG_ENTRIES.map((entry, i) => (
|
||||
<div key={i} className='flex items-center gap-1 py-[1px]'>
|
||||
<div
|
||||
className='h-[3px] w-[3px] flex-shrink-0 rounded-full'
|
||||
className='size-[3px] flex-shrink-0 rounded-full'
|
||||
style={{ backgroundColor: entry.color }}
|
||||
/>
|
||||
<div
|
||||
@@ -592,7 +594,7 @@ function MockFullFiles() {
|
||||
<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'>
|
||||
<File className='h-[14px] w-[14px] text-[#999]' />
|
||||
<File className='size-[14px] text-[#999]' />
|
||||
<span className='text-[#999] text-[13px]'>Files</span>
|
||||
<span className='text-[#D4D4D4] text-[13px]'>/</span>
|
||||
<span className='font-medium text-[#1C1C1C] text-[13px]'>meeting-notes.md</span>
|
||||
@@ -600,7 +602,7 @@ function MockFullFiles() {
|
||||
</div>
|
||||
|
||||
<div className='flex flex-1 overflow-hidden'>
|
||||
<motion.div
|
||||
<m.div
|
||||
className='h-full w-1/2 shrink-0 overflow-hidden'
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
@@ -613,11 +615,11 @@ function MockFullFiles() {
|
||||
autoCorrect='off'
|
||||
className='h-full w-full resize-none overflow-auto whitespace-pre-wrap bg-transparent p-6 font-[300] font-mono text-[#1C1C1C] text-[12px] leading-[1.7] outline-none'
|
||||
/>
|
||||
</motion.div>
|
||||
</m.div>
|
||||
|
||||
<div className='h-full w-px shrink-0 bg-[#E5E5E5]' />
|
||||
|
||||
<motion.div
|
||||
<m.div
|
||||
className='h-full min-w-0 flex-1 overflow-hidden'
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
@@ -628,7 +630,7 @@ function MockFullFiles() {
|
||||
{source}
|
||||
</Streamdown>
|
||||
</div>
|
||||
</motion.div>
|
||||
</m.div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -744,7 +746,7 @@ function MockFullLogs({ revealedRows }: { revealedRows: number }) {
|
||||
<div className='flex min-w-0 flex-1 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'>
|
||||
<Library className='h-[14px] w-[14px] text-[#999]' />
|
||||
<Library className='size-[14px] text-[#999]' />
|
||||
<span className='font-medium text-[#1C1C1C] text-[13px]'>Logs</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -770,7 +772,7 @@ function MockFullLogs({ revealedRows }: { revealedRows: number }) {
|
||||
const statusStyle = LOG_STATUS_STYLES[row[2]] ?? LOG_STATUS_STYLES.success
|
||||
const isSelected = showSidebar && i === selectedRow
|
||||
return (
|
||||
<motion.tr
|
||||
<m.tr
|
||||
key={i}
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
@@ -784,7 +786,7 @@ function MockFullLogs({ revealedRows }: { revealedRows: number }) {
|
||||
<td className='px-6 py-2.5 align-middle'>
|
||||
<span className='flex items-center gap-3 font-medium text-[#1C1C1C] text-[14px]'>
|
||||
<div
|
||||
className='h-[10px] w-[10px] shrink-0 rounded-[3px] border-[1.5px]'
|
||||
className='size-[10px] shrink-0 rounded-[3px] border-[1.5px]'
|
||||
style={{
|
||||
backgroundColor: MOCK_LOG_COLORS[i],
|
||||
borderColor: `${MOCK_LOG_COLORS[i]}60`,
|
||||
@@ -816,7 +818,7 @@ function MockFullLogs({ revealedRows }: { revealedRows: number }) {
|
||||
<td className='px-6 py-2.5 align-middle'>
|
||||
<span className='font-medium text-[#999] text-[14px]'>{row[5]}</span>
|
||||
</td>
|
||||
</motion.tr>
|
||||
</m.tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
@@ -824,7 +826,7 @@ function MockFullLogs({ revealedRows }: { revealedRows: number }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
<m.div
|
||||
className='absolute top-0 right-0 bottom-0 z-10 border-[#E5E5E5] border-l bg-white'
|
||||
initial={{ x: '100%' }}
|
||||
animate={{ x: showSidebar ? 0 : '100%' }}
|
||||
@@ -836,7 +838,7 @@ function MockFullLogs({ revealedRows }: { revealedRows: number }) {
|
||||
onPrev={() => setSelectedRow((r) => Math.max(0, r - 1))}
|
||||
onNext={() => setSelectedRow((r) => Math.min(MOCK_LOG_DATA.length - 1, r + 1))}
|
||||
/>
|
||||
</motion.div>
|
||||
</m.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -871,7 +873,7 @@ function MockLogDetailsSidebar({ selectedRow, onPrev, onNext }: MockLogDetailsSi
|
||||
isPrevDisabled ? 'cursor-not-allowed opacity-40' : 'hover:bg-[#F5F5F5]'
|
||||
)}
|
||||
>
|
||||
<ChevronDown className='h-[14px] w-[14px] rotate-180' />
|
||||
<ChevronDown className='size-[14px] rotate-180' />
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
@@ -882,7 +884,7 @@ function MockLogDetailsSidebar({ selectedRow, onPrev, onNext }: MockLogDetailsSi
|
||||
isNextDisabled ? 'cursor-not-allowed opacity-40' : 'hover:bg-[#F5F5F5]'
|
||||
)}
|
||||
>
|
||||
<ChevronDown className='h-[14px] w-[14px]' />
|
||||
<ChevronDown className='size-[14px]' />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -900,7 +902,7 @@ function MockLogDetailsSidebar({ selectedRow, onPrev, onNext }: MockLogDetailsSi
|
||||
<span className='font-medium text-[#999] text-[12px]'>Workflow</span>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div
|
||||
className='h-[10px] w-[10px] shrink-0 rounded-[3px] border-[1.5px]'
|
||||
className='size-[10px] shrink-0 rounded-[3px] border-[1.5px]'
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
borderColor: workflowBorderColor(color),
|
||||
@@ -972,7 +974,7 @@ function MockFullTable({ revealedRows }: { revealedRows: number }) {
|
||||
<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'>
|
||||
<Table className='h-[14px] w-[14px] text-[#999]' />
|
||||
<Table className='size-[14px] text-[#999]' />
|
||||
<span className='text-[#999] text-[13px]'>Tables</span>
|
||||
<span className='text-[#D4D4D4] text-[13px]'>/</span>
|
||||
<span className='font-medium text-[#1C1C1C] text-[13px]'>Leads</span>
|
||||
@@ -1002,7 +1004,7 @@ function MockFullTable({ revealedRows }: { revealedRows: number }) {
|
||||
<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 className='size-[13px] rounded-[2px] border border-[#D4D4D4]' />
|
||||
</div>
|
||||
</th>
|
||||
{MOCK_TABLE_COLUMNS.map((col) => (
|
||||
@@ -1023,7 +1025,7 @@ function MockFullTable({ revealedRows }: { revealedRows: number }) {
|
||||
{MOCK_TABLE_DATA.slice(0, revealedRows).map((row, i) => {
|
||||
const isSelected = selectedRow === i
|
||||
return (
|
||||
<motion.tr
|
||||
<m.tr
|
||||
key={i}
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
@@ -1059,7 +1061,7 @@ function MockFullTable({ revealedRows }: { revealedRows: number }) {
|
||||
<span className='block truncate text-[#1C1C1C] text-[13px]'>{cell}</span>
|
||||
</td>
|
||||
))}
|
||||
</motion.tr>
|
||||
</m.tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
@@ -1072,7 +1074,7 @@ function MockFullTable({ revealedRows }: { revealedRows: number }) {
|
||||
function ColumnTypeIcon() {
|
||||
return (
|
||||
<svg
|
||||
className='h-3 w-3 shrink-0 text-[#999]'
|
||||
className='size-3 shrink-0 text-[#999]'
|
||||
viewBox='0 0 16 16'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
@@ -1163,7 +1165,7 @@ function DefaultPreview() {
|
||||
const explodeDelay = EXPLODE_BASE_DELAY + index * EXPLODE_STAGGER
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
<m.div
|
||||
key={key}
|
||||
className='absolute flex items-center justify-center rounded-xl border border-[#E5E5E5] bg-white p-2.5 shadow-[0_2px_4px_0_rgba(0,0,0,0.06)]'
|
||||
initial={{ top: '50%', left: '50%', opacity: 0, scale: 0, x: '-50%', y: '-50%' }}
|
||||
@@ -1177,19 +1179,19 @@ function DefaultPreview() {
|
||||
style={{ color }}
|
||||
aria-label={label}
|
||||
>
|
||||
<Icon className='h-6 w-6' />
|
||||
</motion.div>
|
||||
<Icon className='size-6' />
|
||||
</m.div>
|
||||
)
|
||||
})}
|
||||
|
||||
<motion.div
|
||||
<m.div
|
||||
className='absolute top-1/2 left-[48%]'
|
||||
initial={{ opacity: 0, x: '-50%', y: '-50%' }}
|
||||
animate={inView ? { opacity: 1, x: '-50%', y: '-50%' } : undefined}
|
||||
transition={{ duration: 0.4, ease: 'easeOut', delay: 0 }}
|
||||
>
|
||||
<div className='flex h-[36px] items-center gap-2 rounded-[8px] border border-[#E5E5E5] bg-white px-2.5 shadow-[0_2px_6px_0_rgba(0,0,0,0.08)]'>
|
||||
<div className='flex h-[22px] w-[22px] flex-shrink-0 items-center justify-center rounded-[5px] bg-[#1e1e1e]'>
|
||||
<div className='flex size-[22px] flex-shrink-0 items-center justify-center rounded-[5px] bg-[#1e1e1e]'>
|
||||
<svg width='11' height='11' viewBox='0 0 10 10' fill='none'>
|
||||
<path
|
||||
d='M1 9C1 4.58 4.58 1 9 1'
|
||||
@@ -1204,7 +1206,7 @@ function DefaultPreview() {
|
||||
</span>
|
||||
<ChevronDown className='h-[8px] w-[10px] flex-shrink-0 text-[#999]' />
|
||||
</div>
|
||||
</motion.div>
|
||||
</m.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
'use client'
|
||||
|
||||
import { useRef, useState } from 'react'
|
||||
import { type MotionValue, motion, useScroll, useTransform } from 'framer-motion'
|
||||
import {
|
||||
domAnimation,
|
||||
LazyMotion,
|
||||
type MotionValue,
|
||||
m,
|
||||
useScroll,
|
||||
useTransform,
|
||||
} from 'framer-motion'
|
||||
import dynamic from 'next/dynamic'
|
||||
import Image from 'next/image'
|
||||
import { Badge } from '@/components/emcn'
|
||||
@@ -132,7 +139,7 @@ function ScrollLetter({ scrollYProgress, charIndex, children }: ScrollLetterProp
|
||||
const threshold = (charIndex / HEADING_LETTERS.length) * LETTER_REVEAL_SPAN
|
||||
const opacity = useTransform(scrollYProgress, [threshold, threshold + LETTER_FADE_IN], [0.4, 1])
|
||||
|
||||
return <motion.span style={{ opacity }}>{children}</motion.span>
|
||||
return <m.span style={{ opacity }}>{children}</m.span>
|
||||
}
|
||||
|
||||
export default function Features() {
|
||||
@@ -145,154 +152,159 @@ export default function Features() {
|
||||
})
|
||||
|
||||
return (
|
||||
<section
|
||||
id='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'>
|
||||
<Image
|
||||
src='/landing/features-transition.svg'
|
||||
alt=''
|
||||
width={1440}
|
||||
height={366}
|
||||
className='h-auto w-full'
|
||||
/>
|
||||
</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'>
|
||||
<Badge
|
||||
variant='blue'
|
||||
size='md'
|
||||
dot
|
||||
className='font-season uppercase tracking-[0.02em] transition-colors duration-200'
|
||||
style={{
|
||||
color: FEATURE_TABS[activeTab].badgeColor ?? FEATURE_TABS[activeTab].color,
|
||||
backgroundColor: hexToRgba(
|
||||
FEATURE_TABS[activeTab].badgeColor ?? FEATURE_TABS[activeTab].color,
|
||||
0.1
|
||||
),
|
||||
}}
|
||||
>
|
||||
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]'
|
||||
>
|
||||
{HEADING_LETTERS.map((char, i) => (
|
||||
<ScrollLetter key={i} scrollYProgress={scrollYProgress} charIndex={i}>
|
||||
{char}
|
||||
</ScrollLetter>
|
||||
))}
|
||||
<span className='text-[color-mix(in_srgb,var(--landing-text-dark)_40%,transparent)]'>
|
||||
Build agents, connect your data, and monitor every run — all in one workspace.
|
||||
</span>
|
||||
</h2>
|
||||
<LazyMotion features={domAnimation}>
|
||||
<section
|
||||
id='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'>
|
||||
<Image
|
||||
src='/landing/features-transition.svg'
|
||||
alt=''
|
||||
width={1440}
|
||||
height={366}
|
||||
className='h-auto w-full'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='relative mt-10 pb-[60px] lg:mt-[73px] lg:pb-[100px]'>
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='absolute top-0 bottom-0 left-16 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'
|
||||
/>
|
||||
|
||||
<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 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' : ''}`}
|
||||
style={{ backgroundColor: index === activeTab ? '#FDFDFD' : '#F6F6F6' }}
|
||||
>
|
||||
<span className='truncate'>{tab.label}</span>
|
||||
{index === activeTab && (
|
||||
<div className='absolute right-0 bottom-0 left-0 flex h-[6px]'>
|
||||
{tab.segments.map(([opacity, width], i) => (
|
||||
<div
|
||||
key={i}
|
||||
className='h-full shrink-0'
|
||||
style={{
|
||||
width: `${width}%`,
|
||||
backgroundColor: tab.color,
|
||||
opacity,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
<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'>
|
||||
<Badge
|
||||
variant='blue'
|
||||
size='md'
|
||||
dot
|
||||
className='font-season uppercase tracking-[0.02em] transition-colors duration-200'
|
||||
style={{
|
||||
color: FEATURE_TABS[activeTab].badgeColor ?? FEATURE_TABS[activeTab].color,
|
||||
backgroundColor: hexToRgba(
|
||||
FEATURE_TABS[activeTab].badgeColor ?? FEATURE_TABS[activeTab].color,
|
||||
0.1
|
||||
),
|
||||
}}
|
||||
>
|
||||
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]'
|
||||
>
|
||||
{HEADING_LETTERS.map((char, i) => (
|
||||
<ScrollLetter key={i} scrollYProgress={scrollYProgress} charIndex={i}>
|
||||
{char}
|
||||
</ScrollLetter>
|
||||
))}
|
||||
<span className='text-[color-mix(in_srgb,var(--landing-text-dark)_40%,transparent)]'>
|
||||
Build agents, connect your data, and monitor every run, all in one workspace.
|
||||
</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className='relative mt-10 pb-[60px] lg:mt-[73px] lg:pb-[100px]'>
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='absolute top-0 bottom-0 left-16 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'
|
||||
/>
|
||||
|
||||
<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 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' : ''}`}
|
||||
style={{ backgroundColor: index === activeTab ? '#FDFDFD' : '#F6F6F6' }}
|
||||
>
|
||||
<span className='truncate'>{tab.label}</span>
|
||||
{index === activeTab && (
|
||||
<div className='absolute right-0 bottom-0 left-0 flex h-[6px]'>
|
||||
{tab.segments.map(([opacity, width], i) => (
|
||||
<div
|
||||
key={i}
|
||||
className='h-full shrink-0'
|
||||
style={{
|
||||
width: `${width}%`,
|
||||
backgroundColor: tab.color,
|
||||
opacity,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</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>
|
||||
|
||||
<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='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]'>
|
||||
{FEATURE_TABS[activeTab].title}
|
||||
</h3>
|
||||
<p className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-dark)_50%,transparent)] text-md leading-[150%] tracking-[0.02em] lg:text-lg'>
|
||||
{FEATURE_TABS[activeTab].description}
|
||||
</p>
|
||||
</div>
|
||||
<AuthModal defaultView='signup' source='features'>
|
||||
<button
|
||||
type='button'
|
||||
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)]'
|
||||
onClick={() =>
|
||||
trackLandingCta({
|
||||
label: FEATURE_TABS[activeTab].cta,
|
||||
section: 'features',
|
||||
destination: 'auth_modal',
|
||||
})
|
||||
}
|
||||
>
|
||||
{FEATURE_TABS[activeTab].cta}
|
||||
</button>
|
||||
</AuthModal>
|
||||
</div>
|
||||
|
||||
<FeaturesPreview activeTab={activeTab} />
|
||||
</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'
|
||||
className='mt-[60px] hidden h-px bg-[var(--divider)] lg:block'
|
||||
/>
|
||||
</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='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]'>
|
||||
{FEATURE_TABS[activeTab].title}
|
||||
</h3>
|
||||
<p className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-dark)_50%,transparent)] text-md leading-[150%] tracking-[0.02em] lg:text-lg'>
|
||||
{FEATURE_TABS[activeTab].description}
|
||||
</p>
|
||||
</div>
|
||||
<AuthModal defaultView='signup' source='features'>
|
||||
<button
|
||||
type='button'
|
||||
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)]'
|
||||
onClick={() =>
|
||||
trackLandingCta({
|
||||
label: FEATURE_TABS[activeTab].cta,
|
||||
section: 'features',
|
||||
destination: 'auth_modal',
|
||||
})
|
||||
}
|
||||
>
|
||||
{FEATURE_TABS[activeTab].cta}
|
||||
</button>
|
||||
</AuthModal>
|
||||
</div>
|
||||
|
||||
<FeaturesPreview activeTab={activeTab} />
|
||||
</div>
|
||||
|
||||
<div aria-hidden='true' className='mt-[60px] hidden h-px bg-[var(--divider)] lg:block' />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</LazyMotion>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useCallback, useRef, useState } from 'react'
|
||||
import { ArrowUp } from 'lucide-react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { handleKeyboardActivation } from '@/lib/core/utils/keyboard'
|
||||
import { captureClientEvent } from '@/lib/posthog/client'
|
||||
import { useLandingSubmit } from '@/app/(landing)/components/landing-preview/components/landing-preview-panel/landing-preview-panel'
|
||||
import { trackLandingCta } from '@/app/(landing)/landing-analytics'
|
||||
@@ -64,8 +65,14 @@ export function FooterCTA() {
|
||||
|
||||
<div className='mt-8 w-full max-w-[42rem]'>
|
||||
<div
|
||||
role='group'
|
||||
aria-label='Landing prompt input'
|
||||
className='cursor-text rounded-[20px] border border-[var(--landing-bg-elevated)] bg-[var(--landing-bg-surface)] px-2.5 py-2'
|
||||
onClick={() => textareaRef.current?.focus()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.target !== event.currentTarget) return
|
||||
handleKeyboardActivation(event, () => textareaRef.current?.focus())
|
||||
}}
|
||||
>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
@@ -76,7 +83,7 @@ export function FooterCTA() {
|
||||
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] caret-white outline-none placeholder:font-[380] placeholder:text-[var(--landing-text-muted)] focus-visible:ring-0'
|
||||
className='m-0 box-border min-h-[48px] w-full resize-none border-0 bg-transparent p-1 font-body text-[var(--landing-text)] text-base leading-[24px] tracking-[-0.015em] caret-white outline-none placeholder:font-[380] placeholder:text-[var(--landing-text-muted)] focus-visible:ring-0'
|
||||
style={{ maxHeight: `${MAX_HEIGHT}px` }}
|
||||
/>
|
||||
<div className='flex items-center justify-end'>
|
||||
|
||||
@@ -42,8 +42,8 @@ export default function Hero() {
|
||||
>
|
||||
<p className='sr-only'>
|
||||
Sim is the open-source AI workspace where teams build, deploy, and manage AI agents. Connect
|
||||
1,000+ integrations and every major LLM — including OpenAI, Anthropic Claude, Google Gemini,
|
||||
Mistral, and xAI Grok — to create agents that automate real work. Build agents visually with
|
||||
1,000+ integrations and every major LLM, including OpenAI, Anthropic Claude, Google Gemini,
|
||||
Mistral, and xAI Grok, to create agents that automate real work. Build agents visually with
|
||||
the workflow builder, conversationally through Mothership, or programmatically with the API.
|
||||
Trusted by over 100,000 builders at startups and Fortune 500 companies. SOC2 compliant.
|
||||
</p>
|
||||
|
||||
@@ -1,5 +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'
|
||||
@@ -13,7 +12,6 @@ import Testimonials from '@/app/(landing)/components/testimonials/testimonials'
|
||||
|
||||
export {
|
||||
Collaboration,
|
||||
Enterprise,
|
||||
ExternalRedirect,
|
||||
Features,
|
||||
Footer,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { AnimatePresence, domAnimation, LazyMotion, m } from 'framer-motion'
|
||||
import { ChevronDown } from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
|
||||
export interface LandingFAQItem {
|
||||
interface LandingFAQItem {
|
||||
question: string
|
||||
answer: string
|
||||
}
|
||||
@@ -19,67 +19,69 @@ export function LandingFAQ({ faqs }: LandingFAQProps) {
|
||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null)
|
||||
|
||||
return (
|
||||
<div>
|
||||
{faqs.map(({ question, answer }, index) => {
|
||||
const isOpen = openIndex === index
|
||||
const isHovered = hoveredIndex === index
|
||||
const showDivider = index > 0 && hoveredIndex !== index && hoveredIndex !== index - 1
|
||||
<LazyMotion features={domAnimation}>
|
||||
<div>
|
||||
{faqs.map(({ question, answer }, index) => {
|
||||
const isOpen = openIndex === index
|
||||
const isHovered = hoveredIndex === index
|
||||
const showDivider = index > 0 && hoveredIndex !== index && hoveredIndex !== index - 1
|
||||
|
||||
return (
|
||||
<div key={question}>
|
||||
<div
|
||||
className={cn(
|
||||
'h-px w-full bg-[var(--landing-bg-elevated)]',
|
||||
index === 0 || !showDivider ? 'invisible' : 'visible'
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setOpenIndex(isOpen ? null : index)}
|
||||
onMouseEnter={() => setHoveredIndex(index)}
|
||||
onMouseLeave={() => setHoveredIndex(null)}
|
||||
className='-mx-6 flex w-[calc(100%+3rem)] items-center justify-between gap-4 px-6 py-4 text-left transition-colors hover:bg-[var(--landing-bg-elevated)]'
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
<span
|
||||
return (
|
||||
<div key={question}>
|
||||
<div
|
||||
className={cn(
|
||||
'text-[15px] leading-snug tracking-[-0.02em] transition-colors',
|
||||
isOpen
|
||||
? 'text-[var(--landing-text)]'
|
||||
: 'text-[var(--landing-text-body)] hover:text-[var(--landing-text)]'
|
||||
'h-px w-full bg-[var(--landing-bg-elevated)]',
|
||||
index === 0 || !showDivider ? 'invisible' : 'visible'
|
||||
)}
|
||||
>
|
||||
{question}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'h-3 w-3 shrink-0 text-[var(--landing-text-subtle)] transition-transform duration-200',
|
||||
isOpen ? 'rotate-180' : 'rotate-0'
|
||||
)}
|
||||
aria-hidden='true'
|
||||
/>
|
||||
</button>
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.25, ease: [0.4, 0, 0.2, 1] }}
|
||||
className='overflow-hidden'
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setOpenIndex(isOpen ? null : index)}
|
||||
onMouseEnter={() => setHoveredIndex(index)}
|
||||
onMouseLeave={() => setHoveredIndex(null)}
|
||||
className='-mx-6 flex w-[calc(100%+3rem)] items-center justify-between gap-4 px-6 py-4 text-left transition-colors hover:bg-[var(--landing-bg-elevated)]'
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'text-[15px] leading-snug tracking-[-0.02em] transition-colors',
|
||||
isOpen
|
||||
? 'text-[var(--landing-text)]'
|
||||
: 'text-[var(--landing-text-body)] hover:text-[var(--landing-text)]'
|
||||
)}
|
||||
>
|
||||
<div className='pt-2 pb-4'>
|
||||
<p className='text-[14px] text-[var(--landing-text-body)] leading-[1.75]'>
|
||||
{answer}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{question}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'h-3 w-3 shrink-0 text-[var(--landing-text-subtle)] transition-transform duration-200',
|
||||
isOpen ? 'rotate-180' : 'rotate-0'
|
||||
)}
|
||||
aria-hidden='true'
|
||||
/>
|
||||
</button>
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
{isOpen && (
|
||||
<m.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.25, ease: [0.4, 0, 0.2, 1] }}
|
||||
className='overflow-hidden'
|
||||
>
|
||||
<div className='pt-2 pb-4'>
|
||||
<p className='text-[14px] text-[var(--landing-text-body)] leading-[1.75]'>
|
||||
{answer}
|
||||
</p>
|
||||
</div>
|
||||
</m.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</LazyMotion>
|
||||
)
|
||||
}
|
||||
|
||||
+12
-12
@@ -81,9 +81,9 @@ const ROWS: PreviewRow[] = [
|
||||
{
|
||||
id: '1',
|
||||
cells: {
|
||||
name: { icon: <PdfIcon className='h-[14px] w-[14px]' />, label: 'Q1 Performance Report.pdf' },
|
||||
name: { icon: <PdfIcon className='size-[14px]' />, label: 'Q1 Performance Report.pdf' },
|
||||
size: { label: '2.4 MB' },
|
||||
type: { icon: <PdfIcon className='h-[14px] w-[14px]' />, label: 'PDF' },
|
||||
type: { icon: <PdfIcon className='size-[14px]' />, label: 'PDF' },
|
||||
created: { label: '3 hours ago' },
|
||||
owner: ownerCell('T', 'Theo L.'),
|
||||
},
|
||||
@@ -91,9 +91,9 @@ const ROWS: PreviewRow[] = [
|
||||
{
|
||||
id: '2',
|
||||
cells: {
|
||||
name: { icon: <ZipIcon className='h-[14px] w-[14px]' />, label: 'product-screenshots.zip' },
|
||||
name: { icon: <ZipIcon className='size-[14px]' />, label: 'product-screenshots.zip' },
|
||||
size: { label: '18.7 MB' },
|
||||
type: { icon: <ZipIcon className='h-[14px] w-[14px]' />, label: 'ZIP' },
|
||||
type: { icon: <ZipIcon className='size-[14px]' />, label: 'ZIP' },
|
||||
created: { label: '1 day ago' },
|
||||
owner: ownerCell('A', 'Alex M.'),
|
||||
},
|
||||
@@ -101,9 +101,9 @@ const ROWS: PreviewRow[] = [
|
||||
{
|
||||
id: '3',
|
||||
cells: {
|
||||
name: { icon: <JsonlIcon className='h-[14px] w-[14px]' />, label: 'training-dataset.jsonl' },
|
||||
name: { icon: <JsonlIcon className='size-[14px]' />, label: 'training-dataset.jsonl' },
|
||||
size: { label: '892 KB' },
|
||||
type: { icon: <JsonlIcon className='h-[14px] w-[14px]' />, label: 'JSONL' },
|
||||
type: { icon: <JsonlIcon className='size-[14px]' />, label: 'JSONL' },
|
||||
created: { label: '3 days ago' },
|
||||
owner: ownerCell('J', 'Jordan P.'),
|
||||
},
|
||||
@@ -111,9 +111,9 @@ const ROWS: PreviewRow[] = [
|
||||
{
|
||||
id: '4',
|
||||
cells: {
|
||||
name: { icon: <PdfIcon className='h-[14px] w-[14px]' />, label: 'brand-guidelines.pdf' },
|
||||
name: { icon: <PdfIcon className='size-[14px]' />, label: 'brand-guidelines.pdf' },
|
||||
size: { label: '5.1 MB' },
|
||||
type: { icon: <PdfIcon className='h-[14px] w-[14px]' />, label: 'PDF' },
|
||||
type: { icon: <PdfIcon className='size-[14px]' />, label: 'PDF' },
|
||||
created: { label: '1 week ago' },
|
||||
owner: ownerCell('S', 'Sarah K.'),
|
||||
},
|
||||
@@ -121,9 +121,9 @@ const ROWS: PreviewRow[] = [
|
||||
{
|
||||
id: '5',
|
||||
cells: {
|
||||
name: { icon: <AudioIcon className='h-[14px] w-[14px]' />, label: 'customer-interviews.mp3' },
|
||||
name: { icon: <AudioIcon className='size-[14px]' />, label: 'customer-interviews.mp3' },
|
||||
size: { label: '45.2 MB' },
|
||||
type: { icon: <AudioIcon className='h-[14px] w-[14px]' />, label: 'Audio' },
|
||||
type: { icon: <AudioIcon className='size-[14px]' />, label: 'Audio' },
|
||||
created: { label: 'March 20th, 2026' },
|
||||
owner: ownerCell('V', 'Vik M.'),
|
||||
},
|
||||
@@ -131,9 +131,9 @@ const ROWS: PreviewRow[] = [
|
||||
{
|
||||
id: '6',
|
||||
cells: {
|
||||
name: { icon: <DocxIcon className='h-[14px] w-[14px]' />, label: 'onboarding-playbook.docx' },
|
||||
name: { icon: <DocxIcon className='size-[14px]' />, label: 'onboarding-playbook.docx' },
|
||||
size: { label: '1.1 MB' },
|
||||
type: { icon: <DocxIcon className='h-[14px] w-[14px]' />, label: 'DOCX' },
|
||||
type: { icon: <DocxIcon className='size-[14px]' />, label: 'DOCX' },
|
||||
created: { label: 'March 14th, 2026' },
|
||||
owner: ownerCell('S', 'Sarah K.'),
|
||||
},
|
||||
|
||||
+169
-161
@@ -1,10 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { AnimatePresence, domAnimation, LazyMotion, m } 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 { handleKeyboardActivation } from '@/lib/core/utils/keyboard'
|
||||
import { captureClientEvent } from '@/lib/posthog/client'
|
||||
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'
|
||||
@@ -177,176 +178,183 @@ export const LandingPreviewHome = memo(function LandingPreviewHome({
|
||||
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'
|
||||
<LazyMotion features={domAnimation}>
|
||||
<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 */}
|
||||
<m.div
|
||||
className='flex flex-col items-end gap-[6px] pt-3'
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, ease: EASE_OUT }}
|
||||
>
|
||||
<div className='max-w-[70%] overflow-hidden rounded-[16px] bg-[#363636] px-3.5 py-2'>
|
||||
<p
|
||||
className='font-body text-[14px] leading-[1.5]'
|
||||
style={{ color: C.TEXT_PRIMARY }}
|
||||
>
|
||||
<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'
|
||||
{AUTO_PROMPT}
|
||||
</p>
|
||||
</div>
|
||||
</m.div>
|
||||
|
||||
{/* Assistant — no bubble, full-width prose */}
|
||||
<AnimatePresence>
|
||||
{showToolCall && (
|
||||
<m.div
|
||||
className='space-y-2.5'
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, ease: EASE_OUT }}
|
||||
>
|
||||
{/* Agent group header — icon + label + chevron */}
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setToolsExpanded((p) => !p)}
|
||||
className='flex cursor-pointer items-center gap-2'
|
||||
>
|
||||
<div className='flex size-[16px] flex-shrink-0 items-center justify-center'>
|
||||
<Blimp className='size-[16px]' 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={{
|
||||
color: C.TEXT_ICON,
|
||||
transform: toolsExpanded ? 'rotate(0deg)' : 'rotate(-90deg)',
|
||||
gridTemplateRows: toolsExpanded ? '1fr' : '0fr',
|
||||
}}
|
||||
/>
|
||||
</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 className='overflow-hidden'>
|
||||
<div className='flex flex-col gap-1.5 pt-0.5'>
|
||||
<ToolCallRow
|
||||
icon={
|
||||
<Table className='size-[15px]' style={{ color: C.TEXT_TERTIARY }} />
|
||||
}
|
||||
title='Read Customer Leads'
|
||||
/>
|
||||
</div>
|
||||
</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>
|
||||
{/* Response prose — full width, no card */}
|
||||
{isResponding && (
|
||||
<m.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.2, ease: EASE_OUT }}
|
||||
>
|
||||
<ChatMarkdown
|
||||
content={MOCK_RESPONSE}
|
||||
visibleLength={responseTypedLength}
|
||||
isTyping={chatPhase === 'responding'}
|
||||
/>
|
||||
</m.div>
|
||||
)}
|
||||
</m.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
{/* Resource panel — slides in from right */}
|
||||
<AnimatePresence>
|
||||
{showResourcePanel && (
|
||||
<m.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 />
|
||||
</m.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</LazyMotion>
|
||||
)
|
||||
}
|
||||
|
||||
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()}
|
||||
<LazyMotion features={domAnimation}>
|
||||
<div className='flex min-w-0 flex-1 flex-col items-center justify-center px-6 pb-[2vh]'>
|
||||
<m.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 }}
|
||||
>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={inputValue}
|
||||
onChange={(e) => {
|
||||
if (!autoType) setInputValue(e.target.value)
|
||||
What should we get done?
|
||||
</m.p>
|
||||
|
||||
<m.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
|
||||
role='group'
|
||||
aria-label='Preview message input'
|
||||
className='cursor-text rounded-[20px] border px-2.5 py-2'
|
||||
style={{ borderColor: C.BORDER, backgroundColor: C.SURFACE }}
|
||||
onClick={() => textareaRef.current?.focus()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.target !== event.currentTarget) return
|
||||
handleKeyboardActivation(event, () => textareaRef.current?.focus())
|
||||
}}
|
||||
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}
|
||||
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 ? '#808080' : '#e0e0e0',
|
||||
cursor: isEmpty ? 'not-allowed' : 'pointer',
|
||||
>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={inputValue}
|
||||
onChange={(e) => {
|
||||
if (!autoType) setInputValue(e.target.value)
|
||||
}}
|
||||
>
|
||||
<ArrowUp size={16} strokeWidth={2.25} color='#1b1b1b' />
|
||||
</button>
|
||||
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 p-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}
|
||||
aria-label='Submit message'
|
||||
className='flex size-[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>
|
||||
</motion.div>
|
||||
</div>
|
||||
</m.div>
|
||||
</div>
|
||||
</LazyMotion>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -357,7 +365,7 @@ export const LandingPreviewHome = memo(function LandingPreviewHome({
|
||||
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>
|
||||
<div className='flex size-[16px] flex-shrink-0 items-center justify-center'>{icon}</div>
|
||||
<span className='font-base text-[13px]' style={{ color: C.TEXT_SECONDARY }}>
|
||||
{title}
|
||||
</span>
|
||||
@@ -385,7 +393,7 @@ function ChatMarkdown({
|
||||
<div className='font-body text-[14px] leading-[1.6]' style={{ color: C.TEXT_PRIMARY }}>
|
||||
<span dangerouslySetInnerHTML={{ __html: rendered }} />
|
||||
{isTyping && (
|
||||
<motion.span
|
||||
<m.span
|
||||
className='inline-block h-[14px] w-[1.5px] translate-y-[2px] bg-[#e6e6e6]'
|
||||
animate={{ opacity: [1, 0] }}
|
||||
transition={{
|
||||
@@ -406,7 +414,7 @@ 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 }} />
|
||||
<Table className='size-[14px]' style={{ color: C.TEXT_ICON }} />
|
||||
<span className='font-medium text-sm' style={{ color: C.TEXT_PRIMARY }}>
|
||||
Customer Leads
|
||||
</span>
|
||||
@@ -428,7 +436,7 @@ function MiniTablePanel() {
|
||||
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 }} />
|
||||
<Icon className='size-3 shrink-0' style={{ color: C.TEXT_ICON }} />
|
||||
<span className='font-medium text-[11px]' style={{ color: C.TEXT_PRIMARY }}>
|
||||
{col.label}
|
||||
</span>
|
||||
@@ -444,7 +452,7 @@ function MiniTablePanel() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{MINI_TABLE_ROWS.map((row, i) => (
|
||||
<motion.tr
|
||||
<m.tr
|
||||
key={i}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
@@ -472,7 +480,7 @@ function MiniTablePanel() {
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</motion.tr>
|
||||
</m.tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
+2
-2
@@ -16,14 +16,14 @@ import type {
|
||||
} from '@/app/(landing)/components/landing-preview/components/landing-preview-resource/landing-preview-resource'
|
||||
import { LandingPreviewResource } from '@/app/(landing)/components/landing-preview/components/landing-preview-resource/landing-preview-resource'
|
||||
|
||||
const DB_ICON = <Database className='h-[14px] w-[14px]' />
|
||||
const DB_ICON = <Database className='size-[14px]' />
|
||||
|
||||
function connectorIcons(icons: React.ComponentType<{ className?: string }>[]) {
|
||||
return {
|
||||
content: (
|
||||
<div className='flex items-center gap-1'>
|
||||
{icons.map((Icon, i) => (
|
||||
<Icon key={i} className='h-3.5 w-3.5 flex-shrink-0' />
|
||||
<Icon key={i} className='size-3.5 flex-shrink-0' />
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
|
||||
+7
-7
@@ -174,12 +174,12 @@ export function LandingPreviewLogs() {
|
||||
<div className='border-[var(--border)] border-b px-6 py-2.5'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<Library className='h-[14px] w-[14px] text-[var(--text-icon)]' />
|
||||
<Library className='size-[14px] text-[var(--text-icon)]' />
|
||||
<h1 className='font-medium text-[var(--text-body)] text-sm'>Logs</h1>
|
||||
</div>
|
||||
<div className='flex items-center gap-1'>
|
||||
<div className='flex cursor-default items-center rounded-md px-2 py-1 text-[var(--text-secondary)] text-caption'>
|
||||
<Download className='mr-1.5 h-[14px] w-[14px] text-[var(--text-icon)]' />
|
||||
<Download className='mr-1.5 size-[14px] text-[var(--text-icon)]' />
|
||||
Export
|
||||
</div>
|
||||
<button
|
||||
@@ -213,7 +213,7 @@ export function LandingPreviewLogs() {
|
||||
<div className='border-[var(--border)] border-b px-6 py-2.5'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex flex-1 items-center gap-2.5'>
|
||||
<Search className='h-[14px] w-[14px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
<Search className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
<input
|
||||
type='text'
|
||||
value={search}
|
||||
@@ -224,7 +224,7 @@ export function LandingPreviewLogs() {
|
||||
</div>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<div className='flex cursor-default items-center rounded-md px-2 py-1 text-[var(--text-secondary)] text-caption'>
|
||||
<ListFilter className='mr-1.5 h-[14px] w-[14px] text-[var(--text-icon)]' />
|
||||
<ListFilter className='mr-1.5 size-[14px] text-[var(--text-icon)]' />
|
||||
Filter
|
||||
</div>
|
||||
<button
|
||||
@@ -232,7 +232,7 @@ export function LandingPreviewLogs() {
|
||||
onClick={() => handleSort(sortKey ?? 'workflowName')}
|
||||
className='flex cursor-default items-center rounded-md px-2 py-1 text-[var(--text-secondary)] text-caption transition-colors hover-hover:bg-[var(--surface-3)]'
|
||||
>
|
||||
<ArrowUpDown className='mr-1.5 h-[14px] w-[14px] text-[var(--text-icon)]' />
|
||||
<ArrowUpDown className='mr-1.5 size-[14px] text-[var(--text-icon)]' />
|
||||
Sort
|
||||
</button>
|
||||
</div>
|
||||
@@ -266,7 +266,7 @@ export function LandingPreviewLogs() {
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
{sortKey === key && <ArrowUpDown className='h-[10px] w-[10px] opacity-60' />}
|
||||
{sortKey === key && <ArrowUpDown className='size-[10px] opacity-60' />}
|
||||
</button>
|
||||
</th>
|
||||
))}
|
||||
@@ -281,7 +281,7 @@ export function LandingPreviewLogs() {
|
||||
<td className='px-6 align-middle'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div
|
||||
className='h-[10px] w-[10px] flex-shrink-0 rounded-[3px] border-[1.5px]'
|
||||
className='size-[10px] flex-shrink-0 rounded-[3px] border-[1.5px]'
|
||||
style={{
|
||||
backgroundColor: log.workflowColor,
|
||||
borderColor: workflowBorderColor(log.workflowColor),
|
||||
|
||||
+159
-157
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { AnimatePresence, domMax, LazyMotion, m } from 'framer-motion'
|
||||
import { ArrowUp } from 'lucide-react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { useRouter } from 'next/navigation'
|
||||
@@ -174,166 +174,168 @@ export const LandingPreviewPanel = memo(function LandingPreviewPanel({
|
||||
)
|
||||
|
||||
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>
|
||||
<AuthModal defaultView='signup' source='landing_preview'>
|
||||
<button
|
||||
type='button'
|
||||
className='flex gap-1.5'
|
||||
onMouseMove={(e) => setCursorPos({ x: e.clientX, y: e.clientY })}
|
||||
onMouseLeave={() => setCursorPos(null)}
|
||||
onClick={() =>
|
||||
trackLandingCta({
|
||||
label: 'Deploy',
|
||||
section: 'landing_preview',
|
||||
destination: 'auth_modal',
|
||||
})
|
||||
}
|
||||
>
|
||||
<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>
|
||||
<LazyMotion features={domMax}>
|
||||
<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 size-[30px] items-center justify-center rounded-[5px] border border-[#3d3d3d] bg-[#363636]'>
|
||||
<MoreHorizontal className='size-[14px] text-[#e6e6e6]' />
|
||||
</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 className='flex size-[30px] items-center justify-center rounded-[5px] border border-[#3d3d3d] bg-[#363636]'>
|
||||
<BubbleChatPreview className='size-[14px] text-[#e6e6e6]' />
|
||||
</div>
|
||||
</button>
|
||||
</AuthModal>
|
||||
{cursorPos &&
|
||||
createPortal(
|
||||
<div
|
||||
className='pointer-events-none fixed z-[9999]'
|
||||
style={{ left: cursorPos.x + 14, top: cursorPos.y + 14 }}
|
||||
</div>
|
||||
<AuthModal defaultView='signup' source='landing_preview'>
|
||||
<button
|
||||
type='button'
|
||||
className='flex gap-1.5'
|
||||
onMouseMove={(e) => setCursorPos({ x: e.clientX, y: e.clientY })}
|
||||
onMouseLeave={() => setCursorPos(null)}
|
||||
onClick={() =>
|
||||
trackLandingCta({
|
||||
label: 'Deploy',
|
||||
section: 'landing_preview',
|
||||
destination: 'auth_modal',
|
||||
})
|
||||
}
|
||||
>
|
||||
<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 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 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 className='flex h-[30px] items-center gap-2 rounded-[5px] bg-[#33C482] px-2.5 transition-colors hover:bg-[#2DAC72]'>
|
||||
<Play className='size-[11.5px] text-[#1b1b1b]' />
|
||||
<span className='font-medium text-[#1b1b1b] text-[12px]'>Run</span>
|
||||
</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' }}
|
||||
</button>
|
||||
</AuthModal>
|
||||
{cursorPos &&
|
||||
createPortal(
|
||||
<div
|
||||
className='pointer-events-none fixed z-[9999]'
|
||||
style={{ left: cursorPos.x + 14, top: cursorPos.y + 14 }}
|
||||
>
|
||||
{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 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 size-[7px] text-[#1C1C1C]' />
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</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>
|
||||
{/* 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 && (
|
||||
<m.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' && (
|
||||
<m.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] p-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 size-[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>
|
||||
</motion.div>
|
||||
)}
|
||||
</m.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>
|
||||
{activeTab === 'editor' && (
|
||||
<m.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} />
|
||||
</m.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</LazyMotion>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -384,10 +386,10 @@ function EditorTabContent({ editorPrompt, typedLength }: EditorTabContentProps)
|
||||
<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'
|
||||
className='flex size-[18px] flex-shrink-0 items-center justify-center rounded-sm'
|
||||
style={{ background: bgColor }}
|
||||
>
|
||||
<BlockIcon className='h-[12px] w-[12px] text-white' />
|
||||
<BlockIcon className='size-[12px] text-white' />
|
||||
</div>
|
||||
)}
|
||||
<span className='min-w-0 flex-1 truncate font-medium text-[#e6e6e6] text-sm'>
|
||||
@@ -403,11 +405,11 @@ function EditorTabContent({ editorPrompt, typedLength }: EditorTabContentProps)
|
||||
<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'>
|
||||
<div className='rounded-[4px] border border-[#3d3d3d] bg-[#292929] p-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
|
||||
<m.span
|
||||
className='inline-block h-[14px] w-[1.5px] translate-y-[2px] bg-[#e6e6e6]'
|
||||
animate={{ opacity: [1, 0] }}
|
||||
transition={{
|
||||
@@ -428,7 +430,7 @@ function EditorTabContent({ editorPrompt, typedLength }: EditorTabContentProps)
|
||||
<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]' />}
|
||||
{ModelIcon && <ModelIcon className='size-[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>
|
||||
@@ -451,10 +453,10 @@ function EditorTabContent({ editorPrompt, typedLength }: EditorTabContentProps)
|
||||
>
|
||||
{ToolIcon && (
|
||||
<div
|
||||
className='flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center rounded-[4px]'
|
||||
className='flex size-[16px] flex-shrink-0 items-center justify-center rounded-[4px]'
|
||||
style={{ background: tool.bgColor }}
|
||||
>
|
||||
<ToolIcon className='h-[10px] w-[10px] text-white' />
|
||||
<ToolIcon className='size-[10px] text-white' />
|
||||
</div>
|
||||
)}
|
||||
<span className='font-normal text-[#e6e6e6] text-[12px]'>{tool.name}</span>
|
||||
@@ -474,7 +476,7 @@ function EditorTabContent({ editorPrompt, typedLength }: EditorTabContentProps)
|
||||
<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]'
|
||||
className='-translate-y-1/2 absolute top-1/2 size-[14px] rounded-full border-[#e6e6e6] border-[2px] bg-[#292929]'
|
||||
style={{ left: 'calc(70% - 7px)' }}
|
||||
/>
|
||||
</div>
|
||||
@@ -485,7 +487,7 @@ function EditorTabContent({ editorPrompt, typedLength }: EditorTabContentProps)
|
||||
<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'>
|
||||
<div className='rounded-[4px] border border-[#3d3d3d] bg-[#292929] p-2'>
|
||||
<span className='font-mono text-[#787878] text-[12px]'>plain text</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+7
-9
@@ -35,7 +35,7 @@ interface LandingPreviewResourceProps {
|
||||
export function ownerCell(initial: string, name: string): PreviewCell {
|
||||
return {
|
||||
icon: (
|
||||
<span className='flex h-[14px] w-[14px] flex-shrink-0 items-center justify-center rounded-full border border-[var(--border)] bg-[var(--surface-3)] font-medium text-[8px] text-[var(--text-secondary)]'>
|
||||
<span className='flex size-[14px] flex-shrink-0 items-center justify-center rounded-full border border-[var(--border)] bg-[var(--surface-3)] font-medium text-[8px] text-[var(--text-secondary)]'>
|
||||
{initial}
|
||||
</span>
|
||||
),
|
||||
@@ -88,11 +88,11 @@ export function LandingPreviewResource({
|
||||
<div className='border-[var(--border)] border-b px-6 py-2.5'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<Icon className='h-[14px] w-[14px] text-[var(--text-icon)]' />
|
||||
<Icon className='size-[14px] text-[var(--text-icon)]' />
|
||||
<h1 className='font-medium text-[var(--text-body)] text-sm'>{title}</h1>
|
||||
</div>
|
||||
<div className='flex cursor-default items-center rounded-md px-2 py-1 text-[var(--text-secondary)] text-caption'>
|
||||
<Plus className='mr-1.5 h-[14px] w-[14px] text-[var(--text-icon)]' />
|
||||
<Plus className='mr-1.5 size-[14px] text-[var(--text-icon)]' />
|
||||
{createLabel}
|
||||
</div>
|
||||
</div>
|
||||
@@ -102,7 +102,7 @@ export function LandingPreviewResource({
|
||||
<div className='border-[var(--border)] border-b px-6 py-2.5'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex flex-1 items-center gap-2.5'>
|
||||
<Search className='h-[14px] w-[14px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
<Search className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
<input
|
||||
type='text'
|
||||
value={search}
|
||||
@@ -113,7 +113,7 @@ export function LandingPreviewResource({
|
||||
</div>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<div className='flex cursor-default items-center rounded-md px-2 py-1 text-[var(--text-secondary)] text-caption'>
|
||||
<ListFilter className='mr-1.5 h-[14px] w-[14px] text-[var(--text-icon)]' />
|
||||
<ListFilter className='mr-1.5 size-[14px] text-[var(--text-icon)]' />
|
||||
Filter
|
||||
</div>
|
||||
<button
|
||||
@@ -121,7 +121,7 @@ export function LandingPreviewResource({
|
||||
onClick={() => handleSortClick(sortColId ?? columns[0]?.id)}
|
||||
className='flex cursor-default items-center rounded-md px-2 py-1 text-[var(--text-secondary)] text-caption transition-colors hover-hover:bg-[var(--surface-3)]'
|
||||
>
|
||||
<ArrowUpDown className='mr-1.5 h-[14px] w-[14px] text-[var(--text-icon)]' />
|
||||
<ArrowUpDown className='mr-1.5 size-[14px] text-[var(--text-icon)]' />
|
||||
Sort
|
||||
</button>
|
||||
</div>
|
||||
@@ -157,9 +157,7 @@ export function LandingPreviewResource({
|
||||
)}
|
||||
>
|
||||
{col.header}
|
||||
{sortColId === col.id && (
|
||||
<ArrowUpDown className='h-[10px] w-[10px] opacity-60' />
|
||||
)}
|
||||
{sortColId === col.id && <ArrowUpDown className='size-[10px] opacity-60' />}
|
||||
</button>
|
||||
</th>
|
||||
))}
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ import type {
|
||||
} from '@/app/(landing)/components/landing-preview/components/landing-preview-resource/landing-preview-resource'
|
||||
import { LandingPreviewResource } from '@/app/(landing)/components/landing-preview/components/landing-preview-resource/landing-preview-resource'
|
||||
|
||||
const CAL_ICON = <Calendar className='h-[14px] w-[14px]' />
|
||||
const CAL_ICON = <Calendar className='size-[14px]' />
|
||||
|
||||
const COLUMNS: PreviewColumn[] = [
|
||||
{ id: 'task', header: 'Task' },
|
||||
|
||||
+5
-5
@@ -75,7 +75,7 @@ function NavItem({
|
||||
if (!onClick) {
|
||||
return (
|
||||
<div className='pointer-events-none mx-0.5 flex h-[28px] items-center gap-2 rounded-[8px] px-2'>
|
||||
<Icon className='h-[14px] w-[14px] flex-shrink-0' style={{ color: C.TEXT_ICON }} />
|
||||
<Icon className='size-[14px] flex-shrink-0' style={{ color: C.TEXT_ICON }} />
|
||||
<span className='truncate text-[13px]' style={{ color: C.TEXT_BODY, fontWeight: 450 }}>
|
||||
{label}
|
||||
</span>
|
||||
@@ -92,7 +92,7 @@ function NavItem({
|
||||
isActive && 'bg-[var(--c-active)]'
|
||||
)}
|
||||
>
|
||||
<Icon className='h-[14px] w-[14px] flex-shrink-0' style={{ color: C.TEXT_ICON }} />
|
||||
<Icon className='size-[14px] flex-shrink-0' style={{ color: C.TEXT_ICON }} />
|
||||
<span className='truncate text-[13px]' style={{ color: C.TEXT_BODY, fontWeight: 450 }}>
|
||||
{label}
|
||||
</span>
|
||||
@@ -128,7 +128,7 @@ export function LandingPreviewSidebar({
|
||||
className='pointer-events-none flex h-[32px] w-full items-center gap-2 rounded-[8px] border pr-2 pl-[5px]'
|
||||
style={{ borderColor: C.BORDER, backgroundColor: C.SURFACE_2 }}
|
||||
>
|
||||
<div className='flex h-[20px] w-[20px] flex-shrink-0 items-center justify-center rounded-[4px] bg-white'>
|
||||
<div className='flex size-[20px] flex-shrink-0 items-center justify-center rounded-[4px] bg-white'>
|
||||
<svg width='10' height='10' viewBox='0 0 10 10' fill='none'>
|
||||
<path
|
||||
d='M1 9C1 4.58 4.58 1 9 1'
|
||||
@@ -158,7 +158,7 @@ export function LandingPreviewSidebar({
|
||||
isHomeActive && 'bg-[var(--c-active)]'
|
||||
)}
|
||||
>
|
||||
<Home className='h-[14px] w-[14px] flex-shrink-0' style={{ color: C.TEXT_ICON }} />
|
||||
<Home className='size-[14px] flex-shrink-0' style={{ color: C.TEXT_ICON }} />
|
||||
<span className='truncate text-[13px]' style={{ color: C.TEXT_BODY, fontWeight: 450 }}>
|
||||
Home
|
||||
</span>
|
||||
@@ -209,7 +209,7 @@ export function LandingPreviewSidebar({
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className='h-[14px] w-[14px] flex-shrink-0 rounded-[4px] border-[2.5px]'
|
||||
className='size-[14px] flex-shrink-0 rounded-[4px] border-[2.5px]'
|
||||
style={{
|
||||
backgroundColor: workflow.color,
|
||||
borderColor: workflowBorderColor(workflow.color),
|
||||
|
||||
+35
-37
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { AnimatePresence, domAnimation, LazyMotion, m } from 'framer-motion'
|
||||
import { Checkbox } from '@/components/emcn'
|
||||
import {
|
||||
ChevronDown,
|
||||
@@ -50,9 +50,9 @@ const TABLE_METAS: Record<string, string> = {
|
||||
'5': 'Invoice Records',
|
||||
}
|
||||
|
||||
const TABLE_ICON = <Table className='h-[14px] w-[14px]' />
|
||||
const COLUMNS_ICON = <Columns3 className='h-[14px] w-[14px]' />
|
||||
const ROWS_ICON = <Rows3 className='h-[14px] w-[14px]' />
|
||||
const TABLE_ICON = <Table className='size-[14px]' />
|
||||
const COLUMNS_ICON = <Columns3 className='size-[14px]' />
|
||||
const ROWS_ICON = <Rows3 className='size-[14px]' />
|
||||
|
||||
const LIST_ROWS: PreviewRow[] = [
|
||||
{
|
||||
@@ -440,7 +440,7 @@ function SpreadsheetView({ tableId, tableName, onBack }: SpreadsheetViewProps) {
|
||||
onClick={onBack}
|
||||
className='inline-flex items-center px-2 py-1 font-medium text-[var(--text-secondary)] text-sm transition-colors hover-hover:text-[var(--text-body)]'
|
||||
>
|
||||
<Table className='mr-3 h-[14px] w-[14px] text-[var(--text-icon)]' />
|
||||
<Table className='mr-3 size-[14px] text-[var(--text-icon)]' />
|
||||
Tables
|
||||
</button>
|
||||
<span className='select-none text-[var(--text-icon)] text-sm'>/</span>
|
||||
@@ -468,7 +468,7 @@ function SpreadsheetView({ tableId, tableName, onBack }: SpreadsheetViewProps) {
|
||||
return (
|
||||
<th key={col.id} className={CELL_HEADER}>
|
||||
<div className='flex h-full w-full min-w-0 items-center px-2 py-[7px]'>
|
||||
<Icon className='h-3 w-3 shrink-0 text-[var(--text-icon)]' />
|
||||
<Icon className='size-3 shrink-0 text-[var(--text-icon)]' />
|
||||
<span className='ml-1.5 min-w-0 overflow-clip text-ellipsis whitespace-nowrap font-medium text-[var(--text-primary)] text-small'>
|
||||
{col.label}
|
||||
</span>
|
||||
@@ -549,36 +549,34 @@ export function LandingPreviewTables({ autoOpenTableId }: LandingPreviewTablesPr
|
||||
}, [autoOpenTableId])
|
||||
|
||||
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>
|
||||
<LazyMotion features={domAnimation}>
|
||||
<AnimatePresence mode='wait'>
|
||||
{openTableId !== null ? (
|
||||
<m.div
|
||||
key={`spreadsheet-${openTableId}`}
|
||||
className='flex h-full flex-1 flex-col'
|
||||
{...tableViewTransition}
|
||||
>
|
||||
<SpreadsheetView
|
||||
tableId={openTableId}
|
||||
tableName={TABLE_METAS[openTableId] ?? 'Table'}
|
||||
onBack={() => setOpenTableId(null)}
|
||||
/>
|
||||
</m.div>
|
||||
) : (
|
||||
<m.div key='table-list' className='flex h-full flex-1 flex-col' {...tableViewTransition}>
|
||||
<LandingPreviewResource
|
||||
icon={Table}
|
||||
title='Tables'
|
||||
createLabel='New table'
|
||||
searchPlaceholder='Search tables...'
|
||||
columns={LIST_COLUMNS}
|
||||
rows={LIST_ROWS}
|
||||
onRowClick={(id) => setOpenTableId(id)}
|
||||
/>
|
||||
</m.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</LazyMotion>
|
||||
)
|
||||
}
|
||||
|
||||
+14
-12
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { domAnimation, LazyMotion, m } from 'framer-motion'
|
||||
import ReactFlow, {
|
||||
applyEdgeChanges,
|
||||
applyNodeChanges,
|
||||
@@ -62,7 +62,7 @@ function PreviewEdge({
|
||||
|
||||
if (data?.animate) {
|
||||
return (
|
||||
<motion.path
|
||||
<m.path
|
||||
id={id}
|
||||
className='react-flow__edge-path'
|
||||
d={edgePath}
|
||||
@@ -172,15 +172,17 @@ export function LandingPreviewWorkflow({
|
||||
highlightedBlockId,
|
||||
}: LandingPreviewWorkflowProps) {
|
||||
return (
|
||||
<div className='h-full w-full'>
|
||||
<ReactFlowProvider key={workflow.id}>
|
||||
<PreviewFlow
|
||||
workflow={workflow}
|
||||
animate={animate}
|
||||
fitViewOptions={fitViewOptions}
|
||||
highlightedBlockId={highlightedBlockId}
|
||||
/>
|
||||
</ReactFlowProvider>
|
||||
</div>
|
||||
<LazyMotion features={domAnimation}>
|
||||
<div className='h-full w-full'>
|
||||
<ReactFlowProvider key={workflow.id}>
|
||||
<PreviewFlow
|
||||
workflow={workflow}
|
||||
animate={animate}
|
||||
fitViewOptions={fitViewOptions}
|
||||
highlightedBlockId={highlightedBlockId}
|
||||
/>
|
||||
</ReactFlowProvider>
|
||||
</div>
|
||||
</LazyMotion>
|
||||
)
|
||||
}
|
||||
|
||||
+124
-116
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { memo } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { domAnimation, LazyMotion, m } from 'framer-motion'
|
||||
import { Database } from 'lucide-react'
|
||||
import { Handle, type NodeProps, Position } from 'reactflow'
|
||||
import { Blimp } from '@/components/emcn'
|
||||
@@ -145,133 +145,141 @@ export const PreviewBlockNode = memo(function PreviewBlockNode({
|
||||
|
||||
if (blockType === 'note' && markdown) {
|
||||
return (
|
||||
<motion.div
|
||||
className='relative'
|
||||
initial={animate ? { opacity: 0 } : false}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.45, delay, ease: EASE_OUT }}
|
||||
>
|
||||
<div className='w-[280px] select-none rounded-[8px] border border-[#3d3d3d] bg-[#232323]'>
|
||||
<div className='border-[#3d3d3d] border-b p-2'>
|
||||
<span className='font-medium text-[#e6e6e6] text-[16px]'>Note</span>
|
||||
<LazyMotion features={domAnimation}>
|
||||
<m.div
|
||||
className='relative'
|
||||
initial={animate ? { opacity: 0 } : false}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.45, delay, ease: EASE_OUT }}
|
||||
>
|
||||
<div className='w-[280px] select-none rounded-[8px] border border-[#3d3d3d] bg-[#232323]'>
|
||||
<div className='border-[#3d3d3d] border-b p-2'>
|
||||
<span className='font-medium text-[#e6e6e6] text-[16px]'>Note</span>
|
||||
</div>
|
||||
<div className='p-2.5'>
|
||||
<NoteMarkdown content={markdown} />
|
||||
</div>
|
||||
</div>
|
||||
<div className='p-2.5'>
|
||||
<NoteMarkdown content={markdown} />
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</m.div>
|
||||
</LazyMotion>
|
||||
)
|
||||
}
|
||||
|
||||
const hasContent = rows.length > 0 || (tools && tools.length > 0)
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className='relative'
|
||||
initial={animate ? { opacity: 0 } : false}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.45, delay, ease: EASE_OUT }}
|
||||
>
|
||||
<div className='relative z-[20] w-[250px] select-none rounded-[8px] border border-[#3d3d3d] bg-[#232323]'>
|
||||
{/* Target handle (left side) */}
|
||||
{!hideTargetHandle && (
|
||||
<Handle
|
||||
type='target'
|
||||
position={Position.Left}
|
||||
id='target'
|
||||
className={HANDLE_LEFT}
|
||||
style={{ top: '20px', transform: 'translateY(-50%)' }}
|
||||
isConnectableStart={false}
|
||||
isConnectableEnd={false}
|
||||
/>
|
||||
)}
|
||||
<LazyMotion features={domAnimation}>
|
||||
<m.div
|
||||
className='relative'
|
||||
initial={animate ? { opacity: 0 } : false}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.45, delay, ease: EASE_OUT }}
|
||||
>
|
||||
<div className='relative z-[20] w-[250px] select-none rounded-[8px] border border-[#3d3d3d] bg-[#232323]'>
|
||||
{/* Target handle (left side) */}
|
||||
{!hideTargetHandle && (
|
||||
<Handle
|
||||
type='target'
|
||||
position={Position.Left}
|
||||
id='target'
|
||||
className={HANDLE_LEFT}
|
||||
style={{ top: '20px', transform: 'translateY(-50%)' }}
|
||||
isConnectableStart={false}
|
||||
isConnectableEnd={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div
|
||||
className={`flex items-center justify-between p-2 ${hasContent ? 'border-[#3d3d3d] border-b' : ''}`}
|
||||
>
|
||||
<div className='relative z-10 flex min-w-0 flex-1 items-center gap-2.5'>
|
||||
<div
|
||||
className='flex h-[24px] w-[24px] flex-shrink-0 items-center justify-center rounded-[6px]'
|
||||
style={{ background: bgColor }}
|
||||
>
|
||||
{Icon && <Icon className='h-[16px] w-[16px] text-white' />}
|
||||
</div>
|
||||
<span className='truncate font-medium text-[#e6e6e6] text-[16px]'>{name}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sub-block rows + tools */}
|
||||
{hasContent && (
|
||||
<div className='flex flex-col gap-2 p-2'>
|
||||
{rows.map((row) => {
|
||||
const modelEntry = row.title === 'Model' ? getModelIconEntry(row.value) : null
|
||||
const ModelIcon = modelEntry?.icon
|
||||
return (
|
||||
<div key={row.title} className='flex items-center gap-2'>
|
||||
<span className='flex-shrink-0 font-normal text-[#b3b3b3] text-[14px] capitalize'>
|
||||
{row.title}
|
||||
</span>
|
||||
{row.value && (
|
||||
<span className='flex min-w-0 flex-1 items-center justify-end gap-2 font-normal text-[#e6e6e6] text-[14px]'>
|
||||
{ModelIcon && (
|
||||
<ModelIcon
|
||||
className={`inline-block flex-shrink-0 text-[#e6e6e6] ${modelEntry.size ?? 'h-[14px] w-[14px]'}`}
|
||||
/>
|
||||
)}
|
||||
<span className='truncate'>{row.value}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Tool chips — inline with label */}
|
||||
{tools && tools.length > 0 && (
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='flex-shrink-0 font-normal text-[#b3b3b3] text-[14px]'>Tools</span>
|
||||
<div className='flex flex-1 flex-wrap items-center justify-end gap-[5px]'>
|
||||
{tools.map((tool) => {
|
||||
const ToolIcon = BLOCK_ICONS[tool.type]
|
||||
return (
|
||||
<div
|
||||
key={tool.type}
|
||||
className='flex items-center gap-[5px] rounded-[5px] border border-[#3d3d3d] bg-[#2a2a2a] px-[6px] py-[3px]'
|
||||
>
|
||||
<div
|
||||
className='flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center rounded-[4px]'
|
||||
style={{ background: tool.bgColor }}
|
||||
>
|
||||
{ToolIcon && <ToolIcon className='h-[10px] w-[10px] text-white' />}
|
||||
</div>
|
||||
<span className='font-normal text-[#e6e6e6] text-[12px]'>{tool.name}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{/* Header */}
|
||||
<div
|
||||
className={`flex items-center justify-between p-2 ${hasContent ? 'border-[#3d3d3d] border-b' : ''}`}
|
||||
>
|
||||
<div className='relative z-10 flex min-w-0 flex-1 items-center gap-2.5'>
|
||||
<div
|
||||
className='flex size-[24px] flex-shrink-0 items-center justify-center rounded-[6px]'
|
||||
style={{ background: bgColor }}
|
||||
>
|
||||
{Icon && <Icon className='size-[16px] text-white' />}
|
||||
</div>
|
||||
)}
|
||||
<span className='truncate font-medium text-[#e6e6e6] text-[16px]'>{name}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Source handle (right side) */}
|
||||
{!hideSourceHandle && (
|
||||
<Handle
|
||||
type='source'
|
||||
position={Position.Right}
|
||||
id='source'
|
||||
className={HANDLE_RIGHT}
|
||||
style={{ top: '20px', transform: 'translateY(-50%)' }}
|
||||
isConnectableStart={false}
|
||||
isConnectableEnd={false}
|
||||
/>
|
||||
)}
|
||||
{/* Sub-block rows + tools */}
|
||||
{hasContent && (
|
||||
<div className='flex flex-col gap-2 p-2'>
|
||||
{rows.map((row) => {
|
||||
const modelEntry = row.title === 'Model' ? getModelIconEntry(row.value) : null
|
||||
const ModelIcon = modelEntry?.icon
|
||||
return (
|
||||
<div key={row.title} className='flex items-center gap-2'>
|
||||
<span className='flex-shrink-0 font-normal text-[#b3b3b3] text-[14px] capitalize'>
|
||||
{row.title}
|
||||
</span>
|
||||
{row.value && (
|
||||
<span className='flex min-w-0 flex-1 items-center justify-end gap-2 font-normal text-[#e6e6e6] text-[14px]'>
|
||||
{ModelIcon && (
|
||||
<ModelIcon
|
||||
className={`inline-block flex-shrink-0 text-[#e6e6e6] ${modelEntry.size ?? 'h-[14px] w-[14px]'}`}
|
||||
/>
|
||||
)}
|
||||
<span className='truncate'>{row.value}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{isHighlighted && (
|
||||
<div className='pointer-events-none absolute inset-0 z-40 rounded-lg ring-[#33b4ff] ring-[1.75px]' />
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
{/* Tool chips — inline with label */}
|
||||
{tools && tools.length > 0 && (
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='flex-shrink-0 font-normal text-[#b3b3b3] text-[14px]'>
|
||||
Tools
|
||||
</span>
|
||||
<div className='flex flex-1 flex-wrap items-center justify-end gap-[5px]'>
|
||||
{tools.map((tool) => {
|
||||
const ToolIcon = BLOCK_ICONS[tool.type]
|
||||
return (
|
||||
<div
|
||||
key={tool.type}
|
||||
className='flex items-center gap-[5px] rounded-[5px] border border-[#3d3d3d] bg-[#2a2a2a] px-[6px] py-[3px]'
|
||||
>
|
||||
<div
|
||||
className='flex size-[16px] flex-shrink-0 items-center justify-center rounded-[4px]'
|
||||
style={{ background: tool.bgColor }}
|
||||
>
|
||||
{ToolIcon && <ToolIcon className='size-[10px] text-white' />}
|
||||
</div>
|
||||
<span className='font-normal text-[#e6e6e6] text-[12px]'>
|
||||
{tool.name}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Source handle (right side) */}
|
||||
{!hideSourceHandle && (
|
||||
<Handle
|
||||
type='source'
|
||||
position={Position.Right}
|
||||
id='source'
|
||||
className={HANDLE_RIGHT}
|
||||
style={{ top: '20px', transform: 'translateY(-50%)' }}
|
||||
isConnectableStart={false}
|
||||
isConnectableEnd={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isHighlighted && (
|
||||
<div className='pointer-events-none absolute inset-0 z-40 rounded-lg ring-[#33b4ff] ring-[1.75px]' />
|
||||
)}
|
||||
</div>
|
||||
</m.div>
|
||||
</LazyMotion>
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { AnimatePresence, motion, type Variants } from 'framer-motion'
|
||||
import { AnimatePresence, domAnimation, LazyMotion, m, 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'
|
||||
@@ -208,115 +208,113 @@ export function LandingPreview() {
|
||||
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>
|
||||
)}
|
||||
<LazyMotion features={domAnimation}>
|
||||
<m.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}
|
||||
>
|
||||
<m.div className='hidden lg:flex' variants={sidebarVariants}>
|
||||
<LandingPreviewSidebar
|
||||
workflows={PREVIEW_WORKFLOWS}
|
||||
activeWorkflowId={activeWorkflowId}
|
||||
activeView={activeView}
|
||||
onSelectWorkflow={handleSelectWorkflow}
|
||||
onSelectHome={handleSelectHome}
|
||||
onSelectNav={handleSelectNav}
|
||||
/>
|
||||
</m.div>
|
||||
<div className='flex min-w-0 flex-1 flex-col py-2 pr-2 pl-2 lg:pl-0'>
|
||||
<div className='flex flex-1 overflow-hidden rounded-[5px] border border-[#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' && (
|
||||
<m.div
|
||||
key={`wf-${activeWorkflow.id}-${animationKey}`}
|
||||
className='h-full w-full'
|
||||
{...viewTransition}
|
||||
>
|
||||
<LandingPreviewWorkflow
|
||||
workflow={activeWorkflow}
|
||||
animate
|
||||
highlightedBlockId={highlightedBlockId}
|
||||
/>
|
||||
</m.div>
|
||||
)}
|
||||
{activeView === 'home' && (
|
||||
<m.div
|
||||
key={`home-${animationKey}`}
|
||||
className='flex h-full w-full flex-col'
|
||||
{...viewTransition}
|
||||
>
|
||||
<LandingPreviewHome autoType={autoTypeHome} />
|
||||
</m.div>
|
||||
)}
|
||||
{activeView === 'tables' && (
|
||||
<m.div
|
||||
key={`tables-${animationKey}`}
|
||||
className='flex h-full w-full flex-col'
|
||||
{...viewTransition}
|
||||
>
|
||||
<LandingPreviewTables autoOpenTableId={autoTableId} />
|
||||
</m.div>
|
||||
)}
|
||||
{activeView === 'files' && (
|
||||
<m.div key='files' className='flex h-full w-full flex-col' {...viewTransition}>
|
||||
<LandingPreviewFiles />
|
||||
</m.div>
|
||||
)}
|
||||
{activeView === 'knowledge' && (
|
||||
<m.div
|
||||
key='knowledge'
|
||||
className='flex h-full w-full flex-col'
|
||||
{...viewTransition}
|
||||
>
|
||||
<LandingPreviewKnowledge />
|
||||
</m.div>
|
||||
)}
|
||||
{activeView === 'logs' && (
|
||||
<m.div key='logs' className='flex h-full w-full flex-col' initial={false}>
|
||||
<LandingPreviewLogs />
|
||||
</m.div>
|
||||
)}
|
||||
{activeView === 'scheduled-tasks' && (
|
||||
<m.div
|
||||
key='scheduled-tasks'
|
||||
className='flex h-full w-full flex-col'
|
||||
{...viewTransition}
|
||||
>
|
||||
<LandingPreviewScheduledTasks />
|
||||
</m.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
) : (
|
||||
<div className='h-full w-full'>
|
||||
<LandingPreviewWorkflow workflow={activeWorkflow} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<m.div
|
||||
className={isWorkflowView ? 'hidden lg:flex' : 'hidden'}
|
||||
variants={panelVariants}
|
||||
>
|
||||
<LandingPreviewPanel
|
||||
activeWorkflow={activeWorkflow}
|
||||
animationKey={animationKey}
|
||||
onHighlightBlock={handleHighlightBlock}
|
||||
/>
|
||||
</m.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>
|
||||
</m.div>
|
||||
</LazyMotion>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ export function DocsDropdown() {
|
||||
className='flex flex-col gap-1 rounded-[5px] border border-[var(--landing-bg-elevated)] px-2.5 py-2 transition-colors hover:border-[var(--landing-border-strong)] hover:bg-[var(--landing-bg-card)]'
|
||||
>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<Icon className='h-[13px] w-[13px] flex-shrink-0 text-[var(--landing-text-icon)]' />
|
||||
<Icon className='size-[13px] flex-shrink-0 text-[var(--landing-text-icon)]' />
|
||||
<span className='font-[430] font-season text-[var(--landing-text-body)] text-caption'>
|
||||
{card.title}
|
||||
</span>
|
||||
|
||||
@@ -20,7 +20,7 @@ export function GitHubStars() {
|
||||
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)]'
|
||||
aria-label={`GitHub repository — ${stars} stars`}
|
||||
>
|
||||
<GithubOutlineIcon className='h-[14px] w-[14px]' />
|
||||
<GithubOutlineIcon className='size-[14px]' />
|
||||
<span aria-live='polite'>{stars}</span>
|
||||
</a>
|
||||
)
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
import type { ComponentType, SVGProps } from 'react'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
AgentIcon,
|
||||
ApiIcon,
|
||||
McpIcon,
|
||||
PackageSearchIcon,
|
||||
TableIcon,
|
||||
WorkflowIcon,
|
||||
} from '@/components/icons'
|
||||
|
||||
interface ProductLink {
|
||||
label: string
|
||||
description: string
|
||||
href: string
|
||||
external?: boolean
|
||||
icon: ComponentType<SVGProps<SVGSVGElement>>
|
||||
}
|
||||
|
||||
interface SidebarLink {
|
||||
label: string
|
||||
href: string
|
||||
external?: boolean
|
||||
}
|
||||
|
||||
const WORKSPACE: ProductLink[] = [
|
||||
{
|
||||
label: 'Workflows',
|
||||
description: 'Visual AI automation builder',
|
||||
href: 'https://docs.sim.ai/getting-started',
|
||||
external: true,
|
||||
icon: WorkflowIcon,
|
||||
},
|
||||
{
|
||||
label: 'Agent',
|
||||
description: 'Build autonomous AI agents',
|
||||
href: 'https://docs.sim.ai/blocks/agent',
|
||||
external: true,
|
||||
icon: AgentIcon,
|
||||
},
|
||||
{
|
||||
label: 'MCP',
|
||||
description: 'Connect external tools',
|
||||
href: 'https://docs.sim.ai/mcp',
|
||||
external: true,
|
||||
icon: McpIcon,
|
||||
},
|
||||
{
|
||||
label: 'Knowledge Base',
|
||||
description: 'Retrieval-augmented context',
|
||||
href: 'https://docs.sim.ai/knowledgebase',
|
||||
external: true,
|
||||
icon: PackageSearchIcon,
|
||||
},
|
||||
{
|
||||
label: 'Tables',
|
||||
description: 'Structured data storage',
|
||||
href: 'https://docs.sim.ai/tables',
|
||||
external: true,
|
||||
icon: TableIcon,
|
||||
},
|
||||
{
|
||||
label: 'API',
|
||||
description: 'Deploy agents as endpoints',
|
||||
href: 'https://docs.sim.ai/api-reference/getting-started',
|
||||
external: true,
|
||||
icon: ApiIcon,
|
||||
},
|
||||
]
|
||||
|
||||
const EXPLORE: SidebarLink[] = [
|
||||
{ label: 'Models', href: '/models' },
|
||||
{ label: 'Integrations', href: '/integrations' },
|
||||
{ label: 'Changelog', href: '/changelog' },
|
||||
{ label: 'Self-hosting', href: 'https://docs.sim.ai/self-hosting', external: true },
|
||||
]
|
||||
|
||||
function DropdownLink({ link }: { link: ProductLink }) {
|
||||
const Icon = link.icon
|
||||
const Tag = link.external ? 'a' : Link
|
||||
const props = link.external
|
||||
? { href: link.href, target: '_blank' as const, rel: 'noopener noreferrer' }
|
||||
: { href: link.href }
|
||||
|
||||
return (
|
||||
<Tag
|
||||
{...props}
|
||||
className='group/item flex items-start gap-2.5 rounded-[5px] px-2.5 py-2 transition-colors hover:bg-[var(--landing-bg-elevated)]'
|
||||
>
|
||||
<Icon className='mt-0.5 h-[15px] w-[15px] shrink-0 text-[var(--landing-text-icon)]' />
|
||||
<div className='flex flex-col'>
|
||||
<span className='font-[430] font-season text-[13px] text-white leading-tight'>
|
||||
{link.label}
|
||||
</span>
|
||||
<span className='font-season text-[12px] text-[var(--landing-text-subtle)] leading-[150%]'>
|
||||
{link.description}
|
||||
</span>
|
||||
</div>
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
|
||||
export function ProductDropdown() {
|
||||
return (
|
||||
<div className='flex w-[560px] rounded-[5px] border border-[var(--landing-bg-elevated)] bg-[var(--landing-bg)] shadow-overlay'>
|
||||
<div className='flex-1 p-2'>
|
||||
<div className='mb-1 px-2.5 pt-1'>
|
||||
<span className='font-[430] font-season text-[11px] text-[var(--landing-text-subtle)] uppercase tracking-[0.08em]'>
|
||||
Workspace
|
||||
</span>
|
||||
<div className='mt-1.5 h-px bg-[var(--landing-bg-elevated)]' />
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-2'>
|
||||
{WORKSPACE.map((link) => (
|
||||
<DropdownLink key={link.label} link={link} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='w-px self-stretch bg-[var(--landing-bg-elevated)]' />
|
||||
|
||||
<div className='w-[160px] p-2'>
|
||||
<div className='mb-1 px-2.5 pt-1'>
|
||||
<span className='font-[430] font-season text-[11px] text-[var(--landing-text-subtle)] uppercase tracking-[0.08em]'>
|
||||
Explore
|
||||
</span>
|
||||
<div className='mt-1.5 h-px bg-[var(--landing-bg-elevated)]' />
|
||||
</div>
|
||||
|
||||
{EXPLORE.map((link) => {
|
||||
const Tag = link.external ? 'a' : Link
|
||||
const props = link.external
|
||||
? { href: link.href, target: '_blank' as const, rel: 'noopener noreferrer' }
|
||||
: { href: link.href }
|
||||
return (
|
||||
<Tag
|
||||
key={link.label}
|
||||
{...props}
|
||||
className='block rounded-[5px] px-2.5 py-1.5 font-[430] font-season text-[13px] text-white transition-colors hover:bg-[var(--landing-bg-elevated)]'
|
||||
>
|
||||
{link.label}
|
||||
</Tag>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -43,22 +43,28 @@ const LOGO_CELL = 'flex items-center pl-5 lg:pl-16 pr-5'
|
||||
const LINK_CELL = 'flex items-center px-3.5'
|
||||
|
||||
const emptySubscribe = () => () => {}
|
||||
const getLocationSearch = () => window.location.search
|
||||
const getServerLocationSearch = () => ''
|
||||
|
||||
const EMPTY_BLOG_POSTS: NavBlogPost[] = []
|
||||
|
||||
interface NavbarProps {
|
||||
logoOnly?: boolean
|
||||
blogPosts?: NavBlogPost[]
|
||||
}
|
||||
|
||||
export default function Navbar({ logoOnly = false, blogPosts = [] }: NavbarProps) {
|
||||
export default function Navbar({ logoOnly = false, blogPosts = EMPTY_BLOG_POSTS }: NavbarProps) {
|
||||
const brand = getBrandConfig()
|
||||
const sessionCtx = useContext(SessionContext)
|
||||
const session = sessionCtx?.data ?? null
|
||||
const isSessionPending = sessionCtx?.isPending ?? true
|
||||
const isAuthenticated = Boolean(session?.user?.id)
|
||||
const [isBrowsingHome, setIsBrowsingHome] = useState(false)
|
||||
useEffect(() => {
|
||||
setIsBrowsingHome(new URLSearchParams(window.location.search).has('home'))
|
||||
}, [])
|
||||
const locationSearch = useSyncExternalStore(
|
||||
emptySubscribe,
|
||||
getLocationSearch,
|
||||
getServerLocationSearch
|
||||
)
|
||||
const isBrowsingHome = new URLSearchParams(locationSearch).has('home')
|
||||
const useHomeLinks = isAuthenticated || isBrowsingHome
|
||||
const logoHref = useHomeLinks ? '/?home' : '/'
|
||||
const mounted = useSyncExternalStore(
|
||||
@@ -285,7 +291,7 @@ export default function Navbar({ logoOnly = false, blogPosts = [] }: NavbarProps
|
||||
<div className='flex flex-1 items-center justify-end pr-5 lg:hidden'>
|
||||
<button
|
||||
type='button'
|
||||
className='flex h-[32px] w-[32px] items-center justify-center rounded-[5px] transition-colors hover:bg-[var(--landing-bg-elevated)]'
|
||||
className='flex size-[32px] items-center justify-center rounded-[5px] transition-colors hover:bg-[var(--landing-bg-elevated)]'
|
||||
onClick={() => setMobileMenuOpen((prev) => !prev)}
|
||||
aria-label={mobileMenuOpen ? 'Close menu' : 'Open menu'}
|
||||
aria-expanded={mobileMenuOpen}
|
||||
@@ -337,7 +343,7 @@ export default function Navbar({ logoOnly = false, blogPosts = [] }: NavbarProps
|
||||
className='flex items-center gap-2 px-5 py-3.5 text-[var(--landing-text)] transition-colors active:bg-[var(--landing-bg-elevated)]'
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
<GithubOutlineIcon className='h-[14px] w-[14px]' />
|
||||
<GithubOutlineIcon className='size-[14px]' />
|
||||
GitHub
|
||||
</a>
|
||||
</li>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { AnimatePresence, domAnimation, LazyMotion, m } from 'framer-motion'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Badge, ChevronDown } from '@/components/emcn'
|
||||
@@ -315,228 +315,234 @@ export default function Templates() {
|
||||
])
|
||||
|
||||
return (
|
||||
<section id='templates' aria-labelledby='templates-heading' className='pt-[60px] lg:pt-[100px]'>
|
||||
<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,
|
||||
competitor monitoring, social listening, data enrichment, feedback analysis, code review,
|
||||
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>
|
||||
<LazyMotion features={domAnimation}>
|
||||
<section
|
||||
id='templates'
|
||||
aria-labelledby='templates-heading'
|
||||
className='pt-[60px] lg:pt-[100px]'
|
||||
>
|
||||
<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,
|
||||
competitor monitoring, social listening, data enrichment, feedback analysis, code review,
|
||||
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)]'>
|
||||
<div className='relative overflow-hidden'>
|
||||
<div className='px-5 lg:px-16'>
|
||||
<div className='flex flex-col items-start gap-5'>
|
||||
<Badge
|
||||
variant='blue'
|
||||
size='md'
|
||||
dot
|
||||
className='font-season uppercase tracking-[0.02em] transition-colors duration-200'
|
||||
style={{
|
||||
color: activeDepth.color,
|
||||
backgroundColor: hexToRgba(activeDepth.color, 0.1),
|
||||
}}
|
||||
>
|
||||
Templates
|
||||
</Badge>
|
||||
|
||||
<h2
|
||||
id='templates-heading'
|
||||
className='text-balance font-[430] font-season text-[28px] text-white leading-[100%] tracking-[-0.02em] lg:text-[40px]'
|
||||
>
|
||||
Ship your agent in minutes
|
||||
</h2>
|
||||
|
||||
<p className='font-[430] font-season text-[#F6F6F0]/50 text-base leading-[150%] tracking-[0.02em] lg:text-lg'>
|
||||
Pre-built templates for every use case—pick one, swap{' '}
|
||||
<br className='hidden lg:inline' />
|
||||
models and tools to fit your stack, and deploy.
|
||||
</p>
|
||||
</div>
|
||||
</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='flex min-w-0 flex-1 flex-col lg:flex-row'>
|
||||
<div
|
||||
role='tablist'
|
||||
aria-label='Workflow templates'
|
||||
className='flex w-full shrink-0 flex-col border-[var(--landing-bg-elevated)] lg:w-[300px] lg:border-r'
|
||||
>
|
||||
{TEMPLATE_WORKFLOWS.map((workflow, index) => {
|
||||
const isActive = index === activeIndex
|
||||
return (
|
||||
<div key={workflow.id}>
|
||||
<button
|
||||
id={`template-tab-${index}`}
|
||||
type='button'
|
||||
role='tab'
|
||||
aria-selected={isActive}
|
||||
aria-controls={TEMPLATES_PANEL_ID}
|
||||
onClick={() => setActiveIndex(index)}
|
||||
className={cn(
|
||||
'relative w-full text-left',
|
||||
isActive
|
||||
? 'z-10'
|
||||
: cn(
|
||||
'flex items-center px-3 py-2.5 hover:bg-[color-mix(in_srgb,var(--landing-bg-card)_50%,transparent)]',
|
||||
index < TEMPLATE_WORKFLOWS.length - 1 &&
|
||||
'shadow-[inset_0_-1px_0_0_#2A2A2A]'
|
||||
)
|
||||
)}
|
||||
>
|
||||
{isActive ? (
|
||||
(() => {
|
||||
const depth = DEPTH_CONFIGS[workflow.id]
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className='absolute top-[-8px] bottom-0 left-0 w-2'
|
||||
style={{
|
||||
clipPath: LEFT_WALL_CLIP,
|
||||
backgroundColor: hexToRgba(depth.color, 0.63),
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className='absolute right-[-8px] bottom-0 left-2 h-2'
|
||||
style={buildBottomWallStyle(depth)}
|
||||
/>
|
||||
<div className='-translate-y-2 relative flex translate-x-2 items-center bg-[var(--landing-bg-card)] px-3 py-2.5 shadow-[inset_0_0_0_1.5px_#3E3E3E]'>
|
||||
<span className='flex-1 font-[430] font-season text-md text-white'>
|
||||
{workflow.name}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className='-rotate-90 h-[11px] w-[11px] shrink-0'
|
||||
style={{ color: depth.color }}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
})()
|
||||
) : (
|
||||
<span className='font-[430] font-season text-[#F6F6F0]/50 text-md'>
|
||||
{workflow.name}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{isActive && isMobile && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.25, ease: [0.4, 0, 0.2, 1] }}
|
||||
className='overflow-hidden'
|
||||
>
|
||||
<div className='aspect-[16/10] w-full border-[var(--landing-bg-elevated)] border-y bg-[var(--landing-bg)]'>
|
||||
<LandingPreviewWorkflow
|
||||
workflow={workflow}
|
||||
animate
|
||||
fitViewOptions={{ padding: 0.15, minZoom: 0.1, maxZoom: 0.8 }}
|
||||
/>
|
||||
</div>
|
||||
<div className='p-3'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleUseTemplate}
|
||||
disabled={isPreparingTemplate}
|
||||
className='inline-flex h-[32px] w-full cursor-pointer items-center justify-center gap-1.5 rounded-[5px] border border-white bg-white font-[430] font-season text-black text-sm transition-colors active:bg-[#E0E0E0]'
|
||||
>
|
||||
{isPreparingTemplate ? 'Preparing...' : 'Use template'}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div
|
||||
id={TEMPLATES_PANEL_ID}
|
||||
role='tabpanel'
|
||||
aria-labelledby={`template-tab-${activeIndex}`}
|
||||
className='relative hidden flex-1 lg:block'
|
||||
>
|
||||
<div aria-hidden='true' inert className='h-full'>
|
||||
<LandingPreviewWorkflow
|
||||
key={activeIndex}
|
||||
workflow={activeWorkflow}
|
||||
animate
|
||||
fitViewOptions={{ padding: 0.15, maxZoom: 1.3 }}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleUseTemplate}
|
||||
disabled={isPreparingTemplate}
|
||||
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]'
|
||||
<div className='bg-[var(--landing-bg)]'>
|
||||
<div className='relative overflow-hidden'>
|
||||
<div className='px-5 lg:px-16'>
|
||||
<div className='flex flex-col items-start gap-5'>
|
||||
<Badge
|
||||
variant='blue'
|
||||
size='md'
|
||||
dot
|
||||
className='font-season uppercase tracking-[0.02em] transition-colors duration-200'
|
||||
style={{
|
||||
color: activeDepth.color,
|
||||
backgroundColor: hexToRgba(activeDepth.color, 0.1),
|
||||
}}
|
||||
>
|
||||
{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'
|
||||
fill='none'
|
||||
className='transition-transform duration-200 ease-out group-hover/cta:translate-x-[30%]'
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
Templates
|
||||
</Badge>
|
||||
|
||||
<h2
|
||||
id='templates-heading'
|
||||
className='text-balance font-[430] font-season text-[28px] text-white leading-[100%] tracking-[-0.02em] lg:text-[40px]'
|
||||
>
|
||||
Ship your agent in minutes
|
||||
</h2>
|
||||
|
||||
<p className='font-[430] font-season text-[#F6F6F0]/50 text-base leading-[150%] tracking-[0.02em] lg:text-lg'>
|
||||
Pre-built templates for every use case: pick one, swap{' '}
|
||||
<br className='hidden lg:inline' />
|
||||
models and tools to fit your stack, and deploy.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='w-[24px] shrink-0 border-[var(--landing-bg-elevated)] border-l lg:w-16'
|
||||
/>
|
||||
</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='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='flex min-w-0 flex-1 flex-col lg:flex-row'>
|
||||
<div
|
||||
role='tablist'
|
||||
aria-label='Workflow templates'
|
||||
className='flex w-full shrink-0 flex-col border-[var(--landing-bg-elevated)] lg:w-[300px] lg:border-r'
|
||||
>
|
||||
{TEMPLATE_WORKFLOWS.map((workflow, index) => {
|
||||
const isActive = index === activeIndex
|
||||
return (
|
||||
<div key={workflow.id}>
|
||||
<button
|
||||
id={`template-tab-${index}`}
|
||||
type='button'
|
||||
role='tab'
|
||||
aria-selected={isActive}
|
||||
aria-controls={TEMPLATES_PANEL_ID}
|
||||
onClick={() => setActiveIndex(index)}
|
||||
className={cn(
|
||||
'relative w-full text-left',
|
||||
isActive
|
||||
? 'z-10'
|
||||
: cn(
|
||||
'flex items-center px-3 py-2.5 hover:bg-[color-mix(in_srgb,var(--landing-bg-card)_50%,transparent)]',
|
||||
index < TEMPLATE_WORKFLOWS.length - 1 &&
|
||||
'shadow-[inset_0_-1px_0_0_#2A2A2A]'
|
||||
)
|
||||
)}
|
||||
>
|
||||
{isActive ? (
|
||||
(() => {
|
||||
const depth = DEPTH_CONFIGS[workflow.id]
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className='absolute top-[-8px] bottom-0 left-0 w-2'
|
||||
style={{
|
||||
clipPath: LEFT_WALL_CLIP,
|
||||
backgroundColor: hexToRgba(depth.color, 0.63),
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className='absolute right-[-8px] bottom-0 left-2 h-2'
|
||||
style={buildBottomWallStyle(depth)}
|
||||
/>
|
||||
<div className='-translate-y-2 relative flex translate-x-2 items-center bg-[var(--landing-bg-card)] px-3 py-2.5 shadow-[inset_0_0_0_1.5px_#3E3E3E]'>
|
||||
<span className='flex-1 font-[430] font-season text-md text-white'>
|
||||
{workflow.name}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className='-rotate-90 size-[11px] shrink-0'
|
||||
style={{ color: depth.color }}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
})()
|
||||
) : (
|
||||
<span className='font-[430] font-season text-[#F6F6F0]/50 text-md'>
|
||||
{workflow.name}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{isActive && isMobile && (
|
||||
<m.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.25, ease: [0.4, 0, 0.2, 1] }}
|
||||
className='overflow-hidden'
|
||||
>
|
||||
<div className='aspect-[16/10] w-full border-[var(--landing-bg-elevated)] border-y bg-[var(--landing-bg)]'>
|
||||
<LandingPreviewWorkflow
|
||||
workflow={workflow}
|
||||
animate
|
||||
fitViewOptions={{ padding: 0.15, minZoom: 0.1, maxZoom: 0.8 }}
|
||||
/>
|
||||
</div>
|
||||
<div className='p-3'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleUseTemplate}
|
||||
disabled={isPreparingTemplate}
|
||||
className='inline-flex h-[32px] w-full cursor-pointer items-center justify-center gap-1.5 rounded-[5px] border border-white bg-white font-[430] font-season text-black text-sm transition-colors active:bg-[#E0E0E0]'
|
||||
>
|
||||
{isPreparingTemplate ? 'Preparing...' : 'Use template'}
|
||||
</button>
|
||||
</div>
|
||||
</m.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div
|
||||
id={TEMPLATES_PANEL_ID}
|
||||
role='tabpanel'
|
||||
aria-labelledby={`template-tab-${activeIndex}`}
|
||||
className='relative hidden flex-1 lg:block'
|
||||
>
|
||||
<div aria-hidden='true' inert className='h-full'>
|
||||
<LandingPreviewWorkflow
|
||||
key={activeIndex}
|
||||
workflow={activeWorkflow}
|
||||
animate
|
||||
fitViewOptions={{ padding: 0.15, maxZoom: 1.3 }}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleUseTemplate}
|
||||
disabled={isPreparingTemplate}
|
||||
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='size-[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'
|
||||
fill='none'
|
||||
className='transition-transform duration-200 ease-out group-hover/cta:translate-x-[30%]'
|
||||
/>
|
||||
</svg>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</LazyMotion>
|
||||
)
|
||||
}
|
||||
|
||||
+6
-2
@@ -14,14 +14,18 @@ interface TemplateCardButtonProps {
|
||||
export function TemplateCardButton({ prompt, className, children }: TemplateCardButtonProps) {
|
||||
const router = useRouter()
|
||||
|
||||
function handleClick() {
|
||||
function savePromptAndNavigate() {
|
||||
LandingPromptStorage.store(prompt)
|
||||
trackLandingCta({ label: 'Template card', section: 'integrations', destination: '/signup' })
|
||||
router.push('/signup')
|
||||
}
|
||||
|
||||
return (
|
||||
<button type='button' onClick={handleClick} className={cn('w-full text-left', className)}>
|
||||
<button
|
||||
type='button'
|
||||
onClick={savePromptAndNavigate}
|
||||
className={cn('w-full text-left', className)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Loader } from '@/components/emcn'
|
||||
export default function IntegrationDetailLoading() {
|
||||
return (
|
||||
<div className='flex min-h-[60vh] items-center justify-center bg-[var(--landing-bg)]'>
|
||||
<Loader animate className='h-6 w-6 text-[var(--landing-text-muted)]' />
|
||||
<Loader animate className='size-6 text-[var(--landing-text-muted)]' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -321,7 +321,7 @@ export default async function IntegrationPage({ params }: { params: Promise<{ sl
|
||||
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)]'
|
||||
>
|
||||
<svg
|
||||
className='h-3 w-3 shrink-0'
|
||||
className='size-3 shrink-0'
|
||||
viewBox='0 0 10 10'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
@@ -357,7 +357,7 @@ export default async function IntegrationPage({ params }: { params: Promise<{ sl
|
||||
bgColor={bgColor}
|
||||
name={name}
|
||||
Icon={IconComponent}
|
||||
className='h-12 w-12 rounded-[5px]'
|
||||
className='size-12 rounded-[5px]'
|
||||
iconClassName='h-6 w-6'
|
||||
fallbackClassName='text-[20px]'
|
||||
aria-hidden='true'
|
||||
@@ -393,7 +393,7 @@ export default async function IntegrationPage({ params }: { params: Promise<{ sl
|
||||
View docs
|
||||
<svg
|
||||
aria-hidden='true'
|
||||
className='-rotate-45 h-3 w-3 shrink-0'
|
||||
className='-rotate-45 size-3 shrink-0'
|
||||
viewBox='0 0 10 10'
|
||||
fill='none'
|
||||
>
|
||||
@@ -477,7 +477,7 @@ export default async function IntegrationPage({ params }: { params: Promise<{ sl
|
||||
].map(({ step, title, body }) => (
|
||||
<li key={step} className='flex gap-4'>
|
||||
<span
|
||||
className='mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-[var(--landing-border-strong)] font-martian-mono text-[11px] text-[var(--landing-text-subtle)]'
|
||||
className='mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full border border-[var(--landing-border-strong)] font-martian-mono text-[11px] text-[var(--landing-text-subtle)]'
|
||||
aria-hidden='true'
|
||||
>
|
||||
{step}
|
||||
@@ -500,9 +500,9 @@ export default async function IntegrationPage({ params }: { params: Promise<{ sl
|
||||
<section aria-labelledby='triggers-heading'>
|
||||
<div className='px-6 pt-10 pb-4'>
|
||||
<div className='mb-2 flex items-center gap-2.5'>
|
||||
<span className='relative flex h-2 w-2' aria-hidden='true'>
|
||||
<span className='relative flex size-2' aria-hidden='true'>
|
||||
<span className='absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75' />
|
||||
<span className='relative inline-flex h-2 w-2 rounded-full bg-emerald-500' />
|
||||
<span className='relative inline-flex size-2 rounded-full bg-emerald-500' />
|
||||
</span>
|
||||
<h2
|
||||
id='triggers-heading'
|
||||
@@ -512,8 +512,8 @@ export default async function IntegrationPage({ params }: { params: Promise<{ sl
|
||||
</h2>
|
||||
</div>
|
||||
<p className='text-[14px] text-[var(--landing-text-body)] leading-[150%] tracking-[0.02em]'>
|
||||
Connect a {name} webhook to Sim and your agent runs the instant an event happens —
|
||||
no polling, no delay.
|
||||
Connect a {name} webhook to Sim and your agent runs the instant an event happens, no
|
||||
polling, no delay.
|
||||
</p>
|
||||
</div>
|
||||
<div className='h-px w-full bg-[var(--landing-bg-elevated)]' />
|
||||
@@ -585,7 +585,7 @@ export default async function IntegrationPage({ params }: { params: Promise<{ sl
|
||||
name={int?.name ?? bt}
|
||||
Icon={ToolIcon}
|
||||
as='span'
|
||||
className='h-6 w-6 rounded-[4px]'
|
||||
className='size-6 rounded-[4px]'
|
||||
iconClassName='h-3.5 w-3.5'
|
||||
fallbackClassName='text-[10px]'
|
||||
aria-hidden='true'
|
||||
@@ -721,7 +721,7 @@ export default async function IntegrationPage({ params }: { params: Promise<{ sl
|
||||
name={rel.name}
|
||||
Icon={blockTypeToIconMap[rel.type]}
|
||||
as='span'
|
||||
className='h-10 w-10 rounded-[5px]'
|
||||
className='size-10 rounded-[5px]'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
<div className='flex flex-col gap-2'>
|
||||
@@ -753,11 +753,11 @@ export default async function IntegrationPage({ params }: { params: Promise<{ sl
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='h-px w-5 bg-[#3d3d3d]' aria-hidden='true' />
|
||||
<span
|
||||
className='flex h-7 w-7 items-center justify-center rounded-full border border-[var(--landing-border-strong)]'
|
||||
className='flex size-7 items-center justify-center rounded-full border border-[var(--landing-border-strong)]'
|
||||
aria-hidden='true'
|
||||
>
|
||||
<svg
|
||||
className='h-3.5 w-3.5 text-[var(--landing-text-secondary)]'
|
||||
className='size-3.5 text-[var(--landing-text-secondary)]'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
@@ -774,7 +774,7 @@ export default async function IntegrationPage({ params }: { params: Promise<{ sl
|
||||
bgColor={bgColor}
|
||||
name={name}
|
||||
Icon={IconComponent}
|
||||
className='h-14 w-14 rounded-xl'
|
||||
className='size-14 rounded-xl'
|
||||
iconClassName='h-7 w-7'
|
||||
fallbackClassName='text-[22px]'
|
||||
aria-hidden='true'
|
||||
@@ -788,7 +788,7 @@ export default async function IntegrationPage({ params }: { params: Promise<{ sl
|
||||
</h2>
|
||||
<p className='mx-auto mb-8 max-w-[480px] text-[var(--landing-text-body)] text-base leading-[150%] tracking-[0.02em]'>
|
||||
Build your first AI agent with {name} in minutes. Connect to every tool your team uses.
|
||||
Free to start — no credit card required.
|
||||
Free to start, no credit card required.
|
||||
</p>
|
||||
<IntegrationCtaButton
|
||||
label='Build for free'
|
||||
|
||||
@@ -170,7 +170,7 @@ export default function IntegrationsPage() {
|
||||
</section>
|
||||
|
||||
{/* Integration request */}
|
||||
<div className='flex flex-col items-start gap-3 px-6 py-6 sm:flex-row sm:items-center sm:justify-between'>
|
||||
<div className='flex flex-col items-start gap-3 p-6 sm:flex-row sm:items-center sm:justify-between'>
|
||||
<div>
|
||||
<p className='text-[15px] text-white tracking-[-0.02em]'>
|
||||
Don't see the integration you need?
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Loader } from '@/components/emcn'
|
||||
export default function IntegrationDetailLoading() {
|
||||
return (
|
||||
<div className='flex min-h-[60vh] items-center justify-center bg-[var(--landing-bg)]'>
|
||||
<Loader animate className='h-6 w-6 text-[var(--landing-text-muted)]' />
|
||||
<Loader animate className='size-6 text-[var(--landing-text-muted)]' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ export function IntegrationCard({ integration, IconComponent }: IntegrationCardP
|
||||
bgColor={bgColor}
|
||||
name={name}
|
||||
Icon={IconComponent}
|
||||
className='h-10 w-10 rounded-[5px]'
|
||||
className='size-10 rounded-[5px]'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
<div className='flex flex-col gap-2'>
|
||||
@@ -61,7 +61,7 @@ export function IntegrationRow({ integration, IconComponent }: IntegrationRowPro
|
||||
bgColor={bgColor}
|
||||
name={name}
|
||||
Icon={IconComponent}
|
||||
className='h-8 w-8 shrink-0 rounded-[5px]'
|
||||
className='size-8 shrink-0 rounded-[5px]'
|
||||
iconClassName='h-4 w-4'
|
||||
fallbackClassName='text-[13px]'
|
||||
aria-hidden='true'
|
||||
|
||||
@@ -78,7 +78,7 @@ export function IntegrationGrid({ integrations }: IntegrationGridProps) {
|
||||
<div className='relative max-w-[480px] flex-1'>
|
||||
<svg
|
||||
aria-hidden='true'
|
||||
className='-translate-y-1/2 pointer-events-none absolute top-1/2 left-3 h-4 w-4 text-[#555]'
|
||||
className='-translate-y-1/2 pointer-events-none absolute top-1/2 left-3 size-4 text-[#555]'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth={2}
|
||||
|
||||
@@ -84,9 +84,9 @@ export function RequestIntegrationModal() {
|
||||
{status === 'success' ? (
|
||||
<ModalBody>
|
||||
<div className='flex flex-col items-center gap-3 py-6 text-center'>
|
||||
<div className='flex h-10 w-10 items-center justify-center rounded-full bg-[#33C482]/10'>
|
||||
<div className='flex size-10 items-center justify-center rounded-full bg-[#33C482]/10'>
|
||||
<svg
|
||||
className='h-5 w-5 text-[var(--brand-accent)]'
|
||||
className='size-5 text-[var(--brand-accent)]'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
@@ -98,7 +98,7 @@ export function RequestIntegrationModal() {
|
||||
</svg>
|
||||
</div>
|
||||
<p className='text-[14px] text-[var(--landing-text)]'>
|
||||
Request submitted — we'll follow up at{' '}
|
||||
Request submitted. We'll follow up at{' '}
|
||||
<span className='font-medium'>{email}</span>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
|
||||
export type AuthType = 'oauth' | 'api-key' | 'none'
|
||||
|
||||
export interface TriggerInfo {
|
||||
interface TriggerInfo {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface OperationInfo {
|
||||
interface OperationInfo {
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Loader } from '@/components/emcn'
|
||||
export default function ModelDetailLoading() {
|
||||
return (
|
||||
<div className='flex min-h-[60vh] items-center justify-center bg-[var(--landing-bg)]'>
|
||||
<Loader animate className='h-6 w-6 text-[var(--landing-text-muted)]' />
|
||||
<Loader animate className='size-6 text-[var(--landing-text-muted)]' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -168,7 +168,7 @@ export default async function ModelPage({
|
||||
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)]'
|
||||
>
|
||||
<svg
|
||||
className='h-3 w-3 shrink-0'
|
||||
className='size-3 shrink-0'
|
||||
viewBox='0 0 10 10'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
@@ -200,7 +200,7 @@ export default async function ModelPage({
|
||||
<div className='mb-6 flex items-center gap-5'>
|
||||
<ProviderIcon
|
||||
provider={provider}
|
||||
className='h-16 w-16 rounded-[5px]'
|
||||
className='size-16 rounded-[5px]'
|
||||
iconClassName='h-8 w-8'
|
||||
/>
|
||||
<div>
|
||||
@@ -222,12 +222,12 @@ export default async function ModelPage({
|
||||
</p>
|
||||
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<a
|
||||
<Link
|
||||
href='/'
|
||||
className='inline-flex h-[32px] items-center gap-2 rounded-[5px] border border-white bg-white px-2.5 font-season text-black text-sm transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]'
|
||||
>
|
||||
Build with this model
|
||||
</a>
|
||||
</Link>
|
||||
<Link
|
||||
href={provider.href}
|
||||
className='inline-flex h-[32px] items-center rounded-[5px] border border-[var(--landing-border-strong)] px-2.5 font-season text-[var(--landing-text)] text-sm transition-colors hover:bg-[var(--landing-bg-elevated)]'
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Loader } from '@/components/emcn'
|
||||
export default function ModelProviderLoading() {
|
||||
return (
|
||||
<div className='flex min-h-[60vh] items-center justify-center bg-[var(--landing-bg)]'>
|
||||
<Loader animate className='h-6 w-6 text-[var(--landing-text-muted)]' />
|
||||
<Loader animate className='size-6 text-[var(--landing-text-muted)]' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -164,7 +164,7 @@ export default async function ProviderModelsPage({
|
||||
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)]'
|
||||
>
|
||||
<svg
|
||||
className='h-3 w-3 shrink-0'
|
||||
className='size-3 shrink-0'
|
||||
viewBox='0 0 10 10'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
@@ -206,7 +206,7 @@ export default async function ProviderModelsPage({
|
||||
<div className='flex items-center gap-4'>
|
||||
<ProviderIcon
|
||||
provider={provider}
|
||||
className='h-12 w-12 rounded-[5px]'
|
||||
className='size-12 rounded-[5px]'
|
||||
iconClassName='h-6 w-6'
|
||||
/>
|
||||
<h1
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Loader } from '@/components/emcn'
|
||||
export default function ModelDetailLoading() {
|
||||
return (
|
||||
<div className='flex min-h-[60vh] items-center justify-center bg-[var(--landing-bg)]'>
|
||||
<Loader animate className='h-6 w-6 text-[var(--landing-text-muted)]' />
|
||||
<Loader animate className='size-6 text-[var(--landing-text-muted)]' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Loader } from '@/components/emcn'
|
||||
export default function ModelProviderLoading() {
|
||||
return (
|
||||
<div className='flex min-h-[60vh] items-center justify-center bg-[var(--landing-bg)]'>
|
||||
<Loader animate className='h-6 w-6 text-[var(--landing-text-muted)]' />
|
||||
<Loader animate className='size-6 text-[var(--landing-text-muted)]' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ function ModelLabel({ model }: ModelLabelProps) {
|
||||
|
||||
return (
|
||||
<div className='flex w-[90px] shrink-0 items-center justify-end gap-1.5 sm:w-[140px] lg:w-[180px]'>
|
||||
{Icon && <Icon className='h-3.5 w-3.5 shrink-0' />}
|
||||
{Icon && <Icon className='size-3.5 shrink-0' />}
|
||||
<span className='truncate font-medium text-[13px] text-[var(--landing-text)] leading-none tracking-[-0.01em]'>
|
||||
{model.displayName}
|
||||
</span>
|
||||
|
||||
@@ -85,7 +85,7 @@ export function ModelDirectory() {
|
||||
<div className='relative max-w-[480px] flex-1'>
|
||||
<svg
|
||||
aria-hidden='true'
|
||||
className='-translate-y-1/2 pointer-events-none absolute top-1/2 left-3 h-4 w-4 text-[#555]'
|
||||
className='-translate-y-1/2 pointer-events-none absolute top-1/2 left-3 size-4 text-[#555]'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth={2}
|
||||
@@ -157,7 +157,7 @@ export function ModelDirectory() {
|
||||
>
|
||||
<ProviderIcon
|
||||
provider={provider}
|
||||
className='h-8 w-8 rounded-[5px]'
|
||||
className='size-8 rounded-[5px]'
|
||||
iconClassName='h-4 w-4'
|
||||
/>
|
||||
<div className='min-w-0 flex-1'>
|
||||
@@ -206,7 +206,7 @@ export function ModelDirectory() {
|
||||
>
|
||||
<ProviderIcon
|
||||
provider={provider}
|
||||
className='h-8 w-8 rounded-[5px]'
|
||||
className='size-8 rounded-[5px]'
|
||||
iconClassName='h-4 w-4'
|
||||
/>
|
||||
<div className='min-w-0 flex-1'>
|
||||
@@ -236,7 +236,7 @@ function ModelRow({ provider, model }: { provider: CatalogProvider; model: Catal
|
||||
>
|
||||
<ProviderIcon
|
||||
provider={provider}
|
||||
className='h-8 w-8 shrink-0 rounded-[5px]'
|
||||
className='size-8 shrink-0 rounded-[5px]'
|
||||
iconClassName='h-4 w-4'
|
||||
/>
|
||||
|
||||
|
||||
@@ -100,7 +100,7 @@ export function DetailItem({ label, value }: { label: string; value: string }) {
|
||||
export function ChevronArrow() {
|
||||
return (
|
||||
<svg
|
||||
className='h-3 w-3 shrink-0 text-[var(--landing-text-subtle)]'
|
||||
className='size-3 shrink-0 text-[var(--landing-text-subtle)]'
|
||||
viewBox='0 0 10 10'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
@@ -154,11 +154,7 @@ export function FeaturedProviderCard({ provider }: { provider: CatalogProvider }
|
||||
href={provider.href}
|
||||
className='group flex flex-1 flex-col gap-4 border-[var(--landing-bg-elevated)] border-t p-6 transition-colors first:border-t-0 hover:bg-[var(--landing-bg-elevated)] sm:border-t-0 sm:border-l sm:first:border-l-0'
|
||||
>
|
||||
<ProviderIcon
|
||||
provider={provider}
|
||||
className='h-10 w-10 rounded-[5px]'
|
||||
iconClassName='h-5 w-5'
|
||||
/>
|
||||
<ProviderIcon provider={provider} className='size-10 rounded-[5px]' iconClassName='h-5 w-5' />
|
||||
<div className='flex flex-col gap-2'>
|
||||
<h3 className='text-lg text-white leading-tight tracking-[-0.01em]'>{provider.name}</h3>
|
||||
<p className='line-clamp-2 text-[var(--landing-text-muted)] text-sm leading-[150%]'>
|
||||
@@ -181,11 +177,7 @@ export function FeaturedModelCard({
|
||||
href={model.href}
|
||||
className='group flex flex-1 flex-col gap-4 border-[var(--landing-bg-elevated)] border-t p-6 transition-colors first:border-t-0 hover:bg-[var(--landing-bg-elevated)] sm:border-t-0 sm:border-l sm:first:border-l-0'
|
||||
>
|
||||
<ProviderIcon
|
||||
provider={provider}
|
||||
className='h-10 w-10 rounded-[5px]'
|
||||
iconClassName='h-5 w-5'
|
||||
/>
|
||||
<ProviderIcon provider={provider} className='size-10 rounded-[5px]' iconClassName='h-5 w-5' />
|
||||
<div className='flex flex-col gap-2'>
|
||||
<span className='font-martian-mono text-[var(--landing-text-subtle)] text-xs uppercase tracking-[0.1em]'>
|
||||
{provider.name}
|
||||
@@ -255,7 +247,7 @@ export function ModelCard({
|
||||
<div className='mb-4 flex items-start gap-3'>
|
||||
<ProviderIcon
|
||||
provider={provider}
|
||||
className='h-10 w-10 rounded-[5px]'
|
||||
className='size-10 rounded-[5px]'
|
||||
iconClassName='h-5 w-5'
|
||||
/>
|
||||
<div className='min-w-0 flex-1'>
|
||||
|
||||
@@ -83,7 +83,7 @@ export function ModelTimelineChart({ models, providerId }: ModelTimelineChartPro
|
||||
>
|
||||
{/* Dot — centered exactly on the line (70px - 4.5px) */}
|
||||
<div
|
||||
className='-translate-x-1/2 absolute top-[66px] left-1/2 h-[9px] w-[9px] rounded-full transition-[filter,transform] duration-150 group-hover:scale-150 group-hover:brightness-150'
|
||||
className='-translate-x-1/2 absolute top-[66px] left-1/2 size-[9px] rounded-full transition-[filter,transform] duration-150 group-hover:scale-150 group-hover:brightness-150'
|
||||
style={{ backgroundColor: color, opacity: 0.85 }}
|
||||
/>
|
||||
|
||||
|
||||
@@ -780,12 +780,14 @@ export function buildModelCapabilityFacts(model: CatalogModel): CapabilityFact[]
|
||||
}
|
||||
|
||||
export function getCheapestProviderModel(provider: CatalogProvider): CatalogModel | null {
|
||||
return [...provider.models].sort((a, b) => a.pricing.input - b.pricing.input)[0] ?? null
|
||||
if (provider.models.length === 0) return null
|
||||
return provider.models.reduce((min, m) => (m.pricing.input < min.pricing.input ? m : min))
|
||||
}
|
||||
|
||||
export function getLargestContextProviderModel(provider: CatalogProvider): CatalogModel | null {
|
||||
return (
|
||||
[...provider.models].sort((a, b) => (b.contextWindow ?? 0) - (a.contextWindow ?? 0))[0] ?? null
|
||||
if (provider.models.length === 0) return null
|
||||
return provider.models.reduce((max, m) =>
|
||||
(m.contextWindow ?? 0) > (max.contextWindow ?? 0) ? m : max
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -242,7 +242,7 @@ export default async function PartnersPage() {
|
||||
<ul className='space-y-1.5'>
|
||||
{tier.requirements.map((r) => (
|
||||
<li key={r} className='flex items-start gap-2 text-[#999] text-[13px]'>
|
||||
<span className='mt-1.5 h-1 w-1 flex-shrink-0 rounded-full bg-[#555]' />
|
||||
<span className='mt-1.5 size-1 flex-shrink-0 rounded-full bg-[#555]' />
|
||||
{r}
|
||||
</li>
|
||||
))}
|
||||
@@ -254,7 +254,7 @@ export default async function PartnersPage() {
|
||||
<ul className='space-y-1.5'>
|
||||
{tier.perks.map((p) => (
|
||||
<li key={p} className='flex items-start gap-2 text-[#ECECEC] text-[13px]'>
|
||||
<span className='mt-1.5 h-1 w-1 flex-shrink-0 rounded-full bg-[#4CAF50]' />
|
||||
<span className='mt-1.5 size-1 flex-shrink-0 rounded-full bg-[#4CAF50]' />
|
||||
{p}
|
||||
</li>
|
||||
))}
|
||||
@@ -274,7 +274,7 @@ export default async function PartnersPage() {
|
||||
</h2>
|
||||
<p className='mb-10 text-[#F6F6F0]/60 text-[18px] leading-[160%]'>
|
||||
Complete Sim Academy to earn your first certification and unlock partner benefits.
|
||||
It's free to start — no credit card required.
|
||||
It's free to start, no credit card required.
|
||||
</p>
|
||||
{/* TODO: Uncomment when academy is public */}
|
||||
{/* <Link
|
||||
|
||||
@@ -584,7 +584,7 @@ export default function PrivacyPolicy() {
|
||||
Please note that we may ask you to verify your identity before responding to such
|
||||
requests.
|
||||
</p>
|
||||
<p className='mb-4 border-[var(--landing-border-strong)] border-l-4 bg-[var(--landing-bg-elevated)] p-3 text-[var(--landing-text)]'>
|
||||
<p className='mb-4 bg-[var(--landing-bg-elevated)] p-3 text-[var(--landing-text)] shadow-[inset_2px_0_0_var(--landing-border-strong)]'>
|
||||
You have the right to complain to a Data Protection Authority about our collection and use
|
||||
of your Personal Information. For more information, please contact your local data
|
||||
protection authority in the European Economic Area (EEA).
|
||||
|
||||
@@ -291,7 +291,7 @@ export default function TermsOfService() {
|
||||
Agreement. The arbitration will be conducted by JAMS, an established alternative dispute
|
||||
resolution provider.
|
||||
</p>
|
||||
<p className='mb-4 border-[var(--landing-border-strong)] border-l-4 bg-[var(--landing-bg-elevated)] p-3 text-[var(--landing-text)]'>
|
||||
<p className='mb-4 bg-[var(--landing-bg-elevated)] p-3 text-[var(--landing-text)] shadow-[inset_2px_0_0_var(--landing-border-strong)]'>
|
||||
YOU AND COMPANY AGREE THAT EACH OF US MAY BRING CLAIMS AGAINST THE OTHER ONLY ON AN
|
||||
INDIVIDUAL BASIS AND NOT ON A CLASS, REPRESENTATIVE, OR COLLECTIVE BASIS. ONLY INDIVIDUAL
|
||||
RELIEF IS AVAILABLE, AND DISPUTES OF MORE THAN ONE CUSTOMER OR USER CANNOT BE ARBITRATED
|
||||
|
||||
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user