mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
feat(academy): Sim Academy — interactive partner certification platform (#3824)
* fix(import): dedup workflow name (#3813) * feat(concurrency): bullmq based concurrency control system (#3605) * feat(concurrency): bullmq based queueing system * fix bun lock * remove manual execs off queues * address comments * fix legacy team limits * cleanup enterprise typing code * inline child triggers * fix status check * address more comments * optimize reconciler scan * remove dead code * add to landing page * Add load testing framework * update bullmq * fix * fix headless path --------- Co-authored-by: Theodore Li <teddy@zenobiapay.com> * fix(linear): add default null for after cursor (#3814) * fix(knowledge): reject non-alphanumeric file extensions from document names (#3816) * fix(knowledge): reject non-alphanumeric file extensions from document names * fix(knowledge): improve error message when extension is non-alphanumeric * fix(security): SSRF, access control, and info disclosure (#3815) * fix(security): scope copilot feedback GET endpoint to authenticated user Add WHERE clause to filter feedback records by the authenticated user's ID, preventing any authenticated user from reading all users' copilot interactions, queries, and workflow YAML (IDOR / CWE-639). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(smtp): add SSRF validation and genericize network error messages Prevent SSRF via user-controlled smtpHost by validating with validateDatabaseHost before creating the nodemailer transporter. Collapse distinct network error messages (ECONNREFUSED, ECONNRESET, ETIMEDOUT) into a single generic message to prevent port-state leakage. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(security): add SSRF validation to SFTP/SSH and access control to workspace invitations Add `validateDatabaseHost` checks to SFTP and SSH connection utilities to block connections to private/reserved IPs and localhost, matching the existing pattern used by all database tools. Add authorization check to the workspace invitation GET endpoint so only the invitee or a workspace admin can view invitation details. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(smtp): restore SMTP response code handling for post-connection errors SMTP 4xx/5xx response codes are application-level errors (invalid recipient, mailbox full, server error) unrelated to the SSRF hardening goal. Restore response code differentiation and logging to preserve actionable user-facing error messages. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(security): use session email directly instead of extra DB query Addresses PR review feedback — align with the workspace invitation route pattern by using session.user.email instead of re-fetching from the database. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * lint * fix(auth): revert lint autofix that broke hasExternalApiCredentials return type Biome auto-fixed `return auth !== null && auth.startsWith(...)` to `return auth?.startsWith(...)` which returns `boolean | undefined`, not `boolean`, causing a TypeScript build failure. * fix(smtp): pin resolved IP to prevent DNS rebinding (TOCTOU) Use the pre-resolved IP from validateDatabaseHost instead of the original hostname when creating the nodemailer transporter. Set servername to the original hostname to preserve TLS SNI validation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor(security): extract createPinnedLookup helper for DNS rebinding prevention Extract reusable createPinnedLookup from secureFetchWithPinnedIP so non-HTTP transports (SSH, SFTP, IMAP) can pin resolved IPs at the socket level. SMTP route uses host+servername pinning instead since nodemailer doesn't reliably pass lookup to both secure/plaintext paths. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(security): pin IMAP connections to validated resolved IP Pass the resolved IP from validateDatabaseHost to ImapFlow as host, with the original hostname as servername for TLS SNI verification. Closes the DNS TOCTOU rebinding window. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * lint * fix(auth): revert lint autofix on hasExternalApiCredentials return type Also pin SFTP/SSH connections to validated resolved IP to prevent DNS rebinding. * fix(security): short-circuit admin check when caller is invitee Skip the hasWorkspaceAdminAccess DB query when the caller is already the invitee, avoiding an unnecessary round-trip. Aligns with the org invitation route pattern. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> * fix(worker): dockerfile + helm updates (#3818) * fix(worker): dockerfile + helm updates * address comments * update dockerfile (#3819) * fix dockerfile * fix(security): pentest remediation — condition escaping, SSRF hardening, ReDoS protection (#3820) * fix(executor): escape newline characters in condition expression strings Unescaped newline/carriage-return characters in resolved string values cause unterminated string literals in generated JS, crashing condition evaluation with a SyntaxError. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(security): prevent ReDoS in guardrails regex validation Add safe-regex2 to reject catastrophic backtracking patterns before execution and cap input length at 10k characters. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(security): SSRF localhost hardening and regex DoS protection Block localhost/loopback URLs in hosted environments using isHosted flag instead of allowHttp. Add safe-regex2 validation and input length limits to regex guardrails to prevent catastrophic backtracking. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(security): validate regex syntax before safety check Move new RegExp() before safe() so invalid patterns get a proper syntax error instead of a misleading "catastrophic backtracking" message. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(security): address PR review feedback - Hoist isLocalhost && isHosted guard to single early-return before protocol checks, removing redundant duplicate block - Move regex syntax validation (new RegExp) before safe-regex2 check so invalid patterns get proper syntax error instead of misleading "catastrophic backtracking" message * fix(security): remove input length cap from regex validation The 10k character cap would block legitimate guardrail checks on long LLM outputs. Input length doesn't affect ReDoS risk — the safe-regex2 pattern check already prevents catastrophic backtracking. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(tests): mock isHosted in input-validation and function-execute tests Tests that assert self-hosted localhost behavior need isHosted=false, which is not guaranteed in CI where NEXT_PUBLIC_APP_URL is set to the hosted domain. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> * improvement(worker): configuration defaults (#3821) * improvement(worker): configuration defaults * update readmes * realtime curl import * improvement(tour): remove auto-start, only trigger on explicit user action (#3823) * fix(mcp): use correct modal for creating workflow MCP servers in deploy (#3822) * fix(mcp): use correct modal for creating workflow MCP servers in deploy * fix(mcp): show workflows field during loading and when empty * mock course * fix(db): use bigint for token counter columns in user_stats (#3755) * mock course * updates * updated X handle for emir * cleanup: audit and clean academy implementation * fix(academy): add label to ValidationRule, fix quiz gating, simplify getRuleMessage * cleanup: remove unnecessary comments across academy files * refactor(academy): simplify abstractions and fix perf issues * perf(academy): convert course detail page to server component with client island * fix(academy): null-safe canAdvance, render exercise instructions, remove stale comments * fix(academy): remove orphaned migration, fix getCourseById, clean up comments - Delete 0181_academy_certificate.sql (orphaned duplicate not in journal) - Add getCourseById() to content/index.ts; use it in certificates API (was using getCourse which searches by slug, not stable id) - Remove JSX comments from catalog page - Remove redundant `passed` recomputation in LessonQuiz * chore(db): regenerate academy_certificate migration with drizzle-kit * chore: include blog mdx and components changes * fix(blog): correct cn import path * fix(academy): constrain progress bar to max-w-3xl with proper padding * feat(academy): show back-to-course button on first lesson * fix(academy): force dark theme on all /academy routes * content(academy): rewrite sim-foundations course with full 6-module curriculum * fix(academy): correct edge handles, quiz explanation, and starter mock outputs - Fix Exercise 2 initial edge handles: 'starter-1-source'/'agent-1-target' → 'source'/'target' (React Flow actual IDs) - Fix M1-L4 Q4 quiz explanation: remove non-existent Ctrl/Cmd+D and Alt+drag shortcuts - Add starter mock output to all exercises so run animation shows feedback on the first block * refine(academy): fix inaccurate content and improve exercise clarity - Fix Exercise 3: replace hardcoded <agent-1.content> (invalid UUID-based ref) with reference picker instructions - Fix M4 Quiz Q5: Loop block (subflow container) is correct answer, not the Workflow block - Fix M4 Quiz Q4: clarify fan-out vs Parallel block distinction in explanation - Fix M4-L2 video description: accurately describe Loop and Parallel subflow blocks - Fix M2 Quiz Q3: make response format question conceptual rather than syntax-specific - Improve Exercise 4 branching instructions: clarify top=true / bottom=false output handles - Improve Final Project instructions: step-by-step numbered flow * fix(academy): remove double border on quiz question cards * fix(academy): single scroll container on lesson pages — remove nested flex scroll * fix(academy): remove min-h-screen from root layout — fixes double scrollbar on lesson pages * fix(academy): use fixed inset-0 on lesson page to eliminate document-level scrollbar * fix(academy): replace sr-only radio/checkbox inputs with buttons to prevent scroll-on-focus; restore layout min-h-screen * improvement(academy): polish, security hardening, and certificate claim UI - Replace raw localStorage with BrowserStorage utility in local-progress - Pre-compute slug/id Maps in content/index for O(1) course lookups - Move blockMap construction into edge_exists branch only in validation - Extract navBtnClass constant and MetaRow/formatDate helpers in UI - Add rate limiting, server-side completion verification, audit logging, and nanoid cert numbers to certificate issuance endpoint - Add useIssueCertificate mutation hook with completedLessonIds - Wire certificate claim UI into CourseProgress: sign-in prompt, claim button with loading state, and post-issuance view with link to certificate page - Fix lesson page scroll container and quiz scroll-on-focus bug * fix(academy): validate condition branch handles in edge_exists rules - Add sourceHandle field to edge_exists ValidationRule type - Check sourceHandle in validation.ts when specified - Require both condition-if and condition-else branches to be connected in the branching and final project exercises * fix(academy): address PR review — isHosted regression, stuck isExecuting, revoked cert 500, certificate SSR - Restore env-var-based isHosted check (was hardcoded true, breaking self-hosted deployments) - Fix isExecuting stuck at true when mock run fails validation — set isMockRunningRef immediately and reset both flags on early exit - Fix revoked/expired certificate causing 500 — any existing record (not just active) now returns 409 instead of falling through to INSERT - Convert certificate verification page from client component to server component — direct DB fetch, notFound() on missing cert, generateMetadata for SEO/social previews * fix(auth): restore hybrid.ts from staging to fix CI type error * fix(academy): mark video lessons complete on visit and fix sign-in path * fix(academy): replace useEffect+setState with lazy useState initializer in CourseProgress * fix(academy): reset exerciseComplete on lesson navigation, remove unused useAcademyCertificate hook * fix(academy): useState for slug-change reset, cache() for cert page, handleMockRunRef for stale closure * fix(academy): replace shadcn theme vars with explicit hex in LessonVideo fallback * fix(academy): reset completedRef on exercise change, conditional verified badge, multi-select empty guard * fix(academy): type safety fixes — null metadata fallbacks, returning() guard, exhaustive union, empty catch * fix(academy): reset ExerciseView completed banner on nav; fix CourseProgress hydration mismatch * fix(lightbox): guard effect body with isOpen to prevent spurious overflow reset * fix(academy): reset LessonQuiz state on lesson change to prevent stale answers persisting * fix(academy): course not-found metadata title; try-finally guard in mock run loop * fix(academy): type safety, cert persistence, regex guard, mixed-lesson video, shorts support - Derive AcademyCertificate from db $inferSelect to prevent schema drift - Add useCourseCertificate query hook; GET /api/academy/certificates now accepts courseId for authenticated lookup - Use useCourseCertificate in CourseProgress so certificate state survives page refresh - Guard new RegExp(valuePattern) in validation.ts with try/catch; log warn on invalid pattern - Add logger.warn for custom validation rules so content authors are alerted - Add YouTube Shorts URL support to LessonVideo (youtube.com/shorts/VIDEO_ID) - Fix mixed-lesson video gap: render videoUrl above quiz when mixed has quiz but no exercise - Add academy-scoped not-found.tsx with link back to /academy * fix(academy): reset hintIndex when exercise changes * chore: remove ban-spam-accounts script (wrong branch) * fix(academy): enforce availableBlocks in toolbar; fix mixed exercise+quiz rendering - Add useSandboxBlockConstraints context; SandboxCanvasProvider provides exerciseConfig.availableBlocks so the toolbar only shows permitted block types. Empty array hides all blocks (configure-only exercises); non-null array restricts to listed types; triggers always hidden in sandbox. - Fix mixed lesson with both exerciseConfig and quizConfig: exercise renders first, quiz reveals after exercise completes (sequential pedagogy). canAdvance now requires both exerciseComplete && quizComplete when both are present. * chore(academy): remove extraneous inline comments * fix(academy): blank mixed lesson, quiz canAdvance flag, empty-array valueNotEmpty * prep for merge * chore(db): regenerate academy certificate migration after staging merge * fix(academy): disable auto-connect in sandbox mode * fix(academy): render video in mixed lesson with no exercise or quiz * fix(academy): mark mixed video-only lessons complete; handle cert insert race * fix(canvas): add sandbox and embedded to nodes useMemo deps --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Lakee Sivaraya <71339072+lakeesiv@users.noreply.github.com> Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai> Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com> Co-authored-by: Siddharth Ganesan <33737564+Sg312@users.noreply.github.com> Co-authored-by: Theodore Li <teddy@zenobiapay.com>
This commit is contained in:
@@ -18,7 +18,7 @@ export const metadata = {
|
||||
metadataBase: new URL('https://docs.sim.ai'),
|
||||
title: {
|
||||
default: 'Sim Documentation — Build AI Agents & Run Your Agentic Workforce',
|
||||
template: '%s',
|
||||
template: '%s | Sim Docs',
|
||||
},
|
||||
description:
|
||||
'Documentation for Sim — the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows.',
|
||||
|
||||
@@ -26,6 +26,8 @@ const RESOURCES_LINKS: FooterItem[] = [
|
||||
{ label: 'Blog', href: '/blog' },
|
||||
// { label: 'Templates', href: '/templates' },
|
||||
{ label: 'Docs', href: 'https://docs.sim.ai', external: true },
|
||||
{ label: 'Academy', href: '/academy' },
|
||||
{ label: 'Partners', href: '/partners' },
|
||||
{ label: 'Careers', href: 'https://jobs.ashbyhq.com/sim', external: true },
|
||||
{ label: 'Changelog', href: '/changelog' },
|
||||
]
|
||||
|
||||
43
apps/sim/app/(landing)/blog/components/blog-image.tsx
Normal file
43
apps/sim/app/(landing)/blog/components/blog-image.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import NextImage from 'next/image'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { Lightbox } from '@/app/(landing)/blog/components/lightbox'
|
||||
|
||||
interface BlogImageProps {
|
||||
src: string
|
||||
alt?: string
|
||||
width?: number
|
||||
height?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function BlogImage({ src, alt = '', width = 800, height = 450, className }: BlogImageProps) {
|
||||
const [isLightboxOpen, setIsLightboxOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<NextImage
|
||||
src={src}
|
||||
alt={alt}
|
||||
width={width}
|
||||
height={height}
|
||||
className={cn(
|
||||
'h-auto w-full cursor-pointer rounded-lg transition-opacity hover:opacity-95',
|
||||
className
|
||||
)}
|
||||
sizes='(max-width: 768px) 100vw, 800px'
|
||||
loading='lazy'
|
||||
unoptimized
|
||||
onClick={() => setIsLightboxOpen(true)}
|
||||
/>
|
||||
<Lightbox
|
||||
isOpen={isLightboxOpen}
|
||||
onClose={() => setIsLightboxOpen(false)}
|
||||
src={src}
|
||||
alt={alt}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
62
apps/sim/app/(landing)/blog/components/lightbox.tsx
Normal file
62
apps/sim/app/(landing)/blog/components/lightbox.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
interface LightboxProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
src: string
|
||||
alt: string
|
||||
}
|
||||
|
||||
export function Lightbox({ isOpen, onClose, src, alt }: LightboxProps) {
|
||||
const overlayRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (overlayRef.current && event.target === overlayRef.current) {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
document.body.style.overflow = 'hidden'
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
document.body.style.overflow = 'unset'
|
||||
}
|
||||
}, [isOpen, onClose])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
className='fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-12 backdrop-blur-sm'
|
||||
role='dialog'
|
||||
aria-modal='true'
|
||||
aria-label='Image viewer'
|
||||
>
|
||||
<div className='relative max-h-full max-w-full overflow-hidden rounded-xl shadow-2xl'>
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className='max-h-[75vh] max-w-[75vw] cursor-pointer rounded-xl object-contain'
|
||||
loading='lazy'
|
||||
onClick={onClose}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
291
apps/sim/app/(landing)/partners/page.tsx
Normal file
291
apps/sim/app/(landing)/partners/page.tsx
Normal file
@@ -0,0 +1,291 @@
|
||||
import type { Metadata } from 'next'
|
||||
import Link from 'next/link'
|
||||
import { getNavBlogPosts } from '@/lib/blog/registry'
|
||||
import { martianMono } from '@/app/_styles/fonts/martian-mono/martian-mono'
|
||||
import { season } from '@/app/_styles/fonts/season/season'
|
||||
import Footer from '@/app/(home)/components/footer/footer'
|
||||
import Navbar from '@/app/(home)/components/navbar/navbar'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Partner Program',
|
||||
description:
|
||||
'Join the Sim partner program. Build, deploy, and sell AI workflow solutions. Earn your certification through Sim Academy.',
|
||||
metadataBase: new URL('https://sim.ai'),
|
||||
openGraph: {
|
||||
title: 'Partner Program | Sim',
|
||||
description: 'Join the Sim partner program.',
|
||||
type: 'website',
|
||||
},
|
||||
}
|
||||
|
||||
const PARTNER_TIERS = [
|
||||
{
|
||||
name: 'Certified Partner',
|
||||
badge: 'Entry',
|
||||
color: '#3A3A3A',
|
||||
requirements: ['Complete Sim Academy certification', 'Deploy at least 1 live workflow'],
|
||||
perks: [
|
||||
'Official partner badge',
|
||||
'Listed in partner directory',
|
||||
'Early access to new features',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Silver Partner',
|
||||
badge: 'Growth',
|
||||
color: '#5A5A5A',
|
||||
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',
|
||||
color: '#8B7355',
|
||||
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 workflows through hands-on canvas exercises.',
|
||||
},
|
||||
{
|
||||
step: '02',
|
||||
title: 'Build & deploy real solutions',
|
||||
description:
|
||||
'Put your skills to work. Build workflow automations 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 workflows, collaborate on solutions, and shape the product roadmap.',
|
||||
},
|
||||
]
|
||||
|
||||
export default async function PartnersPage() {
|
||||
const blogPosts = await getNavBlogPosts()
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${season.variable} ${martianMono.variable} min-h-screen bg-[#1C1C1C] font-[430] font-season text-[#ECECEC]`}
|
||||
>
|
||||
<header>
|
||||
<Navbar logoOnly={false} blogPosts={blogPosts} />
|
||||
</header>
|
||||
|
||||
<main>
|
||||
{/* Hero */}
|
||||
<section className='border-[#2A2A2A] border-b px-[80px] py-[100px]'>
|
||||
<div className='mx-auto max-w-4xl'>
|
||||
<div className='mb-4 text-[#666] text-[13px] uppercase tracking-[0.12em]'>
|
||||
Partner Program
|
||||
</div>
|
||||
<h1 className='mb-5 text-[64px] text-white leading-[105%] tracking-[-0.03em]'>
|
||||
Build the future
|
||||
<br />
|
||||
of AI automation
|
||||
</h1>
|
||||
<p className='mb-10 max-w-xl text-[#F6F6F0]/60 text-[18px] leading-[160%] tracking-[0.01em]'>
|
||||
Become a certified Sim partner. Complete Sim Academy, deploy real solutions, and earn
|
||||
recognition in the growing ecosystem of AI workflow builders.
|
||||
</p>
|
||||
<div className='flex items-center gap-4'>
|
||||
<Link
|
||||
href='/academy'
|
||||
className='inline-flex h-[44px] items-center rounded-[5px] bg-white px-6 text-[#1C1C1C] text-[15px] transition-colors hover:bg-[#E8E8E8]'
|
||||
>
|
||||
Start Sim Academy →
|
||||
</Link>
|
||||
<a
|
||||
href='#how-it-works'
|
||||
className='inline-flex h-[44px] items-center rounded-[5px] border border-[#3A3A3A] px-6 text-[#ECECEC] text-[15px] transition-colors hover:border-[#4A4A4A]'
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Benefits grid */}
|
||||
<section className='border-[#2A2A2A] border-b px-[80px] py-20'>
|
||||
<div className='mx-auto max-w-5xl'>
|
||||
<div className='mb-12 text-[#666] text-[13px] uppercase tracking-[0.12em]'>
|
||||
Why partner with Sim
|
||||
</div>
|
||||
<div className='grid gap-6 sm:grid-cols-2 lg:grid-cols-3'>
|
||||
{BENEFITS.map((b) => (
|
||||
<div key={b.title} className='rounded-[8px] border border-[#2A2A2A] bg-[#222] p-6'>
|
||||
<div className='mb-3 text-[24px]'>{b.icon}</div>
|
||||
<h3 className='mb-2 text-[#ECECEC] text-[15px]'>{b.title}</h3>
|
||||
<p className='text-[#999] text-[14px] leading-[160%]'>{b.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* How it works */}
|
||||
<section id='how-it-works' className='border-[#2A2A2A] border-b px-[80px] py-20'>
|
||||
<div className='mx-auto max-w-4xl'>
|
||||
<div className='mb-12 text-[#666] text-[13px] uppercase tracking-[0.12em]'>
|
||||
How it works
|
||||
</div>
|
||||
<div className='space-y-10'>
|
||||
{HOW_IT_WORKS.map((step) => (
|
||||
<div key={step.step} className='flex gap-8'>
|
||||
<div className='flex-shrink-0 font-[430] text-[#2A2A2A] text-[48px] leading-none'>
|
||||
{step.step}
|
||||
</div>
|
||||
<div className='pt-2'>
|
||||
<h3 className='mb-2 text-[#ECECEC] text-[18px]'>{step.title}</h3>
|
||||
<p className='text-[#999] text-[15px] leading-[160%]'>{step.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Partner tiers */}
|
||||
<section className='border-[#2A2A2A] border-b px-[80px] py-20'>
|
||||
<div className='mx-auto max-w-5xl'>
|
||||
<div className='mb-12 text-[#666] text-[13px] uppercase tracking-[0.12em]'>
|
||||
Partner tiers
|
||||
</div>
|
||||
<div className='grid gap-5 lg:grid-cols-3'>
|
||||
{PARTNER_TIERS.map((tier) => (
|
||||
<div
|
||||
key={tier.name}
|
||||
className='flex flex-col rounded-[8px] border border-[#2A2A2A] bg-[#222] p-6'
|
||||
>
|
||||
<div className='mb-4 flex items-center justify-between'>
|
||||
<h3 className='text-[#ECECEC] text-[16px]'>{tier.name}</h3>
|
||||
<span
|
||||
className='rounded-full px-2.5 py-0.5 text-[11px]'
|
||||
style={{
|
||||
backgroundColor: `${tier.color}33`,
|
||||
color: tier.color === '#8B7355' ? '#C8A96E' : '#999',
|
||||
border: `1px solid ${tier.color}`,
|
||||
}}
|
||||
>
|
||||
{tier.badge}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className='mb-4'>
|
||||
<p className='mb-2 text-[#555] text-[12px] uppercase tracking-[0.1em]'>
|
||||
Requirements
|
||||
</p>
|
||||
<ul className='space-y-1.5'>
|
||||
{tier.requirements.map((r) => (
|
||||
<li key={r} className='flex items-start gap-2 text-[#999] text-[13px]'>
|
||||
<span className='mt-1.5 h-1 w-1 flex-shrink-0 rounded-full bg-[#555]' />
|
||||
{r}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className='mt-auto'>
|
||||
<p className='mb-2 text-[#555] text-[12px] uppercase tracking-[0.1em]'>Perks</p>
|
||||
<ul className='space-y-1.5'>
|
||||
{tier.perks.map((p) => (
|
||||
<li key={p} className='flex items-start gap-2 text-[#ECECEC] text-[13px]'>
|
||||
<span className='mt-1.5 h-1 w-1 flex-shrink-0 rounded-full bg-[#4CAF50]' />
|
||||
{p}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA */}
|
||||
<section className='px-[80px] py-[100px]'>
|
||||
<div className='mx-auto max-w-3xl text-center'>
|
||||
<h2 className='mb-4 text-[48px] text-white leading-[110%] tracking-[-0.02em]'>
|
||||
Ready to get started?
|
||||
</h2>
|
||||
<p className='mb-10 text-[#F6F6F0]/60 text-[18px] leading-[160%]'>
|
||||
Complete Sim Academy to earn your first certification and unlock partner benefits.
|
||||
It's free to start — no credit card required.
|
||||
</p>
|
||||
<Link
|
||||
href='/academy'
|
||||
className='inline-flex h-[48px] items-center rounded-[5px] bg-white px-8 font-[430] text-[#1C1C1C] text-[15px] transition-colors hover:bg-[#E8E8E8]'
|
||||
>
|
||||
Start Sim Academy →
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -25,6 +25,10 @@ export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
pathname.startsWith('/form') ||
|
||||
pathname.startsWith('/oauth')
|
||||
|
||||
const isDarkModePage = pathname.startsWith('/academy')
|
||||
|
||||
const forcedTheme = isLightModePage ? 'light' : isDarkModePage ? 'dark' : undefined
|
||||
|
||||
return (
|
||||
<NextThemesProvider
|
||||
attribute='class'
|
||||
@@ -32,7 +36,7 @@ export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
storageKey='sim-theme'
|
||||
forcedTheme={isLightModePage ? 'light' : undefined}
|
||||
forcedTheme={forcedTheme}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { CheckCircle2, Circle, ExternalLink, GraduationCap, Loader2 } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { getCompletedLessons } from '@/lib/academy/local-progress'
|
||||
import type { Course } from '@/lib/academy/types'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { useCourseCertificate, useIssueCertificate } from '@/hooks/queries/academy'
|
||||
|
||||
interface CourseProgressProps {
|
||||
course: Course
|
||||
courseSlug: string
|
||||
}
|
||||
|
||||
export function CourseProgress({ course, courseSlug }: CourseProgressProps) {
|
||||
// Start with an empty set so SSR and initial client render match, then hydrate from localStorage.
|
||||
const [completedIds, setCompletedIds] = useState<Set<string>>(() => new Set())
|
||||
useEffect(() => {
|
||||
setCompletedIds(getCompletedLessons())
|
||||
}, [])
|
||||
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 && (
|
||||
<div className='px-4 pt-8 sm:px-8 md:px-[80px]'>
|
||||
<div className='mx-auto max-w-3xl rounded-[8px] border border-[#2A2A2A] bg-[#222] p-4'>
|
||||
<div className='mb-2 flex items-center justify-between text-[13px]'>
|
||||
<span className='text-[#999]'>Your progress</span>
|
||||
<span className='text-[#ECECEC]'>
|
||||
{completedCount}/{totalLessons} lessons
|
||||
</span>
|
||||
</div>
|
||||
<div className='h-1.5 w-full overflow-hidden rounded-full bg-[#2A2A2A]'>
|
||||
<div
|
||||
className='h-full rounded-full bg-[#ECECEC] transition-all'
|
||||
style={{ width: `${percentComplete}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className='px-4 py-14 sm:px-8 md:px-[80px]'>
|
||||
<div className='mx-auto max-w-3xl space-y-10'>
|
||||
{course.modules.map((mod, modIndex) => (
|
||||
<div key={mod.id}>
|
||||
<div className='mb-4 flex items-center gap-3'>
|
||||
<span className='text-[#555] text-[12px]'>Module {modIndex + 1}</span>
|
||||
<div className='h-px flex-1 bg-[#2A2A2A]' />
|
||||
</div>
|
||||
<h2 className='mb-4 font-[430] text-[#ECECEC] text-[18px]'>{mod.title}</h2>
|
||||
<div className='space-y-2'>
|
||||
{mod.lessons.map((lesson) => (
|
||||
<Link
|
||||
key={lesson.id}
|
||||
href={`/academy/${courseSlug}/${lesson.slug}`}
|
||||
className='flex items-center gap-3 rounded-[8px] border border-[#2A2A2A] bg-[#222] px-4 py-3 text-[14px] transition-colors hover:border-[#3A3A3A] hover:bg-[#272727]'
|
||||
>
|
||||
{completedIds.has(lesson.id) ? (
|
||||
<CheckCircle2 className='h-4 w-4 flex-shrink-0 text-[#4CAF50]' />
|
||||
) : (
|
||||
<Circle className='h-4 w-4 flex-shrink-0 text-[#444]' />
|
||||
)}
|
||||
<span className='flex-1 text-[#ECECEC]'>{lesson.title}</span>
|
||||
<span className='text-[#555] text-[12px] capitalize'>{lesson.lessonType}</span>
|
||||
{lesson.videoDurationSeconds && (
|
||||
<span className='text-[#555] text-[12px]'>
|
||||
{Math.round(lesson.videoDurationSeconds / 60)} min
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{totalLessons > 0 && completedCount === totalLessons && (
|
||||
<section className='px-4 pb-16 sm:px-8 md:px-[80px]'>
|
||||
<div className='mx-auto max-w-3xl rounded-[8px] border border-[#3A4A3A] bg-[#1F2A1F] p-6'>
|
||||
{certificate ? (
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<GraduationCap className='h-6 w-6 text-[#4CAF50]' />
|
||||
<div>
|
||||
<p className='font-[430] text-[#ECECEC] text-[15px]'>Certificate issued!</p>
|
||||
<p className='font-mono text-[#666] text-[13px]'>
|
||||
{certificate.certificateNumber}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href={`/academy/certificate/${certificate.certificateNumber}`}
|
||||
className='flex items-center gap-1.5 rounded-[5px] bg-[#4CAF50] px-4 py-2 font-[430] text-[#1C1C1C] text-[13px] transition-colors hover:bg-[#5DBF61]'
|
||||
>
|
||||
View certificate
|
||||
<ExternalLink className='h-3.5 w-3.5' />
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<GraduationCap className='h-6 w-6 text-[#4CAF50]' />
|
||||
<div>
|
||||
<p className='font-[430] text-[#ECECEC] text-[15px]'>Course Complete!</p>
|
||||
<p className='text-[#666] text-[13px]'>
|
||||
{session
|
||||
? error
|
||||
? 'Something went wrong. Try again.'
|
||||
: 'Claim your certificate of completion.'
|
||||
: 'Sign in to claim your certificate.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{session ? (
|
||||
<button
|
||||
type='button'
|
||||
disabled={isPending}
|
||||
onClick={() =>
|
||||
issueCertificate({
|
||||
courseId: course.id,
|
||||
completedLessonIds: [...completedIds],
|
||||
})
|
||||
}
|
||||
className='flex items-center gap-2 rounded-[5px] bg-[#ECECEC] px-4 py-2 font-[430] text-[#1C1C1C] text-[13px] transition-colors hover:bg-white disabled:opacity-50'
|
||||
>
|
||||
{isPending && <Loader2 className='h-3.5 w-3.5 animate-spin' />}
|
||||
{isPending ? 'Issuing…' : 'Get certificate'}
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
href='/login'
|
||||
className='rounded-[5px] bg-[#ECECEC] px-4 py-2 font-[430] text-[#1C1C1C] text-[13px] transition-colors hover:bg-white'
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
68
apps/sim/app/academy/(catalog)/[courseSlug]/page.tsx
Normal file
68
apps/sim/app/academy/(catalog)/[courseSlug]/page.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
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<Metadata> {
|
||||
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 (
|
||||
<main>
|
||||
<section className='border-[#2A2A2A] border-b px-4 py-16 sm:px-8 md:px-[80px]'>
|
||||
<div className='mx-auto max-w-3xl'>
|
||||
<Link
|
||||
href='/academy'
|
||||
className='mb-4 inline-flex items-center gap-1.5 text-[#666] text-[13px] transition-colors hover:text-[#999]'
|
||||
>
|
||||
← All courses
|
||||
</Link>
|
||||
<h1 className='mb-3 font-[430] text-[#ECECEC] text-[36px] leading-[115%] tracking-[-0.02em]'>
|
||||
{course.title}
|
||||
</h1>
|
||||
{course.description && (
|
||||
<p className='mb-6 text-[#F6F6F0]/60 text-[16px] leading-[160%]'>
|
||||
{course.description}
|
||||
</p>
|
||||
)}
|
||||
<div className='mt-6 flex items-center gap-5 text-[#666] text-[13px]'>
|
||||
{course.estimatedMinutes && (
|
||||
<span className='flex items-center gap-1.5'>
|
||||
<Clock className='h-3.5 w-3.5' />
|
||||
{course.estimatedMinutes} min total
|
||||
</span>
|
||||
)}
|
||||
<span className='flex items-center gap-1.5'>
|
||||
<GraduationCap className='h-3.5 w-3.5' />
|
||||
Certificate upon completion
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<CourseProgress course={course} courseSlug={courseSlug} />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
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'
|
||||
|
||||
interface CertificatePageProps {
|
||||
params: Promise<{ certificateNumber: string }>
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: CertificatePageProps): Promise<Metadata> {
|
||||
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<AcademyCertificate | null> => {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(academyCertificate)
|
||||
.where(eq(academyCertificate.certificateNumber, certificateNumber))
|
||||
.limit(1)
|
||||
return (row as unknown as AcademyCertificate) ?? null
|
||||
}
|
||||
)
|
||||
|
||||
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 (
|
||||
<div className='flex items-center justify-between px-5 py-3.5'>
|
||||
<span className='text-[#666] text-[13px]'>{label}</span>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default async function CertificatePage({ params }: CertificatePageProps) {
|
||||
const { certificateNumber } = await params
|
||||
const certificate = await fetchCertificate(certificateNumber)
|
||||
|
||||
if (!certificate) notFound()
|
||||
|
||||
return (
|
||||
<main className='flex flex-1 items-center justify-center px-6 py-20'>
|
||||
<div className='w-full max-w-2xl'>
|
||||
<div className='rounded-[12px] border border-[#3A4A3A] bg-[#1C2A1C] p-10 text-center'>
|
||||
<div className='mb-6 flex justify-center'>
|
||||
<div className='flex h-16 w-16 items-center justify-center rounded-full border-2 border-[#4CAF50]/40 bg-[#4CAF50]/10'>
|
||||
<GraduationCap className='h-8 w-8 text-[#4CAF50]' />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mb-2 text-[#4CAF50]/70 text-[13px] uppercase tracking-[0.12em]'>
|
||||
Certificate of Completion
|
||||
</div>
|
||||
|
||||
<h1 className='mb-1 font-[430] text-[#ECECEC] text-[28px] leading-[120%]'>
|
||||
{certificate.metadata?.courseTitle}
|
||||
</h1>
|
||||
|
||||
{certificate.metadata?.recipientName && (
|
||||
<p className='mb-6 text-[#999] text-[16px]'>
|
||||
Awarded to{' '}
|
||||
<span className='text-[#ECECEC]'>{certificate.metadata.recipientName}</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{certificate.status === 'active' ? (
|
||||
<div className='flex items-center justify-center gap-2 text-[#4CAF50]'>
|
||||
<CheckCircle2 className='h-4 w-4' />
|
||||
<span className='font-[430] text-[14px]'>Verified</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex items-center justify-center gap-2 text-[#f44336]'>
|
||||
<XCircle className='h-4 w-4' />
|
||||
<span className='font-[430] text-[14px] capitalize'>{certificate.status}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='mt-6 divide-y divide-[#2A2A2A] rounded-[8px] border border-[#2A2A2A] bg-[#222]'>
|
||||
<MetaRow label='Certificate number'>
|
||||
<span className='font-mono text-[#ECECEC] text-[13px]'>
|
||||
{certificate.certificateNumber}
|
||||
</span>
|
||||
</MetaRow>
|
||||
<MetaRow label='Issued'>
|
||||
<span className='text-[#ECECEC] text-[13px]'>{formatDate(certificate.issuedAt)}</span>
|
||||
</MetaRow>
|
||||
<MetaRow label='Status'>
|
||||
<span
|
||||
className={`text-[13px] capitalize ${
|
||||
certificate.status === 'active' ? 'text-[#4CAF50]' : 'text-[#f44336]'
|
||||
}`}
|
||||
>
|
||||
{certificate.status}
|
||||
</span>
|
||||
</MetaRow>
|
||||
{certificate.expiresAt && (
|
||||
<MetaRow label='Expires'>
|
||||
<span className='text-[#ECECEC] text-[13px]'>
|
||||
{formatDate(certificate.expiresAt)}
|
||||
</span>
|
||||
</MetaRow>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className='mt-5 text-center text-[#555] text-[13px]'>
|
||||
This certificate was issued by Sim AI, Inc. and verifies the holder has completed the{' '}
|
||||
{certificate.metadata?.courseTitle} program.
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
16
apps/sim/app/academy/(catalog)/layout.tsx
Normal file
16
apps/sim/app/academy/(catalog)/layout.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import type React from 'react'
|
||||
import { getNavBlogPosts } from '@/lib/blog/registry'
|
||||
import Footer from '@/app/(home)/components/footer/footer'
|
||||
import Navbar from '@/app/(home)/components/navbar/navbar'
|
||||
|
||||
export default async function AcademyCatalogLayout({ children }: { children: React.ReactNode }) {
|
||||
const blogPosts = await getNavBlogPosts()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navbar blogPosts={blogPosts} />
|
||||
{children}
|
||||
<Footer hideCTA />
|
||||
</>
|
||||
)
|
||||
}
|
||||
19
apps/sim/app/academy/(catalog)/not-found.tsx
Normal file
19
apps/sim/app/academy/(catalog)/not-found.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import Link from 'next/link'
|
||||
|
||||
export default function AcademyNotFound() {
|
||||
return (
|
||||
<main className='flex flex-1 flex-col items-center justify-center px-6 py-32 text-center'>
|
||||
<p className='mb-2 font-mono text-[#555] text-[13px] uppercase tracking-widest'>404</p>
|
||||
<h1 className='mb-3 font-[430] text-[#ECECEC] text-[28px] leading-[120%]'>Page not found</h1>
|
||||
<p className='mb-8 text-[#666] text-[15px]'>
|
||||
That course or lesson doesn't exist in the Academy.
|
||||
</p>
|
||||
<Link
|
||||
href='/academy'
|
||||
className='rounded-[5px] bg-[#ECECEC] px-5 py-2.5 font-[430] text-[#1C1C1C] text-[14px] transition-colors hover:bg-white'
|
||||
>
|
||||
Back to Academy
|
||||
</Link>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
76
apps/sim/app/academy/(catalog)/page.tsx
Normal file
76
apps/sim/app/academy/(catalog)/page.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { BookOpen, Clock } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { COURSES } from '@/lib/academy/content'
|
||||
|
||||
export default function AcademyCatalogPage() {
|
||||
return (
|
||||
<main>
|
||||
<section className='border-[#2A2A2A] border-b px-4 py-20 sm:px-8 md:px-[80px]'>
|
||||
<div className='mx-auto max-w-3xl'>
|
||||
<div className='mb-3 text-[#999] text-[13px] uppercase tracking-[0.12em]'>
|
||||
Sim Academy
|
||||
</div>
|
||||
<h1 className='mb-4 font-[430] text-[#ECECEC] text-[48px] leading-[110%] tracking-[-0.02em]'>
|
||||
Become a certified
|
||||
<br />
|
||||
Sim partner
|
||||
</h1>
|
||||
<p className='text-[#F6F6F0]/60 text-[18px] leading-[160%] tracking-[0.01em]'>
|
||||
Master AI workflow automation with hands-on interactive exercises on the real Sim
|
||||
canvas. Complete the program to earn your partner certification.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className='px-4 py-16 sm:px-8 md:px-[80px]'>
|
||||
<div className='mx-auto max-w-6xl'>
|
||||
<h2 className='mb-8 text-[#999] text-[13px] uppercase tracking-[0.12em]'>Courses</h2>
|
||||
<div className='grid gap-5 sm:grid-cols-2 lg:grid-cols-3'>
|
||||
{COURSES.map((course) => {
|
||||
const totalLessons = course.modules.reduce((n, m) => n + m.lessons.length, 0)
|
||||
return (
|
||||
<Link
|
||||
key={course.id}
|
||||
href={`/academy/${course.slug}`}
|
||||
className='group flex flex-col rounded-[8px] border border-[#2A2A2A] bg-[#232323] p-5 transition-colors hover:border-[#3A3A3A] hover:bg-[#282828]'
|
||||
>
|
||||
{course.imageUrl && (
|
||||
<div className='mb-4 aspect-video w-full overflow-hidden rounded-[6px] bg-[#1A1A1A]'>
|
||||
<img
|
||||
src={course.imageUrl}
|
||||
alt={course.title}
|
||||
className='h-full w-full object-cover opacity-80'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className='flex-1'>
|
||||
<h3 className='mb-2 font-[430] text-[#ECECEC] text-[16px] leading-[130%] group-hover:text-white'>
|
||||
{course.title}
|
||||
</h3>
|
||||
{course.description && (
|
||||
<p className='mb-4 line-clamp-2 text-[#999] text-[14px] leading-[150%]'>
|
||||
{course.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className='mt-auto flex items-center gap-4 text-[#666] text-[12px]'>
|
||||
{course.estimatedMinutes && (
|
||||
<span className='flex items-center gap-1.5'>
|
||||
<Clock className='h-3 w-3' />
|
||||
{course.estimatedMinutes} min
|
||||
</span>
|
||||
)}
|
||||
<span className='flex items-center gap-1.5'>
|
||||
<BookOpen className='h-3 w-3' />
|
||||
{totalLessons} lessons
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
'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 (
|
||||
<div className='relative flex h-full w-full flex-col overflow-hidden'>
|
||||
<SandboxCanvasProvider
|
||||
exerciseId={lessonId}
|
||||
exerciseConfig={exerciseConfig}
|
||||
onComplete={handleComplete}
|
||||
videoUrl={videoUrl}
|
||||
description={description}
|
||||
className='flex-1'
|
||||
/>
|
||||
|
||||
{completed && (
|
||||
<div className='pointer-events-none absolute inset-0 flex items-start justify-center pt-5'>
|
||||
<div className='pointer-events-auto flex items-center gap-2 rounded-full border border-[#3A4A3A] bg-[#1F2A1F]/95 px-4 py-2 font-[430] text-[#4CAF50] text-[13px] shadow-lg backdrop-blur-sm'>
|
||||
<CheckCircle2 className='h-4 w-4' />
|
||||
Exercise complete!
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { CheckCircle2, XCircle } from 'lucide-react'
|
||||
import { markLessonComplete } from '@/lib/academy/local-progress'
|
||||
import type { QuizDefinition, QuizQuestion } from '@/lib/academy/types'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
|
||||
interface LessonQuizProps {
|
||||
lessonId: string
|
||||
quizConfig: QuizDefinition
|
||||
onPass?: () => void
|
||||
}
|
||||
|
||||
type Answers = Record<number, number | number[] | boolean>
|
||||
|
||||
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<Answers>({})
|
||||
const [result, setResult] = useState<QuizResult | null>(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 (
|
||||
<div className='space-y-6'>
|
||||
<div>
|
||||
<h2 className='font-[430] text-[#ECECEC] text-[20px]'>Quiz</h2>
|
||||
<p className='mt-1 text-[#666] text-[14px]'>
|
||||
Score {quizConfig.passingScore}% or higher to pass.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{quizConfig.questions.map((q, qi) => {
|
||||
const feedback = result?.feedback[qi]
|
||||
const isCorrect = feedback?.correct
|
||||
|
||||
return (
|
||||
<div key={qi} className='rounded-[8px] bg-[#222] p-5'>
|
||||
<p className='mb-4 font-[430] text-[#ECECEC] text-[15px]'>{q.question}</p>
|
||||
|
||||
{q.type === 'multiple_choice' && (
|
||||
<div className='space-y-2'>
|
||||
{q.options.map((opt, oi) => (
|
||||
<button
|
||||
key={oi}
|
||||
type='button'
|
||||
onClick={() => handleAnswer(qi, oi)}
|
||||
disabled={Boolean(result)}
|
||||
className={cn(
|
||||
optionBase,
|
||||
answers[qi] === oi
|
||||
? 'border-[#ECECEC]/40 bg-[#ECECEC]/5 text-[#ECECEC]'
|
||||
: 'border-[#2A2A2A] text-[#999] hover:border-[#3A3A3A] hover:bg-[#272727]',
|
||||
result &&
|
||||
oi === q.correctIndex &&
|
||||
'border-[#4CAF50]/50 bg-[#4CAF50]/5 text-[#4CAF50]',
|
||||
result &&
|
||||
answers[qi] === oi &&
|
||||
oi !== q.correctIndex &&
|
||||
'border-[#f44336]/40 bg-[#f44336]/5 text-[#f44336]'
|
||||
)}
|
||||
>
|
||||
{opt}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{q.type === 'true_false' && (
|
||||
<div className='flex gap-3'>
|
||||
{(['True', 'False'] as const).map((label) => {
|
||||
const val = label === 'True'
|
||||
return (
|
||||
<button
|
||||
key={label}
|
||||
type='button'
|
||||
onClick={() => handleAnswer(qi, val)}
|
||||
disabled={Boolean(result)}
|
||||
className={cn(
|
||||
'flex-1 rounded-[6px] border px-4 py-3 text-[14px] transition-colors disabled:cursor-default',
|
||||
answers[qi] === val
|
||||
? 'border-[#ECECEC]/40 bg-[#ECECEC]/5 text-[#ECECEC]'
|
||||
: 'border-[#2A2A2A] text-[#999] hover:border-[#3A3A3A] hover:bg-[#272727]',
|
||||
result &&
|
||||
val === q.correctAnswer &&
|
||||
'border-[#4CAF50]/50 bg-[#4CAF50]/5 text-[#4CAF50]',
|
||||
result &&
|
||||
answers[qi] === val &&
|
||||
val !== q.correctAnswer &&
|
||||
'border-[#f44336]/40 bg-[#f44336]/5 text-[#f44336]'
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{q.type === 'multi_select' && (
|
||||
<div className='space-y-2'>
|
||||
{q.options.map((opt, oi) => {
|
||||
const selected = ((answers[qi] as number[]) ?? []).includes(oi)
|
||||
return (
|
||||
<button
|
||||
key={oi}
|
||||
type='button'
|
||||
onClick={() => handleMultiSelect(qi, oi)}
|
||||
disabled={Boolean(result)}
|
||||
className={cn(
|
||||
optionBase,
|
||||
selected
|
||||
? 'border-[#ECECEC]/40 bg-[#ECECEC]/5 text-[#ECECEC]'
|
||||
: 'border-[#2A2A2A] text-[#999] hover:border-[#3A3A3A] hover:bg-[#272727]',
|
||||
result &&
|
||||
q.correctIndices.includes(oi) &&
|
||||
'border-[#4CAF50]/50 bg-[#4CAF50]/5 text-[#4CAF50]',
|
||||
result &&
|
||||
selected &&
|
||||
!q.correctIndices.includes(oi) &&
|
||||
'border-[#f44336]/40 bg-[#f44336]/5 text-[#f44336]'
|
||||
)}
|
||||
>
|
||||
{opt}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{feedback && (
|
||||
<div
|
||||
className={cn(
|
||||
'mt-3 flex items-start gap-2 rounded-[6px] px-3 py-2.5 text-[13px]',
|
||||
isCorrect ? 'bg-[#4CAF50]/10 text-[#4CAF50]' : 'bg-[#f44336]/10 text-[#f44336]'
|
||||
)}
|
||||
>
|
||||
{isCorrect ? (
|
||||
<CheckCircle2 className='mt-0.5 h-3.5 w-3.5 flex-shrink-0' />
|
||||
) : (
|
||||
<XCircle className='mt-0.5 h-3.5 w-3.5 flex-shrink-0' />
|
||||
)}
|
||||
<span>{isCorrect ? 'Correct!' : (feedback.explanation ?? 'Incorrect.')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{result && (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-[8px] border p-5',
|
||||
result.passed
|
||||
? 'border-[#3A4A3A] bg-[#1F2A1F] text-[#4CAF50]'
|
||||
: 'border-[#3A2A2A] bg-[#2A1F1F] text-[#f44336]'
|
||||
)}
|
||||
>
|
||||
<p className='font-[430] text-[15px]'>{result.passed ? 'Passed!' : 'Keep trying!'}</p>
|
||||
<p className='mt-1 text-[13px] opacity-80'>
|
||||
Score: {result.score}% (passing: {quizConfig.passingScore}%)
|
||||
</p>
|
||||
{!result.passed && (
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => {
|
||||
setAnswers({})
|
||||
setResult(null)
|
||||
}}
|
||||
className='mt-3 rounded-[5px] border border-[#3A2A2A] bg-[#2A1F1F] px-3 py-1.5 text-[#999] text-[13px] transition-colors hover:border-[#4A3A3A] hover:text-[#ECECEC]'
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!result && (
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleSubmit}
|
||||
disabled={!allAnswered}
|
||||
className='rounded-[5px] bg-[#ECECEC] px-5 py-2.5 font-[430] text-[#1C1C1C] text-[14px] transition-colors hover:bg-white disabled:opacity-40'
|
||||
>
|
||||
Submit answers
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
23
apps/sim/app/academy/[courseSlug]/[lessonSlug]/layout.tsx
Normal file
23
apps/sim/app/academy/[courseSlug]/[lessonSlug]/layout.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
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}</>
|
||||
}
|
||||
219
apps/sim/app/academy/[courseSlug]/[lessonSlug]/page.tsx
Normal file
219
apps/sim/app/academy/[courseSlug]/[lessonSlug]/page.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
'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<Lesson[]>(
|
||||
() => 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 (
|
||||
<div className='flex h-screen items-center justify-center bg-[#1C1C1C]'>
|
||||
<p className='text-[#666] text-[14px]'>Lesson not found.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const hasVideo = Boolean(lesson.videoUrl)
|
||||
const hasExercise = Boolean(lesson.exerciseConfig)
|
||||
const hasQuiz = Boolean(lesson.quizConfig)
|
||||
|
||||
return (
|
||||
<div className='fixed inset-0 flex flex-col overflow-hidden bg-[#1C1C1C]'>
|
||||
<header className='flex h-[52px] flex-shrink-0 items-center justify-between border-[#2A2A2A] border-b bg-[#1C1C1C] px-5'>
|
||||
<div className='flex items-center gap-3 text-[13px]'>
|
||||
<Link href='/' aria-label='Sim home'>
|
||||
<Image
|
||||
src='/logo/b&w/text/b&w.svg'
|
||||
alt='Sim'
|
||||
width={40}
|
||||
height={14}
|
||||
className='opacity-70 invert transition-opacity hover:opacity-100'
|
||||
/>
|
||||
</Link>
|
||||
<span className='text-[#333]'>/</span>
|
||||
<Link href='/academy' className='text-[#666] transition-colors hover:text-[#999]'>
|
||||
Academy
|
||||
</Link>
|
||||
<span className='text-[#333]'>/</span>
|
||||
<Link
|
||||
href={`/academy/${courseSlug}`}
|
||||
className='max-w-[160px] truncate text-[#666] transition-colors hover:text-[#999]'
|
||||
>
|
||||
{course.title}
|
||||
</Link>
|
||||
<span className='text-[#333]'>/</span>
|
||||
<span className='max-w-[200px] truncate text-[#ECECEC]'>{lesson.title}</span>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center gap-2'>
|
||||
{prevLesson ? (
|
||||
<Link href={`/academy/${courseSlug}/${prevLesson.slug}`} className={navBtnClass}>
|
||||
<ChevronLeft className='h-3.5 w-3.5' />
|
||||
Previous
|
||||
</Link>
|
||||
) : (
|
||||
<Link href={`/academy/${courseSlug}`} className={navBtnClass}>
|
||||
<ChevronLeft className='h-3.5 w-3.5' />
|
||||
Course
|
||||
</Link>
|
||||
)}
|
||||
{nextLesson && (
|
||||
<Link
|
||||
href={`/academy/${courseSlug}/${nextLesson.slug}`}
|
||||
onClick={(e) => {
|
||||
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
|
||||
<ChevronRight className='h-3.5 w-3.5' />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className='flex min-h-0 flex-1 overflow-hidden'>
|
||||
{lesson.lessonType === 'video' && hasVideo && (
|
||||
<div className='flex-1 overflow-y-auto p-10'>
|
||||
<div className='mx-auto w-full max-w-3xl'>
|
||||
<LessonVideo url={lesson.videoUrl!} title={lesson.title} />
|
||||
{lesson.description && (
|
||||
<p className='mt-5 text-[#999] text-[15px] leading-[160%]'>{lesson.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lesson.lessonType === 'exercise' && hasExercise && (
|
||||
<ExerciseView
|
||||
lessonId={lesson.id}
|
||||
exerciseConfig={lesson.exerciseConfig!}
|
||||
onComplete={handleExerciseComplete}
|
||||
/>
|
||||
)}
|
||||
|
||||
{lesson.lessonType === 'quiz' && hasQuiz && (
|
||||
<div className='flex-1 overflow-y-auto p-10'>
|
||||
<div className='mx-auto w-full max-w-2xl'>
|
||||
<LessonQuiz
|
||||
lessonId={lesson.id}
|
||||
quizConfig={lesson.quizConfig!}
|
||||
onPass={handleQuizPass}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lesson.lessonType === 'mixed' && (
|
||||
<>
|
||||
{hasExercise && (!exerciseComplete || !hasQuiz) && (
|
||||
<ExerciseView
|
||||
lessonId={lesson.id}
|
||||
exerciseConfig={lesson.exerciseConfig!}
|
||||
onComplete={handleExerciseComplete}
|
||||
videoUrl={!hasQuiz ? lesson.videoUrl : undefined}
|
||||
description={!hasQuiz ? lesson.description : undefined}
|
||||
/>
|
||||
)}
|
||||
{hasExercise && exerciseComplete && hasQuiz && (
|
||||
<div className='flex-1 overflow-y-auto p-8'>
|
||||
<div className='mx-auto w-full max-w-xl space-y-8'>
|
||||
{hasVideo && <LessonVideo url={lesson.videoUrl!} title={lesson.title} />}
|
||||
<LessonQuiz
|
||||
lessonId={lesson.id}
|
||||
quizConfig={lesson.quizConfig!}
|
||||
onPass={handleQuizPass}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!hasExercise && hasQuiz && (
|
||||
<div className='flex-1 overflow-y-auto p-8'>
|
||||
<div className='mx-auto w-full max-w-xl space-y-8'>
|
||||
{hasVideo && <LessonVideo url={lesson.videoUrl!} title={lesson.title} />}
|
||||
<LessonQuiz
|
||||
lessonId={lesson.id}
|
||||
quizConfig={lesson.quizConfig!}
|
||||
onPass={handleQuizPass}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!hasExercise && !hasQuiz && hasVideo && (
|
||||
<div className='flex-1 overflow-y-auto p-10'>
|
||||
<div className='mx-auto w-full max-w-3xl'>
|
||||
<LessonVideo url={lesson.videoUrl!} title={lesson.title} />
|
||||
{lesson.description && (
|
||||
<p className='mt-5 text-[#999] text-[15px] leading-[160%]'>
|
||||
{lesson.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
56
apps/sim/app/academy/components/lesson-video.tsx
Normal file
56
apps/sim/app/academy/components/lesson-video.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
'use client'
|
||||
|
||||
interface LessonVideoProps {
|
||||
url: string
|
||||
title: string
|
||||
}
|
||||
|
||||
export function LessonVideo({ url, title }: LessonVideoProps) {
|
||||
const embedUrl = resolveEmbedUrl(url)
|
||||
|
||||
if (!embedUrl) {
|
||||
return (
|
||||
<div className='flex aspect-video items-center justify-center rounded-lg bg-[#1A1A1A] text-[#666] text-sm'>
|
||||
Video unavailable
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='aspect-video w-full overflow-hidden rounded-lg bg-black'>
|
||||
<iframe
|
||||
src={embedUrl}
|
||||
title={title}
|
||||
allow='accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture'
|
||||
allowFullScreen
|
||||
className='h-full w-full border-0'
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function resolveEmbedUrl(url: string): string | null {
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
|
||||
if (parsed.hostname === 'youtu.be') {
|
||||
return `https://www.youtube.com/embed${parsed.pathname}`
|
||||
}
|
||||
if (parsed.hostname.includes('youtube.com')) {
|
||||
// Shorts: youtube.com/shorts/VIDEO_ID
|
||||
const shortsMatch = parsed.pathname.match(/^\/shorts\/([^/?]+)/)
|
||||
if (shortsMatch) return `https://www.youtube.com/embed/${shortsMatch[1]}`
|
||||
const v = parsed.searchParams.get('v')
|
||||
if (v) return `https://www.youtube.com/embed/${v}`
|
||||
}
|
||||
|
||||
if (parsed.hostname === 'vimeo.com') {
|
||||
const id = parsed.pathname.replace(/^\//, '')
|
||||
if (id) return `https://player.vimeo.com/video/${id}`
|
||||
}
|
||||
|
||||
return null
|
||||
} catch (_e: unknown) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
432
apps/sim/app/academy/components/sandbox-canvas-provider.tsx
Normal file
432
apps/sim/app/academy/components/sandbox-canvas-provider.tsx
Normal file
@@ -0,0 +1,432 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { Edge } from 'reactflow'
|
||||
import { buildMockExecutionPlan } from '@/lib/academy/mock-execution'
|
||||
import type {
|
||||
ExerciseBlockState,
|
||||
ExerciseDefinition,
|
||||
ExerciseEdgeState,
|
||||
ValidationResult,
|
||||
} from '@/lib/academy/types'
|
||||
import { validateExercise } from '@/lib/academy/validation'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { getEffectiveBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
|
||||
import { GlobalCommandsProvider } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
|
||||
import { SandboxWorkspacePermissionsProvider } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import Workflow from '@/app/workspace/[workspaceId]/w/[workflowId]/workflow'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
import { SandboxBlockConstraintsContext } from '@/hooks/use-sandbox-block-constraints'
|
||||
import { useExecutionStore } from '@/stores/execution/store'
|
||||
import { useTerminalConsoleStore } from '@/stores/terminal/console/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
import type { BlockState, SubBlockState, WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
import { LessonVideo } from './lesson-video'
|
||||
import { ValidationChecklist } from './validation-checklist'
|
||||
|
||||
const logger = createLogger('SandboxCanvasProvider')
|
||||
|
||||
const SANDBOX_WORKSPACE_ID = 'sandbox'
|
||||
|
||||
interface SandboxCanvasProviderProps {
|
||||
/** Unique ID for this exercise instance */
|
||||
exerciseId: string
|
||||
/** Full exercise configuration */
|
||||
exerciseConfig: ExerciseDefinition
|
||||
/**
|
||||
* Called when all validation rules pass for the first time.
|
||||
* Receives the current canvas state so the caller can persist it.
|
||||
*/
|
||||
onComplete?: (blocks: ExerciseBlockState[], edges: ExerciseEdgeState[]) => void
|
||||
/** Optional video URL (YouTube/Vimeo) shown above the checklist — used for mixed lessons */
|
||||
videoUrl?: string
|
||||
/** Optional description shown below the video (or below checklist if no video) */
|
||||
description?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a Zustand-compatible WorkflowState from exercise block/edge definitions.
|
||||
* Looks up each block type in the registry to construct proper sub-block and output maps.
|
||||
*/
|
||||
function buildWorkflowState(
|
||||
initialBlocks: ExerciseBlockState[],
|
||||
initialEdges: ExerciseEdgeState[]
|
||||
): WorkflowState {
|
||||
const blocks: Record<string, BlockState> = {}
|
||||
|
||||
for (const exerciseBlock of initialBlocks) {
|
||||
const config = getBlock(exerciseBlock.type)
|
||||
if (!config) {
|
||||
logger.warn(`Unknown block type "${exerciseBlock.type}" in exercise config`)
|
||||
continue
|
||||
}
|
||||
|
||||
const subBlocks: Record<string, SubBlockState> = {}
|
||||
for (const sb of config.subBlocks ?? []) {
|
||||
const overrideValue = exerciseBlock.subBlocks?.[sb.id]
|
||||
subBlocks[sb.id] = {
|
||||
id: sb.id,
|
||||
type: sb.type,
|
||||
value: (overrideValue !== undefined ? overrideValue : null) as SubBlockState['value'],
|
||||
}
|
||||
}
|
||||
|
||||
const outputs = getEffectiveBlockOutputs(exerciseBlock.type, subBlocks, {
|
||||
triggerMode: false,
|
||||
preferToolOutputs: true,
|
||||
})
|
||||
|
||||
blocks[exerciseBlock.id] = {
|
||||
id: exerciseBlock.id,
|
||||
type: exerciseBlock.type,
|
||||
name: config.name,
|
||||
position: exerciseBlock.position,
|
||||
subBlocks,
|
||||
outputs,
|
||||
enabled: true,
|
||||
horizontalHandles: true,
|
||||
advancedMode: false,
|
||||
triggerMode: false,
|
||||
height: 0,
|
||||
locked: exerciseBlock.locked ?? false,
|
||||
}
|
||||
}
|
||||
|
||||
const edges: Edge[] = initialEdges.map((e) => ({
|
||||
id: e.id,
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
sourceHandle: e.sourceHandle,
|
||||
targetHandle: e.targetHandle,
|
||||
type: 'default',
|
||||
data: {},
|
||||
}))
|
||||
|
||||
return { blocks, edges, loops: {}, parallels: {}, lastSaved: Date.now() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the current canvas state from the workflow store and converts it to
|
||||
* the exercise block/edge format used by the validation engine.
|
||||
*/
|
||||
function readCurrentCanvasState(workflowId: string): {
|
||||
blocks: ExerciseBlockState[]
|
||||
edges: ExerciseEdgeState[]
|
||||
} {
|
||||
const workflowStore = useWorkflowStore.getState()
|
||||
const subBlockStore = useSubBlockStore.getState()
|
||||
|
||||
const blocks: ExerciseBlockState[] = Object.values(workflowStore.blocks).map((block) => {
|
||||
const storedValues = subBlockStore.workflowValues[workflowId] ?? {}
|
||||
const blockValues = storedValues[block.id] ?? {}
|
||||
const subBlocks: Record<string, unknown> = { ...blockValues }
|
||||
return {
|
||||
id: block.id,
|
||||
type: block.type,
|
||||
position: block.position,
|
||||
subBlocks,
|
||||
}
|
||||
})
|
||||
|
||||
const edges: ExerciseEdgeState[] = workflowStore.edges.map((e) => ({
|
||||
id: e.id,
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
sourceHandle: e.sourceHandle ?? undefined,
|
||||
targetHandle: e.targetHandle ?? undefined,
|
||||
}))
|
||||
|
||||
return { blocks, edges }
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps the real Sim canvas in sandbox mode for Sim Academy exercises.
|
||||
*
|
||||
* - Pre-hydrates workflow stores directly (no API calls)
|
||||
* - Provides sandbox permissions (canEdit: true, no workspace dependency)
|
||||
* - Displays a constrained block toolbar and live validation checklist
|
||||
* - Supports mock execution to simulate workflow runs
|
||||
*/
|
||||
export function SandboxCanvasProvider({
|
||||
exerciseId,
|
||||
exerciseConfig,
|
||||
onComplete,
|
||||
videoUrl,
|
||||
description,
|
||||
className,
|
||||
}: SandboxCanvasProviderProps) {
|
||||
const [isReady, setIsReady] = useState(false)
|
||||
const [validationResult, setValidationResult] = useState<ValidationResult>({
|
||||
passed: false,
|
||||
results: [],
|
||||
})
|
||||
const [hintIndex, setHintIndex] = useState(-1)
|
||||
const completedRef = useRef(false)
|
||||
const onCompleteRef = useRef(onComplete)
|
||||
onCompleteRef.current = onComplete
|
||||
const isMockRunningRef = useRef(false)
|
||||
const handleMockRunRef = useRef<() => Promise<void>>(async () => {})
|
||||
|
||||
// Stable exercise ID — used as the workflow ID in the stores
|
||||
const workflowId = `sandbox-${exerciseId}`
|
||||
|
||||
const runValidation = useCallback(() => {
|
||||
const { blocks, edges } = readCurrentCanvasState(workflowId)
|
||||
const result = validateExercise(blocks, edges, exerciseConfig.validationRules)
|
||||
|
||||
setValidationResult((prev) => {
|
||||
if (
|
||||
prev.passed === result.passed &&
|
||||
prev.results.length === result.results.length &&
|
||||
prev.results.every((r, i) => r.passed === result.results[i].passed)
|
||||
) {
|
||||
return prev
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
if (result.passed && !completedRef.current) {
|
||||
completedRef.current = true
|
||||
onCompleteRef.current?.(blocks, edges)
|
||||
}
|
||||
}, [workflowId, exerciseConfig.validationRules])
|
||||
|
||||
useEffect(() => {
|
||||
completedRef.current = false
|
||||
setHintIndex(-1)
|
||||
|
||||
const workflowState = buildWorkflowState(
|
||||
exerciseConfig.initialBlocks ?? [],
|
||||
exerciseConfig.initialEdges ?? []
|
||||
)
|
||||
|
||||
const syntheticMetadata: WorkflowMetadata = {
|
||||
id: workflowId,
|
||||
name: 'Exercise',
|
||||
lastModified: new Date(),
|
||||
createdAt: new Date(),
|
||||
color: '#3972F6',
|
||||
workspaceId: SANDBOX_WORKSPACE_ID,
|
||||
sortOrder: 0,
|
||||
isSandbox: true,
|
||||
}
|
||||
|
||||
useWorkflowStore.getState().replaceWorkflowState(workflowState)
|
||||
useSubBlockStore.getState().initializeFromWorkflow(workflowId, workflowState.blocks)
|
||||
useWorkflowRegistry.setState((state) => ({
|
||||
workflows: { ...state.workflows, [workflowId]: syntheticMetadata },
|
||||
activeWorkflowId: workflowId,
|
||||
hydration: {
|
||||
phase: 'ready',
|
||||
workspaceId: SANDBOX_WORKSPACE_ID,
|
||||
workflowId,
|
||||
requestId: null,
|
||||
error: null,
|
||||
},
|
||||
}))
|
||||
|
||||
logger.info('Sandbox stores hydrated', { workflowId })
|
||||
setIsReady(true)
|
||||
|
||||
// Coalesce rapid store updates so validation runs at most once per animation frame.
|
||||
let rafId: number | null = null
|
||||
const scheduleValidation = () => {
|
||||
if (rafId !== null) return
|
||||
rafId = requestAnimationFrame(() => {
|
||||
rafId = null
|
||||
runValidation()
|
||||
})
|
||||
}
|
||||
|
||||
const unsubWorkflow = useWorkflowStore.subscribe(scheduleValidation)
|
||||
const unsubSubBlock = useSubBlockStore.subscribe(scheduleValidation)
|
||||
|
||||
// When the panel's Run button is clicked, useWorkflowExecution sets isExecuting=true
|
||||
// and returns immediately (no API call). Detect that signal here and run mock execution.
|
||||
const unsubExecution = useExecutionStore.subscribe((state) => {
|
||||
const isExec = state.workflowExecutions.get(workflowId)?.isExecuting
|
||||
if (isExec && !isMockRunningRef.current) {
|
||||
void handleMockRunRef.current()
|
||||
}
|
||||
})
|
||||
|
||||
runValidation()
|
||||
|
||||
return () => {
|
||||
if (rafId !== null) cancelAnimationFrame(rafId)
|
||||
unsubWorkflow()
|
||||
unsubSubBlock()
|
||||
unsubExecution()
|
||||
useWorkflowRegistry.setState((state) => {
|
||||
const { [workflowId]: _removed, ...rest } = state.workflows
|
||||
return {
|
||||
workflows: rest,
|
||||
activeWorkflowId: state.activeWorkflowId === workflowId ? null : state.activeWorkflowId,
|
||||
hydration:
|
||||
state.hydration.workflowId === workflowId
|
||||
? { phase: 'idle', workspaceId: null, workflowId: null, requestId: null, error: null }
|
||||
: state.hydration,
|
||||
}
|
||||
})
|
||||
useWorkflowStore.setState({ blocks: {}, edges: [], loops: {}, parallels: {} })
|
||||
useSubBlockStore.setState((state) => {
|
||||
const { [workflowId]: _removed, ...rest } = state.workflowValues
|
||||
return { workflowValues: rest }
|
||||
})
|
||||
}
|
||||
}, [workflowId, exerciseConfig.initialBlocks, exerciseConfig.initialEdges, runValidation])
|
||||
|
||||
const handleMockRun = useCallback(async () => {
|
||||
if (isMockRunningRef.current) return
|
||||
isMockRunningRef.current = true
|
||||
|
||||
const { setActiveBlocks, setIsExecuting } = useExecutionStore.getState()
|
||||
const { blocks, edges } = readCurrentCanvasState(workflowId)
|
||||
const result = validateExercise(blocks, edges, exerciseConfig.validationRules)
|
||||
setValidationResult(result)
|
||||
if (!result.passed) {
|
||||
isMockRunningRef.current = false
|
||||
setIsExecuting(workflowId, false)
|
||||
return
|
||||
}
|
||||
|
||||
const plan = buildMockExecutionPlan(blocks, edges, exerciseConfig.mockOutputs ?? {})
|
||||
if (plan.length === 0) {
|
||||
isMockRunningRef.current = false
|
||||
setIsExecuting(workflowId, false)
|
||||
return
|
||||
}
|
||||
const { addConsole, clearWorkflowConsole } = useTerminalConsoleStore.getState()
|
||||
const workflowBlocks = useWorkflowStore.getState().blocks
|
||||
|
||||
setIsExecuting(workflowId, true)
|
||||
clearWorkflowConsole(workflowId)
|
||||
useTerminalConsoleStore.setState({ isOpen: true })
|
||||
|
||||
try {
|
||||
for (let i = 0; i < plan.length; i++) {
|
||||
const step = plan[i]
|
||||
setActiveBlocks(workflowId, new Set([step.blockId]))
|
||||
await new Promise((resolve) => setTimeout(resolve, step.delay))
|
||||
addConsole({
|
||||
workflowId,
|
||||
blockId: step.blockId,
|
||||
blockName: workflowBlocks[step.blockId]?.name ?? step.blockType,
|
||||
blockType: step.blockType,
|
||||
executionOrder: i,
|
||||
output: step.output,
|
||||
success: true,
|
||||
durationMs: step.delay,
|
||||
})
|
||||
setActiveBlocks(workflowId, new Set())
|
||||
}
|
||||
} finally {
|
||||
setIsExecuting(workflowId, false)
|
||||
isMockRunningRef.current = false
|
||||
}
|
||||
}, [workflowId, exerciseConfig.validationRules, exerciseConfig.mockOutputs])
|
||||
handleMockRunRef.current = handleMockRun
|
||||
|
||||
const handleShowHint = useCallback(() => {
|
||||
const hints = exerciseConfig.hints ?? []
|
||||
if (hints.length === 0) return
|
||||
setHintIndex((i) => Math.min(i + 1, hints.length - 1))
|
||||
}, [exerciseConfig.hints])
|
||||
|
||||
const handlePrevHint = useCallback(() => {
|
||||
setHintIndex((i) => Math.max(i - 1, 0))
|
||||
}, [])
|
||||
|
||||
if (!isReady) {
|
||||
return (
|
||||
<div className='flex h-full w-full items-center justify-center bg-[#0e0e0e]'>
|
||||
<div className='h-5 w-5 animate-spin rounded-full border-2 border-[#ECECEC] border-t-transparent' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const hints = exerciseConfig.hints ?? []
|
||||
const currentHint = hintIndex >= 0 ? hints[hintIndex] : null
|
||||
|
||||
return (
|
||||
<SandboxBlockConstraintsContext.Provider value={exerciseConfig.availableBlocks}>
|
||||
<GlobalCommandsProvider>
|
||||
<SandboxWorkspacePermissionsProvider>
|
||||
<div className={cn('flex h-full w-full overflow-hidden', className)}>
|
||||
<div className='flex w-56 flex-shrink-0 flex-col gap-3 overflow-y-auto border-[#1F1F1F] border-r bg-[#141414] p-3'>
|
||||
{(videoUrl || description) && (
|
||||
<div className='flex flex-col gap-2'>
|
||||
{videoUrl && <LessonVideo url={videoUrl} title='Lesson video' />}
|
||||
{description && (
|
||||
<p className='text-[#666] text-[11px] leading-relaxed'>{description}</p>
|
||||
)}
|
||||
<div className='border-[#1F1F1F] border-t' />
|
||||
</div>
|
||||
)}
|
||||
{exerciseConfig.instructions && (
|
||||
<p className='text-[#999] text-[11px] leading-relaxed'>
|
||||
{exerciseConfig.instructions}
|
||||
</p>
|
||||
)}
|
||||
<ValidationChecklist
|
||||
results={validationResult.results}
|
||||
allPassed={validationResult.passed}
|
||||
/>
|
||||
|
||||
<div className='mt-auto flex flex-col gap-2'>
|
||||
{currentHint && (
|
||||
<div className='rounded-[6px] border border-[#2A2A2A] bg-[#1A1A1A] px-3 py-2 text-[11px]'>
|
||||
<div className='mb-1 flex items-center justify-between'>
|
||||
<span className='font-[430] text-[#666]'>
|
||||
Hint {hintIndex + 1}/{hints.length}
|
||||
</span>
|
||||
<div className='flex gap-1'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={handlePrevHint}
|
||||
disabled={hintIndex === 0}
|
||||
className='rounded px-1 text-[#666] transition-colors hover:text-[#ECECEC] disabled:opacity-30'
|
||||
aria-label='Previous hint'
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleShowHint}
|
||||
disabled={hintIndex === hints.length - 1}
|
||||
className='rounded px-1 text-[#666] transition-colors hover:text-[#ECECEC] disabled:opacity-30'
|
||||
aria-label='Next hint'
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<span className='text-[#ECECEC]'>{currentHint}</span>
|
||||
</div>
|
||||
)}
|
||||
{hints.length > 0 && hintIndex < 0 && (
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleShowHint}
|
||||
className='w-full rounded-[5px] border border-[#2A2A2A] bg-[#1A1A1A] px-3 py-1.5 text-[#999] text-[12px] transition-colors hover:border-[#3A3A3A] hover:text-[#ECECEC]'
|
||||
>
|
||||
Show hint
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='relative flex-1 overflow-hidden'>
|
||||
<Workflow workspaceId={SANDBOX_WORKSPACE_ID} workflowId={workflowId} sandbox />
|
||||
</div>
|
||||
</div>
|
||||
</SandboxWorkspacePermissionsProvider>
|
||||
</GlobalCommandsProvider>
|
||||
</SandboxBlockConstraintsContext.Provider>
|
||||
)
|
||||
}
|
||||
50
apps/sim/app/academy/components/validation-checklist.tsx
Normal file
50
apps/sim/app/academy/components/validation-checklist.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
'use client'
|
||||
|
||||
import { CheckCircle2, Circle } from 'lucide-react'
|
||||
import type { ValidationRuleResult } from '@/lib/academy/types'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
|
||||
interface ValidationChecklistProps {
|
||||
results: ValidationRuleResult[]
|
||||
allPassed: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Checklist showing exercise validation rules and their current pass/fail state.
|
||||
* Rendered inside the exercise sidebar, not as a canvas overlay.
|
||||
*/
|
||||
export function ValidationChecklist({ results, allPassed }: ValidationChecklistProps) {
|
||||
if (results.length === 0) return null
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className='mb-2.5 flex items-center gap-1.5'>
|
||||
<span className='font-[430] text-[#ECECEC] text-[12px]'>Checklist</span>
|
||||
{allPassed && (
|
||||
<span className='ml-auto rounded-full bg-[#4CAF50]/15 px-2 py-0.5 font-[430] text-[#4CAF50] text-[10px]'>
|
||||
Complete
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<ul className='space-y-1.5'>
|
||||
{results.map((result, i) => (
|
||||
<li key={i} className='flex items-start gap-2'>
|
||||
{result.passed ? (
|
||||
<CheckCircle2 className='mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-[#4CAF50]' />
|
||||
) : (
|
||||
<Circle className='mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-[#444]' />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
'text-[11px] leading-tight',
|
||||
result.passed ? 'text-[#555] line-through' : 'text-[#ECECEC]'
|
||||
)}
|
||||
>
|
||||
{result.message}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
25
apps/sim/app/academy/layout.tsx
Normal file
25
apps/sim/app/academy/layout.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import type React from 'react'
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
absolute: 'Sim Academy',
|
||||
template: '%s | Sim Academy',
|
||||
},
|
||||
description:
|
||||
'Become a certified Sim partner — learn to build, integrate, and deploy AI workflows.',
|
||||
metadataBase: new URL('https://sim.ai'),
|
||||
openGraph: {
|
||||
title: 'Sim Academy',
|
||||
description: 'Become a certified Sim partner.',
|
||||
type: 'website',
|
||||
},
|
||||
}
|
||||
|
||||
export default function AcademyLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className='min-h-screen bg-[#1C1C1C] font-[430] font-season text-[#ECECEC]'>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
215
apps/sim/app/api/academy/certificates/route.ts
Normal file
215
apps/sim/app/api/academy/certificates/route.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import { db } from '@sim/db'
|
||||
import { academyCertificate, user } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getCourseById } from '@/lib/academy/content'
|
||||
import type { CertificateMetadata } from '@/lib/academy/types'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import type { TokenBucketConfig } from '@/lib/core/rate-limiter'
|
||||
import { RateLimiter } from '@/lib/core/rate-limiter'
|
||||
|
||||
const logger = createLogger('AcademyCertificatesAPI')
|
||||
|
||||
const rateLimiter = new RateLimiter()
|
||||
const CERT_RATE_LIMIT: TokenBucketConfig = {
|
||||
maxTokens: 5,
|
||||
refillRate: 1,
|
||||
refillIntervalMs: 60 * 60_000, // 1 per hour refill
|
||||
}
|
||||
|
||||
const IssueCertificateSchema = z.object({
|
||||
courseId: z.string(),
|
||||
completedLessonIds: z.array(z.string()),
|
||||
})
|
||||
|
||||
/**
|
||||
* POST /api/academy/certificates
|
||||
* Issues a certificate for the given course after verifying all lessons are completed.
|
||||
* Completion is client-attested: the client sends completed lesson IDs and the server
|
||||
* validates them against the full lesson list for the course.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { allowed } = await rateLimiter.checkRateLimitDirect(
|
||||
`academy:cert:${session.user.id}`,
|
||||
CERT_RATE_LIMIT
|
||||
)
|
||||
if (!allowed) {
|
||||
return NextResponse.json({ error: 'Too many requests' }, { status: 429 })
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const parsed = IssueCertificateSchema.safeParse(body)
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 })
|
||||
}
|
||||
|
||||
const { courseId, completedLessonIds } = parsed.data
|
||||
|
||||
const course = getCourseById(courseId)
|
||||
if (!course) {
|
||||
return NextResponse.json({ error: 'Course not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Verify all lessons in the course are reported as completed
|
||||
const allLessonIds = course.modules.flatMap((m) => m.lessons.map((l) => l.id))
|
||||
const completedSet = new Set(completedLessonIds)
|
||||
const incomplete = allLessonIds.filter((id) => !completedSet.has(id))
|
||||
if (incomplete.length > 0) {
|
||||
return NextResponse.json({ error: 'Course not fully completed', incomplete }, { status: 422 })
|
||||
}
|
||||
|
||||
const [existing, learner] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(academyCertificate)
|
||||
.where(
|
||||
and(
|
||||
eq(academyCertificate.userId, session.user.id),
|
||||
eq(academyCertificate.courseId, courseId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null),
|
||||
db
|
||||
.select({ name: user.name })
|
||||
.from(user)
|
||||
.where(eq(user.id, session.user.id))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null),
|
||||
])
|
||||
|
||||
if (existing) {
|
||||
if (existing.status === 'active') {
|
||||
return NextResponse.json({ certificate: existing })
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: 'A certificate for this course already exists but is not active.' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
const certificateNumber = generateCertificateNumber()
|
||||
const metadata: CertificateMetadata = {
|
||||
recipientName: learner?.name ?? session.user.name ?? 'Partner',
|
||||
courseTitle: course.title,
|
||||
}
|
||||
|
||||
const [certificate] = await db
|
||||
.insert(academyCertificate)
|
||||
.values({
|
||||
id: nanoid(),
|
||||
userId: session.user.id,
|
||||
courseId,
|
||||
status: 'active',
|
||||
certificateNumber,
|
||||
metadata,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning()
|
||||
|
||||
if (!certificate) {
|
||||
const [race] = await db
|
||||
.select()
|
||||
.from(academyCertificate)
|
||||
.where(
|
||||
and(
|
||||
eq(academyCertificate.userId, session.user.id),
|
||||
eq(academyCertificate.courseId, courseId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
if (race?.status === 'active') {
|
||||
return NextResponse.json({ certificate: race })
|
||||
}
|
||||
if (race) {
|
||||
return NextResponse.json(
|
||||
{ error: 'A certificate for this course already exists but is not active.' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
return NextResponse.json({ error: 'Failed to issue certificate' }, { status: 500 })
|
||||
}
|
||||
|
||||
logger.info('Certificate issued', {
|
||||
userId: session.user.id,
|
||||
courseId,
|
||||
certificateNumber,
|
||||
})
|
||||
|
||||
return NextResponse.json({ certificate }, { status: 201 })
|
||||
} catch (error) {
|
||||
logger.error('Failed to issue certificate', { error })
|
||||
return NextResponse.json({ error: 'Failed to issue certificate' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/academy/certificates?certificateNumber=SIM-2026-00042
|
||||
* Public endpoint for verifying a certificate by its number.
|
||||
*
|
||||
* GET /api/academy/certificates?courseId=...
|
||||
* Authenticated endpoint for looking up the current user's certificate for a course.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(req.url)
|
||||
const certificateNumber = searchParams.get('certificateNumber')
|
||||
const courseId = searchParams.get('courseId')
|
||||
|
||||
if (certificateNumber) {
|
||||
const [certificate] = await db
|
||||
.select()
|
||||
.from(academyCertificate)
|
||||
.where(eq(academyCertificate.certificateNumber, certificateNumber))
|
||||
.limit(1)
|
||||
|
||||
if (!certificate) {
|
||||
return NextResponse.json({ error: 'Certificate not found' }, { status: 404 })
|
||||
}
|
||||
return NextResponse.json({ certificate })
|
||||
}
|
||||
|
||||
if (courseId) {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const [certificate] = await db
|
||||
.select()
|
||||
.from(academyCertificate)
|
||||
.where(
|
||||
and(
|
||||
eq(academyCertificate.userId, session.user.id),
|
||||
eq(academyCertificate.courseId, courseId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
return NextResponse.json({ certificate: certificate ?? null })
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'certificateNumber or courseId query parameter is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error('Failed to verify certificate', { error })
|
||||
return NextResponse.json({ error: 'Failed to verify certificate' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/** Generates a human-readable certificate number, e.g. SIM-2026-A3K9XZ2P */
|
||||
function generateCertificateNumber(): string {
|
||||
const year = new Date().getFullYear()
|
||||
return `SIM-${year}-${nanoid(8).toUpperCase()}`
|
||||
}
|
||||
@@ -8,7 +8,9 @@ const baseUrl = getBaseUrl()
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(baseUrl),
|
||||
title: 'Sim — Build AI Agents & Run Your Agentic Workforce',
|
||||
title: {
|
||||
absolute: 'Sim — Build AI Agents & Run Your Agentic Workforce',
|
||||
},
|
||||
description:
|
||||
'Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to orchestrate agentic workflows.',
|
||||
keywords:
|
||||
|
||||
@@ -203,3 +203,35 @@ export function useUserPermissionsContext(): WorkspaceUserPermissions & {
|
||||
const { userPermissions } = useWorkspacePermissionsContext()
|
||||
return userPermissions
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight permissions provider for sandbox/academy contexts.
|
||||
* Grants full edit access without any API calls or workspace dependencies.
|
||||
*/
|
||||
export function SandboxWorkspacePermissionsProvider({ children }: { children: React.ReactNode }) {
|
||||
const sandboxPermissions = useMemo(
|
||||
(): WorkspacePermissionsContextType => ({
|
||||
workspacePermissions: null,
|
||||
permissionsLoading: false,
|
||||
permissionsError: null,
|
||||
updatePermissions: () => {},
|
||||
refetchPermissions: async () => {},
|
||||
userPermissions: {
|
||||
canRead: true,
|
||||
canEdit: true,
|
||||
canAdmin: false,
|
||||
userPermissions: 'write',
|
||||
isLoading: false,
|
||||
error: null,
|
||||
isOfflineMode: false,
|
||||
},
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
||||
return (
|
||||
<WorkspacePermissionsContext.Provider value={sandboxPermissions}>
|
||||
{children}
|
||||
</WorkspacePermissionsContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import { LoopTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/component
|
||||
import { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-config'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { usePermissionConfig } from '@/hooks/use-permission-config'
|
||||
import { useSandboxBlockConstraints } from '@/hooks/use-sandbox-block-constraints'
|
||||
import { useToolbarStore } from '@/stores/panel'
|
||||
|
||||
interface BlockItem {
|
||||
@@ -348,12 +349,20 @@ export const Toolbar = memo(
|
||||
})
|
||||
|
||||
const { filterBlocks } = usePermissionConfig()
|
||||
const sandboxAllowedBlocks = useSandboxBlockConstraints()
|
||||
|
||||
const allTriggers = getTriggers()
|
||||
const allBlocks = getBlocks()
|
||||
|
||||
const blocks = useMemo(() => filterBlocks(allBlocks), [filterBlocks, allBlocks])
|
||||
const triggers = useMemo(() => filterBlocks(allTriggers), [filterBlocks, allTriggers])
|
||||
const blocks = useMemo(() => {
|
||||
const permitted = filterBlocks(allBlocks)
|
||||
if (sandboxAllowedBlocks === null) return permitted
|
||||
return permitted.filter((b) => sandboxAllowedBlocks.includes(b.type))
|
||||
}, [filterBlocks, allBlocks, sandboxAllowedBlocks])
|
||||
const triggers = useMemo(() => {
|
||||
if (sandboxAllowedBlocks !== null) return []
|
||||
return filterBlocks(allTriggers)
|
||||
}, [filterBlocks, allTriggers, sandboxAllowedBlocks])
|
||||
|
||||
const isTriggersAtMinimum = toolbarTriggersHeight <= TRIGGERS_MIN_THRESHOLD
|
||||
|
||||
|
||||
@@ -89,10 +89,15 @@ const logger = createLogger('Panel')
|
||||
*
|
||||
* @returns Panel on the right side of the workflow
|
||||
*/
|
||||
export const Panel = memo(function Panel() {
|
||||
interface PanelProps {
|
||||
/** Override workspaceId when rendered outside a workspace route (e.g. sandbox mode) */
|
||||
workspaceId?: string
|
||||
}
|
||||
|
||||
export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: PanelProps = {}) {
|
||||
const router = useRouter()
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
const workspaceId = propWorkspaceId ?? (params.workspaceId as string)
|
||||
|
||||
const panelRef = useRef<HTMLElement>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
@@ -14,6 +14,8 @@ export interface WorkflowBlockProps {
|
||||
isPreviewSelected?: boolean
|
||||
/** Whether this block is rendered inside an embedded (read-only) workflow view */
|
||||
isEmbedded?: boolean
|
||||
/** Whether this block is rendered inside a sandbox (academy exercise) view with no workspace API calls */
|
||||
isSandbox?: boolean
|
||||
subBlockValues?: Record<string, any>
|
||||
blockState?: any
|
||||
}
|
||||
|
||||
@@ -855,13 +855,14 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
data,
|
||||
selected,
|
||||
}: NodeProps<WorkflowBlockProps>) {
|
||||
const { type, config, name, isPending } = data
|
||||
const { type, config, name, isPending, isSandbox } = data
|
||||
|
||||
const contentRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const params = useParams()
|
||||
const currentWorkflowId = params.workflowId as string
|
||||
const workspaceId = params.workspaceId as string
|
||||
// In sandbox mode pass empty strings so all workspace-scoped queries are disabled
|
||||
const currentWorkflowId = isSandbox ? '' : (params.workflowId as string)
|
||||
const workspaceId = isSandbox ? '' : (params.workspaceId as string)
|
||||
|
||||
const {
|
||||
currentWorkflow,
|
||||
|
||||
@@ -380,6 +380,13 @@ export function useWorkflowExecution() {
|
||||
async (workflowInput?: any, enableDebug = false) => {
|
||||
if (!activeWorkflowId) return
|
||||
|
||||
// Sandbox exercises have no real workflow — signal the SandboxCanvasProvider
|
||||
// to run mock execution by setting isExecuting, then bail out immediately.
|
||||
if (workflows[activeWorkflowId]?.isSandbox) {
|
||||
setIsExecuting(activeWorkflowId, true)
|
||||
return
|
||||
}
|
||||
|
||||
// Get workspaceId from workflow metadata
|
||||
const workspaceId = workflows[activeWorkflowId]?.workspaceId
|
||||
|
||||
|
||||
@@ -231,6 +231,8 @@ interface WorkflowContentProps {
|
||||
workspaceId?: string
|
||||
workflowId?: string
|
||||
embedded?: boolean
|
||||
/** Sandbox mode: full editing enabled but no workspace API calls (used by Sim Academy). */
|
||||
sandbox?: boolean
|
||||
}
|
||||
|
||||
const WorkflowContent = React.memo(
|
||||
@@ -238,6 +240,7 @@ const WorkflowContent = React.memo(
|
||||
workspaceId: propWorkspaceId,
|
||||
workflowId: propWorkflowId,
|
||||
embedded,
|
||||
sandbox,
|
||||
}: WorkflowContentProps = {}) => {
|
||||
const [isCanvasReady, setIsCanvasReady] = useState(false)
|
||||
const [potentialParentId, setPotentialParentId] = useState<string | null>(null)
|
||||
@@ -327,7 +330,7 @@ const WorkflowContent = React.memo(
|
||||
const snapToGridSize = useSnapToGridSize()
|
||||
const snapToGrid = snapToGridSize > 0
|
||||
|
||||
const isAutoConnectEnabled = useAutoConnect()
|
||||
const isAutoConnectEnabled = useAutoConnect() && !sandbox
|
||||
const autoConnectRef = useRef(isAutoConnectEnabled)
|
||||
autoConnectRef.current = isAutoConnectEnabled
|
||||
|
||||
@@ -1236,7 +1239,7 @@ const WorkflowContent = React.memo(
|
||||
clearLockNotification()
|
||||
}
|
||||
|
||||
if (allBlocksLocked) {
|
||||
if (allBlocksLocked && !sandbox) {
|
||||
if (lockNotificationIdRef.current) return
|
||||
|
||||
const isAdmin = effectivePermissions.canAdmin
|
||||
@@ -2192,6 +2195,9 @@ const WorkflowContent = React.memo(
|
||||
const currentWorkflowExists = Boolean(workflows[workflowIdParam])
|
||||
|
||||
useEffect(() => {
|
||||
// In sandbox mode the stores are pre-hydrated externally; skip the API load.
|
||||
if (sandbox) return
|
||||
|
||||
const currentId = workflowIdParam
|
||||
const currentWorkspaceHydration = hydration.workspaceId
|
||||
|
||||
@@ -2260,13 +2266,13 @@ const WorkflowContent = React.memo(
|
||||
workspaceId,
|
||||
])
|
||||
|
||||
useWorkspaceEnvironment(workspaceId)
|
||||
useWorkspaceEnvironment(sandbox ? '' : workspaceId)
|
||||
|
||||
const workflowCount = useMemo(() => Object.keys(workflows).length, [workflows])
|
||||
|
||||
/** Handles navigation validation and redirects for invalid workflow IDs. */
|
||||
useEffect(() => {
|
||||
if (embedded) return
|
||||
if (embedded || sandbox) return
|
||||
|
||||
// Wait for metadata to finish loading before making navigation decisions
|
||||
if (hydration.phase === 'metadata-loading' || hydration.phase === 'idle') {
|
||||
@@ -2451,6 +2457,7 @@ const WorkflowContent = React.memo(
|
||||
isActive,
|
||||
isPending,
|
||||
...(embedded && { isEmbedded: true }),
|
||||
...(sandbox && { isSandbox: true }),
|
||||
},
|
||||
// Include dynamic dimensions for container resizing calculations (must match rendered size)
|
||||
// Both note and workflow blocks calculate dimensions deterministically via useBlockDimensions
|
||||
@@ -2463,7 +2470,16 @@ const WorkflowContent = React.memo(
|
||||
})
|
||||
|
||||
return nodeArray
|
||||
}, [blocksStructureHash, blocks, activeBlockIds, pendingBlocks, isDebugging, getBlockConfig])
|
||||
}, [
|
||||
blocksStructureHash,
|
||||
blocks,
|
||||
activeBlockIds,
|
||||
pendingBlocks,
|
||||
isDebugging,
|
||||
getBlockConfig,
|
||||
sandbox,
|
||||
embedded,
|
||||
])
|
||||
|
||||
// Local state for nodes - allows smooth drag without store updates on every frame
|
||||
const [displayNodes, setDisplayNodes] = useState<Node[]>([])
|
||||
@@ -4096,9 +4112,9 @@ const WorkflowContent = React.memo(
|
||||
<Terminal />
|
||||
</div>
|
||||
|
||||
{!embedded && <Panel />}
|
||||
{(!embedded || sandbox) && <Panel workspaceId={sandbox ? workspaceId : undefined} />}
|
||||
|
||||
{!embedded && oauthModal && (
|
||||
{!embedded && !sandbox && oauthModal && (
|
||||
<Suspense fallback={null}>
|
||||
<LazyOAuthRequiredModal
|
||||
isOpen={true}
|
||||
@@ -4122,18 +4138,27 @@ interface WorkflowProps {
|
||||
workspaceId?: string
|
||||
workflowId?: string
|
||||
embedded?: boolean
|
||||
/** Sandbox mode: full editing enabled but no workspace API calls (used by Sim Academy). */
|
||||
sandbox?: boolean
|
||||
}
|
||||
|
||||
/** Workflow page with ReactFlowProvider and error boundary wrapper. */
|
||||
const Workflow = React.memo(({ workspaceId, workflowId, embedded }: WorkflowProps = {}) => {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<ErrorBoundary>
|
||||
<WorkflowContent workspaceId={workspaceId} workflowId={workflowId} embedded={embedded} />
|
||||
</ErrorBoundary>
|
||||
</ReactFlowProvider>
|
||||
)
|
||||
})
|
||||
const Workflow = React.memo(
|
||||
({ workspaceId, workflowId, embedded, sandbox }: WorkflowProps = {}) => {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<ErrorBoundary>
|
||||
<WorkflowContent
|
||||
workspaceId={workspaceId}
|
||||
workflowId={workflowId}
|
||||
embedded={embedded}
|
||||
sandbox={sandbox}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</ReactFlowProvider>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Workflow.displayName = 'Workflow'
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "emir",
|
||||
"name": "Emir Karabeg",
|
||||
"url": "https://x.com/karabegemir",
|
||||
"xHandle": "karabegemir",
|
||||
"url": "https://x.com/emkara",
|
||||
"xHandle": "emkara",
|
||||
"avatarUrl": "/blog/authors/emir.jpg"
|
||||
}
|
||||
|
||||
44
apps/sim/hooks/queries/academy.ts
Normal file
44
apps/sim/hooks/queries/academy.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import type { AcademyCertificate } from '@/lib/academy/types'
|
||||
import { fetchJson } from '@/hooks/selectors/helpers'
|
||||
|
||||
export const academyKeys = {
|
||||
all: ['academy'] as const,
|
||||
certificates: () => [...academyKeys.all, 'certificate'] as const,
|
||||
certificate: (courseId: string) => [...academyKeys.certificates(), courseId] as const,
|
||||
}
|
||||
|
||||
async function fetchCourseCertificate(
|
||||
courseId: string,
|
||||
signal: AbortSignal
|
||||
): Promise<AcademyCertificate | null> {
|
||||
const data = await fetchJson<{ certificate: AcademyCertificate | null }>(
|
||||
`/api/academy/certificates?courseId=${encodeURIComponent(courseId)}`,
|
||||
{ signal }
|
||||
)
|
||||
return data.certificate
|
||||
}
|
||||
|
||||
export function useCourseCertificate(courseId?: string) {
|
||||
return useQuery({
|
||||
queryKey: academyKeys.certificate(courseId ?? ''),
|
||||
queryFn: ({ signal }) => fetchCourseCertificate(courseId as string, signal),
|
||||
enabled: Boolean(courseId),
|
||||
staleTime: 60 * 1000,
|
||||
})
|
||||
}
|
||||
|
||||
export function useIssueCertificate() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (variables: { courseId: string; completedLessonIds: string[] }) =>
|
||||
fetchJson<{ certificate: AcademyCertificate }>('/api/academy/certificates', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(variables),
|
||||
}).then((d) => d.certificate),
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: academyKeys.certificate(variables.courseId) })
|
||||
},
|
||||
})
|
||||
}
|
||||
13
apps/sim/hooks/use-sandbox-block-constraints.ts
Normal file
13
apps/sim/hooks/use-sandbox-block-constraints.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { createContext, useContext } from 'react'
|
||||
|
||||
/**
|
||||
* Provides the list of block types the learner is allowed to add in a sandbox exercise.
|
||||
* Null means no constraint (all blocks allowed — the default outside sandbox mode).
|
||||
* An empty array means no blocks may be added (configure/connect pre-placed blocks only).
|
||||
*/
|
||||
export const SandboxBlockConstraintsContext = createContext<string[] | null>(null)
|
||||
|
||||
/** Returns the sandbox-allowed block types, or null if not in a sandbox context. */
|
||||
export function useSandboxBlockConstraints(): string[] | null {
|
||||
return useContext(SandboxBlockConstraintsContext)
|
||||
}
|
||||
729
apps/sim/lib/academy/content/courses/sim-foundations.ts
Normal file
729
apps/sim/lib/academy/content/courses/sim-foundations.ts
Normal file
@@ -0,0 +1,729 @@
|
||||
import type { Course } from '@/lib/academy/types'
|
||||
|
||||
/**
|
||||
* Sim Foundations — the introductory partner certification course.
|
||||
*
|
||||
* IDs must never change after a learner has started the course.
|
||||
* Lesson IDs are used as localStorage keys for completion tracking.
|
||||
* The course ID is stored on the certificate record.
|
||||
*/
|
||||
export const simFoundations: Course = {
|
||||
id: 'sim-foundations',
|
||||
slug: 'sim-foundations',
|
||||
title: 'Sim Foundations',
|
||||
description:
|
||||
'Master the core building blocks of Sim — the canvas, agents, data flow, control logic, and deployment — through hands-on interactive exercises on the real canvas.',
|
||||
estimatedMinutes: 75,
|
||||
modules: [
|
||||
{
|
||||
id: 'sim-foundations-m1',
|
||||
title: 'The Canvas',
|
||||
description: 'Get oriented with the Sim canvas and understand how workflows are structured.',
|
||||
lessons: [
|
||||
{
|
||||
id: 'sim-foundations-m1-l1',
|
||||
slug: 'what-is-sim',
|
||||
title: 'What is Sim?',
|
||||
lessonType: 'video',
|
||||
description:
|
||||
'A high-level look at what Sim is, the problems it solves, and what a real workflow looks like running end-to-end.',
|
||||
videoUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
||||
videoDurationSeconds: 180,
|
||||
},
|
||||
{
|
||||
id: 'sim-foundations-m1-l2',
|
||||
slug: 'canvas-tour',
|
||||
title: 'The Canvas Tour',
|
||||
lessonType: 'video',
|
||||
description:
|
||||
'A guided tour of the canvas: placing blocks, connecting them, using the panel, running workflows, and essential keyboard shortcuts.',
|
||||
videoUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
||||
videoDurationSeconds: 240,
|
||||
},
|
||||
{
|
||||
id: 'sim-foundations-m1-l3',
|
||||
slug: 'your-first-workflow',
|
||||
title: 'Your First Workflow',
|
||||
lessonType: 'exercise',
|
||||
description: 'Place an Agent block on the canvas and wire it to the Starter.',
|
||||
exerciseConfig: {
|
||||
instructions:
|
||||
"Every workflow starts with a Starter block. Drag an Agent block from the toolbar onto the canvas, then connect the Starter's output handle to the Agent's input handle. Once connected, click Run to see it execute.",
|
||||
availableBlocks: ['agent'],
|
||||
initialBlocks: [
|
||||
{
|
||||
id: 'starter-1',
|
||||
type: 'starter',
|
||||
position: { x: 120, y: 220 },
|
||||
locked: true,
|
||||
},
|
||||
],
|
||||
validationRules: [
|
||||
{
|
||||
type: 'block_exists',
|
||||
blockType: 'agent',
|
||||
label: 'Add an Agent block to the canvas',
|
||||
},
|
||||
{
|
||||
type: 'edge_exists',
|
||||
sourceType: 'starter',
|
||||
targetType: 'agent',
|
||||
label: 'Connect the Starter to the Agent',
|
||||
},
|
||||
],
|
||||
hints: [
|
||||
'Find the Agent block in the toolbar on the right side of the canvas.',
|
||||
"Hover over the Starter block's right edge to reveal its output handle, then drag to the Agent block.",
|
||||
],
|
||||
mockOutputs: {
|
||||
starter: { response: { result: 'Workflow started' }, delay: 200 },
|
||||
agent: {
|
||||
response: { content: "Hello! I'm your first Sim agent. How can I help?" },
|
||||
delay: 1200,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'sim-foundations-m1-l4',
|
||||
slug: 'canvas-concepts',
|
||||
title: 'Canvas Concepts',
|
||||
lessonType: 'quiz',
|
||||
description: 'Check your understanding of the canvas before moving on.',
|
||||
quizConfig: {
|
||||
passingScore: 75,
|
||||
questions: [
|
||||
{
|
||||
type: 'multiple_choice',
|
||||
question: 'What is the role of the Starter block in a workflow?',
|
||||
options: [
|
||||
'It stores data between workflow runs',
|
||||
'It defines the trigger and initial input for a workflow',
|
||||
'It connects to external APIs',
|
||||
'It runs JavaScript code',
|
||||
],
|
||||
correctIndex: 1,
|
||||
explanation:
|
||||
'The Starter block is always the entry point. It defines how the workflow is triggered — manually, via API, via chat, or on a schedule — and what data is passed in as input.',
|
||||
},
|
||||
{
|
||||
type: 'true_false',
|
||||
question:
|
||||
'A single block can have multiple outgoing connections to different blocks.',
|
||||
correctAnswer: true,
|
||||
explanation:
|
||||
'Yes — blocks can fan out to multiple downstream blocks, which then run in parallel. This is how you split execution into multiple concurrent branches.',
|
||||
},
|
||||
{
|
||||
type: 'multiple_choice',
|
||||
question: 'What does connecting two blocks with an edge do?',
|
||||
options: [
|
||||
'The second block runs immediately, regardless of the first',
|
||||
"Data flows from the source block's output to the target block's input",
|
||||
'Both blocks are merged into one',
|
||||
'The first block is disabled',
|
||||
],
|
||||
correctIndex: 1,
|
||||
explanation:
|
||||
"Edges define data flow. When a block completes, its output is passed downstream to every connected block. The target block won't start until its source has finished.",
|
||||
},
|
||||
{
|
||||
type: 'multiple_choice',
|
||||
question: 'Which keyboard shortcut copies a selected block on the canvas?',
|
||||
options: [
|
||||
'Ctrl/Cmd + D',
|
||||
'Ctrl/Cmd + C, then Ctrl/Cmd + V',
|
||||
'Ctrl/Cmd + X',
|
||||
'Alt + drag',
|
||||
],
|
||||
correctIndex: 1,
|
||||
explanation:
|
||||
'Ctrl/Cmd + C copies the selected block and Ctrl/Cmd + V pastes it. These are the standard copy-paste shortcuts — use them to quickly duplicate blocks on the canvas.',
|
||||
},
|
||||
{
|
||||
type: 'multiple_choice',
|
||||
question: 'What does the terminal console at the bottom of the canvas show?',
|
||||
options: [
|
||||
'The source code of each block',
|
||||
'Block-by-block execution output, including results and errors',
|
||||
'A list of available integrations',
|
||||
'The workflow deployment settings',
|
||||
],
|
||||
correctIndex: 1,
|
||||
explanation:
|
||||
"The terminal console shows you what happened at each block after a run: outputs, tool calls, token counts, and errors. It's your primary debugging tool.",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'sim-foundations-m2',
|
||||
title: 'The Agent Block',
|
||||
description:
|
||||
'Deeply understand the core block in Sim — how agents work, how to attach tools, and how to enforce structured output.',
|
||||
lessons: [
|
||||
{
|
||||
id: 'sim-foundations-m2-l1',
|
||||
slug: 'how-agents-work',
|
||||
title: 'How Agents Work',
|
||||
lessonType: 'video',
|
||||
description:
|
||||
'The Agent block as an LLM with a job: system prompts, model selection, the tool call loop, and what the output contains.',
|
||||
videoUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
||||
videoDurationSeconds: 300,
|
||||
},
|
||||
{
|
||||
id: 'sim-foundations-m2-l2',
|
||||
slug: 'tools-and-integrations',
|
||||
title: 'Tools & Integrations',
|
||||
lessonType: 'video',
|
||||
description:
|
||||
'How to attach tools to an Agent, how the model decides when to call them, and how to connect credentials.',
|
||||
videoUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
||||
videoDurationSeconds: 240,
|
||||
},
|
||||
{
|
||||
id: 'sim-foundations-m2-l3',
|
||||
slug: 'structured-output',
|
||||
title: 'Structured Output',
|
||||
lessonType: 'video',
|
||||
description:
|
||||
'How to use Response Format to enforce a JSON schema on agent output, and how to reference individual fields downstream.',
|
||||
videoUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
||||
videoDurationSeconds: 240,
|
||||
},
|
||||
{
|
||||
id: 'sim-foundations-m2-l4',
|
||||
slug: 'configure-agent',
|
||||
title: 'Configure an Agent',
|
||||
lessonType: 'exercise',
|
||||
description:
|
||||
'The Agent block is already wired up. Open its panel and add a system prompt in the Messages field.',
|
||||
exerciseConfig: {
|
||||
instructions:
|
||||
'The Agent block is already connected to the Starter. Click it to open the panel on the right, then add a system message in the Messages field — try something like "You are a helpful assistant that answers concisely." Once set, click Run.',
|
||||
availableBlocks: [],
|
||||
initialBlocks: [
|
||||
{
|
||||
id: 'starter-1',
|
||||
type: 'starter',
|
||||
position: { x: 80, y: 220 },
|
||||
locked: true,
|
||||
},
|
||||
{
|
||||
id: 'agent-1',
|
||||
type: 'agent',
|
||||
position: { x: 380, y: 220 },
|
||||
locked: false,
|
||||
subBlocks: { model: 'claude-sonnet-4-5' },
|
||||
},
|
||||
],
|
||||
initialEdges: [
|
||||
{
|
||||
id: 'e-starter-agent',
|
||||
source: 'starter-1',
|
||||
target: 'agent-1',
|
||||
sourceHandle: 'source',
|
||||
targetHandle: 'target',
|
||||
},
|
||||
],
|
||||
validationRules: [
|
||||
{
|
||||
type: 'block_configured',
|
||||
blockType: 'agent',
|
||||
subBlockId: 'messages',
|
||||
valueNotEmpty: true,
|
||||
label: 'Add a system prompt in the Messages field',
|
||||
},
|
||||
],
|
||||
hints: [
|
||||
'Click the Agent block to select it — the configuration panel opens on the right.',
|
||||
'In the Messages section, add a system message. Try: "You are a helpful assistant that answers concisely."',
|
||||
],
|
||||
mockOutputs: {
|
||||
starter: { response: { result: 'Workflow started' }, delay: 200 },
|
||||
'agent-1': {
|
||||
response: {
|
||||
content: "Hello! I'm your configured Sim agent. How can I help you today?",
|
||||
},
|
||||
delay: 1800,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'sim-foundations-m2-l5',
|
||||
slug: 'agent-mastery-check',
|
||||
title: 'Agent Mastery Check',
|
||||
lessonType: 'quiz',
|
||||
quizConfig: {
|
||||
passingScore: 80,
|
||||
questions: [
|
||||
{
|
||||
type: 'multiple_choice',
|
||||
question: 'What is the primary purpose of a system prompt on an Agent block?',
|
||||
options: [
|
||||
"It sets the model's temperature and token limit",
|
||||
"It defines the agent's persona, instructions, and constraints",
|
||||
'It controls which tools the agent is allowed to call',
|
||||
'It specifies the JSON schema for the response',
|
||||
],
|
||||
correctIndex: 1,
|
||||
explanation:
|
||||
"The system prompt gives the model its identity and instructions — its role, tone, what it should and shouldn't do. Temperature, tools, and response format are configured separately.",
|
||||
},
|
||||
{
|
||||
type: 'true_false',
|
||||
question:
|
||||
'An Agent can call multiple tools in sequence during a single workflow run before producing its final answer.',
|
||||
correctAnswer: true,
|
||||
explanation:
|
||||
'Agents run a tool call loop: the model calls a tool, receives the result, decides if it needs more information, and can call another tool before producing its final answer.',
|
||||
},
|
||||
{
|
||||
type: 'multiple_choice',
|
||||
question:
|
||||
'You define a Response Format with a field called "sentiment" on an Agent block. What happens to that field?',
|
||||
options: [
|
||||
"It's only available inside the Agent block and can't be used downstream",
|
||||
'It becomes a named output on the Agent block, selectable via the reference picker in any downstream block',
|
||||
'It must be extracted manually using a Function block',
|
||||
"It's merged into the agent's plain-text content output",
|
||||
],
|
||||
correctIndex: 1,
|
||||
explanation:
|
||||
"Fields defined in a Response Format become individual outputs on the Agent block — instead of just a single 'content' string, you get 'sentiment', 'score', etc. as separate values. In any downstream block, type < to open the reference picker and select exactly the field you need.",
|
||||
},
|
||||
{
|
||||
type: 'multiple_choice',
|
||||
question: 'What does setting a tool\'s usage mode to "forced" do?',
|
||||
options: [
|
||||
'The tool runs before the agent sees the input',
|
||||
'The model must call that tool at least once during the run',
|
||||
'The tool is required to return a valid response or the workflow fails',
|
||||
'The tool is hidden from the model but runs automatically',
|
||||
],
|
||||
correctIndex: 1,
|
||||
explanation:
|
||||
'Forced mode guarantees the model will call that specific tool — useful when you always need a web search or database lookup regardless of what the user asked. "Auto" lets the model decide.',
|
||||
},
|
||||
{
|
||||
type: 'multi_select',
|
||||
question:
|
||||
'Which of these can you attach directly to an Agent block? (select all that apply)',
|
||||
options: [
|
||||
'A system prompt',
|
||||
'External tools (search, Slack, GitHub, etc.)',
|
||||
'Custom skills defined in workspace settings',
|
||||
'A response format / JSON schema',
|
||||
'A deployment schedule',
|
||||
],
|
||||
correctIndices: [0, 1, 2, 3],
|
||||
explanation:
|
||||
'System prompts, tools, skills, and response format are all configured directly on the Agent block. Deployment schedules are set on the Starter block, not the Agent.',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'sim-foundations-m3',
|
||||
title: 'Data Flow & Variables',
|
||||
description:
|
||||
'Understand how data moves between blocks and how to reference outputs across your workflow.',
|
||||
lessons: [
|
||||
{
|
||||
id: 'sim-foundations-m3-l1',
|
||||
slug: 'variables-and-references',
|
||||
title: 'Variables & References',
|
||||
lessonType: 'video',
|
||||
description:
|
||||
'The <block.field> reference syntax, the Variables block, environment variables, and live value preview.',
|
||||
videoUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
||||
videoDurationSeconds: 240,
|
||||
},
|
||||
{
|
||||
id: 'sim-foundations-m3-l2',
|
||||
slug: 'multi-step-pipeline',
|
||||
title: 'Build a Multi-Step Pipeline',
|
||||
lessonType: 'exercise',
|
||||
description:
|
||||
'Chain two Agent blocks together so the output of the first flows into the second.',
|
||||
exerciseConfig: {
|
||||
instructions:
|
||||
"Add two Agent blocks to the canvas. Connect the Starter to the first Agent, then the first Agent to the second Agent. This creates a pipeline where data flows through both agents in sequence. In the second agent's Messages field, type < to open the reference picker and select the first agent's output — this is how any block feeds its result into the next one.",
|
||||
availableBlocks: ['agent'],
|
||||
initialBlocks: [
|
||||
{
|
||||
id: 'starter-1',
|
||||
type: 'starter',
|
||||
position: { x: 80, y: 240 },
|
||||
locked: true,
|
||||
},
|
||||
],
|
||||
validationRules: [
|
||||
{
|
||||
type: 'block_exists',
|
||||
blockType: 'agent',
|
||||
count: 2,
|
||||
label: 'Add two Agent blocks to the canvas',
|
||||
},
|
||||
{
|
||||
type: 'edge_exists',
|
||||
sourceType: 'starter',
|
||||
targetType: 'agent',
|
||||
label: 'Connect the Starter to the first Agent',
|
||||
},
|
||||
{
|
||||
type: 'edge_exists',
|
||||
sourceType: 'agent',
|
||||
targetType: 'agent',
|
||||
label: 'Connect the first Agent to the second Agent',
|
||||
},
|
||||
],
|
||||
hints: [
|
||||
'Drag two Agent blocks onto the canvas and position them left to right.',
|
||||
'Connect Starter → Agent 1 first, then Agent 1 → Agent 2.',
|
||||
"In the second agent's Messages field, type < to open the reference picker and select the first agent's output.",
|
||||
],
|
||||
mockOutputs: {
|
||||
starter: { response: { result: 'Workflow started' }, delay: 200 },
|
||||
agent: {
|
||||
response: { content: 'Step one complete. Passing result to the next agent.' },
|
||||
delay: 1000,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'sim-foundations-m4',
|
||||
title: 'Control Flow',
|
||||
description:
|
||||
"Build workflows that branch, route, run in parallel, and loop using Sim's control flow blocks.",
|
||||
lessons: [
|
||||
{
|
||||
id: 'sim-foundations-m4-l1',
|
||||
slug: 'conditions-and-routing',
|
||||
title: 'Conditions & Routing',
|
||||
lessonType: 'video',
|
||||
description:
|
||||
'The Condition block for deterministic branching, the Router block for LLM-powered routing, and when to use each.',
|
||||
videoUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
||||
videoDurationSeconds: 300,
|
||||
},
|
||||
{
|
||||
id: 'sim-foundations-m4-l2',
|
||||
slug: 'parallel-and-loops',
|
||||
title: 'Parallel Execution & Loops',
|
||||
lessonType: 'video',
|
||||
description:
|
||||
'How to run branches simultaneously with fan-out, how the Loop block iterates over a list one item at a time, and how the Parallel block processes all items in a list concurrently.',
|
||||
videoUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
||||
videoDurationSeconds: 180,
|
||||
},
|
||||
{
|
||||
id: 'sim-foundations-m4-l3',
|
||||
slug: 'branching-workflow',
|
||||
title: 'Branching Workflow',
|
||||
lessonType: 'exercise',
|
||||
description:
|
||||
'Build a workflow that routes to different Agent blocks depending on whether a condition is true or false.',
|
||||
exerciseConfig: {
|
||||
instructions:
|
||||
"Build a branching workflow. First, add a Condition block and connect the Starter to it. Then add two Agent blocks and connect one to each of the Condition's output handles — the top handle is the true branch, the bottom handle is the false branch. The Condition evaluates a JavaScript expression and routes execution to whichever branch matches.",
|
||||
availableBlocks: ['condition', 'agent'],
|
||||
initialBlocks: [
|
||||
{
|
||||
id: 'starter-1',
|
||||
type: 'starter',
|
||||
position: { x: 80, y: 260 },
|
||||
locked: true,
|
||||
},
|
||||
],
|
||||
validationRules: [
|
||||
{
|
||||
type: 'block_exists',
|
||||
blockType: 'condition',
|
||||
label: 'Add a Condition block',
|
||||
},
|
||||
{
|
||||
type: 'block_exists',
|
||||
blockType: 'agent',
|
||||
count: 2,
|
||||
label: 'Add two Agent blocks (one for each branch)',
|
||||
},
|
||||
{
|
||||
type: 'edge_exists',
|
||||
sourceType: 'starter',
|
||||
targetType: 'condition',
|
||||
label: 'Connect the Starter to the Condition',
|
||||
},
|
||||
{
|
||||
type: 'edge_exists',
|
||||
sourceType: 'condition',
|
||||
targetType: 'agent',
|
||||
sourceHandle: 'condition-if',
|
||||
label: 'Connect the Condition true branch (top handle) to an Agent',
|
||||
},
|
||||
{
|
||||
type: 'edge_exists',
|
||||
sourceType: 'condition',
|
||||
targetType: 'agent',
|
||||
sourceHandle: 'condition-else',
|
||||
label: 'Connect the Condition false branch (bottom handle) to an Agent',
|
||||
},
|
||||
],
|
||||
hints: [
|
||||
'Add a Condition block — it shows two output handles on the right: the top one is the true branch, the bottom one is the false branch.',
|
||||
'Connect Starter → Condition first, then add two Agent blocks and drag one connection from each output handle to an Agent.',
|
||||
"Click the Condition block to set your expression. Try `true` to always take the true branch while you're testing the wiring.",
|
||||
],
|
||||
mockOutputs: {
|
||||
starter: { response: { result: 'Workflow started' }, delay: 200 },
|
||||
condition: {
|
||||
response: { result: true },
|
||||
delay: 400,
|
||||
},
|
||||
agent: {
|
||||
response: { content: 'Taking the true path — condition was met.' },
|
||||
delay: 1200,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'sim-foundations-m4-l4',
|
||||
slug: 'control-flow-check',
|
||||
title: 'Control Flow Check',
|
||||
lessonType: 'quiz',
|
||||
quizConfig: {
|
||||
passingScore: 75,
|
||||
questions: [
|
||||
{
|
||||
type: 'multiple_choice',
|
||||
question: 'What does the Condition block evaluate?',
|
||||
options: [
|
||||
'A natural language description of a rule',
|
||||
'A JavaScript expression that resolves to true or false',
|
||||
'A SQL query against the workflow state',
|
||||
'An LLM call that decides which path to take',
|
||||
],
|
||||
correctIndex: 1,
|
||||
explanation:
|
||||
'The Condition block evaluates a JavaScript expression — you can reference block outputs like <agent.sentiment> === "negative" or <start.input>.length > 100. It routes to the true or false branch based on the result.',
|
||||
},
|
||||
{
|
||||
type: 'multiple_choice',
|
||||
question: 'When would you choose a Router block over a Condition block?',
|
||||
options: [
|
||||
'When you need an exact boolean true/false decision',
|
||||
'When you have 3 or more named paths and natural language input determines the route',
|
||||
'When you want to run two branches simultaneously',
|
||||
'When you need to loop over a list of items',
|
||||
],
|
||||
correctIndex: 1,
|
||||
explanation:
|
||||
'The Router uses an LLM to intelligently select from multiple named paths — ideal for open-ended inputs like support tickets that could be billing, technical, or general. Condition is better for deterministic boolean logic.',
|
||||
},
|
||||
{
|
||||
type: 'true_false',
|
||||
question: "Blocks on a branch that wasn't taken still execute with an empty input.",
|
||||
correctAnswer: false,
|
||||
explanation:
|
||||
"Blocks on a branch not taken are completely skipped — they don't run at all. Only the matching branch executes.",
|
||||
},
|
||||
{
|
||||
type: 'multiple_choice',
|
||||
question: 'How do you run two independent blocks at the same time in Sim?',
|
||||
options: [
|
||||
'Use a dedicated Parallel block between them',
|
||||
'Connect the same source block to both target blocks (fan-out)',
|
||||
'Set both blocks to "async" mode in their settings',
|
||||
'You cannot — Sim only supports sequential execution',
|
||||
],
|
||||
correctIndex: 1,
|
||||
explanation:
|
||||
"Fan-out: connect one block's output to multiple downstream blocks and all of them start at the same time once the source finishes. The dedicated Parallel block is different — it's a subflow container that iterates over a list and runs its inner blocks once per item, concurrently.",
|
||||
},
|
||||
{
|
||||
type: 'multiple_choice',
|
||||
question: 'How do you iterate over a list of items in Sim?',
|
||||
options: [
|
||||
'Use the Loop block — a subflow container that runs its inner blocks once for each item in a list',
|
||||
'By drawing an edge from a block back to an earlier block on the canvas',
|
||||
'Use the Condition block with a counter variable that increments each pass',
|
||||
'Loops are not supported in Sim',
|
||||
],
|
||||
correctIndex: 0,
|
||||
explanation:
|
||||
'Sim has a dedicated Loop block — a subflow container. You place the blocks you want to repeat inside it, point it at a list, and it runs those inner blocks once per item. Inside the loop, <loop.currentItem> gives you the current item and <loop.index> gives you the position.',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'sim-foundations-m5',
|
||||
title: 'Memory & Knowledge',
|
||||
description:
|
||||
'Give agents access to documents and conversation history to build truly contextual workflows.',
|
||||
lessons: [
|
||||
{
|
||||
id: 'sim-foundations-m5-l1',
|
||||
slug: 'knowledge-bases',
|
||||
title: 'Knowledge Bases',
|
||||
lessonType: 'video',
|
||||
description:
|
||||
'What knowledge bases are, how documents are chunked and embedded, and how to wire a Knowledge block into an Agent.',
|
||||
videoUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
||||
videoDurationSeconds: 240,
|
||||
},
|
||||
{
|
||||
id: 'sim-foundations-m5-l2',
|
||||
slug: 'agent-memory',
|
||||
title: 'Agent Memory',
|
||||
lessonType: 'video',
|
||||
description:
|
||||
'Stateless vs stateful agents, the conversationId, and the three memory modes: full conversation, sliding window, and token window.',
|
||||
videoUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
||||
videoDurationSeconds: 180,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'sim-foundations-m6',
|
||||
title: 'Deploying Your Workflow',
|
||||
description:
|
||||
'Turn a workflow into a real, production-ready product — as an API, a chat interface, or a scheduled job.',
|
||||
lessons: [
|
||||
{
|
||||
id: 'sim-foundations-m6-l1',
|
||||
slug: 'deploy-as-api',
|
||||
title: 'Deploying as an API',
|
||||
lessonType: 'video',
|
||||
description:
|
||||
'How to expose a workflow as an HTTPS REST endpoint, authenticate with API keys, and version your deployment.',
|
||||
videoUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
||||
videoDurationSeconds: 240,
|
||||
},
|
||||
{
|
||||
id: 'sim-foundations-m6-l2',
|
||||
slug: 'chat-deployments',
|
||||
title: 'Chat Deployments',
|
||||
lessonType: 'video',
|
||||
description:
|
||||
'Deploy a workflow as a managed chat UI — streaming responses, file uploads, conversation history, and access control.',
|
||||
videoUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
||||
videoDurationSeconds: 180,
|
||||
},
|
||||
{
|
||||
id: 'sim-foundations-m6-l3',
|
||||
slug: 'schedules-and-webhooks',
|
||||
title: 'Schedules & Webhooks',
|
||||
lessonType: 'video',
|
||||
description:
|
||||
'Trigger workflows automatically on a schedule with cron expressions, or on-demand via incoming webhooks.',
|
||||
videoUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
||||
videoDurationSeconds: 180,
|
||||
},
|
||||
{
|
||||
id: 'sim-foundations-m6-l4',
|
||||
slug: 'final-project',
|
||||
title: 'Final Project',
|
||||
lessonType: 'exercise',
|
||||
description:
|
||||
'Build a complete workflow from scratch: an Agent with a configured system prompt, routing logic via a Condition block, and separate handlers for each branch.',
|
||||
exerciseConfig: {
|
||||
instructions:
|
||||
"Build a complete multi-step workflow from scratch. Step 1: drag an Agent block onto the canvas and connect the Starter to it — this is your main processing agent. Step 2: click the Agent and add a system prompt in the Messages field. Step 3: add a Condition block and connect your Agent to it. Step 4: add two more Agent blocks and connect one to the Condition's true output and one to its false output. This pattern — intake → process → branch → handle — is the foundation of most real Sim deployments.",
|
||||
availableBlocks: ['agent', 'condition'],
|
||||
initialBlocks: [
|
||||
{
|
||||
id: 'starter-1',
|
||||
type: 'starter',
|
||||
position: { x: 60, y: 280 },
|
||||
locked: true,
|
||||
},
|
||||
],
|
||||
validationRules: [
|
||||
{
|
||||
type: 'edge_exists',
|
||||
sourceType: 'starter',
|
||||
targetType: 'agent',
|
||||
label: 'Connect the Starter to an Agent',
|
||||
},
|
||||
{
|
||||
type: 'block_configured',
|
||||
blockType: 'agent',
|
||||
subBlockId: 'messages',
|
||||
valueNotEmpty: true,
|
||||
label: 'Configure a system prompt on your main Agent',
|
||||
},
|
||||
{
|
||||
type: 'block_exists',
|
||||
blockType: 'condition',
|
||||
label: 'Add a Condition block',
|
||||
},
|
||||
{
|
||||
type: 'edge_exists',
|
||||
sourceType: 'agent',
|
||||
targetType: 'condition',
|
||||
label: 'Connect the Agent to the Condition',
|
||||
},
|
||||
{
|
||||
type: 'block_exists',
|
||||
blockType: 'agent',
|
||||
count: 3,
|
||||
label: 'Add two more Agent blocks for the true and false branches',
|
||||
},
|
||||
{
|
||||
type: 'edge_exists',
|
||||
sourceType: 'condition',
|
||||
targetType: 'agent',
|
||||
sourceHandle: 'condition-if',
|
||||
label: 'Connect the Condition true branch to an Agent',
|
||||
},
|
||||
{
|
||||
type: 'edge_exists',
|
||||
sourceType: 'condition',
|
||||
targetType: 'agent',
|
||||
sourceHandle: 'condition-else',
|
||||
label: 'Connect the Condition false branch to an Agent',
|
||||
},
|
||||
],
|
||||
hints: [
|
||||
'Start by placing an Agent block and connecting it to the Starter. Click it to add a system prompt.',
|
||||
'Add a Condition block and connect your first Agent to it.',
|
||||
'Add two more Agent blocks — one for the true branch and one for the false branch.',
|
||||
'You can copy a block with Ctrl/Cmd+C and paste with Ctrl/Cmd+V to save time.',
|
||||
],
|
||||
mockOutputs: {
|
||||
starter: { response: { result: 'Workflow started' }, delay: 200 },
|
||||
agent: {
|
||||
response: {
|
||||
content: 'Analysis complete. Routing to the appropriate handler.',
|
||||
},
|
||||
delay: 1500,
|
||||
},
|
||||
condition: {
|
||||
response: { result: true },
|
||||
delay: 400,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
16
apps/sim/lib/academy/content/index.ts
Normal file
16
apps/sim/lib/academy/content/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { Course } from '@/lib/academy/types'
|
||||
import { simFoundations } from './courses/sim-foundations'
|
||||
|
||||
/** All published courses in display order. */
|
||||
export const COURSES: Course[] = [simFoundations]
|
||||
|
||||
const bySlug = new Map(COURSES.map((c) => [c.slug, c]))
|
||||
const byId = new Map(COURSES.map((c) => [c.id, c]))
|
||||
|
||||
export function getCourse(slug: string): Course | undefined {
|
||||
return bySlug.get(slug)
|
||||
}
|
||||
|
||||
export function getCourseById(id: string): Course | undefined {
|
||||
return byId.get(id)
|
||||
}
|
||||
13
apps/sim/lib/academy/local-progress.ts
Normal file
13
apps/sim/lib/academy/local-progress.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { BrowserStorage } from '@/lib/core/utils/browser-storage'
|
||||
|
||||
const STORAGE_KEY = 'academy:completed'
|
||||
|
||||
export function getCompletedLessons(): Set<string> {
|
||||
return new Set(BrowserStorage.getItem<string[]>(STORAGE_KEY, []))
|
||||
}
|
||||
|
||||
export function markLessonComplete(lessonId: string): void {
|
||||
const ids = getCompletedLessons()
|
||||
ids.add(lessonId)
|
||||
BrowserStorage.setItem(STORAGE_KEY, [...ids])
|
||||
}
|
||||
67
apps/sim/lib/academy/mock-execution.ts
Normal file
67
apps/sim/lib/academy/mock-execution.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import type { ExerciseBlockState, ExerciseEdgeState, MockBlockOutput } from '@/lib/academy/types'
|
||||
|
||||
export interface MockExecutionStep {
|
||||
blockId: string
|
||||
blockType: string
|
||||
output: Record<string, unknown>
|
||||
delay: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a topologically-ordered list of execution steps for mock execution.
|
||||
* Blocks without incoming edges run first; downstream blocks follow.
|
||||
*/
|
||||
export function buildMockExecutionPlan(
|
||||
blocks: ExerciseBlockState[],
|
||||
edges: ExerciseEdgeState[],
|
||||
mockOutputs: Record<string, MockBlockOutput>
|
||||
): MockExecutionStep[] {
|
||||
const steps: MockExecutionStep[] = []
|
||||
const visited = new Set<string>()
|
||||
const adjacency = new Map<string, string[]>()
|
||||
const inDegree = new Map<string, number>()
|
||||
const blockMap = new Map(blocks.map((b) => [b.id, b]))
|
||||
|
||||
for (const block of blocks) {
|
||||
adjacency.set(block.id, [])
|
||||
inDegree.set(block.id, 0)
|
||||
}
|
||||
|
||||
for (const edge of edges) {
|
||||
adjacency.get(edge.source)?.push(edge.target)
|
||||
inDegree.set(edge.target, (inDegree.get(edge.target) ?? 0) + 1)
|
||||
}
|
||||
|
||||
// Kahn's topological sort
|
||||
const queue: string[] = []
|
||||
for (const [id, degree] of inDegree) {
|
||||
if (degree === 0) queue.push(id)
|
||||
}
|
||||
|
||||
while (queue.length > 0) {
|
||||
const blockId = queue.shift()!
|
||||
if (visited.has(blockId)) continue
|
||||
visited.add(blockId)
|
||||
|
||||
const block = blockMap.get(blockId)
|
||||
if (!block) continue
|
||||
|
||||
// Resolve mock output: prefer block-id-keyed, fall back to block-type-keyed
|
||||
const mockOutput = mockOutputs[blockId] ?? mockOutputs[block.type]
|
||||
|
||||
steps.push({
|
||||
blockId,
|
||||
blockType: block.type,
|
||||
output: mockOutput?.response ?? { result: '(no output defined)' },
|
||||
delay: mockOutput?.delay ?? 800,
|
||||
})
|
||||
|
||||
for (const neighbor of adjacency.get(blockId) ?? []) {
|
||||
const newDegree = (inDegree.get(neighbor) ?? 0) - 1
|
||||
inDegree.set(neighbor, newDegree)
|
||||
if (newDegree === 0) queue.push(neighbor)
|
||||
}
|
||||
}
|
||||
|
||||
return steps
|
||||
}
|
||||
155
apps/sim/lib/academy/types.ts
Normal file
155
apps/sim/lib/academy/types.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Sim Academy — shared type definitions.
|
||||
* Course content is file-based (lib/academy/content/); only certificates are DB-backed.
|
||||
*/
|
||||
import type { academyCertificate } from '@sim/db/schema'
|
||||
|
||||
export type LessonType = 'video' | 'exercise' | 'quiz' | 'mixed'
|
||||
|
||||
export interface Course {
|
||||
/** Stable ID — stored on certificates; never change after launch */
|
||||
id: string
|
||||
slug: string
|
||||
title: string
|
||||
description?: string
|
||||
imageUrl?: string
|
||||
estimatedMinutes?: number
|
||||
modules: Module[]
|
||||
}
|
||||
|
||||
export interface Module {
|
||||
id: string
|
||||
title: string
|
||||
description?: string
|
||||
lessons: Lesson[]
|
||||
}
|
||||
|
||||
export interface Lesson {
|
||||
/** Stable ID — stored in localStorage for completion tracking; never change after launch */
|
||||
id: string
|
||||
slug: string
|
||||
title: string
|
||||
description?: string
|
||||
lessonType: LessonType
|
||||
videoUrl?: string
|
||||
videoDurationSeconds?: number
|
||||
exerciseConfig?: ExerciseDefinition
|
||||
quizConfig?: QuizDefinition
|
||||
}
|
||||
|
||||
export type AcademyCertStatus = 'active' | 'revoked' | 'expired'
|
||||
|
||||
export interface CertificateMetadata {
|
||||
/** Recipient name at time of issuance */
|
||||
recipientName: string
|
||||
/** Course title at time of issuance */
|
||||
courseTitle: string
|
||||
}
|
||||
|
||||
/** Certificate record derived from the DB schema — metadata narrowed to its known shape. */
|
||||
export type AcademyCertificate = Omit<typeof academyCertificate.$inferSelect, 'metadata'> & {
|
||||
metadata: CertificateMetadata | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Full configuration for an interactive canvas exercise.
|
||||
* Defined inline in each lesson file.
|
||||
*/
|
||||
export interface ExerciseDefinition {
|
||||
/** Instructions shown to the learner above the checklist */
|
||||
instructions: string
|
||||
/** Block type IDs available in the exercise toolbar */
|
||||
availableBlocks: string[]
|
||||
/** Blocks pre-placed on the canvas at exercise start */
|
||||
initialBlocks?: ExerciseBlockState[]
|
||||
/** Edges pre-placed on the canvas at exercise start */
|
||||
initialEdges?: ExerciseEdgeState[]
|
||||
/** Rules the learner must satisfy to complete the exercise */
|
||||
validationRules: ValidationRule[]
|
||||
/** Progressive hints shown one-at-a-time if the user gets stuck */
|
||||
hints?: string[]
|
||||
/**
|
||||
* Mock outputs displayed per block when the user clicks "Run".
|
||||
* Keyed by block ID (for initial blocks) or block type (for dynamically added blocks).
|
||||
*/
|
||||
mockOutputs?: Record<string, MockBlockOutput>
|
||||
}
|
||||
|
||||
export interface ExerciseBlockState {
|
||||
id: string
|
||||
/** Block type from the block registry (e.g. 'agent', 'function', 'starter') */
|
||||
type: string
|
||||
position: { x: number; y: number }
|
||||
/** Pre-filled sub-block values — keyed by sub-block ID */
|
||||
subBlocks?: Record<string, unknown>
|
||||
/** If true, the block cannot be moved or deleted by the learner */
|
||||
locked?: boolean
|
||||
}
|
||||
|
||||
export interface ExerciseEdgeState {
|
||||
id: string
|
||||
source: string
|
||||
target: string
|
||||
sourceHandle?: string
|
||||
targetHandle?: string
|
||||
}
|
||||
|
||||
export interface MockBlockOutput {
|
||||
response: Record<string, unknown>
|
||||
delay?: number
|
||||
}
|
||||
|
||||
export type ValidationRule = { label?: string } & (
|
||||
| { type: 'block_exists'; blockType: string; count?: number }
|
||||
| {
|
||||
type: 'block_configured'
|
||||
blockType: string
|
||||
subBlockId: string
|
||||
valueNotEmpty?: boolean
|
||||
valuePattern?: string
|
||||
}
|
||||
| { type: 'edge_exists'; sourceType: string; targetType: string; sourceHandle?: string }
|
||||
| { type: 'block_count_min'; count: number }
|
||||
| { type: 'block_count_max'; count: number }
|
||||
| { type: 'custom'; validatorId: string; params?: Record<string, unknown> }
|
||||
)
|
||||
|
||||
export interface ValidationRuleResult {
|
||||
rule: ValidationRule
|
||||
passed: boolean
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface ValidationResult {
|
||||
passed: boolean
|
||||
results: ValidationRuleResult[]
|
||||
}
|
||||
|
||||
/** Full configuration for a quiz. Defined inline in each lesson file. */
|
||||
export interface QuizDefinition {
|
||||
/** Minimum score (0-100) required to pass */
|
||||
passingScore: number
|
||||
questions: QuizQuestion[]
|
||||
}
|
||||
|
||||
export type QuizQuestion =
|
||||
| {
|
||||
type: 'multiple_choice'
|
||||
question: string
|
||||
options: string[]
|
||||
correctIndex: number
|
||||
explanation?: string
|
||||
}
|
||||
| {
|
||||
type: 'multi_select'
|
||||
question: string
|
||||
options: string[]
|
||||
correctIndices: number[]
|
||||
explanation?: string
|
||||
}
|
||||
| {
|
||||
type: 'true_false'
|
||||
question: string
|
||||
correctAnswer: boolean
|
||||
explanation?: string
|
||||
}
|
||||
124
apps/sim/lib/academy/validation.ts
Normal file
124
apps/sim/lib/academy/validation.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type {
|
||||
ExerciseBlockState,
|
||||
ExerciseEdgeState,
|
||||
ValidationResult,
|
||||
ValidationRule,
|
||||
ValidationRuleResult,
|
||||
} from '@/lib/academy/types'
|
||||
|
||||
const logger = createLogger('AcademyValidation')
|
||||
|
||||
/**
|
||||
* Validates a learner's exercise canvas state against a set of rules.
|
||||
* Runs identically on the client (real-time feedback) and server (progress recording).
|
||||
*/
|
||||
export function validateExercise(
|
||||
blocks: ExerciseBlockState[],
|
||||
edges: ExerciseEdgeState[],
|
||||
rules: ValidationRule[]
|
||||
): ValidationResult {
|
||||
const results = rules.map((rule) => {
|
||||
const passed = checkRule(rule, blocks, edges)
|
||||
return {
|
||||
rule,
|
||||
passed,
|
||||
message: getRuleMessage(rule),
|
||||
} satisfies ValidationRuleResult
|
||||
})
|
||||
|
||||
return {
|
||||
passed: results.every((r) => r.passed),
|
||||
results,
|
||||
}
|
||||
}
|
||||
|
||||
function checkRule(
|
||||
rule: ValidationRule,
|
||||
blocks: ExerciseBlockState[],
|
||||
edges: ExerciseEdgeState[]
|
||||
): boolean {
|
||||
switch (rule.type) {
|
||||
case 'block_exists': {
|
||||
const matches = blocks.filter((b) => b.type === rule.blockType)
|
||||
return matches.length >= (rule.count ?? 1)
|
||||
}
|
||||
|
||||
case 'block_configured': {
|
||||
return blocks.some((b) => {
|
||||
if (b.type !== rule.blockType) return false
|
||||
const value = b.subBlocks?.[rule.subBlockId]
|
||||
if (
|
||||
rule.valueNotEmpty &&
|
||||
(value === undefined ||
|
||||
value === null ||
|
||||
value === '' ||
|
||||
(Array.isArray(value) && value.length === 0))
|
||||
)
|
||||
return false
|
||||
if (rule.valuePattern) {
|
||||
let regex: RegExp
|
||||
try {
|
||||
regex = new RegExp(rule.valuePattern)
|
||||
} catch {
|
||||
logger.warn('Invalid valuePattern in block_configured rule', {
|
||||
pattern: rule.valuePattern,
|
||||
})
|
||||
return false
|
||||
}
|
||||
if (!regex.test(String(value ?? ''))) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
case 'edge_exists': {
|
||||
const blockMap = new Map(blocks.map((b) => [b.id, b]))
|
||||
return edges.some((e) => {
|
||||
const source = blockMap.get(e.source)
|
||||
const target = blockMap.get(e.target)
|
||||
if (source?.type !== rule.sourceType || target?.type !== rule.targetType) return false
|
||||
if (rule.sourceHandle && e.sourceHandle !== rule.sourceHandle) return false
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
case 'block_count_min': {
|
||||
return blocks.length >= rule.count
|
||||
}
|
||||
|
||||
case 'block_count_max': {
|
||||
return blocks.length <= rule.count
|
||||
}
|
||||
|
||||
case 'custom': {
|
||||
logger.warn('Custom validation rule encountered — no client registry implementation', {
|
||||
validatorId: rule.validatorId,
|
||||
})
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getRuleMessage(rule: ValidationRule): string {
|
||||
if (rule.label) return rule.label
|
||||
|
||||
switch (rule.type) {
|
||||
case 'block_exists': {
|
||||
const count = rule.count ?? 1
|
||||
return count > 1
|
||||
? `Add ${count} ${rule.blockType} blocks to the canvas`
|
||||
: `Add a ${rule.blockType} block to the canvas`
|
||||
}
|
||||
case 'block_configured':
|
||||
return `Configure the ${rule.blockType} block's ${rule.subBlockId} field`
|
||||
case 'edge_exists':
|
||||
return `Connect the ${rule.sourceType} block to the ${rule.targetType} block`
|
||||
case 'block_count_min':
|
||||
return `Add at least ${rule.count} blocks to the canvas`
|
||||
case 'block_count_max':
|
||||
return `Remove blocks — maximum is ${rule.count}`
|
||||
case 'custom':
|
||||
return 'Complete the custom requirement'
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,16 @@
|
||||
import clsx from 'clsx'
|
||||
import Image from 'next/image'
|
||||
import type { MDXRemoteProps } from 'next-mdx-remote/rsc'
|
||||
import { CodeBlock } from '@/lib/blog/code'
|
||||
import { BlogImage } from '@/app/(landing)/blog/components/blog-image'
|
||||
|
||||
export const mdxComponents: MDXRemoteProps['components'] = {
|
||||
img: (props: any) => (
|
||||
<Image
|
||||
<BlogImage
|
||||
src={props.src}
|
||||
alt={props.alt || ''}
|
||||
width={props.width ? Number(props.width) : 800}
|
||||
height={props.height ? Number(props.height) : 450}
|
||||
className={clsx('h-auto w-full rounded-lg', props.className)}
|
||||
sizes='(max-width: 768px) 100vw, 800px'
|
||||
loading='lazy'
|
||||
unoptimized
|
||||
className={props.className}
|
||||
/>
|
||||
),
|
||||
h2: ({ children, className, ...props }: any) => (
|
||||
|
||||
@@ -28,6 +28,8 @@ export interface WorkflowMetadata {
|
||||
folderId?: string | null
|
||||
sortOrder: number
|
||||
archivedAt?: Date | null
|
||||
/** True for sandbox exercises (Sim Academy). Skips real API calls. */
|
||||
isSandbox?: boolean
|
||||
}
|
||||
|
||||
export type HydrationPhase =
|
||||
|
||||
20
packages/db/migrations/0183_workable_apocalypse.sql
Normal file
20
packages/db/migrations/0183_workable_apocalypse.sql
Normal file
@@ -0,0 +1,20 @@
|
||||
CREATE TYPE "public"."academy_cert_status" AS ENUM('active', 'revoked', 'expired');--> statement-breakpoint
|
||||
CREATE TABLE "academy_certificate" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"course_id" text NOT NULL,
|
||||
"status" "academy_cert_status" DEFAULT 'active' NOT NULL,
|
||||
"issued_at" timestamp DEFAULT now() NOT NULL,
|
||||
"expires_at" timestamp,
|
||||
"certificate_number" text NOT NULL,
|
||||
"metadata" jsonb,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "academy_certificate_certificate_number_unique" UNIQUE("certificate_number")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "academy_certificate" ADD CONSTRAINT "academy_certificate_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "academy_certificate_user_id_idx" ON "academy_certificate" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "academy_certificate_course_id_idx" ON "academy_certificate" USING btree ("course_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "academy_certificate_user_course_unique" ON "academy_certificate" USING btree ("user_id","course_id");--> statement-breakpoint
|
||||
CREATE INDEX "academy_certificate_number_idx" ON "academy_certificate" USING btree ("certificate_number");--> statement-breakpoint
|
||||
CREATE INDEX "academy_certificate_status_idx" ON "academy_certificate" USING btree ("status");
|
||||
14604
packages/db/migrations/meta/0183_snapshot.json
Normal file
14604
packages/db/migrations/meta/0183_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1275,6 +1275,13 @@
|
||||
"when": 1774722988437,
|
||||
"tag": "0182_luxuriant_quasar",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 183,
|
||||
"version": "7",
|
||||
"when": 1774724904239,
|
||||
"tag": "0183_workable_apocalypse",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2863,3 +2863,39 @@ export const mothershipInboxWebhook = pgTable('mothership_inbox_webhook', {
|
||||
secret: text('secret').notNull(),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
})
|
||||
|
||||
// ─── Sim Academy ─────────────────────────────────────────────────────────────
|
||||
|
||||
export const academyCertStatusEnum = pgEnum('academy_cert_status', ['active', 'revoked', 'expired'])
|
||||
|
||||
/** Partner certification records issued on course completion */
|
||||
export const academyCertificate = pgTable(
|
||||
'academy_certificate',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
/** References the file-based course ID from lib/academy/content */
|
||||
courseId: text('course_id').notNull(),
|
||||
status: academyCertStatusEnum('status').notNull().default('active'),
|
||||
issuedAt: timestamp('issued_at').notNull().defaultNow(),
|
||||
/** Optional expiry for recertification requirements */
|
||||
expiresAt: timestamp('expires_at'),
|
||||
/** Human-readable unique certificate number, e.g. SIM-2026-00042 */
|
||||
certificateNumber: text('certificate_number').notNull().unique(),
|
||||
/** Snapshot of name and other metadata at time of issue */
|
||||
metadata: jsonb('metadata'),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
userIdIdx: index('academy_certificate_user_id_idx').on(table.userId),
|
||||
courseIdIdx: index('academy_certificate_course_id_idx').on(table.courseId),
|
||||
userCourseUnique: uniqueIndex('academy_certificate_user_course_unique').on(
|
||||
table.userId,
|
||||
table.courseId
|
||||
),
|
||||
certNumberIdx: index('academy_certificate_number_idx').on(table.certificateNumber),
|
||||
statusIdx: index('academy_certificate_status_idx').on(table.status),
|
||||
})
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user