diff --git a/apps/sim/app/(interfaces)/chat/[identifier]/page.tsx b/apps/sim/app/(interfaces)/chat/[identifier]/page.tsx index 5ed89f37fcc..e1a8c32b17d 100644 --- a/apps/sim/app/(interfaces)/chat/[identifier]/page.tsx +++ b/apps/sim/app/(interfaces)/chat/[identifier]/page.tsx @@ -1,11 +1,39 @@ +import { db } from '@sim/db' +import { chat } from '@sim/db/schema' +import { and, eq, isNull } from 'drizzle-orm' import type { Metadata } from 'next' import ChatClient from '@/app/(interfaces)/chat/[identifier]/chat' import { OfficeEmbedInit } from '@/app/(interfaces)/chat/[identifier]/office-embed-init' -export const metadata: Metadata = { - title: 'Chat', +/** + * Only fully public, active deployments are indexable. Auth-gated (password, + * email, SSO) and inactive/nonexistent chats are noindexed at the page level + * so Google never indexes an auth wall — narrower than blocking `/chat/` + * entirely in robots.ts, which would also hide genuinely public deployments. + */ +export async function generateMetadata({ + params, +}: { + params: Promise<{ identifier: string }> +}): Promise { + const { identifier } = await params + + const [deployment] = await db + .select({ authType: chat.authType, isActive: chat.isActive }) + .from(chat) + .where(and(eq(chat.identifier, identifier), isNull(chat.archivedAt))) + .limit(1) + + const isIndexable = Boolean(deployment?.isActive && deployment.authType === 'public') + + return { + title: 'Chat', + ...(!isIndexable && { robots: { index: false, follow: false } }), + } } +export const dynamic = 'force-dynamic' + export default async function ChatPage({ params, searchParams, diff --git a/apps/sim/app/(landing)/blog/page.tsx b/apps/sim/app/(landing)/blog/page.tsx index b81d2233d29..36bd57d6f7d 100644 --- a/apps/sim/app/(landing)/blog/page.tsx +++ b/apps/sim/app/(landing)/blog/page.tsx @@ -8,6 +8,13 @@ import { SITE_URL } from '@/lib/core/utils/urls' import { Cta } from '@/app/(landing)/components' import { JsonLd } from '@/app/(landing)/components/json-ld' +/** + * Filtered/paginated variants render genuinely different lists, but only the + * bare index is indexable — same policy as the integrations and models + * catalogs — so canonical always points at the unfiltered page and the + * variant itself is noindexed rather than asking Google to index every + * tag/page permutation. + */ export async function generateMetadata({ searchParams, }: { @@ -25,11 +32,8 @@ export async function generateMetadata({ ? `Sim blog posts tagged "${tag}": insights and guides for building AI agents.` : 'Announcements, insights, and guides from Sim, the open-source AI workspace, for building, deploying, and managing AI agents.' - const canonicalParams = new URLSearchParams() - if (tag) canonicalParams.set('tag', tag) - if (pageNum > 1) canonicalParams.set('page', String(pageNum)) - const qs = canonicalParams.toString() - const canonical = `${SITE_URL}/blog${qs ? `?${qs}` : ''}` + const canonical = `${SITE_URL}/blog` + const isFiltered = Boolean(tag) || pageNum > 1 return { title, @@ -57,11 +61,10 @@ export async function generateMetadata({ description, site: '@simdotai', }, + ...(isFiltered && { robots: { index: false, follow: true } }), } } -export const revalidate = 3600 - export default async function BlogIndex({ searchParams, }: { diff --git a/apps/sim/app/(landing)/careers/page.tsx b/apps/sim/app/(landing)/careers/page.tsx index b9a3e5ad55f..9a17cc0867b 100644 --- a/apps/sim/app/(landing)/careers/page.tsx +++ b/apps/sim/app/(landing)/careers/page.tsx @@ -1,16 +1,33 @@ +import type { Metadata } from 'next' import type { SearchParams } from 'nuqs/server' import { buildLandingMetadata } from '@/lib/landing/seo' import Careers from '@/app/(landing)/careers/careers' +import { ALL_FILTER_VALUE, careersSearchParamsCache } from '@/app/(landing)/careers/search-params' -export const revalidate = 3600 +/** + * `team`/`location` render a genuinely different server-rendered job list (see + * search-params.ts), so filtered URLs are noindexed rather than + * self-canonicalized — same policy as the integrations/models/blog catalogs. + */ +export async function generateMetadata({ + searchParams, +}: { + searchParams: Promise +}): Promise { + const { team, location } = await careersSearchParamsCache.parse(searchParams) + const isFiltered = team !== ALL_FILTER_VALUE || location !== ALL_FILTER_VALUE -export const metadata = buildLandingMetadata({ - title: 'Careers at Sim — Build the AI workspace for teams', - description: - 'Join Sim, the open-source AI workspace where teams build, deploy, and manage AI agents. See open engineering, design, and go-to-market roles.', - path: '/careers', - keywords: 'Sim careers, Sim jobs, AI workspace jobs, AI agent engineering jobs, open source jobs', -}) + const base = buildLandingMetadata({ + title: 'Careers | Sim, the AI Workspace', + description: + 'Join Sim, the open-source AI workspace where teams build, deploy, and manage AI agents. See open engineering, design, and go-to-market roles.', + path: '/careers', + keywords: + 'Sim careers, Sim jobs, AI workspace jobs, AI agent engineering jobs, open source jobs', + }) + + return { ...base, ...(isFiltered && { robots: { index: false, follow: true } }) } +} export default function Page({ searchParams }: { searchParams: Promise }) { return diff --git a/apps/sim/app/(landing)/comparison/[provider]/page.tsx b/apps/sim/app/(landing)/comparison/[provider]/page.tsx index 03ffb880ee8..cafff5b7347 100644 --- a/apps/sim/app/(landing)/comparison/[provider]/page.tsx +++ b/apps/sim/app/(landing)/comparison/[provider]/page.tsx @@ -55,7 +55,7 @@ export async function generateMetadata({ } return buildLandingMetadata({ - title: `Sim vs ${competitor.name}: AI Workspace Comparison`, + title: `Sim vs ${competitor.name} | Sim, the AI Workspace`, description: `Compare Sim, the open-source AI workspace, to ${competitor.name} on platform, AI, integrations, pricing, security, and support. Sourced and dated facts.`, path: `/comparison/${competitor.id}`, keywords: [ diff --git a/apps/sim/app/(landing)/comparison/page.tsx b/apps/sim/app/(landing)/comparison/page.tsx index f49fac66949..973911e78e4 100644 --- a/apps/sim/app/(landing)/comparison/page.tsx +++ b/apps/sim/app/(landing)/comparison/page.tsx @@ -44,7 +44,7 @@ const faqItems = [ ] export const metadata: Metadata = buildLandingMetadata({ - title: 'Sim Comparisons: AI Agent & Workflow Platforms', + title: 'Sim Comparisons | Sim, the AI Workspace', description: 'Compare Sim, the open-source AI workspace, to n8n, Zapier, Make, and other workflow automation and AI agent platforms. Sourced, dated, fact-checked.', path: '/comparison', diff --git a/apps/sim/app/(landing)/components/footer/footer.tsx b/apps/sim/app/(landing)/components/footer/footer.tsx index d6f43354103..6e51d218771 100644 --- a/apps/sim/app/(landing)/components/footer/footer.tsx +++ b/apps/sim/app/(landing)/components/footer/footer.tsx @@ -41,7 +41,6 @@ const RESOURCES_LINKS: FooterItem[] = [ { label: 'Blog', href: '/blog' }, { label: 'Docs', href: 'https://docs.sim.ai', external: true }, { label: 'Compare', href: '/comparison' }, - { label: 'Partners', href: '/partners' }, { label: 'Careers', href: '/careers' }, { label: 'Changelog', href: '/changelog' }, { label: 'Contact', href: '/contact' }, diff --git a/apps/sim/app/(landing)/components/logo-shell/logo-shell.tsx b/apps/sim/app/(landing)/components/logo-shell/logo-shell.tsx index 8a132c9d50a..2db9733d619 100644 --- a/apps/sim/app/(landing)/components/logo-shell/logo-shell.tsx +++ b/apps/sim/app/(landing)/components/logo-shell/logo-shell.tsx @@ -7,13 +7,13 @@ import { LogoMark, SimWordmark } from '@/app/(landing)/components/navbar/compone * The canonical light, logo-only page frame - a Sim wordmark linking home, no * marketing menus, on the platform's light tokens (the `light` class pins * light mode regardless of visitor theme). It is the shared base for every - * surface that wants minimal chrome: the global 404, the academy catalog, and - * the `(interfaces)` group (which adds a support footer). The `(auth)` group - * uses its own `AuthShell` with the same look. + * surface that wants minimal chrome: the global 404, and the `(interfaces)` + * group (which adds a support footer). The `(auth)` group uses its own + * `AuthShell` with the same look. * * Children decide their own layout: pass `center` for a single centered column - * (404 message, simple gates); omit it for full-width content (catalogs, the - * live chat overlay, which covers this frame entirely). An optional `footer` + * (404 message, simple gates); omit it for full-width content (the live chat + * overlay, which covers this frame entirely). An optional `footer` * slot renders pinned at the bottom. */ interface LogoShellProps { diff --git a/apps/sim/app/(landing)/integrations/(shell)/page.tsx b/apps/sim/app/(landing)/integrations/(shell)/page.tsx index d8b787f174e..a78e903f56f 100644 --- a/apps/sim/app/(landing)/integrations/(shell)/page.tsx +++ b/apps/sim/app/(landing)/integrations/(shell)/page.tsx @@ -78,30 +78,46 @@ const featured = FEATURED_SLUGS.map((s) => bySlug.get(s)).filter( (i): i is Integration => i !== undefined ) -export const metadata: Metadata = { - title: 'Integrations', - description: `Connect ${INTEGRATION_COUNT}+ apps and services in Sim's AI workspace. Build agents that automate real work with ${TOP_NAMES.join(', ')}, and more.`, - keywords: [ - 'AI workspace integrations', - 'AI agent integrations', - 'AI agent builder integrations', - ...TOP_NAMES.flatMap((n) => [`${n} integration`, `${n} automation`]), - ...allIntegrations.slice(0, 20).map((i) => `${i.name} automation`), - ], - // og:image/twitter:image come from the sibling opengraph-image.tsx - - // Next serves it at a hash-suffixed URL, so hardcoding it here 404s. - openGraph: { - title: 'Integrations | Sim AI Workspace', - description: `Connect ${INTEGRATION_COUNT}+ apps in Sim's AI workspace. Build agents that link ${TOP_NAMES.join(', ')}, and every tool your team uses.`, - url: `${baseUrl}/integrations`, - type: 'website', - }, - twitter: { - card: 'summary_large_image', - title: 'Integrations | Sim', - description: `Connect ${INTEGRATION_COUNT}+ apps in Sim's AI workspace.`, - }, - alternates: { canonical: `${baseUrl}/integrations` }, +/** + * `q`/`category` render a genuinely different server-rendered list (see + * search-params.ts), so filtered URLs are noindexed rather than + * self-canonicalized — keeps the single indexable URL as the bare catalog + * page instead of asking Google to index every filter permutation. + */ +export async function generateMetadata({ + searchParams, +}: { + searchParams: Promise +}): Promise { + const { q, category } = await integrationsSearchParamsCache.parse(searchParams) + const isFiltered = Boolean(q || category) + + return { + title: 'Integrations', + description: `Connect ${INTEGRATION_COUNT}+ apps and services in Sim's AI workspace. Build agents that automate real work with ${TOP_NAMES.join(', ')}, and more.`, + keywords: [ + 'AI workspace integrations', + 'AI agent integrations', + 'AI agent builder integrations', + ...TOP_NAMES.flatMap((n) => [`${n} integration`, `${n} automation`]), + ...allIntegrations.slice(0, 20).map((i) => `${i.name} automation`), + ], + // og:image/twitter:image come from the sibling opengraph-image.tsx - + // Next serves it at a hash-suffixed URL, so hardcoding it here 404s. + openGraph: { + title: 'Integrations | Sim AI Workspace', + description: `Connect ${INTEGRATION_COUNT}+ apps in Sim's AI workspace. Build agents that link ${TOP_NAMES.join(', ')}, and every tool your team uses.`, + url: `${baseUrl}/integrations`, + type: 'website', + }, + twitter: { + card: 'summary_large_image', + title: 'Integrations | Sim', + description: `Connect ${INTEGRATION_COUNT}+ apps in Sim's AI workspace.`, + }, + alternates: { canonical: `${baseUrl}/integrations` }, + ...(isFiltered && { robots: { index: false, follow: true } }), + } } export default async function IntegrationsPage({ diff --git a/apps/sim/app/(landing)/models/(shell)/page.tsx b/apps/sim/app/(landing)/models/(shell)/page.tsx index a933ff3f5f9..624732c9f76 100644 --- a/apps/sim/app/(landing)/models/(shell)/page.tsx +++ b/apps/sim/app/(landing)/models/(shell)/page.tsx @@ -60,38 +60,54 @@ const faqItems = [ }, ] -export const metadata: Metadata = { - title: 'AI Models Directory', - description: `Compare ${TOTAL_MODELS}+ AI models across ${TOTAL_MODEL_PROVIDERS} providers in Sim's AI workspace. Compare pricing, context windows, and capabilities for your agents.`, - keywords: [ - 'AI models directory', - 'AI model comparison', - 'LLM model list', - 'model pricing', - 'context window comparison', - 'OpenAI models', - 'Anthropic models', - 'Google Gemini models', - 'xAI Grok models', - 'Mistral models', - ...TOP_MODEL_PROVIDERS.map((provider) => `${provider} models`), - ], - // og:image/twitter:image come from the sibling opengraph-image.tsx - - // Next serves it at a hash-suffixed URL, so hardcoding it here 404s. - openGraph: { - title: 'AI Models Directory | Sim', - description: `Explore ${TOTAL_MODELS}+ AI models across ${TOTAL_MODEL_PROVIDERS} providers with pricing, context windows, and capability details.`, - url: `${baseUrl}/models`, - type: 'website', - }, - twitter: { - card: 'summary_large_image', - title: 'AI Models Directory | Sim', - description: `Search ${TOTAL_MODELS}+ AI models across ${TOTAL_MODEL_PROVIDERS} providers.`, - }, - alternates: { - canonical: `${baseUrl}/models`, - }, +/** + * `q`/`provider` render a genuinely different server-rendered list (see + * search-params.ts), so filtered URLs are noindexed rather than + * self-canonicalized — keeps the single indexable URL as the bare directory + * page instead of asking Google to index every filter permutation. + */ +export async function generateMetadata({ + searchParams, +}: { + searchParams: Promise +}): Promise { + const { q, provider } = await modelsSearchParamsCache.parse(searchParams) + const isFiltered = Boolean(q || provider) + + return { + title: 'AI Models Directory', + description: `Compare ${TOTAL_MODELS}+ AI models across ${TOTAL_MODEL_PROVIDERS} providers in Sim's AI workspace. Compare pricing, context windows, and capabilities for your agents.`, + keywords: [ + 'AI models directory', + 'AI model comparison', + 'LLM model list', + 'model pricing', + 'context window comparison', + 'OpenAI models', + 'Anthropic models', + 'Google Gemini models', + 'xAI Grok models', + 'Mistral models', + ...TOP_MODEL_PROVIDERS.map((provider) => `${provider} models`), + ], + // og:image/twitter:image come from the sibling opengraph-image.tsx - + // Next serves it at a hash-suffixed URL, so hardcoding it here 404s. + openGraph: { + title: 'AI Models Directory | Sim', + description: `Explore ${TOTAL_MODELS}+ AI models across ${TOTAL_MODEL_PROVIDERS} providers with pricing, context windows, and capability details.`, + url: `${baseUrl}/models`, + type: 'website', + }, + twitter: { + card: 'summary_large_image', + title: 'AI Models Directory | Sim', + description: `Search ${TOTAL_MODELS}+ AI models across ${TOTAL_MODEL_PROVIDERS} providers.`, + }, + alternates: { + canonical: `${baseUrl}/models`, + }, + ...(isFiltered && { robots: { index: false, follow: true } }), + } } export default async function ModelsPage({ diff --git a/apps/sim/app/(landing)/partners/page.tsx b/apps/sim/app/(landing)/partners/page.tsx deleted file mode 100644 index e87dc927489..00000000000 --- a/apps/sim/app/(landing)/partners/page.tsx +++ /dev/null @@ -1,333 +0,0 @@ -import { ChipLink } from '@sim/emcn' -import { SITE_URL } from '@/lib/core/utils/urls' -import { buildLandingMetadata } from '@/lib/landing/seo' -import { JsonLd } from '@/app/(landing)/components/json-ld' - -const PAGE_URL = `${SITE_URL}/partners` -const TITLE = 'Partner Program | Sim' -const DESCRIPTION = - "Join the Sim partner program. Build, deploy, and sell AI agent solutions powered by Sim's AI workspace, and earn your certification through Sim Academy." - -export const metadata = buildLandingMetadata({ - title: TITLE, - description: DESCRIPTION, - path: '/partners', - keywords: - 'Sim partner program, AI agent partners, AI workspace partners, Sim Academy certification, AI agent reseller, co-marketing', - twitterImageAlt: 'Sim', -}) - -const partnersJsonLd = { - '@context': 'https://schema.org', - '@type': 'WebPage', - name: TITLE, - description: DESCRIPTION, - url: PAGE_URL, - isPartOf: { '@type': 'WebSite', name: 'Sim', url: SITE_URL }, -} - -const breadcrumbJsonLd = { - '@context': 'https://schema.org', - '@type': 'BreadcrumbList', - itemListElement: [ - { '@type': 'ListItem', position: 1, name: 'Home', item: SITE_URL }, - { '@type': 'ListItem', position: 2, name: 'Partners', item: PAGE_URL }, - ], -} - -const PARTNER_TIERS = [ - { - name: 'Certified Partner', - badge: 'Entry', - requirements: ['Complete Sim Academy certification', 'Deploy at least 1 live agent'], - perks: [ - 'Official partner badge', - 'Listed in partner directory', - 'Early access to new features', - ], - }, - { - name: 'Silver Partner', - badge: 'Growth', - requirements: [ - 'All Certified requirements', - '3+ active client deployments', - 'Sim Academy advanced certification', - ], - perks: [ - 'All Certified perks', - 'Dedicated partner Slack channel', - 'Co-marketing opportunities', - 'Priority support', - ], - }, - { - name: 'Gold Partner', - badge: 'Premier', - requirements: [ - 'All Silver requirements', - '10+ active client deployments', - 'Sim solutions architect certification', - ], - perks: [ - 'All Silver perks', - 'Revenue share program', - 'Joint case studies', - 'Dedicated partner success manager', - 'Influence product roadmap', - ], - }, -] - -const HOW_IT_WORKS = [ - { - step: '01', - title: 'Sign up & complete Sim Academy', - description: - 'Create an account and work through the Sim Academy certification program. Learn to build, integrate, and deploy AI agents through hands-on exercises.', - }, - { - step: '02', - title: 'Build & deploy real solutions', - description: - 'Put your skills to work. Build AI agents for clients, integrate Sim into existing products, or create your own Sim-powered applications.', - }, - { - step: '03', - title: 'Get certified & grow', - description: - 'Earn your partner certification and unlock perks, co-marketing opportunities, and revenue share as you scale your practice.', - }, -] - -const BENEFITS = [ - { - icon: '🎓', - title: 'Interactive Learning', - description: - 'Learn on the real Sim canvas with drag-and-drop exercises, instant feedback, and guided exercises, not just videos.', - }, - { - icon: '🤝', - title: 'Co-Marketing', - description: - 'Get listed in the Sim partner directory, featured in case studies, and promoted to the Sim user base.', - }, - { - icon: '💰', - title: 'Revenue Share', - description: 'Gold partners earn revenue share on referred customers and managed deployments.', - }, - { - icon: '🚀', - title: 'Early Access', - description: - 'Partners get early access to new Sim features, APIs, and integrations before they launch publicly.', - }, - { - icon: '🛠️', - title: 'Technical Support', - description: - 'Priority technical support, private Slack access, and a dedicated partner success manager for Gold partners.', - }, - { - icon: '📣', - title: 'Community', - description: - 'Join a growing community of Sim builders. Share agents, collaborate on solutions, and shape the product roadmap.', - }, -] - -export default function PartnersPage() { - return ( -
- - - - {/* Hero */} -
-
-

- Sim is the open-source AI workspace where teams build, deploy, and manage AI agents. The - Sim partner program lets agencies and builders get certified through Sim Academy, deploy - real AI agent solutions for clients, and earn co-marketing, revenue share, and priority - support as they grow. -

-
- Partner Program -
-

- Build the future
- of AI agents -

-

- Become a certified Sim partner. Complete Sim Academy, deploy real solutions, and earn - recognition in the growing ecosystem of AI agent builders. -

- -
-
- - {/* Benefits grid */} -
-
-

- Why partner with Sim -

-
- {BENEFITS.map((b) => ( -
-
{b.icon}
-

{b.title}

-

- {b.description} -

-
- ))} -
-
-
- - {/* How it works */} -
-
-

- How it works -

-
- {HOW_IT_WORKS.map((step) => ( -
-
- {step.step} -
-
-

{step.title}

-

- {step.description} -

-
-
- ))} -
-
-
- - {/* Partner tiers */} -
-
-

- Partner tiers -

-
- {PARTNER_TIERS.map((tier) => ( -
-
-

{tier.name}

- - {tier.badge} - -
- -
-

- Requirements -

-
    - {tier.requirements.map((r) => ( -
  • - - {r} -
  • - ))} -
-
- -
-

- Perks -

-
    - {tier.perks.map((p) => ( -
  • - - {p} -
  • - ))} -
-
-
- ))} -
-
-
- - {/* CTA */} -
-
-

- Ready to get started? -

-

- Complete Sim Academy to earn your first certification and unlock partner benefits. It's - free to start, no credit card required. -

-
- - Book a demo - -
-
-
-
- ) -} diff --git a/apps/sim/app/(landing)/pricing/page.tsx b/apps/sim/app/(landing)/pricing/page.tsx index 97ada5ec6b2..7d8a03e2f81 100644 --- a/apps/sim/app/(landing)/pricing/page.tsx +++ b/apps/sim/app/(landing)/pricing/page.tsx @@ -1,21 +1,36 @@ +import type { Metadata } from 'next' import type { SearchParams } from 'nuqs/server' import { buildLandingMetadata } from '@/lib/landing/seo' import Pricing from '@/app/(landing)/pricing/pricing' import { pricingSearchParamsCache } from '@/app/(landing)/pricing/search-params' -export const revalidate = 3600 - const TITLE = 'Pricing | Sim, the AI Workspace' const DESCRIPTION = 'Pricing for Sim, the open-source AI workspace for building, deploying, and managing AI agents. Compare the Free, Pro, Max, and Enterprise plans. Start free.' -export const metadata = buildLandingMetadata({ - title: TITLE, - description: DESCRIPTION, - path: '/pricing', - keywords: - 'Sim pricing, AI workspace pricing, AI agent platform pricing, build AI agents, Pro plan, Max plan, Enterprise plan, open-source AI agents, LLM pricing', -}) +/** + * `billing` renders genuinely different prices server-side (see + * search-params.ts), so the non-default variant is noindexed rather than + * self-canonicalized — same policy as the integrations/models/blog catalogs. + */ +export async function generateMetadata({ + searchParams, +}: { + searchParams: Promise +}): Promise { + const { billing } = await pricingSearchParamsCache.parse(searchParams) + const isFiltered = billing !== 'monthly' + + const base = buildLandingMetadata({ + title: TITLE, + description: DESCRIPTION, + path: '/pricing', + keywords: + 'Sim pricing, AI workspace pricing, AI agent platform pricing, build AI agents, Pro plan, Max plan, Enterprise plan, open-source AI agents, LLM pricing', + }) + + return { ...base, ...(isFiltered && { robots: { index: false, follow: true } }) } +} export default async function Page({ searchParams }: { searchParams: Promise }) { await pricingSearchParamsCache.parse(searchParams) diff --git a/apps/sim/app/_shell/providers/theme-provider.tsx b/apps/sim/app/_shell/providers/theme-provider.tsx index 83a6d802e31..50d9ef6c484 100644 --- a/apps/sim/app/_shell/providers/theme-provider.tsx +++ b/apps/sim/app/_shell/providers/theme-provider.tsx @@ -26,9 +26,7 @@ export function ThemeProvider({ children, ...props }: ThemeProviderProps) { pathname.startsWith('/f/') || pathname.startsWith('/unsubscribe') - const isDarkModePage = pathname.startsWith('/academy') - - const forcedTheme = isLightModePage ? 'light' : isDarkModePage ? 'dark' : undefined + const forcedTheme = isLightModePage ? 'light' : undefined return ( getCompletedLessonsFromSnapshot(completedIdsSnapshot), - [completedIdsSnapshot] - ) - const { data: session } = useSession() - const { data: fetchedCert } = useCourseCertificate(session ? course.id : undefined) - const { mutate: issueCertificate, isPending, data: issuedCert, error } = useIssueCertificate() - const certificate = fetchedCert ?? issuedCert - - const allLessons = course.modules.flatMap((m) => m.lessons) - const totalLessons = allLessons.length - const completedCount = allLessons.filter((l) => completedIds.has(l.id)).length - const percentComplete = totalLessons > 0 ? Math.round((completedCount / totalLessons) * 100) : 0 - - return ( - <> - {completedCount > 0 && ( -
-
-
- Your progress - - {completedCount}/{totalLessons} lessons - -
-
-
-
-
-
- )} - -
-
- {course.modules.map((mod, modIndex) => ( -
-
- Module {modIndex + 1} -
-
-

{mod.title}

-
- {mod.lessons.map((lesson) => ( - - {completedIds.has(lesson.id) ? ( - - ) : ( - - )} - {lesson.title} - {lesson.lessonType} - {lesson.videoDurationSeconds && ( - - {Math.round(lesson.videoDurationSeconds / 60)} min - - )} - - ))} -
-
- ))} -
-
- - {totalLessons > 0 && completedCount === totalLessons && ( -
-
- {certificate ? ( -
-
- -
-

Certificate issued!

-

- {certificate.certificateNumber} -

-
-
- - View certificate - - -
- ) : ( -
-
- -
-

Course Complete!

-

- {session - ? error - ? 'Something went wrong. Try again.' - : 'Claim your certificate of completion.' - : 'Sign in to claim your certificate.'} -

-
-
- {session ? ( - - ) : ( - - Sign in - - )} -
- )} -
-
- )} - - ) -} diff --git a/apps/sim/app/academy/(catalog)/[courseSlug]/page.tsx b/apps/sim/app/academy/(catalog)/[courseSlug]/page.tsx deleted file mode 100644 index 4f0d96a1feb..00000000000 --- a/apps/sim/app/academy/(catalog)/[courseSlug]/page.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { Clock, GraduationCap } from 'lucide-react' -import type { Metadata } from 'next' -import Link from 'next/link' -import { notFound } from 'next/navigation' -import { COURSES, getCourse } from '@/lib/academy/content' -import { CourseProgress } from './components/course-progress' - -interface CourseDetailPageProps { - params: Promise<{ courseSlug: string }> -} - -export function generateStaticParams() { - return COURSES.map((course) => ({ courseSlug: course.slug })) -} - -export async function generateMetadata({ params }: CourseDetailPageProps): Promise { - const { courseSlug } = await params - const course = getCourse(courseSlug) - if (!course) return { title: 'Course Not Found' } - return { - title: course.title, - description: course.description, - } -} - -export default async function CourseDetailPage({ params }: CourseDetailPageProps) { - const { courseSlug } = await params - const course = getCourse(courseSlug) - - if (!course) notFound() - - return ( -
-
-
- - ← All courses - -

- {course.title} -

- {course.description && ( -

- {course.description} -

- )} -
- {course.estimatedMinutes && ( - - - {course.estimatedMinutes} min total - - )} - - - Certificate upon completion - -
-
-
- - -
- ) -} diff --git a/apps/sim/app/academy/(catalog)/certificate/[certificateNumber]/page.tsx b/apps/sim/app/academy/(catalog)/certificate/[certificateNumber]/page.tsx deleted file mode 100644 index f7f4a36f479..00000000000 --- a/apps/sim/app/academy/(catalog)/certificate/[certificateNumber]/page.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import { cache } from 'react' -import { db } from '@sim/db' -import { academyCertificate } from '@sim/db/schema' -import { eq } from 'drizzle-orm' -import { CheckCircle2, GraduationCap, XCircle } from 'lucide-react' -import type { Metadata } from 'next' -import { notFound } from 'next/navigation' -import type { AcademyCertificate } from '@/lib/academy/types' -import { academyCertificateMetadataSchema } from '@/lib/api/contracts/academy' - -interface CertificatePageProps { - params: Promise<{ certificateNumber: string }> -} - -export async function generateMetadata({ params }: CertificatePageProps): Promise { - const { certificateNumber } = await params - const certificate = await fetchCertificate(certificateNumber) - if (!certificate) return { title: 'Certificate Not Found' } - return { - title: `${certificate.metadata?.courseTitle ?? 'Certificate'} — Certificate`, - description: `Verified certificate of completion awarded to ${certificate.metadata?.recipientName ?? 'a recipient'}.`, - } -} - -const fetchCertificate = cache( - async (certificateNumber: string): Promise => { - const [row] = await db - .select() - .from(academyCertificate) - .where(eq(academyCertificate.certificateNumber, certificateNumber)) - .limit(1) - if (!row) return null - return { - ...row, - metadata: academyCertificateMetadataSchema.nullable().parse(row.metadata), - } - } -) - -const DATE_FORMAT: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'long', day: 'numeric' } -function formatDate(date: string | Date) { - return new Date(date).toLocaleDateString('en-US', DATE_FORMAT) -} - -function MetaRow({ label, children }: { label: string; children: React.ReactNode }) { - return ( -
- {label} - {children} -
- ) -} - -export default async function CertificatePage({ params }: CertificatePageProps) { - const { certificateNumber } = await params - const certificate = await fetchCertificate(certificateNumber) - - if (!certificate) notFound() - - return ( -
-
-
-
-
- -
-
- -
- Certificate of Completion -
- -

- {certificate.metadata?.courseTitle} -

- - {certificate.metadata?.recipientName && ( -

- Awarded to{' '} - {certificate.metadata.recipientName} -

- )} - - {certificate.status === 'active' ? ( -
- - Verified -
- ) : ( -
- - {certificate.status} -
- )} -
- -
- - - {certificate.certificateNumber} - - - - {formatDate(certificate.issuedAt)} - - - - {certificate.status} - - - {certificate.expiresAt && ( - - - {formatDate(certificate.expiresAt)} - - - )} -
- -

- This certificate was issued by Sim AI, Inc. and verifies the holder has completed the{' '} - {certificate.metadata?.courseTitle} program. -

-
-
- ) -} diff --git a/apps/sim/app/academy/(catalog)/layout.tsx b/apps/sim/app/academy/(catalog)/layout.tsx deleted file mode 100644 index 3c8635906d1..00000000000 --- a/apps/sim/app/academy/(catalog)/layout.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import type React from 'react' -import { LogoShell } from '@/app/(landing)/components' - -/** - * Academy catalog chrome — the shared light, logo-only {@link LogoShell} around - * every academy catalog page. No marketing menus; full-width content. - */ -export default function AcademyCatalogLayout({ children }: { children: React.ReactNode }) { - return {children} -} diff --git a/apps/sim/app/academy/(catalog)/not-found.tsx b/apps/sim/app/academy/(catalog)/not-found.tsx deleted file mode 100644 index b2c0c764f81..00000000000 --- a/apps/sim/app/academy/(catalog)/not-found.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import Link from 'next/link' - -export default function AcademyNotFound() { - return ( -
-

404

-

Page not found

-

- That course or lesson doesn't exist in the Academy. -

- - Back to Academy - -
- ) -} diff --git a/apps/sim/app/academy/(catalog)/page.tsx b/apps/sim/app/academy/(catalog)/page.tsx deleted file mode 100644 index b770d88c047..00000000000 --- a/apps/sim/app/academy/(catalog)/page.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { BookOpen, Clock } from 'lucide-react' -import Link from 'next/link' -import { COURSES } from '@/lib/academy/content' - -export default function AcademyCatalogPage() { - return ( -
-
-
-
- Sim Academy -
-

- Become a certified -
- Sim partner -

-

- Master AI workflow automation with hands-on interactive exercises on the real Sim - canvas. Complete the program to earn your partner certification. -

-
-
- -
-
-

Courses

-
- {COURSES.map((course) => { - const totalLessons = course.modules.reduce((n, m) => n + m.lessons.length, 0) - return ( - - {course.imageUrl && ( -
- {course.title} -
- )} -
-

- {course.title} -

- {course.description && ( -

- {course.description} -

- )} -
-
- {course.estimatedMinutes && ( - - - {course.estimatedMinutes} min - - )} - - - {totalLessons} lessons - -
- - ) - })} -
-
-
-
- ) -} diff --git a/apps/sim/app/academy/[courseSlug]/[lessonSlug]/components/exercise-view.tsx b/apps/sim/app/academy/[courseSlug]/[lessonSlug]/components/exercise-view.tsx deleted file mode 100644 index 8978affbc87..00000000000 --- a/apps/sim/app/academy/[courseSlug]/[lessonSlug]/components/exercise-view.tsx +++ /dev/null @@ -1,66 +0,0 @@ -'use client' - -import { useCallback, useState } from 'react' -import { CheckCircle2 } from 'lucide-react' -import { markLessonComplete } from '@/lib/academy/local-progress' -import type { ExerciseBlockState, ExerciseDefinition, ExerciseEdgeState } from '@/lib/academy/types' -import { SandboxCanvasProvider } from '@/app/academy/components/sandbox-canvas-provider' - -interface ExerciseViewProps { - lessonId: string - exerciseConfig: ExerciseDefinition - onComplete?: () => void - videoUrl?: string - description?: string -} - -/** - * Orchestrates the sandbox canvas for an exercise lesson. - * Completion is determined client-side by the validation engine and persisted to localStorage. - */ -export function ExerciseView({ - lessonId, - exerciseConfig, - onComplete, - videoUrl, - description, -}: ExerciseViewProps) { - const [completed, setCompleted] = useState(false) - // Reset completion banner when the lesson changes (component is reused across exercise navigations). - const [prevLessonId, setPrevLessonId] = useState(lessonId) - if (prevLessonId !== lessonId) { - setPrevLessonId(lessonId) - setCompleted(false) - } - - const handleComplete = useCallback( - (_blocks: ExerciseBlockState[], _edges: ExerciseEdgeState[]) => { - setCompleted(true) - markLessonComplete(lessonId) - onComplete?.() - }, - [lessonId, onComplete] - ) - - return ( -
- - - {completed && ( -
-
- - Exercise complete! -
-
- )} -
- ) -} diff --git a/apps/sim/app/academy/[courseSlug]/[lessonSlug]/components/lesson-quiz.tsx b/apps/sim/app/academy/[courseSlug]/[lessonSlug]/components/lesson-quiz.tsx deleted file mode 100644 index c8c7fd6316e..00000000000 --- a/apps/sim/app/academy/[courseSlug]/[lessonSlug]/components/lesson-quiz.tsx +++ /dev/null @@ -1,256 +0,0 @@ -'use client' - -import { useState } from 'react' -import { cn } from '@sim/emcn' -import { CheckCircle2, XCircle } from 'lucide-react' -import { markLessonComplete } from '@/lib/academy/local-progress' -import type { QuizDefinition, QuizQuestion } from '@/lib/academy/types' - -interface LessonQuizProps { - lessonId: string - quizConfig: QuizDefinition - onPass?: () => void -} - -type Answers = Record - -interface QuizResult { - score: number - passed: boolean - feedback: Array<{ correct: boolean; explanation?: string }> -} - -function scoreQuiz(questions: QuizQuestion[], answers: Answers, passingScore: number): QuizResult { - const feedback = questions.map((q, i) => { - const answer = answers[i] - let correct = false - if (q.type === 'multiple_choice') correct = answer === q.correctIndex - else if (q.type === 'true_false') correct = answer === q.correctAnswer - else if (q.type === 'multi_select') { - const selected = (answer as number[] | undefined) ?? [] - correct = - selected.length === q.correctIndices.length && - selected.every((v) => q.correctIndices.includes(v)) - } else { - const _exhaustive: never = q - void _exhaustive - } - return { correct, explanation: 'explanation' in q ? q.explanation : undefined } - }) - const score = Math.round((feedback.filter((f) => f.correct).length / questions.length) * 100) - return { score, passed: score >= passingScore, feedback } -} - -const optionBase = - 'w-full text-left rounded-[6px] border px-4 py-3 text-[14px] transition-colors disabled:cursor-default' - -/** - * Interactive quiz component with per-question feedback and retry support. - * Scoring is performed entirely client-side. - */ -export function LessonQuiz({ lessonId, quizConfig, onPass }: LessonQuizProps) { - const [answers, setAnswers] = useState({}) - const [result, setResult] = useState(null) - // Reset quiz state when the lesson changes (component is reused across quiz-lesson navigations). - const [prevLessonId, setPrevLessonId] = useState(lessonId) - if (prevLessonId !== lessonId) { - setPrevLessonId(lessonId) - setAnswers({}) - setResult(null) - } - - const handleAnswer = (qi: number, value: number | boolean) => { - if (!result) setAnswers((prev) => ({ ...prev, [qi]: value })) - } - - const handleMultiSelect = (qi: number, oi: number) => { - if (result) return - setAnswers((prev) => { - const current = (prev[qi] as number[] | undefined) ?? [] - const next = current.includes(oi) ? current.filter((i) => i !== oi) : [...current, oi] - return { ...prev, [qi]: next } - }) - } - - const allAnswered = quizConfig.questions.every((q, i) => { - if (q.type === 'multi_select') - return Array.isArray(answers[i]) && (answers[i] as number[]).length > 0 - return answers[i] !== undefined - }) - - const handleSubmit = () => { - const scored = scoreQuiz(quizConfig.questions, answers, quizConfig.passingScore) - setResult(scored) - if (scored.passed) { - markLessonComplete(lessonId) - onPass?.() - } - } - - return ( -
-
-

Quiz

-

- Score {quizConfig.passingScore}% or higher to pass. -

-
- - {quizConfig.questions.map((q, qi) => { - const feedback = result?.feedback[qi] - const isCorrect = feedback?.correct - - return ( -
-

{q.question}

- - {q.type === 'multiple_choice' && ( -
- {q.options.map((opt, oi) => ( - - ))} -
- )} - - {q.type === 'true_false' && ( -
- {(['True', 'False'] as const).map((label) => { - const val = label === 'True' - return ( - - ) - })} -
- )} - - {q.type === 'multi_select' && ( -
- {q.options.map((opt, oi) => { - const selected = ((answers[qi] as number[]) ?? []).includes(oi) - return ( - - ) - })} -
- )} - - {feedback && ( -
- {isCorrect ? ( - - ) : ( - - )} - {isCorrect ? 'Correct!' : (feedback.explanation ?? 'Incorrect.')} -
- )} -
- ) - })} - - {result && ( -
-

{result.passed ? 'Passed!' : 'Keep trying!'}

-

- Score: {result.score}% (passing: {quizConfig.passingScore}%) -

- {!result.passed && ( - - )} -
- )} - - {!result && ( - - )} -
- ) -} diff --git a/apps/sim/app/academy/[courseSlug]/[lessonSlug]/layout.tsx b/apps/sim/app/academy/[courseSlug]/[lessonSlug]/layout.tsx deleted file mode 100644 index a99df46a42d..00000000000 --- a/apps/sim/app/academy/[courseSlug]/[lessonSlug]/layout.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import type React from 'react' -import { redirect } from 'next/navigation' -import { getSession } from '@/lib/auth' - -interface LessonLayoutProps { - children: React.ReactNode - params: Promise<{ courseSlug: string; lessonSlug: string }> -} - -/** - * Server-side auth gate for lesson pages. - * Redirects unauthenticated users to login before any client JS runs. - */ -export default async function LessonLayout({ children, params }: LessonLayoutProps) { - const session = await getSession() - - if (!session?.user?.id) { - const { courseSlug, lessonSlug } = await params - redirect(`/login?callbackUrl=/academy/${courseSlug}/${lessonSlug}`) - } - - return <>{children} -} diff --git a/apps/sim/app/academy/[courseSlug]/[lessonSlug]/page.tsx b/apps/sim/app/academy/[courseSlug]/[lessonSlug]/page.tsx deleted file mode 100644 index 798051faa54..00000000000 --- a/apps/sim/app/academy/[courseSlug]/[lessonSlug]/page.tsx +++ /dev/null @@ -1,219 +0,0 @@ -'use client' - -import { use, useCallback, useEffect, useMemo, useState } from 'react' -import { ChevronLeft, ChevronRight } from 'lucide-react' -import Image from 'next/image' -import Link from 'next/link' -import { getCourse } from '@/lib/academy/content' -import { markLessonComplete } from '@/lib/academy/local-progress' -import type { Lesson } from '@/lib/academy/types' -import { LessonVideo } from '@/app/academy/components/lesson-video' -import { ExerciseView } from './components/exercise-view' -import { LessonQuiz } from './components/lesson-quiz' - -const navBtnClass = - 'flex items-center gap-1 rounded-[5px] border border-[#2A2A2A] px-3 py-1.5 text-[#999] text-[12px] transition-colors hover:border-[#3A3A3A] hover:text-[#ECECEC]' - -interface LessonPageProps { - params: Promise<{ courseSlug: string; lessonSlug: string }> -} - -export default function LessonPage({ params }: LessonPageProps) { - const { courseSlug, lessonSlug } = use(params) - const course = getCourse(courseSlug) - const [exerciseComplete, setExerciseComplete] = useState(false) - const [quizComplete, setQuizComplete] = useState(false) - // Reset completion state when the lesson changes (Next.js reuses the component across navigations). - const [prevLessonSlug, setPrevLessonSlug] = useState(lessonSlug) - if (prevLessonSlug !== lessonSlug) { - setPrevLessonSlug(lessonSlug) - setExerciseComplete(false) - setQuizComplete(false) - } - - const allLessons = useMemo( - () => course?.modules.flatMap((m) => m.lessons) ?? [], - [course] - ) - - const currentIndex = allLessons.findIndex((l) => l.slug === lessonSlug) - const lesson = allLessons[currentIndex] - const prevLesson = currentIndex > 0 ? allLessons[currentIndex - 1] : null - const nextLesson = currentIndex < allLessons.length - 1 ? allLessons[currentIndex + 1] : null - - const handleExerciseComplete = useCallback(() => setExerciseComplete(true), []) - const handleQuizPass = useCallback(() => setQuizComplete(true), []) - const canAdvance = - (!lesson?.exerciseConfig && !lesson?.quizConfig) || - (Boolean(lesson?.exerciseConfig) && Boolean(lesson?.quizConfig) - ? exerciseComplete && quizComplete - : lesson?.exerciseConfig - ? exerciseComplete - : quizComplete) - - const isUngatedLesson = - lesson?.lessonType === 'video' || - (lesson?.lessonType === 'mixed' && !lesson.exerciseConfig && !lesson.quizConfig) - - useEffect(() => { - if (isUngatedLesson && lesson) { - markLessonComplete(lesson.id) - } - }, [lesson?.id, isUngatedLesson]) - - if (!course || !lesson) { - return ( -
-

Lesson not found.

-
- ) - } - - const hasVideo = Boolean(lesson.videoUrl) - const hasExercise = Boolean(lesson.exerciseConfig) - const hasQuiz = Boolean(lesson.quizConfig) - - return ( -
-
-
- - Sim - - / - - Academy - - / - - {course.title} - - / - {lesson.title} -
- -
- {prevLesson ? ( - - - Previous - - ) : ( - - - Course - - )} - {nextLesson && ( - { - if (!canAdvance) e.preventDefault() - }} - className={`flex items-center gap-1 rounded-[5px] px-3 py-1.5 text-[12px] transition-colors ${ - canAdvance - ? 'bg-[#ECECEC] text-[#1C1C1C] hover:bg-white' - : 'cursor-not-allowed border border-[#2A2A2A] text-[#444]' - }`} - > - Next - - - )} -
-
- -
- {lesson.lessonType === 'video' && hasVideo && ( -
-
- - {lesson.description && ( -

{lesson.description}

- )} -
-
- )} - - {lesson.lessonType === 'exercise' && hasExercise && ( - - )} - - {lesson.lessonType === 'quiz' && hasQuiz && ( -
-
- -
-
- )} - - {lesson.lessonType === 'mixed' && ( - <> - {hasExercise && (!exerciseComplete || !hasQuiz) && ( - - )} - {hasExercise && exerciseComplete && hasQuiz && ( -
-
- {hasVideo && } - -
-
- )} - {!hasExercise && hasQuiz && ( -
-
- {hasVideo && } - -
-
- )} - {!hasExercise && !hasQuiz && hasVideo && ( -
-
- - {lesson.description && ( -

- {lesson.description} -

- )} -
-
- )} - - )} -
-
- ) -} diff --git a/apps/sim/app/academy/components/lesson-video.tsx b/apps/sim/app/academy/components/lesson-video.tsx deleted file mode 100644 index 1a8c97c0641..00000000000 --- a/apps/sim/app/academy/components/lesson-video.tsx +++ /dev/null @@ -1,56 +0,0 @@ -'use client' - -interface LessonVideoProps { - url: string - title: string -} - -export function LessonVideo({ url, title }: LessonVideoProps) { - const embedUrl = resolveEmbedUrl(url) - - if (!embedUrl) { - return ( -
- Video unavailable -
- ) - } - - return ( -
-