Chapter 11: Practical Project Development
Haiyue
36min
Learning Objectives
- Develop a complete blog or content management system
- Build an e-commerce showcase website or corporate website
- Implement complex interactive features and animation effects
- Optimize SEO and user experience metrics
Key Concepts
Practical Project Types
Consolidate Astro knowledge through three complete practical projects:
- Personal Blog System: Content management, SEO optimization, performance improvement
- E-commerce Showcase: Product display, shopping cart, payment integration
- Corporate Website: Brand showcase, contact forms, multilingual support
Project Architecture Design
🔄 正在渲染 Mermaid 图表...
Project 1: Personal Blog System
Project Architecture Design
src/
├── components/
│ ├── blog/
│ │ ├── BlogCard.vue
│ │ ├── BlogList.astro
│ │ ├── CategoryFilter.vue
│ │ └── TagCloud.vue
│ ├── common/
│ │ ├── Header.astro
│ │ ├── Footer.astro
│ │ ├── SEO.astro
│ │ └── ThemeToggle.vue
│ └── ui/
│ ├── Button.vue
│ ├── Input.vue
│ └── Modal.vue
├── content/
│ ├── blog/
│ ├── config.ts
│ └── authors/
├── layouts/
│ ├── BaseLayout.astro
│ ├── BlogLayout.astro
│ └── PostLayout.astro
├── pages/
│ ├── blog/
│ │ ├── index.astro
│ │ ├── [slug].astro
│ │ ├── category/
│ │ └── tags/
│ ├── about.astro
│ ├── contact.astro
│ └── rss.xml.ts
├── stores/
│ ├── theme.ts
│ └── search.ts
└── utils/
├── seo.ts
├── reading-time.ts
└── related-posts.ts
Content Collection Configuration
// src/content/config.ts
import { defineCollection, z } from 'astro:content';
const blogCollection = defineCollection({
type: 'content',
schema: ({ image }) => z.object({
title: z.string(),
description: z.string(),
publishDate: z.date(),
updateDate: z.date().optional(),
author: z.string().default('Default Author'),
tags: z.array(z.string()),
category: z.string(),
featured: z.boolean().default(false),
draft: z.boolean().default(false),
heroImage: image().optional(),
heroAlt: z.string().optional(),
// SEO related
ogImage: image().optional(),
ogDescription: z.string().optional(),
// Reading time (auto-calculated)
readingTime: z.number().optional(),
// Related posts (auto-generated)
relatedPosts: z.array(z.string()).optional(),
}),
});
const authorsCollection = defineCollection({
type: 'data',
schema: ({ image }) => z.object({
name: z.string(),
bio: z.string(),
avatar: image(),
social: z.object({
website: z.string().url().optional(),
twitter: z.string().optional(),
github: z.string().optional(),
linkedin: z.string().optional(),
}).optional(),
}),
});
export const collections = {
'blog': blogCollection,
'authors': authorsCollection,
};
Blog Home Page Implementation
---
// src/pages/blog/index.astro
import { getCollection } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
import BlogCard from '../../components/blog/BlogCard.vue';
import CategoryFilter from '../../components/blog/CategoryFilter.vue';
import FeaturedPosts from '../../components/blog/FeaturedPosts.astro';
import { calculateReadingTime } from '../../utils/reading-time';
// Get all published posts
const allPosts = await getCollection('blog', ({ data }) => {
return !data.draft && data.publishDate <= new Date();
});
// Calculate reading time
const postsWithReadingTime = await Promise.all(
allPosts.map(async (post) => {
const { remarkPluginFrontmatter } = await post.render();
return {
...post,
data: {
...post.data,
readingTime: remarkPluginFrontmatter.readingTime,
},
};
})
);
// Sort by publish date
const sortedPosts = postsWithReadingTime.sort(
(a, b) => b.data.publishDate.valueOf() - a.data.publishDate.valueOf()
);
// Get featured posts
const featuredPosts = sortedPosts.filter(post => post.data.featured).slice(0, 3);
// Get latest posts (excluding featured posts)
const latestPosts = sortedPosts.filter(post => !post.data.featured).slice(0, 9);
// Get all categories
const categories = [...new Set(sortedPosts.map(post => post.data.category))];
// Get popular tags
const allTags = sortedPosts.flatMap(post => post.data.tags);
const tagCounts = allTags.reduce((acc, tag) => {
acc[tag] = (acc[tag] || 0) + 1;
return acc;
}, {} as Record<string, number>);
const popularTags = Object.entries(tagCounts)
.sort(([,a], [,b]) => b - a)
.slice(0, 10)
.map(([tag]) => tag);
---
<BaseLayout
title="Blog - Technical Sharing and Reflection"
description="Share articles on front-end development, Astro framework, Vue.js and personal thoughts"
ogImage="/images/blog-og.jpg"
>
<main class="container mx-auto px-4 py-8">
<!-- Featured posts section -->
{featuredPosts.length > 0 && (
<section class="mb-16">
<h2 class="text-3xl font-bold mb-8 text-center">Featured Posts</h2>
<FeaturedPosts posts={featuredPosts} />
</section>
)}
<div class="grid lg:grid-cols-4 gap-8">
<!-- Main content area -->
<div class="lg:col-span-3">
<!-- Category filter -->
<div class="mb-8">
<CategoryFilter
client:load
categories={categories}
currentCategory=""
/>
</div>
<!-- Post list -->
<div class="space-y-8">
<h2 class="text-2xl font-bold">Latest Posts</h2>
<div class="grid md:grid-cols-2 xl:grid-cols-3 gap-6">
{latestPosts.map((post) => (
<BlogCard
client:visible
post={{
title: post.data.title,
description: post.data.description,
publishDate: post.data.publishDate.toISOString(),
author: post.data.author,
category: post.data.category,
tags: post.data.tags,
slug: post.slug,
readingTime: post.data.readingTime,
heroImage: post.data.heroImage?.src,
}}
/>
))}
</div>
</div>
<!-- Pagination -->
<div class="mt-12 flex justify-center">
<a
href="/blog/page/2"
class="bg-blue-600 text-white px-6 py-3 rounded-lg hover:bg-blue-700 transition-colors"
>
View More Posts
</a>
</div>
</div>
<!-- Sidebar -->
<div class="lg:col-span-1">
<div class="sticky top-8 space-y-8">
<!-- Search box -->
<div class="bg-white dark:bg-gray-800 rounded-lg p-6 shadow-md">
<h3 class="font-semibold mb-4">Search Posts</h3>
<BlogSearch client:load />
</div>
<!-- Popular tags -->
<div class="bg-white dark:bg-gray-800 rounded-lg p-6 shadow-md">
<h3 class="font-semibold mb-4">Popular Tags</h3>
<div class="flex flex-wrap gap-2">
{popularTags.map((tag) => (
<a
href={`/blog/tags/${tag}`}
class="bg-gray-100 dark:bg-gray-700 text-sm px-3 py-1 rounded-full hover:bg-blue-100 dark:hover:bg-blue-900 transition-colors"
>
#{tag}
</a>
))}
</div>
</div>
<!-- Recently updated -->
<div class="bg-white dark:bg-gray-800 rounded-lg p-6 shadow-md">
<h3 class="font-semibold mb-4">Recently Updated</h3>
<div class="space-y-3">
{sortedPosts.slice(0, 5).map((post) => (
<a
href={`/blog/${post.slug}`}
class="block hover:text-blue-600 transition-colors"
>
<div class="text-sm font-medium line-clamp-2">
{post.data.title}
</div>
<div class="text-xs text-gray-500 mt-1">
{post.data.publishDate.toLocaleDateString('en-US')}
</div>
</a>
))}
</div>
</div>
</div>
</div>
</div>
</main>
</BaseLayout>
Post Detail Page
---
// src/pages/blog/[slug].astro
import { getCollection } from 'astro:content';
import PostLayout from '../../layouts/PostLayout.astro';
import RelatedPosts from '../../components/blog/RelatedPosts.astro';
import ShareButtons from '../../components/blog/ShareButtons.vue';
import TableOfContents from '../../components/blog/TableOfContents.vue';
import Comments from '../../components/blog/Comments.vue';
import { findRelatedPosts } from '../../utils/related-posts';
export async function getStaticPaths() {
const posts = await getCollection('blog');
return posts.map((post) => ({
params: { slug: post.slug },
props: { post },
}));
}
const { post } = Astro.props;
const { Content, headings, remarkPluginFrontmatter } = await post.render();
// Get related posts
const allPosts = await getCollection('blog');
const relatedPosts = findRelatedPosts(post, allPosts, 3);
// Get author information
const authors = await getCollection('authors');
const author = authors.find(a => a.id === post.data.author);
// Structured data
const structuredData = {
"@context": "https://schema.org",
"@type": "BlogPosting",
"headline": post.data.title,
"description": post.data.description,
"image": post.data.heroImage?.src,
"author": {
"@type": "Person",
"name": author?.data.name || post.data.author
},
"publisher": {
"@type": "Organization",
"name": "Your Site Name",
"logo": {
"@type": "ImageObject",
"url": "/logo.png"
}
},
"datePublished": post.data.publishDate.toISOString(),
"dateModified": (post.data.updateDate || post.data.publishDate).toISOString(),
"mainEntityOfPage": {
"@type": "WebPage",
"@id": `${Astro.site}blog/${post.slug}`
}
};
---
<PostLayout
title={post.data.title}
description={post.data.description}
ogImage={post.data.ogImage?.src || post.data.heroImage?.src}
publishDate={post.data.publishDate}
updateDate={post.data.updateDate}
author={author?.data.name || post.data.author}
readingTime={remarkPluginFrontmatter.readingTime}
tags={post.data.tags}
category={post.data.category}
>
<!-- Structured data -->
<script type="application/ld+json" set:html={JSON.stringify(structuredData)} />
<article class="max-w-4xl mx-auto">
<!-- Article header -->
<header class="mb-8">
{post.data.heroImage && (
<img
src={post.data.heroImage.src}
alt={post.data.heroAlt || post.data.title}
class="w-full h-96 object-cover rounded-lg mb-6"
loading="eager"
/>
)}
<div class="flex items-center justify-between mb-4">
<div class="flex items-center space-x-4">
{author?.data.avatar && (
<img
src={author.data.avatar.src}
alt={author.data.name}
class="w-12 h-12 rounded-full"
/>
)}
<div>
<div class="font-medium">{author?.data.name || post.data.author}</div>
<div class="text-sm text-gray-500">
{post.data.publishDate.toLocaleDateString('en-US')}
· {remarkPluginFrontmatter.readingTime} min read
</div>
</div>
</div>
<ShareButtons
client:load
url={`${Astro.site}blog/${post.slug}`}
title={post.data.title}
description={post.data.description}
/>
</div>
<h1 class="text-4xl font-bold mb-4">{post.data.title}</h1>
<p class="text-xl text-gray-600 dark:text-gray-300">
{post.data.description}
</p>
<!-- Tags and category -->
<div class="flex flex-wrap items-center gap-4 mt-6">
<a
href={`/blog/category/${post.data.category}`}
class="bg-blue-100 text-blue-800 px-3 py-1 rounded-full text-sm font-medium"
>
{post.data.category}
</a>
<div class="flex flex-wrap gap-2">
{post.data.tags.map((tag) => (
<a
href={`/blog/tags/${tag}`}
class="bg-gray-100 text-gray-700 px-2 py-1 rounded text-sm hover:bg-gray-200 transition-colors"
>
#{tag}
</a>
))}
</div>
</div>
</header>
<div class="grid lg:grid-cols-4 gap-8">
<!-- Article content -->
<div class="lg:col-span-3">
<div class="prose prose-lg max-w-none dark:prose-invert">
<Content />
</div>
<!-- Article footer actions -->
<div class="mt-12 pt-8 border-t border-gray-200 dark:border-gray-700">
<div class="flex justify-between items-center">
<ShareButtons
client:load
url={`${Astro.site}blog/${post.slug}`}
title={post.data.title}
description={post.data.description}
showLabel={true}
/>
<div class="text-sm text-gray-500">
{post.data.updateDate && (
<span>Last updated: {post.data.updateDate.toLocaleDateString('en-US')}</span>
)}
</div>
</div>
</div>
<!-- Author info -->
{author && (
<div class="mt-12 p-6 bg-gray-50 dark:bg-gray-800 rounded-lg">
<div class="flex items-start space-x-4">
<img
src={author.data.avatar.src}
alt={author.data.name}
class="w-16 h-16 rounded-full"
/>
<div>
<h3 class="font-semibold text-lg">{author.data.name}</h3>
<p class="text-gray-600 dark:text-gray-300 mt-2">{author.data.bio}</p>
{author.data.social && (
<div class="flex space-x-4 mt-4">
{author.data.social.website && (
<a href={author.data.social.website} class="text-blue-600 hover:underline">
Website
</a>
)}
{author.data.social.twitter && (
<a href={`https://twitter.com/${author.data.social.twitter}`} class="text-blue-600 hover:underline">
Twitter
</a>
)}
{author.data.social.github && (
<a href={`https://github.com/${author.data.social.github}`} class="text-blue-600 hover:underline">
GitHub
</a>
)}
</div>
)}
</div>
</div>
</div>
)}
<!-- Comments section -->
<div class="mt-12">
<Comments
client:load
postId={post.slug}
title={post.data.title}
/>
</div>
</div>
<!-- Sidebar -->
<div class="lg:col-span-1">
<div class="sticky top-8">
<TableOfContents
client:load
headings={headings}
/>
</div>
</div>
</div>
<!-- Related posts -->
{relatedPosts.length > 0 && (
<div class="mt-16">
<RelatedPosts posts={relatedPosts} />
</div>
)}
</article>
</PostLayout>
RSS Feed Generation
// src/pages/rss.xml.ts
import rss from '@astrojs/rss';
import { getCollection } from 'astro:content';
export async function GET(context) {
const posts = await getCollection('blog', ({ data }) => {
return !data.draft && data.publishDate <= new Date();
});
const sortedPosts = posts.sort(
(a, b) => b.data.publishDate.valueOf() - a.data.publishDate.valueOf()
);
return rss({
title: 'Personal Blog RSS',
description: 'Share front-end development techniques and personal thoughts',
site: context.site,
items: sortedPosts.map((post) => ({
title: post.data.title,
description: post.data.description,
pubDate: post.data.publishDate,
author: post.data.author,
categories: [post.data.category, ...post.data.tags],
link: `/blog/${post.slug}/`,
})),
customData: `
<language>en-US</language>
<lastBuildDate>${new Date().toUTCString()}</lastBuildDate>
<ttl>60</ttl>
`,
});
}
Project 2: E-commerce Showcase Website
Product Display Page
---
// src/pages/products/index.astro
import { getCollection } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
import ProductCard from '../../components/product/ProductCard.vue';
import ProductFilter from '../../components/product/ProductFilter.vue';
import ProductSort from '../../components/product/ProductSort.vue';
// Get all products
const products = await getCollection('products');
// Get categories
const categories = [...new Set(products.map(p => p.data.category))];
// Get brands
const brands = [...new Set(products.map(p => p.data.brand))];
// Price range
const prices = products.map(p => p.data.price);
const priceRange = {
min: Math.min(...prices),
max: Math.max(...prices),
};
---
<BaseLayout
title="Products - Featured Items"
description="Browse our featured products and find the perfect item for you"
>
<main class="container mx-auto px-4 py-8">
<div class="flex justify-between items-center mb-8">
<h1 class="text-3xl font-bold">Products</h1>
<ProductSort client:load />
</div>
<div class="grid lg:grid-cols-4 gap-8">
<!-- Filter sidebar -->
<div class="lg:col-span-1">
<ProductFilter
client:load
categories={categories}
brands={brands}
priceRange={priceRange}
/>
</div>
<!-- Product grid -->
<div class="lg:col-span-3">
<div class="grid md:grid-cols-2 xl:grid-cols-3 gap-6">
{products.map((product) => (
<ProductCard
client:visible
product={{
id: product.id,
name: product.data.name,
price: product.data.price,
originalPrice: product.data.originalPrice,
image: product.data.images[0]?.src,
rating: product.data.rating,
reviewCount: product.data.reviewCount,
slug: product.slug,
category: product.data.category,
brand: product.data.brand,
inStock: product.data.stock > 0,
}}
/>
))}
</div>
<!-- Pagination -->
<div class="mt-12">
<Pagination
currentPage={1}
totalPages={Math.ceil(products.length / 12)}
baseUrl="/products"
/>
</div>
</div>
</div>
</main>
</BaseLayout>
Product Detail Page
---
// src/pages/products/[slug].astro
import { getCollection } from 'astro:content';
import ProductLayout from '../../layouts/ProductLayout.astro';
import ProductGallery from '../../components/product/ProductGallery.vue';
import ProductInfo from '../../components/product/ProductInfo.vue';
import ProductReviews from '../../components/product/ProductReviews.vue';
import RelatedProducts from '../../components/product/RelatedProducts.astro';
export async function getStaticPaths() {
const products = await getCollection('products');
return products.map((product) => ({
params: { slug: product.slug },
props: { product },
}));
}
const { product } = Astro.props;
// Get related products
const allProducts = await getCollection('products');
const relatedProducts = allProducts
.filter(p =>
p.data.category === product.data.category &&
p.slug !== product.slug
)
.slice(0, 4);
// Structured data
const structuredData = {
"@context": "https://schema.org",
"@type": "Product",
"name": product.data.name,
"description": product.data.description,
"image": product.data.images.map(img => img.src),
"brand": {
"@type": "Brand",
"name": product.data.brand
},
"offers": {
"@type": "Offer",
"price": product.data.price,
"priceCurrency": "USD",
"availability": product.data.stock > 0 ?
"https://schema.org/InStock" :
"https://schema.org/OutOfStock",
"seller": {
"@type": "Organization",
"name": "Your Store Name"
}
},
"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": product.data.rating,
"reviewCount": product.data.reviewCount
}
};
---
<ProductLayout
title={`${product.data.name} - Product Details`}
description={product.data.description}
ogImage={product.data.images[0]?.src}
>
<script type="application/ld+json" set:html={JSON.stringify(structuredData)} />
<main class="container mx-auto px-4 py-8">
<!-- Breadcrumbs -->
<nav class="mb-8">
<ol class="flex items-center space-x-2 text-sm">
<li><a href="/" class="text-blue-600 hover:underline">Home</a></li>
<li>/</li>
<li><a href="/products" class="text-blue-600 hover:underline">Products</a></li>
<li>/</li>
<li><a href={`/products/category/${product.data.category}`} class="text-blue-600 hover:underline">
{product.data.category}
</a></li>
<li>/</li>
<li class="text-gray-500">{product.data.name}</li>
</ol>
</nav>
<div class="grid lg:grid-cols-2 gap-12">
<!-- Product images -->
<div>
<ProductGallery
client:load
images={product.data.images.map(img => ({
src: img.src,
alt: img.alt || product.data.name
}))}
/>
</div>
<!-- Product info -->
<div>
<ProductInfo
client:load
product={{
id: product.id,
name: product.data.name,
price: product.data.price,
originalPrice: product.data.originalPrice,
description: product.data.description,
features: product.data.features,
specifications: product.data.specifications,
rating: product.data.rating,
reviewCount: product.data.reviewCount,
stock: product.data.stock,
brand: product.data.brand,
category: product.data.category,
sku: product.data.sku,
variants: product.data.variants,
}}
/>
</div>
</div>
<!-- Detailed product information -->
<div class="mt-16">
<div class="border-b border-gray-200">
<nav class="-mb-px flex space-x-8">
<button class="tab-button active" data-tab="description">
Description
</button>
<button class="tab-button" data-tab="specifications">
Specifications
</button>
<button class="tab-button" data-tab="reviews">
Reviews ({product.data.reviewCount})
</button>
</nav>
</div>
<div class="tab-content mt-8">
<!-- Product description -->
<div id="description" class="tab-panel active">
<div class="prose max-w-none">
<div set:html={product.data.detailedDescription} />
</div>
</div>
<!-- Specifications -->
<div id="specifications" class="tab-panel hidden">
<div class="grid md:grid-cols-2 gap-8">
{Object.entries(product.data.specifications).map(([key, value]) => (
<div class="flex justify-between py-2 border-b border-gray-100">
<span class="font-medium">{key}</span>
<span class="text-gray-600">{value}</span>
</div>
))}
</div>
</div>
<!-- Reviews -->
<div id="reviews" class="tab-panel hidden">
<ProductReviews
client:load
productId={product.id}
averageRating={product.data.rating}
totalReviews={product.data.reviewCount}
/>
</div>
</div>
</div>
<!-- Related products -->
{relatedProducts.length > 0 && (
<div class="mt-16">
<RelatedProducts products={relatedProducts} />
</div>
)}
</main>
</ProductLayout>
<script>
// Tab switching functionality
document.addEventListener('DOMContentLoaded', () => {
const tabButtons = document.querySelectorAll('.tab-button');
const tabPanels = document.querySelectorAll('.tab-panel');
tabButtons.forEach(button => {
button.addEventListener('click', () => {
const tabId = button.getAttribute('data-tab');
// Update button state
tabButtons.forEach(btn => btn.classList.remove('active'));
button.classList.add('active');
// Show corresponding panel
tabPanels.forEach(panel => {
if (panel.id === tabId) {
panel.classList.remove('hidden');
panel.classList.add('active');
} else {
panel.classList.add('hidden');
panel.classList.remove('active');
}
});
});
});
});
</script>
<style>
.tab-button {
@apply py-2 px-1 border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 font-medium text-sm;
}
.tab-button.active {
@apply text-blue-600 border-blue-600;
}
</style>
Project 3: Corporate Website
Home Page Implementation
---
// src/pages/index.astro
import BaseLayout from '../layouts/BaseLayout.astro';
import Hero from '../components/home/Hero.astro';
import Features from '../components/home/Features.astro';
import Services from '../components/home/Services.astro';
import Testimonials from '../components/home/Testimonials.vue';
import Stats from '../components/home/Stats.vue';
import CTA from '../components/home/CTA.astro';
import LatestNews from '../components/home/LatestNews.astro';
// Get latest news
const latestNews = await getCollection('news', ({ data }) => {
return data.featured && data.publishDate <= new Date();
});
const sortedNews = latestNews
.sort((a, b) => b.data.publishDate.valueOf() - a.data.publishDate.valueOf())
.slice(0, 3);
---
<BaseLayout
title="Home - Professional Technology Service Provider"
description="We provide professional web development, mobile app development, and technology consulting services to empower digital transformation"
ogImage="/images/home-og.jpg"
>
<!-- Hero section -->
<Hero />
<!-- Features -->
<Features />
<!-- Services -->
<Services />
<!-- Stats -->
<Stats
client:visible
stats={[
{ label: 'Clients Served', value: 500, suffix: '+' },
{ label: 'Projects Completed', value: 1200, suffix: '+' },
{ label: 'Team Members', value: 50, suffix: '+' },
{ label: 'Years of Service', value: 8, suffix: ' yrs' },
]}
/>
<!-- Testimonials -->
<Testimonials
client:visible
testimonials={[
{
id: 1,
name: 'Mr. Zhang',
company: 'ABC Tech Co.',
content: 'Very pleasant cooperation, high project quality, and very professional team.',
avatar: '/images/testimonial-1.jpg',
rating: 5,
},
{
id: 2,
name: 'Manager Li',
company: 'XYZ Group',
content: 'Timely delivery and great follow-up service. A trustworthy partner.',
avatar: '/images/testimonial-2.jpg',
rating: 5,
},
]}
/>
<!-- Latest news -->
{sortedNews.length > 0 && (
<LatestNews news={sortedNews} />
)}
<!-- Call to action -->
<CTA />
</BaseLayout>
Contact Form Component
<!-- src/components/contact/ContactForm.vue -->
<template>
<form @submit.prevent="submitForm" class="max-w-2xl mx-auto">
<div class="grid md:grid-cols-2 gap-6">
<!-- Name -->
<div>
<label for="name" class="block text-sm font-medium mb-2">
Name <span class="text-red-500">*</span>
</label>
<input
id="name"
v-model="form.name"
type="text"
required
class="form-input"
:class="{ 'border-red-500': errors.name }"
placeholder="Enter your name"
/>
<p v-if="errors.name" class="text-red-500 text-sm mt-1">
{{ errors.name }}
</p>
</div>
<!-- Email -->
<div>
<label for="email" class="block text-sm font-medium mb-2">
Email <span class="text-red-500">*</span>
</label>
<input
id="email"
v-model="form.email"
type="email"
required
class="form-input"
:class="{ 'border-red-500': errors.email }"
placeholder="Enter your email"
/>
<p v-if="errors.email" class="text-red-500 text-sm mt-1">
{{ errors.email }}
</p>
</div>
<!-- Phone -->
<div>
<label for="phone" class="block text-sm font-medium mb-2">
Phone
</label>
<input
id="phone"
v-model="form.phone"
type="tel"
class="form-input"
placeholder="Enter your phone number"
/>
</div>
<!-- Company -->
<div>
<label for="company" class="block text-sm font-medium mb-2">
Company
</label>
<input
id="company"
v-model="form.company"
type="text"
class="form-input"
placeholder="Enter your company name"
/>
</div>
</div>
<!-- Service type -->
<div class="mt-6">
<label for="service" class="block text-sm font-medium mb-2">
Interested Service
</label>
<select
id="service"
v-model="form.service"
class="form-select"
>
<option value="">Select service type</option>
<option value="web-development">Web Development</option>
<option value="mobile-development">Mobile App Development</option>
<option value="consulting">Technology Consulting</option>
<option value="maintenance">System Maintenance</option>
<option value="other">Other</option>
</select>
</div>
<!-- Message -->
<div class="mt-6">
<label for="message" class="block text-sm font-medium mb-2">
Message <span class="text-red-500">*</span>
</label>
<textarea
id="message"
v-model="form.message"
required
rows="5"
class="form-textarea"
:class="{ 'border-red-500': errors.message }"
placeholder="Please describe your requirements in detail..."
></textarea>
<p v-if="errors.message" class="text-red-500 text-sm mt-1">
{{ errors.message }}
</p>
</div>
<!-- Submit button -->
<div class="mt-8">
<button
type="submit"
:disabled="isSubmitting"
class="w-full bg-blue-600 text-white py-3 px-6 rounded-lg font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<span v-if="isSubmitting" class="flex items-center justify-center">
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Sending...
</span>
<span v-else>Send Message</span>
</button>
</div>
<!-- Success message -->
<div
v-if="showSuccess"
class="mt-6 p-4 bg-green-50 border border-green-200 rounded-lg"
>
<div class="flex">
<CheckCircleIcon class="h-5 w-5 text-green-400" />
<div class="ml-3">
<p class="text-sm font-medium text-green-800">
Message sent successfully!
</p>
<p class="mt-1 text-sm text-green-700">
We will reply to your message within 24 hours.
</p>
</div>
</div>
</div>
</form>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue';
import { CheckCircleIcon } from '@heroicons/vue/24/solid';
const form = reactive({
name: '',
email: '',
phone: '',
company: '',
service: '',
message: '',
});
const errors = reactive({
name: '',
email: '',
message: '',
});
const isSubmitting = ref(false);
const showSuccess = ref(false);
function validateForm() {
// Reset errors
Object.keys(errors).forEach(key => {
errors[key] = '';
});
let isValid = true;
// Validate name
if (!form.name.trim()) {
errors.name = 'Please enter name';
isValid = false;
}
// Validate email
if (!form.email.trim()) {
errors.email = 'Please enter email';
isValid = false;
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)) {
errors.email = 'Please enter valid email address';
isValid = false;
}
// Validate message
if (!form.message.trim()) {
errors.message = 'Please enter message';
isValid = false;
} else if (form.message.trim().length < 10) {
errors.message = 'Message must be at least 10 characters';
isValid = false;
}
return isValid;
}
async function submitForm() {
if (!validateForm()) {
return;
}
isSubmitting.value = true;
try {
const response = await fetch('/api/contact', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(form),
});
if (response.ok) {
showSuccess.value = true;
// Reset form
Object.keys(form).forEach(key => {
form[key] = '';
});
// Hide success message after 3 seconds
setTimeout(() => {
showSuccess.value = false;
}, 3000);
} else {
throw new Error('Send failed');
}
} catch (error) {
alert('Send failed, please try again later');
} finally {
isSubmitting.value = false;
}
}
</script>
<style scoped>
.form-input,
.form-select,
.form-textarea {
@apply w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm;
@apply focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500;
@apply dark:bg-gray-700 dark:border-gray-600 dark:text-white;
}
.form-textarea {
@apply resize-vertical;
}
</style>
Key Takeaways
- Project Planning: Reasonable architecture design and directory structure
- Content Management: Use Content Collections for type-safe content management
- SEO Optimization: Structured data, meta tags, sitemaps, etc.
- User Experience: Responsive design, loading performance, interaction feedback
- Feature Completeness: Complete functionality implementation from display to interaction
Important Notes
- Prioritize mobile adaptation and responsive design
- Ensure consistency in front-end and back-end form validation
- Use caching strategies appropriately to improve performance
- Regularly backup content and configuration files