Skip to content

Commit

Permalink
フロントエンドにリアクション機能を実装
Browse files Browse the repository at this point in the history
  • Loading branch information
MurakawaTakuya committed Jan 12, 2025
1 parent 1a83275 commit f13080f
Show file tree
Hide file tree
Showing 7 changed files with 281 additions and 20 deletions.
6 changes: 1 addition & 5 deletions src/Components/Progress/FailedStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,7 @@ export const FailedStep = ({
user: User;
}) => {
return (
<StepperBlock
key={result.goalId}
userData={result.userData}
resultType="failed"
>
<StepperBlock key={result.goalId} result={result} resultType="failed">
<Step
indicator={
<StepIndicator variant="solid" color="danger">
Expand Down
6 changes: 1 addition & 5 deletions src/Components/Progress/PendingStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,7 @@ export const PendingStep = ({
const [isSubmitted, setIsSubmitted] = useState(false);

return (
<StepperBlock
key={result.goalId}
userData={result.userData}
resultType="pending"
>
<StepperBlock key={result.goalId} result={result} resultType="pending">
<Step
active
indicator={
Expand Down
210 changes: 210 additions & 0 deletions src/Components/Progress/Reaction.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
import {
GoalWithIdAndUserData,
ReactionType,
ReactionTypeMap,
} from "@/types/types";
import { updateReaction } from "@/utils/API/Reaction/updateReaction";
import { useUser } from "@/utils/UserContext";
import { Fade, Tooltip } from "@mui/material";
import React, { useEffect, useState } from "react";
import { showSnackBar } from "../SnackBar/SnackBar";

export interface reactionValue {
icon: string;
count?: number;
}

const validReactionTypes = [
...Object.keys(ReactionType.success),
...Object.keys(ReactionType.failed),
];

export const Reaction = ({
resultType,
result,
}: {
resultType?: "success" | "failed" | "pending";
result: GoalWithIdAndUserData;
}) => {
const goalId = result.goalId;

const { user } = useUser();

const [isReacted, setIsReacted] = useState("");
const [isOnceClicked, setIsOnceClicked] = useState(true);
const [reactionList, setReactionList] = useState<{
[key: string]: reactionValue;
}>({});

useEffect(() => {
// 絵文字の種類を定義
let emojiList: { [key: string]: string } = {};
if (resultType === "success") {
emojiList = ReactionType.success;
// keyをreactionListのkey、emojiListをそれぞれのiconに入れる
const updatedReactionList = Object.keys(emojiList).reduce(
(acc: { [key: string]: reactionValue }, key) => {
acc[key] = { icon: emojiList[key], count: 0 };
return acc;
},
{} as { [key: string]: reactionValue }
);
setReactionList(updatedReactionList);
} else if (resultType === "failed") {
emojiList = ReactionType.failed;
const updatedReactionList = Object.keys(emojiList).reduce(
(acc: { [key: string]: reactionValue }, key) => {
acc[key] = { icon: emojiList[key], count: 0 };
return acc;
},
{} as { [key: string]: reactionValue }
);
setReactionList(updatedReactionList);
}

setTimeout(() => {
setIsOnceClicked(false);
}, 1000);
}, []);

useEffect(() => {
// リアクションのカウントを更新
if (result.reaction) {
Object.entries(result.reaction).forEach(([userId, reactionType]) => {
if (!validReactionTypes.includes(reactionType)) {
return;
}

if (reactionList[reactionType] === undefined) {
// successやfailedで別のリアクションが押されている場合
return;
}

if (reactionList[reactionType].count === undefined) {
// 初期化失敗
return;
}

reactionList[reactionType].count += 1;
if (user?.userId === userId) {
setIsReacted(reactionType);
}
});
}
}, [reactionList]);

const handleReaction = (type: string) => {
if (user?.loginType === "Guest" || !user?.isMailVerified) {
return;
}

// typeがReactionTypeに含まれない場合
if (!validReactionTypes.includes(type)) {
showSnackBar({
message: "リアクションに失敗しました。",
type: "warning",
});
return;
}

setIsOnceClicked(true);

const updatedReactionList = { ...reactionList };
if (updatedReactionList[type].count === undefined) {
return;
}

try {
if (isReacted === type) {
// 既に同じものが押されている場合(リアクションを削除)
updateReaction(user.userId, goalId, "");
updatedReactionList[type].count -= 1;
setIsReacted("");
} else {
// 押されていない場合(リアクションを追加もしくは変更)
updateReaction(user.userId, goalId, type as ReactionTypeMap);
updatedReactionList[type].count += 1; // 新しいリアクションを追加
if (isReacted && updatedReactionList[isReacted].count) {
updatedReactionList[isReacted].count -= 1; // 古いリアクションを削除
}
setIsReacted(type);
}
} catch (error) {
showSnackBar({
message: "リアクションに失敗しました。",
type: "warning",
});
}
};

return (
<>
{(resultType === "success" || resultType === "failed") && (
<div
style={{
display: "flex",
justifyContent: "flex-end",
margin: "5px 8px 3px",
}}
>
<Tooltip
title={
user?.loginType === "Guest"
? "ログインしてリアクションしよう!"
: !user?.isMailVerified
? "メール認証してリアクションしよう!"
: resultType === "success"
? "お祝いしよう!"
: "応援しよう!"
}
slots={{
transition: Fade,
}}
slotProps={{
transition: { timeout: 400 },
}}
placement="left"
arrow
open={!isOnceClicked && !isReacted}
sx={{ zIndex: 1 }}
>
<div style={{ display: "flex", alignItems: "center" }}>
{Object.entries(reactionList).map(([key, reaction]) => (
<React.Fragment key={key}>
<button
key={key}
style={{
minWidth: "0",
padding: "0",
margin: "0 5px", // アイコンの間隔を調整
border: "none",
backgroundColor: "transparent",
cursor: "pointer",
}}
onClick={() => handleReaction(key)}
>
<span
style={{
fontSize: "28px",
lineHeight: "1",
filter: isReacted === key ? "none" : "grayscale(1)",
transition: "0.3s",
}}
>
{reaction.icon}
</span>
</button>
<span>{reaction.count}</span>
</React.Fragment>
))}
</div>
</Tooltip>
</div>
)}
</>
);
};

// TODO: 押したときにエフェクト
// TODO: トップページ、h1, h2だけアニメーション無しでもいいかも
// TODO: z-index
12 changes: 9 additions & 3 deletions src/Components/Progress/StepperBlock.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { User } from "@/types/types";
import { GoalWithIdAndUserData } from "@/types/types";
import { getSuccessRate } from "@/utils/successRate";
import { Divider } from "@mui/joy";
import Card from "@mui/joy/Card";
Expand All @@ -8,6 +8,7 @@ import Stepper from "@mui/joy/Stepper";
import Typography, { typographyClasses } from "@mui/joy/Typography";
import { ReactNode } from "react";
import CenterIn from "../Animation/CenterIn";
import { Reaction } from "./Reaction";

const outerBorderColors = {
success: "#008c328a",
Expand All @@ -17,13 +18,16 @@ const outerBorderColors = {

export const StepperBlock = ({
children,
userData = null,
resultType,
result,
}: {
children: ReactNode;
userData?: User | null;
resultType?: "success" | "failed" | "pending";
result: GoalWithIdAndUserData;
}) => {
const goalId = result.goalId;
const userData = result.userData;

const successRate = userData
? getSuccessRate(userData.completed, userData.failed)
: 0;
Expand Down Expand Up @@ -119,6 +123,8 @@ export const StepperBlock = ({
>
{children}
</Stepper>

<Reaction resultType={resultType} result={result} />
</Card>
</CenterIn>
);
Expand Down
6 changes: 1 addition & 5 deletions src/Components/Progress/SuccessStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,11 +66,7 @@ export const SuccessStep = ({
});

return (
<StepperBlock
key={result.goalId}
userData={result.userData}
resultType="success"
>
<StepperBlock key={result.goalId} resultType="success" result={result}>
<Step
active
completed
Expand Down
26 changes: 24 additions & 2 deletions src/types/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export interface Goal {
deadline: Date | string;
text: string;
post?: Omit<Post, "submittedAt"> & { submittedAt: string };
reaction?: Record<string, ReactionTypeMap>;
}

export interface GoalWithIdAndUserData extends Goal {
Expand All @@ -36,14 +37,35 @@ export interface PostWithGoalId extends Post {
goalId: string;
}

export const animationTypes = [
export const ReactionType = {
success: {
laugh: "😆",
surprised: "😲",
clap: "👏",
},
failed: {
sad: "😢",
angry: "😠",
muscle: "💪",
},
};

export type ReactionTypeMap =
| "laugh"
| "surprised"
| "clap"
| "sad"
| "angry"
| "muscle";

export const AnimationTypes = [
"left",
"right",
"center",
"bottom",
"top",
] as const;
export type AnimationType = (typeof animationTypes)[number];
export type AnimationType = (typeof AnimationTypes)[number];

export interface AnimationConfigs {
children: ReactNode;
Expand Down
35 changes: 35 additions & 0 deletions src/utils/API/Reaction/updateReaction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { appCheckToken, functionsEndpoint } from "@/app/firebase";
import { ReactionTypeMap } from "@/types/types";

/**
* Cloud FunctionsのAPIを呼び出して、リアクションを更新する
* reactionTypeに`""`を入れるとリアクションを削除する
*
* @param {string} userId
* @param {string} goalId
* @param {string} reactionType
* @return {*}
*/
export const updateReaction = async (
userId: string,
goalId: string,
reactionType: ReactionTypeMap | ""
) => {
const response = await fetch(`${functionsEndpoint}/reaction/${goalId}`, {
method: "PUT",
headers: {
"X-Firebase-AppCheck": appCheckToken,
"Content-Type": "application/json",
},
body: JSON.stringify({ userId, reactionType }),
});

if (!response.ok) {
const status = response.status;
const data = await response.json();
throw new Error(`Error ${status}: ${data.message}`);
}

const data = await response.json();
return data;
};

0 comments on commit f13080f

Please sign in to comment.