結業專案:洛奇旅行

這篇把前幾天的元件收成洛奇旅行:四個前台頁面、本機後台、專案裡的 JSON,再佈上 GitHub Pages。深度停在照表組裝、講得出資料從哪來。不上 TypeScript。

取得素材

結業站寫在 05-project/。路由從 react-router 引入,沿用 React Router 那篇的 BrowserRouter、Routes、Link 與 NavLink。排版與 ESLint 跟著專案走,不要改個人的使用者設定。一個終端機 pnpm dev,另一個 pnpm api(json-server 仍鎖定 0.17)。

規格

品牌是洛奇旅行。前台用 React 與 Tailwind。json-server 只在本機;正式站改讀專案裡的 JSON,不連後端。

項目 契約
行程欄位 id、title、place、days、price、image、intro
報名欄位 tourId、name、email、people
篩選 列表依 place、days
詳情按鈕 把 tourId 帶到 /booking

頁面

路徑 頁面檔 畫面上的元件
/ HomePage.jsx Hero
/tours ToursPage.jsx TourList、TourCard(#tour-list)
/tours/:id TourDetailPage.jsx TourDetail
/booking 路由直接掛表單 BookingForm(#booking-form)
/admin AdminPage.jsx 行程增刪改,僅本機

每一頁外層都是 SiteHeader(#site-header、#nav-toggle)與 SiteFooter。

元件對到作業

六份作業就是這六個元件。檔名與 id 不要改。

作業元件 檔案 最後放在
SiteHeader src/SiteHeader.jsx 每一頁上方
Hero src/Hero.jsx /
TourList/TourCard src/TourList.jsx、src/TourCard.jsx /tours
TourDetail src/TourDetail.jsx /tours/:id
BookingForm src/BookingForm.jsx /booking
SiteFooter src/SiteFooter.jsx 每一頁下方

照順序組站

先讓本機四條路由與報名走得通,再換成靜態 JSON,最後才處理網址前綴與佈署。列表、篩選與受控表單在 React 基礎,依 id 讀取與增刪改在 React 進階,BrowserRouter 與 Link 在 React Router。這篇只補頁首開關、主視覺、詳情區塊與組裝。

組裝順序

下面五步是組站的先後。先讓本機四條路由與報名走得通,再換成靜態 JSON,最後才處理網址前綴。每一步只開表上的那些檔。

順序 做什麼 開哪一檔
1 頁首可開合、主視覺、詳情區塊 SiteHeader.jsx、Hero.jsx、TourDetail.jsx
2 路由掛上六個元件與 /admin App.jsx
3 正式建置改讀 JSON,報名改寫進瀏覽器 src/data/tours.json、api.js
4 卡片圖片接上站點前綴 TourCard.jsx、TourDetail.jsx
5 設定 base 與 basename,建置後複製 404.html vite.config.js、main.jsx

頁首與路由

頁首用狀態記住選單是否打開,契約 id 是 site-header 與 nav-toggle。主視覺放在首頁,詳情收到一筆行程後畫出標題、圖片與報名連結。App 把這幾頁掛上路由,頁首與頁尾留在路由外面,每一頁都看得到。

src/SiteHeader.jsx
import { useState } from "react";
import { Link } from "react-router";

export default function SiteHeader() {
const [open, setOpen] = useState(false);

return (
<header id="site-header" className="border-b">
<div className="mx-auto flex max-w-6xl items-center gap-4 px-4 py-3">
<Link to="/" className="font-semibold">洛奇旅行</Link>
<button id="nav-toggle" type="button" className="ml-auto sm:hidden" onClick={() => setOpen((value) => !value)}>選單</button>
<nav className={(open ? "flex" : "hidden") + " w-full flex-col gap-3 sm:flex sm:w-auto sm:flex-row sm:gap-4 text-sm"}>
<Link to="/">首頁</Link>
<Link to="/tours">行程</Link>
<Link to="/booking">報名</Link>
</nav>
</div>
</header>
);
}
src/Hero.jsx
import { Link } from "react-router";

export default function Hero() {
return (
<section className="rounded-xl bg-teal-800 px-6 py-16 text-white">
<h1 className="text-4xl font-bold">洛奇旅行</h1>
<p className="mt-3">城市散步、山海與社寺。</p>
<Link className="mt-6 inline-block rounded bg-white px-4 py-2 text-teal-900" to="/tours">看行程</Link>
</section>
);
}
src/HomePage.jsx
import Hero from "./Hero.jsx";

export default function HomePage() {
return <Hero />;
}
src/TourDetail.jsx
import { Link } from "react-router";

const srcOf = (image) => import.meta.env.BASE_URL + image.replace(/^\//, "");

export default function TourDetail({ tour }) {
return (
<article>
<img className="h-56 w-full rounded-xl object-cover" src={srcOf(tour.image)} alt={tour.title} />
<h1 className="mt-4 text-3xl font-bold">{tour.title}</h1>
<p className="mt-2">{tour.place} · {tour.days} 天 · NT${tour.price}</p>
<p className="mt-2">{tour.intro}</p>
<Link className="mt-6 inline-block rounded bg-teal-800 px-4 py-2 text-white" to={"/booking?tourId=" + tour.id}>我要報名</Link>
</article>
);
}

「我要報名」的 to 已經帶著 tourId。報名頁用 React Router:路由、連結與參數 的 useSearchParams 讀這個鍵來預填。

TourDetailPage 用 React 進階的 useEffect 讀一筆。函式名是 getTour,對應那篇的 getPost:參數是 id,失敗就丟錯。讀到資料後改成 <TourDetail tour={tour} />。TourCard 的 img 同樣用 import.meta.env.BASE_URL 接路徑,標題的 Link 仍是 to={"/tours/" + tour.id}。

src/App.jsx
import { Route, Routes } from "react-router";
import SiteHeader from "./SiteHeader.jsx";
import SiteFooter from "./SiteFooter.jsx";
import HomePage from "./HomePage.jsx";
import ToursPage from "./ToursPage.jsx";
import TourDetailPage from "./TourDetailPage.jsx";
import BookingForm from "./BookingForm.jsx";
import AdminPage from "./AdminPage.jsx";

export default function App() {
return (
<>
<SiteHeader />
<main className="mx-auto max-w-6xl px-4 py-8">
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/tours" element={<ToursPage />} />
<Route path="/tours/:id" element={<TourDetailPage />} />
<Route path="/booking" element={<BookingForm />} />
<Route path="/admin" element={<AdminPage />} />
</Routes>
</main>
<SiteFooter />
</>
);
}

換成專案裡的 JSON

本機開發仍走 /api(Vite proxy 到 json-server)。pnpm build 之後沒有那個伺服器,所以 import.meta.env.PROD 為真時改讀 JSON。正式站的報名只寫進這台瀏覽器的 localStorage,重新整理還在,換電腦就沒有。

資料檔

db.json 留給本機 API。線上用的是 src/data/tours.json,內容就是行程陣列,欄位與本機相同。getTours 對應進階篇的 getLocalItems,讀的是清單。getTour 對應 getPost,依 id 取一筆。createBooking 對應 createPost。saveTour 有 id 時走 PUT,與 updatePost 同一種;沒有 id 時走 POST。deleteTour 對應 deletePost。

src/data/tours.json
[
{ "id": 1, "title": "台北城市漫遊", "place": "台北", "days": 3, "price": 12800, "image": "/img/tours/taipei.svg", "intro": "城市散步" },
{ "id": 2, "title": "花蓮山海三日", "place": "花蓮", "days": 3, "price": 15900, "image": "/img/tours/hualien.svg", "intro": "山海" },
{ "id": 3, "title": "京都紅葉散步", "place": "京都", "days": 5, "price": 42800, "image": "/img/tours/kyoto.svg", "intro": "社寺" }
]
src/api.js
import toursJson from "./data/tours.json";

const localTours = () => toursJson;
const readBookings = () => JSON.parse(localStorage.getItem("bookings") || "[]");

export const getTours = async () => {
if (import.meta.env.PROD) return localTours();
const response = await fetch("/api/tours");
if (!response.ok) throw new Error(String(response.status));
return response.json();
};

export const getTour = async (id) => {
if (import.meta.env.PROD) {
const tour = localTours().find((item) => String(item.id) === String(id));
if (!tour) throw new Error("404");
return tour;
}
const response = await fetch("/api/tours/" + id);
if (!response.ok) throw new Error(String(response.status));
return response.json();
};

export const createBooking = async (booking) => {
if (import.meta.env.PROD) {
localStorage.setItem("bookings", JSON.stringify([...readBookings(), booking]));
return booking;
}
const response = await fetch("/api/bookings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(booking),
});
if (!response.ok) throw new Error(String(response.status));
return response.json();
};

export const saveTour = async (tour) => {
const response = await fetch(tour.id ? "/api/tours/" + tour.id : "/api/tours", {
method: tour.id ? "PUT" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(tour),
});
if (!response.ok) throw new Error(String(response.status));
return response.json();
};

export const deleteTour = async (id) => {
const response = await fetch("/api/tours/" + id, { method: "DELETE" });
if (!response.ok) throw new Error(String(response.status));
};

本機後台

/admin 只改行程,不做登入、不做報名清單。正式站打開這頁只顯示說明。F12 的 Network 在本機看得到 POST、PUT、DELETE /api/tours。

src/AdminPage.jsx
import { useEffect, useState } from "react";
import { deleteTour, getTours, saveTour } from "./api.js";

const empty = { title: "", place: "", days: "3", price: "10000", image: "/img/tours/taipei.svg", intro: "" };

export default function AdminPage() {
const [tours, setTours] = useState([]);
const [form, setForm] = useState(empty);
const [message, setMessage] = useState("");
const onChange = (event) => setForm({ ...form, [event.target.name]: event.target.value });

const load = () => getTours().then(setTours).catch((error) => setMessage(String(error)));

useEffect(() => {
if (!import.meta.env.PROD) load();
}, []);

if (import.meta.env.PROD) return <p>後台僅本機示範。請在本機執行 pnpm api。</p>;

const onSubmit = async (event) => {
event.preventDefault();
await saveTour({ ...form, id: form.id ? Number(form.id) : undefined, days: Number(form.days), price: Number(form.price) });
setForm(empty);
setMessage("已儲存");
load();
};

return (
<>
<h1 className="text-3xl font-bold">行程後台</h1>
<form className="mt-4 grid max-w-xl gap-3" onSubmit={onSubmit}>
<input name="title" placeholder="標題" value={form.title} onChange={onChange} required className="p-2" />
<input name="place" placeholder="地點" value={form.place} onChange={onChange} required className="p-2" />
<input name="days" type="number" value={form.days} onChange={onChange} required className="p-2" />
<input name="price" type="number" value={form.price} onChange={onChange} required className="p-2" />
<input name="intro" placeholder="簡介" value={form.intro} onChange={onChange} required className="p-2" />
<button type="submit" className="rounded bg-teal-800 px-4 py-2 text-white">儲存</button>
</form>
<ul className="mt-6 grid gap-2">
{tours.map((tour) => (
<li key={tour.id} className="flex items-center gap-3">
<span>{tour.title}</span>
<button type="button" onClick={() => setForm({ ...tour, days: String(tour.days), price: String(tour.price) })}>編輯</button>
<button type="button" onClick={() => deleteTour(tour.id).then(load)}>刪除</button>
</li>
))}
</ul>
<p className="text-sm">{message}</p>
</>
);
}

注意事項
不要為了上線去架一台 json-server。線上列表來自 tours.json。後台頁在正式站保持「僅本機示範」。

佈上 GitHub Pages

GitHub Pages 的網址是 https://帳號.github.io/倉庫名/。Vite 預設把資源當成放在網域根目錄,所以要設定 base。重新整理 /tours 時,主機找不到那份檔案,要把 index.html 複製成 404.html 當入口。

前綴與指令

把 lokitravel 換成你的倉庫名。basename 要有開頭斜線、不要結尾斜線。本機 pnpm dev 的 BASE_URL 仍是 /。

vite.config.js
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";

export default defineConfig({
base: "/lokitravel/",
plugins: [react(), tailwindcss()],
server: {
proxy: { "/api": { target: "http://localhost:3000", rewrite: (path) => path.replace(/^\/api/, "") } },
},
});
src/main.jsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router";
import "./index.css";
import App from "./App.jsx";

createRoot(document.getElementById("root")).render(
<StrictMode>
<BrowserRouter basename={import.meta.env.BASE_URL.replace(/\/$/, "")}>
<App />
</BrowserRouter>
</StrictMode>,
);
pnpm add -D gh-pages
pnpm build
cp dist/index.html dist/404.html
pnpm exec gh-pages -d dist

倉庫要是公開的。GitHub 的 Settings → Pages 選 gh-pages 分支、資料夾 / (root)。等一分鐘再開 https://帳號.github.io/lokitravel/。圖片路徑若 404,先查 base 與 public/img/tours/ 是否一起被建進去。

驗收

結構與功能 50%、元件作業 30%、展示口述 20%。畫面好不好看不單獨計分。

檢查 過關
路由 /、/tours、/tours/:id、/booking 都進得去
契約 id #site-header、#nav-toggle、#tour-list、#booking-form 還在
列表 改地點或天數後張數有變
詳情 「我要報名」讓表單的 tourId 等於該團
報名 本機 Network 看得到 POST,body 四個欄位都在
後台 本機能新增、修改、刪除一筆;線上該頁寫僅本機
靜態站 GitHub Pages 看得到主視覺與三張卡,不依賴 json-server
口述 不看稿說明一個元件的資料從哪來

小技巧:展示順序
先在本機從列表進詳情再送出報名,再開 GitHub Pages 走同一條瀏覽。口述挑 TourList 或 TourDetail 其中一個即可。

交作業

交的是洛奇旅行這一個站,不是另開習題。路徑、元件與欄位都沿用前面的契約。

交件路徑

這份作業寫在 05-project/。六個元件都掛在 App 上,並能指出每一個最後在哪一條路徑。路由從 react-router 引入。不要裝 TypeScript,不要改欄位名稱。

本機與靜態站

本機要走完篩選列表、詳情、報名與 /admin。正式建置改讀 tours.json,佈上 GitHub Pages 後首頁與 /tours 仍要打得開。

跟著做:

  1. 本機完成:篩選列表、開啟詳情、送出 #booking-form、在 /admin 改一筆行程。
  2. tours.json 至少三筆、七個欄位都在。正式建置不向 json-server 要列表。
  3. 佈上 GitHub Pages 後,首頁與 /tours 仍看得到。用繁中註解寫 PROD 時 getTours 為什麼改讀 JSON。不要裝 TypeScript,不要改欄位名稱。

本篇重點回顧

  • 交作業的路徑是 05-project/。路由從 react-router 引入。
  • 前台是 /、/tours、/tours/:id、/booking。/admin 只在本機增刪改行程。
  • 六個作業元件是 SiteHeader、Hero、TourList/TourCard、TourDetail、BookingForm、SiteFooter。
  • getTours 對應 getLocalItems,getTour 對應 getPost。正式站改讀 tours.json,報名改寫進瀏覽器。
  • base、basename 與 404.html 讓 GitHub Pages 的子路徑打得開。
  • 展示時先走完本機報名,再打開線上靜態站,並講出一個元件的資料來源。