feat(frontend): update pages, components and branding

Refresh Astro frontend implementation including new pages (Portfolio, Teams, Services), components, and styling updates.
This commit is contained in:
2026-02-11 11:50:42 +08:00
parent be7fc902fb
commit 9c2181f743
49 changed files with 9699 additions and 899 deletions

View File

@@ -1,97 +1,157 @@
---
import { Image } from 'astro:assets';
// Footer component with client-side data fetching
import { Image } from "astro:assets";
// Footer component with server-side data fetching
// Use local backend in development, production URL from dev.vars/wrangler
const isDev = import.meta.env.DEV;
const PAYLOAD_CMS_URL = isDev
? "http://localhost:3000" // Local backend in dev (port may vary)
: import.meta.env.PAYLOAD_CMS_URL || "https://enchun-admin.anlstudio.cc";
// Fetch footer data from Payload CMS server-side
let footerNavItems: any[] = [];
try {
const response = await fetch(
`${PAYLOAD_CMS_URL}/api/globals/footer?depth=2&draft=false&locale=undefined&trash=false`,
);
if (response.ok) {
const data = await response.json();
footerNavItems = data?.navItems || data || [];
}
} catch (error) {
console.error("[Footer SSR] Failed to fetch footer:", error);
}
// Fetch categories from Payload CMS server-side
let categories: any[] = [];
try {
const response = await fetch(
`${PAYLOAD_CMS_URL}/api/categories?sort=order&limit=6&depth=0&draft=false`,
);
if (response.ok) {
const data = await response.json();
categories = (data?.docs || [])
.sort((a: any, b: any) => (a.order || 0) - (b.order || 0))
.slice(0, 6);
}
} catch (error) {
console.error("[Footer SSR] Failed to fetch categories:", error);
}
// Get current year for copyright
const currentYear = new Date().getFullYear();
---
<footer class="bg-[var(--color-tropical-blue)] py-10 mt-auto">
<div class="max-w-5xl mx-auto px-4">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-8 mb-8">
<div class="col-span-2">
<Image src="/enchun-logo.svg"
alt="Enchun Digital Logo" class="h-auto w-32 mb-4"
width={919}
height={201}
loading="eager"
decoding="async"
/>
<p class="text-[var(--color-st-tropaz)] text-sm font-light leading-relaxed">恩群數位累積多年廣告行銷操作經驗,擁有全方位行銷人才,讓我們可以為客戶精準的規劃每一分廣告預算,讓你的品牌深入人心。更重要的是恩群的存在,為了成為每家公司最佳數位夥伴,作為彼此最堅強的後盾,你會知道有我們的陪伴 你並不孤單。</p>
</div>
<div>
<h3 class="text-lg font-bold text-[var(--color-st-tropaz)] mb-4">聯絡我們</h3>
<a href="https://www.facebook.com/EnChun-Taiwan-100979265112420" target="_blank" class="flex items-center mb-2">
<Image src="/fb-icon.svg"
alt="Phone Icon" class="h-auto w-6 mb-2"
width={16}
height={16}
loading="eager"
decoding="async"
/>
</a>
<p class="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">enchuntaiwan@gmail.com</a>
</div>
<div>
<h3 class="text-lg font-bold text-[var(--color-st-tropaz)] mb-4">行銷方案</h3>
<ul class="space-y-2" id="marketing-solutions">
<li><span class="text-gray-500">載入中...</span></li>
</ul>
</div>
<div>
<h3 class="text-lg font-bold text-[var(--color-st-tropaz)] mb-4">行銷放大鏡</h3>
<ul class="space-y-2" id="marketing-articles">
<li><span class="text-gray-500">載入中...</span></li>
</ul>
</div>
<footer class="bg-[var(--color-tropical-blue)] py-10 mt-auto relative">
<div class="max-w-5xl mx-auto px-4">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-8 mb-16">
<div class="col-span-2">
<Image
src="/enchun-logo.svg"
alt="Enchun Digital Logo"
class="h-auto w-32 mb-4"
width={919}
height={201}
loading="eager"
decoding="async"
/>
<p
class="text-[var(--color-st-tropaz)] text-sm font-light leading-relaxed"
>
恩群數位累積多年廣告行銷操作經驗,擁有全方位行銷人才,讓我們可以為客戶精準的規劃每一分廣告預算,讓你的品牌深入人心。更重要的是恩群的存在,為了成為每家公司最佳數位夥伴,作為彼此最堅強的後盾,你會知道有我們的陪伴
你並不孤單。
</p>
</div>
<div>
<h3
class="text-lg font-bold text-[var(--color-st-tropaz)] mb-4"
>
聯絡我們
</h3>
<a
href="https://www.facebook.com/EnChun-Taiwan-100979265112420"
target="_blank"
rel="noopener noreferrer"
class="flex items-center mb-2"
>
<Image
src="/fb-icon.svg"
alt="Facebook Icon"
class="h-auto w-6 mb-2"
width={16}
height={16}
loading="eager"
decoding="async"
/>
</a>
<p class="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"
>enchuntaiwan@gmail.com</a
>
</div>
<div>
<h3
class="text-lg font-bold text-[var(--color-st-tropaz)] mb-4"
>
行銷方案
</h3>
<ul class="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="font-normal text-[var(--color-st-tropaz)] hover:text-[var(--color-dove-gray)] transition-colors">
{item.link?.label || "連結"}
</a>
</li>
))
: <li><span class="text-gray-500">載入中...</span></li>
}
</ul>
</div>
<div>
<h3
class="text-lg font-bold text-[var(--color-st-tropaz)] mb-4"
>
行銷放大鏡
</h3>
<ul class="space-y-2" id="marketing-articles">
{categories.length > 0
? categories.map((cat: any) => (
<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>
</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="absolute inset-x-0 w-screen bg-[var(--color-amber)] py-3 text-center">
<p class="text-[var(--color-tarawera)]">copyright ©  Enchun digital  2018 - {new Date().getFullYear()}</p>
</div>
</div>
</footer>
<script>
// Client-side data fetching for footer
interface LinkItem {
link?: {
url?: string;
label?: string;
};
}
async function loadFooterData() {
try {
console.log('Fetching footer data...');
const response = await fetch('/api/globals/footer?depth=2&draft=false&locale=undefined&trash=false');
const data = await response.json();
console.log('Footer data loaded:', data);
// Update marketing solutions
const marketingUl = document.getElementById('marketing-solutions');
if (marketingUl && data.navItems?.[0]?.childNavItems) {
const links = data.navItems[0].childNavItems.map((item: LinkItem) =>
`<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></li>`
).join('');
marketingUl.innerHTML = links;
}
// Update marketing articles (行銷放大鏡)
const articlesUl = document.getElementById('marketing-articles');
if (articlesUl && data.navItems?.[1]?.childNavItems) {
const links = data.navItems[1].childNavItems.map((item: LinkItem) =>
`<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></li>`
).join('');
articlesUl.innerHTML = links;
}
} catch (error) {
console.error('Failed to load footer data:', error);
<style>
/* Footer specific styles */
footer a {
text-decoration: none;
transition: color 0.2s ease-in-out;
}
}
// Load footer data when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', loadFooterData);
} else {
loadFooterData();
}
</script>
footer a:hover {
text-decoration: underline;
}
</style>

View File

@@ -1,18 +1,82 @@
---
import { Image } from "astro:assets";
// Header component
// Header component with scroll-based background and enhanced mobile animations
// Use local backend in development, production URL from dev.vars/wrangler
const isDev = import.meta.env.DEV;
const PAYLOAD_CMS_URL = isDev
? "http://localhost:3000" // Local backend in dev (port may vary)
: import.meta.env.PAYLOAD_CMS_URL || "https://enchun-admin.anlstudio.cc";
// Fetch navigation data from Payload CMS server-side
let navItems: any[] = [];
try {
const response = await fetch(
`${PAYLOAD_CMS_URL}/api/globals/header?depth=2&draft=false&locale=undefined&trash=false`,
);
if (response.ok) {
const data = await response.json();
navItems = data?.navItems || data || [];
}
} catch (error) {
console.error("[Header SSR] Failed to fetch navigation:", error);
}
// Helper to get link URL
function getLinkUrl(link: any): string {
if (!link) return "#";
if (link.type === "custom" && link.url) {
return link.url;
}
if (link.type === "reference" && link.reference?.value) {
if (typeof link.reference.value === "string") {
// It's an ID, construct URL based on relationTo
return `/${link.reference.relationTo || "pages"}/${link.reference.value}`;
} else if (link.reference.value.slug) {
// It's a populated object with slug
return `/${link.reference.value.slug}`;
}
}
return "#";
}
// Check if label should have a badge
function getBadgeForLabel(label: string): string {
if (label.includes("行銷方案")) {
return `<span class="absolute -top-2 -right-3 bg-red-500 text-white text-[6px] font-bold px-1.5 py-0.5 rounded-full uppercase tracking-wider shadow-sm animate-pulse">Hot</span>`;
}
if (label.includes("行銷放大鏡")) {
return `<span class="absolute -top-2 -right-3 bg-red-500 text-white text-[6px] font-bold px-1.5 py-0.5 rounded-full uppercase tracking-wider shadow-sm">New</span>`;
}
return "";
}
// Check if link is active
function isLinkActive(url: string): boolean {
const currentPath = Astro.url.pathname;
return currentPath === url || (url === "/" && currentPath === "/");
}
---
<header class="sticky top-0 z-50 bg-transparent">
<nav class="max-w-5xl mx-auto px-4 py-4">
<header
class="fixed top-0 left-0 right-0 z-50 transition-transform duration-300 ease-in-out"
id="main-header"
>
<nav
class="max-w-5xl mx-auto px-4 py-4 transition-all duration-300 ease-in-out"
id="main-nav"
>
<ul class="flex items-center justify-between list-none">
<li class="flex-shrink-0">
<li class="shrink-0">
<a href="/" class="block">
<!-- Uses Astro's optimized Image component for the site logo -->
<Image
src="/enchun-logo.svg"
alt="Enchun Digital Marketing"
class="w-32 h-auto"
class="w-32 h-auto transition-opacity duration-300 drop-shadow-md"
width={919}
height={201}
loading="eager"
@@ -20,152 +84,430 @@ import { Image } from "astro:assets";
/>
</a>
</li>
<li class="hidden md:flex items-center space-x-6" id="desktop-nav">
<!-- Navigation items will be populated by JavaScript -->
<li class="hidden md:flex items-center space-x-6">
{
navItems.map((item) => {
const link = item.link;
const href = getLinkUrl(link);
const label = link.label || "未命名";
const badge = getBadgeForLabel(label);
const activeClass = isLinkActive(href)
? "nav-active"
: "";
const hasBadge = badge ? "relative inline-block" : "";
return (
<a
href={href}
class={`${hasBadge} text-base font-medium transition-all duration-200 px-3 py-2 rounded ${activeClass} hover:bg-white/10`}
{...(link.newTab && {
target: "_blank",
rel: "noopener noreferrer",
})}
>
{label}
{badge && <Fragment set:html={badge} />}
</a>
);
})
}
</li>
<!-- Mobile menu button -->
<!-- Mobile menu button with animated hamburger/X icon -->
<li class="md:hidden">
<button
class="text-[var(--color-enchunblue)] hover:text-[var(--color-enchunblue)]/80"
class="text-(--color-enchunblue) hover:text-(--color-enchunblue)/80 focus:outline-none relative w-10 h-10 flex items-center justify-center"
id="mobile-menu-button"
aria-label="Toggle mobile menu"
aria-expanded="false"
>
<span class="sr-only text-(--color-enchunblue-dark)"
>Toggle menu</span
>
<svg
width="24"
height="24"
width="28"
height="28"
viewBox="0 0 36 36"
fill="none"
xmlns="http://www.w3.org/2000/svg"
class="w-6 h-6"
class="w-7 h-7 hamburger-icon transition-transform duration-300"
id="hamburger-icon"
>
<path
d="M6 27H22.5C23.325 27 24 26.325 24 25.5C24 24.675 23.325 24 22.5 24H6C5.175 24 4.5 24.675 4.5 25.5C4.5 26.325 5.175 27 6 27ZM6 19.5H18C18.825 19.5 19.5 18.825 19.5 18C19.5 17.175 18.825 16.5 18 16.5H6C5.175 16.5 4.5 17.175 4.5 18C4.5 18.825 5.175 19.5 6 19.5ZM4.5 10.5C4.5 11.325 5.175 12 6 12H22.5C23.325 12 24 11.325 24 10.5C24 9.675 23.325 9 22.5 9H6C5.175 9 4.5 9.675 4.5 10.5ZM30.45 22.32L26.13 18L30.45 13.68C30.5889 13.5411 30.699 13.3763 30.7742 13.1948C30.8493 13.0134 30.888 12.8189 30.888 12.6225C30.888 12.4261 30.8493 12.2316 30.7742 12.0502C30.699 11.8687 30.5889 11.7039 30.45 11.565C30.3111 11.4261 30.1463 11.316 29.9648 11.2408C29.7834 11.1657 29.5889 11.127 29.3925 11.127C29.1961 11.127 29.0016 11.1657 28.8202 11.2408C28.6387 11.316 28.4739 11.4261 28.335 11.565L22.95 16.95C22.8109 17.0888 22.7006 17.2536 22.6254 17.4351C22.5501 17.6165 22.5113 17.811 22.5113 18.0075C22.5113 18.204 22.5501 18.3985 22.6254 18.5799C22.7006 18.7614 22.8109 18.9262 22.95 19.065L28.335 24.45C28.92 25.035 29.865 25.035 30.45 24.45C31.02 23.865 31.035 22.905 30.45 22.32V22.32Z"
fill="currentColor"
class="transition-opacity duration-300"></path>
</svg>
<!-- X icon for close state -->
<svg
width="28"
height="28"
viewBox="0 0 36 36"
fill="none"
xmlns="http://www.w3.org/2000/svg"
class="w-7 h-7 close-icon absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 opacity-0 transition-opacity duration-300 pointer-events-none"
id="close-icon"
>
<path
d="M28.5 9.6L26.4 7.5L18 15.9L9.6 7.5L7.5 9.6L15.9 18L7.5 26.4L9.6 28.5L18 20.1L26.4 28.5L28.5 26.4L20.1 18L28.5 9.6Z"
fill="currentColor"></path>
</svg>
</button>
</li>
</ul>
<!-- Mobile menu -->
<div class="md:hidden hidden" id="mobile-menu">
<ul class="pt-4 pb-2 space-y-2" id="mobile-nav">
<!-- Mobile navigation items will be populated by JavaScript -->
<!-- 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"
id="mobile-menu"
>
<ul
class="flex flex-col items-center justify-center h-full space-y-6 px-4"
id="mobile-nav"
>
{
navItems.map((item, index) => {
const link = item.link;
const href = getLinkUrl(link);
const label = link.label || "未命名";
return (
<li
class={`opacity-0`}
style={`animation-delay: ${index * 50}ms`}
>
<a
href={href}
class="text-2xl text-grey-700 text-shadow-sm font-medium transition-all duration-200 transform hover:scale-105"
{...(link.newTab && {
target: "_blank",
rel: "noopener noreferrer",
})}
>
{label}
</a>
</li>
);
})
}
</ul>
</div>
</nav>
</header>
<script>
interface NavItem {
link: {
type: "reference" | "custom";
label: string;
url?: string;
reference?: {
slug: string;
};
newTab?: boolean;
};
}
// Scroll-based header with smooth hide/show animation
let lastScrollY = 0;
let ticking = false;
let isHeaderHidden = false;
// Fetch navigation data from Payload CMS
async function fetchNavigation() {
try {
// Use local proxy in development to avoid CORS issues
const apiUrl = `/api/globals/header?depth=2&draft=false&locale=undefined&trash=false`;
function initScrollEffect() {
const header = document.getElementById("main-header");
const nav = document.getElementById("main-nav");
const mobileMenu = document.getElementById("mobile-menu");
if (!header || !nav) return;
const response = await fetch(apiUrl);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
const handleScroll = () => {
const scrollY = window.scrollY;
const scrollDelta = scrollY - lastScrollY;
const scrollDirection = scrollDelta > 0 ? "down" : "up";
// Only update lastScrollY if there's meaningful movement
if (Math.abs(scrollDelta) > 1) {
lastScrollY = scrollY;
}
const data = await response.json();
return data.navItems || [];
} catch (error) {
console.error("Error fetching navigation:", error);
throw error;
}
}
// Generate navigation link HTML
function createNavLink(item: NavItem) {
const { link } = item;
let href = "";
if (link.type === "custom" && link.url) {
href = link.url;
} else if (link.type === "reference" && link.reference?.slug) {
href = `/${link.reference.slug}`;
}
const target = link.newTab
? ' target="_blank" rel="noopener noreferrer"'
: "";
const label = link.label;
// Check if current page matches this link
const currentPath = window.location.pathname;
const isActive =
currentPath === href || (href === "/" && currentPath === "/");
// Add badges for specific items (positioned in top right corner)
let badge = "";
if (label.includes("行銷方案")) {
badge =
'<span class="absolute -top-1 -right-1 bg-red-500 text-white text-[0.5rem] px-1 py-0.5 rounded-full">hot</span>';
} else if (label.includes("行銷放大鏡")) {
badge =
'<span class="absolute -top-1 -right-1 bg-red-500 text-white text-[0.5rem] px-1 py-0.5 rounded-full">new</span>';
}
const containerClass = badge ? "relative inline-block" : "";
const activeClass = isActive ? " nav-active" : "";
return `<a href="${href}" class="${containerClass} text-lg font-normal text-shadow-md hover:text-primary transition-colors px-3 py-2${activeClass}"${target}>${label}${badge}</a>`;
}
// Populate navigation
async function populateNavigation() {
const navItems = await fetchNavigation();
const desktopNav = document.getElementById("desktop-nav");
const mobileNav = document.getElementById("mobile-nav");
if (desktopNav && mobileNav) {
// Clear existing content
desktopNav.innerHTML = "";
mobileNav.innerHTML = "";
// Populate desktop navigation
navItems.forEach((item: NavItem) => {
const linkHtml = createNavLink(item);
const li = document.createElement("li");
li.innerHTML = linkHtml;
desktopNav.appendChild(li);
});
// Populate mobile navigation
navItems.forEach((item: NavItem) => {
const linkHtml = createNavLink(item)
.replace("px-3 py-2", "block px-3 py-2")
.replace(
"relative inline-block",
"relative inline-block block",
// Header shrinks and gets background on scroll
if (scrollY > 10) {
if (!header.classList.contains("header-scrolled")) {
header.classList.add(
"bg-white/15",
"backdrop-blur-xl",
"shadow-md",
"header-scrolled",
);
const li = document.createElement("li");
li.innerHTML = linkHtml;
mobileNav.appendChild(li);
header.classList.remove("bg-transparent");
nav.classList.add("py-2");
nav.classList.remove("py-4");
// Adjust mobile menu top position and height when header shrinks
if (mobileMenu) {
mobileMenu.classList.remove(
"top-20",
"h-[calc(100vh-80px)]",
"h-[calc(100dvh-80px)]",
);
mobileMenu.classList.add(
"top-16",
"h-[calc(100vh-64px)]",
"h-[calc(100dvh-64px)]",
);
}
}
} else {
if (header.classList.contains("header-scrolled")) {
header.classList.remove(
"bg-white/15",
"bg-white/90",
"backdrop-blur-xl",
"shadow-md",
"border-b",
"border-gray-200/50",
"header-scrolled",
);
header.classList.add("bg-transparent");
nav.classList.remove("py-2");
nav.classList.add("py-4");
// Reset mobile menu top position and height when header expands
if (mobileMenu) {
mobileMenu.classList.remove(
"top-16",
"h-[calc(100vh-64px)]",
"h-[calc(100dvh-64px)]",
);
mobileMenu.classList.add(
"top-20",
"h-[calc(100vh-80px)]",
"h-[calc(100dvh-80px)]",
);
}
}
}
// Hide/show header based on scroll direction - with threshold
const hideThreshold = 150; // Higher threshold before hiding
const showThreshold = 50; // Lower threshold to show (makes it feel smoother)
if (
scrollY > hideThreshold &&
scrollDirection === "down" &&
!isHeaderHidden
) {
// Only hide if scrolling down consistently
if (scrollDelta > 5) {
// Must be scrolling down with some speed
header.classList.remove("translate-in");
header.classList.add("translate-out");
isHeaderHidden = true;
}
} else if (scrollDirection === "up" && isHeaderHidden) {
// Show immediately when scrolling up
header.classList.remove("translate-out");
header.classList.add("translate-in");
isHeaderHidden = false;
} else if (scrollY < showThreshold) {
// Always show when near top
header.classList.remove("translate-out");
header.classList.add("translate-in");
isHeaderHidden = false;
}
ticking = false;
};
window.addEventListener(
"scroll",
() => {
if (!ticking) {
window.requestAnimationFrame(() => {
handleScroll();
ticking = false;
});
ticking = true;
}
},
{ passive: true },
);
handleScroll();
// Animate mobile menu items on page load
setTimeout(() => {
const mobileItems = document.querySelectorAll("#mobile-nav li");
mobileItems.forEach((item) => {
item.classList.remove("opacity-0");
item.classList.add(
"opacity-100",
"translate-y-0",
"transition-all",
"duration-300",
"ease-out",
);
});
}, 100);
}
// Enhanced mobile menu toggle with animations
let mobileMenuInitialized = false;
function initMobileMenu() {
if (mobileMenuInitialized) return;
const button = document.getElementById("mobile-menu-button");
const menu = document.getElementById("mobile-menu");
const hamburgerIcon = document.getElementById("hamburger-icon");
const closeIcon = document.getElementById("close-icon");
const mobileNavItems = document
.getElementById("mobile-nav")
?.querySelectorAll("li");
if (!button || !menu || !hamburgerIcon || !closeIcon) {
// Retry after a delay if elements aren't ready
setTimeout(initMobileMenu, 100);
return;
}
button.addEventListener("click", () => {
const isMenuOpen = menu.classList.contains("opacity-100");
if (isMenuOpen) {
menu.classList.remove("opacity-100", "visible");
menu.classList.add("opacity-0", "invisible");
hamburgerIcon.classList.remove("opacity-0", "rotate-90");
hamburgerIcon.classList.add("opacity-100");
closeIcon.classList.remove("opacity-100");
closeIcon.classList.add("opacity-0");
button.setAttribute("aria-expanded", "false");
mobileNavItems?.forEach((item) => {
item.classList.remove("opacity-100", "translate-y-0");
item.classList.add("opacity-0", "translate-y-4");
});
document.body.style.overflow = "";
} else {
menu.classList.remove("opacity-0", "invisible");
menu.classList.add("opacity-100", "visible");
hamburgerIcon.classList.remove("opacity-100");
hamburgerIcon.classList.add("opacity-0", "rotate-90");
closeIcon.classList.remove("opacity-0");
closeIcon.classList.add("opacity-100");
button.setAttribute("aria-expanded", "true");
mobileNavItems?.forEach((item, index) => {
setTimeout(() => {
item.classList.remove("opacity-0", "translate-y-4");
item.classList.add(
"opacity-100",
"translate-y-0",
"transition-all",
"duration-300",
"ease-out",
);
}, index * 50);
});
document.body.style.overflow = "hidden";
}
});
menu.addEventListener("click", (e) => {
if (e.target === menu) {
button.click();
}
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && menu.classList.contains("opacity-100")) {
button.click();
}
});
mobileMenuInitialized = true;
}
document.addEventListener("DOMContentLoaded", () => {
initScrollEffect();
initMobileMenu();
});
</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 */
#main-header.translate-out {
transform: translateY(-100%);
}
#main-header.translate-in {
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 */
#main-nav a {
color: var(--color-nav-link);
position: relative;
text-shadow: 1px 1px 3px rgba(0, 0, 0, 0.3);
}
#main-nav a:hover {
color: var(--color-primary);
}
/* Active state */
.nav-active {
color: var(--color-enchunblue) !important;
font-weight: 600;
}
/* Badge positioning and styling */
#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);
}
@media (max-width: 767px) {
#mobile-menu {
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
#mobile-nav a {
padding: 0.75rem 1rem;
min-height: 48px;
display: flex;
align-items: center;
color: var(--color-nav-link);
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
#mobile-nav a:hover {
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
}
// Initialize navigation
populateNavigation();
// Simple mobile menu toggle
const button = document.getElementById("mobile-menu-button");
const menu = document.getElementById("mobile-menu");
if (button && menu) {
button.addEventListener("click", () => {
menu.classList.toggle("hidden");
});
/* 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;
}
</script>
</style>

View File

@@ -0,0 +1,173 @@
---
/**
* PortfolioCard - Portfolio item card component
* Pixel-perfect implementation based on Webflow design
*/
interface PortfolioItem {
slug: string
title: string
description: string
image?: string
tags?: string[]
externalUrl?: string
}
interface Props {
item: PortfolioItem
}
const { item } = Astro.props
const imageUrl = item.image || '/placeholder-portfolio.jpg'
const title = item.title || 'Untitled'
const description = item.description || ''
const tags = item.tags || []
const hasExternalLink = !!item.externalUrl
const linkHref = hasExternalLink ? item.externalUrl : `/website-portfolio/${item.slug}`
const linkTarget = hasExternalLink ? '_blank' : '_self'
const linkRel = hasExternalLink ? 'noopener noreferrer' : ''
---
<li class="portfolio-card-item">
<a
href={linkHref}
target={linkTarget}
rel={linkRel}
class="portfolio-card"
>
<!-- Image Wrapper -->
<div class="portfolio-image-wrapper">
<img
src={imageUrl}
alt={title}
class="portfolio-image"
loading="lazy"
decoding="async"
width="800"
height="450"
/>
</div>
<!-- Content -->
<div class="portfolio-content">
<!-- Title -->
<h3 class="portfolio-title">{title}</h3>
<!-- Description -->
{
description && (
<p class="portfolio-description">{description}</p>
)
}
<!-- Tags -->
{
tags.length > 0 && (
<div class="portfolio-tags">
{tags.map((tag) => (
<span class="portfolio-tag">{tag}</span>
))}
</div>
)
}
</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,89 @@
---
/**
* PortfolioPreviewCard - Portfolio item preview card
*/
import type { PortfolioItem } from '@/lib/api/home'
interface Props {
item: PortfolioItem
}
const { item } = Astro.props
const imageUrl = item.image?.url || '/placeholder-portfolio.jpg'
const imageAlt = item.image?.alt || item.title || 'Portfolio item'
const title = item.title || 'Untitled'
const slug = item.slug || ''
const description = item.description || ''
const websiteType = item.websiteType || ''
// Website type labels
const typeLabels: Record<string, string> = {
corporate: '企業官網',
ecommerce: '電商網站',
landing: '活動頁面',
brand: '品牌網站',
other: '其他',
}
const typeLabel = typeLabels[websiteType] || ''
---
<li class="group">
<a
href={`/website-portfolio/${slug}`}
class="block h-full"
>
<article class="bg-white rounded-lg overflow-hidden shadow-md hover:shadow-xl transition-all duration-300 h-full flex flex-col group-hover:-translate-y-1">
<!-- Image -->
<div class="aspect-video overflow-hidden bg-gray-100">
<img
src={imageUrl}
alt={imageAlt}
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
loading="lazy"
decoding="async"
/>
</div>
<!-- Content -->
<div class="p-6 flex flex-col flex-grow">
<!-- Type Badge -->
{
typeLabel && (
<span class="inline-block px-3 py-1 text-xs font-medium bg-[var(--color-tropical-blue)] text-[var(--color-enchunblue)] rounded-full mb-3 w-fit">
{typeLabel}
</span>
)
}
<!-- Title -->
<h3 class="text-xl font-semibold text-[var(--color-text-primary)] mb-2 group-hover:text-[var(--color-enchunblue)] transition-colors">
{title}
</h3>
<!-- Description -->
{
description && (
<p class="text-[var(--color-text-muted)] line-clamp-2 mb-4 flex-grow">
{description}
</p>
)
}
<!-- CTA -->
<div class="flex items-center text-[var(--color-enchunblue)] font-medium mt-auto">
<span>查看案例</span>
<svg
class="w-4 h-4 ml-1 group-hover:translate-x-1 transition-transform"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</div>
</div>
</article>
</a>
</li>

View File

@@ -0,0 +1,53 @@
---
/**
* ServiceFeatureCard - Individual service feature card component
*/
import type { ServiceFeature } from '@/lib/api/home'
interface Props {
feature: ServiceFeature
}
const { feature } = Astro.props
const icon = feature.icon || '🎯'
const title = feature.title || '服務項目'
const description = feature.description || ''
const linkUrl = feature.link?.url
---
<li class="group">
<a
href={linkUrl || '#'}
class={`block h-full ${!linkUrl ? 'pointer-events-none' : ''}`}
>
<div class="bg-white p-8 rounded-lg shadow-md hover:shadow-xl transition-all duration-300 h-full flex flex-col group-hover:-translate-y-1">
<!-- Icon -->
<div class="text-5xl mb-4" aria-hidden="true">
{icon}
</div>
<!-- Title -->
<h3 class="text-xl font-semibold text-[var(--color-enchunblue)] mb-4 group-hover:text-[var(--color-enchunblue-dark)] transition-colors">
{title}
</h3>
<!-- Description -->
<p class="text-[var(--color-text-secondary)] leading-relaxed flex-grow">
{description}
</p>
<!-- Arrow indicator (only if link exists) -->
{
linkUrl && (
<div class="mt-4 flex items-center text-[var(--color-enchunblue)] font-medium opacity-0 group-hover:opacity-100 transition-opacity">
<span>了解更多</span>
<svg class="w-4 h-4 ml-1 group-hover:translate-x-1 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</div>
)
}
</div>
</a>
</li>

View File

@@ -0,0 +1,215 @@
---
/**
* ArticleCard - Blog article card component
* Displays featured image, title, excerpt, category badge, and published date
*/
interface Category {
id: string
title: string
slug: string
backgroundColor?: string
textColor?: string
}
interface PostImage {
url: string
alt?: string
}
interface Props {
post: {
id: string
title: string
slug: string
heroImage?: PostImage | null
excerpt?: string | null
categories?: Category[] | null
publishedAt?: string | null
createdAt?: string | null
}
}
const { post } = Astro.props
// Format date in Traditional Chinese
const formatDate = (dateStr: string | null | undefined) => {
if (!dateStr) return ''
const date = new Date(dateStr)
return date.toLocaleDateString('zh-TW', {
year: 'numeric',
month: 'long',
day: 'numeric'
})
}
// Get primary category (first one)
const category = post.categories?.[0]
// Generate image URL with fallback
const imageUrl = post.heroImage?.url || '/placeholder-blog.jpg'
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">
<!-- Featured Image -->
<div class="article-image-wrapper">
<img
src={imageUrl}
alt={imageAlt}
loading="lazy"
class="article-image"
width="800"
height="450"
/>
</div>
<!-- Card Content -->
<div class="article-content">
<!-- Category Badge -->
{
category && (
<span
class="category-badge"
style={`background-color: ${category.backgroundColor || 'var(--color-enchunblue)'}; color: ${category.textColor || '#fff'}`}
>
{category.title}
</span>
)
}
<!-- Title -->
<h3 class="article-title">
{post.title}
</h3>
<!-- Excerpt -->
{
post.excerpt && (
<p class="article-excerpt">
{post.excerpt.length > 150 ? post.excerpt.slice(0, 150) + '...' : post.excerpt}
</p>
)
}
<!-- Published Date -->
{
displayDate && (
<time class="article-date" datetime={displayDate}>
{formatDate(displayDate)}
</time>
)
}
</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

@@ -0,0 +1,113 @@
---
/**
* CategoryFilter - Blog category filter component
* Displays category filter buttons with active state
*/
interface Category {
id: string
title: string
slug: string
backgroundColor?: string
textColor?: string
}
interface Props {
categories: Category[]
activeCategory?: string | null
baseUrl?: string
}
const { categories, activeCategory, baseUrl = '/news' } = Astro.props
// Filter out the legacy "文章分類" container category
const filteredCategories = categories.filter(c => c.slug !== 'wen-zhang-fen-lei')
---
<nav class="category-filter" aria-label="文章分類篩選">
<div class="filter-container">
<a
href={baseUrl}
class:filter-button-active={!activeCategory}
class="filter-button"
>
全部文章
</a>
{
filteredCategories.map((category) => (
<a
href={`/wen-zhang-fen-lei/${category.slug}`}
class:filter-button-active={activeCategory === category.slug}
class="filter-button"
aria-label={`篩選 ${category.title} 類別文章`}
>
{category.title}
</a>
))
}
</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

@@ -0,0 +1,195 @@
---
/**
* RelatedPosts - Related articles section component
* Displays 3-4 related posts from the same category
*/
interface Category {
id: string
title: string
slug: string
backgroundColor?: string
textColor?: string
}
interface PostImage {
url: string
alt?: string
}
interface Post {
id: string
title: string
slug: string
heroImage?: PostImage | null
excerpt?: string | null
categories?: Category[] | null
publishedAt?: string | null
}
interface Props {
posts: Post[]
title?: string
}
const { posts, title = '相關文章' } = Astro.props
// Only display if there are related posts
// Component will render empty fragment if no posts
---
{
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">
{title}
</h2>
<div class="related-posts-grid">
{
posts.slice(0, 4).map((post) => (
<a href={`/blog/${post.slug}`} class="related-post-card">
<!-- Post Image -->
<div class="related-post-image">
<img
src={post.heroImage?.url || '/placeholder-blog.jpg'}
alt={post.heroImage?.alt || post.title}
loading="lazy"
width="400"
height="225"
/>
</div>
<!-- Post Content -->
<div class="related-post-content">
<!-- Category Badge -->
{
post.categories && post.categories[0] && (
<span
class="related-category-badge"
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">
{post.title}
</h3>
</div>
</a>
))
}
</div>
</div>
</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

@@ -1,41 +1,47 @@
---
import '../styles/tailwind.css';
import Header from '../components/Header.astro';
import Footer from '../components/Footer.astro';
import "../styles/tailwind.css";
import Header from "../components/Header.astro";
import Footer from "../components/Footer.astro";
// Main layout for the site
---
<!DOCTYPE html>
<!doctype html>
<html lang="zh-TW">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>恩群數位行銷</title>
<meta name="description" content="恩群數位累積多年廣告行銷操作經驗,擁有全方位行銷人才,讓我們可以為客戶精準的規劃每一分廣告預算,讓你的品牌深入人心。" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+TC:wght@300;400;500;600;700&display=swap" rel="stylesheet">
</head>
<body class="bg-background text-text font-sans min-h-screen flex flex-col">
<Header />
<main class="flex-1">
<slot />
</main>
<Footer />
</body>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>恩群數位行銷</title>
<meta
name="description"
content="恩群數位累積多年廣告行銷操作經驗,擁有全方位行銷人才,讓我們可以為客戶精準的規劃每一分廣告預算,讓你的品牌深入人心。"
/>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Noto+Sans+TC:wght@300;400;500;600;700&display=swap"
rel="stylesheet"
/>
</head>
<body class="bg-background text-text font-sans min-h-screen flex flex-col">
<Header />
<main class="flex-1 pt-20">
<slot />
</main>
<Footer />
</body>
</html>
<style>
body {
font-family: var(--font-family-sans);
margin: 0;
min-height: 100vh;
display: flex;
flex-direction: column;
}
main {
flex: 1;
}
</style>
body {
font-family: var(--font-family-sans);
margin: 0;
min-height: 100vh;
display: flex;
flex-direction: column;
}
main {
flex: 1;
}
</style>

View File

@@ -0,0 +1,212 @@
/**
* Blog API utilities
* Helper functions for fetching blog posts and categories from Payload CMS
*/
interface PostImage {
url: string
alt?: string
}
export interface Category {
id: string
title: string
slug: string
backgroundColor?: string
textColor?: string
nameEn?: string
}
export interface Post {
id: string
title: string
slug: string
heroImage?: PostImage | null
ogImage?: PostImage | null
excerpt?: string | null
content?: any
categories?: Category[] | null
relatedPosts?: Post[] | null
publishedAt?: string | null
createdAt?: string | null
updatedAt?: string | null
status?: string
meta?: {
title?: string
description?: string
image?: PostImage
}
}
export interface BlogResponse {
docs: Post[]
totalDocs: number
totalPages: number
page: number
hasPreviousPage: boolean
hasNextPage: boolean
}
// Use production CMS API (enchun-cms.anlstudio.cc) for all environments
// Production CMS has 35+ published posts with proper slugs
const PAYLOAD_API_URL = 'https://enchun-cms.anlstudio.cc/api'
/**
* Fetch published posts from Payload CMS
*/
export async function fetchPosts(
page = 1,
limit = 12,
categorySlug?: string | null
): Promise<BlogResponse> {
try {
// Build query parameters
const params = new URLSearchParams()
if (categorySlug) {
// For category filtering, we need to filter by category
params.append('where[categories][slug][equals]', categorySlug)
}
// Always filter by published status (use _status for Payload's internal status)
params.append('where[_status][equals]', 'published')
params.append('sort', '-publishedAt')
params.append('limit', limit.toString())
params.append('page', page.toString())
params.append('depth', '1')
const response = await fetch(`${PAYLOAD_API_URL}/posts?${params}`)
if (!response.ok) {
throw new Error(`Failed to fetch posts: ${response.statusText}`)
}
const data = await response.json()
return data as BlogResponse
} catch (error) {
console.error('Error fetching posts:', error)
return {
docs: [],
totalDocs: 0,
totalPages: 0,
page: 1,
hasPreviousPage: false,
hasNextPage: false
}
}
}
/**
* Fetch a single post by slug
*/
export async function fetchPostBySlug(slug: string): Promise<Post | 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}/posts?${params}`)
if (!response.ok) {
throw new Error(`Failed to fetch post: ${response.statusText}`)
}
const data = await response.json()
return data.docs?.[0] || null
} catch (error) {
console.error('Error fetching post by slug:', error)
return null
}
}
/**
* Fetch all categories
*/
export async function fetchCategories(): Promise<Category[]> {
try {
const response = await fetch(`${PAYLOAD_API_URL}/categories?sort=order&limit=20&depth=0`)
if (!response.ok) {
throw new Error(`Failed to fetch categories: ${response.statusText}`)
}
const data = await response.json()
return data.docs || []
} catch (error) {
console.error('Error fetching categories:', error)
return []
}
}
/**
* Fetch category by slug
*/
export async function fetchCategoryBySlug(slug: string): Promise<Category | null> {
try {
const response = await fetch(`${PAYLOAD_API_URL}/categories?where[slug][equals]=${slug}&depth=0`)
if (!response.ok) {
throw new Error(`Failed to fetch category: ${response.statusText}`)
}
const data = await response.json()
return data.docs?.[0] || null
} catch (error) {
console.error('Error fetching category by slug:', error)
return null
}
}
/**
* Fetch related posts (same category, excluding current post)
*/
export async function fetchRelatedPosts(
currentPostId: string,
categorySlugs: string[],
limit = 3
): Promise<Post[]> {
try {
if (categorySlugs.length === 0) return []
// Use a simpler approach - fetch published posts and filter
const params = new URLSearchParams()
params.append('where[_status][equals]', 'published')
params.append('limit', '20') // Fetch more to filter client-side
params.append('sort', '-publishedAt')
params.append('depth', '1')
const response = await fetch(`${PAYLOAD_API_URL}/posts?${params}`)
if (!response.ok) {
throw new Error(`Failed to fetch related posts: ${response.statusText}`)
}
const data = await response.json()
const allPosts = data.docs || []
// Filter: same category, exclude current, limit
return allPosts
.filter((post: Post) => post.id !== currentPostId)
.filter((post: Post) =>
post.categories?.some((cat: Category) => categorySlugs.includes(cat.slug))
)
.slice(0, limit)
} catch (error) {
console.error('Error fetching related posts:', error)
return []
}
}
/**
* Format date in Traditional Chinese
*/
export function formatDate(dateStr: string | null | undefined): string {
if (!dateStr) return ''
const date = new Date(dateStr)
return date.toLocaleDateString('zh-TW', {
year: 'numeric',
month: 'long',
day: 'numeric'
})
}

View File

@@ -0,0 +1,169 @@
// Home Page API Service
// Fetches data from Payload CMS for the homepage
export interface ServiceFeature {
icon?: string
title?: string
description?: string
link?: {
url?: string
}
}
interface PortfolioSection {
headline?: string
subheadline?: string
itemsToShow?: number
}
interface CTASection {
headline?: string
description?: string
buttonText?: string
buttonLink?: string
}
export interface HomeData {
heroHeadline?: string
heroSubheadline?: string
heroDesktopVideo?: {
url?: string
filename?: string
alt?: string
sizes?: {
thumbnail?: {
url?: string
}
}
}
heroMobileVideo?: {
url?: string
filename?: string
alt?: string
}
heroFallbackImage?: {
url?: string
filename?: string
alt?: string
}
heroLogo?: {
url?: string
filename?: string
alt?: string
}
serviceFeatures?: ServiceFeature[]
portfolioSection?: PortfolioSection
ctaSection?: CTASection
}
export interface ServiceItem {
icon?: string
title?: string
description?: string
link?: {
url?: string
}
}
export interface PortfolioItem {
id: string
title: string
slug: string
url?: string
image?: {
url?: string
alt?: string
filename?: string
sizes?: {
thumbnail?: {
url?: string
}
}
}
description?: string
websiteType?: string
tags?: Array<{ tag?: string }>
}
// Get API base URL
function getApiBaseUrl(): string {
if (typeof process !== 'undefined' && process.env?.PAYLOAD_CMS_URL) {
return process.env.PAYLOAD_CMS_URL
}
return import.meta.env.PUBLIC_PAYLOAD_CMS_URL || 'https://enchun-admin.anlstudio.cc'
}
const PAYLOAD_URL = getApiBaseUrl()
const PAYLOAD_API_KEY = import.meta.env.PAYLOAD_CMS_API_KEY
// Build common headers
function getHeaders(): HeadersInit {
const headers: HeadersInit = {
'Content-Type': 'application/json',
}
if (PAYLOAD_API_KEY) {
headers['Authorization'] = `Bearer ${PAYLOAD_API_KEY}`
}
return headers
}
// Fetch Home global data
export async function getHomeData(draft = false): Promise<HomeData | null> {
try {
const draftParam = draft ? '?draft=true' : ''
const response = await fetch(`${PAYLOAD_URL}/api/globals/home${draftParam}`, {
headers: getHeaders(),
})
if (!response.ok) {
console.error('Failed to fetch home data:', response.status, response.statusText)
return null
}
const data = await response.json()
return data
} catch (error) {
console.error('Error fetching home data:', error)
return null
}
}
// Fetch Portfolio items for preview
export async function getPortfolioPreview(limit = 3): Promise<PortfolioItem[]> {
try {
const response = await fetch(
`${PAYLOAD_URL}/api/portfolio?sort=-updatedAt&limit=${limit}&depth=1`,
{
headers: getHeaders(),
}
)
if (!response.ok) {
console.error('Failed to fetch portfolio items:', response.status)
return []
}
const data = await response.json()
return data.docs || []
} catch (error) {
console.error('Error fetching portfolio items:', error)
return []
}
}
// Combined fetch for homepage
export async function getHomepageData(draft = false): Promise<{
home: HomeData | null
portfolioItems: PortfolioItem[]
}> {
// First fetch home data to get the itemsToShow value
const home = await getHomeData(draft)
const limit = home?.portfolioSection?.itemsToShow || 3
// Then fetch portfolio items with the correct limit
const portfolioItems = await getPortfolioPreview(limit)
return { home, portfolioItems }
}

View File

@@ -0,0 +1,139 @@
/**
* Portfolio API utilities
* Helper functions for fetching portfolio items from Payload CMS
*/
interface PortfolioImage {
url: string
alt?: string
}
export interface PortfolioTag {
id: string
tag: string
}
export interface PortfolioItem {
id: string
title: string
slug: string
url?: string | null
image?: PortfolioImage | null
description?: string | null
websiteType?: 'corporate' | 'ecommerce' | 'landing' | 'brand' | 'other' | null
tags?: PortfolioTag[] | null
createdAt?: string | null
updatedAt?: string | null
}
export interface PortfolioResponse {
docs: PortfolioItem[]
totalDocs: number
totalPages: number
page: number
hasPreviousPage: boolean
hasNextPage: boolean
}
// Use production CMS API (enchun-cms.anlstudio.cc) for all environments
const PAYLOAD_API_URL = 'https://enchun-cms.anlstudio.cc/api'
/**
* Fetch all portfolio items
*/
export async function fetchPortfolios(
page = 1,
limit = 100
): Promise<PortfolioResponse> {
try {
const params = new URLSearchParams({
sort: '-createdAt',
limit: limit.toString(),
page: page.toString(),
depth: '1'
})
const response = await fetch(`${PAYLOAD_API_URL}/portfolio?${params}`)
if (!response.ok) {
throw new Error(`Failed to fetch portfolios: ${response.statusText}`)
}
const data = await response.json()
return data as PortfolioResponse
} catch (error) {
console.error('Error fetching portfolios:', error)
return {
docs: [],
totalDocs: 0,
totalPages: 0,
page: 1,
hasPreviousPage: false,
hasNextPage: false
}
}
}
/**
* Fetch a single portfolio item by slug
*/
export async function fetchPortfolioBySlug(slug: string): Promise<PortfolioItem | null> {
try {
const response = await fetch(`${PAYLOAD_API_URL}/portfolio?where[slug][equals]=${slug}&depth=1`)
if (!response.ok) {
throw new Error(`Failed to fetch portfolio: ${response.statusText}`)
}
const data = await response.json()
return data.docs?.[0] || null
} catch (error) {
console.error('Error fetching portfolio by slug:', error)
return null
}
}
/**
* Get website type label in Traditional Chinese
*/
export function getWebsiteTypeLabel(type: string | null | undefined): string {
const labels: Record<string, string> = {
corporate: '企業官網',
ecommerce: '電商網站',
landing: '活動頁面',
brand: '品牌網站',
other: '其他'
}
return labels[type || ''] || '其他'
}
/**
* Get all tags from portfolio items
*/
export function getAllTags(portfolios: PortfolioItem[]): string[] {
const tagSet = new Set<string>()
portfolios.forEach(item => {
item.tags?.forEach(tagObj => {
tagSet.add(tagObj.tag)
})
})
return Array.from(tagSet).sort()
}
/**
* Filter portfolios by website type
*/
export function filterByType(portfolios: PortfolioItem[], type: string): PortfolioItem[] {
if (!type || type === 'all') return portfolios
return portfolios.filter(item => item.websiteType === type)
}
/**
* Filter portfolios by tag
*/
export function filterByTag(portfolios: PortfolioItem[], tag: string): PortfolioItem[] {
if (!tag) return portfolios
return portfolios.filter(item =>
item.tags?.some(tagObj => tagObj.tag === tag)
)
}

View File

@@ -0,0 +1,221 @@
/**
* Tests for serializeLexical utility
*/
import { describe, it, expect } from 'vitest'
import { serializeLexical } from './serializeLexical'
describe('serializeLexical', () => {
it('should return empty string for null input', async () => {
const result = await serializeLexical(null)
expect(result).toBe('')
})
it('should return empty string for undefined input', async () => {
const result = await serializeLexical(undefined)
expect(result).toBe('')
})
it('should return empty string for empty string', async () => {
const result = await serializeLexical('')
expect(result).toBe('')
})
it('should return plain text as-is', async () => {
const result = await serializeLexical('Hello world')
expect(result).toBe('Hello world')
})
it('should return HTML as-is', async () => {
const html = '<p>Hello <strong>world</strong></p>'
const result = await serializeLexical(html)
expect(result).toBe(html)
})
it('should convert Lexical JSON to HTML - simple paragraph', async () => {
const lexicalData = {
root: {
type: 'root',
children: [
{
type: 'paragraph',
children: [
{
type: 'text',
text: 'Hello world'
}
]
}
]
}
}
const result = await serializeLexical(lexicalData)
expect(result).toContain('Hello world')
expect(result).toContain('<p>')
expect(result).toContain('</p>')
})
it('should convert Lexical JSON with heading', async () => {
const lexicalData = {
root: {
type: 'root',
children: [
{
type: 'heading',
tag: 'h2',
children: [
{
type: 'text',
text: 'Title'
}
]
}
]
}
}
const result = await serializeLexical(lexicalData)
expect(result).toContain('<h2>')
expect(result).toContain('Title')
expect(result).toContain('</h2>')
})
it('should convert Lexical JSON with bold text', async () => {
const lexicalData = {
root: {
type: 'root',
children: [
{
type: 'paragraph',
children: [
{
type: 'text',
text: 'Hello',
bold: true
}
]
}
]
}
}
const result = await serializeLexical(lexicalData)
expect(result).toContain('<strong>')
expect(result).toContain('Hello')
expect(result).toContain('</strong>')
})
it('should convert Lexical JSON with link', async () => {
const lexicalData = {
root: {
type: 'root',
children: [
{
type: 'paragraph',
children: [
{
type: 'link',
url: 'https://example.com',
children: [
{
type: 'text',
text: 'Click here'
}
]
}
]
}
]
}
}
const result = await serializeLexical(lexicalData)
expect(result).toContain('<a href="https://example.com"')
expect(result).toContain('Click here')
expect(result).toContain('</a>')
})
it('should handle stringified Lexical JSON', async () => {
const lexicalString = JSON.stringify({
root: {
type: 'root',
children: [
{
type: 'paragraph',
children: [
{
type: 'text',
text: 'Test content'
}
]
}
]
}
})
const result = await serializeLexical(lexicalString)
expect(result).toContain('Test content')
})
it('should handle array input gracefully', async () => {
const result = await serializeLexical(['not', 'valid'])
expect(result).toBe('')
})
it('should convert list nodes', async () => {
const lexicalData = {
root: {
type: 'root',
children: [
{
type: 'list',
listType: 'bullet',
children: [
{
type: 'listitem',
children: [
{
type: 'text',
text: 'Item 1'
}
]
}
]
}
]
}
}
const result = await serializeLexical(lexicalData)
expect(result).toContain('<ul>')
expect(result).toContain('<li>')
expect(result).toContain('Item 1')
expect(result).toContain('</li>')
expect(result).toContain('</ul>')
})
it('should handle blockquote', async () => {
const lexicalData = {
root: {
type: 'root',
children: [
{
type: 'quote',
children: [
{
type: 'text',
text: 'Quote text'
}
]
}
]
}
}
const result = await serializeLexical(lexicalData)
expect(result).toContain('<blockquote>')
expect(result).toContain('Quote text')
expect(result).toContain('</blockquote>')
})
})

View File

@@ -0,0 +1,357 @@
/**
* Lexical to HTML Converter
* Converts Payload CMS Lexical editor JSON format to HTML
*
* This implementation uses a simple recursive renderer that works in
* both browser and SSR environments without requiring heavy dependencies.
*/
/**
* Serialized Lexical node types
*/
interface LexicalTextNode {
type: 'text'
text: string
bold?: boolean
italic?: boolean
underline?: boolean
strikethrough?: boolean
code?: boolean
}
interface LexicalHeadingNode {
type: 'heading'
tag: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
children: any[]
}
interface LexicalParagraphNode {
type: 'paragraph'
children: any[]
}
interface LexicalListNode {
type: 'list'
listType: 'bullet' | 'number'
children: any[]
}
interface LexicalListItemNode {
type: 'listitem'
children: any[]
}
interface LexicalLinkNode {
type: 'link'
url: string
title?: string
children: any[]
}
interface LexicalQuoteNode {
type: 'quote'
children: any[]
}
interface LexicalLineBreakNode {
type: 'linebreak'
}
interface LexicalUploadNode {
type: 'upload'
value?: {
url: string
alt?: string
width?: number
height?: number
}
}
interface LexicalBlockNode {
type: 'block'
fields?: {
[key: string]: any
}
}
interface LexicalRootNode {
type: 'root'
children: any[]
}
interface SerializedEditorState {
root?: LexicalRootNode
}
/**
* Convert Lexical JSON to HTML string
*
* @param lexicalData - The Lexical editor state from Payload CMS (object or JSON string)
* @returns HTML string
*/
export async function serializeLexical(lexicalData: unknown): Promise<string> {
// Handle null, undefined, empty values
if (lexicalData === null || lexicalData === undefined) {
return ''
}
// Handle string content (already HTML or JSON string)
if (typeof lexicalData === 'string') {
const trimmed = lexicalData.trim()
// Empty string
if (trimmed === '') {
return ''
}
// Check if it's a JSON object (Lexical format)
if (trimmed.startsWith('{')) {
try {
const parsed = JSON.parse(trimmed) as SerializedEditorState | LexicalRootNode
return renderLexical(parsed)
} catch {
// Not valid JSON, return as-is (might be HTML already)
return trimmed
}
}
// Already HTML or plain text
return trimmed
}
// Handle object (Lexical editor state)
if (typeof lexicalData === 'object') {
// Ensure it's not an array
if (Array.isArray(lexicalData)) {
return ''
}
return renderLexical(lexicalData as SerializedEditorState | LexicalRootNode)
}
return ''
}
/**
* Render Lexical editor state to HTML
*/
function renderLexical(state: SerializedEditorState | LexicalRootNode): string {
if (!state) return ''
// Get root children - handle both formats
let children: any[] = []
if ('root' in state && state.root?.children) {
children = state.root.children
} else if ('children' in state && Array.isArray(state.children)) {
children = state.children
}
return children
.map((child) => renderNode(child))
.filter(Boolean)
.join('')
}
/**
* Render a single Lexical node to HTML
*/
function renderNode(node: any): string {
if (!node || typeof node !== 'object') return ''
const type = node.type
switch (type) {
case 'root':
return (node.children || []).map((child: any) => renderNode(child)).join('')
case 'text':
return renderTextNode(node)
case 'paragraph':
return renderParagraphNode(node)
case 'heading':
return renderHeadingNode(node)
case 'link':
return renderLinkNode(node)
case 'list':
return renderListNode(node)
case 'listitem':
return renderListItemNode(node)
case 'quote':
return renderQuoteNode(node)
case 'linebreak':
return '<br>'
case 'upload':
case 'image':
return renderUploadNode(node)
case 'block':
return renderBlockNode(node)
default:
// Unknown node type - try to render children if available
if (node.children && Array.isArray(node.children)) {
return node.children.map((child: any) => renderNode(child)).join('')
}
return ''
}
}
/**
* Render text node with formatting
*/
function renderTextNode(node: LexicalTextNode): string {
let text = escapeHtml(node.text || '')
// Apply formatting styles (order matters for nested tags)
if (node.code) text = `<code>${text}</code>`
if (node.strikethrough) text = `<s>${text}</s>`
if (node.underline) text = `<u>${text}</u>`
if (node.italic) text = `<em>${text}</em>`
if (node.bold) text = `<strong>${text}</strong>`
return text
}
/**
* Render paragraph node
*/
function renderParagraphNode(node: LexicalParagraphNode): string {
const children = (node.children || []).map((child) => renderNode(child)).join('')
return children ? `<p>${children}</p>` : ''
}
/**
* Render heading node
*/
function renderHeadingNode(node: LexicalHeadingNode): string {
const tag = node.tag || 'h2'
const children = (node.children || []).map((child) => renderNode(child)).join('')
return children ? `<${tag}>${children}</${tag}>` : ''
}
/**
* Render link node
*/
function renderLinkNode(node: LexicalLinkNode): string {
const url = escapeHtml(node.url || '#')
const title = node.title ? ` title="${escapeHtml(node.title)}"` : ''
const children = (node.children || []).map((child) => renderNode(child)).join('')
return children ? `<a href="${url}"${title}>${children}</a>` : ''
}
/**
* Render list node
*/
function renderListNode(node: LexicalListNode): string {
const listType = node.listType === 'number' ? 'ol' : 'ul'
const children = (node.children || []).map((child) => renderNode(child)).join('')
return children ? `<${listType}>${children}</${listType}>` : ''
}
/**
* Render list item node
*/
function renderListItemNode(node: LexicalListItemNode): string {
const children = (node.children || []).map((child) => renderNode(child)).join('')
return children ? `<li>${children}</li>` : ''
}
/**
* Render quote node
*/
function renderQuoteNode(node: LexicalQuoteNode): string {
const children = (node.children || []).map((child) => renderNode(child)).join('')
return children ? `<blockquote>${children}</blockquote>` : ''
}
/**
* Render upload/image node
*/
function renderUploadNode(node: LexicalUploadNode): string {
const url = node.value?.url || ''
const alt = escapeHtml(node.value?.alt || '')
const width = node.value?.width ? ` width="${node.value.width}"` : ''
const height = node.value?.height ? ` height="${node.value.height}"` : ''
if (!url) return ''
return `<img src="${escapeHtml(url)}" alt="${alt}"${width}${height} style="max-width: 100%; height: auto;" loading="lazy" />`
}
/**
* Render block node (for custom blocks like media, banners, etc.)
*/
function renderBlockNode(node: LexicalBlockNode): string {
const fields = node.fields || {}
// Handle media block
if (fields.media) {
const media = fields.media
const url = media.url || ''
const alt = escapeHtml(media.alt || '')
if (url) {
return `<figure><img src="${escapeHtml(url)}" alt="${alt}" style="max-width: 100%; height: auto; border-radius: 8px; margin: 2rem 0;" loading="lazy" /></figure>`
}
}
// Handle other block types
if (fields.blockType === 'banner') {
const content = fields.content || ''
return `<div class="banner">${renderNode({ type: 'root', children: content.root?.children || [] })}</div>`
}
if (fields.blockType === 'code') {
const code = escapeHtml(fields.code || '')
const language = escapeHtml(fields.language || '')
return `<pre><code class="language-${language}">${code}</code></pre>`
}
// Fallback: try to render any nested content
if (fields.content?.root?.children) {
return fields.content.root.children.map((child: any) => renderNode(child)).join('')
}
return ''
}
/**
* Escape HTML special characters
*/
function escapeHtml(text: string): string {
const map: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
}
return text.replace(/[&<>"']/g, (m) => map[m] || m)
}
/**
* Synchronous version for compatibility (not recommended for use with Lexical)
* @deprecated Use serializeLexical instead
*/
export function serializeLexicalSync(lexicalData: unknown): string {
// This is a stub - the actual implementation should be async
// For simple cases, we'll try to return something useful
if (!lexicalData || typeof lexicalData !== 'object') {
return String(lexicalData ?? '')
}
if (typeof lexicalData === 'string') {
return lexicalData
}
// For Lexical objects, return empty since we can't properly render synchronously
return ''
}

View File

@@ -1,31 +1,38 @@
---
import Layout from '../layouts/Layout.astro';
/**
* About Page - 關於恩群數位
* 展示公司特色、服務優勢和與其他公司的差異
*/
import Layout from '../layouts/Layout.astro'
import AboutHero from '../sections/AboutHero.astro'
import FeatureSection from '../sections/FeatureSection.astro'
import ComparisonSection from '../sections/ComparisonSection.astro'
import CTASection from '../sections/CTASection.astro'
// Metadata for SEO
const title = '關於恩群數位 | 專業數位行銷服務團隊'
const description = '恩群數位行銷成立於2018年提供全方位數位行銷服務。我們在地化優先、數據驅動是您最可信赖的數位行銷夥伴。'
---
<Layout>
<section class="about-section">
<div class="container">
<h1>關於恩群</h1>
<div class="prose prose-custom max-w-none">
<p>恩群數位行銷有限公司成立於2018年專注於數位行銷服務。</p>
<p>我們擁有豐富的廣告行銷操作經驗,提供全方位行銷解決方案。</p>
<!-- Add more content from HTML -->
</div>
</div>
</section>
<Layout title={title} description={description}>
<!-- Hero Section -->
<AboutHero />
<!-- Service Features Section -->
<FeatureSection />
<!-- Comparison Section -->
<ComparisonSection />
<!-- CTA Section -->
<CTASection
homeData={{
ctaSection: {
headline: '準備好開始新的旅程了嗎',
description: '讓我們一起為您的品牌打造獨特的數位行銷策略',
buttonText: '聯絡我們',
buttonLink: '/contact-us',
},
}}
/>
</Layout>
<style>
.about-section {
padding: 40px 0;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 20px;
}
h1 {
text-align: center;
margin-bottom: 30px;
}
</style>

View File

@@ -1,85 +1,625 @@
---
import Layout from '../layouts/Layout.astro';
/**
* Contact Page - 聯絡我們頁面
* Pixel-perfect implementation based on Webflow design
* Includes form validation, submission handling, and responsive layout
*/
import Layout from '../layouts/Layout.astro'
// Metadata for SEO
const title = '聯絡我們 | 恩群數位行銷'
const description = '有任何問題嗎?歡迎聯絡恩群數位行銷,我們的專業團隊將竭誠為您服務。電話: 02 5570 0527Email: enchuntaiwan@gmail.com'
---
<Layout>
<section class="contact-section">
<div class="container">
<h1>聯絡我們</h1>
<form id="contact-form">
<div class="form-group">
<label for="name">姓名</label>
<input type="text" id="name" name="name" required />
<Layout title={title} description={description}>
<section class="contact-section" id="contact">
<div class="contactus_wrapper">
<!-- Contact Form Side -->
<div class="contact_form_wrapper">
<h1 class="contact_head">聯絡我們</h1>
<p class="contact_parafraph">
有任何問題嗎?歡迎聯絡我們,我們將竭誠為您服務。
</p>
<p class="contact_reminder">
* 標註欄位為必填
</p>
<!-- Contact Form -->
<form id="contact-form" class="contact_form" novalidate>
<!-- Success Message -->
<div id="form-success" class="w-form-done" style="display: none;">
感謝您的留言!我們會盡快回覆您。
</div>
<!-- Error Message -->
<div id="form-error" class="w-form-fail" style="display: none;">
送出失敗,請稍後再試或直接來電。
</div>
<!-- Form Fields -->
<div class="contact-form-grid">
<!-- Name Field -->
<div class="contact_field_wrapper">
<label for="Name" class="contact_field_name">
姓名 <span>*</span>
</label>
<input
type="text"
id="Name"
name="Name"
class="input_field"
required
minlength="2"
maxlength="256"
placeholder="請輸入您的姓名"
/>
<span class="error-message" id="Name-error"></span>
</div>
<!-- Phone Field -->
<div class="contact_field_wrapper">
<label for="Phone" class="contact_field_name">
聯絡電話 <span>*</span>
</label>
<input
type="tel"
id="Phone"
name="Phone"
class="input_field"
required
placeholder="請輸入您的電話號碼"
/>
<span class="error-message" id="Phone-error"></span>
</div>
</div>
<!-- Email Field -->
<div class="contact_field_wrapper">
<label for="Email" class="contact_field_name">
Email <span>*</span>
</label>
<input
type="email"
id="Email"
name="Email"
class="input_field"
required
placeholder="請輸入您的 Email"
/>
<span class="error-message" id="Email-error"></span>
</div>
<!-- Message Field -->
<div class="contact_field_wrapper">
<label for="Message" class="contact_field_name">
聯絡訊息 <span>*</span>
</label>
<textarea
id="Message"
name="Message"
class="input_field"
minlength="10"
maxlength="5000"
required
placeholder="請輸入您的訊息(至少 10 個字元)"
></textarea>
<span class="error-message" id="Message-error"></span>
</div>
<!-- Submit Button -->
<button type="submit" class="submit-button" id="submit-btn">
<span class="button-text">送出訊息</span>
<span class="button-loading" style="display: none;">送出中...</span>
</button>
</form>
</div>
<!-- Contact Image Side -->
<div class="contact-image">
<div class="image-wrapper">
<img
src="/placeholder-contact.jpg"
alt="聯絡恩群數位"
width="600"
height="400"
/>
</div>
<div class="form-group">
<label for="email">電子郵件</label>
<input type="email" id="email" name="email" required />
<!-- Contact Info Card -->
<div class="contact-info-card">
<h3>聯絡資訊</h3>
<div class="info-item">
<svg class="info-icon" viewBox="0 0 24 24"><path fill="currentColor" d="M6.62 10.79c1.44 2.83 3.76 5.14 6.59 6.59l2.2-2.2c.27-.27.67-.36 1.02-.24 1.12.37 2.33.57 3.57.57.55 0 1.45-.17 2.53-.5.36-.11.74-.47 1.02-.75l2.2-2.2c.27-.27.36-.67.24-1.02-.37-1.12-.57-2.33-.57-3.57 0-.55.17-1.45.5-2.53.36-.11.74.47-1.14.75-1.02zM2.05 21.05c.15.35.48.59.84.59l2.2.73c.36.12.74.12 1.06-.05.13-.07.22-.17.28-.28l2.2-2.2c.27-.27.36-.67.24-1.02-.37-1.12-.57-2.33-.57-3.57 0-.55.17-1.45.5-2.53.36-.11.74.47 1.14.75 1.02L2.05 21.05zM19.95 2.95c-.15-.35-.48-.59-.84-.59l-2.2-.73c-.36-.12-.74-.12-1.06.05-.13.07-.22.17-.28.28l-2.2 2.2c-.27.27-.36.67-.24 1.02.37 1.12.57 2.33.57 3.57 0 .55-.17 1.45-.5 2.53-.36.11-.74-.47-1.14-.75l1.02-1.02 2.2-2.2c.27-.27.67-.36 1.02-.24 1.12.37 2.33.57 3.57.57.55 0 1.45-.17 2.53-.5.36-.11.74-.47 1.02-.75l2.2-2.2c.27-.27.36-.67.24-1.02-.37-1.12-.57-2.33-.57-3.57 0-.55.17-1.45.5-2.53z"/></svg>
<span>諮詢電話: 02 5570 0527</span>
</div>
<div class="info-item">
<svg class="info-icon" viewBox="0 0 24 24"><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-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/></svg>
<span>Email: <a href="mailto:enchuntaiwan@gmail.com">enchuntaiwan@gmail.com</a></span>
</div>
</div>
<div class="form-group">
<label for="message">訊息</label>
<textarea id="message" name="message" required></textarea>
</div>
<button type="submit">送出</button>
</form>
<div class="contact-info">
<p>諮詢電話: 02 5570 0527</p>
<p>電子郵件: <a href="mailto:enchuntaiwan@gmail.com">enchuntaiwan@gmail.com</a></p>
</div>
</div>
</section>
</Layout>
<script>
// Basic form handler - would integrate with Cloudflare Worker
document.getElementById('contact-form')?.addEventListener('submit', async (e) => {
e.preventDefault();
// Submit to Cloudflare Worker
alert('Form submitted (placeholder)');
});
</script>
<style>
/* Contact Section Styles - Pixel-perfect from Webflow */
.contact-section {
padding: 40px 0;
padding: 4rem 0;
background: var(--color-background);
scroll-margin-top: 80px;
}
.container {
max-width: 800px;
.contactus_wrapper {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 3rem;
align-items: center;
max-width: 1200px;
margin: 0 auto;
padding: 0 20px;
}
h1 {
text-align: center;
margin-bottom: 30px;
}
.form-group {
margin-bottom: 20px;
}
label {
display: block;
margin-bottom: 5px;
}
input, textarea {
/* Form Side */
.contact_form_wrapper {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
}
textarea {
height: 150px;
/* Headings */
.contact_head {
font-size: 2.5rem;
font-weight: 700;
line-height: 1.2;
color: var(--color-text-primary);
margin-bottom: 1rem;
}
button {
background: #007bff;
.contact_parafraph {
font-size: 1.125rem;
font-weight: 400;
line-height: 1.6;
color: var(--color-gray-600);
margin-bottom: 0.5rem;
}
.contact_reminder {
font-size: 0.875rem;
font-style: italic;
color: var(--color-text-muted);
margin-bottom: 2rem;
}
/* Form Container */
.contact_form {
padding: 2rem;
background: var(--color-surface);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
}
/* Form Grid */
.contact-form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
margin-bottom: 1.5rem;
}
/* Field Wrapper */
.contact_field_wrapper {
display: flex;
flex-direction: column;
margin-bottom: 1.5rem;
}
.contact_field_wrapper:last-child {
margin-bottom: 2rem;
}
/* Field Label */
.contact_field_name {
font-size: 0.875rem;
font-weight: 600;
color: var(--color-text-primary);
margin-bottom: 0.5rem;
display: block;
}
.contact_field_name span {
color: var(--color-primary);
}
/* Input Fields */
.input_field {
width: 100%;
padding: 0.75rem 1rem;
border: 1px solid var(--color-border);
border-radius: var(--radius);
font-size: 1rem;
line-height: 1.5;
background: var(--color-background);
transition: all var(--transition-fast);
font-family: inherit;
color: var(--color-text-primary);
}
.input_field:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(56, 152, 236, 0.1);
transform: translateY(-1px);
}
.input_field::placeholder {
color: var(--color-text-muted);
}
.input_field.error {
border-color: #dc3545 !important;
background-color: #fff5f5;
}
#Message {
min-height: 120px;
resize: vertical;
}
/* Error Message */
.error-message {
color: #dc3545;
font-size: 0.875rem;
margin-top: 0.25rem;
display: none;
}
.error-message:not(:empty) {
display: block;
}
/* Submit Button */
.submit-button {
background: var(--color-primary);
color: white;
border: none;
padding: 10px 20px;
border-radius: 4px;
border-radius: var(--radius);
padding: 0.875rem 2rem;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
}
button:hover {
background: #0056b3;
}
.contact-info {
margin-top: 40px;
transition: all var(--transition-fast);
text-align: center;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 200px;
width: 100%;
}
</style>
.submit-button:hover:not(:disabled) {
background: var(--color-primary-hover);
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
.submit-button:active:not(:disabled) {
transform: translateY(0);
}
.submit-button:disabled {
background: var(--color-gray-500);
cursor: not-allowed;
opacity: 0.8;
}
/* Form Success/Error Messages */
.w-form-done,
.w-form-fail {
padding: 1rem 1.5rem;
border-radius: var(--radius);
margin-top: 1rem;
text-align: center;
font-weight: 500;
animation: slideUp 0.3s ease-out;
}
.w-form-done {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.w-form-fail {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(1rem);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Image Side */
.contact-image {
display: flex;
flex-direction: column;
gap: 2rem;
justify-content: center;
align-items: center;
}
.image-wrapper {
width: 100%;
border-radius: var(--radius-lg);
overflow: hidden;
box-shadow: var(--shadow-md);
background: var(--color-gray-100);
aspect-ratio: 3/2;
}
.image-wrapper img {
width: 100%;
height: 100%;
object-fit: cover;
}
/* Contact Info Card */
.contact-info-card {
width: 100%;
padding: 1.5rem;
background: white;
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
}
.contact-info-card h3 {
font-size: 1.25rem;
font-weight: 600;
color: var(--color-text-primary);
margin-bottom: 1rem;
}
.info-item {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1rem;
font-size: 0.95rem;
color: var(--color-text-secondary);
}
.info-icon {
width: 20px;
height: 20px;
flex-shrink: 0;
color: var(--color-primary);
}
.info-item a {
color: var(--color-primary);
text-decoration: none;
}
.info-item a:hover {
text-decoration: underline;
}
/* Responsive Adjustments */
@media (max-width: 991px) {
.contactus_wrapper {
grid-template-columns: 1fr;
gap: 2rem;
}
.contact_head {
font-size: 2rem;
}
}
@media (max-width: 767px) {
.contact-section {
padding: 2rem 0;
}
.contact_form {
padding: 1.5rem;
}
.contact-form-grid {
grid-template-columns: 1fr;
gap: 1rem;
}
.submit-button {
min-width: 160px;
}
}
@media (max-width: 479px) {
.contact_form {
padding: 1rem;
}
.contact_head {
font-size: 1.5rem;
}
}
</style>
<script>
// Form validation and submission handler
function initContactForm() {
const form = document.getElementById('contact-form') as HTMLFormElement
const submitBtn = document.getElementById('submit-btn') as HTMLButtonElement
const successMsg = document.getElementById('form-success') as HTMLElement
const errorMsg = document.getElementById('form-error') as HTMLElement
if (!form) return
// Validation patterns
const patterns = {
Name: /^[\u4e00-\u9fa5a-zA-Z\s]{2,256}$/,
Phone: /^[0-9\-\s\+]{6,20}$/,
Email: /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/,
Message: /^.{10,5000}$/
}
// Validation function
function validateField(input: HTMLInputElement | HTMLTextAreaElement): boolean {
const name = input.name
const value = input.value.trim()
const errorSpan = document.getElementById(`${name}-error`) as HTMLElement
if (!input.hasAttribute('required') && !value) {
clearError(input, errorSpan)
return true
}
let isValid = true
let errorMessage = ''
// Required check
if (input.hasAttribute('required') && !value) {
isValid = false
errorMessage = '此欄位為必填'
}
// Pattern validation
else if (patterns[name as keyof typeof patterns] && !patterns[name as keyof typeof patterns].test(value)) {
isValid = false
switch (name) {
case 'Name':
errorMessage = '請輸入有效的姓名(至少 2 個字元)'
break
case 'Phone':
errorMessage = '請輸入有效的電話號碼'
break
case 'Email':
errorMessage = '請輸入有效的 Email 格式'
break
case 'Message':
errorMessage = '訊息至少需要 10 個字元'
break
}
}
// Show/hide error
if (!isValid) {
input.classList.add('error')
if (errorSpan) {
errorSpan.textContent = errorMessage
errorSpan.style.display = 'block'
}
} else {
clearError(input, errorSpan)
}
return isValid
}
function clearError(input: HTMLInputElement | HTMLTextAreaElement, errorSpan: HTMLElement) {
input.classList.remove('error')
if (errorSpan) {
errorSpan.textContent = ''
errorSpan.style.display = 'none'
}
}
// Real-time validation on blur
form.querySelectorAll('input, textarea').forEach((field) => {
field.addEventListener('blur', () => {
validateField(field as HTMLInputElement | HTMLTextAreaElement)
})
field.addEventListener('input', () => {
const input = field as HTMLInputElement | HTMLTextAreaElement
if (input.classList.contains('error')) {
validateField(input)
}
})
})
// Form submission
form.addEventListener('submit', async (e) => {
e.preventDefault()
// Validate all fields
const inputs = form.querySelectorAll('input, textarea') as NodeListOf<HTMLInputElement | HTMLTextAreaElement>
let isFormValid = true
inputs.forEach((input) => {
if (!validateField(input)) {
isFormValid = false
}
})
if (!isFormValid) {
// Scroll to first error
const firstError = form.querySelector('.input_field.error') as HTMLElement
if (firstError) {
firstError.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
return
}
// Show loading state
submitBtn.disabled = true
const buttonText = submitBtn.querySelector('.button-text') as HTMLElement
const buttonLoading = submitBtn.querySelector('.button-loading') as HTMLElement
if (buttonText) buttonText.style.display = 'none'
if (buttonLoading) buttonLoading.style.display = 'inline'
// Hide previous messages
successMsg.style.display = 'none'
errorMsg.style.display = 'none'
// Collect form data
const formData = new FormData(form)
const data = {
name: formData.get('Name'),
phone: formData.get('Phone'),
email: formData.get('Email'),
message: formData.get('Message')
}
try {
// Submit to backend (via API proxy)
const response = await fetch('/api/contact', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
})
if (response.ok) {
// Success
successMsg.style.display = 'block'
form.reset()
window.scrollTo({ top: 0, behavior: 'smooth' })
} else {
throw new Error('Submission failed')
}
} catch (error) {
console.error('Form submission error:', error)
errorMsg.style.display = 'block'
} finally {
// Reset button state
submitBtn.disabled = false
if (buttonText) buttonText.style.display = 'inline'
if (buttonLoading) buttonLoading.style.display = 'none'
}
})
}
// Initialize when DOM is ready
document.addEventListener('DOMContentLoaded', initContactForm)
if (document.readyState !== 'loading') {
initContactForm()
}
</script>

View File

@@ -1,66 +1,56 @@
---
import Layout from "../layouts/Layout.astro";
import VideoHero from "../components/videoHero.astro";
/**
* Homepage - Main landing page
* Fetches data from Payload CMS and renders all sections
* Pixel-perfect implementation based on Webflow design
*/
import Layout from '../layouts/Layout.astro'
import HeroSection from '../sections/HeroSection.astro'
import PainpointSection from '../sections/PainpointSection.astro'
import StatisticsSection from '../sections/StatisticsSection.astro'
// ServiceFeatures - REMOVED: Services should only appear in Footer, not as a main section
// import ServiceFeatures from '../sections/ServiceFeatures.astro'
import ClientCasesSection from '../sections/ClientCasesSection.astro'
import PortfolioPreview from '../sections/PortfolioPreview.astro'
import CTASection from '../sections/CTASection.astro'
import { getHomepageData } from '../lib/api/home'
import type { HomeData, PortfolioItem } from '../lib/api/home'
// Fetch data from Payload CMS
let homeData: HomeData | null = null
let portfolioItems: PortfolioItem[] = []
try {
const data = await getHomepageData()
homeData = data.home
portfolioItems = data.portfolioItems || []
} catch (error) {
console.error('Failed to fetch homepage data:', error)
}
// Metadata for SEO
const title = '恩群數位行銷 | 專業數位行銷服務'
const description = '恩群數位行銷提供專業的 Google Ads、社群代操、論壇行銷、網站設計等全方位數位行銷服務協助您的品牌在數位時代脫穎而出。'
---
<Layout>
<!-- Hero Section -->
<VideoHero
desktopVideo="/video/enchun-hero-background-video.mp4"
mobileVideo="/video/enchun-hero-background-video.webm"
logo="/enchun-logo-full.svg"
header="創造企業更多發展的可能性\n是我們的使命"
subheader="Its our destiny to create possibilities for your business."
/>
<Layout title={title} description={description}>
<!-- Hero Section -->
<HeroSection homeData={homeData} />
<!-- Services Section -->
<section class="py-16 bg-surface">
<div class="max-w-6xl mx-auto px-4">
<h2 class="text-3xl font-bold text-center text-text mb-12">
我們的服務
</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
<div class="bg-white p-6 rounded-lg shadow-md">
<h3 class="text-xl font-semibold text-primary mb-4">
Google Ads
</h3>
<p class="text-text">
專業的Google廣告投放服務幫助您的品牌觸及目標客戶。
</p>
</div>
<div class="bg-white p-6 rounded-lg shadow-md">
<h3 class="text-xl font-semibold text-primary mb-4">
社群行銷
</h3>
<p class="text-text">
全方位社群媒體經營,從內容策劃到數據分析,一站式服務。
</p>
</div>
<div class="bg-white p-6 rounded-lg shadow-md">
<h3 class="text-xl font-semibold text-primary mb-4">
網站設計
</h3>
<p class="text-text">
現代化響應式網站設計,提升品牌形象和用戶體驗。
</p>
</div>
</div>
</div>
</section>
<!-- Painpoint Section (您的困擾) -->
<PainpointSection homeData={homeData} />
<!-- About Section -->
<section class="py-16">
<div class="max-w-6xl mx-auto px-4 text-center">
<h2 class="text-3xl font-bold text-text mb-8">關於恩群</h2>
<p class="text-lg text-text max-w-3xl mx-auto">
恩群數位行銷團隊擁有豐富的數位行銷經驗,我們相信在地化優先、高投資轉換率、數據優先、關係優於銷售。
每一個客戶都是我們重視的夥伴,我們珍惜與客戶的合作關係。
</p>
<a
href="/about-enchun"
class="inline-block mt-6 bg-primary text-white px-6 py-3 rounded-lg font-semibold hover:bg-primary/90 transition-colors"
>了解更多</a
>
</div>
</section>
<!-- Statistics Section (數據統計) -->
<StatisticsSection homeData={homeData} />
<!-- Service Features - REMOVED: Services should only appear in Footer, not as a main section -->
<!-- Client Cases Section (客戶案例) -->
<ClientCasesSection homeData={homeData} />
<!-- Portfolio Preview Section -->
<PortfolioPreview homeData={homeData} portfolioItems={portfolioItems} />
<!-- CTA Section -->
<CTASection homeData={homeData} />
</Layout>

View File

@@ -1,43 +1,24 @@
---
import Layout from '../layouts/Layout.astro';
/**
* Marketing Solutions Page - 行銷方案頁面
* Pixel-perfect implementation based on Webflow design
*/
import Layout from '../layouts/Layout.astro'
import SolutionsHero from '../sections/SolutionsHero.astro'
import ServicesList from '../sections/ServicesList.astro'
// Metadata for SEO
const title = '行銷解決方案 | 恩群數位行銷'
const description = '恩群數位行銷提供全方位的數位行銷服務,包括 Google Ads、社群代操、論壇行銷、網紅行銷、網站設計等協助您的品牌在數位時代脫穎而出。'
---
<Layout>
<section class="solutions-section">
<div class="container">
<h1>行銷方案</h1>
<p>我們提供多樣化的行銷方案,幫助您的品牌深入人心。</p>
<ul>
<li>Google 商家關鍵字</li>
<li>Google Ads</li>
<li>社群代操</li>
<li>論壇行銷</li>
<li>網紅行銷</li>
<li>形象影片</li>
</ul>
</div>
</section>
<Layout title={title} description={description}>
<!-- Hero Section -->
<SolutionsHero
title="行銷解決方案"
subtitle="提供全方位的數位行銷服務,協助您的品牌在數位時代脫穎而出"
/>
<!-- Services List -->
<ServicesList />
</Layout>
<style>
.solutions-section {
padding: 40px 0;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 20px;
}
h1 {
text-align: center;
margin-bottom: 30px;
}
ul {
list-style: none;
padding: 0;
}
li {
padding: 10px 0;
border-bottom: 1px solid #eee;
}
</style>

View File

@@ -1,61 +1,289 @@
---
import Layout from '../layouts/Layout.astro';
/**
* News/Blog Listing Page - 行銷放大鏡
* URL: /news
* Displays all published blog posts from Payload CMS
*/
import Layout from '../layouts/Layout.astro'
import ArticleCard from '../components/blog/ArticleCard.astro'
import CategoryFilter from '../components/blog/CategoryFilter.astro'
import { fetchPosts, fetchCategories } from '../lib/api/blog'
// Placeholder for blog posts - would fetch from CMS
const posts = [
{ slug: 'en-qun-shu-wei-zui-xin-gong-gao', title: '恩群數位最新公告', date: '2023-01-01' },
{ slug: 'google-xiao-xue-tang', title: 'Google小學堂', date: '2023-01-02' },
// Add more
];
// Metadata for SEO
const title = '行銷放大鏡 | 恩群數位行銷'
const description = '閱讀恩群數位的專業行銷文章掌握最新的數位行銷趨勢、社群經營技巧、Google 廣告策略等實用內容。'
// Pagination settings
const PAGE_SIZE = 12
// Get query parameters
const url = Astro.url
const pageParam = url.searchParams.get('page')
const page = Math.max(1, parseInt(pageParam || '1'))
// Fetch posts from Payload CMS
const postsData = await fetchPosts(page, PAGE_SIZE)
const posts = postsData.docs
const totalPages = postsData.totalPages
const hasPreviousPage = postsData.hasPreviousPage
const hasNextPage = postsData.hasNextPage
// Fetch categories
const categories = await fetchCategories()
// Filter out categories without slugs and the legacy "文章分類" container
const validCategories = categories.filter(c =>
c.slug && c.slug !== 'wen-zhang-fen-lei'
)
---
<Layout>
<section class="news-section">
<Layout title={title} description={description}>
<!-- Blog Hero Section -->
<section class="blog-hero" aria-labelledby="blog-heading">
<div class="container">
<h1>行銷放大鏡</h1>
<div class="posts-grid">
{posts.map(post => (
<article class="post-card">
<h2><a href={`/wen-zhang-fen-lei/${post.slug}`}>{post.title}</a></h2>
<p>{post.date}</p>
</article>
))}
<div class="section_header_w_line">
<div class="divider_line"></div>
<div class="header_subtitle">
<h2 id="blog-heading" class="header_subtitle_head">行銷放大鏡</h2>
<p class="header_subtitle_paragraph">Marketing Blog</p>
</div>
<div class="divider_line"></div>
</div>
</div>
</section>
<!-- Category Filter -->
<section class="filter-section">
<div class="container">
<CategoryFilter categories={validCategories} />
</div>
</section>
<!-- Blog Posts Grid -->
<section class="blog-section" aria-label="文章列表">
<div class="container">
{
posts.length > 0 ? (
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-10 w-full max-w-[1200px] mx-auto">
{posts.map((post) => (
<ArticleCard post={post} />
))}
</div>
) : (
<div class="empty-state">
<p class="empty-text">暫無文章</p>
</div>
)
}
<!-- Pagination -->
{
totalPages > 1 && (
<nav class="pagination" aria-label="分頁導航">
<div class="pagination-container">
{
hasPreviousPage && (
<a
href={`?page=${page - 1}`}
class="pagination-link pagination-link-prev"
aria-label="上一頁"
>
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/>
</svg>
上一頁
</a>
)
}
<div class="pagination-info">
<span class="current-page">{page}</span>
<span class="page-separator">/</span>
<span class="total-pages">{totalPages}</span>
</div>
{
hasNextPage && (
<a
href={`?page=${page + 1}`}
class="pagination-link pagination-link-next"
aria-label="下一頁"
>
下一頁
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
<path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/>
</svg>
</a>
)
}
</div>
</nav>
)
}
</div>
</section>
</Layout>
<style>
.news-section {
padding: 40px 0;
/* Blog Hero Section */
.blog-hero {
padding: 60px 20px;
background-color: #ffffff;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 20px;
}
h1 {
.section_header_w_line {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
}
.header_subtitle {
text-align: center;
margin-bottom: 30px;
}
.posts-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 20px;
.header_subtitle_head {
color: var(--color-enchunblue, #23608c);
font-family: "Noto Sans TC", sans-serif;
font-weight: 700;
font-size: 2rem;
margin-bottom: 8px;
}
.post-card {
border: 1px solid #ddd;
padding: 20px;
.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);
}
/* Filter Section */
.filter-section {
background-color: #ffffff;
padding-bottom: 20px;
}
/* Blog Section */
.blog-section {
padding: 40px 20px 60px;
background-color: #f8f9fa;
min-height: 60vh;
}
/* Empty State */
.empty-state {
text-align: center;
padding: 80px 20px;
}
.empty-text {
font-size: 1.125rem;
color: var(--color-gray-500, #999);
}
/* Pagination */
.pagination {
padding-top: 20px;
}
.pagination-container {
display: flex;
align-items: center;
justify-content: center;
gap: 24px;
}
.pagination-link {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
background: #ffffff;
border: 2px solid var(--color-gray-300, #e0e0e0);
border-radius: 8px;
}
.post-card h2 {
margin-top: 0;
}
.post-card a {
color: var(--color-gray-700, #555);
font-family: "Noto Sans TC", sans-serif;
font-size: 0.9375rem;
font-weight: 500;
text-decoration: none;
color: #333;
transition: all 0.25s ease;
}
.post-card a:hover {
color: #007bff;
.pagination-link:hover {
border-color: var(--color-enchunblue, #23608c);
color: var(--color-enchunblue, #23608c);
background: rgba(35, 96, 140, 0.05);
}
</style>
.pagination-info {
display: flex;
align-items: center;
gap: 8px;
font-family: "Quicksand", sans-serif;
font-size: 1rem;
color: var(--color-gray-600, #666);
}
.current-page {
font-weight: 700;
color: var(--color-enchunblue, #23608c);
}
.page-separator {
color: var(--color-gray-400, #ccc);
}
/* Responsive Adjustments */
@media (max-width: 991px) {
.header_subtitle_head {
font-size: 1.75rem;
}
}
@media (max-width: 767px) {
.blog-hero {
padding: 40px 16px;
}
.section_header_w_line {
flex-wrap: wrap;
gap: 12px;
}
.divider_line {
width: 30px;
}
.blog-section {
padding: 30px 16px 50px;
}
.pagination-container {
gap: 16px;
}
.pagination-link {
padding: 8px 16px;
font-size: 0.875rem;
}
.header_subtitle_head {
font-size: 1.5rem;
}
.header_subtitle_paragraph {
font-size: 0.9375rem;
}
}
</style>

View File

@@ -1,28 +1,181 @@
---
import Layout from '../layouts/Layout.astro';
/**
* Teams Page - 恩群大本營
* 展示工作環境、公司故事和員工福利
* Pixel-perfect implementation based on Webflow design
*/
import Layout from '../layouts/Layout.astro'
import TeamsHero from '../sections/TeamsHero.astro'
import EnvironmentSlider from '../sections/EnvironmentSlider.astro'
import CompanyStory from '../sections/CompanyStory.astro'
import BenefitsSection from '../sections/BenefitsSection.astro'
// Metadata for SEO
const title = '恩群大本營 | 恩群數位行銷'
const description = '加入恩群數位的團隊,享受優質的工作環境與完善的員工福利。我們重視個人的特質發揮,歡迎樂於學習、善於建立關係的你加入我們。'
---
<Layout>
<section class="teams-section">
<div class="container">
<h1>恩群大本營</h1>
<p>認識我們的團隊成員。</p>
<!-- Team members would be listed here -->
<Layout title={title} description={description}>
<!-- Hero Section -->
<TeamsHero />
<!-- Environment Slider Section -->
<EnvironmentSlider />
<!-- Company Story Section -->
<CompanyStory />
<!-- Benefits Section -->
<BenefitsSection />
<!-- CTA Section -->
<section class="section-call4action" aria-labelledby="cta-heading">
<div class="container w-container">
<div class="c4a-grid">
<div class="c4a-content">
<h3 id="cta-heading" class="career-c4a-heading">
以人的成長為優先<br />
創造人的最大價值
</h3>
<p class="career-c4a-paragraph">
在恩群數位裡我們重視個人的特質能夠完全發揮,只要你樂於學習、善於跟人建立關係,並且重要的是你有一個善良的心,恩群數位歡迎你的加入
</p>
</div>
<a
href="https://www.104.com.tw/company/1a2x6bkoaj?jobsource=joblist_r_cust"
target="_blank"
rel="noopener noreferrer"
class="c4a-button"
>
立刻申請面試
</a>
<div class="c4a-bg"></div>
</div>
</div>
</section>
</Layout>
<style>
.teams-section {
padding: 40px 0;
/* CTA Section Styles - Pixel-perfect from Webflow */
.section-call4action {
padding: 80px 20px;
text-align: center;
background-color: var(--color-gray-100, #f8f9fa);
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 20px;
}
h1 {
text-align: center;
margin-bottom: 30px;
.w-container {
max-width: 1200px;
margin: 0 auto;
}
</style>
.c4a-grid {
display: grid;
grid-template-columns: 1fr auto;
gap: 32px;
align-items: center;
position: relative;
}
.c4a-content {
text-align: left;
}
.career-c4a-heading {
font-family: "Noto Sans TC", sans-serif;
font-weight: 600;
font-size: 1.75rem;
color: var(--color-tarawera, #23608c);
margin-bottom: 16px;
line-height: 1.4;
}
.career-c4a-paragraph {
font-family: "Noto Sans TC", sans-serif;
font-weight: 400;
font-size: 1rem;
color: var(--color-gray-600);
line-height: 1.6;
max-width: 500px;
}
.c4a-button {
display: inline-flex;
align-items: center;
justify-content: center;
background-color: var(--color-enchunblue);
color: white;
padding: 16px 32px;
border-radius: var(--radius, 8px);
font-weight: 600;
font-size: 1rem;
text-decoration: none;
transition: all var(--transition-base, 0.3s ease);
white-space: nowrap;
}
.c4a-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);
}
.c4a-bg {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(135deg, rgba(35, 96, 140, 0.05) 0%, rgba(35, 96, 140, 0.02) 100%);
border-radius: var(--radius-lg, 12px);
z-index: -1;
}
/* Responsive Adjustments */
@media (max-width: 991px) {
.section-call4action {
padding: 60px 16px;
}
.c4a-grid {
grid-template-columns: 1fr;
text-align: center;
}
.c4a-content {
text-align: center;
}
.career-c4a-paragraph {
max-width: 100%;
}
.c4a-button {
width: 100%;
max-width: 300px;
}
}
@media (max-width: 767px) {
.section-call4action {
padding: 40px 16px;
}
.career-c4a-heading {
font-size: 1.5rem;
}
.career-c4a-paragraph {
font-size: 0.95rem;
}
.c4a-button {
padding: 14px 24px;
font-size: 0.95rem;
}
}
</style>

View File

@@ -0,0 +1,345 @@
---
/**
* 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

@@ -0,0 +1,244 @@
---
/**
* 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,52 +1,442 @@
---
import Layout from '../../layouts/Layout.astro';
/**
* Portfolio Detail Page - 案例詳情頁
* Pixel-perfect implementation based on Webflow design
*/
import Layout from '../../layouts/Layout.astro'
export async function getStaticPaths() {
// Portfolio slugs from sitemap
const slugs = [
'web-design-project-2',
'web-design-project-3',
'web-design-project-4',
'web-design-project-5'
];
// 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>
return slugs.map(slug => ({
params: { slug },
props: { slug }
}));
<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'],
},
}
const { slug } = Astro.props;
export async function getStaticPaths() {
const slugs = Object.keys(portfolioItems)
// Placeholder content
const project = {
title: 'Web Design Project',
description: 'Project description...',
images: []
};
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>
<section class="project-section">
<Layout title={title} description={description}>
<!-- Breadcrumb -->
<nav class="breadcrumb" aria-label="麵包屑導航">
<div class="container">
<h1>{project.title}</h1>
<p>{project.description}</p>
<!-- Images and details -->
<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>
</section>
</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>
.project-section {
padding: 40px 0;
/* 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;
padding: 0 20px;
}
h1 {
/* Header */
.portfolio-detail-header {
margin-bottom: 40px;
text-align: center;
margin-bottom: 30px;
}
</style>
.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

@@ -1,57 +1,225 @@
---
import Layout from '../layouts/Layout.astro';
/**
* Portfolio Listing Page - 案例分享列表頁
* Pixel-perfect implementation based on Webflow design
*/
import Layout from '../layouts/Layout.astro'
import PortfolioCard from '../components/PortfolioCard.astro'
// Placeholder portfolios
const portfolios = [
{ slug: 'web-design-project-2', title: 'Project 2', description: 'Description...' },
// Add more
];
// Metadata for SEO
const title = '案例分享 | 恩群數位行銷'
const description = '瀏覽恩群數位的成功案例,包括企業官網、電商網站、品牌網站等設計作品。'
// Portfolio items - can be fetched from Payload CMS
const portfolioItems = [
{
slug: 'corporate-website-1',
title: '企業官網設計案例',
description: '為知名製造業打造的現代化企業官網,整合產品展示與新聞發佈功能。',
image: '/placeholder-portfolio-1.jpg',
tags: ['企業官網', '響應式設計'],
},
{
slug: 'ecommerce-site-1',
title: '電商平台建置',
description: 'B2C 電商網站,包含會員系統、購物車、金流整合等完整功能。',
image: '/placeholder-portfolio-2.jpg',
tags: ['電商網站', '金流整合'],
},
{
slug: 'brand-website-1',
title: '品牌形象網站',
description: '以視覺故事為核心的品牌網站,展現品牌獨特價值與理念。',
image: '/placeholder-portfolio-3.jpg',
tags: ['品牌網站', '視覺設計'],
},
{
slug: 'landing-page-1',
title: '活動行銷頁面',
description: '高轉換率的活動頁面設計,有效的 CTA 配置與使用者體驗規劃。',
image: '/placeholder-portfolio-4.jpg',
tags: ['活動頁面', '行銷'],
},
]
---
<Layout>
<section class="portfolio-section">
<Layout title={title} description={description}>
<!-- Portfolio Header -->
<section class="portfolio-header" aria-labelledby="portfolio-heading">
<div class="container">
<h1>網站設計作品</h1>
<div class="portfolio-grid">
{portfolios.map(item => (
<div class="portfolio-item">
<h2><a href={`/webdesign-profolio/${item.slug}`}>{item.title}</a></h2>
<p>{item.description}</p>
</div>
))}
</div>
<h1 id="portfolio-heading" class="portfolio-title">案例分享</h1>
<p class="portfolio-subtitle">Selected Works</p>
<div class="divider-line"></div>
<div class="divider-line"></div>
</div>
</section>
<!-- Portfolio Grid -->
<section class="portfolio-grid-section" aria-label="作品列表">
<ul class="portfolio-grid">
{
portfolioItems.map((item) => (
<PortfolioCard item={item} />
))
}
</ul>
</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-section {
padding: 40px 0;
/* Portfolio Page Styles - Pixel-perfect from Webflow */
/* Header Section */
.portfolio-header {
text-align: center;
padding: 60px 20px;
background-color: #ffffff;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 20px;
}
h1 {
text-align: center;
margin-bottom: 30px;
.portfolio-title {
font-family: "Noto Sans TC", sans-serif;
font-size: 2.5rem;
font-weight: 700;
color: var(--color-enchunblue);
margin-bottom: 8px;
}
.portfolio-subtitle {
font-family: "Quicksand", "Noto Sans TC", sans-serif;
font-size: 1.125rem;
color: var(--color-gray-700);
margin-bottom: 24px;
}
.divider-line {
width: 100px;
height: 2px;
background-color: var(--color-enchunblue);
margin: 0 auto;
}
.divider-line:last-child {
width: 60px;
margin-top: 4px;
}
/* Grid Section */
.portfolio-grid-section {
padding: 0 20px 60px;
background-color: #f8f9fa;
}
.portfolio-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
grid-template-columns: repeat(2, 1fr);
gap: 20px;
max-width: 1200px;
margin: 0 auto;
padding: 0;
list-style: none;
}
.portfolio-item {
border: 1px solid #ddd;
padding: 20px;
border-radius: 8px;
/* CTA Section */
.portfolio-cta {
text-align: center;
padding: 80px 20px;
background-color: #ffffff;
}
.portfolio-item a {
.cta-title {
font-family: "Noto Sans TC", sans-serif;
font-size: 1.75rem;
font-weight: 600;
color: var(--color-enchunblue);
margin-bottom: 16px;
}
.cta-description {
font-size: 1rem;
color: var(--color-gray-600);
margin-bottom: 32px;
}
.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;
color: #333;
transition: all var(--transition-base, 0.3s ease);
}
.portfolio-item a:hover {
color: #007bff;
.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);
}
</style>
/* Responsive Adjustments */
@media (max-width: 991px) {
.portfolio-header {
padding: 50px 20px;
}
.portfolio-title {
font-size: 2rem;
}
.portfolio-grid {
gap: 16px;
}
}
@media (max-width: 767px) {
.portfolio-header {
padding: 40px 16px;
}
.portfolio-title {
font-size: 1.75rem;
}
.portfolio-subtitle {
font-size: 1rem;
}
.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;
}
}
</style>

View File

@@ -1,79 +1,233 @@
---
import Layout from '../../layouts/Layout.astro';
/**
* Category Page - 文章分類
* URL: /wen-zhang-fen-lei/[slug]
* Displays posts filtered by category from Payload CMS
*/
import Layout from '../../layouts/Layout.astro'
import ArticleCard from '../../components/blog/ArticleCard.astro'
import { fetchCategoryBySlug, fetchPosts, formatDate } from '../../lib/api/blog'
export async function getStaticPaths() {
// Category slugs
const slugs = [
'en-qun-shu-wei-zui-xin-gong-gao',
'xing-xiao-shi-shi-zui-qian-xian',
'meta-xiao-xue-tang',
'google-xiao-xue-tang'
];
const { slug } = Astro.params
return slugs.map(slug => ({
params: { slug },
props: { slug }
}));
// Fetch category by slug
const category = await fetchCategoryBySlug(slug)
// Handle 404 for non-existent category
if (!category) {
return Astro.redirect('/404')
}
const { slug } = Astro.props;
// Fetch posts for this category (limit 100 for category pages)
const PAGE_SIZE = 100
const postsData = await fetchPosts(1, PAGE_SIZE, slug)
const posts = postsData.docs
// Placeholder - would fetch category and posts from CMS
const category = {
name: 'Category Name',
posts: [
{ slug: 'post1', title: 'Post 1', date: '2023-01-01' }
]
};
// Format date function
const formatDateTW = (dateStr: string | null | undefined): string => {
if (!dateStr) return ''
const date = new Date(dateStr)
return date.toLocaleDateString('zh-TW', {
year: 'numeric',
month: 'long',
day: 'numeric'
})
}
---
<Layout>
<section class="category-section">
<Layout title={category.title}>
<!-- Category Header -->
<section class="category-hero" aria-labelledby="category-heading">
<div class="container">
<h1>{category.name}</h1>
<div class="posts-list">
{category.posts.map(post => (
<article class="post-item">
<h2><a href={`/xing-xiao-fang-da-jing/${post.slug}`}>{post.title}</a></h2>
<p>{post.date}</p>
</article>
))}
</div>
<a href="/news" class="back-link">← 回到文章列表</a>
<h1 id="category-heading" class="category-title">{category.title}</h1>
{
category.nameEn && (
<p class="category-subtitle">{category.nameEn}</p>
)
}
</div>
</section>
<!-- Category Posts -->
<section class="category-posts">
<div class="container">
{
posts.length > 0 ? (
<div class="posts-grid">
{posts.map((post) => (
<article class="post-item">
<a href={`/xing-xiao-fang-da-jing/${post.slug}`} class="post-link">
{
post.heroImage?.url && (
<div class="post-image">
<img
src={post.heroImage.url}
alt={post.heroImage.alt || post.title}
loading="lazy"
/>
</div>
)
}
<div class="post-content">
<h3 class="post-title">{post.title}</h3>
<time class="post-date">{formatDateTW(post.publishedAt)}</time>
{
post.excerpt && (
<p class="post-excerpt">{post.excerpt.slice(0, 120)}...</p>
)
}
</div>
</a>
</article>
))}
</div>
) : (
<div class="empty-state">
<p>此分類暫無文章</p>
</div>
)
}
</div>
</section>
</Layout>
<style>
.category-section {
padding: 40px 0;
.category-hero {
padding: 60px 20px 40px;
text-align: center;
background-color: #ffffff;
}
.container {
max-width: 1000px;
max-width: 1200px;
margin: 0 auto;
padding: 0 20px;
}
h1 {
text-align: center;
margin-bottom: 30px;
}
.posts-list {
display: flex;
flex-direction: column;
gap: 20px;
}
.post-item {
border: 1px solid #ddd;
padding: 20px;
border-radius: 8px;
}
.post-item h2 {
margin-top: 0;
}
.post-item a {
.back-link {
display: inline-block;
margin-bottom: 20px;
color: var(--color-enchunblue, #23608c);
text-decoration: none;
color: #333;
font-weight: 500;
}
.post-item a:hover {
color: #007bff;
.category-title {
font-family: "Noto Sans TC", sans-serif;
font-size: 2rem;
font-weight: 700;
color: var(--color-dark-blue, #1a1a1a);
margin-bottom: 8px;
}
</style>
.category-subtitle {
font-family: "Quicksand", "Noto Sans TC", sans-serif;
font-size: 1rem;
color: var(--color-gray-600, #666);
}
.category-posts {
padding: 40px 20px 60px;
background-color: #f8f9fa;
min-height: 60vh;
}
.posts-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24px;
}
.post-item {
background: #ffffff;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
transition: box-shadow 0.3s ease;
}
.post-item:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
}
.post-link {
display: block;
text-decoration: none;
color: inherit;
}
.post-image {
aspect-ratio: 16 / 9;
overflow: hidden;
background: var(--color-gray-100, #f5f5f5);
}
.post-image img {
width: 100%;
height: 100%;
object-fit: cover;
}
.post-content {
padding: 20px;
}
.post-title {
font-family: "Noto Sans TC", sans-serif;
font-size: 1.125rem;
font-weight: 600;
color: var(--color-dark-blue, #1a1a1a);
margin-bottom: 12px;
line-height: 1.4;
}
.post-date {
font-size: 0.875rem;
color: var(--color-gray-500, #999);
margin-bottom: 12px;
}
.post-excerpt {
font-size: 0.9375rem;
color: var(--color-gray-600, #666);
line-height: 1.6;
}
.empty-state {
text-align: center;
padding: 80px 20px;
color: var(--color-gray-500, #999);
}
/* Responsive */
@media (max-width: 991px) {
.posts-grid {
grid-template-columns: repeat(2, 1fr);
gap: 20px;
}
.category-title {
font-size: 1.75rem;
}
}
@media (max-width: 767px) {
.category-hero {
padding: 40px 16px 30px;
}
.category-title {
font-size: 1.5rem;
}
.posts-grid {
grid-template-columns: 1fr;
gap: 16px;
}
.category-posts {
padding: 30px 16px 50px;
}
}
</style>

View File

@@ -1,66 +1,356 @@
---
import Layout from '../../layouts/Layout.astro';
/**
* Blog Article Detail Page - 行銷放大鏡文章詳情
* URL: /xing-xiao-fang-da-jing/[slug]
* Fetches from Payload CMS and renders with Lexical content
*/
import Layout from '../../layouts/Layout.astro'
import RelatedPosts from '../../components/blog/RelatedPosts.astro'
import { fetchPostBySlug, fetchRelatedPosts, formatDate } from '../../lib/api/blog'
import { serializeLexical } from '../../lib/serializeLexical'
export async function getStaticPaths() {
// Placeholder slugs - would fetch from CMS
const slugs = [
'2-zhao-yao-kong-xiao-fei-zhe-de-xin',
'2022-jie-qing-xing-xiao-quan-gong-lue',
// Add all from sitemap
];
// Get slug from params
const { slug } = Astro.params
return slugs.map(slug => ({
params: { slug },
props: { slug }
}));
// Validate slug
if (!slug) {
return Astro.redirect('/404')
}
const { slug } = Astro.props;
// Fetch post by slug
const post = await fetchPostBySlug(slug)
// Placeholder content - would fetch from CMS
const post = {
title: 'Sample Post Title',
date: 'January 20, 2022',
content: 'Sample content...'
};
// Handle 404 for non-existent or unpublished posts
if (!post) {
return Astro.redirect('/404')
}
// Fetch related posts
const categorySlugs = post.categories?.map(c => c.slug) || []
const relatedPosts = await fetchRelatedPosts(post.id, categorySlugs, 4)
// Format date
const displayDate = formatDate(post.publishedAt)
// Get primary category
const category = post.categories?.[0]
// Serialize Lexical content to HTML (must await the async function)
const contentHtml = await serializeLexical(post.content)
// SEO metadata
const metaTitle = post.meta?.title || post.title
const metaDescription = post.meta?.description || post.excerpt || ''
const metaImage = post.meta?.image?.url || post.ogImage?.url || post.heroImage?.url || ''
---
<Layout>
<section class="post-section">
<div class="container">
<a href="/news" class="back-link">回到文章列表</a>
<article>
<h1>{post.title}</h1>
<p class="post-date">文章發布日期:{post.date}</p>
<div class="post-content prose prose-custom max-w-none">
<p>{post.content}</p>
<!-- More content would be rendered here with markdown -->
<Layout title={metaTitle} description={metaDescription}>
<article class="article-detail">
<!-- Article Header -->
<header class="article-header">
<div class="container">
<!-- Category Badge -->
{
category && (
<span
class="article-category"
style={`background-color: ${category.backgroundColor || 'var(--color-enchunblue)'}; color: ${category.textColor || '#fff'}`}
>
{category.title}
</span>
)
}
<!-- Article Title -->
<h1 class="article-title">
{post.title}
</h1>
<!-- Published Date -->
{
displayDate && (
<time class="article-date" datetime={post.publishedAt || post.createdAt}>
{displayDate}
</time>
)
}
</div>
</header>
<!-- Hero Image -->
{
post.heroImage?.url && (
<div class="article-hero-image">
<img
src={post.heroImage.url}
alt={post.heroImage.alt || post.title}
loading="eager"
width="1200"
height="675"
/>
</div>
</article>
)
}
<!-- Article Content -->
<div class="article-content">
<div class="container">
<div class="content-wrapper">
<!-- Render rich text content with Lexical to HTML conversion -->
<div class="prose" set:html={contentHtml} />
</div>
</div>
</div>
</section>
<!-- Back to List Button -->
<div class="article-actions">
<div class="container">
<a href="/news" class="back-button">
<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>
</div>
</div>
</article>
<!-- Related Posts Section -->
<RelatedPosts posts={relatedPosts} />
</Layout>
<style>
.post-section {
padding: 40px 0;
<!-- Open Graph Tags -->
<script define:vars={{ metaImage, metaTitle, metaDescription }}>
if (typeof document !== 'undefined') {
// Update OG image
const ogImage = document.querySelector('meta[property="og:image"]')
if (ogImage && metaImage) {
ogImage.setAttribute('content', metaImage)
}
// Update OG title
const ogTitle = document.querySelector('meta[property="og:title"]')
if (ogTitle) {
ogTitle.setAttribute('content', metaTitle)
}
// Update OG description
const ogDesc = document.querySelector('meta[property="og:description"]')
if (ogDesc) {
ogDesc.setAttribute('content', metaDescription)
}
}
</script>
<style>
/* Article Header */
.article-header {
padding: 60px 20px 40px;
text-align: center;
background-color: #ffffff;
}
.container {
max-width: 800px;
margin: 0 auto;
padding: 0 20px;
}
.back-link {
.article-category {
display: inline-block;
margin-bottom: 20px;
color: #007bff;
padding: 0.5rem 1rem;
border-radius: 20px;
font-size: 0.875rem;
font-weight: 600;
margin-bottom: 1.5rem;
}
.article-title {
font-family: "Noto Sans TC", sans-serif;
font-size: 2.5rem;
font-weight: 700;
color: var(--color-dark-blue, #1a1a1a);
line-height: 1.3;
margin-bottom: 1rem;
}
.article-date {
font-family: "Quicksand", "Noto Sans TC", sans-serif;
font-size: 1rem;
color: var(--color-gray-500, #999);
}
/* Hero Image */
.article-hero-image {
max-width: 1200px;
margin: 0 auto;
aspect-ratio: 16 / 9;
overflow: hidden;
background: var(--color-gray-100, #f5f5f5);
}
.article-hero-image img {
width: 100%;
height: 100%;
object-fit: cover;
}
/* Article Content */
.article-content {
padding: 60px 20px;
background-color: #ffffff;
}
.content-wrapper {
max-width: 720px;
margin: 0 auto;
}
/* Prose styles for rich text content */
.prose {
font-family: "Noto Sans TC", sans-serif;
color: var(--color-gray-700, #333);
line-height: 1.8;
}
.prose :global(h1),
.prose :global(h2),
.prose :global(h3),
.prose :global(h4) {
font-weight: 700;
color: var(--color-dark-blue, #1a1a1a);
margin-top: 2rem;
margin-bottom: 1rem;
line-height: 1.3;
}
.prose :global(h1) { font-size: 2rem; }
.prose :global(h2) { font-size: 1.75rem; }
.prose :global(h3) { font-size: 1.5rem; }
.prose :global(h4) { font-size: 1.25rem; }
.prose :global(p) {
margin-bottom: 1.25rem;
font-size: 1.0625rem;
}
.prose :global(a) {
color: var(--color-enchunblue, #23608c);
text-decoration: underline;
text-underline-offset: 2px;
}
.prose :global(a:hover) {
color: var(--color-enchunblue-hover, #1a4d6e);
}
.prose :global(img) {
border-radius: 8px;
margin: 2rem 0;
max-width: 100%;
height: auto;
}
.prose :global(ul),
.prose :global(ol) {
padding-left: 1.5rem;
margin-bottom: 1.25rem;
}
.prose :global(li) {
margin-bottom: 0.5rem;
}
.prose :global(blockquote) {
border-left: 4px solid var(--color-enchunblue, #23608c);
padding-left: 1rem;
margin: 2rem 0;
font-style: italic;
color: var(--color-gray-600, #666);
}
.prose :global(code) {
background: var(--color-gray-100, #f5f5f5);
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.9em;
}
.prose :global(pre) {
background: var(--color-dark-blue, #1a1a1a);
color: #fff;
padding: 1rem;
border-radius: 8px;
overflow-x: auto;
margin: 2rem 0;
}
.prose :global(pre code) {
background: transparent;
color: inherit;
}
/* Article Actions */
.article-actions {
padding: 20px;
background-color: #f8f9fa;
}
.back-button {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 12px 24px;
background: #ffffff;
border: 2px solid var(--color-gray-300, #e0e0e0);
border-radius: 8px;
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;
}
.post-date {
color: #666;
margin-bottom: 20px;
.back-button:hover {
border-color: var(--color-enchunblue, #23608c);
color: var(--color-enchunblue, #23608c);
background: rgba(35, 96, 140, 0.05);
}
.post-content {
/* Prose styles handle typography */
/* Responsive Adjustments */
@media (max-width: 991px) {
.article-title {
font-size: 2rem;
}
}
</style>
@media (max-width: 767px) {
.article-header {
padding: 40px 16px 30px;
}
.article-title {
font-size: 1.75rem;
}
.article-hero-image {
aspect-ratio: 4 / 3;
}
.article-content {
padding: 40px 16px;
}
.content-wrapper {
padding: 0;
}
.prose :global(h1) { font-size: 1.75rem; }
.prose :global(h2) { font-size: 1.5rem; }
.prose :global(h3) { font-size: 1.25rem; }
.prose :global(p) {
font-size: 1rem;
}
}
</style>

View File

@@ -0,0 +1,84 @@
---
/**
* AboutHero - Hero section for About page
* Pixel-perfect implementation based on Webflow design
*/
interface Props {
title?: string
subtitle?: string
}
const {
title = '關於恩群數位',
subtitle = 'About Enchun digital',
} = 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>
</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

@@ -0,0 +1,309 @@
---
/**
* BenefitsSection - Work benefits section for Teams page
* Pixel-perfect implementation based on Webflow design
* Features 6 benefit cards with alternating layout
*/
interface BenefitItem {
title: string
icon: string
}
interface Props {
benefits?: BenefitItem[]
}
const defaultBenefits: BenefitItem[] = [
{
title: '高績效、高獎金\n新人開張獎金',
icon: 'bonus',
},
{
title: '生日慶生、電影日\n員工下午茶',
icon: 'birthday',
},
{
title: '教育訓練補助',
icon: 'education',
},
{
title: '寬敞的工作空間',
icon: 'workspace',
},
{
title: '員工國內外旅遊\n部門聚餐、年終活動',
icon: 'travel',
},
{
title: '入職培訓及團隊建設',
icon: 'training',
},
]
const benefits = Astro.props.benefits || defaultBenefits
// Icon SVG components (placeholder for now, replace with actual SVG)
const getIconSVG = (iconType: string) => {
const icons: Record<string, string> = {
bonus: `<svg viewBox="0 0 200 200" class="benefit-icon-svg">
<circle cx="100" cy="70" r="40" fill="#23608c" opacity="0.2"/>
<rect x="60" y="100" width="80" height="60" rx="8" fill="#23608c"/>
<text x="100" y="140" text-anchor="middle" fill="white" font-size="36">💰</text>
</svg>`,
birthday: `<svg viewBox="0 0 200 200" class="benefit-icon-svg">
<circle cx="100" cy="70" r="40" fill="#23608c" opacity="0.2"/>
<rect x="60" y="100" width="80" height="60" rx="8" fill="#23608c"/>
<text x="100" y="140" text-anchor="middle" fill="white" font-size="36">🎂</text>
</svg>`,
education: `<svg viewBox="0 0 200 200" class="benefit-icon-svg">
<circle cx="100" cy="70" r="40" fill="#23608c" opacity="0.2"/>
<rect x="60" y="100" width="80" height="60" rx="8" fill="#23608c"/>
<text x="100" y="140" text-anchor="middle" fill="white" font-size="36">📚</text>
</svg>`,
workspace: `<svg viewBox="0 0 200 200" class="benefit-icon-svg">
<circle cx="100" cy="70" r="40" fill="#23608c" opacity="0.2"/>
<rect x="60" y="100" width="80" height="60" rx="8" fill="#23608c"/>
<text x="100" y="140" text-anchor="middle" fill="white" font-size="36">🏢</text>
</svg>`,
travel: `<svg viewBox="0 0 200 200" class="benefit-icon-svg">
<circle cx="100" cy="70" r="40" fill="#23608c" opacity="0.2"/>
<rect x="60" y="100" width="80" height="60" rx="8" fill="#23608c"/>
<text x="100" y="140" text-anchor="middle" fill="white" font-size="36">✈️</text>
</svg>`,
training: `<svg viewBox="0 0 200 200" class="benefit-icon-svg">
<circle cx="100" cy="70" r="40" fill="#23608c" opacity="0.2"/>
<rect x="60" y="100" width="80" height="60" rx="8" fill="#23608c"/>
<text x="100" y="140" text-anchor="middle" fill="white" font-size="36">🤝</text>
</svg>`,
}
return icons[iconType] || icons.bonus
}
---
<section class="section-benefit" aria-labelledby="benefits-heading">
<div class="container w-container">
<!-- Section Header -->
<div class="section_header_w_line">
<div class="divider_line"></div>
<div class="header_subtitle">
<h2 id="benefits-heading" class="header_subtitle_head">工作福利</h2>
<p class="header_subtitle_paragraph">Benefit Package</p>
</div>
<div class="divider_line"></div>
</div>
<!-- Benefits Grid -->
<div class="benefit-grid-wrapper">
{
benefits.map((benefit, index) => (
<div class={`benefit-card ${index % 2 === 0 ? 'benefit-card' : 'benefit-card-opposite'}`}>
<!-- Odd: Icon on right, Even: Icon on left -->
{
index % 2 === 0 ? (
<>
<div class="benefit-content">
<h3 class="benefit-title-text">{benefit.title}</h3>
</div>
<div class="benefit-image-right" set:html={getIconSVG(benefit.icon)} />
</>
) : (
<>
<div class="benefit-image-left" set:html={getIconSVG(benefit.icon)} />
<div class="benefit-content">
<h3 class="benefit-title-text">{benefit.title}</h3>
</div>
</>
)
}
</div>
))
}
</div>
</div>
</section>
<style>
/* Benefits Section Styles - Pixel-perfect from Webflow */
.section-benefit {
padding: 60px 20px;
background-color: #ffffff;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
.w-container {
max-width: 1200px;
margin: 0 auto;
}
/* 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);
}
/* Benefits Grid */
.benefit-grid-wrapper {
max-width: 1000px;
margin: 0 auto;
}
/* Benefit Card */
.benefit-card,
.benefit-card-opposite {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 40px;
align-items: center;
margin-bottom: 60px;
}
/* Odd cards: icon on right */
.benefit-card {
grid-template-areas: "content image";
}
.benefit-card .benefit-content {
grid-area: content;
text-align: left;
}
.benefit-card .benefit-image-right {
grid-area: image;
display: flex;
justify-content: center;
align-items: center;
}
/* Even cards: icon on left */
.benefit-card-opposite {
grid-template-areas: "image content";
}
.benefit-card-opposite .benefit-content {
grid-area: content;
text-align: left;
}
.benefit-card-opposite .benefit-image-left {
grid-area: image;
display: flex;
justify-content: center;
align-items: center;
}
/* Benefit Title */
.benefit-title-text {
font-size: 1.5rem;
font-weight: 600;
color: var(--color-tarawera, #23608c);
white-space: pre-line;
line-height: 1.4;
}
/* Benefit Icon */
.benefit-icon-svg {
width: 120px;
height: 120px;
}
.benefit-image-right,
.benefit-image-left {
display: flex;
justify-content: center;
align-items: center;
}
.benefit-image-right svg,
.benefit-image-left svg {
width: 120px;
height: 120px;
}
/* Responsive Adjustments */
@media (max-width: 991px) {
.benefit-card,
.benefit-card-opposite {
gap: 24px;
margin-bottom: 40px;
}
.benefit-title-text {
font-size: 1.25rem;
}
}
@media (max-width: 767px) {
.section-benefit {
padding: 40px 16px;
}
.section_header_w_line {
flex-wrap: wrap;
}
.benefit-card,
.benefit-card-opposite {
grid-template-columns: 1fr;
grid-template-areas: "image" "content" !important;
gap: 24px;
margin-bottom: 48px;
}
.benefit-content {
text-align: center !important;
}
.benefit-image-right,
.benefit-image-left {
order: -1;
}
.benefit-title-text {
font-size: 1.125rem;
}
.benefit-icon-svg {
width: 100px;
height: 100px;
}
.benefit-image-right svg,
.benefit-image-left svg {
width: 100px;
height: 100px;
}
}
</style>

View File

@@ -0,0 +1,147 @@
---
/**
* CTASection - Call to action section (Pixel-perfect from Webflow)
* Features: 2-column grid, notification-red button, hover effects
*/
import type { HomeData } from '@/lib/api/home'
interface Props {
homeData?: HomeData | null
}
const { homeData } = Astro.props
// Get CTA config from CMS or use defaults - matching reference HTML
const ctaSection = homeData?.ctaSection
const headline = ctaSection?.headline || '準備好開始新的旅程了嗎 歡迎與我們聯絡'
const description = ctaSection?.description || '' // Reference has no description
const buttonText = ctaSection?.buttonText || '預約諮詢 phone_callback'
const buttonLink = ctaSection?.buttonLink || 'https://heyform.itslouis.cc/form/7mYtUNjA'
---
<section
class="section-call4action"
aria-labelledby="cta-heading"
>
<div class="max-w-6xl mx-auto px-4">
<div class="c4a-grid">
<!-- Text Content -->
<div class="c4a-content">
<h3 id="cta-heading" class="c4a-heading">
{headline}
</h3>
{
description && (
<p class="c4a-description">
{description}
</p>
)
}
</div>
<!-- CTA Button -->
<a
href={buttonLink}
class="c4a-button"
>
<span class="c4a-button-text">
{buttonText}
</span>
</a>
</div>
</div>
</section>
<style>
/* CTA Section Styles - Pixel-perfect from Webflow */
.section-call4action {
padding: 105px 0 126px 0;
}
.c4a-grid {
display: grid;
grid-column-gap: 60px;
grid-template-columns: max-content max-content;
place-items: center;
justify-content: center;
}
.c4a-content {
text-align: left;
}
/* Heading - 1.88em */
.c4a-heading {
color: var(--color-dark-blue);
font-size: 1.88em;
font-weight: 500;
line-height: 1.1;
margin-bottom: 16px;
}
/* Description */
.c4a-description {
font-size: 1.1em;
color: var(--color-gray-700);
line-height: 1.5;
}
/* Button */
.c4a-button {
background-color: var(--color-notification-red);
border-radius: 15px;
padding: 18px 24px;
text-decoration: none;
display: inline-block;
transition: all var(--transition-base, 200ms ease-in-out);
}
.c4a-button:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
.c4a-button-text {
color: #f2f2f2;
font-size: 1.56em;
line-height: 1.1;
text-align: center;
font-weight: 500;
display: block;
}
/* Responsive Adjustments */
@media (max-width: 991px) {
.section-call4action {
padding: 64px 0;
}
.c4a-grid {
grid-template-columns: 1fr;
grid-column-gap: 32px;
text-align: center;
}
.c4a-content {
text-align: center;
}
.c4a-heading {
font-size: 1.5em;
}
}
@media (max-width: 767px) {
.section-call4action {
padding: 48px 0;
}
.c4a-button {
min-width: 160px;
}
.c4a-button-text {
font-size: 1.2em;
}
}
</style>

View File

@@ -0,0 +1,505 @@
---
/**
* ClientCasesSection - Client testimonials carousel
* Features: Auto-play carousel, 2-column grid, responsive
*/
import type { HomeData } from '@/lib/api/home'
interface Props {
homeData?: HomeData | null
}
const { homeData } = Astro.props
// Client cases data - matching reference HTML exactly
const clientCases = [
{
id: 1,
name: '落日精品咖啡',
title: '王孟梵 老闆',
company: '',
image: '/placeholder-client-1.jpg',
review: '業務專業、能反應需求、良好溝通一起努力達成目標,比起自己下廣告,合作更能將每分錢用在刀口上。',
},
{
id: 2,
name: 'Gee hair salon',
title: 'Ivan 店長',
company: '',
image: '/placeholder-client-2.jpg',
review: '與恩群合作多年,疫情時期,不指定、網路指定客數量不減反增,讓在店周圍客人們就進更能找到合適自己的髮型師。',
},
{
id: 3,
name: '高雄欣興租車',
title: 'ANDY 店長',
company: '',
image: '/placeholder-client-3.jpg',
review: '感謝團隊的專業指導與耐心的溝通,讓商家的理念可以轉換成實際的成果,發揮廣告效應最大化!特別是後勤團隊高效的執行能力,深受店家放心😊',
},
{
id: 4,
name: '這健小事運動工作室',
title: 'Nash嘉慶 老闆',
company: '',
image: '/placeholder-client-4.jpg',
review: '對於我們預算有限的自營小工作室的人真的最佳選擇,謝謝你們,讓更多需要運動的人,看見我們',
},
{
id: 5,
name: '即刻體能運動空間',
title: 'Peter 老闆',
company: '',
image: '/placeholder-client-5.jpg',
review: '在疫情期間開業的我們,很慶幸有恩群的協助,讓喜愛健身的客人能搜尋到我們、並給我們機會。感謝恩群團隊,讓我們感受到滿滿的服務熱忱及設身處地為客戶著想的心,非常推薦!',
},
]
---
<section class="section-client-case" aria-labelledby="client-cases-heading">
<div class="max-w-6xl mx-auto px-4">
<!-- Section Header - H1 "客戶案例" -->
<div class="section-header-w-line">
<h1 id="client-cases-heading" class="client-case-heading">
客戶案例
</h1>
<p class="client-case-sub">clients who work with us</p>
<p class="client-case-description">
恩群數位的客戶真實示範,增加流量就是這麼簡單!
</p>
</div>
<!-- Carousel Container -->
<div class="case-slider-wrapper" id="case-carousel">
<!-- Slides Container -->
<div class="case-slides-container">
{
clientCases.map((caseItem, index) => (
<div
class={`case-slide ${index === 0 ? 'active' : ''}`}
data-slide={index}
>
<div class="case-content-grid">
<!-- Image Side -->
<div class="case-image-side">
<div class="case-image-wrapper">
<img
src={caseItem.image}
alt={caseItem.name}
class="case-slide-image"
loading="lazy"
/>
</div>
</div>
<!-- Content Side -->
<div class="case-content-side">
<h3 class="case-heading">{caseItem.name}</h3>
<p class="case-job-title">
{caseItem.title}
</p>
<p class="case-review">「{caseItem.review}」</p>
</div>
</div>
</div>
))
}
</div>
<!-- Navigation Arrows -->
<button class="carousel-nav carousel-nav-prev" aria-label="previous slide">
<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor">
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/>
</svg>
</button>
<button class="carousel-nav carousel-nav-next" aria-label="next slide">
<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor">
<path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/>
</svg>
</button>
<!-- Slide Counter -->
<div class="slide-counter" aria-live="polite">
Slide <span id="current-slide">1</span> of <span id="total-slides">5</span>.
</div>
<!-- Carousel Indicators -->
<div class="carousel-indicators">
{
clientCases.map((_, index) => (
<button
class={`indicator ${index === 0 ? 'active' : ''}`}
data-slide={index}
aria-label={`Go to slide ${index + 1}`}
></button>
))
}
</div>
</div>
</div>
</section>
<style>
/* Client Cases Section Styles - Pixel-perfect from Webflow */
.section-client-case {
padding: 64px 0;
}
/* Section Header */
.section-header-w-line {
text-align: center;
margin-bottom: 3rem;
}
.client-case-heading {
font-family: "Noto Sans TC", sans-serif;
font-size: 1.8em; /* 34.2px at 19px base - matching reference */
font-weight: 700;
color: var(--color-dark-blue, #062841);
text-align: center;
margin-bottom: 0.5rem;
}
.client-case-sub {
font-family: "Quicksand", "Noto Sans TC", sans-serif;
font-size: 1rem;
font-weight: 400;
color: var(--color-gray-600, #666);
text-align: center;
margin: 0.5rem 0;
text-transform: lowercase;
}
.client-case-description {
font-family: "Noto Sans TC", sans-serif;
font-size: 1rem;
font-weight: 400;
color: var(--color-gray-600, #666);
text-align: center;
margin: 0.5rem 0 1rem;
}
/* Carousel Container */
.case-slider-wrapper {
position: relative;
height: 77vh;
overflow: hidden;
}
.case-slides-container {
position: relative;
height: 100%;
}
/* Individual Slide */
.case-slide {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0;
transition: opacity 0.5s ease-in-out;
pointer-events: none;
}
.case-slide.active {
opacity: 1;
pointer-events: auto;
}
/* Content Grid */
.case-content-grid {
display: grid;
grid-template-columns: 1fr 1fr;
grid-column-gap: 55px;
grid-row-gap: 55px;
align-items: center;
height: 100%;
padding: 0 32px;
}
/* Image Side */
.case-image-side {
display: flex;
align-items: center;
justify-content: center;
}
.case-image-wrapper {
width: 100%;
aspect-ratio: 16/9;
border-radius: 12px;
overflow: hidden;
background: var(--color-gray-100);
}
.case-slide-image {
object-fit: cover;
width: 100%;
height: 100%;
}
/* Content Side */
.case-content-side {
display: flex;
flex-direction: column;
justify-content: center;
}
.case-heading {
font-size: 1.7em;
color: var(--color-dark-blue);
font-family: "Noto Sans TC", sans-serif;
margin-bottom: 8px;
font-weight: 600;
}
.case-job-title {
font-size: 18px;
color: var(--color-enchunblue-dark);
font-weight: 100;
margin-bottom: 16px;
}
.case-review {
font-size: 18px;
color: var(--color-gray-700);
font-weight: 100;
line-height: 1.6;
}
/* Carousel Indicators */
.carousel-indicators {
position: absolute;
bottom: 32px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 12px;
}
.indicator {
width: 12px;
height: 12px;
border-radius: 50%;
border: none;
background: rgba(255, 255, 255, 0.5);
cursor: pointer;
transition: background 0.3s ease;
}
.indicator:hover {
background: rgba(255, 255, 255, 0.8);
}
.indicator.active {
background: var(--color-enchunblue);
width: 32px;
border-radius: 6px;
}
/* Navigation Arrows */
.carousel-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 48px;
height: 48px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.9);
border: 2px solid var(--color-enchunblue);
color: var(--color-enchunblue);
font-size: 24px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s ease;
z-index: 10;
}
.carousel-nav:hover {
background: var(--color-enchunblue);
color: white;
}
.carousel-nav-prev {
left: 16px;
}
.carousel-nav-next {
right: 16px;
}
/* Slide Counter */
.slide-counter {
position: absolute;
bottom: 16px;
left: 50%;
transform: translateX(-50%);
font-family: "Quicksand", sans-serif;
font-size: 14px;
color: var(--color-gray-600);
background: rgba(255, 255, 255, 0.9);
padding: 4px 12px;
border-radius: 16px;
}
/* Responsive Adjustments */
@media (max-width: 991px) {
.case-slider-wrapper {
height: auto;
}
.case-slide {
position: relative;
opacity: 1;
pointer-events: auto;
}
.case-slide:not(.active) {
display: none;
}
.case-content-grid {
grid-template-columns: 1fr;
grid-column-gap: 32px;
grid-row-gap: 32px;
padding: 24px;
}
.case-heading {
font-size: 1.4em;
}
}
@media (max-width: 767px) {
.case-slider-wrapper {
height: 59vh;
}
.client-case-sub {
font-size: 20px;
margin-top: 32px;
}
.case-content-grid {
padding: 16px;
}
.case-heading {
font-size: 1.2em;
}
.case-job-title,
.case-review {
font-size: 16px;
}
}
</style>
<script>
// Carousel functionality
function initClientCasesCarousel() {
const slides = document.querySelectorAll('.case-slide')
const indicators = document.querySelectorAll('.indicator')
const prevBtn = document.querySelector('.carousel-nav-prev')
const nextBtn = document.querySelector('.carousel-nav-next')
const currentSlideEl = document.getElementById('current-slide')
const totalSlidesEl = document.getElementById('total-slides')
let currentSlide = 0
const totalSlides = slides.length
const autoplayDelay = 2500 // 2.5 seconds
let autoplayTimer: number | undefined
// Update total slides display
if (totalSlidesEl) {
totalSlidesEl.textContent = totalSlides.toString()
}
function showSlide(index: number) {
// Wrap around
if (index >= totalSlides) index = 0
if (index < 0) index = totalSlides - 1
// Remove active class from all
slides.forEach(slide => slide.classList.remove('active'))
indicators.forEach(ind => ind.classList.remove('active'))
// Add active class to current
slides[index].classList.add('active')
indicators[index].classList.add('active')
// Update counter
if (currentSlideEl) {
currentSlideEl.textContent = (index + 1).toString()
}
currentSlide = index
}
function nextSlide() {
showSlide(currentSlide + 1)
}
function prevSlide() {
showSlide(currentSlide - 1)
}
function startAutoplay() {
stopAutoplay()
autoplayTimer = window.setInterval(nextSlide, autoplayDelay)
}
function stopAutoplay() {
if (autoplayTimer !== undefined) {
clearInterval(autoplayTimer)
autoplayTimer = undefined
}
}
// Click handlers for indicators
indicators.forEach((indicator, index) => {
indicator.addEventListener('click', () => {
showSlide(index)
stopAutoplay()
startAutoplay()
})
})
// Navigation buttons
if (prevBtn) {
prevBtn.addEventListener('click', () => {
prevSlide()
stopAutoplay()
startAutoplay()
})
}
if (nextBtn) {
nextBtn.addEventListener('click', () => {
nextSlide()
stopAutoplay()
startAutoplay()
})
}
// Pause on hover
const carousel = document.getElementById('case-carousel')
if (carousel) {
carousel.addEventListener('mouseenter', stopAutoplay)
carousel.addEventListener('mouseleave', startAutoplay)
}
// Start autoplay
startAutoplay()
}
// Initialize when DOM is ready
document.addEventListener('DOMContentLoaded', initClientCasesCarousel)
if (document.readyState !== 'loading') {
initClientCasesCarousel()
}
</script>

View File

@@ -0,0 +1,131 @@
---
/**
* CompanyStory - Company story section for Teams page
* Pixel-perfect implementation based on Webflow design
*/
interface Props {
title?: string
subtitle?: string
content?: string
}
const {
title = '恩群數位的故事',
subtitle = 'Something About Enchun Digital',
content = '恩群數位是由一群年輕果斷、敢冒險的年輕人聚在一起,共同為台灣在地經營努力不懈的商家老闆們建立品牌的知名度。而商家的經營本身就不是一件容易的事情,早在恩群成立之前,我們便一直聚焦在與不同行業的老闆們建立起信賴可靠的關係,從一家又一家的合作關係當中,聚集了在行銷領域裡的頂尖好手,培育了許多優秀行銷顧問。讓每個辛苦經營的商家老闆可以獲得最佳的服務,讓每次的行銷需求可以透過有效的互動與聆聽,達到彼此心目中的預期目標。數字的確會說話,但是每一個有溫度的服務才是在恩群裡最重視的地方。',
} = Astro.props
---
<section class="section-story" aria-labelledby="story-heading">
<div class="container w-container">
<!-- Section Header -->
<div class="section_header_w_line">
<div class="divider_line"></div>
<div class="header_subtitle">
<h2 id="story-heading" class="header_subtitle_head">{title}</h2>
<p class="header_subtitle_paragraph">{subtitle}</p>
</div>
<div class="divider_line"></div>
</div>
<!-- Story Content -->
<p class="story-paragraph">{content}</p>
</div>
</section>
<style>
/* Company Story Styles - Pixel-perfect from Webflow */
.section-story {
padding: 80px 20px;
text-align: center;
background-color: #ffffff;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
.w-container {
max-width: 1200px;
margin: 0 auto;
}
/* Section Header */
.section_header_w_line {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
margin-bottom: 40px;
}
.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);
}
/* Story Content */
.story-paragraph {
max-width: 800px;
margin: 0 auto;
font-size: 1.125rem;
line-height: 1.8;
color: var(--color-text-secondary);
font-family: "Noto Sans TC", sans-serif;
}
/* Responsive Adjustments */
@media (max-width: 991px) {
.section-story {
padding: 60px 16px;
}
.header_subtitle_head {
font-size: 1.75rem;
}
.story-paragraph {
font-size: 1rem;
}
}
@media (max-width: 767px) {
.section-story {
padding: 40px 16px;
}
.section_header_w_line {
flex-wrap: wrap;
}
.header_subtitle_head {
font-size: 1.5rem;
}
.story-paragraph {
font-size: 0.95rem;
line-height: 1.7;
}
}
</style>

View File

@@ -0,0 +1,274 @@
---
/**
* ComparisonSection - 恩群數位 vs 其他行銷公司 對比表格
* Pixel-perfect implementation based on Webflow design
*/
interface ComparisonItem {
feature: string
enchun: string
others: string
}
const comparisonItems: ComparisonItem[] = [
{
feature: '服務範圍',
enchun: '全方位數位行銷服務,從策略到執行一條龍',
others: '單一服務項目,缺乏整合性',
},
{
feature: '數據分析',
enchun: '專業數據分析團隊,精準追蹤 ROI',
others: '基礎報告,缺乏深度分析',
},
{
feature: '在地化經驗',
enchun: '深耕台灣市場,了解本地消費者習性',
others: '通用策略,缺乏在地化調整',
},
{
feature: '客戶服務',
enchun: '一對一專人服務,快速響應',
others: '標準化流程,回應較慢',
},
{
feature: '價格透明',
enchun: '明確報價,無隱藏費用',
others: '複雜收費結構,容易超支',
},
]
---
<section class="section-comparison" aria-labelledby="comparison-heading">
<div class="w-container">
<!-- Section Header -->
<div class="section-header-w-line">
<h2 id="comparison-heading" class="header-subtitle-head">
為什麼選擇恩群數位
</h2>
<div class="divider-line"></div>
</div>
<!-- Comparison Table -->
<div class="comparison-table-wrapper">
<table class="comparison-table">
<thead>
<tr>
<th class="th-feature">比較項目</th>
<th class="th-enchun">
<span class="enchun-badge">恩群數位</span>
</th>
<th class="th-others">其他行銷公司</th>
</tr>
</thead>
<tbody>
{
comparisonItems.map((item, index) => (
<tr class={index % 2 === 0 ? 'row-even' : 'row-odd'}>
<td class="td-feature">{item.feature}</td>
<td class="td-enchun">
<span class="enchun-icon">✓</span>
{item.enchun}
</td>
<td class="td-others">{item.others}</td>
</tr>
))
}
</tbody>
</table>
</div>
<!-- CTA Note -->
<div class="comparison-note">
<p class="note-text">
選擇恩群數位,讓您的品牌在數位時代脫穎而出!
</p>
</div>
</div>
</section>
<style>
/* Comparison Section Styles - Pixel-perfect from Webflow */
.section-comparison {
background-color: #f8f9fa;
padding: 80px 20px;
}
.w-container {
max-width: 1000px;
margin: 0 auto;
}
/* Section Header */
.section-header-w-line {
text-align: center;
margin-bottom: 48px;
}
.header-subtitle-head {
color: var(--color-enchunblue);
font-family: "Noto Sans TC", "Quicksand", sans-serif;
font-weight: 700;
font-size: 2rem;
margin-bottom: 16px;
}
.divider-line {
background-color: var(--color-enchunblue);
height: 2px;
width: 60px;
margin: 0 auto;
}
/* Table Wrapper */
.comparison-table-wrapper {
background: white;
border-radius: var(--radius-lg);
box-shadow: var(--shadow-md);
overflow: hidden;
}
/* Comparison Table */
.comparison-table {
width: 100%;
border-collapse: collapse;
}
/* Table Header */
.comparison-table thead {
background-color: var(--color-enchunblue);
}
.comparison-table th {
color: white;
font-family: "Noto Sans TC", sans-serif;
font-weight: 600;
font-size: 1.125rem;
padding: 20px;
text-align: left;
}
.th-feature {
width: 20%;
}
.th-enchun {
width: 45%;
text-align: center;
}
.th-others {
width: 35%;
}
/* Enchun Badge */
.enchun-badge {
display: inline-block;
background-color: white;
color: var(--color-enchunblue);
padding: 4px 16px;
border-radius: 20px;
font-weight: 700;
}
/* Table Body */
.comparison-table tbody tr {
border-bottom: 1px solid var(--color-border);
}
.comparison-table tbody tr:last-child {
border-bottom: none;
}
.row-even {
background-color: #ffffff;
}
.row-odd {
background-color: #f8f9fa;
}
.comparison-table td {
padding: 20px;
vertical-align: top;
}
.td-feature {
font-weight: 600;
color: var(--color-text-primary);
}
.td-enchun {
color: var(--color-text-secondary);
position: relative;
}
.enchun-icon {
display: inline-block;
color: #22c55e;
font-weight: bold;
margin-right: 8px;
}
.td-others {
color: var(--color-text-muted);
}
/* Comparison Note */
.comparison-note {
text-align: center;
margin-top: 32px;
}
.note-text {
font-size: 1.125rem;
color: var(--color-enchunblue);
font-weight: 500;
}
/* Responsive Adjustments */
@media (max-width: 991px) {
.section-comparison {
padding: 60px 16px;
}
.comparison-table th,
.comparison-table td {
padding: 16px 12px;
font-size: 0.9rem;
}
.header-subtitle-head {
font-size: 1.75rem;
}
}
@media (max-width: 767px) {
/* Convert to card layout on mobile */
.comparison-table-wrapper {
overflow-x: auto;
}
.comparison-table {
min-width: 600px;
}
.section-comparison {
padding: 40px 12px;
}
.header-subtitle-head {
font-size: 1.5rem;
}
.comparison-table th,
.comparison-table td {
padding: 12px 8px;
font-size: 0.875rem;
}
.enchun-badge {
font-size: 0.875rem;
padding: 2px 12px;
}
}
</style>

View File

@@ -0,0 +1,442 @@
---
/**
* EnvironmentSlider - Photo slider for work environment
* Pixel-perfect implementation based on Webflow design
* Features: 8 photos, arrow navigation, dot navigation, touch swipe support
*/
interface SlideImage {
src: string
alt: string
}
interface Props {
slides?: SlideImage[]
}
const defaultSlides: SlideImage[] = [
{ src: '/placeholder-environment-1.jpg', alt: '恩群環境照片 1' },
{ src: '/placeholder-environment-2.jpg', alt: '恩群環境照片 2' },
{ src: '/placeholder-environment-3.jpg', alt: '恩群環境照片 3' },
{ src: '/placeholder-environment-4.jpg', alt: '恩群環境照片 4' },
{ src: '/placeholder-environment-5.jpg', alt: '恩群環境照片 5' },
{ src: '/placeholder-environment-6.jpg', alt: '恩群環境照片 6' },
{ src: '/placeholder-environment-7.jpg', alt: '恩群環境照片 7' },
{ src: '/placeholder-environment-8.jpg', alt: '恩群環境照片 8' },
]
const slides = Astro.props.slides || defaultSlides
---
<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>
<!-- Environment Slider -->
<div
class="environment-slider"
id="env-slider-{Math.random().toString(36).slice(2, 8)}"
>
<!-- Slides Container -->
<div class="slides-container">
{
slides.map((slide, index) => (
<div class="environment-slide" data-index={index}>
<img
src={slide.src}
alt={slide.alt}
loading={index === 0 ? 'eager' : 'lazy'}
width="800"
height="450"
/>
</div>
))
}
</div>
<!-- Arrow Navigation -->
<button class="slider-arrow slider-arrow-left" aria-label="上一張">
<svg viewBox="0 0 24 24" width="24" height="24">
<path fill="currentColor" d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/>
</svg>
</button>
<button class="slider-arrow slider-arrow-right" aria-label="下一張">
<svg viewBox="0 0 24 24" width="24" height="24">
<path fill="currentColor" d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/>
</svg>
</button>
<!-- Dot Navigation -->
<div class="slider-dots">
{
slides.map((_, index) => (
<button
class={`slider-dot ${index === 0 ? 'active' : ''}`}
data-index={index}
aria-label={`顯示第 ${index + 1} 張照片`}
/>
))
}
</div>
</div>
</div>
</section>
<style>
/* Environment Slider Styles - Pixel-perfect from Webflow */
.section-video {
padding: 80px 20px;
background-color: #ffffff;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
.w-container {
max-width: 1200px;
margin: 0 auto;
}
.spacer8 {
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 {
position: relative;
width: 100%;
max-width: 1200px;
margin: 0 auto;
}
.slides-container {
display: flex;
overflow-x: auto;
scroll-snap-type: x mandatory;
scroll-behavior: smooth;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.slides-container::-webkit-scrollbar {
display: none;
}
.environment-slide {
flex: 0 0 100%;
scroll-snap-align: start;
aspect-ratio: 16/9;
overflow: hidden;
border-radius: 12px;
}
.environment-slide img {
width: 100%;
height: 100%;
object-fit: cover;
}
/* Arrow Navigation */
.slider-arrow {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 48px;
height: 48px;
background-color: rgba(255, 255, 255, 0.8);
border: none;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 200ms ease;
z-index: 10;
}
.slider-arrow:hover {
background-color: white;
transform: translateY(-50%) scale(1.1);
}
.slider-arrow-left {
left: 16px;
}
.slider-arrow-right {
right: 16px;
}
/* Dot Navigation */
.slider-dots {
display: flex;
justify-content: center;
gap: 8px;
margin-top: 16px;
}
.slider-dot {
width: 12px;
height: 12px;
border-radius: 50%;
background-color: rgba(35, 96, 140, 0.3);
border: none;
cursor: pointer;
transition: all 200ms ease;
}
.slider-dot.active {
background-color: var(--color-enchunblue);
width: 32px;
border-radius: 6px;
}
/* Responsive Adjustments */
@media (min-width: 992px) {
.environment-slider {
max-width: 100%;
}
}
@media (max-width: 991px) {
.environment-slider {
max-width: 550px;
}
.section_header_w_line {
flex-wrap: wrap;
}
}
@media (max-width: 767px) {
.section-video {
padding: 60px 16px;
}
.environment-slider {
max-width: 90vw;
}
.slider-arrow {
width: 40px;
height: 40px;
}
.slider-arrow-left {
left: 8px;
}
.slider-arrow-right {
right: 8px;
}
.header_subtitle_head {
font-size: 1.5rem;
}
}
</style>
<script>
// Environment Slider functionality
function initEnvironmentSlider() {
const sliders = document.querySelectorAll('.environment-slider')
sliders.forEach((slider) => {
const container = slider.querySelector('.slides-container') as HTMLElement
const slides = slider.querySelectorAll('.environment-slide')
const dots = slider.querySelectorAll('.slider-dot')
const prevBtn = slider.querySelector('.slider-arrow-left') as HTMLButtonElement
const nextBtn = slider.querySelector('.slider-arrow-right') as HTMLButtonElement
if (!container || slides.length === 0) return
let currentIndex = 0
const totalSlides = slides.length
let isDragging = false
let startPos = 0
let currentTranslate = 0
let prevTranslate = 0
let animationID: number
// Update slider position
const updateSlider = () => {
container.scrollTo({
left: currentIndex * container.offsetWidth,
behavior: 'smooth'
})
updateDots()
}
// Update dots
const updateDots = () => {
dots.forEach((dot, index) => {
if (index === currentIndex) {
dot.classList.add('active')
} else {
dot.classList.remove('active')
}
})
}
// Go to specific slide
const goToSlide = (index: number) => {
if (index < 0) index = totalSlides - 1
if (index >= totalSlides) index = 0
currentIndex = index
updateSlider()
}
// Previous slide
prevBtn?.addEventListener('click', () => goToSlide(currentIndex - 1))
// Next slide
nextBtn?.addEventListener('click', () => goToSlide(currentIndex + 1))
// Dot navigation
dots.forEach((dot, index) => {
dot.addEventListener('click', () => goToSlide(index))
})
// Scroll snap detection
container.addEventListener('scroll', () => {
const slideIndex = Math.round(container.scrollLeft / container.offsetWidth)
if (slideIndex !== currentIndex) {
currentIndex = slideIndex
updateDots()
}
})
// Touch/swipe support
const touchStart = (_index: number) => {
return function(event: TouchEvent) {
isDragging = true
startPos = event.touches[0].clientX
animationID = requestAnimationFrame(animation)
container.style.cursor = 'grabbing'
}
}
const touchEnd = () => {
isDragging = false
cancelAnimationFrame(animationID)
container.style.cursor = 'grab'
const movedBy = currentTranslate - prevTranslate
if (movedBy < -50 && currentIndex < totalSlides - 1) {
currentIndex += 1
} else if (movedBy > 50 && currentIndex > 0) {
currentIndex -= 1
}
goToSlide(currentIndex)
}
const touchMove = (event: TouchEvent) => {
if (isDragging) {
const currentPosition = event.touches[0].clientX
currentTranslate = prevTranslate + currentPosition - startPos
}
}
const animation = () => {
if (isDragging) requestAnimationFrame(animation)
}
// Mouse events for desktop
let mouseStartPos = 0
let isMouseDown = false
container.addEventListener('mousedown', (e: MouseEvent) => {
isMouseDown = true
mouseStartPos = e.clientX
container.style.cursor = 'grabbing'
})
container.addEventListener('mouseup', (e: MouseEvent) => {
if (!isMouseDown) return
isMouseDown = false
container.style.cursor = 'grab'
const movedBy = e.clientX - mouseStartPos
if (movedBy < -50 && currentIndex < totalSlides - 1) {
currentIndex += 1
} else if (movedBy > 50 && currentIndex > 0) {
currentIndex -= 1
}
goToSlide(currentIndex)
})
container.addEventListener('mouseleave', () => {
isMouseDown = false
container.style.cursor = 'grab'
})
// Touch events
container.addEventListener('touchstart', touchStart(currentIndex))
container.addEventListener('touchend', touchEnd)
container.addEventListener('touchmove', touchMove)
// Keyboard navigation
slider.addEventListener('keydown', (e) => {
if (e instanceof KeyboardEvent) {
if (e.key === 'ArrowLeft') goToSlide(currentIndex - 1)
if (e.key === 'ArrowRight') goToSlide(currentIndex + 1)
}
})
// Set initial state
container.style.cursor = 'grab'
})
}
// Initialize when DOM is ready
document.addEventListener('DOMContentLoaded', initEnvironmentSlider)
if (document.readyState !== 'loading') {
initEnvironmentSlider()
}
</script>

View File

@@ -0,0 +1,202 @@
---
/**
* FeatureSection - Service features section with 4 cards
* Pixel-perfect implementation based on Webflow design
*/
interface Props {
title?: string
subtitle?: string
}
const {
title = '我們的服務特色',
subtitle = '為什麼選擇恩群數位?',
} = Astro.props
// Four features data
const features = [
{
icon: 'location_on',
title: '在地化優先',
description: '緊上線下結合曝光渠道,整合多方資訊,帶給消費者最佳的使用體驗,展現商家的獨特之處,順利的將潛在使用者帶到你的實際門市。',
},
{
icon: 'account_balance',
title: '高投資轉換率',
description: '你覺得網路行銷很貴嗎?恩群數位善用每一分廣告預算,讓你在網路上發揮最大效益,幫助店家鎖定精準客群,達成目標。',
},
{
icon: 'analytics',
title: '數據優先',
description: '想要精準行銷?恩群數位從數據中萃取洞察,根據數據分析廣告成效,更聰明、有策略的幫您省下行銷預算。',
},
{
icon: 'handshake',
title: '關係優於銷售',
description: '除了幫您拓展網路上的知名度,我們更是每家公司最專業的數位夥伴,你會知道有恩群的存在,事業路上你並不孤單。',
},
]
---
<section class="section_feature" aria-labelledby="feature-heading">
<div class="w-container">
<!-- Section Header -->
<div class="section_header_w_line">
<h2 id="feature-heading" class="header_subtitle_head">
{title}
</h2>
<p class="header_subtitle_paragraph">
{subtitle}
</p>
<div class="divider_line"></div>
</div>
<!-- Features Grid -->
<div class="feature_grid">
{
features.map((feature) => (
<div class="feature_card">
<!-- Icon -->
<div class="feature_image">
<span class="material-icon">{feature.icon}</span>
</div>
<!-- Title -->
<h3 class="feature_head">{feature.title}</h3>
<!-- Description -->
<p class="feature_description">{feature.description}</p>
</div>
))
}
</div>
</div>
</section>
<style>
/* Feature Section Styles - Pixel-perfect from Webflow */
.section_feature {
background-color: #ffffff;
padding: 80px 20px;
}
.w-container {
max-width: 1200px;
margin: 0 auto;
}
/* Section Header */
.section_header_w_line {
text-align: center;
margin-bottom: 60px;
}
.header_subtitle_head {
color: var(--color-enchunblue);
font-weight: 700;
font-size: 2.25rem;
line-height: 1.2;
margin: 16px 0;
}
.header_subtitle_paragraph {
color: #666666;
font-weight: 400;
font-size: 1rem;
margin-top: 8px;
}
.divider_line {
background-color: var(--color-enchunblue);
height: 2px;
width: 100px;
margin: 0 auto;
}
/* Features Grid */
.feature_grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 30px;
}
/* Feature Card */
.feature_card {
background: #ffffff;
border-radius: var(--radius-lg);
padding: 32px;
transition: all var(--transition-base);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.feature_card:hover {
transform: translateY(-4px);
box-shadow: var(--shadow-lg);
}
/* Icon */
.feature_image {
width: 80px;
height: 80px;
margin-bottom: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.material-icon {
font-size: 64px;
color: var(--color-enchunblue);
}
/* Title */
.feature_head {
color: #333333;
font-weight: 600;
font-size: 1.25rem;
margin-bottom: 12px;
}
/* Description */
.feature_description {
color: #666666;
font-weight: 400;
font-size: 1rem;
line-height: 1.6;
}
/* Responsive Adjustments */
@media (max-width: 991px) {
.feature_grid {
grid-template-columns: repeat(2, 1fr);
gap: 24px;
}
.feature_card {
padding: 24px;
}
}
@media (max-width: 767px) {
.section_feature {
padding: 60px 16px;
}
.feature_grid {
grid-template-columns: 1fr;
gap: 16px;
}
.header_subtitle_head {
font-size: 1.75rem;
}
.feature_card {
padding: 20px;
}
.material-icon {
font-size: 48px;
}
}
</style>

View File

@@ -0,0 +1,268 @@
---
/**
* HeroSection - Pixel-perfect Hero component matching Webflow design
* Supports video background with fallback image and gradient overlay
*/
import { Image } from 'astro:assets'
import type { HomeData } from '@/lib/api/home'
interface Props {
homeData?: HomeData | null
fallbackDesktopVideo?: string
fallbackMobileVideo?: string
}
const {
homeData,
fallbackDesktopVideo = '/video/enchun-hero-background-video.mp4',
fallbackMobileVideo = '/video/enchun-hero-background-video.webm',
} = Astro.props
// Extract hero content from CMS data or use defaults
const heroHeadline = homeData?.heroHeadline || '創造企業更多發展的可能性\n是我們的使命'
const heroSubheadline = homeData?.heroSubheadline || "It's our destiny to create possibilities for your business."
// Get video URLs from CMS or fallback
const desktopVideoUrl = homeData?.heroDesktopVideo?.url || fallbackDesktopVideo
const mobileVideoUrl = homeData?.heroMobileVideo?.url || fallbackMobileVideo
// Get fallback image
const fallbackImageUrl = homeData?.heroFallbackImage?.url
// Get logo
const logoUrl = homeData?.heroLogo?.url || '/enchun-logo-full.svg'
// Helper to get base path without extension
const getBasePath = (path: string) => path.replace(/\.(mp4|webm)$/i, '')
// Generate sources for video elements
const getSources = (inputPath: string) => {
const basePath = getBasePath(inputPath)
return [
{ src: `${basePath}.webm`, type: 'video/webm' },
{ src: `${basePath}.mp4`, type: 'video/mp4' },
]
}
---
<!-- Fallback Image (shown when video fails to load) -->
{
fallbackImageUrl && (
<div class="absolute top-0 left-0 w-full h-[100dvh] z-0">
<img
src={fallbackImageUrl}
alt="Hero background"
class="w-full h-full object-cover"
loading="eager"
decoding="sync"
/>
<div class="absolute top-0 left-0 w-full h-full hero-overlay-home"></div>
</div>
)
}
<!-- Desktop Video -->
<video
id="desktop-video"
class="background-video hidden md:block absolute top-0 left-0 w-full h-[100dvh] object-cover z-0"
autoplay
muted
loop
playsinline
preload="metadata"
>
{
getSources(desktopVideoUrl).map((source) => (
<source src={source.src} type={source.type} />
))
}
</video>
<!-- Mobile Video -->
<video
id="mobile-video"
class="background-video md:hidden block absolute top-0 left-0 w-full h-[100dvh] object-cover z-0"
autoplay
muted
loop
playsinline
preload="metadata"
>
{
getSources(mobileVideoUrl).map((source) => (
<source src={source.src} type={source.type} />
))
}
</video>
<!-- Hero Content Overlay -->
<section class="hero-section -mt-[5rem] flex items-center justify-center relative z-10 overflow-hidden">
<!-- Gradient Overlay -->
<div class="absolute top-0 left-0 w-full h-full hero-overlay-home"></div>
<div class="max-w-6xl mx-auto w-full px-8 relative z-10">
<div class="flex items-center justify-start mb-6">
<!-- Logo (135px width per spec) -->
<Image
height={201}
width={135}
src={logoUrl}
alt="Enchun Logo"
class="hidden md:block hero-logo mr-8"
/>
<!-- Text Content -->
<div class="flex flex-col items-start justify-start">
<!-- Main Headline - 3.39em (64px at 19px base) -->
<h1 class="hero-title-head whitespace-break-spaces">
{heroHeadline}
</h1>
<!-- Subheadline - 1.56em (30px at 19px base) -->
<p class="hero-sub-paragraph-home">
{heroSubheadline}
</p>
</div>
</div>
</div>
</section>
<style>
/* Hero Section Styles - Pixel-perfect from Webflow */
.hero-section {
height: 100vh;
max-height: 88.5vh;
padding-top: 110px;
padding-bottom: 100px;
}
/* Gradient Overlay - left to right fade */
.hero-overlay-home {
background: linear-gradient(
to right,
rgba(0, 0, 0, 0.8) 0%,
rgba(0, 0, 0, 0) 100%
);
}
/* Background Video */
.background-video {
position: absolute;
z-index: -1;
}
/* Logo */
.hero-logo {
width: 135px;
padding-top: 33px;
}
/* Hero Title - 3.39em (64px at 19px base) */
.hero-title-head {
font-size: 3.39em;
line-height: 1.2;
text-align: left;
color: #ffffff;
font-family: "Noto Sans TC", sans-serif;
font-weight: 700;
}
/* Hero Subtitle - 1.56em (30px at 19px base) */
.hero-sub-paragraph-home {
font-size: 1.56em;
font-weight: 300;
line-height: 1.2;
text-align: left;
color: #f2f2f2;
font-family: "Quicksand", "Noto Sans TC", sans-serif;
}
/* Responsive Adjustments */
@media (max-width: 991px) {
.hero-section {
height: 100vh;
max-height: 100vh;
padding-top: 0;
padding-bottom: 0;
}
.hero-title-head {
font-size: 2.45em; /* ~47px */
}
.hero-sub-paragraph-home {
font-size: 1.15em; /* ~22px */
}
}
@media (max-width: 767px) {
.hero-title-head {
font-size: 7vw;
}
.hero-sub-paragraph-home {
font-size: 3.4vw;
}
}
</style>
<script>
// Enhanced mobile/desktop detection and video loading
function initVideoHero() {
const desktopVideo = document.getElementById('desktop-video') as HTMLVideoElement
const mobileVideo = document.getElementById('mobile-video') as HTMLVideoElement
if (!desktopVideo || !mobileVideo) return
// Function to check if we're on mobile
const isMobile = () => window.innerWidth < 768
// Function to switch videos based on device
const switchVideos = () => {
const mobile = isMobile()
if (mobile) {
desktopVideo.style.display = 'none'
mobileVideo.style.display = 'block'
} else {
desktopVideo.style.display = 'block'
mobileVideo.style.display = 'none'
}
}
// Initial switch
switchVideos()
// Listen for resize events
window.addEventListener('resize', () => {
switchVideos()
})
// Handle video loading errors
;(desktopVideo as any).onerror = () => {
console.warn('Desktop video failed to load')
}
;(mobileVideo as any).onerror = () => {
console.warn('Mobile video failed to load')
}
// Preload videos when they're hidden to improve switching
const preloadVideos = () => {
;[desktopVideo, mobileVideo].forEach((video) => {
if ((video as HTMLVideoElement).style.display === 'none') {
video.load()
}
})
}
// Preload on load
window.addEventListener('load', preloadVideos)
}
// Initialize when DOM is ready
document.addEventListener('DOMContentLoaded', initVideoHero)
if (document.readyState !== 'loading') {
initVideoHero()
}
</script>

View File

@@ -0,0 +1,369 @@
---
/**
* PainpointSection - Interactive tab section showing customer pain points
* Features: Tab switching, icon display, hover effects
*/
import type { HomeData } from '@/lib/api/home'
interface Props {
homeData?: HomeData | null
}
const { homeData } = Astro.props
// Pain points data - matching reference HTML exactly
const painpoints = [
{
id: 'company',
title: '行銷公司難找',
icon: '🔍',
description: '市場上有太多行銷公司了,每家都跟我說他們家的服務可以掛保證。但是我應該要找誰合作呢?',
},
{
id: 'methods',
title: '宣傳方法太多難選擇',
icon: '🤔',
description: '網路行銷方法這麼多Google、FB、IG、YT...哪種才適合我的產品?',
},
{
id: 'digital',
title: '數位轉型太難',
icon: '💻',
description: '想要做數位轉型,但不知道從哪裡開始?技術門檻太高?',
},
{
id: 'burning',
title: '廣告行銷像燒錢',
icon: '💸',
description: '廣告預算投入很多,但看不到實際效果?感覺像在燒錢?',
},
]
---
<section class="section-painpoint" aria-labelledby="painpoint-heading">
<div class="max-w-6xl mx-auto px-4">
<!-- Section Header -->
<div class="section-header-w-line">
<h2 id="painpoint-heading" class="header-subtitle-head">
你可能會遇到的煩惱
</h2>
<div class="divider-line"></div>
</div>
<!-- Tabs Container -->
<div class="painpoint-tabs" id="painpoint-tabs">
<!-- Tab Headers -->
<div class="tab-headers">
{
painpoints.map((point, index) => (
<button
class={`painpoint-text ${index === 0 ? 'active' : ''}`}
data-tab={point.id}
aria-selected={index === 0 ? 'true' : 'false'}
role="tab"
>
{point.title}
</button>
))
}
</div>
<!-- Tab Panels -->
<div class="tab-panels">
{
painpoints.map((point, index) => (
<div
id={`panel-${point.id}`}
class={`tab-panel-frame ${index === 0 ? 'active' : ''}`}
role="tabpanel"
aria-labelledby={`tab-${point.id}`}
hidden={index !== 0}
>
<!-- Icon -->
<div class="painpoint-icon">
<div class="icon-holder">
<span class="text-6xl" aria-hidden="true">{point.icon}</span>
</div>
</div>
<!-- Description -->
<p class="painpoint-description">
{point.description}
</p>
<!-- Solution Link -->
<div class="painpoint-solution">
<span class="solution-text">我們有解決方法</span>
<a href="/about-enchun" class="solution-link">
<span class="sr-only">了解更多</span>
</a>
</div>
</div>
))
}
</div>
</div>
</div>
</section>
<style>
/* Painpoint Section Styles - Pixel-perfect from Webflow */
.section-painpoint {
height: 80vh;
max-height: 80vh;
display: flex;
align-items: center;
}
/* Section Header */
.section-header-w-line {
text-align: center;
margin-bottom: 2rem;
}
.header-subtitle-head {
font-size: 1.8rem;
text-align: center;
color: var(--color-dark-blue);
font-weight: 500;
}
.divider-line {
background-color: var(--color-enchunblue);
height: 1px;
width: 60px;
margin: 16px auto;
}
/* Tabs Container */
.painpoint-tabs {
padding-top: 32px;
padding-bottom: 70px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 32px;
}
/* Tab Headers */
.tab-headers {
display: flex;
flex-direction: column;
gap: 24px;
justify-content: center;
}
.painpoint-text {
font-size: 2.08em;
font-weight: 500;
line-height: 1;
color: var(--color-dark-blue);
font-family: "Noto Sans TC", sans-serif;
cursor: pointer;
transition: text-shadow 0.3s ease;
background: none;
border: none;
text-align: left;
padding: 8px 0;
}
.painpoint-text:hover {
text-shadow: 7px 4px 13px rgba(0, 0, 0, 0.5);
}
.painpoint-text.active {
color: var(--color-enchunblue);
font-weight: 600;
}
/* Tab Panels */
.tab-panels {
position: relative;
}
.tab-panel-frame {
border: 1px solid var(--color-enchunblue);
border-radius: 10px;
position: relative;
top: -47px;
padding: 32px;
background: white;
opacity: 0;
transform: translateY(10px);
transition: opacity 0.3s ease, transform 0.3s ease;
display: none;
}
.tab-panel-frame.active {
opacity: 1;
transform: translateY(0);
display: block;
}
/* Icon */
.painpoint-icon {
height: 110px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 24px;
}
.icon-holder {
width: 130px;
height: 115px;
background-color: var(--color-grey6);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
}
/* Description */
.painpoint-description {
font-size: 1.2rem;
line-height: 1.6;
color: var(--color-gray-700);
text-align: center;
margin-bottom: 1rem;
}
/* Solution Section */
.painpoint-solution {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.solution-text {
font-size: 1.1rem;
font-weight: 600;
color: var(--color-enchunblue);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.solution-link {
width: 48px;
height: 48px;
background-color: var(--color-notification-red);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
text-decoration: none;
transition: transform 0.2s ease, background-color 0.2s ease;
position: relative;
}
.solution-link:hover {
transform: scale(1.1);
background-color: #b92d1a;
}
.solution-link::after {
content: '→';
font-size: 1.5rem;
font-weight: bold;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* Responsive Adjustments */
@media (max-width: 991px) {
.section-painpoint {
height: auto;
max-height: none;
padding: 64px 0;
}
.painpoint-tabs {
grid-template-columns: 1fr;
gap: 24px;
}
.tab-headers {
flex-direction: row;
overflow-x: auto;
padding-bottom: 8px;
}
.painpoint-text {
font-size: 1.5em;
white-space: nowrap;
}
.tab-panel-frame {
top: 0;
}
.header-subtitle-head {
font-size: 1.5rem;
}
}
@media (max-width: 767px) {
.painpoint-text {
font-size: 1.2em;
}
.icon-holder {
width: 100px;
height: 90px;
}
}
</style>
<script>
// Tab switching functionality
function initPainpointTabs() {
const tabs = document.querySelectorAll('.painpoint-text')
const panels = document.querySelectorAll('.tab-panel-frame')
tabs.forEach((tab) => {
tab.addEventListener('click', () => {
const tabId = tab.getAttribute('data-tab')
// Remove active class from all tabs
tabs.forEach(t => {
t.classList.remove('active')
t.setAttribute('aria-selected', 'false')
})
// Hide all panels
panels.forEach(p => {
p.classList.remove('active')
;(p as HTMLElement).hidden = true
})
// Activate clicked tab
tab.classList.add('active')
tab.setAttribute('aria-selected', 'true')
// Show corresponding panel
const activePanel = document.getElementById(`panel-${tabId}`)
if (activePanel) {
activePanel.classList.add('active')
;(activePanel as HTMLElement).hidden = false
}
})
})
}
// Initialize when DOM is ready
document.addEventListener('DOMContentLoaded', initPainpointTabs)
if (document.readyState !== 'loading') {
initPainpointTabs()
}
</script>

View File

@@ -0,0 +1,77 @@
---
/**
* PortfolioPreview - Portfolio preview section
*/
import PortfolioPreviewCard from '@/components/PortfolioPreviewCard.astro'
import type { HomeData } from '@/lib/api/home'
import type { PortfolioItem } from '@/lib/api/home'
interface Props {
homeData?: HomeData | null
portfolioItems?: PortfolioItem[]
}
const { homeData, portfolioItems = [] } = Astro.props
// Get section config from CMS or use defaults
const portfolioSection = homeData?.portfolioSection
const headline = portfolioSection?.headline || '精選案例'
const subheadline = portfolioSection?.subheadline || '探索我們為客戶打造的優質網站'
// Show section only if we have items
const showSection = portfolioItems.length > 0
---
{
showSection && (
<section
class="py-16 md:py-24 bg-white"
aria-labelledby="portfolio-heading"
>
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
<!-- Section Header -->
<div class="text-center mb-12 md:mb-16">
<h2
id="portfolio-heading"
class="text-3xl md:text-4xl font-bold text-[var(--color-text-primary)] mb-4"
>
{headline}
</h2>
<p class="text-lg text-[var(--color-text-muted)] max-w-2xl mx-auto">
{subheadline}
</p>
</div>
<!-- Portfolio Grid -->
<ul
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 md:gap-8 mb-12"
role="list"
>
{
portfolioItems.map((item) => (
<PortfolioPreviewCard item={item} />
))
}
</ul>
<!-- View All CTA -->
<div class="text-center">
<a
href="/website-portfolio"
class="inline-flex items-center justify-center px-8 py-3 bg-[var(--color-enchunblue)] text-white font-semibold rounded-lg hover:bg-[var(--color-enchunblue-dark)] transition-colors focus:outline-none focus:ring-2 focus:ring-[var(--color-enchunblue)] focus:ring-offset-2"
>
查看所有作品
<svg
class="w-5 h-5 ml-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
</a>
</div>
</div>
</section>
)
}

View File

@@ -0,0 +1,70 @@
---
/**
* ServiceFeatures - Service features grid section
*/
import ServiceFeatureCard from '@/components/ServiceFeatureCard.astro'
import type { HomeData, ServiceFeature } from '@/lib/api/home'
interface Props {
homeData?: HomeData | null
}
const { homeData } = Astro.props
// Default service features if not provided by CMS
const defaultFeatures: ServiceFeature[] = [
{
icon: '🎯',
title: 'Google 商家關鍵字',
description: '專業的關鍵字優化服務,提升您在 Google 搜尋結果的排名,讓客戶更容易找到您。',
},
{
icon: '📱',
title: '社群代操',
description: '全方位社群媒體經營,從內容策劃到數據分析,提供一站式社群行銷服務。',
},
{
icon: '💬',
title: '論壇行銷',
description: '深耕各大論壇社群,建立品牌口碑,提升用戶信任度與互動參與度。',
},
{
icon: '🎨',
title: '網站設計',
description: '現代化響應式網站設計,結合美學與功能,打造獨特的品牌形象和優質用戶體驗。',
},
]
const serviceFeatures: ServiceFeature[] = (homeData && homeData.serviceFeatures && homeData.serviceFeatures.length > 0)
? homeData.serviceFeatures
: defaultFeatures
---
<section class="py-16 md:py-24 bg-[var(--color-surface)]" aria-labelledby="services-heading">
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
<!-- Section Header -->
<div class="text-center mb-12 md:mb-16">
<h2
id="services-heading"
class="text-3xl md:text-4xl font-bold text-[var(--color-text-primary)] mb-4"
>
我們的服務
</h2>
<p class="text-lg text-[var(--color-text-muted)] max-w-2xl mx-auto">
提供全方位的數位行銷解決方案,協助您的品牌在數位時代脫穎而出
</p>
</div>
<!-- Services Grid -->
<ul
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 md:gap-8"
role="list"
>
{
serviceFeatures.map((feature) => (
<ServiceFeatureCard feature={feature} />
))
}
</ul>
</div>
</section>

View File

@@ -0,0 +1,373 @@
---
/**
* ServicesList - Services list with zig-zag alternating layout
* Pixel-perfect implementation based on Webflow design
*/
interface ServicesListItem {
id: string
title: string
description: string
category: string
icon?: string
isHot?: boolean
image?: string
link?: string
}
interface Props {
services?: ServicesListItem[]
}
const {
services,
} = Astro.props
// Default services data (can be fetched from CMS)
const defaultServices: ServicesListItem[] = [
{
id: 'social-media',
title: '社群經營代操',
category: '海洋專案',
icon: 'facebook',
isHot: true,
image: '/placeholder-service-1.jpg',
description: '專業社群媒體經營團隊,從內容策劃、社群經營到數據分析,提供一站式社群代操服務。我們擅長經營 Facebook、Instagram 等主流平台,幫助品牌建立強大的社群影響力。',
},
{
id: 'google-business',
title: 'Google 商家關鍵字',
category: 'Google',
icon: 'google',
isHot: true,
image: '/placeholder-service-2.jpg',
description: '優化 Google 商家列表,提升在地搜尋排名。透過關鍵字策略、評論管理和商家資訊優化,讓您的商家在 Google 地圖和搜尋結果中脫穎而出。',
},
{
id: 'google-ads',
title: 'Google Ads 關鍵字',
category: 'Google',
icon: 'ads',
isHot: false,
image: '/placeholder-service-3.jpg',
description: '專業的 Google Ads 投放服務,從關鍵字研究、廣告文案撰寫到出價策略優化,精準觸達目標受眾,最大化廣告投資報酬率。',
},
{
id: 'news-media',
title: '網路新聞媒體',
category: '媒體行銷',
icon: 'news',
isHot: false,
image: '/placeholder-service-4.jpg',
description: '與各大新聞媒體合作,提供新聞發佈、媒體採訪、品牌曝光等服務。透過專業的新聞行銷策略,提升品牌知名度和公信力。',
},
{
id: 'influencer',
title: '網紅行銷專案',
category: '口碑行銷',
icon: 'youtube',
isHot: true,
image: '/placeholder-service-5.jpg',
description: '連結品牌與網紅/KOL打造影響者行銷活動。從網紅篩選、活動策劃到內容製作提供完整的網紅行銷解決方案快速建立品牌口碑。',
},
{
id: 'forum',
title: '論壇行銷專案',
category: '口碑行銷',
icon: 'forum',
isHot: false,
image: '/placeholder-service-6.jpg',
description: '深耕各大論壇社群,包括 Dcard、PTT 等平台。透過專業的論壇行銷策略,建立品牌口碑,提升用戶信任度和互動參與度。',
},
{
id: 'website-design',
title: '形象網站設計',
category: '品牌行銷',
icon: 'web',
isHot: false,
image: '/placeholder-service-7.jpg',
description: '現代化響應式網站設計服務,結合美學與功能,打造獨特的品牌形象。從 UI/UX 設計到前端開發,提供完整的網站解決方案。',
},
{
id: 'brand-video',
title: '品牌形象影片',
category: '品牌行銷',
icon: 'video',
isHot: false,
image: '/placeholder-service-8.jpg',
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>`,
}
---
<section class="section_service-list" aria-labelledby="services-heading">
<div class="services-container">
{
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>
<!-- Title -->
<h2
id={`service-${service.id}-title`}
class="service-title"
>
{service.title}
</h2>
<!-- Divider -->
<div class="service-divider"></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"
/>
</div>
<!-- Hot Badge -->
{
service.isHot && (
<span class="service-hot-badge">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

@@ -0,0 +1,94 @@
---
/**
* SolutionsHero - Hero section for Solutions/Services page
* Pixel-perfect implementation based on Webflow design
*/
interface Props {
title?: string
subtitle?: string
}
const {
title = '行銷解決方案',
subtitle = '提供全方位的數位行銷服務,協助您的品牌在數位時代脫穎而出',
} = Astro.props
---
<section class="hero-overlay-solution">
<div class="max-w-6xl mx-auto">
<!-- Main Title -->
<h1 class="hero_title_head-solution">
{title}
</h1>
<!-- Subtitle -->
<p class="hero_sub_paragraph-solution">
{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

@@ -0,0 +1,257 @@
---
/**
* StatisticsSection - Data showcase with countup animation
* Features: 4 statistics, countup animation (3s), responsive grid
*/
import type { HomeData } from '@/lib/api/home'
interface Props {
homeData?: HomeData | null
}
const { homeData } = Astro.props
// Statistics data - matching reference HTML exactly
const statistics = [
{
number: 500,
suffix: '',
percentage: '',
description: '至今協助商家數',
},
{
number: 150,
suffix: '.0',
percentage: '%',
description: '提升成長率',
},
{
number: 98,
suffix: '.0',
percentage: '%',
description: '客戶滿意度',
},
{
number: 95,
suffix: '.0',
percentage: '%',
description: '客戶續約率',
},
]
---
<!-- Statistics Section - Matching reference HTML structure with H1 heading -->
<section class="section-digi-running" aria-labelledby="statistics-heading">
<!-- Section Header - H1 "數據會說話" -->
<div class="section-header-w-line">
<h1 id="statistics-heading" class="statistics-heading">
數據會說話
</h1>
<p class="statistics-subheading">
Statistics reveal the truth
</p>
</div>
<!-- Statistics Data Grid -->
<div class="digi-holder-grid">
{
statistics.map((stat) => (
<div class="digi-item" data-number={stat.number}>
<!-- Number -->
<div class="text-no">
<span class="countup-number" data-target={stat.number}>0</span>
{stat.suffix}
</div>
<!-- Percentage (if separate from suffix) -->
{
stat.percentage && (
<span class="text-percentage">{stat.percentage}</span>
)
}
<!-- Description -->
<p class="text-description">{stat.description}</p>
</div>
))
}
</div>
</section>
<style>
/* Statistics Section Styles - Pixel-perfect from Webflow */
.section-digi-running {
padding: 64px 0;
text-align: center;
}
/* Section Header - Matching reference HTML */
.section-header-w-line {
margin-bottom: 3rem;
}
.statistics-heading {
font-family: "Noto Sans TC", sans-serif;
font-size: 1.8em; /* 34.2px at 19px base */
font-weight: 700;
color: var(--color-dark-blue, #062841);
text-align: center;
margin-bottom: 0.5rem;
}
.statistics-subheading {
font-family: "Quicksand", "Noto Sans TC", sans-serif;
font-size: 1rem;
font-weight: 400;
color: var(--color-gray-600, #666);
text-align: center;
margin: 0;
}
.digi-holder-grid {
display: grid;
grid-column-gap: 85px;
grid-row-gap: 16px;
grid-template-columns: repeat(4, max-content);
justify-content: center;
max-width: 1150px;
margin: 0 auto;
}
.digi-item {
text-align: center;
}
/* Large Number - 72px with amber color */
.text-no {
font-size: 72px;
font-weight: 700;
line-height: 1.2;
color: var(--color-amber);
display: flex;
align-items: baseline;
justify-content: center;
gap: 4px;
}
.countup-number {
display: inline-block;
}
/* Percentage - 36px */
.text-percentage {
font-size: 36px;
font-weight: 700;
line-height: 1.2;
color: var(--color-gray-700);
}
/* Description - 30px */
.text-description {
font-size: 30px;
font-weight: 100;
line-height: 1.2;
color: var(--color-dark-blue);
margin-top: 8px;
}
/* Responsive Adjustments */
@media (max-width: 991px) {
.digi-holder-grid {
grid-template-columns: repeat(2, max-content);
grid-column-gap: 40px;
}
.text-no {
font-size: 56px;
}
.text-percentage {
font-size: 28px;
}
.text-description {
font-size: 24px;
}
}
@media (max-width: 767px) {
.digi-holder-grid {
grid-template-columns: max-content;
grid-column-gap: 24px;
grid-row-gap: 32px;
}
.text-no {
font-size: 48px;
}
.text-percentage {
font-size: 24px;
}
.text-description {
font-size: 20px;
}
}
</style>
<script>
// Countup animation function
function animateCountup(element: Element, target: number, duration: number = 3000) {
const start = 0
const startTime = performance.now()
function update(currentTime: number) {
const elapsed = currentTime - startTime
const progress = Math.min(elapsed / duration, 1)
// Easing function (ease-out)
const easeOut = 1 - Math.pow(1 - progress, 3)
const current = Math.floor(start + (target - start) * easeOut)
element.textContent = current.toString()
if (progress < 1) {
requestAnimationFrame(update)
} else {
element.textContent = target.toString()
}
}
requestAnimationFrame(update)
}
// Intersection Observer for triggering animation when visible
function initStatisticsCountup() {
const observerOptions = {
root: null,
rootMargin: '0px',
threshold: 0.5,
}
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const numbers = entry.target.querySelectorAll('.countup-number')
numbers.forEach(num => {
const target = parseInt(num.getAttribute('data-target') || '0', 10)
animateCountup(num, target, 3000)
})
observer.unobserve(entry.target)
}
})
}, observerOptions)
const section = document.querySelector('.section-digi-running')
if (section) {
observer.observe(section)
}
}
// Initialize when DOM is ready
document.addEventListener('DOMContentLoaded', initStatisticsCountup)
if (document.readyState !== 'loading') {
initStatisticsCountup()
}
</script>

View File

@@ -0,0 +1,95 @@
---
/**
* TeamsHero - Hero section for Teams page
* Pixel-perfect implementation based on Webflow design
*/
interface Props {
title?: string
subtitle?: string
}
const {
title = '恩群大本營',
subtitle = 'Team members of Enchun',
} = 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>
</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

@@ -1,204 +1,385 @@
/* Theme CSS Variables and Custom Styles */
/**
* Theme CSS Variables and Custom Styles
* 恩群數位行銷 - Enchun Digital Marketing
*
* Design tokens extracted from Webflow CSS
* Last Updated: 2026-01-31
*/
/* ============================================
CSS CUSTOM PROPERTIES - DESIGN TOKENS
============================================ */
/* CSS Custom Properties for Theme */
:root {
/* Color Palette */
--color-primary: #1F3A93;
--color-secondary: #F39C12;
--color-accent: #16A085;
--color-enchunblue: #3083BF;
--color-background: #FFFFFF;
--color-surface: #F7FAFC;
--color-text: #1A202C;
--color-text-muted: #718096;
--color-border: #E2E8F0;
/*
Purpose:
Define extended Enchun brand color palette as CSS custom properties, all prefixed with --color- for consistency and semantic clarity.
This ensures all color variables are easily discoverable and maintainable across the codebase.
*/
--color-alabaster: #fafafa;
--color-alto: #d1d1d1;
--color-amber: #ffc107;
--color-black: #000000;
--color-boston-blue: #3083bf;
--color-concrete: #f2f2f2;
--color-cream-can: #f6c456;
--color-dove-gray: #6b6b6b;
--color-dusty-gray: #999999;
--color-emperor: #4f4f4f;
--color-gray: #878787;
--color-killarney: #3c6f50;
--color-lucky-point: #171c61;
--color-manatee: #939494;
--color-mercury: #e3e3e3;
--color-mine-shaft: #333333;
--color-mine-shaft-60: #222222;
--color-nobel: #b6b6b6;
--color-oslo-gray: #939494;
--color-pomegranate: #f44336;
--color-silver: #bdbdbd;
--color-silver-chalice: #acacac;
--color-st-tropaz: #2b618f;
--color-tarawera: #062841;
--color-tropical-blue: #c7e4fa;
--color-tundora: #4d4d4d;
--color-turbo: #ffef00;
--color-valencia: #d84038;
--color-viking: #67aee1;
--color-white: #ffffff;
--color-wild-sand: #f6f6f6;
/* ============================================
🎨 COLORS - Extracted from Webflow CSS
============================================ */
/* Typography */
--font-family-sans: 'Noto Sans CJK TC', 'Inter', system-ui, -apple-system, sans-serif;
--font-family-heading: 'Noto Sans CJK TC', 'Inter', system-ui, -apple-system, sans-serif;
/* Primary Colors (主要色) */
--color-primary: #3898ec; /* Primary blue */
--color-primary-dark: #0082f3; /* Deep blue */
--color-primary-light: #67aee1; /* Light blue */
--color-primary-hover: #2895f7; /* Hover blue */
/* Spacing */
--spacing-xs: 0.25rem;
--spacing-sm: 0.5rem;
--spacing-md: 1rem;
--spacing-lg: 1.5rem;
--spacing-xl: 2rem;
--spacing-2xl: 3rem;
/* Secondary Colors (次要色) */
--color-secondary: #f39c12; /* Secondary orange */
--color-secondary-light: #f6c456; /* Light pink */
--color-secondary-dark: #d84038; /* Deep orange */
/* Border Radius */
--radius-sm: 0.25rem;
--radius-md: 0.5rem;
--radius-lg: 0.75rem;
--radius-xl: 1rem;
/* Accent Colors (強調色) */
--color-accent: #d84038; /* Accent red-orange */
--color-accent-light: #f6c456; /* Light pink */
--color-accent-dark: #ea384c; /* Deep red */
--color-accent-pink: #bb8282; /* Pink */
--color-accent-rose: #c48383; /* Rose */
/* Shadows */
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
/* Link Colors (連結色) */
--color-link: #3083bf; /* Default link */
--color-link-hover: #23608c; /* Link hover */
/* Transitions */
--transition-fast: 150ms ease-in-out;
--transition-normal: 250ms ease-in-out;
--transition-slow: 350ms ease-in-out;
/* Neutral Colors (中性色 - Tailwind Slate mapping) */
--color-white: #ffffff; /* White */
--color-black: #000000; /* Black */
--color-gray-50: #fafafa; /* Surface lightest */
--color-gray-100: #f5f5f5; /* Surface light */
--color-gray-200: #f3f3f3; /* Surface */
--color-gray-300: #eeeeee; /* Border light */
--color-gray-400: #dddddd; /* Border default */
--color-gray-500: #c8c8c8; /* Mid gray */
--color-gray-600: #999999; /* Text muted */
--color-gray-700: #828282; /* Text dark */
--color-gray-800: #758696; /* Dark gray 1 */
--color-gray-900: #5d6c7b; /* Dark gray 2 */
--color-gray-950: #4f4f4f; /* Darkest gray */
/* Semantic Colors (語意化顏色) */
--color-text-primary: #333333; /* Primary text */
--color-text-secondary: #222222; /* Secondary text */
--color-text-muted: #999999; /* Muted text */
--color-text-light: #758696; /* Light text on dark */
--color-text-inverse: #ffffff; /* Inverse (white) */
/* Backgrounds (背景色) */
--color-background: #f2f2f2; /* Main background - Grey 6 from Webflow */
--color-surface: #fafafa; /* Surface background */
--color-surface2: #f3f3f3; /* Surface elevated */
--color-surface-dark: #2226; /* Dark background */
/* Borders (邊框色) */
--color-border: #e2e8f0; /* Default border */
--color-border-light: #dddddd; /* Light border */
/* Category Colors (文章分類) */
--color-category-google: #67aee1; /* Google小學堂 */
--color-category-meta: #8974de; /* Meta小學堂 */
--color-category-news: #3083bf; /* 行銷時事最前線 */
--color-category-enchun: #3898ec; /* 恩群數位 */
/* Badge Colors (標籤) */
--color-badge-hot: #ea384c; /* Hot 標籤 (紅) */
--color-badge-new: #67aee1; /* New 標籤 (淡藍) */
/* Webflow Colors - Story 1-4 Global Layout (Verified against original) */
--color-enchunblue: #23608c; /* Enchun Blue - 品牌/主色 */
--color-enchunblue-dark: #3083bf; /* Enchun Blue Dark - 品牌/主色深色 */
--color-tropical-blue: #c7e4fa; /* Tropical Blue - 頁腳背景 (verified: rgb(199, 228, 250)) */
--color-st-tropaz: #5d7285; /* St. Tropaz - 頁腳文字 */
--color-amber: #f6c456; /* Amber - CTA/強調 */
--color-tarawera: #2d3748; /* Tarawera - 深色文字 */
--color-nav-link: var(--color-gray-200); /* Navigation Link - 使用灰色系 */
/* Webflow Additional Colors - Story 1-5 Homepage */
--color-notification-red: #d84038; /* Notification Red - CTA Button */
--color-dark-blue: #062841; /* Dark Blue - Headings */
--color-medium-blue: #67aee1; /* Medium Blue - Accents */
--color-grey5: #e0e0e0; /* Grey 5 - Borders */
--color-grey6: #f2f2f2; /* Grey 6 - Backgrounds */
/* ============================================
🔤 TYPOGRAPHY - From Webflow
============================================ */
--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;
/* ============================================
📏 SPACING - Based on Tailwind scale
============================================ */
--spacing-xs: 0.25rem; /* 4px */
--spacing-sm: 0.5rem; /* 8px */
--spacing-md: 1rem; /* 16px */
--spacing-lg: 1.5rem; /* 24px */
--spacing-xl: 2rem; /* 32px */
--spacing-2xl: 3rem; /* 48px */
--spacing-3xl: 4rem; /* 64px */
/* Container */
--container-max-width: 1200px;
--container-padding: 1.5rem;
--container-padding-lg: 2rem;
/* ============================================
🔲 BORDER RADIUS
============================================ */
--radius-sm: 0.125rem; /* 2px */
--radius: 0.375rem; /* 6px (DEFAULT) */
--radius-md: 0.5rem; /* 8px */
--radius-lg: 0.75rem; /* 12px */
--radius-xl: 1rem; /* 16px */
--radius-2xl: 1.5rem; /* 24px */
--radius-3xl: 2rem; /* 32px */
--radius-full: 9999px; /* Full circle */
/* ============================================
💫 SHADOWS
============================================ */
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px 0 rgb(0 0 0 / 0.06);
--shadow-md:
0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--shadow-lg:
0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
/* ============================================
⏱ TRANSITIONS
============================================ */
--transition-fast: 150ms ease-in-out;
--transition-base: 200ms ease-in-out;
--transition-normal: 250ms ease-in-out;
--transition-slow: 350ms ease-in-out;
/* ============================================
🎯 Z-INDEX LAYERS
============================================ */
--z-dropdown: 1000;
--z-sticky: 1020;
--z-modal: 1040;
--z-popover: 1060;
--z-tooltip: 1080;
/* ============================================
📱 RESPONSIVE - Font Size Adjustments (Webflow)
============================================ */
/* Webflow breakpoints:
Desktop default: 19px
Tablet (≤991px): 19px
Mobile (≤767px): 16px
Small (≤479px): 13px
*/
--html-font-size-desktop: 19px;
--html-font-size-tablet: 19px; /* 991px breakpoint */
--html-font-size-mobile: 16px; /* 767px breakpoint */
--html-font-size-small: 13px; /* 479px breakpoint */
}
/* Dark Theme (if needed in future) */
@media (prefers-color-scheme: dark) {
:root {
--color-background: #1A202C;
--color-surface: #2D3748;
--color-text: #F7FAFC;
--color-text-muted: #A0AEC0;
--color-border: #4A5568;
}
}
/* ============================================
🌙 DARK MODE (Optional - for future use)
============================================ */
/*@media (prefers-color-scheme: dark) {
:root {
--color-background: #1a202c;
--color-surface: #2d3748;
--color-surface2: #1a202c;
--color-text: #f7fafc;
--color-text-muted: #a0aec0;
--color-text-secondary: #e2e8f0;
--color-border: #4a5568;
}
}*/
/* ============================================
📐 BASE STYLES
============================================ */
/* Base Styles */
* {
box-sizing: border-box;
box-sizing: border-box;
}
html {
font-family: var(--font-family-sans);
line-height: 1.6;
color: var(--color-text);
background-color: var(--color-background);
font-family: var(--font-family-sans);
line-height: 1.5;
color: var(--color-text-primary);
background-color: var(--color-background);
font-size: var(--html-font-size-desktop);
}
body {
margin: 0;
padding: 0;
font-family: inherit;
line-height: inherit;
color: inherit;
background-color: inherit;
margin: 0;
padding: 0;
font-family: inherit;
line-height: inherit;
color: inherit;
background-color: inherit;
}
/* Typography Classes */
/* ============================================
📱 RESPONSIVE FONT SIZE ADJUSTMENTS
============================================ */
@media (max-width: 991px) {
html {
font-size: var(--html-font-size-tablet);
}
}
@media (max-width: 767px) {
html {
font-size: var(--html-font-size-mobile);
}
}
@media (max-width: 479px) {
html {
font-size: var(--html-font-size-small);
}
}
/* ============================================
🎨 UTILITY CLASSES
============================================ */
/* Text Gradient */
.text-gradient {
background: linear-gradient(135deg, var(--color-primary), var(--color-accent));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
background: linear-gradient(
135deg,
var(--color-primary),
var(--color-accent)
);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Animation Classes */
/* Animations */
.fade-in {
animation: fadeIn var(--transition-normal) ease-in-out;
animation: fadeIn var(--transition-normal) ease-in-out;
}
.slide-up {
animation: slideUp var(--transition-normal) ease-in-out;
animation: slideUp var(--transition-normal) ease-in-out;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(1rem);
}
to {
opacity: 1;
transform: translateY(0);
}
from {
opacity: 0;
transform: translateY(1rem);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Utility Classes */
/* Glass Effect */
.glass-effect {
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
}
/* Shadow Custom */
.shadow-custom {
box-shadow: var(--shadow-lg);
box-shadow: var(--shadow-lg);
}
/* Component Specific Styles */
/* ============================================
🔘 BUTTON STYLES
============================================ */
.btn-primary {
background: var(--color-primary);
color: white;
padding: var(--spacing-sm) var(--spacing-lg);
border-radius: var(--radius);
border: none;
font-weight: 600;
cursor: pointer;
transition: all var(--transition-fast);
}
.btn-primary:hover {
background: var(--color-primary-hover);
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
.btn-secondary {
background: var(--color-secondary);
color: white;
padding: var(--spacing-sm) var(--spacing-lg);
border-radius: var(--radius);
border: none;
font-weight: 600;
cursor: pointer;
transition: all var(--transition-fast);
}
.btn-secondary:hover {
background: var(--color-secondary-dark);
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
/* ============================================
🧭 NAVIGATION STYLES
============================================ */
.nav-link {
position: relative;
transition: color var(--transition-fast);
position: relative;
transition: color var(--transition-fast);
}
.nav-link::after {
content: '';
position: absolute;
bottom: -2px;
left: 0;
width: 0;
height: 2px;
background: var(--color-primary);
transition: width var(--transition-fast);
content: "";
position: absolute;
bottom: -2px;
left: 0;
width: 0;
height: 2px;
background: var(--color-primary);
transition: width var(--transition-fast);
}
.nav-link:hover::after {
width: 100%;
}
/* Active Navigation Link Indicator */
.nav-active {
position: relative;
width: 100%;
}
/* Active Navigation Link */
.nav-active::after {
content: '';
position: absolute;
bottom: -2px;
left: 50%;
transform: translateX(-50%);
width: 70%;
height: 2px;
background: var(--color-secondary);
content: "";
position: absolute;
bottom: -2px;
left: 50%;
transform: translateX(-50%);
width: 70%;
height: 2px;
background: var(--color-secondary);
}
/* Prose/Markdown Styles */
/* ============================================
📝 PROSE / MARKDOWN STYLES
============================================ */
.prose-custom {
color: var(--color-text);
font-family: var(--font-family-sans);
color: var(--color-text-primary);
font-family: var(--font-family-sans);
}
.prose-custom h1,
@@ -207,158 +388,160 @@ body {
.prose-custom h4,
.prose-custom h5,
.prose-custom h6 {
color: var(--color-text);
font-weight: 700;
line-height: 1.2;
margin-top: var(--spacing-xl);
margin-bottom: var(--spacing-md);
color: var(--color-text-primary);
font-weight: 700;
line-height: 1.2;
margin-top: var(--spacing-xl);
margin-bottom: var(--spacing-md);
}
.prose-custom h1 {
font-size: 2.25rem;
font-size: 2.25rem;
}
.prose-custom h2 {
font-size: 1.875rem;
font-size: 1.875rem;
}
.prose-custom h3 {
font-size: 1.5rem;
font-size: 1.5rem;
}
.prose-custom p {
margin-bottom: var(--spacing-md);
line-height: 1.7;
margin-bottom: var(--spacing-md);
line-height: 1.7;
}
.prose-custom a {
color: var(--color-primary);
text-decoration: none;
transition: color var(--transition-fast);
color: var(--color-link);
text-decoration: none;
transition: color var(--transition-fast);
}
.prose-custom a:hover {
color: #1a2f7a;
text-decoration: underline;
color: var(--color-link-hover);
text-decoration: underline;
}
.prose-custom strong {
color: var(--color-text);
font-weight: 600;
color: var(--color-text-primary);
font-weight: 600;
}
.prose-custom em {
color: var(--color-text-muted);
}
.prose-custom ul,
.prose-custom ol {
margin-bottom: var(--spacing-md);
padding-left: var(--spacing-lg);
}
.prose-custom li {
margin-bottom: var(--spacing-xs);
line-height: 1.6;
}
.prose-custom blockquote {
border-left: 4px solid var(--color-primary);
padding-left: var(--spacing-md);
margin: var(--spacing-lg) 0;
color: var(--color-text-muted);
font-style: italic;
background: var(--color-surface);
padding: var(--spacing-md);
border-radius: var(--radius-md);
color: var(--color-text-muted);
}
.prose-custom code {
background: var(--color-surface);
color: var(--color-text);
padding: 0.125rem 0.25rem;
border-radius: var(--radius-sm);
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 0.875em;
background: var(--color-surface);
color: var(--color-text-primary);
padding: 0.125rem 0.25rem;
border-radius: var(--radius-sm);
font-family: "Monaco", "Menlo", monospace;
font-size: 0.875em;
}
.prose-custom pre {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--spacing-md);
overflow-x: auto;
margin: var(--spacing-lg) 0;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--spacing-md);
overflow-x: auto;
margin: var(--spacing-lg) 0;
}
.prose-custom pre code {
background: transparent;
padding: 0;
border-radius: 0;
.prose-custom blockquote {
border-left: 4px solid var(--color-primary);
padding-left: var(--spacing-md);
margin: var(--spacing-lg) 0;
color: var(--color-text-muted);
font-style: italic;
background: var(--color-surface);
padding: var(--spacing-md);
border-radius: var(--radius-md);
}
.prose-custom hr {
border: 0;
border-top: 1px solid var(--color-border);
margin: var(--spacing-2xl) 0;
}
/* ============================================
📊 TABLE STYLES
============================================ */
.prose-custom table {
width: 100%;
border-collapse: collapse;
margin: var(--spacing-lg) 0;
width: 100%;
border-collapse: collapse;
margin: var(--spacing-lg) 0;
}
.prose-custom th,
.prose-custom td {
border: 1px solid var(--color-border);
padding: var(--spacing-sm) var(--spacing-md);
text-align: left;
border: 1px solid var(--color-border);
padding: var(--spacing-sm) var(--spacing-md);
text-align: left;
}
.prose-custom th {
background: var(--color-surface);
font-weight: 600;
background: var(--color-surface);
font-weight: 600;
}
/* ============================================
🖼️ IMAGE STYLES
============================================ */
.prose-custom img {
max-width: 100%;
height: auto;
border-radius: var(--radius-md);
margin: var(--spacing-md) 0;
max-width: 100%;
height: auto;
border-radius: var(--radius-md);
margin: var(--spacing-md) 0;
}
/* Button Styles */
.btn-primary {
background: var(--color-primary);
color: white;
padding: var(--spacing-sm) var(--spacing-lg);
border-radius: var(--radius-md);
border: none;
font-weight: 600;
cursor: pointer;
transition: all var(--transition-fast);
/* ============================================
📏 HR (DIVIDER) STYLES
============================================ */
.prose-custom hr {
border: 0;
border-top: 1px solid var(--color-border);
margin: var(--spacing-2xl) 0;
}
.btn-primary:hover {
background: #1a2f7a;
transform: translateY(-1px);
box-shadow: var(--shadow-md);
/* ============================================
🏷️ CATEGORY BADGES
============================================ */
.badge {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: var(--radius-sm);
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.btn-secondary {
background: var(--color-secondary);
color: white;
padding: var(--spacing-sm) var(--spacing-lg);
border-radius: var(--radius-md);
border: none;
font-weight: 600;
cursor: pointer;
transition: all var(--transition-fast);
.badge-hot {
background-color: var(--color-badge-hot);
color: white;
}
.btn-secondary:hover {
background: #e08e0b;
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
.badge-new {
background-color: var(--color-badge-new);
color: white;
}
/* Category Badge Colors */
.badge-category-google {
background-color: var(--color-category-google);
color: white;
}
.badge-category-meta {
background-color: var(--color-category-meta);
color: white;
}
.badge-category-news {
background-color: var(--color-category-news);
color: white;
}
.badge-category-enchun {
background-color: var(--color-category-enchun);
color: white;
}