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: