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
  1. Project Planning: Reasonable architecture design and directory structure
  2. Content Management: Use Content Collections for type-safe content management
  3. SEO Optimization: Structured data, meta tags, sitemaps, etc.
  4. User Experience: Responsive design, loading performance, interaction feedback
  5. 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