Compare commits

...

13 Commits

Author SHA1 Message Date
Vikhyath Mondreti
2149f5e36d v0.3.54: google forms block, new landing page, generic mail tool, billing fixes 2025-09-16 22:13:12 -07:00
Emir Karabeg
009e1da5f1 improvement(platform): ui/ux (#1357)
* improvement: branding; auth; chat-deploy

* improvement: docs favicon

* fix: rolled back verification logic
2025-09-16 21:51:23 -07:00
Vikhyath Mondreti
6101493f12 fix(next-js): pin version (#1358)
* fix(next-js): pin version

* fix
2025-09-16 21:50:52 -07:00
Adam Gough
4b5c2b43e9 feat(google-forms): added google forms block (#1343)
* fix(sidebar): draggable cursor on sidebar when switching workflows (#1276)

* added google form tool to read forms

* added trigger mode and block docs

* updated docs

* removed file

* reverted diff

* greptile comments

* Reverted bun file

* remove outdated code for old webhook modal

* restore ui changes to webhooks

* removed provider specific logic

* fix lint

---------

Co-authored-by: Waleed Latif <walif6@gmail.com>
Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
Co-authored-by: Adam Gough <adamgough@Mac.attlocal.net>
Co-authored-by: Siddharth Ganesan <33737564+Sg312@users.noreply.github.com>
2025-09-16 21:20:59 -07:00
Vikhyath Mondreti
bd402cdda5 fix(layout): fix layout semantics on invite page (#1356) 2025-09-16 21:17:51 -07:00
Emir Karabeg
0c30646a2d improvement: branding; auth; chat-deploy (#1351)
* improvement: branding; auth; chat-deploy

* improvement: docs favicon
2025-09-16 21:16:55 -07:00
Vikhyath Mondreti
53792b9a1d fix bun lock (#1354) 2025-09-16 21:02:11 -07:00
Vikhyath Mondreti
48f86e66f4 fix(better-auth): revert back to version 1.2.9 (#1352) 2025-09-16 20:53:01 -07:00
Waleed
fd422b5d0d feat(sms): add generic sms sending block/tool (#1349)
* update infra and remove railway

* feat(sms): add generic sms sending block/tool

* added docs

* Revert "update infra and remove railway"

This reverts commit abfa2f8d51.

* updated colors

* remove fallbacks, stronger typing
2025-09-16 17:47:15 -07:00
Waleed
17cf72834d improvement(gh): fix i18n github action to run on merge to staging (#1350)
* update infra and remove railway

* improvement(ci): fix i18n github action

* Revert "update infra and remove railway"

This reverts commit abfa2f8d51.
2025-09-16 17:34:53 -07:00
Waleed
3122b506fd feat(tools): add generic mail sending block/tools, updated docs script (#1348)
* update infra and remove railway

* feat(tools): add generic mail sending block/tools, updated docs script

* Revert "update infra and remove railway"

This reverts commit abfa2f8d51.

* remove message id

* updated type
2025-09-16 16:06:31 -07:00
Waleed
a31305b7ee fix(bun): pin bun version for db migrations (#1347)
* fix(migrations): downgrade nextjs

* fix(bun): pin bun version in db migrations

* Revert "fix(migrations): downgrade nextjs"

This reverts commit 27b544f22d.
2025-09-16 14:31:37 -07:00
Emir Karabeg
4f26a7aa73 feat(landing): new landing page (#1219)
* update infra and remove railway

* feat(landing): background; font; metadata; nav

* finished navbar ui

* completed hero UI

* hero heading UI/UX

* updated icon descriptions

* canvas improvements

* canvas improvements

* updated canvas; adjusted background

* removed gsap; adjusted canvas height

* added templates outline

* feat(landing, landing-2): Update background, hero components, nav, integrations, pricing, templates, testimonials, tailwind config

* feat(landing, landing-2): Update background, footer, hero, index components, integrations, landing-pricing, landing templates, footer in sections, icons, middleware

* improvement(landing): optimized html

* feat(landing): update background, footer, hero, integrations, landing-enterprise, landing-pricing, landing-templates, nav, add github-stars route

* feat(landing): added onclicks

* feat(landing): commented out templates

* fix: reset environment

* fixed build

* feat(landing): updated background, footer, index, integrations, landing-pricing, nav, testimonials, landing page, fonts, environment

* feat(landing): swapped integrations and pricing

* navigation for new landing

* login/signup/terms/privacy preliminary changes, as well as navigation setup

* feat(landing,nav,hero,integrations,footer,testimonials,background,structured-data): updates and additions across components

* feat(landing): updated terms and privacy

* feat(auth): adjusted background

* feat(auth): signup and login complete

* feat(auth): completed all flows ui/ux

* fix: testing and build

* feat(landing, auth): update nav and login tests

* fix(ui): update auth navigation component (149 chars)

* restore scripts dir

* revert back to old globals.css brand primary color, updated invite page

* Revert "update infra and remove railway"

This reverts commit abfa2f8d51.

* remove logos

* add gh stars action for reuse on landing + cht

---------

Co-authored-by: waleedlatif1 <walif6@gmail.com>
2025-09-15 21:40:35 -07:00
231 changed files with 8601 additions and 8233 deletions

View File

@@ -2,17 +2,16 @@ name: 'Auto-translate Documentation'
on:
push:
branches: [ main ]
paths:
- 'apps/docs/content/docs/en/**'
- 'apps/docs/i18n.json'
pull_request:
branches: [ main ]
branches: [ staging ]
paths:
- 'apps/docs/content/docs/en/**'
- 'apps/docs/i18n.json'
workflow_dispatch: # Allow manual triggers
permissions:
contents: write
pull-requests: write
jobs:
translate:
runs-on: ubuntu-latest
@@ -24,12 +23,12 @@ jobs:
with:
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0
- name: Setup Bun
uses: oven-sh/setup-bun@v1
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Run Lingo.dev translations
env:
LINGODOTDEV_API_KEY: ${{ secrets.LINGODOTDEV_API_KEY }}
@@ -50,38 +49,56 @@ jobs:
echo "changes=false" >> $GITHUB_OUTPUT
fi
- name: Commit and push translation updates
- name: Create Pull Request with translations
if: steps.changes.outputs.changes == 'true'
run: |
cd apps/docs
git add content/docs/es/ content/docs/fr/ content/docs/zh/ i18n.lock
git commit -m "feat: update translations"
git push origin ${{ github.ref_name }}
- name: Create Pull Request (for feature branches)
if: steps.changes.outputs.changes == 'true' && github.event_name == 'pull_request'
uses: peter-evans/create-pull-request@v5
with:
token: ${{ secrets.GITHUB_TOKEN }}
commit-message: "feat: update translations"
commit-message: "feat(i18n): update translations"
title: "🌐 Auto-update translations"
body: |
## Summary
Automated translation updates for documentation.
Automated translation updates triggered by changes to documentation.
- Updated translations for modified English content
- Generated using Lingo.dev AI translation
- Maintains consistency with source documentation
This PR was automatically created after content changes were made, updating translations for all supported languages using Lingo.dev AI translation engine.
## Test Plan
- [ ] Verify translated content accuracy
- [ ] Check that all links and references work correctly
- [ ] Ensure formatting and structure are preserved
branch: auto-translations
base: ${{ github.base_ref }}
**Original trigger**: ${{ github.event.head_commit.message }}
**Commit**: ${{ github.sha }}
**Workflow**: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
## Type of Change
- [ ] Bug fix
- [ ] New feature
- [ ] Breaking change
- [x] Documentation
- [ ] Other: ___________
## Testing
This PR includes automated translations for modified English documentation content:
- 🇪🇸 Spanish (es) translations
- 🇫🇷 French (fr) translations
- 🇨🇳 Chinese (zh) translations
**What reviewers should focus on:**
- Verify translated content accuracy and context
- Check that all links and references work correctly in translated versions
- Ensure formatting, code blocks, and structure are preserved
- Validate that technical terms are appropriately translated
## Checklist
- [x] Code follows project style guidelines (automated translation)
- [x] Self-reviewed my changes (automated process)
- [ ] Tests added/updated and passing
- [x] No new warnings introduced
- [x] I confirm that I have read and agree to the terms outlined in the [Contributor License Agreement (CLA)](./CONTRIBUTING.md#contributor-license-agreement-cla)
## Screenshots/Videos
<!-- Translation changes are text-based - no visual changes expected -->
<!-- Reviewers should check the documentation site renders correctly for all languages -->
branch: auto-translate/staging-merge-${{ github.run_id }}
base: staging
labels: |
i18n
auto-generated
verify-translations:
needs: translate
@@ -91,26 +108,30 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
ref: staging
- name: Setup Bun
uses: oven-sh/setup-bun@v1
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies
run: |
cd apps/docs
bun install
- name: Build documentation to verify translations
run: |
cd apps/docs
bun run build
- name: Report translation status
run: |
cd apps/docs
echo "## Translation Status Report" >> $GITHUB_STEP_SUMMARY
echo "**Triggered by merge to staging branch**" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
en_count=$(find content/docs/en -name "*.mdx" | wc -l)
es_count=$(find content/docs/es -name "*.mdx" 2>/dev/null | wc -l || echo 0)
@@ -121,6 +142,10 @@ jobs:
fr_percentage=$((fr_count * 100 / en_count))
zh_percentage=$((zh_count * 100 / en_count))
echo "### Coverage Statistics" >> $GITHUB_STEP_SUMMARY
echo "- **🇬🇧 English**: $en_count files (source)" >> $GITHUB_STEP_SUMMARY
echo "- **🇪🇸 Spanish**: $es_count/$en_count files ($es_percentage%)" >> $GITHUB_STEP_SUMMARY
echo "- **🇫🇷 French**: $fr_count/$en_count files ($fr_percentage%)" >> $GITHUB_STEP_SUMMARY
echo "- **🇨🇳 Chinese**: $zh_count/$en_count files ($zh_percentage%)" >> $GITHUB_STEP_SUMMARY
echo "- **🇨🇳 Chinese**: $zh_count/$en_count files ($zh_percentage%)" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "🔄 **Auto-translation PR**: Check for new pull request with updated translations" >> $GITHUB_STEP_SUMMARY

View File

@@ -57,7 +57,7 @@ In Sim, the Airtable integration enables your agents to interact with your Airta
## Usage Instructions
Integrate Airtable functionality to manage table records. List, get, create,
Integrates Airtable into the workflow. Can create, get, list, or update Airtable records. Requires OAuth. Can be used in trigger mode to trigger a workflow when an update is made to an Airtable table.

View File

@@ -47,7 +47,7 @@ In Sim, the ArXiv integration enables your agents to programmatically search, re
## Usage Instructions
Search for academic papers, retrieve metadata, download papers, and access the vast collection of scientific research on ArXiv.
Integrates ArXiv into the workflow. Can search for papers, get paper details, and get author papers. Does not require OAuth or an API key.

View File

@@ -57,7 +57,7 @@ In Sim, the BrowserUse integration allows your agents to interact with the web a
## Usage Instructions
Execute browser automation tasks with BrowserUse to navigate the web, scrape data, and perform actions as if a real user was interacting with the browser. The task runs asynchronously and the block will poll for completion before returning results.
Integrate Browser Use into the workflow. Can navigate the web and perform actions as if a real user was interacting with the browser. Requires API Key.

View File

@@ -198,7 +198,7 @@ In Sim, the Clay integration allows your agents to push structured data into Cla
## Usage Instructions
Populate Clay workbook with data using a JSON or plain text. Enables direct communication and notifications with channel confirmation.
Integrate Clay into the workflow. Can populate a table with data. Requires an API Key.

View File

@@ -43,7 +43,7 @@ In Sim, the Confluence integration enables your agents to access and leverage yo
## Usage Instructions
Connect to Confluence workspaces to retrieve and search documentation. Access page content, metadata, and integrate Confluence documentation into your workflows.
Integrate Confluence into the workflow. Can read and update a page. Requires OAuth.

View File

@@ -22,7 +22,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
<g>
<path
d='M216.856339,16.5966031 C200.285002,8.84328665 182.566144,3.2084988 164.041564,0 C161.766523,4.11318106 159.108624,9.64549908 157.276099,14.0464379 C137.583995,11.0849896 118.072967,11.0849896 98.7430163,14.0464379 C96.9108417,9.64549908 94.1925838,4.11318106 91.8971895,0 C73.3526068,3.2084988 55.6133949,8.86399117 39.0420583,16.6376612 C5.61752293,67.146514 -3.4433191,116.400813 1.08711069,164.955721 C23.2560196,181.510915 44.7403634,191.567697 65.8621325,198.148576 C71.0772151,190.971126 75.7283628,183.341335 79.7352139,175.300261 C72.104019,172.400575 64.7949724,168.822202 57.8887866,164.667963 C59.7209612,163.310589 61.5131304,161.891452 63.2445898,160.431257 C105.36741,180.133187 151.134928,180.133187 192.754523,160.431257 C194.506336,161.891452 196.298154,163.310589 198.110326,164.667963 C191.183787,168.842556 183.854737,172.420929 176.223542,175.320965 C180.230393,183.341335 184.861538,190.991831 190.096624,198.16893 C211.238746,191.588051 232.743023,181.531619 254.911949,164.955721 C260.227747,108.668201 245.831087,59.8662432 216.856339,16.5966031 Z M85.4738752,135.09489 C72.8290281,135.09489 62.4592217,123.290155 62.4592217,108.914901 C62.4592217,94.5396472 72.607595,82.7145587 85.4738752,82.7145587 C98.3405064,82.7145587 108.709962,94.5189427 108.488529,108.914901 C108.508531,123.290155 98.3405064,135.09489 85.4738752,135.09489 Z M170.525237,135.09489 C157.88039,135.09489 147.510584,123.290155 147.510584,108.914901 C147.510584,94.5396472 157.658606,82.7145587 170.525237,82.7145587 C183.391518,82.7145587 193.761324,94.5189427 193.539891,108.914901 C193.539891,123.290155 183.391518,135.09489 170.525237,135.09489 Z'
fill='#5865F2'
fill='currentColor'
fillRule='nonzero'
/>
</g>
@@ -57,7 +57,7 @@ Discord components in Sim use efficient lazy loading, only fetching data when ne
## Usage Instructions
Connect to Discord to send messages, manage channels, and interact with servers. Automate notifications, community management, and integrate Discord into your workflows.
Integrate Discord into the workflow. Can send and get messages, get server information, and get a users information. Requires bot API key.

View File

@@ -39,7 +39,7 @@ In Sim, the ElevenLabs integration enables your agents to convert text to lifeli
## Usage Instructions
Generate realistic speech from text using ElevenLabs voices.
Integrate ElevenLabs into the workflow. Can convert text to speech. Requires API key.

View File

@@ -44,7 +44,7 @@ In Sim, the Exa integration allows your agents to search the web for information
## Usage Instructions
Search the web, retrieve content, find similar links, and answer questions using Exa's powerful AI search capabilities.
Integrate Exa into the workflow. Can search, get contents, find similar links, answer a question, and perform research. Requires API Key.

View File

@@ -50,7 +50,7 @@ The File Parser tool is particularly useful for scenarios where your agents need
## Usage Instructions
Upload and extract contents from structured file formats including PDFs, CSV spreadsheets, and Word documents (DOCX). You can either provide a URL to a file or upload files directly. Specialized parsers extract text and metadata from each format. You can upload multiple files at once and access them individually or as a combined document.
Integrate File into the workflow. Can upload a file manually or insert a file url.

View File

@@ -59,7 +59,7 @@ This allows your agents to gather information from websites, extract structured
## Usage Instructions
Extract content from any website with advanced web scraping or search the web for information. Retrieve clean, structured data from web pages with options to focus on main content, or intelligently search for information across the web.
Integrate Firecrawl into the workflow. Can search, scrape, or crawl websites. Requires API Key.

View File

@@ -1,6 +1,6 @@
---
title: Webhook
description: Receive webhooks from any service
description: Receive webhooks from any service by configuring a custom webhook.
---
import { BlockInfoCard } from "@/components/ui/block-info-card"

View File

@@ -35,7 +35,7 @@ In Sim, the GitHub integration enables your agents to interact directly with Git
## Usage Instructions
Access GitHub repositories, pull requests, and comments through the GitHub API. Automate code reviews, PR management, and repository interactions within your workflow. Trigger workflows from GitHub events like push, pull requests, and issues.
Integrate Github into the workflow. Can get get PR details, create PR comment, get repository info, and get latest commit. Requires github token API Key. Can be used in trigger mode to trigger a workflow when a PR is created, commented on, or a commit is pushed.

View File

@@ -51,7 +51,7 @@ In Sim, the Gmail integration enables your agents to send, read, and search emai
## Usage Instructions
Comprehensive Gmail integration with OAuth authentication. Send email messages, read email content, and trigger workflows from Gmail events like new emails and label changes.
Integrate Gmail into the workflow. Can send, read, and search emails. Requires OAuth. Can be used in trigger mode to trigger a workflow when a new email is received.

View File

@@ -90,7 +90,7 @@ In Sim, the Google Calendar integration enables your agents to programmatically
## Usage Instructions
Integrate Google Calendar functionality to create, read, update, and list calendar events within your workflow. Automate scheduling, check availability, and manage events using OAuth authentication. Email invitations are sent asynchronously and delivery depends on recipients' Google Calendar settings.
Integrate Google Calendar into the workflow. Can create, read, update, and list calendar events. Requires OAuth.

View File

@@ -81,7 +81,7 @@ In Sim, the Google Docs integration enables your agents to interact directly wit
## Usage Instructions
Integrate Google Docs functionality to manage documents. Read content from existing documents, write to documents, and create new documents using OAuth authentication. Supports text content manipulation for document creation and editing.
Integrate Google Docs into the workflow. Can read, write, and create documents. Requires OAuth.

View File

@@ -73,7 +73,7 @@ In Sim, the Google Drive integration enables your agents to interact directly wi
## Usage Instructions
Integrate Google Drive functionality to manage files and folders. Upload new files, get content from existing files, create new folders, and list contents of folders using OAuth authentication. Supports file operations with custom MIME types and folder organization.
Integrate Google Drive into the workflow. Can create, upload, and list files. Requires OAuth.

View File

@@ -0,0 +1,86 @@
---
title: Google Forms
description: Read responses from a Google Form
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="google_forms"
color="#E0E0E0"
icon={true}
iconSvg={`<svg className="block-icon" xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 65' fill='none'>
<path
d='M29.583 0H4.438C1.997 0 0 1.997 0 4.438v56.208C0 63.086 1.997 65.083 4.438 65.083h38.458c2.44 0 4.437-1.997 4.437-4.437V17.75L36.979 10.354 29.583 0Z'
fill='#673AB7'
/>
<path
d='M29.583 0v10.354c0 2.45 1.986 4.438 4.438 4.438h13.312L36.979 10.354 29.583 0Z'
fill='#B39DDB'
/>
<path
d='M19.229 50.292h16.271v-2.959H19.229v2.959Zm0-17.75v2.958h16.271v-2.958H19.229Zm-3.698 1.479c0 1.224-0.995 2.219-2.219 2.219s-2.219-0.995-2.219-2.219c0-1.224 0.995-2.219 2.219-2.219s2.219 0.995 2.219 2.219Zm0 7.396c0 1.224-0.995 2.219-2.219 2.219s-2.219-0.995-2.219-2.219c0-1.224 0.995-2.219 2.219-2.219s2.219 0.995 2.219 2.219Zm0 7.396c0 1.224-0.995 2.219-2.219 2.219s-2.219-0.995-2.219-2.219c0-1.224 0.995-2.219 2.219-2.219s2.219 0.995 2.219 2.219Zm3.698-5.917h16.271v-2.959H19.229v2.959Z'
fill='#F1F1F1'
/>
<defs>
<linearGradient
id='gf-gradient'
x1='30.881'
y1='16.452'
x2='47.333'
y2='32.9'
gradientUnits='userSpaceOnUse'
>
<stop stopColor='#9575CD' />
<stop offset='1' stopColor='#7E57C2' />
</linearGradient>
</defs>
</svg>`}
/>
{/* MANUAL-CONTENT-START:intro */}
[Google Forms](https://forms.google.com) is Google's online survey and form tool that allows users to create forms, collect responses, and analyze results. As part of Google's productivity suite, Google Forms makes it easy to gather information, feedback, and data from users.
Learn how to integrate the Google Forms tool in Sim to automatically read and process form responses in your workflows. This tutorial walks you through connecting Google Forms, retrieving responses, and using collected data to power automation. Perfect for syncing survey results, registrations, or feedback with your agents in real-time.
With Google Forms, you can:
- **Create surveys and forms**: Design custom forms for feedback, registration, quizzes, and more
- **Collect responses automatically**: Gather data from users in real-time
- **Analyze results**: View responses in Google Forms or export to Google Sheets for further analysis
- **Collaborate easily**: Share forms and work with others to build and review questions
- **Integrate with other Google services**: Connect with Google Sheets, Drive, and more
In Sim, the Google Forms integration enables your agents to programmatically access form responses. This allows for powerful automation scenarios such as processing survey data, triggering workflows based on new submissions, and syncing form results with other tools. Your agents can fetch all responses for a form, retrieve a specific response, and use the data to drive intelligent automation. By connecting Sim with Google Forms, you can automate data collection, streamline feedback processing, and incorporate form responses into your agent's capabilities.
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Integrate Google Forms into your workflow. Provide a Form ID to list responses, or specify a Response ID to fetch a single response. Requires OAuth.
## Tools
### `google_forms_get_responses`
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| formId | string | Yes | The ID of the Google Form |
| responseId | string | No | If provided, returns this specific response |
| pageSize | number | No | Max responses to return (service may return fewer). Defaults to 5000 |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `data` | json | Response or list of responses |
## Notes
- Category: `tools`
- Type: `google_forms`

View File

@@ -58,7 +58,7 @@ In Sim, the Google Search integration enables your agents to search the web prog
## Usage Instructions
Searches the web using the Google Custom Search API, which provides high-quality search results from the entire internet or a specific site defined by a custom search engine ID.
Integrate Google Search into the workflow. Can search the web. Requires API Key.

View File

@@ -96,7 +96,7 @@ In Sim, the Google Sheets integration enables your agents to interact directly w
## Usage Instructions
Integrate Google Sheets functionality to manage spreadsheet data. Read data from specific ranges, write new data, update existing cells, and append data to the end of sheets using OAuth authentication. Supports various input and output formats for flexible data handling.
Integrate Google Sheets into the workflow. Can read, write, append, and update data. Requires OAuth.

View File

@@ -66,7 +66,7 @@ In Sim, the HuggingFace integration enables your agents to programmatically gene
## Usage Instructions
Generate completions using Hugging Face Inference API with access to various open-source models. Leverage cutting-edge AI models for chat completions, content generation, and AI-powered conversations with customizable parameters.
Integrate Hugging Face into the workflow. Can generate completions using the Hugging Face Inference API. Requires API Key.

View File

@@ -41,7 +41,7 @@ In Sim, the Hunter.io integration enables your agents to programmatically search
## Usage Instructions
Search for email addresses, verify their deliverability, discover companies, and enrich contact data using Hunter.io's powerful email finding capabilities.
Integrate Hunter into the workflow. Can search domains, find email addresses, verify email addresses, discover companies, find companies, and count email addresses. Requires API Key.

View File

@@ -46,7 +46,7 @@ In Sim, the DALL-E integration enables your agents to generate images programmat
## Usage Instructions
Create high-quality images using OpenAI's image generation models. Configure resolution, quality, style, and other parameters to get exactly the image you need.
Integrate Image Generator into the workflow. Can generate images using DALL-E 3 or GPT Image. Requires API Key.

View File

@@ -63,7 +63,7 @@ This integration is particularly valuable for building agents that need to gathe
## Usage Instructions
Transform web content into clean, readable text using Jina AI's advanced extraction capabilities. Extract meaningful content from websites while preserving important information and optionally gathering links.
Integrate Jina into the workflow. Extracts content from websites. Requires API Key.

View File

@@ -43,7 +43,7 @@ In Sim, the Jira integration allows your agents to seamlessly interact with your
## Usage Instructions
Connect to Jira workspaces to read, write, and update issues. Access content, metadata, and integrate Jira documentation into your workflows.
Integrate Jira into the workflow. Can read, write, and update issues. Requires OAuth.

View File

@@ -49,7 +49,7 @@ In Sim, the Knowledge Base block enables your agents to perform intelligent sema
## Usage Instructions
Perform semantic vector search across knowledge bases, upload individual chunks to existing documents, or create new documents from text content. Uses advanced AI embeddings to understand meaning and context for search operations.
Integrate Knowledge into the workflow. Can search, upload chunks, and create documents.

View File

@@ -10,11 +10,8 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
color="#5E6AD2"
icon={true}
iconSvg={`<svg className="block-icon"
xmlns='http://www.w3.org/2000/svg'
fill='currentColor'
viewBox='0 0 100 100'
>
<path
@@ -42,7 +39,7 @@ In Sim, the Linear integration allows your agents to seamlessly interact with yo
## Usage Instructions
Integrate with Linear to fetch, filter, and create issues directly from your workflow.
Integrate Linear into the workflow. Can read and create issues. Requires OAuth.

View File

@@ -43,7 +43,7 @@ To implement Linkup in your agent, simply add the tool to your agent's configura
## Usage Instructions
Linkup Search allows you to search and retrieve up-to-date information from the web with source attribution.
Integrate Linkup into the workflow. Can search the web. Requires API Key.

View File

@@ -0,0 +1,70 @@
---
title: Mail
description: Send emails using the internal mail service
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="mail"
color="#181C1E"
icon={true}
iconSvg={`<svg className="block-icon"
viewBox='0 0 30 24'
fill='none'
xmlns='http://www.w3.org/2000/svg'
>
<path
d='M2.35742 5.83288L11.7674 12.1071C13.0656 12.9712 13.7141 13.404 14.4151 13.5725C15.0352 13.7208 15.681 13.7208 16.2998 13.5725C17.0008 13.404 17.6492 12.9712 18.9475 12.1071L28.3574 5.83288M8.82844 21.7219H21.8864C24.1513 21.7219 25.2837 21.7219 26.1492 21.2811C26.9097 20.8931 27.5278 20.2744 27.9152 19.5137C28.3574 18.6482 28.3574 17.5158 28.3574 15.2509V7.97102C28.3574 5.70616 28.3574 4.57373 27.9166 3.70823C27.5288 2.94727 26.9102 2.32858 26.1492 1.94084C25.2837 1.5 24.1513 1.5 21.8864 1.5H8.82844C6.56358 1.5 5.43115 1.5 4.56566 1.94084C3.80519 2.32881 3.187 2.94747 2.79961 3.70823C2.35742 4.57373 2.35742 5.70616 2.35742 7.97102V15.2509C2.35742 17.5158 2.35742 18.6482 2.79826 19.5137C3.186 20.2747 3.80469 20.8933 4.56566 21.2811C5.43115 21.7219 6.56358 21.7219 8.82844 21.7219Z'
stroke='currentColor'
strokeWidth='2.5'
strokeLinecap='round'
strokeLinejoin='round'
/>
</svg>`}
/>
{/* MANUAL-CONTENT-START:intro */}
The Mail block allows you to send emails directly from your workflows using Sim's own mail sending infrastructure powered by [Resend](https://resend.com/). This integration enables you to programmatically deliver notifications, alerts, and other important information to users' email addresses without requiring any external configuration or OAuth.
Our internal mail service is designed for reliability and ease of use, making it ideal for automating communications and ensuring your messages reach recipients efficiently.
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Send emails directly using the internal mail service. Uses MAIL_BLOCK_FROM_ADDRESS if configured, otherwise falls back to FROM_EMAIL_ADDRESS. No external configuration or OAuth required. Perfect for sending notifications, alerts, or general purpose emails from your workflows. Supports HTML formatting.
## Tools
### `mail_send`
Send an email using the internal mail service without requiring OAuth or external configuration
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `to` | string | Yes | Recipient email address |
| `subject` | string | Yes | Email subject |
| `body` | string | Yes | Email body content |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Whether the email was sent successfully |
| `to` | string | Recipient email address |
| `subject` | string | Email subject |
| `body` | string | Email body content |
## Notes
- Category: `tools`
- Type: `mail`

View File

@@ -0,0 +1,40 @@
---
title: MCP Tool
description: Execute tools from Model Context Protocol (MCP) servers
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="mcp"
color="#181C1E"
icon={true}
iconSvg={`<svg className="block-icon"
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
>
<rect x='2' y='2' rx='2' ry='2' />
<rect x='2' y='14' rx='2' ry='2' />
<line x1='6' x2='6.01' y1='6' y2='6' />
<line x1='6' x2='6.01' y1='18' y2='18' />
</svg>`}
/>
## Usage Instructions
Integrate MCP into the workflow. Can execute tools from MCP servers. Requires MCP servers in workspace settings.
## Notes
- Category: `tools`
- Type: `mcp`

View File

@@ -44,7 +44,7 @@ In Sim, the Mem0 integration enables your agents to maintain persistent memory a
## Usage Instructions
Add, search, retrieve, and delete memories using Mem0. Store conversation history, user preferences, and context across workflow executions for enhanced AI agent capabilities.
Integrate Mem0 into the workflow. Can add, search, and retrieve memories. Requires API Key.

View File

@@ -35,7 +35,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
## Usage Instructions
Create persistent storage for data that needs to be accessed across multiple workflow steps. Store and retrieve information throughout your workflow execution to maintain context and state.
Integrate Memory into the workflow. Can add, get a memory, get all memories, and delete memories.

View File

@@ -0,0 +1,73 @@
{
"items": [
"index",
"airtable",
"arxiv",
"browser_use",
"clay",
"confluence",
"discord",
"elevenlabs",
"exa",
"file",
"firecrawl",
"generic_webhook",
"github",
"gmail",
"google_calendar",
"google_docs",
"google_drive",
"google_search",
"google_sheets",
"huggingface",
"hunter",
"image_generator",
"jina",
"jira",
"knowledge",
"linear",
"linkup",
"mail",
"mcp",
"mem0",
"memory",
"microsoft_excel",
"microsoft_planner",
"microsoft_teams",
"mistral_parse",
"mongodb",
"mysql",
"notion",
"onedrive",
"openai",
"outlook",
"parallel_ai",
"perplexity",
"pinecone",
"postgresql",
"qdrant",
"reddit",
"s3",
"schedule",
"serper",
"sharepoint",
"slack",
"sms",
"stagehand",
"stagehand_agent",
"supabase",
"tavily",
"telegram",
"thinking",
"translate",
"twilio_sms",
"typeform",
"vision",
"wealthbox",
"webhook",
"whatsapp",
"wikipedia",
"x",
"youtube"
]
}

View File

@@ -94,7 +94,7 @@ In Sim, the Microsoft Excel integration provides seamless access to spreadsheet
## Usage Instructions
Integrate Microsoft Excel functionality to manage spreadsheet data. Read data from specific ranges, write new data, update existing cells, and manipulate table data using OAuth authentication. Supports various input and output formats for flexible data handling.
Integrate Microsoft Excel into the workflow. Can read, write, update, and add to table. Requires OAuth.

View File

@@ -122,7 +122,7 @@ In Sim, the Microsoft Planner integration allows your agents to programmatically
## Usage Instructions
Integrate Microsoft Planner functionality to manage tasks. Read all user tasks, tasks from specific plans, individual tasks, or create new tasks with various properties like title, description, due date, and assignees using OAuth authentication.
Integrate Microsoft Planner into the workflow. Can read and create tasks. Requires OAuth.

View File

@@ -98,7 +98,7 @@ In Sim, the Microsoft Teams integration enables your agents to interact directly
## Usage Instructions
Integrate Microsoft Teams functionality to manage messages. Read content from existing messages and write to messages using OAuth authentication. Supports text content manipulation for message creation and editing.
Integrate Microsoft Teams into the workflow. Can read and write chat messages, and read and write channel messages. Requires OAuth. Can be used in trigger mode to trigger a workflow when a message is sent to a chat or channel.

View File

@@ -79,7 +79,7 @@ The Mistral Parse tool is particularly useful for scenarios where your agents ne
## Usage Instructions
Extract text and structure from PDF documents using Mistral's OCR API. Either enter a URL to a PDF document or upload a PDF file directly. Configure processing options and get the content in your preferred format. For URLs, they must be publicly accessible and point to a valid PDF file. Note: Google Drive, Dropbox, and other cloud storage links are not supported; use a direct download URL from a web server instead.
Integrate Mistral Parse into the workflow. Can extract text from uploaded PDF documents, or from a URL. Requires API Key.

View File

@@ -111,7 +111,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
## Usage Instructions
Connect to any MongoDB database to execute queries, manage data, and perform database operations. Supports find, insert, update, delete, and aggregation operations with secure connection handling.
Integrate MongoDB into the workflow. Can find, insert, update, delete, and aggregate data.

View File

@@ -42,7 +42,7 @@ The MySQL tool is ideal for scenarios where your agents need to interact with st
## Usage Instructions
Connect to any MySQL database to execute queries, manage data, and perform database operations. Supports SELECT, INSERT, UPDATE, DELETE operations with secure connection handling.
Integrate MySQL into the workflow. Can query, insert, update, delete, and execute raw SQL.

View File

@@ -45,7 +45,7 @@ This integration bridges the gap between your AI workflows and your knowledge ba
## Usage Instructions
Integrate with Notion to read content from pages, write new content, and create new pages.
Integrate with Notion into the workflow. Can read page, read database, create page, create database, append content, query database, and search workspace. Requires OAuth.

View File

@@ -51,7 +51,7 @@ In Sim, the OneDrive integration enables your agents to directly interact with y
## Usage Instructions
Integrate OneDrive functionality to manage files and folders. Upload new files, create new folders, and list contents of folders using OAuth authentication. Supports file operations with custom MIME types and folder organization.
Integrate OneDrive into the workflow. Can create, upload, and list files. Requires OAuth.

View File

@@ -43,7 +43,7 @@ In Sim, the OpenAI integration enables your agents to leverage these powerful AI
## Usage Instructions
Convert text into numerical vector representations using OpenAI's embedding models. Transform text data into embeddings for semantic search, clustering, and other vector-based operations.
Integrate Embeddings into the workflow. Can generate embeddings from text. Requires API Key.

View File

@@ -140,7 +140,7 @@ In Sim, the Microsoft Outlook integration enables your agents to interact direct
## Usage Instructions
Integrate Outlook functionality to read, draft, andsend email messages within your workflow. Automate email communications and process email content using OAuth authentication.
Integrate Outlook into the workflow. Can read, draft, and send email messages. Requires OAuth. Can be used in trigger mode to trigger a workflow when a new email is received.

View File

@@ -71,7 +71,7 @@ In Sim, the Parallel AI integration empowers your agents to perform web searches
## Usage Instructions
Search the web using Parallel AI's advanced search capabilities. Get comprehensive results with intelligent processing and content extraction.
Integrate Parallel AI into the workflow. Can search the web. Requires API Key.

View File

@@ -37,7 +37,7 @@ In Sim, the Perplexity integration enables your agents to leverage these powerfu
## Usage Instructions
Generate completions using Perplexity AI models with real-time knowledge and search capabilities. Create responses, answer questions, and generate content with customizable parameters.
Integrate Perplexity into the workflow. Can generate completions using Perplexity AI chat models. Requires API Key.

View File

@@ -45,7 +45,7 @@ In Sim, the Pinecone integration enables your agents to leverage vector search c
## Usage Instructions
Store, search, and retrieve vector embeddings using Pinecone's specialized vector database. Generate embeddings from text and perform semantic similarity searches with customizable filtering options.
Integrate Pinecone into the workflow. Can generate embeddings, upsert text, search with text, fetch vectors, and search with vectors. Requires API Key.

View File

@@ -50,7 +50,7 @@ The PostgreSQL tool is ideal for scenarios where your agents need to interact wi
## Usage Instructions
Connect to any PostgreSQL database to execute queries, manage data, and perform database operations. Supports SELECT, INSERT, UPDATE, DELETE operations with secure connection handling.
Integrate PostgreSQL into the workflow. Can query, insert, update, delete, and execute raw SQL.

View File

@@ -103,7 +103,7 @@ This integration allows your agents to leverage powerful vector search and manag
## Usage Instructions
Store, search, and retrieve vector embeddings using Qdrant. Perform semantic similarity searches and manage your vector collections.
Integrate Qdrant into the workflow. Can upsert, search, and fetch points. Requires API Key.

View File

@@ -39,7 +39,7 @@ These operations let your agents access and analyze Reddit content as part of yo
## Usage Instructions
Access Reddit data to retrieve posts and comments from any subreddit. Get post titles, content, authors, scores, comments and more.
Integrate Reddit into the workflow. Can get posts and comments from a subreddit. Requires OAuth.

View File

@@ -62,7 +62,7 @@ In Sim, the S3 integration enables your agents to retrieve and access files stor
## Usage Instructions
Retrieve and view files from Amazon S3 buckets using presigned URLs.
Integrate S3 into the workflow. Can get presigned URLs for S3 objects. Requires access key and secret access key.

View File

@@ -30,7 +30,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
## Usage Instructions
Configure automated workflow execution with flexible timing options. Set up recurring workflows that run at specific intervals or times.
Integrate Schedule into the workflow. Can trigger a workflow on a schedule configuration.

View File

@@ -78,7 +78,7 @@ In Sim, the Serper integration enables your agents to leverage the power of web
## Usage Instructions
Access real-time web search results with Serper's Google Search API integration. Retrieve structured search data including web pages, news, images, and places with customizable language and region settings.
Integrate Serper into the workflow. Can search the web. Requires API Key.

View File

@@ -61,7 +61,7 @@ In Sim, the SharePoint integration empowers your agents to create and access Sha
## Usage Instructions
Integrate Sharepoint functionality to manage pages. Read and create pages, and list sites using OAuth authentication. Supports page operations with custom MIME types and folder organization.
Integrate Sharepoint into the workflow. Can read and create pages, and list sites. Requires OAuth.

View File

@@ -64,7 +64,7 @@ This allows for powerful automation scenarios such as sending notifications, ale
## Usage Instructions
Comprehensive Slack integration with OAuth authentication. Send formatted messages using Slack's mrkdwn syntax or trigger workflows from Slack events like mentions and messages.
Integrate Slack into the workflow. Can send messages, create canvases, and read messages. Requires OAuth. Can be used in trigger mode to trigger a workflow when a message is sent to a channel.

View File

@@ -0,0 +1,53 @@
---
title: SMS
description: Send SMS messages using the internal SMS service
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="sms"
color="#E0E0E0"
icon={true}
iconSvg={`<svg className="block-icon" fill="#000000" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg"><path d="M 2 5 L 2 25 L 7 25 L 7 30.09375 L 8.625 28.78125 L 13.34375 25 L 30 25 L 30 5 Z M 4 7 L 28 7 L 28 23 L 12.65625 23 L 12.375 23.21875 L 9 25.90625 L 9 23 L 4 23 Z M 8 12 L 8 14 L 24 14 L 24 12 Z M 8 16 L 8 18 L 20 18 L 20 16 Z"/></svg>`}
/>
{/* MANUAL-CONTENT-START:intro */}
The SMS block allows you to send text messages directly from your workflows using Sim's own SMS sending infrastructure powered by Twilio. This integration enables you to programmatically deliver notifications, alerts, and other important information to users' mobile devices without requiring any external configuration or OAuth.
Our internal SMS service is designed for reliability and ease of use, making it ideal for automating communications and ensuring your messages reach recipients efficiently.
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Send SMS messages directly using the internal SMS service powered by Twilio. No external configuration or OAuth required. Perfect for sending notifications, alerts, or general purpose text messages from your workflows. Requires valid phone numbers with country codes.
## Tools
### `sms_send`
Send an SMS message using the internal SMS service powered by Twilio
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `to` | string | Yes | Recipient phone number \(include country code, e.g., +1234567890\) |
| `body` | string | Yes | SMS message content |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Whether the SMS was sent successfully |
| `to` | string | Recipient phone number |
| `body` | string | SMS message content |
## Notes
- Category: `tools`
- Type: `sms`

View File

@@ -191,7 +191,7 @@ In Sim, the Stagehand integration enables your agents to extract structured data
## Usage Instructions
Use Stagehand to extract structured data from webpages using Browserbase and OpenAI.
Integrate Stagehand into the workflow. Can extract structured data from webpages. Requires API Key.

View File

@@ -195,7 +195,7 @@ In Sim, the Stagehand integration enables your agents to seamlessly interact wit
## Usage Instructions
Use Stagehand to create an autonomous web browsing agent that can navigate across websites, perform tasks, and return structured data.
Integrate Stagehand Agent into the workflow. Can navigate the web and perform tasks. Requires API Key.

View File

@@ -76,7 +76,7 @@ Whether youre building internal tools, automating business processes, or powe
## Usage Instructions
Integrate with Supabase to manage your database, authentication, storage, and more. Query data, manage users, and interact with Supabase services directly.
Integrate Supabase into the workflow. Can get many rows, get, create, update, delete, and upsert a row.

View File

@@ -58,7 +58,7 @@ In Sim, the Tavily integration enables your agents to search the web and extract
## Usage Instructions
Access Tavily's AI-powered search engine to find relevant information from across the web. Extract and process content from specific URLs with customizable depth options.
Integrate Tavily into the workflow. Can search the web and extract content from specific URLs. Requires API Key.

View File

@@ -67,7 +67,7 @@ In Sim, the Telegram integration enables your agents to leverage these powerful
## Usage Instructions
Send messages to any Telegram channel using your Bot API key or trigger workflows from Telegram bot messages. Integrate automated notifications and alerts into your workflow to keep your team informed.
Integrate Telegram into the workflow. Can send messages. Can be used in trigger mode to trigger a workflow when a message is sent to a chat.

View File

@@ -48,7 +48,7 @@ With Translate, you can:
## Usage Instructions
Convert text between languages while preserving meaning, nuance, and formatting. Utilize powerful language models to produce natural, fluent translations with appropriate cultural adaptations.
Integrate Translate into the workflow. Can translate text to any language.

View File

@@ -34,7 +34,7 @@ In Sim, the Twilio SMS integration enables your agents to leverage these powerfu
## Usage Instructions
Send text messages to single or multiple recipients using the Twilio API.
Integrate Twilio into the workflow. Can send SMS messages.

View File

@@ -47,7 +47,7 @@ In Sim, the Typeform integration enables your agents to programmatically interac
## Usage Instructions
Access and retrieve responses from your Typeform forms. Integrate form submissions data into your workflow for analysis, storage, or processing.
Integrate Typeform into the workflow. Can retrieve responses, download files, and get form insights. Requires API Key.

View File

@@ -47,7 +47,7 @@ In Sim, the Vision integration enables your agents to analyze images with vision
## Usage Instructions
Process visual content with customizable prompts to extract insights and information from images.
Integrate Vision into the workflow. Can analyze images with vision models. Requires API Key.

View File

@@ -42,7 +42,7 @@ In Sim, the Wealthbox integration enables your agents to seamlessly interact wit
## Usage Instructions
Integrate Wealthbox functionality to manage notes, contacts, and tasks. Read content from existing notes, contacts, and tasks and write to them using OAuth authentication. Supports text content manipulation for note creation and editing.
Integrate Wealthbox into the workflow. Can read and write notes, read and write contacts, and read and write tasks. Requires OAuth.

View File

@@ -37,7 +37,7 @@ In Sim, the WhatsApp integration enables your agents to leverage these messaging
## Usage Instructions
Send messages to WhatsApp users using the WhatsApp Business API. Requires WhatsApp Business API configuration.
Integrate WhatsApp into the workflow. Can send messages.

View File

@@ -54,7 +54,7 @@ In Sim, the Wikipedia integration enables your agents to programmatically access
## Usage Instructions
Access Wikipedia articles, search for pages, get summaries, retrieve full content, and discover random articles from the world's largest encyclopedia.
Integrate Wikipedia into the workflow. Can get page summary, search pages, get page content, and get random page.

View File

@@ -13,6 +13,8 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
<path
d='M 5.9199219 6 L 20.582031 27.375 L 6.2304688 44 L 9.4101562 44 L 21.986328 29.421875 L 31.986328 44 L 44 44 L 28.681641 21.669922 L 42.199219 6 L 39.029297 6 L 27.275391 19.617188 L 17.933594 6 L 5.9199219 6 z M 9.7167969 8 L 16.880859 8 L 40.203125 42 L 33.039062 42 L 9.7167969 8 z'
fill='currentColor'
stroke='currentColor'
strokeWidth='0.5'
/>
</svg>`}
/>
@@ -36,7 +38,7 @@ In Sim, the X integration enables sophisticated social media automation scenario
## Usage Instructions
Connect with X to post tweets, read content, search for information, and access user profiles. Integrate social media capabilities into your workflow with comprehensive X platform access.
Integrate X into the workflow. Can post a new tweet, get tweet details, search tweets, and get user profile. Requires OAuth.

View File

@@ -40,7 +40,7 @@ In Sim, the YouTube integration enables your agents to programmatically search a
## Usage Instructions
Find relevant videos on YouTube using the YouTube Data API. Search for content with customizable result limits and retrieve structured video metadata for integration into your workflow.
Integrate YouTube into the workflow. Can search for videos. Requires API Key.

View File

@@ -19,7 +19,7 @@
"fumadocs-mdx": "^11.5.6",
"fumadocs-ui": "^15.7.5",
"lucide-react": "^0.511.0",
"next": "^15.3.2",
"next": "15.4.1",
"next-themes": "^0.4.6",
"react": "19.1.0",
"react-dom": "19.1.0",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 443 B

After

Width:  |  Height:  |  Size: 521 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 897 B

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 2.1 MiB

View File

@@ -1,16 +1,18 @@
{
"name": "Sim",
"short_name": "Sim",
"name": "MyWebSite",
"short_name": "MySite",
"icons": [
{
"src": "/favicon/android-chrome-192x192.png",
"src": "/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png"
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/favicon/android-chrome-512x512.png",
"src": "/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png"
"type": "image/png",
"purpose": "maskable"
}
],
"theme_color": "#ffffff",

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View File

@@ -0,0 +1,44 @@
export default function AuthBackgroundSVG() {
return (
<svg
aria-hidden='true'
className='pointer-events-none fixed inset-0 h-full w-full'
style={{ zIndex: 5 }}
viewBox='0 0 1880 960'
fill='none'
xmlns='http://www.w3.org/2000/svg'
preserveAspectRatio='xMidYMid slice'
>
{/* Right side paths - extended to connect */}
<path
d='M1393.53 42.8889C1545.99 173.087 1688.28 339.75 1878.44 817.6'
stroke='#E7E4EF'
strokeWidth='2'
/>
<path d='M1624.21 960L1625.78 0' stroke='#E7E4EF' strokeWidth='2' />
<path d='M1832.67 715.81L1880 716.031' stroke='#E7E4EF' strokeWidth='2' />
<path d='M1393.4 40V0' stroke='#E7E4EF' strokeWidth='2' />
<circle cx='1393.03' cy='40.0186' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
<circle cx='1625.28' cy='303.147' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
<circle cx='1837.37' cy='715.81' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
{/* Left side paths - extended to connect */}
<path
d='M160 157.764C319.811 136.451 417.278 102.619 552.39 0'
stroke='#E7E4EF'
strokeWidth='2'
/>
<path d='M310.22 803.025V0' stroke='#E7E4EF' strokeWidth='2' />
<path
d='M160 530.184C256.142 655.353 308.338 749.141 348.382 960'
stroke='#E7E4EF'
strokeWidth='2'
/>
<path d='M160 157.764V960' stroke='#E7E4EF' strokeWidth='2' />
<path d='M-50 157.764L160 157.764' stroke='#E7E4EF' strokeWidth='2' />
<circle cx='160' cy='157.764' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
<circle cx='310.22' cy='803.025' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
<circle cx='160' cy='530.184' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
</svg>
)
}

View File

@@ -0,0 +1,16 @@
import { cn } from '@/lib/utils'
import AuthBackgroundSVG from './auth-background-svg'
type AuthBackgroundProps = {
className?: string
children?: React.ReactNode
}
export default function AuthBackground({ className, children }: AuthBackgroundProps) {
return (
<div className={cn('relative min-h-screen w-full overflow-hidden', className)}>
<AuthBackgroundSVG />
<div className='relative z-20'>{children}</div>
</div>
)
}

View File

@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'
import { GithubIcon, GoogleIcon } from '@/components/icons'
import { Button } from '@/components/ui/button'
import { client } from '@/lib/auth-client'
import { inter } from '@/app/fonts/inter'
interface SocialLoginButtonsProps {
githubAvailable: boolean
@@ -92,24 +93,24 @@ export function SocialLoginButtons({
const githubButton = (
<Button
variant='outline'
className='w-full border-neutral-700 bg-neutral-900 text-white hover:bg-neutral-800 hover:text-white'
className='w-full rounded-[10px] shadow-sm hover:bg-gray-50'
disabled={!githubAvailable || isGithubLoading}
onClick={signInWithGithub}
>
<GithubIcon className='mr-2 h-4 w-4' />
{isGithubLoading ? 'Connecting...' : 'Continue with GitHub'}
<GithubIcon className='!h-[18px] !w-[18px] mr-1' />
{isGithubLoading ? 'Connecting...' : 'GitHub'}
</Button>
)
const googleButton = (
<Button
variant='outline'
className='w-full border-neutral-700 bg-neutral-900 text-white hover:bg-neutral-800 hover:text-white'
className='w-full rounded-[10px] shadow-sm hover:bg-gray-50'
disabled={!googleAvailable || isGoogleLoading}
onClick={signInWithGoogle}
>
<GoogleIcon className='mr-2 h-4 w-4' />
{isGoogleLoading ? 'Connecting...' : 'Continue with Google'}
<GoogleIcon className='!h-[18px] !w-[18px] mr-1' />
{isGoogleLoading ? 'Connecting...' : 'Google'}
</Button>
)
@@ -120,9 +121,9 @@ export function SocialLoginButtons({
}
return (
<div className='grid gap-3'>
{githubAvailable && githubButton}
<div className={`${inter.className} grid gap-3 font-light`}>
{googleAvailable && googleButton}
{githubAvailable && githubButton}
</div>
)
}

View File

@@ -1,48 +1,42 @@
'use client'
import Image from 'next/image'
import Link from 'next/link'
import { useBrandConfig } from '@/lib/branding/branding'
import { GridPattern } from '@/app/(landing)/components/grid-pattern'
import { useEffect } from 'react'
import Nav from '@/app/(landing)/components/nav/nav'
import AuthBackground from './components/auth-background'
// Helper to detect if a color is dark
function isColorDark(hexColor: string): boolean {
const hex = hexColor.replace('#', '')
const r = Number.parseInt(hex.substr(0, 2), 16)
const g = Number.parseInt(hex.substr(2, 2), 16)
const b = Number.parseInt(hex.substr(4, 2), 16)
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
return luminance < 0.5
}
export default function AuthLayout({ children }: { children: React.ReactNode }) {
const brand = useBrandConfig()
useEffect(() => {
// Check if brand background is dark and add class accordingly
const rootStyle = getComputedStyle(document.documentElement)
const brandBackground = rootStyle.getPropertyValue('--brand-background-hex').trim()
if (brandBackground && isColorDark(brandBackground)) {
document.body.classList.add('auth-dark-bg')
} else {
document.body.classList.remove('auth-dark-bg')
}
}, [])
return (
<main className='relative flex min-h-screen flex-col bg-[var(--brand-background-hex)] font-geist-sans text-white'>
{/* Background pattern */}
<GridPattern
x={-5}
y={-5}
className='absolute inset-0 z-0 stroke-[#ababab]/5'
width={90}
height={90}
aria-hidden='true'
/>
<AuthBackground>
<main className='relative flex min-h-screen flex-col font-geist-sans text-foreground'>
{/* Header - Nav handles all conditional logic */}
<Nav hideAuthButtons={true} variant='auth' />
{/* Header */}
<div className='relative z-10 px-6 pt-9'>
<div className='mx-auto max-w-7xl'>
<Link href='/' className='inline-flex'>
{brand.logoUrl ? (
<img
src={brand.logoUrl}
alt={`${brand.name} Logo`}
width={56}
height={56}
className='h-[56px] w-[56px] object-contain'
/>
) : (
<Image src='/sim.svg' alt={`${brand.name} Logo`} width={56} height={56} />
)}
</Link>
{/* Content */}
<div className='relative z-30 flex flex-1 items-center justify-center px-4 pb-24'>
<div className='w-full max-w-lg px-4'>{children}</div>
</div>
</div>
{/* Content */}
<div className='relative z-10 flex flex-1 items-center justify-center px-4 pb-6'>
<div className='w-full max-w-md'>{children}</div>
</div>
</main>
</main>
</AuthBackground>
)
}

View File

@@ -1,258 +0,0 @@
/**
* @vitest-environment jsdom
*/
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { useRouter, useSearchParams } from 'next/navigation'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { client } from '@/lib/auth-client'
import LoginPage from '@/app/(auth)/login/login-form'
vi.mock('next/navigation', () => ({
useRouter: vi.fn(),
useSearchParams: vi.fn(),
}))
vi.mock('@/lib/auth-client', () => ({
client: {
signIn: {
email: vi.fn(),
},
emailOtp: {
sendVerificationOtp: vi.fn(),
},
},
}))
vi.mock('@/app/(auth)/components/social-login-buttons', () => ({
SocialLoginButtons: () => <div data-testid='social-login-buttons'>Social Login Buttons</div>,
}))
const mockRouter = {
push: vi.fn(),
replace: vi.fn(),
back: vi.fn(),
}
const mockSearchParams = {
get: vi.fn(),
}
describe('LoginPage', () => {
beforeEach(() => {
vi.clearAllMocks()
;(useRouter as any).mockReturnValue(mockRouter)
;(useSearchParams as any).mockReturnValue(mockSearchParams)
mockSearchParams.get.mockReturnValue(null)
})
const defaultProps = {
githubAvailable: true,
googleAvailable: true,
isProduction: false,
}
describe('Basic Rendering', () => {
it('should render login form with all required elements', () => {
render(<LoginPage {...defaultProps} />)
expect(screen.getByPlaceholderText(/enter your email/i)).toBeInTheDocument()
expect(screen.getByPlaceholderText(/enter your password/i)).toBeInTheDocument()
expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument()
expect(screen.getByText(/forgot password/i)).toBeInTheDocument()
expect(screen.getByText(/sign up/i)).toBeInTheDocument()
})
it('should render social login buttons', () => {
render(<LoginPage {...defaultProps} />)
expect(screen.getByTestId('social-login-buttons')).toBeInTheDocument()
})
})
describe('Password Visibility Toggle', () => {
it('should toggle password visibility when button is clicked', () => {
render(<LoginPage {...defaultProps} />)
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
const toggleButton = screen.getByLabelText(/show password/i)
expect(passwordInput).toHaveAttribute('type', 'password')
fireEvent.click(toggleButton)
expect(passwordInput).toHaveAttribute('type', 'text')
fireEvent.click(toggleButton)
expect(passwordInput).toHaveAttribute('type', 'password')
})
})
describe('Form Interaction', () => {
it('should allow users to type in form fields', () => {
render(<LoginPage {...defaultProps} />)
const emailInput = screen.getByPlaceholderText(/enter your email/i)
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
fireEvent.change(emailInput, { target: { value: 'user@company.com' } })
fireEvent.change(passwordInput, { target: { value: 'password123' } })
expect(emailInput).toHaveValue('user@company.com')
expect(passwordInput).toHaveValue('password123')
})
it('should show loading state during form submission', async () => {
const mockSignIn = vi.mocked(client.signIn.email)
mockSignIn.mockImplementation(
() =>
new Promise((resolve) =>
setTimeout(() => resolve({ data: { user: { id: '1' } }, error: null }), 100)
)
)
render(<LoginPage {...defaultProps} />)
const emailInput = screen.getByPlaceholderText(/enter your email/i)
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
const submitButton = screen.getByRole('button', { name: /sign in/i })
await act(async () => {
fireEvent.change(emailInput, { target: { value: 'user@company.com' } })
fireEvent.change(passwordInput, { target: { value: 'password123' } })
fireEvent.click(submitButton)
})
await waitFor(() => {
expect(screen.getByText('Signing in...')).toBeInTheDocument()
expect(submitButton).toBeDisabled()
})
})
})
describe('Form Submission', () => {
it('should call signIn with correct credentials', async () => {
const mockSignIn = vi.mocked(client.signIn.email)
mockSignIn.mockResolvedValue({ data: { user: { id: '1' } }, error: null })
render(<LoginPage {...defaultProps} />)
const emailInput = screen.getByPlaceholderText(/enter your email/i)
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
const submitButton = screen.getByRole('button', { name: /sign in/i })
fireEvent.change(emailInput, { target: { value: 'user@company.com' } })
fireEvent.change(passwordInput, { target: { value: 'password123' } })
fireEvent.click(submitButton)
await waitFor(() => {
expect(mockSignIn).toHaveBeenCalledWith(
{
email: 'user@company.com',
password: 'password123',
callbackURL: '/workspace',
},
expect.objectContaining({
onError: expect.any(Function),
})
)
})
})
it('should handle authentication errors', async () => {
const mockSignIn = vi.mocked(client.signIn.email)
mockSignIn.mockImplementation((credentials, options) => {
if (options?.onError) {
options.onError({
error: {
code: 'INVALID_CREDENTIALS',
message: 'Invalid credentials',
} as any,
response: {} as any,
request: {} as any,
} as any)
}
return Promise.resolve({ data: null, error: 'Invalid credentials' })
})
render(<LoginPage {...defaultProps} />)
const emailInput = screen.getByPlaceholderText(/enter your email/i)
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
const submitButton = screen.getByRole('button', { name: /sign in/i })
fireEvent.change(emailInput, { target: { value: 'user@company.com' } })
fireEvent.change(passwordInput, { target: { value: 'wrongpassword' } })
fireEvent.click(submitButton)
await waitFor(() => {
expect(screen.getByText('Invalid email or password')).toBeInTheDocument()
})
})
})
describe('Forgot Password', () => {
it('should open forgot password dialog', () => {
render(<LoginPage {...defaultProps} />)
const forgotPasswordButton = screen.getByText(/forgot password/i)
fireEvent.click(forgotPasswordButton)
expect(screen.getByText('Reset Password')).toBeInTheDocument()
})
})
describe('URL Parameters', () => {
it('should handle invite flow parameter in signup link', () => {
mockSearchParams.get.mockImplementation((param) => {
if (param === 'invite_flow') return 'true'
if (param === 'callbackUrl') return '/invite/123'
return null
})
render(<LoginPage {...defaultProps} />)
const signupLink = screen.getByText(/sign up/i)
expect(signupLink).toHaveAttribute('href', '/signup?invite_flow=true&callbackUrl=/invite/123')
})
it('should default to regular signup link when no invite flow', () => {
render(<LoginPage {...defaultProps} />)
const signupLink = screen.getByText(/sign up/i)
expect(signupLink).toHaveAttribute('href', '/signup')
})
})
describe('Email Verification Flow', () => {
it('should redirect to verification page when email not verified', async () => {
const mockSignIn = vi.mocked(client.signIn.email)
const mockSendOtp = vi.mocked(client.emailOtp.sendVerificationOtp)
mockSignIn.mockRejectedValue({
message: 'Email not verified',
code: 'EMAIL_NOT_VERIFIED',
})
mockSendOtp.mockResolvedValue({ data: null, error: null })
render(<LoginPage {...defaultProps} />)
const emailInput = screen.getByPlaceholderText(/enter your email/i)
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
const submitButton = screen.getByRole('button', { name: /sign in/i })
fireEvent.change(emailInput, { target: { value: 'user@company.com' } })
fireEvent.change(passwordInput, { target: { value: 'password123' } })
fireEvent.click(submitButton)
await waitFor(() => {
expect(mockSendOtp).toHaveBeenCalledWith({
email: 'user@company.com',
type: 'email-verification',
})
expect(mockRouter.push).toHaveBeenCalledWith('/verify')
})
})
})
})

View File

@@ -19,6 +19,8 @@ import { quickValidateEmail } from '@/lib/email/validation'
import { createLogger } from '@/lib/logs/console/logger'
import { cn } from '@/lib/utils'
import { SocialLoginButtons } from '@/app/(auth)/components/social-login-buttons'
import { inter } from '@/app/fonts/inter'
import { soehne } from '@/app/fonts/soehne/soehne'
const logger = createLogger('LoginForm')
@@ -100,6 +102,7 @@ export default function LoginPage({
const [password, setPassword] = useState('')
const [passwordErrors, setPasswordErrors] = useState<string[]>([])
const [showValidationError, setShowValidationError] = useState(false)
const [buttonClass, setButtonClass] = useState('auth-button-gradient')
// Initialize state for URL parameters
const [callbackUrl, setCallbackUrl] = useState('/workspace')
@@ -139,6 +142,34 @@ export default function LoginPage({
const inviteFlow = searchParams.get('invite_flow') === 'true'
setIsInviteFlow(inviteFlow)
}
// Check if CSS variable has been customized
const checkCustomBrand = () => {
const computedStyle = getComputedStyle(document.documentElement)
const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim()
// Check if the CSS variable exists and is different from the default
if (brandAccent && brandAccent !== '#6f3dfa') {
setButtonClass('auth-button-custom')
} else {
setButtonClass('auth-button-gradient')
}
}
checkCustomBrand()
// Also check on window resize or theme changes
window.addEventListener('resize', checkCustomBrand)
const observer = new MutationObserver(checkCustomBrand)
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['style', 'class'],
})
return () => {
window.removeEventListener('resize', checkCustomBrand)
observer.disconnect()
}
}, [searchParams])
useEffect(() => {
@@ -370,167 +401,172 @@ export default function LoginPage({
}
return (
<div className='space-y-6'>
<div className='space-y-2 text-center'>
<h1 className='font-semibold text-[32px] text-white tracking-tight'>Sign In</h1>
<p className='text-neutral-400 text-sm'>
Enter your email below to sign in to your account
<>
<div className='space-y-1 text-center'>
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
Sign in
</h1>
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
Enter your details
</p>
</div>
<div className='flex flex-col gap-6'>
<div className='rounded-xl border border-neutral-700/40 bg-neutral-800/50 p-6 backdrop-blur-sm'>
<SocialLoginButtons
googleAvailable={googleAvailable}
githubAvailable={githubAvailable}
isProduction={isProduction}
callbackURL={callbackUrl}
/>
{(githubAvailable || googleAvailable) && (
<div className='relative mt-2 py-4'>
<div className='absolute inset-0 flex items-center'>
<div className='w-full border-neutral-700/50 border-t' />
</div>
<form onSubmit={onSubmit} className={`${inter.className} mt-8 space-y-8`}>
<div className='space-y-6'>
<div className='space-y-2'>
<div className='flex items-center justify-between'>
<Label htmlFor='email'>Email</Label>
</div>
)}
<form onSubmit={onSubmit} className='space-y-5'>
<div className='space-y-4'>
<div className='space-y-2'>
<Label htmlFor='email' className='text-neutral-300'>
Email
</Label>
<Input
id='email'
name='email'
placeholder='Enter your email'
required
autoCapitalize='none'
autoComplete='email'
autoCorrect='off'
value={email}
onChange={handleEmailChange}
className={cn(
'border-neutral-700 bg-neutral-900 text-white placeholder:text-white/60',
showEmailValidationError &&
emailErrors.length > 0 &&
'border-red-500 focus-visible:ring-red-500'
)}
/>
{showEmailValidationError && emailErrors.length > 0 && (
<div className='mt-1 space-y-1 text-red-400 text-xs'>
{emailErrors.map((error, index) => (
<p key={index}>{error}</p>
))}
</div>
)}
</div>
<div className='space-y-2'>
<div className='flex items-center justify-between'>
<Label htmlFor='password' className='text-neutral-300'>
Password
</Label>
<button
type='button'
onClick={() => setForgotPasswordOpen(true)}
className='font-medium text-neutral-400 text-xs transition hover:text-white'
>
Forgot password?
</button>
</div>
<div className='relative'>
<Input
id='password'
name='password'
required
type={showPassword ? 'text' : 'password'}
autoCapitalize='none'
autoComplete='current-password'
autoCorrect='off'
placeholder='Enter your password'
value={password}
onChange={handlePasswordChange}
className={cn(
'border-neutral-700 bg-neutral-900 pr-10 text-white placeholder:text-white/60',
showValidationError &&
passwordErrors.length > 0 &&
'border-red-500 focus-visible:ring-red-500'
)}
/>
<button
type='button'
onClick={() => setShowPassword(!showPassword)}
className='-translate-y-1/2 absolute top-1/2 right-3 text-neutral-400 transition hover:text-white'
aria-label={showPassword ? 'Hide password' : 'Show password'}
>
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
{showValidationError && passwordErrors.length > 0 && (
<div className='mt-1 space-y-1 text-red-400 text-xs'>
{passwordErrors.map((error, index) => (
<p key={index}>{error}</p>
))}
</div>
)}
<Input
id='email'
name='email'
placeholder='Enter your email'
required
autoCapitalize='none'
autoComplete='email'
autoCorrect='off'
value={email}
onChange={handleEmailChange}
className={cn(
'rounded-[10px] shadow-sm transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100',
showEmailValidationError &&
emailErrors.length > 0 &&
'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500'
)}
/>
{showEmailValidationError && emailErrors.length > 0 && (
<div className='mt-1 space-y-1 text-red-400 text-xs'>
{emailErrors.map((error, index) => (
<p key={index}>{error}</p>
))}
</div>
)}
</div>
<div className='space-y-2'>
<div className='flex items-center justify-between'>
<Label htmlFor='password'>Password</Label>
<button
type='button'
onClick={() => setForgotPasswordOpen(true)}
className='font-medium text-muted-foreground text-xs transition hover:text-foreground'
>
Forgot password?
</button>
</div>
<Button
type='submit'
className='flex h-11 w-full items-center justify-center gap-2 bg-brand-primary font-medium text-base text-white shadow-[var(--brand-primary-hex)]/20 shadow-lg transition-colors duration-200 hover:bg-brand-primary-hover'
disabled={isLoading}
>
{isLoading ? 'Signing in...' : 'Sign In'}
</Button>
</form>
<div className='relative'>
<Input
id='password'
name='password'
required
type={showPassword ? 'text' : 'password'}
autoCapitalize='none'
autoComplete='current-password'
autoCorrect='off'
placeholder='Enter your password'
value={password}
onChange={handlePasswordChange}
className={cn(
'rounded-[10px] pr-10 shadow-sm transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100',
showValidationError &&
passwordErrors.length > 0 &&
'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500'
)}
/>
<button
type='button'
onClick={() => setShowPassword(!showPassword)}
className='-translate-y-1/2 absolute top-1/2 right-3 text-gray-500 transition hover:text-gray-700'
aria-label={showPassword ? 'Hide password' : 'Show password'}
>
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
{showValidationError && passwordErrors.length > 0 && (
<div className='mt-1 space-y-1 text-red-400 text-xs'>
{passwordErrors.map((error, index) => (
<p key={index}>{error}</p>
))}
</div>
)}
</div>
</div>
<div className='text-center text-sm'>
<span className='text-neutral-400'>Don't have an account? </span>
<Link
href={isInviteFlow ? `/signup?invite_flow=true&callbackUrl=${callbackUrl}` : '/signup'}
className='font-medium text-[var(--brand-accent-hex)] underline-offset-4 transition hover:text-[var(--brand-accent-hover-hex)] hover:underline'
>
Sign up
</Link>
</div>
<Button
type='submit'
className={`${buttonClass} flex w-full items-center justify-center gap-2 rounded-[10px] border font-medium text-[15px] text-white transition-all duration-200`}
disabled={isLoading}
>
{isLoading ? 'Signing in...' : 'Sign in'}
</Button>
</form>
<div className='text-center text-neutral-500/80 text-xs leading-relaxed'>
By signing in, you agree to our{' '}
<Link
href='/terms'
className='text-neutral-400 underline-offset-4 transition hover:text-neutral-300 hover:underline'
>
Terms of Service
</Link>{' '}
and{' '}
<Link
href='/privacy'
className='text-neutral-400 underline-offset-4 transition hover:text-neutral-300 hover:underline'
>
Privacy Policy
</Link>
{(githubAvailable || googleAvailable) && (
<div className={`${inter.className} relative my-6 font-light`}>
<div className='absolute inset-0 flex items-center'>
<div className='auth-divider w-full border-t' />
</div>
<div className='relative flex justify-center text-sm'>
<span className='bg-white px-4 font-[340] text-muted-foreground'>Or continue with</span>
</div>
</div>
)}
<SocialLoginButtons
googleAvailable={googleAvailable}
githubAvailable={githubAvailable}
isProduction={isProduction}
callbackURL={callbackUrl}
/>
<div className={`${inter.className} pt-6 text-center font-light text-[14px]`}>
<span className='font-normal'>Don't have an account? </span>
<Link
href={isInviteFlow ? `/signup?invite_flow=true&callbackUrl=${callbackUrl}` : '/signup'}
className='font-medium text-[var(--brand-accent-hex)] underline-offset-4 transition hover:text-[var(--brand-accent-hover-hex)] hover:underline'
>
Sign up
</Link>
</div>
<div
className={`${inter.className} auth-text-muted 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]`}
>
By signing in, you agree to our{' '}
<Link
href='/terms'
target='_blank'
rel='noopener noreferrer'
className='auth-link underline-offset-4 transition hover:underline'
>
Terms of Service
</Link>{' '}
and{' '}
<Link
href='/privacy'
target='_blank'
rel='noopener noreferrer'
className='auth-link underline-offset-4 transition hover:underline'
>
Privacy Policy
</Link>
</div>
<Dialog open={forgotPasswordOpen} onOpenChange={setForgotPasswordOpen}>
<DialogContent className='border border-neutral-700/50 bg-neutral-800/90 text-white backdrop-blur-sm'>
<DialogContent className='auth-card auth-card-shadow max-w-[540px] rounded-[10px] border backdrop-blur-sm'>
<DialogHeader>
<DialogTitle className='font-semibold text-white text-xl tracking-tight'>
<DialogTitle className='auth-text-primary font-semibold text-xl tracking-tight'>
Reset Password
</DialogTitle>
<DialogDescription className='text-neutral-300 text-sm'>
<DialogDescription className='auth-text-secondary text-sm'>
Enter your email address and we'll send you a link to reset your password if your
account exists.
</DialogDescription>
</DialogHeader>
<div className='space-y-4'>
<div className='space-y-2'>
<Label htmlFor='reset-email' className='text-neutral-300'>
Email
</Label>
<div className='flex items-center justify-between'>
<Label htmlFor='reset-email'>Email</Label>
</div>
<Input
id='reset-email'
value={forgotPasswordEmail}
@@ -539,8 +575,9 @@ export default function LoginPage({
required
type='email'
className={cn(
'border-neutral-700/80 bg-neutral-900 text-white placeholder:text-white/60 focus:border-[var(--brand-primary-hover-hex)]/70 focus:ring-[var(--brand-primary-hover-hex)]/20',
resetStatus.type === 'error' && 'border-red-500 focus-visible:ring-red-500'
'rounded-[10px] shadow-sm transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100',
resetStatus.type === 'error' &&
'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500'
)}
/>
{resetStatus.type === 'error' && (
@@ -557,7 +594,7 @@ export default function LoginPage({
<Button
type='button'
onClick={handleForgotPassword}
className='h-11 w-full bg-[var(--brand-primary-hex)] font-medium text-base text-white shadow-[var(--brand-primary-hex)]/20 shadow-lg transition-colors duration-200 hover:bg-[var(--brand-primary-hover-hex)]'
className={`${buttonClass} w-full rounded-[10px] border font-medium text-[15px] text-white transition-all duration-200`}
disabled={isSubmittingReset}
>
{isSubmittingReset ? 'Sending...' : 'Send Reset Link'}
@@ -565,6 +602,6 @@ export default function LoginPage({
</div>
</DialogContent>
</Dialog>
</div>
</>
)
}

View File

@@ -3,16 +3,10 @@
import { Suspense, useEffect, useState } from 'react'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { createLogger } from '@/lib/logs/console/logger'
import { SetNewPasswordForm } from '@/app/(auth)/reset-password/reset-password-form'
import { inter } from '@/app/fonts/inter'
import { soehne } from '@/app/fonts/soehne/soehne'
const logger = createLogger('ResetPasswordPage')
@@ -82,40 +76,42 @@ function ResetPasswordContent() {
}
return (
<main className='flex min-h-screen flex-col items-center justify-center bg-gray-50'>
<div className='sm:mx-auto sm:w-full sm:max-w-md'>
<h1 className='mb-8 text-center font-bold text-2xl'>Sim</h1>
<Card className='w-full'>
<CardHeader>
<CardTitle>Reset your password</CardTitle>
<CardDescription>Enter a new password for your account</CardDescription>
</CardHeader>
<CardContent>
<SetNewPasswordForm
token={token}
onSubmit={handleResetPassword}
isSubmitting={isSubmitting}
statusType={statusMessage.type}
statusMessage={statusMessage.text}
/>
</CardContent>
<CardFooter>
<p className='w-full text-center text-gray-500 text-sm'>
<Link href='/login' className='text-muted-foreground hover:underline'>
Back to login
</Link>
</p>
</CardFooter>
</Card>
<>
<div className='space-y-1 text-center'>
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
Reset your password
</h1>
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
Enter a new password for your account
</p>
</div>
</main>
<div className={`${inter.className} mt-8`}>
<SetNewPasswordForm
token={token}
onSubmit={handleResetPassword}
isSubmitting={isSubmitting}
statusType={statusMessage.type}
statusMessage={statusMessage.text}
/>
</div>
<div className={`${inter.className} pt-6 text-center font-light text-[14px]`}>
<Link
href='/login'
className='font-medium text-[var(--brand-accent-hex)] underline-offset-4 transition hover:text-[var(--brand-accent-hover-hex)] hover:underline'
>
Back to login
</Link>
</div>
</>
)
}
export default function ResetPasswordPage() {
return (
<Suspense
fallback={<div className='flex min-h-screen items-center justify-center'>Loading...</div>}
fallback={<div className='flex h-screen items-center justify-center'>Loading...</div>}
>
<ResetPasswordContent />
</Suspense>

View File

@@ -1,11 +1,12 @@
'use client'
import { useState } from 'react'
import { Loader2 } from 'lucide-react'
import { useEffect, useState } from 'react'
import { Eye, EyeOff } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { cn } from '@/lib/utils'
import { inter } from '@/app/fonts/inter'
interface RequestResetFormProps {
email: string
@@ -26,25 +27,59 @@ export function RequestResetForm({
statusMessage,
className,
}: RequestResetFormProps) {
const [buttonClass, setButtonClass] = useState('auth-button-gradient')
useEffect(() => {
// Check if CSS variable has been customized
const checkCustomBrand = () => {
const computedStyle = getComputedStyle(document.documentElement)
const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim()
// Check if the CSS variable exists and is different from the default
if (brandAccent && brandAccent !== '#6f3dfa') {
setButtonClass('auth-button-custom')
} else {
setButtonClass('auth-button-gradient')
}
}
checkCustomBrand()
// Also check on window resize or theme changes
window.addEventListener('resize', checkCustomBrand)
const observer = new MutationObserver(checkCustomBrand)
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['style', 'class'],
})
return () => {
window.removeEventListener('resize', checkCustomBrand)
observer.disconnect()
}
}, [])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
onSubmit(email)
}
return (
<form onSubmit={handleSubmit} className={className}>
<div className='grid gap-4'>
<div className='grid gap-2'>
<Label htmlFor='reset-email'>Email</Label>
<form onSubmit={handleSubmit} className={cn(`${inter.className} space-y-8`, className)}>
<div className='space-y-6'>
<div className='space-y-2'>
<div className='flex items-center justify-between'>
<Label htmlFor='reset-email'>Email</Label>
</div>
<Input
id='reset-email'
value={email}
onChange={(e) => onEmailChange(e.target.value)}
placeholder='your@email.com'
placeholder='Enter your email'
type='email'
disabled={isSubmitting}
required
className='placeholder:text-white/60'
className='rounded-[10px] shadow-sm transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100'
/>
<p className='text-muted-foreground text-sm'>
We'll send a password reset link to this email address.
@@ -52,30 +87,22 @@ export function RequestResetForm({
</div>
{/* Status message display */}
{statusType && (
{statusType && statusMessage && (
<div
className={cn(
'rounded-md border p-3 text-sm',
statusType === 'success'
? 'border-green-200 bg-green-50 text-green-700'
: 'border-red-200 bg-red-50 text-red-700'
)}
className={cn('text-xs', statusType === 'success' ? 'text-[#4CAF50]' : 'text-red-400')}
>
{statusMessage}
<p>{statusMessage}</p>
</div>
)}
<Button type='submit' disabled={isSubmitting} className='w-full'>
{isSubmitting ? (
<>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
Sending...
</>
) : (
'Send Reset Link'
)}
</Button>
</div>
<Button
type='submit'
disabled={isSubmitting}
className={`${buttonClass} flex w-full items-center justify-center gap-2 rounded-[10px] border font-medium text-[15px] text-white transition-all duration-200`}
>
{isSubmitting ? 'Sending...' : 'Send Reset Link'}
</Button>
</form>
)
}
@@ -100,6 +127,39 @@ export function SetNewPasswordForm({
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [validationMessage, setValidationMessage] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
const [buttonClass, setButtonClass] = useState('auth-button-gradient')
useEffect(() => {
// Check if CSS variable has been customized
const checkCustomBrand = () => {
const computedStyle = getComputedStyle(document.documentElement)
const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim()
// Check if the CSS variable exists and is different from the default
if (brandAccent && brandAccent !== '#6f3dfa') {
setButtonClass('auth-button-custom')
} else {
setButtonClass('auth-button-gradient')
}
}
checkCustomBrand()
// Also check on window resize or theme changes
window.addEventListener('resize', checkCustomBrand)
const observer = new MutationObserver(checkCustomBrand)
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['style', 'class'],
})
return () => {
window.removeEventListener('resize', checkCustomBrand)
observer.disconnect()
}
}, [])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
@@ -120,71 +180,98 @@ export function SetNewPasswordForm({
}
return (
<form onSubmit={handleSubmit} className={className}>
<div className='grid gap-4'>
<div className='grid gap-2'>
<Label htmlFor='password'>New Password</Label>
<Input
id='password'
type='password'
autoCapitalize='none'
autoComplete='new-password'
autoCorrect='off'
disabled={isSubmitting || !token}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
placeholder='Enter new password'
className='placeholder:text-white/60'
/>
<form onSubmit={handleSubmit} className={cn(`${inter.className} space-y-8`, className)}>
<div className='space-y-6'>
<div className='space-y-2'>
<div className='flex items-center justify-between'>
<Label htmlFor='password'>New Password</Label>
</div>
<div className='relative'>
<Input
id='password'
type={showPassword ? 'text' : 'password'}
autoCapitalize='none'
autoComplete='new-password'
autoCorrect='off'
disabled={isSubmitting || !token}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
placeholder='Enter new password'
className={cn(
'rounded-[10px] pr-10 shadow-sm transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100',
validationMessage &&
'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500'
)}
/>
<button
type='button'
onClick={() => setShowPassword(!showPassword)}
className='-translate-y-1/2 absolute top-1/2 right-3 text-gray-500 transition hover:text-gray-700'
aria-label={showPassword ? 'Hide password' : 'Show password'}
>
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
</div>
<div className='grid gap-2'>
<Label htmlFor='confirmPassword'>Confirm Password</Label>
<Input
id='confirmPassword'
type='password'
autoCapitalize='none'
autoComplete='new-password'
autoCorrect='off'
disabled={isSubmitting || !token}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
placeholder='Confirm new password'
className='placeholder:text-white/60'
/>
<div className='space-y-2'>
<div className='flex items-center justify-between'>
<Label htmlFor='confirmPassword'>Confirm Password</Label>
</div>
<div className='relative'>
<Input
id='confirmPassword'
type={showConfirmPassword ? 'text' : 'password'}
autoCapitalize='none'
autoComplete='new-password'
autoCorrect='off'
disabled={isSubmitting || !token}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
placeholder='Confirm new password'
className={cn(
'rounded-[10px] pr-10 shadow-sm transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100',
validationMessage &&
'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500'
)}
/>
<button
type='button'
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
className='-translate-y-1/2 absolute top-1/2 right-3 text-gray-500 transition hover:text-gray-700'
aria-label={showConfirmPassword ? 'Hide password' : 'Show password'}
>
{showConfirmPassword ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
</div>
{validationMessage && (
<div className='rounded-md border border-red-200 bg-red-50 p-3 text-red-700 text-sm'>
{validationMessage}
<div className='mt-1 space-y-1 text-red-400 text-xs'>
<p>{validationMessage}</p>
</div>
)}
{statusType && (
{statusType && statusMessage && (
<div
className={cn(
'rounded-md border p-3 text-sm',
statusType === 'success'
? 'border-green-200 bg-green-50 text-green-700'
: 'border-red-200 bg-red-50 text-red-700'
'mt-1 space-y-1 text-xs',
statusType === 'success' ? 'text-[#4CAF50]' : 'text-red-400'
)}
>
{statusMessage}
<p>{statusMessage}</p>
</div>
)}
<Button disabled={isSubmitting || !token} type='submit' className='w-full'>
{isSubmitting ? (
<>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
Resetting...
</>
) : (
'Reset Password'
)}
</Button>
</div>
<Button
disabled={isSubmitting || !token}
type='submit'
className={`${buttonClass} flex w-full items-center justify-center gap-2 rounded-[10px] border font-medium text-[15px] text-white transition-all duration-200`}
>
{isSubmitting ? 'Resetting...' : 'Reset Password'}
</Button>
</form>
)
}

View File

@@ -1,418 +0,0 @@
/**
* @vitest-environment jsdom
*/
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { useRouter, useSearchParams } from 'next/navigation'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { client, useSession } from '@/lib/auth-client'
import SignupPage from '@/app/(auth)/signup/signup-form'
vi.mock('next/navigation', () => ({
useRouter: vi.fn(),
useSearchParams: vi.fn(),
}))
vi.mock('@/lib/auth-client', () => ({
client: {
signUp: {
email: vi.fn(),
},
emailOtp: {
sendVerificationOtp: vi.fn(),
},
},
useSession: vi.fn(),
}))
vi.mock('@/app/(auth)/components/social-login-buttons', () => ({
SocialLoginButtons: () => <div data-testid='social-login-buttons'>Social Login Buttons</div>,
}))
const mockRouter = {
push: vi.fn(),
replace: vi.fn(),
back: vi.fn(),
}
const mockSearchParams = {
get: vi.fn(),
}
describe('SignupPage', () => {
beforeEach(() => {
vi.clearAllMocks()
;(useRouter as any).mockReturnValue(mockRouter)
;(useSearchParams as any).mockReturnValue(mockSearchParams)
;(useSession as any).mockReturnValue({
refetch: vi.fn().mockResolvedValue({}),
})
mockSearchParams.get.mockReturnValue(null)
})
const defaultProps = {
githubAvailable: true,
googleAvailable: true,
isProduction: false,
}
describe('Basic Rendering', () => {
it('should render signup form with all required elements', () => {
render(<SignupPage {...defaultProps} />)
expect(screen.getByPlaceholderText(/enter your name/i)).toBeInTheDocument()
expect(screen.getByPlaceholderText(/enter your email/i)).toBeInTheDocument()
expect(screen.getByPlaceholderText(/enter your password/i)).toBeInTheDocument()
expect(screen.getByRole('button', { name: /create account/i })).toBeInTheDocument()
expect(screen.getByText(/sign in/i)).toBeInTheDocument()
})
it('should render social login buttons', () => {
render(<SignupPage {...defaultProps} />)
expect(screen.getByTestId('social-login-buttons')).toBeInTheDocument()
})
})
describe('Password Visibility Toggle', () => {
it('should toggle password visibility when button is clicked', () => {
render(<SignupPage {...defaultProps} />)
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
const toggleButton = screen.getByLabelText(/show password/i)
expect(passwordInput).toHaveAttribute('type', 'password')
fireEvent.click(toggleButton)
expect(passwordInput).toHaveAttribute('type', 'text')
fireEvent.click(toggleButton)
expect(passwordInput).toHaveAttribute('type', 'password')
})
})
describe('Form Interaction', () => {
it('should allow users to type in form fields', () => {
render(<SignupPage {...defaultProps} />)
const nameInput = screen.getByPlaceholderText(/enter your name/i)
const emailInput = screen.getByPlaceholderText(/enter your email/i)
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
fireEvent.change(nameInput, { target: { value: 'John Doe' } })
fireEvent.change(emailInput, { target: { value: 'user@company.com' } })
fireEvent.change(passwordInput, { target: { value: 'Password123!' } })
expect(nameInput).toHaveValue('John Doe')
expect(emailInput).toHaveValue('user@company.com')
expect(passwordInput).toHaveValue('Password123!')
})
it('should show loading state during form submission', async () => {
const mockSignUp = vi.mocked(client.signUp.email)
mockSignUp.mockImplementation(
() => new Promise((resolve) => resolve({ data: { user: { id: '1' } }, error: null }))
)
render(<SignupPage {...defaultProps} />)
const nameInput = screen.getByPlaceholderText(/enter your name/i)
const emailInput = screen.getByPlaceholderText(/enter your email/i)
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
const submitButton = screen.getByRole('button', { name: /create account/i })
fireEvent.change(nameInput, { target: { value: 'John Doe' } })
fireEvent.change(emailInput, { target: { value: 'user@company.com' } })
fireEvent.change(passwordInput, { target: { value: 'Password123!' } })
fireEvent.click(submitButton)
expect(screen.getByText('Creating account...')).toBeInTheDocument()
expect(submitButton).toBeDisabled()
})
})
describe('Form Submission', () => {
it('should call signUp with correct credentials and trimmed name', async () => {
const mockSignUp = vi.mocked(client.signUp.email)
const mockSendOtp = vi.mocked(client.emailOtp.sendVerificationOtp)
mockSignUp.mockResolvedValue({ data: { user: { id: '1' } }, error: null })
mockSendOtp.mockResolvedValue({ data: null, error: null })
render(<SignupPage {...defaultProps} />)
const nameInput = screen.getByPlaceholderText(/enter your name/i)
const emailInput = screen.getByPlaceholderText(/enter your email/i)
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
const submitButton = screen.getByRole('button', { name: /create account/i })
// Use valid input that passes all validation rules
fireEvent.change(nameInput, { target: { value: 'John Doe' } })
fireEvent.change(emailInput, { target: { value: 'user@company.com' } })
fireEvent.change(passwordInput, { target: { value: 'Password123!' } })
fireEvent.click(submitButton)
await waitFor(() => {
expect(mockSignUp).toHaveBeenCalledWith(
{
email: 'user@company.com',
password: 'Password123!',
name: 'John Doe',
},
expect.objectContaining({
onError: expect.any(Function),
})
)
})
})
it('should automatically trim spaces from name input', async () => {
const mockSignUp = vi.mocked(client.signUp.email)
mockSignUp.mockResolvedValue({ data: null, error: null })
render(<SignupPage {...defaultProps} />)
const nameInput = screen.getByPlaceholderText(/enter your name/i)
const emailInput = screen.getByPlaceholderText(/enter your email/i)
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
const submitButton = screen.getByRole('button', { name: /create account/i })
fireEvent.change(nameInput, { target: { value: ' John Doe ' } })
fireEvent.change(emailInput, { target: { value: 'user@company.com' } })
fireEvent.change(passwordInput, { target: { value: 'Password123!' } })
fireEvent.click(submitButton)
await waitFor(() => {
expect(mockSignUp).toHaveBeenCalledWith(
expect.objectContaining({
name: 'John Doe',
email: 'user@company.com',
password: 'Password123!',
}),
expect.any(Object)
)
})
})
it('should redirect to verification page after successful signup', async () => {
const mockSignUp = vi.mocked(client.signUp.email)
const mockSendOtp = vi.mocked(client.emailOtp.sendVerificationOtp)
mockSignUp.mockResolvedValue({ data: { user: { id: '1' } }, error: null })
mockSendOtp.mockResolvedValue({ data: null, error: null })
render(<SignupPage {...defaultProps} />)
const nameInput = screen.getByPlaceholderText(/enter your name/i)
const emailInput = screen.getByPlaceholderText(/enter your email/i)
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
const submitButton = screen.getByRole('button', { name: /create account/i })
fireEvent.change(nameInput, { target: { value: 'John Doe' } })
fireEvent.change(emailInput, { target: { value: 'user@company.com' } })
fireEvent.change(passwordInput, { target: { value: 'Password123!' } })
fireEvent.click(submitButton)
await waitFor(() => {
// With sendVerificationOnSignUp: true, OTP is sent automatically by Better Auth
// No manual OTP sending in the component anymore
expect(mockRouter.push).toHaveBeenCalledWith('/verify?fromSignup=true')
})
})
it('should handle signup errors', async () => {
const mockSignUp = vi.mocked(client.signUp.email)
mockSignUp.mockImplementation((credentials, options) => {
if (options?.onError) {
options.onError({
error: {
code: 'USER_ALREADY_EXISTS',
message: 'User already exists',
} as any,
response: {} as any,
request: {} as any,
} as any)
}
return Promise.resolve({ data: null, error: 'User already exists' })
})
render(<SignupPage {...defaultProps} />)
const nameInput = screen.getByPlaceholderText(/enter your name/i)
const emailInput = screen.getByPlaceholderText(/enter your email/i)
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
const submitButton = screen.getByRole('button', { name: /create account/i })
await act(async () => {
fireEvent.change(nameInput, { target: { value: 'John Doe' } })
fireEvent.change(emailInput, { target: { value: 'existing@example.com' } })
fireEvent.change(passwordInput, { target: { value: 'Password123!' } })
fireEvent.click(submitButton)
})
await waitFor(() => {
expect(screen.getByText('Failed to create account')).toBeInTheDocument()
})
})
it('should show warning for names that would be truncated (over 100 characters)', async () => {
const mockSignUp = vi.mocked(client.signUp.email)
const longName = 'a'.repeat(101) // 101 characters
render(<SignupPage {...defaultProps} />)
const nameInput = screen.getByPlaceholderText(/enter your name/i)
const emailInput = screen.getByPlaceholderText(/enter your email/i)
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
const submitButton = screen.getByRole('button', { name: /create account/i })
fireEvent.change(nameInput, { target: { value: longName } })
fireEvent.change(emailInput, { target: { value: 'user@company.com' } })
fireEvent.change(passwordInput, { target: { value: 'ValidPass123!' } })
fireEvent.click(submitButton)
await waitFor(() => {
expect(screen.getByText(/name will be truncated to 100 characters/i)).toBeInTheDocument()
})
// Ensure signUp was not called
expect(mockSignUp).not.toHaveBeenCalled()
})
it('should handle names exactly at 100 characters without warning', async () => {
const mockSignUp = vi.mocked(client.signUp.email)
mockSignUp.mockImplementation(
() => new Promise((resolve) => resolve({ data: { user: { id: '1' } }, error: null }))
)
const exactLengthName = 'a'.repeat(100) // Exactly 100 characters
render(<SignupPage {...defaultProps} />)
const nameInput = screen.getByPlaceholderText(/enter your name/i)
const emailInput = screen.getByPlaceholderText(/enter your email/i)
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
const submitButton = screen.getByRole('button', { name: /create account/i })
fireEvent.change(nameInput, { target: { value: exactLengthName } })
fireEvent.change(emailInput, { target: { value: 'user@company.com' } })
fireEvent.change(passwordInput, { target: { value: 'ValidPass123!' } })
fireEvent.click(submitButton)
// Should not show truncation warning
await waitFor(() => {
expect(screen.queryByText(/name will be truncated/i)).not.toBeInTheDocument()
})
// Should proceed with form submission
await waitFor(() => {
expect(mockSignUp).toHaveBeenCalledWith(
{
email: 'user@company.com',
password: 'ValidPass123!',
name: exactLengthName,
},
expect.any(Object)
)
})
})
it('should handle names exactly at validation errors', async () => {
const mockSignUp = vi.mocked(client.signUp.email)
mockSignUp.mockImplementation((credentials, options) => {
if (options?.onError) {
options.onError({
error: {
code: 'NAME_VALIDATION_ERROR',
message: 'Name validation error',
} as any,
response: {} as any,
request: {} as any,
} as any)
}
return Promise.resolve({ data: null, error: 'Name validation error' })
})
render(<SignupPage {...defaultProps} />)
const nameInput = screen.getByPlaceholderText(/enter your name/i)
const emailInput = screen.getByPlaceholderText(/enter your email/i)
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
const submitButton = screen.getByRole('button', { name: /create account/i })
await act(async () => {
fireEvent.change(nameInput, { target: { value: 'John Doe' } })
fireEvent.change(emailInput, { target: { value: 'user@company.com' } })
fireEvent.change(passwordInput, { target: { value: 'Password123!' } })
fireEvent.click(submitButton)
})
await waitFor(() => {
expect(screen.getByText('Failed to create account')).toBeInTheDocument()
})
})
})
describe('URL Parameters', () => {
it('should prefill email from URL parameter', () => {
mockSearchParams.get.mockImplementation((param) => {
if (param === 'email') return 'prefilled@example.com'
return null
})
render(<SignupPage {...defaultProps} />)
const emailInput = screen.getByPlaceholderText(/enter your email/i)
expect(emailInput).toHaveValue('prefilled@example.com')
})
it('should handle invite flow redirect', async () => {
mockSearchParams.get.mockImplementation((param) => {
if (param === 'redirect') return '/invite/123'
if (param === 'invite_flow') return 'true'
return null
})
const mockSignUp = vi.mocked(client.signUp.email)
mockSignUp.mockResolvedValue({ data: { user: { id: '1' } }, error: null })
render(<SignupPage {...defaultProps} />)
const nameInput = screen.getByPlaceholderText(/enter your name/i)
const emailInput = screen.getByPlaceholderText(/enter your email/i)
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
const submitButton = screen.getByRole('button', { name: /create account/i })
fireEvent.change(nameInput, { target: { value: 'John Doe' } })
fireEvent.change(emailInput, { target: { value: 'user@company.com' } })
fireEvent.change(passwordInput, { target: { value: 'Password123!' } })
fireEvent.click(submitButton)
await waitFor(() => {
expect(mockRouter.push).toHaveBeenCalledWith('/verify?fromSignup=true')
})
})
it('should link to login with invite flow parameters', () => {
mockSearchParams.get.mockImplementation((param) => {
if (param === 'invite_flow') return 'true'
if (param === 'redirect') return '/invite/123'
return null
})
render(<SignupPage {...defaultProps} />)
const loginLink = screen.getByText(/sign in/i)
expect(loginLink).toHaveAttribute('href', '/login?invite_flow=true&callbackUrl=/invite/123')
})
it('should default to regular login link when no invite flow', () => {
render(<SignupPage {...defaultProps} />)
const loginLink = screen.getByText(/sign in/i)
expect(loginLink).toHaveAttribute('href', '/login')
})
})
})

View File

@@ -12,6 +12,8 @@ import { quickValidateEmail } from '@/lib/email/validation'
import { createLogger } from '@/lib/logs/console/logger'
import { cn } from '@/lib/utils'
import { SocialLoginButtons } from '@/app/(auth)/components/social-login-buttons'
import { inter } from '@/app/fonts/inter'
import { soehne } from '@/app/fonts/soehne/soehne'
const logger = createLogger('SignupForm')
@@ -91,6 +93,7 @@ function SignupFormContent({
const [showEmailValidationError, setShowEmailValidationError] = useState(false)
const [redirectUrl, setRedirectUrl] = useState('')
const [isInviteFlow, setIsInviteFlow] = useState(false)
const [buttonClass, setButtonClass] = useState('auth-button-gradient')
// Name validation state
const [name, setName] = useState('')
@@ -120,6 +123,34 @@ function SignupFormContent({
if (inviteFlowParam === 'true') {
setIsInviteFlow(true)
}
// Check if CSS variable has been customized
const checkCustomBrand = () => {
const computedStyle = getComputedStyle(document.documentElement)
const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim()
// Check if the CSS variable exists and is different from the default
if (brandAccent && brandAccent !== '#6f3dfa') {
setButtonClass('auth-button-custom')
} else {
setButtonClass('auth-button-gradient')
}
}
checkCustomBrand()
// Also check on window resize or theme changes
window.addEventListener('resize', checkCustomBrand)
const observer = new MutationObserver(checkCustomBrand)
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['style', 'class'],
})
return () => {
window.removeEventListener('resize', checkCustomBrand)
observer.disconnect()
}
}, [searchParams])
// Validate password and return array of error messages
@@ -361,166 +392,180 @@ function SignupFormContent({
}
return (
<div className='space-y-6'>
<div className='space-y-2 text-center'>
<h1 className='font-semibold text-[32px] text-white tracking-tight'>Create Account</h1>
<p className='text-neutral-400 text-sm'>Enter your details to create a new account</p>
<>
<div className='space-y-1 text-center'>
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
Create an account
</h1>
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
Create an account or log in
</p>
</div>
<div className='flex flex-col gap-6'>
<div className='rounded-xl border border-neutral-700/40 bg-neutral-800/50 p-6 backdrop-blur-sm'>
<SocialLoginButtons
githubAvailable={githubAvailable}
googleAvailable={googleAvailable}
callbackURL={redirectUrl || '/workspace'}
isProduction={isProduction}
/>
{(githubAvailable || googleAvailable) && (
<div className='relative mt-2 py-4'>
<div className='absolute inset-0 flex items-center'>
<div className='w-full border-neutral-700/50 border-t' />
</div>
<form onSubmit={onSubmit} className={`${inter.className} mt-8 space-y-8`}>
<div className='space-y-6'>
<div className='space-y-2'>
<div className='flex items-center justify-between'>
<Label htmlFor='name'>Full name</Label>
</div>
)}
<form onSubmit={onSubmit} className='space-y-5'>
<div className='space-y-4'>
<div className='space-y-2'>
<Label htmlFor='name' className='text-neutral-300'>
Full Name
</Label>
<Input
id='name'
name='name'
placeholder='Enter your name'
type='text'
autoCapitalize='words'
autoComplete='name'
title='Name can only contain letters, spaces, hyphens, and apostrophes'
value={name}
onChange={handleNameChange}
className={cn(
'border-neutral-700 bg-neutral-900 text-white placeholder:text-white/60',
showNameValidationError &&
nameErrors.length > 0 &&
'border-red-500 focus-visible:ring-red-500'
)}
/>
{showNameValidationError && nameErrors.length > 0 && (
<div className='mt-1 space-y-1 text-red-400 text-xs'>
{nameErrors.map((error, index) => (
<p key={index}>{error}</p>
))}
</div>
)}
</div>
<div className='space-y-2'>
<Label htmlFor='email' className='text-neutral-300'>
Email
</Label>
<Input
id='email'
name='email'
placeholder='Enter your email'
autoCapitalize='none'
autoComplete='email'
autoCorrect='off'
value={email}
onChange={handleEmailChange}
className={cn(
'border-neutral-700 bg-neutral-900 text-white placeholder:text-white/60',
(emailError || (showEmailValidationError && emailErrors.length > 0)) &&
'border-red-500 focus-visible:ring-red-500'
)}
/>
{showEmailValidationError && emailErrors.length > 0 && (
<div className='mt-1 space-y-1 text-red-400 text-xs'>
{emailErrors.map((error, index) => (
<p key={index}>{error}</p>
))}
</div>
)}
{emailError && !showEmailValidationError && (
<div className='mt-1 text-red-400 text-xs'>
<p>{emailError}</p>
</div>
)}
</div>
<div className='space-y-2'>
<Label htmlFor='password' className='text-neutral-300'>
Password
</Label>
<div className='relative'>
<Input
id='password'
name='password'
type={showPassword ? 'text' : 'password'}
autoCapitalize='none'
autoComplete='new-password'
placeholder='Enter your password'
autoCorrect='off'
value={password}
onChange={handlePasswordChange}
className='border-neutral-700 bg-neutral-900 pr-10 text-white placeholder:text-white/60'
/>
<button
type='button'
onClick={() => setShowPassword(!showPassword)}
className='-translate-y-1/2 absolute top-1/2 right-3 text-neutral-400 transition hover:text-white'
aria-label={showPassword ? 'Hide password' : 'Show password'}
>
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
{showValidationError && passwordErrors.length > 0 && (
<div className='mt-1 space-y-1 text-red-400 text-xs'>
{passwordErrors.map((error, index) => (
<p key={index}>{error}</p>
))}
</div>
)}
<Input
id='name'
name='name'
placeholder='Enter your name'
type='text'
autoCapitalize='words'
autoComplete='name'
title='Name can only contain letters, spaces, hyphens, and apostrophes'
value={name}
onChange={handleNameChange}
className={cn(
'rounded-[10px] shadow-sm transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100',
showNameValidationError &&
nameErrors.length > 0 &&
'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500'
)}
/>
{showNameValidationError && nameErrors.length > 0 && (
<div className='mt-1 space-y-1 text-red-400 text-xs'>
{nameErrors.map((error, index) => (
<p key={index}>{error}</p>
))}
</div>
)}
</div>
<div className='space-y-2'>
<div className='flex items-center justify-between'>
<Label htmlFor='email'>Email</Label>
</div>
<Button
type='submit'
className='flex h-11 w-full items-center justify-center gap-2 bg-[var(--brand-primary-hex)] font-medium text-base text-white shadow-[var(--brand-primary-hex)]/20 shadow-lg transition-colors duration-200 hover:bg-[var(--brand-primary-hover-hex)]'
disabled={isLoading}
>
{isLoading ? 'Creating account...' : 'Create Account'}
</Button>
</form>
<Input
id='email'
name='email'
placeholder='Enter your email'
autoCapitalize='none'
autoComplete='email'
autoCorrect='off'
value={email}
onChange={handleEmailChange}
className={cn(
'rounded-[10px] shadow-sm transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100',
(emailError || (showEmailValidationError && emailErrors.length > 0)) &&
'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500'
)}
/>
{showEmailValidationError && emailErrors.length > 0 && (
<div className='mt-1 space-y-1 text-red-400 text-xs'>
{emailErrors.map((error, index) => (
<p key={index}>{error}</p>
))}
</div>
)}
{emailError && !showEmailValidationError && (
<div className='mt-1 text-red-400 text-xs'>
<p>{emailError}</p>
</div>
)}
</div>
<div className='space-y-2'>
<div className='flex items-center justify-between'>
<Label htmlFor='password'>Password</Label>
</div>
<div className='relative'>
<Input
id='password'
name='password'
type={showPassword ? 'text' : 'password'}
autoCapitalize='none'
autoComplete='new-password'
placeholder='Enter your password'
autoCorrect='off'
value={password}
onChange={handlePasswordChange}
className={cn(
'rounded-[10px] pr-10 shadow-sm transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100',
showValidationError &&
passwordErrors.length > 0 &&
'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500'
)}
/>
<button
type='button'
onClick={() => setShowPassword(!showPassword)}
className='-translate-y-1/2 absolute top-1/2 right-3 text-gray-500 transition hover:text-gray-700'
aria-label={showPassword ? 'Hide password' : 'Show password'}
>
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
{showValidationError && passwordErrors.length > 0 && (
<div className='mt-1 space-y-1 text-red-400 text-xs'>
{passwordErrors.map((error, index) => (
<p key={index}>{error}</p>
))}
</div>
)}
</div>
</div>
<div className='text-center text-sm'>
<span className='text-neutral-400'>Already have an account? </span>
<Link
href={isInviteFlow ? `/login?invite_flow=true&callbackUrl=${redirectUrl}` : '/login'}
className='font-medium text-[var(--brand-accent-hex)] underline-offset-4 transition hover:text-[var(--brand-accent-hover-hex)] hover:underline'
>
Sign in
</Link>
</div>
<Button
type='submit'
className={`${buttonClass} flex w-full items-center justify-center gap-2 rounded-[10px] border font-medium text-[15px] text-white transition-all duration-200`}
disabled={isLoading}
>
{isLoading ? 'Creating account...' : 'Create account'}
</Button>
</form>
<div className='text-center text-neutral-500/80 text-xs leading-relaxed'>
By creating an account, you agree to our{' '}
<Link
href='/terms'
className='text-neutral-400 underline-offset-4 transition hover:text-neutral-300 hover:underline'
>
Terms of Service
</Link>{' '}
and{' '}
<Link
href='/privacy'
className='text-neutral-400 underline-offset-4 transition hover:text-neutral-300 hover:underline'
>
Privacy Policy
</Link>
{(githubAvailable || googleAvailable) && (
<div className={`${inter.className} relative my-6 font-light`}>
<div className='absolute inset-0 flex items-center'>
<div className='auth-divider w-full border-t' />
</div>
<div className='relative flex justify-center text-sm'>
<span className='bg-white px-4 font-[340] text-muted-foreground'>Or continue with</span>
</div>
</div>
)}
<SocialLoginButtons
githubAvailable={githubAvailable}
googleAvailable={googleAvailable}
callbackURL={redirectUrl || '/workspace'}
isProduction={isProduction}
/>
<div className={`${inter.className} pt-6 text-center font-light text-[14px]`}>
<span className='font-normal'>Already have an account? </span>
<Link
href={isInviteFlow ? `/login?invite_flow=true&callbackUrl=${redirectUrl}` : '/login'}
className='font-medium text-[var(--brand-accent-hex)] underline-offset-4 transition hover:text-[var(--brand-accent-hover-hex)] hover:underline'
>
Sign in
</Link>
</div>
</div>
<div
className={`${inter.className} auth-text-muted 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]`}
>
By creating an account, you agree to our{' '}
<Link
href='/terms'
target='_blank'
rel='noopener noreferrer'
className='auth-link underline-offset-4 transition hover:underline'
>
Terms of Service
</Link>{' '}
and{' '}
<Link
href='/privacy'
target='_blank'
rel='noopener noreferrer'
className='auth-link underline-offset-4 transition hover:underline'
>
Privacy Policy
</Link>
</div>
</>
)
}

View File

@@ -1,10 +1,13 @@
'use client'
import { Suspense, useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/ui/input-otp'
import { cn } from '@/lib/utils'
import { useVerification } from '@/app/(auth)/verify/use-verification'
import { inter } from '@/app/fonts/inter'
import { soehne } from '@/app/fonts/soehne/soehne'
interface VerifyContentProps {
hasResendKey: boolean
@@ -45,19 +48,68 @@ function VerificationForm({
}
}, [countdown, isResendDisabled])
const router = useRouter()
const handleResend = () => {
resendCode()
setIsResendDisabled(true)
setCountdown(30)
}
const handleCancelVerification = () => {
// Clear verification data
if (typeof window !== 'undefined') {
sessionStorage.removeItem('verificationEmail')
sessionStorage.removeItem('inviteRedirectUrl')
sessionStorage.removeItem('isInviteFlow')
// Clear the verification requirement cookie
document.cookie = 'requiresEmailVerification=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT'
}
// Redirect to login
router.push('/login')
}
const [buttonClass, setButtonClass] = useState('auth-button-gradient')
useEffect(() => {
// Check if CSS variable has been customized
const checkCustomBrand = () => {
const computedStyle = getComputedStyle(document.documentElement)
const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim()
// Check if the CSS variable exists and is different from the default
if (brandAccent && brandAccent !== '#6f3dfa') {
setButtonClass('auth-button-custom')
} else {
setButtonClass('auth-button-gradient')
}
}
checkCustomBrand()
// Also check on window resize or theme changes
window.addEventListener('resize', checkCustomBrand)
const observer = new MutationObserver(checkCustomBrand)
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['style', 'class'],
})
return () => {
window.removeEventListener('resize', checkCustomBrand)
observer.disconnect()
}
}, [])
return (
<div className='space-y-6'>
<div className='space-y-2 text-center'>
<h1 className='font-semibold text-[32px] text-white tracking-tight'>
<>
<div className='space-y-1 text-center'>
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
{isVerified ? 'Email Verified!' : 'Verify Your Email'}
</h1>
<p className='text-neutral-400 text-sm'>
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
{isVerified
? 'Your email has been verified. Redirecting to dashboard...'
: hasResendKey
@@ -69,47 +121,75 @@ function VerificationForm({
</div>
{!isVerified && (
<div className='flex flex-col gap-6'>
<div className='rounded-xl border border-neutral-700/40 bg-neutral-800/50 p-6 backdrop-blur-sm'>
<p className='mb-4 text-neutral-400 text-sm'>
<div className={`${inter.className} mt-8 space-y-8`}>
<div className='space-y-6'>
<p className='text-center text-muted-foreground text-sm'>
Enter the 6-digit code to verify your account.
{hasResendKey ? " If you don't see it in your inbox, check your spam folder." : ''}
</p>
<div className='flex justify-center py-4'>
<div className='flex justify-center'>
<InputOTP
maxLength={6}
value={otp}
onChange={handleOtpChange}
disabled={isLoading}
className={cn(
isInvalidOtp ? 'border-red-500 focus-visible:ring-red-500' : 'border-neutral-700'
)}
className={cn('gap-2', isInvalidOtp && 'otp-error')}
>
<InputOTPGroup>
<InputOTPGroup className='[&>div]:!rounded-[10px] gap-2'>
<InputOTPSlot
index={0}
className='border-neutral-700 bg-neutral-900 text-white'
className={cn(
'!rounded-[10px] h-12 w-12 border bg-white text-center font-medium text-lg shadow-sm transition-all duration-200',
'border-gray-300 hover:border-gray-400',
'focus:border-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-100',
isInvalidOtp && 'border-red-500 focus:border-red-500 focus:ring-red-100'
)}
/>
<InputOTPSlot
index={1}
className='border-neutral-700 bg-neutral-900 text-white'
className={cn(
'!rounded-[10px] h-12 w-12 border bg-white text-center font-medium text-lg shadow-sm transition-all duration-200',
'border-gray-300 hover:border-gray-400',
'focus:border-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-100',
isInvalidOtp && 'border-red-500 focus:border-red-500 focus:ring-red-100'
)}
/>
<InputOTPSlot
index={2}
className='border-neutral-700 bg-neutral-900 text-white'
className={cn(
'!rounded-[10px] h-12 w-12 border bg-white text-center font-medium text-lg shadow-sm transition-all duration-200',
'border-gray-300 hover:border-gray-400',
'focus:border-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-100',
isInvalidOtp && 'border-red-500 focus:border-red-500 focus:ring-red-100'
)}
/>
<InputOTPSlot
index={3}
className='border-neutral-700 bg-neutral-900 text-white'
className={cn(
'!rounded-[10px] h-12 w-12 border bg-white text-center font-medium text-lg shadow-sm transition-all duration-200',
'border-gray-300 hover:border-gray-400',
'focus:border-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-100',
isInvalidOtp && 'border-red-500 focus:border-red-500 focus:ring-red-100'
)}
/>
<InputOTPSlot
index={4}
className='border-neutral-700 bg-neutral-900 text-white'
className={cn(
'!rounded-[10px] h-12 w-12 border bg-white text-center font-medium text-lg shadow-sm transition-all duration-200',
'border-gray-300 hover:border-gray-400',
'focus:border-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-100',
isInvalidOtp && 'border-red-500 focus:border-red-500 focus:ring-red-100'
)}
/>
<InputOTPSlot
index={5}
className='border-neutral-700 bg-neutral-900 text-white'
className={cn(
'!rounded-[10px] h-12 w-12 border bg-white text-center font-medium text-lg shadow-sm transition-all duration-200',
'border-gray-300 hover:border-gray-400',
'focus:border-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-100',
isInvalidOtp && 'border-red-500 focus:border-red-500 focus:ring-red-100'
)}
/>
</InputOTPGroup>
</InputOTP>
@@ -117,53 +197,62 @@ function VerificationForm({
{/* Error message */}
{errorMessage && (
<div className='mt-2 mb-4 rounded-md border border-red-900/20 bg-red-900/10 py-2 text-center'>
<p className='font-medium text-red-400 text-sm'>{errorMessage}</p>
</div>
)}
<Button
onClick={verifyCode}
className='h-11 w-full bg-[var(--brand-primary-hex)] font-medium text-base text-white shadow-[var(--brand-primary-hex)]/20 shadow-lg transition-colors duration-200 hover:bg-[var(--brand-primary-hover-hex)]'
disabled={!isOtpComplete || isLoading}
>
{isLoading ? 'Verifying...' : 'Verify Email'}
</Button>
{hasResendKey && (
<div className='mt-4 text-center'>
<p className='text-neutral-400 text-sm'>
Didn't receive a code?{' '}
{countdown > 0 ? (
<span>
Resend in <span className='font-medium text-neutral-300'>{countdown}s</span>
</span>
) : (
<button
className='font-medium text-[var(--brand-accent-hex)] underline-offset-4 transition hover:text-[var(--brand-accent-hover-hex)] hover:underline'
onClick={handleResend}
disabled={isLoading || isResendDisabled}
>
Resend
</button>
)}
</p>
<div className='mt-1 space-y-1 text-center text-red-400 text-xs'>
<p>{errorMessage}</p>
</div>
)}
</div>
<Button
onClick={verifyCode}
className={`${buttonClass} flex w-full items-center justify-center gap-2 rounded-[10px] border font-medium text-[15px] text-white transition-all duration-200`}
disabled={!isOtpComplete || isLoading}
>
{isLoading ? 'Verifying...' : 'Verify Email'}
</Button>
{hasResendKey && (
<div className='text-center'>
<p className='text-muted-foreground text-sm'>
Didn't receive a code?{' '}
{countdown > 0 ? (
<span>
Resend in <span className='font-medium text-foreground'>{countdown}s</span>
</span>
) : (
<button
className='font-medium text-[var(--brand-accent-hex)] underline-offset-4 transition hover:text-[var(--brand-accent-hover-hex)] hover:underline'
onClick={handleResend}
disabled={isLoading || isResendDisabled}
>
Resend
</button>
)}
</p>
</div>
)}
{/* <div className='text-center font-light text-[14px]'>
<button
onClick={handleCancelVerification}
className='font-medium text-[var(--brand-accent-hex)] underline-offset-4 transition hover:text-[var(--brand-accent-hover-hex)] hover:underline'
>
Back to login
</button>
</div> */}
</div>
)}
</div>
</>
)
}
// Fallback component while the verification form is loading
function VerificationFormFallback() {
return (
<div className='p-8 text-center'>
<div className='text-center'>
<div className='animate-pulse'>
<div className='mx-auto mb-4 h-8 w-48 rounded bg-neutral-800' />
<div className='mx-auto h-4 w-64 rounded bg-neutral-800' />
<div className='mx-auto mb-4 h-8 w-48 rounded bg-gray-200' />
<div className='mx-auto h-4 w-64 rounded bg-gray-200' />
</div>
</div>
)

View File

@@ -1,204 +1,26 @@
'use server'
import { createLogger } from '@/lib/logs/console/logger'
import { env } from '@/lib/env'
const DEFAULT_STARS = '15k'
/**
* Format a number to a human-readable format (e.g., 1000 -> 1k, 1100 -> 1.1k)
*/
function formatNumber(num: number): string {
if (num < 1000) {
return num.toString()
}
const logger = createLogger('GitHubStars')
const formatted = (Math.round(num / 100) / 10).toFixed(1)
return formatted.endsWith('.0') ? `${formatted.slice(0, -2)}k` : `${formatted}k`
}
/**
* Server action to fetch GitHub stars
*/
export async function getFormattedGitHubStars(): Promise<string> {
try {
const token = env.GITHUB_TOKEN
const response = await fetch('https://api.github.com/repos/simstudioai/sim', {
const response = await fetch('/api/github-stars', {
headers: {
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'User-Agent': 'SimStudio/1.0',
...(token ? { Authorization: `Bearer ${token}` } : {}),
'Cache-Control': 'max-age=3600', // Cache for 1 hour
},
next: { revalidate: 3600 },
})
if (!response.ok) {
console.error(`GitHub API error: ${response.status} ${response.statusText}`)
return formatNumber(3867)
logger.warn('Failed to fetch GitHub stars from API')
return DEFAULT_STARS
}
const data = await response.json()
return formatNumber(data.stargazers_count || 3867)
return data.stars || DEFAULT_STARS
} catch (error) {
console.error('Error fetching GitHub stars:', error)
return formatNumber(3867)
}
}
interface Contributor {
login: string
avatar_url: string
contributions: number
html_url: string
}
interface CommitData {
sha: string
commit: {
author: {
name: string
email: string
date: string
}
message: string
}
html_url: string
}
interface RepoStats {
stars: number
forks: number
watchers: number
openIssues: number
openPRs: number
}
/**
* Server action to fetch repository statistics
*/
export async function getRepositoryStats(): Promise<RepoStats> {
try {
const token = env.GITHUB_TOKEN
const headers = {
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'User-Agent': 'SimStudio/1.0',
...(token ? { Authorization: `Bearer ${token}` } : {}),
}
const repoResponse = await fetch('https://api.github.com/repos/simstudioai/sim', {
headers,
next: { revalidate: 3600 },
})
const prsResponse = await fetch(
'https://api.github.com/repos/simstudioai/sim/pulls?state=open',
{
headers,
next: { revalidate: 3600 },
}
)
if (!repoResponse.ok || !prsResponse.ok) {
console.error('GitHub API error fetching repo stats')
return {
stars: 3867,
forks: 581,
watchers: 26,
openIssues: 23,
openPRs: 3,
}
}
const repoData = await repoResponse.json()
const prsData = await prsResponse.json()
return {
stars: repoData.stargazers_count || 3867,
forks: repoData.forks_count || 581,
watchers: repoData.subscribers_count || 26,
openIssues: (repoData.open_issues_count || 26) - prsData.length,
openPRs: prsData.length || 3,
}
} catch (error) {
console.error('Error fetching repository stats:', error)
return {
stars: 3867,
forks: 581,
watchers: 26,
openIssues: 23,
openPRs: 3,
}
}
}
/**
* Server action to fetch contributors
*/
export async function getContributors(): Promise<Contributor[]> {
try {
const token = env.GITHUB_TOKEN
const headers = {
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'User-Agent': 'SimStudio/1.0',
...(token ? { Authorization: `Bearer ${token}` } : {}),
}
const response = await fetch(
'https://api.github.com/repos/simstudioai/sim/contributors?per_page=100',
{
headers,
next: { revalidate: 3600 },
}
)
if (!response.ok) {
console.error('GitHub API error fetching contributors')
return []
}
const contributors = await response.json()
return contributors || []
} catch (error) {
console.error('Error fetching contributors:', error)
return []
}
}
/**
* Server action to fetch recent commits for timeline data
*/
export async function getCommitsData(): Promise<CommitData[]> {
try {
const token = env.GITHUB_TOKEN
const headers = {
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'User-Agent': 'SimStudio/1.0',
...(token ? { Authorization: `Bearer ${token}` } : {}),
}
const response = await fetch(
'https://api.github.com/repos/simstudioai/sim/commits?per_page=100',
{
headers,
next: { revalidate: 3600 },
}
)
if (!response.ok) {
console.error('GitHub API error fetching commits')
return []
}
const commits = await response.json()
return commits || []
} catch (error) {
console.error('Error fetching commits:', error)
return []
logger.warn('Error fetching GitHub stars:', error)
return DEFAULT_STARS
}
}

View File

@@ -0,0 +1,136 @@
export default function BackgroundSVG() {
return (
<svg
aria-hidden='true'
focusable='false'
className='-translate-x-1/2 pointer-events-none absolute top-0 left-1/2 z-10 hidden h-full min-h-full w-[1308px] sm:block'
width='1308'
height='4942'
viewBox='0 18 1308 4066'
fill='none'
xmlns='http://www.w3.org/2000/svg'
preserveAspectRatio='xMidYMin slice'
>
{/* Pricing section (original height ~380 units) */}
<path d='M6.71704 1236.22H1300.76' stroke='#E7E4EF' strokeWidth='2' />
<circle cx='11.0557' cy='1236.48' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
<circle cx='1298.02' cy='1236.48' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
<path d='M10.7967 1245.42V1613.91' stroke='#E7E4EF' strokeWidth='2' />
<path d='M1297.76 1245.96V1613.91' stroke='#E7E4EF' strokeWidth='2' />
{/* Integrations section (original height ~412 units) */}
<path d='M6.71704 1614.89H1291.05' stroke='#E7E4EF' strokeWidth='2' />
<circle cx='11.0557' cy='1615.15' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
<circle cx='1298.02' cy='1615.15' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
<path d='M10.7967 1624.61V2026.93' stroke='#E7E4EF' strokeWidth='2' />
<path d='M1297.76 1624.61V2026.93' stroke='#E7E4EF' strokeWidth='2' />
{/* Testimonials section (original short height ~149 units) */}
<path d='M6.71704 2026.71H1300.76' stroke='#E7E4EF' strokeWidth='2' />
<circle cx='11.0557' cy='2026.97' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
<circle cx='1298.02' cy='2026.97' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
<path d='M10.7967 2036.43V2177.43' stroke='#E7E4EF' strokeWidth='2' />
<path d='M1297.76 2036.43V2177.43' stroke='#E7E4EF' strokeWidth='2' />
{/* Footer section line */}
<path d='M6.71704 2177.71H1300.76' stroke='#E7E4EF' strokeWidth='2' />
<circle cx='11.0557' cy='2177.97' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
<circle cx='1298.02' cy='2177.97' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
<path d='M10.7967 2187.43V4090.25' stroke='#E7E4EF' strokeWidth='2' />
<path d='M1297.76 2187.43V4090.25' stroke='#E7E4EF' strokeWidth='2' />
<path
d='M959.828 116.604C1064.72 187.189 1162.61 277.541 1293.45 536.597'
stroke='#E7E4EF'
strokeWidth='1.90903'
/>
<path d='M1118.77 612.174V88' stroke='#E7E4EF' strokeWidth='1.90903' />
<path d='M1261.95 481.414L1289.13 481.533' stroke='#E7E4EF' strokeWidth='1.90903' />
<path d='M960 109.049V88' stroke='#E7E4EF' strokeWidth='1.90903' />
<circle
cx='960.214'
cy='115.214'
r='6.25942'
transform='rotate(90 960.214 115.214)'
fill='white'
stroke='#E7E4EF'
strokeWidth='1.90903'
/>
<circle
cx='1119.21'
cy='258.214'
r='6.25942'
transform='rotate(90 1119.21 258.214)'
fill='white'
stroke='#E7E4EF'
strokeWidth='1.90903'
/>
<circle
cx='1265.19'
cy='481.414'
r='6.25942'
transform='rotate(90 1265.19 481.414)'
fill='white'
stroke='#E7E4EF'
strokeWidth='1.90903'
/>
<path
d='M77 179C225.501 165.887 294.438 145.674 390 85'
stroke='#E7E4EF'
strokeWidth='1.90903'
/>
<path d='M214.855 521.491L215 75' stroke='#E7E4EF' strokeWidth='1.90903' />
<path
d='M76.6567 381.124C177.305 448.638 213.216 499.483 240.767 613.253'
stroke='#E7E4EF'
strokeWidth='1.90903'
/>
<path d='M76.5203 175.703V613.253' stroke='#E7E4EF' strokeWidth='1.90903' />
<path d='M1.07967 179.225L76.6567 179.225' stroke='#E7E4EF' strokeWidth='1.90903' />
<circle
cx='76.3128'
cy='178.882'
r='6.25942'
transform='rotate(90 76.3128 178.882)'
fill='white'
stroke='#E7E4EF'
strokeWidth='1.90903'
/>
<circle
cx='214.511'
cy='528.695'
r='6.25942'
transform='rotate(90 214.511 528.695)'
fill='white'
stroke='#E7E4EF'
strokeWidth='1.90903'
/>
<circle
cx='76.3129'
cy='380.78'
r='6.25942'
transform='rotate(90 76.3129 380.78)'
fill='white'
stroke='#E7E4EF'
strokeWidth='1.90903'
/>
<path d='M10.7967 18V1226.51' stroke='#E7E4EF' strokeWidth='2' />
<path d='M1297.76 18V1227.59' stroke='#E7E4EF' strokeWidth='2' />
<path d='M6.71704 78.533H1300.76' stroke='#E7E4EF' strokeWidth='2' />
<circle cx='10.7967' cy='78.792' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
<circle cx='214.976' cy='78.9761' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
<circle cx='396.976' cy='78.9761' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
<circle cx='1298.02' cy='78.792' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
<circle cx='1118.98' cy='78.9761' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
<circle cx='959.976' cy='78.9761' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
<path d='M16.4341 620.811H1292.13' stroke='#E7E4EF' strokeWidth='2' />
<circle cx='11.0557' cy='621.07' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
<circle cx='76.3758' cy='621.07' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
<circle cx='244.805' cy='621.07' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
<circle cx='10.7967' cy='178.405' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
<circle cx='1298.02' cy='621.07' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
<circle cx='1119.23' cy='621.07' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
<circle cx='1298.02' cy='481.253' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
<circle cx='1298.02' cy='541.714' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
</svg>
)
}

View File

@@ -0,0 +1,22 @@
import dynamic from 'next/dynamic'
import { cn } from '@/lib/utils'
// Lazy load the SVG to reduce initial bundle size
const BackgroundSVG = dynamic(() => import('./background-svg'), {
ssr: true, // Enable SSR for SEO
loading: () => null, // Don't show loading state
})
type BackgroundProps = {
className?: string
children?: React.ReactNode
}
export default function Background({ className, children }: BackgroundProps) {
return (
<div className={cn('relative min-h-screen w-full', className)}>
<BackgroundSVG />
<div className='relative z-0 mx-auto w-full max-w-[1308px]'>{children}</div>
</div>
)
}

View File

@@ -1,98 +0,0 @@
import Image from 'next/image'
import Link from 'next/link'
type BlogCardProps = {
href: string
title: string
description?: string
date?: Date
avatar?: string
author: string
authorRole?: string
type: string
readTime?: string
image?: string
}
const blogConfig = {
agents: '#802efc',
functions: '#FC2E31',
workflows: '#2E8CFC',
// ADD MORE
}
export const BlogCard = ({
href,
image,
title,
description,
date,
avatar,
author,
authorRole,
type,
readTime,
}: BlogCardProps) => {
return (
<Link href={href}>
<div className='flex flex-col rounded-3xl border border-[#606060]/40 bg-[#101010] p-8 transition-all duration-500 hover:bg-[var(--surface-elevated)]'>
{image ? (
<Image
src={image}
alt='Image'
width={2000}
height={2000}
className='aspect-video h-max w-full rounded-xl'
/>
) : (
<></>
)}
{date ? (
<p className='pb-5 font-light text-[#BBBBBB]/70 text-base tracking-tight'>
{date.toLocaleString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}
</p>
) : (
<></>
)}
<div className='flex flex-col gap-6'>
<p className='max-w-96 font-medium text-2xl text-white/80 leading-[1.2] tracking-normal lg:text-3xl'>
{title}
</p>
<p className='font-light text-lg text-white/60 leading-[1.5]'>{description}</p>
</div>
<div className='flex flex-col gap-6 pt-16'>
<div className='flex items-center gap-4'>
{avatar ? (
<Image
src={avatar}
alt='Avatar'
width={64}
height={64}
className='h-16 w-16 rounded-full'
/>
) : (
<></>
)}
<div className='flex flex-col gap-0'>
<p className='font-medium text-white/90 text-xl'>{author}</p>
<p className='font-normal text-base text-white/60'>{authorRole}</p>
</div>
</div>
<div className='flex items-center gap-5'>
<div
className='rounded-lg px-2 py-1'
style={{
background: blogConfig[type.toLowerCase() as keyof typeof blogConfig] ?? '#333',
}}
>
<p className='font-light text-base text-white'>{type}</p>
</div>
<p className='font-light text-base text-white/60'>{readTime} min-read</p>
</div>
</div>
</div>
</Link>
)
}

View File

@@ -0,0 +1,408 @@
import Image from 'next/image'
import Link from 'next/link'
import {
DiscordIcon,
GithubIcon,
HIPAABadgeIcon,
LinkedInIcon,
xIcon as XIcon,
} from '@/components/icons'
import { inter } from '@/app/fonts/inter'
const blocks = [
'Agent',
'API',
'Condition',
'Evaluator',
'Function',
'Loop',
'Parallel',
'Response',
'Router',
'Starter',
'Webhook',
'Workflow',
]
const tools = [
'Airtable',
'ArXiv',
'Browser Use',
'Clay',
'Confluence',
'Discord',
'ElevenLabs',
'Exa',
'File',
'Firecrawl',
'Generic Webhook',
'GitHub',
'Gmail',
'Google Calendar',
'Google Docs',
'Google Drive',
'Google Search',
'Google Sheets',
'HuggingFace',
'Hunter',
'Image Generator',
'Jina',
'Jira',
'Knowledge',
'Linear',
'LinkUp',
'Mem0',
'Memory',
'Microsoft Excel',
'Microsoft Planner',
'Microsoft Teams',
'Mistral Parse',
'MySQL',
'Notion',
'OneDrive',
'OpenAI',
'Outlook',
'Parallel AI',
'Perplexity',
'Pinecone',
'PostgreSQL',
'Qdrant',
'Reddit',
'S3',
'Schedule',
'Serper',
'SharePoint',
'Slack',
'Stagehand',
'Stagehand Agent',
'Supabase',
'Tavily',
'Telegram',
'Thinking',
'Translate',
'Twilio SMS',
'Typeform',
'Vision',
'Wealthbox',
'Webhook',
'WhatsApp',
'Wikipedia',
'X',
'YouTube',
]
interface FooterProps {
fullWidth?: boolean
}
export default function Footer({ fullWidth = false }: FooterProps) {
return (
<footer className={`${inter.className} relative w-full overflow-hidden bg-white`}>
<div
className={
fullWidth
? 'px-4 pt-[40px] pb-[40px] sm:px-4 sm:pt-[34px] sm:pb-[340px]'
: 'px-4 pt-[40px] pb-[40px] sm:px-[50px] sm:pt-[34px] sm:pb-[340px]'
}
>
<div className={`flex gap-[80px] ${fullWidth ? 'justify-center' : ''}`}>
{/* Logo and social links */}
<div className='flex flex-col gap-[24px]'>
<Link href='/' aria-label='Sim home'>
<Image
src='/logo/b&w/text/b&w.svg'
alt='Sim - Workflows for LLMs'
width={49.78314}
height={24.276}
priority
quality={90}
/>
</Link>
{/* Social links */}
<div className='flex items-center gap-[12px]'>
<a
href='https://discord.gg/Hr4UWYEcTT'
target='_blank'
rel='noopener noreferrer'
className='flex items-center text-[16px] text-muted-foreground transition-colors hover:text-foreground'
aria-label='Discord'
>
<DiscordIcon className='h-[20px] w-[20px]' aria-hidden='true' />
</a>
<a
href='https://x.com/simdotai'
target='_blank'
rel='noopener noreferrer'
className='flex items-center text-[16px] text-muted-foreground transition-colors hover:text-foreground'
aria-label='X (Twitter)'
>
<XIcon className='h-[18px] w-[18px]' aria-hidden='true' />
</a>
<a
href='https://www.linkedin.com/company/simstudioai/'
target='_blank'
rel='noopener noreferrer'
className='flex items-center text-[16px] text-muted-foreground transition-colors hover:text-foreground'
aria-label='LinkedIn'
>
<LinkedInIcon className='h-[18px] w-[18px]' aria-hidden='true' />
</a>
<a
href='https://github.com/simstudioai/sim'
target='_blank'
rel='noopener noreferrer'
className='flex items-center text-[16px] text-muted-foreground transition-colors hover:text-foreground'
aria-label='GitHub'
>
<GithubIcon className='h-[20px] w-[20px]' aria-hidden='true' />
</a>
</div>
{/* Compliance badges */}
<div className='mt-[6px] flex items-center gap-[12px]'>
{/* SOC2 badge */}
<Link
href='https://trust.delve.co/sim-studio'
target='_blank'
rel='noopener noreferrer'
>
<Image
src='/footer/soc2.png'
alt='SOC2 Compliant'
width={54}
height={54}
className='object-contain'
loading='lazy'
quality={75}
/>
</Link>
{/* HIPAA badge placeholder - add when available */}
<Link
href='https://trust.delve.co/sim-studio'
target='_blank'
rel='noopener noreferrer'
>
<HIPAABadgeIcon className='h-[54px] w-[54px]' />
</Link>
</div>
</div>
{/* Links section */}
<div>
<h2 className='mb-[16px] font-medium text-[14px] text-foreground'>More Sim</h2>
<div className='flex flex-col gap-[12px]'>
<Link
href='https://docs.sim.ai'
target='_blank'
rel='noopener noreferrer'
className='text-[14px] text-muted-foreground transition-colors hover:text-foreground'
>
Docs
</Link>
<Link
href='#pricing'
className='text-[14px] text-muted-foreground transition-colors hover:text-foreground'
>
Pricing
</Link>
<Link
href='https://form.typeform.com/to/jqCO12pF'
target='_blank'
rel='noopener noreferrer'
className='text-[14px] text-muted-foreground transition-colors hover:text-foreground'
>
Enterprise
</Link>
<Link
href='/privacy'
target='_blank'
rel='noopener noreferrer'
className='text-[14px] text-muted-foreground transition-colors hover:text-foreground'
>
Privacy Policy
</Link>
<Link
href='/terms'
target='_blank'
rel='noopener noreferrer'
className='text-[14px] text-muted-foreground transition-colors hover:text-foreground'
>
Terms of Service
</Link>
</div>
</div>
{/* Blocks section */}
<div className='hidden sm:block'>
<h2 className='mb-[16px] font-medium text-[14px] text-foreground'>Blocks</h2>
<div className='flex flex-col gap-[12px]'>
{blocks.map((block) => (
<Link
key={block}
href={`https://docs.sim.ai/blocks/${block.toLowerCase().replace(' ', '-')}`}
target='_blank'
rel='noopener noreferrer'
className='text-[14px] text-muted-foreground transition-colors hover:text-foreground'
>
{block}
</Link>
))}
</div>
</div>
{/* Tools section - split into columns */}
<div className='hidden sm:block'>
<h2 className='mb-[16px] font-medium text-[14px] text-foreground'>Tools</h2>
<div className='flex gap-[80px]'>
{/* First column */}
<div className='flex flex-col gap-[12px]'>
{tools.slice(0, Math.ceil(tools.length / 4)).map((tool) => (
<Link
key={tool}
href={`https://docs.sim.ai/tools/${tool.toLowerCase().replace(/\s+/g, '_')}`}
target='_blank'
rel='noopener noreferrer'
className='whitespace-nowrap text-[14px] text-muted-foreground transition-colors hover:text-foreground'
>
{tool}
</Link>
))}
</div>
{/* Second column */}
<div className='flex flex-col gap-[12px]'>
{tools
.slice(Math.ceil(tools.length / 4), Math.ceil((tools.length * 2) / 4))
.map((tool) => (
<Link
key={tool}
href={`https://docs.sim.ai/tools/${tool.toLowerCase().replace(/\s+/g, '_')}`}
target='_blank'
rel='noopener noreferrer'
className='whitespace-nowrap text-[14px] text-muted-foreground transition-colors hover:text-foreground'
>
{tool}
</Link>
))}
</div>
{/* Third column */}
<div className='flex flex-col gap-[12px]'>
{tools
.slice(Math.ceil((tools.length * 2) / 4), Math.ceil((tools.length * 3) / 4))
.map((tool) => (
<Link
key={tool}
href={`https://docs.sim.ai/tools/${tool.toLowerCase().replace(/\s+/g, '_')}`}
target='_blank'
rel='noopener noreferrer'
className='whitespace-nowrap text-[14px] text-muted-foreground transition-colors hover:text-foreground'
>
{tool}
</Link>
))}
</div>
{/* Fourth column */}
<div className='flex flex-col gap-[12px]'>
{tools.slice(Math.ceil((tools.length * 3) / 4)).map((tool) => (
<Link
key={tool}
href={`https://docs.sim.ai/tools/${tool.toLowerCase().replace(/\s+/g, '_')}`}
target='_blank'
rel='noopener noreferrer'
className='whitespace-nowrap text-[14px] text-muted-foreground transition-colors hover:text-foreground'
>
{tool}
</Link>
))}
</div>
</div>
</div>
</div>
</div>
{/* Large SIM logo at bottom - half cut off */}
<div className='-translate-x-1/2 pointer-events-none absolute bottom-[-240px] left-1/2 hidden sm:block'>
<svg
xmlns='http://www.w3.org/2000/svg'
width='1128'
height='550'
viewBox='0 0 1128 550'
fill='none'
>
<g filter='url(#filter0_dd_122_4989)'>
<path
d='M3 420.942H77.9115C77.9115 441.473 85.4027 457.843 100.385 470.051C115.367 481.704 135.621 487.53 161.147 487.53C188.892 487.53 210.255 482.258 225.238 471.715C240.22 460.617 247.711 445.913 247.711 427.601C247.711 414.283 243.549 403.185 235.226 394.307C227.457 385.428 213.03 378.215 191.943 372.666L120.361 356.019C84.2929 347.14 57.3802 333.545 39.6234 315.234C22.4215 296.922 13.8206 272.784 13.8206 242.819C13.8206 217.849 20.2019 196.208 32.9646 177.896C46.2822 159.584 64.3165 145.434 87.0674 135.446C110.373 125.458 137.008 120.464 166.973 120.464C196.938 120.464 222.74 125.735 244.382 136.278C266.578 146.821 283.779 161.526 295.987 180.393C308.75 199.259 315.409 221.733 315.964 247.813H241.052C240.497 226.727 233.561 210.357 220.243 198.705C206.926 187.052 188.337 181.225 164.476 181.225C140.06 181.225 121.194 186.497 107.876 197.04C94.5585 207.583 87.8997 222.01 87.8997 240.322C87.8997 267.512 107.876 286.101 147.829 296.09L219.411 313.569C253.815 321.337 279.618 334.1 296.82 351.857C314.022 369.059 322.622 392.642 322.622 422.607C322.622 448.132 315.686 470.606 301.814 490.027C287.941 508.894 268.797 523.599 244.382 534.142C220.521 544.13 192.221 549.124 159.482 549.124C111.76 549.124 73.7498 537.471 45.4499 514.165C17.15 490.86 3 459.785 3 420.942Z'
fill='#DCDCDC'
/>
<path
d='M377.713 539.136V132.117C408.911 143.439 422.667 143.439 455.954 132.117V539.136H377.713ZM416.001 105.211C402.129 105.211 389.921 100.217 379.378 90.2291C369.39 79.686 364.395 67.4782 364.395 53.6057C364.395 39.1783 369.39 26.9705 379.378 16.9823C389.921 6.9941 402.129 2 416.001 2C430.428 2 442.636 6.9941 452.625 16.9823C462.613 26.9705 467.607 39.1783 467.607 53.6057C467.607 67.4782 462.613 79.686 452.625 90.2291C442.636 100.217 430.428 105.211 416.001 105.211Z'
fill='#DCDCDC'
/>
<path
d='M593.961 539.136H515.72V132.117H585.637V200.792C593.961 178.041 610.053 158.752 632.249 143.769C655 128.232 682.467 120.464 714.651 120.464C750.72 120.464 780.685 130.174 804.545 149.596C822.01 163.812 835.016 181.446 843.562 202.5C851.434 181.446 864.509 163.812 882.786 149.596C907.757 130.174 938.554 120.464 975.177 120.464C1021.79 120.464 1058.41 134.059 1085.05 161.249C1111.68 188.439 1125 225.617 1125 272.784V539.136H1048.42V291.928C1048.42 259.744 1040.1 235.051 1023.45 217.849C1007.36 200.092 985.443 191.213 957.698 191.213C938.276 191.213 921.074 195.653 906.092 204.531C891.665 212.855 880.289 225.062 871.966 241.154C863.642 257.247 859.48 276.113 859.48 297.754V539.136H782.072V291.095C782.072 258.911 774.026 234.496 757.934 217.849C741.841 200.647 719.923 192.046 692.178 192.046C672.756 192.046 655.555 196.485 640.572 205.363C626.145 213.687 614.769 225.895 606.446 241.987C598.122 257.524 593.961 276.113 593.961 297.754V539.136Z'
fill='#DCDCDC'
/>
<path
d='M166.973 121.105C196.396 121.105 221.761 126.201 243.088 136.367L244.101 136.855L244.106 136.858C265.86 147.191 282.776 161.528 294.876 179.865L295.448 180.741L295.455 180.753C308.032 199.345 314.656 221.475 315.306 247.171H241.675C240.996 226.243 234.012 209.899 220.666 198.222C207.196 186.435 188.437 180.583 164.476 180.583C139.977 180.583 120.949 185.871 107.478 196.536C93.9928 207.212 87.2578 221.832 87.2578 240.322C87.2579 254.096 92.3262 265.711 102.444 275.127C112.542 284.524 127.641 291.704 147.673 296.712L147.677 296.713L219.259 314.192L219.27 314.195C253.065 321.827 278.469 334.271 295.552 351.48L296.358 352.304L296.365 352.311C313.42 369.365 321.98 392.77 321.98 422.606C321.98 448.005 315.082 470.343 301.297 489.646C287.502 508.408 268.456 523.046 244.134 533.55C220.369 543.498 192.157 548.482 159.481 548.482C111.864 548.482 74.0124 536.855 45.8584 513.67C17.8723 490.623 3.80059 459.948 3.64551 421.584H77.2734C77.4285 441.995 84.9939 458.338 99.9795 470.549L99.9854 470.553L99.9912 470.558C115.12 482.324 135.527 488.172 161.146 488.172C188.96 488.172 210.474 482.889 225.607 472.24L225.613 472.236L225.619 472.231C240.761 461.015 248.353 446.12 248.353 427.601C248.352 414.145 244.145 402.89 235.709 393.884C227.81 384.857 213.226 377.603 192.106 372.045L192.098 372.043L192.089 372.04L120.507 355.394C84.5136 346.533 57.7326 332.983 40.0908 314.794H40.0918C23.0227 296.624 14.4629 272.654 14.4629 242.819C14.4629 217.969 20.8095 196.463 33.4834 178.273C46.7277 160.063 64.6681 145.981 87.3252 136.034L87.3242 136.033C110.536 126.086 137.081 121.106 166.973 121.105ZM975.177 121.105C1021.66 121.105 1058.1 134.658 1084.59 161.698C1111.08 188.741 1124.36 225.743 1124.36 272.784V538.494H1049.07V291.928C1049.07 259.636 1040.71 234.76 1023.92 217.402H1023.91C1007.68 199.5 985.584 190.571 957.697 190.571C938.177 190.571 920.862 195.034 905.771 203.975C891.228 212.365 879.77 224.668 871.396 240.859C863.017 257.059 858.838 276.03 858.838 297.754V538.494H782.714V291.096C782.714 258.811 774.641 234.209 758.395 217.402C742.16 200.053 720.062 191.404 692.178 191.404C673.265 191.404 656.422 195.592 641.666 203.985L640.251 204.808C625.711 213.196 614.254 225.497 605.88 241.684C597.496 257.333 593.318 276.031 593.318 297.754V538.494H516.361V132.759H584.995V200.792L586.24 201.013C594.51 178.408 610.505 159.221 632.607 144.302L632.61 144.3C655.238 128.847 682.574 121.105 714.651 121.105C750.599 121.105 780.413 130.781 804.14 150.094C821.52 164.241 834.461 181.787 842.967 202.741L843.587 204.268L844.163 202.725C851.992 181.786 864.994 164.248 883.181 150.103C908.021 130.782 938.673 121.106 975.177 121.105ZM455.312 538.494H378.354V133.027C393.534 138.491 404.652 141.251 416.05 141.251C427.46 141.251 439.095 138.485 455.312 133.009V538.494ZM416.001 2.6416C430.262 2.6416 442.306 7.57157 452.171 17.4365C462.036 27.3014 466.965 39.3445 466.965 53.6055C466.965 67.3043 462.04 79.3548 452.16 89.7842C442.297 99.6427 430.258 104.569 416.001 104.569C402.303 104.569 390.254 99.6452 379.825 89.7676C369.957 79.3421 365.037 67.2967 365.037 53.6055C365.037 39.3444 369.966 27.3005 379.831 17.4355C390.258 7.56247 402.307 2.64163 416.001 2.6416Z'
stroke='#C1C1C1'
strokeWidth='1.28396'
/>
</g>
<defs>
<filter
id='filter0_dd_122_4989'
x='0'
y='0'
width='1128'
height='550'
filterUnits='userSpaceOnUse'
colorInterpolationFilters='sRGB'
>
<feFlood floodOpacity='0' result='BackgroundImageFix' />
<feColorMatrix
in='SourceAlpha'
type='matrix'
values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0'
result='hardAlpha'
/>
<feMorphology
radius='1'
operator='erode'
in='SourceAlpha'
result='effect1_dropShadow_122_4989'
/>
<feOffset dy='1' />
<feGaussianBlur stdDeviation='1' />
<feColorMatrix type='matrix' values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0' />
<feBlend
mode='normal'
in2='BackgroundImageFix'
result='effect1_dropShadow_122_4989'
/>
<feColorMatrix
in='SourceAlpha'
type='matrix'
values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0'
result='hardAlpha'
/>
<feOffset dy='1' />
<feGaussianBlur stdDeviation='1.5' />
<feColorMatrix type='matrix' values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0' />
<feBlend
mode='normal'
in2='effect1_dropShadow_122_4989'
result='effect2_dropShadow_122_4989'
/>
<feBlend
mode='normal'
in='SourceGraphic'
in2='effect2_dropShadow_122_4989'
result='shape'
/>
</filter>
</defs>
</svg>
</div>
</footer>
)
}

View File

@@ -1,26 +0,0 @@
'use client'
import { motion } from 'framer-motion'
import { GithubIcon } from '@/components/icons'
interface GitHubStarsClientProps {
stars: string
}
export default function GitHubStarsClient({ stars }: GitHubStarsClientProps) {
return (
<motion.a
href='https://github.com/simstudioai/sim'
className='flex items-center gap-2 rounded-md p-1.5 text-white/80 transition-colors duration-200 hover:text-white/100'
aria-label='GitHub'
target='_blank'
rel='noopener noreferrer'
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: 'easeOut', delay: 0.3 }}
>
<GithubIcon className='h-[20px] w-[20px]' />
<span className='font-medium text-base'>{stars}</span>
</motion.a>
)
}

View File

@@ -1,61 +0,0 @@
import { GithubIcon } from '@/components/icons'
import { env } from '@/lib/env'
/**
* Format a number to a human-readable format (e.g., 1000 -> 1k, 1100 -> 1.1k)
*/
function formatNumber(num: number): string {
if (num < 1000) {
return num.toString()
}
// Convert to one decimal place and remove trailing 0
const formatted = (Math.round(num / 100) / 10).toFixed(1)
// Remove .0 if the decimal is 0
return formatted.endsWith('.0') ? `${formatted.slice(0, -2)}k` : `${formatted}k`
}
async function getGitHubStars() {
const token = env.GITHUB_TOKEN
try {
const response = await fetch('https://api.github.com/repos/simstudioai/sim', {
headers: {
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
next: { revalidate: 3600 }, // Revalidate every hour
})
if (!response.ok) {
// Return current stars if API fails, we don't want to break the UI
return 1200
}
const data = await response.json()
return data.stargazers_count
} catch (error) {
console.error('Error fetching GitHub stars:', error)
return 1200
}
}
export default async function GitHubStars() {
const stars = await getGitHubStars()
const formattedStars = formatNumber(stars)
return (
<a
href='https://github.com/simstudioai/sim'
className='flex items-center gap-2 rounded-md p-1.5 text-white/80 transition-colors duration-200 hover:text-white/100'
aria-label='GitHub'
target='_blank'
rel='noopener noreferrer'
>
<GithubIcon className='h-[20px] w-[20px]' />
<span className='font-medium text-base'>{formattedStars}</span>
</a>
)
}

View File

@@ -1,58 +0,0 @@
import { useId } from 'react'
import { cn } from '@/lib/utils'
interface GridPatternProps extends React.SVGProps<SVGSVGElement> {
width?: number
height?: number
x?: number
y?: number
squares?: Array<[x: number, y: number]>
strokeDasharray?: string
className?: string
[key: string]: unknown
}
export function GridPattern({
width = 40,
height = 40,
x = -1,
y = -1,
strokeDasharray = '0',
squares,
className,
...props
}: GridPatternProps) {
const id = useId()
return (
<svg
aria-hidden='true'
className={cn(
'pointer-events-none absolute inset-0 h-full w-full fill-gray-400/30 stroke-gray-400/30',
className
)}
{...props}
>
<defs>
<pattern id={id} width={width} height={height} patternUnits='userSpaceOnUse' x={x} y={y}>
<path d={`M.5 ${height}V.5H${width}`} fill='none' strokeDasharray={strokeDasharray} />
</pattern>
</defs>
<rect width='100%' height='100%' strokeWidth={0} fill={`url(#${id})`} />
{squares && (
<svg x={x} y={y} className='overflow-visible'>
{squares.map(([x, y]) => (
<rect
strokeWidth='0'
key={`${x}-${y}`}
width={width - 1}
height={height - 1}
x={x * width + 1}
y={y * height + 1}
/>
))}
</svg>
)}
</svg>
)
}

View File

@@ -1,245 +0,0 @@
// Assuming custom icons exist for Sim specific things, otherwise use Lucide
import type React from 'react'
import { memo } from 'react'
import {
// For header icon
ChevronDown,
CodeXml,
// For Add Tool button
PlusIcon,
} from 'lucide-react'
import { Handle, type NodeProps, Position } from 'reactflow'
import { AgentIcon, ConnectIcon, SlackIcon, StartIcon } from '@/components/icons'
import { CodeBlock } from '@/components/ui/code-block'
import { cn } from '@/lib/utils'
// Removed DotPattern import
// Configuration for the new block types based on the image
const blockConfig = {
start: {
icon: StartIcon, // Assuming a custom StartIcon
color: '#2563eb', // Blue
name: 'Start',
},
function: {
icon: CodeXml,
color: '#e11d48', // Red
name: 'Function 1',
},
agent: {
icon: AgentIcon, // Assuming custom AgentIcon
color: '#9333ea', // Purple
name: 'Agent 1',
},
router: {
icon: ConnectIcon, // Assuming custom ConnectIcon
color: '#16a34a', // Green
name: 'Router 1',
},
slack: {
icon: SlackIcon, // Assuming custom SlackIcon
color: '#611F69', // Slack-like color (adjust if needed)
name: 'Slack 1',
},
}
export const HeroBlock = memo(({ id, data }: NodeProps) => {
const type = data.type as keyof typeof blockConfig
const config = blockConfig[type] || blockConfig.function
const Icon = config.icon
const nodeName = config.name
const iconBgColor = config.color // Get color from config
const _horizontalHandles = true // Default to horizontal handles like in workflow-block
// Determine if we should show the input handle
// Don't show for start blocks, function1 in hero section, or id=function1
const showInputHandle =
type !== 'start' && !(type === 'function' && id === 'function1' && data.isHeroSection)
return (
// Apply group relative here for handles
<div className='group relative flex flex-col items-center opacity-90'>
{/* Don't show input handle for starter blocks or function1 */}
{showInputHandle && (
<Handle
type='target'
position={Position.Left}
id='target'
className={cn(
'!w-[7px] !h-5',
'!bg-slate-300 dark:!bg-slate-500 !rounded-[2px] !border-none',
'!z-[1000]',
'!opacity-100',
'!left-[-7px]'
)}
style={{
top: '50%',
transform: 'translateY(-50%)',
}}
data-nodeid={id}
data-handleid='target'
isConnectable={true}
/>
)}
{/* Use BlockCard, passing Icon, title, and iconBgColor */}
<BlockCard Icon={Icon} iconBgColor={iconBgColor} title={nodeName}>
{/* Render type-specific content as children */}
<div className='space-y-3 pt-3 text-sm'>
{/* --- Start Block Content --- */}
{type === 'start' && (
<>
<div className='font-medium text-[#7D7D7D] text-base'>Start workflow</div>
<Container>
<p>Run Manually</p>
<ChevronDown size={14} />
</Container>
</>
)}
{/* --- Function Block Content --- */}
{type === 'function' && (
<div className='flex items-center gap-1 font-medium text-neutral-400 text-xs'>
<CodeBlock
code='Write javascript..'
className='min-h-32 w-full border-[#282828] bg-[#212121] p-0 font-geist-mono text-[#7C7C7C]'
/>
</div>
)}
{/* --- Agent Block Content --- */}
{type === 'agent' && (
<div className='flex flex-col gap-4'>
<div className='flex flex-col gap-2'>
<p className='font-medium text-[#7D7D7D] text-base'>Agent</p>
<Container>Enter System Prompt</Container>
</div>
<div className='flex flex-col gap-2'>
<p className='font-medium text-[#7D7D7D] text-base'>User Prompts</p>
<Container>Enter Context</Container>
</div>
<div className='flex w-full gap-3'>
<div className='flex w-full flex-col gap-2'>
<p className='font-medium text-[#7D7D7D] text-base'>Model</p>
<Container>
<p>GPT-4o</p>
<ChevronDown size={14} />
</Container>
</div>
<div className='flex w-full flex-col gap-2'>
<p className='font-medium text-[#7D7D7D] text-base'>Tools</p>
<Container className='justify-center gap-1'>
<PlusIcon size={14} />
Add Tools
</Container>
</div>
</div>
</div>
)}
{/* --- Router Block Content --- */}
{type === 'router' && (
<div className='flex flex-col gap-4'>
<div className='flex flex-col gap-2'>
<p className='font-medium text-[#7D7D7D] text-base'>Prompt</p>
<Container className='min-h-32 items-start'>Enter Prompt</Container>
</div>
<div className='flex flex-col gap-2'>
<p className='font-medium text-[#7D7D7D] text-base'>Model</p>
<Container>
<p>GPT-4o</p>
<ChevronDown size={14} />
</Container>
</div>
</div>
)}
{/* --- Slack Block Content --- */}
{type === 'slack' && (
<div className='flex flex-col gap-4'>
<div className='flex flex-col gap-2'>
<p className='font-medium text-[#7D7D7D] text-base'>Channel</p>
<Container>Enter Slack channel (#general)</Container>
</div>
<div className='flex flex-col gap-2'>
<p className='font-medium text-[#7D7D7D] text-base'>Message</p>
<Container className='min-h-32 items-start'>
<p>Enter your alert message</p>
</Container>
</div>
</div>
)}
</div>
</BlockCard>
{/* Output Handle - Don't show for slack1 */}
{id !== 'slack1' && (
<Handle
type='source'
position={Position.Right}
id='source'
className={cn(
'!w-[7px] !h-5',
'!bg-slate-300 dark:!bg-slate-500 !rounded-[2px] !border-none',
'!z-[1000]',
'!opacity-100',
'!right-[-7px]'
)}
style={{
top: '50%',
transform: 'translateY(-50%)',
}}
data-nodeid={id}
data-handleid='source'
isConnectable={true}
/>
)}
</div>
)
})
const Container = ({ children, className }: { children: React.ReactNode; className?: string }) => {
return (
<div
className={cn(
'flex items-center justify-between rounded-xl border border-[#282828] bg-[#212121] px-3 py-2 font-normal text-[#7C7C7C] text-sm',
className
)}
>
{children}
</div>
)
}
// Modify BlockCard to accept and use iconBgColor prop
const BlockCard = ({
Icon,
iconBgColor,
title,
children,
}: {
Icon: any
iconBgColor: string
title: string
children: React.ReactNode
}) => {
return (
<div className='flex min-h-[100px] w-[280px] flex-col rounded-xl border border-[#333333] bg-[#131313] shadow-[0px_0px_6px_3px_rgba(255,_255,_255,_0.05)]'>
<div className='flex items-center gap-2 border-[#262626] border-b px-4 pt-4 pb-3'>
{/* Apply background color using inline style */}
<div
className={'flex h-6 w-6 items-center justify-center rounded'}
style={{ backgroundColor: iconBgColor }} // Use inline style
>
<Icon className='h-4 w-4 text-white' />
</div>
<p className='font-semibold text-base text-neutral-200'>{title}</p>
</div>
<div className='flex-grow p-4 pt-0'>{children}</div>
</div>
)
}
HeroBlock.displayName = 'HeroBlock'

View File

@@ -1,40 +0,0 @@
import { BaseEdge, type EdgeProps, getSmoothStepPath } from 'reactflow'
export const HeroEdge = ({
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
style = {},
markerEnd,
}: EdgeProps) => {
const isHorizontal = sourcePosition === 'right' || sourcePosition === 'left'
const [edgePath] = getSmoothStepPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
borderRadius: 8,
offset: isHorizontal ? 30 : 20,
})
return (
<BaseEdge
path={edgePath}
style={{
strokeWidth: 2,
stroke: '#404040',
strokeDasharray: '5,5',
zIndex: 5,
...style,
}}
markerEnd={markerEnd || style.markerEnd}
/>
)
}

Some files were not shown because too many files have changed in this diff Show More