diff --git a/.eslintrc.js b/.eslintrc.js index 4c244ffe..1c65dc3f 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -17,13 +17,22 @@ module.exports = { settings: { 'import/resolver': { alias: { - extensions: ['.js', '.jsx'], + extensions: ['.js', '.jsx', '.ts', '.tsx'], map: [['@', '.']], }, + node: { + extensions: ['.js', '.jsx', '.ts', '.tsx'] + }, }, }, rules: { "import/no-extraneous-dependencies": ["error", { devDependencies: ["./*.js"] }], + 'import/extensions': ['error', 'ignorePackages', { + js: 'never', + jsx: 'never', + ts: 'never', + tsx: 'never', + }], 'react/no-unescaped-entities': 'off', '@next/next/no-page-custom-font': 'off', 'react/prop-types': [0], diff --git a/components/Banner.jsx b/components/Banner.jsx new file mode 100644 index 00000000..54d4dc6c --- /dev/null +++ b/components/Banner.jsx @@ -0,0 +1,188 @@ +import styled from '@emotion/styled'; +import Image from '@/shared/components/Image'; +import { Box } from '@mui/material'; + +import LearningMarathonImgDesktopGroup from '@/public/assets/learning-marathon/2025S1-desktop-group.png'; +import LearningMarathonImgDesktopIcon1 from '@/public/assets/learning-marathon/2025S1-desktop-icon-1.png'; +import LearningMarathonImgDesktopIcon2 from '@/public/assets/learning-marathon/2025S1-desktop-icon-2.png'; +import LearningMarathonImgDesktopIcon3 from '@/public/assets/learning-marathon/2025S1-desktop-icon-3.png'; +import LearningMarathonImgDesktopIcon4 from '@/public/assets/learning-marathon/2025S1-desktop-icon-4.png'; +import LearningMarathonImgDesktopIcon5 from '@/public/assets/learning-marathon/2025S1-desktop-icon-5.png'; +import LearningMarathonImgDesktopIcon6 from '@/public/assets/learning-marathon/2025S1-desktop-icon-6.png'; +import LearningMarathonImgDesktopGirl from '@/public/assets/learning-marathon/2025S1-desktop-girl-1.png'; +import LearningMarathonImgDesktopBoy from '@/public/assets/learning-marathon/2025S1-desktop-boy-1.png'; +import LearningMarathonImgDesktopBg from '@/public/assets/learning-marathon/2025S1-desktop-bg.png'; +import LearningMarathonImgMobile from '@/public/assets/learning-marathon/2025S1-mobile@2x.png'; + +import { cn } from '@/utils/cn'; + +const StyledBanner = styled(Box)` + width: 100%; + height: calc(100vw / 1.6); + position: relative; + box-sizing: border-size; + overflow: hidden; + + @media (max-width: 767px) { + height: calc(100vw / 0.6428); + } +`; + +const Banner = ({ children }) => { + return ( + +
+
+ 島島盃 - 學習馬拉松 2025 春季賽 +
+
+ 揪團 +
+
+ 男角色 +
+
+ 女角色 +
+
+
+ icon5 +
+
+
+
+ icon3 +
+
+
+
+ icon1 +
+
+
+
+ icon4 +
+
+
+
+ icon2 +
+
+
+
+ icon6 +
+
+
+
+ 島島盃 - 學習馬拉松 2025 春季賽 +
+ {children} +
+ ); +}; + +export default Banner; diff --git a/components/Group/Form/Form.styled.jsx b/components/Group/Form/Form.styled.jsx index 036344ac..3041d837 100644 --- a/components/Group/Form/Form.styled.jsx +++ b/components/Group/Form/Form.styled.jsx @@ -20,8 +20,13 @@ export const StyledDescription = styled.p` export const StyledContainer = styled.main` position: relative; + display: flex; + align-items: center; + flex-direction: column; + background: #f3fcfc; + padding: 60px 0; + min-height: 100vh; margin: 0 auto; - width: 720px; .MuiInputBase-input, .MuiFormControlLabel-label { @@ -30,7 +35,6 @@ export const StyledContainer = styled.main` @media (max-width: 760px) { padding: 20px; - width: 100%; } `; @@ -58,6 +62,7 @@ export const StyledGroup = styled.div` export const StyledFooter = styled.div` display: flex; justify-content: center; + width: 100%; `; export const StyledChip = styled(Chip)` diff --git a/components/Group/Form/index.jsx b/components/Group/Form/index.jsx index 311d7012..eb14125b 100644 --- a/components/Group/Form/index.jsx +++ b/components/Group/Form/index.jsx @@ -8,6 +8,7 @@ import Button from '@/shared/components/Button'; import Checkbox from '@mui/material/Checkbox'; import FormControlLabel from '@mui/material/FormControlLabel'; import { activityCategoryList } from '@/constants/activityCategory'; +import { ProtectedComponent } from '@/contexts/Auth'; import StyledPaper from '../Paper.styled'; import { StyledHeading, @@ -40,7 +41,6 @@ export default function GroupForm({ onSubmit, }) { const { - notLogin, control, values, errors, @@ -66,14 +66,10 @@ export default function GroupForm({ setIsChecked((pre) => !pre)} /> ); - if (notLogin) { - return ; - } - return ( - + - + {isCreateMode ? '發起揪團' : '編輯揪團'} @@ -149,7 +145,7 @@ export default function GroupForm({ placeholder="希望在什麼時間舉行?" /> - + - + {!isCreateMode && ( - + {values.isGrouping ? '開放揪團中' : '已關閉揪團'} - + ); } diff --git a/components/Group/Form/useGroupForm.jsx b/components/Group/Form/useGroupForm.jsx index 725bf262..a19dbba9 100644 --- a/components/Group/Form/useGroupForm.jsx +++ b/components/Group/Form/useGroupForm.jsx @@ -1,15 +1,14 @@ import dayjs from 'dayjs'; -import { useEffect, useRef, useState } from 'react'; -import { useSelector } from 'react-redux'; +import { useRef, useState } from 'react'; import { ZodType, z } from 'zod'; import { useSnackbar } from '@/contexts/Snackbar'; import { CATEGORIES } from '@/constants/category'; import { AREAS } from '@/constants/areas'; import { EDUCATION_STEP } from '@/constants/member'; import { BASE_URL } from '@/constants/common'; -import openLoginWindow from '@/utils/openLoginWindow'; import { activityCategoryList } from '@/constants/activityCategory'; import useLeaveConfirm from '@/hooks/useLeaveConfirm'; +import { useAuth } from '@/contexts/Auth'; const _eduOptions = EDUCATION_STEP.filter( (edu) => !['master', 'doctor', 'other'].includes(edu.value), @@ -84,9 +83,8 @@ const rules = { }; export default function useGroupForm(defaultValue) { + const { user, token } = useAuth(); const [isDirty, setIsDirty] = useState(false); - const me = useSelector((state) => state.user); - const notLogin = !me?._id; const [values, setValues] = useState(() => ({ ...INITIAL_VALUES, ...defaultValue, @@ -96,7 +94,7 @@ export default function useGroupForm(defaultValue) { rule.safeParse(defaultValue[key])?.data ?? INITIAL_VALUES[key], ]) ), - userId: me?._id, + userId: user?._id, })); const [errors, setErrors] = useState({}); const { pushSnackbar } = useSnackbar(); @@ -136,7 +134,7 @@ export default function useGroupForm(defaultValue) { method: 'DELETE', headers: { 'Content-Type': 'application/json', - Authorization: `Bearer ${me.token}`, + Authorization: `Bearer ${token}`, }, }); }; @@ -154,7 +152,7 @@ export default function useGroupForm(defaultValue) { const response = await fetch(`${BASE_URL}/image`, { method: 'POST', headers: { - Authorization: `Bearer ${me.token}`, + Authorization: `Bearer ${token}`, }, body: formData, }); @@ -211,20 +209,9 @@ export default function useGroupForm(defaultValue) { onValid({ ...result.data, photoURL }); }; - useEffect(() => { - let timer; - if (notLogin) { - timer = setTimeout(() => { - openLoginWindow(); - }, 100); - } - return () => clearTimeout(timer); - }, [notLogin]); - useLeaveConfirm({ shouldConfirm: isDirty }); return { - notLogin, control, errors, values, diff --git a/components/Group/detail/index.jsx b/components/Group/detail/index.jsx index 574e6328..36d73c44 100644 --- a/components/Group/detail/index.jsx +++ b/components/Group/detail/index.jsx @@ -1,8 +1,8 @@ import { useRouter } from 'next/navigation'; -import { useSelector } from 'react-redux'; import Box from '@mui/material/Box'; import Skeleton from '@mui/material/Skeleton'; import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew'; +import { useAuth } from '@/contexts/Auth'; import Image from '@/shared/components/Image'; import ContactButton from '@/shared/components/ContactButton'; import { StyledStatus } from '../GroupList/GroupCard.styled'; @@ -22,8 +22,8 @@ import ShareButtonGroup from './ShareButtonGroup'; function GroupDetail({ id, source, isLoading }) { const router = useRouter(); - const me = useSelector((state) => state.user); - const isMyGroup = source?.userId === me?._id && !!me?._id; + const { user } = useAuth(); + const isMyGroup = source?.userId === user?._id && !!user?._id; return ( diff --git a/components/Home/index.jsx b/components/Home/index.jsx index ae719b89..2117582e 100644 --- a/components/Home/index.jsx +++ b/components/Home/index.jsx @@ -1,7 +1,8 @@ -import React, { useMemo, useRef, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import React, { useRef } from 'react'; import styled from '@emotion/styled'; -import { Divider } from '@mui/material'; -import Banner from './Banner'; +import { Button, Divider } from '@mui/material'; +import Banner from '../Banner'; import Guide from './Guide'; import About from './About'; import Group from './Group'; @@ -11,13 +12,55 @@ import WishResource from './WishResource'; import APPBanner from './APPBanner'; import JoinCooperate from './JoinCooperate'; -const HomeWrapper = styled.div``; +const StyledBannerButton = styled(Button)` + &.MuiButton-root { + position: absolute; + top: calc(100vw / 3.6); + left: 50%; + transform: translate(-50%); + border-radius: 40px; + background: #FFA10B; + display: flex; + width: 250px; + height: 50px; + padding: 5px 20px; + justify-content: center; + align-items: center; + gap: 10px; + flex-shrink: 0; + color: #FFF; + } + &.MuiButton-text { + color: #FFF; + text-align: center; + font-size: 18px; + font-weight: 400; + line-height: 140%; + } + + @media (hover: hover) { + &.MuiButton-root:hover { + box-shadow: 0px 4px 10px 0px rgba(255, 161, 11, 0.50); + } + } + + @media (max-width: 767px) { + &.MuiButton-root { + top: calc(100vw / 1.434); + } + } +`; function Home() { const guideRef = useRef(null); + const router = useRouter(); return ( - - +
+ + { router.push('/learning-marathon#marathon-intro'); }}> + 不要錯過!點我了解 + + @@ -34,7 +77,7 @@ function Home() { - +
); } diff --git a/components/Marathon/Apply/index.jsx b/components/Marathon/Apply/index.jsx new file mode 100644 index 00000000..ca4b8914 --- /dev/null +++ b/components/Marathon/Apply/index.jsx @@ -0,0 +1,392 @@ +import styled from "@emotion/styled"; +import { + Box, + Typography +} from "@mui/material"; +import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'; + +const StyledGroup = styled(Box)` + width: 100%; + max-width: 100%; + + &.showDecorateImg { + position: relative; + } + + &.showDecorateImg:after { + content: ''; + display: block; + position: absolute; + right: 0; + top: -90px; + background-image: url('/assets/pen.png'); + background-size: cover; + background-repeat: no-repeat; + width: 167px; + height: 124px; + } +`; + +const StyledList = styled(Box)` + ul { + list-style-type: disc; + padding-left: 1.4em; + + li { + color: #536166; + font-size: 16px; + font-weight: 400; + line-height: 140%; + text-align: left; + } + } +`; + +const StyledParagraph = styled(Typography)` + font-size: 16px; + font-style: normal; + font-weight: 400; + line-height: 140%; + color: #536166; +`; + +const StyledYear = styled(Typography)` + color: #16B9B3; + font-family: Roboto; + font-size: 20px; + font-style: normal; + font-weight: 700; + line-height: 140%; +`; +const StyledTimelineGroup = styled(Box)` + display: flex; + flex-direction: row; + align-items: stretch; + justify-content: flex-start; + border-radius: 4px; + gap: 4px; +`; + +const StyledDateCard = styled(Box)` + background-color: #FFF; + border-radius: 4px; + width: 120px; + flex-shrink: 0; + padding: 6px 12px; + text-align: center; +`; + +const StyledTime = styled(Typography)` + font-family: Roboto; + font-size: 16px; + font-weight: 400; + line-height: 140%; + color: #536166; + word-break: none; + text-align: center; +`; + +const StyledContent = styled(Typography)` + color: #536166; + font-size: 16px; + font-style: normal; + font-weight: 700; + line-height: 140%; + background-color: #FFF; + border-radius: 4px; + width: 100%; + padding: 10px 20px; + display: flex; + flex-direction: column; + justify-content: center; + align-items: flex-start; +`; + +const StyledDateGroup = styled(Box)` + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + + .date { + margin-right: 4px; + font-size: 20px; + font-style: normal; + font-weight: 700; + line-height: 140%; + color: #536166; + width: 3em; + text-align: right; + } + + .weekday { + display: flex; + align-self: center; + width: 24px; + height: 24px; + flex-shrink: 0; + flex-direction: column; + justify-content: center; + align-items: center; + border-radius: 4px; + background: #FFA10B; + text-align: center; + + span { + color: #FFF; + text-align: center; + font-family: Roboto; + font-size: 16px; + font-style: normal; + font-weight: 700; + line-height: 140%; + } + } +`; +export default function Apply() { + return ( + + + + (一)重要時程 + + + 2024 + + + + +
12/16
+
+
+
+ + 計畫開始申請 + +
+ + + +
12/29
+
+
+ 15:00-16:30 +
+ + 自主學習工作坊暨說明會(線上) + +
+ + 2025 + + + + +
01/19
+
+
+
+ + 申請截止 + +
+ + + +
01/27
+
+
+
+ + 入選與備取公告 + +
+ + + + +
02/03
+
+
+ 23:59 +
+ + 繳費期限 + +
+ + + + +
02/05
+
+
+
+ + 備取遞補公告 + +
+ + + + +
02/09
+
+
+ + +
07/12
+
+
+
+ + 計畫期間 + +
+ + + + +
02/09
+
+
+ 14:00-15:00 +
+ + 暖身活動(線上) + +
+ + + + 線上課時間 + + +
    +
  • 2025/2/15(六)、2025/2/22(六)、2025/3/1(六)14:00-15:30
  • +
+
+
+ + + +
07/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%) + + +
    +
  • 學習評量:評量方式客觀且有效,能真實反映學習成果。
  • +
  • 學習成果呈現方式:成果呈現方式具體且多元,並與學習目標相符,能有效展現學習成果。
  • +
+
+
+ + 評選委員將依據上述標準,綜合考量申請者的學習計畫,進行評分和排序。 + +
+
+ ); +} diff --git a/components/Marathon/Equip/index.jsx b/components/Marathon/Equip/index.jsx new file mode 100644 index 00000000..b4991c63 --- /dev/null +++ b/components/Marathon/Equip/index.jsx @@ -0,0 +1,111 @@ +import styled from "@emotion/styled"; +import { + Box, + Typography +} from "@mui/material"; + +const StyledGroup = styled(Box)` + width: 100%; + max-width: 100%; + display: grid; + grid-template: 1fr 1fr / 1fr 1fr; + gap: 20px; + + @media (max-width: 767px) { + grid-template: 1fr / 1fr; + } +`; + +const StyledCard = styled(Box)` + height: 300px; + border-radius: 10px; + padding: 25px 30px; +`; + +const StyledTitle = styled(Typography)` + color: #293A3D; + font-size: 18px; + font-style: normal; + font-weight: 700; + line-height: 140%; + margin-bottom: 30px; +`; + +const StyledList = styled(Box)` + ul { + list-style-type: disc; + padding-left: 1em; + + li { + color: #293A3D; + font-size: 14px; + font-weight: 400; + line-height: 140%; + text-align: left; + } + } +`; +export default function Equip() { + return ( + + + 「專業陪跑員」
陪你規劃路徑與自我釐清
+ +
    +
  • 3 次 1 小時一對一諮詢
  • +
  • 2 次 1 小時團體諮詢
  • +
  • 引導師每兩週對學員的學習進度給予回饋
  • +
+
+
+ + 「專業課程」
帶你掌握自主學習要領
+ +
    +
  • 「策略」目標設定與學習策略
  • +
  • 「方法」思考、提問、筆記方法
  • +
  • 「人」學習社群與個人狀態釐清
  • +
  • 「展現」成果展現與自我行銷
  • +
+
+
+ + 「百人社群」
讓你找到合適夥伴與各界人脈
+ +
    +
  • 5 次 1 小時全員每月聚會
  • +
  • 專屬學習小組,5 次 1 小時學習小組每月聚會
  • +
  • 島島阿學Discord社群即時交流
  • +
  • 島島阿學網站找夥伴找揪團功能
  • +
+
+
+ + 「AI個人化學習工具」
引導你學習方向及自律學習
+ +
    +
  • 具引導性的自主學習模板
  • +
  • 學習日誌
  • +
  • 學習任務上傳與回饋區
  • +
  • 進度安排與檢核表
  • +
  • 自我檢核表
  • +
  • 學習成果分享專區
  • +
  • AI推薦與引導
  • +
+
+
+
+ ); +} diff --git a/components/Marathon/Faq/index.jsx b/components/Marathon/Faq/index.jsx new file mode 100644 index 00000000..0a337d1c --- /dev/null +++ b/components/Marathon/Faq/index.jsx @@ -0,0 +1,149 @@ +import { useState, useEffect, useRef } from 'react'; +import styled from "@emotion/styled"; +import { + Box, +} from "@mui/material"; +import KeyboardArrowRightIcon from '@mui/icons-material/KeyboardArrowRight'; + +const StyledGroup = styled(Box)` + width: 100%; + max-width: 100%; + border: 1px solid #DEF5F5; + @media (max-width: 767px) { + grid-template: 1fr / 1fr; + } +`; + +const StyledAccordionWrapper = styled.div` + border-radius: 4px; + overflow: hidden; +`; +const StyledAccordionHeader = styled.div` + padding: 12px 16px; + cursor: pointer; + display: flex; + justify-content: flex-start; + align-items: center; + border: 1px solid #DEF5F5; + background-color: #DEF5F5; + + .title { + margin-left: 12px; + font-size: 16px; + font-style: normal; + font-weight: 500; + line-height: 140%; + color: #293A3D; + } + + .MuiSvgIcon-root { + transition: all ease .3s; + color: #293A3D; + } + + .open.MuiSvgIcon-root { + transform: rotate(90deg); + } +`; + +const StyledAccordionContent = styled.div` + background: #fff; + height: auto; + max-height: 0px; + overflow: hidden; + transition: max-height 0.3s ease; +`; + +const StyledContent = styled.div` + padding: 16px; + color: #536166; + font-size: 14px; + font-weight: 400; + line-height: 140%; +`; +function Accordion({ title, children }) { + const [isOpen, setIsOpen] = useState(false); + const [height, setHeight] = useState(0); + const contentRef = useRef(null); + + const toggleAccordion = () => { + setIsOpen((prev) => !prev); + }; + + useEffect(() => { + if (contentRef.current) { + setHeight(contentRef.current.scrollHeight); + } + }, [isOpen]); + return ( + + + + + +

{title}

+
+ + + + {children} + + +
+ ); +} + +export default function Faq() { + return ( + + + 我們歡迎你邀請朋友一起申請,多人申請會有團報優惠價,但如果只有你自己的話也沒問題! + + + 我們會根據每位入選者的背景和特質分組。 + + + 僅有工作坊會有錄影,學習小組會議、引導師諮詢、社群活動等不會有錄影。 + + + 每位入選者的最終成果會公開在島島網站上,除了本次活動入選者外,其他使用者也能了解你在這 5 個月的學習歷程。 + + + 本次計劃鼓勵各地學習者參與,故多數活動為線上,但為增加實體互動,部分社群活動將以實體為主,實體地點將視入圍學員所在地調整,詳細請參考重要時程。 + + + 完成本計劃的參與者將收到電子版證書。 + + + 2025/2/10 課程開始前可全額退費;若於2025/2/16 23:59 前提出退費申請,並將申請寄送至主辦單位電子信箱,即會退還繳納費用總額之二分之一。2025/2/16 23:59 即不退費。 + + + 在申請期間每人只能提交一件學習計畫,待公告入選者後,使用者可新增至多三個學習計劃。 + + + ); +} diff --git a/components/Marathon/Mentors.jsx b/components/Marathon/Mentors.jsx new file mode 100644 index 00000000..fdb18034 --- /dev/null +++ b/components/Marathon/Mentors.jsx @@ -0,0 +1,381 @@ +import React, { useMemo, useRef, useState } from 'react'; +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 Image from '@/shared/components/Image'; +import Modal from '@/shared/components/Modal'; +import { cn } from '@/utils/cn'; + +const mentors = [ + { + title: '引導師', + name: '林怡廷', + image: '/assets/mentors/card-partner-0.jpg', + tags: ['島島阿學共同發起人', '臺灣實驗教育勞動合作社共同發起人', 'g0v零時小學校專案經理'], + social: { + facebook: { + text: 'tiff.lin.1', + url: 'https://www.facebook.com/tiff.lin.1', + }, + medium: { + text: 'ohtiffanylin', + url: 'https://medium.com/@ohtiffanylin', + }, + }, + experiences: [ + '2022 - Now g0v 零時政府揪松團/零時小學校專案經理', + '2021 - Now 臺灣實驗教育勞動合作社/共同發起人、理事', + '2020 - Now 島島阿學學習社群/共同發起人', + '2022 - 2023 振鐸學會/專案秘書', + '2020 - 2021 臺灣實驗教育推動中心第三屆實驗教育工作者培育計畫/學員', + '2020 - 2022 美國實驗教育大學 Goddard College 社會創新與永續研究所 畢業', + '2019 - 2020 青年國際實驗高等教育聯盟/公關', + '2015 - 2019 宜蘭人文國中小 & 展賦行動學苑/教師', + ], + introduction: '#善於傾聽以及提供各種可能性 \n#熱愛釐清需求與分享資源 \n#在美國GoddardCollege用自主學習完成碩士\n\n擔任多年公關、實驗教育教師後,深感對民主教育之興趣,因而就讀美國民主教育大學 Goddard College 社會創新與永續研究所,並以創立島島阿學學習社群為題,以行動研究民主教育,並以自我導向學習完成學業。在擔任實驗教育工作者、g0v 零時小學校專案經理的經驗中,曾引導百位以上學生發展自主學習計畫與數位專案。同時也持續發起各項教育行動,如島島阿學、臺灣實驗教育勞動合作社、鯨落教育聯盟等。' + }, + { + title: '引導師', + name: '許明宏', + image: '/assets/mentors/card-partner-1.jpg', + tags: ['島島阿學共同發起人', '軟體工程師', '臺灣實驗教育勞動合作社共同發起人', 'Side Project Taiwan軟體專案社群共同發起人'], + social: {}, + experiences: [], + introduction: '# 多年豐富自主學習經驗,並累積成為職涯專業能力\n# 擁有公部門、非營利組織、商業公司視野思維\n# 致力於透過設計與科技解決學習問題\n\n擁有豐富的自主學習經驗,從公部門轉領域至教育組織,擔任教育工作者,並因島島阿學網站的開發需求,自學程式設計,而後轉職為軟體工程師。在此過程中,發起了多個創新組織,包括島島阿學、臺灣實驗教育勞動合作社以及軟體專案社群。擅長自主學習、資源連結、使用者研究與軟體開發。\n於島島阿學主要負責團隊經營、使用者需求研究、產品規劃與軟體開發,致力於透過設計與科技解決學習者的實際問題,並打造一個能促進自主學習的生態系統。' + }, + { + title: '引導師', + name: '謝佩君', + image: '/assets/mentors/card-partner-2.jpg', + tags: ['TalentLabs - Learning facilitator'], + social: { + linkedin: { + text: 'Peggy, Pei-Chun Hsieh', + url: 'https://www.linkedin.com/in/peggy-pei-chun-hsieh/', + }, + threads: { + text: 'unfoldingwithpeg', + url: 'https://www.threads.net/@unfoldingwithpeg', + }, + }, + experiences: [], + introduction: '# 引導和學習體驗設計的實踐者\n# 熱愛使用各種數位工具\n# 不知不覺進入教育學習領域工作\n\n在教育和學習產業擁有超過 10 年以上經驗,曾擔任教育顧問和成功大學國際處專案經理。熱衷於分享和推廣各種引導和學習體驗設計資源和概念。從非傳統教學者的角度出發,致力於創造對學習者有意義的學習體驗。喜歡了解和學習相關的知識和技能,包括心理學、行為科學和腦科學。\n擅長線上工作坊設計與引導,並著重在個人學習與發展,並善於使用各種數位工具應用,提升線上互動及學習效益。\n\n在島島阿學主要負責產品規劃及專案管理,希望藉由應用學習理論、行為科學等概念,幫助學習者提升自我導向學理能力,讓學習不再是被動接受,而是主動探索和建構知識的過程。' + }, + { + title: '引導師', + name: '沈潔伃', + image: '/assets/mentors/card-partner-3.jpg', + tags: ['魚水教育催化劑創辦人', '實驗教育審議委員', '芬蘭HundrED Ambassador, Academy Member & Advisory Board'], + social: { + facebook: { + text: 'joannshen0', + url: 'https://www.facebook.com/joannshen0/', + }, + medium: { + text: 'tobeedu', + url: 'https://tobeedu.medium.com/', + }, + }, + experiences: [ + '芬蘭 HundrED/ Ambassador, Academy Member & Advisory Board', + '國際 Sociocracy For All/華語區領導者', + '鯨落教育聯盟/共同發起人', + '自主學習公共化推動連線/共同召集人', + '青年國際實驗高等教育聯盟/共同發起人', + '中華民國振鐸學會/倡議&研究員', + '台灣另類暨實驗教育學會/研究員', + '均一平台教育基金會/倡議&研究員', + '人文生態與教育研究室/研究員', + '國立台灣大學 未來大學計畫/專案助理', + '多縣市學校與非學校型態實驗教育審議委員', + '臺灣實驗教育推動中心/自學手冊撰寫人', + '孩籽實驗教育協會/理事', + '第一屆雜學校/策展執行', + '人文國中小&人文行動高中/助理教師', + '東華大學 多元文化教育碩士班畢業', + '陽明交通大學 百川學士學位學程 首屆學生', + '人文展賦行動學苑(自學團體)畢業', + ], + introduction: '# 擅於發掘學習資源與結合多元可能性\n# 沒有大學學歷但碩士畢業的實驗教育畢業生\n# 國際教育創新與學習生態系統研究者\n\n求學旅途中,我曾被標籤為拒學生,踏入實驗教育後探索自主學習的可能性,透過特殊選才進入大學不分系後,因學習不能自我決定(self-determination)而肄業,隨後以同等學力取得教育碩士學位。這些經歷讓我專注於建構民主、共榮的學習生態系統,並期待教育成為促進社會公平與變革的力量。\n\n過去十多年,我累積了豐富的社會創新實務經驗,足跡遍及國際非營利組織、社會企業、高等教育與實驗教育領域,並創辦了「魚水教育催化劑」,希望透過教育研究與內容策展推動創新與變革,創造全納的教育生態系統。' + }, + { + title: '引導師', + name: '閉恩濡', + image: '/assets/mentors/card-partner-4.jpg', + tags: ['實驗教育工作者', '人文社會學科的信徒', '反求諸己的議題倡議者'], + social: {}, + experiences: [ + 'IDEC2024教育世界博覽會/活動組長', + '光合人文教育工作室/行政人員', + '振鐸學會/均優學習論壇/教育再公共化聯盟/網站編輯', + '《地方創生的人們》/社群編輯', + '教育部大專青年女性培力營/工作團隊', + '中華牧人關懷協會/暑期輔導員', + '《職人Shoukuzine》/專案編輯', + '教育廣播電台節目《教育行動家》/共同主持人', + '人文行動高中/助理教師', + '紀錄片《學習的理由》/募資推廣組', + ], + introduction: '# 擅於發現與定義問題\n# 盡情探索且讓理論結合情境的學習風格\n#上大學前gap year三年從此不再當全職學生\n\n「教育」,在我的成長經歷中,扮演了多變而影響深遠的角色。11歲以前,我是班級裡品學兼優的模範,除了成績維持頂標,體育及音樂比賽也得爭著拿獎牌。直到五年級時因遭受社團同學的排擠, 我開始懼怕上學,而這也成為我與實驗教育相遇的契機。\n\n為了克服我對同儕和團體的排斥,父母將我轉學到實驗學校,重視天賦發展與無界學習的校風,讓我找到自己對「多元文化」與「教育」的興趣。高中畢業後先後於非營利組媒體平台就職,並於2019年入學陽明交通大學百川學士學程(不分系),主修人文社會專業。至今透過專題、營隊與研究報告,實踐自己對「社會運動」,「在地文化」與「教育不平等」的關注。 ' + }, + { + title: '引導師', + name: '楊逸帆', + image: '/assets/mentors/card-partner-5.jpg', + tags: ['《學習的理由》紀錄片導演', '青醒人共生文化智庫研究員', '國際實驗高教知行聯盟共同發起人', '日本綜合人間學會理事'], + social: { + researchGate: { + text: 'Adler-Yang', + url: 'https://www.researchgate.net/profile/Adler-Yang', + }, + }, + experiences: [ + '國際批判實在論學會/理事', + '日本綜合人間學會/理事', + '國家發展委員會台灣國際教育高峰會/策展人', + '雜學校/教務主任', + '國際實驗高教知行聯盟/共同發起人', + '康乃爾大學Cabrera Lab認證「系統思考、製圖與領導」/訓練師', + 'Minerva大學首位台灣學生', + '青醒人共生文化智庫/創辦人、研究員', + '《學習的理由》紀錄片/導演', + ], + introduction: '#系統思考\n#以自我實現承擔社會需要\n#LearningByCaring\n\n「將青春期捐給教育與青少年的世界公民」是楊逸帆的自我認識。十四歲起,他便致力實踐他理解的教育本質:共構理想社會搖籃,使人人以自我實現承擔社會需要。\n\n其處女作《學習的理由》費時七年,涵蓋十五年素材,藉五位台灣體制外學生的真實故事,探究升學與分流體制對青少年的影響,並映射追求成就與認同所隱藏的迷失危機。該作已獲國內外十餘項影展認同,在台灣、香港多次被引用探討,金馬評審與金鐘導演亦分別譽之為「2016十大台灣最重要電影」「近年探討考試教育的最佳作品」,2016年起於台灣與香港各地戲院上映。\n\n青醒人共生文化智庫(原名:Awakening 青醒)是《學習的理由》催生的實驗之一。自2012年成立,成員曾橫跨兩岸三地,藉線上雜誌、特約報導、廣播電台等媒介探究台、港、中、馬、日等亞洲諸國教育與青年議題,亦數次跨洋採訪國際專家、進行田野調查,為台灣唯一當面專訪《讓天賦自由》已故作者Ken Robinson的採訪者。青醒人的媒體實驗,是以「參與式公民研究與實驗」作為根基。此外,青醒人亦以「我的教育我設計」「自我改變地圖」等工作坊,培養教育第一線的自我引導與系統探究能力,並藉累計上千人參與的一手資料庫,研發適用個體與群體的工具、策略與論述。經歷多年研究與實驗所提出並營造之改變理論、計畫與學習文化,催化並豐富了不少兩岸三地青少年的自主學習經驗與開創學習資源、社會行動之能力,其中不乏後來免試錄取競爭激烈之大學,甚至創立教育與永續事業、總統教育獎、北京教育創新大獎獲獎者(如本計畫中的兩位mentor)。楊逸帆亦以青醒人之行動研究成果於2015年入圍彼得・提爾青年改變家計畫(Thiel Fellowship: 20 under 20)複選,獲邀至矽谷參與彼得・提爾基金會年度高峰會。\n\n此外,他亦曾以資源媒合、專業諮詢、評審、統籌、理事等角色參與其他教育組織,如台灣DFC、不太乖教育節、雜學校、香港教育大同、美國國際另類教育資源組織(AERO)、日本綜合人間學會、國際批判實在論學會(IACR)等。曾任國立成功大學教育學程、香港教育燃薪師資培訓計畫講師,於首爾ANYSE論壇、美國AERO論壇、尼泊爾國際民主教育年會(IDEC)等場合演說,於TEDxTaipei演講更獲2014年度十大精選。\n\n雖以教育為起點,楊逸帆的使命是探尋養生而共生世界的可能。\n\n作為社會系統變革者,致力於減輕社會對體制教育的依賴,讓人人成為有系統觀與自我覺察引導能力、不再那麼依賴體制的「自我改變家」。同時,身為社會系統研究者,持續探究世界危機之系統關聯與關鍵因子,文章散見於中英日文專欄、期刊、專書。' + }, + { + title: '助教小天使', + name: '蘇冠彰', + image: '/assets/mentors/card-partner-6.jpg', + tags: ['島島阿學核心團隊', '島島阿學行銷與網站改版開發夥伴', '中原大學應用華語系與心理學系大四生'], + social: { + medium: { + text: 'kangarooblog', + url: 'https://kangarooblog.medium.com/', + }, + }, + experiences: [ + '2021~Now 島島阿學/核心團隊', + '2024 直覺職掘國中生涯探索營隊/助教', + '2024 One-Forty 社團法人台灣四十分之一移工教育文化協會/課程培力組助教', + '2024 IDEC 臺灣教育世界博覽會/活動組組員', + '2023 人生書家-高中生生涯探索/共同創辦人', + '2023 EdYouth社團法人臺灣一滴優教育協會/影響力發展組', + ], + introduction: '#資源橋梁的陪伴者\n#自主學習研究各種數位工具\n#教育與助人工作不斷的走跳者\n\n「以『人』為信仰核心,使『人』成為一個真正的人。」\n嗨!大家我是袋鼠,MBTI是綠老頭又是非常罕見的INFJ提倡者!\n熱愛袋鼠、倉鼠、貓咪,以及是個咖啡成癮者(工作需要咖啡才能動腦XD)\n也喜歡各種社會運動以及議題討論\n\n國高中,討厭教育制度帶給我的厭惡感(沒錯,討厭到寫了三篇讀書心得,有兩篇還是特優😂),開始反思教育的本質與意義。\n到了大學,跨出學校與文理組的邊界後,不斷的自主學習各種技能與領域,並看見教育與生涯的多樣性與選擇性,開始行動以助人角度為主的社會設計與社會創新的旅程。而未來的我將會持續在「助人工作」與「教育創新」繼續創造理想社會,並能人、關懷與陪伴的角度,走入各種教育與社會議題現場。\n如果對於NPO社創或是心理學相關問題有興趣的都可以來討論唷!' + }, +]; + +const MentorCard = ({ mentor, children, className, style, onClick }) => ( + +); + +const Tag = ({ text, className }) => ( +
+ {text} +
+); + +const Mentors = () => { + const [currentMentor, setCurrentMentor] = useState(0); + const mentorsRef = useRef(null); + const [activeMentorName, setActiveMentorName] = useState(''); + const [isOpen, setIsOpen] = useState(false); + const [touchStartX, setTouchStartX] = useState(null); + const timer = useRef(null); + const { translateX, isEnd } = useMemo(() => { + const currentTranslateX = currentMentor * 301; + if (window.innerWidth + currentTranslateX > mentorsRef.current?.scrollWidth) { + return { translateX: mentorsRef.current?.scrollWidth - window.innerWidth, isEnd: true }; + } + return { translateX: currentTranslateX, isEnd: false }; + }, [currentMentor]); + + const socialIcons = { + linkedin: , + threads: , + facebook: , + medium: , + researchGate: , + }; + + const activeMentor = useMemo(() => mentors.find((mentor) => mentor.name === activeMentorName), [activeMentorName]); + + const checkTimer = () => { + if (timer.current) return true; + timer.current = true; + setTimeout(() => { + timer.current = false; + }, 300); + return false; + }; + + const handleNextMentor = () => { + if (checkTimer()) return; + if (!isEnd) { + setCurrentMentor((prev) => prev + 1); + } + }; + + const handlePrevMentor = () => { + if (checkTimer()) return; + if (currentMentor > 0) { + setCurrentMentor((prev) => prev - 1); + } + }; + + const handleOpenModal = (name) => { + setActiveMentorName(name); + setIsOpen(true); + }; + + const handleCloseModal = () => { + setIsOpen(false); + }; + + const handleTouchStart = (e) => { + setTouchStartX(e.touches[0].clientX); + }; + + const handleTouchMove = (e) => { + const touchMoveX = e.touches[0].clientX; + const deltaX = touchMoveX - touchStartX; + if (Math.abs(deltaX) > 50) { + if (deltaX < 0) { + handleNextMentor(); + } else { + handlePrevMentor(); + } + } + }; + + const handleTouchEnd = (e) => { + setTouchStartX(null); + }; + + return ( + <> +
+

引導師介紹

+
+ + + + + + +
+
+ +
+ {mentors.map((mentor) => ( + handleOpenModal(mentor.name)} + > +
+
+ {mentor.tags.slice(0, 1).map((tag, index) => ( + + ))} +
+
{mentor.title} | {mentor.name}
+
+
+ ))} +
+ + setActiveMentorName('')} + > + + + +
+
+ + + +
+
{activeMentor?.title} | {activeMentor?.name}
+
+ {activeMentor?.tags.map((tag) => ( + + ))} +
+
+ {Object.keys(activeMentor?.social || {}).map((key) => ( + + {socialIcons[key]} + {activeMentor?.social[key]?.text} + + ))} +
+
+
+
+ {activeMentor?.experiences?.length > 0 && ( + <> +

經歷

+
    + {activeMentor?.experiences.map((experience) => ( +
  • {experience}
  • + ))} +
+ + )} +
+
+

自我介紹

+

{activeMentor?.introduction}

+
+
+
+ + ); +}; + +export default Mentors; diff --git a/components/Marathon/Participant/index.jsx b/components/Marathon/Participant/index.jsx new file mode 100644 index 00000000..6c43eea5 --- /dev/null +++ b/components/Marathon/Participant/index.jsx @@ -0,0 +1,110 @@ +import styled from "@emotion/styled"; +import { + Box, + Typography +} from "@mui/material"; +import Image from "@/shared/components/Image"; +import PersonaImage1 from "@/public/assets/marathon-persona-1.png"; +import PersonaImage2 from "@/public/assets/marathon-persona-2.png"; +import PersonaImage3 from "@/public/assets/marathon-persona-3.png"; +import PersonaImage4 from "@/public/assets/marathon-persona-4.png"; + +const StyledGroup = styled(Box)` + width: 100%; + max-width: 100%; + display: grid; + grid-template: 1fr 1fr / 1fr 1fr; + gap: 20px; + + @media (max-width: 767px) { + grid-template: 1fr / 1fr; + } +`; + +const StyledCard = styled(Box)` + height: 300px; + border-radius: 10px; + padding: 40px 30px; + text-align: center; + + img { + display: block; + margin: 0 auto; + object-fit: cover; + object-position: center; + } +`; +const StyledImageContainer = styled(Box)` + height: 160px; +`; + +const StyledTitle = styled(Typography)` + color: #293A3D; + text-align: center; + font-size: 18px; + font-style: normal; + font-weight: 700; + line-height: 140%; +`; +export default function Participant() { + return ( + + + + marathon-persona-1 + + 有模糊的職涯/生涯方向,
想開始做準備與鋪路
+
+ + + marathon-persona-2 + + 考試不適合我,
更想用個人經歷上大學
+
+ + + marathon-persona-3 + + 學校課程好無聊,希望可以用
自己的方式學有興趣的事情
+
+ + + marathon-persona-4 + + 想自主學習,
有方向但不確定可以怎麼開始
+
+
+ ); +} diff --git a/components/Marathon/Price/index.jsx b/components/Marathon/Price/index.jsx new file mode 100644 index 00000000..c075c71e --- /dev/null +++ b/components/Marathon/Price/index.jsx @@ -0,0 +1,459 @@ +import styled from "@emotion/styled"; +import { + Box, + Typography +} from "@mui/material"; + +const StyledGroup = styled(Box)` + width: 100%; + max-width: 100%; + + &.showDecorateImg { + position: relative; + + } + + &.showDecorateImg:after { + content: ''; + display: block; + position: absolute; + right: 0; + top: -90px; + background-image: url('/assets/pen.png'); + background-size: cover; + background-repeat: no-repeat; + width: 167px; + height: 124px; + } +`; + +const StyledList = styled(Box)` + ul { + list-style-type: disc; + padding-left: 1.4em; + + li { + color: #536166; + font-size: 16px; + font-weight: 400; + line-height: 140%; + text-align: left; + } + } +`; + +const StyledParagraph = styled(Typography)` + font-size: 16px; + font-style: normal; + font-weight: 400; + line-height: 140%; + color: #536166; +`; + +const StyledDiscount = styled(Box)` + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-start; + color: #516166; + + .price-type { + font-size: 18.023px; + font-style: normal; + font-weight: 500; + line-height: 140%; + margin-right: 0.5em; + } + .curr { + font-family: Roboto; + font-size: 19.31px; + font-weight: 400; + line-height: 140%; + margin-right: 0.5em; + } + .count { + font-family: Roboto; + font-size: 25.746px; + font-style: normal; + font-weight: 700; + line-height: 140% + } + + .price { + position: relative; + &:after { + content: ''; + width: 110%; + height: 2px; + background-color: #516166; + top: 50%; + left: 50%; + transform: translate(-50%); + position: absolute; + } + } + + @media (max-width: 767px) { + justify-content: center; + + .price-type { + font-size: 14px; + } + .curr { + font-size: 15px; + } + .count { + font-size: 20px; + } + .price:after { + height: 1px; + } + } +`; +const StyledPriceCardGroup = styled(Box)` + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0 25.75px; + max-width: 100%; + @media (max-width: 767px) { + gap: 0px 10px; + } +`; + +const StyledPriceCard = styled(Box)` + padding: 25.75px; + border-radius: 12.873px; + background-color: #F3F3F3; + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: space-between; + + .title { + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-start; + color: #536166; + font-size: 20px; + font-style: normal; + font-weight: 700; + line-height: 140%; + margin-bottom: 20px; + } + + .note { + margin-left: 0.5em; + font-weight: 400; + font-size: 16px; + } + + .price { + margin-top: auto; + display: flex; + flex-direction: row; + align-items: flex-end; + justify-content: flex-start; + } + + .curr { + font-family: Roboto; + font-size: 20px; + font-weight: 400; + line-height: 140%; + margin-right: 0.5em; + height: 100%; + display: flex; + align-items: flex-end; + color: #536166; + } + + .count { + height: 100%; + font-family: Roboto; + font-size: 45px; + font-weight: 500; + line-height: 100%; + letter-spacing: -0.496px; + display: flex; + align-items: flex-end; + height: auto; + } + + @media (max-width: 767px) { + border-radius: 10px; + padding: 20px; + + .title { + flex-direction: column; + align-items: flex-start; + font-size: 16px; + margin-bottom: 12px; + } + .note { + font-weight: 400; + font-size: 12px; + margin: 0; + } + .curr { + font-size: 15px; + } + .count { + font-size: 30px; + } + } +`; + +const StyledPartnerPrice = styled(Box)` + padding: 25.75px; + border-radius: 12.873px; + border: 1px solid #89DAD7; + + .group { + border-bottom: 1px solid #DBDBDB; + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-start; + } + + .type { + padding: 12.87px 25.75px; + flex-grow: 1; + width: 50%; + } + + .type p { + color: #536166; + font-size: 20.597px; + font-style: normal; + font-weight: 700; + line-height: 140%; + } + + .price { + flex-shrink: 0; + padding: 7.72px 25.75px; + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + } + + .total { + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-start; + } + + .total .curr { + font-family: Roboto; + font-size: 19.31px; + font-weight: 400; + line-height: 140%; + color: #536166; + margin-right: 0.25em; + } + + .total .count { + font-family: Roboto; + font-size: 25.746px; + font-style: normal; + font-weight: 700; + line-height: 140%; + color: #536166; + } + + .single { + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-start; + } + + .single .curr { + margin-right: 0.25em; + font-family: Roboto; + font-size: 19.31px; + font-style: normal; + font-weight: 400; + line-height: 140%; + color: #536166; + } + + .single .count { + color: #FFA10B; + font-family: Roboto; + font-size: 30.896px; + font-style: normal; + font-weight: 500; + line-height: 150%; + letter-spacing: -0.34px; + } + + @media (max-width: 767px) { + padding: 20px; + border-radius: 10px; + + .type { + width: auto; + padding: 10px; + flex-shrink: 0; + } + + .type p { + font-size: 16px; + } + + .price { + padding: 6px 20px; + flex-direction: column; + align-items: flex-start; + } + + .total .curr { + font-size: 15px; + } + .total .count { + font-size: 20px; + } + .single .curr { + font-size: 15px; + } + .single .count { + font-size: 24px; + } + } +`; +export default function Price() { + return ( + + + 申請無需費用,入選後才需繳交!
+ 完賽可退全額! +
+ + 原價 +
+ NT$ + 32,000 +
+
+ + +
+ 優惠價 +
+
+

NT$

+ 8,000 +
+
+ +
+ 早鳥價 + 12/31 23:59 前申請 +
+
+

NT$

+ 6,000 +
+
+
+ +
+

2人團報價

+
+
+

NT$

+

10,000

+
+
+

/ 一人NT$

+

5,000

+
+
+
+
+

3人團報價

+
+
+

NT$

+

12,000

+
+
+

/ 一人NT$

+

4,000

+
+
+
+
+

4人團報價

+
+
+

NT$

+

12,000

+
+
+

/ 一人NT$

+

3,000

+
+
+
+
+ +
    +
  • 活動費用於入選公告後再繳費即可,主辦單位將會寄發繳費通知到入選者信箱
  • +
  • 若完成指定的條件,會退回活動費用
  • +
  • 島島阿學提供三名中低收入戶學習者免活動費用的參與機會,申請時須提供證明
  • +
  • 申請期間有不定期的折價優惠活動,至高可折 500 元,歡迎追蹤島島阿學 Instagram 與 FB 粉絲專頁。
  • +
+
+ + 退費標準 + + +
    +
  • + 需符合以下三項要求 +
      +
    • + 工作坊、學習小組會議、團體諮詢及 1對1 諮詢,加總不得請假超過5小時。 +
    • +
    • + 準時提交所有每兩週的進度報告。 +
    • +
    • + 於 2025/7/10 前完成以下資料 +
        +
      • + 完成並上傳所有成果發表資料。 +
      • +
      • + 分享至少三個於計劃期間使用的學習資源,並分享使用心得。 +
      • +
      • + 完成學習馬拉松回饋問卷。 +
      • +
      +
    • +
    +
  • +
+
+ +
+ ); +} diff --git a/components/Marathon/SignUp/ConfirmForm.jsx b/components/Marathon/SignUp/ConfirmForm.jsx new file mode 100644 index 00000000..f41e221e --- /dev/null +++ b/components/Marathon/SignUp/ConfirmForm.jsx @@ -0,0 +1,519 @@ +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 { useAuthDispatch } from '@/contexts/Auth'; + +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 { openLoginModal } = useAuthDispatch(); + 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 { + openLoginModal(); + } + }, [userState, openLoginModal]); + 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.apiStateWithType) { + case 'updateMarathonProfileSuccess': { + toast.success('更新成功'); + router.push('/learning-marathon/success'); + break; + } + case 'createMarathonProfileByTokenSuccess': { + toast.success('申請成功'); + router.push('/learning-marathon/success'); + break; + } + case 'updateMarathonProfileFailure': { + toast.error('更新失敗'); + break; + } + case 'createMarathonProfileByTokenFailure': { + toast.error('申請失敗'); + break; + } + default: + } + }, [marathonState.apiStateWithType]); + + 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 || ''} + + 學習資源 + + + {marathonState?.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..c80bf943 --- /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%; + } +`; 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..73be4431 --- /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..a7a29147 --- /dev/null +++ b/components/Marathon/SignUp/MilestoneGroup.jsx @@ -0,0 +1,252 @@ +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 eventWeekRange = 22; + const [startDate, setStartDate] = useState('2025-02-09'); + const [endDate, setEndDate] = useState(dayjs(startDate).add('22', 'week')); + const [frequency, setFrequency] = useState('biweekly'); + + function arabicToChinese(num) { + const digits = ["零", "一", "二", "三", "四", "五", "六", "七", "八", "九"]; + + if (num < 1 || num > 100 || !Number.isInteger(num)) { + return "Input Number must >= 1 && =< 100"; + } + + if (num === 100) { + return "一百"; + } + + const tens = Math.floor(num / 10); + const ones = num % 10; + + if (tens === 0) { + return digits[ones]; + } + if (tens === 1) { + return ones === 0 ? "十" : `十${digits[ones]}`; + } + return `${digits[tens]}十${ones === 0 ? "" : digits[ones]}`; + } + function calculateMilestones( + dateToStart = '2025-02-09', + freq = 'biweekly', + defaultMilestones = [] + ) { + const interval = (freq === 'weekly') ? 7 : 14; + const milestoneLength = (freq === 'weekly') ? 22 : 11; + 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'), + 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 weeklyMilestonesLength = 22; + const eventStartDate = '2025-02-09'; + setStartDate(eventStartDate); + let initMilestones = []; + + if (milestones.length === weeklyMilestonesLength) { + setFrequency('weekly'); + initMilestones = calculateMilestones(eventStartDate, 'weekly', milestones); + } else { + setFrequency('biweekly'); + initMilestones = calculateMilestones(eventStartDate, 'biweekly', milestones); + } + + if (!isDisabled) { + onChange({ + type: 'UPDATE_FIELD', + payload: { + key: 'milestones', + value: initMilestones + } + }); + } + }, []); + + return ( + <> + + 學習里程碑 * + + + 請依據時間與精力設定里程碑(入選後時程表須包含每兩週需繳交的學習任務) + + + + + + + ( + + )} + /> + + + ( + + )} + /> + + + + 每週 + 每兩週 + + + + + + {milestones.map((milestone, i) => { + const interval = frequency === 'biweekly' ? 14 : 7; + const taskStartDate = dayjs('2025-02-9').add(i * interval, 'day'); + const taskEndDate = taskStartDate.add(interval - 1, 'day'); + const weekNumber = frequency === 'biweekly' ? arabicToChinese(i * 2 + 1) : arabicToChinese(i + 1); + return ( + + ); + })} + + + + + ); +} diff --git a/components/Marathon/SignUp/MilestonePanel.jsx b/components/Marathon/SignUp/MilestonePanel.jsx new file mode 100644 index 00000000..b70e2d0a --- /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, + startDate = '', + endDate = '', + weekNumber = '', +}) { + const { + name, + 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} + + + {startDate} + + + + {endDate} + + + + + + + + { + 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 ( + + 選擇項目 + + + ); +} diff --git a/components/Marathon/SignUp/PricingForm.jsx b/components/Marathon/SignUp/PricingForm.jsx new file mode 100644 index 00000000..0af0fccb --- /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..0dbdde07 --- /dev/null +++ b/components/Marathon/SignUp/SaveBar.jsx @@ -0,0 +1,84 @@ +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) { + .top h2 { + font-size: 18px; + } + .MuiStepLabel-label { + display: none; + } + } +`; + +export default function SaveBar({ currentStep }) { + return ( + +
+

申請參加學習馬拉松

+
+
+ + + 編輯個人頁面 + + + 學習計畫填寫 + + + 核對學習計畫內容 + + +
+
+ ); +} 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..28b01ce2 --- /dev/null +++ b/components/Marathon/SignUp/UserProfileForm.jsx @@ -0,0 +1,581 @@ +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 { useAuthDispatch } from '@/contexts/Auth'; +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 authDispatch = useAuthDispatch(); + 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 [hasUpdateAuthContext, setHasUpdateAuthContext] = 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) { + 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); + } + if (!hasUpdateAuthContext) { + authDispatch.updateUser(user); + setHasUpdateAuthContext(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} + + + ))} + + + + +
+ + + + 教育階段 + + + + 居住地 + + {(userState.country === '台灣' || userState.country === 'tw') && ( + + + + + + + + + )} + + + + 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..4255ab09 --- /dev/null +++ b/components/Marathon/SignUp/useEditProfile.jsx @@ -0,0 +1,264 @@ +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'; +import { useAuthDispatch } from '@/contexts/Auth'; + +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: '', + interestList: [], + isSubscribeEmail: true +}; + +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 authDispatch = useAuthDispatch(); + 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, + isSubscribeEmail: true, + interestList: [] + }; + + // TODO: replace with authDispatch + if (type === 'update') { + reduxDispatch(updateUser(payload)); + } else if (type === 'create') { + 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/Spotlight/index.jsx b/components/Marathon/Spotlight/index.jsx new file mode 100644 index 00000000..b288e4b9 --- /dev/null +++ b/components/Marathon/Spotlight/index.jsx @@ -0,0 +1,113 @@ +import styled from "@emotion/styled"; +import { + Box, + Typography +} from "@mui/material"; +import BoomImage from "@/public/assets/booming.png"; + +const StyledGroup = styled(Box)` + width: 100%; + max-width: 100%; + display: block; + gap: 20px; + + @media (max-width: 767px) { + grid-template: 1fr / 1fr; + } +`; + +const StyledCard = styled(Box)` + border-radius: 10px; + padding: 25px 30px; + position: relative; + + &.boom:after { + position: absolute; + content: ''; + background-image: url(/assets/booming.png); + background-size: cover; + background-repeat: no-repeat; + display: block; + width: 185px; + height: 140px; + right: -70px; + bottom: -22px; + } + + @media (max-width: 767px) { + &.boom:after { + display: none; + } + } +`; + +const StyledTitle = styled(Typography)` + color: #FFF; + font-size: 18px; + font-style: normal; + font-weight: 700; + line-height: 140%; + margin-bottom: 30px; +`; + +const StyledList = styled(Box)` + ul { + list-style-type: disc; + padding-left: 1em; + + li { + color: #FFF; + font-size: 14px; + font-weight: 400; + line-height: 140%; + text-align: left; + } + } +`; + +const StyledParagraph = styled(Typography)` + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 140%; + color: #FFF; +`; + +export default function Spotlight() { + return ( + + + 專業且客製化的陪跑方式 + 不只重視成果,更重視過程與你的全人發展,並強調「Knowing知識經驗、Being個人形塑、Doing行動」三者的交織。不只這樣... + +
    +
  • 萃取多位自我導向學習實踐者之經驗
  • +
  • 結合被譽為全球最接近民主教育的美國百年民主大學 Goddard College 教學方法(首次在台灣公開)
  • +
  • 結合 High Performance Learning Journeys 學習引導法
  • +
  • AI推薦與引導
  • +
+
+
+ + AI 個人化學習工具X社群支持 + 有 AI 推薦與引導外,也重視人與人真實地互動! +
+ +
    +
  • 結合 AI 給你更好的資源與人脈推薦,以及學習引導
  • +
  • 跨領域、跨年齡的百人社群,讓你可以找到同儕,也可以找到業界前輩
  • +
+
+
+
+ ); +} diff --git a/components/Partner/Banner/index.jsx b/components/Partner/Banner/index.jsx index bfd0a740..48b32fe9 100644 --- a/components/Partner/Banner/index.jsx +++ b/components/Partner/Banner/index.jsx @@ -1,10 +1,11 @@ +import { useSelector } from 'react-redux'; import styled from '@emotion/styled'; import { useRouter } from 'next/router'; import { Box } from '@mui/material'; import Button from '@/shared/components/Button'; import Image from '@/shared/components/Image'; import partnerImg from '@/public/assets/partner-banner.png'; -import { useSelector } from 'react-redux'; +import { useAuth } from '@/contexts/Auth'; const StyledBanner = styled(Box)(({ theme }) => ({ height: '398px', @@ -62,7 +63,7 @@ const StyledContent = styled(Box)(({ theme }) => ({ const Banner = () => { const router = useRouter(); // select token from user - const { token } = useSelector((state) => state.user); + const { isLoggedIn } = useAuth(); return ( @@ -70,7 +71,7 @@ const Banner = () => {

尋找夥伴

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

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

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

基本資料

+ + + 生日 * + + onChangeHandler({ key: 'birthDay', value: date }) + } + renderInput={(params) => ( + + )} + /> + + + + 性別 * + + {GENDER.map(({ label, value }) => ( + + onChangeHandler({ key: 'gender', value }) + } + sx={{ + border: '1px solid #DBDBDB', + borderRadius: '8px', + padding: '10px', + width: 'calc(calc(100% - 16px) / 3)', + display: 'flex', + justifyItems: 'center', + alignItems: 'center', + cursor: 'pointer', + ...(userState.gender === value + ? { + backgroundColor: '#DEF5F5', + border: '1px solid #16B9B3', + } + : {}), + }} + > + {label} + + ))} + + + + + 身份 * + + {ROLE.map(({ label, value, image }) => ( + handleRoleListChange(value)} + sx={{ + border: '1px solid #DBDBDB', + borderRadius: '8px', + padding: '10px', + margin: '4px', + width: 'calc(calc(100% - 24px) / 3)', + flexBasis: 'calc(calc(100% - 24px) / 3)', + display: 'flex', + flexDirection: 'column', + justifyItems: 'center', + alignItems: 'center', + cursor: 'pointer', + ...(userState.roleList.includes(value) + ? { + backgroundColor: '#DEF5F5', + border: '1px solid #16B9B3', + } + : {}), + '@media (max-width: 767px)': { + height: '100% auto', + width: 'calc(calc(100% - 24px) / 2)', + flexBasis: 'calc(calc(100% - 24px) / 2)', + }, + }} + > + + } + /> + + {label} + + + ))} + + + + + onChangeHandler({ + key: 'isSubscribeEmail', + value: event.target.checked, + }) + } + /> + } + label="訂閱電子報與島島阿學的新資訊" + /> + + {Object.values(errors).length > 0 && ( + + )} + +
+
+
+ ); +} + +export default Step1; diff --git a/components/Signin/Step2.jsx b/components/Signin/Step2.jsx new file mode 100644 index 00000000..4bdfac92 --- /dev/null +++ b/components/Signin/Step2.jsx @@ -0,0 +1,196 @@ +import styled from '@emotion/styled'; +import { Box, Button, Typography, Skeleton } from '@mui/material'; +import { LazyLoadImage } from 'react-lazy-load-image-component'; +import { CATEGORIES } from '@/constants/member'; + +const ContentWrapper = styled.div` + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + background-color: #fff; + border-radius: 16px; + margin: 60px auto; + max-width: 50%; + width: 100%; + @media (max-width: 767px) { + max-width: 80%; + .title { + text-overflow: ellipsis; + width: 100%; + } + } +`; + +// TODO: 待重構 +function Step2({ onChangeHandler, userState = {}, onBack, onNext }) { + const { interestList = [] } = userState; + + return ( + + + + + 您對哪些領域感興趣? + + + 請選擇2-6個您想要關注的學習領域 + + + + {CATEGORIES.map(({ label, value, image }) => ( + { + onChangeHandler({ + key: 'interestList', + value: interestList.includes(value) + ? interestList.filter((data) => data !== value) + : [...interestList, value], + }); + }} + sx={{ + border: '1px solid #DBDBDB', + borderRadius: '8px', + margin: '4px', + padding: '10px', + width: 'calc(calc(100% - 32px) / 4)', + display: 'flex', + flexDirection: 'column', + justifyItems: 'center', + alignItems: 'center', + cursor: 'pointer', + ...(interestList.includes(value) + ? { + backgroundColor: '#DEF5F5', + border: '1px solid #16B9B3', + } + : {}), + '@media (max-width: 767px)': { + height: '100% auto', + width: 'calc(calc(100% - 24px) / 2)', + flexBasis: 'calc(calc(100% - 24px) / 2)', + }, + }} + > + + } + /> + + {label} + + + ))} + + + + + + + + + + + ); +} + +export default Step2; diff --git a/components/Signin/useValidation.jsx b/components/Signin/useValidation.jsx index 0ce216e7..b1de8e35 100644 --- a/components/Signin/useValidation.jsx +++ b/components/Signin/useValidation.jsx @@ -19,6 +19,8 @@ const schema = z.object({ }) .optional(), roleList: z.array(z.string()).min(1, '請選擇您的身份').optional(), + isSubscribeEmail: z.boolean().optional(), + interestList: z.array(z.string()).optional(), }); const initialState = { @@ -26,6 +28,7 @@ const initialState = { gender: '', roleList: [], isSubscribeEmail: true, + interestList: [], }; const userReducer = (state, payload) => { diff --git a/constants/category.js b/constants/category.js index cd57b94c..28640ae2 100644 --- a/constants/category.js +++ b/constants/category.js @@ -231,11 +231,11 @@ export const NAV_LINK = [ // link: '/activities', // target: '_self', // }, - { - name: '找故事', - link: 'https://blog.daoedu.tw', - target: '_blank', - }, + // { + // name: '找故事', + // link: 'https://blog.daoedu.tw', + // target: '_blank', + // }, // { // name: '找場域', // link: '/locations', @@ -273,26 +273,26 @@ export const NAV_LINK_MOBILE = [ // link: '/activities', // target: '_self', // }, - { - name: '找故事', - link: 'https://blog.daoedu.tw', - target: '_blank', - }, + // { + // name: '找故事', + // link: 'https://blog.daoedu.tw', + // target: '_blank', + // }, // { // name: '找場域', // link: '/locations', // target: '_self', // }, - { - name: '新增資源', - link: '/contribute/resource', - target: '_self', - }, - { - name: '關於島島', - link: '/about', - target: '_self', - }, + // { + // name: '新增資源', + // link: '/contribute/resource', + // target: '_self', + // }, + // { + // name: '關於島島', + // link: '/about', + // target: '_self', + // }, { name: '加入社群', link: '/join', diff --git a/contexts/Auth/AuthContext.tsx b/contexts/Auth/AuthContext.tsx new file mode 100644 index 00000000..41db38cb --- /dev/null +++ b/contexts/Auth/AuthContext.tsx @@ -0,0 +1,387 @@ +import { + createContext, + PropsWithChildren, + useContext, + useEffect, + useMemo, + useReducer, + useRef, +} from "react"; +import { useRouter } from "next/navigation"; +import { usePathname } from "next/navigation"; +import useSWR, { SWRConfig } from "swr"; +import { useDispatch } from "react-redux"; + +import { fetchUserByToken, userLogout } from "@/redux/actions/user"; +import { + getRedirectionStorage, + getReminderStorage, + getTokenStorage, +} from "@/utils/storage"; +import { + createUserProfile, + createUserProfileSchema, + fetchUserProfile, + updateUserProfile, + updateUserProfileSchema, +} from "@/services/users"; + +import LoginModal from "./LoginModal"; +import { + AuthState, + AuthDispatch, + Action, + ActionTypes, + LoginStatus, +} from "./type"; + +const LOGIN_TYPE = "login-type"; + +const initialState: AuthState = { + isComplete: false, + isLoggedIn: false, + isTemporary: false, + isOpenLoginModal: false, + loginStatus: LoginStatus.EMPTY, + token: null, + user: null, + redirectUrl: "", +}; + +const AuthContext = createContext(null); +const AuthDispatchContext = createContext(null); + +export const useAuth = () => { + const context = useContext(AuthContext); + if (!context) { + throw new Error("useAuth must be used within an AuthProvider"); + } + return context; +}; + +export const useAuthDispatch = () => { + const context = useContext(AuthDispatchContext); + if (!context) { + throw new Error("useAuthDispatch must be used within an AuthProvider"); + } + return context; +}; + +const checkIsComplete = (data: AuthState["user"]) => { + if (!data) return false; + + const hasAnySocialCode = Object.values(data.contactList || "{}").some( + (socialCode) => Boolean(socialCode) + ); + if (!hasAnySocialCode) return false; + + const keys = [ + "name", + "birthDay", + "gender", + "roleList", + "wantToDoList", + "tagList", + "selfIntroduction", + ] as const; + + return keys.every((key) => + Boolean(Array.isArray(data[key]) ? data[key].length : data[key]) + ); +}; + +const authReducer = (state: AuthState, action: Action): AuthState => { + switch (action.type) { + case ActionTypes.OPEN_LOGIN_MODAL: { + return { + ...initialState, + isOpenLoginModal: true, + redirectUrl: action.payload || "", + }; + } + case ActionTypes.CLOSE_LOGIN_MODAL: { + return { + ...state, + isOpenLoginModal: false, + redirectUrl: "", + }; + } + case ActionTypes.SET_TOKEN: { + return { + ...state, + token: action.payload, + }; + } + case ActionTypes.UPDATE_USER: + case ActionTypes.LOGIN: { + if (!state.token) { + return initialState; + } + if (action.payload) { + const reminder = getReminderStorage().get(); + getReminderStorage().set( + typeof reminder === "number" ? reminder + 1 : 1 + ); + return { + ...state, + isComplete: checkIsComplete(action.payload), + isLoggedIn: true, + isTemporary: false, + user: action.payload, + loginStatus: LoginStatus.PERMANENT, + }; + } + return { + ...state, + isLoggedIn: false, + isTemporary: true, + user: null, + loginStatus: LoginStatus.TEMPORARY, + }; + } + case ActionTypes.LOGOUT: { + return initialState; + } + default: + return state; + } +}; + +export function AuthProvider({ children }: PropsWithChildren) { + const [state, dispatch] = useReducer(authReducer, initialState); + const router = useRouter(); + const pathname = usePathname(); + + // TODO: 待移除 redux,為了同步資訊 + const reduxDispatch = useDispatch(); + + const authDispatch = useMemo(() => { + const setToken = (payload: string) => { + getTokenStorage().set(payload); + dispatch({ type: ActionTypes.SET_TOKEN, payload }); + }; + const logout = () => { + // TODO: 待移除 localStorage.clear,目前只是為了讓 redux 同步登出的暫解 + reduxDispatch(userLogout()); + getTokenStorage().remove(); + getRedirectionStorage().remove(); + dispatch({ type: ActionTypes.LOGOUT }); + }; + return { + setToken, + logout, + login: (payload) => { + dispatch({ type: ActionTypes.LOGIN, payload }); + }, + updateUser: async (input) => { + // TODO: remove after removed redux + if ((input as { _id?: string })?._id) { + setToken((input as any)?.token); + dispatch({ type: ActionTypes.UPDATE_USER, payload: input as any }); + return; + } + + switch (state.loginStatus) { + case LoginStatus.TEMPORARY: { + const request = createUserProfileSchema.parse(input); + const { token, user } = await createUserProfile(request); + setToken(token); + dispatch({ type: ActionTypes.UPDATE_USER, payload: user }); + break; + } + case LoginStatus.PERMANENT: { + const request = updateUserProfileSchema.parse({ + ...state.user, + ...input, + }); + const payload = await updateUserProfile(request); + dispatch({ type: ActionTypes.UPDATE_USER, payload }); + break; + } + } + }, + openLoginModal: (payload) => { + logout(); + if (typeof payload === "string") { + getRedirectionStorage().set(payload); + } + dispatch({ type: ActionTypes.OPEN_LOGIN_MODAL, payload }); + }, + closeLoginModal: () => { + dispatch({ type: ActionTypes.CLOSE_LOGIN_MODAL }); + }, + }; + }, [state.loginStatus, state.user, dispatch]); + + const handleError = (error?: { status?: number }) => { + if (error?.status === 401) { + authDispatch.logout(); + } + }; + + useSWR( + state.token ? [fetchUserProfile.name, state.token] : null, + fetchUserProfile, + { + onSuccess: authDispatch.login, + onError: handleError, + } + ); + + useEffect(() => { + const handleToken = (token: string) => { + if (!token) return; + // TODO: 待移除 redux,為了同步資訊 + reduxDispatch(fetchUserByToken(token)); + authDispatch.setToken(token); + }; + + const receiveMessage = ( + event: MessageEvent<{ + type: typeof LOGIN_TYPE; + payload: { token: string }; + }> + ) => { + if (event.origin !== window.location.origin) return; + if (event.data.type === LOGIN_TYPE) { + handleToken(event.data.payload.token); + } + }; + const removeLoginListener = () => { + window.removeEventListener("message", receiveMessage, false); + }; + + handleToken(getTokenStorage().get()); + + if (state.loginStatus === LoginStatus.PERMANENT) { + removeLoginListener(); + } else { + window.addEventListener("message", receiveMessage, false); + } + + return removeLoginListener; + }, [ + state.loginStatus, + authDispatch.setToken, + authDispatch.logout, + reduxDispatch, + ]); + + useEffect(() => { + switch (state.loginStatus) { + case LoginStatus.TEMPORARY: { + const redirectUrl = state.redirectUrl || getRedirectionStorage().get(); + authDispatch.closeLoginModal(); + router.replace(redirectUrl || "/signin"); + break; + } + case LoginStatus.PERMANENT: { + const redirectUrl = state.redirectUrl || getRedirectionStorage().get(); + authDispatch.closeLoginModal(); + if (redirectUrl) router.replace(redirectUrl); + break; + } + default: + break; + } + }, [ + state.loginStatus, + state.redirectUrl, + router.replace, + authDispatch.closeLoginModal, + ]); + + useEffect(() => { + const redirectionStorage = getRedirectionStorage(); + + if (redirectionStorage.get() === pathname) { + redirectionStorage.remove(); + } + }, [pathname]); + + return ( + + + {children} + + + + ); +} + +interface ProtectedComponentProps extends PropsWithChildren { + redirectOnCancel?: string; + onlyCheckToken?: boolean; +} + +export const ProtectedComponent = ({ + children, + redirectOnCancel, + onlyCheckToken = false, +}: ProtectedComponentProps) => { + const router = useRouter(); + const opened = useRef(false); + const { isLoggedIn, isOpenLoginModal, token } = useAuth(); + const { openLoginModal } = useAuthDispatch(); + const requiresLogin = onlyCheckToken ? !token : !isLoggedIn; + + useEffect(() => { + let timer: NodeJS.Timeout; + if (requiresLogin) { + timer = setTimeout(() => { + opened.current = true; + openLoginModal(); + }, 1000); + } + return () => clearTimeout(timer); + }, [requiresLogin, openLoginModal]); + + useEffect(() => { + if ( + redirectOnCancel && + !isOpenLoginModal && + opened.current && + requiresLogin + ) { + router.replace(redirectOnCancel); + } + }, [ + redirectOnCancel, + isOpenLoginModal, + opened.current, + requiresLogin, + router.replace, + ]); + + if (requiresLogin) return
; + + return children; +}; + +export const sendLoginEvent = (token: string) => { + if (!token) { + // TODO: 處理沒 token 的狀況 + return; + } + + getTokenStorage().remove(); + + if ( + window.opener && + window.opener.location.origin === window.location.origin + ) { + window.opener.postMessage( + { type: LOGIN_TYPE, payload: { token } }, + window.location.origin + ); + window.close(); + } else { + const redirection = getRedirectionStorage().get(); + getTokenStorage().set(token); + window.location.replace(redirection || "/"); + } +}; diff --git a/contexts/Auth/LoginModal.tsx b/contexts/Auth/LoginModal.tsx new file mode 100644 index 00000000..91d554ea --- /dev/null +++ b/contexts/Auth/LoginModal.tsx @@ -0,0 +1,103 @@ +import { useEffect, useRef, useState } from "react"; +import Link from "next/link"; +import { BASE_URL } from "@/constants/common"; +import Image from "@/shared/components/Image"; +import Modal from "@/shared/components/Modal"; +import openWindowPopup from "@/utils/openWindowPopup"; +import { cn } from "@/utils/cn"; + +interface LoginModalProps { + isOpen: boolean; + keepMounted: boolean; + onClose: () => void; +} + +export default function LoginModal({ + isOpen, + keepMounted, + onClose, +}: LoginModalProps) { + const [isOpenWindow, setIsOpenWindow] = useState(false); + const timer = useRef(); + + const handleOpenLoginWindow = () => { + const popup = openWindowPopup({ + url: `${BASE_URL}/auth/google`, + title: "login", + width: 400, + height: 632, + }); + setIsOpenWindow(!!popup?.parent); + clearInterval(timer.current); + + if (popup?.parent) { + timer.current = setInterval(() => { + setIsOpenWindow(!!popup.parent); + }, 300); + } + }; + + useEffect(() => { + if (!isOpenWindow) { + clearInterval(timer.current); + } + }, [isOpenWindow, timer.current]); + + return ( + +
+
+ login +
+
+ +
+ 註冊即代表您同意島島阿學的 + + 服務條款 + + 與 + + 隱私權政策 + +
+
+ ); +} diff --git a/contexts/Auth/index.ts b/contexts/Auth/index.ts new file mode 100644 index 00000000..dc39de3c --- /dev/null +++ b/contexts/Auth/index.ts @@ -0,0 +1 @@ +export * from './AuthContext'; diff --git a/contexts/Auth/type.ts b/contexts/Auth/type.ts new file mode 100644 index 00000000..cf4032a5 --- /dev/null +++ b/contexts/Auth/type.ts @@ -0,0 +1,51 @@ +import type { + CreateUserProfile, + UpdateUserProfile, + IUser, +} from "@/services/users"; + +export enum LoginStatus { + /** 未登入 */ + EMPTY, + /** 臨時登入 */ + TEMPORARY, + /** 正式登入 */ + PERMANENT, +} + +export type AuthState = { + isComplete: boolean; + isLoggedIn: boolean; + isTemporary: boolean; + isOpenLoginModal: boolean; + loginStatus: LoginStatus; + token: string | null; + user: IUser | null; + redirectUrl: string; +}; + +export enum ActionTypes { + OPEN_LOGIN_MODAL = "openLoginModal", + CLOSE_LOGIN_MODAL = "closeLoginModal", + SET_TOKEN = "setToken", + UPDATE_USER = "updateUser", + LOGIN = "login", + LOGOUT = "logout", +} + +export type Action = + | { type: ActionTypes.OPEN_LOGIN_MODAL; payload?: string } + | { type: ActionTypes.CLOSE_LOGIN_MODAL } + | { type: ActionTypes.SET_TOKEN; payload: string } + | { type: ActionTypes.UPDATE_USER; payload: IUser; } + | { type: ActionTypes.LOGIN; payload: IUser | null } + | { type: ActionTypes.LOGOUT }; + +export type AuthDispatch = { + [ActionTypes.OPEN_LOGIN_MODAL]: (redirectUrl?: string) => void; + [ActionTypes.CLOSE_LOGIN_MODAL]: () => void; + [ActionTypes.SET_TOKEN]: (payload: string) => void; + [ActionTypes.UPDATE_USER]: (payload: CreateUserProfile | UpdateUserProfile) => Promise; + [ActionTypes.LOGIN]: (payload: IUser | null) => void; + [ActionTypes.LOGOUT]: () => void; +}; diff --git a/hooks/useFetch.jsx b/hooks/useFetch.jsx index 4cf11cd7..0b909587 100644 --- a/hooks/useFetch.jsx +++ b/hooks/useFetch.jsx @@ -1,12 +1,11 @@ import { useEffect, useReducer, useState } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; import { useRouter } from 'next/navigation'; import { BASE_URL } from '@/constants/common'; -import { userLogout } from '@/redux/actions/user'; +import { useAuth, useAuthDispatch } from '@/contexts/Auth'; const useFetch = (url, { enabled = true, initialValue, onSuccess } = {}) => { - const { token } = useSelector((state) => state.user); - const dispatch = useDispatch(); + const { token } = useAuth(); + const authDispatch = useAuthDispatch(); const router = useRouter(); const [render, refetch] = useReducer((pre) => !pre, true); const [data, setData] = useState(initialValue); @@ -31,7 +30,7 @@ const useFetch = (url, { enabled = true, initialValue, onSuccess } = {}) => { .then((res) => { if (res.status < 300) return res.json(); if (res.status === 401) { - dispatch(userLogout()); + authDispatch.logout(); router.replace('/login') } throw res; diff --git a/hooks/useMutation.jsx b/hooks/useMutation.jsx index a9fa27be..2da91125 100644 --- a/hooks/useMutation.jsx +++ b/hooks/useMutation.jsx @@ -1,12 +1,11 @@ import { useState } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; import { useRouter } from 'next/navigation'; import { BASE_URL } from '@/constants/common'; -import { userLogout } from '@/redux/actions/user'; +import { useAuth, useAuthDispatch } from '@/contexts/Auth'; const useMutation = (url, { method, enabled = true, onSuccess, onError } = {}) => { - const { token } = useSelector((state) => state.user); - const dispatch = useDispatch(); + const { token } = useAuth(); + const authDispatch = useAuthDispatch(); const router = useRouter(); const [isLoading, setIsLoading] = useState(false); const [isError, setIsError] = useState(false); @@ -32,7 +31,7 @@ const useMutation = (url, { method, enabled = true, onSuccess, onError } = {}) = .then((res) => { if (res.status < 300) return res.json(); if (res.status === 401) { - dispatch(userLogout()); + authDispatch.logout(); router.replace('/login'); } throw res; diff --git a/jsconfig.json b/jsconfig.json deleted file mode 100644 index 2a2e4b3b..00000000 --- a/jsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "compilerOptions": { - "paths": { - "@/*": ["./*"] - } - } -} diff --git a/next-env.d.ts b/next-env.d.ts new file mode 100644 index 00000000..4f11a03d --- /dev/null +++ b/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/package.json b/package.json index 7de57d4d..cfe3b76a 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "prop-types": "^15.8.1", "react": "^18.0.0", "react-copy-to-clipboard": "^5.0.4", - "react-dom": "^18.0.0", + "react-dom": "18.0.0", "react-fast-marquee": "^1.3.2", "react-ga": "^3.3.0", "react-hook-form": "^7.53.2", @@ -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": { @@ -74,6 +75,7 @@ "@next/eslint-plugin-next": "^13.2.1", "@tailwindcss/typography": "^0.5.15", "@types/chrome": "^0.0.206", + "@types/react-dom": "^19.0.2", "autoprefixer": "^10.4.20", "babel-plugin-import": "^1.13.8", "eslint": "^8.35.0", @@ -87,6 +89,7 @@ "eslint-plugin-react": "^7.24.0", "eslint-plugin-react-hooks": "^4.2.0", "postcss": "^8.4.47", - "tailwindcss": "^3.4.14" + "tailwindcss": "^3.4.14", + "typescript": "5.7.2" } } diff --git a/pages/_app.jsx b/pages/_app.jsx index e081bcef..d84d6ef6 100644 --- a/pages/_app.jsx +++ b/pages/_app.jsx @@ -1,4 +1,5 @@ import React, { useEffect, useMemo, useState } from 'react'; +import { SWRConfig } from 'swr'; import { ThemeProvider } from '@mui/material/styles'; import CssBaseline from '@mui/material/CssBaseline'; import { Toaster } from 'react-hot-toast'; @@ -8,15 +9,15 @@ import Script from 'next/script'; import Head from 'next/head'; import { persistStore } from 'redux-persist'; import { PersistGate } from 'redux-persist/integration/react'; +import { AuthProvider, useAuth } from '@/contexts/Auth'; import SnackbarProvider from '@/contexts/Snackbar'; import CompleteInfoReminderDialog from '@/shared/components/CompleteInfoReminderDialog'; import GlobalStyle from '@/shared/styles/Global'; import themeFactory from '@/shared/styles/themeFactory'; import storeFactory from '@/redux/store'; -import { checkLoginValidity, fetchUserById } from '@/redux/actions/user'; -import { getRedirectionStorage, getReminderStorage } from '@/utils/storage'; +import { checkLoginValidity } from '@/redux/actions/user'; +import { getReminderStorage } from '@/utils/storage'; import DefaultLayout from '@/layout/DefaultLayout'; -import { startLoginListener } from '@/utils/openLoginWindow'; import { initGA, logPageView } from '../utils/analytics'; import Mode from '../shared/components/Mode'; import 'regenerator-runtime/runtime'; // Speech.js @@ -25,6 +26,11 @@ import "@/shared/styles/global.css"; const store = storeFactory(); const persistor = persistStore(store); +const swrConfig = { + revalidateOnFocus: false, + errorRetryCount: 0, +}; + const App = ({ Component, pageProps }) => { const router = useRouter(); useEffect(() => { @@ -95,9 +101,13 @@ const App = ({ Component, pageProps }) => { - - - + + + + + + + @@ -109,14 +119,13 @@ const ThemeComponentWrap = ({ pageProps, Component }) => { const mode = useSelector((state) => state?.theme?.mode ?? 'light'); const theme = useMemo(() => themeFactory(mode), [mode]); const isEnv = useMemo(() => process.env.NODE_ENV === 'development', []); - const router = useRouter(); - const user = useSelector((state) => state.user); + const { isComplete, isLoggedIn } = useAuth(); const [isOpen, setIsOpen] = useState(false); const Layout = Component?.getLayout || DefaultLayout; const handleClose = () => { setIsOpen(false); - getReminderStorage().set(true); + getReminderStorage().remove(); }; useEffect(() => { @@ -124,25 +133,10 @@ const ThemeComponentWrap = ({ pageProps, Component }) => { }, []); useEffect(() => { - const stopLoginListener = startLoginListener((id, token) => { - const redirectionStorage = getRedirectionStorage(); - const redirectUrl = redirectionStorage.get(); - - dispatch(fetchUserById(id, token)); - - if (redirectUrl) { - redirectionStorage.remove(); - router.replace(redirectUrl); - } - }); - return () => stopLoginListener(); - }, [dispatch, router.replace]); - - useEffect(() => { - if (user?._id && !user?.isComplete && !getReminderStorage().get()) { + if (isLoggedIn && !isComplete && getReminderStorage().get() % 3 === 0) { setIsOpen(true); } - }, [user]); + }, [isLoggedIn, isComplete]); return ( diff --git a/pages/auth/callback/index.jsx b/pages/auth/callback/index.jsx new file mode 100644 index 00000000..b4a53b4b --- /dev/null +++ b/pages/auth/callback/index.jsx @@ -0,0 +1,49 @@ +import { Paper, Typography, Box } from "@mui/material"; +import { useSearchParams } from 'next/navigation'; +import { sendLoginEvent } from "@/contexts/Auth"; + +export default function AuthCallbackPage() { + const searchParams = useSearchParams(); + sendLoginEvent(searchParams.get("token")); + + return ( + + + 正在前往新的島嶼 + + + nobody-land + + + ); +} diff --git a/pages/group/create/index.jsx b/pages/group/create/index.jsx index 36cd3c4d..2827e522 100644 --- a/pages/group/create/index.jsx +++ b/pages/group/create/index.jsx @@ -1,13 +1,9 @@ import React, { useMemo } from 'react'; -import dynamic from 'next/dynamic'; import { useRouter } from 'next/router'; import { useSnackbar } from '@/contexts/Snackbar'; import useMutation from '@/hooks/useMutation'; import SEOConfig from '@/shared/components/SEO'; - -const GroupForm = dynamic(() => import('@/components/Group/Form'), { - ssr: false, -}); +import GroupForm from '@/components/Group/Form'; function CreateGroupPage() { const { pushSnackbar } = useSnackbar(); diff --git a/pages/group/edit/index.jsx b/pages/group/edit/index.jsx index 7bf51740..2ac7d365 100644 --- a/pages/group/edit/index.jsx +++ b/pages/group/edit/index.jsx @@ -1,21 +1,17 @@ import React, { useEffect, useMemo } from 'react'; -import dynamic from 'next/dynamic'; -import { useSelector } from 'react-redux'; import { useRouter } from 'next/router'; import { Box } from '@mui/material'; +import { useAuth } from '@/contexts/Auth'; import { useSnackbar } from '@/contexts/Snackbar'; import useFetch from '@/hooks/useFetch'; import useMutation from '@/hooks/useMutation'; import SEOConfig from '@/shared/components/SEO'; - -const GroupForm = dynamic(() => import('@/components/Group/Form'), { - ssr: false, -}); +import GroupForm from '@/components/Group/Form'; function EditGroupPage() { const { pushSnackbar } = useSnackbar(); const router = useRouter(); - const me = useSelector((state) => state.user); + const { user } = useAuth(); const { id } = router.query; const { data, isFetching } = useFetch(`/activity/${id}`, { enabled: !!id, @@ -48,10 +44,10 @@ function EditGroupPage() { }); useEffect(() => { - if (!me?._id) router.push('/login'); + if (!user?._id) router.push('/login'); if (isFetching || !source?.userId) return; - if (source.userId !== me._id) router.replace(`/group/detail?id=${id}`); - }, [me, source, isFetching, id]); + if (source.userId !== user._id) router.replace(`/group/detail?id=${id}`); + }, [user, source, isFetching, id]); return ( <> diff --git a/pages/index.jsx b/pages/index.jsx index 9c2f0aeb..47afea7e 100644 --- a/pages/index.jsx +++ b/pages/index.jsx @@ -1,16 +1,7 @@ -import React, { useMemo, useEffect } from 'react'; -import styled from '@emotion/styled'; +import React, { useMemo } from 'react'; import { useRouter } from 'next/router'; -import { sendLoginConfirmation } from '@/utils/openLoginWindow'; import SEOConfig from '../shared/components/SEO'; import Home from '../components/Home'; -import Navigation from '../shared/components/Navigation_v2'; -import Footer from '../shared/components/Footer_v2'; - -const HomePageWrapper = styled.div` - --section-height: calc(100vh - 80px); - --section-height-offset: 80px; -`; const HomePage = () => { const router = useRouter(); @@ -46,12 +37,6 @@ const HomePage = () => { [router?.asPath], ); - const { token, id } = router.query; - - useEffect(() => { - sendLoginConfirmation(id, token); - }, [id, token]); - return ( <> @@ -60,14 +45,4 @@ const HomePage = () => { ); }; -HomePage.getLayout = ({ children }) => { - return ( - - - {children} -