fix(org): OG redesign, public meta URLs, and org-site meta copy (#712)
* 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 <[email protected]> * 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 <[email protected]> * fix(org): scope custom favicon to public org-site routes only Co-authored-by: Rotimi Best <[email protected]> * 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 <[email protected]> * chore: remove packages/og-images directory Co-authored-by: Rotimi Best <[email protected]> * 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 <[email protected]> * 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 <[email protected]> * 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 <[email protected]> * 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 <[email protected]> * fix(dashboard): allow File favicon in org update before upload Skip favicon from pre-upload Zod validation when a File is provided, matching how avatar uploads bypass ZUpdateOrganization until resolved. Co-authored-by: Rotimi Best <[email protected]> * fix(dashboard): use public API URL for org OG meta tags Never emit PRIVATE_SERVER_URL in og:image URLs. Prefer PUBLIC_SERVER_URL for crawlers; fall back to same-origin /proxy when unset. Co-authored-by: Rotimi Best <[email protected]> * fix(dashboard): use org landing copy and public OG URLs for org sites Populate og:title, description, and site_name from org landing page hero copy on cloud tenant sites. Build og:image from the tenant origin /proxy path so crawlers never receive internal PRIVATE_SERVER_URL values. Co-authored-by: Rotimi Best <[email protected]> * feat(api): redesign org OG image with logo top-left Replace the saturated gradient layout with a light theme-tinted card: Academy label, org name, and tagline bottom-left; logo top-left; decorative circle top-right; watermark on free plans. Co-authored-by: Rotimi Best <[email protected]> --------- Co-authored-by: Cursor Agent <[email protected]> Co-authored-by: Rotimi Best <[email protected]>
This commit is contained in:
co-authored by
Rotimi Best
Cursor Agent
parent
b8fca607aa
commit
3d15bc0a01
@@ -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<string, unknown> {
|
||||
@@ -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
|
||||
? `<img class="logo-image" src="${escapeHtml(input.logoUrl)}" alt="" />`
|
||||
: `<div class="logo-fallback" aria-hidden="true">${escapeHtml(initials)}</div>`;
|
||||
: `<span class="logo-initials" aria-hidden="true">${escapeHtml(initials)}</span>`;
|
||||
|
||||
const taglineMarkup = tagline ? `<p class="tagline">${escapeHtml(tagline)}</p>` : '';
|
||||
|
||||
@@ -123,17 +158,15 @@ function renderOrgSiteOgTemplate(input: OrgSiteOgInput): { body: string; styles:
|
||||
|
||||
const body = `
|
||||
<div class="canvas">
|
||||
<div class="card">
|
||||
<div class="glow"></div>
|
||||
<div class="content">
|
||||
<div class="decor-circle"></div>
|
||||
<div class="logo-wrap">${logoMarkup}</div>
|
||||
<div class="copy">
|
||||
<div class="content">
|
||||
<p class="academy-label">Academy</p>
|
||||
<div class="accent-bar"></div>
|
||||
<h1 class="org-name">${escapeHtml(orgName)}</h1>
|
||||
${taglineMarkup}
|
||||
</div>
|
||||
</div>
|
||||
${watermarkMarkup}
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
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 {
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=1200,initial-scale=1.0">
|
||||
<title>${input.orgName} Open Graph</title>
|
||||
<title>${escapeHtml(input.orgName)} Open Graph</title>
|
||||
</head>
|
||||
<body>
|
||||
${body}
|
||||
|
||||
@@ -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<UpdateOrganizationRequest>({
|
||||
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
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`;
|
||||
}
|
||||
Reference in New Issue
Block a user