chore: marketing updates and video player improvement

This commit is contained in:
Rotimi Best
2026-05-22 01:28:18 +01:00
parent f0fad9512c
commit 4fdd4115b9
13 changed files with 211 additions and 17 deletions
@@ -10,6 +10,7 @@ import { get } from 'svelte/store';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { handleLocaleChange } from '$lib/utils/functions/translations'; import { handleLocaleChange } from '$lib/utils/functions/translations';
import { identifyPosthogUser } from '$lib/utils/services/posthog'; import { identifyPosthogUser } from '$lib/utils/services/posthog';
import { identifyUserJotUser } from '$lib/utils/services/userjot';
import { isOrgStudent } from '$lib/utils/store/app'; import { isOrgStudent } from '$lib/utils/store/app';
import { isPublicRoute } from '$lib/utils/functions/routes/isPublicRoute'; import { isPublicRoute } from '$lib/utils/functions/routes/isPublicRoute';
import { licenseApi } from '$features/license/api/license.svelte'; import { licenseApi } from '$features/license/api/license.svelte';
@@ -231,6 +232,13 @@ class AppInitApi extends BaseApi {
email: profileStore.email, email: profileStore.email,
name: profileStore.fullname name: profileStore.fullname
}); });
identifyUserJotUser({
id: profileStore.id,
email: profileStore.email,
fullname: profileStore.fullname,
avatarUrl: profileStore.avatarUrl
});
} }
reset() { reset() {
@@ -24,13 +24,19 @@
<Dialog.Root <Dialog.Root
bind:open={$lessonVideoUpload.isModalOpen} bind:open={$lessonVideoUpload.isModalOpen}
onOpenChange={(details) => { onOpenChange={(open) => {
if (!details.open && $lessonVideoUpload.isUploading) return; if (!open) onClose();
if (!details.open) onClose();
}} }}
> >
<Dialog.Content <Dialog.Content
class="flex max-h-[680px] max-w-2xl! flex-col overflow-hidden" class="flex max-h-[680px] max-w-2xl! flex-col overflow-hidden"
showCloseButton={!$lessonVideoUpload.isUploading}
onEscapeKeydown={(e) => {
if ($lessonVideoUpload.isUploading) e.preventDefault();
}}
onInteractOutside={(e) => {
if ($lessonVideoUpload.isUploading) e.preventDefault();
}}
onCloseAutoFocus={(e) => { onCloseAutoFocus={(e) => {
if ($lessonVideoUpload.isUploading) e.preventDefault(); if ($lessonVideoUpload.isUploading) e.preventDefault();
}} }}
@@ -42,7 +48,11 @@
<UnderlineTabs.Root bind:value={currentTab} class=""> <UnderlineTabs.Root bind:value={currentTab} class="">
<UnderlineTabs.List> <UnderlineTabs.List>
{#each tabs as item (item.value)} {#each tabs as item (item.value)}
<UnderlineTabs.Trigger value={String(item.value)} class="ui:flex ui:items-center ui:gap-2"> <UnderlineTabs.Trigger
value={String(item.value)}
disabled={$lessonVideoUpload.isUploading && currentTab !== String(item.value)}
class="ui:flex ui:items-center ui:gap-2"
>
{#if $isFreePlan && item.value === 3} {#if $isFreePlan && item.value === 3}
<ZapIcon size={16} class="filled" /> <ZapIcon size={16} class="filled" />
{:else} {:else}
@@ -581,12 +581,22 @@
</Table.Body> </Table.Body>
</Table.Root> </Table.Root>
{:else} {:else}
<Empty title={$t('course.navItem.compliance.empty_overview')} icon={ShieldCheckIcon} variant="page" /> <Empty
title={$t('course.navItem.compliance.empty_overview')}
description={$t('course.navItem.compliance.empty_overview_description')}
icon={ShieldCheckIcon}
variant="page"
/>
{/if} {/if}
</Tabs.Content> </Tabs.Content>
</Tabs.Root> </Tabs.Root>
{:else} {:else}
<Empty title={$t('course.navItem.compliance.empty_overview')} icon={ShieldCheckIcon} variant="page" /> <Empty
title={$t('course.navItem.compliance.empty_overview')}
description={$t('course.navItem.compliance.empty_overview_description')}
icon={ShieldCheckIcon}
variant="page"
/>
{/if} {/if}
<Dialog.Root bind:open={isBulkActionModalOpen} onOpenChange={handleBulkActionModalChange}> <Dialog.Root bind:open={isBulkActionModalOpen} onOpenChange={handleBulkActionModalChange}>
@@ -1,6 +1,7 @@
import { PUBLIC_IS_SELFHOSTED } from '$env/static/public'; import { PUBLIC_IS_SELFHOSTED } from '$env/static/public';
import { initPosthog, type PosthogBootstrapUser } from '$lib/utils/services/posthog'; import { initPosthog, type PosthogBootstrapUser } from '$lib/utils/services/posthog';
import { initUmami } from '$lib/utils/services/umami'; import { initUmami } from '$lib/utils/services/umami';
import { initUserJot } from '$lib/utils/services/userjot';
import { licenseApi } from '$features/license/api/license.svelte'; import { licenseApi } from '$features/license/api/license.svelte';
let isInitialized = false; let isInitialized = false;
@@ -11,6 +12,7 @@ export function setupAnalytics(user?: PosthogBootstrapUser) {
initPosthog(user); initPosthog(user);
initUmami(); initUmami();
initUserJot();
} }
/** Checks if this is cloud deployment and initializes analytics */ /** Checks if this is cloud deployment and initializes analytics */
@@ -0,0 +1,77 @@
import { dev } from '$app/environment';
import { get } from 'svelte/store';
import { globalStore } from '$lib/utils/store/app';
import { licenseApi } from '$features/license/api/license.svelte';
const USERJOT_APP_ID = 'cm4a6vcmp00jpmdb5n66rmkzz';
let isInitialized = false;
function isWidgetAllowed(): boolean {
if (dev) return false;
if (licenseApi.hasAccess('no-tracking')) return false;
if (get(globalStore).isOrgSite) return false;
return true;
}
function ensureSdkLoaded(): void {
if (window.uj) return;
window.$ujq = window.$ujq || [];
window.uj =
window.uj ||
(new Proxy({} as Window['uj'], {
get:
(_, prop) =>
(...args: unknown[]) =>
window.$ujq.push([prop, ...args])
}) as Window['uj']);
const script = document.createElement('script');
script.type = 'module';
script.async = true;
script.src = 'https://cdn.userjot.com/sdk/v2/uj.js';
const nonce = document.querySelector('script[nonce]')?.getAttribute('nonce');
if (nonce) script.setAttribute('nonce', nonce);
document.head.appendChild(script);
}
export function initUserJot(): void {
if (isInitialized) return;
if (!isWidgetAllowed()) return;
ensureSdkLoaded();
window.uj.init(USERJOT_APP_ID, {
widget: true,
position: 'right',
theme: 'auto'
});
isInitialized = true;
}
type UserJotIdentity = {
id: string;
email?: string;
fullname?: string | null;
avatarUrl?: string | null;
};
export function identifyUserJotUser({ id, email, fullname, avatarUrl }: UserJotIdentity): void {
if (!isWidgetAllowed()) return;
ensureSdkLoaded();
const [firstName, ...rest] = (fullname ?? '').trim().split(/\s+/);
const lastName = rest.join(' ');
window.uj.identify({
id,
email,
firstName: firstName || undefined,
lastName: lastName || undefined,
avatar: avatarUrl ?? undefined
});
}
@@ -0,0 +1,19 @@
declare global {
interface Window {
$ujq: unknown[];
uj: {
init: (
appId: string,
options?: {
widget?: boolean;
position?: 'left' | 'right';
theme?: 'auto' | 'light' | 'dark';
}
) => void;
identify: (user: { id: string; email?: string; firstName?: string; lastName?: string; avatar?: string }) => void;
[method: string]: (...args: unknown[]) => void;
};
}
}
export {};
@@ -1035,7 +1035,8 @@
"actions": { "actions": {
"edit": "Edit", "edit": "Edit",
"where_used": "Where used", "where_used": "Where used",
"download": "Download" "download": "Download",
"manage_thumbnails": "Manage thumbnails"
}, },
"pagination": { "pagination": {
"previous": "Previous", "previous": "Previous",
@@ -1061,6 +1062,21 @@
"thumbnail_alt": "Asset thumbnail", "thumbnail_alt": "Asset thumbnail",
"status": "Status" "status": "Status"
}, },
"thumbnails": {
"manage_title": "Manage thumbnails",
"manage_description": "Preview the video, pick one of the auto-generated frames, or upload your own.",
"preview_unavailable": "Preview unavailable",
"generated": "Generated thumbnails",
"slot_start": "Start",
"slot_middle": "Middle",
"slot_end": "End",
"none_generated_warning": "No thumbnails generated yet. Try regenerating or upload a custom image.",
"upload_custom": "Upload custom thumbnail",
"custom_selected_hint": "Custom upload will be saved when you save your selection.",
"custom_preview_alt": "Custom thumbnail preview",
"regenerate": "Regenerate",
"save_selection": "Save thumbnail"
},
"usage": { "usage": {
"title": "Where Used", "title": "Where Used",
"description": "Current usage references for", "description": "Current usage references for",
@@ -1597,6 +1613,7 @@
"create_course": "Create Course", "create_course": "Create Course",
"view_site": "View Site", "view_site": "View Site",
"visit_site": "Visit Website", "visit_site": "Visit Website",
"open_academy": "Open Academy",
"current_lesson": "Currently Learning", "current_lesson": "Currently Learning",
"currently_learning": "Currently Learning", "currently_learning": "Currently Learning",
"learner": "Learner", "learner": "Learner",
@@ -2957,6 +2974,7 @@
"no_active_cycle": "No active compliance cycle", "no_active_cycle": "No active compliance cycle",
"empty_history": "No compliance history has been recorded yet.", "empty_history": "No compliance history has been recorded yet.",
"empty_overview": "No learner compliance records are available yet.", "empty_overview": "No learner compliance records are available yet.",
"empty_overview_description": "Keep your employees compliant and track who is behind.",
"not_available": "Not available", "not_available": "Not available",
"tabs": { "tabs": {
"overview": "Overview", "overview": "Overview",
@@ -3868,6 +3886,10 @@
"thumbnail_upload_failed": "Failed to upload thumbnail", "thumbnail_upload_failed": "Failed to upload thumbnail",
"thumbnail_too_large": "Thumbnail image is too large", "thumbnail_too_large": "Thumbnail image is too large",
"thumbnail_invalid": "Invalid thumbnail file", "thumbnail_invalid": "Invalid thumbnail file",
"thumbnail_updated": "Thumbnail updated",
"thumbnail_update_failed": "Failed to update thumbnail",
"thumbnail_regen_started": "Regenerating thumbnails…",
"thumbnail_regen_failed": "Failed to regenerate thumbnails",
"transcription_started": "Transcription started", "transcription_started": "Transcription started",
"transcription_start_failed": "Could not start transcription", "transcription_start_failed": "Could not start transcription",
"transcription_completed": "Transcription finished", "transcription_completed": "Transcription finished",
+9
View File
@@ -30,6 +30,15 @@ export function baseOptions(): BaseLayoutProps {
url: 'https://dub.sh/ciodemo', url: 'https://dub.sh/ciodemo',
external: true external: true
}, },
{
text: (
<div className="flex items-center gap-2">
Feedback <SquareArrowOutUpRight />
</div>
),
url: 'https://feedback.classroomio.com',
external: true
},
{ {
text: ( text: (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -176,8 +176,11 @@
<p class="ui:text-foreground mb-4 text-sm font-semibold">Company</p> <p class="ui:text-foreground mb-4 text-sm font-semibold">Company</p>
<ul class="space-y-3"> <ul class="space-y-3">
<li> <li>
<a href="/roadmap" class="ui:text-muted-foreground ui:hover:text-foreground text-sm transition-colors" <a
>Roadmap</a href="https://feedback.classroomio.com"
target="_blank"
rel="noopener"
class="ui:text-muted-foreground ui:hover:text-foreground text-sm transition-colors">Roadmap</a
> >
</li> </li>
<li> <li>
@@ -135,6 +135,16 @@
<title>Compliance Training | ClassroomIO</title> <title>Compliance Training | ClassroomIO</title>
<meta <meta
name="description" name="description"
content="Keep your employees compliant and track who's falling behind. Turn policies and SOPs into certified training with renewals, grace periods, and audit evidence."
/>
<meta property="og:title" content="Keep your employees compliant and track who's falling behind." />
<meta
property="og:description"
content="Turn policies and SOPs into certified training. Track compliance, renewals, grace periods, and audit evidence in one place."
/>
<meta name="twitter:title" content="Keep your employees compliant and track who's falling behind." />
<meta
name="twitter:description"
content="Turn policies and SOPs into certified training. Track compliance, renewals, grace periods, and audit evidence in one place." content="Turn policies and SOPs into certified training. Track compliance, renewals, grace periods, and audit evidence in one place."
/> />
<script <script
@@ -154,8 +164,8 @@
Compliance Training Compliance Training
</Badge> </Badge>
<h1 class="text-[clamp(2rem,3.4vw,2.8rem)] leading-[1.1] font-medium tracking-tight text-gray-950"> <h1 class="text-[clamp(2rem,3.4vw,2.8rem)] leading-[1.1] font-medium tracking-tight text-gray-950">
Audit-ready training, Keep your employees compliant and
<em class="text-blue-700 not-italic">without the spreadsheets.</em> <em class="text-blue-700 not-italic">track who's falling behind.</em>
</h1> </h1>
<HeroSubtitle class="mx-auto"> <HeroSubtitle class="mx-auto">
Turn policies, SOPs, docs, and videos into assigned courses with quizzes, certificates, renewal rules, and Turn policies, SOPs, docs, and videos into assigned courses with quizzes, certificates, renewal rules, and
@@ -341,7 +351,7 @@
</section> </section>
<PageSignupCTA <PageSignupCTA
header="Stop chasing compliance manually." header="Keep your employees compliant and track who's falling behind."
subText="Build certified training from your existing policies, assign it to the right people, and keep proof of completion in one place." subText="Build certified training from your existing policies, assign it to the right people, and keep proof of completion in one place."
btnLabel="Book a Demo" btnLabel="Book a Demo"
/> />
@@ -0,0 +1,13 @@
import { redirect } from '@sveltejs/kit';
import { client } from '$lib/utils/posthog';
export const prerender = false;
export const load = ({ request }) => {
client.capture({
distinctId: request.headers.get('x-forwarded-for') || new Date().getTime().toString(),
event: 'feedback page visited'
});
redirect(307, 'https://feedback.classroomio.com');
};
+4 -2
View File
@@ -69,8 +69,10 @@
<p class="mt-10 w-[80%] text-start text-slate-700 md:w-[40%] md:text-center"> <p class="mt-10 w-[80%] text-start text-slate-700 md:w-[40%] md:text-center">
This is a live document, and will be updated regularly. To submit an idea, or to view a list of feature requests and This is a live document, and will be updated regularly. To submit an idea, or to view a list of feature requests and
ideas that we're considering, check out our <a ideas that we're considering, check out our <a
href="https://github.com/classroomio/classroomio/discussion" href="https://feedback.classroomio.com"
class="underline">GitHub Discussions</a target="_blank"
rel="noopener"
class="underline">feedback portal</a
> >
</p> </p>
</PageHeader> </PageHeader>
@@ -153,17 +153,26 @@
}); });
</script> </script>
<div class="ui:relative" style="max-height: {maxHeight}; min-height: {options.minHeight}; height: {options.height};"> <div
class="ui:relative ui:aspect-video ui:w-full ui:overflow-hidden ui:bg-muted"
style="max-height: {maxHeight}; min-height: {options.minHeight}; height: {options.height};"
>
{#if isYouTube && youtubeVideoId} {#if isYouTube && youtubeVideoId}
<div <div
bind:this={containerElement} bind:this={containerElement}
class="plyr__video-embed" class="plyr__video-embed ui:absolute ui:inset-0 ui:h-full ui:w-full"
data-plyr-provider="youtube" data-plyr-provider="youtube"
data-plyr-embed-id={youtubeVideoId} data-plyr-embed-id={youtubeVideoId}
></div> ></div>
{:else} {:else}
<!-- svelte-ignore a11y_media_has_caption --> <!-- svelte-ignore a11y_media_has_caption -->
<video bind:this={videoElement} {src} {poster} {playsinline} class="plyr-player"> <video
bind:this={videoElement}
{src}
{poster}
{playsinline}
class="plyr-player ui:absolute ui:inset-0 ui:h-full ui:w-full ui:object-contain"
>
{#each tracks as track (track.src + track.srclang)} {#each tracks as track (track.src + track.srclang)}
<track <track
kind={track.kind ?? 'captions'} kind={track.kind ?? 'captions'}