11
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

お題は不問!Qiita Engineer Festa 2023で記事投稿!

【Next.js+TypeScript】+【PHP+MySQL+Docker】でオリジナルアプリ作ってみた

Last updated at Posted at 2023-06-16

はじめに

はじめまして、mamiと申します。
今回、PHPの勉強をすることになったので、その集大成として以前未経験の時に作成したポートフォリオをPHPを取り入れて作り直すことをしました。
バックエンドの勉強も兼ねているので、機能も増えてかなりブラッシュアップできたかなと思っています。
なので今回は備忘録も兼ねて、作り方とかコード解説などをしていこうかと思います。
今まさに未経験で、ポートフォリオの作成に励んでいる初学者の方のご参考になれれば幸いです。

量が量だったのでフロントとバックで記事を分けようかと思ったのですが、面倒だったので 諸事情で1つの記事にまとめさせていただきました。
大ボリュームとなりますが、細かく目次を立ててるので気になるところだけでも見ていってください。

参考程度にですが、未経験の時に作成した分の記事も下記に貼っておきます。

作品紹介

WineChecker
(GitHub👇)

デプロイまではしてませんが、実際に動かしてみたい方はGitHubのREADMEに環境構築手順を書いているので、是非手元で動かしてみてください!

画面

トップページ

image

診断ページ

image

診断結果ページ

image

おすすめ一覧ページ

image

おすすめ詳細ページ

image

ワイン投稿ページ

image

ログインページ

image

会員登録ページ

image

マイページ

image

機能

  • 自分に合ったおすすめワインの診断
  • DBを活用したおすすめ一覧
  • お気に入りワインの投稿
  • ユーザー登録
  • ログイン
  • ユーザー情報の閲覧、編集

フロントエンド

それでは早速、フロントエンドのコードから解説していきます!
れっつごー!

ディレクトリ構成図

wine-next
├- layout-DefaultLayout.tsx
├--- pages -┐  
│           ├- index.tsx
│           ├- _app.tsx
│           ├- _document.tsx
│           ├- components ┐
│           │             ├ Button --------┐
│           │             ├ ActionTab.tsx  ├ BasicButton.tsx
│           │             ├ AppTab.tsx     └ outlineButton.tsx
│           │             └ CustomTextField.tsx
│           ├- login ┐
│           │        ├ index.tsx
│           │        ├ Signup.tsx
│           │        └ userInfo.tsx
│           ├- newPost ┐
│           │          ├ index.tsx
│           │          └ PostComplete.tsx
│           └- shindan ┐
│                      ├ [id].tsx
│                      ├ aka.tsx
│                      ├ index.tsx
│                      ├ recommend.tsx
│                      ├ resultAka.tsx
│                      ├ resultShiro.tsx
│                      └ shiro.tsx
├- src -┐
│       ├- hooks -- useQuestionData.ts
│       ├- layout-DefaultLayout.tsx
│       └- redux -┐
│                 ├ store.ts
│                 └ reducer --- question.ts
├--- pakage.json
├--- theme.tsx
└--- tsconfig.json

TOPページの作成(index.tsx)

pages配下に大元のTopページを作っていきます。

pages/index.tsx
import type { NextPage } from "next";
import { DefaultLayout } from "../src/layout/DefaultLayout";
import { Grid, Typography } from "@mui/material";

import { useAppDispatch } from "../src/redux/hook";
import { resetAnswers } from "../src/redux/reducer/question";
import React from "react";
import useMediaQuery from "@mui/material/useMediaQuery";
import Image from "next/image";
import { OutlineButton } from "./components/Button/outlineButton";

const HomePageText = ({ children }) => (
  <Typography variant="h6">{children}</Typography>
);

const Home: NextPage = () => {
  const dispatch = useAppDispatch();

  // TOPページに来ると診断結果をリセットする
  React.useEffect(() => {
    dispatch(resetAnswers());
  }, [dispatch]);

  return (
    <DefaultLayout>
      <>
        <Grid>
          <Image
            src="/images/top.png"
            alt="Top"
            layout="intrinsic"
            height="400px"
            width="400px"
          />
          <Grid>
            <HomePageText>
              ワインって種類が多すぎて何が自分に合うワインなのかわからない…
            </HomePageText>
            <HomePageText>そう思った経験はありませんか?</HomePageText>
            <HomePageText>
              このサイトは10個の質問に答えるだけで、あなたにぴったりのワインを診断いたします!
            </HomePageText>
            <HomePageText>
              あなたにぴったりのワインを選んで、ワインライフをもっと楽しんでみませんか?
            </HomePageText>
          </Grid>
          <Grid sx={{ paddingTop: "30px" }}>
            <Typography variant="h4" color="#CD1919">
              \ 好みのワインを簡単に診断! /
            </Typography>
          </Grid>
          <Grid sx={{ paddingTop: "30px" }}>
            <Typography variant="h2" color="#CD1919" fontWeight="bold">
              ワイン診断
            </Typography>
          </Grid>
          <Grid sx={{ paddingTop: "30px" }}>
            <OutlineButton href={"/shindan/aka"} color="#CD1919">
              赤ワインで診断
            </OutlineButton>
            <OutlineButton
              href={"/shindan/shiro"}
              color="#10B981"
              style={{ marginLeft: "20px" }}
            >
              白ワインで診断
            </OutlineButton>
          </Grid>
        </Grid>
      </>
    </DefaultLayout>
  );
};

export default Home;

基本的にこのページはデザインの表示のみなので、あまり解説するような処理はしていません。

強いて言うなら下記部分でしょうか。
ここはuseEffectを使用してdispatch関数を依存配列として渡しており、コンポーネントがマウントされた時(初回レンダリング時)に1回だけ実行されます。

const dispatch = useAppDispatch();

  // TOPページに来ると診断結果をリセットする
  React.useEffect(() => {
    dispatch(resetAnswers());
  }, [dispatch]);

これは、簡単に言うとTOPページに来ると診断結果をリセットする処理をしているのですが、実際の処理内容も見てみましょう。
まずuseAppDispatchの中身はこんな感じです。
これは、TypeScriptの型を持つdispatch関数を返すカスタムフックを作成しています。

export const useAppDispatch: () => AppDispatch = useDispatch

そしてメイン処理のresetAnswersですが、現在の状態に対して何も操作を行わず、直接initialState(初期状態)を返しています。
これでこのアクションがディスパッチされると、診断に関する状態は初期状態にリセットされます。

//初期値の代入
const initialState: QuestionState = {
	totalPoint: 0,
	questionNum: 0,
	answers: [],
	rank: 0,
};

export const questionSlice = createSlice({ 
// 省略
resetAnswers: (state) => {  
		return initialState; //直接initialState(初期状態)を返す処理
	}

他の部分に関しては、基本的にデザイン部分になります。
Muiを使用してスタイリングしております。できるだけシンプルで使いやすいデザインで作成しています。

Figmaで最初にデザイン起こしてから、それ通りにコーディングしていくといったイメージですね。

Reduxの設定

状態管理はRedux Toolkitを使用して管理しています。
診断ページを作成する前に状態管理をしてくれるReduxを記述していきましょう。

storeの作成

下記はconfigureStore関数を使ってストアを作成しています。
下の行はストア全体の型とdispatch関数の型をここで定義しています。

src/redux/store.ts
import { configureStore } from "@reduxjs/toolkit";
import question from "./reducer/question";

// configureStore関数を使ってストアを作成
export const store = configureStore({
  reducer: {
    question,
  },
});

// ストア全体の型
export type RootState = ReturnType<typeof store.getState>;
// dispatch関数の型
export type AppDispatch = typeof store.dispatch;

hookの作成

あとで処理内容を定義するカスタムフックを型付しながらここで定義しておきます。

src/redux/hook.ts
import type {TypedUseSelectorHook} from "react-redux";
import type {AppDispatch, RootState} from "./store";
import {useDispatch, useSelector} from "react-redux";

// 型を付与したカスタムフックを定義。
export const useAppDispatch: () => AppDispatch = useDispatch
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector

reducerの作成

ここでは主に、質問に答えたときのロジックと、そのリセット処理を定義しています。

src/redux/reducer/question.ts
import {createSlice, PayloadAction} from "@reduxjs/toolkit";

//型定義
interface QuestionState {
	totalPoint: number;
	questionNum: number;
	answers: number[];
	rank: number;
}

//初期値の代入
const initialState: QuestionState = {
	totalPoint: 0,
	questionNum: 0,
	answers: [],
	rank: 0,
};

export const questionSlice = createSlice({ 
	// createSlice関数を呼び出して、名前、初期状態、およびreducersを定義
	name: "question",
	initialState,
	reducers: {
	// 質問に答えたときのロジックを定義
	answerQuestion: (state, action: PayloadAction<{ 
		value: number;
	}>) => {
		// 配列に回答を追加
		state.answers = [...state.answers, action.payload.value]; // valueをanswers配列に追加
		// questionNumとtotalPointを更新
		state.questionNum += 1;
		state.totalPoint += action.payload.value;

		// totalPointに基づいてrankを設定
		state.rank = state.totalPoint < 5  //totalPointが5以上の時rank0の処理へ
			? 0
			: state.totalPoint < 7  //rankが1の処理へ
				? 1
				: state.totalPoint < 9
					? 2
						: 3;
	},

	// resetの処理を定義
    resetAnswers: (state) => { 
		return initialState; //直接initialState(初期状態)を返す処理
	},
	}
})

export const {answerQuestion, resetAnswers} = questionSlice.actions;
export default questionSlice.reducer;


リセット処理についてはトップページの時に説明した通りですが、質問に答えたときのロジックとしては

まずは最初の行でvalueをanswers配列に追加しています。
2、3行目でquestionNumとtotalPointを更新しています。これによって質問に答えていくと、それに応じたトータルポイントの増加と質問内容が切り替わると言う訳です。

// 配列に回答を追加
state.answers = [...state.answers, action.payload.value]; // valueをanswers配列に追加
// questionNumとtotalPointを更新
state.questionNum += 1;
state.totalPoint += action.payload.value;

そしてそのトータルポイントに応じて、ランクを設定しています。7以上の時はランク1、9以上の時はランク2といった具合ですね。

// totalPointに基づいてrankを設定
	state.rank = state.totalPoint < 5  //totalPointが5以上の時rank0の処理へ
		? 0
		: state.totalPoint < 7  //rankが1の処理へ
			? 1
			: state.totalPoint < 9
			? 2
				: 3;

ワインの診断ページ作成(shindan/aka.tsx)

続いて、実際の質問画面についてです。

pages/shindan/aka.tsx
import React from "react";
import { NextPage } from "next";
import { DefaultLayout } from "../../src/layout/DefaultLayout";
import { CircularProgress, Divider, Grid, Typography } from "@mui/material";
import { useAppDispatch } from "../../src/redux/hook";
import { answerQuestion } from "../../src/redux/reducer/question";
import Image from "next/image";
import { useQuestionData } from "../../src/hooks/useQuestionData";
import { OutlineButton } from "../components/Button/outlineButton";

const styles = {
  wrapper: {
    position: "absolute" as "absolute",
    top: "50%",
    left: "50%",
    transform: "translate(-50%,-50%)",
  },
};

export const Aka: NextPage = () => {
  const dispatch = useAppDispatch();
  const { questionNum, currentQuestion, finished } = useQuestionData();

  const handleButtonClick = (value: number) => {
    dispatch(answerQuestion({ value }));
  };

  return (
    <DefaultLayout>
      <div style={styles.wrapper}>
        {finished ? (
          <>
            <Typography variant={"h5"} style={{ fontSize: "3rem" }}>
              Done!
            </Typography>
            <CircularProgress sx={{ mt: 3 }} />
          </>
        ) : (
          <>
            <Typography variant={"h4"}>
              Q{questionNum + 1}
              {"."}
              {currentQuestion?.q}
            </Typography>
            <Divider sx={{ mt: 2, mb: 4 }} />
            <Grid>
              <Image
                src={`/images/${String(questionNum + 1).padStart(2, "0")}.png`}
                alt="Top"
                layout="intrinsic"
                height="400px"
                width="400px"
              />
            </Grid>
            <Grid
              container
              sx={{ mt: 5 }}
              spacing={2}
              style={{ marginLeft: "auto", marginRight: "auto" }}
            >
              <Grid item xs={6}>
                <OutlineButton
                  fullWidth={true}
                  href={"#"}
                  onClick={() => handleButtonClick(1)}
                  color="#CD1919"
                  style={{ fontSize: "1.5rem" }}
                >
                  {currentQuestion?.a1}
                </OutlineButton>
              </Grid>
              <Grid item xs={6}>
                <OutlineButton
                  fullWidth={true}
                  href={"#"}
                  onClick={() => handleButtonClick(2)}
                  color="#10B981"
                  style={{ fontSize: "1.5rem" }}
                >
                  {currentQuestion?.a2}
                </OutlineButton>
              </Grid>
            </Grid>
          </>
        )}
      </div>
    </DefaultLayout>
  );
};

export default Aka;

ロジック的な部分としては先ほどのreducerを活用していきます。

まずは下記でdispachを定義。
その上でuseQuestionDataフックを使用して、現在の質問、質問番号、および質問が終了したかどうかを取得しています。

const dispatch = useAppDispatch();
const { questionNum, currentQuestion, finished } = useQuestionData();

useQuestionDataフックの詳細な中身については下記の通りです。

src/hooks/useQuestionData.ts
import { useRouter } from "next/router";
import React from "react";
import { questionsDef } from "../../definitions/consts";
import { useAppSelector } from "../redux/hook";

// 質問に関する情報を管理し、全ての質問が終わったら結果のページに移動する
export const useQuestionData = () => {
  const router = useRouter();
  // 現在の質問番号(questionNum)を取得
  const questionNum = useAppSelector(
    (state: { question: { questionNum: number } }) => state.question.questionNum
  );

  // questionsDefという配列から現在の問題のデータを取得
  const currentQuestion = questionsDef?.[questionNum];
  // 全ての質問を回答したかどうか
  const finished = questionNum >= questionsDef.length;

  //タイムアウト時の処理と診断が終了した時の処理
  React.useEffect(() => {
    if (finished) {
      setTimeout(() => {
        const path = router.pathname;
        if(path.includes('aka')) {
            router.push("/shindan/resultAka");
        } else if(path.includes('shiro')) {
            router.push("/shindan/resultShiro");
        }
      }, 2000);
    }
}, [finished, router]);


  return { questionNum, currentQuestion, finished };
};

概ねコメントの通りですが、強いて言うなら下記部分でしょうか。
まず1行目の文で全ての質問を回答したかどうかを見ています。終わった場合はfinishedが true になります。

そしてuseEffect内の処理ですが、パスが赤ワインのものだった場合には、赤ワインの結果ページへ。白だった場合には白ワインの結果ページへ飛ばすような処理がされています。

// 全ての質問を回答したかどうか
  const finished = questionNum >= questionsDef.length;

  //タイムアウト時の処理と診断が終了した時の処理
  React.useEffect(() => {
    if (finished) {
      setTimeout(() => {
        const path = router.pathname;
        if(path.includes('aka')) {
            router.push("/shindan/resultAka");
        } else if(path.includes('shiro')) {
            router.push("/shindan/resultShiro");
        }
      }, 2000);
    }
}, [finished, router]);

診断ページのソースに戻りましょう。
下記はhandleButtonClick、つまり質問に対する回答ボタンを押したときの処理ですね。

reducer/question.tsで作成したanswerQuestionの value をここで加算させています。

const handleButtonClick = (value: number) => {
    dispatch(answerQuestion({ value }));
  };

そして下記のように回答ボタンに応じて増加する値が変わることで、診断結果も変化するという訳です。

// 回答ボタンA
onClick={() => handleButtonClick(1)}

// 回答ボタンB
onClick={() => handleButtonClick(2)}

あとは三項演算子を用いて、先ほど定義したfinishedが true の場合は「Done」という表示とともに、リザルトページへ遷移。

逆に false の場合は質問内容を表示という書き方をしています。

<div style={styles.wrapper}>
        {finished ? (
          <>
            <Typography variant={"h5"} style={{ fontSize: "3rem" }}>
              Done!
            </Typography>
            <CircularProgress sx={{ mt: 3 }} />
          </>
        ) : (
          <>
            // 質問内容の表示
          </>
        )}
</div>

診断結果ページ作成(shindan/resultAka.tsx)

続いて診断結果を表示するページを見ていきましょう。

pages/shindan/resultAka.tsx
import React from "react";
import { NextPage } from "next";
import { DefaultLayout } from "../../src/layout/DefaultLayout";
import { Button, Grid, Paper, Typography } from "@mui/material";
import { useAppSelector } from "../../src/redux/hook";
import Link from "next/link";
import useMediaQuery from "@mui/material/useMediaQuery";
import axios from "axios";
import { resultMessageDef } from "../../definitions/consts";
import { GiGrapes } from "react-icons/gi";

export const ResultAka: NextPage = () => {
  const rank = useAppSelector((state) => state.question.rank);

  const [wineList, setWineList] = React.useState(null);
  const [loading, setLoading] = React.useState(true);

  React.useEffect(() => {
    axios
      .get("http://localhost:8080/shindan/resultAka.php", {})
      .then((res) => {
        const { result, data } = res.data;
        if (result === "SUCCESS") {
          setWineList(data);
        } else {
          // サーバーからエラーレスポンスが返されたときの処理
          console.error("Server returned an error response:", res);
        }
      })
      .catch((err) => {
        // ネットワークエラーやリクエストが中断されたときの処理
        console.error("An error occurred while fetching data:", err);
      })
      .finally(() => {
        setLoading(false);
      });
  }, []);

  if (loading) {
    return <>Loading...</>;
  }

  const resultMessage = wineList?.[rank];
  const resultImage = wineList?.[rank];
  const Message = resultMessageDef?.[rank]; //rankに応じた結果の文言

  return (
    <DefaultLayout>
      <Paper style={{ padding: 5, paddingTop: "50px" }}>
        <Grid style={{ width: "80%", margin: "0 auto", maxWidth: "1200px" }}>
          <div style={{ borderBottom: "2px solid red", paddingBottom: "15px" }}>
            <Typography variant={"h4"} style={{ fontWeight: "bold" }}>
              診断結果
            </Typography>
            <Grid style={{ display: "flex" }}>
              <GiGrapes size={160} />
              <Typography
                variant={"h4"}
                style={{ paddingTop: "20px", color: "red" }}
              >
                {resultMessage?.one_word}
              </Typography>
            </Grid>
          </div>
          <Grid
            style={{
              display: "flex",
              justifyContent: "center",
              paddingTop: "30px",
            }}
          >
            <Grid style={{ padding: "40px 80px" }}>
              {/* eslint-disable-next-line @next/next/no-img-element */}
              <img
                src={`${resultImage?.wine_image}`}
                height="500px"
                width="auto"
                alt="result image"
              />
            </Grid>
            <Grid item container direction="column" justifyContent="center">
              <Typography variant={"h4"} style={{ fontWeight: "bold" }}>
                {resultMessage?.wine_name}
              </Typography>
              <Typography
                variant={"h5"}
                style={{ paddingTop: "20px", color: "red" }}
              >
                {Message}
              </Typography>
              <Typography variant={"h6"} style={{ paddingTop: "40px" }}>
                あなたにおすすめのワインは、
                {resultMessage?.comment}
              </Typography>
            </Grid>
          </Grid>
          <Grid
            container
            spacing={10}
            style={{
              display: "flex",
              justifyContent: "center",
              paddingBottom: "30px",
            }}
          >
            <Grid item>
              <Link href={"/"}>
                <Button
                  style={{
                    fontSize: "1.3rem",
                    color: "#fff",
                    backgroundColor: "#DDA0DD",
                    borderRadius: "5px",
                    padding: "10px 35px",
                  }}
                  sx={{ ":hover": { opacity: 0.8 } }}
                >
                  もう一度
                </Button>
              </Link>
            </Grid>
            <Grid item>
              <Link href={`/shindan/${rank}`}>
                <Button
                  style={{
                    fontSize: "1.3rem",
                    color: "#fff",
                    backgroundColor: "#9370DB",
                    borderRadius: "5px",
                    padding: "10px 30px",
                  }}
                  sx={{ ":hover": { opacity: 0.8 } }}
                >
                  ワインの詳細を見る
                </Button>
              </Link>
            </Grid>
          </Grid>
        </Grid>
      </Paper>
    </DefaultLayout>
  );
};
export default ResultAka;

まずは下記行でuseAppSelectorを用いて、質問の結果(rank)を取得します。
ここのロジックはreducerで説明した通りです。

const rank = useAppSelector((state) => state.question.rank);

そして、useStateを用いて、ワインのリスト(wineList)とデータ読み込み中かどうかのフラグ(loading)の状態を管理します。

まずは、axios.getを用いて、バックエンドからデータを非同期に取得します。
エンドポイント( http://localhost:8080/shindan/resultAka.php )からワインのリザルトリストを取得しています。
このエンドポイントにはJSON形式でデータが渡されています。

その次に.thenを用いて、データ取得が成功した場合の処理を記述します。
ここでは、取得したデータ(res.data)から結果とデータを取り出し、結果が"SUCCESS"だった場合には取得したデータをwineListに設定します。

続いて.catchを用いて、データ取得が失敗した場合の処理(エラーメッセージをコンソールに出力)を記述します。

最後に、.finallyを用いて、データ取得が成功した場合でも失敗した場合でも共通で行う処理を記述します。 loading フラグを false に設定してデータの読み込みが完了したことを行っていますね。

  const [wineList, setWineList] = React.useState(null);
  const [loading, setLoading] = React.useState(true);

  React.useEffect(() => {
    axios
      .get("http://localhost:8080/shindan/resultAka.php", {})
      .then((res) => {
        const { result, data } = res.data;
        if (result === "SUCCESS") {
          setWineList(data);
        } else {
          // サーバーからエラーレスポンスが返されたときの処理
          console.error("Server returned an error response:", res);
        }
      })
      .catch((err) => {
        // ネットワークエラーやリクエストが中断されたときの処理
        console.error("An error occurred while fetching data:", err);
      })
      .finally(() => {
        setLoading(false);
      });
  }, []);

  if (loading) {
    return <>Loading...</>;
  }

そして下記でランクに応じた結果文言や、ワインの写真、コメントなどを定義しています。

const resultMessage = wineList?.[rank];
const resultImage = wineList?.[rank];
const Message = resultMessageDef?.[rank];

あとは return 内で下記のように表示させてあげるだけです。

<Typography variant={"h6"} style={{ paddingTop: "40px" }}>
    あなたにおすすめのワインは、
    {resultMessage?.comment}
</Typography>

以上がメイン機能となるワイン診断の説明となります。
そのほかDBを活用したおすすめ一覧表示や、ログイン、ユーザー登録、ワインの投稿などの機能もあるのでサクッと解説していきます。

おすすめ一覧ページ(shindan/recommend.tsx)

といいつつもおすすめについては特別な処理はしていないので、端折ります。
気になる方は是非コードを直接ご覧ください。

おすすめ詳細ページ(shindan/[id].tsx)

詳細ページについても同様です。

おすすめワイン投稿ページ(newPost/index.tsx)

さて、おすすめの説明が無理矢理終わらせた終わったので、おすすめワインの投稿ページの説明に入っていきます。
コードとしては下記の通りになります。

newPost/index.tsx
import React, { useCallback, useState } from "react";
import axios from "axios";
import { DefaultLayout } from "../../src/layout/DefaultLayout";
import { Grid, Typography, Box, Button, Container } from "@mui/material";
import CustomTextField from "../components/CustomTextField";
import { useRouter } from "next/router";

const initialWineInfo = {
  wine_name: "",
  english_wine_name: "",
  winery: "",
  wine_country: "",
  wine_type: "",
  wine_image: "",
  years: "",
  producer: "",
  wine_url: "",
  one_word: "",
  breed: "",
  capacity: "",
  comment: "",
};

const customLabels = {
  comment: "詳細コメント",
  wine_name: "ワイン名*",
  winery: "ワイナリー*",
  wine_type: "ワインの種類(赤ワイン、白ワインetc)*",
  wine_image: "ワイン画像のURL*",
  wine_country: "産地*",
  wine_url: "ワインのURL*",
  one_word: "おすすめワード",
  english_wine_name: "ワイン名(英名)",
  years: "生産年",
  producer: "製造者",
  breed: "ぶどうの種類*",
  capacity: "容量",
};

const notNullFields = [
  "wine_name",
  "winery",
  "wine_type",
  "wine_image",
  "wine_country",
  "wine_url",
  "breed",
];

const WineRegistration = () => {
  const [wineInfo, setWineInfo] = useState(initialWineInfo);
  const [errors, setErrors] = useState({});
  const router = useRouter();

  const handleChange = useCallback((name, value) => {
    setWineInfo((prevState) => ({
      ...prevState,
      [name]: value,
    }));
  }, []);

  const validate = useCallback(() => {
    let tempErrors = {};
    let formIsValid = true;

    notNullFields.forEach((field) => {
      if (!wineInfo[field]) {
        formIsValid = false;
        tempErrors[field] = "入力必須項目が未入力です。入力してください";
      }
    });

    if (wineInfo.capacity && !Number.isInteger(Number(wineInfo.capacity))) {
      formIsValid = false;
      tempErrors["capacity"] = "半角数字で入力して下さい";
    }

    setErrors(tempErrors);
    return formIsValid;
  }, [wineInfo]);

  const handleSubmit = useCallback(
    (e) => {
      e.preventDefault();

      if (validate()) {
        console.log(wineInfo);
        axios
          .post("http://localhost:8080/recommend/newPost.php", wineInfo, {
            headers: {
              "Content-Type": "application/json",
            },
          })
          .then((response) => {
            console.log(response.data);
            if (response.data.error) {
              alert(response.data.error);
            } else {
              alert(response.data.message);
              router.push("/newPost/PostComplete");
            }
          })
          .catch((error) => {
            console.error(`Error: ${error}`);
          });
      }
    },
    [validate, wineInfo, router]
  );

  return (
    <DefaultLayout>
      <Container sx={{ width: "60%", padding: "50px 0" }}>
        <Typography
          variant="h4"
          sx={{ paddingBottom: "30px", fontWeight: "bold" }}
        >
          おすすめのワインを投稿
        </Typography>
        <Box
          component="form"
          onSubmit={handleSubmit}
          noValidate
          autoComplete="off"
        >
          <Grid container spacing={3}>
            {Object.keys(wineInfo).map((key) => (
              <Grid item xs={12} key={key}>
                <CustomTextField
                  name={key}
                  label={customLabels[key]}
                  value={wineInfo[key]}
                  handleChange={handleChange}
                  error={!!errors[key]} // このキーにエラーがある場合、trueになる
                  helperText={errors[key]} // 画面にエラーメッセージの表示
                />
              </Grid>
            ))}
            <Grid item xs={12}>
              <Button
                type="submit"
                variant="contained"
                color="primary"
                sx={{
                  backgroundColor: "#f59e0b",
                  "&:hover": {
                    backgroundColor: "#f59f0bc5",
                  },
                  fontSize: "16px",
                  fontWeight: "bold",
                }}
              >
                投稿する
              </Button>
            </Grid>
          </Grid>
        </Box>
      </Container>
    </DefaultLayout>
  );
};

export default WineRegistration;

まず、initialWineInfo で、新規ワイン情報の初期状態を設定しています。すべてのフィールドは空の文字列となっています。
customLabels は各フィールドのラベル名を定義。
notNullFields は必須入力フィールドの名前を配列で定義しています。

続いて、handleChange関数で入力フィールドの状態を見ています。変更されるとsetWineInfoに設定されます。
同時にコールバック関数でメモ化処理を行なっております。

  const [wineInfo, setWineInfo] = useState(initialWineInfo);
  const [errors, setErrors] = useState({});

  const handleChange = useCallback((name, value) => {
    setWineInfo((prevState) => ({
      ...prevState,
      [name]: value,
    }));
  }, []);

下記はバリデーションチェックを行なっています。
必須項目が入力されていること、capacity フィールドが整数であることをチェックしています。エラーがある場合、errors ステートにエラーメッセージを設定しています。

const validate = useCallback(() => {
    let tempErrors = {};
    let formIsValid = true;

    notNullFields.forEach((field) => {
      if (!wineInfo[field]) {
        formIsValid = false;
        tempErrors[field] = "入力必須項目が未入力です。入力してください";
      }
    });

    if (wineInfo.capacity && !Number.isInteger(Number(wineInfo.capacity))) {
      formIsValid = false;
      tempErrors["capacity"] = "半角数字で入力して下さい";
    }

    setErrors(tempErrors);
    return formIsValid;
  }, [wineInfo]);

そして「投稿する」ボタンが押されたときの処理を下記に記しています。
まず、e.preventDefault();でフォームのデフォルトの送信動作を防ぎます。

次に、validate()関数が呼び出され、入力フィールドが適切に記入されているかをチェックします。

バリデーションが成功した場合、axios.postを使用して非同期でサーバーにデータを送信します。

.thenの部分では、サーバーからのレスポンスを処理しています。サーバーからの応答にエラーが含まれていれば、そのエラーメッセージをアラートとして表示します。エラーがなければ、成功メッセージをアラートとして表示し、ユーザーを「/newPost/PostComplete」ページにリダイレクトします。

headersを設定しないと怒られてしまうので、設定しています。

.catchはエラーの場合の処理を記述しています。エラーの詳細はコンソールにログとして出力されます。

  const handleSubmit = useCallback(
    (e) => {
      e.preventDefault();

      if (validate()) {
        console.log(wineInfo);
        axios
          .post("http://localhost:8080/recommend/newPost.php", wineInfo, {
            headers: {
              "Content-Type": "application/json",
            },
          })
          .then((response) => {
            console.log(response.data);
            if (response.data.error) {
              alert(response.data.error);
            } else {
              alert(response.data.message);
              router.push("/newPost/PostComplete");
            }
          })
          .catch((error) => {
            console.error(`Error: ${error}`);
          });
      }
    },
    [validate, wineInfo, router]
  );

投稿完了ページ(pages/newPost/PostComplete.tsx)

そして投稿が完了できたら「投稿が完了しました」と表示されるページへ遷移します。特にそれ以上の説明はありません。

newPost/PostComplete.tsx
import React from "react";
import { DefaultLayout } from "../../src/layout/DefaultLayout";
import { Container, Grid, Typography } from "@mui/material";
import { BasicButton } from "../components/Button/BasicButton";

const PostComplete = () => {
  return (
    <DefaultLayout>
      <Container sx={{ width: "60%", padding: "50px 0" }}>
        <Typography
          variant="h4"
          sx={{ paddingTop: "50px", paddingBottom: "70px", fontWeight: "bold" }}
        >
          投稿が完了しました
        </Typography>
        <Grid item>
          <BasicButton
            href={"/" || "#"} // エラー回避
            color="#F59E0B"
          >
            トップページへ戻る
          </BasicButton>
        </Grid>
      </Container>
    </DefaultLayout>
  );
};

export default PostComplete;

ユーザー登録画面(pages/login/Signup.tsx)

続いて、ユーザー登録画面の説明です。
ここで頑張っているのは主にバックエンドの方なので、フロントではユーザーから入力された情報を受け取って、post送信しているだけになります。

pages/login/Signup.tsx
import React, { useState } from "react";
import axios from "axios";
import { DefaultLayout } from "../../src/layout/DefaultLayout";
import TextField from "@mui/material/TextField";
import Box from "@mui/material/Box";
import { Grid, Typography, Button } from "@mui/material";
import Link from "next/link";

const textFieldStyle = {
  width: "400px",
  "& .MuiOutlinedInput-root .MuiOutlinedInput-notchedOutline": {
    borderColor: "#683212",
  },
  "&:hover .MuiOutlinedInput-root .MuiOutlinedInput-notchedOutline": {
    borderColor: "#bb947c",
  },
  "& .MuiOutlinedInput-root.Mui-focused .MuiOutlinedInput-notchedOutline": {
    borderColor: "#683212",
  },
};

const Signup = () => {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [error, setError] = useState(null);
  const [success, setSuccess] = useState(null);

  const handleSubmit = async (event) => {
    event.preventDefault();
    setError(null); // エラーメッセージのリセット
    setSuccess(null); // 完了メッセージのリセット

    try {
      const response = await axios.post(
        "http://localhost:8080/userInfo/userRegistration.php",
        {
          email,
          password,
        }
      );
      setSuccess(response.data.message);
    } catch (error) {
      if (error.response) {
        // サーバーが200レンジ以外のステータスで応答した場合
        setError(error.response.data.message);
      } else if (error.request) {
        setError("サーバが応答しなかったので、後でもう一度試してください。");
      } else {
        setError(
          "リクエストの設定中にエラーが発生しました。再試行してください。"
        );
      }
    }
  };

  return (
    <DefaultLayout>
      <Grid
        container
        direction="column"
        sx={{ alignItems: "center", width: "100%", paddingTop: "80px" }}
      >
        <Typography variant="h4">新規ユーザー登録</Typography>
        <Grid item xs={12}>
          <Box
            component="form"
            onSubmit={handleSubmit}
            noValidate
            autoComplete="off"
          >
            <Grid
              item
              sx={{ alignItems: "center", width: "100%", paddingTop: "40px" }}
            >
              <TextField
                id="outlined-email"
                label="メールアドレス"
                variant="outlined"
                type="email"
                value={email}
                onChange={(e) => setEmail(e.target.value)}
                required
                InputLabelProps={{
                  style: { color: "#683212" },
                }}
                sx={textFieldStyle}
              />
            </Grid>
            <Grid
              item
              sx={{
                alignItems: "center",
                width: "100%",
                paddingTop: "20px",
                paddingBottom: "10px",
              }}
            >
              <TextField
                id="outlined-password"
                label="パスワード"
                variant="outlined"
                type="password"
                value={password}
                onChange={(e) => setPassword(e.target.value)}
                required
                InputLabelProps={{
                  style: { color: "#683212" },
                }}
                sx={textFieldStyle}
              />
            </Grid>
            <span style={{ color: "gray" }}>
              半角英数・記号の8文字以上で入力してください。
            </span>
            <Grid sx={{ paddingTop: "20px" }}>
              {error && <Typography color="error">{error}</Typography>}
              {success && <Typography color="primary">{success}</Typography>}
            </Grid>
            <Grid
              item
              sx={{ alignItems: "center", width: "100%", paddingTop: "30px" }}
            >
              <Button
                type="submit"
                variant="contained"
                sx={{
                  backgroundColor: "#f59e0b",
                  color: "#ffffff",
                  "&:hover": {
                    backgroundColor: "#f59f0bc5",
                  },
                  fontSize: "16px",
                  fontWeight: "bold",
                }}
              >
                アカウントを作成する
              </Button>
            </Grid>
            <Grid sx={{ paddingTop: "20px" }}>
              <span>すでにアカウントをお持ちですか?</span>
              <Link href={"/login"}>
                <span style={{ color: "#F59E0B", cursor: "pointer" }}>
                  ログイン
                </span>
              </Link>
            </Grid>
          </Box>
        </Grid>
      </Grid>
    </DefaultLayout>
  );
};

export default Signup;

まずは下記で、ユーザーが入力する電子メールとパスワードのステートを初期化しています。また、エラーメッセージと成功メッセージを管理するためのステートも初期化しています。

  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [error, setError] = useState(null);
  const [success, setSuccess] = useState(null);

続いてhandleSubmit関数内、まずでエラーメッセージ完了メッセージのリセットの処理。
axios.postで指定のエンドポイントにemailpasswordをpost送信しています。

そのほかはエラーが返ってきた場合の処理になります。
先ほどの投稿の時とロジックが似ているのでここでは割愛致します。

const handleSubmit = async (event) => {
    event.preventDefault();
    setError(null); // エラーメッセージのリセット
    setSuccess(null); // 完了メッセージのリセット

    try {
      const response = await axios.post(
        "http://localhost:8080/userInfo/userRegistration.php",
        {
          email,
          password,
        }
      );
      setSuccess(response.data.message);
    } catch (error) {
      if (error.response) {
        // サーバーが200レンジ以外のステータスで応答した場合
        setError(error.response.data.message);
      } else if (error.request) {
        setError("サーバが応答しなかったので、後でもう一度試してください。");
      } else {
        setError(
          "リクエストの設定中にエラーが発生しました。再試行してください。"
        );
      }
    }
  };

ログイン画面(pages/login/index.tsx)

続いてログイン画面の説明です。
ざっくり説明すると、ログイン処理自体は先ほどと同様にpost通信でemailとpasswordの確認処理を行なっております。
そしてその時に、トークンを生成し、localStorageに保存する処理を行なっております。

pages/login/index.tsx
import React, { useState } from "react";
import axios from "axios";
import { DefaultLayout } from "../../src/layout/DefaultLayout";
import TextField from "@mui/material/TextField";
import Box from "@mui/material/Box";
import { Grid, Typography, Button } from "@mui/material";
import Link from "next/link";

const textFieldStyle = {
  width: "400px",
  "& .MuiOutlinedInput-root .MuiOutlinedInput-notchedOutline": {
    borderColor: "#683212",
  },
  "&:hover .MuiOutlinedInput-root .MuiOutlinedInput-notchedOutline": {
    borderColor: "#bb947c",
  },
  "& .MuiOutlinedInput-root.Mui-focused .MuiOutlinedInput-notchedOutline": {
    borderColor: "#683212",
  },
};

const LoginForm = () => {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [error, setError] = useState("");

  const handleSubmit = async (event) => {
    event.preventDefault();

    try {
      const response = await axios.post("http://localhost:8080/login/login.php", {
        email,
        password,
      });
      const token = response.data.token;
      // token を保存
      localStorage.setItem("token", token);
      // ログインに成功したら、ユーザーをホームページなどにリダイレクト
      window.location.replace("/");
    } catch (error) {
      setError("メールアドレスまたはパスワードが正しくありません。");
    }
  };

  return (
    <DefaultLayout>
      <Grid
        container
        direction="column"
        sx={{ alignItems: "center", width: "100%", paddingTop: "80px" }}
      >
        <Typography variant="h4">ログイン</Typography>
        <Grid item xs={12}>
          <Box
            component="form"
            onSubmit={handleSubmit}
            noValidate
            autoComplete="off"
          >
            <Grid
              item
              sx={{ alignItems: "center", width: "100%", paddingTop: "40px" }}
            >
              <TextField
                id="outlined-email"
                label="メールアドレス"
                variant="outlined"
                type="email"
                value={email}
                onChange={(e) => setEmail(e.target.value)}
                required
                InputLabelProps={{
                  style: { color: "#683212" },
                }}
                sx={textFieldStyle}
              />
            </Grid>
            <Grid
              item
              sx={{
                alignItems: "center",
                width: "100%",
                paddingTop: "20px",
                paddingBottom: "10px",
              }}
            >
              <TextField
                id="outlined-password"
                label="パスワード"
                variant="outlined"
                type="password"
                value={password}
                onChange={(e) => setPassword(e.target.value)}
                required
                InputLabelProps={{
                  style: { color: "#683212" },
                }}
                sx={textFieldStyle}
              />
            </Grid>
            <Grid sx={{ paddingTop: "10px" }}>
              <span style={{ textDecoration: "underline" }}>
                パスワードを忘れた場合
              </span>
            </Grid>
            {error && (
              <Grid sx={{ paddingTop: "20px" }}>
                <Typography color="error">{error}</Typography>
              </Grid>
            )}
            <Grid
              item
              sx={{ alignItems: "center", width: "100%", paddingTop: "30px" }}
            >
              <Button
                type="submit"
                variant="contained"
                sx={{
                  backgroundColor: "#f59e0b",
                  color: "#ffffff",
                  "&:hover": {
                    backgroundColor: "#f59f0bc5",
                  },
                  fontSize: "16px",
                  fontWeight: "bold",
                }}
              >
                ログイン
              </Button>
            </Grid>
            <Grid sx={{ paddingTop: "20px" }}>
              <span>まだアカウントをお持ちでないですか?</span>
              <Link href={"/login/Signup"}>
                <span style={{ color: "#F59E0B", cursor: "pointer" }}>
                  新規登録
                </span>
              </Link>
            </Grid>
          </Box>
        </Grid>
      </Grid>
    </DefaultLayout>
  );
};

export default LoginForm;

ユーザー登録と処理内容が似ているので、違う部分のみ説明します。

まず、handleSubmit関数で入力されたメールアドレスとパスワードを用いてPOSTリクエストをサーバーに送信し、レスポンスから得られたトークンをlocalStorageに保存します。
ログイン成功後はユーザーをホームページにリダイレクトし、ログイン失敗時はエラーメッセージを表示する処理を行なっております。

const handleSubmit = async (event) => {
    event.preventDefault();

    try {
      const response = await axios.post("http://localhost:8080/login/login.php", {
        email,
        password,
      });
      const token = response.data.token;
      // token を保存
      localStorage.setItem("token", token);
      // ログインに成功したら、ユーザーをホームページなどにリダイレクト
      window.location.replace("/");
    } catch (error) {
      setError("メールアドレスまたはパスワードが正しくありません。");
    }
  };

ユーザー情報の確認、編集(pages/login/userInfo.tsx)

いよいよフロントコードの最後の説明になります。長かったですね。。
最後は、ユーザー情報の確認と編集ができる処理を書いています。所謂「マイページ」というやつですね。

ユーザーをトークンで判断しているのですが、このページが一番苦労しました。。
認証認可系は難しいですね。

pages/login/userInfo.tsx
import React, { useState, useEffect } from "react";
import axios from "axios";
import { DefaultLayout } from "../../src/layout/DefaultLayout";
import {
  Box,
  Container,
  Grid,
  TextField,
  Typography,
  Button,
} from "@mui/material";


const UserInfo = () => {
  const [userInfo, setUserInfo] = useState(null);
  const [error, setError] = useState(null);

  const [userName, setUserName] = useState("");
  const [userNameHiragana, setUserNameHiragana] = useState("");
  const [nickname, setNickname] = useState("");
  const [email, setEmail] = useState("");
  const [telephoneNumber, setTelephoneNumber] = useState("");
  const [password, setPassword] = useState("");

  useEffect(() => {
    const token = localStorage.getItem("token");

    // あり得ないが念の為
    if (!token) {
      setError("ログインが必要です。");
      return;
    }

    const fetchUserInfo = async () => {
      try {
        const response = await axios.get(
          `http://localhost:8080/userInfo/getUser.php?token=${token}`
        );
        setUserInfo(response.data);
        setUserName(response.data.user_name);
        setUserNameHiragana(response.data.user_name_hiragana);
        setNickname(response.data.nickname);
        setEmail(response.data.mail_address);
        setTelephoneNumber(response.data.telephone_number);
        setPassword(response.data.user_password);
      } catch (error) {
        setError("ユーザー情報の取得に失敗しました。");
        console.error("Error:", error);
      }
    };
    fetchUserInfo();
  }, []);

  const handleEdit = async () => {
    const params = new URLSearchParams();
    params.append("id", userInfo.id);
    params.append("user_name", userName);
    params.append("user_name_hiragana", userNameHiragana);
    params.append("nickname", nickname);
    params.append("mail_address", email);
    params.append("telephone_number", telephoneNumber);
    params.append("user_password", password);

    console.log(params);

    try {
      await axios.post(
        `http://localhost:8080/userInfo/updateUser.php`,
        JSON.stringify({
          id: userInfo.id,
          user_name: userName,
          user_name_hiragana: userNameHiragana,
          nickname: nickname,
          mail_address: email,
          telephone_number: telephoneNumber,
          user_password: password,
        }),
        {
          headers: {
            "Content-Type": "application/json",
          },
        }
      );
      alert("ユーザー情報が更新されました!");
    } catch (error) {
      console.error("Error:", error);
      alert("ユーザー情報の更新に失敗しました。");
    }
  };

  return (
    <DefaultLayout>
      <Container maxWidth="md" sx={{ width: "50%", padding: "50px 0" }}>
        <Typography
          variant="h4"
          sx={{ paddingBottom: "50px", fontWeight: "bold" }}
        >
          ユーザー情報
        </Typography>
        <Box component="form" noValidate autoComplete="off">
          <Grid container spacing={3}>
            <Grid item xs={12}>
              {error && <Typography color="error">{error}</Typography>}
              {userInfo && (
                <>
                  <TextField
                    label="お名前"
                    value={userName}
                    onChange={(e) => setUserName(e.target.value)}
                    fullWidth
                    sx={{ marginBottom: "20px" }}
                  />
                  <TextField
                    label="ひらがな"
                    value={userNameHiragana}
                    onChange={(e) => setUserNameHiragana(e.target.value)}
                    fullWidth
                    sx={{ marginBottom: "20px" }}
                  />
                  <TextField
                    label="ニックネーム"
                    value={nickname}
                    onChange={(e) => setNickname(e.target.value)}
                    fullWidth
                    sx={{ marginBottom: "20px" }}
                  />
                  <TextField
                    label="メールアドレス"
                    value={email}
                    onChange={(e) => setEmail(e.target.value)}
                    fullWidth
                    sx={{ marginBottom: "20px" }}
                  />
                  <TextField
                    label="電話番号"
                    value={telephoneNumber}
                    onChange={(e) => setTelephoneNumber(e.target.value)}
                    fullWidth
                    sx={{ marginBottom: "20px" }}
                  />
                  <TextField
                    type="password"
                    label="パスワード"
                    value={password}
                    onChange={(e) => setPassword(e.target.value)}
                    fullWidth
                    sx={{ marginBottom: "20px" }}
                  />
                </>
              )}
              <Grid sx={{ padding: "50px 0" }}>
                <Button
                  type="submit"
                  variant="contained"
                  color="primary"
                  onClick={handleEdit}
                  sx={{
                    backgroundColor: "#f59e0b",
                    "&:hover": {
                      backgroundColor: "#f59f0bc5",
                    },
                    fontSize: "16px",
                    fontWeight: "bold",
                  }}
                >
                  更新する
                </Button>
              </Grid>
            </Grid>
          </Grid>
        </Box>
      </Container>
    </DefaultLayout>
  );
};

export default UserInfo;

では、コアとなる部分の説明をしていきます。

下記fetchUserInfoはざっくり言うと、ログイン済みのユーザーの情報をサーバーから取得しています。

const token = localStorage.getItem("token");

でローカルストレージから"token"というキーで保存されているトークンを取得します。これは先ほど、ログイン時に取得したやつですね。

そして、axios.get()を使って、ユーザー情報を取得するためのGETリクエストを行います。このリクエストはトークンをクエリパラメータとしてサーバーに送信されます。サーバーはこのトークンをチェックし、正当なトークンであれば対応するユーザーの情報をset〇〇に格納します。

    const fetchUserInfo = async () => {
      try {
        const response = await axios.get(
          `http://localhost:8080/userInfo/getUser.php?token=${token}`
        );
        setUserInfo(response.data);
        setUserName(response.data.user_name);
        setUserNameHiragana(response.data.user_name_hiragana);
        setNickname(response.data.nickname);
        setEmail(response.data.mail_address);
        setTelephoneNumber(response.data.telephone_number);
        setPassword(response.data.user_password);
      } catch (error) {
        setError("ユーザー情報の取得に失敗しました。");
        console.error("Error:", error);
      }
    };
    fetchUserInfo();
  }, []);

続いて、handleEdit関数はユーザーが「更新する」ボタンを押したときの処理です。

これは、axios.post()を使って、更新したいユーザー情報を含むPOSTリクエストをサーバーに送信します。
POSTリクエストのボディにはJSON形式でユーザー情報が含まれており、これには、ユーザーの名前、ひらがな名、ニックネーム、メールアドレス、電話番号、パスワードが含まれます。

const handleEdit = async () => {
    const params = new URLSearchParams();
    params.append("id", userInfo.id);
    params.append("user_name", userName);
    params.append("user_name_hiragana", userNameHiragana);
    params.append("nickname", nickname);
    params.append("mail_address", email);
    params.append("telephone_number", telephoneNumber);
    params.append("user_password", password);

    console.log(params);

    try {
      await axios.post(
        `http://localhost:8080/userInfo/updateUser.php`,
        JSON.stringify({
          id: userInfo.id,
          user_name: userName,
          user_name_hiragana: userNameHiragana,
          nickname: nickname,
          mail_address: email,
          telephone_number: telephoneNumber,
          user_password: password,
        }),
        {
          headers: {
            "Content-Type": "application/json",
          },
        }
      );
      alert("ユーザー情報が更新されました!");
    } catch (error) {
      console.error("Error:", error);
      alert("ユーザー情報の更新に失敗しました。");
    }
  };

以上がフロントエンド部分のコード説明となります。ロジック部分をメインに説明させていただきましたが、ここまで読んでいただきありがとうございます。

バックエンドにも興味がある方は、この先も読んでいただけると嬉しいです。

バックエンド

では、フロントコードの説明が終わったのでバックエンドの方も解説させていただきます。
基本的にはバックエンドは、APIとして利用しててフロントとのレスポンスエンドポイントを作成している、といった感じです。

こちらは絶賛勉強中のため、拙い部分もあるかと思いますが温かい目で見守っていただけると嬉しいです。

診断結果(html/shindan/resultAka.php)

早速ですが、診断結果のコードから解説していきます。
バックエンドはdockerを使用しています。

設定方法などは下記サイトを参考にしました。

html/shindan/resultAka.php
<?php
$dsn = "mysql:dbname=test_db;host=mysql;port=3306";
$user = "test_user";
$password = "test_password";

header("Access-Control-Allow-Origin: *");
header("Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS");
header("Access-Control-Allow-Headers: Content-Type, Access-Control-Allow-Headers, X-Requested-With");


try {
    // DB接続
    $dbh = new PDO($dsn, $user, $password);

    // 初期データ
    $sampleData = require '../sampleData/akaSample.php';

    // テーブルの存在確認
    $sql = "SHOW TABLES LIKE 'result_aka'";
    $result = $dbh->query($sql)->fetchAll();

    // テーブルが存在しない場合、テーブルを作成
    if (empty($result)) {
        $sql = "CREATE TABLE result_aka (
            id INT AUTO_INCREMENT PRIMARY KEY,
            comment TEXT,
            wine_name VARCHAR(255),
            winery VARCHAR(255),
            wine_type VARCHAR(255),
            wine_image VARCHAR(255),
            wine_country VARCHAR(255),
            wine_url VARCHAR(1000),
            one_word VARCHAR(255),
            english_wine_name VARCHAR(255),
            years VARCHAR(255),
            producer VARCHAR(255),
            breed VARCHAR(255),
            capacity INT
        )";
        $dbh->exec($sql);
    }

    // テーブルが空かどうか確認
    $sql = "SELECT COUNT(*) FROM result_aka";
    $count = $dbh->query($sql)->fetchColumn();

    // テーブルが空ならサンプルデータを挿入
    if ($count == 0) {
        $sql = "INSERT INTO result_aka (comment, wine_name, winery, wine_type, wine_image, wine_country, wine_url, one_word, english_wine_name, years, producer, breed, capacity) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
        $stmt = $dbh->prepare($sql);
        foreach ($sampleData as $data) {
            $stmt->execute([$data['comment'], $data['wine_name'], $data['winery'], $data['wine_type'], $data['wine_image'], $data['wine_country'], $data['wine_url'], $data['one_word'], $data['english_wine_name'], $data['years'], $data['producer'], $data['breed'], $data['capacity']]);
        }
    }

    // データの取得
    $sql = "SELECT id, comment, wine_name, winery, wine_type, wine_image, wine_country, wine_url, one_word, english_wine_name, years, producer, breed, capacity FROM result_aka";
    $wineData = $dbh->query($sql)->fetchAll(PDO::FETCH_ASSOC);

    // JSON形式で出力
    header('Content-Type: application/json');
    echo json_encode([
        'result' => 'SUCCESS',
        'data' => $wineData
    ], JSON_UNESCAPED_UNICODE);
} catch (PDOException $e) {
    print('Error:' . $e->getMessage());
    die();
}

まずはDB接続情報を記述しています。

あとはヘッダーの設定ですね。JSON形式でデータを返すWeb APIとして機能するためのHTTPレスポンスヘッダを設定してします。特に、CORS (Cross-Origin Resource Sharing) の設定を行っています。
これがないと動かした時にCORSエラーで怒られてしまうので、記述しています。

$dsn = "mysql:dbname=test_db;host=mysql;port=3306";
$user = "test_user";
$password = "test_password";

header("Access-Control-Allow-Origin: *");
header("Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS");
header("Access-Control-Allow-Headers: Content-Type, Access-Control-Allow-Headers, X-Requested-With");

try ブロック内では、まず PDO インスタンスを作成してDBに接続します。

result_aka という名前のテーブルがデータベースに存在するかを確認します。

    // DB接続
    $dbh = new PDO($dsn, $user, $password);

    // 初期データ
    $sampleData = require '../sampleData/akaSample.php';

    // テーブルの存在確認
    $sql = "SHOW TABLES LIKE 'result_aka'";
    $result = $dbh->query($sql)->fetchAll();

次に、テーブルが存在しない場合は、テーブルを作成するクエリを実行しています。

    // テーブルが存在しない場合、テーブルを作成
    if (empty($result)) {
        $sql = "CREATE TABLE result_aka (
            id INT AUTO_INCREMENT PRIMARY KEY,
            comment TEXT,
            wine_name VARCHAR(255),
            winery VARCHAR(255),
            wine_type VARCHAR(255),
            wine_image VARCHAR(255),
            wine_country VARCHAR(255),
            wine_url VARCHAR(1000),
            one_word VARCHAR(255),
            english_wine_name VARCHAR(255),
            years VARCHAR(255),
            producer VARCHAR(255),
            breed VARCHAR(255),
            capacity INT
        )";
        $dbh->exec($sql);
    }

そして、そのテーブルが空かどうかの確認を行い、空だった場合はサンプルデータを挿入するクエリを実行します。

    // テーブルが空かどうか確認
    $sql = "SELECT COUNT(*) FROM result_aka";
    $count = $dbh->query($sql)->fetchColumn();

    // テーブルが空ならサンプルデータを挿入
    if ($count == 0) {
        $sql = "INSERT INTO result_aka (comment, wine_name, winery, wine_type, wine_image, wine_country, wine_url, one_word, english_wine_name, years, producer, breed, capacity) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
        $stmt = $dbh->prepare($sql);
        foreach ($sampleData as $data) {
            $stmt->execute([$data['comment'], $data['wine_name'], $data['winery'], $data['wine_type'], $data['wine_image'], $data['wine_country'], $data['wine_url'], $data['one_word'], $data['english_wine_name'], $data['years'], $data['producer'], $data['breed'], $data['capacity']]);
        }
    }

そして、result_aka テーブルから全てのレコードを取得します。

取得したレコードをJSON形式で出力し、この出力はWeb APIのレスポンスとなり、フロントエンドからのリクエストに対する応答として送信されます。

つまり、フロントが診断結果のページに行った際に、下記JSONデータをエンドポイントとして渡している、ということですね。

// データの取得
    $sql = "SELECT id, comment, wine_name, winery, wine_type, wine_image, wine_country, wine_url, one_word, english_wine_name, years, producer, breed, capacity FROM result_aka";
    $wineData = $dbh->query($sql)->fetchAll(PDO::FETCH_ASSOC);

    // JSON形式で出力
    header('Content-Type: application/json');
    echo json_encode([
        'result' => 'SUCCESS',
        'data' => $wineData
    ], JSON_UNESCAPED_UNICODE);

おすすめ一覧(html/recommend/recommend.php)

続いて、DB内にあるおすすめワインの一覧データをフロントエンドに渡してあげる処理ですが、全データを渡すだけなので概ね先ほどのコードとほぼ同じです。

割愛しますが、気になる方は直接コードを見てみてください。

おすすめ詳細(html/recommend/recommend[id].php)

ではそのおすすめの詳細ページです。
ここでは、ワインのIDを見てそれに紐づいたワイン情報を返す処理をしています。

html/recommend/recommend[id].php
<?php
$dsn = "mysql:dbname=test_db;host=mysql;port=3306";
$user = "test_user";
$password = "test_password";

header("Access-Control-Allow-Origin: *");
header("Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS");
header("Access-Control-Allow-Headers: Content-Type, Access-Control-Allow-Headers, X-Requested-With");

try {
    $dbh = new PDO($dsn, $user, $password);

    // ワインIDを取得する
    $id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
    if (!$id) {
        throw new Exception("Invalid wine id");
    }

    // 指定されたIDのワイン情報を取得する
    $sql = "SELECT id, comment, wine_name, winery, wine_type, wine_image, wine_country, wine_url, one_word, english_wine_name, years, producer, breed, capacity FROM recommend_wines WHERE id = ?";
    $stmt = $dbh->prepare($sql);
    $stmt->execute([$id]);
    $wineData = $stmt->fetch(PDO::FETCH_ASSOC);

    // 結果をJSON形式で出力
    header('Content-Type: application/json');
    echo json_encode([
        'result' => $wineData ? 'SUCCESS' : 'FAIL',
        'data' => $wineData ? [$wineData] : [],
    ], JSON_UNESCAPED_UNICODE);
} catch (PDOException $e) {
    print('Error:' . $e->getMessage());
    die();
} catch (Exception $e) {
    header('Content-Type: application/json');
    echo json_encode([
        'result' => 'FAIL',
        'data' => [],
        'message' => $e->getMessage(),
    ], JSON_UNESCAPED_UNICODE);
    die();
}

まずは下記でワインIDを取得しています。これはフロントから指定されるワインのIDのことです。

もし id が設定されていない、または0の場合は例外をスローします。

そして、SELECT文で指定されたワイン情報を取得します。

このexecuteメソッドは、事前に定義されたSQLクエリにパラメータをバインド(結びつけ)し、そのクエリをデータベースで実行します。

$stmt->execute([$id]);

そして、fetchメソッドを使って実行結果を取得します。ここでのPDO::FETCH_ASSOCは、結果セットを連想配列として返すための指示です。

$wineData = $stmt->fetch(PDO::FETCH_ASSOC);
// ワインIDを取得する
    $id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
    if (!$id) {
        throw new Exception("Invalid wine id");
    }

    // 指定されたIDのワイン情報を取得する
    $sql = "SELECT id, comment, wine_name, winery, wine_type, wine_image, wine_country, wine_url, one_word, english_wine_name, years, producer, breed, capacity FROM recommend_wines WHERE id = ?";
    $stmt = $dbh->prepare($sql);
    $stmt->execute([$id]);
    $wineData = $stmt->fetch(PDO::FETCH_ASSOC);

あとは、先ほどと同様に取得したレコードをJSON形式で出力し、この出力はWeb APIのレスポンスとなり、フロントエンドからのリクエストに対する応答として送信します。

ワインの投稿(html/recommend/newPost.php)

続いてワインを投稿する機能の説明です。

html/recommend/newPost.php
<?php

// エラー表示
ini_set('display_errors', 1);
ini_set('display_startup_errors', 1);
error_reporting(E_ALL);

header("Access-Control-Allow-Origin: *");
header("Access-Control-Allow-Headers: Content-Type, Access-Control-Allow-Headers, X-Requested-With");
header("Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS");


if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
	// サーバーがOPTIONSメソッドのリクエストを受け取った際には、何もせずに正常終了
	exit;
} elseif ($_SERVER['REQUEST_METHOD'] !== 'POST') {
	http_response_code(400);
	echo json_encode(['message' => 'Invalid request method.']);
	exit;
}

// データベース接続
$dsn = "mysql:dbname=test_db;host=mysql8-1;port=3306";
$user = "test_user";
$password = "test_password";
$db = new PDO($dsn, $user, $password);

// リクエストからワインの情報を取得
$rawData = file_get_contents('php://input');
error_log('Raw data: ' . $rawData);
$data = json_decode($rawData, true);

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
	http_response_code(400);
	echo json_encode(['message' => 'Invalid request method.']);
	exit;
}

if ($data === null) {
	http_response_code(400);
	echo json_encode(['message' => 'リクエストボディが不適切です。']);
	exit;
}

$comment = $data['comment'];
$wine_name = $data['wine_name'];
$winery = $data['winery'];
$wine_type = $data['wine_type'];
$wine_image = $data['wine_image'];
$wine_country = $data['wine_country'];
$wine_url = $data['wine_url'];
$one_word = $data['one_word'];
$english_wine_name = $data['english_wine_name'];
$years = $data['years'];
$producer = $data['producer'];
$breed = $data['breed'];
$capacity = $data['capacity'];

$stmt = $db->prepare('INSERT INTO recommend_wines (comment, wine_name, winery, wine_type, wine_image, wine_country, wine_url, one_word, english_wine_name, years, producer, breed, capacity) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
$result = $stmt->execute([$comment, $wine_name, $winery, $wine_type, $wine_image, $wine_country, $wine_url, $one_word, $english_wine_name, $years, $producer, $breed, $capacity]);

// 実行結果がfalseの場合はエラーメッセージを返す
if ($result === false) {
	http_response_code(500);
	echo json_encode(['message' => 'データの挿入中にエラーが発生しました。']);
	exit;
}

// 新しいワイン情報が登録された場合、成功メッセージを返す
echo json_encode(['message' => '新しいワインが正常に登録されました。']);
error_log(print_r($data, true));

まず1行目は、POSTリクエストから送られてきた生データを文字列として取得します。ここで'php://input'は、HTTPリクエストから生データを読み込むための特殊なアドレスです。

3行目は、生データ(JSON形式の文字列)をPHPの連想配列に変換します。ここで第二引数のtrueは結果を連想配列として返すことを指定しています。

// リクエストからワインの情報を取得
$rawData = file_get_contents('php://input');
error_log('Raw data: ' . $rawData);
$data = json_decode($rawData, true);

そして下記では、$_SERVER['REQUEST_METHOD'] を使ってリクエストメソッドがPOSTかどうかを確認しています。

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
	http_response_code(400);
	echo json_encode(['message' => 'Invalid request method.']);
	exit;
}

さらに下記で、データがnullでないかの確認を行なっております。

if ($data === null) {
	http_response_code(400);
	echo json_encode(['message' => 'リクエストボディが不適切です。']);
	exit;
}

データが正常に取得できたら、そのデータからワインに関する各情報を取り出します。

その上で、stmt = $db->prepare(...); でSQLの準備を行い、result = $stmt->execute(...); でそのSQLを実行します。
これで、ワインの情報がデータベースに挿入されます。

$comment = $data['comment'];
$wine_name = $data['wine_name'];
$winery = $data['winery'];
$wine_type = $data['wine_type'];
$wine_image = $data['wine_image'];
$wine_country = $data['wine_country'];
$wine_url = $data['wine_url'];
$one_word = $data['one_word'];
$english_wine_name = $data['english_wine_name'];
$years = $data['years'];
$producer = $data['producer'];
$breed = $data['breed'];
$capacity = $data['capacity'];

$stmt = $db->prepare('INSERT INTO recommend_wines (comment, wine_name, winery, wine_type, wine_image, wine_country, wine_url, one_word, english_wine_name, years, producer, breed, capacity) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
$result = $stmt->execute([$comment, $wine_name, $winery, $wine_type, $wine_image, $wine_country, $wine_url, $one_word, $english_wine_name, $years, $producer, $breed, $capacity]);

実行結果がfalseの場合はエラーメッセージを返す処理をします。

if ($result === false) {
	http_response_code(500);
	echo json_encode(['message' => 'データの挿入中にエラーが発生しました。']);
	exit;
}

ログイン処理(html/login/login.php)

続いてログイン周りの処理です。
基本的にはこれまでと同じような処理をしているのですが、違う部分のみ説明していきます。

トークンの作成などもここで行なっており、所謂「認証」作業を行なっております。

<?php
header("Access-Control-Allow-Origin: *");
header("Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS");
header("Access-Control-Allow-Headers: Content-Type, Access-Control-Allow-Headers, X-Requested-With");

if ($_SERVER['REQUEST_METHOD'] == 'OPTIONS') {
    // OPTIONS メソッドのリクエストに対しては適切なレスポンスを返す
    header("HTTP/1.1 200 OK");
    die();
}

// データベース接続
$dsn = "mysql:dbname=test_db;host=mysql8-1;port=3306";
$user = "test_user";
$password = "test_password";
$db = new PDO($dsn, $user, $password);

$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);  //エラーモードの設定

try {
    // テーブル存在確認
    $result = $db->query("SHOW TABLES LIKE 'user'");
    $tableExists = ($result->rowCount() > 0);

    // テーブルが存在しない場合、テーブルを作成
    if (!$tableExists) {
        $sql = "CREATE TABLE `user` (
                `id` int unsigned NOT NULL AUTO_INCREMENT,
                `mail_address` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'sample@example.com',
                `user_password` varchar(300) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '" . password_hash('defaultpassword', PASSWORD_DEFAULT) . "',
                `created_at` datetime NOT NULL,
                `token` varchar(300) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL,
                `user_name` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT 'サンプル',
                `user_name_hiragana` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT 'さんぷる',
                `telephone_number` varchar(15) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT '000-0000-0000',
                `nickname` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT 'nickname',
                PRIMARY KEY (`id`)
        ) ENGINE=InnoDB AUTO_INCREMENT=15 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;";
        $db->exec($sql);

        // テーブルが存在しなかった場合、サンプルデータを挿入
        $stmt = $db->prepare('INSERT INTO user (mail_address, user_password, created_at, token, user_name, user_name_hiragana, telephone_number, nickname) VALUES (?, ?, ?, ?, ?, ?, ?, ?)');
        $token = bin2hex(random_bytes(16));
        $hashed_password = password_hash('defaultpassword', PASSWORD_DEFAULT);
        // サンプルデータを挿入
        $stmt->execute([
            'sample@example.com',
            $hashed_password,
            date("Y-m-d H:i:s"),
            $token,
            'サンプル',
            'さんぷる',
            '000-0000-0000',
            'nickname'
        ]);
    }
} catch (PDOException $e) {
    http_response_code(500);
    echo json_encode(['message' => $e->getMessage()]);
    exit;
}

// リクエストからログイン情報を取得
$data = json_decode(file_get_contents('php://input'), true);
$email = $data['email'];
$password = $data['password'];

// ユーザ情報の取得
$stmt = $db->prepare('SELECT * FROM user WHERE mail_address = ?');
$stmt->execute([$email]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);

// ユーザ情報が存在しない場合、またはパスワードが一致しない場合はエラーを返す
if (!$user || !password_verify($password, $user['user_password'])) {
    http_response_code(401);
    echo json_encode(['message' => 'Email or password is incorrect.']);
    exit;
}

// ユーザ情報が正しい場合、新しいトークンを生成
$token = bin2hex(random_bytes(16));

// 生成されたトークンで既存のトークンを更新
$stmt = $db->prepare('UPDATE user SET token = ? WHERE id = ?');
$stmt->execute([$token, $user['id']]);

echo json_encode(['token' => $token]);

まず try文 では、userテーブルの存在確認を行い、ない場合にはテーブルを作成、サンプルデータの挿入作業をしています。

次に、クライアントからのリクエストからログイン情報(メールアドレスとパスワード)を取得します。

$data = json_decode(file_get_contents('php://input'), true);
$email = $data['email'];
$password = $data['password'];

取得したメールアドレスを用いて、データベースからユーザー情報を取得します。このとき、メールアドレスが一致するユーザーを探します。

$stmt = $db->prepare('SELECT * FROM user WHERE mail_address = ?');
$stmt->execute([$email]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);

ユーザ情報が存在しない場合、またはパスワードが一致しない場合は、401のHTTPステータスコードとエラーメッセージを返します。

if (!$user || !password_verify($password, $user['user_password'])) {
    http_response_code(401);
    echo json_encode(['message' => 'Email or password is incorrect.']);
    exit;
}

ユーザーが存在し、パスワードが一致する場合、新しいトークンを生成します。このトークンは、ユーザーが自分の情報を閲覧したり編集する際に利用します。

そして、データベースのユーザー情報を更新して新しいトークンを保存します。
最後に、新しく生成したトークンをクライアントに返します。

$token = bin2hex(random_bytes(16));

// 生成されたトークンで既存のトークンを更新
$stmt = $db->prepare('UPDATE user SET token = ? WHERE id = ?');
$stmt->execute([$token, $user['id']]);

echo json_encode(['token' => $token]);

ログアウト処理(html/login/logout.php)

続いて、ログアウト処理です。
特段特別なことはしていないのでサクッといきます。

html/login/logout.php
<?php

// リクエストからトークンを取得
$data = json_decode(file_get_contents('php://input'), true);
$token = $data['token'];

// tokensテーブルから該当のトークンを削除
$stmt = $db->prepare('DELETE FROM tokens WHERE token = ?');
$stmt->execute([$token]);

echo json_encode(['message' => 'Logged out successfully.']);
?>

まず下記でトークンを連想配列で取得します。

$data = json_decode(file_get_contents('php://input'), true);
$token = $data['token'];

そしておなじみの構文でDELETEクエリを実行します。
最後に、ログアウトが成功したことを示すメッセージをJSON形式でクライアントに送信して終了です。

$stmt = $db->prepare('DELETE FROM tokens WHERE token = ?');
$stmt->execute([$token]);

echo json_encode(['message' => 'Logged out successfully.']);

ユーザー登録処理(html/userInfo/userRegistration.php)

続いてユーザー登録の処理内容を見ていきましょう。

html/userInfo/userRegistration.php
$dsn = "mysql:dbname=test_db;host=mysql8-1;port=3306";
$user = "test_user";
$password = "test_password";
$db = new PDO($dsn, $user, $password);

$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

try {
	$result = $db->query("SHOW TABLES LIKE 'user'");
	$tableExists = ($result->rowCount() > 0);

	if (!$tableExists) {
		$sql = "CREATE TABLE `user` (
				`id` int unsigned NOT NULL AUTO_INCREMENT,
				`mail_address` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'sample@example.com',
				`user_password` varchar(300) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '" . password_hash('defaultpassword', PASSWORD_DEFAULT) . "',
				`created_at` datetime NOT NULL,
				`token` varchar(300) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL,
				`user_name` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT 'サンプル',
				`user_name_hiragana` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT 'さんぷる',
				`telephone_number` varchar(15) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT '000-0000-0000',
				`nickname` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT 'にっくねーむ'
				PRIMARY KEY (`id`)
		) ENGINE=InnoDB AUTO_INCREMENT=15 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;";
		$db->exec($sql);
	}

	$data = json_decode(file_get_contents('php://input'), true);
	$email = $data['email'] ?? 'sample@example.com';
	$password = $data['password'] ?? 'defaultpassword';

	if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
		http_response_code(400);
		echo json_encode(['message' => 'メールアドレスの形式が正しくありません']);
		exit;
	}

	if (strlen($password) < 8) {
		http_response_code(400);
		echo json_encode(['message' => 'パスワードは8文字以上にしてください']);
		exit;
	}

	$hashed_password = password_hash($password, PASSWORD_DEFAULT);

	$stmt = $db->prepare('INSERT INTO user (mail_address, user_password,
	created_at, token, user_name, user_name_hiragana, telephone_number, nickname) 
	VALUES (?, ?, ?, ?, ?,	?, ?, ?)');
	$token = bin2hex(random_bytes(16));
	$execution_result = $stmt->execute([
		$email,
		$hashed_password,
		date("Y-m-d H:i:s"),
		$token,
		$data['user_name'] ?? 'サンプル',
		$data['user_name_hiragana'] ?? 'さんぷる',
		$data['telephone_number'] ?? '000-0000-0000',
		$data['nickname'] ?? 'にっくねーむ'
	]);

	if (!$execution_result) {
		http_response_code(500);
		echo json_encode(['message' => 'Failed to create user.']);
		exit;
	}

	http_response_code(200);
	echo json_encode(['message' => 'ユーザー登録が完了しました!']);
} catch (PDOException $e) {
	http_response_code(500);
	echo json_encode(['message' => $e->getMessage()]);
}

前半部分はいつも通り、テーブルの存在確認と、なければCREATEクエリを実行します。

そしてこちらもいつも通り、リクエストボディを取得し、JSON形式の連想配列に変換します。
その上で、emailやpasswordがnullであれば、右辺を代入。存在すればそれを代入する処理を行なっております。

要はデフォルト値の設定ですね。

	// リクエストボディを取得し、JSON形式の連想配列に変換
	$data = json_decode(file_get_contents('php://input'), true);
	// emailやpasswordがnullであれば、右辺を代入。存在すればそれを代入。
	$email = $data['email'] ?? 'sample@example.com';
	$password = $data['password'] ?? 'defaultpassword';

下記はメールアドレスのバリデーションチェックを行なっております。
パスワードの方も同様にバリデーションチェックを行います。

	if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
		http_response_code(400);
		echo json_encode(['message' => 'メールアドレスの形式が正しくありません']);
		exit;
	}

そして下記で、パスワードをハッシュ化させます。

	$hashed_password = password_hash($password, PASSWORD_DEFAULT);

そして、userテーブルに新規ユーザー情報を挿入するSQL文を実行します。
それぞれデフォルト値も設定しています。

	$stmt = $db->prepare('INSERT INTO user (mail_address, user_password,
	created_at, token, user_name, user_name_hiragana, telephone_number, nickname) 
	VALUES (?, ?, ?, ?, ?,	?, ?, ?)');
	$token = bin2hex(random_bytes(16));
	$execution_result = $stmt->execute([
		$email,
		$hashed_password,
		date("Y-m-d H:i:s"),
		$token,
		$data['user_name'] ?? 'サンプル',
		$data['user_name_hiragana'] ?? 'さんぷる',
		$data['telephone_number'] ?? '000-0000-0000',
		$data['nickname'] ?? 'にっくねーむ'
	]);

最後に、SQL文の実行結果を確認し、失敗した場合はエラーメッセージを返します。

成功した場合、成功メッセージを返す処理を書いて終わりです。

if (!$execution_result) {
		http_response_code(500);
		echo json_encode(['message' => 'Failed to create user.']);
		exit;
	}
http_response_code(200);
	echo json_encode(['message' => 'ユーザー登録が完了しました!']);

ユーザー情報の閲覧(html/userInfo/getUser.php)

続いてユーザー情報の閲覧処理です。
フロントではトークンが取れるのに、バックエンドでトークンがなかなか渡らずかなり苦労しました。。

トークン取得には色々方法があるそうですが、紆余曲折あって今回は$_GETを使って取得しました。セキュリティ面で心配になりますが、個人開発のローカル環境下ということで多めに見ましょう。

html/userInfo/getUser.php
// データベース接続
$dsn = "mysql:dbname=test_db;host=mysql8-1;port=3306";
$user = "test_user";
$password = "test_password";
$db = new PDO($dsn, $user, $password);

// GETパラメータからトークンを取得
$token = $_GET["token"];

if (!$token) {
	http_response_code(401);
	echo json_encode(['message' => 'Token is required.']);
	exit;
}

// トークンからユーザー情報を取得
$stmt = $db->prepare('SELECT * FROM user WHERE token = ?');
$stmt->execute([$token]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);

if (!$user) {
	http_response_code(401);
	echo json_encode(['message' => 'Invalid token.']);
	exit;
}

echo json_encode($user);

では前述の通り、$_GETでトークンを取得します。

トークンが取れなかった場合にはエラーメッセージを返します。

$token = $_GET["token"];

if (!$token) {
	http_response_code(401);
	echo json_encode(['message' => 'Token is required.']);
	exit;
}

そして、トークンを使用してデータベースから該当するユーザーの情報を取得します。この情報は、そのトークンを持つユーザーの詳細な情報を含む一連のフィールドとなります。

トークンに対応するユーザーがデータベースに存在しない場合、エラーメッセージを返して処理を終了します。

最後に、ユーザーの情報をJSON形式で出力して完了です。

$stmt = $db->prepare('SELECT * FROM user WHERE token = ?');
$stmt->execute([$token]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);

if (!$user) {
	http_response_code(401);
	echo json_encode(['message' => 'Invalid token.']);
	exit;
}

echo json_encode($user);

ユーザー情報の更新(html/userInfo/updateUser.php)

CRUD処理のUpdateをやってみたかったので、無理矢理この機能を入れ込みました。

まあUPDATEクエリを書いただけなんですけど。。。

html/userInfo/updateUser.php
$dsn = "mysql:dbname=test_db;host=mysql8-1;port=3306";
$user = "test_user";
$password = "test_password";
$db = new PDO($dsn, $user, $password);

$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

try {
	$data = json_decode(file_get_contents('php://input'), true);
	$id = $data['id'] ?? null;
	$mail_address = $data['mail_address'] ?? null;
	$user_name = $data['user_name'] ?? null;
	$user_name_hiragana = $data['user_name_hiragana'] ?? null;
	$telephone_number = $data['telephone_number'] ?? null;
	$nickname = $data['nickname'] ?? null;


	if (!$id) {
		http_response_code(400);
		echo json_encode(['message' => 'User ID is missing.']);
		exit;
	}

	$stmt = $db->prepare('UPDATE user SET mail_address = ?, user_name = ?, user_name_hiragana = ?, telephone_number = ?, nickname = ? WHERE id = ?');
	$execution_result = $stmt->execute([
		$mail_address,
		$user_name,
		$user_name_hiragana,
		$telephone_number,
		$nickname,
		$id
	]);

	if (!$execution_result) {
		http_response_code(500);
		echo json_encode(['message' => 'Failed to update user.']);
		exit;
	}

	echo json_encode(['message' => 'ユーザー情報が更新されました!']);
	http_response_code(200);
} catch (PDOException $e) {
	http_response_code(500);
	echo json_encode(['message' => $e->getMessage()]);
}

まずリクエストの本文からJSONデータを解析し、ユーザー情報を取得します。

そしてそれぞれの変数に値を代入し、nullの場合はそのままnullを代入します。

	$data = json_decode(file_get_contents('php://input'), true);
	$id = $data['id'] ?? null;
	$mail_address = $data['mail_address'] ?? null;
	$user_name = $data['user_name'] ?? null;
	$user_name_hiragana = $data['user_name_hiragana'] ?? null;
	$telephone_number = $data['telephone_number'] ?? null;
	$nickname = $data['nickname'] ?? null;

そして、UPDATE SQL文を使って指定のユーザーのレコードを更新します。
この時、IDでユーザーを判断しています。

	$stmt = $db->prepare('UPDATE user SET mail_address = ?, user_name = ?, user_name_hiragana = ?, telephone_number = ?, nickname = ? WHERE id = ?');
	$execution_result = $stmt->execute([
		$mail_address,
		$user_name,
		$user_name_hiragana,
		$telephone_number,
		$nickname,
		$id
	]);

最後に、失敗した場合の処理と、成功したときの処理を書いて終わりです!
(長かった…!!)

	if (!$execution_result) {
		http_response_code(500);
		echo json_encode(['message' => 'Failed to update user.']);
		exit;
	}

	echo json_encode(['message' => 'ユーザー情報が更新されました!']);
	http_response_code(200);

終わりに

ここまで無駄に長いコード解説を見ていただきありがとう御座いました。
どこに需要があるのか全く分かりませんが、自分の理解度の確認も兼ねて書きました。

最近Qiita記事を書くのをサボってしまっていたので、溜まった知見などはどんどんアウトプットしていこうと思います。

それでは、本当にここまでお付き合いいただきありがとう御座いました!!!!!!!!

11
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?