mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-08 22:48:14 -05:00
feat(landing): new landing page (#1219)
* update infra and remove railway
* feat(landing): background; font; metadata; nav
* finished navbar ui
* completed hero UI
* hero heading UI/UX
* updated icon descriptions
* canvas improvements
* canvas improvements
* updated canvas; adjusted background
* removed gsap; adjusted canvas height
* added templates outline
* feat(landing, landing-2): Update background, hero components, nav, integrations, pricing, templates, testimonials, tailwind config
* feat(landing, landing-2): Update background, footer, hero, index components, integrations, landing-pricing, landing templates, footer in sections, icons, middleware
* improvement(landing): optimized html
* feat(landing): update background, footer, hero, integrations, landing-enterprise, landing-pricing, landing-templates, nav, add github-stars route
* feat(landing): added onclicks
* feat(landing): commented out templates
* fix: reset environment
* fixed build
* feat(landing): updated background, footer, index, integrations, landing-pricing, nav, testimonials, landing page, fonts, environment
* feat(landing): swapped integrations and pricing
* navigation for new landing
* login/signup/terms/privacy preliminary changes, as well as navigation setup
* feat(landing,nav,hero,integrations,footer,testimonials,background,structured-data): updates and additions across components
* feat(landing): updated terms and privacy
* feat(auth): adjusted background
* feat(auth): signup and login complete
* feat(auth): completed all flows ui/ux
* fix: testing and build
* feat(landing, auth): update nav and login tests
* fix(ui): update auth navigation component (149 chars)
* restore scripts dir
* revert back to old globals.css brand primary color, updated invite page
* Revert "update infra and remove railway"
This reverts commit abfa2f8d51.
* remove logos
* add gh stars action for reuse on landing + cht
---------
Co-authored-by: waleedlatif1 <walif6@gmail.com>
This commit is contained in:
44
apps/sim/app/(auth)/components/auth-background-svg.tsx
Normal file
44
apps/sim/app/(auth)/components/auth-background-svg.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
export default function AuthBackgroundSVG() {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden='true'
|
||||
className='pointer-events-none fixed inset-0 h-full w-full'
|
||||
style={{ zIndex: 5 }}
|
||||
viewBox='0 0 1880 960'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
preserveAspectRatio='xMidYMid slice'
|
||||
>
|
||||
{/* Right side paths - extended to connect */}
|
||||
<path
|
||||
d='M1393.53 42.8889C1545.99 173.087 1688.28 339.75 1878.44 817.6'
|
||||
stroke='#E7E4EF'
|
||||
strokeWidth='2'
|
||||
/>
|
||||
<path d='M1624.21 960L1625.78 0' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<path d='M1832.67 715.81L1880 716.031' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<path d='M1393.4 40V0' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<circle cx='1393.03' cy='40.0186' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<circle cx='1625.28' cy='303.147' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<circle cx='1837.37' cy='715.81' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
|
||||
|
||||
{/* Left side paths - extended to connect */}
|
||||
<path
|
||||
d='M160 157.764C319.811 136.451 417.278 102.619 552.39 0'
|
||||
stroke='#E7E4EF'
|
||||
strokeWidth='2'
|
||||
/>
|
||||
<path d='M310.22 803.025V0' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<path
|
||||
d='M160 530.184C256.142 655.353 308.338 749.141 348.382 960'
|
||||
stroke='#E7E4EF'
|
||||
strokeWidth='2'
|
||||
/>
|
||||
<path d='M160 157.764V960' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<path d='M-50 157.764L160 157.764' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<circle cx='160' cy='157.764' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<circle cx='310.22' cy='803.025' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<circle cx='160' cy='530.184' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
16
apps/sim/app/(auth)/components/auth-background.tsx
Normal file
16
apps/sim/app/(auth)/components/auth-background.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
import AuthBackgroundSVG from './auth-background-svg'
|
||||
|
||||
type AuthBackgroundProps = {
|
||||
className?: string
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export default function AuthBackground({ className, children }: AuthBackgroundProps) {
|
||||
return (
|
||||
<div className={cn('relative min-h-screen w-full overflow-hidden', className)}>
|
||||
<AuthBackgroundSVG />
|
||||
<div className='relative z-20'>{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'
|
||||
import { GithubIcon, GoogleIcon } from '@/components/icons'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { client } from '@/lib/auth-client'
|
||||
import { inter } from '@/app/fonts/inter'
|
||||
|
||||
interface SocialLoginButtonsProps {
|
||||
githubAvailable: boolean
|
||||
@@ -92,24 +93,24 @@ export function SocialLoginButtons({
|
||||
const githubButton = (
|
||||
<Button
|
||||
variant='outline'
|
||||
className='w-full border-neutral-700 bg-neutral-900 text-white hover:bg-neutral-800 hover:text-white'
|
||||
className='w-full rounded-[10px] shadow-sm hover:bg-gray-50'
|
||||
disabled={!githubAvailable || isGithubLoading}
|
||||
onClick={signInWithGithub}
|
||||
>
|
||||
<GithubIcon className='mr-2 h-4 w-4' />
|
||||
{isGithubLoading ? 'Connecting...' : 'Continue with GitHub'}
|
||||
<GithubIcon className='!h-[18px] !w-[18px] mr-1' />
|
||||
{isGithubLoading ? 'Connecting...' : 'GitHub'}
|
||||
</Button>
|
||||
)
|
||||
|
||||
const googleButton = (
|
||||
<Button
|
||||
variant='outline'
|
||||
className='w-full border-neutral-700 bg-neutral-900 text-white hover:bg-neutral-800 hover:text-white'
|
||||
className='w-full rounded-[10px] shadow-sm hover:bg-gray-50'
|
||||
disabled={!googleAvailable || isGoogleLoading}
|
||||
onClick={signInWithGoogle}
|
||||
>
|
||||
<GoogleIcon className='mr-2 h-4 w-4' />
|
||||
{isGoogleLoading ? 'Connecting...' : 'Continue with Google'}
|
||||
<GoogleIcon className='!h-[18px] !w-[18px] mr-1' />
|
||||
{isGoogleLoading ? 'Connecting...' : 'Google'}
|
||||
</Button>
|
||||
)
|
||||
|
||||
@@ -120,9 +121,9 @@ export function SocialLoginButtons({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='grid gap-3'>
|
||||
{githubAvailable && githubButton}
|
||||
<div className={`${inter.className} grid gap-3 font-light`}>
|
||||
{googleAvailable && googleButton}
|
||||
{githubAvailable && githubButton}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,48 +1,42 @@
|
||||
'use client'
|
||||
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { useBrandConfig } from '@/lib/branding/branding'
|
||||
import { GridPattern } from '@/app/(landing)/components/grid-pattern'
|
||||
import { useEffect } from 'react'
|
||||
import Nav from '@/app/(landing)/components/nav/nav'
|
||||
import AuthBackground from './components/auth-background'
|
||||
|
||||
// Helper to detect if a color is dark
|
||||
function isColorDark(hexColor: string): boolean {
|
||||
const hex = hexColor.replace('#', '')
|
||||
const r = Number.parseInt(hex.substr(0, 2), 16)
|
||||
const g = Number.parseInt(hex.substr(2, 2), 16)
|
||||
const b = Number.parseInt(hex.substr(4, 2), 16)
|
||||
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
|
||||
return luminance < 0.5
|
||||
}
|
||||
|
||||
export default function AuthLayout({ children }: { children: React.ReactNode }) {
|
||||
const brand = useBrandConfig()
|
||||
useEffect(() => {
|
||||
// Check if brand background is dark and add class accordingly
|
||||
const rootStyle = getComputedStyle(document.documentElement)
|
||||
const brandBackground = rootStyle.getPropertyValue('--brand-background-hex').trim()
|
||||
|
||||
if (brandBackground && isColorDark(brandBackground)) {
|
||||
document.body.classList.add('auth-dark-bg')
|
||||
} else {
|
||||
document.body.classList.remove('auth-dark-bg')
|
||||
}
|
||||
}, [])
|
||||
return (
|
||||
<main className='relative flex min-h-screen flex-col bg-[var(--brand-background-hex)] font-geist-sans text-white'>
|
||||
{/* Background pattern */}
|
||||
<GridPattern
|
||||
x={-5}
|
||||
y={-5}
|
||||
className='absolute inset-0 z-0 stroke-[#ababab]/5'
|
||||
width={90}
|
||||
height={90}
|
||||
aria-hidden='true'
|
||||
/>
|
||||
<AuthBackground>
|
||||
<main className='relative flex min-h-screen flex-col font-geist-sans text-foreground'>
|
||||
{/* Header - Nav handles all conditional logic */}
|
||||
<Nav hideAuthButtons={true} variant='auth' />
|
||||
|
||||
{/* Header */}
|
||||
<div className='relative z-10 px-6 pt-9'>
|
||||
<div className='mx-auto max-w-7xl'>
|
||||
<Link href='/' className='inline-flex'>
|
||||
{brand.logoUrl ? (
|
||||
<img
|
||||
src={brand.logoUrl}
|
||||
alt={`${brand.name} Logo`}
|
||||
width={56}
|
||||
height={56}
|
||||
className='h-[56px] w-[56px] object-contain'
|
||||
/>
|
||||
) : (
|
||||
<Image src='/sim.svg' alt={`${brand.name} Logo`} width={56} height={56} />
|
||||
)}
|
||||
</Link>
|
||||
{/* Content */}
|
||||
<div className='relative z-30 flex flex-1 items-center justify-center px-4 pb-24'>
|
||||
<div className='w-full max-w-lg px-4'>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className='relative z-10 flex flex-1 items-center justify-center px-4 pb-6'>
|
||||
<div className='w-full max-w-md'>{children}</div>
|
||||
</div>
|
||||
</main>
|
||||
</main>
|
||||
</AuthBackground>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,258 +0,0 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { client } from '@/lib/auth-client'
|
||||
import LoginPage from '@/app/(auth)/login/login-form'
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: vi.fn(),
|
||||
useSearchParams: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth-client', () => ({
|
||||
client: {
|
||||
signIn: {
|
||||
email: vi.fn(),
|
||||
},
|
||||
emailOtp: {
|
||||
sendVerificationOtp: vi.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/(auth)/components/social-login-buttons', () => ({
|
||||
SocialLoginButtons: () => <div data-testid='social-login-buttons'>Social Login Buttons</div>,
|
||||
}))
|
||||
|
||||
const mockRouter = {
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
back: vi.fn(),
|
||||
}
|
||||
|
||||
const mockSearchParams = {
|
||||
get: vi.fn(),
|
||||
}
|
||||
|
||||
describe('LoginPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
;(useRouter as any).mockReturnValue(mockRouter)
|
||||
;(useSearchParams as any).mockReturnValue(mockSearchParams)
|
||||
mockSearchParams.get.mockReturnValue(null)
|
||||
})
|
||||
|
||||
const defaultProps = {
|
||||
githubAvailable: true,
|
||||
googleAvailable: true,
|
||||
isProduction: false,
|
||||
}
|
||||
|
||||
describe('Basic Rendering', () => {
|
||||
it('should render login form with all required elements', () => {
|
||||
render(<LoginPage {...defaultProps} />)
|
||||
|
||||
expect(screen.getByPlaceholderText(/enter your email/i)).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText(/enter your password/i)).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument()
|
||||
expect(screen.getByText(/forgot password/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/sign up/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render social login buttons', () => {
|
||||
render(<LoginPage {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId('social-login-buttons')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Password Visibility Toggle', () => {
|
||||
it('should toggle password visibility when button is clicked', () => {
|
||||
render(<LoginPage {...defaultProps} />)
|
||||
|
||||
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
|
||||
const toggleButton = screen.getByLabelText(/show password/i)
|
||||
|
||||
expect(passwordInput).toHaveAttribute('type', 'password')
|
||||
|
||||
fireEvent.click(toggleButton)
|
||||
expect(passwordInput).toHaveAttribute('type', 'text')
|
||||
|
||||
fireEvent.click(toggleButton)
|
||||
expect(passwordInput).toHaveAttribute('type', 'password')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Form Interaction', () => {
|
||||
it('should allow users to type in form fields', () => {
|
||||
render(<LoginPage {...defaultProps} />)
|
||||
|
||||
const emailInput = screen.getByPlaceholderText(/enter your email/i)
|
||||
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
|
||||
|
||||
fireEvent.change(emailInput, { target: { value: 'user@company.com' } })
|
||||
fireEvent.change(passwordInput, { target: { value: 'password123' } })
|
||||
|
||||
expect(emailInput).toHaveValue('user@company.com')
|
||||
expect(passwordInput).toHaveValue('password123')
|
||||
})
|
||||
|
||||
it('should show loading state during form submission', async () => {
|
||||
const mockSignIn = vi.mocked(client.signIn.email)
|
||||
mockSignIn.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) =>
|
||||
setTimeout(() => resolve({ data: { user: { id: '1' } }, error: null }), 100)
|
||||
)
|
||||
)
|
||||
|
||||
render(<LoginPage {...defaultProps} />)
|
||||
|
||||
const emailInput = screen.getByPlaceholderText(/enter your email/i)
|
||||
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
|
||||
const submitButton = screen.getByRole('button', { name: /sign in/i })
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(emailInput, { target: { value: 'user@company.com' } })
|
||||
fireEvent.change(passwordInput, { target: { value: 'password123' } })
|
||||
fireEvent.click(submitButton)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Signing in...')).toBeInTheDocument()
|
||||
expect(submitButton).toBeDisabled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Form Submission', () => {
|
||||
it('should call signIn with correct credentials', async () => {
|
||||
const mockSignIn = vi.mocked(client.signIn.email)
|
||||
mockSignIn.mockResolvedValue({ data: { user: { id: '1' } }, error: null })
|
||||
|
||||
render(<LoginPage {...defaultProps} />)
|
||||
|
||||
const emailInput = screen.getByPlaceholderText(/enter your email/i)
|
||||
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
|
||||
const submitButton = screen.getByRole('button', { name: /sign in/i })
|
||||
|
||||
fireEvent.change(emailInput, { target: { value: 'user@company.com' } })
|
||||
fireEvent.change(passwordInput, { target: { value: 'password123' } })
|
||||
fireEvent.click(submitButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSignIn).toHaveBeenCalledWith(
|
||||
{
|
||||
email: 'user@company.com',
|
||||
password: 'password123',
|
||||
callbackURL: '/workspace',
|
||||
},
|
||||
expect.objectContaining({
|
||||
onError: expect.any(Function),
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle authentication errors', async () => {
|
||||
const mockSignIn = vi.mocked(client.signIn.email)
|
||||
|
||||
mockSignIn.mockImplementation((credentials, options) => {
|
||||
if (options?.onError) {
|
||||
options.onError({
|
||||
error: {
|
||||
code: 'INVALID_CREDENTIALS',
|
||||
message: 'Invalid credentials',
|
||||
} as any,
|
||||
response: {} as any,
|
||||
request: {} as any,
|
||||
} as any)
|
||||
}
|
||||
return Promise.resolve({ data: null, error: 'Invalid credentials' })
|
||||
})
|
||||
|
||||
render(<LoginPage {...defaultProps} />)
|
||||
|
||||
const emailInput = screen.getByPlaceholderText(/enter your email/i)
|
||||
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
|
||||
const submitButton = screen.getByRole('button', { name: /sign in/i })
|
||||
|
||||
fireEvent.change(emailInput, { target: { value: 'user@company.com' } })
|
||||
fireEvent.change(passwordInput, { target: { value: 'wrongpassword' } })
|
||||
fireEvent.click(submitButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Invalid email or password')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Forgot Password', () => {
|
||||
it('should open forgot password dialog', () => {
|
||||
render(<LoginPage {...defaultProps} />)
|
||||
|
||||
const forgotPasswordButton = screen.getByText(/forgot password/i)
|
||||
fireEvent.click(forgotPasswordButton)
|
||||
|
||||
expect(screen.getByText('Reset Password')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('URL Parameters', () => {
|
||||
it('should handle invite flow parameter in signup link', () => {
|
||||
mockSearchParams.get.mockImplementation((param) => {
|
||||
if (param === 'invite_flow') return 'true'
|
||||
if (param === 'callbackUrl') return '/invite/123'
|
||||
return null
|
||||
})
|
||||
|
||||
render(<LoginPage {...defaultProps} />)
|
||||
|
||||
const signupLink = screen.getByText(/sign up/i)
|
||||
expect(signupLink).toHaveAttribute('href', '/signup?invite_flow=true&callbackUrl=/invite/123')
|
||||
})
|
||||
|
||||
it('should default to regular signup link when no invite flow', () => {
|
||||
render(<LoginPage {...defaultProps} />)
|
||||
|
||||
const signupLink = screen.getByText(/sign up/i)
|
||||
expect(signupLink).toHaveAttribute('href', '/signup')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Email Verification Flow', () => {
|
||||
it('should redirect to verification page when email not verified', async () => {
|
||||
const mockSignIn = vi.mocked(client.signIn.email)
|
||||
const mockSendOtp = vi.mocked(client.emailOtp.sendVerificationOtp)
|
||||
|
||||
mockSignIn.mockRejectedValue({
|
||||
message: 'Email not verified',
|
||||
code: 'EMAIL_NOT_VERIFIED',
|
||||
})
|
||||
|
||||
mockSendOtp.mockResolvedValue({ data: null, error: null })
|
||||
|
||||
render(<LoginPage {...defaultProps} />)
|
||||
|
||||
const emailInput = screen.getByPlaceholderText(/enter your email/i)
|
||||
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
|
||||
const submitButton = screen.getByRole('button', { name: /sign in/i })
|
||||
|
||||
fireEvent.change(emailInput, { target: { value: 'user@company.com' } })
|
||||
fireEvent.change(passwordInput, { target: { value: 'password123' } })
|
||||
fireEvent.click(submitButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSendOtp).toHaveBeenCalledWith({
|
||||
email: 'user@company.com',
|
||||
type: 'email-verification',
|
||||
})
|
||||
expect(mockRouter.push).toHaveBeenCalledWith('/verify')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -19,6 +19,8 @@ import { quickValidateEmail } from '@/lib/email/validation'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { SocialLoginButtons } from '@/app/(auth)/components/social-login-buttons'
|
||||
import { inter } from '@/app/fonts/inter'
|
||||
import { soehne } from '@/app/fonts/soehne/soehne'
|
||||
|
||||
const logger = createLogger('LoginForm')
|
||||
|
||||
@@ -100,6 +102,7 @@ export default function LoginPage({
|
||||
const [password, setPassword] = useState('')
|
||||
const [passwordErrors, setPasswordErrors] = useState<string[]>([])
|
||||
const [showValidationError, setShowValidationError] = useState(false)
|
||||
const [buttonClass, setButtonClass] = useState('auth-button-gradient')
|
||||
|
||||
// Initialize state for URL parameters
|
||||
const [callbackUrl, setCallbackUrl] = useState('/workspace')
|
||||
@@ -139,6 +142,34 @@ export default function LoginPage({
|
||||
const inviteFlow = searchParams.get('invite_flow') === 'true'
|
||||
setIsInviteFlow(inviteFlow)
|
||||
}
|
||||
|
||||
// Check if CSS variable has been customized
|
||||
const checkCustomBrand = () => {
|
||||
const computedStyle = getComputedStyle(document.documentElement)
|
||||
const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim()
|
||||
|
||||
// Check if the CSS variable exists and is different from the default
|
||||
if (brandAccent && brandAccent !== '#6f3dfa') {
|
||||
setButtonClass('auth-button-custom')
|
||||
} else {
|
||||
setButtonClass('auth-button-gradient')
|
||||
}
|
||||
}
|
||||
|
||||
checkCustomBrand()
|
||||
|
||||
// Also check on window resize or theme changes
|
||||
window.addEventListener('resize', checkCustomBrand)
|
||||
const observer = new MutationObserver(checkCustomBrand)
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['style', 'class'],
|
||||
})
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', checkCustomBrand)
|
||||
observer.disconnect()
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -370,167 +401,172 @@ export default function LoginPage({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
<div className='space-y-2 text-center'>
|
||||
<h1 className='font-semibold text-[32px] text-white tracking-tight'>Sign In</h1>
|
||||
<p className='text-neutral-400 text-sm'>
|
||||
Enter your email below to sign in to your account
|
||||
<>
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
|
||||
Sign in
|
||||
</h1>
|
||||
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
|
||||
Enter your details
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-6'>
|
||||
<div className='rounded-xl border border-neutral-700/40 bg-neutral-800/50 p-6 backdrop-blur-sm'>
|
||||
<SocialLoginButtons
|
||||
googleAvailable={googleAvailable}
|
||||
githubAvailable={githubAvailable}
|
||||
isProduction={isProduction}
|
||||
callbackURL={callbackUrl}
|
||||
/>
|
||||
|
||||
{(githubAvailable || googleAvailable) && (
|
||||
<div className='relative mt-2 py-4'>
|
||||
<div className='absolute inset-0 flex items-center'>
|
||||
<div className='w-full border-neutral-700/50 border-t' />
|
||||
</div>
|
||||
<form onSubmit={onSubmit} className={`${inter.className} mt-8 space-y-8`}>
|
||||
<div className='space-y-6'>
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Label htmlFor='email'>Email</Label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={onSubmit} className='space-y-5'>
|
||||
<div className='space-y-4'>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='email' className='text-neutral-300'>
|
||||
Email
|
||||
</Label>
|
||||
<Input
|
||||
id='email'
|
||||
name='email'
|
||||
placeholder='Enter your email'
|
||||
required
|
||||
autoCapitalize='none'
|
||||
autoComplete='email'
|
||||
autoCorrect='off'
|
||||
value={email}
|
||||
onChange={handleEmailChange}
|
||||
className={cn(
|
||||
'border-neutral-700 bg-neutral-900 text-white placeholder:text-white/60',
|
||||
showEmailValidationError &&
|
||||
emailErrors.length > 0 &&
|
||||
'border-red-500 focus-visible:ring-red-500'
|
||||
)}
|
||||
/>
|
||||
{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>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Label htmlFor='password' className='text-neutral-300'>
|
||||
Password
|
||||
</Label>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setForgotPasswordOpen(true)}
|
||||
className='font-medium text-neutral-400 text-xs transition hover:text-white'
|
||||
>
|
||||
Forgot password?
|
||||
</button>
|
||||
</div>
|
||||
<div className='relative'>
|
||||
<Input
|
||||
id='password'
|
||||
name='password'
|
||||
required
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoCapitalize='none'
|
||||
autoComplete='current-password'
|
||||
autoCorrect='off'
|
||||
placeholder='Enter your password'
|
||||
value={password}
|
||||
onChange={handlePasswordChange}
|
||||
className={cn(
|
||||
'border-neutral-700 bg-neutral-900 pr-10 text-white placeholder:text-white/60',
|
||||
showValidationError &&
|
||||
passwordErrors.length > 0 &&
|
||||
'border-red-500 focus-visible:ring-red-500'
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className='-translate-y-1/2 absolute top-1/2 right-3 text-neutral-400 transition hover:text-white'
|
||||
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||
>
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</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>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Input
|
||||
id='email'
|
||||
name='email'
|
||||
placeholder='Enter your email'
|
||||
required
|
||||
autoCapitalize='none'
|
||||
autoComplete='email'
|
||||
autoCorrect='off'
|
||||
value={email}
|
||||
onChange={handleEmailChange}
|
||||
className={cn(
|
||||
'rounded-[10px] shadow-sm transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100',
|
||||
showEmailValidationError &&
|
||||
emailErrors.length > 0 &&
|
||||
'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500'
|
||||
)}
|
||||
/>
|
||||
{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>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Label htmlFor='password'>Password</Label>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setForgotPasswordOpen(true)}
|
||||
className='font-medium text-muted-foreground text-xs transition hover:text-foreground'
|
||||
>
|
||||
Forgot password?
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type='submit'
|
||||
className='flex h-11 w-full items-center justify-center gap-2 bg-brand-primary font-medium text-base text-white shadow-[var(--brand-primary-hex)]/20 shadow-lg transition-colors duration-200 hover:bg-brand-primary-hover'
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Signing in...' : 'Sign In'}
|
||||
</Button>
|
||||
</form>
|
||||
<div className='relative'>
|
||||
<Input
|
||||
id='password'
|
||||
name='password'
|
||||
required
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoCapitalize='none'
|
||||
autoComplete='current-password'
|
||||
autoCorrect='off'
|
||||
placeholder='Enter your password'
|
||||
value={password}
|
||||
onChange={handlePasswordChange}
|
||||
className={cn(
|
||||
'rounded-[10px] pr-10 shadow-sm transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100',
|
||||
showValidationError &&
|
||||
passwordErrors.length > 0 &&
|
||||
'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500'
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className='-translate-y-1/2 absolute top-1/2 right-3 text-gray-500 transition hover:text-gray-700'
|
||||
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||
>
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</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>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='text-center text-sm'>
|
||||
<span className='text-neutral-400'>Don't have an account? </span>
|
||||
<Link
|
||||
href={isInviteFlow ? `/signup?invite_flow=true&callbackUrl=${callbackUrl}` : '/signup'}
|
||||
className='font-medium text-[var(--brand-accent-hex)] underline-offset-4 transition hover:text-[var(--brand-accent-hover-hex)] hover:underline'
|
||||
>
|
||||
Sign up
|
||||
</Link>
|
||||
</div>
|
||||
<Button
|
||||
type='submit'
|
||||
className={`${buttonClass} flex w-full items-center justify-center gap-2 rounded-[10px] border font-medium text-[15px] text-white transition-all duration-200`}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Signing in...' : 'Sign In'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className='text-center text-neutral-500/80 text-xs leading-relaxed'>
|
||||
By signing in, you agree to our{' '}
|
||||
<Link
|
||||
href='/terms'
|
||||
className='text-neutral-400 underline-offset-4 transition hover:text-neutral-300 hover:underline'
|
||||
>
|
||||
Terms of Service
|
||||
</Link>{' '}
|
||||
and{' '}
|
||||
<Link
|
||||
href='/privacy'
|
||||
className='text-neutral-400 underline-offset-4 transition hover:text-neutral-300 hover:underline'
|
||||
>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
{(githubAvailable || googleAvailable) && (
|
||||
<div className={`${inter.className} relative my-6 font-light`}>
|
||||
<div className='absolute inset-0 flex items-center'>
|
||||
<div className='auth-divider w-full border-t' />
|
||||
</div>
|
||||
<div className='relative flex justify-center text-sm'>
|
||||
<span className='bg-white px-4 font-[340] text-muted-foreground'>Or continue with</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SocialLoginButtons
|
||||
googleAvailable={googleAvailable}
|
||||
githubAvailable={githubAvailable}
|
||||
isProduction={isProduction}
|
||||
callbackURL={callbackUrl}
|
||||
/>
|
||||
|
||||
<div className={`${inter.className} pt-6 text-center font-light text-[14px]`}>
|
||||
<span className='font-normal'>Don't have an account? </span>
|
||||
<Link
|
||||
href={isInviteFlow ? `/signup?invite_flow=true&callbackUrl=${callbackUrl}` : '/signup'}
|
||||
className='font-medium text-[var(--brand-accent-hex)] underline-offset-4 transition hover:text-[var(--brand-accent-hover-hex)] hover:underline'
|
||||
>
|
||||
Sign up
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`${inter.className} auth-text-muted fixed right-0 bottom-0 left-0 z-50 pb-8 text-center font-[340] text-[13px] leading-relaxed`}
|
||||
>
|
||||
By signing in, you agree to our{' '}
|
||||
<Link
|
||||
href='/terms'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='auth-link underline-offset-4 transition hover:underline'
|
||||
>
|
||||
Terms of Service
|
||||
</Link>{' '}
|
||||
and{' '}
|
||||
<Link
|
||||
href='/privacy'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='auth-link underline-offset-4 transition hover:underline'
|
||||
>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Dialog open={forgotPasswordOpen} onOpenChange={setForgotPasswordOpen}>
|
||||
<DialogContent className='border border-neutral-700/50 bg-neutral-800/90 text-white backdrop-blur-sm'>
|
||||
<DialogContent className='auth-card auth-card-shadow max-w-[540px] rounded-[10px] border backdrop-blur-sm'>
|
||||
<DialogHeader>
|
||||
<DialogTitle className='font-semibold text-white text-xl tracking-tight'>
|
||||
<DialogTitle className='auth-text-primary font-semibold text-xl tracking-tight'>
|
||||
Reset Password
|
||||
</DialogTitle>
|
||||
<DialogDescription className='text-neutral-300 text-sm'>
|
||||
<DialogDescription className='auth-text-secondary text-sm'>
|
||||
Enter your email address and we'll send you a link to reset your password if your
|
||||
account exists.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className='space-y-4'>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='reset-email' className='text-neutral-300'>
|
||||
Email
|
||||
</Label>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Label htmlFor='reset-email'>Email</Label>
|
||||
</div>
|
||||
<Input
|
||||
id='reset-email'
|
||||
value={forgotPasswordEmail}
|
||||
@@ -539,8 +575,9 @@ export default function LoginPage({
|
||||
required
|
||||
type='email'
|
||||
className={cn(
|
||||
'border-neutral-700/80 bg-neutral-900 text-white placeholder:text-white/60 focus:border-[var(--brand-primary-hover-hex)]/70 focus:ring-[var(--brand-primary-hover-hex)]/20',
|
||||
resetStatus.type === 'error' && 'border-red-500 focus-visible:ring-red-500'
|
||||
'rounded-[10px] shadow-sm transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100',
|
||||
resetStatus.type === 'error' &&
|
||||
'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500'
|
||||
)}
|
||||
/>
|
||||
{resetStatus.type === 'error' && (
|
||||
@@ -557,7 +594,7 @@ export default function LoginPage({
|
||||
<Button
|
||||
type='button'
|
||||
onClick={handleForgotPassword}
|
||||
className='h-11 w-full bg-[var(--brand-primary-hex)] font-medium text-base text-white shadow-[var(--brand-primary-hex)]/20 shadow-lg transition-colors duration-200 hover:bg-[var(--brand-primary-hover-hex)]'
|
||||
className={`${buttonClass} w-full rounded-[10px] border font-medium text-[15px] text-white transition-all duration-200`}
|
||||
disabled={isSubmittingReset}
|
||||
>
|
||||
{isSubmittingReset ? 'Sending...' : 'Send Reset Link'}
|
||||
@@ -565,6 +602,6 @@ export default function LoginPage({
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,16 +3,10 @@
|
||||
import { Suspense, useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { SetNewPasswordForm } from '@/app/(auth)/reset-password/reset-password-form'
|
||||
import { inter } from '@/app/fonts/inter'
|
||||
import { soehne } from '@/app/fonts/soehne/soehne'
|
||||
|
||||
const logger = createLogger('ResetPasswordPage')
|
||||
|
||||
@@ -82,40 +76,42 @@ function ResetPasswordContent() {
|
||||
}
|
||||
|
||||
return (
|
||||
<main className='flex min-h-screen flex-col items-center justify-center bg-gray-50'>
|
||||
<div className='sm:mx-auto sm:w-full sm:max-w-md'>
|
||||
<h1 className='mb-8 text-center font-bold text-2xl'>Sim</h1>
|
||||
<Card className='w-full'>
|
||||
<CardHeader>
|
||||
<CardTitle>Reset your password</CardTitle>
|
||||
<CardDescription>Enter a new password for your account</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<SetNewPasswordForm
|
||||
token={token}
|
||||
onSubmit={handleResetPassword}
|
||||
isSubmitting={isSubmitting}
|
||||
statusType={statusMessage.type}
|
||||
statusMessage={statusMessage.text}
|
||||
/>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<p className='w-full text-center text-gray-500 text-sm'>
|
||||
<Link href='/login' className='text-muted-foreground hover:underline'>
|
||||
Back to login
|
||||
</Link>
|
||||
</p>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
<>
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
|
||||
Reset your password
|
||||
</h1>
|
||||
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
|
||||
Enter a new password for your account
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div className={`${inter.className} mt-8`}>
|
||||
<SetNewPasswordForm
|
||||
token={token}
|
||||
onSubmit={handleResetPassword}
|
||||
isSubmitting={isSubmitting}
|
||||
statusType={statusMessage.type}
|
||||
statusMessage={statusMessage.text}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`${inter.className} pt-6 text-center font-light text-[14px]`}>
|
||||
<Link
|
||||
href='/login'
|
||||
className='font-medium text-[var(--brand-accent-hex)] underline-offset-4 transition hover:text-[var(--brand-accent-hover-hex)] hover:underline'
|
||||
>
|
||||
Back to login
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={<div className='flex min-h-screen items-center justify-center'>Loading...</div>}
|
||||
fallback={<div className='flex h-screen items-center justify-center'>Loading...</div>}
|
||||
>
|
||||
<ResetPasswordContent />
|
||||
</Suspense>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { inter } from '@/app/fonts/inter'
|
||||
|
||||
interface RequestResetFormProps {
|
||||
email: string
|
||||
@@ -26,25 +27,59 @@ export function RequestResetForm({
|
||||
statusMessage,
|
||||
className,
|
||||
}: RequestResetFormProps) {
|
||||
const [buttonClass, setButtonClass] = useState('auth-button-gradient')
|
||||
|
||||
useEffect(() => {
|
||||
// Check if CSS variable has been customized
|
||||
const checkCustomBrand = () => {
|
||||
const computedStyle = getComputedStyle(document.documentElement)
|
||||
const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim()
|
||||
|
||||
// Check if the CSS variable exists and is different from the default
|
||||
if (brandAccent && brandAccent !== '#6f3dfa') {
|
||||
setButtonClass('auth-button-custom')
|
||||
} else {
|
||||
setButtonClass('auth-button-gradient')
|
||||
}
|
||||
}
|
||||
|
||||
checkCustomBrand()
|
||||
|
||||
// Also check on window resize or theme changes
|
||||
window.addEventListener('resize', checkCustomBrand)
|
||||
const observer = new MutationObserver(checkCustomBrand)
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['style', 'class'],
|
||||
})
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', checkCustomBrand)
|
||||
observer.disconnect()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
onSubmit(email)
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className={className}>
|
||||
<div className='grid gap-4'>
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='reset-email'>Email</Label>
|
||||
<form onSubmit={handleSubmit} className={cn(`${inter.className} space-y-8`, className)}>
|
||||
<div className='space-y-6'>
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Label htmlFor='reset-email'>Email</Label>
|
||||
</div>
|
||||
<Input
|
||||
id='reset-email'
|
||||
value={email}
|
||||
onChange={(e) => onEmailChange(e.target.value)}
|
||||
placeholder='your@email.com'
|
||||
placeholder='Enter your email'
|
||||
type='email'
|
||||
disabled={isSubmitting}
|
||||
required
|
||||
className='placeholder:text-white/60'
|
||||
className='rounded-[10px] shadow-sm transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100'
|
||||
/>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
We'll send a password reset link to this email address.
|
||||
@@ -52,30 +87,22 @@ export function RequestResetForm({
|
||||
</div>
|
||||
|
||||
{/* Status message display */}
|
||||
{statusType && (
|
||||
{statusType && statusMessage && (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-md border p-3 text-sm',
|
||||
statusType === 'success'
|
||||
? 'border-green-200 bg-green-50 text-green-700'
|
||||
: 'border-red-200 bg-red-50 text-red-700'
|
||||
)}
|
||||
className={cn('text-xs', statusType === 'success' ? 'text-[#4CAF50]' : 'text-red-400')}
|
||||
>
|
||||
{statusMessage}
|
||||
<p>{statusMessage}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button type='submit' disabled={isSubmitting} className='w-full'>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||
Sending...
|
||||
</>
|
||||
) : (
|
||||
'Send Reset Link'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type='submit'
|
||||
disabled={isSubmitting}
|
||||
className={`${buttonClass} flex w-full items-center justify-center gap-2 rounded-[10px] border font-medium text-[15px] text-white transition-all duration-200`}
|
||||
>
|
||||
{isSubmitting ? 'Sending...' : 'Send Reset Link'}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -100,6 +127,39 @@ export function SetNewPasswordForm({
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [validationMessage, setValidationMessage] = useState('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
|
||||
const [buttonClass, setButtonClass] = useState('auth-button-gradient')
|
||||
|
||||
useEffect(() => {
|
||||
// Check if CSS variable has been customized
|
||||
const checkCustomBrand = () => {
|
||||
const computedStyle = getComputedStyle(document.documentElement)
|
||||
const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim()
|
||||
|
||||
// Check if the CSS variable exists and is different from the default
|
||||
if (brandAccent && brandAccent !== '#6f3dfa') {
|
||||
setButtonClass('auth-button-custom')
|
||||
} else {
|
||||
setButtonClass('auth-button-gradient')
|
||||
}
|
||||
}
|
||||
|
||||
checkCustomBrand()
|
||||
|
||||
// Also check on window resize or theme changes
|
||||
window.addEventListener('resize', checkCustomBrand)
|
||||
const observer = new MutationObserver(checkCustomBrand)
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['style', 'class'],
|
||||
})
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', checkCustomBrand)
|
||||
observer.disconnect()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
@@ -120,71 +180,98 @@ export function SetNewPasswordForm({
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className={className}>
|
||||
<div className='grid gap-4'>
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='password'>New Password</Label>
|
||||
<Input
|
||||
id='password'
|
||||
type='password'
|
||||
autoCapitalize='none'
|
||||
autoComplete='new-password'
|
||||
autoCorrect='off'
|
||||
disabled={isSubmitting || !token}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
placeholder='Enter new password'
|
||||
className='placeholder:text-white/60'
|
||||
/>
|
||||
<form onSubmit={handleSubmit} className={cn(`${inter.className} space-y-8`, className)}>
|
||||
<div className='space-y-6'>
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Label htmlFor='password'>New Password</Label>
|
||||
</div>
|
||||
<div className='relative'>
|
||||
<Input
|
||||
id='password'
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoCapitalize='none'
|
||||
autoComplete='new-password'
|
||||
autoCorrect='off'
|
||||
disabled={isSubmitting || !token}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
placeholder='Enter new password'
|
||||
className={cn(
|
||||
'rounded-[10px] pr-10 shadow-sm transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100',
|
||||
validationMessage &&
|
||||
'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500'
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className='-translate-y-1/2 absolute top-1/2 right-3 text-gray-500 transition hover:text-gray-700'
|
||||
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||
>
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='confirmPassword'>Confirm Password</Label>
|
||||
<Input
|
||||
id='confirmPassword'
|
||||
type='password'
|
||||
autoCapitalize='none'
|
||||
autoComplete='new-password'
|
||||
autoCorrect='off'
|
||||
disabled={isSubmitting || !token}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
placeholder='Confirm new password'
|
||||
className='placeholder:text-white/60'
|
||||
/>
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Label htmlFor='confirmPassword'>Confirm Password</Label>
|
||||
</div>
|
||||
<div className='relative'>
|
||||
<Input
|
||||
id='confirmPassword'
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
autoCapitalize='none'
|
||||
autoComplete='new-password'
|
||||
autoCorrect='off'
|
||||
disabled={isSubmitting || !token}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
placeholder='Confirm new password'
|
||||
className={cn(
|
||||
'rounded-[10px] pr-10 shadow-sm transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100',
|
||||
validationMessage &&
|
||||
'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500'
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
className='-translate-y-1/2 absolute top-1/2 right-3 text-gray-500 transition hover:text-gray-700'
|
||||
aria-label={showConfirmPassword ? 'Hide password' : 'Show password'}
|
||||
>
|
||||
{showConfirmPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{validationMessage && (
|
||||
<div className='rounded-md border border-red-200 bg-red-50 p-3 text-red-700 text-sm'>
|
||||
{validationMessage}
|
||||
<div className='mt-1 space-y-1 text-red-400 text-xs'>
|
||||
<p>{validationMessage}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{statusType && (
|
||||
{statusType && statusMessage && (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-md border p-3 text-sm',
|
||||
statusType === 'success'
|
||||
? 'border-green-200 bg-green-50 text-green-700'
|
||||
: 'border-red-200 bg-red-50 text-red-700'
|
||||
'mt-1 space-y-1 text-xs',
|
||||
statusType === 'success' ? 'text-[#4CAF50]' : 'text-red-400'
|
||||
)}
|
||||
>
|
||||
{statusMessage}
|
||||
<p>{statusMessage}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button disabled={isSubmitting || !token} type='submit' className='w-full'>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||
Resetting...
|
||||
</>
|
||||
) : (
|
||||
'Reset Password'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
disabled={isSubmitting || !token}
|
||||
type='submit'
|
||||
className={`${buttonClass} flex w-full items-center justify-center gap-2 rounded-[10px] border font-medium text-[15px] text-white transition-all duration-200`}
|
||||
>
|
||||
{isSubmitting ? 'Resetting...' : 'Reset Password'}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,418 +0,0 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { client, useSession } from '@/lib/auth-client'
|
||||
import SignupPage from '@/app/(auth)/signup/signup-form'
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: vi.fn(),
|
||||
useSearchParams: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth-client', () => ({
|
||||
client: {
|
||||
signUp: {
|
||||
email: vi.fn(),
|
||||
},
|
||||
emailOtp: {
|
||||
sendVerificationOtp: vi.fn(),
|
||||
},
|
||||
},
|
||||
useSession: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/(auth)/components/social-login-buttons', () => ({
|
||||
SocialLoginButtons: () => <div data-testid='social-login-buttons'>Social Login Buttons</div>,
|
||||
}))
|
||||
|
||||
const mockRouter = {
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
back: vi.fn(),
|
||||
}
|
||||
|
||||
const mockSearchParams = {
|
||||
get: vi.fn(),
|
||||
}
|
||||
|
||||
describe('SignupPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
;(useRouter as any).mockReturnValue(mockRouter)
|
||||
;(useSearchParams as any).mockReturnValue(mockSearchParams)
|
||||
;(useSession as any).mockReturnValue({
|
||||
refetch: vi.fn().mockResolvedValue({}),
|
||||
})
|
||||
mockSearchParams.get.mockReturnValue(null)
|
||||
})
|
||||
|
||||
const defaultProps = {
|
||||
githubAvailable: true,
|
||||
googleAvailable: true,
|
||||
isProduction: false,
|
||||
}
|
||||
|
||||
describe('Basic Rendering', () => {
|
||||
it('should render signup form with all required elements', () => {
|
||||
render(<SignupPage {...defaultProps} />)
|
||||
|
||||
expect(screen.getByPlaceholderText(/enter your name/i)).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText(/enter your email/i)).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText(/enter your password/i)).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /create account/i })).toBeInTheDocument()
|
||||
expect(screen.getByText(/sign in/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render social login buttons', () => {
|
||||
render(<SignupPage {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId('social-login-buttons')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Password Visibility Toggle', () => {
|
||||
it('should toggle password visibility when button is clicked', () => {
|
||||
render(<SignupPage {...defaultProps} />)
|
||||
|
||||
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
|
||||
const toggleButton = screen.getByLabelText(/show password/i)
|
||||
|
||||
expect(passwordInput).toHaveAttribute('type', 'password')
|
||||
|
||||
fireEvent.click(toggleButton)
|
||||
expect(passwordInput).toHaveAttribute('type', 'text')
|
||||
|
||||
fireEvent.click(toggleButton)
|
||||
expect(passwordInput).toHaveAttribute('type', 'password')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Form Interaction', () => {
|
||||
it('should allow users to type in form fields', () => {
|
||||
render(<SignupPage {...defaultProps} />)
|
||||
|
||||
const nameInput = screen.getByPlaceholderText(/enter your name/i)
|
||||
const emailInput = screen.getByPlaceholderText(/enter your email/i)
|
||||
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
|
||||
|
||||
fireEvent.change(nameInput, { target: { value: 'John Doe' } })
|
||||
fireEvent.change(emailInput, { target: { value: 'user@company.com' } })
|
||||
fireEvent.change(passwordInput, { target: { value: 'Password123!' } })
|
||||
|
||||
expect(nameInput).toHaveValue('John Doe')
|
||||
expect(emailInput).toHaveValue('user@company.com')
|
||||
expect(passwordInput).toHaveValue('Password123!')
|
||||
})
|
||||
|
||||
it('should show loading state during form submission', async () => {
|
||||
const mockSignUp = vi.mocked(client.signUp.email)
|
||||
mockSignUp.mockImplementation(
|
||||
() => new Promise((resolve) => resolve({ data: { user: { id: '1' } }, error: null }))
|
||||
)
|
||||
|
||||
render(<SignupPage {...defaultProps} />)
|
||||
|
||||
const nameInput = screen.getByPlaceholderText(/enter your name/i)
|
||||
const emailInput = screen.getByPlaceholderText(/enter your email/i)
|
||||
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
|
||||
const submitButton = screen.getByRole('button', { name: /create account/i })
|
||||
|
||||
fireEvent.change(nameInput, { target: { value: 'John Doe' } })
|
||||
fireEvent.change(emailInput, { target: { value: 'user@company.com' } })
|
||||
fireEvent.change(passwordInput, { target: { value: 'Password123!' } })
|
||||
fireEvent.click(submitButton)
|
||||
|
||||
expect(screen.getByText('Creating account...')).toBeInTheDocument()
|
||||
expect(submitButton).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Form Submission', () => {
|
||||
it('should call signUp with correct credentials and trimmed name', async () => {
|
||||
const mockSignUp = vi.mocked(client.signUp.email)
|
||||
const mockSendOtp = vi.mocked(client.emailOtp.sendVerificationOtp)
|
||||
|
||||
mockSignUp.mockResolvedValue({ data: { user: { id: '1' } }, error: null })
|
||||
mockSendOtp.mockResolvedValue({ data: null, error: null })
|
||||
|
||||
render(<SignupPage {...defaultProps} />)
|
||||
|
||||
const nameInput = screen.getByPlaceholderText(/enter your name/i)
|
||||
const emailInput = screen.getByPlaceholderText(/enter your email/i)
|
||||
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
|
||||
const submitButton = screen.getByRole('button', { name: /create account/i })
|
||||
|
||||
// Use valid input that passes all validation rules
|
||||
fireEvent.change(nameInput, { target: { value: 'John Doe' } })
|
||||
fireEvent.change(emailInput, { target: { value: 'user@company.com' } })
|
||||
fireEvent.change(passwordInput, { target: { value: 'Password123!' } })
|
||||
fireEvent.click(submitButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSignUp).toHaveBeenCalledWith(
|
||||
{
|
||||
email: 'user@company.com',
|
||||
password: 'Password123!',
|
||||
name: 'John Doe',
|
||||
},
|
||||
expect.objectContaining({
|
||||
onError: expect.any(Function),
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should automatically trim spaces from name input', async () => {
|
||||
const mockSignUp = vi.mocked(client.signUp.email)
|
||||
mockSignUp.mockResolvedValue({ data: null, error: null })
|
||||
|
||||
render(<SignupPage {...defaultProps} />)
|
||||
|
||||
const nameInput = screen.getByPlaceholderText(/enter your name/i)
|
||||
const emailInput = screen.getByPlaceholderText(/enter your email/i)
|
||||
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
|
||||
const submitButton = screen.getByRole('button', { name: /create account/i })
|
||||
|
||||
fireEvent.change(nameInput, { target: { value: ' John Doe ' } })
|
||||
fireEvent.change(emailInput, { target: { value: 'user@company.com' } })
|
||||
fireEvent.change(passwordInput, { target: { value: 'Password123!' } })
|
||||
fireEvent.click(submitButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSignUp).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'John Doe',
|
||||
email: 'user@company.com',
|
||||
password: 'Password123!',
|
||||
}),
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should redirect to verification page after successful signup', async () => {
|
||||
const mockSignUp = vi.mocked(client.signUp.email)
|
||||
const mockSendOtp = vi.mocked(client.emailOtp.sendVerificationOtp)
|
||||
|
||||
mockSignUp.mockResolvedValue({ data: { user: { id: '1' } }, error: null })
|
||||
mockSendOtp.mockResolvedValue({ data: null, error: null })
|
||||
|
||||
render(<SignupPage {...defaultProps} />)
|
||||
|
||||
const nameInput = screen.getByPlaceholderText(/enter your name/i)
|
||||
const emailInput = screen.getByPlaceholderText(/enter your email/i)
|
||||
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
|
||||
const submitButton = screen.getByRole('button', { name: /create account/i })
|
||||
|
||||
fireEvent.change(nameInput, { target: { value: 'John Doe' } })
|
||||
fireEvent.change(emailInput, { target: { value: 'user@company.com' } })
|
||||
fireEvent.change(passwordInput, { target: { value: 'Password123!' } })
|
||||
fireEvent.click(submitButton)
|
||||
|
||||
await waitFor(() => {
|
||||
// With sendVerificationOnSignUp: true, OTP is sent automatically by Better Auth
|
||||
// No manual OTP sending in the component anymore
|
||||
expect(mockRouter.push).toHaveBeenCalledWith('/verify?fromSignup=true')
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle signup errors', async () => {
|
||||
const mockSignUp = vi.mocked(client.signUp.email)
|
||||
|
||||
mockSignUp.mockImplementation((credentials, options) => {
|
||||
if (options?.onError) {
|
||||
options.onError({
|
||||
error: {
|
||||
code: 'USER_ALREADY_EXISTS',
|
||||
message: 'User already exists',
|
||||
} as any,
|
||||
response: {} as any,
|
||||
request: {} as any,
|
||||
} as any)
|
||||
}
|
||||
return Promise.resolve({ data: null, error: 'User already exists' })
|
||||
})
|
||||
|
||||
render(<SignupPage {...defaultProps} />)
|
||||
|
||||
const nameInput = screen.getByPlaceholderText(/enter your name/i)
|
||||
const emailInput = screen.getByPlaceholderText(/enter your email/i)
|
||||
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
|
||||
const submitButton = screen.getByRole('button', { name: /create account/i })
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(nameInput, { target: { value: 'John Doe' } })
|
||||
fireEvent.change(emailInput, { target: { value: 'existing@example.com' } })
|
||||
fireEvent.change(passwordInput, { target: { value: 'Password123!' } })
|
||||
fireEvent.click(submitButton)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Failed to create account')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show warning for names that would be truncated (over 100 characters)', async () => {
|
||||
const mockSignUp = vi.mocked(client.signUp.email)
|
||||
const longName = 'a'.repeat(101) // 101 characters
|
||||
|
||||
render(<SignupPage {...defaultProps} />)
|
||||
|
||||
const nameInput = screen.getByPlaceholderText(/enter your name/i)
|
||||
const emailInput = screen.getByPlaceholderText(/enter your email/i)
|
||||
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
|
||||
const submitButton = screen.getByRole('button', { name: /create account/i })
|
||||
|
||||
fireEvent.change(nameInput, { target: { value: longName } })
|
||||
fireEvent.change(emailInput, { target: { value: 'user@company.com' } })
|
||||
fireEvent.change(passwordInput, { target: { value: 'ValidPass123!' } })
|
||||
fireEvent.click(submitButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/name will be truncated to 100 characters/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Ensure signUp was not called
|
||||
expect(mockSignUp).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle names exactly at 100 characters without warning', async () => {
|
||||
const mockSignUp = vi.mocked(client.signUp.email)
|
||||
mockSignUp.mockImplementation(
|
||||
() => new Promise((resolve) => resolve({ data: { user: { id: '1' } }, error: null }))
|
||||
)
|
||||
|
||||
const exactLengthName = 'a'.repeat(100) // Exactly 100 characters
|
||||
|
||||
render(<SignupPage {...defaultProps} />)
|
||||
|
||||
const nameInput = screen.getByPlaceholderText(/enter your name/i)
|
||||
const emailInput = screen.getByPlaceholderText(/enter your email/i)
|
||||
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
|
||||
const submitButton = screen.getByRole('button', { name: /create account/i })
|
||||
|
||||
fireEvent.change(nameInput, { target: { value: exactLengthName } })
|
||||
fireEvent.change(emailInput, { target: { value: 'user@company.com' } })
|
||||
fireEvent.change(passwordInput, { target: { value: 'ValidPass123!' } })
|
||||
fireEvent.click(submitButton)
|
||||
|
||||
// Should not show truncation warning
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/name will be truncated/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Should proceed with form submission
|
||||
await waitFor(() => {
|
||||
expect(mockSignUp).toHaveBeenCalledWith(
|
||||
{
|
||||
email: 'user@company.com',
|
||||
password: 'ValidPass123!',
|
||||
name: exactLengthName,
|
||||
},
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle names exactly at validation errors', async () => {
|
||||
const mockSignUp = vi.mocked(client.signUp.email)
|
||||
|
||||
mockSignUp.mockImplementation((credentials, options) => {
|
||||
if (options?.onError) {
|
||||
options.onError({
|
||||
error: {
|
||||
code: 'NAME_VALIDATION_ERROR',
|
||||
message: 'Name validation error',
|
||||
} as any,
|
||||
response: {} as any,
|
||||
request: {} as any,
|
||||
} as any)
|
||||
}
|
||||
return Promise.resolve({ data: null, error: 'Name validation error' })
|
||||
})
|
||||
|
||||
render(<SignupPage {...defaultProps} />)
|
||||
|
||||
const nameInput = screen.getByPlaceholderText(/enter your name/i)
|
||||
const emailInput = screen.getByPlaceholderText(/enter your email/i)
|
||||
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
|
||||
const submitButton = screen.getByRole('button', { name: /create account/i })
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(nameInput, { target: { value: 'John Doe' } })
|
||||
fireEvent.change(emailInput, { target: { value: 'user@company.com' } })
|
||||
fireEvent.change(passwordInput, { target: { value: 'Password123!' } })
|
||||
fireEvent.click(submitButton)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Failed to create account')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('URL Parameters', () => {
|
||||
it('should prefill email from URL parameter', () => {
|
||||
mockSearchParams.get.mockImplementation((param) => {
|
||||
if (param === 'email') return 'prefilled@example.com'
|
||||
return null
|
||||
})
|
||||
|
||||
render(<SignupPage {...defaultProps} />)
|
||||
|
||||
const emailInput = screen.getByPlaceholderText(/enter your email/i)
|
||||
expect(emailInput).toHaveValue('prefilled@example.com')
|
||||
})
|
||||
|
||||
it('should handle invite flow redirect', async () => {
|
||||
mockSearchParams.get.mockImplementation((param) => {
|
||||
if (param === 'redirect') return '/invite/123'
|
||||
if (param === 'invite_flow') return 'true'
|
||||
return null
|
||||
})
|
||||
|
||||
const mockSignUp = vi.mocked(client.signUp.email)
|
||||
mockSignUp.mockResolvedValue({ data: { user: { id: '1' } }, error: null })
|
||||
|
||||
render(<SignupPage {...defaultProps} />)
|
||||
|
||||
const nameInput = screen.getByPlaceholderText(/enter your name/i)
|
||||
const emailInput = screen.getByPlaceholderText(/enter your email/i)
|
||||
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
|
||||
const submitButton = screen.getByRole('button', { name: /create account/i })
|
||||
|
||||
fireEvent.change(nameInput, { target: { value: 'John Doe' } })
|
||||
fireEvent.change(emailInput, { target: { value: 'user@company.com' } })
|
||||
fireEvent.change(passwordInput, { target: { value: 'Password123!' } })
|
||||
fireEvent.click(submitButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRouter.push).toHaveBeenCalledWith('/verify?fromSignup=true')
|
||||
})
|
||||
})
|
||||
|
||||
it('should link to login with invite flow parameters', () => {
|
||||
mockSearchParams.get.mockImplementation((param) => {
|
||||
if (param === 'invite_flow') return 'true'
|
||||
if (param === 'redirect') return '/invite/123'
|
||||
return null
|
||||
})
|
||||
|
||||
render(<SignupPage {...defaultProps} />)
|
||||
|
||||
const loginLink = screen.getByText(/sign in/i)
|
||||
expect(loginLink).toHaveAttribute('href', '/login?invite_flow=true&callbackUrl=/invite/123')
|
||||
})
|
||||
|
||||
it('should default to regular login link when no invite flow', () => {
|
||||
render(<SignupPage {...defaultProps} />)
|
||||
|
||||
const loginLink = screen.getByText(/sign in/i)
|
||||
expect(loginLink).toHaveAttribute('href', '/login')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -12,6 +12,8 @@ import { quickValidateEmail } from '@/lib/email/validation'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { SocialLoginButtons } from '@/app/(auth)/components/social-login-buttons'
|
||||
import { inter } from '@/app/fonts/inter'
|
||||
import { soehne } from '@/app/fonts/soehne/soehne'
|
||||
|
||||
const logger = createLogger('SignupForm')
|
||||
|
||||
@@ -91,6 +93,7 @@ function SignupFormContent({
|
||||
const [showEmailValidationError, setShowEmailValidationError] = useState(false)
|
||||
const [redirectUrl, setRedirectUrl] = useState('')
|
||||
const [isInviteFlow, setIsInviteFlow] = useState(false)
|
||||
const [buttonClass, setButtonClass] = useState('auth-button-gradient')
|
||||
|
||||
// Name validation state
|
||||
const [name, setName] = useState('')
|
||||
@@ -120,6 +123,34 @@ function SignupFormContent({
|
||||
if (inviteFlowParam === 'true') {
|
||||
setIsInviteFlow(true)
|
||||
}
|
||||
|
||||
// Check if CSS variable has been customized
|
||||
const checkCustomBrand = () => {
|
||||
const computedStyle = getComputedStyle(document.documentElement)
|
||||
const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim()
|
||||
|
||||
// Check if the CSS variable exists and is different from the default
|
||||
if (brandAccent && brandAccent !== '#6f3dfa') {
|
||||
setButtonClass('auth-button-custom')
|
||||
} else {
|
||||
setButtonClass('auth-button-gradient')
|
||||
}
|
||||
}
|
||||
|
||||
checkCustomBrand()
|
||||
|
||||
// Also check on window resize or theme changes
|
||||
window.addEventListener('resize', checkCustomBrand)
|
||||
const observer = new MutationObserver(checkCustomBrand)
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['style', 'class'],
|
||||
})
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', checkCustomBrand)
|
||||
observer.disconnect()
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
// Validate password and return array of error messages
|
||||
@@ -361,166 +392,180 @@ function SignupFormContent({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
<div className='space-y-2 text-center'>
|
||||
<h1 className='font-semibold text-[32px] text-white tracking-tight'>Create Account</h1>
|
||||
<p className='text-neutral-400 text-sm'>Enter your details to create a new account</p>
|
||||
<>
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
|
||||
Create an account
|
||||
</h1>
|
||||
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
|
||||
Create an account or log in
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-6'>
|
||||
<div className='rounded-xl border border-neutral-700/40 bg-neutral-800/50 p-6 backdrop-blur-sm'>
|
||||
<SocialLoginButtons
|
||||
githubAvailable={githubAvailable}
|
||||
googleAvailable={googleAvailable}
|
||||
callbackURL={redirectUrl || '/workspace'}
|
||||
isProduction={isProduction}
|
||||
/>
|
||||
|
||||
{(githubAvailable || googleAvailable) && (
|
||||
<div className='relative mt-2 py-4'>
|
||||
<div className='absolute inset-0 flex items-center'>
|
||||
<div className='w-full border-neutral-700/50 border-t' />
|
||||
</div>
|
||||
<form onSubmit={onSubmit} className={`${inter.className} mt-8 space-y-8`}>
|
||||
<div className='space-y-6'>
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Label htmlFor='name'>Full Name</Label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={onSubmit} className='space-y-5'>
|
||||
<div className='space-y-4'>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='name' className='text-neutral-300'>
|
||||
Full Name
|
||||
</Label>
|
||||
<Input
|
||||
id='name'
|
||||
name='name'
|
||||
placeholder='Enter your name'
|
||||
type='text'
|
||||
autoCapitalize='words'
|
||||
autoComplete='name'
|
||||
title='Name can only contain letters, spaces, hyphens, and apostrophes'
|
||||
value={name}
|
||||
onChange={handleNameChange}
|
||||
className={cn(
|
||||
'border-neutral-700 bg-neutral-900 text-white placeholder:text-white/60',
|
||||
showNameValidationError &&
|
||||
nameErrors.length > 0 &&
|
||||
'border-red-500 focus-visible:ring-red-500'
|
||||
)}
|
||||
/>
|
||||
{showNameValidationError && nameErrors.length > 0 && (
|
||||
<div className='mt-1 space-y-1 text-red-400 text-xs'>
|
||||
{nameErrors.map((error, index) => (
|
||||
<p key={index}>{error}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='email' className='text-neutral-300'>
|
||||
Email
|
||||
</Label>
|
||||
<Input
|
||||
id='email'
|
||||
name='email'
|
||||
placeholder='Enter your email'
|
||||
autoCapitalize='none'
|
||||
autoComplete='email'
|
||||
autoCorrect='off'
|
||||
value={email}
|
||||
onChange={handleEmailChange}
|
||||
className={cn(
|
||||
'border-neutral-700 bg-neutral-900 text-white placeholder:text-white/60',
|
||||
(emailError || (showEmailValidationError && emailErrors.length > 0)) &&
|
||||
'border-red-500 focus-visible:ring-red-500'
|
||||
)}
|
||||
/>
|
||||
{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>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{emailError && !showEmailValidationError && (
|
||||
<div className='mt-1 text-red-400 text-xs'>
|
||||
<p>{emailError}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='password' className='text-neutral-300'>
|
||||
Password
|
||||
</Label>
|
||||
<div className='relative'>
|
||||
<Input
|
||||
id='password'
|
||||
name='password'
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoCapitalize='none'
|
||||
autoComplete='new-password'
|
||||
placeholder='Enter your password'
|
||||
autoCorrect='off'
|
||||
value={password}
|
||||
onChange={handlePasswordChange}
|
||||
className='border-neutral-700 bg-neutral-900 pr-10 text-white placeholder:text-white/60'
|
||||
/>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className='-translate-y-1/2 absolute top-1/2 right-3 text-neutral-400 transition hover:text-white'
|
||||
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||
>
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</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>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Input
|
||||
id='name'
|
||||
name='name'
|
||||
placeholder='Enter your name'
|
||||
type='text'
|
||||
autoCapitalize='words'
|
||||
autoComplete='name'
|
||||
title='Name can only contain letters, spaces, hyphens, and apostrophes'
|
||||
value={name}
|
||||
onChange={handleNameChange}
|
||||
className={cn(
|
||||
'rounded-[10px] shadow-sm transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100',
|
||||
showNameValidationError &&
|
||||
nameErrors.length > 0 &&
|
||||
'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500'
|
||||
)}
|
||||
/>
|
||||
{showNameValidationError && nameErrors.length > 0 && (
|
||||
<div className='mt-1 space-y-1 text-red-400 text-xs'>
|
||||
{nameErrors.map((error, index) => (
|
||||
<p key={index}>{error}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Label htmlFor='email'>Email</Label>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type='submit'
|
||||
className='flex h-11 w-full items-center justify-center gap-2 bg-[var(--brand-primary-hex)] font-medium text-base text-white shadow-[var(--brand-primary-hex)]/20 shadow-lg transition-colors duration-200 hover:bg-[var(--brand-primary-hover-hex)]'
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Creating account...' : 'Create Account'}
|
||||
</Button>
|
||||
</form>
|
||||
<Input
|
||||
id='email'
|
||||
name='email'
|
||||
placeholder='Enter your email'
|
||||
autoCapitalize='none'
|
||||
autoComplete='email'
|
||||
autoCorrect='off'
|
||||
value={email}
|
||||
onChange={handleEmailChange}
|
||||
className={cn(
|
||||
'rounded-[10px] shadow-sm transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100',
|
||||
(emailError || (showEmailValidationError && emailErrors.length > 0)) &&
|
||||
'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500'
|
||||
)}
|
||||
/>
|
||||
{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>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{emailError && !showEmailValidationError && (
|
||||
<div className='mt-1 text-red-400 text-xs'>
|
||||
<p>{emailError}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Label htmlFor='password'>Password</Label>
|
||||
</div>
|
||||
<div className='relative'>
|
||||
<Input
|
||||
id='password'
|
||||
name='password'
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoCapitalize='none'
|
||||
autoComplete='new-password'
|
||||
placeholder='Enter your password'
|
||||
autoCorrect='off'
|
||||
value={password}
|
||||
onChange={handlePasswordChange}
|
||||
className={cn(
|
||||
'rounded-[10px] pr-10 shadow-sm transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100',
|
||||
showValidationError &&
|
||||
passwordErrors.length > 0 &&
|
||||
'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500'
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className='-translate-y-1/2 absolute top-1/2 right-3 text-gray-500 transition hover:text-gray-700'
|
||||
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||
>
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</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>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='text-center text-sm'>
|
||||
<span className='text-neutral-400'>Already have an account? </span>
|
||||
<Link
|
||||
href={isInviteFlow ? `/login?invite_flow=true&callbackUrl=${redirectUrl}` : '/login'}
|
||||
className='font-medium text-[var(--brand-accent-hex)] underline-offset-4 transition hover:text-[var(--brand-accent-hover-hex)] hover:underline'
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</div>
|
||||
<Button
|
||||
type='submit'
|
||||
className={`${buttonClass} flex w-full items-center justify-center gap-2 rounded-[10px] border font-medium text-[15px] text-white transition-all duration-200`}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Creating account...' : 'Create Account'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className='text-center text-neutral-500/80 text-xs leading-relaxed'>
|
||||
By creating an account, you agree to our{' '}
|
||||
<Link
|
||||
href='/terms'
|
||||
className='text-neutral-400 underline-offset-4 transition hover:text-neutral-300 hover:underline'
|
||||
>
|
||||
Terms of Service
|
||||
</Link>{' '}
|
||||
and{' '}
|
||||
<Link
|
||||
href='/privacy'
|
||||
className='text-neutral-400 underline-offset-4 transition hover:text-neutral-300 hover:underline'
|
||||
>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
{(githubAvailable || googleAvailable) && (
|
||||
<div className={`${inter.className} relative my-6 font-light`}>
|
||||
<div className='absolute inset-0 flex items-center'>
|
||||
<div className='auth-divider w-full border-t' />
|
||||
</div>
|
||||
<div className='relative flex justify-center text-sm'>
|
||||
<span className='bg-white px-4 font-[340] text-muted-foreground'>Or continue with</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SocialLoginButtons
|
||||
githubAvailable={githubAvailable}
|
||||
googleAvailable={googleAvailable}
|
||||
callbackURL={redirectUrl || '/workspace'}
|
||||
isProduction={isProduction}
|
||||
/>
|
||||
|
||||
<div className={`${inter.className} pt-6 text-center font-light text-[14px]`}>
|
||||
<span className='font-normal'>Already have an account? </span>
|
||||
<Link
|
||||
href={isInviteFlow ? `/login?invite_flow=true&callbackUrl=${redirectUrl}` : '/login'}
|
||||
className='font-medium text-[var(--brand-accent-hex)] underline-offset-4 transition hover:text-[var(--brand-accent-hover-hex)] hover:underline'
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`${inter.className} auth-text-muted fixed right-0 bottom-0 left-0 z-50 pb-8 text-center font-[340] text-[13px] leading-relaxed`}
|
||||
>
|
||||
By creating an account, you agree to our{' '}
|
||||
<Link
|
||||
href='/terms'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='auth-link underline-offset-4 transition hover:underline'
|
||||
>
|
||||
Terms of Service
|
||||
</Link>{' '}
|
||||
and{' '}
|
||||
<Link
|
||||
href='/privacy'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='auth-link underline-offset-4 transition hover:underline'
|
||||
>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,13 @@ export function useVerification({
|
||||
const storedEmail = sessionStorage.getItem('verificationEmail')
|
||||
if (storedEmail) {
|
||||
setEmail(storedEmail)
|
||||
} else {
|
||||
// If no email is stored, the verification session is invalid
|
||||
// Clear the verification requirement and redirect to login
|
||||
document.cookie =
|
||||
'requiresEmailVerification=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT'
|
||||
window.location.href = '/login'
|
||||
return
|
||||
}
|
||||
|
||||
// Check for redirect information
|
||||
@@ -226,6 +233,30 @@ export function useVerification({
|
||||
}
|
||||
}, [otp, email, isLoading, isVerified])
|
||||
|
||||
// Clean up verification state on unmount or after 15 minutes
|
||||
useEffect(() => {
|
||||
const cleanupTimer = setTimeout(
|
||||
() => {
|
||||
// Clear verification state after 15 minutes
|
||||
if (typeof window !== 'undefined' && !isVerified) {
|
||||
sessionStorage.removeItem('verificationEmail')
|
||||
sessionStorage.removeItem('inviteRedirectUrl')
|
||||
sessionStorage.removeItem('isInviteFlow')
|
||||
document.cookie =
|
||||
'requiresEmailVerification=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT'
|
||||
|
||||
// Redirect to login
|
||||
window.location.href = '/login'
|
||||
}
|
||||
},
|
||||
15 * 60 * 1000
|
||||
) // 15 minutes
|
||||
|
||||
return () => {
|
||||
clearTimeout(cleanupTimer)
|
||||
}
|
||||
}, [isVerified])
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
if (!isProduction || !hasResendKey) {
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import { Suspense, useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/ui/input-otp'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useVerification } from '@/app/(auth)/verify/use-verification'
|
||||
import { inter } from '@/app/fonts/inter'
|
||||
import { soehne } from '@/app/fonts/soehne/soehne'
|
||||
|
||||
interface VerifyContentProps {
|
||||
hasResendKey: boolean
|
||||
@@ -45,19 +48,68 @@ function VerificationForm({
|
||||
}
|
||||
}, [countdown, isResendDisabled])
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const handleResend = () => {
|
||||
resendCode()
|
||||
setIsResendDisabled(true)
|
||||
setCountdown(30)
|
||||
}
|
||||
|
||||
const handleCancelVerification = () => {
|
||||
// Clear verification data
|
||||
if (typeof window !== 'undefined') {
|
||||
sessionStorage.removeItem('verificationEmail')
|
||||
sessionStorage.removeItem('inviteRedirectUrl')
|
||||
sessionStorage.removeItem('isInviteFlow')
|
||||
|
||||
// Clear the verification requirement cookie
|
||||
document.cookie = 'requiresEmailVerification=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT'
|
||||
}
|
||||
|
||||
// Redirect to login
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
const [buttonClass, setButtonClass] = useState('auth-button-gradient')
|
||||
|
||||
useEffect(() => {
|
||||
// Check if CSS variable has been customized
|
||||
const checkCustomBrand = () => {
|
||||
const computedStyle = getComputedStyle(document.documentElement)
|
||||
const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim()
|
||||
|
||||
// Check if the CSS variable exists and is different from the default
|
||||
if (brandAccent && brandAccent !== '#6f3dfa') {
|
||||
setButtonClass('auth-button-custom')
|
||||
} else {
|
||||
setButtonClass('auth-button-gradient')
|
||||
}
|
||||
}
|
||||
|
||||
checkCustomBrand()
|
||||
|
||||
// Also check on window resize or theme changes
|
||||
window.addEventListener('resize', checkCustomBrand)
|
||||
const observer = new MutationObserver(checkCustomBrand)
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['style', 'class'],
|
||||
})
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', checkCustomBrand)
|
||||
observer.disconnect()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
<div className='space-y-2 text-center'>
|
||||
<h1 className='font-semibold text-[32px] text-white tracking-tight'>
|
||||
<>
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
|
||||
{isVerified ? 'Email Verified!' : 'Verify Your Email'}
|
||||
</h1>
|
||||
<p className='text-neutral-400 text-sm'>
|
||||
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
|
||||
{isVerified
|
||||
? 'Your email has been verified. Redirecting to dashboard...'
|
||||
: hasResendKey
|
||||
@@ -69,47 +121,75 @@ function VerificationForm({
|
||||
</div>
|
||||
|
||||
{!isVerified && (
|
||||
<div className='flex flex-col gap-6'>
|
||||
<div className='rounded-xl border border-neutral-700/40 bg-neutral-800/50 p-6 backdrop-blur-sm'>
|
||||
<p className='mb-4 text-neutral-400 text-sm'>
|
||||
<div className={`${inter.className} mt-8 space-y-8`}>
|
||||
<div className='space-y-6'>
|
||||
<p className='text-center text-muted-foreground text-sm'>
|
||||
Enter the 6-digit code to verify your account.
|
||||
{hasResendKey ? " If you don't see it in your inbox, check your spam folder." : ''}
|
||||
</p>
|
||||
|
||||
<div className='flex justify-center py-4'>
|
||||
<div className='flex justify-center'>
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
value={otp}
|
||||
onChange={handleOtpChange}
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
isInvalidOtp ? 'border-red-500 focus-visible:ring-red-500' : 'border-neutral-700'
|
||||
)}
|
||||
className={cn('gap-2', isInvalidOtp && 'otp-error')}
|
||||
>
|
||||
<InputOTPGroup>
|
||||
<InputOTPGroup className='[&>div]:!rounded-[10px] gap-2'>
|
||||
<InputOTPSlot
|
||||
index={0}
|
||||
className='border-neutral-700 bg-neutral-900 text-white'
|
||||
className={cn(
|
||||
'!rounded-[10px] h-12 w-12 border bg-white text-center font-medium text-lg shadow-sm transition-all duration-200',
|
||||
'border-gray-300 hover:border-gray-400',
|
||||
'focus:border-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-100',
|
||||
isInvalidOtp && 'border-red-500 focus:border-red-500 focus:ring-red-100'
|
||||
)}
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={1}
|
||||
className='border-neutral-700 bg-neutral-900 text-white'
|
||||
className={cn(
|
||||
'!rounded-[10px] h-12 w-12 border bg-white text-center font-medium text-lg shadow-sm transition-all duration-200',
|
||||
'border-gray-300 hover:border-gray-400',
|
||||
'focus:border-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-100',
|
||||
isInvalidOtp && 'border-red-500 focus:border-red-500 focus:ring-red-100'
|
||||
)}
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={2}
|
||||
className='border-neutral-700 bg-neutral-900 text-white'
|
||||
className={cn(
|
||||
'!rounded-[10px] h-12 w-12 border bg-white text-center font-medium text-lg shadow-sm transition-all duration-200',
|
||||
'border-gray-300 hover:border-gray-400',
|
||||
'focus:border-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-100',
|
||||
isInvalidOtp && 'border-red-500 focus:border-red-500 focus:ring-red-100'
|
||||
)}
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={3}
|
||||
className='border-neutral-700 bg-neutral-900 text-white'
|
||||
className={cn(
|
||||
'!rounded-[10px] h-12 w-12 border bg-white text-center font-medium text-lg shadow-sm transition-all duration-200',
|
||||
'border-gray-300 hover:border-gray-400',
|
||||
'focus:border-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-100',
|
||||
isInvalidOtp && 'border-red-500 focus:border-red-500 focus:ring-red-100'
|
||||
)}
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={4}
|
||||
className='border-neutral-700 bg-neutral-900 text-white'
|
||||
className={cn(
|
||||
'!rounded-[10px] h-12 w-12 border bg-white text-center font-medium text-lg shadow-sm transition-all duration-200',
|
||||
'border-gray-300 hover:border-gray-400',
|
||||
'focus:border-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-100',
|
||||
isInvalidOtp && 'border-red-500 focus:border-red-500 focus:ring-red-100'
|
||||
)}
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={5}
|
||||
className='border-neutral-700 bg-neutral-900 text-white'
|
||||
className={cn(
|
||||
'!rounded-[10px] h-12 w-12 border bg-white text-center font-medium text-lg shadow-sm transition-all duration-200',
|
||||
'border-gray-300 hover:border-gray-400',
|
||||
'focus:border-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-100',
|
||||
isInvalidOtp && 'border-red-500 focus:border-red-500 focus:ring-red-100'
|
||||
)}
|
||||
/>
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
@@ -117,53 +197,62 @@ function VerificationForm({
|
||||
|
||||
{/* Error message */}
|
||||
{errorMessage && (
|
||||
<div className='mt-2 mb-4 rounded-md border border-red-900/20 bg-red-900/10 py-2 text-center'>
|
||||
<p className='font-medium text-red-400 text-sm'>{errorMessage}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={verifyCode}
|
||||
className='h-11 w-full bg-[var(--brand-primary-hex)] font-medium text-base text-white shadow-[var(--brand-primary-hex)]/20 shadow-lg transition-colors duration-200 hover:bg-[var(--brand-primary-hover-hex)]'
|
||||
disabled={!isOtpComplete || isLoading}
|
||||
>
|
||||
{isLoading ? 'Verifying...' : 'Verify Email'}
|
||||
</Button>
|
||||
|
||||
{hasResendKey && (
|
||||
<div className='mt-4 text-center'>
|
||||
<p className='text-neutral-400 text-sm'>
|
||||
Didn't receive a code?{' '}
|
||||
{countdown > 0 ? (
|
||||
<span>
|
||||
Resend in <span className='font-medium text-neutral-300'>{countdown}s</span>
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
className='font-medium text-[var(--brand-accent-hex)] underline-offset-4 transition hover:text-[var(--brand-accent-hover-hex)] hover:underline'
|
||||
onClick={handleResend}
|
||||
disabled={isLoading || isResendDisabled}
|
||||
>
|
||||
Resend
|
||||
</button>
|
||||
)}
|
||||
</p>
|
||||
<div className='mt-1 space-y-1 text-center text-red-400 text-xs'>
|
||||
<p>{errorMessage}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={verifyCode}
|
||||
className={`${buttonClass} flex w-full items-center justify-center gap-2 rounded-[10px] border font-medium text-[15px] text-white transition-all duration-200`}
|
||||
disabled={!isOtpComplete || isLoading}
|
||||
>
|
||||
{isLoading ? 'Verifying...' : 'Verify Email'}
|
||||
</Button>
|
||||
|
||||
{hasResendKey && (
|
||||
<div className='text-center'>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
Didn't receive a code?{' '}
|
||||
{countdown > 0 ? (
|
||||
<span>
|
||||
Resend in <span className='font-medium text-foreground'>{countdown}s</span>
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
className='font-medium text-[var(--brand-accent-hex)] underline-offset-4 transition hover:text-[var(--brand-accent-hover-hex)] hover:underline'
|
||||
onClick={handleResend}
|
||||
disabled={isLoading || isResendDisabled}
|
||||
>
|
||||
Resend
|
||||
</button>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='text-center font-light text-[14px]'>
|
||||
<button
|
||||
onClick={handleCancelVerification}
|
||||
className='font-medium text-[var(--brand-accent-hex)] underline-offset-4 transition hover:text-[var(--brand-accent-hover-hex)] hover:underline'
|
||||
>
|
||||
Back to login
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// Fallback component while the verification form is loading
|
||||
function VerificationFormFallback() {
|
||||
return (
|
||||
<div className='p-8 text-center'>
|
||||
<div className='text-center'>
|
||||
<div className='animate-pulse'>
|
||||
<div className='mx-auto mb-4 h-8 w-48 rounded bg-neutral-800' />
|
||||
<div className='mx-auto h-4 w-64 rounded bg-neutral-800' />
|
||||
<div className='mx-auto mb-4 h-8 w-48 rounded bg-gray-200' />
|
||||
<div className='mx-auto h-4 w-64 rounded bg-gray-200' />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,204 +1,26 @@
|
||||
'use server'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
import { env } from '@/lib/env'
|
||||
const DEFAULT_STARS = '15k'
|
||||
|
||||
/**
|
||||
* Format a number to a human-readable format (e.g., 1000 -> 1k, 1100 -> 1.1k)
|
||||
*/
|
||||
function formatNumber(num: number): string {
|
||||
if (num < 1000) {
|
||||
return num.toString()
|
||||
}
|
||||
const logger = createLogger('GitHubStars')
|
||||
|
||||
const formatted = (Math.round(num / 100) / 10).toFixed(1)
|
||||
|
||||
return formatted.endsWith('.0') ? `${formatted.slice(0, -2)}k` : `${formatted}k`
|
||||
}
|
||||
|
||||
/**
|
||||
* Server action to fetch GitHub stars
|
||||
*/
|
||||
export async function getFormattedGitHubStars(): Promise<string> {
|
||||
try {
|
||||
const token = env.GITHUB_TOKEN
|
||||
|
||||
const response = await fetch('https://api.github.com/repos/simstudioai/sim', {
|
||||
const response = await fetch('/api/github-stars', {
|
||||
headers: {
|
||||
Accept: 'application/vnd.github+json',
|
||||
'X-GitHub-Api-Version': '2022-11-28',
|
||||
'User-Agent': 'SimStudio/1.0',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
'Cache-Control': 'max-age=3600', // Cache for 1 hour
|
||||
},
|
||||
next: { revalidate: 3600 },
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`GitHub API error: ${response.status} ${response.statusText}`)
|
||||
return formatNumber(3867)
|
||||
logger.warn('Failed to fetch GitHub stars from API')
|
||||
return DEFAULT_STARS
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return formatNumber(data.stargazers_count || 3867)
|
||||
return data.stars || DEFAULT_STARS
|
||||
} catch (error) {
|
||||
console.error('Error fetching GitHub stars:', error)
|
||||
return formatNumber(3867)
|
||||
}
|
||||
}
|
||||
|
||||
interface Contributor {
|
||||
login: string
|
||||
avatar_url: string
|
||||
contributions: number
|
||||
html_url: string
|
||||
}
|
||||
|
||||
interface CommitData {
|
||||
sha: string
|
||||
commit: {
|
||||
author: {
|
||||
name: string
|
||||
email: string
|
||||
date: string
|
||||
}
|
||||
message: string
|
||||
}
|
||||
html_url: string
|
||||
}
|
||||
|
||||
interface RepoStats {
|
||||
stars: number
|
||||
forks: number
|
||||
watchers: number
|
||||
openIssues: number
|
||||
openPRs: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Server action to fetch repository statistics
|
||||
*/
|
||||
export async function getRepositoryStats(): Promise<RepoStats> {
|
||||
try {
|
||||
const token = env.GITHUB_TOKEN
|
||||
|
||||
const headers = {
|
||||
Accept: 'application/vnd.github+json',
|
||||
'X-GitHub-Api-Version': '2022-11-28',
|
||||
'User-Agent': 'SimStudio/1.0',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
}
|
||||
|
||||
const repoResponse = await fetch('https://api.github.com/repos/simstudioai/sim', {
|
||||
headers,
|
||||
next: { revalidate: 3600 },
|
||||
})
|
||||
|
||||
const prsResponse = await fetch(
|
||||
'https://api.github.com/repos/simstudioai/sim/pulls?state=open',
|
||||
{
|
||||
headers,
|
||||
next: { revalidate: 3600 },
|
||||
}
|
||||
)
|
||||
|
||||
if (!repoResponse.ok || !prsResponse.ok) {
|
||||
console.error('GitHub API error fetching repo stats')
|
||||
return {
|
||||
stars: 3867,
|
||||
forks: 581,
|
||||
watchers: 26,
|
||||
openIssues: 23,
|
||||
openPRs: 3,
|
||||
}
|
||||
}
|
||||
|
||||
const repoData = await repoResponse.json()
|
||||
const prsData = await prsResponse.json()
|
||||
|
||||
return {
|
||||
stars: repoData.stargazers_count || 3867,
|
||||
forks: repoData.forks_count || 581,
|
||||
watchers: repoData.subscribers_count || 26,
|
||||
openIssues: (repoData.open_issues_count || 26) - prsData.length,
|
||||
openPRs: prsData.length || 3,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching repository stats:', error)
|
||||
return {
|
||||
stars: 3867,
|
||||
forks: 581,
|
||||
watchers: 26,
|
||||
openIssues: 23,
|
||||
openPRs: 3,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Server action to fetch contributors
|
||||
*/
|
||||
export async function getContributors(): Promise<Contributor[]> {
|
||||
try {
|
||||
const token = env.GITHUB_TOKEN
|
||||
|
||||
const headers = {
|
||||
Accept: 'application/vnd.github+json',
|
||||
'X-GitHub-Api-Version': '2022-11-28',
|
||||
'User-Agent': 'SimStudio/1.0',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
'https://api.github.com/repos/simstudioai/sim/contributors?per_page=100',
|
||||
{
|
||||
headers,
|
||||
next: { revalidate: 3600 },
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('GitHub API error fetching contributors')
|
||||
return []
|
||||
}
|
||||
|
||||
const contributors = await response.json()
|
||||
return contributors || []
|
||||
} catch (error) {
|
||||
console.error('Error fetching contributors:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Server action to fetch recent commits for timeline data
|
||||
*/
|
||||
export async function getCommitsData(): Promise<CommitData[]> {
|
||||
try {
|
||||
const token = env.GITHUB_TOKEN
|
||||
|
||||
const headers = {
|
||||
Accept: 'application/vnd.github+json',
|
||||
'X-GitHub-Api-Version': '2022-11-28',
|
||||
'User-Agent': 'SimStudio/1.0',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
'https://api.github.com/repos/simstudioai/sim/commits?per_page=100',
|
||||
{
|
||||
headers,
|
||||
next: { revalidate: 3600 },
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('GitHub API error fetching commits')
|
||||
return []
|
||||
}
|
||||
|
||||
const commits = await response.json()
|
||||
return commits || []
|
||||
} catch (error) {
|
||||
console.error('Error fetching commits:', error)
|
||||
return []
|
||||
logger.warn('Error fetching GitHub stars:', error)
|
||||
return DEFAULT_STARS
|
||||
}
|
||||
}
|
||||
|
||||
136
apps/sim/app/(landing)/components/background/background-svg.tsx
Normal file
136
apps/sim/app/(landing)/components/background/background-svg.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
export default function BackgroundSVG() {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden='true'
|
||||
focusable='false'
|
||||
className='-translate-x-1/2 pointer-events-none absolute top-0 left-1/2 z-10 hidden h-full min-h-full w-[1308px] sm:block'
|
||||
width='1308'
|
||||
height='4942'
|
||||
viewBox='0 18 1308 4066'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
preserveAspectRatio='xMidYMin slice'
|
||||
>
|
||||
{/* Pricing section (original height ~380 units) */}
|
||||
<path d='M6.71704 1236.22H1300.76' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<circle cx='11.0557' cy='1236.48' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<circle cx='1298.02' cy='1236.48' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<path d='M10.7967 1245.42V1613.91' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<path d='M1297.76 1245.96V1613.91' stroke='#E7E4EF' strokeWidth='2' />
|
||||
|
||||
{/* Integrations section (original height ~412 units) */}
|
||||
<path d='M6.71704 1614.89H1291.05' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<circle cx='11.0557' cy='1615.15' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<circle cx='1298.02' cy='1615.15' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<path d='M10.7967 1624.61V2026.93' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<path d='M1297.76 1624.61V2026.93' stroke='#E7E4EF' strokeWidth='2' />
|
||||
|
||||
{/* Testimonials section (original short height ~149 units) */}
|
||||
<path d='M6.71704 2026.71H1300.76' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<circle cx='11.0557' cy='2026.97' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<circle cx='1298.02' cy='2026.97' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<path d='M10.7967 2036.43V2177.43' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<path d='M1297.76 2036.43V2177.43' stroke='#E7E4EF' strokeWidth='2' />
|
||||
|
||||
{/* Footer section line */}
|
||||
<path d='M6.71704 2177.71H1300.76' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<circle cx='11.0557' cy='2177.97' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<circle cx='1298.02' cy='2177.97' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<path d='M10.7967 2187.43V4090.25' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<path d='M1297.76 2187.43V4090.25' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<path
|
||||
d='M959.828 116.604C1064.72 187.189 1162.61 277.541 1293.45 536.597'
|
||||
stroke='#E7E4EF'
|
||||
strokeWidth='1.90903'
|
||||
/>
|
||||
<path d='M1118.77 612.174V88' stroke='#E7E4EF' strokeWidth='1.90903' />
|
||||
<path d='M1261.95 481.414L1289.13 481.533' stroke='#E7E4EF' strokeWidth='1.90903' />
|
||||
<path d='M960 109.049V88' stroke='#E7E4EF' strokeWidth='1.90903' />
|
||||
<circle
|
||||
cx='960.214'
|
||||
cy='115.214'
|
||||
r='6.25942'
|
||||
transform='rotate(90 960.214 115.214)'
|
||||
fill='white'
|
||||
stroke='#E7E4EF'
|
||||
strokeWidth='1.90903'
|
||||
/>
|
||||
<circle
|
||||
cx='1119.21'
|
||||
cy='258.214'
|
||||
r='6.25942'
|
||||
transform='rotate(90 1119.21 258.214)'
|
||||
fill='white'
|
||||
stroke='#E7E4EF'
|
||||
strokeWidth='1.90903'
|
||||
/>
|
||||
<circle
|
||||
cx='1265.19'
|
||||
cy='481.414'
|
||||
r='6.25942'
|
||||
transform='rotate(90 1265.19 481.414)'
|
||||
fill='white'
|
||||
stroke='#E7E4EF'
|
||||
strokeWidth='1.90903'
|
||||
/>
|
||||
<path
|
||||
d='M77 179C225.501 165.887 294.438 145.674 390 85'
|
||||
stroke='#E7E4EF'
|
||||
strokeWidth='1.90903'
|
||||
/>
|
||||
<path d='M214.855 521.491L215 75' stroke='#E7E4EF' strokeWidth='1.90903' />
|
||||
<path
|
||||
d='M76.6567 381.124C177.305 448.638 213.216 499.483 240.767 613.253'
|
||||
stroke='#E7E4EF'
|
||||
strokeWidth='1.90903'
|
||||
/>
|
||||
<path d='M76.5203 175.703V613.253' stroke='#E7E4EF' strokeWidth='1.90903' />
|
||||
<path d='M1.07967 179.225L76.6567 179.225' stroke='#E7E4EF' strokeWidth='1.90903' />
|
||||
<circle
|
||||
cx='76.3128'
|
||||
cy='178.882'
|
||||
r='6.25942'
|
||||
transform='rotate(90 76.3128 178.882)'
|
||||
fill='white'
|
||||
stroke='#E7E4EF'
|
||||
strokeWidth='1.90903'
|
||||
/>
|
||||
<circle
|
||||
cx='214.511'
|
||||
cy='528.695'
|
||||
r='6.25942'
|
||||
transform='rotate(90 214.511 528.695)'
|
||||
fill='white'
|
||||
stroke='#E7E4EF'
|
||||
strokeWidth='1.90903'
|
||||
/>
|
||||
<circle
|
||||
cx='76.3129'
|
||||
cy='380.78'
|
||||
r='6.25942'
|
||||
transform='rotate(90 76.3129 380.78)'
|
||||
fill='white'
|
||||
stroke='#E7E4EF'
|
||||
strokeWidth='1.90903'
|
||||
/>
|
||||
<path d='M10.7967 18V1226.51' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<path d='M1297.76 18V1227.59' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<path d='M6.71704 78.533H1300.76' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<circle cx='10.7967' cy='78.792' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<circle cx='214.976' cy='78.9761' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<circle cx='396.976' cy='78.9761' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<circle cx='1298.02' cy='78.792' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<circle cx='1118.98' cy='78.9761' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<circle cx='959.976' cy='78.9761' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<path d='M16.4341 620.811H1292.13' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<circle cx='11.0557' cy='621.07' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<circle cx='76.3758' cy='621.07' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<circle cx='244.805' cy='621.07' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<circle cx='10.7967' cy='178.405' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<circle cx='1298.02' cy='621.07' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<circle cx='1119.23' cy='621.07' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<circle cx='1298.02' cy='481.253' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<circle cx='1298.02' cy='541.714' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
22
apps/sim/app/(landing)/components/background/background.tsx
Normal file
22
apps/sim/app/(landing)/components/background/background.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import dynamic from 'next/dynamic'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Lazy load the SVG to reduce initial bundle size
|
||||
const BackgroundSVG = dynamic(() => import('./background-svg'), {
|
||||
ssr: true, // Enable SSR for SEO
|
||||
loading: () => null, // Don't show loading state
|
||||
})
|
||||
|
||||
type BackgroundProps = {
|
||||
className?: string
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export default function Background({ className, children }: BackgroundProps) {
|
||||
return (
|
||||
<div className={cn('relative min-h-screen w-full', className)}>
|
||||
<BackgroundSVG />
|
||||
<div className='relative z-0 mx-auto w-full max-w-[1308px]'>{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
|
||||
type BlogCardProps = {
|
||||
href: string
|
||||
title: string
|
||||
description?: string
|
||||
date?: Date
|
||||
avatar?: string
|
||||
author: string
|
||||
authorRole?: string
|
||||
type: string
|
||||
readTime?: string
|
||||
image?: string
|
||||
}
|
||||
|
||||
const blogConfig = {
|
||||
agents: '#802efc',
|
||||
functions: '#FC2E31',
|
||||
workflows: '#2E8CFC',
|
||||
// ADD MORE
|
||||
}
|
||||
|
||||
export const BlogCard = ({
|
||||
href,
|
||||
image,
|
||||
title,
|
||||
description,
|
||||
date,
|
||||
avatar,
|
||||
author,
|
||||
authorRole,
|
||||
type,
|
||||
readTime,
|
||||
}: BlogCardProps) => {
|
||||
return (
|
||||
<Link href={href}>
|
||||
<div className='flex flex-col rounded-3xl border border-[#606060]/40 bg-[#101010] p-8 transition-all duration-500 hover:bg-[var(--surface-elevated)]'>
|
||||
{image ? (
|
||||
<Image
|
||||
src={image}
|
||||
alt='Image'
|
||||
width={2000}
|
||||
height={2000}
|
||||
className='aspect-video h-max w-full rounded-xl'
|
||||
/>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
{date ? (
|
||||
<p className='pb-5 font-light text-[#BBBBBB]/70 text-base tracking-tight'>
|
||||
{date.toLocaleString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}
|
||||
</p>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
<div className='flex flex-col gap-6'>
|
||||
<p className='max-w-96 font-medium text-2xl text-white/80 leading-[1.2] tracking-normal lg:text-3xl'>
|
||||
{title}
|
||||
</p>
|
||||
<p className='font-light text-lg text-white/60 leading-[1.5]'>{description}</p>
|
||||
</div>
|
||||
<div className='flex flex-col gap-6 pt-16'>
|
||||
<div className='flex items-center gap-4'>
|
||||
{avatar ? (
|
||||
<Image
|
||||
src={avatar}
|
||||
alt='Avatar'
|
||||
width={64}
|
||||
height={64}
|
||||
className='h-16 w-16 rounded-full'
|
||||
/>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
|
||||
<div className='flex flex-col gap-0'>
|
||||
<p className='font-medium text-white/90 text-xl'>{author}</p>
|
||||
<p className='font-normal text-base text-white/60'>{authorRole}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center gap-5'>
|
||||
<div
|
||||
className='rounded-lg px-2 py-1'
|
||||
style={{
|
||||
background: blogConfig[type.toLowerCase() as keyof typeof blogConfig] ?? '#333',
|
||||
}}
|
||||
>
|
||||
<p className='font-light text-base text-white'>{type}</p>
|
||||
</div>
|
||||
<p className='font-light text-base text-white/60'>{readTime} min-read</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
408
apps/sim/app/(landing)/components/footer/footer.tsx
Normal file
408
apps/sim/app/(landing)/components/footer/footer.tsx
Normal file
@@ -0,0 +1,408 @@
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
DiscordIcon,
|
||||
GithubIcon,
|
||||
HIPAABadgeIcon,
|
||||
LinkedInIcon,
|
||||
xIcon as XIcon,
|
||||
} from '@/components/icons'
|
||||
import { inter } from '@/app/fonts/inter'
|
||||
|
||||
const blocks = [
|
||||
'Agent',
|
||||
'API',
|
||||
'Condition',
|
||||
'Evaluator',
|
||||
'Function',
|
||||
'Loop',
|
||||
'Parallel',
|
||||
'Response',
|
||||
'Router',
|
||||
'Starter',
|
||||
'Webhook',
|
||||
'Workflow',
|
||||
]
|
||||
|
||||
const tools = [
|
||||
'Airtable',
|
||||
'ArXiv',
|
||||
'Browser Use',
|
||||
'Clay',
|
||||
'Confluence',
|
||||
'Discord',
|
||||
'ElevenLabs',
|
||||
'Exa',
|
||||
'File',
|
||||
'Firecrawl',
|
||||
'Generic Webhook',
|
||||
'GitHub',
|
||||
'Gmail',
|
||||
'Google Calendar',
|
||||
'Google Docs',
|
||||
'Google Drive',
|
||||
'Google Search',
|
||||
'Google Sheets',
|
||||
'HuggingFace',
|
||||
'Hunter',
|
||||
'Image Generator',
|
||||
'Jina',
|
||||
'Jira',
|
||||
'Knowledge',
|
||||
'Linear',
|
||||
'LinkUp',
|
||||
'Mem0',
|
||||
'Memory',
|
||||
'Microsoft Excel',
|
||||
'Microsoft Planner',
|
||||
'Microsoft Teams',
|
||||
'Mistral Parse',
|
||||
'MySQL',
|
||||
'Notion',
|
||||
'OneDrive',
|
||||
'OpenAI',
|
||||
'Outlook',
|
||||
'Parallel AI',
|
||||
'Perplexity',
|
||||
'Pinecone',
|
||||
'PostgreSQL',
|
||||
'Qdrant',
|
||||
'Reddit',
|
||||
'S3',
|
||||
'Schedule',
|
||||
'Serper',
|
||||
'SharePoint',
|
||||
'Slack',
|
||||
'Stagehand',
|
||||
'Stagehand Agent',
|
||||
'Supabase',
|
||||
'Tavily',
|
||||
'Telegram',
|
||||
'Thinking',
|
||||
'Translate',
|
||||
'Twilio SMS',
|
||||
'Typeform',
|
||||
'Vision',
|
||||
'Wealthbox',
|
||||
'Webhook',
|
||||
'WhatsApp',
|
||||
'Wikipedia',
|
||||
'X',
|
||||
'YouTube',
|
||||
]
|
||||
|
||||
interface FooterProps {
|
||||
fullWidth?: boolean
|
||||
}
|
||||
|
||||
export default function Footer({ fullWidth = false }: FooterProps) {
|
||||
return (
|
||||
<footer className={`${inter.className} relative w-full overflow-hidden bg-white`}>
|
||||
<div
|
||||
className={
|
||||
fullWidth
|
||||
? 'px-4 pt-[40px] pb-[40px] sm:px-4 sm:pt-[34px] sm:pb-[340px]'
|
||||
: 'px-4 pt-[40px] pb-[40px] sm:px-[50px] sm:pt-[34px] sm:pb-[340px]'
|
||||
}
|
||||
>
|
||||
<div className={`flex gap-[80px] ${fullWidth ? 'justify-center' : ''}`}>
|
||||
{/* Logo and social links */}
|
||||
<div className='flex flex-col gap-[24px]'>
|
||||
<Link href='/' aria-label='Sim home'>
|
||||
<Image
|
||||
src='/logo/b&w/text/b&w.svg'
|
||||
alt='Sim - Workflows for LLMs'
|
||||
width={49.78314}
|
||||
height={24.276}
|
||||
priority
|
||||
quality={90}
|
||||
/>
|
||||
</Link>
|
||||
|
||||
{/* Social links */}
|
||||
<div className='flex items-center gap-[12px]'>
|
||||
<a
|
||||
href='https://discord.gg/Hr4UWYEcTT'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='flex items-center text-[16px] text-muted-foreground transition-colors hover:text-foreground'
|
||||
aria-label='Discord'
|
||||
>
|
||||
<DiscordIcon className='h-[20px] w-[20px]' aria-hidden='true' />
|
||||
</a>
|
||||
<a
|
||||
href='https://x.com/simdotai'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='flex items-center text-[16px] text-muted-foreground transition-colors hover:text-foreground'
|
||||
aria-label='X (Twitter)'
|
||||
>
|
||||
<XIcon className='h-[18px] w-[18px]' aria-hidden='true' />
|
||||
</a>
|
||||
<a
|
||||
href='https://www.linkedin.com/company/simstudioai/'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='flex items-center text-[16px] text-muted-foreground transition-colors hover:text-foreground'
|
||||
aria-label='LinkedIn'
|
||||
>
|
||||
<LinkedInIcon className='h-[18px] w-[18px]' aria-hidden='true' />
|
||||
</a>
|
||||
<a
|
||||
href='https://github.com/simstudioai/sim'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='flex items-center text-[16px] text-muted-foreground transition-colors hover:text-foreground'
|
||||
aria-label='GitHub'
|
||||
>
|
||||
<GithubIcon className='h-[20px] w-[20px]' aria-hidden='true' />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Compliance badges */}
|
||||
<div className='mt-[6px] flex items-center gap-[12px]'>
|
||||
{/* SOC2 badge */}
|
||||
<Link
|
||||
href='https://trust.delve.co/sim-studio'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
<Image
|
||||
src='/footer/soc2.png'
|
||||
alt='SOC2 Compliant'
|
||||
width={54}
|
||||
height={54}
|
||||
className='object-contain'
|
||||
loading='lazy'
|
||||
quality={75}
|
||||
/>
|
||||
</Link>
|
||||
{/* HIPAA badge placeholder - add when available */}
|
||||
<Link
|
||||
href='https://trust.delve.co/sim-studio'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
<HIPAABadgeIcon className='h-[54px] w-[54px]' />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Links section */}
|
||||
<div>
|
||||
<h2 className='mb-[16px] font-medium text-[14px] text-foreground'>More Sim</h2>
|
||||
<div className='flex flex-col gap-[12px]'>
|
||||
<Link
|
||||
href='https://docs.sim.ai'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-[14px] text-muted-foreground transition-colors hover:text-foreground'
|
||||
>
|
||||
Docs
|
||||
</Link>
|
||||
<Link
|
||||
href='#pricing'
|
||||
className='text-[14px] text-muted-foreground transition-colors hover:text-foreground'
|
||||
>
|
||||
Pricing
|
||||
</Link>
|
||||
<Link
|
||||
href='https://form.typeform.com/to/jqCO12pF'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-[14px] text-muted-foreground transition-colors hover:text-foreground'
|
||||
>
|
||||
Enterprise
|
||||
</Link>
|
||||
<Link
|
||||
href='/privacy'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-[14px] text-muted-foreground transition-colors hover:text-foreground'
|
||||
>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
<Link
|
||||
href='/terms'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-[14px] text-muted-foreground transition-colors hover:text-foreground'
|
||||
>
|
||||
Terms of Service
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Blocks section */}
|
||||
<div className='hidden sm:block'>
|
||||
<h2 className='mb-[16px] font-medium text-[14px] text-foreground'>Blocks</h2>
|
||||
<div className='flex flex-col gap-[12px]'>
|
||||
{blocks.map((block) => (
|
||||
<Link
|
||||
key={block}
|
||||
href={`https://docs.sim.ai/blocks/${block.toLowerCase().replace(' ', '-')}`}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-[14px] text-muted-foreground transition-colors hover:text-foreground'
|
||||
>
|
||||
{block}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tools section - split into columns */}
|
||||
<div className='hidden sm:block'>
|
||||
<h2 className='mb-[16px] font-medium text-[14px] text-foreground'>Tools</h2>
|
||||
<div className='flex gap-[80px]'>
|
||||
{/* First column */}
|
||||
<div className='flex flex-col gap-[12px]'>
|
||||
{tools.slice(0, Math.ceil(tools.length / 4)).map((tool) => (
|
||||
<Link
|
||||
key={tool}
|
||||
href={`https://docs.sim.ai/tools/${tool.toLowerCase().replace(/\s+/g, '_')}`}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='whitespace-nowrap text-[14px] text-muted-foreground transition-colors hover:text-foreground'
|
||||
>
|
||||
{tool}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
{/* Second column */}
|
||||
<div className='flex flex-col gap-[12px]'>
|
||||
{tools
|
||||
.slice(Math.ceil(tools.length / 4), Math.ceil((tools.length * 2) / 4))
|
||||
.map((tool) => (
|
||||
<Link
|
||||
key={tool}
|
||||
href={`https://docs.sim.ai/tools/${tool.toLowerCase().replace(/\s+/g, '_')}`}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='whitespace-nowrap text-[14px] text-muted-foreground transition-colors hover:text-foreground'
|
||||
>
|
||||
{tool}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
{/* Third column */}
|
||||
<div className='flex flex-col gap-[12px]'>
|
||||
{tools
|
||||
.slice(Math.ceil((tools.length * 2) / 4), Math.ceil((tools.length * 3) / 4))
|
||||
.map((tool) => (
|
||||
<Link
|
||||
key={tool}
|
||||
href={`https://docs.sim.ai/tools/${tool.toLowerCase().replace(/\s+/g, '_')}`}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='whitespace-nowrap text-[14px] text-muted-foreground transition-colors hover:text-foreground'
|
||||
>
|
||||
{tool}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
{/* Fourth column */}
|
||||
<div className='flex flex-col gap-[12px]'>
|
||||
{tools.slice(Math.ceil((tools.length * 3) / 4)).map((tool) => (
|
||||
<Link
|
||||
key={tool}
|
||||
href={`https://docs.sim.ai/tools/${tool.toLowerCase().replace(/\s+/g, '_')}`}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='whitespace-nowrap text-[14px] text-muted-foreground transition-colors hover:text-foreground'
|
||||
>
|
||||
{tool}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Large SIM logo at bottom - half cut off */}
|
||||
<div className='-translate-x-1/2 pointer-events-none absolute bottom-[-240px] left-1/2 hidden sm:block'>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='1128'
|
||||
height='550'
|
||||
viewBox='0 0 1128 550'
|
||||
fill='none'
|
||||
>
|
||||
<g filter='url(#filter0_dd_122_4989)'>
|
||||
<path
|
||||
d='M3 420.942H77.9115C77.9115 441.473 85.4027 457.843 100.385 470.051C115.367 481.704 135.621 487.53 161.147 487.53C188.892 487.53 210.255 482.258 225.238 471.715C240.22 460.617 247.711 445.913 247.711 427.601C247.711 414.283 243.549 403.185 235.226 394.307C227.457 385.428 213.03 378.215 191.943 372.666L120.361 356.019C84.2929 347.14 57.3802 333.545 39.6234 315.234C22.4215 296.922 13.8206 272.784 13.8206 242.819C13.8206 217.849 20.2019 196.208 32.9646 177.896C46.2822 159.584 64.3165 145.434 87.0674 135.446C110.373 125.458 137.008 120.464 166.973 120.464C196.938 120.464 222.74 125.735 244.382 136.278C266.578 146.821 283.779 161.526 295.987 180.393C308.75 199.259 315.409 221.733 315.964 247.813H241.052C240.497 226.727 233.561 210.357 220.243 198.705C206.926 187.052 188.337 181.225 164.476 181.225C140.06 181.225 121.194 186.497 107.876 197.04C94.5585 207.583 87.8997 222.01 87.8997 240.322C87.8997 267.512 107.876 286.101 147.829 296.09L219.411 313.569C253.815 321.337 279.618 334.1 296.82 351.857C314.022 369.059 322.622 392.642 322.622 422.607C322.622 448.132 315.686 470.606 301.814 490.027C287.941 508.894 268.797 523.599 244.382 534.142C220.521 544.13 192.221 549.124 159.482 549.124C111.76 549.124 73.7498 537.471 45.4499 514.165C17.15 490.86 3 459.785 3 420.942Z'
|
||||
fill='#DCDCDC'
|
||||
/>
|
||||
<path
|
||||
d='M377.713 539.136V132.117C408.911 143.439 422.667 143.439 455.954 132.117V539.136H377.713ZM416.001 105.211C402.129 105.211 389.921 100.217 379.378 90.2291C369.39 79.686 364.395 67.4782 364.395 53.6057C364.395 39.1783 369.39 26.9705 379.378 16.9823C389.921 6.9941 402.129 2 416.001 2C430.428 2 442.636 6.9941 452.625 16.9823C462.613 26.9705 467.607 39.1783 467.607 53.6057C467.607 67.4782 462.613 79.686 452.625 90.2291C442.636 100.217 430.428 105.211 416.001 105.211Z'
|
||||
fill='#DCDCDC'
|
||||
/>
|
||||
<path
|
||||
d='M593.961 539.136H515.72V132.117H585.637V200.792C593.961 178.041 610.053 158.752 632.249 143.769C655 128.232 682.467 120.464 714.651 120.464C750.72 120.464 780.685 130.174 804.545 149.596C822.01 163.812 835.016 181.446 843.562 202.5C851.434 181.446 864.509 163.812 882.786 149.596C907.757 130.174 938.554 120.464 975.177 120.464C1021.79 120.464 1058.41 134.059 1085.05 161.249C1111.68 188.439 1125 225.617 1125 272.784V539.136H1048.42V291.928C1048.42 259.744 1040.1 235.051 1023.45 217.849C1007.36 200.092 985.443 191.213 957.698 191.213C938.276 191.213 921.074 195.653 906.092 204.531C891.665 212.855 880.289 225.062 871.966 241.154C863.642 257.247 859.48 276.113 859.48 297.754V539.136H782.072V291.095C782.072 258.911 774.026 234.496 757.934 217.849C741.841 200.647 719.923 192.046 692.178 192.046C672.756 192.046 655.555 196.485 640.572 205.363C626.145 213.687 614.769 225.895 606.446 241.987C598.122 257.524 593.961 276.113 593.961 297.754V539.136Z'
|
||||
fill='#DCDCDC'
|
||||
/>
|
||||
<path
|
||||
d='M166.973 121.105C196.396 121.105 221.761 126.201 243.088 136.367L244.101 136.855L244.106 136.858C265.86 147.191 282.776 161.528 294.876 179.865L295.448 180.741L295.455 180.753C308.032 199.345 314.656 221.475 315.306 247.171H241.675C240.996 226.243 234.012 209.899 220.666 198.222C207.196 186.435 188.437 180.583 164.476 180.583C139.977 180.583 120.949 185.871 107.478 196.536C93.9928 207.212 87.2578 221.832 87.2578 240.322C87.2579 254.096 92.3262 265.711 102.444 275.127C112.542 284.524 127.641 291.704 147.673 296.712L147.677 296.713L219.259 314.192L219.27 314.195C253.065 321.827 278.469 334.271 295.552 351.48L296.358 352.304L296.365 352.311C313.42 369.365 321.98 392.77 321.98 422.606C321.98 448.005 315.082 470.343 301.297 489.646C287.502 508.408 268.456 523.046 244.134 533.55C220.369 543.498 192.157 548.482 159.481 548.482C111.864 548.482 74.0124 536.855 45.8584 513.67C17.8723 490.623 3.80059 459.948 3.64551 421.584H77.2734C77.4285 441.995 84.9939 458.338 99.9795 470.549L99.9854 470.553L99.9912 470.558C115.12 482.324 135.527 488.172 161.146 488.172C188.96 488.172 210.474 482.889 225.607 472.24L225.613 472.236L225.619 472.231C240.761 461.015 248.353 446.12 248.353 427.601C248.352 414.145 244.145 402.89 235.709 393.884C227.81 384.857 213.226 377.603 192.106 372.045L192.098 372.043L192.089 372.04L120.507 355.394C84.5136 346.533 57.7326 332.983 40.0908 314.794H40.0918C23.0227 296.624 14.4629 272.654 14.4629 242.819C14.4629 217.969 20.8095 196.463 33.4834 178.273C46.7277 160.063 64.6681 145.981 87.3252 136.034L87.3242 136.033C110.536 126.086 137.081 121.106 166.973 121.105ZM975.177 121.105C1021.66 121.105 1058.1 134.658 1084.59 161.698C1111.08 188.741 1124.36 225.743 1124.36 272.784V538.494H1049.07V291.928C1049.07 259.636 1040.71 234.76 1023.92 217.402H1023.91C1007.68 199.5 985.584 190.571 957.697 190.571C938.177 190.571 920.862 195.034 905.771 203.975C891.228 212.365 879.77 224.668 871.396 240.859C863.017 257.059 858.838 276.03 858.838 297.754V538.494H782.714V291.096C782.714 258.811 774.641 234.209 758.395 217.402C742.16 200.053 720.062 191.404 692.178 191.404C673.265 191.404 656.422 195.592 641.666 203.985L640.251 204.808C625.711 213.196 614.254 225.497 605.88 241.684C597.496 257.333 593.318 276.031 593.318 297.754V538.494H516.361V132.759H584.995V200.792L586.24 201.013C594.51 178.408 610.505 159.221 632.607 144.302L632.61 144.3C655.238 128.847 682.574 121.105 714.651 121.105C750.599 121.105 780.413 130.781 804.14 150.094C821.52 164.241 834.461 181.787 842.967 202.741L843.587 204.268L844.163 202.725C851.992 181.786 864.994 164.248 883.181 150.103C908.021 130.782 938.673 121.106 975.177 121.105ZM455.312 538.494H378.354V133.027C393.534 138.491 404.652 141.251 416.05 141.251C427.46 141.251 439.095 138.485 455.312 133.009V538.494ZM416.001 2.6416C430.262 2.6416 442.306 7.57157 452.171 17.4365C462.036 27.3014 466.965 39.3445 466.965 53.6055C466.965 67.3043 462.04 79.3548 452.16 89.7842C442.297 99.6427 430.258 104.569 416.001 104.569C402.303 104.569 390.254 99.6452 379.825 89.7676C369.957 79.3421 365.037 67.2967 365.037 53.6055C365.037 39.3444 369.966 27.3005 379.831 17.4355C390.258 7.56247 402.307 2.64163 416.001 2.6416Z'
|
||||
stroke='#C1C1C1'
|
||||
strokeWidth='1.28396'
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter
|
||||
id='filter0_dd_122_4989'
|
||||
x='0'
|
||||
y='0'
|
||||
width='1128'
|
||||
height='550'
|
||||
filterUnits='userSpaceOnUse'
|
||||
colorInterpolationFilters='sRGB'
|
||||
>
|
||||
<feFlood floodOpacity='0' result='BackgroundImageFix' />
|
||||
<feColorMatrix
|
||||
in='SourceAlpha'
|
||||
type='matrix'
|
||||
values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0'
|
||||
result='hardAlpha'
|
||||
/>
|
||||
<feMorphology
|
||||
radius='1'
|
||||
operator='erode'
|
||||
in='SourceAlpha'
|
||||
result='effect1_dropShadow_122_4989'
|
||||
/>
|
||||
<feOffset dy='1' />
|
||||
<feGaussianBlur stdDeviation='1' />
|
||||
<feColorMatrix type='matrix' values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0' />
|
||||
<feBlend
|
||||
mode='normal'
|
||||
in2='BackgroundImageFix'
|
||||
result='effect1_dropShadow_122_4989'
|
||||
/>
|
||||
<feColorMatrix
|
||||
in='SourceAlpha'
|
||||
type='matrix'
|
||||
values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0'
|
||||
result='hardAlpha'
|
||||
/>
|
||||
<feOffset dy='1' />
|
||||
<feGaussianBlur stdDeviation='1.5' />
|
||||
<feColorMatrix type='matrix' values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0' />
|
||||
<feBlend
|
||||
mode='normal'
|
||||
in2='effect1_dropShadow_122_4989'
|
||||
result='effect2_dropShadow_122_4989'
|
||||
/>
|
||||
<feBlend
|
||||
mode='normal'
|
||||
in='SourceGraphic'
|
||||
in2='effect2_dropShadow_122_4989'
|
||||
result='shape'
|
||||
/>
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'framer-motion'
|
||||
import { GithubIcon } from '@/components/icons'
|
||||
|
||||
interface GitHubStarsClientProps {
|
||||
stars: string
|
||||
}
|
||||
|
||||
export default function GitHubStarsClient({ stars }: GitHubStarsClientProps) {
|
||||
return (
|
||||
<motion.a
|
||||
href='https://github.com/simstudioai/sim'
|
||||
className='flex items-center gap-2 rounded-md p-1.5 text-white/80 transition-colors duration-200 hover:text-white/100'
|
||||
aria-label='GitHub'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, ease: 'easeOut', delay: 0.3 }}
|
||||
>
|
||||
<GithubIcon className='h-[20px] w-[20px]' />
|
||||
<span className='font-medium text-base'>{stars}</span>
|
||||
</motion.a>
|
||||
)
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import { GithubIcon } from '@/components/icons'
|
||||
import { env } from '@/lib/env'
|
||||
|
||||
/**
|
||||
* Format a number to a human-readable format (e.g., 1000 -> 1k, 1100 -> 1.1k)
|
||||
*/
|
||||
function formatNumber(num: number): string {
|
||||
if (num < 1000) {
|
||||
return num.toString()
|
||||
}
|
||||
|
||||
// Convert to one decimal place and remove trailing 0
|
||||
const formatted = (Math.round(num / 100) / 10).toFixed(1)
|
||||
|
||||
// Remove .0 if the decimal is 0
|
||||
return formatted.endsWith('.0') ? `${formatted.slice(0, -2)}k` : `${formatted}k`
|
||||
}
|
||||
|
||||
async function getGitHubStars() {
|
||||
const token = env.GITHUB_TOKEN
|
||||
|
||||
try {
|
||||
const response = await fetch('https://api.github.com/repos/simstudioai/sim', {
|
||||
headers: {
|
||||
Accept: 'application/vnd.github+json',
|
||||
'X-GitHub-Api-Version': '2022-11-28',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
next: { revalidate: 3600 }, // Revalidate every hour
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
// Return current stars if API fails, we don't want to break the UI
|
||||
return 1200
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data.stargazers_count
|
||||
} catch (error) {
|
||||
console.error('Error fetching GitHub stars:', error)
|
||||
return 1200
|
||||
}
|
||||
}
|
||||
|
||||
export default async function GitHubStars() {
|
||||
const stars = await getGitHubStars()
|
||||
const formattedStars = formatNumber(stars)
|
||||
|
||||
return (
|
||||
<a
|
||||
href='https://github.com/simstudioai/sim'
|
||||
className='flex items-center gap-2 rounded-md p-1.5 text-white/80 transition-colors duration-200 hover:text-white/100'
|
||||
aria-label='GitHub'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
<GithubIcon className='h-[20px] w-[20px]' />
|
||||
<span className='font-medium text-base'>{formattedStars}</span>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import { useId } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface GridPatternProps extends React.SVGProps<SVGSVGElement> {
|
||||
width?: number
|
||||
height?: number
|
||||
x?: number
|
||||
y?: number
|
||||
squares?: Array<[x: number, y: number]>
|
||||
strokeDasharray?: string
|
||||
className?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export function GridPattern({
|
||||
width = 40,
|
||||
height = 40,
|
||||
x = -1,
|
||||
y = -1,
|
||||
strokeDasharray = '0',
|
||||
squares,
|
||||
className,
|
||||
...props
|
||||
}: GridPatternProps) {
|
||||
const id = useId()
|
||||
|
||||
return (
|
||||
<svg
|
||||
aria-hidden='true'
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-0 h-full w-full fill-gray-400/30 stroke-gray-400/30',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<defs>
|
||||
<pattern id={id} width={width} height={height} patternUnits='userSpaceOnUse' x={x} y={y}>
|
||||
<path d={`M.5 ${height}V.5H${width}`} fill='none' strokeDasharray={strokeDasharray} />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width='100%' height='100%' strokeWidth={0} fill={`url(#${id})`} />
|
||||
{squares && (
|
||||
<svg x={x} y={y} className='overflow-visible'>
|
||||
{squares.map(([x, y]) => (
|
||||
<rect
|
||||
strokeWidth='0'
|
||||
key={`${x}-${y}`}
|
||||
width={width - 1}
|
||||
height={height - 1}
|
||||
x={x * width + 1}
|
||||
y={y * height + 1}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
)}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -1,245 +0,0 @@
|
||||
// Assuming custom icons exist for Sim specific things, otherwise use Lucide
|
||||
|
||||
import type React from 'react'
|
||||
import { memo } from 'react'
|
||||
import {
|
||||
// For header icon
|
||||
ChevronDown,
|
||||
CodeXml,
|
||||
// For Add Tool button
|
||||
PlusIcon,
|
||||
} from 'lucide-react'
|
||||
import { Handle, type NodeProps, Position } from 'reactflow'
|
||||
import { AgentIcon, ConnectIcon, SlackIcon, StartIcon } from '@/components/icons'
|
||||
import { CodeBlock } from '@/components/ui/code-block'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Removed DotPattern import
|
||||
|
||||
// Configuration for the new block types based on the image
|
||||
const blockConfig = {
|
||||
start: {
|
||||
icon: StartIcon, // Assuming a custom StartIcon
|
||||
color: '#2563eb', // Blue
|
||||
name: 'Start',
|
||||
},
|
||||
function: {
|
||||
icon: CodeXml,
|
||||
color: '#e11d48', // Red
|
||||
name: 'Function 1',
|
||||
},
|
||||
agent: {
|
||||
icon: AgentIcon, // Assuming custom AgentIcon
|
||||
color: '#9333ea', // Purple
|
||||
name: 'Agent 1',
|
||||
},
|
||||
router: {
|
||||
icon: ConnectIcon, // Assuming custom ConnectIcon
|
||||
color: '#16a34a', // Green
|
||||
name: 'Router 1',
|
||||
},
|
||||
slack: {
|
||||
icon: SlackIcon, // Assuming custom SlackIcon
|
||||
color: '#611F69', // Slack-like color (adjust if needed)
|
||||
name: 'Slack 1',
|
||||
},
|
||||
}
|
||||
|
||||
export const HeroBlock = memo(({ id, data }: NodeProps) => {
|
||||
const type = data.type as keyof typeof blockConfig
|
||||
const config = blockConfig[type] || blockConfig.function
|
||||
const Icon = config.icon
|
||||
const nodeName = config.name
|
||||
const iconBgColor = config.color // Get color from config
|
||||
const _horizontalHandles = true // Default to horizontal handles like in workflow-block
|
||||
|
||||
// Determine if we should show the input handle
|
||||
// Don't show for start blocks, function1 in hero section, or id=function1
|
||||
const showInputHandle =
|
||||
type !== 'start' && !(type === 'function' && id === 'function1' && data.isHeroSection)
|
||||
|
||||
return (
|
||||
// Apply group relative here for handles
|
||||
<div className='group relative flex flex-col items-center opacity-90'>
|
||||
{/* Don't show input handle for starter blocks or function1 */}
|
||||
{showInputHandle && (
|
||||
<Handle
|
||||
type='target'
|
||||
position={Position.Left}
|
||||
id='target'
|
||||
className={cn(
|
||||
'!w-[7px] !h-5',
|
||||
'!bg-slate-300 dark:!bg-slate-500 !rounded-[2px] !border-none',
|
||||
'!z-[1000]',
|
||||
'!opacity-100',
|
||||
'!left-[-7px]'
|
||||
)}
|
||||
style={{
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
}}
|
||||
data-nodeid={id}
|
||||
data-handleid='target'
|
||||
isConnectable={true}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Use BlockCard, passing Icon, title, and iconBgColor */}
|
||||
<BlockCard Icon={Icon} iconBgColor={iconBgColor} title={nodeName}>
|
||||
{/* Render type-specific content as children */}
|
||||
<div className='space-y-3 pt-3 text-sm'>
|
||||
{/* --- Start Block Content --- */}
|
||||
{type === 'start' && (
|
||||
<>
|
||||
<div className='font-medium text-[#7D7D7D] text-base'>Start workflow</div>
|
||||
<Container>
|
||||
<p>Run Manually</p>
|
||||
<ChevronDown size={14} />
|
||||
</Container>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* --- Function Block Content --- */}
|
||||
{type === 'function' && (
|
||||
<div className='flex items-center gap-1 font-medium text-neutral-400 text-xs'>
|
||||
<CodeBlock
|
||||
code='Write javascript..'
|
||||
className='min-h-32 w-full border-[#282828] bg-[#212121] p-0 font-geist-mono text-[#7C7C7C]'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* --- Agent Block Content --- */}
|
||||
{type === 'agent' && (
|
||||
<div className='flex flex-col gap-4'>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<p className='font-medium text-[#7D7D7D] text-base'>Agent</p>
|
||||
<Container>Enter System Prompt</Container>
|
||||
</div>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<p className='font-medium text-[#7D7D7D] text-base'>User Prompts</p>
|
||||
<Container>Enter Context</Container>
|
||||
</div>
|
||||
<div className='flex w-full gap-3'>
|
||||
<div className='flex w-full flex-col gap-2'>
|
||||
<p className='font-medium text-[#7D7D7D] text-base'>Model</p>
|
||||
<Container>
|
||||
<p>GPT-4o</p>
|
||||
<ChevronDown size={14} />
|
||||
</Container>
|
||||
</div>
|
||||
<div className='flex w-full flex-col gap-2'>
|
||||
<p className='font-medium text-[#7D7D7D] text-base'>Tools</p>
|
||||
<Container className='justify-center gap-1'>
|
||||
<PlusIcon size={14} />
|
||||
Add Tools
|
||||
</Container>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* --- Router Block Content --- */}
|
||||
{type === 'router' && (
|
||||
<div className='flex flex-col gap-4'>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<p className='font-medium text-[#7D7D7D] text-base'>Prompt</p>
|
||||
<Container className='min-h-32 items-start'>Enter Prompt</Container>
|
||||
</div>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<p className='font-medium text-[#7D7D7D] text-base'>Model</p>
|
||||
<Container>
|
||||
<p>GPT-4o</p>
|
||||
<ChevronDown size={14} />
|
||||
</Container>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* --- Slack Block Content --- */}
|
||||
{type === 'slack' && (
|
||||
<div className='flex flex-col gap-4'>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<p className='font-medium text-[#7D7D7D] text-base'>Channel</p>
|
||||
<Container>Enter Slack channel (#general)</Container>
|
||||
</div>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<p className='font-medium text-[#7D7D7D] text-base'>Message</p>
|
||||
<Container className='min-h-32 items-start'>
|
||||
<p>Enter your alert message</p>
|
||||
</Container>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</BlockCard>
|
||||
|
||||
{/* Output Handle - Don't show for slack1 */}
|
||||
{id !== 'slack1' && (
|
||||
<Handle
|
||||
type='source'
|
||||
position={Position.Right}
|
||||
id='source'
|
||||
className={cn(
|
||||
'!w-[7px] !h-5',
|
||||
'!bg-slate-300 dark:!bg-slate-500 !rounded-[2px] !border-none',
|
||||
'!z-[1000]',
|
||||
'!opacity-100',
|
||||
'!right-[-7px]'
|
||||
)}
|
||||
style={{
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
}}
|
||||
data-nodeid={id}
|
||||
data-handleid='source'
|
||||
isConnectable={true}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
const Container = ({ children, className }: { children: React.ReactNode; className?: string }) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-between rounded-xl border border-[#282828] bg-[#212121] px-3 py-2 font-normal text-[#7C7C7C] text-sm',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Modify BlockCard to accept and use iconBgColor prop
|
||||
const BlockCard = ({
|
||||
Icon,
|
||||
iconBgColor,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
Icon: any
|
||||
iconBgColor: string
|
||||
title: string
|
||||
children: React.ReactNode
|
||||
}) => {
|
||||
return (
|
||||
<div className='flex min-h-[100px] w-[280px] flex-col rounded-xl border border-[#333333] bg-[#131313] shadow-[0px_0px_6px_3px_rgba(255,_255,_255,_0.05)]'>
|
||||
<div className='flex items-center gap-2 border-[#262626] border-b px-4 pt-4 pb-3'>
|
||||
{/* Apply background color using inline style */}
|
||||
<div
|
||||
className={'flex h-6 w-6 items-center justify-center rounded'}
|
||||
style={{ backgroundColor: iconBgColor }} // Use inline style
|
||||
>
|
||||
<Icon className='h-4 w-4 text-white' />
|
||||
</div>
|
||||
<p className='font-semibold text-base text-neutral-200'>{title}</p>
|
||||
</div>
|
||||
<div className='flex-grow p-4 pt-0'>{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
HeroBlock.displayName = 'HeroBlock'
|
||||
@@ -1,40 +0,0 @@
|
||||
import { BaseEdge, type EdgeProps, getSmoothStepPath } from 'reactflow'
|
||||
|
||||
export const HeroEdge = ({
|
||||
id,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
style = {},
|
||||
markerEnd,
|
||||
}: EdgeProps) => {
|
||||
const isHorizontal = sourcePosition === 'right' || sourcePosition === 'left'
|
||||
|
||||
const [edgePath] = getSmoothStepPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
sourcePosition,
|
||||
targetX,
|
||||
targetY,
|
||||
targetPosition,
|
||||
borderRadius: 8,
|
||||
offset: isHorizontal ? 30 : 20,
|
||||
})
|
||||
|
||||
return (
|
||||
<BaseEdge
|
||||
path={edgePath}
|
||||
style={{
|
||||
strokeWidth: 2,
|
||||
stroke: '#404040',
|
||||
strokeDasharray: '5,5',
|
||||
zIndex: 5,
|
||||
...style,
|
||||
}}
|
||||
markerEnd={markerEnd || style.markerEnd}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,279 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import ReactFlow, {
|
||||
ConnectionLineType,
|
||||
type Edge,
|
||||
type EdgeTypes,
|
||||
type Node,
|
||||
type NodeTypes,
|
||||
Position,
|
||||
ReactFlowProvider,
|
||||
useEdgesState,
|
||||
useNodesState,
|
||||
useReactFlow,
|
||||
type Viewport,
|
||||
} from 'reactflow'
|
||||
import 'reactflow/dist/style.css'
|
||||
|
||||
import { HeroBlock } from '@/app/(landing)/components/hero-block'
|
||||
import { HeroEdge } from '@/app/(landing)/components/hero-edge'
|
||||
import { useWindowSize } from '@/app/(landing)/components/use-window-size'
|
||||
|
||||
const nodeTypes: NodeTypes = { heroBlock: HeroBlock }
|
||||
const edgeTypes: EdgeTypes = { heroEdge: HeroEdge }
|
||||
|
||||
// Desktop layout
|
||||
const desktopNodes: Node[] = [
|
||||
{
|
||||
id: 'function1',
|
||||
type: 'heroBlock',
|
||||
position: { x: 150, y: 400 },
|
||||
data: { type: 'function', isHeroSection: true },
|
||||
sourcePosition: Position.Right,
|
||||
targetPosition: Position.Left,
|
||||
},
|
||||
{
|
||||
id: 'agent1',
|
||||
type: 'heroBlock',
|
||||
position: { x: 600, y: 600 },
|
||||
data: { type: 'agent' },
|
||||
sourcePosition: Position.Right,
|
||||
targetPosition: Position.Left,
|
||||
},
|
||||
{
|
||||
id: 'router1',
|
||||
type: 'heroBlock',
|
||||
position: { x: 1050, y: 600 },
|
||||
data: { type: 'router' },
|
||||
sourcePosition: Position.Right,
|
||||
targetPosition: Position.Left,
|
||||
},
|
||||
{
|
||||
id: 'slack1',
|
||||
type: 'heroBlock',
|
||||
position: { x: 1500, y: 400 },
|
||||
data: { type: 'slack' },
|
||||
sourcePosition: Position.Right,
|
||||
targetPosition: Position.Left,
|
||||
},
|
||||
]
|
||||
|
||||
const desktopEdges: Edge[] = [
|
||||
{
|
||||
id: 'func1-agent1',
|
||||
source: 'function1',
|
||||
target: 'agent1',
|
||||
sourceHandle: 'source',
|
||||
targetHandle: 'target',
|
||||
type: 'heroEdge',
|
||||
animated: true,
|
||||
style: { stroke: '#404040', strokeWidth: 2, strokeDasharray: '5,5' },
|
||||
zIndex: 5,
|
||||
},
|
||||
{
|
||||
id: 'agent1-router1',
|
||||
source: 'agent1',
|
||||
target: 'router1',
|
||||
sourceHandle: 'source',
|
||||
targetHandle: 'target',
|
||||
type: 'heroEdge',
|
||||
animated: true,
|
||||
style: { stroke: '#404040', strokeWidth: 2, strokeDasharray: '5,5' },
|
||||
zIndex: 5,
|
||||
},
|
||||
{
|
||||
id: 'router1-slack1',
|
||||
source: 'router1',
|
||||
target: 'slack1',
|
||||
sourceHandle: 'source',
|
||||
targetHandle: 'target',
|
||||
type: 'heroEdge',
|
||||
animated: true,
|
||||
style: { stroke: '#404040', strokeWidth: 2, strokeDasharray: '5,5' },
|
||||
zIndex: 5,
|
||||
},
|
||||
]
|
||||
|
||||
const tabletNodes: Node[] = [
|
||||
{
|
||||
id: 'function1',
|
||||
type: 'heroBlock',
|
||||
position: { x: 50, y: 480 },
|
||||
data: { type: 'function', isHeroSection: true },
|
||||
},
|
||||
{ id: 'agent1', type: 'heroBlock', position: { x: 300, y: 660 }, data: { type: 'agent' } },
|
||||
{ id: 'router1', type: 'heroBlock', position: { x: 550, y: 660 }, data: { type: 'router' } },
|
||||
{ id: 'slack1', type: 'heroBlock', position: { x: 800, y: 480 }, data: { type: 'slack' } },
|
||||
].map((n) => ({ ...n, sourcePosition: Position.Right, targetPosition: Position.Left }))
|
||||
|
||||
const tabletEdges = desktopEdges.map((edge) => ({
|
||||
...edge,
|
||||
sourceHandle: 'source',
|
||||
targetHandle: 'target',
|
||||
type: 'heroEdge',
|
||||
}))
|
||||
|
||||
// Mobile: only the agent node, centered under text
|
||||
const makeMobileNodes = (w: number, h: number): Node[] => {
|
||||
const BLOCK_HALF = 100
|
||||
return [
|
||||
{
|
||||
id: 'agent1',
|
||||
type: 'heroBlock',
|
||||
position: { x: w / 2 - BLOCK_HALF - 180, y: h / 2 },
|
||||
data: { type: 'agent' },
|
||||
sourcePosition: Position.Right,
|
||||
targetPosition: Position.Left,
|
||||
},
|
||||
{
|
||||
id: 'slack1',
|
||||
type: 'heroBlock',
|
||||
position: { x: w / 2 - BLOCK_HALF + 180, y: h / 2 + 200 },
|
||||
data: { type: 'slack' },
|
||||
sourcePosition: Position.Right,
|
||||
targetPosition: Position.Left,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const mobileEdges: Edge[] = [
|
||||
{
|
||||
id: 'agent1-slack1',
|
||||
source: 'agent1',
|
||||
target: 'slack1',
|
||||
sourceHandle: 'source',
|
||||
targetHandle: 'target',
|
||||
type: 'heroEdge',
|
||||
animated: true,
|
||||
style: { stroke: '#404040', strokeWidth: 2, strokeDasharray: '5,5' },
|
||||
zIndex: 5,
|
||||
},
|
||||
]
|
||||
|
||||
const workflowVariants = {
|
||||
hidden: { opacity: 0, scale: 0.98 },
|
||||
visible: { opacity: 1, scale: 1 },
|
||||
}
|
||||
|
||||
export function HeroWorkflow() {
|
||||
const { width = 0, height = 0 } = useWindowSize()
|
||||
const isMobile = width < 768
|
||||
const isTablet = width >= 768 && width < 1024
|
||||
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState<Node[]>([])
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge[]>([])
|
||||
const { fitView } = useReactFlow()
|
||||
|
||||
// Default viewport to make elements smaller
|
||||
const defaultViewport: Viewport = useMemo(() => ({ x: 0, y: 0, zoom: 0.8 }), [])
|
||||
|
||||
// Load layout
|
||||
useEffect(() => {
|
||||
if (isMobile) {
|
||||
setNodes(makeMobileNodes(width, height))
|
||||
setEdges(mobileEdges)
|
||||
} else if (isTablet) {
|
||||
setNodes(tabletNodes)
|
||||
setEdges(tabletEdges)
|
||||
} else {
|
||||
setNodes(desktopNodes)
|
||||
setEdges(desktopEdges)
|
||||
}
|
||||
}, [width, height, isMobile, isTablet, setNodes, setEdges])
|
||||
|
||||
// Center and scale
|
||||
useEffect(() => {
|
||||
if (nodes.length) {
|
||||
if (isMobile) {
|
||||
fitView({ padding: 0.2 }) // reduced padding to zoom in more
|
||||
} else {
|
||||
fitView({ padding: 0.2 }) // added padding to create more space around elements
|
||||
}
|
||||
}
|
||||
}, [nodes, edges, fitView, isMobile])
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className='pointer-events-none absolute inset-0 flex items-center justify-center overflow-hidden'
|
||||
style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
position: 'absolute',
|
||||
top: isMobile ? '180px' : '160px',
|
||||
left: 0,
|
||||
willChange: 'opacity, transform',
|
||||
}}
|
||||
variants={workflowVariants}
|
||||
initial='hidden'
|
||||
animate='visible'
|
||||
transition={{ duration: 0.5, delay: 0.1, ease: 'easeOut' }}
|
||||
>
|
||||
<style jsx global>{`
|
||||
.react-flow__edge-path {
|
||||
stroke-dasharray: 5, 5;
|
||||
stroke-width: 2;
|
||||
animation: dash 1s linear infinite;
|
||||
}
|
||||
@keyframes dash {
|
||||
to {
|
||||
stroke-dashoffset: 10;
|
||||
}
|
||||
}
|
||||
/* Make handles always visible in hero workflow with high z-index */
|
||||
.react-flow__handle {
|
||||
opacity: 1 !important;
|
||||
z-index: 1000 !important;
|
||||
cursor: default !important;
|
||||
}
|
||||
/* Force edges to stay below handles */
|
||||
.react-flow__edge {
|
||||
z-index: 5 !important;
|
||||
}
|
||||
/* Ensure nodes are above edges */
|
||||
.react-flow__node {
|
||||
z-index: 10 !important;
|
||||
}
|
||||
/* Proper z-index stacking for the entire flow */
|
||||
.react-flow__renderer {
|
||||
z-index: 1;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
defaultEdgeOptions={{ type: 'heroEdge', animated: true }}
|
||||
connectionLineType={ConnectionLineType.SmoothStep}
|
||||
connectionLineStyle={{ stroke: '#404040', strokeWidth: 2, strokeDasharray: '5,5' }}
|
||||
minZoom={0.1}
|
||||
maxZoom={1.5}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
nodesDraggable={false}
|
||||
nodesConnectable={false}
|
||||
elementsSelectable={false}
|
||||
panOnScroll={false}
|
||||
zoomOnScroll={false}
|
||||
zoomOnPinch={false}
|
||||
zoomOnDoubleClick={false}
|
||||
panOnDrag={false}
|
||||
selectionOnDrag={false}
|
||||
preventScrolling={true}
|
||||
defaultViewport={defaultViewport}
|
||||
/>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function HeroWorkflowProvider() {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<HeroWorkflow />
|
||||
</ReactFlowProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
'use client'
|
||||
|
||||
import type React from 'react'
|
||||
|
||||
interface IconButtonProps {
|
||||
children: React.ReactNode
|
||||
onClick?: () => void
|
||||
onMouseEnter?: () => void
|
||||
style?: React.CSSProperties
|
||||
'aria-label': string
|
||||
isAutoHovered?: boolean
|
||||
}
|
||||
|
||||
export function IconButton({
|
||||
children,
|
||||
onClick,
|
||||
onMouseEnter,
|
||||
style,
|
||||
'aria-label': ariaLabel,
|
||||
isAutoHovered = false,
|
||||
}: IconButtonProps) {
|
||||
return (
|
||||
<button
|
||||
type='button'
|
||||
aria-label={ariaLabel}
|
||||
onClick={onClick}
|
||||
onMouseEnter={onMouseEnter}
|
||||
className={`flex items-center justify-center rounded-xl border p-2 outline-none transition-all duration-300 ${
|
||||
isAutoHovered
|
||||
? 'border-[#E5E5E5] shadow-[0_2px_4px_0_rgba(0,0,0,0.08)]'
|
||||
: 'border-transparent hover:border-[#E5E5E5] hover:shadow-[0_2px_4px_0_rgba(0,0,0,0.08)]'
|
||||
}`}
|
||||
style={style}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
30
apps/sim/app/(landing)/components/hero/components/index.ts
Normal file
30
apps/sim/app/(landing)/components/hero/components/index.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
// Hero Components
|
||||
export { IconButton } from './icon-button'
|
||||
export { DotPattern } from './landing-canvas/dot-pattern'
|
||||
export type {
|
||||
LandingBlockProps,
|
||||
LandingCardData,
|
||||
} from './landing-canvas/landing-block/landing-block'
|
||||
// Landing Block
|
||||
export { LandingBlock } from './landing-canvas/landing-block/landing-block'
|
||||
export type { LoopNodeData } from './landing-canvas/landing-block/landing-loop-node'
|
||||
export { LandingLoopNode } from './landing-canvas/landing-block/landing-loop-node'
|
||||
export { LandingNode } from './landing-canvas/landing-block/landing-node'
|
||||
export type { LoopBlockProps } from './landing-canvas/landing-block/loop-block'
|
||||
export { LoopBlock } from './landing-canvas/landing-block/loop-block'
|
||||
export type { TagProps } from './landing-canvas/landing-block/tag'
|
||||
export { Tag } from './landing-canvas/landing-block/tag'
|
||||
export type {
|
||||
LandingBlockNode,
|
||||
LandingCanvasProps,
|
||||
LandingEdgeData,
|
||||
LandingGroupData,
|
||||
LandingManualBlock,
|
||||
LandingViewportApi,
|
||||
} from './landing-canvas/landing-canvas'
|
||||
// Landing Canvas
|
||||
export { CARD_HEIGHT, CARD_WIDTH, LandingCanvas } from './landing-canvas/landing-canvas'
|
||||
// Landing Edge
|
||||
export { LandingEdge } from './landing-canvas/landing-edge/landing-edge'
|
||||
export type { LandingFlowProps } from './landing-canvas/landing-flow'
|
||||
export { LandingFlow } from './landing-canvas/landing-flow'
|
||||
@@ -0,0 +1,68 @@
|
||||
import React from 'react'
|
||||
import { BookIcon } from 'lucide-react'
|
||||
import { Tag, type TagProps } from './tag'
|
||||
|
||||
/**
|
||||
* Data structure for a landing card component
|
||||
*/
|
||||
export interface LandingCardData {
|
||||
/** Icon element to display in the card header */
|
||||
icon: React.ReactNode
|
||||
/** Background color for the icon container */
|
||||
color: string | '#f6f6f6'
|
||||
/** Name/title of the card */
|
||||
name: string
|
||||
/** Optional tags to display at the bottom of the card */
|
||||
tags?: TagProps[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for the LandingBlock component
|
||||
*/
|
||||
export interface LandingBlockProps extends LandingCardData {
|
||||
/** Optional CSS class names */
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Landing block component that displays a card with icon, name, and optional tags
|
||||
* @param props - Component properties including icon, color, name, tags, and className
|
||||
* @returns A styled block card component
|
||||
*/
|
||||
export const LandingBlock = React.memo(function LandingBlock({
|
||||
icon,
|
||||
color,
|
||||
name,
|
||||
tags,
|
||||
className,
|
||||
}: LandingBlockProps) {
|
||||
return (
|
||||
<div
|
||||
className={`z-10 flex w-64 flex-col items-start gap-3 rounded-[14px] border border-[#E5E5E5] bg-[#FEFEFE] p-3 ${className ?? ''}`}
|
||||
style={{
|
||||
boxShadow: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
|
||||
}}
|
||||
>
|
||||
<div className='flex w-full items-center justify-between'>
|
||||
<div className='flex items-center gap-2.5'>
|
||||
<div
|
||||
className='flex h-6 w-6 items-center justify-center rounded-[8px] text-white'
|
||||
style={{ backgroundColor: color as string }}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
<p className='text-base text-card-foreground'>{name}</p>
|
||||
</div>
|
||||
<BookIcon className='h-4 w-4 text-muted-foreground' />
|
||||
</div>
|
||||
|
||||
{tags && tags.length > 0 ? (
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{tags.map((tag) => (
|
||||
<Tag key={tag.label} icon={tag.icon} label={tag.label} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { LoopBlock } from './loop-block'
|
||||
|
||||
/**
|
||||
* Data structure for the loop node
|
||||
*/
|
||||
export interface LoopNodeData {
|
||||
/** Label for the loop block */
|
||||
label?: string
|
||||
/** Child content to render inside */
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* React Flow node component for the loop block
|
||||
* Acts as a group node for subflow functionality
|
||||
* @param props - Component properties containing node data
|
||||
* @returns A React Flow compatible loop node component
|
||||
*/
|
||||
export const LandingLoopNode = React.memo(function LandingLoopNode({
|
||||
data,
|
||||
style,
|
||||
}: {
|
||||
data: LoopNodeData
|
||||
style?: React.CSSProperties
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className='nodrag nopan nowheel relative cursor-grab active:cursor-grabbing'
|
||||
style={{
|
||||
width: style?.width || 1198,
|
||||
height: style?.height || 528,
|
||||
backgroundColor: 'transparent',
|
||||
outline: 'none !important',
|
||||
boxShadow: 'none !important',
|
||||
border: 'none !important',
|
||||
}}
|
||||
>
|
||||
<LoopBlock style={{ width: '100%', height: '100%', pointerEvents: 'none' }}>
|
||||
<div className='flex items-start gap-3 px-6 py-4'>
|
||||
<span className='font-medium text-base text-blue-500'>Loop</span>
|
||||
</div>
|
||||
{data.children}
|
||||
</LoopBlock>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,87 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { Handle, Position } from 'reactflow'
|
||||
import { LandingBlock, type LandingCardData } from './landing-block'
|
||||
|
||||
/**
|
||||
* React Flow node component for the landing canvas
|
||||
* Includes CSS animations and connection handles
|
||||
* @param props - Component properties containing node data
|
||||
* @returns A React Flow compatible node component
|
||||
*/
|
||||
export const LandingNode = React.memo(function LandingNode({ data }: { data: LandingCardData }) {
|
||||
const wrapperRef = React.useRef<HTMLDivElement | null>(null)
|
||||
const innerRef = React.useRef<HTMLDivElement | null>(null)
|
||||
const [isAnimated, setIsAnimated] = React.useState(false)
|
||||
|
||||
React.useEffect(() => {
|
||||
const delay = (data as any)?.delay ?? 0
|
||||
const timer = setTimeout(() => {
|
||||
setIsAnimated(true)
|
||||
}, delay * 1000)
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}, [data])
|
||||
|
||||
// Check if this node should have a target handle (schedule node shouldn't)
|
||||
const hideTargetHandle = (data as any)?.hideTargetHandle || false
|
||||
// Check if this node should have a source handle (agent and function nodes shouldn't)
|
||||
const hideSourceHandle = (data as any)?.hideSourceHandle || false
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} className='relative cursor-grab active:cursor-grabbing'>
|
||||
{!hideTargetHandle && (
|
||||
<Handle
|
||||
type='target'
|
||||
position={Position.Left}
|
||||
style={{
|
||||
width: '12px',
|
||||
height: '12px',
|
||||
background: '#FEFEFE',
|
||||
border: '1px solid #E5E5E5',
|
||||
borderRadius: '50%',
|
||||
top: '50%',
|
||||
left: '-20px',
|
||||
transform: 'translateY(-50%)',
|
||||
zIndex: 2,
|
||||
}}
|
||||
isConnectable={false}
|
||||
/>
|
||||
)}
|
||||
{!hideSourceHandle && (
|
||||
<Handle
|
||||
type='source'
|
||||
position={Position.Right}
|
||||
style={{
|
||||
width: '12px',
|
||||
height: '12px',
|
||||
background: '#FEFEFE',
|
||||
border: '1px solid #E5E5E5',
|
||||
borderRadius: '50%',
|
||||
top: '50%',
|
||||
right: '-20px',
|
||||
transform: 'translateY(-50%)',
|
||||
zIndex: 2,
|
||||
}}
|
||||
isConnectable={false}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
ref={innerRef}
|
||||
className={isAnimated ? 'landing-node-animated' : 'landing-node-initial'}
|
||||
style={{
|
||||
opacity: isAnimated ? 1 : 0,
|
||||
transform: isAnimated ? 'translateY(0) scale(1)' : 'translateY(8px) scale(0.98)',
|
||||
transition:
|
||||
'opacity 0.6s cubic-bezier(0.22, 1, 0.36, 1), transform 0.6s cubic-bezier(0.22, 1, 0.36, 1)',
|
||||
willChange: 'transform, opacity',
|
||||
}}
|
||||
>
|
||||
<LandingBlock icon={data.icon} color={data.color} name={data.name} tags={data.tags} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
import React from 'react'
|
||||
|
||||
/**
|
||||
* Props for the LoopBlock component
|
||||
*/
|
||||
export interface LoopBlockProps {
|
||||
/** Child elements to render inside the loop block */
|
||||
children?: React.ReactNode
|
||||
/** Optional CSS class names */
|
||||
className?: string
|
||||
/** Optional inline styles */
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
/**
|
||||
* Loop block container component that provides a styled container
|
||||
* for grouping related elements with a dashed border
|
||||
* @param props - Component properties including children and styling
|
||||
* @returns A styled loop container component
|
||||
*/
|
||||
export const LoopBlock = React.memo(function LoopBlock({
|
||||
children,
|
||||
className,
|
||||
style,
|
||||
}: LoopBlockProps) {
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-shrink-0 ${className ?? ''}`}
|
||||
style={{
|
||||
width: '1198px',
|
||||
height: '528px',
|
||||
borderRadius: '14px',
|
||||
background: 'rgba(59, 130, 246, 0.10)',
|
||||
position: 'relative',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{/* Custom dashed border with SVG */}
|
||||
<svg
|
||||
className='pointer-events-none absolute inset-0 h-full w-full'
|
||||
style={{ borderRadius: '14px' }}
|
||||
preserveAspectRatio='none'
|
||||
>
|
||||
<path
|
||||
className='landing-loop-animated-dash'
|
||||
d='M 1183.5 527.5
|
||||
L 14 527.5
|
||||
A 13.5 13.5 0 0 1 0.5 514
|
||||
L 0.5 14
|
||||
A 13.5 13.5 0 0 1 14 0.5
|
||||
L 1183.5 0.5
|
||||
A 13.5 13.5 0 0 1 1197 14
|
||||
L 1197 514
|
||||
A 13.5 13.5 0 0 1 1183.5 527.5 Z'
|
||||
fill='none'
|
||||
stroke='#3B82F6'
|
||||
strokeWidth='1'
|
||||
strokeDasharray='12 12'
|
||||
strokeLinecap='round'
|
||||
/>
|
||||
</svg>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import React from 'react'
|
||||
|
||||
/**
|
||||
* Properties for a tag component
|
||||
*/
|
||||
export interface TagProps {
|
||||
/** Icon element to display in the tag */
|
||||
icon: React.ReactNode
|
||||
/** Text label for the tag */
|
||||
label: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Tag component for displaying labeled icons in a compact format
|
||||
* @param props - Tag properties including icon and label
|
||||
* @returns A styled tag component
|
||||
*/
|
||||
export const Tag = React.memo(function Tag({ icon, label }: TagProps) {
|
||||
return (
|
||||
<div className='flex w-fit items-center gap-1 rounded-[8px] border border-gray-300 bg-white px-2 py-0.5'>
|
||||
<div className='h-3 w-3 text-muted-foreground'>{icon}</div>
|
||||
<p className='text-muted-foreground text-xs leading-normal'>{label}</p>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,152 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import type { Edge, Node } from 'reactflow'
|
||||
import { ReactFlowProvider } from 'reactflow'
|
||||
import { DotPattern } from './dot-pattern'
|
||||
import type { LandingCardData } from './landing-block/landing-block'
|
||||
import { LandingFlow } from './landing-flow'
|
||||
|
||||
/**
|
||||
* Visual constants for landing node dimensions
|
||||
*/
|
||||
export const CARD_WIDTH = 256
|
||||
export const CARD_HEIGHT = 92
|
||||
|
||||
/**
|
||||
* Landing block node with positioning information
|
||||
*/
|
||||
export interface LandingBlockNode extends LandingCardData {
|
||||
/** Unique identifier for the node */
|
||||
id: string
|
||||
/** X coordinate position */
|
||||
x: number
|
||||
/** Y coordinate position */
|
||||
y: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Data structure for edges connecting nodes
|
||||
*/
|
||||
export interface LandingEdgeData {
|
||||
/** Unique identifier for the edge */
|
||||
id: string
|
||||
/** Source node ID */
|
||||
from: string
|
||||
/** Target node ID */
|
||||
to: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Data structure for grouping visual elements
|
||||
*/
|
||||
export interface LandingGroupData {
|
||||
/** X coordinate of the group */
|
||||
x: number
|
||||
/** Y coordinate of the group */
|
||||
y: number
|
||||
/** Width of the group */
|
||||
w: number
|
||||
/** Height of the group */
|
||||
h: number
|
||||
/** Labels associated with the group */
|
||||
labels: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual block with responsive positioning
|
||||
*/
|
||||
export interface LandingManualBlock extends Omit<LandingCardData, 'x' | 'y'> {
|
||||
/** Unique identifier */
|
||||
id: string
|
||||
/** Responsive position configurations */
|
||||
positions: {
|
||||
/** Position for mobile devices */
|
||||
mobile: { x: number; y: number }
|
||||
/** Position for tablet devices */
|
||||
tablet: { x: number; y: number }
|
||||
/** Position for desktop devices */
|
||||
desktop: { x: number; y: number }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Public API for controlling the viewport
|
||||
*/
|
||||
export interface LandingViewportApi {
|
||||
/**
|
||||
* Pan the viewport to specific coordinates
|
||||
* @param x - X coordinate to pan to
|
||||
* @param y - Y coordinate to pan to
|
||||
* @param options - Optional configuration for the pan animation
|
||||
*/
|
||||
panTo: (x: number, y: number, options?: { duration?: number }) => void
|
||||
/**
|
||||
* Get the current viewport state
|
||||
* @returns Current viewport position and zoom level
|
||||
*/
|
||||
getViewport: () => { x: number; y: number; zoom: number }
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for the LandingCanvas component
|
||||
*/
|
||||
export interface LandingCanvasProps {
|
||||
/** Array of nodes to render */
|
||||
nodes: Node[]
|
||||
/** Array of edges connecting nodes */
|
||||
edges: Edge[]
|
||||
/** Optional group box for visual grouping */
|
||||
groupBox: LandingGroupData | null
|
||||
/** Total width of the world/canvas */
|
||||
worldWidth: number
|
||||
/** Ref to expose viewport control API */
|
||||
viewportApiRef: React.MutableRefObject<LandingViewportApi | null>
|
||||
}
|
||||
|
||||
/**
|
||||
* Main landing canvas component that provides the container and background
|
||||
* for the React Flow visualization
|
||||
* @param props - Component properties including nodes, edges, and viewport control
|
||||
* @returns A canvas component with dot pattern background and React Flow content
|
||||
*/
|
||||
export function LandingCanvas({
|
||||
nodes,
|
||||
edges,
|
||||
groupBox,
|
||||
worldWidth,
|
||||
viewportApiRef,
|
||||
}: LandingCanvasProps) {
|
||||
const flowWrapRef = React.useRef<HTMLDivElement | null>(null)
|
||||
|
||||
return (
|
||||
<div className='relative mx-auto flex h-[612px] w-full max-w-[1285px] border-none bg-background/80'>
|
||||
<DotPattern className='pointer-events-none absolute inset-0 z-0 h-full w-full opacity-20' />
|
||||
|
||||
{/* Use template button overlay */}
|
||||
{/* <button
|
||||
type='button'
|
||||
aria-label='Use template'
|
||||
className='absolute top-[24px] left-[50px] z-20 inline-flex items-center justify-center rounded-[10px] border border-[#343434] bg-gradient-to-b from-[#060606] to-[#323232] px-3 py-1.5 text-sm text-white shadow-[inset_0_1.25px_2.5px_0_#9B77FF] transition-all duration-200'
|
||||
onClick={() => {
|
||||
// Template usage logic will be implemented here
|
||||
}}
|
||||
>
|
||||
Use template
|
||||
</button> */}
|
||||
|
||||
<div ref={flowWrapRef} className='relative z-10 h-full w-full'>
|
||||
<ReactFlowProvider>
|
||||
<LandingFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
groupBox={groupBox}
|
||||
worldWidth={worldWidth}
|
||||
wrapperRef={flowWrapRef}
|
||||
viewportApiRef={viewportApiRef}
|
||||
/>
|
||||
</ReactFlowProvider>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { type EdgeProps, getSmoothStepPath, Position } from 'reactflow'
|
||||
|
||||
/**
|
||||
* Custom edge component with animated dotted line that floats between handles
|
||||
* @param props - React Flow edge properties
|
||||
* @returns An animated dotted edge component
|
||||
*/
|
||||
export const LandingEdge = React.memo(function LandingEdge(props: EdgeProps) {
|
||||
const { id, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, style, data } =
|
||||
props
|
||||
|
||||
// Adjust the connection points to create floating effect
|
||||
// Account for handle size (12px) and additional spacing
|
||||
const handleRadius = 6 // Half of handle width (12px)
|
||||
const floatingGap = 1 // Additional gap for floating effect
|
||||
|
||||
// Calculate adjusted positions based on edge direction
|
||||
let adjustedSourceX = sourceX
|
||||
let adjustedTargetX = targetX
|
||||
|
||||
if (sourcePosition === Position.Right) {
|
||||
adjustedSourceX = sourceX + handleRadius + floatingGap
|
||||
} else if (sourcePosition === Position.Left) {
|
||||
adjustedSourceX = sourceX - handleRadius - floatingGap
|
||||
}
|
||||
|
||||
if (targetPosition === Position.Left) {
|
||||
adjustedTargetX = targetX - handleRadius - floatingGap
|
||||
} else if (targetPosition === Position.Right) {
|
||||
adjustedTargetX = targetX + handleRadius + floatingGap
|
||||
}
|
||||
|
||||
const [path] = getSmoothStepPath({
|
||||
sourceX: adjustedSourceX,
|
||||
sourceY,
|
||||
targetX: adjustedTargetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
borderRadius: 20,
|
||||
offset: 10,
|
||||
})
|
||||
|
||||
return (
|
||||
<g style={{ zIndex: 1 }}>
|
||||
<style>
|
||||
{`
|
||||
@keyframes landing-edge-dash-${id} {
|
||||
from {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
to {
|
||||
stroke-dashoffset: -12;
|
||||
}
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
<path
|
||||
id={id}
|
||||
d={path}
|
||||
fill='none'
|
||||
className='react-flow__edge-path'
|
||||
style={{
|
||||
stroke: '#D1D1D1',
|
||||
strokeWidth: 2,
|
||||
strokeDasharray: '6 6',
|
||||
strokeLinecap: 'round',
|
||||
strokeLinejoin: 'round',
|
||||
pointerEvents: 'none',
|
||||
animation: `landing-edge-dash-${id} 1s linear infinite`,
|
||||
willChange: 'stroke-dashoffset',
|
||||
...style,
|
||||
}}
|
||||
/>
|
||||
</g>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,148 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import ReactFlow, { applyNodeChanges, type NodeChange, useReactFlow } from 'reactflow'
|
||||
import 'reactflow/dist/style.css'
|
||||
import { LandingLoopNode } from './landing-block/landing-loop-node'
|
||||
import { LandingNode } from './landing-block/landing-node'
|
||||
import { CARD_WIDTH, type LandingCanvasProps } from './landing-canvas'
|
||||
import { LandingEdge } from './landing-edge/landing-edge'
|
||||
|
||||
/**
|
||||
* Props for the LandingFlow component
|
||||
*/
|
||||
export interface LandingFlowProps extends LandingCanvasProps {
|
||||
/** Reference to the wrapper element */
|
||||
wrapperRef: React.RefObject<HTMLDivElement | null>
|
||||
}
|
||||
|
||||
/**
|
||||
* React Flow wrapper component for the landing canvas
|
||||
* Handles viewport control, auto-panning, and node/edge rendering
|
||||
* @param props - Component properties including nodes, edges, and viewport control
|
||||
* @returns A configured React Flow instance
|
||||
*/
|
||||
export function LandingFlow({
|
||||
nodes,
|
||||
edges,
|
||||
groupBox,
|
||||
worldWidth,
|
||||
wrapperRef,
|
||||
viewportApiRef,
|
||||
}: LandingFlowProps) {
|
||||
const { setViewport, getViewport } = useReactFlow()
|
||||
const [rfReady, setRfReady] = React.useState(false)
|
||||
const [localNodes, setLocalNodes] = React.useState(nodes)
|
||||
|
||||
// Update local nodes when props change
|
||||
React.useEffect(() => {
|
||||
setLocalNodes(nodes)
|
||||
}, [nodes])
|
||||
|
||||
// Handle node changes (dragging)
|
||||
const onNodesChange = React.useCallback((changes: NodeChange[]) => {
|
||||
setLocalNodes((nds) => applyNodeChanges(changes, nds))
|
||||
}, [])
|
||||
|
||||
// Node and edge types map
|
||||
const nodeTypes = React.useMemo(
|
||||
() => ({
|
||||
landing: LandingNode,
|
||||
landingLoop: LandingLoopNode,
|
||||
group: LandingLoopNode, // Use our custom loop node for group type
|
||||
}),
|
||||
[]
|
||||
)
|
||||
const edgeTypes = React.useMemo(() => ({ landingEdge: LandingEdge }), [])
|
||||
|
||||
// Compose nodes with optional group overlay
|
||||
const flowNodes = localNodes
|
||||
|
||||
// Auto-pan to the right only if content overflows the wrapper
|
||||
React.useEffect(() => {
|
||||
const el = wrapperRef.current as HTMLDivElement | null
|
||||
if (!el || !rfReady || localNodes.length === 0) return
|
||||
|
||||
const containerWidth = el.clientWidth
|
||||
// Derive overflow from actual node positions for accuracy
|
||||
const PAD = 16
|
||||
const maxRight = localNodes.reduce((m, n) => Math.max(m, (n.position?.x ?? 0) + CARD_WIDTH), 0)
|
||||
const contentWidth = Math.max(worldWidth, maxRight + PAD)
|
||||
const overflow = Math.max(0, contentWidth - containerWidth)
|
||||
|
||||
// Delay pan so initial nodes are visible briefly
|
||||
const timer = window.setTimeout(() => {
|
||||
if (overflow > 12) {
|
||||
setViewport({ x: -overflow, y: 0, zoom: 1 }, { duration: 900 })
|
||||
}
|
||||
}, 1400)
|
||||
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [worldWidth, wrapperRef, setViewport, rfReady, localNodes])
|
||||
|
||||
return (
|
||||
<ReactFlow
|
||||
nodes={flowNodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
defaultEdgeOptions={{ type: 'smoothstep' }}
|
||||
elementsSelectable={true}
|
||||
selectNodesOnDrag={false}
|
||||
nodesDraggable={true}
|
||||
nodesConnectable={false}
|
||||
zoomOnScroll={false}
|
||||
zoomOnDoubleClick={false}
|
||||
panOnScroll={false}
|
||||
zoomOnPinch={false}
|
||||
panOnDrag={false}
|
||||
draggable={false}
|
||||
preventScrolling={false}
|
||||
autoPanOnNodeDrag={false}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
fitView={false}
|
||||
defaultViewport={{ x: 0, y: 0, zoom: 1 }}
|
||||
onInit={(instance) => {
|
||||
setRfReady(true)
|
||||
// Expose limited viewport API for outer timeline to pan smoothly
|
||||
viewportApiRef.current = {
|
||||
panTo: (x: number, y: number, options?: { duration?: number }) => {
|
||||
setViewport({ x, y, zoom: 1 }, { duration: options?.duration ?? 0 })
|
||||
},
|
||||
getViewport: () => getViewport(),
|
||||
}
|
||||
}}
|
||||
className='h-full w-full'
|
||||
style={{
|
||||
// Override React Flow's default cursor styles
|
||||
cursor: 'default',
|
||||
}}
|
||||
>
|
||||
<style>
|
||||
{`
|
||||
/* Force default cursor on the canvas/pane */
|
||||
.react-flow__pane {
|
||||
cursor: default !important;
|
||||
}
|
||||
|
||||
/* Force grab cursor on nodes */
|
||||
.react-flow__node {
|
||||
cursor: grab !important;
|
||||
}
|
||||
|
||||
/* Force grabbing cursor when dragging nodes */
|
||||
.react-flow__node.dragging {
|
||||
cursor: grabbing !important;
|
||||
}
|
||||
|
||||
/* Ensure viewport also has default cursor */
|
||||
.react-flow__viewport {
|
||||
cursor: default !important;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
{null}
|
||||
</ReactFlow>
|
||||
)
|
||||
}
|
||||
487
apps/sim/app/(landing)/components/hero/hero.tsx
Normal file
487
apps/sim/app/(landing)/components/hero/hero.tsx
Normal file
@@ -0,0 +1,487 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import {
|
||||
ArrowUp,
|
||||
BinaryIcon,
|
||||
BookIcon,
|
||||
CalendarIcon,
|
||||
CodeIcon,
|
||||
Globe2Icon,
|
||||
MessageSquareIcon,
|
||||
VariableIcon,
|
||||
} from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { type Edge, type Node, Position } from 'reactflow'
|
||||
import {
|
||||
AgentIcon,
|
||||
AirtableIcon,
|
||||
DiscordIcon,
|
||||
GmailIcon,
|
||||
GoogleDriveIcon,
|
||||
GoogleSheetsIcon,
|
||||
JiraIcon,
|
||||
LinearIcon,
|
||||
NotionIcon,
|
||||
OpenAIIcon,
|
||||
OutlookIcon,
|
||||
PackageSearchIcon,
|
||||
PineconeIcon,
|
||||
ScheduleIcon,
|
||||
SlackIcon,
|
||||
StripeIcon,
|
||||
SupabaseIcon,
|
||||
} from '@/components/icons'
|
||||
import { soehne } from '@/app/fonts/soehne/soehne'
|
||||
import {
|
||||
CARD_WIDTH,
|
||||
IconButton,
|
||||
LandingCanvas,
|
||||
type LandingGroupData,
|
||||
type LandingManualBlock,
|
||||
type LandingViewportApi,
|
||||
} from './components'
|
||||
|
||||
/**
|
||||
* Service-specific template messages for the hero input
|
||||
*/
|
||||
const SERVICE_TEMPLATES = {
|
||||
slack: 'Summarizer agent that summarizes each new message in #general and sends me a DM',
|
||||
gmail: 'Alert agent that flags important Gmail messages in my inbox',
|
||||
outlook:
|
||||
'Auto-forwarding agent that classifies each new Outlook email and forwards to separate inboxes for further analysis',
|
||||
pinecone: 'RAG chat agent that uses memories stored in Pinecone',
|
||||
supabase: 'Natural language to SQL agent to query and update data in Supabase',
|
||||
linear: 'Agent that uses Linear to triage issues, assign owners, and draft updates',
|
||||
discord: 'Moderator agent that responds back to users in my Discord server',
|
||||
airtable: 'Alert agent that validates each new record in a table and prepares a weekly report',
|
||||
stripe: 'Agent that analyzes Stripe payment history to spot churn risks and generate summaries',
|
||||
notion: 'Support agent that appends new support tickets to my Notion workspace',
|
||||
googleSheets: 'Data science agent that analyzes Google Sheets data and generates insights',
|
||||
googleDrive: 'Drive reader agent that summarizes content in my Google Drive',
|
||||
jira: 'Engineering manager agent that uses Jira to update ticket statuses, generate sprint reports, and identify blockers',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Landing blocks for the canvas preview
|
||||
*/
|
||||
const LANDING_BLOCKS: LandingManualBlock[] = [
|
||||
{
|
||||
id: 'schedule',
|
||||
name: 'Schedule',
|
||||
color: '#7B68EE',
|
||||
icon: <ScheduleIcon className='h-4 w-4' />,
|
||||
positions: {
|
||||
mobile: { x: 8, y: 60 },
|
||||
tablet: { x: 40, y: 120 },
|
||||
desktop: { x: 60, y: 180 },
|
||||
},
|
||||
tags: [
|
||||
{ icon: <CalendarIcon className='h-3 w-3' />, label: '09:00AM Daily' },
|
||||
{ icon: <Globe2Icon className='h-3 w-3' />, label: 'PST' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'knowledge',
|
||||
name: 'Knowledge',
|
||||
color: '#00B0B0',
|
||||
icon: <PackageSearchIcon className='h-4 w-4' />,
|
||||
positions: {
|
||||
mobile: { x: 120, y: 140 },
|
||||
tablet: { x: 220, y: 200 },
|
||||
desktop: { x: 420, y: 241 },
|
||||
},
|
||||
tags: [
|
||||
{ icon: <BookIcon className='h-3 w-3' />, label: 'Product Vector DB' },
|
||||
{ icon: <BinaryIcon className='h-3 w-3' />, label: 'Limit: 10' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'agent',
|
||||
name: 'Agent',
|
||||
color: '#802FFF',
|
||||
icon: <AgentIcon className='h-4 w-4' />,
|
||||
positions: {
|
||||
mobile: { x: 340, y: 60 },
|
||||
tablet: { x: 540, y: 120 },
|
||||
desktop: { x: 880, y: 142 },
|
||||
},
|
||||
tags: [
|
||||
{ icon: <OpenAIIcon className='h-3 w-3' />, label: 'gpt-5' },
|
||||
{ icon: <MessageSquareIcon className='h-3 w-3' />, label: 'You are a support ag...' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'function',
|
||||
name: 'Function',
|
||||
color: '#FF402F',
|
||||
icon: <CodeIcon className='h-4 w-4' />,
|
||||
positions: {
|
||||
mobile: { x: 480, y: 220 },
|
||||
tablet: { x: 740, y: 280 },
|
||||
desktop: { x: 880, y: 340 },
|
||||
},
|
||||
tags: [
|
||||
{ icon: <CodeIcon className='h-3 w-3' />, label: 'Python' },
|
||||
{ icon: <VariableIcon className='h-3 w-3' />, label: 'time = "2025-09-01...' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* Sample workflow edges for the canvas preview
|
||||
*/
|
||||
const SAMPLE_WORKFLOW_EDGES = [
|
||||
{ id: 'e1', from: 'schedule', to: 'knowledge' },
|
||||
{ id: 'e2', from: 'knowledge', to: 'agent' },
|
||||
{ id: 'e3', from: 'knowledge', to: 'function' },
|
||||
]
|
||||
|
||||
/**
|
||||
* Hero component for the landing page featuring service integrations and workflow preview
|
||||
*/
|
||||
export default function Hero() {
|
||||
const router = useRouter()
|
||||
|
||||
/**
|
||||
* State management for the text input
|
||||
*/
|
||||
const [textValue, setTextValue] = React.useState('')
|
||||
const isEmpty = textValue.trim().length === 0
|
||||
|
||||
/**
|
||||
* State for responsive icon display
|
||||
*/
|
||||
const [visibleIconCount, setVisibleIconCount] = React.useState(13)
|
||||
const [isMobile, setIsMobile] = React.useState(false)
|
||||
|
||||
/**
|
||||
* React Flow state for workflow preview canvas
|
||||
*/
|
||||
const [rfNodes, setRfNodes] = React.useState<Node[]>([])
|
||||
const [rfEdges, setRfEdges] = React.useState<Edge[]>([])
|
||||
const [groupBox, setGroupBox] = React.useState<LandingGroupData | null>(null)
|
||||
const [worldWidth, setWorldWidth] = React.useState<number>(1000)
|
||||
const viewportApiRef = React.useRef<LandingViewportApi | null>(null)
|
||||
|
||||
/**
|
||||
* Auto-hover animation state
|
||||
*/
|
||||
const [autoHoverIndex, setAutoHoverIndex] = React.useState(1)
|
||||
const [isUserHovering, setIsUserHovering] = React.useState(false)
|
||||
const [lastHoveredIndex, setLastHoveredIndex] = React.useState<number | null>(null)
|
||||
const intervalRef = React.useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
/**
|
||||
* Handle service icon click to populate textarea with template
|
||||
*/
|
||||
const handleServiceClick = (service: keyof typeof SERVICE_TEMPLATES) => {
|
||||
setTextValue(SERVICE_TEMPLATES[service])
|
||||
}
|
||||
|
||||
/**
|
||||
* Set visible icon count based on screen size
|
||||
*/
|
||||
React.useEffect(() => {
|
||||
const updateVisibleIcons = () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const mobile = window.innerWidth < 640
|
||||
setVisibleIconCount(mobile ? 6 : 13)
|
||||
setIsMobile(mobile)
|
||||
}
|
||||
}
|
||||
|
||||
updateVisibleIcons()
|
||||
window.addEventListener('resize', updateVisibleIcons)
|
||||
|
||||
return () => window.removeEventListener('resize', updateVisibleIcons)
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Service icons array for easier indexing
|
||||
*/
|
||||
const serviceIcons: Array<{
|
||||
key: string
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
label: string
|
||||
style?: React.CSSProperties
|
||||
}> = [
|
||||
{ key: 'slack', icon: SlackIcon, label: 'Slack' },
|
||||
{ key: 'gmail', icon: GmailIcon, label: 'Gmail' },
|
||||
{ key: 'outlook', icon: OutlookIcon, label: 'Outlook' },
|
||||
{ key: 'pinecone', icon: PineconeIcon, label: 'Pinecone' },
|
||||
{ key: 'supabase', icon: SupabaseIcon, label: 'Supabase' },
|
||||
{ key: 'linear', icon: LinearIcon, label: 'Linear', style: { color: '#5E6AD2' } },
|
||||
{ key: 'discord', icon: DiscordIcon, label: 'Discord', style: { color: '#5765F2' } },
|
||||
{ key: 'airtable', icon: AirtableIcon, label: 'Airtable' },
|
||||
{ key: 'stripe', icon: StripeIcon, label: 'Stripe' },
|
||||
{ key: 'notion', icon: NotionIcon, label: 'Notion' },
|
||||
{ key: 'googleSheets', icon: GoogleSheetsIcon, label: 'Google Sheets' },
|
||||
{ key: 'googleDrive', icon: GoogleDriveIcon, label: 'Google Drive' },
|
||||
{ key: 'jira', icon: JiraIcon, label: 'Jira' },
|
||||
]
|
||||
|
||||
/**
|
||||
* Auto-hover animation effect
|
||||
*/
|
||||
React.useEffect(() => {
|
||||
// Start the interval when component mounts
|
||||
const startInterval = () => {
|
||||
intervalRef.current = setInterval(() => {
|
||||
setAutoHoverIndex((prev) => (prev + 1) % visibleIconCount)
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
// Only run interval when user is not hovering
|
||||
if (!isUserHovering) {
|
||||
startInterval()
|
||||
}
|
||||
|
||||
// Cleanup on unmount or when hovering state changes
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
}
|
||||
}
|
||||
}, [isUserHovering, visibleIconCount])
|
||||
|
||||
/**
|
||||
* Handle mouse enter on icon container
|
||||
*/
|
||||
const handleIconContainerMouseEnter = () => {
|
||||
setIsUserHovering(true)
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mouse leave on icon container
|
||||
*/
|
||||
const handleIconContainerMouseLeave = () => {
|
||||
setIsUserHovering(false)
|
||||
// Start from the next icon after the last hovered one
|
||||
if (lastHoveredIndex !== null) {
|
||||
setAutoHoverIndex((lastHoveredIndex + 1) % visibleIconCount)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle form submission
|
||||
*/
|
||||
const handleSubmit = () => {
|
||||
if (!isEmpty) {
|
||||
router.push('/signup')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle keyboard shortcuts (Enter to submit)
|
||||
*/
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
if (!isEmpty) {
|
||||
handleSubmit()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize workflow preview with sample data
|
||||
*/
|
||||
React.useEffect(() => {
|
||||
// Determine breakpoint for responsive positioning
|
||||
const breakpoint =
|
||||
typeof window !== 'undefined' && window.innerWidth < 640
|
||||
? 'mobile'
|
||||
: typeof window !== 'undefined' && window.innerWidth < 1024
|
||||
? 'tablet'
|
||||
: 'desktop'
|
||||
|
||||
// Convert landing blocks to React Flow nodes
|
||||
const nodes: Node[] = [
|
||||
// Add the loop block node as a group with custom rendering
|
||||
{
|
||||
id: 'loop',
|
||||
type: 'group',
|
||||
position: { x: 720, y: 20 },
|
||||
data: {
|
||||
label: 'Loop',
|
||||
},
|
||||
draggable: false,
|
||||
selectable: false,
|
||||
focusable: false,
|
||||
connectable: false,
|
||||
// Group node properties for subflow functionality
|
||||
style: {
|
||||
width: 1198,
|
||||
height: 528,
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
},
|
||||
},
|
||||
// Convert blocks to nodes
|
||||
...LANDING_BLOCKS.map((block, index) => {
|
||||
// Make agent and function nodes children of the loop
|
||||
const isLoopChild = block.id === 'agent' || block.id === 'function'
|
||||
const baseNode = {
|
||||
id: block.id,
|
||||
type: 'landing',
|
||||
position: isLoopChild
|
||||
? {
|
||||
// Adjust positions relative to loop parent (original positions - loop position)
|
||||
x: block.id === 'agent' ? 160 : 160,
|
||||
y: block.id === 'agent' ? 122 : 320,
|
||||
}
|
||||
: block.positions[breakpoint],
|
||||
data: {
|
||||
icon: block.icon,
|
||||
color: block.color,
|
||||
name: block.name,
|
||||
tags: block.tags,
|
||||
delay: index * 0.18,
|
||||
hideTargetHandle: block.id === 'schedule', // Hide target handle for schedule node
|
||||
hideSourceHandle: block.id === 'agent' || block.id === 'function', // Hide source handle for agent and function nodes
|
||||
},
|
||||
sourcePosition: Position.Right,
|
||||
targetPosition: Position.Left,
|
||||
}
|
||||
|
||||
// Add parent properties for loop children
|
||||
if (isLoopChild) {
|
||||
return {
|
||||
...baseNode,
|
||||
parentId: 'loop',
|
||||
extent: 'parent',
|
||||
}
|
||||
}
|
||||
|
||||
return baseNode
|
||||
}),
|
||||
]
|
||||
|
||||
// Convert sample edges to React Flow edges
|
||||
const rfEdges: Edge[] = SAMPLE_WORKFLOW_EDGES.map((e) => ({
|
||||
id: e.id,
|
||||
source: e.from,
|
||||
target: e.to,
|
||||
type: 'landingEdge',
|
||||
animated: false,
|
||||
data: { delay: 0.6 },
|
||||
}))
|
||||
|
||||
setRfNodes(nodes)
|
||||
setRfEdges(rfEdges)
|
||||
|
||||
// Calculate world width for canvas
|
||||
const maxX = Math.max(...nodes.map((n) => n.position.x))
|
||||
setWorldWidth(maxX + CARD_WIDTH + 32)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<section
|
||||
id='hero'
|
||||
className={`${soehne.className} flex w-full flex-col items-center justify-center pt-[36px] sm:pt-[80px]`}
|
||||
aria-labelledby='hero-heading'
|
||||
>
|
||||
<h1
|
||||
id='hero-heading'
|
||||
className='px-4 text-center font-medium text-[36px] leading-none tracking-tight sm:px-0 sm:text-[74px]'
|
||||
>
|
||||
Workflows for LLMs
|
||||
</h1>
|
||||
<p className='px-4 pt-[6px] text-center text-[18px] opacity-70 sm:px-0 sm:pt-[10px] sm:text-[22px]'>
|
||||
Build and deploy AI agent workflows
|
||||
</p>
|
||||
<div
|
||||
className='flex items-center justify-center gap-[2px] pt-[18px] sm:pt-[32px]'
|
||||
onMouseEnter={handleIconContainerMouseEnter}
|
||||
onMouseLeave={handleIconContainerMouseLeave}
|
||||
>
|
||||
{/* Service integration buttons */}
|
||||
{serviceIcons.slice(0, visibleIconCount).map((service, index) => {
|
||||
const Icon = service.icon
|
||||
return (
|
||||
<IconButton
|
||||
key={service.key}
|
||||
aria-label={service.label}
|
||||
onClick={() => handleServiceClick(service.key as keyof typeof SERVICE_TEMPLATES)}
|
||||
onMouseEnter={() => setLastHoveredIndex(index)}
|
||||
style={service.style}
|
||||
isAutoHovered={!isUserHovering && index === autoHoverIndex}
|
||||
>
|
||||
<Icon className='h-5 w-5 sm:h-6 sm:w-6' />
|
||||
</IconButton>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className='flex w-full items-center justify-center px-4 pt-[8px] sm:px-8 sm:pt-[12px] md:px-[50px]'>
|
||||
<div className='relative w-full sm:w-[640px]'>
|
||||
<label htmlFor='agent-description' className='sr-only'>
|
||||
Describe the AI agent you want to build
|
||||
</label>
|
||||
<textarea
|
||||
id='agent-description'
|
||||
placeholder={
|
||||
isMobile ? 'Build an AI agent...' : 'Ask Sim to build an agent to read my emails...'
|
||||
}
|
||||
className='h-[100px] w-full resize-none px-3 py-2.5 text-sm sm:h-[120px] sm:px-4 sm:py-3 sm:text-base'
|
||||
value={textValue}
|
||||
onChange={(e) => setTextValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
style={{
|
||||
borderRadius: 16,
|
||||
border: 'var(--border-width-border, 1px) solid #E5E5E5',
|
||||
outline: 'none',
|
||||
background: '#FFFFFF',
|
||||
boxShadow:
|
||||
'var(--shadow-xs-offset-x, 0) var(--shadow-xs-offset-y, 2px) var(--shadow-xs-blur-radius, 4px) var(--shadow-xs-spread-radius, 0) var(--shadow-xs-color, rgba(0, 0, 0, 0.08))',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
key={isEmpty ? 'empty' : 'filled'}
|
||||
type='button'
|
||||
aria-label='Submit description'
|
||||
className='absolute right-2.5 bottom-4 flex h-[30px] w-[30px] items-center justify-center transition-all duration-200 sm:right-[11px] sm:bottom-[16px] sm:h-[34px] sm:w-[34px]'
|
||||
disabled={isEmpty}
|
||||
onClick={handleSubmit}
|
||||
style={{
|
||||
padding: '3.75px 3.438px 3.75px 4.063px',
|
||||
borderRadius: 55,
|
||||
...(isEmpty
|
||||
? {
|
||||
border: '0.625px solid #E0E0E0',
|
||||
background: '#E5E5E5',
|
||||
boxShadow: 'none',
|
||||
cursor: 'not-allowed',
|
||||
}
|
||||
: {
|
||||
border: '0.625px solid #343434',
|
||||
background: 'linear-gradient(180deg, #060606 0%, #323232 100%)',
|
||||
boxShadow: '0 1.25px 2.5px 0 #9B77FF inset',
|
||||
cursor: 'pointer',
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<ArrowUp size={18} className='sm:h-5 sm:w-5' color={isEmpty ? '#999999' : '#FFFFFF'} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Canvas - hidden on mobile */}
|
||||
{!isMobile && (
|
||||
<div className='mt-[60px] w-full max-w-[1308px] sm:mt-[127.5px]'>
|
||||
<LandingCanvas
|
||||
nodes={rfNodes}
|
||||
edges={rfEdges}
|
||||
groupBox={groupBox}
|
||||
worldWidth={worldWidth}
|
||||
viewportApiRef={viewportApiRef}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
export default function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = useState(false)
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
const checkMobile = () => setIsMobile(window.innerWidth < 768)
|
||||
checkMobile()
|
||||
|
||||
window.addEventListener('resize', checkMobile)
|
||||
return () => window.removeEventListener('resize', checkMobile)
|
||||
}, [])
|
||||
|
||||
return { isMobile, mounted }
|
||||
}
|
||||
23
apps/sim/app/(landing)/components/index.ts
Normal file
23
apps/sim/app/(landing)/components/index.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import Background from '@/app/(landing)/components/background/background'
|
||||
import Footer from '@/app/(landing)/components/footer/footer'
|
||||
import Hero from '@/app/(landing)/components/hero/hero'
|
||||
import Integrations from '@/app/(landing)/components/integrations/integrations'
|
||||
import LandingPricing from '@/app/(landing)/components/landing-pricing/landing-pricing'
|
||||
import LandingTemplates from '@/app/(landing)/components/landing-templates/landing-templates'
|
||||
import LegalLayout from '@/app/(landing)/components/legal-layout'
|
||||
import Nav from '@/app/(landing)/components/nav/nav'
|
||||
import StructuredData from '@/app/(landing)/components/structured-data'
|
||||
import Testimonials from '@/app/(landing)/components/testimonials/testimonials'
|
||||
|
||||
export {
|
||||
Integrations,
|
||||
Testimonials,
|
||||
LandingTemplates,
|
||||
Nav,
|
||||
Background,
|
||||
Hero,
|
||||
LandingPricing,
|
||||
Footer,
|
||||
StructuredData,
|
||||
LegalLayout,
|
||||
}
|
||||
148
apps/sim/app/(landing)/components/integrations/integrations.tsx
Normal file
148
apps/sim/app/(landing)/components/integrations/integrations.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import * as Icons from '@/components/icons'
|
||||
import { inter } from '@/app/fonts/inter'
|
||||
|
||||
// AI models and providers
|
||||
const modelProviderIcons = [
|
||||
{ icon: Icons.OpenAIIcon, label: 'OpenAI' },
|
||||
{ icon: Icons.AnthropicIcon, label: 'Anthropic' },
|
||||
{ icon: Icons.GeminiIcon, label: 'Gemini' },
|
||||
{ icon: Icons.MistralIcon, label: 'Mistral' },
|
||||
{ icon: Icons.PerplexityIcon, label: 'Perplexity' },
|
||||
{ icon: Icons.xAIIcon, label: 'xAI' },
|
||||
{ icon: Icons.GroqIcon, label: 'Groq' },
|
||||
{ icon: Icons.HuggingFaceIcon, label: 'HuggingFace' },
|
||||
{ icon: Icons.OllamaIcon, label: 'Ollama' },
|
||||
{ icon: Icons.DeepseekIcon, label: 'Deepseek' },
|
||||
{ icon: Icons.ElevenLabsIcon, label: 'ElevenLabs' },
|
||||
]
|
||||
|
||||
// Communication and productivity tools
|
||||
const communicationIcons = [
|
||||
{ icon: Icons.SlackIcon, label: 'Slack' },
|
||||
{ icon: Icons.GmailIcon, label: 'Gmail' },
|
||||
{ icon: Icons.OutlookIcon, label: 'Outlook' },
|
||||
{ icon: Icons.DiscordIcon, label: 'Discord', style: { color: '#5765F2' } },
|
||||
{ icon: Icons.LinearIcon, label: 'Linear', style: { color: '#5E6AD2' } },
|
||||
{ icon: Icons.NotionIcon, label: 'Notion' },
|
||||
{ icon: Icons.JiraIcon, label: 'Jira' },
|
||||
{ icon: Icons.ConfluenceIcon, label: 'Confluence' },
|
||||
{ icon: Icons.TelegramIcon, label: 'Telegram' },
|
||||
{ icon: Icons.GoogleCalendarIcon, label: 'Google Calendar' },
|
||||
{ icon: Icons.GoogleDocsIcon, label: 'Google Docs' },
|
||||
{ icon: Icons.BrowserUseIcon, label: 'BrowserUse' },
|
||||
{ icon: Icons.TypeformIcon, label: 'Typeform' },
|
||||
{ icon: Icons.GithubIcon, label: 'GitHub' },
|
||||
{ icon: Icons.GoogleSheetsIcon, label: 'Google Sheets' },
|
||||
{ icon: Icons.GoogleDriveIcon, label: 'Google Drive' },
|
||||
{ icon: Icons.AirtableIcon, label: 'Airtable' },
|
||||
]
|
||||
|
||||
// Data, storage and search services
|
||||
const dataStorageIcons = [
|
||||
{ icon: Icons.PineconeIcon, label: 'Pinecone' },
|
||||
{ icon: Icons.SupabaseIcon, label: 'Supabase' },
|
||||
{ icon: Icons.PostgresIcon, label: 'PostgreSQL' },
|
||||
{ icon: Icons.MySQLIcon, label: 'MySQL' },
|
||||
{ icon: Icons.QdrantIcon, label: 'Qdrant' },
|
||||
{ icon: Icons.MicrosoftOneDriveIcon, label: 'OneDrive' },
|
||||
{ icon: Icons.MicrosoftSharepointIcon, label: 'SharePoint' },
|
||||
{ icon: Icons.SerperIcon, label: 'Serper' },
|
||||
{ icon: Icons.FirecrawlIcon, label: 'Firecrawl' },
|
||||
{ icon: Icons.StripeIcon, label: 'Stripe' },
|
||||
]
|
||||
|
||||
interface IntegrationBoxProps {
|
||||
icon?: React.ComponentType<{ className?: string }>
|
||||
style?: React.CSSProperties
|
||||
isVisible: boolean
|
||||
}
|
||||
|
||||
function IntegrationBox({ icon: Icon, style, isVisible }: IntegrationBoxProps) {
|
||||
return (
|
||||
<div
|
||||
className='flex h-[72px] w-[72px] items-center justify-center transition-all duration-300'
|
||||
style={{
|
||||
borderRadius: '12px',
|
||||
border: '1px solid var(--base-border, #E5E5E5)',
|
||||
background: 'var(--base-card, #FEFEFE)',
|
||||
opacity: isVisible ? 1 : 0.75,
|
||||
boxShadow: isVisible ? '0 2px 4px 0 rgba(0, 0, 0, 0.08)' : 'none',
|
||||
}}
|
||||
>
|
||||
{Icon && isVisible && (
|
||||
<div style={style}>
|
||||
<Icon className='h-8 w-8' />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface TickerRowProps {
|
||||
direction: 'left' | 'right'
|
||||
offset: number
|
||||
showOdd: boolean
|
||||
icons: Array<{
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
label: string
|
||||
style?: React.CSSProperties
|
||||
}>
|
||||
}
|
||||
|
||||
function TickerRow({ direction, offset, showOdd, icons }: TickerRowProps) {
|
||||
// Create multiple copies of the icons array for seamless looping
|
||||
const extendedIcons = [...icons, ...icons, ...icons, ...icons]
|
||||
|
||||
return (
|
||||
<div className='relative h-[88px] w-full overflow-hidden'>
|
||||
<div
|
||||
className={`absolute flex items-center gap-[16px] ${
|
||||
direction === 'left' ? 'animate-slide-left' : 'animate-slide-right'
|
||||
}`}
|
||||
style={{
|
||||
animationDelay: `${offset}s`,
|
||||
}}
|
||||
>
|
||||
{extendedIcons.map((service, index) => {
|
||||
const isOdd = index % 2 === 1
|
||||
const shouldShow = showOdd ? isOdd : !isOdd
|
||||
return (
|
||||
<IntegrationBox
|
||||
key={`${service.label}-${index}`}
|
||||
icon={service.icon}
|
||||
style={service.style}
|
||||
isVisible={shouldShow}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Integrations() {
|
||||
return (
|
||||
<section
|
||||
id='integrations'
|
||||
className={`${inter.className} flex flex-col pt-[40px] pb-[27px] sm:pt-[24px]`}
|
||||
aria-labelledby='integrations-heading'
|
||||
>
|
||||
<h2
|
||||
id='integrations-heading'
|
||||
className='mb-[4px] px-4 font-medium text-[28px] text-foreground tracking-tight sm:pl-[50px]'
|
||||
>
|
||||
Integrations
|
||||
</h2>
|
||||
<p className='mb-[24px] px-4 text-[#515151] text-[18px] sm:pl-[50px]'>
|
||||
Immediately connect to 100+ models and apps
|
||||
</p>
|
||||
|
||||
{/* Sliding tickers */}
|
||||
<div className='flex w-full flex-col sm:px-[12px]'>
|
||||
<TickerRow direction='left' offset={0} showOdd={false} icons={modelProviderIcons} />
|
||||
<TickerRow direction='right' offset={0.5} showOdd={true} icons={communicationIcons} />
|
||||
<TickerRow direction='left' offset={1} showOdd={false} icons={dataStorageIcons} />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import {
|
||||
ArrowRight,
|
||||
ChevronRight,
|
||||
Code2,
|
||||
Database,
|
||||
DollarSign,
|
||||
Users,
|
||||
Workflow,
|
||||
} from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { inter } from '@/app/fonts/inter'
|
||||
import {
|
||||
ENTERPRISE_PLAN_FEATURES,
|
||||
PRO_PLAN_FEATURES,
|
||||
TEAM_PLAN_FEATURES,
|
||||
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/plan-configs'
|
||||
|
||||
const logger = createLogger('LandingPricing')
|
||||
|
||||
interface PricingFeature {
|
||||
icon: LucideIcon
|
||||
text: string
|
||||
}
|
||||
|
||||
interface PricingTier {
|
||||
name: string
|
||||
tier: string
|
||||
price: string
|
||||
features: PricingFeature[]
|
||||
ctaText: string
|
||||
featured?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Free plan features with consistent icons
|
||||
*/
|
||||
const FREE_PLAN_FEATURES: PricingFeature[] = [
|
||||
{ icon: DollarSign, text: '$10 usage limit' },
|
||||
{ icon: Workflow, text: 'Public template access' },
|
||||
{ icon: Users, text: 'Community support' },
|
||||
{ icon: Database, text: 'Limited log retention' },
|
||||
{ icon: Code2, text: 'CLI/SDK Access' },
|
||||
]
|
||||
|
||||
/**
|
||||
* Available pricing tiers with their features and pricing
|
||||
*/
|
||||
const pricingTiers: PricingTier[] = [
|
||||
{
|
||||
name: 'COMMUNITY',
|
||||
tier: 'Free',
|
||||
price: 'Free',
|
||||
features: FREE_PLAN_FEATURES,
|
||||
ctaText: 'Get Started',
|
||||
},
|
||||
{
|
||||
name: 'PRO',
|
||||
tier: 'Pro',
|
||||
price: '$20/mo',
|
||||
features: PRO_PLAN_FEATURES,
|
||||
ctaText: 'Get Started',
|
||||
featured: true,
|
||||
},
|
||||
{
|
||||
name: 'TEAM',
|
||||
tier: 'Team',
|
||||
price: '$40/mo',
|
||||
features: TEAM_PLAN_FEATURES,
|
||||
ctaText: 'Get Started',
|
||||
},
|
||||
{
|
||||
name: 'ENTERPRISE',
|
||||
tier: 'Enterprise',
|
||||
price: 'Custom',
|
||||
features: ENTERPRISE_PLAN_FEATURES,
|
||||
ctaText: 'Contact Sales',
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* Individual pricing card component
|
||||
* @param tier - The pricing tier data
|
||||
* @param index - The index of the card in the grid
|
||||
* @param isBeforeFeatured - Whether this card is immediately before a featured card
|
||||
*/
|
||||
function PricingCard({
|
||||
tier,
|
||||
index,
|
||||
isBeforeFeatured,
|
||||
}: {
|
||||
tier: PricingTier
|
||||
index: number
|
||||
isBeforeFeatured?: boolean
|
||||
}) {
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
const router = useRouter()
|
||||
|
||||
const handleCtaClick = () => {
|
||||
logger.info(`Pricing CTA clicked: ${tier.name}`)
|
||||
|
||||
if (tier.ctaText === 'Contact Sales') {
|
||||
// Open enterprise form in new tab
|
||||
window.open('https://form.typeform.com/to/jqCO12pF', '_blank')
|
||||
} else {
|
||||
// Navigate to signup page for all "Get Started" buttons
|
||||
router.push('/signup')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
`${inter.className}`,
|
||||
'relative flex h-full flex-col justify-between bg-[#FEFEFE]',
|
||||
tier.featured ? 'p-0' : 'px-0 py-0',
|
||||
'sm:px-5 sm:pt-4 sm:pb-4',
|
||||
tier.featured
|
||||
? 'sm:p-0'
|
||||
: isBeforeFeatured
|
||||
? 'sm:border-[#E7E4EF] sm:border-r-0'
|
||||
: 'sm:border-[#E7E4EF] sm:border-r-2 sm:last:border-r-0',
|
||||
!tier.featured && !isBeforeFeatured && 'lg:[&:nth-child(4n)]:border-r-0',
|
||||
!tier.featured &&
|
||||
!isBeforeFeatured &&
|
||||
'sm:[&:nth-child(2n)]:border-r-0 lg:[&:nth-child(2n)]:border-r-2',
|
||||
tier.featured ? 'z-10 bg-gradient-to-b from-[#8357FF] to-[#6F3DFA] text-white' : ''
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-full flex-col justify-between',
|
||||
tier.featured
|
||||
? 'border-2 border-[#6F3DFA] px-5 pt-4 pb-5 shadow-[inset_0_2px_4px_0_#9B77FF] sm:px-5 sm:pt-4 sm:pb-4'
|
||||
: ''
|
||||
)}
|
||||
>
|
||||
<div className='flex-1'>
|
||||
<div className='mb-1'>
|
||||
<span
|
||||
className={cn(
|
||||
'font-medium text-xs uppercase tracking-wider',
|
||||
tier.featured ? 'text-white/90' : 'text-gray-500'
|
||||
)}
|
||||
>
|
||||
{tier.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className='mb-6'>
|
||||
<span
|
||||
className={cn(
|
||||
'font-medium text-4xl leading-none',
|
||||
tier.featured ? 'text-white' : 'text-black'
|
||||
)}
|
||||
>
|
||||
{tier.price}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ul className='mb-[2px] space-y-3'>
|
||||
{tier.features.map((feature, idx) => (
|
||||
<li key={idx} className='flex items-start gap-2'>
|
||||
<feature.icon
|
||||
className={cn(
|
||||
'mt-0.5 h-4 w-4 flex-shrink-0',
|
||||
tier.featured ? 'text-white/90' : 'text-gray-600'
|
||||
)}
|
||||
/>
|
||||
<span className={cn('text-sm', tier.featured ? 'text-white' : 'text-gray-700')}>
|
||||
{feature.text}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className='mt-9'>
|
||||
{tier.featured ? (
|
||||
<button
|
||||
onClick={handleCtaClick}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
className='group inline-flex w-full items-center justify-center gap-2 rounded-[10px] border border-[#E8E8E8] bg-gradient-to-b from-[#F8F8F8] to-white px-3 py-[6px] font-medium text-[#6F3DFA] text-[14px] shadow-[inset_0_2px_4px_0_rgba(255,255,255,0.9)] transition-all'
|
||||
>
|
||||
<span className='flex items-center gap-1'>
|
||||
{tier.ctaText}
|
||||
<span className='inline-flex transition-transform duration-200 group-hover:translate-x-0.5'>
|
||||
{isHovered ? (
|
||||
<ArrowRight className='h-4 w-4' />
|
||||
) : (
|
||||
<ChevronRight className='h-4 w-4' />
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleCtaClick}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
className='group inline-flex w-full items-center justify-center gap-2 rounded-[10px] border border-[#343434] bg-gradient-to-b from-[#060606] to-[#323232] px-3 py-[6px] font-medium text-[14px] text-white shadow-[inset_0_1.25px_2.5px_0_#9B77FF] transition-all'
|
||||
>
|
||||
<span className='flex items-center gap-1'>
|
||||
{tier.ctaText}
|
||||
<span className='inline-flex transition-transform duration-200 group-hover:translate-x-0.5'>
|
||||
{isHovered ? (
|
||||
<ArrowRight className='h-4 w-4' />
|
||||
) : (
|
||||
<ChevronRight className='h-4 w-4' />
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Landing page pricing section displaying tiered pricing plans
|
||||
*/
|
||||
export default function LandingPricing() {
|
||||
return (
|
||||
<section id='pricing' className='px-4 pt-[19px] sm:px-0 sm:pt-0' aria-label='Pricing plans'>
|
||||
<h2 className='sr-only'>Pricing Plans</h2>
|
||||
<div className='relative mx-auto w-full max-w-[1289px]'>
|
||||
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2 sm:gap-0 lg:grid-cols-4'>
|
||||
{pricingTiers.map((tier, index) => {
|
||||
const nextTier = pricingTiers[index + 1]
|
||||
const isBeforeFeatured = nextTier?.featured
|
||||
return (
|
||||
<PricingCard
|
||||
key={tier.name}
|
||||
tier={tier}
|
||||
index={index}
|
||||
isBeforeFeatured={isBeforeFeatured}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { inter } from '@/app/fonts/inter'
|
||||
|
||||
interface LandingTemplatePreviewProps {
|
||||
previewImage: string
|
||||
avatarImage: string
|
||||
title: string
|
||||
authorName: string
|
||||
usageCount: number
|
||||
}
|
||||
|
||||
export default function LandingTemplatePreview({
|
||||
previewImage,
|
||||
avatarImage,
|
||||
title,
|
||||
authorName,
|
||||
usageCount,
|
||||
}: LandingTemplatePreviewProps) {
|
||||
return (
|
||||
<div className='flex flex-col'>
|
||||
{/* Preview Image */}
|
||||
<div
|
||||
className='h-44 w-full rounded-[10px] bg-center bg-cover bg-no-repeat'
|
||||
style={{
|
||||
backgroundImage: `url(${previewImage}), linear-gradient(to right, #F5F5F5, #F5F5F5)`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Author and Info Section */}
|
||||
<div className='mt-4 flex items-center gap-3'>
|
||||
{/* Avatar */}
|
||||
<div
|
||||
className='h-[32px] w-[32px] flex-shrink-0 rounded-full bg-center bg-cover bg-no-repeat'
|
||||
style={{
|
||||
backgroundImage: `url(${avatarImage}), linear-gradient(to right, #F5F5F5, #F5F5F5)`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Title and Author Info */}
|
||||
<div className='min-w-0 flex-1'>
|
||||
<h4
|
||||
className={`${inter.className} truncate font-medium text-foreground text-sm leading-none`}
|
||||
>
|
||||
{title}
|
||||
</h4>
|
||||
<p
|
||||
className={`${inter.className} mt-1 flex items-center gap-2 text-muted-foreground text-xs`}
|
||||
>
|
||||
<span>{authorName}</span>
|
||||
<span>{usageCount.toLocaleString()} copies</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { inter } from '@/app/fonts/inter'
|
||||
import LandingTemplatePreview from './components/landing-template-preview'
|
||||
|
||||
// Mock data for templates
|
||||
const templates = [
|
||||
{
|
||||
id: 1,
|
||||
previewImage: '/placeholder-template-1.jpg',
|
||||
avatarImage: '/placeholder-avatar-1.jpg',
|
||||
title: 'Meeting notetaker',
|
||||
authorName: 'Emir Ayaz',
|
||||
usageCount: 7800,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
previewImage: '/placeholder-template-2.jpg',
|
||||
avatarImage: '/placeholder-avatar-2.jpg',
|
||||
title: 'Cold outreach sender',
|
||||
authorName: 'Liam Chen',
|
||||
usageCount: 15000,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
previewImage: '/placeholder-template-3.jpg',
|
||||
avatarImage: '/placeholder-avatar-3.jpg',
|
||||
title: 'Campaign scheduler',
|
||||
authorName: 'Jade Monroe',
|
||||
usageCount: 11800,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
previewImage: '/placeholder-template-4.jpg',
|
||||
avatarImage: '/placeholder-avatar-4.jpg',
|
||||
title: 'Lead qualifier',
|
||||
authorName: 'Marcus Vega',
|
||||
usageCount: 13200,
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
previewImage: '/placeholder-template-5.jpg',
|
||||
avatarImage: '/placeholder-avatar-5.jpg',
|
||||
title: 'Performance reporter',
|
||||
authorName: 'Emily Zhao',
|
||||
usageCount: 9500,
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
previewImage: '/placeholder-template-6.jpg',
|
||||
avatarImage: '/placeholder-avatar-6.jpg',
|
||||
title: 'Ad copy generator',
|
||||
authorName: 'Carlos Mendez',
|
||||
usageCount: 14200,
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
previewImage: '/placeholder-template-7.jpg',
|
||||
avatarImage: '/placeholder-avatar-7.jpg',
|
||||
title: 'Product launch email',
|
||||
authorName: 'Lucas Patel',
|
||||
usageCount: 10500,
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
previewImage: '/placeholder-template-8.jpg',
|
||||
avatarImage: '/placeholder-avatar-8.jpg',
|
||||
title: 'Customer support chatbot',
|
||||
authorName: 'Sophia Nguyen',
|
||||
usageCount: 12000,
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
previewImage: '/placeholder-template-9.jpg',
|
||||
avatarImage: '/placeholder-avatar-9.jpg',
|
||||
title: 'Event planner',
|
||||
authorName: 'Aiden Kim',
|
||||
usageCount: 13500,
|
||||
},
|
||||
]
|
||||
|
||||
export default function LandingTemplates() {
|
||||
return (
|
||||
<section
|
||||
id='templates'
|
||||
className={`${inter.className} flex flex-col px-4 pt-[40px] sm:px-[50px] sm:pt-[34px]`}
|
||||
aria-labelledby='templates-heading'
|
||||
>
|
||||
<h2
|
||||
id='templates-heading'
|
||||
className='mb-[16px] font-medium text-[28px] text-foreground tracking-tight sm:mb-[24px]'
|
||||
>
|
||||
Templates
|
||||
</h2>
|
||||
|
||||
{/* Templates Grid */}
|
||||
<div className='grid grid-cols-1 gap-6 sm:gap-8 md:grid-cols-2 lg:grid-cols-3'>
|
||||
{templates.map((template, index) => (
|
||||
<div
|
||||
key={template.id}
|
||||
className={`
|
||||
${index >= 3 ? 'hidden md:block' : ''} ${index >= 6 ? 'md:hidden lg:block' : ''} `}
|
||||
>
|
||||
<LandingTemplatePreview
|
||||
previewImage={template.previewImage}
|
||||
avatarImage={template.avatarImage}
|
||||
title={template.title}
|
||||
authorName={template.authorName}
|
||||
usageCount={template.usageCount}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
35
apps/sim/app/(landing)/components/legal-layout.tsx
Normal file
35
apps/sim/app/(landing)/components/legal-layout.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
'use client'
|
||||
|
||||
import { isHosted } from '@/lib/environment'
|
||||
import Footer from '@/app/(landing)/components/footer/footer'
|
||||
import Nav from '@/app/(landing)/components/nav/nav'
|
||||
import { soehne } from '@/app/fonts/soehne/soehne'
|
||||
|
||||
interface LegalLayoutProps {
|
||||
title: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export default function LegalLayout({ title, children }: LegalLayoutProps) {
|
||||
return (
|
||||
<main className={`${soehne.className} min-h-screen bg-white text-gray-900`}>
|
||||
{/* Header - Nav handles all conditional logic */}
|
||||
<Nav variant='legal' />
|
||||
|
||||
{/* Content */}
|
||||
<div className='px-12 pt-[40px] pb-[40px]'>
|
||||
<h1 className='mb-12 text-center font-bold text-4xl text-gray-900 md:text-5xl'>{title}</h1>
|
||||
<div className='prose prose-gray mx-auto prose-h2:mt-12 prose-h3:mt-8 prose-h2:mb-6 prose-h3:mb-4 space-y-8 text-gray-700'>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer - Only for hosted instances */}
|
||||
{isHosted && (
|
||||
<div className='relative z-20'>
|
||||
<Footer fullWidth={true} />
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import type { ComponentPropsWithoutRef } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface MarqueeProps extends ComponentPropsWithoutRef<'div'> {
|
||||
/**
|
||||
* Optional CSS class name to apply custom styles
|
||||
*/
|
||||
className?: string
|
||||
/**
|
||||
* Whether to reverse the animation direction
|
||||
* @default false
|
||||
*/
|
||||
reverse?: boolean
|
||||
/**
|
||||
* Whether to pause the animation on hover
|
||||
* @default false
|
||||
*/
|
||||
pauseOnHover?: boolean
|
||||
/**
|
||||
* Content to be displayed in the marquee
|
||||
*/
|
||||
children: React.ReactNode
|
||||
/**
|
||||
* Whether to animate vertically instead of horizontally
|
||||
* @default false
|
||||
*/
|
||||
vertical?: boolean
|
||||
/**
|
||||
* Number of times to repeat the content
|
||||
* @default 4
|
||||
*/
|
||||
repeat?: number
|
||||
}
|
||||
|
||||
export function Marquee({
|
||||
className,
|
||||
reverse = false,
|
||||
pauseOnHover = false,
|
||||
children,
|
||||
vertical = false,
|
||||
repeat = 4,
|
||||
...props
|
||||
}: MarqueeProps) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={cn(
|
||||
'group flex overflow-hidden p-0.5 [--duration:40s] [--gap:12px] [gap:var(--gap)]',
|
||||
{
|
||||
'flex-row': !vertical,
|
||||
'flex-col': vertical,
|
||||
},
|
||||
className
|
||||
)}
|
||||
>
|
||||
{Array(repeat)
|
||||
.fill(0)
|
||||
.map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn('flex shrink-0 justify-around [gap:var(--gap)]', {
|
||||
'animate-marquee flex-row': !vertical,
|
||||
'animate-marquee-vertical flex-col': vertical,
|
||||
'group-hover:[animation-play-state:paused]': pauseOnHover,
|
||||
'[animation-direction:reverse]': reverse,
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface OrbitingCirclesProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
className?: string
|
||||
children?: React.ReactNode
|
||||
reverse?: boolean
|
||||
duration?: number
|
||||
delay?: number
|
||||
radius?: number
|
||||
path?: boolean
|
||||
iconSize?: number
|
||||
speed?: number
|
||||
}
|
||||
|
||||
export function OrbitingCircles({
|
||||
className,
|
||||
children,
|
||||
reverse,
|
||||
duration = 20,
|
||||
radius = 160,
|
||||
path = true,
|
||||
iconSize = 30,
|
||||
speed = 1,
|
||||
...props
|
||||
}: OrbitingCirclesProps) {
|
||||
const calculatedDuration = duration / speed
|
||||
return (
|
||||
<>
|
||||
{path && (
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
version='1.1'
|
||||
className='pointer-events-none absolute inset-0 size-full'
|
||||
>
|
||||
<circle className='stroke-1 stroke-white/10' cx='50%' cy='50%' r={radius} fill='none' />
|
||||
</svg>
|
||||
)}
|
||||
{React.Children.map(children, (child, index) => {
|
||||
const angle = (360 / React.Children.count(children)) * index
|
||||
return (
|
||||
<div
|
||||
style={
|
||||
{
|
||||
'--duration': calculatedDuration,
|
||||
'--radius': radius,
|
||||
'--angle': angle,
|
||||
'--icon-size': `${iconSize}px`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
'absolute flex size-[var(--icon-size)] transform-gpu animate-orbit items-center justify-center rounded-full',
|
||||
{ '[animation-direction:reverse]': reverse },
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{child}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,332 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { Menu } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Sheet,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from '@/components/ui/sheet'
|
||||
import { useBrandConfig } from '@/lib/branding/branding'
|
||||
import { usePrefetchOnHover } from '@/app/(landing)/utils/prefetch'
|
||||
|
||||
// --- Framer Motion Variants ---
|
||||
const desktopNavContainerVariants = {
|
||||
hidden: { opacity: 0, y: -10 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
delay: 0.2,
|
||||
duration: 0.3,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const mobileSheetContainerVariants = {
|
||||
hidden: { x: '100%' },
|
||||
visible: {
|
||||
x: 0,
|
||||
transition: { duration: 0.3 },
|
||||
},
|
||||
exit: {
|
||||
x: '100%',
|
||||
transition: { duration: 0.2 },
|
||||
},
|
||||
}
|
||||
|
||||
const mobileNavItemsContainerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: { opacity: 1 },
|
||||
}
|
||||
|
||||
const mobileNavItemVariants = {
|
||||
hidden: { opacity: 0, x: 20 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
transition: { duration: 0.3 },
|
||||
},
|
||||
}
|
||||
|
||||
const mobileButtonVariants = {
|
||||
hidden: { opacity: 0, y: 10 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.3 },
|
||||
},
|
||||
}
|
||||
// --- End Framer Motion Variants ---
|
||||
|
||||
// Component for Navigation Links
|
||||
const NavLinks = ({
|
||||
mobile,
|
||||
currentPath,
|
||||
onContactClick,
|
||||
}: {
|
||||
mobile?: boolean
|
||||
currentPath?: string
|
||||
onContactClick?: () => void
|
||||
}) => {
|
||||
const navigationLinks = [
|
||||
// { href: "/", label: "Marketplace" },
|
||||
...(currentPath !== '/' ? [{ href: '/', label: 'Home' }] : []),
|
||||
{ href: 'https://docs.sim.ai/', label: 'Docs', external: true },
|
||||
// { href: '/', label: 'Blog' },
|
||||
{ href: '/contributors', label: 'Contributors' },
|
||||
]
|
||||
|
||||
const handleContributorsHover = usePrefetchOnHover()
|
||||
|
||||
// Common CSS class for navigation items
|
||||
const navItemClass = `text-white/60 hover:text-white/100 text-base ${
|
||||
mobile ? 'p-2.5 text-lg font-medium text-left' : 'p-1.5'
|
||||
} rounded-md transition-colors duration-200 block md:inline-block`
|
||||
|
||||
return (
|
||||
<>
|
||||
{navigationLinks.map((link) => {
|
||||
const linkElement = (
|
||||
<motion.div variants={mobile ? mobileNavItemVariants : undefined} key={link.label}>
|
||||
<Link
|
||||
href={link.href}
|
||||
className={navItemClass}
|
||||
onMouseEnter={link.label === 'Contributors' ? handleContributorsHover : undefined}
|
||||
{...(link.external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
</motion.div>
|
||||
)
|
||||
|
||||
// Wrap the motion.div with SheetClose if mobile
|
||||
return mobile ? (
|
||||
<SheetClose asChild key={link.label}>
|
||||
{linkElement}
|
||||
</SheetClose>
|
||||
) : (
|
||||
linkElement
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Enterprise button with the same action as contact */}
|
||||
{onContactClick &&
|
||||
(mobile ? (
|
||||
<SheetClose asChild key='enterprise'>
|
||||
<motion.div variants={mobileNavItemVariants}>
|
||||
<Link
|
||||
href='https://form.typeform.com/to/jqCO12pF'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className={navItemClass}
|
||||
>
|
||||
Enterprise
|
||||
</Link>
|
||||
</motion.div>
|
||||
</SheetClose>
|
||||
) : (
|
||||
<motion.div variants={mobile ? mobileNavItemVariants : undefined} key='enterprise'>
|
||||
<Link
|
||||
href='https://form.typeform.com/to/jqCO12pF'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className={navItemClass}
|
||||
>
|
||||
Enterprise
|
||||
</Link>
|
||||
</motion.div>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
interface NavClientProps {
|
||||
children: React.ReactNode
|
||||
initialIsMobile?: boolean
|
||||
currentPath?: string
|
||||
onContactClick?: () => void
|
||||
}
|
||||
|
||||
export default function NavClient({
|
||||
children,
|
||||
initialIsMobile,
|
||||
currentPath,
|
||||
onContactClick,
|
||||
}: NavClientProps) {
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const [isMobile, setIsMobile] = useState(initialIsMobile ?? false)
|
||||
const [isSheetOpen, setIsSheetOpen] = useState(false)
|
||||
const _router = useRouter()
|
||||
const brand = useBrandConfig()
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
const checkMobile = () => setIsMobile(window.innerWidth < 768)
|
||||
checkMobile()
|
||||
|
||||
window.addEventListener('resize', checkMobile)
|
||||
return () => window.removeEventListener('resize', checkMobile)
|
||||
}, [])
|
||||
|
||||
// Handle initial loading state - don't render anything that could cause layout shift
|
||||
// until we've measured the viewport
|
||||
if (!mounted) {
|
||||
return (
|
||||
<nav className='absolute top-1 right-0 left-0 z-30 px-4 py-8'>
|
||||
<div className='relative mx-auto flex max-w-7xl items-center justify-between'>
|
||||
<div className='flex-1'>
|
||||
<div className='h-[32px] w-[32px]' />
|
||||
</div>
|
||||
<div className='flex flex-1 justify-end'>
|
||||
<div className='h-[43px] w-[43px]' />
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<nav className='absolute top-1 right-0 left-0 z-30 px-4 py-8'>
|
||||
<div className='relative mx-auto flex max-w-7xl items-center justify-between'>
|
||||
{!isMobile && (
|
||||
<div className='flex flex-1 items-center'>
|
||||
<div className='inline-block'>
|
||||
<Link href='/' className='inline-flex'>
|
||||
{brand.logoUrl ? (
|
||||
<img
|
||||
src={brand.logoUrl}
|
||||
alt={`${brand.name} Logo`}
|
||||
width={42}
|
||||
height={42}
|
||||
className='h-[42px] w-[42px] object-contain'
|
||||
/>
|
||||
) : (
|
||||
<Image src='/sim.svg' alt={`${brand.name} Logo`} width={42} height={42} />
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isMobile && (
|
||||
<motion.div
|
||||
className='flex items-center gap-4 rounded-lg bg-neutral-700/50 px-2 py-1'
|
||||
variants={desktopNavContainerVariants}
|
||||
initial='hidden'
|
||||
animate='visible'
|
||||
transition={{ delay: 0.2, duration: 0.3, ease: 'easeOut' }}
|
||||
>
|
||||
<NavLinks currentPath={currentPath} onContactClick={onContactClick} />
|
||||
</motion.div>
|
||||
)}
|
||||
{isMobile && <div className='flex-1' />}
|
||||
|
||||
<div className='flex flex-1 items-center justify-end'>
|
||||
<div className={`flex items-center ${isMobile ? 'gap-2' : 'gap-3'}`}>
|
||||
{!isMobile && (
|
||||
<>
|
||||
<div className='flex items-center'>{children}</div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.4 }}
|
||||
>
|
||||
<Link
|
||||
href='https://form.typeform.com/to/jqCO12pF'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
<Button className='h-[43px] bg-[var(--brand-primary-hex)] px-6 py-2 font-geist-sans font-medium text-base text-neutral-100 transition-colors duration-200 hover:bg-[var(--brand-primary-hover-hex)]'>
|
||||
Contact
|
||||
</Button>
|
||||
</Link>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isMobile && (
|
||||
<Sheet open={isSheetOpen} onOpenChange={setIsSheetOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<motion.button
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className='rounded-md p-2 text-white hover:bg-neutral-700/50 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50'
|
||||
>
|
||||
<Menu className='h-6 w-6' />
|
||||
<span className='sr-only'>Toggle menu</span>
|
||||
</motion.button>
|
||||
</SheetTrigger>
|
||||
<AnimatePresence>
|
||||
{isSheetOpen && (
|
||||
<motion.div
|
||||
key='sheet-content'
|
||||
variants={mobileSheetContainerVariants}
|
||||
initial='hidden'
|
||||
animate='visible'
|
||||
exit='exit'
|
||||
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
||||
className='fixed inset-y-0 right-0 z-50'
|
||||
>
|
||||
<SheetContent
|
||||
side='right'
|
||||
className='flex h-full w-[280px] flex-col border-[#181818] border-l bg-[var(--brand-background-hex)] p-6 pt-6 text-white shadow-xl sm:w-[320px] [&>button]:hidden'
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<SheetHeader className='sr-only'>
|
||||
<SheetTitle>Navigation Menu</SheetTitle>
|
||||
</SheetHeader>
|
||||
<motion.div
|
||||
className='flex flex-grow flex-col gap-5'
|
||||
variants={mobileNavItemsContainerVariants}
|
||||
initial='hidden'
|
||||
animate='visible'
|
||||
transition={{
|
||||
delayChildren: 0.1,
|
||||
staggerChildren: 0.08,
|
||||
}}
|
||||
>
|
||||
<NavLinks
|
||||
mobile
|
||||
currentPath={currentPath}
|
||||
onContactClick={onContactClick}
|
||||
/>
|
||||
{children && (
|
||||
<motion.div variants={mobileNavItemVariants}>
|
||||
<SheetClose asChild>{children}</SheetClose>
|
||||
</motion.div>
|
||||
)}
|
||||
<motion.div variants={mobileButtonVariants} className='mt-auto pt-6'>
|
||||
<SheetClose asChild>
|
||||
<Link
|
||||
href='https://form.typeform.com/to/jqCO12pF'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
<Button className='w-full bg-[var(--brand-primary-hex)] py-6 font-medium text-base text-white shadow-[var(--brand-primary-hex)]/20 shadow-lg transition-colors duration-200 hover:bg-[var(--brand-primary-hover-hex)]'>
|
||||
Contact
|
||||
</Button>
|
||||
</Link>
|
||||
</SheetClose>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</SheetContent>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Sheet>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { getFormattedGitHubStars } from '@/app/(landing)/actions/github'
|
||||
import GitHubStarsClient from '@/app/(landing)/components/github-stars-client'
|
||||
import NavClient from '@/app/(landing)/components/nav-client'
|
||||
|
||||
interface NavWrapperProps {
|
||||
onOpenTypeformLink: () => void
|
||||
}
|
||||
|
||||
export default function NavWrapper({ onOpenTypeformLink }: NavWrapperProps) {
|
||||
// Use a client-side component to wrap the navigation
|
||||
// This avoids trying to use server-side UA detection
|
||||
// which has compatibility challenges
|
||||
|
||||
const pathname = usePathname()
|
||||
const [initialIsMobile, setInitialIsMobile] = useState(false)
|
||||
const [isLoaded, setIsLoaded] = useState(false)
|
||||
// Default to a reasonable number and update it later
|
||||
const [starCount, setStarCount] = useState('1.2k')
|
||||
|
||||
useEffect(() => {
|
||||
// Set initial mobile state based on window width
|
||||
setInitialIsMobile(window.innerWidth < 768)
|
||||
|
||||
// Slight delay to ensure smooth animations with other elements
|
||||
setTimeout(() => {
|
||||
setIsLoaded(true)
|
||||
}, 100)
|
||||
|
||||
// Use server action to fetch stars
|
||||
getFormattedGitHubStars()
|
||||
.then((formattedStars) => {
|
||||
setStarCount(formattedStars)
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Failed to fetch GitHub stars:', err)
|
||||
})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<AnimatePresence mode='wait'>
|
||||
{!isLoaded ? (
|
||||
<motion.div
|
||||
key='loading'
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 0.5 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className='absolute top-1 right-0 left-0 z-30 px-4 py-8'
|
||||
>
|
||||
<div className='relative mx-auto flex max-w-7xl items-center justify-between'>
|
||||
<div className='flex-1' />
|
||||
<div className='flex flex-1 justify-end'>
|
||||
<div className='h-[43px] w-[43px]' />
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key='loaded'
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<NavClient
|
||||
initialIsMobile={initialIsMobile}
|
||||
currentPath={pathname}
|
||||
onContactClick={onOpenTypeformLink}
|
||||
>
|
||||
<GitHubStarsClient stars={starCount} />
|
||||
</NavClient>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
)
|
||||
}
|
||||
196
apps/sim/app/(landing)/components/nav/nav.tsx
Normal file
196
apps/sim/app/(landing)/components/nav/nav.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { ArrowRight, ChevronRight } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { GithubIcon } from '@/components/icons'
|
||||
import { useBrandConfig } from '@/lib/branding/branding'
|
||||
import { isHosted } from '@/lib/environment'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getFormattedGitHubStars } from '@/app/(landing)/actions/github'
|
||||
import { soehne } from '@/app/fonts/soehne/soehne'
|
||||
|
||||
const logger = createLogger('nav')
|
||||
|
||||
interface NavProps {
|
||||
hideAuthButtons?: boolean
|
||||
variant?: 'landing' | 'auth' | 'legal'
|
||||
}
|
||||
|
||||
export default function Nav({ hideAuthButtons = false, variant = 'landing' }: NavProps = {}) {
|
||||
const [githubStars, setGithubStars] = useState('15k')
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
const [isLoginHovered, setIsLoginHovered] = useState(false)
|
||||
const router = useRouter()
|
||||
const brand = useBrandConfig()
|
||||
|
||||
useEffect(() => {
|
||||
if (variant !== 'landing') return
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
const fetchStars = async () => {
|
||||
try {
|
||||
const stars = await getFormattedGitHubStars()
|
||||
setGithubStars(stars)
|
||||
} catch (error) {
|
||||
logger.warn('Error fetching GitHub stars:', error)
|
||||
}
|
||||
}
|
||||
fetchStars()
|
||||
}, 2000)
|
||||
|
||||
return () => clearTimeout(timeoutId)
|
||||
}, [variant])
|
||||
|
||||
const handleLoginClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
router.push('/login')
|
||||
},
|
||||
[router]
|
||||
)
|
||||
|
||||
const handleEnterpriseClick = useCallback(() => {
|
||||
window.open('https://form.typeform.com/to/jqCO12pF', '_blank', 'noopener,noreferrer')
|
||||
}, [])
|
||||
|
||||
const NavLinks = () => (
|
||||
<>
|
||||
<li>
|
||||
<Link
|
||||
href='https://docs.sim.ai'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-[16px] text-muted-foreground transition-colors hover:text-foreground'
|
||||
prefetch={false}
|
||||
>
|
||||
Docs
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href='#pricing'
|
||||
className='text-[16px] text-muted-foreground transition-colors hover:text-foreground'
|
||||
scroll={true}
|
||||
>
|
||||
Pricing
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
onClick={handleEnterpriseClick}
|
||||
className='text-[16px] text-muted-foreground transition-colors hover:text-foreground'
|
||||
type='button'
|
||||
aria-label='Contact for Enterprise pricing'
|
||||
>
|
||||
Enterprise
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href='https://github.com/simstudioai/sim'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='flex items-center gap-2 text-[16px] text-muted-foreground transition-colors hover:text-foreground'
|
||||
aria-label={`GitHub repository - ${githubStars} stars`}
|
||||
>
|
||||
<GithubIcon className='h-[16px] w-[16px]' aria-hidden='true' />
|
||||
<span aria-live='polite'>{githubStars}</span>
|
||||
</a>
|
||||
</li>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label='Primary navigation'
|
||||
className={`${soehne.className} flex w-full items-center justify-between px-4 ${
|
||||
variant === 'auth' ? 'pt-[20px] sm:pt-[16.5px]' : 'pt-[12px] sm:pt-[8.5px]'
|
||||
} pb-[21px] sm:px-8 md:px-[44px]`}
|
||||
itemScope
|
||||
itemType='https://schema.org/SiteNavigationElement'
|
||||
>
|
||||
<div className='flex items-center gap-[34px]'>
|
||||
<Link href='/' aria-label={`${brand.name} home`} itemProp='url'>
|
||||
<span itemProp='name' className='sr-only'>
|
||||
{brand.name} Home
|
||||
</span>
|
||||
{brand.logoUrl ? (
|
||||
<Image
|
||||
src={brand.logoUrl}
|
||||
alt={`${brand.name} Logo`}
|
||||
width={49.78314}
|
||||
height={24.276}
|
||||
className='h-[24.276px] w-auto object-contain'
|
||||
priority
|
||||
loading='eager'
|
||||
quality={100}
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
src='/logo/b&w/text/b&w.svg'
|
||||
alt='Sim - Workflows for LLMs'
|
||||
width={49.78314}
|
||||
height={24.276}
|
||||
priority
|
||||
loading='eager'
|
||||
quality={100}
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
{/* Desktop Navigation Links - only show on landing and if hosted */}
|
||||
{variant === 'landing' && isHosted && (
|
||||
<ul className='hidden items-center justify-center gap-[20px] pt-[4px] md:flex'>
|
||||
<NavLinks />
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Auth Buttons - respect hideAuthButtons prop and only show on hosted instances for landing pages */}
|
||||
{!hideAuthButtons && (variant === 'auth' || isHosted) && (
|
||||
<div className='flex items-center justify-center gap-[16px] pt-[1.5px]'>
|
||||
<button
|
||||
onClick={handleLoginClick}
|
||||
onMouseEnter={() => setIsLoginHovered(true)}
|
||||
onMouseLeave={() => setIsLoginHovered(false)}
|
||||
className='group hidden text-[#2E2E2E] text-[16px] transition-colors hover:text-foreground md:block'
|
||||
type='button'
|
||||
aria-label='Log in to your account'
|
||||
>
|
||||
<span className='flex items-center gap-1'>
|
||||
Log in
|
||||
<span className='inline-flex transition-transform duration-200 group-hover:translate-x-0.5'>
|
||||
{isLoginHovered ? (
|
||||
<ArrowRight className='h-4 w-4' aria-hidden='true' />
|
||||
) : (
|
||||
<ChevronRight className='h-4 w-4' aria-hidden='true' />
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<Link
|
||||
href='/signup'
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
className='group inline-flex items-center justify-center gap-2 rounded-[10px] border border-[#6F3DFA] bg-gradient-to-b from-[#8357FF] to-[#6F3DFA] py-[6px] pr-[10px] pl-[12px] text-[14px] text-white shadow-[inset_0_2px_4px_0_#9B77FF] transition-all sm:text-[16px]'
|
||||
aria-label='Get started with Sim - Sign up for free'
|
||||
prefetch={true}
|
||||
>
|
||||
<span className='flex items-center gap-1'>
|
||||
Get started
|
||||
<span className='inline-flex transition-transform duration-200 group-hover:translate-x-0.5'>
|
||||
{isHovered ? (
|
||||
<ArrowRight className='h-4 w-4' aria-hidden='true' />
|
||||
) : (
|
||||
<ChevronRight className='h-4 w-4' aria-hidden='true' />
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'framer-motion'
|
||||
import { getAssetUrl } from '@/lib/utils'
|
||||
import { BlogCard } from '@/app/(landing)/components/blog-card'
|
||||
|
||||
function Blogs() {
|
||||
return (
|
||||
<motion.section
|
||||
className='flex w-full flex-col gap-16 px-8 py-20 md:px-16 lg:px-28 xl:px-32'
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
transition={{ duration: 0.7, delay: 0.01, ease: 'easeOut' }}
|
||||
>
|
||||
<div className='flex flex-col gap-7'>
|
||||
<motion.p
|
||||
className='font-medium text-5xl text-white tracking-normal'
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
transition={{ duration: 0.7, delay: 0.05, ease: 'easeOut' }}
|
||||
>
|
||||
Insights for building
|
||||
<br />
|
||||
smarter Agents
|
||||
</motion.p>
|
||||
<motion.p
|
||||
className='max-w-md font-light text-white/60 text-xl tracking-normal'
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
transition={{ duration: 0.7, delay: 0.15, ease: 'easeOut' }}
|
||||
>
|
||||
Stay ahead with the latest tips, updates, and best practices for AI agent development.
|
||||
</motion.p>
|
||||
</div>
|
||||
|
||||
<div className='flex w-full flex-col gap-12 md:grid md:grid-cols-2 md:grid-rows-1 lg:grid-cols-3'>
|
||||
<motion.div
|
||||
className='flex flex-col gap-12'
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
transition={{ duration: 0.7, delay: 0.18, ease: 'easeOut' }}
|
||||
>
|
||||
<BlogCard
|
||||
href='/blog/test'
|
||||
title='How to Build an Agent in 5 Steps with sim.ai'
|
||||
description="Learn how to create a fully functional AI agent using sim.ai's unified API and workflows."
|
||||
date={new Date('25 April 2025')}
|
||||
author='Emir Ayaz'
|
||||
authorRole='Designer'
|
||||
avatar={getAssetUrl('static/sim.png')}
|
||||
type='Agents'
|
||||
readTime='6'
|
||||
/>
|
||||
<BlogCard
|
||||
href='/blog/test'
|
||||
title='How to Build an Agent in 5 Steps with sim.ai'
|
||||
description="Learn how to create a fully functional AI agent using sim.ai's unified API and workflows."
|
||||
date={new Date('25 April 2025')}
|
||||
author='Emir Ayaz'
|
||||
authorRole='Designer'
|
||||
avatar={getAssetUrl('static/sim.png')}
|
||||
type='Agents'
|
||||
readTime='6'
|
||||
/>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
className='flex flex-col gap-12'
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
transition={{ duration: 0.7, delay: 0.22, ease: 'easeOut' }}
|
||||
>
|
||||
<BlogCard
|
||||
href='/blog/test'
|
||||
title='How to Build an Agent in 5 Steps with sim.ai'
|
||||
description="Learn how to create a fully functional AI agent using sim.ai's unified API and workflows."
|
||||
date={new Date('25 April 2025')}
|
||||
author='Emir Ayaz'
|
||||
authorRole='Designer'
|
||||
avatar={getAssetUrl('static/sim.png')}
|
||||
type='Agents'
|
||||
readTime='6'
|
||||
image={getAssetUrl('static/hero.png')}
|
||||
/>
|
||||
<BlogCard
|
||||
href='/blog/test'
|
||||
title='How to Build an Agent in 5 Steps with sim.ai'
|
||||
description="Learn how to create a fully functional AI agent using sim.ai's unified API and workflows."
|
||||
author='Emir Ayaz'
|
||||
authorRole='Designer'
|
||||
avatar={getAssetUrl('static/sim.png')}
|
||||
type='Agents'
|
||||
readTime='6'
|
||||
/>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
className='hidden flex-col gap-12 lg:flex'
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
transition={{ duration: 0.7, delay: 0.26, ease: 'easeOut' }}
|
||||
>
|
||||
<BlogCard
|
||||
href='/blog/test'
|
||||
title='How to Build an Agent in 5 Steps with sim.ai'
|
||||
description="Learn how to create a fully functional AI agent using sim.ai's unified API and workflows."
|
||||
date={new Date('25 April 2025')}
|
||||
author='Emir Ayaz'
|
||||
authorRole='Designer'
|
||||
avatar={getAssetUrl('static/sim.png')}
|
||||
type='Agents'
|
||||
readTime='6'
|
||||
/>
|
||||
<BlogCard
|
||||
href='/blog/test'
|
||||
title='How to Build an Agent in 5 Steps with sim.ai'
|
||||
description="Learn how to create a fully functional AI agent using sim.ai's unified API and workflows."
|
||||
date={new Date('25 April 2025')}
|
||||
author='Emir Ayaz'
|
||||
authorRole='Designer'
|
||||
avatar={getAssetUrl('static/sim.png')}
|
||||
type='Functions'
|
||||
readTime='6'
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.section>
|
||||
)
|
||||
}
|
||||
|
||||
export default Blogs
|
||||
@@ -1,448 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type React from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { CodeXml, WorkflowIcon } from 'lucide-react'
|
||||
import ReactFlow, {
|
||||
ConnectionLineType,
|
||||
type Edge,
|
||||
type Node,
|
||||
type NodeTypes,
|
||||
Position,
|
||||
ReactFlowProvider,
|
||||
useEdgesState,
|
||||
useNodesState,
|
||||
} from 'reactflow'
|
||||
import 'reactflow/dist/style.css'
|
||||
|
||||
import { AgentIcon, ConnectIcon, StartIcon } from '@/components/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { DotPattern } from '@/app/(landing)/components/dot-pattern'
|
||||
import { HeroBlock } from '@/app/(landing)/components/hero-block'
|
||||
|
||||
// --- Types ---
|
||||
type Feature = {
|
||||
icon: React.ComponentType<React.SVGProps<SVGSVGElement>> | typeof CodeXml
|
||||
color: string
|
||||
name: string
|
||||
feature: {
|
||||
icon: React.ReactNode
|
||||
title: string
|
||||
color: string
|
||||
bullets: string[]
|
||||
}
|
||||
nodes: Node[]
|
||||
edges: Edge[]
|
||||
}
|
||||
type FeaturesArray = Feature[]
|
||||
|
||||
// --- Data ---
|
||||
const nodeTypes: NodeTypes = {
|
||||
heroBlock: HeroBlock,
|
||||
}
|
||||
|
||||
// --- Features as Array ---
|
||||
const features: FeaturesArray = [
|
||||
{
|
||||
icon: AgentIcon,
|
||||
color: 'bg-violet-500/5',
|
||||
name: 'Agent 1',
|
||||
feature: {
|
||||
icon: (
|
||||
<div className='flex h-8 w-8 items-center justify-center rounded bg-[#7c3aed]'>
|
||||
<AgentIcon className='h-5 w-5 text-white' />
|
||||
</div>
|
||||
),
|
||||
title: 'Agents',
|
||||
color: 'bg-violet-500/5',
|
||||
bullets: [
|
||||
'Build intelligent agents with a single configuration, using our unified API for any model.',
|
||||
'Equip agents with persistent memory to maintain context across interactions.',
|
||||
'Deploy agents in workflows with built-in tool calling for real-world actions.',
|
||||
],
|
||||
},
|
||||
nodes: [
|
||||
{
|
||||
id: 'agent1',
|
||||
type: 'heroBlock',
|
||||
position: { x: 75, y: 100 },
|
||||
data: { type: 'agent' },
|
||||
sourcePosition: Position.Right,
|
||||
targetPosition: Position.Left,
|
||||
},
|
||||
{
|
||||
id: 'slack1',
|
||||
type: 'heroBlock',
|
||||
position: { x: 500, y: -50 },
|
||||
data: { type: 'slack' },
|
||||
sourcePosition: Position.Left,
|
||||
targetPosition: Position.Right,
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
id: 'agent1-slack1',
|
||||
source: 'agent1',
|
||||
target: 'slack1',
|
||||
type: 'smoothstep',
|
||||
style: { stroke: '#404040', strokeWidth: 1.5, strokeDasharray: '4 4' },
|
||||
animated: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: WorkflowIcon,
|
||||
color: 'bg-blue-500/5',
|
||||
name: 'Custom Workflows',
|
||||
feature: {
|
||||
icon: (
|
||||
<div className='flex h-8 w-8 items-center justify-center rounded bg-[#2563eb]'>
|
||||
<StartIcon className='h-5 w-5 text-white' />
|
||||
</div>
|
||||
),
|
||||
title: 'Custom Workflows',
|
||||
color: 'bg-blue-500/5',
|
||||
bullets: [
|
||||
'Design multi-step automations visually.',
|
||||
'Mix and match agents, functions, and integrations.',
|
||||
'Branch, loop, and orchestrate complex logic with ease.',
|
||||
],
|
||||
},
|
||||
nodes: [
|
||||
{
|
||||
id: 'start',
|
||||
type: 'heroBlock',
|
||||
position: { x: 75, y: 150 },
|
||||
data: { type: 'start' },
|
||||
sourcePosition: Position.Right,
|
||||
targetPosition: Position.Left,
|
||||
},
|
||||
{
|
||||
id: 'function1',
|
||||
type: 'heroBlock',
|
||||
position: { x: 500, y: -20 },
|
||||
data: { type: 'function' },
|
||||
sourcePosition: Position.Right,
|
||||
targetPosition: Position.Left,
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
id: 'start-func1',
|
||||
source: 'start',
|
||||
target: 'function1',
|
||||
type: 'smoothstep',
|
||||
style: { stroke: '#404040', strokeWidth: 1.5, strokeDasharray: '4 4' },
|
||||
animated: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: CodeXml,
|
||||
color: 'bg-red-500/5',
|
||||
name: 'Function 1',
|
||||
feature: {
|
||||
icon: (
|
||||
<div className='flex h-8 w-8 items-center justify-center rounded bg-[#ef4444]'>
|
||||
<CodeXml className='h-5 w-5 text-white' />
|
||||
</div>
|
||||
),
|
||||
title: 'Custom Functions',
|
||||
color: 'bg-red-500/5',
|
||||
bullets: [
|
||||
'Write and deploy custom logic in seconds.',
|
||||
'Integrate any API or service with minimal boilerplate.',
|
||||
'TypeScript-first, hot-reload, and versioned.',
|
||||
],
|
||||
},
|
||||
nodes: [
|
||||
{
|
||||
id: 'function1',
|
||||
type: 'heroBlock',
|
||||
position: { x: 75, y: 125 },
|
||||
data: { type: 'function' },
|
||||
sourcePosition: Position.Right,
|
||||
targetPosition: Position.Left,
|
||||
},
|
||||
{
|
||||
id: 'agent1',
|
||||
type: 'heroBlock',
|
||||
position: { x: 500, y: -50 },
|
||||
data: { type: 'agent' },
|
||||
sourcePosition: Position.Right,
|
||||
targetPosition: Position.Left,
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
id: 'func1-agent1',
|
||||
source: 'function1',
|
||||
target: 'agent1',
|
||||
type: 'smoothstep',
|
||||
style: { stroke: '#404040', strokeWidth: 1.5, strokeDasharray: '4 4' },
|
||||
animated: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: ConnectIcon,
|
||||
color: 'bg-green-500/5',
|
||||
name: 'Router 1',
|
||||
feature: {
|
||||
icon: (
|
||||
<div className='flex h-8 w-8 items-center justify-center rounded bg-[#22c55e]'>
|
||||
<ConnectIcon className='h-5 w-5 text-white' />
|
||||
</div>
|
||||
),
|
||||
title: 'Routers',
|
||||
color: 'bg-green-500/5',
|
||||
bullets: [
|
||||
'Route tasks dynamically based on context or input.',
|
||||
'Chain multiple agents and tools with conditional logic.',
|
||||
'Easily add fallback and error handling branches.',
|
||||
],
|
||||
},
|
||||
nodes: [
|
||||
{
|
||||
id: 'router1',
|
||||
type: 'heroBlock',
|
||||
position: { x: 75, y: 100 },
|
||||
data: { type: 'router' },
|
||||
sourcePosition: Position.Right,
|
||||
targetPosition: Position.Left,
|
||||
},
|
||||
{
|
||||
id: 'agent1',
|
||||
type: 'heroBlock',
|
||||
position: { x: 500, y: -20 },
|
||||
data: { type: 'agent' },
|
||||
sourcePosition: Position.Right,
|
||||
targetPosition: Position.Left,
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
id: 'router1-agent1',
|
||||
source: 'router1',
|
||||
target: 'agent1',
|
||||
type: 'smoothstep',
|
||||
style: { stroke: '#404040', strokeWidth: 1.5, strokeDasharray: '4 4' },
|
||||
animated: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// Add this mapping at the top of the file
|
||||
const bulletColors = [
|
||||
'bg-violet-500', // Agents
|
||||
'bg-blue-500', // Workflows
|
||||
'bg-red-500', // Functions
|
||||
'bg-green-500', // Routers
|
||||
]
|
||||
|
||||
function FeaturesFlow({ nodes, edges }: { nodes: Node[]; edges: Edge[] }) {
|
||||
const [rfNodes, setRfNodes, onNodesChange] = useNodesState(nodes)
|
||||
const [rfEdges, setRfEdges, onEdgesChange] = useEdgesState(edges)
|
||||
useEffect(() => {
|
||||
setRfNodes(nodes)
|
||||
setRfEdges(edges)
|
||||
}, [nodes, edges, setRfNodes, setRfEdges])
|
||||
return (
|
||||
<motion.div className='relative h-full w-full'>
|
||||
<ReactFlow
|
||||
nodes={rfNodes}
|
||||
edges={rfEdges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
nodeTypes={nodeTypes}
|
||||
connectionLineType={ConnectionLineType.SmoothStep}
|
||||
connectionLineStyle={{ stroke: '#404040', strokeWidth: 1.5, strokeDasharray: '4 4' }}
|
||||
defaultViewport={{ x: 80, y: 0, zoom: 1.3 }}
|
||||
nodesDraggable={false}
|
||||
nodesConnectable={false}
|
||||
elementsSelectable={false}
|
||||
panOnScroll={false}
|
||||
zoomOnScroll={false}
|
||||
zoomOnPinch={false}
|
||||
zoomOnDoubleClick={false}
|
||||
panOnDrag={false}
|
||||
selectionOnDrag={false}
|
||||
preventScrolling={true}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
className='pointer-events-none'
|
||||
/>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
function Features() {
|
||||
const [open, setOpen] = useState(0)
|
||||
const selectedFeature = features[open]
|
||||
|
||||
return (
|
||||
<motion.section
|
||||
className='flex w-full flex-col gap-20 px-8 py-20 md:px-0'
|
||||
initial={{ opacity: 0 }}
|
||||
whileInView={{ opacity: 1 }}
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
transition={{ duration: 0.7, ease: 'easeOut' }}
|
||||
>
|
||||
<motion.div
|
||||
className='flex w-full flex-col items-center gap-7'
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, amount: 0.3 }}
|
||||
transition={{ duration: 0.7, delay: 0.1, ease: 'easeOut' }}
|
||||
>
|
||||
<motion.p
|
||||
className='text-center font-medium text-5xl text-white tracking-tight'
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, amount: 0.3 }}
|
||||
transition={{ duration: 0.6, delay: 0.2 }}
|
||||
>
|
||||
Powerful tools for Agent success
|
||||
</motion.p>
|
||||
<motion.p
|
||||
className='max-w-xl text-center font-light text-white/60 text-xl tracking-normal'
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, amount: 0.3 }}
|
||||
transition={{ duration: 0.6, delay: 0.3 }}
|
||||
>
|
||||
Unlock the full potential of your agents with intuitive features designed to simplify
|
||||
development and maximize impact.
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
|
||||
<div className='flex w-full'>
|
||||
<div className='relative hidden w-full lg:flex'>
|
||||
<div className='-top-48 absolute left-0'>
|
||||
<svg
|
||||
width='1021'
|
||||
height='1126'
|
||||
viewBox='0 0 1021 1126'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<g filter='url(#filter0_f_82_4275)'>
|
||||
<ellipse cx='342.5' cy='556' rx='278.5' ry='280' fill='#593859' />
|
||||
</g>
|
||||
<defs>
|
||||
<filter
|
||||
id='filter0_f_82_4275'
|
||||
x='-336'
|
||||
y='-124'
|
||||
width='1357'
|
||||
height='1360'
|
||||
filterUnits='userSpaceOnUse'
|
||||
colorInterpolationFilters='sRGB'
|
||||
>
|
||||
<feFlood floodOpacity='0' result='BackgroundImageFix' />
|
||||
<feBlend
|
||||
mode='normal'
|
||||
in='SourceGraphic'
|
||||
in2='BackgroundImageFix'
|
||||
result='shape'
|
||||
/>
|
||||
<feGaussianBlur stdDeviation='200' result='effect1_foregroundBlur_82_4275' />
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
<motion.div
|
||||
className='relative z-10 hidden min-h-[44rem] w-full flex-col overflow-hidden rounded-r-3xl border border-[#606060]/30 bg-[#0f0f0f] lg:flex'
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
transition={{ duration: 0.7, delay: 0.2, ease: 'easeOut' }}
|
||||
>
|
||||
<DotPattern className='rounded-r-3xl opacity-20' x={-5} y={-5} />
|
||||
<div className='flex w-full flex-1 items-center justify-center'>
|
||||
<ReactFlowProvider>
|
||||
<FeaturesFlow nodes={selectedFeature.nodes} edges={selectedFeature.edges} />
|
||||
</ReactFlowProvider>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
<div className='flex w-full px-4 md:px-24 lg:px-32 xl:px-48'>
|
||||
<motion.div
|
||||
className='flex w-full flex-col overflow-hidden rounded-3xl border border-[#606060]/30 bg-[#0f0f0f]'
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
transition={{ duration: 0.7, delay: 0.3, ease: 'easeOut' }}
|
||||
>
|
||||
{features.map((f, i) => {
|
||||
const isOpen = open === i
|
||||
return (
|
||||
<motion.div
|
||||
key={f.feature.title}
|
||||
className={cn(
|
||||
'flex h-full w-full flex-col justify-center border-[#222222] border-b',
|
||||
isOpen ? f.color : ''
|
||||
)}
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
transition={{ duration: 0.5, delay: 0.1 + i * 0.08, ease: 'easeInOut' }}
|
||||
>
|
||||
<button
|
||||
className='flex w-full items-center gap-4 px-8 py-6 transition-colors focus:outline-none'
|
||||
onClick={() => setOpen(i)}
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
{f.feature.icon}
|
||||
<span className='font-semibold text-white/70 text-xl'>{f.feature.title}</span>
|
||||
</button>
|
||||
<AnimatePresence initial={false}>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
key='content'
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.45, ease: 'easeInOut' }}
|
||||
className='overflow-hidden px-8'
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 16 }}
|
||||
transition={{ duration: 0.35, ease: 'easeInOut' }}
|
||||
className='pb-8'
|
||||
>
|
||||
{f.feature.bullets.length > 0 && (
|
||||
<ul className='mt-2 flex flex-col gap-5'>
|
||||
{f.feature.bullets.map((b, j) => (
|
||||
<li
|
||||
key={j}
|
||||
className='flex max-w-sm items-start text-lg text-white/80'
|
||||
>
|
||||
<span
|
||||
className={`h-2 w-2 ${bulletColors[i]} mt-2 mr-2 inline-block`}
|
||||
/>
|
||||
<span className='font-light text-base text-white/70 leading-[1.4]'>
|
||||
{b}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.section>
|
||||
)
|
||||
}
|
||||
|
||||
export default Features
|
||||
@@ -1,368 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'framer-motion'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { DiscordIcon, GithubIcon, xIcon as XIcon } from '@/components/icons'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useSession } from '@/lib/auth-client'
|
||||
import useIsMobile from '@/app/(landing)/components/hooks/use-is-mobile'
|
||||
import { usePrefetchOnHover } from '@/app/(landing)/utils/prefetch'
|
||||
|
||||
function Footer() {
|
||||
const router = useRouter()
|
||||
const { isMobile, mounted } = useIsMobile()
|
||||
const { data: session, isPending } = useSession()
|
||||
const isAuthenticated = !isPending && !!session?.user
|
||||
|
||||
const handleContributorsHover = usePrefetchOnHover()
|
||||
|
||||
const handleNavigate = () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
// Check if user has an active session
|
||||
if (isAuthenticated) {
|
||||
router.push('/workspace')
|
||||
} else {
|
||||
// Check if user has logged in before
|
||||
const hasLoggedInBefore =
|
||||
localStorage.getItem('has_logged_in_before') === 'true' ||
|
||||
document.cookie.includes('has_logged_in_before=true')
|
||||
|
||||
if (hasLoggedInBefore) {
|
||||
// User has logged in before but doesn't have an active session
|
||||
router.push('/login')
|
||||
} else {
|
||||
// User has never logged in before
|
||||
router.push('/signup')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!mounted) {
|
||||
return <section className='flex w-full p-4 md:p-9' />
|
||||
}
|
||||
|
||||
// If on mobile, render without animations
|
||||
if (isMobile) {
|
||||
return (
|
||||
<section className='flex w-full p-4 md:p-9'>
|
||||
<div className='flex w-full flex-col rounded-3xl bg-[#2B2334] p-6 sm:p-10 md:p-16'>
|
||||
<div className='flex h-full w-full flex-col justify-between md:flex-row'>
|
||||
{/* Left side content */}
|
||||
<div className='flex flex-col justify-between'>
|
||||
<p className='max-w-lg font-light text-5xl text-[#B5A1D4] leading-[1.1] md:text-6xl'>
|
||||
Ready to build AI faster and easier?
|
||||
</p>
|
||||
<div className='mt-4 pt-4 md:mt-auto md:pt-8'>
|
||||
<Button
|
||||
className='w-fit bg-[#B5A1D4] text-[#1C1C1C] transition-colors duration-500 hover:bg-[#bdaecb]'
|
||||
size={'lg'}
|
||||
variant={'secondary'}
|
||||
onClick={handleNavigate}
|
||||
>
|
||||
Get Started
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right side content */}
|
||||
<div className='relative mt-8 flex w-full flex-col gap-6 md:mt-0 md:w-auto md:flex-row md:items-end md:justify-end md:gap-16'>
|
||||
{/* See repo button positioned absolutely to align with the top text - desktop only */}
|
||||
<div className='absolute top-0 right-0 hidden md:block'>
|
||||
<Link
|
||||
href='https://github.com/simstudioai/sim'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
<Button
|
||||
className='flex items-center gap-2 bg-[#B5A1D4] text-[#1C1C1C] transition-colors duration-500 hover:bg-[#bdaecb]'
|
||||
size={'lg'}
|
||||
variant={'secondary'}
|
||||
>
|
||||
<GithubIcon className='h-5 w-5' />
|
||||
See repo
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Links section - flex row on mobile, part of flex row in md */}
|
||||
<div className='flex w-full flex-row justify-between gap-4 md:w-auto md:justify-start md:gap-16'>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<Link
|
||||
href={'https://docs.sim.ai/'}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='font-light text-[#9E91AA] text-xl transition-all duration-500 hover:text-[#bdaecb] md:text-2xl'
|
||||
>
|
||||
Docs
|
||||
</Link>
|
||||
<Link
|
||||
href={'/contributors'}
|
||||
className='font-light text-[#9E91AA] text-xl transition-all duration-500 hover:text-[#bdaecb] md:text-2xl'
|
||||
onMouseEnter={handleContributorsHover}
|
||||
>
|
||||
Contributors
|
||||
</Link>
|
||||
</div>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<Link
|
||||
href={'/terms'}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='font-light text-[#9E91AA] text-xl transition-all duration-500 hover:text-[#bdaecb] md:text-2xl'
|
||||
>
|
||||
Terms and Conditions
|
||||
</Link>
|
||||
<Link
|
||||
href={'/privacy'}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='font-light text-[#9E91AA] text-xl transition-all duration-500 hover:text-[#bdaecb] md:text-2xl'
|
||||
>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Social icons */}
|
||||
<div className='mt-4 flex items-center md:mt-0 md:justify-end'>
|
||||
<div className='flex gap-4'>
|
||||
<Link
|
||||
href={'https://github.com/simstudioai/sim'}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='flex text-2xl transition-all duration-500 md:hidden'
|
||||
>
|
||||
<svg
|
||||
width='36'
|
||||
height='36'
|
||||
viewBox='0 0 1024 1024'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
clipRule='evenodd'
|
||||
d='M8 0C3.58 0 0 3.58 0 8C0 11.54 2.29 14.53 5.47 15.59C5.87 15.66 6.02 15.42 6.02 15.21C6.02 15.02 6.01 14.39 6.01 13.72C4 14.09 3.48 13.23 3.32 12.78C3.23 12.55 2.84 11.84 2.5 11.65C2.22 11.5 1.82 11.13 2.49 11.12C3.12 11.11 3.57 11.7 3.72 11.94C4.44 13.15 5.59 12.81 6.05 12.6C6.12 12.08 6.33 11.73 6.56 11.53C4.78 11.33 2.92 10.64 2.92 7.58C2.92 6.71 3.23 5.99 3.74 5.43C3.66 5.23 3.38 4.41 3.82 3.31C3.82 3.31 4.49 3.1 6.02 4.13C6.66 3.95 7.34 3.86 8.02 3.86C8.7 3.86 9.38 3.95 10.02 4.13C11.55 3.09 12.22 3.31 12.22 3.31C12.66 4.41 12.38 5.23 12.3 5.43C12.81 5.99 13.12 6.7 13.12 7.58C13.12 10.65 11.25 11.33 9.47 11.53C9.76 11.78 10.01 12.26 10.01 13.01C10.01 14.08 10 14.94 10 15.21C10 15.42 10.15 15.67 10.55 15.59C13.71 14.53 16 11.53 16 8C16 3.58 12.42 0 8 0Z'
|
||||
transform='scale(64)'
|
||||
fill='#9E91AA'
|
||||
/>
|
||||
</svg>
|
||||
</Link>
|
||||
<Link
|
||||
href={'https://discord.gg/Hr4UWYEcTT'}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-2xl transition-all duration-500'
|
||||
>
|
||||
<DiscordIcon className='h-9 w-9 fill-[#9E91AA] hover:fill-[#bdaecb] md:h-10 md:w-10' />
|
||||
</Link>
|
||||
<Link
|
||||
href={'https://x.com/simdotai'}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-2xl transition-all duration-500'
|
||||
>
|
||||
<XIcon className='h-9 w-9 text-[#9E91AA] transition-all duration-500 hover:text-[#bdaecb] md:h-10 md:w-10' />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.section
|
||||
className='flex w-full p-4 md:p-9'
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
transition={{ duration: 0.7, delay: 0.05, ease: 'easeOut' }}
|
||||
>
|
||||
<motion.div
|
||||
className='flex w-full flex-col rounded-3xl bg-[#2B2334] p-6 sm:p-10 md:p-16'
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
transition={{ duration: 0.7, delay: 0.1, ease: 'easeOut' }}
|
||||
>
|
||||
<motion.div
|
||||
className='flex h-full w-full flex-col justify-between md:flex-row'
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
transition={{ duration: 0.7, delay: 0.15, ease: 'easeOut' }}
|
||||
>
|
||||
{/* Left side content */}
|
||||
<div className='flex flex-col justify-between'>
|
||||
<motion.p
|
||||
className='max-w-lg font-light text-5xl text-[#B5A1D4] leading-[1.1] md:text-6xl'
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
transition={{ duration: 0.7, delay: 0.18, ease: 'easeOut' }}
|
||||
>
|
||||
Ready to build AI faster and easier?
|
||||
</motion.p>
|
||||
<motion.div
|
||||
className='mt-4 pt-4 md:mt-auto md:pt-8'
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
transition={{ duration: 0.7, delay: 0.22, ease: 'easeOut' }}
|
||||
>
|
||||
<Button
|
||||
className='w-fit bg-[#B5A1D4] text-[#1C1C1C] transition-colors duration-500 hover:bg-[#bdaecb]'
|
||||
size={'lg'}
|
||||
variant={'secondary'}
|
||||
onClick={handleNavigate}
|
||||
>
|
||||
Get Started
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Right side content */}
|
||||
<motion.div
|
||||
className='relative mt-8 flex w-full flex-col gap-6 md:mt-0 md:w-auto md:flex-row md:items-end md:justify-end md:gap-16'
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
transition={{ duration: 0.7, delay: 0.28, ease: 'easeOut' }}
|
||||
>
|
||||
{/* See repo button positioned absolutely to align with the top text - desktop only */}
|
||||
<motion.div
|
||||
className='absolute top-0 right-0 hidden md:block'
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
transition={{ duration: 0.7, delay: 0.4, ease: 'easeOut' }}
|
||||
>
|
||||
<Link
|
||||
href='https://github.com/simstudioai/sim'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
<Button
|
||||
className='flex items-center gap-2 bg-[#B5A1D4] text-[#1C1C1C] transition-colors duration-500 hover:bg-[#bdaecb]'
|
||||
size={'lg'}
|
||||
variant={'secondary'}
|
||||
>
|
||||
<GithubIcon className='h-5 w-5' />
|
||||
See repo
|
||||
</Button>
|
||||
</Link>
|
||||
</motion.div>
|
||||
|
||||
{/* Links section - flex row on mobile, part of flex row in md */}
|
||||
<div className='flex w-full flex-row justify-between gap-4 md:w-auto md:justify-start md:gap-16'>
|
||||
<motion.div
|
||||
className='flex flex-col gap-2'
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
transition={{ duration: 0.7, delay: 0.32, ease: 'easeOut' }}
|
||||
>
|
||||
<Link
|
||||
href={'https://docs.sim.ai/'}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='font-light text-[#9E91AA] text-xl transition-all duration-500 hover:text-[#bdaecb] md:text-2xl'
|
||||
>
|
||||
Docs
|
||||
</Link>
|
||||
<Link
|
||||
href={'/contributors'}
|
||||
className='font-light text-[#9E91AA] text-xl transition-all duration-500 hover:text-[#bdaecb] md:text-2xl'
|
||||
onMouseEnter={handleContributorsHover}
|
||||
>
|
||||
Contributors
|
||||
</Link>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
className='flex flex-col gap-2'
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
transition={{ duration: 0.7, delay: 0.36, ease: 'easeOut' }}
|
||||
>
|
||||
<Link
|
||||
href={'/terms'}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='font-light text-[#9E91AA] text-xl transition-all duration-500 hover:text-[#bdaecb] md:text-2xl'
|
||||
>
|
||||
Terms and Conditions
|
||||
</Link>
|
||||
<Link
|
||||
href={'/privacy'}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='font-light text-[#9E91AA] text-xl transition-all duration-500 hover:text-[#bdaecb] md:text-2xl'
|
||||
>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Social icons */}
|
||||
<motion.div
|
||||
className='mt-4 flex items-center md:mt-0 md:justify-end'
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
transition={{ duration: 0.7, delay: 0.4, ease: 'easeOut' }}
|
||||
>
|
||||
<div className='flex gap-4'>
|
||||
<Link
|
||||
href={'https://github.com/simstudioai/sim'}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='flex text-2xl transition-all duration-500 md:hidden'
|
||||
>
|
||||
<svg
|
||||
width='36'
|
||||
height='36'
|
||||
viewBox='0 0 1024 1024'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
clipRule='evenodd'
|
||||
d='M8 0C3.58 0 0 3.58 0 8C0 11.54 2.29 14.53 5.47 15.59C5.87 15.66 6.02 15.42 6.02 15.21C6.02 15.02 6.01 14.39 6.01 13.72C4 14.09 3.48 13.23 3.32 12.78C3.23 12.55 2.84 11.84 2.5 11.65C2.22 11.5 1.82 11.13 2.49 11.12C3.12 11.11 3.57 11.7 3.72 11.94C4.44 13.15 5.59 12.81 6.05 12.6C6.12 12.08 6.33 11.73 6.56 11.53C4.78 11.33 2.92 10.64 2.92 7.58C2.92 6.71 3.23 5.99 3.74 5.43C3.66 5.23 3.38 4.41 3.82 3.31C3.82 3.31 4.49 3.1 6.02 4.13C6.66 3.95 7.34 3.86 8.02 3.86C8.7 3.86 9.38 3.95 10.02 4.13C11.55 3.09 12.22 3.31 12.22 3.31C12.66 4.41 12.38 5.23 12.3 5.43C12.81 5.99 13.12 6.7 13.12 7.58C13.12 10.65 11.25 11.33 9.47 11.53C9.76 11.78 10.01 12.26 10.01 13.01C10.01 14.08 10 14.94 10 15.21C10 15.42 10.15 15.67 10.55 15.59C13.71 14.53 16 11.53 16 8C16 3.58 12.42 0 8 0Z'
|
||||
transform='scale(64)'
|
||||
fill='#9E91AA'
|
||||
/>
|
||||
</svg>
|
||||
</Link>
|
||||
<Link
|
||||
href={'https://discord.gg/Hr4UWYEcTT'}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-2xl transition-all duration-500'
|
||||
>
|
||||
<DiscordIcon className='h-9 w-9 fill-[#9E91AA] hover:fill-[#bdaecb] md:h-10 md:w-10' />
|
||||
</Link>
|
||||
<Link
|
||||
href={'https://x.com/simdotai'}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-2xl transition-all duration-500'
|
||||
>
|
||||
<XIcon className='h-9 w-9 text-[#9E91AA] transition-all duration-500 hover:text-[#bdaecb] md:h-10 md:w-10' />
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</motion.section>
|
||||
)
|
||||
}
|
||||
|
||||
export default Footer
|
||||
@@ -1,152 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Command, CornerDownLeft } from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useSession } from '@/lib/auth-client'
|
||||
import { GridPattern } from '@/app/(landing)/components/grid-pattern'
|
||||
import HeroWorkflowProvider from '@/app/(landing)/components/hero-workflow'
|
||||
|
||||
function Hero() {
|
||||
const router = useRouter()
|
||||
const [isTransitioning, setIsTransitioning] = useState(true)
|
||||
const { data: session, isPending } = useSession()
|
||||
const isAuthenticated = !isPending && !!session?.user
|
||||
|
||||
const handleNavigate = () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
// Check if user has an active session
|
||||
if (isAuthenticated) {
|
||||
router.push('/workspace')
|
||||
} else {
|
||||
// Check if user has logged in before
|
||||
const hasLoggedInBefore =
|
||||
localStorage.getItem('has_logged_in_before') === 'true' ||
|
||||
document.cookie.includes('has_logged_in_before=true')
|
||||
|
||||
if (hasLoggedInBefore) {
|
||||
// User has logged in before but doesn't have an active session
|
||||
router.push('/login')
|
||||
} else {
|
||||
// User has never logged in before
|
||||
router.push('/signup')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
||||
handleNavigate()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [isAuthenticated])
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setIsTransitioning(false)
|
||||
}, 300) // Reduced delay for faster button appearance
|
||||
return () => clearTimeout(timer)
|
||||
}, [])
|
||||
|
||||
const renderActionUI = () => {
|
||||
if (isTransitioning || isPending) {
|
||||
return <div className='h-[56px] md:h-[64px]' />
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
variant={'secondary'}
|
||||
onClick={handleNavigate}
|
||||
className='animate-fade-in items-center bg-[var(--brand-primary-hex)] px-7 py-6 font-[420] font-geist-sans text-lg text-neutral-100 tracking-normal shadow-[var(--brand-primary-hex)]/30 shadow-lg hover:bg-[var(--brand-primary-hover-hex)]'
|
||||
aria-label='Start using the platform'
|
||||
>
|
||||
<div className='text-[1.15rem]'>Start now</div>
|
||||
<div className='flex items-center gap-1 pl-2 opacity-80' aria-hidden='true'>
|
||||
<Command size={24} />
|
||||
<CornerDownLeft />
|
||||
</div>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
className='animation-container relative min-h-screen overflow-hidden border-[#181818] border-b pt-28 text-white will-change-[opacity,transform] sm:pt-32 md:pt-40'
|
||||
aria-label='Main hero section'
|
||||
>
|
||||
<GridPattern
|
||||
x={-5}
|
||||
y={-5}
|
||||
className='absolute inset-0 z-0 stroke-[#ababab]/5'
|
||||
width={90}
|
||||
height={90}
|
||||
aria-hidden='true'
|
||||
/>
|
||||
|
||||
{/* Centered black background behind text and button */}
|
||||
<div
|
||||
className='-translate-x-1/2 -translate-y-1/2 absolute top-[28%] left-1/2 w-[95%] md:top-[38%] md:w-[60%] lg:w-[50%]'
|
||||
aria-hidden='true'
|
||||
>
|
||||
<svg
|
||||
width='100%'
|
||||
height='100%'
|
||||
viewBox='0 0 600 480'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
preserveAspectRatio='xMidYMid meet'
|
||||
aria-hidden='true'
|
||||
className='aspect-[5/3] h-auto md:aspect-auto'
|
||||
>
|
||||
<g filter='url(#filter0_b_0_1)'>
|
||||
<ellipse cx='300' cy='240' rx='290' ry='220' fill='var(--brand-background-hex)' />
|
||||
</g>
|
||||
<defs>
|
||||
<filter
|
||||
id='filter0_b_0_1'
|
||||
x='0'
|
||||
y='10'
|
||||
width='600'
|
||||
height='460'
|
||||
filterUnits='userSpaceOnUse'
|
||||
colorInterpolationFilters='sRGB'
|
||||
>
|
||||
<feGaussianBlur stdDeviation='5' />
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className='absolute inset-0 z-10 flex h-full items-center justify-center'
|
||||
aria-hidden='true'
|
||||
>
|
||||
<HeroWorkflowProvider />
|
||||
</div>
|
||||
|
||||
<div className='animation-container relative z-20 space-y-4 px-4 text-center'>
|
||||
<h1 className='animation-container animate-fade-up font-semibold text-[42px] leading-[1.10] opacity-0 will-change-[opacity,transform] [animation-delay:200ms] md:text-[68px]'>
|
||||
Build / Deploy
|
||||
<br />
|
||||
Agent Workflows
|
||||
</h1>
|
||||
|
||||
<p className='animation-container mx-auto max-w-3xl animate-fade-up font-normal text-base text-neutral-400/80 leading-[1.5] tracking-normal opacity-0 will-change-[opacity,transform] [animation-delay:400ms] md:text-xl'>
|
||||
Launch agentic workflows with an open source, <br />
|
||||
user-friendly environment for devs and agents
|
||||
</p>
|
||||
|
||||
<div className='animation-container translate-y-[-10px] animate-fade-up pt-4 pb-10 opacity-0 will-change-[opacity,transform] [animation-delay:600ms]'>
|
||||
{renderActionUI()}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default Hero
|
||||
@@ -1,934 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'framer-motion'
|
||||
import { GitBranch, RefreshCcw } from 'lucide-react'
|
||||
import ReactFlow, { ConnectionLineType, Position, ReactFlowProvider } from 'reactflow'
|
||||
import { DotPattern } from '@/app/(landing)/components/dot-pattern'
|
||||
import { HeroBlock } from '@/app/(landing)/components/hero-block'
|
||||
import { OrbitingCircles } from '@/app/(landing)/components/magicui/orbiting-circles'
|
||||
|
||||
function Integrations() {
|
||||
return (
|
||||
<section className='flex w-full flex-col gap-10 px-8 py-12 md:px-16 lg:px-28 xl:px-32'>
|
||||
<div className='flex flex-col gap-5'>
|
||||
<motion.p
|
||||
className='font-medium text-[42px] text-white leading-none tracking-normal md:text-5xl md:leading-tight'
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
transition={{ duration: 0.7, delay: 0.05, ease: 'easeOut' }}
|
||||
>
|
||||
Everything you need,
|
||||
<br />
|
||||
connected
|
||||
</motion.p>
|
||||
<motion.p
|
||||
className='max-w-md font-light text-white/60 text-xl tracking-normal'
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
transition={{ duration: 0.7, delay: 0.15, ease: 'easeOut' }}
|
||||
>
|
||||
Seamlessly connect your agents with the tools you already use—no extra setup required.
|
||||
</motion.p>
|
||||
</div>
|
||||
|
||||
{/* Desktop view */}
|
||||
<div className='relative z-10 hidden min-h-[36rem] w-full items-center justify-center overflow-hidden rounded-3xl border border-[#606060]/30 bg-[#0f0f0f] md:flex'>
|
||||
<DotPattern className='rounded-3xl opacity-10' x={-5} y={-5} />
|
||||
<div className='-translate-x-1/2 absolute bottom-0 left-1/2'>
|
||||
<svg
|
||||
width='800'
|
||||
height='450'
|
||||
viewBox='0 0 1076 623'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<g filter='url(#filter0_f_113_56)'>
|
||||
<path
|
||||
d='M278.98 498.157L657.323 493.454L573.161 1204.36L499.788 1191.21L278.98 498.157Z'
|
||||
fill='url(#paint0_linear_113_56)'
|
||||
/>
|
||||
</g>
|
||||
<g filter='url(#filter1_f_113_56)'>
|
||||
<path
|
||||
d='M396.125 258.957L774.468 254.254L690.306 965.155L616.933 952.012L396.125 258.957Z'
|
||||
fill='url(#paint1_linear_113_56)'
|
||||
/>
|
||||
</g>
|
||||
<g filter='url(#filter2_f_113_56)'>
|
||||
<path
|
||||
d='M357.731 305.714L604.127 503.599L628.26 978.086L578.913 929.443L357.731 305.714Z'
|
||||
fill='url(#paint2_linear_113_56)'
|
||||
/>
|
||||
</g>
|
||||
<g filter='url(#filter3_f_113_56)'>
|
||||
<path
|
||||
d='M534.274 220.998L736.222 455.766L755.909 905.149L715.466 849.688L534.274 220.998Z'
|
||||
fill='url(#paint3_linear_113_56)'
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter
|
||||
id='filter0_f_113_56'
|
||||
x='-21.02'
|
||||
y='193.454'
|
||||
width='978.342'
|
||||
height='1310.9'
|
||||
filterUnits='userSpaceOnUse'
|
||||
colorInterpolationFilters='sRGB'
|
||||
>
|
||||
<feFlood floodOpacity={0} result='BackgroundImageFix' />
|
||||
<feBlend mode='normal' in='SourceGraphic' in2='BackgroundImageFix' result='shape' />
|
||||
<feGaussianBlur stdDeviation='150' result='effect1_foregroundBlur_113_56' />
|
||||
</filter>
|
||||
<filter
|
||||
id='filter1_f_113_56'
|
||||
x='96.125'
|
||||
y='-45.7463'
|
||||
width='978.342'
|
||||
height='1310.9'
|
||||
filterUnits='userSpaceOnUse'
|
||||
colorInterpolationFilters='sRGB'
|
||||
>
|
||||
<feFlood floodOpacity='0' result='BackgroundImageFix' />
|
||||
<feBlend mode='normal' in='SourceGraphic' in2='BackgroundImageFix' result='shape' />
|
||||
<feGaussianBlur stdDeviation='150' result='effect1_foregroundBlur_113_56' />
|
||||
</filter>
|
||||
<filter
|
||||
id='filter2_f_113_56'
|
||||
x='257.731'
|
||||
y='205.714'
|
||||
width='470.529'
|
||||
height='872.372'
|
||||
filterUnits='userSpaceOnUse'
|
||||
colorInterpolationFilters='sRGB'
|
||||
>
|
||||
<feFlood floodOpacity='0' result='BackgroundImageFix' />
|
||||
<feBlend mode='normal' in='SourceGraphic' in2='BackgroundImageFix' result='shape' />
|
||||
<feGaussianBlur stdDeviation='50' result='effect1_foregroundBlur_113_56' />
|
||||
</filter>
|
||||
<filter
|
||||
id='filter3_f_113_56'
|
||||
x='434.274'
|
||||
y='120.998'
|
||||
width='421.636'
|
||||
height='884.151'
|
||||
filterUnits='userSpaceOnUse'
|
||||
colorInterpolationFilters='sRGB'
|
||||
>
|
||||
<feFlood floodOpacity='0' result='BackgroundImageFix' />
|
||||
<feBlend mode='normal' in='SourceGraphic' in2='BackgroundImageFix' result='shape' />
|
||||
<feGaussianBlur stdDeviation='50' result='effect1_foregroundBlur_113_56' />
|
||||
</filter>
|
||||
<linearGradient
|
||||
id='paint0_linear_113_56'
|
||||
x1='451.681'
|
||||
y1='1151.32'
|
||||
x2='661.061'
|
||||
y2='557.954'
|
||||
gradientUnits='userSpaceOnUse'
|
||||
>
|
||||
<stop stopColor='#9C75D7' />
|
||||
<stop offset='1' stopColor='#9C75D7' stopOpacity='0' />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id='paint1_linear_113_56'
|
||||
x1='568.826'
|
||||
y1='912.119'
|
||||
x2='778.206'
|
||||
y2='318.753'
|
||||
gradientUnits='userSpaceOnUse'
|
||||
>
|
||||
<stop stopColor='#9C75D7' />
|
||||
<stop offset='1' stopColor='#9C75D7' stopOpacity='0' />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id='paint2_linear_113_56'
|
||||
x1='543.08'
|
||||
y1='874.705'
|
||||
x2='742.662'
|
||||
y2='699.882'
|
||||
gradientUnits='userSpaceOnUse'
|
||||
>
|
||||
<stop stopColor='#9C75D7' />
|
||||
<stop offset='1' stopColor='#9C75D7' stopOpacity='0' />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id='paint3_linear_113_56'
|
||||
x1='686.102'
|
||||
y1='791.225'
|
||||
x2='858.04'
|
||||
y2='680.269'
|
||||
gradientUnits='userSpaceOnUse'
|
||||
>
|
||||
<stop stopColor='#9C75D7' />
|
||||
<stop offset='1' stopColor='#9C75D7' stopOpacity='0' />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
<OrbitingCircles radius={160}>
|
||||
<div className='flex aspect-square h-16 w-16 items-center justify-center rounded-xl border border-[#353535] bg-[#242424] p-1 shadow-[0px_2px_6px_0px_rgba(126,_48,_252,_0.1)]'>
|
||||
<Icons.pinecone />
|
||||
</div>
|
||||
<div className='flex aspect-square h-16 w-16 items-center justify-center rounded-xl border border-[#353535] bg-[#242424] p-1 shadow-[0px_2px_6px_0px_rgba(126,_48,_252,_0.1)]'>
|
||||
<Icons.qdrant />
|
||||
</div>
|
||||
<div className='flex aspect-square h-16 w-16 items-center justify-center rounded-xl border border-[#353535] bg-[#242424] p-1 shadow-[0px_2px_6px_0px_rgba(126,_48,_252,_0.1)]'>
|
||||
<Icons.slack />
|
||||
</div>
|
||||
</OrbitingCircles>
|
||||
<OrbitingCircles iconSize={40} radius={320} reverse>
|
||||
<div className='flex aspect-square h-16 w-16 items-center justify-center rounded-xl border border-[#353535] bg-[#242424] p-2 shadow-[0px_2px_6px_0px_rgba(126,_48,_252,_0.1)]'>
|
||||
<Icons.gitHub />
|
||||
</div>
|
||||
<div className='flex aspect-square h-16 w-16 items-center justify-center rounded-xl border border-[#353535] bg-[#242424] p-1 shadow-[0px_2px_6px_0px_rgba(126,_48,_252,_0.1)]'>
|
||||
<Icons.supabase />
|
||||
</div>
|
||||
<div className='flex aspect-square h-16 w-16 items-center justify-center rounded-xl border border-[#353535] bg-[#242424] p-1 shadow-[0px_2px_6px_0px_rgba(126,_48,_252,_0.1)]'>
|
||||
<Icons.perplexity />
|
||||
</div>
|
||||
</OrbitingCircles>
|
||||
<OrbitingCircles iconSize={40} radius={480}>
|
||||
<div className='flex aspect-square h-16 w-16 items-center justify-center rounded-xl border border-[#353535] bg-[#242424] p-2 shadow-[0px_2px_6px_0px_rgba(126,_48,_252,_0.1)]'>
|
||||
<Icons.youtube />
|
||||
</div>
|
||||
<div className='flex aspect-square h-16 w-16 items-center justify-center rounded-xl border border-[#353535] bg-[#242424] p-1 shadow-[0px_2px_6px_0px_rgba(126,_48,_252,_0.1)]'>
|
||||
<Icons.reddit />
|
||||
</div>
|
||||
<div className='flex aspect-square h-16 w-16 items-center justify-center rounded-xl border border-[#353535] bg-[#242424] p-1 shadow-[0px_2px_6px_0px_rgba(126,_48,_252,_0.1)]'>
|
||||
<Icons.notion />
|
||||
</div>
|
||||
</OrbitingCircles>
|
||||
</div>
|
||||
|
||||
{/* Mobile view */}
|
||||
<div className='relative z-10 flex min-h-[28rem] w-full items-center justify-center overflow-hidden rounded-3xl border border-[#606060]/30 bg-[#0f0f0f] md:hidden'>
|
||||
<DotPattern className='rounded-3xl opacity-10' x={-5} y={-5} />
|
||||
<div className='absolute inset-0 z-0 flex items-center justify-center'>
|
||||
<div className='-translate-x-1/2 absolute bottom-[-80px] left-[45%] w-[130%]'>
|
||||
<svg
|
||||
width='100%'
|
||||
height='350'
|
||||
viewBox='0 0 600 450'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
preserveAspectRatio='xMidYMid meet'
|
||||
>
|
||||
<path
|
||||
d='M180 150L380 150L350 380L220 365L180 150Z'
|
||||
fill='url(#mobile_paint0)'
|
||||
filter='url(#mobile_filter0)'
|
||||
/>
|
||||
<path
|
||||
d='M220 70L420 70L390 300L260 285L220 70Z'
|
||||
fill='url(#mobile_paint1)'
|
||||
filter='url(#mobile_filter1)'
|
||||
/>
|
||||
<defs>
|
||||
<filter
|
||||
id='mobile_filter0'
|
||||
x='100'
|
||||
y='70'
|
||||
width='360'
|
||||
height='390'
|
||||
filterUnits='userSpaceOnUse'
|
||||
colorInterpolationFilters='sRGB'
|
||||
>
|
||||
<feFlood floodOpacity='0' result='BackgroundImageFix' />
|
||||
<feBlend
|
||||
mode='normal'
|
||||
in='SourceGraphic'
|
||||
in2='BackgroundImageFix'
|
||||
result='shape'
|
||||
/>
|
||||
<feGaussianBlur stdDeviation='35' result='effect1_foregroundBlur' />
|
||||
</filter>
|
||||
<filter
|
||||
id='mobile_filter1'
|
||||
x='140'
|
||||
y='-10'
|
||||
width='360'
|
||||
height='390'
|
||||
filterUnits='userSpaceOnUse'
|
||||
colorInterpolationFilters='sRGB'
|
||||
>
|
||||
<feFlood floodOpacity='0' result='BackgroundImageFix' />
|
||||
<feBlend
|
||||
mode='normal'
|
||||
in='SourceGraphic'
|
||||
in2='BackgroundImageFix'
|
||||
result='shape'
|
||||
/>
|
||||
<feGaussianBlur stdDeviation='35' result='effect1_foregroundBlur' />
|
||||
</filter>
|
||||
<linearGradient
|
||||
id='mobile_paint0'
|
||||
x1='280'
|
||||
y1='360'
|
||||
x2='370'
|
||||
y2='160'
|
||||
gradientUnits='userSpaceOnUse'
|
||||
>
|
||||
<stop stopColor='#9C75D7' stopOpacity='0.4' />
|
||||
<stop offset='1' stopColor='#9C75D7' stopOpacity='0' />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id='mobile_paint1'
|
||||
x1='320'
|
||||
y1='280'
|
||||
x2='410'
|
||||
y2='80'
|
||||
gradientUnits='userSpaceOnUse'
|
||||
>
|
||||
<stop stopColor='#9C75D7' stopOpacity='0.9' />
|
||||
<stop offset='1' stopColor='#9C75D7' stopOpacity='0' />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<OrbitingCircles radius={100}>
|
||||
<div className='flex aspect-square h-12 w-12 items-center justify-center rounded-xl border border-[#353535] bg-[#242424] p-1 shadow-[0px_2px_6px_0px_rgba(126,_48,_252,_0.1)]'>
|
||||
<Icons.pinecone />
|
||||
</div>
|
||||
<div className='flex aspect-square h-12 w-12 items-center justify-center rounded-xl border border-[#353535] bg-[#242424] p-1 shadow-[0px_2px_6px_0px_rgba(126,_48,_252,_0.1)]'>
|
||||
<Icons.qdrant />
|
||||
</div>
|
||||
<div className='flex aspect-square h-12 w-12 items-center justify-center rounded-xl border border-[#353535] bg-[#242424] p-1 shadow-[0px_2px_6px_0px_rgba(126,_48,_252,_0.1)]'>
|
||||
<Icons.slack />
|
||||
</div>
|
||||
</OrbitingCircles>
|
||||
<OrbitingCircles iconSize={32} radius={180} reverse>
|
||||
<div className='flex aspect-square h-12 w-12 items-center justify-center rounded-xl border border-[#353535] bg-[#242424] p-1 shadow-[0px_2px_6px_0px_rgba(126,_48,_252,_0.1)]'>
|
||||
<Icons.gitHub />
|
||||
</div>
|
||||
<div className='flex aspect-square h-12 w-12 items-center justify-center rounded-xl border border-[#353535] bg-[#242424] p-1 shadow-[0px_2px_6px_0px_rgba(126,_48,_252,_0.1)]'>
|
||||
<Icons.notion />
|
||||
</div>
|
||||
</OrbitingCircles>
|
||||
</div>
|
||||
|
||||
<div className='relative flex w-full flex-col gap-20 text-white lg:flex-row'>
|
||||
<div className='flex w-full flex-col gap-8'>
|
||||
<div className='flex flex-col gap-6'>
|
||||
<motion.div
|
||||
className='flex items-center gap-6'
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
transition={{ duration: 0.7, delay: 0.1, ease: 'easeOut' }}
|
||||
>
|
||||
<RefreshCcw size={24} />
|
||||
<span className='text-2xl'>Sync Knowledge in Seconds</span>
|
||||
</motion.div>
|
||||
<motion.p
|
||||
className='max-w-lg font-light text-lg text-white/60'
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
transition={{ duration: 0.7, delay: 0.18, ease: 'easeOut' }}
|
||||
>
|
||||
Import data from your favorite tools to power your AI agents' knowledge bases—no
|
||||
manual uploads needed.
|
||||
</motion.p>
|
||||
</div>
|
||||
<div className='relative z-10 flex h-80 w-full items-center justify-center overflow-hidden rounded-3xl border border-[#606060]/30 bg-[#0f0f0f]'>
|
||||
<DotPattern className='z-0 rounded-3xl opacity-10' x={-5} y={-5} />
|
||||
<motion.div
|
||||
className='z-10 flex h-full w-full justify-end'
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
transition={{ duration: 0.7, delay: 0.2, ease: 'easeOut' }}
|
||||
>
|
||||
<ReactFlowProvider>
|
||||
<ReactFlow
|
||||
nodes={[
|
||||
{
|
||||
id: 'agent1',
|
||||
type: 'heroBlock',
|
||||
position: { x: 50, y: 100 },
|
||||
data: { type: 'agent' },
|
||||
sourcePosition: Position.Right,
|
||||
targetPosition: Position.Left,
|
||||
},
|
||||
{
|
||||
id: 'slack1',
|
||||
type: 'heroBlock',
|
||||
position: { x: 450, y: -30 },
|
||||
data: { type: 'slack' },
|
||||
sourcePosition: Position.Left,
|
||||
targetPosition: Position.Right,
|
||||
},
|
||||
]}
|
||||
edges={[
|
||||
{
|
||||
id: 'agent1-slack1',
|
||||
source: 'agent1',
|
||||
target: 'slack1',
|
||||
type: 'smoothstep',
|
||||
style: { stroke: '#404040', strokeWidth: 1.5, strokeDasharray: '4 4' },
|
||||
animated: true,
|
||||
},
|
||||
]}
|
||||
nodeTypes={{ heroBlock: HeroBlock }}
|
||||
connectionLineType={ConnectionLineType.SmoothStep}
|
||||
connectionLineStyle={{
|
||||
stroke: '#404040',
|
||||
strokeWidth: 1.5,
|
||||
strokeDasharray: '4 4',
|
||||
}}
|
||||
defaultViewport={{ x: 0, y: 0, zoom: 1 }}
|
||||
nodesDraggable={false}
|
||||
nodesConnectable={false}
|
||||
elementsSelectable={false}
|
||||
panOnScroll={false}
|
||||
zoomOnScroll={false}
|
||||
zoomOnPinch={false}
|
||||
zoomOnDoubleClick={false}
|
||||
panOnDrag={false}
|
||||
selectionOnDrag={false}
|
||||
preventScrolling={true}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
className='pointer-events-none h-full w-full'
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
/>
|
||||
</ReactFlowProvider>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex w-full flex-col gap-8'>
|
||||
<div className='flex flex-col gap-6'>
|
||||
<motion.div
|
||||
className='flex items-center gap-6'
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
transition={{ duration: 0.7, delay: 0.2, ease: 'easeOut' }}
|
||||
>
|
||||
<GitBranch size={24} />
|
||||
<span className='text-2xl'>Automate Workflows with Ease</span>
|
||||
</motion.div>
|
||||
<motion.p
|
||||
className='max-w-lg font-light text-lg text-white/60'
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
transition={{ duration: 0.7, delay: 0.28, ease: 'easeOut' }}
|
||||
>
|
||||
Trigger actions and automate tasks across your apps with pre-built integrations.
|
||||
</motion.p>
|
||||
</div>
|
||||
<div className='relative z-10 flex h-80 w-full items-center justify-center overflow-hidden rounded-3xl border border-[#606060]/30 bg-[#0f0f0f]'>
|
||||
<DotPattern className='z-0 rounded-3xl opacity-10' x={-5} y={-5} />
|
||||
|
||||
<motion.div
|
||||
className='z-10 flex h-full w-full justify-end'
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
transition={{ duration: 0.7, delay: 0.4, ease: 'easeOut' }}
|
||||
>
|
||||
<ReactFlowProvider>
|
||||
<ReactFlow
|
||||
nodes={[
|
||||
{
|
||||
id: 'start',
|
||||
type: 'heroBlock',
|
||||
position: { x: 50, y: 120 },
|
||||
data: { type: 'start' },
|
||||
sourcePosition: Position.Right,
|
||||
targetPosition: Position.Left,
|
||||
},
|
||||
{
|
||||
id: 'function1',
|
||||
type: 'heroBlock',
|
||||
position: { x: 450, y: 80 },
|
||||
data: { type: 'function', isHeroSection: false },
|
||||
sourcePosition: Position.Right,
|
||||
targetPosition: Position.Left,
|
||||
},
|
||||
]}
|
||||
edges={[
|
||||
{
|
||||
id: 'start-func1',
|
||||
source: 'start',
|
||||
target: 'function1',
|
||||
type: 'smoothstep',
|
||||
style: { stroke: '#404040', strokeWidth: 1.5, strokeDasharray: '4 4' },
|
||||
animated: true,
|
||||
},
|
||||
]}
|
||||
nodeTypes={{ heroBlock: HeroBlock }}
|
||||
connectionLineType={ConnectionLineType.SmoothStep}
|
||||
connectionLineStyle={{
|
||||
stroke: '#404040',
|
||||
strokeWidth: 1.5,
|
||||
strokeDasharray: '4 4',
|
||||
}}
|
||||
defaultViewport={{ x: 0, y: 0, zoom: 1 }}
|
||||
nodesDraggable={false}
|
||||
nodesConnectable={false}
|
||||
elementsSelectable={false}
|
||||
panOnScroll={false}
|
||||
zoomOnScroll={false}
|
||||
zoomOnPinch={false}
|
||||
zoomOnDoubleClick={false}
|
||||
panOnDrag={false}
|
||||
selectionOnDrag={false}
|
||||
preventScrolling={true}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
className='pointer-events-none h-full w-full'
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
/>
|
||||
</ReactFlowProvider>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const Icons = {
|
||||
gitHub: () => (
|
||||
<svg width='48' height='48' viewBox='0 0 48 48' fill='none' xmlns='http://www.w3.org/2000/svg'>
|
||||
<g clipPath='url(#clip0_82_6269)'>
|
||||
<path
|
||||
d='M24.0492 0C10.7213 0 0 11 0 24.6C0 35.5 6.88525 44.7 16.4262 47.95C17.6066 48.2 18.0492 47.4 18.0492 46.75C18.0492 46.2 18 44.2 18 42.2C11.3115 43.65 9.93443 39.25 9.93443 39.25C8.85246 36.4 7.27869 35.65 7.27869 35.65C5.11475 34.15 7.42623 34.15 7.42623 34.15C9.83607 34.3 11.1148 36.7 11.1148 36.7C13.2787 40.45 16.7213 39.4 18.0984 38.75C18.2951 37.15 18.9344 36.05 19.623 35.45C14.3115 34.9 8.70492 32.75 8.70492 23.3C8.70492 20.6 9.63935 18.4 11.1639 16.7C10.918 16.1 10.082 13.55 11.4098 10.2C11.4098 10.2 13.4262 9.55 18 12.75C19.918 12.2 21.9836 11.95 24 11.95C26.0164 11.95 28.082 12.25 30 12.75C34.5738 9.55 36.5902 10.2 36.5902 10.2C37.918 13.6 37.082 16.1 36.8361 16.7C38.4098 18.4 39.2951 20.6 39.2951 23.3C39.2951 32.75 33.6885 34.85 28.3279 35.45C29.2131 36.2 29.9508 37.7 29.9508 40C29.9508 43.3 29.9016 45.95 29.9016 46.75C29.9016 47.4 30.3443 48.2 31.5246 47.95C41.1148 44.7 48 35.5 48 24.6C48.0492 11 37.2787 0 24.0492 0Z'
|
||||
fill='white'
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id='clip0_82_6269'>
|
||||
<rect width='48' height='48' fill='white' />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
),
|
||||
pinecone: () => (
|
||||
<svg width='48' height='48' viewBox='0 0 48 48' fill='none' xmlns='http://www.w3.org/2000/svg'>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
clipRule='evenodd'
|
||||
d='M30.3124 0.546056C29.7865 -0.0256784 28.9098 -0.162895 28.2085 0.203015L27.5573 0.523187L20.469 4.20515L22.1221 6.858L26.7307 4.45672L25.6036 10.0826L28.8848 10.6314L30.0369 4.98271L33.4433 8.68755L35.9981 6.72079L30.8384 1.09492H30.8134L30.3124 0.546056ZM20.3688 48C22.072 48 23.4496 46.7651 23.4496 45.2557C23.4496 43.7463 22.072 42.5113 20.3688 42.5113C18.6656 42.5113 17.2881 43.7463 17.2881 45.2557C17.263 46.7651 18.6656 48 20.3688 48ZM24.5016 32.9291L23.3995 38.5778L20.0933 38.029L21.1954 32.4031L16.5867 34.8272L14.9086 32.1744L21.9468 28.4924L22.598 28.1494C23.2993 27.7835 24.176 27.9207 24.7019 28.4924L25.2029 29.0413L30.4377 34.6443L27.8829 36.6339L24.5016 32.9291ZM27.1816 19.4362L26.0795 25.0849L22.7733 24.536L23.8754 18.933L19.2918 21.3343L17.6387 18.6815L24.6518 15.0224V14.9766H24.7019L25.3532 14.6336C26.0545 14.2677 26.9311 14.4049 27.4571 14.9766L27.958 15.5026L33.1678 21.1285L30.613 23.1181L27.1816 19.4362ZM5.56612 41.7567H5.54107L4.8648 41.5737C4.13844 41.3908 3.66255 40.7504 3.71265 40.0643L4.31377 32.426L7.49473 32.6318L7.11902 37.2743L12.0533 34.2098L13.8316 36.6111L8.99754 39.6069L13.9318 40.9105L13.0551 43.7006L5.56612 41.7567ZM38.3024 44.9126L38.077 45.5758C37.8516 46.2162 37.2003 46.6507 36.4489 46.605L35.7476 46.5592L35.6975 46.5821L35.6725 46.5592L27.9079 46.079L28.1083 43.1746L33.268 43.4947L29.8866 39.1724L32.4665 37.4801L35.9229 41.9167L37.4258 37.4343L40.4564 38.2805L38.3024 44.9126ZM47.4195 29.1785L47.7952 29.796C48.1709 30.4135 48.0206 31.191 47.4195 31.6484L46.8684 32.0829V32.1058H46.8434L40.8071 36.7711L38.7032 34.5071L42.6606 31.4426L36.7244 30.4821L37.3005 27.5548L43.2867 28.5153L40.782 24.3988L43.6123 22.958L47.4195 29.1785ZM41.283 16.4174L35.9229 19.0474L34.37 16.4403L39.6549 13.856L34.8209 12.0493L36.0482 9.30503L43.412 12.0265L43.437 12.0036L43.4621 12.0493L44.1383 12.3009C44.8647 12.5753 45.2654 13.2614 45.1402 13.9475L45.015 14.6336L43.6374 21.6087L40.4314 21.0828L41.283 16.4174ZM5.31565 22.5464L11.2768 23.4612L10.7258 26.3884L4.71452 25.4508L7.26931 29.5673L4.43901 31.0309L0.581787 24.8333L0.206084 24.2387C-0.16962 23.6213 -0.0193384 22.8437 0.55674 22.3863L1.10777 21.9518V21.9289H1.13282L7.09398 17.2407L9.22296 19.5048L5.31565 22.5464ZM14.3075 9.87676L18.2649 13.9018L15.8353 15.8914L11.7777 11.7749L10.851 16.4631L7.64501 15.9371L9.04764 8.98485L9.19792 8.2759C9.32315 7.58982 9.97437 7.0867 10.7258 7.06383L11.4271 7.04096L11.4521 7.01809L11.4772 7.04096L19.4421 6.74366L19.5673 9.71667L14.3075 9.87676Z'
|
||||
fill='white'
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
qdrant: () => (
|
||||
<svg width='48' height='48' fill='none' viewBox='0 0 49 56' xmlns='http://www.w3.org/2000/svg'>
|
||||
<g clipPath='url(#b)'>
|
||||
<path
|
||||
d='m38.489 51.477-1.1167-30.787-2.0223-8.1167 13.498 1.429v37.242l-8.2456 4.7589-2.1138-4.5259z'
|
||||
clipRule='evenodd'
|
||||
fill='#24386C'
|
||||
fillRule='evenodd'
|
||||
/>
|
||||
<path
|
||||
d='m48.847 14-8.2457 4.7622-17.016-3.7326-19.917 8.1094-3.3183-9.139 12.122-7 12.126-7 12.123 7 12.126 7z'
|
||||
clipRule='evenodd'
|
||||
fill='#7589BE'
|
||||
fillRule='evenodd'
|
||||
/>
|
||||
<path
|
||||
d='m0.34961 13.999 8.2457 4.7622 4.7798 14.215 16.139 12.913-4.9158 10.109-12.126-7.0004-12.123-7v-28z'
|
||||
clipRule='evenodd'
|
||||
fill='#B2BFE8'
|
||||
fillRule='evenodd'
|
||||
/>
|
||||
<path
|
||||
d='m30.066 38.421-5.4666 8.059v9.5207l7.757-4.4756 3.9968-5.9681'
|
||||
clipRule='evenodd'
|
||||
fill='#24386C'
|
||||
fillRule='evenodd'
|
||||
/>
|
||||
<path
|
||||
d='m24.602 36.962-7.7603-13.436 1.6715-4.4531 6.3544-3.0809 7.488 7.5343-7.7536 13.436z'
|
||||
clipRule='evenodd'
|
||||
fill='#7589BE'
|
||||
fillRule='evenodd'
|
||||
/>
|
||||
<path
|
||||
d='m16.843 23.525 7.7569 4.4756v8.9585l-7.1741 0.3087-4.3397-5.5412 3.7569-8.2016z'
|
||||
clipRule='evenodd'
|
||||
fill='#B2BFE8'
|
||||
fillRule='evenodd'
|
||||
/>
|
||||
<path
|
||||
d='m24.6 28 7.757-4.4752 5.2792 8.7903-6.3886 5.2784-6.6476-0.6346v-8.9589z'
|
||||
clipRule='evenodd'
|
||||
fill='#24386C'
|
||||
fillRule='evenodd'
|
||||
/>
|
||||
<path
|
||||
d='m32.355 51.524 8.2457 4.476v-37.238l-8.0032-4.6189-7.9995-4.6189-8.0031 4.6189-7.9995 4.6189v18.479l7.9995 4.6189 8.0031 4.6193 7.757-4.4797v9.5244zm0-19.045-7.757 4.4793-7.7569-4.4793v-8.9549l7.7569-4.4792 7.757 4.4792v8.9549z'
|
||||
clipRule='evenodd'
|
||||
fill='#DC244C'
|
||||
fillRule='evenodd'
|
||||
/>
|
||||
<path d='m24.603 46.483v-9.5222l-7.7166-4.4411v9.5064l7.7166 4.4569z' fill='url(#a)' />
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id='a'
|
||||
x1='23.18'
|
||||
x2='15.491'
|
||||
y1='38.781'
|
||||
y2='38.781'
|
||||
gradientUnits='userSpaceOnUse'
|
||||
>
|
||||
<stop stopColor='#FF3364' offset='0' />
|
||||
<stop stopColor='#C91540' stopOpacity='0' offset='1' />
|
||||
</linearGradient>
|
||||
<clipPath id='b'>
|
||||
<rect transform='translate(.34961)' width='48.3' height='56' fill='#fff' />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
),
|
||||
slack: () => (
|
||||
<svg width='48' height='48' viewBox='0 0 48 48' fill='none' xmlns='http://www.w3.org/2000/svg'>
|
||||
<g clipPath='url(#clip0_82_6239)'>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
clipRule='evenodd'
|
||||
d='M17.599 0C14.9456 0.00195719 12.7982 2.15095 12.8001 4.79902C12.7982 7.44709 14.9475 9.59609 17.6009 9.59804H22.4017V4.80098C22.4037 2.15291 20.2543 0.00391437 17.599 0C17.6009 0 17.6009 0 17.599 0ZM17.599 12.8H4.80079C2.14741 12.802 -0.00195575 14.9509 5.35946e-06 17.599C-0.00391685 20.2471 2.14545 22.3961 4.79883 22.4H17.599C20.2523 22.398 22.4017 20.2491 22.3997 17.601C22.4017 14.9509 20.2523 12.802 17.599 12.8Z'
|
||||
fill='#36C5F0'
|
||||
/>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
clipRule='evenodd'
|
||||
d='M47.9998 17.599C48.0018 14.9509 45.8524 12.802 43.1991 12.8C40.5457 12.802 38.3963 14.9509 38.3983 17.599V22.4H43.1991C45.8524 22.398 48.0018 20.2491 47.9998 17.599ZM35.1997 17.599V4.79902C35.2017 2.15291 33.0543 0.00391437 30.4009 0C27.7475 0.00195719 25.5981 2.15095 25.6001 4.79902V17.599C25.5962 20.2471 27.7456 22.3961 30.3989 22.4C33.0523 22.398 35.2017 20.2491 35.1997 17.599Z'
|
||||
fill='#2EB67D'
|
||||
/>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
clipRule='evenodd'
|
||||
d='M30.3989 48.0001C33.0523 47.9981 35.2017 45.8492 35.1997 43.2011C35.2017 40.553 33.0523 38.404 30.3989 38.4021H25.5981V43.2011C25.5962 45.8472 27.7456 47.9962 30.3989 48.0001ZM30.3989 35.1981H43.1991C45.8524 35.1962 48.0018 33.0472 47.9998 30.3991C48.0038 27.751 45.8544 25.6021 43.201 25.5981H30.4009C27.7475 25.6001 25.5981 27.7491 25.6001 30.3972C25.5981 33.0472 27.7456 35.1962 30.3989 35.1981Z'
|
||||
fill='#ECB22E'
|
||||
/>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
clipRule='evenodd'
|
||||
d='M1.34093e-06 30.3991C-0.00195976 33.0472 2.14741 35.1962 4.80079 35.1981C7.45416 35.1962 9.60353 33.0472 9.60157 30.3991V25.6001H4.80079C2.14741 25.6021 -0.00195976 27.751 1.34093e-06 30.3991ZM12.8001 30.3991V43.1991C12.7962 45.8472 14.9456 47.9962 17.599 48.0001C20.2523 47.9981 22.4017 45.8491 22.3997 43.2011V30.403C22.4037 27.755 20.2543 25.606 17.6009 25.6021C14.9456 25.6021 12.7982 27.751 12.8001 30.3991Z'
|
||||
fill='#E01E5A'
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id='clip0_82_6239'>
|
||||
<rect width='48' height='48' fill='white' />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
),
|
||||
perplexity: () => (
|
||||
<svg width='48' height='48' viewBox='0 0 48 48' fill='none' xmlns='http://www.w3.org/2000/svg'>
|
||||
<path
|
||||
d='M24 4.5V43.5M13.73 16.573V6.583L24 16.573M24 16.573L13.73 27.01V41.417L24 31.073M24 16.573L34.27 6.583V16.573'
|
||||
stroke='#20808D'
|
||||
strokeWidth='1.66667'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
/>
|
||||
<path
|
||||
d='M13.7299 31.396H9.43994V16.573H38.5599V31.396H34.2699'
|
||||
stroke='#20808D'
|
||||
strokeWidth='1.66667'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
/>
|
||||
<path
|
||||
d='M24 16.573L34.27 27.01V41.417L24 31.073'
|
||||
stroke='#20808D'
|
||||
strokeWidth='1.66667'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
supabase: () => (
|
||||
<svg width='48' height='48' viewBox='0 0 48 48' fill='none' xmlns='http://www.w3.org/2000/svg'>
|
||||
<g clipPath='url(#clip0_82_6260)'>
|
||||
<path
|
||||
d='M28.0545 46.8463C26.7953 48.376 24.2421 47.5379 24.2117 45.5847L23.7681 17.0178H43.6813C47.2882 17.0178 49.2997 21.0363 47.057 23.7611L28.0545 46.8463Z'
|
||||
fill='url(#paint0_linear_82_6260)'
|
||||
/>
|
||||
<path
|
||||
d='M28.0545 46.8463C26.7953 48.376 24.2421 47.5379 24.2117 45.5847L23.7681 17.0178H43.6813C47.2882 17.0178 49.2997 21.0363 47.057 23.7611L28.0545 46.8463Z'
|
||||
fill='url(#paint1_linear_82_6260)'
|
||||
fillOpacity='0.2'
|
||||
/>
|
||||
<path
|
||||
d='M19.956 0.879624C21.2152 -0.650174 23.7685 0.188045 23.7988 2.1412L23.9932 30.7081H4.32919C0.722252 30.7081 -1.2894 26.6896 0.953498 23.9648L19.956 0.879624Z'
|
||||
fill='#3ECF8E'
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id='paint0_linear_82_6260'
|
||||
x1='23.7681'
|
||||
y1='23.3518'
|
||||
x2='41.2706'
|
||||
y2='30.9617'
|
||||
gradientUnits='userSpaceOnUse'
|
||||
>
|
||||
<stop stopColor='#249361' />
|
||||
<stop offset='1' stopColor='#3ECF8E' />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id='paint1_linear_82_6260'
|
||||
x1='15.9216'
|
||||
y1='12.9889'
|
||||
x2='23.5483'
|
||||
y2='27.8727'
|
||||
gradientUnits='userSpaceOnUse'
|
||||
>
|
||||
<stop />
|
||||
<stop offset='1' stopOpacity='0' />
|
||||
</linearGradient>
|
||||
<clipPath id='clip0_82_6260'>
|
||||
<rect width='48' height='48' fill='white' />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
),
|
||||
notion: () => (
|
||||
<svg width='48' height='48' viewBox='0 0 48 48' fill='none' xmlns='http://www.w3.org/2000/svg'>
|
||||
<g clipPath='url(#clip0_82_6265)'>
|
||||
<path
|
||||
d='M3.01725 2.0665L30.7669 0.108884C34.1754 -0.170519 35.0513 0.0178987 37.1944 1.50608L46.0524 7.46703C47.5136 8.49205 48 8.77163 48 9.88781V42.5792C48 44.628 47.2209 45.8398 44.4945 46.0252L12.27 47.8886C10.2238 47.9812 9.24938 47.7018 8.17781 46.3972L1.65488 38.295C0.484875 36.8036 0 35.6873 0 34.3827V5.32405C0 3.64906 0.779062 2.25187 3.01725 2.0665Z'
|
||||
fill='white'
|
||||
/>
|
||||
<path
|
||||
d='M30.7669 0.108884L3.01725 2.0665C0.779062 2.25187 0 3.64906 0 5.32405V34.3829C0 35.6875 0.484688 36.8036 1.65488 38.295L8.17781 46.3972C9.24938 47.7018 10.2238 47.9812 12.27 47.8886L44.4945 46.0252C47.2192 45.8398 48 44.628 48 42.5792V9.88781C48 8.82912 47.562 8.52411 46.2731 7.62035L46.0509 7.46703L37.1944 1.50608C35.0513 0.0178987 34.1756 -0.170519 30.7669 0.108884ZM12.9988 9.35282C10.3676 9.52208 9.77081 9.56041 8.27644 8.39945L4.47675 5.51247C4.0905 5.13885 4.28437 4.67247 5.25731 4.57987L31.9337 2.71808C34.1738 2.53127 35.3406 3.27688 36.2166 3.92847L40.7917 7.09503C40.9873 7.18906 41.4739 7.74644 40.8887 7.74644L13.3399 9.33044L12.9988 9.35282ZM9.93131 42.2998V14.5472C9.93131 13.3352 10.3207 12.7764 11.4876 12.6822L43.1287 10.9128C44.202 10.8202 44.6869 11.4716 44.6869 12.6822V40.2494C44.6869 41.4614 44.4911 42.4864 42.7393 42.5792L12.4605 44.2558C10.7087 44.3484 9.93131 43.7912 9.93131 42.2998ZM39.8207 16.0352C40.0146 16.8736 39.8207 17.712 38.943 17.8078L37.4837 18.084V38.5743C36.2166 39.2257 35.0498 39.5978 34.0749 39.5978C32.5172 39.5978 32.1276 39.1315 30.9607 37.7359L21.4174 23.3934V37.2697L24.4361 37.9227C24.4361 37.9227 24.4361 39.5995 22.0007 39.5995L15.2856 39.9715C15.09 39.5978 15.2856 38.6669 15.9662 38.4817L17.7195 38.0169V19.6698L15.2858 19.4814C15.09 18.6432 15.5764 17.4326 16.9406 17.3384L24.1455 16.8754L34.0751 31.4031V18.5506L31.5442 18.2726C31.3487 17.2458 32.1276 16.5002 33.1005 16.4092L39.8205 16.0354L39.8207 16.0352Z'
|
||||
fill='black'
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id='clip0_82_6265'>
|
||||
<rect width='48' height='48' fill='white' />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
),
|
||||
reddit: () => (
|
||||
<svg width='48' height='48' viewBox='0 0 48 48' fill='none' xmlns='http://www.w3.org/2000/svg'>
|
||||
<g clipPath='url(#clip0_82_6246)'>
|
||||
<path
|
||||
d='M24 0C10.7444 0 0 10.7444 0 24C0 30.6267 2.68667 36.6267 7.02889 40.9711L2.45778 45.5422C1.55111 46.4489 2.19333 48 3.47556 48H24C37.2556 48 48 37.2556 48 24C48 10.7444 37.2556 0 24 0Z'
|
||||
fill='#FF4500'
|
||||
/>
|
||||
<path
|
||||
d='M37.6044 29.3778C40.6997 29.3778 43.2089 26.8686 43.2089 23.7734C43.2089 20.6781 40.6997 18.1689 37.6044 18.1689C34.5092 18.1689 32 20.6781 32 23.7734C32 26.8686 34.5092 29.3778 37.6044 29.3778Z'
|
||||
fill='url(#paint0_radial_82_6246)'
|
||||
/>
|
||||
<path
|
||||
d='M10.3955 29.3778C13.4907 29.3778 15.9999 26.8686 15.9999 23.7734C15.9999 20.6781 13.4907 18.1689 10.3955 18.1689C7.30021 18.1689 4.79102 20.6781 4.79102 23.7734C4.79102 26.8686 7.30021 29.3778 10.3955 29.3778Z'
|
||||
fill='url(#paint1_radial_82_6246)'
|
||||
/>
|
||||
<path
|
||||
d='M24.0132 40.5867C32.8497 40.5867 40.0132 35.2141 40.0132 28.5867C40.0132 21.9593 32.8497 16.5867 24.0132 16.5867C15.1766 16.5867 8.01318 21.9593 8.01318 28.5867C8.01318 35.2141 15.1766 40.5867 24.0132 40.5867Z'
|
||||
fill='url(#paint2_radial_82_6246)'
|
||||
/>
|
||||
<path
|
||||
d='M19.2843 27.44C19.191 29.4578 17.8421 30.1911 16.271 30.1911C14.6999 30.1911 13.5021 29.0955 13.5954 27.0778C13.6888 25.06 15.0377 23.74 16.6088 23.74C18.1799 23.74 19.3777 25.4244 19.2843 27.4422V27.44Z'
|
||||
fill='url(#paint3_radial_82_6246)'
|
||||
/>
|
||||
<path
|
||||
d='M28.7444 27.44C28.8377 29.4578 30.1866 30.1911 31.7577 30.1911C33.3288 30.1911 34.5266 29.0955 34.4332 27.0778C34.3399 25.06 32.991 23.74 31.4199 23.74C29.8488 23.74 28.651 25.4244 28.7444 27.4422V27.44Z'
|
||||
fill='url(#paint4_radial_82_6246)'
|
||||
/>
|
||||
<path
|
||||
d='M17.6955 26.5377C18.0391 26.5377 18.3177 26.2342 18.3177 25.8599C18.3177 25.4856 18.0391 25.1821 17.6955 25.1821C17.3518 25.1821 17.0732 25.4856 17.0732 25.8599C17.0732 26.2342 17.3518 26.5377 17.6955 26.5377Z'
|
||||
fill='#FFC49C'
|
||||
/>
|
||||
<path
|
||||
d='M32.4909 26.5377C32.8345 26.5377 33.1131 26.2342 33.1131 25.8599C33.1131 25.4856 32.8345 25.1821 32.4909 25.1821C32.1472 25.1821 31.8687 25.4856 31.8687 25.8599C31.8687 26.2342 32.1472 26.5377 32.4909 26.5377Z'
|
||||
fill='#FFC49C'
|
||||
/>
|
||||
<path
|
||||
d='M24.0133 31.76C22.0666 31.76 20.2 31.8556 18.4755 32.0311C18.18 32.06 17.9933 32.3667 18.1088 32.64C19.0755 34.9489 21.3555 36.5711 24.0133 36.5711C26.6711 36.5711 28.9533 34.9489 29.9177 32.64C30.0333 32.3667 29.8444 32.06 29.5511 32.0311C27.8244 31.8556 25.96 31.76 24.0133 31.76Z'
|
||||
fill='url(#paint5_radial_82_6246)'
|
||||
/>
|
||||
<path
|
||||
d='M32.7758 14.9557C34.969 14.9557 36.7469 13.1777 36.7469 10.9845C36.7469 8.79135 34.969 7.01343 32.7758 7.01343C30.5826 7.01343 28.8047 8.79135 28.8047 10.9845C28.8047 13.1777 30.5826 14.9557 32.7758 14.9557Z'
|
||||
fill='url(#paint6_radial_82_6246)'
|
||||
/>
|
||||
<path
|
||||
d='M23.9557 17.0933C23.4801 17.0933 23.0957 16.8955 23.0957 16.5888C23.0957 13.0311 25.9913 10.1355 29.549 10.1355C30.0246 10.1355 30.409 10.5199 30.409 10.9955C30.409 11.4711 30.0246 11.8555 29.549 11.8555C26.9401 11.8555 24.8179 13.9777 24.8179 16.5866C24.8179 16.8933 24.4335 17.0911 23.9579 17.0911L23.9557 17.0933Z'
|
||||
fill='url(#paint7_radial_82_6246)'
|
||||
/>
|
||||
<path
|
||||
d='M13.9599 27.2555C14.0465 25.3533 15.311 24.1089 16.7799 24.1089C18.171 24.1089 19.2465 25.5289 19.2865 27.2933C19.3243 25.32 18.1465 23.74 16.6088 23.74C15.071 23.74 13.6888 25.0844 13.5954 27.1178C13.5021 29.1511 14.6999 30.1911 16.271 30.1911H16.3865C14.9554 30.1555 13.8754 29.1267 13.9621 27.2578L13.9599 27.2555ZM34.0665 27.2555C33.9799 25.3533 32.7154 24.1089 31.2465 24.1089C29.8554 24.1089 28.7799 25.5289 28.7399 27.2933C28.7021 25.32 29.8799 23.74 31.4177 23.74C32.9888 23.74 34.3377 25.0844 34.431 27.1178C34.5243 29.1511 33.3265 30.1911 31.7554 30.1911H31.6399C33.071 30.1555 34.151 29.1267 34.0643 27.2578L34.0665 27.2555Z'
|
||||
fill='#842123'
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<radialGradient
|
||||
id='paint0_radial_82_6246'
|
||||
cx='0'
|
||||
cy='0'
|
||||
r='1'
|
||||
gradientUnits='userSpaceOnUse'
|
||||
gradientTransform='translate(37.7222 20.4101) scale(11.3289 9.85613)'
|
||||
>
|
||||
<stop stopColor='#FEFFFF' />
|
||||
<stop offset='0.4' stopColor='#FEFFFF' />
|
||||
<stop offset='0.51' stopColor='#F9FCFC' />
|
||||
<stop offset='0.62' stopColor='#EDF3F5' />
|
||||
<stop offset='0.7' stopColor='#DEE9EC' />
|
||||
<stop offset='0.72' stopColor='#D8E4E8' />
|
||||
<stop offset='0.76' stopColor='#CCD8DF' />
|
||||
<stop offset='0.8' stopColor='#C8D5DD' />
|
||||
<stop offset='0.83' stopColor='#CCD6DE' />
|
||||
<stop offset='0.85' stopColor='#D8DBE2' />
|
||||
<stop offset='0.88' stopColor='#EDE3E9' />
|
||||
<stop offset='0.9' stopColor='#FFEBEF' />
|
||||
</radialGradient>
|
||||
<radialGradient
|
||||
id='paint1_radial_82_6246'
|
||||
cx='0'
|
||||
cy='0'
|
||||
r='1'
|
||||
gradientUnits='userSpaceOnUse'
|
||||
gradientTransform='translate(10.5132 2.68339) scale(11.3289 9.85613)'
|
||||
>
|
||||
<stop stopColor='#FEFFFF' />
|
||||
<stop offset='0.4' stopColor='#FEFFFF' />
|
||||
<stop offset='0.51' stopColor='#F9FCFC' />
|
||||
<stop offset='0.62' stopColor='#EDF3F5' />
|
||||
<stop offset='0.7' stopColor='#DEE9EC' />
|
||||
<stop offset='0.72' stopColor='#D8E4E8' />
|
||||
<stop offset='0.76' stopColor='#CCD8DF' />
|
||||
<stop offset='0.8' stopColor='#C8D5DD' />
|
||||
<stop offset='0.83' stopColor='#CCD6DE' />
|
||||
<stop offset='0.85' stopColor='#D8DBE2' />
|
||||
<stop offset='0.88' stopColor='#EDE3E9' />
|
||||
<stop offset='0.9' stopColor='#FFEBEF' />
|
||||
</radialGradient>
|
||||
<radialGradient
|
||||
id='paint2_radial_82_6246'
|
||||
cx='0'
|
||||
cy='0'
|
||||
r='1'
|
||||
gradientUnits='userSpaceOnUse'
|
||||
gradientTransform='translate(24.3576 18.994) scale(34.1733 23.9213)'
|
||||
>
|
||||
<stop stopColor='#FEFFFF' />
|
||||
<stop offset='0.4' stopColor='#FEFFFF' />
|
||||
<stop offset='0.51' stopColor='#F9FCFC' />
|
||||
<stop offset='0.62' stopColor='#EDF3F5' />
|
||||
<stop offset='0.7' stopColor='#DEE9EC' />
|
||||
<stop offset='0.72' stopColor='#D8E4E8' />
|
||||
<stop offset='0.76' stopColor='#CCD8DF' />
|
||||
<stop offset='0.8' stopColor='#C8D5DD' />
|
||||
<stop offset='0.83' stopColor='#CCD6DE' />
|
||||
<stop offset='0.85' stopColor='#D8DBE2' />
|
||||
<stop offset='0.88' stopColor='#EDE3E9' />
|
||||
<stop offset='0.9' stopColor='#FFEBEF' />
|
||||
</radialGradient>
|
||||
<radialGradient
|
||||
id='paint3_radial_82_6246'
|
||||
cx='0'
|
||||
cy='0'
|
||||
r='1'
|
||||
gradientUnits='userSpaceOnUse'
|
||||
gradientTransform='translate(16.5886 28.3364) scale(3.05544 4.42611)'
|
||||
>
|
||||
<stop stopColor='#FF6600' />
|
||||
<stop offset='0.5' stopColor='#FF4500' />
|
||||
<stop offset='0.7' stopColor='#FC4301' />
|
||||
<stop offset='0.82' stopColor='#F43F07' />
|
||||
<stop offset='0.92' stopColor='#E53812' />
|
||||
<stop offset='1' stopColor='#D4301F' />
|
||||
</radialGradient>
|
||||
<radialGradient
|
||||
id='paint4_radial_82_6246'
|
||||
cx='0'
|
||||
cy='0'
|
||||
r='1'
|
||||
gradientUnits='userSpaceOnUse'
|
||||
gradientTransform='translate(31.4596 28.3364) rotate(180) scale(3.05544 4.42611)'
|
||||
>
|
||||
<stop stopColor='#FF6600' />
|
||||
<stop offset='0.5' stopColor='#FF4500' />
|
||||
<stop offset='0.7' stopColor='#FC4301' />
|
||||
<stop offset='0.82' stopColor='#F43F07' />
|
||||
<stop offset='0.92' stopColor='#E53812' />
|
||||
<stop offset='1' stopColor='#D4301F' />
|
||||
</radialGradient>
|
||||
<radialGradient
|
||||
id='paint5_radial_82_6246'
|
||||
cx='0'
|
||||
cy='0'
|
||||
r='1'
|
||||
gradientUnits='userSpaceOnUse'
|
||||
gradientTransform='translate(23.9844 37.243) scale(10.0667 6.644)'
|
||||
>
|
||||
<stop stopColor='#172E35' />
|
||||
<stop offset='0.29' stopColor='#0E1C21' />
|
||||
<stop offset='0.73' stopColor='#030708' />
|
||||
<stop offset='1' />
|
||||
</radialGradient>
|
||||
<radialGradient
|
||||
id='paint6_radial_82_6246'
|
||||
cx='0'
|
||||
cy='0'
|
||||
r='1'
|
||||
gradientUnits='userSpaceOnUse'
|
||||
gradientTransform='translate(32.8625 7.29369) scale(8.83778 8.66102)'
|
||||
>
|
||||
<stop stopColor='#FEFFFF' />
|
||||
<stop offset='0.4' stopColor='#FEFFFF' />
|
||||
<stop offset='0.51' stopColor='#F9FCFC' />
|
||||
<stop offset='0.62' stopColor='#EDF3F5' />
|
||||
<stop offset='0.7' stopColor='#DEE9EC' />
|
||||
<stop offset='0.72' stopColor='#D8E4E8' />
|
||||
<stop offset='0.76' stopColor='#CCD8DF' />
|
||||
<stop offset='0.8' stopColor='#C8D5DD' />
|
||||
<stop offset='0.83' stopColor='#CCD6DE' />
|
||||
<stop offset='0.85' stopColor='#D8DBE2' />
|
||||
<stop offset='0.88' stopColor='#EDE3E9' />
|
||||
<stop offset='0.9' stopColor='#FFEBEF' />
|
||||
</radialGradient>
|
||||
<radialGradient
|
||||
id='paint7_radial_82_6246'
|
||||
cx='0'
|
||||
cy='0'
|
||||
r='1'
|
||||
gradientUnits='userSpaceOnUse'
|
||||
gradientTransform='translate(29.1801 16.2399) scale(7.24444)'
|
||||
>
|
||||
<stop offset='0.48' stopColor='#7A9299' />
|
||||
<stop offset='0.67' stopColor='#172E35' />
|
||||
<stop offset='0.75' />
|
||||
<stop offset='0.82' stopColor='#172E35' />
|
||||
</radialGradient>
|
||||
<clipPath id='clip0_82_6246'>
|
||||
<rect width='48' height='48' fill='white' />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
),
|
||||
youtube: () => (
|
||||
<svg width='48' height='34' viewBox='0 0 48 34' fill='none' xmlns='http://www.w3.org/2000/svg'>
|
||||
<g clipPath='url(#clip0_82_6277)'>
|
||||
<path
|
||||
d='M46.9399 5.38906C46.6646 4.3716 46.1275 3.44402 45.3822 2.69868C44.6369 1.95333 43.7094 1.41624 42.6919 1.14087C38.967 0.125 23.9757 0.125 23.9757 0.125C23.9757 0.125 8.98354 0.15575 5.25866 1.17162C4.24119 1.44701 3.31362 1.98413 2.56831 2.72951C1.82299 3.47488 1.28595 4.40251 1.01066 5.42C-0.116026 12.0384 -0.553089 22.1232 1.0416 28.4769C1.31692 29.4943 1.85397 30.4219 2.59928 31.1673C3.34459 31.9126 4.27215 32.4497 5.2896 32.7251C9.01447 33.7409 24.0062 33.7409 24.0062 33.7409C24.0062 33.7409 38.9978 33.7409 42.7225 32.7251C43.74 32.4497 44.6676 31.9126 45.4129 31.1673C46.1582 30.422 46.6953 29.4944 46.9707 28.4769C48.159 21.8491 48.5252 11.7704 46.9399 5.38906Z'
|
||||
fill='#FF0000'
|
||||
/>
|
||||
<path d='M19.2041 24.1362L31.6406 16.9329L19.2041 9.72949V24.1362Z' fill='white' />
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id='clip0_82_6277'>
|
||||
<rect width='48' height='33.75' fill='white' transform='translate(0 0.125)' />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
),
|
||||
}
|
||||
|
||||
export default Integrations
|
||||
@@ -1,194 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'framer-motion'
|
||||
import { getAssetUrl } from '@/lib/utils'
|
||||
import useIsMobile from '@/app/(landing)/components/hooks/use-is-mobile'
|
||||
import { Marquee } from '@/app/(landing)/components/magicui/marquee'
|
||||
|
||||
const X_TESTIMONIALS = [
|
||||
{
|
||||
text: "Drag-and-drop AI workflows for devs who'd rather build agents than babysit them.",
|
||||
username: '@GithubProjects',
|
||||
viewCount: '90.4k',
|
||||
tweetUrl: 'https://x.com/GithubProjects/status/1906383555707490499',
|
||||
profileImage: getAssetUrl('twitter/github-projects.jpg'),
|
||||
},
|
||||
{
|
||||
text: 'A very good looking agent workflow builder 🔥 and open source!',
|
||||
username: '@xyflowdev',
|
||||
viewCount: '3,246',
|
||||
tweetUrl: 'https://x.com/xyflowdev/status/1909501499719438670',
|
||||
profileImage: getAssetUrl('twitter/xyflow.jpg'),
|
||||
},
|
||||
{
|
||||
text: "🚨 BREAKING: This startup just dropped the fastest way to build AI agents.\n\nThis Figma-like canvas to build agents will blow your mind.\n\nHere's why this is the best tool for building AI agents:",
|
||||
username: '@hasantoxr',
|
||||
viewCount: '515k',
|
||||
tweetUrl: 'https://x.com/hasantoxr/status/1912909502036525271',
|
||||
profileImage: getAssetUrl('twitter/hasan.jpg'),
|
||||
},
|
||||
{
|
||||
text: 'omfggggg this is the zapier of agent building\n\ni always believed that building agents and using ai should not be limited to technical people. i think this solves just that\n\nthe fact that this is also open source makes me so optimistic about the future of building with ai :)))\n\ncongrats @karabegemir & @typingwala !!!',
|
||||
username: '@nizzyabi',
|
||||
viewCount: '6,269',
|
||||
tweetUrl: 'https://x.com/nizzyabi/status/1907864421227180368',
|
||||
profileImage: getAssetUrl('twitter/nizzy.jpg'),
|
||||
},
|
||||
{
|
||||
text: "One of the best products I've seen in the space, and the hustle and grind I've seen from @karabegemir and @typingwala is insane. Sim is positioned to build something game-changing, and there's no better team for the job.\n\nCongrats on the launch 🚀 🎊 great things ahead!",
|
||||
username: '@firestorm776',
|
||||
viewCount: '956',
|
||||
tweetUrl: 'https://x.com/firestorm776/status/1907896097735061598',
|
||||
profileImage: getAssetUrl('twitter/samarth.jpg'),
|
||||
},
|
||||
{
|
||||
text: 'lfgg got access to @simstudioai via @zerodotemail 😎',
|
||||
username: '@nizzyabi',
|
||||
viewCount: '1,585',
|
||||
tweetUrl: 'https://x.com/nizzyabi/status/1910482357821595944',
|
||||
profileImage: getAssetUrl('twitter/nizzy.jpg'),
|
||||
},
|
||||
{
|
||||
text: 'Feels like we\'re finally getting a "Photoshop moment" for AI devs—visual, intuitive, and fast enough to keep up with ideas mid-flow.',
|
||||
username: '@syamrajk',
|
||||
viewCount: '2,643',
|
||||
tweetUrl: 'https://x.com/syamrajk/status/1912911980110946491',
|
||||
profileImage: getAssetUrl('twitter/syamrajk.jpg'),
|
||||
},
|
||||
{
|
||||
text: "🚨 BREAKING: This startup just dropped the fastest way to build AI agents.\n\nThis Figma-like canvas to build agents will blow your mind.\n\nHere's why this is the best tool for building AI agents:",
|
||||
username: '@lazukars',
|
||||
viewCount: '47.4k',
|
||||
tweetUrl: 'https://x.com/lazukars/status/1913136390503600575',
|
||||
profileImage: getAssetUrl('twitter/lazukars.png'),
|
||||
},
|
||||
{
|
||||
text: 'The use cases are endless. Great work @simstudioai',
|
||||
username: '@daniel_zkim',
|
||||
viewCount: '103',
|
||||
tweetUrl: 'https://x.com/daniel_zkim/status/1907891273664782708',
|
||||
profileImage: getAssetUrl('twitter/daniel.jpg'),
|
||||
},
|
||||
]
|
||||
|
||||
// Split the testimonials into two rows
|
||||
const firstRowTestimonials = X_TESTIMONIALS.slice(0, Math.ceil(X_TESTIMONIALS.length / 2))
|
||||
const secondRowTestimonials = X_TESTIMONIALS.slice(Math.ceil(X_TESTIMONIALS.length / 2))
|
||||
|
||||
function Testimonials() {
|
||||
const { isMobile, mounted } = useIsMobile()
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<section className='relative flex w-full flex-col overflow-hidden py-10 sm:py-12 md:py-16' />
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className='animation-container relative flex w-full flex-col overflow-hidden py-10 will-change-[opacity,transform] sm:py-12 md:py-16'>
|
||||
<div className='flex flex-col items-center gap-3 px-4 pb-6 sm:gap-5 sm:pb-8 md:pb-10'>
|
||||
{isMobile ? (
|
||||
<p className='text-center font-medium text-[42px] text-white tracking-normal md:text-5xl'>
|
||||
Loved by
|
||||
</p>
|
||||
) : (
|
||||
<motion.p
|
||||
className='text-center font-medium text-5xl text-white tracking-normal'
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
transition={{ duration: 0.7, delay: 0.05, ease: 'easeOut' }}
|
||||
>
|
||||
Loved by
|
||||
</motion.p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='mt-0 flex flex-col space-y-2 sm:space-y-3'>
|
||||
{/* First Row of X Posts */}
|
||||
<div className='animation-container flex w-full animate-fade-up flex-col text-white opacity-0 will-change-[opacity,transform] [animation-delay:400ms]'>
|
||||
<Marquee className='flex w-full [--duration:40s]' pauseOnHover={true}>
|
||||
{firstRowTestimonials.map((card, index) => (
|
||||
<motion.div
|
||||
key={`first-row-${index}`}
|
||||
className='mx-0.5 flex min-w-[280px] max-w-[340px] cursor-pointer flex-col gap-2 rounded-lg border border-[#333] bg-[#121212] p-2 sm:min-w-[320px] sm:max-w-[380px] sm:p-3'
|
||||
whileHover={{ scale: 1.02, boxShadow: '0 8px 32px 0 rgba(80, 60, 120, 0.18)' }}
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
|
||||
onClick={() =>
|
||||
card.tweetUrl && window.open(card.tweetUrl, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<p className='font-medium text-sm text-white sm:text-base'>{card.text}</p>
|
||||
</div>
|
||||
<div className='mt-auto flex items-center justify-between'>
|
||||
<div className='flex items-center gap-1.5 sm:gap-2'>
|
||||
{card.profileImage && (
|
||||
<img
|
||||
src={card.profileImage}
|
||||
alt={`${card.username} profile`}
|
||||
className='h-6 w-6 rounded-full border border-[#333] object-cover sm:h-8 sm:w-8'
|
||||
/>
|
||||
)}
|
||||
<div className='flex items-center'>
|
||||
<span className='font-medium text-white/80 text-xs sm:text-sm'>@</span>
|
||||
<p className='font-medium text-white/80 text-xs sm:text-sm'>
|
||||
{card.username.replace('@', '')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex items-center'>
|
||||
<p className='text-[10px] text-white/60 sm:text-xs'>{card.viewCount} views</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</Marquee>
|
||||
</div>
|
||||
|
||||
{/* Second Row of X Posts */}
|
||||
<div className='animation-container flex w-full animate-fade-up flex-col text-white opacity-0 will-change-[opacity,transform] [animation-delay:600ms]'>
|
||||
<Marquee className='flex w-full [--duration:40s]' pauseOnHover={true}>
|
||||
{secondRowTestimonials.map((card, index) => (
|
||||
<motion.div
|
||||
key={`second-row-${index}`}
|
||||
className='mx-0.5 flex min-w-[280px] max-w-[340px] cursor-pointer flex-col gap-2 rounded-lg border border-[#333] bg-[#121212] p-2 sm:min-w-[320px] sm:max-w-[380px] sm:p-3'
|
||||
whileHover={{ scale: 1.02, boxShadow: '0 8px 32px 0 rgba(80, 60, 120, 0.18)' }}
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
|
||||
onClick={() =>
|
||||
card.tweetUrl && window.open(card.tweetUrl, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<p className='font-medium text-sm text-white sm:text-base'>{card.text}</p>
|
||||
</div>
|
||||
<div className='mt-auto flex items-center justify-between'>
|
||||
<div className='flex items-center gap-1.5 sm:gap-2'>
|
||||
{card.profileImage && (
|
||||
<img
|
||||
src={card.profileImage}
|
||||
alt={`${card.username} profile`}
|
||||
className='h-6 w-6 rounded-full border border-[#333] object-cover sm:h-8 sm:w-8'
|
||||
/>
|
||||
)}
|
||||
<div className='flex items-center'>
|
||||
<span className='font-medium text-white/80 text-xs sm:text-sm'>@</span>
|
||||
<p className='font-medium text-white/80 text-xs sm:text-sm'>
|
||||
{card.username.replace('@', '')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex items-center'>
|
||||
<p className='text-[10px] text-white/60 sm:text-xs'>{card.viewCount} views</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</Marquee>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default Testimonials
|
||||
238
apps/sim/app/(landing)/components/structured-data.tsx
Normal file
238
apps/sim/app/(landing)/components/structured-data.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
export default function StructuredData() {
|
||||
const structuredData = {
|
||||
'@context': 'https://schema.org',
|
||||
'@graph': [
|
||||
{
|
||||
'@type': 'Organization',
|
||||
'@id': 'https://sim.ai/#organization',
|
||||
name: 'Sim',
|
||||
alternateName: 'Sim Studio',
|
||||
description:
|
||||
'Open-source AI agent workflow builder used by developers at trail-blazing startups to Fortune 500 companies',
|
||||
url: 'https://sim.ai',
|
||||
logo: {
|
||||
'@type': 'ImageObject',
|
||||
'@id': 'https://sim.ai/#logo',
|
||||
url: 'https://sim.ai/logo/b&w/text/b&w.svg',
|
||||
contentUrl: 'https://sim.ai/logo/b&w/text/b&w.svg',
|
||||
width: 49.78314,
|
||||
height: 24.276,
|
||||
caption: 'Sim Logo',
|
||||
},
|
||||
image: { '@id': 'https://sim.ai/#logo' },
|
||||
sameAs: [
|
||||
'https://x.com/simdotai',
|
||||
'https://github.com/simstudioai/sim',
|
||||
'https://www.linkedin.com/company/simstudioai/',
|
||||
'https://discord.gg/Hr4UWYEcTT',
|
||||
],
|
||||
contactPoint: {
|
||||
'@type': 'ContactPoint',
|
||||
contactType: 'customer support',
|
||||
availableLanguage: ['en'],
|
||||
},
|
||||
},
|
||||
{
|
||||
'@type': 'WebSite',
|
||||
'@id': 'https://sim.ai/#website',
|
||||
url: 'https://sim.ai',
|
||||
name: 'Sim - AI Agent Workflow Builder',
|
||||
description:
|
||||
'Open-source AI agent workflow builder. 30,000+ developers build and deploy agentic workflows. SOC2 and HIPAA compliant.',
|
||||
publisher: {
|
||||
'@id': 'https://sim.ai/#organization',
|
||||
},
|
||||
potentialAction: [
|
||||
{
|
||||
'@type': 'SearchAction',
|
||||
'@id': 'https://sim.ai/#searchaction',
|
||||
target: {
|
||||
'@type': 'EntryPoint',
|
||||
urlTemplate: 'https://sim.ai/search?q={search_term_string}',
|
||||
},
|
||||
'query-input': 'required name=search_term_string',
|
||||
},
|
||||
],
|
||||
inLanguage: 'en-US',
|
||||
},
|
||||
{
|
||||
'@type': 'WebPage',
|
||||
'@id': 'https://sim.ai/#webpage',
|
||||
url: 'https://sim.ai',
|
||||
name: 'Sim - Workflows for LLMs | Build AI Agent Workflows',
|
||||
isPartOf: {
|
||||
'@id': 'https://sim.ai/#website',
|
||||
},
|
||||
about: {
|
||||
'@id': 'https://sim.ai/#software',
|
||||
},
|
||||
datePublished: '2024-01-01T00:00:00+00:00',
|
||||
dateModified: new Date().toISOString(),
|
||||
description:
|
||||
'Build and deploy AI agent workflows with Sim. Visual drag-and-drop interface for creating powerful LLM-powered automations.',
|
||||
breadcrumb: {
|
||||
'@id': 'https://sim.ai/#breadcrumb',
|
||||
},
|
||||
inLanguage: 'en-US',
|
||||
potentialAction: [
|
||||
{
|
||||
'@type': 'ReadAction',
|
||||
target: ['https://sim.ai'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
'@type': 'BreadcrumbList',
|
||||
'@id': 'https://sim.ai/#breadcrumb',
|
||||
itemListElement: [
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 1,
|
||||
name: 'Home',
|
||||
item: 'https://sim.ai',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
'@type': 'SoftwareApplication',
|
||||
'@id': 'https://sim.ai/#software',
|
||||
name: 'Sim - AI Agent Workflow Builder',
|
||||
description:
|
||||
'Open-source AI agent workflow builder used by 30,000+ developers. Build agentic workflows with visual drag-and-drop interface. SOC2 and HIPAA compliant. Integrate with 100+ apps.',
|
||||
applicationCategory: 'DeveloperApplication',
|
||||
applicationSubCategory: 'AI Development Tools',
|
||||
operatingSystem: 'Web, Windows, macOS, Linux',
|
||||
softwareVersion: '1.0',
|
||||
offers: [
|
||||
{
|
||||
'@type': 'Offer',
|
||||
'@id': 'https://sim.ai/#offer-free',
|
||||
name: 'Community Plan',
|
||||
price: '0',
|
||||
priceCurrency: 'USD',
|
||||
priceValidUntil: '2025-12-31',
|
||||
itemCondition: 'https://schema.org/NewCondition',
|
||||
availability: 'https://schema.org/InStock',
|
||||
seller: {
|
||||
'@id': 'https://sim.ai/#organization',
|
||||
},
|
||||
eligibleRegion: {
|
||||
'@type': 'Place',
|
||||
name: 'Worldwide',
|
||||
},
|
||||
},
|
||||
{
|
||||
'@type': 'Offer',
|
||||
'@id': 'https://sim.ai/#offer-pro',
|
||||
name: 'Pro Plan',
|
||||
price: '20',
|
||||
priceCurrency: 'USD',
|
||||
priceSpecification: {
|
||||
'@type': 'UnitPriceSpecification',
|
||||
price: '20',
|
||||
priceCurrency: 'USD',
|
||||
unitText: 'MONTH',
|
||||
billingIncrement: 1,
|
||||
},
|
||||
priceValidUntil: '2025-12-31',
|
||||
itemCondition: 'https://schema.org/NewCondition',
|
||||
availability: 'https://schema.org/InStock',
|
||||
seller: {
|
||||
'@id': 'https://sim.ai/#organization',
|
||||
},
|
||||
},
|
||||
{
|
||||
'@type': 'Offer',
|
||||
'@id': 'https://sim.ai/#offer-team',
|
||||
name: 'Team Plan',
|
||||
price: '40',
|
||||
priceCurrency: 'USD',
|
||||
priceSpecification: {
|
||||
'@type': 'UnitPriceSpecification',
|
||||
price: '40',
|
||||
priceCurrency: 'USD',
|
||||
unitText: 'MONTH',
|
||||
billingIncrement: 1,
|
||||
},
|
||||
priceValidUntil: '2025-12-31',
|
||||
itemCondition: 'https://schema.org/NewCondition',
|
||||
availability: 'https://schema.org/InStock',
|
||||
seller: {
|
||||
'@id': 'https://sim.ai/#organization',
|
||||
},
|
||||
},
|
||||
],
|
||||
aggregateRating: {
|
||||
'@type': 'AggregateRating',
|
||||
ratingValue: '4.8',
|
||||
reviewCount: '150',
|
||||
bestRating: '5',
|
||||
worstRating: '1',
|
||||
},
|
||||
featureList: [
|
||||
'Visual workflow builder',
|
||||
'Drag-and-drop interface',
|
||||
'100+ integrations',
|
||||
'AI model support (OpenAI, Anthropic, Google)',
|
||||
'Real-time collaboration',
|
||||
'Version control',
|
||||
'API access',
|
||||
'Custom functions',
|
||||
'Scheduled workflows',
|
||||
'Event triggers',
|
||||
],
|
||||
screenshot: [
|
||||
{
|
||||
'@type': 'ImageObject',
|
||||
url: 'https://sim.ai/screenshots/workflow-builder.png',
|
||||
caption: 'Sim workflow builder interface',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
'@type': 'FAQPage',
|
||||
'@id': 'https://sim.ai/#faq',
|
||||
mainEntity: [
|
||||
{
|
||||
'@type': 'Question',
|
||||
name: 'What is Sim?',
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: 'Sim is an open-source AI agent workflow builder used by 30,000+ developers at trail-blazing startups to Fortune 500 companies. It provides a visual drag-and-drop interface for building and deploying agentic workflows. Sim is SOC2 and HIPAA compliant.',
|
||||
},
|
||||
},
|
||||
{
|
||||
'@type': 'Question',
|
||||
name: 'Which AI models does Sim support?',
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: 'Sim supports all major AI models including OpenAI (GPT-4, GPT-3.5), Anthropic (Claude), Google (Gemini), Mistral, Perplexity, and many more. You can also connect to open-source models via Ollama.',
|
||||
},
|
||||
},
|
||||
{
|
||||
'@type': 'Question',
|
||||
name: 'Do I need coding skills to use Sim?',
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: 'No coding skills are required! Sim features a visual drag-and-drop interface that makes it easy to build AI workflows. However, developers can also use custom functions and our API for advanced use cases.',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<script
|
||||
type='application/ld+json'
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
|
||||
/>
|
||||
{/* LLM-friendly semantic HTML comments */}
|
||||
{/* About: Sim is a visual workflow builder for AI agents and large language models (LLMs) */}
|
||||
{/* Purpose: Enable users to create AI-powered automations without coding */}
|
||||
{/* Features: Drag-and-drop interface, 100+ integrations, multi-model support */}
|
||||
{/* Use cases: Email automation, chatbots, data analysis, content generation */}
|
||||
</>
|
||||
)
|
||||
}
|
||||
221
apps/sim/app/(landing)/components/testimonials/testimonials.tsx
Normal file
221
apps/sim/app/(landing)/components/testimonials/testimonials.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import Image from 'next/image'
|
||||
import { getAssetUrl } from '@/lib/utils'
|
||||
import { inter } from '@/app/fonts/inter'
|
||||
|
||||
interface Testimonial {
|
||||
text: string
|
||||
name: string
|
||||
username: string
|
||||
viewCount: string
|
||||
tweetUrl: string
|
||||
profileImage: string
|
||||
}
|
||||
|
||||
// Import all testimonials
|
||||
const allTestimonials: Testimonial[] = [
|
||||
{
|
||||
text: "🚨 BREAKING: This startup just dropped the fastest way to build AI agents.\n\nThis Figma-like canvas to build agents will blow your mind.\n\nHere's why this is the best tool for building AI agents:",
|
||||
name: 'Hasan Toor',
|
||||
username: '@hasantoxr',
|
||||
viewCount: '515k',
|
||||
tweetUrl: 'https://x.com/hasantoxr/status/1912909502036525271',
|
||||
profileImage: getAssetUrl('twitter/hasan.jpg'),
|
||||
},
|
||||
{
|
||||
text: "Drag-and-drop AI workflows for devs who'd rather build agents than babysit them.",
|
||||
name: 'GitHub Projects',
|
||||
username: '@GithubProjects',
|
||||
viewCount: '90.4k',
|
||||
tweetUrl: 'https://x.com/GithubProjects/status/1906383555707490499',
|
||||
profileImage: getAssetUrl('twitter/github-projects.jpg'),
|
||||
},
|
||||
{
|
||||
text: "🚨 BREAKING: This startup just dropped the fastest way to build AI agents.\n\nThis Figma-like canvas to build agents will blow your mind.\n\nHere's why this is the best tool for building AI agents:",
|
||||
name: 'Ryan Lazuka',
|
||||
username: '@lazukars',
|
||||
viewCount: '47.4k',
|
||||
tweetUrl: 'https://x.com/lazukars/status/1913136390503600575',
|
||||
profileImage: getAssetUrl('twitter/lazukars.png'),
|
||||
},
|
||||
{
|
||||
text: 'omfggggg this is the zapier of agent building\n\ni always believed that building agents and using ai should not be limited to technical people. i think this solves just that\n\nthe fact that this is also open source makes me so optimistic about the future of building with ai :)))\n\ncongrats @karabegemir & @typingwala !!!',
|
||||
name: 'nizzy',
|
||||
username: '@nizzyabi',
|
||||
viewCount: '6,269',
|
||||
tweetUrl: 'https://x.com/nizzyabi/status/1907864421227180368',
|
||||
profileImage: getAssetUrl('twitter/nizzy.jpg'),
|
||||
},
|
||||
{
|
||||
text: 'A very good looking agent workflow builder 🔥 and open source!',
|
||||
name: 'xyflow',
|
||||
username: '@xyflowdev',
|
||||
viewCount: '3,246',
|
||||
tweetUrl: 'https://x.com/xyflowdev/status/1909501499719438670',
|
||||
profileImage: getAssetUrl('twitter/xyflow.jpg'),
|
||||
},
|
||||
{
|
||||
text: "One of the best products I've seen in the space, and the hustle and grind I've seen from @karabegemir and @typingwala is insane. Sim is positioned to build something game-changing, and there's no better team for the job.\n\nCongrats on the launch 🚀 🎊 great things ahead!",
|
||||
name: 'samarth',
|
||||
username: '@firestorm776',
|
||||
viewCount: '1,256',
|
||||
tweetUrl: 'https://x.com/firestorm776/status/1907896097735061598',
|
||||
profileImage: getAssetUrl('twitter/samarth.jpg'),
|
||||
},
|
||||
{
|
||||
text: 'lfgg got access to @simstudioai via @zerodotemail 😎',
|
||||
name: 'nizzy',
|
||||
username: '@nizzyabi',
|
||||
viewCount: '1,762',
|
||||
tweetUrl: 'https://x.com/nizzyabi/status/1910482357821595944',
|
||||
profileImage: getAssetUrl('twitter/nizzy.jpg'),
|
||||
},
|
||||
{
|
||||
text: 'Feels like we\'re finally getting a "Photoshop moment" for AI devs—visual, intuitive, and fast enough to keep up with ideas mid-flow.',
|
||||
name: 'Syamraj K',
|
||||
username: '@syamrajk',
|
||||
viewCount: '2,784',
|
||||
tweetUrl: 'https://x.com/syamrajk/status/1912911980110946491',
|
||||
profileImage: getAssetUrl('twitter/syamrajk.jpg'),
|
||||
},
|
||||
{
|
||||
text: 'The use cases are endless. Great work @simstudioai',
|
||||
name: 'Daniel Kim',
|
||||
username: '@daniel_zkim',
|
||||
viewCount: '103',
|
||||
tweetUrl: 'https://x.com/daniel_zkim/status/1907891273664782708',
|
||||
profileImage: getAssetUrl('twitter/daniel.jpg'),
|
||||
},
|
||||
]
|
||||
|
||||
export default function Testimonials() {
|
||||
const [currentIndex, setCurrentIndex] = useState(0)
|
||||
const [isTransitioning, setIsTransitioning] = useState(false)
|
||||
const [isPaused, setIsPaused] = useState(false)
|
||||
|
||||
// Create an extended array for smooth infinite scrolling
|
||||
const extendedTestimonials = [...allTestimonials, ...allTestimonials]
|
||||
|
||||
useEffect(() => {
|
||||
// Set up automatic sliding every 3 seconds
|
||||
const interval = setInterval(() => {
|
||||
if (!isPaused) {
|
||||
setIsTransitioning(true)
|
||||
setCurrentIndex((prevIndex) => prevIndex + 1)
|
||||
}
|
||||
}, 3000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [isPaused])
|
||||
|
||||
// Reset position when reaching the end for infinite loop
|
||||
useEffect(() => {
|
||||
if (currentIndex >= allTestimonials.length) {
|
||||
setTimeout(() => {
|
||||
setIsTransitioning(false)
|
||||
setCurrentIndex(0)
|
||||
}, 500) // Match transition duration
|
||||
}
|
||||
}, [currentIndex])
|
||||
|
||||
// Calculate the transform value
|
||||
const getTransformValue = () => {
|
||||
// Each card unit (card + separator) takes exactly 25% width
|
||||
return `translateX(-${currentIndex * 25}%)`
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
id='testimonials'
|
||||
className={`flex hidden h-[150px] items-center sm:block ${inter.variable}`}
|
||||
aria-label='Social proof testimonials'
|
||||
>
|
||||
<div className='relative mx-auto h-full w-full max-w-[1289px] pl-[2px]'>
|
||||
<div
|
||||
className='relative h-full w-full overflow-hidden'
|
||||
onMouseEnter={() => setIsPaused(true)}
|
||||
onMouseLeave={() => setIsPaused(false)}
|
||||
>
|
||||
<div
|
||||
className={`flex h-full ${isTransitioning ? 'transition-transform duration-500 ease-in-out' : ''}`}
|
||||
style={{
|
||||
transform: getTransformValue(),
|
||||
}}
|
||||
>
|
||||
{extendedTestimonials.map((tweet, absoluteIndex) => {
|
||||
// Always show separator except for the very last card in the extended array
|
||||
const showSeparator = absoluteIndex < extendedTestimonials.length - 1
|
||||
|
||||
return (
|
||||
/* Card unit wrapper - exactly 25% width including separator */
|
||||
<div key={`${absoluteIndex}`} className='flex h-full w-1/4 flex-shrink-0'>
|
||||
{/* Tweet container */}
|
||||
<div
|
||||
className='group flex h-full w-full cursor-pointer flex-col px-[12px] py-[12px] transition-all duration-100 hover:bg-[#0A0A0A] sm:px-[14px]'
|
||||
onClick={() => window.open(tweet.tweetUrl, '_blank', 'noopener,noreferrer')}
|
||||
>
|
||||
{/* Top section with profile info */}
|
||||
<div className='flex items-start justify-between'>
|
||||
<div className='flex items-start gap-2'>
|
||||
{/* Profile image */}
|
||||
<Image
|
||||
src={tweet.profileImage}
|
||||
alt={`${tweet.username} profile`}
|
||||
width={34}
|
||||
height={34}
|
||||
className='h-[34px] w-[34px] rounded-full object-cover'
|
||||
quality={75}
|
||||
loading='lazy'
|
||||
/>
|
||||
{/* Name and username stacked */}
|
||||
<div className='flex flex-col'>
|
||||
<span className='font-[500] text-gray-900 text-sm transition-colors duration-300 group-hover:text-white'>
|
||||
{tweet.name}
|
||||
</span>
|
||||
<span className='text-gray-500 text-xs transition-colors duration-300 group-hover:text-white/80'>
|
||||
{tweet.username}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* View count in top right */}
|
||||
<span className='text-gray-400 text-xs transition-colors duration-300 group-hover:text-white/70'>
|
||||
{tweet.viewCount} views
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Tweet content below with padding */}
|
||||
<p
|
||||
className={`${inter.className} mt-2 line-clamp-4 font-[380] text-[#0A0A0A] text-[13px] leading-[1.3] transition-colors duration-300 group-hover:text-white`}
|
||||
>
|
||||
{tweet.text}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Full height vertical separator line */}
|
||||
{showSeparator && (
|
||||
<div className='relative h-full flex-shrink-0'>
|
||||
<svg
|
||||
width='2'
|
||||
height='100%'
|
||||
viewBox='0 0 2 200'
|
||||
preserveAspectRatio='none'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
className='h-full'
|
||||
>
|
||||
{/* Vertical line */}
|
||||
<path d='M1 0V200' stroke='#E7E4EF' strokeWidth='2' />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
interface WindowSize {
|
||||
width: number | undefined
|
||||
height: number | undefined
|
||||
}
|
||||
|
||||
export function useWindowSize(): WindowSize {
|
||||
const [windowSize, setWindowSize] = useState<WindowSize>({
|
||||
width: undefined,
|
||||
height: undefined,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
// Handler to call on window resize
|
||||
function handleResize() {
|
||||
setWindowSize({
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
})
|
||||
}
|
||||
|
||||
// Add event listener
|
||||
window.addEventListener('resize', handleResize)
|
||||
|
||||
// Call handler right away so state gets updated with initial window size
|
||||
handleResize()
|
||||
|
||||
// Remove event listener on cleanup
|
||||
return () => window.removeEventListener('resize', handleResize)
|
||||
}, [])
|
||||
|
||||
return windowSize
|
||||
}
|
||||
@@ -1,604 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import {
|
||||
ChartAreaIcon,
|
||||
GitFork,
|
||||
GitGraph,
|
||||
Github,
|
||||
GitPullRequest,
|
||||
LayoutGrid,
|
||||
MessageCircle,
|
||||
Star,
|
||||
} from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
Bar,
|
||||
BarChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { GridPattern } from '@/app/(landing)/components/grid-pattern'
|
||||
import NavWrapper from '@/app/(landing)/components/nav-wrapper'
|
||||
import Footer from '@/app/(landing)/components/sections/footer'
|
||||
import { getCachedContributorsData, prefetchContributorsData } from '@/app/(landing)/utils/prefetch'
|
||||
|
||||
interface Contributor {
|
||||
login: string
|
||||
avatar_url: string
|
||||
contributions: number
|
||||
html_url: string
|
||||
}
|
||||
|
||||
interface RepoStats {
|
||||
stars: number
|
||||
forks: number
|
||||
watchers: number
|
||||
openIssues: number
|
||||
openPRs: number
|
||||
}
|
||||
|
||||
interface CommitTimelineData {
|
||||
date: string
|
||||
commits: number
|
||||
additions: number
|
||||
deletions: number
|
||||
}
|
||||
|
||||
interface ActivityData {
|
||||
date: string
|
||||
commits: number
|
||||
issues: number
|
||||
pullRequests: number
|
||||
}
|
||||
|
||||
const excludedUsernames = ['dependabot[bot]', 'github-actions']
|
||||
|
||||
const ChartControls = ({
|
||||
showAll,
|
||||
setShowAll,
|
||||
total,
|
||||
}: {
|
||||
showAll: boolean
|
||||
setShowAll: (show: boolean) => void
|
||||
total: number
|
||||
}) => (
|
||||
<div className='mb-4 flex items-center justify-between'>
|
||||
<span className='text-neutral-400 text-sm'>
|
||||
Showing {showAll ? 'all' : 'top 10'} contributors
|
||||
</span>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => setShowAll(!showAll)}
|
||||
className='border-[#606060]/30 bg-[#0f0f0f] text-neutral-300 text-xs backdrop-blur-sm hover:bg-neutral-700/50 hover:text-white'
|
||||
>
|
||||
Show {showAll ? 'less' : 'all'} ({total})
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
|
||||
export default function ContributorsPage() {
|
||||
const [repoStats, setRepoStats] = useState<RepoStats>({
|
||||
stars: 0,
|
||||
forks: 0,
|
||||
watchers: 0,
|
||||
openIssues: 0,
|
||||
openPRs: 0,
|
||||
})
|
||||
const [timelineData, setTimelineData] = useState<CommitTimelineData[]>([])
|
||||
const [activityData, setActivityData] = useState<ActivityData[]>([])
|
||||
const [showAllContributors, setShowAllContributors] = useState(false)
|
||||
const [allContributors, setAllContributors] = useState<Contributor[]>([])
|
||||
|
||||
const handleOpenTypeformLink = () => {
|
||||
window.open('https://form.typeform.com/to/jqCO12pF', '_blank')
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
// First, try to get cached data
|
||||
const cachedData = getCachedContributorsData()
|
||||
|
||||
if (cachedData) {
|
||||
// Use cached data immediately
|
||||
setAllContributors(cachedData.contributors)
|
||||
setRepoStats(cachedData.repoStats)
|
||||
setTimelineData(cachedData.timelineData)
|
||||
setActivityData(cachedData.activityData)
|
||||
} else {
|
||||
// If no cached data, fetch it
|
||||
try {
|
||||
const data = await prefetchContributorsData()
|
||||
setAllContributors(data.contributors)
|
||||
setRepoStats(data.repoStats)
|
||||
setTimelineData(data.timelineData)
|
||||
setActivityData(data.activityData)
|
||||
} catch (err) {
|
||||
console.error('Error fetching data:', err)
|
||||
// Set default values if fetch fails
|
||||
setAllContributors([])
|
||||
setRepoStats({
|
||||
stars: 3867,
|
||||
forks: 581,
|
||||
watchers: 26,
|
||||
openIssues: 23,
|
||||
openPRs: 3,
|
||||
})
|
||||
setTimelineData([])
|
||||
setActivityData([])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadData()
|
||||
}, [])
|
||||
|
||||
const filteredContributors = useMemo(
|
||||
() =>
|
||||
allContributors
|
||||
?.filter((contributor) => !excludedUsernames.includes(contributor.login))
|
||||
.sort((a, b) => b.contributions - a.contributions),
|
||||
[allContributors]
|
||||
)
|
||||
|
||||
return (
|
||||
<main className='relative min-h-screen bg-[var(--brand-background-hex)] font-geist-sans text-white'>
|
||||
{/* Grid pattern background */}
|
||||
<div className='absolute inset-0 bottom-[400px] z-0'>
|
||||
<GridPattern
|
||||
x={-5}
|
||||
y={-5}
|
||||
className='absolute inset-0 stroke-[#ababab]/5'
|
||||
width={90}
|
||||
height={90}
|
||||
aria-hidden='true'
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Header/Navigation */}
|
||||
<NavWrapper onOpenTypeformLink={handleOpenTypeformLink} />
|
||||
|
||||
{/* Content */}
|
||||
<div className='relative z-10'>
|
||||
{/* Hero Section with Integrated Stats */}
|
||||
<section className='px-4 pt-20 pb-12 sm:px-8 sm:pt-28 sm:pb-16 md:px-16 md:pt-40 md:pb-24 lg:px-28 xl:px-32'>
|
||||
<div className='mx-auto max-w-6xl'>
|
||||
{/* Main Hero Content */}
|
||||
<div className='mb-12 text-center sm:mb-16'>
|
||||
<motion.h1
|
||||
className='font-medium text-4xl text-white tracking-tight sm:text-5xl'
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.7, ease: 'easeOut' }}
|
||||
>
|
||||
Contributors
|
||||
</motion.h1>
|
||||
<motion.p
|
||||
className='mx-auto mt-3 max-w-2xl font-light text-lg text-neutral-400 sm:mt-4 sm:text-xl'
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.2 }}
|
||||
>
|
||||
Meet the amazing people who have helped build and improve Sim
|
||||
</motion.p>
|
||||
</div>
|
||||
|
||||
{/* Integrated Project Stats */}
|
||||
<motion.div
|
||||
className='overflow-hidden rounded-2xl border border-[#606060]/30 bg-[#0f0f0f] p-4 backdrop-blur-sm sm:rounded-3xl sm:p-8'
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
transition={{ duration: 0.7, delay: 0.1 }}
|
||||
>
|
||||
{/* Project Header */}
|
||||
<div className='mb-6 flex flex-col items-start justify-between gap-3 sm:mb-8 sm:flex-row sm:items-center sm:gap-4'>
|
||||
<div className='space-y-1'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='relative h-6 w-6 sm:h-8 sm:w-8'>
|
||||
<Image
|
||||
src='/favicon.ico'
|
||||
alt='Sim Logo'
|
||||
className='object-contain'
|
||||
width={32}
|
||||
height={32}
|
||||
/>
|
||||
</div>
|
||||
<h2 className='font-semibold text-lg text-white sm:text-xl'>Sim</h2>
|
||||
</div>
|
||||
<p className='text-neutral-400 text-xs sm:text-sm'>
|
||||
An open source platform for building, testing, and optimizing agentic workflows
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex gap-2 self-end sm:self-auto'>
|
||||
<Button
|
||||
asChild
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='gap-1 border-[#606060]/30 bg-[#0f0f0f] text-neutral-300 text-xs backdrop-blur-sm hover:bg-neutral-700/50 hover:text-white sm:gap-2 sm:text-sm'
|
||||
>
|
||||
<a href='https://github.com/simstudioai/sim' target='_blank' rel='noopener'>
|
||||
<Github className='h-3 w-3 sm:h-4 sm:w-4' />
|
||||
<span className='hidden sm:inline'>View on GitHub</span>
|
||||
<span className='sm:hidden'>GitHub</span>
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid - Mobile: 1 column, Tablet: 2 columns, Desktop: 5 columns */}
|
||||
<div className='mb-6 grid grid-cols-1 gap-3 sm:mb-8 sm:grid-cols-2 sm:gap-4 lg:grid-cols-5'>
|
||||
<div className='rounded-lg border border-[#606060]/20 bg-neutral-800/30 p-3 text-center sm:rounded-xl sm:p-4'>
|
||||
<div className='mb-1 flex items-center justify-center sm:mb-2'>
|
||||
<Star className='h-4 w-4 text-[var(--brand-primary-hex)] sm:h-5 sm:w-5' />
|
||||
</div>
|
||||
<div className='font-bold text-lg text-white sm:text-xl'>{repoStats.stars}</div>
|
||||
<div className='text-neutral-400 text-xs'>Stars</div>
|
||||
</div>
|
||||
|
||||
<div className='rounded-lg border border-[#606060]/20 bg-neutral-800/30 p-3 text-center sm:rounded-xl sm:p-4'>
|
||||
<div className='mb-1 flex items-center justify-center sm:mb-2'>
|
||||
<GitFork className='h-4 w-4 text-[var(--brand-primary-hex)] sm:h-5 sm:w-5' />
|
||||
</div>
|
||||
<div className='font-bold text-lg text-white sm:text-xl'>{repoStats.forks}</div>
|
||||
<div className='text-neutral-400 text-xs'>Forks</div>
|
||||
</div>
|
||||
|
||||
<div className='rounded-lg border border-[#606060]/20 bg-neutral-800/30 p-3 text-center sm:rounded-xl sm:p-4'>
|
||||
<div className='mb-1 flex items-center justify-center sm:mb-2'>
|
||||
<GitGraph className='h-4 w-4 text-[var(--brand-primary-hex)] sm:h-5 sm:w-5' />
|
||||
</div>
|
||||
<div className='font-bold text-lg text-white sm:text-xl'>
|
||||
{filteredContributors?.length || 0}
|
||||
</div>
|
||||
<div className='text-neutral-400 text-xs'>Contributors</div>
|
||||
</div>
|
||||
|
||||
<div className='rounded-lg border border-[#606060]/20 bg-neutral-800/30 p-3 text-center sm:rounded-xl sm:p-4'>
|
||||
<div className='mb-1 flex items-center justify-center sm:mb-2'>
|
||||
<MessageCircle className='h-4 w-4 text-[var(--brand-primary-hex)] sm:h-5 sm:w-5' />
|
||||
</div>
|
||||
<div className='font-bold text-lg text-white sm:text-xl'>
|
||||
{repoStats.openIssues}
|
||||
</div>
|
||||
<div className='text-neutral-400 text-xs'>Open Issues</div>
|
||||
</div>
|
||||
|
||||
<div className='rounded-lg border border-[#606060]/20 bg-neutral-800/30 p-3 text-center sm:rounded-xl sm:p-4'>
|
||||
<div className='mb-1 flex items-center justify-center sm:mb-2'>
|
||||
<GitPullRequest className='h-4 w-4 text-[var(--brand-primary-hex)] sm:h-5 sm:w-5' />
|
||||
</div>
|
||||
<div className='font-bold text-lg text-white sm:text-xl'>{repoStats.openPRs}</div>
|
||||
<div className='text-neutral-400 text-xs'>Pull Requests</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Activity Chart - Mobile responsive */}
|
||||
<div className='rounded-xl border border-[#606060]/30 bg-[#0f0f0f] p-4 sm:rounded-2xl sm:p-6'>
|
||||
<h3 className='mb-3 font-medium text-base text-white sm:mb-4 sm:text-lg'>
|
||||
Commit Activity
|
||||
</h3>
|
||||
<ResponsiveContainer width='100%' height={150} className='sm:!h-[200px]'>
|
||||
<AreaChart data={timelineData} className='-mx-2 sm:-mx-5 mt-1 sm:mt-2'>
|
||||
<defs>
|
||||
<linearGradient id='commits' x1='0' y1='0' x2='0' y2='1'>
|
||||
<stop offset='5%' stopColor='var(--brand-primary-hex)' stopOpacity={0.3} />
|
||||
<stop offset='95%' stopColor='var(--brand-primary-hex)' stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<XAxis
|
||||
dataKey='date'
|
||||
stroke='currentColor'
|
||||
fontSize={10}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
className='text-neutral-400 sm:text-xs'
|
||||
interval={4}
|
||||
/>
|
||||
<YAxis
|
||||
stroke='currentColor'
|
||||
fontSize={10}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => `${value}`}
|
||||
className='text-neutral-400 sm:text-xs'
|
||||
width={30}
|
||||
/>
|
||||
<Tooltip
|
||||
content={({ active, payload }) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<div className='rounded-lg border border-[#606060]/30 bg-[#0f0f0f] p-2 shadow-lg backdrop-blur-sm sm:p-3'>
|
||||
<div className='grid gap-1 sm:gap-2'>
|
||||
<div className='flex items-center gap-1 sm:gap-2'>
|
||||
<GitGraph className='h-3 w-3 text-[var(--brand-primary-hex)] sm:h-4 sm:w-4' />
|
||||
<span className='text-neutral-400 text-xs sm:text-sm'>
|
||||
Commits:
|
||||
</span>
|
||||
<span className='font-medium text-white text-xs sm:text-sm'>
|
||||
{payload[0]?.value}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}}
|
||||
/>
|
||||
<Area
|
||||
type='monotone'
|
||||
dataKey='commits'
|
||||
stroke='var(--brand-primary-hex)'
|
||||
strokeWidth={2}
|
||||
fill='url(#commits)'
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Contributors Display */}
|
||||
<section className='px-4 py-12 sm:px-8 sm:py-16 md:px-16 lg:px-28 xl:px-32'>
|
||||
<div className='mx-auto max-w-6xl'>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
transition={{ duration: 0.7, delay: 0.2 }}
|
||||
>
|
||||
<Tabs defaultValue='grid' className='w-full'>
|
||||
<div className='mb-6 flex justify-center sm:mb-8'>
|
||||
<TabsList className='grid h-full w-full max-w-[300px] grid-cols-2 border border-[#606060]/30 bg-[#0f0f0f] p-1 backdrop-blur-sm sm:w-[200px]'>
|
||||
<TabsTrigger
|
||||
value='grid'
|
||||
className='flex items-center gap-1 text-neutral-400 text-xs data-[state=active]:bg-neutral-700/50 data-[state=active]:text-white data-[state=active]:shadow-sm sm:gap-2 sm:text-sm'
|
||||
>
|
||||
<LayoutGrid className='h-3 w-3 sm:h-4 sm:w-4' />
|
||||
Grid
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value='chart'
|
||||
className='flex items-center gap-1 text-neutral-400 text-xs data-[state=active]:bg-neutral-700/50 data-[state=active]:text-white data-[state=active]:shadow-sm sm:gap-2 sm:text-sm'
|
||||
>
|
||||
<ChartAreaIcon className='h-3 w-3 sm:h-4 sm:w-4' />
|
||||
Chart
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<TabsContent value='grid'>
|
||||
{/* Mobile: 2 columns, Small: 3 columns, Large: 4 columns, XL: 6 columns */}
|
||||
<div className='grid grid-cols-2 gap-3 sm:grid-cols-3 sm:gap-4 lg:grid-cols-4 xl:grid-cols-6'>
|
||||
{filteredContributors?.map((contributor, index) => (
|
||||
<motion.a
|
||||
key={contributor.login}
|
||||
href={contributor.html_url}
|
||||
target='_blank'
|
||||
className='group relative flex flex-col items-center rounded-lg border border-[#606060]/30 bg-[#0f0f0f] p-3 backdrop-blur-sm transition-all hover:bg-neutral-700/50 sm:rounded-xl sm:p-4'
|
||||
whileHover={{ scale: 1.02, y: -2 }}
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
style={{ animationDelay: `${index * 50}ms` }}
|
||||
>
|
||||
<Avatar className='h-12 w-12 ring-2 ring-[#606060]/30 transition-transform group-hover:scale-105 group-hover:ring-[var(--brand-primary-hex)]/60 sm:h-16 sm:w-16'>
|
||||
<AvatarImage
|
||||
src={contributor.avatar_url}
|
||||
alt={contributor.login}
|
||||
className='object-cover'
|
||||
/>
|
||||
<AvatarFallback className='bg-[#0f0f0f] text-[10px] sm:text-xs'>
|
||||
{contributor.login.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className='mt-2 text-center sm:mt-3'>
|
||||
<span className='block font-medium text-white text-xs transition-colors group-hover:text-[var(--brand-primary-hex)] sm:text-sm'>
|
||||
{contributor.login.length > 12
|
||||
? `${contributor.login.slice(0, 12)}...`
|
||||
: contributor.login}
|
||||
</span>
|
||||
<div className='mt-1 flex items-center justify-center gap-1 sm:mt-2'>
|
||||
<GitGraph className='h-2 w-2 text-neutral-400 transition-colors group-hover:text-[var(--brand-primary-hex)] sm:h-3 sm:w-3' />
|
||||
<span className='font-medium text-neutral-300 text-xs transition-colors group-hover:text-white sm:text-sm'>
|
||||
{contributor.contributions}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.a>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='chart'>
|
||||
<div className='rounded-2xl border border-[#606060]/30 bg-[#0f0f0f] p-4 backdrop-blur-sm sm:rounded-3xl sm:p-6'>
|
||||
<ChartControls
|
||||
showAll={showAllContributors}
|
||||
setShowAll={setShowAllContributors}
|
||||
total={filteredContributors?.length || 0}
|
||||
/>
|
||||
|
||||
<ResponsiveContainer width='100%' height={300} className='sm:!h-[400px]'>
|
||||
<BarChart
|
||||
data={filteredContributors?.slice(0, showAllContributors ? undefined : 10)}
|
||||
margin={{ top: 10, right: 10, bottom: 60, left: 10 }}
|
||||
className='sm:!mx-2.5 sm:!mb-2.5'
|
||||
>
|
||||
<XAxis
|
||||
dataKey='login'
|
||||
interval={0}
|
||||
tick={(props) => {
|
||||
const { x, y, payload } = props
|
||||
const contributor = allContributors?.find(
|
||||
(c) => c.login === payload.value
|
||||
)
|
||||
|
||||
return (
|
||||
<g transform={`translate(${x},${y})`}>
|
||||
<foreignObject
|
||||
x='-16'
|
||||
y='8'
|
||||
width='32'
|
||||
height='32'
|
||||
style={{ overflow: 'visible' }}
|
||||
>
|
||||
<Avatar className='h-6 w-6 ring-1 ring-[#606060]/30 sm:h-8 sm:w-8'>
|
||||
<AvatarImage src={contributor?.avatar_url} />
|
||||
<AvatarFallback className='bg-[#0f0f0f] text-[6px] sm:text-[8px]'>
|
||||
{payload.value.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</foreignObject>
|
||||
</g>
|
||||
)
|
||||
}}
|
||||
height={60}
|
||||
className='text-neutral-400'
|
||||
/>
|
||||
<YAxis
|
||||
stroke='currentColor'
|
||||
fontSize={10}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => `${value}`}
|
||||
className='text-neutral-400 sm:text-xs'
|
||||
width={25}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: 'rgb(255 255 255 / 0.05)' }}
|
||||
content={({ active, payload }) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0]?.payload
|
||||
return (
|
||||
<div className='rounded-lg border border-[#606060]/30 bg-[#0f0f0f] p-2 shadow-lg backdrop-blur-sm sm:p-3'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Avatar className='h-6 w-6 ring-1 ring-[#606060]/30 sm:h-8 sm:w-8'>
|
||||
<AvatarImage src={data.avatar_url} />
|
||||
<AvatarFallback className='bg-[#0f0f0f] text-[8px] sm:text-xs'>
|
||||
{data.login.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<div className='font-medium text-white text-xs sm:text-sm'>
|
||||
{data.login}
|
||||
</div>
|
||||
<div className='flex items-center gap-1 text-[10px] text-neutral-400 sm:text-xs'>
|
||||
<GitGraph className='h-2 w-2 sm:h-3 sm:w-3' />
|
||||
<span>{data.contributions} commits</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}}
|
||||
/>
|
||||
<Bar
|
||||
dataKey='contributions'
|
||||
className='fill-[var(--brand-primary-hex)]'
|
||||
radius={[4, 4, 0, 0]}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Call to Action */}
|
||||
<section className='px-4 py-8 sm:px-8 sm:py-10 md:px-16 md:py-16 lg:px-28 xl:px-32'>
|
||||
<div className='mx-auto max-w-6xl'>
|
||||
<motion.div
|
||||
className='relative overflow-hidden rounded-2xl border border-[#606060]/30 bg-[#0f0f0f] sm:rounded-3xl'
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
transition={{ duration: 0.7, delay: 0.3 }}
|
||||
>
|
||||
<div className='relative p-6 sm:p-8 md:p-12 lg:p-16'>
|
||||
<div className='text-center'>
|
||||
<div className='mb-4 inline-flex items-center rounded-full border border-[var(--brand-primary-hex)]/20 bg-[var(--brand-primary-hex)]/10 px-3 py-1 font-medium text-[var(--brand-primary-hex)] text-xs sm:mb-6 sm:px-4 sm:py-2 sm:text-sm'>
|
||||
<Github className='mr-1 h-3 w-3 sm:mr-2 sm:h-4 sm:w-4' />
|
||||
Apache-2.0 Licensed
|
||||
</div>
|
||||
|
||||
<h3 className='font-medium text-2xl text-white leading-[1.1] tracking-tight sm:text-[42px] md:text-5xl'>
|
||||
Want to contribute?
|
||||
</h3>
|
||||
|
||||
<p className='mx-auto mt-3 max-w-2xl font-light text-base text-neutral-400 sm:mt-4 sm:text-xl'>
|
||||
Whether you're fixing bugs, adding features, or improving documentation,
|
||||
every contribution helps build the future of AI workflows.
|
||||
</p>
|
||||
|
||||
<div className='mt-6 flex flex-col gap-3 sm:mt-8 sm:flex-row sm:flex-wrap sm:justify-center sm:gap-4'>
|
||||
<Button
|
||||
asChild
|
||||
size='lg'
|
||||
className='bg-[var(--brand-primary-hex)] text-white transition-colors duration-500 hover:bg-[var(--brand-primary-hover-hex)]'
|
||||
>
|
||||
<a
|
||||
href='https://github.com/simstudioai/sim/blob/main/.github/CONTRIBUTING.md'
|
||||
target='_blank'
|
||||
rel='noopener'
|
||||
>
|
||||
<GitGraph className='mr-2 h-4 w-4 sm:h-5 sm:w-5' />
|
||||
Start Contributing
|
||||
</a>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
asChild
|
||||
variant='outline'
|
||||
size='lg'
|
||||
className='border-[#606060]/30 bg-transparent text-neutral-300 transition-colors duration-500 hover:bg-neutral-700/50 hover:text-white'
|
||||
>
|
||||
<a href='https://github.com/simstudioai/sim' target='_blank' rel='noopener'>
|
||||
<Github className='mr-2 h-4 w-4 sm:h-5 sm:w-5' />
|
||||
View Repository
|
||||
</a>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
asChild
|
||||
variant='outline'
|
||||
size='lg'
|
||||
className='border-[#606060]/30 bg-transparent text-neutral-300 transition-colors duration-500 hover:bg-neutral-700/50 hover:text-white'
|
||||
>
|
||||
<a
|
||||
href='https://github.com/simstudioai/sim/issues'
|
||||
target='_blank'
|
||||
rel='noopener'
|
||||
>
|
||||
<MessageCircle className='mr-2 h-4 w-4 sm:h-5 sm:w-5' />
|
||||
Open Issues
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<Footer />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -1,28 +1,79 @@
|
||||
'use client'
|
||||
import { Suspense } from 'react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { Background, Footer, Nav, StructuredData } from '@/app/(landing)/components'
|
||||
|
||||
import NavWrapper from '@/app/(landing)/components/nav-wrapper'
|
||||
import Footer from '@/app/(landing)/components/sections/footer'
|
||||
import Hero from '@/app/(landing)/components/sections/hero'
|
||||
import Integrations from '@/app/(landing)/components/sections/integrations'
|
||||
import Testimonials from '@/app/(landing)/components/sections/testimonials'
|
||||
// Lazy load heavy components for better initial load performance
|
||||
const Hero = dynamic(() => import('@/app/(landing)/components/hero/hero'), {
|
||||
loading: () => <div className='h-[600px] animate-pulse bg-gray-50' />,
|
||||
})
|
||||
|
||||
const LandingPricing = dynamic(
|
||||
() => import('@/app/(landing)/components/landing-pricing/landing-pricing'),
|
||||
{
|
||||
loading: () => <div className='h-[400px] animate-pulse bg-gray-50' />,
|
||||
}
|
||||
)
|
||||
|
||||
const Integrations = dynamic(() => import('@/app/(landing)/components/integrations/integrations'), {
|
||||
loading: () => <div className='h-[300px] animate-pulse bg-gray-50' />,
|
||||
})
|
||||
|
||||
const Testimonials = dynamic(() => import('@/app/(landing)/components/testimonials/testimonials'), {
|
||||
loading: () => <div className='h-[150px] animate-pulse bg-gray-50' />,
|
||||
})
|
||||
|
||||
export default function Landing() {
|
||||
const handleOpenTypeformLink = () => {
|
||||
window.open('https://form.typeform.com/to/jqCO12pF', '_blank')
|
||||
}
|
||||
|
||||
return (
|
||||
<main className='relative min-h-screen bg-[var(--brand-background-hex)] font-geist-sans'>
|
||||
<NavWrapper onOpenTypeformLink={handleOpenTypeformLink} />
|
||||
|
||||
<Hero />
|
||||
<Testimonials />
|
||||
{/* <Features /> */}
|
||||
<Integrations />
|
||||
{/* <Blogs /> */}
|
||||
|
||||
{/* Footer */}
|
||||
<Footer />
|
||||
</main>
|
||||
<>
|
||||
<StructuredData />
|
||||
<Background>
|
||||
<header>
|
||||
<Nav />
|
||||
</header>
|
||||
<main className='relative'>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div
|
||||
className='h-[600px] animate-pulse bg-gray-50'
|
||||
aria-label='Loading hero section'
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Hero />
|
||||
</Suspense>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div
|
||||
className='h-[400px] animate-pulse bg-gray-50'
|
||||
aria-label='Loading pricing section'
|
||||
/>
|
||||
}
|
||||
>
|
||||
<LandingPricing />
|
||||
</Suspense>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div
|
||||
className='h-[300px] animate-pulse bg-gray-50'
|
||||
aria-label='Loading integrations section'
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Integrations />
|
||||
</Suspense>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div
|
||||
className='h-[150px] animate-pulse bg-gray-50'
|
||||
aria-label='Loading testimonials section'
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Testimonials />
|
||||
</Suspense>
|
||||
</main>
|
||||
<Footer />
|
||||
</Background>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
18
apps/sim/app/(landing)/layout.tsx
Normal file
18
apps/sim/app/(landing)/layout.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL('https://sim.ai'),
|
||||
manifest: '/manifest.json',
|
||||
icons: {
|
||||
icon: '/favicon.ico',
|
||||
apple: '/apple-icon.png',
|
||||
},
|
||||
other: {
|
||||
'msapplication-TileColor': '#000000',
|
||||
'theme-color': '#000000',
|
||||
},
|
||||
}
|
||||
|
||||
export default function LandingLayout({ children }: { children: React.ReactNode }) {
|
||||
return children
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,366 +1,278 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { GridPattern } from '@/app/(landing)/components/grid-pattern'
|
||||
import NavWrapper from '@/app/(landing)/components/nav-wrapper'
|
||||
import Footer from '@/app/(landing)/components/sections/footer'
|
||||
import { LegalLayout } from '@/app/(landing)/components'
|
||||
|
||||
export default function TermsOfService() {
|
||||
const handleOpenTypeformLink = () => {
|
||||
window.open('https://form.typeform.com/to/jqCO12pF', '_blank')
|
||||
}
|
||||
|
||||
return (
|
||||
<main className='relative min-h-screen overflow-hidden bg-[var(--brand-background-hex)] text-white'>
|
||||
{/* Grid pattern background */}
|
||||
<div className='absolute inset-0 bottom-[400px] z-0 overflow-hidden'>
|
||||
<GridPattern
|
||||
x={-5}
|
||||
y={-5}
|
||||
className='absolute inset-0 stroke-[#ababab]/5'
|
||||
width={90}
|
||||
height={90}
|
||||
aria-hidden='true'
|
||||
/>
|
||||
</div>
|
||||
<LegalLayout title='Terms of Service'>
|
||||
<section>
|
||||
<p className='mb-4'>Last Updated: September 10, 2025</p>
|
||||
<p>
|
||||
Please read these Terms of Service ("Terms") carefully before using the Sim platform (the
|
||||
"Service") operated by Sim, Inc ("us", "we", or "our").
|
||||
</p>
|
||||
<p className='mt-4'>
|
||||
By accessing or using the Service, you agree to be bound by these Terms. If you disagree
|
||||
with any part of the terms, you may not access the Service.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Header/Navigation */}
|
||||
<NavWrapper onOpenTypeformLink={handleOpenTypeformLink} />
|
||||
<section>
|
||||
<h2 className='mb-4 font-semibold text-2xl'>1. Accounts</h2>
|
||||
<p className='mb-4'>
|
||||
When you create an account with us, you must provide accurate, complete, and current
|
||||
information. Failure to do so constitutes a breach of the Terms, which may result in
|
||||
immediate termination of your account on our Service.
|
||||
</p>
|
||||
<p className='mb-4'>
|
||||
You are responsible for safeguarding the password that you use to access the Service and
|
||||
for any activities or actions under your password.
|
||||
</p>
|
||||
<p>
|
||||
You agree not to disclose your password to any third party. You must notify us immediately
|
||||
upon becoming aware of any breach of security or unauthorized use of your account.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* background blur */}
|
||||
<div
|
||||
className='-translate-x-1/2 absolute top-0 bottom-0 left-1/2 z-[1] h-full w-[95%] max-w-5xl md:w-[90%] lg:w-[80%]'
|
||||
aria-hidden='true'
|
||||
>
|
||||
<svg
|
||||
width='100%'
|
||||
height='100%'
|
||||
viewBox='0 0 600 1600'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
preserveAspectRatio='xMidYMid slice'
|
||||
className='h-full w-full'
|
||||
>
|
||||
<g filter='url(#filter0_b_terms)'>
|
||||
<rect width='600' height='1600' rx='0' fill='var(--brand-background-hex)' />
|
||||
</g>
|
||||
<defs>
|
||||
<filter
|
||||
id='filter0_b_terms'
|
||||
x='-20'
|
||||
y='-20'
|
||||
width='640'
|
||||
height='1640'
|
||||
filterUnits='userSpaceOnUse'
|
||||
colorInterpolationFilters='sRGB'
|
||||
>
|
||||
<feGaussianBlur stdDeviation='7' />
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
<section>
|
||||
<h2 className='mb-4 font-semibold text-2xl'>2. Intellectual Property</h2>
|
||||
<p className='mb-4'>
|
||||
The Service and its original content, features, and functionality are and will remain the
|
||||
exclusive property of Sim, Inc and its licensors. The Service is protected by copyright,
|
||||
trademark, and other laws of both the United States and foreign countries.
|
||||
</p>
|
||||
<p>
|
||||
Our trademarks and trade dress may not be used in connection with any product or service
|
||||
without the prior written consent of Sim, Inc.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Content */}
|
||||
<div className='relative z-10'>
|
||||
<div className='mx-auto max-w-4xl px-4 py-16 pt-36'>
|
||||
<div className='relative px-4 py-4 sm:px-8'>
|
||||
<h1 className='mb-8 font-bold text-4xl text-white'>Terms of Service</h1>
|
||||
<section>
|
||||
<h2 className='mb-4 font-semibold text-2xl'>3. User Content</h2>
|
||||
<p className='mb-4'>
|
||||
Our Service allows you to post, link, store, share and otherwise make available certain
|
||||
information, text, graphics, videos, or other material ("User Content"). You are
|
||||
responsible for the User Content that you post on or through the Service, including its
|
||||
legality, reliability, and appropriateness.
|
||||
</p>
|
||||
<p className='mb-4'>
|
||||
By posting User Content on or through the Service, you represent and warrant that:
|
||||
</p>
|
||||
<ul className='mb-4 list-disc space-y-2 pl-6'>
|
||||
<li>
|
||||
The User Content is yours (you own it) or you have the right to use it and grant us the
|
||||
rights and license as provided in these Terms.
|
||||
</li>
|
||||
<li>
|
||||
The posting of your User Content on or through the Service does not violate the privacy
|
||||
rights, publicity rights, copyrights, contract rights or any other rights of any person.
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
We reserve the right to terminate the account of any user found to be infringing on a
|
||||
copyright.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div className='space-y-8 text-white/80'>
|
||||
<section>
|
||||
<p className='mb-4'>Last Updated: April 20, 2025</p>
|
||||
<p>
|
||||
Please read these Terms of Service ("Terms") carefully before using the Sim
|
||||
platform (the "Service") operated by Sim, Inc ("us", "we", or "our").
|
||||
</p>
|
||||
<p className='mt-4'>
|
||||
By accessing or using the Service, you agree to be bound by these Terms. If you
|
||||
disagree with any part of the terms, you may not access the Service.
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2 className='mb-4 font-semibold text-2xl'>4. Acceptable Use</h2>
|
||||
<p className='mb-4'>You agree not to use the Service:</p>
|
||||
<ul className='mb-4 list-disc space-y-2 pl-6'>
|
||||
<li>
|
||||
In any way that violates any applicable national or international law or regulation.
|
||||
</li>
|
||||
<li>
|
||||
For the purpose of exploiting, harming, or attempting to exploit or harm minors in any
|
||||
way.
|
||||
</li>
|
||||
<li>
|
||||
To transmit, or procure the sending of, any advertising or promotional material,
|
||||
including any "junk mail", "chain letter," "spam," or any other similar solicitation.
|
||||
</li>
|
||||
<li>
|
||||
To impersonate or attempt to impersonate Sim, Inc, a Sim employee, another user, or any
|
||||
other person or entity.
|
||||
</li>
|
||||
<li>
|
||||
In any way that infringes upon the rights of others, or in any way is illegal,
|
||||
threatening, fraudulent, or harmful.
|
||||
</li>
|
||||
<li>
|
||||
To engage in any other conduct that restricts or inhibits anyone's use or enjoyment of
|
||||
the Service, or which, as determined by us, may harm Sim, Inc or users of the Service or
|
||||
expose them to liability.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className='mb-4 font-semibold text-2xl text-white'>1. Accounts</h2>
|
||||
<p className='mb-4'>
|
||||
When you create an account with us, you must provide accurate, complete, and
|
||||
current information. Failure to do so constitutes a breach of the Terms, which may
|
||||
result in immediate termination of your account on our Service.
|
||||
</p>
|
||||
<p className='mb-4'>
|
||||
You are responsible for safeguarding the password that you use to access the
|
||||
Service and for any activities or actions under your password.
|
||||
</p>
|
||||
<p>
|
||||
You agree not to disclose your password to any third party. You must notify us
|
||||
immediately upon becoming aware of any breach of security or unauthorized use of
|
||||
your account.
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2 className='mb-4 font-semibold text-2xl'>5. Termination</h2>
|
||||
<p className='mb-4'>
|
||||
We may terminate or suspend your account immediately, without prior notice or liability,
|
||||
for any reason whatsoever, including without limitation if you breach the Terms.
|
||||
</p>
|
||||
<p>
|
||||
Upon termination, your right to use the Service will immediately cease. If you wish to
|
||||
terminate your account, you may simply discontinue using the Service.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className='mb-4 font-semibold text-2xl text-white'>2. Intellectual Property</h2>
|
||||
<p className='mb-4'>
|
||||
The Service and its original content, features, and functionality are and will
|
||||
remain the exclusive property of Sim, Inc and its licensors. The Service is
|
||||
protected by copyright, trademark, and other laws of both the United States and
|
||||
foreign countries.
|
||||
</p>
|
||||
<p>
|
||||
Our trademarks and trade dress may not be used in connection with any product or
|
||||
service without the prior written consent of Sim, Inc.
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2 className='mb-4 font-semibold text-2xl'>6. Limitation of Liability</h2>
|
||||
<p className='mb-4'>
|
||||
In no event shall Sim, Inc, nor its directors, employees, partners, agents, suppliers, or
|
||||
affiliates, be liable for any indirect, incidental, special, consequential or punitive
|
||||
damages, including without limitation, loss of profits, data, use, goodwill, or other
|
||||
intangible losses, resulting from:
|
||||
</p>
|
||||
<ul className='list-disc space-y-2 pl-6'>
|
||||
<li>Your access to or use of or inability to access or use the Service;</li>
|
||||
<li>Any conduct or content of any third party on the Service;</li>
|
||||
<li>Any content obtained from the Service; and</li>
|
||||
<li>
|
||||
Unauthorized access, use or alteration of your transmissions or content, whether based
|
||||
on warranty, contract, tort (including negligence) or any other legal theory, whether or
|
||||
not we have been informed of the possibility of such damage.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className='mb-4 font-semibold text-2xl text-white'>3. User Content</h2>
|
||||
<p className='mb-4'>
|
||||
Our Service allows you to post, link, store, share and otherwise make available
|
||||
certain information, text, graphics, videos, or other material ("User Content").
|
||||
You are responsible for the User Content that you post on or through the Service,
|
||||
including its legality, reliability, and appropriateness.
|
||||
</p>
|
||||
<p className='mb-4'>
|
||||
By posting User Content on or through the Service, you represent and warrant that:
|
||||
</p>
|
||||
<ul className='mb-4 list-disc space-y-2 pl-6 marker:text-[#B5A1D4]'>
|
||||
<li>
|
||||
The User Content is yours (you own it) or you have the right to use it and grant
|
||||
us the rights and license as provided in these Terms.
|
||||
</li>
|
||||
<li>
|
||||
The posting of your User Content on or through the Service does not violate the
|
||||
privacy rights, publicity rights, copyrights, contract rights or any other
|
||||
rights of any person.
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
We reserve the right to terminate the account of any user found to be infringing
|
||||
on a copyright.
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2 className='mb-4 font-semibold text-2xl'>7. Disclaimer</h2>
|
||||
<p className='mb-4'>
|
||||
Your use of the Service is at your sole risk. The Service is provided on an "AS IS" and
|
||||
"AS AVAILABLE" basis. The Service is provided without warranties of any kind, whether
|
||||
express or implied, including, but not limited to, implied warranties of merchantability,
|
||||
fitness for a particular purpose, non-infringement or course of performance.
|
||||
</p>
|
||||
<p>Sim, Inc, its subsidiaries, affiliates, and its licensors do not warrant that:</p>
|
||||
<ul className='mb-4 list-disc space-y-2 pl-6'>
|
||||
<li>
|
||||
The Service will function uninterrupted, secure or available at any particular time or
|
||||
location;
|
||||
</li>
|
||||
<li>Any errors or defects will be corrected;</li>
|
||||
<li>The Service is free of viruses or other harmful components; or</li>
|
||||
<li>The results of using the Service will meet your requirements.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className='mb-4 font-semibold text-2xl text-white'>4. Acceptable Use</h2>
|
||||
<p className='mb-4'>You agree not to use the Service:</p>
|
||||
<ul className='mb-4 list-disc space-y-2 pl-6 marker:text-[#B5A1D4]'>
|
||||
<li>
|
||||
In any way that violates any applicable national or international law or
|
||||
regulation.
|
||||
</li>
|
||||
<li>
|
||||
For the purpose of exploiting, harming, or attempting to exploit or harm minors
|
||||
in any way.
|
||||
</li>
|
||||
<li>
|
||||
To transmit, or procure the sending of, any advertising or promotional material,
|
||||
including any "junk mail", "chain letter," "spam," or any other similar
|
||||
solicitation.
|
||||
</li>
|
||||
<li>
|
||||
To impersonate or attempt to impersonate Sim, Inc, a Sim employee, another user,
|
||||
or any other person or entity.
|
||||
</li>
|
||||
<li>
|
||||
In any way that infringes upon the rights of others, or in any way is illegal,
|
||||
threatening, fraudulent, or harmful.
|
||||
</li>
|
||||
<li>
|
||||
To engage in any other conduct that restricts or inhibits anyone's use or
|
||||
enjoyment of the Service, or which, as determined by us, may harm Sim, Inc or
|
||||
users of the Service or expose them to liability.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
<section>
|
||||
<h2 className='mb-4 font-semibold text-2xl'>8. Governing Law</h2>
|
||||
<p>
|
||||
These Terms shall be governed and construed in accordance with the laws of the United
|
||||
States, without regard to its conflict of law provisions.
|
||||
</p>
|
||||
<p className='mt-4'>
|
||||
Our failure to enforce any right or provision of these Terms will not be considered a
|
||||
waiver of those rights. If any provision of these Terms is held to be invalid or
|
||||
unenforceable by a court, the remaining provisions of these Terms will remain in effect.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className='mb-4 font-semibold text-2xl text-white'>5. Termination</h2>
|
||||
<p className='mb-4'>
|
||||
We may terminate or suspend your account immediately, without prior notice or
|
||||
liability, for any reason whatsoever, including without limitation if you breach
|
||||
the Terms.
|
||||
</p>
|
||||
<p>
|
||||
Upon termination, your right to use the Service will immediately cease. If you
|
||||
wish to terminate your account, you may simply discontinue using the Service.
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2 className='mb-4 font-semibold text-2xl'>9. Arbitration Agreement</h2>
|
||||
<p className='mb-4'>
|
||||
Please read the following arbitration agreement carefully. It requires you to arbitrate
|
||||
disputes with Sim, Inc, its parent companies, subsidiaries, affiliates, successors and
|
||||
assigns and all of their respective officers, directors, employees, agents, and
|
||||
representatives (collectively, the "Company Parties") and limits the manner in which you
|
||||
can seek relief from the Company Parties.
|
||||
</p>
|
||||
<p className='mb-4'>
|
||||
You agree that any dispute between you and any of the Company Parties relating to the
|
||||
Site, the Service or these Terms will be resolved by binding arbitration, rather than in
|
||||
court, except that (1) you and the Company Parties may assert individualized claims in
|
||||
small claims court if the claims qualify, remain in such court and advance solely on an
|
||||
individual, non-class basis; and (2) you or the Company Parties may seek equitable relief
|
||||
in court for infringement or other misuse of intellectual property rights.
|
||||
</p>
|
||||
<p className='mb-4'>
|
||||
The Federal Arbitration Act governs the interpretation and enforcement of this Arbitration
|
||||
Agreement. The arbitration will be conducted by JAMS, an established alternative dispute
|
||||
resolution provider.
|
||||
</p>
|
||||
<p className='mb-4 border-[var(--brand-primary-hex)] border-l-4 bg-[var(--brand-primary-hex)]/10 p-3'>
|
||||
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
|
||||
OR CONSOLIDATED WITH THOSE OF ANY OTHER CUSTOMER OR USER.
|
||||
</p>
|
||||
<p className='mb-4'>
|
||||
You have the right to opt out of the provisions of this Arbitration Agreement by sending a
|
||||
timely written notice of your decision to opt out to:{' '}
|
||||
<Link
|
||||
href='mailto:legal@sim.ai'
|
||||
className='text-[var(--brand-primary-hex)] underline hover:text-[var(--brand-primary-hover-hex)]'
|
||||
>
|
||||
legal@sim.ai{' '}
|
||||
</Link>
|
||||
within 30 days after first becoming subject to this Arbitration Agreement.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className='mb-4 font-semibold text-2xl text-white'>
|
||||
6. Limitation of Liability
|
||||
</h2>
|
||||
<p className='mb-4'>
|
||||
In no event shall Sim, Inc, nor its directors, employees, partners, agents,
|
||||
suppliers, or affiliates, be liable for any indirect, incidental, special,
|
||||
consequential or punitive damages, including without limitation, loss of profits,
|
||||
data, use, goodwill, or other intangible losses, resulting from:
|
||||
</p>
|
||||
<ul className='list-disc space-y-2 pl-6 marker:text-[#B5A1D4]'>
|
||||
<li>Your access to or use of or inability to access or use the Service;</li>
|
||||
<li>Any conduct or content of any third party on the Service;</li>
|
||||
<li>Any content obtained from the Service; and</li>
|
||||
<li>
|
||||
Unauthorized access, use or alteration of your transmissions or content, whether
|
||||
based on warranty, contract, tort (including negligence) or any other legal
|
||||
theory, whether or not we have been informed of the possibility of such damage.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
<section>
|
||||
<h2 className='mb-4 font-semibold text-2xl'>10. Changes to Terms</h2>
|
||||
<p>
|
||||
We reserve the right, at our sole discretion, to modify or replace these Terms at any
|
||||
time. If a revision is material, we will try to provide at least 30 days' notice prior to
|
||||
any new terms taking effect. What constitutes a material change will be determined at our
|
||||
sole discretion.
|
||||
</p>
|
||||
<p className='mt-4'>
|
||||
By continuing to access or use our Service after those revisions become effective, you
|
||||
agree to be bound by the revised terms. If you do not agree to the new terms, please stop
|
||||
using the Service.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className='mb-4 font-semibold text-2xl text-white'>7. Disclaimer</h2>
|
||||
<p className='mb-4'>
|
||||
Your use of the Service is at your sole risk. The Service is provided on an "AS
|
||||
IS" and "AS AVAILABLE" basis. The Service is provided without warranties of any
|
||||
kind, whether express or implied, including, but not limited to, implied
|
||||
warranties of merchantability, fitness for a particular purpose, non-infringement
|
||||
or course of performance.
|
||||
</p>
|
||||
<p>
|
||||
Sim, Inc, its subsidiaries, affiliates, and its licensors do not warrant that:
|
||||
</p>
|
||||
<ul className='mb-4 list-disc space-y-2 pl-6 marker:text-[#B5A1D4]'>
|
||||
<li>
|
||||
The Service will function uninterrupted, secure or available at any particular
|
||||
time or location;
|
||||
</li>
|
||||
<li>Any errors or defects will be corrected;</li>
|
||||
<li>The Service is free of viruses or other harmful components; or</li>
|
||||
<li>The results of using the Service will meet your requirements.</li>
|
||||
</ul>
|
||||
</section>
|
||||
<section>
|
||||
<h2 className='mb-4 font-semibold text-2xl'>11. Copyright Policy</h2>
|
||||
<p className='mb-4'>
|
||||
We respect the intellectual property of others and ask that users of our Service do the
|
||||
same. If you believe that one of our users is, through the use of our Service, unlawfully
|
||||
infringing the copyright(s) in a work, please send a notice to our designated Copyright
|
||||
Agent, including the following information:
|
||||
</p>
|
||||
<ul className='mb-4 list-disc space-y-2 pl-6'>
|
||||
<li>Your physical or electronic signature;</li>
|
||||
<li>Identification of the copyrighted work(s) that you claim to have been infringed;</li>
|
||||
<li>Identification of the material on our services that you claim is infringing;</li>
|
||||
<li>Your address, telephone number, and e-mail address;</li>
|
||||
<li>
|
||||
A statement that you have a good-faith belief that the disputed use is not authorized by
|
||||
the copyright owner, its agent, or the law; and
|
||||
</li>
|
||||
<li>
|
||||
A statement, made under the penalty of perjury, that the above information in your
|
||||
notice is accurate and that you are the copyright owner or authorized to act on the
|
||||
copyright owner's behalf.
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
Our Copyright Agent can be reached at:{' '}
|
||||
<Link
|
||||
href='mailto:copyright@sim.ai'
|
||||
className='text-[var(--brand-primary-hex)] underline hover:text-[var(--brand-primary-hover-hex)]'
|
||||
>
|
||||
copyright@sim.ai
|
||||
</Link>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className='mb-4 font-semibold text-2xl text-white'>8. Governing Law</h2>
|
||||
<p>
|
||||
These Terms shall be governed and construed in accordance with the laws of the
|
||||
United States, without regard to its conflict of law provisions.
|
||||
</p>
|
||||
<p className='mt-4'>
|
||||
Our failure to enforce any right or provision of these Terms will not be
|
||||
considered a waiver of those rights. If any provision of these Terms is held to be
|
||||
invalid or unenforceable by a court, the remaining provisions of these Terms will
|
||||
remain in effect.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className='mb-4 font-semibold text-2xl text-white'>9. Arbitration Agreement</h2>
|
||||
<p className='mb-4'>
|
||||
Please read the following arbitration agreement carefully. It requires you to
|
||||
arbitrate disputes with Sim, Inc, its parent companies, subsidiaries, affiliates,
|
||||
successors and assigns and all of their respective officers, directors, employees,
|
||||
agents, and representatives (collectively, the{' '}
|
||||
<span className='text-[#B5A1D4]'>"Company Parties"</span>) and limits the manner
|
||||
in which you can seek relief from the Company Parties.
|
||||
</p>
|
||||
<p className='mb-4'>
|
||||
You agree that any dispute between you and any of the Company Parties relating to
|
||||
the Site, the Service or these Terms will be resolved by binding arbitration,
|
||||
rather than in court, except that (1) you and the Company Parties may assert
|
||||
individualized claims in small claims court if the claims qualify, remain in such
|
||||
court and advance solely on an individual, non-class basis; and (2) you or the
|
||||
Company Parties may seek equitable relief in court for infringement or other
|
||||
misuse of intellectual property rights.
|
||||
</p>
|
||||
<p className='mb-4'>
|
||||
The Federal Arbitration Act governs the interpretation and enforcement of this
|
||||
Arbitration Agreement. The arbitration will be conducted by JAMS, an established
|
||||
alternative dispute resolution provider.
|
||||
</p>
|
||||
<p className='mb-4 border-[var(--brand-primary-hex)] border-l-4 bg-[var(--brand-primary-hex)]/10 p-3'>
|
||||
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 OR CONSOLIDATED WITH THOSE OF ANY OTHER CUSTOMER OR USER.
|
||||
</p>
|
||||
<p className='mb-4'>
|
||||
You have the right to opt out of the provisions of this Arbitration Agreement by
|
||||
sending a timely written notice of your decision to opt out to:{' '}
|
||||
<Link
|
||||
href='mailto:legal@sim.ai'
|
||||
className='text-[#B5A1D4] hover:text-[var(--brand-primary-hex)]'
|
||||
>
|
||||
legal@sim.ai{' '}
|
||||
</Link>
|
||||
within 30 days after first becoming subject to this Arbitration Agreement.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className='mb-4 font-semibold text-2xl text-white'>10. Changes to Terms</h2>
|
||||
<p>
|
||||
We reserve the right, at our sole discretion, to modify or replace these Terms at
|
||||
any time. If a revision is material, we will try to provide at least 30 days'
|
||||
notice prior to any new terms taking effect. What constitutes a material change
|
||||
will be determined at our sole discretion.
|
||||
</p>
|
||||
<p className='mt-4'>
|
||||
By continuing to access or use our Service after those revisions become effective,
|
||||
you agree to be bound by the revised terms. If you do not agree to the new terms,
|
||||
please stop using the Service.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className='mb-4 font-semibold text-2xl text-white'>11. Copyright Policy</h2>
|
||||
<p className='mb-4'>
|
||||
We respect the intellectual property of others and ask that users of our Service
|
||||
do the same. If you believe that one of our users is, through the use of our
|
||||
Service, unlawfully infringing the copyright(s) in a work, please send a notice to
|
||||
our designated Copyright Agent, including the following information:
|
||||
</p>
|
||||
<ul className='mb-4 list-disc space-y-2 pl-6 marker:text-[#B5A1D4]'>
|
||||
<li>Your physical or electronic signature;</li>
|
||||
<li>
|
||||
Identification of the copyrighted work(s) that you claim to have been infringed;
|
||||
</li>
|
||||
<li>
|
||||
Identification of the material on our services that you claim is infringing;
|
||||
</li>
|
||||
<li>Your address, telephone number, and e-mail address;</li>
|
||||
<li>
|
||||
A statement that you have a good-faith belief that the disputed use is not
|
||||
authorized by the copyright owner, its agent, or the law; and
|
||||
</li>
|
||||
<li>
|
||||
A statement, made under the penalty of perjury, that the above information in
|
||||
your notice is accurate and that you are the copyright owner or authorized to
|
||||
act on the copyright owner's behalf.
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
Our Copyright Agent can be reached at:{' '}
|
||||
<Link
|
||||
href='mailto:copyright@sim.ai'
|
||||
className='text-[#B5A1D4] hover:text-[var(--brand-primary-hex)]'
|
||||
>
|
||||
copyright@sim.ai
|
||||
</Link>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className='mb-4 font-semibold text-2xl text-white'>12. Contact Us</h2>
|
||||
<p>
|
||||
If you have any questions about these Terms, please contact us at:{' '}
|
||||
<Link
|
||||
href='mailto:legal@sim.ai'
|
||||
className='text-[#B5A1D4] hover:text-[var(--brand-primary-hex)]'
|
||||
>
|
||||
legal@sim.ai
|
||||
</Link>
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className='relative z-20'>
|
||||
<Footer />
|
||||
</div>
|
||||
</main>
|
||||
<section>
|
||||
<h2 className='mb-4 font-semibold text-2xl'>12. Contact Us</h2>
|
||||
<p>
|
||||
If you have any questions about these Terms, please contact us at:{' '}
|
||||
<Link
|
||||
href='mailto:legal@sim.ai'
|
||||
className='text-[var(--brand-primary-hex)] underline hover:text-[var(--brand-primary-hover-hex)]'
|
||||
>
|
||||
legal@sim.ai
|
||||
</Link>
|
||||
</p>
|
||||
</section>
|
||||
</LegalLayout>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
interface RepoStats {
|
||||
stars: number
|
||||
forks: number
|
||||
watchers: number
|
||||
openIssues: number
|
||||
openPRs: number
|
||||
}
|
||||
|
||||
interface CommitTimelineData {
|
||||
date: string
|
||||
commits: number
|
||||
additions: number
|
||||
deletions: number
|
||||
}
|
||||
|
||||
interface ActivityData {
|
||||
date: string
|
||||
commits: number
|
||||
issues: number
|
||||
pullRequests: number
|
||||
}
|
||||
|
||||
interface CommitData {
|
||||
sha: string
|
||||
commit: {
|
||||
author: {
|
||||
name: string
|
||||
email: string
|
||||
date: string
|
||||
}
|
||||
message: string
|
||||
}
|
||||
html_url: string
|
||||
stats?: {
|
||||
additions: number
|
||||
deletions: number
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate commit timeline data for the last 30 days using real commit data
|
||||
*/
|
||||
export function generateCommitTimelineData(commitsData: CommitData[]): CommitTimelineData[] {
|
||||
return Array.from({ length: 30 }, (_, i) => {
|
||||
const date = new Date()
|
||||
date.setDate(date.getDate() - (29 - i))
|
||||
const dateStr = date.toISOString().split('T')[0]
|
||||
|
||||
const dayCommits = commitsData.filter((commit) => commit.commit.author.date.startsWith(dateStr))
|
||||
|
||||
const stats = dayCommits.reduce(
|
||||
(acc, commit) => {
|
||||
if (commit.stats) {
|
||||
acc.additions += commit.stats.additions || 0
|
||||
acc.deletions += commit.stats.deletions || 0
|
||||
}
|
||||
return acc
|
||||
},
|
||||
{ additions: 0, deletions: 0 }
|
||||
)
|
||||
|
||||
return {
|
||||
date: date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}),
|
||||
commits: dayCommits.length,
|
||||
additions: stats.additions,
|
||||
deletions: stats.deletions,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate activity data for the last 7 days using actual commit data
|
||||
*/
|
||||
export function generateActivityData(
|
||||
commitsData: CommitData[],
|
||||
repoStats?: RepoStats
|
||||
): ActivityData[] {
|
||||
return Array.from({ length: 7 }, (_, i) => {
|
||||
const date = new Date()
|
||||
const today = date.getDay()
|
||||
const daysToSubtract = today + (6 - i)
|
||||
date.setDate(date.getDate() - daysToSubtract)
|
||||
|
||||
const dateStr = date.toISOString().split('T')[0]
|
||||
|
||||
const dayCommits = commitsData.filter((commit) =>
|
||||
commit.commit.author.date.startsWith(dateStr)
|
||||
).length
|
||||
|
||||
return {
|
||||
date: date.toLocaleDateString('en-US', { weekday: 'short' }),
|
||||
commits: dayCommits,
|
||||
issues: repoStats ? Math.floor(repoStats.openIssues / 7) : 0,
|
||||
pullRequests: repoStats ? Math.floor(repoStats.openPRs / 7) : 0,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
// Utility for prefetching and caching contributors page data
|
||||
import { getCommitsData, getContributors, getRepositoryStats } from '@/app/(landing)/actions/github'
|
||||
import { generateActivityData, generateCommitTimelineData } from '@/app/(landing)/utils/github'
|
||||
|
||||
interface Contributor {
|
||||
login: string
|
||||
avatar_url: string
|
||||
contributions: number
|
||||
html_url: string
|
||||
}
|
||||
|
||||
interface RepoStats {
|
||||
stars: number
|
||||
forks: number
|
||||
watchers: number
|
||||
openIssues: number
|
||||
openPRs: number
|
||||
}
|
||||
|
||||
interface CommitTimelineData {
|
||||
date: string
|
||||
commits: number
|
||||
additions: number
|
||||
deletions: number
|
||||
}
|
||||
|
||||
interface ActivityData {
|
||||
date: string
|
||||
commits: number
|
||||
issues: number
|
||||
pullRequests: number
|
||||
}
|
||||
|
||||
interface ContributorsPageData {
|
||||
contributors: Contributor[]
|
||||
repoStats: RepoStats
|
||||
timelineData: CommitTimelineData[]
|
||||
activityData: ActivityData[]
|
||||
}
|
||||
|
||||
// Debounce utility function
|
||||
function debounce<T extends (...args: any[]) => any>(
|
||||
func: T,
|
||||
wait: number
|
||||
): (...args: Parameters<T>) => void {
|
||||
let timeout: NodeJS.Timeout | null = null
|
||||
return (...args: Parameters<T>) => {
|
||||
if (timeout) clearTimeout(timeout)
|
||||
timeout = setTimeout(() => func(...args), wait)
|
||||
}
|
||||
}
|
||||
|
||||
// Cache for the prefetched data
|
||||
let cachedData: ContributorsPageData | null = null
|
||||
let isPreFetching = false
|
||||
let prefetchPromise: Promise<ContributorsPageData> | null = null
|
||||
|
||||
// Create a debounced version of the prefetch function
|
||||
const debouncedPrefetchContributorsData = debounce(() => {
|
||||
prefetchContributorsData().catch((err: unknown) => {
|
||||
console.error('Failed to prefetch contributors data:', err)
|
||||
})
|
||||
}, 100)
|
||||
|
||||
/**
|
||||
* Debounced prefetch function for use in hover handlers
|
||||
* Only triggers after 100ms of stable hover to prevent rapid API calls
|
||||
*/
|
||||
export function usePrefetchOnHover(): () => void {
|
||||
return debouncedPrefetchContributorsData
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefetch contributors page data
|
||||
*/
|
||||
export async function prefetchContributorsData(): Promise<ContributorsPageData> {
|
||||
// If data is already cached, return it
|
||||
if (cachedData) {
|
||||
return cachedData
|
||||
}
|
||||
|
||||
// If already prefetching, return the existing promise
|
||||
if (isPreFetching && prefetchPromise) {
|
||||
return prefetchPromise
|
||||
}
|
||||
|
||||
// Start prefetching
|
||||
prefetchPromise = fetchContributorsData()
|
||||
isPreFetching = true
|
||||
|
||||
try {
|
||||
const data = await prefetchPromise
|
||||
cachedData = data
|
||||
return data
|
||||
} finally {
|
||||
isPreFetching = false
|
||||
prefetchPromise = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached contributors data if available
|
||||
*/
|
||||
export function getCachedContributorsData(): ContributorsPageData | null {
|
||||
return cachedData
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the cached data (useful for refreshing)
|
||||
*/
|
||||
export function clearContributorsCache(): void {
|
||||
cachedData = null
|
||||
isPreFetching = false
|
||||
prefetchPromise = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal function to fetch all contributors data
|
||||
*/
|
||||
async function fetchContributorsData(): Promise<ContributorsPageData> {
|
||||
const [contributors, stats, commits] = await Promise.all([
|
||||
getContributors(),
|
||||
getRepositoryStats(),
|
||||
getCommitsData(),
|
||||
])
|
||||
|
||||
return {
|
||||
contributors,
|
||||
repoStats: stats,
|
||||
timelineData: generateCommitTimelineData(commits),
|
||||
activityData: generateActivityData(commits),
|
||||
}
|
||||
}
|
||||
35
apps/sim/app/api/github-stars/route.ts
Normal file
35
apps/sim/app/api/github-stars/route.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { env } from '@/lib/env'
|
||||
|
||||
function formatStarCount(num: number): string {
|
||||
if (num < 1000) return String(num)
|
||||
const formatted = (Math.round(num / 100) / 10).toFixed(1)
|
||||
return formatted.endsWith('.0') ? `${formatted.slice(0, -2)}k` : `${formatted}k`
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const token = env.GITHUB_TOKEN
|
||||
const response = await fetch('https://api.github.com/repos/simstudioai/sim', {
|
||||
headers: {
|
||||
Accept: 'application/vnd.github+json',
|
||||
'X-GitHub-Api-Version': '2022-11-28',
|
||||
'User-Agent': 'SimStudio/1.0',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
next: { revalidate: 3600 },
|
||||
cache: 'force-cache',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn('GitHub API request failed:', response.status)
|
||||
return NextResponse.json({ stars: formatStarCount(14500) })
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json({ stars: formatStarCount(Number(data?.stargazers_count ?? 14500)) })
|
||||
} catch (error) {
|
||||
console.warn('Error fetching GitHub stars:', error)
|
||||
return NextResponse.json({ stars: formatStarCount(14500) })
|
||||
}
|
||||
}
|
||||
36
apps/sim/app/conditional-theme-provider.tsx
Normal file
36
apps/sim/app/conditional-theme-provider.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
'use client'
|
||||
|
||||
import { usePathname } from 'next/navigation'
|
||||
import type { ThemeProviderProps } from 'next-themes'
|
||||
import { ThemeProvider as NextThemesProvider } from 'next-themes'
|
||||
|
||||
export function ConditionalThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
const pathname = usePathname()
|
||||
|
||||
// Force light mode for landing page (root path and /homepage), auth verify, invite, and legal pages
|
||||
const forcedTheme =
|
||||
pathname === '/' ||
|
||||
pathname === '/homepage' ||
|
||||
pathname.startsWith('/login') ||
|
||||
pathname.startsWith('/signup') ||
|
||||
pathname.startsWith('/terms') ||
|
||||
pathname.startsWith('/privacy') ||
|
||||
pathname.startsWith('/invite') ||
|
||||
pathname.startsWith('/verify')
|
||||
? 'light'
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<NextThemesProvider
|
||||
attribute='class'
|
||||
defaultTheme='system'
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
storageKey='sim-theme'
|
||||
forcedTheme={forcedTheme}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</NextThemesProvider>
|
||||
)
|
||||
}
|
||||
10
apps/sim/app/fonts/inter.ts
Normal file
10
apps/sim/app/fonts/inter.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Inter } from 'next/font/google'
|
||||
|
||||
export const inter = Inter({
|
||||
subsets: ['latin'],
|
||||
display: 'swap',
|
||||
variable: '--font-inter',
|
||||
// Variable font supports weights from 100-900
|
||||
weight: 'variable',
|
||||
fallback: ['system-ui', 'Segoe UI', 'Roboto', 'Helvetica Neue', 'Arial', 'Noto Sans'],
|
||||
})
|
||||
BIN
apps/sim/app/fonts/soehne/soehne-buch-kursiv.woff2
Normal file
BIN
apps/sim/app/fonts/soehne/soehne-buch-kursiv.woff2
Normal file
Binary file not shown.
BIN
apps/sim/app/fonts/soehne/soehne-buch.woff2
Normal file
BIN
apps/sim/app/fonts/soehne/soehne-buch.woff2
Normal file
Binary file not shown.
BIN
apps/sim/app/fonts/soehne/soehne-dreiviertelfett-kursiv.woff2
Normal file
BIN
apps/sim/app/fonts/soehne/soehne-dreiviertelfett-kursiv.woff2
Normal file
Binary file not shown.
BIN
apps/sim/app/fonts/soehne/soehne-dreiviertelfett.woff2
Normal file
BIN
apps/sim/app/fonts/soehne/soehne-dreiviertelfett.woff2
Normal file
Binary file not shown.
BIN
apps/sim/app/fonts/soehne/soehne-halbfett-kursiv.woff2
Normal file
BIN
apps/sim/app/fonts/soehne/soehne-halbfett-kursiv.woff2
Normal file
Binary file not shown.
BIN
apps/sim/app/fonts/soehne/soehne-halbfett.woff2
Normal file
BIN
apps/sim/app/fonts/soehne/soehne-halbfett.woff2
Normal file
Binary file not shown.
BIN
apps/sim/app/fonts/soehne/soehne-kraftig-kursiv.woff2
Normal file
BIN
apps/sim/app/fonts/soehne/soehne-kraftig-kursiv.woff2
Normal file
Binary file not shown.
BIN
apps/sim/app/fonts/soehne/soehne-kraftig.woff2
Normal file
BIN
apps/sim/app/fonts/soehne/soehne-kraftig.woff2
Normal file
Binary file not shown.
BIN
apps/sim/app/fonts/soehne/soehne-leicht-kursiv.woff2
Normal file
BIN
apps/sim/app/fonts/soehne/soehne-leicht-kursiv.woff2
Normal file
Binary file not shown.
BIN
apps/sim/app/fonts/soehne/soehne-leicht.woff2
Normal file
BIN
apps/sim/app/fonts/soehne/soehne-leicht.woff2
Normal file
Binary file not shown.
26
apps/sim/app/fonts/soehne/soehne.ts
Normal file
26
apps/sim/app/fonts/soehne/soehne.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import localFont from 'next/font/local'
|
||||
|
||||
export const soehne = localFont({
|
||||
src: [
|
||||
// Light (leicht)
|
||||
{ path: './soehne-leicht.woff2', weight: '300', style: 'normal' },
|
||||
{ path: './soehne-leicht-kursiv.woff2', weight: '300', style: 'italic' },
|
||||
// Regular (buch)
|
||||
{ path: './soehne-buch.woff2', weight: '400', style: 'normal' },
|
||||
{ path: './soehne-buch-kursiv.woff2', weight: '400', style: 'italic' },
|
||||
// Medium (kräftig)
|
||||
{ path: './soehne-kraftig.woff2', weight: '500', style: 'normal' },
|
||||
{ path: './soehne-kraftig-kursiv.woff2', weight: '500', style: 'italic' },
|
||||
// Semibold (halbfett)
|
||||
{ path: './soehne-halbfett.woff2', weight: '600', style: 'normal' },
|
||||
{ path: './soehne-halbfett-kursiv.woff2', weight: '600', style: 'italic' },
|
||||
// Bold (dreiviertelfett)
|
||||
{ path: './soehne-dreiviertelfett.woff2', weight: '700', style: 'normal' },
|
||||
{ path: './soehne-dreiviertelfett-kursiv.woff2', weight: '700', style: 'italic' },
|
||||
],
|
||||
display: 'swap',
|
||||
preload: true,
|
||||
variable: '--font-soehne',
|
||||
fallback: ['system-ui', 'Segoe UI', 'Roboto', 'Helvetica Neue', 'Arial', 'Noto Sans'],
|
||||
adjustFontFallback: 'Arial',
|
||||
})
|
||||
@@ -27,6 +27,30 @@
|
||||
z-index: 0 !important;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
LANDING LOOP ANIMATION
|
||||
========================================================================== */
|
||||
@keyframes dash-animation {
|
||||
from {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
to {
|
||||
stroke-dashoffset: -24;
|
||||
}
|
||||
}
|
||||
|
||||
.landing-loop-animated-dash {
|
||||
animation: dash-animation 1.5s linear infinite;
|
||||
/* Ensure animation works in React Flow context */
|
||||
will-change: stroke-dashoffset;
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
/* Ensure React Flow doesn't override our animation */
|
||||
.react-flow__node-landingLoop svg rect.landing-loop-animated-dash {
|
||||
animation: dash-animation 1.5s linear infinite !important;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
THEME SYSTEM - CSS CUSTOM PROPERTIES
|
||||
========================================================================== */
|
||||
@@ -96,12 +120,12 @@
|
||||
--gradient-secondary: 336 95% 65%; /* More vibrant pink */
|
||||
|
||||
/* Brand Colors (Default Sim Theme) */
|
||||
--brand-primary-hex: #701ffc; /* Primary brand purple */
|
||||
--brand-primary-hover-hex: #802fff; /* Primary brand purple hover */
|
||||
--brand-secondary-hex: #6518e6; /* Secondary brand purple */
|
||||
--brand-accent-hex: #9d54ff; /* Accent purple for links */
|
||||
--brand-accent-hover-hex: #a66fff; /* Accent purple hover */
|
||||
--brand-background-hex: #0c0c0c; /* Primary dark background */
|
||||
--brand-primary-hex: #8357ff; /* Primary brand purple - matches Get Started gradient start */
|
||||
--brand-primary-hover-hex: #9266ff; /* Primary brand purple hover - matches Get Started hover */
|
||||
--brand-secondary-hex: #6f3dfa; /* Secondary brand purple - matches Get Started gradient end */
|
||||
--brand-accent-hex: #6f3dfa; /* Accent purple for links - matches sign in button */
|
||||
--brand-accent-hover-hex: #8357ff; /* Accent purple hover - matches sign in gradient start */
|
||||
--brand-background-hex: #ffffff; /* Primary light background */
|
||||
|
||||
/* UI Surface Colors */
|
||||
--surface-elevated: #202020; /* Elevated surface background for dark mode */
|
||||
@@ -166,7 +190,7 @@
|
||||
--gradient-primary: 263 90% 75%; /* More vibrant purple for dark mode */
|
||||
--gradient-secondary: 336 100% 72%; /* More vibrant pink for dark mode */
|
||||
|
||||
/* Brand Colors (Same in dark mode) */
|
||||
/* Brand Colors (Keep dark background for actual dark mode) */
|
||||
--brand-primary-hex: #701ffc; /* Primary brand purple */
|
||||
--brand-primary-hover-hex: #802fff; /* Primary brand purple hover */
|
||||
--brand-secondary-hex: #6518e6; /* Secondary brand purple */
|
||||
@@ -371,34 +395,6 @@ input[type="search"]::-ms-clear {
|
||||
background-color: var(--brand-primary-hover-hex);
|
||||
}
|
||||
|
||||
.bg-brand-secondary {
|
||||
background-color: var(--brand-secondary-hex);
|
||||
}
|
||||
|
||||
.bg-brand-accent {
|
||||
background-color: var(--brand-accent-hex);
|
||||
}
|
||||
|
||||
.bg-brand-background {
|
||||
background-color: var(--brand-background-hex);
|
||||
}
|
||||
|
||||
.text-brand-primary {
|
||||
color: var(--brand-primary-hex);
|
||||
}
|
||||
|
||||
.text-brand-accent {
|
||||
color: var(--brand-accent-hex);
|
||||
}
|
||||
|
||||
.text-brand-accent-hover {
|
||||
color: var(--brand-accent-hover-hex);
|
||||
}
|
||||
|
||||
.border-brand-primary {
|
||||
border-color: var(--brand-primary-hex);
|
||||
}
|
||||
|
||||
.hover\:bg-brand-primary-hover:hover {
|
||||
background-color: var(--brand-primary-hover-hex);
|
||||
}
|
||||
@@ -411,6 +407,63 @@ input[type="search"]::-ms-clear {
|
||||
color: var(--brand-accent-hover-hex);
|
||||
}
|
||||
|
||||
/* Gradient Button Utilities */
|
||||
.bg-brand-gradient {
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
color-mix(in srgb, var(--brand-primary-hex) 85%, white),
|
||||
var(--brand-primary-hex)
|
||||
);
|
||||
}
|
||||
|
||||
.border-brand-gradient {
|
||||
border-color: var(--brand-primary-hex);
|
||||
}
|
||||
|
||||
.shadow-brand-gradient {
|
||||
box-shadow: inset 0 2px 4px 0 color-mix(in srgb, var(--brand-primary-hex) 60%, transparent);
|
||||
}
|
||||
|
||||
.hover\:bg-brand-gradient-hover:hover {
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
var(--brand-primary-hover-hex),
|
||||
color-mix(in srgb, var(--brand-primary-hex) 90%, black)
|
||||
);
|
||||
}
|
||||
|
||||
/* Apply fixed default light theme values for auth components */
|
||||
.auth-card {
|
||||
background-color: rgba(255, 255, 255, 0.9) !important;
|
||||
border-color: #e5e5e5 !important;
|
||||
}
|
||||
|
||||
.auth-text-primary {
|
||||
color: #1a1a1a !important;
|
||||
}
|
||||
|
||||
.auth-text-secondary {
|
||||
color: #4a4a4a !important;
|
||||
}
|
||||
|
||||
.auth-text-muted {
|
||||
color: #737373 !important;
|
||||
}
|
||||
|
||||
.auth-divider {
|
||||
border-color: #e5e5e5 !important;
|
||||
}
|
||||
|
||||
.auth-card-shadow {
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.05),
|
||||
0 4px 6px -2px rgba(0, 0, 0, 0.05) !important;
|
||||
}
|
||||
|
||||
.auth-link {
|
||||
color: #737373 !important;
|
||||
}
|
||||
|
||||
/* Surface Utilities */
|
||||
.bg-surface-elevated {
|
||||
background-color: var(--surface-elevated);
|
||||
@@ -474,6 +527,32 @@ input[type="search"]::-ms-clear {
|
||||
animation: placeholder-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Auth Button Styles - Default Gradient */
|
||||
.auth-button-gradient {
|
||||
background: linear-gradient(to bottom, #8357ff, #6f3dfa) !important;
|
||||
border-color: #6f3dfa !important;
|
||||
box-shadow: inset 0 2px 4px 0 #9b77ff !important;
|
||||
}
|
||||
|
||||
.auth-button-gradient:hover {
|
||||
background: linear-gradient(to bottom, #8357ff, #6f3dfa) !important;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Auth Button Styles - CSS Variable Based */
|
||||
.auth-button-custom {
|
||||
background: var(--brand-accent-hex) !important;
|
||||
border-color: var(--brand-accent-hex) !important;
|
||||
/* Remove purple shadow when using custom color */
|
||||
box-shadow: inset 0 2px 4px 0 rgba(0, 0, 0, 0.1) !important;
|
||||
}
|
||||
|
||||
.auth-button-custom:hover {
|
||||
background: var(--brand-accent-hover-hex, var(--brand-accent-hex)) !important;
|
||||
border-color: var(--brand-accent-hover-hex, var(--brand-accent-hex)) !important;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
KEYFRAME ANIMATIONS
|
||||
========================================================================== */
|
||||
|
||||
@@ -1,56 +1,44 @@
|
||||
'use client'
|
||||
|
||||
import Image from 'next/image'
|
||||
import { useBrandConfig } from '@/lib/branding/branding'
|
||||
import { GridPattern } from '@/app/(landing)/components/grid-pattern'
|
||||
import { useEffect } from 'react'
|
||||
import AuthBackground from '@/app/(auth)/components/auth-background'
|
||||
import Nav from '@/app/(landing)/components/nav/nav'
|
||||
|
||||
interface InviteLayoutProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
function isColorDark(hexColor: string): boolean {
|
||||
const hex = hexColor.replace('#', '')
|
||||
const r = Number.parseInt(hex.substr(0, 2), 16)
|
||||
const g = Number.parseInt(hex.substr(2, 2), 16)
|
||||
const b = Number.parseInt(hex.substr(4, 2), 16)
|
||||
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
|
||||
return luminance < 0.5
|
||||
}
|
||||
|
||||
export function InviteLayout({ children }: InviteLayoutProps) {
|
||||
const brandConfig = useBrandConfig()
|
||||
useEffect(() => {
|
||||
const rootStyle = getComputedStyle(document.documentElement)
|
||||
const brandBackground = rootStyle.getPropertyValue('--brand-background-hex').trim()
|
||||
|
||||
if (brandBackground && isColorDark(brandBackground)) {
|
||||
document.body.classList.add('auth-dark-bg')
|
||||
} else {
|
||||
document.body.classList.remove('auth-dark-bg')
|
||||
}
|
||||
}, [])
|
||||
return (
|
||||
<main className='dark relative flex min-h-screen flex-col bg-[var(--brand-background-hex)] font-geist-sans text-white'>
|
||||
{/* Background pattern */}
|
||||
<GridPattern
|
||||
x={-5}
|
||||
y={-5}
|
||||
className='absolute inset-0 z-0 stroke-[#ababab]/5'
|
||||
width={90}
|
||||
height={90}
|
||||
aria-hidden='true'
|
||||
/>
|
||||
<AuthBackground>
|
||||
<main className='relative flex min-h-screen flex-col font-geist-sans text-foreground'>
|
||||
{/* Header - Nav handles all conditional logic */}
|
||||
<Nav hideAuthButtons={true} variant='auth' />
|
||||
|
||||
{/* Content */}
|
||||
<div className='relative z-10 flex flex-1 items-center justify-center px-4 pb-6'>
|
||||
<div className='w-full max-w-md'>
|
||||
<div className='mb-8 text-center'>
|
||||
<Image
|
||||
src={brandConfig.logoUrl || '/logo/primary/text/medium.png'}
|
||||
alt='Sim Logo'
|
||||
width={140}
|
||||
height={42}
|
||||
priority
|
||||
className='mx-auto'
|
||||
/>
|
||||
</div>
|
||||
<div className='rounded-xl border border-neutral-700/40 bg-neutral-800/50 p-6 backdrop-blur-sm'>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
<div className='mt-6 text-center text-neutral-500/80 text-xs leading-relaxed'>
|
||||
Need help?{' '}
|
||||
<a
|
||||
href={`mailto:${brandConfig.supportEmail}`}
|
||||
className='text-[var(--brand-accent-hex)] underline-offset-4 transition hover:text-[var(--brand-accent-hover-hex)] hover:underline'
|
||||
>
|
||||
Contact support
|
||||
</a>
|
||||
</div>
|
||||
{/* Content */}
|
||||
<div className='relative z-30 flex flex-1 items-center justify-center px-4 pb-24'>
|
||||
<div className='w-full max-w-lg px-4'>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</main>
|
||||
</AuthBackground>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { CheckCircle2, Mail, RotateCcw, ShieldX, UserPlus, Users2 } from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { LoadingAgent } from '@/components/ui/loading-agent'
|
||||
import { inter } from '@/app/fonts/inter'
|
||||
import { soehne } from '@/app/fonts/soehne/soehne'
|
||||
|
||||
interface InviteStatusCardProps {
|
||||
type: 'login' | 'loading' | 'error' | 'success' | 'invitation'
|
||||
@@ -29,17 +32,17 @@ const iconMap = {
|
||||
}
|
||||
|
||||
const iconColorMap = {
|
||||
userPlus: 'text-[#701ffc]',
|
||||
mail: 'text-[#701ffc]',
|
||||
users: 'text-[#701ffc]',
|
||||
userPlus: 'text-[var(--brand-primary-hex)]',
|
||||
mail: 'text-[var(--brand-primary-hex)]',
|
||||
users: 'text-[var(--brand-primary-hex)]',
|
||||
error: 'text-red-500 dark:text-red-400',
|
||||
success: 'text-green-500 dark:text-green-400',
|
||||
}
|
||||
|
||||
const iconBgMap = {
|
||||
userPlus: 'bg-[#701ffc]/10',
|
||||
mail: 'bg-[#701ffc]/10',
|
||||
users: 'bg-[#701ffc]/10',
|
||||
userPlus: 'bg-[var(--brand-primary-hex)]/10',
|
||||
mail: 'bg-[var(--brand-primary-hex)]/10',
|
||||
users: 'bg-[var(--brand-primary-hex)]/10',
|
||||
error: 'bg-red-50 dark:bg-red-950/20',
|
||||
success: 'bg-green-50 dark:bg-green-950/20',
|
||||
}
|
||||
@@ -53,12 +56,55 @@ export function InviteStatusCard({
|
||||
isExpiredError = false,
|
||||
}: InviteStatusCardProps) {
|
||||
const router = useRouter()
|
||||
const [buttonClass, setButtonClass] = useState('auth-button-gradient')
|
||||
|
||||
useEffect(() => {
|
||||
const checkCustomBrand = () => {
|
||||
const computedStyle = getComputedStyle(document.documentElement)
|
||||
const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim()
|
||||
if (brandAccent && brandAccent !== '#6f3dfa') {
|
||||
setButtonClass('auth-button-custom')
|
||||
} else {
|
||||
setButtonClass('auth-button-gradient')
|
||||
}
|
||||
}
|
||||
checkCustomBrand()
|
||||
window.addEventListener('resize', checkCustomBrand)
|
||||
const observer = new MutationObserver(checkCustomBrand)
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['style', 'class'],
|
||||
})
|
||||
return () => {
|
||||
window.removeEventListener('resize', checkCustomBrand)
|
||||
observer.disconnect()
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (type === 'loading') {
|
||||
return (
|
||||
<div className='flex w-full max-w-md flex-col items-center'>
|
||||
<LoadingAgent size='lg' />
|
||||
<p className='mt-4 text-muted-foreground text-sm'>{description}</p>
|
||||
<div className={`${soehne.className} space-y-6`}>
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1 className='font-medium text-[32px] text-black tracking-tight'>Loading</h1>
|
||||
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex w-full items-center justify-center py-8'>
|
||||
<LoadingAgent size='lg' />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`${inter.className} auth-text-muted fixed right-0 bottom-0 left-0 z-50 pb-8 text-center font-[340] text-[13px] leading-relaxed`}
|
||||
>
|
||||
Need help?{' '}
|
||||
<a
|
||||
href='mailto:help@sim.ai'
|
||||
className='auth-link underline-offset-4 transition hover:underline'
|
||||
>
|
||||
Contact support
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -68,53 +114,69 @@ export function InviteStatusCard({
|
||||
const iconBg = icon ? iconBgMap[icon] : ''
|
||||
|
||||
return (
|
||||
<div className='flex w-full max-w-md flex-col items-center text-center'>
|
||||
{IconComponent && (
|
||||
<div className={`mb-6 rounded-full p-3 ${iconBg}`}>
|
||||
<IconComponent className={`h-8 w-8 ${iconColor}`} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h1 className='mb-2 font-semibold text-[32px] text-white tracking-tight'>{title}</h1>
|
||||
|
||||
<p className='mb-6 text-neutral-400 text-sm leading-relaxed'>{description}</p>
|
||||
|
||||
<div className='flex w-full flex-col gap-3'>
|
||||
{isExpiredError && (
|
||||
<Button
|
||||
variant='outline'
|
||||
className='h-11 w-full border-[var(--brand-primary-hex)] font-medium text-[var(--brand-primary-hex)] text-base transition-colors duration-200 hover:bg-[var(--brand-primary-hex)] hover:text-white'
|
||||
onClick={() => router.push('/')}
|
||||
>
|
||||
<RotateCcw className='mr-2 h-4 w-4' />
|
||||
Request New Invitation
|
||||
</Button>
|
||||
<div className={`${soehne.className} space-y-6`}>
|
||||
<div className='space-y-1 text-center'>
|
||||
{IconComponent && (
|
||||
<div className={`mx-auto mb-4 w-fit rounded-full p-3 ${iconBg}`}>
|
||||
<IconComponent className={`h-8 w-8 ${iconColor}`} />
|
||||
</div>
|
||||
)}
|
||||
<h1 className='font-medium text-[32px] text-black tracking-tight'>{title}</h1>
|
||||
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{actions.map((action, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
variant={action.variant || 'default'}
|
||||
className={
|
||||
(action.variant || 'default') === 'default'
|
||||
? 'h-11 w-full bg-brand-primary font-medium text-base text-white shadow-[var(--brand-primary-hex)]/20 shadow-lg transition-colors duration-200 hover:bg-brand-primary-hover'
|
||||
: action.variant === 'outline'
|
||||
? 'h-11 w-full border-[var(--brand-primary-hex)] font-medium text-[var(--brand-primary-hex)] text-base transition-colors duration-200 hover:bg-[var(--brand-primary-hex)] hover:text-white'
|
||||
: 'h-11 w-full text-muted-foreground hover:bg-secondary hover:text-foreground'
|
||||
}
|
||||
onClick={action.onClick}
|
||||
disabled={action.disabled || action.loading}
|
||||
>
|
||||
{action.loading ? (
|
||||
<>
|
||||
<LoadingAgent size='sm' />
|
||||
{action.label}...
|
||||
</>
|
||||
) : (
|
||||
action.label
|
||||
)}
|
||||
</Button>
|
||||
))}
|
||||
<div className={`${inter.className} mt-8 space-y-8`}>
|
||||
<div className='flex w-full flex-col gap-3'>
|
||||
{isExpiredError && (
|
||||
<Button
|
||||
variant='outline'
|
||||
className='w-full rounded-[10px] border-[var(--brand-primary-hex)] font-medium text-[15px] text-[var(--brand-primary-hex)] transition-colors duration-200 hover:bg-[var(--brand-primary-hex)] hover:text-white'
|
||||
onClick={() => router.push('/')}
|
||||
>
|
||||
<RotateCcw className='mr-2 h-4 w-4' />
|
||||
Request New Invitation
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{actions.map((action, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
variant={action.variant || 'default'}
|
||||
className={
|
||||
(action.variant || 'default') === 'default'
|
||||
? `${buttonClass} flex w-full items-center justify-center gap-2 rounded-[10px] border font-medium text-[15px] text-white transition-all duration-200`
|
||||
: action.variant === 'outline'
|
||||
? 'w-full rounded-[10px] border-[var(--brand-primary-hex)] font-medium text-[15px] text-[var(--brand-primary-hex)] transition-colors duration-200 hover:bg-[var(--brand-primary-hex)] hover:text-white'
|
||||
: 'w-full rounded-[10px] text-muted-foreground hover:bg-secondary hover:text-foreground'
|
||||
}
|
||||
onClick={action.onClick}
|
||||
disabled={action.disabled || action.loading}
|
||||
>
|
||||
{action.loading ? (
|
||||
<>
|
||||
<LoadingAgent size='sm' />
|
||||
{action.label}...
|
||||
</>
|
||||
) : (
|
||||
action.label
|
||||
)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`${inter.className} auth-text-muted fixed right-0 bottom-0 left-0 z-50 pb-8 text-center font-[340] text-[13px] leading-relaxed`}
|
||||
>
|
||||
Need help?{' '}
|
||||
<a
|
||||
href='mailto:help@sim.ai'
|
||||
className='auth-link underline-offset-4 transition hover:underline'
|
||||
>
|
||||
Contact support
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -5,14 +5,12 @@ import { PublicEnvScript } from 'next-runtime-env'
|
||||
import { BrandedLayout } from '@/components/branded-layout'
|
||||
import { generateThemeCSS } from '@/lib/branding/inject-theme'
|
||||
import { generateBrandedMetadata, generateStructuredData } from '@/lib/branding/metadata'
|
||||
import { env } from '@/lib/env'
|
||||
import { isHosted } from '@/lib/environment'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getAssetUrl } from '@/lib/utils'
|
||||
import '@/app/globals.css'
|
||||
|
||||
import { SessionProvider } from '@/lib/session/session-context'
|
||||
import { ThemeProvider } from '@/app/theme-provider'
|
||||
import { ConditionalThemeProvider } from '@/app/conditional-theme-provider'
|
||||
import { ZoomPrevention } from '@/app/zoom-prevention'
|
||||
|
||||
const logger = createLogger('RootLayout')
|
||||
@@ -86,44 +84,15 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Meta tags for better SEO */}
|
||||
{/* Basic head hints that are not covered by the Metadata API */}
|
||||
<meta name='color-scheme' content='light dark' />
|
||||
<meta name='format-detection' content='telephone=no' />
|
||||
<meta httpEquiv='x-ua-compatible' content='ie=edge' />
|
||||
|
||||
{/* Additional Open Graph tags */}
|
||||
<meta property='og:image:width' content='1200' />
|
||||
<meta property='og:image:height' content='630' />
|
||||
<meta
|
||||
property='og:image:alt'
|
||||
content='Sim - AI Agent Builder with Visual Canvas Interface'
|
||||
/>
|
||||
<meta property='og:site_name' content='Sim' />
|
||||
<meta property='og:locale' content='en_US' />
|
||||
|
||||
{/* Twitter Card tags */}
|
||||
<meta name='twitter:image:width' content='1200' />
|
||||
<meta name='twitter:image:height' content='675' />
|
||||
<meta name='twitter:image:alt' content='Sim - AI Agent Builder' />
|
||||
<meta name='twitter:url' content='https://sim.ai' />
|
||||
<meta name='twitter:domain' content='sim.ai' />
|
||||
|
||||
{/* Additional image sources */}
|
||||
<link rel='image_src' href={getAssetUrl('social/facebook.png')} />
|
||||
|
||||
<PublicEnvScript />
|
||||
|
||||
{/* RB2B Script - Only load on hosted version */}
|
||||
{isHosted && env.NEXT_PUBLIC_RB2B_KEY && (
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `!function () {var reb2b = window.reb2b = window.reb2b || [];if (reb2b.invoked) return;reb2b.invoked = true;reb2b.methods = ["identify", "collect"];reb2b.factory = function (method) {return function () {var args = Array.prototype.slice.call(arguments);args.unshift(method);reb2b.push(args);return reb2b;};};for (var i = 0; i < reb2b.methods.length; i++) {var key = reb2b.methods[i];reb2b[key] = reb2b.factory(key);}reb2b.load = function (key) {var script = document.createElement("script");script.type = "text/javascript";script.async = true;script.src = "https://b2bjsstore.s3.us-west-2.amazonaws.com/b/" + key + "/${env.NEXT_PUBLIC_RB2B_KEY}.js.gz";var first = document.getElementsByTagName("script")[0];first.parentNode.insertBefore(script, first);};reb2b.SNIPPET_VERSION = "1.0.1";reb2b.load("${env.NEXT_PUBLIC_RB2B_KEY}");}();`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</head>
|
||||
<body suppressHydrationWarning>
|
||||
<ThemeProvider>
|
||||
<ConditionalThemeProvider>
|
||||
<SessionProvider>
|
||||
<BrandedLayout>
|
||||
<ZoomPrevention />
|
||||
@@ -136,7 +105,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
)}
|
||||
</BrandedLayout>
|
||||
</SessionProvider>
|
||||
</ThemeProvider>
|
||||
</ConditionalThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
export async function GET() {
|
||||
const llmsContent = `# Sim - AI Agent Workflow Builder
|
||||
Visual platform for building and deploying AI agent workflows
|
||||
|
||||
## Overview
|
||||
Sim is a platform to build, prototype, and deploy AI agent workflows. It's the fastest-growing platform for building AI agent workflows.
|
||||
Sim is an open-source AI agent workflow builder. Developers at trail-blazing startups to Fortune 500 companies deploy agentic workflows on the Sim platform.
|
||||
30,000+ developers are already using Sim to build and deploy AI agent workflows.
|
||||
Sim lets developers integrate with 100+ apps to streamline workflows with AI agents. Sim is SOC2 and HIPAA compliant, ensuring enterprise-level security.
|
||||
|
||||
## Key Features
|
||||
- Visual Workflow Builder: Drag-and-drop interface for creating AI agent workflows
|
||||
|
||||
@@ -5,14 +5,15 @@ export default function manifest(): MetadataRoute.Manifest {
|
||||
const brand = getBrandConfig()
|
||||
|
||||
return {
|
||||
name: brand.name,
|
||||
name: brand.name === 'Sim' ? 'Sim - AI Agent Workflow Builder' : brand.name,
|
||||
short_name: brand.name,
|
||||
description:
|
||||
'Build and deploy AI agents using our Figma-like canvas. Build, write evals, and deploy AI agent workflows that automate workflows and streamline your business processes.',
|
||||
'Open-source AI agent workflow builder. 30,000+ developers build and deploy agentic workflows on Sim. Visual drag-and-drop interface for creating AI automations. SOC2 and HIPAA compliant.',
|
||||
start_url: '/',
|
||||
display: 'standalone',
|
||||
background_color: brand.theme?.backgroundColor || '#701FFC',
|
||||
theme_color: brand.theme?.primaryColor || '#701FFC',
|
||||
background_color: '#ffffff',
|
||||
theme_color: brand.theme?.primaryColor || '#6F3DFA',
|
||||
orientation: 'portrait-primary',
|
||||
icons: [
|
||||
{
|
||||
src: '/favicon/android-chrome-192x192.png',
|
||||
@@ -24,6 +25,23 @@ export default function manifest(): MetadataRoute.Manifest {
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
},
|
||||
{
|
||||
src: '/favicon/apple-touch-icon.png',
|
||||
sizes: '180x180',
|
||||
type: 'image/png',
|
||||
},
|
||||
],
|
||||
categories: ['productivity', 'developer', 'business'],
|
||||
shortcuts: [
|
||||
{
|
||||
name: 'Create Workflow',
|
||||
short_name: 'New',
|
||||
description: 'Create a new AI workflow',
|
||||
url: '/workspace',
|
||||
icons: [{ src: '/icons/new-workflow.png', sizes: '192x192' }],
|
||||
},
|
||||
],
|
||||
lang: 'en-US',
|
||||
dir: 'ltr',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,89 @@
|
||||
import type { Metadata } from 'next'
|
||||
import Landing from '@/app/(landing)/landing'
|
||||
|
||||
export default Landing
|
||||
export const metadata: Metadata = {
|
||||
title: 'Sim - AI Agent Workflow Builder | Open Source Platform',
|
||||
description:
|
||||
'Open-source AI agent workflow builder used by 30,000+ developers. Build and deploy agentic workflows with visual drag-and-drop interface. Connect 100+ apps. SOC2 and HIPAA compliant. Used by startups to Fortune 500 companies.',
|
||||
keywords:
|
||||
'AI agent workflow builder, agentic workflows, open source AI, visual workflow builder, AI automation, LLM workflows, AI agents, workflow automation, no-code AI, SOC2 compliant, HIPAA compliant, enterprise AI',
|
||||
authors: [{ name: 'Sim Studio' }],
|
||||
creator: 'Sim Studio',
|
||||
publisher: 'Sim Studio',
|
||||
formatDetection: {
|
||||
email: false,
|
||||
address: false,
|
||||
telephone: false,
|
||||
},
|
||||
openGraph: {
|
||||
title: 'Sim - AI Agent Workflow Builder | Open Source',
|
||||
description:
|
||||
'Open-source platform used by 30,000+ developers. Build and deploy agentic workflows with drag-and-drop interface. SOC2 & HIPAA compliant. Connect 100+ apps.',
|
||||
type: 'website',
|
||||
url: 'https://sim.ai',
|
||||
siteName: 'Sim',
|
||||
locale: 'en_US',
|
||||
images: [
|
||||
{
|
||||
url: '/social/og-image.png',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: 'Sim - Visual AI Workflow Builder',
|
||||
type: 'image/png',
|
||||
},
|
||||
{
|
||||
url: '/social/og-image-square.png',
|
||||
width: 600,
|
||||
height: 600,
|
||||
alt: 'Sim Logo',
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
site: '@simdotai',
|
||||
creator: '@simdotai',
|
||||
title: 'Sim - AI Agent Workflow Builder | Open Source',
|
||||
description:
|
||||
'Open-source platform for agentic workflows. 30,000+ developers. Visual builder. 100+ integrations. SOC2 & HIPAA compliant.',
|
||||
images: {
|
||||
url: '/social/twitter-image.png',
|
||||
alt: 'Sim - Visual AI Workflow Builder',
|
||||
},
|
||||
},
|
||||
alternates: {
|
||||
canonical: 'https://sim.ai',
|
||||
languages: {
|
||||
'en-US': 'https://sim.ai',
|
||||
},
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
nocache: false,
|
||||
googleBot: {
|
||||
index: true,
|
||||
follow: true,
|
||||
noimageindex: false,
|
||||
'max-video-preview': -1,
|
||||
'max-image-preview': 'large',
|
||||
'max-snippet': -1,
|
||||
},
|
||||
},
|
||||
category: 'technology',
|
||||
classification: 'AI Development Tools',
|
||||
referrer: 'origin-when-cross-origin',
|
||||
// LLM SEO optimizations
|
||||
other: {
|
||||
'llm:content-type': 'AI workflow builder, visual programming, no-code AI development',
|
||||
'llm:use-cases':
|
||||
'email automation, slack bots, discord moderation, data analysis, customer support, content generation',
|
||||
'llm:integrations':
|
||||
'OpenAI, Anthropic, Google AI, Slack, Gmail, Discord, Notion, Airtable, Supabase',
|
||||
'llm:pricing': 'free tier available, pro $20/month, team $40/month, enterprise custom',
|
||||
},
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
return <Landing />
|
||||
}
|
||||
|
||||
44
apps/sim/app/sitemap.ts
Normal file
44
apps/sim/app/sitemap.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { MetadataRoute } from 'next'
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
const baseUrl = 'https://sim.ai'
|
||||
|
||||
// Static pages
|
||||
const staticPages = [
|
||||
{
|
||||
url: baseUrl,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'daily' as const,
|
||||
priority: 1,
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/signup`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'weekly' as const,
|
||||
priority: 0.9,
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/login`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'monthly' as const,
|
||||
priority: 0.8,
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/terms`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'monthly' as const,
|
||||
priority: 0.5,
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/privacy`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'monthly' as const,
|
||||
priority: 0.5,
|
||||
},
|
||||
]
|
||||
|
||||
// You can add dynamic pages here by fetching from database
|
||||
// const dynamicPages = await fetchDynamicPages()
|
||||
|
||||
return [...staticPages]
|
||||
}
|
||||
@@ -247,23 +247,3 @@ export function General() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const SettingRowSkeleton = ({
|
||||
hasInfoButton = false,
|
||||
isSwitch = false,
|
||||
}: {
|
||||
hasInfoButton?: boolean
|
||||
isSwitch?: boolean
|
||||
}) => (
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Skeleton className='h-5 w-32' />
|
||||
{hasInfoButton && <Skeleton className='h-5 w-5 rounded' />}
|
||||
</div>
|
||||
{isSwitch ? (
|
||||
<Skeleton className='h-6 w-11 rounded-full' />
|
||||
) : (
|
||||
<Skeleton className='h-9 w-[180px]' />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
Bot,
|
||||
CreditCard,
|
||||
FileCode,
|
||||
Home,
|
||||
Key,
|
||||
Server,
|
||||
Settings,
|
||||
@@ -129,38 +130,59 @@ export function SettingsNavigation({
|
||||
return true
|
||||
})
|
||||
|
||||
const handleHomepageClick = () => {
|
||||
window.location.href = '/homepage'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='px-2 py-4'>
|
||||
{navigationItems.map((item) => (
|
||||
<div key={item.id} className='mb-1'>
|
||||
<button
|
||||
onClick={() => onSectionChange(item.id)}
|
||||
className={cn(
|
||||
'group flex h-9 w-full cursor-pointer items-center rounded-[8px] px-2 py-2 font-medium font-sans text-sm transition-colors',
|
||||
activeSection === item.id ? 'bg-muted' : 'hover:bg-muted'
|
||||
)}
|
||||
>
|
||||
<item.icon
|
||||
<div className='flex h-full flex-col'>
|
||||
<div className='flex-1 px-2 py-4'>
|
||||
{navigationItems.map((item) => (
|
||||
<div key={item.id} className='mb-1'>
|
||||
<button
|
||||
onClick={() => onSectionChange(item.id)}
|
||||
className={cn(
|
||||
'mr-2 h-[14px] w-[14px] flex-shrink-0 transition-colors',
|
||||
activeSection === item.id
|
||||
? 'text-foreground'
|
||||
: 'text-muted-foreground group-hover:text-foreground'
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'min-w-0 flex-1 select-none truncate pr-1 text-left transition-colors',
|
||||
activeSection === item.id
|
||||
? 'text-foreground'
|
||||
: 'text-muted-foreground group-hover:text-foreground'
|
||||
'group flex h-9 w-full cursor-pointer items-center rounded-[8px] px-2 py-2 font-medium font-sans text-sm transition-colors',
|
||||
activeSection === item.id ? 'bg-muted' : 'hover:bg-muted'
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
<item.icon
|
||||
className={cn(
|
||||
'mr-2 h-[14px] w-[14px] flex-shrink-0 transition-colors',
|
||||
activeSection === item.id
|
||||
? 'text-foreground'
|
||||
: 'text-muted-foreground group-hover:text-foreground'
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'min-w-0 flex-1 select-none truncate pr-1 text-left transition-colors',
|
||||
activeSection === item.id
|
||||
? 'text-foreground'
|
||||
: 'text-muted-foreground group-hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Homepage link - Only show in hosted environments */}
|
||||
{isHosted && (
|
||||
<div className='px-2 pb-4'>
|
||||
<button
|
||||
onClick={handleHomepageClick}
|
||||
className='group flex h-9 w-full cursor-pointer items-center rounded-[8px] px-2 py-2 font-medium font-sans text-sm transition-colors hover:bg-muted'
|
||||
>
|
||||
<Home className='mr-2 h-[14px] w-[14px] flex-shrink-0 text-muted-foreground transition-colors group-hover:text-foreground' />
|
||||
<span className='min-w-0 flex-1 select-none truncate pr-1 text-left text-muted-foreground transition-colors group-hover:text-foreground'>
|
||||
Homepage
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,3 +1,13 @@
|
||||
// Helper to detect if background is dark
|
||||
function isDarkBackground(hexColor: string): boolean {
|
||||
const hex = hexColor.replace('#', '')
|
||||
const r = Number.parseInt(hex.substr(0, 2), 16)
|
||||
const g = Number.parseInt(hex.substr(2, 2), 16)
|
||||
const b = Number.parseInt(hex.substr(4, 2), 16)
|
||||
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
|
||||
return luminance < 0.5
|
||||
}
|
||||
|
||||
export function generateThemeCSS(): string {
|
||||
const cssVars: string[] = []
|
||||
|
||||
@@ -23,6 +33,12 @@ export function generateThemeCSS(): string {
|
||||
|
||||
if (process.env.NEXT_PUBLIC_BRAND_BACKGROUND_COLOR) {
|
||||
cssVars.push(`--brand-background-hex: ${process.env.NEXT_PUBLIC_BRAND_BACKGROUND_COLOR};`)
|
||||
|
||||
// Add dark theme class when background is dark
|
||||
const isDark = isDarkBackground(process.env.NEXT_PUBLIC_BRAND_BACKGROUND_COLOR)
|
||||
if (isDark) {
|
||||
cssVars.push(`--brand-is-dark: 1;`)
|
||||
}
|
||||
}
|
||||
|
||||
return cssVars.length > 0 ? `:root { ${cssVars.join(' ')} }` : ''
|
||||
|
||||
@@ -10,14 +10,15 @@ export function generateBrandedMetadata(override: Partial<Metadata> = {}): Metad
|
||||
const brand = getBrandConfig()
|
||||
|
||||
const defaultTitle = brand.name
|
||||
const defaultDescription = `Build and deploy AI agents using our Figma-like canvas. Build, write evals, and deploy AI agent workflows that automate workflows and streamline your business processes.`
|
||||
const summaryFull = `Sim is an open-source AI agent workflow builder. Developers at trail-blazing startups to Fortune 500 companies deploy agentic workflows on the Sim platform. 30,000+ developers are already using Sim to build and deploy AI agent workflows. Sim lets developers integrate with 100+ apps to streamline workflows with AI agents. Sim is SOC2 and HIPAA compliant, ensuring enterprise-level security.`
|
||||
const summaryShort = `Sim is an open-source AI agent workflow builder.`
|
||||
|
||||
return {
|
||||
title: {
|
||||
template: `%s | ${brand.name}`,
|
||||
default: defaultTitle,
|
||||
},
|
||||
description: defaultDescription,
|
||||
description: summaryShort,
|
||||
applicationName: brand.name,
|
||||
authors: [{ name: brand.name }],
|
||||
generator: 'Next.js',
|
||||
@@ -65,7 +66,7 @@ export function generateBrandedMetadata(override: Partial<Metadata> = {}): Metad
|
||||
locale: 'en_US',
|
||||
url: env.NEXT_PUBLIC_APP_URL || 'https://sim.ai',
|
||||
title: defaultTitle,
|
||||
description: defaultDescription,
|
||||
description: summaryFull,
|
||||
siteName: brand.name,
|
||||
images: [
|
||||
{
|
||||
@@ -79,7 +80,7 @@ export function generateBrandedMetadata(override: Partial<Metadata> = {}): Metad
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: defaultTitle,
|
||||
description: defaultDescription,
|
||||
description: summaryFull,
|
||||
images: [brand.logoUrl || getAssetUrl('social/twitter.png')],
|
||||
creator: '@simstudioai',
|
||||
site: '@simstudioai',
|
||||
@@ -132,7 +133,7 @@ export function generateStructuredData() {
|
||||
'@type': 'SoftwareApplication',
|
||||
name: 'Sim',
|
||||
description:
|
||||
'Build and deploy AI agents using our Figma-like canvas. Build, write evals, and deploy AI agent workflows that automate workflows and streamline your business processes.',
|
||||
'Sim is an open-source AI agent workflow builder. Developers at trail-blazing startups to Fortune 500 companies deploy agentic workflows on the Sim platform. 30,000+ developers are already using Sim to build and deploy AI agent workflows. Sim lets developers integrate with 100+ apps to streamline workflows with AI agents. Sim is SOC2 and HIPAA compliant, ensuring enterprise-level security.',
|
||||
url: 'https://sim.ai',
|
||||
applicationCategory: 'BusinessApplication',
|
||||
operatingSystem: 'Web Browser',
|
||||
|
||||
@@ -216,7 +216,6 @@ export const env = createEnv({
|
||||
NEXT_PUBLIC_GOOGLE_CLIENT_ID: z.string().optional(), // Google OAuth client ID for browser auth
|
||||
|
||||
// Analytics & Tracking
|
||||
NEXT_PUBLIC_RB2B_KEY: z.string().optional(), // RB2B tracking key for B2B analytics
|
||||
NEXT_PUBLIC_GOOGLE_API_KEY: z.string().optional(), // Google API key for client-side API calls
|
||||
NEXT_PUBLIC_GOOGLE_PROJECT_NUMBER: z.string().optional(), // Google project number for Drive picker
|
||||
|
||||
@@ -257,7 +256,6 @@ export const env = createEnv({
|
||||
NEXT_PUBLIC_BLOB_BASE_URL: process.env.NEXT_PUBLIC_BLOB_BASE_URL,
|
||||
NEXT_PUBLIC_BILLING_ENABLED: process.env.NEXT_PUBLIC_BILLING_ENABLED,
|
||||
NEXT_PUBLIC_GOOGLE_CLIENT_ID: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID,
|
||||
NEXT_PUBLIC_RB2B_KEY: process.env.NEXT_PUBLIC_RB2B_KEY,
|
||||
NEXT_PUBLIC_GOOGLE_API_KEY: process.env.NEXT_PUBLIC_GOOGLE_API_KEY,
|
||||
NEXT_PUBLIC_GOOGLE_PROJECT_NUMBER: process.env.NEXT_PUBLIC_GOOGLE_PROJECT_NUMBER,
|
||||
NEXT_PUBLIC_SOCKET_URL: process.env.NEXT_PUBLIC_SOCKET_URL,
|
||||
|
||||
@@ -72,15 +72,25 @@ export async function middleware(request: NextRequest) {
|
||||
return NextResponse.rewrite(new URL(`/chat/${subdomain}${url.pathname}`, request.url))
|
||||
}
|
||||
|
||||
// For self-hosted deployments, redirect root path based on session status
|
||||
// Handle root path redirects based on session status and hosting type
|
||||
// Only apply redirects to the main domain, not subdomains
|
||||
if (!isHosted && !isCustomDomain && url.pathname === '/') {
|
||||
if (hasActiveSession) {
|
||||
// User has active session, redirect to workspace
|
||||
if (!isCustomDomain && (url.pathname === '/' || url.pathname === '/homepage')) {
|
||||
if (!isHosted) {
|
||||
// Self-hosted: Always redirect based on session
|
||||
if (hasActiveSession) {
|
||||
return NextResponse.redirect(new URL('/workspace', request.url))
|
||||
}
|
||||
return NextResponse.redirect(new URL('/login', request.url))
|
||||
}
|
||||
// Hosted: Allow access to /homepage route even for authenticated users
|
||||
if (url.pathname === '/homepage') {
|
||||
return NextResponse.rewrite(new URL('/', request.url))
|
||||
}
|
||||
|
||||
// For root path, redirect authenticated users to workspace
|
||||
if (hasActiveSession && url.pathname === '/') {
|
||||
return NextResponse.redirect(new URL('/workspace', request.url))
|
||||
}
|
||||
// User doesn't have active session, redirect to login
|
||||
return NextResponse.redirect(new URL('/login', request.url))
|
||||
}
|
||||
|
||||
// Handle whitelabel redirects for terms and privacy pages
|
||||
@@ -114,6 +124,14 @@ export async function middleware(request: NextRequest) {
|
||||
return NextResponse.redirect(new URL('/workspace', request.url))
|
||||
}
|
||||
|
||||
// Handle login page - redirect authenticated users to workspace
|
||||
if (url.pathname === '/login' || url.pathname === '/signup') {
|
||||
if (hasActiveSession) {
|
||||
return NextResponse.redirect(new URL('/workspace', request.url))
|
||||
}
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
// Handle protected routes that require authentication
|
||||
if (url.pathname.startsWith('/workspace')) {
|
||||
if (!hasActiveSession) {
|
||||
@@ -218,6 +236,7 @@ export const config = {
|
||||
'/login',
|
||||
'/signup',
|
||||
'/invite/:path*', // Match invitation routes
|
||||
'/((?!_next/static|_next/image|favicon.ico).*)',
|
||||
// Catch-all for other pages, excluding static assets and public directories
|
||||
'/((?!_next/static|_next/image|favicon.ico|logo/|static/|footer/|social/|enterprise/|favicon/|twitter/|robots.txt|sitemap.xml).*)',
|
||||
],
|
||||
}
|
||||
|
||||
BIN
apps/sim/public/footer/soc2.png
Normal file
BIN
apps/sim/public/footer/soc2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 244 KiB |
5
apps/sim/public/logo/b&w/text/b&w.svg
Normal file
5
apps/sim/public/logo/b&w/text/b&w.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg width="58" height="29" viewBox="0 0 58 29" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0 21.9401H3.83372C3.83372 22.9908 4.2171 23.8285 4.98384 24.4533C5.75059 25.0496 6.78711 25.3478 8.09342 25.3478C9.51332 25.3478 10.6066 25.078 11.3734 24.5385C12.1401 23.9705 12.5235 23.218 12.5235 22.2808C12.5235 21.5993 12.3105 21.0313 11.8845 20.577C11.487 20.1226 10.7486 19.7534 9.66951 19.4695L6.00617 18.6175C4.1603 18.1631 2.783 17.4674 1.87426 16.5303C0.993928 15.5931 0.55376 14.3578 0.55376 12.8243C0.55376 11.5464 0.880337 10.4389 1.53349 9.50177C2.21504 8.56464 3.13797 7.84049 4.30229 7.32933C5.495 6.81816 6.85811 6.56258 8.3916 6.56258C9.92509 6.56258 11.2456 6.83236 12.3531 7.37192C13.489 7.91148 14.3694 8.66403 14.9941 9.62956C15.6473 10.5951 15.9881 11.7452 16.0164 13.0799H12.1827C12.1543 12.0008 11.7994 11.163 11.1178 10.5667C10.4362 9.97033 9.48492 9.67216 8.26381 9.67216C7.0143 9.67216 6.04876 9.94194 5.36721 10.4815C4.68566 11.0211 4.34489 11.7594 4.34489 12.6965C4.34489 14.088 5.36721 15.0394 7.41187 15.5505L11.0752 16.4451C12.8359 16.8426 14.1564 17.4958 15.0367 18.4045C15.9171 19.2849 16.3572 20.4918 16.3572 22.0253C16.3572 23.3316 16.0022 24.4817 15.2923 25.4756C14.5823 26.4411 13.6026 27.1937 12.3531 27.7333C11.132 28.2444 9.6837 28.5 8.00822 28.5C5.566 28.5 3.62074 27.9036 2.17244 26.7109C0.724148 25.5182 0 23.9279 0 21.9401Z" fill="black"/>
|
||||
<path d="M19.1766 27.9888V7.15894C20.7732 7.7384 21.4772 7.7384 23.1807 7.15894V27.9888H19.1766ZM21.136 5.78202C20.4261 5.78202 19.8013 5.52644 19.2618 5.01528C18.7506 4.47571 18.495 3.85096 18.495 3.14101C18.495 2.40266 18.7506 1.77791 19.2618 1.26675C19.8013 0.755582 20.4261 0.5 21.136 0.5C21.8744 0.5 22.4991 0.755582 23.0103 1.26675C23.5215 1.77791 23.777 2.40266 23.777 3.14101C23.777 3.85096 23.5215 4.47571 23.0103 5.01528C22.4991 5.52644 21.8744 5.78202 21.136 5.78202Z" fill="black"/>
|
||||
<path d="M30.2434 27.9888H26.2393V7.15894H29.8174V10.6735C30.2434 9.50919 31.067 8.52204 32.2029 7.75529C33.3672 6.96015 34.7729 6.56258 36.42 6.56258C38.2658 6.56258 39.7993 7.05954 41.0204 8.05347C42.2416 9.0474 43.0367 10.3679 43.4059 12.015H42.6817C42.9657 10.3679 43.7466 9.0474 45.0246 8.05347C46.3025 7.05954 47.8785 6.56258 49.7528 6.56258C52.1382 6.56258 54.0125 7.25833 55.3756 8.64983C56.7387 10.0413 57.4203 11.944 57.4203 14.3578V27.9888H53.5013V15.3375C53.5013 13.6905 53.0754 12.4268 52.2234 11.5464C51.3999 10.6377 50.2782 10.1833 48.8583 10.1833C47.8643 10.1833 46.984 10.4105 46.2173 10.8649C45.4789 11.2908 44.8968 11.9156 44.4708 12.7391C44.0448 13.5627 43.8318 14.5282 43.8318 15.6357V27.9888H39.8703V15.295C39.8703 13.6479 39.4585 12.3984 38.635 11.5464C37.8115 10.6661 36.6897 10.2259 35.2699 10.2259C34.2759 10.2259 33.3956 10.4531 32.6288 10.9075C31.8905 11.3334 31.3083 11.9582 30.8824 12.7817C30.4564 13.5769 30.2434 14.5282 30.2434 15.6357V27.9888Z" fill="black"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
@@ -1,28 +1,41 @@
|
||||
# Allow all AI crawlers
|
||||
User-agent: GPTBot
|
||||
Allow: /
|
||||
# robots.txt for https://sim.ai
|
||||
|
||||
User-agent: OAI-SearchBot
|
||||
# Allow all crawlers
|
||||
User-agent: *
|
||||
Allow: /
|
||||
Disallow: /api/
|
||||
Disallow: /workspace/
|
||||
Disallow: /_next/
|
||||
Disallow: /private/
|
||||
Disallow: /*.json$
|
||||
|
||||
# Specific crawler rules
|
||||
User-agent: Googlebot
|
||||
Allow: /
|
||||
Crawl-delay: 0
|
||||
|
||||
User-agent: Bingbot
|
||||
Allow: /
|
||||
Crawl-delay: 1
|
||||
|
||||
# AI/LLM crawlers
|
||||
User-agent: GPTBot
|
||||
Allow: /
|
||||
|
||||
User-agent: ChatGPT-User
|
||||
Allow: /
|
||||
|
||||
# Allow other AI crawlers
|
||||
User-agent: CCBot
|
||||
Allow: /
|
||||
|
||||
User-agent: anthropic-ai
|
||||
Allow: /
|
||||
|
||||
User-agent: Claude-Web
|
||||
Allow: /
|
||||
|
||||
User-agent: PerplexityBot
|
||||
Allow: /
|
||||
# Sitemap location
|
||||
Sitemap: https://sim.ai/sitemap.xml
|
||||
|
||||
User-agent: Applebot-Extended
|
||||
Allow: /
|
||||
|
||||
# Traditional search engines
|
||||
User-agent: *
|
||||
Allow: /
|
||||
Disallow: /api/
|
||||
Disallow: /workspace/
|
||||
Disallow: /chat/
|
||||
Disallow: /.well-known/
|
||||
Disallow: /invite/
|
||||
# Host
|
||||
Host: https://sim.ai
|
||||
@@ -160,6 +160,22 @@ export default {
|
||||
height: '0',
|
||||
},
|
||||
},
|
||||
'slide-left': {
|
||||
'0%': {
|
||||
transform: 'translateX(0)',
|
||||
},
|
||||
'100%': {
|
||||
transform: 'translateX(-50%)',
|
||||
},
|
||||
},
|
||||
'slide-right': {
|
||||
'0%': {
|
||||
transform: 'translateX(-50%)',
|
||||
},
|
||||
'100%': {
|
||||
transform: 'translateX(0)',
|
||||
},
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
'slide-down': 'slide-down 0.3s ease-out',
|
||||
@@ -172,6 +188,8 @@ export default {
|
||||
'pulse-slow': 'pulse-slow 3s ease-in-out infinite',
|
||||
'accordion-down': 'accordion-down 0.2s ease-out',
|
||||
'accordion-up': 'accordion-up 0.2s ease-out',
|
||||
'slide-left': 'slide-left 80s linear infinite',
|
||||
'slide-right': 'slide-right 80s linear infinite',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user