14 Commits

Author SHA1 Message Date
959eaa3d9d chore: migrate from pnpm to bun package manager
Switch package manager from pnpm to bun for faster dependency
installation and better monorepo support. Remove pnpm-specific
configuration files and update all scripts to use bun commands.
2026-03-11 22:45:45 +08:00
c0f7ad7458 fix(frontend): remove isomorphic-dompurify for Cloudflare Workers compatibility 2026-03-11 21:52:22 +08:00
cbe44ffd36 fix(frontend): add fallback for DOMPurify in Cloudflare Workers 2026-03-11 21:47:16 +08:00
acc23b13b6 fix(frontend): add ASSETS binding for worker assets 2026-03-11 21:42:52 +08:00
1d84107147 fix(frontend): add KV namespace to production env 2026-03-11 21:38:32 +08:00
f17523fd91 fix(frontend): add SESSION KV namespace binding
Required by @astrojs/cloudflare adapter for session persistence.
2026-03-11 21:37:35 +08:00
e7344fa7fe fix(frontend): disable Cloudflare KV session persistence
Session KV binding was causing deployment error. Since the project
doesn't use sessions, disable persistence to avoid the binding requirement.
2026-03-11 21:35:51 +08:00
b5a8e9a1e6 chore: 新增 .assetsignore 檔案以忽略 _worker.js 2026-03-11 21:31:53 +08:00
03760b23a5 chore(frontend): consolidate wrangler config to jsonc
- Remove redundant wrangler.toml (old Pages config)
- Rename worker from enchun-frontend to website-enchun-mgr
- Keep wrangler.jsonc as single source of truth
2026-03-11 18:48:13 +08:00
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
54 changed files with 6792 additions and 32115 deletions

View File

@@ -6,11 +6,11 @@ Astro frontend + Payload CMS backend monorepo for website migration.
| Command | Purpose | | Command | Purpose |
|---------|---------| |---------|---------|
| `pnpm install` | Sync dependencies | | `bun install` | Sync dependencies |
| `pnpm dev` | Start dev server (Astro at :4321) | | `bun dev` | Start dev server (Astro at :4321) |
| `pnpm test:unit` | Run Vitest tests | | `bun test:unit` | Run Vitest tests |
| `pnpm test:e2e` | Run Playwright tests | | `bun test:e2e` | Run Playwright tests |
| `pnpm build` | Production build | | `bun build` | Production build |
## Module Locations ## Module Locations

View File

@@ -9,17 +9,17 @@
"build": "cross-env NODE_OPTIONS=--no-deprecation next build", "build": "cross-env NODE_OPTIONS=--no-deprecation next build",
"postbuild": "next-sitemap --config next-sitemap.config.cjs", "postbuild": "next-sitemap --config next-sitemap.config.cjs",
"dev": "cross-env NODE_OPTIONS=--no-deprecation next dev --port 3000", "dev": "cross-env NODE_OPTIONS=--no-deprecation next dev --port 3000",
"dev:prod": "cross-env NODE_OPTIONS=--no-deprecation rm -rf .next && pnpm build && pnpm start", "dev:prod": "cross-env NODE_OPTIONS=--no-deprecation rm -rf .next && bun run build && bun run start",
"generate:importmap": "cross-env NODE_OPTIONS=--no-deprecation payload generate:importmap", "generate:importmap": "cross-env NODE_OPTIONS=--no-deprecation payload generate:importmap",
"generate:types": "cross-env NODE_OPTIONS=--no-deprecation payload generate:types", "generate:types": "cross-env NODE_OPTIONS=--no-deprecation payload generate:types",
"ii": "cross-env NODE_OPTIONS=--no-deprecation pnpm --ignore-workspace install", "ii": "cross-env NODE_OPTIONS=--no-deprecation bun install",
"lint": "cross-env NODE_OPTIONS=--no-deprecation next lint", "lint": "cross-env NODE_OPTIONS=--no-deprecation next lint",
"lint:fix": "cross-env NODE_OPTIONS=--no-deprecation next lint --fix", "lint:fix": "cross-env NODE_OPTIONS=--no-deprecation next lint --fix",
"payload": "cross-env NODE_OPTIONS=--no-deprecation payload", "payload": "cross-env NODE_OPTIONS=--no-deprecation payload",
"reinstall": "cross-env NODE_OPTIONS=--no-deprecation rm -rf node_modules && rm pnpm-lock.yaml && pnpm --ignore-workspace install", "reinstall": "cross-env NODE_OPTIONS=--no-deprecation rm -rf node_modules && rm bun.lockb && bun install",
"start": "cross-env NODE_OPTIONS=--no-deprecation next start", "start": "cross-env NODE_OPTIONS=--no-deprecation next start",
"test": "pnpm run test:int && pnpm run test:e2e", "test": "bun run test:int && bun run test:e2e",
"test:e2e": "cross-env NODE_OPTIONS=\"--no-deprecation --no-experimental-strip-types\" pnpm exec playwright test --config=playwright.config.ts", "test:e2e": "cross-env NODE_OPTIONS=\"--no-deprecation --no-experimental-strip-types\" bunx playwright test --config=playwright.config.ts",
"test:int": "cross-env NODE_OPTIONS=--no-deprecation vitest run --config ./vitest.config.mts", "test:int": "cross-env NODE_OPTIONS=--no-deprecation vitest run --config ./vitest.config.mts",
"test:load": "k6 run tests/k6/public-browsing.js", "test:load": "k6 run tests/k6/public-browsing.js",
"test:load:all": "k6 run tests/k6/public-browsing.js && k6 run tests/k6/api-performance.js", "test:load:all": "k6 run tests/k6/public-browsing.js && k6 run tests/k6/api-performance.js",
@@ -93,9 +93,5 @@
"typescript": "5.7.3", "typescript": "5.7.3",
"vite-tsconfig-paths": "5.1.4", "vite-tsconfig-paths": "5.1.4",
"vitest": "3.2.3" "vitest": "3.2.3"
},
"engines": {
"node": "^18.20.2 || >=20.9.0",
"pnpm": "^9 || ^10"
} }
} }

11203
apps/backend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -8,7 +8,8 @@ import { getPayload } from 'payload'
import React from 'react' import React from 'react'
import PageClient from './page.client' 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 const revalidate = 600
export default async function Page() { export default async function Page() {

View File

@@ -9,6 +9,8 @@ import React from 'react'
import PageClient from './page.client' import PageClient from './page.client'
import { notFound } from 'next/navigation' import { notFound } from 'next/navigation'
// Use dynamic rendering to avoid database connection during build
export const dynamic = 'force-dynamic'
export const revalidate = 600 export const revalidate = 600
type Args = { 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 { CallToAction } from '../../blocks/CallToAction/config'
import { Content } from '../../blocks/Content/config' import { Content } from '../../blocks/Content/config'
import { MediaBlock } from '../../blocks/MediaBlock/config' import { MediaBlock } from '../../blocks/MediaBlock/config'
import { ServicesList } from '../../blocks/ServicesList/config'
import { hero } from '@/heros/config' import { hero } from '@/heros/config'
import { slugField } from '@/fields/slug' import { slugField } from '@/fields/slug'
import { populatePublishedAt } from '../../hooks/populatePublishedAt' import { populatePublishedAt } from '../../hooks/populatePublishedAt'
@@ -76,7 +77,7 @@ export const Pages: CollectionConfig<'pages'> = {
{ {
name: 'layout', name: 'layout',
type: 'blocks', type: 'blocks',
blocks: [CallToAction, Content, MediaBlock, Archive], blocks: [CallToAction, Content, MediaBlock, Archive, ServicesList],
required: true, required: true,
admin: { admin: {
initCollapsed: true, initCollapsed: true,

View File

@@ -194,7 +194,7 @@ export interface Page {
| null; | null;
media?: (string | null) | Media; media?: (string | null) | Media;
}; };
layout: (CallToActionBlock | ContentBlock | MediaBlock | ArchiveBlock)[]; layout: (CallToActionBlock | ContentBlock | MediaBlock | ArchiveBlock | ServicesListBlock)[];
meta?: { meta?: {
title?: string | null; title?: string | null;
/** /**
@@ -564,6 +564,37 @@ export interface ArchiveBlock {
blockName?: string | null; blockName?: string | null;
blockType: 'archive'; 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 * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "portfolio". * via the `definition` "portfolio".
@@ -923,6 +954,7 @@ export interface PagesSelect<T extends boolean = true> {
content?: T | ContentBlockSelect<T>; content?: T | ContentBlockSelect<T>;
mediaBlock?: T | MediaBlockSelect<T>; mediaBlock?: T | MediaBlockSelect<T>;
archive?: T | ArchiveBlockSelect<T>; archive?: T | ArchiveBlockSelect<T>;
servicesList?: T | ServicesListBlockSelect<T>;
}; };
meta?: meta?:
| T | T
@@ -1011,6 +1043,29 @@ export interface ArchiveBlockSelect<T extends boolean = true> {
id?: T; id?: T;
blockName?: 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 * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "posts_select". * via the `definition` "posts_select".

View File

@@ -0,0 +1 @@
_worker.js

View File

@@ -13,6 +13,9 @@ export default defineConfig({
enabled: true, enabled: true,
configPath: './wrangler.jsonc', configPath: './wrangler.jsonc',
}, },
session: {
persistence: 'none',
},
}), }),
vite: { vite: {
plugins: [tailwindcss()], plugins: [tailwindcss()],

View File

@@ -3,3 +3,10 @@
# Production Payload CMS URL (for SSR fetch) # Production Payload CMS URL (for SSR fetch)
PAYLOAD_CMS_URL=https://enchun-admin.anlstudio.cc 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

@@ -6,7 +6,7 @@
"scripts": { "scripts": {
"dev": "astro dev --host --port 4321", "dev": "astro dev --host --port 4321",
"dev:pages": "wrangler pages dev --compatibility-date=2024-01-01", "dev:pages": "wrangler pages dev --compatibility-date=2024-01-01",
"build": "astro build", "build": "astro build && cp .assetsignore dist/",
"preview": "astro preview", "preview": "astro preview",
"check": "astro check", "check": "astro check",
"typecheck": "astro check", "typecheck": "astro check",
@@ -17,7 +17,7 @@
"@tailwindcss/vite": "^4.1.14", "@tailwindcss/vite": "^4.1.14",
"agentation": "^2.1.1", "agentation": "^2.1.1",
"agentation-mcp": "^1.1.0", "agentation-mcp": "^1.1.0",
"astro": "6.0.0-beta.1", "astro": "6.0.0-beta.17",
"better-auth": "^1.3.13" "better-auth": "^1.3.13"
}, },
"devDependencies": { "devDependencies": {

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(); const currentYear = new Date().getFullYear();
--- ---
<footer class="bg-[var(--color-tropical-blue)] py-10 mt-auto relative"> <footer class="bg-(--color-tropical-blue) pt-10 mt-auto">
<div class="max-w-5xl mx-auto px-4"> <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="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-8 mb-16">
<div class="col-span-2"> <div class="col-span-2">
<Image <Image
@@ -56,23 +56,21 @@ const currentYear = new Date().getFullYear();
decoding="async" decoding="async"
/> />
<p <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> </p>
</div> </div>
<div> <div>
<h3 <h3 class="text-lg font-bold text-(--color-st-tropaz) mb-4">
class="text-lg font-bold text-[var(--color-st-tropaz)] mb-4"
>
聯絡我們 聯絡我們
</h3> </h3>
<a <a
href="https://www.facebook.com/EnChun-Taiwan-100979265112420" href="https://www.facebook.com/EnChun-Taiwan-100979265112420"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="flex items-center mb-2" class="flex items-center mb-2 no-underline hover:underline transition-colors"
> >
<Image <Image
src="/fb-icon.svg" src="/fb-icon.svg"
@@ -84,74 +82,77 @@ const currentYear = new Date().getFullYear();
decoding="async" decoding="async"
/> />
</a> </a>
<p class="text-[var(--color-st-tropaz)] mb-2"> <p class="text-sm text-(--color-st-tropaz) mb-2">
諮詢電話:<br /> 02 5570 0527 諮詢電話:<br /> 02 5570 0527
</p> </p>
<a <a
href="mailto:enchuntaiwan@gmail.com" 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 >enchuntaiwan@gmail.com</a
> >
</div> </div>
<div> <div>
<h3 <h3 class="text-lg font-bold text-(--color-st-tropaz) mb-4">
class="text-lg font-bold text-[var(--color-st-tropaz)] mb-4"
>
行銷方案 行銷方案
</h3> </h3>
<ul class="space-y-2" id="marketing-solutions"> <ul
{footerNavItems.length > 0 && footerNavItems[0]?.childNavItems class="text-sm font-thin space-y-2"
? footerNavItems[0].childNavItems.map((item: any) => ( id="marketing-solutions"
>
{
footerNavItems.length > 0 &&
footerNavItems[0]?.childNavItems ? (
footerNavItems[0].childNavItems.map((item: any) => (
<li>
<a
href={item.link?.url || "#"}
class="text-(--color-st-tropaz) hover:text-(--color-dark-blue) hover:font-light no-underline transition-colors duration-300"
>
{item.link?.label || "連結"}
</a>
</li>
))
) : (
<li> <li>
<a href={item.link?.url || "#"} class="font-normal text-[var(--color-st-tropaz)] hover:text-[var(--color-dove-gray)] transition-colors"> <span class="text-gray-500">載入中...</span>
{item.link?.label || "連結"}
</a>
</li> </li>
)) )
: <li><span class="text-gray-500">載入中...</span></li>
} }
</ul> </ul>
</div> </div>
<div> <div>
<h3 <h3 class="text-lg font-bold text-(--color-st-tropaz) mb-4">
class="text-lg font-bold text-[var(--color-st-tropaz)] mb-4"
>
行銷放大鏡 行銷放大鏡
</h3> </h3>
<ul class="space-y-2" id="marketing-articles"> <ul class="text-sm font-thin space-y-2" id="marketing-articles">
{categories.length > 0 {
? categories.map((cat: any) => ( categories.length > 0 ? (
categories.map((cat: any) => (
<li>
<a
href={`/blog/category/${cat.slug}`}
class="text-(--color-st-tropaz) hover:text-(--color-dark-blue) hover:font-light no-underline transition-colors duration-300"
title={cat.nameEn || cat.title}
>
{cat.title}
</a>
</li>
))
) : (
<li> <li>
<a href={`/blog/category/${cat.slug}`} <span class="text-gray-500">暫無分類</span>
class="font-normal text-[var(--color-st-tropaz)] hover:text-[var(--color-dove-gray)] transition-colors"
title={cat.nameEn || cat.title}>
{cat.title}
</a>
</li> </li>
)) )
: <li><span class="text-gray-500">暫無分類</span></li>
} }
</ul> </ul>
</div> </div>
</div> </div>
<div </div>
class="absolute inset-x-0 w-screen bg-[var(--color-amber)] py-3 text-center -left-4" <div
> class="w-screen bg-(--color-amber) font-['Quicksand'] text-(--color-tarawera) py-2 text-xs text-center"
<p class="text-[var(--color-tarawera)]"> >
copyright © Enchun digital 2018 - {currentYear} <p>
</p> copyright © Enchun digital 2018 - {currentYear}
</div> </p>
</div> </div>
</footer> </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 // 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 // Use local backend in development, production URL from dev.vars/wrangler
const isDev = import.meta.env.DEV; const isDev = import.meta.env.DEV;
const PAYLOAD_CMS_URL = isDev 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"; : import.meta.env.PAYLOAD_CMS_URL || "https://enchun-admin.anlstudio.cc";
// Fetch navigation data from Payload CMS server-side // Fetch navigation data from Payload CMS server-side
let navItems: any[] = []; let navItems: NavItem[] = [];
try { try {
const response = await fetch( 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) { if (response.ok) {
const data = await response.json(); const data = await response.json();
navItems = data?.navItems || data || []; navItems = data?.navItems || [];
} }
} catch (error) { } catch (error) {
console.error("[Header SSR] Failed to fetch navigation:", 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 // Helper to get link URL
function getLinkUrl(link: any): string { function getLinkUrl(link: NavLink): string {
if (!link) return "#"; if (!link) return "#";
if (link.type === "custom" && link.url) { if (link.type === "custom" && link.url) {
@@ -32,10 +68,8 @@ function getLinkUrl(link: any): string {
if (link.type === "reference" && link.reference?.value) { if (link.type === "reference" && link.reference?.value) {
if (typeof link.reference.value === "string") { if (typeof link.reference.value === "string") {
// It's an ID, construct URL based on relationTo
return `/${link.reference.relationTo || "pages"}/${link.reference.value}`; return `/${link.reference.relationTo || "pages"}/${link.reference.value}`;
} else if (link.reference.value.slug) { } else if (link.reference.value.slug) {
// It's a populated object with slug
return `/${link.reference.value.slug}`; return `/${link.reference.value.slug}`;
} }
} }
@@ -43,26 +77,21 @@ function getLinkUrl(link: any): string {
return "#"; return "#";
} }
// Check if label should have a badge // Return badge type for label (data-driven, no HTML strings)
function getBadgeForLabel(label: string): string { function getBadgeType(label: string): "hot" | "new" | null {
if (label.includes("行銷方案")) { if (label.includes("行銷方案")) return "hot";
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 "new";
} return null;
if (label.includes("行銷放大鏡")) {
return `<span class="absolute -top-2 -right-3 bg-red-500 text-white text-[6px] font-bold px-1.5 py-0.5 rounded-full uppercase tracking-wider shadow-sm">New</span>`;
}
return "";
} }
// Check if link is active // Check if link is active
function isLinkActive(url: string): boolean { function isLinkActive(url: string): boolean {
const currentPath = Astro.url.pathname; return Astro.url.pathname === url;
return currentPath === url || (url === "/" && currentPath === "/");
} }
--- ---
<header <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" id="main-header"
> >
<nav <nav
@@ -72,46 +101,51 @@ function isLinkActive(url: string): boolean {
<ul class="flex items-center justify-between list-none"> <ul class="flex items-center justify-between list-none">
<li class="shrink-0"> <li class="shrink-0">
<a href="/" class="block"> <a href="/" class="block">
<!-- Uses Astro's optimized Image component for the site logo --> <img
<Image
src="/enchun-logo.svg" src="/enchun-logo.svg"
alt="Enchun Digital Marketing" alt="Enchun Digital Marketing"
class="w-32 h-auto transition-opacity duration-300 drop-shadow-md" class="w-32 h-auto transition-transform duration-300 drop-shadow-md"
width={919} width="919"
height={201} height="201"
loading="eager" loading="eager"
decoding="async" decoding="async"
/> />
</a> </a>
</li> </li>
<li class="hidden md:flex items-center space-x-6"> {
{ navItems.map((item) => {
navItems.map((item) => { const link = item.link;
const link = item.link; const href = getLinkUrl(link);
const href = getLinkUrl(link); const label = link.label || "未命名";
const label = link.label || "未命名"; const badge = getBadgeType(label);
const badge = getBadgeForLabel(label); const active = isLinkActive(href);
const activeClass = isLinkActive(href)
? "nav-active"
: "";
const hasBadge = badge ? "relative inline-block" : "";
return ( return (
<li class="hidden md:block">
<a <a
href={href} 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 && { {...(link.newTab && {
target: "_blank", target: "_blank",
rel: "noopener noreferrer", rel: "noopener noreferrer",
})} })}
> >
{label} {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> </a>
); </li>
}) );
} })
</li> }
<!-- Mobile menu button with animated hamburger/X icon --> <!-- Mobile menu button with animated hamburger/X icon -->
<li class="md:hidden"> <li class="md:hidden">
<button <button
@@ -154,55 +188,57 @@ function isLinkActive(url: string): boolean {
</button> </button>
</li> </li>
</ul> </ul>
<!-- Mobile menu with full-screen overlay --> </nav>
<div <!-- Mobile menu with full-screen overlay (separate nav for semantics) -->
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
id="mobile-menu" 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"
id="mobile-nav"
> >
<ul {
class="flex flex-col items-center justify-center h-full space-y-6 px-4" navItems.map((item, index) => {
id="mobile-nav" const link = item.link;
> const href = getLinkUrl(link);
{ const label = link.label || "未命名";
navItems.map((item, index) => {
const link = item.link;
const href = getLinkUrl(link);
const label = link.label || "未命名";
return ( return (
<li <li
class={`opacity-0`} class="mobile-nav-item"
style={`animation-delay: ${index * 50}ms`} style={`animation-delay: ${index * 50}ms`}
>
<a
href={href}
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",
})}
> >
<a {label}
href={href} </a>
class="text-2xl text-grey-700 text-shadow-sm font-medium transition-all duration-200 transform hover:scale-105" </li>
{...(link.newTab && { );
target: "_blank", })
rel: "noopener noreferrer", }
})} </ul>
>
{label}
</a>
</li>
);
})
}
</ul>
</div>
</nav> </nav>
</header> </header>
<script> <script>
// Scroll-based header with smooth hide/show animation // Scroll-based header with smooth hide/show animation
// Uses AbortController for cleanup on re-mount (View Transitions)
let lastScrollY = 0; let lastScrollY = 0;
let ticking = false; let ticking = false;
let isHeaderHidden = false; let isHeaderHidden = false;
let abortController: AbortController | null = null;
function initScrollEffect() { function initScrollEffect() {
const header = document.getElementById("main-header"); const header = document.getElementById("main-header");
const nav = document.getElementById("main-nav"); const nav = document.getElementById("main-nav");
const mobileMenu = document.getElementById("mobile-menu");
if (!header || !nav) return; if (!header || !nav) return;
const handleScroll = () => { const handleScroll = () => {
@@ -210,90 +246,36 @@ function isLinkActive(url: string): boolean {
const scrollDelta = scrollY - lastScrollY; const scrollDelta = scrollY - lastScrollY;
const scrollDirection = scrollDelta > 0 ? "down" : "up"; const scrollDirection = scrollDelta > 0 ? "down" : "up";
// Only update lastScrollY if there's meaningful movement
if (Math.abs(scrollDelta) > 1) { if (Math.abs(scrollDelta) > 1) {
lastScrollY = scrollY; lastScrollY = scrollY;
} }
// Header shrinks and gets background on scroll // Toggle single class for scrolled state (CSS handles all visuals)
if (scrollY > 10) { if (scrollY > 10) {
if (!header.classList.contains("header-scrolled")) { header.classList.add("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)]",
);
}
}
} else { } else {
if (header.classList.contains("header-scrolled")) { header.classList.remove("header-scrolled");
header.classList.remove(
"bg-white/15",
"bg-white/90",
"backdrop-blur-xl",
"shadow-md",
"border-b",
"border-gray-200/50",
"header-scrolled",
);
header.classList.add("bg-transparent");
nav.classList.remove("py-2");
nav.classList.add("py-4");
// Reset mobile menu top position and height when header expands
if (mobileMenu) {
mobileMenu.classList.remove(
"top-16",
"h-[calc(100vh-64px)]",
"h-[calc(100dvh-64px)]",
);
mobileMenu.classList.add(
"top-20",
"h-[calc(100vh-80px)]",
"h-[calc(100dvh-80px)]",
);
}
}
} }
// Hide/show header based on scroll direction - with threshold // Hide/show header based on scroll direction
const hideThreshold = 150; // Higher threshold before hiding const hideThreshold = 150;
const showThreshold = 50; // Lower threshold to show (makes it feel smoother) const showThreshold = 50;
if ( if (
scrollY > hideThreshold && scrollY > hideThreshold &&
scrollDirection === "down" && scrollDirection === "down" &&
!isHeaderHidden !isHeaderHidden
) { ) {
// Only hide if scrolling down consistently
if (scrollDelta > 5) { if (scrollDelta > 5) {
// Must be scrolling down with some speed
header.classList.remove("translate-in"); header.classList.remove("translate-in");
header.classList.add("translate-out"); header.classList.add("translate-out");
isHeaderHidden = true; isHeaderHidden = true;
} }
} else if (scrollDirection === "up" && isHeaderHidden) { } else if (scrollDirection === "up" && isHeaderHidden) {
// Show immediately when scrolling up
header.classList.remove("translate-out"); header.classList.remove("translate-out");
header.classList.add("translate-in"); header.classList.add("translate-in");
isHeaderHidden = false; isHeaderHidden = false;
} else if (scrollY < showThreshold) { } else if (scrollY < showThreshold) {
// Always show when near top
header.classList.remove("translate-out"); header.classList.remove("translate-out");
header.classList.add("translate-in"); header.classList.add("translate-in");
isHeaderHidden = false; isHeaderHidden = false;
@@ -302,44 +284,23 @@ function isLinkActive(url: string): boolean {
ticking = false; ticking = false;
}; };
const signal = abortController!.signal;
window.addEventListener( window.addEventListener(
"scroll", "scroll",
() => { () => {
if (!ticking) { if (!ticking) {
window.requestAnimationFrame(() => { window.requestAnimationFrame(handleScroll);
handleScroll();
ticking = false;
});
ticking = true; ticking = true;
} }
}, },
{ passive: true }, { passive: true, signal },
); );
handleScroll(); 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() { function initMobileMenu() {
if (mobileMenuInitialized) return;
const button = document.getElementById("mobile-menu-button"); const button = document.getElementById("mobile-menu-button");
const menu = document.getElementById("mobile-menu"); const menu = document.getElementById("mobile-menu");
const hamburgerIcon = document.getElementById("hamburger-icon"); const hamburgerIcon = document.getElementById("hamburger-icon");
@@ -348,88 +309,98 @@ function isLinkActive(url: string): boolean {
.getElementById("mobile-nav") .getElementById("mobile-nav")
?.querySelectorAll("li"); ?.querySelectorAll("li");
if (!button || !menu || !hamburgerIcon || !closeIcon) { if (!button || !menu || !hamburgerIcon || !closeIcon) return;
// Retry after a delay if elements aren't ready
setTimeout(initMobileMenu, 100);
return;
}
button.addEventListener("click", () => { const signal = abortController!.signal;
const isMenuOpen = menu.classList.contains("opacity-100");
if (isMenuOpen) { button.addEventListener(
menu.classList.remove("opacity-100", "visible"); "click",
menu.classList.add("opacity-0", "invisible"); () => {
hamburgerIcon.classList.remove("opacity-0", "rotate-90"); const isMenuOpen = menu.classList.contains("opacity-100");
hamburgerIcon.classList.add("opacity-100");
closeIcon.classList.remove("opacity-100");
closeIcon.classList.add("opacity-0");
button.setAttribute("aria-expanded", "false");
mobileNavItems?.forEach((item) => { if (isMenuOpen) {
item.classList.remove("opacity-100", "translate-y-0"); menu.classList.remove("opacity-100", "visible");
item.classList.add("opacity-0", "translate-y-4"); menu.classList.add("opacity-0", "invisible");
}); hamburgerIcon.classList.remove("opacity-0", "rotate-90");
hamburgerIcon.classList.add("opacity-100");
closeIcon.classList.remove("opacity-100");
closeIcon.classList.add("opacity-0");
button.setAttribute("aria-expanded", "false");
document.body.style.overflow = ""; mobileNavItems?.forEach((item) => {
} else { item.classList.remove("mobile-nav-visible");
menu.classList.remove("opacity-0", "invisible"); item.classList.add("mobile-nav-hidden");
menu.classList.add("opacity-100", "visible"); });
hamburgerIcon.classList.remove("opacity-100");
hamburgerIcon.classList.add("opacity-0", "rotate-90");
closeIcon.classList.remove("opacity-0");
closeIcon.classList.add("opacity-100");
button.setAttribute("aria-expanded", "true");
mobileNavItems?.forEach((item, index) => { document.documentElement.classList.remove(
setTimeout(() => { "mobile-menu-open",
item.classList.remove("opacity-0", "translate-y-4"); );
item.classList.add( } else {
"opacity-100", menu.classList.remove("opacity-0", "invisible");
"translate-y-0", menu.classList.add("opacity-100", "visible");
"transition-all", hamburgerIcon.classList.remove("opacity-100");
"duration-300", hamburgerIcon.classList.add("opacity-0", "rotate-90");
"ease-out", closeIcon.classList.remove("opacity-0");
); closeIcon.classList.add("opacity-100");
}, index * 50); button.setAttribute("aria-expanded", "true");
});
document.body.style.overflow = "hidden"; mobileNavItems?.forEach((item, index) => {
} setTimeout(() => {
}); item.classList.remove("mobile-nav-hidden");
item.classList.add("mobile-nav-visible");
}, index * 50);
});
menu.addEventListener("click", (e) => { document.documentElement.classList.add("mobile-menu-open");
if (e.target === menu) { }
button.click(); },
} { signal },
}); );
document.addEventListener("keydown", (e) => { menu.addEventListener(
if (e.key === "Escape" && menu.classList.contains("opacity-100")) { "click",
button.click(); (e) => {
} if (e.target === menu) {
}); button.click();
}
},
{ signal },
);
mobileMenuInitialized = true; document.addEventListener(
"keydown",
(e) => {
if (
e.key === "Escape" &&
menu.classList.contains("opacity-100")
) {
button.click();
}
},
{ 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(); initScrollEffect();
initMobileMenu(); initMobileMenu();
}); }
document.addEventListener("DOMContentLoaded", init);
document.addEventListener("astro:after-swap", init);
</script> </script>
<style> <style>
#main-header { /* JS-controlled animation states */
transition:
background-color 0.3s ease-in-out,
backdrop-filter 0.3s ease-in-out,
box-shadow 0.3s ease-in-out,
transform 0.3s ease-in-out;
will-change: transform;
}
/* Header translate animation - hide completely above viewport */
#main-header.translate-out { #main-header.translate-out {
transform: translateY(-100%); transform: translateY(-100%);
} }
@@ -438,21 +409,46 @@ function isLinkActive(url: string): boolean {
transform: translateY(0); transform: translateY(0);
} }
/* Nav padding transition for shrink effect */ /* --- Scrolled state (all visual changes via single CSS class) --- */
#main-nav { #main-header.header-scrolled {
transition: padding 0.3s ease-in-out; 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 */ /* Logo shrinks when header is scrolled */
#main-header.header-scrolled img { #main-header.header-scrolled img {
transform: scale(0.85); 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 */ /* Navigation links */
#main-nav a { #main-nav a {
color: var(--color-nav-link); color: var(--color-nav-link);
position: relative;
text-shadow: 1px 1px 3px rgba(0, 0, 0, 0.3); text-shadow: 1px 1px 3px rgba(0, 0, 0, 0.3);
} }
@@ -460,28 +456,53 @@ function isLinkActive(url: string): boolean {
color: var(--color-primary); color: var(--color-primary);
} }
/* Nav link underline animation */
.nav-link {
position: relative;
}
/* Active state */ /* Active state */
.nav-active { .nav-active {
position: relative;
color: var(--color-enchunblue) !important; color: var(--color-enchunblue) !important;
font-weight: 600; font-weight: 600;
} }
/* Badge positioning and styling */ /* Badge positioning for nav */
#main-nav a[class*="relative"] .absolute { .badge-nav {
position: absolute;
top: -4px; top: -4px;
right: -8px; 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 */ .badge-nav.badge-hot {
#mobile-menu { background-color: var(--color-badge-hot, #ea384c);
transition: animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
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-new {
background-color: var(--color-badge-hot, #ea384c);
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
/* Mobile menu */
@media (max-width: 767px) { @media (max-width: 767px) {
#mobile-menu { #mobile-menu {
overflow-y: auto; overflow-y: auto;
@@ -502,12 +523,30 @@ function isLinkActive(url: string): boolean {
} }
} }
/* Smooth transition for all nav elements */ /* Mobile nav item animation states (CSS-driven) */
#main-nav a, .mobile-nav-item {
#mobile-nav a { opacity: 0;
transform: translateY(1rem);
transition: transition:
color 0.2s ease-in-out, opacity 0.3s ease-out,
text-shadow 0.2s ease-in-out, transform 0.3s ease-out;
background-color 0.2s ease-in-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> </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,68 +5,77 @@
*/ */
interface PortfolioItem { interface PortfolioItem {
slug: string slug: string;
title: string title: string;
description: string description: string;
image?: string image?: string;
tags?: string[] tags?: string[];
externalUrl?: string externalUrl?: string;
} }
interface Props { interface Props {
item: PortfolioItem item: PortfolioItem;
} }
const { item } = Astro.props const { item } = Astro.props;
const imageUrl = item.image || '/placeholder-portfolio.jpg' const imageUrl = item.image || "/placeholder-portfolio.jpg";
const title = item.title || 'Untitled' const title = item.title || "Untitled";
const description = item.description || '' const description = item.description || "";
const tags = item.tags || [] const tags = item.tags || [];
const hasExternalLink = !!item.externalUrl const hasExternalLink = !!item.externalUrl;
const linkHref = hasExternalLink ? item.externalUrl : `/website-portfolio/${item.slug}` const linkHref = hasExternalLink
const linkTarget = hasExternalLink ? '_blank' : '_self' ? item.externalUrl
const linkRel = hasExternalLink ? 'noopener noreferrer' : '' : `/website-portfolio/${item.slug}`;
const linkTarget = hasExternalLink ? "_blank" : "_self";
const linkRel = hasExternalLink ? "noopener noreferrer" : "";
--- ---
<li class="portfolio-card-item"> <li class="list-none">
<a <a
href={linkHref} href={linkHref}
target={linkTarget} target={linkTarget}
rel={linkRel} rel={linkRel}
class="portfolio-card" class="block bg-white rounded-xl overflow-hidden shadow-sm hover:shadow-lg transition-all duration-300 ease-in-out no-underline text-inherit hover:-translate-y-1 group"
> >
<!-- Image Wrapper -->
<div class="portfolio-image-wrapper">
<img
src={imageUrl}
alt={title}
class="portfolio-image"
loading="lazy"
decoding="async"
width="800"
height="450"
/>
</div>
<!-- Content --> <!-- Content -->
<div class="portfolio-content"> <div class="p-2 md:p-3">
<!-- Title --> <!-- Title -->
<h3 class="portfolio-title">{title}</h3> <h3
class="font-['Noto_Sans_TC'] text-xl md:text-2xl font-semibold text-[var(--color-tarawera)] mb-2"
>
{title}
</h3>
<!-- Description --> <!-- Description -->
{ {
description && ( description && (
<p class="portfolio-description">{description}</p> <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="relative w-full rounded-lg aspect-video mb-4 overflow-hidden">
<img
src={imageUrl}
alt={title}
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"
height="450"
/>
</div>
<!-- Tags --> <!-- Tags -->
{ {
tags.length > 0 && ( tags.length > 0 && (
<div class="portfolio-tags"> <div class="flex flex-wrap gap-2">
{tags.map((tag) => ( {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> </div>
) )
@@ -74,100 +83,3 @@ const linkRel = hasExternalLink ? 'noopener noreferrer' : ''
</div> </div>
</a> </a>
</li> </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 const displayDate = post.publishedAt || post.createdAt
--- ---
<a href={`/xing-xiao-fang-da-jing/${post.slug}`} class="article-card group"> <a href={`/xing-xiao-fang-da-jing/${post.slug}`} class="block no-underline text-inherit group">
<div class="article-card-inner"> <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 --> <!-- Featured Image -->
<div class="article-image-wrapper"> <div class="aspect-video overflow-hidden bg-[var(--color-gray-100)]">
<img <img
src={imageUrl} src={imageUrl}
alt={imageAlt} alt={imageAlt}
loading="lazy" loading="lazy"
class="article-image" class="w-full h-full object-cover transition-transform duration-400 group-hover:scale-105"
width="800" width="800"
height="450" height="450"
/> />
</div> </div>
<!-- Card Content --> <!-- Card Content -->
<div class="article-content"> <div class="p-6 md:p-5 flex flex-col flex-1">
<!-- Category Badge --> <!-- Category Badge -->
{ {
category && ( category && (
<span <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'}`} style={`background-color: ${category.backgroundColor || 'var(--color-enchunblue)'}; color: ${category.textColor || '#fff'}`}
> >
{category.title} {category.title}
@@ -81,14 +81,14 @@ const displayDate = post.publishedAt || post.createdAt
} }
<!-- Title --> <!-- 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} {post.title}
</h3> </h3>
<!-- Excerpt --> <!-- Excerpt -->
{ {
post.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} {post.excerpt.length > 150 ? post.excerpt.slice(0, 150) + '...' : post.excerpt}
</p> </p>
) )
@@ -97,7 +97,7 @@ const displayDate = post.publishedAt || post.createdAt
<!-- Published Date --> <!-- Published Date -->
{ {
displayDate && ( 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)} {formatDate(displayDate)}
</time> </time>
) )
@@ -105,111 +105,3 @@ const displayDate = post.publishedAt || post.createdAt
</div> </div>
</div> </div>
</a> </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') const filteredCategories = categories.filter(c => c.slug !== 'wen-zhang-fen-lei')
--- ---
<nav class="category-filter" aria-label="文章分類篩選"> <nav class="py-4 mb-8" aria-label="文章分類篩選">
<div class="filter-container"> <div class="flex flex-wrap justify-center gap-3 md:gap-2 max-w-[1200px] mx-auto">
<a <a
href={baseUrl} href={baseUrl}
class:filter-button-active={!activeCategory} 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 }]}
class="filter-button"
> >
全部文章 全部文章
</a> </a>
@@ -38,8 +37,7 @@ const filteredCategories = categories.filter(c => c.slug !== 'wen-zhang-fen-lei'
filteredCategories.map((category) => ( filteredCategories.map((category) => (
<a <a
href={`/wen-zhang-fen-lei/${category.slug}`} href={`/wen-zhang-fen-lei/${category.slug}`}
class:filter-button-active={activeCategory === category.slug} 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 }]}
class="filter-button"
aria-label={`篩選 ${category.title} 類別文章`} aria-label={`篩選 ${category.title} 類別文章`}
> >
{category.title} {category.title}
@@ -48,66 +46,3 @@ const filteredCategories = categories.filter(c => c.slug !== 'wen-zhang-fen-lei'
} }
</div> </div>
</nav> </nav>
<style>
.category-filter {
padding: 1rem 0;
margin-bottom: 2rem;
}
.filter-container {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.75rem;
max-width: 1200px;
margin: 0 auto;
}
.filter-button {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.625rem 1.25rem;
border: 2px solid var(--color-gray-300, #e0e0e0);
border-radius: 24px;
background: #ffffff;
color: var(--color-gray-700, #555);
font-family: "Noto Sans TC", sans-serif;
font-size: 0.9375rem;
font-weight: 500;
text-decoration: none;
transition: all 0.25s ease;
white-space: nowrap;
}
.filter-button:hover {
border-color: var(--color-enchunblue, #23608c);
color: var(--color-enchunblue, #23608c);
background: rgba(35, 96, 140, 0.05);
}
.filter-button-active {
background: var(--color-enchunblue, #23608c);
border-color: var(--color-enchunblue, #23608c);
color: #ffffff !important;
}
.filter-button-active:hover {
background: var(--color-enchunblue-hover, #1a4d6e);
border-color: var(--color-enchunblue-hover, #1a4d6e);
color: #ffffff;
}
/* Responsive Adjustments */
@media (max-width: 767px) {
.filter-container {
gap: 0.5rem;
}
.filter-button {
padding: 0.5rem 1rem;
font-size: 0.875rem;
}
}
</style>

View File

@@ -40,42 +40,43 @@ const { posts, title = '相關文章' } = Astro.props
{ {
posts && posts.length > 0 && ( posts && posts.length > 0 && (
<section class="related-posts-section" aria-labelledby="related-posts-heading"> <section class="py-16 md:py-12 px-6 bg-[var(--color-gray-50)]" aria-labelledby="related-posts-heading">
<div class="container"> <div class="max-w-[1200px] mx-auto">
<h2 id="related-posts-heading" class="related-posts-title"> <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} {title}
</h2> </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) => ( 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 --> <!-- Post Image -->
<div class="related-post-image"> <div class="aspect-video overflow-hidden bg-[var(--color-gray-200)]">
<img <img
src={post.heroImage?.url || '/placeholder-blog.jpg'} src={post.heroImage?.url || '/placeholder-blog.jpg'}
alt={post.heroImage?.alt || post.title} alt={post.heroImage?.alt || post.title}
loading="lazy" loading="lazy"
class="w-full h-full object-cover transition-transform duration-400 group-hover:scale-105"
width="400" width="400"
height="225" height="225"
/> />
</div> </div>
<!-- Post Content --> <!-- Post Content -->
<div class="related-post-content"> <div class="p-5 flex flex-col gap-3">
<!-- Category Badge --> <!-- Category Badge -->
{ {
post.categories && post.categories[0] && ( post.categories && post.categories[0] && (
<span <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'}`} style={`background-color: ${post.categories[0].backgroundColor || 'var(--color-enchunblue)'}; color: ${post.categories[0].textColor || '#fff'}`}
> >
{post.categories[0].title} {post.categories[0].title}
</span> </span>
)} )}
<!-- Post Title --> <!-- 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} {post.title}
</h3> </h3>
</div> </div>
@@ -87,109 +88,3 @@ const { posts, title = '相關文章' } = Astro.props
</section> </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,41 @@
/**
* SVG sanitization utilities
* Prevents XSS attacks from user-provided SVG content
*
* Note: Uses basic regex-based sanitization for Cloudflare Workers compatibility
* DOMPurify doesn't work in Workers due to lack of DOM APIs
*/
/**
* SVG sanitization - removes dangerous tags and attributes
* Suitable for Cloudflare Workers environment
*/
export const sanitizeSvg = (svg: string): string => {
if (!svg || typeof svg !== 'string') {
return ''
}
let sanitized = svg
// Remove script tags and their content
sanitized = sanitized.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
// Remove dangerous tags and their content
sanitized = sanitized.replace(/<(iframe|object|embed|style|link|meta|base)[^>]*>.*?<\/\1>/gi, '')
sanitized = sanitized.replace(/<(iframe|object|embed|style|link|meta|base)[^>]*\/?>/gi, '')
// Remove event handlers (onclick, onload, onerror, etc.)
sanitized = sanitized.replace(/\s+on\w+\s*=\s*["'][^"']*["']/gi, '')
sanitized = sanitized.replace(/\s+on\w+\s*=\s*[^\s>]+/gi, '')
// Remove javascript: URLs
sanitized = sanitized.replace(/javascript\s*:/gi, '')
// Remove data: URLs (except for images which are safe in SVGs)
sanitized = sanitized.replace(/(?<!image\/)data\s*:/gi, '')
// Remove style attributes that could contain expressions
sanitized = sanitized.replace(/\s+style\s*=\s*["'][^"']*expression[^"']*["']/gi, '')
return sanitized
}

View File

@@ -3,20 +3,30 @@
* About Page - 關於恩群數位 * About Page - 關於恩群數位
* 展示公司特色、服務優勢和與其他公司的差異 * 展示公司特色、服務優勢和與其他公司的差異
*/ */
import Layout from '../layouts/Layout.astro' import Layout from "../layouts/Layout.astro";
import AboutHero from '../sections/AboutHero.astro' import AboutHero from "../sections/AboutHero.astro";
import FeatureSection from '../sections/FeatureSection.astro' import FeatureSection from "../sections/FeatureSection.astro";
import ComparisonSection from '../sections/ComparisonSection.astro' import ComparisonSection from "../sections/ComparisonSection.astro";
import CTASection from '../sections/CTASection.astro' import CTASection from "../sections/CTASection.astro";
import SectionHeader from "../components/SectionHeader.astro";
import CtaSection from "@/components/CtaSection.astro";
// Metadata for SEO // Metadata for SEO
const title = '關於恩群數位 | 專業數位行銷服務團隊' const title = "關於恩群數位 | 專業數位行銷服務團隊";
const description = '恩群數位行銷成立於2018年提供全方位數位行銷服務。我們在地化優先、數據驅動是您最可信赖的數位行銷夥伴。' const description =
"恩群數位行銷成立於2018年提供全方位數位行銷服務。我們在地化優先、數據驅動是您最可信赖的數位行銷夥伴。";
--- ---
<Layout title={title} description={description}> <Layout title={title} description={description}>
<!-- Hero Section --> <!-- 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 --> <!-- Service Features Section -->
<FeatureSection /> <FeatureSection />
@@ -25,14 +35,5 @@ const description = '恩群數位行銷成立於2018年提供全方位數位
<ComparisonSection /> <ComparisonSection />
<!-- CTA Section --> <!-- CTA Section -->
<CTASection <CtaSection />
homeData={{
ctaSection: {
headline: '準備好開始新的旅程了嗎',
description: '讓我們一起為您的品牌打造獨特的數位行銷策略',
buttonText: '聯絡我們',
buttonLink: '/contact-us',
},
}}
/>
</Layout> </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 * Pixel-perfect implementation based on Webflow design
* Includes form validation, submission handling, and responsive layout * 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 // Metadata for SEO
const title = '聯絡我們 | 恩群數位行銷' const title = "聯絡我們 | 恩群數位行銷";
const description = '有任何問題嗎?歡迎聯絡恩群數位行銷,我們的專業團隊將竭誠為您服務。電話: 02 5570 0527Email: enchuntaiwan@gmail.com' const description =
"有任何問題嗎?歡迎聯絡恩群數位行銷,我們的專業團隊將竭誠為您服務。電話: 02 5570 0527Email: enchuntaiwan@gmail.com";
--- ---
<Layout title={title} description={description}> <Layout title={title} description={description}>
<section class="contact-section" id="contact"> <HeaderBg />
<div class="contactus_wrapper"> <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 --> <!-- Contact Form Side -->
<div class="contact_form_wrapper"> <div class="w-full lg:order-2">
<h1 class="contact_head">聯絡我們</h1> <h2
<p class="contact_parafraph"> 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>
<p class="contact_reminder"> <p class="text-sm italic text-slate-500 mb-8">
* 標註欄位為必填 有星號的地方 (*) 是必填欄位
</p> </p>
<!-- Contact Form --> <!-- 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 --> <!-- 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> </div>
<!-- Error Message --> <!-- 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> </div>
<!-- Form Fields --> <!-- Name Field -->
<div class="contact-form-grid"> <div class="flex flex-col">
<!-- Name Field --> <label
<div class="contact_field_wrapper"> for="Name"
<label for="Name" class="contact_field_name"> class="text-[0.95rem] font-medium text-slate-500 mb-2 block"
姓名 <span>*</span> >
</label> 姓名*
<input </label>
type="text" <input
id="Name" type="text"
name="Name" id="Name"
class="input_field" name="Name"
required 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]"
minlength="2" required
maxlength="256" minlength="2"
placeholder="請輸入您的姓名" maxlength="256"
/> />
<span class="error-message" id="Name-error"></span> <span
</div> class="error-message text-[#dc3545] text-sm mt-1 hidden"
id="Name-error"></span>
</div>
<!-- Phone Field --> <!-- Phone Field -->
<div class="contact_field_wrapper"> <div class="flex flex-col">
<label for="Phone" class="contact_field_name"> <label
聯絡電話 <span>*</span> for="Phone"
</label> class="text-[0.95rem] font-medium text-slate-500 mb-2 block"
<input >
type="tel" 聯絡電話*
id="Phone" </label>
name="Phone" <input
class="input_field" type="tel"
required id="Phone"
placeholder="請輸入您的電話號碼" name="Phone"
/> 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]"
<span class="error-message" id="Phone-error"></span> required
</div> />
<span
class="error-message text-[#dc3545] text-sm mt-1 hidden"
id="Phone-error"></span>
</div> </div>
<!-- Email Field --> <!-- Email Field -->
<div class="contact_field_wrapper"> <div class="flex flex-col">
<label for="Email" class="contact_field_name"> <label
Email <span>*</span> for="Email"
class="text-[0.95rem] font-medium text-slate-500 mb-2 block"
>
Email *
</label> </label>
<input <input
type="email" type="email"
id="Email" id="Email"
name="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 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> </div>
<!-- Message Field --> <!-- Message Field -->
<div class="contact_field_wrapper"> <div class="flex flex-col">
<label for="Message" class="contact_field_name"> <label
聯絡訊息 <span>*</span> for="Message"
class="text-[0.95rem] font-medium text-slate-500 mb-2 block"
>
聯絡訊息
</label> </label>
<textarea <textarea
id="Message" id="Message"
name="Message" name="Message"
class="input_field" 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]"
minlength="10"
maxlength="5000"
required required
placeholder="請輸入您的訊息(至少 10 個字元)" minlength="10"
></textarea> maxlength="5000"></textarea>
<span class="error-message" id="Message-error"></span> <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> </div>
<!-- Submit Button --> <!-- Submit Button -->
<button type="submit" class="submit-button" id="submit-btn"> <div class="flex justify-end mt-2">
<span class="button-text">送出訊息</span> <button
<span class="button-loading" style="display: none;">送出中...</span> type="submit"
</button> 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> </form>
</div> </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> </div>
</section> </section>
</Layout> </Layout>
<style> <script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer
/* Contact Section Styles - Pixel-perfect from Webflow */ ></script>
.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> <script>
// Form validation and submission handler // Form validation and submission handler
function initContactForm() { function initContactForm() {
const form = document.getElementById('contact-form') as HTMLFormElement const form = document.getElementById("contact-form") as HTMLFormElement;
const submitBtn = document.getElementById('submit-btn') as HTMLButtonElement const submitBtn = document.getElementById(
const successMsg = document.getElementById('form-success') as HTMLElement "submit-btn",
const errorMsg = document.getElementById('form-error') as HTMLElement ) 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 // Validation patterns
const patterns = { const patterns = {
Name: /^[\u4e00-\u9fa5a-zA-Z\s]{2,256}$/, Name: /^[\u4e00-\u9fa5a-zA-Z\s]{2,256}$/,
Phone: /^[0-9\-\s\+]{6,20}$/, Phone: /^[0-9\-\s\+]{6,20}$/,
Email: /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/, Email: /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/,
Message: /^.{10,5000}$/ Message: /^.{10,5000}$/,
} };
// Validation function // Validation function
function validateField(input: HTMLInputElement | HTMLTextAreaElement): boolean { function validateField(
const name = input.name input: HTMLInputElement | HTMLTextAreaElement,
const value = input.value.trim() ): boolean {
const errorSpan = document.getElementById(`${name}-error`) as HTMLElement const name = input.name;
const value = input.value.trim();
const errorSpan = document.getElementById(`${name}-error`) as HTMLElement;
if (!input.hasAttribute('required') && !value) { if (!input.hasAttribute("required") && !value) {
clearError(input, errorSpan) clearError(input, errorSpan);
return true return true;
} }
let isValid = true let isValid = true;
let errorMessage = '' let errorMessage = "";
// Required check // Required check
if (input.hasAttribute('required') && !value) { if (input.hasAttribute("required") && !value) {
isValid = false isValid = false;
errorMessage = '此欄位為必填' errorMessage = "此欄位為必填";
} }
// Pattern validation // Pattern validation
else if (patterns[name as keyof typeof patterns] && !patterns[name as keyof typeof patterns].test(value)) { else if (
isValid = false patterns[name as keyof typeof patterns] &&
!patterns[name as keyof typeof patterns].test(value)
) {
isValid = false;
switch (name) { switch (name) {
case 'Name': case "Name":
errorMessage = '請輸入有效的姓名(至少 2 個字元)' errorMessage = "請輸入有效的姓名(至少 2 個字元)";
break break;
case 'Phone': case "Phone":
errorMessage = '請輸入有效的電話號碼' errorMessage = "請輸入有效的電話號碼";
break break;
case 'Email': case "Email":
errorMessage = '請輸入有效的 Email 格式' errorMessage = "請輸入有效的 Email 格式";
break break;
case 'Message': case "Message":
errorMessage = '訊息至少需要 10 個字元' errorMessage = "訊息至少需要 10 個字元";
break break;
} }
} }
// Show/hide error // Show/hide error
if (!isValid) { if (!isValid) {
input.classList.add('error') input.classList.add("error");
if (errorSpan) { if (errorSpan) {
errorSpan.textContent = errorMessage errorSpan.textContent = errorMessage;
errorSpan.style.display = 'block' errorSpan.style.display = "block";
} }
} else { } else {
clearError(input, errorSpan) clearError(input, errorSpan);
} }
return isValid return isValid;
} }
function clearError(input: HTMLInputElement | HTMLTextAreaElement, errorSpan: HTMLElement) { function clearError(
input.classList.remove('error') input: HTMLInputElement | HTMLTextAreaElement,
errorSpan: HTMLElement,
) {
input.classList.remove("error");
if (errorSpan) { if (errorSpan) {
errorSpan.textContent = '' errorSpan.textContent = "";
errorSpan.style.display = 'none' errorSpan.style.display = "none";
} }
} }
// Real-time validation on blur // Real-time validation on blur
form.querySelectorAll('input, textarea').forEach((field) => { form.querySelectorAll("input, textarea").forEach((field) => {
field.addEventListener('blur', () => { field.addEventListener("blur", () => {
validateField(field as HTMLInputElement | HTMLTextAreaElement) validateField(field as HTMLInputElement | HTMLTextAreaElement);
}) });
field.addEventListener('input', () => { field.addEventListener("input", () => {
const input = field as HTMLInputElement | HTMLTextAreaElement const input = field as HTMLInputElement | HTMLTextAreaElement;
if (input.classList.contains('error')) { if (input.classList.contains("error")) {
validateField(input) validateField(input);
} }
}) });
}) });
// Form submission // Form submission
form.addEventListener('submit', async (e) => { form.addEventListener("submit", async (e) => {
e.preventDefault() e.preventDefault();
// Validate all fields // Validate all fields
const inputs = form.querySelectorAll('input, textarea') as NodeListOf<HTMLInputElement | HTMLTextAreaElement> const inputs = form.querySelectorAll("input, textarea") as NodeListOf<
let isFormValid = true HTMLInputElement | HTMLTextAreaElement
>;
let isFormValid = true;
inputs.forEach((input) => { inputs.forEach((input) => {
if (!validateField(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) { if (!isFormValid) {
// Scroll to first error // 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) { if (firstError) {
firstError.scrollIntoView({ behavior: 'smooth', block: 'center' }) firstError.scrollIntoView({ behavior: "smooth", block: "center" });
} }
return return;
} }
// Show loading state // Show loading state
submitBtn.disabled = true submitBtn.disabled = true;
const buttonText = submitBtn.querySelector('.button-text') as HTMLElement const buttonText = submitBtn.querySelector(".button-text") as HTMLElement;
const buttonLoading = submitBtn.querySelector('.button-loading') as HTMLElement const buttonLoading = submitBtn.querySelector(
if (buttonText) buttonText.style.display = 'none' ".button-loading",
if (buttonLoading) buttonLoading.style.display = 'inline' ) as HTMLElement;
if (buttonText) buttonText.style.display = "none";
if (buttonLoading) buttonLoading.style.display = "inline";
// Hide previous messages // Hide previous messages
successMsg.style.display = 'none' successMsg.style.display = "none";
errorMsg.style.display = 'none' errorMsg.style.display = "none";
// Collect form data
const formData = new FormData(form)
const data = { const data = {
name: formData.get('Name'), name: formData.get("Name"),
phone: formData.get('Phone'), phone: formData.get("Phone"),
email: formData.get('Email'), email: formData.get("Email"),
message: formData.get('Message') message: formData.get("Message"),
} "cf-turnstile-response": turnstileToken,
};
try { try {
// Submit to backend (via API proxy) // Submit to API proxy
const response = await fetch('/api/contact', { const response = await fetch("/api/contact", {
method: 'POST', method: "POST",
headers: { headers: {
'Content-Type': 'application/json' "Content-Type": "application/json",
}, },
body: JSON.stringify(data) body: JSON.stringify(data),
}) });
if (response.ok) { if (response.ok) {
// Success // Success
successMsg.style.display = 'block' successMsg.style.display = "block";
form.reset() form.reset();
window.scrollTo({ top: 0, behavior: 'smooth' }) window.scrollTo({ top: 0, behavior: "smooth" });
} else { } else {
throw new Error('Submission failed') throw new Error("Submission failed");
} }
} catch (error) { } catch (error) {
console.error('Form submission error:', error) console.error("Form submission error:", error);
errorMsg.style.display = 'block' errorMsg.style.display = "block";
} finally { } finally {
// Reset button state // Reset button state
submitBtn.disabled = false submitBtn.disabled = false;
if (buttonText) buttonText.style.display = 'inline' if (buttonText) buttonText.style.display = "inline";
if (buttonLoading) buttonLoading.style.display = 'none' if (buttonLoading) buttonLoading.style.display = "none";
} }
}) });
} }
// Initialize when DOM is ready // Initialize when DOM is ready
document.addEventListener('DOMContentLoaded', initContactForm) document.addEventListener("DOMContentLoaded", initContactForm);
if (document.readyState !== 'loading') { if (document.readyState !== "loading") {
initContactForm() initContactForm();
} }
</script> </script>

View File

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

View File

@@ -39,29 +39,29 @@ const validCategories = categories.filter(c =>
<Layout title={title} description={description}> <Layout title={title} description={description}>
<!-- Blog Hero Section --> <!-- Blog Hero Section -->
<section class="blog-hero" aria-labelledby="blog-heading"> <section class="py-[60px] px-5 bg-white lg:py-10 md:py-10 md:px-4" aria-labelledby="blog-heading">
<div class="container"> <div class="max-w-[1200px] mx-auto">
<div class="section_header_w_line"> <div class="flex items-center justify-center gap-4 md:flex-wrap md:gap-3">
<div class="divider_line"></div> <div class="w-10 h-0.5 bg-link-hover md:w-[30px]"></div>
<div class="header_subtitle"> <div class="text-center">
<h2 id="blog-heading" class="header_subtitle_head">行銷放大鏡</h2> <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="header_subtitle_paragraph">Marketing Blog</p> <p class="text-base text-slate-600 font-accent md:text-[0.9375rem]">Marketing Blog</p>
</div> </div>
<div class="divider_line"></div> <div class="w-10 h-0.5 bg-link-hover md:w-[30px]"></div>
</div> </div>
</div> </div>
</section> </section>
<!-- Category Filter --> <!-- Category Filter -->
<section class="filter-section"> <section class="bg-white pb-5">
<div class="container"> <div class="max-w-[1200px] mx-auto">
<CategoryFilter categories={validCategories} /> <CategoryFilter categories={validCategories} />
</div> </div>
</section> </section>
<!-- Blog Posts Grid --> <!-- Blog Posts Grid -->
<section class="blog-section" aria-label="文章列表"> <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="container"> <div class="max-w-[1200px] mx-auto">
{ {
posts.length > 0 ? ( 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"> <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>
) : ( ) : (
<div class="empty-state"> <div class="text-center py-20 px-5">
<p class="empty-text">暫無文章</p> <p class="text-lg text-slate-500">暫無文章</p>
</div> </div>
) )
} }
@@ -79,13 +79,13 @@ const validCategories = categories.filter(c =>
<!-- Pagination --> <!-- Pagination -->
{ {
totalPages > 1 && ( totalPages > 1 && (
<nav class="pagination" aria-label="分頁導航"> <nav class="pt-5" aria-label="分頁導航">
<div class="pagination-container"> <div class="flex items-center justify-center gap-6 md:gap-4">
{ {
hasPreviousPage && ( hasPreviousPage && (
<a <a
href={`?page=${page - 1}`} 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="上一頁" aria-label="上一頁"
> >
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"> <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"> <div class="flex items-center gap-2 font-accent text-base text-slate-600">
<span class="current-page">{page}</span> <span class="font-bold text-link-hover">{page}</span>
<span class="page-separator">/</span> <span class="text-slate-400">/</span>
<span class="total-pages">{totalPages}</span> <span>{totalPages}</span>
</div> </div>
{ {
hasNextPage && ( hasNextPage && (
<a <a
href={`?page=${page + 1}`} 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="下一頁" aria-label="下一頁"
> >
下一頁 下一頁
@@ -124,166 +124,3 @@ const validCategories = categories.filter(c =>
</section> </section>
</Layout> </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 * Pixel-perfect implementation based on Webflow design
*/ */
import Layout from '../layouts/Layout.astro' import Layout from "../layouts/Layout.astro";
import TeamsHero from '../sections/TeamsHero.astro' import TeamsHero from "../sections/TeamsHero.astro";
import EnvironmentSlider from '../sections/EnvironmentSlider.astro' import EnvironmentSlider from "../sections/EnvironmentSlider.astro";
import CompanyStory from '../sections/CompanyStory.astro' import CompanyStory from "../sections/CompanyStory.astro";
import BenefitsSection from '../sections/BenefitsSection.astro' import BenefitsSection from "../sections/BenefitsSection.astro";
import SectionHeader from "../components/SectionHeader.astro";
import CtaHrCompoents from "../sections/Cta-Hr-compoents.astro";
// Metadata for SEO // Metadata for SEO
const title = '恩群大本營 | 恩群數位行銷' const title = "恩群大本營 | 恩群數位行銷";
const description = '加入恩群數位的團隊,享受優質的工作環境與完善的員工福利。我們重視個人的特質發揮,歡迎樂於學習、善於建立關係的你加入我們。' const description =
"加入恩群數位的團隊,享受優質的工作環境與完善的員工福利。我們重視個人的特質發揮,歡迎樂於學習、善於建立關係的你加入我們。";
--- ---
<Layout title={title} description={description}> <Layout title={title} description={description}>
<!-- Hero Section --> <!-- 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 --> <!-- Environment Slider Section -->
<EnvironmentSlider /> <EnvironmentSlider />
<!-- Company Story Section --> <!-- Company Story Section -->
<CompanyStory /> <CompanyStory />
<!-- Benefits Section --> <!-- Benefits Section -->
<BenefitsSection /> <BenefitsSection />
<!-- CTA Section --> <!-- CTA Section -->
<section class="section-call4action" aria-labelledby="cta-heading"> <CtaHrCompoents
<div class="container w-container"> title="以人的成長為優先<br />創造人的最大價值"
<div class="c4a-grid"> description="在恩群數位裡我們重視個人的特質能夠完全發揮,只要你樂於學習、善於跟人建立關係,並且重要的是你有一個善良的心,恩群數位歡迎你的加入"
<div class="c4a-content"> image={{
<h3 id="cta-heading" class="career-c4a-heading"> url: "https://enchun-cms.anlstudio.cc/api/media/file/61f24aa108528b4723942d01_工作環境-銘言底圖-1400x659.jpg",
以人的成長為優先<br /> alt: "工作環境",
創造人的最大價值 }}
</h3> />
<p class="career-c4a-paragraph">
在恩群數位裡我們重視個人的特質能夠完全發揮,只要你樂於學習、善於跟人建立關係,並且重要的是你有一個善良的心,恩群數位歡迎你的加入
</p>
</div>
<a
href="https://www.104.com.tw/company/1a2x6bkoaj?jobsource=joblist_r_cust"
target="_blank"
rel="noopener noreferrer"
class="c4a-button"
>
立刻申請面試
</a>
<div class="c4a-bg"></div>
</div>
</div>
</section>
</Layout> </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 - 案例分享列表頁 * Portfolio Listing Page - 案例分享列表頁
* Pixel-perfect implementation based on Webflow design * Pixel-perfect implementation based on Webflow design
*/ */
import Layout from '../layouts/Layout.astro' import Layout from "../layouts/Layout.astro";
import PortfolioCard from '../components/PortfolioCard.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 // Metadata for SEO
const title = '案例分享 | 恩群數位行銷' const title = "案例分享 | 恩群數位行銷";
const description = '瀏覽恩群數位的成功案例,包括企業官網、電商網站、品牌網站等設計作品。' const description =
"瀏覽恩群數位的成功案例,包括企業官網、電商網站、品牌網站等設計作品。";
// Portfolio items - can be fetched from Payload CMS // Portfolio items - can be fetched from Payload CMS
const portfolioItems = [ const portfolioItems = [
{ {
slug: 'corporate-website-1', slug: "corporate-website-1",
title: '企業官網設計案例', title: "Saas 一頁式頁面",
description: '為知名製造業打造的現代化企業官網,整合產品展示與新聞發佈功能。', description:
image: '/placeholder-portfolio-1.jpg', "現代SaaS一頁式網站吸引目標客戶並提升轉化率而設計。採用React、Tailwind CSS和TypeScript等先進技術構建響應式流暢導航、視覺效果和性能。非常適合初創公司和科技新創",
tags: ['企業官網', '響應式設計'], image:
"https://enchun-cms.anlstudio.cc/api/media/file/67a99c71f1be7be0b6fb5b6b_saas_website_design-p-1080.webp",
tags: ["SaaS", "一頁式頁面"],
}, },
{ {
slug: 'ecommerce-site-1', slug: "ecommerce-site-1",
title: '電商平台建置', title: "BioNova銷售頁",
description: 'B2C 電商網站,包含會員系統、購物車、金流整合等完整功能。', description:
image: '/placeholder-portfolio-2.jpg', "本網站以簡潔、專業、易於操作的介面,向受眾傳達 BioNova 超能複方菁萃膠囊的產品特色、優勢及功效,並鼓勵消費者立即購買。",
tags: ['電商網站', '金流整合'], image:
"https://enchun-cms.anlstudio.cc/api/media/file/67a9a0aa4ef074c15c2b4409_landingpage_website_design-p-1080.webp",
tags: ["電商網站", "金流整合"],
}, },
{ {
slug: 'brand-website-1', slug: "brand-website-1",
title: '品牌形象網站', title: "美髮業預約頁面",
description: '以視覺故事為核心的品牌網站,展現品牌獨特價值與理念。', description:
image: '/placeholder-portfolio-3.jpg', "美髮官網線上預約系統,透過便捷高效的線上預約系統已成為吸引顧客、提升服務品質的關鍵。一個專業、時尚、易於操作的線上平台,讓顧客隨時隨地輕鬆預約美髮服務,同時展示美髮店的專業形象、服務特色及最新資訊。",
tags: ['品牌網站', '視覺設計'], image:
"https://enchun-cms.anlstudio.cc/api/media/file/67aacde54fbce6ce004c18d2_booking_website_design_alt-p-1080.webp",
tags: ["品牌網站", "視覺設計"],
}, },
{ {
slug: 'landing-page-1', slug: "landing-page-1",
title: '活動行銷頁面', title: "物流業官方網站",
description: '高轉換率的活動頁面設計,有效的 CTA 配置與使用者體驗規劃。', description:
image: '/placeholder-portfolio-4.jpg', "專業、高效的物流網站已成為必然趨勢,透過設計出操作便捷的物流網站,以滿足現代物流業務的需求。提供線上客服功能,解答客戶疑問提供幫助。",
tags: ['活動頁面', '行銷'], image:
"https://enchun-cms.anlstudio.cc/api/media/file/67a9acb0737c25d4ba894081_freight_website_design-p-1080.webp",
tags: ["活動頁面", "行銷"],
}, },
] ];
--- ---
<Layout title={title} description={description}> <Layout title={title} description={description}>
<!-- Portfolio Header --> <HeaderBg />
<section class="portfolio-header" aria-labelledby="portfolio-heading"> <SectionHeader
<div class="container"> title="案例分享"
<h1 id="portfolio-heading" class="portfolio-title">案例分享</h1> subtitle="Selected Works"
<p class="portfolio-subtitle">Selected Works</p> sectionBg="bg-white"
<div class="divider-line"></div> />
<div class="divider-line"></div>
</div>
</section>
<!-- Portfolio Grid --> <!-- Portfolio Grid -->
<section class="portfolio-grid-section" aria-label="作品列表"> <section
<ul class="portfolio-grid"> class="py-0 px-5 pb-20 bg-white lg:px-4 lg:pb-10"
{ aria-label="作品列表"
portfolioItems.map((item) => ( >
<PortfolioCard item={item} /> <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> </ul>
</section> </section>
<!-- CTA Section --> <!-- CTA Section -->
<section class="portfolio-cta" aria-labelledby="cta-heading"> <CtaSection />
<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> </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 * Pixel-perfect implementation based on Webflow design
*/ */
interface Props { interface Props {
title?: string title?: string;
subtitle?: string subtitle?: string;
backgroundImage?: {
url?: string;
alt?: string;
};
} }
const { const {
title = '關於恩群數位', title = "關於恩群數位",
subtitle = 'About Enchun digital', subtitle = "About Enchun digital",
} = Astro.props backgroundImage,
} = Astro.props;
// Determine if we have a background image
const hasBackgroundImage = backgroundImage?.url;
const bgImageUrl = backgroundImage?.url || "";
--- ---
<header class="hero-overlay-about"> <section
<div class="w-container"> class:list={[
<div class="div-block"> // Base styles - relative positioning for overlay
<h1 class="hero_title_head-about">{title}</h1> "relative",
<p class="hero_sub_paragraph-about">{subtitle}</p> "flex",
</div> "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> </section>
<style>
/* About Hero Styles - Pixel-perfect from Webflow */
.hero-overlay-about {
background-color: #ffffff;
padding: 120px 20px;
text-align: center;
}
.w-container {
max-width: 1200px;
margin: 0 auto;
}
.hero_title_head-about {
color: var(--color-enchunblue);
font-family: "Noto Sans TC", "Quicksand", sans-serif;
font-weight: 700;
font-size: 3rem;
line-height: 1.2;
margin-bottom: 16px;
}
.hero_sub_paragraph-about {
color: var(--color-enchunblue-dark);
font-family: "Quicksand", "Noto Sans TC", sans-serif;
font-weight: 400;
font-size: 1.25rem;
line-height: 1.4;
}
/* Responsive Adjustments */
@media (max-width: 991px) {
.hero-overlay-about {
padding: 80px 20px;
}
.hero_title_head-about {
font-size: 2.5rem;
}
.hero_sub_paragraph-about {
font-size: 1.125rem;
}
}
@media (max-width: 767px) {
.hero-overlay-about {
padding: 60px 20px;
}
.hero_title_head-about {
font-size: 2rem;
}
.hero_sub_paragraph-about {
font-size: 1rem;
}
}
</style>

View File

@@ -6,304 +6,93 @@
*/ */
interface BenefitItem { interface BenefitItem {
title: string title: string;
icon: string img: string;
} }
interface Props { interface Props {
benefits?: BenefitItem[] benefits?: BenefitItem[];
} }
const defaultBenefits: BenefitItem[] = [ const defaultBenefits: BenefitItem[] = [
{ {
title: '高績效、高獎金\n新人開張獎金', title: "高績效、高獎金\n新人開張獎金",
icon: 'bonus', 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員工下午茶', title: "生日慶生、電影日\n員工下午茶",
icon: 'birthday', img: "/api/media/file/61f24aa108528be590942d06_Blowing%20out%20Birthday%20candles-bro-%E7%94%9F%E6%97%A5%E6%85%B6%E7%94%9F.svg",
}, },
{ {
title: '教育訓練補助', title: "教育訓練補助",
icon: 'education', 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: '寬敞的工作空間', title: "寬敞的工作空間",
icon: 'workspace', 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部門聚餐、年終活動', title: "員工國內外旅遊\n部門聚餐、年終活動",
icon: 'travel', img: "/api/media/file/61f24aa108528b0960942d04_Flight%20Booking-bro-%E5%93%A1%E5%B7%A5%E6%97%85%E9%81%8A.svg",
}, },
{ {
title: '入職培訓及團隊建設', title: "入職培訓及團隊建設",
icon: 'training', 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 const benefits = Astro.props.benefits || defaultBenefits;
import SectionHeader from "../components/SectionHeader.astro";
// Icon SVG components (placeholder for now, replace with actual SVG)
const getIconSVG = (iconType: string) => {
const icons: Record<string, string> = {
bonus: `<svg viewBox="0 0 200 200" class="benefit-icon-svg">
<circle cx="100" cy="70" r="40" fill="#23608c" opacity="0.2"/>
<rect x="60" y="100" width="80" height="60" rx="8" fill="#23608c"/>
<text x="100" y="140" text-anchor="middle" fill="white" font-size="36">💰</text>
</svg>`,
birthday: `<svg viewBox="0 0 200 200" class="benefit-icon-svg">
<circle cx="100" cy="70" r="40" fill="#23608c" opacity="0.2"/>
<rect x="60" y="100" width="80" height="60" rx="8" fill="#23608c"/>
<text x="100" y="140" text-anchor="middle" fill="white" font-size="36">🎂</text>
</svg>`,
education: `<svg viewBox="0 0 200 200" class="benefit-icon-svg">
<circle cx="100" cy="70" r="40" fill="#23608c" opacity="0.2"/>
<rect x="60" y="100" width="80" height="60" rx="8" fill="#23608c"/>
<text x="100" y="140" text-anchor="middle" fill="white" font-size="36">📚</text>
</svg>`,
workspace: `<svg viewBox="0 0 200 200" class="benefit-icon-svg">
<circle cx="100" cy="70" r="40" fill="#23608c" opacity="0.2"/>
<rect x="60" y="100" width="80" height="60" rx="8" fill="#23608c"/>
<text x="100" y="140" text-anchor="middle" fill="white" font-size="36">🏢</text>
</svg>`,
travel: `<svg viewBox="0 0 200 200" class="benefit-icon-svg">
<circle cx="100" cy="70" r="40" fill="#23608c" opacity="0.2"/>
<rect x="60" y="100" width="80" height="60" rx="8" fill="#23608c"/>
<text x="100" y="140" text-anchor="middle" fill="white" font-size="36">✈️</text>
</svg>`,
training: `<svg viewBox="0 0 200 200" class="benefit-icon-svg">
<circle cx="100" cy="70" r="40" fill="#23608c" opacity="0.2"/>
<rect x="60" y="100" width="80" height="60" rx="8" fill="#23608c"/>
<text x="100" y="140" text-anchor="middle" fill="white" font-size="36">🤝</text>
</svg>`,
}
return icons[iconType] || icons.bonus
}
--- ---
<section class="section-benefit" aria-labelledby="benefits-heading"> <section
<div class="container w-container"> 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 --> <!-- Section Header -->
<div class="section_header_w_line"> <SectionHeader
<div class="divider_line"></div> title="工作福利"
<div class="header_subtitle"> subtitle="Benefit Packages"
<h2 id="benefits-heading" class="header_subtitle_head">工作福利</h2> sectionBg="bg-white"
<p class="header_subtitle_paragraph">Benefit Package</p> />
</div>
<div class="divider_line"></div>
</div>
<!-- Benefits Grid --> <!-- Benefits Grid: 2 cards per row -->
<div class="benefit-grid-wrapper"> <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) => ( benefits.map((benefit, index) => {
<div class={`benefit-card ${index % 2 === 0 ? 'benefit-card' : 'benefit-card-opposite'}`}> const isLeft = index % 2 === 0;
<!-- Odd: Icon on right, Even: Icon on left --> return (
{ <div class="grid grid-cols-2 gap-2 items-center">
index % 2 === 0 ? ( <div
<> class:list={[
<div class="benefit-content"> isLeft ? "order-1 text-right" : "order-2 text-left",
<h3 class="benefit-title-text">{benefit.title}</h3> ]}
</div> >
<div class="benefit-image-right" set:html={getIconSVG(benefit.icon)} /> <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-left" set:html={getIconSVG(benefit.icon)} /> <div
<div class="benefit-content"> class:list={[
<h3 class="benefit-title-text">{benefit.title}</h3> "flex items-center",
</div> isLeft ? "order-2 justify-start" : "order-1 justify-end",
</> ]}
) >
} <img
</div> src={`https://enchun-cms.anlstudio.cc${benefit.img}`}
)) alt={benefit.title}
class="size-35 object-contain"
loading="lazy"
decoding="async"
/>
</div>
</div>
);
})
} }
</div> </div>
</div> </div>
</section> </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 * Pixel-perfect implementation based on Webflow design
*/ */
interface Props { interface Props {
title?: string title?: string;
subtitle?: string subtitle?: string;
content?: string content?: string;
} }
const { const {
title = '恩群數位的故事', title = "恩群數位的故事",
subtitle = 'Something About Enchun Digital', subtitle = "Something About Enchun Digital",
content = '恩群數位是由一群年輕果斷、敢冒險的年輕人聚在一起,共同為台灣在地經營努力不懈的商家老闆們建立品牌的知名度。而商家的經營本身就不是一件容易的事情,早在恩群成立之前,我們便一直聚焦在與不同行業的老闆們建立起信賴可靠的關係,從一家又一家的合作關係當中,聚集了在行銷領域裡的頂尖好手,培育了許多優秀行銷顧問。讓每個辛苦經營的商家老闆可以獲得最佳的服務,讓每次的行銷需求可以透過有效的互動與聆聽,達到彼此心目中的預期目標。數字的確會說話,但是每一個有溫度的服務才是在恩群裡最重視的地方。', content = "恩群數位是由一群年輕果斷、敢冒險的年輕人聚在一起,共同為台灣在地經營努力不懈的商家老闆們建立品牌的知名度。而商家的經營本身就不是一件容易的事情,早在恩群成立之前,我們便一直聚焦在與不同行業的老闆們建立起信賴可靠的關係,從一家又一家的合作關係當中,聚集了在行銷領域裡的頂尖好手,培育了許多優秀行銷顧問。讓每個辛苦經營的商家老闆可以獲得最佳的服務,讓每次的行銷需求可以透過有效的互動與聆聽,達到彼此心目中的預期目標。數字的確會說話,但是每一個有溫度的服務才是在恩群裡最重視的地方。",
} = Astro.props } = Astro.props;
import SectionHeader from "../components/SectionHeader.astro";
--- ---
<section class="section-story" aria-labelledby="story-heading"> <section
<div class="container w-container"> 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 --> <!-- Section Header -->
<div class="section_header_w_line"> <SectionHeader
<div class="divider_line"></div> title="恩群數位的故事"
<div class="header_subtitle"> subtitle="Something About Enchun Digital"
<h2 id="story-heading" class="header_subtitle_head">{title}</h2> sectionBg="bg-white"
<p class="header_subtitle_paragraph">{subtitle}</p> />
</div>
<div class="divider_line"></div>
</div>
<!-- Story Content --> <!-- 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> </div>
</section> </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 其他行銷公司 對比表格 * 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[] = [ const otherCompanyItems = [
{ "缺乏經驗",
feature: '服務範圍', "沒有成效保證",
enchun: '全方位數位行銷服務,從策略到執行一條龍', "售後無服務",
others: '單一服務項目,缺乏整合性', "沒有策略",
}, "不了解客戶需求",
{ "沒有接受客戶反饋",
feature: '數據分析', ];
enchun: '專業數據分析團隊,精準追蹤 ROI',
others: '基礎報告,缺乏深度分析', const enchunItems = [
}, "實際執行經驗豐富",
{ "實際成效",
feature: '在地化經驗', "售後服務架構完善",
enchun: '深耕台灣市場,了解本地消費者習性', "行銷策略有方",
others: '通用策略,缺乏在地化調整', "熟悉客戶需求",
}, "最多客戶回饋",
{ ];
feature: '客戶服務',
enchun: '一對一專人服務,快速響應',
others: '標準化流程,回應較慢',
},
{
feature: '價格透明',
enchun: '明確報價,無隱藏費用',
others: '複雜收費結構,容易超支',
},
]
--- ---
<section class="section-comparison" aria-labelledby="comparison-heading"> <section
<div class="w-container"> 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 --> <!-- Section Header -->
<div class="section-header-w-line"> <SectionHeader
<h2 id="comparison-heading" class="header-subtitle-head"> title="恩群與其他公司有什麼不同"
為什麼選擇恩群數位 subtitle="What make us different from others"
</h2> />
<div class="divider-line"></div>
<!-- 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>
<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">
{
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>
))
}
</ul>
</div>
<!-- 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> </div>
<!-- Comparison Table --> <!-- CTA Button -->
<div class="comparison-table-wrapper"> <div class="mt-12 flex justify-center">
<table class="comparison-table"> <a
<thead> href="https://heyform.itslouis.cc/form/7mYtUNjA"
<tr> target="_blank"
<th class="th-feature">比較項目</th> 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"
<th class="th-enchun"> >
<span class="enchun-badge">恩群數位</span> <div class="tracking-wide">跟行銷顧問聊聊</div>
</th> </a>
<th class="th-others">其他行銷公司</th>
</tr>
</thead>
<tbody>
{
comparisonItems.map((item, index) => (
<tr class={index % 2 === 0 ? 'row-even' : 'row-odd'}>
<td class="td-feature">{item.feature}</td>
<td class="td-enchun">
<span class="enchun-icon">✓</span>
{item.enchun}
</td>
<td class="td-others">{item.others}</td>
</tr>
))
}
</tbody>
</table>
</div>
<!-- CTA Note -->
<div class="comparison-note">
<p class="note-text">
選擇恩群數位,讓您的品牌在數位時代脫穎而出!
</p>
</div> </div>
</div> </div>
</section> </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 { interface SlideImage {
src: string src: string;
alt: string alt: string;
} }
interface Props { interface Props {
slides?: SlideImage[] slides?: SlideImage[];
} }
const defaultSlides: SlideImage[] = [ const defaultSlides: SlideImage[] = [
{ src: '/placeholder-environment-1.jpg', alt: '恩群環境照片 1' }, {
{ src: '/placeholder-environment-2.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.jpg",
{ src: '/placeholder-environment-3.jpg', alt: '恩群環境照片 3' }, alt: "恩群環境照片 1",
{ src: '/placeholder-environment-4.jpg', alt: '恩群環境照片 4' }, },
{ src: '/placeholder-environment-5.jpg', alt: '恩群環境照片 5' }, {
{ src: '/placeholder-environment-6.jpg', alt: '恩群環境照片 6' }, 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",
{ src: '/placeholder-environment-7.jpg', alt: '恩群環境照片 7' }, alt: "恩群環境照片 2",
{ 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-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="工作環境照片"> <section
<div class="container spacer8 w-container"> 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 --> <!-- Section Header -->
<div class="section_header_w_line"> <SectionHeader
<div class="divider_line"></div> title="在恩群工作的環境"
<div class="header_subtitle"> subtitle="Working Enviroment"
<h2 class="header_subtitle_head">在恩群工作的環境</h2> sectionBg="bg-white"
<p class="header_subtitle_paragraph">Working Enviroment</p> />
</div>
<div class="divider_line"></div>
</div>
<!-- Environment Slider --> <!-- Environment Slider -->
<div <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)}" id="env-slider-{Math.random().toString(36).slice(2, 8)}"
> >
<!-- Slides Container --> <!-- 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) => ( 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 <img
src={slide.src} src={slide.src}
alt={slide.alt} alt={slide.alt}
loading={index === 0 ? 'eager' : 'lazy'} loading={index === 0 ? "eager" : "lazy"}
width="800" width="800"
height="450" height="450"
class="w-full h-full object-cover"
/> />
</div> </div>
)) ))
@@ -63,23 +95,33 @@ const slides = Astro.props.slides || defaultSlides
</div> </div>
<!-- Arrow Navigation --> <!-- 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"> <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> </svg>
</button> </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"> <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> </svg>
</button> </button>
<!-- Dot Navigation --> <!-- Dot Navigation -->
<div class="slider-dots"> <div class="slider-dots flex justify-center gap-2 mt-4">
{ {
slides.map((_, index) => ( slides.map((_, index) => (
<button <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} data-index={index}
aria-label={`顯示第 ${index + 1} 張照片`} aria-label={`顯示第 ${index + 1} 張照片`}
/> />
@@ -90,353 +132,183 @@ const slides = Astro.props.slides || defaultSlides
</div> </div>
</section> </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> <script>
// Environment Slider functionality // Environment Slider functionality
function initEnvironmentSlider() { function initEnvironmentSlider() {
const sliders = document.querySelectorAll('.environment-slider') const sliders = document.querySelectorAll(".environment-slider");
sliders.forEach((slider) => { sliders.forEach((slider) => {
const container = slider.querySelector('.slides-container') as HTMLElement const container = slider.querySelector(
const slides = slider.querySelectorAll('.environment-slide') ".slides-container",
const dots = slider.querySelectorAll('.slider-dot') ) as HTMLElement;
const prevBtn = slider.querySelector('.slider-arrow-left') as HTMLButtonElement const slides = slider.querySelectorAll(".environment-slide");
const nextBtn = slider.querySelector('.slider-arrow-right') as HTMLButtonElement 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 let currentIndex = 0;
const totalSlides = slides.length const totalSlides = slides.length;
let isDragging = false let isDragging = false;
let startPos = 0 let startPos = 0;
let currentTranslate = 0 let currentTranslate = 0;
let prevTranslate = 0 let prevTranslate = 0;
let animationID: number let animationID: number;
// Update slider position // Update slider position
const updateSlider = () => { const updateSlider = () => {
container.scrollTo({ container.scrollTo({
left: currentIndex * container.offsetWidth, left: currentIndex * container.offsetWidth,
behavior: 'smooth' behavior: "smooth",
}) });
updateDots() updateDots();
} };
// Update dots // Update dots
const updateDots = () => { const updateDots = () => {
dots.forEach((dot, index) => { dots.forEach((dot, index) => {
if (index === currentIndex) { 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 { } 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 // Go to specific slide
const goToSlide = (index: number) => { const goToSlide = (index: number) => {
if (index < 0) index = totalSlides - 1 if (index < 0) index = totalSlides - 1;
if (index >= totalSlides) index = 0 if (index >= totalSlides) index = 0;
currentIndex = index currentIndex = index;
updateSlider() updateSlider();
} };
// Previous slide // Previous slide
prevBtn?.addEventListener('click', () => goToSlide(currentIndex - 1)) prevBtn?.addEventListener("click", () => goToSlide(currentIndex - 1));
// Next slide // Next slide
nextBtn?.addEventListener('click', () => goToSlide(currentIndex + 1)) nextBtn?.addEventListener("click", () => goToSlide(currentIndex + 1));
// Dot navigation // Dot navigation
dots.forEach((dot, index) => { dots.forEach((dot, index) => {
dot.addEventListener('click', () => goToSlide(index)) dot.addEventListener("click", () => goToSlide(index));
}) });
// Scroll snap detection // Scroll snap detection
container.addEventListener('scroll', () => { container.addEventListener("scroll", () => {
const slideIndex = Math.round(container.scrollLeft / container.offsetWidth) const slideIndex = Math.round(
container.scrollLeft / container.offsetWidth,
);
if (slideIndex !== currentIndex) { if (slideIndex !== currentIndex) {
currentIndex = slideIndex currentIndex = slideIndex;
updateDots() updateDots();
} }
}) });
// Touch/swipe support // Touch/swipe support
const touchStart = (_index: number) => { const touchStart = (_index: number) => {
return function(event: TouchEvent) { return function (event: TouchEvent) {
isDragging = true isDragging = true;
startPos = event.touches[0].clientX startPos = event.touches[0].clientX;
animationID = requestAnimationFrame(animation) animationID = requestAnimationFrame(animation);
container.style.cursor = 'grabbing' container.style.cursor = "grabbing";
} };
} };
const touchEnd = () => { const touchEnd = () => {
isDragging = false isDragging = false;
cancelAnimationFrame(animationID) cancelAnimationFrame(animationID);
container.style.cursor = 'grab' container.style.cursor = "grab";
const movedBy = currentTranslate - prevTranslate const movedBy = currentTranslate - prevTranslate;
if (movedBy < -50 && currentIndex < totalSlides - 1) { if (movedBy < -50 && currentIndex < totalSlides - 1) {
currentIndex += 1 currentIndex += 1;
} else if (movedBy > 50 && currentIndex > 0) { } else if (movedBy > 50 && currentIndex > 0) {
currentIndex -= 1 currentIndex -= 1;
} }
goToSlide(currentIndex) goToSlide(currentIndex);
} };
const touchMove = (event: TouchEvent) => { const touchMove = (event: TouchEvent) => {
if (isDragging) { if (isDragging) {
const currentPosition = event.touches[0].clientX const currentPosition = event.touches[0].clientX;
currentTranslate = prevTranslate + currentPosition - startPos currentTranslate = prevTranslate + currentPosition - startPos;
} }
} };
const animation = () => { const animation = () => {
if (isDragging) requestAnimationFrame(animation) if (isDragging) requestAnimationFrame(animation);
} };
// Mouse events for desktop // Mouse events for desktop
let mouseStartPos = 0 let mouseStartPos = 0;
let isMouseDown = false let isMouseDown = false;
container.addEventListener('mousedown', (e: MouseEvent) => { container.addEventListener("mousedown", (e: MouseEvent) => {
isMouseDown = true isMouseDown = true;
mouseStartPos = e.clientX mouseStartPos = e.clientX;
container.style.cursor = 'grabbing' container.style.cursor = "grabbing";
}) });
container.addEventListener('mouseup', (e: MouseEvent) => { container.addEventListener("mouseup", (e: MouseEvent) => {
if (!isMouseDown) return if (!isMouseDown) return;
isMouseDown = false isMouseDown = false;
container.style.cursor = 'grab' container.style.cursor = "grab";
const movedBy = e.clientX - mouseStartPos const movedBy = e.clientX - mouseStartPos;
if (movedBy < -50 && currentIndex < totalSlides - 1) { if (movedBy < -50 && currentIndex < totalSlides - 1) {
currentIndex += 1 currentIndex += 1;
} else if (movedBy > 50 && currentIndex > 0) { } else if (movedBy > 50 && currentIndex > 0) {
currentIndex -= 1 currentIndex -= 1;
} }
goToSlide(currentIndex) goToSlide(currentIndex);
}) });
container.addEventListener('mouseleave', () => { container.addEventListener("mouseleave", () => {
isMouseDown = false isMouseDown = false;
container.style.cursor = 'grab' container.style.cursor = "grab";
}) });
// Touch events // Touch events
container.addEventListener('touchstart', touchStart(currentIndex)) container.addEventListener("touchstart", touchStart(currentIndex));
container.addEventListener('touchend', touchEnd) container.addEventListener("touchend", touchEnd);
container.addEventListener('touchmove', touchMove) container.addEventListener("touchmove", touchMove);
// Keyboard navigation // Keyboard navigation
slider.addEventListener('keydown', (e) => { slider.addEventListener("keydown", (e) => {
if (e instanceof KeyboardEvent) { if (e instanceof KeyboardEvent) {
if (e.key === 'ArrowLeft') goToSlide(currentIndex - 1) if (e.key === "ArrowLeft") goToSlide(currentIndex - 1);
if (e.key === 'ArrowRight') goToSlide(currentIndex + 1) if (e.key === "ArrowRight") goToSlide(currentIndex + 1);
} }
}) });
// Set initial state // Set initial state
container.style.cursor = 'grab' container.style.cursor = "grab";
}) });
} }
// Initialize when DOM is ready // Initialize when DOM is ready
document.addEventListener('DOMContentLoaded', initEnvironmentSlider) document.addEventListener("DOMContentLoaded", initEnvironmentSlider);
if (document.readyState !== 'loading') { if (document.readyState !== "loading") {
initEnvironmentSlider() initEnvironmentSlider();
} }
</script> </script>

View File

@@ -16,187 +16,60 @@ const {
// Four features data // Four features data
const features = [ 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: '在地化優先', 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: '高投資轉換率', title: '高投資轉換率',
description: '你覺得網路行銷很貴嗎?恩群數位善用每一分廣告預算,讓你在網路上發揮最大效益,幫助店家鎖定精準客群,達成目標。', 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: '數據優先', title: '數據優先',
description: '想要精準行銷?恩群數位從數據中萃取洞察,根據數據分析廣告成效,更聰明、有策略的幫您省下行銷預算。', 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: '關係優於銷售', title: '關係優於銷售',
description: '除了幫您拓展網路上的知名度,我們更是每家公司最專業的數位夥伴,你會知道有恩群的存在,事業路上你並不孤單。', description: '除了幫您拓展網路上的知名度,我們更是每家公司最專業的數位夥伴,你會知道有恩群的存在,事業路上你並不孤單。',
}, },
] ]
import SectionHeader from '../components/SectionHeader.astro'
--- ---
<section class="section_feature" aria-labelledby="feature-heading"> <section class="section_feature bg-white px-4 py-16 md:px-5 md:py-20" aria-labelledby="feature-heading">
<div class="w-container"> <div class="w-container mx-auto max-w-4xl">
<!-- Section Header --> <!-- Section Header -->
<div class="section_header_w_line"> <SectionHeader
<h2 id="feature-heading" class="header_subtitle_head"> title="恩群服務特色"
{title} subtitle="Why you can trust us"
</h2> sectionBg="bg-white"
<p class="header_subtitle_paragraph"> />
{subtitle}
</p>
<div class="divider_line"></div>
</div>
<!-- Features Grid --> <!-- 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) => ( 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">
<!-- Icon --> <div class="flex flex-col items-center gap-4 text-center md:flex-row md:items-start md:text-left lg:gap-6">
<div class="feature_image"> <!-- Icon -->
<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="font-['Noto_Sans_TC'] text-2xl font-semibold text-(--color-dark-blue) lg:text-3xl">{feature.title}</h3>
<!-- Description -->
<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>
<!-- Title -->
<h3 class="feature_head">{feature.title}</h3>
<!-- Description -->
<p class="feature_description">{feature.description}</p>
</div> </div>
)) ))
} }
</div> </div>
</div> </div>
</section> </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', id: 'burning',
title: '廣告行銷像燒錢', title: '廣告行銷像燒錢',
icon: '💸', img: '💸',
description: '廣告預算投入很多,但看不到實際效果?感覺像在燒錢?', description: '廣告預算投入很多,但看不到實際效果?感覺像在燒錢?',
}, },
] ]

View File

@@ -4,14 +4,23 @@
* Pixel-perfect implementation based on Webflow design * Pixel-perfect implementation based on Webflow design
*/ */
import { sanitizeSvg } from '../lib/sanitize'
import { PLACEHOLDER_IMAGE } from '../lib/api/marketing-solution'
interface ServicesListItem { interface ServicesListItem {
id: string id: string
title: string title: string
description: string description: string
category: string category: string
iconType?: 'preset' | 'svg' | 'upload'
icon?: string icon?: string
iconSvg?: string
iconImage?: {
url?: string
alt?: string
}
isHot?: boolean isHot?: boolean
image?: string image?: string | { url?: string; alt?: string }
link?: string link?: string
} }
@@ -29,345 +38,197 @@ const defaultServices: ServicesListItem[] = [
id: 'social-media', id: 'social-media',
title: '社群經營代操', title: '社群經營代操',
category: '海洋專案', category: '海洋專案',
iconType: 'preset',
icon: 'facebook', icon: 'facebook',
isHot: true, isHot: false,
image: '/placeholder-service-1.jpg', 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 等主流平台,幫助品牌建立強大的社群影響力。', description: '專業社群媒體經營團隊,從內容策劃、社群經營到數據分析,提供一站式社群代操服務。我們擅長經營 Facebook、Instagram 等主流平台,幫助品牌建立強大的社群影響力。',
}, },
{ {
id: 'google-business', id: 'google-business',
title: 'Google 商家關鍵字', title: 'Google 商家關鍵字',
category: 'Google', category: 'Google',
iconType: 'preset',
icon: 'google', icon: 'google',
isHot: true, isHot: false,
image: '/placeholder-service-2.jpg', 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 地圖和搜尋結果中脫穎而出。', description: '優化 Google 商家列表,提升在地搜尋排名。透過關鍵字策略、評論管理和商家資訊優化,讓您的商家在 Google 地圖和搜尋結果中脫穎而出。',
}, },
{ {
id: 'google-ads', id: 'google-ads',
title: 'Google Ads 關鍵字', title: 'Google Ads 關鍵字',
category: 'Google', category: 'Google',
iconType: 'preset',
icon: 'ads', icon: 'ads',
isHot: false, isHot: false,
image: '/placeholder-service-3.jpg', image: 'https://enchun-cms.anlstudio.cc/api/media/file/61f24aa108528b3898942cde_Google%20ADS.svg',
description: '專業的 Google Ads 投放服務,從關鍵字研究、廣告文案撰寫到出價策略優化,精準觸達目標受眾,最大化廣告投資報酬率。', description: '專業的 Google Ads 投放服務,從關鍵字研究、廣告文案撰寫到出價策略優化,精準觸達目標受眾,最大化廣告投資報酬率。',
}, },
{ {
id: 'news-media', id: 'news-media',
title: '網路新聞媒體', title: '網路新聞媒體',
category: '媒體行銷', category: '媒體行銷',
iconType: 'preset',
icon: 'news', icon: 'news',
isHot: false, 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: '與各大新聞媒體合作,提供新聞發佈、媒體採訪、品牌曝光等服務。透過專業的新聞行銷策略,提升品牌知名度和公信力。', description: '與各大新聞媒體合作,提供新聞發佈、媒體採訪、品牌曝光等服務。透過專業的新聞行銷策略,提升品牌知名度和公信力。',
}, },
{ {
id: 'influencer', id: 'influencer',
title: '網紅行銷專案', title: '網紅行銷專案',
category: '口碑行銷', category: '口碑行銷',
iconType: 'preset',
icon: 'youtube', icon: 'youtube',
isHot: true, isHot: false,
image: '/placeholder-service-5.jpg', 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打造影響者行銷活動。從網紅篩選、活動策劃到內容製作提供完整的網紅行銷解決方案快速建立品牌口碑。', description: '連結品牌與網紅/KOL打造影響者行銷活動。從網紅篩選、活動策劃到內容製作提供完整的網紅行銷解決方案快速建立品牌口碑。',
}, },
{ {
id: 'forum', id: 'forum',
title: '論壇行銷專案', title: '論壇行銷專案',
category: '口碑行銷', category: '口碑行銷',
iconType: 'preset',
icon: 'forum', icon: 'forum',
isHot: false, 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 等平台。透過專業的論壇行銷策略,建立品牌口碑,提升用戶信任度和互動參與度。', description: '深耕各大論壇社群,包括 Dcard、PTT 等平台。透過專業的論壇行銷策略,建立品牌口碑,提升用戶信任度和互動參與度。',
}, },
{ {
id: 'website-design', id: 'website-design',
title: '形象網站設計', title: '形象網站設計',
category: '品牌行銷', category: '品牌行銷',
iconType: 'preset',
icon: 'web', icon: 'web',
isHot: false, 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 設計到前端開發,提供完整的網站解決方案。', description: '現代化響應式網站設計服務,結合美學與功能,打造獨特的品牌形象。從 UI/UX 設計到前端開發,提供完整的網站解決方案。',
}, },
{ {
id: 'brand-video', id: 'brand-video',
title: '品牌形象影片', title: '品牌形象影片',
category: '品牌行銷', category: '品牌行銷',
iconType: 'preset',
icon: 'video', icon: 'video',
isHot: false, 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: '專業影片製作團隊,從腳本創作、拍攝到後製剪輯,打造高品質的品牌形象影片。透過視覺故事說,傳達品牌核心價值。', description: '專業影片製作團隊,從腳本創作、拍攝到後製剪輯,打造高品質的品牌形象影片。透過視覺故事說,傳達品牌核心價值。',
}, },
] ]
const servicesList = services && services.length > 0 ? services : defaultServices const servicesList = services && services.length > 0 ? services : defaultServices
// Icon SVG components // Helper to get image URL from string or object
const icons = { const getImageUrl = (image: ServicesListItem['image']) => {
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>`, if (!image) return PLACEHOLDER_IMAGE
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>`, return typeof image === 'string' ? image : image.url || PLACEHOLDER_IMAGE
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>`, // Preset icon SVG components
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>`, const presetIcons: Record<string, string> = {
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>`, 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>`,
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>`, 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"> <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="services-container"> <div class="max-w-3xl mx-auto flex flex-col gap-6">
{ {
servicesList.map((service, index) => ( servicesList.map((service, index) => {
<a const isOdd = index % 2 === 0
href={service.link || '#'} const iconHtml = renderIcon(service)
class={`service-item ${index % 2 === 0 ? 'odd' : 'even'}`} return (
aria-labelledby={`service-${service.id}-title`} <a
> href={service.link || '#'}
<!-- Content Side --> class={`
<div class="service-item-content"> grid grid-cols-1 md:grid-cols-2 gap-10 md:gap-6 items-center mb-[60px] md:mb-10 relative
<!-- Category Tag --> transition-transform duration-300 hover:-translate-y-[2px]
<span class="service-category-tag"> no-underline
{service.category} group
</span> `}
aria-labelledby={`service-${service.id}-title`}
>
<!-- Content Side -->
<div class={`service-item-content order-2 ${isOdd ? 'md:order-1' : 'md:order-2'}`}>
<!-- Category Tag with Icon -->
<div class="flex 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 --> <!-- Title -->
<h2 <h2
id={`service-${service.id}-title`} 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} {service.title}
</h2> </h2>
<!-- Divider --> <!-- Divider -->
<div class="service-divider"></div> <div class="w-fill h-[1px] bg-(--color-enchunblue) mb-4"></div>
<!-- Description --> <!-- 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} {service.description}
</p> </p>
<!-- Icon (if provided) -->
{
service.icon && icons[service.icon as keyof typeof icons] && (
<div class="service-icon" set:html={icons[service.icon as keyof typeof icons]} />
)
}
</div>
<!-- Image Side -->
<div class="service-item-image">
<div class="image-wrapper">
<img
src={service.image || '/placeholder-service.jpg'}
alt={service.title}
loading="lazy"
/>
</div> </div>
<!-- Hot Badge --> <!-- Image Side -->
{ <div class={`service-item-image order-1 relative ${isOdd ? 'md:order-2' : 'md:order-1'}`}>
service.isHot && ( <div class="aspect-[4/3] rounded-[12px] overflow-hidden bg-[var(--color-white)]">
<span class="service-hot-badge">HOT</span> <img
) src={getImageUrl(service.image)}
} alt={service.title}
</div> loading="lazy"
</a> class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-[1.05]"
)) />
</div>
<!-- Hot Badge -->
{
service.isHot && (
<span class="absolute top-4 right-4 md:top-3 md:right-3 bg-[var(--color-notification-red)] text-white px-[12px] py-[6px] md:px-[10px] md:py-[4px] rounded-[20px] text-[0.75rem] md:text-[0.7rem] font-bold uppercase">
HOT
</span>
)
}
</div>
</a>
)
})
} }
</div> </div>
</section> </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 * SolutionsHero - Hero section for Solutions/Services page
* Pixel-perfect implementation based on Webflow design * Pixel-perfect implementation based on Webflow design
* Supports optional background image from CMS
* Styled with Tailwind CSS
*/ */
interface Props { interface Props {
title?: string title?: string
subtitle?: string subtitle?: string
backgroundImage?: {
url?: string
alt?: string
}
} }
const { const {
title = '行銷解決方案', title = '行銷解決方案',
subtitle = '提供全方位的數位行銷服務,協助您的品牌在數位時代脫穎而出', subtitle = '提供全方位的數位行銷服務,協助您的品牌在數位時代脫穎而出',
backgroundImage,
} = Astro.props } = Astro.props
// Determine if we have a background image
const hasBackgroundImage = backgroundImage?.url
const bgImageUrl = backgroundImage?.url || ''
--- ---
<section class="hero-overlay-solution"> <section
<div class="max-w-6xl mx-auto"> 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 --> <!-- 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} {title}
</h1> </h1>
<!-- Subtitle --> <!-- 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} {subtitle}
</p> </p>
</div> </div>
</section> </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 * Pixel-perfect implementation based on Webflow design
*/ */
interface Props { interface Props {
title?: string title?: string;
subtitle?: string subtitle?: string;
backgroundImage?: {
url?: string;
alt?: string;
};
} }
const { const {
title = '恩群大本營', title = "恩群大本營",
subtitle = 'Team members of Enchun', subtitle = "Team members of Enchun",
} = Astro.props backgroundImage,
} = Astro.props;
// Determine if we have a background image
const hasBackgroundImage = backgroundImage?.url;
const bgImageUrl = backgroundImage?.url || "";
--- ---
<header class="hero-overlay-team"> <section
<div class="centered-container w-container"> class:list={[
<div class="div-block"> // Base styles - relative positioning for overlay
<h1 class="hero_title_head-team">{title}</h1> "relative",
<p class="hero_sub_paragraph-team">{subtitle}</p> "flex",
</div> "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> </section>
<style>
/* Teams Hero Styles - Pixel-perfect from Webflow */
.hero-overlay-team {
background-color: var(--color-dark-blue);
padding: 120px 20px 80px;
text-align: center;
}
.centered-container {
max-width: 1200px;
margin: 0 auto;
}
.w-container {
max-width: 1200px;
margin: 0 auto;
}
.div-block {
display: flex;
flex-direction: column;
align-items: center;
}
.hero_title_head-team {
color: #ffffff;
font-family: "Noto Sans TC", sans-serif;
font-weight: 700;
font-size: 3.39em;
line-height: 1.2;
margin-bottom: 16px;
}
.hero_sub_paragraph-team {
color: var(--color-gray-100);
font-family: "Quicksand", "Noto Sans TC", sans-serif;
font-weight: 400;
font-size: 1.5em;
line-height: 1.2;
}
/* Responsive Adjustments */
@media (max-width: 991px) {
.hero-overlay-team {
padding: 80px 20px 60px;
}
.hero_title_head-team {
font-size: 2.45em;
}
.hero_sub_paragraph-team {
font-size: 1.15em;
}
}
@media (max-width: 767px) {
.hero-overlay-team {
padding: 60px 16px 40px;
}
.hero_title_head-team {
font-size: 7vw;
}
.hero_sub_paragraph-team {
font-size: 3.4vw;
}
}
</style>

View File

@@ -16,84 +16,142 @@
============================================ */ ============================================ */
/* Primary Colors (主要色) */ /* Primary Colors (主要色) */
--color-primary: #3898ec; /* Primary blue */ --color-primary: #3898ec;
--color-primary-dark: #0082f3; /* Deep blue */ /* Primary blue */
--color-primary-light: #67aee1; /* Light blue */ --color-primary-dark: #0082f3;
--color-primary-hover: #2895f7; /* Hover blue */ /* Deep blue */
--color-primary-light: #67aee1;
/* Light blue */
--color-primary-hover: #2895f7;
/* Hover blue */
/* Secondary Colors (次要色) */ /* Secondary Colors (次要色) */
--color-secondary: #f39c12; /* Secondary orange */ --color-secondary: #f39c12;
--color-secondary-light: #f6c456; /* Light pink */ /* Secondary orange */
--color-secondary-dark: #d84038; /* Deep orange */ --color-secondary-light: #f6c456;
/* Light pink */
--color-secondary-dark: #d84038;
/* Deep orange */
/* Accent Colors (強調色) */ /* Accent Colors (強調色) */
--color-accent: #d84038; /* Accent red-orange */ --color-accent: #d84038;
--color-accent-light: #f6c456; /* Light pink */ /* Accent red-orange */
--color-accent-dark: #ea384c; /* Deep red */ --color-accent-light: #f6c456;
--color-accent-pink: #bb8282; /* Pink */ /* Light pink */
--color-accent-rose: #c48383; /* Rose */ --color-accent-dark: #ea384c;
/* Deep red */
--color-accent-pink: #bb8282;
/* Pink */
--color-accent-rose: #c48383;
/* Rose */
/* Link Colors (連結色) */ /* Link Colors (連結色) */
--color-link: #3083bf; /* Default link */ --color-link: #3083bf;
--color-link-hover: #23608c; /* Link hover */ /* Default link */
--color-link-hover: #23608c;
/* Link hover */
/* Neutral Colors (中性色 - Tailwind Slate mapping) */ /* Neutral Colors (中性色 - Tailwind Slate mapping) */
--color-white: #ffffff; /* White */ --color-white: #ffffff;
--color-black: #000000; /* Black */ /* White */
--color-gray-50: #fafafa; /* Surface lightest */ --color-black: #000000;
--color-gray-100: #f5f5f5; /* Surface light */ /* Black */
--color-gray-200: #f3f3f3; /* Surface */ --color-gray-50: #fafafa;
--color-gray-300: #eeeeee; /* Border light */ /* Surface lightest */
--color-gray-400: #dddddd; /* Border default */ --color-gray-100: #f5f5f5;
--color-gray-500: #c8c8c8; /* Mid gray */ /* Surface light */
--color-gray-600: #999999; /* Text muted */ --color-gray-200: #f3f3f3;
--color-gray-700: #828282; /* Text dark */ /* Surface */
--color-gray-800: #758696; /* Dark gray 1 */ --color-gray-300: #eeeeee;
--color-gray-900: #5d6c7b; /* Dark gray 2 */ /* Border light */
--color-gray-950: #4f4f4f; /* Darkest gray */ --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 (語意化顏色) */ /* Semantic Colors (語意化顏色) */
--color-text-primary: #333333; /* Primary text */ --color-text-primary: #333333;
--color-text-secondary: #222222; /* Secondary text */ /* Primary text */
--color-text-muted: #999999; /* Muted text */ --color-text-secondary: #222222;
--color-text-light: #758696; /* Light text on dark */ /* Secondary text */
--color-text-inverse: #ffffff; /* Inverse (white) */ --color-text-muted: #999999;
/* Muted text */
--color-text-light: #758696;
/* Light text on dark */
--color-text-inverse: #ffffff;
/* Inverse (white) */
/* Backgrounds (背景色) */ /* Backgrounds (背景色) */
--color-background: #f2f2f2; /* Main background - Grey 6 from Webflow */ --color-background: #f2f2f2;
--color-surface: #fafafa; /* Surface background */ /* Main background - Grey 6 from Webflow */
--color-surface2: #f3f3f3; /* Surface elevated */ --color-surface: #fafafa;
--color-surface-dark: #2226; /* Dark background */ /* Surface background */
--color-surface2: #f3f3f3;
/* Surface elevated */
--color-surface-dark: #2226;
/* Dark background */
/* Borders (邊框色) */ /* Borders (邊框色) */
--color-border: #e2e8f0; /* Default border */ --color-border: #e2e8f0;
--color-border-light: #dddddd; /* Light border */ /* Default border */
--color-border-light: #dddddd;
/* Light border */
/* Category Colors (文章分類) */ /* Category Colors (文章分類) */
--color-category-google: #67aee1; /* Google小學堂 */ --color-category-google: #67aee1;
--color-category-meta: #8974de; /* Meta小學堂 */ /* Google小學堂 */
--color-category-news: #3083bf; /* 行銷時事最前線 */ --color-category-meta: #8974de;
--color-category-enchun: #3898ec; /* 恩群數位 */ /* Meta小學堂 */
--color-category-news: #3083bf;
/* 行銷時事最前線 */
--color-category-enchun: #3898ec;
/* 恩群數位 */
/* Badge Colors (標籤) */ /* Badge Colors (標籤) */
--color-badge-hot: #ea384c; /* Hot 標籤 (紅) */ --color-badge-hot: #ea384c;
--color-badge-new: #67aee1; /* New 標籤 (淡藍) */ /* Hot 標籤 () */
--color-badge-new: #67aee1;
/* New 標籤 (淡藍) */
/* Webflow Colors - Story 1-4 Global Layout (Verified against original) */ /* Webflow Colors - Story 1-4 Global Layout (Verified against original) */
--color-enchunblue: #23608c; /* Enchun Blue - 品牌/主色 */ --color-enchunblue: #23608c;
--color-enchunblue-dark: #3083bf; /* Enchun Blue Dark - 品牌/主色深色 */ /* Enchun Blue - 品牌/主色 */
--color-tropical-blue: #c7e4fa; /* Tropical Blue - 頁腳背景 (verified: rgb(199, 228, 250)) */ --color-enchunblue-dark: #3083bf;
--color-st-tropaz: #5d7285; /* St. Tropaz - 頁腳文字 */ /* Enchun Blue Dark - 品牌/主色深色 */
--color-amber: #f6c456; /* Amber - CTA/強調 */ --color-tropical-blue: #c7e4fa;
--color-tarawera: #2d3748; /* Tarawera - 深色文字 */ /* Tropical Blue - 頁腳背景 (verified: rgb(199, 228, 250)) */
--color-nav-link: var(--color-gray-200); /* Navigation Link - 使用灰色系 */ --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 */ /* Webflow Additional Colors - Story 1-5 Homepage */
--color-notification-red: #d84038; /* Notification Red - CTA Button */ --color-notification-red: #d84038;
--color-dark-blue: #062841; /* Dark Blue - Headings */ /* Notification Red - CTA Button */
--color-medium-blue: #67aee1; /* Medium Blue - Accents */ --color-dark-blue: #062841;
--color-grey5: #e0e0e0; /* Grey 5 - Borders */ /* Dark Blue - Headings */
--color-grey6: #f2f2f2; /* Grey 6 - Backgrounds */ --color-medium-blue: #67aee1;
/* Medium Blue - Accents */
--color-grey5: #e0e0e0;
/* Grey 5 - Borders */
--color-grey6: #f2f2f2;
/* Grey 6 - Backgrounds */
/* ============================================ /* ============================================
🔤 TYPOGRAPHY - From Webflow 🔤 TYPOGRAPHY - From Webflow
@@ -101,19 +159,30 @@
--font-family-sans: "Noto Sans TC", "Quicksand", Arial, sans-serif; --font-family-sans: "Noto Sans TC", "Quicksand", Arial, sans-serif;
--font-family-heading: "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 - Based on Tailwind scale
============================================ */ ============================================ */
--spacing-xs: 0.25rem; /* 4px */ --spacing-xs: 0.25rem;
--spacing-sm: 0.5rem; /* 8px */ /* 4px */
--spacing-md: 1rem; /* 16px */ --spacing-sm: 0.5rem;
--spacing-lg: 1.5rem; /* 24px */ /* 8px */
--spacing-xl: 2rem; /* 32px */ --spacing-md: 1rem;
--spacing-2xl: 3rem; /* 48px */ /* 16px */
--spacing-3xl: 4rem; /* 64px */ --spacing-lg: 1.5rem;
/* 24px */
--spacing-xl: 2rem;
/* 32px */
--spacing-2xl: 3rem;
/* 48px */
--spacing-3xl: 4rem;
/* 64px */
/* Container */ /* Container */
--container-max-width: 1200px; --container-max-width: 1200px;
@@ -124,14 +193,22 @@
🔲 BORDER RADIUS 🔲 BORDER RADIUS
============================================ */ ============================================ */
--radius-sm: 0.125rem; /* 2px */ --radius-sm: 0.125rem;
--radius: 0.375rem; /* 6px (DEFAULT) */ /* 2px */
--radius-md: 0.5rem; /* 8px */ --radius: 0.375rem;
--radius-lg: 0.75rem; /* 12px */ /* 6px (DEFAULT) */
--radius-xl: 1rem; /* 16px */ --radius-md: 0.5rem;
--radius-2xl: 1.5rem; /* 24px */ /* 8px */
--radius-3xl: 2rem; /* 32px */ --radius-lg: 0.75rem;
--radius-full: 9999px; /* Full circle */ /* 12px */
--radius-xl: 1rem;
/* 16px */
--radius-2xl: 1.5rem;
/* 24px */
--radius-3xl: 2rem;
/* 32px */
--radius-full: 9999px;
/* Full circle */
/* ============================================ /* ============================================
💫 SHADOWS 💫 SHADOWS
@@ -174,9 +251,12 @@
Small (≤479px): 13px Small (≤479px): 13px
*/ */
--html-font-size-desktop: 19px; --html-font-size-desktop: 19px;
--html-font-size-tablet: 19px; /* 991px breakpoint */ --html-font-size-tablet: 19px;
--html-font-size-mobile: 16px; /* 767px breakpoint */ /* 991px breakpoint */
--html-font-size-small: 13px; /* 479px breakpoint */ --html-font-size-mobile: 16px;
/* 767px breakpoint */
--html-font-size-small: 13px;
/* 479px breakpoint */
} }
/* ============================================ /* ============================================
@@ -248,11 +328,9 @@ body {
/* Text Gradient */ /* Text Gradient */
.text-gradient { .text-gradient {
background: linear-gradient( background: linear-gradient(135deg,
135deg, var(--color-primary),
var(--color-primary), var(--color-accent));
var(--color-accent)
);
-webkit-background-clip: text; -webkit-background-clip: text;
-webkit-text-fill-color: transparent; -webkit-text-fill-color: transparent;
background-clip: text; background-clip: text;
@@ -271,6 +349,7 @@ body {
from { from {
opacity: 0; opacity: 0;
} }
to { to {
opacity: 1; opacity: 1;
} }
@@ -281,6 +360,7 @@ body {
opacity: 0; opacity: 0;
transform: translateY(1rem); transform: translateY(1rem);
} }
to { to {
opacity: 1; opacity: 1;
transform: translateY(0); transform: translateY(0);
@@ -353,7 +433,7 @@ body {
left: 0; left: 0;
width: 0; width: 0;
height: 2px; height: 2px;
background: var(--color-primary); background: var(--color-secondary);
transition: width var(--transition-fast); transition: width var(--transition-fast);
} }
@@ -361,7 +441,11 @@ body {
width: 100%; width: 100%;
} }
/* Active Navigation Link */ /* Active Navigation Link — position: relative ensures ::after stays within the link */
.nav-active {
position: relative;
}
.nav-active::after { .nav-active::after {
content: ""; content: "";
position: absolute; position: absolute;
@@ -398,9 +482,11 @@ body {
.prose-custom h1 { .prose-custom h1 {
font-size: 2.25rem; font-size: 2.25rem;
} }
.prose-custom h2 { .prose-custom h2 {
font-size: 1.875rem; font-size: 1.875rem;
} }
.prose-custom h3 { .prose-custom h3 {
font-size: 1.5rem; font-size: 1.5rem;
} }

View File

@@ -1,20 +1,33 @@
{ {
"$schema": "./node_modules/wrangler/config-schema.json", "$schema": "./node_modules/wrangler/config-schema.json",
"name": "enchun-frontend", "name": "website-enchun-mgr",
"main": "./dist/_worker.js/index.js", "main": "./dist/_worker.js/index.js",
"compatibility_date": "2025-01-19", "compatibility_date": "2025-01-19",
"compatibility_flags": [ "compatibility_flags": [
"nodejs_compat" "nodejs_compat"
], ],
"assets": { "assets": {
"directory": "./dist" "directory": "./dist",
"binding": "ASSETS"
}, },
"kv_namespaces": [
{
"binding": "SESSION",
"id": "0de94ad20be24add94c1f8fe9c4a0440"
}
],
"vars": { "vars": {
"PAYLOAD_CMS_URL": "https://enchun-admin.anlstudio.cc" "PAYLOAD_CMS_URL": "https://enchun-admin.anlstudio.cc"
}, },
"env": { "env": {
"production": { "production": {
"name": "enchun-frontend-production", "name": "website-enchun-mgr-production",
"kv_namespaces": [
{
"binding": "SESSION",
"id": "0de94ad20be24add94c1f8fe9c4a0440"
}
],
"vars": { "vars": {
"PAYLOAD_CMS_URL": "https://enchun-admin.anlstudio.cc" "PAYLOAD_CMS_URL": "https://enchun-admin.anlstudio.cc"
} }

View File

@@ -1,9 +0,0 @@
name = "enchun-frontend"
compatibility_date = "2024-01-01"
pages_build_output_dir = "dist"
[vars]
PAYLOAD_CMS_URL = "https://enchun-admin.anlstudio.cc"
[env.production.vars]
PAYLOAD_CMS_URL = "https://enchun-admin.anlstudio.cc"

4379
bun.lock Normal file

File diff suppressed because it is too large Load Diff

13
bunfig.toml Normal file
View File

@@ -0,0 +1,13 @@
# Bunfig - Bun 套件優化配置
# https://bun.sh/docs/runtime/bunfig
[install]
# 使用精確版本,避免意外升級
exact = true
# 生產環境使用 lockfile
frozenLockfile = true
[install.lockfile]
# 保存 lockfile
save = true

View File

@@ -1,10 +1,10 @@
{ {
"name": "enchun-monorepo", "name": "enchun-monorepo",
"private": true, "private": true,
"packageManager": "pnpm@10.17.0", "packageManager": "bun@1.3.3",
"scripts": { "scripts": {
"dev": "turbo run dev --parallel", "dev": "turbo run dev --parallel",
"dev:stop": "echo 'Stopping dev servers...' && pkill -f 'astro.js dev' && pkill -f 'next dev' && pkill -f 'pnpm dev' && echo 'Dev servers stopped' || echo 'No dev servers were running'", "dev:stop": "echo 'Stopping dev servers...' && pkill -f 'astro dev' && pkill -f 'next dev' && echo 'Dev servers stopped' || echo 'No dev servers were running'",
"build": "turbo run build", "build": "turbo run build",
"lint": "turbo run lint", "lint": "turbo run lint",
"test": "turbo run test", "test": "turbo run test",
@@ -17,11 +17,9 @@
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"turbo": "^2.0.5" "turbo": "^2.0.5"
}, },
"pnpm": { "workspaces": [
"onlyBuiltDependencies": [ "apps/frontend",
"sharp", "apps/backend",
"esbuild", "packages/*"
"unrs-resolver" ]
]
}
} }

View File

@@ -1,22 +0,0 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
devDependencies:
tailwindcss:
specifier: ^4.1.14
version: 4.1.14
packages:
tailwindcss@4.1.14:
resolution: {integrity: sha512-b7pCxjGO98LnxVkKjaZSDeNuljC4ueKUddjENJOADtubtdo8llTaJy7HwBMeLNSSo2N5QIAgklslK1+Ir8r6CA==}
snapshots:
tailwindcss@4.1.14: {}

16304
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +0,0 @@
packages:
- "apps/frontend"
- "apps/backend"
- packages/*