😽

ONSEN GOODS 開発記録 No.4

に公開

frontのルーティング設定

今回のアプリは合計4ページからなるため、react-router-domを使って前回の続きからルーティングを設定していく。
まずは./frontendにconst.jsを作りその中でROUTESを定義する。

/frontend/const.js
export const ROUTES = {
  HOME: "/",
  ONSEN_DETAIL: '/onsen/:id',
  REVIEW: "/onsen/:id/review"
}

次にApp.jsxreturnの中にルートを設定していく。

App.jsx
<Routes>
        <Route path={ROUTES.HOME} element={<Home />} />
        <Route path={ROUTES.ONSEN_DETAIL} element={<OnsenDetail />} />
        <Route path={ROUTES.REVIEW} element={<Review />} />
        <Route path="*" element={<NotFoundPage />} />
      </Routes>

どこからでも温泉一覧ページに戻れるようにLinkタグを配置。(後々改良する。)

App.jsx
<nav>
        <ul>
          <li><Link to={ROUTES.HOME}>ONSEN GOODS</Link></li>
        </ul>
      </nav>

これでルーティング設定は完了。

温泉一覧ページ

まずはfrontend/pages/Home.jsxに必要な定数を定義。

Home.jsx
function Home() {
  const [onsenList, setOnsenList] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

次にページがマウントされたときの処理。

Home.jsx
useEffect(() => {
    // コンポーネントがマウントされたとき、APIからデータ取得
    const fetchOnsenList = async () => {
      try {
        const response = await fetch('http://localhost:3000/api/onsen');
        if (!response.ok) {
          throw new Error (`HTTP error! status: ${response.status}`);
        } 
        const data = await response.json();
        setOnsenList(data); // 取得した値をstateにセット
      } catch (e) {
        setError(e); // エラーstateにセット
        console.error("温泉リストの取得中にエラーが発生しました。:", e);
      } finally {
        setLoading(false); //ロード完了
      }
    };
    fetchOnsenList();
  }, []); //からの依存配列は、コンポーネントがマウントされたときに一度だけ実行されることを意味する。

エラーの場合の処理も記載しつつreturnの中も書いていく

Home.jsx
<div>
      <h1>温泉一覧ページ</h1>
      {onsenList.length === 0 ? (
        <p>登録されている温泉はまだありません。</p>
      ):(
        <ul>
          {onsenList.map((onsen) => (
            <li key={onsen.id}>
              <Link to={ROUTES.ONSEN_DETAIL.replace(':id', onsen.id)}>
                <h2>{onsen.name}</h2>
                <p>{onsen.location}</p>
                <p>{onsen.rating}</p>
                <img src={onsen.image_url} alt={onsen.name} />
              </Link>
            </li>
          ))}
        </ul>
      )}
    </div>

最終的にはこんな感じ。

Home.jsx
import React, { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { ROUTES } from '../const';

function Home() {
  const [onsenList, setOnsenList] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    // コンポーネントがマウントされたとき、APIからデータ取得
    const fetchOnsenList = async () => {
      try {
        const response = await fetch('http://localhost:3000/api/onsen');
        if (!response.ok) {
          throw new Error (`HTTP error! status: ${response.status}`);
        } 
        const data = await response.json();
        setOnsenList(data); // 取得した値をstateにセット
      } catch (e) {
        setError(e); // エラーstateにセット
        console.error("温泉リストの取得中にエラーが発生しました。:", e);
      } finally {
        setLoading(false); //ロード完了
      }
    };
    fetchOnsenList();
  }, []); //からの依存配列は、コンポーネントがマウントされたときに一度だけ実行されることを意味する。

  if (loading) {
    return <div>読み込み中...</div>
  }

  if (error) {
    return <div>エラー: 温泉情報を取得できませんでした。</div>
  }

  return (
    <div>
      <h1>温泉一覧ページ</h1>
      {onsenList.length === 0 ? (
        <p>登録されている温泉はまだありません。</p>
      ):(
        <ul>
          {onsenList.map((onsen) => (
            <li key={onsen.id}>
              <Link to={ROUTES.ONSEN_DETAIL.replace(':id', onsen.id)}>
                <h2>{onsen.name}</h2>
                <p>{onsen.location}</p>
                <p>{onsen.rating}</p>
                <img src={onsen.image_url} alt={onsen.name} />
              </Link>
            </li>
          ))}
        </ul>
      )}
    </div>
  )
}

export default Home

ONSEN_DETAILという温泉詳細ページへのリンクが大事で、ここでreplaceを使ってONSEN_DETAILのリンクを/onsen/:id(各温泉の持つid)に修正することが大事。

温泉詳細ページ

基本的には温泉一覧ページと同じように書いていく。ただ、URLからuseParams()onsen.idをとってこないといけないので、そこに注目。

OnsenDetail.jsx
import React, { useEffect, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { ROUTES } from '../const'

function OnsenDetail() {
  const { id } = useParams();
  const [onsen, setOnsen] = useState(null); // 特定の温泉データを保持する
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    const fetchOnsenDetail = async () => {
      try {
        const response = await fetch(`http://localhost:3000/api/onsen/${id}`)
        if (!response.ok) {
          const errorText = await response.text(); // エラーレスポンスのテキストも取得
          throw new Error(`HTTP error! Status: ${response.status}, Message: ${errorText}`);
        }

        const data = await response.json();
        setOnsen(data);
      } catch (e) {
        setError(e); // エラーが発生した場合、エラーstateにセット
        console.error(`温泉ID ${id} の詳細取得中にエラーが発生しました:`, e);
      } finally {
        setLoading(false); // ロード完了
      }
    };
    fetchOnsenDetail();
  },[id]);

  if (loading) {
    return <div>読み込み中...</div>
  }

  if (error) {
    console.error("バックエンドサーバーが起動しているか、指定されたIDの温泉が存在するか確認してください。")
    return (
      <div>エラー: 温泉詳細情報を取得できませんでした。</div>
    )
  }

  if (!onsen) {
    return (
      <h2>指定された温泉が見つかりませんでした。</h2>
    );
  }

  return (
    <div>
      <h1>{onsen.name}</h1>
      <img src={onsen.image_url} alt={onsen.name} />
      <p>{onsen.rating}</p>
      <p>{onsen.description}</p>
      <p>{onsen.facilities}</p>
      <p>{onsen.updated_at}</p>
      <Link to={ROUTES.REVIEW.replace(':id',id)} >この温泉を評価する</Link><br />
      <Link to="/">←温泉一覧に戻る</Link>
    </div>
  )
}

export default OnsenDetail

また、ここもHome.jsxと同様に温泉評価ページReview.jsxへのパスを修正することを忘れずに

温泉評価ページ

/frontend/pages/Review.jsxに温泉を評価するための処理を書いていく。<form>の処理がメイン

/frontend/pages/Review.jsx
import React, { useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { ROUTES } from '../const'

function Review() {
  const { id } = useParams();
  const navigate = useNavigate();

  const [onsenName, setOnsenName] = useState('');
  const [rating, setRating] = useState(5.0);
  const [comment, setComment] = useState('');
  const [loadingOnsen, setLoadingOnsen] = useState(true); //温泉名取得のロード状態
  const [loadingSubmit, setLoadingSubmit] = useState(false); // 評価送信時のロード
  const [errorOnsen, setErrorOnsen] = useState(null); // 温泉名取得エラー
  const [errorSubmit, setErrorSubmit] = useState(null); // 評価送信エラー
  const [submitSuccess, setSubmitSuccess] = useState(false); //評価送信成功フラグ

  // 評価対象の温泉名を取得
  useEffect(() => {
    const fetchOnsenName = async () => {
      try {
        const response = await fetch(`http://localhost:3000/api/onsen/${id}`);
        if (!response.ok) {
          const errorText = await response.text();
          throw new Error(`HTTP error! Status: ${response.status}, Message: ${errorText}`);
        }
        const data = await response.json();
        setOnsenName(data.name);
      } catch (e) {
        setErrorOnsen(e);
        console.error(`温泉ID ${id} の名前取得中にエラーが発生しました:`, e);
      } finally {
        setLoadingOnsen(false);
      }
    };
    if (id) {
      fetchOnsenName();
    }
  }, [id]);

  // フォーム送信ハンドラ
  const handleSubmit = async (e) => {
    e.preventDefault(); // フォームのデフォルト送信を防ぐ
    setLoadingSubmit(true);
    setErrorSubmit(null);
    setSubmitSuccess(false);

    try {
      const response = await fetch(`http://localhost:3000/api/onsen/${id}/rating`, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ rating, comment }), // 評価とコメントをJSON形式で送信
      });

      if (!response.ok) {
        const errorData = await response.json(); // エラーレスポンスもJSONで取得
        throw new Error(errorData.error || `HTTP error! Status: ${response.status}`);
      }

      // 成功したら温泉詳細ページへ
      setSubmitSuccess(true);
      setTimeout(() => {  // setTimeoutはjsの標準ライブラリ!
        navigate(ROUTES.ONSEN_DETAIL.replace(':id', id));
      }, 2000);

    } catch (e) {
      setErrorSubmit(e);
      console.error("評価の投稿中にエラーが発生しました:", e);
    } finally {
      setLoadingSubmit(false);
    }
  };

  if (loadingOnsen) {
    return (
      <div>温泉名を取得中</div>
    )
  }
  if (errorOnsen) {
    return (
      <div>
        <h2>エラー: 温泉名を取得できませんでした。</h2>
        <Link to={ROUTES.HOME}></Link>
      </div>
    )
  }
  return (
    <div>
      <h1>{onsenName}への評価</h1>

      {submitSuccess && (
        <p>投稿完了!!</p>
      )}
      {errorSubmit && (
        <p>投稿に失敗しました。</p>
      )}

      <form onSubmit={handleSubmit}>
        <div>
          <label htmlFor='rating'>評価 (1.0 - 5.0)</label>
          <input
            type="number"
            id="rating"
            min="1.0"
            max="5.0"
            step="0.1"
            value={rating}
            onChange={(e) => setRating(parseFloat(e.target.value))}
            required
          />
        </div>

        <div>
          <label htmlFor='comment'>コメント</label>
          <textarea
            placeholder='コメントを入力してください'
            id="comment"
            value={comment}
            onChange={(e) => setComment(e.target.value)}
            rows="5"
          ></textarea>
        </div>
        <button type='submit' disabled={loadingSubmit}>
          {loadingSubmit ? '送信中...' : '送信'}
        </button>
      </form>

      <div>
        <Link to={ROUTES.ONSEN_DETAIL.replace(':id', id)}>←温泉詳細ページに戻る</Link>
        <Link to={ROUTES.HOME}>←温泉一覧に戻る</Link>
      </div>
    </div>
  )
}

export default Review

はじめはreview.jsxというコンポーネントにしていたがえらーになることもあるらしいのでコンポーネント名は大文字から始めるべき
ちなみここの動作確認でOnsenDetail.jsxのリンクにreplaceをつけ忘れていた等の原因で結構エラー吐かれた。

ここまでかけたら大方は完成。

動作確認

ターミナルで

Bash
<.\Desktop\ONSEN_GOODS\backend>node server.js
Bash
<.\Desktop\ONSEN_GOODS\frontend>npm run dev

を実行して動作確認

温泉一覧ページ

温泉詳細ページ

温泉評価ページ

これでよし

今後の流れ

次回からは各ページにスタイルをつけていく。今のところ「Tailwind CSS」を使っていこうと考えている。触ったことがないのでダメそうだったら慣れている「CSS Modules」でやっていく。

to be continued ...

Discussion