From b8fca607aa1539ed81a6e5a918647c240c98ee6e Mon Sep 17 00:00:00 2001 From: Rotimi Best Date: Tue, 7 Jul 2026 22:15:11 +0100 Subject: [PATCH] feat(org): dynamic open graph images for school sites (#709) * feat(org): add dynamic open graph images for school sites Generate branded 1200x630 OG images for org public sites via GET /org/og/:siteName.png. Paying cloud orgs include their landing-page tagline; free plans show a Powered by ClassroomIO watermark. Dashboard org-site meta tags now point at the dynamic image. Co-authored-by: Rotimi Best * feat(org): enable custom favicon upload and org-site rendering Allow org admins to upload a favicon from domain settings, persist it via PUT /organization, and render it on public org sites with logo fallback. Co-authored-by: Rotimi Best * fix(org): scope custom favicon to public org-site routes only Co-authored-by: Rotimi Best * refactor(api): inline org-site OG rendering, remove @cio/og-images package Move template and render helpers into apps/api/src/utils/org-site-og.ts. Certificates stay in a shared package because they support multiple templates; org OG is a single API-only concern. Co-authored-by: Rotimi Best * chore: remove packages/og-images directory Co-authored-by: Rotimi Best * refactor(api): mount org OG image under /org-site/og Align with existing org-site public routes (/org-site/course) instead of a top-level /org prefix. Co-authored-by: Rotimi Best * refactor(plans): consolidate isOrgOnFreePlan into options-based utility Move orgId guard and self-hosted/plan checks into @cio/utils/plans. Dashboard and API callers pass plans, isSelfHosted, and optional orgId directly; remove the dashboard store wrapper. Co-authored-by: Rotimi Best * fix(org): require orgId for free-plan check and translate favicon strings Treat missing orgId as not on free plan so nav gating matches prior behavior when currentOrg is unset. Pass organization.id from OG service. Add favicon settings translations for all non-English locales. Co-authored-by: Rotimi Best * refactor(dashboard): move meta tag helpers to metaTags.server.ts Keep PRIVATE_SERVER_URL resolution in a server-only module so dynamic private env imports cannot leak into universal or client bundles. Co-authored-by: Rotimi Best --------- Co-authored-by: Cursor Agent Co-authored-by: Rotimi Best --- apps/api/src/app.ts | 3 +- apps/api/src/routes/org-site/index.ts | 1 + apps/api/src/routes/org-site/og.ts | 36 +++ apps/api/src/services/org/og.ts | 66 +++++ apps/api/src/utils/cloudflare.ts | 20 +- apps/api/src/utils/org-site-og.ts | 277 ++++++++++++++++++ .../lib/features/app/org-site-favicon.svelte | 18 ++ .../src/lib/features/org/api/org.svelte.ts | 16 + .../features/settings/pages/domains.svelte | 64 +++- .../features/ui/navigation/lms-navigation.ts | 10 +- .../{metaTags.ts => metaTags.server.ts} | 82 ++++-- .../src/lib/utils/functions/org-branding.ts | 15 + apps/dashboard/src/lib/utils/store/org.ts | 20 +- .../src/lib/utils/translations/da.json | 4 + .../src/lib/utils/translations/de.json | 4 + .../src/lib/utils/translations/en.json | 4 + .../src/lib/utils/translations/es.json | 4 + .../src/lib/utils/translations/fr.json | 4 + .../src/lib/utils/translations/hi.json | 4 + .../src/lib/utils/translations/pl.json | 4 + .../src/lib/utils/translations/pt.json | 4 + .../src/lib/utils/translations/ru.json | 4 + .../src/lib/utils/translations/vi.json | 4 + .../src/routes/(org-site)/+layout.svelte | 3 + .../routes/(org-site)/courses/+page.server.ts | 14 +- apps/dashboard/src/routes/+layout.server.ts | 2 +- apps/dashboard/src/routes/+page.svelte | 2 + packages/utils/src/plans/index.ts | 1 + packages/utils/src/plans/org-plan.ts | 26 ++ .../validation/organization/organization.ts | 1 + pnpm-lock.yaml | 6 +- 31 files changed, 665 insertions(+), 58 deletions(-) create mode 100644 apps/api/src/routes/org-site/og.ts create mode 100644 apps/api/src/services/org/og.ts create mode 100644 apps/api/src/utils/org-site-og.ts create mode 100644 apps/dashboard/src/lib/features/app/org-site-favicon.svelte rename apps/dashboard/src/lib/utils/functions/{metaTags.ts => metaTags.server.ts} (63%) create mode 100644 apps/dashboard/src/lib/utils/functions/org-branding.ts create mode 100644 packages/utils/src/plans/org-plan.ts diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index bb648b1a..de6bc540 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -32,7 +32,7 @@ import { organizationSsoRouter } from '@api/routes/organization/sso'; import { organizationTokenAuthRouter } from '@api/routes/organization/token-auth'; import { prettyJSON } from 'hono/pretty-json'; import { cohortRouter } from '@api/routes/cohort'; -import { publicCourseRouter } from '@api/routes/org-site'; +import { publicCourseRouter, orgSiteOgRouter } from '@api/routes/org-site'; import { publicWidgetsRouter } from '@api/routes/widgets'; import rateLimiter from '@api/middlewares/rate-limiter'; import { secureHeaders } from 'hono/secure-headers'; @@ -231,6 +231,7 @@ export const app = new Hono() .route('/community', communityRouter) .route('/invite', inviteRouter) .route('/org-site/course', publicCourseRouter) + .route('/org-site/og', orgSiteOgRouter) .route('/public-api/v1', v1Router) .route('/cohort', cohortRouter) .route('/unsplash', unsplashRouter) diff --git a/apps/api/src/routes/org-site/index.ts b/apps/api/src/routes/org-site/index.ts index 50ee3f1b..44122be0 100644 --- a/apps/api/src/routes/org-site/index.ts +++ b/apps/api/src/routes/org-site/index.ts @@ -1 +1,2 @@ export * from './public-course'; +export * from './og'; diff --git a/apps/api/src/routes/org-site/og.ts b/apps/api/src/routes/org-site/og.ts new file mode 100644 index 00000000..13d48072 --- /dev/null +++ b/apps/api/src/routes/org-site/og.ts @@ -0,0 +1,36 @@ +import { generateOrgSiteOgImage, getOrgSiteOgFallbackImageUrl, normalizeOrgOgSiteName } from '@api/services/org/og'; +import { Hono } from '@api/utils/hono'; +import { AppError } from '@api/utils/errors'; + +export const orgSiteOgRouter = new Hono().get('/:siteName', async (c) => { + try { + const siteName = normalizeOrgOgSiteName(c.req.param('siteName')); + const { buffer, etag } = await generateOrgSiteOgImage(siteName); + const ifNoneMatch = c.req.header('if-none-match'); + + if (ifNoneMatch === `"${etag}"`) { + return c.body(null, 304); + } + + c.header('Content-Type', 'image/png'); + c.header('Cache-Control', 'public, max-age=3600, stale-while-revalidate=86400'); + c.header('ETag', `"${etag}"`); + + return c.body( + new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(buffer)); + controller.close(); + } + }) + ); + } catch (error) { + if (error instanceof AppError && error.statusCode === 404) { + return c.redirect(getOrgSiteOgFallbackImageUrl(), 302); + } + + console.error('Failed to generate org OG image:', error); + + return c.redirect(getOrgSiteOgFallbackImageUrl(), 302); + } +}); diff --git a/apps/api/src/services/org/og.ts b/apps/api/src/services/org/og.ts new file mode 100644 index 00000000..79970668 --- /dev/null +++ b/apps/api/src/services/org/og.ts @@ -0,0 +1,66 @@ +import { createHash } from 'crypto'; + +import { AppError, ErrorCodes } from '@api/utils/errors'; +import { getCloudflarePngBuffer } from '@api/utils/cloudflare'; +import { resolveThemeColor } from '@cio/email'; +import { getActiveOrganizationPlan, getOrganizationBySiteName } from '@cio/db/queries/organization'; +import { extractOrgTagline, renderOrgSiteOg } from '@api/utils/org-site-og'; +import { isOrgOnFreePlan } from '@cio/utils/plans'; +import { env } from '@cio/core/config/env'; + +const DEFAULT_OG_IMAGE = 'https://brand.cdn.clsrio.com/og/classroomio-opengraph.jpg'; +const OG_VIEWPORT = { width: 1200, height: 630, deviceScaleFactor: 2 } as const; +const isSelfHosted = env.PUBLIC_IS_SELFHOSTED === 'true'; + +export function normalizeOrgOgSiteName(rawSiteName: string): string { + return rawSiteName.endsWith('.png') ? rawSiteName.slice(0, -4) : rawSiteName; +} + +export function getOrgSiteOgFallbackImageUrl(): string { + return DEFAULT_OG_IMAGE; +} + +function buildOrgSiteOgEtag(payload: { + siteName: string; + orgName: string; + logoUrl?: string; + tagline?: string; + themeColor: string; + showWatermark: boolean; +}): string { + return createHash('sha256').update(JSON.stringify(payload)).digest('hex').slice(0, 16); +} + +export async function generateOrgSiteOgImage(siteName: string): Promise<{ buffer: Buffer; etag: string }> { + const organization = await getOrganizationBySiteName(siteName); + if (!organization) { + throw new AppError('Organization not found', ErrorCodes.ORGANIZATION_NOT_FOUND, 404); + } + + const activePlan = await getActiveOrganizationPlan(organization.id); + const isFreePlan = isOrgOnFreePlan({ + plans: activePlan ? [{ planName: activePlan.planName, isActive: activePlan.isActive }] : [], + isSelfHosted, + orgId: organization.id + }); + + const tagline = isFreePlan ? undefined : extractOrgTagline(organization.landingpage); + const themeColor = resolveThemeColor(organization.theme) ?? '#1d4ed8'; + const renderInput = { + orgName: organization.name, + logoUrl: organization.avatarUrl?.trim() || undefined, + tagline, + themeColor, + showWatermark: isFreePlan + }; + + const etag = buildOrgSiteOgEtag({ + siteName, + ...renderInput + }); + + const { html, styles } = renderOrgSiteOg(renderInput); + const buffer = await getCloudflarePngBuffer(html, styles, OG_VIEWPORT); + + return { buffer, etag }; +} diff --git a/apps/api/src/utils/cloudflare.ts b/apps/api/src/utils/cloudflare.ts index 7db7e7ad..9cdd66d5 100644 --- a/apps/api/src/utils/cloudflare.ts +++ b/apps/api/src/utils/cloudflare.ts @@ -1,5 +1,11 @@ import { CLOUDFLARE } from '@api/constants'; +export type ScreenshotViewport = { + width: number; + height: number; + deviceScaleFactor?: number; +}; + export const getCloudflarePdfBuffer = async (html: string, styles?: string) => { console.log('Generating PDF with Cloudflare API...'); try { @@ -29,9 +35,13 @@ export const getCloudflarePdfBuffer = async (html: string, styles?: string) => { /** * Renders HTML to a PNG via Cloudflare Browser Rendering's `/screenshot` endpoint. - * The viewport is fixed to 1100x780 to match the certificate canvas. + * Defaults to 1100x780 for certificate canvases; pass a custom viewport for other assets. */ -export const getCloudflarePngBuffer = async (html: string, styles?: string) => { +export const getCloudflarePngBuffer = async ( + html: string, + styles?: string, + viewport: ScreenshotViewport = { width: 1100, height: 780, deviceScaleFactor: 2 } +) => { console.log('Generating PNG with Cloudflare API...'); try { const response = await fetch( @@ -45,7 +55,11 @@ export const getCloudflarePngBuffer = async (html: string, styles?: string) => { body: JSON.stringify({ html, addStyleTag: styles ? [{ content: styles }] : undefined, - viewport: { width: 1100, height: 780, deviceScaleFactor: 2 }, + viewport: { + width: viewport.width, + height: viewport.height, + deviceScaleFactor: viewport.deviceScaleFactor ?? 2 + }, screenshotOptions: { type: 'png', omitBackground: false, fullPage: false } }) } diff --git a/apps/api/src/utils/org-site-og.ts b/apps/api/src/utils/org-site-og.ts new file mode 100644 index 00000000..3f3674ed --- /dev/null +++ b/apps/api/src/utils/org-site-og.ts @@ -0,0 +1,277 @@ +export type OrgSiteOgInput = { + orgName: string; + logoUrl?: string; + tagline?: string; + themeColor: string; + showWatermark: boolean; +}; + +export type OrgSiteOgRenderResult = { + html: string; + styles: string; +}; + +const FONTS_LINK_HREF = 'https://fonts.googleapis.com/css2?family=Inter:wght@500;600;700&display=swap'; + +function escapeHtml(input: unknown): string { + return String(input ?? '').replace(/[&<>"']/g, (char) => { + switch (char) { + case '&': + return '&'; + case '<': + return '<'; + case '>': + return '>'; + case '"': + return '"'; + default: + return '''; + } + }); +} + +function truncateText(text: string, maxLength: number): string { + const trimmed = text.trim(); + if (trimmed.length <= maxLength) { + return trimmed; + } + + return `${trimmed.slice(0, maxLength - 1).trim()}…`; +} + +function orgInitials(orgName: string): string { + const words = orgName.trim().split(/\s+/).filter(Boolean); + if (words.length >= 2) { + return `${words[0]![0] ?? ''}${words[1]![0] ?? ''}`.toUpperCase(); + } + + return orgName.trim().slice(0, 2).toUpperCase(); +} + +function shadeColor(hex: string, percent: number): string { + const normalized = hex.startsWith('#') ? hex.slice(1) : hex; + if (normalized.length !== 6) { + return hex; + } + + const numeric = parseInt(normalized, 16); + if (Number.isNaN(numeric)) { + return hex; + } + + const offset = Math.round((percent / 100) * 255); + const clamp = (value: number) => Math.max(0, Math.min(255, value)); + const red = clamp(((numeric >> 16) & 0xff) + offset); + const green = clamp(((numeric >> 8) & 0xff) + offset); + const blue = clamp((numeric & 0xff) + offset); + const next = (red << 16) | (green << 8) | blue; + + return `#${next.toString(16).padStart(6, '0')}`; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * Reads a public-facing tagline from raw org `landingpage` JSON without + * depending on dashboard normalization helpers. + */ +export function extractOrgTagline(landingpage: unknown): string | undefined { + if (!isRecord(landingpage)) { + return undefined; + } + + const footer = landingpage.footer; + if (isRecord(footer)) { + const brand = footer.brand; + if (isRecord(brand) && typeof brand.tagline === 'string') { + const footerTagline = brand.tagline.trim(); + if (footerTagline) { + return footerTagline; + } + } + } + + const hero = landingpage.hero; + if (isRecord(hero) && typeof hero.subheading === 'string') { + const subheading = hero.subheading.trim(); + if (subheading) { + return subheading; + } + } + + return undefined; +} + +function renderOrgSiteOgTemplate(input: OrgSiteOgInput): { body: string; styles: string } { + const orgName = truncateText(input.orgName, 48); + const tagline = input.tagline ? truncateText(input.tagline, 90) : ''; + const themeColor = input.themeColor || '#1d4ed8'; + const gradientEnd = shadeColor(themeColor, -35); + const initials = orgInitials(orgName); + + const logoMarkup = input.logoUrl + ? `` + : ``; + + const taglineMarkup = tagline ? `

${escapeHtml(tagline)}

` : ''; + + const watermarkMarkup = input.showWatermark + ? `
Powered by ClassroomIO
` + : ''; + + const body = ` +
+
+
+
+
${logoMarkup}
+
+

${escapeHtml(orgName)}

+ ${taglineMarkup} +
+
+ ${watermarkMarkup} +
+
`; + + const styles = ` +@import url('${FONTS_LINK_HREF}'); + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html, body { + width: 1200px; + height: 630px; + overflow: hidden; +} + +body { + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; +} + +.canvas { + width: 1200px; + height: 630px; + background: linear-gradient(135deg, ${themeColor} 0%, ${gradientEnd} 100%); +} + +.card { + position: relative; + width: 1200px; + height: 630px; + padding: 72px 80px; + color: #ffffff; + overflow: hidden; +} + +.glow { + position: absolute; + top: -120px; + right: -80px; + width: 420px; + height: 420px; + border-radius: 9999px; + background: rgba(255, 255, 255, 0.12); + filter: blur(8px); +} + +.content { + position: relative; + z-index: 1; + display: flex; + align-items: center; + gap: 40px; + max-width: 1040px; +} + +.logo-wrap { + flex: 0 0 auto; +} + +.logo-image, +.logo-fallback { + width: 128px; + height: 128px; + border-radius: 28px; + object-fit: cover; + background: rgba(255, 255, 255, 0.16); + border: 2px solid rgba(255, 255, 255, 0.28); + box-shadow: 0 18px 40px rgba(15, 23, 42, 0.18); +} + +.logo-fallback { + display: flex; + align-items: center; + justify-content: center; + font-size: 42px; + font-weight: 700; + letter-spacing: 0.04em; +} + +.copy { + min-width: 0; +} + +.org-name { + font-size: 64px; + line-height: 1.05; + font-weight: 700; + letter-spacing: -0.03em; + text-wrap: balance; +} + +.tagline { + margin-top: 18px; + max-width: 760px; + font-size: 30px; + line-height: 1.35; + font-weight: 500; + color: rgba(255, 255, 255, 0.88); + text-wrap: balance; +} + +.watermark { + position: absolute; + right: 48px; + bottom: 36px; + z-index: 2; + padding: 10px 16px; + border-radius: 9999px; + background: rgba(15, 23, 42, 0.28); + border: 1px solid rgba(255, 255, 255, 0.18); + font-size: 18px; + font-weight: 500; + color: rgba(255, 255, 255, 0.92); + letter-spacing: 0.01em; +} + +.watermark strong { + font-weight: 700; +}`; + + return { body, styles }; +} + +export function renderOrgSiteOg(input: OrgSiteOgInput): OrgSiteOgRenderResult { + const { body, styles } = renderOrgSiteOgTemplate(input); + + const html = ` + + + + + ${input.orgName} Open Graph + + +${body} + +`; + + return { html, styles }; +} diff --git a/apps/dashboard/src/lib/features/app/org-site-favicon.svelte b/apps/dashboard/src/lib/features/app/org-site-favicon.svelte new file mode 100644 index 00000000..a3f4ff39 --- /dev/null +++ b/apps/dashboard/src/lib/features/app/org-site-favicon.svelte @@ -0,0 +1,18 @@ + + + + {#if faviconHref} + + {/if} + diff --git a/apps/dashboard/src/lib/features/org/api/org.svelte.ts b/apps/dashboard/src/lib/features/org/api/org.svelte.ts index be9ebe2d..0b64bc99 100644 --- a/apps/dashboard/src/lib/features/org/api/org.svelte.ts +++ b/apps/dashboard/src/lib/features/org/api/org.svelte.ts @@ -48,6 +48,7 @@ import { DEFAULT_ORG_AUDIENCE_QUERY, toAudienceRequestQuery } from '../utils/aud export interface TOrgUpdateForm { name?: string; avatar?: string | File | undefined; + favicon?: string | File | null | undefined; theme?: string; landingpage?: AccountOrg['landingpage']; siteName?: string; @@ -347,14 +348,28 @@ class OrgApi extends BaseApiWithErrors { avatarUrl = fields.avatar; } + let favicon: string | null | undefined; + if (fields.favicon instanceof File) { + favicon = await uploadImage(fields.favicon); + } else if (typeof fields.favicon === 'string') { + favicon = fields.favicon; + } else if (fields.favicon === null) { + favicon = null; + } + // Build update payload fields.avatar = undefined; + fields.favicon = undefined; const updates: TUpdateOrganization = { ...fields, landingpage: fields.landingpage ?? undefined, avatarUrl }; + if (favicon !== undefined) { + updates.favicon = favicon; + } + await this.execute({ requestFn: () => classroomio.organization.$put({ @@ -370,6 +385,7 @@ class OrgApi extends BaseApiWithErrors { return options.onSuccess({ name: response.data.name, avatarUrl: response.data.avatarUrl ?? undefined, + favicon: response.data.favicon ?? undefined, theme: response.data.theme ?? undefined, landingpage: response.data.landingpage ?? undefined, siteName: response.data.siteName ?? undefined, diff --git a/apps/dashboard/src/lib/features/settings/pages/domains.svelte b/apps/dashboard/src/lib/features/settings/pages/domains.svelte index abac933a..d0a86304 100644 --- a/apps/dashboard/src/lib/features/settings/pages/domains.svelte +++ b/apps/dashboard/src/lib/features/settings/pages/domains.svelte @@ -30,11 +30,14 @@ import { DomainInput } from '@cio/ui/custom/domain-input'; import { ComingSoon, UpgradeBanner, UploadImage, VisitOrgSiteButton } from '$features/ui'; import * as Field from '@cio/ui/base/field'; + import { getResolvedUploadLimits } from '$lib/utils/config/upload-limits-context'; let siteName = $derived($currentOrg.siteName); let customDomain = $state(''); let customCode = $state(''); - let favicon = $state(''); + let favicon = $state(); + let hasFaviconChanges = $state(false); + let isFaviconLoading = $state(false); let isLoading = $state(false); let isCustomDomainLoading = $state(false); let isRefreshing = $state(false); @@ -76,6 +79,46 @@ customDomain: '' }); + const uploadLimits = getResolvedUploadLimits(); + const faviconMaxFileSizeMb = uploadLimits.landingImageMb; + + async function handleSaveFavicon() { + if (!hasFaviconChanges) { + return; + } + + isFaviconLoading = true; + + await orgApi.update($currentOrg.id, { + favicon: favicon ?? null + }); + + if (orgApi.success) { + favicon = undefined; + hasFaviconChanges = false; + snackbar.success('components.settings.domains.custom_favicon_saved'); + } + + isFaviconLoading = false; + } + + async function handleRemoveFavicon() { + isFaviconLoading = true; + + await orgApi.update($currentOrg.id, { + favicon: null + }); + + if (orgApi.success) { + $currentOrg.favicon = ''; + favicon = undefined; + hasFaviconChanges = false; + snackbar.success('components.settings.domains.custom_favicon_saved'); + } + + isFaviconLoading = false; + } + async function handleSaveSiteName() { errors = updateOrgSiteNameValidation(siteName) as Error; @@ -565,17 +608,30 @@ {$t('components.settings.domains.custom_favicon')} - + {$t('components.settings.domains.custom_favicon_description')} { + hasFaviconChanges = true; + }} /> + + + {#if $currentOrg.favicon} + + {/if} + diff --git a/apps/dashboard/src/lib/features/ui/navigation/lms-navigation.ts b/apps/dashboard/src/lib/features/ui/navigation/lms-navigation.ts index 553cdda2..7fa78cb3 100644 --- a/apps/dashboard/src/lib/features/ui/navigation/lms-navigation.ts +++ b/apps/dashboard/src/lib/features/ui/navigation/lms-navigation.ts @@ -12,7 +12,8 @@ import { import type { AccountOrg } from '$features/app/types'; import type { Component } from 'svelte'; import { isActive } from '$lib/utils/functions/app'; -import { isOrgOnFreePlan } from '$lib/utils/store/org'; +import { isOrgOnFreePlan } from '@cio/utils/plans'; +import { PUBLIC_IS_SELFHOSTED } from '$env/static/public'; export interface NavItem { title: string; @@ -64,7 +65,12 @@ export const baseNavConfig: NavItemConfig[] = [ path: '/certificates', icon: CertificateIcon, matchPattern: '^/lms/certificates(/.*)?$', - show: (currentOrg) => !isOrgOnFreePlan(currentOrg) + show: (currentOrg) => + !isOrgOnFreePlan({ + plans: currentOrg?.plans, + isSelfHosted: PUBLIC_IS_SELFHOSTED === 'true', + orgId: currentOrg?.id + }) }, { titleKey: 'lms_navigation.explore', diff --git a/apps/dashboard/src/lib/utils/functions/metaTags.ts b/apps/dashboard/src/lib/utils/functions/metaTags.server.ts similarity index 63% rename from apps/dashboard/src/lib/utils/functions/metaTags.ts rename to apps/dashboard/src/lib/utils/functions/metaTags.server.ts index 3688bde3..878739a0 100644 --- a/apps/dashboard/src/lib/utils/functions/metaTags.ts +++ b/apps/dashboard/src/lib/utils/functions/metaTags.server.ts @@ -1,7 +1,8 @@ import type { MetaTagsProps } from 'svelte-meta-tags'; import type { OrgSiteInfo } from '$features/app/layout-setup'; import { PUBLIC_IS_SELFHOSTED } from '$env/static/public'; -import { env } from '$env/dynamic/public'; +import { env as publicEnv } from '$env/dynamic/public'; +import { env as privateEnv } from '$env/dynamic/private'; const isSelfHosted = PUBLIC_IS_SELFHOSTED === 'true'; @@ -9,10 +10,34 @@ const DEFAULT_TITLE = 'ClassroomIO | One Platform for Customer, Partner, and Emp const DEFAULT_DESCRIPTION = 'One platform for customer academies, partner certification, and employee training. Build courses with AI, publish under your domain, and track completions.'; const CLOUD_OG_IMAGE = 'https://brand.cdn.clsrio.com/og/classroomio-opengraph.jpg'; +const ORG_OG_WIDTH = 1200; +const ORG_OG_HEIGHT = 630; + +function getApiServerUrl(): string { + return privateEnv.PRIVATE_SERVER_URL?.trim() || publicEnv.PUBLIC_SERVER_URL?.trim() || ''; +} + +export function getOrgSiteOgImageUrl(siteName: string): string | null { + const apiBase = getApiServerUrl(); + if (!apiBase || !siteName) { + return null; + } + + return `${apiBase.replace(/\/$/, '')}/org-site/og/${encodeURIComponent(siteName)}.png`; +} function resolveOgImageUrl(url: URL, orgSiteInfo: OrgSiteInfo): string { - const envUrl = env.PUBLIC_OG_IMAGE_URL?.trim(); - if (envUrl) return envUrl; + const envUrl = publicEnv.PUBLIC_OG_IMAGE_URL?.trim(); + if (envUrl) { + return envUrl; + } + + if (orgSiteInfo.isOrgSite && orgSiteInfo.org?.siteName) { + const dynamicOgUrl = getOrgSiteOgImageUrl(orgSiteInfo.org.siteName); + if (dynamicOgUrl) { + return dynamicOgUrl; + } + } if (isSelfHosted) { const org = orgSiteInfo.org; @@ -36,32 +61,38 @@ function resolveOgImageUrl(url: URL, orgSiteInfo: OrgSiteInfo): string { return CLOUD_OG_IMAGE; } +function buildOrgOpenGraphImages(ogImageUrl: string, siteName: string) { + return [ + { + url: ogImageUrl, + alt: `${siteName} learning platform`, + width: ORG_OG_WIDTH, + height: ORG_OG_HEIGHT, + secureUrl: ogImageUrl, + type: 'image/png' + } + ]; +} + export function getBaseMetaTags(url: URL, orgSiteInfo: OrgSiteInfo): MetaTagsProps { const title = - env.PUBLIC_APP_TITLE?.trim() || + publicEnv.PUBLIC_APP_TITLE?.trim() || (isSelfHosted && orgSiteInfo.org?.name ? `${orgSiteInfo.org.name} | Learning Platform` : DEFAULT_TITLE); - const description = env.PUBLIC_APP_DESCRIPTION?.trim() || DEFAULT_DESCRIPTION; + const description = publicEnv.PUBLIC_APP_DESCRIPTION?.trim() || DEFAULT_DESCRIPTION; const siteName = - env.PUBLIC_APP_TITLE?.trim() || + publicEnv.PUBLIC_APP_TITLE?.trim() || (isSelfHosted && orgSiteInfo.org?.name ? orgSiteInfo.org.name : null) || 'ClassroomIO'; const ogImageUrl = resolveOgImageUrl(url, orgSiteInfo); + const usesDynamicOrgOg = + orgSiteInfo.isOrgSite && Boolean(orgSiteInfo.org?.siteName) && !publicEnv.PUBLIC_OG_IMAGE_URL?.trim(); - return Object.freeze({ - title, - description, - canonical: new URL(url.pathname, url.origin).href, - openGraph: { - type: 'website', - url: new URL(url.pathname, url.origin).href, - locale: 'en_US', - title, - description, - siteName, - images: [ + const openGraphImages = usesDynamicOrgOg + ? buildOrgOpenGraphImages(ogImageUrl, siteName) + : [ { url: ogImageUrl, alt: `${siteName} platform for customer, partner, and employee education`, @@ -78,7 +109,20 @@ export function getBaseMetaTags(url: URL, orgSiteInfo: OrgSiteInfo): MetaTagsPro secureUrl: 'https://brand.cdn.clsrio.com/og/classroomio-opengraph.webp', type: 'image/webp' } - ] + ]; + + return Object.freeze({ + title, + description, + canonical: new URL(url.pathname, url.origin).href, + openGraph: { + type: 'website', + url: new URL(url.pathname, url.origin).href, + locale: 'en_US', + title, + description, + siteName, + images: openGraphImages }, twitter: { handle: '@classroomio', diff --git a/apps/dashboard/src/lib/utils/functions/org-branding.ts b/apps/dashboard/src/lib/utils/functions/org-branding.ts new file mode 100644 index 00000000..12b0768d --- /dev/null +++ b/apps/dashboard/src/lib/utils/functions/org-branding.ts @@ -0,0 +1,15 @@ +import type { AccountOrg } from '$features/app/types'; + +export function getOrgFaviconHref(org: Pick | null | undefined): string | null { + const favicon = org?.favicon?.trim(); + if (favicon) { + return favicon; + } + + const avatarUrl = org?.avatarUrl?.trim(); + if (avatarUrl) { + return avatarUrl; + } + + return null; +} diff --git a/apps/dashboard/src/lib/utils/store/org.ts b/apps/dashboard/src/lib/utils/store/org.ts index 98a54880..4427eae7 100644 --- a/apps/dashboard/src/lib/utils/store/org.ts +++ b/apps/dashboard/src/lib/utils/store/org.ts @@ -4,7 +4,7 @@ import merge from 'lodash/merge'; import type { AccountOrg } from '$features/app/types'; import type { OrgTeamMember } from '../types/org'; -import { canUsePublicApi, PLAN } from '@cio/utils/plans'; +import { canUsePublicApi, isOrgOnFreePlan, PLAN } from '@cio/utils/plans'; import { PUBLIC_IS_SELFHOSTED } from '$env/static/public'; import { ROLE, TENANT_ROOT_DOMAIN } from '@cio/utils/constants'; import { STEPS } from '../constants/quiz'; @@ -71,16 +71,6 @@ const getActivePlan = (org: AccountOrg) => { return org.plans.find((p) => p.isActive); }; -export function isOrgOnFreePlan(org: AccountOrg | null | undefined) { - if (!org?.id || PUBLIC_IS_SELFHOSTED === 'true') { - return false; - } - - const plan = getActivePlan(org); - - return !plan || plan.planName === PLAN.BASIC; -} - export const currentOrgPlan = derived(currentOrg, ($currentOrg) => getActivePlan($currentOrg)); export const currentOrgPath = derived(currentOrg, ($currentOrg) => @@ -136,7 +126,13 @@ export function getOrgPublicUrl(org: OrgPublicOrigin, pathname = '/'): string { export const currentOrgDomain = derived(currentOrg, ($currentOrg) => getOrgPublicOrigin($currentOrg)); -export const isFreePlan = derived(currentOrg, ($currentOrg) => isOrgOnFreePlan($currentOrg)); +export const isFreePlan = derived(currentOrg, ($currentOrg) => + isOrgOnFreePlan({ + plans: $currentOrg.plans, + isSelfHosted: PUBLIC_IS_SELFHOSTED === 'true', + orgId: $currentOrg.id + }) +); export const isEnterprisePlan = derived(currentOrg, ($currentOrg) => { if (PUBLIC_IS_SELFHOSTED === 'true') return true; diff --git a/apps/dashboard/src/lib/utils/translations/da.json b/apps/dashboard/src/lib/utils/translations/da.json index 583c8602..edea6b0f 100644 --- a/apps/dashboard/src/lib/utils/translations/da.json +++ b/apps/dashboard/src/lib/utils/translations/da.json @@ -2640,6 +2640,10 @@ "refresh": "Opdater", "remove": "Fjern", "custom_favicon": "Brugerdefineret favicon", + "custom_favicon_description": "Vises i browserfaner, når besøgende åbner jeres offentlige skolewebsted. Falder tilbage til jeres organisationslogo, hvis den ikke er angivet.", + "custom_favicon_save": "Gem favicon", + "custom_favicon_remove": "Fjern favicon", + "custom_favicon_saved": "Favicon opdateret", "custom_code": "Brugerdefineret kode", "reconnect": "Gentilslut domæne", "reconnect_hint": "Gentilslut dette domæne for at generere nye Cloudflare-opsætningsposter.", diff --git a/apps/dashboard/src/lib/utils/translations/de.json b/apps/dashboard/src/lib/utils/translations/de.json index 3ab2327f..b55908a8 100644 --- a/apps/dashboard/src/lib/utils/translations/de.json +++ b/apps/dashboard/src/lib/utils/translations/de.json @@ -2640,6 +2640,10 @@ "refresh": "Aktualisieren", "remove": "Entfernen", "custom_favicon": "Benutzerdefiniertes Favicon", + "custom_favicon_description": "Wird in Browser-Tabs angezeigt, wenn Besucher Ihre öffentliche Schulwebsite öffnen. Falls nicht festgelegt, wird das Logo Ihrer Organisation verwendet.", + "custom_favicon_save": "Favicon speichern", + "custom_favicon_remove": "Favicon entfernen", + "custom_favicon_saved": "Favicon aktualisiert", "custom_code": "Benutzerdefinierter Code", "reconnect": "Domäne erneut verbinden", "reconnect_hint": "Verbinden Sie diese Domäne erneut, um neue Cloudflare-Setup-Datensätze zu generieren.", diff --git a/apps/dashboard/src/lib/utils/translations/en.json b/apps/dashboard/src/lib/utils/translations/en.json index 2bac86b8..97edbc79 100644 --- a/apps/dashboard/src/lib/utils/translations/en.json +++ b/apps/dashboard/src/lib/utils/translations/en.json @@ -1635,6 +1635,10 @@ "reconnect_description": "This domain needs to be connected again to generate new setup records.", "error_description": "This custom domain exists, but verification is not complete yet.", "custom_favicon": "Custom favicon", + "custom_favicon_description": "Shown in browser tabs when visitors open your public school site. Falls back to your organization logo when unset.", + "custom_favicon_save": "Save favicon", + "custom_favicon_remove": "Remove favicon", + "custom_favicon_saved": "Favicon updated", "custom_code": "Custom code" }, "customize_lms": { diff --git a/apps/dashboard/src/lib/utils/translations/es.json b/apps/dashboard/src/lib/utils/translations/es.json index 9cefa0fc..f31ea4f1 100644 --- a/apps/dashboard/src/lib/utils/translations/es.json +++ b/apps/dashboard/src/lib/utils/translations/es.json @@ -2640,6 +2640,10 @@ "refresh": "Actualizar", "remove": "Quitar", "custom_favicon": "favicon personalizado", + "custom_favicon_description": "Se muestra en las pestañas del navegador cuando los visitantes abren el sitio público de su escuela. Si no se establece, se usa el logo de su organización.", + "custom_favicon_save": "Guardar favicon", + "custom_favicon_remove": "Eliminar favicon", + "custom_favicon_saved": "Favicon actualizado", "custom_code": "código personalizado", "reconnect": "Reconectar dominio", "reconnect_hint": "Vuelva a conectar este dominio para generar nuevos registros de configuración de Cloudflare.", diff --git a/apps/dashboard/src/lib/utils/translations/fr.json b/apps/dashboard/src/lib/utils/translations/fr.json index b4060a44..f2b7709c 100644 --- a/apps/dashboard/src/lib/utils/translations/fr.json +++ b/apps/dashboard/src/lib/utils/translations/fr.json @@ -2640,6 +2640,10 @@ "refresh": "Actualiser", "remove": "Supprimer", "custom_favicon": "Icône de favori personnalisée", + "custom_favicon_description": "S'affiche dans les onglets du navigateur lorsque les visiteurs ouvrent le site public de votre école. Utilise le logo de votre organisation par défaut si aucun n'est défini.", + "custom_favicon_save": "Enregistrer le favicon", + "custom_favicon_remove": "Supprimer le favicon", + "custom_favicon_saved": "Favicon mis à jour", "custom_code": "Code personnalisé", "reconnect": "Reconnecter le domaine", "reconnect_hint": "Reconnectez ce domaine pour générer de nouveaux enregistrements de configuration Cloudflare.", diff --git a/apps/dashboard/src/lib/utils/translations/hi.json b/apps/dashboard/src/lib/utils/translations/hi.json index a2d25416..9315a55c 100644 --- a/apps/dashboard/src/lib/utils/translations/hi.json +++ b/apps/dashboard/src/lib/utils/translations/hi.json @@ -2640,6 +2640,10 @@ "refresh": "ताज़ा करें", "remove": "हटाओ", "custom_favicon": "कस्टम फ़ेविकॉन", + "custom_favicon_description": "जब विज़िटर आपकी सार्वजनिक स्कूल साइट खोलते हैं तो ब्राउज़र टैब में दिखाई देता है। यदि सेट नहीं है, तो आपके संगठन के लोगो का उपयोग होता है।", + "custom_favicon_save": "फ़ेविकॉन सहेजें", + "custom_favicon_remove": "फ़ेविकॉन हटाएं", + "custom_favicon_saved": "फ़ेविकॉन अपडेट किया गया", "custom_code": "कस्टम कोड", "reconnect": "डोमेन पुनः कनेक्ट करें", "reconnect_hint": "नए क्लाउडफ़ेयर सेटअप रिकॉर्ड बनाने के लिए इस डोमेन को पुनः कनेक्ट करें।", diff --git a/apps/dashboard/src/lib/utils/translations/pl.json b/apps/dashboard/src/lib/utils/translations/pl.json index 05eb8f60..774e88d5 100644 --- a/apps/dashboard/src/lib/utils/translations/pl.json +++ b/apps/dashboard/src/lib/utils/translations/pl.json @@ -2640,6 +2640,10 @@ "refresh": "Odśwież", "remove": "Usuń", "custom_favicon": "Niestandardowa favikona", + "custom_favicon_description": "Wyświetla się na kartach przeglądarki, gdy odwiedzający otwierają publiczną witrynę szkoły. Jeśli nie ustawiono, używane jest logo organizacji.", + "custom_favicon_save": "Zapisz faviconę", + "custom_favicon_remove": "Usuń faviconę", + "custom_favicon_saved": "Favicona zaktualizowana", "custom_code": "Kod niestandardowy", "reconnect": "Połącz ponownie domenę", "reconnect_hint": "Połącz ponownie tę domenę, aby wygenerować nowe rekordy konfiguracji Cloudflare.", diff --git a/apps/dashboard/src/lib/utils/translations/pt.json b/apps/dashboard/src/lib/utils/translations/pt.json index 6fdc4475..d741d3de 100644 --- a/apps/dashboard/src/lib/utils/translations/pt.json +++ b/apps/dashboard/src/lib/utils/translations/pt.json @@ -2640,6 +2640,10 @@ "refresh": "Atualizar", "remove": "Remover", "custom_favicon": "Favicon personalizado", + "custom_favicon_description": "Aparece nas abas do navegador quando visitantes abrem o site público da escola. Se não definido, usa o logotipo da organização.", + "custom_favicon_save": "Salvar favicon", + "custom_favicon_remove": "Remover favicon", + "custom_favicon_saved": "Favicon atualizado", "custom_code": "Código personalizado", "reconnect": "Reconectar domínio", "reconnect_hint": "Reconecte este domínio para gerar novos registros de configuração da Cloudflare.", diff --git a/apps/dashboard/src/lib/utils/translations/ru.json b/apps/dashboard/src/lib/utils/translations/ru.json index c84d5a20..b2544248 100644 --- a/apps/dashboard/src/lib/utils/translations/ru.json +++ b/apps/dashboard/src/lib/utils/translations/ru.json @@ -2640,6 +2640,10 @@ "refresh": "Обновить", "remove": "Удалить", "custom_favicon": "Пользовательский значок", + "custom_favicon_description": "Отображается на вкладках браузера, когда посетители открывают публичный сайт вашей школы. Если не задан, используется логотип организации.", + "custom_favicon_save": "Сохранить значок", + "custom_favicon_remove": "Удалить значок", + "custom_favicon_saved": "Значок обновлён", "custom_code": "Пользовательский код", "reconnect": "Переподключить домен", "reconnect_hint": "Повторно подключите этот домен, чтобы создать новые записи настройки Cloudflare.", diff --git a/apps/dashboard/src/lib/utils/translations/vi.json b/apps/dashboard/src/lib/utils/translations/vi.json index 80c7a11d..8fc374c7 100644 --- a/apps/dashboard/src/lib/utils/translations/vi.json +++ b/apps/dashboard/src/lib/utils/translations/vi.json @@ -2640,6 +2640,10 @@ "refresh": "Làm mới", "remove": "Xóa", "custom_favicon": "Biểu tượng yêu thích tùy chỉnh", + "custom_favicon_description": "Hiển thị trên tab trình duyệt khi khách truy cập mở trang công khai của trường. Nếu chưa đặt, sẽ dùng logo tổ chức của bạn.", + "custom_favicon_save": "Lưu favicon", + "custom_favicon_remove": "Xóa favicon", + "custom_favicon_saved": "Favicon đã được cập nhật", "custom_code": "Mã tùy chỉnh", "reconnect": "Kết nối lại miền", "reconnect_hint": "Kết nối lại miền này để tạo bản ghi thiết lập Cloudflare mới.", diff --git a/apps/dashboard/src/routes/(org-site)/+layout.svelte b/apps/dashboard/src/routes/(org-site)/+layout.svelte index dfca32b4..baac3ce1 100644 --- a/apps/dashboard/src/routes/(org-site)/+layout.svelte +++ b/apps/dashboard/src/routes/(org-site)/+layout.svelte @@ -1,5 +1,6 @@ + + {#if data.org?.isRestricted || $currentOrg.isRestricted} {:else} diff --git a/apps/dashboard/src/routes/(org-site)/courses/+page.server.ts b/apps/dashboard/src/routes/(org-site)/courses/+page.server.ts index 2966e95f..44ffb701 100644 --- a/apps/dashboard/src/routes/(org-site)/courses/+page.server.ts +++ b/apps/dashboard/src/routes/(org-site)/courses/+page.server.ts @@ -79,19 +79,7 @@ export const load = async ({ parent, url }) => { type: 'website', url: canonicalUrl, title: orgTitle, - description: orgDescription, - ...(org.avatarUrl - ? { - images: [ - { - url: org.avatarUrl, - alt: `${org.name} logo`, - secureUrl: org.avatarUrl, - type: 'image/jpeg' - } - ] - } - : {}) + description: orgDescription } } satisfies MetaTagsProps); diff --git a/apps/dashboard/src/routes/+layout.server.ts b/apps/dashboard/src/routes/+layout.server.ts index 1d9e3882..45257d9c 100644 --- a/apps/dashboard/src/routes/+layout.server.ts +++ b/apps/dashboard/src/routes/+layout.server.ts @@ -2,7 +2,7 @@ import type { AccountOrg } from '$features/app/types'; import type { MetaTagsProps } from 'svelte-meta-tags'; import type { UploadLimits } from '$lib/utils/config/upload-limits'; import { PUBLIC_IS_SELFHOSTED } from '$env/static/public'; -import { getBaseMetaTags } from '$lib/utils/functions/metaTags'; +import { getBaseMetaTags } from '$lib/utils/functions/metaTags.server'; import { getOrgSiteInfo } from '$features/app/layout-setup'; import { getUploadLimits } from '$lib/utils/config/upload-limits'; diff --git a/apps/dashboard/src/routes/+page.svelte b/apps/dashboard/src/routes/+page.svelte index 6b4a28be..cfb63821 100644 --- a/apps/dashboard/src/routes/+page.svelte +++ b/apps/dashboard/src/routes/+page.svelte @@ -9,6 +9,7 @@ import { Empty } from '@cio/ui/custom/empty'; import { SimpleLogoNav } from '@cio/ui/custom/simple-logo-nav'; import { buildOrgLandingPageProps, normalizeLandingPageSettings } from '$features/org/utils/landing-page'; + import OrgSiteFavicon from '$features/app/org-site-favicon.svelte'; import { basePath } from '$lib/utils/store/app'; import { t } from '$lib/utils/functions/translations'; import { user } from '$lib/utils/store/user'; @@ -64,6 +65,7 @@ {#if data.isOrgSite && data.org} + {#if data.ThemeComponent && landingPageProps} {/if} diff --git a/packages/utils/src/plans/index.ts b/packages/utils/src/plans/index.ts index 8568e059..c9159027 100644 --- a/packages/utils/src/plans/index.ts +++ b/packages/utils/src/plans/index.ts @@ -1,6 +1,7 @@ export * from './constants'; export * from './automation'; export * from './public-api'; +export * from './org-plan'; export * from './utils'; export * from './token-packs'; diff --git a/packages/utils/src/plans/org-plan.ts b/packages/utils/src/plans/org-plan.ts new file mode 100644 index 00000000..6d538ece --- /dev/null +++ b/packages/utils/src/plans/org-plan.ts @@ -0,0 +1,26 @@ +import { PLAN } from './constants'; + +export type OrgPlanLike = { + planName: string | null; + isActive: boolean | null; +}; + +export type IsOrgOnFreePlanOptions = { + plans?: OrgPlanLike[] | null; + isSelfHosted: boolean; + orgId?: string | null; +}; + +export function isOrgOnFreePlan({ plans, isSelfHosted, orgId }: IsOrgOnFreePlanOptions): boolean { + if (!orgId) { + return false; + } + + if (isSelfHosted) { + return false; + } + + const activePlan = plans?.find((plan) => plan.isActive); + + return !activePlan || activePlan.planName === PLAN.BASIC; +} diff --git a/packages/utils/src/validation/organization/organization.ts b/packages/utils/src/validation/organization/organization.ts index 7d691ae7..22037c86 100644 --- a/packages/utils/src/validation/organization/organization.ts +++ b/packages/utils/src/validation/organization/organization.ts @@ -96,6 +96,7 @@ export const ZUpdateOrganization = z.object({ }) .optional(), avatarUrl: z.url().optional(), + favicon: z.union([z.url(), z.null()]).optional(), theme: z.string().optional(), landingpage: z.record(z.string(), z.unknown()).optional(), siteName: z.string().min(1).optional(), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7b11137d..0e6b98fc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18647,7 +18647,7 @@ snapshots: dependencies: '@types/estree': 1.0.8 estree-walker: 2.0.2 - picomatch: 4.0.3 + picomatch: 4.0.4 optionalDependencies: rollup: 4.54.0 @@ -28538,8 +28538,8 @@ snapshots: tinyglobby@0.2.15: dependencies: - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 tinyglobby@0.2.16: dependencies: