Integrate CMS with Marketing Solutions page

Links the marketing solutions frontend page to the Payload CMS Pages
collection via the new API library. Removes legacy static portfolio
routes and components to consolidate marketing content. Enhances the
Header and Footer Astro components with improved responsive styling.
This commit is contained in:
2026-02-27 20:05:43 +08:00
parent b1a8006f12
commit b199f89998
29 changed files with 6475 additions and 2434 deletions

View File

@@ -0,0 +1,208 @@
/**
* Marketing Solutions API utilities
* Helper functions for fetching marketing solutions page data from Payload CMS
*/
// Constants
const CMS_BASE_URL = 'https://enchun-cms.anlstudio.cc'
const PAYLOAD_API_URL = `${CMS_BASE_URL}/api`
export const MARKETING_SOLUTIONS_SLUG = 'marketing-solutions'
export const PLACEHOLDER_IMAGE = '/placeholder-service.jpg'
/**
* Convert relative URL to absolute URL with CMS base URL
*/
function getFullUrl(url: string | undefined): string | undefined {
if (!url) return undefined
if (url.startsWith('http://') || url.startsWith('https://')) return url
return `${CMS_BASE_URL}${url.startsWith('/') ? '' : '/'}${url}`
}
export interface ServiceItem {
id: string
title: string
description: string
category: string
iconType?: 'preset' | 'svg' | 'upload'
icon?: string
iconSvg?: string
iconImage?: {
url?: string
alt?: string
}
isHot?: boolean
image?: {
url?: string
alt?: string
}
link?: string
}
export interface PageHero {
type?: string
richText?: {
root?: {
children?: Array<{
type: string
children?: Array<{
text: string
}>
}>
}
}
media?: {
url?: string
alt?: string
}
}
export interface PageLayout {
blockType: string
services?: ServiceItem[]
}
export interface PageData {
id: string
title: string
slug: string
hero?: PageHero
layout?: PageLayout[]
}
/**
* Extract plain text from Lexical richText structure
*/
function extractTextFromRichText(richText: PageHero['richText']): string | undefined {
if (!richText?.root?.children) return undefined
const texts: string[] = []
for (const child of richText.root.children) {
if (child.children) {
for (const textNode of child.children) {
if (textNode.text) {
texts.push(textNode.text)
}
}
}
}
return texts.length > 0 ? texts.join(' ') : undefined
}
/**
* Fetch page by slug from Payload CMS
*/
export async function getPageBySlug(slug: string): Promise<PageData | null> {
try {
const params = new URLSearchParams()
params.append('where[slug][equals]', slug)
params.append('where[_status][equals]', 'published')
params.append('depth', '2')
const response = await fetch(`${PAYLOAD_API_URL}/pages?${params}`)
if (!response.ok) {
throw new Error(`Failed to fetch page: ${response.statusText}`)
}
const data = await response.json()
return data.docs?.[0] || null
} catch (error) {
console.error('Error fetching page by slug:', error)
return null
}
}
/**
* Fetch Marketing Solutions page data
* Returns hero title/subtitle/image and services list
*/
export async function getMarketingSolutionsPage(): Promise<{
heroTitle?: string
heroSubtitle?: string
heroImage?: {
url?: string
alt?: string
}
services: ServiceItem[]
} | null> {
try {
const page = await getPageBySlug(MARKETING_SOLUTIONS_SLUG)
if (!page) {
return null
}
// Extract hero content
const heroTitle = page.hero?.type === 'highImpact'
? page.title
: extractTextFromRichText(page.hero?.richText)
// Find hero subtitle from hero richText (second paragraph if exists)
function extractText(node: any): string {
if (!node) return ''
if (node.type === 'text') return node.text || ''
if (Array.isArray(node.children)) {
return node.children.map(extractText).join(' ')
}
return ''
}
let heroSubtitle: string | undefined
const paragraphs = page.hero?.richText?.root?.children?.filter(
(child: any) => child.type === 'paragraph'
)
if (paragraphs?.length) {
heroSubtitle = extractText(paragraphs[0]).trim()
}
// Extract hero image if available
const heroImage = page.hero?.media
? {
url: getFullUrl(page.hero.media.url),
alt: page.hero.media.alt || page.title,
}
: undefined
// Find ServicesList block in layout
const servicesBlock = page.layout?.find(
(block) => block.blockType === 'servicesList'
)
// Transform services data
const services: ServiceItem[] = servicesBlock?.services?.map((service: any) => ({
id: service.id,
title: service.title,
description: service.description,
category: service.category,
iconType: service.iconType || 'preset',
icon: service.icon,
iconSvg: service.iconSvg,
iconImage: service.iconImage
? {
url: getFullUrl(service.iconImage.url),
alt: service.iconImage.alt || service.title,
}
: undefined,
isHot: service.isHot,
image: service.image
? {
url: getFullUrl(service.image.url),
alt: service.image.alt || service.title,
}
: undefined,
link: service.link,
})) || []
return {
heroTitle,
heroSubtitle,
heroImage,
services,
}
} catch (error) {
console.error('Error fetching marketing solutions page:', error)
return null
}
}

View File

@@ -0,0 +1,40 @@
/**
* SVG sanitization utilities
* Prevents XSS attacks from user-provided SVG content
*/
import DOMPurify from 'isomorphic-dompurify'
/**
* Sanitize SVG content to prevent XSS attacks
* Only allows safe SVG elements and attributes
*/
export const sanitizeSvg = (svg: string): string => {
return DOMPurify.sanitize(svg, {
USE_PROFILES: { svg: true, svgFilters: true },
ADD_TAGS: ['use', 'defs', 'symbol'],
ADD_ATTR: [
'viewBox',
'fill',
'class',
'stroke',
'stroke-width',
'd',
'cx',
'cy',
'r',
'x',
'y',
'width',
'height',
'transform',
'xmlns',
'xmlns:xlink',
'xlink:href',
'preserveAspectRatio',
'clip-rule',
'fill-rule',
],
FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'style'],
FORBID_ATTR: ['onload', 'onerror', 'onclick', 'onmouseover'],
})
}