feat(frontend): update pages, components and branding
Refresh Astro frontend implementation including new pages (Portfolio, Teams, Services), components, and styling updates.
This commit is contained in:
52
apps/frontend/Dockerfile
Normal file
52
apps/frontend/Dockerfile
Normal file
@@ -0,0 +1,52 @@
|
||||
# Use Node.js 18 Alpine for smaller image size
|
||||
FROM node:18-alpine AS base
|
||||
|
||||
# Install pnpm globally
|
||||
RUN npm install -g pnpm@10.17.0
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
|
||||
# Install dependencies
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
RUN pnpm build
|
||||
|
||||
# Production stage
|
||||
FROM node:18-alpine AS production
|
||||
|
||||
# Install pnpm globally
|
||||
RUN npm install -g pnpm@10.17.0
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
|
||||
# Install only production dependencies
|
||||
RUN pnpm install --frozen-lockfile --prod
|
||||
|
||||
# Copy built application from base stage
|
||||
COPY --from=base /app/dist ./dist
|
||||
COPY --from=base /app/src ./src
|
||||
COPY --from=base /app/astro.config.mjs ./
|
||||
COPY --from=base /app/tailwind.config.mjs ./
|
||||
|
||||
# Expose port 4321 (Astro default)
|
||||
EXPOSE 4321
|
||||
|
||||
# Set environment variables
|
||||
ENV NODE_ENV=production
|
||||
ENV HOST=0.0.0.0
|
||||
ENV PORT=4321
|
||||
|
||||
# Start the application
|
||||
CMD ["pnpm", "preview"]
|
||||
@@ -2,6 +2,7 @@
|
||||
import { defineConfig } from "astro/config";
|
||||
import cloudflare from "@astrojs/cloudflare";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import path from "node:path";
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
@@ -15,6 +16,11 @@ export default defineConfig({
|
||||
}),
|
||||
vite: {
|
||||
plugins: [tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve('./src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# Local development environment variables
|
||||
# Cloudflare Workers local development environment variables
|
||||
# This file is used by wrangler for local development
|
||||
PAYLOAD_CMS_URL=https://enchun-admin.anlstudio.cc
|
||||
|
||||
# Production Payload CMS URL (for SSR fetch)
|
||||
PAYLOAD_CMS_URL=https://enchun-admin.anlstudio.cc
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "^12.6.12",
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
"agentation": "^2.1.1",
|
||||
"agentation-mcp": "^1.1.0",
|
||||
"astro": "6.0.0-beta.1",
|
||||
"better-auth": "^1.3.13"
|
||||
},
|
||||
|
||||
@@ -1,97 +1,157 @@
|
||||
---
|
||||
import { Image } from 'astro:assets';
|
||||
// Footer component with client-side data fetching
|
||||
import { Image } from "astro:assets";
|
||||
// Footer component with server-side data fetching
|
||||
|
||||
// Use local backend in development, production URL from dev.vars/wrangler
|
||||
const isDev = import.meta.env.DEV;
|
||||
const PAYLOAD_CMS_URL = isDev
|
||||
? "http://localhost:3000" // Local backend in dev (port may vary)
|
||||
: import.meta.env.PAYLOAD_CMS_URL || "https://enchun-admin.anlstudio.cc";
|
||||
|
||||
// Fetch footer data from Payload CMS server-side
|
||||
let footerNavItems: any[] = [];
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${PAYLOAD_CMS_URL}/api/globals/footer?depth=2&draft=false&locale=undefined&trash=false`,
|
||||
);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
footerNavItems = data?.navItems || data || [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Footer SSR] Failed to fetch footer:", error);
|
||||
}
|
||||
|
||||
// Fetch categories from Payload CMS server-side
|
||||
let categories: any[] = [];
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${PAYLOAD_CMS_URL}/api/categories?sort=order&limit=6&depth=0&draft=false`,
|
||||
);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
categories = (data?.docs || [])
|
||||
.sort((a: any, b: any) => (a.order || 0) - (b.order || 0))
|
||||
.slice(0, 6);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Footer SSR] Failed to fetch categories:", error);
|
||||
}
|
||||
|
||||
// Get current year for copyright
|
||||
const currentYear = new Date().getFullYear();
|
||||
---
|
||||
|
||||
<footer class="bg-[var(--color-tropical-blue)] py-10 mt-auto">
|
||||
<div class="max-w-5xl mx-auto px-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-8 mb-8">
|
||||
<div class="col-span-2">
|
||||
<Image src="/enchun-logo.svg"
|
||||
alt="Enchun Digital Logo" class="h-auto w-32 mb-4"
|
||||
width={919}
|
||||
height={201}
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
/>
|
||||
<p class="text-[var(--color-st-tropaz)] text-sm font-light leading-relaxed">恩群數位累積多年廣告行銷操作經驗,擁有全方位行銷人才,讓我們可以為客戶精準的規劃每一分廣告預算,讓你的品牌深入人心。更重要的是恩群的存在,為了成為每家公司最佳數位夥伴,作為彼此最堅強的後盾,你會知道有我們的陪伴 你並不孤單。</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-bold text-[var(--color-st-tropaz)] mb-4">聯絡我們</h3>
|
||||
<a href="https://www.facebook.com/EnChun-Taiwan-100979265112420" target="_blank" class="flex items-center mb-2">
|
||||
<Image src="/fb-icon.svg"
|
||||
alt="Phone Icon" class="h-auto w-6 mb-2"
|
||||
width={16}
|
||||
height={16}
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
/>
|
||||
</a>
|
||||
<p class="text-[var(--color-st-tropaz)] mb-2">諮詢電話:<br> 02 5570 0527</p>
|
||||
<a href="mailto:enchuntaiwan@gmail.com" class="text-primary hover:text-secondary transition-colors">enchuntaiwan@gmail.com</a>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-bold text-[var(--color-st-tropaz)] mb-4">行銷方案</h3>
|
||||
<ul class="space-y-2" id="marketing-solutions">
|
||||
<li><span class="text-gray-500">載入中...</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-bold text-[var(--color-st-tropaz)] mb-4">行銷放大鏡</h3>
|
||||
<ul class="space-y-2" id="marketing-articles">
|
||||
<li><span class="text-gray-500">載入中...</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
<footer class="bg-[var(--color-tropical-blue)] py-10 mt-auto relative">
|
||||
<div class="max-w-5xl mx-auto px-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-8 mb-16">
|
||||
<div class="col-span-2">
|
||||
<Image
|
||||
src="/enchun-logo.svg"
|
||||
alt="Enchun Digital Logo"
|
||||
class="h-auto w-32 mb-4"
|
||||
width={919}
|
||||
height={201}
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
/>
|
||||
<p
|
||||
class="text-[var(--color-st-tropaz)] text-sm font-light leading-relaxed"
|
||||
>
|
||||
恩群數位累積多年廣告行銷操作經驗,擁有全方位行銷人才,讓我們可以為客戶精準的規劃每一分廣告預算,讓你的品牌深入人心。更重要的是恩群的存在,為了成為每家公司最佳數位夥伴,作為彼此最堅強的後盾,你會知道有我們的陪伴
|
||||
你並不孤單。
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3
|
||||
class="text-lg font-bold text-[var(--color-st-tropaz)] mb-4"
|
||||
>
|
||||
聯絡我們
|
||||
</h3>
|
||||
<a
|
||||
href="https://www.facebook.com/EnChun-Taiwan-100979265112420"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center mb-2"
|
||||
>
|
||||
<Image
|
||||
src="/fb-icon.svg"
|
||||
alt="Facebook Icon"
|
||||
class="h-auto w-6 mb-2"
|
||||
width={16}
|
||||
height={16}
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
/>
|
||||
</a>
|
||||
<p class="text-[var(--color-st-tropaz)] mb-2">
|
||||
諮詢電話:<br /> 02 5570 0527
|
||||
</p>
|
||||
<a
|
||||
href="mailto:enchuntaiwan@gmail.com"
|
||||
class="text-primary hover:text-secondary transition-colors"
|
||||
>enchuntaiwan@gmail.com</a
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<h3
|
||||
class="text-lg font-bold text-[var(--color-st-tropaz)] mb-4"
|
||||
>
|
||||
行銷方案
|
||||
</h3>
|
||||
<ul class="space-y-2" id="marketing-solutions">
|
||||
{footerNavItems.length > 0 && footerNavItems[0]?.childNavItems
|
||||
? footerNavItems[0].childNavItems.map((item: any) => (
|
||||
<li>
|
||||
<a href={item.link?.url || "#"} class="font-normal text-[var(--color-st-tropaz)] hover:text-[var(--color-dove-gray)] transition-colors">
|
||||
{item.link?.label || "連結"}
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
: <li><span class="text-gray-500">載入中...</span></li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3
|
||||
class="text-lg font-bold text-[var(--color-st-tropaz)] mb-4"
|
||||
>
|
||||
行銷放大鏡
|
||||
</h3>
|
||||
<ul class="space-y-2" id="marketing-articles">
|
||||
{categories.length > 0
|
||||
? categories.map((cat: any) => (
|
||||
<li>
|
||||
<a href={`/blog/category/${cat.slug}`}
|
||||
class="font-normal text-[var(--color-st-tropaz)] hover:text-[var(--color-dove-gray)] transition-colors"
|
||||
title={cat.nameEn || cat.title}>
|
||||
{cat.title}
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
: <li><span class="text-gray-500">暫無分類</span></li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="absolute inset-x-0 w-screen bg-[var(--color-amber)] py-3 text-center -left-4"
|
||||
>
|
||||
<p class="text-[var(--color-tarawera)]">
|
||||
copyright © Enchun digital 2018 - {currentYear}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute inset-x-0 w-screen bg-[var(--color-amber)] py-3 text-center">
|
||||
<p class="text-[var(--color-tarawera)]">copyright © Enchun digital 2018 - {new Date().getFullYear()}</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
// Client-side data fetching for footer
|
||||
interface LinkItem {
|
||||
link?: {
|
||||
url?: string;
|
||||
label?: string;
|
||||
};
|
||||
}
|
||||
|
||||
async function loadFooterData() {
|
||||
try {
|
||||
console.log('Fetching footer data...');
|
||||
const response = await fetch('/api/globals/footer?depth=2&draft=false&locale=undefined&trash=false');
|
||||
const data = await response.json();
|
||||
console.log('Footer data loaded:', data);
|
||||
|
||||
// Update marketing solutions
|
||||
const marketingUl = document.getElementById('marketing-solutions');
|
||||
if (marketingUl && data.navItems?.[0]?.childNavItems) {
|
||||
const links = data.navItems[0].childNavItems.map((item: LinkItem) =>
|
||||
`<li><a href="${item.link?.url || '#'}" class="font-normal text-[var(--color-st-tropaz)] hover:text-[var(--color-dove-gray)] transition-colors">${item.link?.label}</a></li>`
|
||||
).join('');
|
||||
marketingUl.innerHTML = links;
|
||||
}
|
||||
|
||||
// Update marketing articles (行銷放大鏡)
|
||||
const articlesUl = document.getElementById('marketing-articles');
|
||||
if (articlesUl && data.navItems?.[1]?.childNavItems) {
|
||||
const links = data.navItems[1].childNavItems.map((item: LinkItem) =>
|
||||
`<li><a href="${item.link?.url || '#'}" class="font-normal text-[var(--color-st-tropaz)] hover:text-[var(--color-dove-gray)] transition-colors">${item.link?.label}</a></li>`
|
||||
).join('');
|
||||
articlesUl.innerHTML = links;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load footer data:', error);
|
||||
<style>
|
||||
/* Footer specific styles */
|
||||
footer a {
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease-in-out;
|
||||
}
|
||||
}
|
||||
|
||||
// Load footer data when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', loadFooterData);
|
||||
} else {
|
||||
loadFooterData();
|
||||
}
|
||||
</script>
|
||||
|
||||
footer a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,18 +1,82 @@
|
||||
---
|
||||
import { Image } from "astro:assets";
|
||||
// Header component
|
||||
// Header component with scroll-based background and enhanced mobile animations
|
||||
|
||||
// Use local backend in development, production URL from dev.vars/wrangler
|
||||
const isDev = import.meta.env.DEV;
|
||||
const PAYLOAD_CMS_URL = isDev
|
||||
? "http://localhost:3000" // Local backend in dev (port may vary)
|
||||
: import.meta.env.PAYLOAD_CMS_URL || "https://enchun-admin.anlstudio.cc";
|
||||
|
||||
// Fetch navigation data from Payload CMS server-side
|
||||
let navItems: any[] = [];
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${PAYLOAD_CMS_URL}/api/globals/header?depth=2&draft=false&locale=undefined&trash=false`,
|
||||
);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
navItems = data?.navItems || data || [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Header SSR] Failed to fetch navigation:", error);
|
||||
}
|
||||
|
||||
// Helper to get link URL
|
||||
function getLinkUrl(link: any): string {
|
||||
if (!link) return "#";
|
||||
|
||||
if (link.type === "custom" && link.url) {
|
||||
return link.url;
|
||||
}
|
||||
|
||||
if (link.type === "reference" && link.reference?.value) {
|
||||
if (typeof link.reference.value === "string") {
|
||||
// It's an ID, construct URL based on relationTo
|
||||
return `/${link.reference.relationTo || "pages"}/${link.reference.value}`;
|
||||
} else if (link.reference.value.slug) {
|
||||
// It's a populated object with slug
|
||||
return `/${link.reference.value.slug}`;
|
||||
}
|
||||
}
|
||||
|
||||
return "#";
|
||||
}
|
||||
|
||||
// Check if label should have a badge
|
||||
function getBadgeForLabel(label: string): string {
|
||||
if (label.includes("行銷方案")) {
|
||||
return `<span class="absolute -top-2 -right-3 bg-red-500 text-white text-[6px] font-bold px-1.5 py-0.5 rounded-full uppercase tracking-wider shadow-sm animate-pulse">Hot</span>`;
|
||||
}
|
||||
if (label.includes("行銷放大鏡")) {
|
||||
return `<span class="absolute -top-2 -right-3 bg-red-500 text-white text-[6px] font-bold px-1.5 py-0.5 rounded-full uppercase tracking-wider shadow-sm">New</span>`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
// Check if link is active
|
||||
function isLinkActive(url: string): boolean {
|
||||
const currentPath = Astro.url.pathname;
|
||||
return currentPath === url || (url === "/" && currentPath === "/");
|
||||
}
|
||||
---
|
||||
|
||||
<header class="sticky top-0 z-50 bg-transparent">
|
||||
<nav class="max-w-5xl mx-auto px-4 py-4">
|
||||
<header
|
||||
class="fixed top-0 left-0 right-0 z-50 transition-transform duration-300 ease-in-out"
|
||||
id="main-header"
|
||||
>
|
||||
<nav
|
||||
class="max-w-5xl mx-auto px-4 py-4 transition-all duration-300 ease-in-out"
|
||||
id="main-nav"
|
||||
>
|
||||
<ul class="flex items-center justify-between list-none">
|
||||
<li class="flex-shrink-0">
|
||||
<li class="shrink-0">
|
||||
<a href="/" class="block">
|
||||
<!-- Uses Astro's optimized Image component for the site logo -->
|
||||
<Image
|
||||
src="/enchun-logo.svg"
|
||||
alt="Enchun Digital Marketing"
|
||||
class="w-32 h-auto"
|
||||
class="w-32 h-auto transition-opacity duration-300 drop-shadow-md"
|
||||
width={919}
|
||||
height={201}
|
||||
loading="eager"
|
||||
@@ -20,152 +84,430 @@ import { Image } from "astro:assets";
|
||||
/>
|
||||
</a>
|
||||
</li>
|
||||
<li class="hidden md:flex items-center space-x-6" id="desktop-nav">
|
||||
<!-- Navigation items will be populated by JavaScript -->
|
||||
<li class="hidden md:flex items-center space-x-6">
|
||||
{
|
||||
navItems.map((item) => {
|
||||
const link = item.link;
|
||||
const href = getLinkUrl(link);
|
||||
const label = link.label || "未命名";
|
||||
const badge = getBadgeForLabel(label);
|
||||
const activeClass = isLinkActive(href)
|
||||
? "nav-active"
|
||||
: "";
|
||||
const hasBadge = badge ? "relative inline-block" : "";
|
||||
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
class={`${hasBadge} text-base font-medium transition-all duration-200 px-3 py-2 rounded ${activeClass} hover:bg-white/10`}
|
||||
{...(link.newTab && {
|
||||
target: "_blank",
|
||||
rel: "noopener noreferrer",
|
||||
})}
|
||||
>
|
||||
{label}
|
||||
{badge && <Fragment set:html={badge} />}
|
||||
</a>
|
||||
);
|
||||
})
|
||||
}
|
||||
</li>
|
||||
<!-- Mobile menu button -->
|
||||
<!-- Mobile menu button with animated hamburger/X icon -->
|
||||
<li class="md:hidden">
|
||||
<button
|
||||
class="text-[var(--color-enchunblue)] hover:text-[var(--color-enchunblue)]/80"
|
||||
class="text-(--color-enchunblue) hover:text-(--color-enchunblue)/80 focus:outline-none relative w-10 h-10 flex items-center justify-center"
|
||||
id="mobile-menu-button"
|
||||
aria-label="Toggle mobile menu"
|
||||
aria-expanded="false"
|
||||
>
|
||||
<span class="sr-only text-(--color-enchunblue-dark)"
|
||||
>Toggle menu</span
|
||||
>
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
width="28"
|
||||
height="28"
|
||||
viewBox="0 0 36 36"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-6 h-6"
|
||||
class="w-7 h-7 hamburger-icon transition-transform duration-300"
|
||||
id="hamburger-icon"
|
||||
>
|
||||
<path
|
||||
d="M6 27H22.5C23.325 27 24 26.325 24 25.5C24 24.675 23.325 24 22.5 24H6C5.175 24 4.5 24.675 4.5 25.5C4.5 26.325 5.175 27 6 27ZM6 19.5H18C18.825 19.5 19.5 18.825 19.5 18C19.5 17.175 18.825 16.5 18 16.5H6C5.175 16.5 4.5 17.175 4.5 18C4.5 18.825 5.175 19.5 6 19.5ZM4.5 10.5C4.5 11.325 5.175 12 6 12H22.5C23.325 12 24 11.325 24 10.5C24 9.675 23.325 9 22.5 9H6C5.175 9 4.5 9.675 4.5 10.5ZM30.45 22.32L26.13 18L30.45 13.68C30.5889 13.5411 30.699 13.3763 30.7742 13.1948C30.8493 13.0134 30.888 12.8189 30.888 12.6225C30.888 12.4261 30.8493 12.2316 30.7742 12.0502C30.699 11.8687 30.5889 11.7039 30.45 11.565C30.3111 11.4261 30.1463 11.316 29.9648 11.2408C29.7834 11.1657 29.5889 11.127 29.3925 11.127C29.1961 11.127 29.0016 11.1657 28.8202 11.2408C28.6387 11.316 28.4739 11.4261 28.335 11.565L22.95 16.95C22.8109 17.0888 22.7006 17.2536 22.6254 17.4351C22.5501 17.6165 22.5113 17.811 22.5113 18.0075C22.5113 18.204 22.5501 18.3985 22.6254 18.5799C22.7006 18.7614 22.8109 18.9262 22.95 19.065L28.335 24.45C28.92 25.035 29.865 25.035 30.45 24.45C31.02 23.865 31.035 22.905 30.45 22.32V22.32Z"
|
||||
fill="currentColor"
|
||||
class="transition-opacity duration-300"></path>
|
||||
</svg>
|
||||
<!-- X icon for close state -->
|
||||
<svg
|
||||
width="28"
|
||||
height="28"
|
||||
viewBox="0 0 36 36"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-7 h-7 close-icon absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 opacity-0 transition-opacity duration-300 pointer-events-none"
|
||||
id="close-icon"
|
||||
>
|
||||
<path
|
||||
d="M28.5 9.6L26.4 7.5L18 15.9L9.6 7.5L7.5 9.6L15.9 18L7.5 26.4L9.6 28.5L18 20.1L26.4 28.5L28.5 26.4L20.1 18L28.5 9.6Z"
|
||||
fill="currentColor"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<!-- Mobile menu -->
|
||||
<div class="md:hidden hidden" id="mobile-menu">
|
||||
<ul class="pt-4 pb-2 space-y-2" id="mobile-nav">
|
||||
<!-- Mobile navigation items will be populated by JavaScript -->
|
||||
<!-- Mobile menu with full-screen overlay -->
|
||||
<div
|
||||
class="md:hidden fixed left-0 right-0 top-20 h-[calc(100vh-80px)] h-[calc(100dvh-80px)] bg-white/20 backdrop-blur-lg opacity-0 invisible transition-all duration-300 ease-in-out"
|
||||
id="mobile-menu"
|
||||
>
|
||||
<ul
|
||||
class="flex flex-col items-center justify-center h-full space-y-6 px-4"
|
||||
id="mobile-nav"
|
||||
>
|
||||
{
|
||||
navItems.map((item, index) => {
|
||||
const link = item.link;
|
||||
const href = getLinkUrl(link);
|
||||
const label = link.label || "未命名";
|
||||
|
||||
return (
|
||||
<li
|
||||
class={`opacity-0`}
|
||||
style={`animation-delay: ${index * 50}ms`}
|
||||
>
|
||||
<a
|
||||
href={href}
|
||||
class="text-2xl text-grey-700 text-shadow-sm font-medium transition-all duration-200 transform hover:scale-105"
|
||||
{...(link.newTab && {
|
||||
target: "_blank",
|
||||
rel: "noopener noreferrer",
|
||||
})}
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<script>
|
||||
interface NavItem {
|
||||
link: {
|
||||
type: "reference" | "custom";
|
||||
label: string;
|
||||
url?: string;
|
||||
reference?: {
|
||||
slug: string;
|
||||
};
|
||||
newTab?: boolean;
|
||||
};
|
||||
}
|
||||
// Scroll-based header with smooth hide/show animation
|
||||
let lastScrollY = 0;
|
||||
let ticking = false;
|
||||
let isHeaderHidden = false;
|
||||
|
||||
// Fetch navigation data from Payload CMS
|
||||
async function fetchNavigation() {
|
||||
try {
|
||||
// Use local proxy in development to avoid CORS issues
|
||||
const apiUrl = `/api/globals/header?depth=2&draft=false&locale=undefined&trash=false`;
|
||||
function initScrollEffect() {
|
||||
const header = document.getElementById("main-header");
|
||||
const nav = document.getElementById("main-nav");
|
||||
const mobileMenu = document.getElementById("mobile-menu");
|
||||
if (!header || !nav) return;
|
||||
|
||||
const response = await fetch(apiUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
const handleScroll = () => {
|
||||
const scrollY = window.scrollY;
|
||||
const scrollDelta = scrollY - lastScrollY;
|
||||
const scrollDirection = scrollDelta > 0 ? "down" : "up";
|
||||
|
||||
// Only update lastScrollY if there's meaningful movement
|
||||
if (Math.abs(scrollDelta) > 1) {
|
||||
lastScrollY = scrollY;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.navItems || [];
|
||||
} catch (error) {
|
||||
console.error("Error fetching navigation:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate navigation link HTML
|
||||
function createNavLink(item: NavItem) {
|
||||
const { link } = item;
|
||||
let href = "";
|
||||
|
||||
if (link.type === "custom" && link.url) {
|
||||
href = link.url;
|
||||
} else if (link.type === "reference" && link.reference?.slug) {
|
||||
href = `/${link.reference.slug}`;
|
||||
}
|
||||
|
||||
const target = link.newTab
|
||||
? ' target="_blank" rel="noopener noreferrer"'
|
||||
: "";
|
||||
const label = link.label;
|
||||
|
||||
// Check if current page matches this link
|
||||
const currentPath = window.location.pathname;
|
||||
const isActive =
|
||||
currentPath === href || (href === "/" && currentPath === "/");
|
||||
|
||||
// Add badges for specific items (positioned in top right corner)
|
||||
let badge = "";
|
||||
if (label.includes("行銷方案")) {
|
||||
badge =
|
||||
'<span class="absolute -top-1 -right-1 bg-red-500 text-white text-[0.5rem] px-1 py-0.5 rounded-full">hot</span>';
|
||||
} else if (label.includes("行銷放大鏡")) {
|
||||
badge =
|
||||
'<span class="absolute -top-1 -right-1 bg-red-500 text-white text-[0.5rem] px-1 py-0.5 rounded-full">new</span>';
|
||||
}
|
||||
|
||||
const containerClass = badge ? "relative inline-block" : "";
|
||||
const activeClass = isActive ? " nav-active" : "";
|
||||
|
||||
return `<a href="${href}" class="${containerClass} text-lg font-normal text-shadow-md hover:text-primary transition-colors px-3 py-2${activeClass}"${target}>${label}${badge}</a>`;
|
||||
}
|
||||
|
||||
// Populate navigation
|
||||
async function populateNavigation() {
|
||||
const navItems = await fetchNavigation();
|
||||
|
||||
const desktopNav = document.getElementById("desktop-nav");
|
||||
const mobileNav = document.getElementById("mobile-nav");
|
||||
|
||||
if (desktopNav && mobileNav) {
|
||||
// Clear existing content
|
||||
desktopNav.innerHTML = "";
|
||||
mobileNav.innerHTML = "";
|
||||
|
||||
// Populate desktop navigation
|
||||
navItems.forEach((item: NavItem) => {
|
||||
const linkHtml = createNavLink(item);
|
||||
const li = document.createElement("li");
|
||||
li.innerHTML = linkHtml;
|
||||
desktopNav.appendChild(li);
|
||||
});
|
||||
|
||||
// Populate mobile navigation
|
||||
navItems.forEach((item: NavItem) => {
|
||||
const linkHtml = createNavLink(item)
|
||||
.replace("px-3 py-2", "block px-3 py-2")
|
||||
.replace(
|
||||
"relative inline-block",
|
||||
"relative inline-block block",
|
||||
// Header shrinks and gets background on scroll
|
||||
if (scrollY > 10) {
|
||||
if (!header.classList.contains("header-scrolled")) {
|
||||
header.classList.add(
|
||||
"bg-white/15",
|
||||
"backdrop-blur-xl",
|
||||
"shadow-md",
|
||||
"header-scrolled",
|
||||
);
|
||||
const li = document.createElement("li");
|
||||
li.innerHTML = linkHtml;
|
||||
mobileNav.appendChild(li);
|
||||
header.classList.remove("bg-transparent");
|
||||
nav.classList.add("py-2");
|
||||
nav.classList.remove("py-4");
|
||||
// Adjust mobile menu top position and height when header shrinks
|
||||
if (mobileMenu) {
|
||||
mobileMenu.classList.remove(
|
||||
"top-20",
|
||||
"h-[calc(100vh-80px)]",
|
||||
"h-[calc(100dvh-80px)]",
|
||||
);
|
||||
mobileMenu.classList.add(
|
||||
"top-16",
|
||||
"h-[calc(100vh-64px)]",
|
||||
"h-[calc(100dvh-64px)]",
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (header.classList.contains("header-scrolled")) {
|
||||
header.classList.remove(
|
||||
"bg-white/15",
|
||||
"bg-white/90",
|
||||
"backdrop-blur-xl",
|
||||
"shadow-md",
|
||||
"border-b",
|
||||
"border-gray-200/50",
|
||||
"header-scrolled",
|
||||
);
|
||||
header.classList.add("bg-transparent");
|
||||
nav.classList.remove("py-2");
|
||||
nav.classList.add("py-4");
|
||||
// Reset mobile menu top position and height when header expands
|
||||
if (mobileMenu) {
|
||||
mobileMenu.classList.remove(
|
||||
"top-16",
|
||||
"h-[calc(100vh-64px)]",
|
||||
"h-[calc(100dvh-64px)]",
|
||||
);
|
||||
mobileMenu.classList.add(
|
||||
"top-20",
|
||||
"h-[calc(100vh-80px)]",
|
||||
"h-[calc(100dvh-80px)]",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hide/show header based on scroll direction - with threshold
|
||||
const hideThreshold = 150; // Higher threshold before hiding
|
||||
const showThreshold = 50; // Lower threshold to show (makes it feel smoother)
|
||||
|
||||
if (
|
||||
scrollY > hideThreshold &&
|
||||
scrollDirection === "down" &&
|
||||
!isHeaderHidden
|
||||
) {
|
||||
// Only hide if scrolling down consistently
|
||||
if (scrollDelta > 5) {
|
||||
// Must be scrolling down with some speed
|
||||
header.classList.remove("translate-in");
|
||||
header.classList.add("translate-out");
|
||||
isHeaderHidden = true;
|
||||
}
|
||||
} else if (scrollDirection === "up" && isHeaderHidden) {
|
||||
// Show immediately when scrolling up
|
||||
header.classList.remove("translate-out");
|
||||
header.classList.add("translate-in");
|
||||
isHeaderHidden = false;
|
||||
} else if (scrollY < showThreshold) {
|
||||
// Always show when near top
|
||||
header.classList.remove("translate-out");
|
||||
header.classList.add("translate-in");
|
||||
isHeaderHidden = false;
|
||||
}
|
||||
|
||||
ticking = false;
|
||||
};
|
||||
|
||||
window.addEventListener(
|
||||
"scroll",
|
||||
() => {
|
||||
if (!ticking) {
|
||||
window.requestAnimationFrame(() => {
|
||||
handleScroll();
|
||||
ticking = false;
|
||||
});
|
||||
ticking = true;
|
||||
}
|
||||
},
|
||||
{ passive: true },
|
||||
);
|
||||
|
||||
handleScroll();
|
||||
|
||||
// Animate mobile menu items on page load
|
||||
setTimeout(() => {
|
||||
const mobileItems = document.querySelectorAll("#mobile-nav li");
|
||||
mobileItems.forEach((item) => {
|
||||
item.classList.remove("opacity-0");
|
||||
item.classList.add(
|
||||
"opacity-100",
|
||||
"translate-y-0",
|
||||
"transition-all",
|
||||
"duration-300",
|
||||
"ease-out",
|
||||
);
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// Enhanced mobile menu toggle with animations
|
||||
let mobileMenuInitialized = false;
|
||||
|
||||
function initMobileMenu() {
|
||||
if (mobileMenuInitialized) return;
|
||||
|
||||
const button = document.getElementById("mobile-menu-button");
|
||||
const menu = document.getElementById("mobile-menu");
|
||||
const hamburgerIcon = document.getElementById("hamburger-icon");
|
||||
const closeIcon = document.getElementById("close-icon");
|
||||
const mobileNavItems = document
|
||||
.getElementById("mobile-nav")
|
||||
?.querySelectorAll("li");
|
||||
|
||||
if (!button || !menu || !hamburgerIcon || !closeIcon) {
|
||||
// Retry after a delay if elements aren't ready
|
||||
setTimeout(initMobileMenu, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
button.addEventListener("click", () => {
|
||||
const isMenuOpen = menu.classList.contains("opacity-100");
|
||||
|
||||
if (isMenuOpen) {
|
||||
menu.classList.remove("opacity-100", "visible");
|
||||
menu.classList.add("opacity-0", "invisible");
|
||||
hamburgerIcon.classList.remove("opacity-0", "rotate-90");
|
||||
hamburgerIcon.classList.add("opacity-100");
|
||||
closeIcon.classList.remove("opacity-100");
|
||||
closeIcon.classList.add("opacity-0");
|
||||
button.setAttribute("aria-expanded", "false");
|
||||
|
||||
mobileNavItems?.forEach((item) => {
|
||||
item.classList.remove("opacity-100", "translate-y-0");
|
||||
item.classList.add("opacity-0", "translate-y-4");
|
||||
});
|
||||
|
||||
document.body.style.overflow = "";
|
||||
} else {
|
||||
menu.classList.remove("opacity-0", "invisible");
|
||||
menu.classList.add("opacity-100", "visible");
|
||||
hamburgerIcon.classList.remove("opacity-100");
|
||||
hamburgerIcon.classList.add("opacity-0", "rotate-90");
|
||||
closeIcon.classList.remove("opacity-0");
|
||||
closeIcon.classList.add("opacity-100");
|
||||
button.setAttribute("aria-expanded", "true");
|
||||
|
||||
mobileNavItems?.forEach((item, index) => {
|
||||
setTimeout(() => {
|
||||
item.classList.remove("opacity-0", "translate-y-4");
|
||||
item.classList.add(
|
||||
"opacity-100",
|
||||
"translate-y-0",
|
||||
"transition-all",
|
||||
"duration-300",
|
||||
"ease-out",
|
||||
);
|
||||
}, index * 50);
|
||||
});
|
||||
|
||||
document.body.style.overflow = "hidden";
|
||||
}
|
||||
});
|
||||
|
||||
menu.addEventListener("click", (e) => {
|
||||
if (e.target === menu) {
|
||||
button.click();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && menu.classList.contains("opacity-100")) {
|
||||
button.click();
|
||||
}
|
||||
});
|
||||
|
||||
mobileMenuInitialized = true;
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initScrollEffect();
|
||||
initMobileMenu();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#main-header {
|
||||
transition:
|
||||
background-color 0.3s ease-in-out,
|
||||
backdrop-filter 0.3s ease-in-out,
|
||||
box-shadow 0.3s ease-in-out,
|
||||
transform 0.3s ease-in-out;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* Header translate animation - hide completely above viewport */
|
||||
#main-header.translate-out {
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
|
||||
#main-header.translate-in {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Nav padding transition for shrink effect */
|
||||
#main-nav {
|
||||
transition: padding 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
/* Logo shrinks when header is scrolled */
|
||||
#main-header.header-scrolled img {
|
||||
transform: scale(0.85);
|
||||
transition: transform 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
/* Navigation links */
|
||||
#main-nav a {
|
||||
color: var(--color-nav-link);
|
||||
position: relative;
|
||||
text-shadow: 1px 1px 3px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
#main-nav a:hover {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* Active state */
|
||||
.nav-active {
|
||||
color: var(--color-enchunblue) !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Badge positioning and styling */
|
||||
#main-nav a[class*="relative"] .absolute {
|
||||
top: -4px;
|
||||
right: -8px;
|
||||
}
|
||||
|
||||
/* Mobile menu styles */
|
||||
#mobile-menu {
|
||||
transition:
|
||||
opacity 0.3s ease-in-out,
|
||||
visibility 0.3s ease-in-out,
|
||||
top 0.3s ease-in-out;
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
#mobile-menu {
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
#mobile-nav a {
|
||||
padding: 0.75rem 1rem;
|
||||
min-height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--color-nav-link);
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
#mobile-nav a:hover {
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize navigation
|
||||
populateNavigation();
|
||||
|
||||
// Simple mobile menu toggle
|
||||
const button = document.getElementById("mobile-menu-button");
|
||||
const menu = document.getElementById("mobile-menu");
|
||||
if (button && menu) {
|
||||
button.addEventListener("click", () => {
|
||||
menu.classList.toggle("hidden");
|
||||
});
|
||||
/* Smooth transition for all nav elements */
|
||||
#main-nav a,
|
||||
#mobile-nav a {
|
||||
transition:
|
||||
color 0.2s ease-in-out,
|
||||
text-shadow 0.2s ease-in-out,
|
||||
background-color 0.2s ease-in-out;
|
||||
}
|
||||
</script>
|
||||
</style>
|
||||
|
||||
173
apps/frontend/src/components/PortfolioCard.astro
Normal file
173
apps/frontend/src/components/PortfolioCard.astro
Normal file
@@ -0,0 +1,173 @@
|
||||
---
|
||||
/**
|
||||
* PortfolioCard - Portfolio item card component
|
||||
* Pixel-perfect implementation based on Webflow design
|
||||
*/
|
||||
|
||||
interface PortfolioItem {
|
||||
slug: string
|
||||
title: string
|
||||
description: string
|
||||
image?: string
|
||||
tags?: string[]
|
||||
externalUrl?: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
item: PortfolioItem
|
||||
}
|
||||
|
||||
const { item } = Astro.props
|
||||
|
||||
const imageUrl = item.image || '/placeholder-portfolio.jpg'
|
||||
const title = item.title || 'Untitled'
|
||||
const description = item.description || ''
|
||||
const tags = item.tags || []
|
||||
const hasExternalLink = !!item.externalUrl
|
||||
const linkHref = hasExternalLink ? item.externalUrl : `/website-portfolio/${item.slug}`
|
||||
const linkTarget = hasExternalLink ? '_blank' : '_self'
|
||||
const linkRel = hasExternalLink ? 'noopener noreferrer' : ''
|
||||
---
|
||||
|
||||
<li class="portfolio-card-item">
|
||||
<a
|
||||
href={linkHref}
|
||||
target={linkTarget}
|
||||
rel={linkRel}
|
||||
class="portfolio-card"
|
||||
>
|
||||
<!-- Image Wrapper -->
|
||||
<div class="portfolio-image-wrapper">
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={title}
|
||||
class="portfolio-image"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
width="800"
|
||||
height="450"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="portfolio-content">
|
||||
<!-- Title -->
|
||||
<h3 class="portfolio-title">{title}</h3>
|
||||
|
||||
<!-- Description -->
|
||||
{
|
||||
description && (
|
||||
<p class="portfolio-description">{description}</p>
|
||||
)
|
||||
}
|
||||
|
||||
<!-- Tags -->
|
||||
{
|
||||
tags.length > 0 && (
|
||||
<div class="portfolio-tags">
|
||||
{tags.map((tag) => (
|
||||
<span class="portfolio-tag">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<style>
|
||||
/* Portfolio Card Styles - Pixel-perfect from Webflow */
|
||||
.portfolio-card-item {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.portfolio-card {
|
||||
background-color: white;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
transition: all 300ms ease-in-out;
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.portfolio-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
/* Image Wrapper */
|
||||
.portfolio-image-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding-bottom: 56.25%; /* 16:9 */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.portfolio-image {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 300ms ease-in-out;
|
||||
}
|
||||
|
||||
.portfolio-card:hover .portfolio-image {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
/* Content */
|
||||
.portfolio-content {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.portfolio-title {
|
||||
font-family: "Noto Sans TC", sans-serif;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-tarawera, #2d3748);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.portfolio-description {
|
||||
font-family: "Noto Sans TC", sans-serif;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-gray-600, #666666);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* Tags */
|
||||
.portfolio-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.portfolio-tag {
|
||||
padding: 4px 10px;
|
||||
background-color: var(--color-gray-100, #f2f2f2);
|
||||
border-radius: 16px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-gray-700, #4a5568);
|
||||
}
|
||||
|
||||
/* Responsive Adjustments */
|
||||
@media (max-width: 767px) {
|
||||
.portfolio-content {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.portfolio-title {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.portfolio-description {
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
89
apps/frontend/src/components/PortfolioPreviewCard.astro
Normal file
89
apps/frontend/src/components/PortfolioPreviewCard.astro
Normal file
@@ -0,0 +1,89 @@
|
||||
---
|
||||
/**
|
||||
* PortfolioPreviewCard - Portfolio item preview card
|
||||
*/
|
||||
import type { PortfolioItem } from '@/lib/api/home'
|
||||
|
||||
interface Props {
|
||||
item: PortfolioItem
|
||||
}
|
||||
|
||||
const { item } = Astro.props
|
||||
|
||||
const imageUrl = item.image?.url || '/placeholder-portfolio.jpg'
|
||||
const imageAlt = item.image?.alt || item.title || 'Portfolio item'
|
||||
const title = item.title || 'Untitled'
|
||||
const slug = item.slug || ''
|
||||
const description = item.description || ''
|
||||
const websiteType = item.websiteType || ''
|
||||
|
||||
// Website type labels
|
||||
const typeLabels: Record<string, string> = {
|
||||
corporate: '企業官網',
|
||||
ecommerce: '電商網站',
|
||||
landing: '活動頁面',
|
||||
brand: '品牌網站',
|
||||
other: '其他',
|
||||
}
|
||||
|
||||
const typeLabel = typeLabels[websiteType] || ''
|
||||
---
|
||||
|
||||
<li class="group">
|
||||
<a
|
||||
href={`/website-portfolio/${slug}`}
|
||||
class="block h-full"
|
||||
>
|
||||
<article class="bg-white rounded-lg overflow-hidden shadow-md hover:shadow-xl transition-all duration-300 h-full flex flex-col group-hover:-translate-y-1">
|
||||
<!-- Image -->
|
||||
<div class="aspect-video overflow-hidden bg-gray-100">
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={imageAlt}
|
||||
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="p-6 flex flex-col flex-grow">
|
||||
<!-- Type Badge -->
|
||||
{
|
||||
typeLabel && (
|
||||
<span class="inline-block px-3 py-1 text-xs font-medium bg-[var(--color-tropical-blue)] text-[var(--color-enchunblue)] rounded-full mb-3 w-fit">
|
||||
{typeLabel}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
<!-- Title -->
|
||||
<h3 class="text-xl font-semibold text-[var(--color-text-primary)] mb-2 group-hover:text-[var(--color-enchunblue)] transition-colors">
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
<!-- Description -->
|
||||
{
|
||||
description && (
|
||||
<p class="text-[var(--color-text-muted)] line-clamp-2 mb-4 flex-grow">
|
||||
{description}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
<!-- CTA -->
|
||||
<div class="flex items-center text-[var(--color-enchunblue)] font-medium mt-auto">
|
||||
<span>查看案例</span>
|
||||
<svg
|
||||
class="w-4 h-4 ml-1 group-hover:translate-x-1 transition-transform"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</a>
|
||||
</li>
|
||||
53
apps/frontend/src/components/ServiceFeatureCard.astro
Normal file
53
apps/frontend/src/components/ServiceFeatureCard.astro
Normal file
@@ -0,0 +1,53 @@
|
||||
---
|
||||
/**
|
||||
* ServiceFeatureCard - Individual service feature card component
|
||||
*/
|
||||
import type { ServiceFeature } from '@/lib/api/home'
|
||||
|
||||
interface Props {
|
||||
feature: ServiceFeature
|
||||
}
|
||||
|
||||
const { feature } = Astro.props
|
||||
|
||||
const icon = feature.icon || '🎯'
|
||||
const title = feature.title || '服務項目'
|
||||
const description = feature.description || ''
|
||||
const linkUrl = feature.link?.url
|
||||
---
|
||||
|
||||
<li class="group">
|
||||
<a
|
||||
href={linkUrl || '#'}
|
||||
class={`block h-full ${!linkUrl ? 'pointer-events-none' : ''}`}
|
||||
>
|
||||
<div class="bg-white p-8 rounded-lg shadow-md hover:shadow-xl transition-all duration-300 h-full flex flex-col group-hover:-translate-y-1">
|
||||
<!-- Icon -->
|
||||
<div class="text-5xl mb-4" aria-hidden="true">
|
||||
{icon}
|
||||
</div>
|
||||
|
||||
<!-- Title -->
|
||||
<h3 class="text-xl font-semibold text-[var(--color-enchunblue)] mb-4 group-hover:text-[var(--color-enchunblue-dark)] transition-colors">
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
<!-- Description -->
|
||||
<p class="text-[var(--color-text-secondary)] leading-relaxed flex-grow">
|
||||
{description}
|
||||
</p>
|
||||
|
||||
<!-- Arrow indicator (only if link exists) -->
|
||||
{
|
||||
linkUrl && (
|
||||
<div class="mt-4 flex items-center text-[var(--color-enchunblue)] font-medium opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<span>了解更多</span>
|
||||
<svg class="w-4 h-4 ml-1 group-hover:translate-x-1 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
215
apps/frontend/src/components/blog/ArticleCard.astro
Normal file
215
apps/frontend/src/components/blog/ArticleCard.astro
Normal file
@@ -0,0 +1,215 @@
|
||||
---
|
||||
/**
|
||||
* ArticleCard - Blog article card component
|
||||
* Displays featured image, title, excerpt, category badge, and published date
|
||||
*/
|
||||
|
||||
interface Category {
|
||||
id: string
|
||||
title: string
|
||||
slug: string
|
||||
backgroundColor?: string
|
||||
textColor?: string
|
||||
}
|
||||
|
||||
interface PostImage {
|
||||
url: string
|
||||
alt?: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
post: {
|
||||
id: string
|
||||
title: string
|
||||
slug: string
|
||||
heroImage?: PostImage | null
|
||||
excerpt?: string | null
|
||||
categories?: Category[] | null
|
||||
publishedAt?: string | null
|
||||
createdAt?: string | null
|
||||
}
|
||||
}
|
||||
|
||||
const { post } = Astro.props
|
||||
|
||||
// Format date in Traditional Chinese
|
||||
const formatDate = (dateStr: string | null | undefined) => {
|
||||
if (!dateStr) return ''
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleDateString('zh-TW', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
// Get primary category (first one)
|
||||
const category = post.categories?.[0]
|
||||
|
||||
// Generate image URL with fallback
|
||||
const imageUrl = post.heroImage?.url || '/placeholder-blog.jpg'
|
||||
const imageAlt = post.heroImage?.alt || post.title
|
||||
const displayDate = post.publishedAt || post.createdAt
|
||||
---
|
||||
|
||||
<a href={`/xing-xiao-fang-da-jing/${post.slug}`} class="article-card group">
|
||||
<div class="article-card-inner">
|
||||
<!-- Featured Image -->
|
||||
<div class="article-image-wrapper">
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={imageAlt}
|
||||
loading="lazy"
|
||||
class="article-image"
|
||||
width="800"
|
||||
height="450"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Card Content -->
|
||||
<div class="article-content">
|
||||
<!-- Category Badge -->
|
||||
{
|
||||
category && (
|
||||
<span
|
||||
class="category-badge"
|
||||
style={`background-color: ${category.backgroundColor || 'var(--color-enchunblue)'}; color: ${category.textColor || '#fff'}`}
|
||||
>
|
||||
{category.title}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
<!-- Title -->
|
||||
<h3 class="article-title">
|
||||
{post.title}
|
||||
</h3>
|
||||
|
||||
<!-- Excerpt -->
|
||||
{
|
||||
post.excerpt && (
|
||||
<p class="article-excerpt">
|
||||
{post.excerpt.length > 150 ? post.excerpt.slice(0, 150) + '...' : post.excerpt}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
<!-- Published Date -->
|
||||
{
|
||||
displayDate && (
|
||||
<time class="article-date" datetime={displayDate}>
|
||||
{formatDate(displayDate)}
|
||||
</time>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<style>
|
||||
.article-card {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.article-card-inner {
|
||||
background: #ffffff;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
transition: all 0.3s ease;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.article-card:hover .article-card-inner {
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.article-card:hover .article-image {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
/* Image Section */
|
||||
.article-image-wrapper {
|
||||
aspect-ratio: 16 / 9;
|
||||
overflow: hidden;
|
||||
background: var(--color-gray-100, #f5f5f5);
|
||||
}
|
||||
|
||||
.article-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.4s ease;
|
||||
}
|
||||
|
||||
/* Content Section */
|
||||
.article-content {
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.category-badge {
|
||||
display: inline-block;
|
||||
padding: 0.375rem 0.875rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.75rem;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.article-title {
|
||||
font-family: "Noto Sans TC", sans-serif;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-dark-blue, #1a1a1a);
|
||||
line-height: 1.4;
|
||||
margin-bottom: 0.75rem;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.article-excerpt {
|
||||
font-family: "Noto Sans TC", sans-serif;
|
||||
font-size: 0.9375rem;
|
||||
color: var(--color-gray-600, #666);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 1rem;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.article-date {
|
||||
font-family: "Quicksand", "Noto Sans TC", sans-serif;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-gray-500, #999);
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
/* Responsive Adjustments */
|
||||
@media (max-width: 767px) {
|
||||
.article-content {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.article-title {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.article-excerpt {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
113
apps/frontend/src/components/blog/CategoryFilter.astro
Normal file
113
apps/frontend/src/components/blog/CategoryFilter.astro
Normal file
@@ -0,0 +1,113 @@
|
||||
---
|
||||
/**
|
||||
* CategoryFilter - Blog category filter component
|
||||
* Displays category filter buttons with active state
|
||||
*/
|
||||
|
||||
interface Category {
|
||||
id: string
|
||||
title: string
|
||||
slug: string
|
||||
backgroundColor?: string
|
||||
textColor?: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
categories: Category[]
|
||||
activeCategory?: string | null
|
||||
baseUrl?: string
|
||||
}
|
||||
|
||||
const { categories, activeCategory, baseUrl = '/news' } = Astro.props
|
||||
|
||||
// Filter out the legacy "文章分類" container category
|
||||
const filteredCategories = categories.filter(c => c.slug !== 'wen-zhang-fen-lei')
|
||||
---
|
||||
|
||||
<nav class="category-filter" aria-label="文章分類篩選">
|
||||
<div class="filter-container">
|
||||
<a
|
||||
href={baseUrl}
|
||||
class:filter-button-active={!activeCategory}
|
||||
class="filter-button"
|
||||
>
|
||||
全部文章
|
||||
</a>
|
||||
|
||||
{
|
||||
filteredCategories.map((category) => (
|
||||
<a
|
||||
href={`/wen-zhang-fen-lei/${category.slug}`}
|
||||
class:filter-button-active={activeCategory === category.slug}
|
||||
class="filter-button"
|
||||
aria-label={`篩選 ${category.title} 類別文章`}
|
||||
>
|
||||
{category.title}
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<style>
|
||||
.category-filter {
|
||||
padding: 1rem 0;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.filter-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.filter-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.625rem 1.25rem;
|
||||
border: 2px solid var(--color-gray-300, #e0e0e0);
|
||||
border-radius: 24px;
|
||||
background: #ffffff;
|
||||
color: var(--color-gray-700, #555);
|
||||
font-family: "Noto Sans TC", sans-serif;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
transition: all 0.25s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filter-button:hover {
|
||||
border-color: var(--color-enchunblue, #23608c);
|
||||
color: var(--color-enchunblue, #23608c);
|
||||
background: rgba(35, 96, 140, 0.05);
|
||||
}
|
||||
|
||||
.filter-button-active {
|
||||
background: var(--color-enchunblue, #23608c);
|
||||
border-color: var(--color-enchunblue, #23608c);
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.filter-button-active:hover {
|
||||
background: var(--color-enchunblue-hover, #1a4d6e);
|
||||
border-color: var(--color-enchunblue-hover, #1a4d6e);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* Responsive Adjustments */
|
||||
@media (max-width: 767px) {
|
||||
.filter-container {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.filter-button {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
195
apps/frontend/src/components/blog/RelatedPosts.astro
Normal file
195
apps/frontend/src/components/blog/RelatedPosts.astro
Normal file
@@ -0,0 +1,195 @@
|
||||
---
|
||||
/**
|
||||
* RelatedPosts - Related articles section component
|
||||
* Displays 3-4 related posts from the same category
|
||||
*/
|
||||
|
||||
interface Category {
|
||||
id: string
|
||||
title: string
|
||||
slug: string
|
||||
backgroundColor?: string
|
||||
textColor?: string
|
||||
}
|
||||
|
||||
interface PostImage {
|
||||
url: string
|
||||
alt?: string
|
||||
}
|
||||
|
||||
interface Post {
|
||||
id: string
|
||||
title: string
|
||||
slug: string
|
||||
heroImage?: PostImage | null
|
||||
excerpt?: string | null
|
||||
categories?: Category[] | null
|
||||
publishedAt?: string | null
|
||||
}
|
||||
|
||||
interface Props {
|
||||
posts: Post[]
|
||||
title?: string
|
||||
}
|
||||
|
||||
const { posts, title = '相關文章' } = Astro.props
|
||||
|
||||
// Only display if there are related posts
|
||||
// Component will render empty fragment if no posts
|
||||
---
|
||||
|
||||
{
|
||||
posts && posts.length > 0 && (
|
||||
<section class="related-posts-section" aria-labelledby="related-posts-heading">
|
||||
<div class="container">
|
||||
<h2 id="related-posts-heading" class="related-posts-title">
|
||||
{title}
|
||||
</h2>
|
||||
|
||||
<div class="related-posts-grid">
|
||||
{
|
||||
posts.slice(0, 4).map((post) => (
|
||||
<a href={`/blog/${post.slug}`} class="related-post-card">
|
||||
<!-- Post Image -->
|
||||
<div class="related-post-image">
|
||||
<img
|
||||
src={post.heroImage?.url || '/placeholder-blog.jpg'}
|
||||
alt={post.heroImage?.alt || post.title}
|
||||
loading="lazy"
|
||||
width="400"
|
||||
height="225"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Post Content -->
|
||||
<div class="related-post-content">
|
||||
<!-- Category Badge -->
|
||||
{
|
||||
post.categories && post.categories[0] && (
|
||||
<span
|
||||
class="related-category-badge"
|
||||
style={`background-color: ${post.categories[0].backgroundColor || 'var(--color-enchunblue)'}; color: ${post.categories[0].textColor || '#fff'}`}
|
||||
>
|
||||
{post.categories[0].title}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<!-- Post Title -->
|
||||
<h3 class="related-post-title">
|
||||
{post.title}
|
||||
</h3>
|
||||
</div>
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
<style>
|
||||
.related-posts-section {
|
||||
padding: 4rem 1.5rem;
|
||||
background: var(--color-gray-50, #f9fafb);
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.related-posts-title {
|
||||
font-family: "Noto Sans TC", sans-serif;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-enchunblue, #23608c);
|
||||
text-align: center;
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.related-posts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.related-post-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #ffffff;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
transition: all 0.3s ease;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.related-post-card:hover {
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.12);
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.related-post-card:hover .related-post-image img {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.related-post-image {
|
||||
aspect-ratio: 16 / 9;
|
||||
overflow: hidden;
|
||||
background: var(--color-gray-200, #e5e5e5);
|
||||
}
|
||||
|
||||
.related-post-image img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.4s ease;
|
||||
}
|
||||
|
||||
.related-post-content {
|
||||
padding: 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.related-category-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 16px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.related-post-title {
|
||||
font-family: "Noto Sans TC", sans-serif;
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-dark-blue, #1a1a1a);
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Responsive Adjustments */
|
||||
@media (max-width: 767px) {
|
||||
.related-posts-section {
|
||||
padding: 3rem 1rem;
|
||||
}
|
||||
|
||||
.related-posts-title {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.related-posts-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,41 +1,47 @@
|
||||
---
|
||||
import '../styles/tailwind.css';
|
||||
import Header from '../components/Header.astro';
|
||||
import Footer from '../components/Footer.astro';
|
||||
import "../styles/tailwind.css";
|
||||
import Header from "../components/Header.astro";
|
||||
import Footer from "../components/Footer.astro";
|
||||
|
||||
// Main layout for the site
|
||||
---
|
||||
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="zh-TW">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>恩群數位行銷</title>
|
||||
<meta name="description" content="恩群數位累積多年廣告行銷操作經驗,擁有全方位行銷人才,讓我們可以為客戶精準的規劃每一分廣告預算,讓你的品牌深入人心。" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+TC:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body class="bg-background text-text font-sans min-h-screen flex flex-col">
|
||||
<Header />
|
||||
<main class="flex-1">
|
||||
<slot />
|
||||
</main>
|
||||
<Footer />
|
||||
</body>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>恩群數位行銷</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="恩群數位累積多年廣告行銷操作經驗,擁有全方位行銷人才,讓我們可以為客戶精準的規劃每一分廣告預算,讓你的品牌深入人心。"
|
||||
/>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Noto+Sans+TC:wght@300;400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body class="bg-background text-text font-sans min-h-screen flex flex-col">
|
||||
<Header />
|
||||
<main class="flex-1 pt-20">
|
||||
<slot />
|
||||
</main>
|
||||
<Footer />
|
||||
</body>
|
||||
</html>
|
||||
|
||||
<style>
|
||||
body {
|
||||
font-family: var(--font-family-sans);
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
main {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
body {
|
||||
font-family: var(--font-family-sans);
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
main {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
|
||||
212
apps/frontend/src/lib/api/blog.ts
Normal file
212
apps/frontend/src/lib/api/blog.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* Blog API utilities
|
||||
* Helper functions for fetching blog posts and categories from Payload CMS
|
||||
*/
|
||||
|
||||
interface PostImage {
|
||||
url: string
|
||||
alt?: string
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
id: string
|
||||
title: string
|
||||
slug: string
|
||||
backgroundColor?: string
|
||||
textColor?: string
|
||||
nameEn?: string
|
||||
}
|
||||
|
||||
export interface Post {
|
||||
id: string
|
||||
title: string
|
||||
slug: string
|
||||
heroImage?: PostImage | null
|
||||
ogImage?: PostImage | null
|
||||
excerpt?: string | null
|
||||
content?: any
|
||||
categories?: Category[] | null
|
||||
relatedPosts?: Post[] | null
|
||||
publishedAt?: string | null
|
||||
createdAt?: string | null
|
||||
updatedAt?: string | null
|
||||
status?: string
|
||||
meta?: {
|
||||
title?: string
|
||||
description?: string
|
||||
image?: PostImage
|
||||
}
|
||||
}
|
||||
|
||||
export interface BlogResponse {
|
||||
docs: Post[]
|
||||
totalDocs: number
|
||||
totalPages: number
|
||||
page: number
|
||||
hasPreviousPage: boolean
|
||||
hasNextPage: boolean
|
||||
}
|
||||
|
||||
// Use production CMS API (enchun-cms.anlstudio.cc) for all environments
|
||||
// Production CMS has 35+ published posts with proper slugs
|
||||
const PAYLOAD_API_URL = 'https://enchun-cms.anlstudio.cc/api'
|
||||
|
||||
/**
|
||||
* Fetch published posts from Payload CMS
|
||||
*/
|
||||
export async function fetchPosts(
|
||||
page = 1,
|
||||
limit = 12,
|
||||
categorySlug?: string | null
|
||||
): Promise<BlogResponse> {
|
||||
try {
|
||||
// Build query parameters
|
||||
const params = new URLSearchParams()
|
||||
|
||||
if (categorySlug) {
|
||||
// For category filtering, we need to filter by category
|
||||
params.append('where[categories][slug][equals]', categorySlug)
|
||||
}
|
||||
// Always filter by published status (use _status for Payload's internal status)
|
||||
params.append('where[_status][equals]', 'published')
|
||||
|
||||
params.append('sort', '-publishedAt')
|
||||
params.append('limit', limit.toString())
|
||||
params.append('page', page.toString())
|
||||
params.append('depth', '1')
|
||||
|
||||
const response = await fetch(`${PAYLOAD_API_URL}/posts?${params}`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch posts: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data as BlogResponse
|
||||
} catch (error) {
|
||||
console.error('Error fetching posts:', error)
|
||||
return {
|
||||
docs: [],
|
||||
totalDocs: 0,
|
||||
totalPages: 0,
|
||||
page: 1,
|
||||
hasPreviousPage: false,
|
||||
hasNextPage: false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single post by slug
|
||||
*/
|
||||
export async function fetchPostBySlug(slug: string): Promise<Post | null> {
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
params.append('where[slug][equals]', slug)
|
||||
params.append('where[_status][equals]', 'published')
|
||||
params.append('depth', '2')
|
||||
|
||||
const response = await fetch(`${PAYLOAD_API_URL}/posts?${params}`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch post: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data.docs?.[0] || null
|
||||
} catch (error) {
|
||||
console.error('Error fetching post by slug:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all categories
|
||||
*/
|
||||
export async function fetchCategories(): Promise<Category[]> {
|
||||
try {
|
||||
const response = await fetch(`${PAYLOAD_API_URL}/categories?sort=order&limit=20&depth=0`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch categories: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data.docs || []
|
||||
} catch (error) {
|
||||
console.error('Error fetching categories:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch category by slug
|
||||
*/
|
||||
export async function fetchCategoryBySlug(slug: string): Promise<Category | null> {
|
||||
try {
|
||||
const response = await fetch(`${PAYLOAD_API_URL}/categories?where[slug][equals]=${slug}&depth=0`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch category: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data.docs?.[0] || null
|
||||
} catch (error) {
|
||||
console.error('Error fetching category by slug:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch related posts (same category, excluding current post)
|
||||
*/
|
||||
export async function fetchRelatedPosts(
|
||||
currentPostId: string,
|
||||
categorySlugs: string[],
|
||||
limit = 3
|
||||
): Promise<Post[]> {
|
||||
try {
|
||||
if (categorySlugs.length === 0) return []
|
||||
|
||||
// Use a simpler approach - fetch published posts and filter
|
||||
const params = new URLSearchParams()
|
||||
params.append('where[_status][equals]', 'published')
|
||||
params.append('limit', '20') // Fetch more to filter client-side
|
||||
params.append('sort', '-publishedAt')
|
||||
params.append('depth', '1')
|
||||
|
||||
const response = await fetch(`${PAYLOAD_API_URL}/posts?${params}`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch related posts: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const allPosts = data.docs || []
|
||||
|
||||
// Filter: same category, exclude current, limit
|
||||
return allPosts
|
||||
.filter((post: Post) => post.id !== currentPostId)
|
||||
.filter((post: Post) =>
|
||||
post.categories?.some((cat: Category) => categorySlugs.includes(cat.slug))
|
||||
)
|
||||
.slice(0, limit)
|
||||
} catch (error) {
|
||||
console.error('Error fetching related posts:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date in Traditional Chinese
|
||||
*/
|
||||
export function formatDate(dateStr: string | null | undefined): string {
|
||||
if (!dateStr) return ''
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleDateString('zh-TW', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})
|
||||
}
|
||||
169
apps/frontend/src/lib/api/home.ts
Normal file
169
apps/frontend/src/lib/api/home.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
// Home Page API Service
|
||||
// Fetches data from Payload CMS for the homepage
|
||||
|
||||
export interface ServiceFeature {
|
||||
icon?: string
|
||||
title?: string
|
||||
description?: string
|
||||
link?: {
|
||||
url?: string
|
||||
}
|
||||
}
|
||||
|
||||
interface PortfolioSection {
|
||||
headline?: string
|
||||
subheadline?: string
|
||||
itemsToShow?: number
|
||||
}
|
||||
|
||||
interface CTASection {
|
||||
headline?: string
|
||||
description?: string
|
||||
buttonText?: string
|
||||
buttonLink?: string
|
||||
}
|
||||
|
||||
export interface HomeData {
|
||||
heroHeadline?: string
|
||||
heroSubheadline?: string
|
||||
heroDesktopVideo?: {
|
||||
url?: string
|
||||
filename?: string
|
||||
alt?: string
|
||||
sizes?: {
|
||||
thumbnail?: {
|
||||
url?: string
|
||||
}
|
||||
}
|
||||
}
|
||||
heroMobileVideo?: {
|
||||
url?: string
|
||||
filename?: string
|
||||
alt?: string
|
||||
}
|
||||
heroFallbackImage?: {
|
||||
url?: string
|
||||
filename?: string
|
||||
alt?: string
|
||||
}
|
||||
heroLogo?: {
|
||||
url?: string
|
||||
filename?: string
|
||||
alt?: string
|
||||
}
|
||||
serviceFeatures?: ServiceFeature[]
|
||||
portfolioSection?: PortfolioSection
|
||||
ctaSection?: CTASection
|
||||
}
|
||||
|
||||
export interface ServiceItem {
|
||||
icon?: string
|
||||
title?: string
|
||||
description?: string
|
||||
link?: {
|
||||
url?: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface PortfolioItem {
|
||||
id: string
|
||||
title: string
|
||||
slug: string
|
||||
url?: string
|
||||
image?: {
|
||||
url?: string
|
||||
alt?: string
|
||||
filename?: string
|
||||
sizes?: {
|
||||
thumbnail?: {
|
||||
url?: string
|
||||
}
|
||||
}
|
||||
}
|
||||
description?: string
|
||||
websiteType?: string
|
||||
tags?: Array<{ tag?: string }>
|
||||
}
|
||||
|
||||
// Get API base URL
|
||||
function getApiBaseUrl(): string {
|
||||
if (typeof process !== 'undefined' && process.env?.PAYLOAD_CMS_URL) {
|
||||
return process.env.PAYLOAD_CMS_URL
|
||||
}
|
||||
return import.meta.env.PUBLIC_PAYLOAD_CMS_URL || 'https://enchun-admin.anlstudio.cc'
|
||||
}
|
||||
|
||||
const PAYLOAD_URL = getApiBaseUrl()
|
||||
const PAYLOAD_API_KEY = import.meta.env.PAYLOAD_CMS_API_KEY
|
||||
|
||||
// Build common headers
|
||||
function getHeaders(): HeadersInit {
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
if (PAYLOAD_API_KEY) {
|
||||
headers['Authorization'] = `Bearer ${PAYLOAD_API_KEY}`
|
||||
}
|
||||
|
||||
return headers
|
||||
}
|
||||
|
||||
// Fetch Home global data
|
||||
export async function getHomeData(draft = false): Promise<HomeData | null> {
|
||||
try {
|
||||
const draftParam = draft ? '?draft=true' : ''
|
||||
const response = await fetch(`${PAYLOAD_URL}/api/globals/home${draftParam}`, {
|
||||
headers: getHeaders(),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Failed to fetch home data:', response.status, response.statusText)
|
||||
return null
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error('Error fetching home data:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch Portfolio items for preview
|
||||
export async function getPortfolioPreview(limit = 3): Promise<PortfolioItem[]> {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${PAYLOAD_URL}/api/portfolio?sort=-updatedAt&limit=${limit}&depth=1`,
|
||||
{
|
||||
headers: getHeaders(),
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Failed to fetch portfolio items:', response.status)
|
||||
return []
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data.docs || []
|
||||
} catch (error) {
|
||||
console.error('Error fetching portfolio items:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// Combined fetch for homepage
|
||||
export async function getHomepageData(draft = false): Promise<{
|
||||
home: HomeData | null
|
||||
portfolioItems: PortfolioItem[]
|
||||
}> {
|
||||
// First fetch home data to get the itemsToShow value
|
||||
const home = await getHomeData(draft)
|
||||
const limit = home?.portfolioSection?.itemsToShow || 3
|
||||
|
||||
// Then fetch portfolio items with the correct limit
|
||||
const portfolioItems = await getPortfolioPreview(limit)
|
||||
|
||||
return { home, portfolioItems }
|
||||
}
|
||||
139
apps/frontend/src/lib/api/portfolio.ts
Normal file
139
apps/frontend/src/lib/api/portfolio.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Portfolio API utilities
|
||||
* Helper functions for fetching portfolio items from Payload CMS
|
||||
*/
|
||||
|
||||
interface PortfolioImage {
|
||||
url: string
|
||||
alt?: string
|
||||
}
|
||||
|
||||
export interface PortfolioTag {
|
||||
id: string
|
||||
tag: string
|
||||
}
|
||||
|
||||
export interface PortfolioItem {
|
||||
id: string
|
||||
title: string
|
||||
slug: string
|
||||
url?: string | null
|
||||
image?: PortfolioImage | null
|
||||
description?: string | null
|
||||
websiteType?: 'corporate' | 'ecommerce' | 'landing' | 'brand' | 'other' | null
|
||||
tags?: PortfolioTag[] | null
|
||||
createdAt?: string | null
|
||||
updatedAt?: string | null
|
||||
}
|
||||
|
||||
export interface PortfolioResponse {
|
||||
docs: PortfolioItem[]
|
||||
totalDocs: number
|
||||
totalPages: number
|
||||
page: number
|
||||
hasPreviousPage: boolean
|
||||
hasNextPage: boolean
|
||||
}
|
||||
|
||||
// Use production CMS API (enchun-cms.anlstudio.cc) for all environments
|
||||
const PAYLOAD_API_URL = 'https://enchun-cms.anlstudio.cc/api'
|
||||
|
||||
/**
|
||||
* Fetch all portfolio items
|
||||
*/
|
||||
export async function fetchPortfolios(
|
||||
page = 1,
|
||||
limit = 100
|
||||
): Promise<PortfolioResponse> {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
sort: '-createdAt',
|
||||
limit: limit.toString(),
|
||||
page: page.toString(),
|
||||
depth: '1'
|
||||
})
|
||||
|
||||
const response = await fetch(`${PAYLOAD_API_URL}/portfolio?${params}`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch portfolios: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data as PortfolioResponse
|
||||
} catch (error) {
|
||||
console.error('Error fetching portfolios:', error)
|
||||
return {
|
||||
docs: [],
|
||||
totalDocs: 0,
|
||||
totalPages: 0,
|
||||
page: 1,
|
||||
hasPreviousPage: false,
|
||||
hasNextPage: false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single portfolio item by slug
|
||||
*/
|
||||
export async function fetchPortfolioBySlug(slug: string): Promise<PortfolioItem | null> {
|
||||
try {
|
||||
const response = await fetch(`${PAYLOAD_API_URL}/portfolio?where[slug][equals]=${slug}&depth=1`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch portfolio: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data.docs?.[0] || null
|
||||
} catch (error) {
|
||||
console.error('Error fetching portfolio by slug:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get website type label in Traditional Chinese
|
||||
*/
|
||||
export function getWebsiteTypeLabel(type: string | null | undefined): string {
|
||||
const labels: Record<string, string> = {
|
||||
corporate: '企業官網',
|
||||
ecommerce: '電商網站',
|
||||
landing: '活動頁面',
|
||||
brand: '品牌網站',
|
||||
other: '其他'
|
||||
}
|
||||
return labels[type || ''] || '其他'
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all tags from portfolio items
|
||||
*/
|
||||
export function getAllTags(portfolios: PortfolioItem[]): string[] {
|
||||
const tagSet = new Set<string>()
|
||||
portfolios.forEach(item => {
|
||||
item.tags?.forEach(tagObj => {
|
||||
tagSet.add(tagObj.tag)
|
||||
})
|
||||
})
|
||||
return Array.from(tagSet).sort()
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter portfolios by website type
|
||||
*/
|
||||
export function filterByType(portfolios: PortfolioItem[], type: string): PortfolioItem[] {
|
||||
if (!type || type === 'all') return portfolios
|
||||
return portfolios.filter(item => item.websiteType === type)
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter portfolios by tag
|
||||
*/
|
||||
export function filterByTag(portfolios: PortfolioItem[], tag: string): PortfolioItem[] {
|
||||
if (!tag) return portfolios
|
||||
return portfolios.filter(item =>
|
||||
item.tags?.some(tagObj => tagObj.tag === tag)
|
||||
)
|
||||
}
|
||||
221
apps/frontend/src/lib/serializeLexical.spec.ts
Normal file
221
apps/frontend/src/lib/serializeLexical.spec.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* Tests for serializeLexical utility
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { serializeLexical } from './serializeLexical'
|
||||
|
||||
describe('serializeLexical', () => {
|
||||
it('should return empty string for null input', async () => {
|
||||
const result = await serializeLexical(null)
|
||||
expect(result).toBe('')
|
||||
})
|
||||
|
||||
it('should return empty string for undefined input', async () => {
|
||||
const result = await serializeLexical(undefined)
|
||||
expect(result).toBe('')
|
||||
})
|
||||
|
||||
it('should return empty string for empty string', async () => {
|
||||
const result = await serializeLexical('')
|
||||
expect(result).toBe('')
|
||||
})
|
||||
|
||||
it('should return plain text as-is', async () => {
|
||||
const result = await serializeLexical('Hello world')
|
||||
expect(result).toBe('Hello world')
|
||||
})
|
||||
|
||||
it('should return HTML as-is', async () => {
|
||||
const html = '<p>Hello <strong>world</strong></p>'
|
||||
const result = await serializeLexical(html)
|
||||
expect(result).toBe(html)
|
||||
})
|
||||
|
||||
it('should convert Lexical JSON to HTML - simple paragraph', async () => {
|
||||
const lexicalData = {
|
||||
root: {
|
||||
type: 'root',
|
||||
children: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
children: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Hello world'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const result = await serializeLexical(lexicalData)
|
||||
expect(result).toContain('Hello world')
|
||||
expect(result).toContain('<p>')
|
||||
expect(result).toContain('</p>')
|
||||
})
|
||||
|
||||
it('should convert Lexical JSON with heading', async () => {
|
||||
const lexicalData = {
|
||||
root: {
|
||||
type: 'root',
|
||||
children: [
|
||||
{
|
||||
type: 'heading',
|
||||
tag: 'h2',
|
||||
children: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Title'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const result = await serializeLexical(lexicalData)
|
||||
expect(result).toContain('<h2>')
|
||||
expect(result).toContain('Title')
|
||||
expect(result).toContain('</h2>')
|
||||
})
|
||||
|
||||
it('should convert Lexical JSON with bold text', async () => {
|
||||
const lexicalData = {
|
||||
root: {
|
||||
type: 'root',
|
||||
children: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
children: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Hello',
|
||||
bold: true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const result = await serializeLexical(lexicalData)
|
||||
expect(result).toContain('<strong>')
|
||||
expect(result).toContain('Hello')
|
||||
expect(result).toContain('</strong>')
|
||||
})
|
||||
|
||||
it('should convert Lexical JSON with link', async () => {
|
||||
const lexicalData = {
|
||||
root: {
|
||||
type: 'root',
|
||||
children: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
children: [
|
||||
{
|
||||
type: 'link',
|
||||
url: 'https://example.com',
|
||||
children: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Click here'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const result = await serializeLexical(lexicalData)
|
||||
expect(result).toContain('<a href="https://example.com"')
|
||||
expect(result).toContain('Click here')
|
||||
expect(result).toContain('</a>')
|
||||
})
|
||||
|
||||
it('should handle stringified Lexical JSON', async () => {
|
||||
const lexicalString = JSON.stringify({
|
||||
root: {
|
||||
type: 'root',
|
||||
children: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
children: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Test content'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const result = await serializeLexical(lexicalString)
|
||||
expect(result).toContain('Test content')
|
||||
})
|
||||
|
||||
it('should handle array input gracefully', async () => {
|
||||
const result = await serializeLexical(['not', 'valid'])
|
||||
expect(result).toBe('')
|
||||
})
|
||||
|
||||
it('should convert list nodes', async () => {
|
||||
const lexicalData = {
|
||||
root: {
|
||||
type: 'root',
|
||||
children: [
|
||||
{
|
||||
type: 'list',
|
||||
listType: 'bullet',
|
||||
children: [
|
||||
{
|
||||
type: 'listitem',
|
||||
children: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Item 1'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const result = await serializeLexical(lexicalData)
|
||||
expect(result).toContain('<ul>')
|
||||
expect(result).toContain('<li>')
|
||||
expect(result).toContain('Item 1')
|
||||
expect(result).toContain('</li>')
|
||||
expect(result).toContain('</ul>')
|
||||
})
|
||||
|
||||
it('should handle blockquote', async () => {
|
||||
const lexicalData = {
|
||||
root: {
|
||||
type: 'root',
|
||||
children: [
|
||||
{
|
||||
type: 'quote',
|
||||
children: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Quote text'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const result = await serializeLexical(lexicalData)
|
||||
expect(result).toContain('<blockquote>')
|
||||
expect(result).toContain('Quote text')
|
||||
expect(result).toContain('</blockquote>')
|
||||
})
|
||||
})
|
||||
357
apps/frontend/src/lib/serializeLexical.ts
Normal file
357
apps/frontend/src/lib/serializeLexical.ts
Normal file
@@ -0,0 +1,357 @@
|
||||
/**
|
||||
* Lexical to HTML Converter
|
||||
* Converts Payload CMS Lexical editor JSON format to HTML
|
||||
*
|
||||
* This implementation uses a simple recursive renderer that works in
|
||||
* both browser and SSR environments without requiring heavy dependencies.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Serialized Lexical node types
|
||||
*/
|
||||
interface LexicalTextNode {
|
||||
type: 'text'
|
||||
text: string
|
||||
bold?: boolean
|
||||
italic?: boolean
|
||||
underline?: boolean
|
||||
strikethrough?: boolean
|
||||
code?: boolean
|
||||
}
|
||||
|
||||
interface LexicalHeadingNode {
|
||||
type: 'heading'
|
||||
tag: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
|
||||
children: any[]
|
||||
}
|
||||
|
||||
interface LexicalParagraphNode {
|
||||
type: 'paragraph'
|
||||
children: any[]
|
||||
}
|
||||
|
||||
interface LexicalListNode {
|
||||
type: 'list'
|
||||
listType: 'bullet' | 'number'
|
||||
children: any[]
|
||||
}
|
||||
|
||||
interface LexicalListItemNode {
|
||||
type: 'listitem'
|
||||
children: any[]
|
||||
}
|
||||
|
||||
interface LexicalLinkNode {
|
||||
type: 'link'
|
||||
url: string
|
||||
title?: string
|
||||
children: any[]
|
||||
}
|
||||
|
||||
interface LexicalQuoteNode {
|
||||
type: 'quote'
|
||||
children: any[]
|
||||
}
|
||||
|
||||
interface LexicalLineBreakNode {
|
||||
type: 'linebreak'
|
||||
}
|
||||
|
||||
interface LexicalUploadNode {
|
||||
type: 'upload'
|
||||
value?: {
|
||||
url: string
|
||||
alt?: string
|
||||
width?: number
|
||||
height?: number
|
||||
}
|
||||
}
|
||||
|
||||
interface LexicalBlockNode {
|
||||
type: 'block'
|
||||
fields?: {
|
||||
[key: string]: any
|
||||
}
|
||||
}
|
||||
|
||||
interface LexicalRootNode {
|
||||
type: 'root'
|
||||
children: any[]
|
||||
}
|
||||
|
||||
interface SerializedEditorState {
|
||||
root?: LexicalRootNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Lexical JSON to HTML string
|
||||
*
|
||||
* @param lexicalData - The Lexical editor state from Payload CMS (object or JSON string)
|
||||
* @returns HTML string
|
||||
*/
|
||||
export async function serializeLexical(lexicalData: unknown): Promise<string> {
|
||||
// Handle null, undefined, empty values
|
||||
if (lexicalData === null || lexicalData === undefined) {
|
||||
return ''
|
||||
}
|
||||
|
||||
// Handle string content (already HTML or JSON string)
|
||||
if (typeof lexicalData === 'string') {
|
||||
const trimmed = lexicalData.trim()
|
||||
|
||||
// Empty string
|
||||
if (trimmed === '') {
|
||||
return ''
|
||||
}
|
||||
|
||||
// Check if it's a JSON object (Lexical format)
|
||||
if (trimmed.startsWith('{')) {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as SerializedEditorState | LexicalRootNode
|
||||
return renderLexical(parsed)
|
||||
} catch {
|
||||
// Not valid JSON, return as-is (might be HTML already)
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
|
||||
// Already HTML or plain text
|
||||
return trimmed
|
||||
}
|
||||
|
||||
// Handle object (Lexical editor state)
|
||||
if (typeof lexicalData === 'object') {
|
||||
// Ensure it's not an array
|
||||
if (Array.isArray(lexicalData)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return renderLexical(lexicalData as SerializedEditorState | LexicalRootNode)
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Render Lexical editor state to HTML
|
||||
*/
|
||||
function renderLexical(state: SerializedEditorState | LexicalRootNode): string {
|
||||
if (!state) return ''
|
||||
|
||||
// Get root children - handle both formats
|
||||
let children: any[] = []
|
||||
if ('root' in state && state.root?.children) {
|
||||
children = state.root.children
|
||||
} else if ('children' in state && Array.isArray(state.children)) {
|
||||
children = state.children
|
||||
}
|
||||
|
||||
return children
|
||||
.map((child) => renderNode(child))
|
||||
.filter(Boolean)
|
||||
.join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single Lexical node to HTML
|
||||
*/
|
||||
function renderNode(node: any): string {
|
||||
if (!node || typeof node !== 'object') return ''
|
||||
|
||||
const type = node.type
|
||||
|
||||
switch (type) {
|
||||
case 'root':
|
||||
return (node.children || []).map((child: any) => renderNode(child)).join('')
|
||||
|
||||
case 'text':
|
||||
return renderTextNode(node)
|
||||
|
||||
case 'paragraph':
|
||||
return renderParagraphNode(node)
|
||||
|
||||
case 'heading':
|
||||
return renderHeadingNode(node)
|
||||
|
||||
case 'link':
|
||||
return renderLinkNode(node)
|
||||
|
||||
case 'list':
|
||||
return renderListNode(node)
|
||||
|
||||
case 'listitem':
|
||||
return renderListItemNode(node)
|
||||
|
||||
case 'quote':
|
||||
return renderQuoteNode(node)
|
||||
|
||||
case 'linebreak':
|
||||
return '<br>'
|
||||
|
||||
case 'upload':
|
||||
case 'image':
|
||||
return renderUploadNode(node)
|
||||
|
||||
case 'block':
|
||||
return renderBlockNode(node)
|
||||
|
||||
default:
|
||||
// Unknown node type - try to render children if available
|
||||
if (node.children && Array.isArray(node.children)) {
|
||||
return node.children.map((child: any) => renderNode(child)).join('')
|
||||
}
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render text node with formatting
|
||||
*/
|
||||
function renderTextNode(node: LexicalTextNode): string {
|
||||
let text = escapeHtml(node.text || '')
|
||||
|
||||
// Apply formatting styles (order matters for nested tags)
|
||||
if (node.code) text = `<code>${text}</code>`
|
||||
if (node.strikethrough) text = `<s>${text}</s>`
|
||||
if (node.underline) text = `<u>${text}</u>`
|
||||
if (node.italic) text = `<em>${text}</em>`
|
||||
if (node.bold) text = `<strong>${text}</strong>`
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
/**
|
||||
* Render paragraph node
|
||||
*/
|
||||
function renderParagraphNode(node: LexicalParagraphNode): string {
|
||||
const children = (node.children || []).map((child) => renderNode(child)).join('')
|
||||
return children ? `<p>${children}</p>` : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Render heading node
|
||||
*/
|
||||
function renderHeadingNode(node: LexicalHeadingNode): string {
|
||||
const tag = node.tag || 'h2'
|
||||
const children = (node.children || []).map((child) => renderNode(child)).join('')
|
||||
return children ? `<${tag}>${children}</${tag}>` : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Render link node
|
||||
*/
|
||||
function renderLinkNode(node: LexicalLinkNode): string {
|
||||
const url = escapeHtml(node.url || '#')
|
||||
const title = node.title ? ` title="${escapeHtml(node.title)}"` : ''
|
||||
const children = (node.children || []).map((child) => renderNode(child)).join('')
|
||||
return children ? `<a href="${url}"${title}>${children}</a>` : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Render list node
|
||||
*/
|
||||
function renderListNode(node: LexicalListNode): string {
|
||||
const listType = node.listType === 'number' ? 'ol' : 'ul'
|
||||
const children = (node.children || []).map((child) => renderNode(child)).join('')
|
||||
return children ? `<${listType}>${children}</${listType}>` : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Render list item node
|
||||
*/
|
||||
function renderListItemNode(node: LexicalListItemNode): string {
|
||||
const children = (node.children || []).map((child) => renderNode(child)).join('')
|
||||
return children ? `<li>${children}</li>` : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Render quote node
|
||||
*/
|
||||
function renderQuoteNode(node: LexicalQuoteNode): string {
|
||||
const children = (node.children || []).map((child) => renderNode(child)).join('')
|
||||
return children ? `<blockquote>${children}</blockquote>` : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Render upload/image node
|
||||
*/
|
||||
function renderUploadNode(node: LexicalUploadNode): string {
|
||||
const url = node.value?.url || ''
|
||||
const alt = escapeHtml(node.value?.alt || '')
|
||||
const width = node.value?.width ? ` width="${node.value.width}"` : ''
|
||||
const height = node.value?.height ? ` height="${node.value.height}"` : ''
|
||||
|
||||
if (!url) return ''
|
||||
|
||||
return `<img src="${escapeHtml(url)}" alt="${alt}"${width}${height} style="max-width: 100%; height: auto;" loading="lazy" />`
|
||||
}
|
||||
|
||||
/**
|
||||
* Render block node (for custom blocks like media, banners, etc.)
|
||||
*/
|
||||
function renderBlockNode(node: LexicalBlockNode): string {
|
||||
const fields = node.fields || {}
|
||||
|
||||
// Handle media block
|
||||
if (fields.media) {
|
||||
const media = fields.media
|
||||
const url = media.url || ''
|
||||
const alt = escapeHtml(media.alt || '')
|
||||
|
||||
if (url) {
|
||||
return `<figure><img src="${escapeHtml(url)}" alt="${alt}" style="max-width: 100%; height: auto; border-radius: 8px; margin: 2rem 0;" loading="lazy" /></figure>`
|
||||
}
|
||||
}
|
||||
|
||||
// Handle other block types
|
||||
if (fields.blockType === 'banner') {
|
||||
const content = fields.content || ''
|
||||
return `<div class="banner">${renderNode({ type: 'root', children: content.root?.children || [] })}</div>`
|
||||
}
|
||||
|
||||
if (fields.blockType === 'code') {
|
||||
const code = escapeHtml(fields.code || '')
|
||||
const language = escapeHtml(fields.language || '')
|
||||
return `<pre><code class="language-${language}">${code}</code></pre>`
|
||||
}
|
||||
|
||||
// Fallback: try to render any nested content
|
||||
if (fields.content?.root?.children) {
|
||||
return fields.content.root.children.map((child: any) => renderNode(child)).join('')
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML special characters
|
||||
*/
|
||||
function escapeHtml(text: string): string {
|
||||
const map: Record<string, string> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
}
|
||||
return text.replace(/[&<>"']/g, (m) => map[m] || m)
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous version for compatibility (not recommended for use with Lexical)
|
||||
* @deprecated Use serializeLexical instead
|
||||
*/
|
||||
export function serializeLexicalSync(lexicalData: unknown): string {
|
||||
// This is a stub - the actual implementation should be async
|
||||
// For simple cases, we'll try to return something useful
|
||||
if (!lexicalData || typeof lexicalData !== 'object') {
|
||||
return String(lexicalData ?? '')
|
||||
}
|
||||
|
||||
if (typeof lexicalData === 'string') {
|
||||
return lexicalData
|
||||
}
|
||||
|
||||
// For Lexical objects, return empty since we can't properly render synchronously
|
||||
return ''
|
||||
}
|
||||
@@ -1,31 +1,38 @@
|
||||
---
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
/**
|
||||
* About Page - 關於恩群數位
|
||||
* 展示公司特色、服務優勢和與其他公司的差異
|
||||
*/
|
||||
import Layout from '../layouts/Layout.astro'
|
||||
import AboutHero from '../sections/AboutHero.astro'
|
||||
import FeatureSection from '../sections/FeatureSection.astro'
|
||||
import ComparisonSection from '../sections/ComparisonSection.astro'
|
||||
import CTASection from '../sections/CTASection.astro'
|
||||
|
||||
// Metadata for SEO
|
||||
const title = '關於恩群數位 | 專業數位行銷服務團隊'
|
||||
const description = '恩群數位行銷成立於2018年,提供全方位數位行銷服務。我們在地化優先、數據驅動,是您最可信赖的數位行銷夥伴。'
|
||||
---
|
||||
|
||||
<Layout>
|
||||
<section class="about-section">
|
||||
<div class="container">
|
||||
<h1>關於恩群</h1>
|
||||
<div class="prose prose-custom max-w-none">
|
||||
<p>恩群數位行銷有限公司成立於2018年,專注於數位行銷服務。</p>
|
||||
<p>我們擁有豐富的廣告行銷操作經驗,提供全方位行銷解決方案。</p>
|
||||
<!-- Add more content from HTML -->
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<Layout title={title} description={description}>
|
||||
<!-- Hero Section -->
|
||||
<AboutHero />
|
||||
|
||||
<!-- Service Features Section -->
|
||||
<FeatureSection />
|
||||
|
||||
<!-- Comparison Section -->
|
||||
<ComparisonSection />
|
||||
|
||||
<!-- CTA Section -->
|
||||
<CTASection
|
||||
homeData={{
|
||||
ctaSection: {
|
||||
headline: '準備好開始新的旅程了嗎',
|
||||
description: '讓我們一起為您的品牌打造獨特的數位行銷策略',
|
||||
buttonText: '聯絡我們',
|
||||
buttonLink: '/contact-us',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Layout>
|
||||
|
||||
<style>
|
||||
.about-section {
|
||||
padding: 40px 0;
|
||||
}
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
h1 {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,85 +1,625 @@
|
||||
---
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
/**
|
||||
* Contact Page - 聯絡我們頁面
|
||||
* Pixel-perfect implementation based on Webflow design
|
||||
* Includes form validation, submission handling, and responsive layout
|
||||
*/
|
||||
import Layout from '../layouts/Layout.astro'
|
||||
|
||||
// Metadata for SEO
|
||||
const title = '聯絡我們 | 恩群數位行銷'
|
||||
const description = '有任何問題嗎?歡迎聯絡恩群數位行銷,我們的專業團隊將竭誠為您服務。電話: 02 5570 0527,Email: enchuntaiwan@gmail.com'
|
||||
---
|
||||
|
||||
<Layout>
|
||||
<section class="contact-section">
|
||||
<div class="container">
|
||||
<h1>聯絡我們</h1>
|
||||
<form id="contact-form">
|
||||
<div class="form-group">
|
||||
<label for="name">姓名</label>
|
||||
<input type="text" id="name" name="name" required />
|
||||
<Layout title={title} description={description}>
|
||||
<section class="contact-section" id="contact">
|
||||
<div class="contactus_wrapper">
|
||||
<!-- Contact Form Side -->
|
||||
<div class="contact_form_wrapper">
|
||||
<h1 class="contact_head">聯絡我們</h1>
|
||||
<p class="contact_parafraph">
|
||||
有任何問題嗎?歡迎聯絡我們,我們將竭誠為您服務。
|
||||
</p>
|
||||
<p class="contact_reminder">
|
||||
* 標註欄位為必填
|
||||
</p>
|
||||
|
||||
<!-- Contact Form -->
|
||||
<form id="contact-form" class="contact_form" novalidate>
|
||||
<!-- Success Message -->
|
||||
<div id="form-success" class="w-form-done" style="display: none;">
|
||||
感謝您的留言!我們會盡快回覆您。
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div id="form-error" class="w-form-fail" style="display: none;">
|
||||
送出失敗,請稍後再試或直接來電。
|
||||
</div>
|
||||
|
||||
<!-- Form Fields -->
|
||||
<div class="contact-form-grid">
|
||||
<!-- Name Field -->
|
||||
<div class="contact_field_wrapper">
|
||||
<label for="Name" class="contact_field_name">
|
||||
姓名 <span>*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="Name"
|
||||
name="Name"
|
||||
class="input_field"
|
||||
required
|
||||
minlength="2"
|
||||
maxlength="256"
|
||||
placeholder="請輸入您的姓名"
|
||||
/>
|
||||
<span class="error-message" id="Name-error"></span>
|
||||
</div>
|
||||
|
||||
<!-- Phone Field -->
|
||||
<div class="contact_field_wrapper">
|
||||
<label for="Phone" class="contact_field_name">
|
||||
聯絡電話 <span>*</span>
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
id="Phone"
|
||||
name="Phone"
|
||||
class="input_field"
|
||||
required
|
||||
placeholder="請輸入您的電話號碼"
|
||||
/>
|
||||
<span class="error-message" id="Phone-error"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Email Field -->
|
||||
<div class="contact_field_wrapper">
|
||||
<label for="Email" class="contact_field_name">
|
||||
Email <span>*</span>
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="Email"
|
||||
name="Email"
|
||||
class="input_field"
|
||||
required
|
||||
placeholder="請輸入您的 Email"
|
||||
/>
|
||||
<span class="error-message" id="Email-error"></span>
|
||||
</div>
|
||||
|
||||
<!-- Message Field -->
|
||||
<div class="contact_field_wrapper">
|
||||
<label for="Message" class="contact_field_name">
|
||||
聯絡訊息 <span>*</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="Message"
|
||||
name="Message"
|
||||
class="input_field"
|
||||
minlength="10"
|
||||
maxlength="5000"
|
||||
required
|
||||
placeholder="請輸入您的訊息(至少 10 個字元)"
|
||||
></textarea>
|
||||
<span class="error-message" id="Message-error"></span>
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<button type="submit" class="submit-button" id="submit-btn">
|
||||
<span class="button-text">送出訊息</span>
|
||||
<span class="button-loading" style="display: none;">送出中...</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Contact Image Side -->
|
||||
<div class="contact-image">
|
||||
<div class="image-wrapper">
|
||||
<img
|
||||
src="/placeholder-contact.jpg"
|
||||
alt="聯絡恩群數位"
|
||||
width="600"
|
||||
height="400"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="email">電子郵件</label>
|
||||
<input type="email" id="email" name="email" required />
|
||||
|
||||
<!-- Contact Info Card -->
|
||||
<div class="contact-info-card">
|
||||
<h3>聯絡資訊</h3>
|
||||
<div class="info-item">
|
||||
<svg class="info-icon" viewBox="0 0 24 24"><path fill="currentColor" d="M6.62 10.79c1.44 2.83 3.76 5.14 6.59 6.59l2.2-2.2c.27-.27.67-.36 1.02-.24 1.12.37 2.33.57 3.57.57.55 0 1.45-.17 2.53-.5.36-.11.74-.47 1.02-.75l2.2-2.2c.27-.27.36-.67.24-1.02-.37-1.12-.57-2.33-.57-3.57 0-.55.17-1.45.5-2.53.36-.11.74.47-1.14.75-1.02zM2.05 21.05c.15.35.48.59.84.59l2.2.73c.36.12.74.12 1.06-.05.13-.07.22-.17.28-.28l2.2-2.2c.27-.27.36-.67.24-1.02-.37-1.12-.57-2.33-.57-3.57 0-.55.17-1.45.5-2.53.36-.11.74.47 1.14.75 1.02L2.05 21.05zM19.95 2.95c-.15-.35-.48-.59-.84-.59l-2.2-.73c-.36-.12-.74-.12-1.06.05-.13.07-.22.17-.28.28l-2.2 2.2c-.27.27-.36.67-.24 1.02.37 1.12.57 2.33.57 3.57 0 .55-.17 1.45-.5 2.53-.36.11-.74-.47-1.14-.75l1.02-1.02 2.2-2.2c.27-.27.67-.36 1.02-.24 1.12.37 2.33.57 3.57.57.55 0 1.45-.17 2.53-.5.36-.11.74-.47 1.02-.75l2.2-2.2c.27-.27.36-.67.24-1.02-.37-1.12-.57-2.33-.57-3.57 0-.55.17-1.45.5-2.53z"/></svg>
|
||||
<span>諮詢電話: 02 5570 0527</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<svg class="info-icon" viewBox="0 0 24 24"><path fill="currentColor" d="M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/></svg>
|
||||
<span>Email: <a href="mailto:enchuntaiwan@gmail.com">enchuntaiwan@gmail.com</a></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="message">訊息</label>
|
||||
<textarea id="message" name="message" required></textarea>
|
||||
</div>
|
||||
<button type="submit">送出</button>
|
||||
</form>
|
||||
<div class="contact-info">
|
||||
<p>諮詢電話: 02 5570 0527</p>
|
||||
<p>電子郵件: <a href="mailto:enchuntaiwan@gmail.com">enchuntaiwan@gmail.com</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</Layout>
|
||||
|
||||
<script>
|
||||
// Basic form handler - would integrate with Cloudflare Worker
|
||||
document.getElementById('contact-form')?.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
// Submit to Cloudflare Worker
|
||||
alert('Form submitted (placeholder)');
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Contact Section Styles - Pixel-perfect from Webflow */
|
||||
.contact-section {
|
||||
padding: 40px 0;
|
||||
padding: 4rem 0;
|
||||
background: var(--color-background);
|
||||
scroll-margin-top: 80px;
|
||||
}
|
||||
.container {
|
||||
max-width: 800px;
|
||||
|
||||
.contactus_wrapper {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 3rem;
|
||||
align-items: center;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
h1 {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
input, textarea {
|
||||
|
||||
/* Form Side */
|
||||
.contact_form_wrapper {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
textarea {
|
||||
height: 150px;
|
||||
|
||||
/* Headings */
|
||||
.contact_head {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
button {
|
||||
background: #007bff;
|
||||
|
||||
.contact_parafraph {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 400;
|
||||
line-height: 1.6;
|
||||
color: var(--color-gray-600);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.contact_reminder {
|
||||
font-size: 0.875rem;
|
||||
font-style: italic;
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
/* Form Container */
|
||||
.contact_form {
|
||||
padding: 2rem;
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
/* Form Grid */
|
||||
.contact-form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
/* Field Wrapper */
|
||||
.contact_field_wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.contact_field_wrapper:last-child {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
/* Field Label */
|
||||
.contact_field_name {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: 0.5rem;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.contact_field_name span {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* Input Fields */
|
||||
.input_field {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
background: var(--color-background);
|
||||
transition: all var(--transition-fast);
|
||||
font-family: inherit;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.input_field:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px rgba(56, 152, 236, 0.1);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.input_field::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.input_field.error {
|
||||
border-color: #dc3545 !important;
|
||||
background-color: #fff5f5;
|
||||
}
|
||||
|
||||
#Message {
|
||||
min-height: 120px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
/* Error Message */
|
||||
.error-message {
|
||||
color: #dc3545;
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.25rem;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.error-message:not(:empty) {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Submit Button */
|
||||
.submit-button {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 4px;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.875rem 2rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:hover {
|
||||
background: #0056b3;
|
||||
}
|
||||
.contact-info {
|
||||
margin-top: 40px;
|
||||
transition: all var(--transition-fast);
|
||||
text-align: center;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 200px;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
.submit-button:hover:not(:disabled) {
|
||||
background: var(--color-primary-hover);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.submit-button:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.submit-button:disabled {
|
||||
background: var(--color-gray-500);
|
||||
cursor: not-allowed;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Form Success/Error Messages */
|
||||
.w-form-done,
|
||||
.w-form-fail {
|
||||
padding: 1rem 1.5rem;
|
||||
border-radius: var(--radius);
|
||||
margin-top: 1rem;
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
.w-form-done {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
|
||||
.w-form-fail {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(1rem);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Image Side */
|
||||
.contact-image {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.image-wrapper {
|
||||
width: 100%;
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-md);
|
||||
background: var(--color-gray-100);
|
||||
aspect-ratio: 3/2;
|
||||
}
|
||||
|
||||
.image-wrapper img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* Contact Info Card */
|
||||
.contact-info-card {
|
||||
width: 100%;
|
||||
padding: 1.5rem;
|
||||
background: white;
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.contact-info-card h3 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.95rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.info-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.info-item a {
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.info-item a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Responsive Adjustments */
|
||||
@media (max-width: 991px) {
|
||||
.contactus_wrapper {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.contact_head {
|
||||
font-size: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.contact-section {
|
||||
padding: 2rem 0;
|
||||
}
|
||||
|
||||
.contact_form {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.contact-form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.submit-button {
|
||||
min-width: 160px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 479px) {
|
||||
.contact_form {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.contact_head {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Form validation and submission handler
|
||||
function initContactForm() {
|
||||
const form = document.getElementById('contact-form') as HTMLFormElement
|
||||
const submitBtn = document.getElementById('submit-btn') as HTMLButtonElement
|
||||
const successMsg = document.getElementById('form-success') as HTMLElement
|
||||
const errorMsg = document.getElementById('form-error') as HTMLElement
|
||||
|
||||
if (!form) return
|
||||
|
||||
// Validation patterns
|
||||
const patterns = {
|
||||
Name: /^[\u4e00-\u9fa5a-zA-Z\s]{2,256}$/,
|
||||
Phone: /^[0-9\-\s\+]{6,20}$/,
|
||||
Email: /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/,
|
||||
Message: /^.{10,5000}$/
|
||||
}
|
||||
|
||||
// Validation function
|
||||
function validateField(input: HTMLInputElement | HTMLTextAreaElement): boolean {
|
||||
const name = input.name
|
||||
const value = input.value.trim()
|
||||
const errorSpan = document.getElementById(`${name}-error`) as HTMLElement
|
||||
|
||||
if (!input.hasAttribute('required') && !value) {
|
||||
clearError(input, errorSpan)
|
||||
return true
|
||||
}
|
||||
|
||||
let isValid = true
|
||||
let errorMessage = ''
|
||||
|
||||
// Required check
|
||||
if (input.hasAttribute('required') && !value) {
|
||||
isValid = false
|
||||
errorMessage = '此欄位為必填'
|
||||
}
|
||||
// Pattern validation
|
||||
else if (patterns[name as keyof typeof patterns] && !patterns[name as keyof typeof patterns].test(value)) {
|
||||
isValid = false
|
||||
switch (name) {
|
||||
case 'Name':
|
||||
errorMessage = '請輸入有效的姓名(至少 2 個字元)'
|
||||
break
|
||||
case 'Phone':
|
||||
errorMessage = '請輸入有效的電話號碼'
|
||||
break
|
||||
case 'Email':
|
||||
errorMessage = '請輸入有效的 Email 格式'
|
||||
break
|
||||
case 'Message':
|
||||
errorMessage = '訊息至少需要 10 個字元'
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Show/hide error
|
||||
if (!isValid) {
|
||||
input.classList.add('error')
|
||||
if (errorSpan) {
|
||||
errorSpan.textContent = errorMessage
|
||||
errorSpan.style.display = 'block'
|
||||
}
|
||||
} else {
|
||||
clearError(input, errorSpan)
|
||||
}
|
||||
|
||||
return isValid
|
||||
}
|
||||
|
||||
function clearError(input: HTMLInputElement | HTMLTextAreaElement, errorSpan: HTMLElement) {
|
||||
input.classList.remove('error')
|
||||
if (errorSpan) {
|
||||
errorSpan.textContent = ''
|
||||
errorSpan.style.display = 'none'
|
||||
}
|
||||
}
|
||||
|
||||
// Real-time validation on blur
|
||||
form.querySelectorAll('input, textarea').forEach((field) => {
|
||||
field.addEventListener('blur', () => {
|
||||
validateField(field as HTMLInputElement | HTMLTextAreaElement)
|
||||
})
|
||||
|
||||
field.addEventListener('input', () => {
|
||||
const input = field as HTMLInputElement | HTMLTextAreaElement
|
||||
if (input.classList.contains('error')) {
|
||||
validateField(input)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Form submission
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault()
|
||||
|
||||
// Validate all fields
|
||||
const inputs = form.querySelectorAll('input, textarea') as NodeListOf<HTMLInputElement | HTMLTextAreaElement>
|
||||
let isFormValid = true
|
||||
|
||||
inputs.forEach((input) => {
|
||||
if (!validateField(input)) {
|
||||
isFormValid = false
|
||||
}
|
||||
})
|
||||
|
||||
if (!isFormValid) {
|
||||
// Scroll to first error
|
||||
const firstError = form.querySelector('.input_field.error') as HTMLElement
|
||||
if (firstError) {
|
||||
firstError.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Show loading state
|
||||
submitBtn.disabled = true
|
||||
const buttonText = submitBtn.querySelector('.button-text') as HTMLElement
|
||||
const buttonLoading = submitBtn.querySelector('.button-loading') as HTMLElement
|
||||
if (buttonText) buttonText.style.display = 'none'
|
||||
if (buttonLoading) buttonLoading.style.display = 'inline'
|
||||
|
||||
// Hide previous messages
|
||||
successMsg.style.display = 'none'
|
||||
errorMsg.style.display = 'none'
|
||||
|
||||
// Collect form data
|
||||
const formData = new FormData(form)
|
||||
const data = {
|
||||
name: formData.get('Name'),
|
||||
phone: formData.get('Phone'),
|
||||
email: formData.get('Email'),
|
||||
message: formData.get('Message')
|
||||
}
|
||||
|
||||
try {
|
||||
// Submit to backend (via API proxy)
|
||||
const response = await fetch('/api/contact', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
// Success
|
||||
successMsg.style.display = 'block'
|
||||
form.reset()
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
} else {
|
||||
throw new Error('Submission failed')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Form submission error:', error)
|
||||
errorMsg.style.display = 'block'
|
||||
} finally {
|
||||
// Reset button state
|
||||
submitBtn.disabled = false
|
||||
if (buttonText) buttonText.style.display = 'inline'
|
||||
if (buttonLoading) buttonLoading.style.display = 'none'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Initialize when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', initContactForm)
|
||||
if (document.readyState !== 'loading') {
|
||||
initContactForm()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,66 +1,56 @@
|
||||
---
|
||||
import Layout from "../layouts/Layout.astro";
|
||||
import VideoHero from "../components/videoHero.astro";
|
||||
/**
|
||||
* Homepage - Main landing page
|
||||
* Fetches data from Payload CMS and renders all sections
|
||||
* Pixel-perfect implementation based on Webflow design
|
||||
*/
|
||||
import Layout from '../layouts/Layout.astro'
|
||||
import HeroSection from '../sections/HeroSection.astro'
|
||||
import PainpointSection from '../sections/PainpointSection.astro'
|
||||
import StatisticsSection from '../sections/StatisticsSection.astro'
|
||||
// ServiceFeatures - REMOVED: Services should only appear in Footer, not as a main section
|
||||
// import ServiceFeatures from '../sections/ServiceFeatures.astro'
|
||||
import ClientCasesSection from '../sections/ClientCasesSection.astro'
|
||||
import PortfolioPreview from '../sections/PortfolioPreview.astro'
|
||||
import CTASection from '../sections/CTASection.astro'
|
||||
import { getHomepageData } from '../lib/api/home'
|
||||
import type { HomeData, PortfolioItem } from '../lib/api/home'
|
||||
|
||||
// Fetch data from Payload CMS
|
||||
let homeData: HomeData | null = null
|
||||
let portfolioItems: PortfolioItem[] = []
|
||||
|
||||
try {
|
||||
const data = await getHomepageData()
|
||||
homeData = data.home
|
||||
portfolioItems = data.portfolioItems || []
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch homepage data:', error)
|
||||
}
|
||||
|
||||
// Metadata for SEO
|
||||
const title = '恩群數位行銷 | 專業數位行銷服務'
|
||||
const description = '恩群數位行銷提供專業的 Google Ads、社群代操、論壇行銷、網站設計等全方位數位行銷服務,協助您的品牌在數位時代脫穎而出。'
|
||||
---
|
||||
|
||||
<Layout>
|
||||
<!-- Hero Section -->
|
||||
<VideoHero
|
||||
desktopVideo="/video/enchun-hero-background-video.mp4"
|
||||
mobileVideo="/video/enchun-hero-background-video.webm"
|
||||
logo="/enchun-logo-full.svg"
|
||||
header="創造企業更多發展的可能性\n是我們的使命"
|
||||
subheader="Its our destiny to create possibilities for your business."
|
||||
/>
|
||||
<Layout title={title} description={description}>
|
||||
<!-- Hero Section -->
|
||||
<HeroSection homeData={homeData} />
|
||||
|
||||
<!-- Services Section -->
|
||||
<section class="py-16 bg-surface">
|
||||
<div class="max-w-6xl mx-auto px-4">
|
||||
<h2 class="text-3xl font-bold text-center text-text mb-12">
|
||||
我們的服務
|
||||
</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<div class="bg-white p-6 rounded-lg shadow-md">
|
||||
<h3 class="text-xl font-semibold text-primary mb-4">
|
||||
Google Ads
|
||||
</h3>
|
||||
<p class="text-text">
|
||||
專業的Google廣告投放服務,幫助您的品牌觸及目標客戶。
|
||||
</p>
|
||||
</div>
|
||||
<div class="bg-white p-6 rounded-lg shadow-md">
|
||||
<h3 class="text-xl font-semibold text-primary mb-4">
|
||||
社群行銷
|
||||
</h3>
|
||||
<p class="text-text">
|
||||
全方位社群媒體經營,從內容策劃到數據分析,一站式服務。
|
||||
</p>
|
||||
</div>
|
||||
<div class="bg-white p-6 rounded-lg shadow-md">
|
||||
<h3 class="text-xl font-semibold text-primary mb-4">
|
||||
網站設計
|
||||
</h3>
|
||||
<p class="text-text">
|
||||
現代化響應式網站設計,提升品牌形象和用戶體驗。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Painpoint Section (您的困擾) -->
|
||||
<PainpointSection homeData={homeData} />
|
||||
|
||||
<!-- About Section -->
|
||||
<section class="py-16">
|
||||
<div class="max-w-6xl mx-auto px-4 text-center">
|
||||
<h2 class="text-3xl font-bold text-text mb-8">關於恩群</h2>
|
||||
<p class="text-lg text-text max-w-3xl mx-auto">
|
||||
恩群數位行銷團隊擁有豐富的數位行銷經驗,我們相信在地化優先、高投資轉換率、數據優先、關係優於銷售。
|
||||
每一個客戶都是我們重視的夥伴,我們珍惜與客戶的合作關係。
|
||||
</p>
|
||||
<a
|
||||
href="/about-enchun"
|
||||
class="inline-block mt-6 bg-primary text-white px-6 py-3 rounded-lg font-semibold hover:bg-primary/90 transition-colors"
|
||||
>了解更多</a
|
||||
>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Statistics Section (數據統計) -->
|
||||
<StatisticsSection homeData={homeData} />
|
||||
|
||||
<!-- Service Features - REMOVED: Services should only appear in Footer, not as a main section -->
|
||||
|
||||
<!-- Client Cases Section (客戶案例) -->
|
||||
<ClientCasesSection homeData={homeData} />
|
||||
|
||||
<!-- Portfolio Preview Section -->
|
||||
<PortfolioPreview homeData={homeData} portfolioItems={portfolioItems} />
|
||||
|
||||
<!-- CTA Section -->
|
||||
<CTASection homeData={homeData} />
|
||||
</Layout>
|
||||
|
||||
@@ -1,43 +1,24 @@
|
||||
---
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
/**
|
||||
* Marketing Solutions Page - 行銷方案頁面
|
||||
* Pixel-perfect implementation based on Webflow design
|
||||
*/
|
||||
import Layout from '../layouts/Layout.astro'
|
||||
import SolutionsHero from '../sections/SolutionsHero.astro'
|
||||
import ServicesList from '../sections/ServicesList.astro'
|
||||
|
||||
// Metadata for SEO
|
||||
const title = '行銷解決方案 | 恩群數位行銷'
|
||||
const description = '恩群數位行銷提供全方位的數位行銷服務,包括 Google Ads、社群代操、論壇行銷、網紅行銷、網站設計等,協助您的品牌在數位時代脫穎而出。'
|
||||
---
|
||||
|
||||
<Layout>
|
||||
<section class="solutions-section">
|
||||
<div class="container">
|
||||
<h1>行銷方案</h1>
|
||||
<p>我們提供多樣化的行銷方案,幫助您的品牌深入人心。</p>
|
||||
<ul>
|
||||
<li>Google 商家關鍵字</li>
|
||||
<li>Google Ads</li>
|
||||
<li>社群代操</li>
|
||||
<li>論壇行銷</li>
|
||||
<li>網紅行銷</li>
|
||||
<li>形象影片</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
<Layout title={title} description={description}>
|
||||
<!-- Hero Section -->
|
||||
<SolutionsHero
|
||||
title="行銷解決方案"
|
||||
subtitle="提供全方位的數位行銷服務,協助您的品牌在數位時代脫穎而出"
|
||||
/>
|
||||
|
||||
<!-- Services List -->
|
||||
<ServicesList />
|
||||
</Layout>
|
||||
|
||||
<style>
|
||||
.solutions-section {
|
||||
padding: 40px 0;
|
||||
}
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
h1 {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
li {
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
</style>
|
||||
@@ -1,61 +1,289 @@
|
||||
---
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
/**
|
||||
* News/Blog Listing Page - 行銷放大鏡
|
||||
* URL: /news
|
||||
* Displays all published blog posts from Payload CMS
|
||||
*/
|
||||
import Layout from '../layouts/Layout.astro'
|
||||
import ArticleCard from '../components/blog/ArticleCard.astro'
|
||||
import CategoryFilter from '../components/blog/CategoryFilter.astro'
|
||||
import { fetchPosts, fetchCategories } from '../lib/api/blog'
|
||||
|
||||
// Placeholder for blog posts - would fetch from CMS
|
||||
const posts = [
|
||||
{ slug: 'en-qun-shu-wei-zui-xin-gong-gao', title: '恩群數位最新公告', date: '2023-01-01' },
|
||||
{ slug: 'google-xiao-xue-tang', title: 'Google小學堂', date: '2023-01-02' },
|
||||
// Add more
|
||||
];
|
||||
// Metadata for SEO
|
||||
const title = '行銷放大鏡 | 恩群數位行銷'
|
||||
const description = '閱讀恩群數位的專業行銷文章,掌握最新的數位行銷趨勢、社群經營技巧、Google 廣告策略等實用內容。'
|
||||
|
||||
// Pagination settings
|
||||
const PAGE_SIZE = 12
|
||||
|
||||
// Get query parameters
|
||||
const url = Astro.url
|
||||
const pageParam = url.searchParams.get('page')
|
||||
const page = Math.max(1, parseInt(pageParam || '1'))
|
||||
|
||||
// Fetch posts from Payload CMS
|
||||
const postsData = await fetchPosts(page, PAGE_SIZE)
|
||||
const posts = postsData.docs
|
||||
const totalPages = postsData.totalPages
|
||||
const hasPreviousPage = postsData.hasPreviousPage
|
||||
const hasNextPage = postsData.hasNextPage
|
||||
|
||||
// Fetch categories
|
||||
const categories = await fetchCategories()
|
||||
|
||||
// Filter out categories without slugs and the legacy "文章分類" container
|
||||
const validCategories = categories.filter(c =>
|
||||
c.slug && c.slug !== 'wen-zhang-fen-lei'
|
||||
)
|
||||
---
|
||||
|
||||
<Layout>
|
||||
<section class="news-section">
|
||||
<Layout title={title} description={description}>
|
||||
<!-- Blog Hero Section -->
|
||||
<section class="blog-hero" aria-labelledby="blog-heading">
|
||||
<div class="container">
|
||||
<h1>行銷放大鏡</h1>
|
||||
<div class="posts-grid">
|
||||
{posts.map(post => (
|
||||
<article class="post-card">
|
||||
<h2><a href={`/wen-zhang-fen-lei/${post.slug}`}>{post.title}</a></h2>
|
||||
<p>{post.date}</p>
|
||||
</article>
|
||||
))}
|
||||
<div class="section_header_w_line">
|
||||
<div class="divider_line"></div>
|
||||
<div class="header_subtitle">
|
||||
<h2 id="blog-heading" class="header_subtitle_head">行銷放大鏡</h2>
|
||||
<p class="header_subtitle_paragraph">Marketing Blog</p>
|
||||
</div>
|
||||
<div class="divider_line"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Category Filter -->
|
||||
<section class="filter-section">
|
||||
<div class="container">
|
||||
<CategoryFilter categories={validCategories} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Blog Posts Grid -->
|
||||
<section class="blog-section" aria-label="文章列表">
|
||||
<div class="container">
|
||||
{
|
||||
posts.length > 0 ? (
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-10 w-full max-w-[1200px] mx-auto">
|
||||
{posts.map((post) => (
|
||||
<ArticleCard post={post} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div class="empty-state">
|
||||
<p class="empty-text">暫無文章</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
<!-- Pagination -->
|
||||
{
|
||||
totalPages > 1 && (
|
||||
<nav class="pagination" aria-label="分頁導航">
|
||||
<div class="pagination-container">
|
||||
{
|
||||
hasPreviousPage && (
|
||||
<a
|
||||
href={`?page=${page - 1}`}
|
||||
class="pagination-link pagination-link-prev"
|
||||
aria-label="上一頁"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
|
||||
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/>
|
||||
</svg>
|
||||
上一頁
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
<div class="pagination-info">
|
||||
<span class="current-page">{page}</span>
|
||||
<span class="page-separator">/</span>
|
||||
<span class="total-pages">{totalPages}</span>
|
||||
</div>
|
||||
|
||||
{
|
||||
hasNextPage && (
|
||||
<a
|
||||
href={`?page=${page + 1}`}
|
||||
class="pagination-link pagination-link-next"
|
||||
aria-label="下一頁"
|
||||
>
|
||||
下一頁
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
|
||||
<path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/>
|
||||
</svg>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
</Layout>
|
||||
|
||||
<style>
|
||||
.news-section {
|
||||
padding: 40px 0;
|
||||
/* Blog Hero Section */
|
||||
.blog-hero {
|
||||
padding: 60px 20px;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
h1 {
|
||||
|
||||
.section_header_w_line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.header_subtitle {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.posts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
|
||||
.header_subtitle_head {
|
||||
color: var(--color-enchunblue, #23608c);
|
||||
font-family: "Noto Sans TC", sans-serif;
|
||||
font-weight: 700;
|
||||
font-size: 2rem;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.post-card {
|
||||
border: 1px solid #ddd;
|
||||
padding: 20px;
|
||||
|
||||
.header_subtitle_paragraph {
|
||||
color: var(--color-gray-600, #666);
|
||||
font-family: "Quicksand", "Noto Sans TC", sans-serif;
|
||||
font-weight: 400;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.divider_line {
|
||||
width: 40px;
|
||||
height: 2px;
|
||||
background-color: var(--color-enchunblue, #23608c);
|
||||
}
|
||||
|
||||
/* Filter Section */
|
||||
.filter-section {
|
||||
background-color: #ffffff;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
/* Blog Section */
|
||||
.blog-section {
|
||||
padding: 40px 20px 60px;
|
||||
background-color: #f8f9fa;
|
||||
min-height: 60vh;
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 80px 20px;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 1.125rem;
|
||||
color: var(--color-gray-500, #999);
|
||||
}
|
||||
|
||||
/* Pagination */
|
||||
.pagination {
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.pagination-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.pagination-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 20px;
|
||||
background: #ffffff;
|
||||
border: 2px solid var(--color-gray-300, #e0e0e0);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.post-card h2 {
|
||||
margin-top: 0;
|
||||
}
|
||||
.post-card a {
|
||||
color: var(--color-gray-700, #555);
|
||||
font-family: "Noto Sans TC", sans-serif;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
color: #333;
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
.post-card a:hover {
|
||||
color: #007bff;
|
||||
|
||||
.pagination-link:hover {
|
||||
border-color: var(--color-enchunblue, #23608c);
|
||||
color: var(--color-enchunblue, #23608c);
|
||||
background: rgba(35, 96, 140, 0.05);
|
||||
}
|
||||
</style>
|
||||
|
||||
.pagination-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-family: "Quicksand", sans-serif;
|
||||
font-size: 1rem;
|
||||
color: var(--color-gray-600, #666);
|
||||
}
|
||||
|
||||
.current-page {
|
||||
font-weight: 700;
|
||||
color: var(--color-enchunblue, #23608c);
|
||||
}
|
||||
|
||||
.page-separator {
|
||||
color: var(--color-gray-400, #ccc);
|
||||
}
|
||||
|
||||
/* Responsive Adjustments */
|
||||
@media (max-width: 991px) {
|
||||
.header_subtitle_head {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.blog-hero {
|
||||
padding: 40px 16px;
|
||||
}
|
||||
|
||||
.section_header_w_line {
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.divider_line {
|
||||
width: 30px;
|
||||
}
|
||||
|
||||
.blog-section {
|
||||
padding: 30px 16px 50px;
|
||||
}
|
||||
|
||||
.pagination-container {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.pagination-link {
|
||||
padding: 8px 16px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.header_subtitle_head {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.header_subtitle_paragraph {
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,28 +1,181 @@
|
||||
---
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
/**
|
||||
* Teams Page - 恩群大本營
|
||||
* 展示工作環境、公司故事和員工福利
|
||||
* Pixel-perfect implementation based on Webflow design
|
||||
*/
|
||||
import Layout from '../layouts/Layout.astro'
|
||||
import TeamsHero from '../sections/TeamsHero.astro'
|
||||
import EnvironmentSlider from '../sections/EnvironmentSlider.astro'
|
||||
import CompanyStory from '../sections/CompanyStory.astro'
|
||||
import BenefitsSection from '../sections/BenefitsSection.astro'
|
||||
|
||||
// Metadata for SEO
|
||||
const title = '恩群大本營 | 恩群數位行銷'
|
||||
const description = '加入恩群數位的團隊,享受優質的工作環境與完善的員工福利。我們重視個人的特質發揮,歡迎樂於學習、善於建立關係的你加入我們。'
|
||||
---
|
||||
|
||||
<Layout>
|
||||
<section class="teams-section">
|
||||
<div class="container">
|
||||
<h1>恩群大本營</h1>
|
||||
<p>認識我們的團隊成員。</p>
|
||||
<!-- Team members would be listed here -->
|
||||
<Layout title={title} description={description}>
|
||||
<!-- Hero Section -->
|
||||
<TeamsHero />
|
||||
|
||||
<!-- Environment Slider Section -->
|
||||
<EnvironmentSlider />
|
||||
|
||||
<!-- Company Story Section -->
|
||||
<CompanyStory />
|
||||
|
||||
<!-- Benefits Section -->
|
||||
<BenefitsSection />
|
||||
|
||||
<!-- CTA Section -->
|
||||
<section class="section-call4action" aria-labelledby="cta-heading">
|
||||
<div class="container w-container">
|
||||
<div class="c4a-grid">
|
||||
<div class="c4a-content">
|
||||
<h3 id="cta-heading" class="career-c4a-heading">
|
||||
以人的成長為優先<br />
|
||||
創造人的最大價值
|
||||
</h3>
|
||||
<p class="career-c4a-paragraph">
|
||||
在恩群數位裡我們重視個人的特質能夠完全發揮,只要你樂於學習、善於跟人建立關係,並且重要的是你有一個善良的心,恩群數位歡迎你的加入
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href="https://www.104.com.tw/company/1a2x6bkoaj?jobsource=joblist_r_cust"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="c4a-button"
|
||||
>
|
||||
立刻申請面試
|
||||
</a>
|
||||
<div class="c4a-bg"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</Layout>
|
||||
|
||||
<style>
|
||||
.teams-section {
|
||||
padding: 40px 0;
|
||||
/* CTA Section Styles - Pixel-perfect from Webflow */
|
||||
.section-call4action {
|
||||
padding: 80px 20px;
|
||||
text-align: center;
|
||||
background-color: var(--color-gray-100, #f8f9fa);
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
h1 {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
|
||||
.w-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
.c4a-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 32px;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.c4a-content {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.career-c4a-heading {
|
||||
font-family: "Noto Sans TC", sans-serif;
|
||||
font-weight: 600;
|
||||
font-size: 1.75rem;
|
||||
color: var(--color-tarawera, #23608c);
|
||||
margin-bottom: 16px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.career-c4a-paragraph {
|
||||
font-family: "Noto Sans TC", sans-serif;
|
||||
font-weight: 400;
|
||||
font-size: 1rem;
|
||||
color: var(--color-gray-600);
|
||||
line-height: 1.6;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.c4a-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--color-enchunblue);
|
||||
color: white;
|
||||
padding: 16px 32px;
|
||||
border-radius: var(--radius, 8px);
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
text-decoration: none;
|
||||
transition: all var(--transition-base, 0.3s ease);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.c4a-button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-md, 0 4px 6px rgba(0, 0, 0, 0.1));
|
||||
background-color: var(--color-enchunblue-hover, #1a4d6e);
|
||||
}
|
||||
|
||||
.c4a-bg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(135deg, rgba(35, 96, 140, 0.05) 0%, rgba(35, 96, 140, 0.02) 100%);
|
||||
border-radius: var(--radius-lg, 12px);
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
/* Responsive Adjustments */
|
||||
@media (max-width: 991px) {
|
||||
.section-call4action {
|
||||
padding: 60px 16px;
|
||||
}
|
||||
|
||||
.c4a-grid {
|
||||
grid-template-columns: 1fr;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.c4a-content {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.career-c4a-paragraph {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.c4a-button {
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.section-call4action {
|
||||
padding: 40px 16px;
|
||||
}
|
||||
|
||||
.career-c4a-heading {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.career-c4a-paragraph {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.c4a-button {
|
||||
padding: 14px 24px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
345
apps/frontend/src/pages/web-portfolios/[slug].astro
Normal file
345
apps/frontend/src/pages/web-portfolios/[slug].astro
Normal file
@@ -0,0 +1,345 @@
|
||||
---
|
||||
/**
|
||||
* Portfolio Detail Page - 作品詳情頁
|
||||
* Displays full project information with live website link
|
||||
* Pixel-perfect implementation based on Webflow design
|
||||
*/
|
||||
import Layout from '../../layouts/Layout.astro'
|
||||
import { fetchPortfolioBySlug, getWebsiteTypeLabel } from '../../lib/api/portfolio'
|
||||
|
||||
// Get slug from params
|
||||
const { slug } = Astro.params
|
||||
|
||||
// Validate slug
|
||||
if (!slug) {
|
||||
return Astro.redirect('/404')
|
||||
}
|
||||
|
||||
// Fetch portfolio item
|
||||
const portfolio = await fetchPortfolioBySlug(slug)
|
||||
|
||||
// Handle 404 for non-existent portfolio
|
||||
if (!portfolio) {
|
||||
return Astro.redirect('/404')
|
||||
}
|
||||
|
||||
// Get website type label
|
||||
const typeLabel = getWebsiteTypeLabel(portfolio.websiteType)
|
||||
|
||||
// Get tags
|
||||
const tags = portfolio.tags?.map(t => t.tag) || []
|
||||
|
||||
// SEO metadata
|
||||
const title = `${portfolio.title} | 恩群數位案例分享`
|
||||
const description = portfolio.description || `瀏覽 ${portfolio.title} 專案詳情`
|
||||
---
|
||||
|
||||
<Layout title={title} description={description}>
|
||||
<article class="portfolio-detail">
|
||||
<div class="container">
|
||||
<!-- Back Link -->
|
||||
<a href="/portfolio" class="back-link">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
|
||||
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/>
|
||||
</svg>
|
||||
返回作品列表
|
||||
</a>
|
||||
|
||||
<!-- Project Header -->
|
||||
<header class="project-header">
|
||||
<div class="project-meta">
|
||||
<span class="project-type-badge">{typeLabel}</span>
|
||||
</div>
|
||||
<h1 class="project-title">{portfolio.title}</h1>
|
||||
|
||||
{
|
||||
portfolio.description && (
|
||||
<p class="project-description">{portfolio.description}</p>
|
||||
)
|
||||
}
|
||||
|
||||
<!-- Tags -->
|
||||
{
|
||||
tags.length > 0 && (
|
||||
<div class="project-tags">
|
||||
{tags.map((tag) => (
|
||||
<span class="tag">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</header>
|
||||
|
||||
<!-- Hero Image -->
|
||||
{
|
||||
portfolio.image?.url && (
|
||||
<div class="project-hero-image">
|
||||
<img
|
||||
src={portfolio.image.url}
|
||||
alt={portfolio.image.alt || portfolio.title}
|
||||
loading="eager"
|
||||
width="1200"
|
||||
height="675"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
<!-- Project Content -->
|
||||
<div class="project-content">
|
||||
<div class="content-section">
|
||||
<h2>專案介紹</h2>
|
||||
<p>
|
||||
此專案展示了我們在{typeLabel}領域的專業能力。
|
||||
我們致力於為客戶打造符合品牌定位、使用體驗優良的數位產品。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="content-section">
|
||||
<h2>專案連結</h2>
|
||||
{
|
||||
portfolio.url ? (
|
||||
<a
|
||||
href={portfolio.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="btn-primary"
|
||||
>
|
||||
前往網站
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
|
||||
<path d="M19 19H5V5h7V3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2v-7h-2v7zM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z"/>
|
||||
</svg>
|
||||
</a>
|
||||
) : (
|
||||
<p class="text-muted">此專案暫無公開連結</p>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CTA Section -->
|
||||
<div class="detail-cta">
|
||||
<h3>喜歡這個作品嗎?</h3>
|
||||
<p>讓我們一起為您的品牌打造獨特的數位體驗</p>
|
||||
<a href="/contact-us" class="cta-button">
|
||||
聯絡我們
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</Layout>
|
||||
|
||||
<style>
|
||||
/* Portfolio Detail */
|
||||
.portfolio-detail {
|
||||
padding: 60px 20px;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Back Link */
|
||||
.back-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 2rem;
|
||||
color: var(--color-enchunblue, #23608c);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: color 0.25s ease;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
color: var(--color-enchunblue-hover, #1a4d6e);
|
||||
}
|
||||
|
||||
/* Project Header */
|
||||
.project-header {
|
||||
text-align: center;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.project-meta {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.project-type-badge {
|
||||
display: inline-block;
|
||||
background: var(--color-enchunblue, #23608c);
|
||||
color: white;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.project-title {
|
||||
font-family: "Noto Sans TC", sans-serif;
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-dark-blue, #1a1a1a);
|
||||
margin-bottom: 1rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.project-description {
|
||||
font-family: "Noto Sans TC", sans-serif;
|
||||
font-size: 1.125rem;
|
||||
color: var(--color-gray-600, #666);
|
||||
max-width: 700px;
|
||||
margin: 0 auto 1.5rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.project-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.tag {
|
||||
background: var(--color-gray-100, #f5f5f5);
|
||||
color: var(--color-gray-700, #4a5568);
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Hero Image */
|
||||
.project-hero-image {
|
||||
margin-bottom: 3rem;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.project-hero-image img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Project Content */
|
||||
.project-content {
|
||||
display: grid;
|
||||
gap: 2.5rem;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.content-section h2 {
|
||||
font-family: "Noto Sans TC", sans-serif;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-dark-blue, #1a1a1a);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.content-section p {
|
||||
color: var(--color-gray-600, #666);
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: var(--color-enchunblue, #23608c);
|
||||
color: white;
|
||||
padding: 0.875rem 1.75rem;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--color-enchunblue-hover, #1a4d6e);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(35, 96, 140, 0.3);
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: var(--color-gray-500, #999);
|
||||
}
|
||||
|
||||
/* Detail CTA */
|
||||
.detail-cta {
|
||||
text-align: center;
|
||||
padding: 3rem 2rem;
|
||||
background: var(--color-gray-50, #f9fafb);
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.detail-cta h3 {
|
||||
font-family: "Noto Sans TC", sans-serif;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-enchunblue, #23608c);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.detail-cta p {
|
||||
color: var(--color-gray-600, #666);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.cta-button {
|
||||
display: inline-block;
|
||||
background: var(--color-enchunblue, #23608c);
|
||||
color: white;
|
||||
padding: 14px 28px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.cta-button:hover {
|
||||
background: var(--color-enchunblue-hover, #1a4d6e);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(35, 96, 140, 0.3);
|
||||
}
|
||||
|
||||
/* Responsive Adjustments */
|
||||
@media (max-width: 991px) {
|
||||
.portfolio-detail {
|
||||
padding: 50px 20px;
|
||||
}
|
||||
|
||||
.project-title {
|
||||
font-size: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.portfolio-detail {
|
||||
padding: 40px 16px;
|
||||
}
|
||||
|
||||
.project-title {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.project-description {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.content-section h2 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.detail-cta {
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
|
||||
.detail-cta h3 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
244
apps/frontend/src/pages/web-portfolios/index.astro
Normal file
244
apps/frontend/src/pages/web-portfolios/index.astro
Normal file
@@ -0,0 +1,244 @@
|
||||
---
|
||||
/**
|
||||
* Portfolio Listing Page - 案例分享列表頁
|
||||
* Displays all portfolio items in a 2-column grid
|
||||
* Pixel-perfect implementation based on Webflow design
|
||||
*/
|
||||
import Layout from '../../layouts/Layout.astro'
|
||||
import PortfolioCard from '../../components/PortfolioCard.astro'
|
||||
import { fetchPortfolios } from '../../lib/api/portfolio'
|
||||
|
||||
// Metadata for SEO
|
||||
const title = '案例分享 | 恩群數位行銷'
|
||||
const description = '瀏覽恩群數位的成功案例,包括企業官網、電商網站、品牌網站等設計作品。'
|
||||
|
||||
// Fetch portfolios from Payload CMS
|
||||
const portfoliosData = await fetchPortfolios(1, 100)
|
||||
const portfolios = portfoliosData.docs
|
||||
---
|
||||
|
||||
<Layout title={title} description={description}>
|
||||
<!-- Portfolio Header -->
|
||||
<section class="portfolio-header" aria-labelledby="portfolio-heading">
|
||||
<div class="container">
|
||||
<div class="section_header_w_line">
|
||||
<div class="divider_line"></div>
|
||||
<div class="header_subtitle">
|
||||
<h2 id="portfolio-heading" class="header_subtitle_head">案例分享</h2>
|
||||
<p class="header_subtitle_paragraph">Our Works</p>
|
||||
</div>
|
||||
<div class="divider_line"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Portfolio Grid -->
|
||||
<section class="portfolio-grid-section" aria-label="作品列表">
|
||||
<div class="container">
|
||||
{
|
||||
portfolios.length > 0 ? (
|
||||
<ul class="portfolio-grid">
|
||||
{
|
||||
portfolios.map((item) => (
|
||||
<PortfolioCard
|
||||
item={{
|
||||
slug: item.slug,
|
||||
title: item.title,
|
||||
description: item.description || '',
|
||||
image: item.image?.url,
|
||||
tags: item.tags?.map(t => t.tag) || [],
|
||||
externalUrl: item.url || undefined,
|
||||
}}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
) : (
|
||||
<div class="empty-state">
|
||||
<p class="empty-text">暫無作品資料</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CTA Section -->
|
||||
<section class="portfolio-cta" aria-labelledby="cta-heading">
|
||||
<div class="container">
|
||||
<h2 id="cta-heading" class="cta-title">
|
||||
有興趣與我們合作嗎?
|
||||
</h2>
|
||||
<p class="cta-description">
|
||||
讓我們一起為您的品牌打造獨特的數位體驗
|
||||
</p>
|
||||
<a href="/contact-us" class="cta-button">
|
||||
聯絡我們
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</Layout>
|
||||
|
||||
<style>
|
||||
/* Portfolio Header */
|
||||
.portfolio-header {
|
||||
padding: 60px 20px;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.section_header_w_line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.header_subtitle {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.header_subtitle_head {
|
||||
color: var(--color-enchunblue, #23608c);
|
||||
font-family: "Noto Sans TC", sans-serif;
|
||||
font-weight: 700;
|
||||
font-size: 2rem;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.header_subtitle_paragraph {
|
||||
color: var(--color-gray-600, #666);
|
||||
font-family: "Quicksand", "Noto Sans TC", sans-serif;
|
||||
font-weight: 400;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.divider_line {
|
||||
width: 40px;
|
||||
height: 2px;
|
||||
background-color: var(--color-enchunblue, #23608c);
|
||||
}
|
||||
|
||||
/* Grid Section */
|
||||
.portfolio-grid-section {
|
||||
padding: 0 20px 60px;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.portfolio-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 20px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 80px 20px;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 1.125rem;
|
||||
color: var(--color-gray-500, #999);
|
||||
}
|
||||
|
||||
/* CTA Section */
|
||||
.portfolio-cta {
|
||||
text-align: center;
|
||||
padding: 80px 20px;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.cta-title {
|
||||
font-family: "Noto Sans TC", sans-serif;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-enchunblue, #23608c);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.cta-description {
|
||||
font-size: 1rem;
|
||||
color: var(--color-gray-600, #666);
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.cta-button {
|
||||
display: inline-block;
|
||||
background-color: var(--color-enchunblue, #23608c);
|
||||
color: white;
|
||||
padding: 16px 32px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.cta-button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(35, 96, 140, 0.3);
|
||||
background-color: var(--color-enchunblue-hover, #1a4d6e);
|
||||
}
|
||||
|
||||
/* Responsive Adjustments */
|
||||
@media (max-width: 991px) {
|
||||
.portfolio-header {
|
||||
padding: 50px 20px;
|
||||
}
|
||||
|
||||
.header_subtitle_head {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.portfolio-grid {
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.portfolio-header {
|
||||
padding: 40px 16px;
|
||||
}
|
||||
|
||||
.header_subtitle_head {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.header_subtitle_paragraph {
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.portfolio-grid-section {
|
||||
padding: 0 16px 40px;
|
||||
}
|
||||
|
||||
.portfolio-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.portfolio-cta {
|
||||
padding: 60px 16px;
|
||||
}
|
||||
|
||||
.cta-title {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.cta-description {
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.cta-button {
|
||||
padding: 14px 24px;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,52 +1,442 @@
|
||||
---
|
||||
import Layout from '../../layouts/Layout.astro';
|
||||
/**
|
||||
* Portfolio Detail Page - 案例詳情頁
|
||||
* Pixel-perfect implementation based on Webflow design
|
||||
*/
|
||||
import Layout from '../../layouts/Layout.astro'
|
||||
|
||||
export async function getStaticPaths() {
|
||||
// Portfolio slugs from sitemap
|
||||
const slugs = [
|
||||
'web-design-project-2',
|
||||
'web-design-project-3',
|
||||
'web-design-project-4',
|
||||
'web-design-project-5'
|
||||
];
|
||||
// Get portfolio items
|
||||
const portfolioItems: Record<string, {
|
||||
title: string
|
||||
client?: string
|
||||
date?: string
|
||||
category?: string
|
||||
description: string
|
||||
images?: string[]
|
||||
externalUrl?: string
|
||||
}> = {
|
||||
'corporate-website-1': {
|
||||
title: '企業官網設計案例',
|
||||
client: '知名製造業公司',
|
||||
date: '2024年1月',
|
||||
category: '企業官網',
|
||||
description: `
|
||||
<p>這是一個為知名製造業公司打造的現代化企業官網專案。客戶希望建立一個能夠有效展示產品系列、公司形象以及最新消息的專業網站。</p>
|
||||
|
||||
return slugs.map(slug => ({
|
||||
params: { slug },
|
||||
props: { slug }
|
||||
}));
|
||||
<h3>專案背景</h3>
|
||||
<p>客戶原有的網站設計過時,難以在手機設備上正常瀏覽,且內容管理不夠靈活。他們需要一個響應式設計的現代化網站,能夠自動適應各種設備,並方便內容團隊更新。</p>
|
||||
|
||||
<h3>解決方案</h3>
|
||||
<p>我們採用了最新的網頁技術,打造了一個完全響應式的企業官網。網站架構清晰,導航直觀,產品展示頁面設計精美,同時整合了新聞發佈功能,讓客戶能夠快速分享公司動態。</p>
|
||||
|
||||
<h3>專案成果</h3>
|
||||
<p>網站上線後,客戶反映使用者停留時間增加了 40%,詢問量也顯著提升。響應式設計讓行動用戶也能獲得良好的瀏覽體驗,整體滿意度非常高。</p>
|
||||
`,
|
||||
images: ['/placeholder-portfolio-1.jpg'],
|
||||
},
|
||||
'ecommerce-site-1': {
|
||||
title: '電商平台建置',
|
||||
client: '精品品牌',
|
||||
date: '2024年2月',
|
||||
category: '電商網站',
|
||||
description: `
|
||||
<p>這是一個為精品品牌打造的 B2C 電商網站專案。客戶需要一個功能完整、設計精緻的線上購物平台。</p>
|
||||
|
||||
<h3>專案背景</h3>
|
||||
<p>客戶希望拓展線上銷售渠道,建立一個能夠完整呈現品牌調性的電商網站。需求包含會員系統、購物車、金流整合、庫存管理等完整功能。</p>
|
||||
|
||||
<h3>解決方案</h3>
|
||||
<p>我們設計了一個簡潔優雅的電商平台,整合了完整的購物流程。從商品瀏覽、加入購物車、結帳到訂單追蹤,整個流程流暢無縫。同時整合了主流金流與物流服務。</p>
|
||||
|
||||
<h3>專案成果</h3>
|
||||
<p>網站上線後首月即達到預期銷售目標,客戶反應購物體驗流暢,設計風格也獲得用戶高度評價。</p>
|
||||
`,
|
||||
images: ['/placeholder-portfolio-2.jpg'],
|
||||
},
|
||||
'brand-website-1': {
|
||||
title: '品牌形象網站',
|
||||
client: '新創品牌',
|
||||
date: '2024年3月',
|
||||
category: '品牌網站',
|
||||
description: `
|
||||
<p>這是一個為新創品牌打造的以視覺故事為核心的品牌網站專案。</p>
|
||||
|
||||
<h3>專案背景</h3>
|
||||
<p>客戶是一個新創品牌,需要一個能夠有效傳達品牌故事、價值理念的網站。他們希望網站不只是資訊展示,更能成為品牌與用戶情感連結的橋樑。</p>
|
||||
|
||||
<h3>解決方案</h3>
|
||||
<p>我們以視覺故事為設計核心,運用大型視覺元素、動畫效果和互動設計,打造了一個充滿故事性的品牌網站。每一個區塊都精心設計,引導用戶深入了解品牌。</p>
|
||||
|
||||
<h3>專案成果</h3>
|
||||
<p>網站成功建立了強烈的品牌印象,訪客平均停留時間超過 3 分鐘,品牌認知度顯著提升。</p>
|
||||
`,
|
||||
images: ['/placeholder-portfolio-3.jpg'],
|
||||
},
|
||||
'landing-page-1': {
|
||||
title: '活動行銷頁面',
|
||||
client: '活動主辦單位',
|
||||
date: '2024年4月',
|
||||
category: '活動頁面',
|
||||
description: `
|
||||
<p>這是一個為大型活動設計的高轉換率行銷頁面專案。</p>
|
||||
|
||||
<h3>專案背景</h3>
|
||||
<p>客戶需要一個能夠有效吸引報名、轉換率高的活動頁面。頁面需要能夠清楚傳達活動資訊、吸引目光,並引導用戶完成報名流程。</p>
|
||||
|
||||
<h3>解決方案</h3>
|
||||
<p>我們設計了一個視覺衝擊力強的活動頁面,CTA 按鈕配置經過精心規劃,報名流程簡化到最少步驟。同時加入倒數計時、名額顯示等促進轉換的元素。</p>
|
||||
|
||||
<h3>專案成果</h3>
|
||||
<p>頁面上線後,報名轉換率達到 15%,遠超過預期目標,活動順利達到滿檔狀態。</p>
|
||||
`,
|
||||
images: ['/placeholder-portfolio-4.jpg'],
|
||||
},
|
||||
}
|
||||
|
||||
const { slug } = Astro.props;
|
||||
export async function getStaticPaths() {
|
||||
const slugs = Object.keys(portfolioItems)
|
||||
|
||||
// Placeholder content
|
||||
const project = {
|
||||
title: 'Web Design Project',
|
||||
description: 'Project description...',
|
||||
images: []
|
||||
};
|
||||
return slugs.map((slug) => ({
|
||||
params: { slug },
|
||||
props: { slug }
|
||||
}))
|
||||
}
|
||||
|
||||
const { slug } = Astro.props
|
||||
const project = portfolioItems[slug] || {
|
||||
title: '作品詳情',
|
||||
description: '<p>暫無內容</p>',
|
||||
images: ['/placeholder-portfolio.jpg'],
|
||||
}
|
||||
|
||||
// Metadata for SEO
|
||||
const title = `${project.title} | 恩群數位案例`
|
||||
const description = project.description.replace(/<[^>]*>/g, '').slice(0, 160)
|
||||
---
|
||||
|
||||
<Layout>
|
||||
<section class="project-section">
|
||||
<Layout title={title} description={description}>
|
||||
<!-- Breadcrumb -->
|
||||
<nav class="breadcrumb" aria-label="麵包屑導航">
|
||||
<div class="container">
|
||||
<h1>{project.title}</h1>
|
||||
<p>{project.description}</p>
|
||||
<!-- Images and details -->
|
||||
<ol class="breadcrumb-list">
|
||||
<li><a href="/">首頁</a></li>
|
||||
<li><a href="/website-portfolio">案例分享</a></li>
|
||||
<li aria-current="page">{project.title}</li>
|
||||
</ol>
|
||||
</div>
|
||||
</section>
|
||||
</nav>
|
||||
|
||||
<!-- Portfolio Detail -->
|
||||
<article class="portfolio-detail">
|
||||
<div class="container">
|
||||
<!-- Header -->
|
||||
<header class="portfolio-detail-header">
|
||||
<h1 class="portfolio-detail-title">{project.title}</h1>
|
||||
|
||||
<div class="portfolio-detail-meta">
|
||||
{
|
||||
project.client && (
|
||||
<span class="meta-item">
|
||||
<strong>客戶:</strong>{project.client}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
{
|
||||
project.date && (
|
||||
<span class="meta-item">
|
||||
<strong>日期:</strong>{project.date}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
{
|
||||
project.category && (
|
||||
<span class="meta-item">
|
||||
<strong>類別:</strong>{project.category}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Featured Image -->
|
||||
{
|
||||
project.images && project.images.length > 0 && (
|
||||
<div class="portfolio-detail-image-wrapper">
|
||||
<img
|
||||
src={project.images[0]}
|
||||
alt={project.title}
|
||||
class="portfolio-detail-image"
|
||||
loading="eager"
|
||||
width="1000"
|
||||
height="562"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
<!-- Description -->
|
||||
<div class="portfolio-detail-content">
|
||||
<div class="description-wrapper" set:html={project.description} />
|
||||
</div>
|
||||
|
||||
<!-- CTA -->
|
||||
<div class="portfolio-detail-cta">
|
||||
<h3 class="cta-heading">有專案想要討論嗎?</h3>
|
||||
<p class="cta-subheading">我們很樂意聽聽您的需求</p>
|
||||
<a href="/contact-us" class="cta-button">
|
||||
聯絡我們
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Related Projects -->
|
||||
<div class="related-projects">
|
||||
<h3 class="related-title">更多案例</h3>
|
||||
<a href="/website-portfolio" class="view-all-link">
|
||||
查看全部案例 →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</Layout>
|
||||
|
||||
<style>
|
||||
.project-section {
|
||||
padding: 40px 0;
|
||||
/* Portfolio Detail Styles - Pixel-perfect from Webflow */
|
||||
|
||||
/* Breadcrumb */
|
||||
.breadcrumb {
|
||||
padding: 16px 20px;
|
||||
background-color: #f8f9fa;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.breadcrumb-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-gray-600);
|
||||
}
|
||||
|
||||
.breadcrumb-list a {
|
||||
color: var(--color-enchunblue);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.breadcrumb-list a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.breadcrumb-list li:not(:last-child)::after {
|
||||
content: '/';
|
||||
margin-left: 8px;
|
||||
color: var(--color-gray-400);
|
||||
}
|
||||
|
||||
/* Detail Section */
|
||||
.portfolio-detail {
|
||||
padding: 60px 20px;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
h1 {
|
||||
|
||||
/* Header */
|
||||
.portfolio-detail-header {
|
||||
margin-bottom: 40px;
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
</style>
|
||||
|
||||
.portfolio-detail-title {
|
||||
font-family: "Noto Sans TC", sans-serif;
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-tarawera, #2d3748);
|
||||
margin-bottom: 24px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.portfolio-detail-meta {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 24px;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-gray-600);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.meta-item strong {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
/* Featured Image */
|
||||
.portfolio-detail-image-wrapper {
|
||||
margin-bottom: 40px;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.portfolio-detail-image {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Content */
|
||||
.portfolio-detail-content {
|
||||
margin-bottom: 60px;
|
||||
}
|
||||
|
||||
.description-wrapper {
|
||||
font-family: "Noto Sans TC", sans-serif;
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.8;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.description-wrapper :global(p) {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.description-wrapper :global(h3) {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-enchunblue);
|
||||
margin-top: 32px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* CTA Section */
|
||||
.portfolio-detail-cta {
|
||||
text-align: center;
|
||||
padding: 60px;
|
||||
background: linear-gradient(135deg, rgba(35, 96, 140, 0.05) 0%, rgba(35, 96, 140, 0.02) 100%);
|
||||
border-radius: 12px;
|
||||
margin-bottom: 60px;
|
||||
}
|
||||
|
||||
.cta-heading {
|
||||
font-family: "Noto Sans TC", sans-serif;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-enchunblue);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.cta-subheading {
|
||||
font-size: 1rem;
|
||||
color: var(--color-gray-600);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.cta-button {
|
||||
display: inline-block;
|
||||
background-color: var(--color-enchunblue);
|
||||
color: white;
|
||||
padding: 16px 32px;
|
||||
border-radius: var(--radius, 8px);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
transition: all var(--transition-base, 0.3s ease);
|
||||
}
|
||||
|
||||
.cta-button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-md, 0 4px 6px rgba(0, 0, 0, 0.1));
|
||||
background-color: var(--color-enchunblue-hover, #1a4d6e);
|
||||
}
|
||||
|
||||
/* Related Projects */
|
||||
.related-projects {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-top: 32px;
|
||||
border-top: 1px solid var(--color-border, #e5e7eb);
|
||||
}
|
||||
|
||||
.related-title {
|
||||
font-family: "Noto Sans TC", sans-serif;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.view-all-link {
|
||||
color: var(--color-enchunblue);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: color var(--transition-fast, 0.2s ease);
|
||||
}
|
||||
|
||||
.view-all-link:hover {
|
||||
color: var(--color-enchunblue-hover, #1a4d6e);
|
||||
}
|
||||
|
||||
/* Responsive Adjustments */
|
||||
@media (max-width: 991px) {
|
||||
.portfolio-detail {
|
||||
padding: 50px 16px;
|
||||
}
|
||||
|
||||
.portfolio-detail-title {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.portfolio-detail-cta {
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.cta-heading {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.breadcrumb {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.breadcrumb-list {
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.portfolio-detail {
|
||||
padding: 40px 16px;
|
||||
}
|
||||
|
||||
.portfolio-detail-title {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.portfolio-detail-meta {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.portfolio-detail-cta {
|
||||
padding: 32px 20px;
|
||||
}
|
||||
|
||||
.related-projects {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.description-wrapper {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.description-wrapper :global(h3) {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,57 +1,225 @@
|
||||
---
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
/**
|
||||
* Portfolio Listing Page - 案例分享列表頁
|
||||
* Pixel-perfect implementation based on Webflow design
|
||||
*/
|
||||
import Layout from '../layouts/Layout.astro'
|
||||
import PortfolioCard from '../components/PortfolioCard.astro'
|
||||
|
||||
// Placeholder portfolios
|
||||
const portfolios = [
|
||||
{ slug: 'web-design-project-2', title: 'Project 2', description: 'Description...' },
|
||||
// Add more
|
||||
];
|
||||
// Metadata for SEO
|
||||
const title = '案例分享 | 恩群數位行銷'
|
||||
const description = '瀏覽恩群數位的成功案例,包括企業官網、電商網站、品牌網站等設計作品。'
|
||||
|
||||
// Portfolio items - can be fetched from Payload CMS
|
||||
const portfolioItems = [
|
||||
{
|
||||
slug: 'corporate-website-1',
|
||||
title: '企業官網設計案例',
|
||||
description: '為知名製造業打造的現代化企業官網,整合產品展示與新聞發佈功能。',
|
||||
image: '/placeholder-portfolio-1.jpg',
|
||||
tags: ['企業官網', '響應式設計'],
|
||||
},
|
||||
{
|
||||
slug: 'ecommerce-site-1',
|
||||
title: '電商平台建置',
|
||||
description: 'B2C 電商網站,包含會員系統、購物車、金流整合等完整功能。',
|
||||
image: '/placeholder-portfolio-2.jpg',
|
||||
tags: ['電商網站', '金流整合'],
|
||||
},
|
||||
{
|
||||
slug: 'brand-website-1',
|
||||
title: '品牌形象網站',
|
||||
description: '以視覺故事為核心的品牌網站,展現品牌獨特價值與理念。',
|
||||
image: '/placeholder-portfolio-3.jpg',
|
||||
tags: ['品牌網站', '視覺設計'],
|
||||
},
|
||||
{
|
||||
slug: 'landing-page-1',
|
||||
title: '活動行銷頁面',
|
||||
description: '高轉換率的活動頁面設計,有效的 CTA 配置與使用者體驗規劃。',
|
||||
image: '/placeholder-portfolio-4.jpg',
|
||||
tags: ['活動頁面', '行銷'],
|
||||
},
|
||||
]
|
||||
---
|
||||
|
||||
<Layout>
|
||||
<section class="portfolio-section">
|
||||
<Layout title={title} description={description}>
|
||||
<!-- Portfolio Header -->
|
||||
<section class="portfolio-header" aria-labelledby="portfolio-heading">
|
||||
<div class="container">
|
||||
<h1>網站設計作品</h1>
|
||||
<div class="portfolio-grid">
|
||||
{portfolios.map(item => (
|
||||
<div class="portfolio-item">
|
||||
<h2><a href={`/webdesign-profolio/${item.slug}`}>{item.title}</a></h2>
|
||||
<p>{item.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<h1 id="portfolio-heading" class="portfolio-title">案例分享</h1>
|
||||
<p class="portfolio-subtitle">Selected Works</p>
|
||||
<div class="divider-line"></div>
|
||||
<div class="divider-line"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Portfolio Grid -->
|
||||
<section class="portfolio-grid-section" aria-label="作品列表">
|
||||
<ul class="portfolio-grid">
|
||||
{
|
||||
portfolioItems.map((item) => (
|
||||
<PortfolioCard item={item} />
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- CTA Section -->
|
||||
<section class="portfolio-cta" aria-labelledby="cta-heading">
|
||||
<div class="container">
|
||||
<h2 id="cta-heading" class="cta-title">
|
||||
有興趣與我們合作嗎?
|
||||
</h2>
|
||||
<p class="cta-description">
|
||||
讓我們一起為您的品牌打造獨特的數位體驗
|
||||
</p>
|
||||
<a href="/contact-us" class="cta-button">
|
||||
聯絡我們
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</Layout>
|
||||
|
||||
<style>
|
||||
.portfolio-section {
|
||||
padding: 40px 0;
|
||||
/* Portfolio Page Styles - Pixel-perfect from Webflow */
|
||||
|
||||
/* Header Section */
|
||||
.portfolio-header {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
h1 {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
|
||||
.portfolio-title {
|
||||
font-family: "Noto Sans TC", sans-serif;
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-enchunblue);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.portfolio-subtitle {
|
||||
font-family: "Quicksand", "Noto Sans TC", sans-serif;
|
||||
font-size: 1.125rem;
|
||||
color: var(--color-gray-700);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.divider-line {
|
||||
width: 100px;
|
||||
height: 2px;
|
||||
background-color: var(--color-enchunblue);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.divider-line:last-child {
|
||||
width: 60px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Grid Section */
|
||||
.portfolio-grid-section {
|
||||
padding: 0 20px 60px;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.portfolio-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 20px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.portfolio-item {
|
||||
border: 1px solid #ddd;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
|
||||
/* CTA Section */
|
||||
.portfolio-cta {
|
||||
text-align: center;
|
||||
padding: 80px 20px;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
.portfolio-item a {
|
||||
|
||||
.cta-title {
|
||||
font-family: "Noto Sans TC", sans-serif;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-enchunblue);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.cta-description {
|
||||
font-size: 1rem;
|
||||
color: var(--color-gray-600);
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.cta-button {
|
||||
display: inline-block;
|
||||
background-color: var(--color-enchunblue);
|
||||
color: white;
|
||||
padding: 16px 32px;
|
||||
border-radius: var(--radius, 8px);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
color: #333;
|
||||
transition: all var(--transition-base, 0.3s ease);
|
||||
}
|
||||
.portfolio-item a:hover {
|
||||
color: #007bff;
|
||||
|
||||
.cta-button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-md, 0 4px 6px rgba(0, 0, 0, 0.1));
|
||||
background-color: var(--color-enchunblue-hover, #1a4d6e);
|
||||
}
|
||||
</style>
|
||||
|
||||
/* Responsive Adjustments */
|
||||
@media (max-width: 991px) {
|
||||
.portfolio-header {
|
||||
padding: 50px 20px;
|
||||
}
|
||||
|
||||
.portfolio-title {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.portfolio-grid {
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.portfolio-header {
|
||||
padding: 40px 16px;
|
||||
}
|
||||
|
||||
.portfolio-title {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.portfolio-subtitle {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.portfolio-grid-section {
|
||||
padding: 0 16px 40px;
|
||||
}
|
||||
|
||||
.portfolio-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.portfolio-cta {
|
||||
padding: 60px 16px;
|
||||
}
|
||||
|
||||
.cta-title {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,79 +1,233 @@
|
||||
---
|
||||
import Layout from '../../layouts/Layout.astro';
|
||||
/**
|
||||
* Category Page - 文章分類
|
||||
* URL: /wen-zhang-fen-lei/[slug]
|
||||
* Displays posts filtered by category from Payload CMS
|
||||
*/
|
||||
import Layout from '../../layouts/Layout.astro'
|
||||
import ArticleCard from '../../components/blog/ArticleCard.astro'
|
||||
import { fetchCategoryBySlug, fetchPosts, formatDate } from '../../lib/api/blog'
|
||||
|
||||
export async function getStaticPaths() {
|
||||
// Category slugs
|
||||
const slugs = [
|
||||
'en-qun-shu-wei-zui-xin-gong-gao',
|
||||
'xing-xiao-shi-shi-zui-qian-xian',
|
||||
'meta-xiao-xue-tang',
|
||||
'google-xiao-xue-tang'
|
||||
];
|
||||
const { slug } = Astro.params
|
||||
|
||||
return slugs.map(slug => ({
|
||||
params: { slug },
|
||||
props: { slug }
|
||||
}));
|
||||
// Fetch category by slug
|
||||
const category = await fetchCategoryBySlug(slug)
|
||||
|
||||
// Handle 404 for non-existent category
|
||||
if (!category) {
|
||||
return Astro.redirect('/404')
|
||||
}
|
||||
|
||||
const { slug } = Astro.props;
|
||||
// Fetch posts for this category (limit 100 for category pages)
|
||||
const PAGE_SIZE = 100
|
||||
const postsData = await fetchPosts(1, PAGE_SIZE, slug)
|
||||
const posts = postsData.docs
|
||||
|
||||
// Placeholder - would fetch category and posts from CMS
|
||||
const category = {
|
||||
name: 'Category Name',
|
||||
posts: [
|
||||
{ slug: 'post1', title: 'Post 1', date: '2023-01-01' }
|
||||
]
|
||||
};
|
||||
// Format date function
|
||||
const formatDateTW = (dateStr: string | null | undefined): string => {
|
||||
if (!dateStr) return ''
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleDateString('zh-TW', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})
|
||||
}
|
||||
---
|
||||
|
||||
<Layout>
|
||||
<section class="category-section">
|
||||
<Layout title={category.title}>
|
||||
<!-- Category Header -->
|
||||
<section class="category-hero" aria-labelledby="category-heading">
|
||||
<div class="container">
|
||||
<h1>{category.name}</h1>
|
||||
<div class="posts-list">
|
||||
{category.posts.map(post => (
|
||||
<article class="post-item">
|
||||
<h2><a href={`/xing-xiao-fang-da-jing/${post.slug}`}>{post.title}</a></h2>
|
||||
<p>{post.date}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
<a href="/news" class="back-link">← 回到文章列表</a>
|
||||
<h1 id="category-heading" class="category-title">{category.title}</h1>
|
||||
{
|
||||
category.nameEn && (
|
||||
<p class="category-subtitle">{category.nameEn}</p>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Category Posts -->
|
||||
<section class="category-posts">
|
||||
<div class="container">
|
||||
{
|
||||
posts.length > 0 ? (
|
||||
<div class="posts-grid">
|
||||
{posts.map((post) => (
|
||||
<article class="post-item">
|
||||
<a href={`/xing-xiao-fang-da-jing/${post.slug}`} class="post-link">
|
||||
{
|
||||
post.heroImage?.url && (
|
||||
<div class="post-image">
|
||||
<img
|
||||
src={post.heroImage.url}
|
||||
alt={post.heroImage.alt || post.title}
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<div class="post-content">
|
||||
<h3 class="post-title">{post.title}</h3>
|
||||
<time class="post-date">{formatDateTW(post.publishedAt)}</time>
|
||||
{
|
||||
post.excerpt && (
|
||||
<p class="post-excerpt">{post.excerpt.slice(0, 120)}...</p>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</a>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div class="empty-state">
|
||||
<p>此分類暫無文章</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
</Layout>
|
||||
|
||||
<style>
|
||||
.category-section {
|
||||
padding: 40px 0;
|
||||
.category-hero {
|
||||
padding: 60px 20px 40px;
|
||||
text-align: center;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1000px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
h1 {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.posts-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
.post-item {
|
||||
border: 1px solid #ddd;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.post-item h2 {
|
||||
margin-top: 0;
|
||||
}
|
||||
.post-item a {
|
||||
|
||||
.back-link {
|
||||
display: inline-block;
|
||||
margin-bottom: 20px;
|
||||
color: var(--color-enchunblue, #23608c);
|
||||
text-decoration: none;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
.post-item a:hover {
|
||||
color: #007bff;
|
||||
|
||||
.category-title {
|
||||
font-family: "Noto Sans TC", sans-serif;
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-dark-blue, #1a1a1a);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
.category-subtitle {
|
||||
font-family: "Quicksand", "Noto Sans TC", sans-serif;
|
||||
font-size: 1rem;
|
||||
color: var(--color-gray-600, #666);
|
||||
}
|
||||
|
||||
.category-posts {
|
||||
padding: 40px 20px 60px;
|
||||
background-color: #f8f9fa;
|
||||
min-height: 60vh;
|
||||
}
|
||||
|
||||
.posts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.post-item {
|
||||
background: #ffffff;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
transition: box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.post-item:hover {
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.post-link {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.post-image {
|
||||
aspect-ratio: 16 / 9;
|
||||
overflow: hidden;
|
||||
background: var(--color-gray-100, #f5f5f5);
|
||||
}
|
||||
|
||||
.post-image img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.post-content {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.post-title {
|
||||
font-family: "Noto Sans TC", sans-serif;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-dark-blue, #1a1a1a);
|
||||
margin-bottom: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.post-date {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-gray-500, #999);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.post-excerpt {
|
||||
font-size: 0.9375rem;
|
||||
color: var(--color-gray-600, #666);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 80px 20px;
|
||||
color: var(--color-gray-500, #999);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 991px) {
|
||||
.posts-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.category-title {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.category-hero {
|
||||
padding: 40px 16px 30px;
|
||||
}
|
||||
|
||||
.category-title {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.posts-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.category-posts {
|
||||
padding: 30px 16px 50px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,66 +1,356 @@
|
||||
---
|
||||
import Layout from '../../layouts/Layout.astro';
|
||||
/**
|
||||
* Blog Article Detail Page - 行銷放大鏡文章詳情
|
||||
* URL: /xing-xiao-fang-da-jing/[slug]
|
||||
* Fetches from Payload CMS and renders with Lexical content
|
||||
*/
|
||||
import Layout from '../../layouts/Layout.astro'
|
||||
import RelatedPosts from '../../components/blog/RelatedPosts.astro'
|
||||
import { fetchPostBySlug, fetchRelatedPosts, formatDate } from '../../lib/api/blog'
|
||||
import { serializeLexical } from '../../lib/serializeLexical'
|
||||
|
||||
export async function getStaticPaths() {
|
||||
// Placeholder slugs - would fetch from CMS
|
||||
const slugs = [
|
||||
'2-zhao-yao-kong-xiao-fei-zhe-de-xin',
|
||||
'2022-jie-qing-xing-xiao-quan-gong-lue',
|
||||
// Add all from sitemap
|
||||
];
|
||||
// Get slug from params
|
||||
const { slug } = Astro.params
|
||||
|
||||
return slugs.map(slug => ({
|
||||
params: { slug },
|
||||
props: { slug }
|
||||
}));
|
||||
// Validate slug
|
||||
if (!slug) {
|
||||
return Astro.redirect('/404')
|
||||
}
|
||||
|
||||
const { slug } = Astro.props;
|
||||
// Fetch post by slug
|
||||
const post = await fetchPostBySlug(slug)
|
||||
|
||||
// Placeholder content - would fetch from CMS
|
||||
const post = {
|
||||
title: 'Sample Post Title',
|
||||
date: 'January 20, 2022',
|
||||
content: 'Sample content...'
|
||||
};
|
||||
// Handle 404 for non-existent or unpublished posts
|
||||
if (!post) {
|
||||
return Astro.redirect('/404')
|
||||
}
|
||||
|
||||
// Fetch related posts
|
||||
const categorySlugs = post.categories?.map(c => c.slug) || []
|
||||
const relatedPosts = await fetchRelatedPosts(post.id, categorySlugs, 4)
|
||||
|
||||
// Format date
|
||||
const displayDate = formatDate(post.publishedAt)
|
||||
|
||||
// Get primary category
|
||||
const category = post.categories?.[0]
|
||||
|
||||
// Serialize Lexical content to HTML (must await the async function)
|
||||
const contentHtml = await serializeLexical(post.content)
|
||||
|
||||
// SEO metadata
|
||||
const metaTitle = post.meta?.title || post.title
|
||||
const metaDescription = post.meta?.description || post.excerpt || ''
|
||||
const metaImage = post.meta?.image?.url || post.ogImage?.url || post.heroImage?.url || ''
|
||||
---
|
||||
|
||||
<Layout>
|
||||
<section class="post-section">
|
||||
<div class="container">
|
||||
<a href="/news" class="back-link">回到文章列表</a>
|
||||
<article>
|
||||
<h1>{post.title}</h1>
|
||||
<p class="post-date">文章發布日期:{post.date}</p>
|
||||
<div class="post-content prose prose-custom max-w-none">
|
||||
<p>{post.content}</p>
|
||||
<!-- More content would be rendered here with markdown -->
|
||||
<Layout title={metaTitle} description={metaDescription}>
|
||||
<article class="article-detail">
|
||||
<!-- Article Header -->
|
||||
<header class="article-header">
|
||||
<div class="container">
|
||||
<!-- Category Badge -->
|
||||
{
|
||||
category && (
|
||||
<span
|
||||
class="article-category"
|
||||
style={`background-color: ${category.backgroundColor || 'var(--color-enchunblue)'}; color: ${category.textColor || '#fff'}`}
|
||||
>
|
||||
{category.title}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
<!-- Article Title -->
|
||||
<h1 class="article-title">
|
||||
{post.title}
|
||||
</h1>
|
||||
|
||||
<!-- Published Date -->
|
||||
{
|
||||
displayDate && (
|
||||
<time class="article-date" datetime={post.publishedAt || post.createdAt}>
|
||||
{displayDate}
|
||||
</time>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Hero Image -->
|
||||
{
|
||||
post.heroImage?.url && (
|
||||
<div class="article-hero-image">
|
||||
<img
|
||||
src={post.heroImage.url}
|
||||
alt={post.heroImage.alt || post.title}
|
||||
loading="eager"
|
||||
width="1200"
|
||||
height="675"
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
<!-- Article Content -->
|
||||
<div class="article-content">
|
||||
<div class="container">
|
||||
<div class="content-wrapper">
|
||||
<!-- Render rich text content with Lexical to HTML conversion -->
|
||||
<div class="prose" set:html={contentHtml} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Back to List Button -->
|
||||
<div class="article-actions">
|
||||
<div class="container">
|
||||
<a href="/news" class="back-button">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
|
||||
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/>
|
||||
</svg>
|
||||
回到文章列表
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<!-- Related Posts Section -->
|
||||
<RelatedPosts posts={relatedPosts} />
|
||||
</Layout>
|
||||
|
||||
<style>
|
||||
.post-section {
|
||||
padding: 40px 0;
|
||||
<!-- Open Graph Tags -->
|
||||
<script define:vars={{ metaImage, metaTitle, metaDescription }}>
|
||||
if (typeof document !== 'undefined') {
|
||||
// Update OG image
|
||||
const ogImage = document.querySelector('meta[property="og:image"]')
|
||||
if (ogImage && metaImage) {
|
||||
ogImage.setAttribute('content', metaImage)
|
||||
}
|
||||
|
||||
// Update OG title
|
||||
const ogTitle = document.querySelector('meta[property="og:title"]')
|
||||
if (ogTitle) {
|
||||
ogTitle.setAttribute('content', metaTitle)
|
||||
}
|
||||
|
||||
// Update OG description
|
||||
const ogDesc = document.querySelector('meta[property="og:description"]')
|
||||
if (ogDesc) {
|
||||
ogDesc.setAttribute('content', metaDescription)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Article Header */
|
||||
.article-header {
|
||||
padding: 60px 20px 40px;
|
||||
text-align: center;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
.back-link {
|
||||
|
||||
.article-category {
|
||||
display: inline-block;
|
||||
margin-bottom: 20px;
|
||||
color: #007bff;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.article-title {
|
||||
font-family: "Noto Sans TC", sans-serif;
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-dark-blue, #1a1a1a);
|
||||
line-height: 1.3;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.article-date {
|
||||
font-family: "Quicksand", "Noto Sans TC", sans-serif;
|
||||
font-size: 1rem;
|
||||
color: var(--color-gray-500, #999);
|
||||
}
|
||||
|
||||
/* Hero Image */
|
||||
.article-hero-image {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
aspect-ratio: 16 / 9;
|
||||
overflow: hidden;
|
||||
background: var(--color-gray-100, #f5f5f5);
|
||||
}
|
||||
|
||||
.article-hero-image img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* Article Content */
|
||||
.article-content {
|
||||
padding: 60px 20px;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Prose styles for rich text content */
|
||||
.prose {
|
||||
font-family: "Noto Sans TC", sans-serif;
|
||||
color: var(--color-gray-700, #333);
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.prose :global(h1),
|
||||
.prose :global(h2),
|
||||
.prose :global(h3),
|
||||
.prose :global(h4) {
|
||||
font-weight: 700;
|
||||
color: var(--color-dark-blue, #1a1a1a);
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.prose :global(h1) { font-size: 2rem; }
|
||||
.prose :global(h2) { font-size: 1.75rem; }
|
||||
.prose :global(h3) { font-size: 1.5rem; }
|
||||
.prose :global(h4) { font-size: 1.25rem; }
|
||||
|
||||
.prose :global(p) {
|
||||
margin-bottom: 1.25rem;
|
||||
font-size: 1.0625rem;
|
||||
}
|
||||
|
||||
.prose :global(a) {
|
||||
color: var(--color-enchunblue, #23608c);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.prose :global(a:hover) {
|
||||
color: var(--color-enchunblue-hover, #1a4d6e);
|
||||
}
|
||||
|
||||
.prose :global(img) {
|
||||
border-radius: 8px;
|
||||
margin: 2rem 0;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.prose :global(ul),
|
||||
.prose :global(ol) {
|
||||
padding-left: 1.5rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.prose :global(li) {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.prose :global(blockquote) {
|
||||
border-left: 4px solid var(--color-enchunblue, #23608c);
|
||||
padding-left: 1rem;
|
||||
margin: 2rem 0;
|
||||
font-style: italic;
|
||||
color: var(--color-gray-600, #666);
|
||||
}
|
||||
|
||||
.prose :global(code) {
|
||||
background: var(--color-gray-100, #f5f5f5);
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.prose :global(pre) {
|
||||
background: var(--color-dark-blue, #1a1a1a);
|
||||
color: #fff;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.prose :global(pre code) {
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* Article Actions */
|
||||
.article-actions {
|
||||
padding: 20px;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 24px;
|
||||
background: #ffffff;
|
||||
border: 2px solid var(--color-gray-300, #e0e0e0);
|
||||
border-radius: 8px;
|
||||
color: var(--color-gray-700, #555);
|
||||
font-family: "Noto Sans TC", sans-serif;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
.post-date {
|
||||
color: #666;
|
||||
margin-bottom: 20px;
|
||||
|
||||
.back-button:hover {
|
||||
border-color: var(--color-enchunblue, #23608c);
|
||||
color: var(--color-enchunblue, #23608c);
|
||||
background: rgba(35, 96, 140, 0.05);
|
||||
}
|
||||
.post-content {
|
||||
/* Prose styles handle typography */
|
||||
|
||||
/* Responsive Adjustments */
|
||||
@media (max-width: 991px) {
|
||||
.article-title {
|
||||
font-size: 2rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.article-header {
|
||||
padding: 40px 16px 30px;
|
||||
}
|
||||
|
||||
.article-title {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.article-hero-image {
|
||||
aspect-ratio: 4 / 3;
|
||||
}
|
||||
|
||||
.article-content {
|
||||
padding: 40px 16px;
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.prose :global(h1) { font-size: 1.75rem; }
|
||||
.prose :global(h2) { font-size: 1.5rem; }
|
||||
.prose :global(h3) { font-size: 1.25rem; }
|
||||
.prose :global(p) {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
84
apps/frontend/src/sections/AboutHero.astro
Normal file
84
apps/frontend/src/sections/AboutHero.astro
Normal file
@@ -0,0 +1,84 @@
|
||||
---
|
||||
/**
|
||||
* AboutHero - Hero section for About page
|
||||
* Pixel-perfect implementation based on Webflow design
|
||||
*/
|
||||
interface Props {
|
||||
title?: string
|
||||
subtitle?: string
|
||||
}
|
||||
|
||||
const {
|
||||
title = '關於恩群數位',
|
||||
subtitle = 'About Enchun digital',
|
||||
} = Astro.props
|
||||
---
|
||||
|
||||
<header class="hero-overlay-about">
|
||||
<div class="w-container">
|
||||
<div class="div-block">
|
||||
<h1 class="hero_title_head-about">{title}</h1>
|
||||
<p class="hero_sub_paragraph-about">{subtitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<style>
|
||||
/* About Hero Styles - Pixel-perfect from Webflow */
|
||||
.hero-overlay-about {
|
||||
background-color: #ffffff;
|
||||
padding: 120px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.w-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.hero_title_head-about {
|
||||
color: var(--color-enchunblue);
|
||||
font-family: "Noto Sans TC", "Quicksand", sans-serif;
|
||||
font-weight: 700;
|
||||
font-size: 3rem;
|
||||
line-height: 1.2;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.hero_sub_paragraph-about {
|
||||
color: var(--color-enchunblue-dark);
|
||||
font-family: "Quicksand", "Noto Sans TC", sans-serif;
|
||||
font-weight: 400;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Responsive Adjustments */
|
||||
@media (max-width: 991px) {
|
||||
.hero-overlay-about {
|
||||
padding: 80px 20px;
|
||||
}
|
||||
|
||||
.hero_title_head-about {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.hero_sub_paragraph-about {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.hero-overlay-about {
|
||||
padding: 60px 20px;
|
||||
}
|
||||
|
||||
.hero_title_head-about {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.hero_sub_paragraph-about {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
309
apps/frontend/src/sections/BenefitsSection.astro
Normal file
309
apps/frontend/src/sections/BenefitsSection.astro
Normal file
@@ -0,0 +1,309 @@
|
||||
---
|
||||
/**
|
||||
* BenefitsSection - Work benefits section for Teams page
|
||||
* Pixel-perfect implementation based on Webflow design
|
||||
* Features 6 benefit cards with alternating layout
|
||||
*/
|
||||
|
||||
interface BenefitItem {
|
||||
title: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
benefits?: BenefitItem[]
|
||||
}
|
||||
|
||||
const defaultBenefits: BenefitItem[] = [
|
||||
{
|
||||
title: '高績效、高獎金\n新人開張獎金',
|
||||
icon: 'bonus',
|
||||
},
|
||||
{
|
||||
title: '生日慶生、電影日\n員工下午茶',
|
||||
icon: 'birthday',
|
||||
},
|
||||
{
|
||||
title: '教育訓練補助',
|
||||
icon: 'education',
|
||||
},
|
||||
{
|
||||
title: '寬敞的工作空間',
|
||||
icon: 'workspace',
|
||||
},
|
||||
{
|
||||
title: '員工國內外旅遊\n部門聚餐、年終活動',
|
||||
icon: 'travel',
|
||||
},
|
||||
{
|
||||
title: '入職培訓及團隊建設',
|
||||
icon: 'training',
|
||||
},
|
||||
]
|
||||
|
||||
const benefits = Astro.props.benefits || defaultBenefits
|
||||
|
||||
// Icon SVG components (placeholder for now, replace with actual SVG)
|
||||
const getIconSVG = (iconType: string) => {
|
||||
const icons: Record<string, string> = {
|
||||
bonus: `<svg viewBox="0 0 200 200" class="benefit-icon-svg">
|
||||
<circle cx="100" cy="70" r="40" fill="#23608c" opacity="0.2"/>
|
||||
<rect x="60" y="100" width="80" height="60" rx="8" fill="#23608c"/>
|
||||
<text x="100" y="140" text-anchor="middle" fill="white" font-size="36">💰</text>
|
||||
</svg>`,
|
||||
birthday: `<svg viewBox="0 0 200 200" class="benefit-icon-svg">
|
||||
<circle cx="100" cy="70" r="40" fill="#23608c" opacity="0.2"/>
|
||||
<rect x="60" y="100" width="80" height="60" rx="8" fill="#23608c"/>
|
||||
<text x="100" y="140" text-anchor="middle" fill="white" font-size="36">🎂</text>
|
||||
</svg>`,
|
||||
education: `<svg viewBox="0 0 200 200" class="benefit-icon-svg">
|
||||
<circle cx="100" cy="70" r="40" fill="#23608c" opacity="0.2"/>
|
||||
<rect x="60" y="100" width="80" height="60" rx="8" fill="#23608c"/>
|
||||
<text x="100" y="140" text-anchor="middle" fill="white" font-size="36">📚</text>
|
||||
</svg>`,
|
||||
workspace: `<svg viewBox="0 0 200 200" class="benefit-icon-svg">
|
||||
<circle cx="100" cy="70" r="40" fill="#23608c" opacity="0.2"/>
|
||||
<rect x="60" y="100" width="80" height="60" rx="8" fill="#23608c"/>
|
||||
<text x="100" y="140" text-anchor="middle" fill="white" font-size="36">🏢</text>
|
||||
</svg>`,
|
||||
travel: `<svg viewBox="0 0 200 200" class="benefit-icon-svg">
|
||||
<circle cx="100" cy="70" r="40" fill="#23608c" opacity="0.2"/>
|
||||
<rect x="60" y="100" width="80" height="60" rx="8" fill="#23608c"/>
|
||||
<text x="100" y="140" text-anchor="middle" fill="white" font-size="36">✈️</text>
|
||||
</svg>`,
|
||||
training: `<svg viewBox="0 0 200 200" class="benefit-icon-svg">
|
||||
<circle cx="100" cy="70" r="40" fill="#23608c" opacity="0.2"/>
|
||||
<rect x="60" y="100" width="80" height="60" rx="8" fill="#23608c"/>
|
||||
<text x="100" y="140" text-anchor="middle" fill="white" font-size="36">🤝</text>
|
||||
</svg>`,
|
||||
}
|
||||
return icons[iconType] || icons.bonus
|
||||
}
|
||||
---
|
||||
|
||||
<section class="section-benefit" aria-labelledby="benefits-heading">
|
||||
<div class="container w-container">
|
||||
<!-- Section Header -->
|
||||
<div class="section_header_w_line">
|
||||
<div class="divider_line"></div>
|
||||
<div class="header_subtitle">
|
||||
<h2 id="benefits-heading" class="header_subtitle_head">工作福利</h2>
|
||||
<p class="header_subtitle_paragraph">Benefit Package</p>
|
||||
</div>
|
||||
<div class="divider_line"></div>
|
||||
</div>
|
||||
|
||||
<!-- Benefits Grid -->
|
||||
<div class="benefit-grid-wrapper">
|
||||
{
|
||||
benefits.map((benefit, index) => (
|
||||
<div class={`benefit-card ${index % 2 === 0 ? 'benefit-card' : 'benefit-card-opposite'}`}>
|
||||
<!-- Odd: Icon on right, Even: Icon on left -->
|
||||
{
|
||||
index % 2 === 0 ? (
|
||||
<>
|
||||
<div class="benefit-content">
|
||||
<h3 class="benefit-title-text">{benefit.title}</h3>
|
||||
</div>
|
||||
<div class="benefit-image-right" set:html={getIconSVG(benefit.icon)} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div class="benefit-image-left" set:html={getIconSVG(benefit.icon)} />
|
||||
<div class="benefit-content">
|
||||
<h3 class="benefit-title-text">{benefit.title}</h3>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
/* Benefits Section Styles - Pixel-perfect from Webflow */
|
||||
.section-benefit {
|
||||
padding: 60px 20px;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.w-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Section Header */
|
||||
.section_header_w_line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
.header_subtitle {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.header_subtitle_head {
|
||||
color: var(--color-enchunblue);
|
||||
font-family: "Noto Sans TC", sans-serif;
|
||||
font-weight: 700;
|
||||
font-size: 2rem;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.header_subtitle_paragraph {
|
||||
color: var(--color-gray-600);
|
||||
font-family: "Quicksand", sans-serif;
|
||||
font-weight: 400;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.divider_line {
|
||||
width: 40px;
|
||||
height: 2px;
|
||||
background-color: var(--color-enchunblue);
|
||||
}
|
||||
|
||||
/* Benefits Grid */
|
||||
.benefit-grid-wrapper {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Benefit Card */
|
||||
.benefit-card,
|
||||
.benefit-card-opposite {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 40px;
|
||||
align-items: center;
|
||||
margin-bottom: 60px;
|
||||
}
|
||||
|
||||
/* Odd cards: icon on right */
|
||||
.benefit-card {
|
||||
grid-template-areas: "content image";
|
||||
}
|
||||
|
||||
.benefit-card .benefit-content {
|
||||
grid-area: content;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.benefit-card .benefit-image-right {
|
||||
grid-area: image;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Even cards: icon on left */
|
||||
.benefit-card-opposite {
|
||||
grid-template-areas: "image content";
|
||||
}
|
||||
|
||||
.benefit-card-opposite .benefit-content {
|
||||
grid-area: content;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.benefit-card-opposite .benefit-image-left {
|
||||
grid-area: image;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Benefit Title */
|
||||
.benefit-title-text {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-tarawera, #23608c);
|
||||
white-space: pre-line;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Benefit Icon */
|
||||
.benefit-icon-svg {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
}
|
||||
|
||||
.benefit-image-right,
|
||||
.benefit-image-left {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.benefit-image-right svg,
|
||||
.benefit-image-left svg {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
}
|
||||
|
||||
/* Responsive Adjustments */
|
||||
@media (max-width: 991px) {
|
||||
.benefit-card,
|
||||
.benefit-card-opposite {
|
||||
gap: 24px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.benefit-title-text {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.section-benefit {
|
||||
padding: 40px 16px;
|
||||
}
|
||||
|
||||
.section_header_w_line {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.benefit-card,
|
||||
.benefit-card-opposite {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-areas: "image" "content" !important;
|
||||
gap: 24px;
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
.benefit-content {
|
||||
text-align: center !important;
|
||||
}
|
||||
|
||||
.benefit-image-right,
|
||||
.benefit-image-left {
|
||||
order: -1;
|
||||
}
|
||||
|
||||
.benefit-title-text {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.benefit-icon-svg {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
.benefit-image-right svg,
|
||||
.benefit-image-left svg {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
147
apps/frontend/src/sections/CTASection.astro
Normal file
147
apps/frontend/src/sections/CTASection.astro
Normal file
@@ -0,0 +1,147 @@
|
||||
---
|
||||
/**
|
||||
* CTASection - Call to action section (Pixel-perfect from Webflow)
|
||||
* Features: 2-column grid, notification-red button, hover effects
|
||||
*/
|
||||
import type { HomeData } from '@/lib/api/home'
|
||||
|
||||
interface Props {
|
||||
homeData?: HomeData | null
|
||||
}
|
||||
|
||||
const { homeData } = Astro.props
|
||||
|
||||
// Get CTA config from CMS or use defaults - matching reference HTML
|
||||
const ctaSection = homeData?.ctaSection
|
||||
const headline = ctaSection?.headline || '準備好開始新的旅程了嗎 歡迎與我們聯絡'
|
||||
const description = ctaSection?.description || '' // Reference has no description
|
||||
const buttonText = ctaSection?.buttonText || '預約諮詢 phone_callback'
|
||||
const buttonLink = ctaSection?.buttonLink || 'https://heyform.itslouis.cc/form/7mYtUNjA'
|
||||
---
|
||||
|
||||
<section
|
||||
class="section-call4action"
|
||||
aria-labelledby="cta-heading"
|
||||
>
|
||||
<div class="max-w-6xl mx-auto px-4">
|
||||
<div class="c4a-grid">
|
||||
<!-- Text Content -->
|
||||
<div class="c4a-content">
|
||||
<h3 id="cta-heading" class="c4a-heading">
|
||||
{headline}
|
||||
</h3>
|
||||
{
|
||||
description && (
|
||||
<p class="c4a-description">
|
||||
{description}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- CTA Button -->
|
||||
<a
|
||||
href={buttonLink}
|
||||
class="c4a-button"
|
||||
>
|
||||
<span class="c4a-button-text">
|
||||
{buttonText}
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
/* CTA Section Styles - Pixel-perfect from Webflow */
|
||||
.section-call4action {
|
||||
padding: 105px 0 126px 0;
|
||||
}
|
||||
|
||||
.c4a-grid {
|
||||
display: grid;
|
||||
grid-column-gap: 60px;
|
||||
grid-template-columns: max-content max-content;
|
||||
place-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.c4a-content {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* Heading - 1.88em */
|
||||
.c4a-heading {
|
||||
color: var(--color-dark-blue);
|
||||
font-size: 1.88em;
|
||||
font-weight: 500;
|
||||
line-height: 1.1;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* Description */
|
||||
.c4a-description {
|
||||
font-size: 1.1em;
|
||||
color: var(--color-gray-700);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Button */
|
||||
.c4a-button {
|
||||
background-color: var(--color-notification-red);
|
||||
border-radius: 15px;
|
||||
padding: 18px 24px;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
transition: all var(--transition-base, 200ms ease-in-out);
|
||||
}
|
||||
|
||||
.c4a-button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.c4a-button-text {
|
||||
color: #f2f2f2;
|
||||
font-size: 1.56em;
|
||||
line-height: 1.1;
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Responsive Adjustments */
|
||||
@media (max-width: 991px) {
|
||||
.section-call4action {
|
||||
padding: 64px 0;
|
||||
}
|
||||
|
||||
.c4a-grid {
|
||||
grid-template-columns: 1fr;
|
||||
grid-column-gap: 32px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.c4a-content {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.c4a-heading {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.section-call4action {
|
||||
padding: 48px 0;
|
||||
}
|
||||
|
||||
.c4a-button {
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.c4a-button-text {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
505
apps/frontend/src/sections/ClientCasesSection.astro
Normal file
505
apps/frontend/src/sections/ClientCasesSection.astro
Normal file
@@ -0,0 +1,505 @@
|
||||
---
|
||||
/**
|
||||
* ClientCasesSection - Client testimonials carousel
|
||||
* Features: Auto-play carousel, 2-column grid, responsive
|
||||
*/
|
||||
import type { HomeData } from '@/lib/api/home'
|
||||
|
||||
interface Props {
|
||||
homeData?: HomeData | null
|
||||
}
|
||||
|
||||
const { homeData } = Astro.props
|
||||
|
||||
// Client cases data - matching reference HTML exactly
|
||||
const clientCases = [
|
||||
{
|
||||
id: 1,
|
||||
name: '落日精品咖啡',
|
||||
title: '王孟梵 老闆',
|
||||
company: '',
|
||||
image: '/placeholder-client-1.jpg',
|
||||
review: '業務專業、能反應需求、良好溝通一起努力達成目標,比起自己下廣告,合作更能將每分錢用在刀口上。',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Gee hair salon',
|
||||
title: 'Ivan 店長',
|
||||
company: '',
|
||||
image: '/placeholder-client-2.jpg',
|
||||
review: '與恩群合作多年,疫情時期,不指定、網路指定客數量不減反增,讓在店周圍客人們就進更能找到合適自己的髮型師。',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: '高雄欣興租車',
|
||||
title: 'ANDY 店長',
|
||||
company: '',
|
||||
image: '/placeholder-client-3.jpg',
|
||||
review: '感謝團隊的專業指導與耐心的溝通,讓商家的理念可以轉換成實際的成果,發揮廣告效應最大化!特別是後勤團隊高效的執行能力,深受店家放心😊',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: '這健小事運動工作室',
|
||||
title: 'Nash嘉慶 老闆',
|
||||
company: '',
|
||||
image: '/placeholder-client-4.jpg',
|
||||
review: '對於我們預算有限的自營小工作室的人真的最佳選擇,謝謝你們,讓更多需要運動的人,看見我們',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: '即刻體能運動空間',
|
||||
title: 'Peter 老闆',
|
||||
company: '',
|
||||
image: '/placeholder-client-5.jpg',
|
||||
review: '在疫情期間開業的我們,很慶幸有恩群的協助,讓喜愛健身的客人能搜尋到我們、並給我們機會。感謝恩群團隊,讓我們感受到滿滿的服務熱忱及設身處地為客戶著想的心,非常推薦!',
|
||||
},
|
||||
]
|
||||
---
|
||||
|
||||
<section class="section-client-case" aria-labelledby="client-cases-heading">
|
||||
<div class="max-w-6xl mx-auto px-4">
|
||||
<!-- Section Header - H1 "客戶案例" -->
|
||||
<div class="section-header-w-line">
|
||||
<h1 id="client-cases-heading" class="client-case-heading">
|
||||
客戶案例
|
||||
</h1>
|
||||
<p class="client-case-sub">clients who work with us</p>
|
||||
<p class="client-case-description">
|
||||
恩群數位的客戶真實示範,增加流量就是這麼簡單!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Carousel Container -->
|
||||
<div class="case-slider-wrapper" id="case-carousel">
|
||||
<!-- Slides Container -->
|
||||
<div class="case-slides-container">
|
||||
{
|
||||
clientCases.map((caseItem, index) => (
|
||||
<div
|
||||
class={`case-slide ${index === 0 ? 'active' : ''}`}
|
||||
data-slide={index}
|
||||
>
|
||||
<div class="case-content-grid">
|
||||
<!-- Image Side -->
|
||||
<div class="case-image-side">
|
||||
<div class="case-image-wrapper">
|
||||
<img
|
||||
src={caseItem.image}
|
||||
alt={caseItem.name}
|
||||
class="case-slide-image"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content Side -->
|
||||
<div class="case-content-side">
|
||||
<h3 class="case-heading">{caseItem.name}</h3>
|
||||
<p class="case-job-title">
|
||||
{caseItem.title}
|
||||
</p>
|
||||
<p class="case-review">「{caseItem.review}」</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Navigation Arrows -->
|
||||
<button class="carousel-nav carousel-nav-prev" aria-label="previous slide">
|
||||
<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor">
|
||||
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="carousel-nav carousel-nav-next" aria-label="next slide">
|
||||
<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor">
|
||||
<path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Slide Counter -->
|
||||
<div class="slide-counter" aria-live="polite">
|
||||
Slide <span id="current-slide">1</span> of <span id="total-slides">5</span>.
|
||||
</div>
|
||||
|
||||
<!-- Carousel Indicators -->
|
||||
<div class="carousel-indicators">
|
||||
{
|
||||
clientCases.map((_, index) => (
|
||||
<button
|
||||
class={`indicator ${index === 0 ? 'active' : ''}`}
|
||||
data-slide={index}
|
||||
aria-label={`Go to slide ${index + 1}`}
|
||||
></button>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
/* Client Cases Section Styles - Pixel-perfect from Webflow */
|
||||
.section-client-case {
|
||||
padding: 64px 0;
|
||||
}
|
||||
|
||||
/* Section Header */
|
||||
.section-header-w-line {
|
||||
text-align: center;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.client-case-heading {
|
||||
font-family: "Noto Sans TC", sans-serif;
|
||||
font-size: 1.8em; /* 34.2px at 19px base - matching reference */
|
||||
font-weight: 700;
|
||||
color: var(--color-dark-blue, #062841);
|
||||
text-align: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.client-case-sub {
|
||||
font-family: "Quicksand", "Noto Sans TC", sans-serif;
|
||||
font-size: 1rem;
|
||||
font-weight: 400;
|
||||
color: var(--color-gray-600, #666);
|
||||
text-align: center;
|
||||
margin: 0.5rem 0;
|
||||
text-transform: lowercase;
|
||||
}
|
||||
|
||||
.client-case-description {
|
||||
font-family: "Noto Sans TC", sans-serif;
|
||||
font-size: 1rem;
|
||||
font-weight: 400;
|
||||
color: var(--color-gray-600, #666);
|
||||
text-align: center;
|
||||
margin: 0.5rem 0 1rem;
|
||||
}
|
||||
|
||||
/* Carousel Container */
|
||||
.case-slider-wrapper {
|
||||
position: relative;
|
||||
height: 77vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.case-slides-container {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Individual Slide */
|
||||
.case-slide {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
transition: opacity 0.5s ease-in-out;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.case-slide.active {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Content Grid */
|
||||
.case-content-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-column-gap: 55px;
|
||||
grid-row-gap: 55px;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
padding: 0 32px;
|
||||
}
|
||||
|
||||
/* Image Side */
|
||||
.case-image-side {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.case-image-wrapper {
|
||||
width: 100%;
|
||||
aspect-ratio: 16/9;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: var(--color-gray-100);
|
||||
}
|
||||
|
||||
.case-slide-image {
|
||||
object-fit: cover;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Content Side */
|
||||
.case-content-side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.case-heading {
|
||||
font-size: 1.7em;
|
||||
color: var(--color-dark-blue);
|
||||
font-family: "Noto Sans TC", sans-serif;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.case-job-title {
|
||||
font-size: 18px;
|
||||
color: var(--color-enchunblue-dark);
|
||||
font-weight: 100;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.case-review {
|
||||
font-size: 18px;
|
||||
color: var(--color-gray-700);
|
||||
font-weight: 100;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Carousel Indicators */
|
||||
.carousel-indicators {
|
||||
position: absolute;
|
||||
bottom: 32px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.indicator {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
cursor: pointer;
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
.indicator:hover {
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.indicator.active {
|
||||
background: var(--color-enchunblue);
|
||||
width: 32px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* Navigation Arrows */
|
||||
.carousel-nav {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border: 2px solid var(--color-enchunblue);
|
||||
color: var(--color-enchunblue);
|
||||
font-size: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.carousel-nav:hover {
|
||||
background: var(--color-enchunblue);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.carousel-nav-prev {
|
||||
left: 16px;
|
||||
}
|
||||
|
||||
.carousel-nav-next {
|
||||
right: 16px;
|
||||
}
|
||||
|
||||
/* Slide Counter */
|
||||
.slide-counter {
|
||||
position: absolute;
|
||||
bottom: 16px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-family: "Quicksand", sans-serif;
|
||||
font-size: 14px;
|
||||
color: var(--color-gray-600);
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
padding: 4px 12px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
/* Responsive Adjustments */
|
||||
@media (max-width: 991px) {
|
||||
.case-slider-wrapper {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.case-slide {
|
||||
position: relative;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.case-slide:not(.active) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.case-content-grid {
|
||||
grid-template-columns: 1fr;
|
||||
grid-column-gap: 32px;
|
||||
grid-row-gap: 32px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.case-heading {
|
||||
font-size: 1.4em;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.case-slider-wrapper {
|
||||
height: 59vh;
|
||||
}
|
||||
|
||||
.client-case-sub {
|
||||
font-size: 20px;
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.case-content-grid {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.case-heading {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.case-job-title,
|
||||
.case-review {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Carousel functionality
|
||||
function initClientCasesCarousel() {
|
||||
const slides = document.querySelectorAll('.case-slide')
|
||||
const indicators = document.querySelectorAll('.indicator')
|
||||
const prevBtn = document.querySelector('.carousel-nav-prev')
|
||||
const nextBtn = document.querySelector('.carousel-nav-next')
|
||||
const currentSlideEl = document.getElementById('current-slide')
|
||||
const totalSlidesEl = document.getElementById('total-slides')
|
||||
|
||||
let currentSlide = 0
|
||||
const totalSlides = slides.length
|
||||
const autoplayDelay = 2500 // 2.5 seconds
|
||||
let autoplayTimer: number | undefined
|
||||
|
||||
// Update total slides display
|
||||
if (totalSlidesEl) {
|
||||
totalSlidesEl.textContent = totalSlides.toString()
|
||||
}
|
||||
|
||||
function showSlide(index: number) {
|
||||
// Wrap around
|
||||
if (index >= totalSlides) index = 0
|
||||
if (index < 0) index = totalSlides - 1
|
||||
|
||||
// Remove active class from all
|
||||
slides.forEach(slide => slide.classList.remove('active'))
|
||||
indicators.forEach(ind => ind.classList.remove('active'))
|
||||
|
||||
// Add active class to current
|
||||
slides[index].classList.add('active')
|
||||
indicators[index].classList.add('active')
|
||||
|
||||
// Update counter
|
||||
if (currentSlideEl) {
|
||||
currentSlideEl.textContent = (index + 1).toString()
|
||||
}
|
||||
|
||||
currentSlide = index
|
||||
}
|
||||
|
||||
function nextSlide() {
|
||||
showSlide(currentSlide + 1)
|
||||
}
|
||||
|
||||
function prevSlide() {
|
||||
showSlide(currentSlide - 1)
|
||||
}
|
||||
|
||||
function startAutoplay() {
|
||||
stopAutoplay()
|
||||
autoplayTimer = window.setInterval(nextSlide, autoplayDelay)
|
||||
}
|
||||
|
||||
function stopAutoplay() {
|
||||
if (autoplayTimer !== undefined) {
|
||||
clearInterval(autoplayTimer)
|
||||
autoplayTimer = undefined
|
||||
}
|
||||
}
|
||||
|
||||
// Click handlers for indicators
|
||||
indicators.forEach((indicator, index) => {
|
||||
indicator.addEventListener('click', () => {
|
||||
showSlide(index)
|
||||
stopAutoplay()
|
||||
startAutoplay()
|
||||
})
|
||||
})
|
||||
|
||||
// Navigation buttons
|
||||
if (prevBtn) {
|
||||
prevBtn.addEventListener('click', () => {
|
||||
prevSlide()
|
||||
stopAutoplay()
|
||||
startAutoplay()
|
||||
})
|
||||
}
|
||||
|
||||
if (nextBtn) {
|
||||
nextBtn.addEventListener('click', () => {
|
||||
nextSlide()
|
||||
stopAutoplay()
|
||||
startAutoplay()
|
||||
})
|
||||
}
|
||||
|
||||
// Pause on hover
|
||||
const carousel = document.getElementById('case-carousel')
|
||||
if (carousel) {
|
||||
carousel.addEventListener('mouseenter', stopAutoplay)
|
||||
carousel.addEventListener('mouseleave', startAutoplay)
|
||||
}
|
||||
|
||||
// Start autoplay
|
||||
startAutoplay()
|
||||
}
|
||||
|
||||
// Initialize when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', initClientCasesCarousel)
|
||||
if (document.readyState !== 'loading') {
|
||||
initClientCasesCarousel()
|
||||
}
|
||||
</script>
|
||||
131
apps/frontend/src/sections/CompanyStory.astro
Normal file
131
apps/frontend/src/sections/CompanyStory.astro
Normal file
@@ -0,0 +1,131 @@
|
||||
---
|
||||
/**
|
||||
* CompanyStory - Company story section for Teams page
|
||||
* Pixel-perfect implementation based on Webflow design
|
||||
*/
|
||||
interface Props {
|
||||
title?: string
|
||||
subtitle?: string
|
||||
content?: string
|
||||
}
|
||||
|
||||
const {
|
||||
title = '恩群數位的故事',
|
||||
subtitle = 'Something About Enchun Digital',
|
||||
content = '恩群數位是由一群年輕果斷、敢冒險的年輕人聚在一起,共同為台灣在地經營努力不懈的商家老闆們建立品牌的知名度。而商家的經營本身就不是一件容易的事情,早在恩群成立之前,我們便一直聚焦在與不同行業的老闆們建立起信賴可靠的關係,從一家又一家的合作關係當中,聚集了在行銷領域裡的頂尖好手,培育了許多優秀行銷顧問。讓每個辛苦經營的商家老闆可以獲得最佳的服務,讓每次的行銷需求可以透過有效的互動與聆聽,達到彼此心目中的預期目標。數字的確會說話,但是每一個有溫度的服務才是在恩群裡最重視的地方。',
|
||||
} = Astro.props
|
||||
---
|
||||
|
||||
<section class="section-story" aria-labelledby="story-heading">
|
||||
<div class="container w-container">
|
||||
<!-- Section Header -->
|
||||
<div class="section_header_w_line">
|
||||
<div class="divider_line"></div>
|
||||
<div class="header_subtitle">
|
||||
<h2 id="story-heading" class="header_subtitle_head">{title}</h2>
|
||||
<p class="header_subtitle_paragraph">{subtitle}</p>
|
||||
</div>
|
||||
<div class="divider_line"></div>
|
||||
</div>
|
||||
|
||||
<!-- Story Content -->
|
||||
<p class="story-paragraph">{content}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
/* Company Story Styles - Pixel-perfect from Webflow */
|
||||
.section-story {
|
||||
padding: 80px 20px;
|
||||
text-align: center;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.w-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Section Header */
|
||||
.section_header_w_line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.header_subtitle {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.header_subtitle_head {
|
||||
color: var(--color-enchunblue);
|
||||
font-family: "Noto Sans TC", sans-serif;
|
||||
font-weight: 700;
|
||||
font-size: 2rem;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.header_subtitle_paragraph {
|
||||
color: var(--color-gray-600);
|
||||
font-family: "Quicksand", sans-serif;
|
||||
font-weight: 400;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.divider_line {
|
||||
width: 40px;
|
||||
height: 2px;
|
||||
background-color: var(--color-enchunblue);
|
||||
}
|
||||
|
||||
/* Story Content */
|
||||
.story-paragraph {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.8;
|
||||
color: var(--color-text-secondary);
|
||||
font-family: "Noto Sans TC", sans-serif;
|
||||
}
|
||||
|
||||
/* Responsive Adjustments */
|
||||
@media (max-width: 991px) {
|
||||
.section-story {
|
||||
padding: 60px 16px;
|
||||
}
|
||||
|
||||
.header_subtitle_head {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.story-paragraph {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.section-story {
|
||||
padding: 40px 16px;
|
||||
}
|
||||
|
||||
.section_header_w_line {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.header_subtitle_head {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.story-paragraph {
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
274
apps/frontend/src/sections/ComparisonSection.astro
Normal file
274
apps/frontend/src/sections/ComparisonSection.astro
Normal file
@@ -0,0 +1,274 @@
|
||||
---
|
||||
/**
|
||||
* ComparisonSection - 恩群數位 vs 其他行銷公司 對比表格
|
||||
* Pixel-perfect implementation based on Webflow design
|
||||
*/
|
||||
interface ComparisonItem {
|
||||
feature: string
|
||||
enchun: string
|
||||
others: string
|
||||
}
|
||||
|
||||
const comparisonItems: ComparisonItem[] = [
|
||||
{
|
||||
feature: '服務範圍',
|
||||
enchun: '全方位數位行銷服務,從策略到執行一條龍',
|
||||
others: '單一服務項目,缺乏整合性',
|
||||
},
|
||||
{
|
||||
feature: '數據分析',
|
||||
enchun: '專業數據分析團隊,精準追蹤 ROI',
|
||||
others: '基礎報告,缺乏深度分析',
|
||||
},
|
||||
{
|
||||
feature: '在地化經驗',
|
||||
enchun: '深耕台灣市場,了解本地消費者習性',
|
||||
others: '通用策略,缺乏在地化調整',
|
||||
},
|
||||
{
|
||||
feature: '客戶服務',
|
||||
enchun: '一對一專人服務,快速響應',
|
||||
others: '標準化流程,回應較慢',
|
||||
},
|
||||
{
|
||||
feature: '價格透明',
|
||||
enchun: '明確報價,無隱藏費用',
|
||||
others: '複雜收費結構,容易超支',
|
||||
},
|
||||
]
|
||||
---
|
||||
|
||||
<section class="section-comparison" aria-labelledby="comparison-heading">
|
||||
<div class="w-container">
|
||||
<!-- Section Header -->
|
||||
<div class="section-header-w-line">
|
||||
<h2 id="comparison-heading" class="header-subtitle-head">
|
||||
為什麼選擇恩群數位
|
||||
</h2>
|
||||
<div class="divider-line"></div>
|
||||
</div>
|
||||
|
||||
<!-- Comparison Table -->
|
||||
<div class="comparison-table-wrapper">
|
||||
<table class="comparison-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="th-feature">比較項目</th>
|
||||
<th class="th-enchun">
|
||||
<span class="enchun-badge">恩群數位</span>
|
||||
</th>
|
||||
<th class="th-others">其他行銷公司</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{
|
||||
comparisonItems.map((item, index) => (
|
||||
<tr class={index % 2 === 0 ? 'row-even' : 'row-odd'}>
|
||||
<td class="td-feature">{item.feature}</td>
|
||||
<td class="td-enchun">
|
||||
<span class="enchun-icon">✓</span>
|
||||
{item.enchun}
|
||||
</td>
|
||||
<td class="td-others">{item.others}</td>
|
||||
</tr>
|
||||
))
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- CTA Note -->
|
||||
<div class="comparison-note">
|
||||
<p class="note-text">
|
||||
選擇恩群數位,讓您的品牌在數位時代脫穎而出!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
/* Comparison Section Styles - Pixel-perfect from Webflow */
|
||||
.section-comparison {
|
||||
background-color: #f8f9fa;
|
||||
padding: 80px 20px;
|
||||
}
|
||||
|
||||
.w-container {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Section Header */
|
||||
.section-header-w-line {
|
||||
text-align: center;
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
.header-subtitle-head {
|
||||
color: var(--color-enchunblue);
|
||||
font-family: "Noto Sans TC", "Quicksand", sans-serif;
|
||||
font-weight: 700;
|
||||
font-size: 2rem;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.divider-line {
|
||||
background-color: var(--color-enchunblue);
|
||||
height: 2px;
|
||||
width: 60px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Table Wrapper */
|
||||
.comparison-table-wrapper {
|
||||
background: white;
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Comparison Table */
|
||||
.comparison-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
/* Table Header */
|
||||
.comparison-table thead {
|
||||
background-color: var(--color-enchunblue);
|
||||
}
|
||||
|
||||
.comparison-table th {
|
||||
color: white;
|
||||
font-family: "Noto Sans TC", sans-serif;
|
||||
font-weight: 600;
|
||||
font-size: 1.125rem;
|
||||
padding: 20px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.th-feature {
|
||||
width: 20%;
|
||||
}
|
||||
|
||||
.th-enchun {
|
||||
width: 45%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.th-others {
|
||||
width: 35%;
|
||||
}
|
||||
|
||||
/* Enchun Badge */
|
||||
.enchun-badge {
|
||||
display: inline-block;
|
||||
background-color: white;
|
||||
color: var(--color-enchunblue);
|
||||
padding: 4px 16px;
|
||||
border-radius: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Table Body */
|
||||
.comparison-table tbody tr {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.comparison-table tbody tr:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.row-even {
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.row-odd {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.comparison-table td {
|
||||
padding: 20px;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.td-feature {
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.td-enchun {
|
||||
color: var(--color-text-secondary);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.enchun-icon {
|
||||
display: inline-block;
|
||||
color: #22c55e;
|
||||
font-weight: bold;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.td-others {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Comparison Note */
|
||||
.comparison-note {
|
||||
text-align: center;
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.note-text {
|
||||
font-size: 1.125rem;
|
||||
color: var(--color-enchunblue);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Responsive Adjustments */
|
||||
@media (max-width: 991px) {
|
||||
.section-comparison {
|
||||
padding: 60px 16px;
|
||||
}
|
||||
|
||||
.comparison-table th,
|
||||
.comparison-table td {
|
||||
padding: 16px 12px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.header-subtitle-head {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
/* Convert to card layout on mobile */
|
||||
.comparison-table-wrapper {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.comparison-table {
|
||||
min-width: 600px;
|
||||
}
|
||||
|
||||
.section-comparison {
|
||||
padding: 40px 12px;
|
||||
}
|
||||
|
||||
.header-subtitle-head {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.comparison-table th,
|
||||
.comparison-table td {
|
||||
padding: 12px 8px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.enchun-badge {
|
||||
font-size: 0.875rem;
|
||||
padding: 2px 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
442
apps/frontend/src/sections/EnvironmentSlider.astro
Normal file
442
apps/frontend/src/sections/EnvironmentSlider.astro
Normal file
@@ -0,0 +1,442 @@
|
||||
---
|
||||
/**
|
||||
* EnvironmentSlider - Photo slider for work environment
|
||||
* Pixel-perfect implementation based on Webflow design
|
||||
* Features: 8 photos, arrow navigation, dot navigation, touch swipe support
|
||||
*/
|
||||
|
||||
interface SlideImage {
|
||||
src: string
|
||||
alt: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
slides?: SlideImage[]
|
||||
}
|
||||
|
||||
const defaultSlides: SlideImage[] = [
|
||||
{ src: '/placeholder-environment-1.jpg', alt: '恩群環境照片 1' },
|
||||
{ src: '/placeholder-environment-2.jpg', alt: '恩群環境照片 2' },
|
||||
{ src: '/placeholder-environment-3.jpg', alt: '恩群環境照片 3' },
|
||||
{ src: '/placeholder-environment-4.jpg', alt: '恩群環境照片 4' },
|
||||
{ src: '/placeholder-environment-5.jpg', alt: '恩群環境照片 5' },
|
||||
{ src: '/placeholder-environment-6.jpg', alt: '恩群環境照片 6' },
|
||||
{ src: '/placeholder-environment-7.jpg', alt: '恩群環境照片 7' },
|
||||
{ src: '/placeholder-environment-8.jpg', alt: '恩群環境照片 8' },
|
||||
]
|
||||
|
||||
const slides = Astro.props.slides || defaultSlides
|
||||
---
|
||||
|
||||
<section class="section-video" aria-label="工作環境照片">
|
||||
<div class="container spacer8 w-container">
|
||||
<!-- Section Header -->
|
||||
<div class="section_header_w_line">
|
||||
<div class="divider_line"></div>
|
||||
<div class="header_subtitle">
|
||||
<h2 class="header_subtitle_head">在恩群工作的環境</h2>
|
||||
<p class="header_subtitle_paragraph">Working Enviroment</p>
|
||||
</div>
|
||||
<div class="divider_line"></div>
|
||||
</div>
|
||||
|
||||
<!-- Environment Slider -->
|
||||
<div
|
||||
class="environment-slider"
|
||||
id="env-slider-{Math.random().toString(36).slice(2, 8)}"
|
||||
>
|
||||
<!-- Slides Container -->
|
||||
<div class="slides-container">
|
||||
{
|
||||
slides.map((slide, index) => (
|
||||
<div class="environment-slide" data-index={index}>
|
||||
<img
|
||||
src={slide.src}
|
||||
alt={slide.alt}
|
||||
loading={index === 0 ? 'eager' : 'lazy'}
|
||||
width="800"
|
||||
height="450"
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Arrow Navigation -->
|
||||
<button class="slider-arrow slider-arrow-left" aria-label="上一張">
|
||||
<svg viewBox="0 0 24 24" width="24" height="24">
|
||||
<path fill="currentColor" d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="slider-arrow slider-arrow-right" aria-label="下一張">
|
||||
<svg viewBox="0 0 24 24" width="24" height="24">
|
||||
<path fill="currentColor" d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Dot Navigation -->
|
||||
<div class="slider-dots">
|
||||
{
|
||||
slides.map((_, index) => (
|
||||
<button
|
||||
class={`slider-dot ${index === 0 ? 'active' : ''}`}
|
||||
data-index={index}
|
||||
aria-label={`顯示第 ${index + 1} 張照片`}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
/* Environment Slider Styles - Pixel-perfect from Webflow */
|
||||
.section-video {
|
||||
padding: 80px 20px;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.w-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.spacer8 {
|
||||
padding-top: 2rem;
|
||||
}
|
||||
|
||||
/* Section Header */
|
||||
.section_header_w_line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
.header_subtitle {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.header_subtitle_head {
|
||||
color: var(--color-enchunblue);
|
||||
font-family: "Noto Sans TC", sans-serif;
|
||||
font-weight: 700;
|
||||
font-size: 2rem;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.header_subtitle_paragraph {
|
||||
color: var(--color-gray-600);
|
||||
font-family: "Quicksand", sans-serif;
|
||||
font-weight: 400;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.divider_line {
|
||||
width: 40px;
|
||||
height: 2px;
|
||||
background-color: var(--color-enchunblue);
|
||||
}
|
||||
|
||||
/* Environment Slider */
|
||||
.environment-slider {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.slides-container {
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
scroll-snap-type: x mandatory;
|
||||
scroll-behavior: smooth;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.slides-container::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.environment-slide {
|
||||
flex: 0 0 100%;
|
||||
scroll-snap-align: start;
|
||||
aspect-ratio: 16/9;
|
||||
overflow: hidden;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.environment-slide img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* Arrow Navigation */
|
||||
.slider-arrow {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background-color: rgba(255, 255, 255, 0.8);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 200ms ease;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.slider-arrow:hover {
|
||||
background-color: white;
|
||||
transform: translateY(-50%) scale(1.1);
|
||||
}
|
||||
|
||||
.slider-arrow-left {
|
||||
left: 16px;
|
||||
}
|
||||
|
||||
.slider-arrow-right {
|
||||
right: 16px;
|
||||
}
|
||||
|
||||
/* Dot Navigation */
|
||||
.slider-dots {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.slider-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background-color: rgba(35, 96, 140, 0.3);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 200ms ease;
|
||||
}
|
||||
|
||||
.slider-dot.active {
|
||||
background-color: var(--color-enchunblue);
|
||||
width: 32px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* Responsive Adjustments */
|
||||
@media (min-width: 992px) {
|
||||
.environment-slider {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 991px) {
|
||||
.environment-slider {
|
||||
max-width: 550px;
|
||||
}
|
||||
|
||||
.section_header_w_line {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.section-video {
|
||||
padding: 60px 16px;
|
||||
}
|
||||
|
||||
.environment-slider {
|
||||
max-width: 90vw;
|
||||
}
|
||||
|
||||
.slider-arrow {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.slider-arrow-left {
|
||||
left: 8px;
|
||||
}
|
||||
|
||||
.slider-arrow-right {
|
||||
right: 8px;
|
||||
}
|
||||
|
||||
.header_subtitle_head {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Environment Slider functionality
|
||||
function initEnvironmentSlider() {
|
||||
const sliders = document.querySelectorAll('.environment-slider')
|
||||
|
||||
sliders.forEach((slider) => {
|
||||
const container = slider.querySelector('.slides-container') as HTMLElement
|
||||
const slides = slider.querySelectorAll('.environment-slide')
|
||||
const dots = slider.querySelectorAll('.slider-dot')
|
||||
const prevBtn = slider.querySelector('.slider-arrow-left') as HTMLButtonElement
|
||||
const nextBtn = slider.querySelector('.slider-arrow-right') as HTMLButtonElement
|
||||
|
||||
if (!container || slides.length === 0) return
|
||||
|
||||
let currentIndex = 0
|
||||
const totalSlides = slides.length
|
||||
let isDragging = false
|
||||
let startPos = 0
|
||||
let currentTranslate = 0
|
||||
let prevTranslate = 0
|
||||
let animationID: number
|
||||
|
||||
// Update slider position
|
||||
const updateSlider = () => {
|
||||
container.scrollTo({
|
||||
left: currentIndex * container.offsetWidth,
|
||||
behavior: 'smooth'
|
||||
})
|
||||
updateDots()
|
||||
}
|
||||
|
||||
// Update dots
|
||||
const updateDots = () => {
|
||||
dots.forEach((dot, index) => {
|
||||
if (index === currentIndex) {
|
||||
dot.classList.add('active')
|
||||
} else {
|
||||
dot.classList.remove('active')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Go to specific slide
|
||||
const goToSlide = (index: number) => {
|
||||
if (index < 0) index = totalSlides - 1
|
||||
if (index >= totalSlides) index = 0
|
||||
currentIndex = index
|
||||
updateSlider()
|
||||
}
|
||||
|
||||
// Previous slide
|
||||
prevBtn?.addEventListener('click', () => goToSlide(currentIndex - 1))
|
||||
|
||||
// Next slide
|
||||
nextBtn?.addEventListener('click', () => goToSlide(currentIndex + 1))
|
||||
|
||||
// Dot navigation
|
||||
dots.forEach((dot, index) => {
|
||||
dot.addEventListener('click', () => goToSlide(index))
|
||||
})
|
||||
|
||||
// Scroll snap detection
|
||||
container.addEventListener('scroll', () => {
|
||||
const slideIndex = Math.round(container.scrollLeft / container.offsetWidth)
|
||||
if (slideIndex !== currentIndex) {
|
||||
currentIndex = slideIndex
|
||||
updateDots()
|
||||
}
|
||||
})
|
||||
|
||||
// Touch/swipe support
|
||||
const touchStart = (_index: number) => {
|
||||
return function(event: TouchEvent) {
|
||||
isDragging = true
|
||||
startPos = event.touches[0].clientX
|
||||
animationID = requestAnimationFrame(animation)
|
||||
container.style.cursor = 'grabbing'
|
||||
}
|
||||
}
|
||||
|
||||
const touchEnd = () => {
|
||||
isDragging = false
|
||||
cancelAnimationFrame(animationID)
|
||||
container.style.cursor = 'grab'
|
||||
|
||||
const movedBy = currentTranslate - prevTranslate
|
||||
|
||||
if (movedBy < -50 && currentIndex < totalSlides - 1) {
|
||||
currentIndex += 1
|
||||
} else if (movedBy > 50 && currentIndex > 0) {
|
||||
currentIndex -= 1
|
||||
}
|
||||
|
||||
goToSlide(currentIndex)
|
||||
}
|
||||
|
||||
const touchMove = (event: TouchEvent) => {
|
||||
if (isDragging) {
|
||||
const currentPosition = event.touches[0].clientX
|
||||
currentTranslate = prevTranslate + currentPosition - startPos
|
||||
}
|
||||
}
|
||||
|
||||
const animation = () => {
|
||||
if (isDragging) requestAnimationFrame(animation)
|
||||
}
|
||||
|
||||
// Mouse events for desktop
|
||||
let mouseStartPos = 0
|
||||
let isMouseDown = false
|
||||
|
||||
container.addEventListener('mousedown', (e: MouseEvent) => {
|
||||
isMouseDown = true
|
||||
mouseStartPos = e.clientX
|
||||
container.style.cursor = 'grabbing'
|
||||
})
|
||||
|
||||
container.addEventListener('mouseup', (e: MouseEvent) => {
|
||||
if (!isMouseDown) return
|
||||
isMouseDown = false
|
||||
container.style.cursor = 'grab'
|
||||
|
||||
const movedBy = e.clientX - mouseStartPos
|
||||
if (movedBy < -50 && currentIndex < totalSlides - 1) {
|
||||
currentIndex += 1
|
||||
} else if (movedBy > 50 && currentIndex > 0) {
|
||||
currentIndex -= 1
|
||||
}
|
||||
goToSlide(currentIndex)
|
||||
})
|
||||
|
||||
container.addEventListener('mouseleave', () => {
|
||||
isMouseDown = false
|
||||
container.style.cursor = 'grab'
|
||||
})
|
||||
|
||||
// Touch events
|
||||
container.addEventListener('touchstart', touchStart(currentIndex))
|
||||
container.addEventListener('touchend', touchEnd)
|
||||
container.addEventListener('touchmove', touchMove)
|
||||
|
||||
// Keyboard navigation
|
||||
slider.addEventListener('keydown', (e) => {
|
||||
if (e instanceof KeyboardEvent) {
|
||||
if (e.key === 'ArrowLeft') goToSlide(currentIndex - 1)
|
||||
if (e.key === 'ArrowRight') goToSlide(currentIndex + 1)
|
||||
}
|
||||
})
|
||||
|
||||
// Set initial state
|
||||
container.style.cursor = 'grab'
|
||||
})
|
||||
}
|
||||
|
||||
// Initialize when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', initEnvironmentSlider)
|
||||
if (document.readyState !== 'loading') {
|
||||
initEnvironmentSlider()
|
||||
}
|
||||
</script>
|
||||
202
apps/frontend/src/sections/FeatureSection.astro
Normal file
202
apps/frontend/src/sections/FeatureSection.astro
Normal file
@@ -0,0 +1,202 @@
|
||||
---
|
||||
/**
|
||||
* FeatureSection - Service features section with 4 cards
|
||||
* Pixel-perfect implementation based on Webflow design
|
||||
*/
|
||||
interface Props {
|
||||
title?: string
|
||||
subtitle?: string
|
||||
}
|
||||
|
||||
const {
|
||||
title = '我們的服務特色',
|
||||
subtitle = '為什麼選擇恩群數位?',
|
||||
} = Astro.props
|
||||
|
||||
// Four features data
|
||||
const features = [
|
||||
{
|
||||
icon: 'location_on',
|
||||
title: '在地化優先',
|
||||
description: '緊上線下結合曝光渠道,整合多方資訊,帶給消費者最佳的使用體驗,展現商家的獨特之處,順利的將潛在使用者帶到你的實際門市。',
|
||||
},
|
||||
{
|
||||
icon: 'account_balance',
|
||||
title: '高投資轉換率',
|
||||
description: '你覺得網路行銷很貴嗎?恩群數位善用每一分廣告預算,讓你在網路上發揮最大效益,幫助店家鎖定精準客群,達成目標。',
|
||||
},
|
||||
{
|
||||
icon: 'analytics',
|
||||
title: '數據優先',
|
||||
description: '想要精準行銷?恩群數位從數據中萃取洞察,根據數據分析廣告成效,更聰明、有策略的幫您省下行銷預算。',
|
||||
},
|
||||
{
|
||||
icon: 'handshake',
|
||||
title: '關係優於銷售',
|
||||
description: '除了幫您拓展網路上的知名度,我們更是每家公司最專業的數位夥伴,你會知道有恩群的存在,事業路上你並不孤單。',
|
||||
},
|
||||
]
|
||||
---
|
||||
|
||||
<section class="section_feature" aria-labelledby="feature-heading">
|
||||
<div class="w-container">
|
||||
<!-- Section Header -->
|
||||
<div class="section_header_w_line">
|
||||
<h2 id="feature-heading" class="header_subtitle_head">
|
||||
{title}
|
||||
</h2>
|
||||
<p class="header_subtitle_paragraph">
|
||||
{subtitle}
|
||||
</p>
|
||||
<div class="divider_line"></div>
|
||||
</div>
|
||||
|
||||
<!-- Features Grid -->
|
||||
<div class="feature_grid">
|
||||
{
|
||||
features.map((feature) => (
|
||||
<div class="feature_card">
|
||||
<!-- Icon -->
|
||||
<div class="feature_image">
|
||||
<span class="material-icon">{feature.icon}</span>
|
||||
</div>
|
||||
|
||||
<!-- Title -->
|
||||
<h3 class="feature_head">{feature.title}</h3>
|
||||
|
||||
<!-- Description -->
|
||||
<p class="feature_description">{feature.description}</p>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
/* Feature Section Styles - Pixel-perfect from Webflow */
|
||||
.section_feature {
|
||||
background-color: #ffffff;
|
||||
padding: 80px 20px;
|
||||
}
|
||||
|
||||
.w-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Section Header */
|
||||
.section_header_w_line {
|
||||
text-align: center;
|
||||
margin-bottom: 60px;
|
||||
}
|
||||
|
||||
.header_subtitle_head {
|
||||
color: var(--color-enchunblue);
|
||||
font-weight: 700;
|
||||
font-size: 2.25rem;
|
||||
line-height: 1.2;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.header_subtitle_paragraph {
|
||||
color: #666666;
|
||||
font-weight: 400;
|
||||
font-size: 1rem;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.divider_line {
|
||||
background-color: var(--color-enchunblue);
|
||||
height: 2px;
|
||||
width: 100px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Features Grid */
|
||||
.feature_grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 30px;
|
||||
}
|
||||
|
||||
/* Feature Card */
|
||||
.feature_card {
|
||||
background: #ffffff;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 32px;
|
||||
transition: all var(--transition-base);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.feature_card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
/* Icon */
|
||||
.feature_image {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin-bottom: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.material-icon {
|
||||
font-size: 64px;
|
||||
color: var(--color-enchunblue);
|
||||
}
|
||||
|
||||
/* Title */
|
||||
.feature_head {
|
||||
color: #333333;
|
||||
font-weight: 600;
|
||||
font-size: 1.25rem;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* Description */
|
||||
.feature_description {
|
||||
color: #666666;
|
||||
font-weight: 400;
|
||||
font-size: 1rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Responsive Adjustments */
|
||||
@media (max-width: 991px) {
|
||||
.feature_grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.feature_card {
|
||||
padding: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.section_feature {
|
||||
padding: 60px 16px;
|
||||
}
|
||||
|
||||
.feature_grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.header_subtitle_head {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.feature_card {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.material-icon {
|
||||
font-size: 48px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
268
apps/frontend/src/sections/HeroSection.astro
Normal file
268
apps/frontend/src/sections/HeroSection.astro
Normal file
@@ -0,0 +1,268 @@
|
||||
---
|
||||
/**
|
||||
* HeroSection - Pixel-perfect Hero component matching Webflow design
|
||||
* Supports video background with fallback image and gradient overlay
|
||||
*/
|
||||
import { Image } from 'astro:assets'
|
||||
import type { HomeData } from '@/lib/api/home'
|
||||
|
||||
interface Props {
|
||||
homeData?: HomeData | null
|
||||
fallbackDesktopVideo?: string
|
||||
fallbackMobileVideo?: string
|
||||
}
|
||||
|
||||
const {
|
||||
homeData,
|
||||
fallbackDesktopVideo = '/video/enchun-hero-background-video.mp4',
|
||||
fallbackMobileVideo = '/video/enchun-hero-background-video.webm',
|
||||
} = Astro.props
|
||||
|
||||
// Extract hero content from CMS data or use defaults
|
||||
const heroHeadline = homeData?.heroHeadline || '創造企業更多發展的可能性\n是我們的使命'
|
||||
const heroSubheadline = homeData?.heroSubheadline || "It's our destiny to create possibilities for your business."
|
||||
|
||||
// Get video URLs from CMS or fallback
|
||||
const desktopVideoUrl = homeData?.heroDesktopVideo?.url || fallbackDesktopVideo
|
||||
const mobileVideoUrl = homeData?.heroMobileVideo?.url || fallbackMobileVideo
|
||||
|
||||
// Get fallback image
|
||||
const fallbackImageUrl = homeData?.heroFallbackImage?.url
|
||||
|
||||
// Get logo
|
||||
const logoUrl = homeData?.heroLogo?.url || '/enchun-logo-full.svg'
|
||||
|
||||
// Helper to get base path without extension
|
||||
const getBasePath = (path: string) => path.replace(/\.(mp4|webm)$/i, '')
|
||||
|
||||
// Generate sources for video elements
|
||||
const getSources = (inputPath: string) => {
|
||||
const basePath = getBasePath(inputPath)
|
||||
return [
|
||||
{ src: `${basePath}.webm`, type: 'video/webm' },
|
||||
{ src: `${basePath}.mp4`, type: 'video/mp4' },
|
||||
]
|
||||
}
|
||||
---
|
||||
|
||||
<!-- Fallback Image (shown when video fails to load) -->
|
||||
{
|
||||
fallbackImageUrl && (
|
||||
<div class="absolute top-0 left-0 w-full h-[100dvh] z-0">
|
||||
<img
|
||||
src={fallbackImageUrl}
|
||||
alt="Hero background"
|
||||
class="w-full h-full object-cover"
|
||||
loading="eager"
|
||||
decoding="sync"
|
||||
/>
|
||||
<div class="absolute top-0 left-0 w-full h-full hero-overlay-home"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
<!-- Desktop Video -->
|
||||
<video
|
||||
id="desktop-video"
|
||||
class="background-video hidden md:block absolute top-0 left-0 w-full h-[100dvh] object-cover z-0"
|
||||
autoplay
|
||||
muted
|
||||
loop
|
||||
playsinline
|
||||
preload="metadata"
|
||||
>
|
||||
{
|
||||
getSources(desktopVideoUrl).map((source) => (
|
||||
<source src={source.src} type={source.type} />
|
||||
))
|
||||
}
|
||||
</video>
|
||||
|
||||
<!-- Mobile Video -->
|
||||
<video
|
||||
id="mobile-video"
|
||||
class="background-video md:hidden block absolute top-0 left-0 w-full h-[100dvh] object-cover z-0"
|
||||
autoplay
|
||||
muted
|
||||
loop
|
||||
playsinline
|
||||
preload="metadata"
|
||||
>
|
||||
{
|
||||
getSources(mobileVideoUrl).map((source) => (
|
||||
<source src={source.src} type={source.type} />
|
||||
))
|
||||
}
|
||||
</video>
|
||||
|
||||
<!-- Hero Content Overlay -->
|
||||
<section class="hero-section -mt-[5rem] flex items-center justify-center relative z-10 overflow-hidden">
|
||||
<!-- Gradient Overlay -->
|
||||
<div class="absolute top-0 left-0 w-full h-full hero-overlay-home"></div>
|
||||
|
||||
<div class="max-w-6xl mx-auto w-full px-8 relative z-10">
|
||||
<div class="flex items-center justify-start mb-6">
|
||||
<!-- Logo (135px width per spec) -->
|
||||
<Image
|
||||
height={201}
|
||||
width={135}
|
||||
src={logoUrl}
|
||||
alt="Enchun Logo"
|
||||
class="hidden md:block hero-logo mr-8"
|
||||
/>
|
||||
|
||||
<!-- Text Content -->
|
||||
<div class="flex flex-col items-start justify-start">
|
||||
<!-- Main Headline - 3.39em (64px at 19px base) -->
|
||||
<h1 class="hero-title-head whitespace-break-spaces">
|
||||
{heroHeadline}
|
||||
</h1>
|
||||
|
||||
<!-- Subheadline - 1.56em (30px at 19px base) -->
|
||||
<p class="hero-sub-paragraph-home">
|
||||
{heroSubheadline}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
/* Hero Section Styles - Pixel-perfect from Webflow */
|
||||
.hero-section {
|
||||
height: 100vh;
|
||||
max-height: 88.5vh;
|
||||
padding-top: 110px;
|
||||
padding-bottom: 100px;
|
||||
}
|
||||
|
||||
/* Gradient Overlay - left to right fade */
|
||||
.hero-overlay-home {
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
rgba(0, 0, 0, 0.8) 0%,
|
||||
rgba(0, 0, 0, 0) 100%
|
||||
);
|
||||
}
|
||||
|
||||
/* Background Video */
|
||||
.background-video {
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
/* Logo */
|
||||
.hero-logo {
|
||||
width: 135px;
|
||||
padding-top: 33px;
|
||||
}
|
||||
|
||||
/* Hero Title - 3.39em (64px at 19px base) */
|
||||
.hero-title-head {
|
||||
font-size: 3.39em;
|
||||
line-height: 1.2;
|
||||
text-align: left;
|
||||
color: #ffffff;
|
||||
font-family: "Noto Sans TC", sans-serif;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Hero Subtitle - 1.56em (30px at 19px base) */
|
||||
.hero-sub-paragraph-home {
|
||||
font-size: 1.56em;
|
||||
font-weight: 300;
|
||||
line-height: 1.2;
|
||||
text-align: left;
|
||||
color: #f2f2f2;
|
||||
font-family: "Quicksand", "Noto Sans TC", sans-serif;
|
||||
}
|
||||
|
||||
/* Responsive Adjustments */
|
||||
@media (max-width: 991px) {
|
||||
.hero-section {
|
||||
height: 100vh;
|
||||
max-height: 100vh;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.hero-title-head {
|
||||
font-size: 2.45em; /* ~47px */
|
||||
}
|
||||
|
||||
.hero-sub-paragraph-home {
|
||||
font-size: 1.15em; /* ~22px */
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.hero-title-head {
|
||||
font-size: 7vw;
|
||||
}
|
||||
|
||||
.hero-sub-paragraph-home {
|
||||
font-size: 3.4vw;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Enhanced mobile/desktop detection and video loading
|
||||
function initVideoHero() {
|
||||
const desktopVideo = document.getElementById('desktop-video') as HTMLVideoElement
|
||||
const mobileVideo = document.getElementById('mobile-video') as HTMLVideoElement
|
||||
|
||||
if (!desktopVideo || !mobileVideo) return
|
||||
|
||||
// Function to check if we're on mobile
|
||||
const isMobile = () => window.innerWidth < 768
|
||||
|
||||
// Function to switch videos based on device
|
||||
const switchVideos = () => {
|
||||
const mobile = isMobile()
|
||||
|
||||
if (mobile) {
|
||||
desktopVideo.style.display = 'none'
|
||||
mobileVideo.style.display = 'block'
|
||||
} else {
|
||||
desktopVideo.style.display = 'block'
|
||||
mobileVideo.style.display = 'none'
|
||||
}
|
||||
}
|
||||
|
||||
// Initial switch
|
||||
switchVideos()
|
||||
|
||||
// Listen for resize events
|
||||
window.addEventListener('resize', () => {
|
||||
switchVideos()
|
||||
})
|
||||
|
||||
// Handle video loading errors
|
||||
;(desktopVideo as any).onerror = () => {
|
||||
console.warn('Desktop video failed to load')
|
||||
}
|
||||
|
||||
;(mobileVideo as any).onerror = () => {
|
||||
console.warn('Mobile video failed to load')
|
||||
}
|
||||
|
||||
// Preload videos when they're hidden to improve switching
|
||||
const preloadVideos = () => {
|
||||
;[desktopVideo, mobileVideo].forEach((video) => {
|
||||
if ((video as HTMLVideoElement).style.display === 'none') {
|
||||
video.load()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Preload on load
|
||||
window.addEventListener('load', preloadVideos)
|
||||
}
|
||||
|
||||
// Initialize when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', initVideoHero)
|
||||
if (document.readyState !== 'loading') {
|
||||
initVideoHero()
|
||||
}
|
||||
</script>
|
||||
369
apps/frontend/src/sections/PainpointSection.astro
Normal file
369
apps/frontend/src/sections/PainpointSection.astro
Normal file
@@ -0,0 +1,369 @@
|
||||
---
|
||||
/**
|
||||
* PainpointSection - Interactive tab section showing customer pain points
|
||||
* Features: Tab switching, icon display, hover effects
|
||||
*/
|
||||
import type { HomeData } from '@/lib/api/home'
|
||||
|
||||
interface Props {
|
||||
homeData?: HomeData | null
|
||||
}
|
||||
|
||||
const { homeData } = Astro.props
|
||||
|
||||
// Pain points data - matching reference HTML exactly
|
||||
const painpoints = [
|
||||
{
|
||||
id: 'company',
|
||||
title: '行銷公司難找',
|
||||
icon: '🔍',
|
||||
description: '市場上有太多行銷公司了,每家都跟我說他們家的服務可以掛保證。但是我應該要找誰合作呢?',
|
||||
},
|
||||
{
|
||||
id: 'methods',
|
||||
title: '宣傳方法太多難選擇',
|
||||
icon: '🤔',
|
||||
description: '網路行銷方法這麼多,Google、FB、IG、YT...哪種才適合我的產品?',
|
||||
},
|
||||
{
|
||||
id: 'digital',
|
||||
title: '數位轉型太難',
|
||||
icon: '💻',
|
||||
description: '想要做數位轉型,但不知道從哪裡開始?技術門檻太高?',
|
||||
},
|
||||
{
|
||||
id: 'burning',
|
||||
title: '廣告行銷像燒錢',
|
||||
icon: '💸',
|
||||
description: '廣告預算投入很多,但看不到實際效果?感覺像在燒錢?',
|
||||
},
|
||||
]
|
||||
---
|
||||
|
||||
<section class="section-painpoint" aria-labelledby="painpoint-heading">
|
||||
<div class="max-w-6xl mx-auto px-4">
|
||||
<!-- Section Header -->
|
||||
<div class="section-header-w-line">
|
||||
<h2 id="painpoint-heading" class="header-subtitle-head">
|
||||
你可能會遇到的煩惱
|
||||
</h2>
|
||||
<div class="divider-line"></div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs Container -->
|
||||
<div class="painpoint-tabs" id="painpoint-tabs">
|
||||
<!-- Tab Headers -->
|
||||
<div class="tab-headers">
|
||||
{
|
||||
painpoints.map((point, index) => (
|
||||
<button
|
||||
class={`painpoint-text ${index === 0 ? 'active' : ''}`}
|
||||
data-tab={point.id}
|
||||
aria-selected={index === 0 ? 'true' : 'false'}
|
||||
role="tab"
|
||||
>
|
||||
{point.title}
|
||||
</button>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Tab Panels -->
|
||||
<div class="tab-panels">
|
||||
{
|
||||
painpoints.map((point, index) => (
|
||||
<div
|
||||
id={`panel-${point.id}`}
|
||||
class={`tab-panel-frame ${index === 0 ? 'active' : ''}`}
|
||||
role="tabpanel"
|
||||
aria-labelledby={`tab-${point.id}`}
|
||||
hidden={index !== 0}
|
||||
>
|
||||
<!-- Icon -->
|
||||
<div class="painpoint-icon">
|
||||
<div class="icon-holder">
|
||||
<span class="text-6xl" aria-hidden="true">{point.icon}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<p class="painpoint-description">
|
||||
{point.description}
|
||||
</p>
|
||||
|
||||
<!-- Solution Link -->
|
||||
<div class="painpoint-solution">
|
||||
<span class="solution-text">我們有解決方法</span>
|
||||
<a href="/about-enchun" class="solution-link">
|
||||
<span class="sr-only">了解更多</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
/* Painpoint Section Styles - Pixel-perfect from Webflow */
|
||||
.section-painpoint {
|
||||
height: 80vh;
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Section Header */
|
||||
.section-header-w-line {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.header-subtitle-head {
|
||||
font-size: 1.8rem;
|
||||
text-align: center;
|
||||
color: var(--color-dark-blue);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.divider-line {
|
||||
background-color: var(--color-enchunblue);
|
||||
height: 1px;
|
||||
width: 60px;
|
||||
margin: 16px auto;
|
||||
}
|
||||
|
||||
/* Tabs Container */
|
||||
.painpoint-tabs {
|
||||
padding-top: 32px;
|
||||
padding-bottom: 70px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
/* Tab Headers */
|
||||
.tab-headers {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.painpoint-text {
|
||||
font-size: 2.08em;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
color: var(--color-dark-blue);
|
||||
font-family: "Noto Sans TC", sans-serif;
|
||||
cursor: pointer;
|
||||
transition: text-shadow 0.3s ease;
|
||||
background: none;
|
||||
border: none;
|
||||
text-align: left;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.painpoint-text:hover {
|
||||
text-shadow: 7px 4px 13px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.painpoint-text.active {
|
||||
color: var(--color-enchunblue);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Tab Panels */
|
||||
.tab-panels {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tab-panel-frame {
|
||||
border: 1px solid var(--color-enchunblue);
|
||||
border-radius: 10px;
|
||||
position: relative;
|
||||
top: -47px;
|
||||
padding: 32px;
|
||||
background: white;
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-panel-frame.active {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Icon */
|
||||
.painpoint-icon {
|
||||
height: 110px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.icon-holder {
|
||||
width: 130px;
|
||||
height: 115px;
|
||||
background-color: var(--color-grey6);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Description */
|
||||
.painpoint-description {
|
||||
font-size: 1.2rem;
|
||||
line-height: 1.6;
|
||||
color: var(--color-gray-700);
|
||||
text-align: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* Solution Section */
|
||||
.painpoint-solution {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.solution-text {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-enchunblue);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.solution-link {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background-color: var(--color-notification-red);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
transition: transform 0.2s ease, background-color 0.2s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.solution-link:hover {
|
||||
transform: scale(1.1);
|
||||
background-color: #b92d1a;
|
||||
}
|
||||
|
||||
.solution-link::after {
|
||||
content: '→';
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* Responsive Adjustments */
|
||||
@media (max-width: 991px) {
|
||||
.section-painpoint {
|
||||
height: auto;
|
||||
max-height: none;
|
||||
padding: 64px 0;
|
||||
}
|
||||
|
||||
.painpoint-tabs {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.tab-headers {
|
||||
flex-direction: row;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.painpoint-text {
|
||||
font-size: 1.5em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tab-panel-frame {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.header-subtitle-head {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.painpoint-text {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.icon-holder {
|
||||
width: 100px;
|
||||
height: 90px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Tab switching functionality
|
||||
function initPainpointTabs() {
|
||||
const tabs = document.querySelectorAll('.painpoint-text')
|
||||
const panels = document.querySelectorAll('.tab-panel-frame')
|
||||
|
||||
tabs.forEach((tab) => {
|
||||
tab.addEventListener('click', () => {
|
||||
const tabId = tab.getAttribute('data-tab')
|
||||
|
||||
// Remove active class from all tabs
|
||||
tabs.forEach(t => {
|
||||
t.classList.remove('active')
|
||||
t.setAttribute('aria-selected', 'false')
|
||||
})
|
||||
|
||||
// Hide all panels
|
||||
panels.forEach(p => {
|
||||
p.classList.remove('active')
|
||||
;(p as HTMLElement).hidden = true
|
||||
})
|
||||
|
||||
// Activate clicked tab
|
||||
tab.classList.add('active')
|
||||
tab.setAttribute('aria-selected', 'true')
|
||||
|
||||
// Show corresponding panel
|
||||
const activePanel = document.getElementById(`panel-${tabId}`)
|
||||
if (activePanel) {
|
||||
activePanel.classList.add('active')
|
||||
;(activePanel as HTMLElement).hidden = false
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Initialize when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', initPainpointTabs)
|
||||
if (document.readyState !== 'loading') {
|
||||
initPainpointTabs()
|
||||
}
|
||||
</script>
|
||||
77
apps/frontend/src/sections/PortfolioPreview.astro
Normal file
77
apps/frontend/src/sections/PortfolioPreview.astro
Normal file
@@ -0,0 +1,77 @@
|
||||
---
|
||||
/**
|
||||
* PortfolioPreview - Portfolio preview section
|
||||
*/
|
||||
import PortfolioPreviewCard from '@/components/PortfolioPreviewCard.astro'
|
||||
import type { HomeData } from '@/lib/api/home'
|
||||
import type { PortfolioItem } from '@/lib/api/home'
|
||||
|
||||
interface Props {
|
||||
homeData?: HomeData | null
|
||||
portfolioItems?: PortfolioItem[]
|
||||
}
|
||||
|
||||
const { homeData, portfolioItems = [] } = Astro.props
|
||||
|
||||
// Get section config from CMS or use defaults
|
||||
const portfolioSection = homeData?.portfolioSection
|
||||
const headline = portfolioSection?.headline || '精選案例'
|
||||
const subheadline = portfolioSection?.subheadline || '探索我們為客戶打造的優質網站'
|
||||
|
||||
// Show section only if we have items
|
||||
const showSection = portfolioItems.length > 0
|
||||
---
|
||||
|
||||
{
|
||||
showSection && (
|
||||
<section
|
||||
class="py-16 md:py-24 bg-white"
|
||||
aria-labelledby="portfolio-heading"
|
||||
>
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<!-- Section Header -->
|
||||
<div class="text-center mb-12 md:mb-16">
|
||||
<h2
|
||||
id="portfolio-heading"
|
||||
class="text-3xl md:text-4xl font-bold text-[var(--color-text-primary)] mb-4"
|
||||
>
|
||||
{headline}
|
||||
</h2>
|
||||
<p class="text-lg text-[var(--color-text-muted)] max-w-2xl mx-auto">
|
||||
{subheadline}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Portfolio Grid -->
|
||||
<ul
|
||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 md:gap-8 mb-12"
|
||||
role="list"
|
||||
>
|
||||
{
|
||||
portfolioItems.map((item) => (
|
||||
<PortfolioPreviewCard item={item} />
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
|
||||
<!-- View All CTA -->
|
||||
<div class="text-center">
|
||||
<a
|
||||
href="/website-portfolio"
|
||||
class="inline-flex items-center justify-center px-8 py-3 bg-[var(--color-enchunblue)] text-white font-semibold rounded-lg hover:bg-[var(--color-enchunblue-dark)] transition-colors focus:outline-none focus:ring-2 focus:ring-[var(--color-enchunblue)] focus:ring-offset-2"
|
||||
>
|
||||
查看所有作品
|
||||
<svg
|
||||
class="w-5 h-5 ml-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
70
apps/frontend/src/sections/ServiceFeatures.astro
Normal file
70
apps/frontend/src/sections/ServiceFeatures.astro
Normal file
@@ -0,0 +1,70 @@
|
||||
---
|
||||
/**
|
||||
* ServiceFeatures - Service features grid section
|
||||
*/
|
||||
import ServiceFeatureCard from '@/components/ServiceFeatureCard.astro'
|
||||
import type { HomeData, ServiceFeature } from '@/lib/api/home'
|
||||
|
||||
interface Props {
|
||||
homeData?: HomeData | null
|
||||
}
|
||||
|
||||
const { homeData } = Astro.props
|
||||
|
||||
// Default service features if not provided by CMS
|
||||
const defaultFeatures: ServiceFeature[] = [
|
||||
{
|
||||
icon: '🎯',
|
||||
title: 'Google 商家關鍵字',
|
||||
description: '專業的關鍵字優化服務,提升您在 Google 搜尋結果的排名,讓客戶更容易找到您。',
|
||||
},
|
||||
{
|
||||
icon: '📱',
|
||||
title: '社群代操',
|
||||
description: '全方位社群媒體經營,從內容策劃到數據分析,提供一站式社群行銷服務。',
|
||||
},
|
||||
{
|
||||
icon: '💬',
|
||||
title: '論壇行銷',
|
||||
description: '深耕各大論壇社群,建立品牌口碑,提升用戶信任度與互動參與度。',
|
||||
},
|
||||
{
|
||||
icon: '🎨',
|
||||
title: '網站設計',
|
||||
description: '現代化響應式網站設計,結合美學與功能,打造獨特的品牌形象和優質用戶體驗。',
|
||||
},
|
||||
]
|
||||
|
||||
const serviceFeatures: ServiceFeature[] = (homeData && homeData.serviceFeatures && homeData.serviceFeatures.length > 0)
|
||||
? homeData.serviceFeatures
|
||||
: defaultFeatures
|
||||
---
|
||||
|
||||
<section class="py-16 md:py-24 bg-[var(--color-surface)]" aria-labelledby="services-heading">
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<!-- Section Header -->
|
||||
<div class="text-center mb-12 md:mb-16">
|
||||
<h2
|
||||
id="services-heading"
|
||||
class="text-3xl md:text-4xl font-bold text-[var(--color-text-primary)] mb-4"
|
||||
>
|
||||
我們的服務
|
||||
</h2>
|
||||
<p class="text-lg text-[var(--color-text-muted)] max-w-2xl mx-auto">
|
||||
提供全方位的數位行銷解決方案,協助您的品牌在數位時代脫穎而出
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Services Grid -->
|
||||
<ul
|
||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 md:gap-8"
|
||||
role="list"
|
||||
>
|
||||
{
|
||||
serviceFeatures.map((feature) => (
|
||||
<ServiceFeatureCard feature={feature} />
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
373
apps/frontend/src/sections/ServicesList.astro
Normal file
373
apps/frontend/src/sections/ServicesList.astro
Normal file
@@ -0,0 +1,373 @@
|
||||
---
|
||||
/**
|
||||
* ServicesList - Services list with zig-zag alternating layout
|
||||
* Pixel-perfect implementation based on Webflow design
|
||||
*/
|
||||
|
||||
interface ServicesListItem {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
category: string
|
||||
icon?: string
|
||||
isHot?: boolean
|
||||
image?: string
|
||||
link?: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
services?: ServicesListItem[]
|
||||
}
|
||||
|
||||
const {
|
||||
services,
|
||||
} = Astro.props
|
||||
|
||||
// Default services data (can be fetched from CMS)
|
||||
const defaultServices: ServicesListItem[] = [
|
||||
{
|
||||
id: 'social-media',
|
||||
title: '社群經營代操',
|
||||
category: '海洋專案',
|
||||
icon: 'facebook',
|
||||
isHot: true,
|
||||
image: '/placeholder-service-1.jpg',
|
||||
description: '專業社群媒體經營團隊,從內容策劃、社群經營到數據分析,提供一站式社群代操服務。我們擅長經營 Facebook、Instagram 等主流平台,幫助品牌建立強大的社群影響力。',
|
||||
},
|
||||
{
|
||||
id: 'google-business',
|
||||
title: 'Google 商家關鍵字',
|
||||
category: 'Google',
|
||||
icon: 'google',
|
||||
isHot: true,
|
||||
image: '/placeholder-service-2.jpg',
|
||||
description: '優化 Google 商家列表,提升在地搜尋排名。透過關鍵字策略、評論管理和商家資訊優化,讓您的商家在 Google 地圖和搜尋結果中脫穎而出。',
|
||||
},
|
||||
{
|
||||
id: 'google-ads',
|
||||
title: 'Google Ads 關鍵字',
|
||||
category: 'Google',
|
||||
icon: 'ads',
|
||||
isHot: false,
|
||||
image: '/placeholder-service-3.jpg',
|
||||
description: '專業的 Google Ads 投放服務,從關鍵字研究、廣告文案撰寫到出價策略優化,精準觸達目標受眾,最大化廣告投資報酬率。',
|
||||
},
|
||||
{
|
||||
id: 'news-media',
|
||||
title: '網路新聞媒體',
|
||||
category: '媒體行銷',
|
||||
icon: 'news',
|
||||
isHot: false,
|
||||
image: '/placeholder-service-4.jpg',
|
||||
description: '與各大新聞媒體合作,提供新聞發佈、媒體採訪、品牌曝光等服務。透過專業的新聞行銷策略,提升品牌知名度和公信力。',
|
||||
},
|
||||
{
|
||||
id: 'influencer',
|
||||
title: '網紅行銷專案',
|
||||
category: '口碑行銷',
|
||||
icon: 'youtube',
|
||||
isHot: true,
|
||||
image: '/placeholder-service-5.jpg',
|
||||
description: '連結品牌與網紅/KOL,打造影響者行銷活動。從網紅篩選、活動策劃到內容製作,提供完整的網紅行銷解決方案,快速建立品牌口碑。',
|
||||
},
|
||||
{
|
||||
id: 'forum',
|
||||
title: '論壇行銷專案',
|
||||
category: '口碑行銷',
|
||||
icon: 'forum',
|
||||
isHot: false,
|
||||
image: '/placeholder-service-6.jpg',
|
||||
description: '深耕各大論壇社群,包括 Dcard、PTT 等平台。透過專業的論壇行銷策略,建立品牌口碑,提升用戶信任度和互動參與度。',
|
||||
},
|
||||
{
|
||||
id: 'website-design',
|
||||
title: '形象網站設計',
|
||||
category: '品牌行銷',
|
||||
icon: 'web',
|
||||
isHot: false,
|
||||
image: '/placeholder-service-7.jpg',
|
||||
description: '現代化響應式網站設計服務,結合美學與功能,打造獨特的品牌形象。從 UI/UX 設計到前端開發,提供完整的網站解決方案。',
|
||||
},
|
||||
{
|
||||
id: 'brand-video',
|
||||
title: '品牌形象影片',
|
||||
category: '品牌行銷',
|
||||
icon: 'video',
|
||||
isHot: false,
|
||||
image: '/placeholder-service-8.jpg',
|
||||
description: '專業影片製作團隊,從腳本創作、拍攝到後製剪輯,打造高品質的品牌形象影片。透過視覺故事說,傳達品牌核心價值。',
|
||||
},
|
||||
]
|
||||
|
||||
const servicesList = services && services.length > 0 ? services : defaultServices
|
||||
|
||||
// Icon SVG components
|
||||
const icons = {
|
||||
facebook: `<svg viewBox="0 0 24 24" class="w-8 h-8"><path fill="currentColor" d="M12 2C6.477 2 2 6.477 2 12c0 4.991 3.657 9.128 8.438 9.879V14.89h-2.54V12h2.54V9.797c0-2.506 1.492-3.89 3.777-3.89 1.094 0 2.238.195 2.238.195v2.46h-1.26c-1.243 0-1.63.771-1.63 1.562V12h2.773l-.443 2.89h-2.33v6.989C18.343 21.129 22 16.99 22 12c0-5.523-4.477-10-10-10z"/></svg>`,
|
||||
google: `<svg viewBox="0 0 24 24" class="w-8 h-8"><path fill="currentColor" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/><path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/><path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/><path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/></svg>`,
|
||||
ads: `<svg viewBox="0 0 24 24" class="w-8 h-8"><path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/></svg>`,
|
||||
news: `<svg viewBox="0 0 24 24" class="w-8 h-8"><path fill="currentColor" d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-5 14H7v-2h7v2zm3-4H7v-2h10v2zm0-4H7V7h10v2z"/></svg>`,
|
||||
youtube: `<svg viewBox="0 0 24 24" class="w-8 h-8"><path fill="currentColor" d="M23.5 6.188c-1.258-2.667-4.636-4.764-8.148-5.36-.734-.12-3.32-.264-3.352-.264-3.032 0-2.618.144-3.352.264-3.512.596-6.89 2.693-8.148 5.36-.438.928-.796 2.622-1.028 5.578l-.01.232c0 .046-.004.136-.004.232 0 .096.004.186.004.232l.01.232c.232 2.956.59 4.65 1.028 5.578 1.258 2.667 4.636 4.764 8.148 5.36.734.12 3.32.264 3.352.264 3.032 0 2.618-.144 3.352-.264 3.512-.596 6.89-2.693 8.148-5.36.438-.928.796-2.622 1.028-5.578l.01-.232c0-.046.004-.136.004-.232 0-.096-.004-.186-.004-.232l-.01-.232c-.232-2.956-.59-4.65-1.028-5.578zM9.5 15.5v-7l6 3.5-6 3.5z"/></svg>`,
|
||||
forum: `<svg viewBox="0 0 24 24" class="w-8 h-8"><path fill="currentColor" d="M20 2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h4l4 4 4-4h4c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-2 12H6v-2h12v2zm0-3H6V9h12v2zm0-3H6V6h12v2z"/></svg>`,
|
||||
web: `<svg viewBox="0 0 24 24" class="w-8 h-8"><path fill="currentColor" d="M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm-5 14H4v-4h11v4zm0-5H4V9h11v4zm5 5h-4V9h4v9z"/></svg>`,
|
||||
video: `<svg viewBox="0 0 24 24" class="w-8 h-8"><path fill="currentColor" d="M18 4l2 4h-3l-2-4h-2l2 4h-3l-2-4H8l2 4H7L5 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V4h-3l-2 4h-3l2-4h-2z"/></svg>`,
|
||||
}
|
||||
---
|
||||
|
||||
<section class="section_service-list" aria-labelledby="services-heading">
|
||||
<div class="services-container">
|
||||
{
|
||||
servicesList.map((service, index) => (
|
||||
<a
|
||||
href={service.link || '#'}
|
||||
class={`service-item ${index % 2 === 0 ? 'odd' : 'even'}`}
|
||||
aria-labelledby={`service-${service.id}-title`}
|
||||
>
|
||||
<!-- Content Side -->
|
||||
<div class="service-item-content">
|
||||
<!-- Category Tag -->
|
||||
<span class="service-category-tag">
|
||||
{service.category}
|
||||
</span>
|
||||
|
||||
<!-- Title -->
|
||||
<h2
|
||||
id={`service-${service.id}-title`}
|
||||
class="service-title"
|
||||
>
|
||||
{service.title}
|
||||
</h2>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="service-divider"></div>
|
||||
|
||||
<!-- Description -->
|
||||
<p class="service-description">
|
||||
{service.description}
|
||||
</p>
|
||||
|
||||
<!-- Icon (if provided) -->
|
||||
{
|
||||
service.icon && icons[service.icon as keyof typeof icons] && (
|
||||
<div class="service-icon" set:html={icons[service.icon as keyof typeof icons]} />
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Image Side -->
|
||||
<div class="service-item-image">
|
||||
<div class="image-wrapper">
|
||||
<img
|
||||
src={service.image || '/placeholder-service.jpg'}
|
||||
alt={service.title}
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Hot Badge -->
|
||||
{
|
||||
service.isHot && (
|
||||
<span class="service-hot-badge">HOT</span>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
/* Services List Styles - Pixel-perfect from Webflow */
|
||||
.section_service-list {
|
||||
background-color: #ffffff;
|
||||
padding: 60px 20px;
|
||||
}
|
||||
|
||||
.services-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Service Item - Zig-zag Layout */
|
||||
.service-item {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 40px;
|
||||
align-items: center;
|
||||
margin-bottom: 60px;
|
||||
text-decoration: none;
|
||||
position: relative;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
/* Odd items - content on left */
|
||||
.service-item.odd {
|
||||
grid-template-areas: "content image";
|
||||
}
|
||||
|
||||
.service-item.odd .service-item-content {
|
||||
grid-area: content;
|
||||
}
|
||||
|
||||
.service-item.odd .service-item-image {
|
||||
grid-area: image;
|
||||
}
|
||||
|
||||
/* Even items - content on right */
|
||||
.service-item.even {
|
||||
grid-template-areas: "image content";
|
||||
}
|
||||
|
||||
.service-item.even .service-item-content {
|
||||
grid-area: content;
|
||||
}
|
||||
|
||||
.service-item.even .service-item-image {
|
||||
grid-area: image;
|
||||
}
|
||||
|
||||
/* Hover Effects */
|
||||
.service-item:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.service-item:hover .service-title {
|
||||
color: var(--color-enchunblue);
|
||||
}
|
||||
|
||||
/* Category Tag */
|
||||
.service-category-tag {
|
||||
display: inline-block;
|
||||
padding: 6px 14px;
|
||||
background-color: var(--color-enchunblue);
|
||||
color: white;
|
||||
border-radius: 20px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* Title */
|
||||
.service-title {
|
||||
font-family: "Noto Sans TC", sans-serif;
|
||||
font-weight: 700;
|
||||
font-size: 1.75rem;
|
||||
color: var(--color-dark-blue);
|
||||
margin-bottom: 16px;
|
||||
line-height: 1.3;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
/* Divider */
|
||||
.service-divider {
|
||||
width: 60px;
|
||||
height: 2px;
|
||||
background-color: var(--color-enchunblue);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* Description */
|
||||
.service-description {
|
||||
font-family: "Quicksand", "Noto Sans TC", sans-serif;
|
||||
font-weight: 400;
|
||||
font-size: 1rem;
|
||||
color: var(--color-gray-700);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Image Side */
|
||||
.service-item-image {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.image-wrapper {
|
||||
aspect-ratio: 16/9;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: var(--color-gray-100);
|
||||
}
|
||||
|
||||
.image-wrapper img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.service-item:hover .image-wrapper img {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
/* Hot Badge */
|
||||
.service-hot-badge {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
background-color: var(--color-notification-red);
|
||||
color: white;
|
||||
padding: 6px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Service Icon - shown by default when content exists */
|
||||
.service-item-content .service-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Show icon only if icon content exists (not hidden) */
|
||||
.service-item-content:has(.service-icon) .service-icon {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Responsive Adjustments */
|
||||
@media (max-width: 991px) {
|
||||
.service-item {
|
||||
gap: 24px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.service-title {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.section_service-list {
|
||||
padding: 40px 16px;
|
||||
}
|
||||
|
||||
.service-item {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-areas: "image" "content" !important;
|
||||
gap: 24px;
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
.service-item-content {
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.service-item-image {
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.service-title {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.service-description {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.service-hot-badge {
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
padding: 4px 10px;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
94
apps/frontend/src/sections/SolutionsHero.astro
Normal file
94
apps/frontend/src/sections/SolutionsHero.astro
Normal file
@@ -0,0 +1,94 @@
|
||||
---
|
||||
/**
|
||||
* SolutionsHero - Hero section for Solutions/Services page
|
||||
* Pixel-perfect implementation based on Webflow design
|
||||
*/
|
||||
interface Props {
|
||||
title?: string
|
||||
subtitle?: string
|
||||
}
|
||||
|
||||
const {
|
||||
title = '行銷解決方案',
|
||||
subtitle = '提供全方位的數位行銷服務,協助您的品牌在數位時代脫穎而出',
|
||||
} = Astro.props
|
||||
---
|
||||
|
||||
<section class="hero-overlay-solution">
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<!-- Main Title -->
|
||||
<h1 class="hero_title_head-solution">
|
||||
{title}
|
||||
</h1>
|
||||
|
||||
<!-- Subtitle -->
|
||||
<p class="hero_sub_paragraph-solution">
|
||||
{subtitle}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
/* Solutions Hero Styles - Pixel-perfect from Webflow */
|
||||
.hero-overlay-solution {
|
||||
background-color: var(--color-dark-blue);
|
||||
max-height: 63.5vh;
|
||||
padding: 120px 20px 80px;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Title - 3.39em (64px at 19px base) */
|
||||
.hero_title_head-solution {
|
||||
color: #ffffff;
|
||||
font-family: "Noto Sans TC", sans-serif;
|
||||
font-weight: 700;
|
||||
font-size: 3.39em;
|
||||
line-height: 1.2;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* Subtitle - 1.56em (30px at 19px base) */
|
||||
.hero_sub_paragraph-solution {
|
||||
color: var(--color-gray-100);
|
||||
font-family: "Quicksand", "Noto Sans TC", sans-serif;
|
||||
font-weight: 400;
|
||||
font-size: 1.56em;
|
||||
line-height: 1.2;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Responsive Adjustments */
|
||||
@media (max-width: 991px) {
|
||||
.hero-overlay-solution {
|
||||
max-height: none;
|
||||
padding: 80px 20px 60px;
|
||||
}
|
||||
|
||||
.hero_title_head-solution {
|
||||
font-size: 2.45em;
|
||||
}
|
||||
|
||||
.hero_sub_paragraph-solution {
|
||||
font-size: 1.15em;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.hero-overlay-solution {
|
||||
padding: 60px 16px 40px;
|
||||
}
|
||||
|
||||
.hero_title_head-solution {
|
||||
font-size: 7vw;
|
||||
}
|
||||
|
||||
.hero_sub_paragraph-solution {
|
||||
font-size: 3.4vw;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
257
apps/frontend/src/sections/StatisticsSection.astro
Normal file
257
apps/frontend/src/sections/StatisticsSection.astro
Normal file
@@ -0,0 +1,257 @@
|
||||
---
|
||||
/**
|
||||
* StatisticsSection - Data showcase with countup animation
|
||||
* Features: 4 statistics, countup animation (3s), responsive grid
|
||||
*/
|
||||
import type { HomeData } from '@/lib/api/home'
|
||||
|
||||
interface Props {
|
||||
homeData?: HomeData | null
|
||||
}
|
||||
|
||||
const { homeData } = Astro.props
|
||||
|
||||
// Statistics data - matching reference HTML exactly
|
||||
const statistics = [
|
||||
{
|
||||
number: 500,
|
||||
suffix: '',
|
||||
percentage: '',
|
||||
description: '至今協助商家數',
|
||||
},
|
||||
{
|
||||
number: 150,
|
||||
suffix: '.0',
|
||||
percentage: '%',
|
||||
description: '提升成長率',
|
||||
},
|
||||
{
|
||||
number: 98,
|
||||
suffix: '.0',
|
||||
percentage: '%',
|
||||
description: '客戶滿意度',
|
||||
},
|
||||
{
|
||||
number: 95,
|
||||
suffix: '.0',
|
||||
percentage: '%',
|
||||
description: '客戶續約率',
|
||||
},
|
||||
]
|
||||
---
|
||||
|
||||
<!-- Statistics Section - Matching reference HTML structure with H1 heading -->
|
||||
<section class="section-digi-running" aria-labelledby="statistics-heading">
|
||||
<!-- Section Header - H1 "數據會說話" -->
|
||||
<div class="section-header-w-line">
|
||||
<h1 id="statistics-heading" class="statistics-heading">
|
||||
數據會說話
|
||||
</h1>
|
||||
<p class="statistics-subheading">
|
||||
Statistics reveal the truth
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Statistics Data Grid -->
|
||||
<div class="digi-holder-grid">
|
||||
{
|
||||
statistics.map((stat) => (
|
||||
<div class="digi-item" data-number={stat.number}>
|
||||
<!-- Number -->
|
||||
<div class="text-no">
|
||||
<span class="countup-number" data-target={stat.number}>0</span>
|
||||
{stat.suffix}
|
||||
</div>
|
||||
|
||||
<!-- Percentage (if separate from suffix) -->
|
||||
{
|
||||
stat.percentage && (
|
||||
<span class="text-percentage">{stat.percentage}</span>
|
||||
)
|
||||
}
|
||||
|
||||
<!-- Description -->
|
||||
<p class="text-description">{stat.description}</p>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
/* Statistics Section Styles - Pixel-perfect from Webflow */
|
||||
.section-digi-running {
|
||||
padding: 64px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Section Header - Matching reference HTML */
|
||||
.section-header-w-line {
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.statistics-heading {
|
||||
font-family: "Noto Sans TC", sans-serif;
|
||||
font-size: 1.8em; /* 34.2px at 19px base */
|
||||
font-weight: 700;
|
||||
color: var(--color-dark-blue, #062841);
|
||||
text-align: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.statistics-subheading {
|
||||
font-family: "Quicksand", "Noto Sans TC", sans-serif;
|
||||
font-size: 1rem;
|
||||
font-weight: 400;
|
||||
color: var(--color-gray-600, #666);
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.digi-holder-grid {
|
||||
display: grid;
|
||||
grid-column-gap: 85px;
|
||||
grid-row-gap: 16px;
|
||||
grid-template-columns: repeat(4, max-content);
|
||||
justify-content: center;
|
||||
max-width: 1150px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.digi-item {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Large Number - 72px with amber color */
|
||||
.text-no {
|
||||
font-size: 72px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
color: var(--color-amber);
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.countup-number {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* Percentage - 36px */
|
||||
.text-percentage {
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
color: var(--color-gray-700);
|
||||
}
|
||||
|
||||
/* Description - 30px */
|
||||
.text-description {
|
||||
font-size: 30px;
|
||||
font-weight: 100;
|
||||
line-height: 1.2;
|
||||
color: var(--color-dark-blue);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* Responsive Adjustments */
|
||||
@media (max-width: 991px) {
|
||||
.digi-holder-grid {
|
||||
grid-template-columns: repeat(2, max-content);
|
||||
grid-column-gap: 40px;
|
||||
}
|
||||
|
||||
.text-no {
|
||||
font-size: 56px;
|
||||
}
|
||||
|
||||
.text-percentage {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.text-description {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.digi-holder-grid {
|
||||
grid-template-columns: max-content;
|
||||
grid-column-gap: 24px;
|
||||
grid-row-gap: 32px;
|
||||
}
|
||||
|
||||
.text-no {
|
||||
font-size: 48px;
|
||||
}
|
||||
|
||||
.text-percentage {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.text-description {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Countup animation function
|
||||
function animateCountup(element: Element, target: number, duration: number = 3000) {
|
||||
const start = 0
|
||||
const startTime = performance.now()
|
||||
|
||||
function update(currentTime: number) {
|
||||
const elapsed = currentTime - startTime
|
||||
const progress = Math.min(elapsed / duration, 1)
|
||||
|
||||
// Easing function (ease-out)
|
||||
const easeOut = 1 - Math.pow(1 - progress, 3)
|
||||
const current = Math.floor(start + (target - start) * easeOut)
|
||||
|
||||
element.textContent = current.toString()
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(update)
|
||||
} else {
|
||||
element.textContent = target.toString()
|
||||
}
|
||||
}
|
||||
|
||||
requestAnimationFrame(update)
|
||||
}
|
||||
|
||||
// Intersection Observer for triggering animation when visible
|
||||
function initStatisticsCountup() {
|
||||
const observerOptions = {
|
||||
root: null,
|
||||
rootMargin: '0px',
|
||||
threshold: 0.5,
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
const numbers = entry.target.querySelectorAll('.countup-number')
|
||||
numbers.forEach(num => {
|
||||
const target = parseInt(num.getAttribute('data-target') || '0', 10)
|
||||
animateCountup(num, target, 3000)
|
||||
})
|
||||
observer.unobserve(entry.target)
|
||||
}
|
||||
})
|
||||
}, observerOptions)
|
||||
|
||||
const section = document.querySelector('.section-digi-running')
|
||||
if (section) {
|
||||
observer.observe(section)
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', initStatisticsCountup)
|
||||
if (document.readyState !== 'loading') {
|
||||
initStatisticsCountup()
|
||||
}
|
||||
</script>
|
||||
95
apps/frontend/src/sections/TeamsHero.astro
Normal file
95
apps/frontend/src/sections/TeamsHero.astro
Normal file
@@ -0,0 +1,95 @@
|
||||
---
|
||||
/**
|
||||
* TeamsHero - Hero section for Teams page
|
||||
* Pixel-perfect implementation based on Webflow design
|
||||
*/
|
||||
interface Props {
|
||||
title?: string
|
||||
subtitle?: string
|
||||
}
|
||||
|
||||
const {
|
||||
title = '恩群大本營',
|
||||
subtitle = 'Team members of Enchun',
|
||||
} = Astro.props
|
||||
---
|
||||
|
||||
<header class="hero-overlay-team">
|
||||
<div class="centered-container w-container">
|
||||
<div class="div-block">
|
||||
<h1 class="hero_title_head-team">{title}</h1>
|
||||
<p class="hero_sub_paragraph-team">{subtitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<style>
|
||||
/* Teams Hero Styles - Pixel-perfect from Webflow */
|
||||
.hero-overlay-team {
|
||||
background-color: var(--color-dark-blue);
|
||||
padding: 120px 20px 80px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.centered-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.w-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.div-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.hero_title_head-team {
|
||||
color: #ffffff;
|
||||
font-family: "Noto Sans TC", sans-serif;
|
||||
font-weight: 700;
|
||||
font-size: 3.39em;
|
||||
line-height: 1.2;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.hero_sub_paragraph-team {
|
||||
color: var(--color-gray-100);
|
||||
font-family: "Quicksand", "Noto Sans TC", sans-serif;
|
||||
font-weight: 400;
|
||||
font-size: 1.5em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
/* Responsive Adjustments */
|
||||
@media (max-width: 991px) {
|
||||
.hero-overlay-team {
|
||||
padding: 80px 20px 60px;
|
||||
}
|
||||
|
||||
.hero_title_head-team {
|
||||
font-size: 2.45em;
|
||||
}
|
||||
|
||||
.hero_sub_paragraph-team {
|
||||
font-size: 1.15em;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.hero-overlay-team {
|
||||
padding: 60px 16px 40px;
|
||||
}
|
||||
|
||||
.hero_title_head-team {
|
||||
font-size: 7vw;
|
||||
}
|
||||
|
||||
.hero_sub_paragraph-team {
|
||||
font-size: 3.4vw;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,204 +1,385 @@
|
||||
/* Theme CSS Variables and Custom Styles */
|
||||
/**
|
||||
* Theme CSS Variables and Custom Styles
|
||||
* 恩群數位行銷 - Enchun Digital Marketing
|
||||
*
|
||||
* Design tokens extracted from Webflow CSS
|
||||
* Last Updated: 2026-01-31
|
||||
*/
|
||||
|
||||
/* ============================================
|
||||
CSS CUSTOM PROPERTIES - DESIGN TOKENS
|
||||
============================================ */
|
||||
|
||||
/* CSS Custom Properties for Theme */
|
||||
:root {
|
||||
/* Color Palette */
|
||||
--color-primary: #1F3A93;
|
||||
--color-secondary: #F39C12;
|
||||
--color-accent: #16A085;
|
||||
--color-enchunblue: #3083BF;
|
||||
--color-background: #FFFFFF;
|
||||
--color-surface: #F7FAFC;
|
||||
--color-text: #1A202C;
|
||||
--color-text-muted: #718096;
|
||||
--color-border: #E2E8F0;
|
||||
/*
|
||||
Purpose:
|
||||
Define extended Enchun brand color palette as CSS custom properties, all prefixed with --color- for consistency and semantic clarity.
|
||||
This ensures all color variables are easily discoverable and maintainable across the codebase.
|
||||
*/
|
||||
--color-alabaster: #fafafa;
|
||||
--color-alto: #d1d1d1;
|
||||
--color-amber: #ffc107;
|
||||
--color-black: #000000;
|
||||
--color-boston-blue: #3083bf;
|
||||
--color-concrete: #f2f2f2;
|
||||
--color-cream-can: #f6c456;
|
||||
--color-dove-gray: #6b6b6b;
|
||||
--color-dusty-gray: #999999;
|
||||
--color-emperor: #4f4f4f;
|
||||
--color-gray: #878787;
|
||||
--color-killarney: #3c6f50;
|
||||
--color-lucky-point: #171c61;
|
||||
--color-manatee: #939494;
|
||||
--color-mercury: #e3e3e3;
|
||||
--color-mine-shaft: #333333;
|
||||
--color-mine-shaft-60: #222222;
|
||||
--color-nobel: #b6b6b6;
|
||||
--color-oslo-gray: #939494;
|
||||
--color-pomegranate: #f44336;
|
||||
--color-silver: #bdbdbd;
|
||||
--color-silver-chalice: #acacac;
|
||||
--color-st-tropaz: #2b618f;
|
||||
--color-tarawera: #062841;
|
||||
--color-tropical-blue: #c7e4fa;
|
||||
--color-tundora: #4d4d4d;
|
||||
--color-turbo: #ffef00;
|
||||
--color-valencia: #d84038;
|
||||
--color-viking: #67aee1;
|
||||
--color-white: #ffffff;
|
||||
--color-wild-sand: #f6f6f6;
|
||||
/* ============================================
|
||||
🎨 COLORS - Extracted from Webflow CSS
|
||||
============================================ */
|
||||
|
||||
/* Typography */
|
||||
--font-family-sans: 'Noto Sans CJK TC', 'Inter', system-ui, -apple-system, sans-serif;
|
||||
--font-family-heading: 'Noto Sans CJK TC', 'Inter', system-ui, -apple-system, sans-serif;
|
||||
/* Primary Colors (主要色) */
|
||||
--color-primary: #3898ec; /* Primary blue */
|
||||
--color-primary-dark: #0082f3; /* Deep blue */
|
||||
--color-primary-light: #67aee1; /* Light blue */
|
||||
--color-primary-hover: #2895f7; /* Hover blue */
|
||||
|
||||
/* Spacing */
|
||||
--spacing-xs: 0.25rem;
|
||||
--spacing-sm: 0.5rem;
|
||||
--spacing-md: 1rem;
|
||||
--spacing-lg: 1.5rem;
|
||||
--spacing-xl: 2rem;
|
||||
--spacing-2xl: 3rem;
|
||||
/* Secondary Colors (次要色) */
|
||||
--color-secondary: #f39c12; /* Secondary orange */
|
||||
--color-secondary-light: #f6c456; /* Light pink */
|
||||
--color-secondary-dark: #d84038; /* Deep orange */
|
||||
|
||||
/* Border Radius */
|
||||
--radius-sm: 0.25rem;
|
||||
--radius-md: 0.5rem;
|
||||
--radius-lg: 0.75rem;
|
||||
--radius-xl: 1rem;
|
||||
/* Accent Colors (強調色) */
|
||||
--color-accent: #d84038; /* Accent red-orange */
|
||||
--color-accent-light: #f6c456; /* Light pink */
|
||||
--color-accent-dark: #ea384c; /* Deep red */
|
||||
--color-accent-pink: #bb8282; /* Pink */
|
||||
--color-accent-rose: #c48383; /* Rose */
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
||||
/* Link Colors (連結色) */
|
||||
--color-link: #3083bf; /* Default link */
|
||||
--color-link-hover: #23608c; /* Link hover */
|
||||
|
||||
/* Transitions */
|
||||
--transition-fast: 150ms ease-in-out;
|
||||
--transition-normal: 250ms ease-in-out;
|
||||
--transition-slow: 350ms ease-in-out;
|
||||
/* Neutral Colors (中性色 - Tailwind Slate mapping) */
|
||||
--color-white: #ffffff; /* White */
|
||||
--color-black: #000000; /* Black */
|
||||
--color-gray-50: #fafafa; /* Surface lightest */
|
||||
--color-gray-100: #f5f5f5; /* Surface light */
|
||||
--color-gray-200: #f3f3f3; /* Surface */
|
||||
--color-gray-300: #eeeeee; /* Border light */
|
||||
--color-gray-400: #dddddd; /* Border default */
|
||||
--color-gray-500: #c8c8c8; /* Mid gray */
|
||||
--color-gray-600: #999999; /* Text muted */
|
||||
--color-gray-700: #828282; /* Text dark */
|
||||
--color-gray-800: #758696; /* Dark gray 1 */
|
||||
--color-gray-900: #5d6c7b; /* Dark gray 2 */
|
||||
--color-gray-950: #4f4f4f; /* Darkest gray */
|
||||
|
||||
/* Semantic Colors (語意化顏色) */
|
||||
--color-text-primary: #333333; /* Primary text */
|
||||
--color-text-secondary: #222222; /* Secondary text */
|
||||
--color-text-muted: #999999; /* Muted text */
|
||||
--color-text-light: #758696; /* Light text on dark */
|
||||
--color-text-inverse: #ffffff; /* Inverse (white) */
|
||||
|
||||
/* Backgrounds (背景色) */
|
||||
--color-background: #f2f2f2; /* Main background - Grey 6 from Webflow */
|
||||
--color-surface: #fafafa; /* Surface background */
|
||||
--color-surface2: #f3f3f3; /* Surface elevated */
|
||||
--color-surface-dark: #2226; /* Dark background */
|
||||
|
||||
/* Borders (邊框色) */
|
||||
--color-border: #e2e8f0; /* Default border */
|
||||
--color-border-light: #dddddd; /* Light border */
|
||||
|
||||
/* Category Colors (文章分類) */
|
||||
--color-category-google: #67aee1; /* Google小學堂 */
|
||||
--color-category-meta: #8974de; /* Meta小學堂 */
|
||||
--color-category-news: #3083bf; /* 行銷時事最前線 */
|
||||
--color-category-enchun: #3898ec; /* 恩群數位 */
|
||||
|
||||
/* Badge Colors (標籤) */
|
||||
--color-badge-hot: #ea384c; /* Hot 標籤 (紅) */
|
||||
--color-badge-new: #67aee1; /* New 標籤 (淡藍) */
|
||||
|
||||
/* Webflow Colors - Story 1-4 Global Layout (Verified against original) */
|
||||
--color-enchunblue: #23608c; /* Enchun Blue - 品牌/主色 */
|
||||
--color-enchunblue-dark: #3083bf; /* Enchun Blue Dark - 品牌/主色深色 */
|
||||
--color-tropical-blue: #c7e4fa; /* Tropical Blue - 頁腳背景 (verified: rgb(199, 228, 250)) */
|
||||
--color-st-tropaz: #5d7285; /* St. Tropaz - 頁腳文字 */
|
||||
--color-amber: #f6c456; /* Amber - CTA/強調 */
|
||||
--color-tarawera: #2d3748; /* Tarawera - 深色文字 */
|
||||
--color-nav-link: var(--color-gray-200); /* Navigation Link - 使用灰色系 */
|
||||
|
||||
/* Webflow Additional Colors - Story 1-5 Homepage */
|
||||
--color-notification-red: #d84038; /* Notification Red - CTA Button */
|
||||
--color-dark-blue: #062841; /* Dark Blue - Headings */
|
||||
--color-medium-blue: #67aee1; /* Medium Blue - Accents */
|
||||
--color-grey5: #e0e0e0; /* Grey 5 - Borders */
|
||||
--color-grey6: #f2f2f2; /* Grey 6 - Backgrounds */
|
||||
|
||||
/* ============================================
|
||||
🔤 TYPOGRAPHY - From Webflow
|
||||
============================================ */
|
||||
|
||||
--font-family-sans: "Noto Sans TC", "Quicksand", Arial, sans-serif;
|
||||
--font-family-heading: "Noto Sans TC", "Quicksand", Arial, sans-serif;
|
||||
--font-family-accent: "Quicksand", "Noto Sans TC", sans-serif;
|
||||
|
||||
/* ============================================
|
||||
📏 SPACING - Based on Tailwind scale
|
||||
============================================ */
|
||||
|
||||
--spacing-xs: 0.25rem; /* 4px */
|
||||
--spacing-sm: 0.5rem; /* 8px */
|
||||
--spacing-md: 1rem; /* 16px */
|
||||
--spacing-lg: 1.5rem; /* 24px */
|
||||
--spacing-xl: 2rem; /* 32px */
|
||||
--spacing-2xl: 3rem; /* 48px */
|
||||
--spacing-3xl: 4rem; /* 64px */
|
||||
|
||||
/* Container */
|
||||
--container-max-width: 1200px;
|
||||
--container-padding: 1.5rem;
|
||||
--container-padding-lg: 2rem;
|
||||
|
||||
/* ============================================
|
||||
🔲 BORDER RADIUS
|
||||
============================================ */
|
||||
|
||||
--radius-sm: 0.125rem; /* 2px */
|
||||
--radius: 0.375rem; /* 6px (DEFAULT) */
|
||||
--radius-md: 0.5rem; /* 8px */
|
||||
--radius-lg: 0.75rem; /* 12px */
|
||||
--radius-xl: 1rem; /* 16px */
|
||||
--radius-2xl: 1.5rem; /* 24px */
|
||||
--radius-3xl: 2rem; /* 32px */
|
||||
--radius-full: 9999px; /* Full circle */
|
||||
|
||||
/* ============================================
|
||||
💫 SHADOWS
|
||||
============================================ */
|
||||
|
||||
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||
--shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px 0 rgb(0 0 0 / 0.06);
|
||||
--shadow-md:
|
||||
0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||
--shadow-lg:
|
||||
0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
||||
|
||||
/* ============================================
|
||||
⏱ TRANSITIONS
|
||||
============================================ */
|
||||
|
||||
--transition-fast: 150ms ease-in-out;
|
||||
--transition-base: 200ms ease-in-out;
|
||||
--transition-normal: 250ms ease-in-out;
|
||||
--transition-slow: 350ms ease-in-out;
|
||||
|
||||
/* ============================================
|
||||
🎯 Z-INDEX LAYERS
|
||||
============================================ */
|
||||
|
||||
--z-dropdown: 1000;
|
||||
--z-sticky: 1020;
|
||||
--z-modal: 1040;
|
||||
--z-popover: 1060;
|
||||
--z-tooltip: 1080;
|
||||
|
||||
/* ============================================
|
||||
📱 RESPONSIVE - Font Size Adjustments (Webflow)
|
||||
============================================ */
|
||||
|
||||
/* Webflow breakpoints:
|
||||
Desktop default: 19px
|
||||
Tablet (≤991px): 19px
|
||||
Mobile (≤767px): 16px
|
||||
Small (≤479px): 13px
|
||||
*/
|
||||
--html-font-size-desktop: 19px;
|
||||
--html-font-size-tablet: 19px; /* 991px breakpoint */
|
||||
--html-font-size-mobile: 16px; /* 767px breakpoint */
|
||||
--html-font-size-small: 13px; /* 479px breakpoint */
|
||||
}
|
||||
|
||||
/* Dark Theme (if needed in future) */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--color-background: #1A202C;
|
||||
--color-surface: #2D3748;
|
||||
--color-text: #F7FAFC;
|
||||
--color-text-muted: #A0AEC0;
|
||||
--color-border: #4A5568;
|
||||
}
|
||||
}
|
||||
/* ============================================
|
||||
🌙 DARK MODE (Optional - for future use)
|
||||
============================================ */
|
||||
|
||||
/*@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--color-background: #1a202c;
|
||||
--color-surface: #2d3748;
|
||||
--color-surface2: #1a202c;
|
||||
--color-text: #f7fafc;
|
||||
--color-text-muted: #a0aec0;
|
||||
--color-text-secondary: #e2e8f0;
|
||||
--color-border: #4a5568;
|
||||
}
|
||||
}*/
|
||||
|
||||
/* ============================================
|
||||
📐 BASE STYLES
|
||||
============================================ */
|
||||
|
||||
/* Base Styles */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
font-family: var(--font-family-sans);
|
||||
line-height: 1.6;
|
||||
color: var(--color-text);
|
||||
background-color: var(--color-background);
|
||||
font-family: var(--font-family-sans);
|
||||
line-height: 1.5;
|
||||
color: var(--color-text-primary);
|
||||
background-color: var(--color-background);
|
||||
font-size: var(--html-font-size-desktop);
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
line-height: inherit;
|
||||
color: inherit;
|
||||
background-color: inherit;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
line-height: inherit;
|
||||
color: inherit;
|
||||
background-color: inherit;
|
||||
}
|
||||
|
||||
/* Typography Classes */
|
||||
/* ============================================
|
||||
📱 RESPONSIVE FONT SIZE ADJUSTMENTS
|
||||
============================================ */
|
||||
|
||||
@media (max-width: 991px) {
|
||||
html {
|
||||
font-size: var(--html-font-size-tablet);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
html {
|
||||
font-size: var(--html-font-size-mobile);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 479px) {
|
||||
html {
|
||||
font-size: var(--html-font-size-small);
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
🎨 UTILITY CLASSES
|
||||
============================================ */
|
||||
|
||||
/* Text Gradient */
|
||||
.text-gradient {
|
||||
background: linear-gradient(135deg, var(--color-primary), var(--color-accent));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--color-primary),
|
||||
var(--color-accent)
|
||||
);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
/* Animation Classes */
|
||||
/* Animations */
|
||||
.fade-in {
|
||||
animation: fadeIn var(--transition-normal) ease-in-out;
|
||||
animation: fadeIn var(--transition-normal) ease-in-out;
|
||||
}
|
||||
|
||||
.slide-up {
|
||||
animation: slideUp var(--transition-normal) ease-in-out;
|
||||
animation: slideUp var(--transition-normal) ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(1rem);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(1rem);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Utility Classes */
|
||||
/* Glass Effect */
|
||||
.glass-effect {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
/* Shadow Custom */
|
||||
.shadow-custom {
|
||||
box-shadow: var(--shadow-lg);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
/* Component Specific Styles */
|
||||
/* ============================================
|
||||
🔘 BUTTON STYLES
|
||||
============================================ */
|
||||
|
||||
.btn-primary {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
padding: var(--spacing-sm) var(--spacing-lg);
|
||||
border-radius: var(--radius);
|
||||
border: none;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--color-primary-hover);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--color-secondary);
|
||||
color: white;
|
||||
padding: var(--spacing-sm) var(--spacing-lg);
|
||||
border-radius: var(--radius);
|
||||
border: none;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--color-secondary-dark);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
🧭 NAVIGATION STYLES
|
||||
============================================ */
|
||||
|
||||
.nav-link {
|
||||
position: relative;
|
||||
transition: color var(--transition-fast);
|
||||
position: relative;
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.nav-link::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
left: 0;
|
||||
width: 0;
|
||||
height: 2px;
|
||||
background: var(--color-primary);
|
||||
transition: width var(--transition-fast);
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
left: 0;
|
||||
width: 0;
|
||||
height: 2px;
|
||||
background: var(--color-primary);
|
||||
transition: width var(--transition-fast);
|
||||
}
|
||||
|
||||
.nav-link:hover::after {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Active Navigation Link Indicator */
|
||||
.nav-active {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Active Navigation Link */
|
||||
.nav-active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 70%;
|
||||
height: 2px;
|
||||
background: var(--color-secondary);
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 70%;
|
||||
height: 2px;
|
||||
background: var(--color-secondary);
|
||||
}
|
||||
|
||||
/* Prose/Markdown Styles */
|
||||
/* ============================================
|
||||
📝 PROSE / MARKDOWN STYLES
|
||||
============================================ */
|
||||
|
||||
.prose-custom {
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-family-sans);
|
||||
color: var(--color-text-primary);
|
||||
font-family: var(--font-family-sans);
|
||||
}
|
||||
|
||||
.prose-custom h1,
|
||||
@@ -207,158 +388,160 @@ body {
|
||||
.prose-custom h4,
|
||||
.prose-custom h5,
|
||||
.prose-custom h6 {
|
||||
color: var(--color-text);
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
margin-top: var(--spacing-xl);
|
||||
margin-bottom: var(--spacing-md);
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
margin-top: var(--spacing-xl);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.prose-custom h1 {
|
||||
font-size: 2.25rem;
|
||||
font-size: 2.25rem;
|
||||
}
|
||||
|
||||
.prose-custom h2 {
|
||||
font-size: 1.875rem;
|
||||
font-size: 1.875rem;
|
||||
}
|
||||
|
||||
.prose-custom h3 {
|
||||
font-size: 1.5rem;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.prose-custom p {
|
||||
margin-bottom: var(--spacing-md);
|
||||
line-height: 1.7;
|
||||
margin-bottom: var(--spacing-md);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.prose-custom a {
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
transition: color var(--transition-fast);
|
||||
color: var(--color-link);
|
||||
text-decoration: none;
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.prose-custom a:hover {
|
||||
color: #1a2f7a;
|
||||
text-decoration: underline;
|
||||
color: var(--color-link-hover);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.prose-custom strong {
|
||||
color: var(--color-text);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.prose-custom em {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.prose-custom ul,
|
||||
.prose-custom ol {
|
||||
margin-bottom: var(--spacing-md);
|
||||
padding-left: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.prose-custom li {
|
||||
margin-bottom: var(--spacing-xs);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.prose-custom blockquote {
|
||||
border-left: 4px solid var(--color-primary);
|
||||
padding-left: var(--spacing-md);
|
||||
margin: var(--spacing-lg) 0;
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
background: var(--color-surface);
|
||||
padding: var(--spacing-md);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.prose-custom code {
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
padding: 0.125rem 0.25rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 0.875em;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-primary);
|
||||
padding: 0.125rem 0.25rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: "Monaco", "Menlo", monospace;
|
||||
font-size: 0.875em;
|
||||
}
|
||||
|
||||
.prose-custom pre {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--spacing-md);
|
||||
overflow-x: auto;
|
||||
margin: var(--spacing-lg) 0;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--spacing-md);
|
||||
overflow-x: auto;
|
||||
margin: var(--spacing-lg) 0;
|
||||
}
|
||||
|
||||
.prose-custom pre code {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
.prose-custom blockquote {
|
||||
border-left: 4px solid var(--color-primary);
|
||||
padding-left: var(--spacing-md);
|
||||
margin: var(--spacing-lg) 0;
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
background: var(--color-surface);
|
||||
padding: var(--spacing-md);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.prose-custom hr {
|
||||
border: 0;
|
||||
border-top: 1px solid var(--color-border);
|
||||
margin: var(--spacing-2xl) 0;
|
||||
}
|
||||
/* ============================================
|
||||
📊 TABLE STYLES
|
||||
============================================ */
|
||||
|
||||
.prose-custom table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: var(--spacing-lg) 0;
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: var(--spacing-lg) 0;
|
||||
}
|
||||
|
||||
.prose-custom th,
|
||||
.prose-custom td {
|
||||
border: 1px solid var(--color-border);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
text-align: left;
|
||||
border: 1px solid var(--color-border);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.prose-custom th {
|
||||
background: var(--color-surface);
|
||||
font-weight: 600;
|
||||
background: var(--color-surface);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
🖼️ IMAGE STYLES
|
||||
============================================ */
|
||||
|
||||
.prose-custom img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: var(--radius-md);
|
||||
margin: var(--spacing-md) 0;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: var(--radius-md);
|
||||
margin: var(--spacing-md) 0;
|
||||
}
|
||||
|
||||
/* Button Styles */
|
||||
.btn-primary {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
padding: var(--spacing-sm) var(--spacing-lg);
|
||||
border-radius: var(--radius-md);
|
||||
border: none;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
/* ============================================
|
||||
📏 HR (DIVIDER) STYLES
|
||||
============================================ */
|
||||
|
||||
.prose-custom hr {
|
||||
border: 0;
|
||||
border-top: 1px solid var(--color-border);
|
||||
margin: var(--spacing-2xl) 0;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #1a2f7a;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-md);
|
||||
/* ============================================
|
||||
🏷️ CATEGORY BADGES
|
||||
============================================ */
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--color-secondary);
|
||||
color: white;
|
||||
padding: var(--spacing-sm) var(--spacing-lg);
|
||||
border-radius: var(--radius-md);
|
||||
border: none;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
.badge-hot {
|
||||
background-color: var(--color-badge-hot);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #e08e0b;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
.badge-new {
|
||||
background-color: var(--color-badge-new);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Category Badge Colors */
|
||||
.badge-category-google {
|
||||
background-color: var(--color-category-google);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge-category-meta {
|
||||
background-color: var(--color-category-meta);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge-category-news {
|
||||
background-color: var(--color-category-news);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge-category-enchun {
|
||||
background-color: var(--color-category-enchun);
|
||||
color: white;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@shared/*": ["../packages/shared/src/*"]
|
||||
}
|
||||
},
|
||||
|
||||
23
apps/frontend/wrangler.jsonc
Normal file
23
apps/frontend/wrangler.jsonc
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "./node_modules/wrangler/config-schema.json",
|
||||
"name": "enchun-frontend",
|
||||
"main": "./dist/_worker.js/index.js",
|
||||
"compatibility_date": "2025-01-19",
|
||||
"compatibility_flags": [
|
||||
"nodejs_compat"
|
||||
],
|
||||
"assets": {
|
||||
"directory": "./dist"
|
||||
},
|
||||
"vars": {
|
||||
"PAYLOAD_CMS_URL": "https://enchun-admin.anlstudio.cc"
|
||||
},
|
||||
"env": {
|
||||
"production": {
|
||||
"name": "enchun-frontend-production",
|
||||
"vars": {
|
||||
"PAYLOAD_CMS_URL": "https://enchun-admin.anlstudio.cc"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user