【Next】Next.jsでクイズアプリを作る

Next.jsでクイズアプリを作っていきます。Typescriptも使用していきます。バージョンによってエラーが起きる可能性がありので下記のバージョンを確認してください。

- next@12.1.0
- styled-components@5.3.3
- typescript@4.6.2

![next_quiz.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2542657/76202fd5-dd2c-d66d-fea6-85d00210ead5.png)

- [デプロイ](https://next-typescript-quiz.vercel.app/)
- [コード](https://github.com/kubota20/next_typescript_quiz)


#### 参考動画

https://www.youtube.com/watch?v=F2JCjVSZlG0&t=372s

こちらの動画はReactでクイズ作ってます。Reactで作りたい場合はこちらの動画を見てください。またこの動画では画像を使用していますがここでは使用せずcssを使っていきます。

#### 外部API

[QuizAPI](https://opentdb.com)

こちらのAPIはAPIキーがいらず登録もしなくていいので簡単に使用できます。

#

では早速作っていきましょう。


```
yarn create next-app --typescript
```
中身はこんな感じにしていきます。またアプリを作るとき**styles**は省略していきますので詳しくは[コード](https://github.com/kubota20/next_typescript_quiz)を確認してください。
```
.
┝ node_modules
┝ public
┝ src
│　┝ components
│  ┝ api
│  ┝ Questions
│  ┝ utils
│ ┝ pages
│ ┝ styles
┝ .eslintrc.json
┝ .gitignore
┝ next-nev.d.ts
┝ next.config.js
┝ README.md
┝ package.json
┝ tsconfig.json
└ yarn.lock
```
インストール

```
yarn add styled-components

yarn add -D @types/styled-components

```

最初に`tscofig.jsox`を少し変えていきます。

```json:tscofig.jsox
{
  "compilerOptions": {
    "baseUrl": "src",
  }, 
}
```
これで`../`を減らせます。

- _app.tsx

```react:pages/_app.tsx
import 'styles/globals.css'
import Layout from 'components/Layout'
import type { AppProps } from 'next/app'

function MyApp({ Component, pageProps }: AppProps) {
  return (
    <Layout>
      <Component {...pageProps} />
    </Layout>
  )
}

export default MyApp
```

- _document.tsx

```react:pages/_document.tsx
import { Html, Head, Main, NextScript } from 'next/document'

const MyDocument = () => {
  return (
    <Html lang="ja-JP">
      <Head>
        <meta name="application-name" content="MyTemplate" />
        <meta name="description" content="" />
        <link
          href="https://fonts.googleapis.com/css2?family=Catamaran:wght@700&family=Fascinate+Inline&display=swap"
          rel="stylesheet"
        />
      </Head>
      <body>
        <Main />
        <NextScript />
      </body>
    </Html>
  )
}

export default MyDocument


```
- Layout.tsx

```react:components/Layout.tsx
import Head from 'next/head'

import { ReactNode } from 'react'

type Props = {
  children?: ReactNode
}

const Layout = ({ children }: Props) => {
  return (
    <div>
      <Head>
        <title>MyTemplate</title>
      </Head>

      <div className="content">{children}</div>

      <footer className=""></footer>
    </div>
  )
}

export default Layout
```
- index.tsx

```react:pages/index.tsx
import type { NextPage } from 'next'
import QuestionCard from 'components/Questions/QuestionCard'
import styles from 'styles/Home.module.css'

const Home: NextPage= () => {

 const startTrivia = async () => {}

 const nextQuestion = () => {}

 const checkAnswer = (e: any) => {}

 return (
  <div className={styles.container}>
   <h1>Next.js quiz</h1>
   <button className={styles.start}>
          start
   </button>
   <p className={styles.score}>Score:{score}</p>
   <p>Loading Questons...</p>
// <QuestionCard/>
   <button>Next Question</button>
  </div>
 )
}

export default Home
```
`QuestionCard.tsx`
```react:components/Questions/QuestionCard.tsx
import styles from 'styles/QuestionCard.module.css'
import { ButtonWrapper } from './styled'

const QuestionCard = () => {
 return (
  <div className={styles.container}>
   <p>Question: </p>
   <p/>
   <div>
    <ButtonWrapper>
      <button>
         <span/>
       </button>
     </ButtonWrapper>
   </div>
  </div>
 )
}
```

cssやstyledの中身は[コード](https://github.com/kubota20/next_typescript_quiz)を確認してコピペしてください。

- http://localhost:3000

localhostを確認できましたか?

- utiles.ts
これはクイズをランダムに表示させるための必要なものです。

```react:components/utiles/utiles.ts
export const shuffleArray = (array: any[]) =>
  [...array].sort(() => Math.random() - 0.5)
```

utilesはAPIに持っていきます。

- API.ts

```react:components/api/API.ts
import { shuffleArray } from '../utils/utils'

export type Question = {
  category: string
  correct_answer: string
  difficulty: string
  incorrect_answers: string[]
  question: string
  type: string
}

// 難易度
export enum Difficulty {
  EASY = 'easy',
  MEDIUM = 'medium',
  HARD = 'hard',
}

export type QuestionsState = Question & { answers: string[] }



export const fetchQuizQuestions = async (
  amount: number,
  difficulty: Difficulty,
): Promise<QuestionsState[]> => {

// amount はクイズの問題を表示する数を入れます。数の設定はindex.tsx　でします。
// difficulty は難易度これも設定はindex.tsx　でします。
// type=multiple は複数の種類の問題

  const endpoint = `https://opentdb.com/api.php?amount=${amount}&difficulty=${difficulty}&type=multiple`
  const data = await (await fetch(endpoint)).json()

  return data.results.map((question: Question) => ({
    ...question,
// ここでshuffleArrayでランダムに問題を表示させます
    answers: shuffleArray([
      ...question.incorrect_answers,
      question.correct_answer,
    ]),
  }))
}

```

https://opentdb.com

__Open Trivia DB__　では難易度、数、問題のタイプなど色んな組み合わせができます[API](https://opentdb.com/api_config.php)で設定できるので、是非自分で設定してみてください。

ではindex.tsxでスタート時の動きを書いていきます
- index.tsx

```react:pages/index.tsx
import { useState } from 'react'
import { fetchQuizQuestions } from '../components/api/API'
            :
// types
import { QuestionsState, Difficulty } from '../components/api/API'

export type AnswerObject = {
  question: string
  answer: string
  correct: boolean
  correctAnswer: string
}

// クイズの出題数
const TOTAL_QUESTIONS = 10

const Home: NextPage = () => {
  const [loading, setLoading] = useState(false)
  const [questions, setQuestions] = useState<QuestionsState[]>([])
  const [number, setNumber] = useState(0)
  const [userAnswers, setUserAnswers] = useState<AnswerObject[]>([])
  const [score, setScore] = useState(0)
  const [gameOver, setGameOver] = useState(true)

  const startTrivia = async () => {
    setLoading(true)
    setGameOver(false)
    const newQuestions = await fetchQuizQuestions(
      TOTAL_QUESTIONS,
      Difficulty.EASY,
    )
    setQuestions(newQuestions)
    setScore(0)
    setUserAnswers([])
    setNumber(0)
    setLoading(false)
  }

  const nextQuestion = () => {}

  const checkAnswer = (e: any) => {}

  return (
    <div className={styles.container}>
      <h1>Next.js quiz</h1>
       {gameOver || userAnswers.length === TOTAL_QUESTIONS ? (
        <button className={styles.start} onClick={startTrivia}>
          start
        </button>
      ) : null}
// !gameOverで間違いたらスコアをカウントしない
       {!gameOver ? <p className={styles.score}>Score:{score}</p> : null}
       {loading ? <p>Loading Questons...</p> : null}

        :
    </div>
  )
}
```

__startTrivia__ ではstartボタンを押したときにクイズのデータを持ってきて`setScore`で０から始め`setNumber`でカウントしていきます。最後に`setLoading(false)`にしましょう。

__checkAnswer__ の中身を書いていきます。

```react:pages/index.tsx

const checkAnswer = (e: any) => {
    if (!gameOver) {
      const answer = e.currentTarget.value
      // 正解かの確認をしています
      const correct = questions[number].correct_answer === answer
      // 答えが正しければスコアを追加
      if (correct) setScore((prev) => prev + 1)
      // ユーザーの回答を保存します      
      const answerObject = {
        question: questions[number].question,
        answer,
        correct,
        correctAnswer: questions[number].correct_answer,
       }
      setUserAnswers((prev) => [...prev, answerObject])
    }
  }

 return( 
              :
              :
        
      {!loading && !gameOver && (
        <QuestionCard
          questionNr={number + 1}
          totalQuestions={TOTAL_QUESTIONS}
          question={questions[number].question}
          answers={questions[number].answers}
          userAnswer={userAnswers ? userAnswers[number] : undefined}
          callback={checkAnswer}
        />
      )}
 )

```

__checkAnswer__ の変数は __QuestionCard__ に渡します。

__nextQuestion__ を書いていきます。

```react:pages/index.tsx
const nextQuestion = () => {
    // 10問までNext Questionボタンを押せるようにします。
    const nextQ = number + 1

    if (nextQ === TOTAL_QUESTIONS) {
      setGameOver(true)
    } else {
      setNumber(nextQ)
    }
  }

 return (
              :
              :


     {!gameOver &&
      !loading &&
      userAnswers.length === number + 1 &&
      number !== TOTAL_QUESTIONS - 1 ? (
        <button onClick={nextQuestion}>Next Question</button>
      ) : null}
 )
```

<details><summary>index.tsx全体のコード</summary>



```react:index.tsx
import { useState } from 'react'
import type { NextPage } from 'next'
import { fetchQuizQuestions } from '../components/api/API'
// components
import QuestionCard from 'components/Questions/QuestionCard'
// styles
import styles from 'styles/Home.module.css'
// types
import { QuestionsState, Difficulty } from '../components/api/API'

export type AnswerObject = {
  question: string
  answer: string
  correct: boolean
  correctAnswer: string
}

const TOTAL_QUESTIONS = 10

const Home: NextPage = () => {
  const [loading, setLoading] = useState(false)
  const [questions, setQuestions] = useState<QuestionsState[]>([])
  const [number, setNumber] = useState(0)
  const [userAnswers, setUserAnswers] = useState<AnswerObject[]>([])
  const [score, setScore] = useState(0)
  const [gameOver, setGameOver] = useState(true)

  const startTrivia = async () => {
    setLoading(true)
    setGameOver(false)
    const newQuestions = await fetchQuizQuestions(
      TOTAL_QUESTIONS,
      Difficulty.EASY,
    )
    setQuestions(newQuestions)
    setScore(0)
    setUserAnswers([])
    setNumber(0)
    setLoading(false)
  }

  const checkAnswer = (e: any) => {
    if (!gameOver) {
      const answer = e.currentTarget.value
      const correct = questions[number].correct_answer === answer
      if (correct) setScore((prev) => prev + 1)
      const answerObject = {
        question: questions[number].question,
        answer,
        correct,
        correctAnswer: questions[number].correct_answer,
      }
      setUserAnswers((prev) => [...prev, answerObject])
    }
  }

  const nextQuestion = () => {
    const nextQ = number + 1

    if (nextQ === TOTAL_QUESTIONS) {
      setGameOver(true)
    } else {
      setNumber(nextQ)
    }
  }

  return (
    <div className={styles.container}>
      <h1>Next.js quiz</h1>
      {gameOver || userAnswers.length === TOTAL_QUESTIONS ? (
        <button className={styles.start} onClick={startTrivia}>
          start
        </button>
      ) : null}
      {!gameOver ? <p className={styles.score}>Score:{score}</p> : null}
      {loading ? <p>Loading Questons...</p> : null}
      {!loading && !gameOver && (
        <QuestionCard
          questionNr={number + 1}
          totalQuestions={TOTAL_QUESTIONS}
          question={questions[number].question}
          answers={questions[number].answers}
          userAnswer={userAnswers ? userAnswers[number] : undefined}
          callback={checkAnswer}
        />
      )}
      {!gameOver &&
      !loading &&
      userAnswers.length === number + 1 &&
      number !== TOTAL_QUESTIONS - 1 ? (
        <button onClick={nextQuestion}>Next Question</button>
      ) : null}
    </div>
  )
}

export default Home

```

</details>

__QuestionCard__ を作れば完成です。

```react:components/Questions/QuestionCard.tsx

import { AnswerObject } from 'pages/index'
// styles
import styles from 'styles/QuestionCard.module.css'
import { ButtonWrapper } from './styled'

type Props = {
  question: string
  answers: string[]
  callback: (e: React.MouseEvent<HTMLButtonElement>) => void
  userAnswer: AnswerObject | undefined
  questionNr: number
  totalQuestions: number
}

const QuestionCard = ({
  question,
  answers,
  callback,
  userAnswer,
  questionNr,
  totalQuestions,
}: Props) => {
  return (
    <div className={styles.container}>
      <p>
        {' '}
        Question: {questionNr} / {totalQuestions}
      </p>
      <p dangerouslySetInnerHTML={{ __html: question }} />
      <div>
        {answers.map((answer) => (
          <ButtonWrapper
            key={answer}
            correct={userAnswer?.correctAnswer === answer}
            userClicked={userAnswer?.answer === answer}
          >
            <button
              disabled={userAnswer ? true : false}
              value={answer}
              onClick={callback}
            >
              <span dangerouslySetInnerHTML={{ __html: answer }} />
            </button>
          </ButtonWrapper>
        ))}
      </div>
    </div>
  )
}

export default QuestionCard
```

http://localhost:3000 を開いて確かめてください。