diff --git a/apps/api/src/utils/org-site-og.ts b/apps/api/src/utils/org-site-og.ts index 3f3674ed..cafd4607 100644 --- a/apps/api/src/utils/org-site-og.ts +++ b/apps/api/src/utils/org-site-og.ts @@ -48,25 +48,57 @@ function orgInitials(orgName: string): string { return orgName.trim().slice(0, 2).toUpperCase(); } -function shadeColor(hex: string, percent: number): string { +function hexToRgb(hex: string): { red: number; green: number; blue: number } | null { const normalized = hex.startsWith('#') ? hex.slice(1) : hex; if (normalized.length !== 6) { - return hex; + return null; } const numeric = parseInt(normalized, 16); if (Number.isNaN(numeric)) { - return hex; + return null; } - 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 { + red: (numeric >> 16) & 0xff, + green: (numeric >> 8) & 0xff, + blue: numeric & 0xff + }; +} - return `#${next.toString(16).padStart(6, '0')}`; +function mixWithWhite(hex: string, amount = 0.9): string { + const rgb = hexToRgb(hex); + if (!rgb) { + return '#f8fafc'; + } + + const red = Math.round(rgb.red * (1 - amount) + 255 * amount); + const green = Math.round(rgb.green * (1 - amount) + 255 * amount); + const blue = Math.round(rgb.blue * (1 - amount) + 255 * amount); + + return `rgb(${red}, ${green}, ${blue})`; +} + +function rgba(hex: string, alpha: number): string { + const rgb = hexToRgb(hex); + if (!rgb) { + return `rgba(29, 78, 216, ${alpha})`; + } + + return `rgba(${rgb.red}, ${rgb.green}, ${rgb.blue}, ${alpha})`; +} + +function darken(hex: string, amount = 0.55): string { + const rgb = hexToRgb(hex); + if (!rgb) { + return '#0f172a'; + } + + const red = Math.round(rgb.red * (1 - amount)); + const green = Math.round(rgb.green * (1 - amount)); + const blue = Math.round(rgb.blue * (1 - amount)); + + return `rgb(${red}, ${green}, ${blue})`; } function isRecord(value: unknown): value is Record { @@ -108,12 +140,15 @@ function renderOrgSiteOgTemplate(input: OrgSiteOgInput): { body: string; styles: 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 background = mixWithWhite(themeColor, 0.9); + const titleColor = darken(themeColor, 0.62); + const taglineColor = darken(themeColor, 0.42); + const labelColor = rgba(themeColor, 0.72); const initials = orgInitials(orgName); const logoMarkup = input.logoUrl ? `` - : ``; + : ``; const taglineMarkup = tagline ? `

${escapeHtml(tagline)}

` : ''; @@ -123,17 +158,15 @@ function renderOrgSiteOgTemplate(input: OrgSiteOgInput): { body: string; styles: const body = `
-
-
-
-
${logoMarkup}
-
-

${escapeHtml(orgName)}

- ${taglineMarkup} -
-
- ${watermarkMarkup} +
+
${logoMarkup}
+
+

Academy

+
+

${escapeHtml(orgName)}

+ ${taglineMarkup}
+ ${watermarkMarkup}
`; const styles = ` @@ -156,103 +189,112 @@ body { } .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; + background: ${background}; overflow: hidden; } -.glow { +.decor-circle { position: absolute; - top: -120px; - right: -80px; - width: 420px; - height: 420px; + top: -180px; + right: -120px; + width: 520px; + height: 520px; 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; + background: ${rgba(themeColor, 0.22)}; } .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 { + position: absolute; + top: 56px; + left: 64px; + z-index: 2; + width: 88px; + height: 88px; + border-radius: 20px; + background: ${themeColor}; display: flex; align-items: center; justify-content: center; - font-size: 42px; + box-shadow: 0 16px 40px ${rgba(themeColor, 0.28)}; +} + +.logo-image { + width: 56px; + height: 56px; + border-radius: 12px; + object-fit: cover; +} + +.logo-initials { + color: #ffffff; + font-size: 30px; font-weight: 700; letter-spacing: 0.04em; } -.copy { - min-width: 0; +.content { + position: absolute; + left: 72px; + bottom: 72px; + z-index: 2; + max-width: 760px; +} + +.academy-label { + font-size: 22px; + font-weight: 600; + color: ${labelColor}; + letter-spacing: 0.01em; +} + +.accent-bar { + width: 56px; + height: 5px; + border-radius: 9999px; + background: ${themeColor}; + margin: 14px 0 22px; } .org-name { - font-size: 64px; + font-size: 58px; line-height: 1.05; font-weight: 700; letter-spacing: -0.03em; + color: ${titleColor}; text-wrap: balance; } .tagline { - margin-top: 18px; - max-width: 760px; + margin-top: 16px; + max-width: 720px; font-size: 30px; line-height: 1.35; font-weight: 500; - color: rgba(255, 255, 255, 0.88); + color: ${taglineColor}; text-wrap: balance; } .watermark { position: absolute; - right: 48px; - bottom: 36px; - z-index: 2; + right: 64px; + bottom: 40px; + z-index: 3; 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; + background: rgba(255, 255, 255, 0.72); + border: 1px solid ${rgba(themeColor, 0.18)}; + font-size: 16px; font-weight: 500; - color: rgba(255, 255, 255, 0.92); + color: ${taglineColor}; letter-spacing: 0.01em; } .watermark strong { font-weight: 700; + color: ${titleColor}; }`; return { body, styles }; @@ -266,7 +308,7 @@ export function renderOrgSiteOg(input: OrgSiteOgInput): OrgSiteOgRenderResult { - ${input.orgName} Open Graph + ${escapeHtml(input.orgName)} Open Graph ${body} 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 0b64bc99..f8bd8ea4 100644 --- a/apps/dashboard/src/lib/features/org/api/org.svelte.ts +++ b/apps/dashboard/src/lib/features/org/api/org.svelte.ts @@ -331,7 +331,12 @@ class OrgApi extends BaseApiWithErrors { fields: TOrgUpdateForm, options: { onSuccess?: (data: TUpdateOrganization) => void } = {} ) { - const result = ZUpdateOrganization.safeParse(fields); + const { avatar, favicon, ...rest } = fields; + const validationPayload = { + ...rest, + ...(typeof favicon === 'string' || favicon === null ? { favicon } : {}) + }; + const result = ZUpdateOrganization.safeParse(validationPayload); if (!result.success) { this.errors = mapZodErrorsToTranslations(result.error, 'organization'); @@ -342,19 +347,19 @@ class OrgApi extends BaseApiWithErrors { // Handle avatar upload if provided let avatarUrl: string | undefined; - if (fields.avatar instanceof File) { - avatarUrl = await uploadImage(fields.avatar); - } else if (typeof fields.avatar === 'string') { - avatarUrl = fields.avatar; + if (avatar instanceof File) { + avatarUrl = await uploadImage(avatar); + } else if (typeof avatar === 'string') { + avatarUrl = 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; + let resolvedFavicon: string | null | undefined; + if (favicon instanceof File) { + resolvedFavicon = await uploadImage(favicon); + } else if (typeof favicon === 'string') { + resolvedFavicon = favicon; + } else if (favicon === null) { + resolvedFavicon = null; } // Build update payload @@ -366,8 +371,8 @@ class OrgApi extends BaseApiWithErrors { avatarUrl }; - if (favicon !== undefined) { - updates.favicon = favicon; + if (resolvedFavicon !== undefined) { + updates.favicon = resolvedFavicon; } await this.execute({ diff --git a/apps/dashboard/src/lib/utils/functions/metaTags.server.ts b/apps/dashboard/src/lib/utils/functions/metaTags.server.ts index 878739a0..db4dcd4e 100644 --- a/apps/dashboard/src/lib/utils/functions/metaTags.server.ts +++ b/apps/dashboard/src/lib/utils/functions/metaTags.server.ts @@ -2,7 +2,7 @@ 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 as publicEnv } from '$env/dynamic/public'; -import { env as privateEnv } from '$env/dynamic/private'; +import { buildOrgSiteTitle, extractOrgSiteMetaCopy } from '$lib/utils/functions/org-site-meta'; const isSelfHosted = PUBLIC_IS_SELFHOSTED === 'true'; @@ -13,17 +13,32 @@ const CLOUD_OG_IMAGE = 'https://brand.cdn.clsrio.com/og/classroomio-opengraph.jp const ORG_OG_WIDTH = 1200; const ORG_OG_HEIGHT = 630; -function getApiServerUrl(): string { - return privateEnv.PRIVATE_SERVER_URL?.trim() || publicEnv.PUBLIC_SERVER_URL?.trim() || ''; +function isPublicHttpOrigin(value: string): boolean { + try { + const parsed = new URL(value); + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { + return false; + } + + const host = parsed.hostname; + if (host === 'localhost' || host === '127.0.0.1') { + return true; + } + + return host.includes('.'); + } catch { + return false; + } } -export function getOrgSiteOgImageUrl(siteName: string): string | null { - const apiBase = getApiServerUrl(); - if (!apiBase || !siteName) { +function getOrgSiteOgImageUrl(siteName: string, pageUrl: URL): string | null { + if (!siteName) { return null; } - return `${apiBase.replace(/\/$/, '')}/org-site/og/${encodeURIComponent(siteName)}.png`; + const ogPath = `/org-site/og/${encodeURIComponent(siteName)}.png`; + + return `${pageUrl.origin}/proxy${ogPath}`; } function resolveOgImageUrl(url: URL, orgSiteInfo: OrgSiteInfo): string { @@ -33,12 +48,17 @@ function resolveOgImageUrl(url: URL, orgSiteInfo: OrgSiteInfo): string { } if (orgSiteInfo.isOrgSite && orgSiteInfo.org?.siteName) { - const dynamicOgUrl = getOrgSiteOgImageUrl(orgSiteInfo.org.siteName); + const dynamicOgUrl = getOrgSiteOgImageUrl(orgSiteInfo.org.siteName, url); if (dynamicOgUrl) { return dynamicOgUrl; } } + const publicServerUrl = publicEnv.PUBLIC_SERVER_URL?.trim(); + if (publicServerUrl && isPublicHttpOrigin(publicServerUrl) && orgSiteInfo.org?.siteName) { + return `${publicServerUrl.replace(/\/$/, '')}/org-site/og/${encodeURIComponent(orgSiteInfo.org.siteName)}.png`; + } + if (isSelfHosted) { const org = orgSiteInfo.org; if (!org) { @@ -61,27 +81,54 @@ function resolveOgImageUrl(url: URL, orgSiteInfo: OrgSiteInfo): string { return CLOUD_OG_IMAGE; } -function buildOrgOpenGraphImages(ogImageUrl: string, siteName: string) { +function buildOrgOpenGraphImages(ogImageUrl: string, orgName: string) { return [ { url: ogImageUrl, - alt: `${siteName} learning platform`, + alt: `${orgName} learning platform`, width: ORG_OG_WIDTH, height: ORG_OG_HEIGHT, - secureUrl: ogImageUrl, + secureUrl: ogImageUrl.startsWith('https://') ? ogImageUrl : undefined, type: 'image/png' } ]; } +function resolveOrgSiteMeta(orgSiteInfo: OrgSiteInfo): { + title: string; + description: string; + siteName: string; +} | null { + const org = orgSiteInfo.org; + if (!orgSiteInfo.isOrgSite || !org?.name?.trim()) { + return null; + } + + const orgName = org.name.trim(); + const metaCopy = extractOrgSiteMetaCopy(org.landingpage); + + return { + title: publicEnv.PUBLIC_APP_TITLE?.trim() || buildOrgSiteTitle(orgName, metaCopy.heading), + description: + publicEnv.PUBLIC_APP_DESCRIPTION?.trim() || + metaCopy.description || + `Explore courses and training programs from ${orgName}.`, + siteName: orgName + }; +} + export function getBaseMetaTags(url: URL, orgSiteInfo: OrgSiteInfo): MetaTagsProps { + const orgMeta = resolveOrgSiteMeta(orgSiteInfo); + const title = + orgMeta?.title || publicEnv.PUBLIC_APP_TITLE?.trim() || (isSelfHosted && orgSiteInfo.org?.name ? `${orgSiteInfo.org.name} | Learning Platform` : DEFAULT_TITLE); - const description = publicEnv.PUBLIC_APP_DESCRIPTION?.trim() || DEFAULT_DESCRIPTION; + const description = orgMeta?.description || publicEnv.PUBLIC_APP_DESCRIPTION?.trim() || DEFAULT_DESCRIPTION; const siteName = + orgMeta?.siteName || publicEnv.PUBLIC_APP_TITLE?.trim() || (isSelfHosted && orgSiteInfo.org?.name ? orgSiteInfo.org.name : null) || 'ClassroomIO'; @@ -98,7 +145,7 @@ export function getBaseMetaTags(url: URL, orgSiteInfo: OrgSiteInfo): MetaTagsPro alt: `${siteName} platform for customer, partner, and employee education`, width: 1920, height: 1080, - secureUrl: ogImageUrl, + secureUrl: ogImageUrl.startsWith('https://') ? ogImageUrl : undefined, type: 'image/jpeg' }, { @@ -111,6 +158,10 @@ export function getBaseMetaTags(url: URL, orgSiteInfo: OrgSiteInfo): MetaTagsPro } ]; + const imageAlt = usesDynamicOrgOg + ? `${siteName} learning platform` + : `${siteName} platform for customer, partner, and employee education`; + return Object.freeze({ title, description, @@ -131,7 +182,7 @@ export function getBaseMetaTags(url: URL, orgSiteInfo: OrgSiteInfo): MetaTagsPro title, description, image: ogImageUrl, - imageAlt: `${siteName} platform for customer, partner, and employee education` + imageAlt } }); } diff --git a/apps/dashboard/src/lib/utils/functions/org-site-meta.ts b/apps/dashboard/src/lib/utils/functions/org-site-meta.ts new file mode 100644 index 00000000..21ef278a --- /dev/null +++ b/apps/dashboard/src/lib/utils/functions/org-site-meta.ts @@ -0,0 +1,57 @@ +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export type OrgSiteMetaCopy = { + heading?: string; + description?: string; +}; + +export function extractOrgSiteMetaCopy(landingpage: unknown): OrgSiteMetaCopy { + if (!isRecord(landingpage)) { + return {}; + } + + const copy: OrgSiteMetaCopy = {}; + + const hero = landingpage.hero; + if (isRecord(hero)) { + if (typeof hero.heading === 'string') { + const heading = hero.heading.trim(); + if (heading) { + copy.heading = heading; + } + } + + if (typeof hero.subheading === 'string') { + const subheading = hero.subheading.trim(); + if (subheading) { + copy.description = subheading; + } + } + } + + const footer = landingpage.footer; + if (isRecord(footer)) { + const brand = footer.brand; + if (isRecord(brand) && typeof brand.tagline === 'string') { + const tagline = brand.tagline.trim(); + if (tagline && !copy.description) { + copy.description = tagline; + } + } + } + + return copy; +} + +export function buildOrgSiteTitle(orgName: string, heading?: string): string { + const trimmedName = orgName.trim(); + const trimmedHeading = heading?.trim(); + + if (trimmedHeading) { + return `${trimmedName} | ${trimmedHeading}`; + } + + return `${trimmedName} | Learning Platform`; +}