Compare commits

5 Commits

Author SHA1 Message Date
2e32d52133 Refactor frontend sections to use Tailwind CSS
Convert custom CSS styling to Tailwind utility classes across marketing
and about pages. Improve responsive layouts in feature grids, service
lists, and sliders. Consolidate section headers using the shared
SectionHeader component to maintain visual consistency.
2026-03-11 17:42:34 +08:00
df1efb4881 feat(contact): implement Turnstile protection via API proxy
- Add `pages/api/contact.ts` to proxy n8n webhook and verify Turnstile tokens.
- Update `contact-us.astro` form to include Turnstile widget and validation logic.
- Replace hardcoded sitekey with `PUBLIC_TURNSTILE_SITE_KEY` from environment variables.
- Update `dev.vars` to include Cloudflare Turnstile keys.
2026-03-01 14:06:44 +08:00
84b5a498e6 feat: Redesign the contact page by adding a contact image and updating the form layout and styling. 2026-03-01 12:51:36 +08:00
173905ecd3 Extract generic UI components
Reduces duplication across marketing pages by converting sections into
reusable components like CtaSection and HeaderBg. Consolidates styling
patterns to improve maintainability and consistency of the user interface.
2026-02-28 04:55:25 +08:00
b199f89998 Integrate CMS with Marketing Solutions page
Links the marketing solutions frontend page to the Payload CMS Pages
collection via the new API library. Removes legacy static portfolio
routes and components to consolidate marketing content. Enhances the
Header and Footer Astro components with improved responsive styling.
2026-02-27 20:05:43 +08:00
44 changed files with 8096 additions and 4900 deletions

View File

@@ -16,25 +16,9 @@ import { generateMeta } from '@/utilities/generateMeta'
import PageClient from './page.client'
import { LivePreviewListener } from '@/components/LivePreviewListener'
export async function generateStaticParams() {
const payload = await getPayload({ config: configPromise })
const posts = await payload.find({
collection: 'posts',
draft: false,
limit: 1000,
overrideAccess: false,
pagination: false,
select: {
slug: true,
},
})
const params = posts.docs.map(({ slug }) => {
return { slug }
})
return params
}
// Use dynamic rendering instead of static generation
// This avoids the need for database connection during build time
export const dynamic = 'force-dynamic'
type Args = {
params: Promise<{

View File

@@ -8,7 +8,8 @@ import { getPayload } from 'payload'
import React from 'react'
import PageClient from './page.client'
export const dynamic = 'force-static'
// Use dynamic rendering to avoid database connection during build
export const dynamic = 'force-dynamic'
export const revalidate = 600
export default async function Page() {

View File

@@ -9,6 +9,8 @@ import React from 'react'
import PageClient from './page.client'
import { notFound } from 'next/navigation'
// Use dynamic rendering to avoid database connection during build
export const dynamic = 'force-dynamic'
export const revalidate = 600
type Args = {

View File

@@ -0,0 +1,86 @@
import type { Block } from 'payload'
export const ServicesList: Block = {
slug: 'servicesList',
interfaceName: 'ServicesListBlock',
fields: [
{
name: 'services',
type: 'array',
required: true,
admin: {
initCollapsed: true,
},
fields: [
{
name: 'title',
type: 'text',
required: true,
},
{
name: 'description',
type: 'textarea',
required: true,
},
{
name: 'category',
type: 'text',
required: true,
},
{
name: 'iconType',
type: 'select',
defaultValue: 'preset',
options: [
{ label: 'Preset Icon', value: 'preset' },
{ label: 'Custom SVG', value: 'svg' },
{ label: 'Upload Image', value: 'upload' },
],
},
{
name: 'icon',
type: 'text',
admin: {
description: 'Preset icon name: facebook, google, ads, news, youtube, forum, web, video',
condition: (_, siblingData) => siblingData?.iconType === 'preset',
},
},
{
name: 'iconSvg',
type: 'textarea',
admin: {
description: 'Paste SVG code directly (e.g., <svg>...</svg>)',
condition: (_, siblingData) => siblingData?.iconType === 'svg',
},
},
{
name: 'iconImage',
type: 'upload',
relationTo: 'media',
admin: {
description: 'Upload an icon image (SVG, PNG)',
condition: (_, siblingData) => siblingData?.iconType === 'upload',
},
},
{
name: 'isHot',
type: 'checkbox',
defaultValue: false,
},
{
name: 'image',
type: 'upload',
relationTo: 'media',
},
{
name: 'link',
type: 'text',
},
],
},
],
labels: {
singular: 'Services List',
plural: 'Services Lists',
},
}

View File

@@ -8,6 +8,7 @@ import { Archive } from '../../blocks/ArchiveBlock/config'
import { CallToAction } from '../../blocks/CallToAction/config'
import { Content } from '../../blocks/Content/config'
import { MediaBlock } from '../../blocks/MediaBlock/config'
import { ServicesList } from '../../blocks/ServicesList/config'
import { hero } from '@/heros/config'
import { slugField } from '@/fields/slug'
import { populatePublishedAt } from '../../hooks/populatePublishedAt'
@@ -76,7 +77,7 @@ export const Pages: CollectionConfig<'pages'> = {
{
name: 'layout',
type: 'blocks',
blocks: [CallToAction, Content, MediaBlock, Archive],
blocks: [CallToAction, Content, MediaBlock, Archive, ServicesList],
required: true,
admin: {
initCollapsed: true,

View File

@@ -194,7 +194,7 @@ export interface Page {
| null;
media?: (string | null) | Media;
};
layout: (CallToActionBlock | ContentBlock | MediaBlock | ArchiveBlock)[];
layout: (CallToActionBlock | ContentBlock | MediaBlock | ArchiveBlock | ServicesListBlock)[];
meta?: {
title?: string | null;
/**
@@ -564,6 +564,37 @@ export interface ArchiveBlock {
blockName?: string | null;
blockType: 'archive';
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "ServicesListBlock".
*/
export interface ServicesListBlock {
services: {
title: string;
description: string;
category: string;
iconType?: ('preset' | 'svg' | 'upload') | null;
/**
* Preset icon name: facebook, google, ads, news, youtube, forum, web, video
*/
icon?: string | null;
/**
* Paste SVG code directly (e.g., <svg>...</svg>)
*/
iconSvg?: string | null;
/**
* Upload an icon image (SVG, PNG)
*/
iconImage?: (string | null) | Media;
isHot?: boolean | null;
image?: (string | null) | Media;
link?: string | null;
id?: string | null;
}[];
id?: string | null;
blockName?: string | null;
blockType: 'servicesList';
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "portfolio".
@@ -923,6 +954,7 @@ export interface PagesSelect<T extends boolean = true> {
content?: T | ContentBlockSelect<T>;
mediaBlock?: T | MediaBlockSelect<T>;
archive?: T | ArchiveBlockSelect<T>;
servicesList?: T | ServicesListBlockSelect<T>;
};
meta?:
| T
@@ -1011,6 +1043,29 @@ export interface ArchiveBlockSelect<T extends boolean = true> {
id?: T;
blockName?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "ServicesListBlock_select".
*/
export interface ServicesListBlockSelect<T extends boolean = true> {
services?:
| T
| {
title?: T;
description?: T;
category?: T;
iconType?: T;
icon?: T;
iconSvg?: T;
iconImage?: T;
isHot?: T;
image?: T;
link?: T;
id?: T;
};
id?: T;
blockName?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "posts_select".

View File

@@ -3,3 +3,10 @@
# Production Payload CMS URL (for SSR fetch)
PAYLOAD_CMS_URL=https://enchun-admin.anlstudio.cc
# Cloudflare Turnstile
CF_TURNSTILE_SITE_KEY=0x4AAAAAACkOUZK2u7Fo8IZ-
CF_TURNSTILE_SECRET_KEY=0x4AAAAAACkOUV9TDUOwVdcdLUakEVxJjww
# n8n webhook
N8N_WEBHOOK_URL=https://n8n.anlstudio.cc/webhook/contact

View File

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

View File

@@ -0,0 +1,34 @@
---
/**
* CTA Section Component
*/
---
<section
class="py-15 px-5 md:py-20 bg-white text-center mx-auto"
aria-labelledby="cta-heading"
>
<div class="max-w-3xl mx-auto">
<div class="flex flex-row items-center justify-center gap-10">
<div class="flex flex-col items-start">
<h2
id="cta-heading"
class="text-xl font-bold text-(--dark-blue) lg:text-3xl"
>
準備好開始新的旅程了嗎?
</h2>
<p
class="text-xl font-semibold text-(--dark-blue) mb-4 lg:text-3xl"
>
歡迎與我們聯絡
</p>
</div>
<a
href="http://heyform.anlstudio.cc/form/FdA22OCm"
class="flex text-xl bg-(--color-notification-red) text-white px-6 py-3 rounded-lg font-semibold transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:bg-(--color-accent-dark)"
>
預約諮詢
</a>
</div>
</div>
</section>

View File

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

View File

@@ -1,29 +1,65 @@
---
import { Image } from "astro:assets";
// Header component with scroll-based background and enhanced mobile animations
// --- TypeScript Interfaces (from payload-types.ts Header global) ---
interface NavLink {
type?: ("reference" | "custom") | null;
newTab?: boolean | null;
reference?:
| ({ relationTo: "pages"; value: string | { slug: string } } | null)
| ({ relationTo: "posts"; value: string | { slug: string } } | null);
url?: string | null;
label: string;
}
interface NavItem {
link: NavLink;
id?: string | null;
}
// --- Hardcoded fallback nav (used when CMS is unavailable) ---
const FALLBACK_NAV: NavItem[] = [
{ link: { type: "custom", url: "/about-enchun", label: "關於恩群" } },
{
link: {
type: "custom",
url: "/marketing-solutions",
label: "行銷方案",
},
},
{ link: { type: "custom", url: "/marketing-lens", label: "行銷放大鏡" } },
{ link: { type: "custom", url: "/enchun-basecamp", label: "恩群大本營" } },
{ link: { type: "custom", url: "/website-portfolio", label: "網站設計" } },
{ link: { type: "custom", url: "/contact", label: "聯絡我們" } },
];
// 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)
? "http://localhost:3000"
: import.meta.env.PAYLOAD_CMS_URL || "https://enchun-admin.anlstudio.cc";
// Fetch navigation data from Payload CMS server-side
let navItems: any[] = [];
let navItems: NavItem[] = [];
try {
const response = await fetch(
`${PAYLOAD_CMS_URL}/api/globals/header?depth=2&draft=false&locale=undefined&trash=false`,
`${PAYLOAD_CMS_URL}/api/globals/header?depth=2&draft=false&trash=false`,
);
if (response.ok) {
const data = await response.json();
navItems = data?.navItems || data || [];
navItems = data?.navItems || [];
}
} catch (error) {
console.error("[Header SSR] Failed to fetch navigation:", error);
}
// Use fallback when CMS returns empty
if (!navItems.length) {
navItems = FALLBACK_NAV;
}
// Helper to get link URL
function getLinkUrl(link: any): string {
function getLinkUrl(link: NavLink): string {
if (!link) return "#";
if (link.type === "custom" && link.url) {
@@ -32,10 +68,8 @@ function getLinkUrl(link: any): string {
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}`;
}
}
@@ -43,26 +77,21 @@ function getLinkUrl(link: any): string {
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 "";
// Return badge type for label (data-driven, no HTML strings)
function getBadgeType(label: string): "hot" | "new" | null {
if (label.includes("行銷方案")) return "hot";
if (label.includes("行銷放大鏡")) return "new";
return null;
}
// Check if link is active
function isLinkActive(url: string): boolean {
const currentPath = Astro.url.pathname;
return currentPath === url || (url === "/" && currentPath === "/");
return Astro.url.pathname === url;
}
---
<header
class="fixed top-0 left-0 right-0 z-50 transition-transform duration-300 ease-in-out"
class="fixed top-0 left-0 right-0 z-50 transition-all duration-300 ease-in-out will-change-transform"
id="main-header"
>
<nav
@@ -72,46 +101,51 @@ function isLinkActive(url: string): boolean {
<ul class="flex items-center justify-between list-none">
<li class="shrink-0">
<a href="/" class="block">
<!-- Uses Astro's optimized Image component for the site logo -->
<Image
<img
src="/enchun-logo.svg"
alt="Enchun Digital Marketing"
class="w-32 h-auto transition-opacity duration-300 drop-shadow-md"
width={919}
height={201}
class="w-32 h-auto transition-transform duration-300 drop-shadow-md"
width="919"
height="201"
loading="eager"
decoding="async"
/>
</a>
</li>
<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" : "";
const badge = getBadgeType(label);
const active = isLinkActive(href);
return (
<li class="hidden md:block">
<a
href={href}
class={`${hasBadge} text-base font-medium transition-all duration-200 px-3 py-2 rounded ${activeClass} hover:bg-white/10`}
class:list={[
"nav-link text-base font-medium transition-all duration-200 px-3 py-2 rounded hover:bg-white/10",
{ "relative inline-block": badge },
{ "nav-active": active },
]}
{...(link.newTab && {
target: "_blank",
rel: "noopener noreferrer",
})}
>
{label}
{badge && <Fragment set:html={badge} />}
{badge === "hot" && (
<span class="badge-nav badge-hot">Hot</span>
)}
{badge === "new" && (
<span class="badge-nav badge-new">New</span>
)}
</a>
</li>
);
})
}
</li>
<!-- Mobile menu button with animated hamburger/X icon -->
<li class="md:hidden">
<button
@@ -154,10 +188,12 @@ function isLinkActive(url: string): boolean {
</button>
</li>
</ul>
<!-- Mobile menu with full-screen overlay -->
<div
class="md:hidden fixed left-0 right-0 top-20 h-[calc(100vh-80px)] h-[calc(100dvh-80px)] bg-white/20 backdrop-blur-lg opacity-0 invisible transition-all duration-300 ease-in-out"
</nav>
<!-- Mobile menu with full-screen overlay (separate nav for semantics) -->
<nav
class="md:hidden fixed left-0 right-0 mobile-menu-top mobile-menu-height bg-white/20 backdrop-blur-xl opacity-0 invisible transition-[opacity,visibility,top] duration-300 ease-in-out"
id="mobile-menu"
aria-label="Mobile navigation"
>
<ul
class="flex flex-col items-center justify-center h-full space-y-6 px-4"
@@ -171,12 +207,12 @@ function isLinkActive(url: string): boolean {
return (
<li
class={`opacity-0`}
class="mobile-nav-item"
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"
class="text-2xl text-gray-700 font-medium transition-all duration-200 hover:scale-105 p-3 min-h-12 flex items-center"
{...(link.newTab && {
target: "_blank",
rel: "noopener noreferrer",
@@ -189,20 +225,20 @@ function isLinkActive(url: string): boolean {
})
}
</ul>
</div>
</nav>
</header>
<script>
// Scroll-based header with smooth hide/show animation
// Uses AbortController for cleanup on re-mount (View Transitions)
let lastScrollY = 0;
let ticking = false;
let isHeaderHidden = false;
let abortController: AbortController | null = null;
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 handleScroll = () => {
@@ -210,90 +246,36 @@ function isLinkActive(url: string): boolean {
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;
}
// Header shrinks and gets background on scroll
// Toggle single class for scrolled state (CSS handles all visuals)
if (scrollY > 10) {
if (!header.classList.contains("header-scrolled")) {
header.classList.add(
"bg-white/15",
"backdrop-blur-xl",
"shadow-md",
"header-scrolled",
);
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)]",
);
}
}
header.classList.add("header-scrolled");
} 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)]",
);
}
}
header.classList.remove("header-scrolled");
}
// 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)
// Hide/show header based on scroll direction
const hideThreshold = 150;
const showThreshold = 50;
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;
@@ -302,44 +284,23 @@ function isLinkActive(url: string): boolean {
ticking = false;
};
const signal = abortController!.signal;
window.addEventListener(
"scroll",
() => {
if (!ticking) {
window.requestAnimationFrame(() => {
handleScroll();
ticking = false;
});
window.requestAnimationFrame(handleScroll);
ticking = true;
}
},
{ passive: true },
{ passive: true, signal },
);
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");
@@ -348,13 +309,13 @@ function isLinkActive(url: string): boolean {
.getElementById("mobile-nav")
?.querySelectorAll("li");
if (!button || !menu || !hamburgerIcon || !closeIcon) {
// Retry after a delay if elements aren't ready
setTimeout(initMobileMenu, 100);
return;
}
if (!button || !menu || !hamburgerIcon || !closeIcon) return;
button.addEventListener("click", () => {
const signal = abortController!.signal;
button.addEventListener(
"click",
() => {
const isMenuOpen = menu.classList.contains("opacity-100");
if (isMenuOpen) {
@@ -367,11 +328,13 @@ function isLinkActive(url: string): boolean {
button.setAttribute("aria-expanded", "false");
mobileNavItems?.forEach((item) => {
item.classList.remove("opacity-100", "translate-y-0");
item.classList.add("opacity-0", "translate-y-4");
item.classList.remove("mobile-nav-visible");
item.classList.add("mobile-nav-hidden");
});
document.body.style.overflow = "";
document.documentElement.classList.remove(
"mobile-menu-open",
);
} else {
menu.classList.remove("opacity-0", "invisible");
menu.classList.add("opacity-100", "visible");
@@ -383,53 +346,61 @@ function isLinkActive(url: string): boolean {
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",
);
item.classList.remove("mobile-nav-hidden");
item.classList.add("mobile-nav-visible");
}, index * 50);
});
document.body.style.overflow = "hidden";
document.documentElement.classList.add("mobile-menu-open");
}
});
},
{ signal },
);
menu.addEventListener("click", (e) => {
menu.addEventListener(
"click",
(e) => {
if (e.target === menu) {
button.click();
}
});
},
{ signal },
);
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && menu.classList.contains("opacity-100")) {
document.addEventListener(
"keydown",
(e) => {
if (
e.key === "Escape" &&
menu.classList.contains("opacity-100")
) {
button.click();
}
});
mobileMenuInitialized = true;
},
{ signal },
);
}
document.addEventListener("DOMContentLoaded", () => {
function init() {
// Cleanup previous listeners (View Transitions re-mount safety)
abortController?.abort();
abortController = new AbortController();
// Reset state
lastScrollY = 0;
ticking = false;
isHeaderHidden = false;
initScrollEffect();
initMobileMenu();
});
}
document.addEventListener("DOMContentLoaded", init);
document.addEventListener("astro:after-swap", init);
</script>
<style>
#main-header {
transition:
background-color 0.3s ease-in-out,
backdrop-filter 0.3s ease-in-out,
box-shadow 0.3s ease-in-out,
transform 0.3s ease-in-out;
will-change: transform;
}
/* Header translate animation - hide completely above viewport */
/* JS-controlled animation states */
#main-header.translate-out {
transform: translateY(-100%);
}
@@ -438,21 +409,46 @@ function isLinkActive(url: string): boolean {
transform: translateY(0);
}
/* Nav padding transition for shrink effect */
#main-nav {
transition: padding 0.3s ease-in-out;
/* --- Scrolled state (all visual changes via single CSS class) --- */
#main-header.header-scrolled {
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
box-shadow: var(--shadow-md, 0 4px 6px -1px rgb(0 0 0 / 0.1));
}
#main-header:not(.header-scrolled) {
background: transparent;
}
#main-header.header-scrolled #main-nav {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
}
/* Logo shrinks when header is scrolled */
#main-header.header-scrolled img {
transform: scale(0.85);
transition: transform 0.3s ease-in-out;
}
/* Mobile menu positioning: adjusts based on header scroll state */
.mobile-menu-top {
top: 5rem; /* = py-4 header height */
}
.mobile-menu-height {
height: calc(100dvh - 5rem);
}
#main-header.header-scrolled ~ #mobile-menu,
#main-header.header-scrolled #mobile-menu {
top: 4rem; /* = py-2 header height */
height: calc(100dvh - 4rem);
}
/* Navigation links */
#main-nav a {
color: var(--color-nav-link);
position: relative;
text-shadow: 1px 1px 3px rgba(0, 0, 0, 0.3);
}
@@ -460,28 +456,53 @@ function isLinkActive(url: string): boolean {
color: var(--color-primary);
}
/* Nav link underline animation */
.nav-link {
position: relative;
}
/* Active state */
.nav-active {
position: relative;
color: var(--color-enchunblue) !important;
font-weight: 600;
}
/* Badge positioning and styling */
#main-nav a[class*="relative"] .absolute {
/* Badge positioning for nav */
.badge-nav {
position: absolute;
top: -4px;
right: -8px;
font-size: 6px;
font-weight: 700;
padding: 2px 6px;
border-radius: 9999px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: white;
box-shadow: var(--shadow-sm);
}
/* 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);
.badge-nav.badge-hot {
background-color: var(--color-badge-hot, #ea384c);
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
.badge-nav.badge-new {
background-color: var(--color-badge-hot, #ea384c);
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
/* Mobile menu */
@media (max-width: 767px) {
#mobile-menu {
overflow-y: auto;
@@ -502,12 +523,30 @@ function isLinkActive(url: string): boolean {
}
}
/* Smooth transition for all nav elements */
#main-nav a,
#mobile-nav a {
/* Mobile nav item animation states (CSS-driven) */
.mobile-nav-item {
opacity: 0;
transform: translateY(1rem);
transition:
color 0.2s ease-in-out,
text-shadow 0.2s ease-in-out,
background-color 0.2s ease-in-out;
opacity 0.3s ease-out,
transform 0.3s ease-out;
}
.mobile-nav-item.mobile-nav-visible {
opacity: 1;
transform: translateY(0);
}
.mobile-nav-item.mobile-nav-hidden {
opacity: 0;
transform: translateY(1rem);
}
/* iOS scroll lock */
:global(html.mobile-menu-open) {
overflow: hidden;
position: fixed;
width: 100%;
height: 100%;
}
</style>

View File

@@ -0,0 +1,10 @@
---
---
<!-- nav bg-placeholder -->
<div
id="header-bg"
class="h-[90px] -mt-[100px] bg-linear-to-b from-(--color-dark-blue) to-(--color-enchunblue)"
>
</div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,20 +3,30 @@
* 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'
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";
import SectionHeader from "../components/SectionHeader.astro";
import CtaSection from "@/components/CtaSection.astro";
// Metadata for SEO
const title = '關於恩群數位 | 專業數位行銷服務團隊'
const description = '恩群數位行銷成立於2018年提供全方位數位行銷服務。我們在地化優先、數據驅動是您最可信赖的數位行銷夥伴。'
const title = "關於恩群數位 | 專業數位行銷服務團隊";
const description =
"恩群數位行銷成立於2018年提供全方位數位行銷服務。我們在地化優先、數據驅動是您最可信赖的數位行銷夥伴。";
---
<Layout title={title} description={description}>
<!-- Hero Section -->
<AboutHero />
<AboutHero
title="關於恩群數位"
subtitle="About Enchun digital"
backgroundImage={{
url: "https://enchun-cms.anlstudio.cc/api/media/file/61f24aa108528b2535942cd4_IMG_9090%201.jpg",
alt: "關於恩群數位的背景圖",
}}
/>
<!-- Service Features Section -->
<FeatureSection />
@@ -25,14 +35,5 @@ const description = '恩群數位行銷成立於2018年提供全方位數位
<ComparisonSection />
<!-- CTA Section -->
<CTASection
homeData={{
ctaSection: {
headline: '準備好開始新的旅程了嗎',
description: '讓我們一起為您的品牌打造獨特的數位行銷策略',
buttonText: '聯絡我們',
buttonLink: '/contact-us',
},
}}
/>
<CtaSection />
</Layout>

View File

@@ -0,0 +1,82 @@
import type { APIRoute } from "astro";
// Your Cloudflare Turnstile Secret Key
// In production, this should be set in environment variables
const TURNSTILE_SECRET_KEY = import.meta.env.CF_TURNSTILE_SECRET_KEY || import.meta.env.TURNSTILE_SECRET_KEY;
// The n8n webhook URL
const WEBHOOK_URL = import.meta.env.N8N_WEBHOOK_URL || "https://n8n.anlstudio.cc/webhook/contact";
export const POST: APIRoute = async ({ request }) => {
try {
const body = await request.json();
const { name, phone, email, message, "cf-turnstile-response": token } = body;
// Basic validation
if (!name || !phone || !email || !message) {
return new Response(JSON.stringify({ error: "Missing required fields" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
if (!token) {
return new Response(JSON.stringify({ error: "[Turnstile] No token provided" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
// Verify Turnstile token
if (TURNSTILE_SECRET_KEY) {
const turnstileVerify = await fetch(
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
secret: TURNSTILE_SECRET_KEY,
response: token,
}).toString(),
}
);
const turnstileResult = await turnstileVerify.json();
if (!turnstileResult.success) {
return new Response(JSON.stringify({ error: "[Turnstile] Token verification failed" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
} else {
console.warn("TURNSTILE_SECRET_KEY is not defined. Skipping verification.");
}
// After successful verification, send data to n8n
const n8nResponse = await fetch(WEBHOOK_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ name, phone, email, message }),
});
if (!n8nResponse.ok) {
throw new Error(`n8n webhook failed with status ${n8nResponse.status}`);
}
return new Response(JSON.stringify({ success: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
console.error("API proxy error:", error);
return new Response(JSON.stringify({ error: "Internal server error" }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
};

View File

@@ -4,622 +4,387 @@
* Pixel-perfect implementation based on Webflow design
* Includes form validation, submission handling, and responsive layout
*/
import Layout from '../layouts/Layout.astro'
import Layout from "../layouts/Layout.astro";
import HeaderBg from "../components/HeaderBg.astro";
// Metadata for SEO
const title = '聯絡我們 | 恩群數位行銷'
const description = '有任何問題嗎?歡迎聯絡恩群數位行銷,我們的專業團隊將竭誠為您服務。電話: 02 5570 0527Email: enchuntaiwan@gmail.com'
const title = "聯絡我們 | 恩群數位行銷";
const description =
"有任何問題嗎?歡迎聯絡恩群數位行銷,我們的專業團隊將竭誠為您服務。電話: 02 5570 0527Email: enchuntaiwan@gmail.com";
---
<Layout title={title} description={description}>
<section class="contact-section" id="contact">
<div class="contactus_wrapper">
<HeaderBg />
<section class="py-20 bg-[#f4f5f7] scroll-mt-20 lg:py-10" id="contact">
<div
class="grid grid-cols-2 gap-16 items-start max-w-3xl mx-auto px-5 lg:grid-cols-2 lg:gap-10"
>
<!-- Contact Image Side -->
<div class="flex justify-center items-center w-full lg:order-1 pt-4">
<img
src="https://enchun-cms.anlstudio.cc/api/media/file/61f24aa108528b532e942d09_Contact%20us-bro%201.svg"
alt="聯絡我們插圖"
class="w-full h-auto object-contain drop-shadow-sm"
/>
</div>
<!-- Contact Form Side -->
<div class="contact_form_wrapper">
<h1 class="contact_head">聯絡我們</h1>
<p class="contact_parafraph">
有任何問題嗎?歡迎聯絡我們,我們將竭誠為您服務。
<div class="w-full lg:order-2">
<h2
class="text-4xl font-bold leading-snug text-[#2a5b83] mb-3 lg:text-3xl"
>
與我們聯絡
</h2>
<p
class="text-lg font-medium text-[#2a5b83] mb-2 tracking-wide lg:text-base"
>
任何關於行銷的相關訊息或是詢價,歡迎與我們聯絡
</p>
<p class="contact_reminder">
* 標註欄位為必填
<p class="text-sm italic text-slate-500 mb-8">
有星號的地方 (*) 是必填欄位
</p>
<!-- Contact Form -->
<form id="contact-form" class="contact_form" novalidate>
<form id="contact-form" class="w-full flex flex-col gap-6" novalidate>
<!-- Success Message -->
<div id="form-success" class="w-form-done" style="display: none;">
<div
id="form-success"
class="p-4 px-6 rounded-lg text-center font-medium bg-[#d4edda] text-[#155724] border border-[#c3e6cb] animate-[slideUp_0.3s_ease-out]"
style="display: none;"
>
感謝您的留言!我們會盡快回覆您。
</div>
<!-- Error Message -->
<div id="form-error" class="w-form-fail" style="display: none;">
<div
id="form-error"
class="p-4 px-6 rounded-lg text-center font-medium bg-[#f8d7da] text-[#721c24] border border-[#f5c6cb] animate-[slideUp_0.3s_ease-out]"
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>
<div class="flex flex-col">
<label
for="Name"
class="text-[0.95rem] font-medium text-slate-500 mb-2 block"
>
姓名*
</label>
<input
type="text"
id="Name"
name="Name"
class="input_field"
class="w-full px-4 py-2.5 border border-[#4a90e2] rounded-lg text-base bg-white focus:outline-none focus:ring-2 focus:ring-[#4a90e2]/20 transition-all shadow-sm [&.error]:border-[#dc3545]"
required
minlength="2"
maxlength="256"
placeholder="請輸入您的姓名"
/>
<span class="error-message" id="Name-error"></span>
<span
class="error-message text-[#dc3545] text-sm mt-1 hidden"
id="Name-error"></span>
</div>
<!-- Phone Field -->
<div class="contact_field_wrapper">
<label for="Phone" class="contact_field_name">
聯絡電話 <span>*</span>
<div class="flex flex-col">
<label
for="Phone"
class="text-[0.95rem] font-medium text-slate-500 mb-2 block"
>
聯絡電話*
</label>
<input
type="tel"
id="Phone"
name="Phone"
class="input_field"
class="w-full px-4 py-2.5 border border-white shadow-sm rounded-lg text-base bg-white focus:outline-none focus:border-[#4a90e2] focus:ring-2 focus:ring-[#4a90e2]/20 transition-all [&.error]:border-[#dc3545]"
required
placeholder="請輸入您的電話號碼"
/>
<span class="error-message" id="Phone-error"></span>
</div>
<span
class="error-message text-[#dc3545] text-sm mt-1 hidden"
id="Phone-error"></span>
</div>
<!-- Email Field -->
<div class="contact_field_wrapper">
<label for="Email" class="contact_field_name">
Email <span>*</span>
<div class="flex flex-col">
<label
for="Email"
class="text-[0.95rem] font-medium text-slate-500 mb-2 block"
>
Email *
</label>
<input
type="email"
id="Email"
name="Email"
class="input_field"
class="w-full px-4 py-2.5 border border-white shadow-sm rounded-lg text-base bg-white focus:outline-none focus:border-[#4a90e2] focus:ring-2 focus:ring-[#4a90e2]/20 transition-all [&.error]:border-[#dc3545]"
required
placeholder="請輸入您的 Email"
/>
<span class="error-message" id="Email-error"></span>
<span
class="error-message text-[#dc3545] text-sm mt-1 hidden"
id="Email-error"></span>
</div>
<!-- Message Field -->
<div class="contact_field_wrapper">
<label for="Message" class="contact_field_name">
聯絡訊息 <span>*</span>
<div class="flex flex-col">
<label
for="Message"
class="text-[0.95rem] font-medium text-slate-500 mb-2 block"
>
聯絡訊息
</label>
<textarea
id="Message"
name="Message"
class="input_field"
minlength="10"
maxlength="5000"
class="w-full px-4 py-3 border border-white shadow-sm rounded-lg text-base bg-white focus:outline-none focus:border-[#4a90e2] focus:ring-2 focus:ring-[#4a90e2]/20 transition-all resize-y min-h-[120px] [&.error]:border-[#dc3545]"
required
placeholder="請輸入您的訊息(至少 10 個字元)"
></textarea>
<span class="error-message" id="Message-error"></span>
minlength="10"
maxlength="5000"></textarea>
<span
class="error-message text-[#dc3545] text-sm mt-1 hidden"
id="Message-error"></span>
</div>
<!-- Turnstile Widget -->
<div class="flex flex-col mt-2">
<div
class="cf-turnstile"
data-sitekey={import.meta.env.PUBLIC_TURNSTILE_SITE_KEY ||
"0x4AAAAAACkOUZK2u7Fo8IZ-"}
data-theme="light"
>
</div>
<span
class="error-message text-[#dc3545] text-sm mt-1 hidden"
id="turnstile-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>
<div class="flex justify-end mt-2">
<button
type="submit"
class="bg-[#2a5b83] text-white border-none rounded-lg px-10 py-[0.6rem] text-[1.05rem] font-medium cursor-pointer transition-all duration-200 hover:bg-[#1f4666] shadow-sm transform hover:-translate-y-0.5 min-w-[120px] disabled:bg-slate-400 disabled:cursor-not-allowed disabled:transform-none"
id="submit-btn"
>
<span class="button-text">送出</span>
<span class="button-loading" style="display: none;"
>送出中...</span
>
</button>
</div>
</form>
</div>
<!-- Contact Image Side -->
<div class="contact-image">
<div class="image-wrapper">
<img
src="/placeholder-contact.jpg"
alt="聯絡恩群數位"
width="600"
height="400"
/>
</div>
<!-- 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>
</div>
</section>
</Layout>
<style>
/* Contact Section Styles - Pixel-perfect from Webflow */
.contact-section {
padding: 4rem 0;
background: var(--color-background);
scroll-margin-top: 80px;
}
.contactus_wrapper {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 3rem;
align-items: center;
max-width: 1200px;
margin: 0 auto;
padding: 0 20px;
}
/* Form Side */
.contact_form_wrapper {
width: 100%;
}
/* Headings */
.contact_head {
font-size: 2.5rem;
font-weight: 700;
line-height: 1.2;
color: var(--color-text-primary);
margin-bottom: 1rem;
}
.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;
border-radius: var(--radius);
padding: 0.875rem 2rem;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all var(--transition-fast);
text-align: center;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 200px;
width: 100%;
}
.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 src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer
></script>
<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
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;
const turnstileError = document.getElementById(
"turnstile-error",
) as HTMLElement;
if (!form) return
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}$/
}
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
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
if (!input.hasAttribute("required") && !value) {
clearError(input, errorSpan);
return true;
}
let isValid = true
let errorMessage = ''
let isValid = true;
let errorMessage = "";
// Required check
if (input.hasAttribute('required') && !value) {
isValid = false
errorMessage = '此欄位為必填'
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
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
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')
input.classList.add("error");
if (errorSpan) {
errorSpan.textContent = errorMessage
errorSpan.style.display = 'block'
errorSpan.textContent = errorMessage;
errorSpan.style.display = "block";
}
} else {
clearError(input, errorSpan)
clearError(input, errorSpan);
}
return isValid
return isValid;
}
function clearError(input: HTMLInputElement | HTMLTextAreaElement, errorSpan: HTMLElement) {
input.classList.remove('error')
function clearError(
input: HTMLInputElement | HTMLTextAreaElement,
errorSpan: HTMLElement,
) {
input.classList.remove("error");
if (errorSpan) {
errorSpan.textContent = ''
errorSpan.style.display = 'none'
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)
})
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)
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()
form.addEventListener("submit", async (e) => {
e.preventDefault();
// Validate all fields
const inputs = form.querySelectorAll('input, textarea') as NodeListOf<HTMLInputElement | HTMLTextAreaElement>
let isFormValid = true
const inputs = form.querySelectorAll("input, textarea") as NodeListOf<
HTMLInputElement | HTMLTextAreaElement
>;
let isFormValid = true;
inputs.forEach((input) => {
if (!validateField(input)) {
isFormValid = false
isFormValid = false;
}
});
// Collect form data to check turnstile token
const formData = new FormData(form);
const turnstileToken = formData.get("cf-turnstile-response");
// Validate Turnstile
if (!turnstileToken) {
isFormValid = false;
if (turnstileError) {
turnstileError.textContent = "請完成驗證";
turnstileError.style.display = "block";
}
} else {
if (turnstileError) {
turnstileError.textContent = "";
turnstileError.style.display = "none";
}
}
})
if (!isFormValid) {
// Scroll to first error
const firstError = form.querySelector('.input_field.error') as HTMLElement
const firstError = form.querySelector(
".input_field.error, #turnstile-error[style*='display: block']",
) as HTMLElement;
if (firstError) {
firstError.scrollIntoView({ behavior: 'smooth', block: 'center' })
firstError.scrollIntoView({ behavior: "smooth", block: "center" });
}
return
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'
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'
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')
}
name: formData.get("Name"),
phone: formData.get("Phone"),
email: formData.get("Email"),
message: formData.get("Message"),
"cf-turnstile-response": turnstileToken,
};
try {
// Submit to backend (via API proxy)
const response = await fetch('/api/contact', {
method: 'POST',
// Submit to API proxy
const response = await fetch("/api/contact", {
method: "POST",
headers: {
'Content-Type': 'application/json'
"Content-Type": "application/json",
},
body: JSON.stringify(data)
})
body: JSON.stringify(data),
});
if (response.ok) {
// Success
successMsg.style.display = 'block'
form.reset()
window.scrollTo({ top: 0, behavior: 'smooth' })
successMsg.style.display = "block";
form.reset();
window.scrollTo({ top: 0, behavior: "smooth" });
} else {
throw new Error('Submission failed')
throw new Error("Submission failed");
}
} catch (error) {
console.error('Form submission error:', error)
errorMsg.style.display = 'block'
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'
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()
document.addEventListener("DOMContentLoaded", initContactForm);
if (document.readyState !== "loading") {
initContactForm();
}
</script>

View File

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

View File

@@ -39,29 +39,29 @@ const validCategories = categories.filter(c =>
<Layout title={title} description={description}>
<!-- Blog Hero Section -->
<section class="blog-hero" aria-labelledby="blog-heading">
<div class="container">
<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>
<section class="py-[60px] px-5 bg-white lg:py-10 md:py-10 md:px-4" aria-labelledby="blog-heading">
<div class="max-w-[1200px] mx-auto">
<div class="flex items-center justify-center gap-4 md:flex-wrap md:gap-3">
<div class="w-10 h-0.5 bg-link-hover md:w-[30px]"></div>
<div class="text-center">
<h2 id="blog-heading" class="text-2xl font-bold text-link-hover mb-2 lg:text-[1.75rem] md:text-[1.5rem]">行銷放大鏡</h2>
<p class="text-base text-slate-600 font-accent md:text-[0.9375rem]">Marketing Blog</p>
</div>
<div class="divider_line"></div>
<div class="w-10 h-0.5 bg-link-hover md:w-[30px]"></div>
</div>
</div>
</section>
<!-- Category Filter -->
<section class="filter-section">
<div class="container">
<section class="bg-white pb-5">
<div class="max-w-[1200px] mx-auto">
<CategoryFilter categories={validCategories} />
</div>
</section>
<!-- Blog Posts Grid -->
<section class="blog-section" aria-label="文章列表">
<div class="container">
<section class="py-10 px-5 pb-[60px] bg-slate-100 min-h-[60vh] md:py-[30px] md:px-4 md:pb-[50px]" aria-label="文章列表">
<div class="max-w-[1200px] mx-auto">
{
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">
@@ -70,8 +70,8 @@ const validCategories = categories.filter(c =>
))}
</div>
) : (
<div class="empty-state">
<p class="empty-text">暫無文章</p>
<div class="text-center py-20 px-5">
<p class="text-lg text-slate-500">暫無文章</p>
</div>
)
}
@@ -79,13 +79,13 @@ const validCategories = categories.filter(c =>
<!-- Pagination -->
{
totalPages > 1 && (
<nav class="pagination" aria-label="分頁導航">
<div class="pagination-container">
<nav class="pt-5" aria-label="分頁導航">
<div class="flex items-center justify-center gap-6 md:gap-4">
{
hasPreviousPage && (
<a
href={`?page=${page - 1}`}
class="pagination-link pagination-link-prev"
class="inline-flex items-center gap-2 px-5 py-[10px] bg-white border-2 border-slate-300 rounded-md text-slate-700 text-[0.9375rem] font-medium no-underline transition-all duration-[250ms] hover:border-link-hover hover:text-link-hover hover:bg-[rgba(35,96,140,0.05)] md:px-4 md:py-2 md:text-[0.875rem]"
aria-label="上一頁"
>
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
@@ -96,17 +96,17 @@ const validCategories = categories.filter(c =>
)
}
<div class="pagination-info">
<span class="current-page">{page}</span>
<span class="page-separator">/</span>
<span class="total-pages">{totalPages}</span>
<div class="flex items-center gap-2 font-accent text-base text-slate-600">
<span class="font-bold text-link-hover">{page}</span>
<span class="text-slate-400">/</span>
<span>{totalPages}</span>
</div>
{
hasNextPage && (
<a
href={`?page=${page + 1}`}
class="pagination-link pagination-link-next"
class="inline-flex items-center gap-2 px-5 py-[10px] bg-white border-2 border-slate-300 rounded-md text-slate-700 text-[0.9375rem] font-medium no-underline transition-all duration-[250ms] hover:border-link-hover hover:text-link-hover hover:bg-[rgba(35,96,140,0.05)] md:px-4 md:py-2 md:text-[0.875rem]"
aria-label="下一頁"
>
下一頁
@@ -124,166 +124,3 @@ const validCategories = categories.filter(c =>
</section>
</Layout>
<style>
/* Blog Hero Section */
.blog-hero {
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);
}
/* 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;
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;
}
.pagination-link:hover {
border-color: var(--color-enchunblue, #23608c);
color: var(--color-enchunblue, #23608c);
background: rgba(35, 96, 140, 0.05);
}
.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

@@ -4,178 +4,48 @@
* 展示工作環境、公司故事和員工福利
* 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'
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";
import SectionHeader from "../components/SectionHeader.astro";
import CtaHrCompoents from "../sections/Cta-Hr-compoents.astro";
// Metadata for SEO
const title = '恩群大本營 | 恩群數位行銷'
const description = '加入恩群數位的團隊,享受優質的工作環境與完善的員工福利。我們重視個人的特質發揮,歡迎樂於學習、善於建立關係的你加入我們。'
const title = "恩群大本營 | 恩群數位行銷";
const description =
"加入恩群數位的團隊,享受優質的工作環境與完善的員工福利。我們重視個人的特質發揮,歡迎樂於學習、善於建立關係的你加入我們。";
---
<Layout title={title} description={description}>
<!-- Hero Section -->
<TeamsHero />
<TeamsHero
title="恩群大本營"
subtitle="Team members of Enchun"
backgroundImage={{
url: "https://enchun-cms.anlstudio.cc/api/media/file/61f24aa108528b3141942d00_IMG_9088%20copy.JPG",
alt: "Teams Hero",
}}
/>
<!-- 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>
<CtaHrCompoents
title="以人的成長為優先<br />創造人的最大價值"
description="在恩群數位裡我們重視個人的特質能夠完全發揮,只要你樂於學習、善於跟人建立關係,並且重要的是你有一個善良的心,恩群數位歡迎你的加入"
image={{
url: "https://enchun-cms.anlstudio.cc/api/media/file/61f24aa108528b4723942d01_工作環境-銘言底圖-1400x659.jpg",
alt: "工作環境",
}}
/>
</Layout>
<style>
/* 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;
}
.w-container {
max-width: 1200px;
margin: 0 auto;
}
.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

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

View File

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

View File

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

View File

@@ -3,223 +3,78 @@
* Portfolio Listing Page - 案例分享列表頁
* Pixel-perfect implementation based on Webflow design
*/
import Layout from '../layouts/Layout.astro'
import PortfolioCard from '../components/PortfolioCard.astro'
import Layout from "../layouts/Layout.astro";
import PortfolioCard from "../components/PortfolioCard.astro";
import SectionHeader from "../components/SectionHeader.astro";
import HeaderBg from "../components/HeaderBg.astro";
import CtaSection from "../components/CtaSection.astro";
// Metadata for SEO
const title = '案例分享 | 恩群數位行銷'
const description = '瀏覽恩群數位的成功案例,包括企業官網、電商網站、品牌網站等設計作品。'
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: "corporate-website-1",
title: "Saas 一頁式頁面",
description:
"現代SaaS一頁式網站吸引目標客戶並提升轉化率而設計。採用React、Tailwind CSS和TypeScript等先進技術構建響應式流暢導航、視覺效果和性能。非常適合初創公司和科技新創",
image:
"https://enchun-cms.anlstudio.cc/api/media/file/67a99c71f1be7be0b6fb5b6b_saas_website_design-p-1080.webp",
tags: ["SaaS", "一頁式頁面"],
},
{
slug: 'ecommerce-site-1',
title: '電商平台建置',
description: 'B2C 電商網站,包含會員系統、購物車、金流整合等完整功能。',
image: '/placeholder-portfolio-2.jpg',
tags: ['電商網站', '金流整合'],
slug: "ecommerce-site-1",
title: "BioNova銷售頁",
description:
"本網站以簡潔、專業、易於操作的介面,向受眾傳達 BioNova 超能複方菁萃膠囊的產品特色、優勢及功效,並鼓勵消費者立即購買。",
image:
"https://enchun-cms.anlstudio.cc/api/media/file/67a9a0aa4ef074c15c2b4409_landingpage_website_design-p-1080.webp",
tags: ["電商網站", "金流整合"],
},
{
slug: 'brand-website-1',
title: '品牌形象網站',
description: '以視覺故事為核心的品牌網站,展現品牌獨特價值與理念。',
image: '/placeholder-portfolio-3.jpg',
tags: ['品牌網站', '視覺設計'],
slug: "brand-website-1",
title: "美髮業預約頁面",
description:
"美髮官網線上預約系統,透過便捷高效的線上預約系統已成為吸引顧客、提升服務品質的關鍵。一個專業、時尚、易於操作的線上平台,讓顧客隨時隨地輕鬆預約美髮服務,同時展示美髮店的專業形象、服務特色及最新資訊。",
image:
"https://enchun-cms.anlstudio.cc/api/media/file/67aacde54fbce6ce004c18d2_booking_website_design_alt-p-1080.webp",
tags: ["品牌網站", "視覺設計"],
},
{
slug: 'landing-page-1',
title: '活動行銷頁面',
description: '高轉換率的活動頁面設計,有效的 CTA 配置與使用者體驗規劃。',
image: '/placeholder-portfolio-4.jpg',
tags: ['活動頁面', '行銷'],
slug: "landing-page-1",
title: "物流業官方網站",
description:
"專業、高效的物流網站已成為必然趨勢,透過設計出操作便捷的物流網站,以滿足現代物流業務的需求。提供線上客服功能,解答客戶疑問提供幫助。",
image:
"https://enchun-cms.anlstudio.cc/api/media/file/67a9acb0737c25d4ba894081_freight_website_design-p-1080.webp",
tags: ["活動頁面", "行銷"],
},
]
];
---
<Layout title={title} description={description}>
<!-- Portfolio Header -->
<section class="portfolio-header" aria-labelledby="portfolio-heading">
<div class="container">
<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>
<HeaderBg />
<SectionHeader
title="案例分享"
subtitle="Selected Works"
sectionBg="bg-white"
/>
<!-- Portfolio Grid -->
<section class="portfolio-grid-section" aria-label="作品列表">
<ul class="portfolio-grid">
{
portfolioItems.map((item) => (
<PortfolioCard item={item} />
))
}
<section
class="py-0 px-5 pb-20 bg-white lg:px-4 lg:pb-10"
aria-label="作品列表"
>
<ul
class="grid grid-cols-2 gap-5 max-w-3xl mx-auto p-0 list-none lg:gap-8 md:grid-cols-2 md:gap-6"
>
{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>
<CtaSection />
</Layout>
<style>
/* 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;
}
.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(2, 1fr);
gap: 20px;
max-width: 1200px;
margin: 0 auto;
padding: 0;
list-style: none;
}
/* 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);
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;
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);
}
/* 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

@@ -4,81 +4,73 @@
* Pixel-perfect implementation based on Webflow design
*/
interface Props {
title?: string
subtitle?: string
title?: string;
subtitle?: string;
backgroundImage?: {
url?: string;
alt?: string;
};
}
const {
title = '關於恩群數位',
subtitle = 'About Enchun digital',
} = Astro.props
title = "關於恩群數位",
subtitle = "About Enchun digital",
backgroundImage,
} = Astro.props;
// Determine if we have a background image
const hasBackgroundImage = backgroundImage?.url;
const bgImageUrl = backgroundImage?.url || "";
---
<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>
<section
class:list={[
// Base styles - relative positioning for overlay
"relative",
"flex",
"items-center",
"justify-center",
"overflow-hidden",
"text-center",
"px-5",
// Background color fallback
!hasBackgroundImage && "bg-(--color-dark-blue)",
// Background image styles
hasBackgroundImage && "bg-cover bg-center bg-no-repeat",
// Pull up to counteract layout's pt-20 padding (80px)
"-mt-20",
// Full viewport height
"min-h-dvh",
"z-0",
]}
style={hasBackgroundImage
? `background-image: url('${bgImageUrl}')`
: undefined}
>
{/* Background image overlay for text readability */}
{
hasBackgroundImage && (
<div
class="absolute inset-0 bg-linear-to-b from-black/80 to-transparent z-1"
aria-hidden="true"
/>
)
}
{/* Content container - relative z-index above overlay */}
<div class="relative z-2 max-w-6xl mx-auto">
<!-- Main Title -->
<h1
class="text-white text-shadow-md font-['Noto_Sans_TC',sans-serif]! font-bold leading-[1.2] -mb-1 text-6xl! md:text-5xl"
>
{title}
</h1>
<!-- Subtitle -->
<p
class="text-gray-100 text-shadow-md font-['Quicksand'] font-thin leading-[1.2] max-w-3xl mx-auto text-3xl! md:text-2xl"
>
{subtitle}
</p>
</div>
</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>
</section>

View File

@@ -6,304 +6,93 @@
*/
interface BenefitItem {
title: string
icon: string
title: string;
img: string;
}
interface Props {
benefits?: BenefitItem[]
benefits?: BenefitItem[];
}
const defaultBenefits: BenefitItem[] = [
{
title: '高績效、高獎金\n新人開張獎金',
icon: 'bonus',
title: "高績效、高獎金\n新人開張獎金",
img: "/api/media/file/61f24aa108528b79b2942d05_Make%20it%20rain-bro-%E6%96%B0%E4%BA%BA%E9%96%8B%E5%BC%B5%E7%8D%8E%E9%87%91.svg",
},
{
title: '生日慶生、電影日\n員工下午茶',
icon: 'birthday',
title: "生日慶生、電影日\n員工下午茶",
img: "/api/media/file/61f24aa108528be590942d06_Blowing%20out%20Birthday%20candles-bro-%E7%94%9F%E6%97%A5%E6%85%B6%E7%94%9F.svg",
},
{
title: '教育訓練補助',
icon: 'education',
title: "教育訓練補助",
img: "/api/media/file/61f24aa108528be22a942d03_Online%20learning-bro-%E6%95%99%E8%82%B2%E8%A8%93%E7%B7%B4%E8%A3%9C%E5%8A%A9.svg",
},
{
title: '寬敞的工作空間',
icon: 'workspace',
title: "寬敞的工作空間",
img: "/api/media/file/61f24aa108528be064942d08_Shared%20workspace-bro-%E5%AF%AC%E6%95%9E%E7%9A%84%E5%B7%A5%E4%BD%9C%E7%A9%BA%E9%96%93.svg",
},
{
title: '員工國內外旅遊\n部門聚餐、年終活動',
icon: 'travel',
title: "員工國內外旅遊\n部門聚餐、年終活動",
img: "/api/media/file/61f24aa108528b0960942d04_Flight%20Booking-bro-%E5%93%A1%E5%B7%A5%E6%97%85%E9%81%8A.svg",
},
{
title: '入職培訓及團隊建設',
icon: 'training',
title: "入職培訓及團隊建設",
img: "/api/media/file/61f24aa108528bf90b942d02_Brainstorming-bro-%E5%85%A5%E8%81%B7%E5%9F%B9%E8%A8%93.svg",
},
]
];
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
}
const benefits = Astro.props.benefits || defaultBenefits;
import SectionHeader from "../components/SectionHeader.astro";
---
<section class="section-benefit" aria-labelledby="benefits-heading">
<div class="container w-container">
<section
class="py-15 px-5 bg-white md:py-10 md:px-4"
aria-labelledby="benefits-heading"
>
<div class="max-w-6xl mx-auto">
<!-- 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>
<SectionHeader
title="工作福利"
subtitle="Benefit Packages"
sectionBg="bg-white"
/>
<!-- Benefits Grid -->
<div class="benefit-grid-wrapper">
<!-- Benefits Grid: 2 cards per row -->
<div
class="max-w-md md:max-w-3xl mx-auto grid grid-cols-1 gap-x-2 gap-y-2 md:gap-y-6 md:grid-cols-2 py-18"
>
{
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>
benefits.map((benefit, index) => {
const isLeft = index % 2 === 0;
return (
<div class="grid grid-cols-2 gap-2 items-center">
<div
class:list={[
isLeft ? "order-1 text-right" : "order-2 text-left",
]}
>
<h3 class="text-xl md:text-base lg:text-xl font-semibold text-(--color-enchunblue) whitespace-pre-wrap leading-snug">
{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
class:list={[
"flex items-center",
isLeft ? "order-2 justify-start" : "order-1 justify-end",
]}
>
<img
src={`https://enchun-cms.anlstudio.cc${benefit.img}`}
alt={benefit.title}
class="size-35 object-contain"
loading="lazy"
decoding="async"
/>
</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

@@ -4,128 +4,36 @@
* Pixel-perfect implementation based on Webflow design
*/
interface Props {
title?: string
subtitle?: string
content?: string
title?: string;
subtitle?: string;
content?: string;
}
const {
title = '恩群數位的故事',
subtitle = 'Something About Enchun Digital',
content = '恩群數位是由一群年輕果斷、敢冒險的年輕人聚在一起,共同為台灣在地經營努力不懈的商家老闆們建立品牌的知名度。而商家的經營本身就不是一件容易的事情,早在恩群成立之前,我們便一直聚焦在與不同行業的老闆們建立起信賴可靠的關係,從一家又一家的合作關係當中,聚集了在行銷領域裡的頂尖好手,培育了許多優秀行銷顧問。讓每個辛苦經營的商家老闆可以獲得最佳的服務,讓每次的行銷需求可以透過有效的互動與聆聽,達到彼此心目中的預期目標。數字的確會說話,但是每一個有溫度的服務才是在恩群裡最重視的地方。',
} = Astro.props
title = "恩群數位的故事",
subtitle = "Something About Enchun Digital",
content = "恩群數位是由一群年輕果斷、敢冒險的年輕人聚在一起,共同為台灣在地經營努力不懈的商家老闆們建立品牌的知名度。而商家的經營本身就不是一件容易的事情,早在恩群成立之前,我們便一直聚焦在與不同行業的老闆們建立起信賴可靠的關係,從一家又一家的合作關係當中,聚集了在行銷領域裡的頂尖好手,培育了許多優秀行銷顧問。讓每個辛苦經營的商家老闆可以獲得最佳的服務,讓每次的行銷需求可以透過有效的互動與聆聽,達到彼此心目中的預期目標。數字的確會說話,但是每一個有溫度的服務才是在恩群裡最重視的地方。",
} = Astro.props;
import SectionHeader from "../components/SectionHeader.astro";
---
<section class="section-story" aria-labelledby="story-heading">
<div class="container w-container">
<section
class="section-story py-20 md:py-[60px] md:px-4 max-md:py-10 max-md:px-4 text-center bg-white"
aria-labelledby="story-heading"
>
<div class="container w-container max-w-4xl mx-auto">
<!-- 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>
<SectionHeader
title="恩群數位的故事"
subtitle="Something About Enchun Digital"
sectionBg="bg-white"
/>
<!-- Story Content -->
<p class="story-paragraph">{content}</p>
<p
class="story-paragraph max-w-3xl mx-auto text-base md:text-lg font-thin leading-[1.8] max-md:leading-[1.7] text-(--color-text-secondary)/80 font-[Noto_Sans_TC,sans-serif]"
>
{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

@@ -1,274 +1,139 @@
---
import SectionHeader from "@/components/SectionHeader.astro";
/**
* ComparisonSection - 恩群數位 vs 其他行銷公司 對比表格
* Pixel-perfect implementation based on Webflow design
* Custom layout based on new design specs with Tailwind CSS
*/
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: '複雜收費結構,容易超支',
},
]
const otherCompanyItems = [
"缺乏經驗",
"沒有成效保證",
"售後無服務",
"沒有策略",
"不了解客戶需求",
"沒有接受客戶反饋",
];
const enchunItems = [
"實際執行經驗豐富",
"實際成效",
"售後服務架構完善",
"行銷策略有方",
"熟悉客戶需求",
"最多客戶回饋",
];
---
<section class="section-comparison" aria-labelledby="comparison-heading">
<div class="w-container">
<section
class="bg-[#f8f9fa] py-10 px-3 md:py-[60px] md:px-4 lg:py-20 lg:px-5"
aria-labelledby="comparison-heading"
>
<div class="max-w-[1000px] mx-auto">
<!-- Section Header -->
<div class="section-header-w-line">
<h2 id="comparison-heading" class="header-subtitle-head">
為什麼選擇恩群數位
</h2>
<div class="divider-line"></div>
<SectionHeader
title="恩群與其他公司有什麼不同"
subtitle="What make us different from others"
/>
<!-- Comparison Cards Layout -->
<div
class="relative bg-white rounded-2xl shadow-xl overflow-hidden mt-10 md:mt-12 max-w-4xl mx-auto border border-gray-100"
>
<!-- Winning Medal (Absolute Positioned) -->
<div
class="absolute -top-2 right-6 md:right-12 w-16 h-16 md:w-20 md:h-20 z-10 drop-shadow-lg"
>
<img
src="https://cdn.prod.website-files.com/61f24aa108528b1962942c95/61f24aa108528b7eab942cd9_winning%20medal.svg"
loading="lazy"
alt="Winning Medal"
class="w-full h-full object-contain"
/>
</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>
<div class="grid grid-cols-1 md:grid-cols-2">
<!-- Left Column: Other Companies -->
<div
class="p-8 md:p-12 border-b md:border-b-0 md:border-r border-gray-200 bg-gray-50 flex flex-col gap-6"
>
<h3
class="text-2xl md:text-3xl font-bold text-gray-800 tracking-tight font-['Noto_Sans_TC',sans-serif]"
>
其他行銷公司
</h3>
<p
class="text-gray-500 text-sm md:text-base leading-relaxed h-[60px]"
>
市場上每間行銷公司想要讓自己的公司服務可以被看見,有時候客戶只能成為待宰羔羊
</p>
<div class="h-px w-full bg-gray-200 my-2"></div>
<ul role="list" class="flex flex-col gap-4 md:gap-5 mt-2">
{
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>
otherCompanyItems.map((item) => (
<li class="flex items-center gap-3">
<span class="text-gray-700 font-medium text-base md:text-lg flex-1">
{item}
</span>
<div class="flex-shrink-0 flex items-center justify-center w-6 h-6 rounded-full bg-red-50 relative top-1">
<img
src="https://cdn.prod.website-files.com/61f24aa108528b1962942c95/61f24aa108528b9634942cdb_wrong.svg"
loading="lazy"
alt="Cross Icon"
class="w-3 h-3"
/>
</div>
</li>
))
}
</tbody>
</table>
</ul>
</div>
<!-- CTA Note -->
<div class="comparison-note">
<p class="note-text">
選擇恩群數位,讓您的品牌在數位時代脫穎而出!
<!-- Right Column: Enchun Digital -->
<div class="p-8 md:p-12 flex flex-col gap-6 relative bg-white">
<h3
class="text-2xl md:text-3xl font-bold text-[var(--color-enchunblue)] tracking-tight font-['Noto_Sans_TC',sans-serif]"
>
恩群數位
</h3>
<p
class="text-gray-600 text-sm md:text-base leading-relaxed h-[60px]"
>
在恩群數位每個客戶都是我們最重視的拍檔,不論合作的項目大小,我們珍惜與客戶的合作關係
</p>
<div class="h-px w-full bg-blue-100 my-2"></div>
<ul role="list" class="flex flex-col gap-4 md:gap-5 mt-2">
{
enchunItems.map((item) => (
<li class="flex items-center gap-3">
<span class="text-gray-800 font-medium text-base md:text-lg flex-1">
{item}
</span>
<div class="flex-shrink-0 flex items-center justify-center w-6 h-6 rounded-full bg-green-50 relative top-1">
<img
src="https://cdn.prod.website-files.com/61f24aa108528b1962942c95/61f24aa108528b7a79942cda_checked.svg"
loading="lazy"
alt="Check Icon"
class="w-4 h-4"
/>
</div>
</li>
))
}
</ul>
</div>
</div>
</div>
<!-- CTA Button -->
<div class="mt-12 flex justify-center">
<a
href="https://heyform.itslouis.cc/form/7mYtUNjA"
target="_blank"
class="inline-flex items-center justify-center bg-[var(--color-enchunblue)] text-white font-bold text-lg px-10 py-4 rounded-full shadow-[0_8px_20px_-6px_rgba(43,83,186,0.5)] hover:shadow-[0_12px_24px_-8px_rgba(43,83,186,0.6)] hover:opacity-95 transition-all duration-300 transform hover:-translate-y-1"
>
<div class="tracking-wide">跟行銷顧問聊聊</div>
</a>
</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,62 @@
---
/**
* Cta-Hr-compoents - HR CTA Section
* 招募行動呼籲區塊,引導求職者申請面試
*/
interface Props {
title: string;
description: string;
image?: { url: string; alt: string };
}
const { title, description, image } = Astro.props;
---
<section
class="relative py-20 px-5 bg-slate-100 bg-cover bg-center overflow-hidden"
aria-labelledby="cta-heading"
style={image ? `background-image: url('${image.url}')` : undefined}
>
{/* Dark overlay when image is present */}
{
image && (
<div
class="absolute inset-0 bg-linear-to-b from-transparent to-black/70 z-0"
aria-hidden="true"
/>
)
}
<div class="relative z-10 max-w-3xl md:max-w-3xl mx-auto">
<div
class="grid grid-rows-[1fr_auto] gap-8 items-center lg:grid-cols-[1fr_auto]"
>
<div class="text-center md:text-left">
<h3
id="cta-heading"
class:list={[
"text-4xl md:text-4xl font-semibold mb-4 leading-snug",
image ? "text-white" : "text-[#23608c]",
]}
>
<Fragment set:html={title} />
</h3>
<p
class:list={[
"text-sm md:text-base leading-relaxed text-balance max-w-xl lg:max-w-full",
image ? "text-white/80" : "text-slate-600",
]}
>
{description}
</p>
</div>
<a
href="https://www.104.com.tw/company/1a2x6bkoaj?jobsource=joblist_r_cust"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center justify-center mx-auto bg-(--color-pale-purple) text-white px-8 py-4 rounded-md font-semibold text-xl transition-all duration-200 whitespace-nowrap hover:-translate-y-0.5 hover:shadow-md hover:bg-(--color-pale-purple)/90 w-full max-w-3xs md:w-full lg:max-w-[300px]"
>
立刻申請面試
</a>
</div>
</div>
</section>

View File

@@ -6,56 +6,88 @@
*/
interface SlideImage {
src: string
alt: string
src: string;
alt: string;
}
interface Props {
slides?: SlideImage[]
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' },
]
{
src: "https://enchun-cms.anlstudio.cc/api/media/file/61f76b4962117e2d84363174_%E6%81%A9%E7%BE%A4%E7%92%B0%E5%A2%83%20%E7%85%A7%E7%89%871.jpg",
alt: "恩群環境照片 1",
},
{
src: "https://enchun-cms.anlstudio.cc/api/media/file/61f76b49558b7e5b0e81de8f_%E6%81%A9%E7%BE%A4%E7%92%B0%E5%A2%83%20%E7%85%A7%E7%89%871-7.jpg",
alt: "恩群環境照片 2",
},
{
src: "https://enchun-cms.anlstudio.cc/api/media/file/61f76b4962117e2d84363174_%E6%81%A9%E7%BE%A4%E7%92%B0%E5%A2%83%20%E7%85%A7%E7%89%871-1.jpg",
alt: "恩群環境照片 3",
},
{
src: "https://enchun-cms.anlstudio.cc/api/media/file/61f76c511895ed028da1c7f0_%E6%81%A9%E7%BE%A4%E7%92%B0%E5%A2%83%20%E7%85%A7%E7%89%871-3.jpg",
alt: "恩群環境照片 4",
},
{
src: "https://enchun-cms.anlstudio.cc/api/media/file/61f76b48558b7e072a81de8e_%E6%81%A9%E7%BE%A4%E7%92%B0%E5%A2%83%20%E7%85%A7%E7%89%871-4.jpg",
alt: "恩群環境照片 5",
},
{
src: "https://enchun-cms.anlstudio.cc/api/media/file/61f76b483504a25babe537ef_%E6%81%A9%E7%BE%A4%E7%92%B0%E5%A2%83%20%E7%85%A7%E7%89%871-1.jpg",
alt: "恩群環境照片 6",
},
{
src: "https://enchun-cms.anlstudio.cc/api/media/file/61f76b48ef5754e17a8c1676_%E6%81%A9%E7%BE%A4%E7%92%B0%E5%A2%83%20%E7%85%A7%E7%89%871-6.jpg",
alt: "恩群環境照片 7",
},
{
src: "https://enchun-cms.anlstudio.cc/api/media/file/620639d7a68fef44f6569b23_%E6%81%A9%E7%BE%A4%E7%92%B0%E5%A2%83%20%E7%85%A7%E7%89%879.jpg",
alt: "恩群環境照片 8",
},
];
const slides = Astro.props.slides || defaultSlides
const slides = Astro.props.slides || defaultSlides;
import SectionHeader from "../components/SectionHeader.astro";
---
<section class="section-video" aria-label="工作環境照片">
<div class="container spacer8 w-container">
<section
class="section-video py-10 md:py-20 md:px-5 px-4 bg-white"
aria-label="工作環境照片"
>
<div class="container w-container max-w-3xl mx-auto">
<!-- Section Header -->
<div class="section_header_w_line">
<div class="divider_line"></div>
<div class="header_subtitle">
<h2 class="header_subtitle_head">在恩群工作的環境</h2>
<p class="header_subtitle_paragraph">Working Enviroment</p>
</div>
<div class="divider_line"></div>
</div>
<SectionHeader
title="在恩群工作的環境"
subtitle="Working Enviroment"
sectionBg="bg-white"
/>
<!-- Environment Slider -->
<div
class="environment-slider"
class="environment-slider relative w-full max-w-4xl mx-auto"
id="env-slider-{Math.random().toString(36).slice(2, 8)}"
>
<!-- Slides Container -->
<div class="slides-container">
<div
class="slides-container flex overflow-x-auto snap-x snap-mandatory scroll-smooth overscroll-x-contain [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden cursor-grab"
>
{
slides.map((slide, index) => (
<div class="environment-slide" data-index={index}>
<div
class="environment-slide flex-none w-full snap-start aspect-video overflow-hidden rounded-xl"
data-index={index}
>
<img
src={slide.src}
alt={slide.alt}
loading={index === 0 ? 'eager' : 'lazy'}
loading={index === 0 ? "eager" : "lazy"}
width="800"
height="450"
class="w-full h-full object-cover"
/>
</div>
))
@@ -63,23 +95,33 @@ const slides = Astro.props.slides || defaultSlides
</div>
<!-- Arrow Navigation -->
<button class="slider-arrow slider-arrow-left" aria-label="上一張">
<button
class="slider-arrow slider-arrow-left absolute top-1/2 -translate-y-1/2 w-12 h-12 md:w-12 md:h-12 w-10 h-10 bg-white/80 rounded-full flex items-center justify-center cursor-pointer transition-all duration-200 ease-in-out z-10 hover:bg-white hover:scale-110 left-4 md:left-4 left-2"
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"/>
<path
fill="currentColor"
d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"></path>
</svg>
</button>
<button class="slider-arrow slider-arrow-right" aria-label="下一張">
<button
class="slider-arrow slider-arrow-right absolute top-1/2 -translate-y-1/2 w-12 h-12 md:w-12 md:h-12 w-10 h-10 bg-white/80 rounded-full flex items-center justify-center cursor-pointer transition-all duration-200 ease-in-out z-10 hover:bg-white hover:scale-110 right-4 md:right-4 right-2"
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"/>
<path
fill="currentColor"
d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"></path>
</svg>
</button>
<!-- Dot Navigation -->
<div class="slider-dots">
<div class="slider-dots flex justify-center gap-2 mt-4">
{
slides.map((_, index) => (
<button
class={`slider-dot ${index === 0 ? 'active' : ''}`}
class={`slider-dot w-3 h-3 rounded-full cursor-pointer transition-all duration-200 ease-in-out ${index === 0 ? "active bg-(--color-enchunblue) w-8 rounded-[6px]" : "bg-(--color-enchunblue)/30"}`}
data-index={index}
aria-label={`顯示第 ${index + 1} 張照片`}
/>
@@ -90,353 +132,183 @@ const slides = Astro.props.slides || defaultSlides
</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')
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
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
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
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()
}
behavior: "smooth",
});
updateDots();
};
// Update dots
const updateDots = () => {
dots.forEach((dot, index) => {
if (index === currentIndex) {
dot.classList.add('active')
dot.classList.add(
"active",
"w-8",
"rounded-[6px]",
"bg-(--color-enchunblue)",
);
dot.classList.remove("w-3", "bg-(--color-enchunblue)/30");
} else {
dot.classList.remove('active')
}
})
dot.classList.remove(
"active",
"w-8",
"rounded-[6px]",
"bg-(--color-enchunblue)",
);
dot.classList.add("w-3", "bg-(--color-enchunblue)/30");
}
});
};
// Go to specific slide
const goToSlide = (index: number) => {
if (index < 0) index = totalSlides - 1
if (index >= totalSlides) index = 0
currentIndex = index
updateSlider()
}
if (index < 0) index = totalSlides - 1;
if (index >= totalSlides) index = 0;
currentIndex = index;
updateSlider();
};
// Previous slide
prevBtn?.addEventListener('click', () => goToSlide(currentIndex - 1))
prevBtn?.addEventListener("click", () => goToSlide(currentIndex - 1));
// Next slide
nextBtn?.addEventListener('click', () => goToSlide(currentIndex + 1))
nextBtn?.addEventListener("click", () => goToSlide(currentIndex + 1));
// Dot navigation
dots.forEach((dot, index) => {
dot.addEventListener('click', () => goToSlide(index))
})
dot.addEventListener("click", () => goToSlide(index));
});
// Scroll snap detection
container.addEventListener('scroll', () => {
const slideIndex = Math.round(container.scrollLeft / container.offsetWidth)
container.addEventListener("scroll", () => {
const slideIndex = Math.round(
container.scrollLeft / container.offsetWidth,
);
if (slideIndex !== currentIndex) {
currentIndex = slideIndex
updateDots()
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'
}
}
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'
isDragging = false;
cancelAnimationFrame(animationID);
container.style.cursor = "grab";
const movedBy = currentTranslate - prevTranslate
const movedBy = currentTranslate - prevTranslate;
if (movedBy < -50 && currentIndex < totalSlides - 1) {
currentIndex += 1
currentIndex += 1;
} else if (movedBy > 50 && currentIndex > 0) {
currentIndex -= 1
currentIndex -= 1;
}
goToSlide(currentIndex)
}
goToSlide(currentIndex);
};
const touchMove = (event: TouchEvent) => {
if (isDragging) {
const currentPosition = event.touches[0].clientX
currentTranslate = prevTranslate + currentPosition - startPos
}
const currentPosition = event.touches[0].clientX;
currentTranslate = prevTranslate + currentPosition - startPos;
}
};
const animation = () => {
if (isDragging) requestAnimationFrame(animation)
}
if (isDragging) requestAnimationFrame(animation);
};
// Mouse events for desktop
let mouseStartPos = 0
let isMouseDown = false
let mouseStartPos = 0;
let isMouseDown = false;
container.addEventListener('mousedown', (e: MouseEvent) => {
isMouseDown = true
mouseStartPos = e.clientX
container.style.cursor = 'grabbing'
})
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'
container.addEventListener("mouseup", (e: MouseEvent) => {
if (!isMouseDown) return;
isMouseDown = false;
container.style.cursor = "grab";
const movedBy = e.clientX - mouseStartPos
const movedBy = e.clientX - mouseStartPos;
if (movedBy < -50 && currentIndex < totalSlides - 1) {
currentIndex += 1
currentIndex += 1;
} else if (movedBy > 50 && currentIndex > 0) {
currentIndex -= 1
currentIndex -= 1;
}
goToSlide(currentIndex)
})
goToSlide(currentIndex);
});
container.addEventListener('mouseleave', () => {
isMouseDown = false
container.style.cursor = 'grab'
})
container.addEventListener("mouseleave", () => {
isMouseDown = false;
container.style.cursor = "grab";
});
// Touch events
container.addEventListener('touchstart', touchStart(currentIndex))
container.addEventListener('touchend', touchEnd)
container.addEventListener('touchmove', touchMove)
container.addEventListener("touchstart", touchStart(currentIndex));
container.addEventListener("touchend", touchEnd);
container.addEventListener("touchmove", touchMove);
// Keyboard navigation
slider.addEventListener('keydown', (e) => {
slider.addEventListener("keydown", (e) => {
if (e instanceof KeyboardEvent) {
if (e.key === 'ArrowLeft') goToSlide(currentIndex - 1)
if (e.key === 'ArrowRight') goToSlide(currentIndex + 1)
if (e.key === "ArrowLeft") goToSlide(currentIndex - 1);
if (e.key === "ArrowRight") goToSlide(currentIndex + 1);
}
})
});
// Set initial state
container.style.cursor = 'grab'
})
container.style.cursor = "grab";
});
}
// Initialize when DOM is ready
document.addEventListener('DOMContentLoaded', initEnvironmentSlider)
if (document.readyState !== 'loading') {
initEnvironmentSlider()
document.addEventListener("DOMContentLoaded", initEnvironmentSlider);
if (document.readyState !== "loading") {
initEnvironmentSlider();
}
</script>

View File

@@ -16,187 +16,60 @@ const {
// Four features data
const features = [
{
icon: 'location_on',
img: 'https://enchun-cms.anlstudio.cc/api/media/file/61f24aa108528bec64942cd5_address-Bro-%E5%9C%A8%E5%9C%B0%E5%8C%96%E5%84%AA%E5%85%88%201.svg',
title: '在地化優先',
description: '上線下結合曝光渠道,整合多方資訊,帶給消費者最佳的使用體驗,展現商家的獨特之處,順利的將潛在使用者帶到你的實際門市。',
description: '上線下結合曝光渠道,整合多方資訊,帶給消費者最佳的使用體驗,展現商家的獨特之處,順利的將潛在使用者帶到你的實際門市。',
},
{
icon: 'account_balance',
img: 'https://enchun-cms.anlstudio.cc/api/media/file/61f24aa108528b582e942cd6_Banknote-bro-%E9%AB%98%E6%8A%95%E8%B3%87%E5%A0%B1%E9%85%AC%E7%8E%87%201.svg',
title: '高投資轉換率',
description: '你覺得網路行銷很貴嗎?恩群數位善用每一分廣告預算,讓你在網路上發揮最大效益,幫助店家鎖定精準客群,達成目標。',
},
{
icon: 'analytics',
img: 'https://enchun-cms.anlstudio.cc/api/media/file/61f24aa108528b517f942cd8_Social%20Dashboard-bro-%E6%95%B8%E6%93%9A%E5%84%AA%E5%85%88%201.svg',
title: '數據優先',
description: '想要精準行銷?恩群數位從數據中萃取洞察,根據數據分析廣告成效,更聰明、有策略的幫您省下行銷預算。',
},
{
icon: 'handshake',
img: 'https://enchun-cms.anlstudio.cc/api/media/file/61f24aa108528b26a1942cd7_Partnership-bro-%E9%97%9C%E4%BF%82%E5%84%AA%E5%85%88%201.svg',
title: '關係優於銷售',
description: '除了幫您拓展網路上的知名度,我們更是每家公司最專業的數位夥伴,你會知道有恩群的存在,事業路上你並不孤單。',
},
]
import SectionHeader from '../components/SectionHeader.astro'
---
<section class="section_feature" aria-labelledby="feature-heading">
<div class="w-container">
<section class="section_feature bg-white px-4 py-16 md:px-5 md:py-20" aria-labelledby="feature-heading">
<div class="w-container mx-auto max-w-4xl">
<!-- 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>
<SectionHeader
title="恩群服務特色"
subtitle="Why you can trust us"
sectionBg="bg-white"
/>
<!-- Features Grid -->
<div class="feature_grid">
<div class="grid grid-cols-2 gap-1 md:grid-cols-2 md:gap-2 lg:gap-6">
{
features.map((feature) => (
<div class="feature_card">
<div class="feature_card bg-white p-5 transition-all duration-[var(--transition-base)] hover:-translate-y-1 md:p-6 lg:p-8">
<div class="flex flex-col items-center gap-4 text-center md:flex-row md:items-start md:text-left lg:gap-6">
<!-- Icon -->
<div class="feature_image">
<span class="material-icon">{feature.icon}</span>
<div class="flex w-full max-w-[100px] shrink-0 items-center justify-center md:max-w-[110px] lg:max-w-[190px]">
<img src={feature.img} alt={feature.title} class="h-auto w-full object-contain" />
</div>
<div class="flex flex-col gap-2">
<!-- Title -->
<h3 class="feature_head">{feature.title}</h3>
<h3 class="font-['Noto_Sans_TC'] text-2xl font-semibold text-(--color-dark-blue) lg:text-3xl">{feature.title}</h3>
<!-- Description -->
<p class="feature_description">{feature.description}</p>
<p class="font-['Noto_Sans_TC'] text-sm font-thin leading-[1.6] text-grey-2 lg:text-base">{feature.description}</p>
</div>
</div>
</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

@@ -34,7 +34,7 @@ const painpoints = [
{
id: 'burning',
title: '廣告行銷像燒錢',
icon: '💸',
img: '💸',
description: '廣告預算投入很多,但看不到實際效果?感覺像在燒錢?',
},
]

View File

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

View File

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

View File

@@ -4,92 +4,73 @@
* Pixel-perfect implementation based on Webflow design
*/
interface Props {
title?: string
subtitle?: string
title?: string;
subtitle?: string;
backgroundImage?: {
url?: string;
alt?: string;
};
}
const {
title = '恩群大本營',
subtitle = 'Team members of Enchun',
} = Astro.props
title = "恩群大本營",
subtitle = "Team members of Enchun",
backgroundImage,
} = Astro.props;
// Determine if we have a background image
const hasBackgroundImage = backgroundImage?.url;
const bgImageUrl = backgroundImage?.url || "";
---
<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>
<section
class:list={[
// Base styles - relative positioning for overlay
"relative",
"flex",
"items-center",
"justify-center",
"overflow-hidden",
"text-center",
"px-5",
// Background color fallback
!hasBackgroundImage && "bg-(--color-dark-blue)",
// Background image styles
hasBackgroundImage && "bg-cover bg-center bg-no-repeat",
// Pull up to counteract layout's pt-20 padding (80px)
"-mt-20",
// Full viewport height
"min-h-dvh",
"z-0",
]}
style={hasBackgroundImage
? `background-image: url('${bgImageUrl}')`
: undefined}
>
{/* Background image overlay for text readability */}
{
hasBackgroundImage && (
<div
class="absolute inset-0 bg-linear-to-b from-black/80 to-transparent z-1"
aria-hidden="true"
/>
)
}
{/* Content container - relative z-index above overlay */}
<div class="relative z-2 max-w-6xl mx-auto">
<!-- Main Title -->
<h1
class="text-white text-shadow-md font-['Noto_Sans_TC',sans-serif]! font-bold leading-[1.2] -mb-1 text-6xl! md:text-5xl"
>
{title}
</h1>
<!-- Subtitle -->
<p
class="text-gray-100 text-shadow-md font-['Quicksand'] font-thin leading-[1.2] max-w-3xl mx-auto text-3xl! md:text-2xl"
>
{subtitle}
</p>
</div>
</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>
</section>

View File

@@ -16,84 +16,142 @@
============================================ */
/* 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 */
--color-primary: #3898ec;
/* Primary blue */
--color-primary-dark: #0082f3;
/* Deep blue */
--color-primary-light: #67aee1;
/* Light blue */
--color-primary-hover: #2895f7;
/* Hover blue */
/* Secondary Colors (次要色) */
--color-secondary: #f39c12; /* Secondary orange */
--color-secondary-light: #f6c456; /* Light pink */
--color-secondary-dark: #d84038; /* Deep orange */
--color-secondary: #f39c12;
/* Secondary orange */
--color-secondary-light: #f6c456;
/* Light pink */
--color-secondary-dark: #d84038;
/* Deep orange */
/* 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 */
--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 */
/* Link Colors (連結色) */
--color-link: #3083bf; /* Default link */
--color-link-hover: #23608c; /* Link hover */
--color-link: #3083bf;
/* Default link */
--color-link-hover: #23608c;
/* Link hover */
/* 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 */
--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) */
--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 */
--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 */
--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; /* 恩群數位 */
--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 標籤 (淡藍) */
--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 - 使用灰色系 */
--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-pale-purple: oklch(62.664% 0.15547 290.298);
/* Pale Purple - CTA/強調 */
--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 */
--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
@@ -101,19 +159,30 @@
--font-family-sans: "Noto Sans TC", "Quicksand", Arial, sans-serif;
--font-family-heading: "Noto Sans TC", "Quicksand", Arial, sans-serif;
--font-family-accent: "Quicksand", "Noto Sans TC", sans-serif;
/* Rich Text Block Typography */
--rtb-heading: 1.44rem;
--rtb-body: 1rem;
--rtb-blockquote: 0.9rem;
/* ============================================
📏 SPACING - Based on Tailwind scale
============================================ */
--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 */
--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;
@@ -124,14 +193,22 @@
🔲 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 */
--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
@@ -174,9 +251,12 @@
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 */
--html-font-size-tablet: 19px;
/* 991px breakpoint */
--html-font-size-mobile: 16px;
/* 767px breakpoint */
--html-font-size-small: 13px;
/* 479px breakpoint */
}
/* ============================================
@@ -248,11 +328,9 @@ body {
/* Text Gradient */
.text-gradient {
background: linear-gradient(
135deg,
background: linear-gradient(135deg,
var(--color-primary),
var(--color-accent)
);
var(--color-accent));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
@@ -271,6 +349,7 @@ body {
from {
opacity: 0;
}
to {
opacity: 1;
}
@@ -281,6 +360,7 @@ body {
opacity: 0;
transform: translateY(1rem);
}
to {
opacity: 1;
transform: translateY(0);
@@ -353,7 +433,7 @@ body {
left: 0;
width: 0;
height: 2px;
background: var(--color-primary);
background: var(--color-secondary);
transition: width var(--transition-fast);
}
@@ -361,7 +441,11 @@ body {
width: 100%;
}
/* Active Navigation Link */
/* Active Navigation Link — position: relative ensures ::after stays within the link */
.nav-active {
position: relative;
}
.nav-active::after {
content: "";
position: absolute;
@@ -398,9 +482,11 @@ body {
.prose-custom h1 {
font-size: 2.25rem;
}
.prose-custom h2 {
font-size: 1.875rem;
}
.prose-custom h3 {
font-size: 1.5rem;
}

4438
bun.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -23,5 +23,10 @@
"esbuild",
"unrs-resolver"
]
}
},
"workspaces": [
"apps/frontend",
"apps/backend",
"packages/*"
]
}

1641
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff