はじめまして、mamiと申します。
タイトルの通り、プログラミング初心者の私がポートフォリオ用の診断アプリを作ったので紹介させていただきます。
下記で詳しく説明していますが、このアプリはバックエンドはnode.jsを使ってDBとのやり取りをおこなっております。
バックエンドの方はまた別で解説させていただくので、今回はフロント部分を解説していきます!
未経験ゆえに、型付けが甘かったり技術的に未熟だなと思われる部分もあるかと思いますが、ご指摘点があればコメントしてもらえるとうれしいです。
それでは早速、行ってみましょう!!
作成したものの紹介
WineChecker
(GitHub👇)
先日までHerokuへデプロイしていたんですが、有料になってしまったので今は非公開となっております。。
なのでGifでお許しを。
機能としては
・自分に合ったおすすめワインの診断
・DBを活用したおすすめ一覧
ディレクトリ構成図
wine-next
├- layout-DefaultLayout.tsx
├--- pages -┐
│ ├- api--hello.ts
│ ├- index.tsx
│ ├- _app.tsx
│ ├- _document.tsx
│ └- shindan ┐
│ ├ [id].tsx
│ ├ aka.tsx
│ ├ index.tsx
│ ├ recommend.tsx
│ ├ recommendPages.tsx
│ ├ resultAka.tsx
│ ├ resultShiro.tsx
│ └ shiro.tsx
├--- redux -- hook.ts
│ ├ store.ts
│ └ reducer --- question.ts
│
├--- pakage.json
├--- theme.tsx
└--- tsconfig.json
package.json
ライブラリなどインストールしたものはこちらを参考に
{
"name": "wine-next",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@emotion/react": "^11.10.4",
"@emotion/server": "^11.10.0",
"@emotion/styled": "^11.10.4",
"@mui/material": "^5.10.5",
"@reduxjs/toolkit": "^1.8.5",
"@types/ua-parser-js": "^0.7.36",
"axios": "^0.27.2",
"cors": "^2.8.5",
"css-mediaquery": "^0.1.2",
"express": "^4.18.1",
"express-graphql": "^0.12.0",
"graphql": "^15.8.0",
"mysql": "^2.18.1",
"next": "12.3.0",
"pg": "^8.8.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-redux": "^8.0.2",
"react-responsive": "^9.0.0",
"reflect-metadata": "^0.1.13",
"typeorm": "^0.3.10",
"typescript": "^4.8.3",
"ua-parser-js": "^1.0.2"
},
"devDependencies": {
"@types/express": "^4.17.14",
"@types/mysql": "^2.15.21",
"@types/node": "^18.7.17",
"@types/pg": "^8.6.5",
"@types/react": "18.0.19",
"@types/react-dom": "18.0.6",
"eslint": "8.23.1",
"eslint-config-next": "12.3.0",
"ts-node": "^10.9.1",
"tsconfig-paths": "^4.1.0"
}
}
TOPページの作成(index.tsx)
pages配下に大元のTopページを作っていきます。
基本的にこのページはデザインの表示のみなので、解説するような処理はしていません。
強いて言うならuseMediaQuery
を使用してレスポンシブ設定をしています。
三項演算子を利用して767px以下であればレスポンシブを、という風に記述しております。
(完成した後で思いましたが、この記述方法だとPCの表示と大きく異なる表示もできるというメリットがあるんですが、同じ表示のものまでPCとSPで2回書かないといけないので記述が多くなってしまうのが難点ですね。。
次回開発の際にはその辺も考慮していきたいですね)
import type { NextPage } from 'next'
import Head from 'next/head'
import {DefaultLayout} from "../layout/DefaultLayout";
import {Card, CardActionArea, CardContent, CardHeader, CardMedia, Grid, Typography, Button} from "@mui/material";
import Link from "next/link";
import { relative } from 'path';
import { useAppDispatch } from '../redux/hook';
import { resetAnswers } from '../redux/reducer/question';
import React from 'react';
import useMediaQuery from "@mui/material/useMediaQuery";
const Home: NextPage = () => {
//resetの処理を宣言
const dispatch = useAppDispatch();
React.useEffect(() => { //ページを開いた時にリセットする
dispatch(resetAnswers());
}, []);
//レスポンシブ設定を定義
const matches = useMediaQuery("(min-width:767px)");
return (
<DefaultLayout>
{matches ? (
<>
<Grid container spacing={2} sx={{}}>
<div style={{position: "absolute" ,top: "50%", left: "50%", transform: "translate(-50%,-50%)",fontFamily: "Comic Sans MS", }}>
<h1 style={{
fontSize: "6rem",
textShadow: "0 0 0.1em rgba(255,255,255,0.05),0.01em 0.04em 0.03em rgba(255,255,255,0.4)",
color: "transparent",
fontWeight: "bold",
background : "rgba(0,0,0,1)",
WebkitBackgroundClip : "text" }}>
30秒でワイン診断!
</h1>
<p style={{ paddingBottom: "30px", fontSize: "2.5rem", fontFamily: "Hannotate SC", fontWeight: "bold", textShadow: "-1px -1px 0 white, -1px 0 0 white, -1px 1px 0 white,0 -1px 0 white,0 1px 0 red,1px -1px 0 white, 1px 0 0 white, 1px 1px 0 white" }}>
会員登録不要!利用ももちろん無料!<br></br>
5つの質問に答えるだけで、あなたの<br></br>
好みのワインをお探し致します!</p>
<Link href={"/shindan"}>
<Button
sx={{
fontSize: "2.5rem",
fontFamily: "Hannotate SC",
width: "350px",
color: "#333",
fontWeight: 700,
backgroundImage: "linear-gradient(170deg, #659de6, #5abab8)",
borderRadius: "50vh",
transition: "0.3s",
":hover":{opacity:0.8}
}}>
今すぐ診断
</Button>
</Link>
</div>
</Grid>
</>
) : (
<>
<Grid container spacing={2} sx={{}}>
<div style={{position: "absolute" ,top: "65%", left: "50%", transform: "translate(-50%,-50%)",fontFamily: "Comic Sans MS", }}>
<h1 style={{
fontSize: "3.5rem",
textShadow: "0 0 0.1em rgba(255,255,255,0.05),0.01em 0.04em 0.03em rgba(255,255,255,0.4)",
color: "transparent",
fontWeight: "bold",
background : "rgba(0,0,0,1)",
WebkitBackgroundClip : "text" }}>
30秒でワイン診断!
</h1>
<p style={{ paddingBottom: "30px", fontSize: "1.8rem", fontFamily: "Hannotate SC", fontWeight: "bold", textShadow: "-1px -1px 0 white, -1px 0 0 white, -1px 1px 0 white,0 -1px 0 white,0 1px 0 red,1px -1px 0 white, 1px 0 0 white, 1px 1px 0 white" }}>
会員登録不要!<br></br>
利用ももちろん無料!<br></br>
5つの質問に答える<br></br>
だけであなたの好みの<br></br>
ワインをお探し致します!</p>
<Link href={"/shindan"}>
<Button
sx={{
fontSize: "2rem",
fontFamily: "Hannotate SC",
width: "300px",
color: "#333",
fontWeight: 700,
backgroundImage: "linear-gradient(170deg, #659de6, #5abab8)",
borderRadius: "50vh",
transition: "0.3s",
marginBottom: "100px"
}}>
今すぐ診断
</Button>
</Link>
</div>
</Grid>
</>
)}
</DefaultLayout>
)
}
export default Home;
選択ページの作成(shinsan/index.ts)
こちらも基本的にデザインのみなのでTOPページとほぼ同じです。
import React from "react";
import {NextPage} from "next";
import {DefaultLayout} from "../../layout/DefaultLayout";
import {Button, Typography,Box} from "@mui/material";
import Link from "next/link";
import {useAppDispatch} from "../../redux/hook";
import {resetAnswers} from "../../redux/reducer/question";
import useMediaQuery from "@mui/material/useMediaQuery";
const Shindan: NextPage = () => {
//resetの処理を宣言
const dispatch = useAppDispatch();
React.useEffect(() => { //ページを開いた時にリセットする
dispatch(resetAnswers());
}, []);
//レスポンシブ設定を定義
const matches = useMediaQuery("(min-width:767px)");
return (
<DefaultLayout>
{matches ? (
<>
<div style={{position: "absolute" ,top: "50%", left: "50%", transform: "translate(-50%,-50%)",fontFamily: "Comic Sans MS",paddingBottom: "150px" }}>
<Typography variant={"h5"}>
<Box style={{
paddingBottom: "60px",
fontSize: "6rem",
textShadow: "0 0 0.1em rgba(255,255,255,0.05),0.01em 0.04em 0.03em rgba(255,255,255,0.4)",
color: "transparent",
fontWeight: "bold",
background : "rgba(0,0,0,1)",
WebkitBackgroundClip : "text"
}}>
おすすめワイン診断
</Box>
</Typography>
<Link href={"/shindan/aka"} >
<Button variant={"contained"}
sx={{
marginRight:"30px",
fontSize: "1.8rem",
fontFamily: "Hannotate SC",
width: "320px",
color: "#DC143C",
fontWeight: 700,
backgroundImage: "linear-gradient(170deg, #659de6, #5abab8)",
borderRadius: "50vh",
transition: "0.3s",
":hover":{opacity:0.8}
}}>
赤ワインで診断
</Button>
</Link>
<Link href={"/shindan/shiro"}>
<Button variant={"contained"}
sx={{
fontSize: "1.8rem",
fontFamily: "Hannotate SC",
width: "320px",
color: "white",
fontWeight: 700,
backgroundImage: "linear-gradient(170deg, #659de6, #5abab8)",
borderRadius: "50vh",
transition: "0.3s",
":hover":{opacity:0.8}
}}>
白ワインで診断
</Button>
</Link>
</div>
</>
) : (
<>
<div style={{position: "absolute" ,top: "60%", left: "50%", transform: "translate(-50%,-50%)",fontFamily: "Comic Sans MS",paddingBottom: "150px" }}>
<Typography variant={"h5"}>
<Box style={{
paddingBottom: "60px",
fontSize: "3.5rem",
textShadow: "0 0 0.1em rgba(255,255,255,0.05),0.01em 0.04em 0.03em rgba(255,255,255,0.4)",
color: "transparent",
fontWeight: "bold",
background : "rgba(0,0,0,1)",
WebkitBackgroundClip : "text"
}}>
おすすめ<br></br>
ワイン診断
</Box>
</Typography>
<Link href={"/shindan/aka"} >
<Button variant={"contained"}
sx={{
fontSize: "1.5rem",
fontFamily: "Hannotate SC",
width: "250px",
marginBottom: "20px",
color: "#DC143C",
fontWeight: 700,
backgroundImage: "linear-gradient(170deg, #659de6, #5abab8)",
borderRadius: "50vh",
transition: "0.3s"
}}>
赤ワインで診断
</Button>
</Link>
<Link href={"/shindan/shiro"}>
<Button variant={"contained"}
sx={{
fontSize: "1.5rem",
fontFamily: "Hannotate SC",
width: "250px",
color: "white",
fontWeight: 700,
backgroundImage: "linear-gradient(170deg, #659de6, #5abab8)",
borderRadius: "50vh",
transition: "0.3s",
":hover":{opacity:0.8}
}}>
白ワインで診断
</Button>
</Link>
</div>
</>
)}
</DefaultLayout>
)
}
export default Shindan;
Reduxの設定
状態管理はRedux Toolkitを使用して管理しています。
診断ページを作成する前に状態管理をしてくれるReduxを記述していきましょう。
storeの作成
import {configureStore} from "@reduxjs/toolkit";
import question from "./reducer/question";
import { createStore, combineReducers } from "redux";
export const store = configureStore({
reducer: {
question,
}
})
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
hookの作成
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の作成
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=変数、初期値や値を更新する関数
name: "question",
initialState,
reducers: {
// 回答した時の処理
answerQuestion: (state, action: PayloadAction<{ //PayloadAction = 起こったイベント
value: number;
}>) => {
// 配列に回答を追加
state.answers = [...state.answers, action.payload.value]; //action.payload = アクションに必要なデータ
// 質問番号をインクリメント
state.questionNum += 1; //1を加算して代入
state.totalPoint += action.payload.value; //action.payloadの値を加算して代入
state.rank = state.totalPoint < 5 //トータルポイントが5以上の時rank0の処理へ
? 0
: state.totalPoint < 7 //rankが1の処理へ
? 1
: state.totalPoint < 9
? 2
: 3;
},
// resetの処理を定義
resetAnswers: (state) => { //stateにinitialState(stateの初期値)を代入
return initialState;
},
}
})
export const {answerQuestion, resetAnswers} = questionSlice.actions;
export default questionSlice.reducer;
赤ワインの診断ページ作成(shindan/aka.tsx)
ここからいよいよプログラミンらしい処理をしていきます。
診断の処理になるのでメインの部分ですね。
細かい説明は個別で下記で詳しく解説します。
import React from "react";
import {NextPage} from "next";
import {DefaultLayout} from "../../layout/DefaultLayout";
import {Button, CircularProgress, Divider, Grid,Typography} from "@mui/material";
import {useAppDispatch, useAppSelector} from "../../redux/hook";
import {questionsDef, totalQuestionCount} from "../../definitions/consts";
import {answerQuestion} from "../../redux/reducer/question";
import {useRouter} from "next/router";
import useMediaQuery from "@mui/material/useMediaQuery";
export const Aka: NextPage = () => {
const router = useRouter();
const questionNum = useAppSelector((state: { question: { questionNum: number; }; }) => state.question.questionNum);
const dispatch = useAppDispatch();
const matches = useMediaQuery("(min-width:767px)"); //レスポンシブ設定を定義
// 現在の質問(indexが配列長を超えた場合はundefined)
const currentQuestion = questionsDef?.[questionNum];
// 全ての質問を回答したかどうか
const finished = questionNum >= questionsDef.length;
//タイムアウト時の処理
React.useEffect(() => {
if (finished) {
setTimeout(() => {
router.push("/shindan/resultAka");
}, 2000);
}
}, [finished]);
return (
<DefaultLayout >
{matches ? (
<>
{finished ? (
<>
<div style={{position: "absolute" ,top: "50%", left: "50%", transform: "translate(-50%,-50%)",fontFamily: "Comic Sans MS", }}>
<Typography variant={"h5"} style={{fontSize: "3rem"}}>
Done!
</Typography>
<CircularProgress sx={{mt: 3}} />
</div>
</>
) : (
<>
<div style={{
position: "absolute" ,
top: "50%",
left: "50%",
transform: "translate(-50%,-50%)",
fontFamily: "Comic Sans MS",
}}>
<Typography variant={"h5"} style={{fontSize: "3rem"}}>
質問({questionNum + 1}/{totalQuestionCount}) {/* 質問残数の表示 */}
</Typography>
<Divider sx={{mt: 2, mb: 4}} />
<Typography variant={"h5"} style={{fontSize: "5rem"}}>
{currentQuestion.q} {/* 質問内容 */}
</Typography>
<Grid container sx={{mt: 5}} spacing={2} style={{marginLeft: "auto",marginRight: "auto"}}>
<Grid item xs={6}>
<Button
variant={"outlined"}
fullWidth
onClick={() => {
dispatch(answerQuestion({value: 1}));
}}
style={{fontSize: "2rem"}}
>
{currentQuestion.a1} {/* 回答1 */}
</Button>
</Grid>
<Grid item xs={6}>
<Button
variant="contained"
fullWidth
onClick={() => {
dispatch(answerQuestion({value: 2}));
}}
style={{fontSize: "2rem"}}
>
{currentQuestion.a2} {/* 回答2 */}
</Button>
</Grid>
</Grid>
</div>
</>
)}
</>
) : (
<>
{finished ? (
<>
<div style={{position: "absolute" ,top: "50%", left: "50%", transform: "translate(-50%,-50%)",fontFamily: "Comic Sans MS", }}>
<Typography variant={"h5"} style={{fontSize: "3rem"}}>
Done!
</Typography>
<CircularProgress sx={{mt: 3}} />
</div>
</>
) : (
<>
<div style={{position: "absolute" ,top: "55%", left: "50%", transform: "translate(-50%,-50%)",fontFamily: "Comic Sans MS", }}>
<Typography variant={"h5"} style={{fontSize: "2rem", paddingTop: "40px"}}>
質問({questionNum + 1}/{totalQuestionCount}) {/* 質問残数の表示 */}
</Typography>
<Divider sx={{mt: 2, mb: 4}} />
<Typography variant={"h5"} style={{fontSize: "2.5rem"}}>
{currentQuestion.q} {/* 質問内容 */}
</Typography>
<Grid container sx={{mt: 5}} spacing={1} style={{marginLeft: "auto",marginRight: "auto"}}>
<Grid item xs={60}>
<Button
variant={"outlined"}
fullWidth
onClick={() => {
dispatch(answerQuestion({value: 1}));
}}
style={{fontSize: "1.4rem"}}
>
{currentQuestion.a1} {/* 回答1 */}
</Button>
</Grid>
<Grid item xs={60}>
<Button
variant="contained"
fullWidth
onClick={() => {
dispatch(answerQuestion({value: 2}));
}}
style={{fontSize: "1.4rem"}}
>
{currentQuestion.a2} {/* 回答2 */}
</Button>
</Grid>
</Grid>
</div>
</>
)}
</>
)}
</DefaultLayout>
)
}
export default Aka;
①useRouterの定義・質問番号の管理・dispatchの定義
- ルーティングの設定。
useRouter
の定義。
後のif文でリザルト画面にルーティングさせるための定義。 -
questionNum
(質問の番号)をuseAppSelector
で管理(reduxの機能)。
useAppSelector
は各UIコンポーネントでstateを持ってくることに使われます。
つまりここの処理はquestionNum
をstate で管理させる記述。 -
dispatch
の定義。
Reduxの話になりますが、そもそもdispatchとはactionをstoreまで伝播させる関数(操作)のこと。
ここではその定義のみ最初に記述。
const router = useRouter();
const questionNum = useAppSelector((state: { question: { questionNum: number; }; }) => state.question.questionNum);
const dispatch = useAppDispatch();
②現在の質問確認・全ての質問に回答したか
- 現在の質問
indexが配列長を超えた場合はundefined
になるように記述。
下の質問の表示で使うcurrentQuestion
を定義。 - 全ての質問を回答したかどうか
質問に全部回答したかlengthで計測。
questionNum
がquestionsDef
の数を超えたらfinished
の状態を付与。
// 現在の質問(indexが配列長を超えた場合はundefined)
const currentQuestion = questionsDef?.[questionNum];
// 全ての質問を回答したかどうか
const finished = questionNum >= questionsDef.length;
ちなみに質問内容は 下記definitions/consts.tsファイルに記述。
export const questionsDef = [
{
q: "洋食と和食どっちが好きですか?",
a1: "洋食",
a2: "和食"
},
{
q: "料理の好みは?",
a1: "こってり派",
a2: "さっぱり派"
},
{
q: "チョコレートはどっちが好き?",
a1: "高カカオのビターチョコレート",
a2: "甘くてまろやかなミルクチョコレート"
},
{
q: "季節を感じるお酒がいい",
a1: "気にしない",
a2: "もちろん"
},
{
q: "料理と一緒にワインを味わいたい",
a1: "もちろん",
a2: "別がいい"
}];
③finished時の処理とタイムアウト時の処理
useEffect
でfinishedを発火のタイミングとして、上記でルーティングしておいた/shindan/resultAka
へ飛ばせる処理。
2000秒を超えたら自動的にリザルト画面へ行かせる。
React.useEffect(() => {
if (finished) {
setTimeout(() => {
router.push("/shindan/resultAka");
}, 2000);
}
}, [finished]);
④return内の処理。三項演算子を追加(回答終了した時の処理と、質問内容を表示する処理)
長いので簡潔に。
三項演算子でfinished
がture
の場合は「回答終了した時の処理」。false
の場合は「質問内容を表示する処理」で分岐させます。
return (
<>
{finished ? (
<>
回答終了した時の処理
</>
) : (
<>
質問内容を表示する処理
</>
)}
</>
)
⑤質問内容の表示
<Grid>
<Typography>
質問({questionNum + 1}/{totalQuestionCount}) {/* 質問残数の表示 */}
</Typography>
<Typography>
{currentQuestion.q} {/* 質問内容 */}
</Typography>
<Grid>
<Grid>
<Button>
{currentQuestion.a1} {/* 回答1 */}
</Button>
</Grid>
<Grid>
<Button onClick={() => {
dispatch(answerQuestion({value: 2}));
}}>
{currentQuestion.a2} {/* 回答2 */}
</Button>
</Grid>
</Grid>
- ボタンの処理
回答2を押した場合のみカウントが増加する仕組みを記述。
<Button onClick={() => {
dispatch(answerQuestion({value: 2}));
}}>
{currentQuestion.a2} {/* 回答2 */}
</Button>
4.白ワインの診断ページ作成(shindan/shiro.tsx)
こちらは文言が少し違うだけでやってる処理は赤ワインとほぼ一緒です。
長くなってしまうので割愛します。もしこの白ワインのコードも見てみたい方はGitの方を確認してみてください!
5.赤ワインの診断結果ページ作成(shindan/resultAka.tsx)
import React from "react";
import {NextPage} from "next";
import {DefaultLayout} from "../../layout/DefaultLayout";
import {Button, Divider, Grid,Paper, Typography,CardMedia,Box} from "@mui/material";
import {useAppSelector} from "../../redux/hook";
import {resultMessageDef} from "../../definitions/consts";
import Link from "next/link";
import useMediaQuery from "@mui/material/useMediaQuery";
import axios from "axios";
export const ResultAka: NextPage = () => {
const totalPoint = useAppSelector(state => state.question.totalPoint); //totalPointにquestion+totalPointを代入
const rank = useAppSelector(state => state.question.rank); //rankにquestion+rankを代入
const backendBaseUrl = "https://wine-app-express.herokuapp.com/";
const matches = useMediaQuery("(min-width:767px)"); //レスポンシブ設定を定義
const [wineList, setWineList] = React.useState(null);
const [loading ] = React.useState(false);
// バックエンドとの繋ぎ込み
React.useEffect(() => {
axios.get("https://wine-app-express.herokuapp.com/wines", {})
.then((res) => {
const { result, data } = res.data;
if (result === "SUCCESS") {
setWineList(data);
}
if (loading) {
return <>loading...</>;
}
});
}, []);
const resultMessage = wineList?.[rank]; //rankに応じた結果の文言
const resultImage = wineList?.[rank];
const Message = resultMessageDef?.[rank]; //rankに応じた結果の文言
return (
<DefaultLayout>
{matches ? (
<>
<div style={{width: "100%",top: "55%", left: "50%",}}>
<Paper sx={{padding: 5}}>
<Typography variant={"h5"} style={{fontSize: "3rem"}}>
診断結果
</Typography>
<Divider sx={{mt: 2, mb: 4}}/>
<Typography variant={"h5"} >
<p style={{fontSize: "2rem", lineHeight: "0.2em"}}>あなたにおすすめのワインは…</p>
<p style={{fontSize: "2rem", fontWeight: "bold"}}>{Message}</p>
<p style={{fontSize: "3rem", fontWeight: "bold", lineHeight: "1.1em"}}>{resultMessage?.name}</p>
<p style={{fontSize: "2rem", fontWeight: "bold", lineHeight: "1.1em"}}>{resultMessage?.oneWord}</p>
</Typography>
<Box style={{height:"50%",width: "auto", margin: "0px auto",}}>
<CardMedia
component="img"
image={`${backendBaseUrl}${resultImage?.image.src}`}
sx={{height:"auto", width:"8%", margin: "0px auto"}}
/>
</Box>
<Grid container sx={{mt: 5}} spacing={2} style={{display: "flex", justifyContent: "center"}}>
<Grid item xs={3} >
<Link href={"/shindan"}>
<Button
style={{fontSize: "1.8rem", color:"#fff", backgroundColor: "#DDA0DD", borderRadius: "100vh", padding:15, paddingRight:"50px", paddingLeft:"50px"}}
sx={{":hover":{opacity:0.8}}}>
もう一度
</Button>
</Link>
</Grid>
<Grid item xs={3} >
<Link href={"/"}>
<Button
style={{fontSize: "1.8rem", color:"#fff", backgroundColor: "#9370DB", borderRadius: "100vh", padding:15}}
sx={{":hover":{opacity:0.8}}}>
トップページに戻る
</Button>
</Link>
</Grid>
</Grid>
</Paper>
</div>
</>
) : (
<>
<div style={{width: "100%",top: "55%", left: "50%",}}>
<Paper sx={{padding: 5}}>
<Typography variant={"h5"} style={{fontSize: "2rem"}}>
診断結果
</Typography>
<Divider sx={{mt: 2, mb: 4}}/>
<Typography variant={"h5"} >
<p style={{fontSize: "1.3rem"}}>あなたにおすすめのワインは…</p>
<p style={{fontSize: "1.5rem", fontWeight: "bold"}}>{Message}</p>
<p style={{fontSize: "2.5rem", fontWeight: "bold", lineHeight: "1.1em"}}>{resultMessage?.name}</p>
<p style={{fontSize: "2rem", fontWeight: "bold", lineHeight: "1.1em"}}>{resultMessage?.oneWord}</p>
</Typography>
<Box style={{height:"50%",width: "auto", margin: "0px auto",}}>
<CardMedia
component="img"
image={`${backendBaseUrl}${resultImage?.image.src}`}
sx={{height:"auto", width:"50%", margin: "0px auto"}}
/>
</Box>
<Grid container sx={{mt: 5}} spacing={2} style={{display:"flex", flexFlow: "column", justifyContent:"space-around",paddingLeft:50}}>
<Grid item xs={3} >
<Link href={"/shindan"}>
<Button
style={{fontSize: "1.5rem", color:"#fff", backgroundColor: "#DDA0DD", borderRadius: "100vh", padding:15, paddingRight:"50px", paddingLeft:"50px",width: "250px"}}>
もう一度
</Button>
</Link>
</Grid>
<Grid item xs={3} >
<Link href={"/"}>
<Button
style={{fontSize: "1.5rem", color:"#fff", backgroundColor: "#9370DB", borderRadius: "100vh", padding:15,width: "250px"}}>
トップページに戻る
</Button>
</Link>
</Grid>
</Grid>
</Paper>
</div>
</>
)}
</DefaultLayout>
)
}
export default ResultAka;
①rankの定義
そもそもですが、この診断アプリは結果の方式をポイント形式で分岐させています。
なので、答えた回答に応じてポイントを付与しています。
その結果をrankで定義していきます。
useAppSelector
は redux/hook.ts
で定義しているuseSelector
です。
map state to props
に相当するのが useSelector
です。
useSelector
は、stateの必要な部分を返す関数の引数を受け取ります。
ここではrankにquestion+rankを代入。
const rank = useAppSelector(state => state.question.rank); //rankにquestion+rankを代入
②バックエンドとの繋ぎ込みの設定
まずバックエンドから持ってきたワインの情報を管理するwineList
をuseState
で定義。
同様にloading
の処理も定義。
次にaxios でバックエンドの情報を取得。
レスポンスを"SUCCESS”の場合はsetWineList
を返し、loadingの場合はloading
を返す。
("SUCCESS”はバックエンドで事前に定義)
const [wineList, setWineList] = React.useState(null);
const [loading ] = React.useState(false);
// バックエンドとの繋ぎ込み
React.useEffect(() => {
axios.get("https://wine-app-express.herokuapp.com/wines", {})
.then((res) => {
const { result, data } = res.data;
if (result === "SUCCESS") {
setWineList(data);
}
if (loading) {
return <>loading...</>;
}
});
}, []);
③返すメッセージの定義
wineList から取得した情報を rank に応じた結果の文言を表示させる処理です。
const resultMessage = wineList?.[rank]; //rankに応じた結果の文言
const resultImage = wineList?.[rank];
const Message = resultMessageDef?.[rank]; //rankに応じた結果の文言
④return内を記述。
<Typography >
<p >あなたにおすすめのワインは…</p>
<p >{Message}</p>
<p >{resultMessage?.name}</p>
<p >{resultMessage?.oneWord}</p>
</Typography>
上記で定義した文言を配置していく。
name
やoneWord
はバックエンド側で定義。
画像の表示は下記のように記述。
<CardMedia
component="img"
image={`${backendBaseUrl}${resultImage?.image.src}`}
/>
バックエンドのソースを定義し、resrtImage
から画像のURLを取ってくる。
上記でリザルト画面の処理完了。診断ページはこれで終わりです!
白ワインも同じように定義にして完了ですね!
6.おすすめ一覧ページ作成(shindan/recommend.tsx)
import type { NextPage } from 'next';
import Head from 'next/head';
import {DefaultLayout} from "../../layout/DefaultLayout";
import {Card, CardActionArea, CardContent, CardHeader, CardMedia, Grid, Typography, Button} from "@mui/material";
import Link from "next/link";
import useMediaQuery from "@mui/material/useMediaQuery";
import React from 'react';
import axios from "axios";
const backendBaseUrl = "https://wine-app-express.herokuapp.com/";
//ワインの品種等掲載
const WineCard: React.FC<{
data: any;
}> = ({data}) => {
// PCならtrue, mobileならfalse
const matches = useMediaQuery("(min-width:767px)");
return (
<Link href={`/shindan/${data.id}`}>
<Card sx={{textAlign: "left",height:370}}>
<CardActionArea>
<Grid sx={{height:"100",width:"200"}}>
<CardMedia
component="img"
height="200"
image={`${backendBaseUrl}${data.image.src}`}
sx={{height:"100", width:"auto",marginLeft: 16,}}
/>
</Grid>
<CardContent>
<Typography gutterBottom>
{data.winery?.name}
</Typography>
<Typography variant="h6">
{data.name}
</Typography>
<Typography >
{data.wineTypes &&
data.wineTypes.map((wineType) => wineType.name).join(", ")}
</Typography>
<Typography variant="body2">
{/* {data.description} */}
</Typography>
</CardContent>
</CardActionArea>
</Card>
</Link>
);
}
const Recommend: NextPage = () => {
// PCならtrue, mobileならfalse
const matches = useMediaQuery("(min-width:767px)");
//バックエンドとの繋ぎ込み処理
const [loading , setLoading] = React.useState(false);
const [wineList, setWineList] = React.useState(undefined);
React.useEffect(() => {
setLoading(true);
axios.get("https://wine-app-express.herokuapp.com//wines", {
// headers: {
// "Content-Type": "application/x-www-form-urlencoded",
// "Access-Control-Allow-Origin": "*",
// },
})
.then((res) => {
const { result, data } = res.data;
if (result === "SUCCESS") {
setWineList(data);
}
setLoading(false);
});
}, []);
return (
<DefaultLayout>
{matches ? (
<>
<div style={{width: "100%"}}>
<Grid container spacing={2} style={{width: "80%", margin: "0px auto", marginTop:50, marginBottom: 70 }}>
{loading ? (
<>Loading中</>
) : (
<>
{wineList?.map((wine) => (
<Grid item xs={3} key={wine.id}>
<WineCard data={wine} />
</Grid>
))}
</>
)}
</Grid>
</div>
</>
) : (
<>
<div style={{width: "100%",height: "100%"}}>
<Grid container spacing={2} style={{width: "80%", margin: "0px auto", marginTop:10, marginBottom: 70 }}>
{loading ? (
<>Loading中</>
) : (
<>
{wineList?.map((wine) => (
<Grid item xs={12} key={wine.id}>
<WineCard data={wine} />
</Grid>
))}
</>
)}
</Grid>
</div>
</>
)}
</DefaultLayout>
)
}
export default Recommend;
①リザルト画面と同様にバックエンドとの繋ぎ込み
リザルト画面の時の処理とほぼ同様。
②map
でワインを並べる
{wineList?.map((wine) => (
<Grid item xs={3} key={wine.id}>
<WineCard data={wine} />
</Grid>
)}
map
で wineList
の中の id
ごとにワインを並べる処理。
③ワイン情報の表示設定
リザルト画面でバックエンドから情報を引っ張ってきたのと同様に、ここでもワイン名や産地などの情報をバックエンドより取得。
今回はdata
で定義。余計なmuiを省くと下記のような感じ。
const WineCard: React.FC<{
data: any;
}> = ({data}) => {
return (
<Link href={`/shindan/${data.id}`}>
<Card >
<CardMedia
component="img"
image={`${backendBaseUrl}${data.image.src}`}
/>
{data.winery?.name}
{data.name}
{data.wineTypes &&
data.wineTypes.map((wineType) => wineType.name).join(", ")}
</Card>
<Link>
);
}
6.おすすめ詳細ページ作成(shindan/[id].tsx)
import {NextPage} from "next";
import {useRouter} from "next/router";
import React from "react";
import {DefaultLayout} from "../../layout/DefaultLayout";
import {Card, CardMedia, Typography, Button,Container ,Box,Link} from "@mui/material";
import axios from "axios";
import useMediaQuery from "@mui/material/useMediaQuery";
const WineDetail: NextPage = () => {
const router = useRouter();
const {id} = router.query;
const backendBaseUrl = "https://wine-app-express.herokuapp.com/";
//レスポンシブ設定を定義
const matches = useMediaQuery("(min-width:767px)");
//バックエンドとの繋ぎ込み処理
const [loading ] = React.useState(false);
const [wineList, setWineList] = React.useState(null);
React.useEffect(() => {
if (id) {
// setLoading(true);
axios.get(`https://wine-app-express.herokuapp.com/wine/${id}`)
.then((res) => {
const { result, data } = res.data;
if (result === "SUCCESS") {
setWineList(data);
}
if (loading) {
return <>loading...</>;
}
});
}
}, [id]);
return (
<DefaultLayout>
{matches ? (
<>
<Container fixed style={{position: "relative"}}>
<Box style={{position: "absolute",display: "flex",left: "5%",width: "100%"}}>
<Card sx={{height:"100vh", width:"50vh",margin: "10px"}}>
<CardMedia
component="img"
image={`${backendBaseUrl}${wineList?.image.src}`}
sx={{height:"100%", width:"auto",margin: "10px",marginLeft: "120px"}}
/>
</Card>
<Card sx={{ bgcolor: '#E6E6FA', height: '100vh' , top: "25px", width: "60vh"}}
style={{display: "flex",flexDirection: "column",marginTop: "10px", textAlign: "left",paddingLeft: "20px",paddingRight: "15px"}}>
<Typography variant="h4" style={{marginTop: 30, marginBottom: 30, fontWeight: "bold"}}>
{wineList?.name}
</Typography>
<Box sx={{borderBottom: 5 , marginBottom: 5, color: "white",}}/>
<Typography variant="h5">
【種類】{wineList?.wineTypes.map((wineType) => wineType.name)}
</Typography>
<Typography variant="h5" sx={{marginTop: "10px"}}>
【おすすめ一言】{wineList?.oneWord}
</Typography>
<Typography variant="h5" sx={{marginTop: "10px"}}>
【原産国名】{wineList?.country}
</Typography>
<Typography variant="h5" sx={{marginTop: "10px"}}>
【産地】{wineList?.winery.name}
</Typography>
<Typography variant="h5" sx={{marginTop: "10px"}}>
【ぶどう品種】{wineList?.breed}
</Typography>
<Typography variant="body1" sx={{marginTop: "10px"}}>
【詳細】{wineList?.description}
</Typography>
<Link target="_blank" href={wineList?.link} sx={{marginTop: "30px", textAlign: "center",textDecoration: "none"}}>
<Button variant="contained" sx={{
marginTop: "10px",
background: " rgb(243,208,120)",
boxShadow: "0 1px 0 rgba(255,255,255,.4) inset",
borderRadius: "3px",
borderColor: "#a88734 #9c7e31 #846a29",
borderStyle: "solid",
borderWidth: "1px",
width: "300px",
height: "80px",
color: "#111",
fontSize: "1.2em",
fontWeight: "bold",
":hover":{
background: "#eeb933",
color: "#222222"
}
}}>
amazonで見てみる
</Button>
</Link>
</Card>
</Box>
</Container>
</>
) : (
<>
<div style={{width:"100%"}}>
<Card sx={{height:"60vh", width:"100%",marginTop: "10px"}}>
<CardMedia
component="img"
image={`${backendBaseUrl}${wineList?.image.src}`}
sx={{height:"100%", width:"auto",margin: "10px",marginLeft: "120px"}}
/>
</Card>
<Card sx={{ bgcolor: '#E6E6FA', width:"100%"}}
style={{marginTop: "10px", textAlign: "left",paddingLeft: "20px",paddingRight: "15px"}}>
<Typography variant="h4" style={{marginTop: 30, marginBottom: 30, fontWeight: "bold"}}>
{wineList?.name}
</Typography>
<Box sx={{borderBottom: 5 , marginBottom: 5, color: "white"}}/>
<Typography variant="h5">
【種類】{wineList?.wineTypes.map((wineType) => wineType.name)}
</Typography>
<Typography variant="h5" sx={{marginTop: "10px"}}>
【おすすめ一言】{wineList?.oneWord}
</Typography>
<Typography variant="h5" sx={{marginTop: "10px"}}>
【原産国名】{wineList?.country}
</Typography>
<Typography variant="h5" sx={{marginTop: "10px"}}>
【産地】{wineList?.winery.name}
</Typography>
<Typography variant="h5" sx={{marginTop: "10px"}}>
【ぶどう品種】{wineList?.breed}
</Typography>
<Typography variant="body1" sx={{marginTop: "10px"}}>
【詳細】{wineList?.description}
</Typography>
<Typography sx={{marginTop: "30px", textAlign: "center",marginBottom: "40px"}}>
<Link target="_blank" href={wineList?.link} sx={{marginTop: "30px", textDecoration: "none"}}>
<Button variant="contained" sx={{
marginTop: "10px",
background: " rgb(243,208,120)",
boxShadow: "0 1px 0 rgba(255,255,255,.4) inset",
borderRadius: "3px",
borderColor: "#a88734 #9c7e31 #846a29",
borderStyle: "solid",
borderWidth: "1px",
width: "300px",
height: "80px",
color: "#111",
fontSize: "1.2em",
fontWeight: "bold",
":hover":{
background: "#eeb933",
color: "#222222"
}
}}>
amazonで見てみる
</Button>
</Link>
</Typography>
</Card>
</div>
</>
)}
</DefaultLayout>
)
}
export default WineDetail;
①バックエンドとの繋ぎ込み
これまで同様なので割愛。
今回はidを返してあげる処理なので、最後の返り値に [id]
を返してあげることを忘れず!
②ワインの詳細情報を表示
こちらもこれまで同様にwineListの情報をそれぞれ取得してあげるだけです。
アマゾンリンクも貼りたかったのでリンク作成してみました。
同じような処理なのでこちらも割愛!
終わりに
ここまで無駄に長いコード共に私の記事を読んでいただきありがとうございました…!
少し前の自分のコードなんですが、色々とリファクタリングしたいなと思うところが多々あったので、成長していると捉えてまた何か作れるように頑張ります!
バックエンドの記事もまた後日あげますので、興味のある方はそちらも是非ご覧ください。
それでは皆様良いお年を!!!!!