mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
560fa75155 | ||
|
|
1728c370de | ||
|
|
82e58a5082 | ||
|
|
336c065234 | ||
|
|
b3713642b2 | ||
|
|
b9b930bb63 | ||
|
|
f1ead2ed55 | ||
|
|
30377d775b | ||
|
|
d013132d0e | ||
|
|
7b0ce8064a | ||
|
|
0ea73263df | ||
|
|
edc502384b | ||
|
|
e2be99263c | ||
|
|
f6b461ad47 | ||
|
|
e4d35735b1 | ||
|
|
b4064c57fb | ||
|
|
eac41ca105 | ||
|
|
d2c3c1c39e | ||
|
|
8f3e864751 | ||
|
|
23c3072784 | ||
|
|
33fdb11396 | ||
|
|
21156dd54a | ||
|
|
c05e2e0fc8 | ||
|
|
a7c1e510e6 | ||
|
|
271624a402 | ||
|
|
dda012eae9 | ||
|
|
2dd6d3d1e6 | ||
|
|
14089f7dbb | ||
|
|
b90bb75cda | ||
|
|
fb233d003d | ||
|
|
34df3333d1 | ||
|
|
23677d41a0 | ||
|
|
a489f91085 | ||
|
|
ed6e7845cc | ||
|
|
e698f9fe14 | ||
|
|
db1798267e | ||
|
|
e615816dce | ||
|
|
5f1d5e0618 | ||
|
|
ed5645166e | ||
|
|
50e42c2041 | ||
|
|
e70e1ec8c5 | ||
|
|
4c474e03c1 | ||
|
|
b0980b1e09 | ||
|
|
66ce673629 | ||
|
|
f37e4b67c7 | ||
|
|
7a1a46067d | ||
|
|
bf60670c0b | ||
|
|
8a481b612d | ||
|
|
bc4b7f5759 | ||
|
|
5aa0b4d5d4 | ||
|
|
3774f33d39 | ||
|
|
3597eacdb7 | ||
|
|
ca87d7ce29 | ||
|
|
c5fe92567a | ||
|
|
36bc57f0b9 |
@@ -71,12 +71,14 @@ export const {service}Connector: ConnectorConfig = {
|
||||
],
|
||||
|
||||
listDocuments: async (accessToken, sourceConfig, cursor) => {
|
||||
// Paginate via cursor, extract text, compute SHA-256 hash
|
||||
// Return metadata stubs with contentDeferred: true (if per-doc content fetch needed)
|
||||
// Or full documents with content (if list API returns content inline)
|
||||
// Return { documents: ExternalDocument[], nextCursor?, hasMore }
|
||||
},
|
||||
|
||||
getDocument: async (accessToken, sourceConfig, externalId) => {
|
||||
// Return ExternalDocument or null
|
||||
// Fetch full content for a single document
|
||||
// Return ExternalDocument with contentDeferred: false, or null
|
||||
},
|
||||
|
||||
validateConfig: async (accessToken, sourceConfig) => {
|
||||
@@ -281,26 +283,110 @@ Every document returned from `listDocuments`/`getDocument` must include:
|
||||
{
|
||||
externalId: string // Source-specific unique ID
|
||||
title: string // Document title
|
||||
content: string // Extracted plain text
|
||||
content: string // Extracted plain text (or '' if contentDeferred)
|
||||
contentDeferred?: boolean // true = content will be fetched via getDocument
|
||||
mimeType: 'text/plain' // Always text/plain (content is extracted)
|
||||
contentHash: string // SHA-256 of content (change detection)
|
||||
contentHash: string // Metadata-based hash for change detection
|
||||
sourceUrl?: string // Link back to original (stored on document record)
|
||||
metadata?: Record<string, unknown> // Source-specific data (fed to mapTags)
|
||||
}
|
||||
```
|
||||
|
||||
## Content Hashing (Required)
|
||||
## Content Deferral (Required for file/content-download connectors)
|
||||
|
||||
The sync engine uses content hashes for change detection:
|
||||
**All connectors that require per-document API calls to fetch content MUST use `contentDeferred: true`.** This is the standard pattern — `listDocuments` returns lightweight metadata stubs, and content is fetched lazily by the sync engine via `getDocument` only for new/changed documents.
|
||||
|
||||
This pattern is critical for reliability: the sync engine processes documents in batches and enqueues each batch for processing immediately. If a sync times out, all previously-batched documents are already queued. Without deferral, content downloads during listing can exhaust the sync task's time budget before any documents are saved.
|
||||
|
||||
### When to use `contentDeferred: true`
|
||||
|
||||
- The service's list API does NOT return document content (only metadata)
|
||||
- Content requires a separate download/export API call per document
|
||||
- Examples: Google Drive, OneDrive, SharePoint, Dropbox, Notion, Confluence, Gmail, Obsidian, Evernote, GitHub
|
||||
|
||||
### When NOT to use `contentDeferred`
|
||||
|
||||
- The list API already returns the full content inline (e.g., Slack messages, Reddit posts, HubSpot notes)
|
||||
- No per-document API call is needed to get content
|
||||
|
||||
### Content Hash Strategy
|
||||
|
||||
Use a **metadata-based** `contentHash` — never a content-based hash. The hash must be derivable from the list response metadata alone, so the sync engine can detect changes without downloading content.
|
||||
|
||||
Good metadata hash sources:
|
||||
- `modifiedTime` / `lastModifiedDateTime` — changes when file is edited
|
||||
- Git blob SHA — unique per content version
|
||||
- API-provided content hash (e.g., Dropbox `content_hash`)
|
||||
- Version number (e.g., Confluence page version)
|
||||
|
||||
Format: `{service}:{id}:{changeIndicator}`
|
||||
|
||||
```typescript
|
||||
async function computeContentHash(content: string): Promise<string> {
|
||||
const data = new TextEncoder().encode(content)
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', data)
|
||||
return Array.from(new Uint8Array(hashBuffer)).map(b => b.toString(16).padStart(2, '0')).join('')
|
||||
// Google Drive: modifiedTime changes on edit
|
||||
contentHash: `gdrive:${file.id}:${file.modifiedTime ?? ''}`
|
||||
|
||||
// GitHub: blob SHA is a content-addressable hash
|
||||
contentHash: `gitsha:${item.sha}`
|
||||
|
||||
// Dropbox: API provides content_hash
|
||||
contentHash: `dropbox:${entry.id}:${entry.content_hash ?? entry.server_modified}`
|
||||
|
||||
// Confluence: version number increments on edit
|
||||
contentHash: `confluence:${page.id}:${page.version.number}`
|
||||
```
|
||||
|
||||
**Critical invariant:** The `contentHash` MUST be identical whether produced by `listDocuments` (stub) or `getDocument` (full doc). Both should use the same stub function to guarantee this.
|
||||
|
||||
### Implementation Pattern
|
||||
|
||||
```typescript
|
||||
// 1. Create a stub function (sync, no API calls)
|
||||
function fileToStub(file: ServiceFile): ExternalDocument {
|
||||
return {
|
||||
externalId: file.id,
|
||||
title: file.name || 'Untitled',
|
||||
content: '',
|
||||
contentDeferred: true,
|
||||
mimeType: 'text/plain',
|
||||
sourceUrl: `https://service.com/file/${file.id}`,
|
||||
contentHash: `service:${file.id}:${file.modifiedTime ?? ''}`,
|
||||
metadata: { /* fields needed by mapTags */ },
|
||||
}
|
||||
}
|
||||
|
||||
// 2. listDocuments returns stubs (fast, metadata only)
|
||||
listDocuments: async (accessToken, sourceConfig, cursor) => {
|
||||
const response = await fetchWithRetry(listUrl, { ... })
|
||||
const files = (await response.json()).files
|
||||
const documents = files.map(fileToStub)
|
||||
return { documents, nextCursor, hasMore }
|
||||
}
|
||||
|
||||
// 3. getDocument fetches content and returns full doc with SAME contentHash
|
||||
getDocument: async (accessToken, sourceConfig, externalId) => {
|
||||
const metadata = await fetchWithRetry(metadataUrl, { ... })
|
||||
const file = await metadata.json()
|
||||
if (file.trashed) return null
|
||||
|
||||
try {
|
||||
const content = await fetchContent(accessToken, file)
|
||||
if (!content.trim()) return null
|
||||
const stub = fileToStub(file)
|
||||
return { ...stub, content, contentDeferred: false }
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to fetch content for: ${file.name}`, { error })
|
||||
return null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Reference Implementations
|
||||
|
||||
- **Google Drive**: `connectors/google-drive/google-drive.ts` — file download/export with `modifiedTime` hash
|
||||
- **GitHub**: `connectors/github/github.ts` — git blob SHA hash
|
||||
- **Notion**: `connectors/notion/notion.ts` — blocks API with `last_edited_time` hash
|
||||
- **Confluence**: `connectors/confluence/confluence.ts` — version number hash
|
||||
|
||||
## tagDefinitions — Declared Tag Definitions
|
||||
|
||||
Declare which tags the connector populates using semantic IDs. Shown in the add-connector modal as opt-out checkboxes.
|
||||
@@ -409,7 +495,10 @@ export const CONNECTOR_REGISTRY: ConnectorRegistry = {
|
||||
|
||||
## Reference Implementations
|
||||
|
||||
- **OAuth**: `apps/sim/connectors/confluence/confluence.ts` — multiple config field types, `mapTags`, label fetching
|
||||
- **OAuth + contentDeferred**: `apps/sim/connectors/google-drive/google-drive.ts` — file download with metadata-based hash, `orderBy` for deterministic pagination
|
||||
- **OAuth + contentDeferred (blocks API)**: `apps/sim/connectors/notion/notion.ts` — complex block content extraction deferred to `getDocument`
|
||||
- **OAuth + contentDeferred (git)**: `apps/sim/connectors/github/github.ts` — blob SHA hash, tree listing
|
||||
- **OAuth + inline content**: `apps/sim/connectors/confluence/confluence.ts` — multiple config field types, `mapTags`, label fetching
|
||||
- **API key**: `apps/sim/connectors/fireflies/fireflies.ts` — GraphQL API with Bearer token auth
|
||||
|
||||
## Checklist
|
||||
@@ -425,7 +514,9 @@ export const CONNECTOR_REGISTRY: ConnectorRegistry = {
|
||||
- `selectorKey` exists in `hooks/selectors/registry.ts`
|
||||
- `dependsOn` references selector field IDs (not `canonicalParamId`)
|
||||
- Dependency `canonicalParamId` values exist in `SELECTOR_CONTEXT_FIELDS`
|
||||
- [ ] `listDocuments` handles pagination and computes content hashes
|
||||
- [ ] `listDocuments` handles pagination with metadata-based content hashes
|
||||
- [ ] `contentDeferred: true` used if content requires per-doc API calls (file download, export, blocks fetch)
|
||||
- [ ] `contentHash` is metadata-based (not content-based) and identical between stub and `getDocument`
|
||||
- [ ] `sourceUrl` set on each ExternalDocument (full URL, not relative)
|
||||
- [ ] `metadata` includes source-specific data for tag mapping
|
||||
- [ ] `tagDefinitions` declared for each semantic key returned by `mapTags`
|
||||
|
||||
@@ -141,12 +141,24 @@ For each API endpoint the connector calls:
|
||||
|
||||
## Step 6: Validate Data Transformation
|
||||
|
||||
### Content Deferral (CRITICAL)
|
||||
Connectors that require per-document API calls to fetch content (file download, export, blocks fetch) MUST use `contentDeferred: true`. This is the standard pattern for reliability — without it, content downloads during listing can exhaust the sync task's time budget before any documents are saved.
|
||||
|
||||
- [ ] If the connector downloads content per-doc during `listDocuments`, it MUST use `contentDeferred: true` instead
|
||||
- [ ] `listDocuments` returns lightweight stubs with `content: ''` and `contentDeferred: true`
|
||||
- [ ] `getDocument` fetches actual content and returns the full document with `contentDeferred: false`
|
||||
- [ ] A shared stub function (e.g., `fileToStub`) is used by both `listDocuments` and `getDocument` to guarantee `contentHash` consistency
|
||||
- [ ] `contentHash` is metadata-based (e.g., `service:{id}:{modifiedTime}`), NOT content-based — it must be derivable from list metadata alone
|
||||
- [ ] The `contentHash` is identical whether produced by `listDocuments` or `getDocument`
|
||||
|
||||
Connectors where the list API already returns content inline (e.g., Slack messages, Reddit posts) do NOT need `contentDeferred`.
|
||||
|
||||
### ExternalDocument Construction
|
||||
- [ ] `externalId` is a stable, unique identifier from the source API
|
||||
- [ ] `title` is extracted from the correct field and has a sensible fallback (e.g., `'Untitled'`)
|
||||
- [ ] `content` is plain text — HTML content is stripped using `htmlToPlainText` from `@/connectors/utils`
|
||||
- [ ] `mimeType` is `'text/plain'`
|
||||
- [ ] `contentHash` is computed using `computeContentHash` from `@/connectors/utils`
|
||||
- [ ] `contentHash` uses a metadata-based format (e.g., `service:{id}:{modifiedTime}`) for connectors with `contentDeferred: true`, or `computeContentHash` from `@/connectors/utils` for inline-content connectors
|
||||
- [ ] `sourceUrl` is a valid, complete URL back to the original resource (not relative)
|
||||
- [ ] `metadata` contains all fields referenced by `mapTags` and `tagDefinitions`
|
||||
|
||||
@@ -200,6 +212,8 @@ For each API endpoint the connector calls:
|
||||
- [ ] Fetches a single document by `externalId`
|
||||
- [ ] Returns `null` for 404 / not found (does not throw)
|
||||
- [ ] Returns the same `ExternalDocument` shape as `listDocuments`
|
||||
- [ ] If `listDocuments` uses `contentDeferred: true`, `getDocument` MUST fetch actual content and return `contentDeferred: false`
|
||||
- [ ] If `listDocuments` uses `contentDeferred: true`, `getDocument` MUST use the same stub function to ensure `contentHash` is identical
|
||||
- [ ] Handles all content types that `listDocuments` can produce (e.g., if `listDocuments` returns both pages and blogposts, `getDocument` must handle both — not hardcode one endpoint)
|
||||
- [ ] Forwards `syncContext` if it needs cached state (user names, field maps, etc.)
|
||||
- [ ] Error handling is graceful (catches, logs, returns null or throws with context)
|
||||
@@ -253,6 +267,8 @@ Group findings by severity:
|
||||
- Missing error handling that would crash the sync
|
||||
- `requiredScopes` not a subset of OAuth provider scopes
|
||||
- Query/filter injection: user-controlled values interpolated into OData `$filter`, SOQL, or query strings without escaping
|
||||
- Per-document content download in `listDocuments` without `contentDeferred: true` — causes sync timeouts for large document sets
|
||||
- `contentHash` mismatch between `listDocuments` stub and `getDocument` return — causes unnecessary re-processing every sync
|
||||
|
||||
**Warning** (incorrect behavior, data quality issues, or convention violations):
|
||||
- HTML content not stripped via `htmlToPlainText`
|
||||
@@ -300,6 +316,7 @@ After fixing, confirm:
|
||||
- [ ] Validated scopes are sufficient for all API endpoints the connector calls
|
||||
- [ ] Validated token refresh config (`useBasicAuth`, `supportsRefreshTokenRotation`)
|
||||
- [ ] Validated pagination: cursor names, page sizes, hasMore logic, no silent caps
|
||||
- [ ] Validated content deferral: `contentDeferred: true` used when per-doc content fetch required, metadata-based `contentHash` consistent between stub and `getDocument`
|
||||
- [ ] Validated data transformation: plain text extraction, HTML stripping, content hashing
|
||||
- [ ] Validated tag definitions match mapTags output, correct fieldTypes
|
||||
- [ ] Validated config fields: canonical pairs, selector keys, required flags
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM oven/bun:1.3.10-alpine
|
||||
FROM oven/bun:1.3.11-alpine
|
||||
|
||||
# Install necessary packages for development
|
||||
RUN apk add --no-cache \
|
||||
|
||||
2
.github/workflows/docs-embeddings.yml
vendored
2
.github/workflows/docs-embeddings.yml
vendored
@@ -20,7 +20,7 @@ jobs:
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.10
|
||||
bun-version: 1.3.11
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
|
||||
4
.github/workflows/i18n.yml
vendored
4
.github/workflows/i18n.yml
vendored
@@ -23,7 +23,7 @@ jobs:
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.10
|
||||
bun-version: 1.3.11
|
||||
|
||||
- name: Cache Bun dependencies
|
||||
uses: actions/cache@v4
|
||||
@@ -122,7 +122,7 @@ jobs:
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.10
|
||||
bun-version: 1.3.11
|
||||
|
||||
- name: Cache Bun dependencies
|
||||
uses: actions/cache@v4
|
||||
|
||||
2
.github/workflows/migrations.yml
vendored
2
.github/workflows/migrations.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.10
|
||||
bun-version: 1.3.11
|
||||
|
||||
- name: Cache Bun dependencies
|
||||
uses: actions/cache@v4
|
||||
|
||||
2
.github/workflows/publish-cli.yml
vendored
2
.github/workflows/publish-cli.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.10
|
||||
bun-version: 1.3.11
|
||||
|
||||
- name: Setup Node.js for npm publishing
|
||||
uses: actions/setup-node@v4
|
||||
|
||||
2
.github/workflows/publish-ts-sdk.yml
vendored
2
.github/workflows/publish-ts-sdk.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.10
|
||||
bun-version: 1.3.11
|
||||
|
||||
- name: Setup Node.js for npm publishing
|
||||
uses: actions/setup-node@v4
|
||||
|
||||
2
.github/workflows/test-build.yml
vendored
2
.github/workflows/test-build.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.10
|
||||
bun-version: 1.3.11
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
|
||||
10
README.md
10
README.md
@@ -74,6 +74,10 @@ docker compose -f docker-compose.prod.yml up -d
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000)
|
||||
|
||||
#### Background worker note
|
||||
|
||||
The Docker Compose stack starts a dedicated worker container by default. If `REDIS_URL` is not configured, the worker will start, log that it is idle, and do no queue processing. This is expected. Queue-backed API, webhook, and schedule execution requires Redis; installs without Redis continue to use the inline execution path.
|
||||
|
||||
Sim also supports local models via [Ollama](https://ollama.ai) and [vLLM](https://docs.vllm.ai/) — see the [Docker self-hosting docs](https://docs.sim.ai/self-hosting/docker) for setup details.
|
||||
|
||||
### Self-hosted: Manual Setup
|
||||
@@ -113,10 +117,12 @@ cd packages/db && bunx drizzle-kit migrate --config=./drizzle.config.ts
|
||||
5. Start development servers:
|
||||
|
||||
```bash
|
||||
bun run dev:full # Starts both Next.js app and realtime socket server
|
||||
bun run dev:full # Starts Next.js app, realtime socket server, and the BullMQ worker
|
||||
```
|
||||
|
||||
Or run separately: `bun run dev` (Next.js) and `cd apps/sim && bun run dev:sockets` (realtime).
|
||||
If `REDIS_URL` is not configured, the worker will remain idle and execution continues inline.
|
||||
|
||||
Or run separately: `bun run dev` (Next.js), `cd apps/sim && bun run dev:sockets` (realtime), and `cd apps/sim && bun run worker` (BullMQ worker).
|
||||
|
||||
## Copilot API Keys
|
||||
|
||||
|
||||
@@ -8,8 +8,6 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
export const viewport: Viewport = {
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
userScalable: false,
|
||||
themeColor: [
|
||||
{ media: '(prefers-color-scheme: light)', color: '#ffffff' },
|
||||
{ media: '(prefers-color-scheme: dark)', color: '#0c0c0c' },
|
||||
@@ -20,7 +18,7 @@ export const metadata = {
|
||||
metadataBase: new URL('https://docs.sim.ai'),
|
||||
title: {
|
||||
default: 'Sim Documentation — Build AI Agents & Run Your Agentic Workforce',
|
||||
template: '%s',
|
||||
template: '%s | Sim Docs',
|
||||
},
|
||||
description:
|
||||
'Documentation for Sim — the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows.',
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -74,6 +74,7 @@ import {
|
||||
GoogleVaultIcon,
|
||||
GrafanaIcon,
|
||||
GrainIcon,
|
||||
GranolaIcon,
|
||||
GreenhouseIcon,
|
||||
GreptileIcon,
|
||||
HexIcon,
|
||||
@@ -88,6 +89,7 @@ import {
|
||||
JiraIcon,
|
||||
JiraServiceManagementIcon,
|
||||
KalshiIcon,
|
||||
KetchIcon,
|
||||
LangsmithIcon,
|
||||
LemlistIcon,
|
||||
LinearIcon,
|
||||
@@ -247,6 +249,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
google_vault: GoogleVaultIcon,
|
||||
grafana: GrafanaIcon,
|
||||
grain: GrainIcon,
|
||||
granola: GranolaIcon,
|
||||
greenhouse: GreenhouseIcon,
|
||||
greptile: GreptileIcon,
|
||||
hex: HexIcon,
|
||||
@@ -262,6 +265,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
jira: JiraIcon,
|
||||
jira_service_management: JiraServiceManagementIcon,
|
||||
kalshi_v2: KalshiIcon,
|
||||
ketch: KetchIcon,
|
||||
knowledge: PackageSearchIcon,
|
||||
langsmith: LangsmithIcon,
|
||||
lemlist: LemlistIcon,
|
||||
|
||||
@@ -195,6 +195,17 @@ By default, your usage is capped at the credits included in your plan. To allow
|
||||
|
||||
Max (individual) shares the same rate limits as team plans. Team plans (Pro or Max for Teams) use the Max-tier rate limits.
|
||||
|
||||
### Concurrent Execution Limits
|
||||
|
||||
| Plan | Concurrent Executions |
|
||||
|------|----------------------|
|
||||
| **Free** | 5 |
|
||||
| **Pro** | 50 |
|
||||
| **Max / Team** | 200 |
|
||||
| **Enterprise** | 200 (customizable) |
|
||||
|
||||
Concurrent execution limits control how many workflow executions can run simultaneously within a workspace. When the limit is reached, new executions are queued and admitted as running executions complete. Manual runs from the editor are not subject to these limits.
|
||||
|
||||
### File Storage
|
||||
|
||||
| Plan | Storage |
|
||||
|
||||
92
apps/docs/content/docs/en/tools/granola.mdx
Normal file
92
apps/docs/content/docs/en/tools/granola.mdx
Normal file
@@ -0,0 +1,92 @@
|
||||
---
|
||||
title: Granola
|
||||
description: Access meeting notes and transcripts from Granola
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="granola"
|
||||
color="#B2C147"
|
||||
/>
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Integrate Granola into your workflow to retrieve meeting notes, summaries, attendees, and transcripts.
|
||||
|
||||
|
||||
|
||||
## Tools
|
||||
|
||||
### `granola_list_notes`
|
||||
|
||||
Lists meeting notes from Granola with optional date filters and pagination.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Granola API key |
|
||||
| `createdBefore` | string | No | Return notes created before this date \(ISO 8601\) |
|
||||
| `createdAfter` | string | No | Return notes created after this date \(ISO 8601\) |
|
||||
| `updatedAfter` | string | No | Return notes updated after this date \(ISO 8601\) |
|
||||
| `cursor` | string | No | Pagination cursor from a previous response |
|
||||
| `pageSize` | number | No | Number of notes per page \(1-30, default 10\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `notes` | json | List of meeting notes |
|
||||
| ↳ `id` | string | Note ID |
|
||||
| ↳ `title` | string | Note title |
|
||||
| ↳ `ownerName` | string | Note owner name |
|
||||
| ↳ `ownerEmail` | string | Note owner email |
|
||||
| ↳ `createdAt` | string | Creation timestamp |
|
||||
| ↳ `updatedAt` | string | Last update timestamp |
|
||||
| `hasMore` | boolean | Whether more notes are available |
|
||||
| `cursor` | string | Pagination cursor for the next page |
|
||||
|
||||
### `granola_get_note`
|
||||
|
||||
Retrieves a specific meeting note from Granola by ID, including summary, attendees, calendar event details, and optionally the transcript.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Granola API key |
|
||||
| `noteId` | string | Yes | The note ID \(e.g., not_1d3tmYTlCICgjy\) |
|
||||
| `includeTranscript` | string | No | Whether to include the meeting transcript |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `id` | string | Note ID |
|
||||
| `title` | string | Note title |
|
||||
| `ownerName` | string | Note owner name |
|
||||
| `ownerEmail` | string | Note owner email |
|
||||
| `createdAt` | string | Creation timestamp |
|
||||
| `updatedAt` | string | Last update timestamp |
|
||||
| `summaryText` | string | Plain text summary of the meeting |
|
||||
| `summaryMarkdown` | string | Markdown-formatted summary of the meeting |
|
||||
| `attendees` | json | Meeting attendees |
|
||||
| ↳ `name` | string | Attendee name |
|
||||
| ↳ `email` | string | Attendee email |
|
||||
| `folders` | json | Folders the note belongs to |
|
||||
| ↳ `id` | string | Folder ID |
|
||||
| ↳ `name` | string | Folder name |
|
||||
| `calendarEventTitle` | string | Calendar event title |
|
||||
| `calendarOrganiser` | string | Calendar event organiser email |
|
||||
| `calendarEventId` | string | Calendar event ID |
|
||||
| `scheduledStartTime` | string | Scheduled start time |
|
||||
| `scheduledEndTime` | string | Scheduled end time |
|
||||
| `invitees` | json | Calendar event invitee emails |
|
||||
| `transcript` | json | Meeting transcript entries \(only if requested\) |
|
||||
| ↳ `speaker` | string | Speaker source \(microphone or speaker\) |
|
||||
| ↳ `text` | string | Transcript text |
|
||||
| ↳ `startTime` | string | Segment start time |
|
||||
| ↳ `endTime` | string | Segment end time |
|
||||
|
||||
|
||||
149
apps/docs/content/docs/en/tools/ketch.mdx
Normal file
149
apps/docs/content/docs/en/tools/ketch.mdx
Normal file
@@ -0,0 +1,149 @@
|
||||
---
|
||||
title: Ketch
|
||||
description: Manage privacy consent, subscriptions, and data subject rights
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="ketch"
|
||||
color="#9B5CFF"
|
||||
/>
|
||||
|
||||
{/* MANUAL-CONTENT-START:intro */}
|
||||
[Ketch](https://www.ketch.com/) is an AI-powered privacy, consent, and data governance platform that helps organizations automate compliance with global privacy regulations. It provides tools for managing consent preferences, handling data subject rights requests, and controlling subscription communications.
|
||||
|
||||
With Ketch, you can:
|
||||
|
||||
- **Retrieve consent status**: Query the current consent preferences for any data subject across configured purposes and legal bases
|
||||
- **Update consent preferences**: Set or modify consent for specific purposes (e.g., analytics, marketing) with the appropriate legal basis (opt-in, opt-out, disclosure)
|
||||
- **Manage subscriptions**: Get and update subscription topic preferences and global controls across contact methods like email and SMS
|
||||
- **Invoke data subject rights**: Submit privacy rights requests including data access, deletion, correction, and processing restriction under regulations like GDPR and CCPA
|
||||
|
||||
To use Ketch, drop the Ketch block into your workflow and provide your organization code, property code, and environment code. The Ketch Web API is a public API — no API key or OAuth credentials are required. Identity is determined by the organization and property codes along with the data subject's identity (e.g., email address).
|
||||
|
||||
These capabilities let you automate privacy compliance workflows, respond to user consent changes in real time, and manage data subject rights requests as part of your broader automation pipelines.
|
||||
{/* MANUAL-CONTENT-END */}
|
||||
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Integrate Ketch into the workflow. Retrieve and update consent preferences, manage subscription topics and controls, and submit data subject rights requests for access, deletion, correction, or processing restriction.
|
||||
|
||||
|
||||
|
||||
## Tools
|
||||
|
||||
### `ketch_get_consent`
|
||||
|
||||
Retrieve consent status for a data subject. Returns the current consent preferences for each configured purpose.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `organizationCode` | string | Yes | Ketch organization code |
|
||||
| `propertyCode` | string | Yes | Digital property code defined in Ketch |
|
||||
| `environmentCode` | string | Yes | Environment code defined in Ketch \(e.g., "production"\) |
|
||||
| `jurisdictionCode` | string | No | Jurisdiction code \(e.g., "gdpr", "ccpa"\) |
|
||||
| `identities` | json | Yes | Identity map \(e.g., \{"email": "user@example.com"\}\) |
|
||||
| `purposes` | json | No | Optional purposes to filter the consent query |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `purposes` | object | Map of purpose codes to consent status and legal basis |
|
||||
| ↳ `allowed` | string | Consent status for the purpose: "granted" or "denied" |
|
||||
| ↳ `legalBasisCode` | string | Legal basis code \(e.g., "consent_optin", "consent_optout", "disclosure", "other"\) |
|
||||
| `vendors` | object | Map of vendor consent statuses |
|
||||
|
||||
### `ketch_set_consent`
|
||||
|
||||
Update consent preferences for a data subject. Sets the consent status for specified purposes with the appropriate legal basis.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `organizationCode` | string | Yes | Ketch organization code |
|
||||
| `propertyCode` | string | Yes | Digital property code defined in Ketch |
|
||||
| `environmentCode` | string | Yes | Environment code defined in Ketch \(e.g., "production"\) |
|
||||
| `jurisdictionCode` | string | No | Jurisdiction code \(e.g., "gdpr", "ccpa"\) |
|
||||
| `identities` | json | Yes | Identity map \(e.g., \{"email": "user@example.com"\}\) |
|
||||
| `purposes` | json | Yes | Map of purpose codes to consent settings \(e.g., \{"analytics": \{"allowed": "granted", "legalBasisCode": "consent_optin"\}\}\) |
|
||||
| `collectedAt` | number | No | UNIX timestamp when consent was collected \(defaults to current time\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `purposes` | object | Updated consent status map of purpose codes to consent settings |
|
||||
| ↳ `allowed` | string | Consent status for the purpose: "granted" or "denied" |
|
||||
| ↳ `legalBasisCode` | string | Legal basis code \(e.g., "consent_optin", "consent_optout", "disclosure", "other"\) |
|
||||
|
||||
### `ketch_get_subscriptions`
|
||||
|
||||
Retrieve subscription preferences for a data subject. Returns the current subscription topic and control statuses.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `organizationCode` | string | Yes | Ketch organization code |
|
||||
| `propertyCode` | string | Yes | Digital property code defined in Ketch |
|
||||
| `environmentCode` | string | Yes | Environment code defined in Ketch \(e.g., "production"\) |
|
||||
| `identities` | json | Yes | Identity map \(e.g., \{"email": "user@example.com"\}\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `topics` | object | Map of topic codes to contact method settings \(e.g., \{"newsletter": \{"email": \{"status": "granted"\}\}\}\) |
|
||||
| `controls` | object | Map of control codes to settings \(e.g., \{"global_unsubscribe": \{"status": "denied"\}\}\) |
|
||||
|
||||
### `ketch_set_subscriptions`
|
||||
|
||||
Update subscription preferences for a data subject. Sets topic and control statuses for email, SMS, and other contact methods.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `organizationCode` | string | Yes | Ketch organization code |
|
||||
| `propertyCode` | string | Yes | Digital property code defined in Ketch |
|
||||
| `environmentCode` | string | Yes | Environment code defined in Ketch \(e.g., "production"\) |
|
||||
| `identities` | json | Yes | Identity map \(e.g., \{"email": "user@example.com"\}\) |
|
||||
| `topics` | json | No | Map of topic codes to contact method settings \(e.g., \{"newsletter": \{"email": \{"status": "granted"\}, "sms": \{"status": "denied"\}\}\}\) |
|
||||
| `controls` | json | No | Map of control codes to settings \(e.g., \{"global_unsubscribe": \{"status": "denied"\}\}\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `success` | boolean | Whether the subscription preferences were updated |
|
||||
|
||||
### `ketch_invoke_right`
|
||||
|
||||
Submit a data subject rights request (e.g., access, delete, correct, restrict processing). Initiates a privacy rights workflow in Ketch.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `organizationCode` | string | Yes | Ketch organization code |
|
||||
| `propertyCode` | string | Yes | Digital property code defined in Ketch |
|
||||
| `environmentCode` | string | Yes | Environment code defined in Ketch \(e.g., "production"\) |
|
||||
| `jurisdictionCode` | string | Yes | Jurisdiction code \(e.g., "gdpr", "ccpa"\) |
|
||||
| `rightCode` | string | Yes | Privacy right code to invoke \(e.g., "access", "delete", "correct", "restrict_processing"\) |
|
||||
| `identities` | json | Yes | Identity map \(e.g., \{"email": "user@example.com"\}\) |
|
||||
| `userData` | json | No | Optional data subject information \(e.g., \{"email": "user@example.com", "firstName": "John", "lastName": "Doe"\}\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `success` | boolean | Whether the rights request was submitted |
|
||||
| `message` | string | Response message from Ketch |
|
||||
|
||||
|
||||
@@ -68,6 +68,7 @@
|
||||
"google_vault",
|
||||
"grafana",
|
||||
"grain",
|
||||
"granola",
|
||||
"greenhouse",
|
||||
"greptile",
|
||||
"hex",
|
||||
@@ -83,6 +84,7 @@
|
||||
"jira",
|
||||
"jira_service_management",
|
||||
"kalshi",
|
||||
"ketch",
|
||||
"knowledge",
|
||||
"langsmith",
|
||||
"lemlist",
|
||||
|
||||
@@ -14,8 +14,8 @@ export default function AuthLayoutClient({ children }: { children: React.ReactNo
|
||||
|
||||
return (
|
||||
<AuthBackground className='dark font-[430] font-season'>
|
||||
<main className='relative flex min-h-full flex-col text-[#ECECEC]'>
|
||||
<header className='shrink-0 bg-[#1C1C1C]'>
|
||||
<main className='relative flex min-h-full flex-col text-[var(--landing-text)]'>
|
||||
<header className='shrink-0 bg-[var(--landing-bg)]'>
|
||||
<Navbar logoOnly />
|
||||
</header>
|
||||
<div className='relative z-30 flex flex-1 items-center justify-center px-4 pb-24'>
|
||||
|
||||
@@ -9,7 +9,7 @@ type AuthBackgroundProps = {
|
||||
export default function AuthBackground({ className, children }: AuthBackgroundProps) {
|
||||
return (
|
||||
<div className={cn('fixed inset-0 overflow-hidden', className)}>
|
||||
<div className='-z-50 pointer-events-none absolute inset-0 bg-[#1C1C1C]' />
|
||||
<div className='-z-50 pointer-events-none absolute inset-0 bg-[var(--landing-bg)]' />
|
||||
<AuthBackgroundSVG />
|
||||
<div className='relative z-20 h-full overflow-auto'>{children}</div>
|
||||
</div>
|
||||
|
||||
6
apps/sim/app/(auth)/components/auth-button-classes.ts
Normal file
6
apps/sim/app/(auth)/components/auth-button-classes.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/** Shared className for primary auth/status CTA buttons on dark auth surfaces. */
|
||||
export const AUTH_PRIMARY_CTA_BASE =
|
||||
'inline-flex h-[32px] items-center justify-center gap-2 rounded-[5px] border border-[var(--auth-primary-btn-border)] bg-[var(--auth-primary-btn-bg)] px-2.5 font-[430] font-season text-[var(--auth-primary-btn-text)] text-sm transition-colors hover:border-[var(--auth-primary-btn-hover-border)] hover:bg-[var(--auth-primary-btn-hover-bg)] hover:text-[var(--auth-primary-btn-hover-text)] disabled:cursor-not-allowed disabled:opacity-50' as const
|
||||
|
||||
/** Full-width variant used for primary auth form submit buttons. */
|
||||
export const AUTH_SUBMIT_BTN = `${AUTH_PRIMARY_CTA_BASE} w-full` as const
|
||||
@@ -1,102 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { forwardRef, useState } from 'react'
|
||||
import { ArrowRight, ChevronRight, Loader2 } from 'lucide-react'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { useBrandConfig } from '@/ee/whitelabeling'
|
||||
|
||||
export interface BrandedButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
loading?: boolean
|
||||
loadingText?: string
|
||||
showArrow?: boolean
|
||||
fullWidth?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Branded button for auth and status pages.
|
||||
* Default: white button matching the landing page "Get started" style.
|
||||
* Whitelabel: uses the brand's primary color as background with white text.
|
||||
*/
|
||||
export const BrandedButton = forwardRef<HTMLButtonElement, BrandedButtonProps>(
|
||||
(
|
||||
{
|
||||
children,
|
||||
loading = false,
|
||||
loadingText,
|
||||
showArrow = true,
|
||||
fullWidth = true,
|
||||
className,
|
||||
disabled,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const brand = useBrandConfig()
|
||||
const hasCustomColor = brand.isWhitelabeled && Boolean(brand.theme?.primaryColor)
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
|
||||
const handleMouseEnter = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
setIsHovered(true)
|
||||
onMouseEnter?.(e)
|
||||
}
|
||||
|
||||
const handleMouseLeave = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
setIsHovered(false)
|
||||
onMouseLeave?.(e)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
{...props}
|
||||
disabled={disabled || loading}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
className={cn(
|
||||
'group inline-flex h-[32px] items-center justify-center gap-[8px] rounded-[5px] border px-[10px] font-[430] font-season text-[14px] transition-colors disabled:cursor-not-allowed disabled:opacity-50',
|
||||
!hasCustomColor &&
|
||||
'border-[#FFFFFF] bg-[#FFFFFF] text-black hover:border-[#E0E0E0] hover:bg-[#E0E0E0]',
|
||||
fullWidth && 'w-full',
|
||||
className
|
||||
)}
|
||||
style={
|
||||
hasCustomColor
|
||||
? {
|
||||
backgroundColor: isHovered
|
||||
? (brand.theme?.primaryHoverColor ?? brand.theme?.primaryColor)
|
||||
: brand.theme?.primaryColor,
|
||||
borderColor: isHovered
|
||||
? (brand.theme?.primaryHoverColor ?? brand.theme?.primaryColor)
|
||||
: brand.theme?.primaryColor,
|
||||
color: '#FFFFFF',
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
<span className='flex items-center gap-2'>
|
||||
<Loader2 className='h-4 w-4 animate-spin' />
|
||||
{loadingText ? `${loadingText}...` : children}
|
||||
</span>
|
||||
) : showArrow ? (
|
||||
<span className='flex items-center gap-1'>
|
||||
{children}
|
||||
<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>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
BrandedButton.displayName = 'BrandedButton'
|
||||
@@ -4,23 +4,18 @@ import { useRouter } from 'next/navigation'
|
||||
import { Button } from '@/components/emcn'
|
||||
import { getEnv, isTruthy } from '@/lib/core/config/env'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes'
|
||||
|
||||
interface SSOLoginButtonProps {
|
||||
callbackURL?: string
|
||||
className?: string
|
||||
// Visual variant for button styling and placement contexts
|
||||
// - 'primary' matches the main auth action button style
|
||||
// - 'outline' matches social provider buttons
|
||||
variant?: 'primary' | 'outline'
|
||||
// Optional class used when variant is primary to match brand/gradient
|
||||
primaryClassName?: string
|
||||
}
|
||||
|
||||
export function SSOLoginButton({
|
||||
callbackURL,
|
||||
className,
|
||||
variant = 'outline',
|
||||
primaryClassName,
|
||||
}: SSOLoginButtonProps) {
|
||||
const router = useRouter()
|
||||
|
||||
@@ -33,11 +28,6 @@ export function SSOLoginButton({
|
||||
router.push(ssoUrl)
|
||||
}
|
||||
|
||||
const primaryBtnClasses = cn(
|
||||
primaryClassName || 'branded-button-gradient',
|
||||
'flex w-full items-center justify-center gap-2 rounded-[10px] border font-medium text-[15px] text-white transition-all duration-200'
|
||||
)
|
||||
|
||||
const outlineBtnClasses = cn('w-full rounded-[10px]')
|
||||
|
||||
return (
|
||||
@@ -45,7 +35,7 @@ export function SSOLoginButton({
|
||||
type='button'
|
||||
onClick={handleSSOClick}
|
||||
variant={variant === 'outline' ? 'outline' : undefined}
|
||||
className={cn(variant === 'outline' ? outlineBtnClasses : primaryBtnClasses, className)}
|
||||
className={cn(variant === 'outline' ? outlineBtnClasses : AUTH_SUBMIT_BTN, className)}
|
||||
>
|
||||
Sign in with SSO
|
||||
</Button>
|
||||
|
||||
@@ -18,18 +18,18 @@ export function StatusPageLayout({
|
||||
}: StatusPageLayoutProps) {
|
||||
return (
|
||||
<AuthBackground className='dark font-[430] font-season'>
|
||||
<main className='relative flex min-h-full flex-col text-[#ECECEC]'>
|
||||
<header className='shrink-0 bg-[#1C1C1C]'>
|
||||
<main className='relative flex min-h-full flex-col text-[var(--landing-text)]'>
|
||||
<header className='shrink-0 bg-[var(--landing-bg)]'>
|
||||
<Navbar logoOnly />
|
||||
</header>
|
||||
<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'>
|
||||
<div className='flex flex-col items-center justify-center'>
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1 className='font-[430] font-season text-[40px] text-white leading-[110%] tracking-[-0.02em]'>
|
||||
<h1 className='text-balance font-[430] font-season text-[40px] text-white leading-[110%] tracking-[-0.02em]'>
|
||||
{title}
|
||||
</h1>
|
||||
<p className='font-[430] font-season text-[#F6F6F6]/60 text-[18px] leading-[125%] tracking-[0.02em]'>
|
||||
<p className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_60%,transparent)] text-lg leading-[125%] tracking-[0.02em]'>
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -11,12 +11,12 @@ export function SupportFooter({ position = 'fixed' }: SupportFooterProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`right-0 bottom-0 left-0 z-50 pb-8 text-center font-[340] text-[#999] text-[13px] leading-relaxed ${position}`}
|
||||
className={`right-0 bottom-0 left-0 z-50 pb-8 text-center font-[340] text-[var(--landing-text-muted)] text-small leading-relaxed ${position}`}
|
||||
>
|
||||
Need help?{' '}
|
||||
<a
|
||||
href={`mailto:${brandConfig.supportEmail}`}
|
||||
className='text-[#999] underline-offset-4 transition hover:text-[#ECECEC] hover:underline'
|
||||
className='text-[var(--landing-text-muted)] underline-offset-4 transition hover:text-[var(--landing-text)] hover:underline'
|
||||
>
|
||||
Contact support
|
||||
</a>
|
||||
|
||||
@@ -4,21 +4,21 @@ export default function LoginLoading() {
|
||||
return (
|
||||
<div className='flex flex-col items-center'>
|
||||
<Skeleton className='h-[38px] w-[80px] rounded-[4px]' />
|
||||
<div className='mt-[32px] w-full space-y-[8px]'>
|
||||
<div className='mt-8 w-full space-y-2'>
|
||||
<Skeleton className='h-[14px] w-[40px] rounded-[4px]' />
|
||||
<Skeleton className='h-[44px] w-full rounded-[10px]' />
|
||||
</div>
|
||||
<div className='mt-[16px] w-full space-y-[8px]'>
|
||||
<div className='mt-4 w-full space-y-2'>
|
||||
<Skeleton className='h-[14px] w-[64px] rounded-[4px]' />
|
||||
<Skeleton className='h-[44px] w-full rounded-[10px]' />
|
||||
</div>
|
||||
<Skeleton className='mt-[24px] h-[44px] w-full rounded-[10px]' />
|
||||
<Skeleton className='mt-[24px] h-[1px] w-full rounded-[1px]' />
|
||||
<div className='mt-[24px] flex w-full gap-[12px]'>
|
||||
<Skeleton className='mt-6 h-[44px] w-full rounded-[10px]' />
|
||||
<Skeleton className='mt-6 h-[1px] w-full rounded-[1px]' />
|
||||
<div className='mt-6 flex w-full gap-3'>
|
||||
<Skeleton className='h-[44px] flex-1 rounded-[10px]' />
|
||||
<Skeleton className='h-[44px] flex-1 rounded-[10px]' />
|
||||
</div>
|
||||
<Skeleton className='mt-[24px] h-[14px] w-[200px] rounded-[4px]' />
|
||||
<Skeleton className='mt-6 h-[14px] w-[200px] rounded-[4px]' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import { Eye, EyeOff, Loader2 } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import {
|
||||
@@ -20,10 +20,9 @@ import { validateCallbackUrl } from '@/lib/core/security/input-validation'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
||||
import { BrandedButton } from '@/app/(auth)/components/branded-button'
|
||||
import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes'
|
||||
import { SocialLoginButtons } from '@/app/(auth)/components/social-login-buttons'
|
||||
import { SSOLoginButton } from '@/app/(auth)/components/sso-login-button'
|
||||
import { useBrandedButtonClass } from '@/hooks/use-branded-button-class'
|
||||
|
||||
const logger = createLogger('LoginForm')
|
||||
|
||||
@@ -87,8 +86,6 @@ export default function LoginPage({
|
||||
const [passwordErrors, setPasswordErrors] = useState<string[]>([])
|
||||
const [showValidationError, setShowValidationError] = useState(false)
|
||||
const [formError, setFormError] = useState<string | null>(null)
|
||||
const buttonClass = useBrandedButtonClass()
|
||||
|
||||
const callbackUrlParam = searchParams?.get('callbackUrl')
|
||||
const isValidCallbackUrl = callbackUrlParam ? validateCallbackUrl(callbackUrlParam) : false
|
||||
const invalidCallbackRef = useRef(false)
|
||||
@@ -174,7 +171,7 @@ export default function LoginPage({
|
||||
callbackURL: safeCallbackUrl,
|
||||
},
|
||||
{
|
||||
onError: (ctx) => {
|
||||
onError: (ctx: any) => {
|
||||
logger.error('Login error:', ctx.error)
|
||||
|
||||
if (ctx.error.code?.includes('EMAIL_NOT_VERIFIED')) {
|
||||
@@ -342,10 +339,10 @@ export default function LoginPage({
|
||||
return (
|
||||
<>
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1 className='font-[430] font-season text-[40px] text-white leading-[110%] tracking-[-0.02em]'>
|
||||
<h1 className='text-balance font-[430] font-season text-[40px] text-white leading-[110%] tracking-[-0.02em]'>
|
||||
Sign in
|
||||
</h1>
|
||||
<p className='font-[430] font-season text-[#F6F6F6]/60 text-[18px] leading-[125%] tracking-[0.02em]'>
|
||||
<p className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_60%,transparent)] text-lg leading-[125%] tracking-[0.02em]'>
|
||||
Enter your details
|
||||
</p>
|
||||
</div>
|
||||
@@ -353,11 +350,7 @@ export default function LoginPage({
|
||||
{/* SSO Login Button (primary top-only when it is the only method) */}
|
||||
{showTopSSO && (
|
||||
<div className='mt-8'>
|
||||
<SSOLoginButton
|
||||
callbackURL={callbackUrl}
|
||||
variant='primary'
|
||||
primaryClassName={buttonClass}
|
||||
/>
|
||||
<SSOLoginButton callbackURL={callbackUrl} variant='primary' />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -399,7 +392,7 @@ export default function LoginPage({
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setForgotPasswordOpen(true)}
|
||||
className='font-medium text-[#999] text-xs transition hover:text-[#ECECEC]'
|
||||
className='font-medium text-[var(--landing-text-muted)] text-xs transition hover:text-[var(--landing-text)]'
|
||||
>
|
||||
Forgot password?
|
||||
</button>
|
||||
@@ -426,7 +419,7 @@ export default function LoginPage({
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className='-translate-y-1/2 absolute top-1/2 right-3 text-[#999] transition hover:text-[#ECECEC]'
|
||||
className='-translate-y-1/2 absolute top-1/2 right-3 text-[var(--landing-text-muted)] transition hover:text-[var(--landing-text)]'
|
||||
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||
>
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
@@ -454,14 +447,16 @@ export default function LoginPage({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<BrandedButton
|
||||
type='submit'
|
||||
disabled={isLoading}
|
||||
loading={isLoading}
|
||||
loadingText='Signing in'
|
||||
>
|
||||
Sign in
|
||||
</BrandedButton>
|
||||
<button type='submit' disabled={isLoading} className={AUTH_SUBMIT_BTN}>
|
||||
{isLoading ? (
|
||||
<span className='flex items-center gap-2'>
|
||||
<Loader2 className='h-4 w-4 animate-spin' />
|
||||
Signing in...
|
||||
</span>
|
||||
) : (
|
||||
'Sign in'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
@@ -469,10 +464,12 @@ export default function LoginPage({
|
||||
{showDivider && (
|
||||
<div className='relative my-6 font-light'>
|
||||
<div className='absolute inset-0 flex items-center'>
|
||||
<div className='w-full border-[#2A2A2A] border-t' />
|
||||
<div className='w-full border-[var(--landing-bg-elevated)] border-t' />
|
||||
</div>
|
||||
<div className='relative flex justify-center text-sm'>
|
||||
<span className='bg-[#1C1C1C] px-4 font-[340] text-[#999]'>Or continue with</span>
|
||||
<span className='bg-[var(--landing-bg)] px-4 font-[340] text-[var(--landing-text-muted)]'>
|
||||
Or continue with
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -486,11 +483,7 @@ export default function LoginPage({
|
||||
callbackURL={callbackUrl}
|
||||
>
|
||||
{ssoEnabled && !hasOnlySSO && (
|
||||
<SSOLoginButton
|
||||
callbackURL={callbackUrl}
|
||||
variant='outline'
|
||||
primaryClassName={buttonClass}
|
||||
/>
|
||||
<SSOLoginButton callbackURL={callbackUrl} variant='outline' />
|
||||
)}
|
||||
</SocialLoginButtons>
|
||||
</div>
|
||||
@@ -502,20 +495,20 @@ export default function LoginPage({
|
||||
<span className='font-normal'>Don't have an account? </span>
|
||||
<Link
|
||||
href={isInviteFlow ? `/signup?invite_flow=true&callbackUrl=${callbackUrl}` : '/signup'}
|
||||
className='font-medium text-[#ECECEC] underline-offset-4 transition hover:text-white hover:underline'
|
||||
className='font-medium text-[var(--landing-text)] underline-offset-4 transition hover:text-white hover:underline'
|
||||
>
|
||||
Sign up
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='absolute right-0 bottom-0 left-0 px-8 pb-8 text-center font-[340] text-[#999] text-[13px] leading-relaxed sm:px-8 md:px-[44px]'>
|
||||
<div className='absolute right-0 bottom-0 left-0 px-8 pb-8 text-center font-[340] text-[13px] text-[var(--landing-text-muted)] leading-relaxed sm:px-8 md:px-11'>
|
||||
By signing in, you agree to our{' '}
|
||||
<Link
|
||||
href='/terms'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-[#999] underline-offset-4 transition hover:text-[#ECECEC] hover:underline'
|
||||
className='text-[var(--landing-text-muted)] underline-offset-4 transition hover:text-[var(--landing-text)] hover:underline'
|
||||
>
|
||||
Terms of Service
|
||||
</Link>{' '}
|
||||
@@ -524,7 +517,7 @@ export default function LoginPage({
|
||||
href='/privacy'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-[#999] underline-offset-4 transition hover:text-[#ECECEC] hover:underline'
|
||||
className='text-[var(--landing-text-muted)] underline-offset-4 transition hover:text-[var(--landing-text)] hover:underline'
|
||||
>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
@@ -569,14 +562,16 @@ export default function LoginPage({
|
||||
<p>{resetStatus.message}</p>
|
||||
</div>
|
||||
)}
|
||||
<BrandedButton
|
||||
type='submit'
|
||||
disabled={isSubmittingReset}
|
||||
loading={isSubmittingReset}
|
||||
loadingText='Sending'
|
||||
>
|
||||
Send Reset Link
|
||||
</BrandedButton>
|
||||
<button type='submit' disabled={isSubmittingReset} className={AUTH_SUBMIT_BTN}>
|
||||
{isSubmittingReset ? (
|
||||
<span className='flex items-center gap-2'>
|
||||
<Loader2 className='h-4 w-4 animate-spin' />
|
||||
Sending...
|
||||
</span>
|
||||
) : (
|
||||
'Send Reset Link'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</ModalBody>
|
||||
|
||||
@@ -3,16 +3,16 @@ import { Skeleton } from '@/components/emcn'
|
||||
export default function OAuthConsentLoading() {
|
||||
return (
|
||||
<div className='flex flex-col items-center'>
|
||||
<div className='flex items-center gap-[16px]'>
|
||||
<div className='flex items-center gap-4'>
|
||||
<Skeleton className='h-[48px] w-[48px] rounded-[12px]' />
|
||||
<Skeleton className='h-[20px] w-[20px] rounded-[4px]' />
|
||||
<Skeleton className='h-[48px] w-[48px] rounded-[12px]' />
|
||||
</div>
|
||||
<Skeleton className='mt-[24px] h-[38px] w-[220px] rounded-[4px]' />
|
||||
<Skeleton className='mt-[8px] h-[14px] w-[280px] rounded-[4px]' />
|
||||
<Skeleton className='mt-[24px] h-[56px] w-full rounded-[8px]' />
|
||||
<Skeleton className='mt-[16px] h-[120px] w-full rounded-[8px]' />
|
||||
<div className='mt-[24px] flex w-full max-w-[410px] gap-[12px]'>
|
||||
<Skeleton className='mt-6 h-[38px] w-[220px] rounded-[4px]' />
|
||||
<Skeleton className='mt-2 h-[14px] w-[280px] rounded-[4px]' />
|
||||
<Skeleton className='mt-6 h-[56px] w-full rounded-[8px]' />
|
||||
<Skeleton className='mt-4 h-[120px] w-full rounded-[8px]' />
|
||||
<div className='mt-6 flex w-full max-w-[410px] gap-3'>
|
||||
<Skeleton className='h-[44px] flex-1 rounded-[10px]' />
|
||||
<Skeleton className='h-[44px] flex-1 rounded-[10px]' />
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { ArrowLeftRight } from 'lucide-react'
|
||||
import { ArrowLeftRight, Loader2 } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { Button } from '@/components/emcn'
|
||||
import { signOut, useSession } from '@/lib/auth/auth-client'
|
||||
import { BrandedButton } from '@/app/(auth)/components/branded-button'
|
||||
import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes'
|
||||
|
||||
const SCOPE_DESCRIPTIONS: Record<string, string> = {
|
||||
openid: 'Verify your identity',
|
||||
@@ -127,10 +127,10 @@ export default function OAuthConsentPage() {
|
||||
return (
|
||||
<div className='flex flex-col items-center justify-center'>
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1 className='font-[430] font-season text-[40px] text-white leading-[110%] tracking-[-0.02em]'>
|
||||
<h1 className='text-balance font-[430] font-season text-[40px] text-white leading-[110%] tracking-[-0.02em]'>
|
||||
Authorize Application
|
||||
</h1>
|
||||
<p className='font-[430] font-season text-[#F6F6F6]/60 text-[18px] leading-[125%] tracking-[0.02em]'>
|
||||
<p className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_60%,transparent)] text-lg leading-[125%] tracking-[0.02em]'>
|
||||
Loading application details...
|
||||
</p>
|
||||
</div>
|
||||
@@ -142,15 +142,17 @@ export default function OAuthConsentPage() {
|
||||
return (
|
||||
<div className='flex flex-col items-center justify-center'>
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1 className='font-[430] font-season text-[40px] text-white leading-[110%] tracking-[-0.02em]'>
|
||||
<h1 className='text-balance font-[430] font-season text-[40px] text-white leading-[110%] tracking-[-0.02em]'>
|
||||
Authorization Error
|
||||
</h1>
|
||||
<p className='font-[430] font-season text-[#F6F6F6]/60 text-[18px] leading-[125%] tracking-[0.02em]'>
|
||||
<p className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_60%,transparent)] text-lg leading-[125%] tracking-[0.02em]'>
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
<div className='mt-8 w-full max-w-[410px] space-y-3'>
|
||||
<BrandedButton onClick={() => router.push('/')}>Return to Home</BrandedButton>
|
||||
<button onClick={() => router.push('/')} className={AUTH_SUBMIT_BTN}>
|
||||
Return to Home
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -170,11 +172,11 @@ export default function OAuthConsentPage() {
|
||||
className='rounded-[10px]'
|
||||
/>
|
||||
) : (
|
||||
<div className='flex h-12 w-12 items-center justify-center rounded-[10px] bg-[#2A2A2A] font-medium text-[#999] text-[18px]'>
|
||||
<div className='flex h-12 w-12 items-center justify-center rounded-[10px] bg-[var(--landing-bg-elevated)] font-medium text-[var(--landing-text-muted)] text-lg'>
|
||||
{(clientName ?? '?').charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<ArrowLeftRight className='h-5 w-5 text-[#999]' />
|
||||
<ArrowLeftRight className='h-5 w-5 text-[var(--landing-text-muted)]' />
|
||||
<Image
|
||||
src='/new/logo/colorized-bg.svg'
|
||||
alt='Sim'
|
||||
@@ -185,17 +187,17 @@ export default function OAuthConsentPage() {
|
||||
</div>
|
||||
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1 className='font-[430] font-season text-[40px] text-white leading-[110%] tracking-[-0.02em]'>
|
||||
<h1 className='text-balance font-[430] font-season text-[40px] text-white leading-[110%] tracking-[-0.02em]'>
|
||||
Authorize Application
|
||||
</h1>
|
||||
<p className='font-[430] font-season text-[#F6F6F6]/60 text-[18px] leading-[125%] tracking-[0.02em]'>
|
||||
<span className='font-medium text-[#ECECEC]'>{clientName}</span> is requesting access to
|
||||
your account
|
||||
<p className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_60%,transparent)] text-lg leading-[125%] tracking-[0.02em]'>
|
||||
<span className='font-medium text-[var(--landing-text)]'>{clientName}</span> is requesting
|
||||
access to your account
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{session?.user && (
|
||||
<div className='mt-5 flex items-center gap-3 rounded-lg border border-[#2A2A2A] px-4 py-3'>
|
||||
<div className='mt-5 flex items-center gap-3 rounded-lg border border-[var(--landing-bg-elevated)] px-4 py-3'>
|
||||
{session.user.image ? (
|
||||
<Image
|
||||
src={session.user.image}
|
||||
@@ -206,20 +208,22 @@ export default function OAuthConsentPage() {
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<div className='flex h-8 w-8 items-center justify-center rounded-full bg-[#2A2A2A] font-medium text-[#999] text-[13px]'>
|
||||
<div className='flex h-8 w-8 items-center justify-center rounded-full bg-[var(--landing-bg-elevated)] font-medium text-[var(--landing-text-muted)] text-small'>
|
||||
{(session.user.name ?? session.user.email ?? '?').charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<div className='min-w-0'>
|
||||
{session.user.name && (
|
||||
<p className='truncate font-medium text-[14px]'>{session.user.name}</p>
|
||||
<p className='truncate font-medium text-sm'>{session.user.name}</p>
|
||||
)}
|
||||
<p className='truncate text-[#999] text-[13px]'>{session.user.email}</p>
|
||||
<p className='truncate text-[var(--landing-text-muted)] text-small'>
|
||||
{session.user.email}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleSwitchAccount}
|
||||
className='ml-auto text-[#999] text-[13px] underline-offset-2 transition-colors hover:text-[#ECECEC] hover:underline'
|
||||
className='ml-auto text-[var(--landing-text-muted)] text-small underline-offset-2 transition-colors hover:text-[var(--landing-text)] hover:underline'
|
||||
>
|
||||
Switch
|
||||
</button>
|
||||
@@ -228,11 +232,14 @@ export default function OAuthConsentPage() {
|
||||
|
||||
{scopes.length > 0 && (
|
||||
<div className='mt-5 w-full max-w-[410px]'>
|
||||
<div className='rounded-lg border p-4'>
|
||||
<p className='mb-3 font-medium text-[14px]'>This will allow the application to:</p>
|
||||
<div className='rounded-lg border border-[var(--landing-bg-elevated)] p-4'>
|
||||
<p className='mb-3 font-medium text-sm'>This will allow the application to:</p>
|
||||
<ul className='space-y-2'>
|
||||
{scopes.map((s) => (
|
||||
<li key={s} className='flex items-start gap-2 font-normal text-[#999] text-[13px]'>
|
||||
<li
|
||||
key={s}
|
||||
className='flex items-start gap-2 font-normal text-[var(--landing-text-muted)] text-small'
|
||||
>
|
||||
<span className='mt-0.5 text-green-500'>✓</span>
|
||||
<span>{SCOPE_DESCRIPTIONS[s] ?? s}</span>
|
||||
</li>
|
||||
@@ -252,15 +259,20 @@ export default function OAuthConsentPage() {
|
||||
>
|
||||
Deny
|
||||
</Button>
|
||||
<BrandedButton
|
||||
fullWidth
|
||||
showArrow={false}
|
||||
loading={submitting}
|
||||
loadingText='Authorizing'
|
||||
<button
|
||||
onClick={() => handleConsent(true)}
|
||||
disabled={submitting}
|
||||
className={AUTH_SUBMIT_BTN}
|
||||
>
|
||||
Allow
|
||||
</BrandedButton>
|
||||
{submitting ? (
|
||||
<span className='flex items-center gap-2'>
|
||||
<Loader2 className='h-4 w-4 animate-spin' />
|
||||
Authorizing...
|
||||
</span>
|
||||
) : (
|
||||
'Allow'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -4,13 +4,13 @@ export default function ResetPasswordLoading() {
|
||||
return (
|
||||
<div className='flex flex-col items-center'>
|
||||
<Skeleton className='h-[38px] w-[160px] rounded-[4px]' />
|
||||
<Skeleton className='mt-[12px] h-[14px] w-[280px] rounded-[4px]' />
|
||||
<div className='mt-[32px] w-full space-y-[8px]'>
|
||||
<Skeleton className='mt-3 h-[14px] w-[280px] rounded-[4px]' />
|
||||
<div className='mt-8 w-full space-y-2'>
|
||||
<Skeleton className='h-[14px] w-[40px] rounded-[4px]' />
|
||||
<Skeleton className='h-[44px] w-full rounded-[10px]' />
|
||||
</div>
|
||||
<Skeleton className='mt-[24px] h-[44px] w-full rounded-[10px]' />
|
||||
<Skeleton className='mt-[24px] h-[14px] w-[120px] rounded-[4px]' />
|
||||
<Skeleton className='mt-6 h-[44px] w-full rounded-[10px]' />
|
||||
<Skeleton className='mt-6 h-[14px] w-[120px] rounded-[4px]' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -69,10 +69,10 @@ function ResetPasswordContent() {
|
||||
return (
|
||||
<>
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1 className='font-[430] font-season text-[40px] text-white leading-[110%] tracking-[-0.02em]'>
|
||||
<h1 className='text-balance font-[430] font-season text-[40px] text-white leading-[110%] tracking-[-0.02em]'>
|
||||
Reset your password
|
||||
</h1>
|
||||
<p className='font-[430] font-season text-[#F6F6F6]/60 text-[18px] leading-[125%] tracking-[0.02em]'>
|
||||
<p className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_60%,transparent)] text-lg leading-[125%] tracking-[0.02em]'>
|
||||
Enter a new password for your account
|
||||
</p>
|
||||
</div>
|
||||
@@ -87,10 +87,10 @@ function ResetPasswordContent() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='pt-6 text-center font-light text-[14px]'>
|
||||
<div className='pt-6 text-center font-light text-sm'>
|
||||
<Link
|
||||
href='/login'
|
||||
className='font-medium text-[#ECECEC] underline-offset-4 transition hover:text-white hover:underline'
|
||||
className='font-medium text-[var(--landing-text)] underline-offset-4 transition hover:text-white hover:underline'
|
||||
>
|
||||
Back to login
|
||||
</Link>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import { Eye, EyeOff, Loader2 } from 'lucide-react'
|
||||
import { Input, Label } from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { BrandedButton } from '@/app/(auth)/components/branded-button'
|
||||
import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes'
|
||||
|
||||
interface RequestResetFormProps {
|
||||
email: string
|
||||
@@ -46,7 +46,7 @@ export function RequestResetForm({
|
||||
disabled={isSubmitting}
|
||||
required
|
||||
/>
|
||||
<p className='text-[#999] text-sm'>
|
||||
<p className='text-[var(--landing-text-muted)] text-sm'>
|
||||
We'll send a password reset link to this email address.
|
||||
</p>
|
||||
</div>
|
||||
@@ -54,21 +54,26 @@ export function RequestResetForm({
|
||||
{/* Status message display */}
|
||||
{statusType && statusMessage && (
|
||||
<div
|
||||
className={cn('text-xs', statusType === 'success' ? 'text-[#4CAF50]' : 'text-red-400')}
|
||||
className={cn(
|
||||
'text-xs',
|
||||
statusType === 'success' ? 'text-[var(--success)]' : 'text-red-400'
|
||||
)}
|
||||
>
|
||||
<p>{statusMessage}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<BrandedButton
|
||||
type='submit'
|
||||
disabled={isSubmitting}
|
||||
loading={isSubmitting}
|
||||
loadingText='Sending'
|
||||
>
|
||||
Send Reset Link
|
||||
</BrandedButton>
|
||||
<button type='submit' disabled={isSubmitting} className={AUTH_SUBMIT_BTN}>
|
||||
{isSubmitting ? (
|
||||
<span className='flex items-center gap-2'>
|
||||
<Loader2 className='h-4 w-4 animate-spin' />
|
||||
Sending...
|
||||
</span>
|
||||
) : (
|
||||
'Send Reset Link'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -162,7 +167,7 @@ export function SetNewPasswordForm({
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className='-translate-y-1/2 absolute top-1/2 right-3 text-[#999] transition hover:text-[#ECECEC]'
|
||||
className='-translate-y-1/2 absolute top-1/2 right-3 text-[var(--landing-text-muted)] transition hover:text-[var(--landing-text)]'
|
||||
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||
>
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
@@ -190,7 +195,7 @@ export function SetNewPasswordForm({
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
className='-translate-y-1/2 absolute top-1/2 right-3 text-[#999] transition hover:text-[#ECECEC]'
|
||||
className='-translate-y-1/2 absolute top-1/2 right-3 text-[var(--landing-text-muted)] transition hover:text-[var(--landing-text)]'
|
||||
aria-label={showConfirmPassword ? 'Hide password' : 'Show password'}
|
||||
>
|
||||
{showConfirmPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
@@ -208,7 +213,7 @@ export function SetNewPasswordForm({
|
||||
<div
|
||||
className={cn(
|
||||
'mt-1 space-y-1 text-xs',
|
||||
statusType === 'success' ? 'text-[#4CAF50]' : 'text-red-400'
|
||||
statusType === 'success' ? 'text-[var(--success)]' : 'text-red-400'
|
||||
)}
|
||||
>
|
||||
<p>{statusMessage}</p>
|
||||
@@ -216,14 +221,16 @@ export function SetNewPasswordForm({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<BrandedButton
|
||||
type='submit'
|
||||
disabled={isSubmitting || !token}
|
||||
loading={isSubmitting}
|
||||
loadingText='Resetting'
|
||||
>
|
||||
Reset Password
|
||||
</BrandedButton>
|
||||
<button type='submit' disabled={isSubmitting || !token} className={AUTH_SUBMIT_BTN}>
|
||||
{isSubmitting ? (
|
||||
<span className='flex items-center gap-2'>
|
||||
<Loader2 className='h-4 w-4 animate-spin' />
|
||||
Resetting...
|
||||
</span>
|
||||
) : (
|
||||
'Reset Password'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,25 +4,25 @@ export default function SignupLoading() {
|
||||
return (
|
||||
<div className='flex flex-col items-center'>
|
||||
<Skeleton className='h-[38px] w-[100px] rounded-[4px]' />
|
||||
<div className='mt-[32px] w-full space-y-[8px]'>
|
||||
<div className='mt-8 w-full space-y-2'>
|
||||
<Skeleton className='h-[14px] w-[40px] rounded-[4px]' />
|
||||
<Skeleton className='h-[44px] w-full rounded-[10px]' />
|
||||
</div>
|
||||
<div className='mt-[16px] w-full space-y-[8px]'>
|
||||
<div className='mt-4 w-full space-y-2'>
|
||||
<Skeleton className='h-[14px] w-[40px] rounded-[4px]' />
|
||||
<Skeleton className='h-[44px] w-full rounded-[10px]' />
|
||||
</div>
|
||||
<div className='mt-[16px] w-full space-y-[8px]'>
|
||||
<div className='mt-4 w-full space-y-2'>
|
||||
<Skeleton className='h-[14px] w-[64px] rounded-[4px]' />
|
||||
<Skeleton className='h-[44px] w-full rounded-[10px]' />
|
||||
</div>
|
||||
<Skeleton className='mt-[24px] h-[44px] w-full rounded-[10px]' />
|
||||
<Skeleton className='mt-[24px] h-[1px] w-full rounded-[1px]' />
|
||||
<div className='mt-[24px] flex w-full gap-[12px]'>
|
||||
<Skeleton className='mt-6 h-[44px] w-full rounded-[10px]' />
|
||||
<Skeleton className='mt-6 h-[1px] w-full rounded-[1px]' />
|
||||
<div className='mt-6 flex w-full gap-3'>
|
||||
<Skeleton className='h-[44px] flex-1 rounded-[10px]' />
|
||||
<Skeleton className='h-[44px] flex-1 rounded-[10px]' />
|
||||
</div>
|
||||
<Skeleton className='mt-[24px] h-[14px] w-[220px] rounded-[4px]' />
|
||||
<Skeleton className='mt-6 h-[14px] w-[220px] rounded-[4px]' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { Suspense, useMemo, useRef, useState } from 'react'
|
||||
import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import { Eye, EyeOff, Loader2 } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { Input, Label } from '@/components/emcn'
|
||||
@@ -11,10 +11,9 @@ import { client, useSession } from '@/lib/auth/auth-client'
|
||||
import { getEnv, isFalsy, isTruthy } from '@/lib/core/config/env'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
||||
import { BrandedButton } from '@/app/(auth)/components/branded-button'
|
||||
import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes'
|
||||
import { SocialLoginButtons } from '@/app/(auth)/components/social-login-buttons'
|
||||
import { SSOLoginButton } from '@/app/(auth)/components/sso-login-button'
|
||||
import { useBrandedButtonClass } from '@/hooks/use-branded-button-class'
|
||||
|
||||
const logger = createLogger('SignupForm')
|
||||
|
||||
@@ -96,8 +95,6 @@ function SignupFormContent({
|
||||
const captchaResolveRef = useRef<((token: string) => void) | null>(null)
|
||||
const captchaRejectRef = useRef<((reason: Error) => void) | null>(null)
|
||||
const turnstileSiteKey = useMemo(() => getEnv('NEXT_PUBLIC_TURNSTILE_SITE_KEY'), [])
|
||||
const buttonClass = useBrandedButtonClass()
|
||||
|
||||
const redirectUrl = useMemo(
|
||||
() => searchParams.get('redirect') || searchParams.get('callbackUrl') || '',
|
||||
[searchParams]
|
||||
@@ -363,10 +360,10 @@ function SignupFormContent({
|
||||
return (
|
||||
<>
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1 className='font-[430] font-season text-[40px] text-white leading-[110%] tracking-[-0.02em]'>
|
||||
<h1 className='text-balance font-[430] font-season text-[40px] text-white leading-[110%] tracking-[-0.02em]'>
|
||||
Create an account
|
||||
</h1>
|
||||
<p className='font-[430] font-season text-[#F6F6F6]/60 text-[18px] leading-[125%] tracking-[0.02em]'>
|
||||
<p className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_60%,transparent)] text-lg leading-[125%] tracking-[0.02em]'>
|
||||
Create an account or log in
|
||||
</p>
|
||||
</div>
|
||||
@@ -380,115 +377,150 @@ function SignupFormContent({
|
||||
return hasOnlySSO
|
||||
})() && (
|
||||
<div className='mt-8'>
|
||||
<SSOLoginButton
|
||||
callbackURL={redirectUrl || '/workspace'}
|
||||
variant='primary'
|
||||
primaryClassName={buttonClass}
|
||||
/>
|
||||
<SSOLoginButton callbackURL={redirectUrl || '/workspace'} variant='primary' />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Email/Password Form - show unless explicitly disabled */}
|
||||
{!isFalsy(getEnv('NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED')) && (
|
||||
<form onSubmit={onSubmit} className='mt-8 space-y-8'>
|
||||
<form onSubmit={onSubmit} className='mt-8 space-y-10'>
|
||||
<div className='space-y-6'>
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Label htmlFor='name'>Full name</Label>
|
||||
</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(
|
||||
showNameValidationError &&
|
||||
nameErrors.length > 0 &&
|
||||
'border-red-500 focus:border-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 className='relative'>
|
||||
<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(
|
||||
showNameValidationError &&
|
||||
nameErrors.length > 0 &&
|
||||
'border-red-500 focus:border-red-500'
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute right-0 left-0 z-10 grid transition-[grid-template-rows] duration-200 ease-out',
|
||||
showNameValidationError && nameErrors.length > 0
|
||||
? 'grid-rows-[1fr]'
|
||||
: 'grid-rows-[0fr]'
|
||||
)}
|
||||
aria-live={showNameValidationError && nameErrors.length > 0 ? 'polite' : 'off'}
|
||||
>
|
||||
<div className='overflow-hidden'>
|
||||
<div className='mt-1 space-y-1 text-red-400 text-xs'>
|
||||
{nameErrors.map((error, index) => (
|
||||
<p key={index}>{error}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Label htmlFor='email'>Email</Label>
|
||||
</div>
|
||||
<Input
|
||||
id='email'
|
||||
name='email'
|
||||
placeholder='Enter your email'
|
||||
autoCapitalize='none'
|
||||
autoComplete='email'
|
||||
autoCorrect='off'
|
||||
value={email}
|
||||
onChange={handleEmailChange}
|
||||
className={cn(
|
||||
(emailError || (showEmailValidationError && emailErrors.length > 0)) &&
|
||||
'border-red-500 focus:border-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 className='relative'>
|
||||
<Input
|
||||
id='email'
|
||||
name='email'
|
||||
placeholder='Enter your email'
|
||||
autoCapitalize='none'
|
||||
autoComplete='email'
|
||||
autoCorrect='off'
|
||||
value={email}
|
||||
onChange={handleEmailChange}
|
||||
className={cn(
|
||||
(emailError || (showEmailValidationError && emailErrors.length > 0)) &&
|
||||
'border-red-500 focus:border-red-500'
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute right-0 left-0 z-10 grid transition-[grid-template-rows] duration-200 ease-out',
|
||||
(showEmailValidationError && emailErrors.length > 0) ||
|
||||
(emailError && !showEmailValidationError)
|
||||
? 'grid-rows-[1fr]'
|
||||
: 'grid-rows-[0fr]'
|
||||
)}
|
||||
aria-live={
|
||||
(showEmailValidationError && emailErrors.length > 0) ||
|
||||
(emailError && !showEmailValidationError)
|
||||
? 'polite'
|
||||
: 'off'
|
||||
}
|
||||
>
|
||||
<div className='overflow-hidden'>
|
||||
<div className='mt-1 space-y-1 text-red-400 text-xs'>
|
||||
{showEmailValidationError && emailErrors.length > 0 ? (
|
||||
emailErrors.map((error, index) => <p key={index}>{error}</p>)
|
||||
) : emailError && !showEmailValidationError ? (
|
||||
<p>{emailError}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{emailError && !showEmailValidationError && (
|
||||
<div className='mt-1 text-red-400 text-xs'>
|
||||
<p>{emailError}</p>
|
||||
</div>
|
||||
)}
|
||||
</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(
|
||||
'pr-10',
|
||||
showValidationError &&
|
||||
passwordErrors.length > 0 &&
|
||||
'border-red-500 focus:border-red-500'
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className='-translate-y-1/2 absolute top-1/2 right-3 text-[#999] transition hover:text-[#ECECEC]'
|
||||
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 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(
|
||||
'pr-10',
|
||||
showValidationError &&
|
||||
passwordErrors.length > 0 &&
|
||||
'border-red-500 focus:border-red-500'
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className='-translate-y-1/2 absolute top-1/2 right-3 text-[var(--landing-text-muted)] transition hover:text-[var(--landing-text)]'
|
||||
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||
>
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute right-0 left-0 z-10 grid transition-[grid-template-rows] duration-200 ease-out',
|
||||
showValidationError && passwordErrors.length > 0
|
||||
? 'grid-rows-[1fr]'
|
||||
: 'grid-rows-[0fr]'
|
||||
)}
|
||||
aria-live={showValidationError && passwordErrors.length > 0 ? 'polite' : 'off'}
|
||||
>
|
||||
<div className='overflow-hidden'>
|
||||
<div className='mt-1 space-y-1 text-red-400 text-xs'>
|
||||
{passwordErrors.map((error, index) => (
|
||||
<p key={index}>{error}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -509,14 +541,16 @@ function SignupFormContent({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<BrandedButton
|
||||
type='submit'
|
||||
disabled={isLoading}
|
||||
loading={isLoading}
|
||||
loadingText='Creating account'
|
||||
>
|
||||
Create account
|
||||
</BrandedButton>
|
||||
<button type='submit' disabled={isLoading} className={cn('!mt-6', AUTH_SUBMIT_BTN)}>
|
||||
{isLoading ? (
|
||||
<span className='flex items-center gap-2'>
|
||||
<Loader2 className='h-4 w-4 animate-spin' />
|
||||
Creating account...
|
||||
</span>
|
||||
) : (
|
||||
'Create account'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
@@ -532,10 +566,12 @@ function SignupFormContent({
|
||||
})() && (
|
||||
<div className='relative my-6 font-light'>
|
||||
<div className='absolute inset-0 flex items-center'>
|
||||
<div className='w-full border-[#2A2A2A] border-t' />
|
||||
<div className='w-full border-[var(--landing-bg-elevated)] border-t' />
|
||||
</div>
|
||||
<div className='relative flex justify-center text-sm'>
|
||||
<span className='bg-[#1C1C1C] px-4 font-[340] text-[#999]'>Or continue with</span>
|
||||
<span className='bg-[var(--landing-bg)] px-4 font-[340] text-[var(--landing-text-muted)]'>
|
||||
Or continue with
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -560,33 +596,29 @@ function SignupFormContent({
|
||||
isProduction={isProduction}
|
||||
>
|
||||
{isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED')) && (
|
||||
<SSOLoginButton
|
||||
callbackURL={redirectUrl || '/workspace'}
|
||||
variant='outline'
|
||||
primaryClassName={buttonClass}
|
||||
/>
|
||||
<SSOLoginButton callbackURL={redirectUrl || '/workspace'} variant='outline' />
|
||||
)}
|
||||
</SocialLoginButtons>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='pt-6 text-center font-light text-[14px]'>
|
||||
<div className='pt-6 text-center font-light text-sm'>
|
||||
<span className='font-normal'>Already have an account? </span>
|
||||
<Link
|
||||
href={isInviteFlow ? `/login?invite_flow=true&callbackUrl=${redirectUrl}` : '/login'}
|
||||
className='font-medium text-[#ECECEC] underline-offset-4 transition hover:text-white hover:underline'
|
||||
className='font-medium text-[var(--landing-text)] underline-offset-4 transition hover:text-white hover:underline'
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className='absolute right-0 bottom-0 left-0 px-8 pb-8 text-center font-[340] text-[#999] text-[13px] leading-relaxed sm:px-8 md:px-[44px]'>
|
||||
<div className='absolute right-0 bottom-0 left-0 px-8 pb-8 text-center font-[340] text-[var(--landing-text-muted)] text-small leading-relaxed sm:px-8 md:px-11'>
|
||||
By creating an account, you agree to our{' '}
|
||||
<Link
|
||||
href='/terms'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-[#999] underline-offset-4 transition hover:text-[#ECECEC] hover:underline'
|
||||
className='text-[var(--landing-text-muted)] underline-offset-4 transition hover:text-[var(--landing-text)] hover:underline'
|
||||
>
|
||||
Terms of Service
|
||||
</Link>{' '}
|
||||
@@ -595,7 +627,7 @@ function SignupFormContent({
|
||||
href='/privacy'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-[#999] underline-offset-4 transition hover:text-[#ECECEC] hover:underline'
|
||||
className='text-[var(--landing-text-muted)] underline-offset-4 transition hover:text-[var(--landing-text)] hover:underline'
|
||||
>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
|
||||
@@ -4,13 +4,13 @@ export default function SSOLoading() {
|
||||
return (
|
||||
<div className='flex flex-col items-center'>
|
||||
<Skeleton className='h-[38px] w-[120px] rounded-[4px]' />
|
||||
<Skeleton className='mt-[12px] h-[14px] w-[260px] rounded-[4px]' />
|
||||
<div className='mt-[32px] w-full space-y-[8px]'>
|
||||
<Skeleton className='mt-3 h-[14px] w-[260px] rounded-[4px]' />
|
||||
<div className='mt-8 w-full space-y-2'>
|
||||
<Skeleton className='h-[14px] w-[80px] rounded-[4px]' />
|
||||
<Skeleton className='h-[44px] w-full rounded-[10px]' />
|
||||
</div>
|
||||
<Skeleton className='mt-[24px] h-[44px] w-full rounded-[10px]' />
|
||||
<Skeleton className='mt-[24px] h-[14px] w-[120px] rounded-[4px]' />
|
||||
<Skeleton className='mt-6 h-[44px] w-full rounded-[10px]' />
|
||||
<Skeleton className='mt-6 h-[14px] w-[120px] rounded-[4px]' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,9 +4,9 @@ export default function VerifyLoading() {
|
||||
return (
|
||||
<div className='flex flex-col items-center'>
|
||||
<Skeleton className='h-[38px] w-[180px] rounded-[4px]' />
|
||||
<Skeleton className='mt-[12px] h-[14px] w-[300px] rounded-[4px]' />
|
||||
<Skeleton className='mt-[4px] h-[14px] w-[240px] rounded-[4px]' />
|
||||
<Skeleton className='mt-[32px] h-[44px] w-full rounded-[10px]' />
|
||||
<Skeleton className='mt-3 h-[14px] w-[300px] rounded-[4px]' />
|
||||
<Skeleton className='mt-1 h-[14px] w-[240px] rounded-[4px]' />
|
||||
<Skeleton className='mt-8 h-[44px] w-full rounded-[10px]' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import { Suspense, useEffect, useState } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { BrandedButton } from '@/app/(auth)/components/branded-button'
|
||||
import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes'
|
||||
import { useVerification } from '@/app/(auth)/verify/use-verification'
|
||||
|
||||
interface VerifyContentProps {
|
||||
@@ -59,10 +60,10 @@ function VerificationForm({
|
||||
return (
|
||||
<>
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1 className='font-[430] font-season text-[40px] text-white leading-[110%] tracking-[-0.02em]'>
|
||||
<h1 className='text-balance font-[430] font-season text-[40px] text-white leading-[110%] tracking-[-0.02em]'>
|
||||
{isVerified ? 'Email Verified!' : 'Verify Your Email'}
|
||||
</h1>
|
||||
<p className='font-[430] font-season text-[#F6F6F6]/60 text-[18px] leading-[125%] tracking-[0.02em]'>
|
||||
<p className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_60%,transparent)] text-lg leading-[125%] tracking-[0.02em]'>
|
||||
{isVerified
|
||||
? 'Your email has been verified. Redirecting to dashboard...'
|
||||
: !isEmailVerificationEnabled
|
||||
@@ -78,7 +79,7 @@ function VerificationForm({
|
||||
{!isVerified && isEmailVerificationEnabled && (
|
||||
<div className='mt-8 space-y-8'>
|
||||
<div className='space-y-6'>
|
||||
<p className='text-center text-[#999] text-sm'>
|
||||
<p className='text-center text-[var(--landing-text-muted)] text-sm'>
|
||||
Enter the 6-digit code to verify your account.
|
||||
{hasEmailService ? " If you don't see it in your inbox, check your spam folder." : ''}
|
||||
</p>
|
||||
@@ -110,27 +111,33 @@ function VerificationForm({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<BrandedButton
|
||||
<button
|
||||
onClick={verifyCode}
|
||||
disabled={!isOtpComplete || isLoading}
|
||||
loading={isLoading}
|
||||
loadingText='Verifying'
|
||||
showArrow={false}
|
||||
className={AUTH_SUBMIT_BTN}
|
||||
>
|
||||
Verify Email
|
||||
</BrandedButton>
|
||||
{isLoading ? (
|
||||
<span className='flex items-center gap-2'>
|
||||
<Loader2 className='h-4 w-4 animate-spin' />
|
||||
Verifying...
|
||||
</span>
|
||||
) : (
|
||||
'Verify Email'
|
||||
)}
|
||||
</button>
|
||||
|
||||
{hasEmailService && (
|
||||
<div className='text-center'>
|
||||
<p className='text-[#999] text-sm'>
|
||||
<p className='text-[var(--landing-text-muted)] text-sm'>
|
||||
Didn't receive a code?{' '}
|
||||
{countdown > 0 ? (
|
||||
<span>
|
||||
Resend in <span className='font-medium text-[#ECECEC]'>{countdown}s</span>
|
||||
Resend in{' '}
|
||||
<span className='font-medium text-[var(--landing-text)]'>{countdown}s</span>
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
className='font-medium text-[#ECECEC] underline-offset-4 transition hover:text-white hover:underline'
|
||||
className='font-medium text-[var(--landing-text)] underline-offset-4 transition hover:text-white hover:underline'
|
||||
onClick={handleResend}
|
||||
disabled={isLoading || isResendDisabled}
|
||||
>
|
||||
@@ -141,7 +148,7 @@ function VerificationForm({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='text-center font-light text-[14px]'>
|
||||
<div className='text-center font-light text-sm'>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
@@ -151,7 +158,7 @@ function VerificationForm({
|
||||
}
|
||||
router.push('/signup')
|
||||
}}
|
||||
className='font-medium text-[#ECECEC] underline-offset-4 transition hover:text-white hover:underline'
|
||||
className='font-medium text-[var(--landing-text)] underline-offset-4 transition hover:text-white hover:underline'
|
||||
>
|
||||
Back to signup
|
||||
</button>
|
||||
@@ -166,8 +173,8 @@ function VerificationFormFallback() {
|
||||
return (
|
||||
<div className='text-center'>
|
||||
<div className='animate-pulse'>
|
||||
<div className='mx-auto mb-4 h-8 w-48 rounded bg-[#2A2A2A]' />
|
||||
<div className='mx-auto h-4 w-64 rounded bg-[#2A2A2A]' />
|
||||
<div className='mx-auto mb-4 h-8 w-48 rounded bg-[var(--surface-4)]' />
|
||||
<div className='mx-auto h-4 w-64 rounded bg-[var(--surface-4)]' />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -25,7 +25,7 @@ function DotGrid({ className, cols, rows, gap = 0 }: DotGridProps) {
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: cols * rows }, (_, i) => (
|
||||
<div key={i} className='h-[1.5px] w-[1.5px] rounded-full bg-[#2A2A2A]' />
|
||||
<div key={i} className='h-[1.5px] w-[1.5px] rounded-full bg-[var(--landing-bg-elevated)]' />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
@@ -89,7 +89,7 @@ function VikhyathCursor() {
|
||||
<div className='absolute top-0 left-[56.02px]'>
|
||||
<CursorArrow fill='#2ABBF8' />
|
||||
</div>
|
||||
<div className='-left-[4px] absolute top-[18px] flex items-center rounded bg-[#2ABBF8] px-[5px] py-[3px] font-[420] font-season text-[#202020] text-[14px] leading-[100%] tracking-[-0.02em]'>
|
||||
<div className='-left-[4px] absolute top-4.5 flex items-center rounded bg-[#2ABBF8] px-[5px] py-[3px] font-[420] font-season text-[var(--landing-text-dark)] text-sm leading-[100%] tracking-[-0.02em]'>
|
||||
Vikhyath
|
||||
</div>
|
||||
</div>
|
||||
@@ -113,7 +113,7 @@ function AlexaCursor() {
|
||||
<div className='absolute top-0 left-0'>
|
||||
<CursorArrow fill='#FFCC02' />
|
||||
</div>
|
||||
<div className='absolute top-[16px] left-[23px] flex items-center rounded bg-[#FFCC02] px-[5px] py-[3px] font-[420] font-season text-[#202020] text-[14px] leading-[100%] tracking-[-0.02em]'>
|
||||
<div className='absolute top-4 left-[23px] flex items-center rounded bg-[#FFCC02] px-[5px] py-[3px] font-[420] font-season text-[var(--landing-text-dark)] text-sm leading-[100%] tracking-[-0.02em]'>
|
||||
Alexa
|
||||
</div>
|
||||
</div>
|
||||
@@ -143,7 +143,7 @@ function YouCursor({ x, y, visible }: YouCursorProps) {
|
||||
<svg width='23.15' height='21.1' viewBox='0 0 17.5 16.4' fill='none'>
|
||||
<path d={CURSOR_ARROW_MIRRORED_PATH} fill='#33C482' />
|
||||
</svg>
|
||||
<div className='absolute top-[16px] left-[23px] flex items-center rounded bg-[#33C482] px-[5px] py-[3px] font-[420] font-season text-[#202020] text-[14px] leading-[100%] tracking-[-0.02em]'>
|
||||
<div className='absolute top-4 left-[23px] flex items-center rounded bg-[var(--brand-accent)] px-[5px] py-[3px] font-[420] font-season text-[var(--landing-text-dark)] text-sm leading-[100%] tracking-[-0.02em]'>
|
||||
You
|
||||
</div>
|
||||
</div>
|
||||
@@ -212,7 +212,7 @@ export default function Collaboration() {
|
||||
ref={sectionRef}
|
||||
id='collaboration'
|
||||
aria-labelledby='collaboration-heading'
|
||||
className='bg-[#1C1C1C]'
|
||||
className='bg-[var(--landing-bg)]'
|
||||
style={{ cursor: isHovering ? 'none' : 'auto' }}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
@@ -222,7 +222,7 @@ export default function Collaboration() {
|
||||
<style dangerouslySetInnerHTML={{ __html: CURSOR_KEYFRAMES }} />
|
||||
|
||||
<DotGrid
|
||||
className='overflow-hidden border-[#2A2A2A] border-y bg-[#1C1C1C] p-[6px]'
|
||||
className='overflow-hidden border-[var(--landing-bg-elevated)] border-y bg-[var(--landing-bg)] p-1.5'
|
||||
cols={120}
|
||||
rows={1}
|
||||
gap={6}
|
||||
@@ -230,33 +230,33 @@ export default function Collaboration() {
|
||||
|
||||
<div className='relative overflow-hidden'>
|
||||
<div className='grid grid-cols-1 md:grid-cols-[auto_1fr]'>
|
||||
<div className='flex flex-col items-start gap-3 px-4 pt-[60px] pb-8 sm:gap-4 sm:px-8 md:gap-[20px] md:px-[80px] md:pt-[100px]'>
|
||||
<div className='flex flex-col items-start gap-3 px-4 pt-[60px] pb-8 sm:gap-4 sm:px-8 md:gap-5 md:px-20 md:pt-[100px]'>
|
||||
<Badge
|
||||
variant='blue'
|
||||
size='md'
|
||||
dot
|
||||
className='bg-[#33C482]/10 font-season text-[#33C482] uppercase tracking-[0.02em]'
|
||||
className='bg-[color-mix(in_srgb,var(--brand-accent)_10%,transparent)] font-season text-[var(--brand-accent)] uppercase tracking-[0.02em]'
|
||||
>
|
||||
Teams
|
||||
</Badge>
|
||||
|
||||
<h2
|
||||
id='collaboration-heading'
|
||||
className='font-[430] font-season text-[32px] text-white leading-[100%] tracking-[-0.02em] sm:text-[36px] md:text-[40px]'
|
||||
className='text-balance font-[430] font-season text-[32px] text-white leading-[100%] tracking-[-0.02em] sm:text-[36px] md:text-[40px]'
|
||||
>
|
||||
Realtime
|
||||
<br />
|
||||
collaboration
|
||||
</h2>
|
||||
|
||||
<p className='font-[430] font-season text-[#F6F6F0]/50 text-[15px] leading-[150%] tracking-[0.02em] md:text-[18px]'>
|
||||
<p className='font-[430] font-season text-[#F6F6F0]/50 text-base leading-[150%] tracking-[0.02em] md:text-lg'>
|
||||
Grab your team. Build agents together <br className='hidden md:block' />
|
||||
in real-time inside your workspace.
|
||||
</p>
|
||||
|
||||
<Link
|
||||
href='/signup'
|
||||
className='group/cta mt-[12px] inline-flex h-[32px] cursor-none items-center gap-[6px] rounded-[5px] border border-[#FFFFFF] bg-[#FFFFFF] px-[10px] font-[430] font-season text-[14px] text-black transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]'
|
||||
className='group/cta mt-3 inline-flex h-[32px] cursor-none items-center gap-1.5 rounded-[5px] border border-white bg-white px-2.5 font-[430] font-season text-black text-sm transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]'
|
||||
>
|
||||
Build together
|
||||
<span className='relative h-[10px] w-[10px] shrink-0'>
|
||||
@@ -288,7 +288,6 @@ export default function Collaboration() {
|
||||
width={876}
|
||||
height={480}
|
||||
className='h-full w-auto object-left md:min-w-[100vw]'
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
<div className='hidden lg:block'>
|
||||
@@ -306,16 +305,16 @@ export default function Collaboration() {
|
||||
href='/blog/multiplayer'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='relative mx-4 mb-6 flex cursor-none items-center gap-[14px] rounded-[5px] border border-[#2A2A2A] bg-[#1C1C1C] px-[12px] py-[10px] transition-colors hover:border-[#3d3d3d] hover:bg-[#232323] sm:mx-8 md:absolute md:bottom-10 md:left-[80px] md:z-20 md:mx-0 md:mb-0'
|
||||
className='relative mx-4 mb-6 flex cursor-none items-center gap-3.5 rounded-[5px] border border-[var(--landing-bg-elevated)] bg-[var(--landing-bg)] px-3 py-2.5 transition-colors hover:border-[var(--landing-border-strong)] hover:bg-[var(--landing-bg-card)] sm:mx-8 md:absolute md:bottom-10 md:left-20 md:z-20 md:mx-0 md:mb-0'
|
||||
>
|
||||
<div className='relative h-7 w-11 shrink-0'>
|
||||
<Image src='/landing/multiplayer-cursors.svg' alt='' fill className='object-contain' />
|
||||
</div>
|
||||
<div className='flex flex-col gap-[2px]'>
|
||||
<span className='font-[430] font-season text-[#F6F6F0]/50 text-[12px] uppercase leading-[100%] tracking-[0.08em]'>
|
||||
<div className='flex flex-col gap-0.5'>
|
||||
<span className='font-[430] font-season text-[#F6F6F0]/50 text-caption uppercase leading-[100%] tracking-[0.08em]'>
|
||||
Blog
|
||||
</span>
|
||||
<span className='font-[430] font-season text-[#F6F6F0] text-[14px] leading-[125%] tracking-[0.02em]'>
|
||||
<span className='font-[430] font-season text-[#F6F6F0] text-sm leading-[125%] tracking-[0.02em]'>
|
||||
How we built realtime collaboration
|
||||
</span>
|
||||
</div>
|
||||
@@ -323,7 +322,7 @@ export default function Collaboration() {
|
||||
</div>
|
||||
|
||||
<DotGrid
|
||||
className='overflow-hidden border-[#2A2A2A] border-y bg-[#1C1C1C] p-[6px]'
|
||||
className='overflow-hidden border-[var(--landing-bg-elevated)] border-y bg-[var(--landing-bg)] p-1.5'
|
||||
cols={120}
|
||||
rows={1}
|
||||
gap={6}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import freeEmailDomains from 'free-email-domains'
|
||||
import { z } from 'zod'
|
||||
import { NO_EMAIL_HEADER_CONTROL_CHARS_REGEX } from '@/lib/messaging/email/utils'
|
||||
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
||||
|
||||
const FREE_EMAIL_DOMAINS = new Set(freeEmailDomains)
|
||||
|
||||
export const DEMO_REQUEST_REGION_VALUES = [
|
||||
'north_america',
|
||||
'europe',
|
||||
@@ -11,13 +14,14 @@ export const DEMO_REQUEST_REGION_VALUES = [
|
||||
'other',
|
||||
] as const
|
||||
|
||||
export const DEMO_REQUEST_USER_COUNT_VALUES = [
|
||||
export const DEMO_REQUEST_COMPANY_SIZE_VALUES = [
|
||||
'1_10',
|
||||
'11_50',
|
||||
'51_200',
|
||||
'201_500',
|
||||
'501_1000',
|
||||
'1000_plus',
|
||||
'1001_10000',
|
||||
'10000_plus',
|
||||
] as const
|
||||
|
||||
export const DEMO_REQUEST_REGION_OPTIONS = [
|
||||
@@ -29,13 +33,14 @@ export const DEMO_REQUEST_REGION_OPTIONS = [
|
||||
{ value: 'other', label: 'Other' },
|
||||
] as const
|
||||
|
||||
export const DEMO_REQUEST_USER_COUNT_OPTIONS = [
|
||||
{ value: '1_10', label: '1-10' },
|
||||
{ value: '11_50', label: '11-50' },
|
||||
{ value: '51_200', label: '51-200' },
|
||||
{ value: '201_500', label: '201-500' },
|
||||
{ value: '501_1000', label: '501-1,000' },
|
||||
{ value: '1000_plus', label: '1,000+' },
|
||||
export const DEMO_REQUEST_COMPANY_SIZE_OPTIONS = [
|
||||
{ value: '1_10', label: '1–10' },
|
||||
{ value: '11_50', label: '11–50' },
|
||||
{ value: '51_200', label: '51–200' },
|
||||
{ value: '201_500', label: '201–500' },
|
||||
{ value: '501_1000', label: '501–1,000' },
|
||||
{ value: '1001_10000', label: '1,001–10,000' },
|
||||
{ value: '10000_plus', label: '10,000+' },
|
||||
] as const
|
||||
|
||||
export const demoRequestSchema = z.object({
|
||||
@@ -57,7 +62,11 @@ export const demoRequestSchema = z.object({
|
||||
.min(1, 'Company email is required')
|
||||
.max(320)
|
||||
.transform((value) => value.toLowerCase())
|
||||
.refine((value) => quickValidateEmail(value).isValid, 'Enter a valid work email'),
|
||||
.refine((value) => quickValidateEmail(value).isValid, 'Enter a valid work email')
|
||||
.refine((value) => {
|
||||
const domain = value.split('@')[1]
|
||||
return domain ? !FREE_EMAIL_DOMAINS.has(domain) : true
|
||||
}, 'Please use your work email address'),
|
||||
phoneNumber: z
|
||||
.string()
|
||||
.trim()
|
||||
@@ -67,8 +76,8 @@ export const demoRequestSchema = z.object({
|
||||
region: z.enum(DEMO_REQUEST_REGION_VALUES, {
|
||||
errorMap: () => ({ message: 'Please select a region' }),
|
||||
}),
|
||||
userCount: z.enum(DEMO_REQUEST_USER_COUNT_VALUES, {
|
||||
errorMap: () => ({ message: 'Please select the number of users' }),
|
||||
companySize: z.enum(DEMO_REQUEST_COMPANY_SIZE_VALUES, {
|
||||
errorMap: () => ({ message: 'Please select company size' }),
|
||||
}),
|
||||
details: z.string().trim().min(1, 'Details are required').max(2000),
|
||||
})
|
||||
@@ -79,6 +88,6 @@ export function getDemoRequestRegionLabel(value: DemoRequestPayload['region']):
|
||||
return DEMO_REQUEST_REGION_OPTIONS.find((option) => option.value === value)?.label ?? value
|
||||
}
|
||||
|
||||
export function getDemoRequestUserCountLabel(value: DemoRequestPayload['userCount']): string {
|
||||
return DEMO_REQUEST_USER_COUNT_OPTIONS.find((option) => option.value === value)?.label ?? value
|
||||
export function getDemoRequestCompanySizeLabel(value: DemoRequestPayload['companySize']): string {
|
||||
return DEMO_REQUEST_COMPANY_SIZE_OPTIONS.find((option) => option.value === value)?.label ?? value
|
||||
}
|
||||
|
||||
@@ -16,8 +16,8 @@ import {
|
||||
} from '@/components/emcn'
|
||||
import { Check } from '@/components/emcn/icons'
|
||||
import {
|
||||
DEMO_REQUEST_COMPANY_SIZE_OPTIONS,
|
||||
DEMO_REQUEST_REGION_OPTIONS,
|
||||
DEMO_REQUEST_USER_COUNT_OPTIONS,
|
||||
type DemoRequestPayload,
|
||||
demoRequestSchema,
|
||||
} from '@/app/(home)/components/demo-request/consts'
|
||||
@@ -36,13 +36,13 @@ interface DemoRequestFormState {
|
||||
companyEmail: string
|
||||
phoneNumber: string
|
||||
region: DemoRequestPayload['region'] | ''
|
||||
userCount: DemoRequestPayload['userCount'] | ''
|
||||
companySize: DemoRequestPayload['companySize'] | ''
|
||||
details: string
|
||||
}
|
||||
|
||||
const SUBMIT_SUCCESS_MESSAGE = "We'll be in touch soon!"
|
||||
const COMBOBOX_REGIONS = [...DEMO_REQUEST_REGION_OPTIONS]
|
||||
const COMBOBOX_USER_COUNTS = [...DEMO_REQUEST_USER_COUNT_OPTIONS]
|
||||
const COMBOBOX_COMPANY_SIZES = [...DEMO_REQUEST_COMPANY_SIZE_OPTIONS]
|
||||
|
||||
const INITIAL_FORM_STATE: DemoRequestFormState = {
|
||||
firstName: '',
|
||||
@@ -50,7 +50,7 @@ const INITIAL_FORM_STATE: DemoRequestFormState = {
|
||||
companyEmail: '',
|
||||
phoneNumber: '',
|
||||
region: '',
|
||||
userCount: '',
|
||||
companySize: '',
|
||||
details: '',
|
||||
}
|
||||
|
||||
@@ -118,7 +118,7 @@ export function DemoRequestModal({ children, theme = 'dark' }: DemoRequestModalP
|
||||
companyEmail: fieldErrors.companyEmail?.[0],
|
||||
phoneNumber: fieldErrors.phoneNumber?.[0],
|
||||
region: fieldErrors.region?.[0],
|
||||
userCount: fieldErrors.userCount?.[0],
|
||||
companySize: fieldErrors.companySize?.[0],
|
||||
details: fieldErrors.details?.[0],
|
||||
})
|
||||
return
|
||||
@@ -235,13 +235,13 @@ export function DemoRequestModal({ children, theme = 'dark' }: DemoRequestModalP
|
||||
filterOptions={false}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField htmlFor='userCount' label='Number of users' error={errors.userCount}>
|
||||
<FormField htmlFor='companySize' label='Company size' error={errors.companySize}>
|
||||
<Combobox
|
||||
options={COMBOBOX_USER_COUNTS}
|
||||
value={form.userCount}
|
||||
selectedValue={form.userCount}
|
||||
options={COMBOBOX_COMPANY_SIZES}
|
||||
value={form.companySize}
|
||||
selectedValue={form.companySize}
|
||||
onChange={(value) =>
|
||||
updateField('userCount', value as DemoRequestPayload['userCount'])
|
||||
updateField('companySize', value as DemoRequestPayload['companySize'])
|
||||
}
|
||||
placeholder='Select'
|
||||
editable={false}
|
||||
|
||||
@@ -81,6 +81,56 @@ function ProviderPreviewIcon({ providerId }: { providerId?: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
interface FeatureToggleItemProps {
|
||||
feature: PermissionFeature
|
||||
enabled: boolean
|
||||
color: string
|
||||
isInView: boolean
|
||||
delay: number
|
||||
textClassName: string
|
||||
transition: Record<string, unknown>
|
||||
onToggle: () => void
|
||||
}
|
||||
|
||||
function FeatureToggleItem({
|
||||
feature,
|
||||
enabled,
|
||||
color,
|
||||
isInView,
|
||||
delay,
|
||||
textClassName,
|
||||
transition,
|
||||
onToggle,
|
||||
}: FeatureToggleItemProps) {
|
||||
return (
|
||||
<motion.div
|
||||
key={feature.key}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
aria-label={`Toggle ${feature.name}`}
|
||||
aria-pressed={enabled}
|
||||
className='flex cursor-pointer items-center gap-2 rounded-[4px] py-0.5'
|
||||
initial={{ opacity: 0, x: -6 }}
|
||||
animate={isInView ? { opacity: 1, x: 0 } : {}}
|
||||
transition={{ ...transition, delay }}
|
||||
onClick={onToggle}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onToggle()
|
||||
}
|
||||
}}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<CheckboxIcon checked={enabled} color={color} />
|
||||
<ProviderPreviewIcon providerId={feature.providerId} />
|
||||
<span className={textClassName} style={{ color: enabled ? '#F6F6F6AA' : '#F6F6F640' }}>
|
||||
{feature.name}
|
||||
</span>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AccessControlPanel() {
|
||||
const ref = useRef(null)
|
||||
const isInView = useInView(ref, { once: true, margin: '-40px' })
|
||||
@@ -97,39 +147,25 @@ export function AccessControlPanel() {
|
||||
|
||||
return (
|
||||
<div key={category.label} className={catIdx > 0 ? 'mt-4' : ''}>
|
||||
<span className='font-[430] font-season text-[#F6F6F6]/30 text-[10px] uppercase leading-none tracking-[0.08em]'>
|
||||
<span className='font-[430] font-season text-[#F6F6F6]/55 text-[10px] uppercase leading-none tracking-[0.08em]'>
|
||||
{category.label}
|
||||
</span>
|
||||
<div className='mt-[8px] grid grid-cols-2 gap-x-4 gap-y-[8px]'>
|
||||
{category.features.map((feature, featIdx) => {
|
||||
const enabled = accessState[feature.key]
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={feature.key}
|
||||
className='flex cursor-pointer items-center gap-[8px] rounded-[4px] py-[2px]'
|
||||
initial={{ opacity: 0, x: -6 }}
|
||||
animate={isInView ? { opacity: 1, x: 0 } : {}}
|
||||
transition={{
|
||||
delay: 0.05 + (offsetBefore + featIdx) * 0.04,
|
||||
duration: 0.3,
|
||||
}}
|
||||
onClick={() =>
|
||||
setAccessState((prev) => ({ ...prev, [feature.key]: !prev[feature.key] }))
|
||||
}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<CheckboxIcon checked={enabled} color={category.color} />
|
||||
<ProviderPreviewIcon providerId={feature.providerId} />
|
||||
<span
|
||||
className='truncate font-[430] font-season text-[13px] leading-none tracking-[0.02em]'
|
||||
style={{ color: enabled ? '#F6F6F6AA' : '#F6F6F640' }}
|
||||
>
|
||||
{feature.name}
|
||||
</span>
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
<div className='mt-2 grid grid-cols-2 gap-x-4 gap-y-2'>
|
||||
{category.features.map((feature, featIdx) => (
|
||||
<FeatureToggleItem
|
||||
key={feature.key}
|
||||
feature={feature}
|
||||
enabled={accessState[feature.key]}
|
||||
color={category.color}
|
||||
isInView={isInView}
|
||||
delay={0.05 + (offsetBefore + featIdx) * 0.04}
|
||||
textClassName='truncate font-[430] font-season text-[13px] leading-none tracking-[0.02em]'
|
||||
transition={{ duration: 0.3 }}
|
||||
onToggle={() =>
|
||||
setAccessState((prev) => ({ ...prev, [feature.key]: !prev[feature.key] }))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -140,12 +176,11 @@ export function AccessControlPanel() {
|
||||
<div className='hidden lg:block'>
|
||||
{PERMISSION_CATEGORIES.map((category, catIdx) => (
|
||||
<div key={category.label} className={catIdx > 0 ? 'mt-4' : ''}>
|
||||
<span className='font-[430] font-season text-[#F6F6F6]/30 text-[10px] uppercase leading-none tracking-[0.08em]'>
|
||||
<span className='font-[430] font-season text-[#F6F6F6]/55 text-[10px] uppercase leading-none tracking-[0.08em]'>
|
||||
{category.label}
|
||||
</span>
|
||||
<div className='mt-[8px] grid grid-cols-2 gap-x-4 gap-y-[8px]'>
|
||||
<div className='mt-2 grid grid-cols-2 gap-x-4 gap-y-2'>
|
||||
{category.features.map((feature, featIdx) => {
|
||||
const enabled = accessState[feature.key]
|
||||
const currentIndex =
|
||||
PERMISSION_CATEGORIES.slice(0, catIdx).reduce(
|
||||
(sum, c) => sum + c.features.length,
|
||||
@@ -153,30 +188,19 @@ export function AccessControlPanel() {
|
||||
) + featIdx
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
<FeatureToggleItem
|
||||
key={feature.key}
|
||||
className='flex cursor-pointer items-center gap-[8px] rounded-[4px] py-[2px]'
|
||||
initial={{ opacity: 0, x: -6 }}
|
||||
animate={isInView ? { opacity: 1, x: 0 } : {}}
|
||||
transition={{
|
||||
delay: 0.1 + currentIndex * 0.04,
|
||||
duration: 0.3,
|
||||
ease: [0.25, 0.46, 0.45, 0.94],
|
||||
}}
|
||||
onClick={() =>
|
||||
feature={feature}
|
||||
enabled={accessState[feature.key]}
|
||||
color={category.color}
|
||||
isInView={isInView}
|
||||
delay={0.1 + currentIndex * 0.04}
|
||||
textClassName='truncate font-[430] font-season text-[11px] leading-none tracking-[0.02em] transition-opacity duration-200'
|
||||
transition={{ duration: 0.3, ease: [0.25, 0.46, 0.45, 0.94] }}
|
||||
onToggle={() =>
|
||||
setAccessState((prev) => ({ ...prev, [feature.key]: !prev[feature.key] }))
|
||||
}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<CheckboxIcon checked={enabled} color={category.color} />
|
||||
<ProviderPreviewIcon providerId={feature.providerId} />
|
||||
<span
|
||||
className='truncate font-[430] font-season text-[11px] leading-none tracking-[0.02em] transition-opacity duration-200'
|
||||
style={{ color: enabled ? '#F6F6F6AA' : '#F6F6F640' }}
|
||||
>
|
||||
{feature.name}
|
||||
</span>
|
||||
</motion.div>
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -125,7 +125,7 @@ function AuditRow({ entry, index }: AuditRowProps) {
|
||||
const timeAgo = formatTimeAgo(entry.insertedAt)
|
||||
|
||||
return (
|
||||
<div className='group relative overflow-hidden border-[#2A2A2A] border-b bg-[#1C1C1C] transition-colors duration-150 last:border-b-0 hover:bg-[#212121]'>
|
||||
<div className='group relative overflow-hidden border-[var(--landing-border)] border-b bg-[var(--landing-bg)] transition-colors duration-150 last:border-b-0 hover:bg-[#212121]'>
|
||||
{/* Left accent bar -- brightness encodes recency */}
|
||||
<div
|
||||
aria-hidden='true'
|
||||
@@ -146,14 +146,14 @@ function AuditRow({ entry, index }: AuditRowProps) {
|
||||
</div>
|
||||
|
||||
{/* Time */}
|
||||
<span className='w-[56px] shrink-0 font-[430] font-season text-[#F6F6F6]/30 text-[11px] leading-none tracking-[0.02em]'>
|
||||
<span className='w-[56px] shrink-0 font-[430] font-season text-[#F6F6F6]/55 text-[11px] leading-none tracking-[0.02em]'>
|
||||
{timeAgo}
|
||||
</span>
|
||||
|
||||
<span className='min-w-0 truncate font-[430] font-season text-[12px] leading-none tracking-[0.02em]'>
|
||||
<span className='text-[#F6F6F6]/80'>{entry.actor}</span>
|
||||
<span className='hidden sm:inline'>
|
||||
<span className='text-[#F6F6F6]/40'> · </span>
|
||||
<span className='text-[#F6F6F6]/60'> · </span>
|
||||
<span className='text-[#F6F6F6]/55'>{entry.description}</span>
|
||||
</span>
|
||||
</span>
|
||||
@@ -209,8 +209,8 @@ export function AuditLogPreview() {
|
||||
transition={{
|
||||
layout: {
|
||||
type: 'spring',
|
||||
stiffness: 380,
|
||||
damping: 38,
|
||||
stiffness: 350,
|
||||
damping: 50,
|
||||
mass: 0.8,
|
||||
},
|
||||
y: { duration: 0.32, ease: [0.25, 0.46, 0.45, 0.94] },
|
||||
|
||||
@@ -22,13 +22,27 @@ import { DemoRequestModal } from '@/app/(home)/components/demo-request/demo-requ
|
||||
import { AccessControlPanel } from '@/app/(home)/components/enterprise/components/access-control-panel'
|
||||
import { AuditLogPreview } from '@/app/(home)/components/enterprise/components/audit-log-preview'
|
||||
|
||||
const MARQUEE_KEYFRAMES = `
|
||||
@keyframes marquee {
|
||||
const ENTERPRISE_FEATURE_MARQUEE_STYLES = `
|
||||
@keyframes enterprise-feature-marquee {
|
||||
0% { transform: translateX(0); }
|
||||
100% { transform: translateX(-25%); }
|
||||
}
|
||||
.enterprise-feature-marquee-track {
|
||||
animation: enterprise-feature-marquee 30s linear infinite;
|
||||
}
|
||||
.enterprise-feature-marquee:hover .enterprise-feature-marquee-track {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
.enterprise-feature-marquee-tag {
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
@keyframes marquee { 0%, 100% { transform: none; } }
|
||||
.enterprise-feature-marquee-track {
|
||||
animation: none;
|
||||
}
|
||||
.enterprise-feature-marquee-tag {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
@@ -51,13 +65,13 @@ const FEATURE_TAGS = [
|
||||
|
||||
function TrustStrip() {
|
||||
return (
|
||||
<div className='mx-6 mt-4 grid grid-cols-1 overflow-hidden rounded-[8px] border border-[#2A2A2A] sm:grid-cols-3 md:mx-8'>
|
||||
<div className='mx-6 mt-4 grid grid-cols-1 overflow-hidden rounded-lg border border-[var(--landing-bg-elevated)] sm:grid-cols-3 md:mx-8'>
|
||||
{/* SOC 2 + HIPAA combined */}
|
||||
<Link
|
||||
href='https://app.vanta.com/sim.ai/trust/v35ia0jil4l7dteqjgaktn'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='group flex items-center gap-3 border-[#2A2A2A] border-b px-4 py-[14px] transition-colors hover:bg-[#212121] sm:border-r sm:border-b-0'
|
||||
className='group flex items-center gap-3 border-[var(--landing-bg-elevated)] border-b px-4 py-3.5 transition-colors hover:bg-[#212121] sm:border-r sm:border-b-0'
|
||||
>
|
||||
<Image
|
||||
src='/footer/soc2.png'
|
||||
@@ -65,12 +79,13 @@ function TrustStrip() {
|
||||
width={22}
|
||||
height={22}
|
||||
className='shrink-0 object-contain'
|
||||
unoptimized
|
||||
/>
|
||||
<div className='flex flex-col gap-[3px]'>
|
||||
<strong className='font-[430] font-season text-[13px] text-white leading-none'>
|
||||
<strong className='font-[430] font-season text-small text-white leading-none'>
|
||||
SOC 2 & HIPAA
|
||||
</strong>
|
||||
<span className='font-[430] font-season text-[#F6F6F6]/30 text-[11px] leading-none tracking-[0.02em] transition-colors group-hover:text-[#F6F6F6]/55'>
|
||||
<span className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_55%,transparent)] text-xs leading-none tracking-[0.02em] transition-colors group-hover:text-[color-mix(in_srgb,var(--landing-text-subtle)_75%,transparent)]'>
|
||||
Type II · PHI protected →
|
||||
</span>
|
||||
</div>
|
||||
@@ -81,31 +96,31 @@ function TrustStrip() {
|
||||
href='https://github.com/simstudioai/sim'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='group flex items-center gap-3 border-[#2A2A2A] border-b px-4 py-[14px] transition-colors hover:bg-[#212121] sm:border-r sm:border-b-0'
|
||||
className='group flex items-center gap-3 border-[var(--landing-bg-elevated)] border-b px-4 py-3.5 transition-colors hover:bg-[#212121] sm:border-r sm:border-b-0'
|
||||
>
|
||||
<div className='flex h-[22px] w-[22px] shrink-0 items-center justify-center rounded-full bg-[#FFCC02]/10'>
|
||||
<GithubIcon width={11} height={11} className='text-[#FFCC02]/75' />
|
||||
</div>
|
||||
<div className='flex flex-col gap-[3px]'>
|
||||
<strong className='font-[430] font-season text-[13px] text-white leading-none'>
|
||||
<strong className='font-[430] font-season text-small text-white leading-none'>
|
||||
Open Source
|
||||
</strong>
|
||||
<span className='font-[430] font-season text-[#F6F6F6]/30 text-[11px] leading-none tracking-[0.02em] transition-colors group-hover:text-[#F6F6F6]/55'>
|
||||
<span className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_55%,transparent)] text-xs leading-none tracking-[0.02em] transition-colors group-hover:text-[color-mix(in_srgb,var(--landing-text-subtle)_75%,transparent)]'>
|
||||
View on GitHub →
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* SSO */}
|
||||
<div className='flex items-center gap-3 px-4 py-[14px]'>
|
||||
<div className='flex items-center gap-3 px-4 py-3.5'>
|
||||
<div className='flex h-[22px] w-[22px] shrink-0 items-center justify-center rounded-full bg-[#2ABBF8]/10'>
|
||||
<Lock className='h-[14px] w-[14px] text-[#2ABBF8]/75' />
|
||||
</div>
|
||||
<div className='flex flex-col gap-[3px]'>
|
||||
<strong className='font-[430] font-season text-[13px] text-white leading-none'>
|
||||
<strong className='font-[430] font-season text-small text-white leading-none'>
|
||||
SSO & SCIM
|
||||
</strong>
|
||||
<span className='font-[430] font-season text-[#F6F6F6]/30 text-[11px] leading-none tracking-[0.02em]'>
|
||||
<span className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_55%,transparent)] text-xs leading-none tracking-[0.02em]'>
|
||||
Okta, Azure AD, Google
|
||||
</span>
|
||||
</div>
|
||||
@@ -116,9 +131,13 @@ function TrustStrip() {
|
||||
|
||||
export default function Enterprise() {
|
||||
return (
|
||||
<section id='enterprise' aria-labelledby='enterprise-heading' className='bg-[#F6F6F6]'>
|
||||
<div className='px-4 pt-[60px] pb-[40px] sm:px-8 sm:pt-[80px] sm:pb-0 md:px-[80px] md:pt-[100px]'>
|
||||
<div className='flex flex-col items-start gap-3 sm:gap-4 md:gap-[20px]'>
|
||||
<section
|
||||
id='enterprise'
|
||||
aria-labelledby='enterprise-heading'
|
||||
className='bg-[var(--landing-bg-section)]'
|
||||
>
|
||||
<div className='px-4 pt-[60px] pb-10 sm:px-8 sm:pt-20 sm:pb-0 md:px-20 md:pt-[100px]'>
|
||||
<div className='flex flex-col items-start gap-3 sm:gap-4 md:gap-5'>
|
||||
<Badge
|
||||
variant='blue'
|
||||
size='md'
|
||||
@@ -130,7 +149,7 @@ export default function Enterprise() {
|
||||
|
||||
<h2
|
||||
id='enterprise-heading'
|
||||
className='max-w-[600px] font-[430] font-season text-[#1C1C1C] text-[32px] leading-[100%] tracking-[-0.02em] sm:text-[36px] md:text-[40px]'
|
||||
className='max-w-[600px] text-balance font-[430] font-season text-[32px] text-[var(--landing-text-dark)] leading-[100%] tracking-[-0.02em] sm:text-[36px] md:text-[40px]'
|
||||
>
|
||||
Enterprise features for
|
||||
<br />
|
||||
@@ -138,15 +157,15 @@ export default function Enterprise() {
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className='mt-8 overflow-hidden rounded-[12px] bg-[#1C1C1C] sm:mt-10 md:mt-12'>
|
||||
<div className='grid grid-cols-1 border-[#2A2A2A] border-b lg:grid-cols-[1fr_420px]'>
|
||||
<div className='mt-8 overflow-hidden rounded-[12px] bg-[var(--landing-bg)] sm:mt-10 md:mt-12'>
|
||||
<div className='grid grid-cols-1 border-[var(--landing-border)] border-b lg:grid-cols-[1fr_420px]'>
|
||||
{/* Audit Trail */}
|
||||
<div className='border-[#2A2A2A] lg:border-r'>
|
||||
<div className='border-[var(--landing-border)] lg:border-r'>
|
||||
<div className='px-6 pt-6 md:px-8 md:pt-8'>
|
||||
<h3 className='font-[430] font-season text-[16px] text-white leading-[120%] tracking-[-0.01em]'>
|
||||
Audit Trail
|
||||
</h3>
|
||||
<p className='mt-2 max-w-[480px] font-[430] font-season text-[#F6F6F6]/50 text-[14px] leading-[150%] tracking-[0.02em]'>
|
||||
<p className='mt-2 max-w-[480px] font-[430] font-season text-[#F6F6F6]/70 text-[14px] leading-[150%] tracking-[0.02em]'>
|
||||
Every action is captured with full actor attribution.
|
||||
</p>
|
||||
</div>
|
||||
@@ -155,12 +174,12 @@ export default function Enterprise() {
|
||||
</div>
|
||||
|
||||
{/* Access Control */}
|
||||
<div className='border-[#2A2A2A] border-t lg:border-t-0'>
|
||||
<div className='border-[var(--landing-border)] border-t lg:border-t-0'>
|
||||
<div className='px-6 pt-6 md:px-8 md:pt-8'>
|
||||
<h3 className='font-[430] font-season text-[16px] text-white leading-[120%] tracking-[-0.01em]'>
|
||||
Access Control
|
||||
</h3>
|
||||
<p className='mt-[6px] font-[430] font-season text-[#F6F6F6]/50 text-[14px] leading-[150%] tracking-[0.02em]'>
|
||||
<p className='mt-1.5 font-[430] font-season text-[#F6F6F6]/70 text-[14px] leading-[150%] tracking-[0.02em]'>
|
||||
Restrict providers, surfaces, and tools per group.
|
||||
</p>
|
||||
</div>
|
||||
@@ -172,27 +191,27 @@ export default function Enterprise() {
|
||||
|
||||
<TrustStrip />
|
||||
|
||||
{/* Scrolling feature ticker */}
|
||||
<div className='relative mt-6 overflow-hidden border-[#2A2A2A] border-t'>
|
||||
<style dangerouslySetInnerHTML={{ __html: MARQUEE_KEYFRAMES }} />
|
||||
{/* Scrolling feature ticker — keyframe loop; pause on hover. Tags use transitions for hover. */}
|
||||
<div className='enterprise-feature-marquee relative mt-6 overflow-hidden border-[var(--landing-bg-elevated)] border-t'>
|
||||
<style dangerouslySetInnerHTML={{ __html: ENTERPRISE_FEATURE_MARQUEE_STYLES }} />
|
||||
{/* Fade edges */}
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='pointer-events-none absolute top-0 bottom-0 left-0 z-10 w-24'
|
||||
style={{ background: 'linear-gradient(to right, #1C1C1C, transparent)' }}
|
||||
style={{ background: 'linear-gradient(to right, var(--landing-bg), transparent)' }}
|
||||
/>
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='pointer-events-none absolute top-0 right-0 bottom-0 z-10 w-24'
|
||||
style={{ background: 'linear-gradient(to left, #1C1C1C, transparent)' }}
|
||||
style={{ background: 'linear-gradient(to left, var(--landing-bg), transparent)' }}
|
||||
/>
|
||||
{/* Duplicate tags for seamless loop */}
|
||||
<div className='flex w-max' style={{ animation: 'marquee 30s linear infinite' }}>
|
||||
<div className='enterprise-feature-marquee-track flex w-max'>
|
||||
{[...FEATURE_TAGS, ...FEATURE_TAGS, ...FEATURE_TAGS, ...FEATURE_TAGS].map(
|
||||
(tag, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className='whitespace-nowrap border-[#2A2A2A] border-r px-5 py-4 font-[430] font-season text-[#F6F6F6]/40 text-[13px] leading-none tracking-[0.02em]'
|
||||
className='enterprise-feature-marquee-tag whitespace-nowrap border-[var(--landing-bg-elevated)] border-r px-5 py-4 font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_60%,transparent)] text-small leading-none tracking-[0.02em] hover:bg-white/[0.04] hover:text-[color-mix(in_srgb,var(--landing-text-subtle)_80%,transparent)]'
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
@@ -201,14 +220,14 @@ export default function Enterprise() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between border-[#2A2A2A] border-t px-6 py-5 md:px-8 md:py-6'>
|
||||
<p className='font-[430] font-season text-[#F6F6F6]/40 text-[15px] leading-[150%] tracking-[0.02em]'>
|
||||
<div className='flex items-center justify-between border-[var(--landing-bg-elevated)] border-t px-6 py-5 md:px-8 md:py-6'>
|
||||
<p className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_60%,transparent)] text-base leading-[150%] tracking-[0.02em]'>
|
||||
Ready for growth?
|
||||
</p>
|
||||
<DemoRequestModal>
|
||||
<button
|
||||
type='button'
|
||||
className='group/cta inline-flex h-[32px] cursor-pointer items-center gap-[6px] rounded-[5px] border border-white bg-white px-[10px] font-[430] font-season text-[14px] text-black transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]'
|
||||
className='group/cta inline-flex h-[32px] cursor-pointer items-center gap-1.5 rounded-[5px] border border-white bg-white px-2.5 font-[430] font-season text-[14px] text-black transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]'
|
||||
>
|
||||
Book a demo
|
||||
<span className='relative h-[10px] w-[10px] shrink-0'>
|
||||
|
||||
@@ -304,7 +304,7 @@ function WorkspacePreview({ activeTab, isActive }: { activeTab: number; isActive
|
||||
|
||||
function MockUserInput({ text }: { text: string }) {
|
||||
return (
|
||||
<div className='flex w-[380px] items-center gap-[6px] rounded-[16px] border border-[#E0E0E0] bg-white px-[10px] py-[8px] shadow-[0_2px_8px_rgba(0,0,0,0.06)]'>
|
||||
<div className='flex w-[380px] items-center gap-1.5 rounded-[16px] border border-[#E0E0E0] bg-white px-2.5 py-2 shadow-[0_2px_8px_rgba(0,0,0,0.06)]'>
|
||||
<div className='flex h-[24px] w-[24px] flex-shrink-0 items-center justify-center rounded-full border border-[#E8E8E8]'>
|
||||
<svg width='12' height='12' viewBox='0 0 12 12' fill='none'>
|
||||
<path d='M6 2.5v7M2.5 6h7' stroke='#999' strokeWidth='1.5' strokeLinecap='round' />
|
||||
@@ -364,7 +364,7 @@ function MiniCardHeader({
|
||||
color?: string
|
||||
}) {
|
||||
return (
|
||||
<div className='flex items-center gap-[4px] border-[#F0F0F0] border-b px-[8px] py-[5px]'>
|
||||
<div className='flex items-center gap-1 border-[#F0F0F0] border-b px-2 py-1.5'>
|
||||
<MiniCardIcon variant={variant} color={color} />
|
||||
<span className='truncate font-medium text-[#888] text-[7px] leading-none'>{label}</span>
|
||||
</div>
|
||||
@@ -419,7 +419,7 @@ function MiniCardBody({ variant, color }: { variant: CardVariant; color?: string
|
||||
|
||||
function PromptCardBody() {
|
||||
return (
|
||||
<div className='px-[8px] py-[6px]'>
|
||||
<div className='px-2 py-1.5'>
|
||||
<p className='break-words text-[#AAAAAA] text-[6.5px] leading-[10px]'>{TYPING_PROMPT}</p>
|
||||
</div>
|
||||
)
|
||||
@@ -427,7 +427,7 @@ function PromptCardBody() {
|
||||
|
||||
function FileCardBody() {
|
||||
return (
|
||||
<div className='flex flex-col gap-[3px] px-[8px] py-[6px]'>
|
||||
<div className='flex flex-col gap-[3px] px-2 py-1.5'>
|
||||
<div className='h-[2px] w-[78%] rounded-full bg-[#E8E8E8]' />
|
||||
<div className='h-[2px] w-[92%] rounded-full bg-[#E8E8E8]' />
|
||||
<div className='h-[2px] w-[62%] rounded-full bg-[#E8E8E8]' />
|
||||
@@ -450,7 +450,7 @@ const TABLE_ROW_WIDTHS = [
|
||||
function TableCardBody() {
|
||||
return (
|
||||
<div className='flex flex-col'>
|
||||
<div className='flex items-center gap-[4px] bg-[#FAFAFA] px-[6px] py-[3px]'>
|
||||
<div className='flex items-center gap-1 bg-[#FAFAFA] px-1.5 py-[3px]'>
|
||||
<div className='h-[2px] flex-1 rounded-full bg-[#D4D4D4]' />
|
||||
<div className='h-[2px] flex-1 rounded-full bg-[#D4D4D4]' />
|
||||
<div className='h-[2px] flex-1 rounded-full bg-[#D4D4D4]' />
|
||||
@@ -458,7 +458,7 @@ function TableCardBody() {
|
||||
{TABLE_ROW_WIDTHS.map((row, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className='flex items-center gap-[4px] border-[#F5F5F5] border-b px-[6px] py-[3.5px]'
|
||||
className='flex items-center gap-1 border-[#F5F5F5] border-b px-1.5 py-[3.5px]'
|
||||
>
|
||||
<div className='h-[1.5px] rounded-full bg-[#EBEBEB]' style={{ width: `${row[0]}%` }} />
|
||||
<div className='h-[1.5px] rounded-full bg-[#EBEBEB]' style={{ width: `${row[1]}%` }} />
|
||||
@@ -472,17 +472,17 @@ function TableCardBody() {
|
||||
function WorkflowCardBody({ color }: { color: string }) {
|
||||
return (
|
||||
<div className='relative h-full w-full'>
|
||||
<div className='absolute top-[10px] left-[10px] h-[14px] w-[14px] rounded-[3px] border border-[#E0E0E0] bg-[#F8F8F8]' />
|
||||
<div className='absolute top-2.5 left-[10px] h-[14px] w-[14px] rounded-[3px] border border-[#E0E0E0] bg-[#F8F8F8]' />
|
||||
<div className='absolute top-[16px] left-[24px] h-[1px] w-[16px] bg-[#D8D8D8]' />
|
||||
<div
|
||||
className='absolute top-[10px] left-[40px] h-[14px] w-[14px] rounded-[3px] border-[2px]'
|
||||
className='absolute top-2.5 left-[40px] h-[14px] w-[14px] rounded-[3px] border-[2px]'
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
borderColor: `${color}60`,
|
||||
backgroundClip: 'padding-box',
|
||||
}}
|
||||
/>
|
||||
<div className='absolute top-[24px] left-[46px] h-[12px] w-[1px] bg-[#D8D8D8]' />
|
||||
<div className='absolute top-6 left-[46px] h-[12px] w-[1px] bg-[#D8D8D8]' />
|
||||
<div className='absolute top-[36px] left-[40px] h-[14px] w-[14px] rounded-[3px] border border-[#E0E0E0] bg-[#F8F8F8]' />
|
||||
<div className='absolute top-[42px] left-[54px] h-[1px] w-[14px] bg-[#D8D8D8]' />
|
||||
<div
|
||||
@@ -502,9 +502,9 @@ const KB_WIDTHS = [70, 85, 55, 80, 48] as const
|
||||
|
||||
function KnowledgeCardBody() {
|
||||
return (
|
||||
<div className='flex flex-col gap-[5px] px-[8px] py-[6px]'>
|
||||
<div className='flex flex-col gap-[5px] px-2 py-1.5'>
|
||||
{KB_WIDTHS.map((w, i) => (
|
||||
<div key={i} className='flex items-center gap-[4px]'>
|
||||
<div key={i} className='flex items-center gap-1'>
|
||||
<div className='h-[3px] w-[3px] flex-shrink-0 rounded-full bg-[#D4D4D4]' />
|
||||
<div className='h-[1.5px] rounded-full bg-[#E8E8E8]' style={{ width: `${w}%` }} />
|
||||
</div>
|
||||
@@ -524,9 +524,9 @@ const LOG_ENTRIES = [
|
||||
|
||||
function LogsCardBody() {
|
||||
return (
|
||||
<div className='flex flex-col gap-[3px] px-[6px] py-[4px]'>
|
||||
<div className='flex flex-col gap-[3px] px-1.5 py-1'>
|
||||
{LOG_ENTRIES.map((entry, i) => (
|
||||
<div key={i} className='flex items-center gap-[4px] py-[1px]'>
|
||||
<div key={i} className='flex items-center gap-1 py-[1px]'>
|
||||
<div
|
||||
className='h-[3px] w-[3px] flex-shrink-0 rounded-full'
|
||||
style={{ backgroundColor: entry.color }}
|
||||
@@ -620,8 +620,8 @@ const MD_COMPONENTS: Components = {
|
||||
{children}
|
||||
</h2>
|
||||
),
|
||||
ul: ({ children }) => <ul className='mb-3 list-disc pl-[24px]'>{children}</ul>,
|
||||
ol: ({ children }) => <ol className='mb-3 list-decimal pl-[24px]'>{children}</ol>,
|
||||
ul: ({ children }) => <ul className='mb-3 list-disc pl-6'>{children}</ul>,
|
||||
ol: ({ children }) => <ol className='mb-3 list-decimal pl-6'>{children}</ol>,
|
||||
li: ({ children }) => (
|
||||
<li className='mb-1 text-[#1C1C1C] text-[14px] leading-[1.6]'>{children}</li>
|
||||
),
|
||||
@@ -633,8 +633,8 @@ function MockFullFiles() {
|
||||
|
||||
return (
|
||||
<div className='flex h-full flex-col'>
|
||||
<div className='flex h-[44px] shrink-0 items-center border-[#E5E5E5] border-b px-[24px]'>
|
||||
<div className='flex items-center gap-[6px]'>
|
||||
<div className='flex h-[44px] shrink-0 items-center border-[#E5E5E5] border-b px-6'>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<File className='h-[14px] w-[14px] text-[#999]' />
|
||||
<span className='text-[#999] text-[13px]'>Files</span>
|
||||
<span className='text-[#D4D4D4] text-[13px]'>/</span>
|
||||
@@ -654,7 +654,7 @@ function MockFullFiles() {
|
||||
onChange={(e) => setSource(e.target.value)}
|
||||
spellCheck={false}
|
||||
autoCorrect='off'
|
||||
className='h-full w-full resize-none overflow-auto whitespace-pre-wrap bg-transparent p-[24px] font-[300] font-mono text-[#1C1C1C] text-[12px] leading-[1.7] outline-none'
|
||||
className='h-full w-full resize-none overflow-auto whitespace-pre-wrap bg-transparent p-6 font-[300] font-mono text-[#1C1C1C] text-[12px] leading-[1.7] outline-none'
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
@@ -666,7 +666,7 @@ function MockFullFiles() {
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.4, delay: 0.5 }}
|
||||
>
|
||||
<div className='h-full overflow-auto p-[24px]'>
|
||||
<div className='h-full overflow-auto p-6'>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={MD_COMPONENTS}>
|
||||
{source}
|
||||
</ReactMarkdown>
|
||||
@@ -686,8 +686,8 @@ const KB_STATUS_STYLES: Record<string, { bg: string; text: string; label: string
|
||||
function MockFullKnowledgeBase({ revealedRows }: { revealedRows: number }) {
|
||||
return (
|
||||
<div className='flex h-full flex-col'>
|
||||
<div className='flex h-[44px] shrink-0 items-center border-[#E5E5E5] border-b px-[24px]'>
|
||||
<div className='flex items-center gap-[6px]'>
|
||||
<div className='flex h-[44px] shrink-0 items-center border-[#E5E5E5] border-b px-6'>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<Database className='h-[14px] w-[14px] text-[#999]' />
|
||||
<span className='text-[#999] text-[13px]'>Knowledge Base</span>
|
||||
<span className='text-[#D4D4D4] text-[13px]'>/</span>
|
||||
@@ -695,12 +695,12 @@ function MockFullKnowledgeBase({ revealedRows }: { revealedRows: number }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex h-[36px] shrink-0 items-center border-[#E5E5E5] border-b px-[24px]'>
|
||||
<div className='flex items-center gap-[6px]'>
|
||||
<div className='flex h-[24px] items-center gap-[4px] rounded-[6px] border border-[#E5E5E5] px-[8px] text-[#999] text-[12px]'>
|
||||
<div className='flex h-[36px] shrink-0 items-center border-[#E5E5E5] border-b px-6'>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<div className='flex h-[24px] items-center gap-1 rounded-[6px] border border-[#E5E5E5] px-2 text-[#999] text-[12px]'>
|
||||
Sort
|
||||
</div>
|
||||
<div className='flex h-[24px] items-center gap-[4px] rounded-[6px] border border-[#E5E5E5] px-[8px] text-[#999] text-[12px]'>
|
||||
<div className='flex h-[24px] items-center gap-1 rounded-[6px] border border-[#E5E5E5] px-2 text-[#999] text-[12px]'>
|
||||
Filter
|
||||
</div>
|
||||
</div>
|
||||
@@ -716,7 +716,7 @@ function MockFullKnowledgeBase({ revealedRows }: { revealedRows: number }) {
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className='border-[#E5E5E5] border-r border-b bg-[#FAFAFA] px-[4px] py-[7px] text-center align-middle'>
|
||||
<th className='border-[#E5E5E5] border-r border-b bg-[#FAFAFA] px-1 py-[7px] text-center align-middle'>
|
||||
<div className='flex items-center justify-center'>
|
||||
<div className='h-[13px] w-[13px] rounded-[2px] border border-[#D4D4D4]' />
|
||||
</div>
|
||||
@@ -724,7 +724,7 @@ function MockFullKnowledgeBase({ revealedRows }: { revealedRows: number }) {
|
||||
{MOCK_KB_COLUMNS.map((col) => (
|
||||
<th
|
||||
key={col}
|
||||
className='border-[#E5E5E5] border-r border-b bg-[#FAFAFA] px-[8px] py-[7px] text-left align-middle'
|
||||
className='border-[#E5E5E5] border-r border-b bg-[#FAFAFA] px-2 py-[7px] text-left align-middle'
|
||||
>
|
||||
<span className='font-base text-[#999] text-[13px]'>{col}</span>
|
||||
</th>
|
||||
@@ -742,11 +742,11 @@ function MockFullKnowledgeBase({ revealedRows }: { revealedRows: number }) {
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||
>
|
||||
<td className='border-[#E5E5E5] border-r border-b px-[4px] py-[7px] text-center align-middle'>
|
||||
<td className='border-[#E5E5E5] border-r border-b px-1 py-[7px] text-center align-middle'>
|
||||
<span className='text-[#999] text-[11px] tabular-nums'>{i + 1}</span>
|
||||
</td>
|
||||
<td className='border-[#E5E5E5] border-r border-b px-[8px] py-[7px] align-middle'>
|
||||
<span className='flex items-center gap-[8px] text-[#1C1C1C] text-[13px]'>
|
||||
<td className='border-[#E5E5E5] border-r border-b px-2 py-[7px] align-middle'>
|
||||
<span className='flex items-center gap-2 text-[#1C1C1C] text-[13px]'>
|
||||
<DocIcon className='h-[14px] w-[14px] shrink-0' />
|
||||
<span className='truncate'>{row[0]}</span>
|
||||
</span>
|
||||
@@ -754,14 +754,14 @@ function MockFullKnowledgeBase({ revealedRows }: { revealedRows: number }) {
|
||||
{row.slice(1, 4).map((cell, j) => (
|
||||
<td
|
||||
key={j}
|
||||
className='border-[#E5E5E5] border-r border-b px-[8px] py-[7px] align-middle'
|
||||
className='border-[#E5E5E5] border-r border-b px-2 py-[7px] align-middle'
|
||||
>
|
||||
<span className='text-[#999] text-[13px]'>{cell}</span>
|
||||
</td>
|
||||
))}
|
||||
<td className='border-[#E5E5E5] border-r border-b px-[8px] py-[7px] align-middle'>
|
||||
<td className='border-[#E5E5E5] border-r border-b px-2 py-[7px] align-middle'>
|
||||
<span
|
||||
className='inline-flex items-center rounded-full px-[8px] py-[2px] font-medium text-[11px]'
|
||||
className='inline-flex items-center rounded-full px-2 py-0.5 font-medium text-[11px]'
|
||||
style={{ backgroundColor: status.bg, color: status.text }}
|
||||
>
|
||||
{status.label}
|
||||
@@ -885,8 +885,8 @@ function MockFullLogs({ revealedRows }: { revealedRows: number }) {
|
||||
return (
|
||||
<div className='relative flex h-full'>
|
||||
<div className='flex min-w-0 flex-1 flex-col'>
|
||||
<div className='flex h-[44px] shrink-0 items-center border-[#E5E5E5] border-b px-[24px]'>
|
||||
<div className='flex items-center gap-[6px]'>
|
||||
<div className='flex h-[44px] shrink-0 items-center border-[#E5E5E5] border-b px-6'>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<Library className='h-[14px] w-[14px] text-[#999]' />
|
||||
<span className='font-medium text-[#1C1C1C] text-[13px]'>Logs</span>
|
||||
</div>
|
||||
@@ -902,7 +902,7 @@ function MockFullLogs({ revealedRows }: { revealedRows: number }) {
|
||||
<thead className='shadow-[inset_0_-1px_0_#E5E5E5]'>
|
||||
<tr>
|
||||
{['Workflow', 'Date', 'Status', 'Cost', 'Trigger', 'Duration'].map((col) => (
|
||||
<th key={col} className='h-10 px-[24px] py-[10px] text-left align-middle'>
|
||||
<th key={col} className='h-10 px-6 py-2.5 text-left align-middle'>
|
||||
<span className='font-base text-[#999] text-[13px]'>{col}</span>
|
||||
</th>
|
||||
))}
|
||||
@@ -924,8 +924,8 @@ function MockFullLogs({ revealedRows }: { revealedRows: number }) {
|
||||
)}
|
||||
onClick={() => setSelectedRow(i)}
|
||||
>
|
||||
<td className='px-[24px] py-[10px] align-middle'>
|
||||
<span className='flex items-center gap-[12px] font-medium text-[#1C1C1C] text-[14px]'>
|
||||
<td className='px-6 py-2.5 align-middle'>
|
||||
<span className='flex items-center gap-3 font-medium text-[#1C1C1C] text-[14px]'>
|
||||
<div
|
||||
className='h-[10px] w-[10px] shrink-0 rounded-[3px] border-[1.5px]'
|
||||
style={{
|
||||
@@ -937,26 +937,26 @@ function MockFullLogs({ revealedRows }: { revealedRows: number }) {
|
||||
<span className='truncate'>{row[0]}</span>
|
||||
</span>
|
||||
</td>
|
||||
<td className='px-[24px] py-[10px] align-middle'>
|
||||
<td className='px-6 py-2.5 align-middle'>
|
||||
<span className='font-medium text-[#999] text-[14px]'>{row[1]}</span>
|
||||
</td>
|
||||
<td className='px-[24px] py-[10px] align-middle'>
|
||||
<td className='px-6 py-2.5 align-middle'>
|
||||
<span
|
||||
className='inline-flex items-center rounded-full px-[8px] py-[2px] font-medium text-[11px]'
|
||||
className='inline-flex items-center rounded-full px-2 py-0.5 font-medium text-[11px]'
|
||||
style={{ backgroundColor: statusStyle.bg, color: statusStyle.text }}
|
||||
>
|
||||
{statusStyle.label}
|
||||
</span>
|
||||
</td>
|
||||
<td className='px-[24px] py-[10px] align-middle'>
|
||||
<td className='px-6 py-2.5 align-middle'>
|
||||
<span className='font-medium text-[#999] text-[14px]'>{row[3]}</span>
|
||||
</td>
|
||||
<td className='px-[24px] py-[10px] align-middle'>
|
||||
<span className='rounded-[4px] bg-[#F5F5F5] px-[6px] py-[2px] text-[#666] text-[11px]'>
|
||||
<td className='px-6 py-2.5 align-middle'>
|
||||
<span className='rounded-[4px] bg-[#F5F5F5] px-1.5 py-0.5 text-[#666] text-[11px]'>
|
||||
{row[4]}
|
||||
</span>
|
||||
</td>
|
||||
<td className='px-[24px] py-[10px] align-middle'>
|
||||
<td className='px-6 py-2.5 align-middle'>
|
||||
<span className='font-medium text-[#999] text-[14px]'>{row[5]}</span>
|
||||
</td>
|
||||
</motion.tr>
|
||||
@@ -1001,7 +1001,7 @@ function MockLogDetailsSidebar({ selectedRow, onPrev, onNext }: MockLogDetailsSi
|
||||
const isNextDisabled = selectedRow === MOCK_LOG_DATA.length - 1
|
||||
|
||||
return (
|
||||
<div className='flex h-full flex-col overflow-y-auto px-[14px] pt-[12px]'>
|
||||
<div className='flex h-full flex-col overflow-y-auto px-3.5 pt-3'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='font-medium text-[#1C1C1C] text-[14px]'>Log Details</span>
|
||||
<div className='flex items-center gap-[1px]'>
|
||||
@@ -1030,18 +1030,18 @@ function MockLogDetailsSidebar({ selectedRow, onPrev, onNext }: MockLogDetailsSi
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mt-[20px] flex flex-col gap-[10px]'>
|
||||
<div className='flex items-center gap-[16px] px-[1px]'>
|
||||
<div className='flex w-[120px] shrink-0 flex-col gap-[8px]'>
|
||||
<div className='mt-5 flex flex-col gap-2.5'>
|
||||
<div className='flex items-center gap-4 px-[1px]'>
|
||||
<div className='flex w-[120px] shrink-0 flex-col gap-2'>
|
||||
<span className='font-medium text-[#999] text-[12px]'>Timestamp</span>
|
||||
<div className='flex items-center gap-[6px]'>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<span className='font-medium text-[#666] text-[13px]'>{date}</span>
|
||||
<span className='font-medium text-[#666] text-[13px]'>{time}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex min-w-0 flex-1 flex-col gap-[8px]'>
|
||||
<div className='flex min-w-0 flex-1 flex-col gap-2'>
|
||||
<span className='font-medium text-[#999] text-[12px]'>Workflow</span>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div
|
||||
className='h-[10px] w-[10px] shrink-0 rounded-[3px] border-[1.5px]'
|
||||
style={{
|
||||
@@ -1056,42 +1056,39 @@ function MockLogDetailsSidebar({ selectedRow, onPrev, onNext }: MockLogDetailsSi
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col'>
|
||||
<div className='flex h-[42px] items-center justify-between border-[#E5E5E5] border-b px-[8px]'>
|
||||
<div className='flex h-[42px] items-center justify-between border-[#E5E5E5] border-b px-2'>
|
||||
<span className='font-medium text-[#999] text-[12px]'>Level</span>
|
||||
<span
|
||||
className='inline-flex items-center rounded-full px-[8px] py-[2px] font-medium text-[11px]'
|
||||
className='inline-flex items-center rounded-full px-2 py-0.5 font-medium text-[11px]'
|
||||
style={{ backgroundColor: statusStyle.bg, color: statusStyle.text }}
|
||||
>
|
||||
{statusStyle.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex h-[42px] items-center justify-between border-[#E5E5E5] border-b px-[8px]'>
|
||||
<div className='flex h-[42px] items-center justify-between border-[#E5E5E5] border-b px-2'>
|
||||
<span className='font-medium text-[#999] text-[12px]'>Trigger</span>
|
||||
<span className='rounded-[4px] bg-[#F5F5F5] px-[6px] py-[2px] text-[#666] text-[11px]'>
|
||||
<span className='rounded-[4px] bg-[#F5F5F5] px-1.5 py-0.5 text-[#666] text-[11px]'>
|
||||
{row[4]}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex h-[42px] items-center justify-between px-[8px]'>
|
||||
<div className='flex h-[42px] items-center justify-between px-2'>
|
||||
<span className='font-medium text-[#999] text-[12px]'>Duration</span>
|
||||
<span className='font-medium text-[#666] text-[13px]'>{row[5]}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-[6px] rounded-[6px] border border-[#E5E5E5] bg-[#FAFAFA] px-[10px] py-[8px]'>
|
||||
<div className='flex flex-col gap-1.5 rounded-[6px] border border-[#E5E5E5] bg-[#FAFAFA] px-2.5 py-2'>
|
||||
<span className='font-medium text-[#999] text-[12px]'>Workflow Output</span>
|
||||
<div className='rounded-[6px] bg-[#F0F0F0] p-[10px] font-mono text-[#555] text-[11px] leading-[1.5]'>
|
||||
<div className='rounded-[6px] bg-[#F0F0F0] p-2.5 font-mono text-[#555] text-[11px] leading-[1.5]'>
|
||||
{detail.output}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-[6px] rounded-[6px] border border-[#E5E5E5] bg-[#FAFAFA] px-[10px] py-[8px]'>
|
||||
<div className='flex flex-col gap-1.5 rounded-[6px] border border-[#E5E5E5] bg-[#FAFAFA] px-2.5 py-2'>
|
||||
<span className='font-medium text-[#999] text-[12px]'>Trace Spans</span>
|
||||
<div className='flex flex-col gap-[6px]'>
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
{detail.spans.map((span, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn('flex flex-col gap-[3px]', span.depth === 1 && 'ml-[12px]')}
|
||||
>
|
||||
<div key={i} className={cn('flex flex-col gap-[3px]', span.depth === 1 && 'ml-3')}>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='font-mono text-[#555] text-[11px]'>{span.name}</span>
|
||||
<span className='font-medium text-[#999] text-[11px]'>{span.ms}ms</span>
|
||||
@@ -1116,8 +1113,8 @@ function MockFullTable({ revealedRows }: { revealedRows: number }) {
|
||||
|
||||
return (
|
||||
<div className='flex h-full flex-col'>
|
||||
<div className='flex h-[44px] shrink-0 items-center border-[#E5E5E5] border-b px-[24px]'>
|
||||
<div className='flex items-center gap-[6px]'>
|
||||
<div className='flex h-[44px] shrink-0 items-center border-[#E5E5E5] border-b px-6'>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<Table className='h-[14px] w-[14px] text-[#999]' />
|
||||
<span className='text-[#999] text-[13px]'>Tables</span>
|
||||
<span className='text-[#D4D4D4] text-[13px]'>/</span>
|
||||
@@ -1125,12 +1122,12 @@ function MockFullTable({ revealedRows }: { revealedRows: number }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex h-[36px] shrink-0 items-center border-[#E5E5E5] border-b px-[24px]'>
|
||||
<div className='flex items-center gap-[6px]'>
|
||||
<div className='flex h-[24px] items-center gap-[4px] rounded-[6px] border border-[#E5E5E5] px-[8px] text-[#999] text-[12px]'>
|
||||
<div className='flex h-[36px] shrink-0 items-center border-[#E5E5E5] border-b px-6'>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<div className='flex h-[24px] items-center gap-1 rounded-[6px] border border-[#E5E5E5] px-2 text-[#999] text-[12px]'>
|
||||
Sort
|
||||
</div>
|
||||
<div className='flex h-[24px] items-center gap-[4px] rounded-[6px] border border-[#E5E5E5] px-[8px] text-[#999] text-[12px]'>
|
||||
<div className='flex h-[24px] items-center gap-1 rounded-[6px] border border-[#E5E5E5] px-2 text-[#999] text-[12px]'>
|
||||
Filter
|
||||
</div>
|
||||
</div>
|
||||
@@ -1146,7 +1143,7 @@ function MockFullTable({ revealedRows }: { revealedRows: number }) {
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className='border-[#E5E5E5] border-r border-b bg-[#FAFAFA] px-[4px] py-[7px] text-center align-middle'>
|
||||
<th className='border-[#E5E5E5] border-r border-b bg-[#FAFAFA] px-1 py-[7px] text-center align-middle'>
|
||||
<div className='flex items-center justify-center'>
|
||||
<div className='h-[13px] w-[13px] rounded-[2px] border border-[#D4D4D4]' />
|
||||
</div>
|
||||
@@ -1154,9 +1151,9 @@ function MockFullTable({ revealedRows }: { revealedRows: number }) {
|
||||
{MOCK_TABLE_COLUMNS.map((col) => (
|
||||
<th
|
||||
key={col}
|
||||
className='border-[#E5E5E5] border-r border-b bg-[#FAFAFA] px-[8px] py-[7px] text-left align-middle'
|
||||
className='border-[#E5E5E5] border-r border-b bg-[#FAFAFA] px-2 py-[7px] text-left align-middle'
|
||||
>
|
||||
<div className='flex items-center gap-[6px]'>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<ColumnTypeIcon />
|
||||
<span className='font-medium text-[#1C1C1C] text-[13px]'>{col}</span>
|
||||
<ChevronDown className='ml-auto h-[7px] w-[9px] shrink-0 text-[#CCC]' />
|
||||
@@ -1179,7 +1176,7 @@ function MockFullTable({ revealedRows }: { revealedRows: number }) {
|
||||
>
|
||||
<td
|
||||
className={cn(
|
||||
'border-[#E5E5E5] border-r border-b px-[4px] py-[7px] text-center align-middle',
|
||||
'border-[#E5E5E5] border-r border-b px-1 py-[7px] text-center align-middle',
|
||||
isSelected ? 'bg-[rgba(37,99,235,0.06)]' : 'hover:bg-[#FAFAFA]'
|
||||
)}
|
||||
>
|
||||
@@ -1189,7 +1186,7 @@ function MockFullTable({ revealedRows }: { revealedRows: number }) {
|
||||
<td
|
||||
key={j}
|
||||
className={cn(
|
||||
'relative border-[#E5E5E5] border-r border-b px-[8px] py-[7px] align-middle',
|
||||
'relative border-[#E5E5E5] border-r border-b px-2 py-[7px] align-middle',
|
||||
isSelected ? 'bg-[rgba(37,99,235,0.06)]' : 'hover:bg-[#FAFAFA]'
|
||||
)}
|
||||
>
|
||||
@@ -1311,13 +1308,13 @@ function DefaultPreview() {
|
||||
return (
|
||||
<motion.div
|
||||
key={key}
|
||||
className='absolute flex items-center justify-center rounded-xl border border-[#E5E5E5] bg-white p-[10px] shadow-[0_2px_4px_0_rgba(0,0,0,0.06)]'
|
||||
className='absolute flex items-center justify-center rounded-xl border border-[#E5E5E5] bg-white p-2.5 shadow-[0_2px_4px_0_rgba(0,0,0,0.06)]'
|
||||
initial={{ top: '50%', left: '50%', opacity: 0, scale: 0, x: '-50%', y: '-50%' }}
|
||||
animate={inView ? { top, left, opacity: 1, scale: 1, x: '-50%', y: '-50%' } : undefined}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 50,
|
||||
damping: 12,
|
||||
stiffness: 80,
|
||||
damping: 20,
|
||||
delay: explodeDelay,
|
||||
}}
|
||||
style={{ color }}
|
||||
@@ -1334,7 +1331,7 @@ function DefaultPreview() {
|
||||
animate={inView ? { opacity: 1, x: '-50%', y: '-50%' } : undefined}
|
||||
transition={{ duration: 0.4, ease: 'easeOut', delay: 0 }}
|
||||
>
|
||||
<div className='flex h-[36px] items-center gap-[8px] rounded-[8px] border border-[#E5E5E5] bg-white px-[10px] shadow-[0_2px_6px_0_rgba(0,0,0,0.08)]'>
|
||||
<div className='flex h-[36px] items-center gap-2 rounded-[8px] border border-[#E5E5E5] bg-white px-2.5 shadow-[0_2px_6px_0_rgba(0,0,0,0.08)]'>
|
||||
<div className='flex h-[22px] w-[22px] flex-shrink-0 items-center justify-center rounded-[5px] bg-[#1e1e1e]'>
|
||||
<svg width='11' height='11' viewBox='0 0 10 10' fill='none'>
|
||||
<path
|
||||
|
||||
@@ -152,7 +152,7 @@ function DotGrid({
|
||||
return (
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className={`h-full shrink-0 bg-[#F6F6F6] p-[6px] ${borderLeft ? 'border-[#E9E9E9] border-l' : ''}`}
|
||||
className={`h-full shrink-0 bg-[var(--landing-bg-section)] p-1.5 ${borderLeft ? 'border-[var(--divider)] border-l' : ''}`}
|
||||
style={{
|
||||
width: width ? `${width}px` : undefined,
|
||||
display: 'grid',
|
||||
@@ -181,7 +181,7 @@ export default function Features() {
|
||||
<section
|
||||
id='features'
|
||||
aria-labelledby='features-heading'
|
||||
className='relative overflow-hidden bg-[#F6F6F6]'
|
||||
className='relative overflow-hidden bg-[var(--landing-bg-section)]'
|
||||
>
|
||||
<div aria-hidden='true' className='absolute top-0 left-0 w-full'>
|
||||
<Image
|
||||
@@ -190,15 +190,11 @@ export default function Features() {
|
||||
width={1440}
|
||||
height={366}
|
||||
className='h-auto w-full'
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='relative z-10 pt-[60px] lg:pt-[100px]'>
|
||||
<div
|
||||
ref={sectionRef}
|
||||
className='flex flex-col items-start gap-[20px] px-[24px] lg:px-[80px]'
|
||||
>
|
||||
<div ref={sectionRef} className='flex flex-col items-start gap-5 px-6 lg:px-20'>
|
||||
<Badge
|
||||
variant='blue'
|
||||
size='md'
|
||||
@@ -216,31 +212,31 @@ export default function Features() {
|
||||
</Badge>
|
||||
<h2
|
||||
id='features-heading'
|
||||
className='max-w-[900px] font-[430] font-season text-[#1C1C1C] text-[28px] leading-[110%] tracking-[-0.02em] md:text-[40px]'
|
||||
className='max-w-[900px] text-balance font-[430] font-season text-[28px] text-[var(--landing-text-dark)] leading-[110%] tracking-[-0.02em] md:text-[40px]'
|
||||
>
|
||||
{HEADING_LETTERS.map((char, i) => (
|
||||
<ScrollLetter key={i} scrollYProgress={scrollYProgress} charIndex={i}>
|
||||
{char}
|
||||
</ScrollLetter>
|
||||
))}
|
||||
<span className='text-[#1C1C1C]/40'>
|
||||
<span className='text-[color-mix(in_srgb,var(--landing-text-dark)_40%,transparent)]'>
|
||||
Design powerful workflows, connect your data, and monitor every run — all in one
|
||||
platform.
|
||||
</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className='relative mt-[40px] pb-[40px] lg:mt-[73px] lg:pb-[80px]'>
|
||||
<div className='relative mt-10 pb-10 lg:mt-[73px] lg:pb-20'>
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='absolute top-0 bottom-0 left-[80px] z-20 hidden w-px bg-[#E9E9E9] lg:block'
|
||||
className='absolute top-0 bottom-0 left-[80px] z-20 hidden w-px bg-[var(--divider)] lg:block'
|
||||
/>
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='absolute top-0 right-[80px] bottom-0 z-20 hidden w-px bg-[#E9E9E9] lg:block'
|
||||
className='absolute top-0 right-[80px] bottom-0 z-20 hidden w-px bg-[var(--divider)] lg:block'
|
||||
/>
|
||||
|
||||
<div className='flex h-[68px] border border-[#E9E9E9] lg:overflow-hidden'>
|
||||
<div className='flex h-[68px] border border-[var(--divider)] lg:overflow-hidden'>
|
||||
<div className='h-full shrink-0'>
|
||||
<div className='h-full lg:hidden'>
|
||||
<DotGrid cols={3} rows={8} width={24} />
|
||||
@@ -258,7 +254,7 @@ export default function Features() {
|
||||
role='tab'
|
||||
aria-selected={index === activeTab}
|
||||
onClick={() => setActiveTab(index)}
|
||||
className={`relative h-full flex-1 items-center justify-center whitespace-nowrap px-[12px] font-medium font-season text-[#212121] text-[12px] uppercase lg:px-0 lg:text-[14px]${tab.hideOnMobile ? ' hidden lg:flex' : ' flex'}${index > 0 ? ' border-[#E9E9E9] border-l' : ''}`}
|
||||
className={`relative h-full flex-1 items-center justify-center whitespace-nowrap px-3 font-medium font-season text-[var(--landing-text-dark)] text-caption uppercase lg:px-0 lg:text-sm${tab.hideOnMobile ? ' hidden lg:flex' : ' flex'}${index > 0 ? ' border-[var(--divider)] border-l' : ''}`}
|
||||
style={{ backgroundColor: index === activeTab ? '#FDFDFD' : '#F6F6F6' }}
|
||||
>
|
||||
{tab.mobileLabel ? (
|
||||
@@ -298,19 +294,19 @@ export default function Features() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mt-[32px] flex flex-col gap-[24px] px-[24px] lg:mt-[60px] lg:grid lg:grid-cols-[1fr_2.8fr] lg:gap-[60px] lg:px-[120px]'>
|
||||
<div className='flex flex-col items-start justify-between gap-[24px] pt-[20px] lg:h-[560px] lg:gap-0'>
|
||||
<div className='flex flex-col items-start gap-[16px]'>
|
||||
<h3 className='font-[430] font-season text-[#1C1C1C] text-[24px] leading-[120%] tracking-[-0.02em] lg:text-[28px]'>
|
||||
<div className='mt-8 flex flex-col gap-6 px-6 lg:mt-[60px] lg:grid lg:grid-cols-[1fr_2.8fr] lg:gap-[60px] lg:px-[120px]'>
|
||||
<div className='flex flex-col items-start justify-between gap-6 pt-5 lg:h-[560px] lg:gap-0'>
|
||||
<div className='flex flex-col items-start gap-4'>
|
||||
<h3 className='font-[430] font-season text-[24px] text-[var(--landing-text-dark)] leading-[120%] tracking-[-0.02em] lg:text-[28px]'>
|
||||
{FEATURE_TABS[activeTab].title}
|
||||
</h3>
|
||||
<p className='font-[430] font-season text-[#1C1C1C]/50 text-[16px] leading-[150%] tracking-[0.02em] lg:text-[18px]'>
|
||||
<p className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-dark)_50%,transparent)] text-md leading-[150%] tracking-[0.02em] lg:text-lg'>
|
||||
{FEATURE_TABS[activeTab].description}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href='/signup'
|
||||
className='group/cta inline-flex h-[32px] items-center gap-[6px] rounded-[5px] border border-[#1D1D1D] bg-[#1D1D1D] px-[10px] font-[430] font-season text-[14px] text-white transition-colors hover:border-[#2A2A2A] hover:bg-[#2A2A2A]'
|
||||
className='group/cta inline-flex h-[32px] items-center gap-1.5 rounded-[5px] border border-[#1D1D1D] bg-[#1D1D1D] px-2.5 font-[430] font-season text-sm text-white transition-colors hover:border-[var(--landing-bg-elevated)] hover:bg-[var(--landing-bg-elevated)]'
|
||||
>
|
||||
{FEATURE_TABS[activeTab].cta}
|
||||
<span className='relative h-[10px] w-[10px] shrink-0'>
|
||||
@@ -336,7 +332,7 @@ export default function Features() {
|
||||
<FeaturesPreview activeTab={activeTab} />
|
||||
</div>
|
||||
|
||||
<div aria-hidden='true' className='mt-[60px] hidden h-px bg-[#E9E9E9] lg:block' />
|
||||
<div aria-hidden='true' className='mt-[60px] hidden h-px bg-[var(--divider)] lg:block' />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -4,12 +4,12 @@ import { useCallback, useRef, useState } from 'react'
|
||||
import { ArrowUp } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useLandingSubmit } from '@/app/(home)/components/landing-preview/components/landing-preview-panel/landing-preview-panel'
|
||||
import { useAnimatedPlaceholder } from '@/app/workspace/[workspaceId]/home/hooks/use-animated-placeholder'
|
||||
import { useAnimatedPlaceholder } from '@/hooks/use-animated-placeholder'
|
||||
|
||||
const MAX_HEIGHT = 120
|
||||
|
||||
const CTA_BUTTON =
|
||||
'inline-flex items-center h-[32px] rounded-[5px] border px-[10px] font-[430] font-season text-[14px]'
|
||||
'inline-flex items-center h-[32px] rounded-[5px] border px-2.5 font-[430] font-season text-sm'
|
||||
|
||||
export function FooterCTA() {
|
||||
const landingSubmit = useLandingSubmit()
|
||||
@@ -41,14 +41,14 @@ export function FooterCTA() {
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className='flex flex-col items-center px-4 pt-[120px] pb-[100px] sm:px-8 md:px-[80px]'>
|
||||
<h2 className='text-center font-[430] font-season text-[#1C1C1C] text-[28px] leading-[100%] tracking-[-0.02em] sm:text-[32px] md:text-[36px]'>
|
||||
<div className='flex flex-col items-center px-4 pt-[120px] pb-[100px] sm:px-8 md:px-20'>
|
||||
<h2 className='text-balance text-center font-[430] font-season text-[28px] text-[var(--landing-text-dark)] leading-[100%] tracking-[-0.02em] sm:text-[32px] md:text-[36px]'>
|
||||
What should we get done?
|
||||
</h2>
|
||||
|
||||
<div className='mt-8 w-full max-w-[42rem]'>
|
||||
<div
|
||||
className='cursor-text rounded-[20px] border border-[#E5E5E5] bg-white px-[10px] py-[8px] shadow-sm'
|
||||
className='cursor-text rounded-[20px] border border-[var(--landing-bg-skeleton)] bg-white px-2.5 py-2 shadow-sm'
|
||||
onClick={() => textareaRef.current?.focus()}
|
||||
>
|
||||
<textarea
|
||||
@@ -59,7 +59,7 @@ export function FooterCTA() {
|
||||
onInput={handleInput}
|
||||
placeholder={animatedPlaceholder}
|
||||
rows={2}
|
||||
className='m-0 box-border min-h-[48px] w-full resize-none border-0 bg-transparent px-[4px] py-[4px] font-body text-[#1C1C1C] text-[15px] leading-[24px] tracking-[-0.015em] outline-none placeholder:font-[380] placeholder:text-[#999] focus-visible:ring-0'
|
||||
className='m-0 box-border min-h-[48px] w-full resize-none border-0 bg-transparent px-1 py-1 font-body text-[var(--landing-text-dark)] text-base leading-[24px] tracking-[-0.015em] outline-none placeholder:font-[380] placeholder:text-[var(--landing-text-muted)] focus-visible:ring-0'
|
||||
style={{ caretColor: '#1C1C1C', maxHeight: `${MAX_HEIGHT}px` }}
|
||||
/>
|
||||
<div className='flex items-center justify-end'>
|
||||
@@ -67,6 +67,7 @@ export function FooterCTA() {
|
||||
type='button'
|
||||
onClick={handleSubmit}
|
||||
disabled={isEmpty}
|
||||
aria-label='Submit message'
|
||||
className='flex h-[28px] w-[28px] items-center justify-center rounded-full border-0 p-0 transition-colors'
|
||||
style={{
|
||||
background: isEmpty ? '#C0C0C0' : '#1C1C1C',
|
||||
@@ -79,18 +80,18 @@ export function FooterCTA() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mt-8 flex gap-[8px]'>
|
||||
<div className='mt-8 flex gap-2'>
|
||||
<a
|
||||
href='https://docs.sim.ai'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className={`${CTA_BUTTON} border-[#D4D4D4] text-[#1C1C1C] transition-colors hover:bg-[#E8E8E8]`}
|
||||
className={`${CTA_BUTTON} border-[var(--landing-border-subtle)] text-[var(--landing-text-dark)] transition-colors hover:bg-[var(--landing-bg-skeleton)]`}
|
||||
>
|
||||
Docs
|
||||
</a>
|
||||
<Link
|
||||
href='/signup'
|
||||
className={`${CTA_BUTTON} gap-[8px] border-[#1C1C1C] bg-[#1C1C1C] text-white transition-colors hover:border-[#333] hover:bg-[#333]`}
|
||||
className={`${CTA_BUTTON} gap-2 border-[var(--landing-bg)] bg-[var(--landing-bg)] text-white transition-colors hover:border-[var(--landing-bg-elevated)] hover:bg-[var(--landing-bg-elevated)]`}
|
||||
>
|
||||
Get started
|
||||
</Link>
|
||||
|
||||
@@ -2,7 +2,8 @@ import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { FooterCTA } from '@/app/(home)/components/footer/footer-cta'
|
||||
|
||||
const LINK_CLASS = 'text-[14px] text-[#999] transition-colors hover:text-[#ECECEC]'
|
||||
const LINK_CLASS =
|
||||
'text-sm text-[var(--landing-text-muted)] transition-colors hover:text-[var(--landing-text)]'
|
||||
|
||||
interface FooterItem {
|
||||
label: string
|
||||
@@ -25,6 +26,8 @@ const RESOURCES_LINKS: FooterItem[] = [
|
||||
{ label: 'Blog', href: '/blog' },
|
||||
// { label: 'Templates', href: '/templates' },
|
||||
{ label: 'Docs', href: 'https://docs.sim.ai', external: true },
|
||||
// { label: 'Academy', href: '/academy' },
|
||||
{ label: 'Partners', href: '/partners' },
|
||||
{ label: 'Careers', href: 'https://jobs.ashbyhq.com/sim', external: true },
|
||||
{ label: 'Changelog', href: '/changelog' },
|
||||
]
|
||||
@@ -81,8 +84,8 @@ const LEGAL_LINKS: FooterItem[] = [
|
||||
function FooterColumn({ title, items }: { title: string; items: FooterItem[] }) {
|
||||
return (
|
||||
<div>
|
||||
<h3 className='mb-[16px] font-medium text-[#ECECEC] text-[14px]'>{title}</h3>
|
||||
<div className='flex flex-col gap-[10px]'>
|
||||
<h3 className='mb-4 font-medium text-[var(--landing-text)] text-sm'>{title}</h3>
|
||||
<div className='flex flex-col gap-2.5'>
|
||||
{items.map(({ label, href, external }) =>
|
||||
external ? (
|
||||
<a
|
||||
@@ -113,16 +116,16 @@ export default function Footer({ hideCTA }: FooterProps) {
|
||||
return (
|
||||
<footer
|
||||
role='contentinfo'
|
||||
className={`bg-[#F6F6F6] pb-[40px] font-[430] font-season text-[14px]${hideCTA ? ' pt-[40px]' : ''}`}
|
||||
className={`bg-[var(--landing-bg-section)] pb-10 font-[430] font-season text-sm${hideCTA ? ' pt-10' : ''}`}
|
||||
>
|
||||
{!hideCTA && <FooterCTA />}
|
||||
<div className='px-4 sm:px-8 md:px-[80px]'>
|
||||
<div className='relative overflow-hidden rounded-lg bg-[#1C1C1C] px-6 pt-[40px] pb-[32px] sm:px-10 sm:pt-[48px] sm:pb-[40px]'>
|
||||
<div className='px-4 sm:px-8 md:px-20'>
|
||||
<div className='relative overflow-hidden rounded-lg bg-[var(--landing-bg)] px-6 pt-10 pb-8 sm:px-10 sm:pt-12 sm:pb-10'>
|
||||
<nav
|
||||
aria-label='Footer navigation'
|
||||
className='relative z-[1] grid grid-cols-2 gap-x-8 gap-y-10 sm:grid-cols-3 lg:grid-cols-7'
|
||||
>
|
||||
<div className='col-span-2 flex flex-col gap-[24px] sm:col-span-1'>
|
||||
<div className='col-span-2 flex flex-col gap-6 sm:col-span-1'>
|
||||
<Link href='/' aria-label='Sim home'>
|
||||
<Image
|
||||
src='/logo/sim-landing.svg'
|
||||
|
||||
@@ -20,13 +20,13 @@ const LandingPreview = dynamic(
|
||||
),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <div className='aspect-[1116/549] w-full rounded bg-[#1b1b1b]' />,
|
||||
loading: () => <div className='aspect-[1116/549] w-full rounded bg-[var(--landing-bg)]' />,
|
||||
}
|
||||
)
|
||||
|
||||
/** Shared base classes for CTA link buttons — matches Deploy/Run button styling in the preview panel. */
|
||||
const CTA_BASE =
|
||||
'inline-flex items-center h-[32px] rounded-[5px] border px-[10px] font-[430] font-season text-[14px]'
|
||||
'inline-flex items-center h-[32px] rounded-[5px] border px-2.5 font-[430] font-season text-sm'
|
||||
|
||||
export default function Hero() {
|
||||
const blockStates = useBlockCycle()
|
||||
@@ -35,7 +35,7 @@ export default function Hero() {
|
||||
<section
|
||||
id='hero'
|
||||
aria-labelledby='hero-heading'
|
||||
className='relative flex flex-col items-center overflow-hidden bg-[#1C1C1C] pt-[60px] pb-[12px] lg:pt-[100px]'
|
||||
className='relative flex flex-col items-center overflow-hidden bg-[var(--landing-bg)] pt-[60px] pb-3 lg:pt-[100px]'
|
||||
>
|
||||
<p className='sr-only'>
|
||||
Sim is the open-source platform to build AI agents and run your agentic workforce. Connect
|
||||
@@ -59,22 +59,22 @@ export default function Hero() {
|
||||
<Image src='/landing/card-right.svg' alt='' fill className='object-contain' />
|
||||
</div>
|
||||
|
||||
<div className='relative z-10 flex flex-col items-center gap-[12px]'>
|
||||
<div className='relative z-10 flex flex-col items-center gap-3'>
|
||||
<h1
|
||||
id='hero-heading'
|
||||
className='font-[430] font-season text-[36px] text-white leading-[100%] tracking-[-0.02em] sm:text-[48px] lg:text-[72px]'
|
||||
className='text-balance font-[430] font-season text-[36px] text-white leading-[100%] tracking-[-0.02em] sm:text-[48px] lg:text-[72px]'
|
||||
>
|
||||
Build AI Agents
|
||||
</h1>
|
||||
<p className='font-[430] font-season text-[#F6F6F6]/60 text-[15px] leading-[125%] tracking-[0.02em] lg:text-[18px]'>
|
||||
<p className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_60%,transparent)] text-base leading-[125%] tracking-[0.02em] lg:text-lg'>
|
||||
Sim is the AI Workspace for Agent Builders.
|
||||
</p>
|
||||
|
||||
<div className='mt-[12px] flex items-center gap-[8px]'>
|
||||
<div className='mt-3 flex items-center gap-2'>
|
||||
<DemoRequestModal>
|
||||
<button
|
||||
type='button'
|
||||
className={`${CTA_BASE} border-[#3d3d3d] bg-transparent text-[#ECECEC] transition-colors hover:bg-[#2A2A2A]`}
|
||||
className={`${CTA_BASE} border-[var(--landing-border-strong)] bg-transparent text-[var(--landing-text)] transition-colors hover:bg-[var(--landing-bg-elevated)]`}
|
||||
aria-label='Get a demo'
|
||||
>
|
||||
Get a demo
|
||||
@@ -82,7 +82,7 @@ export default function Hero() {
|
||||
</DemoRequestModal>
|
||||
<Link
|
||||
href='/signup'
|
||||
className={`${CTA_BASE} gap-[8px] border-[#FFFFFF] bg-[#FFFFFF] text-black transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]`}
|
||||
className={`${CTA_BASE} gap-2 border-white bg-white text-black transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]`}
|
||||
aria-label='Get started with Sim'
|
||||
>
|
||||
Get started
|
||||
@@ -119,7 +119,7 @@ export default function Hero() {
|
||||
<BlocksRightSideAnimated animState={blockStates.rightSide} />
|
||||
</div>
|
||||
|
||||
<div className='relative z-10 overflow-hidden rounded border border-[#2A2A2A]'>
|
||||
<div className='relative z-10 overflow-hidden rounded border border-[var(--landing-bg-elevated)]'>
|
||||
<LandingPreview />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { memo, useCallback, useRef, useState } from 'react'
|
||||
import { ArrowUp } from 'lucide-react'
|
||||
import { useLandingSubmit } from '@/app/(home)/components/landing-preview/components/landing-preview-panel/landing-preview-panel'
|
||||
import { useAnimatedPlaceholder } from '@/app/workspace/[workspaceId]/home/hooks/use-animated-placeholder'
|
||||
import { useAnimatedPlaceholder } from '@/hooks/use-animated-placeholder'
|
||||
|
||||
const C = {
|
||||
SURFACE: '#292929',
|
||||
@@ -48,10 +48,10 @@ export const LandingPreviewHome = memo(function LandingPreviewHome() {
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className='flex min-w-0 flex-1 flex-col items-center justify-center px-[24px] pb-[2vh]'>
|
||||
<div className='flex min-w-0 flex-1 flex-col items-center justify-center px-6 pb-[2vh]'>
|
||||
<p
|
||||
role='presentation'
|
||||
className='mb-[24px] max-w-[42rem] font-[430] font-season text-[32px] tracking-[-0.02em]'
|
||||
className='mb-6 max-w-[42rem] font-[430] font-season text-[32px] tracking-[-0.02em]'
|
||||
style={{ color: C.TEXT_PRIMARY }}
|
||||
>
|
||||
What should we get done?
|
||||
@@ -59,7 +59,7 @@ export const LandingPreviewHome = memo(function LandingPreviewHome() {
|
||||
|
||||
<div className='w-full max-w-[32rem]'>
|
||||
<div
|
||||
className='cursor-text rounded-[20px] border px-[10px] py-[8px]'
|
||||
className='cursor-text rounded-[20px] border px-2.5 py-2'
|
||||
style={{ borderColor: C.BORDER, backgroundColor: C.SURFACE }}
|
||||
onClick={() => textareaRef.current?.focus()}
|
||||
>
|
||||
@@ -71,7 +71,7 @@ export const LandingPreviewHome = memo(function LandingPreviewHome() {
|
||||
onInput={handleInput}
|
||||
placeholder={animatedPlaceholder}
|
||||
rows={1}
|
||||
className='m-0 box-border min-h-[24px] w-full resize-none overflow-y-auto border-0 bg-transparent px-[4px] py-[4px] font-body text-[15px] leading-[24px] tracking-[-0.015em] outline-none placeholder:font-[380] placeholder:text-[#787878] focus-visible:ring-0'
|
||||
className='m-0 box-border min-h-[24px] w-full resize-none overflow-y-auto border-0 bg-transparent px-1 py-1 font-body text-[15px] leading-[24px] tracking-[-0.015em] outline-none placeholder:font-[380] placeholder:text-[#787878] focus-visible:ring-0'
|
||||
style={{
|
||||
color: C.TEXT_PRIMARY,
|
||||
caretColor: C.TEXT_PRIMARY,
|
||||
|
||||
@@ -59,10 +59,10 @@ export const LandingPreviewPanel = memo(function LandingPreviewPanel() {
|
||||
|
||||
return (
|
||||
<div className='flex h-full w-[280px] flex-shrink-0 flex-col bg-[#1e1e1e]'>
|
||||
<div className='flex h-full flex-col border-[#2c2c2c] border-l pt-[14px]'>
|
||||
<div className='flex h-full flex-col border-[#2c2c2c] border-l pt-3.5'>
|
||||
{/* Header — More + Chat | Deploy + Run */}
|
||||
<div className='flex flex-shrink-0 items-center justify-between px-[8px]'>
|
||||
<div className='pointer-events-none flex gap-[6px]'>
|
||||
<div className='flex flex-shrink-0 items-center justify-between px-2'>
|
||||
<div className='pointer-events-none flex gap-1.5'>
|
||||
<div className='flex h-[30px] w-[30px] items-center justify-center rounded-[5px] border border-[#3d3d3d] bg-[#363636]'>
|
||||
<MoreHorizontal className='h-[14px] w-[14px] text-[#e6e6e6]' />
|
||||
</div>
|
||||
@@ -72,14 +72,14 @@ export const LandingPreviewPanel = memo(function LandingPreviewPanel() {
|
||||
</div>
|
||||
<Link
|
||||
href='/signup'
|
||||
className='flex gap-[6px]'
|
||||
className='flex gap-1.5'
|
||||
onMouseMove={(e) => setCursorPos({ x: e.clientX, y: e.clientY })}
|
||||
onMouseLeave={() => setCursorPos(null)}
|
||||
>
|
||||
<div className='flex h-[30px] items-center rounded-[5px] bg-[#33C482] px-[10px] transition-colors hover:bg-[#2DAC72]'>
|
||||
<div className='flex h-[30px] items-center rounded-[5px] bg-[#33C482] px-2.5 transition-colors hover:bg-[#2DAC72]'>
|
||||
<span className='font-medium text-[#1b1b1b] text-[12px]'>Deploy</span>
|
||||
</div>
|
||||
<div className='flex h-[30px] items-center gap-[8px] rounded-[5px] bg-[#33C482] px-[10px] transition-colors hover:bg-[#2DAC72]'>
|
||||
<div className='flex h-[30px] items-center gap-2 rounded-[5px] bg-[#33C482] px-2.5 transition-colors hover:bg-[#2DAC72]'>
|
||||
<Play className='h-[11.5px] w-[11.5px] text-[#1b1b1b]' />
|
||||
<span className='font-medium text-[#1b1b1b] text-[12px]'>Run</span>
|
||||
</div>
|
||||
@@ -101,7 +101,7 @@ export const LandingPreviewPanel = memo(function LandingPreviewPanel() {
|
||||
<div className='h-full w-[8px] bg-[#FA4EDF]' />
|
||||
<div className='h-full w-[14px] bg-[#FA4EDF] opacity-60' />
|
||||
</div>
|
||||
<div className='flex items-center gap-[5px] bg-white px-[6px] py-[4px] font-medium text-[#1C1C1C] text-[11px]'>
|
||||
<div className='flex items-center gap-[5px] bg-white px-1.5 py-1 font-medium text-[#1C1C1C] text-[11px]'>
|
||||
Get started
|
||||
<ChevronDown className='-rotate-90 h-[7px] w-[7px] text-[#1C1C1C]' />
|
||||
</div>
|
||||
@@ -111,31 +111,31 @@ export const LandingPreviewPanel = memo(function LandingPreviewPanel() {
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className='flex flex-shrink-0 items-center px-[8px] pt-[14px]'>
|
||||
<div className='pointer-events-none flex gap-[4px]'>
|
||||
<div className='flex h-[28px] items-center rounded-[6px] border border-[#3d3d3d] bg-[#363636] px-[8px] py-[5px]'>
|
||||
<div className='flex flex-shrink-0 items-center px-2 pt-3.5'>
|
||||
<div className='pointer-events-none flex gap-1'>
|
||||
<div className='flex h-[28px] items-center rounded-[6px] border border-[#3d3d3d] bg-[#363636] px-2 py-[5px]'>
|
||||
<span className='font-medium text-[#e6e6e6] text-[12.5px]'>Copilot</span>
|
||||
</div>
|
||||
<div className='flex h-[28px] items-center rounded-[6px] border border-transparent px-[8px] py-[5px]'>
|
||||
<div className='flex h-[28px] items-center rounded-[6px] border border-transparent px-2 py-[5px]'>
|
||||
<span className='font-medium text-[#787878] text-[12.5px]'>Toolbar</span>
|
||||
</div>
|
||||
<div className='flex h-[28px] items-center rounded-[6px] border border-transparent px-[8px] py-[5px]'>
|
||||
<div className='flex h-[28px] items-center rounded-[6px] border border-transparent px-2 py-[5px]'>
|
||||
<span className='font-medium text-[#787878] text-[12.5px]'>Editor</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab content — copilot */}
|
||||
<div className='flex flex-1 flex-col overflow-hidden pt-[12px]'>
|
||||
<div className='flex flex-1 flex-col overflow-hidden pt-3'>
|
||||
<div className='flex h-full flex-col'>
|
||||
{/* Copilot header bar — matches mx-[-1px] in real copilot */}
|
||||
<div className='pointer-events-none mx-[-1px] flex flex-shrink-0 items-center rounded-[4px] border border-[#2c2c2c] bg-[#292929] px-[12px] py-[6px]'>
|
||||
<div className='pointer-events-none mx-[-1px] flex flex-shrink-0 items-center rounded-[4px] border border-[#2c2c2c] bg-[#292929] px-3 py-1.5'>
|
||||
<span className='truncate font-medium text-[#e6e6e6] text-[14px]'>New Chat</span>
|
||||
</div>
|
||||
|
||||
{/* User input — matches real UserInput at p-[8px] inside copilot welcome state */}
|
||||
<div className='px-[8px] pt-[12px] pb-[8px]'>
|
||||
<div className='rounded-[4px] border border-[#3d3d3d] bg-[#292929] px-[6px] py-[6px]'>
|
||||
<div className='px-2 pt-3 pb-2'>
|
||||
<div className='rounded-[4px] border border-[#3d3d3d] bg-[#292929] px-1.5 py-1.5'>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={inputValue}
|
||||
@@ -143,7 +143,7 @@ export const LandingPreviewPanel = memo(function LandingPreviewPanel() {
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder='Build an AI agent...'
|
||||
rows={2}
|
||||
className='mb-[6px] min-h-[48px] w-full cursor-text resize-none border-0 bg-transparent px-[2px] py-1 font-base text-[#e6e6e6] text-sm leading-[1.25rem] placeholder-[#787878] caret-[#e6e6e6] outline-none'
|
||||
className='mb-1.5 min-h-[48px] w-full cursor-text resize-none border-0 bg-transparent px-0.5 py-1 font-base text-[#e6e6e6] text-sm leading-[1.25rem] placeholder-[#787878] caret-[#e6e6e6] outline-none'
|
||||
/>
|
||||
<div className='flex items-center justify-end'>
|
||||
<button
|
||||
|
||||
@@ -57,7 +57,7 @@ function StaticNavItem({
|
||||
label: string
|
||||
}) {
|
||||
return (
|
||||
<div className='pointer-events-none mx-[2px] flex h-[28px] items-center gap-[8px] rounded-[8px] px-[8px]'>
|
||||
<div className='pointer-events-none mx-0.5 flex h-[28px] items-center gap-2 rounded-[8px] px-2'>
|
||||
<Icon className='h-[14px] w-[14px] flex-shrink-0' style={{ color: C.TEXT_ICON }} />
|
||||
<span className='truncate text-[13px]' style={{ color: C.TEXT_BODY, fontWeight: 450 }}>
|
||||
{label}
|
||||
@@ -82,13 +82,13 @@ export function LandingPreviewSidebar({
|
||||
|
||||
return (
|
||||
<div
|
||||
className='flex h-full w-[248px] flex-shrink-0 flex-col pt-[12px]'
|
||||
className='flex h-full w-[248px] flex-shrink-0 flex-col pt-3'
|
||||
style={{ backgroundColor: C.SURFACE_1 }}
|
||||
>
|
||||
{/* Workspace Header */}
|
||||
<div className='flex-shrink-0 px-[10px]'>
|
||||
<div className='flex-shrink-0 px-2.5'>
|
||||
<div
|
||||
className='pointer-events-none flex h-[32px] w-full items-center gap-[8px] rounded-[8px] border pr-[8px] pl-[5px]'
|
||||
className='pointer-events-none flex h-[32px] w-full items-center gap-2 rounded-[8px] border pr-2 pl-[5px]'
|
||||
style={{ borderColor: C.BORDER, backgroundColor: C.SURFACE_2 }}
|
||||
>
|
||||
<div className='flex h-[20px] w-[20px] flex-shrink-0 items-center justify-center rounded-[4px] bg-white'>
|
||||
@@ -112,11 +112,11 @@ export function LandingPreviewSidebar({
|
||||
</div>
|
||||
|
||||
{/* Top Navigation: Home (interactive), Search (static) */}
|
||||
<div className='mt-[10px] flex flex-shrink-0 flex-col gap-[2px] px-[8px]'>
|
||||
<div className='mt-2.5 flex flex-shrink-0 flex-col gap-0.5 px-2'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={onSelectHome}
|
||||
className='mx-[2px] flex h-[28px] items-center gap-[8px] rounded-[8px] px-[8px] transition-colors'
|
||||
className='mx-0.5 flex h-[28px] items-center gap-2 rounded-[8px] px-2 transition-colors'
|
||||
style={{ backgroundColor: isHomeActive ? C.SURFACE_ACTIVE : 'transparent' }}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isHomeActive) e.currentTarget.style.backgroundColor = C.SURFACE_ACTIVE
|
||||
@@ -134,13 +134,13 @@ export function LandingPreviewSidebar({
|
||||
</div>
|
||||
|
||||
{/* Workspace */}
|
||||
<div className='mt-[14px] flex flex-shrink-0 flex-col'>
|
||||
<div className='px-[16px] pb-[6px]'>
|
||||
<div className='mt-3.5 flex flex-shrink-0 flex-col'>
|
||||
<div className='px-4 pb-1.5'>
|
||||
<div className='font-base text-[12px]' style={{ color: C.TEXT_ICON }}>
|
||||
Workspace
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-col gap-[2px] px-[8px]'>
|
||||
<div className='flex flex-col gap-0.5 px-2'>
|
||||
{WORKSPACE_NAV.map((item) => (
|
||||
<StaticNavItem key={item.id} icon={item.icon} label={item.label} />
|
||||
))}
|
||||
@@ -148,15 +148,15 @@ export function LandingPreviewSidebar({
|
||||
</div>
|
||||
|
||||
{/* Scrollable Tasks + Workflows */}
|
||||
<div className='flex flex-1 flex-col overflow-y-auto overflow-x-hidden pt-[14px]'>
|
||||
<div className='flex flex-1 flex-col overflow-y-auto overflow-x-hidden pt-3.5'>
|
||||
{/* Workflows */}
|
||||
<div className='flex flex-col'>
|
||||
<div className='px-[16px]'>
|
||||
<div className='px-4'>
|
||||
<div className='font-base text-[12px]' style={{ color: C.TEXT_ICON }}>
|
||||
Workflows
|
||||
</div>
|
||||
</div>
|
||||
<div className='mt-[6px] flex flex-col gap-[2px] px-[8px]'>
|
||||
<div className='mt-1.5 flex flex-col gap-0.5 px-2'>
|
||||
{workflows.map((workflow) => {
|
||||
const isActive = activeView === 'workflow' && workflow.id === activeWorkflowId
|
||||
return (
|
||||
@@ -164,7 +164,7 @@ export function LandingPreviewSidebar({
|
||||
key={workflow.id}
|
||||
type='button'
|
||||
onClick={() => onSelectWorkflow(workflow.id)}
|
||||
className='group mx-[2px] flex h-[28px] w-full items-center gap-[8px] rounded-[8px] px-[8px] transition-colors'
|
||||
className='group mx-0.5 flex h-[28px] w-full items-center gap-2 rounded-[8px] px-2 transition-colors'
|
||||
style={{ backgroundColor: isActive ? C.SURFACE_ACTIVE : 'transparent' }}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isActive) e.currentTarget.style.backgroundColor = C.SURFACE_ACTIVE
|
||||
@@ -195,7 +195,7 @@ export function LandingPreviewSidebar({
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className='flex flex-shrink-0 flex-col gap-[2px] px-[8px] pt-[9px] pb-[8px]'>
|
||||
<div className='flex flex-shrink-0 flex-col gap-0.5 px-2 pt-[9px] pb-2'>
|
||||
{FOOTER_NAV.map((item) => (
|
||||
<StaticNavItem key={item.id} icon={item.icon} label={item.label} />
|
||||
))}
|
||||
|
||||
@@ -137,7 +137,7 @@ function PreviewFlow({ workflow, animate = false, fitViewOptions }: LandingPrevi
|
||||
proOptions={PRO_OPTIONS}
|
||||
fitView
|
||||
fitViewOptions={resolvedFitViewOptions}
|
||||
className='h-full w-full bg-[#1b1b1b]'
|
||||
className='h-full w-full bg-[var(--landing-bg)]'
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -150,10 +150,10 @@ export const PreviewBlockNode = memo(function PreviewBlockNode({
|
||||
transition={{ duration: 0.45, delay, ease: EASE_OUT }}
|
||||
>
|
||||
<div className='w-[280px] select-none rounded-[8px] border border-[#3d3d3d] bg-[#232323]'>
|
||||
<div className='border-[#3d3d3d] border-b p-[8px]'>
|
||||
<div className='border-[#3d3d3d] border-b p-2'>
|
||||
<span className='font-medium text-[#e6e6e6] text-[16px]'>Note</span>
|
||||
</div>
|
||||
<div className='p-[10px]'>
|
||||
<div className='p-2.5'>
|
||||
<NoteMarkdown content={markdown} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -186,9 +186,9 @@ export const PreviewBlockNode = memo(function PreviewBlockNode({
|
||||
|
||||
{/* Header */}
|
||||
<div
|
||||
className={`flex items-center justify-between p-[8px] ${hasContent ? 'border-[#3d3d3d] border-b' : ''}`}
|
||||
className={`flex items-center justify-between p-2 ${hasContent ? 'border-[#3d3d3d] border-b' : ''}`}
|
||||
>
|
||||
<div className='relative z-10 flex min-w-0 flex-1 items-center gap-[10px]'>
|
||||
<div className='relative z-10 flex min-w-0 flex-1 items-center gap-2.5'>
|
||||
<div
|
||||
className='flex h-[24px] w-[24px] flex-shrink-0 items-center justify-center rounded-[6px]'
|
||||
style={{ background: bgColor }}
|
||||
@@ -201,17 +201,17 @@ export const PreviewBlockNode = memo(function PreviewBlockNode({
|
||||
|
||||
{/* Sub-block rows + tools */}
|
||||
{hasContent && (
|
||||
<div className='flex flex-col gap-[8px] p-[8px]'>
|
||||
<div className='flex flex-col gap-2 p-2'>
|
||||
{rows.map((row) => {
|
||||
const modelEntry = row.title === 'Model' ? getModelIconEntry(row.value) : null
|
||||
const ModelIcon = modelEntry?.icon
|
||||
return (
|
||||
<div key={row.title} className='flex items-center gap-[8px]'>
|
||||
<div key={row.title} className='flex items-center gap-2'>
|
||||
<span className='flex-shrink-0 font-normal text-[#b3b3b3] text-[14px] capitalize'>
|
||||
{row.title}
|
||||
</span>
|
||||
{row.value && (
|
||||
<span className='flex min-w-0 flex-1 items-center justify-end gap-[5px] font-normal text-[#e6e6e6] text-[14px]'>
|
||||
<span className='flex min-w-0 flex-1 items-center justify-end gap-2 font-normal text-[#e6e6e6] text-[14px]'>
|
||||
{ModelIcon && (
|
||||
<ModelIcon
|
||||
className={`inline-block flex-shrink-0 text-[#e6e6e6] ${modelEntry.size ?? 'h-[14px] w-[14px]'}`}
|
||||
@@ -226,15 +226,15 @@ export const PreviewBlockNode = memo(function PreviewBlockNode({
|
||||
|
||||
{/* Tool chips — inline with label */}
|
||||
{tools && tools.length > 0 && (
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='flex-shrink-0 font-normal text-[#b3b3b3] text-[14px]'>Tools</span>
|
||||
<div className='flex flex-1 flex-wrap items-center justify-end gap-[5px]'>
|
||||
<div className='flex flex-1 flex-wrap items-center justify-end gap-2'>
|
||||
{tools.map((tool) => {
|
||||
const ToolIcon = BLOCK_ICONS[tool.type]
|
||||
return (
|
||||
<div
|
||||
key={tool.type}
|
||||
className='flex items-center gap-[5px] rounded-[5px] border border-[#3d3d3d] bg-[#2a2a2a] px-[6px] py-[3px]'
|
||||
className='flex items-center gap-2 rounded-[5px] border border-[#3d3d3d] bg-[#2a2a2a] px-2 py-1'
|
||||
>
|
||||
<div
|
||||
className='flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center rounded-[4px]'
|
||||
@@ -277,13 +277,13 @@ function NoteMarkdown({ content }: { content: string }) {
|
||||
const lines = content.split('\n')
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-[4px]'>
|
||||
<div className='flex flex-col gap-1'>
|
||||
{lines.map((line, i) => {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) return <div key={i} className='h-[4px]' />
|
||||
|
||||
if (trimmed === '---') {
|
||||
return <hr key={i} className='my-[4px] border-[#3d3d3d] border-t' />
|
||||
return <hr key={i} className='my-1 border-[#3d3d3d] border-t' />
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('### ')) {
|
||||
|
||||
@@ -81,7 +81,7 @@ export function LandingPreview() {
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className='dark flex aspect-[1116/549] w-full overflow-hidden rounded bg-[#1e1e1e] antialiased'
|
||||
className='dark flex aspect-[1116/549] w-full overflow-hidden rounded bg-[var(--landing-bg-surface)] antialiased'
|
||||
initial='hidden'
|
||||
animate='visible'
|
||||
variants={containerVariants}
|
||||
@@ -95,8 +95,8 @@ export function LandingPreview() {
|
||||
onSelectHome={handleSelectHome}
|
||||
/>
|
||||
</motion.div>
|
||||
<div className='flex min-w-0 flex-1 flex-col py-[8px] pr-[8px] pl-[8px] lg:pl-0'>
|
||||
<div className='flex flex-1 overflow-hidden rounded-[8px] border border-[#2c2c2c] bg-[#1b1b1b]'>
|
||||
<div className='flex min-w-0 flex-1 flex-col py-2 pr-2 pl-2 lg:pl-0'>
|
||||
<div className='flex flex-1 overflow-hidden rounded-[8px] border border-[#2c2c2c] bg-[var(--landing-bg)]'>
|
||||
<div
|
||||
className={
|
||||
isWorkflowView
|
||||
|
||||
@@ -29,7 +29,7 @@ function BlogCard({
|
||||
<Link
|
||||
href={`/blog/${slug}`}
|
||||
className={cn(
|
||||
'group/card flex flex-col overflow-hidden rounded-[5px] border border-[#2A2A2A] bg-[#1C1C1C] transition-colors hover:border-[#3D3D3D] hover:bg-[#2A2A2A]',
|
||||
'group/card flex flex-col overflow-hidden rounded-[5px] border border-[var(--landing-bg-elevated)] bg-[var(--landing-bg)] transition-colors hover:border-[var(--landing-border-strong)] hover:bg-[var(--landing-bg-elevated)]',
|
||||
className
|
||||
)}
|
||||
prefetch={false}
|
||||
@@ -41,11 +41,12 @@ function BlogCard({
|
||||
fill
|
||||
sizes={sizes}
|
||||
className='object-cover transition-transform duration-200 group-hover/card:scale-[1.02]'
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
<div className='flex-shrink-0 px-[10px] py-[6px]'>
|
||||
<div className='flex-shrink-0 px-2.5 py-1.5'>
|
||||
<span
|
||||
className='font-[430] font-season text-[#cdcdcd] leading-[140%]'
|
||||
className='font-[430] font-season text-[var(--landing-text-body)] leading-[140%]'
|
||||
style={{ fontSize: titleSize }}
|
||||
>
|
||||
{title}
|
||||
@@ -65,8 +66,8 @@ export function BlogDropdown({ posts }: BlogDropdownProps) {
|
||||
if (!featured) return null
|
||||
|
||||
return (
|
||||
<div className='w-[560px] rounded-[5px] border border-[#2A2A2A] bg-[#1C1C1C] p-[16px] shadow-[0_16px_48px_rgba(0,0,0,0.4)]'>
|
||||
<div className='grid grid-cols-3 gap-[8px]'>
|
||||
<div className='w-[560px] rounded-[5px] border border-[var(--landing-bg-elevated)] bg-[var(--landing-bg)] p-4 shadow-overlay'>
|
||||
<div className='grid grid-cols-3 gap-2'>
|
||||
<BlogCard
|
||||
slug={featured.slug}
|
||||
image={featured.ogImage}
|
||||
|
||||
@@ -37,15 +37,15 @@ const RESOURCE_CARDS = [
|
||||
|
||||
export function DocsDropdown() {
|
||||
return (
|
||||
<div className='w-[480px] rounded-[5px] border border-[#2A2A2A] bg-[#1C1C1C] p-[16px] shadow-[0_16px_48px_rgba(0,0,0,0.4)]'>
|
||||
<div className='grid grid-cols-2 gap-[10px]'>
|
||||
<div className='w-[480px] rounded-[5px] border border-[var(--landing-bg-elevated)] bg-[var(--landing-bg)] p-4 shadow-overlay'>
|
||||
<div className='grid grid-cols-2 gap-2.5'>
|
||||
{PREVIEW_CARDS.map((card) => (
|
||||
<a
|
||||
key={card.title}
|
||||
href={card.href}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='group/card overflow-hidden rounded-[5px] border border-[#2A2A2A] bg-[#1C1C1C] transition-colors hover:border-[#3D3D3D] hover:bg-[#2A2A2A]'
|
||||
className='group/card overflow-hidden rounded-[5px] border border-[var(--landing-bg-elevated)] bg-[var(--landing-bg)] transition-colors hover:border-[var(--landing-border-strong)] hover:bg-[var(--landing-bg-elevated)]'
|
||||
>
|
||||
<div className='relative h-[120px] w-full overflow-hidden bg-[#141414]'>
|
||||
<Image
|
||||
@@ -54,10 +54,11 @@ export function DocsDropdown() {
|
||||
fill
|
||||
sizes='220px'
|
||||
className='scale-[1.04] object-cover transition-transform duration-200 group-hover/card:scale-[1.06]'
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
<div className='px-[10px] py-[8px]'>
|
||||
<span className='font-[430] font-season text-[#cdcdcd] text-[13px]'>
|
||||
<div className='px-2.5 py-2'>
|
||||
<span className='font-[430] font-season text-[var(--landing-text-body)] text-small'>
|
||||
{card.title}
|
||||
</span>
|
||||
</div>
|
||||
@@ -65,7 +66,7 @@ export function DocsDropdown() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className='mt-[8px] grid grid-cols-3 gap-[8px]'>
|
||||
<div className='mt-2 grid grid-cols-3 gap-2'>
|
||||
{RESOURCE_CARDS.map((card) => {
|
||||
const Icon = card.icon
|
||||
return (
|
||||
@@ -74,15 +75,15 @@ export function DocsDropdown() {
|
||||
href={card.href}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='flex flex-col gap-[4px] rounded-[5px] border border-[#2A2A2A] px-[10px] py-[8px] transition-colors hover:border-[#3D3D3D] hover:bg-[#232323]'
|
||||
className='flex flex-col gap-1 rounded-[5px] border border-[var(--landing-bg-elevated)] px-2.5 py-2 transition-colors hover:border-[var(--landing-border-strong)] hover:bg-[var(--landing-bg-card)]'
|
||||
>
|
||||
<div className='flex items-center gap-[6px]'>
|
||||
<Icon className='h-[13px] w-[13px] flex-shrink-0 text-[#939393]' />
|
||||
<span className='font-[430] font-season text-[#cdcdcd] text-[12px]'>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<Icon className='h-[13px] w-[13px] flex-shrink-0 text-[var(--landing-text-icon)]' />
|
||||
<span className='font-[430] font-season text-[var(--landing-text-body)] text-caption'>
|
||||
{card.title}
|
||||
</span>
|
||||
</div>
|
||||
<span className='font-season text-[#939393] text-[11px] leading-[130%]'>
|
||||
<span className='font-season text-[var(--landing-text-icon)] text-xs leading-[130%]'>
|
||||
{card.description}
|
||||
</span>
|
||||
</a>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { GithubOutlineIcon } from '@/components/icons'
|
||||
import { getFormattedGitHubStars } from '@/app/(landing)/actions/github'
|
||||
import { getFormattedGitHubStars } from '@/app/(home)/actions/github'
|
||||
|
||||
const logger = createLogger('github-stars')
|
||||
|
||||
@@ -31,7 +31,7 @@ export function GitHubStars() {
|
||||
href='https://github.com/simstudioai/sim'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='flex items-center gap-[8px] px-[12px]'
|
||||
className='flex items-center gap-2 px-3'
|
||||
aria-label={`GitHub repository — ${stars} stars`}
|
||||
>
|
||||
<GithubOutlineIcon className='h-[14px] w-[14px]' />
|
||||
|
||||
@@ -32,8 +32,8 @@ const NAV_LINKS: NavLink[] = [
|
||||
{ label: 'Enterprise', href: 'https://form.typeform.com/to/jqCO12pF', external: true },
|
||||
]
|
||||
|
||||
const LOGO_CELL = 'flex items-center pl-[20px] lg:pl-[80px] pr-[20px]'
|
||||
const LINK_CELL = 'flex items-center px-[14px]'
|
||||
const LOGO_CELL = 'flex items-center pl-5 lg:pl-20 pr-5'
|
||||
const LINK_CELL = 'flex items-center px-3.5'
|
||||
|
||||
interface NavbarProps {
|
||||
logoOnly?: boolean
|
||||
@@ -96,7 +96,7 @@ export default function Navbar({ logoOnly = false, blogPosts = [] }: NavbarProps
|
||||
return (
|
||||
<nav
|
||||
aria-label='Primary navigation'
|
||||
className='relative flex h-[52px] border-[#2A2A2A] border-b-[1px] bg-[#1C1C1C] font-[430] font-season text-[#ECECEC] text-[14px]'
|
||||
className='relative flex h-[52px] border-[var(--landing-bg-elevated)] border-b-[1px] bg-[var(--landing-bg)] font-[430] font-season text-[var(--landing-text)] text-sm'
|
||||
itemScope
|
||||
itemType='https://schema.org/SiteNavigationElement'
|
||||
>
|
||||
@@ -138,9 +138,9 @@ export default function Navbar({ logoOnly = false, blogPosts = [] }: NavbarProps
|
||||
const isHighlighted = isActive || isThisHovered
|
||||
const isDimmed = anyHighlighted && !isHighlighted
|
||||
const linkClass = cn(
|
||||
icon ? `${LINK_CELL} gap-[8px]` : LINK_CELL,
|
||||
icon ? `${LINK_CELL} gap-2` : LINK_CELL,
|
||||
'transition-colors duration-200',
|
||||
isDimmed && 'text-[#F6F6F6]/60'
|
||||
isDimmed && 'text-[color-mix(in_srgb,var(--landing-text-subtle)_60%,transparent)]'
|
||||
)
|
||||
const chevron = icon === 'chevron' && <NavChevron open={isActive} />
|
||||
|
||||
@@ -152,19 +152,26 @@ export default function Navbar({ logoOnly = false, blogPosts = [] }: NavbarProps
|
||||
onMouseEnter={() => openDropdown(dropdown)}
|
||||
onMouseLeave={scheduleClose}
|
||||
>
|
||||
<button
|
||||
type='button'
|
||||
className={cn(linkClass, 'h-full cursor-pointer')}
|
||||
aria-expanded={isActive}
|
||||
aria-haspopup='true'
|
||||
>
|
||||
{label}
|
||||
{chevron}
|
||||
</button>
|
||||
{external ? (
|
||||
<a
|
||||
href={href}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className={cn(linkClass, 'h-full cursor-pointer')}
|
||||
>
|
||||
{label}
|
||||
{chevron}
|
||||
</a>
|
||||
) : (
|
||||
<Link href={href} className={cn(linkClass, 'h-full cursor-pointer')}>
|
||||
{label}
|
||||
{chevron}
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'-mt-[2px] absolute top-full left-0 z-50',
|
||||
'-mt-0.5 absolute top-full left-0 z-50',
|
||||
isActive
|
||||
? 'pointer-events-auto opacity-100'
|
||||
: 'pointer-events-none opacity-0'
|
||||
@@ -218,14 +225,14 @@ export default function Navbar({ logoOnly = false, blogPosts = [] }: NavbarProps
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'hidden items-center gap-[8px] pr-[80px] pl-[20px] lg:flex',
|
||||
'hidden items-center gap-2 pr-20 pl-5 lg:flex',
|
||||
isSessionPending && 'invisible'
|
||||
)}
|
||||
>
|
||||
{isAuthenticated ? (
|
||||
<Link
|
||||
href='/workspace'
|
||||
className='inline-flex h-[30px] items-center gap-[7px] rounded-[5px] border border-[#FFFFFF] bg-[#FFFFFF] px-[9px] text-[13.5px] text-black transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]'
|
||||
className='inline-flex h-[30px] items-center gap-[7px] rounded-[5px] border border-[var(--white)] bg-[var(--white)] px-[9px] text-[13.5px] text-black transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]'
|
||||
aria-label='Go to app'
|
||||
>
|
||||
Go to App
|
||||
@@ -234,14 +241,14 @@ export default function Navbar({ logoOnly = false, blogPosts = [] }: NavbarProps
|
||||
<>
|
||||
<Link
|
||||
href='/login'
|
||||
className='inline-flex h-[30px] items-center rounded-[5px] border border-[#3d3d3d] px-[9px] text-[#ECECEC] text-[13.5px] transition-colors hover:bg-[#2A2A2A]'
|
||||
className='inline-flex h-[30px] items-center rounded-[5px] border border-[var(--landing-border-strong)] px-[9px] text-[13.5px] text-[var(--landing-text)] transition-colors hover:bg-[var(--landing-bg-elevated)]'
|
||||
aria-label='Log in'
|
||||
>
|
||||
Log in
|
||||
</Link>
|
||||
<Link
|
||||
href='/signup'
|
||||
className='inline-flex h-[30px] items-center gap-[7px] rounded-[5px] border border-[#FFFFFF] bg-[#FFFFFF] px-[9px] text-[13.5px] text-black transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]'
|
||||
className='inline-flex h-[30px] items-center gap-[7px] rounded-[5px] border border-[var(--white)] bg-[var(--white)] px-2.5 text-[13.5px] text-black transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]'
|
||||
aria-label='Get started with Sim'
|
||||
>
|
||||
Get started
|
||||
@@ -250,10 +257,10 @@ export default function Navbar({ logoOnly = false, blogPosts = [] }: NavbarProps
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='flex flex-1 items-center justify-end pr-[20px] lg:hidden'>
|
||||
<div className='flex flex-1 items-center justify-end pr-5 lg:hidden'>
|
||||
<button
|
||||
type='button'
|
||||
className='flex h-[32px] w-[32px] items-center justify-center rounded-[5px] transition-colors hover:bg-[#2A2A2A]'
|
||||
className='flex h-[32px] w-[32px] items-center justify-center rounded-[5px] transition-colors hover:bg-[var(--landing-bg-elevated)]'
|
||||
onClick={() => setMobileMenuOpen((prev) => !prev)}
|
||||
aria-label={mobileMenuOpen ? 'Close menu' : 'Open menu'}
|
||||
aria-expanded={mobileMenuOpen}
|
||||
@@ -264,7 +271,7 @@ export default function Navbar({ logoOnly = false, blogPosts = [] }: NavbarProps
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'fixed inset-x-0 top-[52px] bottom-0 z-50 flex flex-col overflow-y-auto bg-[#1C1C1C] font-[430] font-season text-[14px] transition-all duration-200 lg:hidden',
|
||||
'fixed inset-x-0 top-[52px] bottom-0 z-50 flex flex-col overflow-y-auto bg-[var(--landing-bg)] font-[430] font-season text-sm transition-all duration-200 lg:hidden',
|
||||
mobileMenuOpen ? 'visible opacity-100' : 'invisible opacity-0'
|
||||
)}
|
||||
>
|
||||
@@ -273,13 +280,13 @@ export default function Navbar({ logoOnly = false, blogPosts = [] }: NavbarProps
|
||||
const href =
|
||||
useHomeLinks && rawHref.startsWith('/#') ? `/?home${rawHref.slice(1)}` : rawHref
|
||||
return (
|
||||
<li key={label} className='border-[#2A2A2A] border-b'>
|
||||
<li key={label} className='border-[var(--landing-border)] border-b'>
|
||||
{external ? (
|
||||
<a
|
||||
href={href}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='flex items-center justify-between px-[20px] py-[14px] text-[#ECECEC] transition-colors active:bg-[#2A2A2A]'
|
||||
className='flex items-center justify-between px-5 py-3.5 text-[var(--landing-text)] transition-colors active:bg-[var(--landing-bg-elevated)]'
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
{label}
|
||||
@@ -288,7 +295,7 @@ export default function Navbar({ logoOnly = false, blogPosts = [] }: NavbarProps
|
||||
) : (
|
||||
<Link
|
||||
href={href}
|
||||
className='flex items-center px-[20px] py-[14px] text-[#ECECEC] transition-colors active:bg-[#2A2A2A]'
|
||||
className='flex items-center px-5 py-3.5 text-[var(--landing-text)] transition-colors active:bg-[var(--landing-bg-elevated)]'
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
{label}
|
||||
@@ -297,12 +304,12 @@ export default function Navbar({ logoOnly = false, blogPosts = [] }: NavbarProps
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
<li className='border-[#2A2A2A] border-b'>
|
||||
<li className='border-[var(--landing-border)] border-b'>
|
||||
<a
|
||||
href='https://github.com/simstudioai/sim'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='flex items-center gap-[8px] px-[20px] py-[14px] text-[#ECECEC] transition-colors active:bg-[#2A2A2A]'
|
||||
className='flex items-center gap-2 px-5 py-3.5 text-[var(--landing-text)] transition-colors active:bg-[var(--landing-bg-elevated)]'
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
<GithubOutlineIcon className='h-[14px] w-[14px]' />
|
||||
@@ -312,15 +319,12 @@ export default function Navbar({ logoOnly = false, blogPosts = [] }: NavbarProps
|
||||
</ul>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'mt-auto flex flex-col gap-[10px] p-[20px]',
|
||||
isSessionPending && 'invisible'
|
||||
)}
|
||||
className={cn('mt-auto flex flex-col gap-2.5 p-5', isSessionPending && 'invisible')}
|
||||
>
|
||||
{isAuthenticated ? (
|
||||
<Link
|
||||
href='/workspace'
|
||||
className='flex h-[32px] items-center justify-center rounded-[5px] border border-[#FFFFFF] bg-[#FFFFFF] text-[14px] text-black transition-colors active:bg-[#E0E0E0]'
|
||||
className='flex h-[32px] items-center justify-center rounded-[5px] border border-[var(--white)] bg-[var(--white)] text-[14px] text-black transition-colors active:bg-[#E0E0E0]'
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
aria-label='Go to app'
|
||||
>
|
||||
@@ -330,7 +334,7 @@ export default function Navbar({ logoOnly = false, blogPosts = [] }: NavbarProps
|
||||
<>
|
||||
<Link
|
||||
href='/login'
|
||||
className='flex h-[32px] items-center justify-center rounded-[5px] border border-[#3d3d3d] text-[#ECECEC] text-[14px] transition-colors active:bg-[#2A2A2A]'
|
||||
className='flex h-[32px] items-center justify-center rounded-[5px] border border-[var(--landing-border-strong)] text-[14px] text-[var(--landing-text)] transition-colors active:bg-[var(--landing-bg-elevated)]'
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
aria-label='Log in'
|
||||
>
|
||||
@@ -338,7 +342,7 @@ export default function Navbar({ logoOnly = false, blogPosts = [] }: NavbarProps
|
||||
</Link>
|
||||
<Link
|
||||
href='/signup'
|
||||
className='flex h-[32px] items-center justify-center rounded-[5px] border border-[#FFFFFF] bg-[#FFFFFF] text-[14px] text-black transition-colors active:bg-[#E0E0E0]'
|
||||
className='flex h-[32px] items-center justify-center rounded-[5px] border border-[var(--white)] bg-[var(--white)] text-[14px] text-black transition-colors active:bg-[#E0E0E0]'
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
aria-label='Get started with Sim'
|
||||
>
|
||||
@@ -425,7 +429,13 @@ function MobileMenuIcon({ open }: { open: boolean }) {
|
||||
|
||||
function ExternalArrowIcon() {
|
||||
return (
|
||||
<svg width='12' height='12' viewBox='0 0 12 12' fill='none' className='text-[#666]'>
|
||||
<svg
|
||||
width='12'
|
||||
height='12'
|
||||
viewBox='0 0 12 12'
|
||||
fill='none'
|
||||
className='text-[var(--landing-text-secondary)]'
|
||||
>
|
||||
<path
|
||||
d='M3.5 2.5H9.5V8.5M9 3L3 9'
|
||||
stroke='currentColor'
|
||||
|
||||
@@ -25,6 +25,7 @@ const PRICING_TIERS: PricingTier[] = [
|
||||
'5GB file storage',
|
||||
'3 tables · 1,000 rows each',
|
||||
'5 min execution limit',
|
||||
'5 concurrent/workspace',
|
||||
'7-day log retention',
|
||||
'CLI/SDK/MCP Access',
|
||||
],
|
||||
@@ -42,6 +43,7 @@ const PRICING_TIERS: PricingTier[] = [
|
||||
'50GB file storage',
|
||||
'25 tables · 5,000 rows each',
|
||||
'50 min execution · 150 runs/min',
|
||||
'50 concurrent/workspace',
|
||||
'Unlimited log retention',
|
||||
'CLI/SDK/MCP Access',
|
||||
],
|
||||
@@ -59,6 +61,7 @@ const PRICING_TIERS: PricingTier[] = [
|
||||
'500GB file storage',
|
||||
'25 tables · 5,000 rows each',
|
||||
'50 min execution · 300 runs/min',
|
||||
'200 concurrent/workspace',
|
||||
'Unlimited log retention',
|
||||
'CLI/SDK/MCP Access',
|
||||
],
|
||||
@@ -75,6 +78,7 @@ const PRICING_TIERS: PricingTier[] = [
|
||||
'Custom file storage',
|
||||
'10,000 tables · 1M rows each',
|
||||
'Custom execution limits',
|
||||
'Custom concurrency limits',
|
||||
'Unlimited log retention',
|
||||
'SSO & SCIM · SOC2 & HIPAA',
|
||||
'Self hosting · Dedicated support',
|
||||
@@ -107,21 +111,21 @@ function PricingCard({ tier }: PricingCardProps) {
|
||||
|
||||
return (
|
||||
<article className='flex flex-1 flex-col' aria-labelledby={`${tier.id}-heading`}>
|
||||
<div className='flex flex-1 flex-col gap-6 rounded-t-lg border border-[#E5E5E5] border-b-0 bg-white p-5'>
|
||||
<div className='flex flex-1 flex-col gap-6 rounded-t-lg border border-[var(--landing-border-light)] border-b-0 bg-white p-5'>
|
||||
<div className='flex flex-col'>
|
||||
<h3
|
||||
id={`${tier.id}-heading`}
|
||||
className='font-[430] font-season text-[#1C1C1C] text-[24px] leading-[100%] tracking-[-0.02em]'
|
||||
className='font-[430] font-season text-[24px] text-[var(--landing-text-dark)] leading-[100%] tracking-[-0.02em]'
|
||||
>
|
||||
{tier.name}
|
||||
</h3>
|
||||
<p className='mt-2 min-h-[44px] font-[430] font-season text-[#5c5c5c] text-[14px] leading-[125%] tracking-[0.02em]'>
|
||||
<p className='mt-2 min-h-[44px] font-[430] font-season text-[#5c5c5c] text-sm leading-[125%] tracking-[0.02em]'>
|
||||
{tier.description}
|
||||
</p>
|
||||
<p className='mt-4 flex items-center gap-1.5 font-[430] font-season text-[#1C1C1C] text-[20px] leading-[100%] tracking-[-0.02em]'>
|
||||
<p className='mt-4 flex items-center gap-1.5 font-[430] font-season text-[20px] text-[var(--landing-text-dark)] leading-[100%] tracking-[-0.02em]'>
|
||||
{tier.price}
|
||||
{tier.billingPeriod && (
|
||||
<span className='text-[#737373] text-[16px]'>{tier.billingPeriod}</span>
|
||||
<span className='text-[#737373] text-md'>{tier.billingPeriod}</span>
|
||||
)}
|
||||
</p>
|
||||
<div className='mt-4'>
|
||||
@@ -129,7 +133,7 @@ function PricingCard({ tier }: PricingCardProps) {
|
||||
<DemoRequestModal theme='light'>
|
||||
<button
|
||||
type='button'
|
||||
className='flex h-[32px] w-full items-center justify-center rounded-[5px] border border-[#E5E5E5] bg-transparent px-[10px] font-[430] font-season text-[#1C1C1C] text-[14px] transition-colors hover:bg-[#F0F0F0]'
|
||||
className='flex h-[32px] w-full items-center justify-center rounded-[5px] border border-[var(--landing-border-light)] bg-transparent px-2.5 font-[430] font-season text-[14px] text-[var(--landing-text-dark)] transition-colors hover:bg-[var(--landing-bg-hover)]'
|
||||
>
|
||||
{tier.cta.label}
|
||||
</button>
|
||||
@@ -137,14 +141,14 @@ function PricingCard({ tier }: PricingCardProps) {
|
||||
) : isPro ? (
|
||||
<Link
|
||||
href={tier.cta.href || '/signup'}
|
||||
className='flex h-[32px] w-full items-center justify-center rounded-[5px] border border-[#1D1D1D] bg-[#1D1D1D] px-[10px] font-[430] font-season text-[14px] text-white transition-colors hover:border-[#2A2A2A] hover:bg-[#2A2A2A]'
|
||||
className='flex h-[32px] w-full items-center justify-center rounded-[5px] border border-[#1D1D1D] bg-[#1D1D1D] px-2.5 font-[430] font-season text-[14px] text-white transition-colors hover:border-[var(--landing-border)] hover:bg-[var(--landing-bg-elevated)]'
|
||||
>
|
||||
{tier.cta.label}
|
||||
</Link>
|
||||
) : (
|
||||
<Link
|
||||
href={tier.cta.href || '/signup'}
|
||||
className='flex h-[32px] w-full items-center justify-center rounded-[5px] border border-[#E5E5E5] px-[10px] font-[430] font-season text-[#1C1C1C] text-[14px] transition-colors hover:bg-[#F0F0F0]'
|
||||
className='flex h-[32px] w-full items-center justify-center rounded-[5px] border border-[var(--landing-border-light)] px-2.5 font-[430] font-season text-[14px] text-[var(--landing-text-dark)] transition-colors hover:bg-[var(--landing-bg-hover)]'
|
||||
>
|
||||
{tier.cta.label}
|
||||
</Link>
|
||||
@@ -156,7 +160,7 @@ function PricingCard({ tier }: PricingCardProps) {
|
||||
{tier.features.map((feature) => (
|
||||
<li key={feature} className='flex items-center gap-2'>
|
||||
<CheckIcon color='#404040' />
|
||||
<span className='font-[400] font-season text-[#5c5c5c] text-[14px] leading-[125%] tracking-[0.02em]'>
|
||||
<span className='font-[400] font-season text-[#5c5c5c] text-sm leading-[125%] tracking-[0.02em]'>
|
||||
{feature}
|
||||
</span>
|
||||
</li>
|
||||
@@ -187,9 +191,13 @@ function PricingCard({ tier }: PricingCardProps) {
|
||||
*/
|
||||
export default function Pricing() {
|
||||
return (
|
||||
<section id='pricing' aria-labelledby='pricing-heading' className='bg-[#F6F6F6]'>
|
||||
<div className='px-4 pt-[60px] pb-[40px] sm:px-8 sm:pt-[80px] sm:pb-0 md:px-[80px] md:pt-[100px]'>
|
||||
<div className='flex flex-col items-start gap-3 sm:gap-4 md:gap-[20px]'>
|
||||
<section
|
||||
id='pricing'
|
||||
aria-labelledby='pricing-heading'
|
||||
className='bg-[var(--landing-bg-section)]'
|
||||
>
|
||||
<div className='px-4 pt-[60px] pb-10 sm:px-8 sm:pt-20 sm:pb-0 md:px-20 md:pt-[100px]'>
|
||||
<div className='flex flex-col items-start gap-3 sm:gap-4 md:gap-5'>
|
||||
<Badge
|
||||
variant='blue'
|
||||
size='md'
|
||||
@@ -201,7 +209,7 @@ export default function Pricing() {
|
||||
|
||||
<h2
|
||||
id='pricing-heading'
|
||||
className='font-[430] font-season text-[#1C1C1C] text-[32px] leading-[100%] tracking-[-0.02em] sm:text-[36px] md:text-[40px]'
|
||||
className='text-balance font-[430] font-season text-[32px] text-[var(--landing-text-dark)] leading-[100%] tracking-[-0.02em] sm:text-[36px] md:text-[40px]'
|
||||
>
|
||||
Pricing
|
||||
</h2>
|
||||
|
||||
@@ -19,7 +19,7 @@ const LandingPreviewWorkflow = dynamic(
|
||||
).then((mod) => mod.LandingPreviewWorkflow),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <div className='h-full w-full bg-[#1b1b1b]' />,
|
||||
loading: () => <div className='h-full w-full bg-[var(--landing-bg)]' />,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -337,7 +337,7 @@ function DotGrid({ className, cols, rows, gap = 0 }: DotGridProps) {
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: cols * rows }, (_, i) => (
|
||||
<div key={i} className='h-[1.5px] w-[1.5px] rounded-full bg-[#2A2A2A]' />
|
||||
<div key={i} className='h-[1.5px] w-[1.5px] rounded-full bg-[var(--landing-bg-elevated)]' />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
@@ -412,7 +412,7 @@ export default function Templates() {
|
||||
ref={sectionRef}
|
||||
id='templates'
|
||||
aria-labelledby='templates-heading'
|
||||
className='mt-[40px] mb-[80px]'
|
||||
className='mt-10 mb-20'
|
||||
>
|
||||
<p className='sr-only'>
|
||||
Sim includes {TEMPLATE_WORKFLOWS.length} pre-built workflow templates covering OCR
|
||||
@@ -422,9 +422,9 @@ export default function Templates() {
|
||||
customise it, and deploy in minutes.
|
||||
</p>
|
||||
|
||||
<div className='bg-[#1C1C1C]'>
|
||||
<div className='bg-[var(--landing-bg)]'>
|
||||
<DotGrid
|
||||
className='overflow-hidden border-[#2A2A2A] border-y bg-[#1C1C1C] p-[6px]'
|
||||
className='overflow-hidden border-[var(--landing-bg-elevated)] border-y bg-[var(--landing-bg)] p-1.5'
|
||||
cols={160}
|
||||
rows={1}
|
||||
gap={6}
|
||||
@@ -449,8 +449,8 @@ export default function Templates() {
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className='px-[20px] pt-[60px] lg:px-[80px] lg:pt-[100px]'>
|
||||
<div className='flex flex-col items-start gap-[20px]'>
|
||||
<div className='px-5 pt-[60px] lg:px-20 lg:pt-[100px]'>
|
||||
<div className='flex flex-col items-start gap-5'>
|
||||
<Badge
|
||||
variant='blue'
|
||||
size='md'
|
||||
@@ -466,12 +466,12 @@ export default function Templates() {
|
||||
|
||||
<h2
|
||||
id='templates-heading'
|
||||
className='font-[430] font-season text-[28px] text-white leading-[100%] tracking-[-0.02em] lg:text-[40px]'
|
||||
className='text-balance font-[430] font-season text-[28px] text-white leading-[100%] tracking-[-0.02em] lg:text-[40px]'
|
||||
>
|
||||
Ship your agent in minutes
|
||||
</h2>
|
||||
|
||||
<p className='font-[430] font-season text-[#F6F6F0]/50 text-[15px] leading-[150%] tracking-[0.02em] lg:text-[18px]'>
|
||||
<p className='font-[430] font-season text-[#F6F6F0]/50 text-base leading-[150%] tracking-[0.02em] lg:text-lg'>
|
||||
Pre-built templates for every use case—pick one, swap{' '}
|
||||
<br className='hidden lg:inline' />
|
||||
models and tools to fit your stack, and deploy.
|
||||
@@ -479,11 +479,11 @@ export default function Templates() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mt-[40px] flex border-[#2A2A2A] border-y lg:mt-[73px]'>
|
||||
<div className='mt-10 flex border-[var(--landing-bg-elevated)] border-y lg:mt-[73px]'>
|
||||
<div className='shrink-0'>
|
||||
<div className='h-full lg:hidden'>
|
||||
<DotGrid
|
||||
className='h-full w-[24px] overflow-hidden border-[#2A2A2A] border-r p-[4px]'
|
||||
className='h-full w-[24px] overflow-hidden border-[var(--landing-bg-elevated)] border-r p-1'
|
||||
cols={2}
|
||||
rows={55}
|
||||
gap={4}
|
||||
@@ -491,7 +491,7 @@ export default function Templates() {
|
||||
</div>
|
||||
<div className='hidden h-full lg:block'>
|
||||
<DotGrid
|
||||
className='h-full w-[80px] overflow-hidden border-[#2A2A2A] border-r p-[6px]'
|
||||
className='h-full w-[80px] overflow-hidden border-[var(--landing-bg-elevated)] border-r p-1.5'
|
||||
cols={8}
|
||||
rows={55}
|
||||
gap={6}
|
||||
@@ -503,7 +503,7 @@ export default function Templates() {
|
||||
<div
|
||||
role='tablist'
|
||||
aria-label='Workflow templates'
|
||||
className='flex w-full shrink-0 flex-col border-[#2A2A2A] lg:w-[300px] lg:border-r'
|
||||
className='flex w-full shrink-0 flex-col border-[var(--landing-bg-elevated)] lg:w-[300px] lg:border-r'
|
||||
>
|
||||
{TEMPLATE_WORKFLOWS.map((workflow, index) => {
|
||||
const isActive = index === activeIndex
|
||||
@@ -521,7 +521,7 @@ export default function Templates() {
|
||||
isActive
|
||||
? 'z-10'
|
||||
: cn(
|
||||
'flex items-center px-[12px] py-[10px] hover:bg-[#232323]/50',
|
||||
'flex items-center px-3 py-2.5 hover:bg-[color-mix(in_srgb,var(--landing-bg-card)_50%,transparent)]',
|
||||
index < TEMPLATE_WORKFLOWS.length - 1 &&
|
||||
'shadow-[inset_0_-1px_0_0_#2A2A2A]'
|
||||
)
|
||||
@@ -543,8 +543,8 @@ export default function Templates() {
|
||||
className='absolute right-[-8px] bottom-0 left-2 h-2'
|
||||
style={buildBottomWallStyle(depth)}
|
||||
/>
|
||||
<div className='-translate-y-2 relative flex translate-x-2 items-center bg-[#242424] px-[12px] py-[10px] shadow-[inset_0_0_0_1.5px_#3E3E3E]'>
|
||||
<span className='flex-1 font-[430] font-season text-[16px] text-white'>
|
||||
<div className='-translate-y-2 relative flex translate-x-2 items-center bg-[var(--landing-bg-card)] px-3 py-2.5 shadow-[inset_0_0_0_1.5px_#3E3E3E]'>
|
||||
<span className='flex-1 font-[430] font-season text-md text-white'>
|
||||
{workflow.name}
|
||||
</span>
|
||||
<ChevronDown
|
||||
@@ -556,7 +556,7 @@ export default function Templates() {
|
||||
)
|
||||
})()
|
||||
) : (
|
||||
<span className='font-[430] font-season text-[#F6F6F0]/50 text-[16px]'>
|
||||
<span className='font-[430] font-season text-[#F6F6F0]/50 text-md'>
|
||||
{workflow.name}
|
||||
</span>
|
||||
)}
|
||||
@@ -571,19 +571,19 @@ export default function Templates() {
|
||||
transition={{ duration: 0.25, ease: [0.4, 0, 0.2, 1] }}
|
||||
className='overflow-hidden'
|
||||
>
|
||||
<div className='aspect-[16/10] w-full border-[#2A2A2A] border-y bg-[#1b1b1b]'>
|
||||
<div className='aspect-[16/10] w-full border-[var(--landing-bg-elevated)] border-y bg-[var(--landing-bg)]'>
|
||||
<LandingPreviewWorkflow
|
||||
workflow={workflow}
|
||||
animate
|
||||
fitViewOptions={{ padding: 0.15, maxZoom: 1.3 }}
|
||||
/>
|
||||
</div>
|
||||
<div className='p-[12px]'>
|
||||
<div className='p-3'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleUseTemplate}
|
||||
disabled={isPreparingTemplate}
|
||||
className='inline-flex h-[32px] w-full cursor-pointer items-center justify-center gap-[6px] rounded-[5px] border border-[#FFFFFF] bg-[#FFFFFF] font-[430] font-season text-[14px] text-black transition-colors active:bg-[#E0E0E0]'
|
||||
className='inline-flex h-[32px] w-full cursor-pointer items-center justify-center gap-1.5 rounded-[5px] border border-white bg-white font-[430] font-season text-black text-sm transition-colors active:bg-[#E0E0E0]'
|
||||
>
|
||||
{isPreparingTemplate ? 'Preparing...' : 'Use template'}
|
||||
</button>
|
||||
@@ -614,7 +614,7 @@ export default function Templates() {
|
||||
type='button'
|
||||
onClick={handleUseTemplate}
|
||||
disabled={isPreparingTemplate}
|
||||
className='group/cta absolute top-[16px] right-[16px] z-10 inline-flex h-[32px] cursor-pointer items-center gap-[6px] rounded-[5px] border border-[#FFFFFF] bg-[#FFFFFF] px-[10px] font-[430] font-season text-[14px] text-black transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]'
|
||||
className='group/cta absolute top-4 right-[16px] z-10 inline-flex h-[32px] cursor-pointer items-center gap-1.5 rounded-[5px] border border-white bg-white px-2.5 font-[430] font-season text-black text-sm transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]'
|
||||
>
|
||||
{isPreparingTemplate ? 'Preparing...' : 'Use template'}
|
||||
<span className='relative h-[10px] w-[10px] shrink-0'>
|
||||
@@ -642,7 +642,7 @@ export default function Templates() {
|
||||
<div className='shrink-0'>
|
||||
<div className='h-full lg:hidden'>
|
||||
<DotGrid
|
||||
className='h-full w-[24px] overflow-hidden border-[#2A2A2A] border-l p-[4px]'
|
||||
className='h-full w-[24px] overflow-hidden border-[var(--landing-bg-elevated)] border-l p-1'
|
||||
cols={2}
|
||||
rows={55}
|
||||
gap={4}
|
||||
@@ -650,7 +650,7 @@ export default function Templates() {
|
||||
</div>
|
||||
<div className='hidden h-full lg:block'>
|
||||
<DotGrid
|
||||
className='h-full w-[80px] overflow-hidden border-[#2A2A2A] border-l p-[6px]'
|
||||
className='h-full w-[80px] overflow-hidden border-[var(--landing-bg-elevated)] border-l p-1.5'
|
||||
cols={8}
|
||||
rows={55}
|
||||
gap={6}
|
||||
|
||||
@@ -36,7 +36,9 @@ export default async function Landing() {
|
||||
const blogPosts = await getNavBlogPosts()
|
||||
|
||||
return (
|
||||
<div className={`${season.variable} ${martianMono.variable} min-h-screen bg-[#1C1C1C]`}>
|
||||
<div
|
||||
className={`${season.variable} ${martianMono.variable} min-h-screen bg-[var(--landing-bg)]`}
|
||||
>
|
||||
<a
|
||||
href='#main-content'
|
||||
className='sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-[100] focus:rounded-md focus:bg-white focus:px-4 focus:py-2 focus:font-medium focus:text-black focus:text-sm'
|
||||
|
||||
@@ -10,7 +10,7 @@ export function BackLink() {
|
||||
return (
|
||||
<Link
|
||||
href='/blog'
|
||||
className='group flex items-center gap-1 text-[#999] text-sm hover:text-[#ECECEC]'
|
||||
className='group flex items-center gap-1 text-[var(--landing-text-muted)] text-sm hover:text-[var(--landing-text)]'
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
|
||||
@@ -7,51 +7,51 @@ export default function BlogPostLoading() {
|
||||
<div className='mx-auto max-w-[1450px] px-6 pt-8 sm:px-8 sm:pt-12 md:px-12 md:pt-16'>
|
||||
{/* Back link */}
|
||||
<div className='mb-6'>
|
||||
<Skeleton className='h-[16px] w-[60px] rounded-[4px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='h-[16px] w-[60px] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
|
||||
</div>
|
||||
{/* Image + title row */}
|
||||
<div className='flex flex-col gap-8 md:flex-row md:gap-12'>
|
||||
{/* Image */}
|
||||
<div className='w-full flex-shrink-0 md:w-[450px]'>
|
||||
<Skeleton className='aspect-[450/360] w-full rounded-lg bg-[#2A2A2A]' />
|
||||
<Skeleton className='aspect-[450/360] w-full rounded-lg bg-[var(--landing-bg-elevated)]' />
|
||||
</div>
|
||||
{/* Title + author */}
|
||||
<div className='flex flex-1 flex-col justify-between'>
|
||||
<div>
|
||||
<Skeleton className='h-[48px] w-full rounded-[4px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='mt-[8px] h-[48px] w-[80%] rounded-[4px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='h-[48px] w-full rounded-[4px] bg-[var(--landing-bg-elevated)]' />
|
||||
<Skeleton className='mt-2 h-[48px] w-[80%] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
|
||||
</div>
|
||||
<div className='mt-4 flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Skeleton className='h-[24px] w-[24px] rounded-full bg-[#2A2A2A]' />
|
||||
<Skeleton className='h-[16px] w-[100px] rounded-[4px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='h-[24px] w-[24px] rounded-full bg-[var(--landing-bg-elevated)]' />
|
||||
<Skeleton className='h-[16px] w-[100px] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
|
||||
</div>
|
||||
<Skeleton className='h-[32px] w-[32px] rounded-[6px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='h-[32px] w-[32px] rounded-[6px] bg-[var(--landing-bg-elevated)]' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Divider */}
|
||||
<Skeleton className='mt-8 h-[1px] w-full bg-[#2A2A2A] sm:mt-12' />
|
||||
<Skeleton className='mt-8 h-[1px] w-full bg-[var(--landing-bg-elevated)] sm:mt-12' />
|
||||
{/* Date + description */}
|
||||
<div className='flex flex-col gap-6 py-8 sm:flex-row sm:items-start sm:justify-between sm:gap-8 sm:py-10'>
|
||||
<Skeleton className='h-[16px] w-[120px] flex-shrink-0 rounded-[4px] bg-[#2A2A2A]' />
|
||||
<div className='flex-1 space-y-[8px]'>
|
||||
<Skeleton className='h-[20px] w-full rounded-[4px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='h-[20px] w-[70%] rounded-[4px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='h-[16px] w-[120px] flex-shrink-0 rounded-[4px] bg-[var(--landing-bg-elevated)]' />
|
||||
<div className='flex-1 space-y-2'>
|
||||
<Skeleton className='h-[20px] w-full rounded-[4px] bg-[var(--landing-bg-elevated)]' />
|
||||
<Skeleton className='h-[20px] w-[70%] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Article body */}
|
||||
<div className='mx-auto max-w-[900px] px-6 pb-20 sm:px-8 md:px-12'>
|
||||
<div className='space-y-[16px]'>
|
||||
<Skeleton className='h-[16px] w-full rounded-[4px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='h-[16px] w-[95%] rounded-[4px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='h-[16px] w-[88%] rounded-[4px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='h-[16px] w-full rounded-[4px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='mt-[24px] h-[24px] w-[200px] rounded-[4px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='h-[16px] w-full rounded-[4px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='h-[16px] w-[92%] rounded-[4px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='h-[16px] w-[85%] rounded-[4px] bg-[#2A2A2A]' />
|
||||
<div className='space-y-4'>
|
||||
<Skeleton className='h-[16px] w-full rounded-[4px] bg-[var(--landing-bg-elevated)]' />
|
||||
<Skeleton className='h-[16px] w-[95%] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
|
||||
<Skeleton className='h-[16px] w-[88%] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
|
||||
<Skeleton className='h-[16px] w-full rounded-[4px] bg-[var(--landing-bg-elevated)]' />
|
||||
<Skeleton className='mt-6 h-[24px] w-[200px] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
|
||||
<Skeleton className='h-[16px] w-full rounded-[4px] bg-[var(--landing-bg-elevated)]' />
|
||||
<Skeleton className='h-[16px] w-[92%] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
|
||||
<Skeleton className='h-[16px] w-[85%] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -60,12 +60,13 @@ export default async function Page({ params }: { params: Promise<{ slug: string
|
||||
sizes='(max-width: 768px) 100vw, 450px'
|
||||
priority
|
||||
itemProp='image'
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-1 flex-col justify-between'>
|
||||
<h1
|
||||
className='font-[500] text-[#ECECEC] text-[36px] leading-tight tracking-tight sm:text-[48px] md:text-[56px] lg:text-[64px]'
|
||||
className='text-balance font-[500] text-[36px] text-[var(--landing-text)] leading-tight tracking-tight sm:text-[48px] md:text-[56px] lg:text-[64px]'
|
||||
itemProp='headline'
|
||||
>
|
||||
{post.title}
|
||||
@@ -84,7 +85,7 @@ export default async function Page({ params }: { params: Promise<{ slug: string
|
||||
href={a?.url || '#'}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer author'
|
||||
className='text-[#999] text-[14px] leading-[1.5] hover:text-[#ECECEC] sm:text-[16px]'
|
||||
className='text-[var(--landing-text-muted)] text-sm leading-[1.5] hover:text-[var(--landing-text)] sm:text-md'
|
||||
itemProp='author'
|
||||
itemScope
|
||||
itemType='https://schema.org/Person'
|
||||
@@ -98,11 +99,11 @@ export default async function Page({ params }: { params: Promise<{ slug: string
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr className='mt-8 border-[#2A2A2A] border-t sm:mt-12' />
|
||||
<hr className='mt-8 border-[var(--landing-bg-elevated)] border-t sm:mt-12' />
|
||||
<div className='flex flex-col gap-6 py-8 sm:flex-row sm:items-start sm:justify-between sm:gap-8 sm:py-10'>
|
||||
<div className='flex flex-shrink-0 items-center gap-4'>
|
||||
<time
|
||||
className='block text-[#999] text-[14px] leading-[1.5] sm:text-[16px]'
|
||||
className='block text-[var(--landing-text-muted)] text-sm leading-[1.5] sm:text-md'
|
||||
dateTime={post.date}
|
||||
itemProp='datePublished'
|
||||
>
|
||||
@@ -115,7 +116,7 @@ export default async function Page({ params }: { params: Promise<{ slug: string
|
||||
<meta itemProp='dateModified' content={post.updated ?? post.date} />
|
||||
</div>
|
||||
<div className='flex-1'>
|
||||
<p className='m-0 block translate-y-[-4px] font-[400] text-[#999] text-[18px] leading-[1.5] sm:text-[20px] md:text-[26px]'>
|
||||
<p className='m-0 block translate-y-[-4px] font-[400] text-[var(--landing-text-muted)] text-lg leading-[1.5] sm:text-[20px] md:text-[26px]'>
|
||||
{post.description}
|
||||
</p>
|
||||
</div>
|
||||
@@ -123,18 +124,18 @@ export default async function Page({ params }: { params: Promise<{ slug: string
|
||||
</header>
|
||||
|
||||
<div className='mx-auto max-w-[900px] px-6 pb-20 sm:px-8 md:px-12' itemProp='articleBody'>
|
||||
<div className='prose prose-lg prose-invert max-w-none prose-blockquote:border-[#3d3d3d] prose-hr:border-[#2A2A2A] prose-a:text-[#ECECEC] prose-blockquote:text-[#999] prose-code:text-[#ECECEC] prose-headings:text-[#ECECEC] prose-li:text-[#999] prose-p:text-[#999] prose-strong:text-[#ECECEC]'>
|
||||
<div className='prose prose-lg prose-invert max-w-none prose-blockquote:border-[var(--landing-border-strong)] prose-hr:border-[var(--landing-bg-elevated)] prose-a:text-[var(--landing-text)] prose-blockquote:text-[var(--landing-text-muted)] prose-code:text-[var(--landing-text)] prose-headings:text-[var(--landing-text)] prose-li:text-[var(--landing-text-muted)] prose-p:text-[var(--landing-text-muted)] prose-strong:text-[var(--landing-text)]'>
|
||||
<Article />
|
||||
{post.faq && post.faq.length > 0 ? <FAQ items={post.faq} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
{related.length > 0 && (
|
||||
<div className='mx-auto max-w-[900px] px-6 pb-24 sm:px-8 md:px-12'>
|
||||
<h2 className='mb-4 font-[500] text-[#ECECEC] text-[24px]'>Related posts</h2>
|
||||
<h2 className='mb-4 font-[500] text-[24px] text-[var(--landing-text)]'>Related posts</h2>
|
||||
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2 sm:gap-6 lg:grid-cols-3'>
|
||||
{related.map((p) => (
|
||||
<Link key={p.slug} href={`/blog/${p.slug}`} className='group'>
|
||||
<div className='overflow-hidden rounded-lg border border-[#2A2A2A]'>
|
||||
<div className='overflow-hidden rounded-lg border border-[var(--landing-bg-elevated)]'>
|
||||
<Image
|
||||
src={p.ogImage}
|
||||
alt={p.title}
|
||||
@@ -143,16 +144,19 @@ export default async function Page({ params }: { params: Promise<{ slug: string
|
||||
className='h-[160px] w-full object-cover'
|
||||
sizes='(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw'
|
||||
loading='lazy'
|
||||
unoptimized
|
||||
/>
|
||||
<div className='p-3'>
|
||||
<div className='mb-1 text-[#999] text-xs'>
|
||||
<div className='mb-1 text-[var(--landing-text-muted)] text-xs'>
|
||||
{new Date(p.date).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</div>
|
||||
<div className='font-[500] text-[#ECECEC] text-sm leading-tight'>{p.title}</div>
|
||||
<div className='font-[500] text-[var(--landing-text)] text-sm leading-tight'>
|
||||
{p.title}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@@ -41,7 +41,7 @@ export function ShareButton({ url, title }: ShareButtonProps) {
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className='flex items-center gap-1.5 text-[#999] text-sm hover:text-[#ECECEC]'
|
||||
className='flex items-center gap-1.5 text-[var(--landing-text-muted)] text-sm hover:text-[var(--landing-text)]'
|
||||
aria-label='Share this post'
|
||||
>
|
||||
<Share2 className='h-4 w-4' />
|
||||
|
||||
@@ -6,16 +6,16 @@ export default function AuthorLoading() {
|
||||
return (
|
||||
<main className='mx-auto max-w-[900px] px-6 py-10 sm:px-8 md:px-12'>
|
||||
<div className='mb-6 flex items-center gap-3'>
|
||||
<Skeleton className='h-[40px] w-[40px] rounded-full bg-[#2A2A2A]' />
|
||||
<Skeleton className='h-[32px] w-[160px] rounded-[4px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='h-[40px] w-[40px] rounded-full bg-[var(--landing-bg-elevated)]' />
|
||||
<Skeleton className='h-[32px] w-[160px] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
|
||||
</div>
|
||||
<div className='grid grid-cols-1 gap-8 sm:grid-cols-2'>
|
||||
{Array.from({ length: SKELETON_POST_COUNT }).map((_, i) => (
|
||||
<div key={i} className='overflow-hidden rounded-lg border border-[#2A2A2A]'>
|
||||
<Skeleton className='h-[160px] w-full rounded-none bg-[#2A2A2A]' />
|
||||
<div key={i} className='overflow-hidden rounded-lg border border-[var(--landing-border)]'>
|
||||
<Skeleton className='h-[160px] w-full rounded-none bg-[var(--landing-bg-elevated)]' />
|
||||
<div className='p-3'>
|
||||
<Skeleton className='mb-1 h-[12px] w-[80px] rounded-[4px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='h-[14px] w-[200px] rounded-[4px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='mb-1 h-[12px] w-[80px] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
|
||||
<Skeleton className='h-[14px] w-[200px] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -23,7 +23,7 @@ export default async function AuthorPage({ params }: { params: Promise<{ id: str
|
||||
if (!author) {
|
||||
return (
|
||||
<main className='mx-auto max-w-[900px] px-6 py-10 sm:px-8 md:px-12'>
|
||||
<h1 className='font-[500] text-[#ECECEC] text-[32px]'>Author not found</h1>
|
||||
<h1 className='font-[500] text-[32px] text-[var(--landing-text)]'>Author not found</h1>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -52,28 +52,33 @@ export default async function AuthorPage({ params }: { params: Promise<{ id: str
|
||||
unoptimized
|
||||
/>
|
||||
) : null}
|
||||
<h1 className='font-[500] text-[#ECECEC] text-[32px] leading-tight'>{author.name}</h1>
|
||||
<h1 className='font-[500] text-[32px] text-[var(--landing-text)] leading-tight'>
|
||||
{author.name}
|
||||
</h1>
|
||||
</div>
|
||||
<div className='grid grid-cols-1 gap-8 sm:grid-cols-2'>
|
||||
{posts.map((p) => (
|
||||
<Link key={p.slug} href={`/blog/${p.slug}`} className='group'>
|
||||
<div className='overflow-hidden rounded-lg border border-[#2A2A2A]'>
|
||||
<div className='overflow-hidden rounded-lg border border-[var(--landing-bg-elevated)]'>
|
||||
<Image
|
||||
src={p.ogImage}
|
||||
alt={p.title}
|
||||
width={600}
|
||||
height={315}
|
||||
className='h-[160px] w-full object-cover transition-transform group-hover:scale-[1.02]'
|
||||
unoptimized
|
||||
/>
|
||||
<div className='p-3'>
|
||||
<div className='mb-1 text-[#999] text-xs'>
|
||||
<div className='mb-1 text-[var(--landing-text-muted)] text-xs'>
|
||||
{new Date(p.date).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</div>
|
||||
<div className='font-[500] text-[#ECECEC] text-sm leading-tight'>{p.title}</div>
|
||||
<div className='font-[500] text-[var(--landing-text)] text-sm leading-tight'>
|
||||
{p.title}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
43
apps/sim/app/(landing)/blog/components/blog-image.tsx
Normal file
43
apps/sim/app/(landing)/blog/components/blog-image.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import NextImage from 'next/image'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { Lightbox } from '@/app/(landing)/blog/components/lightbox'
|
||||
|
||||
interface BlogImageProps {
|
||||
src: string
|
||||
alt?: string
|
||||
width?: number
|
||||
height?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function BlogImage({ src, alt = '', width = 800, height = 450, className }: BlogImageProps) {
|
||||
const [isLightboxOpen, setIsLightboxOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<NextImage
|
||||
src={src}
|
||||
alt={alt}
|
||||
width={width}
|
||||
height={height}
|
||||
className={cn(
|
||||
'h-auto w-full cursor-pointer rounded-lg transition-opacity hover:opacity-95',
|
||||
className
|
||||
)}
|
||||
sizes='(max-width: 768px) 100vw, 800px'
|
||||
loading='lazy'
|
||||
unoptimized
|
||||
onClick={() => setIsLightboxOpen(true)}
|
||||
/>
|
||||
<Lightbox
|
||||
isOpen={isLightboxOpen}
|
||||
onClose={() => setIsLightboxOpen(false)}
|
||||
src={src}
|
||||
alt={alt}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
62
apps/sim/app/(landing)/blog/components/lightbox.tsx
Normal file
62
apps/sim/app/(landing)/blog/components/lightbox.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
interface LightboxProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
src: string
|
||||
alt: string
|
||||
}
|
||||
|
||||
export function Lightbox({ isOpen, onClose, src, alt }: LightboxProps) {
|
||||
const overlayRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (overlayRef.current && event.target === overlayRef.current) {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
document.body.style.overflow = 'hidden'
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
document.body.style.overflow = 'unset'
|
||||
}
|
||||
}, [isOpen, onClose])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
className='fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-12 backdrop-blur-sm'
|
||||
role='dialog'
|
||||
aria-modal='true'
|
||||
aria-label='Image viewer'
|
||||
>
|
||||
<div className='relative max-h-full max-w-full overflow-hidden rounded-xl shadow-2xl'>
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className='max-h-[75vh] max-w-[75vw] cursor-pointer rounded-xl object-contain'
|
||||
loading='lazy'
|
||||
onClick={onClose}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -26,7 +26,7 @@ export default async function StudioLayout({ children }: { children: React.React
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex min-h-screen flex-col bg-[#1C1C1C] font-[430] font-season text-[#ECECEC]'>
|
||||
<div className='flex min-h-screen flex-col bg-[var(--landing-bg)] font-[430] font-season text-[var(--landing-text)]'>
|
||||
<script
|
||||
type='application/ld+json'
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(orgJsonLd) }}
|
||||
|
||||
@@ -5,20 +5,23 @@ const SKELETON_CARD_COUNT = 6
|
||||
export default function BlogLoading() {
|
||||
return (
|
||||
<main className='mx-auto max-w-[1200px] px-6 py-12 sm:px-8 md:px-12'>
|
||||
<Skeleton className='h-[48px] w-[100px] rounded-[4px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='mt-3 h-[18px] w-[420px] rounded-[4px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='h-[48px] w-[100px] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
|
||||
<Skeleton className='mt-3 h-[18px] w-[420px] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
|
||||
<div className='mt-10 grid grid-cols-1 gap-4 md:grid-cols-2 md:gap-6 lg:grid-cols-3'>
|
||||
{Array.from({ length: SKELETON_CARD_COUNT }).map((_, i) => (
|
||||
<div key={i} className='flex flex-col overflow-hidden rounded-xl border border-[#2A2A2A]'>
|
||||
<Skeleton className='aspect-video w-full rounded-none bg-[#2A2A2A]' />
|
||||
<div
|
||||
key={i}
|
||||
className='flex flex-col overflow-hidden rounded-xl border border-[var(--landing-border)]'
|
||||
>
|
||||
<Skeleton className='aspect-video w-full rounded-none bg-[var(--landing-bg-elevated)]' />
|
||||
<div className='flex flex-1 flex-col p-4'>
|
||||
<Skeleton className='mb-2 h-[12px] w-[80px] rounded-[4px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='mb-1 h-[20px] w-[85%] rounded-[4px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='mb-3 h-[14px] w-full rounded-[4px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='h-[14px] w-[70%] rounded-[4px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='mb-2 h-[12px] w-[80px] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
|
||||
<Skeleton className='mb-1 h-[20px] w-[85%] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
|
||||
<Skeleton className='mb-3 h-[14px] w-full rounded-[4px] bg-[var(--landing-bg-elevated)]' />
|
||||
<Skeleton className='h-[14px] w-[70%] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
|
||||
<div className='mt-3 flex items-center gap-2'>
|
||||
<Skeleton className='h-[16px] w-[16px] rounded-full bg-[#2A2A2A]' />
|
||||
<Skeleton className='h-[12px] w-[80px] rounded-[4px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='h-[16px] w-[16px] rounded-full bg-[var(--landing-bg-elevated)]' />
|
||||
<Skeleton className='h-[12px] w-[80px] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -50,10 +50,10 @@ export default async function BlogIndex({
|
||||
type='application/ld+json'
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(blogJsonLd) }}
|
||||
/>
|
||||
<h1 className='mb-3 font-[500] text-[#ECECEC] text-[40px] leading-tight sm:text-[56px]'>
|
||||
<h1 className='mb-3 text-balance font-[500] text-[40px] text-[var(--landing-text)] leading-tight sm:text-[56px]'>
|
||||
Blog
|
||||
</h1>
|
||||
<p className='mb-10 text-[#999] text-[18px]'>
|
||||
<p className='mb-10 text-[var(--landing-text-muted)] text-lg'>
|
||||
Announcements, insights, and guides for building AI agent workflows.
|
||||
</p>
|
||||
|
||||
@@ -75,18 +75,18 @@ export default async function BlogIndex({
|
||||
{pageNum > 1 && (
|
||||
<Link
|
||||
href={`/blog?page=${pageNum - 1}${tag ? `&tag=${encodeURIComponent(tag)}` : ''}`}
|
||||
className='rounded-[5px] border border-[#3d3d3d] px-3 py-1 text-[#ECECEC] text-sm transition-colors hover:bg-[#2A2A2A]'
|
||||
className='rounded-[5px] border border-[var(--landing-border-strong)] px-3 py-1 text-[var(--landing-text)] text-sm transition-colors hover:bg-[var(--landing-bg-elevated)]'
|
||||
>
|
||||
Previous
|
||||
</Link>
|
||||
)}
|
||||
<span className='text-[#999] text-sm'>
|
||||
<span className='text-[var(--landing-text-muted)] text-sm'>
|
||||
Page {pageNum} of {totalPages}
|
||||
</span>
|
||||
{pageNum < totalPages && (
|
||||
<Link
|
||||
href={`/blog?page=${pageNum + 1}${tag ? `&tag=${encodeURIComponent(tag)}` : ''}`}
|
||||
className='rounded-[5px] border border-[#3d3d3d] px-3 py-1 text-[#ECECEC] text-sm transition-colors hover:bg-[#2A2A2A]'
|
||||
className='rounded-[5px] border border-[var(--landing-border-strong)] px-3 py-1 text-[var(--landing-text)] text-sm transition-colors hover:bg-[var(--landing-bg-elevated)]'
|
||||
>
|
||||
Next
|
||||
</Link>
|
||||
|
||||
@@ -25,13 +25,14 @@ export function PostGrid({ posts }: { posts: Post[] }) {
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 md:gap-6 lg:grid-cols-3'>
|
||||
{posts.map((p, index) => (
|
||||
<Link key={p.slug} href={`/blog/${p.slug}`} className='group flex flex-col'>
|
||||
<div className='flex h-full flex-col overflow-hidden rounded-xl border border-[#2A2A2A] transition-colors duration-300 hover:border-[#3d3d3d]'>
|
||||
<div className='flex h-full flex-col overflow-hidden rounded-xl border border-[var(--landing-bg-elevated)] transition-colors duration-300 hover:border-[var(--landing-border-strong)]'>
|
||||
{/* Image container with fixed aspect ratio to prevent layout shift */}
|
||||
<div className='relative aspect-video w-full overflow-hidden'>
|
||||
<Image
|
||||
src={p.ogImage}
|
||||
alt={p.title}
|
||||
sizes='(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw'
|
||||
unoptimized
|
||||
priority={index < 6}
|
||||
loading={index < 6 ? undefined : 'lazy'}
|
||||
fill
|
||||
@@ -39,29 +40,33 @@ export function PostGrid({ posts }: { posts: Post[] }) {
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-1 flex-col p-4'>
|
||||
<div className='mb-2 text-[#999] text-xs'>
|
||||
<div className='mb-2 text-[var(--landing-text-muted)] text-xs'>
|
||||
{new Date(p.date).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</div>
|
||||
<h3 className='mb-1 font-[500] text-[#ECECEC] text-lg leading-tight'>{p.title}</h3>
|
||||
<p className='mb-3 line-clamp-3 flex-1 text-[#999] text-sm'>{p.description}</p>
|
||||
<h3 className='mb-1 font-[500] text-[var(--landing-text)] text-lg leading-tight'>
|
||||
{p.title}
|
||||
</h3>
|
||||
<p className='mb-3 line-clamp-3 flex-1 text-[var(--landing-text-muted)] text-sm'>
|
||||
{p.description}
|
||||
</p>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='-space-x-1.5 flex'>
|
||||
{(p.authors && p.authors.length > 0 ? p.authors : [p.author])
|
||||
.slice(0, 3)
|
||||
.map((author, idx) => (
|
||||
<Avatar key={idx} className='size-4 border border-[#1C1C1C]'>
|
||||
<Avatar key={idx} className='size-4 border border-[var(--landing-text)]'>
|
||||
<AvatarImage src={author?.avatarUrl} alt={author?.name} />
|
||||
<AvatarFallback className='border border-[#1C1C1C] bg-[#2A2A2A] text-[#999] text-[10px]'>
|
||||
<AvatarFallback className='border border-[var(--landing-text)] bg-[var(--landing-bg-elevated)] text-[var(--landing-text-muted)] text-micro'>
|
||||
{author?.name.slice(0, 2)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
))}
|
||||
</div>
|
||||
<span className='text-[#999] text-xs'>
|
||||
<span className='text-[var(--landing-text-muted)] text-xs'>
|
||||
{(p.authors && p.authors.length > 0 ? p.authors : [p.author])
|
||||
.slice(0, 2)
|
||||
.map((a) => a?.name)
|
||||
|
||||
@@ -5,12 +5,12 @@ const SKELETON_TAG_COUNT = 12
|
||||
export default function TagsLoading() {
|
||||
return (
|
||||
<main className='mx-auto max-w-[900px] px-6 py-10 sm:px-8 md:px-12'>
|
||||
<Skeleton className='mb-6 h-[32px] w-[200px] rounded-[4px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='mb-6 h-[32px] w-[200px] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
|
||||
<div className='flex flex-wrap gap-3'>
|
||||
{Array.from({ length: SKELETON_TAG_COUNT }).map((_, i) => (
|
||||
<Skeleton
|
||||
key={i}
|
||||
className='h-[30px] rounded-full bg-[#2A2A2A]'
|
||||
className='h-[30px] rounded-full bg-[var(--landing-bg-elevated)]'
|
||||
style={{ width: `${60 + (i % 4) * 24}px` }}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -10,11 +10,13 @@ export default async function TagsIndex() {
|
||||
const tags = await getAllTags()
|
||||
return (
|
||||
<main className='mx-auto max-w-[900px] px-6 py-10 sm:px-8 md:px-12'>
|
||||
<h1 className='mb-6 font-[500] text-[#ECECEC] text-[32px] leading-tight'>Browse by tag</h1>
|
||||
<h1 className='mb-6 font-[500] text-[32px] text-[var(--landing-text)] leading-tight'>
|
||||
Browse by tag
|
||||
</h1>
|
||||
<div className='flex flex-wrap gap-3'>
|
||||
<Link
|
||||
href='/blog'
|
||||
className='rounded-full border border-[#3d3d3d] px-3 py-1 text-[#ECECEC] text-sm transition-colors hover:bg-[#2A2A2A]'
|
||||
className='rounded-full border border-[var(--landing-border-strong)] px-3 py-1 text-[var(--landing-text)] text-sm transition-colors hover:bg-[var(--landing-bg-elevated)]'
|
||||
>
|
||||
All
|
||||
</Link>
|
||||
@@ -22,7 +24,7 @@ export default async function TagsIndex() {
|
||||
<Link
|
||||
key={t.tag}
|
||||
href={`/blog?tag=${encodeURIComponent(t.tag)}`}
|
||||
className='rounded-full border border-[#3d3d3d] px-3 py-1 text-[#ECECEC] text-sm transition-colors hover:bg-[#2A2A2A]'
|
||||
className='rounded-full border border-[var(--landing-border-strong)] px-3 py-1 text-[var(--landing-text)] text-sm transition-colors hover:bg-[var(--landing-bg-elevated)]'
|
||||
>
|
||||
{t.tag} ({t.count})
|
||||
</Link>
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
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='4970'
|
||||
viewBox='0 18 1308 4094'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
preserveAspectRatio='xMidYMin slice'
|
||||
>
|
||||
{/* Pricing section (extended by ~28 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.42V1641.91' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<path d='M1297.76 1245.96V1641.91' stroke='#E7E4EF' strokeWidth='2' />
|
||||
|
||||
{/* Integrations section (shifted down by 28 units) */}
|
||||
<path d='M6.71704 1642.89H1291.05' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<circle cx='11.0557' cy='1643.15' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<circle cx='1298.02' cy='1643.15' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<path d='M10.7967 1652.61V2054.93' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<path d='M1297.76 1652.61V2054.93' stroke='#E7E4EF' strokeWidth='2' />
|
||||
|
||||
{/* Testimonials section (shifted down by 28 units) */}
|
||||
<path d='M6.71704 2054.71H1300.76' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<circle cx='11.0557' cy='2054.97' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<circle cx='1298.02' cy='2054.97' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<path d='M10.7967 2064.43V2205.43' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<path d='M1297.76 2064.43V2205.43' stroke='#E7E4EF' strokeWidth='2' />
|
||||
|
||||
{/* Footer section line (shifted down by 28 units) */}
|
||||
<path d='M6.71704 2205.71H1300.76' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<circle cx='11.0557' cy='2205.97' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<circle cx='1298.02' cy='2205.97' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<path d='M10.7967 2215.43V4118.25' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<path d='M1297.76 2215.43V4118.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>
|
||||
)
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import dynamic from 'next/dynamic'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
|
||||
// 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)}>
|
||||
<div className='-z-50 pointer-events-none fixed inset-0 bg-white' />
|
||||
<BackgroundSVG />
|
||||
<div className='relative z-0 mx-auto w-full max-w-[1308px]'>{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { HIPAABadgeIcon } from '@/components/icons'
|
||||
|
||||
export default function ComplianceBadges() {
|
||||
return (
|
||||
<div className='mt-[6px] flex items-center gap-[12px]'>
|
||||
{/* SOC2 badge */}
|
||||
<Link
|
||||
href='https://app.vanta.com/sim.ai/trust/v35ia0jil4l7dteqjgaktn'
|
||||
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 */}
|
||||
<Link
|
||||
href='https://app.vanta.com/sim.ai/trust/v35ia0jil4l7dteqjgaktn'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
<HIPAABadgeIcon className='h-[54px] w-[54px]' />
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import ComplianceBadges from './compliance-badges'
|
||||
import Logo from './logo'
|
||||
import SocialLinks from './social-links'
|
||||
import StatusIndicator from './status-indicator'
|
||||
|
||||
export { ComplianceBadges, Logo, SocialLinks, StatusIndicator }
|
||||
@@ -1,17 +0,0 @@
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
|
||||
export default function Logo() {
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { DiscordIcon, GithubIcon, LinkedInIcon, xIcon as XIcon } from '@/components/icons'
|
||||
|
||||
export default function SocialLinks() {
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { SVGProps } from 'react'
|
||||
import Link from 'next/link'
|
||||
import type { StatusType } from '@/app/api/status/types'
|
||||
import { useStatus } from '@/hooks/queries/status'
|
||||
|
||||
interface StatusDotIconProps extends SVGProps<SVGSVGElement> {
|
||||
status: 'operational' | 'degraded' | 'outage' | 'maintenance' | 'loading' | 'error'
|
||||
}
|
||||
|
||||
export function StatusDotIcon({ status, className, ...props }: StatusDotIconProps) {
|
||||
const colors = {
|
||||
operational: '#10B981',
|
||||
degraded: '#F59E0B',
|
||||
outage: '#EF4444',
|
||||
maintenance: '#3B82F6',
|
||||
loading: '#9CA3AF',
|
||||
error: '#9CA3AF',
|
||||
}
|
||||
|
||||
return (
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width={6}
|
||||
height={6}
|
||||
viewBox='0 0 6 6'
|
||||
fill='none'
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
<circle cx={3} cy={3} r={3} fill={colors[status]} />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<StatusType, string> = {
|
||||
operational: 'text-[#10B981] hover:text-[#059669]',
|
||||
degraded: 'text-[#F59E0B] hover:text-[#D97706]',
|
||||
outage: 'text-[#EF4444] hover:text-[#DC2626]',
|
||||
maintenance: 'text-[#3B82F6] hover:text-[#2563EB]',
|
||||
loading: 'text-muted-foreground hover:text-foreground',
|
||||
error: 'text-muted-foreground hover:text-foreground',
|
||||
}
|
||||
|
||||
export default function StatusIndicator() {
|
||||
const { data, isLoading, isError } = useStatus()
|
||||
|
||||
const status = isLoading ? 'loading' : isError ? 'error' : data?.status || 'error'
|
||||
const message = isLoading
|
||||
? 'Checking Status...'
|
||||
: isError
|
||||
? 'Status Unknown'
|
||||
: data?.message || 'Status Unknown'
|
||||
const statusUrl = data?.url || 'https://status.sim.ai'
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={statusUrl}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className={`flex min-w-[165px] items-center gap-[6px] whitespace-nowrap text-[12px] transition-colors ${STATUS_COLORS[status]}`}
|
||||
aria-label={`System status: ${message}`}
|
||||
>
|
||||
<StatusDotIcon status={status} className='h-[6px] w-[6px]' aria-hidden='true' />
|
||||
<span>{message}</span>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
export const FOOTER_BLOCKS = [
|
||||
'Agent',
|
||||
'API',
|
||||
'Condition',
|
||||
'Evaluator',
|
||||
'Function',
|
||||
'Guardrails',
|
||||
'Human In The Loop',
|
||||
'Loop',
|
||||
'Parallel',
|
||||
'Response',
|
||||
'Router',
|
||||
'Starter',
|
||||
'Webhook',
|
||||
'Workflow',
|
||||
]
|
||||
|
||||
export const FOOTER_TOOLS = [
|
||||
'Airtable',
|
||||
'Apify',
|
||||
'Apollo',
|
||||
'ArXiv',
|
||||
'Browser Use',
|
||||
'Calendly',
|
||||
'Clay',
|
||||
'Confluence',
|
||||
'Discord',
|
||||
'ElevenLabs',
|
||||
'Exa',
|
||||
'Firecrawl',
|
||||
'GitHub',
|
||||
'Gmail',
|
||||
'Google Drive',
|
||||
'HubSpot',
|
||||
'HuggingFace',
|
||||
'Hunter',
|
||||
'Incidentio',
|
||||
'Intercom',
|
||||
'Jina',
|
||||
'Jira',
|
||||
'Knowledge',
|
||||
'Linear',
|
||||
'LinkUp',
|
||||
'LinkedIn',
|
||||
'Mailchimp',
|
||||
'Mailgun',
|
||||
'MCP',
|
||||
'Mem0',
|
||||
'Microsoft Excel',
|
||||
'Microsoft Planner',
|
||||
'Microsoft Teams',
|
||||
'Mistral Parse',
|
||||
'MongoDB',
|
||||
'MySQL',
|
||||
'Neo4j',
|
||||
'Notion',
|
||||
'OneDrive',
|
||||
'OpenAI',
|
||||
'Outlook',
|
||||
'Parallel AI',
|
||||
'Perplexity',
|
||||
'Pinecone',
|
||||
'Pipedrive',
|
||||
'PostHog',
|
||||
'PostgreSQL',
|
||||
'Qdrant',
|
||||
'Reddit',
|
||||
'Resend',
|
||||
'S3',
|
||||
'Salesforce',
|
||||
'SendGrid',
|
||||
'Serper',
|
||||
'ServiceNow',
|
||||
'SharePoint',
|
||||
'Slack',
|
||||
'Smtp',
|
||||
'Stagehand',
|
||||
'Stripe',
|
||||
'Supabase',
|
||||
'Tavily',
|
||||
'Telegram',
|
||||
'Translate',
|
||||
'Trello',
|
||||
'Twilio',
|
||||
'Typeform',
|
||||
'Vision',
|
||||
'Wait',
|
||||
'Wealthbox',
|
||||
'Webflow',
|
||||
'WhatsApp',
|
||||
'Wikipedia',
|
||||
'X',
|
||||
'YouTube',
|
||||
'Zendesk',
|
||||
'Zep',
|
||||
]
|
||||
@@ -1,280 +0,0 @@
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
ComplianceBadges,
|
||||
Logo,
|
||||
SocialLinks,
|
||||
StatusIndicator,
|
||||
} from '@/app/(landing)/components/footer/components'
|
||||
import { FOOTER_BLOCKS, FOOTER_TOOLS } from '@/app/(landing)/components/footer/consts'
|
||||
|
||||
interface FooterProps {
|
||||
fullWidth?: boolean
|
||||
}
|
||||
|
||||
export default function Footer({ fullWidth = false }: FooterProps) {
|
||||
return (
|
||||
<footer 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]'>
|
||||
<Logo />
|
||||
<SocialLinks />
|
||||
<ComplianceBadges />
|
||||
<StatusIndicator />
|
||||
</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='/blog'
|
||||
className='text-[14px] text-muted-foreground transition-colors hover:text-foreground'
|
||||
>
|
||||
Blog
|
||||
</Link>
|
||||
<Link
|
||||
href='/changelog'
|
||||
className='text-[14px] text-muted-foreground transition-colors hover:text-foreground'
|
||||
>
|
||||
Changelog
|
||||
</Link>
|
||||
<Link
|
||||
href='https://status.sim.ai'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-[14px] text-muted-foreground transition-colors hover:text-foreground'
|
||||
>
|
||||
Status
|
||||
</Link>
|
||||
<a
|
||||
href='https://jobs.ashbyhq.com/sim'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-[14px] text-muted-foreground transition-colors hover:text-foreground'
|
||||
>
|
||||
Careers
|
||||
</a>
|
||||
<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]'>
|
||||
{FOOTER_BLOCKS.map((block) => (
|
||||
<Link
|
||||
key={block}
|
||||
href={`https://docs.sim.ai/blocks/${block.toLowerCase().replaceAll(' ', '-')}`}
|
||||
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]'>
|
||||
{FOOTER_TOOLS.slice(0, Math.ceil(FOOTER_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]'>
|
||||
{FOOTER_TOOLS.slice(
|
||||
Math.ceil(FOOTER_TOOLS.length / 4),
|
||||
Math.ceil((FOOTER_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]'>
|
||||
{FOOTER_TOOLS.slice(
|
||||
Math.ceil((FOOTER_TOOLS.length * 2) / 4),
|
||||
Math.ceil((FOOTER_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]'>
|
||||
{FOOTER_TOOLS.slice(Math.ceil((FOOTER_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,38 +0,0 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
export { IconButton } from './icon-button'
|
||||
export { DotPattern } from './landing-canvas/dot-pattern'
|
||||
export type {
|
||||
LandingBlockProps,
|
||||
LandingCardData,
|
||||
} from './landing-canvas/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 { SubBlockRowProps, TagProps } from './landing-canvas/landing-block/tag'
|
||||
export { SubBlockRow, Tag } from './landing-canvas/landing-block/tag'
|
||||
export type {
|
||||
LandingBlockNode,
|
||||
LandingCanvasProps,
|
||||
LandingEdgeData,
|
||||
LandingGroupData,
|
||||
LandingManualBlock,
|
||||
LandingViewportApi,
|
||||
} from './landing-canvas/landing-canvas'
|
||||
export { CARD_HEIGHT, CARD_WIDTH, LandingCanvas } from './landing-canvas/landing-canvas'
|
||||
export { LandingEdge } from './landing-canvas/landing-edge/landing-edge'
|
||||
export type { LandingFlowProps } from './landing-canvas/landing-flow'
|
||||
export { LandingFlow } from './landing-canvas/landing-flow'
|
||||
@@ -1,133 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type React from 'react'
|
||||
import { useEffect, useId, useRef, useState } from 'react'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
|
||||
/**
|
||||
* DotPattern Component Props
|
||||
*
|
||||
* @param {number} [width=16] - The horizontal spacing between dots
|
||||
* @param {number} [height=16] - The vertical spacing between dots
|
||||
* @param {number} [x=0] - The x-offset of the entire pattern
|
||||
* @param {number} [y=0] - The y-offset of the entire pattern
|
||||
* @param {number} [cx=1] - The x-offset of individual dots
|
||||
* @param {number} [cy=1] - The y-offset of individual dots
|
||||
* @param {number} [cr=1] - The radius of each dot
|
||||
* @param {string} [className] - Additional CSS classes to apply to the SVG container
|
||||
* @param {boolean} [glow=false] - Whether dots should have a glowing animation effect
|
||||
*/
|
||||
interface DotPatternProps extends React.SVGProps<SVGSVGElement> {
|
||||
width?: number
|
||||
height?: number
|
||||
x?: number
|
||||
y?: number
|
||||
cx?: number
|
||||
cy?: number
|
||||
cr?: number
|
||||
className?: string
|
||||
glow?: boolean
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* DotPattern Component
|
||||
*
|
||||
* A React component that creates an animated or static dot pattern background using SVG.
|
||||
* The pattern automatically adjusts to fill its container and can optionally display glowing dots.
|
||||
*
|
||||
* @component
|
||||
*
|
||||
* @see DotPatternProps for the props interface.
|
||||
*
|
||||
* @example
|
||||
* // Basic usage
|
||||
* <DotPattern />
|
||||
*
|
||||
* // With glowing effect and custom spacing
|
||||
* <DotPattern
|
||||
* width={20}
|
||||
* height={20}
|
||||
* glow={true}
|
||||
* className="opacity-50"
|
||||
* />
|
||||
*
|
||||
* @notes
|
||||
* - The component is client-side only ("use client")
|
||||
* - Automatically responds to container size changes
|
||||
* - When glow is enabled, dots will animate with random delays and durations
|
||||
* - Uses Motion for animations
|
||||
* - Dots color can be controlled via the text color utility classes
|
||||
*/
|
||||
|
||||
export function DotPattern({
|
||||
width = 16,
|
||||
height = 16,
|
||||
x = 0,
|
||||
y = 0,
|
||||
cx = 1,
|
||||
cy = 1,
|
||||
cr = 1,
|
||||
className,
|
||||
glow = false,
|
||||
...props
|
||||
}: DotPatternProps) {
|
||||
const id = useId()
|
||||
const containerRef = useRef<SVGSVGElement>(null)
|
||||
const [dimensions, setDimensions] = useState({ width: 0, height: 0 })
|
||||
|
||||
useEffect(() => {
|
||||
const updateDimensions = () => {
|
||||
if (containerRef.current) {
|
||||
const { width, height } = containerRef.current.getBoundingClientRect()
|
||||
setDimensions({ width, height })
|
||||
}
|
||||
}
|
||||
|
||||
updateDimensions()
|
||||
window.addEventListener('resize', updateDimensions)
|
||||
return () => window.removeEventListener('resize', updateDimensions)
|
||||
}, [])
|
||||
|
||||
const dots = Array.from(
|
||||
{
|
||||
length: Math.ceil(dimensions.width / width) * Math.ceil(dimensions.height / height),
|
||||
},
|
||||
(_, i) => {
|
||||
const col = i % Math.ceil(dimensions.width / width)
|
||||
const row = Math.floor(i / Math.ceil(dimensions.width / width))
|
||||
return {
|
||||
x: col * width + cx,
|
||||
y: row * height + cy,
|
||||
delay: Math.random() * 5,
|
||||
duration: Math.random() * 3 + 2,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return (
|
||||
<svg
|
||||
ref={containerRef}
|
||||
aria-hidden='true'
|
||||
className={cn('pointer-events-none absolute inset-0 h-full w-full', className)}
|
||||
{...props}
|
||||
>
|
||||
<defs>
|
||||
<radialGradient id={`${id}-gradient`}>
|
||||
<stop offset='0%' stopColor='currentColor' stopOpacity='1' />
|
||||
<stop offset='100%' stopColor='currentColor' stopOpacity='0' />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
{dots.map((dot, index) => (
|
||||
<circle
|
||||
key={`${dot.x}-${dot.y}`}
|
||||
cx={dot.x}
|
||||
cy={dot.y}
|
||||
r={cr}
|
||||
fill={glow ? `url(#${id}-gradient)` : 'currentColor'}
|
||||
className='text-neutral-400/80'
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
SubBlockRow,
|
||||
type SubBlockRowProps,
|
||||
} from '@/app/(landing)/components/hero/components/landing-canvas/landing-block/tag'
|
||||
|
||||
/**
|
||||
* Data structure for a landing card component
|
||||
* Matches the workflow block structure from the application
|
||||
*/
|
||||
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 subblock rows to display below the header */
|
||||
tags?: SubBlockRowProps[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 subblock rows
|
||||
* Styled to match the application's workflow blocks
|
||||
* @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) {
|
||||
const hasContentBelowHeader = tags && tags.length > 0
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`z-10 flex w-[250px] flex-col rounded-[8px] border border-[#E5E5E5] bg-white ${className ?? ''}`}
|
||||
>
|
||||
{/* Header - matches workflow-block.tsx header styling */}
|
||||
<div
|
||||
className={`flex items-center justify-between p-[8px] ${hasContentBelowHeader ? 'border-[#E5E5E5] border-b' : ''}`}
|
||||
>
|
||||
<div className='flex min-w-0 flex-1 items-center gap-[10px]'>
|
||||
<div
|
||||
className='flex h-[24px] w-[24px] flex-shrink-0 items-center justify-center rounded-[6px]'
|
||||
style={{ background: color as string }}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
<span className='truncate font-medium text-[#171717] text-[16px]' title={name}>
|
||||
{name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content - SubBlock Rows matching workflow-block.tsx */}
|
||||
{hasContentBelowHeader && (
|
||||
<div className='flex flex-col gap-[8px] p-[8px]'>
|
||||
{tags.map((tag) => (
|
||||
<SubBlockRow key={tag.label} icon={tag.icon} label={tag.label} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -1,49 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { LoopBlock } from '@/app/(landing)/components/hero/components/landing-canvas/landing-block/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>
|
||||
)
|
||||
})
|
||||
@@ -1,95 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { Handle, Position } from 'reactflow'
|
||||
import {
|
||||
LandingBlock,
|
||||
type LandingCardData,
|
||||
} from '@/app/(landing)/components/hero/components/landing-canvas/landing-block/landing-block'
|
||||
|
||||
/**
|
||||
* Handle Y offset from block top - matches HANDLE_POSITIONS.DEFAULT_Y_OFFSET
|
||||
*/
|
||||
const HANDLE_Y_OFFSET = 20
|
||||
|
||||
/**
|
||||
* React Flow node component for the landing canvas
|
||||
* Styled to match the application's workflow blocks
|
||||
* @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: '7px',
|
||||
height: '20px',
|
||||
background: '#D1D1D1',
|
||||
border: 'none',
|
||||
borderRadius: '2px 0 0 2px',
|
||||
top: `${HANDLE_Y_OFFSET}px`,
|
||||
left: '-7px',
|
||||
transform: 'translateY(-50%)',
|
||||
zIndex: 10,
|
||||
}}
|
||||
isConnectable={false}
|
||||
/>
|
||||
)}
|
||||
{!hideSourceHandle && (
|
||||
<Handle
|
||||
type='source'
|
||||
position={Position.Right}
|
||||
style={{
|
||||
width: '7px',
|
||||
height: '20px',
|
||||
background: '#D1D1D1',
|
||||
border: 'none',
|
||||
borderRadius: '0 2px 2px 0',
|
||||
top: `${HANDLE_Y_OFFSET}px`,
|
||||
right: '-7px',
|
||||
transform: 'translateY(-50%)',
|
||||
zIndex: 10,
|
||||
}}
|
||||
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: isAnimated ? 'auto' : 'transform, opacity',
|
||||
}}
|
||||
>
|
||||
<LandingBlock icon={data.icon} color={data.color} name={data.name} tags={data.tags} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -1,66 +0,0 @@
|
||||
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
|
||||
* Styled to match the application's subflow containers
|
||||
* @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: '8px',
|
||||
background: 'rgba(59, 130, 246, 0.08)',
|
||||
position: 'relative',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{/* Custom dashed border with SVG - 8px border radius to match blocks */}
|
||||
<svg
|
||||
className='pointer-events-none absolute inset-0 h-full w-full'
|
||||
style={{ borderRadius: '8px' }}
|
||||
preserveAspectRatio='none'
|
||||
>
|
||||
<path
|
||||
className='landing-loop-animated-dash'
|
||||
d='M 1190 527.5
|
||||
L 8 527.5
|
||||
A 7.5 7.5 0 0 1 0.5 520
|
||||
L 0.5 8
|
||||
A 7.5 7.5 0 0 1 8 0.5
|
||||
L 1190 0.5
|
||||
A 7.5 7.5 0 0 1 1197.5 8
|
||||
L 1197.5 520
|
||||
A 7.5 7.5 0 0 1 1190 527.5 Z'
|
||||
fill='none'
|
||||
stroke='#3B82F6'
|
||||
strokeWidth='1'
|
||||
strokeDasharray='8 8'
|
||||
strokeLinecap='round'
|
||||
/>
|
||||
</svg>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -1,52 +0,0 @@
|
||||
import React from 'react'
|
||||
|
||||
/**
|
||||
* Properties for a subblock row component
|
||||
* Matches the SubBlockRow pattern from workflow-block.tsx
|
||||
*/
|
||||
export interface SubBlockRowProps {
|
||||
/** Icon element to display (optional, for visual context) */
|
||||
icon?: React.ReactNode
|
||||
/** Text label for the row title */
|
||||
label: string
|
||||
/** Optional value to display on the right side */
|
||||
value?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Kept for backwards compatibility
|
||||
*/
|
||||
export type TagProps = SubBlockRowProps
|
||||
|
||||
/**
|
||||
* SubBlockRow component matching the workflow block's subblock row style
|
||||
* @param props - Row properties including label and optional value
|
||||
* @returns A styled row component
|
||||
*/
|
||||
export const SubBlockRow = React.memo(function SubBlockRow({ label, value }: SubBlockRowProps) {
|
||||
// Split label by colon to separate title and value if present
|
||||
const [title, displayValue] = label.includes(':')
|
||||
? label.split(':').map((s) => s.trim())
|
||||
: [label, value]
|
||||
|
||||
return (
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<span className='min-w-0 truncate text-[#888888] text-[14px] capitalize' title={title}>
|
||||
{title}
|
||||
</span>
|
||||
{displayValue && (
|
||||
<span
|
||||
className='flex-1 truncate text-right text-[#171717] text-[14px]'
|
||||
title={displayValue}
|
||||
>
|
||||
{displayValue}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* Tag component - alias for SubBlockRow for backwards compatibility
|
||||
*/
|
||||
export const Tag = SubBlockRow
|
||||
@@ -1,153 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import type { Edge, Node } from 'reactflow'
|
||||
import { ReactFlowProvider } from 'reactflow'
|
||||
import { DotPattern } from '@/app/(landing)/components/hero/components/landing-canvas/dot-pattern'
|
||||
import type { LandingCardData } from '@/app/(landing)/components/hero/components/landing-canvas/landing-block/landing-block'
|
||||
import { LandingFlow } from '@/app/(landing)/components/hero/components/landing-canvas/landing-flow'
|
||||
|
||||
/**
|
||||
* Visual constants for landing node dimensions
|
||||
* Matches BLOCK_DIMENSIONS from the application
|
||||
*/
|
||||
export const CARD_WIDTH = 250
|
||||
export const CARD_HEIGHT = 100
|
||||
|
||||
/**
|
||||
* 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>
|
||||
)
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { type EdgeProps, getSmoothStepPath, Position } from 'reactflow'
|
||||
|
||||
/**
|
||||
* Custom edge component with animated dashed line
|
||||
* Styled to match the application's workflow edges with rectangular handles
|
||||
* @param props - React Flow edge properties
|
||||
* @returns An animated dashed edge component
|
||||
*/
|
||||
export const LandingEdge = React.memo(function LandingEdge(props: EdgeProps) {
|
||||
const { id, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, style } = props
|
||||
|
||||
// Adjust the connection points to connect flush with rectangular handles
|
||||
// Handle width is 7px, positioned at -7px from edge
|
||||
let adjustedSourceX = sourceX
|
||||
let adjustedTargetX = targetX
|
||||
|
||||
if (sourcePosition === Position.Right) {
|
||||
adjustedSourceX = sourceX + 1
|
||||
} else if (sourcePosition === Position.Left) {
|
||||
adjustedSourceX = sourceX - 1
|
||||
}
|
||||
|
||||
if (targetPosition === Position.Left) {
|
||||
adjustedTargetX = targetX - 1
|
||||
} else if (targetPosition === Position.Right) {
|
||||
adjustedTargetX = targetX + 1
|
||||
}
|
||||
|
||||
const [path] = getSmoothStepPath({
|
||||
sourceX: adjustedSourceX,
|
||||
sourceY,
|
||||
targetX: adjustedTargetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
borderRadius: 8,
|
||||
offset: 16,
|
||||
})
|
||||
|
||||
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`,
|
||||
...style,
|
||||
}}
|
||||
/>
|
||||
</g>
|
||||
)
|
||||
})
|
||||
@@ -1,151 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import ReactFlow, { applyNodeChanges, type NodeChange, useReactFlow } from 'reactflow'
|
||||
import 'reactflow/dist/style.css'
|
||||
import { LandingLoopNode } from '@/app/(landing)/components/hero/components/landing-canvas/landing-block/landing-loop-node'
|
||||
import { LandingNode } from '@/app/(landing)/components/hero/components/landing-canvas/landing-block/landing-node'
|
||||
import {
|
||||
CARD_WIDTH,
|
||||
type LandingCanvasProps,
|
||||
} from '@/app/(landing)/components/hero/components/landing-canvas/landing-canvas'
|
||||
import { LandingEdge } from '@/app/(landing)/components/hero/components/landing-canvas/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>
|
||||
)
|
||||
}
|
||||
@@ -1,467 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { ArrowUp, CodeIcon } 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,
|
||||
OutlookIcon,
|
||||
PackageSearchIcon,
|
||||
PineconeIcon,
|
||||
ScheduleIcon,
|
||||
SlackIcon,
|
||||
StripeIcon,
|
||||
SupabaseIcon,
|
||||
} from '@/components/icons'
|
||||
import { LandingPromptStorage } from '@/lib/core/utils/browser-storage'
|
||||
import {
|
||||
CARD_WIDTH,
|
||||
IconButton,
|
||||
LandingCanvas,
|
||||
type LandingGroupData,
|
||||
type LandingManualBlock,
|
||||
type LandingViewportApi,
|
||||
} from '@/app/(landing)/components/hero/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
|
||||
* Styled to match the application's workflow blocks with subblock rows
|
||||
*/
|
||||
const LANDING_BLOCKS: LandingManualBlock[] = [
|
||||
{
|
||||
id: 'schedule',
|
||||
name: 'Schedule',
|
||||
color: '#7B68EE',
|
||||
icon: <ScheduleIcon className='h-[16px] w-[16px] text-white' />,
|
||||
positions: {
|
||||
mobile: { x: 8, y: 60 },
|
||||
tablet: { x: 40, y: 120 },
|
||||
desktop: { x: 60, y: 180 },
|
||||
},
|
||||
tags: [{ label: 'Time: 09:00AM Daily' }, { label: 'Timezone: PST' }],
|
||||
},
|
||||
{
|
||||
id: 'knowledge',
|
||||
name: 'Knowledge',
|
||||
color: '#00B0B0',
|
||||
icon: <PackageSearchIcon className='h-[16px] w-[16px] text-white' />,
|
||||
positions: {
|
||||
mobile: { x: 120, y: 140 },
|
||||
tablet: { x: 220, y: 200 },
|
||||
desktop: { x: 420, y: 241 },
|
||||
},
|
||||
tags: [{ label: 'Source: Product Vector DB' }, { label: 'Limit: 10' }],
|
||||
},
|
||||
{
|
||||
id: 'agent',
|
||||
name: 'Agent',
|
||||
color: '#802FFF',
|
||||
icon: <AgentIcon className='h-[16px] w-[16px] text-white' />,
|
||||
positions: {
|
||||
mobile: { x: 340, y: 60 },
|
||||
tablet: { x: 540, y: 120 },
|
||||
desktop: { x: 880, y: 142 },
|
||||
},
|
||||
tags: [{ label: 'Model: gpt-5' }, { label: 'Prompt: You are a support ag...' }],
|
||||
},
|
||||
{
|
||||
id: 'function',
|
||||
name: 'Function',
|
||||
color: '#FF402F',
|
||||
icon: <CodeIcon className='h-[16px] w-[16px] text-white' />,
|
||||
positions: {
|
||||
mobile: { x: 480, y: 220 },
|
||||
tablet: { x: 740, y: 280 },
|
||||
desktop: { x: 880, y: 340 },
|
||||
},
|
||||
tags: [{ label: 'Language: Python' }, { label: 'Code: 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', style: { color: '#635BFF' } },
|
||||
{ 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) {
|
||||
LandingPromptStorage.store(textValue)
|
||||
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='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,25 +1,4 @@
|
||||
import Background from '@/app/(landing)/components/background/background'
|
||||
import ExternalRedirect from '@/app/(landing)/components/external-redirect'
|
||||
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,
|
||||
ExternalRedirect,
|
||||
}
|
||||
export { LegalLayout, ExternalRedirect }
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
import * as Icons from '@/components/icons'
|
||||
|
||||
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' },
|
||||
{ icon: Icons.VllmIcon, label: 'vLLM' },
|
||||
]
|
||||
|
||||
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.CalendlyIcon, label: 'Calendly' },
|
||||
{ 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' },
|
||||
]
|
||||
|
||||
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) {
|
||||
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='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>
|
||||
)
|
||||
}
|
||||
@@ -1,238 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { ComponentType, SVGProps } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import {
|
||||
ArrowRight,
|
||||
ChevronRight,
|
||||
Code2,
|
||||
Database,
|
||||
DollarSign,
|
||||
HardDrive,
|
||||
type LucideIcon,
|
||||
RefreshCw,
|
||||
Timer,
|
||||
Zap,
|
||||
} from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { ENTERPRISE_PLAN_FEATURES } from '@/app/workspace/[workspaceId]/settings/components/subscription/plan-configs'
|
||||
|
||||
const logger = createLogger('LandingPricing')
|
||||
|
||||
interface PricingFeature {
|
||||
icon: LucideIcon | ComponentType<SVGProps<SVGSVGElement>>
|
||||
text: string
|
||||
}
|
||||
|
||||
interface PricingTier {
|
||||
name: string
|
||||
tier: string
|
||||
price: string
|
||||
features: PricingFeature[]
|
||||
ctaText: string
|
||||
featured?: boolean
|
||||
}
|
||||
|
||||
const FREE_PLAN_FEATURES: PricingFeature[] = [
|
||||
{ icon: DollarSign, text: '1,000 credits (trial)' },
|
||||
{ icon: HardDrive, text: '5GB file storage' },
|
||||
{ icon: Timer, text: '5 min execution limit' },
|
||||
{ icon: Database, text: 'Limited log retention' },
|
||||
{ icon: Code2, text: 'CLI/SDK Access' },
|
||||
]
|
||||
|
||||
const PRO_LANDING_FEATURES: PricingFeature[] = [
|
||||
{ icon: DollarSign, text: '6,000 credits/mo' },
|
||||
{ icon: RefreshCw, text: '+50 daily refresh credits' },
|
||||
{ icon: Zap, text: '150 runs/min (sync)' },
|
||||
{ icon: Timer, text: '50 min sync execution limit' },
|
||||
{ icon: HardDrive, text: '50GB file storage' },
|
||||
]
|
||||
|
||||
const MAX_LANDING_FEATURES: PricingFeature[] = [
|
||||
{ icon: DollarSign, text: '25,000 credits/mo' },
|
||||
{ icon: RefreshCw, text: '+200 daily refresh credits' },
|
||||
{ icon: Zap, text: '300 runs/min (sync)' },
|
||||
{ icon: Timer, text: '50 min sync execution limit' },
|
||||
{ icon: HardDrive, text: '500GB file storage' },
|
||||
]
|
||||
|
||||
const pricingTiers: PricingTier[] = [
|
||||
{
|
||||
name: 'COMMUNITY',
|
||||
tier: 'Free',
|
||||
price: 'Free',
|
||||
features: FREE_PLAN_FEATURES,
|
||||
ctaText: 'Get Started',
|
||||
},
|
||||
{
|
||||
name: 'PRO',
|
||||
tier: 'Pro',
|
||||
price: '$25/mo',
|
||||
features: PRO_LANDING_FEATURES,
|
||||
ctaText: 'Get Started',
|
||||
featured: true,
|
||||
},
|
||||
{
|
||||
name: 'MAX',
|
||||
tier: 'Max',
|
||||
price: '$100/mo',
|
||||
features: MAX_LANDING_FEATURES,
|
||||
ctaText: 'Get Started',
|
||||
},
|
||||
{
|
||||
name: 'ENTERPRISE',
|
||||
tier: 'Enterprise',
|
||||
price: 'Custom',
|
||||
features: ENTERPRISE_PLAN_FEATURES,
|
||||
ctaText: 'Contact Sales',
|
||||
},
|
||||
]
|
||||
|
||||
function PricingCard({
|
||||
tier,
|
||||
isBeforeFeatured,
|
||||
}: {
|
||||
tier: PricingTier
|
||||
isBeforeFeatured?: boolean
|
||||
}) {
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
const router = useRouter()
|
||||
|
||||
const handleCtaClick = () => {
|
||||
logger.info(`Pricing CTA clicked: ${tier.name}`)
|
||||
|
||||
if (tier.ctaText === 'Contact Sales') {
|
||||
window.open('https://form.typeform.com/to/jqCO12pF', '_blank')
|
||||
} else {
|
||||
router.push('/signup')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'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>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pricing grid with all tier cards. Rendered as a client component because
|
||||
* the tier data contains component references (icon functions) which are
|
||||
* not serializable across the RSC boundary.
|
||||
*/
|
||||
export function PricingGrid() {
|
||||
return (
|
||||
<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} isBeforeFeatured={isBeforeFeatured} />
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { PricingGrid } from '@/app/(landing)/components/landing-pricing/components/pricing-card'
|
||||
|
||||
/**
|
||||
* Landing page pricing section displaying tiered pricing plans
|
||||
*/
|
||||
export default function LandingPricing() {
|
||||
return (
|
||||
<section id='pricing' className='px-4 pt-[23px] sm:px-0 sm:pt-[4px]' aria-label='Pricing plans'>
|
||||
<h2 className='sr-only'>Pricing Plans</h2>
|
||||
<div className='relative mx-auto w-full max-w-[1289px]'>
|
||||
<PricingGrid />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user