mirror of
https://github.com/simstudioai/sim.git
synced 2026-03-15 03:00:33 -04:00
Compare commits
3 Commits
improvemen
...
improvemen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9d5e6000b | ||
|
|
d557bf527c | ||
|
|
dc98812bd7 |
@@ -29,8 +29,8 @@
|
||||
"next": "16.1.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"postgres": "^3.4.5",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"react": "19.2.1",
|
||||
"react-dom": "19.2.1",
|
||||
"shiki": "4.0.0",
|
||||
"tailwind-merge": "^3.0.2"
|
||||
},
|
||||
|
||||
@@ -25,7 +25,7 @@ function DotGrid({ className, cols, rows, gap = 0 }: DotGridProps) {
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: cols * rows }, (_, i) => (
|
||||
<div key={i} className='h-[2px] w-[2px] rounded-full bg-[#2A2A2A]' />
|
||||
<div key={i} className='h-[1.5px] w-[1.5px] rounded-full bg-[#2A2A2A]' />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
@@ -268,13 +268,13 @@ export default function Collaboration() {
|
||||
collaboration
|
||||
</h2>
|
||||
|
||||
<p className='font-[430] font-season text-[#F6F6F0]/50 text-[14px] leading-[125%] tracking-[0.02em] sm:text-[16px]'>
|
||||
<p className='font-[430] font-season text-[#F6F6F0]/50 text-[18px] leading-[150%] tracking-[0.02em]'>
|
||||
Grab your team. Build agents together <br /> in real-time inside your workspace.
|
||||
</p>
|
||||
|
||||
<Link
|
||||
href='/signup'
|
||||
className='group/cta mt-[12px] inline-flex h-[32px] cursor-none items-center gap-[6px] rounded-[5px] border border-[#33C482] bg-[#33C482] px-[10px] font-[430] font-season text-[14px] text-black transition-[filter] hover:brightness-110'
|
||||
className='group/cta mt-[12px] inline-flex h-[32px] cursor-none items-center gap-[6px] rounded-[5px] border border-[#FFFFFF] bg-[#FFFFFF] px-[10px] font-[430] font-season text-[14px] text-black transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]'
|
||||
>
|
||||
Build together
|
||||
<span className='relative h-[10px] w-[10px] shrink-0'>
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
'use client'
|
||||
|
||||
import { type SVGProps, useRef } from 'react'
|
||||
import { motion, useInView } from 'framer-motion'
|
||||
import { ChevronDown } from '@/components/emcn'
|
||||
import { Database, File, Library, Table } from '@/components/emcn/icons'
|
||||
import {
|
||||
AnthropicIcon,
|
||||
GeminiIcon,
|
||||
GmailIcon,
|
||||
GroqIcon,
|
||||
HubspotIcon,
|
||||
OpenAIIcon,
|
||||
SalesforceIcon,
|
||||
SlackIcon,
|
||||
xAIIcon,
|
||||
} from '@/components/icons'
|
||||
|
||||
interface IconEntry {
|
||||
key: string
|
||||
icon: React.ComponentType<SVGProps<SVGSVGElement>>
|
||||
label: string
|
||||
top: string
|
||||
left: string
|
||||
color?: string
|
||||
}
|
||||
|
||||
const SCATTERED_ICONS: IconEntry[] = [
|
||||
{ key: 'slack', icon: SlackIcon, label: 'Slack', top: '8%', left: '14%' },
|
||||
{ key: 'openai', icon: OpenAIIcon, label: 'OpenAI', top: '8%', left: '44%' },
|
||||
{ key: 'anthropic', icon: AnthropicIcon, label: 'Anthropic', top: '10%', left: '78%' },
|
||||
{ key: 'gmail', icon: GmailIcon, label: 'Gmail', top: '24%', left: '90%' },
|
||||
{ key: 'salesforce', icon: SalesforceIcon, label: 'Salesforce', top: '28%', left: '6%' },
|
||||
{ key: 'table', icon: Table, label: 'Tables', top: '22%', left: '30%' },
|
||||
{ key: 'xai', icon: xAIIcon, label: 'xAI', top: '26%', left: '66%' },
|
||||
{ key: 'hubspot', icon: HubspotIcon, label: 'HubSpot', top: '55%', left: '4%', color: '#FF7A59' },
|
||||
{ key: 'database', icon: Database, label: 'Database', top: '74%', left: '68%' },
|
||||
{ key: 'file', icon: File, label: 'Files', top: '70%', left: '18%' },
|
||||
{ key: 'gemini', icon: GeminiIcon, label: 'Gemini', top: '58%', left: '86%' },
|
||||
{ key: 'logs', icon: Library, label: 'Logs', top: '86%', left: '44%' },
|
||||
{ key: 'groq', icon: GroqIcon, label: 'Groq', top: '90%', left: '82%' },
|
||||
]
|
||||
|
||||
const EXPLODE_STAGGER = 0.04
|
||||
const EXPLODE_BASE_DELAY = 0.1
|
||||
|
||||
export function FeaturesPreview() {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const inView = useInView(containerRef, { once: true, margin: '-80px' })
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className='relative h-[560px] w-full overflow-hidden'>
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='absolute inset-0'
|
||||
style={{
|
||||
backgroundImage: 'radial-gradient(circle, #D4D4D4 0.75px, transparent 0.75px)',
|
||||
backgroundSize: '12px 12px',
|
||||
maskImage: 'radial-gradient(ellipse 70% 65% at 48% 50%, black 30%, transparent 80%)',
|
||||
WebkitMaskImage:
|
||||
'radial-gradient(ellipse 70% 65% at 48% 50%, black 30%, transparent 80%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{SCATTERED_ICONS.map(({ key, icon: Icon, label, top, left, color }, index) => {
|
||||
const explodeDelay = EXPLODE_BASE_DELAY + index * EXPLODE_STAGGER
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={key}
|
||||
className='absolute flex items-center justify-center rounded-xl border border-[#E5E5E5] bg-white p-[10px] shadow-[0_2px_4px_0_rgba(0,0,0,0.06)]'
|
||||
initial={{ top: '50%', left: '50%', opacity: 0, scale: 0, x: '-50%', y: '-50%' }}
|
||||
animate={inView ? { top, left, opacity: 1, scale: 1, x: '-50%', y: '-50%' } : undefined}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 50,
|
||||
damping: 12,
|
||||
delay: explodeDelay,
|
||||
}}
|
||||
style={{ color }}
|
||||
aria-label={label}
|
||||
>
|
||||
<Icon className='h-6 w-6' />
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
|
||||
<motion.div
|
||||
className='absolute top-1/2 left-[48%]'
|
||||
initial={{ opacity: 0, x: '-50%', y: '-50%' }}
|
||||
animate={inView ? { opacity: 1, x: '-50%', y: '-50%' } : undefined}
|
||||
transition={{ duration: 0.4, ease: 'easeOut', delay: 0 }}
|
||||
>
|
||||
<div className='flex h-[36px] items-center gap-[8px] rounded-[8px] border border-[#E5E5E5] bg-white px-[10px] shadow-[0_2px_6px_0_rgba(0,0,0,0.08)]'>
|
||||
<div className='flex h-[22px] w-[22px] flex-shrink-0 items-center justify-center rounded-[5px] bg-[#1e1e1e]'>
|
||||
<svg width='11' height='11' viewBox='0 0 10 10' fill='none'>
|
||||
<path
|
||||
d='M1 9C1 4.58 4.58 1 9 1'
|
||||
stroke='white'
|
||||
strokeWidth='1.8'
|
||||
strokeLinecap='round'
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<span className='whitespace-nowrap font-medium font-season text-[#1C1C1C] text-[13px] tracking-[0.02em]'>
|
||||
My Workspace
|
||||
</span>
|
||||
<ChevronDown className='h-[8px] w-[10px] flex-shrink-0 text-[#999]' />
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRef, useState } from 'react'
|
||||
import { type MotionValue, motion, useScroll, useTransform } from 'framer-motion'
|
||||
import Image from 'next/image'
|
||||
import { Badge } from '@/components/emcn'
|
||||
import Link from 'next/link'
|
||||
import { Badge, ChevronDown } from '@/components/emcn'
|
||||
import { FeaturesPreview } from '@/app/(home)/components/features/components/features-preview'
|
||||
|
||||
function hexToRgba(hex: string, alpha: number): string {
|
||||
const r = Number.parseInt(hex.slice(1, 3), 16)
|
||||
@@ -13,8 +16,12 @@ function hexToRgba(hex: string, alpha: number): string {
|
||||
|
||||
const FEATURE_TABS = [
|
||||
{
|
||||
label: 'Integrations',
|
||||
label: 'Mothership',
|
||||
color: '#FA4EDF',
|
||||
title: 'Your AI command center',
|
||||
description:
|
||||
'Direct your entire AI workforce from one place. Build agents, spin up workflows, query tables, and manage every resource across your workspace — in natural language.',
|
||||
cta: 'Explore mothership',
|
||||
segments: [
|
||||
[0.3, 8],
|
||||
[0.25, 10],
|
||||
@@ -29,8 +36,12 @@ const FEATURE_TABS = [
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Copilot',
|
||||
label: 'Tables',
|
||||
color: '#2ABBF8',
|
||||
title: 'A database, built in',
|
||||
description:
|
||||
'Filter, sort, and edit data inline, then wire it directly into your workflows. Agents query, insert, and update rows on every run — no external database needed.',
|
||||
cta: 'Explore tables',
|
||||
segments: [
|
||||
[0.25, 12],
|
||||
[0.4, 10],
|
||||
@@ -44,59 +55,33 @@ const FEATURE_TABS = [
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Models',
|
||||
color: '#00F701',
|
||||
badgeColor: '#22C55E',
|
||||
segments: [
|
||||
[0.2, 6],
|
||||
[0.35, 10],
|
||||
[0.3, 8],
|
||||
[0.5, 10],
|
||||
[0.6, 8],
|
||||
[0.75, 12],
|
||||
[0.85, 10],
|
||||
[1, 8],
|
||||
[0.9, 12],
|
||||
[1, 10],
|
||||
[0.95, 6],
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Deploy',
|
||||
label: 'Files',
|
||||
color: '#FFCC02',
|
||||
badgeColor: '#EAB308',
|
||||
segments: [
|
||||
[0.3, 12],
|
||||
[0.25, 8],
|
||||
[0.4, 10],
|
||||
[0.55, 10],
|
||||
[0.7, 8],
|
||||
[0.6, 10],
|
||||
[0.85, 12],
|
||||
[1, 10],
|
||||
[0.9, 10],
|
||||
[1, 10],
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Logs',
|
||||
color: '#FF6B35',
|
||||
title: 'Upload, create, and share',
|
||||
description:
|
||||
'Create or upload documents, spreadsheets, and media that agents can read, write, and reference across workflows. One shared store your entire team and every agent can pull from.',
|
||||
cta: 'Explore files',
|
||||
segments: [
|
||||
[0.25, 10],
|
||||
[0.35, 8],
|
||||
[0.3, 10],
|
||||
[0.4, 8],
|
||||
[0.35, 12],
|
||||
[0.5, 10],
|
||||
[0.65, 8],
|
||||
[0.8, 12],
|
||||
[0.9, 10],
|
||||
[0.75, 10],
|
||||
[0.9, 12],
|
||||
[1, 10],
|
||||
[0.85, 12],
|
||||
[0.85, 10],
|
||||
[1, 10],
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Knowledge Base',
|
||||
color: '#8B5CF6',
|
||||
title: 'Your context engine',
|
||||
description:
|
||||
'Sync institutional knowledge from 30+ live connectors — Notion, Drive, Slack, Confluence, and more — so every agent draws from the same truth across your entire organization.',
|
||||
cta: 'Explore knowledge base',
|
||||
segments: [
|
||||
[0.3, 10],
|
||||
[0.25, 8],
|
||||
@@ -110,8 +95,47 @@ const FEATURE_TABS = [
|
||||
[1, 10],
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Logs',
|
||||
color: '#FF6B35',
|
||||
title: 'Full visibility, every run',
|
||||
description:
|
||||
'Trace every execution block by block — inputs, outputs, cost, and duration. Filter by status or workflow, replay snapshots, and export reports to keep your team accountable.',
|
||||
cta: 'Explore logs',
|
||||
segments: [
|
||||
[0.25, 10],
|
||||
[0.35, 8],
|
||||
[0.3, 10],
|
||||
[0.5, 10],
|
||||
[0.65, 8],
|
||||
[0.8, 12],
|
||||
[0.9, 10],
|
||||
[1, 10],
|
||||
[0.85, 12],
|
||||
[1, 10],
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const HEADING_TEXT = 'Everything you need to build, deploy, and manage AI agents. '
|
||||
const HEADING_LETTERS = HEADING_TEXT.split('')
|
||||
|
||||
const LETTER_REVEAL_SPAN = 0.85
|
||||
const LETTER_FADE_IN = 0.04
|
||||
|
||||
interface ScrollLetterProps {
|
||||
scrollYProgress: MotionValue<number>
|
||||
charIndex: number
|
||||
children: string
|
||||
}
|
||||
|
||||
function ScrollLetter({ scrollYProgress, charIndex, children }: ScrollLetterProps) {
|
||||
const threshold = (charIndex / HEADING_LETTERS.length) * LETTER_REVEAL_SPAN
|
||||
const opacity = useTransform(scrollYProgress, [threshold, threshold + LETTER_FADE_IN], [0.4, 1])
|
||||
|
||||
return <motion.span style={{ opacity }}>{children}</motion.span>
|
||||
}
|
||||
|
||||
function DotGrid({
|
||||
cols,
|
||||
rows,
|
||||
@@ -126,7 +150,7 @@ function DotGrid({
|
||||
return (
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className={`shrink-0 bg-[#FDFDFD] p-[6px] ${borderLeft ? 'border-[#E9E9E9] border-l' : ''}`}
|
||||
className={`shrink-0 bg-[#F6F6F6] p-[6px] ${borderLeft ? 'border-[#E9E9E9] border-l' : ''}`}
|
||||
style={{
|
||||
width: width ? `${width}px` : undefined,
|
||||
display: 'grid',
|
||||
@@ -136,20 +160,26 @@ function DotGrid({
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: cols * rows }, (_, i) => (
|
||||
<div key={i} className='h-[2px] w-[2px] rounded-full bg-[#DEDEDE]' />
|
||||
<div key={i} className='h-[1.5px] w-[1.5px] rounded-full bg-[#DEDEDE]' />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Features() {
|
||||
const sectionRef = useRef<HTMLDivElement>(null)
|
||||
const [activeTab, setActiveTab] = useState(0)
|
||||
|
||||
const { scrollYProgress } = useScroll({
|
||||
target: sectionRef,
|
||||
offset: ['start 0.9', 'start 0.2'],
|
||||
})
|
||||
|
||||
return (
|
||||
<section
|
||||
id='features'
|
||||
aria-labelledby='features-heading'
|
||||
className='relative overflow-hidden bg-[#F6F6F6] pb-[144px]'
|
||||
className='relative overflow-hidden bg-[#F6F6F6]'
|
||||
>
|
||||
<div aria-hidden='true' className='absolute top-0 left-0 w-full'>
|
||||
<Image
|
||||
@@ -163,7 +193,7 @@ export default function Features() {
|
||||
</div>
|
||||
|
||||
<div className='relative z-10 pt-[100px]'>
|
||||
<div className='flex flex-col items-start gap-[20px] px-[80px]'>
|
||||
<div ref={sectionRef} className='flex flex-col items-start gap-[20px] px-[80px]'>
|
||||
<Badge
|
||||
variant='blue'
|
||||
size='md'
|
||||
@@ -177,51 +207,110 @@ export default function Features() {
|
||||
),
|
||||
}}
|
||||
>
|
||||
Features
|
||||
Workspace
|
||||
</Badge>
|
||||
<h2
|
||||
id='features-heading'
|
||||
className='font-[430] font-season text-[#1C1C1C] text-[40px] leading-[100%] tracking-[-0.02em]'
|
||||
className='max-w-[900px] font-[430] font-season text-[#1C1C1C] text-[40px] leading-[110%] tracking-[-0.02em]'
|
||||
>
|
||||
Power your AI workforce
|
||||
{HEADING_LETTERS.map((char, i) => (
|
||||
<ScrollLetter key={i} scrollYProgress={scrollYProgress} charIndex={i}>
|
||||
{char}
|
||||
</ScrollLetter>
|
||||
))}
|
||||
<span className='text-[#1C1C1C]/40'>
|
||||
Design powerful workflows, connect your data, and monitor every run — all in one
|
||||
platform.
|
||||
</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className='mt-[73px] flex h-[68px] overflow-hidden border border-[#E9E9E9]'>
|
||||
<DotGrid cols={10} rows={8} width={80} />
|
||||
<div className='relative mt-[73px] pb-[80px]'>
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='absolute top-0 bottom-0 left-[80px] z-20 w-px bg-[#E9E9E9]'
|
||||
/>
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='absolute top-0 right-[80px] bottom-0 z-20 w-px bg-[#E9E9E9]'
|
||||
/>
|
||||
|
||||
<div role='tablist' aria-label='Feature categories' className='flex flex-1'>
|
||||
{FEATURE_TABS.map((tab, index) => (
|
||||
<button
|
||||
key={tab.label}
|
||||
type='button'
|
||||
role='tab'
|
||||
aria-selected={index === activeTab}
|
||||
onClick={() => setActiveTab(index)}
|
||||
className='relative flex h-full flex-1 items-center justify-center border-[#E9E9E9] border-l font-medium font-season text-[#212121] text-[14px] uppercase'
|
||||
style={{ backgroundColor: index === activeTab ? '#FDFDFD' : '#F6F6F6' }}
|
||||
>
|
||||
{tab.label}
|
||||
{index === activeTab && (
|
||||
<div className='absolute right-0 bottom-0 left-0 flex h-[6px]'>
|
||||
{tab.segments.map(([opacity, width], i) => (
|
||||
<div
|
||||
key={i}
|
||||
className='h-full shrink-0'
|
||||
style={{
|
||||
width: `${width}%`,
|
||||
backgroundColor: tab.color,
|
||||
opacity,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
<div className='flex h-[68px] overflow-hidden border border-[#E9E9E9]'>
|
||||
<DotGrid cols={10} rows={8} width={80} />
|
||||
|
||||
<div role='tablist' aria-label='Feature categories' className='flex flex-1'>
|
||||
{FEATURE_TABS.map((tab, index) => (
|
||||
<button
|
||||
key={tab.label}
|
||||
type='button'
|
||||
role='tab'
|
||||
aria-selected={index === activeTab}
|
||||
onClick={() => setActiveTab(index)}
|
||||
className='relative flex h-full flex-1 items-center justify-center border-[#E9E9E9] border-l font-medium font-season text-[#212121] text-[14px] uppercase'
|
||||
style={{ backgroundColor: index === activeTab ? '#FDFDFD' : '#F6F6F6' }}
|
||||
>
|
||||
{tab.label}
|
||||
{index === activeTab && (
|
||||
<div className='absolute right-0 bottom-0 left-0 flex h-[6px]'>
|
||||
{tab.segments.map(([opacity, width], i) => (
|
||||
<div
|
||||
key={i}
|
||||
className='h-full shrink-0'
|
||||
style={{
|
||||
width: `${width}%`,
|
||||
backgroundColor: tab.color,
|
||||
opacity,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DotGrid cols={10} rows={8} width={80} borderLeft />
|
||||
</div>
|
||||
|
||||
<DotGrid cols={10} rows={8} width={80} borderLeft />
|
||||
<div className='mt-[60px] grid grid-cols-[1fr_2.8fr] gap-[60px] px-[120px]'>
|
||||
<div className='flex h-[560px] flex-col items-start justify-between pt-[20px]'>
|
||||
<div className='flex flex-col items-start gap-[16px]'>
|
||||
<h3 className='font-[430] font-season text-[#1C1C1C] text-[28px] leading-[120%] tracking-[-0.02em]'>
|
||||
{FEATURE_TABS[activeTab].title}
|
||||
</h3>
|
||||
<p className='font-[430] font-season text-[#1C1C1C]/50 text-[18px] leading-[150%] tracking-[0.02em]'>
|
||||
{FEATURE_TABS[activeTab].description}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href='/signup'
|
||||
className='group/cta inline-flex h-[32px] items-center gap-[6px] rounded-[5px] border border-[#1D1D1D] bg-[#1D1D1D] px-[10px] font-[430] font-season text-[14px] text-white transition-colors hover:border-[#2A2A2A] hover:bg-[#2A2A2A]'
|
||||
>
|
||||
{FEATURE_TABS[activeTab].cta}
|
||||
<span className='relative h-[10px] w-[10px] shrink-0'>
|
||||
<ChevronDown className='-rotate-90 absolute inset-0 h-[10px] w-[10px] transition-opacity duration-150 group-hover/cta:opacity-0' />
|
||||
<svg
|
||||
className='absolute inset-0 h-[10px] w-[10px] opacity-0 transition-opacity duration-150 group-hover/cta:opacity-100'
|
||||
viewBox='0 0 10 10'
|
||||
fill='none'
|
||||
>
|
||||
<path
|
||||
d='M1 5H8M5.5 2L8.5 5L5.5 8'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.33'
|
||||
strokeLinecap='square'
|
||||
strokeLinejoin='miter'
|
||||
fill='none'
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<FeaturesPreview />
|
||||
</div>
|
||||
|
||||
<div aria-hidden='true' className='mt-[60px] h-px bg-[#E9E9E9]' />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,18 +1,189 @@
|
||||
/**
|
||||
* Landing page footer — navigation, legal links, and entity reinforcement.
|
||||
*
|
||||
* SEO:
|
||||
* - `<footer role="contentinfo">` with `<nav aria-label="Footer navigation">`.
|
||||
* - Link groups under semantic headings (`<h3>`). All links are `<Link>` or `<a>` with `href`.
|
||||
* - External links include `rel="noopener noreferrer"`.
|
||||
* - Legal links (Privacy, Terms) must be crawlable (trust signals).
|
||||
*
|
||||
* GEO:
|
||||
* - Include "Sim — Build AI agents and run your agentic workforce" as visible text (entity reinforcement).
|
||||
* - Social links (X, GitHub, LinkedIn, Discord) must match `sameAs` in structured-data.tsx.
|
||||
* - Link to all major pages: Docs, Pricing, Enterprise, Careers, Changelog (internal link graph).
|
||||
* - Display compliance badges (SOC2, HIPAA) and status page link as visible trust signals.
|
||||
*/
|
||||
export default function Footer() {
|
||||
return null
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { FOOTER_BLOCKS, FOOTER_TOOLS } from '@/app/(landing)/components/footer/consts'
|
||||
|
||||
const LINK_CLASS = 'text-[14px] text-[#999] transition-colors hover:text-[#ECECEC]'
|
||||
|
||||
interface FooterLink {
|
||||
label: string
|
||||
href: string
|
||||
external?: boolean
|
||||
}
|
||||
|
||||
const FOOTER_LINKS: FooterLink[] = [
|
||||
{ label: 'Docs', href: 'https://docs.sim.ai', external: true },
|
||||
{ label: 'Pricing', href: '#pricing' },
|
||||
{ label: 'Enterprise', href: 'https://form.typeform.com/to/jqCO12pF', external: true },
|
||||
{ label: 'Sim Studio', href: '/studio' },
|
||||
{ label: 'Changelog', href: '/changelog' },
|
||||
{ label: 'Status', href: 'https://status.sim.ai', external: true },
|
||||
{ label: 'Careers', href: 'https://jobs.ashbyhq.com/sim', external: true },
|
||||
{ label: 'SOC2', href: 'https://trust.delve.co/sim-studio', external: true },
|
||||
{ label: 'Privacy Policy', href: '/privacy', external: true },
|
||||
{ label: 'Terms of Service', href: '/terms', external: true },
|
||||
]
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer
|
||||
role='contentinfo'
|
||||
className='relative w-full overflow-hidden bg-[#1C1C1C] font-[430] font-season text-[14px]'
|
||||
>
|
||||
<div className='px-4 pt-[80px] pb-[40px] sm:px-8 sm:pb-[340px] md:px-[80px]'>
|
||||
<nav aria-label='Footer navigation' className='flex justify-between'>
|
||||
{/* Brand column */}
|
||||
<div className='flex flex-col gap-[24px]'>
|
||||
<Link href='/' aria-label='Sim home'>
|
||||
<Image
|
||||
src='/logo/sim-landing.svg'
|
||||
alt='Sim'
|
||||
width={71}
|
||||
height={22}
|
||||
className='h-[22px] w-auto'
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Community column */}
|
||||
<div>
|
||||
<h3 className='mb-[16px] font-medium text-[#ECECEC] text-[14px]'>Community</h3>
|
||||
<div className='flex flex-col gap-[12px]'>
|
||||
<a
|
||||
href='https://discord.gg/Hr4UWYEcTT'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className={LINK_CLASS}
|
||||
>
|
||||
Discord
|
||||
</a>
|
||||
<a
|
||||
href='https://x.com/simdotai'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className={LINK_CLASS}
|
||||
>
|
||||
X (Twitter)
|
||||
</a>
|
||||
<a
|
||||
href='https://www.linkedin.com/company/simstudioai/'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className={LINK_CLASS}
|
||||
>
|
||||
LinkedIn
|
||||
</a>
|
||||
<a
|
||||
href='https://github.com/simstudioai/sim'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className={LINK_CLASS}
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Links column */}
|
||||
<div>
|
||||
<h3 className='mb-[16px] font-medium text-[#ECECEC] text-[14px]'>More Sim</h3>
|
||||
<div className='flex flex-col gap-[12px]'>
|
||||
{FOOTER_LINKS.map(({ label, href, external }) =>
|
||||
external ? (
|
||||
<a
|
||||
key={label}
|
||||
href={href}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className={LINK_CLASS}
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
) : (
|
||||
<Link key={label} href={href} className={LINK_CLASS}>
|
||||
{label}
|
||||
</Link>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Blocks column */}
|
||||
<div className='hidden sm:block'>
|
||||
<h3 className='mb-[16px] font-medium text-[#ECECEC] text-[14px]'>Blocks</h3>
|
||||
<div className='flex flex-col gap-[12px]'>
|
||||
{FOOTER_BLOCKS.map((block) => (
|
||||
<a
|
||||
key={block}
|
||||
href={`https://docs.sim.ai/blocks/${block.toLowerCase().replaceAll(' ', '-')}`}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className={LINK_CLASS}
|
||||
>
|
||||
{block}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tools columns */}
|
||||
<div className='hidden sm:block'>
|
||||
<h3 className='mb-[16px] font-medium text-[#ECECEC] text-[14px]'>Tools</h3>
|
||||
<div className='flex gap-[80px]'>
|
||||
{[0, 1, 2, 3].map((quarter) => {
|
||||
const start = Math.ceil((FOOTER_TOOLS.length * quarter) / 4)
|
||||
const end =
|
||||
quarter === 3
|
||||
? FOOTER_TOOLS.length
|
||||
: Math.ceil((FOOTER_TOOLS.length * (quarter + 1)) / 4)
|
||||
return (
|
||||
<div key={quarter} className='flex flex-col gap-[12px]'>
|
||||
{FOOTER_TOOLS.slice(start, end).map((tool) => (
|
||||
<a
|
||||
key={tool}
|
||||
href={`https://docs.sim.ai/tools/${tool.toLowerCase().replace(/\s+/g, '_')}`}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className={`whitespace-nowrap ${LINK_CLASS}`}
|
||||
>
|
||||
{tool}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Large SIM wordmark — half cut off */}
|
||||
<div className='-translate-x-1/2 pointer-events-none absolute bottom-[-240px] left-1/2 hidden sm:block'>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='1128'
|
||||
height='550'
|
||||
viewBox='0 0 1128 550'
|
||||
fill='none'
|
||||
>
|
||||
<path
|
||||
d='M3 420.942H77.9115C77.9115 441.473 85.4027 457.843 100.385 470.051C115.367 481.704 135.621 487.53 161.147 487.53C188.892 487.53 210.255 482.258 225.238 471.715C240.22 460.617 247.711 445.913 247.711 427.601C247.711 414.283 243.549 403.185 235.226 394.307C227.457 385.428 213.03 378.215 191.943 372.666L120.361 356.019C84.2929 347.14 57.3802 333.545 39.6234 315.234C22.4215 296.922 13.8206 272.784 13.8206 242.819C13.8206 217.849 20.2019 196.208 32.9646 177.896C46.2822 159.584 64.3165 145.434 87.0674 135.446C110.373 125.458 137.008 120.464 166.973 120.464C196.938 120.464 222.74 125.735 244.382 136.278C266.578 146.821 283.779 161.526 295.987 180.393C308.75 199.259 315.409 221.733 315.964 247.813H241.052C240.497 226.727 233.561 210.357 220.243 198.705C206.926 187.052 188.337 181.225 164.476 181.225C140.06 181.225 121.194 186.497 107.876 197.04C94.5585 207.583 87.8997 222.01 87.8997 240.322C87.8997 267.512 107.876 286.101 147.829 296.09L219.411 313.569C253.815 321.337 279.618 334.1 296.82 351.857C314.022 369.059 322.622 392.642 322.622 422.607C322.622 448.132 315.686 470.606 301.814 490.027C287.941 508.894 268.797 523.599 244.382 534.142C220.521 544.13 192.221 549.124 159.482 549.124C111.76 549.124 73.7498 537.471 45.4499 514.165C17.15 490.86 3 459.785 3 420.942Z'
|
||||
fill='#2A2A2A'
|
||||
/>
|
||||
<path
|
||||
d='M377.713 539.136V132.117C408.911 143.439 422.667 143.439 455.954 132.117V539.136H377.713ZM416.001 105.211C402.129 105.211 389.921 100.217 379.378 90.2291C369.39 79.686 364.395 67.4782 364.395 53.6057C364.395 39.1783 369.39 26.9705 379.378 16.9823C389.921 6.9941 402.129 2 416.001 2C430.428 2 442.636 6.9941 452.625 16.9823C462.613 26.9705 467.607 39.1783 467.607 53.6057C467.607 67.4782 462.613 79.686 452.625 90.2291C442.636 100.217 430.428 105.211 416.001 105.211Z'
|
||||
fill='#2A2A2A'
|
||||
/>
|
||||
<path
|
||||
d='M593.961 539.136H515.72V132.117H585.637V200.792C593.961 178.041 610.053 158.752 632.249 143.769C655 128.232 682.467 120.464 714.651 120.464C750.72 120.464 780.685 130.174 804.545 149.596C822.01 163.812 835.016 181.446 843.562 202.5C851.434 181.446 864.509 163.812 882.786 149.596C907.757 130.174 938.554 120.464 975.177 120.464C1021.79 120.464 1058.41 134.059 1085.05 161.249C1111.68 188.439 1125 225.617 1125 272.784V539.136H1048.42V291.928C1048.42 259.744 1040.1 235.051 1023.45 217.849C1007.36 200.092 985.443 191.213 957.698 191.213C938.276 191.213 921.074 195.653 906.092 204.531C891.665 212.855 880.289 225.062 871.966 241.154C863.642 257.247 859.48 276.113 859.48 297.754V539.136H782.072V291.095C782.072 258.911 774.026 234.496 757.934 217.849C741.841 200.647 719.923 192.046 692.178 192.046C672.756 192.046 655.555 196.485 640.572 205.363C626.145 213.687 614.769 225.895 606.446 241.987C598.122 257.524 593.961 276.113 593.961 297.754V539.136Z'
|
||||
fill='#2A2A2A'
|
||||
/>
|
||||
<path
|
||||
d='M166.973 121.105C196.396 121.105 221.761 126.201 243.088 136.367L244.101 136.855L244.106 136.858C265.86 147.191 282.776 161.528 294.876 179.865L295.448 180.741L295.455 180.753C308.032 199.345 314.656 221.475 315.306 247.171H241.675C240.996 226.243 234.012 209.899 220.666 198.222C207.196 186.435 188.437 180.583 164.476 180.583C139.977 180.583 120.949 185.871 107.478 196.536C93.9928 207.212 87.2578 221.832 87.2578 240.322C87.2579 254.096 92.3262 265.711 102.444 275.127C112.542 284.524 127.641 291.704 147.673 296.712L147.677 296.713L219.259 314.192L219.27 314.195C253.065 321.827 278.469 334.271 295.552 351.48L296.358 352.304L296.365 352.311C313.42 369.365 321.98 392.77 321.98 422.606C321.98 448.005 315.082 470.343 301.297 489.646C287.502 508.408 268.456 523.046 244.134 533.55C220.369 543.498 192.157 548.482 159.481 548.482C111.864 548.482 74.0124 536.855 45.8584 513.67C17.8723 490.623 3.80059 459.948 3.64551 421.584H77.2734C77.4285 441.995 84.9939 458.338 99.9795 470.549L99.9854 470.553L99.9912 470.558C115.12 482.324 135.527 488.172 161.146 488.172C188.96 488.172 210.474 482.889 225.607 472.24L225.613 472.236L225.619 472.231C240.761 461.015 248.353 446.12 248.353 427.601C248.352 414.145 244.145 402.89 235.709 393.884C227.81 384.857 213.226 377.603 192.106 372.045L192.098 372.043L192.089 372.04L120.507 355.394C84.5136 346.533 57.7326 332.983 40.0908 314.794H40.0918C23.0227 296.624 14.4629 272.654 14.4629 242.819C14.4629 217.969 20.8095 196.463 33.4834 178.273C46.7277 160.063 64.6681 145.981 87.3252 136.034L87.3242 136.033C110.536 126.086 137.081 121.106 166.973 121.105ZM975.177 121.105C1021.66 121.105 1058.1 134.658 1084.59 161.698C1111.08 188.741 1124.36 225.743 1124.36 272.784V538.494H1049.07V291.928C1049.07 259.636 1040.71 234.76 1023.92 217.402H1023.91C1007.68 199.5 985.584 190.571 957.697 190.571C938.177 190.571 920.862 195.034 905.771 203.975C891.228 212.365 879.77 224.668 871.396 240.859C863.017 257.059 858.838 276.03 858.838 297.754V538.494H782.714V291.096C782.714 258.811 774.641 234.209 758.395 217.402C742.16 200.053 720.062 191.404 692.178 191.404C673.265 191.404 656.422 195.592 641.666 203.985L640.251 204.808C625.711 213.196 614.254 225.497 605.88 241.684C597.496 257.333 593.318 276.031 593.318 297.754V538.494H516.361V132.759H584.995V200.792L586.24 201.013C594.51 178.408 610.505 159.221 632.607 144.302L632.61 144.3C655.238 128.847 682.574 121.105 714.651 121.105C750.599 121.105 780.413 130.781 804.14 150.094C821.52 164.241 834.461 181.787 842.967 202.741L843.587 204.268L844.163 202.725C851.992 181.786 864.994 164.248 883.181 150.103C908.021 130.782 938.673 121.106 975.177 121.105ZM455.312 538.494H378.354V133.027C393.534 138.491 404.652 141.251 416.05 141.251C427.46 141.251 439.095 138.485 455.312 133.009V538.494ZM416.001 2.6416C430.262 2.6416 442.306 7.57157 452.171 17.4365C462.036 27.3014 466.965 39.3445 466.965 53.6055C466.965 67.3043 462.04 79.3548 452.16 89.7842C442.297 99.6427 430.258 104.569 416.001 104.569C402.303 104.569 390.254 99.6452 379.825 89.7676C369.957 79.3421 365.037 67.2967 365.037 53.6055C365.037 39.3444 369.966 27.3005 379.831 17.4355C390.258 7.56247 402.307 2.64163 416.001 2.6416Z'
|
||||
stroke='#3D3D3D'
|
||||
strokeWidth='1.28396'
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ export default function Hero() {
|
||||
<section
|
||||
id='hero'
|
||||
aria-labelledby='hero-heading'
|
||||
className='relative flex flex-col items-center overflow-hidden bg-[#1C1C1C] pt-[71px]'
|
||||
className='relative flex flex-col items-center overflow-hidden bg-[#1C1C1C] pt-[100px] pb-[12px]'
|
||||
>
|
||||
<p className='sr-only'>
|
||||
Sim is the open-source platform to build AI agents and run your agentic workforce. Connect
|
||||
@@ -53,7 +53,7 @@ export default function Hero() {
|
||||
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='pointer-events-none absolute top-[-2.8vw] right-[0vw] z-0 aspect-[471/470] w-[32.7vw]'
|
||||
className='pointer-events-none absolute top-[-2.8vw] right-[-4vw] z-0 aspect-[471/470] w-[32.7vw]'
|
||||
>
|
||||
<Image src='/landing/card-right.svg' alt='' fill className='object-contain' />
|
||||
</div>
|
||||
@@ -61,25 +61,25 @@ export default function Hero() {
|
||||
<div className='relative z-10 flex flex-col items-center gap-[12px]'>
|
||||
<h1
|
||||
id='hero-heading'
|
||||
className='font-[430] font-season text-[64px] text-white leading-[100%] tracking-[-0.02em]'
|
||||
className='font-[430] font-season text-[72px] text-white leading-[100%] tracking-[-0.02em]'
|
||||
>
|
||||
Build Agents
|
||||
Build AI Agents
|
||||
</h1>
|
||||
<p className='font-[430] font-season text-[#F6F6F6]/60 text-[16px] leading-[125%] tracking-[0.02em]'>
|
||||
Build and deploy agentic workflows
|
||||
<p className='font-[430] font-season text-[#F6F6F6]/60 text-[18px] leading-[125%] tracking-[0.02em]'>
|
||||
Sim is the AI Workspace for Agent Builders.
|
||||
</p>
|
||||
|
||||
<div className='mt-[12px] flex items-center gap-[8px]'>
|
||||
<Link
|
||||
href='/login'
|
||||
href='/enterprise'
|
||||
className={`${CTA_BASE} border-[#3d3d3d] text-[#ECECEC] transition-colors hover:bg-[#2A2A2A]`}
|
||||
aria-label='Log in'
|
||||
aria-label='Get a demo'
|
||||
>
|
||||
Log in
|
||||
Get a demo
|
||||
</Link>
|
||||
<Link
|
||||
href='/signup'
|
||||
className={`${CTA_BASE} gap-[8px] border-[#33C482] bg-[#33C482] text-black transition-[filter] hover:brightness-110`}
|
||||
className={`${CTA_BASE} gap-[8px] border-[#FFFFFF] bg-[#FFFFFF] text-black transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]`}
|
||||
aria-label='Get started with Sim'
|
||||
>
|
||||
Get started
|
||||
@@ -101,7 +101,7 @@ export default function Hero() {
|
||||
<BlocksTopLeftAnimated animState={blockStates.topLeft} />
|
||||
</div>
|
||||
|
||||
<div className='relative z-10 mx-auto mt-[2.4vw] w-[78.9vw] px-[1.4vw]'>
|
||||
<div className='relative z-10 mx-auto mt-[3.2vw] w-[78.9vw] px-[1.4vw]'>
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='-translate-y-1/2 pointer-events-none absolute top-[50%] right-[calc(100%-1.41vw)] z-20 w-[calc(16px_+_1.25vw)] max-w-[34px]'
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
'use client'
|
||||
|
||||
import { memo, useCallback, useRef, useState } from 'react'
|
||||
import { ArrowUp } from 'lucide-react'
|
||||
import { useLandingSubmit } from '@/app/(home)/components/landing-preview/components/landing-preview-panel/landing-preview-panel'
|
||||
import { useAnimatedPlaceholder } from '@/app/workspace/[workspaceId]/home/hooks/use-animated-placeholder'
|
||||
|
||||
const C = {
|
||||
SURFACE: '#292929',
|
||||
BORDER: '#3d3d3d',
|
||||
TEXT_PRIMARY: '#e6e6e6',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Landing preview replica of the workspace Home initial view.
|
||||
* Shows a greeting heading and a minimal chat input (no + or mic).
|
||||
* On submit, stores the prompt and redirects to /signup.
|
||||
*/
|
||||
export const LandingPreviewHome = memo(function LandingPreviewHome() {
|
||||
const landingSubmit = useLandingSubmit()
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const animatedPlaceholder = useAnimatedPlaceholder()
|
||||
|
||||
const isEmpty = inputValue.trim().length === 0
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (isEmpty) return
|
||||
landingSubmit(inputValue)
|
||||
}, [isEmpty, inputValue, landingSubmit])
|
||||
|
||||
const MAX_HEIGHT = 200
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSubmit()
|
||||
}
|
||||
},
|
||||
[handleSubmit]
|
||||
)
|
||||
|
||||
const handleInput = useCallback((e: React.FormEvent<HTMLTextAreaElement>) => {
|
||||
const target = e.target as HTMLTextAreaElement
|
||||
target.style.height = 'auto'
|
||||
target.style.height = `${Math.min(target.scrollHeight, MAX_HEIGHT)}px`
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className='flex min-w-0 flex-1 flex-col items-center justify-center px-[24px] pb-[2vh]'>
|
||||
<h1
|
||||
className='mb-[24px] max-w-[42rem] font-[430] font-season text-[32px] tracking-[-0.02em]'
|
||||
style={{ color: C.TEXT_PRIMARY }}
|
||||
>
|
||||
What should we get done?
|
||||
</h1>
|
||||
|
||||
<div className='w-full max-w-[32rem]'>
|
||||
<div
|
||||
className='cursor-text rounded-[20px] border px-[10px] py-[8px]'
|
||||
style={{ borderColor: C.BORDER, backgroundColor: C.SURFACE }}
|
||||
onClick={() => textareaRef.current?.focus()}
|
||||
>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onInput={handleInput}
|
||||
placeholder={animatedPlaceholder}
|
||||
rows={1}
|
||||
className='m-0 box-border min-h-[24px] w-full resize-none overflow-y-auto border-0 bg-transparent px-[4px] py-[4px] font-body text-[15px] leading-[24px] tracking-[-0.015em] outline-none placeholder:font-[380] placeholder:text-[#787878] focus-visible:ring-0'
|
||||
style={{
|
||||
color: C.TEXT_PRIMARY,
|
||||
caretColor: C.TEXT_PRIMARY,
|
||||
maxHeight: `${MAX_HEIGHT}px`,
|
||||
}}
|
||||
/>
|
||||
<div className='flex items-center justify-end'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleSubmit}
|
||||
disabled={isEmpty}
|
||||
className='flex h-[28px] w-[28px] items-center justify-center rounded-full border-0 p-0 transition-colors'
|
||||
style={{
|
||||
background: isEmpty ? '#808080' : '#e0e0e0',
|
||||
cursor: isEmpty ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
>
|
||||
<ArrowUp size={16} strokeWidth={2.25} color='#1b1b1b' />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -8,6 +8,23 @@ import { createPortal } from 'react-dom'
|
||||
import { BubbleChatPreview, ChevronDown, MoreHorizontal, Play } from '@/components/emcn'
|
||||
import { LandingPromptStorage } from '@/lib/core/utils/browser-storage'
|
||||
|
||||
/**
|
||||
* Stores the prompt in browser storage and redirects to /signup.
|
||||
* Shared by both the copilot panel and the landing home view.
|
||||
*/
|
||||
export function useLandingSubmit() {
|
||||
const router = useRouter()
|
||||
return useCallback(
|
||||
(text: string) => {
|
||||
const trimmed = text.trim()
|
||||
if (!trimmed) return
|
||||
LandingPromptStorage.store(trimmed)
|
||||
router.push('/signup')
|
||||
},
|
||||
[router]
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight static panel replicating the real workspace panel styling.
|
||||
* The copilot tab is active with a functional user input.
|
||||
@@ -18,7 +35,7 @@ import { LandingPromptStorage } from '@/lib/core/utils/browser-storage'
|
||||
* inside Content > Copilot > header-bar(mx-[-1px]) > UserInput(p-8)
|
||||
*/
|
||||
export const LandingPreviewPanel = memo(function LandingPreviewPanel() {
|
||||
const router = useRouter()
|
||||
const landingSubmit = useLandingSubmit()
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const [cursorPos, setCursorPos] = useState<{ x: number; y: number } | null>(null)
|
||||
@@ -27,9 +44,8 @@ export const LandingPreviewPanel = memo(function LandingPreviewPanel() {
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (isEmpty) return
|
||||
LandingPromptStorage.store(inputValue)
|
||||
router.push('/signup')
|
||||
}, [isEmpty, inputValue, router])
|
||||
landingSubmit(inputValue)
|
||||
}, [isEmpty, inputValue, landingSubmit])
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
@@ -60,10 +76,10 @@ export const LandingPreviewPanel = memo(function LandingPreviewPanel() {
|
||||
onMouseMove={(e) => setCursorPos({ x: e.clientX, y: e.clientY })}
|
||||
onMouseLeave={() => setCursorPos(null)}
|
||||
>
|
||||
<div className='flex h-[30px] items-center rounded-[5px] bg-[#32bd7e] px-[10px] transition-[filter] hover:brightness-110'>
|
||||
<div className='flex h-[30px] items-center rounded-[5px] bg-[#33C482] px-[10px] transition-colors hover:bg-[#2DAC72]'>
|
||||
<span className='font-medium text-[#1b1b1b] text-[12px]'>Deploy</span>
|
||||
</div>
|
||||
<div className='flex h-[30px] items-center gap-[8px] rounded-[5px] bg-[#32bd7e] px-[10px] transition-[filter] hover:brightness-110'>
|
||||
<div className='flex h-[30px] items-center gap-[8px] rounded-[5px] bg-[#33C482] px-[10px] transition-colors hover:bg-[#2DAC72]'>
|
||||
<Play className='h-[11.5px] w-[11.5px] text-[#1b1b1b]' />
|
||||
<span className='font-medium text-[#1b1b1b] text-[12px]'>Run</span>
|
||||
</div>
|
||||
|
||||
@@ -1,141 +1,204 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Database, Layout, Search, Settings } from 'lucide-react'
|
||||
import { ChevronDown, Library } from '@/components/emcn'
|
||||
import { ChevronDown, Home, Library } from '@/components/emcn'
|
||||
import {
|
||||
Calendar,
|
||||
Database,
|
||||
File,
|
||||
HelpCircle,
|
||||
Search,
|
||||
Settings,
|
||||
Table,
|
||||
} from '@/components/emcn/icons'
|
||||
import type { PreviewWorkflow } from '@/app/(home)/components/landing-preview/components/landing-preview-workflow/workflow-data'
|
||||
|
||||
/**
|
||||
* Props for the LandingPreviewSidebar component
|
||||
*/
|
||||
interface LandingPreviewSidebarProps {
|
||||
workflows: PreviewWorkflow[]
|
||||
activeWorkflowId: string
|
||||
activeView: 'home' | 'workflow'
|
||||
onSelectWorkflow: (id: string) => void
|
||||
onSelectHome: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Static footer navigation items matching the real sidebar
|
||||
* Hardcoded dark-theme equivalents of the real sidebar CSS variables.
|
||||
* The preview lives inside a `dark` wrapper but CSS variable cascade
|
||||
* isn't guaranteed, so we pin the hex values directly.
|
||||
*/
|
||||
const FOOTER_NAV_ITEMS = [
|
||||
{ id: 'logs', label: 'Logs', icon: Library },
|
||||
{ id: 'templates', label: 'Templates', icon: Layout },
|
||||
const C = {
|
||||
SURFACE_1: '#1e1e1e',
|
||||
SURFACE_2: '#252525',
|
||||
SURFACE_ACTIVE: '#363636',
|
||||
BORDER: '#2c2c2c',
|
||||
TEXT_PRIMARY: '#e6e6e6',
|
||||
TEXT_BODY: '#cdcdcd',
|
||||
TEXT_ICON: '#939393',
|
||||
BRAND: '#33C482',
|
||||
} as const
|
||||
|
||||
const WORKSPACE_NAV = [
|
||||
{ id: 'tables', label: 'Tables', icon: Table },
|
||||
{ id: 'files', label: 'Files', icon: File },
|
||||
{ id: 'knowledge-base', label: 'Knowledge Base', icon: Database },
|
||||
{ id: 'scheduled-tasks', label: 'Scheduled Tasks', icon: Calendar },
|
||||
{ id: 'logs', label: 'Logs', icon: Library },
|
||||
] as const
|
||||
|
||||
const FOOTER_NAV = [
|
||||
{ id: 'help', label: 'Help', icon: HelpCircle },
|
||||
{ id: 'settings', label: 'Settings', icon: Settings },
|
||||
] as const
|
||||
|
||||
function StaticNavItem({
|
||||
icon: Icon,
|
||||
label,
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }>
|
||||
label: string
|
||||
}) {
|
||||
return (
|
||||
<div className='pointer-events-none mx-[2px] flex h-[28px] items-center gap-[8px] rounded-[8px] px-[8px]'>
|
||||
<Icon className='h-[14px] w-[14px] flex-shrink-0' style={{ color: C.TEXT_ICON }} />
|
||||
<span className='truncate text-[13px]' style={{ color: C.TEXT_BODY, fontWeight: 450 }}>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight static sidebar replicating the real workspace sidebar styling.
|
||||
* Lightweight sidebar replicating the real workspace sidebar layout and sizing.
|
||||
* Starts from the workspace header (no logo/collapse row).
|
||||
* Only workflow items are interactive — everything else is pointer-events-none.
|
||||
*
|
||||
* Colors sourced from the dark theme CSS variables:
|
||||
* --surface-1: #1e1e1e, --surface-5: #363636, --border: #2c2c2c, --border-1: #3d3d3d
|
||||
* --text-primary: #e6e6e6, --text-tertiary: #b3b3b3, --text-muted: #787878
|
||||
*/
|
||||
export function LandingPreviewSidebar({
|
||||
workflows,
|
||||
activeWorkflowId,
|
||||
activeView,
|
||||
onSelectWorkflow,
|
||||
onSelectHome,
|
||||
}: LandingPreviewSidebarProps) {
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
setIsDropdownOpen((prev) => !prev)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDropdownOpen) return
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
||||
setIsDropdownOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [isDropdownOpen])
|
||||
const isHomeActive = activeView === 'home'
|
||||
|
||||
return (
|
||||
<div className='flex h-full w-[220px] flex-shrink-0 flex-col border-[#2c2c2c] border-r bg-[#1e1e1e]'>
|
||||
{/* Header */}
|
||||
<div className='relative flex-shrink-0 px-[14px] pt-[12px]' ref={dropdownRef}>
|
||||
<div className='flex items-center justify-between'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleToggle}
|
||||
className='group -mx-[6px] flex cursor-pointer items-center gap-[8px] rounded-[6px] bg-transparent px-[6px] py-[4px] transition-colors hover:bg-[#363636]'
|
||||
<div
|
||||
className='flex h-full w-[248px] flex-shrink-0 flex-col pt-[12px] tracking-[0.02em]'
|
||||
style={{ backgroundColor: C.SURFACE_1 }}
|
||||
>
|
||||
{/* Workspace Header */}
|
||||
<div className='flex-shrink-0 px-[10px]'>
|
||||
<div
|
||||
className='pointer-events-none flex h-[32px] w-full items-center gap-[8px] rounded-[8px] border pr-[8px] pl-[5px]'
|
||||
style={{ borderColor: C.BORDER, backgroundColor: C.SURFACE_2 }}
|
||||
>
|
||||
<div className='flex h-[20px] w-[20px] flex-shrink-0 items-center justify-center rounded-[4px] bg-white'>
|
||||
<svg width='10' height='10' viewBox='0 0 10 10' fill='none'>
|
||||
<path
|
||||
d='M1 9C1 4.58 4.58 1 9 1'
|
||||
stroke='#1e1e1e'
|
||||
strokeWidth='1.8'
|
||||
strokeLinecap='round'
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<span
|
||||
className='min-w-0 flex-1 truncate text-left font-medium text-[13px]'
|
||||
style={{ color: C.TEXT_PRIMARY }}
|
||||
>
|
||||
<span className='truncate font-base text-[#e6e6e6] text-[14px]'>My Workspace</span>
|
||||
<ChevronDown
|
||||
className={`h-[8px] w-[10px] flex-shrink-0 text-[#787878] transition-all duration-100 group-hover:text-[#cccccc] ${isDropdownOpen ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
<div className='pointer-events-none flex flex-shrink-0 items-center'>
|
||||
<Search className='h-[14px] w-[14px] text-[#787878]' />
|
||||
Superark
|
||||
</span>
|
||||
<ChevronDown className='h-[8px] w-[10px] flex-shrink-0' style={{ color: C.TEXT_ICON }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top Navigation: Home (interactive), Search (static) */}
|
||||
<div className='mt-[10px] flex flex-shrink-0 flex-col gap-[2px] px-[8px]'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={onSelectHome}
|
||||
className='mx-[2px] flex h-[28px] items-center gap-[8px] rounded-[8px] px-[8px] transition-colors'
|
||||
style={{ backgroundColor: isHomeActive ? C.SURFACE_ACTIVE : 'transparent' }}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isHomeActive) e.currentTarget.style.backgroundColor = C.SURFACE_ACTIVE
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isHomeActive) e.currentTarget.style.backgroundColor = 'transparent'
|
||||
}}
|
||||
>
|
||||
<Home className='h-[14px] w-[14px] flex-shrink-0' style={{ color: C.TEXT_ICON }} />
|
||||
<span className='truncate text-[13px]' style={{ color: C.TEXT_BODY, fontWeight: 450 }}>
|
||||
Home
|
||||
</span>
|
||||
</button>
|
||||
<StaticNavItem icon={Search} label='Search' />
|
||||
</div>
|
||||
|
||||
{/* Workspace */}
|
||||
<div className='mt-[14px] flex flex-shrink-0 flex-col'>
|
||||
<div className='px-[16px] pb-[6px]'>
|
||||
<div className='font-base text-[12px]' style={{ color: C.TEXT_ICON }}>
|
||||
Workspace
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-col gap-[2px] px-[8px]'>
|
||||
{WORKSPACE_NAV.map((item) => (
|
||||
<StaticNavItem key={item.id} icon={item.icon} label={item.label} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Workspace switcher dropdown */}
|
||||
{isDropdownOpen && (
|
||||
<div className='absolute top-[42px] left-[8px] z-50 min-w-[160px] max-w-[160px] rounded-[6px] bg-[#242424] px-[6px] py-[6px] shadow-lg'>
|
||||
<div
|
||||
className='flex h-[26px] cursor-pointer items-center gap-[8px] rounded-[6px] bg-[#3d3d3d] px-[6px] font-base text-[#e6e6e6] text-[13px]'
|
||||
role='menuitem'
|
||||
onClick={() => setIsDropdownOpen(false)}
|
||||
>
|
||||
<span className='min-w-0 flex-1 truncate'>My Workspace</span>
|
||||
{/* Scrollable Tasks + Workflows */}
|
||||
<div className='flex flex-1 flex-col overflow-y-auto overflow-x-hidden pt-[14px]'>
|
||||
{/* Workflows */}
|
||||
<div className='flex flex-col'>
|
||||
<div className='px-[16px]'>
|
||||
<div className='font-base text-[12px]' style={{ color: C.TEXT_ICON }}>
|
||||
Workflows
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Workflow items */}
|
||||
<div className='mt-[8px] space-y-[2px] overflow-x-hidden px-[8px]'>
|
||||
{workflows.map((workflow) => {
|
||||
const isActive = workflow.id === activeWorkflowId
|
||||
return (
|
||||
<button
|
||||
key={workflow.id}
|
||||
type='button'
|
||||
onClick={() => onSelectWorkflow(workflow.id)}
|
||||
className={`group flex h-[26px] w-full items-center gap-[8px] rounded-[8px] px-[6px] text-[14px] transition-colors ${
|
||||
isActive ? 'bg-[#363636]' : 'bg-transparent hover:bg-[#363636]'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className='h-[14px] w-[14px] flex-shrink-0 rounded-[4px]'
|
||||
style={{ backgroundColor: workflow.color }}
|
||||
/>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div
|
||||
className={`min-w-0 truncate text-left font-medium ${
|
||||
isActive ? 'text-[#e6e6e6]' : 'text-[#b3b3b3] group-hover:text-[#e6e6e6]'
|
||||
}`}
|
||||
<div className='mt-[6px] flex flex-col gap-[2px] px-[8px]'>
|
||||
{workflows.map((workflow) => {
|
||||
const isActive = activeView === 'workflow' && workflow.id === activeWorkflowId
|
||||
return (
|
||||
<button
|
||||
key={workflow.id}
|
||||
type='button'
|
||||
onClick={() => onSelectWorkflow(workflow.id)}
|
||||
className='group mx-[2px] flex h-[28px] w-full items-center gap-[8px] rounded-[8px] px-[8px] transition-colors'
|
||||
style={{ backgroundColor: isActive ? C.SURFACE_ACTIVE : 'transparent' }}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isActive) e.currentTarget.style.backgroundColor = C.SURFACE_ACTIVE
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isActive) e.currentTarget.style.backgroundColor = 'transparent'
|
||||
}}
|
||||
>
|
||||
{workflow.name}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<div
|
||||
className='h-[14px] w-[14px] flex-shrink-0 rounded-[4px] border-[2.5px]'
|
||||
style={{
|
||||
backgroundColor: workflow.color,
|
||||
borderColor: `${workflow.color}60`,
|
||||
backgroundClip: 'padding-box',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className='min-w-0 flex-1 truncate text-left text-[13px]'
|
||||
style={{ color: C.TEXT_BODY, fontWeight: 450 }}
|
||||
>
|
||||
{workflow.name}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer navigation — static */}
|
||||
<div className='pointer-events-none mt-auto flex flex-shrink-0 flex-col gap-[2px] border-[#2c2c2c] border-t px-[7.75px] pt-[8px] pb-[8px]'>
|
||||
{FOOTER_NAV_ITEMS.map((item) => {
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className='flex h-[26px] items-center gap-[8px] rounded-[8px] px-[6px] text-[14px]'
|
||||
>
|
||||
<Icon className='h-[14px] w-[14px] flex-shrink-0 text-[#b3b3b3]' />
|
||||
<span className='truncate font-medium text-[#b3b3b3] text-[13px]'>{item.label}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{/* Footer */}
|
||||
<div className='flex flex-shrink-0 flex-col gap-[2px] px-[8px] pt-[9px] pb-[8px]'>
|
||||
{FOOTER_NAV.map((item) => (
|
||||
<StaticNavItem key={item.id} icon={item.icon} label={item.label} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { memo } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Database } from 'lucide-react'
|
||||
import { Handle, type NodeProps, Position } from 'reactflow'
|
||||
import { Blimp } from '@/components/emcn'
|
||||
import {
|
||||
AgentIcon,
|
||||
AnthropicIcon,
|
||||
@@ -63,6 +64,7 @@ const BLOCK_ICONS: Record<string, React.ComponentType<{ className?: string }>> =
|
||||
reducto: ReductoIcon,
|
||||
textract: TextractIcon,
|
||||
linkedin: LinkedInIcon,
|
||||
mothership: Blimp,
|
||||
}
|
||||
|
||||
/** Model prefix → provider icon for the "Model" row in agent blocks. */
|
||||
|
||||
@@ -91,11 +91,11 @@ const IT_SERVICE_WORKFLOW: PreviewWorkflow = {
|
||||
}
|
||||
|
||||
/**
|
||||
* Content pipeline workflow — Schedule -> Agent (X + YouTube tools)
|
||||
* Self-healing CRM workflow — Schedule -> Mothership
|
||||
*/
|
||||
const CONTENT_PIPELINE_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'wf-content-pipeline',
|
||||
name: 'Content Pipeline',
|
||||
const SELF_HEALING_CRM_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'wf-self-healing-crm',
|
||||
name: 'Self-healing CRM',
|
||||
color: '#33C482',
|
||||
blocks: [
|
||||
{
|
||||
@@ -111,23 +111,16 @@ const CONTENT_PIPELINE_WORKFLOW: PreviewWorkflow = {
|
||||
hideTargetHandle: true,
|
||||
},
|
||||
{
|
||||
id: 'agent-2',
|
||||
name: 'Agent',
|
||||
type: 'agent',
|
||||
bgColor: '#701ffc',
|
||||
rows: [
|
||||
{ title: 'Model', value: 'grok-4' },
|
||||
{ title: 'System Prompt', value: 'Repurpose trending...' },
|
||||
],
|
||||
tools: [
|
||||
{ name: 'X', type: 'x', bgColor: '#000000' },
|
||||
{ name: 'YouTube', type: 'youtube', bgColor: '#FF0000' },
|
||||
],
|
||||
id: 'mothership-1',
|
||||
name: 'Update Agent',
|
||||
type: 'mothership',
|
||||
bgColor: '#33C482',
|
||||
rows: [{ title: 'Prompt', value: 'Audit CRM records, fix...' }],
|
||||
position: { x: 420, y: 180 },
|
||||
hideSourceHandle: true,
|
||||
},
|
||||
],
|
||||
edges: [{ id: 'e-3', source: 'schedule-1', target: 'agent-2' }],
|
||||
edges: [{ id: 'e-3', source: 'schedule-1', target: 'mothership-1' }],
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -154,7 +147,7 @@ const NEW_AGENT_WORKFLOW: PreviewWorkflow = {
|
||||
}
|
||||
|
||||
export const PREVIEW_WORKFLOWS: PreviewWorkflow[] = [
|
||||
CONTENT_PIPELINE_WORKFLOW,
|
||||
SELF_HEALING_CRM_WORKFLOW,
|
||||
IT_SERVICE_WORKFLOW,
|
||||
NEW_AGENT_WORKFLOW,
|
||||
]
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { motion, type Variants } from 'framer-motion'
|
||||
import { LandingPreviewHome } from '@/app/(home)/components/landing-preview/components/landing-preview-home/landing-preview-home'
|
||||
import { LandingPreviewPanel } from '@/app/(home)/components/landing-preview/components/landing-preview-panel/landing-preview-panel'
|
||||
import { LandingPreviewSidebar } from '@/app/(home)/components/landing-preview/components/landing-preview-sidebar/landing-preview-sidebar'
|
||||
import { LandingPreviewWorkflow } from '@/app/(home)/components/landing-preview/components/landing-preview-workflow/landing-preview-workflow'
|
||||
@@ -56,6 +57,7 @@ const panelVariants: Variants = {
|
||||
* load — workflow switches render instantly.
|
||||
*/
|
||||
export function LandingPreview() {
|
||||
const [activeView, setActiveView] = useState<'home' | 'workflow'>('workflow')
|
||||
const [activeWorkflowId, setActiveWorkflowId] = useState(PREVIEW_WORKFLOWS[0].id)
|
||||
const isInitialMount = useRef(true)
|
||||
|
||||
@@ -63,12 +65,23 @@ export function LandingPreview() {
|
||||
isInitialMount.current = false
|
||||
}, [])
|
||||
|
||||
const handleSelectWorkflow = useCallback((id: string) => {
|
||||
setActiveWorkflowId(id)
|
||||
setActiveView('workflow')
|
||||
}, [])
|
||||
|
||||
const handleSelectHome = useCallback(() => {
|
||||
setActiveView('home')
|
||||
}, [])
|
||||
|
||||
const activeWorkflow =
|
||||
PREVIEW_WORKFLOWS.find((w) => w.id === activeWorkflowId) ?? PREVIEW_WORKFLOWS[0]
|
||||
|
||||
const isWorkflowView = activeView === 'workflow'
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className='dark flex aspect-[1116/549] w-full overflow-hidden rounded bg-[#1b1b1b] antialiased'
|
||||
className='dark flex aspect-[1116/549] w-full overflow-hidden rounded bg-[#1e1e1e] antialiased'
|
||||
initial='hidden'
|
||||
animate='visible'
|
||||
variants={containerVariants}
|
||||
@@ -77,15 +90,34 @@ export function LandingPreview() {
|
||||
<LandingPreviewSidebar
|
||||
workflows={PREVIEW_WORKFLOWS}
|
||||
activeWorkflowId={activeWorkflowId}
|
||||
onSelectWorkflow={setActiveWorkflowId}
|
||||
activeView={activeView}
|
||||
onSelectWorkflow={handleSelectWorkflow}
|
||||
onSelectHome={handleSelectHome}
|
||||
/>
|
||||
</motion.div>
|
||||
<div className='relative flex-1 overflow-hidden'>
|
||||
<LandingPreviewWorkflow workflow={activeWorkflow} animate={isInitialMount.current} />
|
||||
<div className='flex min-w-0 flex-1 flex-col p-[8px] pl-0'>
|
||||
<div className='flex flex-1 overflow-hidden rounded-[8px] border border-[#2c2c2c] bg-[#1b1b1b]'>
|
||||
<div
|
||||
className={
|
||||
isWorkflowView
|
||||
? 'relative min-w-0 flex-1 overflow-hidden'
|
||||
: 'relative flex min-w-0 flex-1 flex-col overflow-hidden'
|
||||
}
|
||||
>
|
||||
{isWorkflowView ? (
|
||||
<LandingPreviewWorkflow workflow={activeWorkflow} animate={isInitialMount.current} />
|
||||
) : (
|
||||
<LandingPreviewHome />
|
||||
)}
|
||||
</div>
|
||||
<motion.div
|
||||
className={isWorkflowView ? 'hidden lg:flex' : 'hidden'}
|
||||
variants={panelVariants}
|
||||
>
|
||||
<LandingPreviewPanel />
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
<motion.div className='hidden lg:flex' variants={panelVariants}>
|
||||
<LandingPreviewPanel />
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ export default function Navbar() {
|
||||
</Link>
|
||||
<Link
|
||||
href='/signup'
|
||||
className='inline-flex h-[30px] items-center gap-[7px] rounded-[5px] border border-[#33C482] bg-[#33C482] px-[9px] text-[13.5px] text-black transition-[filter] hover:brightness-110'
|
||||
className='inline-flex h-[30px] items-center gap-[7px] rounded-[5px] border border-[#FFFFFF] bg-[#FFFFFF] px-[9px] text-[13.5px] text-black transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]'
|
||||
aria-label='Get started with Sim'
|
||||
>
|
||||
Get started
|
||||
|
||||
@@ -123,7 +123,7 @@ function PricingCard({ tier }: PricingCardProps) {
|
||||
) : isPro ? (
|
||||
<Link
|
||||
href={tier.cta.href}
|
||||
className='flex h-[32px] w-full items-center justify-center rounded-[5px] border border-[#33C482] bg-[#33C482] px-[10px] font-[430] font-season text-[14px] text-white transition-[filter] hover:brightness-110'
|
||||
className='flex h-[32px] w-full items-center justify-center rounded-[5px] border border-[#1D1D1D] bg-[#1D1D1D] px-[10px] font-[430] font-season text-[14px] text-white transition-colors hover:border-[#2A2A2A] hover:bg-[#2A2A2A]'
|
||||
>
|
||||
{tier.cta.label}
|
||||
</Link>
|
||||
@@ -174,7 +174,7 @@ function PricingCard({ tier }: PricingCardProps) {
|
||||
export default function Pricing() {
|
||||
return (
|
||||
<section id='pricing' aria-labelledby='pricing-heading' className='bg-[#F6F6F6]'>
|
||||
<div className='px-4 pt-[100px] pb-8 sm:px-8 md:px-[80px]'>
|
||||
<div className='px-4 pt-[100px] pb-[80px] sm:px-8 md:px-[80px]'>
|
||||
<div className='flex flex-col items-start gap-3 sm:gap-4 md:gap-[20px]'>
|
||||
<Badge
|
||||
variant='blue'
|
||||
|
||||
@@ -337,7 +337,7 @@ function DotGrid({ className, cols, rows, gap = 0 }: DotGridProps) {
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: cols * rows }, (_, i) => (
|
||||
<div key={i} className='h-[2px] w-[2px] rounded-full bg-[#2A2A2A]' />
|
||||
<div key={i} className='h-[1.5px] w-[1.5px] rounded-full bg-[#2A2A2A]' />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
@@ -462,7 +462,7 @@ export default function Templates() {
|
||||
Ship your agent in minutes
|
||||
</h2>
|
||||
|
||||
<p className='font-[430] font-season text-[#F6F6F0]/50 text-[16px] leading-[125%] tracking-[0.02em]'>
|
||||
<p className='font-[430] font-season text-[#F6F6F0]/50 text-[18px] leading-[150%] tracking-[0.02em]'>
|
||||
Pre-built templates for every use case—pick one, swap <br />
|
||||
models and tools to fit your stack, and deploy.
|
||||
</p>
|
||||
@@ -557,7 +557,7 @@ export default function Templates() {
|
||||
type='button'
|
||||
onClick={handleUseTemplate}
|
||||
disabled={isPreparingTemplate}
|
||||
className='group/cta absolute top-[16px] right-[16px] z-10 inline-flex h-[32px] cursor-pointer items-center gap-[6px] rounded-[5px] border border-[#33C482] bg-[#33C482] px-[10px] font-[430] font-season text-[14px] text-black transition-[filter] hover:brightness-110'
|
||||
className='group/cta absolute top-[16px] right-[16px] z-10 inline-flex h-[32px] cursor-pointer items-center gap-[6px] rounded-[5px] border border-[#FFFFFF] bg-[#FFFFFF] px-[10px] font-[430] font-season text-[14px] text-black transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]'
|
||||
>
|
||||
{isPreparingTemplate ? 'Preparing...' : 'Use template'}
|
||||
<span className='relative h-[10px] w-[10px] shrink-0'>
|
||||
|
||||
@@ -33,27 +33,25 @@
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
html[data-sidebar-collapsed] .sidebar-container span,
|
||||
html[data-sidebar-collapsed] .sidebar-container .text-small {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.sidebar-container .sidebar-collapse-hide {
|
||||
transition: opacity 60ms ease;
|
||||
}
|
||||
|
||||
.sidebar-container[data-collapsed] .sidebar-collapse-hide,
|
||||
html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-hide {
|
||||
.sidebar-container[data-collapsed] .sidebar-collapse-hide {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-remove {
|
||||
display: none;
|
||||
@keyframes sidebar-collapse-guard {
|
||||
from {
|
||||
pointer-events: none;
|
||||
}
|
||||
to {
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-btn {
|
||||
width: 0;
|
||||
opacity: 0;
|
||||
.sidebar-container[data-collapsed] {
|
||||
animation: sidebar-collapse-guard 250ms step-end;
|
||||
}
|
||||
|
||||
.sidebar-container.is-resizing {
|
||||
@@ -160,7 +158,7 @@ html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-btn {
|
||||
--brand-400: #8e4cfb;
|
||||
--brand-secondary: #33b4ff;
|
||||
--brand-tertiary: #22c55e;
|
||||
--brand-tertiary-2: #32bd7e;
|
||||
--brand-tertiary-2: #33c482;
|
||||
--selection: #1a5cf6;
|
||||
--warning: #ea580c;
|
||||
|
||||
@@ -282,7 +280,7 @@ html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-btn {
|
||||
--brand-400: #8e4cfb;
|
||||
--brand-secondary: #33b4ff;
|
||||
--brand-tertiary: #22c55e;
|
||||
--brand-tertiary-2: #32bd7e;
|
||||
--brand-tertiary-2: #33c482;
|
||||
--selection: #4b83f7;
|
||||
--warning: #ff6600;
|
||||
|
||||
|
||||
187
apps/sim/app/api/attribution/route.ts
Normal file
187
apps/sim/app/api/attribution/route.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* POST /api/attribution
|
||||
*
|
||||
* Automatic UTM-based referral attribution.
|
||||
*
|
||||
* Reads the `sim_utm` cookie (set by proxy on auth pages), matches a campaign
|
||||
* by UTM specificity, and atomically inserts an attribution record + applies
|
||||
* bonus credits.
|
||||
*
|
||||
* Idempotent — the unique constraint on `userId` prevents double-attribution.
|
||||
*/
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { referralAttribution, referralCampaigns, userStats } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { cookies } from 'next/headers'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { applyBonusCredits } from '@/lib/billing/credits/bonus'
|
||||
|
||||
const logger = createLogger('AttributionAPI')
|
||||
|
||||
const COOKIE_NAME = 'sim_utm'
|
||||
|
||||
const UtmCookieSchema = z.object({
|
||||
utm_source: z.string().optional(),
|
||||
utm_medium: z.string().optional(),
|
||||
utm_campaign: z.string().optional(),
|
||||
utm_content: z.string().optional(),
|
||||
referrer_url: z.string().optional(),
|
||||
landing_page: z.string().optional(),
|
||||
created_at: z.string().optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Finds the most specific active campaign matching the given UTM params.
|
||||
* Null fields on a campaign act as wildcards. Ties broken by newest campaign.
|
||||
*/
|
||||
async function findMatchingCampaign(utmData: z.infer<typeof UtmCookieSchema>) {
|
||||
const campaigns = await db
|
||||
.select()
|
||||
.from(referralCampaigns)
|
||||
.where(eq(referralCampaigns.isActive, true))
|
||||
|
||||
let bestMatch: (typeof campaigns)[number] | null = null
|
||||
let bestScore = -1
|
||||
|
||||
for (const campaign of campaigns) {
|
||||
let score = 0
|
||||
let mismatch = false
|
||||
|
||||
const fields = [
|
||||
{ campaignVal: campaign.utmSource, utmVal: utmData.utm_source },
|
||||
{ campaignVal: campaign.utmMedium, utmVal: utmData.utm_medium },
|
||||
{ campaignVal: campaign.utmCampaign, utmVal: utmData.utm_campaign },
|
||||
{ campaignVal: campaign.utmContent, utmVal: utmData.utm_content },
|
||||
] as const
|
||||
|
||||
for (const { campaignVal, utmVal } of fields) {
|
||||
if (campaignVal === null) continue
|
||||
if (campaignVal === utmVal) {
|
||||
score++
|
||||
} else {
|
||||
mismatch = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!mismatch && score > 0) {
|
||||
if (
|
||||
score > bestScore ||
|
||||
(score === bestScore &&
|
||||
bestMatch &&
|
||||
campaign.createdAt.getTime() > bestMatch.createdAt.getTime())
|
||||
) {
|
||||
bestScore = score
|
||||
bestMatch = campaign
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bestMatch
|
||||
}
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const cookieStore = await cookies()
|
||||
const utmCookie = cookieStore.get(COOKIE_NAME)
|
||||
if (!utmCookie?.value) {
|
||||
return NextResponse.json({ attributed: false, reason: 'no_utm_cookie' })
|
||||
}
|
||||
|
||||
let utmData: z.infer<typeof UtmCookieSchema>
|
||||
try {
|
||||
let decoded: string
|
||||
try {
|
||||
decoded = decodeURIComponent(utmCookie.value)
|
||||
} catch {
|
||||
decoded = utmCookie.value
|
||||
}
|
||||
utmData = UtmCookieSchema.parse(JSON.parse(decoded))
|
||||
} catch {
|
||||
logger.warn('Failed to parse UTM cookie', { userId: session.user.id })
|
||||
cookieStore.delete(COOKIE_NAME)
|
||||
return NextResponse.json({ attributed: false, reason: 'invalid_cookie' })
|
||||
}
|
||||
|
||||
const matchedCampaign = await findMatchingCampaign(utmData)
|
||||
if (!matchedCampaign) {
|
||||
cookieStore.delete(COOKIE_NAME)
|
||||
return NextResponse.json({ attributed: false, reason: 'no_matching_campaign' })
|
||||
}
|
||||
|
||||
const bonusAmount = Number(matchedCampaign.bonusCreditAmount)
|
||||
|
||||
let attributed = false
|
||||
await db.transaction(async (tx) => {
|
||||
const [existingStats] = await tx
|
||||
.select({ id: userStats.id })
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, session.user.id))
|
||||
.limit(1)
|
||||
|
||||
if (!existingStats) {
|
||||
await tx.insert(userStats).values({
|
||||
id: nanoid(),
|
||||
userId: session.user.id,
|
||||
})
|
||||
}
|
||||
|
||||
const result = await tx
|
||||
.insert(referralAttribution)
|
||||
.values({
|
||||
id: nanoid(),
|
||||
userId: session.user.id,
|
||||
campaignId: matchedCampaign.id,
|
||||
utmSource: utmData.utm_source || null,
|
||||
utmMedium: utmData.utm_medium || null,
|
||||
utmCampaign: utmData.utm_campaign || null,
|
||||
utmContent: utmData.utm_content || null,
|
||||
referrerUrl: utmData.referrer_url || null,
|
||||
landingPage: utmData.landing_page || null,
|
||||
bonusCreditAmount: bonusAmount.toString(),
|
||||
})
|
||||
.onConflictDoNothing({ target: referralAttribution.userId })
|
||||
.returning({ id: referralAttribution.id })
|
||||
|
||||
if (result.length > 0) {
|
||||
await applyBonusCredits(session.user.id, bonusAmount, tx)
|
||||
attributed = true
|
||||
}
|
||||
})
|
||||
|
||||
if (attributed) {
|
||||
logger.info('Referral attribution created and bonus credits applied', {
|
||||
userId: session.user.id,
|
||||
campaignId: matchedCampaign.id,
|
||||
campaignName: matchedCampaign.name,
|
||||
utmSource: utmData.utm_source,
|
||||
utmCampaign: utmData.utm_campaign,
|
||||
utmContent: utmData.utm_content,
|
||||
bonusAmount,
|
||||
})
|
||||
} else {
|
||||
logger.info('User already attributed, skipping', { userId: session.user.id })
|
||||
}
|
||||
|
||||
cookieStore.delete(COOKIE_NAME)
|
||||
|
||||
return NextResponse.json({
|
||||
attributed,
|
||||
bonusAmount: attributed ? bonusAmount : undefined,
|
||||
reason: attributed ? undefined : 'already_attributed',
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Attribution error', { error })
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
} from '@/lib/copilot/chat-streaming'
|
||||
import { COPILOT_REQUEST_MODES } from '@/lib/copilot/models'
|
||||
import { orchestrateCopilotStream } from '@/lib/copilot/orchestrator'
|
||||
import { getStreamMeta, readStreamEvents } from '@/lib/copilot/orchestrator/stream/buffer'
|
||||
import {
|
||||
authenticateCopilotRequestSessionOnly,
|
||||
createBadRequestResponse,
|
||||
@@ -455,30 +454,6 @@ export async function GET(req: NextRequest) {
|
||||
return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
let streamSnapshot: {
|
||||
events: Array<{ eventId: number; streamId: string; event: Record<string, unknown> }>
|
||||
status: string
|
||||
} | null = null
|
||||
|
||||
if (chat.conversationId) {
|
||||
try {
|
||||
const [meta, events] = await Promise.all([
|
||||
getStreamMeta(chat.conversationId),
|
||||
readStreamEvents(chat.conversationId, 0),
|
||||
])
|
||||
streamSnapshot = {
|
||||
events: events || [],
|
||||
status: meta?.status || 'unknown',
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('Failed to read stream snapshot for chat', {
|
||||
chatId,
|
||||
conversationId: chat.conversationId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const transformedChat = {
|
||||
id: chat.id,
|
||||
title: chat.title,
|
||||
@@ -491,7 +466,6 @@ export async function GET(req: NextRequest) {
|
||||
resources: Array.isArray(chat.resources) ? chat.resources : [],
|
||||
createdAt: chat.createdAt,
|
||||
updatedAt: chat.updatedAt,
|
||||
...(streamSnapshot ? { streamSnapshot } : {}),
|
||||
}
|
||||
|
||||
logger.info(`Retrieved chat ${chatId}`)
|
||||
|
||||
@@ -171,7 +171,7 @@ export async function GET(request: NextRequest) {
|
||||
([category, templates]) => `
|
||||
<h2 style="margin-top: 24px; margin-bottom: 12px; font-size: 14px; color: #666; text-transform: uppercase; letter-spacing: 0.5px;">${category}</h2>
|
||||
<ul style="list-style: none; padding: 0; margin: 0;">
|
||||
${templates.map((t) => `<li style="margin: 8px 0;"><a href="?template=${t}" style="color: #32bd7e; text-decoration: none; font-size: 16px;">${t}</a></li>`).join('')}
|
||||
${templates.map((t) => `<li style="margin: 8px 0;"><a href="?template=${t}" style="color: #33C482; text-decoration: none; font-size: 16px;">${t}</a></li>`).join('')}
|
||||
</ul>
|
||||
`
|
||||
)
|
||||
|
||||
@@ -120,8 +120,8 @@ export async function verifyFileAccess(
|
||||
return true
|
||||
}
|
||||
|
||||
// 1. Workspace / mothership files: Check database first (most reliable for both local and cloud)
|
||||
if (inferredContext === 'workspace' || inferredContext === 'mothership') {
|
||||
// 1. Workspace files: Check database first (most reliable for both local and cloud)
|
||||
if (inferredContext === 'workspace') {
|
||||
return await verifyWorkspaceFileAccess(cloudKey, userId, customConfig, isLocal)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import { sanitizeFileName } from '@/executor/constants'
|
||||
import '@/lib/uploads/core/setup.server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import type { StorageContext } from '@/lib/uploads/config'
|
||||
import { generateWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
|
||||
import { isImageFileType, resolveFileType } from '@/lib/uploads/utils/file-utils'
|
||||
import {
|
||||
SUPPORTED_AUDIO_EXTENSIONS,
|
||||
@@ -75,7 +74,10 @@ export async function POST(request: NextRequest) {
|
||||
const uploadResults = []
|
||||
|
||||
for (const file of files) {
|
||||
const originalName = file.name || 'untitled'
|
||||
const originalName = file.name
|
||||
if (!originalName) {
|
||||
throw new InvalidRequestError('File name is missing')
|
||||
}
|
||||
|
||||
if (!validateFileExtension(originalName)) {
|
||||
const extension = originalName.split('.').pop()?.toLowerCase() || 'unknown'
|
||||
@@ -233,53 +235,6 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle mothership context (chat-scoped uploads to workspace S3)
|
||||
if (context === 'mothership') {
|
||||
if (!workspaceId) {
|
||||
throw new InvalidRequestError('Mothership context requires workspaceId parameter')
|
||||
}
|
||||
|
||||
logger.info(`Uploading mothership file: ${originalName}`)
|
||||
|
||||
const storageKey = generateWorkspaceFileKey(workspaceId, originalName)
|
||||
|
||||
const metadata: Record<string, string> = {
|
||||
originalName: originalName,
|
||||
uploadedAt: new Date().toISOString(),
|
||||
purpose: 'mothership',
|
||||
userId: session.user.id,
|
||||
workspaceId,
|
||||
}
|
||||
|
||||
const fileInfo = await storageService.uploadFile({
|
||||
file: buffer,
|
||||
fileName: storageKey,
|
||||
contentType: file.type || 'application/octet-stream',
|
||||
context: 'mothership',
|
||||
preserveKey: true,
|
||||
customKey: storageKey,
|
||||
metadata,
|
||||
})
|
||||
|
||||
const finalPath = usingCloudStorage ? `${fileInfo.path}?context=mothership` : fileInfo.path
|
||||
|
||||
uploadResults.push({
|
||||
fileName: originalName,
|
||||
presignedUrl: '',
|
||||
fileInfo: {
|
||||
path: finalPath,
|
||||
key: fileInfo.key,
|
||||
name: originalName,
|
||||
size: buffer.length,
|
||||
type: file.type || 'application/octet-stream',
|
||||
},
|
||||
directUploadSupported: false,
|
||||
})
|
||||
|
||||
logger.info(`Successfully uploaded mothership file: ${fileInfo.key}`)
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle copilot, chat, profile-pictures contexts
|
||||
if (context === 'copilot' || context === 'chat' || context === 'profile-pictures') {
|
||||
if (context === 'copilot') {
|
||||
|
||||
@@ -31,8 +31,6 @@ const FileAttachmentSchema = z.object({
|
||||
const ResourceAttachmentSchema = z.object({
|
||||
type: z.enum(['workflow', 'table', 'file', 'knowledgebase']),
|
||||
id: z.string().min(1),
|
||||
title: z.string().optional(),
|
||||
active: z.boolean().optional(),
|
||||
})
|
||||
|
||||
const MothershipMessageSchema = z.object({
|
||||
@@ -126,19 +124,9 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
if (Array.isArray(resourceAttachments) && resourceAttachments.length > 0) {
|
||||
const results = await Promise.allSettled(
|
||||
resourceAttachments.map(async (r) => {
|
||||
const ctx = await resolveActiveResourceContext(
|
||||
r.type,
|
||||
r.id,
|
||||
workspaceId,
|
||||
authenticatedUserId
|
||||
)
|
||||
if (!ctx) return null
|
||||
return {
|
||||
...ctx,
|
||||
tag: r.active ? '@active_tab' : '@open_tab',
|
||||
}
|
||||
})
|
||||
resourceAttachments.map((r) =>
|
||||
resolveActiveResourceContext(r.type, r.id, workspaceId, authenticatedUserId)
|
||||
)
|
||||
)
|
||||
for (const result of results) {
|
||||
if (result.status === 'fulfilled' && result.value) {
|
||||
|
||||
171
apps/sim/app/api/referral-code/redeem/route.ts
Normal file
171
apps/sim/app/api/referral-code/redeem/route.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* POST /api/referral-code/redeem
|
||||
*
|
||||
* Redeem a referral/promo code to receive bonus credits.
|
||||
*
|
||||
* Body:
|
||||
* - code: string — The referral code to redeem
|
||||
*
|
||||
* Response: { redeemed: boolean, bonusAmount?: number, error?: string }
|
||||
*
|
||||
* Constraints:
|
||||
* - Enterprise users cannot redeem codes
|
||||
* - One redemption per user, ever (unique constraint on userId)
|
||||
* - One redemption per organization for team users (partial unique on organizationId)
|
||||
*/
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { referralAttribution, referralCampaigns, userStats } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
|
||||
import { applyBonusCredits } from '@/lib/billing/credits/bonus'
|
||||
import { isEnterprise, isTeam } from '@/lib/billing/plan-helpers'
|
||||
|
||||
const logger = createLogger('ReferralCodeRedemption')
|
||||
|
||||
const RedeemCodeSchema = z.object({
|
||||
code: z.string().min(1, 'Code is required'),
|
||||
})
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { code } = RedeemCodeSchema.parse(body)
|
||||
|
||||
const subscription = await getHighestPrioritySubscription(session.user.id)
|
||||
|
||||
if (isEnterprise(subscription?.plan)) {
|
||||
return NextResponse.json({
|
||||
redeemed: false,
|
||||
error: 'Enterprise accounts cannot redeem referral codes',
|
||||
})
|
||||
}
|
||||
|
||||
const isTeamSub = isTeam(subscription?.plan)
|
||||
const orgId = isTeamSub ? subscription!.referenceId : null
|
||||
|
||||
const normalizedCode = code.trim().toUpperCase()
|
||||
|
||||
const [campaign] = await db
|
||||
.select()
|
||||
.from(referralCampaigns)
|
||||
.where(and(eq(referralCampaigns.code, normalizedCode), eq(referralCampaigns.isActive, true)))
|
||||
.limit(1)
|
||||
|
||||
if (!campaign) {
|
||||
logger.info('Invalid code redemption attempt', {
|
||||
userId: session.user.id,
|
||||
code: normalizedCode,
|
||||
})
|
||||
return NextResponse.json({ error: 'Invalid or expired code' }, { status: 404 })
|
||||
}
|
||||
|
||||
const [existingUserAttribution] = await db
|
||||
.select({ id: referralAttribution.id })
|
||||
.from(referralAttribution)
|
||||
.where(eq(referralAttribution.userId, session.user.id))
|
||||
.limit(1)
|
||||
|
||||
if (existingUserAttribution) {
|
||||
return NextResponse.json({
|
||||
redeemed: false,
|
||||
error: 'You have already redeemed a code',
|
||||
})
|
||||
}
|
||||
|
||||
if (orgId) {
|
||||
const [existingOrgAttribution] = await db
|
||||
.select({ id: referralAttribution.id })
|
||||
.from(referralAttribution)
|
||||
.where(eq(referralAttribution.organizationId, orgId))
|
||||
.limit(1)
|
||||
|
||||
if (existingOrgAttribution) {
|
||||
return NextResponse.json({
|
||||
redeemed: false,
|
||||
error: 'A code has already been redeemed for your organization',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const bonusAmount = Number(campaign.bonusCreditAmount)
|
||||
|
||||
let redeemed = false
|
||||
await db.transaction(async (tx) => {
|
||||
const [existingStats] = await tx
|
||||
.select({ id: userStats.id })
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, session.user.id))
|
||||
.limit(1)
|
||||
|
||||
if (!existingStats) {
|
||||
await tx.insert(userStats).values({
|
||||
id: nanoid(),
|
||||
userId: session.user.id,
|
||||
})
|
||||
}
|
||||
|
||||
const result = await tx
|
||||
.insert(referralAttribution)
|
||||
.values({
|
||||
id: nanoid(),
|
||||
userId: session.user.id,
|
||||
organizationId: orgId,
|
||||
campaignId: campaign.id,
|
||||
utmSource: null,
|
||||
utmMedium: null,
|
||||
utmCampaign: null,
|
||||
utmContent: null,
|
||||
referrerUrl: null,
|
||||
landingPage: null,
|
||||
bonusCreditAmount: bonusAmount.toString(),
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning({ id: referralAttribution.id })
|
||||
|
||||
if (result.length > 0) {
|
||||
await applyBonusCredits(session.user.id, bonusAmount, tx)
|
||||
redeemed = true
|
||||
}
|
||||
})
|
||||
|
||||
if (redeemed) {
|
||||
logger.info('Referral code redeemed', {
|
||||
userId: session.user.id,
|
||||
organizationId: orgId,
|
||||
code: normalizedCode,
|
||||
campaignId: campaign.id,
|
||||
campaignName: campaign.name,
|
||||
bonusAmount,
|
||||
})
|
||||
}
|
||||
|
||||
if (!redeemed) {
|
||||
return NextResponse.json({
|
||||
redeemed: false,
|
||||
error: 'You have already redeemed a code',
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
redeemed: true,
|
||||
bonusAmount,
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: error.errors[0].message }, { status: 400 })
|
||||
}
|
||||
logger.error('Referral code redemption error', { error })
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -103,6 +103,7 @@ export type {
|
||||
AdminOrganization,
|
||||
AdminOrganizationBillingSummary,
|
||||
AdminOrganizationDetail,
|
||||
AdminReferralCampaign,
|
||||
AdminSeatAnalytics,
|
||||
AdminSingleResponse,
|
||||
AdminSubscription,
|
||||
@@ -117,6 +118,7 @@ export type {
|
||||
AdminWorkspaceMember,
|
||||
DbMember,
|
||||
DbOrganization,
|
||||
DbReferralCampaign,
|
||||
DbSubscription,
|
||||
DbUser,
|
||||
DbUserStats,
|
||||
@@ -145,6 +147,7 @@ export {
|
||||
parseWorkflowVariables,
|
||||
toAdminFolder,
|
||||
toAdminOrganization,
|
||||
toAdminReferralCampaign,
|
||||
toAdminSubscription,
|
||||
toAdminUser,
|
||||
toAdminWorkflow,
|
||||
|
||||
142
apps/sim/app/api/v1/admin/referral-campaigns/[id]/route.ts
Normal file
142
apps/sim/app/api/v1/admin/referral-campaigns/[id]/route.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* GET /api/v1/admin/referral-campaigns/:id
|
||||
*
|
||||
* Get a single referral campaign by ID.
|
||||
*
|
||||
* PATCH /api/v1/admin/referral-campaigns/:id
|
||||
*
|
||||
* Update campaign fields. All fields are optional.
|
||||
*
|
||||
* Body:
|
||||
* - name: string (non-empty) - Campaign name
|
||||
* - bonusCreditAmount: number (> 0) - Bonus credits in dollars
|
||||
* - isActive: boolean - Enable/disable the campaign
|
||||
* - code: string | null (min 6 chars, auto-uppercased, null to remove) - Redeemable code
|
||||
* - utmSource: string | null - UTM source match (null = wildcard)
|
||||
* - utmMedium: string | null - UTM medium match (null = wildcard)
|
||||
* - utmCampaign: string | null - UTM campaign match (null = wildcard)
|
||||
* - utmContent: string | null - UTM content match (null = wildcard)
|
||||
*/
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { referralCampaigns } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
|
||||
import {
|
||||
badRequestResponse,
|
||||
internalErrorResponse,
|
||||
notFoundResponse,
|
||||
singleResponse,
|
||||
} from '@/app/api/v1/admin/responses'
|
||||
import { toAdminReferralCampaign } from '@/app/api/v1/admin/types'
|
||||
|
||||
const logger = createLogger('AdminReferralCampaignDetailAPI')
|
||||
|
||||
interface RouteParams {
|
||||
id: string
|
||||
}
|
||||
|
||||
export const GET = withAdminAuthParams<RouteParams>(async (_, context) => {
|
||||
try {
|
||||
const { id: campaignId } = await context.params
|
||||
|
||||
const [campaign] = await db
|
||||
.select()
|
||||
.from(referralCampaigns)
|
||||
.where(eq(referralCampaigns.id, campaignId))
|
||||
.limit(1)
|
||||
|
||||
if (!campaign) {
|
||||
return notFoundResponse('Campaign')
|
||||
}
|
||||
|
||||
logger.info(`Admin API: Retrieved referral campaign ${campaignId}`)
|
||||
|
||||
return singleResponse(toAdminReferralCampaign(campaign, getBaseUrl()))
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to get referral campaign', { error })
|
||||
return internalErrorResponse('Failed to get referral campaign')
|
||||
}
|
||||
})
|
||||
|
||||
export const PATCH = withAdminAuthParams<RouteParams>(async (request, context) => {
|
||||
try {
|
||||
const { id: campaignId } = await context.params
|
||||
const body = await request.json()
|
||||
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(referralCampaigns)
|
||||
.where(eq(referralCampaigns.id, campaignId))
|
||||
.limit(1)
|
||||
|
||||
if (!existing) {
|
||||
return notFoundResponse('Campaign')
|
||||
}
|
||||
|
||||
const updateData: Record<string, unknown> = { updatedAt: new Date() }
|
||||
|
||||
if (body.name !== undefined) {
|
||||
if (typeof body.name !== 'string' || body.name.trim().length === 0) {
|
||||
return badRequestResponse('name must be a non-empty string')
|
||||
}
|
||||
updateData.name = body.name.trim()
|
||||
}
|
||||
|
||||
if (body.bonusCreditAmount !== undefined) {
|
||||
if (
|
||||
typeof body.bonusCreditAmount !== 'number' ||
|
||||
!Number.isFinite(body.bonusCreditAmount) ||
|
||||
body.bonusCreditAmount <= 0
|
||||
) {
|
||||
return badRequestResponse('bonusCreditAmount must be a positive number')
|
||||
}
|
||||
updateData.bonusCreditAmount = body.bonusCreditAmount.toString()
|
||||
}
|
||||
|
||||
if (body.isActive !== undefined) {
|
||||
if (typeof body.isActive !== 'boolean') {
|
||||
return badRequestResponse('isActive must be a boolean')
|
||||
}
|
||||
updateData.isActive = body.isActive
|
||||
}
|
||||
|
||||
if (body.code !== undefined) {
|
||||
if (body.code !== null) {
|
||||
if (typeof body.code !== 'string') {
|
||||
return badRequestResponse('code must be a string or null')
|
||||
}
|
||||
if (body.code.trim().length < 6) {
|
||||
return badRequestResponse('code must be at least 6 characters')
|
||||
}
|
||||
}
|
||||
updateData.code = body.code ? body.code.trim().toUpperCase() : null
|
||||
}
|
||||
|
||||
for (const field of ['utmSource', 'utmMedium', 'utmCampaign', 'utmContent'] as const) {
|
||||
if (body[field] !== undefined) {
|
||||
if (body[field] !== null && typeof body[field] !== 'string') {
|
||||
return badRequestResponse(`${field} must be a string or null`)
|
||||
}
|
||||
updateData[field] = body[field] || null
|
||||
}
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(referralCampaigns)
|
||||
.set(updateData)
|
||||
.where(eq(referralCampaigns.id, campaignId))
|
||||
.returning()
|
||||
|
||||
logger.info(`Admin API: Updated referral campaign ${campaignId}`, {
|
||||
fields: Object.keys(updateData).filter((k) => k !== 'updatedAt'),
|
||||
})
|
||||
|
||||
return singleResponse(toAdminReferralCampaign(updated, getBaseUrl()))
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to update referral campaign', { error })
|
||||
return internalErrorResponse('Failed to update referral campaign')
|
||||
}
|
||||
})
|
||||
@@ -1,160 +1,104 @@
|
||||
/**
|
||||
* GET /api/v1/admin/referral-campaigns
|
||||
*
|
||||
* List Stripe promotion codes with cursor-based pagination.
|
||||
* List referral campaigns with optional filtering and pagination.
|
||||
*
|
||||
* Query Parameters:
|
||||
* - limit: number (default: 50, max: 100)
|
||||
* - starting_after: string (cursor — Stripe promotion code ID)
|
||||
* - active: 'true' | 'false' (optional filter)
|
||||
* - active: string (optional) - Filter by active status ('true' or 'false')
|
||||
* - limit: number (default: 50, max: 250)
|
||||
* - offset: number (default: 0)
|
||||
*
|
||||
* POST /api/v1/admin/referral-campaigns
|
||||
*
|
||||
* Create a Stripe coupon and an associated promotion code.
|
||||
* Create a new referral campaign.
|
||||
*
|
||||
* Body:
|
||||
* - name: string (required) — Display name for the coupon
|
||||
* - percentOff: number (required, 1–100) — Percentage discount
|
||||
* - code: string | null (optional, min 6 chars, auto-uppercased) — Desired code
|
||||
* - duration: 'once' | 'repeating' | 'forever' (default: 'once')
|
||||
* - durationInMonths: number (required when duration is 'repeating')
|
||||
* - maxRedemptions: number (optional) — Total redemption cap
|
||||
* - expiresAt: ISO 8601 string (optional) — Promotion code expiry
|
||||
* - name: string (required) - Campaign name
|
||||
* - bonusCreditAmount: number (required, > 0) - Bonus credits in dollars
|
||||
* - code: string | null (optional, min 6 chars, auto-uppercased) - Redeemable code
|
||||
* - utmSource: string | null (optional) - UTM source match (null = wildcard)
|
||||
* - utmMedium: string | null (optional) - UTM medium match (null = wildcard)
|
||||
* - utmCampaign: string | null (optional) - UTM campaign match (null = wildcard)
|
||||
* - utmContent: string | null (optional) - UTM content match (null = wildcard)
|
||||
*/
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { referralCampaigns } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { NextResponse } from 'next/server'
|
||||
import type Stripe from 'stripe'
|
||||
import { requireStripeClient } from '@/lib/billing/stripe-client'
|
||||
import { count, eq, type SQL } from 'drizzle-orm'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
|
||||
import {
|
||||
badRequestResponse,
|
||||
internalErrorResponse,
|
||||
listResponse,
|
||||
singleResponse,
|
||||
} from '@/app/api/v1/admin/responses'
|
||||
import {
|
||||
type AdminReferralCampaign,
|
||||
createPaginationMeta,
|
||||
parsePaginationParams,
|
||||
toAdminReferralCampaign,
|
||||
} from '@/app/api/v1/admin/types'
|
||||
|
||||
const logger = createLogger('AdminPromoCodes')
|
||||
|
||||
const VALID_DURATIONS = ['once', 'repeating', 'forever'] as const
|
||||
type Duration = (typeof VALID_DURATIONS)[number]
|
||||
|
||||
interface PromoCodeResponse {
|
||||
id: string
|
||||
code: string
|
||||
couponId: string
|
||||
name: string
|
||||
percentOff: number
|
||||
duration: string
|
||||
durationInMonths: number | null
|
||||
maxRedemptions: number | null
|
||||
expiresAt: string | null
|
||||
active: boolean
|
||||
timesRedeemed: number
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
function formatPromoCode(promo: {
|
||||
id: string
|
||||
code: string
|
||||
coupon: {
|
||||
id: string
|
||||
name: string | null
|
||||
percent_off: number | null
|
||||
duration: string
|
||||
duration_in_months: number | null
|
||||
}
|
||||
max_redemptions: number | null
|
||||
expires_at: number | null
|
||||
active: boolean
|
||||
times_redeemed: number
|
||||
created: number
|
||||
}): PromoCodeResponse {
|
||||
return {
|
||||
id: promo.id,
|
||||
code: promo.code,
|
||||
couponId: promo.coupon.id,
|
||||
name: promo.coupon.name ?? '',
|
||||
percentOff: promo.coupon.percent_off ?? 0,
|
||||
duration: promo.coupon.duration,
|
||||
durationInMonths: promo.coupon.duration_in_months,
|
||||
maxRedemptions: promo.max_redemptions,
|
||||
expiresAt: promo.expires_at ? new Date(promo.expires_at * 1000).toISOString() : null,
|
||||
active: promo.active,
|
||||
timesRedeemed: promo.times_redeemed,
|
||||
createdAt: new Date(promo.created * 1000).toISOString(),
|
||||
}
|
||||
}
|
||||
const logger = createLogger('AdminReferralCampaignsAPI')
|
||||
|
||||
export const GET = withAdminAuth(async (request) => {
|
||||
const url = new URL(request.url)
|
||||
const { limit, offset } = parsePaginationParams(url)
|
||||
const activeFilter = url.searchParams.get('active')
|
||||
|
||||
try {
|
||||
const stripe = requireStripeClient()
|
||||
const url = new URL(request.url)
|
||||
const conditions: SQL<unknown>[] = []
|
||||
if (activeFilter === 'true') {
|
||||
conditions.push(eq(referralCampaigns.isActive, true))
|
||||
} else if (activeFilter === 'false') {
|
||||
conditions.push(eq(referralCampaigns.isActive, false))
|
||||
}
|
||||
|
||||
const limitParam = url.searchParams.get('limit')
|
||||
let limit = limitParam ? Number.parseInt(limitParam, 10) : 50
|
||||
if (Number.isNaN(limit) || limit < 1) limit = 50
|
||||
if (limit > 100) limit = 100
|
||||
const whereClause = conditions.length > 0 ? conditions[0] : undefined
|
||||
const baseUrl = getBaseUrl()
|
||||
|
||||
const startingAfter = url.searchParams.get('starting_after') || undefined
|
||||
const activeFilter = url.searchParams.get('active')
|
||||
const [countResult, campaigns] = await Promise.all([
|
||||
db.select({ total: count() }).from(referralCampaigns).where(whereClause),
|
||||
db
|
||||
.select()
|
||||
.from(referralCampaigns)
|
||||
.where(whereClause)
|
||||
.orderBy(referralCampaigns.createdAt)
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
])
|
||||
|
||||
const listParams: Record<string, unknown> = { limit }
|
||||
if (startingAfter) listParams.starting_after = startingAfter
|
||||
if (activeFilter === 'true') listParams.active = true
|
||||
else if (activeFilter === 'false') listParams.active = false
|
||||
const total = countResult[0].total
|
||||
const data: AdminReferralCampaign[] = campaigns.map((c) => toAdminReferralCampaign(c, baseUrl))
|
||||
const pagination = createPaginationMeta(total, limit, offset)
|
||||
|
||||
const promoCodes = await stripe.promotionCodes.list(listParams)
|
||||
logger.info(`Admin API: Listed ${data.length} referral campaigns (total: ${total})`)
|
||||
|
||||
const data = promoCodes.data.map(formatPromoCode)
|
||||
|
||||
logger.info(`Admin API: Listed ${data.length} Stripe promotion codes`)
|
||||
|
||||
return NextResponse.json({
|
||||
data,
|
||||
hasMore: promoCodes.has_more,
|
||||
...(data.length > 0 ? { nextCursor: data[data.length - 1].id } : {}),
|
||||
})
|
||||
return listResponse(data, pagination)
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to list promotion codes', { error })
|
||||
return internalErrorResponse('Failed to list promotion codes')
|
||||
logger.error('Admin API: Failed to list referral campaigns', { error })
|
||||
return internalErrorResponse('Failed to list referral campaigns')
|
||||
}
|
||||
})
|
||||
|
||||
export const POST = withAdminAuth(async (request) => {
|
||||
try {
|
||||
const stripe = requireStripeClient()
|
||||
const body = await request.json()
|
||||
const { name, code, utmSource, utmMedium, utmCampaign, utmContent, bonusCreditAmount } = body
|
||||
|
||||
const { name, percentOff, code, duration, durationInMonths, maxRedemptions, expiresAt } = body
|
||||
|
||||
if (!name || typeof name !== 'string' || name.trim().length === 0) {
|
||||
return badRequestResponse('name is required and must be a non-empty string')
|
||||
if (!name || typeof name !== 'string') {
|
||||
return badRequestResponse('name is required and must be a string')
|
||||
}
|
||||
|
||||
if (
|
||||
typeof percentOff !== 'number' ||
|
||||
!Number.isFinite(percentOff) ||
|
||||
percentOff < 1 ||
|
||||
percentOff > 100
|
||||
typeof bonusCreditAmount !== 'number' ||
|
||||
!Number.isFinite(bonusCreditAmount) ||
|
||||
bonusCreditAmount <= 0
|
||||
) {
|
||||
return badRequestResponse('percentOff must be a number between 1 and 100')
|
||||
}
|
||||
|
||||
const effectiveDuration: Duration = duration ?? 'once'
|
||||
if (!VALID_DURATIONS.includes(effectiveDuration)) {
|
||||
return badRequestResponse(`duration must be one of: ${VALID_DURATIONS.join(', ')}`)
|
||||
}
|
||||
|
||||
if (effectiveDuration === 'repeating') {
|
||||
if (
|
||||
typeof durationInMonths !== 'number' ||
|
||||
!Number.isInteger(durationInMonths) ||
|
||||
durationInMonths < 1
|
||||
) {
|
||||
return badRequestResponse(
|
||||
'durationInMonths is required and must be a positive integer when duration is "repeating"'
|
||||
)
|
||||
}
|
||||
return badRequestResponse('bonusCreditAmount must be a positive number')
|
||||
}
|
||||
|
||||
if (code !== undefined && code !== null) {
|
||||
@@ -166,77 +110,31 @@ export const POST = withAdminAuth(async (request) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (maxRedemptions !== undefined && maxRedemptions !== null) {
|
||||
if (
|
||||
typeof maxRedemptions !== 'number' ||
|
||||
!Number.isInteger(maxRedemptions) ||
|
||||
maxRedemptions < 1
|
||||
) {
|
||||
return badRequestResponse('maxRedemptions must be a positive integer')
|
||||
}
|
||||
}
|
||||
const id = nanoid()
|
||||
|
||||
if (expiresAt !== undefined && expiresAt !== null) {
|
||||
const parsed = new Date(expiresAt)
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return badRequestResponse('expiresAt must be a valid ISO 8601 date string')
|
||||
}
|
||||
if (parsed.getTime() <= Date.now()) {
|
||||
return badRequestResponse('expiresAt must be in the future')
|
||||
}
|
||||
}
|
||||
const [campaign] = await db
|
||||
.insert(referralCampaigns)
|
||||
.values({
|
||||
id,
|
||||
name,
|
||||
code: code ? code.trim().toUpperCase() : null,
|
||||
utmSource: utmSource || null,
|
||||
utmMedium: utmMedium || null,
|
||||
utmCampaign: utmCampaign || null,
|
||||
utmContent: utmContent || null,
|
||||
bonusCreditAmount: bonusCreditAmount.toString(),
|
||||
})
|
||||
.returning()
|
||||
|
||||
const coupon = await stripe.coupons.create({
|
||||
name: name.trim(),
|
||||
percent_off: percentOff,
|
||||
duration: effectiveDuration,
|
||||
...(effectiveDuration === 'repeating' ? { duration_in_months: durationInMonths } : {}),
|
||||
logger.info(`Admin API: Created referral campaign ${id}`, {
|
||||
name,
|
||||
code: campaign.code,
|
||||
bonusCreditAmount,
|
||||
})
|
||||
|
||||
let promoCode
|
||||
try {
|
||||
const promoParams: Stripe.PromotionCodeCreateParams = {
|
||||
coupon: coupon.id,
|
||||
...(code ? { code: code.trim().toUpperCase() } : {}),
|
||||
...(maxRedemptions ? { max_redemptions: maxRedemptions } : {}),
|
||||
...(expiresAt ? { expires_at: Math.floor(new Date(expiresAt).getTime() / 1000) } : {}),
|
||||
}
|
||||
|
||||
promoCode = await stripe.promotionCodes.create(promoParams)
|
||||
} catch (promoError) {
|
||||
try {
|
||||
await stripe.coupons.del(coupon.id)
|
||||
} catch (cleanupError) {
|
||||
logger.error(
|
||||
'Admin API: Failed to clean up orphaned coupon after promo code creation failed',
|
||||
{
|
||||
couponId: coupon.id,
|
||||
cleanupError,
|
||||
}
|
||||
)
|
||||
}
|
||||
throw promoError
|
||||
}
|
||||
|
||||
logger.info('Admin API: Created Stripe promotion code', {
|
||||
promoCodeId: promoCode.id,
|
||||
code: promoCode.code,
|
||||
couponId: coupon.id,
|
||||
percentOff,
|
||||
duration: effectiveDuration,
|
||||
})
|
||||
|
||||
return singleResponse(formatPromoCode(promoCode))
|
||||
return singleResponse(toAdminReferralCampaign(campaign, getBaseUrl()))
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
'type' in error &&
|
||||
(error as { type: string }).type === 'StripeInvalidRequestError'
|
||||
) {
|
||||
logger.warn('Admin API: Stripe rejected promotion code request', { error: error.message })
|
||||
return badRequestResponse(error.message)
|
||||
}
|
||||
logger.error('Admin API: Failed to create promotion code', { error })
|
||||
return internalErrorResponse('Failed to create promotion code')
|
||||
logger.error('Admin API: Failed to create referral campaign', { error })
|
||||
return internalErrorResponse('Failed to create referral campaign')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
auditLog,
|
||||
member,
|
||||
organization,
|
||||
referralCampaigns,
|
||||
subscription,
|
||||
user,
|
||||
userStats,
|
||||
@@ -32,6 +33,7 @@ export type DbOrganization = InferSelectModel<typeof organization>
|
||||
export type DbSubscription = InferSelectModel<typeof subscription>
|
||||
export type DbMember = InferSelectModel<typeof member>
|
||||
export type DbUserStats = InferSelectModel<typeof userStats>
|
||||
export type DbReferralCampaign = InferSelectModel<typeof referralCampaigns>
|
||||
|
||||
// =============================================================================
|
||||
// Pagination
|
||||
@@ -648,6 +650,52 @@ export interface AdminUndeployResult {
|
||||
isDeployed: boolean
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Referral Campaign Types
|
||||
// =============================================================================
|
||||
|
||||
export interface AdminReferralCampaign {
|
||||
id: string
|
||||
name: string
|
||||
code: string | null
|
||||
utmSource: string | null
|
||||
utmMedium: string | null
|
||||
utmCampaign: string | null
|
||||
utmContent: string | null
|
||||
bonusCreditAmount: string
|
||||
isActive: boolean
|
||||
signupUrl: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export function toAdminReferralCampaign(
|
||||
dbCampaign: DbReferralCampaign,
|
||||
baseUrl: string
|
||||
): AdminReferralCampaign {
|
||||
const utmParams = new URLSearchParams()
|
||||
if (dbCampaign.utmSource) utmParams.set('utm_source', dbCampaign.utmSource)
|
||||
if (dbCampaign.utmMedium) utmParams.set('utm_medium', dbCampaign.utmMedium)
|
||||
if (dbCampaign.utmCampaign) utmParams.set('utm_campaign', dbCampaign.utmCampaign)
|
||||
if (dbCampaign.utmContent) utmParams.set('utm_content', dbCampaign.utmContent)
|
||||
const query = utmParams.toString()
|
||||
|
||||
return {
|
||||
id: dbCampaign.id,
|
||||
name: dbCampaign.name,
|
||||
code: dbCampaign.code,
|
||||
utmSource: dbCampaign.utmSource,
|
||||
utmMedium: dbCampaign.utmMedium,
|
||||
utmCampaign: dbCampaign.utmCampaign,
|
||||
utmContent: dbCampaign.utmContent,
|
||||
bonusCreditAmount: dbCampaign.bonusCreditAmount,
|
||||
isActive: dbCampaign.isActive,
|
||||
signupUrl: query ? `${baseUrl}/signup?${query}` : null,
|
||||
createdAt: dbCampaign.createdAt.toISOString(),
|
||||
updatedAt: dbCampaign.updatedAt.toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Audit Log Types
|
||||
// =============================================================================
|
||||
|
||||
@@ -20,10 +20,6 @@ import {
|
||||
} from '@/lib/execution/call-chain'
|
||||
import { createExecutionEventWriter, setExecutionMeta } from '@/lib/execution/event-buffer'
|
||||
import { processInputFileFields } from '@/lib/execution/files'
|
||||
import {
|
||||
registerManualExecutionAborter,
|
||||
unregisterManualExecutionAborter,
|
||||
} from '@/lib/execution/manual-cancellation'
|
||||
import { preprocessExecution } from '@/lib/execution/preprocessing'
|
||||
import { LoggingSession } from '@/lib/logs/execution/logging-session'
|
||||
import {
|
||||
@@ -849,7 +845,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
const encoder = new TextEncoder()
|
||||
const timeoutController = createTimeoutAbortController(preprocessResult.executionTimeout?.sync)
|
||||
let isStreamClosed = false
|
||||
let isManualAbortRegistered = false
|
||||
|
||||
const eventWriter = createExecutionEventWriter(executionId)
|
||||
setExecutionMeta(executionId, {
|
||||
@@ -862,9 +857,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
async start(controller) {
|
||||
let finalMetaStatus: 'complete' | 'error' | 'cancelled' | null = null
|
||||
|
||||
registerManualExecutionAborter(executionId, timeoutController.abort)
|
||||
isManualAbortRegistered = true
|
||||
|
||||
const sendEvent = (event: ExecutionEvent) => {
|
||||
if (!isStreamClosed) {
|
||||
try {
|
||||
@@ -1232,10 +1224,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
})
|
||||
finalMetaStatus = 'error'
|
||||
} finally {
|
||||
if (isManualAbortRegistered) {
|
||||
unregisterManualExecutionAborter(executionId)
|
||||
isManualAbortRegistered = false
|
||||
}
|
||||
try {
|
||||
await eventWriter.close()
|
||||
} catch (closeError) {
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { NextRequest } from 'next/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mockCheckHybridAuth = vi.fn()
|
||||
const mockAuthorizeWorkflowByWorkspacePermission = vi.fn()
|
||||
const mockMarkExecutionCancelled = vi.fn()
|
||||
const mockAbortManualExecution = vi.fn()
|
||||
|
||||
vi.mock('@sim/logger', () => ({
|
||||
createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/hybrid', () => ({
|
||||
checkHybridAuth: (...args: unknown[]) => mockCheckHybridAuth(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/execution/cancellation', () => ({
|
||||
markExecutionCancelled: (...args: unknown[]) => mockMarkExecutionCancelled(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/execution/manual-cancellation', () => ({
|
||||
abortManualExecution: (...args: unknown[]) => mockAbortManualExecution(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workflows/utils', () => ({
|
||||
authorizeWorkflowByWorkspacePermission: (params: unknown) =>
|
||||
mockAuthorizeWorkflowByWorkspacePermission(params),
|
||||
}))
|
||||
|
||||
import { POST } from './route'
|
||||
|
||||
describe('POST /api/workflows/[id]/executions/[executionId]/cancel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCheckHybridAuth.mockResolvedValue({ success: true, userId: 'user-1' })
|
||||
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ allowed: true })
|
||||
mockAbortManualExecution.mockReturnValue(false)
|
||||
})
|
||||
|
||||
it('returns success when cancellation was durably recorded', async () => {
|
||||
mockMarkExecutionCancelled.mockResolvedValue({
|
||||
durablyRecorded: true,
|
||||
reason: 'recorded',
|
||||
})
|
||||
|
||||
const response = await POST(
|
||||
new NextRequest('http://localhost/api/workflows/wf-1/executions/ex-1/cancel', {
|
||||
method: 'POST',
|
||||
}),
|
||||
{
|
||||
params: Promise.resolve({ id: 'wf-1', executionId: 'ex-1' }),
|
||||
}
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
success: true,
|
||||
executionId: 'ex-1',
|
||||
redisAvailable: true,
|
||||
durablyRecorded: true,
|
||||
locallyAborted: false,
|
||||
reason: 'recorded',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns unsuccessful response when Redis is unavailable', async () => {
|
||||
mockMarkExecutionCancelled.mockResolvedValue({
|
||||
durablyRecorded: false,
|
||||
reason: 'redis_unavailable',
|
||||
})
|
||||
|
||||
const response = await POST(
|
||||
new NextRequest('http://localhost/api/workflows/wf-1/executions/ex-1/cancel', {
|
||||
method: 'POST',
|
||||
}),
|
||||
{
|
||||
params: Promise.resolve({ id: 'wf-1', executionId: 'ex-1' }),
|
||||
}
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
success: false,
|
||||
executionId: 'ex-1',
|
||||
redisAvailable: false,
|
||||
durablyRecorded: false,
|
||||
locallyAborted: false,
|
||||
reason: 'redis_unavailable',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns unsuccessful response when Redis persistence fails', async () => {
|
||||
mockMarkExecutionCancelled.mockResolvedValue({
|
||||
durablyRecorded: false,
|
||||
reason: 'redis_write_failed',
|
||||
})
|
||||
|
||||
const response = await POST(
|
||||
new NextRequest('http://localhost/api/workflows/wf-1/executions/ex-1/cancel', {
|
||||
method: 'POST',
|
||||
}),
|
||||
{
|
||||
params: Promise.resolve({ id: 'wf-1', executionId: 'ex-1' }),
|
||||
}
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
success: false,
|
||||
executionId: 'ex-1',
|
||||
redisAvailable: true,
|
||||
durablyRecorded: false,
|
||||
locallyAborted: false,
|
||||
reason: 'redis_write_failed',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns success when local fallback aborts execution without Redis durability', async () => {
|
||||
mockMarkExecutionCancelled.mockResolvedValue({
|
||||
durablyRecorded: false,
|
||||
reason: 'redis_unavailable',
|
||||
})
|
||||
mockAbortManualExecution.mockReturnValue(true)
|
||||
|
||||
const response = await POST(
|
||||
new NextRequest('http://localhost/api/workflows/wf-1/executions/ex-1/cancel', {
|
||||
method: 'POST',
|
||||
}),
|
||||
{
|
||||
params: Promise.resolve({ id: 'wf-1', executionId: 'ex-1' }),
|
||||
}
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
success: true,
|
||||
executionId: 'ex-1',
|
||||
redisAvailable: false,
|
||||
durablyRecorded: false,
|
||||
locallyAborted: true,
|
||||
reason: 'redis_unavailable',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -2,7 +2,6 @@ import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { markExecutionCancelled } from '@/lib/execution/cancellation'
|
||||
import { abortManualExecution } from '@/lib/execution/manual-cancellation'
|
||||
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
|
||||
|
||||
const logger = createLogger('CancelExecutionAPI')
|
||||
@@ -46,27 +45,20 @@ export async function POST(
|
||||
|
||||
logger.info('Cancel execution requested', { workflowId, executionId, userId: auth.userId })
|
||||
|
||||
const cancellation = await markExecutionCancelled(executionId)
|
||||
const locallyAborted = abortManualExecution(executionId)
|
||||
const marked = await markExecutionCancelled(executionId)
|
||||
|
||||
if (cancellation.durablyRecorded) {
|
||||
if (marked) {
|
||||
logger.info('Execution marked as cancelled in Redis', { executionId })
|
||||
} else if (locallyAborted) {
|
||||
logger.info('Execution cancelled via local in-process fallback', { executionId })
|
||||
} else {
|
||||
logger.warn('Execution cancellation was not durably recorded', {
|
||||
logger.info('Redis not available, cancellation will rely on connection close', {
|
||||
executionId,
|
||||
reason: cancellation.reason,
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: cancellation.durablyRecorded || locallyAborted,
|
||||
success: true,
|
||||
executionId,
|
||||
redisAvailable: cancellation.reason !== 'redis_unavailable',
|
||||
durablyRecorded: cancellation.durablyRecorded,
|
||||
locallyAborted,
|
||||
reason: cancellation.reason,
|
||||
redisAvailable: marked,
|
||||
})
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to cancel execution', { workflowId, executionId, error: error.message })
|
||||
|
||||
@@ -93,7 +93,10 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
return NextResponse.json({ error: 'No file provided' }, { status: 400 })
|
||||
}
|
||||
|
||||
const fileName = rawFile.name || 'untitled'
|
||||
const fileName = rawFile.name
|
||||
if (!fileName) {
|
||||
return NextResponse.json({ error: 'File name is missing' }, { status: 400 })
|
||||
}
|
||||
|
||||
const maxSize = 100 * 1024 * 1024
|
||||
if (rawFile.size > maxSize) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { Skeleton } from '@/components/emcn'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
export function ChatLoadingState() {
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { Skeleton } from '@/components/emcn'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import AuthBackground from '@/app/(auth)/components/auth-background'
|
||||
|
||||
export function FormLoadingState() {
|
||||
|
||||
@@ -11,7 +11,7 @@ interface ThankYouScreenProps {
|
||||
}
|
||||
|
||||
/** Default green color matching --brand-tertiary-2 */
|
||||
const DEFAULT_THANK_YOU_COLOR = '#32bd7e'
|
||||
const DEFAULT_THANK_YOU_COLOR = '#33C482'
|
||||
|
||||
/** Legacy blue default that should be treated as "no custom color" */
|
||||
const LEGACY_BLUE_DEFAULT = '#3972F6'
|
||||
|
||||
@@ -114,7 +114,6 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
|
||||
if (isCollapsed) {
|
||||
document.documentElement.style.setProperty('--sidebar-width', '51px');
|
||||
document.documentElement.setAttribute('data-sidebar-collapsed', '');
|
||||
} else {
|
||||
var width = state && state.sidebarWidth;
|
||||
var maxSidebarWidth = window.innerWidth * 0.3;
|
||||
|
||||
@@ -1249,7 +1249,7 @@ export default function ResumeExecutionPage({
|
||||
{message && <Badge variant='green'>{message}</Badge>}
|
||||
|
||||
{/* Action */}
|
||||
<Button variant='tertiary' onClick={handleResume} disabled={resumeDisabled}>
|
||||
<Button variant='primary' onClick={handleResume} disabled={resumeDisabled}>
|
||||
{loadingAction ? 'Resuming...' : 'Resume Execution'}
|
||||
</Button>
|
||||
</>
|
||||
|
||||
@@ -27,8 +27,8 @@ import {
|
||||
PopoverContent,
|
||||
PopoverItem,
|
||||
PopoverTrigger,
|
||||
Skeleton,
|
||||
} from '@/components/emcn'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { VerifiedBadge } from '@/components/ui/verified-badge'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
@@ -733,7 +733,7 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
<>
|
||||
{!currentUserId ? (
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
onClick={() => {
|
||||
const callbackUrl =
|
||||
isWorkspaceContext && workspaceId
|
||||
@@ -749,7 +749,7 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
</Button>
|
||||
) : isWorkspaceContext ? (
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
onClick={handleUseTemplate}
|
||||
disabled={isUsing}
|
||||
className='!text-[#FFFFFF] h-[32px] rounded-[6px] px-[12px] text-[14px]'
|
||||
|
||||
@@ -1,886 +0,0 @@
|
||||
import type { ComponentType, SVGProps } from 'react'
|
||||
import {
|
||||
BookOpen,
|
||||
Bug,
|
||||
Calendar,
|
||||
Card,
|
||||
ClipboardList,
|
||||
DocumentAttachment,
|
||||
File,
|
||||
FolderCode,
|
||||
Hammer,
|
||||
Integration,
|
||||
Layout,
|
||||
Library,
|
||||
Mail,
|
||||
Pencil,
|
||||
Rocket,
|
||||
Search,
|
||||
Send,
|
||||
ShieldCheck,
|
||||
Table,
|
||||
Users,
|
||||
Wrench,
|
||||
} from '@/components/emcn/icons'
|
||||
import {
|
||||
AirtableIcon,
|
||||
AmplitudeIcon,
|
||||
ApolloIcon,
|
||||
CalendlyIcon,
|
||||
ConfluenceIcon,
|
||||
DatadogIcon,
|
||||
DiscordIcon,
|
||||
FirecrawlIcon,
|
||||
GithubIcon,
|
||||
GmailIcon,
|
||||
GongIcon,
|
||||
GoogleCalendarIcon,
|
||||
GoogleDriveIcon,
|
||||
GoogleSheetsIcon,
|
||||
GreenhouseIcon,
|
||||
HubspotIcon,
|
||||
IntercomIcon,
|
||||
JiraIcon,
|
||||
LemlistIcon,
|
||||
LinearIcon,
|
||||
LinkedInIcon,
|
||||
MicrosoftTeamsIcon,
|
||||
NotionIcon,
|
||||
PagerDutyIcon,
|
||||
RedditIcon,
|
||||
SalesforceIcon,
|
||||
ShopifyIcon,
|
||||
SlackIcon,
|
||||
StripeIcon,
|
||||
TwilioIcon,
|
||||
TypeformIcon,
|
||||
WebflowIcon,
|
||||
WordpressIcon,
|
||||
YouTubeIcon,
|
||||
ZendeskIcon,
|
||||
} from '@/components/icons'
|
||||
import { MarkdownIcon } from '@/components/icons/document-icons'
|
||||
|
||||
/**
|
||||
* Modules that a template leverages.
|
||||
* Used to show pill badges so users understand what platform features are involved.
|
||||
*/
|
||||
export const MODULE_META = {
|
||||
'knowledge-base': { label: 'Knowledge Base' },
|
||||
tables: { label: 'Tables' },
|
||||
files: { label: 'Files' },
|
||||
workflows: { label: 'Workflows' },
|
||||
scheduled: { label: 'Scheduled Tasks' },
|
||||
agent: { label: 'Agent' },
|
||||
} as const
|
||||
|
||||
export type ModuleTag = keyof typeof MODULE_META
|
||||
|
||||
/**
|
||||
* Categories for grouping templates in the UI.
|
||||
*/
|
||||
export const CATEGORY_META = {
|
||||
popular: { label: 'Popular' },
|
||||
sales: { label: 'Sales & CRM' },
|
||||
support: { label: 'Support' },
|
||||
engineering: { label: 'Engineering' },
|
||||
marketing: { label: 'Marketing & Content' },
|
||||
productivity: { label: 'Productivity' },
|
||||
operations: { label: 'Operations' },
|
||||
} as const
|
||||
|
||||
export type Category = keyof typeof CATEGORY_META
|
||||
|
||||
/**
|
||||
* Freeform tags for cross-cutting concerns that don't fit neatly into a single category.
|
||||
* Use these to filter templates by persona, pattern, or domain in the future.
|
||||
*
|
||||
* Persona tags: founder, sales, engineering, marketing, support, hr, finance, product, community, devops
|
||||
* Pattern tags: monitoring, reporting, automation, research, sync, communication, analysis
|
||||
* Domain tags: ecommerce, legal, recruiting, infrastructure, content, crm
|
||||
*/
|
||||
export type Tag = string
|
||||
|
||||
export interface TemplatePrompt {
|
||||
icon: ComponentType<SVGProps<SVGSVGElement>>
|
||||
title: string
|
||||
prompt: string
|
||||
image?: string
|
||||
modules: ModuleTag[]
|
||||
category: Category
|
||||
tags: Tag[]
|
||||
featured?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* To add a new template:
|
||||
* 1. Add an entry to this array with the required fields.
|
||||
* 2. Set `featured: true` if it should appear in the initial grid.
|
||||
* 3. Optionally add a screenshot to `/public/templates/` and reference it in `image`.
|
||||
* 4. Add relevant `tags` for cross-cutting filtering (persona, pattern, domain).
|
||||
*/
|
||||
export const TEMPLATES: TemplatePrompt[] = [
|
||||
// ── Popular / Featured ──────────────────────────────────────────────────
|
||||
{
|
||||
icon: Table,
|
||||
title: 'Self-populating CRM',
|
||||
prompt:
|
||||
'Create a self-healing CRM table that keeps track of all my customers by integrating with my existing data sources. Schedule a recurring job every morning to automatically pull updates from all relevant data sources and keep my CRM up to date.',
|
||||
image: '/templates/crm-light.png',
|
||||
modules: ['tables', 'scheduled', 'workflows'],
|
||||
category: 'popular',
|
||||
tags: ['founder', 'sales', 'crm', 'sync', 'automation'],
|
||||
featured: true,
|
||||
},
|
||||
{
|
||||
icon: GoogleCalendarIcon,
|
||||
title: 'Meeting prep agent',
|
||||
prompt:
|
||||
'Create an agent that checks my Google Calendar each morning, researches every attendee and topic on the web, and prepares a brief for each meeting so I walk in fully prepared. Schedule it to run every weekday morning.',
|
||||
image: '/templates/meeting-prep-dark.png',
|
||||
modules: ['agent', 'scheduled', 'workflows'],
|
||||
category: 'popular',
|
||||
tags: ['founder', 'sales', 'research', 'automation'],
|
||||
featured: true,
|
||||
},
|
||||
{
|
||||
icon: MarkdownIcon,
|
||||
title: 'Resolve todo list',
|
||||
prompt:
|
||||
'Create a file of all my todos then go one by one and check off every time a todo is done. Look at my calendar and see what I have to do.',
|
||||
image: '/templates/todo-list-light.png',
|
||||
modules: ['files', 'agent', 'workflows'],
|
||||
category: 'popular',
|
||||
tags: ['individual', 'automation'],
|
||||
featured: true,
|
||||
},
|
||||
{
|
||||
icon: Search,
|
||||
title: 'Research assistant',
|
||||
prompt:
|
||||
'Build an agent that takes a topic, searches the web for the latest information, summarizes key findings, and compiles them into a clean document I can review.',
|
||||
image: '/templates/research-assistant-dark.png',
|
||||
modules: ['agent', 'files', 'workflows'],
|
||||
category: 'popular',
|
||||
tags: ['founder', 'research', 'content', 'individual'],
|
||||
featured: true,
|
||||
},
|
||||
{
|
||||
icon: GmailIcon,
|
||||
title: 'Auto-reply agent',
|
||||
prompt:
|
||||
'Create a workflow that reads my Gmail inbox, identifies emails that need a response, and drafts contextual replies for each one. Schedule it to run every hour.',
|
||||
image: '/templates/gmail-agent-dark.png',
|
||||
modules: ['agent', 'workflows'],
|
||||
category: 'popular',
|
||||
tags: ['individual', 'communication', 'automation'],
|
||||
featured: true,
|
||||
},
|
||||
{
|
||||
icon: Table,
|
||||
title: 'Expense tracker',
|
||||
prompt:
|
||||
'Create a table that tracks all my expenses by pulling transactions from my connected accounts. Categorize each expense automatically and generate a weekly summary report.',
|
||||
image: '/templates/expense-tracker-light.png',
|
||||
modules: ['tables', 'scheduled', 'workflows'],
|
||||
category: 'popular',
|
||||
tags: ['finance', 'individual', 'reporting'],
|
||||
featured: true,
|
||||
},
|
||||
|
||||
// ── Sales & CRM ────────────────────────────────────────────────────────
|
||||
{
|
||||
icon: FolderCode,
|
||||
title: 'RFP and proposal drafter',
|
||||
prompt:
|
||||
'Create a knowledge base from my past proposals, case studies, and company information. Then build an agent that drafts responses to new RFPs by matching requirements to relevant past work, generating tailored sections, and compiling a complete proposal file.',
|
||||
modules: ['knowledge-base', 'files', 'agent'],
|
||||
category: 'sales',
|
||||
tags: ['sales', 'content', 'enterprise'],
|
||||
},
|
||||
{
|
||||
icon: Library,
|
||||
title: 'Competitive battle cards',
|
||||
prompt:
|
||||
'Create an agent that deep-researches each of my competitors using web search — their product features, pricing, positioning, strengths, and weaknesses — and generates a structured battle card document for each one that my sales team can reference during calls.',
|
||||
modules: ['agent', 'files', 'workflows'],
|
||||
category: 'sales',
|
||||
tags: ['sales', 'research', 'content'],
|
||||
},
|
||||
{
|
||||
icon: ClipboardList,
|
||||
title: 'QBR prep agent',
|
||||
prompt:
|
||||
'Build a workflow that compiles everything needed for a quarterly business review — pulling customer usage data, support ticket history, billing summary, and key milestones from my tables — and generates a polished QBR document ready to present.',
|
||||
modules: ['tables', 'files', 'agent', 'workflows'],
|
||||
category: 'sales',
|
||||
tags: ['sales', 'support', 'reporting'],
|
||||
},
|
||||
{
|
||||
icon: SalesforceIcon,
|
||||
title: 'CRM knowledge search',
|
||||
prompt:
|
||||
'Create a knowledge base connected to my Salesforce account so all deals, contacts, notes, and activities are automatically synced and searchable. Then build an agent I can ask things like "what\'s the history with Acme Corp?" or "who was involved in the last enterprise deal?" and get instant answers with CRM record citations.',
|
||||
modules: ['knowledge-base', 'agent'],
|
||||
category: 'sales',
|
||||
tags: ['sales', 'crm', 'research'],
|
||||
},
|
||||
{
|
||||
icon: HubspotIcon,
|
||||
title: 'HubSpot deal search',
|
||||
prompt:
|
||||
'Create a knowledge base connected to my HubSpot account so all deals, contacts, and activity history are automatically synced and searchable. Then build an agent I can ask things like "what happened with the Stripe integration deal?" or "which deals closed last quarter over $50k?" and get answers with HubSpot record links.',
|
||||
modules: ['knowledge-base', 'agent'],
|
||||
category: 'sales',
|
||||
tags: ['sales', 'crm', 'research'],
|
||||
},
|
||||
{
|
||||
icon: Users,
|
||||
title: 'Lead enrichment pipeline',
|
||||
prompt:
|
||||
'Build a workflow that watches my leads table for new entries, enriches each lead with company size, funding, tech stack, and decision-maker contacts using Apollo and web search, then updates the table with the enriched information.',
|
||||
modules: ['tables', 'agent', 'workflows'],
|
||||
category: 'sales',
|
||||
tags: ['sales', 'crm', 'automation', 'research'],
|
||||
},
|
||||
{
|
||||
icon: ApolloIcon,
|
||||
title: 'Prospect researcher',
|
||||
prompt:
|
||||
'Create an agent that takes a company name, deep-researches them across the web, finds key decision-makers, recent news, funding rounds, and pain points, then compiles a prospect brief I can review before outreach.',
|
||||
modules: ['agent', 'files', 'workflows'],
|
||||
category: 'sales',
|
||||
tags: ['sales', 'research'],
|
||||
},
|
||||
{
|
||||
icon: LemlistIcon,
|
||||
title: 'Outbound sequence builder',
|
||||
prompt:
|
||||
'Build a workflow that reads leads from my table, researches each prospect and their company on the web, writes a personalized cold email tailored to their role and pain points, and sends it via Gmail. Schedule it to run daily to process new leads automatically.',
|
||||
modules: ['tables', 'agent', 'workflows'],
|
||||
category: 'sales',
|
||||
tags: ['sales', 'communication', 'automation'],
|
||||
},
|
||||
{
|
||||
icon: SalesforceIcon,
|
||||
title: 'Deal pipeline tracker',
|
||||
prompt:
|
||||
'Create a table with columns for deal name, stage, amount, close date, and next steps. Build a workflow that syncs open deals from Salesforce into this table daily, and sends me a Slack summary each morning of deals that need attention or are at risk of slipping.',
|
||||
modules: ['tables', 'scheduled', 'agent', 'workflows'],
|
||||
category: 'sales',
|
||||
tags: ['sales', 'crm', 'monitoring', 'reporting'],
|
||||
},
|
||||
{
|
||||
icon: HubspotIcon,
|
||||
title: 'Win/loss analyzer',
|
||||
prompt:
|
||||
'Build a workflow that pulls closed deals from HubSpot each week, analyzes patterns in wins vs losses — deal size, industry, sales cycle length, objections — and generates a report file with actionable insights on what to change. Schedule it to run every Monday.',
|
||||
modules: ['agent', 'files', 'scheduled', 'workflows'],
|
||||
category: 'sales',
|
||||
tags: ['sales', 'crm', 'analysis', 'reporting'],
|
||||
},
|
||||
{
|
||||
icon: GongIcon,
|
||||
title: 'Sales call analyzer',
|
||||
prompt:
|
||||
'Build a workflow that pulls call transcripts from Gong after each sales call, identifies key objections raised, action items promised, and competitor mentions, updates the deal record in my CRM, and posts a call summary with next steps to the Slack deal channel.',
|
||||
modules: ['agent', 'tables', 'workflows'],
|
||||
category: 'sales',
|
||||
tags: ['sales', 'analysis', 'communication'],
|
||||
},
|
||||
{
|
||||
icon: WebflowIcon,
|
||||
title: 'Webflow lead capture pipeline',
|
||||
prompt:
|
||||
'Create a workflow that monitors new Webflow form submissions, enriches each lead with company and contact data using Apollo and web search, adds them to a tracking table with a lead score, and sends a Slack notification to the sales team for high-potential leads.',
|
||||
modules: ['tables', 'agent', 'workflows'],
|
||||
category: 'sales',
|
||||
tags: ['sales', 'crm', 'automation'],
|
||||
},
|
||||
|
||||
// ── Support ─────────────────────────────────────────────────────────────
|
||||
{
|
||||
icon: Send,
|
||||
title: 'Customer support bot',
|
||||
prompt:
|
||||
'Create a knowledge base and connect it to my Notion or Google Docs so it stays synced with my product documentation automatically. Then build an agent that answers customer questions using it with sourced citations and deploy it as a chat endpoint.',
|
||||
modules: ['knowledge-base', 'agent', 'workflows'],
|
||||
category: 'support',
|
||||
tags: ['support', 'communication', 'automation'],
|
||||
},
|
||||
{
|
||||
icon: SlackIcon,
|
||||
title: 'Slack Q&A bot',
|
||||
prompt:
|
||||
'Create a knowledge base connected to my Notion workspace so it stays synced with my company wiki. Then build a workflow that monitors Slack channels for questions and answers them using the knowledge base with source citations.',
|
||||
modules: ['knowledge-base', 'agent', 'workflows'],
|
||||
category: 'support',
|
||||
tags: ['support', 'communication', 'team'],
|
||||
},
|
||||
{
|
||||
icon: IntercomIcon,
|
||||
title: 'Customer feedback analyzer',
|
||||
prompt:
|
||||
'Build a scheduled workflow that pulls support tickets and conversations from Intercom daily, categorizes them by theme and sentiment, tracks trends in a table, and sends a weekly Slack report highlighting the top feature requests and pain points.',
|
||||
modules: ['tables', 'scheduled', 'agent', 'workflows'],
|
||||
category: 'support',
|
||||
tags: ['support', 'product', 'analysis', 'reporting'],
|
||||
},
|
||||
{
|
||||
icon: Table,
|
||||
title: 'Churn risk detector',
|
||||
prompt:
|
||||
'Create a workflow that monitors customer activity — support ticket frequency, response sentiment, usage patterns — scores each account for churn risk in a table, and triggers a Slack alert to the account team when a customer crosses the risk threshold.',
|
||||
modules: ['tables', 'scheduled', 'agent', 'workflows'],
|
||||
category: 'support',
|
||||
tags: ['support', 'sales', 'monitoring', 'analysis'],
|
||||
},
|
||||
{
|
||||
icon: DiscordIcon,
|
||||
title: 'Discord community manager',
|
||||
prompt:
|
||||
'Create a knowledge base connected to my Google Docs or Notion with product documentation. Then build a workflow that monitors my Discord server for unanswered questions, answers them using the knowledge base, tracks common questions in a table, and sends a weekly community summary to Slack.',
|
||||
modules: ['knowledge-base', 'tables', 'agent', 'scheduled', 'workflows'],
|
||||
category: 'support',
|
||||
tags: ['community', 'support', 'communication'],
|
||||
},
|
||||
{
|
||||
icon: TypeformIcon,
|
||||
title: 'Survey response analyzer',
|
||||
prompt:
|
||||
'Create a workflow that pulls new Typeform responses daily, categorizes feedback by theme and sentiment, logs structured results to a table, and sends a Slack digest when a new batch of responses comes in with the key takeaways.',
|
||||
modules: ['tables', 'scheduled', 'agent', 'workflows'],
|
||||
category: 'support',
|
||||
tags: ['product', 'analysis', 'reporting'],
|
||||
},
|
||||
{
|
||||
icon: GmailIcon,
|
||||
title: 'Email knowledge search',
|
||||
prompt:
|
||||
'Create a knowledge base connected to my Gmail so all my emails are automatically synced, chunked, and searchable. Then build an agent I can ask things like "what did Sarah say about the pricing proposal?" or "find the contract John sent last month" and get instant answers with the original email cited.',
|
||||
modules: ['knowledge-base', 'agent'],
|
||||
category: 'support',
|
||||
tags: ['individual', 'research', 'communication'],
|
||||
},
|
||||
{
|
||||
icon: ZendeskIcon,
|
||||
title: 'Support ticket knowledge search',
|
||||
prompt:
|
||||
'Create a knowledge base connected to my Zendesk account so all past tickets, resolutions, and agent notes are automatically synced and searchable. Then build an agent my support team can ask things like "how do we usually resolve the SSO login issue?" or "has anyone reported this billing bug before?" to find past solutions instantly.',
|
||||
modules: ['knowledge-base', 'agent'],
|
||||
category: 'support',
|
||||
tags: ['support', 'research', 'team'],
|
||||
},
|
||||
|
||||
// ── Engineering ─────────────────────────────────────────────────────────
|
||||
{
|
||||
icon: Wrench,
|
||||
title: 'Feature spec writer',
|
||||
prompt:
|
||||
'Create an agent that takes a rough feature idea or user story, researches how similar features work in competing products, and writes a complete product requirements document with user stories, acceptance criteria, edge cases, and technical considerations.',
|
||||
modules: ['agent', 'files', 'workflows'],
|
||||
category: 'engineering',
|
||||
tags: ['product', 'engineering', 'research', 'content'],
|
||||
},
|
||||
{
|
||||
icon: JiraIcon,
|
||||
title: 'Jira knowledge search',
|
||||
prompt:
|
||||
'Create a knowledge base connected to my Jira project so all tickets, comments, and resolutions are automatically synced and searchable. Then build an agent I can ask things like "how did we fix the auth timeout issue?" or "what was decided about the API redesign?" and get answers with ticket citations.',
|
||||
modules: ['knowledge-base', 'agent'],
|
||||
category: 'engineering',
|
||||
tags: ['engineering', 'research'],
|
||||
},
|
||||
{
|
||||
icon: LinearIcon,
|
||||
title: 'Linear knowledge search',
|
||||
prompt:
|
||||
'Create a knowledge base connected to my Linear workspace so all issues, comments, project updates, and decisions are automatically synced and searchable. Then build an agent I can ask things like "why did we deprioritize the mobile app?" or "what was the root cause of the checkout bug?" and get answers traced back to specific issues.',
|
||||
modules: ['knowledge-base', 'agent'],
|
||||
category: 'engineering',
|
||||
tags: ['engineering', 'research', 'product'],
|
||||
},
|
||||
{
|
||||
icon: Bug,
|
||||
title: 'Bug triage agent',
|
||||
prompt:
|
||||
'Build an agent that monitors Sentry for new errors, automatically triages them by severity and affected users, creates Linear tickets for critical issues with full stack traces, and sends a Slack notification to the on-call channel.',
|
||||
modules: ['agent', 'workflows'],
|
||||
category: 'engineering',
|
||||
tags: ['engineering', 'devops', 'automation'],
|
||||
},
|
||||
{
|
||||
icon: GithubIcon,
|
||||
title: 'PR review assistant',
|
||||
prompt:
|
||||
'Create a knowledge base connected to my GitHub repo so it stays synced with my style guide and coding standards. Then build a workflow that reviews new pull requests against it, checks for common issues and security vulnerabilities, and posts a review comment with specific suggestions.',
|
||||
modules: ['knowledge-base', 'agent', 'workflows'],
|
||||
category: 'engineering',
|
||||
tags: ['engineering', 'automation'],
|
||||
},
|
||||
{
|
||||
icon: GithubIcon,
|
||||
title: 'Changelog generator',
|
||||
prompt:
|
||||
'Build a scheduled workflow that runs every Friday, pulls all merged PRs from GitHub for the week, categorizes changes as features, fixes, or improvements, and generates a user-facing changelog document with clear descriptions.',
|
||||
modules: ['scheduled', 'agent', 'files', 'workflows'],
|
||||
category: 'engineering',
|
||||
tags: ['engineering', 'product', 'reporting', 'content'],
|
||||
},
|
||||
{
|
||||
icon: LinearIcon,
|
||||
title: 'Incident postmortem writer',
|
||||
prompt:
|
||||
'Create a workflow that when triggered after an incident, pulls the Slack thread from the incident channel, gathers relevant Sentry errors and deployment logs, and drafts a structured postmortem with timeline, root cause, and action items.',
|
||||
modules: ['agent', 'files', 'workflows'],
|
||||
category: 'engineering',
|
||||
tags: ['engineering', 'devops', 'analysis'],
|
||||
},
|
||||
{
|
||||
icon: NotionIcon,
|
||||
title: 'Documentation auto-updater',
|
||||
prompt:
|
||||
'Create a knowledge base connected to my GitHub repository so code and docs stay synced. Then build a scheduled weekly workflow that detects API changes, compares them against the knowledge base to find outdated documentation, and either updates Notion pages directly or creates Linear tickets for the needed changes.',
|
||||
modules: ['scheduled', 'agent', 'workflows'],
|
||||
category: 'engineering',
|
||||
tags: ['engineering', 'sync', 'automation'],
|
||||
},
|
||||
{
|
||||
icon: PagerDutyIcon,
|
||||
title: 'Incident response coordinator',
|
||||
prompt:
|
||||
'Create a knowledge base connected to my Confluence or Notion with runbooks and incident procedures. Then build a workflow triggered by PagerDuty incidents that searches the runbooks, gathers related Datadog alerts, identifies the on-call rotation, and posts a comprehensive incident brief to Slack.',
|
||||
modules: ['knowledge-base', 'agent', 'workflows'],
|
||||
category: 'engineering',
|
||||
tags: ['devops', 'engineering', 'automation'],
|
||||
},
|
||||
{
|
||||
icon: JiraIcon,
|
||||
title: 'Sprint report generator',
|
||||
prompt:
|
||||
'Create a scheduled workflow that runs at the end of each sprint, pulls all completed, in-progress, and blocked Jira tickets, calculates velocity and carry-over, and generates a sprint summary document with charts and trends to share with the team.',
|
||||
modules: ['scheduled', 'agent', 'files', 'workflows'],
|
||||
category: 'engineering',
|
||||
tags: ['engineering', 'reporting', 'team'],
|
||||
},
|
||||
{
|
||||
icon: ConfluenceIcon,
|
||||
title: 'Knowledge base sync',
|
||||
prompt:
|
||||
'Create a knowledge base connected to my Confluence workspace so all wiki pages are automatically synced and searchable. Then build a scheduled workflow that identifies stale pages not updated in 90 days and sends a Slack reminder to page owners to review them.',
|
||||
modules: ['knowledge-base', 'scheduled', 'agent', 'workflows'],
|
||||
category: 'engineering',
|
||||
tags: ['engineering', 'sync', 'team'],
|
||||
},
|
||||
|
||||
// ── Marketing & Content ─────────────────────────────────────────────────
|
||||
{
|
||||
icon: Pencil,
|
||||
title: 'Long-form content writer',
|
||||
prompt:
|
||||
'Build a workflow that takes a topic or brief, researches it deeply across the web, generates a detailed outline, then writes a full long-form article with sections, examples, and a conclusion. Save the final draft as a document for review.',
|
||||
modules: ['agent', 'files', 'workflows'],
|
||||
category: 'marketing',
|
||||
tags: ['content', 'research', 'marketing'],
|
||||
},
|
||||
{
|
||||
icon: Layout,
|
||||
title: 'Case study generator',
|
||||
prompt:
|
||||
'Create a knowledge base from my customer data and interview notes, then build a workflow that generates a polished case study file with the challenge, solution, results, and a pull quote — formatted and ready to publish.',
|
||||
modules: ['knowledge-base', 'files', 'agent'],
|
||||
category: 'marketing',
|
||||
tags: ['marketing', 'content', 'sales'],
|
||||
},
|
||||
{
|
||||
icon: Table,
|
||||
title: 'Social media content calendar',
|
||||
prompt:
|
||||
'Build a workflow that generates a full month of social media content for my brand. Research trending topics in my industry, create a table with post dates, platforms, copy drafts, and hashtags, then schedule a weekly refresh to keep the calendar filled with fresh ideas.',
|
||||
modules: ['tables', 'agent', 'scheduled', 'workflows'],
|
||||
category: 'marketing',
|
||||
tags: ['marketing', 'content', 'automation'],
|
||||
},
|
||||
{
|
||||
icon: Integration,
|
||||
title: 'Multi-language content translator',
|
||||
prompt:
|
||||
'Create a workflow that takes a document or blog post and translates it into multiple target languages while preserving tone, formatting, and brand voice. Save each translation as a separate file and flag sections that may need human review for cultural nuance.',
|
||||
modules: ['files', 'agent', 'workflows'],
|
||||
category: 'marketing',
|
||||
tags: ['content', 'enterprise', 'automation'],
|
||||
},
|
||||
{
|
||||
icon: YouTubeIcon,
|
||||
title: 'Content repurposer',
|
||||
prompt:
|
||||
'Build a workflow that takes a YouTube video URL, pulls the video details and description, researches the topic on the web for additional context, and generates a Twitter thread, LinkedIn post, and blog summary optimized for each platform.',
|
||||
modules: ['agent', 'files', 'workflows'],
|
||||
category: 'marketing',
|
||||
tags: ['marketing', 'content', 'automation'],
|
||||
},
|
||||
{
|
||||
icon: RedditIcon,
|
||||
title: 'Social mention tracker',
|
||||
prompt:
|
||||
'Create a scheduled workflow that monitors Reddit and X for mentions of my brand and competitors, scores each mention by sentiment and reach, logs them to a table, and sends a daily Slack digest of notable mentions.',
|
||||
modules: ['tables', 'scheduled', 'agent', 'workflows'],
|
||||
category: 'marketing',
|
||||
tags: ['marketing', 'monitoring', 'analysis'],
|
||||
},
|
||||
{
|
||||
icon: FirecrawlIcon,
|
||||
title: 'SEO content brief generator',
|
||||
prompt:
|
||||
'Build a workflow that takes a target keyword, scrapes the top 10 ranking pages, analyzes their content structure and subtopics, then generates a detailed content brief with outline, word count target, questions to answer, and internal linking suggestions.',
|
||||
modules: ['agent', 'files', 'workflows'],
|
||||
category: 'marketing',
|
||||
tags: ['marketing', 'content', 'research'],
|
||||
},
|
||||
{
|
||||
icon: Mail,
|
||||
title: 'Newsletter curator',
|
||||
prompt:
|
||||
'Create a scheduled weekly workflow that scrapes my favorite industry news sites and blogs, picks the top stories relevant to my audience, writes summaries for each, and drafts a ready-to-send newsletter in Mailchimp.',
|
||||
modules: ['scheduled', 'agent', 'files', 'workflows'],
|
||||
category: 'marketing',
|
||||
tags: ['marketing', 'content', 'communication'],
|
||||
},
|
||||
{
|
||||
icon: LinkedInIcon,
|
||||
title: 'LinkedIn content engine',
|
||||
prompt:
|
||||
'Build a workflow that scrapes my company blog for new posts, generates LinkedIn posts with hooks, insights, and calls-to-action optimized for engagement, and saves drafts as files for my review before posting to LinkedIn.',
|
||||
modules: ['agent', 'files', 'scheduled', 'workflows'],
|
||||
category: 'marketing',
|
||||
tags: ['marketing', 'content', 'automation'],
|
||||
},
|
||||
{
|
||||
icon: WordpressIcon,
|
||||
title: 'Blog auto-publisher',
|
||||
prompt:
|
||||
'Build a workflow that takes a draft document, optimizes it for SEO by researching target keywords, formats it for WordPress with proper headings and meta description, and publishes it as a draft post for final review.',
|
||||
modules: ['agent', 'files', 'workflows'],
|
||||
category: 'marketing',
|
||||
tags: ['marketing', 'content', 'automation'],
|
||||
},
|
||||
|
||||
// ── Productivity ────────────────────────────────────────────────────────
|
||||
{
|
||||
icon: BookOpen,
|
||||
title: 'Personal knowledge assistant',
|
||||
prompt:
|
||||
'Create a knowledge base and connect it to my Google Drive, Notion, or Obsidian so all my notes, docs, and articles are automatically synced and embedded. Then build an agent that I can ask anything — it should answer with citations and deploy as a chat endpoint.',
|
||||
modules: ['knowledge-base', 'agent'],
|
||||
category: 'productivity',
|
||||
tags: ['individual', 'research', 'team'],
|
||||
},
|
||||
{
|
||||
icon: SlackIcon,
|
||||
title: 'Slack knowledge search',
|
||||
prompt:
|
||||
'Create a knowledge base connected to my Slack workspace so all channel conversations and threads are automatically synced and searchable. Then build an agent I can ask things like "what did the team decide about the launch date?" or "what was the outcome of the design review?" and get answers with links to the original messages.',
|
||||
modules: ['knowledge-base', 'agent'],
|
||||
category: 'productivity',
|
||||
tags: ['team', 'research', 'communication'],
|
||||
},
|
||||
{
|
||||
icon: NotionIcon,
|
||||
title: 'Notion knowledge search',
|
||||
prompt:
|
||||
'Create a knowledge base connected to my Notion workspace so all pages, databases, meeting notes, and wikis are automatically synced and searchable. Then build an agent I can ask things like "what\'s our refund policy?" or "what was decided in the Q3 planning doc?" and get instant answers with page links.',
|
||||
modules: ['knowledge-base', 'agent'],
|
||||
category: 'productivity',
|
||||
tags: ['team', 'research'],
|
||||
},
|
||||
{
|
||||
icon: GoogleDriveIcon,
|
||||
title: 'Google Drive knowledge search',
|
||||
prompt:
|
||||
'Create a knowledge base connected to my Google Drive so all documents, spreadsheets, and presentations are automatically synced and searchable. Then build an agent I can ask things like "find the board deck from last quarter" or "what were the KPIs in the marketing plan?" and get answers with doc links.',
|
||||
modules: ['knowledge-base', 'agent'],
|
||||
category: 'productivity',
|
||||
tags: ['individual', 'team', 'research'],
|
||||
},
|
||||
{
|
||||
icon: DocumentAttachment,
|
||||
title: 'Document summarizer',
|
||||
prompt:
|
||||
'Create a workflow that takes any uploaded document — PDF, contract, report, research paper — and generates a structured summary with key takeaways, action items, important dates, and a one-paragraph executive overview.',
|
||||
modules: ['files', 'agent', 'workflows'],
|
||||
category: 'productivity',
|
||||
tags: ['individual', 'analysis', 'team'],
|
||||
},
|
||||
{
|
||||
icon: Table,
|
||||
title: 'Bulk data classifier',
|
||||
prompt:
|
||||
'Build a workflow that takes a table of unstructured data — support tickets, feedback, survey responses, leads, or any text — runs each row through an agent to classify, tag, score, and enrich it, then writes the structured results back to the table.',
|
||||
modules: ['tables', 'agent', 'workflows'],
|
||||
category: 'productivity',
|
||||
tags: ['analysis', 'automation', 'team'],
|
||||
},
|
||||
{
|
||||
icon: File,
|
||||
title: 'Automated narrative report',
|
||||
prompt:
|
||||
'Build a scheduled workflow that pulls key data from my tables every week, analyzes trends and anomalies, and writes a narrative report — not just charts and numbers, but written insights explaining what changed, why it matters, and what to do next. Save it as a document and send a summary to Slack.',
|
||||
modules: ['tables', 'scheduled', 'agent', 'files', 'workflows'],
|
||||
category: 'productivity',
|
||||
tags: ['founder', 'reporting', 'analysis'],
|
||||
},
|
||||
{
|
||||
icon: Rocket,
|
||||
title: 'Investor update writer',
|
||||
prompt:
|
||||
'Build a workflow that pulls key metrics from my tables — revenue, growth, burn rate, headcount, milestones — and drafts a concise investor update with highlights, lowlights, asks, and KPIs. Save it as a file I can review before sending. Schedule it to run on the first of each month.',
|
||||
modules: ['tables', 'scheduled', 'agent', 'files', 'workflows'],
|
||||
category: 'productivity',
|
||||
tags: ['founder', 'reporting', 'communication'],
|
||||
},
|
||||
{
|
||||
icon: BookOpen,
|
||||
title: 'Email digest curator',
|
||||
prompt:
|
||||
'Create a scheduled daily workflow that searches the web for the latest articles, papers, and news on topics I care about, picks the top 5 most relevant pieces, writes a one-paragraph summary for each, and delivers a curated reading digest to my inbox or Slack.',
|
||||
modules: ['scheduled', 'agent', 'files', 'workflows'],
|
||||
category: 'productivity',
|
||||
tags: ['individual', 'research', 'content'],
|
||||
},
|
||||
{
|
||||
icon: Search,
|
||||
title: 'Knowledge extractor',
|
||||
prompt:
|
||||
'Build a workflow that takes raw meeting notes, brainstorm dumps, or research transcripts, extracts the key insights, decisions, and facts, organizes them by topic, and saves them into my knowledge base so they are searchable and reusable in future conversations.',
|
||||
modules: ['files', 'knowledge-base', 'agent', 'workflows'],
|
||||
category: 'productivity',
|
||||
tags: ['individual', 'team', 'research'],
|
||||
},
|
||||
{
|
||||
icon: Calendar,
|
||||
title: 'Weekly team digest',
|
||||
prompt:
|
||||
"Build a scheduled workflow that runs every Friday, pulls the week's GitHub commits, closed Linear issues, and key Slack conversations, then emails a formatted weekly summary to the team.",
|
||||
modules: ['scheduled', 'agent', 'workflows'],
|
||||
category: 'productivity',
|
||||
tags: ['engineering', 'team', 'reporting'],
|
||||
},
|
||||
{
|
||||
icon: ClipboardList,
|
||||
title: 'Daily standup summary',
|
||||
prompt:
|
||||
'Create a scheduled workflow that reads the #standup Slack channel each morning, summarizes what everyone is working on, identifies blockers, and posts a structured recap to a Google Doc.',
|
||||
modules: ['scheduled', 'agent', 'files', 'workflows'],
|
||||
category: 'productivity',
|
||||
tags: ['team', 'reporting', 'communication'],
|
||||
},
|
||||
{
|
||||
icon: GmailIcon,
|
||||
title: 'Email triage assistant',
|
||||
prompt:
|
||||
'Build a workflow that scans my Gmail inbox every hour, categorizes emails by urgency and type (action needed, FYI, follow-up), drafts replies for routine messages, and sends me a prioritized summary in Slack so I only open what matters. Schedule it to run hourly.',
|
||||
modules: ['agent', 'scheduled', 'workflows'],
|
||||
category: 'productivity',
|
||||
tags: ['individual', 'communication', 'automation'],
|
||||
},
|
||||
{
|
||||
icon: SlackIcon,
|
||||
title: 'Meeting notes to action items',
|
||||
prompt:
|
||||
'Create a workflow that takes meeting notes or a transcript, extracts action items with owners and due dates, creates tasks in Linear or Asana for each one, and posts a summary to the relevant Slack channel.',
|
||||
modules: ['agent', 'workflows'],
|
||||
category: 'productivity',
|
||||
tags: ['team', 'automation'],
|
||||
},
|
||||
{
|
||||
icon: GoogleSheetsIcon,
|
||||
title: 'Weekly metrics report',
|
||||
prompt:
|
||||
'Build a scheduled workflow that pulls data from Stripe and my database every Monday, calculates key metrics like MRR, churn, new subscriptions, and failed payments, populates a Google Sheet, and Slacks the team a summary with week-over-week trends.',
|
||||
modules: ['scheduled', 'tables', 'agent', 'workflows'],
|
||||
category: 'productivity',
|
||||
tags: ['founder', 'finance', 'reporting'],
|
||||
},
|
||||
{
|
||||
icon: AmplitudeIcon,
|
||||
title: 'Product analytics digest',
|
||||
prompt:
|
||||
'Create a scheduled weekly workflow that pulls key product metrics from Amplitude — active users, feature adoption rates, retention cohorts, and top events — generates an executive summary with week-over-week trends, and posts it to Slack.',
|
||||
modules: ['scheduled', 'agent', 'workflows'],
|
||||
category: 'productivity',
|
||||
tags: ['product', 'reporting', 'analysis'],
|
||||
},
|
||||
{
|
||||
icon: CalendlyIcon,
|
||||
title: 'Scheduling follow-up automator',
|
||||
prompt:
|
||||
'Build a workflow that monitors new Calendly bookings, researches each attendee and their company, prepares a pre-meeting brief with relevant context, and sends a personalized confirmation email with an agenda and any prep materials.',
|
||||
modules: ['agent', 'workflows'],
|
||||
category: 'productivity',
|
||||
tags: ['sales', 'research', 'automation'],
|
||||
},
|
||||
{
|
||||
icon: TwilioIcon,
|
||||
title: 'SMS appointment reminders',
|
||||
prompt:
|
||||
'Create a scheduled workflow that checks Google Calendar each morning for appointments in the next 24 hours, and sends an SMS reminder to each attendee via Twilio with the meeting time, location, and any prep notes.',
|
||||
modules: ['scheduled', 'agent', 'workflows'],
|
||||
category: 'productivity',
|
||||
tags: ['individual', 'communication', 'automation'],
|
||||
},
|
||||
{
|
||||
icon: MicrosoftTeamsIcon,
|
||||
title: 'Microsoft Teams daily brief',
|
||||
prompt:
|
||||
'Build a scheduled workflow that pulls updates from your project tools — GitHub commits, Jira ticket status changes, and calendar events — and posts a formatted daily brief to your Microsoft Teams channel each morning.',
|
||||
modules: ['scheduled', 'agent', 'workflows'],
|
||||
category: 'productivity',
|
||||
tags: ['team', 'reporting', 'enterprise'],
|
||||
},
|
||||
|
||||
// ── Operations ──────────────────────────────────────────────────────────
|
||||
{
|
||||
icon: Table,
|
||||
title: 'Data cleanup agent',
|
||||
prompt:
|
||||
'Create a workflow that takes a messy table — inconsistent formatting, duplicates, missing fields, typos — and cleans it up by standardizing values, merging duplicates, filling gaps where possible, and flagging rows that need human review.',
|
||||
modules: ['tables', 'agent', 'workflows'],
|
||||
category: 'operations',
|
||||
tags: ['automation', 'analysis'],
|
||||
},
|
||||
{
|
||||
icon: Hammer,
|
||||
title: 'Training material generator',
|
||||
prompt:
|
||||
'Create a knowledge base from my product documentation, then build a workflow that generates training materials from it — onboarding guides, FAQ documents, step-by-step tutorials, and quiz questions. Schedule it to regenerate weekly so materials stay current as docs change.',
|
||||
modules: ['knowledge-base', 'files', 'agent', 'scheduled'],
|
||||
category: 'operations',
|
||||
tags: ['hr', 'content', 'team', 'automation'],
|
||||
},
|
||||
{
|
||||
icon: File,
|
||||
title: 'SOP generator',
|
||||
prompt:
|
||||
'Create an agent that takes a brief description of any business process — from employee onboarding to incident response to content publishing — and generates a detailed standard operating procedure document with numbered steps, responsible roles, decision points, and checklists.',
|
||||
modules: ['files', 'agent'],
|
||||
category: 'operations',
|
||||
tags: ['team', 'enterprise', 'content'],
|
||||
},
|
||||
{
|
||||
icon: Card,
|
||||
title: 'Invoice processor',
|
||||
prompt:
|
||||
'Build a workflow that processes invoice PDFs from Gmail, extracts vendor name, amount, due date, and line items, then logs everything to a tracking table and sends a Slack alert for invoices due within 7 days.',
|
||||
modules: ['files', 'tables', 'agent', 'workflows'],
|
||||
category: 'operations',
|
||||
tags: ['finance', 'automation'],
|
||||
},
|
||||
{
|
||||
icon: File,
|
||||
title: 'Contract analyzer',
|
||||
prompt:
|
||||
'Create a knowledge base from my standard contract terms, then build a workflow that reviews uploaded contracts against it — extracting key clauses like payment terms, liability caps, and termination conditions, flagging deviations, and outputting a summary to a table.',
|
||||
modules: ['knowledge-base', 'files', 'tables', 'agent'],
|
||||
category: 'operations',
|
||||
tags: ['legal', 'analysis'],
|
||||
},
|
||||
{
|
||||
icon: FirecrawlIcon,
|
||||
title: 'Competitive intel monitor',
|
||||
prompt:
|
||||
'Build a scheduled workflow that scrapes competitor websites, pricing pages, and changelog pages weekly using Firecrawl, compares against previous snapshots, summarizes any changes, logs them to a tracking table, and sends a Slack alert for major updates.',
|
||||
modules: ['scheduled', 'tables', 'agent', 'workflows'],
|
||||
category: 'operations',
|
||||
tags: ['founder', 'product', 'monitoring', 'research'],
|
||||
},
|
||||
{
|
||||
icon: StripeIcon,
|
||||
title: 'Revenue operations dashboard',
|
||||
prompt:
|
||||
'Create a scheduled daily workflow that pulls payment data from Stripe, calculates MRR, net revenue, failed payments, and new subscriptions, logs everything to a table with historical tracking, and sends a daily Slack summary with trends and anomalies.',
|
||||
modules: ['tables', 'scheduled', 'agent', 'workflows'],
|
||||
category: 'operations',
|
||||
tags: ['finance', 'founder', 'reporting', 'monitoring'],
|
||||
},
|
||||
{
|
||||
icon: ShopifyIcon,
|
||||
title: 'E-commerce order monitor',
|
||||
prompt:
|
||||
'Build a workflow that monitors Shopify orders, flags high-value or unusual orders for review, tracks fulfillment status in a table, and sends daily inventory and sales summaries to Slack with restock alerts when items run low.',
|
||||
modules: ['tables', 'scheduled', 'agent', 'workflows'],
|
||||
category: 'operations',
|
||||
tags: ['ecommerce', 'monitoring', 'reporting'],
|
||||
},
|
||||
{
|
||||
icon: ShieldCheck,
|
||||
title: 'Compliance document checker',
|
||||
prompt:
|
||||
'Create a knowledge base from my compliance requirements and policies, then build an agent that reviews uploaded policy documents and SOC 2 evidence against it, identifies gaps or outdated sections, and generates a remediation checklist file with priority levels.',
|
||||
modules: ['knowledge-base', 'files', 'agent'],
|
||||
category: 'operations',
|
||||
tags: ['legal', 'enterprise', 'analysis'],
|
||||
},
|
||||
{
|
||||
icon: Users,
|
||||
title: 'New hire onboarding automation',
|
||||
prompt:
|
||||
"Build a workflow that when triggered with a new hire's info, creates their accounts, sends a personalized welcome message in Slack, schedules 1:1s with their team on Google Calendar, shares relevant onboarding docs from the knowledge base, and tracks completion in a table.",
|
||||
modules: ['knowledge-base', 'tables', 'agent', 'workflows'],
|
||||
category: 'operations',
|
||||
tags: ['hr', 'automation', 'team'],
|
||||
},
|
||||
{
|
||||
icon: Library,
|
||||
title: 'Candidate screening assistant',
|
||||
prompt:
|
||||
'Create a knowledge base from my job descriptions and hiring criteria, then build a workflow that takes uploaded resumes, evaluates candidates against the requirements, scores them on experience, skills, and culture fit, and populates a comparison table with a summary and recommendation for each.',
|
||||
modules: ['knowledge-base', 'files', 'tables', 'agent'],
|
||||
category: 'operations',
|
||||
tags: ['hr', 'recruiting', 'analysis'],
|
||||
},
|
||||
{
|
||||
icon: GreenhouseIcon,
|
||||
title: 'Recruiting pipeline automator',
|
||||
prompt:
|
||||
'Build a scheduled workflow that syncs open jobs and candidates from Greenhouse to a tracking table daily, flags candidates who have been in the same stage for more than 5 days, and sends a Slack summary to hiring managers with pipeline stats and bottlenecks.',
|
||||
modules: ['tables', 'scheduled', 'agent', 'workflows'],
|
||||
category: 'operations',
|
||||
tags: ['hr', 'recruiting', 'monitoring', 'reporting'],
|
||||
},
|
||||
{
|
||||
icon: DatadogIcon,
|
||||
title: 'Infrastructure health report',
|
||||
prompt:
|
||||
'Create a scheduled daily workflow that queries Datadog for key infrastructure metrics — error rates, latency percentiles, CPU and memory usage — logs them to a table for trend tracking, and sends a morning Slack report highlighting any anomalies or degradations.',
|
||||
modules: ['tables', 'scheduled', 'agent', 'workflows'],
|
||||
category: 'operations',
|
||||
tags: ['devops', 'infrastructure', 'monitoring', 'reporting'],
|
||||
},
|
||||
{
|
||||
icon: AirtableIcon,
|
||||
title: 'Airtable data sync',
|
||||
prompt:
|
||||
'Create a scheduled workflow that syncs records from my Airtable base into a Sim table every hour, keeping both in sync. Use an agent to detect changes, resolve conflicts, and flag any discrepancies for review in Slack.',
|
||||
modules: ['tables', 'scheduled', 'agent', 'workflows'],
|
||||
category: 'operations',
|
||||
tags: ['sync', 'automation'],
|
||||
},
|
||||
{
|
||||
icon: Search,
|
||||
title: 'Multi-source knowledge hub',
|
||||
prompt:
|
||||
'Create a knowledge base and connect it to Confluence, Notion, and Google Drive so all my company documentation is automatically synced, chunked, and embedded. Then deploy a Q&A agent that can answer questions across all sources with citations.',
|
||||
modules: ['knowledge-base', 'scheduled', 'agent', 'workflows'],
|
||||
category: 'operations',
|
||||
tags: ['enterprise', 'team', 'sync', 'automation'],
|
||||
},
|
||||
{
|
||||
icon: Table,
|
||||
title: 'Customer 360 view',
|
||||
prompt:
|
||||
'Create a comprehensive customer table that aggregates data from my CRM, support tickets, billing history, and product usage into a single unified view per customer. Schedule it to sync daily and send a Slack alert when any customer shows signs of trouble across multiple signals.',
|
||||
modules: ['tables', 'scheduled', 'agent', 'workflows'],
|
||||
category: 'operations',
|
||||
tags: ['founder', 'sales', 'support', 'enterprise', 'sync'],
|
||||
},
|
||||
]
|
||||
@@ -1,3 +1 @@
|
||||
export type { Category, ModuleTag, Tag, TemplatePrompt } from './consts'
|
||||
export { CATEGORY_META, MODULE_META, TEMPLATES } from './consts'
|
||||
export { TemplatePrompts } from './template-prompts'
|
||||
|
||||
@@ -1,152 +1,96 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import type { ComponentType, SVGProps } from 'react'
|
||||
import Image from 'next/image'
|
||||
import { ChevronDown } from '@/components/emcn/icons'
|
||||
import type { Category, ModuleTag } from './consts'
|
||||
import { CATEGORY_META, MODULE_META, TEMPLATES } from './consts'
|
||||
import { Search, Table } from '@/components/emcn/icons'
|
||||
import { GmailIcon, GoogleCalendarIcon } from '@/components/icons'
|
||||
import { MarkdownIcon } from '@/components/icons/document-icons'
|
||||
|
||||
const FEATURED_TEMPLATES = TEMPLATES.filter((t) => t.featured)
|
||||
const EXTRA_TEMPLATES = TEMPLATES.filter((t) => !t.featured)
|
||||
|
||||
/** Group non-featured templates by category, preserving category order. */
|
||||
function getGroupedExtras() {
|
||||
const groups: { category: Category; label: string; templates: typeof TEMPLATES }[] = []
|
||||
const byCategory = new Map<Category, typeof TEMPLATES>()
|
||||
|
||||
for (const t of EXTRA_TEMPLATES) {
|
||||
const existing = byCategory.get(t.category)
|
||||
if (existing) {
|
||||
existing.push(t)
|
||||
} else {
|
||||
const arr = [t]
|
||||
byCategory.set(t.category, arr)
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, meta] of Object.entries(CATEGORY_META)) {
|
||||
const cat = key as Category
|
||||
if (cat === 'popular') continue
|
||||
const items = byCategory.get(cat)
|
||||
if (items?.length) {
|
||||
groups.push({ category: cat, label: meta.label, templates: items })
|
||||
}
|
||||
}
|
||||
|
||||
return groups
|
||||
interface TemplatePrompt {
|
||||
icon: ComponentType<SVGProps<SVGSVGElement>>
|
||||
title: string
|
||||
prompt: string
|
||||
image: string
|
||||
}
|
||||
|
||||
const GROUPED_EXTRAS = getGroupedExtras()
|
||||
|
||||
function ModulePills({ modules }: { modules: ModuleTag[] }) {
|
||||
return (
|
||||
<div className='flex flex-wrap gap-[4px]'>
|
||||
{modules.map((mod) => (
|
||||
<span
|
||||
key={mod}
|
||||
className='rounded-full bg-[var(--surface-3)] px-[6px] py-[1px] text-[11px] text-[var(--text-secondary)]'
|
||||
>
|
||||
{MODULE_META[mod].label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const TEMPLATES: TemplatePrompt[] = [
|
||||
{
|
||||
icon: Table,
|
||||
title: 'Self-populating CRM',
|
||||
prompt:
|
||||
'Create a self-healing CRM table that keeps track of all my customers by integrating with my existing data sources. Schedule a recurring job every morning to automatically pull updates from all relevant data sources and keep my CRM up to date.',
|
||||
image: '/templates/crm-light.png',
|
||||
},
|
||||
{
|
||||
icon: GoogleCalendarIcon,
|
||||
title: 'Meeting prep agent',
|
||||
prompt:
|
||||
'Create an agent that checks my calendar each morning, pulls context on every attendee and topic, and prepares a brief for each meeting so I walk in fully prepared.',
|
||||
image: '/templates/meeting-prep-dark.png',
|
||||
},
|
||||
{
|
||||
icon: MarkdownIcon,
|
||||
title: 'Resolve todo list',
|
||||
prompt:
|
||||
'Create a file of all my todos then go one by one and check off every time a todo is done. Look at my calendar and see what I have to do.',
|
||||
image: '/templates/todo-list-light.png',
|
||||
},
|
||||
{
|
||||
icon: Search,
|
||||
title: 'Research assistant',
|
||||
prompt:
|
||||
'Build an agent that takes a topic, searches the web for the latest information, summarizes key findings, and compiles them into a clean document I can review.',
|
||||
image: '/templates/research-assistant-dark.png',
|
||||
},
|
||||
{
|
||||
icon: GmailIcon,
|
||||
title: 'Auto-reply agent',
|
||||
prompt: 'Create a Gmail agent that drafts responses to relevant emails automatically.',
|
||||
image: '/templates/gmail-agent-dark.png',
|
||||
},
|
||||
{
|
||||
icon: Table,
|
||||
title: 'Expense tracker',
|
||||
prompt:
|
||||
'Create a table that tracks all my expenses by pulling transactions from my connected accounts. Categorize each expense automatically and generate a weekly summary report.',
|
||||
image: '/templates/expense-tracker-light.png',
|
||||
},
|
||||
]
|
||||
|
||||
interface TemplatePromptsProps {
|
||||
onSelect: (prompt: string) => void
|
||||
}
|
||||
|
||||
export function TemplatePrompts({ onSelect }: TemplatePromptsProps) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-[24px]'>
|
||||
{/* Featured grid */}
|
||||
<div className='grid grid-cols-3 gap-[16px]'>
|
||||
{FEATURED_TEMPLATES.map((template) => (
|
||||
<TemplateCard key={template.title} template={template} onSelect={onSelect} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Expand / collapse */}
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setExpanded((prev) => !prev)}
|
||||
aria-expanded={expanded}
|
||||
className='flex items-center justify-center gap-[6px] text-[13px] text-[var(--text-secondary)] transition-colors hover:text-[var(--text-body)]'
|
||||
>
|
||||
{expanded ? (
|
||||
<>
|
||||
Show less <ChevronDown className='h-[14px] w-[14px] rotate-180' />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
More examples <ChevronDown className='h-[14px] w-[14px]' />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Categorized extras */}
|
||||
{expanded && (
|
||||
<div className='flex flex-col gap-[32px]'>
|
||||
{GROUPED_EXTRAS.map((group) => (
|
||||
<div key={group.category} className='flex flex-col gap-[12px]'>
|
||||
<h3 className='font-medium text-[13px] text-[var(--text-secondary)]'>
|
||||
{group.label}
|
||||
</h3>
|
||||
<div className='grid grid-cols-3 gap-[16px]'>
|
||||
{group.templates.map((template) => (
|
||||
<TemplateCard key={template.title} template={template} onSelect={onSelect} />
|
||||
))}
|
||||
<div className='grid grid-cols-3 gap-[16px]'>
|
||||
{TEMPLATES.map((template) => {
|
||||
const Icon = template.icon
|
||||
return (
|
||||
<button
|
||||
key={template.title}
|
||||
type='button'
|
||||
onClick={() => onSelect(template.prompt)}
|
||||
className='group flex cursor-pointer flex-col text-left'
|
||||
>
|
||||
<div className='overflow-hidden rounded-[10px] border border-[var(--border-1)]'>
|
||||
<div className='relative h-[120px] w-full overflow-hidden'>
|
||||
<Image
|
||||
src={template.image}
|
||||
alt={template.title}
|
||||
fill
|
||||
unoptimized
|
||||
className='object-cover transition-transform duration-300 group-hover:scale-105'
|
||||
/>
|
||||
</div>
|
||||
<div className='flex items-center gap-[6px] border-[var(--border-1)] border-t bg-[var(--white)] px-[10px] py-[6px] dark:bg-[var(--surface-4)]'>
|
||||
<Icon className='h-[14px] w-[14px] shrink-0 text-[var(--text-icon)]' />
|
||||
<span className='font-base text-[14px] text-[var(--text-body)]'>
|
||||
{template.title}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface TemplateCardProps {
|
||||
template: (typeof TEMPLATES)[number]
|
||||
onSelect: (prompt: string) => void
|
||||
}
|
||||
|
||||
function TemplateCard({ template, onSelect }: TemplateCardProps) {
|
||||
const Icon = template.icon
|
||||
|
||||
return (
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => onSelect(template.prompt)}
|
||||
aria-label={`Select template: ${template.title}`}
|
||||
className='group flex cursor-pointer flex-col text-left'
|
||||
>
|
||||
<div className='overflow-hidden rounded-[10px] border border-[var(--border-1)]'>
|
||||
<div className='relative h-[120px] w-full overflow-hidden'>
|
||||
{template.image ? (
|
||||
<Image
|
||||
src={template.image}
|
||||
alt={template.title}
|
||||
fill
|
||||
unoptimized
|
||||
className='object-cover transition-transform duration-300 group-hover:scale-105'
|
||||
/>
|
||||
) : (
|
||||
<div className='flex h-full w-full items-center justify-center bg-[var(--surface-3)] transition-colors group-hover:bg-[var(--surface-4)]'>
|
||||
<Icon className='h-[32px] w-[32px] text-[var(--text-icon)] opacity-40' />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex flex-col gap-[4px] border-[var(--border-1)] border-t bg-[var(--white)] px-[10px] py-[6px] dark:bg-[var(--surface-4)]'>
|
||||
<div className='flex items-center gap-[6px]'>
|
||||
<Icon className='h-[14px] w-[14px] shrink-0 text-[var(--text-icon)]' />
|
||||
<span className='font-base text-[14px] text-[var(--text-body)]'>{template.title}</span>
|
||||
</div>
|
||||
<ModulePills modules={template.modules} />
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -212,7 +212,6 @@ export function UserInput({
|
||||
|
||||
const files = useFileAttachments({
|
||||
userId: userId || session?.user?.id,
|
||||
workspaceId,
|
||||
disabled: false,
|
||||
isLoading: isSending,
|
||||
})
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
import { persistImportedWorkflow } from '@/lib/workflows/operations/import-export'
|
||||
import { useChatHistory, useMarkTaskRead } from '@/hooks/queries/tasks'
|
||||
import type { ChatContext } from '@/stores/panel'
|
||||
import { useSidebarStore } from '@/stores/sidebar/store'
|
||||
import {
|
||||
MessageContent,
|
||||
MothershipView,
|
||||
@@ -167,9 +166,6 @@ export function Home({ chatId }: HomeProps = {}) {
|
||||
|
||||
const handleResourceEvent = useCallback(() => {
|
||||
if (isResourceCollapsedRef.current) {
|
||||
/** Auto-collapse sidebar to give resource panel maximum width for immersive experience */
|
||||
const { isCollapsed, toggleCollapsed } = useSidebarStore.getState()
|
||||
if (!isCollapsed) toggleCollapsed()
|
||||
setIsResourceCollapsed(false)
|
||||
setIsResourceAnimatingIn(true)
|
||||
}
|
||||
@@ -178,7 +174,6 @@ export function Home({ chatId }: HomeProps = {}) {
|
||||
const {
|
||||
messages,
|
||||
isSending,
|
||||
isReconnecting,
|
||||
sendMessage,
|
||||
stopGeneration,
|
||||
resolvedChatId,
|
||||
@@ -331,7 +326,7 @@ export function Home({ chatId }: HomeProps = {}) {
|
||||
return () => ro.disconnect()
|
||||
}, [hasMessages])
|
||||
|
||||
if (chatId && (isLoadingHistory || isReconnecting)) {
|
||||
if (!hasMessages && chatId && isLoadingHistory) {
|
||||
return (
|
||||
<ChatSkeleton>
|
||||
<UserInput
|
||||
@@ -348,7 +343,7 @@ export function Home({ chatId }: HomeProps = {}) {
|
||||
|
||||
if (!hasMessages) {
|
||||
return (
|
||||
<div className='h-full overflow-y-auto bg-[var(--bg)]'>
|
||||
<div className='h-full overflow-y-auto bg-[var(--bg)] [scrollbar-gutter:stable]'>
|
||||
<div className='flex min-h-full flex-col items-center justify-center px-[24px] pb-[2vh]'>
|
||||
<h1 className='mb-[24px] max-w-[42rem] font-[430] font-season text-[32px] text-[var(--text-primary)] tracking-[-0.02em]'>
|
||||
What should we get done
|
||||
@@ -377,7 +372,7 @@ export function Home({ chatId }: HomeProps = {}) {
|
||||
<div className='flex h-full min-w-0 flex-1 flex-col'>
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className='min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-6 pt-4 pb-8'
|
||||
className='min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-6 pt-4 pb-8 [scrollbar-gutter:stable]'
|
||||
>
|
||||
<div className='mx-auto max-w-[42rem] space-y-6'>
|
||||
{messages.map((msg, index) => {
|
||||
|
||||
@@ -45,7 +45,6 @@ import type {
|
||||
export interface UseChatReturn {
|
||||
messages: ChatMessage[]
|
||||
isSending: boolean
|
||||
isReconnecting: boolean
|
||||
error: string | null
|
||||
resolvedChatId: string | undefined
|
||||
sendMessage: (
|
||||
@@ -251,7 +250,6 @@ export function useChat(
|
||||
const queryClient = useQueryClient()
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([])
|
||||
const [isSending, setIsSending] = useState(false)
|
||||
const [isReconnecting, setIsReconnecting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [resolvedChatId, setResolvedChatId] = useState<string | undefined>(initialChatId)
|
||||
const [resources, setResources] = useState<MothershipResource[]>([])
|
||||
@@ -270,10 +268,6 @@ export function useChat(
|
||||
}, [messageQueue])
|
||||
|
||||
const sendMessageRef = useRef<UseChatReturn['sendMessage']>(async () => {})
|
||||
const processSSEStreamRef = useRef<
|
||||
(reader: ReadableStreamDefaultReader<Uint8Array>, assistantId: string) => Promise<void>
|
||||
>(async () => {})
|
||||
const finalizeRef = useRef<(options?: { error?: boolean }) => void>(() => {})
|
||||
|
||||
const abortControllerRef = useRef<AbortController | null>(null)
|
||||
const chatIdRef = useRef<string | undefined>(initialChatId)
|
||||
@@ -335,7 +329,6 @@ export function useChat(
|
||||
setMessages([])
|
||||
setError(null)
|
||||
setIsSending(false)
|
||||
setIsReconnecting(false)
|
||||
setResources([])
|
||||
setActiveResourceId(null)
|
||||
setMessageQueue([])
|
||||
@@ -353,7 +346,6 @@ export function useChat(
|
||||
setMessages([])
|
||||
setError(null)
|
||||
setIsSending(false)
|
||||
setIsReconnecting(false)
|
||||
setResources([])
|
||||
setActiveResourceId(null)
|
||||
setMessageQueue([])
|
||||
@@ -373,95 +365,6 @@ export function useChat(
|
||||
ensureWorkflowInRegistry(resource.id, resource.title, workspaceId)
|
||||
}
|
||||
}
|
||||
|
||||
// Kick off stream reconnection immediately if there's an active stream.
|
||||
// The stream snapshot was fetched in parallel with the chat history (same
|
||||
// API call), so there's no extra round-trip.
|
||||
const activeStreamId = chatHistory.activeStreamId
|
||||
const snapshot = chatHistory.streamSnapshot
|
||||
if (activeStreamId && !sendingRef.current) {
|
||||
const gen = ++streamGenRef.current
|
||||
const abortController = new AbortController()
|
||||
abortControllerRef.current = abortController
|
||||
streamIdRef.current = activeStreamId
|
||||
sendingRef.current = true
|
||||
setIsReconnecting(true)
|
||||
|
||||
const assistantId = crypto.randomUUID()
|
||||
|
||||
const reconnect = async () => {
|
||||
try {
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
const batchEvents = snapshot?.events ?? []
|
||||
const streamStatus = snapshot?.status ?? ''
|
||||
|
||||
if (!snapshot || (batchEvents.length === 0 && streamStatus === 'unknown')) {
|
||||
// No snapshot available — stream buffer expired. Clean up.
|
||||
const cid = chatIdRef.current
|
||||
if (cid) {
|
||||
fetch('/api/mothership/chat/stop', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ chatId: cid, streamId: activeStreamId, content: '' }),
|
||||
}).catch(() => {})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
setIsSending(true)
|
||||
setIsReconnecting(false)
|
||||
|
||||
const lastEventId =
|
||||
batchEvents.length > 0 ? batchEvents[batchEvents.length - 1].eventId : 0
|
||||
const isStreamDone = streamStatus === 'complete' || streamStatus === 'error'
|
||||
|
||||
const combinedStream = new ReadableStream<Uint8Array>({
|
||||
async start(controller) {
|
||||
if (batchEvents.length > 0) {
|
||||
const sseText = batchEvents
|
||||
.map((e) => `data: ${JSON.stringify(e.event)}\n`)
|
||||
.join('\n')
|
||||
controller.enqueue(encoder.encode(`${sseText}\n`))
|
||||
}
|
||||
|
||||
if (!isStreamDone) {
|
||||
try {
|
||||
const sseRes = await fetch(
|
||||
`/api/copilot/chat/stream?streamId=${activeStreamId}&from=${lastEventId}`,
|
||||
{ signal: abortController.signal }
|
||||
)
|
||||
if (sseRes.ok && sseRes.body) {
|
||||
const reader = sseRes.body.getReader()
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
controller.enqueue(value)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (!(err instanceof Error && err.name === 'AbortError')) {
|
||||
logger.warn('SSE tail failed during reconnect', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
|
||||
await processSSEStreamRef.current(combinedStream.getReader(), assistantId)
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === 'AbortError') return
|
||||
} finally {
|
||||
setIsReconnecting(false)
|
||||
if (streamGenRef.current === gen) {
|
||||
finalizeRef.current()
|
||||
}
|
||||
}
|
||||
}
|
||||
reconnect()
|
||||
}
|
||||
}, [chatHistory, workspaceId])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -502,14 +405,11 @@ export function useChat(
|
||||
|
||||
const flush = () => {
|
||||
streamingBlocksRef.current = [...blocks]
|
||||
const snapshot = { content: runningText, contentBlocks: [...blocks] }
|
||||
setMessages((prev) => {
|
||||
const idx = prev.findIndex((m) => m.id === assistantId)
|
||||
if (idx >= 0) {
|
||||
return prev.map((m) => (m.id === assistantId ? { ...m, ...snapshot } : m))
|
||||
}
|
||||
return [...prev, { id: assistantId, role: 'assistant' as const, ...snapshot }]
|
||||
})
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === assistantId ? { ...m, content: runningText, contentBlocks: [...blocks] } : m
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
while (true) {
|
||||
@@ -762,9 +662,6 @@ export function useChat(
|
||||
},
|
||||
[workspaceId, queryClient, addResource, removeResource]
|
||||
)
|
||||
useLayoutEffect(() => {
|
||||
processSSEStreamRef.current = processSSEStream
|
||||
})
|
||||
|
||||
const persistPartialResponse = useCallback(async () => {
|
||||
const chatId = chatIdRef.current
|
||||
@@ -853,9 +750,50 @@ export function useChat(
|
||||
},
|
||||
[invalidateChatQueries]
|
||||
)
|
||||
useLayoutEffect(() => {
|
||||
finalizeRef.current = finalize
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const activeStreamId = chatHistory?.activeStreamId
|
||||
if (!activeStreamId || !appliedChatIdRef.current || sendingRef.current) return
|
||||
|
||||
const gen = ++streamGenRef.current
|
||||
const abortController = new AbortController()
|
||||
abortControllerRef.current = abortController
|
||||
sendingRef.current = true
|
||||
setIsSending(true)
|
||||
|
||||
const assistantId = crypto.randomUUID()
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: assistantId,
|
||||
role: 'assistant' as const,
|
||||
content: '',
|
||||
contentBlocks: [],
|
||||
},
|
||||
])
|
||||
|
||||
const reconnect = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/copilot/chat/stream?streamId=${activeStreamId}&from=0`, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
if (!response.ok || !response.body) return
|
||||
await processSSEStream(response.body.getReader(), assistantId)
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === 'AbortError') return
|
||||
} finally {
|
||||
if (streamGenRef.current === gen) {
|
||||
finalize()
|
||||
}
|
||||
}
|
||||
}
|
||||
reconnect()
|
||||
|
||||
return () => {
|
||||
abortController.abort()
|
||||
appliedChatIdRef.current = undefined
|
||||
}
|
||||
}, [chatHistory?.activeStreamId, processSSEStream, finalize])
|
||||
|
||||
const sendMessage = useCallback(
|
||||
async (message: string, fileAttachments?: FileAttachmentForApi[], contexts?: ChatContext[]) => {
|
||||
@@ -942,15 +880,12 @@ export function useChat(
|
||||
try {
|
||||
const currentActiveId = activeResourceIdRef.current
|
||||
const currentResources = resourcesRef.current
|
||||
const resourceAttachments =
|
||||
currentResources.length > 0
|
||||
? currentResources.map((r) => ({
|
||||
type: r.type,
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
active: r.id === currentActiveId,
|
||||
}))
|
||||
: undefined
|
||||
const activeRes = currentActiveId
|
||||
? currentResources.find((r) => r.id === currentActiveId)
|
||||
: undefined
|
||||
const resourceAttachments = activeRes
|
||||
? [{ type: activeRes.type, id: activeRes.id }]
|
||||
: undefined
|
||||
|
||||
const response = await fetch(MOTHERSHIP_CHAT_API_PATH, {
|
||||
method: 'POST',
|
||||
@@ -999,11 +934,7 @@ export function useChat(
|
||||
if (sendingRef.current) {
|
||||
await persistPartialResponse()
|
||||
}
|
||||
const sid =
|
||||
streamIdRef.current ||
|
||||
queryClient.getQueryData<TaskChatHistory>(taskKeys.detail(chatIdRef.current))
|
||||
?.activeStreamId ||
|
||||
undefined
|
||||
const sid = streamIdRef.current
|
||||
streamGenRef.current++
|
||||
abortControllerRef.current?.abort()
|
||||
abortControllerRef.current = null
|
||||
@@ -1120,7 +1051,6 @@ export function useChat(
|
||||
return {
|
||||
messages,
|
||||
isSending,
|
||||
isReconnecting,
|
||||
error,
|
||||
resolvedChatId,
|
||||
sendMessage,
|
||||
|
||||
@@ -554,7 +554,7 @@ export function DocumentTagsModal({
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
onClick={saveDocumentTag}
|
||||
className='flex-1'
|
||||
disabled={!canSaveTag}
|
||||
@@ -718,7 +718,7 @@ export function DocumentTagsModal({
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
onClick={saveDocumentTag}
|
||||
className='flex-1'
|
||||
disabled={
|
||||
|
||||
@@ -558,7 +558,7 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo
|
||||
<Button variant='default' onClick={() => onOpenChange(false)} disabled={isCreating}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant='tertiary' onClick={handleSubmit} disabled={!canSubmit || isCreating}>
|
||||
<Button variant='primary' onClick={handleSubmit} disabled={!canSubmit || isCreating}>
|
||||
{isCreating ? (
|
||||
<>
|
||||
<Loader2 className='mr-1.5 h-3.5 w-3.5 animate-spin' />
|
||||
|
||||
@@ -347,7 +347,7 @@ export function AddDocumentsModal({
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
type='button'
|
||||
onClick={handleUpload}
|
||||
disabled={files.length === 0 || isUploading}
|
||||
|
||||
@@ -387,7 +387,7 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
onClick={saveTagDefinition}
|
||||
className='flex-1'
|
||||
disabled={
|
||||
|
||||
@@ -23,9 +23,9 @@ import {
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
Skeleton,
|
||||
Tooltip,
|
||||
} from '@/components/emcn'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import {
|
||||
getCanonicalScopesForProvider,
|
||||
|
||||
@@ -19,8 +19,8 @@ import {
|
||||
ModalTabsContent,
|
||||
ModalTabsList,
|
||||
ModalTabsTrigger,
|
||||
Skeleton,
|
||||
} from '@/components/emcn'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { CONNECTOR_REGISTRY } from '@/connectors/registry'
|
||||
import type { ConnectorConfig } from '@/connectors/types'
|
||||
import type { ConnectorData } from '@/hooks/queries/kb/connectors'
|
||||
@@ -161,7 +161,7 @@ export function EditConnectorModal({
|
||||
<Button variant='default' onClick={() => onOpenChange(false)} disabled={isSaving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant='tertiary' onClick={handleSave} disabled={!hasChanges || isSaving}>
|
||||
<Button variant='primary' onClick={handleSave} disabled={!hasChanges || isSaving}>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className='mr-1.5 h-3.5 w-3.5 animate-spin' />
|
||||
|
||||
@@ -124,7 +124,7 @@ export function RenameDocumentModal({
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
type='submit'
|
||||
disabled={isSubmitting || !name?.trim() || name.trim() === initialName}
|
||||
>
|
||||
|
||||
@@ -522,7 +522,7 @@ export function CreateBaseModal({ open, onOpenChange }: CreateBaseModalProps) {
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
type='submit'
|
||||
disabled={isSubmitting || !nameValue?.trim()}
|
||||
>
|
||||
|
||||
@@ -159,7 +159,7 @@ export function EditKnowledgeBaseModal({
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
type='submit'
|
||||
disabled={isSubmitting || !nameValue?.trim() || !isDirty}
|
||||
>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { Skeleton } from '@/components/emcn'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { formatLatency } from '@/app/workspace/[workspaceId]/logs/utils'
|
||||
import type { DashboardStatsResponse, WorkflowStats } from '@/hooks/queries/logs'
|
||||
import { useFilterStore } from '@/stores/logs/filters/store'
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
Tooltip,
|
||||
} from '@/components/emcn'
|
||||
import { Copy as CopyIcon, Search as SearchIcon } from '@/components/emcn/icons'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { BASE_EXECUTION_CHARGE } from '@/lib/billing/constants'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { formatDuration } from '@/lib/core/utils/formatting'
|
||||
@@ -395,7 +396,7 @@ export const LogDetails = memo(function LogDetails({
|
||||
</div>
|
||||
|
||||
{/* Content - Scrollable */}
|
||||
<div className='mt-[20px] h-full w-full overflow-y-auto' ref={scrollAreaRef}>
|
||||
<ScrollArea className='mt-[20px] h-full w-full overflow-y-auto' ref={scrollAreaRef}>
|
||||
<div className='flex flex-col gap-[10px] pb-[16px]'>
|
||||
{/* Timestamp & Workflow Row */}
|
||||
<div className='flex min-w-0 items-center gap-[16px] px-[1px]'>
|
||||
@@ -631,7 +632,7 @@ export const LogDetails = memo(function LogDetails({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { Badge, Combobox, type ComboboxOption, Label, Skeleton } from '@/components/emcn'
|
||||
import { Badge, Combobox, type ComboboxOption, Label } from '@/components/emcn'
|
||||
import { Skeleton } from '@/components/ui'
|
||||
import { useWorkflows } from '@/hooks/queries/workflows'
|
||||
|
||||
interface WorkflowSelectorProps {
|
||||
|
||||
@@ -18,11 +18,11 @@ import {
|
||||
ModalTabsContent,
|
||||
ModalTabsList,
|
||||
ModalTabsTrigger,
|
||||
Skeleton,
|
||||
TagInput,
|
||||
type TagItem,
|
||||
} from '@/components/emcn'
|
||||
import { SlackIcon } from '@/components/icons'
|
||||
import { Skeleton } from '@/components/ui'
|
||||
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
|
||||
import { getTriggerOptions } from '@/lib/logs/get-trigger-options'
|
||||
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
||||
@@ -580,7 +580,7 @@ export const NotificationSettings = memo(function NotificationSettings({
|
||||
|
||||
<div className='flex flex-shrink-0 items-center gap-[8px]'>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
onClick={() => handleTest(subscription.id)}
|
||||
disabled={testNotification.isPending && testStatus?.id !== subscription.id}
|
||||
>
|
||||
@@ -1235,7 +1235,7 @@ export const NotificationSettings = memo(function NotificationSettings({
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
onClick={handleSave}
|
||||
disabled={createNotification.isPending || updateNotification.isPending}
|
||||
>
|
||||
@@ -1254,7 +1254,7 @@ export const NotificationSettings = memo(function NotificationSettings({
|
||||
resetForm()
|
||||
setShowForm(true)
|
||||
}}
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Plus className='mr-[6px] h-[13px] w-[13px]' />
|
||||
|
||||
@@ -541,7 +541,7 @@ export function ScheduleModal({ open, onOpenChange, workspaceId, schedule }: Sch
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
onClick={handleSubmit}
|
||||
disabled={
|
||||
!isFormValid || createScheduleMutation.isPending || updateScheduleMutation.isPending
|
||||
|
||||
@@ -11,11 +11,10 @@ import {
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
Skeleton,
|
||||
Switch,
|
||||
Tooltip,
|
||||
} from '@/components/emcn'
|
||||
import { Input } from '@/components/ui'
|
||||
import { Input, Skeleton } from '@/components/ui'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { formatDate } from '@/lib/core/utils/formatting'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
@@ -150,7 +149,7 @@ export function ApiKeys() {
|
||||
e.currentTarget.blur()
|
||||
setIsCreateDialogOpen(true)
|
||||
}}
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
disabled={createButtonDisabled}
|
||||
>
|
||||
<Plus className='mr-[6px] h-[13px] w-[13px]' />
|
||||
|
||||
@@ -190,7 +190,7 @@ export function CreateApiKeyModal({
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
onClick={handleCreateKey}
|
||||
disabled={
|
||||
!keyName.trim() ||
|
||||
|
||||
@@ -277,11 +277,7 @@ export function BYOK() {
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant='primary'
|
||||
className='!bg-[var(--brand-tertiary-2)] !text-[var(--text-inverse)] hover:!bg-[var(--brand-tertiary-2)]/90'
|
||||
onClick={() => openEditModal(provider.id)}
|
||||
>
|
||||
<Button variant='primary' onClick={() => openEditModal(provider.id)}>
|
||||
Add Key
|
||||
</Button>
|
||||
)}
|
||||
@@ -391,7 +387,7 @@ export function BYOK() {
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
onClick={handleSave}
|
||||
disabled={!apiKeyInput.trim() || upsertKey.isPending}
|
||||
>
|
||||
|
||||
@@ -200,7 +200,7 @@ export function Copilot() {
|
||||
setIsCreateDialogOpen(true)
|
||||
setCreateError(null)
|
||||
}}
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Plus className='mr-[6px] h-[13px] w-[13px]' />
|
||||
@@ -302,7 +302,7 @@ export function Copilot() {
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
onClick={handleCreateKey}
|
||||
disabled={!newKeyName.trim() || generateKey.isPending}
|
||||
>
|
||||
|
||||
@@ -17,12 +17,11 @@ import {
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
Skeleton,
|
||||
TagInput,
|
||||
type TagItem,
|
||||
} from '@/components/emcn'
|
||||
import { GmailIcon, OutlookIcon } from '@/components/icons'
|
||||
import { Input as BaseInput } from '@/components/ui'
|
||||
import { Input as BaseInput, Skeleton } from '@/components/ui'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { getSubscriptionStatus } from '@/lib/billing/client'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
@@ -629,7 +628,7 @@ export function CredentialSets() {
|
||||
/>
|
||||
</div>
|
||||
{canManageCredentialSets && (
|
||||
<Button variant='tertiary' onClick={() => setShowCreateModal(true)}>
|
||||
<Button variant='primary' onClick={() => setShowCreateModal(true)}>
|
||||
<Plus className='mr-[6px] h-[13px] w-[13px]' />
|
||||
Create
|
||||
</Button>
|
||||
@@ -672,7 +671,7 @@ export function CredentialSets() {
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
onClick={() => handleAcceptInvitation(invitation.token)}
|
||||
disabled={acceptInvitation.isPending}
|
||||
>
|
||||
@@ -844,7 +843,7 @@ export function CredentialSets() {
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
onClick={handleCreateCredentialSet}
|
||||
disabled={!newSetName.trim() || createCredentialSet.isPending}
|
||||
>
|
||||
|
||||
@@ -17,12 +17,11 @@ import {
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
Skeleton,
|
||||
Textarea,
|
||||
Tooltip,
|
||||
Trash,
|
||||
} from '@/components/emcn'
|
||||
import { Input } from '@/components/ui'
|
||||
import { Input, Skeleton } from '@/components/ui'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import {
|
||||
clearPendingCredentialCreateRequest,
|
||||
@@ -1312,7 +1311,7 @@ export function CredentialsManager() {
|
||||
</Button>
|
||||
{isSelectedAdmin && (
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
onClick={handleSaveDetails}
|
||||
disabled={!isDetailsDirty || isSavingDetails}
|
||||
>
|
||||
@@ -1415,7 +1414,7 @@ export function CredentialsManager() {
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isLoading || !hasChanges || hasConflicts || hasInvalidKeys}
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
className={`${hasConflicts || hasInvalidKeys ? 'cursor-not-allowed opacity-50' : ''}`}
|
||||
>
|
||||
Save
|
||||
|
||||
@@ -101,7 +101,7 @@ export function CustomTools() {
|
||||
className='h-auto flex-1 border-0 bg-transparent p-0 font-base leading-none placeholder:text-[var(--text-tertiary)] focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={() => setShowAddForm(true)} disabled={isLoading} variant='tertiary'>
|
||||
<Button onClick={() => setShowAddForm(true)} disabled={isLoading} variant='primary'>
|
||||
<Plus className='mr-[6px] h-[13px] w-[13px]' />
|
||||
Add
|
||||
</Button>
|
||||
|
||||
@@ -50,7 +50,7 @@ export function Debug() {
|
||||
disabled={importWorkflow.isPending}
|
||||
/>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
onClick={handleImport}
|
||||
disabled={importWorkflow.isPending || !workflowId.trim()}
|
||||
>
|
||||
|
||||
@@ -515,7 +515,7 @@ export function General() {
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
onClick={handleResetPasswordConfirm}
|
||||
disabled={resetPassword.isPending || resetPassword.isSuccess}
|
||||
>
|
||||
|
||||
@@ -108,7 +108,7 @@ export function InboxEnableToggle() {
|
||||
<Button variant='default' onClick={() => setIsEnableOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant='tertiary' onClick={handleEnable} disabled={toggleInbox.isPending}>
|
||||
<Button variant='primary' onClick={handleEnable} disabled={toggleInbox.isPending}>
|
||||
{toggleInbox.isPending ? 'Enabling...' : 'Enable'}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
|
||||
@@ -282,7 +282,7 @@ export function InboxSettingsTab() {
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
onClick={handleAddSender}
|
||||
disabled={!newSenderEmail.trim() || addSender.isPending}
|
||||
>
|
||||
|
||||
@@ -41,7 +41,7 @@ export function Inbox() {
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
onClick={() => router.push(`/workspace/${workspaceId}/settings/subscription`)}
|
||||
>
|
||||
Upgrade to Max
|
||||
|
||||
@@ -17,11 +17,10 @@ import {
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
Skeleton,
|
||||
Textarea,
|
||||
Tooltip,
|
||||
} from '@/components/emcn'
|
||||
import { Input as UiInput } from '@/components/ui'
|
||||
import { Skeleton, Input as UiInput } from '@/components/ui'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import {
|
||||
clearPendingCredentialCreateRequest,
|
||||
@@ -679,7 +678,7 @@ export function IntegrationsManager() {
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
onClick={handleCreateCredential}
|
||||
disabled={
|
||||
!createOAuthProviderId ||
|
||||
@@ -1028,7 +1027,7 @@ export function IntegrationsManager() {
|
||||
</Button>
|
||||
{isSelectedAdmin && (
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
onClick={handleSaveDetails}
|
||||
disabled={!isDetailsDirty || isSavingDetails}
|
||||
>
|
||||
@@ -1067,7 +1066,7 @@ export function IntegrationsManager() {
|
||||
<Button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
disabled={credentialsLoading}
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
>
|
||||
<Plus className='mr-[6px] h-[13px] w-[13px]' />
|
||||
Connect
|
||||
|
||||
@@ -719,12 +719,12 @@ export function McpServerFormModal({
|
||||
<Button
|
||||
onClick={handleSubmitJson}
|
||||
disabled={isSubmitting || !jsonInput.trim()}
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
>
|
||||
{isSubmitting ? 'Adding...' : submitLabel}
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={handleSubmitForm} disabled={isSubmitDisabled} variant='tertiary'>
|
||||
<Button onClick={handleSubmitForm} disabled={isSubmitDisabled} variant='primary'>
|
||||
{isSubmitting ? (mode === 'add' ? 'Adding...' : 'Saving...') : submitLabel}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -637,11 +637,7 @@ export function MCP({ initialServerId }: MCPProps) {
|
||||
className='h-auto flex-1 border-0 bg-transparent p-0 font-base leading-none placeholder:text-[var(--text-tertiary)] focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setShowAddModal(true)}
|
||||
variant='tertiary'
|
||||
disabled={serversLoading}
|
||||
>
|
||||
<Button onClick={() => setShowAddModal(true)} variant='primary' disabled={serversLoading}>
|
||||
<Plus className='mr-[6px] h-[13px] w-[13px]' />
|
||||
Add
|
||||
</Button>
|
||||
|
||||
@@ -303,7 +303,7 @@ export function RecentlyDeleted() {
|
||||
<div className='flex shrink-0 items-center gap-[8px]'>
|
||||
<span className='text-[13px] text-[var(--text-tertiary)]'>Restored</span>
|
||||
<Button
|
||||
variant='default'
|
||||
variant='primary'
|
||||
size='sm'
|
||||
onClick={() =>
|
||||
router.push(
|
||||
@@ -316,7 +316,7 @@ export function RecentlyDeleted() {
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant='default'
|
||||
variant='primary'
|
||||
size='sm'
|
||||
disabled={isRestoring}
|
||||
onClick={() => handleRestore(resource)}
|
||||
|
||||
@@ -210,7 +210,7 @@ export function SkillModal({
|
||||
<Button variant='default' onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant='tertiary' onClick={handleSave} disabled={saving || !hasChanges}>
|
||||
<Button variant='primary' onClick={handleSave} disabled={saving || !hasChanges}>
|
||||
{saving ? 'Saving...' : initialValues ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -95,7 +95,7 @@ export function Skills() {
|
||||
className='h-auto flex-1 border-0 bg-transparent p-0 font-base leading-none placeholder:text-[var(--text-tertiary)] focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={() => setShowAddForm(true)} disabled={isLoading} variant='tertiary'>
|
||||
<Button onClick={() => setShowAddForm(true)} disabled={isLoading} variant='primary'>
|
||||
<Plus className='mr-[6px] h-[13px] w-[13px]' />
|
||||
Add
|
||||
</Button>
|
||||
|
||||
@@ -160,7 +160,7 @@ export function CreditBalance({
|
||||
</Button>
|
||||
</ModalClose>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
onClick={handlePurchase}
|
||||
disabled={purchaseCredits.isPending || !amount}
|
||||
>
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export { CreditBalance } from './credit-balance'
|
||||
export { PlanCard, type PlanCardProps, type PlanFeature } from './plan-card'
|
||||
export { ReferralCode } from './referral-code'
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { ReferralCode } from './referral-code'
|
||||
@@ -0,0 +1,82 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Button, Input, Label } from '@/components/emcn'
|
||||
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
|
||||
import { useRedeemReferralCode } from '@/hooks/queries/subscription'
|
||||
|
||||
interface ReferralCodeProps {
|
||||
onRedeemComplete?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline referral/promo code entry field with redeem button.
|
||||
* One-time use per account — shows success or "already redeemed" state.
|
||||
*/
|
||||
export function ReferralCode({ onRedeemComplete }: ReferralCodeProps) {
|
||||
const [code, setCode] = useState('')
|
||||
const redeemCode = useRedeemReferralCode()
|
||||
|
||||
const handleRedeem = () => {
|
||||
const trimmed = code.trim()
|
||||
if (!trimmed || redeemCode.isPending) return
|
||||
|
||||
redeemCode.mutate(
|
||||
{ code: trimmed },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setCode('')
|
||||
onRedeemComplete?.()
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (redeemCode.isSuccess) {
|
||||
return (
|
||||
<div className='flex items-center justify-between'>
|
||||
<Label>Referral Code</Label>
|
||||
<span className='text-[13px] text-[var(--text-secondary)]'>
|
||||
+{dollarsToCredits(redeemCode.data.bonusAmount ?? 0).toLocaleString()} credits applied
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-[4px]'>
|
||||
<div className='flex items-center justify-between gap-[12px]'>
|
||||
<Label className='shrink-0'>Referral Code</Label>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<Input
|
||||
type='text'
|
||||
value={code}
|
||||
onChange={(e) => {
|
||||
setCode(e.target.value)
|
||||
redeemCode.reset()
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleRedeem()
|
||||
}}
|
||||
placeholder='Enter code'
|
||||
className='h-[32px] w-[140px] bg-[var(--surface-4)] text-[13px]'
|
||||
disabled={redeemCode.isPending}
|
||||
/>
|
||||
<Button
|
||||
variant='default'
|
||||
className='h-[32px] shrink-0 text-[13px]'
|
||||
onClick={handleRedeem}
|
||||
disabled={redeemCode.isPending || !code.trim()}
|
||||
>
|
||||
{redeemCode.isPending ? 'Redeeming...' : 'Redeem'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{redeemCode.error && (
|
||||
<span className='text-right text-[11px] text-[var(--text-error)]'>
|
||||
{redeemCode.error.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -14,10 +14,10 @@ import {
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
Skeleton,
|
||||
Switch,
|
||||
Tooltip,
|
||||
} from '@/components/emcn'
|
||||
import { Skeleton } from '@/components/ui'
|
||||
import { useSession, useSubscription } from '@/lib/auth/auth-client'
|
||||
import { USAGE_THRESHOLDS } from '@/lib/billing/client/consts'
|
||||
import { useSubscriptionUpgrade } from '@/lib/billing/client/upgrade'
|
||||
@@ -47,6 +47,7 @@ import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/provide
|
||||
import {
|
||||
CreditBalance,
|
||||
PlanCard,
|
||||
ReferralCode,
|
||||
} from '@/app/workspace/[workspaceId]/settings/components/subscription/components'
|
||||
import {
|
||||
ENTERPRISE_PLAN_FEATURES,
|
||||
@@ -244,7 +245,7 @@ function CreditPlanCard({
|
||||
{isCancelledAtPeriodEnd ? 'Restore Subscription' : 'Manage plan'}
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={onButtonClick} className='w-full' variant='tertiary'>
|
||||
<Button onClick={onButtonClick} className='w-full' variant='primary'>
|
||||
{buttonText}
|
||||
</Button>
|
||||
)}
|
||||
@@ -999,6 +1000,11 @@ export function Subscription() {
|
||||
inlineButton
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Referral Code */}
|
||||
{!subscription.isEnterprise && (
|
||||
<ReferralCode onRedeemComplete={() => refetchSubscription()} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1128,7 +1134,7 @@ function TeamPlanModal({ open, onOpenChange, isAnnual, onConfirm }: TeamPlanModa
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
onClick={() => onConfirm(selectedTier, selectedSeats)}
|
||||
disabled={selectedSeats < 1}
|
||||
>
|
||||
@@ -1285,7 +1291,7 @@ function ManagePlanModal({
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
className='ml-[12px] shrink-0'
|
||||
onClick={action.onClick}
|
||||
disabled={action.disabled}
|
||||
@@ -1306,7 +1312,7 @@ function ManagePlanModal({
|
||||
<Button variant='default' onClick={() => onOpenChange(false)}>
|
||||
Close
|
||||
</Button>
|
||||
<Button variant='tertiary' onClick={onRestore}>
|
||||
<Button variant='primary' onClick={onRestore}>
|
||||
Restore Subscription
|
||||
</Button>
|
||||
</>
|
||||
|
||||
@@ -239,7 +239,7 @@ export function MemberInvitationCard({
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
onClick={() => onInviteMember()}
|
||||
disabled={!hasValidEmails || isInviting || !hasAvailableSeats}
|
||||
>
|
||||
|
||||
@@ -111,7 +111,7 @@ export function NoOrganizationView({
|
||||
)}
|
||||
<div className='flex justify-end'>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
onClick={onCreateOrganization}
|
||||
disabled={!orgName || !orgSlug || isCreatingOrg}
|
||||
>
|
||||
@@ -193,7 +193,7 @@ export function NoOrganizationView({
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
onClick={onCreateOrganization}
|
||||
disabled={isCreatingOrg || !orgName.trim()}
|
||||
>
|
||||
@@ -217,7 +217,7 @@ export function NoOrganizationView({
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button variant='tertiary' onClick={() => navigateToSettings({ section: 'subscription' })}>
|
||||
<Button variant='primary' onClick={() => navigateToSettings({ section: 'subscription' })}>
|
||||
Upgrade to Team Plan
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Badge, Button, Skeleton } from '@/components/emcn'
|
||||
import { Badge, Button } from '@/components/emcn'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { checkEnterprisePlan } from '@/lib/billing/subscriptions/utils'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
|
||||
@@ -71,7 +72,7 @@ export function TeamSeatsOverview({
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
onClick={() => {
|
||||
onConfirmTeamUpgrade(2)
|
||||
}}
|
||||
|
||||
@@ -135,7 +135,7 @@ export function TeamSeats({
|
||||
<Tooltip.Trigger asChild>
|
||||
<span>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
onClick={() => onConfirm(selectedSeats)}
|
||||
disabled={
|
||||
isLoading ||
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Skeleton, type TagItem } from '@/components/emcn'
|
||||
import type { TagItem } from '@/components/emcn'
|
||||
import { Skeleton } from '@/components/ui'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { getPlanTierCredits, getPlanTierDollars } from '@/lib/billing/plan-helpers'
|
||||
import { checkEnterprisePlan } from '@/lib/billing/subscriptions/utils'
|
||||
|
||||
@@ -4,8 +4,9 @@ import { useEffect, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Camera, Globe, Linkedin, Mail } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import { Button, Combobox, Input, Skeleton, Textarea } from '@/components/emcn'
|
||||
import { Button, Combobox, Input, Textarea } from '@/components/emcn'
|
||||
import { AgentIcon, xIcon as XIcon } from '@/components/icons'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
||||
import type { CreatorProfileDetails } from '@/app/_types/creator-profile'
|
||||
@@ -504,7 +505,7 @@ export function TemplateProfile() {
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={saveStatus === 'saving' || !isFormValid}
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
>
|
||||
{saveStatus === 'saving' ? 'Saving...' : saveStatus === 'saved' ? 'Saved' : 'Save'}
|
||||
</Button>
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
Skeleton,
|
||||
SModalTabs,
|
||||
SModalTabsBody,
|
||||
SModalTabsContent,
|
||||
@@ -28,7 +27,7 @@ import {
|
||||
Textarea,
|
||||
Tooltip,
|
||||
} from '@/components/emcn'
|
||||
import { Input } from '@/components/ui'
|
||||
import { Input, Skeleton } from '@/components/ui'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { useApiKeys } from '@/hooks/queries/api-keys'
|
||||
@@ -350,7 +349,7 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
|
||||
<Tooltip.Trigger asChild>
|
||||
<div className='inline-flex'>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
onClick={() => setShowAddWorkflow(true)}
|
||||
disabled
|
||||
>
|
||||
@@ -365,7 +364,7 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
|
||||
</Tooltip.Root>
|
||||
) : (
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
onClick={() => setShowAddWorkflow(true)}
|
||||
disabled={!canAddWorkflow}
|
||||
>
|
||||
@@ -481,7 +480,7 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
|
||||
workflows via the MCP block.
|
||||
</p>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
className='self-start'
|
||||
disabled={addToWorkspaceMutation.isPending || addedToWorkspace}
|
||||
onClick={async () => {
|
||||
@@ -740,7 +739,7 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
onClick={async () => {
|
||||
if (!toolToView) return
|
||||
try {
|
||||
@@ -860,7 +859,7 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
onClick={handleAddWorkflow}
|
||||
disabled={!selectedWorkflowId || addToolMutation.isPending}
|
||||
>
|
||||
@@ -921,7 +920,7 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
onClick={handleSaveServerEdit}
|
||||
disabled={
|
||||
!editServerName.trim() ||
|
||||
@@ -1060,7 +1059,7 @@ export function WorkflowMcpServers() {
|
||||
className='h-auto flex-1 border-0 bg-transparent p-0 font-base leading-none placeholder:text-[var(--text-tertiary)] focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={() => setShowAddModal(true)} disabled={isLoading} variant='tertiary'>
|
||||
<Button onClick={() => setShowAddModal(true)} disabled={isLoading} variant='primary'>
|
||||
<Plus className='mr-[6px] h-[13px] w-[13px]' />
|
||||
Add
|
||||
</Button>
|
||||
@@ -1204,7 +1203,7 @@ export function WorkflowMcpServers() {
|
||||
<Button
|
||||
onClick={handleCreateServer}
|
||||
disabled={!isFormValid || createServerMutation.isPending}
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
>
|
||||
{createServerMutation.isPending ? 'Adding...' : 'Add Server'}
|
||||
</Button>
|
||||
|
||||
@@ -221,7 +221,7 @@ export function RowModal({ mode, isOpen, onClose, table, row, rowIds, onSuccess
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
onClick={handleFormSubmit}
|
||||
disabled={isSubmitting}
|
||||
className='min-w-[120px]'
|
||||
|
||||
@@ -65,7 +65,7 @@ export function CheckpointConfirmation({
|
||||
{!isRestoreVariant && onContinue && (
|
||||
<Button
|
||||
onClick={onContinue}
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
size='sm'
|
||||
className='flex-1'
|
||||
disabled={isProcessing}
|
||||
|
||||
@@ -1390,7 +1390,7 @@ function RunSkipButtons({
|
||||
// Standardized buttons for all interrupt tools: Allow, Always Allow, Skip
|
||||
return (
|
||||
<div className='mt-[10px] flex gap-[6px]'>
|
||||
<Button onClick={onRun} disabled={isProcessing} variant='tertiary'>
|
||||
<Button onClick={onRun} disabled={isProcessing} variant='primary'>
|
||||
{isProcessing ? 'Allowing...' : 'Allow'}
|
||||
</Button>
|
||||
{showAlwaysAllow && (
|
||||
@@ -2130,7 +2130,7 @@ export function ToolCall({
|
||||
onStateChange?.('background')
|
||||
await sendToolDecision(toolCall.id, 'background')
|
||||
}}
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
title='Move to Background'
|
||||
>
|
||||
Move to Background
|
||||
@@ -2144,7 +2144,7 @@ export function ToolCall({
|
||||
onStateChange?.('background')
|
||||
await sendToolDecision(toolCall.id, 'background')
|
||||
}}
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
title='Wake'
|
||||
>
|
||||
Wake
|
||||
|
||||
@@ -43,7 +43,6 @@ export interface MessageFileAttachment {
|
||||
|
||||
interface UseFileAttachmentsProps {
|
||||
userId?: string
|
||||
workspaceId?: string
|
||||
disabled?: boolean
|
||||
isLoading?: boolean
|
||||
}
|
||||
@@ -56,7 +55,7 @@ interface UseFileAttachmentsProps {
|
||||
* @returns File attachment state and operations
|
||||
*/
|
||||
export function useFileAttachments(props: UseFileAttachmentsProps) {
|
||||
const { userId, workspaceId, disabled, isLoading } = props
|
||||
const { userId, disabled, isLoading } = props
|
||||
|
||||
const [attachedFiles, setAttachedFiles] = useState<AttachedFile[]>([])
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
@@ -136,10 +135,7 @@ export function useFileAttachments(props: UseFileAttachmentsProps) {
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('context', 'mothership')
|
||||
if (workspaceId) {
|
||||
formData.append('workspaceId', workspaceId)
|
||||
}
|
||||
formData.append('context', 'copilot')
|
||||
|
||||
const uploadResponse = await fetch('/api/files/upload', {
|
||||
method: 'POST',
|
||||
@@ -175,7 +171,7 @@ export function useFileAttachments(props: UseFileAttachmentsProps) {
|
||||
}
|
||||
}
|
||||
},
|
||||
[userId, workspaceId]
|
||||
[userId]
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -188,7 +188,6 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
|
||||
const fileAttachments = useFileAttachments({
|
||||
userId: session?.user?.id,
|
||||
workspaceId,
|
||||
disabled,
|
||||
isLoading,
|
||||
})
|
||||
|
||||
@@ -12,11 +12,11 @@ import {
|
||||
Code,
|
||||
Input,
|
||||
Label,
|
||||
Skeleton,
|
||||
TagInput,
|
||||
Textarea,
|
||||
Tooltip,
|
||||
} from '@/components/emcn'
|
||||
import { Skeleton } from '@/components/ui'
|
||||
import type { AgentAuthentication, AgentCapabilities } from '@/lib/a2a/types'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
|
||||
|
||||
@@ -9,9 +9,9 @@ import {
|
||||
Code,
|
||||
Combobox,
|
||||
Label,
|
||||
Skeleton,
|
||||
Tooltip,
|
||||
} from '@/components/emcn'
|
||||
import { Skeleton } from '@/components/ui'
|
||||
import { OutputSelect } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select'
|
||||
|
||||
interface WorkflowDeploymentInfo {
|
||||
|
||||
@@ -14,13 +14,12 @@ import {
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
Skeleton,
|
||||
TagInput,
|
||||
type TagItem,
|
||||
Textarea,
|
||||
Tooltip,
|
||||
} from '@/components/emcn'
|
||||
import { Alert, AlertDescription } from '@/components/ui'
|
||||
import { Alert, AlertDescription, Skeleton } from '@/components/ui'
|
||||
import { getEnv, isTruthy } from '@/lib/core/config/env'
|
||||
import { generatePassword } from '@/lib/core/security/encryption'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
|
||||
@@ -8,12 +8,12 @@ import {
|
||||
ButtonGroupItem,
|
||||
Input,
|
||||
Label,
|
||||
Skeleton,
|
||||
TagInput,
|
||||
type TagItem,
|
||||
Textarea,
|
||||
Tooltip,
|
||||
} from '@/components/emcn'
|
||||
import { Skeleton } from '@/components/ui'
|
||||
import { isDev } from '@/lib/core/config/feature-flags'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { getBaseUrl, getEmailDomain } from '@/lib/core/utils/urls'
|
||||
|
||||
@@ -9,9 +9,9 @@ import {
|
||||
PopoverContent,
|
||||
PopoverItem,
|
||||
PopoverTrigger,
|
||||
Skeleton,
|
||||
Tooltip,
|
||||
} from '@/components/emcn'
|
||||
import { Skeleton } from '@/components/ui'
|
||||
import { formatDateTime } from '@/lib/core/utils/formatting'
|
||||
import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/persistence/utils'
|
||||
import { useUpdateDeploymentVersion } from '@/hooks/queries/deployments'
|
||||
|
||||
@@ -13,9 +13,9 @@ import {
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
Skeleton,
|
||||
Tooltip,
|
||||
} from '@/components/emcn'
|
||||
import { Skeleton } from '@/components/ui'
|
||||
import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/persistence/utils'
|
||||
import { Preview, PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview'
|
||||
import { useDeploymentVersionState, useRevertToVersion } from '@/hooks/queries/workflows'
|
||||
|
||||
@@ -10,9 +10,9 @@ import {
|
||||
type ComboboxOption,
|
||||
Input,
|
||||
Label,
|
||||
Skeleton,
|
||||
Textarea,
|
||||
} from '@/components/emcn'
|
||||
import { Skeleton } from '@/components/ui'
|
||||
import { generateToolInputSchema, sanitizeToolName } from '@/lib/mcp/workflow-tool-schema'
|
||||
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
|
||||
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
|
||||
|
||||
@@ -12,10 +12,10 @@ import {
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
Skeleton,
|
||||
TagInput,
|
||||
Textarea,
|
||||
} from '@/components/emcn'
|
||||
import { Skeleton } from '@/components/ui'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { captureAndUploadOGImage, OG_IMAGE_HEIGHT, OG_IMAGE_WIDTH } from '@/lib/og'
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user