From 8cf00a308da07d02fda427051c260c1c52fd953b Mon Sep 17 00:00:00 2001 From: ruby10127130 Date: Wed, 18 Dec 2024 03:27:09 +0800 Subject: [PATCH 1/7] feat: add validate rules and related styles - block next step if there are errors exist --- components/Marathon/SignUp/Edit.styled.jsx | 19 ++ components/Marathon/SignUp/MarathonForm.jsx | 238 +++++++++++++++++- components/Marathon/SignUp/MilestoneGroup.jsx | 12 +- utils/storage.js | 1 + 4 files changed, 255 insertions(+), 15 deletions(-) diff --git a/components/Marathon/SignUp/Edit.styled.jsx b/components/Marathon/SignUp/Edit.styled.jsx index c80bf943..338ac25a 100644 --- a/components/Marathon/SignUp/Edit.styled.jsx +++ b/components/Marathon/SignUp/Edit.styled.jsx @@ -80,6 +80,9 @@ export const StyledSection = styled(Box)` border-radius: 16px; border: 1px solid #DBDBDB; + &.error { + border-color: #EF5364; + } @media (max-width: 767px) { padding: 32px 16px; @@ -187,6 +190,12 @@ export const StyledInputBase = styled(InputBase)` border-width: 1px; padding: 12px 16px; } + + &.error { + border-color: #EF5364; + outline-color: #EF5364; + position: relative; + } `; export const StyledTextareaAutosize = styled(TextareaAutosize)` width: 100%; @@ -200,10 +209,20 @@ export const StyledTextareaAutosize = styled(TextareaAutosize)` border: 2px solid #16B9B3; padding: 11px 15px; outline-color: #16B9B3; + + &.error { + border-color: #EF5364; + outline-color: #EF5364; + } } .MuiInputBase-input { padding: 0; line-height: 140%; } + + &.error { + border-color: #EF5364; + outline-color: #16B9B3; + } `; diff --git a/components/Marathon/SignUp/MarathonForm.jsx b/components/Marathon/SignUp/MarathonForm.jsx index 73be4431..9a7e43e7 100644 --- a/components/Marathon/SignUp/MarathonForm.jsx +++ b/components/Marathon/SignUp/MarathonForm.jsx @@ -1,9 +1,11 @@ import { useState, useEffect, useReducer } from 'react'; import { useDispatch, useSelector } from 'react-redux'; +import toast from 'react-hot-toast'; import { updateNewMarathon } from '@/redux/actions/marathon'; import { initialState as reduxInitMarathonState } from '@/redux/reducers/marathon'; +import { getMarathonErrorsStorage } from '@/utils/storage'; import { Box, @@ -11,6 +13,7 @@ import { FormControlLabel, Checkbox, } from '@mui/material'; +import ClearIcon from '@mui/icons-material/Clear'; import MilestoneGroup from './MilestoneGroup'; import { @@ -72,7 +75,8 @@ export default function MarathonForm({ }) { const reduxDispatch = useDispatch(); const [hasLoaded, setHasLoaded] = useState(false); - + const [errors, setErrors] = useState({}); + const [hasErrors, setHasErrors] = useState(false); const marathonState = useSelector((state) => { return state.marathon; }); const localStorgeStored = window.localStorage.getItem('newMarathon'); const editingMarathon = localStorgeStored ? JSON.parse(localStorgeStored) : null; @@ -84,17 +88,95 @@ export default function MarathonForm({ const [newMarathon, setNewMarathon] = useReducer(marathonFormReducer, initialState()); const onNextStep = () => { - reduxDispatch(updateNewMarathon(newMarathon)); - setCurrentStep(currentStep + 1); + if (hasErrors) { + toast.error('請修正錯誤'); + } else { + reduxDispatch(updateNewMarathon(newMarathon)); + setCurrentStep(currentStep + 1); + } }; const onPrevStep = () => { reduxDispatch(updateNewMarathon(newMarathon)); setCurrentStep(currentStep - 1); }; + const handleValidate = (name, input, errorMessage) => { + let validate = false; + switch (name) { + case 'title': + validate = (value) => { + return (value.trim().length > 0); + }; + break; + case 'description': + validate = (value) => { + return (value.trim().length > 0); + }; + break; + case 'motivationDescription': + validate = (value) => { + return (value.trim().length > 0); + }; + break; + case 'milestonesName': + validate = (value) => { + const names = value.filter((milestone, _i) => { + return (milestone.name.trim().length > 0); + }); + return names.length === newMarathon.milestones?.length; + }; + break; + case 'goals': + validate = (value) => { + return (value.trim().length > 0); + }; + break; + case 'content': + validate = (value) => { + return (value.trim().length > 0); + }; + break; + case 'strategiesDescription': + validate = (value) => { + return (value.trim().length > 0); + }; + break; + case 'outcomesDescription': + validate = (value) => { + return (value.trim().length > 0); + }; + break; + case 'resources': + validate = (value) => { + return (value.trim().length > 0); + }; + break; + default: + break; + } + + if (validate(input)) { + setErrors((prevErrors) => { + const { [name]: _, ...remainingErrors } = prevErrors; + return remainingErrors; + }); + } else { + setErrors({ + ...errors, + [name]: { + message: errorMessage || null + } + }); + } + return validate(input); + }; useEffect(() => { setHasLoaded(true); + const storagedErrors = getMarathonErrorsStorage().get(); + if (storagedErrors) { + setErrors(storagedErrors); + } }, []); useEffect(() => { @@ -103,9 +185,21 @@ export default function MarathonForm({ } }, [newMarathon]); + useEffect(() => { + getMarathonErrorsStorage().set(errors); + if (Object.keys(errors).length) { + setHasErrors(true); + } else { + setHasErrors(false); + } + }, [errors]); + return ( <> - + : null} placeholder="範例:成為一位Youtuber、半世紀以來的氣候變遷紀錄研究、開一間線上甜點店" /> + {errors.title && ( + + {errors.title?.message} + + )} 計畫簡述 * @@ -168,9 +276,22 @@ export default function MarathonForm({ value: e.target.value } }); + handleValidate('description', e.target.value, '請填寫計畫簡述'); }} placeholder="範例:因為對剪影片和當 Youtuber 有興趣,我預計會研究搞笑型 Youtuber 的影片腳本與剪輯方式、拍攝我日常生活及練習剪輯,並建立 Youtube 頻道上傳影片。希望能藉此了解如何當一位 Youtuber。" + className={errors.description ? 'error' : ''} /> + {errors.description && ( + + {errors.description?.message} + + )} @@ -215,10 +336,23 @@ export default function MarathonForm({ value: e.target.value } }); + handleValidate('motivationDescription', e.target.value, '請填寫學習動機'); }} + className={errors.motivationDescription ? 'error' : ''} value={newMarathon?.motivation?.description || ''} placeholder="範例:因為同學常常說我很好笑,很適合把生活日常做成影片,我也發現自己對做影片、當Youtuber有興趣,所以想要嘗試累積作品,並開一個 Youtuber 頻道。" /> + {errors.motivationDescription && ( + + {errors.motivationDescription?.message} + + )} @@ -238,12 +372,25 @@ export default function MarathonForm({ value: e.target.value } }); + handleValidate('goals', e.target.value, '請填寫學習目標'); }} value={newMarathon.goals || ''} placeholder="範例: - 能收集並分析搞笑風格的 Youtuber - 能拍攝畫面穩定、清晰且具專業感的影片" + className={errors.goals ? 'error' : ''} /> + {errors.goals && ( + + {errors.goals?.message} + + )} @@ -263,13 +410,26 @@ export default function MarathonForm({ value: e.target.value } }); + handleValidate('content', e.target.value, '請填寫學習內容'); }} value={newMarathon.content || ''} placeholder="範例: - 內容規劃與創意發想(定位、主題、腳本) - 基礎拍攝技術(攝影設備、燈光、語音) - 影片剪輯與後製(剪輯軟體、配樂)" + className={errors.content ? 'error' : ''} /> + {errors.content && ( + + {errors.content?.message} + + )} @@ -316,10 +476,23 @@ export default function MarathonForm({ value: e.target.value } }); + handleValidate('strategiesDescription', e.target.value, '請填寫學習方法與策略'); }} value={newMarathon?.strategies?.description || ''} placeholder="範例:我預計會研究影片腳本、拍攝與剪輯方式,接著了解拍攝、剪輯與Youtube頻道經營,並同時練習拍攝與剪輯,開始經營頻道。我會用notion整理我收集到的資料以及筆記。" + className={errors.strategiesDescription ? 'error' : ''} /> + {errors.strategiesDescription && ( + + {errors.strategiesDescription?.message} + + )} @@ -335,28 +508,52 @@ export default function MarathonForm({ sx={{ width: '100%' }} placeholder="範例:YouTube 創作者的實用資源" value={newMarathon.resources || ''} - onChange={(e) => setNewMarathon({ - type: 'UPDATE_FIELD', - payload: { - key: 'resources', - value: e.target.value - } - })} + onChange={(e) => { + setNewMarathon({ + type: 'UPDATE_FIELD', + payload: { + key: 'resources', + value: e.target.value + } + }); + handleValidate('resources', e.target.value, '請填寫學習資源'); + }} + className={errors.resources ? 'error' : 'warning'} + endAdornment={errors.resources ? : null} /> + {errors.resources && ( + + {errors.resources?.message} + + )} - + - + 學習成果及呈現方式 * @@ -395,10 +592,25 @@ export default function MarathonForm({ value: e.target.value } }); + handleValidate('outcomesDescription', e.target.value, '請填寫學習成果'); }} value={newMarathon?.outcomes?.description || ''} placeholder="範例:我預計會架設一個Youtube頻道,並上傳至少5支影片,並整理觀眾回饋與相關數據。" + className={errors.outcomesDescription ? 'error' : ''} /> + {errors.outcomesDescription && ( + + {errors.outcomesDescription?.message} + + )} { const changedMilestones = milestones.map((item, _i) => { return (item._tempId === newMilestone._tempId ? newMilestone : item); }); - + // check if milestone name exist + onValidate('milestonesName', changedMilestones, '請填寫每週 / 隔週里程碑目標'); onChange({ type: 'UPDATE_FIELD', payload: { @@ -246,6 +251,9 @@ export default function MilestoneGroup({ })} + ); diff --git a/utils/storage.js b/utils/storage.js index 9a6fba78..a6fdd1f9 100644 --- a/utils/storage.js +++ b/utils/storage.js @@ -20,3 +20,4 @@ export const getTokenStorage = () => createStorage('_token'); export const getRedirectionStorage = () => createStorage('_r'); export const getTrustWebsitesStorage = () => createStorage('_trustWeb'); export const getReminderStorage = () => createStorage('_reminder'); +export const getMarathonErrorsStorage = () => createStorage('_marathonFormErrors'); From 66c2cc1a9d23cf2a327c1c190e849e2f63e2b06a Mon Sep 17 00:00:00 2001 From: ruby10127130 Date: Wed, 18 Dec 2024 16:13:05 +0800 Subject: [PATCH 2/7] feat: adjust savebar padding-top and padding-bottom on mobile devices --- components/Marathon/SignUp/SaveBar.jsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/components/Marathon/SignUp/SaveBar.jsx b/components/Marathon/SignUp/SaveBar.jsx index 0dbdde07..316d86df 100644 --- a/components/Marathon/SignUp/SaveBar.jsx +++ b/components/Marathon/SignUp/SaveBar.jsx @@ -43,14 +43,13 @@ export const StyledSaveBar = styled(Box)` } .MuiStepLabel-iconContainer { - - .MuiStepIcon-text { fill: #FFF; } } @media (max-width: 767px) { + padding: 8px 6.9vw; .top h2 { font-size: 18px; } From d8300b3245812cb6b7afeb616e5d2e3427607860 Mon Sep 17 00:00:00 2001 From: ruby10127130 Date: Wed, 18 Dec 2024 16:15:08 +0800 Subject: [PATCH 3/7] chore: remove no use useState --- components/Marathon/SignUp/SaveBar.jsx | 1 - 1 file changed, 1 deletion(-) diff --git a/components/Marathon/SignUp/SaveBar.jsx b/components/Marathon/SignUp/SaveBar.jsx index 316d86df..374badb0 100644 --- a/components/Marathon/SignUp/SaveBar.jsx +++ b/components/Marathon/SignUp/SaveBar.jsx @@ -1,4 +1,3 @@ -import { useState } from 'react'; import styled from '@emotion/styled'; import { Box } from '@mui/material'; import Stepper from '@mui/material/Stepper'; From 54c8e4e0d626180a180af108346b320d0909ba50 Mon Sep 17 00:00:00 2001 From: ruby10127130 Date: Wed, 18 Dec 2024 16:49:47 +0800 Subject: [PATCH 4/7] feat: page/marathon/landing mentor section add text more to improve ux --- components/Marathon/Mentors.jsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/components/Marathon/Mentors.jsx b/components/Marathon/Mentors.jsx index fcf1cbac..1504e43b 100644 --- a/components/Marathon/Mentors.jsx +++ b/components/Marathon/Mentors.jsx @@ -3,7 +3,7 @@ import { CiCircleChevRight, CiCircleChevLeft } from "react-icons/ci"; import { IoClose } from "react-icons/io5"; import { FaLinkedin, FaMedium, FaResearchgate, FaSquareFacebook, FaSquareThreads } from "react-icons/fa6"; import { IconButton } from '@mui/material'; - +import EastIcon from '@mui/icons-material/East'; import Image from '@/shared/components/Image'; import Modal from '@/shared/components/Modal'; import { cn } from '@/utils/cn'; @@ -303,11 +303,11 @@ const Mentors = () => { handleOpenModal(mentor.name)} > -
-
+
+
{mentor.tags.slice(0, 1).map((tag, index) => ( { /> ))}
-
{mentor.title} | {mentor.name}
+
{mentor.title} | {mentor.name}
+
more
))} From adc2676221fad3bbfdfb8c48dc2094c4e29bd29d Mon Sep 17 00:00:00 2001 From: ruby10127130 Date: Wed, 18 Dec 2024 17:07:55 +0800 Subject: [PATCH 5/7] feat: adjust mobile styles of date selector section in milestoneGroup to improve ux --- components/Marathon/SignUp/MilestoneGroup.jsx | 164 ++++++++++-------- 1 file changed, 93 insertions(+), 71 deletions(-) diff --git a/components/Marathon/SignUp/MilestoneGroup.jsx b/components/Marathon/SignUp/MilestoneGroup.jsx index a7a29147..88b5892c 100644 --- a/components/Marathon/SignUp/MilestoneGroup.jsx +++ b/components/Marathon/SignUp/MilestoneGroup.jsx @@ -1,5 +1,6 @@ import { v4 as uuidv4 } from 'uuid'; import { useState, useEffect } from "react"; +import styled from '@emotion/styled'; import { Box, Grid, @@ -14,6 +15,29 @@ import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; import { StyledGroup } from "./Edit.styled"; import MilestonePanel from "./MilestonePanel"; +const StyledDateSection = styled(Box)` + display: flex; + justify-content: flex-start; + align-items: flex-start; + width: 100%; + gap: 10px; + + .startDate, .endDate, .frequency { + flex-shrink: 0; + width: 25%; + } + + @media (max-width: 767px) { + flex-wrap: wrap; + .startDate, .endDate { + width: calc(50% - 5px); + } + .frequency { + width: 100%; + } + } +`; + export default function MilestoneGroup({ milestones = [], onChange = null, @@ -154,77 +178,75 @@ export default function MilestoneGroup({ - - - ( - - )} - /> - - - ( - - )} - /> - - - - 每週 - 每兩週 - - - + + ( + + )} + /> + + ( + + )} + /> + + 每週 + 每兩週 + + {milestones.map((milestone, i) => { From 20ef51ca7ad4d8ecd3e6254173de47a16f4791c2 Mon Sep 17 00:00:00 2001 From: ruby10127130 Date: Wed, 18 Dec 2024 18:18:37 +0800 Subject: [PATCH 6/7] feat: adjust mobile styles in EditMilestone to improve ux --- .../Marathon/SignUp/EditSubMilestone.jsx | 226 ++++++++++-------- 1 file changed, 126 insertions(+), 100 deletions(-) diff --git a/components/Marathon/SignUp/EditSubMilestone.jsx b/components/Marathon/SignUp/EditSubMilestone.jsx index acff0f83..1352f326 100644 --- a/components/Marathon/SignUp/EditSubMilestone.jsx +++ b/components/Marathon/SignUp/EditSubMilestone.jsx @@ -33,6 +33,7 @@ const FixedLabel = styled(Typography)` width: 20px; flex-shrink: 0; `; + const StyledContainer = styled(Box)` display: flex; flex-direction: row; @@ -40,6 +41,24 @@ const StyledContainer = styled(Box)` align-items: center; gap: 10px; width: 100%; + backgroundColor: #FFF; + padding: 12px 16px; + border-radius: 8px; + border: 1px solid #DBDBDB; + + @media (max-width: 767px) { + display: grid; + grid-template-areas: + "content buttons" + "date date"; + grid-template-columns: 1fr auto; + grid-template-rows: auto auto; + } + + &:focus-within { + border: 1px solid #16B9B3; + padding: 12px 16px; + } .content { flex-grow: 1; @@ -48,6 +67,11 @@ const StyledContainer = styled(Box)` align-items: center; justify-content: flex-start; gap: 10px; + grid-area: content; + } + .weekdaySelector { + grid-area: date; + } .buttons { @@ -56,29 +80,7 @@ const StyledContainer = styled(Box)` 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; + grid-area: buttons; } .title { @@ -88,7 +90,12 @@ const StyledGridItem = styled(Grid)` width: 100%; justify-content: space-between; flex-wrap: nowrap; - + grid-area: title; + gap: 4px; + span { + margin-right: 4px; + flex-shrink: 0; + } p { color: #293A3D; font-size: 14px; @@ -96,8 +103,17 @@ const StyledGridItem = styled(Grid)` font-weight: 400; line-height: 140%; } - } + } `; + +const StyledButtonGroup = styled(Box)` + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + gap: 10px; +`; + const StyledWeekdaySelector = styled(Select)` font-size: 12px; font-style: normal; @@ -109,7 +125,6 @@ const StyledWeekdaySelector = styled(Select)` justify-content: flex-start; width: 100%; max-width: 150px; - min-width: 56px; gap: 8px; padding: 0 0 0 0; height: 100%; @@ -128,6 +143,16 @@ const StyledWeekdaySelector = styled(Select)` height: 16px; fill: #92989A; } + .MuiInputBase-input { + padding-right: 0 !important; + text-align: right; + } + @media (max-width: 767px) { + .MuiInputBase-input { + text-align: left; + max-width: 100%; + } + } `; const StyledInputBase = styled(InputBase)` width: 100%; @@ -247,8 +272,8 @@ export default function EditSubMilestone({ }; return ( - - + + {`${index + 1}.`} - - - )} /> - )} - renderValue={ - (selected) => - selected?.length ? selected - .map((ISODate) => ISOToWeekday(ISODate)) - .filter(Boolean) - .join(", ") : '自訂' - } - sx={{ - '.MuiSelect-icon': { - display: 'none', + + + )} /> + )} + 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' }, - }} - MenuProps={{ - PaperProps: { - style: { - padding: '12px', - maxHeight: 150, - overflowY: 'auto', - scrollbarWidth: 'thin', - maxWidth: '140px' - }, - }, - MenuListProps: { - style: { - padding: '0' - } + }, + MenuListProps: { + style: { + padding: '0' } - }} - > - {ZH_WEEK_DAY_MAP.map((zhDay) => { - const isSelected = newMilestone.dates.includes(weekdayToISO(zhDay)); - return ( - - {zhDay} - - ); - })} - + } + }} + > + {ZH_WEEK_DAY_MAP.map((zhDay) => { + const isSelected = newMilestone.dates.includes(weekdayToISO(zhDay)); + return ( + + {zhDay} + + ); + })} + + - - - + + + + - - - - - - + + + + + ); } From f6927f0caa799593375055cce5f472456287f277 Mon Sep 17 00:00:00 2001 From: ruby10127130 Date: Wed, 18 Dec 2024 18:19:21 +0800 Subject: [PATCH 7/7] chore: remove un used mui component in components/EditMilestone --- components/Marathon/SignUp/EditSubMilestone.jsx | 1 - 1 file changed, 1 deletion(-) diff --git a/components/Marathon/SignUp/EditSubMilestone.jsx b/components/Marathon/SignUp/EditSubMilestone.jsx index 1352f326..87d02fd6 100644 --- a/components/Marathon/SignUp/EditSubMilestone.jsx +++ b/components/Marathon/SignUp/EditSubMilestone.jsx @@ -3,7 +3,6 @@ import styled from "@emotion/styled"; import { Typography, Box, - Grid, IconButton, MenuItem, Select,