diff --git a/.eslintrc.js b/.eslintrc.js index 4c244ffe..1c65dc3f 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -17,13 +17,22 @@ module.exports = { settings: { 'import/resolver': { alias: { - extensions: ['.js', '.jsx'], + extensions: ['.js', '.jsx', '.ts', '.tsx'], map: [['@', '.']], }, + node: { + extensions: ['.js', '.jsx', '.ts', '.tsx'] + }, }, }, rules: { "import/no-extraneous-dependencies": ["error", { devDependencies: ["./*.js"] }], + 'import/extensions': ['error', 'ignorePackages', { + js: 'never', + jsx: 'never', + ts: 'never', + tsx: 'never', + }], 'react/no-unescaped-entities': 'off', '@next/next/no-page-custom-font': 'off', 'react/prop-types': [0], diff --git a/components/Group/Form/Form.styled.jsx b/components/Group/Form/Form.styled.jsx index 036344ac..3041d837 100644 --- a/components/Group/Form/Form.styled.jsx +++ b/components/Group/Form/Form.styled.jsx @@ -20,8 +20,13 @@ export const StyledDescription = styled.p` export const StyledContainer = styled.main` position: relative; + display: flex; + align-items: center; + flex-direction: column; + background: #f3fcfc; + padding: 60px 0; + min-height: 100vh; margin: 0 auto; - width: 720px; .MuiInputBase-input, .MuiFormControlLabel-label { @@ -30,7 +35,6 @@ export const StyledContainer = styled.main` @media (max-width: 760px) { padding: 20px; - width: 100%; } `; @@ -58,6 +62,7 @@ export const StyledGroup = styled.div` export const StyledFooter = styled.div` display: flex; justify-content: center; + width: 100%; `; export const StyledChip = styled(Chip)` diff --git a/components/Group/Form/index.jsx b/components/Group/Form/index.jsx index 311d7012..eb14125b 100644 --- a/components/Group/Form/index.jsx +++ b/components/Group/Form/index.jsx @@ -8,6 +8,7 @@ import Button from '@/shared/components/Button'; import Checkbox from '@mui/material/Checkbox'; import FormControlLabel from '@mui/material/FormControlLabel'; import { activityCategoryList } from '@/constants/activityCategory'; +import { ProtectedComponent } from '@/contexts/Auth'; import StyledPaper from '../Paper.styled'; import { StyledHeading, @@ -40,7 +41,6 @@ export default function GroupForm({ onSubmit, }) { const { - notLogin, control, values, errors, @@ -66,14 +66,10 @@ export default function GroupForm({ setIsChecked((pre) => !pre)} /> ); - if (notLogin) { - return ; - } - return ( - + - + {isCreateMode ? '發起揪團' : '編輯揪團'} @@ -149,7 +145,7 @@ export default function GroupForm({ placeholder="希望在什麼時間舉行?" /> - + - + {!isCreateMode && ( - + {values.isGrouping ? '開放揪團中' : '已關閉揪團'} - + ); } diff --git a/components/Group/Form/useGroupForm.jsx b/components/Group/Form/useGroupForm.jsx index 725bf262..a19dbba9 100644 --- a/components/Group/Form/useGroupForm.jsx +++ b/components/Group/Form/useGroupForm.jsx @@ -1,15 +1,14 @@ import dayjs from 'dayjs'; -import { useEffect, useRef, useState } from 'react'; -import { useSelector } from 'react-redux'; +import { useRef, useState } from 'react'; import { ZodType, z } from 'zod'; import { useSnackbar } from '@/contexts/Snackbar'; import { CATEGORIES } from '@/constants/category'; import { AREAS } from '@/constants/areas'; import { EDUCATION_STEP } from '@/constants/member'; import { BASE_URL } from '@/constants/common'; -import openLoginWindow from '@/utils/openLoginWindow'; import { activityCategoryList } from '@/constants/activityCategory'; import useLeaveConfirm from '@/hooks/useLeaveConfirm'; +import { useAuth } from '@/contexts/Auth'; const _eduOptions = EDUCATION_STEP.filter( (edu) => !['master', 'doctor', 'other'].includes(edu.value), @@ -84,9 +83,8 @@ const rules = { }; export default function useGroupForm(defaultValue) { + const { user, token } = useAuth(); const [isDirty, setIsDirty] = useState(false); - const me = useSelector((state) => state.user); - const notLogin = !me?._id; const [values, setValues] = useState(() => ({ ...INITIAL_VALUES, ...defaultValue, @@ -96,7 +94,7 @@ export default function useGroupForm(defaultValue) { rule.safeParse(defaultValue[key])?.data ?? INITIAL_VALUES[key], ]) ), - userId: me?._id, + userId: user?._id, })); const [errors, setErrors] = useState({}); const { pushSnackbar } = useSnackbar(); @@ -136,7 +134,7 @@ export default function useGroupForm(defaultValue) { method: 'DELETE', headers: { 'Content-Type': 'application/json', - Authorization: `Bearer ${me.token}`, + Authorization: `Bearer ${token}`, }, }); }; @@ -154,7 +152,7 @@ export default function useGroupForm(defaultValue) { const response = await fetch(`${BASE_URL}/image`, { method: 'POST', headers: { - Authorization: `Bearer ${me.token}`, + Authorization: `Bearer ${token}`, }, body: formData, }); @@ -211,20 +209,9 @@ export default function useGroupForm(defaultValue) { onValid({ ...result.data, photoURL }); }; - useEffect(() => { - let timer; - if (notLogin) { - timer = setTimeout(() => { - openLoginWindow(); - }, 100); - } - return () => clearTimeout(timer); - }, [notLogin]); - useLeaveConfirm({ shouldConfirm: isDirty }); return { - notLogin, control, errors, values, diff --git a/components/Group/detail/index.jsx b/components/Group/detail/index.jsx index 574e6328..36d73c44 100644 --- a/components/Group/detail/index.jsx +++ b/components/Group/detail/index.jsx @@ -1,8 +1,8 @@ import { useRouter } from 'next/navigation'; -import { useSelector } from 'react-redux'; import Box from '@mui/material/Box'; import Skeleton from '@mui/material/Skeleton'; import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew'; +import { useAuth } from '@/contexts/Auth'; import Image from '@/shared/components/Image'; import ContactButton from '@/shared/components/ContactButton'; import { StyledStatus } from '../GroupList/GroupCard.styled'; @@ -22,8 +22,8 @@ import ShareButtonGroup from './ShareButtonGroup'; function GroupDetail({ id, source, isLoading }) { const router = useRouter(); - const me = useSelector((state) => state.user); - const isMyGroup = source?.userId === me?._id && !!me?._id; + const { user } = useAuth(); + const isMyGroup = source?.userId === user?._id && !!user?._id; return ( diff --git a/components/Marathon/Banner/index.jsx b/components/Marathon/Banner/index.jsx index 64d71b61..0d9c4101 100644 --- a/components/Marathon/Banner/index.jsx +++ b/components/Marathon/Banner/index.jsx @@ -1,9 +1,10 @@ -import { useRouter } from 'next/router'; +import { useRouter } from 'next/navigation'; import styled from '@emotion/styled'; import Button from '@/shared/components/Button'; import groupBannerImg from '@/public/assets/group-banner.png'; import Image from '@/shared/components/Image'; import InfoCompletionGuard from '@/shared/components/InfoCompletionGuard'; +import { useAuth, useAuthDispatch } from '@/contexts/Auth'; const StyledBanner = styled.div` position: relative; @@ -48,6 +49,16 @@ const StyledBannerContent = styled.div` const Banner = () => { const router = useRouter(); + const { isLoggedIn, isTemporary } = useAuth(); + const { openLoginModal } = useAuthDispatch(); + + const handleClick = () => { + if (isLoggedIn || isTemporary) { + router.push('/learning-marathon/signup'); + } else { + openLoginModal('/learning-marathon/signup'); + } + }; return ( @@ -64,7 +75,7 @@ const Banner = () => {

島島盃 - 學習馬拉松 2025 春季賽

註冊並加入我們,立即報名!

- +
diff --git a/components/Marathon/SignUp/ConfirmForm.jsx b/components/Marathon/SignUp/ConfirmForm.jsx index 7513f799..7218212a 100644 --- a/components/Marathon/SignUp/ConfirmForm.jsx +++ b/components/Marathon/SignUp/ConfirmForm.jsx @@ -17,6 +17,7 @@ import { Radio, FormControlLabel, } from '@mui/material'; +import { useAuthDispatch } from '@/contexts/Auth'; import { StyledSection, @@ -217,6 +218,7 @@ export default function ConfirmForm({ const token = useSelector((state) => { return state.user.token; }); const [newMarathon, setNewMarathon] = useState(reduxInitMarathonState); const router = useRouter(); + const { openLoginModal } = useAuthDispatch(); const [user, setUser] = useState({ name: "", token: "", @@ -259,9 +261,9 @@ export default function ConfirmForm({ location: userLocation }); } else { - router.push('/learning-marathon/login'); + openLoginModal(); } - }, [userState]); + }, [userState, openLoginModal]); const onSubmit = async () => { if (!marathonState) { console.error('no data to submit'); diff --git a/components/Marathon/SignUp/UserProfileForm.jsx b/components/Marathon/SignUp/UserProfileForm.jsx index 0fef2365..d1ce4c35 100644 --- a/components/Marathon/SignUp/UserProfileForm.jsx +++ b/components/Marathon/SignUp/UserProfileForm.jsx @@ -116,9 +116,6 @@ export default function UserProfileForm({ }; useEffect(() => { - if (!user._id && (user.userType !== 'no_data')) { - router.push('/'); - } if (user._id) { Object.entries(user).forEach(([key, value]) => { if (key === 'contactList') { diff --git a/components/Marathon/SignUp/useEditProfile.jsx b/components/Marathon/SignUp/useEditProfile.jsx index ed1f3403..83697db1 100644 --- a/components/Marathon/SignUp/useEditProfile.jsx +++ b/components/Marathon/SignUp/useEditProfile.jsx @@ -3,6 +3,7 @@ import { useReducer, useRef, useState } from 'react'; import { useDispatch } from 'react-redux'; import { updateUser, createUser } from '@/redux/actions/user'; import { z } from 'zod'; +import { useAuthDispatch } from '@/contexts/Auth'; const initialState = { name: '', @@ -112,6 +113,7 @@ const userReducer = (state, payload) => { const useEditProfile = () => { const reduxDispatch = useDispatch(); const [userState, stateDispatch] = useReducer(userReducer, initialState); + const authDispatch = useAuthDispatch(); const [errors, setErrors] = useState({}); const refs = useRef({}); @@ -230,6 +232,7 @@ const useEditProfile = () => { } else { reduxDispatch(createUser(payload)); } + authDispatch.updateUser(payload); return true; }; diff --git a/components/Partner/Banner/index.jsx b/components/Partner/Banner/index.jsx index bfd0a740..48b32fe9 100644 --- a/components/Partner/Banner/index.jsx +++ b/components/Partner/Banner/index.jsx @@ -1,10 +1,11 @@ +import { useSelector } from 'react-redux'; import styled from '@emotion/styled'; import { useRouter } from 'next/router'; import { Box } from '@mui/material'; import Button from '@/shared/components/Button'; import Image from '@/shared/components/Image'; import partnerImg from '@/public/assets/partner-banner.png'; -import { useSelector } from 'react-redux'; +import { useAuth } from '@/contexts/Auth'; const StyledBanner = styled(Box)(({ theme }) => ({ height: '398px', @@ -62,7 +63,7 @@ const StyledContent = styled(Box)(({ theme }) => ({ const Banner = () => { const router = useRouter(); // select token from user - const { token } = useSelector((state) => state.user); + const { isLoggedIn } = useAuth(); return ( @@ -70,7 +71,7 @@ const Banner = () => {

尋找夥伴

想找到一起交流的學習夥伴嗎

註冊加入會員,並填寫個人資料,你的資訊就會刊登在頁面上囉!

- {!token && ( + {!isLoggedIn && ( )} diff --git a/components/Profile/Accountsetting/index.jsx b/components/Profile/Accountsetting/index.jsx index 29415736..a3a8f423 100644 --- a/components/Profile/Accountsetting/index.jsx +++ b/components/Profile/Accountsetting/index.jsx @@ -8,9 +8,9 @@ import { FormControlLabel, } from '@mui/material'; import { useRouter } from 'next/router'; -import { useDispatch, useSelector } from 'react-redux'; -import { updateUser, userLogout } from '@/redux/actions/user'; import styled from '@emotion/styled'; +import { useDispatch } from 'react-redux'; +import { useAuth, useAuthDispatch } from '@/contexts/Auth'; const StyledTypographyStyle = styled(Typography)` font-family: Noto Sans TC; @@ -32,11 +32,10 @@ const StyledLogoutBtn = styled(Button)` `; const AccountSetting = () => { - const dispatch = useDispatch(); const router = useRouter(); - + const authDispatch = useAuthDispatch(); + const { user } = useAuth(); const [isSubscribeEmail, setIsSubscribeEmail] = useState(false); - const user = useSelector((state) => state.user); const onUpdateUser = (status) => { const payload = { @@ -44,17 +43,17 @@ const AccountSetting = () => { email: user.email, isSubscribeEmail: status, }; - dispatch(updateUser(payload)); + authDispatch.updateUser(payload); }; const logout = () => { - dispatch(userLogout()); + authDispatch.logout(); router.push('/'); }; useEffect(() => { setIsSubscribeEmail(user?.isSubscribeEmail || false); - }, [user.isSubscribeEmail]); + }, [user]); return ( { wordBreak: 'break-all', }} > - {user.email} + {user?.email}
{/* diff --git a/components/Profile/Edit/index.jsx b/components/Profile/Edit/index.jsx index 99a8fa5a..49173e30 100644 --- a/components/Profile/Edit/index.jsx +++ b/components/Profile/Edit/index.jsx @@ -1,11 +1,11 @@ import React, { useEffect, useState } from 'react'; import dayjs from 'dayjs'; import toast from 'react-hot-toast'; -import { useSearchParams } from 'next/navigation'; import useMediaQuery from '@mui/material/useMediaQuery'; import { useRouter } from 'next/router'; import { useSelector } from 'react-redux'; import { TAIWAN_DISTRICT, COUNTRIES } from '@/constants/areas'; +import { useAuth } from '@/contexts/Auth'; import { GENDER, @@ -19,7 +19,6 @@ import { Typography, TextField, Switch, - TextareaAutosize, MenuItem, Select, Grid, @@ -51,26 +50,26 @@ import { StyledButton, } from './Edit.styled'; +// TODO: 待重構 function EditPage() { const mobileScreen = useMediaQuery('(max-width: 767px)'); const [isSetting, setIsSetting] = useState(false); const router = useRouter(); - const searchParams = useSearchParams(); - const check = searchParams.get('check'); const { userState, errors, onChangeHandler, + validate, onSubmit: onEditSubmit, setRef, } = useEditProfile(); - const user = useSelector((state) => state.user); + const { user, token, isComplete } = useAuth(); const { tags } = useSelector((state) => state.partners); useEffect(() => { - if (user._id) { + if (user?._id) { Object.entries(user).forEach(([key, value]) => { if (key === 'contactList') { const { instagram, facebook, discord, line } = value; @@ -95,7 +94,7 @@ function EditPage() { } else { router.push('/'); } - }, [user]); + }, [user, token]); const onUpdateUser = async () => { const resultStatus = await onEditSubmit({ @@ -106,34 +105,17 @@ function EditPage() { toast.error('請修正錯誤'); return; } - if (!resultStatus && !check) { + if (resultStatus) { + toast.success('更新成功'); + } else { toast.error('更新失敗'); } }; useEffect(() => { - switch (user.apiState) { - case 'Resolve': { - toast.success('更新成功'); - router.push('/profile'); - break; - } - case 'Reject': { - toast.error('更新失敗'); - break; - } - default: - } - }, [user.apiState]); - - useEffect(() => { - if (check === '1' && user._id && isSetting) { - onUpdateUser(); - router.replace({ query: { id: 'person-setting' } }, undefined, { - scroll: false, - }); - } - }, [searchParams, user._id && isSetting]); + if (isComplete) return; + validate(userState); + }, [userState, isComplete]); return ( diff --git a/components/Profile/Edit/useEditProfile.jsx b/components/Profile/Edit/useEditProfile.jsx index cada083c..8281c52b 100644 --- a/components/Profile/Edit/useEditProfile.jsx +++ b/components/Profile/Edit/useEditProfile.jsx @@ -1,8 +1,7 @@ import dayjs from 'dayjs'; import { useReducer, useRef, useState } from 'react'; -import { useDispatch } from 'react-redux'; -import { updateUser } from '@/redux/actions/user'; import { z } from 'zod'; +import { useAuthDispatch } from '@/contexts/Auth'; const initialState = { name: '', @@ -110,7 +109,7 @@ const userReducer = (state, payload) => { }; const useEditProfile = () => { - const reduxDispatch = useDispatch(); + const authDispatch = useAuthDispatch(); const [userState, stateDispatch] = useReducer(userReducer, initialState); const [errors, setErrors] = useState({}); const refs = useRef({}); @@ -221,8 +220,12 @@ const useEditProfile = () => { isOpenProfile, }; - reduxDispatch(updateUser(payload)); - return true; + try { + await authDispatch.updateUser(payload); + return true; + } catch (error) { + return false; + } }; const checkBeforeSubmit = async ({ id, email }) => { @@ -240,6 +243,7 @@ const useEditProfile = () => { return { userState, onChangeHandler, + validate, onSubmit: checkBeforeSubmit, setRef, errors, diff --git a/components/Profile/MyGroup/GroupCard.jsx b/components/Profile/MyGroup/GroupCard.jsx index 4e133960..cb4d61f8 100644 --- a/components/Profile/MyGroup/GroupCard.jsx +++ b/components/Profile/MyGroup/GroupCard.jsx @@ -1,11 +1,11 @@ import { useState } from 'react'; import { useRouter } from 'next/router'; -import { useSelector } from 'react-redux'; import Menu from '@mui/material/Menu'; import IconButton from '@mui/material/IconButton'; import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined'; import MoreVertOutlinedIcon from '@mui/icons-material/MoreVertOutlined'; import Image from '@/shared/components/Image'; +import { useAuth } from '@/contexts/Auth'; import emptyCoverImg from '@/public/assets/empty-cover.png'; import useMutation from '@/hooks/useMutation'; import { timeDuration } from '@/utils/date'; @@ -37,10 +37,10 @@ function GroupCard({ onUpdateGrouping, onDeleteGroup, }) { - const me = useSelector((state) => state.user); + const { user } = useAuth(); const router = useRouter(); const [anchorEl, setAnchorEl] = useState(null); - const isEnabledMutation = me._id === userId; + const isEnabledMutation = user?._id === userId; const apiUpdateGrouping = useMutation(`/activity/${_id}`, { method: 'PUT', diff --git a/components/Signin/Step1.jsx b/components/Signin/Step1.jsx new file mode 100644 index 00000000..1cfa92f9 --- /dev/null +++ b/components/Signin/Step1.jsx @@ -0,0 +1,219 @@ +import { GENDER, ROLE } from '@/constants/member'; +import dayjs from 'dayjs'; +import { Box, Button, Typography, Skeleton, TextField } from '@mui/material'; +import { LazyLoadImage } from 'react-lazy-load-image-component'; +import { MobileDatePicker } from '@mui/x-date-pickers/MobileDatePicker'; +import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import Checkbox from '@mui/material/Checkbox'; +import { + StyledContentWrapper, + StyledQuestionInput, +} from '@/components/Signin/Signin.styled'; +import ErrorMessage from '@/components/Signin/ErrorMessage'; + +// TODO: 待重構 +function Step1({ errors, onChangeHandler, userState = {}, onNext }) { + const handleRoleListChange = (value) => { + const { roleList = [] } = userState; + const updatedRoleList = roleList.includes(value) + ? roleList.filter((role) => role !== value) + : [...roleList, value]; + onChangeHandler({ key: 'roleList', value: updatedRoleList }); + }; + + return ( + + + +

基本資料

+ + + 生日 * + + onChangeHandler({ key: 'birthDay', value: date }) + } + renderInput={(params) => ( + + )} + /> + + + + 性別 * + + {GENDER.map(({ label, value }) => ( + + onChangeHandler({ key: 'gender', value }) + } + sx={{ + border: '1px solid #DBDBDB', + borderRadius: '8px', + padding: '10px', + width: 'calc(calc(100% - 16px) / 3)', + display: 'flex', + justifyItems: 'center', + alignItems: 'center', + cursor: 'pointer', + ...(userState.gender === value + ? { + backgroundColor: '#DEF5F5', + border: '1px solid #16B9B3', + } + : {}), + }} + > + {label} + + ))} + + + + + 身份 * + + {ROLE.map(({ label, value, image }) => ( + handleRoleListChange(value)} + sx={{ + border: '1px solid #DBDBDB', + borderRadius: '8px', + padding: '10px', + margin: '4px', + width: 'calc(calc(100% - 24px) / 3)', + flexBasis: 'calc(calc(100% - 24px) / 3)', + display: 'flex', + flexDirection: 'column', + justifyItems: 'center', + alignItems: 'center', + cursor: 'pointer', + ...(userState.roleList.includes(value) + ? { + backgroundColor: '#DEF5F5', + border: '1px solid #16B9B3', + } + : {}), + '@media (max-width: 767px)': { + height: '100% auto', + width: 'calc(calc(100% - 24px) / 2)', + flexBasis: 'calc(calc(100% - 24px) / 2)', + }, + }} + > + + } + /> + + {label} + + + ))} + + + + + onChangeHandler({ + key: 'isSubscribeEmail', + value: event.target.checked, + }) + } + /> + } + label="訂閱電子報與島島阿學的新資訊" + /> + + {Object.values(errors).length > 0 && ( + + )} + +
+
+
+ ); +} + +export default Step1; diff --git a/components/Signin/Step2.jsx b/components/Signin/Step2.jsx new file mode 100644 index 00000000..4bdfac92 --- /dev/null +++ b/components/Signin/Step2.jsx @@ -0,0 +1,196 @@ +import styled from '@emotion/styled'; +import { Box, Button, Typography, Skeleton } from '@mui/material'; +import { LazyLoadImage } from 'react-lazy-load-image-component'; +import { CATEGORIES } from '@/constants/member'; + +const ContentWrapper = styled.div` + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + background-color: #fff; + border-radius: 16px; + margin: 60px auto; + max-width: 50%; + width: 100%; + @media (max-width: 767px) { + max-width: 80%; + .title { + text-overflow: ellipsis; + width: 100%; + } + } +`; + +// TODO: 待重構 +function Step2({ onChangeHandler, userState = {}, onBack, onNext }) { + const { interestList = [] } = userState; + + return ( + + + + + 您對哪些領域感興趣? + + + 請選擇2-6個您想要關注的學習領域 + + + + {CATEGORIES.map(({ label, value, image }) => ( + { + onChangeHandler({ + key: 'interestList', + value: interestList.includes(value) + ? interestList.filter((data) => data !== value) + : [...interestList, value], + }); + }} + sx={{ + border: '1px solid #DBDBDB', + borderRadius: '8px', + margin: '4px', + padding: '10px', + width: 'calc(calc(100% - 32px) / 4)', + display: 'flex', + flexDirection: 'column', + justifyItems: 'center', + alignItems: 'center', + cursor: 'pointer', + ...(interestList.includes(value) + ? { + backgroundColor: '#DEF5F5', + border: '1px solid #16B9B3', + } + : {}), + '@media (max-width: 767px)': { + height: '100% auto', + width: 'calc(calc(100% - 24px) / 2)', + flexBasis: 'calc(calc(100% - 24px) / 2)', + }, + }} + > + + } + /> + + {label} + + + ))} + + + + + + + + + + + ); +} + +export default Step2; diff --git a/components/Signin/useValidation.jsx b/components/Signin/useValidation.jsx index 0ce216e7..b1de8e35 100644 --- a/components/Signin/useValidation.jsx +++ b/components/Signin/useValidation.jsx @@ -19,6 +19,8 @@ const schema = z.object({ }) .optional(), roleList: z.array(z.string()).min(1, '請選擇您的身份').optional(), + isSubscribeEmail: z.boolean().optional(), + interestList: z.array(z.string()).optional(), }); const initialState = { @@ -26,6 +28,7 @@ const initialState = { gender: '', roleList: [], isSubscribeEmail: true, + interestList: [], }; const userReducer = (state, payload) => { diff --git a/contexts/Auth/AuthContext.tsx b/contexts/Auth/AuthContext.tsx new file mode 100644 index 00000000..828fcba5 --- /dev/null +++ b/contexts/Auth/AuthContext.tsx @@ -0,0 +1,380 @@ +import { + createContext, + PropsWithChildren, + useContext, + useEffect, + useMemo, + useReducer, + useRef, +} from "react"; +import { useRouter } from "next/navigation"; +import { usePathname } from "next/navigation"; +import useSWR, { SWRConfig } from "swr"; +import { useDispatch } from "react-redux"; + +import { fetchUserByToken, userLogout } from "@/redux/actions/user"; +import { + getRedirectionStorage, + getReminderStorage, + getTokenStorage, +} from "@/utils/storage"; +import { + createUserProfile, + createUserProfileSchema, + fetchUserProfile, + updateUserProfile, + updateUserProfileSchema, +} from "@/services/users"; + +import LoginModal from "./LoginModal"; +import { + AuthState, + AuthDispatch, + Action, + ActionTypes, + LoginStatus, +} from "./type"; + +const LOGIN_TYPE = "login-type"; + +const initialState: AuthState = { + isComplete: false, + isLoggedIn: false, + isTemporary: false, + isOpenLoginModal: false, + loginStatus: LoginStatus.EMPTY, + token: null, + user: null, + redirectUrl: "", +}; + +const AuthContext = createContext(null); +const AuthDispatchContext = createContext(null); + +export const useAuth = () => { + const context = useContext(AuthContext); + if (!context) { + throw new Error("useAuth must be used within an AuthProvider"); + } + return context; +}; + +export const useAuthDispatch = () => { + const context = useContext(AuthDispatchContext); + if (!context) { + throw new Error("useAuthDispatch must be used within an AuthProvider"); + } + return context; +}; + +const checkIsComplete = (data: AuthState["user"]) => { + if (!data) return false; + + const hasAnySocialCode = Object.values(data.contactList || "{}").some( + (socialCode) => Boolean(socialCode) + ); + if (!hasAnySocialCode) return false; + + const keys = [ + "name", + "birthDay", + "gender", + "roleList", + "wantToDoList", + "tagList", + "selfIntroduction", + ] as const; + + return keys.every((key) => + Boolean(Array.isArray(data[key]) ? data[key].length : data[key]) + ); +}; + +const authReducer = (state: AuthState, action: Action): AuthState => { + switch (action.type) { + case ActionTypes.OPEN_LOGIN_MODAL: { + return { + ...initialState, + isOpenLoginModal: true, + redirectUrl: action.payload || "", + }; + } + case ActionTypes.CLOSE_LOGIN_MODAL: { + return { + ...state, + isOpenLoginModal: false, + redirectUrl: "", + }; + } + case ActionTypes.SET_TOKEN: { + return { + ...state, + token: action.payload, + }; + } + case ActionTypes.UPDATE_USER: + case ActionTypes.LOGIN: { + if (!state.token) { + return initialState; + } + if (action.payload) { + const reminder = getReminderStorage().get(); + getReminderStorage().set( + typeof reminder === "number" ? reminder + 1 : 1 + ); + return { + ...state, + isComplete: checkIsComplete(action.payload), + isLoggedIn: true, + isTemporary: false, + user: action.payload, + loginStatus: LoginStatus.PERMANENT, + }; + } + return { + ...state, + isLoggedIn: false, + isTemporary: true, + user: null, + loginStatus: LoginStatus.TEMPORARY, + }; + } + case ActionTypes.LOGOUT: { + return initialState; + } + default: + return state; + } +}; + +export function AuthProvider({ children }: PropsWithChildren) { + const [state, dispatch] = useReducer(authReducer, initialState); + const router = useRouter(); + const pathname = usePathname(); + + // TODO: 待移除 redux,為了同步資訊 + const reduxDispatch = useDispatch(); + + const authDispatch = useMemo(() => { + const setToken = (payload: string) => { + getTokenStorage().set(payload); + dispatch({ type: ActionTypes.SET_TOKEN, payload }); + }; + const logout = () => { + // TODO: 待移除 localStorage.clear,目前只是為了讓 redux 同步登出的暫解 + reduxDispatch(userLogout()); + getTokenStorage().remove(); + getRedirectionStorage().remove(); + dispatch({ type: ActionTypes.LOGOUT }); + }; + return { + setToken, + logout, + login: (payload) => { + dispatch({ type: ActionTypes.LOGIN, payload }); + }, + updateUser: async (input) => { + switch (state.loginStatus) { + case LoginStatus.TEMPORARY: { + const request = createUserProfileSchema.parse(input); + const { token, user } = await createUserProfile(request); + setToken(token); + dispatch({ type: ActionTypes.UPDATE_USER, payload: user }); + break; + } + case LoginStatus.PERMANENT: { + const request = updateUserProfileSchema.parse({ + ...state.user, + ...input, + }); + const payload = await updateUserProfile(request); + dispatch({ type: ActionTypes.UPDATE_USER, payload }); + break; + } + } + }, + openLoginModal: (payload) => { + logout(); + if (typeof payload === "string") { + getRedirectionStorage().set(payload); + } + dispatch({ type: ActionTypes.OPEN_LOGIN_MODAL, payload }); + }, + closeLoginModal: () => { + dispatch({ type: ActionTypes.CLOSE_LOGIN_MODAL }); + }, + }; + }, [state.loginStatus, state.user, dispatch]); + + const handleError = (error?: { status?: number }) => { + if (error?.status === 401) { + authDispatch.logout(); + } + }; + + useSWR( + state.token ? [fetchUserProfile.name, state.token] : null, + fetchUserProfile, + { + onSuccess: authDispatch.login, + onError: handleError, + } + ); + + useEffect(() => { + const handleToken = (token: string) => { + if (!token) return; + // TODO: 待移除 redux,為了同步資訊 + reduxDispatch(fetchUserByToken(token)); + authDispatch.setToken(token); + }; + + const receiveMessage = ( + event: MessageEvent<{ + type: typeof LOGIN_TYPE; + payload: { token: string }; + }> + ) => { + if (event.origin !== window.location.origin) return; + if (event.data.type === LOGIN_TYPE) { + handleToken(event.data.payload.token); + } + }; + const removeLoginListener = () => { + window.removeEventListener("message", receiveMessage, false); + }; + + handleToken(getTokenStorage().get()); + + if (state.loginStatus === LoginStatus.PERMANENT) { + removeLoginListener(); + } else { + window.addEventListener("message", receiveMessage, false); + } + + return removeLoginListener; + }, [ + state.loginStatus, + authDispatch.setToken, + authDispatch.logout, + reduxDispatch, + ]); + + useEffect(() => { + switch (state.loginStatus) { + case LoginStatus.TEMPORARY: { + const redirectUrl = state.redirectUrl || getRedirectionStorage().get(); + authDispatch.closeLoginModal(); + router.replace(redirectUrl || "/signin"); + break; + } + case LoginStatus.PERMANENT: { + const redirectUrl = state.redirectUrl || getRedirectionStorage().get(); + authDispatch.closeLoginModal(); + if (redirectUrl) router.replace(redirectUrl); + break; + } + default: + break; + } + }, [ + state.loginStatus, + state.redirectUrl, + router.replace, + authDispatch.closeLoginModal, + ]); + + useEffect(() => { + const redirectionStorage = getRedirectionStorage(); + + if (redirectionStorage.get() === pathname) { + redirectionStorage.remove(); + } + }, [pathname]); + + return ( + + + {children} + + + + ); +} + +interface ProtectedComponentProps extends PropsWithChildren { + redirectOnCancel?: string; + onlyCheckToken?: boolean; +} + +export const ProtectedComponent = ({ + children, + redirectOnCancel, + onlyCheckToken = false, +}: ProtectedComponentProps) => { + const router = useRouter(); + const opened = useRef(false); + const { user, isLoggedIn, isOpenLoginModal, token } = useAuth(); + const { openLoginModal } = useAuthDispatch(); + const requiresLoginModal = onlyCheckToken ? !token : !isLoggedIn; + + useEffect(() => { + let timer: NodeJS.Timeout; + if (requiresLoginModal) { + timer = setTimeout(() => { + opened.current = true; + openLoginModal(); + }, 1000); + } + return () => clearTimeout(timer); + }, [requiresLoginModal, openLoginModal]); + + useEffect(() => { + if ( + redirectOnCancel && + !isOpenLoginModal && + opened.current && + requiresLoginModal + ) { + router.replace(redirectOnCancel); + } + }, [ + redirectOnCancel, + isOpenLoginModal, + opened.current, + requiresLoginModal, + router.replace, + ]); + + if (!user) return
; + + return children; +}; + +export const sendLoginEvent = (token: string) => { + if (!token) { + // TODO: 處理沒 token 的狀況 + return; + } + + getTokenStorage().remove(); + + if ( + window.opener && + window.opener.location.origin === window.location.origin + ) { + window.opener.postMessage( + { type: LOGIN_TYPE, payload: { token } }, + window.location.origin + ); + window.close(); + } else { + const redirection = getRedirectionStorage().get(); + getTokenStorage().set(token); + window.location.replace(redirection || "/"); + } +}; diff --git a/contexts/Auth/LoginModal.tsx b/contexts/Auth/LoginModal.tsx new file mode 100644 index 00000000..91d554ea --- /dev/null +++ b/contexts/Auth/LoginModal.tsx @@ -0,0 +1,103 @@ +import { useEffect, useRef, useState } from "react"; +import Link from "next/link"; +import { BASE_URL } from "@/constants/common"; +import Image from "@/shared/components/Image"; +import Modal from "@/shared/components/Modal"; +import openWindowPopup from "@/utils/openWindowPopup"; +import { cn } from "@/utils/cn"; + +interface LoginModalProps { + isOpen: boolean; + keepMounted: boolean; + onClose: () => void; +} + +export default function LoginModal({ + isOpen, + keepMounted, + onClose, +}: LoginModalProps) { + const [isOpenWindow, setIsOpenWindow] = useState(false); + const timer = useRef(); + + const handleOpenLoginWindow = () => { + const popup = openWindowPopup({ + url: `${BASE_URL}/auth/google`, + title: "login", + width: 400, + height: 632, + }); + setIsOpenWindow(!!popup?.parent); + clearInterval(timer.current); + + if (popup?.parent) { + timer.current = setInterval(() => { + setIsOpenWindow(!!popup.parent); + }, 300); + } + }; + + useEffect(() => { + if (!isOpenWindow) { + clearInterval(timer.current); + } + }, [isOpenWindow, timer.current]); + + return ( + +
+
+ login +
+
+ +
+ 註冊即代表您同意島島阿學的 + + 服務條款 + + 與 + + 隱私權政策 + +
+
+ ); +} diff --git a/contexts/Auth/index.ts b/contexts/Auth/index.ts new file mode 100644 index 00000000..dc39de3c --- /dev/null +++ b/contexts/Auth/index.ts @@ -0,0 +1 @@ +export * from './AuthContext'; diff --git a/contexts/Auth/type.ts b/contexts/Auth/type.ts new file mode 100644 index 00000000..cf4032a5 --- /dev/null +++ b/contexts/Auth/type.ts @@ -0,0 +1,51 @@ +import type { + CreateUserProfile, + UpdateUserProfile, + IUser, +} from "@/services/users"; + +export enum LoginStatus { + /** 未登入 */ + EMPTY, + /** 臨時登入 */ + TEMPORARY, + /** 正式登入 */ + PERMANENT, +} + +export type AuthState = { + isComplete: boolean; + isLoggedIn: boolean; + isTemporary: boolean; + isOpenLoginModal: boolean; + loginStatus: LoginStatus; + token: string | null; + user: IUser | null; + redirectUrl: string; +}; + +export enum ActionTypes { + OPEN_LOGIN_MODAL = "openLoginModal", + CLOSE_LOGIN_MODAL = "closeLoginModal", + SET_TOKEN = "setToken", + UPDATE_USER = "updateUser", + LOGIN = "login", + LOGOUT = "logout", +} + +export type Action = + | { type: ActionTypes.OPEN_LOGIN_MODAL; payload?: string } + | { type: ActionTypes.CLOSE_LOGIN_MODAL } + | { type: ActionTypes.SET_TOKEN; payload: string } + | { type: ActionTypes.UPDATE_USER; payload: IUser; } + | { type: ActionTypes.LOGIN; payload: IUser | null } + | { type: ActionTypes.LOGOUT }; + +export type AuthDispatch = { + [ActionTypes.OPEN_LOGIN_MODAL]: (redirectUrl?: string) => void; + [ActionTypes.CLOSE_LOGIN_MODAL]: () => void; + [ActionTypes.SET_TOKEN]: (payload: string) => void; + [ActionTypes.UPDATE_USER]: (payload: CreateUserProfile | UpdateUserProfile) => Promise; + [ActionTypes.LOGIN]: (payload: IUser | null) => void; + [ActionTypes.LOGOUT]: () => void; +}; diff --git a/hooks/useFetch.jsx b/hooks/useFetch.jsx index 4cf11cd7..0b909587 100644 --- a/hooks/useFetch.jsx +++ b/hooks/useFetch.jsx @@ -1,12 +1,11 @@ import { useEffect, useReducer, useState } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; import { useRouter } from 'next/navigation'; import { BASE_URL } from '@/constants/common'; -import { userLogout } from '@/redux/actions/user'; +import { useAuth, useAuthDispatch } from '@/contexts/Auth'; const useFetch = (url, { enabled = true, initialValue, onSuccess } = {}) => { - const { token } = useSelector((state) => state.user); - const dispatch = useDispatch(); + const { token } = useAuth(); + const authDispatch = useAuthDispatch(); const router = useRouter(); const [render, refetch] = useReducer((pre) => !pre, true); const [data, setData] = useState(initialValue); @@ -31,7 +30,7 @@ const useFetch = (url, { enabled = true, initialValue, onSuccess } = {}) => { .then((res) => { if (res.status < 300) return res.json(); if (res.status === 401) { - dispatch(userLogout()); + authDispatch.logout(); router.replace('/login') } throw res; diff --git a/hooks/useMutation.jsx b/hooks/useMutation.jsx index a9fa27be..2da91125 100644 --- a/hooks/useMutation.jsx +++ b/hooks/useMutation.jsx @@ -1,12 +1,11 @@ import { useState } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; import { useRouter } from 'next/navigation'; import { BASE_URL } from '@/constants/common'; -import { userLogout } from '@/redux/actions/user'; +import { useAuth, useAuthDispatch } from '@/contexts/Auth'; const useMutation = (url, { method, enabled = true, onSuccess, onError } = {}) => { - const { token } = useSelector((state) => state.user); - const dispatch = useDispatch(); + const { token } = useAuth(); + const authDispatch = useAuthDispatch(); const router = useRouter(); const [isLoading, setIsLoading] = useState(false); const [isError, setIsError] = useState(false); @@ -32,7 +31,7 @@ const useMutation = (url, { method, enabled = true, onSuccess, onError } = {}) = .then((res) => { if (res.status < 300) return res.json(); if (res.status === 401) { - dispatch(userLogout()); + authDispatch.logout(); router.replace('/login'); } throw res; diff --git a/jsconfig.json b/jsconfig.json deleted file mode 100644 index 2a2e4b3b..00000000 --- a/jsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "compilerOptions": { - "paths": { - "@/*": ["./*"] - } - } -} diff --git a/next-env.d.ts b/next-env.d.ts new file mode 100644 index 00000000..4f11a03d --- /dev/null +++ b/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/package.json b/package.json index dd2e5979..cfe3b76a 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "prop-types": "^15.8.1", "react": "^18.0.0", "react-copy-to-clipboard": "^5.0.4", - "react-dom": "^18.0.0", + "react-dom": "18.0.0", "react-fast-marquee": "^1.3.2", "react-ga": "^3.3.0", "react-hook-form": "^7.53.2", @@ -75,6 +75,7 @@ "@next/eslint-plugin-next": "^13.2.1", "@tailwindcss/typography": "^0.5.15", "@types/chrome": "^0.0.206", + "@types/react-dom": "^19.0.2", "autoprefixer": "^10.4.20", "babel-plugin-import": "^1.13.8", "eslint": "^8.35.0", @@ -88,6 +89,7 @@ "eslint-plugin-react": "^7.24.0", "eslint-plugin-react-hooks": "^4.2.0", "postcss": "^8.4.47", - "tailwindcss": "^3.4.14" + "tailwindcss": "^3.4.14", + "typescript": "5.7.2" } } diff --git a/pages/_app.jsx b/pages/_app.jsx index e081bcef..d84d6ef6 100644 --- a/pages/_app.jsx +++ b/pages/_app.jsx @@ -1,4 +1,5 @@ import React, { useEffect, useMemo, useState } from 'react'; +import { SWRConfig } from 'swr'; import { ThemeProvider } from '@mui/material/styles'; import CssBaseline from '@mui/material/CssBaseline'; import { Toaster } from 'react-hot-toast'; @@ -8,15 +9,15 @@ import Script from 'next/script'; import Head from 'next/head'; import { persistStore } from 'redux-persist'; import { PersistGate } from 'redux-persist/integration/react'; +import { AuthProvider, useAuth } from '@/contexts/Auth'; import SnackbarProvider from '@/contexts/Snackbar'; import CompleteInfoReminderDialog from '@/shared/components/CompleteInfoReminderDialog'; import GlobalStyle from '@/shared/styles/Global'; import themeFactory from '@/shared/styles/themeFactory'; import storeFactory from '@/redux/store'; -import { checkLoginValidity, fetchUserById } from '@/redux/actions/user'; -import { getRedirectionStorage, getReminderStorage } from '@/utils/storage'; +import { checkLoginValidity } from '@/redux/actions/user'; +import { getReminderStorage } from '@/utils/storage'; import DefaultLayout from '@/layout/DefaultLayout'; -import { startLoginListener } from '@/utils/openLoginWindow'; import { initGA, logPageView } from '../utils/analytics'; import Mode from '../shared/components/Mode'; import 'regenerator-runtime/runtime'; // Speech.js @@ -25,6 +26,11 @@ import "@/shared/styles/global.css"; const store = storeFactory(); const persistor = persistStore(store); +const swrConfig = { + revalidateOnFocus: false, + errorRetryCount: 0, +}; + const App = ({ Component, pageProps }) => { const router = useRouter(); useEffect(() => { @@ -95,9 +101,13 @@ const App = ({ Component, pageProps }) => { - - - + + + + + + + @@ -109,14 +119,13 @@ const ThemeComponentWrap = ({ pageProps, Component }) => { const mode = useSelector((state) => state?.theme?.mode ?? 'light'); const theme = useMemo(() => themeFactory(mode), [mode]); const isEnv = useMemo(() => process.env.NODE_ENV === 'development', []); - const router = useRouter(); - const user = useSelector((state) => state.user); + const { isComplete, isLoggedIn } = useAuth(); const [isOpen, setIsOpen] = useState(false); const Layout = Component?.getLayout || DefaultLayout; const handleClose = () => { setIsOpen(false); - getReminderStorage().set(true); + getReminderStorage().remove(); }; useEffect(() => { @@ -124,25 +133,10 @@ const ThemeComponentWrap = ({ pageProps, Component }) => { }, []); useEffect(() => { - const stopLoginListener = startLoginListener((id, token) => { - const redirectionStorage = getRedirectionStorage(); - const redirectUrl = redirectionStorage.get(); - - dispatch(fetchUserById(id, token)); - - if (redirectUrl) { - redirectionStorage.remove(); - router.replace(redirectUrl); - } - }); - return () => stopLoginListener(); - }, [dispatch, router.replace]); - - useEffect(() => { - if (user?._id && !user?.isComplete && !getReminderStorage().get()) { + if (isLoggedIn && !isComplete && getReminderStorage().get() % 3 === 0) { setIsOpen(true); } - }, [user]); + }, [isLoggedIn, isComplete]); return ( diff --git a/pages/auth/callback/index.jsx b/pages/auth/callback/index.jsx index b3b6364d..b4a53b4b 100644 --- a/pages/auth/callback/index.jsx +++ b/pages/auth/callback/index.jsx @@ -1,40 +1,10 @@ -import { useState, useEffect } from "react"; import { Paper, Typography, Box } from "@mui/material"; import { useSearchParams } from 'next/navigation'; -import { useDispatch, useSelector } from 'react-redux'; -import { fetchUserByToken } from "@/redux/actions/user"; +import { sendLoginEvent } from "@/contexts/Auth"; export default function AuthCallbackPage() { const searchParams = useSearchParams(); - const dispatch = useDispatch(); - const [isLoading, setIsLoading] = useState(true); - const me = useSelector((state) => state.user); - - useEffect(() => { - const tempToken = searchParams.get("token"); - - if (tempToken) { - dispatch(fetchUserByToken(tempToken)); - } else { - console.error("unfound token"); - } - }, [searchParams, dispatch]); - - useEffect(() => { - if (window.opener && isLoading && me) { - if (me._id) { - window.opener.postMessage({ type: 'USER_UPDATED' }, window.location.origin); - setIsLoading(false); - window.close(); - } - - if (me.userType === 'no_data') { - window.opener.postMessage({ type: 'TEMP_TOKEN_UPDATED' }, window.location.origin); - setIsLoading(false); - window.close(); - } - } - }, [me._id, me.tempToken, isLoading]); + sendLoginEvent(searchParams.get("token")); return ( import('@/components/Group/Form'), { - ssr: false, -}); +import GroupForm from '@/components/Group/Form'; function CreateGroupPage() { const { pushSnackbar } = useSnackbar(); diff --git a/pages/group/edit/index.jsx b/pages/group/edit/index.jsx index 7bf51740..2ac7d365 100644 --- a/pages/group/edit/index.jsx +++ b/pages/group/edit/index.jsx @@ -1,21 +1,17 @@ import React, { useEffect, useMemo } from 'react'; -import dynamic from 'next/dynamic'; -import { useSelector } from 'react-redux'; import { useRouter } from 'next/router'; import { Box } from '@mui/material'; +import { useAuth } from '@/contexts/Auth'; import { useSnackbar } from '@/contexts/Snackbar'; import useFetch from '@/hooks/useFetch'; import useMutation from '@/hooks/useMutation'; import SEOConfig from '@/shared/components/SEO'; - -const GroupForm = dynamic(() => import('@/components/Group/Form'), { - ssr: false, -}); +import GroupForm from '@/components/Group/Form'; function EditGroupPage() { const { pushSnackbar } = useSnackbar(); const router = useRouter(); - const me = useSelector((state) => state.user); + const { user } = useAuth(); const { id } = router.query; const { data, isFetching } = useFetch(`/activity/${id}`, { enabled: !!id, @@ -48,10 +44,10 @@ function EditGroupPage() { }); useEffect(() => { - if (!me?._id) router.push('/login'); + if (!user?._id) router.push('/login'); if (isFetching || !source?.userId) return; - if (source.userId !== me._id) router.replace(`/group/detail?id=${id}`); - }, [me, source, isFetching, id]); + if (source.userId !== user._id) router.replace(`/group/detail?id=${id}`); + }, [user, source, isFetching, id]); return ( <> diff --git a/pages/index.jsx b/pages/index.jsx index 9c2f0aeb..414daacc 100644 --- a/pages/index.jsx +++ b/pages/index.jsx @@ -1,7 +1,6 @@ -import React, { useMemo, useEffect } from 'react'; +import React, { useMemo } from 'react'; import styled from '@emotion/styled'; import { useRouter } from 'next/router'; -import { sendLoginConfirmation } from '@/utils/openLoginWindow'; import SEOConfig from '../shared/components/SEO'; import Home from '../components/Home'; import Navigation from '../shared/components/Navigation_v2'; @@ -46,12 +45,6 @@ const HomePage = () => { [router?.asPath], ); - const { token, id } = router.query; - - useEffect(() => { - sendLoginConfirmation(id, token); - }, [id, token]); - return ( <> diff --git a/pages/learning-marathon/signup/index.jsx b/pages/learning-marathon/signup/index.jsx index 3b7aa651..a6dda0bf 100644 --- a/pages/learning-marathon/signup/index.jsx +++ b/pages/learning-marathon/signup/index.jsx @@ -14,6 +14,7 @@ import SaveBar from '@/components/Marathon/SignUp/SaveBar'; import UserProfileForm from '@/components/Marathon/SignUp/UserProfileForm'; import MarathonForm from '@/components/Marathon/SignUp/MarathonForm'; import ConfirmForm from '@/components/Marathon/SignUp/ConfirmForm'; +import { ProtectedComponent } from '@/contexts/Auth'; const HomePageWrapper = styled.div` --section-height: calc(100vh - 80px); @@ -99,7 +100,7 @@ const LearningMarathonSignUp = () => { }); }, [currentStep]); return ( - <> + { - + ); }; diff --git a/pages/partner/detail/index.jsx b/pages/partner/detail/index.jsx index 82b0f969..b82b91dc 100644 --- a/pages/partner/detail/index.jsx +++ b/pages/partner/detail/index.jsx @@ -10,6 +10,7 @@ import { clearPartnerState, fetchPartnerById, } from '@/redux/actions/partners'; +import { useAuth } from '@/contexts/Auth'; const HomePageWrapper = styled.div` --section-height: calc(100vh - 80px); @@ -26,7 +27,7 @@ const PartnerDetailPage = () => { const { partner } = useSelector((state) => state?.partners); // fetch login user info - const { email } = useSelector((state) => state?.user); + const { user } = useAuth(); const fetchUser = async () => { dispatch(fetchPartnerById({ id: partnerId })); @@ -45,7 +46,7 @@ const PartnerDetailPage = () => { ); }; diff --git a/pages/profile/index.jsx b/pages/profile/index.jsx index e9c0bc09..e65aced0 100644 --- a/pages/profile/index.jsx +++ b/pages/profile/index.jsx @@ -1,11 +1,11 @@ import { useMemo, useState } from 'react'; import { useRouter } from 'next/router'; -import { useSelector } from 'react-redux'; import styled from '@emotion/styled'; import Tabs from '@mui/material/Tabs'; import Tab from '@mui/material/Tab'; import Typography from '@mui/material/Typography'; import Box from '@mui/material/Box'; +import { ProtectedComponent, useAuth } from '@/contexts/Auth'; import Edit from '@/components/Profile/Edit'; import Footer from '@/shared/components/Footer_v2'; import SEOConfig from '@/shared/components/SEO'; @@ -63,7 +63,7 @@ function a11yProps(index) { const ProfilePage = () => { const router = useRouter(); const mobileScreen = useMediaQuery('(max-width: 767px)'); - const me = useSelector((state) => state.user); + const { user } = useAuth(); const tabs = [ { id: 'person-setting', @@ -73,7 +73,7 @@ const ProfilePage = () => { { id: 'my-group', tabLabel: '我的揪團', - view: , + view: , }, { id: 'account-setting', @@ -83,7 +83,7 @@ const ProfilePage = () => { { id: 'my-marathon', tabLabel: '學習計畫', - view: + view: } ]; @@ -113,7 +113,7 @@ const ProfilePage = () => { }; return ( - <> + { ))} - + {tabs.map((tab, index) => ( {tab.view} @@ -171,7 +171,7 @@ const ProfilePage = () => { ))} - + ); }; diff --git a/pages/profile/myprofile/index.jsx b/pages/profile/myprofile/index.jsx index dadec1b7..824ba751 100644 --- a/pages/profile/myprofile/index.jsx +++ b/pages/profile/myprofile/index.jsx @@ -1,6 +1,6 @@ import React from 'react'; -import { useSelector } from 'react-redux'; import styled from '@emotion/styled'; +import { useAuth } from '@/contexts/Auth'; import Navigation from '@/shared/components/Navigation_v2'; import Footer from '@/shared/components/Footer_v2'; import Profile from '@/components/Profile'; @@ -11,7 +11,7 @@ const HomePageWrapper = styled.div` `; const MyProfilePage = () => { - const user = useSelector((state) => state.user); + const { user } = useAuth(); return ; }; diff --git a/pages/signin/index.jsx b/pages/signin/index.jsx index 6556cbb2..0fd63f0b 100644 --- a/pages/signin/index.jsx +++ b/pages/signin/index.jsx @@ -1,280 +1,84 @@ -import { useMemo, useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { useRouter } from 'next/router'; -import { useSelector, useDispatch } from 'react-redux'; -import { createUser } from '@/redux/actions/user'; -import { GENDER, ROLE } from '@/constants/member'; -import dayjs from 'dayjs'; -import { Box, Typography, Button, Skeleton, TextField } from '@mui/material'; -import { LazyLoadImage } from 'react-lazy-load-image-component'; -import { MobileDatePicker } from '@mui/x-date-pickers/MobileDatePicker'; -import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; -import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; -import FormControlLabel from '@mui/material/FormControlLabel'; -import Checkbox from '@mui/material/Checkbox'; -import SEOConfig from '@/shared/components/SEO'; import Navigation from '@/shared/components/Navigation_v2'; import Footer from '@/shared/components/Footer_v2'; -import { - HomePageWrapper, - StyledContentWrapper, - StyledQuestionInput, -} from '@/components/Signin/Signin.styled'; -import ErrorMessage from '@/components/Signin/ErrorMessage'; +import { HomePageWrapper } from '@/components/Signin/Signin.styled'; import useProfileValidation from '@/components/Signin/useValidation'; -import { sendLoginConfirmation } from '@/utils/openLoginWindow'; +import { useAuth, useAuthDispatch } from '@/contexts/Auth'; +import Step1 from '@/components/Signin/Step1'; +import Step2 from '@/components/Signin/Step2'; +import TipModal from '@/components/Signin/Interest/TipModal'; function SignInPage() { const router = useRouter(); - const dispatch = useDispatch(); - - const { id, token } = router.query; + const [step, setStep] = useState(1); + const [open, setOpen] = useState(false); const { errors, onChangeHandler, userState, validateFields } = useProfileValidation(); - const { createdDate, updatedDate } = useSelector((state) => state?.user); - - // Oath login - useEffect(() => { - sendLoginConfirmation(id, token, `/signin?id=${id}`); - }, [id, token]); - - useEffect(() => { - if (createdDate !== updatedDate) { - router.push('/profile'); - } - }, [createdDate, updatedDate]); + const { isLoggedIn, token } = useAuth(); + const { updateUser } = useAuthDispatch(); - const handleRoleListChange = (value) => { - const { roleList } = userState; - const updatedRoleList = roleList.includes(value) - ? roleList.filter((role) => role !== value) - : [...roleList, value]; - onChangeHandler({ key: 'roleList', value: updatedRoleList }); - }; - - const onCreateUser = () => { - const { birthDay, gender, roleList, isSubscribeEmail } = userState; - if (validateFields({ birthDay, gender, roleList }, true)) { + const handleSubmit = async () => { + if (validateFields(userState, true)) { const payload = { - id, - birthDay: birthDay.toISOString(), - gender, - roleList, - isSubscribeEmail, + ...userState, + isSendEmail: true, + birthDay: userState.birthDay.toISOString(), }; - dispatch(createUser(payload)); - router.push(`/signin/interest?id=${id}`); + try { + await updateUser(payload); + } catch (error) { + console.error(error); + } } }; - const SEOData = useMemo( - () => ({ - title: '編輯我的島島資料|島島阿學', - description: - '「島島阿學」盼能透過建立多元的學習資源網絡,讓自主學習者能找到合適的成長方法,進一步成為自己想成為的人,從中培養共好精神。目前正積極打造「可共編的學習資源平台」。', - keywords: '島島阿學', - author: '島島阿學', - copyright: '島島阿學', - imgLink: 'https://www.daoedu.tw/preview.webp', - link: `${process.env.HOSTNAME}${router?.asPath}`, - }), - [router?.asPath], - ); + useEffect(() => { + if (isLoggedIn) { + setOpen(true); + } else if (!token) { + router.push('/'); + } + }, [isLoggedIn, token]); return ( <> - - - - - -

基本資料

- - - 生日 * - - onChangeHandler({ key: 'birthDay', value: date }) - } - renderInput={(params) => ( - - )} - /> - - - - 性別 * - - {GENDER.map(({ label, value }) => ( - - onChangeHandler({ key: 'gender', value }) - } - sx={{ - border: '1px solid #DBDBDB', - borderRadius: '8px', - padding: '10px', - width: 'calc(calc(100% - 16px) / 3)', - display: 'flex', - justifyItems: 'center', - alignItems: 'center', - cursor: 'pointer', - ...(userState.gender === value - ? { - backgroundColor: '#DEF5F5', - border: '1px solid #16B9B3', - } - : {}), - }} - > - {label} - - ))} - - - - - 身份 * - - {ROLE.map(({ label, value, image }) => ( - handleRoleListChange(value)} - sx={{ - border: '1px solid #DBDBDB', - borderRadius: '8px', - padding: '10px', - margin: '4px', - width: 'calc(calc(100% - 24px) / 3)', - flexBasis: 'calc(calc(100% - 24px) / 3)', - display: 'flex', - flexDirection: 'column', - justifyItems: 'center', - alignItems: 'center', - cursor: 'pointer', - ...(userState.roleList.includes(value) - ? { - backgroundColor: '#DEF5F5', - border: '1px solid #16B9B3', - } - : {}), - '@media (max-width: 767px)': { - height: '100% auto', - width: 'calc(calc(100% - 24px) / 2)', - flexBasis: 'calc(calc(100% - 24px) / 2)', - }, - }} - > - - } - /> - - {label} - - - ))} - - - - - onChangeHandler({ - key: 'isSubscribeEmail', - value: event.target.checked, - }) - } - /> - } - label="訂閱電子報與島島阿學的新資訊" - /> - - {Object.values(errors).join('') && ( - - )} - -
-
-
-
+ {step === 1 && ( + { + if (validateFields(userState, true)) { + setStep(2); + window.scrollTo({ top: 0, behavior: 'smooth' }); + } + }} + /> + )} + {step === 2 && ( + { + setStep(1); + window.scrollTo({ top: 0, behavior: 'smooth' }); + }} + onNext={handleSubmit} + /> + )} + { + setOpen(false); + router.replace('/'); + }} + onOk={() => { + setOpen(false); + router.replace('/profile'); + }} + /> ); } diff --git a/pages/signin/interest/index.jsx b/pages/signin/interest/index.jsx deleted file mode 100644 index 852c17f5..00000000 --- a/pages/signin/interest/index.jsx +++ /dev/null @@ -1,286 +0,0 @@ -import React, { useMemo, useState, useEffect } from 'react'; -import styled from '@emotion/styled'; -import { useRouter } from 'next/router'; -import { useSelector, useDispatch } from 'react-redux'; -import { updateUser } from '@/redux/actions/user'; - -import { Box, Typography, Button, Skeleton } from '@mui/material'; -import { LazyLoadImage } from 'react-lazy-load-image-component'; -import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; -import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; -import SEOConfig from '@/shared/components/SEO'; -import Navigation from '@/shared/components/Navigation_v2'; -import Footer from '@/shared/components/Footer_v2'; -import { CATEGORIES } from '@/constants/member'; -import TipModal from '@/components/Signin/Interest/TipModal'; - -const HomePageWrapper = styled.div` - --section-height: calc(100vh - 80px); - --section-height-offset: 80px; - background: linear-gradient(0deg, #f3fcfc, #f3fcfc), #f7f8fa; -`; - -const ContentWrapper = styled.div` - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - background-color: #fff; - border-radius: 16px; - margin: 60px auto; - max-width: 50%; - width: 100%; - @media (max-width: 767px) { - max-width: 80%; - .title { - text-overflow: ellipsis; - width: 100%; - } - } -`; - -function SignInInterestPage() { - const router = useRouter(); - const { id } = router.query; - const dispatch = useDispatch(); - - const { - _id: userId, - interestList: userInterestList = [], - email: userEmail, - } = useSelector((state) => state?.user); - - const [interestList, setInterestList] = useState([]); - const [open, setOpen] = useState(false); - - useEffect(() => { - if (userId) { - setInterestList(userInterestList); - } - }, [userId]); - - const onUpdateUser = (successCallback) => { - const payload = { - id: userId, - interestList, - email: userEmail, - }; - dispatch(updateUser(payload)); - successCallback(); - }; - - const SEOData = useMemo( - () => ({ - title: '編輯我的島島資料|島島阿學', - description: - '「島島阿學」盼能透過建立多元的學習資源網絡,讓自主學習者能找到合適的成長方法,進一步成為自己想成為的人,從中培養共好精神。目前正積極打造「可共編的學習資源平台」。', - keywords: '島島阿學', - author: '島島阿學', - copyright: '島島阿學', - imgLink: 'https://www.daoedu.tw/preview.webp', - link: `${process.env.HOSTNAME}${router?.asPath}`, - }), - [router?.asPath], - ); - - return ( - <> - { - setOpen(false); - router.push('/'); - // router.push('/partner'); - }} - onOk={() => { - setOpen(false); - router.push('/profile'); - // router.push('/profile/edit'); - }} - /> - - - - - - - 您對哪些領域感興趣? - - - 請選擇2-6個您想要關注的學習領域 - - - - {CATEGORIES.map(({ label, value, image }) => ( - { - if (interestList.includes(value)) { - setInterestList((state) => - state.filter((data) => data !== value), - ); - } else { - setInterestList((state) => [...state, value]); - } - }} - sx={{ - border: '1px solid #DBDBDB', - borderRadius: '8px', - margin: '4px', - padding: '10px', - width: 'calc(calc(100% - 32px) / 4)', - display: 'flex', - flexDirection: 'column', - justifyItems: 'center', - alignItems: 'center', - cursor: 'pointer', - ...(interestList.includes(value) - ? { - backgroundColor: '#DEF5F5', - border: '1px solid #16B9B3', - } - : {}), - '@media (max-width: 767px)': { - height: '100% auto', - width: 'calc(calc(100% - 24px) / 2)', - flexBasis: 'calc(calc(100% - 24px) / 2)', - }, - }} - > - - } - /> - - {label} - - - ))} - - - - - - - - - - - - - ); -} - -SignInInterestPage.getLayout = ({ children }) => { - return ( - - - {children} -