歡迎來到倒數第二天! 我們昨天完成了在分支上的 React 程式碼實作測驗,確認了我們目前的實作是能撐起本地的測試的,雖然因為安全性的考量我們決定不在這次的進度中加入這個功能,但至少我們大致了解實際上市面上的產品可能是用什麼方向切入這類的需求,以及有哪些可以查詢的關鍵字去真正做到能落地的版本。
今天我們要做的是最後的整理,把專案中一些明顯有些矛盾或未完成的地方補完,雖然我們在之前的重構中已經處理掉許多部分了,不過還是有個最最最明顯的玩意我們一直忽略了,也就是我們的 Landing Page,之前一直專注在內容頁,實作登入系統後也沒有再回過那個測試用的首頁,但實際上我們如果今天點開localhost:3000/,看到的還是那個第一周做的超簡陋玩意,這很明顯是不OK的! 好在,這樣的主頁面是有著最多訓練資料的, AI 模型在產生這樣的內容可說是分分鐘的事情,看起來要寫的程式碼不少,但一點也不複雜的,馬上開始吧!
✅ 移除之前的測試首頁。
✅ 實作首頁用到的 AnimatedCard
、 ScrollProgress
& Header
。
✅ 加入基本的動畫效果。
首先請你在components/Header.tsx
寫入以下的內容:
'use client';
import Link from 'next/link';
import { Bot } from 'lucide-react';
import { usePathname } from 'next/navigation';
import { useEffect, useState } from 'react';
import { createClient } from '@/app/lib/supabase/client';
import type { User } from '@supabase/supabase-js';
export default function Header() {
const pathname = usePathname();
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
// 如果在 dashboard 或其他主要頁面,不顯示 Header
const isDashboardRoute =
pathname?.startsWith('/dashboard') ||
pathname?.startsWith('/practice') ||
pathname?.startsWith('/history') ||
pathname?.startsWith('/interview');
useEffect(() => {
const supabase = createClient();
// 取得當前使用者
const getUser = async () => {
const {
data: { user },
} = await supabase.auth.getUser();
setUser(user);
setLoading(false);
};
getUser();
// 監聽認證狀態變化
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((_event, session) => {
setUser(session?.user ?? null);
});
return () => {
subscription.unsubscribe();
};
}, []);
if (isDashboardRoute) return null;
return (
<header className="fixed top-0 left-0 right-0 z-50 bg-gray-900/80 backdrop-blur-md border-b border-gray-800">
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
{/* Logo 和名稱 */}
<Link
href="/"
className="flex items-center gap-3 hover:opacity-80 transition-opacity"
>
<Bot size={32} className="text-blue-400" />
<span className="text-xl font-bold text-white">
AI Interview Pro
</span>
</Link>
{/* 導航按鈕 */}
<div className="flex items-center gap-4">
{pathname === '/auth' ? (
<Link
href="/"
className="px-4 py-2 text-gray-300 hover:text-white transition-colors"
>
回首頁
</Link>
) : (
<>
{!loading &&
(user ? (
// 已登入:只顯示進入主控台
<Link
href="/dashboard"
className="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white font-semibold rounded-lg transition-colors"
>
進入主控台
</Link>
) : (
// 未登入:顯示登入按鈕(使用相同樣式)
<Link
href="/auth"
className="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white font-semibold rounded-lg transition-colors"
>
登入
</Link>
))}
</>
)}
</div>
</div>
</div>
</header>
);
}
除了常見的Logo之外,也補上了登入的驗證邏輯,透過之前實作的 Supabase Auth 我們可以輕鬆的判斷使用者是否是登入狀態,從而顯示對應需要顯示的按鈕。
這個組件其實相對的沒有這麼必須,單純是我個人蠻喜歡這樣的效果的,這也很常見到在學習網站或是其他商業SaaS網站上看到,只是個會隨著往下捲動顯示的進度條,實作上也不困難,但會給頁面帶來還不錯的效果。
在同一個資料夾下新增components/ScrollProgress.tsx
,並寫入以下的內容:
'use client';
import { useEffect, useState, useRef } from 'react';
export default function ScrollProgress() {
const [scrollProgress, setScrollProgress] = useState(0);
const throttleTimeout = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
const handleScroll = () => {
// 如果已經有排程的更新,直接返回
if (throttleTimeout.current) return;
// 設定 throttle,每 100ms 最多更新一次
throttleTimeout.current = setTimeout(() => {
const scrollTop = window.scrollY;
const docHeight = document.documentElement.scrollHeight - window.innerHeight;
const progress = (scrollTop / docHeight) * 100;
setScrollProgress(progress);
// 清除 timeout 標記
throttleTimeout.current = null;
}, 100);
};
// 初始化時計算一次
handleScroll();
window.addEventListener('scroll', handleScroll, { passive: true });
return () => {
window.removeEventListener('scroll', handleScroll);
if (throttleTimeout.current) {
clearTimeout(throttleTimeout.current);
}
};
}, []);
return (
<div className="fixed top-0 left-0 right-0 z-[100] h-1 bg-gray-800">
<div
className="h-full bg-gradient-to-r from-blue-500 via-purple-500 to-pink-500 transition-all duration-150"
style={{ width: `${scrollProgress}%` }}
/>
</div>
);
}
我們利用scrollTop
以及docHeight
去計算出目前捲動的進度,同時利用一個簡單的throttle
實作去避免scroll
事件多次觸發,由於不需要寫一個通用throttle
函數,單純在組件內的實踐可以很簡單。
由於在捲動的過程中,我希望顯示的幾個卡片區塊能有基本的動畫效果,大致上就是進入到視窗範圍中時就淡出,讓卡片隨著滾動一張張的出現,為此我想做個useScrollAnimation
hook 去封裝這層邏輯,請你在app/hooks/useScrollAnimation.ts
寫入以下的內容:
'use client';
import { useEffect, useRef, useState } from 'react';
export function useScrollAnimation(threshold = 0.1) {
const ref = useRef<HTMLDivElement>(null);
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true);
// 一旦可見就停止觀察,避免重複觸發
if (ref.current) {
observer.unobserve(ref.current);
}
}
},
{
threshold,
rootMargin: '0px 0px -100px 0px', // 提前 100px 觸發動畫
}
);
if (ref.current) {
observer.observe(ref.current);
}
return () => {
if (ref.current) {
observer.unobserve(ref.current);
}
};
}, [threshold]);
return { ref, isVisible };
}
主要的實踐邏輯是利用瀏覽器一個強大的 API IntersectionObserver
,也是我個人相當喜歡的一個 API,通常我在做許多 scroll 事件時,如果要處理一些進入 viewport 的邏輯都會優先使用這個東西,最常見的例子莫過於無限捲動了,如果這是你第一次看到這玩意,我強烈推薦你針對它做一點了解,是個好東西!
大致上的邏輯就是你可以選定一個元素作為觀察的容器,當有元素進入這個容器時,你可以觸發對應的 callback ,當沒有指定元素時就會預設為整個 root
元素,threshold
與 rootMargin
則是讓你用來控制元素的百分之多少進入容器以及容器實際的觀察範圍,讓你更精確的控制 callback 觸發的時機,透過這個 useScrollAnimation
hook,我們就可以把輸出的 ref
掛在任何我們想觀察的元素上,一旦該元素進入可視範圍,我們就更新isVisible
的值,而我們最終會透過這個值去實作動畫效果。
最後就是組裝了,回到我們之前那個慘淡的首頁app/page.tsx
將原本的內容全都刪除,然後貼上以下的完整內容:
'use client';
import Link from 'next/link';
import Header from './components/layout/Header';
import ScrollProgress from './components/layout/ScrollProgress';
import { useScrollAnimation } from './hooks/useScrollAnimation';
import { useEffect, useState } from 'react';
import { createClient } from './lib/supabase/client';
import type { User } from '@supabase/supabase-js';
import {
Bot,
Code,
MessageSquare,
BarChart2,
Zap,
ArrowRight,
CheckCircle2,
Star,
Check
} from 'lucide-react';
// 使用者回饋資料
const testimonials = [
{
name: '李小明',
role: '前端工程師 @ 國泰金控',
avatar: '👨💻',
content: '使用 AI Interview Pro 練習了兩個月,成功拿到夢想公司的 offer!AI 的回饋非常專業且具體。',
rating: 5,
},
{
name: '王美華',
role: '資深前端工程師 @ 台積電',
avatar: '👩💻',
content: '這個平台讓我在轉職過程中更有信心。程式實作的題目很貼近真實面試,幫助我發現了很多盲點。',
rating: 5,
},
{
name: '張志偉',
role: 'Full Stack Developer @ 新創公司',
avatar: '👨🎓',
content: '隨時隨地都能練習,不用擔心打擾真人面試官。AI 的評分標準也很客觀,讓我知道該往哪個方向努力。',
rating: 5,
},
];
// 費用方案
const pricingPlans = [
{
name: '免費體驗',
price: 0,
period: '永久免費',
features: [
'每日 5 次練習機會',
'基礎題庫存取',
'AI 基本回饋',
'7 天歷史記錄',
],
highlight: false,
},
{
name: '專業版',
price: 299,
period: '每月',
features: [
'無限次練習',
'完整題庫存取',
'AI 深度分析回饋',
'無限歷史記錄',
'進度追蹤儀表板',
'程式碼執行環境',
],
highlight: true,
},
{
name: '企業版',
price: 999,
period: '每月',
features: [
'專業版所有功能',
'客製化題庫',
'團隊管理功能',
'詳細分析報告',
'優先技術支援',
'API 整合服務',
],
highlight: false,
},
];
// 動畫卡片組件
function AnimatedCard({ children, delay = 0 }: { children: React.ReactNode; delay?: number }) {
const { ref, isVisible } = useScrollAnimation();
return (
<div
ref={ref}
className={`transition-all duration-700 ${
isVisible
? 'opacity-100 translate-y-0'
: 'opacity-0 translate-y-10'
}`}
style={{ transitionDelay: `${delay}ms` }}
>
{children}
</div>
);
}
export default function Home() {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const supabase = createClient();
// 取得當前使用者
const getUser = async () => {
const { data: { user } } = await supabase.auth.getUser();
setUser(user);
setLoading(false);
};
getUser();
// 監聽認證狀態變化
const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => {
setUser(session?.user ?? null);
});
return () => {
subscription.unsubscribe();
};
}, []);
return (
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-blue-900 to-gray-900 text-white">
<ScrollProgress />
<Header />
{/* Hero Section */}
<main className="pt-24 pb-16 px-4 sm:px-6 lg:px-8">
<div className="container mx-auto max-w-6xl">
{/* Hero Content */}
<div className="text-center mb-16">
<div className="flex justify-center mb-6">
<Bot size={80} className="text-blue-400 animate-pulse" />
</div>
<h1 className="text-5xl md:text-6xl font-bold mb-6 bg-gradient-to-r from-blue-400 to-purple-500 bg-clip-text text-transparent">
AI Interview Pro
</h1>
<p className="text-xl md:text-2xl text-gray-300 mb-4">
用 AI 技術,精準模擬真實前端面試
</p>
<p className="text-lg text-gray-400 mb-8 max-w-2xl mx-auto">
從概念問答到程式實作,讓 AI 面試官協助你成為頂尖工程師
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
{!loading && (
user ? (
// 已登入:只顯示進入主控台
<Link
href="/dashboard"
className="px-8 py-4 bg-blue-600 hover:bg-blue-700 text-white font-bold rounded-lg transition-all transform hover:scale-105 shadow-lg flex items-center justify-center gap-2"
>
進入主控台 <ArrowRight size={20} />
</Link>
) : (
// 未登入:顯示開始練習和進入主控台兩個按鈕
<>
<Link
href="/auth"
className="px-8 py-4 bg-blue-600 hover:bg-blue-700 text-white font-bold rounded-lg transition-all transform hover:scale-105 shadow-lg flex items-center justify-center gap-2"
>
開始練習 <ArrowRight size={20} />
</Link>
<Link
href="/dashboard"
className="px-8 py-4 bg-gray-700 hover:bg-gray-600 text-white font-bold rounded-lg transition-all transform hover:scale-105 shadow-lg"
>
進入主控台
</Link>
</>
)
)}
</div>
</div>
{/* Features Section */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 mb-16">
<AnimatedCard delay={0}>
<div className="bg-gray-800/50 backdrop-blur-sm p-8 rounded-xl border border-gray-700 hover:border-blue-500 transition-all h-full">
<MessageSquare className="text-blue-400 mb-4" size={40} />
<h3 className="text-xl font-bold mb-3">概念問答</h3>
<p className="text-gray-400">
深入測試你對前端技術的理解,包括 JavaScript、React、CSS 等核心概念
</p>
</div>
</AnimatedCard>
<AnimatedCard delay={200}>
<div className="bg-gray-800/50 backdrop-blur-sm p-8 rounded-xl border border-gray-700 hover:border-purple-500 transition-all h-full">
<Code className="text-purple-400 mb-4" size={40} />
<h3 className="text-xl font-bold mb-3">程式實作</h3>
<p className="text-gray-400">
在真實的編輯器環境中撰寫程式碼,即時執行並獲得 AI 的專業回饋
</p>
</div>
</AnimatedCard>
<AnimatedCard delay={400}>
<div className="bg-gray-800/50 backdrop-blur-sm p-8 rounded-xl border border-gray-700 hover:border-green-500 transition-all h-full">
<BarChart2 className="text-green-400 mb-4" size={40} />
<h3 className="text-xl font-bold mb-3">進度追蹤</h3>
<p className="text-gray-400">
詳細的數據分析,追蹤你的學習進度,找出需要加強的領域
</p>
</div>
</AnimatedCard>
</div>
{/* Benefits Section */}
<AnimatedCard>
<div className="bg-gray-800/30 backdrop-blur-sm rounded-2xl p-8 md:p-12 border border-gray-700 mb-16">
<h2 className="text-3xl font-bold mb-8 text-center">為什麼選擇 AI Interview Pro?</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<AnimatedCard delay={0}>
<div className="flex items-start gap-4">
<CheckCircle2 className="text-blue-400 flex-shrink-0 mt-1" size={24} />
<div>
<h4 className="font-semibold mb-2">24/7 隨時練習</h4>
<p className="text-gray-400">不受時間限制,隨時隨地開始你的面試練習</p>
</div>
</div>
</AnimatedCard>
<AnimatedCard delay={200}>
<div className="flex items-start gap-4">
<CheckCircle2 className="text-purple-400 flex-shrink-0 mt-1" size={24} />
<div>
<h4 className="font-semibold mb-2">即時 AI 回饋</h4>
<p className="text-gray-400">獲得專業、詳細的評估和改進建議</p>
</div>
</div>
</AnimatedCard>
<AnimatedCard delay={400}>
<div className="flex items-start gap-4">
<CheckCircle2 className="text-green-400 flex-shrink-0 mt-1" size={24} />
<div>
<h4 className="font-semibold mb-2">真實面試情境</h4>
<p className="text-gray-400">模擬真實的面試流程和問題難度</p>
</div>
</div>
</AnimatedCard>
<AnimatedCard delay={600}>
<div className="flex items-start gap-4">
<CheckCircle2 className="text-yellow-400 flex-shrink-0 mt-1" size={24} />
<div>
<h4 className="font-semibold mb-2">持續進步追蹤</h4>
<p className="text-gray-400">量化你的進步,看見自己的成長軌跡</p>
</div>
</div>
</AnimatedCard>
</div>
</div>
</AnimatedCard>
{/* Testimonials Section */}
<AnimatedCard>
<div className="mb-16">
<h2 className="text-3xl font-bold mb-4 text-center">使用者真實回饋</h2>
<p className="text-gray-400 text-center mb-12 text-lg">
看看其他工程師如何透過 AI Interview Pro 達成目標
</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{testimonials.map((testimonial, index) => (
<AnimatedCard key={index} delay={index * 200}>
<div className="bg-gray-800/50 backdrop-blur-sm p-6 rounded-xl border border-gray-700 hover:border-blue-500 transition-all h-full">
<div className="flex items-center gap-4 mb-4">
<div className="text-4xl">{testimonial.avatar}</div>
<div>
<h4 className="font-semibold">{testimonial.name}</h4>
<p className="text-sm text-gray-400">{testimonial.role}</p>
</div>
</div>
<div className="flex gap-1 mb-4">
{[...Array(testimonial.rating)].map((_, i) => (
<Star key={i} size={16} className="text-yellow-400 fill-yellow-400" />
))}
</div>
<p className="text-gray-300 text-sm leading-relaxed">
{testimonial.content}
</p>
</div>
</AnimatedCard>
))}
</div>
</div>
</AnimatedCard>
{/* Pricing Section */}
<AnimatedCard>
<div className="mb-16">
<h2 className="text-3xl font-bold mb-4 text-center">選擇最適合你的方案</h2>
<p className="text-gray-400 text-center mb-12 text-lg">
從免費體驗開始,隨時升級到專業版或企業版
</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{pricingPlans.map((plan, index) => (
<AnimatedCard key={index} delay={index * 200}>
<div
className={`bg-gray-800/50 backdrop-blur-sm p-8 rounded-xl border transition-all h-full flex flex-col ${
plan.highlight
? 'border-blue-500 ring-2 ring-blue-500 scale-105'
: 'border-gray-700 hover:border-blue-500'
}`}
>
{plan.highlight && (
<div className="text-center mb-4">
<span className="bg-gradient-to-r from-blue-500 to-purple-500 text-white text-xs font-bold px-3 py-1 rounded-full">
最受歡迎
</span>
</div>
)}
<h3 className="text-2xl font-bold mb-2 text-center">{plan.name}</h3>
<div className="text-center mb-6">
<span className="text-4xl font-bold">
{plan.price === 0 ? '免費' : `NT$ ${plan.price}`}
</span>
{plan.price > 0 && (
<span className="text-gray-400 text-sm ml-2">/ {plan.period}</span>
)}
{plan.price === 0 && (
<div className="text-gray-400 text-sm mt-1">{plan.period}</div>
)}
</div>
<ul className="space-y-3 mb-8 flex-grow">
{plan.features.map((feature, i) => (
<li key={i} className="flex items-start gap-3">
<Check size={20} className="text-green-400 flex-shrink-0 mt-0.5" />
<span className="text-gray-300 text-sm">{feature}</span>
</li>
))}
</ul>
<Link
href="/auth"
className={`block text-center py-3 px-6 rounded-lg font-semibold transition-all ${
plan.highlight
? 'bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white'
: 'bg-gray-700 hover:bg-gray-600 text-white'
}`}
>
{plan.price === 0 ? '立即體驗' : '開始使用'}
</Link>
</div>
</AnimatedCard>
))}
</div>
</div>
</AnimatedCard>
{/* CTA Section */}
<AnimatedCard>
<div className="text-center">
<h2 className="text-3xl font-bold mb-4">準備好開始了嗎?</h2>
<p className="text-gray-400 mb-8 text-lg">
立即註冊,開始你的前端工程師進階之旅
</p>
<Link
href="/auth"
className="inline-flex items-center gap-2 px-10 py-4 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white font-bold rounded-lg transition-all transform hover:scale-105 shadow-xl text-lg"
>
<Zap size={24} />
免費開始練習
</Link>
</div>
</AnimatedCard>
</div>
</main>
{/* Footer */}
<footer className="border-t border-gray-800 py-8 px-4 bg-gray-900/50 backdrop-blur-sm">
<div className="container mx-auto max-w-6xl text-center text-gray-400">
<p>© 2025 AI Interview Pro. 精進技能,成為頂尖工程師。</p>
</div>
</footer>
</div>
);
}
AnimatedCard
組件function AnimatedCard({ children, delay = 0 }) {
const { ref, isVisible } = useScrollAnimation();
return (
<div ref={ref} className={`transition-all duration-700 ${
isVisible ? 'opacity-100 translate-y-0' : 'opacity-0
translate-y-10'
}`} style={{ transitionDelay: `${delay}ms` }}>
{children}
</div>
);
}
封裝了滾動動畫邏輯,當元素進入視窗時會從透明+下移狀態過渡到完全可見,可
透過 delay 參數實現依序出現效果。
假資料設定
主頁面結構
每個區塊都使用 AnimatedCard
包裹,搭配不同的 delay
值實現流暢的進場動畫。
完成後的主頁面如下圖:
![]() |
---|
圖1 :主畫面部分截圖 |
隨著你往下滾動,上方的進度條與下方的卡片都會有基本的動畫效果出現,讓整個頁面看起來還挺像一回事的!
呼!終於把欠的東西補上了,主頁面應該是我們一早就要弄的東西,但我一直忽略了這一點而把重心放在一些不熟悉技術的實作,但至少我們在結束前弄出一個還算完整的作品了!做了現代主頁面的同時也介紹了幾個前端常用的基本動畫技巧,讓整個成品不至於像是高中生的電腦課作業!
專案本身的開發到這邊就完全結束了,各位這一路走來辛苦了,明天會是整個專案的總覽以及一些回顧,我們會一一點出我們的專案設計以及我們的實作,同時給出一些專案可行的優化方向,我會盡量讓建議的部分通用化,讓你可以用在任何的專案上,畢竟 AI 面試官這個專案只是我從剛學習程式開發時就一直很想弄的東西之一,這次的系列文只是藉著 AI 應用之名圓我自己一個夢,我們明天見吧!