Project_MultiStepForm.jsx
React.js/lessons/07_conditional_rendering/projects/Project_MultiStepForm.jsx
/**
* Project: 多步驟訂閱表單 — 條件渲染練習
*
* 學習重點:
* - 根據 step 狀態渲染不同的內容
* - 三元運算子在 JSX 中的使用
* - && 短路求值
* - 動態樣式(根據條件改變 UI)
* - Guard Clause(提早 return)
*
* 使用方式:將此檔案內容複製到你的 App.jsx 中
*/
import { useState } from 'react';
const PLANS = [
{ id: 'starter', name: '入門方案', price: 0, features: ['5 個專案', '1GB 儲存空間', '社群支援'], icon: '🌱', color: '#22c55e' },
{ id: 'pro', name: '專業方案', price: 299, features: ['無限專案', '50GB 儲存空間', '優先支援', 'API 存取'], icon: '⚡', color: '#3b82f6', recommended: true },
{ id: 'enterprise', name: '企業方案', price: 899, features: ['無限一切', '專屬帳管', 'SLA 保證', '自訂整合', '進階分析'], icon: '🏢', color: '#8b5cf6' },
];
function MultiStepForm() {
const [step, setStep] = useState(1);
const [formData, setFormData] = useState({
name: '',
email: '',
company: '',
plan: '',
paymentMethod: '',
cardNumber: '',
agreeTerms: false,
});
const [isSubmitted, setIsSubmitted] = useState(false);
const updateField = (field, value) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
const totalSteps = 4;
const nextStep = () => setStep((s) => Math.min(s + 1, totalSteps));
const prevStep = () => setStep((s) => Math.max(s - 1, 1));
const canProceed = () => {
switch (step) {
case 1: return formData.name.trim() && formData.email.trim();
case 2: return !!formData.plan;
case 3: return formData.plan === 'starter' || (formData.paymentMethod && (formData.paymentMethod !== 'credit_card' || formData.cardNumber.length >= 10));
case 4: return formData.agreeTerms;
default: return false;
}
};
const handleSubmit = () => {
setIsSubmitted(true);
};
const selectedPlan = PLANS.find((p) => p.id === formData.plan);
// 提交成功畫面
if (isSubmitted) {
return (
<div style={{ maxWidth: '500px', margin: '80px auto', textAlign: 'center', fontFamily: "'Segoe UI', sans-serif", padding: '0 16px' }}>
<div style={{ fontSize: '4rem', marginBottom: '16px' }}>🎉</div>
<h1 style={{ color: '#0f172a' }}>訂閱成功!</h1>
<p style={{ color: '#64748b' }}>
{formData.name},您已成功訂閱 <strong>{selectedPlan?.name}</strong>。
</p>
<p style={{ color: '#64748b' }}>確認信已寄送至 {formData.email}</p>
<button
onClick={() => { setIsSubmitted(false); setStep(1); setFormData({ name: '', email: '', company: '', plan: '', paymentMethod: '', cardNumber: '', agreeTerms: false }); }}
style={{ marginTop: '24px', padding: '12px 32px', borderRadius: '10px', border: 'none', backgroundColor: '#4f46e5', color: 'white', cursor: 'pointer', fontWeight: 'bold', fontSize: '1rem' }}
>
重新開始
</button>
</div>
);
}
const inputStyle = {
width: '100%',
padding: '12px 16px',
border: '2px solid #e2e8f0',
borderRadius: '10px',
fontSize: '1rem',
outline: 'none',
boxSizing: 'border-box',
transition: 'border-color 0.2s',
};
return (
<div style={{ maxWidth: '600px', margin: '32px auto', fontFamily: "'Segoe UI', 'Noto Sans TC', sans-serif", padding: '0 16px' }}>
{/* 進度條 */}
<div style={{ display: 'flex', alignItems: 'center', marginBottom: '40px' }}>
{[1, 2, 3, 4].map((s) => (
<div key={s} style={{ display: 'flex', alignItems: 'center', flex: s < 4 ? 1 : 'none' }}>
<div style={{
width: '40px', height: '40px', borderRadius: '50%',
backgroundColor: step > s ? '#22c55e' : step === s ? '#4f46e5' : '#e2e8f0',
color: step >= s ? 'white' : '#94a3b8',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontWeight: 'bold', fontSize: '0.9rem', transition: 'all 0.3s', flexShrink: 0,
}}>
{step > s ? '✓' : s}
</div>
{s < 4 && (
<div style={{ flex: 1, height: '3px', backgroundColor: step > s ? '#22c55e' : '#e2e8f0', margin: '0 8px', transition: 'background-color 0.3s' }} />
)}
</div>
))}
</div>
{/* 步驟 1:個人資料 */}
{step === 1 && (
<div>
<h2 style={{ color: '#0f172a', marginBottom: '4px' }}>個人資料</h2>
<p style={{ color: '#94a3b8', marginTop: 0 }}>請填寫您的基本資訊</p>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontWeight: '600', color: '#374151', fontSize: '0.9rem' }}>
姓名 <span style={{ color: '#ef4444' }}>*</span>
</label>
<input style={inputStyle} value={formData.name} onChange={(e) => updateField('name', e.target.value)} placeholder="請輸入姓名" />
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontWeight: '600', color: '#374151', fontSize: '0.9rem' }}>
Email <span style={{ color: '#ef4444' }}>*</span>
</label>
<input style={inputStyle} type="email" value={formData.email} onChange={(e) => updateField('email', e.target.value)} placeholder="example@email.com" />
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontWeight: '600', color: '#374151', fontSize: '0.9rem' }}>公司(選填)</label>
<input style={inputStyle} value={formData.company} onChange={(e) => updateField('company', e.target.value)} placeholder="公司名稱" />
</div>
</div>
)}
{/* 步驟 2:選擇方案 */}
{step === 2 && (
<div>
<h2 style={{ color: '#0f172a', marginBottom: '4px' }}>選擇方案</h2>
<p style={{ color: '#94a3b8', marginTop: 0 }}>選擇最適合您的方案</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{PLANS.map((plan) => (
<div
key={plan.id}
onClick={() => updateField('plan', plan.id)}
style={{
padding: '20px',
borderRadius: '12px',
border: `2px solid ${formData.plan === plan.id ? plan.color : '#e2e8f0'}`,
backgroundColor: formData.plan === plan.id ? `${plan.color}08` : 'white',
cursor: 'pointer',
position: 'relative',
transition: 'all 0.2s',
}}
>
{plan.recommended && (
<span style={{
position: 'absolute', top: '-10px', right: '16px',
padding: '2px 12px', borderRadius: '10px',
backgroundColor: plan.color, color: 'white',
fontSize: '0.7rem', fontWeight: 'bold',
}}>
推薦
</span>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<span style={{ fontSize: '1.3rem', marginRight: '8px' }}>{plan.icon}</span>
<strong style={{ color: '#0f172a' }}>{plan.name}</strong>
</div>
<div style={{ textAlign: 'right' }}>
<span style={{ fontSize: '1.5rem', fontWeight: 'bold', color: plan.color }}>
{plan.price === 0 ? '免費' : `NT$ ${plan.price}`}
</span>
{plan.price > 0 && <span style={{ color: '#94a3b8', fontSize: '0.8rem' }}>/月</span>}
</div>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', marginTop: '12px' }}>
{plan.features.map((f) => (
<span key={f} style={{ padding: '2px 10px', borderRadius: '6px', backgroundColor: '#f1f5f9', color: '#475569', fontSize: '0.8rem' }}>
✓ {f}
</span>
))}
</div>
</div>
))}
</div>
</div>
)}
{/* 步驟 3:付款 */}
{step === 3 && (
<div>
<h2 style={{ color: '#0f172a', marginBottom: '4px' }}>付款資訊</h2>
{formData.plan === 'starter' ? (
<div style={{ textAlign: 'center', padding: '40px', backgroundColor: '#f0fdf4', borderRadius: '12px' }}>
<p style={{ fontSize: '2rem', margin: '0 0 8px' }}>🎁</p>
<p style={{ color: '#166534', fontWeight: '600' }}>免費方案無需付款!</p>
<p style={{ color: '#4ade80', fontSize: '0.9rem' }}>直接進入下一步完成設定</p>
</div>
) : (
<>
<p style={{ color: '#94a3b8', marginTop: 0 }}>選擇付款方式</p>
<div style={{ display: 'flex', gap: '8px', marginBottom: '20px' }}>
{[
{ id: 'credit_card', label: '💳 信用卡' },
{ id: 'bank', label: '🏦 銀行轉帳' },
{ id: 'line_pay', label: '📱 LINE Pay' },
].map((m) => (
<button
key={m.id}
onClick={() => updateField('paymentMethod', m.id)}
style={{
flex: 1, padding: '12px', borderRadius: '10px',
border: `2px solid ${formData.paymentMethod === m.id ? '#4f46e5' : '#e2e8f0'}`,
backgroundColor: formData.paymentMethod === m.id ? '#eef2ff' : 'white',
cursor: 'pointer', fontSize: '0.9rem',
}}
>
{m.label}
</button>
))}
</div>
{formData.paymentMethod === 'credit_card' && (
<div>
<label style={{ display: 'block', marginBottom: '6px', fontWeight: '600', color: '#374151', fontSize: '0.9rem' }}>卡號</label>
<input style={inputStyle} value={formData.cardNumber} onChange={(e) => updateField('cardNumber', e.target.value.replace(/\D/g, ''))} placeholder="1234 5678 9012 3456" maxLength={16} />
</div>
)}
{formData.paymentMethod === 'bank' && (
<div style={{ padding: '16px', backgroundColor: '#f8fafc', borderRadius: '10px' }}>
<p style={{ margin: '0 0 4px', fontWeight: '600' }}>匯款資訊</p>
<p style={{ margin: 0, color: '#64748b', fontSize: '0.9rem' }}>銀行:台灣銀行 (004)</p>
<p style={{ margin: 0, color: '#64748b', fontSize: '0.9rem' }}>帳號:123-456-789-000</p>
</div>
)}
{formData.paymentMethod === 'line_pay' && (
<div style={{ padding: '16px', backgroundColor: '#f0fdf4', borderRadius: '10px', textAlign: 'center' }}>
<p style={{ fontSize: '2rem', margin: '0 0 8px' }}>📱</p>
<p style={{ margin: 0, color: '#166534' }}>點擊確認後將導向 LINE Pay 付款頁面</p>
</div>
)}
</>
)}
</div>
)}
{/* 步驟 4:確認 */}
{step === 4 && (
<div>
<h2 style={{ color: '#0f172a', marginBottom: '4px' }}>確認訂單</h2>
<p style={{ color: '#94a3b8', marginTop: 0 }}>請確認以下資訊是否正確</p>
<div style={{ backgroundColor: '#f8fafc', padding: '20px', borderRadius: '12px', marginBottom: '16px' }}>
{[
{ label: '姓名', value: formData.name },
{ label: 'Email', value: formData.email },
formData.company && { label: '公司', value: formData.company },
{ label: '方案', value: selectedPlan ? `${selectedPlan.icon} ${selectedPlan.name}` : '' },
{ label: '費用', value: selectedPlan?.price === 0 ? '免費' : `NT$ ${selectedPlan?.price}/月` },
formData.paymentMethod && { label: '付款方式', value: formData.paymentMethod === 'credit_card' ? '信用卡' : formData.paymentMethod === 'bank' ? '銀行轉帳' : 'LINE Pay' },
]
.filter(Boolean)
.map((item) => (
<div key={item.label} style={{ display: 'flex', justifyContent: 'space-between', padding: '8px 0', borderBottom: '1px solid #e2e8f0' }}>
<span style={{ color: '#64748b' }}>{item.label}</span>
<span style={{ color: '#0f172a', fontWeight: '500' }}>{item.value}</span>
</div>
))}
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
<input
type="checkbox"
checked={formData.agreeTerms}
onChange={(e) => updateField('agreeTerms', e.target.checked)}
style={{ width: '18px', height: '18px', accentColor: '#4f46e5' }}
/>
<span style={{ color: '#475569', fontSize: '0.9rem' }}>
我同意<a href="#" style={{ color: '#4f46e5' }}>服務條款</a>和<a href="#" style={{ color: '#4f46e5' }}>隱私政策</a>
</span>
</label>
</div>
)}
{/* 按鈕列 */}
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: '32px' }}>
{step > 1 ? (
<button onClick={prevStep} style={{ padding: '12px 28px', borderRadius: '10px', border: '1px solid #e2e8f0', backgroundColor: 'white', color: '#64748b', cursor: 'pointer', fontSize: '0.95rem' }}>
← 上一步
</button>
) : <div />}
{step < totalSteps ? (
<button onClick={nextStep} disabled={!canProceed()} style={{
padding: '12px 28px', borderRadius: '10px', border: 'none',
backgroundColor: canProceed() ? '#4f46e5' : '#cbd5e1',
color: 'white', cursor: canProceed() ? 'pointer' : 'not-allowed',
fontWeight: 'bold', fontSize: '0.95rem',
}}>
下一步 →
</button>
) : (
<button onClick={handleSubmit} disabled={!canProceed()} style={{
padding: '12px 28px', borderRadius: '10px', border: 'none',
backgroundColor: canProceed() ? '#22c55e' : '#cbd5e1',
color: 'white', cursor: canProceed() ? 'pointer' : 'not-allowed',
fontWeight: 'bold', fontSize: '0.95rem',
}}>
✓ 確認訂閱
</button>
)}
</div>
</div>
);
}
export default MultiStepForm;
Related articles
App.js
App.js — javascript source code from the React.js learning materials (React.js/lessons/01_introduction/example/src/App.js).
Read article →App.test.js
App.test.js — javascript source code from the React.js learning materials (React.js/lessons/01_introduction/example/src/App.test.js).
Read article →index.js
index.js — javascript source code from the React.js learning materials (React.js/lessons/01_introduction/example/src/index.js).
Read article →reportWebVitals.js
reportWebVitals.js — javascript source code from the React.js learning materials (React.js/lessons/01_introduction/example/src/reportWebVitals.js).
Read article →setupTests.js
setupTests.js — javascript source code from the React.js learning materials (React.js/lessons/01_introduction/example/src/setupTests.js).
Read article →Project_HelloReact.jsx
Project_HelloReact.jsx — javascript source code from the React.js learning materials (React.js/lessons/01_introduction/projects/Project_HelloReact.jsx).
Read article →