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:
208
apps/frontend/src/lib/api/marketing-solution.ts
Normal file
208
apps/frontend/src/lib/api/marketing-solution.ts
Normal 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
|
||||
}
|
||||
}
|
||||
40
apps/frontend/src/lib/sanitize.ts
Normal file
40
apps/frontend/src/lib/sanitize.ts
Normal 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'],
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user