diff --git a/components/Marathon/About/index.jsx b/components/Marathon/About/index.jsx new file mode 100644 index 00000000..7cfe1f3e --- /dev/null +++ b/components/Marathon/About/index.jsx @@ -0,0 +1,48 @@ +import styled from '@emotion/styled'; +import { Typography } from '@mui/material'; +import { useRouter } from 'next/router'; + +const GuideWrapper = styled.div` + width: 90%; + margin: 0 auto; + padding-top: 40px; + padding-bottom: 40px; + + .guide-title { + color: #536166; + font-weight: bold; + font-size: 40px; + line-height: 50px; + letter-spacing: 0.08em; + margin-left: '20px'; + } + + @media (max-width: 767px) { + padding-top: 40px; + padding-bottom: 20px; + } +`; + +const About = () => { + const router = useRouter(); + return ( + + + 計畫進行方式與內容 + + + ); +}; + +export default About; diff --git a/components/Marathon/Banner/CardList/Card.jsx b/components/Marathon/Banner/CardList/Card.jsx new file mode 100644 index 00000000..9ecc4c21 --- /dev/null +++ b/components/Marathon/Banner/CardList/Card.jsx @@ -0,0 +1,39 @@ +import styled from '@emotion/styled'; +import Link from 'next/link'; + +const CardWrapper = styled.li` + border-radius: 10px; + width: 260px; + height: 320px; + overflow: hidden; + cursor: pointer; +`; + +const ContentWrapper = styled.div` + height: 260px; + background-image: url(${(props) => props.image}); + background-repeat: no-repeat; + background-size: 100% 100%; +`; + +const FooterWrapper = styled.div` + background-color: #ffffff; + display: flex; + align-items: center; + height: 60px; + padding-left: 20px; + font-weight: 500; +`; + +const Card = ({ title, link, image }) => { + return ( + + + + {title} + + + ); +}; + +export default Card; diff --git a/components/Marathon/Banner/CardList/index.jsx b/components/Marathon/Banner/CardList/index.jsx new file mode 100644 index 00000000..5a938c24 --- /dev/null +++ b/components/Marathon/Banner/CardList/index.jsx @@ -0,0 +1,31 @@ +import styled from '@emotion/styled'; +import Card from './Card'; + +const CardListWrapper = styled.ul` + display: flex; + flex-wrap: wrap; + justify-content: center; + align-items: center; + padding-top: 20px; + padding-bottom: 20px; + li { + margin: 20px; + } +`; + +const CardList = ({ list }) => { + return ( + + {list.map((category) => ( + + ))} + + ); +}; + +export default CardList; diff --git a/components/Marathon/Banner/Title/index.jsx b/components/Marathon/Banner/Title/index.jsx new file mode 100644 index 00000000..07fb1009 --- /dev/null +++ b/components/Marathon/Banner/Title/index.jsx @@ -0,0 +1,51 @@ +import React from 'react'; +import styled from '@emotion/styled'; +import Typed from 'react-typed'; + +const TitleWrapper = styled.div` + min-height: 70px; + h1 { + font-size: 24px; + line-height: 28px; + letter-spacing: 0.08em; + color: #f0f0f0; + font-weight: 500; + text-align: center; + } + + h2 { + font-size: 16px; + line-height: 22px; + letter-spacing: 0.08em; + text-align: center; + margin-top: 10px; + color: #f0f0f0; + font-weight: 500; + } + @media (max-width: 768px) { + min-height: 130px; + } +`; + +const Title = () => { + return ( + + + + + + + + + ); +}; + +export default Title; diff --git a/components/Marathon/Banner/index.jsx b/components/Marathon/Banner/index.jsx new file mode 100644 index 00000000..64d71b61 --- /dev/null +++ b/components/Marathon/Banner/index.jsx @@ -0,0 +1,74 @@ +import { useRouter } from 'next/router'; +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'; + +const StyledBanner = styled.div` + position: relative; + height: 398px; + + picture { + position: absolute; + z-index: -1; + width: 100%; + top: 0; + height: 100%; + + img { + height: inherit; + object-fit: cover; + } + } +`; + +const StyledBannerContent = styled.div` + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + padding-top: 100px; + + h1 { + margin-bottom: 8px; + font-weight: 700; + font-size: 36px; + line-height: 140%; + color: #536166; + } + + p { + font-weight: 400; + font-size: 14px; + line-height: 140%; + color: #536166; + } +`; + +const Banner = () => { + const router = useRouter(); + + return ( + + + + + + 島島盃 - 學習馬拉松 2025 春季賽 + 註冊並加入我們,立即報名! + + router.push('/learning-marathon/login')}>立即報名 + + + + ); +}; + +export default Banner; diff --git a/components/Marathon/Edm/index.jsx b/components/Marathon/Edm/index.jsx new file mode 100644 index 00000000..a55ac0df --- /dev/null +++ b/components/Marathon/Edm/index.jsx @@ -0,0 +1,108 @@ +import React from 'react'; +import styled from '@emotion/styled'; +import { Button, Box, Typography, Link } from '@mui/material'; + +const EdmWrapper = styled.div` + width: 90%; + /* height: calc(var(--section-height) + var(--section-height-offset)); */ + margin: 0 auto; + padding-top: 40px; + padding-bottom: 120px; + @media (max-width: 767px) { + padding-top: 40px; + padding-bottom: 20px; + } +`; + +function Edm() { + return ( + + + + 想收到最新資訊嗎? + + + 歡迎訂閱島島電子報 + + + 每月與您分享最新資訊,內容包含:國內外教育新聞、自學經驗分享、實驗教育職缺、每月最新自學資源 + + + + 訂閱電子報 + + + + + ); +} + +export default Edm; diff --git a/components/Marathon/SignUp/ConfirmForm.jsx b/components/Marathon/SignUp/ConfirmForm.jsx new file mode 100644 index 00000000..7513f799 --- /dev/null +++ b/components/Marathon/SignUp/ConfirmForm.jsx @@ -0,0 +1,511 @@ +import { useState, useEffect } from 'react'; +import styled from '@emotion/styled'; +import { EDUCATION_STEP, ROLE } from '@/constants/member'; +import toast from 'react-hot-toast'; +import { initialState as reduxInitMarathonState } from '@/redux/reducers/marathon'; +import { useRouter } from 'next/router'; +import { useDispatch, useSelector } from 'react-redux'; +import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined'; +import { + createMarathonProfileByToken, + updateMarathonProfile, +} from '@/redux/actions/marathon'; +import { + Box, + Typography, + Checkbox, + Radio, + FormControlLabel, +} from '@mui/material'; + +import { + StyledSection, + StyledButtonGroup, + StyledButton, + StyledGroup +} from './Edit.styled'; +import MilestoneGroup from './MilestoneGroup'; + +const StyledMarathonTitleSection = styled(Box)` + padding: 10px; + width: 100%; + + .tag { + display: inline-block; + width: auto; + padding: 3px 10px; + border-radius: 4px; + background-color: #DEF5F5; + + span { + color: #16B9B3; + font-size: 12px; + font-weight: 500; + line-height: 140%; + display: flex; + gap: 4px; + align-items: center; + + &:before { + content: ""; + display: block; + width: 8px; + height: 8px; + background-color: #16B9B3; + border-radius: 100%; + } + } + } + + h2 { + margin-top: 8px; + color: #536166; + font-size: 22px; + font-style: normal; + font-weight: 700; + line-height: 140%; + } +`; +const StyledSectionTitle = styled(Typography)` + color: #293A3D; + font-size: 16px; + font-style: normal; + font-weight: 500; + line-height: 140%; + margin-bottom: 8px; +`; +const StyledDivider = styled.hr` + margin: 20px 0; +`; +const StyledUserSection = styled.div` + width: 100%; + padding: 30px; + border-radius: 16px; + border: 1px solid #DBDBDB; + background-color: #FFF; + margin-top: 16px; + + .content { + width: 88%; + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-start; + } + + .avatar { + width: 40px; + height: 40px; + margin-right: 12px; + border-radius: 100%; + } + + .user { + flex-grow: 0; + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: center; + margin-right: 10px; + } + + .userName { + margin-bottom: 4px; + font-size: 14px; + font-weight: 500; + line-height: 140%; + } + + .userType { + font-size: 14px; + font-weight: 400; + line-height: 140%; + } + + .userTags { + flex-grow: 1; + margin-bottom: auto; + } + + .userTags .tag { + color: #293A3D; + font-size: 14px; + font-weight: 400; + line-height: 140%; + border-radius: 4px; + padding: 3px 10px; + background: #F3F3F3; + } + + .location { + margin-bottom: auto; + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-start; + color: #536166; + font-family: "Noto Sans TC"; + font-size: 14px; + font-weight: 400; + line-height: 140%; + + .MuiSvgIcon-root { + width: 16px; + height: 16px; + margin-right: 4px; + } + } +`; + +const StyledTags = styled(Box)` + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-start; + gap: 8px; + + .tag { + padding: 2px 8px; + border-radius: 4px; + background-color: #DEF5F5; + } + + .tag span { + font-size: 14px; + font-weight: 400; + line-height: 140%; + color: #293A3D; + } +`; +const StyledFormControlLabel = styled(FormControlLabel)` + margin: 0; + + .MuiRadio-root.Mui-disabled, + .MuiCheckbox-root.Mui-disabled { + padding: 0; + margin-right: 4px; + color: rgba(22, 185, 179, 0.5); + } + .MuiFormControlLabel-label.Mui-disabled { + color: #293A3D; + } +`; +const StyledParagraph = styled.p` + font-size: 16px; + font-weight: 400; + line-height: 140%; + color: #011416; +`; +const StyledNote = styled(Typography)` + font-size: 14px; + font-weight: 400; + line-height: 140%; + height: 40px; + display: flex; + flex-direction: row; + align-items: center; +`; + +export default function ConfirmForm({ + setCurrentStep, + currentStep, +}) { + const [hasClickSubmitButton, setHasClickSubmitButton] = useState(false); + const reduxDispatch = useDispatch(); + const marathonState = useSelector((state) => { return state.marathon; }); + const userState = useSelector((state) => { return state.user; }); + const token = useSelector((state) => { return state.user.token; }); + const [newMarathon, setNewMarathon] = useState(reduxInitMarathonState); + const router = useRouter(); + const [user, setUser] = useState({ + name: "", + token: "", + roleList: "", + education: "", + avatar: "" + }); + + const onPrevStep = () => { + setCurrentStep(currentStep - 1); + }; + + useEffect(() => { + setNewMarathon(marathonState); + }, []); + + useEffect(() => { + if (userState._id) { + let userLocation = userState?.location; + let userRole = userState?.roleList; + let userEdu = userState?.educationStage; + + if (userState?.location?.length > 1) { + userLocation = userState?.location.split('@')[1]; + } + + if (userState?.roleList?.length) { + userRole = ROLE.find((item) => item.key === userState.roleList[0])?.label; + } + + if (userState?.educationStage) { + userEdu = EDUCATION_STEP.find((item) => item.key === userState.educationStage)?.label; + } + setUser({ + name: userState.name, + token: userState.token, + role: userRole, + education: userEdu, + avatar: userState.photoURL, + location: userLocation + }); + } else { + router.push('/learning-marathon/login'); + } + }, [userState]); + const onSubmit = async () => { + if (!marathonState) { + console.error('no data to submit'); + return; + } + + const submitData = { + ...marathonState, + userId: userState._id, + status: 'Complete' + }; + if (marathonState._id) { + reduxDispatch(updateMarathonProfile(token, marathonState._id, submitData)); + localStorage.removeItem('newMarathon'); + } else { + // if first time signup, create profile + reduxDispatch(createMarathonProfileByToken(token, submitData)); + localStorage.removeItem('newMarathon'); + } + setHasClickSubmitButton(true); + }; + + useEffect(() => { + switch (marathonState.apiState) { + case 'success': { + toast.success('更新成功'); + break; + } + case 'Reject': { + toast.error('更新失敗'); + break; + } + default: + } + }, [user.apiState]); + useEffect(() => { + if (marathonState._id && hasClickSubmitButton) { + router.push('/learning-marathon/success'); + } + }, [hasClickSubmitButton, user.apiState, marathonState]); + return ( + <> + + + 徵件計畫 + + 學習主題名稱:{marathonState?.title} + + + + + + {user?.name} + {user?.role} + + + {user?.education} + + {user?.location} + + + + + 計畫簡述 + {marathonState?.description} + + 學習動機 + + {marathonState?.motivation?.tags?.map((tag, _i) => { + return ( + + {tag} + + ); + })} + + {marathonState?.motivation?.description || ''} + + 學習目標 + {marathonState?.goals} + + 學習內容 + {marathonState?.content} + + 學習方法與策略 + + {marathonState?.strategies?.tags.map((tag, _i) => { + return ( + + {tag} + + ); + })} + + {marathonState?.strategies?.description || ''} + + 學習資源 + + + {newMarathon?.resources} + + + + + + + + + + 學習成果及呈現方式 + + {marathonState?.outcomes?.tags?.map((tag, _i) => { + return ( + + {tag} + + ); + })} + + {marathonState?.outcomes?.description || ''} + + + ) + } + /> + + + 報名的資格 + + ) + } + label={marathonState?.pricing?.option} + /> + { + marathonState?.pricing?.file && ( + + + 證明文件的連結 + + + + {marathonState.pricing.file} + + + + ) + } + { + marathonState?.pricing?.email?.length > 0 && ( + <> + + + 夥伴的 Email + + { + marathonState.pricing.email.map((email, _i) => { + return ( + + + {email} + + + ); + }) + } + + > + ) + } + + 主辦單位將於報名成功後,確認並通知各報名者須繳交之費用 + + + + + 上一步 + + + 提交報名 + + + > + ); +} diff --git a/components/Marathon/SignUp/Edit.styled.jsx b/components/Marathon/SignUp/Edit.styled.jsx new file mode 100644 index 00000000..59ff338c --- /dev/null +++ b/components/Marathon/SignUp/Edit.styled.jsx @@ -0,0 +1,209 @@ +import styled from '@emotion/styled'; +import { + Box, + Typography, + Button, + InputBase, + TextareaAutosize +} from '@mui/material'; + +export const MarathonSignUpWrapper = styled(Box)` +min-height: 100vh; +padding-bottom: 80px; +`; + +export const FormWrapper = styled.form` + --section-height: calc(100vh - 80px); + --section-height-offset: 80px; +`; + +export const ContentWrapper = styled.div` + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + border-radius: 16px; + margin: 0 auto; + width: 737px; + max-width: 100%; + + @media (max-width: 767px) { + width: 100%; + .title { + text-overflow: ellipsis; + width: 100%; + } + } +`; + +export const StyledTitleWrap = styled(Box)` + background-color: #ffffff; + padding: 5%; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + width: 100%; + border-radius: 16px; + + border: 1px solid #DBDBDB; + + h2 { + font-weight: 700; + font-size: 22px; + line-height: 140%; + text-align: center; + color: #536166; + } + + .title-memo { + font-weight: 700; + font-size: 14px; + line-height: 140%; + text-align: center; + color: #536166; + margin-top: 8px; + } +`; +export const StyledMemo = styled.p` + font-weight: 400; + font-size: 14px; + line-height: 140%; + text-align: center; + color: #536166; + margin-top: 8px; +`; +export const StyledSection = styled(Box)` + background-color: #ffffff; + padding: 40px; + width: 100%; + border-radius: 16px; + border: 1px solid #DBDBDB; + + + @media (max-width: 767px) { + padding: 32px 16px; + } + +`; + +export const StyledGroup = styled(Box)` + width: 100%; + display: flex; + flex-direction: column; + justify-content: center; + align-items: flex-start; + margin-top: ${({ mt = '20' }) => `${mt}px`}; +`; + +export const StyledSelectWrapper = styled.div` + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + width: 100%; + margin-top: 10px; +`; + +export const StyledSelectText = styled(Typography)` + margin: auto; + font-weight: ${({ isselected }) => + isselected === 'true' ? '700' : 'normal'}; +`; + +export const StyledSelectBox = styled(Box)` + border: 1px solid #dbdbdb; + border-radius: 8px; + padding: 10px; + width: ${({ col = '3' }) => `calc(calc(100% - 16px) / ${col})`}; + display: flex; + justify-items: center; + align-items: center; + cursor: pointer; + background-color: ${({ isselected }) => + isselected === 'true' ? '#DEF5F5' : 'initial'}; + border: ${({ isselected }) => + isselected === 'true' ? '1px solid #16B9B3' : '1px solid #DBDBDB'}; + margin-bottom: 12px; +`; + +export const StyledToggleWrapper = styled(Box)` + border: 1px solid #dbdbdb; + border-radius: 8px; + display: flex; + justify-content: space-between; + align-items: center; + padding: 13px 16px; +`; + +export const StyledToggleText = styled(Typography)` + font-weight: 500; + font-size: 16px; + line-height: 140%; + color: #293a3d; +`; + +export const StyledButtonGroup = styled(Box)` + margin-top: 24px; + width: 737px; + max-width: 100%; + display: flex; + gap: 8px; + + @media (max-width: 767px) { + width: 100%; + } +`; + +export const StyledButton = styled(Button)(({ variant = 'contained' }) => ({ + ...(variant === 'contained' && { + color: '#ffffff', + backgroundColor: '#16b9b3', + }), + width: '100%', + height: '40px', + borderRadius: '20px', +})); + +export const StyledInputBase = styled(InputBase)` + width: 100%; + border: 1px solid #DBDBDB; + background-color: #FFF; + border-radius: 8px; + padding: 12px 16px; + box-sizing: border-box; + + &.Mui-focused { + border: 2px solid #16B9B3; + padding: 11px 15px; + } + + .MuiInputBase-input { + padding: 0; + line-height: 140%; + } + + &.milestone.Mui-focused { + border-width: 1px; + padding: 12px 16px; + } +`; +export const StyledTextareaAutosize = styled(TextareaAutosize)` + width: 100%; + padding: 12px 16px; + width: 100%; + min-height:100px; + border-radius: 8px; + border: 1px solid #DBDBDB; + + &:focus, &:focus-visible { + border: 2px solid #16B9B3; + padding: 11px 15px; + outline-color: #16B9B3; + } + + .MuiInputBase-input { + padding: 0; + line-height: 140%; + } +`; \ No newline at end of file diff --git a/components/Marathon/SignUp/EditFormInput.jsx b/components/Marathon/SignUp/EditFormInput.jsx new file mode 100644 index 00000000..8145a47d --- /dev/null +++ b/components/Marathon/SignUp/EditFormInput.jsx @@ -0,0 +1,36 @@ +import { forwardRef } from 'react'; +import { Typography, TextField } from '@mui/material'; +import { StyledGroup } from './Edit.styled'; + +function EditFormInput( + { + title = '', + parmKey = '', + value = '', + onChange = () => ({}), + errorMsg = '', + isRequire = false, + placeholder = '', + }, + ref, +) { + return ( + + + {title} {isRequire && '*'} + + onChange({ key: parmKey, value: e.target.value })} + error={!!errorMsg} + helperText={errorMsg} + /> + + ); +} + +export default forwardRef(EditFormInput); diff --git a/components/Marathon/SignUp/EditProfileConstant.js b/components/Marathon/SignUp/EditProfileConstant.js new file mode 100644 index 00000000..b674ff0a --- /dev/null +++ b/components/Marathon/SignUp/EditProfileConstant.js @@ -0,0 +1,21 @@ +export const NAME = 'name'; +export const PHOTO_URL = 'photoURL'; +export const BIRTHDAY = 'birthDay'; +export const GENDER = 'gender'; +export const ROLE_LIST = 'roleList'; +export const WANT_TO_DO_LIST = 'wantToDoList'; +export const INSTAGRAM = 'instagram'; +export const FACEBOOK = 'facebook'; +export const DISCORD = 'discord'; +export const LINE = 'line'; +export const EDUCATION_STAGE = 'educationStage'; +export const LOCATION = 'location'; +export const TAG_LIST = 'tagList'; +export const SELF_INTRODUCTION = 'selfIntroduction'; +export const SHARE = 'share'; +export const IS_OPEN_LOCATION = 'isOpenLocation'; +export const IS_OPEN_PROFILE = 'isOpenProfile'; +export const IS_LOADING_SUBMIT = 'isLoadingSubmit'; +export const COUNTRY = 'country'; +export const CITY = 'city'; +export const DISTRICT = 'district'; diff --git a/components/Marathon/SignUp/EditSubMilestone.jsx b/components/Marathon/SignUp/EditSubMilestone.jsx new file mode 100644 index 00000000..acff0f83 --- /dev/null +++ b/components/Marathon/SignUp/EditSubMilestone.jsx @@ -0,0 +1,337 @@ +import { useState } from 'react'; +import styled from "@emotion/styled"; +import { + Typography, + Box, + Grid, + IconButton, + MenuItem, + Select, + InputBase, +} from '@mui/material'; +import CalendarTodayOutlinedIcon from '@mui/icons-material/CalendarTodayOutlined'; +import SendIcon from '@mui/icons-material/Send'; +import ClearIcon from '@mui/icons-material/Clear'; +import { + ZH_WEEK_DAY_MAP, + ISOToWeekday, + weekdayToISO +} from './dateMap'; + +const StyledMenuItem = styled(MenuItem)` + padding: 8px; + margin-bottom: 4px; + margin-right: 4px; + border-radius: 4px; +`; + +const FixedLabel = styled(Typography)` + font-size: 14px; + color: #293A3D; + width: 20px; + text-align: center; + width: 20px; + flex-shrink: 0; +`; +const StyledContainer = styled(Box)` + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + gap: 10px; + width: 100%; + + .content { + flex-grow: 1; + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-start; + gap: 10px; + } + + .buttons { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + gap: 10px; + } +`; + +const StyledButtonGroup = styled(Box)` + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + gap: 10px; +`; +const StyledGridItem = styled(Grid)` + background-color: #FFF; + display: flex; + padding: 12px 16px; + flex-direction: column; + align-items: center; + gap: 8px; + align-self: stretch; + border-radius: 8px; + + &:focus-within { + border: 1px solid #16B9B3; + padding: 11px 15px; + } + + .title { + display: flex; + flex-direction: row; + align-items: center; + width: 100%; + justify-content: space-between; + flex-wrap: nowrap; + + p { + color: #293A3D; + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 140%; + } + } +`; +const StyledWeekdaySelector = styled(Select)` + font-size: 12px; + font-style: normal; + font-weight: 300; + line-height: 140%; + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-start; + width: 100%; + max-width: 150px; + min-width: 56px; + gap: 8px; + padding: 0 0 0 0; + height: 100%; + + .MuiSelect-select.MuiSelect-multiple { + padding: 0; + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 140%; + color: #92989A; + } + + .MuiSvgIcon-root { + width: 16px; + height: 16px; + fill: #92989A; + } +`; +const StyledInputBase = styled(InputBase)` + width: 100%; + border-radius: 8px; + padding: 0; + box-sizing: border-box; + + &.Mui-focused { + padding: 0px; + } + + &:focus-visible { + outline: none; + } + + .MuiInputBase-input { + padding: 0; + line-height: 140%; + font-size: 14px; + font-weight: 400; + line-height: 140%; + + &:focus, &:focus-visible { + outline: 0; + } + } +`; + +const StyledCancelButton = styled(IconButton)` + &.MuiIconButton-root { + display: flex; + width: 24px; + height: 24px; + justify-content: center; + align-items: center; + gap: 10px; + border-radius: 2px; + opacity: 0.5; + background-color: #DBDBDB; + + @media (hover: hover) { + &:hover { + background-color: #89DAD7; + } + } + } + + .MuiSvgIcon-root { + width: 18px; + height: 18px; + flex-shrink: 0; + } +`; +const StyledSubmitButton = styled(IconButton)` + + &.MuiIconButton-root { + display: flex; + width: 24px; + height: 24px; + justify-content: center; + align-items: center; + gap: 10px; + border-radius: 2px; + opacity: 0.5; + background-color: #DBDBDB; + + @media (hover: hover) { + &:hover { + background-color: #89DAD7; + } + } + } + + .MuiSvgIcon-root { + width: 18px; + height: 18px; + flex-shrink: 0; + } +`; + +export default function EditSubMilestone({ + milestone = { + dates: [], + name: '', + description: '', + }, + index = 0, + onShow, + onSubmit, + tempId, + type = 'create' +}) { + const [newMilestone, setNewMilestone] = useState(milestone); + + const handleChangeWeekdays = (e) => { + setNewMilestone({ + ...newMilestone, + dates: e.target.value + }); + }; + + const handleChangeName = (e) => { + setNewMilestone({ + ...newMilestone, + name: e.target.value + }); + }; + const handleClickSendButton = () => { + onShow(false); + onSubmit({ + ...newMilestone, + _tempId: tempId + }); + }; + const handleCloseEditPanel = () => { + onShow(false); + }; + + return ( + + + {`${index + 1}.`} + + + + )} /> + )} + renderValue={ + (selected) => + selected?.length ? selected + .map((ISODate) => ISOToWeekday(ISODate)) + .filter(Boolean) + .join(", ") : '自訂' + } + sx={{ + '.MuiSelect-icon': { + display: 'none', + }, + }} + MenuProps={{ + PaperProps: { + style: { + padding: '12px', + maxHeight: 150, + overflowY: 'auto', + scrollbarWidth: 'thin', + maxWidth: '140px' + }, + }, + MenuListProps: { + style: { + padding: '0' + } + } + }} + > + {ZH_WEEK_DAY_MAP.map((zhDay) => { + const isSelected = newMilestone.dates.includes(weekdayToISO(zhDay)); + return ( + + {zhDay} + + ); + })} + + + + + + + + + + + + + ); +} diff --git a/components/Marathon/SignUp/ErrorMessage.jsx b/components/Marathon/SignUp/ErrorMessage.jsx new file mode 100644 index 00000000..45b38d6d --- /dev/null +++ b/components/Marathon/SignUp/ErrorMessage.jsx @@ -0,0 +1,30 @@ +import { IoMdCloseCircleOutline } from 'react-icons/io'; +import { Box, Typography } from '@mui/material'; + +const ErrorMessage = ({ errText }) => { + return ( + errText && ( + + + {errText} + + ) + ); +}; + +export default ErrorMessage; diff --git a/components/Marathon/SignUp/MarathonForm.jsx b/components/Marathon/SignUp/MarathonForm.jsx new file mode 100644 index 00000000..590a2c94 --- /dev/null +++ b/components/Marathon/SignUp/MarathonForm.jsx @@ -0,0 +1,455 @@ +import { useState, useEffect, useReducer } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { + updateNewMarathon +} from '@/redux/actions/marathon'; +import { initialState as reduxInitMarathonState } from '@/redux/reducers/marathon'; + +import { + Box, + Typography, + FormControlLabel, + Checkbox, +} from '@mui/material'; + +import MilestoneGroup from './MilestoneGroup'; +import { + StyledGroup, + StyledSection, + StyledButtonGroup, + StyledButton, + StyledInputBase, + StyledTextareaAutosize, +} from './Edit.styled'; +import MultiSelectDropdown from './MultiSelectDropdown'; +import PricingForm from './PricingForm'; + +const marathonFormReducer = (state, action) => { + const { key, value } = action.payload; + switch (action.type) { + case 'SET_NEW_MARATHON': + return { + ...state, + ...action.payload.value + }; + case 'UPDATE_FIELD': + return { + ...state, + [key]: value + }; + case 'UPDATE_MOTIVATION_FIELD': + return { + ...state, + motivation: { + ...state.motivation, + [key]: value, + } + }; + case 'UPDATE_STRATEGIES_FIELD': + return { + ...state, + strategies: { + ...state.strategies, + [key]: value, + } + }; + case 'UPDATE_OUTCOMES_FIELD': + return { + ...state, + outcomes: { + ...state.outcomes, + [key]: value, + } + }; + default: + return state; + } +}; + +export default function MarathonForm({ + setCurrentStep, + currentStep, +}) { + const reduxDispatch = useDispatch(); + const [hasLoaded, setHasLoaded] = useState(false); + + const marathonState = useSelector((state) => { return state.marathon; }); + const localStorgeStored = window.localStorage.getItem('newMarathon'); + const editingMarathon = localStorgeStored ? JSON.parse(localStorgeStored) : null; + + const initialState = () => { + // 優先使用編輯中的資料,其次使用暫存在 marathonState 的資料,最後使用 reduxInit 預設模板 + return editingMarathon || marathonState || reduxInitMarathonState; + }; + const [newMarathon, setNewMarathon] = useReducer(marathonFormReducer, initialState()); + + const onNextStep = () => { + reduxDispatch(updateNewMarathon(newMarathon)); + setCurrentStep(currentStep + 1); + }; + + const onPrevStep = () => { + reduxDispatch(updateNewMarathon(newMarathon)); + setCurrentStep(currentStep - 1); + }; + + useEffect(() => { + setHasLoaded(true); + }, []); + + useEffect(() => { + if (newMarathon && hasLoaded) { + window.localStorage.setItem('newMarathon', JSON.stringify(newMarathon)); + } + }, [newMarathon]); + + return ( + <> + + + 學習計畫 + + + 計劃內容在報名截止日前皆可修改。 + 入選公告後,所有入選者及報名者亦可持續修改學習計劃 + + + + { + setNewMarathon({ + type: 'UPDATE_FIELD', + payload: { key: 'title', value: e.target.value } + }); + }} + sx={{ + mb: '8px', + padding: '17px 16px 12px' + }} + placeholder="範例:成為一位Youtuber、半世紀以來的氣候變遷紀錄研究、開一間線上甜點店" + /> + + + 計畫簡述 * + + + 請摘要學習計畫。包含你為什麼想做此計畫?你的目標是什麼呢?預計如何達成? + + { + setNewMarathon({ + type: 'UPDATE_FIELD', + payload: { + key: 'description', + value: e.target.value + } + }); + }} + placeholder="範例:因為對剪影片和當 Youtuber 有興趣,我預計會研究搞笑型 Youtuber 的影片腳本與剪輯方式、拍攝我日常生活及練習剪輯,並建立 Youtube 頻道上傳影片。希望能藉此了解如何當一位 Youtuber。" + /> + + + + 學習動機 * + + + 為什麼會想啟動這個學習計畫?受到哪些經歷、刺激、啟發,包含相關生活、學習等經驗。 + + + { + setNewMarathon({ + type: 'UPDATE_MOTIVATION_FIELD', + payload: { + key: 'description', + value: e.target.value + } + }); + }} + value={newMarathon?.motivation?.description || ''} + placeholder="範例:因為同學常常說我很好笑,很適合把生活日常做成影片,我也發現自己對做影片、當Youtuber有興趣,所以想要嘗試累積作品,並開一個 Youtuber 頻道。" + /> + + + + 學習目標 * + + + 你希望學習後獲得什麼收穫?例如知識或技能的習得,又或者態度或習慣的改變。 + + { + setNewMarathon({ + type: 'UPDATE_FIELD', + payload: { + key: 'goals', + value: e.target.value + } + }); + }} + value={newMarathon.goals || ''} + placeholder="範例: +- 能收集並分析搞笑風格的 Youtuber +- 能拍攝畫面穩定、清晰且具專業感的影片" + /> + + + + 學習內容 * + + + 依據你的學習目標,你具體會學哪些內容呢?例如特定的知識、技能、思維、習慣等。 + + { + setNewMarathon({ + type: 'UPDATE_FIELD', + payload: { + key: 'content', + value: e.target.value + } + }); + }} + value={newMarathon.content || ''} + placeholder="範例: +- 內容規劃與創意發想(定位、主題、腳本) +- 基礎拍攝技術(攝影設備、燈光、語音) +- 影片剪輯與後製(剪輯軟體、配樂)" + /> + + + + 學習方法與策略 * + + + 你會如何學習?請先勾選預計的學習方法,並敘述各種學習方法會如何相互搭配。此外,你會如何在過程中使用什麼方式紀錄你的學習呢?例如文字筆記以部落格文章做分享等。 + + + { + setNewMarathon({ + type: "UPDATE_STRATEGIES_FIELD", + payload: { + key: 'description', + value: e.target.value + } + }); + }} + value={newMarathon?.strategies?.description || ''} + placeholder="範例:我預計會研究影片腳本、拍攝與剪輯方式,接著了解拍攝、剪輯與Youtube頻道經營,並同時練習拍攝與剪輯,開始經營頻道。我會用notion整理我收集到的資料以及筆記。" + /> + + + + 學習資源 * + + + 你會使用哪些資源呢?包含網路資源的連結、書籍名稱、人/組織、社群、活動/課程、學習工具等,請至少附上名稱與相關連結 + + setNewMarathon({ + type: 'UPDATE_FIELD', + payload: { + key: 'resources', + value: e.target.value + } + })} + /> + + + + + + + + + + + + + 學習成果及呈現方式 * + + + 你最終會用何種方式統整與呈現你所有學習收穫呢? + + + { + setNewMarathon({ + type: 'UPDATE_OUTCOMES_FIELD', + payload: { + key: 'description', + value: e.target.value + } + }); + }} + value={newMarathon?.outcomes?.description || ''} + placeholder="範例:我預計會架設一個Youtube頻道,並上傳至少5支影片,並整理觀眾回饋與相關數據。" + /> + { + setNewMarathon({ + type: 'UPDATE_FIELD', + payload: { + key: 'isPublic', + value: e.target.checked + } + }); + }} + sx={{ + padding: '0', + marginRight: '5px' + }} + /> + ) + } + /> + + + + + + + + + 上一步 + + + 下一步 + + + > + + ); +} diff --git a/components/Marathon/SignUp/MilestoneGroup.jsx b/components/Marathon/SignUp/MilestoneGroup.jsx new file mode 100644 index 00000000..d91fba10 --- /dev/null +++ b/components/Marathon/SignUp/MilestoneGroup.jsx @@ -0,0 +1,294 @@ +import { v4 as uuidv4 } from 'uuid'; +import { useState, useEffect } from "react"; +import { + Box, + Grid, + TextField, + MenuItem, + Typography, +} from "@mui/material"; +import dayjs from 'dayjs'; +import { DatePicker } from '@mui/x-date-pickers'; +import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import { StyledGroup } from "./Edit.styled"; +import MilestonePanel from "./MilestonePanel"; + +export default function MilestoneGroup({ + milestones = [], + onChange = null, + isDisabled = false +}) { + const testMilestones = [ + { + name: "第一階段 - 初步學習", + startDate: "2024-01-10T00:00:00.000Z", + endDate: "2024-02-10T00:00:00.000Z", + subMilestones: [ + { + name: "學習自主學習方法", + dates: ["2024-01-05T00:00:00.000Z", "2024-01-06T00:00:00.000Z"], + description: "學員將學習不同的自主學習方法,並選擇適合自己的方式。", + }, + { + name: "設立學習目標", + dates: ["2024-01-07T00:00:00.000Z", "2024-01-08T00:00:00.000Z"], + description: "學員將設立短期和長期的學習目標。", + }, + ], + }, + { + name: "第二階段 - 應用實踐", + startDate: "2024-03-01T00:00:00.000Z", + endDate: "2024-03-15T00:00:00.000Z", + subMilestones: [ + { + name: "實踐學習技巧", + dates: ["2024-02-25T00:00:00.000Z", "2024-02-28T00:00:00.000Z"], + description: "學員將開始實踐所學的時間管理與學習技巧。", + }, + { + name: "評估學習成果", + dates: ["2024-03-10T00:00:00.000Z", "2024-03-12T00:00:00.000Z"], + description: "學員將評估自己在學習過程中的進展,並調整學習策略。", + }, + ], + }, + ]; + + const eventWeekRange = 11; // Must be 11 weeks + const [startDate, setStartDate] = useState(dayjs()); + const [endDate, setEndDate] = useState(dayjs().add('11', 'week')); + const [frequency, setFrequency] = useState('weekly'); + + function calculateMilestones( + dateToStart = dayjs(), + freq = 'weekly', + defaultMilestones = [] + ) { + const interval = (freq === 'weekly') ? 7 : 14; + const milestoneLength = (freq === 'weekly') ? 11 : 6; + const week = (freq === 'weekly') ? ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十', '十一'] : ['一', '三', '五', '七', '九', '十一']; + const newData = []; + const mode = defaultMilestones.length ? 'modify' : 'create'; + + for (let i = 0; i < milestoneLength; i += 1) { + const start = dayjs(dateToStart).add(i * interval, 'day'); + const end = start.add(interval - 1, 'day'); + const existingMilestone = defaultMilestones[i] || {}; + const newSubMilestones = (mode === 'create') ? [] : existingMilestone.subMilestones || []; + + newData.push({ + ...existingMilestone, + _tempId: existingMilestone._tempId || `temp_${uuidv4()}`, + name: existingMilestone.name || '', + startDate: start.format('YYYY-MM-DD'), + endDate: end.format('YYYY-MM-DD'), + weekNumber: `第${week[i]}週`, + subMilestones: newSubMilestones + }); + } + + return [...newData]; + } + const handleFrequency = (e) => { + // if change frequency, clear all data + setFrequency(e.target.value); + const changedMilestones = calculateMilestones(startDate, e.target.value, []); + onChange({ + type: 'UPDATE_FIELD', + payload: { + key: 'milestones', + value: changedMilestones + } + }); + }; + const handleStartDate = (eventStartDate) => { + setStartDate(eventStartDate); + const eventEndDate = dayjs(eventStartDate).add(eventWeekRange, 'week'); + setEndDate(eventEndDate); + const changedMilestones = calculateMilestones(eventStartDate, frequency, milestones); + onChange({ + type: 'UPDATE_FIELD', + payload: { + key: 'milestones', + value: changedMilestones + } + }); + }; + const handleEndDate = (fakeDate) => { + const eventEndDate = dayjs(startDate).add(eventWeekRange, 'week'); + setEndDate(eventEndDate); + }; + const updateMilestone = (newMilestone) => { + const changedMilestones = milestones.map((item, _i) => { + return (item._tempId === newMilestone._tempId ? newMilestone : item); + }); + + onChange({ + type: 'UPDATE_FIELD', + payload: { + key: 'milestones', + value: changedMilestones + } + }); + }; + useEffect(() => { + const storgedStartDate = milestones[0]?.startDate || dayjs(); + setStartDate(storgedStartDate); + + const biWeeklyMilestonesLength = 6; + const weeklyMilestonesLength = 11; + let initMilestones = []; + + if (isDisabled) { + if (milestones.length === biWeeklyMilestonesLength) { + setFrequency('biweekly'); + } else { + setFrequency('weekly'); + } + } else { + if (milestones.length === biWeeklyMilestonesLength) { + setFrequency('biweekly'); + initMilestones = calculateMilestones(storgedStartDate, 'biweekly', milestones); + onChange({ + type: 'UPDATE_FIELD', + payload: { + key: 'milestones', + value: initMilestones + } + }); + return; + } + + if (milestones.length === weeklyMilestonesLength) { + setFrequency('weekly'); + initMilestones = calculateMilestones(storgedStartDate, 'weekly', milestones); + onChange({ + type: 'UPDATE_FIELD', + payload: { + key: 'milestones', + value: initMilestones + } + }); + return; + } + + if (!milestones.length) { + setFrequency('weekly'); + initMilestones = calculateMilestones(dayjs(), 'weekly', []); + onChange({ + type: 'UPDATE_FIELD', + payload: { + key: 'milestones', + value: initMilestones + } + }); + } + } + }, []); + + return ( + <> + + 學習里程碑 * + + + 請依據時間與精力設定里程碑(入選後時程表須包含每兩週需繳交的學習任務) + + + + + + + ( + + )} + /> + + + ( + + )} + /> + + + + 每週 + 每兩週 + + + + + + {milestones.map((milestone, _i) => { + return ( + + ); + })} + + + + > + ); +} diff --git a/components/Marathon/SignUp/MilestonePanel.jsx b/components/Marathon/SignUp/MilestonePanel.jsx new file mode 100644 index 00000000..107a90dd --- /dev/null +++ b/components/Marathon/SignUp/MilestonePanel.jsx @@ -0,0 +1,212 @@ +import { v4 as uuidv4 } from 'uuid'; +import { useState } from 'react'; +import styled from "@emotion/styled"; +import dayjs from "dayjs"; + +import { + Grid, + Typography, +} from "@mui/material"; +import ArrowForwardIcon from '@mui/icons-material/ArrowForward'; +import AddIcon from '@mui/icons-material/Add'; +import SubMilestonePanel from './SubMilestonePanel'; +import EditSubMilestone from './EditSubMilestone'; + +import { + StyledInputBase +} from './Edit.styled'; + +const StyledWeek = styled(Typography)` + color: #FFF; + text-align: center; + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 140%; + display: flex; + height: 35px; + padding: 5px 20px; + justify-content: center; + align-items: center; + gap: 10px; + border-radius: 20px; + background: #16B9B3; +`; + +const StyledGridContainer = styled(Grid)` + display: flex; + padding: 10px; + align-items: center; + gap: 10px; + align-self: stretch; + border-radius: 12px; + background-color: #F3F3F3; + width: 100%; + border: 1px solid #ddd; + max-width: 100%; +`; + +const StyledWeekRange = styled.div` + color: #92989A; + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + + padding-right: 0.25em; + + .MuiTypography-root { + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 140%; + } + + .MuiSvgIcon-root{ + width: 14px; + height: 14px; + margin: 0 4px; + } +`; + +const StyledAddButton = styled.button` + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + color: #92989A; + font-size: 14px; + font-weight: 400; + line-height: 140%; + gap: 8px; + padding-right: 0.25em; + + @media (hover: hover) { + &:hover { + color: #16B9B3; + + } + } + +`; + +export default function MilestonePanel({ + milestone, + onChange = null, + isDisabled = false, +}) { + const { + name, + weekNumber, + startDate, + endDate, + subMilestones + } = milestone; + const [onEdit, setOnEdit] = useState(false); + const handleClickAddSubMilestone = () => { + setOnEdit(true); + }; + + const handleChangeMilestoneName = (e) => { + onChange({ + ...milestone, + name: e.target.value + }); + }; + const handleAddSubMilestone = (subMilestone) => { + const newSubMilestones = [...milestone.subMilestones, subMilestone]; + onChange({ + ...milestone, + subMilestones: newSubMilestones + }); + }; + + const handleDeleteSubMilestone = (deletedItem) => { + const newSubMilestones = (milestone.subMilestones).filter((item, _i) => { + return (item._tempId !== deletedItem._tempId); + }); + onChange({ + ...milestone, + subMilestones: newSubMilestones + }); + }; + + const handleEditSubMilestone = (newItem) => { + const newSubMilestones = (milestone.subMilestones).map((item, _i) => { + return (newItem._tempId === item._tempId) ? newItem : item; + }); + onChange({ + ...milestone, + subMilestones: newSubMilestones + }); + }; + + return ( + + + {weekNumber} + + + {dayjs(startDate).format('YYYY/MM/DD')} + + + + {dayjs(endDate).format('YYYY/MM/DD')} + + + + + + + + { + milestone.subMilestones.map((subMilestone, index) => { + return (( + + )); + }) + } + {/* adding pannel */} + {onEdit && ( + + )} + {!isDisabled && ( + + + 新增子任務 + + + )} + + ); +} diff --git a/components/Marathon/SignUp/MultiSelectDropdown.jsx b/components/Marathon/SignUp/MultiSelectDropdown.jsx new file mode 100644 index 00000000..7ac3695f --- /dev/null +++ b/components/Marathon/SignUp/MultiSelectDropdown.jsx @@ -0,0 +1,91 @@ +import styled from '@emotion/styled'; +import { + Select, + MenuItem, + ListItemText, + FormControl, + InputLabel, + OutlinedInput +} from '@mui/material'; + +const StyledMenuItem = styled(MenuItem)` + padding: 8px; + margin-bottom: 4px; + margin-right: 4px; + border-radius: 4px; +`; +const StyledListItemText = styled(ListItemText)` + .MuiTypography-root { + color: #2D3648; + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 20px; + } +`; +export default function MultiSelectDropdown({ + listItems = [], + selectedItems = [], + onChange, + placeholder, + type = "" +}) { + // 設定選擇的項目 + + const handleChange = (event) => { + const value = event.target?.value; + onChange({ + type, + payload: { + key: 'tags', + value + } + }); + }; + + return ( + + 選擇項目 + } + renderValue={(selected) => selected.join(', ')} + placeholder={placeholder} + sx={{ + backgroundColor: '#DEF5F5', + marginBottom: '8px', + }} + MenuProps={{ + PaperProps: { + style: { + padding: '12px', + maxHeight: 300, + overflowY: 'auto', + scrollbarWidth: 'thin' + }, + } + }} + > + { + listItems.map((item, index) => { + return ( + + + + ); + }) + } + + + ); +} diff --git a/components/Marathon/SignUp/PricingForm.jsx b/components/Marathon/SignUp/PricingForm.jsx new file mode 100644 index 00000000..1a801d93 --- /dev/null +++ b/components/Marathon/SignUp/PricingForm.jsx @@ -0,0 +1,306 @@ +import styled from "@emotion/styled"; +import { + Typography, + FormControlLabel, + Box, + Radio, + RadioGroup, +} from '@mui/material'; + +import { + StyledInputBase +} from './Edit.styled'; + +const StyledRadioGroup = styled(RadioGroup)` + width: 100%; + .MuiFormControlLabel-root { + margin: 8px 0; + } + .MuiSvgIcon-root { + font-size: 20px; + } + + .MuiButtonBase-root { + padding: 0; + margin-right: 5px; + + + .MuiTypography-root { + font-size: 14px; + font-weight: 400; + line-height: 140%; + color: #92989A; + } + + &.Mui-checked + .MuiTypography-root { + color: #293A3D; + } + } +`; +const StyledNote = styled(Typography)` + font-size: 14px; + font-weight: 400; + line-height: 140%; + height: 40px; + display: flex; + flex-direction: row; + align-items: center; + +`; + +export default function PricingForm({ + pricing, + onChange, + type +}) { + const handleCheckOption = (e) => { + onChange({ + type, + payload: { + key: 'pricing', + value: { + ...pricing, + option: e.target.value, + email: [], + file: "" + } + } + }); + }; + const handleChangeFile = (e) => { + onChange({ + type, + payload: { + key: 'pricing', + value: { + ...pricing, + file: e.target.value + } + } + }); + }; + + const handleChangeEmail = (e, index) => { + const emails = pricing.email || []; + emails[index] = e.target.value; + onChange({ + type, + payload: { + key: 'pricing', + value: { + ...pricing, + email: emails + } + } + }); + }; + + return ( + <> + + 請選擇你報名的資格 + + + + + ) + } + label="中低收入戶:將提供三位免費參與資格" + /> + {(pricing.option === "中低收入戶:將提供三位免費參與資格") && ( + + + 請將證明文件上傳至雲端空間後,將連結填入以下欄位 + + + + )} + + + ) + } + label="優惠價:8000 元" + /> + + ) + } + label="個人早鳥價:6000 元(早鳥優惠截止至 2024年12月31日 23:59 分)" + /> + + + ) + } + label="2人團報價:10000元(一人5000元)" + /> + {(pricing.option === '2人團報價:10000元(一人5000元)') && ( + + + 請填入夥伴的 Email + + handleChangeEmail(e, 0)} + sx={{ marginBottom: '8px' }} + value={pricing.email[0] || ''} + /> + + )} + + + + ) + } + label="3人團報價:12000元(一人4000元)" + /> + {(pricing.option === "3人團報價:12000元(一人4000元)") && ( + + + 請填入夥伴的 Email + + handleChangeEmail(e, 0)} + sx={{ marginBottom: '8px' }} + value={pricing.email[0] || ''} + /> + handleChangeEmail(e, 1)} + sx={{ marginBottom: '8px' }} + value={pricing.email[1] || ''} + /> + + )} + + + + ) + } + label="4人團報價:12000元(一人3000元)" + /> + {(pricing.option === "4人團報價:12000元(一人3000元)") && ( + + + 請填入夥伴的 Email + + handleChangeEmail(e, 0)} + sx={{ marginBottom: '8px' }} + value={pricing.email[0] || ''} + /> + handleChangeEmail(e, 1)} + sx={{ marginBottom: '8px' }} + value={pricing.email[1] || ''} + /> + handleChangeEmail(e, 2)} + sx={{ marginBottom: '8px' }} + value={pricing.email[2] || ''} + /> + + )} + + + + 主辦單位將於報名成功後,確認並通知各報名者須繳交之費用 + + > + ); +} diff --git a/components/Marathon/SignUp/SaveBar.jsx b/components/Marathon/SignUp/SaveBar.jsx new file mode 100644 index 00000000..ca1c1d7d --- /dev/null +++ b/components/Marathon/SignUp/SaveBar.jsx @@ -0,0 +1,81 @@ +import { useState } from 'react'; +import styled from '@emotion/styled'; +import { Box } from '@mui/material'; +import Stepper from '@mui/material/Stepper'; +import Step from '@mui/material/Step'; +import StepLabel from '@mui/material/StepLabel'; + +export const StyledSaveBar = styled(Box)` + background-color: #FFF; + padding: 15px 6.9vw; + display: flex; + flex-direction: column; + align-items: center; + justify-content: space-between; + flex-wrap: nowrap; + gap: 20px; + box-shadow: 0px 2px 4px -1px rgba(0,0,0,0.2),0px 4px 5px 0px rgba(0,0,0,0.14),0px 1px 10px 0px rgba(0,0,0,0.12); + position: sticky; + z-index: 99; + top: 118px; + width: 100%; + left: 0; + + .top { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + width: 100%; + } + + .bottom { + width: 100%; + } + + h2 { + color: #16B9B3; + flex-shrink: 0; + font-family: "Noto Sans TC"; + font-size: 22px; + font-weight: 700; + line-height: 140% + } + + .MuiStepLabel-iconContainer { + + + .MuiStepIcon-text { + fill: #FFF; + } + } + + @media (max-width: 767px) { + .MuiStepLabel-label { + display: none; + } + } +`; + +export default function SaveBar({ currentStep }) { + return ( + + + 報名參加學習徵件計畫 + + + + + 編輯個人頁面 + + + 學習計畫填寫 + + + 核對學習計畫內容 + + + + + ); +}; \ No newline at end of file diff --git a/components/Marathon/SignUp/SubMilestonePanel.jsx b/components/Marathon/SignUp/SubMilestonePanel.jsx new file mode 100644 index 00000000..0e0f13a9 --- /dev/null +++ b/components/Marathon/SignUp/SubMilestonePanel.jsx @@ -0,0 +1,212 @@ +import { useState, useEffect } from 'react'; +import styled from '@emotion/styled'; +import dayjs from 'dayjs'; +import EditOutlinedIcon from '@mui/icons-material/EditOutlined'; +import ArrowForwardIcon from '@mui/icons-material/ArrowForward'; +import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; +import CalendarTodayOutlinedIcon from '@mui/icons-material/CalendarTodayOutlined'; +import { + Grid, + IconButton, + Typography +} from '@mui/material'; +import EditSubMilestone from './EditSubMilestone'; +import { ISOToWeekday, weekdayToISO } from './dateMap'; + +const FixedLabel = styled(Typography)` + font-size: 14px; + color: #293A3D; + width: 20px; + text-align: center; + font-style: normal; + font-weight: 400; + line-height: 140%; +`; +const StyledGridItem = styled(Grid)` + background-color: #FFF; + display: flex; + height: auto; + padding: 12px 16px; + flex-direction: row; + justify-content: flex-start; + align-items: center; + gap: 8px; + align-self: stretch; + border-radius: 8px; + + .content { + flex-grow: 1; + } + + .title { + display: flex; + flex-direction: row; + align-items: center; + width: 100%; + justify-content: flex-start; + flex-wrap: nowrap; + margin-bottom: 10px; + + p { + color: #293A3D; + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 140%; + } + } + + .buttons { + margin-left: auto; + display: flex; + flex-direction: row; + align-items: flex-start; + justify-content: center; + gap: 10px; + display: none; + height: 100%; + } + + @media (hover: hover) { + &:hover { + cursor: pointer; + } + &:hover .buttons { + display: flex; + } + } + @media (max-width: 767px) { + .buttons { + display: flex; + } + } + + .weekday { + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-start; + width: 100%; + padding-left: 20px; + gap: 5px; + + span { + color: #92989A; + font-size: 12px; + font-style: normal; + font-weight: 300; + line-height: 140%; + } + + .MuiSvgIcon-root { + width: 14px; + height: 14px; + fill: #92989A; + } + + @media (max-width: 767px) { + align-items: flex-start; + .MuiSvgIcon-root { + margin-top: 0.06em; + } + } + } + + + .MuiIconButton-root { + display: flex; + width: 24px; + height: 24px; + justify-content: center; + align-items: center; + gap: var(--Number-10, 10px); + border-radius: 2px; + opacity: 0.5; + background: #DBDBDB; + + &:hover { + background-color: #89DAD7; + } + } + + .MuiSvgIcon-root { + width: 18px; + height: 18px; + flex-shrink: 0; + } +`; + +export default function SubMilestonePanel({ + subMilestone, + index, + onChange = null, + onDelete, + isDisabled = false +}) { + const [newMilestone, setNewMilestone] = useState({}); + const [isEditing, setIsEditing] = useState(false); + const formattedWeekdays = subMilestone.dates + .map((ISODate) => ISOToWeekday(ISODate)) + .filter(Boolean) + .join(", "); + + const handleDelete = () => { + onDelete(subMilestone); + }; + const handleEdit = () => { + setIsEditing(true); + }; + useEffect(() => { + setNewMilestone(subMilestone); + }, [subMilestone]); + return ( + <> + {isEditing ? + ( + + ) : ( + + + + + {`${index + 1}.`} + + + {subMilestone.name || ''} + + + + + {formattedWeekdays} + + + {!isDisabled && ( + + + + + + + + + )} + + )} + > + ); +} diff --git a/components/Marathon/SignUp/TheAvator.jsx b/components/Marathon/SignUp/TheAvator.jsx new file mode 100644 index 00000000..fdd6ebe3 --- /dev/null +++ b/components/Marathon/SignUp/TheAvator.jsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { LazyLoadImage } from 'react-lazy-load-image-component'; + +import { Skeleton } from '@mui/material'; + +const EditAvator = ({ + url = 'https://imgur.com/EADd1UD.png', + height = 128, + width = 128, +}) => { + return ( + + } + /> + ); +}; + +export default EditAvator; diff --git a/components/Marathon/SignUp/UserProfileForm.jsx b/components/Marathon/SignUp/UserProfileForm.jsx new file mode 100644 index 00000000..0fef2365 --- /dev/null +++ b/components/Marathon/SignUp/UserProfileForm.jsx @@ -0,0 +1,577 @@ +import { useState, useEffect } from 'react'; +import { TAIWAN_DISTRICT, COUNTRIES } from '@/constants/areas'; +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 { useDispatch, useSelector } from 'react-redux'; +import { + fetchMarathonProfileByUserEvent +} from "@/redux/actions/marathon"; +import { + GENDER, + ROLE, + EDUCATION_STAGE, + WANT_TO_DO_WITH_PARTNER, +} from '@/constants/member'; + +import { + Box, + Typography, + TextField, + Switch, + TextareaAutosize, + MenuItem, + Select, + Grid, +} from '@mui/material'; + +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 Fields from '@/components/Group/Form/Fields'; +import useEditProfile from './useEditProfile'; +import ErrorMessage from './ErrorMessage'; + +import TheAvator from './TheAvator'; +import FormInput from './EditFormInput'; +import { + FormWrapper, + ContentWrapper, + StyledGroup, + StyledSelectWrapper, + StyledSelectBox, + StyledSelectText, + StyledToggleWrapper, + StyledToggleText, + StyledTitleWrap, + StyledSection, + StyledButtonGroup, + StyledButton, + MarathonSignUpWrapper, +} from './Edit.styled'; + +export default function UserProfileForm({ + currentStep, + setCurrentStep, +}) { + const reduxDispatch = useDispatch(); + const mobileScreen = useMediaQuery('(max-width: 767px)'); + const [isSetting, setIsSetting] = useState(false); + const router = useRouter(); + const searchParams = useSearchParams(); + const check = searchParams.get('check'); + const [hasClickNextStep, setHasClickNextStep] = useState(false); + const [hasGetLatestMarathon, setHasGetLatestMarathon] = useState(false); + const { + userState, + errors, + onChangeHandler, + onSubmit: onEditSubmit, + setRef, + } = useEditProfile(); + + const user = useSelector((state) => state.user); + const marathonState = useSelector((state) => state.marathon); + const onUpdateUser = async () => { + const resultStatus = await onEditSubmit({ + id: user._id, + email: user.email, + type: 'update' + }); + + setHasClickNextStep(true); + if (Object.values(errors).length) { + toast.error('請修正錯誤'); + return; + } + if (!resultStatus && !check) { + toast.error('更新失敗'); + } + }; + + const onCreateUser = async () => { + const resultStatus = await onEditSubmit({ + id: user._id, + email: user.email, + type: 'create' + }); + setHasClickNextStep(true); + if (Object.values(errors).length) { + toast.error('請修正錯誤'); + return; + } + if (!resultStatus && !check) { + toast.error('註冊失敗'); + } + }; + const onNextStep = () => { + if (user.userType === 'normal') { + onUpdateUser(); + } + if (user.userType === 'no_data') { + onCreateUser(); + } + }; + + useEffect(() => { + if (!user._id && (user.userType !== 'no_data')) { + router.push('/'); + } + if (user._id) { + Object.entries(user).forEach(([key, value]) => { + if (key === 'contactList') { + const { instagram, facebook, discord, line } = value; + onChangeHandler({ key: 'instagram', value: instagram || '' }); + onChangeHandler({ key: 'facebook', value: facebook || '' }); + onChangeHandler({ key: 'discord', value: discord || '' }); + onChangeHandler({ key: 'line', value: line || '' }); + } else if (key === 'birthDay') { + const parsedDate = dayjs(value); + onChangeHandler({ key: 'birthDay', value: parsedDate }); + } else if (key === 'location') { + onChangeHandler({ key, value }); + const [country, city, district] = value.split('@'); + onChangeHandler({ key: 'country', value: country || null }); + onChangeHandler({ key: 'city', value: city || null }); + onChangeHandler({ key: 'district', value: district || null }); + } else { + onChangeHandler({ key, value }); + } + }); + setIsSetting(true); + if (!hasGetLatestMarathon) { + reduxDispatch(fetchMarathonProfileByUserEvent(user._id, "2025S1")); + setHasGetLatestMarathon(true); + } + } + }, [user]); + + useEffect(() => { + switch (user.apiState) { + case 'Resolve': { + toast.success('更新成功'); + break; + } + case 'Reject': { + toast.error('更新失敗'); + break; + } + default: + } + }, [user.apiState]); + + useEffect(() => { + if ( + user._id && + isSetting && + (user.apiState === 'Resolve') && + hasClickNextStep // set hasClickNextStep to avoid auto next step after login + ) { + setCurrentStep(currentStep + 1); + } + }, [user._id, isSetting, user.apiState, hasClickNextStep, marathonState]); + return ( + + <> + + 編輯個人頁面 + + 填寫完整資訊可以幫助其他夥伴更了解你哦! + + + + + setRef('name', element)} + title="名稱" + parmKey="name" + value={userState.name || ''} + onChange={onChangeHandler} + errorMsg={errors.name ? errors.name : ''} + /> + + 生日 * + + onChangeHandler({ key: 'birthDay', value: date }) + } + renderInput={(params) => ( + setRef('birthDay', element)} + sx={{ width: '100%' }} + label="" + error={!!errors.birthDay} + helperText={errors.birthDay ? errors.birthDay : ''} + /> + )} + maxDate={dayjs().subtract(16, 'year')} + defaultCalendarMonth={dayjs().subtract(18, 'year')} + /> + + + 性別 * + setRef('gender', element)} + > + {GENDER.map(({ label, value }) => ( + { + onChangeHandler({ key: 'gender', value }); + }} + > + + {label} + + + ))} + + + + + 身份 * + setRef('roleList', element)} + > + {ROLE.map(({ label, value }) => ( + + onChangeHandler({ + key: 'roleList', + value, + isMultiple: true, + }) + } + > + + {label} + + + ))} + + + + + + + + + 教育階段 + { + onChangeHandler({ + key: 'educationStage', + value: event.target.value, + }); + }} + sx={{ width: '100%' }} + > + + 請選擇您目前的教育階段 + + {EDUCATION_STAGE.map(({ label, value }) => ( + + {label} + + ))} + + + + 居住地 + { + onChangeHandler({ + key: 'country', + value: event.target.value, + }); + }} + sx={{ width: '100%' }} + > + + 請選擇居住地 + + {COUNTRIES.map(({ name, label }) => ( + + {label} + + ))} + + {(userState.country === '台灣' || userState.country === 'tw') && ( + + + { + onChangeHandler({ + key: 'city', + value: event.target.value, + }); + }} + sx={{ width: '100%' }} + > + + 縣市 + + {TAIWAN_DISTRICT.map(({ name }) => ( + + {name} + + ))} + + + + { + onChangeHandler({ + key: 'district', + value: event.target.value, + }); + }} + sx={{ width: '100%' }} + > + + 鄉鎮市區 + + {TAIWAN_DISTRICT.find( + ({ name }) => name === userState.city, + )?.districts.map(({ name, zip }) => ( + + {name} + + ))} + + + + )} + + + + setRef('socialCode', element)} + sx={{ + marginTop: '16px', + border: errors.socialCode ? '1px solid red' : '', + }} + > + + + 聯絡方式 * + + + 聯絡資訊會呈現在你的公開頁面上,讓夥伴能聯繫你,至少填寫一個社交媒體帳號 + + + + {Object.entries({ + instagram: 'Instagram', + discord: 'Discord', + line: 'Line', + facebook: 'Facebook', + }).map(([key, title]) => ( + + setRef(key, element)} + title={title} + parmKey={key} + value={userState[key] || ''} + onChange={onChangeHandler} + placeholder="請填寫ID" + errorMsg={ + errors[key] + ? errors[key] + : errors.socialCode + ? '請填寫您的 ID' + : '' + } + /> + + ))} + + + + + + + + + setRef('wantToDoList', element)} + > + 想和夥伴一起 * + + + {WANT_TO_DO_WITH_PARTNER.map(({ label, value }) => ( + { + onChangeHandler({ + key: 'wantToDoList', + value, + isMultiple: true, + }); + }} + > + + {label} + + + ))} + + + + + + 可以和夥伴分享的事物 + + { + onChangeHandler({ key: 'share', value: e.target.value }); + }} + /> + + + 標籤 + setRef(name, element), + onChange: ({ target }) => + onChangeHandler({ key: target.name, value: target.value }), + }} + /> + + 可以是學習領域、興趣等等的標籤,例如:音樂創作、程式語言、電繪、社會議題。 + + + + + + + 個人簡介 * + + setRef('selfIntroduction', element)} + style={{ + width: '100%', + minHeight: '100px', + padding: '10px', + borderRadius: '8px ', + border: '1px solid #DBDBDB', + }} + placeholder="寫下關於你的資訊,讓其他島民更認識你!也可以多描述想和夥伴一起做的事喔!" + value={userState.selfIntroduction} + onChange={(event) => { + onChangeHandler({ + key: 'selfIntroduction', + value: event.target.value, + }); + }} + /> + + + + + + + 公開顯示居住地 + { + onChangeHandler({ + key: 'isOpenLocation', + value, + }); + }} + /> + + + 公開個人頁面尋找夥伴 + { + onChangeHandler({ + key: 'isOpenProfile', + value, + }); + }} + /> + + + + + 下一步 + + + > + ); +} diff --git a/components/Marathon/SignUp/dateMap.jsx b/components/Marathon/SignUp/dateMap.jsx new file mode 100644 index 00000000..0d2ac189 --- /dev/null +++ b/components/Marathon/SignUp/dateMap.jsx @@ -0,0 +1,59 @@ +export const ISO_WEEK_DAY_MAP = [ + "2024-01-01T00:00:00.000Z", + "2024-01-02T00:00:00.000Z", + "2024-01-03T00:00:00.000Z", + "2024-01-04T00:00:00.000Z", + "2024-01-05T00:00:00.000Z", + "2024-01-06T00:00:00.000Z", + "2024-01-07T00:00:00.000Z" +]; +export const ZH_WEEK_DAY_MAP = [ + "週一", + "週二", + "週三", + "週四", + "週五", + "週六", + "週日" +]; +export const ISOToWeekday = (isoDate) => { + switch (isoDate) { + case ("2024-01-01T00:00:00.000Z"): + return "週一"; + case ("2024-01-02T00:00:00.000Z"): + return "週二"; + case ("2024-01-03T00:00:00.000Z"): + return "週三"; + case ("2024-01-04T00:00:00.000Z"): + return "週四"; + case ("2024-01-05T00:00:00.000Z"): + return "週五"; + case ("2024-01-06T00:00:00.000Z"): + return "週六"; + case ("2024-01-07T00:00:00.000Z"): + return "週日"; + default: + return null; + } +}; + +export const weekdayToISO = (weekday) => { + switch (weekday) { + case ("週一"): + return "2024-01-01T00:00:00.000Z"; + case ("週二"): + return "2024-01-02T00:00:00.000Z"; + case ("週三"): + return "2024-01-03T00:00:00.000Z"; + case ("週四"): + return "2024-01-04T00:00:00.000Z"; + case ("週五"): + return "2024-01-05T00:00:00.000Z"; + case ("週六"): + return "2024-01-06T00:00:00.000Z"; + case ("週日"): + return "2024-01-07T00:00:00.000Z"; + default: + return null; + } +}; diff --git a/components/Marathon/SignUp/useEditProfile.jsx b/components/Marathon/SignUp/useEditProfile.jsx new file mode 100644 index 00000000..ed1f3403 --- /dev/null +++ b/components/Marathon/SignUp/useEditProfile.jsx @@ -0,0 +1,257 @@ +import dayjs from 'dayjs'; +import { useReducer, useRef, useState } from 'react'; +import { useDispatch } from 'react-redux'; +import { updateUser, createUser } from '@/redux/actions/user'; +import { z } from 'zod'; + +const initialState = { + name: '', + photoURL: '', + birthDay: dayjs(), + gender: '', + roleList: [], + wantToDoList: [], + instagram: '', + facebook: '', + discord: '', + line: '', + educationStage: '-1', + location: '台灣', + tagList: [], + selfIntroduction: '', + share: '', + isOpenLocation: true, + isOpenProfile: true, + isLoadingSubmit: false, + country: '', + city: '', + district: '', +}; + +const buildValidator = (maxLength, regex, maxMsg, regMsg) => + z.string().max(maxLength, maxMsg).regex(regex, regMsg).optional(); + +const schema = z.object({ + name: z + .string() + .min(1, { message: '請輸入名字' }) + .max(50, { message: '名字過長' }) + .optional(), + gender: z + .string() + .refine((val) => val !== undefined && val !== '', { + message: '請選擇您的性別', + }) + .optional(), + birthDay: z + .any() + .refine((date) => dayjs(date).isValid(), { + message: '請選擇您的出生日期', + }) + .refine((date) => dayjs().diff(date, 'year') >= 16, { + message: '您的年齡未滿16歲,目前無法於平台註冊,請詳閱島島社群條款', + }) + .optional(), + instagram: buildValidator( + 30, + /^($|[a-zA-Z0-9_.]{2,20})$/, + '長度最多30個字元', + '長度最少2個字元,支援英文、數字、底線、句號', + ), + facebook: buildValidator( + 64, + /^($|[a-zA-Z0-9_.]{5,20})$/, + '長度最多64個字元', + '長度最少5個字元,支援英文、數字、底線、句號', + ), + discord: buildValidator( + 32, + /^($|[a-zA-Z0-9_.]{2,20})$/, + '長度最多32個字元', + '長度最少2個字元,支援英文、數字、底線、句號', + ), + line: buildValidator( + 20, + /^($|[a-zA-Z0-9_.]{3,20})$/, + '長度最多20個字元', + '長度最少6個字元,支援英文、數字、底線、句號', + ), + wantToDoList: z + .array(z.string()) + .min(1, '為了讓其他島民更認識你,請至少選擇一項想進行的事項') + .optional(), + tagList: z + .array(z.string()) + .min(1, '為了讓其他島民更認識你,請至少選擇一項標籤') + .optional(), + selfIntroduction: z + .string() + .min(1, '為了讓其他島民更認識你,請簡述您的個人經歷、想做的事項') + .optional(), + roleList: z.array(z.string()).min(1, '請選擇您的身份').optional(), +}); + +const userReducer = (state, payload) => { + const { key, value, isMultiple = false } = payload; + if (isMultiple) { + return { + ...state, + [key]: state[key].includes(value) + ? state[key].filter((role) => role !== value) + : [...state[key], value], + }; + } else if (state && state[key] !== undefined) { + return { + ...state, + [key]: value, + }; + } + return state; +}; + +const useEditProfile = () => { + const reduxDispatch = useDispatch(); + const [userState, stateDispatch] = useReducer(userReducer, initialState); + const [errors, setErrors] = useState({}); + const refs = useRef({}); + + const validate = (state = {}, isPartial = false) => { + const [key, val] = Object.entries(state)[0]; + + const result = isPartial + ? schema + .partial({ [key]: true }) + .safeParse({ [key]: key === 'birthDay' ? val?.$d : val }) + : schema + .refine( + (data) => + !!data.instagram || + !!data.facebook || + !!data.discord || + !!data.line, + { + message: '至少填寫一個社交媒體帳號', + path: ['socialCode'], + }, + ) + .safeParse({ + ...state, + birthDay: state.birthDay.$d, + }); + + let isFocus = false; + + if (!result.success) { + const newErrors = Object.fromEntries( + result.error.errors.map((err) => { + if (!isPartial && !isFocus) { + const element = refs.current[err.path[0]]; + isFocus = true; + + if (['INPUT', 'TEXTAREA'].includes(element.tagName)) { + element.focus(); + } else { + element.scrollIntoView({ block: 'center' }); + } + } + return [err.path[0], err.message]; + }), + ); + setErrors(newErrors); + } + if (isPartial && result.success) { + const obj = { ...errors }; + delete obj[key]; + setErrors(obj); + } + return result.success; + }; + + const onChangeHandler = ({ key, value, isMultiple }) => { + stateDispatch({ key, value, isMultiple }); + // if isMultiple is true, value must be in array , if not, create a new array then check + const checkVal = isMultiple && !Array.isArray(isMultiple) ? [value] : value; + validate({ [key]: checkVal }, true); + }; + + const onSubmit = async ({ id, email, type }) => { + const readyForUpdate = (type === 'update') && (id && email); + const readyForCreate = (type === 'create'); + + if (!readyForUpdate && !readyForCreate) return false; + + const { + name, + birthDay, + gender, + roleList, + educationStage, + wantToDoList, + share, + isOpenLocation, + isOpenProfile, + tagList, + selfIntroduction, + instagram, + facebook, + discord, + line, + country, + city, + district, + } = userState; + + const payload = { + id, + email, + name, + birthDay: dayjs(birthDay).format('YYYY/MM/DD'), + gender, + roleList, + contactList: { + instagram, + facebook, + discord, + line, + }, + wantToDoList, + educationStage, + location: + country === '國外' ? country : [country, city, district].join('@'), + tagList, + selfIntroduction, + share, + isOpenLocation, + isOpenProfile, + }; + + if (type === 'update') { + reduxDispatch(updateUser(payload)); + } else { + reduxDispatch(createUser(payload)); + } + return true; + }; + + const checkBeforeSubmit = async ({ id, email, type }) => { + if (validate(userState)) { + const result = await onSubmit({ id, email, type }); + return result; + } + return false; + }; + + const setRef = (name, element) => { + refs.current[name] = element; + }; + + return { + userState, + onChangeHandler, + onSubmit: checkBeforeSubmit, + setRef, + errors, + }; +}; + +export default useEditProfile; diff --git a/components/Marathon/index.jsx b/components/Marathon/index.jsx new file mode 100644 index 00000000..e3e38c7f --- /dev/null +++ b/components/Marathon/index.jsx @@ -0,0 +1,571 @@ +import { useMemo, useRef, useState } from 'react'; +import styled from '@emotion/styled'; +import { Divider, Typography, Box, Grid } from '@mui/material'; +import Banner from './Banner'; +import About from './About'; +import Edm from './Edm'; + +const LearningMarathonWrapper = styled.div``; + +const StyledGuideTitle = styled(Typography)` + color: #293A3D; + font-weight: bold; + line-height: 140%; + margin-left: 0; + text-align: left; + font-size: 22px; +`; + +const StyledGuideSubtitle = styled(Typography)` + font-size: 16px; + font-width: 500; + line-height: 140%; +`; +const StyledGuideParagraph = styled(Typography)` + font-size: 16px; + font-style: normal; + font-weight: 400; + line-height: 140%; + display: block; + text-align: left; + color: #536166; +`; + +const GuideWrapper = styled.div` + width: 52vw; + margin: 0 auto; + padding-top: 100px; + padding-bottom: 100px; + + @media (max-width: 767px) { + width: 100%; + padding-top: 40px; + padding-bottom: 20px; + } +`; + +const StyledList = styled(Box)` + + ul { + list-style-type: inherit; + padding-left: 1.5em; + } + + ol { + list-style-type: decimal; + padding-left: 1.5em; + } + + li { + font-size: 16px; + font-style: normal; + font-weight: 400; + line-height: 140%; + color: #536166; + } + + p, span { + font-size: 16px; + font-style: normal; + font-weight: 400; + line-height: 140%; + color: #536166; + } +`; +const StyledMethodCard = styled(Box)` + display: flex; + width: 100%; + height: 300px; + padding: 25px 30px; + flex-direction: column; + align-items: flex-start; + gap: 30px; + align-self: stretch; + border-radius: 10px; + + h3 { + color: #293A3D; + font-size: 18px; + font-style: normal; + font-weight: 700; + line-height: 140%; + } + + ul { + list-style-type: inherit; + padding-left: 1.5em; + } + + li { + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 140%; + color: #293A3D; + } +`; + +const StyledSpotlightCard = styled(Box)` + padding: 25px 30px; + color: #FFF; + border-radius: 10px; + + ul { + padding-left: 1.5em; + list-style-type: inherit; + } + + h3 { + color: #FFF; + font-size: 18px; + font-style: normal; + font-weight: 700; + line-height: 140%; + margin-bottom: 36px; + } + + p, li { + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 140%; + } +`; + +function Marathon() { + const guideRef = useRef(null); + return ( + + + + + + 活動介紹 + + + 學習這趟漫長的馬拉松,我可不可以用我的方式跑向屬於我的終點? + 發展興趣、改變生活習慣、上理想的大學、生涯規劃、發起社會行動,每一個生活大小事都是一場學習馬拉松。然而,每一次的奮力前行總會遇到「不知道怎麼計畫」、「好難自律」、「沒有伴」、「資源與人脈有限」、「無限自我質疑」等難題... + + + 島島盃將提供你四大裝備: + + + + + 「專業陪跑員」陪你規劃路徑與自我釐清 + + + 「百人社群」讓你找到合適夥伴與各界人脈 + + + 「AI個人化數位工具」讓你在紀錄與覆盤中自律學習、AI智慧推薦與引導 + + + 「專業課程」帶你掌握自主學習要領 + + + + + 如果你有些想做的計畫,正在等待個契機開始,現在就是時候。 + 五個月的馬拉松後,你將會在計畫過程中「豐富知識經驗、在學習中形塑自我、為生活與社會帶來實際行動」,而最終的成果發表你還有機會獲得獎助金。 + + + 島島盃 2025 春季學習馬拉松,將以學習者以自我需求出發設計學習計畫,開啟一趟自我導向學習馬拉松,往哪跑?怎麼跑?跑多快?終點在哪由你決定,島島阿學陪你一起跑。 + 邀請你一起「為自己重新打造喜歡的學習生活」,讓我們陪伴彼此,成就自我與他人。 + + + + + + + 誰適合參加? + + + + 16歲以上學習者皆可報名,優先以高中及大學生為主 + 有意願為自己打造專屬學習旅程的學習者 + + + + + 如果你符合下列一項,那你也許就是適合的參加的人: + + + 有模糊的職涯/生涯方向,想開始做準備與鋪路 + 學校課程好無聊,希望可以用自己的方式學自己有興趣的事情 + 考試不適合我,更想用個人經歷上大學 + 想自主學習,有方向但不確定可以怎麼開始 + + + + 特別提醒: + 活動重視社群互動與共學,若無法在計劃期間投入時間參與並和其他夥伴和 Mentor 互動,請斟酌報名。 + + + + + + + 馬拉松進行方式 + + + 我們提供的裝備 + + + + + 「專業陪跑員」 陪你規劃路徑與自我釐清 + + 3 次 1 小時一對一諮詢 + 2 次 1 小時團體諮詢 + Mentor 每兩週對學員的學習進度給予回饋 + + + + + + 「專業課程」 帶你掌握自主學習要領 + + 「策略」目標設定與學習策略 + 「方法」思考、提問、筆記方法 + 「人」學習社群與個人狀態釐清 + 「展現」成果展現與自我行銷 + + + + + + 「百人社群」 讓你找到合適夥伴與各界人脈 + + 5 次 1 小時全員每月聚會 + 專屬學習小組,5 次 1 小時學習小組每月聚會 + 島島阿學 Discord 社群即時交流 島島阿學網站找夥伴找揪團功能 + + + + + + + 「AI 個人化學習工具」 引導你學習方向及自律學習 + + 具引導性的自主學習模板 + 學習日誌 + 學習任務上傳與回饋區 + 進度安排與檢核表 + 自我檢核表 + 學習成果分享專區 + AI智慧推薦與引導 + + + + + + 這場馬拉松有什麼不一樣? + + + + + 專業且客製化的陪跑方式 + 不只重視成果,更重視過程與你的全人發展,並強調「Knowing知識經驗、Being個人形塑、Doing行動」三者的交織。不只這樣... + + 萃取多位自我導向學習實踐者之經驗 + 結合被譽為全球最接近民主教育的美國百年民主大學 Goddard College 教學方法(首次在台灣公開) + 結合 High Performance Learning Journeys 學習引導法 + AI智慧推薦與引導 + + + + + + AI 個人化學習工具X社群支持 + 有 AI 推薦與引導外,也重視人與人真實地互動! + + 結合 AI 給你更好的資源與人脈推薦,以及學習引導 + 跨領域、跨年齡的百人社群,讓你可以找到同儕,也可以找到業界前輩 + + + + + + + + + + + 你可以預期的收穫 + + + 只要報名,不論有無入選,就可以優先使用島島阿學 AI 個人化學習工具,包含自主學習模板、學習日誌、學習進度追蹤、AI 智慧與引導等功能! + + + + + 而入選後,你還可以與專屬引導師與學習夥伴跑完一趟自我導向學習的馬拉松,完成遲遲未開始的計畫,並在過程中... + + + 習得AI世代不可或缺的「自主學習力、協作力、跨領域學習力」 + 更深入認識自己,將學習與自身需求連結,找到學習的內在動機 + 豐富學習資源與人脈,讓學習不再孤單,並增加學習可能性 + 完成一份具體的學習計畫與成果,兼顧各自需求與外界認可 + 成為助人者,完成整趟學習馬拉松者將獲得自主學習引導師優先培訓機會 + + + + + + + + + 成果發表與獎勵 + + + 在學習馬拉松尾聲,針對入選的20位學員,島島阿學將舉辦成果分享日,並邀請引導師及入選者作為評審,更提供NT$ 5000元獎金支持優秀計畫持續發展! + + + 獎勵 + + + + + 成果分享活動將選出5位優選參與者,每位可獲 NT$ 5000元獎金、優選證明,以及島島阿學專訪與媒體曝光。 + + + 評選標準: + + + 學習歷程紀錄與反思完成度(60%):可以清楚學習每一個過程的狀態(如遇的困難、解決方法、心態等)、反思以及下一步行動的改變。 + + + 學習成果完成度(40%):學習成果達到預期的學習目標的程度。 + + + + + + + + 分享路上的風景 + + + 每位參與者在計劃結束時需在島島阿學網站公開學習計劃。 + 每位參與者在計劃結束時須分享至少三個於計劃期間使用的學習資源,並分享使用心得。 + 每位參與者需完成學習馬拉松回饋問卷。 + + + + + + + + + 如何申請 + + + (一)重要時程: + + + 計畫開始報名:2024/12/15 + 線上說明會暨自主學習小小工作坊:2024/12/21(六)15:00-16:30 + 申請截止:2025/1/19 23:59 + 入選與備取公告:2025/1/27 + 繳費期限:2025/2/2 23:59 + 備取遞補公告:2025/2/4 + 計劃期間:2025/2/9-2025/7/12 + 線上暖身活動:2025/2/9(日)14:00-15:30 + 線上課時間:待確認,前三堂課程將於 2/10-3/10 之間舉行。 + 成果分享日:2025/7/12(六)10:00-16:00 + 社群交流線上與實體時間: + + 線上:2/23(日)19:30-21:00、4/20(日)19:30-21:00、6/22(日)19:30-21:00 + 實體:3/23(日)15:00-16:30 台北、5/25(日)15:00-16:30 台中 + + + + + (二)申請方式: + + + 透過註冊島島阿學官網會員系統並同時填寫線上表單申請本計劃 + 請透過此連結申請 + 在報名截止日前皆可修改申請內容 + 入選名額:20 位 + + + + (三)評選標準: + + + 為確保學習計畫的品質和有效性,評選將依據以下標準進行: + + + 1、計畫完整性 (30%) + + + + 計畫簡述:願景清晰明確,具體可行,例如實現願景的步驟合理、邏輯性強,且有階段性規劃。 + 學習動機:動機強烈且具說服力,能清楚連結個人經驗與學習主題。 + 學習內容:學習內容具體且聚焦,與學習主題密切相關。 + + + + 2、目標與方法 (30%) + + + + 學習目標:目標明確、可衡量、可達成、具相關性。 + 學習方法與策略:方法和策略多元且有效,能促進學習目標的達成。 + + + + 3、資源與時程 (20%) + + + + 學習資源:資源類型多元且可靠,包含線上線下資源、書籍、師資、社群等。 + 學習時程表:時程安排合理,學習進度規劃明確。 + + + + 4、評量與成果 (20%) + + + + 學習評量:評量方式客觀且有效,能真實反映學習成果。 + 學習成果呈現方式:成果呈現方式具體且多元,並與學習目標相符,能有效展現學習成果。 + + + + 評選委員將依據上述標準,綜合考量申請者的學習計畫,進行評分和排序。 + + + + + + ); +} + +export default Marathon; diff --git a/components/Profile/MyMarathon/LoadingCard.jsx b/components/Profile/MyMarathon/LoadingCard.jsx new file mode 100644 index 00000000..fc0e2239 --- /dev/null +++ b/components/Profile/MyMarathon/LoadingCard.jsx @@ -0,0 +1,52 @@ +import Skeleton from '@mui/material/Skeleton'; +import IconButton from '@mui/material/IconButton'; +import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined'; +import MoreVertOutlinedIcon from '@mui/icons-material/MoreVertOutlined'; +import { + StyledContainer, + StyledFooter, + StyledGroupCard, + StyledText, + StyledTitle, + StyledFlex, + StyledImageWrapper, +} from './MarathonCard.styled'; + +function LoadingCard() { + return ( + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export default LoadingCard; diff --git a/components/Profile/MyMarathon/MarathonCard.jsx b/components/Profile/MyMarathon/MarathonCard.jsx new file mode 100644 index 00000000..f3f98039 --- /dev/null +++ b/components/Profile/MyMarathon/MarathonCard.jsx @@ -0,0 +1,95 @@ +import { useState } from 'react'; +import { useRouter } from 'next/router'; +import { useDispatch } from 'react-redux'; +import { fetchMarathonProfileById } from '@/redux/actions/marathon'; +import MoreVertOutlinedIcon from '@mui/icons-material/MoreVertOutlined'; +import Image from '@/shared/components/Image'; +import emptyCoverImg from '@/public/assets/empty-cover.png'; +import { + IconButton, + Menu, +} from '@mui/material'; +import { + StyledGroupCard, + StyledImageWrapper, + StyledContainer, + StyledTitle, + StyledText, + StyledFooter, + StyledFlex, + StyledStatus, + StyledMenuItem +} from "./MarathonCard.styled"; + +export default function MarathonCard({ marathon }) { + const { title, isPublic } = marathon; + const router = useRouter(); + const [anchorEl, setAnchorEl] = useState(null); + const reduxDispatch = useDispatch(); + const handleMenu = (event) => { + event.preventDefault(); + setAnchorEl(event.currentTarget); + }; + + const handleClose = () => { + setAnchorEl(null); + }; + const handleClickEdit = () => { + setAnchorEl(null); + window.localStorage.setItem('fromProfilePage', 'click_edit'); + reduxDispatch(fetchMarathonProfileById(marathon._id)); + router.push('/learning-marathon/signup'); + }; + const handleClickDetail = () => { + setAnchorEl(null); + window.localStorage.setItem('fromProfilePage', 'click_detail'); + reduxDispatch(fetchMarathonProfileById(marathon._id)); + router.push('/learning-marathon/signup'); + }; + return ( + + + + + + {title} + + 2025 春季學習馬拉松 + + + + {isPublic ? "公開" : "不公開"} + + + + + + + + + 檢視學習計畫 + + + 編輯學習計畫 + + + + + ); +}; \ No newline at end of file diff --git a/components/Profile/MyMarathon/MarathonCard.styled.jsx b/components/Profile/MyMarathon/MarathonCard.styled.jsx new file mode 100644 index 00000000..5e754751 --- /dev/null +++ b/components/Profile/MyMarathon/MarathonCard.styled.jsx @@ -0,0 +1,116 @@ +import styled from '@emotion/styled'; +import { + Box, + MenuItem +} from '@mui/material'; + +export const StyledGroupsWrapper = styled.div` + background-color: #ffffff; + max-width: 672px; + border-radius: 16px; + padding: 36px 40px; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + + @media (max-width: 767px) { + padding: 16px 20px; + } + + ${(props) => props.sx} +`; +export const StyledGroupCard = styled(Box)` + width: 100%; + display: flex; + position: relative; + background: #fff; + border-radius: 4px; + gap: 16px; + + @media (max-width: 767px) { + flex-direction: column; + } +`; + +export const StyledImageWrapper = styled.div` + flex: 1; + overflow: hidden; + + img { + vertical-align: middle; + } +`; +export const StyledContainer = styled.div` + display: flex; + flex-direction: column; + justify-content: space-around; + flex: 1; + padding: 0 10px; +`; + +export const StyledTitle = styled.h2` + font-size: 16px; + font-weight: bold; + line-height: 1.6; + margin-bottom: 4px; + display: -webkit-box; + color: #293a3d; + -webkit-box-orient: vertical; + -webkit-line-clamp: 1; + overflow: hidden; +`; +export const StyledText = styled.div` + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: ${(props) => props.lineClamp || '1'}; + overflow: hidden; + color: ${(props) => props.color || '#536166'}; + font-size: ${(props) => props.fontSize || '14px'}; + word-break: break-word; +`; +export const StyledFooter = styled.footer` + display: flex; + justify-content: space-between; + align-items: center; +`; + +export const StyledFlex = styled.div` + display: flex; + align-items: center; + gap: 8px; + width: 100%; +`; + +export const StyledStatus = styled.div` + --bg-color: #def5f5; + --color: #16b9b3; + display: flex; + align-items: center; + width: max-content; + font-size: 12px; + padding: 4px 10px; + height: 24px; + background: var(--bg-color); + color: var(--color); + border-radius: 4px; + font-weight: 500; + gap: 4px; + margin-right: auto; + &::before { + content: ''; + display: block; + width: 8px; + height: 8px; + background: var(--color); + border-radius: 50%; + } + + &.finished { + --bg-color: #f3f3f3; + --color: #92989a; + } +`; +export const StyledMenuItem = styled(MenuItem)` + min-width: 146px; +`; diff --git a/components/Profile/MyMarathon/index.jsx b/components/Profile/MyMarathon/index.jsx new file mode 100644 index 00000000..41210559 --- /dev/null +++ b/components/Profile/MyMarathon/index.jsx @@ -0,0 +1,70 @@ +import { useState, useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { + fetchUserByToken +} from '@/redux/actions/user'; + +import { + Typography, + Grid +} from '@mui/material'; + +import { + StyledGroupsWrapper, +} from "./MarathonCard.styled"; +import MarathonCard from './MarathonCard'; + +const MyMarathon = ({ title, sx }) => { + const reduxDispatch = useDispatch(); + const userState = useSelector((state) => { return state.user; }); + const [marathons, setMarathons] = useState([]); + const { apiState } = userState; + + useEffect(() => { + if (userState.token) { + setMarathons(userState.marathons); + reduxDispatch(fetchUserByToken(userState.token)); + } + }, []); + + useEffect(() => { + if (userState.marathons.length) { + setMarathons(userState.marathons); + } + }, [userState]); + + useEffect(() => { + if (userState.apiState === 'Resolve' && userState.marathons.length) { + setMarathons(userState.marathons); + } + }, [apiState]); + + return ( + + {title && ( + + {title} + + )} + + {marathons.length > 0 && ( + marathons.map((marathon, _i) => { + return ( + + + + ); + }) + ) + } + + + ); +}; + +export default MyMarathon; diff --git a/package.json b/package.json index 7de57d4d..5b601420 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "1.0.0", "private": true, "scripts": { - "dev": "next dev -p 5000", + "dev": "next dev -p 5001", "dev-https": "node server.js", "static": "serve out", "build": "next build && next export", @@ -67,6 +67,7 @@ "swr": "^2.2.5", "tailwind-merge": "^2.5.4", "use-image": "^1.0.10", + "uuid": "^11.0.3", "zod": "^3.22.4" }, "devDependencies": { diff --git a/pages/auth/callback/index.jsx b/pages/auth/callback/index.jsx index da59d1f2..b3b6364d 100644 --- a/pages/auth/callback/index.jsx +++ b/pages/auth/callback/index.jsx @@ -28,7 +28,7 @@ export default function AuthCallbackPage() { window.close(); } - if (me.tempToken) { + if (me.userType === 'no_data') { window.opener.postMessage({ type: 'TEMP_TOKEN_UPDATED' }, window.location.origin); setIsLoading(false); window.close(); diff --git a/pages/learning-marathon/index.jsx b/pages/learning-marathon/index.jsx new file mode 100644 index 00000000..f579ffe9 --- /dev/null +++ b/pages/learning-marathon/index.jsx @@ -0,0 +1,73 @@ +import React, { useMemo, useEffect } from 'react'; +import styled from '@emotion/styled'; +import { useRouter } from 'next/router'; +import { sendLoginConfirmation } from '@/utils/openLoginWindow'; +import SEOConfig from '@/shared/components/SEO'; +import Marathon from '@/components/Marathon'; +import Navigation from '@/shared/components/Navigation_v2'; +import Footer from '@/shared/components/Footer_v2'; + +const HomePageWrapper = styled.div` + --section-height: calc(100vh - 80px); + --section-height-offset: 80px; +`; + +const LearningMarathon = () => { + const router = useRouter(); + const SEOData = useMemo( + () => ({ + title: '島島盃 - 2025 春季學習馬拉松|多元學習資源平台|島島阿學', + description: + '「島島阿學」盼能透過建立多元的學習資源網絡,讓自主學習者能找到合適的成長方法,進一步成為自己想成為的人,從中培養共好精神。目前正積極打造「可共編的學習資源平台」。', + keywords: '島島阿學', + author: '島島阿學', + copyright: '島島阿學', + imgLink: 'https://www.daoedu.tw/preview.webp', + link: `${process.env.HOSTNAME}${router?.asPath}`, + structuredData: [ + { + '@context': 'https://schema.org', + '@type': 'WebSite', + url: 'https://www.daoedu.tw', + potentialAction: { + '@type': 'SearchAction', + 'query-input': 'required name=q', + target: 'https://www.daoedu.tw/search?q={q}', + }, + }, + { + '@context': 'https://schema.org', + '@type': 'Organization', + url: 'https://www.daoedu.tw', + logo: 'https://www.daoedu.tw/favicon-112.png', + }, + ], + }), + [router?.asPath], + ); + + const { token, id } = router.query; + + useEffect(() => { + sendLoginConfirmation(id, token); + }, [id, token]); + + return ( + <> + + + > + ); +}; + +LearningMarathon.getLayout = ({ children }) => { + return ( + + + {children} + + + ); +}; + +export default LearningMarathon; diff --git a/pages/learning-marathon/login/index.jsx b/pages/learning-marathon/login/index.jsx new file mode 100644 index 00000000..f630ee6a --- /dev/null +++ b/pages/learning-marathon/login/index.jsx @@ -0,0 +1,186 @@ +import { useState, useEffect, useMemo } from 'react'; +import styled from '@emotion/styled'; +import { useRouter } from 'next/router'; +import Script from 'next/script'; +import { Box, Typography, Button, Skeleton } from '@mui/material'; +import { LazyLoadImage } from 'react-lazy-load-image-component'; +import SEOConfig from '@/shared/components/SEO'; +import Navigation from '@/shared/components/Navigation_v2'; +import Footer from '@/shared/components/Footer_v2'; +import { BASE_URL } from '@/constants/common'; +import openLoginWindow from '@/utils/openLoginWindow'; +import { useSelector } from 'react-redux'; +import { getRedirectionStorage } from '@/utils/storage'; + +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: 8px; + margin: 60px auto; + padding: 40px 40px; + max-width: 440px; + width: 100%; + @media (max-width: 767px) { + width: 90%; + .title { + text-overflow: ellipsis; + width: 100%; + } + } +`; + +const LoginPage = () => { + const LOGIN_PATH = `${BASE_URL}/auth/google`; + const router = useRouter(); + const SEOData = useMemo( + () => ({ + title: '登入島島|島島阿學', + description: + '「島島阿學」盼能透過建立多元的學習資源網絡,讓自主學習者能找到合適的成長方法,進一步成為自己想成為的人,從中培養共好精神。目前正積極打造「可共編的學習資源平台」。', + keywords: '島島阿學', + author: '島島阿學', + copyright: '島島阿學', + imgLink: 'https://www.daoedu.tw/preview.webp', + link: `${process.env.HOSTNAME}${router?.asPath}`, + }), + [router?.asPath], + ); + const user = useSelector((state) => state.user); + + useEffect(() => { + const onMessageChange = (e) => { + const oldUser = e.data?.type === 'USER_UPDATED'; + const newUser = e.data?.type === 'TEMP_TOKEN_UPDATED'; + const redirectUrl = getRedirectionStorage().get(); + const hasLogin = user._id || (user.userType === 'no_data'); + if (hasLogin || oldUser || newUser) { + window.location.href = redirectUrl; + } + }; + + window.addEventListener('message', onMessageChange); + + return () => { + window.removeEventListener('message', onMessageChange); + }; + }, []); + + return ( + <> + + + + + + 歡迎回來島島阿學! + + + } + /> + openLoginWindow('/learning-marathon/signup', LOGIN_PATH)} + sx={{ + width: '100%', + marginTop: '24px', + borderRadius: '20px', + color: '#fff', + bgcolor: '#16B9B3', + boxShadow: '0px 4px 10px rgba(89, 182, 178, 0.5)', + }} + variant="contained" + > + Google 登入 / 註冊 + + + + {`註冊即代表您同意島島阿學的 `} + { + router.push('/terms/privacypolicy'); + }} + sx={{ + color: '#16B9B3', + fontWeight: 700, + fontSize: '14px', + textDecoration: 'underline', + cursor: 'pointer', + }} + > + 服務條款 + + {` 與 `} + { + router.push('/terms/privacypolicy'); + }} + sx={{ + color: '#16B9B3', + fontWeight: 700, + fontSize: '14px', + textDecoration: 'underline', + cursor: 'pointer', + }} + > + 隱私政策 + + + + + + > + ); +}; + +LoginPage.getLayout = ({ children }) => { + return ( + + + {children} + + + ); +}; + +export default LoginPage; diff --git a/pages/learning-marathon/signup/index.jsx b/pages/learning-marathon/signup/index.jsx new file mode 100644 index 00000000..3b7aa651 --- /dev/null +++ b/pages/learning-marathon/signup/index.jsx @@ -0,0 +1,138 @@ +import { useMemo, useEffect, useState } from 'react'; +import styled from '@emotion/styled'; +import { useRouter } from 'next/router'; +import { useSelector } from 'react-redux'; + +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 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'; + +const HomePageWrapper = styled.div` + --section-height: calc(100vh - 80px); + --section-height-offset: 80px; +`; + +const FormWrapper = styled.form` + padding: 50px 0; + + @media (max-width: 767px) { + padding: 20px 16px; + } +`; + +const ContentWrapper = styled.div` + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + border-radius: 16px; + margin: 0 auto; + width: 737px; + max-width: 100%; + + @media (max-width: 767px) { + width: 100%; + .title { + text-overflow: ellipsis; + width: 100%; + } + } +`; +const LearningMarathonSignUp = () => { + const router = useRouter(); + const SEOData = useMemo( + () => ({ + title: '報名島島盃 - 2025 春季學習馬拉松|多元學習資源平台|島島阿學', + description: + '「島島阿學」盼能透過建立多元的學習資源網絡,讓自主學習者能找到合適的成長方法,進一步成為自己想成為的人,從中培養共好精神。目前正積極打造「可共編的學習資源平台」。', + keywords: '島島阿學', + author: '島島阿學', + copyright: '島島阿學', + imgLink: 'https://www.daoedu.tw/preview.webp', + link: `${process.env.HOSTNAME}${router?.asPath}`, + structuredData: [ + { + '@context': 'https://schema.org', + '@type': 'WebSite', + url: 'https://www.daoedu.tw', + potentialAction: { + '@type': 'SearchAction', + 'query-input': 'required name=q', + target: 'https://www.daoedu.tw/search?q={q}', + }, + }, + { + '@context': 'https://schema.org', + '@type': 'Organization', + url: 'https://www.daoedu.tw', + logo: 'https://www.daoedu.tw/favicon-112.png', + }, + ], + }), + [router?.asPath], + ); + const [currentStep, setCurrentStep] = useState(0); + const fromProfilePage = window.localStorage.getItem('fromProfilePage'); + const marathonState = useSelector((state) => { return state.marathon; }); + if (fromProfilePage && marathonState._id) { + if (fromProfilePage === 'click_edit') { + window.localStorage.removeItem('fromProfilePage'); + setCurrentStep(1); + } + if (fromProfilePage === 'click_detail') { + window.localStorage.removeItem('fromProfilePage'); + setCurrentStep(2); + } + } + useEffect(() => { + window.scrollTo({ + top: 0, + behavior: 'smooth', + }); + }, [currentStep]); + return ( + <> + + + + + + { + currentStep === 0 ? ( + + ) : currentStep === 1 ? ( + + ) : + } + + + + + > + ); +}; + +LearningMarathonSignUp.getLayout = ({ children }) => { + return ( + + + {children} + + + ); +}; + +export default LearningMarathonSignUp; diff --git a/pages/learning-marathon/success/index.jsx b/pages/learning-marathon/success/index.jsx new file mode 100644 index 00000000..ccc71d5a --- /dev/null +++ b/pages/learning-marathon/success/index.jsx @@ -0,0 +1,185 @@ +import React, { useMemo } from 'react'; +import styled from '@emotion/styled'; +import { useRouter } from 'next/router'; +import SEOConfig from '@/shared/components/SEO'; +import Navigation from '@/shared/components/Navigation_v2'; +import Footer from '@/shared/components/Footer_v2'; +import { Box, Typography, Button } from "@mui/material"; +import emailImg from '@/public/assets/mail.png'; + +const HomePageWrapper = styled.div` + --section-height: calc(100vh - 80px); + --section-height-offset: 80px; + background: linear-gradient(0deg, #F3FCFC 0%, #F3FCFC 100%), #F7F8FA; +`; + +const StyledBar = styled(Box)` + background-color: #FFF; + display: flex; + padding: 15px 6.9vw; + flex-direction: column; + align-items: flex-start; + gap: 20px; + align-self: stretch; + border-radius: 8px; + background: #FFF; + box-shadow: 0px 4px 4px 0px rgba(0, 0, 0, 0.25); + + h2 { + color: #16B9B3; + flex-shrink: 0; + font-family: "Noto Sans TC"; + font-size: 22px; + font-weight: 700; + line-height: 140% + } +`; + +const StyledButtonGroup = styled(Box)` + width: 100%; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + + @media (max-width: 767px) { + width: 100%; + grid-template-columns: 1fr; + gap: 20px; + } +`; + +const StyledButton = styled(Button)(({ variant = 'contained' }) => ({ + ...(variant === 'contained' && { + color: '#ffffff', + backgroundColor: '#16b9b3', + }), + width: '100%', + height: '40px', + borderRadius: '20px', +})); + +const StyledSection = styled(Box)` + width: 39.1vw; + max-width: 598px; + margin: 0 auto; + padding: 50px 16px; + + @media (max-width: 767px) { + width: 100%; + padding: 32px 16px; + } +`; + +const LearningMarathonSignUp = () => { + const router = useRouter(); + const SEOData = useMemo( + () => ({ + title: '報名島島盃 - 2025 春季學習馬拉松|多元學習資源平台|島島阿學', + description: + '「島島阿學」盼能透過建立多元的學習資源網絡,讓自主學習者能找到合適的成長方法,進一步成為自己想成為的人,從中培養共好精神。目前正積極打造「可共編的學習資源平台」。', + keywords: '島島阿學', + author: '島島阿學', + copyright: '島島阿學', + imgLink: 'https://www.daoedu.tw/preview.webp', + link: `${process.env.HOSTNAME}${router?.asPath}`, + structuredData: [ + { + '@context': 'https://schema.org', + '@type': 'WebSite', + url: 'https://www.daoedu.tw', + potentialAction: { + '@type': 'SearchAction', + 'query-input': 'required name=q', + target: 'https://www.daoedu.tw/search?q={q}', + }, + }, + { + '@context': 'https://schema.org', + '@type': 'Organization', + url: 'https://www.daoedu.tw', + logo: 'https://www.daoedu.tw/favicon-112.png', + }, + ], + }), + [router?.asPath], + ); + + return ( + <> + + + 報名參加學習徵件計畫 + + + + 報名成功 + + + 記得到信箱確認收到報名成功信,並確認信件沒有跑進垃圾桶。 + + + + + + 接著可以... + + + router.push('/learning-marathon/signup')} + > + 再次修改資料 + + router.push('/search')} + > + 尋找學習資源 + + + + > + ); +}; + +LearningMarathonSignUp.getLayout = ({ children }) => { + return ( + + + {children} + + + ); +}; + +export default LearningMarathonSignUp; diff --git a/pages/profile/index.jsx b/pages/profile/index.jsx index 573b2cd2..e9c0bc09 100644 --- a/pages/profile/index.jsx +++ b/pages/profile/index.jsx @@ -11,6 +11,7 @@ import Footer from '@/shared/components/Footer_v2'; import SEOConfig from '@/shared/components/SEO'; import Navigation from '@/shared/components/Navigation_v2'; import MyGroup from '@/components/Profile/MyGroup'; +import MyMarathon from '@/components/Profile/MyMarathon'; import AccountSetting from '@/components/Profile/Accountsetting'; import useMediaQuery from '@mui/material/useMediaQuery'; @@ -79,6 +80,11 @@ const ProfilePage = () => { tabLabel: '帳號設定', view: , }, + { + id: 'my-marathon', + tabLabel: '學習計畫', + view: + } ]; const [value, setValue] = useState(() => { diff --git a/public/assets/mail.png b/public/assets/mail.png new file mode 100644 index 00000000..623c8240 Binary files /dev/null and b/public/assets/mail.png differ diff --git a/redux/actions/marathon.js b/redux/actions/marathon.js index ae449ec2..b861b334 100644 --- a/redux/actions/marathon.js +++ b/redux/actions/marathon.js @@ -38,11 +38,18 @@ export function deleteMarathonProfile(token, id) { }; } -export function fetchMarathonProfileByUserId(userId) { +export function fetchMarathonProfileByUserEvent(userId, eventId) { return { - type: 'FETCH_MARATHON_PROFILE_BY_USER_ID', + type: "FETCH_MARATHON_PROFILE_BY_USER_EVENT", payload: { - userId - } + userId, + eventId, + }, + }; +} +export function updateNewMarathon(marathon) { + return { + type: "UPDATE_NEW_MARATHON", + payload: marathon, }; } diff --git a/redux/reducers/marathon.js b/redux/reducers/marathon.js index 7bcc83e6..ec8ed04f 100644 --- a/redux/reducers/marathon.js +++ b/redux/reducers/marathon.js @@ -1,83 +1,138 @@ -// import toast from 'react-hot-toast'; - -const initialState = { - title: '', - eventId: '', - userId: '', - description: '', - motivation: { tags: [], description: '' }, +export const initialState = { + title: "", + eventId: "2025S1", + userId: "", + description: "", + motivation: { tags: [], description: "" }, content: "", goals: "", - strategies: { tags: [], description: '' }, + strategies: { tags: [], description: "" }, resources: [], milestones: [], - outcomes: { tags: [], description: '' }, + outcomes: { tags: [], description: "" }, status: "Ongoing", - pricing: { option: "", pricing: 0, email: [], file: "" }, + registrationStatus: "Open", + registrationDate: "", + pricing: { + option: "", + price: 0, + email: [], + file: "", + }, isPublic: false, - startDate: '', - endDate: '', - userMarathon: [] + startDate: "", + endDate: "", }; const reducer = (state = initialState, action) => { + const { key, value } = action.payload || {}; switch (action.type) { - case 'FETCH_MARATHON_PROFILE_BY_USER_ID': { + case "FETCH_MARATHON_PROFILE_BY_USER_ID": { return { ...state, - apiState: 'pending' + apiState: "pending", }; } - case 'FETCH_MARATHON_PROFILE_BY_USER_ID_SUCCESS': { + case "FETCH_MARATHON_PROFILE_BY_USER_ID_SUCCESS": { return { ...state, userMarathon: action.payload, - apiState: 'success' + apiState: "success", }; } - case 'FETCH_MARATHON_PROFILE_BY_USER_ID_FAILURE': { + case "FETCH_MARATHON_PROFILE_BY_USER_ID_FAILURE": { return { ...state, - apiState: 'reject' + apiState: "reject", + }; + } + case "UPDATE_NEW_MARATHON": { + return { + ...action.payload, }; } - case 'CREATE_MARATHON_PROFILE': { + case "FETCH_MARATHON_PROFILE_BY_USER_EVENT": { return { ...state, - apiState: 'pending' + apiState: "pending", }; } - case 'CREATE_MARATHON_PROFILE_BY_TOKEN_SUCCESS': { + case "FETCH_MARATHON_PROFILE_BY_USER_EVENT_SUCCESS": { return { ...state, ...action.payload, - apiState: 'success' + apiState: "success", }; } - case 'FETCH_MARATHON_PROFILE_BY_ID': { + case "FETCH_MARATHON_PROFILE_BY_USER_EVENT_FAILURE": { return { ...state, - apiState: 'pending' + apiState: "reject", }; } - case 'FETCH_MARATHON_PROFILE_BY_ID_SUCCESS': { + case "FETCH_MARATHON_PROFILE_BY_ID_SUCCESS": { return { ...state, ...action.payload, - apiState: 'success' + apiState: "success", + }; + } + case "FETCH_MARATHON_PROFILE_BY_ID_FAILURE": { + return { + ...state, + apiState: "reject", }; } - case 'FETCH_MARATHON_PROFILE_BY_ID_FAILURE': { + case "UPDATE_MARATHON_PROFILE": { return { ...state, - apiState: 'reject' + apiState: "pending", }; } - case 'UPDATE_MARATHON_PROFILE_SUCCESS': { + case "UPDATE_MARATHON_PROFILE_SUCCESS": { return { ...state, ...action.payload, - apiState: 'success', + apiState: "success", + }; + } + case "UPDATE_MARATHON_PROFILE_FAILURE": { + return { + ...state, + apiState: "reject", + }; + } + + case "CREATE_MARATHON_PROFILE_BY_TOKEN": { + return { + ...state, + apiState: "pending", + }; + } + case "CREATE_MARATHON_PROFILE_BY_TOKEN_SUCCESS": { + console.log("in reducer", action.payload); + return { + ...state, + ...action.payload, + apiState: "success", + }; + } + case "CREATE_MARATHON_PROFILE_BY_TOKEN_FAILURE": { + return { + ...state, + apiState: "reject", + }; + } + case "FETCH_MARATHON_PROFILE_BY_ID": { + return { + ...state, + apiState: "pending", + }; + } + case 'UPDATE_USER_PROFILE_API_STATE_RESET': { + return { + ...state, + apiState: 'None', }; } default: { diff --git a/redux/reducers/user.js b/redux/reducers/user.js index fede417f..d61ea5f3 100644 --- a/redux/reducers/user.js +++ b/redux/reducers/user.js @@ -81,6 +81,7 @@ const reducer = (state = initialState, action) => { ...state, ...action.payload, apiState: 'Resolve', + userType: 'normal', isComplete: checkIsComplete(action.payload), }; } diff --git a/redux/sagas/marathon/index.js b/redux/sagas/marathon/index.js index 59e7539a..eca3ea20 100644 --- a/redux/sagas/marathon/index.js +++ b/redux/sagas/marathon/index.js @@ -1,4 +1,5 @@ -import { put, takeEvery, call } from "redux-saga/effects"; +import { put, takeEvery } from "redux-saga/effects"; +import * as localforage from "localforage"; import { BASE_URL } from "@/constants/common"; import req from "@/utils/request"; @@ -18,6 +19,22 @@ function* fetchMarathonProfileByUserId(action) { } } +function* fetchMarathonProfileByUserEvent(action) { + const { userId, eventId } = action.payload; + try { + const URL = `${BASE_URL}/marathon?userId=${userId}&eventId=${eventId}`; + + const result = yield req(URL); + yield put({ + type: "FETCH_MARATHON_PROFILE_BY_USER_EVENT_SUCCESS", + payload: result.data && result.data[0] + }); + } catch (error) { + console.log(error); + yield put({ type: "FETCH_MARATHON_PROFILE_BY_USER_EVENT_FAILURE" }); + } +} + function* fetchMarathonProfileById(action) { const { id } = action.payload; // marathon._id try { @@ -52,6 +69,9 @@ function* createMarathonProfileByToken(action) { } catch (error) { console.log(error); yield put({ type: "CREATE_MARATHON_PROFILE_BY_TOKEN_FAILURE" }); + } finally { + yield new Promise((res) => setTimeout(res, 300)); + yield put({ type: "CREATE_MARATHON_PROFILE_BY_TOKEN_API_STATE_RESET" }); } } @@ -106,10 +126,10 @@ function* deleteMarathonProfile(action) { function* marathonSaga() { yield takeEvery("FETCH_MARATHON_PROFILE_BY_ID", fetchMarathonProfileById); + yield takeEvery("FETCH_MARATHON_PROFILE_BY_USER_EVENT", fetchMarathonProfileByUserEvent); yield takeEvery("CREATE_MARATHON_PROFILE_BY_TOKEN", createMarathonProfileByToken); yield takeEvery("UPDATE_MARATHON_PROFILE", updateMarathonProfile); yield takeEvery("DELETE_MARATHON_PROFILE", deleteMarathonProfile); - yield takeEvery("FETCH_MARATHON_PROFILE_BY_USER_ID", fetchMarathonProfileByUserId); } export default marathonSaga; diff --git a/redux/sagas/user/index.js b/redux/sagas/user/index.js index 69214dfc..56a04465 100644 --- a/redux/sagas/user/index.js +++ b/redux/sagas/user/index.js @@ -40,8 +40,15 @@ function* fetchAllUsers() { function* createUserProfile(action) { const { user } = action.payload; + let URL = ""; + try { - const URL = `${BASE_URL}/user/${user.id}`; + if (user.id) { + URL = `${BASE_URL}/user/${user.id}`; + } else { + URL = `${BASE_URL}/user`; + } + // if success => status: 201, token, user const result = yield req(URL, { method: 'POST', @@ -110,8 +117,8 @@ function* fetchUserById(action) { // fetch user data by token function* fetchUserByToken(action) { const token = action.payload?.token; + const URL = `${BASE_URL}/user/me`; try { - const URL = `${BASE_URL}/user/me`; const result = yield call(req, URL, { method: "GET", headers: { @@ -126,6 +133,7 @@ function* fetchUserByToken(action) { payload: result.data && { _id: result.data._id, ...result.data, + userType: 'normal', token, tokenExpiry: handleTokenExpiry(true), marathons: marathonResponse?.data || [], @@ -136,7 +144,8 @@ function* fetchUserByToken(action) { type: "FETCH_USER_BY_TOKEN_SUCCESS_NO_DATA", payload: { ...result.data, - tempToken: token, + userType: 'no_data', + token, tokenExpiry: handleTokenExpiry(true), }, }); diff --git a/utils/openLoginWindow.js b/utils/openLoginWindow.js index 1d092833..674b2b8e 100644 --- a/utils/openLoginWindow.js +++ b/utils/openLoginWindow.js @@ -8,9 +8,17 @@ export const startLoginListener = (callback) => { if (e.origin !== window.location.origin) return; if (e.data.type === "login") { const { token, id } = e.data.payload; + const redirectionStorage = getRedirectionStorage(); + const redirectUrl = redirectionStorage.get(); + if (typeof callback === "function") { callback(id, token); } + + if (redirectUrl) { + redirectionStorage.remove(); + window.location.replace(redirectUrl); + } } }; window.addEventListener("message", receiveMessage, false); diff --git a/yarn.lock b/yarn.lock index 8df046bd..f8534b35 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7361,16 +7361,7 @@ strict-event-emitter@^0.4.3: resolved "https://registry.yarnpkg.com/strict-event-emitter/-/strict-event-emitter-0.4.6.tgz#ff347c8162b3e931e3ff5f02cfce6772c3b07eb3" integrity sha512-12KWeb+wixJohmnwNFerbyiBrAlq5qJLwIt38etRtKtmmHyDSoGlIqFE9wx+4IwG0aDjI7GV8tc8ZccjWZZtTg== -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -string-width@^4.1.0: +"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -7459,14 +7450,7 @@ stringify-object@^3.3.0: is-obj "^1.0.1" is-regexp "^1.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - -strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -7961,6 +7945,11 @@ util-deprecate@^1.0.2: resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== +uuid@^11.0.3: + version "11.0.3" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-11.0.3.tgz#248451cac9d1a4a4128033e765d137e2b2c49a3d" + integrity sha512-d0z310fCWv5dJwnX1Y/MncBAqGMKEzlBb1AOf7z9K8ALnd0utBX/msg/fA0+sbyN1ihbMsLhrBlnl1ak7Wa0rg== + vfile-message@^4.0.0: version "4.0.2" resolved "https://registry.yarnpkg.com/vfile-message/-/vfile-message-4.0.2.tgz#c883c9f677c72c166362fd635f21fc165a7d1181"
註冊並加入我們,立即報名!
{marathonState?.motivation?.description || ''}
+ 填寫完整資訊可以幫助其他夥伴更了解你哦! +
不只重視成果,更重視過程與你的全人發展,並強調「Knowing知識經驗、Being個人形塑、Doing行動」三者的交織。不只這樣...
有 AI 推薦與引導外,也重視人與人真實地互動!