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

@@ -17,8 +17,9 @@
"@tailwindcss/vite": "^4.1.14",
"agentation": "^2.1.1",
"agentation-mcp": "^1.1.0",
"astro": "6.0.0-beta.1",
"better-auth": "^1.3.13"
"astro": "6.0.0-beta.17",
"better-auth": "^1.3.13",
"isomorphic-dompurify": "^3.0.0"
},
"devDependencies": {
"@astrojs/check": "^0.9.6",

View File

@@ -42,8 +42,8 @@ try {
const currentYear = new Date().getFullYear();
---
<footer class="bg-[var(--color-tropical-blue)] py-10 mt-auto relative">
<div class="max-w-5xl mx-auto px-4">
<footer class="bg-[var(--color-tropical-blue)] pt-10 mt-auto relative">
<div class="max-w-4xl mx-auto">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-8 mb-16">
<div class="col-span-2">
<Image
@@ -56,7 +56,7 @@ const currentYear = new Date().getFullYear();
decoding="async"
/>
<p
class="text-[var(--color-st-tropaz)] text-sm font-light leading-relaxed"
class="text-[var(--color-st-tropaz)] text-sm pr-8 font-light leading-relaxed"
>
恩群數位累積多年廣告行銷操作經驗,擁有全方位行銷人才,讓我們可以為客戶精準的規劃每一分廣告預算,讓你的品牌深入人心。更重要的是恩群的存在,為了成為每家公司最佳數位夥伴,作為彼此最堅強的後盾,你會知道有我們的陪伴
你並不孤單。
@@ -72,7 +72,7 @@ const currentYear = new Date().getFullYear();
href="https://www.facebook.com/EnChun-Taiwan-100979265112420"
target="_blank"
rel="noopener noreferrer"
class="flex items-center mb-2"
class="flex items-center mb-2 no-underline hover:underline transition-colors"
>
<Image
src="/fb-icon.svg"
@@ -84,12 +84,12 @@ const currentYear = new Date().getFullYear();
decoding="async"
/>
</a>
<p class="text-[var(--color-st-tropaz)] mb-2">
<p class="text-sm text-[var(--color-st-tropaz)] mb-2">
諮詢電話:<br /> 02 5570 0527
</p>
<a
href="mailto:enchuntaiwan@gmail.com"
class="text-primary hover:text-secondary transition-colors"
class="text-xs text-primary hover:text-secondary no-underline hover:underline transition-colors"
>enchuntaiwan@gmail.com</a
>
</div>
@@ -99,16 +99,28 @@ const currentYear = new Date().getFullYear();
>
行銷方案
</h3>
<ul class="space-y-2" id="marketing-solutions">
{footerNavItems.length > 0 && footerNavItems[0]?.childNavItems
? footerNavItems[0].childNavItems.map((item: any) => (
<ul
class="text-sm font-thin space-y-2"
id="marketing-solutions"
>
{
footerNavItems.length > 0 &&
footerNavItems[0]?.childNavItems ? (
footerNavItems[0].childNavItems.map((item: any) => (
<li>
<a
href={item.link?.url || "#"}
class="text-(--color-st-tropaz) hover:text-(--color-dark-blue) hover:font-light no-underline transition-colors duration-300"
>
{item.link?.label || "連結"}
</a>
</li>
))
) : (
<li>
<a href={item.link?.url || "#"} class="font-normal text-[var(--color-st-tropaz)] hover:text-[var(--color-dove-gray)] transition-colors">
{item.link?.label || "連結"}
</a>
<span class="text-gray-500">載入中...</span>
</li>
))
: <li><span class="text-gray-500">載入中...</span></li>
)
}
</ul>
</div>
@@ -118,40 +130,35 @@ const currentYear = new Date().getFullYear();
>
行銷放大鏡
</h3>
<ul class="space-y-2" id="marketing-articles">
{categories.length > 0
? categories.map((cat: any) => (
<ul class="text-sm font-thin space-y-2" id="marketing-articles">
{
categories.length > 0 ? (
categories.map((cat: any) => (
<li>
<a
href={`/blog/category/${cat.slug}`}
class="text-(--color-st-tropaz) hover:text-(--color-dark-blue) hover:font-light no-underline transition-colors duration-300"
title={cat.nameEn || cat.title}
>
{cat.title}
</a>
</li>
))
) : (
<li>
<a href={`/blog/category/${cat.slug}`}
class="font-normal text-[var(--color-st-tropaz)] hover:text-[var(--color-dove-gray)] transition-colors"
title={cat.nameEn || cat.title}>
{cat.title}
</a>
<span class="text-gray-500">暫無分類</span>
</li>
))
: <li><span class="text-gray-500">暫無分類</span></li>
)
}
</ul>
</div>
</div>
<div
class="absolute inset-x-0 w-screen bg-[var(--color-amber)] py-3 text-center -left-4"
>
<p class="text-[var(--color-tarawera)]">
copyright © Enchun digital 2018 - {currentYear}
</p>
</div>
</div>
<div
class="w-screen bg-[var(--color-amber)] font-['Quicksand'] text-[var(--color-tarawera)] py-2 text-xs text-center"
>
<p>
copyright © Enchun digital 2018 - {currentYear}
</p>
</div>
</footer>
<style>
/* Footer specific styles */
footer a {
text-decoration: none;
transition: color 0.2s ease-in-out;
}
footer a:hover {
text-decoration: underline;
}
</style>

View File

@@ -62,10 +62,10 @@ function isLinkActive(url: string): boolean {
---
<header
class="fixed top-0 left-0 right-0 z-50 transition-transform duration-300 ease-in-out"
class="fixed top-0 left-0 right-0 z-50 transition-all duration-300 ease-in-out will-change-transform"
id="main-header"
>
<nav
<nav
class="max-w-5xl mx-auto px-4 py-4 transition-all duration-300 ease-in-out"
id="main-nav"
>
@@ -155,8 +155,8 @@ function isLinkActive(url: string): boolean {
</li>
</ul>
<!-- Mobile menu with full-screen overlay -->
<div
class="md:hidden fixed left-0 right-0 top-20 h-[calc(100vh-80px)] h-[calc(100dvh-80px)] bg-white/20 backdrop-blur-lg opacity-0 invisible transition-all duration-300 ease-in-out"
<div
class="md:hidden fixed left-0 right-0 top-20 h-[calc(100vh-80px)] h-[calc(100dvh-80px)] bg-white/20 backdrop-blur-xl opacity-0 invisible transition-[opacity,visibility,top] duration-300 ease-in-out"
id="mobile-menu"
>
<ul
@@ -176,7 +176,7 @@ function isLinkActive(url: string): boolean {
>
<a
href={href}
class="text-2xl text-grey-700 text-shadow-sm font-medium transition-all duration-200 transform hover:scale-105"
class="text-2xl text-grey-700 font-medium transition-all duration-200 hover:scale-105 p-3 min-h-12 flex items-center [text-shadow:0_1px_2px_rgba(0,0,0,0.05)] hover:[text-shadow:0_2px_4px_rgba(0,0,0,0.1)]"
{...(link.newTab && {
target: "_blank",
rel: "noopener noreferrer",
@@ -420,16 +420,7 @@ function isLinkActive(url: string): boolean {
</script>
<style>
#main-header {
transition:
background-color 0.3s ease-in-out,
backdrop-filter 0.3s ease-in-out,
box-shadow 0.3s ease-in-out,
transform 0.3s ease-in-out;
will-change: transform;
}
/* Header translate animation - hide completely above viewport */
/* JS-controlled animation states - cannot be converted to Tailwind */
#main-header.translate-out {
transform: translateY(-100%);
}
@@ -438,21 +429,15 @@ function isLinkActive(url: string): boolean {
transform: translateY(0);
}
/* Nav padding transition for shrink effect */
#main-nav {
transition: padding 0.3s ease-in-out;
}
/* Logo shrinks when header is scrolled */
#main-header.header-scrolled img {
transform: scale(0.85);
transition: transform 0.3s ease-in-out;
}
/* Navigation links */
/* Navigation links - text-shadow not in Tailwind */
#main-nav a {
color: var(--color-nav-link);
position: relative;
text-shadow: 1px 1px 3px rgba(0, 0, 0, 0.3);
}
@@ -466,22 +451,13 @@ function isLinkActive(url: string): boolean {
font-weight: 600;
}
/* Badge positioning and styling */
/* Badge positioning */
#main-nav a[class*="relative"] .absolute {
top: -4px;
right: -8px;
}
/* Mobile menu styles */
#mobile-menu {
transition:
opacity 0.3s ease-in-out,
visibility 0.3s ease-in-out,
top 0.3s ease-in-out;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
}
/* Mobile menu - backdrop-blur-lg already in class */
@media (max-width: 767px) {
#mobile-menu {
overflow-y: auto;
@@ -501,13 +477,4 @@ function isLinkActive(url: string): boolean {
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
}
/* Smooth transition for all nav elements */
#main-nav a,
#mobile-nav a {
transition:
color 0.2s ease-in-out,
text-shadow 0.2s ease-in-out,
background-color 0.2s ease-in-out;
}
</style>

View File

@@ -29,19 +29,19 @@ const linkTarget = hasExternalLink ? '_blank' : '_self'
const linkRel = hasExternalLink ? 'noopener noreferrer' : ''
---
<li class="portfolio-card-item">
<li class="list-none">
<a
href={linkHref}
target={linkTarget}
rel={linkRel}
class="portfolio-card"
class="block bg-white rounded-xl overflow-hidden shadow-sm hover:shadow-lg transition-all duration-300 ease-in-out no-underline text-inherit hover:-translate-y-1 group"
>
<!-- Image Wrapper -->
<div class="portfolio-image-wrapper">
<div class="relative w-full aspect-video overflow-hidden">
<img
src={imageUrl}
alt={title}
class="portfolio-image"
class="absolute inset-0 w-full h-full object-cover transition-transform duration-300 ease-in-out group-hover:scale-105"
loading="lazy"
decoding="async"
width="800"
@@ -50,23 +50,23 @@ const linkRel = hasExternalLink ? 'noopener noreferrer' : ''
</div>
<!-- Content -->
<div class="portfolio-content">
<div class="p-6 md:p-5">
<!-- Title -->
<h3 class="portfolio-title">{title}</h3>
<h3 class="font-['Noto_Sans_TC'] text-xl md:text-lg font-semibold text-[var(--color-tarawera)] mb-2">{title}</h3>
<!-- Description -->
{
description && (
<p class="portfolio-description">{description}</p>
<p class="font-['Noto_Sans_TC'] text-sm md:text-[0.8125rem] text-[var(--color-gray-600)] leading-normal mb-4">{description}</p>
)
}
<!-- Tags -->
{
tags.length > 0 && (
<div class="portfolio-tags">
<div class="flex flex-wrap gap-2">
{tags.map((tag) => (
<span class="portfolio-tag">{tag}</span>
<span class="px-2.5 py-1 bg-[var(--color-gray-100)] rounded-full text-xs font-medium text-[var(--color-gray-700)]">{tag}</span>
))}
</div>
)
@@ -74,100 +74,3 @@ const linkRel = hasExternalLink ? 'noopener noreferrer' : ''
</div>
</a>
</li>
<style>
/* Portfolio Card Styles - Pixel-perfect from Webflow */
.portfolio-card-item {
list-style: none;
}
.portfolio-card {
background-color: white;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
transition: all 300ms ease-in-out;
display: block;
text-decoration: none;
color: inherit;
}
.portfolio-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
}
/* Image Wrapper */
.portfolio-image-wrapper {
position: relative;
width: 100%;
padding-bottom: 56.25%; /* 16:9 */
overflow: hidden;
}
.portfolio-image {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 300ms ease-in-out;
}
.portfolio-card:hover .portfolio-image {
transform: scale(1.05);
}
/* Content */
.portfolio-content {
padding: 24px;
}
.portfolio-title {
font-family: "Noto Sans TC", sans-serif;
font-size: 1.25rem;
font-weight: 600;
color: var(--color-tarawera, #2d3748);
margin-bottom: 8px;
}
.portfolio-description {
font-family: "Noto Sans TC", sans-serif;
font-size: 0.875rem;
color: var(--color-gray-600, #666666);
line-height: 1.5;
margin-bottom: 16px;
}
/* Tags */
.portfolio-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.portfolio-tag {
padding: 4px 10px;
background-color: var(--color-gray-100, #f2f2f2);
border-radius: 16px;
font-size: 0.75rem;
font-weight: 500;
color: var(--color-gray-700, #4a5568);
}
/* Responsive Adjustments */
@media (max-width: 767px) {
.portfolio-content {
padding: 20px;
}
.portfolio-title {
font-size: 1.125rem;
}
.portfolio-description {
font-size: 0.8125rem;
}
}
</style>

View File

@@ -0,0 +1,27 @@
---
/**
* SectionHeader - Reusable section header with divider lines
* Pixel-perfect implementation based on Webflow design
*/
interface Props {
title: string
subtitle: string
class?: string
sectionBg?:string
}
const { title, subtitle, class: className, sectionBg:classNameBg } = Astro.props
---
<section class:list={classNameBg}>
<div class:list={['flex max-w-3xl items-center justify-center gap-4 py-12 mx-auto max-lg:flex-wrap', className]}>
<div class="w-full h-px bg-(--color-enchunblue)"></div>
<div class="text-center">
<h2 class="text-(--color-dark-blue) font-['Noto_Sans_TC'] font-bold text-3xl max-md:text-xl whitespace-nowrap">{title}</h2>
<p class="text-gray-600) font-['Quicksand'] font-thin text-2xl tracking-wider whitespace-nowrap">{subtitle}</p>
</div>
<div class="w-full h-px bg-(--color-enchunblue)"></div>
</div>
</section>

View File

@@ -52,27 +52,27 @@ const imageAlt = post.heroImage?.alt || post.title
const displayDate = post.publishedAt || post.createdAt
---
<a href={`/xing-xiao-fang-da-jing/${post.slug}`} class="article-card group">
<div class="article-card-inner">
<a href={`/xing-xiao-fang-da-jing/${post.slug}`} class="block no-underline text-inherit group">
<div class="bg-white rounded-xl overflow-hidden shadow-sm hover:shadow-lg transition-all duration-300 h-full flex flex-col group-hover:-translate-y-1">
<!-- Featured Image -->
<div class="article-image-wrapper">
<div class="aspect-video overflow-hidden bg-[var(--color-gray-100)]">
<img
src={imageUrl}
alt={imageAlt}
loading="lazy"
class="article-image"
class="w-full h-full object-cover transition-transform duration-400 group-hover:scale-105"
width="800"
height="450"
/>
</div>
<!-- Card Content -->
<div class="article-content">
<div class="p-6 md:p-5 flex flex-col flex-1">
<!-- Category Badge -->
{
category && (
<span
class="category-badge"
class="inline-block px-3 py-1.5 rounded-3xl text-[0.8125rem] font-semibold mb-3 self-start"
style={`background-color: ${category.backgroundColor || 'var(--color-enchunblue)'}; color: ${category.textColor || '#fff'}`}
>
{category.title}
@@ -81,14 +81,14 @@ const displayDate = post.publishedAt || post.createdAt
}
<!-- Title -->
<h3 class="article-title">
<h3 class="font-['Noto_Sans_TC'] text-xl md:text-lg font-bold text-[var(--color-dark-blue)] leading-snug mb-3 line-clamp-2">
{post.title}
</h3>
<!-- Excerpt -->
{
post.excerpt && (
<p class="article-excerpt">
<p class="font-['Noto_Sans_TC'] text-[0.9375rem] md:text-sm text-[var(--color-gray-600)] leading-relaxed mb-4 line-clamp-2 flex-1">
{post.excerpt.length > 150 ? post.excerpt.slice(0, 150) + '...' : post.excerpt}
</p>
)
@@ -97,7 +97,7 @@ const displayDate = post.publishedAt || post.createdAt
<!-- Published Date -->
{
displayDate && (
<time class="article-date" datetime={displayDate}>
<time class="font-['Quicksand','Noto_Sans_TC'] text-sm text-[var(--color-gray-500)] mt-auto" datetime={displayDate}>
{formatDate(displayDate)}
</time>
)
@@ -105,111 +105,3 @@ const displayDate = post.publishedAt || post.createdAt
</div>
</div>
</a>
<style>
.article-card {
display: block;
text-decoration: none;
color: inherit;
}
.article-card-inner {
background: #ffffff;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
transition: all 0.3s ease;
height: 100%;
display: flex;
flex-direction: column;
}
.article-card:hover .article-card-inner {
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
transform: translateY(-4px);
}
.article-card:hover .article-image {
transform: scale(1.05);
}
/* Image Section */
.article-image-wrapper {
aspect-ratio: 16 / 9;
overflow: hidden;
background: var(--color-gray-100, #f5f5f5);
}
.article-image {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.4s ease;
}
/* Content Section */
.article-content {
padding: 1.5rem;
display: flex;
flex-direction: column;
flex: 1;
}
.category-badge {
display: inline-block;
padding: 0.375rem 0.875rem;
border-radius: 20px;
font-size: 0.8125rem;
font-weight: 600;
margin-bottom: 0.75rem;
align-self: flex-start;
}
.article-title {
font-family: "Noto Sans TC", sans-serif;
font-size: 1.25rem;
font-weight: 700;
color: var(--color-dark-blue, #1a1a1a);
line-height: 1.4;
margin-bottom: 0.75rem;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.article-excerpt {
font-family: "Noto Sans TC", sans-serif;
font-size: 0.9375rem;
color: var(--color-gray-600, #666);
line-height: 1.6;
margin-bottom: 1rem;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
flex: 1;
}
.article-date {
font-family: "Quicksand", "Noto Sans TC", sans-serif;
font-size: 0.875rem;
color: var(--color-gray-500, #999);
margin-top: auto;
}
/* Responsive Adjustments */
@media (max-width: 767px) {
.article-content {
padding: 1.25rem;
}
.article-title {
font-size: 1.125rem;
}
.article-excerpt {
font-size: 0.875rem;
}
}
</style>

View File

@@ -24,12 +24,11 @@ const { categories, activeCategory, baseUrl = '/news' } = Astro.props
const filteredCategories = categories.filter(c => c.slug !== 'wen-zhang-fen-lei')
---
<nav class="category-filter" aria-label="文章分類篩選">
<div class="filter-container">
<nav class="py-4 mb-8" aria-label="文章分類篩選">
<div class="flex flex-wrap justify-center gap-3 md:gap-2 max-w-[1200px] mx-auto">
<a
href={baseUrl}
class:filter-button-active={!activeCategory}
class="filter-button"
class:list={["inline-flex items-center justify-center px-5 py-2.5 md:px-4 md:py-2 border-2 border-[var(--color-gray-300)] rounded-3xl bg-white text-[var(--color-gray-700)] font-['Noto_Sans_TC'] text-[0.9375rem] md:text-sm font-medium no-underline whitespace-nowrap transition-all duration-[250ms] hover:border-[var(--color-enchunblue)] hover:text-[var(--color-enchunblue)] hover:bg-[rgba(35,96,140,0.05)]", { "bg-[var(--color-enchunblue)] border-[var(--color-enchunblue)] text-white hover:bg-[var(--color-enchunblue-hover)] hover:border-[var(--color-enchunblue-hover)]": !activeCategory }]}
>
全部文章
</a>
@@ -38,8 +37,7 @@ const filteredCategories = categories.filter(c => c.slug !== 'wen-zhang-fen-lei'
filteredCategories.map((category) => (
<a
href={`/wen-zhang-fen-lei/${category.slug}`}
class:filter-button-active={activeCategory === category.slug}
class="filter-button"
class:list={["inline-flex items-center justify-center px-5 py-2.5 md:px-4 md:py-2 border-2 border-[var(--color-gray-300)] rounded-3xl bg-white text-[var(--color-gray-700)] font-['Noto_Sans_TC'] text-[0.9375rem] md:text-sm font-medium no-underline whitespace-nowrap transition-all duration-[250ms] hover:border-[var(--color-enchunblue)] hover:text-[var(--color-enchunblue)] hover:bg-[rgba(35,96,140,0.05)]", { "bg-[var(--color-enchunblue)] border-[var(--color-enchunblue)] text-white hover:bg-[var(--color-enchunblue-hover)] hover:border-[var(--color-enchunblue-hover)]": activeCategory === category.slug }]}
aria-label={`篩選 ${category.title} 類別文章`}
>
{category.title}
@@ -48,66 +46,3 @@ const filteredCategories = categories.filter(c => c.slug !== 'wen-zhang-fen-lei'
}
</div>
</nav>
<style>
.category-filter {
padding: 1rem 0;
margin-bottom: 2rem;
}
.filter-container {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.75rem;
max-width: 1200px;
margin: 0 auto;
}
.filter-button {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.625rem 1.25rem;
border: 2px solid var(--color-gray-300, #e0e0e0);
border-radius: 24px;
background: #ffffff;
color: var(--color-gray-700, #555);
font-family: "Noto Sans TC", sans-serif;
font-size: 0.9375rem;
font-weight: 500;
text-decoration: none;
transition: all 0.25s ease;
white-space: nowrap;
}
.filter-button:hover {
border-color: var(--color-enchunblue, #23608c);
color: var(--color-enchunblue, #23608c);
background: rgba(35, 96, 140, 0.05);
}
.filter-button-active {
background: var(--color-enchunblue, #23608c);
border-color: var(--color-enchunblue, #23608c);
color: #ffffff !important;
}
.filter-button-active:hover {
background: var(--color-enchunblue-hover, #1a4d6e);
border-color: var(--color-enchunblue-hover, #1a4d6e);
color: #ffffff;
}
/* Responsive Adjustments */
@media (max-width: 767px) {
.filter-container {
gap: 0.5rem;
}
.filter-button {
padding: 0.5rem 1rem;
font-size: 0.875rem;
}
}
</style>

View File

@@ -40,42 +40,43 @@ const { posts, title = '相關文章' } = Astro.props
{
posts && posts.length > 0 && (
<section class="related-posts-section" aria-labelledby="related-posts-heading">
<div class="container">
<h2 id="related-posts-heading" class="related-posts-title">
<section class="py-16 md:py-12 px-6 bg-[var(--color-gray-50)]" aria-labelledby="related-posts-heading">
<div class="max-w-[1200px] mx-auto">
<h2 id="related-posts-heading" class="font-['Noto_Sans_TC'] text-[1.75rem] md:text-2xl font-bold text-[var(--color-enchunblue)] text-center mb-10 md:mb-8">
{title}
</h2>
<div class="related-posts-grid">
<div class="grid grid-cols-1 md:grid-cols-[repeat(auto-fit,minmax(280px,1fr))] gap-6 md:gap-4">
{
posts.slice(0, 4).map((post) => (
<a href={`/blog/${post.slug}`} class="related-post-card">
<a href={`/blog/${post.slug}`} class="flex flex-col bg-white rounded-xl overflow-hidden shadow-sm hover:shadow-lg transition-all duration-300 no-underline text-inherit hover:-translate-y-1 group">
<!-- Post Image -->
<div class="related-post-image">
<div class="aspect-video overflow-hidden bg-[var(--color-gray-200)]">
<img
src={post.heroImage?.url || '/placeholder-blog.jpg'}
alt={post.heroImage?.alt || post.title}
loading="lazy"
class="w-full h-full object-cover transition-transform duration-400 group-hover:scale-105"
width="400"
height="225"
/>
</div>
<!-- Post Content -->
<div class="related-post-content">
<div class="p-5 flex flex-col gap-3">
<!-- Category Badge -->
{
post.categories && post.categories[0] && (
<span
class="related-category-badge"
class="inline-block px-3 py-1 rounded-2xl text-xs font-semibold self-start"
style={`background-color: ${post.categories[0].backgroundColor || 'var(--color-enchunblue)'}; color: ${post.categories[0].textColor || '#fff'}`}
>
{post.categories[0].title}
</span>
)}
)}
<!-- Post Title -->
<h3 class="related-post-title">
<h3 class="font-['Noto_Sans_TC'] text-[1.0625rem] font-semibold text-[var(--color-dark-blue)] leading-snug line-clamp-2">
{post.title}
</h3>
</div>
@@ -87,109 +88,3 @@ const { posts, title = '相關文章' } = Astro.props
</section>
)
}
<style>
.related-posts-section {
padding: 4rem 1.5rem;
background: var(--color-gray-50, #f9fafb);
}
.container {
max-width: 1200px;
margin: 0 auto;
}
.related-posts-title {
font-family: "Noto Sans TC", sans-serif;
font-size: 1.75rem;
font-weight: 700;
color: var(--color-enchunblue, #23608c);
text-align: center;
margin-bottom: 2.5rem;
}
.related-posts-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1.5rem;
}
.related-post-card {
display: flex;
flex-direction: column;
background: #ffffff;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
transition: all 0.3s ease;
text-decoration: none;
color: inherit;
}
.related-post-card:hover {
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.12);
transform: translateY(-4px);
}
.related-post-card:hover .related-post-image img {
transform: scale(1.05);
}
.related-post-image {
aspect-ratio: 16 / 9;
overflow: hidden;
background: var(--color-gray-200, #e5e5e5);
}
.related-post-image img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.4s ease;
}
.related-post-content {
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.related-category-badge {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 16px;
font-size: 0.75rem;
font-weight: 600;
align-self: flex-start;
}
.related-post-title {
font-family: "Noto Sans TC", sans-serif;
font-size: 1.0625rem;
font-weight: 600;
color: var(--color-dark-blue, #1a1a1a);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Responsive Adjustments */
@media (max-width: 767px) {
.related-posts-section {
padding: 3rem 1rem;
}
.related-posts-title {
font-size: 1.5rem;
margin-bottom: 2rem;
}
.related-posts-grid {
grid-template-columns: 1fr;
gap: 1rem;
}
}
</style>

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'],
})
}

View File

@@ -2,10 +2,22 @@
/**
* Marketing Solutions Page - 行銷方案頁面
* Pixel-perfect implementation based on Webflow design
* Data fetched from Payload CMS Pages Collection API
*/
import Layout from '../layouts/Layout.astro'
import SolutionsHero from '../sections/SolutionsHero.astro'
import ServicesList from '../sections/ServicesList.astro'
import { getMarketingSolutionsPage } from '../lib/api/marketing-solution'
import SectionHeader from '../components/SectionHeader.astro'
// Fetch page data from CMS
const pageData = await getMarketingSolutionsPage()
// Use CMS data or fallback to defaults
const heroTitle = pageData?.heroTitle || '行銷解決方案'
const heroSubtitle = pageData?.heroSubtitle || '提供全方位的數位行銷服務,協助您的品牌在數位時代脫穎而出'
const heroImage = pageData?.heroImage
const services = pageData?.services || []
// Metadata for SEO
const title = '行銷解決方案 | 恩群數位行銷'
@@ -15,10 +27,16 @@ const description = '恩群數位行銷提供全方位的數位行銷服務,
<Layout title={title} description={description}>
<!-- Hero Section -->
<SolutionsHero
title="行銷解決方案"
subtitle="提供全方位的數位行銷服務,協助您的品牌在數位時代脫穎而出"
title={heroTitle}
subtitle={heroSubtitle}
backgroundImage={heroImage}
/>
<!-- Section Header -->
<SectionHeader
title="行銷方案"
subtitle="Marketing solutions"
sectionBg="bg-white"
/>
<!-- Services List -->
<ServicesList />
<ServicesList services={services} />
</Layout>

View File

@@ -1,345 +0,0 @@
---
/**
* Portfolio Detail Page - 作品詳情頁
* Displays full project information with live website link
* Pixel-perfect implementation based on Webflow design
*/
import Layout from '../../layouts/Layout.astro'
import { fetchPortfolioBySlug, getWebsiteTypeLabel } from '../../lib/api/portfolio'
// Get slug from params
const { slug } = Astro.params
// Validate slug
if (!slug) {
return Astro.redirect('/404')
}
// Fetch portfolio item
const portfolio = await fetchPortfolioBySlug(slug)
// Handle 404 for non-existent portfolio
if (!portfolio) {
return Astro.redirect('/404')
}
// Get website type label
const typeLabel = getWebsiteTypeLabel(portfolio.websiteType)
// Get tags
const tags = portfolio.tags?.map(t => t.tag) || []
// SEO metadata
const title = `${portfolio.title} | 恩群數位案例分享`
const description = portfolio.description || `瀏覽 ${portfolio.title} 專案詳情`
---
<Layout title={title} description={description}>
<article class="portfolio-detail">
<div class="container">
<!-- Back Link -->
<a href="/portfolio" class="back-link">
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/>
</svg>
返回作品列表
</a>
<!-- Project Header -->
<header class="project-header">
<div class="project-meta">
<span class="project-type-badge">{typeLabel}</span>
</div>
<h1 class="project-title">{portfolio.title}</h1>
{
portfolio.description && (
<p class="project-description">{portfolio.description}</p>
)
}
<!-- Tags -->
{
tags.length > 0 && (
<div class="project-tags">
{tags.map((tag) => (
<span class="tag">{tag}</span>
))}
</div>
)
}
</header>
<!-- Hero Image -->
{
portfolio.image?.url && (
<div class="project-hero-image">
<img
src={portfolio.image.url}
alt={portfolio.image.alt || portfolio.title}
loading="eager"
width="1200"
height="675"
/>
</div>
)
}
<!-- Project Content -->
<div class="project-content">
<div class="content-section">
<h2>專案介紹</h2>
<p>
此專案展示了我們在{typeLabel}領域的專業能力。
我們致力於為客戶打造符合品牌定位、使用體驗優良的數位產品。
</p>
</div>
<div class="content-section">
<h2>專案連結</h2>
{
portfolio.url ? (
<a
href={portfolio.url}
target="_blank"
rel="noopener noreferrer"
class="btn-primary"
>
前往網站
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
<path d="M19 19H5V5h7V3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2v-7h-2v7zM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z"/>
</svg>
</a>
) : (
<p class="text-muted">此專案暫無公開連結</p>
)
}
</div>
</div>
<!-- CTA Section -->
<div class="detail-cta">
<h3>喜歡這個作品嗎?</h3>
<p>讓我們一起為您的品牌打造獨特的數位體驗</p>
<a href="/contact-us" class="cta-button">
聯絡我們
</a>
</div>
</div>
</article>
</Layout>
<style>
/* Portfolio Detail */
.portfolio-detail {
padding: 60px 20px;
background-color: #ffffff;
}
.container {
max-width: 1000px;
margin: 0 auto;
}
/* Back Link */
.back-link {
display: inline-flex;
align-items: center;
gap: 8px;
margin-bottom: 2rem;
color: var(--color-enchunblue, #23608c);
text-decoration: none;
font-weight: 500;
transition: color 0.25s ease;
}
.back-link:hover {
color: var(--color-enchunblue-hover, #1a4d6e);
}
/* Project Header */
.project-header {
text-align: center;
margin-bottom: 3rem;
}
.project-meta {
margin-bottom: 1rem;
}
.project-type-badge {
display: inline-block;
background: var(--color-enchunblue, #23608c);
color: white;
padding: 0.5rem 1rem;
border-radius: 20px;
font-size: 0.875rem;
font-weight: 500;
}
.project-title {
font-family: "Noto Sans TC", sans-serif;
font-size: 2.5rem;
font-weight: 700;
color: var(--color-dark-blue, #1a1a1a);
margin-bottom: 1rem;
line-height: 1.3;
}
.project-description {
font-family: "Noto Sans TC", sans-serif;
font-size: 1.125rem;
color: var(--color-gray-600, #666);
max-width: 700px;
margin: 0 auto 1.5rem;
line-height: 1.7;
}
.project-tags {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.75rem;
}
.tag {
background: var(--color-gray-100, #f5f5f5);
color: var(--color-gray-700, #4a5568);
padding: 0.5rem 1rem;
border-radius: 20px;
font-size: 0.875rem;
font-weight: 500;
}
/* Hero Image */
.project-hero-image {
margin-bottom: 3rem;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
}
.project-hero-image img {
width: 100%;
height: auto;
display: block;
}
/* Project Content */
.project-content {
display: grid;
gap: 2.5rem;
margin-bottom: 3rem;
}
.content-section h2 {
font-family: "Noto Sans TC", sans-serif;
font-size: 1.5rem;
font-weight: 600;
color: var(--color-dark-blue, #1a1a1a);
margin-bottom: 1rem;
}
.content-section p {
color: var(--color-gray-600, #666);
line-height: 1.8;
}
.btn-primary {
display: inline-flex;
align-items: center;
gap: 8px;
background: var(--color-enchunblue, #23608c);
color: white;
padding: 0.875rem 1.75rem;
border-radius: 8px;
text-decoration: none;
font-weight: 500;
transition: all 0.3s ease;
}
.btn-primary:hover {
background: var(--color-enchunblue-hover, #1a4d6e);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(35, 96, 140, 0.3);
}
.text-muted {
color: var(--color-gray-500, #999);
}
/* Detail CTA */
.detail-cta {
text-align: center;
padding: 3rem 2rem;
background: var(--color-gray-50, #f9fafb);
border-radius: 16px;
}
.detail-cta h3 {
font-family: "Noto Sans TC", sans-serif;
font-size: 1.5rem;
font-weight: 600;
color: var(--color-enchunblue, #23608c);
margin-bottom: 0.75rem;
}
.detail-cta p {
color: var(--color-gray-600, #666);
margin-bottom: 1.5rem;
}
.cta-button {
display: inline-block;
background: var(--color-enchunblue, #23608c);
color: white;
padding: 14px 28px;
border-radius: 8px;
font-weight: 600;
text-decoration: none;
transition: all 0.3s ease;
}
.cta-button:hover {
background: var(--color-enchunblue-hover, #1a4d6e);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(35, 96, 140, 0.3);
}
/* Responsive Adjustments */
@media (max-width: 991px) {
.portfolio-detail {
padding: 50px 20px;
}
.project-title {
font-size: 2rem;
}
}
@media (max-width: 767px) {
.portfolio-detail {
padding: 40px 16px;
}
.project-title {
font-size: 1.75rem;
}
.project-description {
font-size: 1rem;
}
.content-section h2 {
font-size: 1.25rem;
}
.detail-cta {
padding: 2rem 1.5rem;
}
.detail-cta h3 {
font-size: 1.25rem;
}
}
</style>

View File

@@ -1,244 +0,0 @@
---
/**
* Portfolio Listing Page - 案例分享列表頁
* Displays all portfolio items in a 2-column grid
* Pixel-perfect implementation based on Webflow design
*/
import Layout from '../../layouts/Layout.astro'
import PortfolioCard from '../../components/PortfolioCard.astro'
import { fetchPortfolios } from '../../lib/api/portfolio'
// Metadata for SEO
const title = '案例分享 | 恩群數位行銷'
const description = '瀏覽恩群數位的成功案例,包括企業官網、電商網站、品牌網站等設計作品。'
// Fetch portfolios from Payload CMS
const portfoliosData = await fetchPortfolios(1, 100)
const portfolios = portfoliosData.docs
---
<Layout title={title} description={description}>
<!-- Portfolio Header -->
<section class="portfolio-header" aria-labelledby="portfolio-heading">
<div class="container">
<div class="section_header_w_line">
<div class="divider_line"></div>
<div class="header_subtitle">
<h2 id="portfolio-heading" class="header_subtitle_head">案例分享</h2>
<p class="header_subtitle_paragraph">Our Works</p>
</div>
<div class="divider_line"></div>
</div>
</div>
</section>
<!-- Portfolio Grid -->
<section class="portfolio-grid-section" aria-label="作品列表">
<div class="container">
{
portfolios.length > 0 ? (
<ul class="portfolio-grid">
{
portfolios.map((item) => (
<PortfolioCard
item={{
slug: item.slug,
title: item.title,
description: item.description || '',
image: item.image?.url,
tags: item.tags?.map(t => t.tag) || [],
externalUrl: item.url || undefined,
}}
/>
))
}
</ul>
) : (
<div class="empty-state">
<p class="empty-text">暫無作品資料</p>
</div>
)
}
</div>
</section>
<!-- CTA Section -->
<section class="portfolio-cta" aria-labelledby="cta-heading">
<div class="container">
<h2 id="cta-heading" class="cta-title">
有興趣與我們合作嗎?
</h2>
<p class="cta-description">
讓我們一起為您的品牌打造獨特的數位體驗
</p>
<a href="/contact-us" class="cta-button">
聯絡我們
</a>
</div>
</section>
</Layout>
<style>
/* Portfolio Header */
.portfolio-header {
padding: 60px 20px;
background-color: #ffffff;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
.section_header_w_line {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
}
.header_subtitle {
text-align: center;
}
.header_subtitle_head {
color: var(--color-enchunblue, #23608c);
font-family: "Noto Sans TC", sans-serif;
font-weight: 700;
font-size: 2rem;
margin-bottom: 8px;
}
.header_subtitle_paragraph {
color: var(--color-gray-600, #666);
font-family: "Quicksand", "Noto Sans TC", sans-serif;
font-weight: 400;
font-size: 1rem;
}
.divider_line {
width: 40px;
height: 2px;
background-color: var(--color-enchunblue, #23608c);
}
/* Grid Section */
.portfolio-grid-section {
padding: 0 20px 60px;
background-color: #f8f9fa;
}
.portfolio-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20px;
max-width: 1200px;
margin: 0 auto;
padding: 0;
list-style: none;
}
/* Empty State */
.empty-state {
text-align: center;
padding: 80px 20px;
}
.empty-text {
font-size: 1.125rem;
color: var(--color-gray-500, #999);
}
/* CTA Section */
.portfolio-cta {
text-align: center;
padding: 80px 20px;
background-color: #ffffff;
}
.cta-title {
font-family: "Noto Sans TC", sans-serif;
font-size: 1.75rem;
font-weight: 600;
color: var(--color-enchunblue, #23608c);
margin-bottom: 16px;
}
.cta-description {
font-size: 1rem;
color: var(--color-gray-600, #666);
margin-bottom: 32px;
}
.cta-button {
display: inline-block;
background-color: var(--color-enchunblue, #23608c);
color: white;
padding: 16px 32px;
border-radius: 8px;
font-weight: 600;
text-decoration: none;
transition: all 0.3s ease;
}
.cta-button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(35, 96, 140, 0.3);
background-color: var(--color-enchunblue-hover, #1a4d6e);
}
/* Responsive Adjustments */
@media (max-width: 991px) {
.portfolio-header {
padding: 50px 20px;
}
.header_subtitle_head {
font-size: 1.75rem;
}
.portfolio-grid {
gap: 16px;
}
}
@media (max-width: 767px) {
.portfolio-header {
padding: 40px 16px;
}
.header_subtitle_head {
font-size: 1.5rem;
}
.header_subtitle_paragraph {
font-size: 0.9375rem;
}
.portfolio-grid-section {
padding: 0 16px 40px;
}
.portfolio-grid {
grid-template-columns: 1fr;
gap: 16px;
}
.portfolio-cta {
padding: 60px 16px;
}
.cta-title {
font-size: 1.5rem;
}
.cta-description {
font-size: 0.9375rem;
}
.cta-button {
padding: 14px 24px;
font-size: 0.9375rem;
}
}
</style>

View File

@@ -1,442 +0,0 @@
---
/**
* Portfolio Detail Page - 案例詳情頁
* Pixel-perfect implementation based on Webflow design
*/
import Layout from '../../layouts/Layout.astro'
// Get portfolio items
const portfolioItems: Record<string, {
title: string
client?: string
date?: string
category?: string
description: string
images?: string[]
externalUrl?: string
}> = {
'corporate-website-1': {
title: '企業官網設計案例',
client: '知名製造業公司',
date: '2024年1月',
category: '企業官網',
description: `
<p>這是一個為知名製造業公司打造的現代化企業官網專案。客戶希望建立一個能夠有效展示產品系列、公司形象以及最新消息的專業網站。</p>
<h3>專案背景</h3>
<p>客戶原有的網站設計過時,難以在手機設備上正常瀏覽,且內容管理不夠靈活。他們需要一個響應式設計的現代化網站,能夠自動適應各種設備,並方便內容團隊更新。</p>
<h3>解決方案</h3>
<p>我們採用了最新的網頁技術,打造了一個完全響應式的企業官網。網站架構清晰,導航直觀,產品展示頁面設計精美,同時整合了新聞發佈功能,讓客戶能夠快速分享公司動態。</p>
<h3>專案成果</h3>
<p>網站上線後,客戶反映使用者停留時間增加了 40%,詢問量也顯著提升。響應式設計讓行動用戶也能獲得良好的瀏覽體驗,整體滿意度非常高。</p>
`,
images: ['/placeholder-portfolio-1.jpg'],
},
'ecommerce-site-1': {
title: '電商平台建置',
client: '精品品牌',
date: '2024年2月',
category: '電商網站',
description: `
<p>這是一個為精品品牌打造的 B2C 電商網站專案。客戶需要一個功能完整、設計精緻的線上購物平台。</p>
<h3>專案背景</h3>
<p>客戶希望拓展線上銷售渠道,建立一個能夠完整呈現品牌調性的電商網站。需求包含會員系統、購物車、金流整合、庫存管理等完整功能。</p>
<h3>解決方案</h3>
<p>我們設計了一個簡潔優雅的電商平台,整合了完整的購物流程。從商品瀏覽、加入購物車、結帳到訂單追蹤,整個流程流暢無縫。同時整合了主流金流與物流服務。</p>
<h3>專案成果</h3>
<p>網站上線後首月即達到預期銷售目標,客戶反應購物體驗流暢,設計風格也獲得用戶高度評價。</p>
`,
images: ['/placeholder-portfolio-2.jpg'],
},
'brand-website-1': {
title: '品牌形象網站',
client: '新創品牌',
date: '2024年3月',
category: '品牌網站',
description: `
<p>這是一個為新創品牌打造的以視覺故事為核心的品牌網站專案。</p>
<h3>專案背景</h3>
<p>客戶是一個新創品牌,需要一個能夠有效傳達品牌故事、價值理念的網站。他們希望網站不只是資訊展示,更能成為品牌與用戶情感連結的橋樑。</p>
<h3>解決方案</h3>
<p>我們以視覺故事為設計核心,運用大型視覺元素、動畫效果和互動設計,打造了一個充滿故事性的品牌網站。每一個區塊都精心設計,引導用戶深入了解品牌。</p>
<h3>專案成果</h3>
<p>網站成功建立了強烈的品牌印象,訪客平均停留時間超過 3 分鐘,品牌認知度顯著提升。</p>
`,
images: ['/placeholder-portfolio-3.jpg'],
},
'landing-page-1': {
title: '活動行銷頁面',
client: '活動主辦單位',
date: '2024年4月',
category: '活動頁面',
description: `
<p>這是一個為大型活動設計的高轉換率行銷頁面專案。</p>
<h3>專案背景</h3>
<p>客戶需要一個能夠有效吸引報名、轉換率高的活動頁面。頁面需要能夠清楚傳達活動資訊、吸引目光,並引導用戶完成報名流程。</p>
<h3>解決方案</h3>
<p>我們設計了一個視覺衝擊力強的活動頁面CTA 按鈕配置經過精心規劃,報名流程簡化到最少步驟。同時加入倒數計時、名額顯示等促進轉換的元素。</p>
<h3>專案成果</h3>
<p>頁面上線後,報名轉換率達到 15%,遠超過預期目標,活動順利達到滿檔狀態。</p>
`,
images: ['/placeholder-portfolio-4.jpg'],
},
}
export async function getStaticPaths() {
const slugs = Object.keys(portfolioItems)
return slugs.map((slug) => ({
params: { slug },
props: { slug }
}))
}
const { slug } = Astro.props
const project = portfolioItems[slug] || {
title: '作品詳情',
description: '<p>暫無內容</p>',
images: ['/placeholder-portfolio.jpg'],
}
// Metadata for SEO
const title = `${project.title} | 恩群數位案例`
const description = project.description.replace(/<[^>]*>/g, '').slice(0, 160)
---
<Layout title={title} description={description}>
<!-- Breadcrumb -->
<nav class="breadcrumb" aria-label="麵包屑導航">
<div class="container">
<ol class="breadcrumb-list">
<li><a href="/">首頁</a></li>
<li><a href="/website-portfolio">案例分享</a></li>
<li aria-current="page">{project.title}</li>
</ol>
</div>
</nav>
<!-- Portfolio Detail -->
<article class="portfolio-detail">
<div class="container">
<!-- Header -->
<header class="portfolio-detail-header">
<h1 class="portfolio-detail-title">{project.title}</h1>
<div class="portfolio-detail-meta">
{
project.client && (
<span class="meta-item">
<strong>客戶:</strong>{project.client}
</span>
)
}
{
project.date && (
<span class="meta-item">
<strong>日期:</strong>{project.date}
</span>
)
}
{
project.category && (
<span class="meta-item">
<strong>類別:</strong>{project.category}
</span>
)
}
</div>
</header>
<!-- Featured Image -->
{
project.images && project.images.length > 0 && (
<div class="portfolio-detail-image-wrapper">
<img
src={project.images[0]}
alt={project.title}
class="portfolio-detail-image"
loading="eager"
width="1000"
height="562"
/>
</div>
)
}
<!-- Description -->
<div class="portfolio-detail-content">
<div class="description-wrapper" set:html={project.description} />
</div>
<!-- CTA -->
<div class="portfolio-detail-cta">
<h3 class="cta-heading">有專案想要討論嗎?</h3>
<p class="cta-subheading">我們很樂意聽聽您的需求</p>
<a href="/contact-us" class="cta-button">
聯絡我們
</a>
</div>
<!-- Related Projects -->
<div class="related-projects">
<h3 class="related-title">更多案例</h3>
<a href="/website-portfolio" class="view-all-link">
查看全部案例 →
</a>
</div>
</div>
</article>
</Layout>
<style>
/* Portfolio Detail Styles - Pixel-perfect from Webflow */
/* Breadcrumb */
.breadcrumb {
padding: 16px 20px;
background-color: #f8f9fa;
border-bottom: 1px solid #e5e7eb;
}
.breadcrumb-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
max-width: 1200px;
margin: 0 auto;
padding: 0;
list-style: none;
font-size: 0.875rem;
color: var(--color-gray-600);
}
.breadcrumb-list a {
color: var(--color-enchunblue);
text-decoration: none;
}
.breadcrumb-list a:hover {
text-decoration: underline;
}
.breadcrumb-list li:not(:last-child)::after {
content: '/';
margin-left: 8px;
color: var(--color-gray-400);
}
/* Detail Section */
.portfolio-detail {
padding: 60px 20px;
background-color: #ffffff;
}
.container {
max-width: 1000px;
margin: 0 auto;
}
/* Header */
.portfolio-detail-header {
margin-bottom: 40px;
text-align: center;
}
.portfolio-detail-title {
font-family: "Noto Sans TC", sans-serif;
font-size: 2.5rem;
font-weight: 700;
color: var(--color-tarawera, #2d3748);
margin-bottom: 24px;
line-height: 1.2;
}
.portfolio-detail-meta {
display: flex;
justify-content: center;
gap: 24px;
font-size: 0.875rem;
color: var(--color-gray-600);
flex-wrap: wrap;
}
.meta-item strong {
color: var(--color-text-primary);
}
/* Featured Image */
.portfolio-detail-image-wrapper {
margin-bottom: 40px;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
}
.portfolio-detail-image {
width: 100%;
height: auto;
display: block;
}
/* Content */
.portfolio-detail-content {
margin-bottom: 60px;
}
.description-wrapper {
font-family: "Noto Sans TC", sans-serif;
font-size: 1.125rem;
line-height: 1.8;
color: var(--color-text-primary);
}
.description-wrapper :global(p) {
margin-bottom: 20px;
}
.description-wrapper :global(h3) {
font-size: 1.5rem;
font-weight: 600;
color: var(--color-enchunblue);
margin-top: 32px;
margin-bottom: 16px;
}
/* CTA Section */
.portfolio-detail-cta {
text-align: center;
padding: 60px;
background: linear-gradient(135deg, rgba(35, 96, 140, 0.05) 0%, rgba(35, 96, 140, 0.02) 100%);
border-radius: 12px;
margin-bottom: 60px;
}
.cta-heading {
font-family: "Noto Sans TC", sans-serif;
font-size: 1.75rem;
font-weight: 600;
color: var(--color-enchunblue);
margin-bottom: 8px;
}
.cta-subheading {
font-size: 1rem;
color: var(--color-gray-600);
margin-bottom: 24px;
}
.cta-button {
display: inline-block;
background-color: var(--color-enchunblue);
color: white;
padding: 16px 32px;
border-radius: var(--radius, 8px);
font-weight: 600;
text-decoration: none;
transition: all var(--transition-base, 0.3s ease);
}
.cta-button:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-md, 0 4px 6px rgba(0, 0, 0, 0.1));
background-color: var(--color-enchunblue-hover, #1a4d6e);
}
/* Related Projects */
.related-projects {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 32px;
border-top: 1px solid var(--color-border, #e5e7eb);
}
.related-title {
font-family: "Noto Sans TC", sans-serif;
font-size: 1.25rem;
font-weight: 600;
color: var(--color-text-primary);
}
.view-all-link {
color: var(--color-enchunblue);
text-decoration: none;
font-weight: 500;
transition: color var(--transition-fast, 0.2s ease);
}
.view-all-link:hover {
color: var(--color-enchunblue-hover, #1a4d6e);
}
/* Responsive Adjustments */
@media (max-width: 991px) {
.portfolio-detail {
padding: 50px 16px;
}
.portfolio-detail-title {
font-size: 2rem;
}
.portfolio-detail-cta {
padding: 40px;
}
.cta-heading {
font-size: 1.5rem;
}
}
@media (max-width: 767px) {
.breadcrumb {
padding: 12px 16px;
}
.breadcrumb-list {
font-size: 0.8125rem;
}
.portfolio-detail {
padding: 40px 16px;
}
.portfolio-detail-title {
font-size: 1.75rem;
}
.portfolio-detail-meta {
flex-direction: column;
gap: 8px;
}
.portfolio-detail-cta {
padding: 32px 20px;
}
.related-projects {
flex-direction: column;
gap: 16px;
align-items: flex-start;
}
.description-wrapper {
font-size: 1rem;
}
.description-wrapper :global(h3) {
font-size: 1.25rem;
}
}
</style>

View File

@@ -14,71 +14,11 @@ const {
} = Astro.props
---
<header class="hero-overlay-about">
<div class="w-container">
<div class="div-block">
<h1 class="hero_title_head-about">{title}</h1>
<p class="hero_sub_paragraph-about">{subtitle}</p>
<header class="bg-white py-[120px] px-5 lg:py-20 md:py-15 text-center">
<div class="max-w-[1200px] mx-auto">
<div class="flex flex-col items-center">
<h1 class="text-[var(--color-enchunblue)] font-['Noto_Sans_TC','Quicksand'] font-bold text-3xl lg:text-[2.5rem] md:text-2xl leading-tight mb-4">{title}</h1>
<p class="text-[var(--color-enchunblue-dark)] font-['Quicksand','Noto_Sans_TC'] font-normal text-xl lg:text-[1.125rem] md:text-base leading-relaxed">{subtitle}</p>
</div>
</div>
</header>
<style>
/* About Hero Styles - Pixel-perfect from Webflow */
.hero-overlay-about {
background-color: #ffffff;
padding: 120px 20px;
text-align: center;
}
.w-container {
max-width: 1200px;
margin: 0 auto;
}
.hero_title_head-about {
color: var(--color-enchunblue);
font-family: "Noto Sans TC", "Quicksand", sans-serif;
font-weight: 700;
font-size: 3rem;
line-height: 1.2;
margin-bottom: 16px;
}
.hero_sub_paragraph-about {
color: var(--color-enchunblue-dark);
font-family: "Quicksand", "Noto Sans TC", sans-serif;
font-weight: 400;
font-size: 1.25rem;
line-height: 1.4;
}
/* Responsive Adjustments */
@media (max-width: 991px) {
.hero-overlay-about {
padding: 80px 20px;
}
.hero_title_head-about {
font-size: 2.5rem;
}
.hero_sub_paragraph-about {
font-size: 1.125rem;
}
}
@media (max-width: 767px) {
.hero-overlay-about {
padding: 60px 20px;
}
.hero_title_head-about {
font-size: 2rem;
}
.hero_sub_paragraph-about {
font-size: 1rem;
}
}
</style>

View File

@@ -26,19 +26,17 @@ const defaultSlides: SlideImage[] = [
]
const slides = Astro.props.slides || defaultSlides
import SectionHeader from '../components/SectionHeader.astro'
---
<section class="section-video" aria-label="工作環境照片">
<div class="container spacer8 w-container">
<!-- Section Header -->
<div class="section_header_w_line">
<div class="divider_line"></div>
<div class="header_subtitle">
<h2 class="header_subtitle_head">在恩群工作的環境</h2>
<p class="header_subtitle_paragraph">Working Enviroment</p>
</div>
<div class="divider_line"></div>
</div>
<SectionHeader
title="在恩群工作的環境"
subtitle="Working Enviroment"
/>
<!-- Environment Slider -->
<div
@@ -111,39 +109,7 @@ const slides = Astro.props.slides || defaultSlides
padding-top: 2rem;
}
/* Section Header */
.section_header_w_line {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
margin-bottom: 48px;
}
.header_subtitle {
text-align: center;
}
.header_subtitle_head {
color: var(--color-enchunblue);
font-family: "Noto Sans TC", sans-serif;
font-weight: 700;
font-size: 2rem;
margin-bottom: 8px;
}
.header_subtitle_paragraph {
color: var(--color-gray-600);
font-family: "Quicksand", sans-serif;
font-weight: 400;
font-size: 1rem;
}
.divider_line {
width: 40px;
height: 2px;
background-color: var(--color-enchunblue);
}
/* Environment Slider */
.environment-slider {
@@ -246,10 +212,6 @@ const slides = Astro.props.slides || defaultSlides
.environment-slider {
max-width: 550px;
}
.section_header_w_line {
flex-wrap: wrap;
}
}
@media (max-width: 767px) {
@@ -273,10 +235,6 @@ const slides = Astro.props.slides || defaultSlides
.slider-arrow-right {
right: 8px;
}
.header_subtitle_head {
font-size: 1.5rem;
}
}
</style>

View File

@@ -4,14 +4,23 @@
* Pixel-perfect implementation based on Webflow design
*/
import { sanitizeSvg } from '../lib/sanitize'
import { PLACEHOLDER_IMAGE } from '../lib/api/marketing-solution'
interface ServicesListItem {
id: string
title: string
description: string
category: string
iconType?: 'preset' | 'svg' | 'upload'
icon?: string
iconSvg?: string
iconImage?: {
url?: string
alt?: string
}
isHot?: boolean
image?: string
image?: string | { url?: string; alt?: string }
link?: string
}
@@ -29,345 +38,197 @@ const defaultServices: ServicesListItem[] = [
id: 'social-media',
title: '社群經營代操',
category: '海洋專案',
iconType: 'preset',
icon: 'facebook',
isHot: true,
image: '/placeholder-service-1.jpg',
isHot: false,
image: 'https://enchun-cms.anlstudio.cc/api/media/file/61f24aa108528b3ab4942cf1_%E7%A4%BE%E7%BE%A4%E7%B6%93%E7%87%9F%E4%BB%A3%E6%93%8D.svg',
description: '專業社群媒體經營團隊,從內容策劃、社群經營到數據分析,提供一站式社群代操服務。我們擅長經營 Facebook、Instagram 等主流平台,幫助品牌建立強大的社群影響力。',
},
{
id: 'google-business',
title: 'Google 商家關鍵字',
category: 'Google',
iconType: 'preset',
icon: 'google',
isHot: true,
image: '/placeholder-service-2.jpg',
isHot: false,
image: 'https://enchun-cms.anlstudio.cc/api/media/file/61f24aa108528b5a4c942cec_%E5%95%86%E5%AE%B6%E9%97%9C%E9%8D%B5%E5%AD%97.svg',
description: '優化 Google 商家列表,提升在地搜尋排名。透過關鍵字策略、評論管理和商家資訊優化,讓您的商家在 Google 地圖和搜尋結果中脫穎而出。',
},
{
id: 'google-ads',
title: 'Google Ads 關鍵字',
category: 'Google',
iconType: 'preset',
icon: 'ads',
isHot: false,
image: '/placeholder-service-3.jpg',
image: 'https://enchun-cms.anlstudio.cc/api/media/file/61f24aa108528b3898942cde_Google%20ADS.svg',
description: '專業的 Google Ads 投放服務,從關鍵字研究、廣告文案撰寫到出價策略優化,精準觸達目標受眾,最大化廣告投資報酬率。',
},
{
id: 'news-media',
title: '網路新聞媒體',
category: '媒體行銷',
iconType: 'preset',
icon: 'news',
isHot: false,
image: '/placeholder-service-4.jpg',
image: 'https://enchun-cms.anlstudio.cc/api/media/file/61f24aa108528b0b57942cee_%E7%B6%B2%E8%B7%AF%E6%96%B0%E8%81%9E%E5%AA%92%E9%AB%94.svg',
description: '與各大新聞媒體合作,提供新聞發佈、媒體採訪、品牌曝光等服務。透過專業的新聞行銷策略,提升品牌知名度和公信力。',
},
{
id: 'influencer',
title: '網紅行銷專案',
category: '口碑行銷',
iconType: 'preset',
icon: 'youtube',
isHot: true,
image: '/placeholder-service-5.jpg',
isHot: false,
image: 'https://enchun-cms.anlstudio.cc/api/media/file/61f24aa108528b2267942cf2_%E7%B6%B2%E7%B4%85%E8%A1%8C%E9%8A%B7%E5%B0%88%E6%A1%88.svg',
description: '連結品牌與網紅/KOL打造影響者行銷活動。從網紅篩選、活動策劃到內容製作提供完整的網紅行銷解決方案快速建立品牌口碑。',
},
{
id: 'forum',
title: '論壇行銷專案',
category: '口碑行銷',
iconType: 'preset',
icon: 'forum',
isHot: false,
image: '/placeholder-service-6.jpg',
image: 'https://enchun-cms.anlstudio.cc/api/media/file/61f24aa108528be8e0942cf0_%E8%AB%96%E5%A3%87%E8%A1%8C%E9%8A%B7%E5%B0%88%E6%A1%88.svg',
description: '深耕各大論壇社群,包括 Dcard、PTT 等平台。透過專業的論壇行銷策略,建立品牌口碑,提升用戶信任度和互動參與度。',
},
{
id: 'website-design',
title: '形象網站設計',
category: '品牌行銷',
iconType: 'preset',
icon: 'web',
isHot: false,
image: '/placeholder-service-7.jpg',
image: 'https://enchun-cms.anlstudio.cc/api/media/file/61f24aa108528bc016942cef_%E5%BD%A2%E8%B1%A1%E7%B6%B2%E7%AB%99%E8%A8%AD%E8%A8%88.svg',
description: '現代化響應式網站設計服務,結合美學與功能,打造獨特的品牌形象。從 UI/UX 設計到前端開發,提供完整的網站解決方案。',
},
{
id: 'brand-video',
title: '品牌形象影片',
category: '品牌行銷',
iconType: 'preset',
icon: 'video',
isHot: false,
image: '/placeholder-service-8.jpg',
image: 'https://enchun-cms.anlstudio.cc/api/media/file/61f24aa108528b678d942ced_%E5%93%81%E7%89%8C%E5%BD%A2%E8%B1%A1%E5%BD%B1%E7%89%87.svg',
description: '專業影片製作團隊,從腳本創作、拍攝到後製剪輯,打造高品質的品牌形象影片。透過視覺故事說,傳達品牌核心價值。',
},
]
const servicesList = services && services.length > 0 ? services : defaultServices
// Icon SVG components
const icons = {
facebook: `<svg viewBox="0 0 24 24" class="w-8 h-8"><path fill="currentColor" d="M12 2C6.477 2 2 6.477 2 12c0 4.991 3.657 9.128 8.438 9.879V14.89h-2.54V12h2.54V9.797c0-2.506 1.492-3.89 3.777-3.89 1.094 0 2.238.195 2.238.195v2.46h-1.26c-1.243 0-1.63.771-1.63 1.562V12h2.773l-.443 2.89h-2.33v6.989C18.343 21.129 22 16.99 22 12c0-5.523-4.477-10-10-10z"/></svg>`,
google: `<svg viewBox="0 0 24 24" class="w-8 h-8"><path fill="currentColor" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/><path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/><path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/><path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/></svg>`,
ads: `<svg viewBox="0 0 24 24" class="w-8 h-8"><path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/></svg>`,
news: `<svg viewBox="0 0 24 24" class="w-8 h-8"><path fill="currentColor" d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-5 14H7v-2h7v2zm3-4H7v-2h10v2zm0-4H7V7h10v2z"/></svg>`,
youtube: `<svg viewBox="0 0 24 24" class="w-8 h-8"><path fill="currentColor" d="M23.5 6.188c-1.258-2.667-4.636-4.764-8.148-5.36-.734-.12-3.32-.264-3.352-.264-3.032 0-2.618.144-3.352.264-3.512.596-6.89 2.693-8.148 5.36-.438.928-.796 2.622-1.028 5.578l-.01.232c0 .046-.004.136-.004.232 0 .096.004.186.004.232l.01.232c.232 2.956.59 4.65 1.028 5.578 1.258 2.667 4.636 4.764 8.148 5.36.734.12 3.32.264 3.352.264 3.032 0 2.618-.144 3.352-.264 3.512-.596 6.89-2.693 8.148-5.36.438-.928.796-2.622 1.028-5.578l.01-.232c0-.046.004-.136.004-.232 0-.096-.004-.186-.004-.232l-.01-.232c-.232-2.956-.59-4.65-1.028-5.578zM9.5 15.5v-7l6 3.5-6 3.5z"/></svg>`,
forum: `<svg viewBox="0 0 24 24" class="w-8 h-8"><path fill="currentColor" d="M20 2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h4l4 4 4-4h4c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-2 12H6v-2h12v2zm0-3H6V9h12v2zm0-3H6V6h12v2z"/></svg>`,
web: `<svg viewBox="0 0 24 24" class="w-8 h-8"><path fill="currentColor" d="M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm-5 14H4v-4h11v4zm0-5H4V9h11v4zm5 5h-4V9h4v9z"/></svg>`,
video: `<svg viewBox="0 0 24 24" class="w-8 h-8"><path fill="currentColor" d="M18 4l2 4h-3l-2-4h-2l2 4h-3l-2-4H8l2 4H7L5 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V4h-3l-2 4h-3l2-4h-2z"/></svg>`,
// Helper to get image URL from string or object
const getImageUrl = (image: ServicesListItem['image']) => {
if (!image) return PLACEHOLDER_IMAGE
return typeof image === 'string' ? image : image.url || PLACEHOLDER_IMAGE
}
// Preset icon SVG components
const presetIcons: Record<string, string> = {
facebook: `<svg viewBox="0 0 24 24" class="w-6 h-6" fill="currentColor"><path d="M12 2C6.477 2 2 6.477 2 12c0 4.991 3.657 9.128 8.438 9.879V14.89h-2.54V12h2.54V9.797c0-2.506 1.492-3.89 3.777-3.89 1.094 0 2.238.195 2.238.195v2.46h-1.26c-1.243 0-1.63.771-1.63 1.562V12h2.773l-.443 2.89h-2.33v6.989C18.343 21.129 22 16.99 22 12c0-5.523-4.477-10-10-10z"/></svg>`,
google: `<svg viewBox="0 0 24 24" class="w-6 h-6"><path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/><path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/><path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/><path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/></svg>`,
ads: `<svg viewBox="0 0 24 24" class="w-6 h-6" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/></svg>`,
news: `<svg viewBox="0 0 24 24" class="w-6 h-6" fill="currentColor"><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-5 14H7v-2h7v2zm3-4H7v-2h10v2zm0-4H7V7h10v2z"/></svg>`,
youtube: `<svg viewBox="0 0 24 24" class="w-6 h-6" fill="currentColor"><path d="M23.5 6.188c-1.258-2.667-4.636-4.764-8.148-5.36-.734-.12-3.32-.264-3.352-.264-3.032 0-2.618.144-3.352.264-3.512.596-6.89 2.693-8.148 5.36-.438.928-.796 2.622-1.028 5.578l-.01.232c0 .046-.004.136-.004.232 0 .096.004.186.004.232l.01.232c.232 2.956.59 4.65 1.028 5.578 1.258 2.667 4.636 4.764 8.148 5.36.734.12 3.32.264 3.352.264 3.032 0 2.618-.144 3.352-.264 3.512-.596 6.89-2.693 8.148-5.36.438-.928.796-2.622 1.028-5.578l.01-.232c0-.046.004-.136.004-.232 0-.096-.004-.186-.004-.232l-.01-.232c-.232-2.956-.59-4.65-1.028-5.578zM9.5 15.5v-7l6 3.5-6 3.5z"/></svg>`,
forum: `<svg viewBox="0 0 24 24" class="w-6 h-6" fill="currentColor"><path d="M20 2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h4l4 4 4-4h4c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-2 12H6v-2h12v2zm0-3H6V9h12v2zm0-3H6V6h12v2z"/></svg>`,
web: `<svg viewBox="0 0 24 24" class="w-6 h-6" fill="currentColor"><path d="M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm-5 14H4v-4h11v4zm0-5H4V9h11v4zm5 5h-4V9h4v9z"/></svg>`,
video: `<svg viewBox="0 0 24 24" class="w-6 h-6" fill="currentColor"><path d="M18 4l2 4h-3l-2-4h-2l2 4h-3l-2-4H8l2 4H7L5 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V4h-3l-2 4h-3l2-4h-2z"/></svg>`,
}
// Helper to render icon based on type with XSS protection
const renderIcon = (service: ServicesListItem): string | null => {
const iconType = service.iconType || 'preset'
if (iconType === 'svg' && service.iconSvg) {
// Sanitize user-provided SVG to prevent XSS attacks
return sanitizeSvg(service.iconSvg)
}
if (iconType === 'upload' && service.iconImage?.url) {
return `<img src="${service.iconImage.url}" alt="${service.iconImage.alt || service.title}" class="w-6 h-6 object-contain" />`
}
if (iconType === 'preset' && service.icon && presetIcons[service.icon]) {
return presetIcons[service.icon]
}
return null
}
---
<section class="section_service-list" aria-labelledby="services-heading">
<div class="services-container">
<section class="bg-white py-[60px] px-5 md:py-[60px] md:px-5 lg:py-[60px] lg:px-5" aria-labelledby="services-heading">
<div class="max-w-3xl mx-auto">
{
servicesList.map((service, index) => (
<a
href={service.link || '#'}
class={`service-item ${index % 2 === 0 ? 'odd' : 'even'}`}
aria-labelledby={`service-${service.id}-title`}
>
<!-- Content Side -->
<div class="service-item-content">
<!-- Category Tag -->
<span class="service-category-tag">
{service.category}
</span>
servicesList.map((service, index) => {
const isOdd = index % 2 === 0
const iconHtml = renderIcon(service)
return (
<a
href={service.link || '#'}
class={`
grid grid-cols-1 md:grid-cols-2 gap-10 md:gap-6 items-center mb-[60px] md:mb-10 relative
transition-transform duration-300 hover:-translate-y-[2px]
no-underline
group
`}
aria-labelledby={`service-${service.id}-title`}
>
<!-- Content Side -->
<div class={`service-item-content order-2 ${isOdd ? 'md:order-1' : 'md:order-2'}`}>
<!-- Category Tag with Icon -->
<div class="flex items-center gap-2 mb-4">
<span class="inline-block text-(--color-enchunblue) text-3xl font-light">
{service.category}
</span>
{/* Icon displayed next to category */}
{
iconHtml && (
<span class="inline-flex items-center justify-center w-8 h-8 text-(--color-enchunblue)" set:html={iconHtml} />
)
}
</div>
<!-- Title -->
<h2
id={`service-${service.id}-title`}
class="service-title"
>
{service.title}
</h2>
<!-- Title -->
<h2
id={`service-${service.id}-title`}
class="font-['Noto_Sans_TC',sans-serif] font-bold text-2xl md:text-xl text-(--color-dark-blue) mb-4 leading-[1.3] transition-colors duration-300 group-hover:text-(--color-enchunblue)"
>
{service.title}
</h2>
<!-- Divider -->
<div class="service-divider"></div>
<!-- Divider -->
<div class="w-fill h-[1px] bg-(--color-enchunblue) mb-4"></div>
<!-- Description -->
<p class="service-description">
{service.description}
</p>
<!-- Icon (if provided) -->
{
service.icon && icons[service.icon as keyof typeof icons] && (
<div class="service-icon" set:html={icons[service.icon as keyof typeof icons]} />
)
}
</div>
<!-- Image Side -->
<div class="service-item-image">
<div class="image-wrapper">
<img
src={service.image || '/placeholder-service.jpg'}
alt={service.title}
loading="lazy"
/>
<!-- Description -->
<p class="font-['Quicksand',_'Noto_Sans_TC',sans-serif] font-thin text-2xl md:text-xl text-gray-700 leading-[1.6]">
{service.description}
</p>
</div>
<!-- Hot Badge -->
{
service.isHot && (
<span class="service-hot-badge">HOT</span>
)
}
</div>
</a>
))
<!-- Image Side -->
<div class={`service-item-image order-1 relative ${isOdd ? 'md:order-2' : 'md:order-1'}`}>
<div class="aspect-[4/3] rounded-[12px] overflow-hidden bg-[var(--color-white)]">
<img
src={getImageUrl(service.image)}
alt={service.title}
loading="lazy"
class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-[1.05]"
/>
</div>
<!-- Hot Badge -->
{
service.isHot && (
<span class="absolute top-4 right-4 md:top-3 md:right-3 bg-[var(--color-notification-red)] text-white px-[12px] py-[6px] md:px-[10px] md:py-[4px] rounded-[20px] text-[0.75rem] md:text-[0.7rem] font-bold uppercase">
HOT
</span>
)
}
</div>
</a>
)
})
}
</div>
</section>
<style>
/* Services List Styles - Pixel-perfect from Webflow */
.section_service-list {
background-color: #ffffff;
padding: 60px 20px;
}
.services-container {
max-width: 1200px;
margin: 0 auto;
}
/* Service Item - Zig-zag Layout */
.service-item {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 40px;
align-items: center;
margin-bottom: 60px;
text-decoration: none;
position: relative;
transition: transform 0.3s ease;
}
/* Odd items - content on left */
.service-item.odd {
grid-template-areas: "content image";
}
.service-item.odd .service-item-content {
grid-area: content;
}
.service-item.odd .service-item-image {
grid-area: image;
}
/* Even items - content on right */
.service-item.even {
grid-template-areas: "image content";
}
.service-item.even .service-item-content {
grid-area: content;
}
.service-item.even .service-item-image {
grid-area: image;
}
/* Hover Effects */
.service-item:hover {
transform: translateY(-2px);
}
.service-item:hover .service-title {
color: var(--color-enchunblue);
}
/* Category Tag */
.service-category-tag {
display: inline-block;
padding: 6px 14px;
background-color: var(--color-enchunblue);
color: white;
border-radius: 20px;
font-size: 0.875rem;
font-weight: 600;
margin-bottom: 16px;
}
/* Title */
.service-title {
font-family: "Noto Sans TC", sans-serif;
font-weight: 700;
font-size: 1.75rem;
color: var(--color-dark-blue);
margin-bottom: 16px;
line-height: 1.3;
transition: color 0.3s ease;
}
/* Divider */
.service-divider {
width: 60px;
height: 2px;
background-color: var(--color-enchunblue);
margin-bottom: 16px;
}
/* Description */
.service-description {
font-family: "Quicksand", "Noto Sans TC", sans-serif;
font-weight: 400;
font-size: 1rem;
color: var(--color-gray-700);
line-height: 1.6;
}
/* Image Side */
.service-item-image {
position: relative;
}
.image-wrapper {
aspect-ratio: 16/9;
border-radius: 12px;
overflow: hidden;
background: var(--color-gray-100);
}
.image-wrapper img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
}
.service-item:hover .image-wrapper img {
transform: scale(1.05);
}
/* Hot Badge */
.service-hot-badge {
position: absolute;
top: 16px;
right: 16px;
background-color: var(--color-notification-red);
color: white;
padding: 6px 12px;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
}
/* Service Icon - shown by default when content exists */
.service-item-content .service-icon {
display: none;
}
/* Show icon only if icon content exists (not hidden) */
.service-item-content:has(.service-icon) .service-icon {
display: block;
}
/* Responsive Adjustments */
@media (max-width: 991px) {
.service-item {
gap: 24px;
margin-bottom: 40px;
}
.service-title {
font-size: 1.5rem;
}
}
@media (max-width: 767px) {
.section_service-list {
padding: 40px 16px;
}
.service-item {
grid-template-columns: 1fr;
grid-template-areas: "image" "content" !important;
gap: 24px;
margin-bottom: 48px;
}
.service-item-content {
order: 2;
}
.service-item-image {
order: 1;
}
.service-title {
font-size: 1.3rem;
}
.service-description {
font-size: 0.95rem;
}
.service-hot-badge {
top: 12px;
right: 12px;
padding: 4px 10px;
font-size: 0.7rem;
}
}
</style>

View File

@@ -2,93 +2,71 @@
/**
* SolutionsHero - Hero section for Solutions/Services page
* Pixel-perfect implementation based on Webflow design
* Supports optional background image from CMS
* Styled with Tailwind CSS
*/
interface Props {
title?: string
subtitle?: string
backgroundImage?: {
url?: string
alt?: string
}
}
const {
title = '行銷解決方案',
subtitle = '提供全方位的數位行銷服務,協助您的品牌在數位時代脫穎而出',
backgroundImage,
} = Astro.props
// Determine if we have a background image
const hasBackgroundImage = backgroundImage?.url
const bgImageUrl = backgroundImage?.url || ''
---
<section class="hero-overlay-solution">
<div class="max-w-6xl mx-auto">
<section
class:list={[
// Base styles - relative positioning for overlay
'relative',
'flex',
'items-center',
'justify-center',
'overflow-hidden',
'text-center',
'px-5',
// Background color fallback
!hasBackgroundImage && 'bg-(--color-dark-blue)',
// Background image styles
hasBackgroundImage && 'bg-size-[120vw] bg-center bg-no-repeat',
// Pull up to counteract layout's pt-20 padding (80px)
'-mt-20',
// Full viewport height
'min-h-dvh',
'z-0',
]}
style={hasBackgroundImage ? `background-image: url('${bgImageUrl}')` : undefined}
>
{/* Background image overlay for text readability */}
{
hasBackgroundImage && (
<div
class="absolute inset-0 bg-linear-to-b from-black/80 to-transparent z-1"
aria-hidden="true"
/>
)
}
{/* Content container - relative z-index above overlay */}
<div class="relative z-2 max-w-6xl mx-auto">
<!-- Main Title -->
<h1 class="hero_title_head-solution">
<h1 class="text-white text-shadow-md font-['Noto_Sans_TC',sans-serif]! font-bold leading-[1.2] -mb-1 text-6xl! md:text-5xl">
{title}
</h1>
<!-- Subtitle -->
<p class="hero_sub_paragraph-solution">
<p class="text-gray-100 text-shadow-md font-['Quicksand'] font-thin leading-[1.2] max-w-3xl mx-auto text-3xl! md:text-2xl">
{subtitle}
</p>
</div>
</section>
<style>
/* Solutions Hero Styles - Pixel-perfect from Webflow */
.hero-overlay-solution {
background-color: var(--color-dark-blue);
max-height: 63.5vh;
padding: 120px 20px 80px;
text-align: center;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
/* Title - 3.39em (64px at 19px base) */
.hero_title_head-solution {
color: #ffffff;
font-family: "Noto Sans TC", sans-serif;
font-weight: 700;
font-size: 3.39em;
line-height: 1.2;
margin-bottom: 16px;
}
/* Subtitle - 1.56em (30px at 19px base) */
.hero_sub_paragraph-solution {
color: var(--color-gray-100);
font-family: "Quicksand", "Noto Sans TC", sans-serif;
font-weight: 400;
font-size: 1.56em;
line-height: 1.2;
max-width: 800px;
margin: 0 auto;
}
/* Responsive Adjustments */
@media (max-width: 991px) {
.hero-overlay-solution {
max-height: none;
padding: 80px 20px 60px;
}
.hero_title_head-solution {
font-size: 2.45em;
}
.hero_sub_paragraph-solution {
font-size: 1.15em;
}
}
@media (max-width: 767px) {
.hero-overlay-solution {
padding: 60px 16px 40px;
}
.hero_title_head-solution {
font-size: 7vw;
}
.hero_sub_paragraph-solution {
font-size: 3.4vw;
}
}
</style>

View File

@@ -14,82 +14,11 @@ const {
} = Astro.props
---
<header class="hero-overlay-team">
<div class="centered-container w-container">
<div class="div-block">
<h1 class="hero_title_head-team">{title}</h1>
<p class="hero_sub_paragraph-team">{subtitle}</p>
<header class="bg-[var(--color-dark-blue)] pt-[120px] pb-20 px-5 lg:pt-20 lg:pb-15 md:pt-15 md:pb-10 md:px-4 text-center">
<div class="max-w-[1200px] mx-auto">
<div class="flex flex-col items-center">
<h1 class="text-white font-['Noto_Sans_TC'] font-bold text-[3.39em] lg:text-[2.45em] md:text-[7vw] leading-tight mb-4">{title}</h1>
<p class="text-[var(--color-gray-100)] font-['Quicksand','Noto_Sans_TC'] font-normal text-[1.5em] lg:text-[1.15em] md:text-[3.4vw] leading-tight">{subtitle}</p>
</div>
</div>
</header>
<style>
/* Teams Hero Styles - Pixel-perfect from Webflow */
.hero-overlay-team {
background-color: var(--color-dark-blue);
padding: 120px 20px 80px;
text-align: center;
}
.centered-container {
max-width: 1200px;
margin: 0 auto;
}
.w-container {
max-width: 1200px;
margin: 0 auto;
}
.div-block {
display: flex;
flex-direction: column;
align-items: center;
}
.hero_title_head-team {
color: #ffffff;
font-family: "Noto Sans TC", sans-serif;
font-weight: 700;
font-size: 3.39em;
line-height: 1.2;
margin-bottom: 16px;
}
.hero_sub_paragraph-team {
color: var(--color-gray-100);
font-family: "Quicksand", "Noto Sans TC", sans-serif;
font-weight: 400;
font-size: 1.5em;
line-height: 1.2;
}
/* Responsive Adjustments */
@media (max-width: 991px) {
.hero-overlay-team {
padding: 80px 20px 60px;
}
.hero_title_head-team {
font-size: 2.45em;
}
.hero_sub_paragraph-team {
font-size: 1.15em;
}
}
@media (max-width: 767px) {
.hero-overlay-team {
padding: 60px 16px 40px;
}
.hero_title_head-team {
font-size: 7vw;
}
.hero_sub_paragraph-team {
font-size: 3.4vw;
}
}
</style>

View File

@@ -101,7 +101,11 @@
--font-family-sans: "Noto Sans TC", "Quicksand", Arial, sans-serif;
--font-family-heading: "Noto Sans TC", "Quicksand", Arial, sans-serif;
--font-family-accent: "Quicksand", "Noto Sans TC", sans-serif;
/* Rich Text Block Typography */
--rtb-heading: 1.44rem;
--rtb-body: 1rem;
--rtb-blockquote: 0.9rem;
/* ============================================
📏 SPACING - Based on Tailwind scale