切版做完之後,這一章把同一份洛奇旅行的行程與報名接到雲端。前台不設會員,訪客直接填表。只有後台使用 Firebase Authentication 的 email 與密碼。資料放在 Cloud Firestore。下面的路徑寫 07-project-final。人還在自己的 06-project-init 裡時,資料夾名稱換成那一份,檔案相對位置相同。
從切版繼續 這一章沿用第 12 篇結束時的畫面、路由與色票。先選定一份專案並啟動,再看清楚哪些讀寫仍停在這次開啟的記憶體裡。後面才一次換一層。
複製並啟動 兩種起點改的是同一套檔案。已經裝過 Node.js 24 與 pnpm 的人直接做下面兩步。剛重灌的電腦先照 Tailwind:utilities、RWD、深色模式 把 Node 24 與 pnpm 裝好。
步驟 1:選定專案 跟著 結業專案:洛奇旅行(一)切版 做完的人,06-project-init 已經與 05-project-prototype 相同。留在那個資料夾,跳到下一步。
還沒做完、或想直接拿切版完成品的人,在 studiesAdvanced 倉庫根目錄把 05-project-prototype 複製成 07-project-final。目的資料夾還不存在時才複製。05-project-prototype 留著對切版,後面的修改寫在複本。
macOS(終端機在倉庫根目錄):
cp -R 05-project-prototype 07-project-final
Windows(PowerShell,同樣在倉庫根目錄):
Copy-Item -Recurse 05 -project-prototype 07 -project-final
資料夾已經在 目的資料夾已經存在時跳過複製,直接做下一步。macOS 的 cp -R 會把來源嵌進既有資料夾。已做完第 12 篇的人直接用 06-project-init。
步驟 2:安裝並啟動 node_modules 與 dist 是這一台電腦裝出來、建出來的結果。複製之後先刪掉,再安裝,然後啟動。人已經在自己的 06-project-init 裡、依賴也裝過時,直接執行 pnpm dev。
macOS(以複本為例;沿用第 12 篇的人把路徑換成 06-project-init):
rm -rf 07-project-final/node_modules 07-project-final/distcd 07-project-finalpnpm install pnpm dev
Windows(PowerShell):
Remove-Item -Recurse -Force 07 -project-final \node_modules, 07 -project-final \dist -ErrorAction SilentlyContinuecd 07 -project-final pnpm install pnpm dev
終端機會印出本機網址。瀏覽器打開之後,畫面應與切版完成品相同。
解析
05-project-prototype 與做完第 12 篇的 06-project-init 是同一份切版,差別只在資料夾從哪裡來。
pnpm install 依 package.json 與鎖定檔裝依賴。pnpm dev 啟動 Vite 開發伺服器。
色票、路由表與契約 id(site-header、nav-toggle、tour-list、booking-form)維持不變,後面的功能加在這些頁面之上。
記憶體狀態與本章範圍 切版裡的登入、報名與後台儲存只改這次開啟的記憶體。重新整理之後,畫面回到 src/data/ 的種子。這一章要換掉的是這一層讀寫。版面、色票與路由留在第 12 篇的結果。
graph LR
seed["src/data 種子"] --> store["StoreProvider"]
store --> pages["頁面"]
session["sessionStorage"] --> gate["假的登入"]
前台不建立會員。訪客選好出發日就填姓名、email 與電話,報名寫進訂單。後台才登入,而且只用 email 與密碼。行程改從雲端讀。後台的新增與編輯行程這一章拿掉,清單只讀雲端上已經寫入的行程。剩餘名額要掃過所有訂單才算得出來,訂單稍後只准登入者讀,所以前台改顯示每團名額。
這一章結束時,頁面呼叫同一組服務函式。服務函式再呼叫 Firestore。管理員的身份由 Authentication 回報,後台畫面在確認有登入者之後才渲染訂單。
graph LR
page["頁面"] --> api["api.js"]
api --> fs["Cloud Firestore"]
admin["管理員"] --> authn["Authentication"]
authn --> gate["後台畫面"]
gate --> api
解析
切版的 sessionStorage 鍵 loki-travel-session 這一章不再讀取。關掉分頁就消失的那組假帳號,換成 Authentication 自己保存的登入狀態。
行程文件用原本的 id,例如 hualien-taroko,詳情網址不用改。
訂單由 Firestore 編號。集合名稱是 orders。
色票與十二條既有路由維持不變。多出來的只有種子頁 /seed。
認識 Firebase Firebase 是 Google 提供的後端服務,專案在瀏覽器裡用它的 JavaScript SDK 呼叫。這一章只用其中兩項:Cloud Firestore 存文件,Authentication 確認管理員是誰。先知道這兩項各自負責什麼,再建立專案、把設定交給 Vite。
Firestore 與 Authentication Firestore 是文件資料庫。資料分成集合與文件。集合是一疊文件,文件是一筆欄位。欄位可以是字串、數字、陣列或巢狀物件。這一章的行程集合叫 tours,一筆行程一份文件。訂單集合叫 orders,一筆報名一份文件。
graph LR
tours["集合 tours"] --> doc["文件 hualien-taroko"]
doc --> fields["欄位 title、price、departures"]
讀寫發生在瀏覽器。SDK 送出請求,畫面接著等結果。失敗時(沒有網路、規則拒絕)會丟出錯誤,頁面要自己顯示,不能假設種子還在記憶體裡。
Authentication 管的是「這個人是誰」。這一章只用 email 與密碼。它不負責這個人能不能讀某一筆訂單。能不能讀寫是 Firestore 安全規則的事,留到最後一節。規則生效之前,資料庫先放在測試模式,種子才寫得進去。
金鑰會出現在前端 網頁應用程式的設定會被打包進 JavaScript。任何人打開網站都能看到。真正擋住讀寫的是安全規則。設定出現在前端是預期的。
解析
集合裡可以沒有文件。第一次讀 tours 會得到空陣列,不是例外。
文件 id 可以自己指定,也可以讓 Firestore 產生。行程用前者,訂單用後者。
Authentication 的登入狀態由 SDK 記在這台瀏覽器。重新整理後,onAuthStateChanged 會再回報一次。
測試模式約 30 天內允許所有讀寫。它不是這一章的正式規則。
建立專案與初始化 設定只寫在這三個地方:Firebase 主控台的專案、本機的 .env.local、src/firebase.js。後面的服務函式只向 firebase.js 拿 db 與 auth,不再抄金鑰。
步驟 1:建立 Firebase 專案 打開 Firebase 主控台 ,新增專案。建立時關閉 Google Analytics,這一章用不到。
專案建好後,進入 Firestore Database,建立資料庫。地點建立後不能改,選離自己近的,例如 asia-east1。模式先選測試模式,讓後面的種子頁寫得進去。正式規則在最後一節才貼上,這裡先不要自訂規則。
Authentication 的開關先不要開。管理員帳號留到登入那一節,避免同一項設定寫兩次。
步驟 2:註冊網頁應用程式 在專案設定裡新增網頁應用程式。不要勾選 Firebase Hosting。註冊完成後,主控台給一份設定物件。先留在主控台,下一步才抄進本機。
步驟 3:環境變數 Vite 只把以 VITE_ 開頭的變數放進瀏覽器端的 import.meta.env。沒有這個前綴的變數,元件讀不到。範例檔進版控,值留空。真正的金鑰寫在 .env.local,這個檔名已經被 *.local 忽略。再補上 .env 與 .env.*,避免有人把金鑰寫進別的檔名後 commit。!.env.example 必須放在 .env.* 後面,範例檔才不會被一起忽略。
07-project-final/.gitignore *.local +.env +.env.* +!.env.example .DS_Store
07-project-final/.env.example # 對照 Firebase 主控台「網頁應用程式」設定物件的同名字段。值留空,真正的金鑰只寫在 .env.local。 VITE_FIREBASE_API_KEY= VITE_FIREBASE_AUTH_DOMAIN= VITE_FIREBASE_PROJECT_ID= VITE_FIREBASE_STORAGE_BUCKET= VITE_FIREBASE_MESSAGING_SENDER_ID= VITE_FIREBASE_APP_ID=
複製範例再填值。主控台的 apiKey 填進 VITE_FIREBASE_API_KEY,其餘欄位同樣加上 VITE_FIREBASE_ 前綴,駝峰改成大寫底線。
cp .env.example .env.local
改完 .env.local 要停掉開發伺服器再執行 pnpm dev。Vite 在啟動時讀環境變數,存檔不會熱更新這一份。
還沒有金鑰也能打包 .env.local 留空時,後面的頁面會顯示尚未設定。pnpm build 不會因為缺少金鑰失敗。要讓打包後的網站真的連上 Firebase,得在有填好 .env.local 的電腦上建置。
步驟 4:安裝 SDK 並初始化 在專案資料夾安裝 Firebase。pnpm 12 預設不執行依賴的安裝腳本,除非在 pnpm-workspace.yaml 明確允許。@firebase/util 與 protobufjs 的 postinstall 是套件自己的準備步驟,允許之後 pnpm install 才會以成功結束。
07-project-final/pnpm-workspace.yaml minimumReleaseAgeExclude: - '@vitejs/plugin-react@6.2.1' +allowBuilds: + '@firebase/util': true + protobufjs: true
初始化集中在一個檔。沒有 apiKey 或 projectId 時不呼叫 initializeApp,開發伺服器不會在啟動當下因空設定崩潰。熱更新會再執行這個檔,已經建立過的 App 就沿用。
07-project-final/src/firebase.js import { getApps, initializeApp } from 'firebase/app' ;import { getAuth } from 'firebase/auth' ;import { getFirestore } from 'firebase/firestore' ;const firebaseConfig = { apiKey : import .meta .env .VITE_FIREBASE_API_KEY , authDomain : import .meta .env .VITE_FIREBASE_AUTH_DOMAIN , projectId : import .meta .env .VITE_FIREBASE_PROJECT_ID , storageBucket : import .meta .env .VITE_FIREBASE_STORAGE_BUCKET , messagingSenderId : import .meta .env .VITE_FIREBASE_MESSAGING_SENDER_ID , appId : import .meta .env .VITE_FIREBASE_APP_ID , }; export const missingFirebaseMessage = '尚未設定 Firebase。請依 .env.example 建立 .env.local,存檔後重新啟動開發伺服器。' ;export const firebaseReady = Boolean (firebaseConfig.apiKey && firebaseConfig.projectId );const app = firebaseReady ? (getApps ()[0 ] ?? initializeApp (firebaseConfig)) : null ;export const db = app ? getFirestore (app) : null ;export const auth = app ? getAuth (app) : null ;
解析
firebaseReady 為假時,db 與 auth 都是 null。頁面讀取時才丟出 missingFirebaseMessage,這句話只宣告在這個檔。
getFirestore 與 getAuth 拿到的是同一份 App 上的兩項服務。
環境變數在建置時被寫進產物。沒有填值的建置,連線用的字串是空的。
讀寫介面 頁面不要直接呼叫 Firestore。先把讀寫收成服務函式,再讓 Hook 撐住等待與失敗。頁面只看這兩個結果。
服務函式 src/api.js 是這一章唯一會 import Firestore 方法的檔。database 在每次讀寫時檢查 db。沒有初始化就丟出上面那句錯誤,呼叫端用 catch 接。
getDocs 讀整個集合,getDoc 讀一份文件。setDoc 寫到指定 id,行程用這個,id 就是切版裡的 tour.id。addDoc 讓 Firestore 產生 id,訂單用這個。updateDoc 只改帶上去的欄位。serverTimestamp 由伺服器填寫入時間,清單依它由新到舊。
種子函式走訪 src/data/tours.js。文件已經存在就略過,所以按鈕可以重複按,不會蓋掉主控台裡改過的欄位。回傳值是這次新寫入的筆數。
07-project-final/src/api.js import { addDoc, collection, doc, getDoc, getDocs, orderBy, query, serverTimestamp, setDoc, updateDoc } from 'firebase/firestore' ;import { tours } from './data/tours.js' ;import { db, missingFirebaseMessage } from './firebase.js' ;const database = ( ) => { if (!db) throw new Error (missingFirebaseMessage); return db; }; export const listTours = async ( ) => { const snap = await getDocs (collection (database (), 'tours' )); return snap.docs .map ((item ) => ({ id : item.id , ...item.data () })).sort ((a, b ) => a.title .localeCompare (b.title , 'zh-Hant' )); }; export const getTour = async (id ) => { const snap = await getDoc (doc (database (), 'tours' , id)); return snap.exists () ? { id : snap.id , ...snap.data () } : null ; }; export const seedTours = async ( ) => { const base = database (); let created = 0 ; for (const tour of tours) { const ref = doc (base, 'tours' , tour.id ); if ((await getDoc (ref)).exists ()) continue ; await setDoc (ref, tour); created += 1 ; } return created; }; export const createOrder = async (input ) => { const ref = await addDoc (collection (database (), 'orders' ), { ...input, status : 'pending' , createdAt : serverTimestamp () }); return { id : ref.id , status : 'pending' , ...input }; }; export const listOrders = async ( ) => { const snap = await getDocs (query (collection (database (), 'orders' ), orderBy ('createdAt' , 'desc' ))); return snap.docs .map ((item ) => ({ id : item.id , ...item.data () })); }; export const updateOrderStatus = async (id, status ) => { await updateDoc (doc (database (), 'orders' , id), { status }); };
解析
回傳的物件帶上 id。畫面的 key 與網址用的是這個欄位,不是 Firestore 內部另存的一份。
getTour 找不到文件時回傳 null,詳情頁把它畫成找不到頁面。
createOrder 回傳的物件還不含伺服器時間戳。完成頁只用這份回傳,不立刻再讀一次訂單。
listOrders 的 orderBy('createdAt') 是單一欄位排序,Firestore 會自動建索引。每筆訂單都要有 createdAt,缺了這個欄位的文件不會出現在結果裡。
參考答案
拿掉存在就略過的那一行之後,每次按下都會用 src/data/tours.js 蓋掉雲端上的同名文件。
07-project-final/src/api.js // ... const ref = doc(base, 'tours', tour.id); - if ((await getDoc(ref)).exists()) continue; await setDoc(ref, tour); // ...
自訂 Hook 服務函式回傳 Promise。頁面要的是三件事:還在等、失敗了、最新一筆結果。useAsync 把這三件事留在 state。第二個參數是真正會變的值,例如行程 id。load 每次 render 都是新函式,不放進依賴,否則詳情頁會不停重抓。reload 只把 tick 加一,效果函式就再跑一次。
抓取開始時先清掉上一筆資料並進入讀取中。清理函式把 active 設成假,元件卸載後不再 setState。
useTours、useTour、useOrders 是這三個畫面專用的薄包裝。LoadGate 在讀取中或失敗時畫 #content,成功才把孩子交回去。跳到內容的連結仍然找得到這個 id。
07-project-final/src/useAsync.js import { useEffect, useState } from 'react' ;import { getTour, listOrders, listTours } from './api.js' ;export function useAsync (load, deps ) { const [data, setData] = useState (null ); const [error, setError] = useState (null ); const [loading, setLoading] = useState (true ); const [tick, setTick] = useState (0 ); useEffect (() => { let active = true ; setLoading (true ); setError (null ); setData (null ); load () .then ((value ) => { if (active) setData (value); }) .catch ((err ) => { if (active) setError (err); }) .finally (() => { if (active) setLoading (false ); }); return () => { active = false ; }; }, [tick, ...deps]); return { data, error, loading, reload : () => setTick ((n ) => n + 1 ) }; } export const useTours = ( ) => useAsync (listTours, []);export const useTour = (id ) => useAsync (() => getTour (id), [id]);export const useOrders = ( ) => useAsync (listOrders, []);
07-project-final/src/LoadGate.jsx import Alert from '@mui/material/Alert' ;const frame = 'mx-auto w-full max-w-6xl flex-1 px-4 py-16 sm:px-6 md:py-24' ;export default function LoadGate ({ loading, error, children } ) { if (loading) return <main id ="content" className ={frame} > <p className ="text-lg text-muted" > 讀取中…</p > </main > ; if (error) return <main id ="content" className ={frame} > <Alert severity ="error" > {error.message}</Alert > </main > ; return children; }
解析
loading 一開始是 true。第一個畫面是「讀取中…」,不是空的行程列表。
失敗時 data 維持 null,LoadGate 顯示 error.message。沒填環境變數時,這句就是 missingFirebaseMessage。
useTour 的依賴是 [id]。網址上的 id 變了才重抓那一筆。
每個頁面各自呼叫 Hook,各自讀一次。這一章先不把結果放進全站共用的快取。
接到畫面 服務函式與 Hook 就緒之後,把首頁、列表、詳情改成讀雲端,再用一頁把種子寫上去。報名表接著改為新增訂單。
行程讀取與種子 列表與首頁用 useTours。詳情用 useTour,讀取結束仍沒有文件才畫找不到頁面。讀取中不要先畫 404。出發日不再計算剩餘人數,連結直接到報名頁,不再繞去 /login。
步驟 1:三個頁面改讀雲端 store.jsx 先留著。報名與後台下一節才脫離它。
07-project-final/src/pages/HomePage.jsx import { useEffect } from 'react'; +import Alert from '@mui/material/Alert'; import Typography from '@mui/material/Typography'; // ... import TourCard from '../components/TourCard.jsx'; -import { useStore } from '../store.jsx'; +import LoadGate from '../LoadGate.jsx'; +import { useTours } from '../useAsync.js'; export default function HomePage() { - const { tours } = useStore(); + const { data, loading, error } = useTours(); + const tours = data ?? []; const featured = tours.filter((tour) => tour.featured); useEffect(() => { document.title = '洛奇旅行'; }, []); + if (loading || error) return <LoadGate loading={loading} error={error} />; return ( <main id="content"> + {tours.length ? null : <div className="mx-auto w-full max-w-6xl px-4 pt-10 sm:px-6"><Alert severity="info">還沒有行程。請先打開 /seed 寫入種子。</Alert></div>} <HeroSlider slides={featured.length ? featured : tours.slice(0, 4)} /> // ...
列表的 useMemo 必須留在提前返回之前。Hook 不能寫在 if 後面。集合還是空的時候,空狀態改提示去種子頁。篩選把已有的行程濾光時,仍維持原本的「沒有符合的條件」。
07-project-final/src/pages/ToursPage.jsx import TourFilters from '../components/TourFilters.jsx'; +import LoadGate from '../LoadGate.jsx'; import { matchPrice } from '../format.js'; -import { useStore } from '../store.jsx'; +import { useTours } from '../useAsync.js'; export default function ToursPage() { - const { tours } = useStore(); + const { data, loading, error } = useTours(); + const tours = data ?? []; const [params, setParams] = useSearchParams(); useEffect(() => { document.title = '行程 · 洛奇旅行'; }, []); // ... }, [params, tours]); + if (loading || error) return <LoadGate loading={loading} error={error} />; return ( // ... - <Alert severity="info">沒有符合的條件。</Alert> + <Alert severity="info">{tours.length ? '沒有符合的條件。' : '還沒有行程。請先打開 /seed 寫入種子。'}</Alert> <Typography id="empty-title" variant="h2" className="mt-3">沒有符合的行程</Typography> - <p className="mt-3 max-w-prose">換一個地區或天數,或清掉價格條件。</p> + <p className="mt-3 max-w-prose">{tours.length ? '換一個地區或天數,或清掉價格條件。' : '種子寫入後再回到這一頁。'}</p> // ...
07-project-final/src/pages/TourDetailPage.jsx import NotFoundPage from './NotFoundPage.jsx'; -import { ntd, seatsLeft } from '../format.js'; -import { useSession } from '../session.jsx'; -import { useStore } from '../store.jsx'; +import LoadGate from '../LoadGate.jsx'; +import { ntd } from '../format.js'; +import { useTour } from '../useAsync.js'; export default function TourDetailPage() { const { id } = useParams(); - const { tours, bookings } = useStore(); - const { user } = useSession(); - const tour = tours.find((item) => item.id === id); - const [departureId, setDepartureId] = useState(tour?.departures[0]?.id ?? ''); + const { data: tour, loading, error } = useTour(id); + const [departureId, setDepartureId] = useState(''); useEffect(() => { document.title = tour ? `${tour.title} · 洛奇旅行` : '找不到頁面 · 洛奇旅行'; }, [tour]); useEffect(() => { setDepartureId(tour?.departures[0]?.id ?? ''); }, [tour]); + if (loading || error) return <LoadGate loading={loading} error={error} />; if (!tour) return <NotFoundPage />; // ... - const next = `/booking?tourId=${tour.id}&departureId=${selected.id}`; - const bookTo = user ? next : `/login?next=${encodeURIComponent(next)}`; + const bookTo = `/booking?tourId=${tour.id}&departureId=${selected.id}`; // ... - <p className="mt-3 text-base text-muted">最近一梯 {tour.departures[0].date} 尚餘 {seatsLeft(bookings, tour.id, tour.departures[0])} 人。</p> - <DepartureList tour={tour} bookings={bookings} /> + <p className="mt-3 text-base text-muted">最近一梯 {tour.departures[0].date},每團 {tour.departures[0].capacity} 人。名額不在前台計算。</p> + <DepartureList tour={tour} /> // ... - <span className="text-base text-muted">剩餘 {seatsLeft(bookings, tour.id, departure)} 人</span> + <span className="text-base text-muted">名額 {departure.capacity} 人</span> // ...
手機上的出發日列表改成只收行程,按鈕直接前往報名。
07-project-final/src/components/DepartureList.jsx import { Link } from 'react-router' ;import Button from '@mui/material/Button' ;export default function DepartureList ({ tour } ) { return ( <ul className ="panel mt-4 max-w-3xl divide-y divide-line overflow-hidden px-5" > {tour.departures.map((departure) => ( <li key ={departure.id} className ="flex flex-col gap-3 py-4 sm:flex-row sm:items-center sm:justify-between" > <div > <p className ="font-medium" > {departure.date}</p > <p className ="text-muted" > 名額 {departure.capacity} 人</p > </div > <Button component ={Link} to ={ `/booking ?tourId =${tour.id}&departureId =${departure.id} `} variant ="contained" className ="w-full sm:w-auto" > 立即報名</Button > </li > ))} </ul > ); }
解析
首頁在讀取中整頁交給 LoadGate,輪播不會先用空陣列畫一幀。
集合是空的時,首頁與列表都指向 /seed。種子頁下一小節才掛上路由,這時點進去會落到找不到頁面,寫完下一步就通了。
詳情的 bookTo 不再看 session。未登入的訪客進得了報名頁。
seatsLeft 還留在 format.js,前台不再呼叫。訂單不能公開讀,減完的人數也不會準。
步驟 2:種子頁 種子頁不放進頁首。網址是 /seed。按下之後呼叫 seedTours。測試模式底下,八筆行程會出現在主控台的 tours 集合。已經寫過再按,筆數是 0,畫面寫「沒有覆寫」。
規則換成最後一節的內容之後,這個按鈕會被拒絕。那是預期的:正式規則不允許瀏覽器寫行程。
07-project-final/src/pages/SeedPage.jsx import { useEffect, useState } from 'react' ;import { Link } from 'react-router' ;import Alert from '@mui/material/Alert' ;import Button from '@mui/material/Button' ;import Typography from '@mui/material/Typography' ;import { seedTours } from '../api.js' ;import { firebaseReady, missingFirebaseMessage } from '../firebase.js' ;export default function SeedPage ( ) { const [message, setMessage] = useState ('' ); const [error, setError] = useState ('' ); const [busy, setBusy] = useState (false ); useEffect (() => { document .title = '寫入行程種子 · 洛奇旅行' ; }, []); const run = async ( ) => { setBusy (true ); setError ('' ); setMessage ('' ); try { const created = await seedTours (); setMessage (created ? `已寫入 ${created} 筆行程。` : '行程都已存在,沒有覆寫。' ); } catch (err) { setError (err.message ); } finally { setBusy (false ); } }; return ( <main id ="content" className ="mx-auto w-full max-w-6xl flex-1 px-4 py-16 sm:px-6 md:py-24" > <Typography variant ="h1" > 寫入行程種子</Typography > <p className ="mt-3 max-w-prose text-lg leading-relaxed text-muted" > 把專案裡的行程寫進 Firestore 的 tours。已經存在的文件會略過,可以重複按。</p > {firebaseReady ? null : <Alert className ="mt-8" severity ="warning" > {missingFirebaseMessage}</Alert > } {message ? <Alert className ="mt-8" severity ="success" > {message}</Alert > : null} {error ? <Alert className ="mt-8" severity ="error" > {error}</Alert > : null} <div className ="mt-8 flex flex-wrap gap-3" > <Button variant ="contained" disabled ={!firebaseReady || busy } onClick ={run} > {busy ? '寫入中…' : '寫入種子'}</Button > <Button component ={Link} to ="/tours" variant ="outlined" > 看行程</Button > </div > </main > ); }
路由先加在現有的 SessionProvider 裡面。登入提供者下一節才換成 Authentication。
07-project-final/src/App.jsx import LoginPage from './pages/LoginPage.jsx'; +import SeedPage from './pages/SeedPage.jsx'; import AdminLoginPage from './pages/AdminLoginPage.jsx'; // ... <Route path="login" element={<LoginPage />} /> + <Route path="seed" element={<SeedPage />} /> <Route path="admin/login" element={<AdminLoginPage />} /> // ...
寫入成功後打開 /tours。八筆行程應來自 Firestore,重新整理還在。主控台的 tours 集合也能看到相同的文件 id。
解析
種子頁在按下時才寫入,打開網址本身不會改資料。
firebaseReady 為假時按鈕停用,並顯示同一句尚未設定。
文件 id 用行程 id,詳情路由 /tours/hualien-taroko 對得上雲端那一份。
這一頁能寫入,是因為資料庫仍在測試模式。它不是正式規則的一部分。
報名表寫入訂單 報名不再包 MemberGate。姓名與 email 改成可填。送出時呼叫 createOrder,成功後把回傳的那一筆放進路由的 state,導到完成頁。完成頁重新整理後,state 消失。訂單不能公開讀,所以這一頁不再向雲端要那一筆。
/login 與 /me 留著路由,內容改成說明。頁首的「登入」下一節才拿掉。刪掉已經沒有人 import 的 src/components/MemberGate.jsx。
07-project-final/src/components/BookingForm.jsx import { useState } from 'react' ;import Alert from '@mui/material/Alert' ;import Button from '@mui/material/Button' ;import TextField from '@mui/material/TextField' ;import Typography from '@mui/material/Typography' ;import { createOrder } from '../api.js' ;import { ntd } from '../format.js' ;import FieldSelect from './FieldSelect.jsx' ;export default function BookingForm ({ tour, departure, onDone } ) { const [people, setPeople] = useState ('2' ); const [name, setName] = useState ('' ); const [email, setEmail] = useState ('' ); const [phone, setPhone] = useState ('' ); const [submitted, setSubmitted] = useState (false ); const [error, setError] = useState ('' ); const [busy, setBusy] = useState (false ); const subtotal = tour.price * Number (people); const invalid = !name.trim () || !email.includes ('@' ) || !phone.trim (); const submit = async (event ) => { event.preventDefault (); setSubmitted (true ); if (invalid) { setError ('請填姓名、有效的 email 與電話' ); return ; } setBusy (true ); setError ('' ); try { const row = await createOrder ({ tourId : tour.id , tourTitle : tour.title , departureId : departure.id , date : departure.date , people : Number (people), name : name.trim (), email : email.trim (), phone : phone.trim (), subtotal, }); onDone (row); } catch (err) { setError (err.message ); setBusy (false ); } }; return ( <form id ="booking-form" noValidate className ="panel grid gap-4 p-6 md:p-8" onSubmit ={submit} > <Typography variant ="h2" > 填寫資料</Typography > <div className ="grid gap-4 sm:grid-cols-2" > <TextField label ="姓名" required value ={name} error ={submitted && !name.trim ()} onChange ={(event) => setName(event.target.value)} /> <TextField label ="Email" type ="email" required value ={email} error ={submitted && !email.includes ('@')} onChange ={(event) => setEmail(event.target.value)} /> </div > <FieldSelect id ="people" label ="人數" value ={people} onChange ={setPeople} options ={[1, 2 , 3 , 4 , 5 , 6 , 7 , 8 ].map ((count ) => ({ value: String(count), label: String(count) }))} /> <TextField label ="電話" type ="tel" required placeholder ="0912000111" value ={phone} error ={submitted && !phone.trim ()} helperText =" " onChange ={(event) => setPhone(event.target.value)} /> {error ? <Alert severity ="error" > {error}</Alert > : null} <p > 小計 <span className ="font-serif text-2xl font-bold text-action" > {ntd(subtotal)}</span > </p > <p className ="text-muted" > 小計會依人數更新。送出後我們用 email 確認。</p > <Button type ="submit" variant ="contained" disabled ={busy} > {busy ? '送出中…' : '送出報名'}</Button > </form > ); }
07-project-final/src/pages/BookingPage.jsx import { useEffect } from 'react' ;import { Link , useNavigate, useSearchParams } from 'react-router' ;import Alert from '@mui/material/Alert' ;import Step from '@mui/material/Step' ;import StepLabel from '@mui/material/StepLabel' ;import Stepper from '@mui/material/Stepper' ;import Typography from '@mui/material/Typography' ;import BookingForm from '../components/BookingForm.jsx' ;import LoadGate from '../LoadGate.jsx' ;import { ntd } from '../format.js' ;import { useTours } from '../useAsync.js' ;const steps = ['選出發日' , '填寫資料' , '送出確認' ];export default function BookingPage ( ) { const { data, loading, error } = useTours (); const tours = data ?? []; const [params] = useSearchParams (); const navigate = useNavigate (); const tour = tours.find ((item ) => item.id === (params.get ('tourId' ) || 'hualien-taroko' )); const departure = tour?.departures .find ((item ) => item.id === params.get ('departureId' )) || tour?.departures [0 ]; useEffect (() => { document .title = '報名 · 洛奇旅行' ; }, []); if (loading || error) return <LoadGate loading ={loading} error ={error} /> ; return ( <main id ="content" className ="mx-auto w-full max-w-6xl flex-1 px-4 py-16 sm:px-6 md:py-24" > <Stepper activeStep ={1} alternativeLabel className ="mb-10" sx ={{ '& .MuiStepLabel-label ': { fontSize: '1.125rem ', fontWeight: 700 , marginTop: '8px ' } }}> {steps.map((label) => <Step key ={label} > <StepLabel > {label}</StepLabel > </Step > )} </Stepper > <Typography variant ="h1" > 報名</Typography > <p className ="mt-3 max-w-prose text-lg leading-relaxed text-muted" > 不用登入。留下姓名、email 與電話,送出後會寫進訂單。</p > {!tour || !departure ? ( <Alert className ="mt-8" severity ="warning" > 找不到這趟行程或出發日。<Link className ="ml-2 underline" to ="/tours" > 回行程</Link > </Alert > ) : ( <div className ="mt-10 grid items-start gap-8 lg:grid-cols-[minmax(0,1.15fr)_minmax(18rem,22rem)]" > <BookingForm tour ={tour} departure ={departure} onDone ={(row) => navigate('/booking/done', { state: row })} /> <aside className ="panel p-6 md:p-8 lg:sticky lg:top-24" > <Typography variant ="h2" > 這趟行程</Typography > <dl className ="mt-5 grid gap-4 text-base" > <div > <dt className ="text-muted" > 行程</dt > <dd className ="mt-1 text-lg font-medium" > {tour.title}</dd > </div > <div > <dt className ="text-muted" > 出發日</dt > <dd className ="mt-1 text-lg" > {departure.date}</dd > </div > <div > <dt className ="text-muted" > 天數</dt > <dd className ="mt-1 text-lg" > {tour.days} 天</dd > </div > <div > <dt className ="text-muted" > 每人</dt > <dd className ="mt-1 whitespace-nowrap font-serif text-3xl font-bold text-ink" > {ntd(tour.price)}<span className ="font-sans text-base font-normal text-muted" > / 人</span > </dd > </div > </dl > </aside > </div > )} </main > ); }
07-project-final/src/pages/BookingDonePage.jsx import { useEffect } from 'react' ;import { Link , useLocation } from 'react-router' ;import Alert from '@mui/material/Alert' ;import Button from '@mui/material/Button' ;import Chip from '@mui/material/Chip' ;import Step from '@mui/material/Step' ;import StepLabel from '@mui/material/StepLabel' ;import Stepper from '@mui/material/Stepper' ;import Typography from '@mui/material/Typography' ;import { ntd, statusLabel } from '../format.js' ;const steps = ['選出發日' , '填寫資料' , '送出確認' ];export default function BookingDonePage ( ) { const { state } = useLocation (); const row = state?.id ? state : null ; useEffect (() => { document .title = '報名已送出 · 洛奇旅行' ; }, []); return ( <main id ="content" className ="mx-auto w-full max-w-6xl flex-1 px-4 py-16 sm:px-6 md:py-24" > <Stepper activeStep ={2} alternativeLabel className ="mb-8" > {steps.map((label) => <Step key ={label} > <StepLabel > {label}</StepLabel > </Step > )} </Stepper > <Chip color ="primary" label ={statusLabel[row?.status] || '待確認 '} /> <Typography variant ="h1" className ="mt-3 max-w-[10em]" > 報名已送出</Typography > <Alert className ="mt-4 max-w-prose" severity ="success" > 我們會用 email 再跟你確認。訂單內容只有管理員讀得到,重新整理後這一頁不再從雲端把資料讀回來。</Alert > {row ? ( <> <Typography variant ="h2" className ="mt-10" > 這筆內容</Typography > <dl className ="panel mt-4 max-w-prose divide-y divide-line px-5" > {[['行程', row.tourTitle], ['出發日', row.date], ['人數', row.people], ['姓名', row.name], ['電話', row.phone], ['小計', ntd(row.subtotal)], ['狀態', statusLabel[row.status] || row.status]].map(([label, value]) => ( <div key ={label} className ="grid gap-1 py-3 sm:grid-cols-[8rem_1fr]" > <dt className ="text-muted" > {label}</dt > <dd className ={label === '小計' ? 'font-serif text-2xl font-bold text-action ' : undefined }> {value}</dd > </div > ))} </dl > </> ) : <p className ="mt-8 max-w-prose text-lg text-muted" > 這一頁只保留剛送出的那一筆。請從行程重新報名。</p > } <div className="mt-8 flex flex-wrap gap-3" > <Button component ={Link} to ="/tours" variant ="contained" > 回行程</Button > <Button component ={Link} to ="/" variant ="outlined" > 回首頁</Button > </div> </main> ); }
07-project-final/src/pages/LoginPage.jsx import { useEffect } from 'react' ;import { Link } from 'react-router' ;import Button from '@mui/material/Button' ;import Typography from '@mui/material/Typography' ;export default function LoginPage ( ) { useEffect (() => { document .title = '報名不必登入 · 洛奇旅行' ; }, []); return ( <main id ="content" className ="mx-auto w-full max-w-6xl flex-1 px-4 py-16 sm:px-6 md:py-24" > <Typography variant ="h1" > 報名不必登入</Typography > <p className ="mt-3 max-w-prose text-lg leading-relaxed text-muted" > 前台沒有會員。選好出發日後直接填表。管理員登入在頁尾。</p > <Button className ="mt-8" component ={Link} to ="/tours" variant ="contained" > 去看行程</Button > </main > ); }
07-project-final/src/pages/MyBookingsPage.jsx import { useEffect } from 'react' ;import { Link } from 'react-router' ;import Button from '@mui/material/Button' ;import Typography from '@mui/material/Typography' ;export default function MyBookingsPage ( ) { useEffect (() => { document .title = '我的報名 · 洛奇旅行' ; }, []); return ( <main id ="content" className ="mx-auto w-full max-w-6xl flex-1 px-4 py-16 sm:px-6 md:py-24" > <Typography variant ="h1" > 我的報名</Typography > <p className ="mt-3 max-w-prose text-lg leading-relaxed text-muted" > 這一章沒有會員,瀏覽器也不能讀訂單。確認信走 email,名單只出現在後台。</p > <Button className ="mt-8" component ={Link} to ="/tours" variant ="contained" > 去看行程</Button > </main > ); }
送出一筆之後,主控台的 orders 集合應多一份文件,status 是 pending。重新整理完成頁,明細消失,Firestore 裡的文件還在。src/data/bookings.js 不再被頁面 import。
解析
onDone 用路由 state 把剛寫入的欄位帶過去。這份 state 只活在這一次導頁。雲端那一筆由管理員在訂單清單讀取。
表單的 id="booking-form" 維持契約。驗證在送出前做完,不合就不呼叫 createOrder。
測試模式允許未登入者新增訂單。正式規則仍會保留「任何人可新增」。
/me 不再列出假資料。公開讀取訂單會把電話暴露給每一個訪客,下一節的規則會擋住。
管理員、規則與部署 前台已經能讀行程、能寫訂單。這一節把後台的假 session 換成 Authentication,清單改讀 orders,最後貼上安全規則,並沿用第 12 篇的靜態打包。
登入與受保護畫面 Authentication 只回答「現在有沒有登入者」。這一章不開放註冊,所以登入者就是你在主控台建立的那位管理員。規則用 request.auth != null 表示已登入,沒有另外的角色欄位。
畫面上的閘門是 AdminGate。它先等 onAuthStateChanged 回報,再決定要畫孩子還是登入提示。不等的話,重新整理的第一幀會把已登入的人當成訪客。
步驟 1:建立管理員帳號 在 Firebase 主控台打開 Authentication,登入方式啟用電子郵件/密碼。在使用者分頁手動新增一位使用者,email 與密碼自己留著,不要寫進專案。
應用程式沒有註冊表單。別人不能自己在網站上變成管理員。
步驟 2:AuthProvider auth.jsx 訂閱登入狀態,並提供 signIn 與 signOut。沒有初始化時,signIn 拒絕並帶上同一句尚未設定。ready 在第一次回報之後才是真。沒有 Firebase 時不必等,直接視為已確認、使用者是 null。
07-project-final/src/auth.jsx import { createContext, useContext, useEffect, useMemo, useState } from 'react' ;import { onAuthStateChanged, signInWithEmailAndPassword, signOut } from 'firebase/auth' ;import { auth, firebaseReady, missingFirebaseMessage } from './firebase.js' ;const AuthContext = createContext (null );export function AuthProvider ({ children } ) { const [user, setUser] = useState (null ); const [ready, setReady] = useState (!firebaseReady); useEffect (() => { if (!auth) return undefined ; return onAuthStateChanged (auth, (next ) => { setUser (next); setReady (true ); }); }, []); const api = useMemo (() => ({ user, ready, firebaseReady, signIn (email, password ) { if (!auth) return Promise .reject (new Error (missingFirebaseMessage)); return signInWithEmailAndPassword (auth, email, password); }, signOut ( ) { return auth ? signOut (auth) : Promise .resolve (); }, }), [user, ready]); return <AuthContext.Provider value ={api} > {children}</AuthContext.Provider > ; } export const useAuth = ( ) => useContext (AuthContext );
App 先在原有的 provider 外面包一層 AuthProvider。StoreProvider 還要留給行程管理頁,訂單那一節改完再拿掉。種子路由留著。
07-project-final/src/App.jsx +import { AuthProvider } from './auth.jsx'; import SiteLayout from './layouts/SiteLayout.jsx'; // ... export default function App() { return ( + <AuthProvider> <SessionProvider> <StoreProvider> <Routes> // ... </Routes> </StoreProvider> </SessionProvider> + </AuthProvider> ); }
版面裡的成功提示原本來自 store。訂單狀態改由清單頁自己顯示錯誤,這個 Snackbar 拿掉。
07-project-final/src/layouts/SiteLayout.jsx import { Outlet } from 'react-router' ;import SiteHeader from '../components/SiteHeader.jsx' ;import SiteFooter from '../components/SiteFooter.jsx' ;export default function SiteLayout ( ) { return ( <div className ="flex min-h-screen flex-col bg-paper font-sans text-ink" > <SiteHeader /> <div className ="flex flex-1 flex-col" > <Outlet /> </div > <SiteFooter /> </div > ); }
頁首只在後台路徑顯示「後台登入」。已登入時顯示 email。前台不再放會員登入按鈕。
07-project-final/src/components/SiteHeader.jsx -import { useSession } from '../session.jsx'; +import { useAuth } from '../auth.jsx'; // ... export default function SiteHeader() { - const { user, signOut } = useSession(); + const { user, signOut } = useAuth(); // ... - const account = ( - user ? ( - <Button type="button" aria-haspopup="true" aria-expanded={Boolean(anchor)} onClick={(event) => setAnchor(event.currentTarget)} className="gap-2 text-ink"> - <Avatar className="h-8 w-8 bg-action text-base font-bold text-white">{user.name.slice(0, 1)}</Avatar> - {user.name} - </Button> - ) : <Button component={Link} to={admin ? '/admin/login' : '/login'} onClick={close}>{admin ? '後台登入' : '登入'}</Button> - ); + const label = user?.email ?? ''; + const account = user ? ( + <Button type="button" aria-haspopup="true" aria-expanded={Boolean(anchor)} onClick={(event) => setAnchor(event.currentTarget)} className="gap-2 text-ink"> + <Avatar className="h-8 w-8 bg-action text-base font-bold text-white">{label.slice(0, 1).toUpperCase()}</Avatar> + {label} + </Button> + ) : admin ? <Button component={Link} to="/admin/login" onClick={close}>後台登入</Button> : null; // ... {user ? ( <Button type="button" aria-haspopup="true" onClick={(event) => setAnchor(event.currentTarget)} className="justify-start gap-2 text-ink"> - <Avatar className="h-8 w-8 bg-action text-base font-bold text-white">{user.name.slice(0, 1)}</Avatar> - {user.name} + <Avatar className="h-8 w-8 bg-action text-base font-bold text-white">{label.slice(0, 1).toUpperCase()}</Avatar> + {label} </Button> - ) : <Button component={Link} to={admin ? '/admin/login' : '/login'} onClick={close}>{admin ? '後台登入' : '登入'}</Button>} + ) : admin ? <Button component={Link} to="/admin/login" onClick={close}>後台登入</Button> : null} // ...
步驟 3:登入表單與閘門 密碼錯誤時,Firebase 的代碼是 auth/invalid-credential。這一種改寫成中文。其他失敗仍顯示 err.message,尚未設定 Firebase 時就是那句固定文字。
07-project-final/src/pages/AdminLoginPage.jsx import { useEffect, useState } from 'react' ;import { useNavigate } from 'react-router' ;import Alert from '@mui/material/Alert' ;import Button from '@mui/material/Button' ;import Paper from '@mui/material/Paper' ;import TextField from '@mui/material/TextField' ;import Typography from '@mui/material/Typography' ;import { useAuth } from '../auth.jsx' ;export default function AdminLoginPage ( ) { const { signIn, user } = useAuth (); const navigate = useNavigate (); const [email, setEmail] = useState ('' ); const [password, setPassword] = useState ('' ); const [error, setError] = useState ('' ); const [busy, setBusy] = useState (false ); useEffect (() => { document .title = '後台登入 · 洛奇旅行' ; }, []); useEffect (() => { if (user) navigate ('/admin/bookings' ); }, [user, navigate]); const submit = async (event ) => { event.preventDefault (); setError ('' ); setBusy (true ); try { await signIn (email.trim (), password); navigate ('/admin/bookings' ); } catch (err) { setError (err.code === 'auth/invalid-credential' ? 'email 或密碼不對。請用主控台建立的那一組。' : err.message ); setBusy (false ); } }; return ( <main id ="content" className ="mx-auto flex w-full max-w-6xl flex-1 flex-col px-4 py-16 sm:px-6 md:py-24" > <Paper component ="form" elevation ={0} className ="panel grid max-w-md gap-4 p-6 md:p-8" onSubmit ={submit} > <Typography variant ="h1" > 後台登入</Typography > <p className ="text-lg leading-relaxed" > 使用 Firebase Authentication 的 email 與密碼。前台報名不需要登入。</p > <TextField label ="Email" type ="email" autoComplete ="username" value ={email} onChange ={(event) => setEmail(event.target.value)} required /> <TextField label ="密碼" type ="password" autoComplete ="current-password" value ={password} onChange ={(event) => setPassword(event.target.value)} required /> {error ? <Alert severity ="error" > {error}</Alert > : null} <Button type ="submit" variant ="contained" disabled ={busy} > {busy ? '登入中…' : '登入'}</Button > </Paper > </main > ); }
07-project-final/src/components/AdminGate.jsx import { Link } from 'react-router' ;import Alert from '@mui/material/Alert' ;import Button from '@mui/material/Button' ;import { useAuth } from '../auth.jsx' ;import { missingFirebaseMessage } from '../firebase.js' ;export default function AdminGate ({ children } ) { const { user, ready, firebaseReady } = useAuth (); if (!firebaseReady) { return ( <main id ="content" className ="mx-auto w-full max-w-6xl flex-1 px-4 py-16 sm:px-6 md:py-24" > <Alert severity ="warning" > {missingFirebaseMessage}</Alert > </main > ); } if (!ready) return <main id ="content" className ="mx-auto w-full max-w-6xl flex-1 px-4 py-16 text-lg text-muted sm:px-6 md:py-24" > 確認身份…</main > ; if (user) return children; return ( <main id ="content" className ="mx-auto w-full max-w-6xl flex-1 px-4 py-16 sm:px-6 md:py-24" > <Alert severity ="warning" > 訂單只有管理員可以看。</Alert > <Button className ="mt-6" component ={Link} to ="/admin/login" variant ="contained" > 到後台登入</Button > </main > ); }
換你試試: 登入失敗時一律顯示「登入失敗」,不要把 Firebase 的英文訊息直接給使用者。密碼錯誤那句中文留著。
參考答案
auth/invalid-credential 仍用中文。其餘失敗改成固定句子,尚未設定的說明也就不再從 err.message 漏出來。若要保留那句,得在 catch 裡先判斷 err.message === missingFirebaseMessage。
07-project-final/src/pages/AdminLoginPage.jsx // ... } catch (err) { - setError(err.code === 'auth/invalid-credential' ? 'email 或密碼不對。請用主控台建立的那一組。' : err.message); + setError(err.code === 'auth/invalid-credential' ? 'email 或密碼不對。請用主控台建立的那一組。' : '登入失敗'); setBusy(false); } // ...
解析
signInWithEmailAndPassword 成功後,onAuthStateChanged 會更新 user。表單另外 navigate 一次,已登入者重新打開登入頁也會被帶去清單。
AdminGate 的三種畫面依序是:沒有設定、還在確認、沒有使用者。有使用者才渲染孩子。
閘門只擋畫面。沒有登入的人仍可從瀏覽器直接呼叫 SDK。下一節的規則才擋讀取。
頁首已經改接 useAuth。session.jsx 等訂單頁也離開 store 再一起刪。
訂單清單與狀態 後台行程頁改成只讀。清單頁用 useOrders,狀態下拉改呼叫 updateOrderStatus,成功後 reload。這兩個頁面與表格都換成下面的寫法之後,再拿掉記憶體 provider,並刪掉已經沒有人 import 的檔。
07-project-final/src/pages/AdminToursPage.jsx import { useEffect } from 'react' ;import { Link } from 'react-router' ;import Button from '@mui/material/Button' ;import Card from '@mui/material/Card' ;import Table from '@mui/material/Table' ;import TableBody from '@mui/material/TableBody' ;import TableCell from '@mui/material/TableCell' ;import TableContainer from '@mui/material/TableContainer' ;import TableHead from '@mui/material/TableHead' ;import TableRow from '@mui/material/TableRow' ;import Typography from '@mui/material/Typography' ;import AdminGate from '../components/AdminGate.jsx' ;import AdminTabs from '../components/AdminTabs.jsx' ;import LoadGate from '../LoadGate.jsx' ;import { ntd } from '../format.js' ;import { useTours } from '../useAsync.js' ;function AdminToursBody ( ) { const { data, loading, error } = useTours (); const tours = data ?? []; useEffect (() => { document .title = '行程管理 · 洛奇旅行' ; }, []); if (loading || error) return <LoadGate loading ={loading} error ={error} /> ; return ( <main id ="content" className ="mx-auto w-full max-w-6xl flex-1 px-4 py-16 sm:px-6 md:py-24" > <p className ="font-bold text-action" > 管理員</p > <Typography variant ="h1" className ="mt-2" > 行程管理</Typography > <p className ="mt-3 max-w-prose text-lg leading-relaxed text-muted" > 清單來自 Firestore。這一章只讀,不在後台新增或修改。還沒有資料時,先寫入種子。</p > <AdminTabs /> <Button className ="mt-4" component ={Link} to ="/seed" variant ="outlined" > 寫入種子</Button > {tours.length ? ( <> <TableContainer elevation ={0} className ="panel mt-8 hidden overflow-x-auto md:block" > <Table > <TableHead > <TableRow > {['標題', '地區', '主題', '天數', '價格'].map((label) => <TableCell key ={label} className ="px-4 py-3" > {label}</TableCell > )} </TableRow > </TableHead > <TableBody > {tours.map((tour) => ( <TableRow key ={tour.id} > <TableCell className ="px-4 py-4" > {tour.title}</TableCell > <TableCell className ="px-4 py-4" > {tour.region}</TableCell > <TableCell className ="px-4 py-4" > {tour.theme}</TableCell > <TableCell className ="px-4 py-4" > {tour.days} 天</TableCell > <TableCell className ="px-4 py-4" > {ntd(tour.price)}</TableCell > </TableRow > ))} </TableBody > </Table > </TableContainer > <div className ="mt-8 grid gap-4 md:hidden" > {tours.map((tour) => ( <Card key ={tour.id} className ="p-6" > <h3 className ="font-serif text-2xl font-semibold" > {tour.title}</h3 > <p className ="mt-2 text-muted" > {tour.region} · {tour.theme} · {tour.days} 天 · {ntd(tour.price)}</p > </Card > ))} </div > </> ) : <p className ="mt-8 text-base text-muted" > 還沒有行程。</p > } </main> ); } export default function AdminToursPage ( ) { return <AdminGate > <AdminToursBody /> </AdminGate > ; }
表格不再自己碰 store。狀態變更由清單頁傳進來。
07-project-final/src/components/BookingTable.jsx import { ntd, statusLabel } from '../format.js'; -import { useStore } from '../store.jsx'; import { tokens } from '../theme.js'; // ... -function StatusSelect({ booking }) { - const { setBookingStatus } = useStore(); +function StatusSelect({ booking, onStatus }) { const tone = statusTone[booking.status] || statusTone.pending; // ... - onChange={(event) => setBookingStatus(booking.id, event.target.value)} + onChange={(event) => onStatus(booking.id, event.target.value)} // ... -export default function BookingTable({ bookings }) { +export default function BookingTable({ bookings, onStatus }) { // ... - <TableCell className="px-4 py-4"><StatusSelect booking={booking} /></TableCell> + <TableCell className="px-4 py-4"><StatusSelect booking={booking} onStatus={onStatus} /></TableCell> // ... - <div className="mt-3"><StatusSelect booking={booking} /></div> + <div className="mt-3"><StatusSelect booking={booking} onStatus={onStatus} /></div> // ...
07-project-final/src/pages/AdminBookingsPage.jsx import { useEffect, useMemo, useState } from 'react'; +import Alert from '@mui/material/Alert'; import MenuItem from '@mui/material/MenuItem'; // ... import BookingTable from '../components/BookingTable.jsx'; +import LoadGate from '../LoadGate.jsx'; +import { updateOrderStatus } from '../api.js'; import { statusLabel } from '../format.js'; -import { useStore } from '../store.jsx'; +import { useOrders } from '../useAsync.js'; // ... function AdminBookingsBody() { - const { bookings } = useStore(); + const { data, loading, error, reload } = useOrders(); + const bookings = data ?? []; const [status, setStatus] = useState('all'); const [query, setQuery] = useState(''); const [sort, setSort] = useState('date-desc'); + const [notice, setNotice] = useState(''); useEffect(() => { document.title = '報名清單 · 洛奇旅行'; }, []); // ... - .filter((booking) => !keyword || booking.name.includes(keyword)) + .filter((booking) => !keyword || booking.name?.includes(keyword)) // ... }, [bookings, status, query, sort]); + + const changeStatus = async (id, next) => { + setNotice(''); + try { + await updateOrderStatus(id, next); + reload(); + } catch (err) { + setNotice(err.message); + } + }; + + if (loading || error) return <LoadGate loading={loading} error={error} />; return ( // ... - <p className="mt-3 max-w-prose text-lg leading-relaxed text-muted">查看所有報名,並把狀態改為待確認、已確認或已取消。</p> + <p className="mt-3 max-w-prose text-lg leading-relaxed text-muted">查看所有訂單,並把狀態改為待確認、已確認或已取消。</p> <AdminTabs /> + {notice ? <Alert className="mt-6" severity="error">{notice}</Alert> : null} // ... - {shown.length ? <BookingTable bookings={shown} /> : <p className="mt-8 text-base text-muted">沒有符合的報名。</p>} + {shown.length ? <BookingTable bookings={shown} onStatus={changeStatus} /> : <p className="mt-8 text-base text-muted">沒有符合的報名。</p>} // ...
頁面換成上面的寫法之後,App 拿掉記憶體那兩層。
07-project-final/src/App.jsx -import { SessionProvider } from './session.jsx'; -import { StoreProvider } from './store.jsx'; import { AuthProvider } from './auth.jsx'; // ... export default function App() { return ( <AuthProvider> - <SessionProvider> - <StoreProvider> <Routes> // ... </Routes> - </StoreProvider> - </SessionProvider> </AuthProvider> ); }
接著刪掉這四個檔:
src/store.jsx
src/session.jsx
src/components/TourForm.jsx
src/components/MemberGate.jsx(報名那一節若已刪,這裡不用再刪)
未登入時打開 /admin/bookings,應停在閘門,不應出現訂單。登入後看得到剛才那一筆。把狀態改成已確認,重新整理後仍是已確認。主控台裡同一份文件的 status 一起變。
graph LR
form["登入表單"] --> authn["Authentication"]
authn --> gate["AdminGate"]
gate --> list["listOrders"]
list --> fs["orders"]
select["狀態下拉"] --> update["updateOrderStatus"]
update --> fs
解析
AdminBookingsBody 寫在 AdminGate 裡面。沒有通過閘門就不會呼叫 listOrders。
reload 會再進入讀取中。清單會閃一下,換來的是雲端上的新狀態。
行程頁的「寫入種子」只是連到 /seed。規則收緊之後,那個按鈕會失敗,清單仍可讀。
篩選與排序仍在瀏覽器做。Firestore 這一章只負責把訂單整份拿回來。
安全規則與靜態部署 規則是伺服器上的判斷,跟 React 的閘門各寫一次。閘門決定畫不畫。規則決定 SDK 的請求過不過。這一節的規則全文只放在 firestore.rules,主控台貼上同一份,不要在別處再寫一套。
步驟 1:貼上規則 tours 任何人可讀,任何人不可寫。種子必須在貼上之前寫完。orders 任何人可新增。讀取與更新要 request.auth != null。刪除不開放。
已登入就代表管理員,是因為網站沒有註冊。若以後加上註冊,這條規則會讓每一位註冊使用者讀到全部訂單,那時要改成自訂聲明或管理員清單,這一章不做。
07-project-final/firestore.rules rules_version = '2'; service cloud.firestore { match /databases/{database}/documents { match /tours/{tourId} { allow read: if true; allow write: if false; } match /orders/{orderId} { allow create: if true; allow read, update: if request.auth != null; allow delete: if false; } } }
Firestore 主控台打開規則分頁,用這份取代測試模式的內容,發布。發布後做三件事確認:
未登入時,行程列表仍看得到。
未登入時,報名仍送得出去。
未登入時,/admin/bookings 進不了清單。登出後,主控台以外的讀取會被拒絕。/seed 再按會失敗。
graph TD
guest["未登入"] --> readTours["可讀 tours"]
guest --> createOrder["可新增 orders"]
guest --> deny["不可讀、不可改 orders"]
signed["已登入"] --> readOrders["可讀、可改 orders"]
解析
allow write 涵蓋建立、更新與刪除。行程用 write: if false 一次擋住。
訂單把 create 與 read, update 拆開。訪客只能新增,不能回頭讀自己剛寫的那筆。完成頁才改走路由 state。
request.auth 是 Authentication 帶上來的身分。沒登入時是 null。
規則沒有檢查欄位形狀。表單負責少送空的姓名,規則負責誰可以碰到集合。
步驟 2:打包 打包指令仍是第 12 篇那一條:vite build 之後把 index.html 複製成 404.html。網站掛在網域根目錄時,路由維持預設。
Firebase 的程式會打成單獨一包,體積超過 Vite 預設的 500 kB 警告線。chunkSizeWarningLimit 只把警告門檻調到 600,不改變檔案內容。金鑰在建置當下寫進這包。要發布能連線的版本,就在填好 .env.local 的電腦上執行 pnpm build。
這一章不上 Firebase Hosting。靜態檔仍照第 12 篇放到 GitHub Pages。發布前確認 .env.local 沒有被 commit。
07-project-final/vite.config.js build: { + chunkSizeWarningLimit: 600, rollupOptions: { output: { manualChunks(id) { if (!id.includes('node_modules')) return undefined; if (id.includes('@mui') || id.includes('@emotion')) return 'mui'; + if (id.includes('firebase')) return 'firebase'; if (id.includes('react-dom') || id.includes('react-router') || id.includes('/react/')) return 'react'; return undefined; }, // ...
解析
manualChunks 只拆檔。畫面與規則都不會因為拆檔改變。
警告門檻設在 vite.config.js 這一處。Firebase 那包大約 570 kB。
dist/ 已在 .gitignore。要更新線上網站時,用建置結果覆蓋 Pages,不要把 node_modules 推進去。
空的環境變數也能建置成功。那種產物打開後,行程頁會顯示尚未設定。
本篇重點回顧
前台不設會員。訪客直接把報名寫進 orders。只有後台使用 email 與密碼。
Firebase 的網頁設定放在 .env.local,以 VITE_ 開頭。範例檔是 .env.example。金鑰不要 commit。
src/api.js 集中 Firestore 讀寫。useAsync 把等待、失敗與結果留在頁面。
/seed 在測試模式把 src/data/tours.js 寫進 tours。已存在的文件略過。
AdminGate 等 Authentication 回報之後才畫訂單。閘門擋的是畫面。
firestore.rules 讓行程公開可讀、不可寫,訂單公開可新增、僅登入者可讀可改。
打包沿用第 12 篇。連得上雲端的產物,要在有 .env.local 的電腦上建置。
專有名詞中英對照 從切版繼續
切版(layout)
開發伺服器(dev server)
種子資料(seed data)
記憶體狀態(in-memory state)
工作階段儲存(sessionStorage)
認識 Firebase
Firebase
雲端文件資料庫(Cloud Firestore)
集合(collection)
文件(document)
欄位(field)
身份驗證(Firebase Authentication)
安全規則(security rules)
測試模式(test mode)
環境變數(environment variable)
網頁應用程式(web app)
讀寫介面
資料層(data layer)
服務函式(service function)
承諾(Promise)
鉤子(Hook)
副作用(effect)
接到畫面
查詢快照(query snapshot)
文件識別碼(document id)
路由狀態(history state)
管理員、規則與部署
受保護畫面(protected view)
登入狀態監聽(onAuthStateChanged)
授權(authorization)
靜態部署(static hosting)
程式碼分割(code splitting)