S SmartDocs
系列: React.js javascript 317 行 · 更新於 2026-04-02

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;

相關文章