diff --git a/README.md b/README.md index 831361fb3ed..114d5ffc207 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,10 @@ docker compose -f docker-compose.prod.yml up -d Open [http://localhost:3000](http://localhost:3000) +#### Background worker note + +The Docker Compose stack starts a dedicated worker container by default. If `REDIS_URL` is not configured, the worker will start, log that it is idle, and do no queue processing. This is expected. Queue-backed API, webhook, and schedule execution requires Redis; installs without Redis continue to use the inline execution path. + Sim also supports local models via [Ollama](https://ollama.ai) and [vLLM](https://docs.vllm.ai/) — see the [Docker self-hosting docs](https://docs.sim.ai/self-hosting/docker) for setup details. ### Self-hosted: Manual Setup @@ -113,10 +117,12 @@ cd packages/db && bunx drizzle-kit migrate --config=./drizzle.config.ts 5. Start development servers: ```bash -bun run dev:full # Starts both Next.js app and realtime socket server +bun run dev:full # Starts Next.js app, realtime socket server, and the BullMQ worker ``` -Or run separately: `bun run dev` (Next.js) and `cd apps/sim && bun run dev:sockets` (realtime). +If `REDIS_URL` is not configured, the worker will remain idle and execution continues inline. + +Or run separately: `bun run dev` (Next.js), `cd apps/sim && bun run dev:sockets` (realtime), and `cd apps/sim && bun run worker` (BullMQ worker). ## Copilot API Keys diff --git a/apps/docs/app/layout.tsx b/apps/docs/app/layout.tsx index 91eeb72f972..ea06926c72a 100644 --- a/apps/docs/app/layout.tsx +++ b/apps/docs/app/layout.tsx @@ -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.', diff --git a/apps/docs/content/docs/en/execution/costs.mdx b/apps/docs/content/docs/en/execution/costs.mdx index 25f4cc05adf..9f7af19f3d0 100644 --- a/apps/docs/content/docs/en/execution/costs.mdx +++ b/apps/docs/content/docs/en/execution/costs.mdx @@ -195,6 +195,17 @@ By default, your usage is capped at the credits included in your plan. To allow Max (individual) shares the same rate limits as team plans. Team plans (Pro or Max for Teams) use the Max-tier rate limits. +### Concurrent Execution Limits + +| Plan | Concurrent Executions | +|------|----------------------| +| **Free** | 5 | +| **Pro** | 50 | +| **Max / Team** | 200 | +| **Enterprise** | 200 (customizable) | + +Concurrent execution limits control how many workflow executions can run simultaneously within a workspace. When the limit is reached, new executions are queued and admitted as running executions complete. Manual runs from the editor are not subject to these limits. + ### File Storage | Plan | Storage | diff --git a/apps/sim/app/(auth)/components/auth-button-classes.ts b/apps/sim/app/(auth)/components/auth-button-classes.ts index 02d1d5e47ed..a55f334ea8e 100644 --- a/apps/sim/app/(auth)/components/auth-button-classes.ts +++ b/apps/sim/app/(auth)/components/auth-button-classes.ts @@ -1,3 +1,6 @@ -/** Shared className for primary auth form submit buttons across all auth pages. */ -export const AUTH_SUBMIT_BTN = - 'inline-flex h-[32px] w-full items-center justify-center gap-2 rounded-[5px] border border-white bg-white px-2.5 font-[430] font-season text-black text-sm transition-colors hover:border-[var(--border-1)] hover:bg-[var(--border-1)] disabled:cursor-not-allowed disabled:opacity-50' as const +/** Shared className for primary auth/status CTA buttons on dark auth surfaces. */ +export const AUTH_PRIMARY_CTA_BASE = + 'inline-flex h-[32px] items-center justify-center gap-2 rounded-[5px] border border-[var(--auth-primary-btn-border)] bg-[var(--auth-primary-btn-bg)] px-2.5 font-[430] font-season text-[var(--auth-primary-btn-text)] text-sm transition-colors hover:border-[var(--auth-primary-btn-hover-border)] hover:bg-[var(--auth-primary-btn-hover-bg)] hover:text-[var(--auth-primary-btn-hover-text)] disabled:cursor-not-allowed disabled:opacity-50' as const + +/** Full-width variant used for primary auth form submit buttons. */ +export const AUTH_SUBMIT_BTN = `${AUTH_PRIMARY_CTA_BASE} w-full` as const diff --git a/apps/sim/app/(home)/components/collaboration/collaboration.tsx b/apps/sim/app/(home)/components/collaboration/collaboration.tsx index 302bcc05904..02bee46ab15 100644 --- a/apps/sim/app/(home)/components/collaboration/collaboration.tsx +++ b/apps/sim/app/(home)/components/collaboration/collaboration.tsx @@ -288,7 +288,6 @@ export default function Collaboration() { width={876} height={480} className='h-full w-auto object-left md:min-w-[100vw]' - priority />
diff --git a/apps/sim/app/(home)/components/enterprise/components/access-control-panel.tsx b/apps/sim/app/(home)/components/enterprise/components/access-control-panel.tsx index 652b2b98ed3..e8c79862acd 100644 --- a/apps/sim/app/(home)/components/enterprise/components/access-control-panel.tsx +++ b/apps/sim/app/(home)/components/enterprise/components/access-control-panel.tsx @@ -81,6 +81,56 @@ function ProviderPreviewIcon({ providerId }: { providerId?: string }) { ) } +interface FeatureToggleItemProps { + feature: PermissionFeature + enabled: boolean + color: string + isInView: boolean + delay: number + textClassName: string + transition: Record + onToggle: () => void +} + +function FeatureToggleItem({ + feature, + enabled, + color, + isInView, + delay, + textClassName, + transition, + onToggle, +}: FeatureToggleItemProps) { + return ( + { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onToggle() + } + }} + whileTap={{ scale: 0.98 }} + > + + + + {feature.name} + + + ) +} + export function AccessControlPanel() { const ref = useRef(null) const isInView = useInView(ref, { once: true, margin: '-40px' }) @@ -97,39 +147,25 @@ export function AccessControlPanel() { return (
0 ? 'mt-4' : ''}> - + {category.label}
- {category.features.map((feature, featIdx) => { - const enabled = accessState[feature.key] - - return ( - - setAccessState((prev) => ({ ...prev, [feature.key]: !prev[feature.key] })) - } - whileTap={{ scale: 0.98 }} - > - - - - {feature.name} - - - ) - })} + {category.features.map((feature, featIdx) => ( + + setAccessState((prev) => ({ ...prev, [feature.key]: !prev[feature.key] })) + } + /> + ))}
) @@ -140,12 +176,11 @@ export function AccessControlPanel() {
{PERMISSION_CATEGORIES.map((category, catIdx) => (
0 ? 'mt-4' : ''}> - + {category.label}
{category.features.map((feature, featIdx) => { - const enabled = accessState[feature.key] const currentIndex = PERMISSION_CATEGORIES.slice(0, catIdx).reduce( (sum, c) => sum + c.features.length, @@ -153,30 +188,19 @@ export function AccessControlPanel() { ) + featIdx return ( - + feature={feature} + enabled={accessState[feature.key]} + color={category.color} + isInView={isInView} + delay={0.1 + currentIndex * 0.04} + textClassName='truncate font-[430] font-season text-[11px] leading-none tracking-[0.02em] transition-opacity duration-200' + transition={{ duration: 0.3, ease: [0.25, 0.46, 0.45, 0.94] }} + onToggle={() => setAccessState((prev) => ({ ...prev, [feature.key]: !prev[feature.key] })) } - whileTap={{ scale: 0.98 }} - > - - - - {feature.name} - - + /> ) })}
diff --git a/apps/sim/app/(home)/components/enterprise/components/audit-log-preview.tsx b/apps/sim/app/(home)/components/enterprise/components/audit-log-preview.tsx index 53c938af1f5..89ad672e678 100644 --- a/apps/sim/app/(home)/components/enterprise/components/audit-log-preview.tsx +++ b/apps/sim/app/(home)/components/enterprise/components/audit-log-preview.tsx @@ -146,14 +146,14 @@ function AuditRow({ entry, index }: AuditRowProps) {
{/* Time */} - + {timeAgo} {entry.actor} - · + · {entry.description} diff --git a/apps/sim/app/(home)/components/enterprise/enterprise.tsx b/apps/sim/app/(home)/components/enterprise/enterprise.tsx index 52da8845d49..da8a88461c6 100644 --- a/apps/sim/app/(home)/components/enterprise/enterprise.tsx +++ b/apps/sim/app/(home)/components/enterprise/enterprise.tsx @@ -85,7 +85,7 @@ function TrustStrip() { SOC 2 & HIPAA - + Type II · PHI protected →
@@ -105,7 +105,7 @@ function TrustStrip() { Open Source - + View on GitHub →
@@ -120,7 +120,7 @@ function TrustStrip() { SSO & SCIM - + Okta, Azure AD, Google @@ -165,7 +165,7 @@ export default function Enterprise() {

Audit Trail

-

+

Every action is captured with full actor attribution.

@@ -179,7 +179,7 @@ export default function Enterprise() {

Access Control

-

+

Restrict providers, surfaces, and tools per group.

@@ -211,7 +211,7 @@ export default function Enterprise() { (tag, i) => ( {tag} @@ -221,7 +221,7 @@ export default function Enterprise() {
-

+

Ready for growth?

diff --git a/apps/sim/app/(home)/components/features/features.tsx b/apps/sim/app/(home)/components/features/features.tsx index d402803ac91..1b5d9b8c9a8 100644 --- a/apps/sim/app/(home)/components/features/features.tsx +++ b/apps/sim/app/(home)/components/features/features.tsx @@ -190,7 +190,6 @@ export default function Features() { width={1440} height={366} className='h-auto w-full' - priority />
diff --git a/apps/sim/app/(home)/components/footer/footer-cta.tsx b/apps/sim/app/(home)/components/footer/footer-cta.tsx index b67ae3b3f84..984252f270a 100644 --- a/apps/sim/app/(home)/components/footer/footer-cta.tsx +++ b/apps/sim/app/(home)/components/footer/footer-cta.tsx @@ -67,6 +67,7 @@ export function FooterCTA() { type='button' onClick={handleSubmit} disabled={isEmpty} + aria-label='Submit message' className='flex h-[28px] w-[28px] items-center justify-center rounded-full border-0 p-0 transition-colors' style={{ background: isEmpty ? '#C0C0C0' : '#1C1C1C', diff --git a/apps/sim/app/(home)/components/footer/footer.tsx b/apps/sim/app/(home)/components/footer/footer.tsx index 35a12d31b5c..0f167ee787f 100644 --- a/apps/sim/app/(home)/components/footer/footer.tsx +++ b/apps/sim/app/(home)/components/footer/footer.tsx @@ -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' }, ] diff --git a/apps/sim/app/(home)/components/pricing/pricing.tsx b/apps/sim/app/(home)/components/pricing/pricing.tsx index 0c244a398e2..509da377d69 100644 --- a/apps/sim/app/(home)/components/pricing/pricing.tsx +++ b/apps/sim/app/(home)/components/pricing/pricing.tsx @@ -25,6 +25,7 @@ const PRICING_TIERS: PricingTier[] = [ '5GB file storage', '3 tables · 1,000 rows each', '5 min execution limit', + '5 concurrent/workspace', '7-day log retention', 'CLI/SDK/MCP Access', ], @@ -42,6 +43,7 @@ const PRICING_TIERS: PricingTier[] = [ '50GB file storage', '25 tables · 5,000 rows each', '50 min execution · 150 runs/min', + '50 concurrent/workspace', 'Unlimited log retention', 'CLI/SDK/MCP Access', ], @@ -59,6 +61,7 @@ const PRICING_TIERS: PricingTier[] = [ '500GB file storage', '25 tables · 5,000 rows each', '50 min execution · 300 runs/min', + '200 concurrent/workspace', 'Unlimited log retention', 'CLI/SDK/MCP Access', ], @@ -75,6 +78,7 @@ const PRICING_TIERS: PricingTier[] = [ 'Custom file storage', '10,000 tables · 1M rows each', 'Custom execution limits', + 'Custom concurrency limits', 'Unlimited log retention', 'SSO & SCIM · SOC2 & HIPAA', 'Self hosting · Dedicated support', diff --git a/apps/sim/app/(landing)/blog/components/blog-image.tsx b/apps/sim/app/(landing)/blog/components/blog-image.tsx new file mode 100644 index 00000000000..84be2e6b2f5 --- /dev/null +++ b/apps/sim/app/(landing)/blog/components/blog-image.tsx @@ -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 ( + <> + setIsLightboxOpen(true)} + /> + setIsLightboxOpen(false)} + src={src} + alt={alt} + /> + + ) +} diff --git a/apps/sim/app/(landing)/blog/components/lightbox.tsx b/apps/sim/app/(landing)/blog/components/lightbox.tsx new file mode 100644 index 00000000000..edc83015f98 --- /dev/null +++ b/apps/sim/app/(landing)/blog/components/lightbox.tsx @@ -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(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 ( +
+
+ {alt} +
+
+ ) +} diff --git a/apps/sim/app/(landing)/partners/page.tsx b/apps/sim/app/(landing)/partners/page.tsx new file mode 100644 index 00000000000..e3c564edf01 --- /dev/null +++ b/apps/sim/app/(landing)/partners/page.tsx @@ -0,0 +1,293 @@ +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 ( +
+
+ +
+ +
+ {/* Hero */} +
+
+
+ Partner Program +
+

+ Build the future +
+ of AI automation +

+

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

+
+ {/* TODO: Uncomment when academy is public */} + {/* + Start Sim Academy → + */} + + Learn more + +
+
+
+ + {/* Benefits grid */} +
+
+
+ Why partner with Sim +
+
+ {BENEFITS.map((b) => ( +
+
{b.icon}
+

{b.title}

+

{b.description}

+
+ ))} +
+
+
+ + {/* How it works */} +
+
+
+ How it works +
+
+ {HOW_IT_WORKS.map((step) => ( +
+
+ {step.step} +
+
+

{step.title}

+

{step.description}

+
+
+ ))} +
+
+
+ + {/* Partner tiers */} +
+
+
+ Partner tiers +
+
+ {PARTNER_TIERS.map((tier) => ( +
+
+

{tier.name}

+ + {tier.badge} + +
+ +
+

+ Requirements +

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

Perks

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

+ Ready to get started? +

+

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

+ {/* TODO: Uncomment when academy is public */} + {/* + Start Sim Academy → + */} +
+
+
+ +
+
+ ) +} diff --git a/apps/sim/app/_shell/providers/theme-provider.tsx b/apps/sim/app/_shell/providers/theme-provider.tsx index 43a6f0af2b5..e64ec9232c0 100644 --- a/apps/sim/app/_shell/providers/theme-provider.tsx +++ b/apps/sim/app/_shell/providers/theme-provider.tsx @@ -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 ( {children} diff --git a/apps/sim/app/_styles/globals.css b/apps/sim/app/_styles/globals.css index e781191f6f9..0c80aef072a 100644 --- a/apps/sim/app/_styles/globals.css +++ b/apps/sim/app/_styles/globals.css @@ -15,6 +15,12 @@ --toolbar-triggers-height: 300px; /* TOOLBAR_TRIGGERS_HEIGHT.DEFAULT */ --editor-connections-height: 172px; /* EDITOR_CONNECTIONS_HEIGHT.DEFAULT */ --terminal-height: 206px; /* TERMINAL_HEIGHT.DEFAULT */ + --auth-primary-btn-bg: #ffffff; + --auth-primary-btn-border: #ffffff; + --auth-primary-btn-text: #000000; + --auth-primary-btn-hover-bg: #e0e0e0; + --auth-primary-btn-hover-border: #e0e0e0; + --auth-primary-btn-hover-text: #000000; /* z-index scale for layered UI Popover must be above modal so dropdowns inside modals render correctly */ diff --git a/apps/sim/app/academy/(catalog)/[courseSlug]/components/course-progress.tsx b/apps/sim/app/academy/(catalog)/[courseSlug]/components/course-progress.tsx new file mode 100644 index 00000000000..ff0b9edd49e --- /dev/null +++ b/apps/sim/app/academy/(catalog)/[courseSlug]/components/course-progress.tsx @@ -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>(() => 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 && ( +
+
+
+ Your progress + + {completedCount}/{totalLessons} lessons + +
+
+
+
+
+
+ )} + +
+
+ {course.modules.map((mod, modIndex) => ( +
+
+ Module {modIndex + 1} +
+
+

{mod.title}

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

Certificate issued!

+

+ {certificate.certificateNumber} +

+
+
+ + View certificate + + +
+ ) : ( +
+
+ +
+

Course Complete!

+

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

+
+
+ {session ? ( + + ) : ( + + Sign in + + )} +
+ )} +
+
+ )} + + ) +} diff --git a/apps/sim/app/academy/(catalog)/[courseSlug]/page.tsx b/apps/sim/app/academy/(catalog)/[courseSlug]/page.tsx new file mode 100644 index 00000000000..63da8de68d2 --- /dev/null +++ b/apps/sim/app/academy/(catalog)/[courseSlug]/page.tsx @@ -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 { + const { courseSlug } = await params + const course = getCourse(courseSlug) + if (!course) return { title: 'Course Not Found' } + return { + title: course.title, + description: course.description, + } +} + +export default async function CourseDetailPage({ params }: CourseDetailPageProps) { + const { courseSlug } = await params + const course = getCourse(courseSlug) + + if (!course) notFound() + + return ( +
+
+
+ + ← All courses + +

+ {course.title} +

+ {course.description && ( +

+ {course.description} +

+ )} +
+ {course.estimatedMinutes && ( + + + {course.estimatedMinutes} min total + + )} + + + Certificate upon completion + +
+
+
+ + +
+ ) +} diff --git a/apps/sim/app/academy/(catalog)/certificate/[certificateNumber]/page.tsx b/apps/sim/app/academy/(catalog)/certificate/[certificateNumber]/page.tsx new file mode 100644 index 00000000000..3f70f454973 --- /dev/null +++ b/apps/sim/app/academy/(catalog)/certificate/[certificateNumber]/page.tsx @@ -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 { + const { certificateNumber } = await params + const certificate = await fetchCertificate(certificateNumber) + if (!certificate) return { title: 'Certificate Not Found' } + return { + title: `${certificate.metadata?.courseTitle ?? 'Certificate'} — Certificate`, + description: `Verified certificate of completion awarded to ${certificate.metadata?.recipientName ?? 'a recipient'}.`, + } +} + +const fetchCertificate = cache( + async (certificateNumber: string): Promise => { + const [row] = await db + .select() + .from(academyCertificate) + .where(eq(academyCertificate.certificateNumber, certificateNumber)) + .limit(1) + 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 ( +
+ {label} + {children} +
+ ) +} + +export default async function CertificatePage({ params }: CertificatePageProps) { + const { certificateNumber } = await params + const certificate = await fetchCertificate(certificateNumber) + + if (!certificate) notFound() + + return ( +
+
+
+
+
+ +
+
+ +
+ Certificate of Completion +
+ +

+ {certificate.metadata?.courseTitle} +

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

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

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

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

+
+
+ ) +} diff --git a/apps/sim/app/academy/(catalog)/layout.tsx b/apps/sim/app/academy/(catalog)/layout.tsx new file mode 100644 index 00000000000..fb400ef2834 --- /dev/null +++ b/apps/sim/app/academy/(catalog)/layout.tsx @@ -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 ( + <> + + {children} +