feat: certificates, landing pages, ai tutor, website update, compliance

This commit is contained in:
Rotimi Best
2026-05-18 03:47:22 +01:00
parent c31ad63955
commit ac3a2c14a7
644 changed files with 56050 additions and 5439 deletions
@@ -0,0 +1,86 @@
<script>
import { BlurFade } from '@cio/ui/custom/animation/blurfade';
import LayoutTemplate from '@lucide/svelte/icons/layout-template';
import MessagesSquare from '@lucide/svelte/icons/messages-square';
import Terminal from '@lucide/svelte/icons/terminal';
const lanes = [
{
Icon: LayoutTemplate,
title: 'Pick a template',
description: 'Product 101, onboarding, or expert track. Fill three fields, get a course draft.'
},
{
Icon: MessagesSquare,
title: 'Chat with the builder',
description: 'Claude lives in the editor. It plans, drafts, and writes quizzes — you approve each step.'
},
{
Icon: Terminal,
title: 'Use your own agent',
description: 'Drive course authoring from Cursor, Claude Code, Codex, or OpenCode via MCP.'
}
];
const agents = [
{ name: 'Cursor', logo: '/cursor-logo.png' },
{ name: 'Claude Code', logo: '/claude-logo.svg' },
{ name: 'Codex', logo: '/codex-logo.png' },
{ name: 'OpenCode', logo: '/opencode-logo.svg' }
];
</script>
<section class="bg-gray-50 px-6 py-12 lg:px-12 lg:py-16">
<div class="mx-auto max-w-[1100px]">
<BlurFade once>
<div class="mb-2 text-xs font-medium tracking-widest text-blue-700 uppercase">AI authoring</div>
<h2 class="max-w-[680px] text-[clamp(1.75rem,3vw,2.6rem)] leading-[1.1] font-medium tracking-tight text-gray-950">
Build entire courses with AI.
</h2>
<p class="mt-3 max-w-[560px] text-sm leading-relaxed text-gray-500">
Generate the structure, lessons, and quizzes from your docs — then review and approve every step before it
ships. Three ways in. Pick whichever fits your team.
</p>
</BlurFade>
<BlurFade delay={0.1} once>
<div class="mx-auto mt-10 w-full max-w-[1100px]">
<div class="overflow-hidden rounded-2xl bg-white shadow-sm ring-1 ring-gray-200">
<img
src="/ai-course-builder.jpeg"
alt="Course builder — plan, approve, implement. Templates, in-editor chat, and MCP."
class="block h-auto w-full"
loading="lazy"
decoding="async"
/>
</div>
</div>
</BlurFade>
<div class="mt-10 grid grid-cols-1 gap-8 md:grid-cols-3">
{#each lanes as lane, i}
<BlurFade delay={0.05 * i + 0.15} once>
<div>
<div class="text-gray-950">
<lane.Icon size={20} strokeWidth={1.6} />
</div>
<h3 class="mt-3 text-sm font-medium text-gray-950 lg:text-base">{lane.title}</h3>
<p class="mt-1.5 max-w-[280px] text-xs leading-relaxed text-gray-500 lg:text-sm">{lane.description}</p>
</div>
</BlurFade>
{/each}
</div>
<BlurFade delay={0.3} once>
<div class="mt-10 flex flex-wrap items-center gap-x-6 gap-y-3 border-t border-gray-200 pt-6">
<span class="text-[10px] font-medium tracking-widest text-gray-500 uppercase">Works with</span>
{#each agents as agent}
<div class="flex items-center gap-1.5">
<img src={agent.logo} alt="{agent.name} logo" class="h-4 w-4 rounded-full" />
<span class="text-xs text-gray-600">{agent.name}</span>
</div>
{/each}
</div>
</BlurFade>
</div>
</section>
@@ -1,6 +1,23 @@
<script>
import { BlurFade } from '@cio/ui/custom/animation/blurfade';
import { BorderBeam } from '@cio/ui/custom/animation/border-beam';
import CodeBlock from './code-block.svelte';
// mcp.json — sky-300 keys · emerald-300 strings · slate-500 punctuation
// 2-space indent (real spaces; body uses whitespace-pre).
const mcpJsonLines = [
'<span class="text-slate-500">{</span>',
' <span class="text-sky-300">"mcpServers"</span><span class="text-slate-500">: {</span>',
' <span class="text-sky-300">"classroomio"</span><span class="text-slate-500">: {</span>',
' <span class="text-sky-300">"command"</span><span class="text-slate-500">:</span> <span class="text-emerald-300">"npx"</span><span class="text-slate-500">,</span>',
' <span class="text-sky-300">"args"</span><span class="text-slate-500">: [</span><span class="text-emerald-300">"-y"</span><span class="text-slate-500">,</span> <span class="text-emerald-300">"@classroomio/mcp"</span><span class="text-slate-500">],</span>',
' <span class="text-sky-300">"env"</span><span class="text-slate-500">: {</span>',
' <span class="text-sky-300">"CLASSROOMIO_API_URL"</span><span class="text-slate-500">:</span> <span class="text-emerald-300">"https://api.classroomio.com"</span><span class="text-slate-500">,</span>',
' <span class="text-sky-300">"CLASSROOMIO_API_KEY"</span><span class="text-slate-500">:</span> <span class="text-emerald-300">"&lt;your-mcp-key&gt;"</span>',
' <span class="text-slate-500">}</span>',
' <span class="text-slate-500">}</span>',
' <span class="text-slate-500">}</span>',
'<span class="text-slate-500">}</span>'
];
const aiFeatures = [
{
@@ -72,29 +89,12 @@
<BlurFade delay={0.2} once>
<div>
<!-- Code Block -->
<div class="relative overflow-hidden rounded-2xl border border-gray-800 bg-[#0d0d0d]">
<BorderBeam size={200} duration={10} colorFrom="#0233BD" colorTo="#6b8fff" />
<div class="flex items-center gap-2 border-b border-gray-800 bg-gray-900 px-5 py-3.5">
<div class="h-2.5 w-2.5 rounded-full bg-red-400"></div>
<div class="h-2.5 w-2.5 rounded-full bg-yellow-400"></div>
<div class="h-2.5 w-2.5 rounded-full bg-green-500"></div>
<span class="ml-2 text-xs text-gray-600">mcp.json</span>
</div>
<div class="p-6 font-mono text-xs leading-loose whitespace-pre text-gray-400 lg:text-sm">
{'{'}<br />{' '}<span class="text-blue-400">"mcpServers"</span>: {'{'}<br />{' '}<span
class="text-blue-400">"classroomio"</span
>: {'{'}<br />{' '}<span class="text-blue-400">"command"</span>:
<span class="text-green-400">"npx"</span>,<br />{' '}<span class="text-blue-400">"args"</span>:
[<span class="text-green-400">"-y"</span>, <span class="text-green-400">"@classroomio/mcp"</span>],<br
/>{' '}<span class="text-blue-400">"env"</span>: {'{'}<br />{' '}<span
class="text-blue-400">"CLASSROOMIO_API_URL"</span
>: <span class="text-green-400">"https://api.classroomio.com"</span>,<br />{' '}<span
class="text-blue-400">"CLASSROOMIO_API_KEY"</span
>: <span class="text-green-400">"&lt;your-mcp-key&gt;"</span><br />{' }'}<br />{' }'}<br
/>{' }'}<br />{'}'}
</div>
</div>
<CodeBlock
fileName="mcp.json"
lang="JSON"
codeLines={mcpJsonLines}
statusBar="paste into ~/.claude/mcp.json · restart your client"
/>
<!-- Supported Agents -->
<div class="mt-6">
@@ -0,0 +1,166 @@
<script>
import { BlurFade } from '@cio/ui/custom/animation/blurfade';
import { BotIcon, BotOffIcon } from '@cio/ui/custom/moving-icons';
import ImagePlaceholder from './image-placeholder.svelte';
import ArrowRight from '@lucide/svelte/icons/arrow-right';
/**
* AI teaching assistant section — centered, light theme, logo on top, gradient accent,
* large heading, short subhead, optional CTA, and an image placeholder underneath.
* Used on the homepage + each solution page with variant copy.
*
* @typedef {Object} Props
* @property {string} [accent] Small gradient line above the heading (e.g. "Academy", "Compliance AI")
* @property {string} [title] Main heading
* @property {string} description Subhead paragraph (1–3 sentences)
* @property {string} [ctaLabel] CTA button label (optional). Omit to hide the button.
* @property {string} [ctaHref] CTA destination
* @property {string} [imagePath] Suggested static file path for the image placeholder
* @property {string} [imageCaption] Caption shown inside the placeholder
* @property {boolean} [showImage] Show the image placeholder (default true)
* @property {string} [imageSrc] Real image path. When set, renders a real <img> (takes priority over vimeoVideoId).
* @property {string} [imageAlt] Alt text for the real image
* @property {string} [vimeoVideoId] Vimeo video id. When provided (and no imageSrc), replaces the image placeholder.
* @property {string} [videoAspect] CSS padding-top percentage controlling the video aspect ratio (default "54.68%")
* @property {string} [videoTitle] Accessible title for the iframe
* @property {string} [bgClass] Section background (default bg-white)
*/
/** @type {Props} */
let {
accent = 'Academy',
title = 'AI-powered Teaching Assistant',
description,
ctaLabel,
ctaHref = '/customer-education',
imagePath = '/static/ai-tutor-stack.png',
imageCaption = 'AI tutor in a lesson — lesson-aware chat, follow-ups, translation, summarisation',
showImage = true,
imageSrc,
imageAlt = '',
vimeoVideoId,
videoAspect = '54.68%',
videoTitle = 'AI Teaching Assistant on ClassroomIO.',
bgClass = 'bg-white'
} = $props();
/** @type {HTMLElement | null} */
let sectionEl = $state(null);
let inZone = $state(true);
const inZoneBorder = 'border-blue-500';
const outZoneBorder = 'border-yellow-500';
const inZoneBot = '#2563eb';
const outZoneBot = '#eab308';
</script>
<section
bind:this={sectionEl}
role="region"
aria-label={title}
onmouseenter={() => (inZone = true)}
onmouseleave={() => (inZone = false)}
class="{bgClass} {inZone
? inZoneBorder
: outZoneBorder} border-t-2 border-dashed px-6 py-12 transition-colors duration-200 lg:px-12 lg:py-16"
>
<div class="mx-auto max-w-[1100px] text-center">
<BlurFade once>
<div class="relative mx-auto mb-2 h-12 w-12">
<div
class="absolute inset-0 flex items-center justify-center transition-opacity duration-300 ease-in-out"
style="opacity: {inZone ? 1 : 0}; pointer-events: {inZone ? 'auto' : 'none'};"
aria-hidden={!inZone}
>
<BotIcon
size={48}
strokeWidth={1.6}
color={inZoneBot}
trackElement={sectionEl}
trackRadius={420}
maxEyeOffset={2.2}
/>
</div>
<div
class="absolute inset-0 flex items-center justify-center transition-opacity duration-300 ease-in-out"
style="opacity: {inZone ? 0 : 1}; pointer-events: {inZone ? 'none' : 'auto'};"
aria-hidden={inZone}
>
<BotOffIcon size={48} strokeWidth={1.6} color={outZoneBot} />
</div>
</div>
<p class="mb-6 text-[11px] text-gray-400">(you have full control over what i say and do)</p>
</BlurFade>
{#if accent}
<BlurFade delay={0.05} once>
<p
class="bg-gradient-to-r from-blue-600 via-fuchsia-500 to-orange-400 bg-clip-text text-[clamp(1.4rem,2.2vw,2rem)] leading-[1.1] font-medium tracking-tight text-transparent"
>
{accent}
</p>
</BlurFade>
{/if}
<BlurFade delay={0.1} once>
<h2
class="mx-auto max-w-[820px] text-[clamp(1.75rem,3.4vw,2.6rem)] leading-[1.1] font-medium tracking-tight text-gray-950"
>
{title}
</h2>
</BlurFade>
<BlurFade delay={0.2} once>
<p class="mx-auto mt-4 max-w-[600px] text-sm leading-relaxed text-gray-500">
{description}
</p>
</BlurFade>
{#if ctaLabel}
<BlurFade delay={0.3} once>
<div class="mt-6 flex justify-center">
<a
href={ctaHref}
class="inline-flex items-center gap-2 rounded-full bg-gray-950 px-5 py-2.5 text-sm font-medium text-white no-underline transition-all hover:-translate-y-0.5 hover:bg-blue-700"
>
{ctaLabel}
<ArrowRight size={14} />
</a>
</div>
</BlurFade>
{/if}
{#if !imageSrc && !vimeoVideoId && showImage}
<BlurFade delay={0.35} once>
<div class="mx-auto mt-10 w-full max-w-[820px]">
<ImagePlaceholder suggestedFile={imagePath} caption={imageCaption} aspect="aspect-[16/9]" />
</div>
</BlurFade>
{/if}
</div>
{#if imageSrc}
<BlurFade delay={0.35} once>
<div class="mx-auto mt-10 w-full max-w-[1100px]">
<div class="overflow-hidden rounded-2xl bg-white shadow-sm ring-1 ring-gray-200">
<img src={imageSrc} alt={imageAlt} class="block h-auto w-full" loading="lazy" decoding="async" />
</div>
</div>
</BlurFade>
{:else if vimeoVideoId}
<BlurFade delay={0.35} once>
<div class="mx-auto mt-10 w-full max-w-[960px] overflow-hidden rounded-lg">
<div style="padding:{videoAspect} 0 0 0;position:relative;">
<iframe
src="https://player.vimeo.com/video/{vimeoVideoId}?badge=0&autopause=0&player_id=0&app_id=58479&autoplay=1&muted=1&loop=1"
frameborder="0"
allow="autoplay; fullscreen; picture-in-picture; clipboard-write; encrypted-media; web-share"
referrerpolicy="strict-origin-when-cross-origin"
style="position:absolute;top:0;left:0;width:100%;height:100%;"
title={videoTitle}
></iframe>
</div>
</div>
</BlurFade>
{/if}
</section>
@@ -7,16 +7,14 @@
'Full API access to every object in your LMS. Create users, enroll learners, pull completions. All programmatically, at scale.'
},
{
icon: '⚡',
title: 'Zapier Integration',
comingSoon: true,
icon: '🤖',
title: 'MCP server',
description:
'Connect to 6,000+ apps without writing a line of code. Auto-enroll from your CRM, send Slack alerts on completion, update your spreadsheet.'
'Drive course authoring from Cursor, Claude Code, Codex, or OpenCode. The MCP server exposes the API surface as agent tools — list, create, update, publish.'
},
{
icon: '🪝',
title: 'Webhooks',
comingSoon: true,
description:
'Real-time event notifications for every action. Course enrolled, lesson completed, certificate issued. Push to any endpoint instantly.'
}
@@ -40,15 +38,7 @@
class="rounded-2xl border border-gray-200 bg-white p-8 transition-all duration-200 hover:border-gray-950 hover:shadow-lg hover:shadow-black/5"
>
<div class="mb-4 text-3xl">{item.icon}</div>
<h3 class="mb-2 text-base font-medium">
{item.title}
{#if item.comingSoon}
<span
class="ml-1 rounded-full bg-blue-700 px-1.5 py-0.5 align-middle text-[0.6rem] tracking-wide text-white uppercase"
>Soon</span
>
{/if}
</h3>
<h3 class="mb-2 text-base font-medium">{item.title}</h3>
<p class="text-sm leading-relaxed text-gray-500">{item.description}</p>
</div>
{/each}
@@ -0,0 +1,55 @@
<script>
import BoxedGrid from './boxed-grid.svelte';
import Webhook from '@lucide/svelte/icons/webhook';
import Code2 from '@lucide/svelte/icons/code-2';
import Sparkles from '@lucide/svelte/icons/sparkles';
const lanes = [
{
Icon: Code2,
title: 'REST API',
description: 'Every object, reachable over HTTP. Provision users, pull completions, build your own tools.'
},
{
Icon: Webhook,
title: 'Webhooks',
description: 'Signed events on enrollment, completion, certificate. Retries on failure.'
},
{
Icon: Sparkles,
title: 'MCP server',
description: 'Course authoring as agent tools. Works with Cursor, Claude Code, Codex, OpenCode.'
}
];
</script>
<section class="bg-gray-50 px-6 py-12 lg:px-12 lg:py-16">
<div class="mx-auto max-w-[1100px]">
<div class="mb-8 max-w-[640px]">
<div class="mb-2 text-xs font-medium tracking-widest text-blue-700 uppercase">For developers</div>
<h2 class="text-[clamp(1.75rem,2.6vw,2.2rem)] leading-[1.15] font-medium tracking-tight">API, Webhooks, MCP.</h2>
<p class="mt-3 text-sm leading-relaxed text-gray-500">
No middleware in between. Wire it into the stack you already have.
</p>
</div>
<BoxedGrid items={lanes} cols={3} padding="p-5 lg:p-6">
{#snippet cell(lane)}
<div class="mb-3 flex h-9 w-9 items-center justify-center rounded-xl bg-blue-50 text-blue-700">
<lane.Icon size={16} strokeWidth={1.75} />
</div>
<h3 class="text-sm font-medium text-gray-950 lg:text-base">{lane.title}</h3>
<p class="mt-1.5 text-xs leading-relaxed text-gray-500 lg:text-sm">{lane.description}</p>
{/snippet}
</BoxedGrid>
<div class="mt-6">
<a
href="/automation"
class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-5 py-2.5 text-sm font-medium text-gray-950 no-underline transition-all hover:border-gray-950"
>
See the developer reference →
</a>
</div>
</div>
</section>
@@ -0,0 +1,53 @@
<script>
import { BlurFade } from '@cio/ui/custom/animation/blurfade';
/**
* Boxed grid of cards — one outer rounded border, internal dividers between cells.
* Reused by RoiBand, AutomationBand, customer-ed outcomes, etc.
*
* @typedef {Object} Props
* @property {any[]} items
* @property {import('svelte').Snippet<[any, number]>} cell Snippet that renders one cell's content
* @property {2 | 3 | 4} [cols] Desktop column count (default 3)
* @property {string} [bgClass] Background class for the container (default bg-white)
* @property {string} [padding] Cell padding (default "p-6 lg:p-8")
* @property {boolean} [animate] Wrap each cell in BlurFade (default true)
*/
/** @type {Props} */
let { items, cell, cols = 3, bgClass = 'bg-white', padding = 'p-6 lg:p-8', animate = true } = $props();
const colsClass = {
2: 'sm:grid-cols-2',
3: 'sm:grid-cols-3',
4: 'sm:grid-cols-4'
}[cols];
</script>
<div class="grid grid-cols-1 overflow-hidden rounded-2xl border border-gray-200 {bgClass} {colsClass}">
{#each items as item, i (i)}
{#if animate}
<BlurFade delay={0.05 * i} once>
<div
class="h-full border-gray-200 {padding}"
class:border-t={i > 0}
class:sm:border-l={i % cols !== 0}
class:sm:border-t-0={i < cols}
class:sm:border-t={i >= cols}
>
{@render cell(item, i)}
</div>
</BlurFade>
{:else}
<div
class="h-full border-gray-200 {padding}"
class:border-t={i > 0}
class:sm:border-l={i % cols !== 0}
class:sm:border-t-0={i < cols}
class:sm:border-t={i >= cols}
>
{@render cell(item, i)}
</div>
{/if}
{/each}
</div>
@@ -0,0 +1,30 @@
<script>
import { BlurFade } from '@cio/ui/custom/animation/blurfade';
import ImagePlaceholder from './image-placeholder.svelte';
</script>
<section class="bg-white px-6 py-16 lg:px-12 lg:py-24">
<div class="mx-auto max-w-[1100px]">
<BlurFade delay={0} once>
<div class="mb-10 max-w-[620px]">
<div class="mb-3 text-xs font-medium tracking-widest text-blue-700 uppercase">Certificate templates</div>
<h2 class="text-[clamp(2rem,3vw,2.8rem)] leading-[1.15] font-medium tracking-tight">
Design certificates that
<em class="text-blue-700 not-italic">look like yours.</em>
</h2>
<p class="mt-5 text-base leading-relaxed text-gray-500">
A real design canvas — drag elements, swap brand colours and fonts, configure the ID format. Issue PDFs and
PNGs learners actually want to share.
</p>
</div>
</BlurFade>
<BlurFade delay={0.1} once>
<ImagePlaceholder
suggestedFile="/static/certificate-editor.png"
caption="Screenshot of the certificate template editor"
aspect="aspect-[16/9]"
/>
</BlurFade>
</div>
</section>
@@ -0,0 +1,69 @@
<script>
import { onMount } from 'svelte';
import { renderCertificateDocument, DEFAULT_CERTIFICATE_DESIGN } from '@cio/certificates';
/**
* Renders the default ClassroomIO certificate template (classique) in an iframe.
* Used on customer-education and compliance-training pages.
*
* @typedef {Object} Props
* @property {string} recipient Person's name
* @property {string} achievement Course or program name
* @property {string} issued Issue date (e.g. "11 May 2026")
* @property {string} verifyValue Certificate ID rendered in the seal
* @property {string} [orgName] Org name shown on the top tag (default "ClassroomIO")
* @property {string} [courseDescription] Body description (default derived from achievement)
*/
/** @type {Props} */
let {
recipient,
achievement,
issued,
verifyValue,
orgName = 'ClassroomIO',
courseDescription = `For successfully completing ${achievement}.`
} = $props();
const srcdoc = renderCertificateDocument(DEFAULT_CERTIFICATE_DESIGN, {
recipientName: recipient,
courseName: achievement,
courseDescription,
orgName,
date: issued,
certificateId: verifyValue
});
const NATIVE_W = 1100;
const NATIVE_H = 780;
/** @type {HTMLDivElement | null} */
let stageEl = $state(null);
let scale = $state(0.5);
onMount(() => {
if (!stageEl || typeof ResizeObserver === 'undefined') return;
const observer = new ResizeObserver(() => {
if (!stageEl) return;
const rect = stageEl.getBoundingClientRect();
if (rect.width > 0) scale = rect.width / NATIVE_W;
});
observer.observe(stageEl);
return () => observer.disconnect();
});
</script>
<div
bind:this={stageEl}
class="relative overflow-hidden rounded-2xl border border-gray-200 shadow-2xl/10"
style="aspect-ratio: {NATIVE_W} / {NATIVE_H};"
>
<iframe
title="Certificate preview"
sandbox="allow-same-origin"
{srcdoc}
class="absolute top-0 left-0 border-0"
style="width: {NATIVE_W}px; height: {NATIVE_H}px; transform: scale({scale}); transform-origin: top left;"
></iframe>
</div>
@@ -0,0 +1,89 @@
<script>
/**
* @typedef {Object} Props
* @property {string} fileName Filename shown in chrome (e.g. "mcp.json")
* @property {string[]} codeLines HTML-formatted source lines (one entry per visual line)
* @property {string} [lang] Optional language badge (e.g. "JSON", "HTML", "TypeScript")
* @property {string} [statusBar] Optional status bar text
* @property {boolean} [copyable] Show a Copy button (default true)
* @property {boolean} [showLineNumbers] Show line-number gutter (default false)
* @property {string} [class] Extra wrapper classes
*/
/** @type {Props} */
let {
fileName,
codeLines,
lang = '',
statusBar = '',
copyable = true,
showLineNumbers = false,
class: className = ''
} = $props();
</script>
<div
class="relative overflow-hidden rounded-2xl bg-[#0d1117] shadow-[0_20px_60px_-20px_rgba(0,0,0,0.6)] ring-1 ring-white/[0.08] {className}"
>
<!-- Editor chrome -->
<div class="flex items-center justify-between border-b border-white/[0.06] bg-[#0a0e14] px-4 py-3">
<div class="flex items-center gap-2">
<div class="h-2.5 w-2.5 rounded-full bg-[#ff5f57]"></div>
<div class="h-2.5 w-2.5 rounded-full bg-[#febc2e]"></div>
<div class="h-2.5 w-2.5 rounded-full bg-[#28c840]"></div>
<span class="cio-code-font ml-3 text-[12px] text-slate-500">{fileName}</span>
</div>
<div class="flex items-center gap-2">
{#if lang}
<span
class="cio-code-font rounded-md bg-white/[0.04] px-2 py-0.5 text-[10px] font-medium tracking-wider text-slate-400 uppercase"
>{lang}</span
>
{/if}
{#if copyable}
<span
class="cio-code-font cursor-pointer rounded-md bg-blue-500/10 px-2 py-0.5 text-[10px] font-medium tracking-wide text-blue-300 ring-1 ring-blue-500/20 transition-colors hover:bg-blue-500/15"
>Copy</span
>
{/if}
</div>
</div>
<!-- Body -->
<div class="cio-code-font flex text-[13px] leading-[1.6] text-slate-300">
{#if showLineNumbers}
<div class="py-5 pr-3 pl-5 text-right text-slate-600 tabular-nums select-none">
{#each codeLines as _, n}
<div>{n + 1}</div>
{/each}
</div>
{/if}
<div class="flex-1 overflow-x-auto whitespace-pre {showLineNumbers ? 'pr-5' : 'px-5'} py-5">
{#each codeLines as line}
<div>{@html line || ' '}</div>
{/each}
</div>
</div>
{#if statusBar}
<div class="cio-code-font border-t border-white/[0.06] bg-black/40 px-4 py-2 text-[10.5px] text-slate-500">
<span class="text-blue-300">●</span>
{statusBar}
</div>
{/if}
</div>
<style>
/* JetBrains Mono loaded via the Google Fonts link in src/app.html. System stack as fallback. */
/* :global(...*) forces the font onto every descendant — including {@html}-rendered
spans that don't carry Svelte's scope hash, and overrides any stray universal rules. */
.cio-code-font,
:global(.cio-code-font *) {
font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace;
font-feature-settings:
'liga' 0,
'calt' 0;
font-variant-ligatures: none;
letter-spacing: 0;
}
</style>
@@ -0,0 +1,102 @@
<script>
import { BlurFade } from '@cio/ui/custom/animation/blurfade';
import Code2 from '@lucide/svelte/icons/code-2';
import CodeBlock from './code-block.svelte';
/**
* @typedef {Object} Capability
* @property {string} label
* @property {string} sub
*/
/**
* @typedef {Object} Props
* @property {string} eyebrow Eyebrow label (e.g. "Embed anywhere")
* @property {string} title Section heading
* @property {string} description Body paragraph
* @property {string} fileName Filename shown in code-editor chrome (e.g. "partner-portal.html")
* @property {string[]} codeLines HTML-formatted lines for the code body (each may contain tag spans)
* @property {string} statusBar Bottom status line text
* @property {string} previewUrl URL shown in the preview browser bar
* @property {string} previewImageSrc Static path for the preview image
* @property {string} [previewAlt] Alt text for the preview image
* @property {Capability[]} [capabilities] Optional 3 capability bullets shown below the pair
* @property {string} [bgClass] Section background (default bg-white)
*/
/** @type {Props} */
let {
eyebrow,
title,
description,
fileName,
codeLines,
statusBar,
previewUrl,
previewImageSrc,
previewAlt = '',
capabilities = [],
bgClass = 'bg-white'
} = $props();
</script>
<section class="px-6 py-16 lg:px-12 lg:py-24 {bgClass}">
<div class="mx-auto max-w-[1100px]">
<!-- Intro -->
<div class="mb-12 grid grid-cols-1 gap-10 lg:grid-cols-[0.85fr_1.15fr] lg:gap-20">
<BlurFade delay={0} once>
<div>
<div class="mb-3 inline-flex items-center gap-2 text-xs font-medium tracking-widest text-blue-700 uppercase">
<Code2 size={14} />
{eyebrow}
</div>
<h2 class="text-[clamp(2rem,3vw,2.6rem)] leading-[1.15] font-medium tracking-tight">{title}</h2>
</div>
</BlurFade>
<BlurFade delay={0.1} once>
<p class="text-base leading-relaxed text-gray-500">{description}</p>
</BlurFade>
</div>
<!-- Code → Preview pair -->
<div class="grid grid-cols-1 items-center gap-6 lg:grid-cols-[0.95fr_1.05fr]">
<!-- LEFT: Code editor -->
<BlurFade delay={0.05} once>
<CodeBlock {fileName} {codeLines} lang="HTML" {statusBar} />
</BlurFade>
<!-- RIGHT: Preview -->
<BlurFade delay={0.15} once>
<div class="overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-2xl/5">
<!-- Browser chrome -->
<div class="flex items-center gap-2 border-b border-gray-200 bg-white px-4 py-3">
<div class="h-2 w-2 rounded-full bg-red-400"></div>
<div class="h-2 w-2 rounded-full bg-yellow-400"></div>
<div class="h-2 w-2 rounded-full bg-green-500"></div>
<div class="ml-3 flex flex-1 items-center gap-2 rounded-md bg-gray-50 px-3 py-1.5 text-xs text-gray-500">
<span class="text-green-600">●</span>
{previewUrl}
</div>
<span class="rounded-full bg-blue-50 px-2 py-0.5 text-[10px] font-medium text-blue-700">Renders as →</span>
</div>
<img src={previewImageSrc} alt={previewAlt} class="block h-auto w-full" loading="lazy" decoding="async" />
</div>
</BlurFade>
</div>
<!-- Capabilities -->
{#if capabilities.length}
<div class="mt-8 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{#each capabilities as item, i}
<BlurFade delay={0.05 * i} once>
<div class="rounded-xl border border-gray-200 bg-white px-4 py-3">
<p class="text-sm font-medium text-gray-950">{item.label}</p>
<p class="text-xs text-gray-500">{item.sub}</p>
</div>
</BlurFade>
{/each}
</div>
{/if}
</div>
</section>
@@ -1,6 +1,5 @@
<script>
import { BlurFade } from '@cio/ui/custom/animation/blurfade';
import { BorderBeam } from '@cio/ui/custom/animation/border-beam';
import { DotPattern } from '@cio/ui/custom/animation/dot-pattern';
const features = [
@@ -146,8 +145,6 @@
<div class="relative">
<div class="relative overflow-hidden rounded-2xl border border-white/[0.08] bg-white/[0.03] p-6">
<BorderBeam colorFrom="#3b82f6" colorTo="#0233BD" size={200} duration={8} />
<div class="mb-5 flex items-center gap-2">
<div class="h-3 w-3 rounded-full bg-white/[0.15]"></div>
<div class="h-3 w-3 rounded-full bg-white/[0.1]"></div>
@@ -0,0 +1,98 @@
<script>
import { BlurFade } from '@cio/ui/custom/animation/blurfade';
import ArrowRight from '@lucide/svelte/icons/arrow-right';
/**
* @typedef {Object} Feature
* @property {any} Icon
* @property {string} title
* @property {string} description
*/
/**
* @typedef {Object} Props
* @property {string} eyebrow
* @property {string} title
* @property {string} [description]
* @property {string} [ctaLabel]
* @property {string} [ctaHref]
* @property {Feature[]} features
* @property {2|3|4} [columns] Desktop columns (default 3)
* @property {string} [bgClass]
*/
/** @type {Props & { headerAside?: import('svelte').Snippet }} */
let {
eyebrow,
title,
description,
ctaLabel,
ctaHref,
features,
columns = 3,
bgClass = 'bg-white',
headerAside
} = $props();
// Per-columns class string so Tailwind picks up the arbitrary nth-child variants statically.
const gridClass = {
2: 'sm:grid-cols-2 [&>*:nth-child(2n)]:sm:border-r-0 [&>*:nth-last-child(-n+2)]:sm:border-b-0',
3: 'sm:grid-cols-2 lg:grid-cols-3 [&>*:nth-child(2n)]:sm:border-r-0 [&>*:nth-child(2n)]:lg:border-r [&>*:nth-child(3n)]:lg:border-r-0 [&>*:nth-last-child(-n+2)]:sm:border-b-0 [&>*:nth-last-child(-n+3)]:lg:border-b-0',
4: 'sm:grid-cols-2 lg:grid-cols-4 [&>*:nth-child(2n)]:sm:border-r-0 [&>*:nth-child(2n)]:lg:border-r [&>*:nth-child(4n)]:lg:border-r-0 [&>*:nth-last-child(-n+2)]:sm:border-b-0 [&>*:nth-last-child(-n+4)]:lg:border-b-0'
}[columns];
</script>
<section class="px-6 py-12 lg:px-12 lg:py-16 {bgClass}">
<div class="mx-auto max-w-[1100px]">
<div class="mb-10 grid grid-cols-1 gap-10 lg:grid-cols-[1.05fr_1fr] lg:gap-20">
<div>
<div class="mb-3 text-xs font-medium tracking-widest text-blue-700 uppercase">{eyebrow}</div>
<h2 class="text-[clamp(2rem,3.2vw,2.75rem)] leading-[1.05] font-medium tracking-tight text-gray-950">
{title}
</h2>
</div>
{#if headerAside}
<div class="flex flex-col justify-start lg:pt-2">
{@render headerAside()}
</div>
{:else if description || ctaLabel}
<div class="flex flex-col justify-start lg:pt-2">
{#if description}
<p class="text-base leading-relaxed text-gray-600">{description}</p>
{/if}
{#if ctaLabel && ctaHref}
<a
href={ctaHref}
class="mt-6 inline-flex w-fit items-center gap-2 text-sm font-medium text-blue-700 underline decoration-blue-300 underline-offset-4 transition-colors hover:text-blue-800 hover:decoration-blue-500"
>
{ctaLabel}
<ArrowRight size={16} />
</a>
{/if}
</div>
{/if}
</div>
<div class="overflow-hidden rounded-2xl border border-gray-200 bg-white">
<div class="grid grid-cols-1 {gridClass}">
{#each features as feature, i}
<div class="group relative border-b border-gray-200 transition-colors hover:bg-gray-50/60 sm:border-r">
<BlurFade delay={0.04 * i} once>
<div class="h-full p-6">
<p class="font-mono text-[11px] tracking-[0.15em] text-gray-400">
{String(i + 1).padStart(2, '0')}
</p>
<div class="mt-4 flex items-center gap-2.5">
<span class="text-blue-700"><feature.Icon size={18} /></span>
<h3 class="text-sm font-semibold text-gray-950">{feature.title}</h3>
</div>
<p class="mt-3 text-sm leading-relaxed text-gray-500">{feature.description}</p>
</div>
</BlurFade>
</div>
{/each}
</div>
</div>
</div>
</section>
@@ -1,84 +1,207 @@
<script>
<script lang="ts">
import { BlurFade } from '@cio/ui/custom/animation/blurfade';
import Palette from '@lucide/svelte/icons/palette';
import Globe from '@lucide/svelte/icons/globe';
import Code2 from '@lucide/svelte/icons/code-2';
import Settings2 from '@lucide/svelte/icons/settings-2';
import Lock from '@lucide/svelte/icons/lock';
import { onDestroy } from 'svelte';
const features = [
{
Icon: Palette,
iconBg: 'bg-rose-50',
iconColor: 'text-rose-600',
title: 'Full Theming',
description: 'Pick the colors, fonts, and logo. Your brand shows up everywhere your learners look.',
tags: ['Custom colors', 'Logo upload', 'Font control']
},
{
Icon: Globe,
iconBg: 'bg-blue-50',
iconColor: 'text-blue-600',
title: 'Custom Domain',
description:
'Host the LMS on learn.yourcompany.com with SSL included. No ClassroomIO branding anywhere on the page.',
tags: ['SSL included', 'White label', 'DNS setup guide']
},
{
Icon: Code2,
iconBg: 'bg-violet-50',
iconColor: 'text-violet-600',
title: 'Embed courses on your site',
description:
'Add one script tag to any page. Your courses render right there and send learners back to your LMS to enroll.',
tags: ['One-line script', 'Auto-styled', 'Links to your domain']
},
{
Icon: Settings2,
iconBg: 'bg-emerald-50',
iconColor: 'text-emerald-600',
title: 'Deep LMS Customization',
description:
'The codebase is modern and readable. Your developers can change anything without fighting a plugin system.',
tags: ['Modern stack', 'Clean codebase', 'Extensible']
}
const ROTATION_INTERVAL_MS = 3000;
let selectedTheme = $state('blue');
let intervalId = $state<NodeJS.Timeout | null>(null);
const themes = [
{ id: 'blue', color: '#1d4ee2', bgClass: 'bg-blue-700/20', activeBorder: 'border-[#1d4ee2]' },
{ id: 'rose', color: '#be1241', bgClass: 'bg-red-600/20', activeBorder: 'border-[#be1241]' },
{ id: 'green', color: '#0c891b', bgClass: 'bg-green-600/20', activeBorder: 'border-[#0c891b]' },
{ id: 'orange', color: '#cc4902', bgClass: 'bg-orange-600/20', activeBorder: 'border-[#cc4902]' },
{ id: 'purple', color: '#9333ea', bgClass: 'bg-purple-600/20', activeBorder: 'border-purple-600' }
];
function startRotation() {
if (intervalId) return;
intervalId = setInterval(() => {
const currentIndex = themes.findIndex((t) => t.id === selectedTheme);
const nextIndex = (currentIndex + 1) % themes.length;
selectedTheme = themes[nextIndex].id;
}, ROTATION_INTERVAL_MS);
}
function stopRotation() {
if (intervalId) {
clearInterval(intervalId);
intervalId = null;
}
}
function selectTheme(themeId: string) {
selectedTheme = themeId;
stopRotation();
}
startRotation();
onDestroy(() => {
stopRotation();
});
</script>
<section class="bg-gray-50 px-6 py-16 lg:px-12 lg:py-24">
<section class="bg-[#f5f4f0] px-6 py-12 lg:px-12 lg:py-16">
<div class="mx-auto max-w-[1100px]">
<BlurFade once>
<div class="mb-3.5 text-xs font-medium tracking-widest text-blue-700 uppercase">Make it yours</div>
<h2 class="mb-4 text-[clamp(2rem,3vw,2.8rem)] leading-[1.15] font-medium tracking-tight">
Your brand.<br />Your domain. Your rules.
<h2
class="max-w-[820px] text-[clamp(1.75rem,2.8vw,2.4rem)] leading-[1.1] font-medium tracking-tight text-gray-950"
>
Make it yours,<br />down to the favicon.
</h2>
<p class="mb-14 max-w-[520px] text-base leading-relaxed text-gray-500 lg:text-lg">
ClassroomIO bends to fit your organization, not the other way around.
<p class="mt-3 max-w-[620px] text-sm leading-relaxed text-gray-500">
Your brand, your domain, your content. ClassroomIO bends to fit the academy you want to ship.
</p>
</BlurFade>
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
{#each features as feature, i}
<BlurFade delay={0.1 * i} once>
<div class="rounded-2xl border border-gray-200 bg-white p-8 lg:p-9">
<div
class="mb-5 flex h-12 w-12 items-center justify-center rounded-xl {feature.iconBg} {feature.iconColor}"
>
<feature.Icon class="h-6 w-6" strokeWidth={1.75} />
</div>
<h3 class="mb-2.5 text-xl font-medium tracking-tight">{feature.title}</h3>
<p class="mb-5 text-sm leading-relaxed text-gray-500">{feature.description}</p>
<div class="flex flex-wrap gap-2">
{#each feature.tags as tag}
<span
class="rounded-full border border-gray-200 bg-gray-50 px-2.5 py-1 text-xs font-medium text-gray-500"
<div class="mt-8 grid grid-cols-1 gap-4 md:grid-cols-2">
<!-- Theme + branding -->
<BlurFade delay={0.05} once>
<div
class="flex h-full flex-col rounded-2xl border border-gray-200/80 p-6 transition-colors duration-500 lg:p-7 {themes.find(
(t) => t.id === selectedTheme
)?.bgClass}"
>
<h3 class="text-base font-medium tracking-tight text-gray-950 lg:text-lg">Theme everything</h3>
<p class="mt-2 max-w-[420px] text-sm leading-relaxed text-gray-500">
Pick a theme color or drop in your own hex. Every page picks it up.
</p>
<!-- Visual: actual theme swatches from the app -->
<div class="mt-8 flex flex-1 flex-col justify-end">
<div class="flex flex-wrap items-center gap-2.5">
{#each themes as theme (theme.id)}
<button
type="button"
class="flex size-8 cursor-pointer items-center justify-center rounded-full border-2 bg-white transition-all {selectedTheme ===
theme.id
? theme.activeBorder
: 'border-transparent'}"
aria-label="{theme.id} theme"
onclick={() => selectTheme(theme.id)}
>
{tag}
</span>
<span class="size-5 rounded-full" style="background-color: {theme.color}"></span>
</button>
{/each}
<div
class="flex h-9 w-9 items-center justify-center rounded-full border-2 border-dashed border-gray-400 text-gray-500"
aria-label="Custom hex"
>
+
</div>
</div>
</div>
</BlurFade>
{/each}
</div>
</BlurFade>
<!-- Custom domain -->
<BlurFade delay={0.1} once>
<div class="flex h-full flex-col rounded-2xl border border-gray-200/80 bg-[#ecebe6] p-6 lg:p-7">
<h3 class="text-base font-medium tracking-tight text-gray-950 lg:text-lg">Your domain</h3>
<p class="mt-2 max-w-[420px] text-sm leading-relaxed text-gray-500">
Map a CNAME, click verify. SSL is handled. No ClassroomIO branding on the page.
</p>
<!-- Visual: browser bar mock -->
<div class="mt-8 flex flex-1 flex-col justify-end">
<div class="overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm">
<div class="flex items-center gap-2 border-b border-gray-100 bg-white px-3 py-2.5">
<div class="h-2 w-2 rounded-full bg-red-400"></div>
<div class="h-2 w-2 rounded-full bg-yellow-400"></div>
<div class="h-2 w-2 rounded-full bg-green-500"></div>
<div
class="ml-2 flex flex-1 items-center gap-1.5 rounded-md bg-gray-50 px-2.5 py-1 text-[11px] text-gray-600"
>
<Lock size={10} class="text-green-600" />
<span class="font-mono">learn.acme.com</span>
</div>
</div>
<div class="grid grid-cols-3 gap-1.5 p-3">
<div class="h-12 rounded-md bg-gradient-to-br from-blue-200 to-blue-400"></div>
<div class="h-12 rounded-md bg-gradient-to-br from-indigo-200 to-blue-500"></div>
<div class="h-12 rounded-md bg-gradient-to-br from-sky-200 to-cyan-400"></div>
</div>
</div>
</div>
</div>
</BlurFade>
<!-- Embed -->
<BlurFade delay={0.15} once>
<div class="flex h-full flex-col rounded-2xl border border-gray-200/80 bg-[#ecebe6] p-6 lg:p-7">
<h3 class="text-base font-medium tracking-tight text-gray-950 lg:text-lg">Embed anywhere</h3>
<p class="mt-2 max-w-[420px] text-sm leading-relaxed text-gray-500">
One script tag. Drop courses into your help center, in-product, or marketing site.
</p>
<!-- Visual: code snippet -->
<div class="mt-8 flex flex-1 flex-col justify-end">
<div
class="overflow-hidden rounded-xl border border-gray-800/90 bg-gray-950 p-4 font-mono text-[11px] leading-relaxed text-gray-300 shadow-sm"
>
<p class="text-gray-500">&lt;!-- in your help center --&gt;</p>
<p class="mt-1.5">
<span class="text-pink-400">&lt;script</span>
<span class="text-sky-300"> src</span>=<span class="text-emerald-300">"cdn.cio/widget.js"</span><span
class="text-pink-400">&gt;&lt;/script&gt;</span
>
</p>
<p class="mt-1.5">
<span class="text-pink-400">&lt;div</span>
<span class="text-sky-300"> data-cio-widget</span>=<span class="text-emerald-300">"onboarding"</span
><span class="text-pink-400">&gt;&lt;/div&gt;</span>
</p>
</div>
</div>
</div>
</BlurFade>
<!-- Open source / deep customization -->
<BlurFade delay={0.2} once>
<div class="flex h-full flex-col rounded-2xl border border-gray-200/80 bg-[#ecebe6] p-6 lg:p-7">
<h3 class="text-base font-medium tracking-tight text-gray-950 lg:text-lg">Fork the codebase</h3>
<p class="mt-2 max-w-[420px] text-sm leading-relaxed text-gray-500">
Modern stack, readable codebase, AGPL on GitHub. Change whatever you need.
</p>
<!-- Visual: stylised file tree / stack -->
<div class="mt-8 flex flex-1 flex-col justify-end">
<div class="space-y-1.5">
<div
class="flex items-center gap-2 rounded-md bg-white/70 px-3 py-2 text-[11px] font-medium text-gray-700 ring-1 ring-gray-200"
>
<span class="h-1.5 w-1.5 rounded-full bg-emerald-500"></span>
<span class="font-mono">apps/dashboard</span>
<span class="ml-auto text-gray-400">Svelte 5</span>
</div>
<div
class="flex items-center gap-2 rounded-md bg-white/70 px-3 py-2 text-[11px] font-medium text-gray-700 ring-1 ring-gray-200"
>
<span class="h-1.5 w-1.5 rounded-full bg-blue-500"></span>
<span class="font-mono">apps/api</span>
<span class="ml-auto text-gray-400">Hono · Drizzle</span>
</div>
<div
class="flex items-center gap-2 rounded-md bg-white/70 px-3 py-2 text-[11px] font-medium text-gray-700 ring-1 ring-gray-200"
>
<span class="h-1.5 w-1.5 rounded-full bg-fuchsia-500"></span>
<span class="font-mono">packages/ai-assistant</span>
<span class="ml-auto text-gray-400">Claude</span>
</div>
<div
class="flex items-center gap-2 rounded-md bg-white/70 px-3 py-2 text-[11px] font-medium text-gray-700 ring-1 ring-gray-200"
>
<span class="h-1.5 w-1.5 rounded-full bg-amber-500"></span>
<span class="font-mono">packages/mcp</span>
<span class="ml-auto text-gray-400">MCP</span>
</div>
</div>
</div>
</div>
</BlurFade>
</div>
</div>
</section>
+170 -88
View File
@@ -1,128 +1,210 @@
<script>
import { Button } from '@cio/ui/base/button';
import Facebook from '@lucide/svelte/icons/facebook';
import Youtube from '@lucide/svelte/icons/youtube';
import Twitter from '@lucide/svelte/icons/twitter';
</script>
<div class="border-t px-[2%] lg:px-[14%]">
<div class="flex flex-col items-center justify-between py-[10%] pl-5 lg:flex-row lg:pl-0">
<div class="w-full min-w-[200px] lg:w-3/12">
<img width="32" height="32" loading="lazy" src="/logo-192.png" alt="classroomio logo" class="mb-5" />
<p class="mr-2 mb-5 text-sm leading-7 tracking-wide text-gray-600 lg:text-base lg:leading-7">
The Open Source Learning Management System for Companies
<br />
The LMS for compliance training and certification programs.
<footer
class="ui:border-border relative overflow-hidden border-t bg-[linear-gradient(to_top,#eef3fb_0%,#f8fafc_100%)] px-[5%] py-12 lg:px-[10%]"
>
<!-- Minor cloud — top-left, smaller than the hero -->
<svg
class="pointer-events-none absolute -top-4 left-3 z-[2] h-12 w-[160px] md:-top-5 md:left-4 md:h-16 md:w-[210px] lg:-top-6 lg:h-20 lg:w-[260px]"
viewBox="0 0 240 110"
fill="none"
aria-hidden="true"
>
<path
d="M 22 80
C 22 60, 42 50, 62 56
C 68 36, 100 30, 116 52
C 130 32, 168 38, 174 60
C 196 56, 216 68, 214 82
C 214 96, 196 102, 174 96
C 164 106, 130 106, 120 96
C 110 106, 78 106, 66 94
C 46 100, 22 96, 22 80 Z"
fill="white"
stroke="#94a3b8"
stroke-width="1.5"
/>
</svg>
<!-- Second smaller cloud, offset right -->
<svg
class="pointer-events-none absolute top-3 left-32 z-[2] h-9 w-[110px] md:top-4 md:left-44 md:h-12 md:w-[140px] lg:top-6 lg:left-[200px] lg:h-14 lg:w-[180px]"
viewBox="0 0 200 90"
fill="none"
aria-hidden="true"
>
<path
d="M 16 64
C 16 48, 34 40, 52 46
C 58 30, 86 26, 100 44
C 114 28, 144 34, 150 52
C 168 48, 184 58, 182 70
C 182 82, 168 86, 150 82
C 142 90, 114 90, 106 82
C 98 90, 70 90, 60 80
C 42 86, 16 82, 16 64 Z"
fill="white"
stroke="#94a3b8"
stroke-width="1.5"
/>
</svg>
<div class="relative z-10 flex flex-col gap-10 lg:flex-row lg:justify-between">
<!-- Brand -->
<div class="flex flex-col gap-4 lg:max-w-[220px]">
<img width="32" height="32" loading="lazy" src="/logo-192.png" alt="ClassroomIO logo" />
<p class="ui:text-muted-foreground text-sm leading-6">
The Open Source LMS for compliance training and certification programs.
</p>
<!-- <a
href="https://www.producthunt.com/posts/classroomio?utm_source=badge-featured&utm_medium=badge&utm_souce=badge-classroomio"
target="_blank"
><img
loading="lazy"
src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=425236&theme=light"
alt="ClassroomIO - The&#0032;Open&#0032;Source&#0032;Platform&#0032;for&#0032;Tech&#0032;Bootcamps | Product Hunt"
style="width: 250px; height: 54px;"
width="250"
height="54"
/></a
> -->
<div class="mt-5 flex items-center gap-3 lg:mt-10 lg:gap-7">
<Button
<div class="flex items-center gap-2">
<a
href="https://www.facebook.com/classroomiohq"
target="_blank"
variant="ghost"
aria-label="Facebook Link"
aria-label="Facebook"
class="ui:text-muted-foreground ui:hover:text-foreground p-1 transition-colors"
>
<Facebook />
</Button>
<Button href="https://twitter.com/classroomio" target="_blank" variant="ghost" aria-label="Twitter Link">
<img width="20" height="20" loading="lazy" src="/twitter.svg" alt="" />
</Button>
<Button href="https://www.youtube.com/@ClassroomIO" target="_blank" variant="ghost" aria-label="Youtube Link">
<Youtube />
</Button>
<Facebook size={18} />
</a>
<a
href="https://twitter.com/classroomio"
target="_blank"
aria-label="Twitter"
class="ui:text-muted-foreground ui:hover:text-foreground p-1 transition-colors"
>
<Twitter size={18} />
</a>
<a
href="https://www.youtube.com/@ClassroomIO"
target="_blank"
aria-label="YouTube"
class="ui:text-muted-foreground ui:hover:text-foreground p-1 transition-colors"
>
<Youtube size={18} />
</a>
</div>
</div>
<nav
class="mt-16 flex w-full flex-col items-start justify-end gap-12 space-y-6 lg:mt-0 lg:w-3/4 lg:flex-row lg:space-y-0"
>
<div class="max-w-[120px]">
<p class="mb-3 text-base leading-6 font-semibold text-gray-900 lg:mb-10 lg:text-base">Product</p>
<ul class="space-y-2 lg:space-y-4">
<li class="text-sm leading-6 tracking-wide text-gray-600 hover:text-gray-900 lg:text-base">
<a href="/#features"> Certifications </a>
<!-- Nav columns -->
<nav class="grid grid-cols-2 gap-8 sm:grid-cols-4">
<div>
<p class="ui:text-foreground mb-4 text-sm font-semibold">Product</p>
<ul class="space-y-3">
<li>
<a href="/mcp-recipes" class="ui:text-muted-foreground ui:hover:text-foreground text-sm transition-colors"
>MCP Recipes</a
>
</li>
<li class="text-sm leading-6 tracking-wide text-gray-600 hover:text-gray-900 lg:text-base">
<a href="/#ai"> AI Content </a>
<li>
<a href="/automation" class="ui:text-muted-foreground ui:hover:text-foreground text-sm transition-colors"
>API & MCP</a
>
</li>
<!-- <li class="text-sm leading-6 tracking-wide text-gray-600 hover:text-gray-900 lg:text-base">
<a href="/#deploy"> Deploy </a>
</li> -->
<li class="text-sm leading-6 tracking-wide text-gray-600 hover:text-gray-900 lg:text-base">
<a href="/#testimonial"> Testimonials </a>
<li>
<a href="/pricing" class="ui:text-muted-foreground ui:hover:text-foreground text-sm transition-colors"
>Pricing</a
>
</li>
</ul>
</div>
<div class="max-w-[120px]">
<p class="mb-3 text-base leading-6 font-semibold text-gray-900 lg:mb-10 lg:text-base">Solutions</p>
<ul class="space-y-2 lg:space-y-4">
<li class="text-sm leading-6 tracking-wide text-gray-600 hover:text-gray-900 lg:text-base">
<a href="/employee-training">Employee Training</a>
<div>
<p class="ui:text-foreground mb-4 text-sm font-semibold">Solutions</p>
<ul class="space-y-3">
<li>
<a
href="/customer-education"
class="ui:text-muted-foreground ui:hover:text-foreground text-sm transition-colors">Customer Education</a
>
</li>
<li class="text-sm leading-6 tracking-wide text-gray-600 hover:text-gray-900 lg:text-base">
<a href="/bootcamps">Bootcamps</a>
<li>
<a
href="/compliance-training"
class="ui:text-muted-foreground ui:hover:text-foreground text-sm transition-colors">Compliance Training</a
>
</li>
<li class="text-sm leading-6 tracking-wide text-gray-600 hover:text-gray-900 lg:text-base">
<a href="/customer-education">Customer Education</a>
<li>
<a
href="/partner-training"
class="ui:text-muted-foreground ui:hover:text-foreground text-sm transition-colors">Partner Training</a
>
</li>
</ul>
</div>
<div class="max-w-[120px]">
<p class="mb-3 text-base leading-6 font-semibold text-gray-900 lg:mb-10 lg:text-base">Resources</p>
<ul class="space-y-2 lg:space-y-4">
<li class="text-sm leading-6 tracking-wide text-gray-600 hover:text-gray-900 lg:text-base">
<a href="/contact">Contact us</a>
<div>
<p class="ui:text-foreground mb-4 text-sm font-semibold">Resources</p>
<ul class="space-y-3">
<li>
<a
href="/docs-vs-company-academy"
class="ui:text-muted-foreground ui:hover:text-foreground text-sm transition-colors"
>Docs vs Company Academy</a
>
</li>
<li class="text-sm leading-6 tracking-wide text-gray-600 hover:text-gray-900 lg:text-base">
<a href="/teach">Teach with us</a>
<li>
<a href="/teach" class="ui:text-muted-foreground ui:hover:text-foreground text-sm transition-colors"
>Teach with us</a
>
</li>
<li class="text-sm leading-6 tracking-wide text-gray-600 hover:text-gray-900 lg:text-base">
<a target="_blank" href="https://classroomio.com/docs/"> Help center </a>
<li>
<a
href="https://classroomio.com/docs/"
target="_blank"
class="ui:text-muted-foreground ui:hover:text-foreground text-sm transition-colors">Help Center</a
>
</li>
<li class="text-sm leading-6 tracking-wide text-gray-600 hover:text-gray-900 lg:text-base">
<a target="_blank" href="https://classroomio.com/docs/quickstart/signup"> Getting Started </a>
<li>
<a
href="https://classroomio.com/docs/quickstart/signup"
target="_blank"
class="ui:text-muted-foreground ui:hover:text-foreground text-sm transition-colors">Getting Started</a
>
</li>
<li class="text-sm leading-6 tracking-wide text-gray-600 hover:text-gray-900 lg:text-base">
<a href="/oss-friends"> OSS Friends </a>
<li>
<a href="/oss-friends" class="ui:text-muted-foreground ui:hover:text-foreground text-sm transition-colors"
>OSS Friends</a
>
</li>
</ul>
</div>
<div class="max-w-[120px]">
<p class="mb-3 text-base leading-6 font-semibold text-gray-900 lg:mb-10 lg:text-base">Company</p>
<ul class="space-y-2 lg:space-y-4">
<li class="text-sm leading-6 tracking-wide text-gray-600 hover:text-gray-900 lg:text-base">
<a href="/roadmap"> Roadmap </a>
<div>
<p class="ui:text-foreground mb-4 text-sm font-semibold">Company</p>
<ul class="space-y-3">
<li>
<a href="/roadmap" class="ui:text-muted-foreground ui:hover:text-foreground text-sm transition-colors"
>Roadmap</a
>
</li>
<li class="text-sm leading-6 tracking-wide text-gray-600 hover:text-gray-900 lg:text-base">
<a href="mailto:help@classroomio.com" target="_blank"> Support </a>
<li>
<a
href="mailto:help@classroomio.com"
target="_blank"
class="ui:text-muted-foreground ui:hover:text-foreground text-sm transition-colors">Support</a
>
</li>
<li class="text-sm leading-6 tracking-wide text-gray-600 hover:text-gray-900 lg:text-base">
<a href="/privacy"> Privacy policy </a>
<li>
<a href="/privacy" class="ui:text-muted-foreground ui:hover:text-foreground text-sm transition-colors"
>Privacy Policy</a
>
</li>
<li class="text-sm leading-6 tracking-wide text-gray-600 hover:text-gray-900 lg:text-base">
<a target="_blank" href="https://classroomio.com/docs/quickstart/signup"> Getting Started </a>
</li>
<li class="text-sm leading-6 tracking-wide text-gray-600 hover:text-gray-900 lg:text-base">
<a href="/tos"> Terms of use </a>
<li>
<a href="/tos" class="ui:text-muted-foreground ui:hover:text-foreground text-sm transition-colors"
>Terms of Use</a
>
</li>
</ul>
</div>
</nav>
</div>
<footer class="border-t py-3 text-center text-xs leading-5 font-light text-gray-500 lg:text-left lg:text-base">
<p class="mx-auto w-fit">All rights reserved - ClassroomIO © {new Date().getFullYear()}</p>
</footer>
</div>
<div
class="ui:border-border ui:text-muted-foreground relative z-10 mt-10 border-t pt-6 text-center text-xs lg:text-left"
>
All rights reserved — ClassroomIO © {new Date().getFullYear()}
</div>
</footer>
@@ -0,0 +1,18 @@
<script>
/**
* Standard hero subtitle paragraph used across marketing pages.
* Defaults: `mt-6 max-w-[640px] text-lg leading-relaxed text-gray-600`.
* Pass `class` to override or extend (e.g. a different max-width).
*
* @typedef {Object} Props
* @property {string} [class]
* @property {import('svelte').Snippet} [children]
*/
/** @type {Props} */
let { class: className = '', children } = $props();
</script>
<p class="mt-6 max-w-[640px] text-lg leading-relaxed text-gray-600 {className}">
{@render children?.()}
</p>
+161 -124
View File
@@ -1,139 +1,176 @@
<script>
import { BlurFade } from '@cio/ui/custom/animation/blurfade';
import { BorderBeam } from '@cio/ui/custom/animation/border-beam';
import { Badge } from '@cio/ui/base/badge';
import { Button } from '@cio/ui/base/button';
import ParticleField from './particle-field.svelte';
</script>
<section class="px-6 pt-32 pb-16 lg:px-12 lg:pt-40 lg:pb-24">
<div class="mx-auto grid max-w-[1100px] grid-cols-1 items-center gap-12 lg:grid-cols-2 lg:gap-20">
<div>
<BlurFade delay={0} once>
<a href="/blog/learn-from-the-best" class="mb-7 inline-block">
<Badge variant="secondary" class="gap-2! px-3.5! py-1.5! text-xs! underline!">
<span class="relative flex h-2 w-2">
<span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-400 opacity-75"></span>
<span class="relative inline-flex h-2 w-2 rounded-full bg-green-500"></span>
</span>
Introducing ClassroomIO Version 2 →
</Badge>
</a>
</BlurFade>
<section class="relative overflow-hidden px-6 pt-32 pb-16 lg:px-12 lg:pt-40 lg:pb-24">
<!-- Soft off-white base with the faintest blue undertone -->
<div
class="pointer-events-none absolute inset-0 bg-[linear-gradient(135deg,#f7f9fc_0%,#eef2fa_60%,#e5ecf7_100%)]"
></div>
<BlurFade delay={0.1} once>
<h1 class="mb-6 text-[clamp(2.4rem,4vw,3.4rem)] leading-[1.12] font-medium tracking-tight text-gray-950">
The LMS for
<em class="text-blue-700 not-italic">compliance training</em>
and
<em class="text-blue-700 not-italic">certification programs.</em>
</h1>
</BlurFade>
<!-- Bordered clouds, top-left — crisp SVG silhouettes -->
<div class="pointer-events-none absolute inset-0 z-[1] overflow-hidden">
<!-- Cloud 1 (largest) -->
<svg
class="cio-cloud-1 absolute top-6 left-3 h-24 w-[220px] md:top-8 md:left-5 md:h-36 md:w-[340px] lg:top-10 lg:left-6 lg:h-48 lg:w-[460px]"
viewBox="0 0 240 110"
fill="none"
aria-hidden="true"
>
<path
d="M 22 80
C 22 60, 42 50, 62 56
C 68 36, 100 30, 116 52
C 130 32, 168 38, 174 60
C 196 56, 216 68, 214 82
C 214 96, 196 102, 174 96
C 164 106, 130 106, 120 96
C 110 106, 78 106, 66 94
C 46 100, 22 96, 22 80 Z"
fill="white"
stroke="#cbd5e1"
stroke-width="0.75"
/>
</svg>
<BlurFade delay={0.2} once>
<p class="mb-9 max-w-[480px] text-base leading-relaxed text-gray-500 lg:text-lg">
ClassroomIO is the open source LMS built for compliance-driven teams. Run your certification programs, track
completions, and prove training happened. No Moodle headaches.
</p>
</BlurFade>
<!-- Cloud 2 (medium) -->
<svg
class="cio-cloud-2 absolute top-24 left-40 h-16 w-[150px] md:top-32 md:left-56 md:h-24 md:w-[230px] lg:top-40 lg:left-72 lg:h-36 lg:w-[320px]"
viewBox="0 0 200 90"
fill="none"
aria-hidden="true"
>
<path
d="M 16 64
C 16 48, 34 40, 52 46
C 58 30, 86 26, 100 44
C 114 28, 144 34, 150 52
C 168 48, 184 58, 182 70
C 182 82, 168 86, 150 82
C 142 90, 114 90, 106 82
C 98 90, 70 90, 60 80
C 42 86, 16 82, 16 64 Z"
fill="white"
stroke="#cbd5e1"
stroke-width="0.75"
/>
</svg>
<BlurFade delay={0.3} once>
<div class="flex flex-wrap items-center gap-3.5">
<a
href="/signup"
class="inline-flex items-center gap-2 rounded-lg bg-gray-950 px-7 py-3.5 text-sm font-medium text-white no-underline transition-all duration-200 hover:-translate-y-0.5 hover:bg-blue-700 lg:text-base"
>
Get Started Free →
</a>
<button
data-cal-link="classroomio/demo"
data-cal-config="'layout':'month_view'"
class="cursor-pointer rounded-lg border-[1.5px] border-gray-200 bg-transparent px-7 py-3.5 text-sm font-medium text-gray-950 transition-all duration-200 hover:border-gray-950 lg:text-base"
>
Book a Demo
</button>
</div>
</BlurFade>
<!-- Cloud 3 (small, lower) -->
<svg
class="cio-cloud-3 absolute top-40 left-16 h-14 w-[120px] md:top-56 md:left-20 md:h-20 md:w-[180px] lg:top-72 lg:left-28 lg:h-28 lg:w-[240px]"
viewBox="0 0 180 80"
fill="none"
aria-hidden="true"
>
<path
d="M 14 56
C 14 42, 30 36, 44 40
C 50 26, 76 22, 88 38
C 100 24, 128 30, 134 46
C 150 42, 166 52, 164 62
C 164 72, 150 76, 134 72
C 128 80, 100 80, 92 72
C 84 80, 60 80, 52 70
C 36 76, 14 72, 14 56 Z"
fill="white"
stroke="#cbd5e1"
stroke-width="0.75"
/>
</svg>
</div>
<BlurFade delay={0.4} once>
<div class="mt-7 flex flex-wrap items-center gap-5 text-xs text-gray-500">
<span class="flex items-center gap-1.5">
<span class="font-medium text-blue-700">✓</span> Open source
</span>
<span class="flex items-center gap-1.5">
<span class="font-medium text-blue-700">✓</span> Self-hosted
</span>
<span class="flex items-center gap-1.5">
<span class="font-medium text-blue-700">✓</span> No per-seat fees
</span>
</div>
</BlurFade>
</div>
<!-- Particle cloud — same component used in the footer at lower intensity -->
<ParticleField class="absolute inset-0 z-[2] h-full w-full" />
<!-- Bottom fade to next section -->
<div
class="pointer-events-none absolute right-0 bottom-0 left-0 h-32 bg-gradient-to-b from-transparent to-white"
></div>
<div class="relative z-10 mx-auto flex max-w-[1100px] flex-col items-center text-center">
<BlurFade delay={0} once>
<h1 class="max-w-[820px] text-[clamp(2rem,4vw,3.25rem)] leading-[1.1] font-medium tracking-tight text-gray-950">
Teach your customers your product
<em class="text-blue-700 not-italic">before support has to.</em>
</h1>
</BlurFade>
<BlurFade delay={0.1} once>
<p class="mt-5 max-w-[580px] text-sm leading-relaxed lg:text-base">
Launch a branded customer academy in days. Train customers, certify power users, and drive adoption on your own
domain, with an AI tutor in every lesson.
</p>
</BlurFade>
<!-- Hero Visual -->
<BlurFade delay={0.2} once>
<div class="relative overflow-hidden rounded-2xl border border-gray-200 bg-gray-50 shadow-2xl/5">
<BorderBeam size={250} duration={12} colorFrom="#0233BD" colorTo="#6b8fff" delay={1} />
<div class="flex items-center gap-2 border-b border-gray-200 bg-white px-4 py-3.5">
<div class="h-2.5 w-2.5 rounded-full bg-red-400"></div>
<div class="h-2.5 w-2.5 rounded-full bg-yellow-400"></div>
<div class="h-2.5 w-2.5 rounded-full bg-green-500"></div>
</div>
<div class="p-6">
<div class="mb-3.5 text-xs font-medium tracking-wider text-gray-500 uppercase">Team Progress - This Week</div>
<div class="mt-7 flex flex-wrap items-center justify-center gap-3">
<Button data-cal-config="'layout':'month_view'" data-cal-link="classroomio/demo">Book a Demo</Button>
<Button href="/signup" variant="outline">Start free</Button>
</div>
</BlurFade>
</div>
<div class="mb-3 flex items-center gap-3.5 rounded-xl border border-gray-200 bg-white p-4">
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-blue-50 text-lg">📋</div>
<div class="min-w-0 flex-1">
<div class="mb-1 text-sm font-medium">Annual Compliance Training</div>
<div class="h-1 overflow-hidden rounded-full bg-gray-200">
<div class="h-full rounded-full bg-blue-700" style="width: 85%"></div>
</div>
</div>
<div
class="shrink-0 rounded-full bg-blue-50 px-2 py-0.5 text-[0.7rem] font-medium whitespace-nowrap text-blue-700"
>
85%
</div>
</div>
<div class="mb-3 flex items-center gap-3.5 rounded-xl border border-gray-200 bg-white p-4">
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-green-50 text-lg">🛡️</div>
<div class="min-w-0 flex-1">
<div class="mb-1 text-sm font-medium">Security & Compliance</div>
<div class="h-1 overflow-hidden rounded-full bg-gray-200">
<div class="h-full rounded-full bg-green-500" style="width: 100%"></div>
</div>
</div>
<div
class="shrink-0 rounded-full bg-green-50 px-2 py-0.5 text-[0.7rem] font-medium whitespace-nowrap text-green-600"
>
✓ Done
</div>
</div>
<div class="mb-3 flex items-center gap-3.5 rounded-xl border border-gray-200 bg-white p-4">
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-orange-50 text-lg">🎯</div>
<div class="min-w-0 flex-1">
<div class="mb-1 text-sm font-medium">Product Certification</div>
<div class="h-1 overflow-hidden rounded-full bg-gray-200">
<div class="h-full rounded-full bg-blue-700" style="width: 42%"></div>
</div>
</div>
<div
class="shrink-0 rounded-full bg-blue-50 px-2 py-0.5 text-[0.7rem] font-medium whitespace-nowrap text-blue-700"
>
42%
</div>
</div>
<div class="mt-4 flex items-center gap-3 rounded-lg bg-blue-50 p-4">
<span class="text-xl">🏆</span>
<div>
<div class="text-sm font-medium">3 certificates issued today</div>
<div class="text-xs text-gray-500">Sarah, Marcus & Priya completed their certs</div>
</div>
</div>
</div>
<div class="relative z-30 mx-auto mt-14 w-full max-w-[1100px]">
<BlurFade delay={0.3} once>
<div class="overflow-hidden rounded-2xl bg-white shadow-sm ring-1 ring-gray-200">
<img
src="/hero-academy.jpeg"
alt="Branded customer academy on a custom domain — catalog, programs, AI tutor"
width="2560"
height="1440"
class="block h-auto w-full"
loading="eager"
decoding="async"
/>
</div>
</BlurFade>
</div>
</section>
<style>
.cio-cloud-1 {
animation: cio-cloud-drift-1 22s ease-in-out infinite;
}
.cio-cloud-2 {
animation: cio-cloud-drift-2 28s ease-in-out infinite;
}
.cio-cloud-3 {
animation: cio-cloud-drift-3 26s ease-in-out infinite;
}
@keyframes cio-cloud-drift-1 {
0%,
100% {
transform: translate(0, 0) scale(1);
opacity: 0.9;
}
50% {
transform: translate(28px, 10px) scale(1.04);
opacity: 1;
}
}
@keyframes cio-cloud-drift-2 {
0%,
100% {
transform: translate(0, 0) scale(1);
opacity: 0.85;
}
50% {
transform: translate(36px, -8px) scale(1.06);
opacity: 1;
}
}
@keyframes cio-cloud-drift-3 {
0%,
100% {
transform: translate(0, 0) scale(1);
opacity: 0.8;
}
50% {
transform: translate(-12px, 14px) scale(1.05);
opacity: 0.95;
}
}
</style>
@@ -0,0 +1,57 @@
<script>
import { BlurFade } from '@cio/ui/custom/animation/blurfade';
import ImagePlaceholder from './image-placeholder.svelte';
/**
* Heading row + full-width image preview. Renders a real image when `imageSrc` is set,
* otherwise drops back to `ImagePlaceholder` so unstaged sections still surface the gap.
*
* @typedef {Object} Props
* @property {string} eyebrow
* @property {string} title
* @property {string} [description]
* @property {string} [imageSrc] Real image path (omit to render placeholder).
* @property {string} [imageAlt]
* @property {string} [suggestedFile] Required when `imageSrc` is omitted.
* @property {string} [caption]
* @property {string} [aspect] Tailwind aspect class (default "aspect-[16/9]")
* @property {string} [bgClass] (default "bg-gray-50")
*/
/** @type {Props} */
let {
eyebrow,
title,
description,
imageSrc,
imageAlt = '',
suggestedFile,
caption = '',
aspect = 'aspect-[16/9]',
bgClass = 'bg-gray-50'
} = $props();
</script>
<section class="px-6 py-12 lg:px-12 lg:py-16 {bgClass}">
<div class="mx-auto max-w-[1100px]">
<div class="mb-10 max-w-[620px]">
<div class="mb-2 text-xs font-medium tracking-widest text-blue-700 uppercase">{eyebrow}</div>
<h2 class="text-[clamp(1.75rem,2.6vw,2.2rem)] leading-[1.15] font-medium tracking-tight">{title}</h2>
{#if description}
<p class="mt-5 text-base leading-relaxed text-gray-500">{description}</p>
{/if}
</div>
<BlurFade delay={0.1} once>
<div class="mx-auto max-w-[1100px]">
{#if imageSrc}
<div class="overflow-hidden rounded-2xl bg-white shadow-sm ring-1 ring-gray-200">
<img src={imageSrc} alt={imageAlt} class="block h-auto w-full" loading="lazy" decoding="async" />
</div>
{:else}
<ImagePlaceholder {suggestedFile} {caption} {aspect} />
{/if}
</div>
</BlurFade>
</div>
</section>
@@ -0,0 +1,29 @@
<script>
import Image from '@lucide/svelte/icons/image';
/**
* @typedef {Object} Props
* @property {string} suggestedFile Suggested static path (e.g. "/static/foo.png")
* @property {string} [caption] Short description of what should go here
* @property {string} [aspect] Tailwind aspect class (default "aspect-[4/3]")
* @property {string} [className] Extra classes
*/
/** @type {Props} */
let { suggestedFile, caption = '', aspect = 'aspect-[4/3]', className = '' } = $props();
</script>
<div
class="relative flex w-full flex-col items-center justify-center gap-3 overflow-hidden rounded-2xl border-2 border-dashed border-blue-300 bg-gradient-to-br from-blue-50 via-white to-gray-100 p-6 text-center {aspect} {className}"
>
<div
class="flex h-14 w-14 items-center justify-center rounded-2xl bg-white text-blue-700 shadow ring-1 ring-blue-100"
>
<Image size={26} strokeWidth={1.6} />
</div>
<p class="text-sm font-medium text-gray-700">Image placeholder</p>
<p class="text-xs text-gray-500">Suggested: {suggestedFile}</p>
{#if caption}
<p class="max-w-[80%] text-[11px] text-gray-400">{caption}</p>
{/if}
</div>
+25
View File
@@ -1,30 +1,55 @@
// UI Components - Named exports for easy importing
export { default as AiBuildsSection } from './ai-builds-section.svelte';
export { default as AiSection } from './ai-section.svelte';
export { default as AiTutorSection } from './ai-tutor-section.svelte';
export { default as AutomateSection } from './automate-section.svelte';
export { default as AutomationBand } from './automation-band.svelte';
export { default as BlogListItem } from './blog-list-item.svelte';
export { default as BoxedGrid } from './boxed-grid.svelte';
export { default as CertificateMock } from './certificate-mock.svelte';
export { default as CertEditorSection } from './cert-editor-section.svelte';
export { default as CertSection } from './cert-section.svelte';
export { default as CodeBlock } from './code-block.svelte';
export { default as CoreFeatureSection } from './core-feature-section.svelte';
export { default as CoreFeatures } from './core-features.svelte';
export { default as CustomImg } from './custom-img.svelte';
export { default as DeploySection } from './deploy-section.svelte';
export { default as EmbedCodePreview } from './embed-code-preview.svelte';
export { default as EnterpriseSection } from './enterprise-section.svelte';
export { default as Faq } from './faq.svelte';
export { default as FeatureGrid } from './feature-grid.svelte';
export { default as FeaturesSection } from './features-section.svelte';
export { default as Footer } from './footer.svelte';
export { default as FooterCta } from './footer-cta.svelte';
export { default as Hero } from './hero.svelte';
export { default as HeroSubtitle } from './hero-subtitle.svelte';
export { default as ImageDemoSection } from './image-demo-section.svelte';
export { default as ImagePlaceholder } from './image-placeholder.svelte';
export { default as LanguageReel } from './language-reel.svelte';
export { default as LearnerSection } from './learner-section.svelte';
export { default as Modal } from './modal.svelte';
export { default as MoreFeatures } from './more-features.svelte';
export { default as McpServerSection } from './mcp-server-section.svelte';
export { default as MultiAcademySection } from './multi-academy-section.svelte';
export { default as Navigation } from './navigation.svelte';
export { default as OssSection } from './oss-section.svelte';
export { default as NotFound } from './not-found.svelte';
export { default as PageHeader } from './page-header.svelte';
export { default as PageSignupCTA } from './page-signup-cta.svelte';
export { default as ParticleField } from './particle-field.svelte';
export { default as PillList } from './pill-list.svelte';
export { default as QuestionTypes } from './question-types.svelte';
export { default as RoiBand } from './roi-band.svelte';
export { default as SenjaEmbed } from './senja-embed.svelte';
export { default as SplitFeatureSection } from './split-feature-section.svelte';
export { default as StudentDashboard } from './student-dashboard.svelte';
export { default as Success } from './success.svelte';
export { default as Testimonial } from './testimonial.svelte';
export { default as Timeline } from './timeline.svelte';
export { default as ToolsHeader } from './tools-header.svelte';
export { default as TrustBand } from './trust-band.svelte';
export { default as UploadWidget } from './upload-widget.svelte';
export { default as VideoDemoSection } from './video-demo-section.svelte';
export { default as VideoPlaceholder } from './video-placeholder.svelte';
export { default as VsHelpDocs } from './vs-help-docs.svelte';
export { default as WorkflowSteps } from './workflow-steps.svelte';
@@ -0,0 +1,238 @@
<script>
import { BlurFade } from '@cio/ui/custom/animation/blurfade';
import Check from '@lucide/svelte/icons/check';
import Languages from '@lucide/svelte/icons/languages';
/**
* @typedef {Object} Lang
* @property {string} code Two-letter language code (e.g. "en", "es")
* @property {string} native Native script name (e.g. "Español", "हिन्दी")
* @property {string} en English name (e.g. "Spanish", "Hindi")
* @property {string} sample Translated sample lesson title in this language
* @property {string} tone Tailwind gradient classes (e.g. "from-blue-100 to-blue-200")
* @property {string} ring Tailwind ring color class (e.g. "ring-blue-200")
*/
/**
* @typedef {Object} Props
* @property {Lang[]} langs
* @property {string} eyebrow Eyebrow label (e.g. "Global partners")
* @property {string} headlineLead Headline lead text (e.g. "Train your network")
* @property {string} headlineAccent Headline accent text shown in blue with a sweep (e.g. "in their language.")
* @property {string} description Body paragraph
* @property {string} moduleLabel Top of preview card (e.g. "Module 2 · Implementation")
* @property {string} footerLabel Bottom badge in preview card (e.g. "8 lessons" or "6 lessons · 18 min")
* @property {string} [bgClass] Optional section background (defaults to bg-gray-50)
*/
/** @type {Props} */
let {
langs,
eyebrow,
headlineLead,
headlineAccent,
description,
moduleLabel,
footerLabel,
bgClass = 'bg-gray-50'
} = $props();
</script>
<section class="relative overflow-hidden px-6 py-16 lg:px-12 lg:py-24 {bgClass}">
<div
class="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_85%_40%,rgba(2,51,189,0.06),transparent_55%)]"
></div>
<div class="relative mx-auto max-w-[1100px]">
<div class="grid grid-cols-1 items-center gap-12 lg:grid-cols-2 lg:gap-20">
<BlurFade delay={0} once>
<div>
<div class="mb-3 inline-flex items-center gap-2 text-xs font-medium tracking-widest text-blue-700 uppercase">
<Languages size={14} />
{eyebrow}
</div>
<h2 class="text-[clamp(2rem,3vw,2.6rem)] leading-[1.15] font-medium tracking-tight">
{headlineLead}
<span class="cio-lang-headline relative inline-block">
<span class="text-blue-700">{headlineAccent}</span>
</span>
</h2>
<p class="mt-5 text-base leading-relaxed text-gray-500">{description}</p>
<!-- Inline language switcher mock -->
<div
class="mt-7 inline-flex w-full max-w-[320px] flex-col rounded-2xl border border-gray-200 bg-white p-1.5 shadow-sm"
>
<div class="px-3 py-1.5 text-[10px] font-medium tracking-widest text-gray-500 uppercase">
Lesson language
</div>
<div class="space-y-1">
{#each langs.slice(0, 4) as lang, i}
<div
class="flex items-center justify-between rounded-lg px-3 py-2 transition-colors hover:bg-gray-50 {i ===
1
? 'bg-blue-50'
: ''}"
>
<div class="flex items-center gap-3">
<span
class="flex h-6 w-6 items-center justify-center rounded-md bg-gradient-to-br {lang.tone} text-[10px] font-bold text-gray-800 ring-1 {lang.ring}"
>
{lang.code.toUpperCase()}
</span>
<span class="text-sm text-gray-800">{lang.native}</span>
<span class="text-xs text-gray-400">{lang.en}</span>
</div>
{#if i === 1}
<Check size={14} class="text-blue-700" />
{/if}
</div>
{/each}
<div class="rounded-lg px-3 py-2 text-xs text-gray-400">
+ {Math.max(0, langs.length - 4)} more languages
</div>
</div>
</div>
</div>
</BlurFade>
<!-- Animated polyglot lesson reel -->
<BlurFade delay={0.15} once>
<div class="relative">
<div
class="pointer-events-none absolute -inset-4 z-0 rounded-3xl bg-[conic-gradient(from_30deg,rgba(2,51,189,0.08),rgba(2,51,189,0.18),rgba(2,51,189,0.08))] blur-2xl"
></div>
<div class="relative rounded-2xl border border-gray-200 bg-white shadow-2xl/5">
<div class="flex items-center justify-between border-b border-gray-100 px-5 py-3.5">
<div class="flex items-center gap-2 text-xs text-gray-500">
<span class="relative flex h-2 w-2">
<span class="absolute inline-flex h-2 w-2 animate-ping rounded-full bg-blue-500 opacity-60"></span>
<span class="relative inline-flex h-2 w-2 rounded-full bg-blue-600"></span>
</span>
{moduleLabel}
</div>
<div class="text-[10px] font-medium tracking-widest text-blue-700 uppercase">Live preview</div>
</div>
<!-- Cycling lesson card -->
<div class="relative h-[260px] overflow-hidden">
{#each langs as lang, i}
<div
class="cio-lang-cycle absolute inset-0 flex flex-col px-6 py-6"
style:--delay="{i * 2}s"
style:--total="{langs.length * 2}s"
>
<div class="mb-4 flex items-center gap-3">
<span
class="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br {lang.tone} text-sm font-bold text-gray-800 ring-1 {lang.ring}"
>
{lang.code.toUpperCase()}
</span>
<div>
<p class="text-[11px] tracking-wide text-gray-400 uppercase">Now rendering · {lang.en}</p>
<p class="text-xs text-gray-600">{lang.native}</p>
</div>
</div>
<p class="text-xl leading-snug font-medium text-gray-950" dir="auto">{lang.sample}</p>
<div class="mt-5 space-y-2">
<div class="h-1.5 w-[88%] rounded-full bg-gray-100"></div>
<div class="h-1.5 w-[72%] rounded-full bg-gray-100"></div>
<div class="h-1.5 w-[60%] rounded-full bg-gray-100"></div>
</div>
<div class="mt-auto flex items-center justify-between pt-4">
<span class="rounded-full bg-blue-50 px-2.5 py-1 text-[10px] font-medium text-blue-700"
>{footerLabel}</span
>
<span class="text-[10px] text-gray-400">auto · per-learner language</span>
</div>
</div>
{/each}
</div>
<!-- Progress dots -->
<div class="border-t border-gray-100 px-5 py-3">
<div class="flex items-center justify-center gap-1.5">
{#each langs as _, i}
<span
class="cio-lang-dot h-1.5 rounded-full bg-gray-200"
style:--delay="{i * 2}s"
style:--total="{langs.length * 2}s"
></span>
{/each}
</div>
</div>
</div>
</div>
</BlurFade>
</div>
</div>
</section>
<style>
/* Each language gets a 2s window; total = langs.length × 2s, supplied via --total */
.cio-lang-cycle {
opacity: 0;
animation: cio-lang-fade var(--total, 20s) ease-in-out infinite;
animation-delay: var(--delay, 0s);
}
@keyframes cio-lang-fade {
0% {
opacity: 0;
transform: translateY(8px);
}
1% {
opacity: 1;
transform: translateY(0);
}
9% {
opacity: 1;
transform: translateY(0);
}
11% {
opacity: 0;
transform: translateY(-8px);
}
100% {
opacity: 0;
transform: translateY(-8px);
}
}
.cio-lang-dot {
width: 6px;
animation: cio-lang-dot var(--total, 20s) ease-in-out infinite;
animation-delay: var(--delay, 0s);
}
@keyframes cio-lang-dot {
0%,
11%,
100% {
background-color: rgb(229 231 235);
width: 6px;
}
1%,
9% {
background-color: rgb(29 78 216);
width: 22px;
}
}
.cio-lang-headline::before {
content: '';
position: absolute;
inset: auto 0 -2px 0;
height: 2px;
background: linear-gradient(90deg, transparent, rgba(29, 78, 216, 0.35), transparent);
animation: cio-lang-sweep 3.5s ease-in-out infinite;
}
@keyframes cio-lang-sweep {
0%,
100% {
transform: translateX(-100%);
opacity: 0;
}
50% {
transform: translateX(100%);
opacity: 1;
}
}
</style>
@@ -1,6 +1,5 @@
<script>
import { BlurFade } from '@cio/ui/custom/animation/blurfade';
import { BorderBeam } from '@cio/ui/custom/animation/border-beam';
const learnerModes = [
{
@@ -77,7 +76,6 @@
<!-- Lesson Mock UI -->
<BlurFade delay={0.2} once>
<div class="relative overflow-hidden rounded-2xl border border-gray-200 bg-gray-50 shadow-2xl/5">
<BorderBeam size={200} duration={14} colorFrom="#0233BD" colorTo="#93c5fd" />
<div class="flex items-center justify-between border-b border-gray-200 bg-white px-5 py-4">
<div class="text-sm font-medium">Data Privacy & Compliance</div>
<div class="rounded-full border border-gray-200 bg-gray-50 px-2.5 py-0.5 text-xs text-gray-500">
@@ -0,0 +1,98 @@
<script>
import CodeBlock from './code-block.svelte';
/**
* Shared MCP server section — dark background, copy + mcp.json on left/right.
* Used on /automation and /mcp-recipes.
*
* @typedef {Object} Props
* @property {string} [eyebrow]
* @property {string} [title]
* @property {string} [description]
* @property {boolean} [showTools] Render the list of MCP tool names (default true)
*/
/** @type {Props} */
let {
eyebrow = 'MCP server',
title = 'Course authoring from your coding agent.',
description = 'Connect Cursor, Claude Code, Codex, or OpenCode to ClassroomIO and let your agent draft courses, reorganise sections, generate exercises, and publish them without leaving your terminal.',
showTools = true
} = $props();
const mcpJsonLines = [
'<span class="text-slate-500">{</span>',
' <span class="text-sky-300">"mcpServers"</span><span class="text-slate-500">: {</span>',
' <span class="text-sky-300">"classroomio"</span><span class="text-slate-500">: {</span>',
' <span class="text-sky-300">"command"</span><span class="text-slate-500">:</span> <span class="text-emerald-300">"npx"</span><span class="text-slate-500">,</span>',
' <span class="text-sky-300">"args"</span><span class="text-slate-500">: [</span><span class="text-emerald-300">"-y"</span><span class="text-slate-500">,</span> <span class="text-emerald-300">"@classroomio/mcp"</span><span class="text-slate-500">],</span>',
' <span class="text-sky-300">"env"</span><span class="text-slate-500">: { </span><span class="text-sky-300">"CLASSROOMIO_API_KEY"</span><span class="text-slate-500">: </span><span class="text-emerald-300">"&lt;your-mcp-key&gt;"</span><span class="text-slate-500"> }</span>',
' <span class="text-slate-500">}</span>',
' <span class="text-slate-500">}</span>',
'<span class="text-slate-500">}</span>'
];
const mcpTools = [
'list_org_courses',
'create_course_draft',
'create_course_draft_from_course',
'update_course_draft',
'publish_course_draft',
'publish_course_draft_to_existing_course',
'update_course_landing_page',
'create_course_exercise',
'create_course_exercise_from_template',
'update_course_exercise',
'reorder_course_content',
'tag_courses',
'tag_course_draft',
'get_course_structure',
'get_course_draft',
'get_course_exercise',
'list_course_exercises'
];
</script>
<section class="relative overflow-hidden bg-gray-950 px-6 py-12 text-white lg:px-12 lg:py-16">
<div
class="pointer-events-none absolute -top-[200px] -right-[200px] h-[500px] w-[500px] bg-[radial-gradient(circle,rgba(2,51,189,0.18)_0%,transparent_70%)]"
></div>
<div class="relative mx-auto grid max-w-[1100px] grid-cols-1 items-start gap-12 lg:grid-cols-[1fr_1fr] lg:gap-20">
<div>
<div class="mb-2 text-xs font-medium tracking-widest text-blue-400 uppercase">{eyebrow}</div>
<h2 class="text-[clamp(1.75rem,2.6vw,2.2rem)] leading-[1.15] font-medium tracking-tight text-white">
{title}
</h2>
<p class="mt-5 max-w-[520px] text-sm leading-relaxed text-gray-400">
{description}
</p>
{#if showTools}
<div class="mt-6">
<p class="mb-3 text-[10px] tracking-wider text-gray-500 uppercase">Available tools</p>
<div class="flex flex-wrap gap-2">
{#each mcpTools as tool}
<span
class="rounded-md border border-white/10 bg-white/[0.05] px-2.5 py-1 font-mono text-[11px] text-blue-300"
>
{tool}
</span>
{/each}
</div>
</div>
{/if}
</div>
<div class="relative">
<div class="relative overflow-hidden rounded-2xl border border-white/[0.08] bg-white/[0.03]">
<CodeBlock
fileName="mcp.json"
lang="JSON"
codeLines={mcpJsonLines}
statusBar="paste into your agent's MCP config"
/>
</div>
</div>
</div>
</section>
@@ -0,0 +1,133 @@
<script>
import { BlurFade } from '@cio/ui/custom/animation/blurfade';
import BoxedGrid from './boxed-grid.svelte';
import Palette from '@lucide/svelte/icons/palette';
import Globe from '@lucide/svelte/icons/globe';
import Receipt from '@lucide/svelte/icons/receipt';
const academies = [
{
index: '01',
name: 'Customer Academy',
domain: 'learn.acme.com',
audience: 'Customers · onboarding · adoption',
barWidth: '72%',
dot: 'bg-blue-500',
bar: 'bg-blue-500',
badge: 'bg-blue-50 text-blue-700'
},
{
index: '02',
name: 'Partner Academy',
domain: 'partners.acme.com',
audience: 'Resellers · agencies · integrators',
barWidth: '54%',
dot: 'bg-violet-500',
bar: 'bg-violet-500',
badge: 'bg-violet-50 text-violet-700'
},
{
index: '03',
name: 'Internal Academy',
domain: 'team.acme.com',
audience: 'Employees · onboarding · compliance',
barWidth: '88%',
dot: 'bg-emerald-500',
bar: 'bg-emerald-500',
badge: 'bg-emerald-50 text-emerald-700'
}
];
const benefits = [
{
Icon: Palette,
title: 'Brand isolated',
description: 'Own logo, colors, theme. Customers never see partner content.'
},
{
Icon: Globe,
title: 'Own domain',
description: 'Point a CNAME, click verify. SSL handled.'
},
{
Icon: Receipt,
title: 'One invoice',
description: 'Add a new academy when you launch a new audience.'
}
];
</script>
<section class="bg-white px-6 py-12 lg:px-12 lg:py-16">
<div class="mx-auto max-w-[1100px]">
<!-- Header: eyebrow on top, headline + supporting text in a split row -->
<BlurFade once>
<div class="text-xs font-medium tracking-widest text-blue-700 uppercase">Multi-academy</div>
</BlurFade>
<div class="mt-6 grid grid-cols-1 gap-8 lg:grid-cols-[1.4fr_1fr] lg:items-end">
<BlurFade delay={0.05} once>
<h2 class="text-[clamp(1.75rem,2.6vw,2.2rem)] leading-[1.15] font-medium tracking-tight text-gray-950">
One platform,
<em class="text-gray-700 not-italic">every audience.</em>
</h2>
</BlurFade>
<BlurFade delay={0.1} once>
<p class="max-w-[360px] text-sm leading-relaxed text-gray-500 lg:text-right lg:text-base">
Three workspaces, three brands, three domains. One login. One bill.
</p>
</BlurFade>
</div>
<!-- Academies list -->
<div class="mt-10 border-t border-gray-200">
{#each academies as a, i}
<BlurFade delay={0.05 * i + 0.15} once>
<div
class="grid grid-cols-[auto_1fr] gap-x-4 border-b border-gray-200 py-5 lg:grid-cols-[40px_1.6fr_1.6fr_1fr_auto] lg:items-center lg:gap-x-6"
>
<!-- Number -->
<p class="font-mono text-xs text-gray-400">{a.index}</p>
<!-- Name + domain -->
<div class="flex items-start gap-3">
<span class="mt-2 h-2 w-2 shrink-0 rounded-full {a.dot}"></span>
<div>
<p class="text-sm text-gray-950 lg:text-base">{a.name}</p>
<p class="font-mono text-[11px] text-gray-500 lg:text-xs">{a.domain}</p>
</div>
</div>
<!-- Audience -->
<p class="col-span-2 mt-2 text-sm text-gray-600 lg:col-span-1 lg:mt-0">{a.audience}</p>
<!-- Progress bar -->
<div class="col-span-2 mt-2 h-1 w-full overflow-hidden rounded-full bg-gray-100 lg:col-span-1 lg:mt-0">
<div class="h-full rounded-full {a.bar}" style="width: {a.barWidth}"></div>
</div>
<!-- Active badge -->
<span
class="col-span-2 mt-2 inline-block w-fit rounded-full px-2.5 py-1 text-xs font-medium {a.badge} lg:col-span-1 lg:mt-0"
>
Active
</span>
</div>
</BlurFade>
{/each}
</div>
<!-- Bottom feature row -->
<div class="mt-10">
<BoxedGrid items={benefits} cols={3} padding="p-5 lg:p-6">
{#snippet cell(benefit)}
<div class="text-gray-950">
<benefit.Icon size={18} strokeWidth={1.6} />
</div>
<h3 class="mt-3 text-sm font-medium text-gray-950 lg:text-base">{benefit.title}</h3>
<p class="mt-1.5 text-xs leading-relaxed text-gray-500 lg:text-sm">{benefit.description}</p>
{/snippet}
</BoxedGrid>
</div>
</div>
</section>
@@ -1,13 +1,17 @@
<script lang="ts">
import ChevronDown from '@lucide/svelte/icons/chevron-down';
import Gamepad from '@lucide/svelte/icons/gamepad';
import Github from '@lucide/svelte/icons/github';
import Hourglass from '@lucide/svelte/icons/hourglass';
import LoaderCircle from '@lucide/svelte/icons/loader-circle';
import LocateFixed from '@lucide/svelte/icons/locate-fixed';
import Menu from '@lucide/svelte/icons/menu';
import MessagesSquare from '@lucide/svelte/icons/messages-square';
import MousePointerClick from '@lucide/svelte/icons/mouse-pointer-click';
import Server from '@lucide/svelte/icons/server';
import Sparkles from '@lucide/svelte/icons/sparkles';
import Timer from '@lucide/svelte/icons/timer';
import Webhook from '@lucide/svelte/icons/webhook';
import X from '@lucide/svelte/icons/x';
import { page } from '$app/state';
import { fly } from 'svelte/transition';
@@ -61,23 +65,50 @@
}
const solutions: NavCollectionItem[] = [
{
key: 'employee-training',
title: 'Employee Training',
subtitle: 'Keep your team in sync.',
href: '/employee-training'
},
{
key: 'bootcamps',
title: 'Bootcamps',
subtitle: 'Drive student satisfaction.',
href: '/bootcamps'
},
{
key: 'customer-education',
title: 'Customer Education',
subtitle: 'Teach customers your product.',
subtitle: 'Customer academies for SaaS.',
href: '/customer-education'
},
{
key: 'compliance-training',
title: 'Compliance Training',
subtitle: 'Turn policies and SOPs into proof.',
href: '/compliance-training'
},
{
key: 'partner-training',
title: 'Partner Training',
subtitle: 'A separate academy for your channel.',
href: '/partner-training'
}
];
const developers: NavCollectionItem[] = [
{
key: 'automation',
title: 'Automation',
subtitle: 'API, Webhooks, and MCP server.',
href: '/automation'
},
{
key: 'self-hosting',
title: 'Self-hosting & Docs',
subtitle: 'Run ClassroomIO in your own VPC.',
href: 'https://docs.classroomio.com'
},
{
key: 'mcp-recipes',
title: 'MCP Recipes',
subtitle: 'Ready-to-run agent prompts.',
href: '/mcp-recipes'
},
{
key: 'github',
title: 'GitHub',
subtitle: 'Star, read the source, fork.',
href: '/github'
}
];
@@ -121,6 +152,12 @@
href: '/',
items: solutions
},
{
key: 'developers',
title: 'Developers',
href: '/automation',
items: developers
},
{
key: 'free-tools',
title: 'Free Tools',
@@ -140,6 +177,7 @@
];
let isSolutionsActive = $derived(!!solutions.some((s) => activeLink.includes(s.key)));
let isDevelopersActive = $derived(!!developers.some((d) => activeLink.includes(d.key)));
let isFreeToolsActive = $derived(activeLink.startsWith('/tools'));
function isNavItemActive(navItem: NavItem) {
@@ -147,6 +185,10 @@
return isSolutionsActive;
}
if (navItem.key === 'developers') {
return isDevelopersActive;
}
if (navItem.key === 'free-tools') {
return isFreeToolsActive;
}
@@ -165,12 +207,20 @@
{...restProps}
>
<div class="shrink-0">
{#if key === 'employee-training'}
{#if key === 'compliance-training'}
<LibraryBigIcon size={24} />
{:else if key === 'bootcamps'}
{:else if key === 'partner-training'}
<LocateFixed size={24} />
{:else if key === 'customer-education'}
<MessagesSquare size={24} />
{:else if key === 'automation'}
<Webhook size={24} />
{:else if key === 'self-hosting'}
<Server size={24} />
{:else if key === 'mcp-recipes'}
<Sparkles size={24} />
{:else if key === 'github'}
<Github size={24} />
{:else if key === 'progress'}
<LoaderCircle size={24} />
{:else if key === 'pomodoro'}
@@ -206,7 +256,7 @@
</div>
</a>
<section class="hidden lg:block">
<section class="hidden! lg:block!">
<NavigationMenu.Root class="p-1">
<NavigationMenu.List class="flex w-full items-center justify-center gap-1">
{#each navItems as navItem}
@@ -253,7 +303,7 @@
</NavigationMenu.Root>
</section>
<div class="hidden flex-row items-center justify-between gap-3 lg:flex">
<div class="hidden! flex-row items-center justify-between gap-3 lg:flex!">
<a href="/discord" target="_blank" class="flex items-center transition-opacity duration-200 hover:opacity-80">
<img loading="lazy" alt="discord logo" src="/discord-blue.png" class="h-5 w-6 cursor-pointer" />
</a>
@@ -284,7 +334,7 @@
</a>
</div>
<button type="button" aria-label="Hamburger Menu" class="shrink-0 lg:hidden" onclick={handleShowSolutions}>
<button type="button" aria-label="Hamburger Menu" class="shrink-0 lg:hidden!" onclick={handleShowSolutions}>
<Menu size={24} />
</button>
@@ -0,0 +1,85 @@
<script>
import { BlurFade } from '@cio/ui/custom/animation/blurfade';
import Github from '@lucide/svelte/icons/github';
import Server from '@lucide/svelte/icons/server';
import Coins from '@lucide/svelte/icons/coins';
/** @type {{ stars?: number }} */
let { stars = 0 } = $props();
const lanes = [
{
Icon: Github,
title: 'AGPL on GitHub',
description: 'Dashboard, API, AI assistant, MCP server. All of it.'
},
{
Icon: Server,
title: 'Self-host or hosted',
description: 'Run it in your VPC, or let us run it. Same product.'
},
{
Icon: Coins,
title: 'No per-seat fees',
description: 'Pricing scales with workspaces, not headcount.'
}
];
</script>
<section class="bg-white px-6 py-16 lg:px-12 lg:py-24">
<div class="mx-auto max-w-[1100px]">
<div class="mb-12 grid grid-cols-1 gap-12 lg:grid-cols-[1fr_1fr] lg:items-end lg:gap-20">
<BlurFade once>
<div>
<div class="mb-3 text-xs font-medium tracking-widest text-blue-700 uppercase">Open source</div>
<h2 class="text-[clamp(2rem,3vw,2.8rem)] leading-[1.15] font-medium tracking-tight">
Read it. Fork it. Run it.
</h2>
</div>
</BlurFade>
<BlurFade delay={0.1} once>
<p class="text-base leading-relaxed text-gray-500 lg:text-lg">
AGPL on GitHub. Self-host or use ours. Bring your own AI keys.
</p>
</BlurFade>
</div>
<div class="grid grid-cols-1 gap-5 md:grid-cols-3">
{#each lanes as lane, i}
<BlurFade delay={0.08 * i} once>
<div class="h-full rounded-2xl border border-gray-200 bg-gray-50 p-6">
<div
class="mb-4 flex h-11 w-11 items-center justify-center rounded-xl bg-white text-blue-700 ring-1 ring-gray-200"
>
<lane.Icon size={20} strokeWidth={1.75} />
</div>
<h3 class="text-base font-medium text-gray-950">{lane.title}</h3>
<p class="mt-2 text-sm leading-relaxed text-gray-500">{lane.description}</p>
</div>
</BlurFade>
{/each}
</div>
<div class="mt-8 flex flex-wrap items-center gap-3">
<a
href="/github"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 rounded-lg bg-gray-950 px-5 py-2.5 text-sm font-medium text-white no-underline transition-all hover:-translate-y-0.5 hover:bg-blue-700"
>
<Github size={16} />
Star on GitHub
{#if stars}<span class="text-blue-300">{stars}</span>{/if}
</a>
<a
href="https://docs.classroomio.com/self-hosting"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-5 py-2.5 text-sm font-medium text-gray-950 no-underline transition-all hover:border-gray-950"
>
Self-hosting guide →
</a>
</div>
</div>
</section>
@@ -14,7 +14,7 @@
<header class="relative w-full overflow-hidden border-b border-slate-100 pt-32 pb-16 lg:min-h-[20rem]">
<DotField
dotRadius={1.5}
dotSpacing={14}
dotSpacing={15}
cursorRadius={420}
bulgeOnly={true}
bulgeStrength={56}
@@ -1,5 +1,6 @@
<script lang="ts">
import { Button } from '@cio/ui/base/button';
import { Waves } from '@cio/ui/custom/animation/waves';
interface Props {
header?: string;
@@ -13,19 +14,40 @@
</script>
<div
class="floating grad relative mx-auto my-5 flex h-[61vh] w-full max-w-4xl flex-col items-center justify-center space-y-8 px-6 py-12 text-center shadow-2xl lg:h-[50vh] lg:w-[70%] lg:rounded-3xl"
class="relative mx-auto my-5 flex h-[61vh] w-full max-w-4xl flex-col items-center justify-center overflow-hidden bg-gray-950 px-6 py-12 text-center shadow-2xl lg:h-[50vh] lg:w-[70%] lg:rounded-3xl"
>
<div>
<h3 class="font-display mt-3 max-w-2xl text-xl tracking-tight text-white lg:mt-2 lg:text-4xl">
<div
class="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_50%_100%,rgba(2,51,189,0.92)_0%,rgba(4,15,45,0.98)_58%,rgb(3,7,18)_100%)]"
></div>
<Waves
lineColor="rgba(255,255,255,0.18)"
class="ui:pointer-events-none ui:absolute ui:inset-0 ui:h-full ui:w-full"
xGap={14}
yGap={34}
waveAmpX={24}
waveAmpY={12}
maxCursorMove={72}
/>
<div
class="pointer-events-none absolute inset-0 bg-linear-to-t from-gray-950/40 via-transparent to-white/[0.04]"
></div>
<div class="relative z-10 flex w-full flex-col items-center text-center">
<h3 class="font-display mx-auto mt-3 max-w-2xl text-center text-xl tracking-tight text-white lg:mt-2 lg:text-4xl">
{header}
</h3>
<p class="text-md mt-6 text-gray-300">
<p class="text-md mx-auto mt-6 max-w-2xl text-center text-gray-300">
{subText}
</p>
</div>
{#if demo}
<Button variant="ghost" data-cal-config="'layout':'month_view'" data-cal-link="classroomio/demo" class="bg-white!">
<Button
variant="ghost"
data-cal-config="'layout':'month_view'"
data-cal-link="classroomio/demo"
class="relative z-10 mt-8 bg-white!"
>
{btnLabel}
</Button>
{:else}
@@ -33,21 +55,10 @@
href={link}
variant="link"
target="_blank"
class="ui:bg-secondary ui:text-secondary-foreground"
class="ui:bg-secondary ui:text-secondary-foreground relative z-10 mt-8"
rel="noopener noreferrer nofollow"
>
{btnLabel}
</Button>
{/if}
</div>
<style>
.grad {
/* background: rgb(2, 51, 189);
background: radial-gradient(circle at 50% 100%, rgba(2, 51, 189, 1) 1%, rgb(4, 2, 56) 100%); */
background-size: cover;
background-repeat: no-repeat;
background-position: center center;
background-image: url('https://brand.cdn.clsrio.com/cta-bg.png');
}
</style>
@@ -0,0 +1,242 @@
<script>
import { onMount } from 'svelte';
/**
* Reusable animated particle cloud, same family as the hero.
*
* @typedef {Object} ClusterSpec
* @property {number} x Normalized 0–1 x position
* @property {number} y Normalized 0–1 y position
* @property {number} rx
* @property {number} ry
* @property {number} density
* @property {number} warmth 0 (cool) → 1 (warm/brand blue)
*
* @typedef {Object} Props
* @property {ClusterSpec[]} [clusters] Density centers. Defaults to the hero's 4-cluster diagonal.
* @property {number} [intensity] Multiplier for particle count (default 1)
* @property {boolean} [highlights] Render glowing twinkle stars + constellation lines (default true)
* @property {string} [class] Extra classes for the canvas element
*/
/** @type {Props} */
let {
clusters: clusterSpec = [
{ x: 0.3, y: 0.72, rx: 90, ry: 130, density: 0.55, warmth: 0.1 },
{ x: 0.52, y: 0.55, rx: 150, ry: 100, density: 0.95, warmth: 0.38 },
{ x: 0.74, y: 0.38, rx: 190, ry: 150, density: 1.25, warmth: 0.78 },
{ x: 0.92, y: 0.22, rx: 120, ry: 85, density: 0.7, warmth: 1.0 }
],
intensity = 1,
highlights: showHighlights = true,
class: className = ''
} = $props();
let canvas;
onMount(() => {
if (!canvas) return;
const ctx = canvas.getContext('2d');
const dpr = Math.min(window.devicePixelRatio || 1, 2);
let w = 0;
let h = 0;
let particles = [];
let highlights = [];
let clusters = [];
let rafId;
let time = 0;
function warmRGB(wm) {
const r = Math.round(110 - wm * 80);
const g = Math.round(130 - wm * 50);
const b = Math.round(180 + wm * 50);
return [r, g, b];
}
function gaussian2d(rx, ry) {
const u1 = Math.max(Math.random(), 1e-6);
const u2 = Math.random();
const r = Math.sqrt(-2 * Math.log(u1));
const t = 2 * Math.PI * u2;
return [r * Math.cos(t) * rx, r * Math.sin(t) * ry];
}
function resize() {
const rect = canvas.getBoundingClientRect();
w = rect.width;
h = rect.height;
canvas.width = Math.floor(w * dpr);
canvas.height = Math.floor(h * dpr);
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.scale(dpr, dpr);
seed();
}
function seed() {
particles = [];
highlights = [];
// Scale cluster sizes (and therefore particle counts) with viewport width.
// Reference width is 1100px (the content max-width). Mobile shrinks to ~50%, never bigger than 1×.
const scale = Math.max(0.5, Math.min(1, w / 1100));
clusters = clusterSpec.map((c) => ({
x: c.x * w,
y: c.y * h,
rx: c.rx * scale,
ry: c.ry * scale,
density: c.density,
warmth: c.warmth
}));
for (const c of clusters) {
const count = Math.floor(((c.rx * c.ry) / 18) * c.density * intensity);
for (let i = 0; i < count; i++) {
const [dx, dy] = gaussian2d(c.rx * 0.34, c.ry * 0.34);
const z = Math.random();
let r, a;
if (z < 0.32) {
r = 0.3 + Math.random() * 0.6;
a = 0.1 + Math.random() * 0.18;
} else if (z < 0.85) {
r = 0.55 + Math.random() * 1.05;
a = 0.28 + Math.random() * 0.38;
} else {
r = 1.0 + Math.random() * 1.6;
a = 0.5 + Math.random() * 0.45;
}
particles.push({
bx: c.x + dx,
by: c.y + dy,
r,
a,
warmth: Math.min(1, Math.max(0, c.warmth + (Math.random() - 0.5) * 0.18)),
phase: Math.random() * Math.PI * 2,
speed: 0.3 + Math.random() * 0.7,
amp: 0.6 + Math.random() * 1.8
});
}
}
const fringeCount = Math.floor(w * 0.18 * intensity);
for (let i = 0; i < fringeCount; i++) {
const c = clusters[1 + (i % Math.max(1, clusters.length - 1))] || clusters[0];
const [dx, dy] = gaussian2d(c.rx * 1.4, c.ry * 1.4);
particles.push({
bx: c.x + dx,
by: c.y + dy,
r: 0.25 + Math.random() * 0.4,
a: 0.06 + Math.random() * 0.15,
warmth: c.warmth,
phase: Math.random() * Math.PI * 2,
speed: 0.2 + Math.random() * 0.4,
amp: 0.8 + Math.random() * 1.6
});
}
if (showHighlights) {
const starCount = 38;
for (let i = 0; i < starCount; i++) {
const c = clusters[Math.max(0, clusters.length - 2) + (i % 2)] || clusters[0];
const [dx, dy] = gaussian2d(c.rx * 0.45, c.ry * 0.45);
highlights.push({
x: c.x + dx,
y: c.y + dy,
r: 1.2 + Math.random() * 1.6,
phase: Math.random() * Math.PI * 2,
speed: 0.8 + Math.random() * 0.7,
warmth: c.warmth
});
}
}
}
function draw() {
ctx.clearRect(0, 0, w, h);
time += 0.0035;
ctx.globalCompositeOperation = 'source-over';
for (const c of clusters) {
const radius = Math.max(c.rx, c.ry) * 1.7;
const [r, g, b] = warmRGB(c.warmth);
const grd = ctx.createRadialGradient(c.x, c.y, 0, c.x, c.y, radius);
grd.addColorStop(0, `rgba(${r},${g},${b},${0.1 * c.density})`);
grd.addColorStop(0.45, `rgba(${r},${g},${b},${0.04 * c.density})`);
grd.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = grd;
ctx.fillRect(c.x - radius, c.y - radius, radius * 2, radius * 2);
}
for (let i = 0; i < particles.length; i++) {
const p = particles[i];
const dx = Math.sin(time * p.speed + p.phase) * p.amp;
const dy = Math.cos(time * p.speed * 0.65 + p.phase) * p.amp * 0.7;
const x = p.bx + dx;
const y = p.by + dy;
const [r, g, b] = warmRGB(p.warmth);
ctx.beginPath();
ctx.arc(x, y, p.r, 0, Math.PI * 2);
ctx.fillStyle = `rgba(${r},${g},${b},${p.a})`;
ctx.fill();
}
if (showHighlights) {
ctx.lineWidth = 0.55;
for (let i = 0; i < highlights.length; i++) {
const a = highlights[i];
for (let j = i + 1; j < highlights.length; j++) {
const b = highlights[j];
const dx = a.x - b.x;
const dy = a.y - b.y;
const d2 = dx * dx + dy * dy;
if (d2 < 12000) {
const d = Math.sqrt(d2);
const alpha = (1 - d / 110) * 0.22;
if (alpha > 0) {
const wm = (a.warmth + b.warmth) * 0.5;
const [rr, gg, bb] = warmRGB(wm);
ctx.strokeStyle = `rgba(${rr},${gg},${bb},${alpha})`;
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.stroke();
}
}
}
}
ctx.globalCompositeOperation = 'screen';
for (const hl of highlights) {
const tw = 0.55 + 0.45 * Math.sin(time * hl.speed + hl.phase);
const [r, g, b] = warmRGB(hl.warmth);
const haloR = hl.r * 7.5;
const grd = ctx.createRadialGradient(hl.x, hl.y, 0, hl.x, hl.y, haloR);
grd.addColorStop(0, `rgba(${r},${g},${b},${0.55 * tw})`);
grd.addColorStop(0.35, `rgba(${r},${g},${b},${0.13 * tw})`);
grd.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = grd;
ctx.beginPath();
ctx.arc(hl.x, hl.y, haloR, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.arc(hl.x, hl.y, hl.r * (0.85 + 0.3 * tw), 0, Math.PI * 2);
ctx.fillStyle = `rgba(225,236,255,${0.9 * tw})`;
ctx.fill();
}
ctx.globalCompositeOperation = 'source-over';
}
rafId = requestAnimationFrame(draw);
}
resize();
draw();
window.addEventListener('resize', resize);
return () => {
cancelAnimationFrame(rafId);
window.removeEventListener('resize', resize);
};
});
</script>
<canvas bind:this={canvas} class="pointer-events-none {className}" aria-hidden="true"></canvas>
@@ -0,0 +1,62 @@
<script>
import { BlurFade } from '@cio/ui/custom/animation/blurfade';
/**
* Wrap-flow list of pill-shaped bordered cards with an optional colored dot.
* Used on solution pages for use-case lists, audience lists, etc.
*
* @typedef {Object} Props
* @property {string[]} items
* @property {boolean} [showDot] Show a small pastel dot at the start of each pill (default true)
* @property {boolean} [animate] Wrap each pill in BlurFade (default true)
* @property {string} [gap] Gap class between pills (default "gap-3")
* @property {string} [size] Padding/text size variant: "default" | "compact" (default "default")
*/
/** @type {Props} */
let { items, showDot = true, animate = true, gap = 'gap-3', size = 'default' } = $props();
const dotColors = [
'bg-blue-300',
'bg-emerald-300',
'bg-amber-300',
'bg-violet-300',
'bg-rose-300',
'bg-cyan-300',
'bg-orange-300',
'bg-fuchsia-300'
];
const sizes = {
default: 'px-4 py-2 text-sm',
compact: 'px-3 py-1.5 text-xs'
};
</script>
<div class="flex flex-wrap {gap}">
{#each items as item, i}
{#if animate}
<BlurFade delay={0.03 * i} once>
<span
class="inline-flex items-center gap-2 rounded-full border border-gray-200 bg-white text-gray-800 {sizes[
size
]}"
>
{#if showDot}
<span class="h-2 w-2 rounded-full {dotColors[i % dotColors.length]}"></span>
{/if}
{item}
</span>
</BlurFade>
{:else}
<span
class="inline-flex items-center gap-2 rounded-full border border-gray-200 bg-white text-gray-800 {sizes[size]}"
>
{#if showDot}
<span class="h-2 w-2 rounded-full {dotColors[i % dotColors.length]}"></span>
{/if}
{item}
</span>
{/if}
{/each}
</div>
@@ -0,0 +1,47 @@
<script>
import BoxedGrid from './boxed-grid.svelte';
/**
* @typedef {Object} Props
* @property {string} [bgClass]
* @property {string} [eyebrow]
* @property {string} [heading]
* @property {string} [subhead]
*/
/** @type {Props} */
let {
bgClass = 'bg-white',
eyebrow = 'The case for an academy',
heading = 'Customers who learn the product, stay.',
subhead = 'Numbers from teams who built one:'
} = $props();
const stats = [
{ value: '+22%', label: 'Higher retention', source: 'Forrester · customer-education TEI' },
{ value: '+38%', label: 'Product adoption', source: 'Intellum / Forrester benchmarks' },
{ value: '−6.1%', label: 'Support costs', source: 'TSIA · education services impact' }
];
</script>
<section class="{bgClass} px-6 py-12 lg:px-12 lg:py-16">
<div class="mx-auto max-w-[1100px]">
<div class="mb-8 max-w-[640px]">
<div class="mb-2 text-xs font-medium tracking-widest text-blue-700 uppercase">{eyebrow}</div>
<h2 class="text-[clamp(1.75rem,2.6vw,2.2rem)] leading-[1.15] font-medium tracking-tight">{heading}</h2>
<p class="mt-3 text-sm leading-relaxed text-gray-500">{subhead}</p>
</div>
<BoxedGrid items={stats} cols={3} padding="px-5 py-10 lg:py-12">
{#snippet cell(stat)}
<div class="flex h-full flex-col items-center justify-center text-center">
<p class="text-[clamp(2rem,3.4vw,2.8rem)] font-medium tracking-tight text-gray-950">{stat.value}</p>
<p class="mt-1.5 text-sm text-gray-600">{stat.label}</p>
<p class="mt-3 text-[11px] text-gray-400">{stat.source}</p>
</div>
{/snippet}
</BoxedGrid>
<p class="mt-4 text-xs text-gray-400">Industry benchmarks. Your mileage will vary.</p>
</div>
</section>
@@ -0,0 +1,50 @@
<script>
/**
* Two-column feature split — text + optional bullets on one side, image on the other.
* Replaces bespoke per-page splits so we keep grid + spacing consistent across marketing pages.
*
* @typedef {Object} Props
* @property {string} [eyebrow]
* @property {string} title
* @property {string} description
* @property {string} imageSrc
* @property {string} imageAlt
* @property {string} [imageAspect] Tailwind aspect class (default "aspect-[4/3]")
* @property {'left'|'right'} [imagePosition] (default "right")
* @property {string} [bgClass] Section background (default "bg-white")
* @property {import('svelte').Snippet} [bullets] Snippet rendering `<li>` children
*/
/** @type {Props} */
let {
eyebrow,
title,
description,
imageSrc,
imageAlt,
imageAspect = 'aspect-[4/3]',
imagePosition = 'right',
bgClass = 'bg-white',
bullets
} = $props();
</script>
<section class="px-6 py-12 lg:px-12 lg:py-16 {bgClass}">
<div class="mx-auto grid max-w-[1100px] grid-cols-1 items-center gap-12 lg:grid-cols-2 lg:gap-20">
<div class={imagePosition === 'left' ? 'lg:order-2' : ''}>
{#if eyebrow}
<div class="mb-2 text-xs font-medium tracking-widest text-blue-700 uppercase">{eyebrow}</div>
{/if}
<h2 class="text-[clamp(1.75rem,2.6vw,2.2rem)] leading-[1.15] font-medium tracking-tight">{title}</h2>
<p class="mt-5 text-base leading-relaxed text-gray-500">{description}</p>
{#if bullets}
<ul class="mt-6 space-y-3 text-sm text-gray-700">
{@render bullets()}
</ul>
{/if}
</div>
<div class="overflow-hidden rounded-2xl bg-white shadow-sm ring-1 ring-gray-200 {imageAspect}">
<img src={imageSrc} alt={imageAlt} class="h-full w-full object-cover" loading="lazy" />
</div>
</div>
</section>
@@ -2,13 +2,13 @@
import { SenjaEmbed } from '$lib/components';
</script>
<section id="testimonial" class="bg-white px-6 py-16 lg:px-12 lg:py-24">
<section id="testimonial" class="bg-white px-6 py-12 lg:px-12 lg:py-16">
<div class="mx-auto max-w-[1100px] text-center">
<div class="mb-3.5 text-xs font-bold tracking-widest text-blue-700 uppercase">Testimonials</div>
<h2 class="mb-4 text-[clamp(2rem,3vw,2.8rem)] leading-[1.15] font-bold tracking-tight">
<div class="mb-2 text-xs font-medium tracking-widest text-blue-700 uppercase">Testimonials</div>
<h2 class="mb-3 text-[clamp(1.75rem,2.6vw,2.2rem)] leading-[1.15] font-medium tracking-tight">
Hear from happy ClassroomIO users
</h2>
<p class="mx-auto mb-10 max-w-[520px] text-base leading-relaxed text-gray-500 lg:text-lg">
<p class="mx-auto mb-8 max-w-[520px] text-sm leading-relaxed text-gray-500">
See what excites them about building and managing training programs with ClassroomIO.
</p>
@@ -0,0 +1,22 @@
<script>
/**
* @typedef {Object} Props
* @property {string} caption Eyebrow caption (e.g. "For partner programs at")
* @property {string[]} items Items shown pipe-separated
*/
/** @type {Props} */
let { caption, items } = $props();
</script>
<section class="border-b border-gray-100 bg-white px-6 py-10 lg:px-12">
<div class="mx-auto max-w-[1100px]">
<p class="mb-6 text-center text-xs font-medium tracking-widest text-gray-500 uppercase">{caption}</p>
<div class="flex flex-wrap items-center justify-center gap-x-10 gap-y-4 text-sm font-medium text-gray-500">
{#each items as item, i}
{#if i > 0}<span class="text-gray-300">·</span>{/if}
<span>{item}</span>
{/each}
</div>
</div>
</section>
@@ -0,0 +1,34 @@
<script>
import { BlurFade } from '@cio/ui/custom/animation/blurfade';
import VideoPlaceholder from './video-placeholder.svelte';
/**
* @typedef {Object} Props
* @property {string} eyebrow Eyebrow label (e.g. "A live walkthrough")
* @property {string} title Section heading
* @property {string} [description] Lead paragraph below the heading
* @property {string} suggestedFile Path passed through to VideoPlaceholder
* @property {string} [bgClass] Optional section background (default bg-gray-50)
*/
/** @type {Props} */
let { eyebrow, title, description, suggestedFile, bgClass = 'bg-gray-50' } = $props();
</script>
<section class="px-6 py-12 lg:px-12 lg:py-16 {bgClass}">
<div class="mx-auto max-w-[1100px]">
<div class="mb-10 max-w-[620px]">
<div class="mb-2 text-xs font-medium tracking-widest text-blue-700 uppercase">{eyebrow}</div>
<h2 class="text-[clamp(1.75rem,2.6vw,2.2rem)] leading-[1.15] font-medium tracking-tight">{title}</h2>
{#if description}
<p class="mt-5 text-base leading-relaxed text-gray-500">{description}</p>
{/if}
</div>
<BlurFade delay={0.1} once>
<div class="relative mx-auto max-w-[920px]">
<VideoPlaceholder {suggestedFile} />
</div>
</BlurFade>
</div>
</section>
@@ -0,0 +1,30 @@
<script>
import PlayCircle from '@lucide/svelte/icons/play-circle';
/**
* @typedef {Object} Props
* @property {string} suggestedFile Suggested static path (e.g. "/compliance-demo.mp4")
* @property {string} [caption] Optional short description
* @property {string} [className] Extra classes
*/
/** @type {Props} */
let { suggestedFile, caption = '', className = '' } = $props();
</script>
<div
class="relative aspect-video w-full overflow-hidden rounded-2xl border-2 border-dashed border-blue-300 bg-gradient-to-br from-blue-50 via-white to-gray-100 {className}"
>
<div class="absolute inset-0 flex flex-col items-center justify-center gap-3 text-center">
<div
class="flex h-20 w-20 items-center justify-center rounded-full bg-white text-blue-700 shadow-lg ring-1 ring-blue-100"
>
<PlayCircle size={48} strokeWidth={1.4} />
</div>
<p class="text-sm font-medium text-gray-700">Video placeholder</p>
<p class="text-xs text-gray-500">Replace with HeroVideoDialog · suggested file: {suggestedFile}</p>
{#if caption}
<p class="max-w-[80%] text-[11px] text-gray-400">{caption}</p>
{/if}
</div>
</div>
@@ -0,0 +1,146 @@
<script>
import { BlurFade } from '@cio/ui/custom/animation/blurfade';
import Check from '@lucide/svelte/icons/check';
import X from '@lucide/svelte/icons/x';
/**
* @typedef {Object} Props
* @property {string} [bgClass]
* @property {string} [eyebrow]
* @property {string} [heading]
* @property {string} [subhead]
* @property {boolean} [showHeading]
*/
/** @type {Props} */
let {
bgClass = 'bg-white',
eyebrow = 'Help docs are not an academy',
heading = 'Reading docs isn’t the same as learning.',
subhead = 'Intercom Articles, Zendesk Guide, and Confluence are great at one thing — answering "how do I do X?" when the user already knows what they need. They were never built to teach.',
showHeading = true
} = $props();
const rows = [
{
capability: 'Sequenced learning path',
docs: false,
academy: true,
note: 'A course goes from "what this product is" to "I can do this unsupervised." Docs are alphabetical, not sequential.'
},
{
capability: 'Assessment & quiz grading',
docs: false,
academy: true,
note: 'You can’t prove someone understood an article. You can prove they passed a 12-question final.'
},
{
capability: 'Certificates & verifiable IDs',
docs: false,
academy: true,
note: 'A shareable cert turns power-users into a public community. Help docs don’t.'
},
{
capability: 'Programs / multi-course paths',
docs: false,
academy: true,
note: 'Onboarding week, admin track, power-user path — three audiences, three sequenced journeys, one academy.'
},
{
capability: 'AI tutor inside the lesson',
docs: false,
academy: true,
note: 'Lesson-aware Claude tutor answers follow-ups before the learner bounces to a support ticket.'
},
{
capability: 'Per-learner progress & analytics',
docs: false,
academy: true,
note: 'See who finished, who stalled, where they got stuck. Page-view analytics don’t answer "did this customer learn?"'
},
{
capability: 'Branded portal on your domain',
docs: true,
academy: true,
note: 'Both can. Help-doc tools usually charge extra for it. ClassroomIO doesn’t.'
},
{
capability: 'Search & quick lookup',
docs: true,
academy: false,
note: 'When someone already knows what they need, docs win. Pair an academy with your docs — don’t replace them.'
},
{
capability: 'In-product contextual answers',
docs: true,
academy: false,
note: 'Help widgets in-app are still where short-form, "right now" answers belong.'
}
];
</script>
<section class="{bgClass} px-6 py-16 lg:px-12 lg:py-24">
<div class="mx-auto max-w-[1100px]">
{#if showHeading}
<div class="mb-12 max-w-[680px]">
<div class="mb-3 text-xs font-medium tracking-widest text-blue-700 uppercase">{eyebrow}</div>
<h2 class="text-[clamp(2rem,3vw,2.8rem)] leading-[1.15] font-medium tracking-tight">{heading}</h2>
<p class="mt-5 text-base leading-relaxed text-gray-500">{subhead}</p>
</div>
{/if}
<div class="overflow-hidden rounded-2xl border border-gray-200 bg-white">
<div
class="grid grid-cols-[1.6fr_repeat(2,minmax(0,0.8fr))_1.4fr] gap-0 border-b border-gray-200 bg-gray-50 px-5 py-3.5 text-[11px] font-medium tracking-widest text-gray-500 uppercase"
>
<div>Capability</div>
<div class="text-center">Help docs</div>
<div class="text-center">Company academy</div>
<div class="hidden lg:block">Why it matters</div>
</div>
{#each rows as row, i}
<BlurFade delay={0.03 * i} once>
<div
class="grid grid-cols-[1.6fr_repeat(2,minmax(0,0.8fr))_1.4fr] items-start gap-0 border-b border-gray-100 px-5 py-4 text-sm last:border-b-0 hover:bg-blue-50/30"
>
<div class="font-medium text-gray-950">{row.capability}</div>
<div class="flex justify-center">
{#if row.docs}
<span class="flex h-7 w-7 items-center justify-center rounded-full bg-emerald-50 text-emerald-600">
<Check size={16} strokeWidth={2.5} />
</span>
{:else}
<span class="flex h-7 w-7 items-center justify-center rounded-full bg-gray-100 text-gray-400">
<X size={16} strokeWidth={2.5} />
</span>
{/if}
</div>
<div class="flex justify-center">
{#if row.academy}
<span
class="flex h-7 w-7 items-center justify-center rounded-full bg-blue-50 text-blue-700 ring-1 ring-blue-200"
>
<Check size={16} strokeWidth={2.5} />
</span>
{:else}
<span class="flex h-7 w-7 items-center justify-center rounded-full bg-gray-100 text-gray-400">
<X size={16} strokeWidth={2.5} />
</span>
{/if}
</div>
<div class="hidden text-xs leading-relaxed text-gray-500 lg:block">{row.note}</div>
</div>
</BlurFade>
{/each}
</div>
<p class="mt-6 text-xs text-gray-500">
Compared against typical capabilities of Intercom Articles, Zendesk Guide, and Confluence at time of writing. Run
your own academy alongside your existing help center — most teams who launch a customer academy keep both.
</p>
</div>
</section>
@@ -0,0 +1,113 @@
<script>
/**
* @typedef {Object} Step
* @property {string} title
* @property {string} description
* @property {string} [label] Optional small uppercase label shown below the description (e.g. "STEP 01")
*/
/**
* @typedef {Object} Props
* @property {string} eyebrow Eyebrow label (e.g. "How it works")
* @property {string} title Section heading
* @property {string} [description] Optional lead paragraph below the heading
* @property {Step[]} steps Workflow steps (typically 4)
* @property {string} [bgClass] Optional section background (default bg-white)
*/
/** @type {Props} */
let { eyebrow, title, description, steps, bgClass = 'bg-white' } = $props();
let hoveredIndex = $state(-1);
function isActive(index) {
if (hoveredIndex === -1) return index === 0;
return index <= hoveredIndex;
}
</script>
<section class="px-6 py-12 lg:px-12 lg:py-16 {bgClass}">
<div class="mx-auto max-w-[1100px]">
<div class="max-w-[680px]">
<div class="mb-2 text-xs font-medium tracking-widest text-blue-700 uppercase">{eyebrow}</div>
<h2 class="text-[clamp(1.75rem,2.6vw,2.2rem)] leading-[1.15] font-medium tracking-tight">{title}</h2>
{#if description}
<p class="mt-5 text-base leading-relaxed text-gray-500">{description}</p>
{/if}
</div>
<!-- Desktop: horizontal timeline -->
<div class="mt-12 hidden! md:block!">
<div class="relative">
<!-- connecting line -->
<div class="absolute top-[7px] right-2 left-2 h-px bg-gray-200"></div>
<div
class="relative grid gap-8"
style:grid-template-columns="repeat({steps.length}, minmax(0, 1fr))"
onmouseleave={() => (hoveredIndex = -1)}
role="presentation"
>
{#each steps as step, i}
<div class="relative" onmouseenter={() => (hoveredIndex = i)} role="presentation">
<span
class="relative z-10 flex h-3.5 w-3.5 items-center justify-center rounded-full transition-all duration-200 {isActive(
i
)
? 'bg-blue-700 ring-4 ring-blue-100'
: 'bg-gray-300'}"
>
{#if i === 0 && hoveredIndex === -1}
<span class="absolute inline-flex h-3.5 w-3.5 animate-ping rounded-full bg-blue-500 opacity-60"
></span>
{/if}
</span>
<div class="mt-6">
<h3 class="text-sm font-medium text-gray-950">{step.title}</h3>
<p class="mt-2 text-sm leading-relaxed text-gray-500">{step.description}</p>
<p
class="mt-4 font-mono text-[10px] tracking-[0.15em] uppercase transition-colors duration-200 {isActive(
i
)
? 'text-blue-700'
: 'text-gray-400'}"
>
{step.label ?? `Step ${String(i + 1).padStart(2, '0')}`}
</p>
</div>
</div>
{/each}
</div>
</div>
</div>
<!-- Mobile: vertical timeline -->
<div class="mt-10 md:hidden!">
<div class="relative space-y-8 pl-6" onmouseleave={() => (hoveredIndex = -1)} role="presentation">
<div class="absolute top-2 bottom-2 left-[6px] w-px bg-gray-200"></div>
{#each steps as step, i}
<div class="relative" onmouseenter={() => (hoveredIndex = i)} role="presentation">
<span
class="absolute top-1 -left-6 flex h-3 w-3 items-center justify-center rounded-full transition-all duration-200 {isActive(
i
)
? 'bg-blue-700 ring-4 ring-blue-100'
: 'bg-gray-300'}"
></span>
<h3 class="text-sm font-medium text-gray-950">{step.title}</h3>
<p class="mt-2 text-sm leading-relaxed text-gray-500">{step.description}</p>
<p
class="mt-3 font-mono text-[10px] tracking-[0.15em] uppercase transition-colors duration-200 {isActive(i)
? 'text-blue-700'
: 'text-gray-400'}"
>
{step.label ?? `Step ${String(i + 1).padStart(2, '0')}`}
</p>
</div>
{/each}
</div>
</div>
</div>
</section>
@@ -1,81 +0,0 @@
<script>
import { Button } from '@cio/ui/base/button';
import * as Field from '@cio/ui/base/field';
import { Input } from '@cio/ui/base/input';
import { Textarea } from '@cio/ui/base/textarea';
import { isFormValid } from '$lib/utils/is-form-valid';
import { submitForm } from '$lib/utils/submit-form';
import { Success } from '$lib/components';
let name = $state('');
let email = $state('');
let title = $state('');
let description = $state('');
let isLoading = $state(false);
let errorMessage = $state('');
let sent = $state(false);
const handleClick = async () => {
const data = { name, email, title, description };
if (!isFormValid(data)) {
errorMessage = 'Please fill out all required fields.';
return;
}
isLoading = true;
const formData = {
title: `[FEEDBACK] ${title}`,
description: `My name is ${JSON.stringify(name)}, email is ${JSON.stringify(
email
)}. \n\n${JSON.stringify(description)}.`,
replyTo: email
};
try {
await submitForm(formData);
sent = true;
} catch (error) {
console.log(error);
} finally {
isLoading = false;
}
};
</script>
<div>
{#if sent}
<Success />
{:else}
<div class="my-10 w-full space-y-5">
<Field.Group>
<div class="flex flex-col gap-10 md:flex-row">
<Field.Field class="w-full">
<Field.Label for="feedback-name">Your Name</Field.Label>
<Input id="feedback-name" bind:value={name} required />
</Field.Field>
<Field.Field class="w-full">
<Field.Label for="feedback-email">Your Email</Field.Label>
<Input id="feedback-email" type="email" bind:value={email} required />
</Field.Field>
</div>
<Field.Field>
<Field.Label for="feedback-subject">Subject</Field.Label>
<Input id="feedback-subject" bind:value={title} required />
</Field.Field>
<Field.Field>
<Field.Label for="feedback-message">Message</Field.Label>
<Textarea id="feedback-message" bind:value={description} required />
</Field.Field>
{#if !!errorMessage}
<Field.Field>
<Field.Error>{errorMessage}</Field.Error>
</Field.Field>
{/if}
</Field.Group>
<Button onclick={handleClick} disabled={isLoading} loading={isLoading}>Submit Feedback</Button>
</div>
{/if}
</div>
@@ -1,81 +0,0 @@
<script>
import { Button } from '@cio/ui/base/button';
import * as Field from '@cio/ui/base/field';
import { Input } from '@cio/ui/base/input';
import { Textarea } from '@cio/ui/base/textarea';
import { isFormValid } from '$lib/utils/is-form-valid';
import { submitForm } from '$lib/utils/submit-form';
import { Success } from '$lib/components';
let name = $state('');
let email = $state('');
let title = $state('');
let description = $state('');
let isLoading = $state(false);
let errorMessage = $state('');
let sent = $state(false);
async function handleClick() {
const data = { name, email, title, description };
if (!isFormValid(data)) {
errorMessage = 'Please fill out all required fields.';
return;
}
isLoading = true;
const formData = {
title: `[HELP] ${title}`,
description: `My name is ${JSON.stringify(name)}, email is ${JSON.stringify(
email
)}. \n\n${JSON.stringify(description)}.`,
replyTo: email
};
try {
await submitForm(formData);
sent = true;
} catch (error) {
console.log(error);
} finally {
isLoading = false;
}
}
</script>
<div>
{#if sent}
<Success />
{:else}
<div class="my-10 w-full space-y-5">
<Field.Group>
<div class="flex flex-col gap-10 md:flex-row">
<Field.Field class="w-full">
<Field.Label for="help-name">Your Name</Field.Label>
<Input id="help-name" bind:value={name} required />
</Field.Field>
<Field.Field class="w-full">
<Field.Label for="help-email">Your Email</Field.Label>
<Input id="help-email" type="email" bind:value={email} required />
</Field.Field>
</div>
<Field.Field>
<Field.Label for="help-subject">Subject</Field.Label>
<Input id="help-subject" bind:value={title} required />
</Field.Field>
<Field.Field>
<Field.Label for="help-message">Message</Field.Label>
<Textarea id="help-message" bind:value={description} required />
</Field.Field>
{#if !!errorMessage}
<Field.Field>
<Field.Error>{errorMessage}</Field.Error>
</Field.Field>
{/if}
</Field.Group>
<Button onclick={handleClick} disabled={isLoading} loading={isLoading}>Submit Request</Button>
</div>
{/if}
</div>
-20
View File
@@ -1,20 +0,0 @@
export const submitForm = async (formData: any) => {
try {
const response = await fetch('/api/contact', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(formData)
});
if (!response.ok) {
throw new Error('Failed to submit the form');
}
const data = await response.json();
console.log('Response from API:', data);
} catch (error) {
console.error('Error submitting form:', error);
}
};
-9
View File
@@ -24,12 +24,3 @@ export type OssFriend = {
description: string;
href: string;
};
export type FORM_TYPE_KEY = keyof typeof FORM_TYPE;
export enum FORM_TYPE {
BUG = 'BUG',
HELP = 'HELP',
FEATURE = 'FEATURE',
FEEDBACK = 'FEEDBACK'
}