mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
Compare commits
34 Commits
fix/mother
...
feat/table
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dcb714d9b1 | ||
|
|
b8b939ead9 | ||
|
|
6df65127fb | ||
|
|
738d51af0d | ||
|
|
b0870f4afa | ||
|
|
395a61d1b6 | ||
|
|
680c9cddf0 | ||
|
|
6818c510c7 | ||
|
|
38c892230a | ||
|
|
aa1f60540d | ||
|
|
8906439a7e | ||
|
|
ad68dc16f2 | ||
|
|
7ecd377c41 | ||
|
|
743742d058 | ||
|
|
b7b575c4d2 | ||
|
|
aad620c456 | ||
|
|
f57294936b | ||
|
|
8837f14194 | ||
|
|
f077751ce8 | ||
|
|
75bdf46e6b | ||
|
|
952915abfc | ||
|
|
cbc9f4248c | ||
|
|
5ba3118495 | ||
|
|
00ff21ab9c | ||
|
|
a2f8ed06c8 | ||
|
|
f347e3fca0 | ||
|
|
e13f52fea2 | ||
|
|
e6b2b739cf | ||
|
|
9ae656c0d5 | ||
|
|
c738226c06 | ||
|
|
8f15be23a0 | ||
|
|
b2d146ca0a | ||
|
|
d06aa1de7e | ||
|
|
5b9f0d73c2 |
@@ -117,6 +117,8 @@ export const {service}Connector: ConnectorConfig = {
|
||||
|
||||
The add-connector modal renders these automatically — no custom UI needed.
|
||||
|
||||
Three field types are supported: `short-input`, `dropdown`, and `selector`.
|
||||
|
||||
```typescript
|
||||
// Text input
|
||||
{
|
||||
@@ -141,6 +143,136 @@ The add-connector modal renders these automatically — no custom UI needed.
|
||||
}
|
||||
```
|
||||
|
||||
## Dynamic Selectors (Canonical Pairs)
|
||||
|
||||
Use `type: 'selector'` to fetch options dynamically from the existing selector registry (`hooks/selectors/registry.ts`). Selectors are always paired with a manual fallback input using the **canonical pair** pattern — a `selector` field (basic mode) and a `short-input` field (advanced mode) linked by `canonicalParamId`.
|
||||
|
||||
The user sees a toggle button (ArrowLeftRight) to switch between the selector dropdown and manual text input. On submit, the modal resolves each canonical pair to the active mode's value, keyed by `canonicalParamId`.
|
||||
|
||||
### Rules
|
||||
|
||||
1. **Every selector field MUST have a canonical pair** — a corresponding `short-input` (or `dropdown`) field with the same `canonicalParamId` and `mode: 'advanced'`.
|
||||
2. **`required` must be set identically on both fields** in a pair. If the selector is required, the manual input must also be required.
|
||||
3. **`canonicalParamId` must match the key the connector expects in `sourceConfig`** (e.g. `baseId`, `channel`, `teamId`). The advanced field's `id` should typically match `canonicalParamId`.
|
||||
4. **`dependsOn` references the selector field's `id`**, not the `canonicalParamId`. The modal propagates dependency clearing across canonical siblings automatically — changing either field in a parent pair clears dependent children.
|
||||
|
||||
### Selector canonical pair example (Airtable base → table cascade)
|
||||
|
||||
```typescript
|
||||
configFields: [
|
||||
// Base: selector (basic) + manual (advanced)
|
||||
{
|
||||
id: 'baseSelector',
|
||||
title: 'Base',
|
||||
type: 'selector',
|
||||
selectorKey: 'airtable.bases', // Must exist in hooks/selectors/registry.ts
|
||||
canonicalParamId: 'baseId',
|
||||
mode: 'basic',
|
||||
placeholder: 'Select a base',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'baseId',
|
||||
title: 'Base ID',
|
||||
type: 'short-input',
|
||||
canonicalParamId: 'baseId',
|
||||
mode: 'advanced',
|
||||
placeholder: 'e.g. appXXXXXXXXXXXXXX',
|
||||
required: true,
|
||||
},
|
||||
// Table: selector depends on base (basic) + manual (advanced)
|
||||
{
|
||||
id: 'tableSelector',
|
||||
title: 'Table',
|
||||
type: 'selector',
|
||||
selectorKey: 'airtable.tables',
|
||||
canonicalParamId: 'tableIdOrName',
|
||||
mode: 'basic',
|
||||
dependsOn: ['baseSelector'], // References the selector field ID
|
||||
placeholder: 'Select a table',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'tableIdOrName',
|
||||
title: 'Table Name or ID',
|
||||
type: 'short-input',
|
||||
canonicalParamId: 'tableIdOrName',
|
||||
mode: 'advanced',
|
||||
placeholder: 'e.g. Tasks',
|
||||
required: true,
|
||||
},
|
||||
// Non-selector fields stay as-is
|
||||
{ id: 'maxRecords', title: 'Max Records', type: 'short-input', ... },
|
||||
]
|
||||
```
|
||||
|
||||
### Selector with domain dependency (Jira/Confluence pattern)
|
||||
|
||||
When a selector depends on a plain `short-input` field (no canonical pair), `dependsOn` references that field's `id` directly. The `domain` field's value maps to `SelectorContext.domain` automatically via `SELECTOR_CONTEXT_FIELDS`.
|
||||
|
||||
```typescript
|
||||
configFields: [
|
||||
{
|
||||
id: 'domain',
|
||||
title: 'Jira Domain',
|
||||
type: 'short-input',
|
||||
placeholder: 'yoursite.atlassian.net',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'projectSelector',
|
||||
title: 'Project',
|
||||
type: 'selector',
|
||||
selectorKey: 'jira.projects',
|
||||
canonicalParamId: 'projectKey',
|
||||
mode: 'basic',
|
||||
dependsOn: ['domain'],
|
||||
placeholder: 'Select a project',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'projectKey',
|
||||
title: 'Project Key',
|
||||
type: 'short-input',
|
||||
canonicalParamId: 'projectKey',
|
||||
mode: 'advanced',
|
||||
placeholder: 'e.g. ENG, PROJ',
|
||||
required: true,
|
||||
},
|
||||
]
|
||||
```
|
||||
|
||||
### How `dependsOn` maps to `SelectorContext`
|
||||
|
||||
The connector selector field builds a `SelectorContext` from dependency values. For the mapping to work, each dependency's `canonicalParamId` (or field `id` for non-canonical fields) must exist in `SELECTOR_CONTEXT_FIELDS` (`lib/workflows/subblocks/context.ts`):
|
||||
|
||||
```
|
||||
oauthCredential, domain, teamId, projectId, knowledgeBaseId, planId,
|
||||
siteId, collectionId, spreadsheetId, fileId, baseId, datasetId, serviceDeskId
|
||||
```
|
||||
|
||||
### Available selector keys
|
||||
|
||||
Check `hooks/selectors/types.ts` for the full `SelectorKey` union. Common ones for connectors:
|
||||
|
||||
| SelectorKey | Context Deps | Returns |
|
||||
|-------------|-------------|---------|
|
||||
| `airtable.bases` | credential | Base ID + name |
|
||||
| `airtable.tables` | credential, `baseId` | Table ID + name |
|
||||
| `slack.channels` | credential | Channel ID + name |
|
||||
| `gmail.labels` | credential | Label ID + name |
|
||||
| `google.calendar` | credential | Calendar ID + name |
|
||||
| `linear.teams` | credential | Team ID + name |
|
||||
| `linear.projects` | credential, `teamId` | Project ID + name |
|
||||
| `jira.projects` | credential, `domain` | Project key + name |
|
||||
| `confluence.spaces` | credential, `domain` | Space key + name |
|
||||
| `notion.databases` | credential | Database ID + name |
|
||||
| `asana.workspaces` | credential | Workspace GID + name |
|
||||
| `microsoft.teams` | credential | Team ID + name |
|
||||
| `microsoft.channels` | credential, `teamId` | Channel ID + name |
|
||||
| `webflow.sites` | credential | Site ID + name |
|
||||
| `outlook.folders` | credential | Folder ID + name |
|
||||
|
||||
## ExternalDocument Shape
|
||||
|
||||
Every document returned from `listDocuments`/`getDocument` must include:
|
||||
@@ -287,6 +419,12 @@ export const CONNECTOR_REGISTRY: ConnectorRegistry = {
|
||||
- [ ] **Auth configured correctly:**
|
||||
- OAuth: `auth.provider` matches an existing `OAuthService` in `lib/oauth/types.ts`
|
||||
- API key: `auth.label` and `auth.placeholder` set appropriately
|
||||
- [ ] **Selector fields configured correctly (if applicable):**
|
||||
- Every `type: 'selector'` field has a canonical pair (`short-input` or `dropdown` with same `canonicalParamId` and `mode: 'advanced'`)
|
||||
- `required` is identical on both fields in each canonical pair
|
||||
- `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
|
||||
- [ ] `sourceUrl` set on each ExternalDocument (full URL, not relative)
|
||||
- [ ] `metadata` includes source-specific data for tag mapping
|
||||
|
||||
316
.claude/commands/validate-connector.md
Normal file
316
.claude/commands/validate-connector.md
Normal file
@@ -0,0 +1,316 @@
|
||||
---
|
||||
description: Validate an existing knowledge base connector against its service's API docs
|
||||
argument-hint: <service-name> [api-docs-url]
|
||||
---
|
||||
|
||||
# Validate Connector Skill
|
||||
|
||||
You are an expert auditor for Sim knowledge base connectors. Your job is to thoroughly validate that an existing connector is correct, complete, and follows all conventions.
|
||||
|
||||
## Your Task
|
||||
|
||||
When the user asks you to validate a connector:
|
||||
1. Read the service's API documentation (via Context7 or WebFetch)
|
||||
2. Read the connector implementation, OAuth config, and registry entries
|
||||
3. Cross-reference everything against the API docs and Sim conventions
|
||||
4. Report all issues found, grouped by severity (critical, warning, suggestion)
|
||||
5. Fix all issues after reporting them
|
||||
|
||||
## Step 1: Gather All Files
|
||||
|
||||
Read **every** file for the connector — do not skip any:
|
||||
|
||||
```
|
||||
apps/sim/connectors/{service}/{service}.ts # Connector implementation
|
||||
apps/sim/connectors/{service}/index.ts # Barrel export
|
||||
apps/sim/connectors/registry.ts # Connector registry entry
|
||||
apps/sim/connectors/types.ts # ConnectorConfig interface, ExternalDocument, etc.
|
||||
apps/sim/connectors/utils.ts # Shared utilities (computeContentHash, htmlToPlainText, etc.)
|
||||
apps/sim/lib/oauth/oauth.ts # OAUTH_PROVIDERS — single source of truth for scopes
|
||||
apps/sim/lib/oauth/utils.ts # getCanonicalScopesForProvider, getScopesForService, SCOPE_DESCRIPTIONS
|
||||
apps/sim/lib/oauth/types.ts # OAuthService union type
|
||||
apps/sim/components/icons.tsx # Icon definition for the service
|
||||
```
|
||||
|
||||
If the connector uses selectors, also read:
|
||||
```
|
||||
apps/sim/hooks/selectors/registry.ts # Selector key definitions
|
||||
apps/sim/hooks/selectors/types.ts # SelectorKey union type
|
||||
apps/sim/lib/workflows/subblocks/context.ts # SELECTOR_CONTEXT_FIELDS
|
||||
```
|
||||
|
||||
## Step 2: Pull API Documentation
|
||||
|
||||
Fetch the official API docs for the service. This is the **source of truth** for:
|
||||
- Endpoint URLs, HTTP methods, and auth headers
|
||||
- Required vs optional parameters
|
||||
- Parameter types and allowed values
|
||||
- Response shapes and field names
|
||||
- Pagination patterns (cursor, offset, next token)
|
||||
- Rate limits and error formats
|
||||
- OAuth scopes and their meanings
|
||||
|
||||
Use Context7 (resolve-library-id → query-docs) or WebFetch to retrieve documentation. If both fail, note which claims are based on training knowledge vs verified docs.
|
||||
|
||||
## Step 3: Validate API Endpoints
|
||||
|
||||
For **every** API call in the connector (`listDocuments`, `getDocument`, `validateConfig`, and any helper functions), verify against the API docs:
|
||||
|
||||
### URLs and Methods
|
||||
- [ ] Base URL is correct for the service's API version
|
||||
- [ ] Endpoint paths match the API docs exactly
|
||||
- [ ] HTTP method is correct (GET, POST, PUT, PATCH, DELETE)
|
||||
- [ ] Path parameters are correctly interpolated and URI-encoded where needed
|
||||
- [ ] Query parameters use correct names and formats per the API docs
|
||||
|
||||
### Headers
|
||||
- [ ] Authorization header uses the correct format:
|
||||
- OAuth: `Authorization: Bearer ${accessToken}`
|
||||
- API Key: correct header name per the service's docs
|
||||
- [ ] `Content-Type` is set for POST/PUT/PATCH requests
|
||||
- [ ] Any service-specific headers are present (e.g., `Notion-Version`, `Dropbox-API-Arg`)
|
||||
- [ ] No headers are sent that the API doesn't support or silently ignores
|
||||
|
||||
### Request Bodies
|
||||
- [ ] POST/PUT body fields match API parameter names exactly
|
||||
- [ ] Required fields are always sent
|
||||
- [ ] Optional fields are conditionally included (not sent as `null` or empty unless the API expects that)
|
||||
- [ ] Field value types match API expectations (string vs number vs boolean)
|
||||
|
||||
### Input Sanitization
|
||||
- [ ] User-controlled values interpolated into query strings are properly escaped:
|
||||
- OData `$filter`: single quotes escaped with `''` (e.g., `externalId.replace(/'/g, "''")`)
|
||||
- SOQL: single quotes escaped with `\'`
|
||||
- GraphQL variables: passed as variables, not interpolated into query strings
|
||||
- URL path segments: `encodeURIComponent()` applied
|
||||
- [ ] URL-type config fields (e.g., `siteUrl`, `instanceUrl`) are normalized:
|
||||
- Strip `https://` / `http://` prefix if the API expects bare domains
|
||||
- Strip trailing `/`
|
||||
- Apply `.trim()` before validation
|
||||
|
||||
### Response Parsing
|
||||
- [ ] Response structure is correctly traversed (e.g., `data.results` vs `data.items` vs `data`)
|
||||
- [ ] Field names extracted match what the API actually returns
|
||||
- [ ] Nullable fields are handled with `?? null` or `|| undefined`
|
||||
- [ ] Error responses are checked before accessing data fields
|
||||
|
||||
## Step 4: Validate OAuth Scopes (if OAuth connector)
|
||||
|
||||
Scopes must be correctly declared and sufficient for all API calls the connector makes.
|
||||
|
||||
### Connector requiredScopes
|
||||
- [ ] `requiredScopes` in the connector's `auth` config lists all scopes needed by the connector
|
||||
- [ ] Each scope in `requiredScopes` is a real, valid scope recognized by the service's API
|
||||
- [ ] No invalid, deprecated, or made-up scopes are listed
|
||||
- [ ] No unnecessary excess scopes beyond what the connector actually needs
|
||||
|
||||
### Scope Subset Validation (CRITICAL)
|
||||
- [ ] Every scope in `requiredScopes` exists in the OAuth provider's `scopes` array in `lib/oauth/oauth.ts`
|
||||
- [ ] Find the provider in `OAUTH_PROVIDERS[providerGroup].services[serviceId].scopes`
|
||||
- [ ] Verify: `requiredScopes` ⊆ `OAUTH_PROVIDERS scopes` (every required scope is present in the provider config)
|
||||
- [ ] If a required scope is NOT in the provider config, flag as **critical** — the connector will fail at runtime
|
||||
|
||||
### Scope Sufficiency
|
||||
For each API endpoint the connector calls:
|
||||
- [ ] Identify which scopes are required per the API docs
|
||||
- [ ] Verify those scopes are included in the connector's `requiredScopes`
|
||||
- [ ] If the connector calls endpoints requiring scopes not in `requiredScopes`, flag as **warning**
|
||||
|
||||
### Token Refresh Config
|
||||
- [ ] Check the `getOAuthTokenRefreshConfig` function in `lib/oauth/oauth.ts` for this provider
|
||||
- [ ] `useBasicAuth` matches the service's token exchange requirements
|
||||
- [ ] `supportsRefreshTokenRotation` matches whether the service issues rotating refresh tokens
|
||||
- [ ] Token endpoint URL is correct
|
||||
|
||||
## Step 5: Validate Pagination
|
||||
|
||||
### listDocuments Pagination
|
||||
- [ ] Cursor/pagination parameter name matches the API docs
|
||||
- [ ] Response pagination field is correctly extracted (e.g., `next_cursor`, `nextPageToken`, `@odata.nextLink`, `offset`)
|
||||
- [ ] `hasMore` is correctly determined from the response
|
||||
- [ ] `nextCursor` is correctly passed back for the next page
|
||||
- [ ] `maxItems` / `maxRecords` cap is correctly applied across pages using `syncContext.totalDocsFetched`
|
||||
- [ ] Page size is within the API's allowed range (not exceeding max page size)
|
||||
- [ ] Last page precision: when a `maxItems` cap exists, the final page request uses `Math.min(PAGE_SIZE, remaining)` to avoid fetching more records than needed
|
||||
- [ ] No off-by-one errors in pagination tracking
|
||||
- [ ] The connector does NOT hit known API pagination limits silently (e.g., HubSpot search 10k cap)
|
||||
|
||||
### Pagination State Across Pages
|
||||
- [ ] `syncContext` is used to cache state across pages (user names, field maps, instance URLs, portal IDs, etc.)
|
||||
- [ ] Cached state in `syncContext` is correctly initialized on first page and reused on subsequent pages
|
||||
|
||||
## Step 6: Validate Data Transformation
|
||||
|
||||
### 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`
|
||||
- [ ] `sourceUrl` is a valid, complete URL back to the original resource (not relative)
|
||||
- [ ] `metadata` contains all fields referenced by `mapTags` and `tagDefinitions`
|
||||
|
||||
### Content Extraction
|
||||
- [ ] Rich text / HTML fields are converted to plain text before indexing
|
||||
- [ ] Important content is not silently dropped (e.g., nested blocks, table cells, code blocks)
|
||||
- [ ] Content is not silently truncated without logging a warning
|
||||
- [ ] Empty/blank documents are properly filtered out
|
||||
- [ ] Size checks use `Buffer.byteLength(text, 'utf8')` not `text.length` when comparing against byte-based limits (e.g., `MAX_FILE_SIZE` in bytes)
|
||||
|
||||
## Step 7: Validate Tag Definitions and mapTags
|
||||
|
||||
### tagDefinitions
|
||||
- [ ] Each `tagDefinition` has an `id`, `displayName`, and `fieldType`
|
||||
- [ ] `fieldType` matches the actual data type: `'text'` for strings, `'number'` for numbers, `'date'` for dates, `'boolean'` for booleans
|
||||
- [ ] Every `id` in `tagDefinitions` is returned by `mapTags`
|
||||
- [ ] No `tagDefinition` references a field that `mapTags` never produces
|
||||
|
||||
### mapTags
|
||||
- [ ] Return keys match `tagDefinition` `id` values exactly
|
||||
- [ ] Date values are properly parsed using `parseTagDate` from `@/connectors/utils`
|
||||
- [ ] Array values are properly joined using `joinTagArray` from `@/connectors/utils`
|
||||
- [ ] Number values are validated (not `NaN`)
|
||||
- [ ] Metadata field names accessed in `mapTags` match what `listDocuments`/`getDocument` store in `metadata`
|
||||
|
||||
## Step 8: Validate Config Fields and Validation
|
||||
|
||||
### configFields
|
||||
- [ ] Every field has `id`, `title`, `type`
|
||||
- [ ] `required` is set explicitly (not omitted)
|
||||
- [ ] Dropdown fields have `options` with `label` and `id` for each option
|
||||
- [ ] Selector fields follow the canonical pair pattern:
|
||||
- A `type: 'selector'` field with `selectorKey`, `canonicalParamId`, `mode: 'basic'`
|
||||
- A `type: 'short-input'` field with the same `canonicalParamId`, `mode: 'advanced'`
|
||||
- `required` is identical on both fields in the pair
|
||||
- [ ] `selectorKey` values exist in the selector registry
|
||||
- [ ] `dependsOn` references selector field `id` values, not `canonicalParamId`
|
||||
|
||||
### validateConfig
|
||||
- [ ] Validates all required fields are present before making API calls
|
||||
- [ ] Validates optional numeric fields (checks `Number.isNaN`, positive values)
|
||||
- [ ] Makes a lightweight API call to verify access (e.g., fetch 1 record, get profile)
|
||||
- [ ] Uses `VALIDATE_RETRY_OPTIONS` for retry budget
|
||||
- [ ] Returns `{ valid: true }` on success
|
||||
- [ ] Returns `{ valid: false, error: 'descriptive message' }` on failure
|
||||
- [ ] Catches exceptions and returns user-friendly error messages
|
||||
- [ ] Does NOT make expensive calls (full data listing, large queries)
|
||||
|
||||
## Step 9: Validate getDocument
|
||||
|
||||
- [ ] Fetches a single document by `externalId`
|
||||
- [ ] Returns `null` for 404 / not found (does not throw)
|
||||
- [ ] Returns the same `ExternalDocument` shape as `listDocuments`
|
||||
- [ ] 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)
|
||||
- [ ] Does not redundantly re-fetch data already included in the initial API response (e.g., if comments come back with the post, don't fetch them again separately)
|
||||
|
||||
## Step 10: Validate General Quality
|
||||
|
||||
### fetchWithRetry Usage
|
||||
- [ ] All external API calls use `fetchWithRetry` from `@/lib/knowledge/documents/utils`
|
||||
- [ ] No raw `fetch()` calls to external APIs
|
||||
- [ ] `VALIDATE_RETRY_OPTIONS` used in `validateConfig`
|
||||
- [ ] If `validateConfig` calls a shared helper (e.g., `linearGraphQL`, `resolveId`), that helper must accept and forward `retryOptions` to `fetchWithRetry`
|
||||
- [ ] Default retry options used in `listDocuments`/`getDocument`
|
||||
|
||||
### API Efficiency
|
||||
- [ ] APIs that support field selection (e.g., `$select`, `sysparm_fields`, `fields`) should request only the fields the connector needs — in both `listDocuments` AND `getDocument`
|
||||
- [ ] No redundant API calls: if a helper already fetches data (e.g., site metadata), callers should reuse the result instead of making a second call for the same information
|
||||
- [ ] Sequential per-item API calls (fetching details for each document in a loop) should be batched with `Promise.all` and a concurrency limit of 3-5
|
||||
|
||||
### Error Handling
|
||||
- [ ] Individual document failures are caught and logged without aborting the sync
|
||||
- [ ] API error responses include status codes in error messages
|
||||
- [ ] No unhandled promise rejections in concurrent operations
|
||||
|
||||
### Concurrency
|
||||
- [ ] Concurrent API calls use reasonable batch sizes (3-5 is typical)
|
||||
- [ ] No unbounded `Promise.all` over large arrays
|
||||
|
||||
### Logging
|
||||
- [ ] Uses `createLogger` from `@sim/logger` (not `console.log`)
|
||||
- [ ] Logs sync progress at `info` level
|
||||
- [ ] Logs errors at `warn` or `error` level with context
|
||||
|
||||
### Registry
|
||||
- [ ] Connector is exported from `connectors/{service}/index.ts`
|
||||
- [ ] Connector is registered in `connectors/registry.ts`
|
||||
- [ ] Registry key matches the connector's `id` field
|
||||
|
||||
## Step 11: Report and Fix
|
||||
|
||||
### Report Format
|
||||
|
||||
Group findings by severity:
|
||||
|
||||
**Critical** (will cause runtime errors, data loss, or auth failures):
|
||||
- Wrong API endpoint URL or HTTP method
|
||||
- Invalid or missing OAuth scopes (not in provider config)
|
||||
- Incorrect response field mapping (accessing wrong path)
|
||||
- SOQL/query fields that don't exist on the target object
|
||||
- Pagination that silently hits undocumented API limits
|
||||
- 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
|
||||
|
||||
**Warning** (incorrect behavior, data quality issues, or convention violations):
|
||||
- HTML content not stripped via `htmlToPlainText`
|
||||
- `getDocument` not forwarding `syncContext`
|
||||
- `getDocument` hardcoded to one content type when `listDocuments` returns multiple (e.g., only pages but not blogposts)
|
||||
- Missing `tagDefinition` for metadata fields returned by `mapTags`
|
||||
- Incorrect `useBasicAuth` or `supportsRefreshTokenRotation` in token refresh config
|
||||
- Invalid scope names that the API doesn't recognize (even if silently ignored)
|
||||
- Private resources excluded from name-based lookup despite scopes being available
|
||||
- Silent data truncation without logging
|
||||
- Size checks using `text.length` (character count) instead of `Buffer.byteLength` (byte count) for byte-based limits
|
||||
- URL-type config fields not normalized (protocol prefix, trailing slashes cause API failures)
|
||||
- `VALIDATE_RETRY_OPTIONS` not threaded through helper functions called by `validateConfig`
|
||||
|
||||
**Suggestion** (minor improvements):
|
||||
- Missing incremental sync support despite API supporting it
|
||||
- Overly broad scopes that could be narrowed (not wrong, but could be tighter)
|
||||
- Source URL format could be more specific
|
||||
- Missing `orderBy` for deterministic pagination
|
||||
- Redundant API calls that could be cached in `syncContext`
|
||||
- Sequential per-item API calls that could be batched with `Promise.all` (concurrency 3-5)
|
||||
- API supports field selection but connector fetches all fields (e.g., missing `$select`, `sysparm_fields`, `fields`)
|
||||
- `getDocument` re-fetches data already included in the initial API response (e.g., comments returned with post)
|
||||
- Last page of pagination requests full `PAGE_SIZE` when fewer records remain (`Math.min(PAGE_SIZE, remaining)`)
|
||||
|
||||
### Fix All Issues
|
||||
|
||||
After reporting, fix every **critical** and **warning** issue. Apply **suggestions** where they don't add unnecessary complexity.
|
||||
|
||||
### Validation Output
|
||||
|
||||
After fixing, confirm:
|
||||
1. `bun run lint` passes
|
||||
2. TypeScript compiles clean
|
||||
3. Re-read all modified files to verify fixes are correct
|
||||
|
||||
## Checklist Summary
|
||||
|
||||
- [ ] Read connector implementation, types, utils, registry, and OAuth config
|
||||
- [ ] Pulled and read official API documentation for the service
|
||||
- [ ] Validated every API endpoint URL, method, headers, and body against API docs
|
||||
- [ ] Validated input sanitization: no query/filter injection, URL fields normalized
|
||||
- [ ] Validated OAuth scopes: `requiredScopes` ⊆ OAuth provider `scopes` in `oauth.ts`
|
||||
- [ ] Validated each scope is real and recognized by the service's API
|
||||
- [ ] 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 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
|
||||
- [ ] Validated validateConfig: lightweight check, error messages, retry options
|
||||
- [ ] Validated getDocument: null on 404, all content types handled, no redundant re-fetches, syncContext forwarding
|
||||
- [ ] Validated fetchWithRetry used for all external calls (no raw fetch), VALIDATE_RETRY_OPTIONS threaded through helpers
|
||||
- [ ] Validated API efficiency: field selection used, no redundant calls, sequential fetches batched
|
||||
- [ ] Validated error handling: graceful failures, no unhandled rejections
|
||||
- [ ] Validated logging: createLogger, no console.log
|
||||
- [ ] Validated registry: correct export, correct key
|
||||
- [ ] Reported all issues grouped by severity
|
||||
- [ ] Fixed all critical and warning issues
|
||||
- [ ] Ran `bun run lint` after fixes
|
||||
- [ ] Verified TypeScript compiles clean
|
||||
@@ -1,18 +1,7 @@
|
||||
'use client'
|
||||
import { memo } from 'react'
|
||||
|
||||
import { memo, useEffect, useState } from 'react'
|
||||
|
||||
/** Shared corner radius from Figma export for all decorative rects. */
|
||||
const RX = '2.59574'
|
||||
|
||||
const ENTER_STAGGER = 0.06
|
||||
const ENTER_DURATION = 0.3
|
||||
const EXIT_STAGGER = 0.12
|
||||
const EXIT_DURATION = 0.5
|
||||
const INITIAL_HOLD = 3000
|
||||
const HOLD_BETWEEN = 3000
|
||||
const TRANSITION_PAUSE = 400
|
||||
|
||||
interface BlockRect {
|
||||
opacity: number
|
||||
width: string
|
||||
@@ -23,8 +12,6 @@ interface BlockRect {
|
||||
transform?: string
|
||||
}
|
||||
|
||||
type AnimState = 'visible' | 'exiting' | 'hidden'
|
||||
|
||||
const RECTS = {
|
||||
topRight: [
|
||||
{ opacity: 1, x: '0', y: '0', width: '16.8626', height: '33.7252', fill: '#2ABBF8' },
|
||||
@@ -67,76 +54,33 @@ const RECTS = {
|
||||
fill: '#FA4EDF',
|
||||
},
|
||||
],
|
||||
left: [
|
||||
{
|
||||
opacity: 0.6,
|
||||
width: '34.240',
|
||||
height: '33.725',
|
||||
fill: '#FA4EDF',
|
||||
transform: 'matrix(0 1 1 0 0 0)',
|
||||
},
|
||||
{
|
||||
opacity: 0.6,
|
||||
width: '16.8626',
|
||||
height: '68.480',
|
||||
fill: '#FA4EDF',
|
||||
transform: 'matrix(-1 0 0 1 33.727 0)',
|
||||
},
|
||||
bottomLeft: [
|
||||
{ opacity: 1, x: '0', y: '0', width: '16.8626', height: '33.7252', fill: '#2ABBF8' },
|
||||
{ opacity: 0.6, x: '0', y: '0', width: '85.3433', height: '16.8626', fill: '#2ABBF8' },
|
||||
{ opacity: 1, x: '0', y: '0', width: '16.8626', height: '16.8626', fill: '#2ABBF8' },
|
||||
{ opacity: 0.6, x: '34.2403', y: '0', width: '34.2403', height: '33.7252', fill: '#2ABBF8' },
|
||||
{ opacity: 1, x: '34.2403', y: '0', width: '16.8626', height: '16.8626', fill: '#2ABBF8' },
|
||||
{
|
||||
opacity: 1,
|
||||
x: '51.6188',
|
||||
y: '16.8626',
|
||||
width: '16.8626',
|
||||
height: '16.8626',
|
||||
fill: '#FA4EDF',
|
||||
transform: 'matrix(-1 0 0 1 33.727 17.378)',
|
||||
},
|
||||
{
|
||||
opacity: 0.6,
|
||||
width: '16.8626',
|
||||
height: '33.986',
|
||||
fill: '#FA4EDF',
|
||||
transform: 'matrix(0 1 1 0 0 51.616)',
|
||||
},
|
||||
{
|
||||
opacity: 0.6,
|
||||
width: '16.8626',
|
||||
height: '140.507',
|
||||
fill: '#00F701',
|
||||
transform: 'matrix(-1 0 0 1 33.986 85.335)',
|
||||
},
|
||||
{
|
||||
opacity: 0.4,
|
||||
x: '17.119',
|
||||
y: '136.962',
|
||||
width: '34.240',
|
||||
height: '16.8626',
|
||||
fill: '#FFCC02',
|
||||
transform: 'rotate(-90 17.119 136.962)',
|
||||
},
|
||||
{
|
||||
opacity: 1,
|
||||
x: '17.119',
|
||||
y: '136.962',
|
||||
width: '16.8626',
|
||||
height: '16.8626',
|
||||
fill: '#FFCC02',
|
||||
transform: 'rotate(-90 17.119 136.962)',
|
||||
},
|
||||
{
|
||||
opacity: 0.5,
|
||||
width: '34.240',
|
||||
height: '33.725',
|
||||
fill: '#00F701',
|
||||
transform: 'matrix(0 1 1 0 0.257 153.825)',
|
||||
fill: '#2ABBF8',
|
||||
},
|
||||
{ opacity: 1, x: '68.4812', y: '0', width: '54.6502', height: '16.8626', fill: '#00F701' },
|
||||
{ opacity: 0.6, x: '106.268', y: '0', width: '34.2403', height: '33.7252', fill: '#00F701' },
|
||||
{ opacity: 0.6, x: '106.268', y: '0', width: '51.103', height: '16.8626', fill: '#00F701' },
|
||||
{
|
||||
opacity: 1,
|
||||
x: '123.6484',
|
||||
y: '16.8626',
|
||||
width: '16.8626',
|
||||
height: '16.8626',
|
||||
fill: '#00F701',
|
||||
transform: 'matrix(0 1 1 0 0.257 153.825)',
|
||||
},
|
||||
],
|
||||
right: [
|
||||
bottomRight: [
|
||||
{
|
||||
opacity: 0.6,
|
||||
width: '16.8626',
|
||||
@@ -175,68 +119,33 @@ const RECTS = {
|
||||
{
|
||||
opacity: 0.6,
|
||||
width: '16.8626',
|
||||
height: '33.726',
|
||||
fill: '#FA4EDF',
|
||||
transform: 'matrix(0 1 1 0 0.012 68.510)',
|
||||
},
|
||||
{
|
||||
opacity: 0.6,
|
||||
width: '16.8626',
|
||||
height: '102.384',
|
||||
height: '34.24',
|
||||
fill: '#2ABBF8',
|
||||
transform: 'matrix(-1 0 0 1 33.787 102.384)',
|
||||
transform: 'matrix(-1 0 0 1 33.787 68)',
|
||||
},
|
||||
{
|
||||
opacity: 0.4,
|
||||
x: '17.131',
|
||||
y: '153.859',
|
||||
width: '34.241',
|
||||
height: '16.8626',
|
||||
fill: '#00F701',
|
||||
transform: 'rotate(-90 17.131 153.859)',
|
||||
},
|
||||
{
|
||||
opacity: 1,
|
||||
x: '17.131',
|
||||
y: '153.859',
|
||||
width: '16.8626',
|
||||
height: '16.8626',
|
||||
fill: '#00F701',
|
||||
transform: 'rotate(-90 17.131 153.859)',
|
||||
fill: '#1A8FCC',
|
||||
transform: 'matrix(-1 0 0 1 33.787 85)',
|
||||
},
|
||||
],
|
||||
} as const satisfies Record<string, readonly BlockRect[]>
|
||||
|
||||
type Position = keyof typeof RECTS
|
||||
|
||||
function enterTime(pos: Position): number {
|
||||
return (RECTS[pos].length - 1) * ENTER_STAGGER + ENTER_DURATION
|
||||
}
|
||||
|
||||
function exitTime(pos: Position): number {
|
||||
return (RECTS[pos].length - 1) * EXIT_STAGGER + EXIT_DURATION
|
||||
}
|
||||
|
||||
interface BlockGroupProps {
|
||||
width: number
|
||||
height: number
|
||||
viewBox: string
|
||||
rects: readonly BlockRect[]
|
||||
animState: AnimState
|
||||
globalOpacity: number
|
||||
}
|
||||
const GLOBAL_OPACITY = 0.55
|
||||
|
||||
const BlockGroup = memo(function BlockGroup({
|
||||
width,
|
||||
height,
|
||||
viewBox,
|
||||
rects,
|
||||
animState,
|
||||
globalOpacity,
|
||||
}: BlockGroupProps) {
|
||||
const isVisible = animState === 'visible'
|
||||
const isExiting = animState === 'exiting'
|
||||
|
||||
}: {
|
||||
width: number
|
||||
height: number
|
||||
viewBox: string
|
||||
rects: readonly BlockRect[]
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
width={width}
|
||||
@@ -245,7 +154,7 @@ const BlockGroup = memo(function BlockGroup({
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
className='h-auto w-full'
|
||||
style={{ opacity: globalOpacity }}
|
||||
style={{ opacity: GLOBAL_OPACITY }}
|
||||
>
|
||||
{rects.map((r, i) => (
|
||||
<rect
|
||||
@@ -257,114 +166,29 @@ const BlockGroup = memo(function BlockGroup({
|
||||
rx={RX}
|
||||
fill={r.fill}
|
||||
transform={r.transform}
|
||||
style={{
|
||||
opacity: isVisible ? r.opacity : 0,
|
||||
transition: `opacity ${isExiting ? EXIT_DURATION : ENTER_DURATION}s ease ${
|
||||
isVisible ? i * ENTER_STAGGER : isExiting ? i * EXIT_STAGGER : 0
|
||||
}s`,
|
||||
}}
|
||||
opacity={r.opacity}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
)
|
||||
})
|
||||
|
||||
function useGroupState(): [AnimState, (s: AnimState) => void] {
|
||||
return useState<AnimState>('visible')
|
||||
}
|
||||
|
||||
function useBlockCycle() {
|
||||
const [topRight, setTopRight] = useGroupState()
|
||||
const [left, setLeft] = useGroupState()
|
||||
const [right, setRight] = useGroupState()
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined' && !window.matchMedia('(min-width: 1024px)').matches) return
|
||||
|
||||
const cancelled = { current: false }
|
||||
const wait = (ms: number) => new Promise<void>((r) => setTimeout(r, ms))
|
||||
|
||||
async function exit(setter: (s: AnimState) => void, pos: Position, pauseAfter: number) {
|
||||
if (cancelled.current) return
|
||||
setter('exiting')
|
||||
await wait(exitTime(pos) * 1000)
|
||||
if (cancelled.current) return
|
||||
setter('hidden')
|
||||
await wait(pauseAfter)
|
||||
}
|
||||
|
||||
async function enter(setter: (s: AnimState) => void, pos: Position, pauseAfter: number) {
|
||||
if (cancelled.current) return
|
||||
setter('visible')
|
||||
await wait(enterTime(pos) * 1000 + pauseAfter)
|
||||
}
|
||||
|
||||
const run = async () => {
|
||||
await wait(INITIAL_HOLD)
|
||||
|
||||
while (!cancelled.current) {
|
||||
await exit(setTopRight, 'topRight', TRANSITION_PAUSE)
|
||||
await exit(setLeft, 'left', HOLD_BETWEEN)
|
||||
await enter(setLeft, 'left', TRANSITION_PAUSE)
|
||||
await enter(setTopRight, 'topRight', TRANSITION_PAUSE)
|
||||
await exit(setRight, 'right', HOLD_BETWEEN)
|
||||
await enter(setRight, 'right', HOLD_BETWEEN)
|
||||
}
|
||||
}
|
||||
|
||||
run()
|
||||
return () => {
|
||||
cancelled.current = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { topRight, left, right } as const
|
||||
}
|
||||
|
||||
/**
|
||||
* Ambient animated block decorations for the docs layout.
|
||||
* Adapts the landing page's colorful block patterns with slightly reduced
|
||||
* opacity and the same staggered enter/exit animation cycle.
|
||||
*/
|
||||
export function AnimatedBlocks() {
|
||||
const states = useBlockCycle()
|
||||
|
||||
return (
|
||||
<div
|
||||
className='pointer-events-none fixed inset-0 z-0 hidden overflow-hidden lg:block'
|
||||
aria-hidden='true'
|
||||
>
|
||||
<div className='absolute top-[93px] right-0 w-[calc(140px+10.76vw)] max-w-[295px]'>
|
||||
<BlockGroup
|
||||
width={295}
|
||||
height={34}
|
||||
viewBox='0 0 295 34'
|
||||
rects={RECTS.topRight}
|
||||
animState={states.topRight}
|
||||
globalOpacity={0.75}
|
||||
/>
|
||||
<BlockGroup width={295} height={34} viewBox='0 0 295 34' rects={RECTS.topRight} />
|
||||
</div>
|
||||
|
||||
<div className='-translate-y-1/2 absolute top-[50%] left-0 w-[calc(16px+1.25vw)] max-w-[34px] scale-x-[-1]'>
|
||||
<BlockGroup
|
||||
width={34}
|
||||
height={226}
|
||||
viewBox='0 0 34 226.021'
|
||||
rects={RECTS.left}
|
||||
animState={states.left}
|
||||
globalOpacity={0.75}
|
||||
/>
|
||||
<div className='-left-24 absolute bottom-0 w-[calc(140px+10.76vw)] max-w-[295px] rotate-180'>
|
||||
<BlockGroup width={295} height={34} viewBox='0 0 295 34' rects={RECTS.bottomLeft} />
|
||||
</div>
|
||||
|
||||
<div className='-translate-y-1/2 absolute top-[50%] right-0 w-[calc(16px+1.25vw)] max-w-[34px]'>
|
||||
<BlockGroup
|
||||
width={34}
|
||||
height={205}
|
||||
viewBox='0 0 34 204.769'
|
||||
rects={RECTS.right}
|
||||
animState={states.right}
|
||||
globalOpacity={0.75}
|
||||
/>
|
||||
<div className='-bottom-2 absolute right-0 w-[calc(16px+1.25vw)] max-w-[34px]'>
|
||||
<BlockGroup width={34} height={102} viewBox='0 0 34 102' rects={RECTS.bottomRight} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -29,8 +29,8 @@
|
||||
"next": "16.1.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"postgres": "^3.4.5",
|
||||
"react": "19.2.1",
|
||||
"react-dom": "19.2.1",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"shiki": "4.0.0",
|
||||
"tailwind-merge": "^3.0.2"
|
||||
},
|
||||
|
||||
@@ -2,32 +2,22 @@
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import AuthBackground from '@/app/(auth)/components/auth-background'
|
||||
import Nav from '@/app/(landing)/components/nav/nav'
|
||||
|
||||
function isColorDark(hexColor: string): boolean {
|
||||
const hex = hexColor.replace('#', '')
|
||||
const r = Number.parseInt(hex.substr(0, 2), 16)
|
||||
const g = Number.parseInt(hex.substr(2, 2), 16)
|
||||
const b = Number.parseInt(hex.substr(4, 2), 16)
|
||||
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
|
||||
return luminance < 0.5
|
||||
}
|
||||
import Navbar from '@/app/(home)/components/navbar/navbar'
|
||||
|
||||
export default function AuthLayoutClient({ children }: { children: React.ReactNode }) {
|
||||
useEffect(() => {
|
||||
const rootStyle = getComputedStyle(document.documentElement)
|
||||
const brandBackground = rootStyle.getPropertyValue('--brand-background-hex').trim()
|
||||
|
||||
if (brandBackground && isColorDark(brandBackground)) {
|
||||
document.body.classList.add('auth-dark-bg')
|
||||
} else {
|
||||
document.body.classList.remove('auth-dark-bg')
|
||||
document.documentElement.classList.add('dark')
|
||||
return () => {
|
||||
document.documentElement.classList.remove('dark')
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<AuthBackground>
|
||||
<main className='relative flex min-h-screen flex-col text-foreground'>
|
||||
<Nav hideAuthButtons={true} variant='auth' />
|
||||
<AuthBackground className='dark font-[430] font-season'>
|
||||
<main className='relative flex min-h-full flex-col text-[#ECECEC]'>
|
||||
<header className='shrink-0 bg-[#1C1C1C]'>
|
||||
<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'>{children}</div>
|
||||
</div>
|
||||
|
||||
@@ -1,44 +1,93 @@
|
||||
export default function AuthBackgroundSVG() {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden='true'
|
||||
className='pointer-events-none fixed inset-0 h-full w-full'
|
||||
style={{ zIndex: 5 }}
|
||||
viewBox='0 0 1880 960'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
preserveAspectRatio='xMidYMid slice'
|
||||
>
|
||||
{/* Right side paths - extended to connect */}
|
||||
<path
|
||||
d='M1393.53 42.8889C1545.99 173.087 1688.28 339.75 1878.44 817.6'
|
||||
stroke='#E7E4EF'
|
||||
strokeWidth='2'
|
||||
/>
|
||||
<path d='M1624.21 960L1625.78 0' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<path d='M1832.67 715.81L1880 716.031' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<path d='M1393.4 40V0' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<circle cx='1393.03' cy='40.0186' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<circle cx='1625.28' cy='303.147' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<circle cx='1837.37' cy='715.81' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<>
|
||||
{/* Top-left card outline */}
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='pointer-events-none absolute top-[-3vw] left-[-10vw] z-[5] aspect-[344/328] w-[38vw]'
|
||||
>
|
||||
<svg
|
||||
viewBox='0 0 344 328'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
preserveAspectRatio='xMidYMid meet'
|
||||
className='h-full w-full'
|
||||
>
|
||||
<path
|
||||
d='M322.641 326.586L335.508 326.586C339.926 326.586 343.508 323.004 343.508 318.586V153.613C343.508 149.195 339.926 145.613 335.508 145.613H228.282C223.864 145.613 220.282 142.031 220.282 137.613V-50H190.282V137.613C190.282 142.031 186.7 145.613 182.282 145.613H-157V318.586C-157 323.004 -153.418 326.586 -149 326.586H322.641Z'
|
||||
fill='#1C1C1C'
|
||||
stroke='#323232'
|
||||
strokeOpacity='0.4'
|
||||
strokeWidth='1'
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Left side paths - extended to connect */}
|
||||
<path
|
||||
d='M160 157.764C319.811 136.451 417.278 102.619 552.39 0'
|
||||
stroke='#E7E4EF'
|
||||
strokeWidth='2'
|
||||
/>
|
||||
<path d='M310.22 803.025V0' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<path
|
||||
d='M160 530.184C256.142 655.353 308.338 749.141 348.382 960'
|
||||
stroke='#E7E4EF'
|
||||
strokeWidth='2'
|
||||
/>
|
||||
<path d='M160 157.764V960' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<path d='M-50 157.764L160 157.764' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<circle cx='160' cy='157.764' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<circle cx='310.22' cy='803.025' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<circle cx='160' cy='530.184' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
|
||||
</svg>
|
||||
{/* Top-right card outline */}
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='pointer-events-none absolute top-[-4vw] right-[-14vw] z-[5] aspect-[471/470] w-[42vw]'
|
||||
>
|
||||
<svg
|
||||
viewBox='0 0 471 470'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
preserveAspectRatio='xMidYMid meet'
|
||||
className='h-full w-full'
|
||||
>
|
||||
<path
|
||||
d='M471 94.274L471 124.274L365.88 124.274C361.462 124.274 357.88 127.856 357.88 132.274L357.88 225.495C357.88 229.913 354.298 233.495 349.88 233.495L219.5 233.495C215.082 233.495 211.5 237.077 211.5 241.495L211.5 461.5C211.5 465.918 207.918 469.5 203.5 469.5L8.5 469.5C4.082 469.5 0.5 465.918 0.5 461.5L0.5 157.274C0.5 152.856 4.082 149.274 8.5 149.274L184 149.274C188.418 149.274 192 145.692 192 141.274L192 102.274C192 97.856 195.582 94.274 200 94.274L471 94.274Z'
|
||||
fill='#1C1C1C'
|
||||
stroke='#323232'
|
||||
strokeOpacity='0.4'
|
||||
strokeWidth='1'
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Bottom-left card outline (mirrored) */}
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='pointer-events-none absolute bottom-[-6vw] left-[-12vw] z-[5] aspect-[471/470] w-[36vw] rotate-180'
|
||||
>
|
||||
<svg
|
||||
viewBox='0 0 471 470'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
preserveAspectRatio='xMidYMid meet'
|
||||
className='h-full w-full'
|
||||
>
|
||||
<path
|
||||
d='M471 94.274L471 124.274L365.88 124.274C361.462 124.274 357.88 127.856 357.88 132.274L357.88 225.495C357.88 229.913 354.298 233.495 349.88 233.495L219.5 233.495C215.082 233.495 211.5 237.077 211.5 241.495L211.5 461.5C211.5 465.918 207.918 469.5 203.5 469.5L8.5 469.5C4.082 469.5 0.5 465.918 0.5 461.5L0.5 157.274C0.5 152.856 4.082 149.274 8.5 149.274L184 149.274C188.418 149.274 192 145.692 192 141.274L192 102.274C192 97.856 195.582 94.274 200 94.274L471 94.274Z'
|
||||
fill='#1C1C1C'
|
||||
stroke='#323232'
|
||||
strokeOpacity='0.4'
|
||||
strokeWidth='1'
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Bottom-right card outline (mirrored) */}
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='pointer-events-none absolute right-[-12vw] bottom-[-5vw] z-[5] aspect-[344/328] w-[34vw] rotate-180'
|
||||
>
|
||||
<svg
|
||||
viewBox='0 0 344 328'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
preserveAspectRatio='xMidYMid meet'
|
||||
className='h-full w-full'
|
||||
>
|
||||
<path
|
||||
d='M322.641 326.586L335.508 326.586C339.926 326.586 343.508 323.004 343.508 318.586V153.613C343.508 149.195 339.926 145.613 335.508 145.613H228.282C223.864 145.613 220.282 142.031 220.282 137.613V-50H190.282V137.613C190.282 142.031 186.7 145.613 182.282 145.613H-157V318.586C-157 323.004 -153.418 326.586 -149 326.586H322.641Z'
|
||||
fill='#1C1C1C'
|
||||
stroke='#323232'
|
||||
strokeOpacity='0.4'
|
||||
strokeWidth='1'
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,10 +8,10 @@ type AuthBackgroundProps = {
|
||||
|
||||
export default function AuthBackground({ className, children }: AuthBackgroundProps) {
|
||||
return (
|
||||
<div className={cn('relative min-h-screen w-full overflow-hidden', className)}>
|
||||
<div className='-z-50 pointer-events-none fixed inset-0 bg-white' />
|
||||
<div className={cn('fixed inset-0 overflow-hidden', className)}>
|
||||
<div className='-z-50 pointer-events-none absolute inset-0 bg-[#1C1C1C]' />
|
||||
<AuthBackgroundSVG />
|
||||
<div className='relative z-20'>{children}</div>
|
||||
<div className='relative z-20 h-full overflow-auto'>{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,36 +2,20 @@
|
||||
|
||||
import { forwardRef, useState } from 'react'
|
||||
import { ArrowRight, ChevronRight, Loader2 } from 'lucide-react'
|
||||
import { Button, type ButtonProps as EmcnButtonProps } from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { useBrandedButtonClass } from '@/hooks/use-branded-button-class'
|
||||
import { useBrandConfig } from '@/ee/whitelabeling'
|
||||
|
||||
export interface BrandedButtonProps extends Omit<EmcnButtonProps, 'variant' | 'size'> {
|
||||
/** Shows loading spinner and disables button */
|
||||
export interface BrandedButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
loading?: boolean
|
||||
/** Text to show when loading (appends "..." automatically) */
|
||||
loadingText?: string
|
||||
/** Show arrow animation on hover (default: true) */
|
||||
showArrow?: boolean
|
||||
/** Make button full width (default: true) */
|
||||
fullWidth?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Branded button for auth and status pages.
|
||||
* Automatically detects whitelabel customization and applies appropriate styling.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // Primary branded button with arrow
|
||||
* <BrandedButton onClick={handleSubmit}>Sign In</BrandedButton>
|
||||
*
|
||||
* // Loading state
|
||||
* <BrandedButton loading loadingText="Signing in">Sign In</BrandedButton>
|
||||
*
|
||||
* // Without arrow animation
|
||||
* <BrandedButton showArrow={false}>Continue</BrandedButton>
|
||||
* ```
|
||||
* 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>(
|
||||
(
|
||||
@@ -49,7 +33,8 @@ export const BrandedButton = forwardRef<HTMLButtonElement, BrandedButtonProps>(
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const buttonClass = useBrandedButtonClass()
|
||||
const brand = useBrandConfig()
|
||||
const hasCustomColor = brand.isWhitelabeled && Boolean(brand.theme?.primaryColor)
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
|
||||
const handleMouseEnter = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
@@ -63,15 +48,32 @@ export const BrandedButton = forwardRef<HTMLButtonElement, BrandedButtonProps>(
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
<button
|
||||
ref={ref}
|
||||
variant='branded'
|
||||
size='branded'
|
||||
{...props}
|
||||
disabled={disabled || loading}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
className={cn(buttonClass, 'group', fullWidth && 'w-full', className)}
|
||||
{...props}
|
||||
className={cn(
|
||||
'group inline-flex h-[30px] items-center justify-center gap-[7px] rounded-[5px] border px-[9px] text-[13.5px] 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'>
|
||||
@@ -92,7 +94,7 @@ export const BrandedButton = forwardRef<HTMLButtonElement, BrandedButtonProps>(
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</Button>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import { type ReactNode, useEffect, useState } from 'react'
|
||||
import { Button } from '@/components/emcn'
|
||||
import { GithubIcon, GoogleIcon } from '@/components/icons'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { client } from '@/lib/auth/auth-client'
|
||||
import { inter } from '@/app/_styles/fonts/inter/inter'
|
||||
|
||||
interface SocialLoginButtonsProps {
|
||||
githubAvailable: boolean
|
||||
@@ -82,7 +81,7 @@ export function SocialLoginButtons({
|
||||
const githubButton = (
|
||||
<Button
|
||||
variant='outline'
|
||||
className='w-full rounded-[10px] shadow-sm hover:bg-gray-50'
|
||||
className='w-full rounded-[10px]'
|
||||
disabled={!githubAvailable || isGithubLoading}
|
||||
onClick={signInWithGithub}
|
||||
>
|
||||
@@ -94,7 +93,7 @@ export function SocialLoginButtons({
|
||||
const googleButton = (
|
||||
<Button
|
||||
variant='outline'
|
||||
className='w-full rounded-[10px] shadow-sm hover:bg-gray-50'
|
||||
className='w-full rounded-[10px]'
|
||||
disabled={!googleAvailable || isGoogleLoading}
|
||||
onClick={signInWithGoogle}
|
||||
>
|
||||
@@ -110,7 +109,7 @@ export function SocialLoginButtons({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${inter.className} grid gap-3 font-light`}>
|
||||
<div className='grid gap-3 font-light'>
|
||||
{googleAvailable && googleButton}
|
||||
{githubAvailable && githubButton}
|
||||
{children}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Button } from '@/components/emcn'
|
||||
import { getEnv, isTruthy } from '@/lib/core/config/env'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
|
||||
@@ -38,7 +38,7 @@ export function SSOLoginButton({
|
||||
'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] shadow-sm hover:bg-gray-50')
|
||||
const outlineBtnClasses = cn('w-full rounded-[10px]')
|
||||
|
||||
return (
|
||||
<Button
|
||||
|
||||
@@ -1,69 +1,37 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { inter } from '@/app/_styles/fonts/inter/inter'
|
||||
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
||||
import AuthBackground from '@/app/(auth)/components/auth-background'
|
||||
import Nav from '@/app/(landing)/components/nav/nav'
|
||||
import Navbar from '@/app/(home)/components/navbar/navbar'
|
||||
import { SupportFooter } from './support-footer'
|
||||
|
||||
export interface StatusPageLayoutProps {
|
||||
/** Page title displayed prominently */
|
||||
title: string
|
||||
/** Description text below the title */
|
||||
description: string | ReactNode
|
||||
/** Content to render below the title/description (usually buttons) */
|
||||
children?: ReactNode
|
||||
/** Whether to show the support footer (default: true) */
|
||||
showSupportFooter?: boolean
|
||||
/** Whether to hide the nav bar (useful for embedded forms) */
|
||||
hideNav?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified layout for status/error pages (404, form unavailable, chat error, etc.).
|
||||
* Uses AuthBackground and Nav for consistent styling with auth pages.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <StatusPageLayout
|
||||
* title="Page Not Found"
|
||||
* description="The page you're looking for doesn't exist."
|
||||
* >
|
||||
* <BrandedButton onClick={() => router.push('/')}>Return to Home</BrandedButton>
|
||||
* </StatusPageLayout>
|
||||
* ```
|
||||
*/
|
||||
export function StatusPageLayout({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
showSupportFooter = true,
|
||||
hideNav = false,
|
||||
}: StatusPageLayoutProps) {
|
||||
return (
|
||||
<AuthBackground>
|
||||
<main className='relative flex min-h-screen flex-col text-foreground'>
|
||||
{!hideNav && <Nav hideAuthButtons={true} variant='auth' />}
|
||||
<AuthBackground className='dark font-[430] font-season'>
|
||||
<main className='relative flex min-h-full flex-col text-[#ECECEC]'>
|
||||
<header className='shrink-0 bg-[#1C1C1C]'>
|
||||
<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={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
|
||||
{description}
|
||||
</p>
|
||||
<h1 className='font-[500] text-[#ECECEC] text-[32px] tracking-tight'>{title}</h1>
|
||||
<p className='font-[380] text-[#999] text-[16px]'>{description}</p>
|
||||
</div>
|
||||
|
||||
{children && (
|
||||
<div className={`${inter.className} mt-8 w-full max-w-[410px] space-y-3`}>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
{children && <div className='mt-8 w-full max-w-[410px] space-y-3'>{children}</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,37 +1,22 @@
|
||||
'use client'
|
||||
|
||||
import { inter } from '@/app/_styles/fonts/inter/inter'
|
||||
import { useBrandConfig } from '@/ee/whitelabeling'
|
||||
|
||||
export interface SupportFooterProps {
|
||||
/** Position style - 'fixed' for pages without AuthLayout, 'absolute' for pages with AuthLayout */
|
||||
position?: 'fixed' | 'absolute'
|
||||
}
|
||||
|
||||
/**
|
||||
* Support footer component for auth and status pages.
|
||||
* Displays a "Need help? Contact support" link using branded support email.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // Fixed position (for standalone pages)
|
||||
* <SupportFooter />
|
||||
*
|
||||
* // Absolute position (for pages using AuthLayout)
|
||||
* <SupportFooter position="absolute" />
|
||||
* ```
|
||||
*/
|
||||
export function SupportFooter({ position = 'fixed' }: SupportFooterProps) {
|
||||
const brandConfig = useBrandConfig()
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${inter.className} auth-text-muted right-0 bottom-0 left-0 z-50 pb-8 text-center font-[340] text-[13px] leading-relaxed ${position}`}
|
||||
className={`right-0 bottom-0 left-0 z-50 pb-8 text-center font-[340] text-[#999] text-[13px] leading-relaxed ${position}`}
|
||||
>
|
||||
Need help?{' '}
|
||||
<a
|
||||
href={`mailto:${brandConfig.supportEmail}`}
|
||||
className='auth-link underline-offset-4 transition hover:underline'
|
||||
className='text-[#999] underline-offset-4 transition hover:text-[#ECECEC] hover:underline'
|
||||
>
|
||||
Contact support
|
||||
</a>
|
||||
|
||||
@@ -6,21 +6,19 @@ import { Eye, EyeOff } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
Input,
|
||||
Label,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalDescription,
|
||||
ModalHeader,
|
||||
} from '@/components/emcn'
|
||||
import { client } from '@/lib/auth/auth-client'
|
||||
import { getEnv, isFalsy, isTruthy } from '@/lib/core/config/env'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
||||
import { inter } from '@/app/_styles/fonts/inter/inter'
|
||||
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
||||
import { BrandedButton } from '@/app/(auth)/components/branded-button'
|
||||
import { SocialLoginButtons } from '@/app/(auth)/components/social-login-buttons'
|
||||
import { SSOLoginButton } from '@/app/(auth)/components/sso-login-button'
|
||||
@@ -385,17 +383,13 @@ export default function LoginPage({
|
||||
return (
|
||||
<>
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
|
||||
Sign in
|
||||
</h1>
|
||||
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
|
||||
Enter your details
|
||||
</p>
|
||||
<h1 className='font-[500] text-[#ECECEC] text-[32px] tracking-tight'>Sign in</h1>
|
||||
<p className='font-[380] text-[#999] text-[16px]'>Enter your details</p>
|
||||
</div>
|
||||
|
||||
{/* SSO Login Button (primary top-only when it is the only method) */}
|
||||
{showTopSSO && (
|
||||
<div className={`${inter.className} mt-8`}>
|
||||
<div className='mt-8'>
|
||||
<SSOLoginButton
|
||||
callbackURL={callbackUrl}
|
||||
variant='primary'
|
||||
@@ -406,14 +400,14 @@ export default function LoginPage({
|
||||
|
||||
{/* Password reset success message */}
|
||||
{resetSuccessMessage && (
|
||||
<div className={`${inter.className} mt-1 space-y-1 text-[#4CAF50] text-xs`}>
|
||||
<div className='mt-1 space-y-1 text-[#4CAF50] text-xs'>
|
||||
<p>{resetSuccessMessage}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Email/Password Form - show unless explicitly disabled */}
|
||||
{!isFalsy(getEnv('NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED')) && (
|
||||
<form onSubmit={onSubmit} className={`${inter.className} mt-8 space-y-8`}>
|
||||
<form onSubmit={onSubmit} className='mt-8 space-y-8'>
|
||||
<div className='space-y-6'>
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
@@ -430,10 +424,9 @@ export default function LoginPage({
|
||||
value={email}
|
||||
onChange={handleEmailChange}
|
||||
className={cn(
|
||||
'rounded-[10px] shadow-sm transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100',
|
||||
showEmailValidationError &&
|
||||
emailErrors.length > 0 &&
|
||||
'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500'
|
||||
'border-red-500 focus:border-red-500'
|
||||
)}
|
||||
/>
|
||||
{showEmailValidationError && emailErrors.length > 0 && (
|
||||
@@ -450,7 +443,7 @@ export default function LoginPage({
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setForgotPasswordOpen(true)}
|
||||
className='font-medium text-muted-foreground text-xs transition hover:text-foreground'
|
||||
className='font-medium text-[#999] text-xs transition hover:text-[#ECECEC]'
|
||||
>
|
||||
Forgot password?
|
||||
</button>
|
||||
@@ -468,16 +461,16 @@ export default function LoginPage({
|
||||
value={password}
|
||||
onChange={handlePasswordChange}
|
||||
className={cn(
|
||||
'rounded-[10px] pr-10 shadow-sm transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100',
|
||||
'pr-10',
|
||||
showValidationError &&
|
||||
passwordErrors.length > 0 &&
|
||||
'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500'
|
||||
'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-gray-500 transition hover:text-gray-700'
|
||||
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} />}
|
||||
@@ -506,18 +499,18 @@ export default function LoginPage({
|
||||
|
||||
{/* Divider - show when we have multiple auth methods */}
|
||||
{showDivider && (
|
||||
<div className={`${inter.className} relative my-6 font-light`}>
|
||||
<div className='relative my-6 font-light'>
|
||||
<div className='absolute inset-0 flex items-center'>
|
||||
<div className='auth-divider w-full border-t' />
|
||||
<div className='w-full border-[#2A2A2A] border-t' />
|
||||
</div>
|
||||
<div className='relative flex justify-center text-sm'>
|
||||
<span className='bg-white px-4 font-[340] text-muted-foreground'>Or continue with</span>
|
||||
<span className='bg-[#1C1C1C] px-4 font-[340] text-[#999]'>Or continue with</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showBottomSection && (
|
||||
<div className={cn(inter.className, !emailEnabled ? 'mt-8' : undefined)}>
|
||||
<div className={cn(!emailEnabled ? 'mt-8' : undefined)}>
|
||||
<SocialLoginButtons
|
||||
googleAvailable={googleAvailable}
|
||||
githubAvailable={githubAvailable}
|
||||
@@ -537,26 +530,24 @@ export default function LoginPage({
|
||||
|
||||
{/* Only show signup link if email/password signup is enabled */}
|
||||
{!isFalsy(getEnv('NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED')) && (
|
||||
<div className={`${inter.className} pt-6 text-center font-light text-[14px]`}>
|
||||
<div className='pt-6 text-center font-light text-[14px]'>
|
||||
<span className='font-normal'>Don't have an account? </span>
|
||||
<Link
|
||||
href={isInviteFlow ? `/signup?invite_flow=true&callbackUrl=${callbackUrl}` : '/signup'}
|
||||
className='font-medium text-[var(--brand-accent-hex)] underline-offset-4 transition hover:text-[var(--brand-accent-hover-hex)] hover:underline'
|
||||
className='font-medium text-[#ECECEC] underline-offset-4 transition hover:text-white hover:underline'
|
||||
>
|
||||
Sign up
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`${inter.className} auth-text-muted absolute right-0 bottom-0 left-0 px-8 pb-8 text-center font-[340] 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-[#999] text-[13px] leading-relaxed sm:px-8 md:px-[44px]'>
|
||||
By signing in, you agree to our{' '}
|
||||
<Link
|
||||
href='/terms'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='auth-link underline-offset-4 transition hover:underline'
|
||||
className='text-[#999] underline-offset-4 transition hover:text-[#ECECEC] hover:underline'
|
||||
>
|
||||
Terms of Service
|
||||
</Link>{' '}
|
||||
@@ -565,64 +556,58 @@ export default function LoginPage({
|
||||
href='/privacy'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='auth-link underline-offset-4 transition hover:underline'
|
||||
className='text-[#999] underline-offset-4 transition hover:text-[#ECECEC] hover:underline'
|
||||
>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Dialog open={forgotPasswordOpen} onOpenChange={setForgotPasswordOpen}>
|
||||
<DialogContent className='auth-card auth-card-shadow max-w-[540px] rounded-[10px] border backdrop-blur-sm'>
|
||||
<DialogHeader>
|
||||
<DialogTitle className='font-semibold text-black text-xl tracking-tight'>
|
||||
Reset Password
|
||||
</DialogTitle>
|
||||
<DialogDescription className='text-muted-foreground text-sm'>
|
||||
<Modal open={forgotPasswordOpen} onOpenChange={setForgotPasswordOpen}>
|
||||
<ModalContent className='dark' size='sm'>
|
||||
<ModalHeader>Reset Password</ModalHeader>
|
||||
<ModalBody>
|
||||
<ModalDescription className='mb-4 text-[var(--text-muted)] text-sm'>
|
||||
Enter your email address and we'll send you a link to reset your password if your
|
||||
account exists.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className='space-y-4'>
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
</ModalDescription>
|
||||
<div className='space-y-4'>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='reset-email'>Email</Label>
|
||||
</div>
|
||||
<Input
|
||||
id='reset-email'
|
||||
value={forgotPasswordEmail}
|
||||
onChange={(e) => setForgotPasswordEmail(e.target.value)}
|
||||
placeholder='Enter your email'
|
||||
required
|
||||
type='email'
|
||||
className={cn(
|
||||
'rounded-[10px] shadow-sm transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100',
|
||||
resetStatus.type === 'error' &&
|
||||
'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500'
|
||||
<Input
|
||||
id='reset-email'
|
||||
value={forgotPasswordEmail}
|
||||
onChange={(e) => setForgotPasswordEmail(e.target.value)}
|
||||
placeholder='Enter your email'
|
||||
required
|
||||
type='email'
|
||||
className={cn(
|
||||
resetStatus.type === 'error' && 'border-red-500 focus:border-red-500'
|
||||
)}
|
||||
/>
|
||||
{resetStatus.type === 'error' && (
|
||||
<div className='mt-1 text-red-400 text-xs'>
|
||||
<p>{resetStatus.message}</p>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{resetStatus.type === 'error' && (
|
||||
<div className='mt-1 space-y-1 text-red-400 text-xs'>
|
||||
</div>
|
||||
{resetStatus.type === 'success' && (
|
||||
<div className='mt-1 text-[#4CAF50] text-xs'>
|
||||
<p>{resetStatus.message}</p>
|
||||
</div>
|
||||
)}
|
||||
<BrandedButton
|
||||
type='button'
|
||||
onClick={handleForgotPassword}
|
||||
disabled={isSubmittingReset}
|
||||
loading={isSubmittingReset}
|
||||
loadingText='Sending'
|
||||
>
|
||||
Send Reset Link
|
||||
</BrandedButton>
|
||||
</div>
|
||||
{resetStatus.type === 'success' && (
|
||||
<div className='mt-1 space-y-1 text-[#4CAF50] text-xs'>
|
||||
<p>{resetStatus.message}</p>
|
||||
</div>
|
||||
)}
|
||||
<BrandedButton
|
||||
type='button'
|
||||
onClick={handleForgotPassword}
|
||||
disabled={isSubmittingReset}
|
||||
loading={isSubmittingReset}
|
||||
loadingText='Sending'
|
||||
>
|
||||
Send Reset Link
|
||||
</BrandedButton>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,8 +6,6 @@ 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 { inter } from '@/app/_styles/fonts/inter/inter'
|
||||
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
||||
import { BrandedButton } from '@/app/(auth)/components/branded-button'
|
||||
|
||||
const SCOPE_DESCRIPTIONS: Record<string, string> = {
|
||||
@@ -129,12 +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={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
|
||||
<h1 className={'font-[500] text-[#ECECEC] text-[32px] tracking-tight'}>
|
||||
Authorize Application
|
||||
</h1>
|
||||
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
|
||||
Loading application details...
|
||||
</p>
|
||||
<p className={'font-[380] text-[#999] text-[16px]'}>Loading application details...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -144,14 +140,12 @@ export default function OAuthConsentPage() {
|
||||
return (
|
||||
<div className='flex flex-col items-center justify-center'>
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
|
||||
<h1 className={'font-[500] text-[#ECECEC] text-[32px] tracking-tight'}>
|
||||
Authorization Error
|
||||
</h1>
|
||||
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
|
||||
{error}
|
||||
</p>
|
||||
<p className={'font-[380] text-[#999] text-[16px]'}>{error}</p>
|
||||
</div>
|
||||
<div className={`${inter.className} mt-8 w-full max-w-[410px] space-y-3`}>
|
||||
<div className='mt-8 w-full max-w-[410px] space-y-3'>
|
||||
<BrandedButton onClick={() => router.push('/')}>Return to Home</BrandedButton>
|
||||
</div>
|
||||
</div>
|
||||
@@ -172,11 +166,11 @@ export default function OAuthConsentPage() {
|
||||
className='rounded-[10px]'
|
||||
/>
|
||||
) : (
|
||||
<div className='flex h-12 w-12 items-center justify-center rounded-[10px] bg-muted font-medium text-[18px] text-muted-foreground'>
|
||||
<div className='flex h-12 w-12 items-center justify-center rounded-[10px] bg-[#2A2A2A] font-medium text-[#999] text-[18px]'>
|
||||
{(clientName ?? '?').charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<ArrowLeftRight className='h-5 w-5 text-muted-foreground' />
|
||||
<ArrowLeftRight className='h-5 w-5 text-[#999]' />
|
||||
<Image
|
||||
src='/new/logo/colorized-bg.svg'
|
||||
alt='Sim'
|
||||
@@ -187,19 +181,17 @@ export default function OAuthConsentPage() {
|
||||
</div>
|
||||
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
|
||||
<h1 className={'font-[500] text-[#ECECEC] text-[32px] tracking-tight'}>
|
||||
Authorize Application
|
||||
</h1>
|
||||
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
|
||||
<span className='font-medium text-foreground'>{clientName}</span> is requesting access to
|
||||
<p className={'font-[380] text-[#999] text-[16px]'}>
|
||||
<span className='font-medium text-[#ECECEC]'>{clientName}</span> is requesting access to
|
||||
your account
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{session?.user && (
|
||||
<div
|
||||
className={`${inter.className} mt-5 flex items-center gap-3 rounded-lg border px-4 py-3`}
|
||||
>
|
||||
<div className='mt-5 flex items-center gap-3 rounded-lg border border-[#2A2A2A] px-4 py-3'>
|
||||
{session.user.image ? (
|
||||
<Image
|
||||
src={session.user.image}
|
||||
@@ -210,7 +202,7 @@ export default function OAuthConsentPage() {
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<div className='flex h-8 w-8 items-center justify-center rounded-full bg-muted font-medium text-[13px] text-muted-foreground'>
|
||||
<div className='flex h-8 w-8 items-center justify-center rounded-full bg-[#2A2A2A] font-medium text-[#999] text-[13px]'>
|
||||
{(session.user.name ?? session.user.email ?? '?').charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
@@ -218,12 +210,12 @@ export default function OAuthConsentPage() {
|
||||
{session.user.name && (
|
||||
<p className='truncate font-medium text-[14px]'>{session.user.name}</p>
|
||||
)}
|
||||
<p className='truncate text-[13px] text-muted-foreground'>{session.user.email}</p>
|
||||
<p className='truncate text-[#999] text-[13px]'>{session.user.email}</p>
|
||||
</div>
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleSwitchAccount}
|
||||
className='ml-auto text-[13px] text-muted-foreground underline-offset-2 transition-colors hover:text-foreground hover:underline'
|
||||
className='ml-auto text-[#999] text-[13px] underline-offset-2 transition-colors hover:text-[#ECECEC] hover:underline'
|
||||
>
|
||||
Switch
|
||||
</button>
|
||||
@@ -231,15 +223,12 @@ export default function OAuthConsentPage() {
|
||||
)}
|
||||
|
||||
{scopes.length > 0 && (
|
||||
<div className={`${inter.className} mt-5 w-full max-w-[410px]`}>
|
||||
<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>
|
||||
<ul className='space-y-2'>
|
||||
{scopes.map((s) => (
|
||||
<li
|
||||
key={s}
|
||||
className='flex items-start gap-2 font-normal text-[13px] text-muted-foreground'
|
||||
>
|
||||
<li key={s} className='flex items-start gap-2 font-normal text-[#999] text-[13px]'>
|
||||
<span className='mt-0.5 text-green-500'>✓</span>
|
||||
<span>{SCOPE_DESCRIPTIONS[s] ?? s}</span>
|
||||
</li>
|
||||
@@ -249,7 +238,7 @@ export default function OAuthConsentPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`${inter.className} mt-6 flex w-full max-w-[410px] gap-3`}>
|
||||
<div className='mt-6 flex w-full max-w-[410px] gap-3'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='md'
|
||||
|
||||
@@ -4,8 +4,6 @@ import { Suspense, useEffect, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import Link from 'next/link'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { inter } from '@/app/_styles/fonts/inter/inter'
|
||||
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
||||
import { SetNewPasswordForm } from '@/app/(auth)/reset-password/reset-password-form'
|
||||
|
||||
const logger = createLogger('ResetPasswordPage')
|
||||
@@ -76,15 +74,13 @@ function ResetPasswordContent() {
|
||||
return (
|
||||
<>
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
|
||||
<h1 className='font-[500] text-[#ECECEC] text-[32px] tracking-tight'>
|
||||
Reset your password
|
||||
</h1>
|
||||
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
|
||||
Enter a new password for your account
|
||||
</p>
|
||||
<p className='font-[380] text-[#999] text-[16px]'>Enter a new password for your account</p>
|
||||
</div>
|
||||
|
||||
<div className={`${inter.className} mt-8`}>
|
||||
<div className='mt-8'>
|
||||
<SetNewPasswordForm
|
||||
token={token}
|
||||
onSubmit={handleResetPassword}
|
||||
@@ -94,10 +90,10 @@ function ResetPasswordContent() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`${inter.className} pt-6 text-center font-light text-[14px]`}>
|
||||
<div className='pt-6 text-center font-light text-[14px]'>
|
||||
<Link
|
||||
href='/login'
|
||||
className='font-medium text-[var(--brand-accent-hex)] underline-offset-4 transition hover:text-[var(--brand-accent-hover-hex)] hover:underline'
|
||||
className='font-medium text-[#ECECEC] underline-offset-4 transition hover:text-white hover:underline'
|
||||
>
|
||||
Back to login
|
||||
</Link>
|
||||
|
||||
@@ -2,10 +2,8 @@
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Input, Label } from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { inter } from '@/app/_styles/fonts/inter/inter'
|
||||
import { BrandedButton } from '@/app/(auth)/components/branded-button'
|
||||
|
||||
interface RequestResetFormProps {
|
||||
@@ -33,7 +31,7 @@ export function RequestResetForm({
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className={cn(`${inter.className} space-y-8`, className)}>
|
||||
<form onSubmit={handleSubmit} className={cn('space-y-8', className)}>
|
||||
<div className='space-y-6'>
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
@@ -47,9 +45,8 @@ export function RequestResetForm({
|
||||
type='email'
|
||||
disabled={isSubmitting}
|
||||
required
|
||||
className='rounded-[10px] shadow-sm transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100'
|
||||
/>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
<p className='text-[#999] text-sm'>
|
||||
We'll send a password reset link to this email address.
|
||||
</p>
|
||||
</div>
|
||||
@@ -142,7 +139,7 @@ export function SetNewPasswordForm({
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className={cn(`${inter.className} space-y-8`, className)}>
|
||||
<form onSubmit={handleSubmit} className={cn('space-y-8', className)}>
|
||||
<div className='space-y-6'>
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
@@ -160,16 +157,12 @@ export function SetNewPasswordForm({
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
placeholder='Enter new password'
|
||||
className={cn(
|
||||
'rounded-[10px] pr-10 shadow-sm transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100',
|
||||
validationMessage &&
|
||||
'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500'
|
||||
)}
|
||||
className={cn('pr-10', validationMessage && '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-gray-500 transition hover:text-gray-700'
|
||||
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} />}
|
||||
@@ -192,16 +185,12 @@ export function SetNewPasswordForm({
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
placeholder='Confirm new password'
|
||||
className={cn(
|
||||
'rounded-[10px] pr-10 shadow-sm transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100',
|
||||
validationMessage &&
|
||||
'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500'
|
||||
)}
|
||||
className={cn('pr-10', validationMessage && 'border-red-500 focus:border-red-500')}
|
||||
/>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
className='-translate-y-1/2 absolute top-1/2 right-3 text-gray-500 transition hover:text-gray-700'
|
||||
className='-translate-y-1/2 absolute top-1/2 right-3 text-[#999] transition hover:text-[#ECECEC]'
|
||||
aria-label={showConfirmPassword ? 'Hide password' : 'Show password'}
|
||||
>
|
||||
{showConfirmPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
|
||||
@@ -5,14 +5,11 @@ import { createLogger } from '@sim/logger'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Input, Label } from '@/components/emcn'
|
||||
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 { inter } from '@/app/_styles/fonts/inter/inter'
|
||||
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
||||
import { BrandedButton } from '@/app/(auth)/components/branded-button'
|
||||
import { SocialLoginButtons } from '@/app/(auth)/components/social-login-buttons'
|
||||
import { SSOLoginButton } from '@/app/(auth)/components/sso-login-button'
|
||||
@@ -344,12 +341,8 @@ function SignupFormContent({
|
||||
return (
|
||||
<>
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
|
||||
Create an account
|
||||
</h1>
|
||||
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
|
||||
Create an account or log in
|
||||
</p>
|
||||
<h1 className='font-[500] text-[#ECECEC] text-[32px] tracking-tight'>Create an account</h1>
|
||||
<p className='font-[380] text-[#999] text-[16px]'>Create an account or log in</p>
|
||||
</div>
|
||||
|
||||
{/* SSO Login Button (primary top-only when it is the only method) */}
|
||||
@@ -360,7 +353,7 @@ function SignupFormContent({
|
||||
const hasOnlySSO = ssoEnabled && !emailEnabled && !hasSocial
|
||||
return hasOnlySSO
|
||||
})() && (
|
||||
<div className={`${inter.className} mt-8`}>
|
||||
<div className='mt-8'>
|
||||
<SSOLoginButton
|
||||
callbackURL={redirectUrl || '/workspace'}
|
||||
variant='primary'
|
||||
@@ -371,7 +364,7 @@ function SignupFormContent({
|
||||
|
||||
{/* Email/Password Form - show unless explicitly disabled */}
|
||||
{!isFalsy(getEnv('NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED')) && (
|
||||
<form onSubmit={onSubmit} className={`${inter.className} mt-8 space-y-8`}>
|
||||
<form onSubmit={onSubmit} className='mt-8 space-y-8'>
|
||||
<div className='space-y-6'>
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
@@ -388,10 +381,9 @@ function SignupFormContent({
|
||||
value={name}
|
||||
onChange={handleNameChange}
|
||||
className={cn(
|
||||
'rounded-[10px] shadow-sm transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100',
|
||||
showNameValidationError &&
|
||||
nameErrors.length > 0 &&
|
||||
'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500'
|
||||
'border-red-500 focus:border-red-500'
|
||||
)}
|
||||
/>
|
||||
{showNameValidationError && nameErrors.length > 0 && (
|
||||
@@ -416,9 +408,8 @@ function SignupFormContent({
|
||||
value={email}
|
||||
onChange={handleEmailChange}
|
||||
className={cn(
|
||||
'rounded-[10px] shadow-sm transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100',
|
||||
(emailError || (showEmailValidationError && emailErrors.length > 0)) &&
|
||||
'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500'
|
||||
'border-red-500 focus:border-red-500'
|
||||
)}
|
||||
/>
|
||||
{showEmailValidationError && emailErrors.length > 0 && (
|
||||
@@ -450,16 +441,16 @@ function SignupFormContent({
|
||||
value={password}
|
||||
onChange={handlePasswordChange}
|
||||
className={cn(
|
||||
'rounded-[10px] pr-10 shadow-sm transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100',
|
||||
'pr-10',
|
||||
showValidationError &&
|
||||
passwordErrors.length > 0 &&
|
||||
'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500'
|
||||
'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-gray-500 transition hover:text-gray-700'
|
||||
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} />}
|
||||
@@ -496,12 +487,12 @@ function SignupFormContent({
|
||||
const showDivider = (emailEnabled || hasOnlySSO) && showBottomSection
|
||||
return showDivider
|
||||
})() && (
|
||||
<div className={`${inter.className} relative my-6 font-light`}>
|
||||
<div className='relative my-6 font-light'>
|
||||
<div className='absolute inset-0 flex items-center'>
|
||||
<div className='auth-divider w-full border-t' />
|
||||
<div className='w-full border-[#2A2A2A] border-t' />
|
||||
</div>
|
||||
<div className='relative flex justify-center text-sm'>
|
||||
<span className='bg-white px-4 font-[340] text-muted-foreground'>Or continue with</span>
|
||||
<span className='bg-[#1C1C1C] px-4 font-[340] text-[#999]'>Or continue with</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -516,7 +507,6 @@ function SignupFormContent({
|
||||
})() && (
|
||||
<div
|
||||
className={cn(
|
||||
inter.className,
|
||||
isFalsy(getEnv('NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED')) ? 'mt-8' : undefined
|
||||
)}
|
||||
>
|
||||
@@ -537,25 +527,23 @@ function SignupFormContent({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`${inter.className} pt-6 text-center font-light text-[14px]`}>
|
||||
<div className='pt-6 text-center font-light text-[14px]'>
|
||||
<span className='font-normal'>Already have an account? </span>
|
||||
<Link
|
||||
href={isInviteFlow ? `/login?invite_flow=true&callbackUrl=${redirectUrl}` : '/login'}
|
||||
className='font-medium text-[var(--brand-accent-hex)] underline-offset-4 transition hover:text-[var(--brand-accent-hover-hex)] hover:underline'
|
||||
className='font-medium text-[#ECECEC] underline-offset-4 transition hover:text-white hover:underline'
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`${inter.className} auth-text-muted absolute right-0 bottom-0 left-0 px-8 pb-8 text-center font-[340] 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-[#999] text-[13px] leading-relaxed sm:px-8 md:px-[44px]'>
|
||||
By creating an account, you agree to our{' '}
|
||||
<Link
|
||||
href='/terms'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='auth-link underline-offset-4 transition hover:underline'
|
||||
className='text-[#999] underline-offset-4 transition hover:text-[#ECECEC] hover:underline'
|
||||
>
|
||||
Terms of Service
|
||||
</Link>{' '}
|
||||
@@ -564,7 +552,7 @@ function SignupFormContent({
|
||||
href='/privacy'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='auth-link underline-offset-4 transition hover:underline'
|
||||
className='text-[#999] underline-offset-4 transition hover:text-[#ECECEC] hover:underline'
|
||||
>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
|
||||
@@ -2,13 +2,10 @@
|
||||
|
||||
import { Suspense, useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/ui/input-otp'
|
||||
import { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { inter } from '@/app/_styles/fonts/inter/inter'
|
||||
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
||||
import { BrandedButton } from '@/app/(auth)/components/branded-button'
|
||||
import { useVerification } from '@/app/(auth)/verify/use-verification'
|
||||
import { useBrandedButtonClass } from '@/hooks/use-branded-button-class'
|
||||
|
||||
interface VerifyContentProps {
|
||||
hasEmailService: boolean
|
||||
@@ -59,15 +56,13 @@ function VerificationForm({
|
||||
setCountdown(30)
|
||||
}
|
||||
|
||||
const buttonClass = useBrandedButtonClass()
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
|
||||
<h1 className='font-[500] text-[#ECECEC] text-[32px] tracking-tight'>
|
||||
{isVerified ? 'Email Verified!' : 'Verify Your Email'}
|
||||
</h1>
|
||||
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
|
||||
<p className='font-[380] text-[#999] text-[16px]'>
|
||||
{isVerified
|
||||
? 'Your email has been verified. Redirecting to dashboard...'
|
||||
: !isEmailVerificationEnabled
|
||||
@@ -81,9 +76,9 @@ function VerificationForm({
|
||||
</div>
|
||||
|
||||
{!isVerified && isEmailVerificationEnabled && (
|
||||
<div className={`${inter.className} mt-8 space-y-8`}>
|
||||
<div className='mt-8 space-y-8'>
|
||||
<div className='space-y-6'>
|
||||
<p className='text-center text-muted-foreground text-sm'>
|
||||
<p className='text-center text-[#999] 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>
|
||||
@@ -96,61 +91,13 @@ function VerificationForm({
|
||||
disabled={isLoading}
|
||||
className={cn('gap-2', isInvalidOtp && 'otp-error')}
|
||||
>
|
||||
<InputOTPGroup className='[&>div]:!rounded-[10px] gap-2'>
|
||||
<InputOTPSlot
|
||||
index={0}
|
||||
className={cn(
|
||||
'!rounded-[10px] h-12 w-12 border bg-white text-center font-medium text-lg shadow-sm transition-all duration-200',
|
||||
'border-gray-300 hover:border-gray-400',
|
||||
'focus:border-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-100',
|
||||
isInvalidOtp && 'border-red-500 focus:border-red-500 focus:ring-red-100'
|
||||
)}
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={1}
|
||||
className={cn(
|
||||
'!rounded-[10px] h-12 w-12 border bg-white text-center font-medium text-lg shadow-sm transition-all duration-200',
|
||||
'border-gray-300 hover:border-gray-400',
|
||||
'focus:border-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-100',
|
||||
isInvalidOtp && 'border-red-500 focus:border-red-500 focus:ring-red-100'
|
||||
)}
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={2}
|
||||
className={cn(
|
||||
'!rounded-[10px] h-12 w-12 border bg-white text-center font-medium text-lg shadow-sm transition-all duration-200',
|
||||
'border-gray-300 hover:border-gray-400',
|
||||
'focus:border-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-100',
|
||||
isInvalidOtp && 'border-red-500 focus:border-red-500 focus:ring-red-100'
|
||||
)}
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={3}
|
||||
className={cn(
|
||||
'!rounded-[10px] h-12 w-12 border bg-white text-center font-medium text-lg shadow-sm transition-all duration-200',
|
||||
'border-gray-300 hover:border-gray-400',
|
||||
'focus:border-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-100',
|
||||
isInvalidOtp && 'border-red-500 focus:border-red-500 focus:ring-red-100'
|
||||
)}
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={4}
|
||||
className={cn(
|
||||
'!rounded-[10px] h-12 w-12 border bg-white text-center font-medium text-lg shadow-sm transition-all duration-200',
|
||||
'border-gray-300 hover:border-gray-400',
|
||||
'focus:border-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-100',
|
||||
isInvalidOtp && 'border-red-500 focus:border-red-500 focus:ring-red-100'
|
||||
)}
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={5}
|
||||
className={cn(
|
||||
'!rounded-[10px] h-12 w-12 border bg-white text-center font-medium text-lg shadow-sm transition-all duration-200',
|
||||
'border-gray-300 hover:border-gray-400',
|
||||
'focus:border-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-100',
|
||||
isInvalidOtp && 'border-red-500 focus:border-red-500 focus:ring-red-100'
|
||||
)}
|
||||
/>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={0} className={cn(isInvalidOtp && 'border-red-500')} />
|
||||
<InputOTPSlot index={1} className={cn(isInvalidOtp && 'border-red-500')} />
|
||||
<InputOTPSlot index={2} className={cn(isInvalidOtp && 'border-red-500')} />
|
||||
<InputOTPSlot index={3} className={cn(isInvalidOtp && 'border-red-500')} />
|
||||
<InputOTPSlot index={4} className={cn(isInvalidOtp && 'border-red-500')} />
|
||||
<InputOTPSlot index={5} className={cn(isInvalidOtp && 'border-red-500')} />
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</div>
|
||||
@@ -163,25 +110,27 @@ function VerificationForm({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
<BrandedButton
|
||||
onClick={verifyCode}
|
||||
className={`${buttonClass} flex w-full items-center justify-center gap-2 rounded-[10px] border font-medium text-[15px] text-white transition-all duration-200`}
|
||||
disabled={!isOtpComplete || isLoading}
|
||||
loading={isLoading}
|
||||
loadingText='Verifying'
|
||||
showArrow={false}
|
||||
>
|
||||
{isLoading ? 'Verifying...' : 'Verify Email'}
|
||||
</Button>
|
||||
Verify Email
|
||||
</BrandedButton>
|
||||
|
||||
{hasEmailService && (
|
||||
<div className='text-center'>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
<p className='text-[#999] text-sm'>
|
||||
Didn't receive a code?{' '}
|
||||
{countdown > 0 ? (
|
||||
<span>
|
||||
Resend in <span className='font-medium text-foreground'>{countdown}s</span>
|
||||
Resend in <span className='font-medium text-[#ECECEC]'>{countdown}s</span>
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
className='font-medium text-[var(--brand-accent-hex)] underline-offset-4 transition hover:text-[var(--brand-accent-hover-hex)] hover:underline'
|
||||
className='font-medium text-[#ECECEC] underline-offset-4 transition hover:text-white hover:underline'
|
||||
onClick={handleResend}
|
||||
disabled={isLoading || isResendDisabled}
|
||||
>
|
||||
@@ -202,7 +151,7 @@ function VerificationForm({
|
||||
}
|
||||
router.push('/signup')
|
||||
}}
|
||||
className='font-medium text-[var(--brand-accent-hex)] underline-offset-4 transition hover:text-[var(--brand-accent-hover-hex)] hover:underline'
|
||||
className='font-medium text-[#ECECEC] underline-offset-4 transition hover:text-white hover:underline'
|
||||
>
|
||||
Back to signup
|
||||
</button>
|
||||
@@ -217,8 +166,8 @@ function VerificationFormFallback() {
|
||||
return (
|
||||
<div className='text-center'>
|
||||
<div className='animate-pulse'>
|
||||
<div className='mx-auto mb-4 h-8 w-48 rounded bg-gray-200' />
|
||||
<div className='mx-auto h-4 w-64 rounded bg-gray-200' />
|
||||
<div className='mx-auto mb-4 h-8 w-48 rounded bg-[#2A2A2A]' />
|
||||
<div className='mx-auto h-4 w-64 rounded bg-[#2A2A2A]' />
|
||||
</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-[2px] w-[2px] rounded-full bg-[#2A2A2A]' />
|
||||
<div key={i} className='h-[1.5px] w-[1.5px] rounded-full bg-[#2A2A2A]' />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
@@ -268,13 +268,13 @@ export default function Collaboration() {
|
||||
collaboration
|
||||
</h2>
|
||||
|
||||
<p className='font-[430] font-season text-[#F6F6F0]/50 text-[14px] leading-[125%] tracking-[0.02em] sm:text-[16px]'>
|
||||
<p className='font-[430] font-season text-[#F6F6F0]/50 text-[18px] leading-[150%] tracking-[0.02em]'>
|
||||
Grab your team. Build agents together <br /> 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-[#33C482] bg-[#33C482] px-[10px] font-[430] font-season text-[14px] text-black transition-[filter] hover:brightness-110'
|
||||
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]'
|
||||
>
|
||||
Build together
|
||||
<span className='relative h-[10px] w-[10px] shrink-0'>
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
'use client'
|
||||
|
||||
import { type SVGProps, useRef } from 'react'
|
||||
import { motion, useInView } from 'framer-motion'
|
||||
import { ChevronDown } from '@/components/emcn'
|
||||
import { Database, File, Library, Table } from '@/components/emcn/icons'
|
||||
import {
|
||||
AnthropicIcon,
|
||||
GeminiIcon,
|
||||
GmailIcon,
|
||||
GroqIcon,
|
||||
HubspotIcon,
|
||||
OpenAIIcon,
|
||||
SalesforceIcon,
|
||||
SlackIcon,
|
||||
xAIIcon,
|
||||
} from '@/components/icons'
|
||||
|
||||
interface IconEntry {
|
||||
key: string
|
||||
icon: React.ComponentType<SVGProps<SVGSVGElement>>
|
||||
label: string
|
||||
top: string
|
||||
left: string
|
||||
color?: string
|
||||
}
|
||||
|
||||
const SCATTERED_ICONS: IconEntry[] = [
|
||||
{ key: 'slack', icon: SlackIcon, label: 'Slack', top: '8%', left: '14%' },
|
||||
{ key: 'openai', icon: OpenAIIcon, label: 'OpenAI', top: '8%', left: '44%' },
|
||||
{ key: 'anthropic', icon: AnthropicIcon, label: 'Anthropic', top: '10%', left: '78%' },
|
||||
{ key: 'gmail', icon: GmailIcon, label: 'Gmail', top: '24%', left: '90%' },
|
||||
{ key: 'salesforce', icon: SalesforceIcon, label: 'Salesforce', top: '28%', left: '6%' },
|
||||
{ key: 'table', icon: Table, label: 'Tables', top: '22%', left: '30%' },
|
||||
{ key: 'xai', icon: xAIIcon, label: 'xAI', top: '26%', left: '66%' },
|
||||
{ key: 'hubspot', icon: HubspotIcon, label: 'HubSpot', top: '55%', left: '4%', color: '#FF7A59' },
|
||||
{ key: 'database', icon: Database, label: 'Database', top: '74%', left: '68%' },
|
||||
{ key: 'file', icon: File, label: 'Files', top: '70%', left: '18%' },
|
||||
{ key: 'gemini', icon: GeminiIcon, label: 'Gemini', top: '58%', left: '86%' },
|
||||
{ key: 'logs', icon: Library, label: 'Logs', top: '86%', left: '44%' },
|
||||
{ key: 'groq', icon: GroqIcon, label: 'Groq', top: '90%', left: '82%' },
|
||||
]
|
||||
|
||||
const EXPLODE_STAGGER = 0.04
|
||||
const EXPLODE_BASE_DELAY = 0.1
|
||||
|
||||
export function FeaturesPreview() {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const inView = useInView(containerRef, { once: true, margin: '-80px' })
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className='relative h-[560px] w-full overflow-hidden'>
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='absolute inset-0'
|
||||
style={{
|
||||
backgroundImage: 'radial-gradient(circle, #D4D4D4 0.75px, transparent 0.75px)',
|
||||
backgroundSize: '12px 12px',
|
||||
maskImage: 'radial-gradient(ellipse 70% 65% at 48% 50%, black 30%, transparent 80%)',
|
||||
WebkitMaskImage:
|
||||
'radial-gradient(ellipse 70% 65% at 48% 50%, black 30%, transparent 80%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{SCATTERED_ICONS.map(({ key, icon: Icon, label, top, left, color }, index) => {
|
||||
const explodeDelay = EXPLODE_BASE_DELAY + index * EXPLODE_STAGGER
|
||||
|
||||
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)]'
|
||||
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,
|
||||
delay: explodeDelay,
|
||||
}}
|
||||
style={{ color }}
|
||||
aria-label={label}
|
||||
>
|
||||
<Icon className='h-6 w-6' />
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
|
||||
<motion.div
|
||||
className='absolute top-1/2 left-[48%]'
|
||||
initial={{ opacity: 0, x: '-50%', y: '-50%' }}
|
||||
animate={inView ? { opacity: 1, x: '-50%', y: '-50%' } : undefined}
|
||||
transition={{ duration: 0.4, ease: 'easeOut', delay: 0 }}
|
||||
>
|
||||
<div className='flex h-[36px] items-center gap-[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-[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
|
||||
d='M1 9C1 4.58 4.58 1 9 1'
|
||||
stroke='white'
|
||||
strokeWidth='1.8'
|
||||
strokeLinecap='round'
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<span className='whitespace-nowrap font-medium font-season text-[#1C1C1C] text-[13px] tracking-[0.02em]'>
|
||||
My Workspace
|
||||
</span>
|
||||
<ChevronDown className='h-[8px] w-[10px] flex-shrink-0 text-[#999]' />
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRef, useState } from 'react'
|
||||
import { type MotionValue, motion, useScroll, useTransform } from 'framer-motion'
|
||||
import Image from 'next/image'
|
||||
import { Badge } from '@/components/emcn'
|
||||
import Link from 'next/link'
|
||||
import { Badge, ChevronDown } from '@/components/emcn'
|
||||
import { FeaturesPreview } from '@/app/(home)/components/features/components/features-preview'
|
||||
|
||||
function hexToRgba(hex: string, alpha: number): string {
|
||||
const r = Number.parseInt(hex.slice(1, 3), 16)
|
||||
@@ -13,8 +16,12 @@ function hexToRgba(hex: string, alpha: number): string {
|
||||
|
||||
const FEATURE_TABS = [
|
||||
{
|
||||
label: 'Integrations',
|
||||
label: 'Mothership',
|
||||
color: '#FA4EDF',
|
||||
title: 'Your AI command center',
|
||||
description:
|
||||
'Direct your entire AI workforce from one place. Build agents, spin up workflows, query tables, and manage every resource across your workspace — in natural language.',
|
||||
cta: 'Explore mothership',
|
||||
segments: [
|
||||
[0.3, 8],
|
||||
[0.25, 10],
|
||||
@@ -29,8 +36,12 @@ const FEATURE_TABS = [
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Copilot',
|
||||
label: 'Tables',
|
||||
color: '#2ABBF8',
|
||||
title: 'A database, built in',
|
||||
description:
|
||||
'Filter, sort, and edit data inline, then wire it directly into your workflows. Agents query, insert, and update rows on every run — no external database needed.',
|
||||
cta: 'Explore tables',
|
||||
segments: [
|
||||
[0.25, 12],
|
||||
[0.4, 10],
|
||||
@@ -44,59 +55,33 @@ const FEATURE_TABS = [
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Models',
|
||||
color: '#00F701',
|
||||
badgeColor: '#22C55E',
|
||||
segments: [
|
||||
[0.2, 6],
|
||||
[0.35, 10],
|
||||
[0.3, 8],
|
||||
[0.5, 10],
|
||||
[0.6, 8],
|
||||
[0.75, 12],
|
||||
[0.85, 10],
|
||||
[1, 8],
|
||||
[0.9, 12],
|
||||
[1, 10],
|
||||
[0.95, 6],
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Deploy',
|
||||
label: 'Files',
|
||||
color: '#FFCC02',
|
||||
badgeColor: '#EAB308',
|
||||
segments: [
|
||||
[0.3, 12],
|
||||
[0.25, 8],
|
||||
[0.4, 10],
|
||||
[0.55, 10],
|
||||
[0.7, 8],
|
||||
[0.6, 10],
|
||||
[0.85, 12],
|
||||
[1, 10],
|
||||
[0.9, 10],
|
||||
[1, 10],
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Logs',
|
||||
color: '#FF6B35',
|
||||
title: 'Upload, create, and share',
|
||||
description:
|
||||
'Create or upload documents, spreadsheets, and media that agents can read, write, and reference across workflows. One shared store your entire team and every agent can pull from.',
|
||||
cta: 'Explore files',
|
||||
segments: [
|
||||
[0.25, 10],
|
||||
[0.35, 8],
|
||||
[0.3, 10],
|
||||
[0.4, 8],
|
||||
[0.35, 12],
|
||||
[0.5, 10],
|
||||
[0.65, 8],
|
||||
[0.8, 12],
|
||||
[0.9, 10],
|
||||
[0.75, 10],
|
||||
[0.9, 12],
|
||||
[1, 10],
|
||||
[0.85, 12],
|
||||
[0.85, 10],
|
||||
[1, 10],
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Knowledge Base',
|
||||
color: '#8B5CF6',
|
||||
title: 'Your context engine',
|
||||
description:
|
||||
'Sync institutional knowledge from 30+ live connectors — Notion, Drive, Slack, Confluence, and more — so every agent draws from the same truth across your entire organization.',
|
||||
cta: 'Explore knowledge base',
|
||||
segments: [
|
||||
[0.3, 10],
|
||||
[0.25, 8],
|
||||
@@ -110,8 +95,47 @@ const FEATURE_TABS = [
|
||||
[1, 10],
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Logs',
|
||||
color: '#FF6B35',
|
||||
title: 'Full visibility, every run',
|
||||
description:
|
||||
'Trace every execution block by block — inputs, outputs, cost, and duration. Filter by status or workflow, replay snapshots, and export reports to keep your team accountable.',
|
||||
cta: 'Explore logs',
|
||||
segments: [
|
||||
[0.25, 10],
|
||||
[0.35, 8],
|
||||
[0.3, 10],
|
||||
[0.5, 10],
|
||||
[0.65, 8],
|
||||
[0.8, 12],
|
||||
[0.9, 10],
|
||||
[1, 10],
|
||||
[0.85, 12],
|
||||
[1, 10],
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const HEADING_TEXT = 'Everything you need to build, deploy, and manage AI agents. '
|
||||
const HEADING_LETTERS = HEADING_TEXT.split('')
|
||||
|
||||
const LETTER_REVEAL_SPAN = 0.85
|
||||
const LETTER_FADE_IN = 0.04
|
||||
|
||||
interface ScrollLetterProps {
|
||||
scrollYProgress: MotionValue<number>
|
||||
charIndex: number
|
||||
children: string
|
||||
}
|
||||
|
||||
function ScrollLetter({ scrollYProgress, charIndex, children }: ScrollLetterProps) {
|
||||
const threshold = (charIndex / HEADING_LETTERS.length) * LETTER_REVEAL_SPAN
|
||||
const opacity = useTransform(scrollYProgress, [threshold, threshold + LETTER_FADE_IN], [0.4, 1])
|
||||
|
||||
return <motion.span style={{ opacity }}>{children}</motion.span>
|
||||
}
|
||||
|
||||
function DotGrid({
|
||||
cols,
|
||||
rows,
|
||||
@@ -126,7 +150,7 @@ function DotGrid({
|
||||
return (
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className={`shrink-0 bg-[#FDFDFD] p-[6px] ${borderLeft ? 'border-[#E9E9E9] border-l' : ''}`}
|
||||
className={`shrink-0 bg-[#F6F6F6] p-[6px] ${borderLeft ? 'border-[#E9E9E9] border-l' : ''}`}
|
||||
style={{
|
||||
width: width ? `${width}px` : undefined,
|
||||
display: 'grid',
|
||||
@@ -136,20 +160,26 @@ function DotGrid({
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: cols * rows }, (_, i) => (
|
||||
<div key={i} className='h-[2px] w-[2px] rounded-full bg-[#DEDEDE]' />
|
||||
<div key={i} className='h-[1.5px] w-[1.5px] rounded-full bg-[#DEDEDE]' />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Features() {
|
||||
const sectionRef = useRef<HTMLDivElement>(null)
|
||||
const [activeTab, setActiveTab] = useState(0)
|
||||
|
||||
const { scrollYProgress } = useScroll({
|
||||
target: sectionRef,
|
||||
offset: ['start 0.9', 'start 0.2'],
|
||||
})
|
||||
|
||||
return (
|
||||
<section
|
||||
id='features'
|
||||
aria-labelledby='features-heading'
|
||||
className='relative overflow-hidden bg-[#F6F6F6] pb-[144px]'
|
||||
className='relative overflow-hidden bg-[#F6F6F6]'
|
||||
>
|
||||
<div aria-hidden='true' className='absolute top-0 left-0 w-full'>
|
||||
<Image
|
||||
@@ -163,7 +193,7 @@ export default function Features() {
|
||||
</div>
|
||||
|
||||
<div className='relative z-10 pt-[100px]'>
|
||||
<div className='flex flex-col items-start gap-[20px] px-[80px]'>
|
||||
<div ref={sectionRef} className='flex flex-col items-start gap-[20px] px-[80px]'>
|
||||
<Badge
|
||||
variant='blue'
|
||||
size='md'
|
||||
@@ -177,51 +207,110 @@ export default function Features() {
|
||||
),
|
||||
}}
|
||||
>
|
||||
Features
|
||||
Workspace
|
||||
</Badge>
|
||||
<h2
|
||||
id='features-heading'
|
||||
className='font-[430] font-season text-[#1C1C1C] text-[40px] leading-[100%] tracking-[-0.02em]'
|
||||
className='max-w-[900px] font-[430] font-season text-[#1C1C1C] text-[40px] leading-[110%] tracking-[-0.02em]'
|
||||
>
|
||||
Power your AI workforce
|
||||
{HEADING_LETTERS.map((char, i) => (
|
||||
<ScrollLetter key={i} scrollYProgress={scrollYProgress} charIndex={i}>
|
||||
{char}
|
||||
</ScrollLetter>
|
||||
))}
|
||||
<span className='text-[#1C1C1C]/40'>
|
||||
Design powerful workflows, connect your data, and monitor every run — all in one
|
||||
platform.
|
||||
</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className='mt-[73px] flex h-[68px] overflow-hidden border border-[#E9E9E9]'>
|
||||
<DotGrid cols={10} rows={8} width={80} />
|
||||
<div className='relative mt-[73px] pb-[80px]'>
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='absolute top-0 bottom-0 left-[80px] z-20 w-px bg-[#E9E9E9]'
|
||||
/>
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='absolute top-0 right-[80px] bottom-0 z-20 w-px bg-[#E9E9E9]'
|
||||
/>
|
||||
|
||||
<div role='tablist' aria-label='Feature categories' className='flex flex-1'>
|
||||
{FEATURE_TABS.map((tab, index) => (
|
||||
<button
|
||||
key={tab.label}
|
||||
type='button'
|
||||
role='tab'
|
||||
aria-selected={index === activeTab}
|
||||
onClick={() => setActiveTab(index)}
|
||||
className='relative flex h-full flex-1 items-center justify-center border-[#E9E9E9] border-l font-medium font-season text-[#212121] text-[14px] uppercase'
|
||||
style={{ backgroundColor: index === activeTab ? '#FDFDFD' : '#F6F6F6' }}
|
||||
>
|
||||
{tab.label}
|
||||
{index === activeTab && (
|
||||
<div className='absolute right-0 bottom-0 left-0 flex h-[6px]'>
|
||||
{tab.segments.map(([opacity, width], i) => (
|
||||
<div
|
||||
key={i}
|
||||
className='h-full shrink-0'
|
||||
style={{
|
||||
width: `${width}%`,
|
||||
backgroundColor: tab.color,
|
||||
opacity,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
<div className='flex h-[68px] overflow-hidden border border-[#E9E9E9]'>
|
||||
<DotGrid cols={10} rows={8} width={80} />
|
||||
|
||||
<div role='tablist' aria-label='Feature categories' className='flex flex-1'>
|
||||
{FEATURE_TABS.map((tab, index) => (
|
||||
<button
|
||||
key={tab.label}
|
||||
type='button'
|
||||
role='tab'
|
||||
aria-selected={index === activeTab}
|
||||
onClick={() => setActiveTab(index)}
|
||||
className='relative flex h-full flex-1 items-center justify-center border-[#E9E9E9] border-l font-medium font-season text-[#212121] text-[14px] uppercase'
|
||||
style={{ backgroundColor: index === activeTab ? '#FDFDFD' : '#F6F6F6' }}
|
||||
>
|
||||
{tab.label}
|
||||
{index === activeTab && (
|
||||
<div className='absolute right-0 bottom-0 left-0 flex h-[6px]'>
|
||||
{tab.segments.map(([opacity, width], i) => (
|
||||
<div
|
||||
key={i}
|
||||
className='h-full shrink-0'
|
||||
style={{
|
||||
width: `${width}%`,
|
||||
backgroundColor: tab.color,
|
||||
opacity,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DotGrid cols={10} rows={8} width={80} borderLeft />
|
||||
</div>
|
||||
|
||||
<DotGrid cols={10} rows={8} width={80} borderLeft />
|
||||
<div className='mt-[60px] grid grid-cols-[1fr_2.8fr] gap-[60px] px-[120px]'>
|
||||
<div className='flex h-[560px] flex-col items-start justify-between pt-[20px]'>
|
||||
<div className='flex flex-col items-start gap-[16px]'>
|
||||
<h3 className='font-[430] font-season text-[#1C1C1C] text-[28px] leading-[120%] tracking-[-0.02em]'>
|
||||
{FEATURE_TABS[activeTab].title}
|
||||
</h3>
|
||||
<p className='font-[430] font-season text-[#1C1C1C]/50 text-[18px] leading-[150%] tracking-[0.02em]'>
|
||||
{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]'
|
||||
>
|
||||
{FEATURE_TABS[activeTab].cta}
|
||||
<span className='relative h-[10px] w-[10px] shrink-0'>
|
||||
<ChevronDown className='-rotate-90 absolute inset-0 h-[10px] w-[10px] transition-opacity duration-150 group-hover/cta:opacity-0' />
|
||||
<svg
|
||||
className='absolute inset-0 h-[10px] w-[10px] opacity-0 transition-opacity duration-150 group-hover/cta:opacity-100'
|
||||
viewBox='0 0 10 10'
|
||||
fill='none'
|
||||
>
|
||||
<path
|
||||
d='M1 5H8M5.5 2L8.5 5L5.5 8'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.33'
|
||||
strokeLinecap='square'
|
||||
strokeLinejoin='miter'
|
||||
fill='none'
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<FeaturesPreview />
|
||||
</div>
|
||||
|
||||
<div aria-hidden='true' className='mt-[60px] h-px bg-[#E9E9E9]' />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,18 +1,189 @@
|
||||
/**
|
||||
* Landing page footer — navigation, legal links, and entity reinforcement.
|
||||
*
|
||||
* SEO:
|
||||
* - `<footer role="contentinfo">` with `<nav aria-label="Footer navigation">`.
|
||||
* - Link groups under semantic headings (`<h3>`). All links are `<Link>` or `<a>` with `href`.
|
||||
* - External links include `rel="noopener noreferrer"`.
|
||||
* - Legal links (Privacy, Terms) must be crawlable (trust signals).
|
||||
*
|
||||
* GEO:
|
||||
* - Include "Sim — Build AI agents and run your agentic workforce" as visible text (entity reinforcement).
|
||||
* - Social links (X, GitHub, LinkedIn, Discord) must match `sameAs` in structured-data.tsx.
|
||||
* - Link to all major pages: Docs, Pricing, Enterprise, Careers, Changelog (internal link graph).
|
||||
* - Display compliance badges (SOC2, HIPAA) and status page link as visible trust signals.
|
||||
*/
|
||||
export default function Footer() {
|
||||
return null
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { FOOTER_BLOCKS, FOOTER_TOOLS } from '@/app/(landing)/components/footer/consts'
|
||||
|
||||
const LINK_CLASS = 'text-[14px] text-[#999] transition-colors hover:text-[#ECECEC]'
|
||||
|
||||
interface FooterLink {
|
||||
label: string
|
||||
href: string
|
||||
external?: boolean
|
||||
}
|
||||
|
||||
const FOOTER_LINKS: FooterLink[] = [
|
||||
{ label: 'Docs', href: 'https://docs.sim.ai', external: true },
|
||||
{ label: 'Pricing', href: '#pricing' },
|
||||
{ label: 'Enterprise', href: 'https://form.typeform.com/to/jqCO12pF', external: true },
|
||||
{ label: 'Sim Studio', href: '/studio' },
|
||||
{ label: 'Changelog', href: '/changelog' },
|
||||
{ label: 'Status', href: 'https://status.sim.ai', external: true },
|
||||
{ label: 'Careers', href: 'https://jobs.ashbyhq.com/sim', external: true },
|
||||
{ label: 'SOC2', href: 'https://trust.delve.co/sim-studio', external: true },
|
||||
{ label: 'Privacy Policy', href: '/privacy', external: true },
|
||||
{ label: 'Terms of Service', href: '/terms', external: true },
|
||||
]
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer
|
||||
role='contentinfo'
|
||||
className='relative w-full overflow-hidden bg-[#1C1C1C] font-[430] font-season text-[14px]'
|
||||
>
|
||||
<div className='px-4 pt-[80px] pb-[40px] sm:px-8 sm:pb-[340px] md:px-[80px]'>
|
||||
<nav aria-label='Footer navigation' className='flex justify-between'>
|
||||
{/* Brand column */}
|
||||
<div className='flex flex-col gap-[24px]'>
|
||||
<Link href='/' aria-label='Sim home'>
|
||||
<Image
|
||||
src='/logo/sim-landing.svg'
|
||||
alt='Sim'
|
||||
width={71}
|
||||
height={22}
|
||||
className='h-[22px] w-auto'
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Community column */}
|
||||
<div>
|
||||
<h3 className='mb-[16px] font-medium text-[#ECECEC] text-[14px]'>Community</h3>
|
||||
<div className='flex flex-col gap-[12px]'>
|
||||
<a
|
||||
href='https://discord.gg/Hr4UWYEcTT'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className={LINK_CLASS}
|
||||
>
|
||||
Discord
|
||||
</a>
|
||||
<a
|
||||
href='https://x.com/simdotai'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className={LINK_CLASS}
|
||||
>
|
||||
X (Twitter)
|
||||
</a>
|
||||
<a
|
||||
href='https://www.linkedin.com/company/simstudioai/'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className={LINK_CLASS}
|
||||
>
|
||||
LinkedIn
|
||||
</a>
|
||||
<a
|
||||
href='https://github.com/simstudioai/sim'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className={LINK_CLASS}
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Links column */}
|
||||
<div>
|
||||
<h3 className='mb-[16px] font-medium text-[#ECECEC] text-[14px]'>More Sim</h3>
|
||||
<div className='flex flex-col gap-[12px]'>
|
||||
{FOOTER_LINKS.map(({ label, href, external }) =>
|
||||
external ? (
|
||||
<a
|
||||
key={label}
|
||||
href={href}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className={LINK_CLASS}
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
) : (
|
||||
<Link key={label} href={href} className={LINK_CLASS}>
|
||||
{label}
|
||||
</Link>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Blocks column */}
|
||||
<div className='hidden sm:block'>
|
||||
<h3 className='mb-[16px] font-medium text-[#ECECEC] text-[14px]'>Blocks</h3>
|
||||
<div className='flex flex-col gap-[12px]'>
|
||||
{FOOTER_BLOCKS.map((block) => (
|
||||
<a
|
||||
key={block}
|
||||
href={`https://docs.sim.ai/blocks/${block.toLowerCase().replaceAll(' ', '-')}`}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className={LINK_CLASS}
|
||||
>
|
||||
{block}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tools columns */}
|
||||
<div className='hidden sm:block'>
|
||||
<h3 className='mb-[16px] font-medium text-[#ECECEC] text-[14px]'>Tools</h3>
|
||||
<div className='flex gap-[80px]'>
|
||||
{[0, 1, 2, 3].map((quarter) => {
|
||||
const start = Math.ceil((FOOTER_TOOLS.length * quarter) / 4)
|
||||
const end =
|
||||
quarter === 3
|
||||
? FOOTER_TOOLS.length
|
||||
: Math.ceil((FOOTER_TOOLS.length * (quarter + 1)) / 4)
|
||||
return (
|
||||
<div key={quarter} className='flex flex-col gap-[12px]'>
|
||||
{FOOTER_TOOLS.slice(start, end).map((tool) => (
|
||||
<a
|
||||
key={tool}
|
||||
href={`https://docs.sim.ai/tools/${tool.toLowerCase().replace(/\s+/g, '_')}`}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className={`whitespace-nowrap ${LINK_CLASS}`}
|
||||
>
|
||||
{tool}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Large SIM wordmark — 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'
|
||||
>
|
||||
<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='#2A2A2A'
|
||||
/>
|
||||
<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='#2A2A2A'
|
||||
/>
|
||||
<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='#2A2A2A'
|
||||
/>
|
||||
<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='#3D3D3D'
|
||||
strokeWidth='1.28396'
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ export default function Hero() {
|
||||
<section
|
||||
id='hero'
|
||||
aria-labelledby='hero-heading'
|
||||
className='relative flex flex-col items-center overflow-hidden bg-[#1C1C1C] pt-[71px]'
|
||||
className='relative flex flex-col items-center overflow-hidden bg-[#1C1C1C] pt-[100px] pb-[12px]'
|
||||
>
|
||||
<p className='sr-only'>
|
||||
Sim is the open-source platform to build AI agents and run your agentic workforce. Connect
|
||||
@@ -53,7 +53,7 @@ export default function Hero() {
|
||||
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='pointer-events-none absolute top-[-2.8vw] right-[0vw] z-0 aspect-[471/470] w-[32.7vw]'
|
||||
className='pointer-events-none absolute top-[-2.8vw] right-[-4vw] z-0 aspect-[471/470] w-[32.7vw]'
|
||||
>
|
||||
<Image src='/landing/card-right.svg' alt='' fill className='object-contain' />
|
||||
</div>
|
||||
@@ -61,25 +61,27 @@ export default function Hero() {
|
||||
<div className='relative z-10 flex flex-col items-center gap-[12px]'>
|
||||
<h1
|
||||
id='hero-heading'
|
||||
className='font-[430] font-season text-[64px] text-white leading-[100%] tracking-[-0.02em]'
|
||||
className='font-[430] font-season text-[72px] text-white leading-[100%] tracking-[-0.02em]'
|
||||
>
|
||||
Build Agents
|
||||
Build AI Agents
|
||||
</h1>
|
||||
<p className='font-[430] font-season text-[#F6F6F6]/60 text-[16px] leading-[125%] tracking-[0.02em]'>
|
||||
Build and deploy agentic workflows
|
||||
<p className='font-[430] font-season text-[#F6F6F6]/60 text-[18px] leading-[125%] tracking-[0.02em]'>
|
||||
Sim is the AI Workspace for Agent Builders.
|
||||
</p>
|
||||
|
||||
<div className='mt-[12px] flex items-center gap-[8px]'>
|
||||
<Link
|
||||
href='/login'
|
||||
<a
|
||||
href='https://form.typeform.com/to/jqCO12pF'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className={`${CTA_BASE} border-[#3d3d3d] text-[#ECECEC] transition-colors hover:bg-[#2A2A2A]`}
|
||||
aria-label='Log in'
|
||||
aria-label='Get a demo'
|
||||
>
|
||||
Log in
|
||||
</Link>
|
||||
Get a demo
|
||||
</a>
|
||||
<Link
|
||||
href='/signup'
|
||||
className={`${CTA_BASE} gap-[8px] border-[#33C482] bg-[#33C482] text-black transition-[filter] hover:brightness-110`}
|
||||
className={`${CTA_BASE} gap-[8px] border-[#FFFFFF] bg-[#FFFFFF] text-black transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]`}
|
||||
aria-label='Get started with Sim'
|
||||
>
|
||||
Get started
|
||||
@@ -101,7 +103,7 @@ export default function Hero() {
|
||||
<BlocksTopLeftAnimated animState={blockStates.topLeft} />
|
||||
</div>
|
||||
|
||||
<div className='relative z-10 mx-auto mt-[2.4vw] w-[78.9vw] px-[1.4vw]'>
|
||||
<div className='relative z-10 mx-auto mt-[3.2vw] w-[78.9vw] px-[1.4vw]'>
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='-translate-y-1/2 pointer-events-none absolute top-[50%] right-[calc(100%-1.41vw)] z-20 w-[calc(16px_+_1.25vw)] max-w-[34px]'
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
'use client'
|
||||
|
||||
import { memo, useCallback, useRef, useState } from 'react'
|
||||
import { ArrowUp } from 'lucide-react'
|
||||
import { useLandingSubmit } from '@/app/(home)/components/landing-preview/components/landing-preview-panel/landing-preview-panel'
|
||||
import { useAnimatedPlaceholder } from '@/app/workspace/[workspaceId]/home/hooks/use-animated-placeholder'
|
||||
|
||||
const C = {
|
||||
SURFACE: '#292929',
|
||||
BORDER: '#3d3d3d',
|
||||
TEXT_PRIMARY: '#e6e6e6',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Landing preview replica of the workspace Home initial view.
|
||||
* Shows a greeting heading and a minimal chat input (no + or mic).
|
||||
* On submit, stores the prompt and redirects to /signup.
|
||||
*/
|
||||
export const LandingPreviewHome = memo(function LandingPreviewHome() {
|
||||
const landingSubmit = useLandingSubmit()
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const animatedPlaceholder = useAnimatedPlaceholder()
|
||||
|
||||
const isEmpty = inputValue.trim().length === 0
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (isEmpty) return
|
||||
landingSubmit(inputValue)
|
||||
}, [isEmpty, inputValue, landingSubmit])
|
||||
|
||||
const MAX_HEIGHT = 200
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSubmit()
|
||||
}
|
||||
},
|
||||
[handleSubmit]
|
||||
)
|
||||
|
||||
const handleInput = useCallback((e: React.FormEvent<HTMLTextAreaElement>) => {
|
||||
const target = e.target as HTMLTextAreaElement
|
||||
target.style.height = 'auto'
|
||||
target.style.height = `${Math.min(target.scrollHeight, MAX_HEIGHT)}px`
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className='flex min-w-0 flex-1 flex-col items-center justify-center px-[24px] pb-[2vh]'>
|
||||
<h1
|
||||
className='mb-[24px] max-w-[42rem] font-[430] font-season text-[32px] tracking-[-0.02em]'
|
||||
style={{ color: C.TEXT_PRIMARY }}
|
||||
>
|
||||
What should we get done?
|
||||
</h1>
|
||||
|
||||
<div className='w-full max-w-[32rem]'>
|
||||
<div
|
||||
className='cursor-text rounded-[20px] border px-[10px] py-[8px]'
|
||||
style={{ borderColor: C.BORDER, backgroundColor: C.SURFACE }}
|
||||
onClick={() => textareaRef.current?.focus()}
|
||||
>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onInput={handleInput}
|
||||
placeholder={animatedPlaceholder}
|
||||
rows={1}
|
||||
className='m-0 box-border min-h-[24px] w-full resize-none overflow-y-auto border-0 bg-transparent px-[4px] py-[4px] font-body text-[15px] leading-[24px] tracking-[-0.015em] outline-none placeholder:font-[380] placeholder:text-[#787878] focus-visible:ring-0'
|
||||
style={{
|
||||
color: C.TEXT_PRIMARY,
|
||||
caretColor: C.TEXT_PRIMARY,
|
||||
maxHeight: `${MAX_HEIGHT}px`,
|
||||
}}
|
||||
/>
|
||||
<div className='flex items-center justify-end'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleSubmit}
|
||||
disabled={isEmpty}
|
||||
className='flex h-[28px] w-[28px] items-center justify-center rounded-full border-0 p-0 transition-colors'
|
||||
style={{
|
||||
background: isEmpty ? '#808080' : '#e0e0e0',
|
||||
cursor: isEmpty ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
>
|
||||
<ArrowUp size={16} strokeWidth={2.25} color='#1b1b1b' />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -8,6 +8,23 @@ import { createPortal } from 'react-dom'
|
||||
import { BubbleChatPreview, ChevronDown, MoreHorizontal, Play } from '@/components/emcn'
|
||||
import { LandingPromptStorage } from '@/lib/core/utils/browser-storage'
|
||||
|
||||
/**
|
||||
* Stores the prompt in browser storage and redirects to /signup.
|
||||
* Shared by both the copilot panel and the landing home view.
|
||||
*/
|
||||
export function useLandingSubmit() {
|
||||
const router = useRouter()
|
||||
return useCallback(
|
||||
(text: string) => {
|
||||
const trimmed = text.trim()
|
||||
if (!trimmed) return
|
||||
LandingPromptStorage.store(trimmed)
|
||||
router.push('/signup')
|
||||
},
|
||||
[router]
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight static panel replicating the real workspace panel styling.
|
||||
* The copilot tab is active with a functional user input.
|
||||
@@ -18,7 +35,7 @@ import { LandingPromptStorage } from '@/lib/core/utils/browser-storage'
|
||||
* inside Content > Copilot > header-bar(mx-[-1px]) > UserInput(p-8)
|
||||
*/
|
||||
export const LandingPreviewPanel = memo(function LandingPreviewPanel() {
|
||||
const router = useRouter()
|
||||
const landingSubmit = useLandingSubmit()
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const [cursorPos, setCursorPos] = useState<{ x: number; y: number } | null>(null)
|
||||
@@ -27,9 +44,8 @@ export const LandingPreviewPanel = memo(function LandingPreviewPanel() {
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (isEmpty) return
|
||||
LandingPromptStorage.store(inputValue)
|
||||
router.push('/signup')
|
||||
}, [isEmpty, inputValue, router])
|
||||
landingSubmit(inputValue)
|
||||
}, [isEmpty, inputValue, landingSubmit])
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
@@ -60,10 +76,10 @@ export const LandingPreviewPanel = memo(function LandingPreviewPanel() {
|
||||
onMouseMove={(e) => setCursorPos({ x: e.clientX, y: e.clientY })}
|
||||
onMouseLeave={() => setCursorPos(null)}
|
||||
>
|
||||
<div className='flex h-[30px] items-center rounded-[5px] bg-[#32bd7e] px-[10px] transition-[filter] hover:brightness-110'>
|
||||
<div className='flex h-[30px] items-center rounded-[5px] bg-[#33C482] px-[10px] 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-[#32bd7e] px-[10px] transition-[filter] hover:brightness-110'>
|
||||
<div className='flex h-[30px] items-center gap-[8px] rounded-[5px] bg-[#33C482] px-[10px] 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>
|
||||
|
||||
@@ -1,141 +1,204 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Database, Layout, Search, Settings } from 'lucide-react'
|
||||
import { ChevronDown, Library } from '@/components/emcn'
|
||||
import { ChevronDown, Home, Library } from '@/components/emcn'
|
||||
import {
|
||||
Calendar,
|
||||
Database,
|
||||
File,
|
||||
HelpCircle,
|
||||
Search,
|
||||
Settings,
|
||||
Table,
|
||||
} from '@/components/emcn/icons'
|
||||
import type { PreviewWorkflow } from '@/app/(home)/components/landing-preview/components/landing-preview-workflow/workflow-data'
|
||||
|
||||
/**
|
||||
* Props for the LandingPreviewSidebar component
|
||||
*/
|
||||
interface LandingPreviewSidebarProps {
|
||||
workflows: PreviewWorkflow[]
|
||||
activeWorkflowId: string
|
||||
activeView: 'home' | 'workflow'
|
||||
onSelectWorkflow: (id: string) => void
|
||||
onSelectHome: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Static footer navigation items matching the real sidebar
|
||||
* Hardcoded dark-theme equivalents of the real sidebar CSS variables.
|
||||
* The preview lives inside a `dark` wrapper but CSS variable cascade
|
||||
* isn't guaranteed, so we pin the hex values directly.
|
||||
*/
|
||||
const FOOTER_NAV_ITEMS = [
|
||||
{ id: 'logs', label: 'Logs', icon: Library },
|
||||
{ id: 'templates', label: 'Templates', icon: Layout },
|
||||
const C = {
|
||||
SURFACE_1: '#1e1e1e',
|
||||
SURFACE_2: '#252525',
|
||||
SURFACE_ACTIVE: '#363636',
|
||||
BORDER: '#2c2c2c',
|
||||
TEXT_PRIMARY: '#e6e6e6',
|
||||
TEXT_BODY: '#cdcdcd',
|
||||
TEXT_ICON: '#939393',
|
||||
BRAND: '#33C482',
|
||||
} as const
|
||||
|
||||
const WORKSPACE_NAV = [
|
||||
{ id: 'tables', label: 'Tables', icon: Table },
|
||||
{ id: 'files', label: 'Files', icon: File },
|
||||
{ id: 'knowledge-base', label: 'Knowledge Base', icon: Database },
|
||||
{ id: 'scheduled-tasks', label: 'Scheduled Tasks', icon: Calendar },
|
||||
{ id: 'logs', label: 'Logs', icon: Library },
|
||||
] as const
|
||||
|
||||
const FOOTER_NAV = [
|
||||
{ id: 'help', label: 'Help', icon: HelpCircle },
|
||||
{ id: 'settings', label: 'Settings', icon: Settings },
|
||||
] as const
|
||||
|
||||
function StaticNavItem({
|
||||
icon: Icon,
|
||||
label,
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }>
|
||||
label: string
|
||||
}) {
|
||||
return (
|
||||
<div className='pointer-events-none mx-[2px] flex h-[28px] items-center gap-[8px] rounded-[8px] px-[8px]'>
|
||||
<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}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight static sidebar replicating the real workspace sidebar styling.
|
||||
* Lightweight sidebar replicating the real workspace sidebar layout and sizing.
|
||||
* Starts from the workspace header (no logo/collapse row).
|
||||
* Only workflow items are interactive — everything else is pointer-events-none.
|
||||
*
|
||||
* Colors sourced from the dark theme CSS variables:
|
||||
* --surface-1: #1e1e1e, --surface-5: #363636, --border: #2c2c2c, --border-1: #3d3d3d
|
||||
* --text-primary: #e6e6e6, --text-tertiary: #b3b3b3, --text-muted: #787878
|
||||
*/
|
||||
export function LandingPreviewSidebar({
|
||||
workflows,
|
||||
activeWorkflowId,
|
||||
activeView,
|
||||
onSelectWorkflow,
|
||||
onSelectHome,
|
||||
}: LandingPreviewSidebarProps) {
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
setIsDropdownOpen((prev) => !prev)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDropdownOpen) return
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
||||
setIsDropdownOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [isDropdownOpen])
|
||||
const isHomeActive = activeView === 'home'
|
||||
|
||||
return (
|
||||
<div className='flex h-full w-[220px] flex-shrink-0 flex-col border-[#2c2c2c] border-r bg-[#1e1e1e]'>
|
||||
{/* Header */}
|
||||
<div className='relative flex-shrink-0 px-[14px] pt-[12px]' ref={dropdownRef}>
|
||||
<div className='flex items-center justify-between'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleToggle}
|
||||
className='group -mx-[6px] flex cursor-pointer items-center gap-[8px] rounded-[6px] bg-transparent px-[6px] py-[4px] transition-colors hover:bg-[#363636]'
|
||||
<div
|
||||
className='flex h-full w-[248px] flex-shrink-0 flex-col pt-[12px]'
|
||||
style={{ backgroundColor: C.SURFACE_1 }}
|
||||
>
|
||||
{/* Workspace Header */}
|
||||
<div className='flex-shrink-0 px-[10px]'>
|
||||
<div
|
||||
className='pointer-events-none flex h-[32px] w-full items-center gap-[8px] rounded-[8px] border pr-[8px] 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'>
|
||||
<svg width='10' height='10' viewBox='0 0 10 10' fill='none'>
|
||||
<path
|
||||
d='M1 9C1 4.58 4.58 1 9 1'
|
||||
stroke='#1e1e1e'
|
||||
strokeWidth='1.8'
|
||||
strokeLinecap='round'
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<span
|
||||
className='min-w-0 flex-1 truncate text-left font-medium text-[13px]'
|
||||
style={{ color: C.TEXT_PRIMARY }}
|
||||
>
|
||||
<span className='truncate font-base text-[#e6e6e6] text-[14px]'>My Workspace</span>
|
||||
<ChevronDown
|
||||
className={`h-[8px] w-[10px] flex-shrink-0 text-[#787878] transition-all duration-100 group-hover:text-[#cccccc] ${isDropdownOpen ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
<div className='pointer-events-none flex flex-shrink-0 items-center'>
|
||||
<Search className='h-[14px] w-[14px] text-[#787878]' />
|
||||
Superark
|
||||
</span>
|
||||
<ChevronDown className='h-[8px] w-[10px] flex-shrink-0' style={{ color: C.TEXT_ICON }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top Navigation: Home (interactive), Search (static) */}
|
||||
<div className='mt-[10px] flex flex-shrink-0 flex-col gap-[2px] px-[8px]'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={onSelectHome}
|
||||
className='mx-[2px] flex h-[28px] items-center gap-[8px] rounded-[8px] px-[8px] transition-colors'
|
||||
style={{ backgroundColor: isHomeActive ? C.SURFACE_ACTIVE : 'transparent' }}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isHomeActive) e.currentTarget.style.backgroundColor = C.SURFACE_ACTIVE
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isHomeActive) e.currentTarget.style.backgroundColor = 'transparent'
|
||||
}}
|
||||
>
|
||||
<Home 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 }}>
|
||||
Home
|
||||
</span>
|
||||
</button>
|
||||
<StaticNavItem icon={Search} label='Search' />
|
||||
</div>
|
||||
|
||||
{/* Workspace */}
|
||||
<div className='mt-[14px] flex flex-shrink-0 flex-col'>
|
||||
<div className='px-[16px] pb-[6px]'>
|
||||
<div className='font-base text-[12px]' style={{ color: C.TEXT_ICON }}>
|
||||
Workspace
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-col gap-[2px] px-[8px]'>
|
||||
{WORKSPACE_NAV.map((item) => (
|
||||
<StaticNavItem key={item.id} icon={item.icon} label={item.label} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Workspace switcher dropdown */}
|
||||
{isDropdownOpen && (
|
||||
<div className='absolute top-[42px] left-[8px] z-50 min-w-[160px] max-w-[160px] rounded-[6px] bg-[#242424] px-[6px] py-[6px] shadow-lg'>
|
||||
<div
|
||||
className='flex h-[26px] cursor-pointer items-center gap-[8px] rounded-[6px] bg-[#3d3d3d] px-[6px] font-base text-[#e6e6e6] text-[13px]'
|
||||
role='menuitem'
|
||||
onClick={() => setIsDropdownOpen(false)}
|
||||
>
|
||||
<span className='min-w-0 flex-1 truncate'>My Workspace</span>
|
||||
{/* Scrollable Tasks + Workflows */}
|
||||
<div className='flex flex-1 flex-col overflow-y-auto overflow-x-hidden pt-[14px]'>
|
||||
{/* Workflows */}
|
||||
<div className='flex flex-col'>
|
||||
<div className='px-[16px]'>
|
||||
<div className='font-base text-[12px]' style={{ color: C.TEXT_ICON }}>
|
||||
Workflows
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Workflow items */}
|
||||
<div className='mt-[8px] space-y-[2px] overflow-x-hidden px-[8px]'>
|
||||
{workflows.map((workflow) => {
|
||||
const isActive = workflow.id === activeWorkflowId
|
||||
return (
|
||||
<button
|
||||
key={workflow.id}
|
||||
type='button'
|
||||
onClick={() => onSelectWorkflow(workflow.id)}
|
||||
className={`group flex h-[26px] w-full items-center gap-[8px] rounded-[8px] px-[6px] text-[14px] transition-colors ${
|
||||
isActive ? 'bg-[#363636]' : 'bg-transparent hover:bg-[#363636]'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className='h-[14px] w-[14px] flex-shrink-0 rounded-[4px]'
|
||||
style={{ backgroundColor: workflow.color }}
|
||||
/>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div
|
||||
className={`min-w-0 truncate text-left font-medium ${
|
||||
isActive ? 'text-[#e6e6e6]' : 'text-[#b3b3b3] group-hover:text-[#e6e6e6]'
|
||||
}`}
|
||||
<div className='mt-[6px] flex flex-col gap-[2px] px-[8px]'>
|
||||
{workflows.map((workflow) => {
|
||||
const isActive = activeView === 'workflow' && workflow.id === activeWorkflowId
|
||||
return (
|
||||
<button
|
||||
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'
|
||||
style={{ backgroundColor: isActive ? C.SURFACE_ACTIVE : 'transparent' }}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isActive) e.currentTarget.style.backgroundColor = C.SURFACE_ACTIVE
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isActive) e.currentTarget.style.backgroundColor = 'transparent'
|
||||
}}
|
||||
>
|
||||
{workflow.name}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<div
|
||||
className='h-[14px] w-[14px] flex-shrink-0 rounded-[4px] border-[2.5px]'
|
||||
style={{
|
||||
backgroundColor: workflow.color,
|
||||
borderColor: `${workflow.color}60`,
|
||||
backgroundClip: 'padding-box',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className='min-w-0 flex-1 truncate text-left text-[13px]'
|
||||
style={{ color: C.TEXT_BODY, fontWeight: 450 }}
|
||||
>
|
||||
{workflow.name}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer navigation — static */}
|
||||
<div className='pointer-events-none mt-auto flex flex-shrink-0 flex-col gap-[2px] border-[#2c2c2c] border-t px-[7.75px] pt-[8px] pb-[8px]'>
|
||||
{FOOTER_NAV_ITEMS.map((item) => {
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className='flex h-[26px] items-center gap-[8px] rounded-[8px] px-[6px] text-[14px]'
|
||||
>
|
||||
<Icon className='h-[14px] w-[14px] flex-shrink-0 text-[#b3b3b3]' />
|
||||
<span className='truncate font-medium text-[#b3b3b3] text-[13px]'>{item.label}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{/* Footer */}
|
||||
<div className='flex flex-shrink-0 flex-col gap-[2px] px-[8px] pt-[9px] pb-[8px]'>
|
||||
{FOOTER_NAV.map((item) => (
|
||||
<StaticNavItem key={item.id} icon={item.icon} label={item.label} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { memo } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Database } from 'lucide-react'
|
||||
import { Handle, type NodeProps, Position } from 'reactflow'
|
||||
import { Blimp } from '@/components/emcn'
|
||||
import {
|
||||
AgentIcon,
|
||||
AnthropicIcon,
|
||||
@@ -63,6 +64,7 @@ const BLOCK_ICONS: Record<string, React.ComponentType<{ className?: string }>> =
|
||||
reducto: ReductoIcon,
|
||||
textract: TextractIcon,
|
||||
linkedin: LinkedInIcon,
|
||||
mothership: Blimp,
|
||||
}
|
||||
|
||||
/** Model prefix → provider icon for the "Model" row in agent blocks. */
|
||||
|
||||
@@ -91,11 +91,11 @@ const IT_SERVICE_WORKFLOW: PreviewWorkflow = {
|
||||
}
|
||||
|
||||
/**
|
||||
* Content pipeline workflow — Schedule -> Agent (X + YouTube tools)
|
||||
* Self-healing CRM workflow — Schedule -> Mothership
|
||||
*/
|
||||
const CONTENT_PIPELINE_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'wf-content-pipeline',
|
||||
name: 'Content Pipeline',
|
||||
const SELF_HEALING_CRM_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'wf-self-healing-crm',
|
||||
name: 'Self-healing CRM',
|
||||
color: '#33C482',
|
||||
blocks: [
|
||||
{
|
||||
@@ -111,23 +111,16 @@ const CONTENT_PIPELINE_WORKFLOW: PreviewWorkflow = {
|
||||
hideTargetHandle: true,
|
||||
},
|
||||
{
|
||||
id: 'agent-2',
|
||||
name: 'Agent',
|
||||
type: 'agent',
|
||||
bgColor: '#701ffc',
|
||||
rows: [
|
||||
{ title: 'Model', value: 'grok-4' },
|
||||
{ title: 'System Prompt', value: 'Repurpose trending...' },
|
||||
],
|
||||
tools: [
|
||||
{ name: 'X', type: 'x', bgColor: '#000000' },
|
||||
{ name: 'YouTube', type: 'youtube', bgColor: '#FF0000' },
|
||||
],
|
||||
id: 'mothership-1',
|
||||
name: 'Update Agent',
|
||||
type: 'mothership',
|
||||
bgColor: '#33C482',
|
||||
rows: [{ title: 'Prompt', value: 'Audit CRM records, fix...' }],
|
||||
position: { x: 420, y: 180 },
|
||||
hideSourceHandle: true,
|
||||
},
|
||||
],
|
||||
edges: [{ id: 'e-3', source: 'schedule-1', target: 'agent-2' }],
|
||||
edges: [{ id: 'e-3', source: 'schedule-1', target: 'mothership-1' }],
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -154,7 +147,7 @@ const NEW_AGENT_WORKFLOW: PreviewWorkflow = {
|
||||
}
|
||||
|
||||
export const PREVIEW_WORKFLOWS: PreviewWorkflow[] = [
|
||||
CONTENT_PIPELINE_WORKFLOW,
|
||||
SELF_HEALING_CRM_WORKFLOW,
|
||||
IT_SERVICE_WORKFLOW,
|
||||
NEW_AGENT_WORKFLOW,
|
||||
]
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { motion, type Variants } from 'framer-motion'
|
||||
import { LandingPreviewHome } from '@/app/(home)/components/landing-preview/components/landing-preview-home/landing-preview-home'
|
||||
import { LandingPreviewPanel } from '@/app/(home)/components/landing-preview/components/landing-preview-panel/landing-preview-panel'
|
||||
import { LandingPreviewSidebar } from '@/app/(home)/components/landing-preview/components/landing-preview-sidebar/landing-preview-sidebar'
|
||||
import { LandingPreviewWorkflow } from '@/app/(home)/components/landing-preview/components/landing-preview-workflow/landing-preview-workflow'
|
||||
@@ -56,6 +57,7 @@ const panelVariants: Variants = {
|
||||
* load — workflow switches render instantly.
|
||||
*/
|
||||
export function LandingPreview() {
|
||||
const [activeView, setActiveView] = useState<'home' | 'workflow'>('workflow')
|
||||
const [activeWorkflowId, setActiveWorkflowId] = useState(PREVIEW_WORKFLOWS[0].id)
|
||||
const isInitialMount = useRef(true)
|
||||
|
||||
@@ -63,12 +65,23 @@ export function LandingPreview() {
|
||||
isInitialMount.current = false
|
||||
}, [])
|
||||
|
||||
const handleSelectWorkflow = useCallback((id: string) => {
|
||||
setActiveWorkflowId(id)
|
||||
setActiveView('workflow')
|
||||
}, [])
|
||||
|
||||
const handleSelectHome = useCallback(() => {
|
||||
setActiveView('home')
|
||||
}, [])
|
||||
|
||||
const activeWorkflow =
|
||||
PREVIEW_WORKFLOWS.find((w) => w.id === activeWorkflowId) ?? PREVIEW_WORKFLOWS[0]
|
||||
|
||||
const isWorkflowView = activeView === 'workflow'
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className='dark flex aspect-[1116/549] w-full overflow-hidden rounded bg-[#1b1b1b] antialiased'
|
||||
className='dark flex aspect-[1116/549] w-full overflow-hidden rounded bg-[#1e1e1e] antialiased'
|
||||
initial='hidden'
|
||||
animate='visible'
|
||||
variants={containerVariants}
|
||||
@@ -77,15 +90,34 @@ export function LandingPreview() {
|
||||
<LandingPreviewSidebar
|
||||
workflows={PREVIEW_WORKFLOWS}
|
||||
activeWorkflowId={activeWorkflowId}
|
||||
onSelectWorkflow={setActiveWorkflowId}
|
||||
activeView={activeView}
|
||||
onSelectWorkflow={handleSelectWorkflow}
|
||||
onSelectHome={handleSelectHome}
|
||||
/>
|
||||
</motion.div>
|
||||
<div className='relative flex-1 overflow-hidden'>
|
||||
<LandingPreviewWorkflow workflow={activeWorkflow} animate={isInitialMount.current} />
|
||||
<div className='flex min-w-0 flex-1 flex-col p-[8px] pl-0'>
|
||||
<div className='flex flex-1 overflow-hidden rounded-[8px] border border-[#2c2c2c] bg-[#1b1b1b]'>
|
||||
<div
|
||||
className={
|
||||
isWorkflowView
|
||||
? 'relative min-w-0 flex-1 overflow-hidden'
|
||||
: 'relative flex min-w-0 flex-1 flex-col overflow-hidden'
|
||||
}
|
||||
>
|
||||
{isWorkflowView ? (
|
||||
<LandingPreviewWorkflow workflow={activeWorkflow} animate={isInitialMount.current} />
|
||||
) : (
|
||||
<LandingPreviewHome />
|
||||
)}
|
||||
</div>
|
||||
<motion.div
|
||||
className={isWorkflowView ? 'hidden lg:flex' : 'hidden'}
|
||||
variants={panelVariants}
|
||||
>
|
||||
<LandingPreviewPanel />
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
<motion.div className='hidden lg:flex' variants={panelVariants}>
|
||||
<LandingPreviewPanel />
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { ChevronDown } from '@/components/emcn'
|
||||
import { GitHubStars } from '@/app/(home)/components/navbar/components/github-stars'
|
||||
import { getBrandConfig } from '@/ee/whitelabeling'
|
||||
|
||||
interface NavLink {
|
||||
label: string
|
||||
@@ -11,10 +12,10 @@ interface NavLink {
|
||||
}
|
||||
|
||||
const NAV_LINKS: NavLink[] = [
|
||||
{ label: 'Docs', href: '/docs', icon: 'chevron' },
|
||||
{ label: 'Docs', href: 'https://docs.sim.ai', external: true },
|
||||
{ label: 'Pricing', href: '/pricing' },
|
||||
{ label: 'Careers', href: '/careers' },
|
||||
{ label: 'Enterprise', href: '/enterprise' },
|
||||
{ label: 'Enterprise', href: 'https://form.typeform.com/to/jqCO12pF', external: true },
|
||||
]
|
||||
|
||||
/** Logo and nav edge: horizontal padding (px) for left/right symmetry. */
|
||||
@@ -23,7 +24,13 @@ const LOGO_CELL = 'flex items-center px-[20px]'
|
||||
/** Links: even spacing between items. */
|
||||
const LINK_CELL = 'flex items-center px-[14px]'
|
||||
|
||||
export default function Navbar() {
|
||||
interface NavbarProps {
|
||||
logoOnly?: boolean
|
||||
}
|
||||
|
||||
export default function Navbar({ logoOnly = false }: NavbarProps) {
|
||||
const brand = getBrandConfig()
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label='Primary navigation'
|
||||
@@ -32,66 +39,82 @@ export default function Navbar() {
|
||||
itemType='https://schema.org/SiteNavigationElement'
|
||||
>
|
||||
{/* Logo */}
|
||||
<Link href='/' className={LOGO_CELL} aria-label='Sim home' itemProp='url'>
|
||||
<Link href='/' className={LOGO_CELL} aria-label={`${brand.name} home`} itemProp='url'>
|
||||
<span itemProp='name' className='sr-only'>
|
||||
Sim
|
||||
{brand.name}
|
||||
</span>
|
||||
<Image
|
||||
src='/logo/sim-landing.svg'
|
||||
alt='Sim'
|
||||
width={71}
|
||||
height={22}
|
||||
className='h-[22px] w-auto'
|
||||
priority
|
||||
/>
|
||||
{brand.logoUrl ? (
|
||||
<Image
|
||||
src={brand.logoUrl}
|
||||
alt={`${brand.name} Logo`}
|
||||
width={71}
|
||||
height={22}
|
||||
className='h-[22px] w-auto object-contain'
|
||||
priority
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
src='/logo/sim-landing.svg'
|
||||
alt='Sim'
|
||||
width={71}
|
||||
height={22}
|
||||
className='h-[22px] w-auto'
|
||||
priority
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
{/* Links */}
|
||||
<ul className='mt-[0.75px] flex'>
|
||||
{NAV_LINKS.map(({ label, href, external, icon }) => (
|
||||
<li key={label} className='flex'>
|
||||
{external ? (
|
||||
<a href={href} target='_blank' rel='noopener noreferrer' className={LINK_CELL}>
|
||||
{label}
|
||||
</a>
|
||||
) : (
|
||||
<Link
|
||||
href={href}
|
||||
className={icon ? `${LINK_CELL} gap-[8px]` : LINK_CELL}
|
||||
aria-label={label}
|
||||
>
|
||||
{label}
|
||||
{icon === 'chevron' && (
|
||||
<ChevronDown className='mt-[1.75px] h-[10px] w-[10px] flex-shrink-0 text-[#ECECEC]' />
|
||||
{!logoOnly && (
|
||||
<>
|
||||
{/* Links */}
|
||||
<ul className='mt-[0.75px] flex'>
|
||||
{NAV_LINKS.map(({ label, href, external, icon }) => (
|
||||
<li key={label} className='flex'>
|
||||
{external ? (
|
||||
<a href={href} target='_blank' rel='noopener noreferrer' className={LINK_CELL}>
|
||||
{label}
|
||||
</a>
|
||||
) : (
|
||||
<Link
|
||||
href={href}
|
||||
className={icon ? `${LINK_CELL} gap-[8px]` : LINK_CELL}
|
||||
aria-label={label}
|
||||
>
|
||||
{label}
|
||||
{icon === 'chevron' && (
|
||||
<ChevronDown className='mt-[1.75px] h-[10px] w-[10px] flex-shrink-0 text-[#ECECEC]' />
|
||||
)}
|
||||
</Link>
|
||||
)}
|
||||
</Link>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
<li className='flex'>
|
||||
<GitHubStars />
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
))}
|
||||
<li className='flex'>
|
||||
<GitHubStars />
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div className='flex-1' />
|
||||
<div className='flex-1' />
|
||||
|
||||
{/* CTAs */}
|
||||
<div className='flex items-center gap-[8px] px-[20px]'>
|
||||
<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]'
|
||||
aria-label='Log in'
|
||||
>
|
||||
Log in
|
||||
</Link>
|
||||
<Link
|
||||
href='/signup'
|
||||
className='inline-flex h-[30px] items-center gap-[7px] rounded-[5px] border border-[#33C482] bg-[#33C482] px-[9px] text-[13.5px] text-black transition-[filter] hover:brightness-110'
|
||||
aria-label='Get started with Sim'
|
||||
>
|
||||
Get started
|
||||
</Link>
|
||||
</div>
|
||||
{/* CTAs */}
|
||||
<div className='flex items-center gap-[8px] px-[20px]'>
|
||||
<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]'
|
||||
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]'
|
||||
aria-label='Get started with Sim'
|
||||
>
|
||||
Get started
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -123,7 +123,7 @@ function PricingCard({ tier }: PricingCardProps) {
|
||||
) : isPro ? (
|
||||
<Link
|
||||
href={tier.cta.href}
|
||||
className='flex h-[32px] w-full items-center justify-center rounded-[5px] border border-[#33C482] bg-[#33C482] px-[10px] font-[430] font-season text-[14px] text-white transition-[filter] hover:brightness-110'
|
||||
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]'
|
||||
>
|
||||
{tier.cta.label}
|
||||
</Link>
|
||||
@@ -174,7 +174,7 @@ function PricingCard({ tier }: PricingCardProps) {
|
||||
export default function Pricing() {
|
||||
return (
|
||||
<section id='pricing' aria-labelledby='pricing-heading' className='bg-[#F6F6F6]'>
|
||||
<div className='px-4 pt-[100px] pb-8 sm:px-8 md:px-[80px]'>
|
||||
<div className='px-4 pt-[100px] pb-[80px] sm:px-8 md:px-[80px]'>
|
||||
<div className='flex flex-col items-start gap-3 sm:gap-4 md:gap-[20px]'>
|
||||
<Badge
|
||||
variant='blue'
|
||||
|
||||
@@ -74,6 +74,10 @@ export default function StructuredData() {
|
||||
'Sim is 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. Create agents, workflows, knowledge bases, tables, and docs.',
|
||||
breadcrumb: { '@id': 'https://sim.ai/#breadcrumb' },
|
||||
inLanguage: 'en-US',
|
||||
speakable: {
|
||||
'@type': 'SpeakableSpecification',
|
||||
cssSelector: ['#hero-heading', '[id="hero"] p'],
|
||||
},
|
||||
potentialAction: [{ '@type': 'ReadAction', target: ['https://sim.ai'] }],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -337,7 +337,7 @@ function DotGrid({ className, cols, rows, gap = 0 }: DotGridProps) {
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: cols * rows }, (_, i) => (
|
||||
<div key={i} className='h-[2px] w-[2px] rounded-full bg-[#2A2A2A]' />
|
||||
<div key={i} className='h-[1.5px] w-[1.5px] rounded-full bg-[#2A2A2A]' />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
@@ -462,7 +462,7 @@ export default function Templates() {
|
||||
Ship your agent in minutes
|
||||
</h2>
|
||||
|
||||
<p className='font-[430] font-season text-[#F6F6F0]/50 text-[16px] leading-[125%] tracking-[0.02em]'>
|
||||
<p className='font-[430] font-season text-[#F6F6F0]/50 text-[18px] leading-[150%] tracking-[0.02em]'>
|
||||
Pre-built templates for every use case—pick one, swap <br />
|
||||
models and tools to fit your stack, and deploy.
|
||||
</p>
|
||||
@@ -557,7 +557,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-[#33C482] bg-[#33C482] px-[10px] font-[430] font-season text-[14px] text-black transition-[filter] hover:brightness-110'
|
||||
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]'
|
||||
>
|
||||
{isPreparingTemplate ? 'Preparing...' : 'Use template'}
|
||||
<span className='relative h-[10px] w-[10px] shrink-0'>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import Link from 'next/link'
|
||||
import { inter } from '@/app/_styles/fonts/inter/inter'
|
||||
import {
|
||||
ComplianceBadges,
|
||||
Logo,
|
||||
@@ -14,7 +13,7 @@ interface FooterProps {
|
||||
|
||||
export default function Footer({ fullWidth = false }: FooterProps) {
|
||||
return (
|
||||
<footer className={`${inter.className} relative w-full overflow-hidden bg-white`}>
|
||||
<footer className='relative w-full overflow-hidden bg-white'>
|
||||
<div
|
||||
className={
|
||||
fullWidth
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
SupabaseIcon,
|
||||
} from '@/components/icons'
|
||||
import { LandingPromptStorage } from '@/lib/core/utils/browser-storage'
|
||||
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
||||
import {
|
||||
CARD_WIDTH,
|
||||
IconButton,
|
||||
@@ -364,7 +363,7 @@ export default function Hero() {
|
||||
return (
|
||||
<section
|
||||
id='hero'
|
||||
className={`${soehne.className} flex w-full flex-col items-center justify-center pt-[36px] sm:pt-[80px]`}
|
||||
className='flex w-full flex-col items-center justify-center pt-[36px] sm:pt-[80px]'
|
||||
aria-labelledby='hero-heading'
|
||||
>
|
||||
<h1
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import * as Icons from '@/components/icons'
|
||||
import { inter } from '@/app/_styles/fonts/inter/inter'
|
||||
|
||||
const modelProviderIcons = [
|
||||
{ icon: Icons.OpenAIIcon, label: 'OpenAI' },
|
||||
@@ -122,7 +121,7 @@ export default function Integrations() {
|
||||
return (
|
||||
<section
|
||||
id='integrations'
|
||||
className={`${inter.className} flex flex-col pt-[40px] pb-[27px] sm:pt-[24px]`}
|
||||
className='flex flex-col pt-[40px] pb-[27px] sm:pt-[24px]'
|
||||
aria-labelledby='integrations-heading'
|
||||
>
|
||||
<h2
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { inter } from '@/app/_styles/fonts/inter/inter'
|
||||
import { ENTERPRISE_PLAN_FEATURES } from '@/app/workspace/[workspaceId]/settings/components/subscription/plan-configs'
|
||||
|
||||
const logger = createLogger('LandingPricing')
|
||||
@@ -117,7 +116,6 @@ function PricingCard({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
`${inter.className}`,
|
||||
'relative flex h-full flex-col justify-between bg-[#FEFEFE]',
|
||||
tier.featured ? 'p-0' : 'px-0 py-0',
|
||||
'sm:px-5 sm:pt-4 sm:pb-4',
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { inter } from '@/app/_styles/fonts/inter/inter'
|
||||
|
||||
interface LandingTemplatePreviewProps {
|
||||
previewImage: string
|
||||
avatarImage: string
|
||||
@@ -37,14 +35,8 @@ export default function LandingTemplatePreview({
|
||||
|
||||
{/* Title and Author Info */}
|
||||
<div className='min-w-0 flex-1'>
|
||||
<h4
|
||||
className={`${inter.className} truncate font-medium text-foreground text-sm leading-none`}
|
||||
>
|
||||
{title}
|
||||
</h4>
|
||||
<p
|
||||
className={`${inter.className} mt-1 flex items-center gap-2 text-muted-foreground text-xs`}
|
||||
>
|
||||
<h4 className='truncate font-medium text-foreground text-sm leading-none'>{title}</h4>
|
||||
<p className='mt-1 flex items-center gap-2 text-muted-foreground text-xs'>
|
||||
<span>{authorName}</span>
|
||||
<span>{usageCount.toLocaleString()} copies</span>
|
||||
</p>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { inter } from '@/app/_styles/fonts/inter/inter'
|
||||
import LandingTemplatePreview from '@/app/(landing)/components/landing-templates/components/landing-template-preview'
|
||||
|
||||
const templates = [
|
||||
@@ -80,7 +79,7 @@ export default function LandingTemplates() {
|
||||
return (
|
||||
<section
|
||||
id='templates'
|
||||
className={`${inter.className} flex flex-col px-4 pt-[40px] sm:px-[50px] sm:pt-[34px]`}
|
||||
className='flex flex-col px-4 pt-[40px] sm:px-[50px] sm:pt-[34px]'
|
||||
aria-labelledby='templates-heading'
|
||||
>
|
||||
<h2
|
||||
|
||||
@@ -1,36 +1,29 @@
|
||||
'use client'
|
||||
|
||||
import { isHosted } from '@/lib/core/config/feature-flags'
|
||||
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
||||
import Footer from '@/app/(landing)/components/footer/footer'
|
||||
import Nav from '@/app/(landing)/components/nav/nav'
|
||||
import Footer from '@/app/(home)/components/footer/footer'
|
||||
import Navbar from '@/app/(home)/components/navbar/navbar'
|
||||
|
||||
interface LegalLayoutProps {
|
||||
title: string
|
||||
children: React.ReactNode
|
||||
navVariant?: 'landing' | 'auth' | 'legal'
|
||||
}
|
||||
|
||||
export default function LegalLayout({ title, children, navVariant = 'legal' }: LegalLayoutProps) {
|
||||
export default function LegalLayout({ title, children }: LegalLayoutProps) {
|
||||
return (
|
||||
<main className={`${soehne.className} min-h-screen bg-white text-gray-900`}>
|
||||
{/* Header - Nav handles all conditional logic */}
|
||||
<Nav variant={navVariant} />
|
||||
<main className='min-h-screen bg-[#1C1C1C] font-[430] font-season text-[#ECECEC]'>
|
||||
<header>
|
||||
<Navbar />
|
||||
</header>
|
||||
|
||||
{/* Content */}
|
||||
<div className='px-12 pt-[40px] pb-[40px]'>
|
||||
<h1 className='mb-12 text-center font-bold text-4xl text-gray-900 md:text-5xl'>{title}</h1>
|
||||
<div className='prose prose-gray mx-auto prose-h2:mt-12 prose-h3:mt-8 prose-h2:mb-6 prose-h3:mb-4 space-y-8 text-gray-700'>
|
||||
<div className='mx-auto max-w-[800px] px-6 pt-[60px] pb-[80px] sm:px-12'>
|
||||
<h1 className='mb-12 text-center font-[500] text-4xl text-[#ECECEC] md:text-5xl'>
|
||||
{title}
|
||||
</h1>
|
||||
<div className='space-y-8 text-[#999] text-[15px] leading-[1.7] [&_h2]:mt-12 [&_h2]:mb-6 [&_h2]:text-[#ECECEC] [&_h3]:mt-8 [&_h3]:mb-4 [&_h3]:text-[#ECECEC] [&_li]:text-[#999] [&_strong]:text-[#ECECEC]'>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer - Only for hosted instances */}
|
||||
{isHosted && (
|
||||
<div className='relative z-20'>
|
||||
<Footer fullWidth={true} />
|
||||
</div>
|
||||
)}
|
||||
{isHosted && <Footer />}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { GithubIcon } from '@/components/icons'
|
||||
import { isHosted } from '@/lib/core/config/feature-flags'
|
||||
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
||||
import { getFormattedGitHubStars } from '@/app/(landing)/actions/github'
|
||||
import { useBrandConfig } from '@/ee/whitelabeling'
|
||||
import { useBrandedButtonClass } from '@/hooks/use-branded-button-class'
|
||||
@@ -118,7 +117,7 @@ export default function Nav({ hideAuthButtons = false, variant = 'landing' }: Na
|
||||
return (
|
||||
<nav
|
||||
aria-label='Primary navigation'
|
||||
className={`${soehne.className} flex w-full items-center justify-between px-4 ${
|
||||
className={`flex w-full items-center justify-between px-4 ${
|
||||
variant === 'auth' ? 'pt-[20px] sm:pt-[16.5px]' : 'pt-[12px] sm:pt-[8.5px]'
|
||||
} pb-[21px] sm:px-8 md:px-[44px]`}
|
||||
itemScope
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import Image from 'next/image'
|
||||
import { inter } from '@/app/_styles/fonts/inter/inter'
|
||||
|
||||
interface Testimonial {
|
||||
text: string
|
||||
@@ -123,7 +122,7 @@ export default function Testimonials() {
|
||||
return (
|
||||
<section
|
||||
id='testimonials'
|
||||
className={`flex hidden h-[150px] items-center sm:block ${inter.variable}`}
|
||||
className='flex hidden h-[150px] items-center sm:block'
|
||||
aria-label='Social proof testimonials'
|
||||
>
|
||||
<div className='relative mx-auto h-full w-full max-w-[1289px] pl-[2px]'>
|
||||
@@ -180,9 +179,7 @@ export default function Testimonials() {
|
||||
</div>
|
||||
|
||||
{/* Tweet content below with padding */}
|
||||
<p
|
||||
className={`${inter.className} mt-2 line-clamp-4 font-[380] text-[#0A0A0A] text-[13px] leading-[1.3] transition-colors duration-300 group-hover:text-white`}
|
||||
>
|
||||
<p className='mt-2 line-clamp-4 font-[380] text-[#0A0A0A] text-[13px] leading-[1.3] transition-colors duration-300 group-hover:text-white'>
|
||||
{tweet.text}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -582,7 +582,7 @@ export default function PrivacyPolicy() {
|
||||
Please note that we may ask you to verify your identity before responding to such
|
||||
requests.
|
||||
</p>
|
||||
<p className='mb-4 border-[var(--brand-primary-hex)] border-l-4 bg-[var(--brand-primary-hex)]/10 p-3'>
|
||||
<p className='mb-4 border-[#3d3d3d] border-l-4 bg-[#2A2A2A] p-3 text-[#ECECEC]'>
|
||||
You have the right to complain to a Data Protection Authority about our collection and use
|
||||
of your Personal Information. For more information, please contact your local data
|
||||
protection authority in the European Economic Area (EEA).
|
||||
@@ -604,10 +604,7 @@ export default function PrivacyPolicy() {
|
||||
sharing practices (such as analytics or advertising services) may be considered a "sale"
|
||||
or "share" under CCPA/CPRA. You have the right to opt-out of such data sharing. To
|
||||
exercise this right, contact us at{' '}
|
||||
<Link
|
||||
href='mailto:privacy@sim.ai'
|
||||
className='text-[var(--brand-primary-hex)] underline hover:text-[var(--brand-primary-hover-hex)]'
|
||||
>
|
||||
<Link href='mailto:privacy@sim.ai' className='text-[#ECECEC] underline hover:text-white'>
|
||||
privacy@sim.ai
|
||||
</Link>
|
||||
.
|
||||
@@ -693,10 +690,7 @@ export default function PrivacyPolicy() {
|
||||
Sim interacts with are not covered by this policy and should be reported directly to the
|
||||
solution vendor in accordance with their disclosure policy (if any). Before beginning your
|
||||
inquiry, email us at{' '}
|
||||
<Link
|
||||
href='mailto:security@sim.ai'
|
||||
className='text-[var(--brand-primary-hex)] underline hover:text-[var(--brand-primary-hover-hex)]'
|
||||
>
|
||||
<Link href='mailto:security@sim.ai' className='text-[#ECECEC] underline hover:text-white'>
|
||||
security@sim.ai
|
||||
</Link>{' '}
|
||||
if you're unsure whether a system or endpoint is in scope.
|
||||
@@ -715,10 +709,7 @@ export default function PrivacyPolicy() {
|
||||
<h3 className='mb-2 font-medium text-xl'>Reporting a vulnerability</h3>
|
||||
<p className='mb-4'>
|
||||
To report any security flaws, send an email to{' '}
|
||||
<Link
|
||||
href='mailto:security@sim.ai'
|
||||
className='text-[var(--brand-primary-hex)] underline hover:text-[var(--brand-primary-hover-hex)]'
|
||||
>
|
||||
<Link href='mailto:security@sim.ai' className='text-[#ECECEC] underline hover:text-white'>
|
||||
security@sim.ai
|
||||
</Link>
|
||||
. The next business day, we'll acknowledge receipt of your vulnerability report and keep
|
||||
@@ -762,7 +753,7 @@ export default function PrivacyPolicy() {
|
||||
Email:{' '}
|
||||
<Link
|
||||
href='mailto:privacy@sim.ai'
|
||||
className='text-[var(--brand-primary-hex)] underline hover:text-[var(--brand-primary-hover-hex)]'
|
||||
className='text-[#ECECEC] underline hover:text-white'
|
||||
>
|
||||
privacy@sim.ai
|
||||
</Link>
|
||||
|
||||
@@ -10,7 +10,7 @@ export function BackLink() {
|
||||
return (
|
||||
<Link
|
||||
href='/studio'
|
||||
className='group flex items-center gap-1 text-gray-600 text-sm hover:text-gray-900'
|
||||
className='group flex items-center gap-1 text-[#999] text-sm hover:text-[#ECECEC]'
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
|
||||
@@ -6,7 +6,6 @@ import { FAQ } from '@/lib/blog/faq'
|
||||
import { getAllPostMeta, getPostBySlug, getRelatedPosts } from '@/lib/blog/registry'
|
||||
import { buildArticleJsonLd, buildBreadcrumbJsonLd, buildPostMetadata } from '@/lib/blog/seo'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
||||
import { BackLink } from '@/app/(landing)/studio/[slug]/back-link'
|
||||
import { ShareButton } from '@/app/(landing)/studio/[slug]/share-button'
|
||||
|
||||
@@ -36,11 +35,7 @@ export default async function Page({ params }: { params: Promise<{ slug: string
|
||||
const related = await getRelatedPosts(slug, 3)
|
||||
|
||||
return (
|
||||
<article
|
||||
className={`${soehne.className} w-full`}
|
||||
itemScope
|
||||
itemType='https://schema.org/BlogPosting'
|
||||
>
|
||||
<article className='w-full' itemScope itemType='https://schema.org/BlogPosting'>
|
||||
<script
|
||||
type='application/ld+json'
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
@@ -71,7 +66,7 @@ export default async function Page({ params }: { params: Promise<{ slug: string
|
||||
</div>
|
||||
<div className='flex flex-1 flex-col justify-between'>
|
||||
<h1
|
||||
className='font-medium text-[36px] leading-tight tracking-tight sm:text-[48px] md:text-[56px] lg:text-[64px]'
|
||||
className='font-[500] text-[#ECECEC] text-[36px] leading-tight tracking-tight sm:text-[48px] md:text-[56px] lg:text-[64px]'
|
||||
itemProp='headline'
|
||||
>
|
||||
{post.title}
|
||||
@@ -90,7 +85,7 @@ export default async function Page({ params }: { params: Promise<{ slug: string
|
||||
href={a?.url || '#'}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer author'
|
||||
className='text-[14px] text-gray-600 leading-[1.5] hover:text-gray-900 sm:text-[16px]'
|
||||
className='text-[#999] text-[14px] leading-[1.5] hover:text-[#ECECEC] sm:text-[16px]'
|
||||
itemProp='author'
|
||||
itemScope
|
||||
itemType='https://schema.org/Person'
|
||||
@@ -104,11 +99,11 @@ export default async function Page({ params }: { params: Promise<{ slug: string
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr className='mt-8 border-gray-200 border-t sm:mt-12' />
|
||||
<hr className='mt-8 border-[#2A2A2A] 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-[14px] text-gray-600 leading-[1.5] sm:text-[16px]'
|
||||
className='block text-[#999] text-[14px] leading-[1.5] sm:text-[16px]'
|
||||
dateTime={post.date}
|
||||
itemProp='datePublished'
|
||||
>
|
||||
@@ -121,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-[18px] leading-[1.5] sm:text-[20px] md:text-[26px]'>
|
||||
<p className='m-0 block translate-y-[-4px] font-[400] text-[#999] text-[18px] leading-[1.5] sm:text-[20px] md:text-[26px]'>
|
||||
{post.description}
|
||||
</p>
|
||||
</div>
|
||||
@@ -129,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 max-w-none'>
|
||||
<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]'>
|
||||
<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-medium text-[24px]'>Related posts</h2>
|
||||
<h2 className='mb-4 font-[500] text-[#ECECEC] text-[24px]'>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={`/studio/${p.slug}`} className='group'>
|
||||
<div className='overflow-hidden rounded-lg border border-gray-200'>
|
||||
<div className='overflow-hidden rounded-lg border border-[#2A2A2A]'>
|
||||
<Image
|
||||
src={p.ogImage}
|
||||
alt={p.title}
|
||||
@@ -152,14 +147,14 @@ export default async function Page({ params }: { params: Promise<{ slug: string
|
||||
unoptimized
|
||||
/>
|
||||
<div className='p-3'>
|
||||
<div className='mb-1 text-gray-600 text-xs'>
|
||||
<div className='mb-1 text-[#999] text-xs'>
|
||||
{new Date(p.date).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</div>
|
||||
<div className='font-medium text-sm leading-tight'>{p.title}</div>
|
||||
<div className='font-[500] text-[#ECECEC] text-sm leading-tight'>{p.title}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@@ -2,7 +2,12 @@
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Share2 } from 'lucide-react'
|
||||
import { Popover, PopoverContent, PopoverItem, PopoverTrigger } from '@/components/emcn'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/emcn'
|
||||
|
||||
interface ShareButtonProps {
|
||||
url: string
|
||||
@@ -10,56 +15,46 @@ interface ShareButtonProps {
|
||||
}
|
||||
|
||||
export function ShareButton({ url, title }: ShareButtonProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const handleCopyLink = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url)
|
||||
setCopied(true)
|
||||
setTimeout(() => {
|
||||
setCopied(false)
|
||||
setOpen(false)
|
||||
}, 1000)
|
||||
setTimeout(() => setCopied(false), 1500)
|
||||
} catch {
|
||||
setOpen(false)
|
||||
/* clipboard unavailable */
|
||||
}
|
||||
}
|
||||
|
||||
const handleShareTwitter = () => {
|
||||
const tweetUrl = `https://twitter.com/intent/tweet?url=${encodeURIComponent(url)}&text=${encodeURIComponent(title)}`
|
||||
window.open(tweetUrl, '_blank', 'noopener,noreferrer')
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const handleShareLinkedIn = () => {
|
||||
const linkedInUrl = `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(url)}`
|
||||
window.open(linkedInUrl, '_blank', 'noopener,noreferrer')
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
colorScheme='inverted'
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className='flex items-center gap-1.5 text-gray-600 text-sm hover:text-gray-900'
|
||||
className='flex items-center gap-1.5 text-[#999] text-sm hover:text-[#ECECEC]'
|
||||
aria-label='Share this post'
|
||||
>
|
||||
<Share2 className='h-4 w-4' />
|
||||
<span>Share</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align='end' minWidth={140}>
|
||||
<PopoverItem onClick={handleCopyLink}>{copied ? 'Copied!' : 'Copy link'}</PopoverItem>
|
||||
<PopoverItem onClick={handleShareTwitter}>Share on X</PopoverItem>
|
||||
<PopoverItem onClick={handleShareLinkedIn}>Share on LinkedIn</PopoverItem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuItem onSelect={handleCopyLink}>
|
||||
{copied ? 'Copied!' : 'Copy link'}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={handleShareTwitter}>Share on X</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={handleShareLinkedIn}>Share on LinkedIn</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { Metadata } from 'next'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { getAllPostMeta } from '@/lib/blog/registry'
|
||||
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
||||
|
||||
export const revalidate = 3600
|
||||
|
||||
@@ -23,8 +22,8 @@ export default async function AuthorPage({ params }: { params: Promise<{ id: str
|
||||
const author = posts[0]?.author
|
||||
if (!author) {
|
||||
return (
|
||||
<main className={`${soehne.className} mx-auto max-w-[900px] px-6 py-10 sm:px-8 md:px-12`}>
|
||||
<h1 className='font-medium text-[32px]'>Author not found</h1>
|
||||
<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>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -37,7 +36,7 @@ export default async function AuthorPage({ params }: { params: Promise<{ id: str
|
||||
image: author.avatarUrl,
|
||||
}
|
||||
return (
|
||||
<main className={`${soehne.className} mx-auto max-w-[900px] px-6 py-10 sm:px-8 md:px-12`}>
|
||||
<main className='mx-auto max-w-[900px] px-6 py-10 sm:px-8 md:px-12'>
|
||||
<script
|
||||
type='application/ld+json'
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(personJsonLd) }}
|
||||
@@ -53,12 +52,12 @@ export default async function AuthorPage({ params }: { params: Promise<{ id: str
|
||||
unoptimized
|
||||
/>
|
||||
) : null}
|
||||
<h1 className='font-medium text-[32px] leading-tight'>{author.name}</h1>
|
||||
<h1 className='font-[500] text-[#ECECEC] text-[32px] 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={`/studio/${p.slug}`} className='group'>
|
||||
<div className='overflow-hidden rounded-lg border border-gray-200'>
|
||||
<div className='overflow-hidden rounded-lg border border-[#2A2A2A]'>
|
||||
<Image
|
||||
src={p.ogImage}
|
||||
alt={p.title}
|
||||
@@ -68,14 +67,14 @@ export default async function AuthorPage({ params }: { params: Promise<{ id: str
|
||||
unoptimized
|
||||
/>
|
||||
<div className='p-3'>
|
||||
<div className='mb-1 text-gray-600 text-xs'>
|
||||
<div className='mb-1 text-[#999] text-xs'>
|
||||
{new Date(p.date).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</div>
|
||||
<div className='font-medium text-sm leading-tight'>{p.title}</div>
|
||||
<div className='font-[500] text-[#ECECEC] text-sm leading-tight'>{p.title}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Footer, Nav } from '@/app/(landing)/components'
|
||||
import Footer from '@/app/(home)/components/footer/footer'
|
||||
import Navbar from '@/app/(home)/components/navbar/navbar'
|
||||
|
||||
export default function StudioLayout({ children }: { children: React.ReactNode }) {
|
||||
const orgJsonLd = {
|
||||
@@ -23,7 +24,7 @@ export default function StudioLayout({ children }: { children: React.ReactNode }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex min-h-screen flex-col'>
|
||||
<div className='flex min-h-screen flex-col bg-[#1C1C1C] font-[430] font-season text-[#ECECEC]'>
|
||||
<script
|
||||
type='application/ld+json'
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(orgJsonLd) }}
|
||||
@@ -32,9 +33,11 @@ export default function StudioLayout({ children }: { children: React.ReactNode }
|
||||
type='application/ld+json'
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(websiteJsonLd) }}
|
||||
/>
|
||||
<Nav hideAuthButtons={false} variant='landing' />
|
||||
<header>
|
||||
<Navbar />
|
||||
</header>
|
||||
<main className='relative flex-1'>{children}</main>
|
||||
<Footer fullWidth={true} />
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Metadata } from 'next'
|
||||
import Link from 'next/link'
|
||||
import { getAllPostMeta } from '@/lib/blog/registry'
|
||||
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
||||
import { PostGrid } from '@/app/(landing)/studio/post-grid'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -46,13 +45,15 @@ export default async function StudioIndex({
|
||||
}
|
||||
|
||||
return (
|
||||
<main className={`${soehne.className} mx-auto max-w-[1200px] px-6 py-12 sm:px-8 md:px-12`}>
|
||||
<main className='mx-auto max-w-[1200px] px-6 py-12 sm:px-8 md:px-12'>
|
||||
<script
|
||||
type='application/ld+json'
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(studioJsonLd) }}
|
||||
/>
|
||||
<h1 className='mb-3 font-medium text-[40px] leading-tight sm:text-[56px]'>Sim Studio</h1>
|
||||
<p className='mb-10 text-[18px] text-gray-700'>
|
||||
<h1 className='mb-3 font-[500] text-[#ECECEC] text-[40px] leading-tight sm:text-[56px]'>
|
||||
Sim Studio
|
||||
</h1>
|
||||
<p className='mb-10 text-[#999] text-[18px]'>
|
||||
Announcements, insights, and guides for building AI agent workflows.
|
||||
</p>
|
||||
|
||||
@@ -74,18 +75,18 @@ export default async function StudioIndex({
|
||||
{pageNum > 1 && (
|
||||
<Link
|
||||
href={`/studio?page=${pageNum - 1}${tag ? `&tag=${encodeURIComponent(tag)}` : ''}`}
|
||||
className='rounded border px-3 py-1 text-sm'
|
||||
className='rounded-[5px] border border-[#3d3d3d] px-3 py-1 text-[#ECECEC] text-sm transition-colors hover:bg-[#2A2A2A]'
|
||||
>
|
||||
Previous
|
||||
</Link>
|
||||
)}
|
||||
<span className='text-gray-600 text-sm'>
|
||||
<span className='text-[#999] text-sm'>
|
||||
Page {pageNum} of {totalPages}
|
||||
</span>
|
||||
{pageNum < totalPages && (
|
||||
<Link
|
||||
href={`/studio?page=${pageNum + 1}${tag ? `&tag=${encodeURIComponent(tag)}` : ''}`}
|
||||
className='rounded border px-3 py-1 text-sm'
|
||||
className='rounded-[5px] border border-[#3d3d3d] px-3 py-1 text-[#ECECEC] text-sm transition-colors hover:bg-[#2A2A2A]'
|
||||
>
|
||||
Next
|
||||
</Link>
|
||||
|
||||
@@ -27,7 +27,7 @@ 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={`/studio/${p.slug}`} className='group flex flex-col'>
|
||||
<div className='flex h-full flex-col overflow-hidden rounded-xl border border-gray-200 transition-colors duration-300 hover:border-gray-300'>
|
||||
<div className='flex h-full flex-col overflow-hidden rounded-xl border border-[#2A2A2A] transition-colors duration-300 hover:border-[#3d3d3d]'>
|
||||
{/* Image container with fixed aspect ratio to prevent layout shift */}
|
||||
<div className='relative aspect-video w-full overflow-hidden'>
|
||||
<Image
|
||||
@@ -42,29 +42,29 @@ export function PostGrid({ posts }: { posts: Post[] }) {
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-1 flex-col p-4'>
|
||||
<div className='mb-2 text-gray-600 text-xs'>
|
||||
<div className='mb-2 text-[#999] text-xs'>
|
||||
{new Date(p.date).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</div>
|
||||
<h3 className='shine-text mb-1 font-medium text-lg leading-tight'>{p.title}</h3>
|
||||
<p className='mb-3 line-clamp-3 flex-1 text-gray-700 text-sm'>{p.description}</p>
|
||||
<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>
|
||||
<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-white'>
|
||||
<Avatar key={idx} className='size-4 border border-[#1C1C1C]'>
|
||||
<AvatarImage src={author?.avatarUrl} alt={author?.name} />
|
||||
<AvatarFallback className='border border-white bg-gray-100 text-[10px] text-gray-600'>
|
||||
<AvatarFallback className='border border-[#1C1C1C] bg-[#2A2A2A] text-[#999] text-[10px]'>
|
||||
{author?.name.slice(0, 2)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
))}
|
||||
</div>
|
||||
<span className='text-gray-600 text-xs'>
|
||||
<span className='text-[#999] text-xs'>
|
||||
{(p.authors && p.authors.length > 0 ? p.authors : [p.author])
|
||||
.slice(0, 2)
|
||||
.map((a) => a?.name)
|
||||
|
||||
@@ -10,16 +10,19 @@ 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-medium text-[32px] leading-tight'>Browse by tag</h1>
|
||||
<h1 className='mb-6 font-[500] text-[#ECECEC] text-[32px] leading-tight'>Browse by tag</h1>
|
||||
<div className='flex flex-wrap gap-3'>
|
||||
<Link href='/studio' className='rounded-full border border-gray-300 px-3 py-1 text-sm'>
|
||||
<Link
|
||||
href='/studio'
|
||||
className='rounded-full border border-[#3d3d3d] px-3 py-1 text-[#ECECEC] text-sm transition-colors hover:bg-[#2A2A2A]'
|
||||
>
|
||||
All
|
||||
</Link>
|
||||
{tags.map((t) => (
|
||||
<Link
|
||||
key={t.tag}
|
||||
href={`/studio?tag=${encodeURIComponent(t.tag)}`}
|
||||
className='rounded-full border border-gray-300 px-3 py-1 text-sm'
|
||||
className='rounded-full border border-[#3d3d3d] px-3 py-1 text-[#ECECEC] text-sm transition-colors hover:bg-[#2A2A2A]'
|
||||
>
|
||||
{t.tag} ({t.count})
|
||||
</Link>
|
||||
|
||||
@@ -289,7 +289,7 @@ export default function TermsOfService() {
|
||||
Agreement. The arbitration will be conducted by JAMS, an established alternative dispute
|
||||
resolution provider.
|
||||
</p>
|
||||
<p className='mb-4 border-[var(--brand-primary-hex)] border-l-4 bg-[var(--brand-primary-hex)]/10 p-3'>
|
||||
<p className='mb-4 border-[#3d3d3d] border-l-4 bg-[#2A2A2A] p-3 text-[#ECECEC]'>
|
||||
YOU AND COMPANY AGREE THAT EACH OF US MAY BRING CLAIMS AGAINST THE OTHER ONLY ON AN
|
||||
INDIVIDUAL BASIS AND NOT ON A CLASS, REPRESENTATIVE, OR COLLECTIVE BASIS. ONLY INDIVIDUAL
|
||||
RELIEF IS AVAILABLE, AND DISPUTES OF MORE THAN ONE CUSTOMER OR USER CANNOT BE ARBITRATED
|
||||
@@ -298,10 +298,7 @@ export default function TermsOfService() {
|
||||
<p className='mb-4'>
|
||||
You have the right to opt out of the provisions of this Arbitration Agreement by sending a
|
||||
timely written notice of your decision to opt out to:{' '}
|
||||
<Link
|
||||
href='mailto:legal@sim.ai'
|
||||
className='text-[var(--brand-primary-hex)] underline hover:text-[var(--brand-primary-hover-hex)]'
|
||||
>
|
||||
<Link href='mailto:legal@sim.ai' className='text-[#ECECEC] underline hover:text-white'>
|
||||
legal@sim.ai{' '}
|
||||
</Link>
|
||||
within 30 days after first becoming subject to this Arbitration Agreement.
|
||||
@@ -350,7 +347,7 @@ export default function TermsOfService() {
|
||||
Our Copyright Agent can be reached at:{' '}
|
||||
<Link
|
||||
href='mailto:copyright@sim.ai'
|
||||
className='text-[var(--brand-primary-hex)] underline hover:text-[var(--brand-primary-hover-hex)]'
|
||||
className='text-[#ECECEC] underline hover:text-white'
|
||||
>
|
||||
copyright@sim.ai
|
||||
</Link>
|
||||
@@ -361,10 +358,7 @@ export default function TermsOfService() {
|
||||
<h2 className='mb-4 font-semibold text-2xl'>18. Contact Us</h2>
|
||||
<p>
|
||||
If you have any questions about these Terms, please contact us at:{' '}
|
||||
<Link
|
||||
href='mailto:legal@sim.ai'
|
||||
className='text-[var(--brand-primary-hex)] underline hover:text-[var(--brand-primary-hover-hex)]'
|
||||
>
|
||||
<Link href='mailto:legal@sim.ai' className='text-[#ECECEC] underline hover:text-white'>
|
||||
legal@sim.ai
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
@@ -17,6 +17,18 @@
|
||||
--terminal-height: 206px; /* TERMINAL_HEIGHT.DEFAULT */
|
||||
}
|
||||
|
||||
.workspace-root {
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.workspace-root code,
|
||||
.workspace-root kbd,
|
||||
.workspace-root samp,
|
||||
.workspace-root pre,
|
||||
.workspace-root .font-mono {
|
||||
letter-spacing: normal;
|
||||
}
|
||||
|
||||
.sidebar-container {
|
||||
width: var(--sidebar-width);
|
||||
transition: width 200ms cubic-bezier(0.25, 0.1, 0.25, 1);
|
||||
@@ -33,14 +45,42 @@
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
html[data-sidebar-collapsed] .sidebar-container span,
|
||||
html[data-sidebar-collapsed] .sidebar-container .text-small {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.sidebar-container .sidebar-collapse-hide {
|
||||
transition: opacity 60ms ease;
|
||||
}
|
||||
|
||||
.sidebar-container[data-collapsed] .sidebar-collapse-hide {
|
||||
.sidebar-container[data-collapsed] .sidebar-collapse-hide,
|
||||
html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-hide {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-remove {
|
||||
display: none;
|
||||
}
|
||||
|
||||
html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-btn {
|
||||
width: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@keyframes sidebar-collapse-guard {
|
||||
from {
|
||||
pointer-events: none;
|
||||
}
|
||||
to {
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-container[data-collapsed] {
|
||||
animation: sidebar-collapse-guard 250ms step-end;
|
||||
}
|
||||
|
||||
.sidebar-container.is-resizing {
|
||||
transition: none;
|
||||
}
|
||||
@@ -145,7 +185,7 @@
|
||||
--brand-400: #8e4cfb;
|
||||
--brand-secondary: #33b4ff;
|
||||
--brand-tertiary: #22c55e;
|
||||
--brand-tertiary-2: #32bd7e;
|
||||
--brand-tertiary-2: #33c482;
|
||||
--selection: #1a5cf6;
|
||||
--warning: #ea580c;
|
||||
|
||||
@@ -267,7 +307,7 @@
|
||||
--brand-400: #8e4cfb;
|
||||
--brand-secondary: #33b4ff;
|
||||
--brand-tertiary: #22c55e;
|
||||
--brand-tertiary-2: #32bd7e;
|
||||
--brand-tertiary-2: #33c482;
|
||||
--selection: #4b83f7;
|
||||
--warning: #ff6600;
|
||||
|
||||
@@ -550,42 +590,6 @@ input[type="search"]::-ms-clear {
|
||||
background-color: hsl(var(--input-background));
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
background-color: var(--white) !important;
|
||||
border-color: var(--border-muted) !important;
|
||||
}
|
||||
|
||||
.dark .auth-card {
|
||||
background-color: var(--surface-1) !important;
|
||||
border-color: var(--border-muted) !important;
|
||||
}
|
||||
|
||||
.auth-text-primary {
|
||||
color: var(--text-inverse) !important;
|
||||
}
|
||||
|
||||
.auth-text-secondary {
|
||||
color: var(--text-secondary) !important;
|
||||
}
|
||||
|
||||
.auth-text-muted {
|
||||
color: var(--text-muted) !important;
|
||||
}
|
||||
|
||||
.auth-divider {
|
||||
border-color: var(--border-muted) !important;
|
||||
}
|
||||
|
||||
.auth-card-shadow {
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.05),
|
||||
0 4px 6px -2px rgba(0, 0, 0, 0.05) !important;
|
||||
}
|
||||
|
||||
.auth-link {
|
||||
color: var(--text-muted) !important;
|
||||
}
|
||||
|
||||
.transition-ring {
|
||||
transition-property: box-shadow, transform;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
@@ -790,6 +794,59 @@ input[type="search"]::-ms-clear {
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes notification-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(calc(var(--stack-offset, 0px) - 8px)) scale(0.97);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(var(--stack-offset, 0px)) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes notification-countdown {
|
||||
from {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
to {
|
||||
stroke-dashoffset: 34.56;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes notification-exit {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateX(var(--stack-offset, 0px)) scale(1);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateX(calc(var(--stack-offset, 0px) + 8px)) scale(0.97);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes toast-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px) scale(0.97);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes toast-exit {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateY(8px) scale(0.97);
|
||||
}
|
||||
}
|
||||
|
||||
/* WandPromptBar status indicator */
|
||||
@keyframes smoke-pulse {
|
||||
0%,
|
||||
|
||||
@@ -1,187 +0,0 @@
|
||||
/**
|
||||
* POST /api/attribution
|
||||
*
|
||||
* Automatic UTM-based referral attribution.
|
||||
*
|
||||
* Reads the `sim_utm` cookie (set by proxy on auth pages), matches a campaign
|
||||
* by UTM specificity, and atomically inserts an attribution record + applies
|
||||
* bonus credits.
|
||||
*
|
||||
* Idempotent — the unique constraint on `userId` prevents double-attribution.
|
||||
*/
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { referralAttribution, referralCampaigns, userStats } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { cookies } from 'next/headers'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { applyBonusCredits } from '@/lib/billing/credits/bonus'
|
||||
|
||||
const logger = createLogger('AttributionAPI')
|
||||
|
||||
const COOKIE_NAME = 'sim_utm'
|
||||
|
||||
const UtmCookieSchema = z.object({
|
||||
utm_source: z.string().optional(),
|
||||
utm_medium: z.string().optional(),
|
||||
utm_campaign: z.string().optional(),
|
||||
utm_content: z.string().optional(),
|
||||
referrer_url: z.string().optional(),
|
||||
landing_page: z.string().optional(),
|
||||
created_at: z.string().optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Finds the most specific active campaign matching the given UTM params.
|
||||
* Null fields on a campaign act as wildcards. Ties broken by newest campaign.
|
||||
*/
|
||||
async function findMatchingCampaign(utmData: z.infer<typeof UtmCookieSchema>) {
|
||||
const campaigns = await db
|
||||
.select()
|
||||
.from(referralCampaigns)
|
||||
.where(eq(referralCampaigns.isActive, true))
|
||||
|
||||
let bestMatch: (typeof campaigns)[number] | null = null
|
||||
let bestScore = -1
|
||||
|
||||
for (const campaign of campaigns) {
|
||||
let score = 0
|
||||
let mismatch = false
|
||||
|
||||
const fields = [
|
||||
{ campaignVal: campaign.utmSource, utmVal: utmData.utm_source },
|
||||
{ campaignVal: campaign.utmMedium, utmVal: utmData.utm_medium },
|
||||
{ campaignVal: campaign.utmCampaign, utmVal: utmData.utm_campaign },
|
||||
{ campaignVal: campaign.utmContent, utmVal: utmData.utm_content },
|
||||
] as const
|
||||
|
||||
for (const { campaignVal, utmVal } of fields) {
|
||||
if (campaignVal === null) continue
|
||||
if (campaignVal === utmVal) {
|
||||
score++
|
||||
} else {
|
||||
mismatch = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!mismatch && score > 0) {
|
||||
if (
|
||||
score > bestScore ||
|
||||
(score === bestScore &&
|
||||
bestMatch &&
|
||||
campaign.createdAt.getTime() > bestMatch.createdAt.getTime())
|
||||
) {
|
||||
bestScore = score
|
||||
bestMatch = campaign
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bestMatch
|
||||
}
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const cookieStore = await cookies()
|
||||
const utmCookie = cookieStore.get(COOKIE_NAME)
|
||||
if (!utmCookie?.value) {
|
||||
return NextResponse.json({ attributed: false, reason: 'no_utm_cookie' })
|
||||
}
|
||||
|
||||
let utmData: z.infer<typeof UtmCookieSchema>
|
||||
try {
|
||||
let decoded: string
|
||||
try {
|
||||
decoded = decodeURIComponent(utmCookie.value)
|
||||
} catch {
|
||||
decoded = utmCookie.value
|
||||
}
|
||||
utmData = UtmCookieSchema.parse(JSON.parse(decoded))
|
||||
} catch {
|
||||
logger.warn('Failed to parse UTM cookie', { userId: session.user.id })
|
||||
cookieStore.delete(COOKIE_NAME)
|
||||
return NextResponse.json({ attributed: false, reason: 'invalid_cookie' })
|
||||
}
|
||||
|
||||
const matchedCampaign = await findMatchingCampaign(utmData)
|
||||
if (!matchedCampaign) {
|
||||
cookieStore.delete(COOKIE_NAME)
|
||||
return NextResponse.json({ attributed: false, reason: 'no_matching_campaign' })
|
||||
}
|
||||
|
||||
const bonusAmount = Number(matchedCampaign.bonusCreditAmount)
|
||||
|
||||
let attributed = false
|
||||
await db.transaction(async (tx) => {
|
||||
const [existingStats] = await tx
|
||||
.select({ id: userStats.id })
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, session.user.id))
|
||||
.limit(1)
|
||||
|
||||
if (!existingStats) {
|
||||
await tx.insert(userStats).values({
|
||||
id: nanoid(),
|
||||
userId: session.user.id,
|
||||
})
|
||||
}
|
||||
|
||||
const result = await tx
|
||||
.insert(referralAttribution)
|
||||
.values({
|
||||
id: nanoid(),
|
||||
userId: session.user.id,
|
||||
campaignId: matchedCampaign.id,
|
||||
utmSource: utmData.utm_source || null,
|
||||
utmMedium: utmData.utm_medium || null,
|
||||
utmCampaign: utmData.utm_campaign || null,
|
||||
utmContent: utmData.utm_content || null,
|
||||
referrerUrl: utmData.referrer_url || null,
|
||||
landingPage: utmData.landing_page || null,
|
||||
bonusCreditAmount: bonusAmount.toString(),
|
||||
})
|
||||
.onConflictDoNothing({ target: referralAttribution.userId })
|
||||
.returning({ id: referralAttribution.id })
|
||||
|
||||
if (result.length > 0) {
|
||||
await applyBonusCredits(session.user.id, bonusAmount, tx)
|
||||
attributed = true
|
||||
}
|
||||
})
|
||||
|
||||
if (attributed) {
|
||||
logger.info('Referral attribution created and bonus credits applied', {
|
||||
userId: session.user.id,
|
||||
campaignId: matchedCampaign.id,
|
||||
campaignName: matchedCampaign.name,
|
||||
utmSource: utmData.utm_source,
|
||||
utmCampaign: utmData.utm_campaign,
|
||||
utmContent: utmData.utm_content,
|
||||
bonusAmount,
|
||||
})
|
||||
} else {
|
||||
logger.info('User already attributed, skipping', { userId: session.user.id })
|
||||
}
|
||||
|
||||
cookieStore.delete(COOKIE_NAME)
|
||||
|
||||
return NextResponse.json({
|
||||
attributed,
|
||||
bonusAmount: attributed ? bonusAmount : undefined,
|
||||
reason: attributed ? undefined : 'already_attributed',
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Attribution error', { error })
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from '@/lib/copilot/chat-streaming'
|
||||
import { COPILOT_REQUEST_MODES } from '@/lib/copilot/models'
|
||||
import { orchestrateCopilotStream } from '@/lib/copilot/orchestrator'
|
||||
import { getStreamMeta, readStreamEvents } from '@/lib/copilot/orchestrator/stream/buffer'
|
||||
import {
|
||||
authenticateCopilotRequestSessionOnly,
|
||||
createBadRequestResponse,
|
||||
@@ -454,6 +455,30 @@ export async function GET(req: NextRequest) {
|
||||
return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
let streamSnapshot: {
|
||||
events: Array<{ eventId: number; streamId: string; event: Record<string, unknown> }>
|
||||
status: string
|
||||
} | null = null
|
||||
|
||||
if (chat.conversationId) {
|
||||
try {
|
||||
const [meta, events] = await Promise.all([
|
||||
getStreamMeta(chat.conversationId),
|
||||
readStreamEvents(chat.conversationId, 0),
|
||||
])
|
||||
streamSnapshot = {
|
||||
events: events || [],
|
||||
status: meta?.status || 'unknown',
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('Failed to read stream snapshot for chat', {
|
||||
chatId,
|
||||
conversationId: chat.conversationId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const transformedChat = {
|
||||
id: chat.id,
|
||||
title: chat.title,
|
||||
@@ -466,6 +491,7 @@ export async function GET(req: NextRequest) {
|
||||
resources: Array.isArray(chat.resources) ? chat.resources : [],
|
||||
createdAt: chat.createdAt,
|
||||
updatedAt: chat.updatedAt,
|
||||
...(streamSnapshot ? { streamSnapshot } : {}),
|
||||
}
|
||||
|
||||
logger.info(`Retrieved chat ${chatId}`)
|
||||
|
||||
@@ -171,7 +171,7 @@ export async function GET(request: NextRequest) {
|
||||
([category, templates]) => `
|
||||
<h2 style="margin-top: 24px; margin-bottom: 12px; font-size: 14px; color: #666; text-transform: uppercase; letter-spacing: 0.5px;">${category}</h2>
|
||||
<ul style="list-style: none; padding: 0; margin: 0;">
|
||||
${templates.map((t) => `<li style="margin: 8px 0;"><a href="?template=${t}" style="color: #32bd7e; text-decoration: none; font-size: 16px;">${t}</a></li>`).join('')}
|
||||
${templates.map((t) => `<li style="margin: 8px 0;"><a href="?template=${t}" style="color: #33C482; text-decoration: none; font-size: 16px;">${t}</a></li>`).join('')}
|
||||
</ul>
|
||||
`
|
||||
)
|
||||
|
||||
@@ -120,8 +120,8 @@ export async function verifyFileAccess(
|
||||
return true
|
||||
}
|
||||
|
||||
// 1. Workspace files: Check database first (most reliable for both local and cloud)
|
||||
if (inferredContext === 'workspace') {
|
||||
// 1. Workspace / mothership files: Check database first (most reliable for both local and cloud)
|
||||
if (inferredContext === 'workspace' || inferredContext === 'mothership') {
|
||||
return await verifyWorkspaceFileAccess(cloudKey, userId, customConfig, isLocal)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { sanitizeFileName } from '@/executor/constants'
|
||||
import '@/lib/uploads/core/setup.server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import type { StorageContext } from '@/lib/uploads/config'
|
||||
import { generateWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
|
||||
import { isImageFileType, resolveFileType } from '@/lib/uploads/utils/file-utils'
|
||||
import {
|
||||
SUPPORTED_AUDIO_EXTENSIONS,
|
||||
@@ -46,9 +47,10 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
const formData = await request.formData()
|
||||
|
||||
const files = formData.getAll('file') as File[]
|
||||
const rawFiles = formData.getAll('file')
|
||||
const files = rawFiles.filter((f): f is File => f instanceof File)
|
||||
|
||||
if (!files || files.length === 0) {
|
||||
if (files.length === 0) {
|
||||
throw new InvalidRequestError('No files provided')
|
||||
}
|
||||
|
||||
@@ -73,7 +75,7 @@ export async function POST(request: NextRequest) {
|
||||
const uploadResults = []
|
||||
|
||||
for (const file of files) {
|
||||
const originalName = file.name
|
||||
const originalName = file.name || 'untitled'
|
||||
|
||||
if (!validateFileExtension(originalName)) {
|
||||
const extension = originalName.split('.').pop()?.toLowerCase() || 'unknown'
|
||||
@@ -231,6 +233,53 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle mothership context (chat-scoped uploads to workspace S3)
|
||||
if (context === 'mothership') {
|
||||
if (!workspaceId) {
|
||||
throw new InvalidRequestError('Mothership context requires workspaceId parameter')
|
||||
}
|
||||
|
||||
logger.info(`Uploading mothership file: ${originalName}`)
|
||||
|
||||
const storageKey = generateWorkspaceFileKey(workspaceId, originalName)
|
||||
|
||||
const metadata: Record<string, string> = {
|
||||
originalName: originalName,
|
||||
uploadedAt: new Date().toISOString(),
|
||||
purpose: 'mothership',
|
||||
userId: session.user.id,
|
||||
workspaceId,
|
||||
}
|
||||
|
||||
const fileInfo = await storageService.uploadFile({
|
||||
file: buffer,
|
||||
fileName: storageKey,
|
||||
contentType: file.type || 'application/octet-stream',
|
||||
context: 'mothership',
|
||||
preserveKey: true,
|
||||
customKey: storageKey,
|
||||
metadata,
|
||||
})
|
||||
|
||||
const finalPath = usingCloudStorage ? `${fileInfo.path}?context=mothership` : fileInfo.path
|
||||
|
||||
uploadResults.push({
|
||||
fileName: originalName,
|
||||
presignedUrl: '',
|
||||
fileInfo: {
|
||||
path: finalPath,
|
||||
key: fileInfo.key,
|
||||
name: originalName,
|
||||
size: buffer.length,
|
||||
type: file.type || 'application/octet-stream',
|
||||
},
|
||||
directUploadSupported: false,
|
||||
})
|
||||
|
||||
logger.info(`Successfully uploaded mothership file: ${fileInfo.key}`)
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle copilot, chat, profile-pictures contexts
|
||||
if (context === 'copilot' || context === 'chat' || context === 'profile-pictures') {
|
||||
if (context === 'copilot') {
|
||||
|
||||
@@ -19,6 +19,7 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { validateOAuthAccessToken } from '@/lib/auth/oauth-token'
|
||||
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
|
||||
import { ORCHESTRATION_TIMEOUT_MS, SIM_AGENT_API_URL } from '@/lib/copilot/constants'
|
||||
import { orchestrateCopilotStream } from '@/lib/copilot/orchestrator'
|
||||
import { orchestrateSubagentStream } from '@/lib/copilot/orchestrator/subagent'
|
||||
import {
|
||||
executeToolServerSide,
|
||||
@@ -28,6 +29,10 @@ import { DIRECT_TOOL_DEFS, SUBAGENT_TOOL_DEFS } from '@/lib/copilot/tools/mcp/de
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { RateLimiter } from '@/lib/core/rate-limiter'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import {
|
||||
authorizeWorkflowByWorkspacePermission,
|
||||
resolveWorkflowIdForUser,
|
||||
} from '@/lib/workflows/utils'
|
||||
|
||||
const logger = createLogger('CopilotMcpAPI')
|
||||
const mcpRateLimiter = new RateLimiter()
|
||||
@@ -660,12 +665,110 @@ async function handleDirectToolCall(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build mode uses the main chat orchestrator with the 'fast' command instead of
|
||||
* the subagent endpoint. In Go, 'build' is not a registered subagent — it's a mode
|
||||
* (ModeFast) on the main chat processor that bypasses subagent orchestration and
|
||||
* executes all tools directly.
|
||||
*/
|
||||
async function handleBuildToolCall(
|
||||
args: Record<string, unknown>,
|
||||
userId: string,
|
||||
abortSignal?: AbortSignal
|
||||
): Promise<CallToolResult> {
|
||||
try {
|
||||
const requestText = (args.request as string) || JSON.stringify(args)
|
||||
const workflowId = args.workflowId as string | undefined
|
||||
|
||||
const resolved = workflowId
|
||||
? await (async () => {
|
||||
const authorization = await authorizeWorkflowByWorkspacePermission({
|
||||
workflowId,
|
||||
userId,
|
||||
action: 'read',
|
||||
})
|
||||
return authorization.allowed ? { workflowId } : null
|
||||
})()
|
||||
: await resolveWorkflowIdForUser(userId)
|
||||
|
||||
if (!resolved?.workflowId) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
success: false,
|
||||
error: 'workflowId is required for build. Call create_workflow first.',
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
|
||||
const chatId = randomUUID()
|
||||
|
||||
const requestPayload = {
|
||||
message: requestText,
|
||||
workflowId: resolved.workflowId,
|
||||
userId,
|
||||
model: DEFAULT_COPILOT_MODEL,
|
||||
mode: 'agent',
|
||||
commands: ['fast'],
|
||||
messageId: randomUUID(),
|
||||
chatId,
|
||||
}
|
||||
|
||||
const result = await orchestrateCopilotStream(requestPayload, {
|
||||
userId,
|
||||
workflowId: resolved.workflowId,
|
||||
chatId,
|
||||
goRoute: '/api/mcp',
|
||||
autoExecuteTools: true,
|
||||
timeout: 300000,
|
||||
interactive: false,
|
||||
abortSignal,
|
||||
})
|
||||
|
||||
const responseData = {
|
||||
success: result.success,
|
||||
content: result.content,
|
||||
toolCalls: result.toolCalls,
|
||||
error: result.error,
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify(responseData, null, 2) }],
|
||||
isError: !result.success,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Build tool call failed', { error })
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Build failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubagentToolCall(
|
||||
toolDef: (typeof SUBAGENT_TOOL_DEFS)[number],
|
||||
args: Record<string, unknown>,
|
||||
userId: string,
|
||||
abortSignal?: AbortSignal
|
||||
): Promise<CallToolResult> {
|
||||
if (toolDef.agentId === 'build') {
|
||||
return handleBuildToolCall(args, userId, abortSignal)
|
||||
}
|
||||
|
||||
try {
|
||||
const requestText =
|
||||
(args.request as string) ||
|
||||
|
||||
@@ -31,6 +31,8 @@ const FileAttachmentSchema = z.object({
|
||||
const ResourceAttachmentSchema = z.object({
|
||||
type: z.enum(['workflow', 'table', 'file', 'knowledgebase']),
|
||||
id: z.string().min(1),
|
||||
title: z.string().optional(),
|
||||
active: z.boolean().optional(),
|
||||
})
|
||||
|
||||
const MothershipMessageSchema = z.object({
|
||||
@@ -124,9 +126,19 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
if (Array.isArray(resourceAttachments) && resourceAttachments.length > 0) {
|
||||
const results = await Promise.allSettled(
|
||||
resourceAttachments.map((r) =>
|
||||
resolveActiveResourceContext(r.type, r.id, workspaceId, authenticatedUserId)
|
||||
)
|
||||
resourceAttachments.map(async (r) => {
|
||||
const ctx = await resolveActiveResourceContext(
|
||||
r.type,
|
||||
r.id,
|
||||
workspaceId,
|
||||
authenticatedUserId
|
||||
)
|
||||
if (!ctx) return null
|
||||
return {
|
||||
...ctx,
|
||||
tag: r.active ? '@active_tab' : '@open_tab',
|
||||
}
|
||||
})
|
||||
)
|
||||
for (const result of results) {
|
||||
if (result.status === 'fulfilled' && result.value) {
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
/**
|
||||
* POST /api/referral-code/redeem
|
||||
*
|
||||
* Redeem a referral/promo code to receive bonus credits.
|
||||
*
|
||||
* Body:
|
||||
* - code: string — The referral code to redeem
|
||||
*
|
||||
* Response: { redeemed: boolean, bonusAmount?: number, error?: string }
|
||||
*
|
||||
* Constraints:
|
||||
* - Enterprise users cannot redeem codes
|
||||
* - One redemption per user, ever (unique constraint on userId)
|
||||
* - One redemption per organization for team users (partial unique on organizationId)
|
||||
*/
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { referralAttribution, referralCampaigns, userStats } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
|
||||
import { applyBonusCredits } from '@/lib/billing/credits/bonus'
|
||||
import { isEnterprise, isTeam } from '@/lib/billing/plan-helpers'
|
||||
|
||||
const logger = createLogger('ReferralCodeRedemption')
|
||||
|
||||
const RedeemCodeSchema = z.object({
|
||||
code: z.string().min(1, 'Code is required'),
|
||||
})
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { code } = RedeemCodeSchema.parse(body)
|
||||
|
||||
const subscription = await getHighestPrioritySubscription(session.user.id)
|
||||
|
||||
if (isEnterprise(subscription?.plan)) {
|
||||
return NextResponse.json({
|
||||
redeemed: false,
|
||||
error: 'Enterprise accounts cannot redeem referral codes',
|
||||
})
|
||||
}
|
||||
|
||||
const isTeamSub = isTeam(subscription?.plan)
|
||||
const orgId = isTeamSub ? subscription!.referenceId : null
|
||||
|
||||
const normalizedCode = code.trim().toUpperCase()
|
||||
|
||||
const [campaign] = await db
|
||||
.select()
|
||||
.from(referralCampaigns)
|
||||
.where(and(eq(referralCampaigns.code, normalizedCode), eq(referralCampaigns.isActive, true)))
|
||||
.limit(1)
|
||||
|
||||
if (!campaign) {
|
||||
logger.info('Invalid code redemption attempt', {
|
||||
userId: session.user.id,
|
||||
code: normalizedCode,
|
||||
})
|
||||
return NextResponse.json({ error: 'Invalid or expired code' }, { status: 404 })
|
||||
}
|
||||
|
||||
const [existingUserAttribution] = await db
|
||||
.select({ id: referralAttribution.id })
|
||||
.from(referralAttribution)
|
||||
.where(eq(referralAttribution.userId, session.user.id))
|
||||
.limit(1)
|
||||
|
||||
if (existingUserAttribution) {
|
||||
return NextResponse.json({
|
||||
redeemed: false,
|
||||
error: 'You have already redeemed a code',
|
||||
})
|
||||
}
|
||||
|
||||
if (orgId) {
|
||||
const [existingOrgAttribution] = await db
|
||||
.select({ id: referralAttribution.id })
|
||||
.from(referralAttribution)
|
||||
.where(eq(referralAttribution.organizationId, orgId))
|
||||
.limit(1)
|
||||
|
||||
if (existingOrgAttribution) {
|
||||
return NextResponse.json({
|
||||
redeemed: false,
|
||||
error: 'A code has already been redeemed for your organization',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const bonusAmount = Number(campaign.bonusCreditAmount)
|
||||
|
||||
let redeemed = false
|
||||
await db.transaction(async (tx) => {
|
||||
const [existingStats] = await tx
|
||||
.select({ id: userStats.id })
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, session.user.id))
|
||||
.limit(1)
|
||||
|
||||
if (!existingStats) {
|
||||
await tx.insert(userStats).values({
|
||||
id: nanoid(),
|
||||
userId: session.user.id,
|
||||
})
|
||||
}
|
||||
|
||||
const result = await tx
|
||||
.insert(referralAttribution)
|
||||
.values({
|
||||
id: nanoid(),
|
||||
userId: session.user.id,
|
||||
organizationId: orgId,
|
||||
campaignId: campaign.id,
|
||||
utmSource: null,
|
||||
utmMedium: null,
|
||||
utmCampaign: null,
|
||||
utmContent: null,
|
||||
referrerUrl: null,
|
||||
landingPage: null,
|
||||
bonusCreditAmount: bonusAmount.toString(),
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning({ id: referralAttribution.id })
|
||||
|
||||
if (result.length > 0) {
|
||||
await applyBonusCredits(session.user.id, bonusAmount, tx)
|
||||
redeemed = true
|
||||
}
|
||||
})
|
||||
|
||||
if (redeemed) {
|
||||
logger.info('Referral code redeemed', {
|
||||
userId: session.user.id,
|
||||
organizationId: orgId,
|
||||
code: normalizedCode,
|
||||
campaignId: campaign.id,
|
||||
campaignName: campaign.name,
|
||||
bonusAmount,
|
||||
})
|
||||
}
|
||||
|
||||
if (!redeemed) {
|
||||
return NextResponse.json({
|
||||
redeemed: false,
|
||||
error: 'You have already redeemed a code',
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
redeemed: true,
|
||||
bonusAmount,
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: error.errors[0].message }, { status: 400 })
|
||||
}
|
||||
logger.error('Referral code redemption error', { error })
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
276
apps/sim/app/api/table/import-csv/route.ts
Normal file
276
apps/sim/app/api/table/import-csv/route.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import {
|
||||
batchInsertRows,
|
||||
createTable,
|
||||
deleteTable,
|
||||
getWorkspaceTableLimits,
|
||||
type TableSchema,
|
||||
} from '@/lib/table'
|
||||
import type { ColumnDefinition, RowData } from '@/lib/table/types'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
import { normalizeColumn } from '@/app/api/table/utils'
|
||||
|
||||
const logger = createLogger('TableImportCSV')
|
||||
|
||||
const MAX_CSV_FILE_SIZE = 50 * 1024 * 1024
|
||||
const MAX_BATCH_SIZE = 1000
|
||||
const SCHEMA_SAMPLE_SIZE = 100
|
||||
|
||||
type ColumnType = 'string' | 'number' | 'boolean' | 'date'
|
||||
|
||||
async function parseCsvBuffer(
|
||||
buffer: Buffer,
|
||||
delimiter = ','
|
||||
): Promise<{ headers: string[]; rows: Record<string, unknown>[] }> {
|
||||
const { parse } = await import('csv-parse/sync')
|
||||
const parsed = parse(buffer.toString('utf-8'), {
|
||||
columns: true,
|
||||
skip_empty_lines: true,
|
||||
trim: true,
|
||||
relax_column_count: true,
|
||||
relax_quotes: true,
|
||||
skip_records_with_error: true,
|
||||
cast: false,
|
||||
delimiter,
|
||||
}) as Record<string, unknown>[]
|
||||
|
||||
if (parsed.length === 0) {
|
||||
throw new Error('CSV file has no data rows')
|
||||
}
|
||||
|
||||
const headers = Object.keys(parsed[0])
|
||||
if (headers.length === 0) {
|
||||
throw new Error('CSV file has no headers')
|
||||
}
|
||||
|
||||
return { headers, rows: parsed }
|
||||
}
|
||||
|
||||
function inferColumnType(values: unknown[]): ColumnType {
|
||||
const nonEmpty = values.filter((v) => v !== null && v !== undefined && v !== '')
|
||||
if (nonEmpty.length === 0) return 'string'
|
||||
|
||||
const allNumber = nonEmpty.every((v) => {
|
||||
const n = Number(v)
|
||||
return !Number.isNaN(n) && String(v).trim() !== ''
|
||||
})
|
||||
if (allNumber) return 'number'
|
||||
|
||||
const allBoolean = nonEmpty.every((v) => {
|
||||
const s = String(v).toLowerCase()
|
||||
return s === 'true' || s === 'false'
|
||||
})
|
||||
if (allBoolean) return 'boolean'
|
||||
|
||||
const isoDatePattern = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2})?)?/
|
||||
const allDate = nonEmpty.every((v) => {
|
||||
const s = String(v)
|
||||
return isoDatePattern.test(s) && !Number.isNaN(Date.parse(s))
|
||||
})
|
||||
if (allDate) return 'date'
|
||||
|
||||
return 'string'
|
||||
}
|
||||
|
||||
function inferSchema(headers: string[], rows: Record<string, unknown>[]): ColumnDefinition[] {
|
||||
const sample = rows.slice(0, SCHEMA_SAMPLE_SIZE)
|
||||
const seen = new Set<string>()
|
||||
|
||||
return headers.map((name) => {
|
||||
let colName = sanitizeName(name)
|
||||
let suffix = 2
|
||||
while (seen.has(colName.toLowerCase())) {
|
||||
colName = `${sanitizeName(name)}_${suffix}`
|
||||
suffix++
|
||||
}
|
||||
seen.add(colName.toLowerCase())
|
||||
|
||||
return {
|
||||
name: colName,
|
||||
type: inferColumnType(sample.map((r) => r[name])),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips non-alphanumeric characters (except underscore), collapses runs of
|
||||
* underscores, and ensures the name starts with a letter or underscore.
|
||||
* Used for both table names and column names to satisfy NAME_PATTERN.
|
||||
*/
|
||||
function sanitizeName(raw: string, fallbackPrefix = 'col'): string {
|
||||
let name = raw
|
||||
.trim()
|
||||
.replace(/[^a-zA-Z0-9_]/g, '_')
|
||||
.replace(/_+/g, '_')
|
||||
.replace(/^_+|_+$/g, '')
|
||||
|
||||
if (!name || /^\d/.test(name)) {
|
||||
name = `${fallbackPrefix}_${name}`
|
||||
}
|
||||
|
||||
return name
|
||||
}
|
||||
|
||||
function coerceValue(value: unknown, colType: ColumnType): string | number | boolean | null {
|
||||
if (value === null || value === undefined || value === '') return null
|
||||
switch (colType) {
|
||||
case 'number': {
|
||||
const n = Number(value)
|
||||
return Number.isNaN(n) ? null : n
|
||||
}
|
||||
case 'boolean': {
|
||||
const s = String(value).toLowerCase()
|
||||
if (s === 'true') return true
|
||||
if (s === 'false') return false
|
||||
return null
|
||||
}
|
||||
case 'date': {
|
||||
const d = new Date(String(value))
|
||||
return Number.isNaN(d.getTime()) ? String(value) : d.toISOString()
|
||||
}
|
||||
default:
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
|
||||
function coerceRows(
|
||||
rows: Record<string, unknown>[],
|
||||
columns: ColumnDefinition[],
|
||||
headerToColumn: Map<string, string>
|
||||
): RowData[] {
|
||||
const colTypeMap = new Map(columns.map((c) => [c.name, c.type as ColumnType]))
|
||||
|
||||
return rows.map((row) => {
|
||||
const coerced: RowData = {}
|
||||
for (const [header, value] of Object.entries(row)) {
|
||||
const colName = headerToColumn.get(header)
|
||||
if (colName) {
|
||||
coerced[colName] = coerceValue(value, colTypeMap.get(colName) ?? 'string')
|
||||
}
|
||||
}
|
||||
return coerced
|
||||
})
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!authResult.success || !authResult.userId) {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
|
||||
}
|
||||
|
||||
const formData = await request.formData()
|
||||
const file = formData.get('file')
|
||||
const workspaceId = formData.get('workspaceId') as string | null
|
||||
|
||||
if (!file || !(file instanceof File)) {
|
||||
return NextResponse.json({ error: 'CSV file is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (file.size > MAX_CSV_FILE_SIZE) {
|
||||
return NextResponse.json(
|
||||
{ error: `File exceeds maximum allowed size of ${MAX_CSV_FILE_SIZE / (1024 * 1024)} MB` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!workspaceId) {
|
||||
return NextResponse.json({ error: 'Workspace ID is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const permission = await getUserEntityPermissions(authResult.userId, 'workspace', workspaceId)
|
||||
if (permission !== 'write' && permission !== 'admin') {
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
const ext = file.name.split('.').pop()?.toLowerCase()
|
||||
if (ext !== 'csv' && ext !== 'tsv') {
|
||||
return NextResponse.json({ error: 'Only CSV and TSV files are supported' }, { status: 400 })
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer())
|
||||
const delimiter = ext === 'tsv' ? '\t' : ','
|
||||
const { headers, rows } = await parseCsvBuffer(buffer, delimiter)
|
||||
|
||||
const columns = inferSchema(headers, rows)
|
||||
const headerToColumn = new Map(headers.map((h, i) => [h, columns[i].name]))
|
||||
|
||||
const tableName = sanitizeName(file.name.replace(/\.[^.]+$/, ''), 'imported_table')
|
||||
const planLimits = await getWorkspaceTableLimits(workspaceId)
|
||||
|
||||
const normalizedSchema: TableSchema = {
|
||||
columns: columns.map(normalizeColumn),
|
||||
}
|
||||
|
||||
const table = await createTable(
|
||||
{
|
||||
name: tableName,
|
||||
description: `Imported from ${file.name}`,
|
||||
schema: normalizedSchema,
|
||||
workspaceId,
|
||||
userId: authResult.userId,
|
||||
maxRows: planLimits.maxRowsPerTable,
|
||||
maxTables: planLimits.maxTables,
|
||||
},
|
||||
requestId
|
||||
)
|
||||
|
||||
try {
|
||||
const coerced = coerceRows(rows, columns, headerToColumn)
|
||||
let inserted = 0
|
||||
for (let i = 0; i < coerced.length; i += MAX_BATCH_SIZE) {
|
||||
const batch = coerced.slice(i, i + MAX_BATCH_SIZE)
|
||||
const batchRequestId = crypto.randomUUID().slice(0, 8)
|
||||
const result = await batchInsertRows(
|
||||
{ tableId: table.id, rows: batch, workspaceId, userId: authResult.userId },
|
||||
table,
|
||||
batchRequestId
|
||||
)
|
||||
inserted += result.length
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] CSV imported`, {
|
||||
tableId: table.id,
|
||||
fileName: file.name,
|
||||
columns: columns.length,
|
||||
rows: inserted,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
table: {
|
||||
id: table.id,
|
||||
name: table.name,
|
||||
description: table.description,
|
||||
schema: normalizedSchema,
|
||||
rowCount: inserted,
|
||||
},
|
||||
},
|
||||
})
|
||||
} catch (insertError) {
|
||||
await deleteTable(table.id, requestId).catch(() => {})
|
||||
throw insertError
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
logger.error(`[${requestId}] CSV import failed:`, error)
|
||||
|
||||
const isClientError =
|
||||
message.includes('maximum table limit') ||
|
||||
message.includes('CSV file has no') ||
|
||||
message.includes('Invalid table name') ||
|
||||
message.includes('Invalid schema') ||
|
||||
message.includes('already exists')
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: isClientError ? message : 'Failed to import CSV' },
|
||||
{ status: isClientError ? 400 : 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -103,7 +103,6 @@ export type {
|
||||
AdminOrganization,
|
||||
AdminOrganizationBillingSummary,
|
||||
AdminOrganizationDetail,
|
||||
AdminReferralCampaign,
|
||||
AdminSeatAnalytics,
|
||||
AdminSingleResponse,
|
||||
AdminSubscription,
|
||||
@@ -118,7 +117,6 @@ export type {
|
||||
AdminWorkspaceMember,
|
||||
DbMember,
|
||||
DbOrganization,
|
||||
DbReferralCampaign,
|
||||
DbSubscription,
|
||||
DbUser,
|
||||
DbUserStats,
|
||||
@@ -147,7 +145,6 @@ export {
|
||||
parseWorkflowVariables,
|
||||
toAdminFolder,
|
||||
toAdminOrganization,
|
||||
toAdminReferralCampaign,
|
||||
toAdminSubscription,
|
||||
toAdminUser,
|
||||
toAdminWorkflow,
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
/**
|
||||
* GET /api/v1/admin/referral-campaigns/:id
|
||||
*
|
||||
* Get a single referral campaign by ID.
|
||||
*
|
||||
* PATCH /api/v1/admin/referral-campaigns/:id
|
||||
*
|
||||
* Update campaign fields. All fields are optional.
|
||||
*
|
||||
* Body:
|
||||
* - name: string (non-empty) - Campaign name
|
||||
* - bonusCreditAmount: number (> 0) - Bonus credits in dollars
|
||||
* - isActive: boolean - Enable/disable the campaign
|
||||
* - code: string | null (min 6 chars, auto-uppercased, null to remove) - Redeemable code
|
||||
* - utmSource: string | null - UTM source match (null = wildcard)
|
||||
* - utmMedium: string | null - UTM medium match (null = wildcard)
|
||||
* - utmCampaign: string | null - UTM campaign match (null = wildcard)
|
||||
* - utmContent: string | null - UTM content match (null = wildcard)
|
||||
*/
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { referralCampaigns } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
|
||||
import {
|
||||
badRequestResponse,
|
||||
internalErrorResponse,
|
||||
notFoundResponse,
|
||||
singleResponse,
|
||||
} from '@/app/api/v1/admin/responses'
|
||||
import { toAdminReferralCampaign } from '@/app/api/v1/admin/types'
|
||||
|
||||
const logger = createLogger('AdminReferralCampaignDetailAPI')
|
||||
|
||||
interface RouteParams {
|
||||
id: string
|
||||
}
|
||||
|
||||
export const GET = withAdminAuthParams<RouteParams>(async (_, context) => {
|
||||
try {
|
||||
const { id: campaignId } = await context.params
|
||||
|
||||
const [campaign] = await db
|
||||
.select()
|
||||
.from(referralCampaigns)
|
||||
.where(eq(referralCampaigns.id, campaignId))
|
||||
.limit(1)
|
||||
|
||||
if (!campaign) {
|
||||
return notFoundResponse('Campaign')
|
||||
}
|
||||
|
||||
logger.info(`Admin API: Retrieved referral campaign ${campaignId}`)
|
||||
|
||||
return singleResponse(toAdminReferralCampaign(campaign, getBaseUrl()))
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to get referral campaign', { error })
|
||||
return internalErrorResponse('Failed to get referral campaign')
|
||||
}
|
||||
})
|
||||
|
||||
export const PATCH = withAdminAuthParams<RouteParams>(async (request, context) => {
|
||||
try {
|
||||
const { id: campaignId } = await context.params
|
||||
const body = await request.json()
|
||||
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(referralCampaigns)
|
||||
.where(eq(referralCampaigns.id, campaignId))
|
||||
.limit(1)
|
||||
|
||||
if (!existing) {
|
||||
return notFoundResponse('Campaign')
|
||||
}
|
||||
|
||||
const updateData: Record<string, unknown> = { updatedAt: new Date() }
|
||||
|
||||
if (body.name !== undefined) {
|
||||
if (typeof body.name !== 'string' || body.name.trim().length === 0) {
|
||||
return badRequestResponse('name must be a non-empty string')
|
||||
}
|
||||
updateData.name = body.name.trim()
|
||||
}
|
||||
|
||||
if (body.bonusCreditAmount !== undefined) {
|
||||
if (
|
||||
typeof body.bonusCreditAmount !== 'number' ||
|
||||
!Number.isFinite(body.bonusCreditAmount) ||
|
||||
body.bonusCreditAmount <= 0
|
||||
) {
|
||||
return badRequestResponse('bonusCreditAmount must be a positive number')
|
||||
}
|
||||
updateData.bonusCreditAmount = body.bonusCreditAmount.toString()
|
||||
}
|
||||
|
||||
if (body.isActive !== undefined) {
|
||||
if (typeof body.isActive !== 'boolean') {
|
||||
return badRequestResponse('isActive must be a boolean')
|
||||
}
|
||||
updateData.isActive = body.isActive
|
||||
}
|
||||
|
||||
if (body.code !== undefined) {
|
||||
if (body.code !== null) {
|
||||
if (typeof body.code !== 'string') {
|
||||
return badRequestResponse('code must be a string or null')
|
||||
}
|
||||
if (body.code.trim().length < 6) {
|
||||
return badRequestResponse('code must be at least 6 characters')
|
||||
}
|
||||
}
|
||||
updateData.code = body.code ? body.code.trim().toUpperCase() : null
|
||||
}
|
||||
|
||||
for (const field of ['utmSource', 'utmMedium', 'utmCampaign', 'utmContent'] as const) {
|
||||
if (body[field] !== undefined) {
|
||||
if (body[field] !== null && typeof body[field] !== 'string') {
|
||||
return badRequestResponse(`${field} must be a string or null`)
|
||||
}
|
||||
updateData[field] = body[field] || null
|
||||
}
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(referralCampaigns)
|
||||
.set(updateData)
|
||||
.where(eq(referralCampaigns.id, campaignId))
|
||||
.returning()
|
||||
|
||||
logger.info(`Admin API: Updated referral campaign ${campaignId}`, {
|
||||
fields: Object.keys(updateData).filter((k) => k !== 'updatedAt'),
|
||||
})
|
||||
|
||||
return singleResponse(toAdminReferralCampaign(updated, getBaseUrl()))
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to update referral campaign', { error })
|
||||
return internalErrorResponse('Failed to update referral campaign')
|
||||
}
|
||||
})
|
||||
@@ -1,104 +1,160 @@
|
||||
/**
|
||||
* GET /api/v1/admin/referral-campaigns
|
||||
*
|
||||
* List referral campaigns with optional filtering and pagination.
|
||||
* List Stripe promotion codes with cursor-based pagination.
|
||||
*
|
||||
* Query Parameters:
|
||||
* - active: string (optional) - Filter by active status ('true' or 'false')
|
||||
* - limit: number (default: 50, max: 250)
|
||||
* - offset: number (default: 0)
|
||||
* - limit: number (default: 50, max: 100)
|
||||
* - starting_after: string (cursor — Stripe promotion code ID)
|
||||
* - active: 'true' | 'false' (optional filter)
|
||||
*
|
||||
* POST /api/v1/admin/referral-campaigns
|
||||
*
|
||||
* Create a new referral campaign.
|
||||
* Create a Stripe coupon and an associated promotion code.
|
||||
*
|
||||
* Body:
|
||||
* - name: string (required) - Campaign name
|
||||
* - bonusCreditAmount: number (required, > 0) - Bonus credits in dollars
|
||||
* - code: string | null (optional, min 6 chars, auto-uppercased) - Redeemable code
|
||||
* - utmSource: string | null (optional) - UTM source match (null = wildcard)
|
||||
* - utmMedium: string | null (optional) - UTM medium match (null = wildcard)
|
||||
* - utmCampaign: string | null (optional) - UTM campaign match (null = wildcard)
|
||||
* - utmContent: string | null (optional) - UTM content match (null = wildcard)
|
||||
* - name: string (required) — Display name for the coupon
|
||||
* - percentOff: number (required, 1–100) — Percentage discount
|
||||
* - code: string | null (optional, min 6 chars, auto-uppercased) — Desired code
|
||||
* - duration: 'once' | 'repeating' | 'forever' (default: 'once')
|
||||
* - durationInMonths: number (required when duration is 'repeating')
|
||||
* - maxRedemptions: number (optional) — Total redemption cap
|
||||
* - expiresAt: ISO 8601 string (optional) — Promotion code expiry
|
||||
*/
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { referralCampaigns } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { count, eq, type SQL } from 'drizzle-orm'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { NextResponse } from 'next/server'
|
||||
import type Stripe from 'stripe'
|
||||
import { requireStripeClient } from '@/lib/billing/stripe-client'
|
||||
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
|
||||
import {
|
||||
badRequestResponse,
|
||||
internalErrorResponse,
|
||||
listResponse,
|
||||
singleResponse,
|
||||
} from '@/app/api/v1/admin/responses'
|
||||
import {
|
||||
type AdminReferralCampaign,
|
||||
createPaginationMeta,
|
||||
parsePaginationParams,
|
||||
toAdminReferralCampaign,
|
||||
} from '@/app/api/v1/admin/types'
|
||||
|
||||
const logger = createLogger('AdminReferralCampaignsAPI')
|
||||
const logger = createLogger('AdminPromoCodes')
|
||||
|
||||
const VALID_DURATIONS = ['once', 'repeating', 'forever'] as const
|
||||
type Duration = (typeof VALID_DURATIONS)[number]
|
||||
|
||||
interface PromoCodeResponse {
|
||||
id: string
|
||||
code: string
|
||||
couponId: string
|
||||
name: string
|
||||
percentOff: number
|
||||
duration: string
|
||||
durationInMonths: number | null
|
||||
maxRedemptions: number | null
|
||||
expiresAt: string | null
|
||||
active: boolean
|
||||
timesRedeemed: number
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
function formatPromoCode(promo: {
|
||||
id: string
|
||||
code: string
|
||||
coupon: {
|
||||
id: string
|
||||
name: string | null
|
||||
percent_off: number | null
|
||||
duration: string
|
||||
duration_in_months: number | null
|
||||
}
|
||||
max_redemptions: number | null
|
||||
expires_at: number | null
|
||||
active: boolean
|
||||
times_redeemed: number
|
||||
created: number
|
||||
}): PromoCodeResponse {
|
||||
return {
|
||||
id: promo.id,
|
||||
code: promo.code,
|
||||
couponId: promo.coupon.id,
|
||||
name: promo.coupon.name ?? '',
|
||||
percentOff: promo.coupon.percent_off ?? 0,
|
||||
duration: promo.coupon.duration,
|
||||
durationInMonths: promo.coupon.duration_in_months,
|
||||
maxRedemptions: promo.max_redemptions,
|
||||
expiresAt: promo.expires_at ? new Date(promo.expires_at * 1000).toISOString() : null,
|
||||
active: promo.active,
|
||||
timesRedeemed: promo.times_redeemed,
|
||||
createdAt: new Date(promo.created * 1000).toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
export const GET = withAdminAuth(async (request) => {
|
||||
const url = new URL(request.url)
|
||||
const { limit, offset } = parsePaginationParams(url)
|
||||
const activeFilter = url.searchParams.get('active')
|
||||
|
||||
try {
|
||||
const conditions: SQL<unknown>[] = []
|
||||
if (activeFilter === 'true') {
|
||||
conditions.push(eq(referralCampaigns.isActive, true))
|
||||
} else if (activeFilter === 'false') {
|
||||
conditions.push(eq(referralCampaigns.isActive, false))
|
||||
}
|
||||
const stripe = requireStripeClient()
|
||||
const url = new URL(request.url)
|
||||
|
||||
const whereClause = conditions.length > 0 ? conditions[0] : undefined
|
||||
const baseUrl = getBaseUrl()
|
||||
const limitParam = url.searchParams.get('limit')
|
||||
let limit = limitParam ? Number.parseInt(limitParam, 10) : 50
|
||||
if (Number.isNaN(limit) || limit < 1) limit = 50
|
||||
if (limit > 100) limit = 100
|
||||
|
||||
const [countResult, campaigns] = await Promise.all([
|
||||
db.select({ total: count() }).from(referralCampaigns).where(whereClause),
|
||||
db
|
||||
.select()
|
||||
.from(referralCampaigns)
|
||||
.where(whereClause)
|
||||
.orderBy(referralCampaigns.createdAt)
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
])
|
||||
const startingAfter = url.searchParams.get('starting_after') || undefined
|
||||
const activeFilter = url.searchParams.get('active')
|
||||
|
||||
const total = countResult[0].total
|
||||
const data: AdminReferralCampaign[] = campaigns.map((c) => toAdminReferralCampaign(c, baseUrl))
|
||||
const pagination = createPaginationMeta(total, limit, offset)
|
||||
const listParams: Record<string, unknown> = { limit }
|
||||
if (startingAfter) listParams.starting_after = startingAfter
|
||||
if (activeFilter === 'true') listParams.active = true
|
||||
else if (activeFilter === 'false') listParams.active = false
|
||||
|
||||
logger.info(`Admin API: Listed ${data.length} referral campaigns (total: ${total})`)
|
||||
const promoCodes = await stripe.promotionCodes.list(listParams)
|
||||
|
||||
return listResponse(data, pagination)
|
||||
const data = promoCodes.data.map(formatPromoCode)
|
||||
|
||||
logger.info(`Admin API: Listed ${data.length} Stripe promotion codes`)
|
||||
|
||||
return NextResponse.json({
|
||||
data,
|
||||
hasMore: promoCodes.has_more,
|
||||
...(data.length > 0 ? { nextCursor: data[data.length - 1].id } : {}),
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to list referral campaigns', { error })
|
||||
return internalErrorResponse('Failed to list referral campaigns')
|
||||
logger.error('Admin API: Failed to list promotion codes', { error })
|
||||
return internalErrorResponse('Failed to list promotion codes')
|
||||
}
|
||||
})
|
||||
|
||||
export const POST = withAdminAuth(async (request) => {
|
||||
try {
|
||||
const stripe = requireStripeClient()
|
||||
const body = await request.json()
|
||||
const { name, code, utmSource, utmMedium, utmCampaign, utmContent, bonusCreditAmount } = body
|
||||
|
||||
if (!name || typeof name !== 'string') {
|
||||
return badRequestResponse('name is required and must be a string')
|
||||
const { name, percentOff, code, duration, durationInMonths, maxRedemptions, expiresAt } = body
|
||||
|
||||
if (!name || typeof name !== 'string' || name.trim().length === 0) {
|
||||
return badRequestResponse('name is required and must be a non-empty string')
|
||||
}
|
||||
|
||||
if (
|
||||
typeof bonusCreditAmount !== 'number' ||
|
||||
!Number.isFinite(bonusCreditAmount) ||
|
||||
bonusCreditAmount <= 0
|
||||
typeof percentOff !== 'number' ||
|
||||
!Number.isFinite(percentOff) ||
|
||||
percentOff < 1 ||
|
||||
percentOff > 100
|
||||
) {
|
||||
return badRequestResponse('bonusCreditAmount must be a positive number')
|
||||
return badRequestResponse('percentOff must be a number between 1 and 100')
|
||||
}
|
||||
|
||||
const effectiveDuration: Duration = duration ?? 'once'
|
||||
if (!VALID_DURATIONS.includes(effectiveDuration)) {
|
||||
return badRequestResponse(`duration must be one of: ${VALID_DURATIONS.join(', ')}`)
|
||||
}
|
||||
|
||||
if (effectiveDuration === 'repeating') {
|
||||
if (
|
||||
typeof durationInMonths !== 'number' ||
|
||||
!Number.isInteger(durationInMonths) ||
|
||||
durationInMonths < 1
|
||||
) {
|
||||
return badRequestResponse(
|
||||
'durationInMonths is required and must be a positive integer when duration is "repeating"'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (code !== undefined && code !== null) {
|
||||
@@ -110,31 +166,77 @@ export const POST = withAdminAuth(async (request) => {
|
||||
}
|
||||
}
|
||||
|
||||
const id = nanoid()
|
||||
if (maxRedemptions !== undefined && maxRedemptions !== null) {
|
||||
if (
|
||||
typeof maxRedemptions !== 'number' ||
|
||||
!Number.isInteger(maxRedemptions) ||
|
||||
maxRedemptions < 1
|
||||
) {
|
||||
return badRequestResponse('maxRedemptions must be a positive integer')
|
||||
}
|
||||
}
|
||||
|
||||
const [campaign] = await db
|
||||
.insert(referralCampaigns)
|
||||
.values({
|
||||
id,
|
||||
name,
|
||||
code: code ? code.trim().toUpperCase() : null,
|
||||
utmSource: utmSource || null,
|
||||
utmMedium: utmMedium || null,
|
||||
utmCampaign: utmCampaign || null,
|
||||
utmContent: utmContent || null,
|
||||
bonusCreditAmount: bonusCreditAmount.toString(),
|
||||
})
|
||||
.returning()
|
||||
if (expiresAt !== undefined && expiresAt !== null) {
|
||||
const parsed = new Date(expiresAt)
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return badRequestResponse('expiresAt must be a valid ISO 8601 date string')
|
||||
}
|
||||
if (parsed.getTime() <= Date.now()) {
|
||||
return badRequestResponse('expiresAt must be in the future')
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Admin API: Created referral campaign ${id}`, {
|
||||
name,
|
||||
code: campaign.code,
|
||||
bonusCreditAmount,
|
||||
const coupon = await stripe.coupons.create({
|
||||
name: name.trim(),
|
||||
percent_off: percentOff,
|
||||
duration: effectiveDuration,
|
||||
...(effectiveDuration === 'repeating' ? { duration_in_months: durationInMonths } : {}),
|
||||
})
|
||||
|
||||
return singleResponse(toAdminReferralCampaign(campaign, getBaseUrl()))
|
||||
let promoCode
|
||||
try {
|
||||
const promoParams: Stripe.PromotionCodeCreateParams = {
|
||||
coupon: coupon.id,
|
||||
...(code ? { code: code.trim().toUpperCase() } : {}),
|
||||
...(maxRedemptions ? { max_redemptions: maxRedemptions } : {}),
|
||||
...(expiresAt ? { expires_at: Math.floor(new Date(expiresAt).getTime() / 1000) } : {}),
|
||||
}
|
||||
|
||||
promoCode = await stripe.promotionCodes.create(promoParams)
|
||||
} catch (promoError) {
|
||||
try {
|
||||
await stripe.coupons.del(coupon.id)
|
||||
} catch (cleanupError) {
|
||||
logger.error(
|
||||
'Admin API: Failed to clean up orphaned coupon after promo code creation failed',
|
||||
{
|
||||
couponId: coupon.id,
|
||||
cleanupError,
|
||||
}
|
||||
)
|
||||
}
|
||||
throw promoError
|
||||
}
|
||||
|
||||
logger.info('Admin API: Created Stripe promotion code', {
|
||||
promoCodeId: promoCode.id,
|
||||
code: promoCode.code,
|
||||
couponId: coupon.id,
|
||||
percentOff,
|
||||
duration: effectiveDuration,
|
||||
})
|
||||
|
||||
return singleResponse(formatPromoCode(promoCode))
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to create referral campaign', { error })
|
||||
return internalErrorResponse('Failed to create referral campaign')
|
||||
if (
|
||||
error instanceof Error &&
|
||||
'type' in error &&
|
||||
(error as { type: string }).type === 'StripeInvalidRequestError'
|
||||
) {
|
||||
logger.warn('Admin API: Stripe rejected promotion code request', { error: error.message })
|
||||
return badRequestResponse(error.message)
|
||||
}
|
||||
logger.error('Admin API: Failed to create promotion code', { error })
|
||||
return internalErrorResponse('Failed to create promotion code')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -9,7 +9,6 @@ import type {
|
||||
auditLog,
|
||||
member,
|
||||
organization,
|
||||
referralCampaigns,
|
||||
subscription,
|
||||
user,
|
||||
userStats,
|
||||
@@ -33,7 +32,6 @@ export type DbOrganization = InferSelectModel<typeof organization>
|
||||
export type DbSubscription = InferSelectModel<typeof subscription>
|
||||
export type DbMember = InferSelectModel<typeof member>
|
||||
export type DbUserStats = InferSelectModel<typeof userStats>
|
||||
export type DbReferralCampaign = InferSelectModel<typeof referralCampaigns>
|
||||
|
||||
// =============================================================================
|
||||
// Pagination
|
||||
@@ -650,52 +648,6 @@ export interface AdminUndeployResult {
|
||||
isDeployed: boolean
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Referral Campaign Types
|
||||
// =============================================================================
|
||||
|
||||
export interface AdminReferralCampaign {
|
||||
id: string
|
||||
name: string
|
||||
code: string | null
|
||||
utmSource: string | null
|
||||
utmMedium: string | null
|
||||
utmCampaign: string | null
|
||||
utmContent: string | null
|
||||
bonusCreditAmount: string
|
||||
isActive: boolean
|
||||
signupUrl: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export function toAdminReferralCampaign(
|
||||
dbCampaign: DbReferralCampaign,
|
||||
baseUrl: string
|
||||
): AdminReferralCampaign {
|
||||
const utmParams = new URLSearchParams()
|
||||
if (dbCampaign.utmSource) utmParams.set('utm_source', dbCampaign.utmSource)
|
||||
if (dbCampaign.utmMedium) utmParams.set('utm_medium', dbCampaign.utmMedium)
|
||||
if (dbCampaign.utmCampaign) utmParams.set('utm_campaign', dbCampaign.utmCampaign)
|
||||
if (dbCampaign.utmContent) utmParams.set('utm_content', dbCampaign.utmContent)
|
||||
const query = utmParams.toString()
|
||||
|
||||
return {
|
||||
id: dbCampaign.id,
|
||||
name: dbCampaign.name,
|
||||
code: dbCampaign.code,
|
||||
utmSource: dbCampaign.utmSource,
|
||||
utmMedium: dbCampaign.utmMedium,
|
||||
utmCampaign: dbCampaign.utmCampaign,
|
||||
utmContent: dbCampaign.utmContent,
|
||||
bonusCreditAmount: dbCampaign.bonusCreditAmount,
|
||||
isActive: dbCampaign.isActive,
|
||||
signupUrl: query ? `${baseUrl}/signup?${query}` : null,
|
||||
createdAt: dbCampaign.createdAt.toISOString(),
|
||||
updatedAt: dbCampaign.updatedAt.toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Audit Log Types
|
||||
// =============================================================================
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { db, workflow, workflowDeploymentVersion } from '@sim/db'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, desc, eq } from 'drizzle-orm'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
@@ -22,8 +22,11 @@ import {
|
||||
validateWorkflowSchedules,
|
||||
} from '@/lib/workflows/schedules'
|
||||
import { validateWorkflowPermissions } from '@/lib/workflows/utils'
|
||||
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
import {
|
||||
checkNeedsRedeployment,
|
||||
createErrorResponse,
|
||||
createSuccessResponse,
|
||||
} from '@/app/api/workflows/utils'
|
||||
|
||||
const logger = createLogger('WorkflowDeployAPI')
|
||||
|
||||
@@ -55,43 +58,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
})
|
||||
}
|
||||
|
||||
let needsRedeployment = false
|
||||
const [active] = await db
|
||||
.select({ state: workflowDeploymentVersion.state })
|
||||
.from(workflowDeploymentVersion)
|
||||
.where(
|
||||
and(
|
||||
eq(workflowDeploymentVersion.workflowId, id),
|
||||
eq(workflowDeploymentVersion.isActive, true)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(workflowDeploymentVersion.createdAt))
|
||||
.limit(1)
|
||||
|
||||
if (active?.state) {
|
||||
const { loadWorkflowFromNormalizedTables } = await import('@/lib/workflows/persistence/utils')
|
||||
const normalizedData = await loadWorkflowFromNormalizedTables(id)
|
||||
if (normalizedData) {
|
||||
const [workflowRecord] = await db
|
||||
.select({ variables: workflow.variables })
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, id))
|
||||
.limit(1)
|
||||
|
||||
const currentState = {
|
||||
blocks: normalizedData.blocks,
|
||||
edges: normalizedData.edges,
|
||||
loops: normalizedData.loops,
|
||||
parallels: normalizedData.parallels,
|
||||
variables: workflowRecord?.variables || {},
|
||||
}
|
||||
const { hasWorkflowChanged } = await import('@/lib/workflows/comparison')
|
||||
needsRedeployment = hasWorkflowChanged(
|
||||
currentState as WorkflowState,
|
||||
active.state as WorkflowState
|
||||
)
|
||||
}
|
||||
}
|
||||
const needsRedeployment = await checkNeedsRedeployment(id)
|
||||
|
||||
logger.info(`[${requestId}] Successfully retrieved deployment info: ${id}`)
|
||||
|
||||
|
||||
@@ -20,6 +20,10 @@ import {
|
||||
} from '@/lib/execution/call-chain'
|
||||
import { createExecutionEventWriter, setExecutionMeta } from '@/lib/execution/event-buffer'
|
||||
import { processInputFileFields } from '@/lib/execution/files'
|
||||
import {
|
||||
registerManualExecutionAborter,
|
||||
unregisterManualExecutionAborter,
|
||||
} from '@/lib/execution/manual-cancellation'
|
||||
import { preprocessExecution } from '@/lib/execution/preprocessing'
|
||||
import { LoggingSession } from '@/lib/logs/execution/logging-session'
|
||||
import {
|
||||
@@ -845,6 +849,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
const encoder = new TextEncoder()
|
||||
const timeoutController = createTimeoutAbortController(preprocessResult.executionTimeout?.sync)
|
||||
let isStreamClosed = false
|
||||
let isManualAbortRegistered = false
|
||||
|
||||
const eventWriter = createExecutionEventWriter(executionId)
|
||||
setExecutionMeta(executionId, {
|
||||
@@ -857,6 +862,9 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
async start(controller) {
|
||||
let finalMetaStatus: 'complete' | 'error' | 'cancelled' | null = null
|
||||
|
||||
registerManualExecutionAborter(executionId, timeoutController.abort)
|
||||
isManualAbortRegistered = true
|
||||
|
||||
const sendEvent = (event: ExecutionEvent) => {
|
||||
if (!isStreamClosed) {
|
||||
try {
|
||||
@@ -1224,6 +1232,10 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
})
|
||||
finalMetaStatus = 'error'
|
||||
} finally {
|
||||
if (isManualAbortRegistered) {
|
||||
unregisterManualExecutionAborter(executionId)
|
||||
isManualAbortRegistered = false
|
||||
}
|
||||
try {
|
||||
await eventWriter.close()
|
||||
} catch (closeError) {
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { NextRequest } from 'next/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mockCheckHybridAuth = vi.fn()
|
||||
const mockAuthorizeWorkflowByWorkspacePermission = vi.fn()
|
||||
const mockMarkExecutionCancelled = vi.fn()
|
||||
const mockAbortManualExecution = vi.fn()
|
||||
|
||||
vi.mock('@sim/logger', () => ({
|
||||
createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/hybrid', () => ({
|
||||
checkHybridAuth: (...args: unknown[]) => mockCheckHybridAuth(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/execution/cancellation', () => ({
|
||||
markExecutionCancelled: (...args: unknown[]) => mockMarkExecutionCancelled(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/execution/manual-cancellation', () => ({
|
||||
abortManualExecution: (...args: unknown[]) => mockAbortManualExecution(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workflows/utils', () => ({
|
||||
authorizeWorkflowByWorkspacePermission: (params: unknown) =>
|
||||
mockAuthorizeWorkflowByWorkspacePermission(params),
|
||||
}))
|
||||
|
||||
import { POST } from './route'
|
||||
|
||||
describe('POST /api/workflows/[id]/executions/[executionId]/cancel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCheckHybridAuth.mockResolvedValue({ success: true, userId: 'user-1' })
|
||||
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ allowed: true })
|
||||
mockAbortManualExecution.mockReturnValue(false)
|
||||
})
|
||||
|
||||
it('returns success when cancellation was durably recorded', async () => {
|
||||
mockMarkExecutionCancelled.mockResolvedValue({
|
||||
durablyRecorded: true,
|
||||
reason: 'recorded',
|
||||
})
|
||||
|
||||
const response = await POST(
|
||||
new NextRequest('http://localhost/api/workflows/wf-1/executions/ex-1/cancel', {
|
||||
method: 'POST',
|
||||
}),
|
||||
{
|
||||
params: Promise.resolve({ id: 'wf-1', executionId: 'ex-1' }),
|
||||
}
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
success: true,
|
||||
executionId: 'ex-1',
|
||||
redisAvailable: true,
|
||||
durablyRecorded: true,
|
||||
locallyAborted: false,
|
||||
reason: 'recorded',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns unsuccessful response when Redis is unavailable', async () => {
|
||||
mockMarkExecutionCancelled.mockResolvedValue({
|
||||
durablyRecorded: false,
|
||||
reason: 'redis_unavailable',
|
||||
})
|
||||
|
||||
const response = await POST(
|
||||
new NextRequest('http://localhost/api/workflows/wf-1/executions/ex-1/cancel', {
|
||||
method: 'POST',
|
||||
}),
|
||||
{
|
||||
params: Promise.resolve({ id: 'wf-1', executionId: 'ex-1' }),
|
||||
}
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
success: false,
|
||||
executionId: 'ex-1',
|
||||
redisAvailable: false,
|
||||
durablyRecorded: false,
|
||||
locallyAborted: false,
|
||||
reason: 'redis_unavailable',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns unsuccessful response when Redis persistence fails', async () => {
|
||||
mockMarkExecutionCancelled.mockResolvedValue({
|
||||
durablyRecorded: false,
|
||||
reason: 'redis_write_failed',
|
||||
})
|
||||
|
||||
const response = await POST(
|
||||
new NextRequest('http://localhost/api/workflows/wf-1/executions/ex-1/cancel', {
|
||||
method: 'POST',
|
||||
}),
|
||||
{
|
||||
params: Promise.resolve({ id: 'wf-1', executionId: 'ex-1' }),
|
||||
}
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
success: false,
|
||||
executionId: 'ex-1',
|
||||
redisAvailable: true,
|
||||
durablyRecorded: false,
|
||||
locallyAborted: false,
|
||||
reason: 'redis_write_failed',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns success when local fallback aborts execution without Redis durability', async () => {
|
||||
mockMarkExecutionCancelled.mockResolvedValue({
|
||||
durablyRecorded: false,
|
||||
reason: 'redis_unavailable',
|
||||
})
|
||||
mockAbortManualExecution.mockReturnValue(true)
|
||||
|
||||
const response = await POST(
|
||||
new NextRequest('http://localhost/api/workflows/wf-1/executions/ex-1/cancel', {
|
||||
method: 'POST',
|
||||
}),
|
||||
{
|
||||
params: Promise.resolve({ id: 'wf-1', executionId: 'ex-1' }),
|
||||
}
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
success: true,
|
||||
executionId: 'ex-1',
|
||||
redisAvailable: false,
|
||||
durablyRecorded: false,
|
||||
locallyAborted: true,
|
||||
reason: 'redis_unavailable',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { markExecutionCancelled } from '@/lib/execution/cancellation'
|
||||
import { abortManualExecution } from '@/lib/execution/manual-cancellation'
|
||||
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
|
||||
|
||||
const logger = createLogger('CancelExecutionAPI')
|
||||
@@ -45,20 +46,27 @@ export async function POST(
|
||||
|
||||
logger.info('Cancel execution requested', { workflowId, executionId, userId: auth.userId })
|
||||
|
||||
const marked = await markExecutionCancelled(executionId)
|
||||
const cancellation = await markExecutionCancelled(executionId)
|
||||
const locallyAborted = abortManualExecution(executionId)
|
||||
|
||||
if (marked) {
|
||||
if (cancellation.durablyRecorded) {
|
||||
logger.info('Execution marked as cancelled in Redis', { executionId })
|
||||
} else if (locallyAborted) {
|
||||
logger.info('Execution cancelled via local in-process fallback', { executionId })
|
||||
} else {
|
||||
logger.info('Redis not available, cancellation will rely on connection close', {
|
||||
logger.warn('Execution cancellation was not durably recorded', {
|
||||
executionId,
|
||||
reason: cancellation.reason,
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
success: cancellation.durablyRecorded || locallyAborted,
|
||||
executionId,
|
||||
redisAvailable: marked,
|
||||
redisAvailable: cancellation.reason !== 'redis_unavailable',
|
||||
durablyRecorded: cancellation.durablyRecorded,
|
||||
locallyAborted,
|
||||
reason: cancellation.reason,
|
||||
})
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to cancel execution', { workflowId, executionId, error: error.message })
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { db, workflow, workflowDeploymentVersion } from '@sim/db'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, desc, eq } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { hasWorkflowChanged } from '@/lib/workflows/comparison'
|
||||
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
|
||||
import { validateWorkflowAccess } from '@/app/api/workflows/middleware'
|
||||
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
import {
|
||||
checkNeedsRedeployment,
|
||||
createErrorResponse,
|
||||
createSuccessResponse,
|
||||
} from '@/app/api/workflows/utils'
|
||||
|
||||
const logger = createLogger('WorkflowStatusAPI')
|
||||
|
||||
@@ -23,54 +22,9 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
return createErrorResponse(validation.error.message, validation.error.status)
|
||||
}
|
||||
|
||||
let needsRedeployment = false
|
||||
|
||||
if (validation.workflow.isDeployed) {
|
||||
const normalizedData = await loadWorkflowFromNormalizedTables(id)
|
||||
|
||||
if (!normalizedData) {
|
||||
return createSuccessResponse({
|
||||
isDeployed: validation.workflow.isDeployed,
|
||||
deployedAt: validation.workflow.deployedAt,
|
||||
isPublished: validation.workflow.isPublished,
|
||||
needsRedeployment: false,
|
||||
})
|
||||
}
|
||||
|
||||
const [workflowRecord] = await db
|
||||
.select({ variables: workflow.variables })
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, id))
|
||||
.limit(1)
|
||||
|
||||
const currentState = {
|
||||
blocks: normalizedData.blocks,
|
||||
edges: normalizedData.edges,
|
||||
loops: normalizedData.loops,
|
||||
parallels: normalizedData.parallels,
|
||||
variables: workflowRecord?.variables || {},
|
||||
lastSaved: Date.now(),
|
||||
}
|
||||
|
||||
const [active] = await db
|
||||
.select({ state: workflowDeploymentVersion.state })
|
||||
.from(workflowDeploymentVersion)
|
||||
.where(
|
||||
and(
|
||||
eq(workflowDeploymentVersion.workflowId, id),
|
||||
eq(workflowDeploymentVersion.isActive, true)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(workflowDeploymentVersion.createdAt))
|
||||
.limit(1)
|
||||
|
||||
if (active?.state) {
|
||||
needsRedeployment = hasWorkflowChanged(
|
||||
currentState as WorkflowState,
|
||||
active.state as WorkflowState
|
||||
)
|
||||
}
|
||||
}
|
||||
const needsRedeployment = validation.workflow.isDeployed
|
||||
? await checkNeedsRedeployment(id)
|
||||
: false
|
||||
|
||||
return createSuccessResponse({
|
||||
isDeployed: validation.workflow.isDeployed,
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { db, workflow, workflowDeploymentVersion } from '@sim/db'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, desc, eq } from 'drizzle-orm'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { hasWorkflowChanged } from '@/lib/workflows/comparison'
|
||||
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
const logger = createLogger('WorkflowUtils')
|
||||
|
||||
@@ -18,6 +23,50 @@ export function createSuccessResponse(data: any) {
|
||||
return NextResponse.json(data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a deployed workflow has changes that require redeployment.
|
||||
* Compares the current persisted state (from normalized tables) against the
|
||||
* active deployment version state.
|
||||
*
|
||||
* This is the single source of truth for redeployment detection — used by
|
||||
* both the /deploy and /status endpoints to ensure consistent results.
|
||||
*/
|
||||
export async function checkNeedsRedeployment(workflowId: string): Promise<boolean> {
|
||||
const [active] = await db
|
||||
.select({ state: workflowDeploymentVersion.state })
|
||||
.from(workflowDeploymentVersion)
|
||||
.where(
|
||||
and(
|
||||
eq(workflowDeploymentVersion.workflowId, workflowId),
|
||||
eq(workflowDeploymentVersion.isActive, true)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(workflowDeploymentVersion.createdAt))
|
||||
.limit(1)
|
||||
|
||||
if (!active?.state) return false
|
||||
|
||||
const [normalizedData, [workflowRecord]] = await Promise.all([
|
||||
loadWorkflowFromNormalizedTables(workflowId),
|
||||
db
|
||||
.select({ variables: workflow.variables })
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, workflowId))
|
||||
.limit(1),
|
||||
])
|
||||
if (!normalizedData) return false
|
||||
|
||||
const currentState = {
|
||||
blocks: normalizedData.blocks,
|
||||
edges: normalizedData.edges,
|
||||
loops: normalizedData.loops,
|
||||
parallels: normalizedData.parallels,
|
||||
variables: workflowRecord?.variables || {},
|
||||
}
|
||||
|
||||
return hasWorkflowChanged(currentState as WorkflowState, active.state as WorkflowState)
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies user's workspace permissions using the permissions table
|
||||
* @param userId User ID to check
|
||||
|
||||
@@ -87,32 +87,33 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
}
|
||||
|
||||
const formData = await request.formData()
|
||||
const file = formData.get('file') as File
|
||||
const rawFile = formData.get('file')
|
||||
|
||||
if (!file) {
|
||||
if (!rawFile || !(rawFile instanceof File)) {
|
||||
return NextResponse.json({ error: 'No file provided' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Validate file size (100MB limit)
|
||||
const fileName = rawFile.name || 'untitled'
|
||||
|
||||
const maxSize = 100 * 1024 * 1024
|
||||
if (file.size > maxSize) {
|
||||
if (rawFile.size > maxSize) {
|
||||
return NextResponse.json(
|
||||
{ error: `File size exceeds 100MB limit (${(file.size / (1024 * 1024)).toFixed(2)}MB)` },
|
||||
{ error: `File size exceeds 100MB limit (${(rawFile.size / (1024 * 1024)).toFixed(2)}MB)` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer())
|
||||
const buffer = Buffer.from(await rawFile.arrayBuffer())
|
||||
|
||||
const userFile = await uploadWorkspaceFile(
|
||||
workspaceId,
|
||||
session.user.id,
|
||||
buffer,
|
||||
file.name,
|
||||
file.type || 'application/octet-stream'
|
||||
fileName,
|
||||
rawFile.type || 'application/octet-stream'
|
||||
)
|
||||
|
||||
logger.info(`[${requestId}] Uploaded workspace file: ${file.name}`)
|
||||
logger.info(`[${requestId}] Uploaded workspace file: ${fileName}`)
|
||||
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
@@ -122,8 +123,8 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
action: AuditAction.FILE_UPLOADED,
|
||||
resourceType: AuditResourceType.FILE,
|
||||
resourceId: userFile.id,
|
||||
resourceName: file.name,
|
||||
description: `Uploaded file "${file.name}"`,
|
||||
resourceName: fileName,
|
||||
description: `Uploaded file "${fileName}"`,
|
||||
request,
|
||||
})
|
||||
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useBrandedButtonClass } from '@/hooks/use-branded-button-class'
|
||||
|
||||
interface BrandedLinkProps {
|
||||
href: string
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
target?: string
|
||||
rel?: string
|
||||
}
|
||||
|
||||
export function BrandedLink({ href, children, className = '', target, rel }: BrandedLinkProps) {
|
||||
const buttonClass = useBrandedButtonClass()
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
target={target}
|
||||
rel={rel}
|
||||
className={`${buttonClass} group inline-flex items-center justify-center gap-2 rounded-[10px] py-[6px] pr-[10px] pl-[12px] text-[15px] text-white transition-all ${className}`}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,5 @@
|
||||
import { BookOpen, Github, Rss } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { inter } from '@/app/_styles/fonts/inter/inter'
|
||||
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
||||
import { BrandedLink } from '@/app/changelog/components/branded-link'
|
||||
import ChangelogList from '@/app/changelog/components/timeline-list'
|
||||
|
||||
export interface ChangelogEntry {
|
||||
@@ -47,44 +44,38 @@ export default async function ChangelogContent() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='min-h-screen bg-background'>
|
||||
<div className='min-h-screen'>
|
||||
<div className='relative grid md:grid-cols-2'>
|
||||
{/* Left intro panel */}
|
||||
<div className='relative top-0 overflow-hidden border-border border-b px-6 py-16 sm:px-10 md:sticky md:h-dvh md:border-r md:border-b-0 md:px-12 md:py-24'>
|
||||
<div className='absolute inset-0 bg-grid-pattern opacity-[0.03] dark:opacity-[0.06]' />
|
||||
<div className='absolute inset-0 bg-gradient-to-tr from-background via-transparent to-background/60' />
|
||||
|
||||
<div className='relative top-0 overflow-hidden border-[#2A2A2A] border-b px-6 py-16 sm:px-10 md:sticky md:h-dvh md:border-r md:border-b-0 md:px-12 md:py-24'>
|
||||
<div className='relative mx-auto h-full max-w-xl md:flex md:flex-col md:justify-center'>
|
||||
<h1
|
||||
className={`${soehne.className} mt-6 font-semibold text-4xl tracking-tight sm:text-5xl`}
|
||||
>
|
||||
Changelog
|
||||
</h1>
|
||||
<p className={`${inter.className} mt-4 text-muted-foreground text-sm`}>
|
||||
<h1 className='mt-6 font-[500] text-4xl tracking-tight sm:text-5xl'>Changelog</h1>
|
||||
<p className='mt-4 text-[#999] text-sm'>
|
||||
Stay up-to-date with the latest features, improvements, and bug fixes in Sim. All
|
||||
changes are documented here with detailed release notes.
|
||||
</p>
|
||||
<hr className='mt-6 border-border' />
|
||||
<hr className='mt-6 border-[#2A2A2A]' />
|
||||
|
||||
<div className='mt-6 flex flex-wrap items-center gap-3 text-sm'>
|
||||
<BrandedLink
|
||||
<Link
|
||||
href='https://github.com/simstudioai/sim/releases'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='inline-flex items-center gap-2 rounded-[5px] border border-[#FFFFFF] bg-[#FFFFFF] px-[9px] py-[5px] text-[13.5px] text-black transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]'
|
||||
>
|
||||
<Github className='h-4 w-4' />
|
||||
View on GitHub
|
||||
</BrandedLink>
|
||||
</Link>
|
||||
<Link
|
||||
href='https://docs.sim.ai'
|
||||
className='inline-flex items-center gap-2 rounded-[10px] border border-border py-[6px] pr-[10px] pl-[12px] text-[15px] transition-all hover:bg-muted'
|
||||
className='inline-flex items-center gap-2 rounded-[5px] border border-[#3d3d3d] px-[9px] py-[5px] text-[#ECECEC] text-[13.5px] transition-colors hover:bg-[#2A2A2A]'
|
||||
>
|
||||
<BookOpen className='h-4 w-4' />
|
||||
Documentation
|
||||
</Link>
|
||||
<Link
|
||||
href='/changelog.xml'
|
||||
className='inline-flex items-center gap-2 rounded-[10px] border border-border py-[6px] pr-[10px] pl-[12px] text-[15px] transition-all hover:bg-muted'
|
||||
className='inline-flex items-center gap-2 rounded-[5px] border border-[#3d3d3d] px-[9px] py-[5px] text-[#ECECEC] text-[13.5px] transition-colors hover:bg-[#2A2A2A]'
|
||||
>
|
||||
<Rss className='h-4 w-4' />
|
||||
RSS Feed
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
import React from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/emcn'
|
||||
import { inter } from '@/app/_styles/fonts/inter/inter'
|
||||
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
||||
import type { ChangelogEntry } from '@/app/changelog/components/changelog-content'
|
||||
|
||||
type Props = { initialEntries: ChangelogEntry[] }
|
||||
@@ -100,7 +98,7 @@ export default function ChangelogList({ initialEntries }: Props) {
|
||||
<div key={entry.tag}>
|
||||
<div className='flex items-center justify-between gap-4'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className={`${soehne.className} font-semibold text-[18px] tracking-tight`}>
|
||||
<div className='font-[500] text-[#ECECEC] text-[18px] tracking-tight'>
|
||||
{entry.tag}
|
||||
</div>
|
||||
{entry.contributors && entry.contributors.length > 0 && (
|
||||
@@ -115,7 +113,7 @@ export default function ChangelogList({ initialEntries }: Props) {
|
||||
title={`@${contributor}`}
|
||||
className='block'
|
||||
>
|
||||
<Avatar className='size-6 ring-2 ring-background'>
|
||||
<Avatar className='size-6 ring-2 ring-[#1C1C1C]'>
|
||||
<AvatarImage
|
||||
src={`https://avatars.githubusercontent.com/${contributor}`}
|
||||
alt={`@${contributor}`}
|
||||
@@ -126,14 +124,14 @@ export default function ChangelogList({ initialEntries }: Props) {
|
||||
</a>
|
||||
))}
|
||||
{entry.contributors.length > 5 && (
|
||||
<div className='relative flex size-6 items-center justify-center rounded-full bg-muted text-[10px] text-foreground ring-2 ring-background hover:z-10'>
|
||||
<div className='relative flex size-6 items-center justify-center rounded-full bg-[#2A2A2A] text-[#ECECEC] text-[10px] ring-2 ring-[#1C1C1C] hover:z-10'>
|
||||
+{entry.contributors.length - 5}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={`${inter.className} text-muted-foreground text-xs`}>
|
||||
<div className='text-[#999] text-xs'>
|
||||
{new Date(entry.date).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
@@ -142,15 +140,13 @@ export default function ChangelogList({ initialEntries }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`${inter.className} prose prose-sm dark:prose-invert max-w-none prose-headings:font-semibold prose-a:text-brand-primary prose-headings:text-foreground prose-p:text-muted-foreground prose-a:no-underline hover:prose-a:underline`}
|
||||
>
|
||||
<div className='max-w-none'>
|
||||
<ReactMarkdown
|
||||
components={{
|
||||
h2: ({ children, ...props }) =>
|
||||
isContributorsLabel(children) ? null : (
|
||||
<h3
|
||||
className={`${soehne.className} mt-5 mb-2 font-medium text-[13px] text-foreground tracking-tight`}
|
||||
className='mt-5 mb-2 font-[500] text-[#ECECEC] text-[13px] tracking-tight'
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
@@ -159,7 +155,7 @@ export default function ChangelogList({ initialEntries }: Props) {
|
||||
h3: ({ children, ...props }) =>
|
||||
isContributorsLabel(children) ? null : (
|
||||
<h4
|
||||
className={`${soehne.className} mt-4 mb-1 font-medium text-[13px] text-foreground tracking-tight`}
|
||||
className='mt-4 mb-1 font-[500] text-[#ECECEC] text-[13px] tracking-tight'
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
@@ -174,28 +170,25 @@ export default function ChangelogList({ initialEntries }: Props) {
|
||||
const text = String(children)
|
||||
if (/^\s*contributors\s*:?\s*$/i.test(text)) return null
|
||||
return (
|
||||
<li className='text-[13px] text-muted-foreground leading-relaxed' {...props}>
|
||||
<li className='text-[#999] text-[13px] leading-relaxed' {...props}>
|
||||
{children}
|
||||
</li>
|
||||
)
|
||||
},
|
||||
p: ({ children, ...props }) =>
|
||||
/^\s*contributors\s*:?\s*$/i.test(String(children)) ? null : (
|
||||
<p
|
||||
className='mb-3 text-[13px] text-muted-foreground leading-relaxed'
|
||||
{...props}
|
||||
>
|
||||
<p className='mb-3 text-[#999] text-[13px] leading-relaxed' {...props}>
|
||||
{children}
|
||||
</p>
|
||||
),
|
||||
strong: ({ children, ...props }) => (
|
||||
<strong className='font-medium text-foreground' {...props}>
|
||||
<strong className='font-[500] text-[#ECECEC]' {...props}>
|
||||
{children}
|
||||
</strong>
|
||||
),
|
||||
code: ({ children, ...props }) => (
|
||||
<code
|
||||
className='rounded bg-muted px-1 py-0.5 font-mono text-foreground text-xs'
|
||||
className='rounded bg-[#2A2A2A] px-1 py-0.5 font-mono text-[#ECECEC] text-xs'
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
@@ -224,7 +217,7 @@ export default function ChangelogList({ initialEntries }: Props) {
|
||||
type='button'
|
||||
onClick={loadMore}
|
||||
disabled={loading}
|
||||
className='rounded-md border border-border px-3 py-1.5 text-[13px] hover:bg-muted disabled:opacity-60'
|
||||
className='rounded-[5px] border border-[#3d3d3d] px-3 py-1.5 text-[#ECECEC] text-[13px] transition-colors hover:bg-[#2A2A2A] disabled:opacity-60'
|
||||
>
|
||||
{loading ? 'Loading…' : 'Show more'}
|
||||
</button>
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import Nav from '@/app/(landing)/components/nav/nav'
|
||||
import { martianMono } from '@/app/_styles/fonts/martian-mono/martian-mono'
|
||||
import Footer from '@/app/(home)/components/footer/footer'
|
||||
import Navbar from '@/app/(home)/components/navbar/navbar'
|
||||
|
||||
export default function ChangelogLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className='relative min-h-screen text-foreground'>
|
||||
<div className='-z-50 pointer-events-none fixed inset-0 bg-white' />
|
||||
<Nav />
|
||||
<div
|
||||
className={`${martianMono.variable} relative min-h-screen bg-[#1C1C1C] font-[430] font-season text-[#ECECEC]`}
|
||||
>
|
||||
<header>
|
||||
<Navbar />
|
||||
</header>
|
||||
{children}
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,17 +2,13 @@
|
||||
|
||||
import { type KeyboardEvent, useEffect, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Input } from '@/components/emcn'
|
||||
import { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/ui/input-otp'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Input, InputOTP, InputOTPGroup, InputOTPSlot, Label } from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
||||
import { inter } from '@/app/_styles/fonts/inter/inter'
|
||||
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
||||
import AuthBackground from '@/app/(auth)/components/auth-background'
|
||||
import { BrandedButton } from '@/app/(auth)/components/branded-button'
|
||||
import { SupportFooter } from '@/app/(auth)/components/support-footer'
|
||||
import Nav from '@/app/(landing)/components/nav/nav'
|
||||
import Navbar from '@/app/(home)/components/navbar/navbar'
|
||||
|
||||
const logger = createLogger('EmailAuth')
|
||||
|
||||
@@ -185,26 +181,26 @@ export default function EmailAuth({ identifier, onAuthSuccess }: EmailAuthProps)
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthBackground>
|
||||
<main className='relative flex min-h-screen flex-col text-foreground'>
|
||||
<Nav hideAuthButtons={true} variant='auth' />
|
||||
<AuthBackground className='dark font-[430] font-season'>
|
||||
<main className='relative flex min-h-screen flex-col text-[#ECECEC]'>
|
||||
<header className='shrink-0 bg-[#1C1C1C]'>
|
||||
<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={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}
|
||||
>
|
||||
<h1 className='font-[500] text-[#ECECEC] text-[32px] tracking-tight'>
|
||||
{showOtpVerification ? 'Verify Your Email' : 'Email Verification'}
|
||||
</h1>
|
||||
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
|
||||
<p className='font-[380] text-[#999] text-[16px]'>
|
||||
{showOtpVerification
|
||||
? `A verification code has been sent to ${email}`
|
||||
: 'This chat requires email verification'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={`${inter.className} mt-8 w-full max-w-[410px]`}>
|
||||
<div className='mt-8 w-full max-w-[410px]'>
|
||||
{!showOtpVerification ? (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
@@ -229,10 +225,9 @@ export default function EmailAuth({ identifier, onAuthSuccess }: EmailAuthProps)
|
||||
onChange={handleEmailChange}
|
||||
onKeyDown={handleEmailKeyDown}
|
||||
className={cn(
|
||||
'rounded-[10px] shadow-sm transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100',
|
||||
showEmailValidationError &&
|
||||
emailErrors.length > 0 &&
|
||||
'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500'
|
||||
'border-red-500 focus:border-red-500'
|
||||
)}
|
||||
autoFocus
|
||||
/>
|
||||
@@ -251,7 +246,7 @@ export default function EmailAuth({ identifier, onAuthSuccess }: EmailAuthProps)
|
||||
</form>
|
||||
) : (
|
||||
<div className='space-y-6'>
|
||||
<p className='text-center text-muted-foreground text-sm'>
|
||||
<p className='text-center text-[#999] text-sm'>
|
||||
Enter the 6-digit code to verify your account. If you don't see it in your
|
||||
inbox, check your spam folder.
|
||||
</p>
|
||||
@@ -269,18 +264,12 @@ export default function EmailAuth({ identifier, onAuthSuccess }: EmailAuthProps)
|
||||
disabled={isVerifyingOtp}
|
||||
className={cn('gap-2', authError && 'otp-error')}
|
||||
>
|
||||
<InputOTPGroup className='[&>div]:!rounded-[10px] gap-2'>
|
||||
<InputOTPGroup>
|
||||
{[0, 1, 2, 3, 4, 5].map((index) => (
|
||||
<InputOTPSlot
|
||||
key={index}
|
||||
index={index}
|
||||
className={cn(
|
||||
'!rounded-[10px] h-12 w-12 border bg-white text-center font-medium text-lg shadow-sm transition-all duration-200',
|
||||
'border-gray-300 hover:border-gray-400',
|
||||
'focus:border-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-100',
|
||||
authError &&
|
||||
'border-red-500 focus:border-red-500 focus:ring-red-100'
|
||||
)}
|
||||
className={cn(authError && 'border-red-500')}
|
||||
/>
|
||||
))}
|
||||
</InputOTPGroup>
|
||||
|
||||
@@ -3,15 +3,12 @@
|
||||
import { type KeyboardEvent, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import { Input } from '@/components/emcn'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Input, Label } from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { inter } from '@/app/_styles/fonts/inter/inter'
|
||||
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
||||
import AuthBackground from '@/app/(auth)/components/auth-background'
|
||||
import { BrandedButton } from '@/app/(auth)/components/branded-button'
|
||||
import { SupportFooter } from '@/app/(auth)/components/support-footer'
|
||||
import Nav from '@/app/(landing)/components/nav/nav'
|
||||
import Navbar from '@/app/(home)/components/navbar/navbar'
|
||||
|
||||
const logger = createLogger('PasswordAuth')
|
||||
|
||||
@@ -82,19 +79,19 @@ export default function PasswordAuth({ identifier, onAuthSuccess }: PasswordAuth
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthBackground>
|
||||
<main className='relative flex min-h-screen flex-col text-foreground'>
|
||||
<Nav hideAuthButtons={true} variant='auth' />
|
||||
<AuthBackground className='dark font-[430] font-season'>
|
||||
<main className='relative flex min-h-screen flex-col text-[#ECECEC]'>
|
||||
<header className='shrink-0 bg-[#1C1C1C]'>
|
||||
<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={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}
|
||||
>
|
||||
<h1 className='font-[500] text-[#ECECEC] text-[32px] tracking-tight'>
|
||||
Password Required
|
||||
</h1>
|
||||
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
|
||||
<p className='font-[380] text-[#999] text-[16px]'>
|
||||
This chat is password-protected
|
||||
</p>
|
||||
</div>
|
||||
@@ -104,7 +101,7 @@ export default function PasswordAuth({ identifier, onAuthSuccess }: PasswordAuth
|
||||
e.preventDefault()
|
||||
handleAuthenticate()
|
||||
}}
|
||||
className={`${inter.className} mt-8 w-full max-w-[410px] space-y-6`}
|
||||
className='mt-8 w-full max-w-[410px] space-y-6'
|
||||
>
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
@@ -124,17 +121,17 @@ export default function PasswordAuth({ identifier, onAuthSuccess }: PasswordAuth
|
||||
onChange={handlePasswordChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={cn(
|
||||
'rounded-[10px] pr-10 shadow-sm transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100',
|
||||
'pr-10',
|
||||
showValidationError &&
|
||||
passwordErrors.length > 0 &&
|
||||
'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500'
|
||||
'border-red-500 focus:border-red-500'
|
||||
)}
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className='-translate-y-1/2 absolute top-1/2 right-3 text-gray-500 transition hover:text-gray-700'
|
||||
className='-translate-y-1/2 absolute top-1/2 right-3 text-[#999] hover:text-[#ECECEC]'
|
||||
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||
>
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { GithubIcon } from '@/components/icons'
|
||||
import { inter } from '@/app/_styles/fonts/inter/inter'
|
||||
import { useBrandConfig } from '@/ee/whitelabeling'
|
||||
|
||||
interface ChatHeaderProps {
|
||||
@@ -41,7 +40,7 @@ export function ChatHeader({ chatConfig, starCount }: ChatHeaderProps) {
|
||||
className='h-6 w-6 rounded-md object-cover'
|
||||
/>
|
||||
)}
|
||||
<h2 className={`${inter.className} font-medium text-[18px] text-foreground`}>
|
||||
<h2 className='font-medium text-[18px] text-foreground'>
|
||||
{chatConfig?.customizations?.headerText || chatConfig?.title || 'Chat'}
|
||||
</h2>
|
||||
</div>
|
||||
@@ -57,9 +56,7 @@ export function ChatHeader({ chatConfig, starCount }: ChatHeaderProps) {
|
||||
aria-label={`GitHub repository - ${starCount} stars`}
|
||||
>
|
||||
<GithubIcon className='h-[16px] w-[16px]' aria-hidden='true' />
|
||||
<span className={`${inter.className}`} aria-live='polite'>
|
||||
{starCount}
|
||||
</span>
|
||||
<span aria-live='polite'>{starCount}</span>
|
||||
</a>
|
||||
{/* Only show Sim logo if no custom branding is set */}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Skeleton } from '@/components/emcn'
|
||||
|
||||
export function ChatLoadingState() {
|
||||
return (
|
||||
|
||||
@@ -12,7 +12,7 @@ export function FormErrorState({ error }: FormErrorStateProps) {
|
||||
const router = useRouter()
|
||||
|
||||
return (
|
||||
<StatusPageLayout title='Form Unavailable' description={error} hideNav>
|
||||
<StatusPageLayout title='Form Unavailable' description={error}>
|
||||
<BrandedButton onClick={() => router.push('/workspace')}>Return to Workspace</BrandedButton>
|
||||
</StatusPageLayout>
|
||||
)
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useCallback, useRef, useState } from 'react'
|
||||
import { Upload, X } from 'lucide-react'
|
||||
import { Input, Label, Switch, Textarea } from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { inter } from '@/app/_styles/fonts/inter/inter'
|
||||
|
||||
interface InputField {
|
||||
name: string
|
||||
@@ -96,9 +95,7 @@ export function FormField({
|
||||
onCheckedChange={onChange}
|
||||
style={value ? { backgroundColor: primaryColor } : undefined}
|
||||
/>
|
||||
<span className={`${inter.className} text-[14px] text-muted-foreground`}>
|
||||
{value ? 'Yes' : 'No'}
|
||||
</span>
|
||||
<span className={'text-[14px] text-muted-foreground'}>{value ? 'Yes' : 'No'}</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -159,7 +156,7 @@ export function FormField({
|
||||
className='mb-2 h-6 w-6 text-muted-foreground'
|
||||
style={isDragging ? { color: primaryColor } : undefined}
|
||||
/>
|
||||
<p className={`${inter.className} text-center text-[14px] text-muted-foreground`}>
|
||||
<p className={'text-center text-[14px] text-muted-foreground'}>
|
||||
<span style={{ color: primaryColor }} className='font-medium'>
|
||||
Click to upload
|
||||
</span>{' '}
|
||||
@@ -175,12 +172,10 @@ export function FormField({
|
||||
className='flex items-center justify-between rounded-[8px] border border-border bg-muted/30 px-3 py-2'
|
||||
>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<p
|
||||
className={`${inter.className} truncate font-medium text-[13px] text-foreground`}
|
||||
>
|
||||
<p className={'truncate font-medium text-[13px] text-foreground'}>
|
||||
{file.name}
|
||||
</p>
|
||||
<p className={`${inter.className} text-[12px] text-muted-foreground`}>
|
||||
<p className={'text-[12px] text-muted-foreground'}>
|
||||
{formatFileSize(file.size)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -217,7 +212,7 @@ export function FormField({
|
||||
|
||||
return (
|
||||
<div className='space-y-2'>
|
||||
<Label className={`${inter.className} font-medium text-[14px] text-foreground`}>
|
||||
<Label className={'font-medium text-[14px] text-foreground'}>
|
||||
{displayLabel}
|
||||
{isRequired && <span className='ml-0.5 text-[var(--text-error)]'>*</span>}
|
||||
</Label>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Skeleton } from '@/components/emcn'
|
||||
import AuthBackground from '@/app/(auth)/components/auth-background'
|
||||
|
||||
export function FormLoadingState() {
|
||||
|
||||
@@ -2,14 +2,12 @@
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import { Input } from '@/components/emcn'
|
||||
import { Input, Label } from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { inter } from '@/app/_styles/fonts/inter/inter'
|
||||
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
||||
import AuthBackground from '@/app/(auth)/components/auth-background'
|
||||
import { BrandedButton } from '@/app/(auth)/components/branded-button'
|
||||
import { SupportFooter } from '@/app/(auth)/components/support-footer'
|
||||
import Nav from '@/app/(landing)/components/nav/nav'
|
||||
import Navbar from '@/app/(home)/components/navbar/navbar'
|
||||
|
||||
interface PasswordAuthProps {
|
||||
onSubmit: (password: string) => void
|
||||
@@ -34,34 +32,26 @@ export function PasswordAuth({ onSubmit, error }: PasswordAuthProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthBackground>
|
||||
<main className='relative flex min-h-screen flex-col text-foreground'>
|
||||
<Nav hideAuthButtons={true} variant='auth' />
|
||||
<AuthBackground className='dark font-[430] font-season'>
|
||||
<main className='relative flex min-h-screen flex-col text-[#ECECEC]'>
|
||||
<header className='shrink-0 bg-[#1C1C1C]'>
|
||||
<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={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}
|
||||
>
|
||||
<h1 className='font-[500] text-[#ECECEC] text-[32px] tracking-tight'>
|
||||
Password Required
|
||||
</h1>
|
||||
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
|
||||
<p className='font-[380] text-[#999] text-[16px]'>
|
||||
Enter the password to access this form.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className={`${inter.className} mt-8 w-full max-w-[410px] space-y-6`}
|
||||
>
|
||||
<form onSubmit={handleSubmit} className='mt-8 w-full max-w-[410px] space-y-6'>
|
||||
<div className='space-y-2'>
|
||||
<label
|
||||
htmlFor='form-password'
|
||||
className='font-medium text-[14px] text-foreground'
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<Label htmlFor='form-password'>Password</Label>
|
||||
<div className='relative'>
|
||||
<Input
|
||||
id='form-password'
|
||||
@@ -69,16 +59,13 @@ export function PasswordAuth({ onSubmit, error }: PasswordAuthProps) {
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder='Enter password'
|
||||
className={cn(
|
||||
'rounded-[10px] pr-10 shadow-sm transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100',
|
||||
error && 'border-red-500 focus:border-red-500 focus:ring-red-100'
|
||||
)}
|
||||
className={cn(error && 'border-red-500 focus:border-red-500')}
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className='-translate-y-1/2 absolute top-1/2 right-3 text-muted-foreground hover:text-foreground'
|
||||
className='-translate-y-1/2 absolute top-1/2 right-3 text-[#999] hover:text-[#ECECEC]'
|
||||
>
|
||||
{showPassword ? <EyeOff className='h-4 w-4' /> : <Eye className='h-4 w-4' />}
|
||||
</button>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import Image from 'next/image'
|
||||
import { inter } from '@/app/_styles/fonts/inter/inter'
|
||||
import { useBrandConfig } from '@/ee/whitelabeling'
|
||||
|
||||
export function PoweredBySim() {
|
||||
@@ -9,7 +8,9 @@ export function PoweredBySim() {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${inter.className} auth-text-muted fixed right-0 bottom-0 left-0 z-50 pb-8 text-center font-[340] text-[13px] leading-relaxed`}
|
||||
className={
|
||||
'fixed right-0 bottom-0 left-0 z-50 pb-8 text-center font-[340] text-[#999] text-[13px] leading-relaxed'
|
||||
}
|
||||
>
|
||||
<a
|
||||
href='https://sim.ai'
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { CheckCircle2 } from 'lucide-react'
|
||||
import { inter } from '@/app/_styles/fonts/inter/inter'
|
||||
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
||||
|
||||
interface ThankYouScreenProps {
|
||||
title: string
|
||||
@@ -11,7 +9,7 @@ interface ThankYouScreenProps {
|
||||
}
|
||||
|
||||
/** Default green color matching --brand-tertiary-2 */
|
||||
const DEFAULT_THANK_YOU_COLOR = '#32bd7e'
|
||||
const DEFAULT_THANK_YOU_COLOR = '#33C482'
|
||||
|
||||
/** Legacy blue default that should be treated as "no custom color" */
|
||||
const LEGACY_BLUE_DEFAULT = '#3972F6'
|
||||
@@ -31,16 +29,12 @@ export function ThankYouScreen({ title, message, primaryColor }: ThankYouScreenP
|
||||
<CheckCircle2 className='h-10 w-10' style={{ color: thankYouColor }} />
|
||||
</div>
|
||||
<h2
|
||||
className={`${soehne.className} mt-6 font-medium text-[32px] tracking-tight`}
|
||||
className={'mt-6 font-[500] text-[32px] tracking-tight'}
|
||||
style={{ color: thankYouColor }}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
<p
|
||||
className={`${inter.className} mt-3 max-w-md font-[380] text-[16px] text-muted-foreground`}
|
||||
>
|
||||
{message}
|
||||
</p>
|
||||
<p className={'mt-3 max-w-md font-[380] text-[#999] text-[16px]'}>{message}</p>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
|
||||
@@ -21,7 +21,6 @@ export default function FormError({ error, reset }: FormErrorProps) {
|
||||
<StatusPageLayout
|
||||
title='Something went wrong'
|
||||
description='We encountered an error loading this form. Please try again.'
|
||||
hideNav
|
||||
>
|
||||
<BrandedButton onClick={reset}>Try again</BrandedButton>
|
||||
</StatusPageLayout>
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { inter } from '@/app/_styles/fonts/inter/inter'
|
||||
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
||||
import { martianMono } from '@/app/_styles/fonts/martian-mono/martian-mono'
|
||||
import AuthBackground from '@/app/(auth)/components/auth-background'
|
||||
import { BrandedButton } from '@/app/(auth)/components/branded-button'
|
||||
import { SupportFooter } from '@/app/(auth)/components/support-footer'
|
||||
@@ -238,8 +237,8 @@ export default function Form({ identifier }: { identifier: string }) {
|
||||
|
||||
if (isSubmitted && thankYouData) {
|
||||
return (
|
||||
<AuthBackground>
|
||||
<main className='relative flex min-h-screen flex-col text-foreground'>
|
||||
<AuthBackground className={`${martianMono.variable} dark font-[430] font-season`}>
|
||||
<main className='relative flex min-h-screen flex-col text-[#ECECEC]'>
|
||||
<div className='relative z-30 flex flex-1 items-center justify-center px-4 pb-24'>
|
||||
<ThankYouScreen
|
||||
title={thankYouData.title}
|
||||
@@ -270,29 +269,23 @@ export default function Form({ identifier }: { identifier: string }) {
|
||||
)
|
||||
|
||||
return (
|
||||
<AuthBackground>
|
||||
<main className='relative flex min-h-screen flex-col text-foreground'>
|
||||
<AuthBackground className={`${martianMono.variable} dark font-[430] font-season`}>
|
||||
<main className='relative flex min-h-screen flex-col text-[#ECECEC]'>
|
||||
<div className='relative z-30 flex flex-1 justify-center px-4 pt-16 pb-24'>
|
||||
<div className='w-full max-w-[410px]'>
|
||||
{/* Form title */}
|
||||
<div className='mb-8 text-center'>
|
||||
<h1
|
||||
className={`${soehne.className} font-medium text-[28px] text-foreground tracking-tight`}
|
||||
>
|
||||
<h1 className='font-[500] text-[#ECECEC] text-[28px] tracking-tight'>
|
||||
{formConfig.title}
|
||||
</h1>
|
||||
{formConfig.description && (
|
||||
<p
|
||||
className={`${inter.className} mt-2 font-[380] text-[15px] text-muted-foreground`}
|
||||
>
|
||||
{formConfig.description}
|
||||
</p>
|
||||
<p className='mt-2 font-[380] text-[#999] text-[15px]'>{formConfig.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className={`${inter.className} space-y-6`}>
|
||||
<form onSubmit={handleSubmit} className='space-y-6'>
|
||||
{fields.length === 0 ? (
|
||||
<div className='rounded-[10px] border border-border bg-muted/50 p-6 text-center text-muted-foreground'>
|
||||
<div className='rounded-[10px] border border-[#2A2A2A] bg-[#2A2A2A] p-6 text-center text-[#999]'>
|
||||
This form has no fields configured.
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import AuthBackground from '@/app/(auth)/components/auth-background'
|
||||
import Nav from '@/app/(landing)/components/nav/nav'
|
||||
import { SupportFooter } from '@/app/(auth)/components/support-footer'
|
||||
import Navbar from '@/app/(home)/components/navbar/navbar'
|
||||
|
||||
interface InviteLayoutProps {
|
||||
children: React.ReactNode
|
||||
@@ -9,15 +9,16 @@ interface InviteLayoutProps {
|
||||
|
||||
export default function InviteLayout({ children }: InviteLayoutProps) {
|
||||
return (
|
||||
<AuthBackground>
|
||||
<main className='relative flex min-h-screen flex-col text-foreground'>
|
||||
<Nav hideAuthButtons={true} variant='auth' />
|
||||
<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'>{children}</div>
|
||||
</div>
|
||||
<div className='relative flex min-h-screen flex-col bg-[#1C1C1C] font-[430] font-season text-[#ECECEC]'>
|
||||
<header className='shrink-0'>
|
||||
<Navbar logoOnly />
|
||||
</header>
|
||||
<main className='flex flex-1 flex-col items-center justify-center px-4'>
|
||||
<div className='w-full max-w-lg px-4'>
|
||||
<div className='flex flex-col items-center justify-center'>{children}</div>
|
||||
</div>
|
||||
</main>
|
||||
</AuthBackground>
|
||||
<SupportFooter position='absolute' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,10 +2,7 @@
|
||||
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { inter } from '@/app/_styles/fonts/inter/inter'
|
||||
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
||||
import { BrandedButton } from '@/app/(auth)/components/branded-button'
|
||||
import { SupportFooter } from '@/app/(auth)/components/support-footer'
|
||||
|
||||
interface InviteStatusCardProps {
|
||||
type: 'login' | 'loading' | 'error' | 'success' | 'invitation' | 'warning'
|
||||
@@ -35,17 +32,12 @@ export function InviteStatusCard({
|
||||
return (
|
||||
<>
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
|
||||
Loading
|
||||
</h1>
|
||||
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
|
||||
{description}
|
||||
</p>
|
||||
<h1 className='font-[500] text-[#ECECEC] text-[32px] tracking-tight'>Loading</h1>
|
||||
<p className='font-[380] text-[#999] text-[16px]'>{description}</p>
|
||||
</div>
|
||||
<div className={`${inter.className} mt-8 flex w-full items-center justify-center py-8`}>
|
||||
<Loader2 className='h-8 w-8 animate-spin text-muted-foreground' />
|
||||
<div className='mt-8 flex w-full items-center justify-center py-8'>
|
||||
<Loader2 className='h-8 w-8 animate-spin text-[#999]' />
|
||||
</div>
|
||||
<SupportFooter position='absolute' />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -53,33 +45,35 @@ export function InviteStatusCard({
|
||||
return (
|
||||
<>
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
|
||||
{title}
|
||||
</h1>
|
||||
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
|
||||
{description}
|
||||
</p>
|
||||
<h1 className='font-[500] text-[#ECECEC] text-[32px] tracking-tight'>{title}</h1>
|
||||
<p className='font-[380] text-[#999] text-[16px]'>{description}</p>
|
||||
</div>
|
||||
|
||||
<div className={`${inter.className} mt-8 w-full max-w-[410px] space-y-3`}>
|
||||
<div className='mt-8 w-full max-w-[410px] space-y-3'>
|
||||
{isExpiredError && (
|
||||
<BrandedButton onClick={() => router.push('/')}>Request New Invitation</BrandedButton>
|
||||
<BrandedButton onClick={() => router.push('/')} showArrow={false}>
|
||||
Request New Invitation
|
||||
</BrandedButton>
|
||||
)}
|
||||
|
||||
{actions.map((action, index) => (
|
||||
<BrandedButton
|
||||
key={index}
|
||||
onClick={action.onClick}
|
||||
disabled={action.disabled}
|
||||
disabled={action.disabled || action.loading}
|
||||
loading={action.loading}
|
||||
loadingText={action.label}
|
||||
showArrow={false}
|
||||
className={
|
||||
index !== 0
|
||||
? 'border-[#3d3d3d] bg-transparent text-[#ECECEC] hover:border-[#3d3d3d] hover:bg-[#2A2A2A]'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{action.label}
|
||||
</BrandedButton>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<SupportFooter position='absolute' />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -114,6 +114,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
|
||||
if (isCollapsed) {
|
||||
document.documentElement.style.setProperty('--sidebar-width', '51px');
|
||||
document.documentElement.setAttribute('data-sidebar-collapsed', '');
|
||||
} else {
|
||||
var width = state && state.sidebarWidth;
|
||||
var maxSidebarWidth = window.innerWidth * 0.3;
|
||||
|
||||
@@ -1,18 +1,31 @@
|
||||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import AuthBackground from '@/app/(auth)/components/auth-background'
|
||||
import { BrandedButton } from '@/app/(auth)/components/branded-button'
|
||||
import { StatusPageLayout } from '@/app/(auth)/components/status-page-layout'
|
||||
import Navbar from '@/app/(home)/components/navbar/navbar'
|
||||
|
||||
export default function NotFound() {
|
||||
const router = useRouter()
|
||||
|
||||
return (
|
||||
<StatusPageLayout
|
||||
title='Page Not Found'
|
||||
description="The page you're looking for doesn't exist or has been moved."
|
||||
>
|
||||
<BrandedButton onClick={() => router.push('/')}>Return to Home</BrandedButton>
|
||||
</StatusPageLayout>
|
||||
<AuthBackground className='dark font-[430] font-season'>
|
||||
<main className='relative flex min-h-full flex-col text-[#ECECEC]'>
|
||||
<header className='shrink-0 bg-[#1C1C1C]'>
|
||||
<Navbar />
|
||||
</header>
|
||||
<div className='relative z-30 flex flex-1 flex-col items-center justify-center px-4 pb-24'>
|
||||
<h1 className='font-[500] text-[48px] tracking-tight'>Page Not Found</h1>
|
||||
<p className='mt-2 text-[#999] text-[16px]'>
|
||||
The page you're looking for doesn't exist or has been moved.
|
||||
</p>
|
||||
<div className='mt-8 w-full max-w-[200px]'>
|
||||
<BrandedButton onClick={() => router.push('/')} showArrow={false}>
|
||||
Return to Home
|
||||
</BrandedButton>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</AuthBackground>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ export const metadata: Metadata = {
|
||||
canonical: baseUrl,
|
||||
languages: {
|
||||
'en-US': baseUrl,
|
||||
'x-default': baseUrl,
|
||||
},
|
||||
},
|
||||
robots: {
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import Nav from '@/app/(landing)/components/nav/nav'
|
||||
import Navbar from '@/app/(home)/components/navbar/navbar'
|
||||
import { useBrandConfig } from '@/ee/whitelabeling'
|
||||
import type { ResumeStatus } from '@/executor/types'
|
||||
|
||||
@@ -809,8 +809,10 @@ export default function ResumeExecutionPage({
|
||||
if (!executionDetail) {
|
||||
return (
|
||||
<Tooltip.Provider>
|
||||
<div style={{ minHeight: '100vh', background: 'var(--bg)' }}>
|
||||
<Nav variant='auth' />
|
||||
<div className='font-season' style={{ minHeight: '100vh', background: 'var(--bg)' }}>
|
||||
<header>
|
||||
<Navbar />
|
||||
</header>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
@@ -846,8 +848,10 @@ export default function ResumeExecutionPage({
|
||||
|
||||
return (
|
||||
<Tooltip.Provider>
|
||||
<div style={{ minHeight: '100vh', background: 'var(--bg)' }}>
|
||||
<Nav variant='auth' />
|
||||
<div className='font-season' style={{ minHeight: '100vh', background: 'var(--bg)' }}>
|
||||
<header>
|
||||
<Navbar />
|
||||
</header>
|
||||
<div style={{ maxWidth: '1200px', margin: '0 auto', padding: '32px 24px' }}>
|
||||
{/* Header */}
|
||||
<div
|
||||
@@ -1249,7 +1253,7 @@ export default function ResumeExecutionPage({
|
||||
{message && <Badge variant='green'>{message}</Badge>}
|
||||
|
||||
{/* Action */}
|
||||
<Button variant='tertiary' onClick={handleResume} disabled={resumeDisabled}>
|
||||
<Button variant='primary' onClick={handleResume} disabled={resumeDisabled}>
|
||||
{loadingAction ? 'Resuming...' : 'Resume Execution'}
|
||||
</Button>
|
||||
</>
|
||||
|
||||
@@ -27,8 +27,8 @@ import {
|
||||
PopoverContent,
|
||||
PopoverItem,
|
||||
PopoverTrigger,
|
||||
Skeleton,
|
||||
} from '@/components/emcn'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { VerifiedBadge } from '@/components/ui/verified-badge'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
@@ -733,7 +733,7 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
<>
|
||||
{!currentUserId ? (
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
onClick={() => {
|
||||
const callbackUrl =
|
||||
isWorkspaceContext && workspaceId
|
||||
@@ -749,7 +749,7 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
</Button>
|
||||
) : isWorkspaceContext ? (
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
onClick={handleUseTemplate}
|
||||
disabled={isUsing}
|
||||
className='!text-[#FFFFFF] h-[32px] rounded-[6px] px-[12px] text-[14px]'
|
||||
|
||||
@@ -3,10 +3,7 @@
|
||||
import { Suspense, useEffect, useState } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { inter } from '@/app/_styles/fonts/inter/inter'
|
||||
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
||||
import { BrandedButton } from '@/app/(auth)/components/branded-button'
|
||||
import { SupportFooter } from '@/app/(auth)/components/support-footer'
|
||||
import { InviteLayout } from '@/app/invite/components'
|
||||
|
||||
interface UnsubscribeData {
|
||||
@@ -122,17 +119,14 @@ function UnsubscribeContent() {
|
||||
return (
|
||||
<InviteLayout>
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
|
||||
Loading
|
||||
</h1>
|
||||
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
|
||||
<h1 className={'font-medium text-[#ECECEC] text-[32px] tracking-tight'}>Loading</h1>
|
||||
<p className={'font-[380] text-[#999] text-[16px]'}>
|
||||
Validating your unsubscribe link...
|
||||
</p>
|
||||
</div>
|
||||
<div className={`${inter.className} mt-8 flex w-full items-center justify-center py-8`}>
|
||||
<Loader2 className='h-8 w-8 animate-spin text-muted-foreground' />
|
||||
<div className={'mt-8 flex w-full items-center justify-center py-8'}>
|
||||
<Loader2 className='h-8 w-8 animate-spin text-[#999]' />
|
||||
</div>
|
||||
<SupportFooter position='absolute' />
|
||||
</InviteLayout>
|
||||
)
|
||||
}
|
||||
@@ -141,19 +135,15 @@ function UnsubscribeContent() {
|
||||
return (
|
||||
<InviteLayout>
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
|
||||
<h1 className={'font-medium text-[#ECECEC] text-[32px] tracking-tight'}>
|
||||
Invalid Unsubscribe Link
|
||||
</h1>
|
||||
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
|
||||
{error}
|
||||
</p>
|
||||
<p className={'font-[380] text-[#999] text-[16px]'}>{error}</p>
|
||||
</div>
|
||||
|
||||
<div className={`${inter.className} mt-8 w-full max-w-[410px] space-y-3`}>
|
||||
<div className={'mt-8 w-full max-w-[410px] space-y-3'}>
|
||||
<BrandedButton onClick={() => window.history.back()}>Go Back</BrandedButton>
|
||||
</div>
|
||||
|
||||
<SupportFooter position='absolute' />
|
||||
</InviteLayout>
|
||||
)
|
||||
}
|
||||
@@ -162,20 +152,18 @@ function UnsubscribeContent() {
|
||||
return (
|
||||
<InviteLayout>
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
|
||||
<h1 className={'font-medium text-[#ECECEC] text-[32px] tracking-tight'}>
|
||||
Important Account Emails
|
||||
</h1>
|
||||
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
|
||||
<p className={'font-[380] text-[#999] text-[16px]'}>
|
||||
Transactional emails like password resets, account confirmations, and security alerts
|
||||
cannot be unsubscribed from as they contain essential information for your account.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={`${inter.className} mt-8 w-full max-w-[410px] space-y-3`}>
|
||||
<div className={'mt-8 w-full max-w-[410px] space-y-3'}>
|
||||
<BrandedButton onClick={() => window.close()}>Close</BrandedButton>
|
||||
</div>
|
||||
|
||||
<SupportFooter position='absolute' />
|
||||
</InviteLayout>
|
||||
)
|
||||
}
|
||||
@@ -184,20 +172,18 @@ function UnsubscribeContent() {
|
||||
return (
|
||||
<InviteLayout>
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
|
||||
<h1 className={'font-medium text-[#ECECEC] text-[32px] tracking-tight'}>
|
||||
Successfully Unsubscribed
|
||||
</h1>
|
||||
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
|
||||
<p className={'font-[380] text-[#999] text-[16px]'}>
|
||||
You have been unsubscribed from our emails. You will stop receiving emails within 48
|
||||
hours.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={`${inter.className} mt-8 w-full max-w-[410px] space-y-3`}>
|
||||
<div className={'mt-8 w-full max-w-[410px] space-y-3'}>
|
||||
<BrandedButton onClick={() => window.close()}>Close</BrandedButton>
|
||||
</div>
|
||||
|
||||
<SupportFooter position='absolute' />
|
||||
</InviteLayout>
|
||||
)
|
||||
}
|
||||
@@ -207,18 +193,16 @@ function UnsubscribeContent() {
|
||||
return (
|
||||
<InviteLayout>
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
|
||||
<h1 className={'font-medium text-[#ECECEC] text-[32px] tracking-tight'}>
|
||||
Email Preferences
|
||||
</h1>
|
||||
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
|
||||
<p className={'font-[380] text-[#999] text-[16px]'}>
|
||||
Choose which emails you'd like to stop receiving.
|
||||
</p>
|
||||
<p className={`${inter.className} mt-2 font-[380] text-[14px] text-muted-foreground`}>
|
||||
{data?.email}
|
||||
</p>
|
||||
<p className={'mt-2 font-[380] text-[#999] text-[14px]'}>{data?.email}</p>
|
||||
</div>
|
||||
|
||||
<div className={`${inter.className} mt-8 w-full max-w-[410px] space-y-3`}>
|
||||
<div className={'mt-8 w-full max-w-[410px] space-y-3'}>
|
||||
<BrandedButton
|
||||
onClick={() => handleUnsubscribe('all')}
|
||||
disabled={processing || isAlreadyUnsubscribedFromAll}
|
||||
@@ -231,9 +215,7 @@ function UnsubscribeContent() {
|
||||
</BrandedButton>
|
||||
|
||||
<div className='py-2 text-center'>
|
||||
<span className={`${inter.className} font-[380] text-[14px] text-muted-foreground`}>
|
||||
or choose specific types
|
||||
</span>
|
||||
<span className={'font-[380] text-[#999] text-[14px]'}>or choose specific types</span>
|
||||
</div>
|
||||
|
||||
<BrandedButton
|
||||
@@ -276,14 +258,12 @@ function UnsubscribeContent() {
|
||||
</BrandedButton>
|
||||
</div>
|
||||
|
||||
<div className={`${inter.className} mt-6 max-w-[410px] text-center`}>
|
||||
<p className='font-[380] text-[13px] text-muted-foreground'>
|
||||
<div className={'mt-6 max-w-[410px] text-center'}>
|
||||
<p className='font-[380] text-[#999] text-[13px]'>
|
||||
You'll continue receiving important account emails like password resets and security
|
||||
alerts.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SupportFooter position='absolute' />
|
||||
</InviteLayout>
|
||||
)
|
||||
}
|
||||
@@ -294,17 +274,14 @@ export default function Unsubscribe() {
|
||||
fallback={
|
||||
<InviteLayout>
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
|
||||
Loading
|
||||
</h1>
|
||||
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
|
||||
<h1 className={'font-medium text-[#ECECEC] text-[32px] tracking-tight'}>Loading</h1>
|
||||
<p className={'font-[380] text-[#999] text-[16px]'}>
|
||||
Validating your unsubscribe link...
|
||||
</p>
|
||||
</div>
|
||||
<div className={`${inter.className} mt-8 flex w-full items-center justify-center py-8`}>
|
||||
<Loader2 className='h-8 w-8 animate-spin text-muted-foreground' />
|
||||
<div className={'mt-8 flex w-full items-center justify-center py-8'}>
|
||||
<Loader2 className='h-8 w-8 animate-spin text-[#999]' />
|
||||
</div>
|
||||
<SupportFooter position='absolute' />
|
||||
</InviteLayout>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -98,7 +98,7 @@ export function FileViewer({
|
||||
file={file}
|
||||
workspaceId={workspaceId}
|
||||
canEdit={canEdit}
|
||||
previewMode={previewMode ?? (showPreview ? 'split' : 'editor')}
|
||||
previewMode={previewMode ?? (showPreview ? 'preview' : 'editor')}
|
||||
autoFocus={autoFocus}
|
||||
onDirtyChange={onDirtyChange}
|
||||
onSaveStatusChange={onSaveStatusChange}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export type { PreviewMode } from './file-viewer'
|
||||
export { FileViewer, isPreviewable, isTextEditable } from './file-viewer'
|
||||
export { PREVIEW_ONLY_EXTENSIONS, RICH_PREVIEWABLE_EXTENSIONS } from './preview-panel'
|
||||
export { RICH_PREVIEWABLE_EXTENSIONS } from './preview-panel'
|
||||
|
||||
@@ -23,9 +23,6 @@ const PREVIEWABLE_EXTENSIONS: Record<string, PreviewType> = {
|
||||
svg: 'svg',
|
||||
}
|
||||
|
||||
/** Extensions that should default to rendered preview (no raw editor). */
|
||||
export const PREVIEW_ONLY_EXTENSIONS = new Set(['html', 'htm', 'svg'])
|
||||
|
||||
/** All extensions that have a rich preview renderer. */
|
||||
export const RICH_PREVIEWABLE_EXTENSIONS = new Set(Object.keys(PREVIEWABLE_EXTENSIONS))
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user