first commit

This commit is contained in:
2026-09-11 15:49:41 +08:00
commit 5b69bc818a
98 changed files with 30551 additions and 0 deletions
+36
View File
@@ -0,0 +1,36 @@
# src/components/site/AGENTS.md
## Purpose
前台 React 元件(Astro island 內容),用 Chakra UI v3 建立一頁式官網、Blog 列表同文章頁。
## Ownership
- 擁有 `src/components/site/` 所有 `.tsx` 元件。
- 唔擁有資料讀取(由 `.astro` 傳 props)同 Chakra theme(屬父層 `theme/system.ts`)。
## Local Contracts
- **Chakra v3 + Astro**:一個頁面只掛一個 React root island。`Provider``<ChakraProvider value={system}>`)由 `SiteChrome`(首頁)或 `BlogIndex` / `BlogPost` 包住。**唔可以用 `client:only`**(會失去 SEO)。
- **版型**`Section`(統一 max-width / padding / 背景 tone)+ `SectionHeading` 包每個 section。
- **Chrome**`SiteChrome` 組合 `Header` + `<main>` + `Footer` + `WhatsAppFab`Blog 頁自行組合同一批元件。
- **資料**:全部經 props 傳入(型別來自 `data/content.ts` / `db/schema.ts`);元件**唔直接讀 DB**。
- **樣式 token**:用 `theme/system.ts` 的語意 token`brand.*`=琥珀金、`ink`=墨黑、`fg.muted``border.subtle``bg.subtle`),避免散落硬編色值(現時仍有少量硬編 hex,新增時優先跟 token)。
- **設計風格**editorial 極簡風(見 `docs/superpowers/specs/2026-09-11-editorial-redesign-design.md`)——暖米白底、Noto Serif HK 標題、髮絲線分隔、編號式清單。**禁止**:漸層、背景光斑、`rounded-full` 膠囊掣、pill badge 眉題、陰影浮卡、icon 圓角磚。
- **圖示**`lucide-react` 幼線 icon`strokeWidth={1.5}` 為佳);內容項目**唔再用** `extra` 圖示代碼(`service``feature``extra` 已停用;`step``extra` 係步驟編號 `01``05`)。
- **響應式**:用 Chakra 的 `base` / `md` / `lg` object syntax。
- 文案一律繁體中文(廣東話)。
## Work Guidance
- 新增首頁 section:建立元件 → 喺 `HomePage.tsx` 接入 → 資料由 `getHomeData``data/content.ts`)提供。
- 新元件唔好各自建立 `Provider`,跟返現有最外層包法。
## Verification
- `npm run build`
- `npm run dev` 目測 RWD、錨點、FAQ accordion、WhatsApp link、無 console / hydration error。
## Child DOX Index
無。
+140
View File
@@ -0,0 +1,140 @@
import { Box, Container, Grid, HStack, Heading, Image, Stack, Text } from "@chakra-ui/react";
import { ArrowRight, CalendarDays } from "lucide-react";
import type { Settings } from "../../data/content";
import { Footer } from "./Footer";
import { Header } from "./Header";
import { Provider } from "./Provider";
import { WhatsAppFab } from "./WhatsAppFab";
export type BlogListItem = {
slug: string;
title: string;
excerpt: string | null;
coverImage: string | null;
dateLabel: string;
};
export function BlogIndex({ posts, settings }: { posts: BlogListItem[]; settings: Settings }) {
const [featured, ...rest] = posts;
return (
<Provider>
<Header settings={settings} />
<Box as="main" py={{ base: 14, md: 20 }} minH="60vh">
<Container maxW="1200px" px={{ base: 5, md: 8 }}>
<Stack gap="5" mb={{ base: 10, md: 14 }} pb={{ base: 8, md: 10 }} borderBottomWidth="2px" borderColor="ink">
<HStack gap="3" color="brand.700">
<Box w="8" h="2px" bg="brand.500" />
<Text fontSize="xs" fontWeight="700" letterSpacing="0.2em">
BLOG
</Text>
</HStack>
<Heading as="h1" fontSize={{ base: "4xl", md: "6xl" }} fontWeight="900" letterSpacing="-0.01em" color="ink">
</Heading>
<Text fontSize={{ base: "md", md: "lg" }} color="fg.muted" maxW="2xl">
</Text>
</Stack>
{posts.length === 0 ? (
<Text color="fg.muted"></Text>
) : (
<Stack gap={{ base: 10, md: 14 }}>
{/* 最新文章 featured */}
<Grid
asChild
templateColumns={{ base: "1fr", md: "1fr 1fr" }}
gap={{ base: 5, md: 10 }}
alignItems="center"
role="group"
cursor="pointer"
>
<a href={`/blog/${featured.slug}`}>
{featured.coverImage && (
<Box overflow="hidden">
<Image
src={featured.coverImage}
alt={featured.title}
w="full"
aspectRatio="16 / 10"
objectFit="cover"
transition="transform .5s"
_groupHover={{ transform: "scale(1.03)" }}
/>
</Box>
)}
<Stack gap="3">
<HStack gap="2" fontSize="xs" letterSpacing="0.1em" color="fg.muted" fontWeight="medium">
<CalendarDays size={13} />
{featured.dateLabel}
</HStack>
<Heading as="h2" fontSize={{ base: "xl", md: "3xl" }} fontWeight="900" color="ink" lineHeight="1.3" _groupHover={{ color: "brand.700" }} transition="color .2s">
{featured.title}
</Heading>
{featured.excerpt && (
<Text fontSize="sm" color="fg.muted" lineHeight="1.9">
{featured.excerpt}
</Text>
)}
<Text color="brand.700" fontWeight="600" fontSize="sm">
<ArrowRight size={14} style={{ display: "inline", verticalAlign: "-2px" }} />
</Text>
</Stack>
</a>
</Grid>
{/* 其餘文章:髮絲線清單 */}
{rest.length > 0 && (
<Stack gap="0" borderTopWidth="1px" borderColor="border.subtle">
{rest.map((post) => (
<Grid
key={post.slug}
asChild
templateColumns={{ base: "1fr", md: "140px 1fr auto" }}
gap={{ base: 2, md: 8 }}
alignItems={{ md: "center" }}
py={{ base: 5, md: 6 }}
borderBottomWidth="1px"
borderColor="border.subtle"
role="group"
cursor="pointer"
>
<a href={`/blog/${post.slug}`}>
<HStack gap="2" fontSize="xs" letterSpacing="0.08em" color="fg.muted" fontWeight="medium">
<CalendarDays size={13} />
{post.dateLabel}
</HStack>
<Stack gap="1">
<Heading as="h2" fontSize={{ base: "lg", md: "xl" }} fontWeight="700" color="ink" lineHeight="1.4" _groupHover={{ color: "brand.700" }} transition="color .2s">
{post.title}
</Heading>
{post.excerpt && (
<Text fontSize="sm" color="fg.muted" lineHeight="1.7" lineClamp={2}>
{post.excerpt}
</Text>
)}
</Stack>
<Box
color="brand.700"
opacity={{ base: 1, md: 0 }}
transform={{ md: "translateX(-6px)" }}
transition="all .2s"
_groupHover={{ opacity: 1, transform: "translateX(0)" }}
>
<ArrowRight size={20} />
</Box>
</a>
</Grid>
))}
</Stack>
)}
</Stack>
)}
</Container>
</Box>
<Footer settings={settings} />
<WhatsAppFab settings={settings} />
</Provider>
);
}
+85
View File
@@ -0,0 +1,85 @@
import { Box, Button, Container, Heading, Image, Link, Text, VStack } from "@chakra-ui/react";
import { ArrowLeft, CalendarDays } from "lucide-react";
import type { Settings } from "../../data/content";
import { Footer } from "./Footer";
import { Header } from "./Header";
import { Provider } from "./Provider";
import { WhatsAppFab } from "./WhatsAppFab";
const prose = {
color: "#3B3835",
fontSize: "1.05rem",
lineHeight: 1.95,
"& h2": { fontFamily: "'Noto Serif HK', serif", fontSize: "1.6rem", fontWeight: 700, mt: "2.5rem", mb: "0.75rem", color: "#1A1917", letterSpacing: "-0.01em" },
"& h3": { fontFamily: "'Noto Serif HK', serif", fontSize: "1.25rem", fontWeight: 700, mt: "2rem", mb: "0.5rem", color: "#1A1917" },
"& p": { mb: "1.25rem" },
"& ul, & ol": { pl: "1.5rem", mb: "1.25rem" },
"& li": { mb: "0.4rem" },
"& a": { color: "#B45309", textDecoration: "underline", textUnderlineOffset: "3px" },
"& strong": { fontWeight: 700, color: "#1A1917" },
"& blockquote": { borderLeft: "3px solid #D97706", pl: "1rem", color: "#6E6862", my: "1.5rem" },
"& img": { my: "1.5rem" },
"& code": { bg: "#F3EFE7", px: "6px", py: "2px", fontSize: "0.9em" },
"& pre": { bg: "#1A1917", color: "#FAF8F4", p: "1rem", overflowX: "auto", mb: "1.5rem" },
};
type Props = {
title: string;
dateLabel: string;
coverImage: string | null;
html: string;
settings: Settings;
};
export function BlogPost({ title, dateLabel, coverImage, html, settings }: Props) {
return (
<Provider>
<Header settings={settings} />
<Box as="main" py={{ base: 10, md: 16 }}>
<Container maxW="780px" px={{ base: 5, md: 8 }}>
<Link
href="/blog"
display="inline-flex"
alignItems="center"
gap="2"
fontSize="sm"
fontWeight="medium"
color="fg.muted"
mb="8"
_hover={{ color: "brand.700", textDecoration: "none" }}
>
<ArrowLeft size={16} />
Blog
</Link>
<VStack align="flex-start" gap="5" mb="10" pb="8" borderBottomWidth="2px" borderColor="ink">
<Heading as="h1" fontSize={{ base: "3xl", md: "5xl" }} fontWeight="900" letterSpacing="-0.01em" lineHeight="1.25" color="ink">
{title}
</Heading>
{dateLabel && (
<Text fontSize="xs" letterSpacing="0.1em" fontWeight="medium" color="fg.muted">
<CalendarDays size={14} style={{ display: "inline", marginRight: 6, verticalAlign: "-2px" }} />
{dateLabel}
</Text>
)}
</VStack>
{coverImage && (
<Image src={coverImage} alt={title} w="full" mb="10" objectFit="cover" />
)}
<Box css={prose} dangerouslySetInnerHTML={{ __html: html }} />
<Button asChild variant="outline" mt="12" rounded="none" borderColor="ink" color="ink" _hover={{ bg: "ink", color: "white" }}>
<a href="/blog">
<ArrowLeft size={16} />
</a>
</Button>
</Container>
</Box>
<Footer settings={settings} />
<WhatsAppFab settings={settings} />
</Provider>
);
}
+97
View File
@@ -0,0 +1,97 @@
import { Box, Grid, Heading, HStack, Image, Stack, Text } from "@chakra-ui/react";
import { MapPin } from "lucide-react";
import type { CaseStudy } from "../../db/schema";
import { Section } from "./Section";
import { SectionHeading } from "./SectionHeading";
export function Cases({ items }: { items: CaseStudy[] }) {
if (items.length === 0) return null;
const [featured, ...rest] = items;
return (
<Section id="cases" tone="white">
<SectionHeading
eyebrow="完成案例"
title="真實村屋工程案例"
description="我哋為香港各區村屋提供專業太陽能方案,累積豐富施工經驗。"
/>
<Stack gap={{ base: 12, md: 16 }}>
{/* 首案例 featured:雜誌式大圖排版 */}
<Grid
templateColumns={{ base: "1fr", lg: "1.2fr 0.8fr" }}
gap={{ base: 5, lg: 10 }}
alignItems="end"
role="group"
>
<Box overflow="hidden">
<Image
src={featured.imageUrl ?? undefined}
alt={featured.title}
w="full"
aspectRatio={{ base: "4 / 3", lg: "16 / 10" }}
objectFit="cover"
transition="transform .5s"
_groupHover={{ transform: "scale(1.03)" }}
/>
</Box>
<Stack gap="3">
<HStack gap="3" fontSize="xs" letterSpacing="0.1em" color="fg.muted" fontWeight="medium">
<Text>{featured.completedAt}</Text>
{featured.location && (
<HStack gap="1" color="brand.700">
<MapPin size={12} />
<Text>{featured.location}</Text>
</HStack>
)}
</HStack>
<Heading as="h3" fontSize={{ base: "xl", md: "2xl" }} fontWeight="700" color="ink" lineHeight="1.35" _groupHover={{ color: "brand.700" }} transition="color .2s">
{featured.title}
</Heading>
{featured.description && (
<Text fontSize="sm" color="fg.muted" lineHeight="1.9">
{featured.description}
</Text>
)}
</Stack>
</Grid>
{rest.length > 0 && (
<Grid templateColumns={{ base: "1fr", md: "repeat(2, 1fr)", lg: `repeat(${Math.min(rest.length, 3)}, 1fr)` }} gap={{ base: 10, md: 8 }}>
{rest.map((item) => (
<Stack key={item.id} gap="3" role="group" borderTopWidth="2px" borderColor="ink" pt="5">
<Box overflow="hidden">
<Image
src={item.imageUrl ?? undefined}
alt={item.title}
w="full"
aspectRatio="4 / 3"
objectFit="cover"
transition="transform .5s"
_groupHover={{ transform: "scale(1.03)" }}
/>
</Box>
<HStack gap="3" fontSize="xs" letterSpacing="0.1em" color="fg.muted" fontWeight="medium">
<Text>{item.completedAt}</Text>
{item.location && (
<HStack gap="1" color="brand.700">
<MapPin size={12} />
<Text>{item.location}</Text>
</HStack>
)}
</HStack>
<Heading as="h3" fontSize="lg" fontWeight="700" color="ink" lineHeight="1.4" _groupHover={{ color: "brand.700" }} transition="color .2s">
{item.title}
</Heading>
{item.description && (
<Text fontSize="sm" color="fg.muted" lineHeight="1.8">
{item.description}
</Text>
)}
</Stack>
))}
</Grid>
)}
</Stack>
</Section>
);
}
+65
View File
@@ -0,0 +1,65 @@
import { Box, Button, Container, Grid, HStack, Heading, Stack, Text } from "@chakra-ui/react";
import { Mail, MapPin, Phone } from "lucide-react";
import { mailHref, telHref, whatsappHref, type Settings } from "../../data/content";
import { WhatsAppIcon } from "./icons";
export function ContactCta({ settings }: { settings: Settings }) {
const contact = [
{ icon: Phone, label: settings.phone, href: telHref(settings) },
{ icon: Mail, label: settings.email, href: mailHref(settings) },
];
return (
<Box id="contact" bg="ink" color="white" py={{ base: 16, md: 24 }} scrollMarginTop="80px">
<Container maxW="1200px" px={{ base: 5, md: 8 }}>
<Grid templateColumns={{ base: "1fr", lg: "1.2fr 0.8fr" }} gap={{ base: 10, lg: 16 }} alignItems="center">
<Stack gap="6" align="flex-start">
<HStack gap="3" color="brand.400">
<Box w="8" h="2px" bg="brand.400" />
<Text fontSize="xs" fontWeight="700" letterSpacing="0.2em">
</Text>
</HStack>
<Heading as="h2" fontSize={{ base: "3xl", md: "5xl" }} fontWeight="900" letterSpacing="-0.01em" lineHeight="1.2">
</Heading>
<Text fontSize={{ base: "md", md: "lg" }} color="whiteAlpha.700" maxW="xl">
WhatsApp
</Text>
<HStack gap="4" flexWrap="wrap" pt="2">
<Button asChild size="lg" rounded="none" px="8" bg="brand.600" color="white" fontWeight="700" _hover={{ bg: "brand.500" }}>
<a href={whatsappHref(settings)} target="_blank" rel="noopener">
<WhatsAppIcon style={{ width: 18, height: 18 }} />
WhatsApp
</a>
</Button>
<Button asChild variant="outline" rounded="none" borderColor="whiteAlpha.500" color="white" size="lg" px="8" fontWeight="700" _hover={{ bg: "whiteAlpha.100" }}>
<a href={telHref(settings)}>
<Phone size={18} />
</a>
</Button>
</HStack>
</Stack>
<Stack gap="5" borderLeftWidth={{ lg: "1px" }} borderColor="whiteAlpha.200" pl={{ lg: 12 }}>
{contact.map((c) => (
<HStack key={c.label} gap="3" fontSize="sm" color="whiteAlpha.800">
<Box color="brand.400">
<c.icon size={16} />
</Box>
<Text>{c.label}</Text>
</HStack>
))}
<HStack gap="3" align="flex-start" fontSize="sm" color="whiteAlpha.800">
<Box color="brand.400" mt="1">
<MapPin size={16} />
</Box>
<Text>{settings.address}</Text>
</HStack>
</Stack>
</Grid>
</Container>
</Box>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { Accordion, Box, Text } from "@chakra-ui/react";
import { Plus } from "lucide-react";
import type { ContentItem } from "../../db/schema";
import { Section } from "./Section";
import { SectionHeading } from "./SectionHeading";
export function Faq({ items }: { items: ContentItem[] }) {
if (items.length === 0) return null;
return (
<Section id="faq">
<SectionHeading
eyebrow="常見問題"
title="安裝前,你可能想知嘅事"
description="仲有其他疑問?歡迎隨時 WhatsApp 問我哋。"
/>
<Box maxW="860px">
<Accordion.Root collapsible defaultValue={["faq-0"]}>
{items.map((item, index) => (
<Accordion.Item
key={item.id}
value={`faq-${index}`}
borderTopWidth="1px"
borderColor="border.subtle"
_last={{ borderBottomWidth: "1px" }}
>
<Accordion.ItemTrigger py="6" px="0" _hover={{ bg: "transparent" }}>
<Text flex="1" textAlign="start" fontFamily="heading" fontWeight="700" color="ink" fontSize={{ base: "md", md: "lg" }}>
{item.title}
</Text>
<Accordion.ItemIndicator color="brand.700" _open={{ transform: "rotate(45deg)" }}>
<Plus size={18} />
</Accordion.ItemIndicator>
</Accordion.ItemTrigger>
<Accordion.ItemContent>
<Accordion.ItemBody px="0" pb="7" pt="0" color="fg.muted" fontSize="sm" lineHeight="1.9" maxW="2xl">
{item.description}
</Accordion.ItemBody>
</Accordion.ItemContent>
</Accordion.Item>
))}
</Accordion.Root>
</Box>
</Section>
);
}
+48
View File
@@ -0,0 +1,48 @@
import { Box, Heading, SimpleGrid, Stack, Text } from "@chakra-ui/react";
import type { ContentItem } from "../../db/schema";
import { Section } from "./Section";
import { SectionHeading } from "./SectionHeading";
export function Features({ items }: { items: ContentItem[] }) {
return (
<Section id="why" tone="subtle">
<SectionHeading
eyebrow="點解揀我哋"
title="結合專業工程同貼心溝通"
description="我哋唔止做工程,仲重視你由頭到尾嘅體驗同安心。"
/>
<SimpleGrid
columns={{ base: 1, sm: 2, lg: 3 }}
gap="0"
borderWidth="1px"
borderColor="border.subtle"
bg="white"
>
{items.map((item, i) => (
<Stack
key={item.id}
gap="3"
p={{ base: 6, md: 8 }}
borderTopWidth={{ base: i === 0 ? "0" : "1px", sm: i < 2 ? "0" : "1px", lg: i < 3 ? "0" : "1px" }}
borderLeftWidth={{
base: "0",
sm: i % 2 === 1 ? "1px" : "0",
lg: i % 3 === 0 ? "0" : "1px",
}}
borderColor="border.subtle"
>
<Text fontFamily="heading" fontWeight="700" fontSize="sm" letterSpacing="0.1em" color="brand.600">
{String(i + 1).padStart(2, "0")}
</Text>
<Heading as="h3" fontSize="lg" fontWeight="700" color="ink">
{item.title}
</Heading>
<Text fontSize="sm" color="fg.muted" lineHeight="1.8">
{item.description}
</Text>
</Stack>
))}
</SimpleGrid>
</Section>
);
}
+98
View File
@@ -0,0 +1,98 @@
import { Box, Container, HStack, Link, Stack, Text, VStack } from "@chakra-ui/react";
import { Mail, MapPin, Phone } from "lucide-react";
import { mailHref, telHref, whatsappHref, type Settings } from "../../data/content";
import { WhatsAppIcon } from "./icons";
export function Footer({ settings }: { settings: Settings }) {
const name = settings.company_name || "盈豐太陽能工程有限公司";
const shortName = settings.company_short_name || settings.company_name || "盈豐太陽能";
const year = new Date().getFullYear();
return (
<Box as="footer" bg="ink" color="white" borderTopWidth="1px" borderColor="whiteAlpha.200" pt={{ base: 12, md: 16 }} pb="8">
<Container maxW="1200px" px={{ base: 5, md: 8 }}>
<Stack
direction={{ base: "column", md: "row" }}
justify="space-between"
gap={{ base: 10, md: 16 }}
>
<VStack align="flex-start" gap="4" maxW="sm">
<Text fontFamily="heading" fontWeight="900" fontSize="xl" letterSpacing="0.02em">
{shortName}
</Text>
<Text color="whiteAlpha.600" fontSize="sm" lineHeight="1.8">
</Text>
<Text color="whiteAlpha.500" fontSize="xs" letterSpacing="0.08em">
{settings.license_no}
</Text>
</VStack>
<HStack gap={{ base: 10, md: 16 }} align="flex-start">
<VStack align="flex-start" gap="3">
<Text fontWeight="700" fontSize="xs" letterSpacing="0.15em" color="whiteAlpha.500">
</Text>
{[
{ label: "服務範圍", href: "#services" },
{ label: "完成案例", href: "#cases" },
{ label: "服務流程", href: "#process" },
{ label: "常見問題", href: "#faq" },
{ label: "Blog", href: "/blog" },
].map((l) => (
<Link
key={l.href}
href={l.href}
fontSize="sm"
color="whiteAlpha.700"
_hover={{ color: "white", textDecoration: "none" }}
>
{l.label}
</Link>
))}
</VStack>
<VStack align="flex-start" gap="3">
<Text fontWeight="700" fontSize="xs" letterSpacing="0.15em" color="whiteAlpha.500">
</Text>
<HStack gap="2" color="whiteAlpha.700" fontSize="sm">
<Phone size={16} />
<Link href={telHref(settings)} color="whiteAlpha.700" _hover={{ color: "white", textDecoration: "none" }}>
{settings.phone}
</Link>
</HStack>
<HStack gap="2" color="whiteAlpha.700" fontSize="sm">
<WhatsAppIcon style={{ width: 16, height: 16 }} />
<Link href={whatsappHref(settings)} target="_blank" rel="noopener" color="whiteAlpha.700" _hover={{ color: "white", textDecoration: "none" }}>
WhatsApp
</Link>
</HStack>
<HStack gap="2" color="whiteAlpha.700" fontSize="sm">
<Mail size={16} />
<Link href={mailHref(settings)} color="whiteAlpha.700" _hover={{ color: "white", textDecoration: "none" }}>
{settings.email}
</Link>
</HStack>
<HStack gap="2" color="whiteAlpha.700" fontSize="sm" align="flex-start">
<MapPin size={16} style={{ marginTop: 3 }} />
<Text>{settings.address}</Text>
</HStack>
</VStack>
</HStack>
</Stack>
<Box borderTopWidth="1px" borderColor="whiteAlpha.200" mt="12" pt="6">
<HStack justify="space-between" flexWrap="wrap" gap="2">
<Text fontSize="xs" color="whiteAlpha.500">
© {year} {name} All rights reserved.
</Text>
<Link href="/admin" fontSize="xs" color="whiteAlpha.500" _hover={{ color: "whiteAlpha.900", textDecoration: "none" }}>
</Link>
</HStack>
</Box>
</Container>
</Box>
);
}
+150
View File
@@ -0,0 +1,150 @@
import {
Box,
Button,
CloseButton,
Container,
Drawer,
Flex,
HStack,
IconButton,
Link,
Portal,
Stack,
Text,
} from "@chakra-ui/react";
import { Menu, Phone } from "lucide-react";
import { telHref, whatsappHref, type Settings } from "../../data/content";
import { WhatsAppIcon } from "./icons";
const NAV = [
{ label: "服務", href: "#services" },
{ label: "完成案例", href: "#cases" },
{ label: "服務流程", href: "#process" },
{ label: "常見問題", href: "#faq" },
{ label: "Blog", href: "/blog" },
];
export function Header({ settings }: { settings: Settings }) {
const name = settings.company_short_name || settings.company_name || "盈豐太陽能";
const wa = whatsappHref(settings);
return (
<Box
as="header"
position="sticky"
top="0"
zIndex="sticky"
bg="#FAF8F4"
borderBottomWidth="1px"
borderColor="border.subtle"
>
<Container maxW="1200px" px={{ base: 5, md: 8 }}>
<Flex h="72px" align="center" justify="space-between" gap="4">
<Stack asChild gap="0.5" cursor="pointer" lineHeight="1.2">
<a href="/">
<Text fontFamily="heading" fontWeight="900" fontSize="xl" letterSpacing="0.02em" color="ink">
{name}
</Text>
<Text fontSize="11px" letterSpacing="0.12em" color="fg.muted" display={{ base: "none", sm: "block" }}>
{settings.license_no || "村屋太陽能專家"}
</Text>
</a>
</Stack>
<HStack gap="8" display={{ base: "none", lg: "flex" }}>
{NAV.map((item) => (
<Link
key={item.href}
href={item.href}
fontSize="sm"
fontWeight="medium"
color="ink"
_hover={{ color: "brand.700", textDecoration: "none" }}
>
{item.label}
</Link>
))}
</HStack>
<HStack gap="5">
<Link
href={telHref(settings)}
display={{ base: "none", md: "flex" }}
alignItems="center"
gap="2"
fontSize="sm"
fontWeight="semibold"
color="ink"
_hover={{ textDecoration: "none", color: "brand.700" }}
>
<Phone size={16} />
{settings.phone}
</Link>
<Button
asChild
size="sm"
rounded="none"
px="5"
bg="ink"
color="white"
_hover={{ bg: "brand.700" }}
display={{ base: "none", sm: "inline-flex" }}
>
<a href={wa} target="_blank" rel="noopener">
<WhatsAppIcon style={{ width: 16, height: 16 }} />
</a>
</Button>
<Drawer.Root>
<Drawer.Trigger asChild>
<IconButton aria-label="開啟選單" variant="ghost" display={{ base: "inline-flex", lg: "none" }}>
<Menu />
</IconButton>
</Drawer.Trigger>
<Portal>
<Drawer.Backdrop />
<Drawer.Positioner>
<Drawer.Content bg="#FAF8F4">
<Drawer.Header borderBottomWidth="1px" borderColor="border.subtle">
<Drawer.Title fontFamily="heading" fontWeight="900">{name}</Drawer.Title>
</Drawer.Header>
<Drawer.Body>
<Stack gap="1">
{NAV.map((item) => (
<Link
key={item.href}
href={item.href}
py="3"
fontWeight="medium"
color="ink"
borderBottomWidth="1px"
borderColor="border.subtle"
_hover={{ textDecoration: "none", color: "brand.700" }}
>
{item.label}
</Link>
))}
</Stack>
</Drawer.Body>
<Drawer.Footer pb="8">
<Button asChild width="full" rounded="none" bg="ink" color="white" _hover={{ bg: "brand.700" }}>
<a href={wa} target="_blank" rel="noopener">
<WhatsAppIcon style={{ width: 18, height: 18 }} />
WhatsApp
</a>
</Button>
</Drawer.Footer>
<Drawer.CloseTrigger asChild>
<CloseButton size="sm" />
</Drawer.CloseTrigger>
</Drawer.Content>
</Drawer.Positioner>
</Portal>
</Drawer.Root>
</HStack>
</Flex>
</Container>
</Box>
);
}
+106
View File
@@ -0,0 +1,106 @@
import { Box, Button, Container, Grid, HStack, Heading, Image, Link, SimpleGrid, Stack, Text, VStack } from "@chakra-ui/react";
import { ArrowRight, Check } from "lucide-react";
import { whatsappHref, type Settings } from "../../data/content";
import { WhatsAppIcon } from "./icons";
const TRUST = ["註冊電業承辦商", "舊客口碑介紹", "一對一專人跟進"];
export function Hero({ settings }: { settings: Settings }) {
const stats = [
{ label: "一般回本期", value: "約 69 年" },
{ label: "系統可用年期", value: "2025 年" },
{ label: "牌照", value: settings.license_no || "註冊電業承辦商" },
];
return (
<Box pt={{ base: 12, md: 20 }} pb={{ base: 16, md: 24 }}>
<Container maxW="1200px" px={{ base: 5, md: 8 }}>
<Grid templateColumns={{ base: "1fr", lg: "1.05fr 0.95fr" }} gap={{ base: 12, lg: 20 }} alignItems="center">
<VStack align="flex-start" gap="7">
<HStack gap="3" color="brand.700">
<Box w="8" h="2px" bg="brand.500" />
<Text fontSize="xs" fontWeight="700" letterSpacing="0.2em">
{settings.hero_eyebrow || "香港村屋太陽能專家"}
</Text>
</HStack>
<Heading
as="h1"
fontSize={{ base: "4xl", sm: "5xl", md: "6xl" }}
fontWeight="900"
letterSpacing="-0.01em"
lineHeight="1.15"
color="ink"
>
{settings.hero_title}
</Heading>
<Text fontSize={{ base: "md", md: "lg" }} color="fg.muted" maxW="xl">
{settings.hero_subtitle}
</Text>
<HStack gap="6" flexWrap="wrap" pt="1" align="center">
<Button asChild size="lg" rounded="none" px="8" bg="ink" color="white" fontWeight="semibold" _hover={{ bg: "brand.700" }}>
<a href={whatsappHref(settings)} target="_blank" rel="noopener">
<WhatsAppIcon style={{ width: 18, height: 18 }} />
{settings.hero_primary_cta || "免費預約評估"}
</a>
</Button>
<Link
href="#cases"
fontWeight="semibold"
color="ink"
borderBottomWidth="2px"
borderColor="brand.500"
pb="0.5"
_hover={{ color: "brand.700", textDecoration: "none" }}
>
{settings.hero_secondary_cta || "睇完成案例"} <ArrowRight size={15} style={{ display: "inline", verticalAlign: "-2px" }} />
</Link>
</HStack>
<HStack gap={{ base: "4", md: "6" }} flexWrap="wrap" pt="2">
{TRUST.map((t) => (
<HStack key={t} gap="2" color="fg.muted" fontSize="sm">
<Box color="brand.600">
<Check size={15} strokeWidth={3} />
</Box>
<Text>{t}</Text>
</HStack>
))}
</HStack>
</VStack>
<Stack gap="0">
<Image
src={settings.hero_image}
alt="村屋天台太陽能系統"
w="full"
aspectRatio="4 / 3"
objectFit="cover"
/>
<SimpleGrid
columns={3}
borderTopWidth="2px"
borderColor="ink"
pt="5"
mt="6"
gap="4"
>
{stats.map((s) => (
<Stack key={s.label} gap="1">
<Text fontSize="xs" letterSpacing="0.08em" color="fg.muted">
{s.label}
</Text>
<Text fontFamily="heading" fontWeight="700" fontSize={{ base: "md", md: "lg" }} color="brand.700">
{s.value}
</Text>
</Stack>
))}
</SimpleGrid>
</Stack>
</Grid>
</Container>
</Box>
);
}
+26
View File
@@ -0,0 +1,26 @@
import type { HomeData } from "../../data/content";
import { Cases } from "./Cases";
import { ContactCta } from "./ContactCta";
import { Faq } from "./Faq";
import { Features } from "./Features";
import { Hero } from "./Hero";
import { Process } from "./Process";
import { Services } from "./Services";
import { SiteChrome } from "./SiteChrome";
import { TrustBar } from "./TrustBar";
export function HomePage({ data }: { data: HomeData }) {
const { settings, services, features, steps, faqs, cases } = data;
return (
<SiteChrome settings={settings}>
<Hero settings={settings} />
<TrustBar />
<Services items={services} />
<Features items={features} />
<Process items={steps} />
<Cases items={cases} />
<Faq items={faqs} />
<ContactCta settings={settings} />
</SiteChrome>
);
}
+44
View File
@@ -0,0 +1,44 @@
import { Box, SimpleGrid, Stack, Text } from "@chakra-ui/react";
import type { ContentItem } from "../../db/schema";
import { Section } from "./Section";
import { SectionHeading } from "./SectionHeading";
export function Process({ items }: { items: ContentItem[] }) {
return (
<Section id="process">
<SectionHeading
eyebrow="服務流程"
title="專業安裝流程 5 步到位"
description="清晰透明嘅流程,每一步都有專人跟進,你唔需要自己四圍撲。"
/>
<Box>
<SimpleGrid columns={{ base: 1, md: 5 }} gap={{ base: 10, md: 6 }}>
{items.map((item) => (
<Stack
key={item.id}
align="flex-start"
textAlign="left"
gap="3"
>
<Box
fontFamily="heading"
fontWeight="900"
fontSize="5xl"
lineHeight="1"
color="brand.600"
>
{item.extra}
</Box>
<Text fontFamily="heading" fontWeight="700" fontSize="lg" color="ink">
{item.title}
</Text>
<Text fontSize="sm" color="fg.muted" lineHeight="1.75">
{item.description}
</Text>
</Stack>
))}
</SimpleGrid>
</Box>
</Section>
);
}
+7
View File
@@ -0,0 +1,7 @@
import { ChakraProvider } from "@chakra-ui/react";
import type { ReactNode } from "react";
import { system } from "../../theme/system";
export function Provider({ children }: { children: ReactNode }) {
return <ChakraProvider value={system}>{children}</ChakraProvider>;
}
+26
View File
@@ -0,0 +1,26 @@
import { Box, Container, type BoxProps } from "@chakra-ui/react";
import type { ReactNode } from "react";
type SectionProps = BoxProps & {
id?: string;
tone?: "paper" | "subtle" | "white";
children: ReactNode;
};
export function Section({ id, tone = "paper", children, ...rest }: SectionProps) {
const bg = tone === "subtle" ? "bg.subtle" : tone === "white" ? "white" : "transparent";
return (
<Box
as="section"
id={id}
bg={bg}
py={{ base: 16, md: 24 }}
scrollMarginTop="80px"
{...rest}
>
<Container maxW="1200px" px={{ base: 5, md: 8 }}>
{children}
</Container>
</Box>
);
}
+38
View File
@@ -0,0 +1,38 @@
import { Box, Heading, HStack, Stack, Text } from "@chakra-ui/react";
type Props = {
eyebrow?: string;
title: string;
description?: string;
align?: "center" | "start";
};
export function SectionHeading({ eyebrow, title, description, align = "start" }: Props) {
return (
<Stack gap="5" textAlign={align} align={align === "center" ? "center" : "flex-start"} mb={{ base: 10, md: 14 }}>
{eyebrow && (
<HStack gap="3" color="brand.700">
<Box w="8" h="2px" bg="brand.500" />
<Text fontSize="xs" fontWeight="700" letterSpacing="0.2em">
{eyebrow}
</Text>
</HStack>
)}
<Heading
as="h2"
fontSize={{ base: "3xl", md: "5xl" }}
fontWeight="900"
letterSpacing="-0.01em"
lineHeight="1.2"
color="ink"
>
{title}
</Heading>
{description && (
<Text fontSize={{ base: "md", md: "lg" }} color="fg.muted" maxW="2xl">
{description}
</Text>
)}
</Stack>
);
}
+56
View File
@@ -0,0 +1,56 @@
import { Box, Grid, Heading, Stack, Text } from "@chakra-ui/react";
import { ArrowRight } from "lucide-react";
import type { ContentItem } from "../../db/schema";
import { Section } from "./Section";
import { SectionHeading } from "./SectionHeading";
export function Services({ items }: { items: ContentItem[] }) {
return (
<Section id="services">
<SectionHeading
eyebrow="服務範圍"
title="專為村屋而設嘅太陽能方案"
description="由天台評估到掛表發電,我哋提供真正一站式嘅村屋太陽能服務。"
/>
<Stack gap="0" borderTopWidth="1px" borderColor="border.subtle">
{items.map((item, i) => (
<Grid
key={item.id}
templateColumns={{ base: "1fr", md: "80px 1fr 2fr 40px" }}
gap={{ base: 2, md: 8 }}
alignItems={{ md: "center" }}
py={{ base: 6, md: 8 }}
borderBottomWidth="1px"
borderColor="border.subtle"
role="group"
>
<Text
fontFamily="heading"
fontWeight="700"
fontSize={{ base: "lg", md: "2xl" }}
color="brand.600"
>
{String(i + 1).padStart(2, "0")}
</Text>
<Heading as="h3" fontSize={{ base: "xl", md: "2xl" }} fontWeight="700" color="ink" _groupHover={{ color: "brand.700" }} transition="color .2s">
{item.title}
</Heading>
<Text fontSize="sm" color="fg.muted" lineHeight="1.8">
{item.description}
</Text>
<Box
color="brand.700"
opacity="0"
transform="translateX(-6px)"
transition="all .2s"
_groupHover={{ opacity: 1, transform: "translateX(0)" }}
display={{ base: "none", md: "block" }}
>
<ArrowRight size={20} />
</Box>
</Grid>
))}
</Stack>
</Section>
);
}
+18
View File
@@ -0,0 +1,18 @@
import { Box } from "@chakra-ui/react";
import type { ReactNode } from "react";
import type { Settings } from "../../data/content";
import { Footer } from "./Footer";
import { Header } from "./Header";
import { Provider } from "./Provider";
import { WhatsAppFab } from "./WhatsAppFab";
export function SiteChrome({ settings, children }: { settings: Settings; children: ReactNode }) {
return (
<Provider>
<Header settings={settings} />
<Box as="main">{children}</Box>
<Footer settings={settings} />
<WhatsAppFab settings={settings} />
</Provider>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { Box, Container, SimpleGrid, Stack, Text } from "@chakra-ui/react";
import { BadgeCheck, FileCheck2, ShieldCheck, Timer } from "lucide-react";
const ITEMS = [
{ icon: ShieldCheck, title: "註冊電業承辦商", desc: "合資格承辦,安全合法" },
{ icon: FileCheck2, title: "上網電價 FiT 代辦", desc: "中電/港燈申請全程跟進" },
{ icon: BadgeCheck, title: "認可人士 AP 簽發", desc: "C/PVS、WR1、GF1 文件" },
{ icon: Timer, title: "2025 年系統壽命", desc: "優質器材,長遠穩定" },
];
export function TrustBar() {
return (
<Box bg="white" borderY="1px solid" borderColor="border.subtle">
<Container maxW="1200px" px={{ base: 5, md: 8 }}>
<SimpleGrid columns={{ base: 1, sm: 2, md: 4 }} gap="0">
{ITEMS.map((item, i) => (
<Stack
key={item.title}
direction="row"
gap="4"
align="flex-start"
py={{ base: 5, md: 8 }}
px={{ md: 6 }}
borderTopWidth={{ base: i === 0 ? "0" : "1px", sm: i < 2 ? "0" : "1px", md: "0" }}
borderLeftWidth={{ base: "0", sm: i % 2 === 1 ? "1px" : "0", md: i === 0 ? "0" : "1px" }}
borderColor="border.subtle"
>
<Box color="brand.700" flexShrink="0" mt="0.5">
<item.icon size={24} strokeWidth={1.5} />
</Box>
<Stack gap="0.5">
<Text fontWeight="700" fontSize="sm" color="ink">
{item.title}
</Text>
<Text fontSize="xs" color="fg.muted">
{item.desc}
</Text>
</Stack>
</Stack>
))}
</SimpleGrid>
</Container>
</Box>
);
}
+30
View File
@@ -0,0 +1,30 @@
import { Link } from "@chakra-ui/react";
import { whatsappHref, type Settings } from "../../data/content";
import { WhatsAppIcon } from "./icons";
export function WhatsAppFab({ settings }: { settings: Settings }) {
return (
<Link
href={whatsappHref(settings)}
target="_blank"
rel="noopener"
position="fixed"
bottom={{ base: "5", md: "7" }}
right={{ base: "5", md: "7" }}
zIndex="dropdown"
boxSize="56px"
rounded="full"
bg="ink"
color="white"
display="flex"
alignItems="center"
justifyContent="center"
boxShadow="0 8px 24px rgba(26,25,23,0.3)"
transition="transform .2s"
_hover={{ transform: "scale(1.08)", textDecoration: "none", bg: "brand.700" }}
aria-label="WhatsApp 查詢"
>
<WhatsAppIcon style={{ width: 28, height: 28 }} />
</Link>
);
}
+7
View File
@@ -0,0 +1,7 @@
export function WhatsAppIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" {...props}>
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51l-.57-.01c-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 0 1-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 0 1-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 0 1 2.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0 0 12.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 0 0 5.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 0 0-3.48-8.413" />
</svg>
);
}