diff --git a/.babelrc b/.babelrc index f26687df..ca0cb10d 100644 --- a/.babelrc +++ b/.babelrc @@ -10,5 +10,25 @@ } ] ], - "plugins": ["@emotion/babel-plugin"] + "plugins": [ + [ + "babel-plugin-import", + { + "libraryName": "@mui/material", + "libraryDirectory": "", + "camel2DashComponentName": false + }, + "core" + ], + [ + "babel-plugin-import", + { + "libraryName": "@mui/icons-material", + "libraryDirectory": "", + "camel2DashComponentName": false + }, + "icons" + ], + "@emotion/babel-plugin" + ] } \ No newline at end of file diff --git a/.env.sample b/.env.sample new file mode 100644 index 00000000..4b73e256 --- /dev/null +++ b/.env.sample @@ -0,0 +1 @@ +NEXT_PUBLIC_API_URL= \ No newline at end of file diff --git a/.eslintrc.js b/.eslintrc.js index 77efc207..ffdd3c2b 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -14,13 +14,14 @@ module.exports = { node: true, }, ignorePatterns: ['.eslintrc.js'], - // settings: { - // 'import/resolver': { - // node: { - // extensions: ['.js', '.jsx', '.ts', '.tsx'], - // }, - // }, - // }, + settings: { + 'import/resolver': { + alias: { + extensions: ['.js', '.jsx'], + map: [['@', '.']], + }, + }, + }, rules: { 'react/no-unescaped-entities': 'off', '@next/next/no-page-custom-font': 'off', @@ -41,6 +42,7 @@ module.exports = { 'operator-linebreak': 0, 'function-paren-newline': 0, 'jsx-a11y/click-events-have-key-events': 0, + 'jsx-a11y/control-has-associated-label': 0, 'jsx-a11y/no-noninteractive-element-interactions': 0, 'react/jsx-one-expression-per-line': 0, 'no-confusing-arrow': 0, diff --git a/.yarn/install-state.gz b/.yarn/install-state.gz new file mode 100644 index 00000000..12e58850 Binary files /dev/null and b/.yarn/install-state.gz differ diff --git a/components/Group/AreaChips.jsx b/components/Group/AreaChips.jsx new file mode 100644 index 00000000..6bb3a551 --- /dev/null +++ b/components/Group/AreaChips.jsx @@ -0,0 +1,48 @@ +import { useCallback, useMemo } from 'react'; +import styled from '@emotion/styled'; +import { AREAS } from '@/constants/areas'; +import useSearchParamsManager from '@/hooks/useSearchParamsManager'; +import Chip from '@/shared/components/Chip'; + +const StyledAreaChips = styled.ul` + display: flex; + flex-wrap: wrap; + margin-bottom: 16px; + gap: 12px 0; +`; + +const AreaChips = () => { + const [getSearchParams, pushState] = useSearchParamsManager(); + + const currentArea = useMemo( + () => + getSearchParams('area').filter((area) => + AREAS.find(({ name }) => name === area), + ), + [getSearchParams], + ); + + const handleClickArea = useCallback( + (event) => { + const targetArea = event.target.parentNode.textContent; + const areas = currentArea.filter((area) => area !== targetArea); + + pushState('area', areas.toString()); + }, + [pushState, currentArea], + ); + + return ( + currentArea.length > 0 && ( + + {currentArea.map((name) => ( +
  • + +
  • + ))} +
    + ) + ); +}; + +export default AreaChips; diff --git a/components/Group/Banner.jsx b/components/Group/Banner.jsx new file mode 100644 index 00000000..fdce15c9 --- /dev/null +++ b/components/Group/Banner.jsx @@ -0,0 +1,69 @@ +import { useRouter } from 'next/router'; +import styled from '@emotion/styled'; +import Button from '@/shared/components/Button'; +import groupBannerImg from '@/public/assets/group-banner.png'; +import Image from '@/shared/components/Image'; + +const StyledBanner = styled.div` + position: relative; + + picture { + position: absolute; + width: 100%; + top: 0; + height: 398px; + img { + height: inherit; + } + } + + h1 { + margin-bottom: 8px; + font-weight: 700; + font-size: 36px; + line-height: 140%; + color: #536166; + } + + p { + font-weight: 400; + font-size: 14px; + line-height: 140%; + color: #536166; + } + + > div { + position: relative; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + padding-top: 100px; + } +`; + +const Banner = () => { + const router = useRouter(); + + return ( + + + 揪團封面 + +
    +

    揪團

    +

    想一起組織有趣的活動或學習小組嗎?

    +

    註冊並加入我們,然後創建你的活動,讓更多人一起參加!

    + +
    +
    + ); +}; + +export default Banner; diff --git a/components/Group/Form/Fields/AreaCheckbox.jsx b/components/Group/Form/Fields/AreaCheckbox.jsx new file mode 100644 index 00000000..17feb88c --- /dev/null +++ b/components/Group/Form/Fields/AreaCheckbox.jsx @@ -0,0 +1,88 @@ +import { useEffect, useState } from 'react'; +import Box from '@mui/material/Box'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import Checkbox from '@mui/material/Checkbox'; +import Select from './Select'; + +export default function AreaCheckbox({ + options, + itemLabel, + itemValue, + name, + value, + control, +}) { + const [isPhysicalArea, setIsPhysicalArea] = useState(false); + + const getPhysicalArea = (data) => + options.find((option) => data.includes(option.name)); + + const handleChange = (val) => + control.onChange({ target: { name, value: val } }); + + const physicalAreaValue = getPhysicalArea(value)?.name || ''; + + const toggleIsPhysicalArea = () => { + const updatedValue = value.filter((v) => !getPhysicalArea([v])); + handleChange(updatedValue); + setIsPhysicalArea((pre) => !pre); + }; + + const handleCheckboxChange = (_value) => { + const updatedValue = value.includes(_value) + ? value.filter((v) => v !== _value) + : [...value, _value]; + handleChange(updatedValue); + }; + + const handlePhysicalAreaChange = ({ target }) => { + const updatedValue = value + .filter((v) => !getPhysicalArea([v])) + .concat(target.value); + handleChange(updatedValue); + }; + + const physicalAreaControl = { + onChange: handlePhysicalAreaChange, + onBlur: handlePhysicalAreaChange, + }; + + useEffect(() => { + if (value.find((v) => getPhysicalArea([v]))) setIsPhysicalArea(true); + }, [value]); + + return ( + <> + + } + label="實體活動" + checked={isPhysicalArea} + /> + + )} + {input.trim() && ( + + + + )} + + {helperText} + {error} + + ); +} + +export default TagsField; diff --git a/components/Group/Form/Fields/TextField.jsx b/components/Group/Form/Fields/TextField.jsx new file mode 100644 index 00000000..edf16965 --- /dev/null +++ b/components/Group/Form/Fields/TextField.jsx @@ -0,0 +1,31 @@ +import MuiTextField from '@mui/material/TextField'; + +export default function TextField({ + id, + placeholder, + multiline, + name, + helperText, + control, + value, + error, +}) { + return ( + <> + + {error} + + ); +} diff --git a/components/Group/Form/Fields/Upload.jsx b/components/Group/Form/Fields/Upload.jsx new file mode 100644 index 00000000..28cdbae6 --- /dev/null +++ b/components/Group/Form/Fields/Upload.jsx @@ -0,0 +1,130 @@ +import { useEffect, useRef, useState } from 'react'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import DeleteSvg from '@/public/assets/icons/delete.svg'; +import Image from '@/shared/components/Image'; +import { StyledUpload } from '../Form.styled'; +import UploadSvg from './UploadSvg'; + +export default function Upload({ name, value, control }) { + const [preview, setPreview] = useState(value || ''); + const [error, setError] = useState(''); + const inputRef = useRef(); + + const changeHandler = (file) => { + const event = { + target: { + name, + value: file, + }, + }; + control.onChange(event); + }; + + const handleFile = (file) => { + const imageType = /image.*/; + const maxSize = 500 * 1024; // 500 KB + + setPreview(''); + setError(''); + if (!file.type.match(imageType)) { + setError('僅支援上傳圖片唷!'); + return; + } + + if (file.size > maxSize) { + setError('圖片最大限制 500 KB'); + return; + } + + const reader = new FileReader(); + reader.onload = (e) => setPreview(e.target.result); + reader.readAsDataURL(file); + changeHandler(file); + }; + + const handleDragEnter = (e) => { + e.stopPropagation(); + e.preventDefault(); + }; + + const handleDragOver = (e) => { + e.stopPropagation(); + e.preventDefault(); + }; + + const handleDrop = (e) => { + e.stopPropagation(); + e.preventDefault(); + + const { files } = e.dataTransfer; + if (files?.[0]) handleFile(files[0]); + }; + + const handleChange = (e) => { + const { files } = e.target; + if (files?.[0]) handleFile(files[0]); + }; + + const handleClear = () => { + setPreview(''); + setError(''); + inputRef.current.value = ''; + changeHandler(''); + }; + + useEffect(() => { + if (typeof value === 'string') setPreview(value); + }, [value]); + + return ( + + {preview && ( + + )} + inputRef.current.click()} + onDragEnter={handleDragEnter} + onDragOver={handleDragOver} + onDrop={handleDrop} + > + {preview && ( + 預覽封面圖 + )} + + + {preview ? '上傳其他圖片' : '點擊此處或將圖片拖曳至此'} + + + + {error} + + ); +} diff --git a/components/Group/Form/Fields/UploadSvg.jsx b/components/Group/Form/Fields/UploadSvg.jsx new file mode 100644 index 00000000..3e13bf23 --- /dev/null +++ b/components/Group/Form/Fields/UploadSvg.jsx @@ -0,0 +1,18 @@ +export default function UploadSvg({ isActive }) { + const fillColor = isActive ? '#FFFFFF' : '#89DAD7'; + + return ( + + + + ); +} diff --git a/components/Group/Form/Fields/Wrapper.jsx b/components/Group/Form/Fields/Wrapper.jsx new file mode 100644 index 00000000..359ff89f --- /dev/null +++ b/components/Group/Form/Fields/Wrapper.jsx @@ -0,0 +1,37 @@ +import Tooltip from '@mui/material/Tooltip'; +import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; +import { StyledLabel, StyledGroup } from '../Form.styled'; + +const popperProps = { + popper: { + modifiers: [ + { + name: 'offset', + options: { + offset: [0, -14], + }, + }, + ], + }, +}; + +export default function Wrapper({ id, required, label, children, tooltip }) { + return ( + + + {label} + {tooltip && ( + + + + )} + + {children} + + ); +} diff --git a/components/Group/Form/Fields/index.jsx b/components/Group/Form/Fields/index.jsx new file mode 100644 index 00000000..f8383205 --- /dev/null +++ b/components/Group/Form/Fields/index.jsx @@ -0,0 +1,34 @@ +import { useId } from 'react'; +import AreaCheckbox from './AreaCheckbox'; +import Select from './Select'; +import TagsField from './TagsField'; +import TextField from './TextField'; +import Upload from './Upload'; +import Wrapper from './Wrapper'; + +const withWrapper = (Component) => (props) => { + const id = useId(); + const formItemId = `form-item-${id}`; + const { required, label, tooltip } = props; + + return ( + + + + ); +}; + +const Fields = { + AreaCheckbox: withWrapper(AreaCheckbox), + Select: withWrapper(Select), + TagsField: withWrapper(TagsField), + TextField: withWrapper(TextField), + Upload: withWrapper(Upload), +}; + +export default Fields; diff --git a/components/Group/Form/Form.styled.jsx b/components/Group/Form/Form.styled.jsx new file mode 100644 index 00000000..634d1de6 --- /dev/null +++ b/components/Group/Form/Form.styled.jsx @@ -0,0 +1,196 @@ +import styled from '@emotion/styled'; +import InputLabel from '@mui/material/InputLabel'; +import Chip from '@mui/material/Chip'; + +export const StyledHeading = styled.h1` + margin-bottom: 8px; + text-align: center; + font-size: 22px; + font-weight: 700; + color: #536166; +`; + +export const StyledDescription = styled.p` + margin-bottom: 40px; + text-align: center; + font-size: 14px; + font-weight: 400; + color: #536166; +`; + +export const StyledContainer = styled.main` + position: relative; + margin: 0 auto; + width: 720px; + + .MuiInputBase-input, + .MuiFormControlLabel-label { + font-size: 14px; + } + + @media (max-width: 760px) { + padding: 20px; + width: 100%; + } +`; + +export const StyledLabel = styled(InputLabel)` + display: block; + margin-bottom: 8px; + font-size: 16px; + font-weight: 500; + color: #293a3d; +`; + +export const StyledGroup = styled.div` + margin-bottom: 20px; + + .error-message { + font-size: 14px; + color: red; + } +`; + +export const StyledFooter = styled.div` + display: flex; + justify-content: center; +`; + +export const StyledChip = styled(Chip)` + font-size: 14px; + border-radius: 4px; + background: #def5f5; + color: #293a3d; + + .MuiChip-label { + padding-right: 4px; + } +`; + +export const StyledSwitchWrapper = styled.div` + padding: 4px 16px; + display: flex; + justify-content: space-between; + align-items: center; + font-size: 16px; + font-weight: 500; + color: #293a3d; + border: 1px solid rgba(0, 0, 0, 0.23); + border-radius: 4px; +`; + +export const StyledTagsField = styled.div( + ({ theme }) => ` + position: relative; + padding: 8px 16px; + display: flex; + flex-wrap: wrap; + gap: 8px; + + input { + margin: -8px -16px; + padding: 8px 16px; + min-height: 40px; + width: 8rem; + border: none; + flex: 1; + + &:focus { + outline: none; + } + } + + button { + position: absolute; + right: 4px; + bottom: 4px; + } + + &:hover::after { + border-color: ${theme.palette.text.main}; + } + + &::after { + content: ''; + position: absolute; + inset: 0; + border: 1px solid rgba(0, 0, 0, 0.23); + border-radius: 4px; + pointer-events: none; + } + + &:focus-within::after { + border: 2px solid ${theme.palette.primary.main}; + } +`, +); + +export const StyledUpload = styled.div` + position: relative; + height: 300px; + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + border: 1px dashed #89dad7; + border-radius: 8px; + background: #f3fcfc; + color: #16b9b3; + font-size: 14px; + font-weight: 500; + overflow: hidden; + transition: background 0.15s; + gap: 8px; + svg, + .preview { + pointer-events: none; + } + svg, + .upload-message { + position: relative; + z-index: 1; + } + input { + display: none; + } + .preview { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + } + &:hover { + background: #def5f5; + } + &::after { + content: ''; + position: absolute; + inset: 0; + transition: background 0.15s; + } + + .lazy-load-image-background { + position: absolute; + } + + &.has-image { + color: #ffffff; + border-style: solid; + + svg, + .upload-message { + transition: opacity 0.15s; + opacity: 0; + } + + &:hover { + svg, + .upload-message { + opacity: 1; + } + &::after { + background: #0003; + } + } + } +`; diff --git a/components/Group/Form/index.jsx b/components/Group/Form/index.jsx new file mode 100644 index 00000000..5364eaed --- /dev/null +++ b/components/Group/Form/index.jsx @@ -0,0 +1,175 @@ +import { useEffect } from 'react'; +import Box from '@mui/material/Box'; +import Switch from '@mui/material/Switch'; +import CircularProgress from '@mui/material/CircularProgress'; +import Button from '@/shared/components/Button'; +import StyledPaper from '../Paper.styled'; +import { + StyledHeading, + StyledDescription, + StyledContainer, + StyledFooter, + StyledSwitchWrapper, +} from './Form.styled'; +import Fields from './Fields'; +import useGroupForm, { + areasOptions, + categoriesOptions, + eduOptions, +} from './useGroupForm'; + +export default function GroupForm({ + mode, + defaultValues, + isLoading, + onSubmit, +}) { + const { control, values, errors, isDirty, setValues, handleSubmit } = + useGroupForm(); + const isCreateMode = mode === 'create'; + + useEffect(() => { + if (!defaultValues) return; + setValues({ + ...defaultValues, + originPhotoURL: defaultValues.photoURL, + }); + }, [defaultValues]); + + return ( + + + + + {isCreateMode ? '發起揪團' : '編輯揪團'} + + + 填寫完整資訊可以幫助其他夥伴更了解揪團內容哦! + + + + + + + + + + + + + + {!isCreateMode && ( + + + {values.isGrouping ? '開放揪團中' : '已關閉揪團'} + + control.onChange({ + target: { name: 'isGrouping', value: !values.isGrouping }, + }) + } + /> + + + )} + + + + + + ); +} diff --git a/components/Group/Form/useGroupForm.jsx b/components/Group/Form/useGroupForm.jsx new file mode 100644 index 00000000..bdbfe042 --- /dev/null +++ b/components/Group/Form/useGroupForm.jsx @@ -0,0 +1,157 @@ +import { useEffect, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { useRouter } from 'next/router'; +import { ZodType, z } from 'zod'; +import { CATEGORIES } from '@/constants/category'; +import { AREAS } from '@/constants/areas'; +import { EDUCATION_STEP } from '@/constants/member'; +import { BASE_URL } from '@/constants/common'; + +const _eduOptions = EDUCATION_STEP.filter( + (edu) => !['master', 'doctor', 'other'].includes(edu.value), +); +_eduOptions.push({ key: 'noLimit', value: 'noLimit', label: '不限' }); + +export const categoriesOptions = CATEGORIES; +export const areasOptions = AREAS.filter((area) => area.label !== '線上'); +export const eduOptions = _eduOptions; + +const DEFAULT_VALUES = { + userId: '', + title: '', + file: null, + originPhotoURL: '', + photoURL: '', + photoAlt: '', + category: [], + area: [], + time: '', + partnerStyle: '', + partnerEducationStep: [], + description: '', + tagList: [], + isGrouping: true, +}; + +const rules = { + userId: z.string().optional(), + title: z.string().min(1, '請輸入標題').max(50, '請勿輸入超過 50 字'), + file: z.any(), + photoURL: z.string().or(z.instanceof(Blob)), + photoAlt: z.string(), + category: z + .array(z.enum(categoriesOptions.map(({ value }) => value))) + .min(1, '請選擇學習領域'), + area: z.array(z.string()).min(1, '請選擇地點'), + time: z.string().max(50, '請勿輸入超過 50 字'), + partnerStyle: z.string().max(50, '請勿輸入超過 50 字'), + partnerEducationStep: z + .array(z.enum(eduOptions.map(({ label }) => label))) + .min(1, '請選擇教育階段'), + description: z + .string() + .min(1, '請輸入揪團描述') + .max(2000, '請勿輸入超過 2000 字'), + tagList: z.array(z.string()), + isGrouping: z.boolean(), +}; + +export default function useGroupForm() { + const router = useRouter(); + const [isDirty, setIsDirty] = useState(false); + const me = useSelector((state) => state.user); + const [values, setValues] = useState({ + ...DEFAULT_VALUES, + userId: me?._id, + }); + const [errors, setErrors] = useState({}); + const schema = z.object(rules); + + const onChange = ({ target }) => { + const { name, value } = target; + const rule = rules[name]; + + if (rule instanceof ZodType) { + const result = rule.safeParse(value); + + setErrors((pre) => ({ + ...pre, + [name]: result.error?.issues?.[0]?.message, + })); + } + setIsDirty(true); + setValues((pre) => ({ ...pre, [name]: value })); + }; + + const onBlur = onChange; + + const control = { + onChange, + onBlur, + }; + + const handleSubmit = (onValid) => async () => { + if (!schema.safeParse(values).success) { + const updatedErrors = Object.fromEntries( + Object.entries(rules).map(([key, rule]) => [ + key, + rule.safeParse(values[key]).error?.issues?.[0]?.message, + ]), + ); + setErrors(updatedErrors); + return; + } + + if (values.originPhotoURL === values.photoURL) { + onValid(values); + return; + } + + if (values.originPhotoURL) { + const pathArray = values.originPhotoURL.split('/'); + fetch(`${BASE_URL}/image/${pathArray[pathArray.length - 1]}`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${me.token}`, + }, + }); + } + + let photoURL = ''; + + if (values.photoURL instanceof Blob) { + const formData = new FormData(); + + formData.append('file', values.photoURL); + + try { + photoURL = await fetch(`${BASE_URL}/image`, { + method: 'POST', + headers: { + Authorization: `Bearer ${me.token}`, + }, + body: formData, + }) + .then((response) => response.json()) + .then((data) => data.url); + } catch { + photoURL = ''; + } + } + onValid({ ...values, photoURL }); + }; + + useEffect(() => { + if (!me?._id) router.push('/login'); + }, [me, router]); + + return { + control, + errors, + values, + isDirty, + setValues, + handleSubmit, + }; +} diff --git a/components/Group/GroupList/GroupCard.jsx b/components/Group/GroupList/GroupCard.jsx new file mode 100644 index 00000000..4c0c6876 --- /dev/null +++ b/components/Group/GroupList/GroupCard.jsx @@ -0,0 +1,69 @@ +import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined'; +import Image from '@/shared/components/Image'; +import emptyCoverImg from '@/public/assets/empty-cover.png'; +import { timeDuration } from '@/utils/date'; +import { + StyledAreas, + StyledContainer, + StyledFooter, + StyledGroupCard, + StyledInfo, + StyledLabel, + StyledText, + StyledTitle, + StyledStatus, +} from './GroupCard.styled'; + +function GroupCard({ + _id, + photoURL, + photoAlt, + title = '未定義主題', + category = [], + partnerEducationStep, + description, + area, + isGrouping, + updatedDate, +}) { + const formatToString = (data, defaultValue = '') => + Array.isArray(data) && data.length ? data.join('、') : data || defaultValue; + + return ( + + {photoAlt + + {title} + + + 學習領域 + {formatToString(category, '不拘')} + + + 適合階段 + {formatToString(partnerEducationStep, '皆可')} + + + + {description} + + + + + {formatToString(area, '待討論')} + + + + + {isGrouping ? ( + 揪團中 + ) : ( + 已結束 + )} + + + + ); +} + +export default GroupCard; diff --git a/components/Group/GroupList/GroupCard.styled.jsx b/components/Group/GroupList/GroupCard.styled.jsx new file mode 100644 index 00000000..e14a65e9 --- /dev/null +++ b/components/Group/GroupList/GroupCard.styled.jsx @@ -0,0 +1,102 @@ +import styled from '@emotion/styled'; +import Link from 'next/link'; + +export const StyledLabel = styled.span` + flex-basis: 50px; + color: #293a3d; + font-size: 12px; + font-weight: bold; +`; + +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 || '12px'}; +`; + +export const StyledTitle = styled.h2` + font-size: 14px; + font-weight: bold; + line-height: 1.4; + display: -webkit-box; + color: #293a3d; + -webkit-box-orient: vertical; + -webkit-line-clamp: 1; + overflow: hidden; +`; + +export const StyledInfo = styled.div` + ${StyledLabel} { + margin-right: 5px; + padding-right: 5px; + border-right: 1px solid #536166; + } +`; + +export const StyledFooter = styled.footer` + display: flex; + justify-content: space-between; + align-items: center; + + time { + font-size: 12px; + font-weight: 300; + color: #92989a; + } +`; + +export const StyledStatus = styled.div` + --bg-color: #def5f5; + --color: #16b9b3; + display: flex; + align-items: center; + width: max-content; + font-size: 12px; + padding: 4px 10px; + background: var(--bg-color); + color: var(--color); + border-radius: 4px; + font-weight: 500; + gap: 4px; + + &::before { + content: ''; + display: block; + width: 8px; + height: 8px; + background: var(--color); + border-radius: 50%; + } + + &.finished { + --bg-color: #f3f3f3; + --color: #92989a; + } +`; + +export const StyledContainer = styled.div` + padding: 10px; + display: flex; + flex-direction: column; + gap: 10px; +`; + +export const StyledAreas = styled.div` + display: flex; + align-items: center; +`; + +export const StyledGroupCard = styled(Link)` + display: block; + position: relative; + background: #fff; + padding: 0.5rem; + border-radius: 4px; + + img { + vertical-align: middle; + } +`; diff --git a/components/Group/GroupList/index.jsx b/components/Group/GroupList/index.jsx new file mode 100644 index 00000000..5d7e3931 --- /dev/null +++ b/components/Group/GroupList/index.jsx @@ -0,0 +1,90 @@ +import { useEffect, Fragment } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import styled from '@emotion/styled'; +import useMediaQuery from '@mui/material/useMediaQuery'; +import { Box } from '@mui/material'; +import useSearchParamsManager from '@/hooks/useSearchParamsManager'; +import { setQuery } from '@/redux/actions/group'; +import GroupCard from './GroupCard'; + +export const StyledGroupItem = styled.li` + position: relative; + margin-top: 1rem; + flex-basis: 33.33%; + overflow: hidden; + transition: transform 0.15s, box-shadow 0.15s; + + &:hover { + z-index: 1; + transform: scale(1.0125); + box-shadow: 0 0 6px 2px #0001; + } + + @media (max-width: 767px) { + flex-basis: 50%; + } + + @media (max-width: 560px) { + flex-basis: 100%; + } +`; + +const StyledDivider = styled.li` + flex-basis: 100%; + background: #dbdbdb; + height: 1px; +`; + +const StyledGroupList = styled.ul` + display: flex; + flex-wrap: wrap; +`; + +function GroupList() { + const dispatch = useDispatch(); + const [getSearchParams] = useSearchParamsManager(); + const { items, isLoading } = useSelector((state) => state.group); + + const isMobileScreen = useMediaQuery('(max-width: 560px)'); + const isPadScreen = useMediaQuery('(max-width: 767px)') && !isMobileScreen; + const isDeskTopScreen = !isPadScreen; + + useEffect(() => { + dispatch(setQuery(getSearchParams())); + }, [getSearchParams]); + + return ( + <> + + {items?.length || isLoading ? ( + items.map((data, index) => { + const isLast = index === items.length - 1; + const shouldRenderDivider = + (isMobileScreen && !isLast) || + (isPadScreen && !isLast && index % 2 === 1) || + (isDeskTopScreen && !isLast && index % 3 === 2); + + return ( + + + + + {shouldRenderDivider && } + + ); + }) + ) : ( +
  • + 哎呀!這裡好像沒有符合你條件的揪團,別失望!讓我們試試其他選項。 +
  • + )} +
    + + {isLoading && ( + 搜尋揪團中~ + )} + + ); +} + +export default GroupList; diff --git a/components/Group/More.jsx b/components/Group/More.jsx new file mode 100644 index 00000000..342f68e9 --- /dev/null +++ b/components/Group/More.jsx @@ -0,0 +1,34 @@ +import { useDispatch, useSelector } from 'react-redux'; +import { Box, Button } from '@mui/material'; +import { setPageSize } from '@/redux/actions/group'; + +export default function More() { + const dispatch = useDispatch(); + const { pageSize, total, isLoading } = useSelector((state) => state.group); + const isMore = total > pageSize || isLoading; + + return ( + + {isMore ? ( + + ) : ( + '已經到底囉~' + )} + + ); +} diff --git a/components/Group/Paper.styled.jsx b/components/Group/Paper.styled.jsx new file mode 100644 index 00000000..805f8c85 --- /dev/null +++ b/components/Group/Paper.styled.jsx @@ -0,0 +1,12 @@ +import styled from '@emotion/styled'; +import { Box } from '@mui/material'; + +const StyledPaper = styled(Box)` + padding: 32px; + border-radius: 20px; + box-shadow: 0px 4px 6px rgba(196, 194, 193, 0.2); + background: #fff; + z-index: 2; +`; + +export default StyledPaper; diff --git a/components/Group/SearchField/CheckboxGrouping.jsx b/components/Group/SearchField/CheckboxGrouping.jsx new file mode 100644 index 00000000..afbc734c --- /dev/null +++ b/components/Group/SearchField/CheckboxGrouping.jsx @@ -0,0 +1,35 @@ +import FormControlLabel from '@mui/material/FormControlLabel'; +import Checkbox from '@mui/material/Checkbox'; +import useSearchParamsManager from '@/hooks/useSearchParamsManager'; + +export default function CheckboxGrouping() { + const QUERY_KEY = 'isGrouping'; + const [getSearchParams, pushState] = useSearchParamsManager(); + + const handleClick = ({ target: { checked } }) => { + pushState(QUERY_KEY, checked || ''); + }; + + const checkbox = ( + + ); + + return ( + + ); +} diff --git a/components/Group/SearchField/SearchInput.jsx b/components/Group/SearchField/SearchInput.jsx new file mode 100644 index 00000000..1a70d086 --- /dev/null +++ b/components/Group/SearchField/SearchInput.jsx @@ -0,0 +1,99 @@ +import { useState, useEffect } from 'react'; +import dynamic from 'next/dynamic'; +import styled from '@emotion/styled'; +import InputBase from '@mui/material/InputBase'; +import Paper from '@mui/material/Paper'; +import IconButton from '@mui/material/IconButton'; +import MicIcon from '@mui/icons-material/Mic'; +import SearchIcon from '@mui/icons-material/Search'; +import useSearchParamsManager from '@/hooks/useSearchParamsManager'; + +const Speech = dynamic(import('@/shared/components/Speech'), { + ssr: false, +}); + +const SearchInputWrapper = styled(Paper)` + width: 100%; + position: relative; + display: flex; + align-items: center; + border: 1px solid #dbdbdb; + border-radius: 30px; + padding-right: 4px; + box-shadow: none; + overflow: hidden; + + @media (max-width: 767px) { + border-radius: 20px; + width: 100%; + } +`; + +const IconButtonWrapper = styled(IconButton)` + color: #536166; + border-radius: 40px; + height: 40px; + width: 40px; +`; + +const InputBaseWrapper = styled(InputBase)(() => ({ + flex: 1, + '& .MuiInputBase-input': { + paddingTop: '14px', + paddingLeft: '20px', + paddingBottom: '14px', + background: 'white', + zIndex: 10, + borderRadius: '20px', + width: '100%', + fontSize: 14, + }, +})); + +const SearchInput = () => { + const [getSearchParams, pushState] = useSearchParamsManager(); + const [keyword, setKeyword] = useState(''); + const [isSpeechMode, setIsSpeechMode] = useState(false); + const currentKeyword = getSearchParams('q').toString(); + + useEffect(() => { + setKeyword(currentKeyword); + }, [currentKeyword]); + + const handleChange = ({ target }) => { + setKeyword(target.value); + }; + + /** @type {(event: SubmitEvent) => void} */ + const handleSubmit = (event) => { + event.preventDefault(); + pushState('q', keyword); + }; + + return ( + + + {isSpeechMode && ( + + )} + setIsSpeechMode(true)} + > + + + + + + + ); +}; + +export default SearchInput; diff --git a/components/Group/SearchField/SelectedAreas.jsx b/components/Group/SearchField/SelectedAreas.jsx new file mode 100644 index 00000000..25354144 --- /dev/null +++ b/components/Group/SearchField/SelectedAreas.jsx @@ -0,0 +1,29 @@ +import Select from '@/shared/components/Select'; +import { AREAS } from '@/constants/areas'; +import useSearchParamsManager from '@/hooks/useSearchParamsManager'; + +export default function SelectedAreas() { + const QUERY_KEY = 'area'; + const [getSearchParams, pushState] = useSearchParamsManager(); + + const handleChange = ({ target: { value } }) => { + pushState(QUERY_KEY, value.toString()); + }; + + return ( + + selected.length === 0 ? '適合的教育階段' : selected.join('、') + } + sx={{ + '@media (max-width: 767px)': { + width: '100%', + }, + }} + /> + ); +} diff --git a/components/Group/SearchField/index.jsx b/components/Group/SearchField/index.jsx new file mode 100644 index 00000000..35f7a40e --- /dev/null +++ b/components/Group/SearchField/index.jsx @@ -0,0 +1,38 @@ +import styled from '@emotion/styled'; +import SearchInput from './SearchInput'; +import SelectedAreas from './SelectedAreas'; +import SelectedEducationStep from './SelectedEducationStep'; +import CheckboxGrouping from './CheckboxGrouping'; + +const StyledSearchField = styled.div` + margin-top: 8px; + width: 100%; + + .selects-wrapper { + margin-top: 12px; + display: flex; + align-items: center; + gap: 16px; + + @media (max-width: 767px) { + margin: 10px 0; + flex-direction: column; + align-items: stretch; + } + } +`; + +const SearchField = () => { + return ( + + +
    + + + +
    +
    + ); +}; + +export default SearchField; diff --git a/components/Group/SelectedCategory.jsx b/components/Group/SelectedCategory.jsx new file mode 100644 index 00000000..068835e6 --- /dev/null +++ b/components/Group/SelectedCategory.jsx @@ -0,0 +1,134 @@ +import { useCallback, useMemo, useRef, useState } from 'react'; +import styled from '@emotion/styled'; +import { CATEGORIES } from '@/constants/category'; +import useSearchParamsManager from '@/hooks/useSearchParamsManager'; +import ScrollButton from '@/shared/components/ScrollButton'; +import Chip from '@/shared/components/Chip'; + +const StyledSelectedCategory = styled.div` + margin-top: 12px; + display: flex; + align-items: center; + + > p { + margin-right: 20px; + font-weight: 700; + font-size: 14px; + color: #536166; + flex-shrink: 0; + } + + > div { + position: relative; + max-width: calc(100% - 76px); + } + + ul { + display: flex; + overflow-x: scroll; + -ms-overflow-style: none; /* IE */ + scrollbar-width: none; /* Firefox */ + scroll-behavior: smooth; + + &::-webkit-scrollbar { + display: none; /* Chrome, Safari, Edge and Opera */ + } + + @media (max-width: 767px) { + margin: 10px 0; + } + } +`; + +const SelectedCategory = () => { + /** @type {React.MutableRefObject} */ + const categoryListRef = useRef(null); + const [getSearchParams, pushState] = useSearchParamsManager(); + const [isShowLeftScrollButton, setIsShowLeftScrollButton] = useState(false); + const [isShowRightScrollButton, setIsShowRightScrollButton] = useState(false); + + const currentCategories = useMemo( + () => getSearchParams('category'), + [getSearchParams], + ); + + const handleClickCategory = useCallback( + (event) => { + const targetCategory = event.target.textContent; + const hasCategory = currentCategories.includes(targetCategory); + const categories = hasCategory + ? currentCategories.filter((category) => category !== targetCategory) + : [...currentCategories, targetCategory]; + + pushState('category', categories.toString()); + }, + [pushState, currentCategories], + ); + + const updateScrollButtonVisibility = () => { + const { scrollLeft, scrollWidth, clientWidth } = categoryListRef.current; + const isStart = Math.floor(scrollLeft) <= 0; + const isEnd = Math.ceil(scrollLeft + clientWidth) >= scrollWidth; + + setIsShowLeftScrollButton(!isStart); + setIsShowRightScrollButton(!isEnd); + }; + + const resetScrollButtonVisibility = () => { + setIsShowLeftScrollButton(false); + setIsShowRightScrollButton(false); + }; + + const scrollButtonHandler = (type) => () => { + const delta = categoryListRef.current.offsetWidth + 100; + + if (type === 'left') { + categoryListRef.current.scrollLeft -= delta; + } else if (type === 'right') { + categoryListRef.current.scrollLeft += delta; + } + }; + + return ( + +

    學習領域

    +
    + +
      +
    • + pushState('category')} + /> +
    • + {CATEGORIES.map(({ key, value }) => ( +
    • + +
    • + ))} +
    + +
    +
    + ); +}; + +export default SelectedCategory; diff --git a/components/Group/detail/Contact/Feedback.jsx b/components/Group/detail/Contact/Feedback.jsx new file mode 100644 index 00000000..b4f8da99 --- /dev/null +++ b/components/Group/detail/Contact/Feedback.jsx @@ -0,0 +1,110 @@ +import { useId, forwardRef } from 'react'; +import { + Box, + Button, + Dialog, + DialogTitle, + Slide, + Typography, + useMediaQuery, +} from '@mui/material'; +import contractDoneImg from '@/public/assets/contactdone.png'; +import contractErrorImg from '@/public/assets/contacterror.png'; + +const Transition = forwardRef((props, ref) => { + return ; +}); + +function Feedback({ type, onClose }) { + const id = useId(); + const isMobileScreen = useMediaQuery('(max-width: 560px)'); + const titleId = `modal-title-${id}`; + const descriptionId = `modal-description-${id}`; + const contentMap = { + success: { + imgSrc: contractDoneImg.src, + imgAlt: 'success cover', + title: '已送出邀請', + description: '請耐心等候夥伴的回應', + buttonText: '關閉', + }, + error: { + imgSrc: contractErrorImg.src, + imgAlt: 'error cover', + title: '哎呀!有不明錯誤', + buttonText: '再試一次', + }, + }; + const content = contentMap[type] || {}; + + return ( + + + {content.imgAlt} + + + {content.title} + + + {content.description} + + + + + + ); +} + +export default Feedback; diff --git a/components/Group/detail/Contact/index.jsx b/components/Group/detail/Contact/index.jsx new file mode 100644 index 00000000..db9f4821 --- /dev/null +++ b/components/Group/detail/Contact/index.jsx @@ -0,0 +1,291 @@ +import { useId, useState, forwardRef, useEffect } from 'react'; +import { useRouter } from 'next/router'; +import { useSelector } from 'react-redux'; +import styled from '@emotion/styled'; +import { + Avatar, + Box, + Button, + Dialog, + DialogTitle, + IconButton, + Slide, + Typography, + TextareaAutosize, + useMediaQuery, +} from '@mui/material'; +import CloseIcon from '@mui/icons-material/Close'; +import { ROLE } from '@/constants/member'; +import chatSvg from '@/public/assets/icons/chat.svg'; +import useMutation from '@/hooks/useMutation'; +import { mapToTable } from '@/utils/helper'; +import Feedback from './Feedback'; + +const ROLELIST = mapToTable(ROLE); + +const StyledTitle = styled.label` + display: block; + color: var(--black-white-gray-dark, #293a3d); + font-size: 16px; + font-style: normal; + font-weight: 500; + line-height: 140%; /* 22.4px */ + margin-bottom: 11px; +`; +const StyledTextArea = styled(TextareaAutosize)` + display: block; + padding: 12px 16px; + background: var(--black-white-white, #fff); + border-radius: 8px; + border: 1px solid var(--black-white-gray-very-light, #dbdbdb); + width: 100%; + min-height: 128px; +`; + +const Transition = forwardRef((props, ref) => { + return ; +}); + +function ContactButton({ + user, + children, + title, + description, + descriptionPlaceholder, + isLoading, +}) { + const id = useId(); + const router = useRouter(); + const me = useSelector((state) => state.user); + const [open, setOpen] = useState(false); + const [message, setMessage] = useState(''); + const [contact, setContact] = useState(''); + const [feedback, setFeedback] = useState(''); + const isMobileScreen = useMediaQuery('(max-width: 560px)'); + const titleId = `modal-title-${id}`; + const descriptionId = `modal-description-${id}`; + const messageId = `message-${id}`; + const contactId = `contact-${id}`; + const role = + ROLE.find(({ key }) => user?.roleList?.includes(key))?.label || '暫無資料'; + + const handleClose = () => { + setOpen(false); + setMessage(''); + setContact(''); + }; + const { mutate } = useMutation(`/email`, { + method: 'POST', + onSuccess: () => { + handleClose(); + setFeedback('success'); + }, + onError: () => { + handleClose(); + setFeedback('error'); + }, + }); + + const handleSubmit = () => { + mutate({ + userId: me._id, + url: window.location.origin, + name: me.name, + roleList: + me.roleList.length > 0 + ? me.roleList.map((roleKey) => ROLELIST[roleKey]) + : [''], + photoUrl: me.photoURL, + from: me.email, + to: user.email, + subject: '【島島阿學】點開 Email,認識新夥伴', + title: '你發起的揪團有人來信!', + text: message, + information: [me.email, contact], + }); + }; + + useEffect(() => { + if (!me?._id && open) router.push('/login'); + }, [me, open, router]); + + return ( + <> + + + + {title} + + + + + + + +
    + + {user?.name || '名稱'} + + + {role} + +
    +
    + +
    + + {description} + + setMessage(e.target.value)} + placeholder={descriptionPlaceholder} + /> +
    + +
    + 聯絡資訊 + setContact(e.target.value)} + placeholder="寫下您的聯絡資訊,初次聯繫建議提供「想公開的社群媒體帳號、email」即可。" + /> +
    + + + + + +
    +
    + setFeedback('')} /> + + ); +} + +export default ContactButton; diff --git a/components/Group/detail/Detail.styled.jsx b/components/Group/detail/Detail.styled.jsx new file mode 100644 index 00000000..2a9a2ecc --- /dev/null +++ b/components/Group/detail/Detail.styled.jsx @@ -0,0 +1,56 @@ +import styled from '@emotion/styled'; +import Button from '@mui/material/Button'; + +export const StyledGoBack = styled.div` + display: inline-block; + padding: 0 4px; + margin-bottom: 10px; + color: #536166; + font-size: 14px; + cursor: pointer; + + span { + padding-left: 4px; + } +`; + +export const StyledHeading = styled.h1` + margin-top: 8px; + font-size: 22px; + font-weight: 700; + color: #536166; +`; + +export const StyledContainer = styled.main` + position: relative; + padding-top: 60px; + margin: 0 auto; + width: 720px; + + @media (max-width: 760px) { + padding: 20px; + width: 100%; + } +`; + +export const StyledDesktopEditButton = styled(Button)` + position: absolute; + top: 0; + right: 0; + border-radius: 20px; + + @media (max-width: 767px) { + display: none; + } +`; + +export const StyledMobileEditButton = styled(Button)` + display: none; + max-width: 316px; + width: 100%; + border-radius: 20px; + + @media (max-width: 767px) { + display: block; + } +`; diff --git a/components/Group/detail/Empty.jsx b/components/Group/detail/Empty.jsx new file mode 100644 index 00000000..2e088bf1 --- /dev/null +++ b/components/Group/detail/Empty.jsx @@ -0,0 +1,43 @@ +import Link from 'next/link'; +import Box from '@mui/material/Box'; +import nobodyLandGif from '@/public/assets/nobody-land.gif'; +import Button from '@/shared/components/Button'; +import StyledPaper from '../Paper.styled'; +import { StyledContainer } from './Detail.styled'; + +function EmptyGroup() { + return ( + + + + + 糟糕!找不到這個揪團,要不要尋找別的揪團? + + + nobody-land + + + + + + + + ); +} + +export default EmptyGroup; diff --git a/components/Group/detail/More.jsx b/components/Group/detail/More.jsx new file mode 100644 index 00000000..f2be94b3 --- /dev/null +++ b/components/Group/detail/More.jsx @@ -0,0 +1,54 @@ +import { useState } from 'react'; +import IconButton from '@mui/material/IconButton'; +import Menu from '@mui/material/Menu'; +import MenuItem from '@mui/material/MenuItem'; +import MoreVertOutlinedIcon from '@mui/icons-material/MoreVertOutlined'; + +export default function More() { + const [anchorEl, setAnchorEl] = useState(null); + + const handleMenu = (event) => { + setAnchorEl(event.currentTarget); + }; + + const handleClose = () => { + setAnchorEl(null); + }; + + return ( + <> + + + + + + 檢舉 + + + + ); +} diff --git a/components/Group/detail/OrganizerCard.jsx b/components/Group/detail/OrganizerCard.jsx new file mode 100644 index 00000000..1709207c --- /dev/null +++ b/components/Group/detail/OrganizerCard.jsx @@ -0,0 +1,143 @@ +import styled from '@emotion/styled'; +import Skeleton from '@mui/material/Skeleton'; +import Avatar from '@mui/material/Avatar'; +import { EDUCATION_STEP, ROLE } from '@/constants/member'; +import locationSvg from '@/public/assets/icons/location.svg'; +import Chip from '@/shared/components/Chip'; +import { timeDuration } from '@/utils/date'; + +const StyledHeader = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + + @media (max-width: 480px) { + flex-direction: column; + align-items: start; + } +`; + +const StyledFlex = styled.div` + display: flex; + justify-content: space-between; + align-items: center; +`; + +const StyledText = styled.div` + display: flex; + align-items: center; + font-size: 14px; + font-weight: 400; + color: #536166; + white-space: pre-wrap; + word-break: break-word; +`; + +const StyledTag = styled.div` + line-height: 1; + padding: 5px 10px; + border-radius: 4px; + font-size: 14px; + font-weight: 400; + background: #f3f3f3; + color: #293a3d; +`; + +const StyledTags = styled.div` + margin-top: 10px; + margin-bottom: 20px; + display: flex; + flex-wrap: wrap; + gap: 5px; + + @media (max-width: 480px) { + margin-top: 0; + margin-bottom: 10px; + } + + > div { + margin: 0; + } +`; + +const StyledTime = styled.time` + display: flex; + justify-content: flex-end; + font-size: 12px; + color: #92989a; +`; + +function OrganizerCard({ data = {}, isLoading }) { + const educationStage = + EDUCATION_STEP.find(({ key }) => key === data?.user?.educationStage) + ?.label || '暫無資料'; + const role = + ROLE.find(({ key }) => data?.user?.roleList?.includes(key))?.label || + '暫無資料'; + const location = + data?.user?.location === 'tw' + ? '台灣' + : data?.user?.location.replace('台灣', '').split('@').join(' '); + + return ( + <> + + + +
    + + + {isLoading ? ( + + ) : ( + data?.user?.name + )} + + + {isLoading ? ( + + ) : ( + educationStage + )} + + + + {isLoading ? : role} + +
    +
    + + location icon + {isLoading ? : location} + +
    + + {isLoading ? ( +
    + + + +
    + ) : ( + data?.description + )} +
    + + {Array.isArray(data?.tagList) && + data.tagList.map((tag) => )} + + + {isLoading ? ( + + ) : ( + timeDuration(data?.updatedDate) + )} + + + ); +} + +export default OrganizerCard; diff --git a/components/Group/detail/TeamInfoCard.jsx b/components/Group/detail/TeamInfoCard.jsx new file mode 100644 index 00000000..27c98b8f --- /dev/null +++ b/components/Group/detail/TeamInfoCard.jsx @@ -0,0 +1,87 @@ +import styled from '@emotion/styled'; +import Skeleton from '@mui/material/Skeleton'; +import bachelorCapSvg from '@/public/assets/icons/bachelorCap.svg'; +import categorySvg from '@/public/assets/icons/category.svg'; +import clockSvg from '@/public/assets/icons/clock.svg'; +import locationSvg from '@/public/assets/icons/location.svg'; +import personSvg from '@/public/assets/icons/person.svg'; + +const StyledItem = styled.div` + padding: 7px 0; + display: flex; + + @media (max-width: 480px) { + padding: 12px 0; + flex-direction: column; + } + + h3 { + display: flex; + padding-bottom: 5px; + align-items: center; + min-width: 140px; + font-size: 14px; + font-weight: 500; + color: #293a3d; + gap: 5px; + } + + p { + flex: 1; + font-size: 14px; + font-weight: 400; + color: #536166; + } + + & + & { + border-top: 1px solid #f3f3f3; + } + + &:first-of-type { + padding-top: 0; + } + + &:last-of-type { + padding-bottom: 0; + } +`; + +const format = (value) => + Array.isArray(value) ? value.filter(Boolean).join('、') : value; + +const labels = [ + { + key: 'category', + icon: categorySvg.src, + text: '學習領域', + }, + { + key: 'area', + icon: locationSvg.src, + text: '地點', + }, + { key: 'time', icon: clockSvg.src, text: '時間' }, + { key: 'partnerStyle', icon: personSvg.src, text: '想找的夥伴' }, + { + key: 'partnerEducationStep', + icon: bachelorCapSvg.src, + text: '適合的教育階段', + }, +]; + +function TeamInfoCard({ data = {}, isLoading }) { + return labels.map( + ({ key, icon, text }) => + data[key] && ( + +

    + {`${text} + {text} +

    +

    {isLoading ? : format(data[key])}

    +
    + ), + ); +} + +export default TeamInfoCard; diff --git a/components/Group/detail/index.jsx b/components/Group/detail/index.jsx new file mode 100644 index 00000000..3625e57d --- /dev/null +++ b/components/Group/detail/index.jsx @@ -0,0 +1,101 @@ +import { useRouter } from 'next/navigation'; +import { useSelector } from 'react-redux'; +import styled from '@emotion/styled'; +import Button from '@mui/material/Button'; +import Box from '@mui/material/Box'; +import Skeleton from '@mui/material/Skeleton'; +import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew'; +import Image from '@/shared/components/Image'; +import { StyledStatus } from '../GroupList/GroupCard.styled'; +import StyledPaper from '../Paper.styled'; +import TeamInfoCard from './TeamInfoCard'; +import OrganizerCard from './OrganizerCard'; +import More from './More'; +import { + StyledContainer, + StyledHeading, + StyledGoBack, + StyledDesktopEditButton, + StyledMobileEditButton, +} from './Detail.styled'; +import ContactButton from './Contact'; + +function GroupDetail({ id, source, isLoading }) { + const router = useRouter(); + const me = useSelector((state) => state.user); + const isMyGroup = source?.userId === me?._id; + + return ( + + + + + 返回 + + {isLoading ? ( + + ) : ( + {source?.photoAlt} + )} + + {isLoading ? ( + + ) : source?.isGrouping ? ( + 揪團中 + ) : ( + 已結束 + )} + {isMyGroup ? ( + router.push(`/group/edit?id=${id}`)} + > + 編輯 + + ) : ( + + )} + + {isLoading ? : source?.title} + + + + + + + + + + {isMyGroup ? ( + router.push(`/group/edit?id=${id}`)} + > + 編輯 + + ) : ( + + )} + + + + ); +} + +export default GroupDetail; diff --git a/components/Group/index.jsx b/components/Group/index.jsx new file mode 100644 index 00000000..bf8c4d6d --- /dev/null +++ b/components/Group/index.jsx @@ -0,0 +1,45 @@ +import styled from '@emotion/styled'; +import { Box } from '@mui/material'; +import AreaChips from './AreaChips'; +import Banner from './Banner'; +import SearchField from './SearchField'; +import SelectedCategory from './SelectedCategory'; +import StyledPaper from './Paper.styled'; +import GroupList from './GroupList'; +import More from './More'; + +const StyledContainer = styled.div` + position: relative; + margin: 70px auto 0; + width: 924px; + + @media (max-width: 1024px) { + width: 768px; + } + + @media (max-width: 800px) { + padding: 0 16px; + width: 100%; + } +`; + +function Group() { + return ( + + + + + + + + + + + + + + + ); +} + +export default Group; diff --git a/components/Home/Group/index.jsx b/components/Home/Group/index.jsx index 23a2f76e..c63099b4 100644 --- a/components/Home/Group/index.jsx +++ b/components/Home/Group/index.jsx @@ -95,12 +95,7 @@ const Group = () => { > -
    - - + + +

    尋找夥伴

    +

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

    +

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

    + {!token && ( + + )} +
    + + + 尋找夥伴 + +
    ); }; diff --git a/components/Partner/Group/index.jsx b/components/Partner/Group/index.jsx deleted file mode 100644 index daae7a54..00000000 --- a/components/Partner/Group/index.jsx +++ /dev/null @@ -1,137 +0,0 @@ -import React, { useState } from 'react'; -import styled from '@emotion/styled'; -import Box from '@mui/material/Box'; -import { Button, Typography } from '@mui/material'; -import { FacebookRounded } from '@mui/icons-material'; -import { useRouter } from 'next/router'; -import WramModal from '../../../shared/components/WarmModal'; - -const GroupWrapper = styled.div` - width: 90%; - /* height: calc(var(--section-height) + var(--section-height-offset)); */ - margin: 0 auto; - padding-top: 80px; - padding-bottom: 80px; - - @media (max-width: 767px) { - padding-top: 40px; - padding-bottom: 20px; - } -`; - -const Group = () => { - const router = useRouter(); - const [open, setOpen] = useState(false); - - return ( - - - 加入島島阿學學習社群 - - - - - - 我們是島島阿學學習社群,努力搭起互助學習的橋梁。 - - - - - 期盼以集體智慧,打造沒有天花板的學習環境,一個以自主學習為主的民主社群。 - - - - - 目前提供學習資源網以及社群的服務,包含各領域各種形式的資源、學習活動、學習經驗、教育新聞等等。 - - - - - 我們認為社群即資源、支援,讓學習者在民主教育的社群中,以共好的概念,解決彼此學習的問題,支持彼此成為自己想成為的人。 - - - - - 社群中有許多有愛的島友即時地分享各種學習資源唷!快加入吧! - - - - - - - - - - - group - - - ); -}; - -export default Group; diff --git a/components/Partner/Parnter.styled.jsx b/components/Partner/Parnter.styled.jsx new file mode 100644 index 00000000..5f50432d --- /dev/null +++ b/components/Partner/Parnter.styled.jsx @@ -0,0 +1,31 @@ +import styled from '@emotion/styled'; +import { Box } from '@mui/material'; + +export const StyledWrapper = styled.div` + position: relative; + margin: 70px auto 0; + padding-bottom: 14px; + width: 100%; + max-width: 1024px; + min-height: 100vh; + margin-top: -80px; + + @media (max-width: 900px) { + padding: 0 16px 44px; + margin-top: -50px; + } +`; +export const StyledContent = styled(Box)` + margin-top: 24px; + padding: 32px 40px; + background-color: #fff; + border-radius: 20px; + @media (max-width: 900px) { + padding: 0; + background-color: transparent; + } +`; + +export const StyledSearchWrapper = styled(Box)` + margin-top: 24px; +`; diff --git a/components/Partner/PartnerList/PartnerCard/PartnerCard.styled.jsx b/components/Partner/PartnerList/PartnerCard/PartnerCard.styled.jsx new file mode 100644 index 00000000..de5d5a61 --- /dev/null +++ b/components/Partner/PartnerList/PartnerCard/PartnerCard.styled.jsx @@ -0,0 +1,112 @@ +import styled from '@emotion/styled'; +import { LazyLoadImage } from 'react-lazy-load-image-component'; +import { Grid, Box, Typography } from '@mui/material'; + +export const StyledCard = styled(Box)` + display: flex; + padding: 12px; + background-color: #fff; + justify-content: space-between; + align-items: flex-start; + width: 100%; + border-radius: 20px; + cursor: pointer; + &:hover { + box-shadow: 0px 4px 10px 0px rgba(196, 194, 193, 0.4); + h2 { + color: #16b9b3; + } + } +`; + +export const StyledCardContainer = styled(Box)` + width: 100%; +`; + +export const StyledImage = styled(LazyLoadImage)` + display: block; + width: 50px; + height: 50px; + border-radius: 50%; + background: rgba(240, 240, 240, 0.8); + object-fit: cover; + object-position: center; +`; + +export const StyledCardTitle = styled.h2` + color: #293a3d; + font-weight: 500; + font-size: 16px; + margin-right: 5px; +`; + +export const StyledCardLabel = styled(Typography)` + color: var(--black-white-gray-dark, #293a3d); + text-align: center; + font-family: Noto Sans TC; + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 140%; + border-radius: 4px; + background: #f3f3f3; + padding: 3px 10px; +`; + +export const StyledCardSubtitle = styled(Typography)` + color: #92989a; + font-weight: 400; + font-size: 14px; +`; + +export const StyledTypoCaption = styled(Typography)` + color: #92989a; + font-family: 'Noto Sans TC'; + font-size: 12px; + font-style: normal; + line-height: 1.4; +`; + +export const StyledTagContainer = styled(Grid)` + display: flex; + align-items: center; +`; + +export const StyledTagText = styled(Grid)` + color: var(--black-white-gray, #536166); + text-align: center; + font-family: 'Noto Sans TC'; + font-size: 12px; + font-style: normal; + line-height: 1.4; + border-radius: 13px; + padding: 3px 10px; + display: flex; + justify-content: center; + background: #def5f5; +`; + +export const StyledTypoEllipsis = styled(Box)` + display: block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +`; + +export const FlexSBAlignCenter = styled(Box)` + display: flex; + justify-content: space-between; + align-items: center; +`; + +export const FlexAlignCenter = styled(Box)` + display: flex; + align-items: center; +`; + +export const FlexColCenterSB = styled(Box)` + display: flex; + flex-direction: column; + justify-content: center; + align-items: space-between; +`; diff --git a/components/Partner/PartnerList/PartnerCard/PartnerCardAvator.jsx b/components/Partner/PartnerList/PartnerCard/PartnerCardAvator.jsx new file mode 100644 index 00000000..6b599709 --- /dev/null +++ b/components/Partner/PartnerList/PartnerCard/PartnerCardAvator.jsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { Skeleton } from '@mui/material'; +import { StyledImage } from './PartnerCard.styled'; + +const PartnerCardAvator = ({ image }) => { + return image ? ( + + ) : ( + + ); +}; + +export default PartnerCardAvator; diff --git a/components/Partner/PartnerList/PartnerCard/PartnerCardDescription.jsx b/components/Partner/PartnerList/PartnerCard/PartnerCardDescription.jsx new file mode 100644 index 00000000..513689a2 --- /dev/null +++ b/components/Partner/PartnerList/PartnerCard/PartnerCardDescription.jsx @@ -0,0 +1,20 @@ +import { Typography } from '@mui/material'; +import { StyledTypoEllipsis } from './PartnerCard.styled'; + +const PartnerCardDescription = ({ title, content, ...rest }) => { + return ( + + + {title} + + + | + + + {content || '尚未填寫'} + + + ); +}; + +export default PartnerCardDescription; diff --git a/components/Partner/PartnerList/PartnerCard/PartnerCardTag.jsx b/components/Partner/PartnerList/PartnerCard/PartnerCardTag.jsx new file mode 100644 index 00000000..5a625a5e --- /dev/null +++ b/components/Partner/PartnerList/PartnerCard/PartnerCardTag.jsx @@ -0,0 +1,24 @@ +import { StyledTagContainer, StyledTagText } from './PartnerCard.styled'; + +const PartnerCardTag = ({ tagList = [] }) => { + const showItems = tagList.slice(0, 5); + const hideItems = tagList.slice(5); + return ( + tagList.length > 0 && ( + + {showItems + .filter((t) => typeof t === 'string' && t.trim() !== '') + .map((tag) => ( + + {tag} + + ))} + {hideItems.length > 0 && ( + {hideItems.length} + )} + + ) + ); +}; + +export default PartnerCardTag; diff --git a/components/Partner/PartnerList/PartnerCard/index.jsx b/components/Partner/PartnerList/PartnerCard/index.jsx index d94f5b56..a028a2a9 100644 --- a/components/Partner/PartnerList/PartnerCard/index.jsx +++ b/components/Partner/PartnerList/PartnerCard/index.jsx @@ -1,140 +1,95 @@ -import React, { useRef } from 'react'; -import styled from '@emotion/styled'; -import { Box, Typography, Divider, Skeleton } from '@mui/material'; -import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined'; -import { LazyLoadImage } from 'react-lazy-load-image-component'; +import { Box } from '@mui/material'; import { WANT_TO_DO_WITH_PARTNER, - CATEGORIES, -} from '../../../../constants/member'; -import { mapToTable } from '../../../../utils/helper'; + ROLE, + EDUCATION_STAGE, +} from '@/constants/member'; +import moment from 'moment'; +import { mapToTable } from '@/utils/helper'; +import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined'; +import PartnerCardAvator from './PartnerCardAvator'; +import PartnerCardDescription from './PartnerCardDescription'; +import PartnerCardTag from './PartnerCardTag'; + +import { + StyledCard, + StyledCardContainer, + StyledCardTitle, + StyledCardLabel, + StyledCardSubtitle, + StyledTypoCaption, + FlexSBAlignCenter, + FlexAlignCenter, + FlexColCenterSB, +} from './PartnerCard.styled'; const WANT_TO_DO_WITH_PARTNER_TABLE = mapToTable(WANT_TO_DO_WITH_PARTNER); -const CATEGORIES_TABLE = mapToTable(CATEGORIES); +const ROLELIST = mapToTable(ROLE); +const EDUCATION_STAGE_TABLE = mapToTable(EDUCATION_STAGE); + function PartnerCard({ - id, image, name, - subTitle, - canShare = [], - canTogether = [], + share, + tagList = [], + wantToDoList = [], + roleList = [], + location, + educationStage, + updatedDate, }) { - return ( - - - - - - } - /> + const wantTodo = wantToDoList + .map((item) => WANT_TO_DO_WITH_PARTNER_TABLE[item]) + .join('、'); - - - {name} - - - {subTitle} - - {/* - {' '} - 台北市松山區 - */} - - - {/* - {tagList.map((tag) => ( - - ))} - */} - - - - - 可分享 - - - {canShare - .map((item) => WANT_TO_DO_WITH_PARTNER_TABLE[item] || '') - .join(', ')} - - - - - 想一起 - - - {canTogether - .map((item) => CATEGORIES_TABLE[item] || '') - .join(', ')} - - + const role = roleList.length > 0 && ROLELIST[roleList[0]]; + const edu = educationStage && EDUCATION_STAGE_TABLE[educationStage]; + const locations = location && location.split('@'); + + return ( + + + + + + + {name} + {edu && {edu}} + + {role && {role}} + + + + + - - + + + + + {locations && ( + <> + + {location + ? location.length >= 2 + ? locations + .join('') + .replace('台灣', '') + .replace('null', '') + : locations.join('') + : '-'} + + )} + + + + {updatedDate + ? moment(updatedDate).fromNow() + : moment(new Date() - 500 * 60 * 60).fromNow()} + + + + ); } diff --git a/components/Partner/PartnerList/index.jsx b/components/Partner/PartnerList/index.jsx index 807fcefe..bf11de8f 100644 --- a/components/Partner/PartnerList/index.jsx +++ b/components/Partner/PartnerList/index.jsx @@ -1,86 +1,59 @@ -import React, { useRef } from 'react'; -import styled from '@emotion/styled'; -import { Box, Typography, Divider, Skeleton } from '@mui/material'; -import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined'; -import { LazyLoadImage } from 'react-lazy-load-image-component'; +import { Fragment } from 'react'; +import { useRouter } from 'next/router'; +import { useSelector } from 'react-redux'; +import useMediaQuery from '@mui/material/useMediaQuery'; +import { Grid, Box } from '@mui/material'; import PartnerCard from './PartnerCard'; -const LIST = [ - { - name: '許浪手', - image: - 'https://images.unsplash.com/photo-1502680390469-be75c86b636f?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxzZWFyY2h8Mnx8c3VyZnxlbnwwfHwwfHw%3D&auto=format&fit=crop&w=800&q=60', - subTitle: '實驗教育老師', - canShare: '心智圖法', - canTogether: '學習交流、教學相長', - tags: ['實驗教育'], - location: '台北市松山區', - }, - { - name: '許浪手2', - image: - 'https://images.unsplash.com/photo-1502680390469-be75c86b636f?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxzZWFyY2h8Mnx8c3VyZnxlbnwwfHwwfHw%3D&auto=format&fit=crop&w=800&q=60', - subTitle: '實驗教育老師', - canShare: '心智圖法', - canTogether: '學習交流、教學相長', - tags: ['實驗教育'], - location: '台北市松山區', - }, - { - name: '許浪手3', - image: - 'https://images.unsplash.com/photo-1502680390469-be75c86b636f?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxzZWFyY2h8Mnx8c3VyZnxlbnwwfHwwfHw%3D&auto=format&fit=crop&w=800&q=60', - subTitle: '實驗教育老師', - canShare: '心智圖法', - canTogether: '學習交流、教學相長', - tags: ['實驗教育'], - location: '台北市松山區', - }, - { - name: '許浪手4', - image: - 'https://images.unsplash.com/photo-1502680390469-be75c86b636f?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxzZWFyY2h8Mnx8c3VyZnxlbnwwfHwwfHw%3D&auto=format&fit=crop&w=800&q=60', - subTitle: '實驗教育老師', - canShare: '心智圖法', - canTogether: '學習交流、教學相長', - tags: ['實驗教育'], - location: '台北市松山區', - }, -]; +function PartnerList() { + const router = useRouter(); + + const partners = useSelector((state) => state.partners); + + const lists = partners.items || []; + const mobileScreen = useMediaQuery('(max-width: 900px)'); -function PartnerList({ list = [] }) { return ( - - - {list.map( - ({ - id, - userName, - photoURL, - subTitle, - wantToLearnList, - interestAreaList, - }) => ( + + {lists.map((item, idx) => ( + + router.push(`partner/detail?id=${item._id}`)} + item + width="100%" + md={6} + mb={mobileScreen && '12px'} + > - ), - )} - - + + {!mobileScreen && (idx + 1) % 2 === 0 && idx + 1 !== lists.length && ( + + + + )} + + ))} + ); } diff --git a/components/Partner/SearchField/AgeCheckbox/index.jsx b/components/Partner/SearchField/AgeCheckbox/index.jsx deleted file mode 100644 index 20fc06de..00000000 --- a/components/Partner/SearchField/AgeCheckbox/index.jsx +++ /dev/null @@ -1,91 +0,0 @@ -/* eslint-disable react/jsx-wrap-multilines */ -import React, { useState } from 'react'; -import styled from '@emotion/styled'; -import { Box, Select, MenuItem } from '@mui/material'; -import { useRouter } from 'next/router'; -// import { SEARCH_TAGS } from "../../../constants/category"; -import OutlinedInput from '@mui/material/OutlinedInput'; -import InputLabel from '@mui/material/InputLabel'; -import FormControl from '@mui/material/FormControl'; -import Chip from '@mui/material/Chip'; -import FormLabel from '@mui/material/FormLabel'; -import FormGroup from '@mui/material/FormGroup'; -import FormControlLabel from '@mui/material/FormControlLabel'; -import FormHelperText from '@mui/material/FormHelperText'; -import Checkbox from '@mui/material/Checkbox'; - -const ITEM_HEIGHT = 48; -const ITEM_PADDING_TOP = 8; -const MenuProps = { - PaperProps: { - style: { - maxHeight: ITEM_HEIGHT * 4.5 + ITEM_PADDING_TOP, - width: 250, - }, - }, -}; - -const names = ['學齡前', '國小', '國高中', '大學以上']; - -const AgeDropdown = () => { - const { query, push } = useRouter(); - const ages = query?.ages ? (query?.ages).split(',') : []; - const handleChange = (event) => { - const newAges = query?.ages ? (query?.ages).split(',') : []; - const { name } = event.target; - const { checked } = event.target; - if (checked) { - newAges.push(name); - } else { - const index = newAges.indexOf(name); - newAges.splice(index, 1); - } - if (newAges.length === 0) { - delete query.ages; - push({ - pathname: '/search', - query, - }); - } else { - push({ - pathname: '/search', - query: { - ...query, - ages: newAges.join(','), - }, - }); - } - }; - return ( - - 年齡層 - - {names.map((name) => ( - - } - /> - ))} - - - ); -}; - -export default AgeDropdown; diff --git a/components/Partner/SearchField/AgeDropdown/index.jsx b/components/Partner/SearchField/AgeDropdown/index.jsx deleted file mode 100644 index 93531bbe..00000000 --- a/components/Partner/SearchField/AgeDropdown/index.jsx +++ /dev/null @@ -1,81 +0,0 @@ -import React, { useState } from 'react'; -import styled from '@emotion/styled'; -import { Box, Select, MenuItem } from '@mui/material'; -import { useRouter } from 'next/router'; -// import { SEARCH_TAGS } from "../../../constants/category"; -import OutlinedInput from '@mui/material/OutlinedInput'; -import InputLabel from '@mui/material/InputLabel'; -import FormControl from '@mui/material/FormControl'; -import Chip from '@mui/material/Chip'; - -const ITEM_HEIGHT = 48; -const ITEM_PADDING_TOP = 8; -const MenuProps = { - PaperProps: { - style: { - maxHeight: ITEM_HEIGHT * 4.5 + ITEM_PADDING_TOP, - width: 250, - }, - }, -}; - -const names = ['學齡前', '國小', '國高中', '大學以上']; - -const AgeDropdown = () => { - const { query, push } = useRouter(); - const ages = query?.ages ? (query?.ages).split(',') : []; - const handleChange = (event) => { - const { - target: { value }, - } = event; - - if (value.length > 0) { - push({ - pathname: '/search', - query: { - ...query, - ages: value.join(','), - }, - }); - } else { - delete query.ages; - push({ - pathname: '/search', - query, - }); - } - }; - return ( - - 年齡層 - - - ); -}; - -export default AgeDropdown; diff --git a/components/Partner/SearchField/FeeDropdown/index.jsx b/components/Partner/SearchField/FeeDropdown/index.jsx deleted file mode 100644 index 19452fae..00000000 --- a/components/Partner/SearchField/FeeDropdown/index.jsx +++ /dev/null @@ -1,99 +0,0 @@ -import React, { useState } from 'react'; -import styled from '@emotion/styled'; -import { Box, Select, MenuItem } from '@mui/material'; -import { useRouter } from 'next/router'; -// import { SEARCH_TAGS } from "../../../constants/category"; -import OutlinedInput from '@mui/material/OutlinedInput'; -import InputLabel from '@mui/material/InputLabel'; -import FormControl from '@mui/material/FormControl'; -import Chip from '@mui/material/Chip'; -import Radio from '@mui/material/Radio'; -import RadioGroup from '@mui/material/RadioGroup'; -import FormControlLabel from '@mui/material/FormControlLabel'; -import FormLabel from '@mui/material/FormLabel'; - -const ITEM_HEIGHT = 48; -const ITEM_PADDING_TOP = 8; -const MenuProps = { - PaperProps: { - style: { - maxHeight: ITEM_HEIGHT * 4.5 + ITEM_PADDING_TOP, - width: 250, - }, - }, -}; - -const names = ['不拘', '免費', '部分免費', '需付費']; - -const FeeDropdown = () => { - const { query, push } = useRouter(); - const fee = query?.fee ? (query?.fee).split(',') : []; - const handleChange = (event) => { - const { - target: { value }, - } = event; - - if (value === names[0]) { - delete query.fee; - push({ - pathname: '/search', - query, - }); - } else { - push({ - pathname: '/search', - query: { - ...query, - fee: value, - }, - }); - } - }; - return ( - - 費用 - - {names.map((name) => ( - } - /> - ))} - - {/* */} - - ); -}; - -export default FeeDropdown; diff --git a/components/Partner/SearchField/HotTags/index.jsx b/components/Partner/SearchField/HotTags/index.jsx deleted file mode 100644 index 6de3607e..00000000 --- a/components/Partner/SearchField/HotTags/index.jsx +++ /dev/null @@ -1,50 +0,0 @@ -import React from 'react'; -import styled from '@emotion/styled'; -import { Whatshot } from '@mui/icons-material'; -import { Box } from '@mui/material'; -import { SEARCH_TAGS } from '../../../../constants/category'; -import Item from './item'; -// import { TikTokFont } from "../../../../shared/styles/css"; - -const TagsWrapper = styled.ul` - display: flex; - justify-content: flex-start; - align-items: center; - margin: auto 5px; - white-space: nowrap; - max-width: calc(100vw - 49px); - overflow-x: scroll; - -ms-overflow-style: none; /* IE */ - scrollbar-width: none; /* Firefox */ - &::-webkit-scrollbar { - display: none; /* Chrome, Safari, Edge and Opera */ - } -`; - -const Tags = ({ queryList }) => { - const lastSelectedCat = queryList.length > 0 && queryList[0]; - const hotTags = - Array.isArray(queryList) && queryList.length > 0 && lastSelectedCat - ? SEARCH_TAGS[lastSelectedCat] - : SEARCH_TAGS['全部']; - return ( - - - - {hotTags.map((value) => ( - - ))} - - - ); -}; - -export default Tags; diff --git a/components/Partner/SearchField/HotTags/item.jsx b/components/Partner/SearchField/HotTags/item.jsx deleted file mode 100644 index 6804b277..00000000 --- a/components/Partner/SearchField/HotTags/item.jsx +++ /dev/null @@ -1,72 +0,0 @@ -import React, { useCallback, useMemo } from 'react'; -import { useRouter } from 'next/router'; -import { Chip } from '@mui/material'; -import { COLOR_TABLE } from '../../../../constants/notion'; -import stringSanitizer from '../../../../utils/sanitizer'; - -// const TagWrapper = styled(Chip)` -// margin: auto 5px; -// font-weight: 700; -// white-space: nowrap; -// a { -// color: #37b9eb; -// font-weight: bold; -// font-size: 16px; -// } - -// a:hover { -// text-decoration: underline; -// } - -// @media (max-width: 767px) { -// left: 70px; -// width: 85vw; -// overflow-x: visible; -// a { -// color: #007bbb; -// font-size: 14px; -// } -// } -// `; -const Tag = ({ title }) => { - const { push, query } = useRouter(); - const queryTags = useMemo( - () => - typeof query.tags === 'string' - ? stringSanitizer(query.tags).split(',') - : [], - [query.tags], - ); - const linkHandler = useCallback( - (targetQuery) => { - push({ - pathname: '/search', - query: { - ...query, - tags: [...new Set([...queryTags, targetQuery])].join(','), - }, - }); - }, - [push, query, queryTags], - ); - return ( - linkHandler(title)} - sx={{ - backgroundColor: COLOR_TABLE.pink, - cursor: 'pointer', - margin: '5px', - whiteSpace: 'nowrap', - fontWeight: 500, - fontSize: '14px', - '&:hover': { - opacity: '60%', - transition: 'transform 0.4s', - }, - }} - /> - ); -}; - -export default Tag; diff --git a/components/Partner/SearchField/SearchInput.jsx b/components/Partner/SearchField/SearchInput.jsx new file mode 100644 index 00000000..ba485a31 --- /dev/null +++ b/components/Partner/SearchField/SearchInput.jsx @@ -0,0 +1,98 @@ +import { useState, useEffect } from 'react'; +import dynamic from 'next/dynamic'; +import styled from '@emotion/styled'; +import InputBase from '@mui/material/InputBase'; +import Paper from '@mui/material/Paper'; +import IconButton from '@mui/material/IconButton'; +import MicIcon from '@mui/icons-material/Mic'; +import SearchIcon from '@mui/icons-material/Search'; +import useSearchParamsManager from '@/hooks/useSearchParamsManager'; + +const Speech = dynamic(import('@/shared/components/Speech'), { + ssr: false, +}); + +const SearchInputWrapper = styled(Paper)` + width: 100%; + position: relative; + display: flex; + align-items: center; + border: 1px solid #dbdbdb; + border-radius: 30px; + padding-right: 4px; + box-shadow: none; + overflow: hidden; + + @media (max-width: 767px) { + border-radius: 20px; + width: 100%; + } +`; + +const IconButtonWrapper = styled(IconButton)` + color: #536166; + border-radius: 40px; + height: 40px; + width: 40px; +`; + +const InputBaseWrapper = styled(InputBase)(() => ({ + flex: 1, + '& .MuiInputBase-input': { + paddingTop: '14px', + paddingLeft: '20px', + paddingBottom: '14px', + background: 'white', + zIndex: 10, + borderRadius: '20px', + width: '100%', + fontSize: 14, + }, +})); + +const SearchInput = () => { + const [getSearchParams, pushState] = useSearchParamsManager(); + const [keyword, setKeyword] = useState(''); + const [isSpeechMode, setIsSpeechMode] = useState(false); + const currentKeyword = getSearchParams('q').toString(); + + useEffect(() => { + setKeyword(currentKeyword); + }, [currentKeyword]); + + const handleChange = ({ target }) => { + setKeyword(target.value); + }; + + const handleSubmit = (event) => { + event.preventDefault(); + pushState('q', keyword); + }; + + return ( + + + {isSpeechMode && ( + + )} + setIsSpeechMode(true)} + > + + + + + + + ); +}; + +export default SearchInput; diff --git a/components/Partner/SearchField/SearchInput/Button/index.jsx b/components/Partner/SearchField/SearchInput/Button/index.jsx deleted file mode 100644 index a9e3cb0b..00000000 --- a/components/Partner/SearchField/SearchInput/Button/index.jsx +++ /dev/null @@ -1,39 +0,0 @@ -import React from 'react'; -import { IconButton } from '@mui/material'; -import SearchIcon from '@mui/icons-material/Search'; -import styled from '@emotion/styled'; - -const SearchButtonWrapper = styled(IconButton)` - overflow: hidden; - color: #16b9b3; - width: 40px; - height: 100%; - right: 0; - border-radius: 0; - padding: 10px; - - &:hover { - background-color: white; - /* opacity: 0.8; - transition: opacity 0.5s; */ - } - @media (max-width: 767px) { - width: 40px; - padding: 0px; - /* border-radius: 20px; */ - } -`; - -const SearchButton = ({ routingPush }) => ( - { - routingPush(); - // addSearchHistory(); - }} - aria-label="search" - > - - -); - -export default SearchButton; diff --git a/components/Partner/SearchField/SearchInput/SuggestList/index.jsx b/components/Partner/SearchField/SearchInput/SuggestList/index.jsx deleted file mode 100644 index 738f997a..00000000 --- a/components/Partner/SearchField/SearchInput/SuggestList/index.jsx +++ /dev/null @@ -1,99 +0,0 @@ -import React from 'react'; -import styled from '@emotion/styled'; -import { css } from '@emotion/react'; -import Link from 'next/link'; - -const SuggestWrapper = styled.div` - width: 100%; - top: 20px; - left: 0px; - background-color: white; - position: absolute; - display: flex; - flex-direction: column; - justify-content: flex-start; - border-bottom-left-radius: 10px; - border-bottom-right-radius: 10px; - border: 2px #37b9eb solid; - overflow: hidden; - border-top: 0; - /* box-shadow: 0 4px 6px rgb(32 33 36 / 28%); */ - ${({ isFocus, isEmpty }) => - isFocus && - !isEmpty && - css` - border: 0px; - `} - - a { - display: block; - padding: 6px 12px; - color: black; - - &:hover { - background-color: #eeeeee; - } - - &:first-of-type { - margin-top: 15px; - } - - &:last-of-type { - border-bottom-left-radius: 10px; - border-bottom-right-radius: 10px; - } - } -`; - -const SuggestList = ({ - isFocus, - keyword, - suggestKeywords, - addSearchHistory, - referenceSelected, -}) => { - const isServerSide = !process.browser; - if (isServerSide) return <>; - const historyKeywords = - JSON.parse(window?.localStorage.getItem('historyKeywords') || null) || []; - - if (!isFocus) return <>; - - if (keyword.length === 0 && historyKeywords.length > 0) { - return ( - - {historyKeywords.map(({ keyword: suggest, id }, idx) => ( - - {suggest} - - ))} - - ); - } - - return ( - - {keyword.length > 0 && - Array.isArray(suggestKeywords) && - suggestKeywords.map((suggest, idx) => ( - addSearchHistory(suggest)} - style={{ - background: referenceSelected === idx ? '#eee' : null, - wordBreak: 'break-all', - }} - > - {suggest} - - ))} - - ); -}; - -export default SuggestList; diff --git a/components/Partner/SearchField/SearchInput/index.jsx b/components/Partner/SearchField/SearchInput/index.jsx deleted file mode 100644 index d23aaaf4..00000000 --- a/components/Partner/SearchField/SearchInput/index.jsx +++ /dev/null @@ -1,178 +0,0 @@ -import React, { useState, useEffect, useCallback, useMemo } from 'react'; -// import ClickAwayListener from "@mui/base/ClickAwayListener"; -import InputBase from '@mui/material/InputBase'; -import Paper from '@mui/material/Paper'; -import styled from '@emotion/styled'; -// import { Search } from "@mui/icons-material"; -import { useRouter } from 'next/router'; -// import i18n from "../../../../../constants/i18n"; -// import SuggestList from "./SuggestList"; -import { IconButton, Box } from '@mui/material'; -import MicIcon from '@mui/icons-material/Mic'; -import dynamic from 'next/dynamic'; -import SearchButton from './Button'; - -const Speech = dynamic(import('../../../../shared/components/Speech'), { - ssr: false, -}); - -const SearchToolsWrapper = styled(Box)` - position: relative; - height: 40px; - margin-left: auto; - margin-right: 5px; - display: flex; -`; - -const SearchButtonWrapper = styled(IconButton)` - /* position: absolute; */ - overflow: hidden; - color: white; - border-radius: 10px; - float: right; - height: 100%; - width: 40px; - right: 0; - &:hover { - /* background-color: #007bbb; */ - } -`; -const FormWrapper = styled.form` - width: 100%; -`; - -const SearchInputWrapper = styled(Paper)` - height: 40px; - width: 100%; - position: relative; - border-radius: 10px; - // 可以試著淡化border - border: 2px solid #16b9b3; - box-shadow: none; - overflow: hidden; - - @media (max-width: 767px) { - border-radius: 20px; - width: 100%; - } -`; - -const PLACEHOLDER_TEXT = [ - '英語, 心理學, 自主學習 ...', - '好想出國喔~該來學英語了', - '我的腦袋不太好,但是知道邏輯要訓練', - '不會寫程式,也要了解科技趨勢', - '斜槓與文青的時間到了', - '誰說健身不是學習的一種?', - '生活在學習', -]; - -const InputBaseWrapper = styled(InputBase)` - background: white; - z-index: 10; - border-bottom-right-radius: 20px; - border-top-right-radius: 20px; - margin-left: 10px; - width: 100%; - - @media (max-width: 767px) { - border-radius: 20px; - } -`; - -const SearchInput = () => { - const { query, push } = useRouter(); - // const isServerSide = useMemo(() => !process.browser, []); - const [keyword, setKeyword] = useState(query?.q); - const [isSpeechMode, setIsSpeechMode] = useState(false); - // const [referenceSelected, setReferenceSelected] = useState(null); - - useEffect(() => { - setKeyword(query?.q ?? ''); - }, [query?.q]); - - const routingPush = useCallback( - (words) => { - if (words !== '') { - push({ - query: { - ...query, - q: words, - }, - }); - } else { - delete query.q; - push({ - query, - }); - } - }, - [push, query], - ); - - const placeholder = useMemo( - () => PLACEHOLDER_TEXT[Math.floor(Math.random() * 7)], - [], - ); - - return ( - - { - e.preventDefault(); - if (keyword !== '') { - push({ - query: { - ...query, - q: keyword, - }, - }); - } else if (keyword.length === 0) { - delete query.q; - push({ query }); - } - }} - > - { - // setReferenceSelected(null); - setKeyword(event.target.value); - }} - // components={<>} - /> - - {isSpeechMode && ( - - )} - - setIsSpeechMode(true)} - > - - - {}} /> - - - ); -}; - -export default SearchInput; diff --git a/components/Partner/SearchField/SearchTags.jsx b/components/Partner/SearchField/SearchTags.jsx new file mode 100644 index 00000000..7368e417 --- /dev/null +++ b/components/Partner/SearchField/SearchTags.jsx @@ -0,0 +1,84 @@ +import React, { useState, useEffect } from 'react'; +import styled from '@emotion/styled'; +import { SEARCH_TAGS } from '@/constants/category'; +import useSearchParamsManager from '@/hooks/useSearchParamsManager'; + +const StyledContainer = styled.div` + margin-top: 12px; + display: flex; + align-items: center; + width: 100%; + @media (max-width: 767px) { + margin-left: 10px 0; + flex-direction: column; + align-items: flex-start; + } + > p { + color: #536166; + font-size: 14px; + font-style: normal; + font-weight: 700; + line-height: 140%; + white-space: nowrap; + @media (max-width: 767px) { + margin-bottom: 8px; + } + } + ul { + display: flex; + flex-wrap: nowrap; + overflow-x: auto; + + -ms-overflow-style: none; /* IE */ + scrollbar-width: none; /* Firefox */ + scroll-behavior: smooth; + + margin-left: 24px; + + &::-webkit-scrollbar { + display: none; /* Chrome, Safari, Edge and Opera */ + } + @media (max-width: 767px) { + margin-left: 0; + } + } + ul > li { + color: #16b9b3; + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 140%; + margin-right: 16px; + flex: 0 0 auto; + cursor: pointer; + } +`; + +const SearchTags = ({ searchTags = [] }) => { + const [getSearchParams, pushState] = useSearchParamsManager(); + const [_, setTag] = useState(); + const currentTags = getSearchParams('tag').toString(); + + const handleChange = (val) => { + pushState('tag', val.toString()); + }; + + useEffect(() => { + setTag(currentTags); + }, [currentTags]); + + return ( + +

    熱門標籤

    +
      + {searchTags.map((t) => ( +
    • handleChange(t)}> + {t} +
    • + ))} +
    +
    + ); +}; + +export default SearchTags; diff --git a/components/Partner/SearchField/SelectedAreas.jsx b/components/Partner/SearchField/SelectedAreas.jsx new file mode 100644 index 00000000..47ea9062 --- /dev/null +++ b/components/Partner/SearchField/SelectedAreas.jsx @@ -0,0 +1,34 @@ +import Select from '@/shared/components/Select'; +import { TAIWAN_DISTRICT } from '@/constants/areas'; +import useSearchParamsManager from '@/hooks/useSearchParamsManager'; + +export default function SelectedAreas() { + const QUERY_KEY = 'area'; + const [getSearchParams, pushState] = useSearchParamsManager(); + + const handleChange = ({ target: { value } }) => { + pushState(QUERY_KEY, value.toString()); + }; + + const AREAS = TAIWAN_DISTRICT.map(({ name }) => ({ + name, + label: name, + })).concat([{ name: '國外', label: '國外' }]); + + return ( + + selected.length === 0 ? '教育階段' : selected.join('、') + } + sx={{ + '@media (max-width: 767px)': { + width: '100%', + }, + }} + /> + ); +} diff --git a/components/Partner/SearchField/SelectedFriendType.jsx b/components/Partner/SearchField/SelectedFriendType.jsx new file mode 100644 index 00000000..1d012942 --- /dev/null +++ b/components/Partner/SearchField/SelectedFriendType.jsx @@ -0,0 +1,35 @@ +import Select from '@/shared/components/Select'; +import { ROLE } from '@/constants/member'; +import useSearchParamsManager from '@/hooks/useSearchParamsManager'; + +const ROLE_TYPE = ROLE.map(({ label, key }) => ({ label, key })); + +const SelectedFriendType = () => { + const QUERY_KEY = 'role'; + const [getSearchParams, pushState] = useSearchParamsManager(); + + const handleChange = ({ target: { value } }) => { + pushState(QUERY_KEY, value.toString()); + }; + + return ( + { + onChangeHandler({ + key: 'educationStage', + value: event.target.value, + }); }} + sx={{ width: '100%' }} > - 教育階段 - -
    - + + + 居住地 + { - setLocation(event.target.value); - }} - // placeholder="請選擇您或孩子目前的教育階段" - sx={{ width: '100%' }} - > - - 請選擇居住地 + + 請選擇居住地 + + {COUNTRIES.map(({ name, label }) => ( + + {label} - {COUNTIES.map(({ name, alpha2 }) => ( - - {name} - - ))} - - {/* { - setLocation(event.target.value); - }} - /> */} - - + {(userState.country === '台灣' || userState.country === 'tw') && ( + + + + + + + + + )} + + + + + + + 聯絡方式 + + - 想和夥伴一起 - - - {WANT_TO_DO_WITH_PARTNER.slice(0, 3).map( - ({ label, value }) => ( - { - if (wantToLearnList.includes(value)) { - setWantToLearnList((state) => - state.filter((data) => data !== value), - ); - } else { - setWantToLearnList((state) => [...state, value]); - } - }} - sx={{ - border: '1px solid #DBDBDB', - borderRadius: '8px', - padding: '10px', - width: 'calc(calc(100% - 16px) / 3)', - display: 'flex', - justifyItems: 'center', - alignItems: 'center', - cursor: 'pointer', - ...(wantToLearnList.includes(value) - ? { - backgroundColor: '#DEF5F5', - border: '1px solid #16B9B3', - } - : {}), - }} - > - - {label} - - - ), - )} - - + + + {Object.entries({ + instagram: 'Instagram', + discord: 'Discord', + line: 'Line', + facebook: 'Facebook', + }).map(([key, title]) => ( + + + + ))} + + + + + + 想和夥伴一起 + + {WANT_TO_DO_WITH_PARTNER.map(({ label, value }) => ( + { + onChangeHandler({ + key: 'wantToDoList', + value, + isMultiple: true, + }); }} > - {WANT_TO_DO_WITH_PARTNER.slice(3).map( - ({ label, value }) => ( - { - if (wantToLearnList.includes(value)) { - setWantToLearnList((state) => - state.filter((data) => data !== value), - ); - } else { - setWantToLearnList((state) => [...state, value]); - } - }} - sx={{ - border: '1px solid #DBDBDB', - borderRadius: '8px', - padding: '10px', - width: 'calc(calc(100% - 16px) / 3)', - display: 'flex', - justifyItems: 'center', - alignItems: 'center', - cursor: 'pointer', - ...(wantToLearnList.includes(value) - ? { - backgroundColor: '#DEF5F5', - border: '1px solid #16B9B3', - } - : {}), - }} - > - - {label} - - - ), - )} - - - - - 可以和夥伴分享的事物 - - - {/* - 標籤 - - - 可以是學習領域、興趣等等的標籤,例如:音樂創作、程式語言、電繪、社會議題。 - - */} - - 個人網站或社群 - { - setUrl(event.target.value); - }} - /> - - + {label} + + + ))} + + + + + 可以和夥伴分享的事物 + + { + onChangeHandler({ key: 'share', value: e.target.value }); }} - > - 個人簡介 - { - setDescription(event.target.value); - }} - /> - - - - + + + 標籤 + { + onChangeHandler({ key: 'tagList', value, isMultiple: true }); }} - > - - 公開顯示居住地 - - { - setIsOpenLocation(value); - }} - /> - - + - - 公開個人頁面尋找夥伴 - - { - setIsOpenProfile(value); - }} - /> - - - - - - - - + /> + + + + + { + router.push('/profile/myprofile'); + }} + > + 查看我的頁面 + + + 儲存資料 + + + - + ); } diff --git a/components/Profile/Edit/useEditProfile.jsx b/components/Profile/Edit/useEditProfile.jsx new file mode 100644 index 00000000..897c8945 --- /dev/null +++ b/components/Profile/Edit/useEditProfile.jsx @@ -0,0 +1,195 @@ +import dayjs from 'dayjs'; +import { useReducer, useState } from 'react'; +import { useDispatch } from 'react-redux'; +import { updateUser } from '@/redux/actions/user'; +import { z } from 'zod'; + +const initialState = { + name: '', + photoURL: '', + birthDay: dayjs(), + gender: '', + roleList: [], + wantToDoList: [], + instagram: '', + facebook: '', + discord: '', + line: '', + educationStage: '-1', + location: '台灣', + tagList: [], + selfIntroduction: '', + share: '', + isOpenLocation: false, + isOpenProfile: false, + isLoadingSubmit: false, + country: '', + city: '', + district: '', +}; + +const buildValidator = (maxLength, regex, maxMsg, regMsg) => + z.string().max(maxLength, maxMsg).regex(regex, regMsg).optional(); + +const tempSchema = Object.keys(initialState).reduce((acc, key) => { + return key !== 'birthDay' + ? { + ...acc, + [key]: z.string().optional(), + } + : acc; +}, {}); + +const schema = z.object({ + ...tempSchema, + name: z + .string() + .min(1, { message: '請輸入名字' }) + .max(50, { message: '名字過長' }) + .optional(), + isOpenLocation: z.boolean().optional(), + isOpenProfile: z.boolean().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_.]{6,20})$/, + '長度最多20個字元', + '長度最少6個字元,支援英文、數字、底線、句號', + ), +}); + +const userReducer = (state, payload) => { + const { key, value, isMultiple = false } = payload; + if (isMultiple) { + return { + ...state, + [key]: state[key].includes(value) + ? state[key].filter((role) => role !== value) + : [...state[key], value], + }; + } else if (state && state[key] !== undefined) { + return { + ...state, + [key]: value, + }; + } + return state; +}; + +const useEditProfile = () => { + const reduxDispatch = useDispatch(); + const [userState, stateDispatch] = useReducer(userReducer, initialState); + const [errors, setErrors] = useState({}); + + const validate = (state = {}, isPartial = false) => { + const [key, value] = Object.entries(state)[0]; + if (key !== 'birthDay') { + const result = isPartial + ? schema.partial({ [key]: true }).safeParse({ [key]: value }) + : schema.safeParse({ [key]: value }); + + if (!result.success) { + result.error.errors.forEach((err) => { + setErrors({ [err.path[0]]: err.message }); + }); + } + if (isPartial && result.success) { + const obj = { ...errors }; + delete obj[key]; + setErrors(obj); + } + + return result.success; + } + return true; + }; + + const onChangeHandler = ({ key, value, isMultiple }) => { + stateDispatch({ key, value, isMultiple }); + validate({ [key]: value }, true); + }; + + const onSubmit = async ({ id, email }) => { + if (!id || !email) return; + 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, + gender, + roleList, + contactList: { + instagram, + facebook, + discord, + line, + }, + wantToDoList, + educationStage, + location: + country === '國外' ? country : [country, city, district].join('@'), + tagList, + selfIntroduction, + share, + isOpenLocation, + isOpenProfile, + }; + + reduxDispatch(updateUser(payload)); + }; + + const checkBeforeSubmit = ({ id, email }) => { + if (validate(userState)) { + onSubmit({ id, email }); + return true; + } + return false; + }; + + return { + userState, + onChangeHandler, + onSubmit: checkBeforeSubmit, + errors, + }; +}; + +export default useEditProfile; diff --git a/components/Profile/InputTags/index.jsx b/components/Profile/InputTags/index.jsx new file mode 100644 index 00000000..cb2b247b --- /dev/null +++ b/components/Profile/InputTags/index.jsx @@ -0,0 +1,84 @@ +import styled from '@emotion/styled'; +import { TextField, Box, Typography, Icon } from '@mui/material'; +import CloseOutlinedIcon from '@mui/icons-material/CloseOutlined'; + +const Tag = ({ label, onCancel }) => { + return ( + + + {label} + + + + ); +}; + +const StyledTagsField = styled(TextField)` + input { + padding-left: ${({ hasData }) => (hasData ? '0' : '16px')}; + } +`; + +function InputTags({ value = [], change }) { + const keyDownHandle = (e) => { + if (e.keyCode === 13) { + if (!value.includes(e.target.value)) { + change(e.target.value); + e.target.value = ''; + } + } + }; + return ( + 0} + fullWidth="true" + placeholder="搜尋或新增標籤" + onKeyDown={keyDownHandle} + className="input-tags" + InputProps={ + value.length && { + startAdornment: ( + + {Array.isArray(value) && + value.map( + (item) => + typeof item === 'string' && ( + change(item)} + /> + ), + )} + + ), + } + } + /> + ); +} + +export default InputTags; diff --git a/components/Profile/MyGroup/GroupCard.jsx b/components/Profile/MyGroup/GroupCard.jsx new file mode 100644 index 00000000..f5dba38b --- /dev/null +++ b/components/Profile/MyGroup/GroupCard.jsx @@ -0,0 +1,140 @@ +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 emptyCoverImg from '@/public/assets/empty-cover.png'; +import useMutation from '@/hooks/useMutation'; +import { timeDuration } from '@/utils/date'; +import { + StyledAreas, + StyledContainer, + StyledFooter, + StyledGroupCard, + StyledText, + StyledTitle, + StyledTime, + StyledFlex, + StyledStatus, + StyledMenuItem, + StyledImageWrapper, +} from './GroupCard.styled'; + +function GroupCard({ + _id, + photoURL, + photoAlt, + title = '未定義主題', + description, + area, + isGrouping, + userId, + updatedDate, + onUpdateGrouping, + onDeleteGroup, +}) { + const me = useSelector((state) => state.user); + const router = useRouter(); + const [anchorEl, setAnchorEl] = useState(null); + const isEnabledMutation = me._id === userId; + + const apiUpdateGrouping = useMutation(`/activity/${_id}`, { + method: 'PUT', + enabled: isEnabledMutation, + onSuccess: onUpdateGrouping, + }); + + const apiDeleteGroup = useMutation(`/activity/${_id}`, { + method: 'DELETE', + enabled: isEnabledMutation, + onSuccess: onDeleteGroup, + }); + + const handleMenu = (event) => { + event.preventDefault(); + setAnchorEl(event.currentTarget); + }; + + const handleClose = () => { + setAnchorEl(null); + }; + + const handleGrouping = () => { + handleClose(); + apiUpdateGrouping.mutate({ isGrouping: !isGrouping }); + }; + + const handleDeleteGroup = () => { + handleClose(); + apiDeleteGroup.mutate(); + }; + + const formatToString = (data, defaultValue = '') => + Array.isArray(data) && data.length ? data.join('、') : data || defaultValue; + + return ( + <> + + + {photoAlt + + + {title} + + {description} + + + + {formatToString(area, '待討論')} + + + {timeDuration(updatedDate)} + + {isGrouping ? ( + 揪團中 + ) : ( + 已結束 + )} + {isEnabledMutation && ( + + + + )} + + + + + + + router.push(`/group/edit?id=${_id}`)}> + 編輯 + + + {isGrouping ? '結束揪團' : '開放揪團'} + + 刪除 + + + ); +} + +export default GroupCard; diff --git a/components/Profile/MyGroup/GroupCard.styled.jsx b/components/Profile/MyGroup/GroupCard.styled.jsx new file mode 100644 index 00000000..bad7abc0 --- /dev/null +++ b/components/Profile/MyGroup/GroupCard.styled.jsx @@ -0,0 +1,121 @@ +import Link from 'next/link'; +import styled from '@emotion/styled'; +import Divider from '@mui/material/Divider'; +import MenuItem from '@mui/material/MenuItem'; + +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 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 StyledFooter = styled.footer` + display: flex; + justify-content: space-between; + align-items: center; +`; + +export const StyledTime = styled.time` + font-size: 12px; + font-weight: 300; + color: #92989a; +`; + +export const StyledFlex = styled.div` + display: flex; + align-items: center; + gap: 8px; +`; + +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; + + &::before { + content: ''; + display: block; + width: 8px; + height: 8px; + background: var(--color); + border-radius: 50%; + } + + &.finished { + --bg-color: #f3f3f3; + --color: #92989a; + } +`; + +export const StyledContainer = styled.div` + display: flex; + flex-direction: column; + justify-content: space-around; + flex: 1; + padding: 0 10px; +`; + +export const StyledAreas = styled.div` + padding: 4px 0; + display: flex; + align-items: center; +`; + +export const StyledGroupCard = styled(Link)` + 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 StyledMenuItem = styled(MenuItem)` + min-width: 146px; +`; + +export const StyledDivider = styled(Divider)` + width: 100%; + color: #000; + margin: 30px 0; + height: 2px; +`; diff --git a/components/Profile/MyGroup/LoadingCard.jsx b/components/Profile/MyGroup/LoadingCard.jsx new file mode 100644 index 00000000..51018493 --- /dev/null +++ b/components/Profile/MyGroup/LoadingCard.jsx @@ -0,0 +1,63 @@ +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 { + StyledAreas, + StyledContainer, + StyledFooter, + StyledGroupCard, + StyledText, + StyledTitle, + StyledTime, + StyledFlex, + StyledImageWrapper, +} from './GroupCard.styled'; + +function LoadingCard() { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export default LoadingCard; diff --git a/components/Profile/MyGroup/index.jsx b/components/Profile/MyGroup/index.jsx new file mode 100644 index 00000000..c0aaedb5 --- /dev/null +++ b/components/Profile/MyGroup/index.jsx @@ -0,0 +1,110 @@ +import { Fragment, useState } from 'react'; +import styled from '@emotion/styled'; +import { Box, Typography } from '@mui/material'; +import useFetch from '@/hooks/useFetch'; +import GroupCard from './GroupCard'; +import LoadingCard from './LoadingCard'; +import { StyledDivider } from './GroupCard.styled'; + +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} +`; + +const MyGroup = ({ title, sx, userId }) => { + const [response, setResponse] = useState(null); + const { isFetching } = useFetch(`/activity/user/${userId}`, { + enabled: !!userId, + onSuccess: setResponse, + }); + + const getTargetIndexById = (data, id) => { + if (!Array.isArray(data)) return -1; + const targetIndex = data.findIndex((item) => item?._id === id); + if (!(targetIndex > -1)) return -1; + return targetIndex; + }; + + const handleUpdateGrouping = (id) => { + setResponse((pre) => { + const targetIndex = getTargetIndexById(pre.data, id); + if (!(targetIndex > -1)) return pre; + const target = pre.data[targetIndex]; + const updatedTarget = { ...target, isGrouping: !target.isGrouping }; + + return { + ...pre, + data: [ + ...pre.data.slice(0, targetIndex), + updatedTarget, + ...pre.data.slice(targetIndex + 1), + ], + }; + }); + }; + + const handleDeleteGroup = (id) => { + setResponse((pre) => { + const targetIndex = getTargetIndexById(pre.data, id); + if (!(targetIndex > -1)) return pre; + + return { + ...pre, + data: [ + ...pre.data.slice(0, targetIndex), + ...pre.data.slice(targetIndex + 1), + ], + }; + }); + }; + + if (!userId) { + return 趕快發起屬於你的揪團吧~; + } + + return ( + + {title && ( + + {title} + + )} + + + {isFetching ? ( + + ) : Array.isArray(response?.data) && response.data.length ? ( + response.data.map((item, index) => ( + + {index > 0 && } + handleUpdateGrouping(item._id)} + onDeleteGroup={() => handleDeleteGroup(item._id)} + /> + + )) + ) : ( + 趕快發起屬於你的揪團吧~ + )} + + + ); +}; + +export default MyGroup; diff --git a/components/Profile/UserCard/Dropdown.jsx b/components/Profile/UserCard/Dropdown.jsx new file mode 100644 index 00000000..75b971cb --- /dev/null +++ b/components/Profile/UserCard/Dropdown.jsx @@ -0,0 +1,74 @@ +import { useState } from 'react'; +import styled from '@emotion/styled'; +import { Box, Button, Menu, MenuItem } from '@mui/material'; +import MoreVertRoundedIcon from '@mui/icons-material/MoreVertRounded'; +import Icon from '@mui/material/Icon'; + +const StyledMenu = styled((props) => ( + +))(() => ({ + '& .MuiPaper-root': { + borderRadius: 8, + minWidth: 150, + padding: '12px', + boxShadow: '0px 4px 10px 0px rgba(196, 194, 193, 0.40)', + }, + '& .MuiMenu-list': { + padding: '0', + }, + '& .MuiMenuItem-root': { + padding: '8px', + }, +})); + +export default function Dropdown({ sx }) { + const [anchorEl, setAnchorEl] = useState(null); + const open = Boolean(anchorEl); + const handleClick = (event) => { + setAnchorEl(event.currentTarget); + }; + const handleClose = () => { + setAnchorEl(null); + }; + + return ( + + + + + 檢舉 + + + + ); +} diff --git a/components/Profile/UserCard/index.jsx b/components/Profile/UserCard/index.jsx index adcee0d3..6330c0f6 100644 --- a/components/Profile/UserCard/index.jsx +++ b/components/Profile/UserCard/index.jsx @@ -1,10 +1,13 @@ -import { Box, Button, Chip, Skeleton, Typography } from '@mui/material'; +import styled from '@emotion/styled'; +import { Box, Chip, Button, Skeleton, Typography } from '@mui/material'; import { LazyLoadImage } from 'react-lazy-load-image-component'; import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined'; import EditOutlinedIcon from '@mui/icons-material/EditOutlined'; -import moment from 'moment/moment'; +import moment from 'moment'; import { useRouter } from 'next/router'; -import LOCATION from '../../../constants/countries.json'; +import { RiInstagramFill } from 'react-icons/ri'; +import { FaFacebook, FaLine, FaDiscord } from 'react-icons/fa'; +import DropdownMenu from './Dropdown'; const BottonEdit = { color: '#536166', @@ -18,149 +21,187 @@ const BottonEdit = { color: '#16B9B3', }, '@media (max-width: 767px)': { - position: 'absolute', - right: '25%', - top: '252%', - width: '160px', + display: 'none', }, }; +const StyledProfileWrapper = styled(Box)` + width: 100%; + padding: 30px; + background-color: #fff; + border-radius: 20px; + @media (max-width: 767px) { + width: 100%; + padding: 16px; + } +`; +const StyledProfileBaseInfo = styled(Box)` + display: flex; + justify-content: flex-start; + align-items: center; +`; +const StyledProfileTitle = styled(Box)` + div { + display: flex; + align-items: center; + } + h2 { + color: #536166; + font-size: 18px; + font-style: normal; + font-weight: 700; + line-height: 120%; + margin-right: 10px; + } + span { + border-radius: 4px; + background: #f3f3f3; + padding: 3px 10px; + } + p { + color: #92989a; + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 140%; /* 19.6px */ + } +`; +const StyledProfileLocation = styled(Typography)` + margin-top: 12px; + display: flex; + justify-content: flex-start; + align-items: center; + color: #536166; + font-size: 12px; + font-style: normal; + font-weight: 500; + line-height: 140%; /* 16.8px */ +`; +const StyledProfileTag = styled(Box)` + margin-top: 24px; + display: flex; + flex-wrap: wrap; +`; +const StyledProfileOther = styled(Box)` + margin-top: 24px; + display: flex; + justify-content: space-between; + align-items: flex-end; + @media (max-width: 767px) { + flex-direction: column; + align-items: flex-start; + } +`; +const StyledProfileSocial = styled.ul` + display: flex; + align-items: center; + flex-direction: column; + align-items: flex-start; + li { + align-items: center; + display: flex; + margin-right: 16px; + margin-bottom: 8px; + } + li:last-of-type { + margin-bottom: 0; + } + li svg { + color: #16b9b3; + } + li p, + li a { + margin-left: 5px; + color: #293a3d; + font-size: 12px; + font-style: normal; + font-weight: 400; + line-height: 140%; + } + + li a { + color: #16b9b3; + cursor: pointer; + text-decoration: underline; + } +`; +const StyledProfileDate = styled.p` + font-size: 12px; + color: #92989a; + font-weight: 400; + line-height: 140%; + @media (max-width: 767px) { + width: 100%; + text-align: right; + } +`; + function Tag({ label }) { return ( ); } + +function Avator({ photoURL }) { + return ( + + } + /> + ); +} + function UserCard({ - isLoading, - tagList, + isLoginUser, + tagList = [], + role, educationStepLabel, photoURL, userName, location, + contactList = {}, + updatedDate, }) { - console.log(educationStepLabel); const router = useRouter(); - if (isLoading) { - return ( - - - - - - - - - {' '} - - - - - - - - - ); - } + const locations = location && location.split('@'); + return ( - - - - } - /> + + {isLoginUser ? ( + ) : ( + + )} + + + - - {userName || '-'} - - - - - - - - {' '} - {LOCATION.find( - (item) => item.alpha2 === location || item.alpha3 === location, - )?.name || '-'} - + +
    +

    {userName || '-'}

    + {educationStepLabel && {educationStepLabel}} +
    +

    {role || '-'}

    +
    + + + + {location + ? location.length >= 2 + ? locations.join('').replace('台灣', '').replaceAll('null', '') + : locations.join('') + : '-'} +
    -
    - - + + + {Array.isArray(tagList) && ( + {tagList.map((tag) => ( ))} - - - {moment(new Date() - 500 * 60 * 60).fromNow()} - - -
    + + )} + + + + {!!contactList.instagram && ( +
  • + + + {contactList.instagram} + +
  • + )} + {!!contactList.facebook && ( +
  • + + + {contactList.facebook} + +
  • + )} + {!!contactList.line && ( +
  • + +

    {contactList.line}

    +
  • + )} + {!!contactList.discord && ( +
  • + +

    {contactList.discord}

    +
  • + )} +
    + + {updatedDate + ? moment(updatedDate).fromNow() + : moment(new Date() - 500 * 60 * 60).fromNow()} + +
    + ); } diff --git a/components/Profile/UserTabs/UserInfoBasic.jsx b/components/Profile/UserTabs/UserInfoBasic.jsx new file mode 100644 index 00000000..c01558b9 --- /dev/null +++ b/components/Profile/UserTabs/UserInfoBasic.jsx @@ -0,0 +1,32 @@ +import { StyledPanelBox, StyledPanelText } from './UserTabs.styled'; + +function UserInfoBasic({ description = '', wantToDoList = [], share = '' }) { + return ( + + +

    可分享

    + {share || '尚未填寫'} +
    + +

    想一起

    + {wantToDoList || '尚未填寫'} +
    + +

    簡介

    +
    + {description ? ( + description.split('\n').map((d) => {d}) + ) : ( + 尚未填寫 + )} +
    +
    +
    + ); +} + +export default UserInfoBasic; diff --git a/components/Profile/UserTabs/UserTabs.styled.jsx b/components/Profile/UserTabs/UserTabs.styled.jsx new file mode 100644 index 00000000..3662d6ab --- /dev/null +++ b/components/Profile/UserTabs/UserTabs.styled.jsx @@ -0,0 +1,48 @@ +import styled from '@emotion/styled'; +import { Box } from '@mui/material'; + +export const StyledTabContextBox = styled(Box)(({ theme }) => ({ + borderBottom: '1px solid #536166', + color: theme.secondary, // Assuming secondary is a valid theme property + borderColor: theme.secondary, // Use borderColor for indicator color + '@media (max-width: 767px)': { + width: '100%', + }, +})); + +export const StyledPanelBox = styled(Box)` + width: 720px; + padding: 40px 30px; + margin-top: '10px'; + @media (max-width: 767px) { + width: 100%; + padding: 30px; + } +`; + +export const StyledPanelText = styled(Box)` + display: flex; + p { + color: #293a3d; + font-weight: 500; + white-space: nowrap; + min-width: 50px; + } + span { + color: #536166; + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 140%; + margin-left: 12px; + display: grid; + place-items: center; + } + @media (max-width: 767px) { + flex-direction: column; + span { + margin-left: 0px; + place-items: start; + } + } +`; diff --git a/components/Profile/UserTabs/index.jsx b/components/Profile/UserTabs/index.jsx index f461aac0..f98ab55b 100644 --- a/components/Profile/UserTabs/index.jsx +++ b/components/Profile/UserTabs/index.jsx @@ -1,296 +1,48 @@ -import { Box, Typography, Divider, Skeleton } from '@mui/material'; +import { useState } from 'react'; +import { Box } from '@mui/material'; import Tab from '@mui/material/Tab'; import { TabContext } from '@mui/lab'; import TabList from '@mui/lab/TabList'; import TabPanel from '@mui/lab/TabPanel'; -import { useState } from 'react'; -import { WANT_TO_DO_WITH_PARTNER } from '../../../constants/member'; -import { mapToTable } from '../../../utils/helper'; - -const UserTabs = ({ - description = '', - wantToLearnList = [], - isLoading = false, -}) => { - // console.log('description', description); - // console.log('wantToLearnList', wantToLearnList); +import { StyledTabContextBox } from './UserTabs.styled'; +const UserTabs = ({ panels = [] }) => { const [value, setValue] = useState('1'); - if (isLoading) { - return ( - - - - setValue(newValue)} - aria-label="lab API tabs example" - centered - sx={{ - width: '100%', - }} - > - - - - - - - - - 可分享 - - - - - - - 想一起 - - - - - - - 個人網站 - - - - - - - 簡介 - - - - - - - - - - - - - ); - } return ( - + setValue(newValue)} - aria-label="lab API tabs example" centered - sx={{ - width: '100%', - }} + sx={{ width: '100%' }} > - - + {panels.length > 0 && + panels.map((panel) => ( + + ))} - - - - - - 可分享 - - - - - - - - 想一起 - - - {wantToLearnList - .map((item) => mapToTable(WANT_TO_DO_WITH_PARTNER)[item]) - .join(', ') || '-'} - - - - - - 個人網站 - - - - - - - - 簡介 - - - {description || '-'} - - - - - - - 即將推出,敬請期待 - - + + {panels.length > 0 && + panels.map((panel) => ( + + {panel.content} + + ))} ); diff --git a/components/Profile/index.jsx b/components/Profile/index.jsx index fe27f6f1..eba8e54e 100644 --- a/components/Profile/index.jsx +++ b/components/Profile/index.jsx @@ -1,16 +1,22 @@ -import React, { useMemo, useState, useLayoutEffect } from 'react'; +import { useMemo } from 'react'; import { useRouter } from 'next/router'; -import { Box, Button } from '@mui/material'; -import { useAuthState } from 'react-firebase-hooks/auth'; -import { getAuth } from 'firebase/auth'; -import { getFirestore, doc, getDoc } from 'firebase/firestore'; +import { Box, Button, Typography } from '@mui/material'; +import Skeleton from '@mui/material/Skeleton'; import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'; -import { CATEGORIES } from '../../constants/member'; -import { mapToTable } from '../../utils/helper'; +import EditOutlinedIcon from '@mui/icons-material/EditOutlined'; + +import { + WANT_TO_DO_WITH_PARTNER, + ROLE, + EDUCATION_STAGE, +} from '@/constants/member'; +import { mapToTable } from '@/utils/helper'; +import SEOConfig from '@/shared/components/SEO'; +import MyGroup from './MyGroup'; import UserCard from './UserCard'; import UserTabs from './UserTabs'; -import SEOConfig from '../../shared/components/SEO'; -import ContactModal from './Contact'; +import UserInfoBasic from './UserTabs/UserInfoBasic'; +import { StyledPanelBox } from './UserTabs/UserTabs.styled'; const BottonBack = { color: '#536166', @@ -26,42 +32,55 @@ const BottonBack = { position: 'unset', }, }; +const BottonEdit = { + display: 'none', + '@media (max-width: 767px)': { + display: 'flex', + width: '100%', + color: '#536166', + fontSize: '14px', + boxShadow: 'unset', + borderRadius: '20px', + marginTop: '32px', + padding: '8px 0', + '&:hover': { + color: '#16B9B3', + }, + }, +}; +const WANT_TO_DO_WITH_PARTNER_TABLE = mapToTable(WANT_TO_DO_WITH_PARTNER); +const ROLELIST = mapToTable(ROLE); +const EDUCATION_STAGE_TABLE = mapToTable(EDUCATION_STAGE); -const Profile = () => { +const Profile = ({ + _id, + name, + email, + photoURL, + tagList = [], + roleList = [], + educationStage, + selfIntroduction, + wantToDoList = [], + location, + share, + enableContactBtn = false, + sendEmail, + handleContactPartner, + contactList = {}, + updatedDate, + isLoading, +}) => { const router = useRouter(); - const auth = getAuth(); - const [user, isLoadingUser] = useAuthState(auth); - const [userName, setUserName] = useState(''); - const [description, setDescription] = useState(''); - const [photoURL, setPhotoURL] = useState(''); - const [location, setLocation] = useState(''); - const [wantToLearnList, setWantToLearnList] = useState([]); - const [interestAreaList, setInterestAreaList] = useState([]); - const [isLoading, setIsLoading] = useState(isLoadingUser); - const [open, setOpen] = useState(false); - - useLayoutEffect(() => { - const db = getFirestore(); - if (!isLoadingUser && user?.uid) { - const docRef = doc(db, 'partnerlist', user?.uid || ''); - getDoc(docRef).then((docSnap) => { - const data = docSnap.data(); - console.log('data', data); - setUserName(data?.userName || ''); - setPhotoURL(data?.photoURL || ''); - setDescription(data?.description || ''); - setWantToLearnList(data?.wantToLearnList || []); - setInterestAreaList(data?.interestAreaList || []); - setLocation(data?.location || ''); - setIsLoading(false); - }); - } - console.log(description); - }, [user, isLoadingUser]); + const role = roleList.length > 0 && ROLELIST[roleList[0]]; + const edu = educationStage && EDUCATION_STAGE_TABLE[educationStage]; + const wantTodo = wantToDoList + .map((item) => WANT_TO_DO_WITH_PARTNER_TABLE[item]) + .join('、'); const SEOData = useMemo( () => ({ - title: `${userName}的小島|島島阿學`, + title: `${name}的小島|島島阿學`, description: '「島島阿學」盼能透過建立多元的學習資源網絡,讓自主學習者能找到合適的成長方法,進一步成為自己想成為的人,從中培養共好精神。目前正積極打造「可共編的學習資源平台」。', keywords: '島島阿學', @@ -70,81 +89,174 @@ const Profile = () => { imgLink: 'https://www.daoedu.tw/preview.webp', link: `${process.env.HOSTNAME}${router?.asPath}`, }), - [router?.asPath], + [router?.asPath, name], ); - const tagList = interestAreaList.map((item) => mapToTable(CATEGORIES)[item]); - return ( - { - setOpen(false); - // router.push('/'); - // router.push('/partner'); - }} - onOk={() => { - setOpen(false); - // router.push('/profile'); - // router.push('/profile/edit'); - }} - /> - + ) : ( + typeof t === 'string' && t !== '')} + photoURL={photoURL} + userName={name} + location={location} + updatedDate={updatedDate} + contactList={contactList} + /> + )} + + {/* UserTabs */} + {isLoading ? ( + + ) : ( + + ), + }, + { + id: '2', + title: '推薦的資源', + content: 即將推出,敬請期待, + }, + { + id: '3', + title: '發起的揪團', + content: ( + + ), + }, + ]} /> - - - + )} + {email !== sendEmail ? ( + <> + + {!enableContactBtn && ( + router.push('/login')} + sx={{ cursor: 'pointer', mt: '5px', fontSize: '12px' }} + > + + 註冊 + + 或 + + 登入 + + 即可聯繫夥伴! + + )} + + ) : ( + + )} ); }; diff --git a/components/Search/SearchResultList/Item/index.jsx b/components/Search/SearchResultList/Item/index.jsx index d77e067a..1865187a 100644 --- a/components/Search/SearchResultList/Item/index.jsx +++ b/components/Search/SearchResultList/Item/index.jsx @@ -102,13 +102,11 @@ const Item = ({ data, queryTags }) => { [data], ); - const title = useMemo( - () => - (data?.properties['資源名稱']?.title ?? []).find( - (item) => item?.type === 'text', - )?.plain_text, - [data?.properties], - ); + const titleTextList = (data?.properties['資源名稱']?.title ?? []) + .filter((item) => item?.type === 'text') + .map((item) => item?.plain_text); + + const title = useMemo(() => titleTextList.join(''), [data?.properties]); const contributors = useMemo( () => data?.properties['創建者']?.multi_select ?? [], diff --git a/constants/areas.js b/constants/areas.js new file mode 100644 index 00000000..cb323329 --- /dev/null +++ b/constants/areas.js @@ -0,0 +1,1650 @@ +export const AREAS = [ + { name: '線上', label: '線上' }, + { name: '台北市', label: '台北市' }, + { name: '新北市', label: '新北市' }, + { name: '基隆市', label: '基隆市' }, + { name: '桃園市', label: '桃園市' }, + { name: '新竹市', label: '新竹市' }, + { name: '新竹縣', label: '新竹縣' }, + { name: '苗栗縣', label: '苗栗縣' }, + { name: '台中市', label: '台中市' }, + { name: '南投縣', label: '南投縣' }, + { name: '彰化縣', label: '彰化縣' }, + { name: '雲林縣', label: '雲林縣' }, + { name: '嘉義市', label: '嘉義市' }, + { name: '嘉義縣', label: '嘉義縣' }, + { name: '台南市', label: '台南市' }, + { name: '高雄市', label: '高雄市' }, + { name: '屏東縣', label: '屏東縣' }, + { name: '台東縣', label: '台東縣' }, + { name: '花蓮縣', label: '花蓮縣' }, + { name: '宜蘭縣', label: '宜蘭縣' }, + { name: '澎湖縣', label: '澎湖縣' }, + { name: '金門縣', label: '金門縣' }, + { name: '連江縣', label: '連江縣' }, +]; + +// https://gist.github.com/abc873693/2804e64324eaaf26515281710e1792df +export const TAIWAN_DISTRICT = [ + { + districts: [ + { + zip: '100', + name: '中正區', + }, + { + zip: '103', + name: '大同區', + }, + { + zip: '104', + name: '中山區', + }, + { + zip: '105', + name: '松山區', + }, + { + zip: '106', + name: '大安區', + }, + { + zip: '108', + name: '萬華區', + }, + { + zip: '110', + name: '信義區', + }, + { + zip: '111', + name: '士林區', + }, + { + zip: '112', + name: '北投區', + }, + { + zip: '114', + name: '內湖區', + }, + { + zip: '115', + name: '南港區', + }, + { + zip: '116', + name: '文山區', + }, + ], + name: '臺北市', + }, + { + districts: [ + { + zip: '200', + name: '仁愛區', + }, + { + zip: '201', + name: '信義區', + }, + { + zip: '202', + name: '中正區', + }, + { + zip: '203', + name: '中山區', + }, + { + zip: '204', + name: '安樂區', + }, + { + zip: '205', + name: '暖暖區', + }, + { + zip: '206', + name: '七堵區', + }, + ], + name: '基隆市', + }, + { + districts: [ + { + zip: '207', + name: '萬里區', + }, + { + zip: '208', + name: '金山區', + }, + { + zip: '220', + name: '板橋區', + }, + { + zip: '221', + name: '汐止區', + }, + { + zip: '222', + name: '深坑區', + }, + { + zip: '223', + name: '石碇區', + }, + { + zip: '224', + name: '瑞芳區', + }, + { + zip: '226', + name: '平溪區', + }, + { + zip: '227', + name: '雙溪區', + }, + { + zip: '228', + name: '貢寮區', + }, + { + zip: '231', + name: '新店區', + }, + { + zip: '232', + name: '坪林區', + }, + { + zip: '233', + name: '烏來區', + }, + { + zip: '234', + name: '永和區', + }, + { + zip: '235', + name: '中和區', + }, + { + zip: '236', + name: '土城區', + }, + { + zip: '237', + name: '三峽區', + }, + { + zip: '238', + name: '樹林區', + }, + { + zip: '239', + name: '鶯歌區', + }, + { + zip: '241', + name: '三重區', + }, + { + zip: '242', + name: '新莊區', + }, + { + zip: '243', + name: '泰山區', + }, + { + zip: '244', + name: '林口區', + }, + { + zip: '247', + name: '蘆洲區', + }, + { + zip: '248', + name: '五股區', + }, + { + zip: '249', + name: '八里區', + }, + { + zip: '251', + name: '淡水區', + }, + { + zip: '252', + name: '三芝區', + }, + { + zip: '253', + name: '石門區', + }, + ], + name: '新北市', + }, + { + districts: [ + { + zip: '209', + name: '南竿鄉', + }, + { + zip: '210', + name: '北竿鄉', + }, + { + zip: '211', + name: '莒光鄉', + }, + { + zip: '212', + name: '東引鄉', + }, + ], + name: '連江縣', + }, + { + districts: [ + { + zip: '260', + name: '宜蘭市', + }, + { + zip: '263', + name: '壯圍鄉', + }, + { + zip: '261', + name: '頭城鎮', + }, + { + zip: '262', + name: '礁溪鄉', + }, + { + zip: '264', + name: '員山鄉', + }, + { + zip: '265', + name: '羅東鎮', + }, + { + zip: '266', + name: '三星鄉', + }, + { + zip: '267', + name: '大同鄉', + }, + { + zip: '268', + name: '五結鄉', + }, + { + zip: '269', + name: '冬山鄉', + }, + { + zip: '270', + name: '蘇澳鎮', + }, + { + zip: '272', + name: '南澳鄉', + }, + { + zip: '290', + name: '釣魚臺', + }, + ], + name: '宜蘭縣', + }, + { + districts: [ + { + zip: '290', + name: '釣魚臺', + }, + ], + name: '釣魚臺', + }, + { + districts: [ + { + zip: '300', + name: '東區', + }, + { + zip: '300', + name: '北區', + }, + { + zip: '300', + name: '香山區', + }, + ], + name: '新竹市', + }, + { + districts: [ + { + zip: '308', + name: '寶山鄉', + }, + { + zip: '302', + name: '竹北市', + }, + { + zip: '303', + name: '湖口鄉', + }, + { + zip: '304', + name: '新豐鄉', + }, + { + zip: '305', + name: '新埔鎮', + }, + { + zip: '306', + name: '關西鎮', + }, + { + zip: '307', + name: '芎林鄉', + }, + { + zip: '310', + name: '竹東鎮', + }, + { + zip: '311', + name: '五峰鄉', + }, + { + zip: '312', + name: '橫山鄉', + }, + { + zip: '313', + name: '尖石鄉', + }, + { + zip: '314', + name: '北埔鄉', + }, + { + zip: '315', + name: '峨眉鄉', + }, + ], + name: '新竹縣', + }, + { + districts: [ + { + zip: '320', + name: '中壢區', + }, + { + zip: '324', + name: '平鎮區', + }, + { + zip: '325', + name: '龍潭區', + }, + { + zip: '326', + name: '楊梅區', + }, + { + zip: '327', + name: '新屋區', + }, + { + zip: '328', + name: '觀音區', + }, + { + zip: '330', + name: '桃園區', + }, + { + zip: '333', + name: '龜山區', + }, + { + zip: '334', + name: '八德區', + }, + { + zip: '335', + name: '大溪區', + }, + { + zip: '336', + name: '復興區', + }, + { + zip: '337', + name: '大園區', + }, + { + zip: '338', + name: '蘆竹區', + }, + ], + name: '桃園市', + }, + { + districts: [ + { + zip: '350', + name: '竹南鎮', + }, + { + zip: '351', + name: '頭份市', + }, + { + zip: '352', + name: '三灣鄉', + }, + { + zip: '353', + name: '南庄鄉', + }, + { + zip: '354', + name: '獅潭鄉', + }, + { + zip: '356', + name: '後龍鎮', + }, + { + zip: '357', + name: '通霄鎮', + }, + { + zip: '358', + name: '苑裡鎮', + }, + { + zip: '360', + name: '苗栗市', + }, + { + zip: '361', + name: '造橋鄉', + }, + { + zip: '362', + name: '頭屋鄉', + }, + { + zip: '363', + name: '公館鄉', + }, + { + zip: '364', + name: '大湖鄉', + }, + { + zip: '365', + name: '泰安鄉', + }, + { + zip: '366', + name: '銅鑼鄉', + }, + { + zip: '367', + name: '三義鄉', + }, + { + zip: '368', + name: '西湖鄉', + }, + { + zip: '369', + name: '卓蘭鎮', + }, + ], + name: '苗栗縣', + }, + { + districts: [ + { + zip: '400', + name: '中區', + }, + { + zip: '401', + name: '東區', + }, + { + zip: '402', + name: '南區', + }, + { + zip: '403', + name: '西區', + }, + { + zip: '404', + name: '北區', + }, + { + zip: '406', + name: '北屯區', + }, + { + zip: '407', + name: '西屯區', + }, + { + zip: '408', + name: '南屯區', + }, + { + zip: '411', + name: '太平區', + }, + { + zip: '412', + name: '大里區', + }, + { + zip: '413', + name: '霧峰區', + }, + { + zip: '414', + name: '烏日區', + }, + { + zip: '420', + name: '豐原區', + }, + { + zip: '421', + name: '后里區', + }, + { + zip: '422', + name: '石岡區', + }, + { + zip: '423', + name: '東勢區', + }, + { + zip: '424', + name: '和平區', + }, + { + zip: '426', + name: '新社區', + }, + { + zip: '427', + name: '潭子區', + }, + { + zip: '428', + name: '大雅區', + }, + { + zip: '429', + name: '神岡區', + }, + { + zip: '432', + name: '大肚區', + }, + { + zip: '433', + name: '沙鹿區', + }, + { + zip: '434', + name: '龍井區', + }, + { + zip: '435', + name: '梧棲區', + }, + { + zip: '436', + name: '清水區', + }, + { + zip: '437', + name: '大甲區', + }, + { + zip: '438', + name: '外埔區', + }, + { + zip: '439', + name: '大安區', + }, + ], + name: '臺中市', + }, + { + districts: [ + { + zip: '500', + name: '彰化市', + }, + { + zip: '502', + name: '芬園鄉', + }, + { + zip: '503', + name: '花壇鄉', + }, + { + zip: '504', + name: '秀水鄉', + }, + { + zip: '505', + name: '鹿港鎮', + }, + { + zip: '506', + name: '福興鄉', + }, + { + zip: '507', + name: '線西鄉', + }, + { + zip: '508', + name: '和美鎮', + }, + { + zip: '509', + name: '伸港鄉', + }, + { + zip: '510', + name: '員林市', + }, + { + zip: '511', + name: '社頭鄉', + }, + { + zip: '512', + name: '永靖鄉', + }, + { + zip: '513', + name: '埔心鄉', + }, + { + zip: '514', + name: '溪湖鎮', + }, + { + zip: '515', + name: '大村鄉', + }, + { + zip: '516', + name: '埔鹽鄉', + }, + { + zip: '520', + name: '田中鎮', + }, + { + zip: '521', + name: '北斗鎮', + }, + { + zip: '522', + name: '田尾鄉', + }, + { + zip: '523', + name: '埤頭鄉', + }, + { + zip: '524', + name: '溪州鄉', + }, + { + zip: '525', + name: '竹塘鄉', + }, + { + zip: '526', + name: '二林鎮', + }, + { + zip: '527', + name: '大城鄉', + }, + { + zip: '528', + name: '芳苑鄉', + }, + { + zip: '530', + name: '二水鄉', + }, + ], + name: '彰化縣', + }, + { + districts: [ + { + zip: '540', + name: '南投市', + }, + { + zip: '541', + name: '中寮鄉', + }, + { + zip: '542', + name: '草屯鎮', + }, + { + zip: '544', + name: '國姓鄉', + }, + { + zip: '545', + name: '埔里鎮', + }, + { + zip: '546', + name: '仁愛鄉', + }, + { + zip: '551', + name: '名間鄉', + }, + { + zip: '552', + name: '集集鎮', + }, + { + zip: '553', + name: '水里鄉', + }, + { + zip: '555', + name: '魚池鄉', + }, + { + zip: '556', + name: '信義鄉', + }, + { + zip: '557', + name: '竹山鎮', + }, + { + zip: '558', + name: '鹿谷鄉', + }, + ], + name: '南投縣', + }, + { + districts: [ + { + zip: '600', + name: '西區', + }, + { + zip: '600', + name: '東區', + }, + ], + name: '嘉義市', + }, + { + districts: [ + { + zip: '602', + name: '番路鄉', + }, + { + zip: '603', + name: '梅山鄉', + }, + { + zip: '604', + name: '竹崎鄉', + }, + { + zip: '605', + name: '阿里山鄉', + }, + { + zip: '606', + name: '中埔鄉', + }, + { + zip: '607', + name: '大埔鄉', + }, + { + zip: '608', + name: '水上鄉', + }, + { + zip: '611', + name: '鹿草鄉', + }, + { + zip: '612', + name: '太保市', + }, + { + zip: '613', + name: '朴子市', + }, + { + zip: '614', + name: '東石鄉', + }, + { + zip: '615', + name: '六腳鄉', + }, + { + zip: '616', + name: '新港鄉', + }, + { + zip: '621', + name: '民雄鄉', + }, + { + zip: '622', + name: '大林鎮', + }, + { + zip: '623', + name: '溪口鄉', + }, + { + zip: '624', + name: '義竹鄉', + }, + { + zip: '625', + name: '布袋鎮', + }, + ], + name: '嘉義縣', + }, + { + districts: [ + { + zip: '630', + name: '斗南鎮', + }, + { + zip: '631', + name: '大埤鄉', + }, + { + zip: '632', + name: '虎尾鎮', + }, + { + zip: '633', + name: '土庫鎮', + }, + { + zip: '634', + name: '褒忠鄉', + }, + { + zip: '635', + name: '東勢鄉', + }, + { + zip: '636', + name: '臺西鄉', + }, + { + zip: '637', + name: '崙背鄉', + }, + { + zip: '638', + name: '麥寮鄉', + }, + { + zip: '640', + name: '斗六市', + }, + { + zip: '643', + name: '林內鄉', + }, + { + zip: '646', + name: '古坑鄉', + }, + { + zip: '647', + name: '莿桐鄉', + }, + { + zip: '648', + name: '西螺鎮', + }, + { + zip: '649', + name: '二崙鄉', + }, + { + zip: '651', + name: '北港鎮', + }, + { + zip: '652', + name: '水林鄉', + }, + { + zip: '653', + name: '口湖鄉', + }, + { + zip: '654', + name: '四湖鄉', + }, + { + zip: '655', + name: '元長鄉', + }, + ], + name: '雲林縣', + }, + { + districts: [ + { + zip: '700', + name: '中西區', + }, + { + zip: '701', + name: '東區', + }, + { + zip: '702', + name: '南區', + }, + { + zip: '704', + name: '北區', + }, + { + zip: '708', + name: '安平區', + }, + { + zip: '709', + name: '安南區', + }, + { + zip: '710', + name: '永康區', + }, + { + zip: '711', + name: '歸仁區', + }, + { + zip: '712', + name: '新化區', + }, + { + zip: '713', + name: '左鎮區', + }, + { + zip: '714', + name: '玉井區', + }, + { + zip: '715', + name: '楠西區', + }, + { + zip: '716', + name: '南化區', + }, + { + zip: '717', + name: '仁德區', + }, + { + zip: '718', + name: '關廟區', + }, + { + zip: '719', + name: '龍崎區', + }, + { + zip: '720', + name: '官田區', + }, + { + zip: '721', + name: '麻豆區', + }, + { + zip: '722', + name: '佳里區', + }, + { + zip: '723', + name: '西港區', + }, + { + zip: '724', + name: '七股區', + }, + { + zip: '725', + name: '將軍區', + }, + { + zip: '726', + name: '學甲區', + }, + { + zip: '727', + name: '北門區', + }, + { + zip: '730', + name: '新營區', + }, + { + zip: '731', + name: '後壁區', + }, + { + zip: '732', + name: '白河區', + }, + { + zip: '733', + name: '東山區', + }, + { + zip: '734', + name: '六甲區', + }, + { + zip: '735', + name: '下營區', + }, + { + zip: '736', + name: '柳營區', + }, + { + zip: '737', + name: '鹽水區', + }, + { + zip: '741', + name: '善化區', + }, + { + zip: '744', + name: '新市區', + }, + { + zip: '742', + name: '大內區', + }, + { + zip: '743', + name: '山上區', + }, + { + zip: '745', + name: '安定區', + }, + ], + name: '臺南市', + }, + { + districts: [ + { + zip: '800', + name: '新興區', + }, + { + zip: '801', + name: '前金區', + }, + { + zip: '802', + name: '苓雅區', + }, + { + zip: '803', + name: '鹽埕區', + }, + { + zip: '804', + name: '鼓山區', + }, + { + zip: '805', + name: '旗津區', + }, + { + zip: '806', + name: '前鎮區', + }, + { + zip: '807', + name: '三民區', + }, + { + zip: '811', + name: '楠梓區', + }, + { + zip: '812', + name: '小港區', + }, + { + zip: '813', + name: '左營區', + }, + { + zip: '814', + name: '仁武區', + }, + { + zip: '815', + name: '大社區', + }, + { + zip: '817', + name: '東沙群島', + }, + { + zip: '819', + name: '南沙群島', + }, + { + zip: '820', + name: '岡山區', + }, + { + zip: '821', + name: '路竹區', + }, + { + zip: '822', + name: '阿蓮區', + }, + { + zip: '823', + name: '田寮區', + }, + { + zip: '824', + name: '燕巢區', + }, + { + zip: '825', + name: '橋頭區', + }, + { + zip: '826', + name: '梓官區', + }, + { + zip: '827', + name: '彌陀區', + }, + { + zip: '828', + name: '永安區', + }, + { + zip: '829', + name: '湖內區', + }, + { + zip: '830', + name: '鳳山區', + }, + { + zip: '831', + name: '大寮區', + }, + { + zip: '832', + name: '林園區', + }, + { + zip: '833', + name: '鳥松區', + }, + { + zip: '840', + name: '大樹區', + }, + { + zip: '842', + name: '旗山區', + }, + { + zip: '843', + name: '美濃區', + }, + { + zip: '844', + name: '六龜區', + }, + { + zip: '845', + name: '內門區', + }, + { + zip: '846', + name: '杉林區', + }, + { + zip: '847', + name: '甲仙區', + }, + { + zip: '848', + name: '桃源區', + }, + { + zip: '849', + name: '那瑪夏區', + }, + { + zip: '851', + name: '茂林區', + }, + { + zip: '852', + name: '茄萣區', + }, + ], + name: '高雄市', + }, + { + districts: [ + { + zip: '817', + name: '東沙群島', + }, + { + zip: '819', + name: '南沙群島', + }, + ], + name: '南海島', + }, + { + districts: [ + { + zip: '880', + name: '馬公市', + }, + { + zip: '881', + name: '西嶼鄉', + }, + { + zip: '882', + name: '望安鄉', + }, + { + zip: '883', + name: '七美鄉', + }, + { + zip: '884', + name: '白沙鄉', + }, + { + zip: '885', + name: '湖西鄉', + }, + ], + name: '澎湖縣', + }, + { + districts: [ + { + zip: '890', + name: '金沙鎮', + }, + { + zip: '891', + name: '金湖鎮', + }, + { + zip: '892', + name: '金寧鄉', + }, + { + zip: '893', + name: '金城鎮', + }, + { + zip: '894', + name: '烈嶼鄉', + }, + { + zip: '896', + name: '烏坵鄉', + }, + ], + name: '金門縣', + }, + { + districts: [ + { + zip: '900', + name: '屏東市', + }, + { + zip: '901', + name: '三地門鄉', + }, + { + zip: '902', + name: '霧臺鄉', + }, + { + zip: '903', + name: '瑪家鄉', + }, + { + zip: '904', + name: '九如鄉', + }, + { + zip: '905', + name: '里港鄉', + }, + { + zip: '906', + name: '高樹鄉', + }, + { + zip: '907', + name: '鹽埔鄉', + }, + { + zip: '908', + name: '長治鄉', + }, + { + zip: '909', + name: '麟洛鄉', + }, + { + zip: '911', + name: '竹田鄉', + }, + { + zip: '912', + name: '內埔鄉', + }, + { + zip: '913', + name: '萬丹鄉', + }, + { + zip: '920', + name: '潮州鎮', + }, + { + zip: '921', + name: '泰武鄉', + }, + { + zip: '922', + name: '來義鄉', + }, + { + zip: '923', + name: '萬巒鄉', + }, + { + zip: '924', + name: '崁頂鄉', + }, + { + zip: '925', + name: '新埤鄉', + }, + { + zip: '926', + name: '南州鄉', + }, + { + zip: '927', + name: '林邊鄉', + }, + { + zip: '928', + name: '東港鎮', + }, + { + zip: '929', + name: '琉球鄉', + }, + { + zip: '931', + name: '佳冬鄉', + }, + { + zip: '932', + name: '新園鄉', + }, + { + zip: '940', + name: '枋寮鄉', + }, + { + zip: '941', + name: '枋山鄉', + }, + { + zip: '942', + name: '春日鄉', + }, + { + zip: '943', + name: '獅子鄉', + }, + { + zip: '944', + name: '車城鄉', + }, + { + zip: '945', + name: '牡丹鄉', + }, + { + zip: '946', + name: '恆春鎮', + }, + { + zip: '947', + name: '滿州鄉', + }, + ], + name: '屏東縣', + }, + { + districts: [ + { + zip: '950', + name: '臺東市', + }, + { + zip: '951', + name: '綠島鄉', + }, + { + zip: '952', + name: '蘭嶼鄉', + }, + { + zip: '953', + name: '延平鄉', + }, + { + zip: '954', + name: '卑南鄉', + }, + { + zip: '955', + name: '鹿野鄉', + }, + { + zip: '956', + name: '關山鎮', + }, + { + zip: '957', + name: '海端鄉', + }, + { + zip: '958', + name: '池上鄉', + }, + { + zip: '959', + name: '東河鄉', + }, + { + zip: '961', + name: '成功鎮', + }, + { + zip: '962', + name: '長濱鄉', + }, + { + zip: '963', + name: '太麻里鄉', + }, + { + zip: '964', + name: '金峰鄉', + }, + { + zip: '965', + name: '大武鄉', + }, + { + zip: '966', + name: '達仁鄉', + }, + ], + name: '臺東縣', + }, + { + districts: [ + { + zip: '970', + name: '花蓮市', + }, + { + zip: '971', + name: '新城鄉', + }, + { + zip: '972', + name: '秀林鄉', + }, + { + zip: '973', + name: '吉安鄉', + }, + { + zip: '974', + name: '壽豐鄉', + }, + { + zip: '975', + name: '鳳林鎮', + }, + { + zip: '976', + name: '光復鄉', + }, + { + zip: '977', + name: '豐濱鄉', + }, + { + zip: '978', + name: '瑞穗鄉', + }, + { + zip: '979', + name: '萬榮鄉', + }, + { + zip: '981', + name: '玉里鎮', + }, + { + zip: '982', + name: '卓溪鄉', + }, + { + zip: '983', + name: '富里鄉', + }, + ], + name: '花蓮縣', + }, +]; + +export const COUNTRIES = [ + { name: '國外', label: '國外' }, + { name: '台灣', label: '台灣' }, +]; diff --git a/constants/category.js b/constants/category.js index 79629194..8c7f2c6a 100644 --- a/constants/category.js +++ b/constants/category.js @@ -62,50 +62,62 @@ export const SEARCH_TAGS = { export const CATEGORIES = [ { key: 'language', + label: '語言與文學', value: '語言與文學', }, { key: 'math', + label: '數學與邏輯', value: '數學與邏輯', }, { key: 'comsci', + label: '資訊與工程', value: '資訊與工程', }, { key: 'humanity', + label: '人文社會', value: '人文社會', }, { key: 'natusci', + label: '自然科學', value: '自然科學', }, { key: 'art', + label: '藝術', value: '藝術', }, { key: 'education', + label: '教育', value: '教育', }, { key: 'life', + label: '生活', value: '生活', }, { key: 'health', + label: '運動/心理/醫學', value: '運動/心理/醫學', }, { key: 'business', + label: '商業與社會創新', value: '商業與社會創新', }, { key: 'multires', + label: '綜合型學習資源', value: '綜合型學習資源', }, { key: 'learningtools', + label: '學習/教學工具', value: '學習/教學工具', }, ]; @@ -204,30 +216,35 @@ export const NAV_LINK = [ link: '/search', target: '_self', }, - // { - // name: '找夥伴', - // link: '/partner', - // target: '_self', - // }, { - name: '找活動', - link: '/activities', + name: '找夥伴', + link: '/partner', target: '_self', }, + { + name: '找揪團', + link: '/group', + target: '_self', + }, + // { + // name: '找活動', + // link: '/activities', + // target: '_self', + // }, { name: '找故事', link: 'https://blog.daoedu.tw', target: '_blank', }, - { - name: '找場域', - link: '/locations', - target: '_self', - }, + // { + // name: '找場域', + // link: '/locations', + // target: '_self', + // }, { name: '加入社群', - link: 'https://www.facebook.com/groups/2237666046370459', - target: '_blank', + link: '/join', + target: '_self', }, // { // name: '找學習空間', @@ -241,26 +258,31 @@ export const NAV_LINK_MOBILE = [ link: '/search', target: '_self', }, - // { - // name: '找夥伴', - // link: '/partner', - // target: '_self', - // }, { - name: '找活動', - link: '/activities', + name: '找夥伴', + link: '/partner', target: '_self', }, + { + name: '找揪團', + link: '/group', + target: '_self', + }, + // { + // name: '找活動', + // link: '/activities', + // target: '_self', + // }, { name: '找故事', link: 'https://blog.daoedu.tw', target: '_blank', }, - { - name: '找場域', - link: '/locations', - target: '_self', - }, + // { + // name: '找場域', + // link: '/locations', + // target: '_self', + // }, { name: '新增資源', link: '/contribute/resource', @@ -273,8 +295,8 @@ export const NAV_LINK_MOBILE = [ }, { name: '加入社群', - link: 'https://www.facebook.com/groups/2237666046370459', - target: '_blank', + link: '/join', + target: '_self', }, // { // name: '找學習空間', @@ -294,19 +316,24 @@ export const FOOTER_LINK = [ target: '_self', }, { - name: '找活動', - link: '/activities', - target: '_self', - }, - { - name: '找場域', - link: '/locations', + name: '找揪團', + link: '/group', target: '_self', }, + // { + // name: '找活動', + // link: '/activities', + // target: '_self', + // }, + // { + // name: '找場域', + // link: '/locations', + // target: '_self', + // }, { name: '加入社群', - link: 'https://www.facebook.com/groups/2237666046370459', - target: '_blank', + link: '/join', + target: '_self', }, { name: '隱私權政策', diff --git a/constants/common.js b/constants/common.js new file mode 100644 index 00000000..c7739f81 --- /dev/null +++ b/constants/common.js @@ -0,0 +1,5 @@ +const isDev = process.env.NODE_ENV === 'development'; + +export const BASE_URL = isDev + ? '/dev-proxy-api' + : process.env.NEXT_PUBLIC_API_URL; diff --git a/constants/member.js b/constants/member.js index ef40c414..6c2ca210 100644 --- a/constants/member.js +++ b/constants/member.js @@ -56,38 +56,60 @@ export const ROLE = [ export const EDUCATION_STEP = [ { label: '學齡前', + key: 'preschool', value: 'preschool', }, { label: '國小低年級', + key: 'elementary-junior', value: 'elementary-junior', }, { label: '國小中年級', + key: 'elementary-middle', value: 'elementary-middle', }, { label: '國小高年級', + key: 'elementary-senior', value: 'elementary-senior', }, { label: '國中', + key: 'junior-high', value: 'junior-high', }, { label: '高中', + key: 'high', value: 'high', }, { label: '大學', + key: 'university', value: 'university', }, + { + label: '碩士', + key: 'master', + value: 'master', + }, + { + label: '博士', + key: 'doctor', + value: 'doctor', + }, { label: '其他', + key: 'other', value: 'other', }, ]; +export const EDUCATION_STAGE = EDUCATION_STEP.filter( + (step) => step.key !== 'master' && step.key !== 'doctor', +); + export const WANT_TO_DO_WITH_PARTNER = [ { label: '交朋友', @@ -110,7 +132,7 @@ export const WANT_TO_DO_WITH_PARTNER = [ value: 'make-group-class', }, { - label: '做專案', + label: '做專案/競賽', key: 'do-project', value: 'do-project', }, diff --git a/contexts/Snackbar.jsx b/contexts/Snackbar.jsx new file mode 100644 index 00000000..303a6526 --- /dev/null +++ b/contexts/Snackbar.jsx @@ -0,0 +1,61 @@ +import { createContext, useContext, useState } from 'react'; +import MuiSnackbar from '@mui/material/Snackbar'; +import IconButton from '@mui/material/IconButton'; +import CloseIcon from '@mui/icons-material/Close'; + +const SnackbarContext = createContext({ + pushSnackbar: () => Promise.resolve(), +}); + +export const useSnackbar = () => useContext(SnackbarContext); + +function CloseButton({ onClick }) { + return ( + + + + ); +} + +export default function SnackbarProvider({ children }) { + const [queue, setQueue] = useState([]); + + const pushSnackbar = ({ message }) => + new Promise((resolve) => { + setQueue((pre) => [ + ...pre, + { id: Math.random(), open: true, message, resolve }, + ]); + }); + + const closeSnackbar = (id) => (e) => { + e?.stopPropagation?.(); + setQueue((pre) => { + const index = pre.findIndex((data) => data.id === id); + if (!(index > -1)) return pre; + queue[index].resolve(); + return [...pre.slice(0, index), ...pre.slice(index + 1)]; + }); + }; + + return ( + + {children} + {queue.map((data) => ( + } + autoHideDuration={5000} + /> + ))} + + ); +} diff --git a/hooks/useFetch.jsx b/hooks/useFetch.jsx index d8ee6838..4cf11cd7 100644 --- a/hooks/useFetch.jsx +++ b/hooks/useFetch.jsx @@ -1,17 +1,55 @@ -import { useEffect, useState } from 'react'; +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'; + +const useFetch = (url, { enabled = true, initialValue, onSuccess } = {}) => { + const { token } = useSelector((state) => state.user); + const dispatch = useDispatch(); + const router = useRouter(); + const [render, refetch] = useReducer((pre) => !pre, true); + const [data, setData] = useState(initialValue); + const [isFetching, setIsFetching] = useState(enabled); + const [isError, setIsError] = useState(false); + + useEffect(() => { + if (!enabled) return; + + const endpoint = url.startsWith('http') ? url : `${BASE_URL}${url}`; + const headers = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }; + const requestData = { headers } + let pass = true; + + setIsFetching(true); + setIsError(false); + + fetch(endpoint, requestData) + .then((res) => { + if (res.status < 300) return res.json(); + if (res.status === 401) { + dispatch(userLogout()); + router.replace('/login') + } + throw res; + }) + .then((json) => pass && setData(json)) + .catch(() => setIsError(true)) + .finally(() => setIsFetching(false)); + + return () => { + pass = false; + }; + }, [enabled, token, url, render]); -const useFetch = (url, initialValue) => { - const [result, setResult] = useState(initialValue); - const [loading, setLoading] = useState(true); useEffect(() => { - fetch(url) - .then((res) => res.json()) - .then((json) => { - setResult(json); - setLoading(false); - }); - }, []); - return { result, loading }; + if (onSuccess) onSuccess(data); + }, [onSuccess, data]); + + return { data, isFetching, isError, refetch }; }; export default useFetch; diff --git a/hooks/useMutation.jsx b/hooks/useMutation.jsx new file mode 100644 index 00000000..a9fa27be --- /dev/null +++ b/hooks/useMutation.jsx @@ -0,0 +1,51 @@ +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'; + +const useMutation = (url, { method, enabled = true, onSuccess, onError } = {}) => { + const { token } = useSelector((state) => state.user); + const dispatch = useDispatch(); + const router = useRouter(); + const [isLoading, setIsLoading] = useState(false); + const [isError, setIsError] = useState(false); + + const mutate = (values) => { + if (!enabled) return; + + const endpoint = url.startsWith('http') ? url : `${BASE_URL}${url}`; + const headers = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }; + const requestData = { + method, + body: JSON.stringify(values), + headers, + }; + + setIsLoading(true); + setIsError(false); + + fetch(endpoint, requestData) + .then((res) => { + if (res.status < 300) return res.json(); + if (res.status === 401) { + dispatch(userLogout()); + router.replace('/login'); + } + throw res; + }) + .then(onSuccess) + .catch((e) => { + onError?.(e); + setIsError(true); + }) + .finally(() => setIsLoading(false)); + }; + + return { mutate, isLoading, isError }; +}; + +export default useMutation; diff --git a/hooks/useSearchParamsManager.jsx b/hooks/useSearchParamsManager.jsx new file mode 100644 index 00000000..58b50f8c --- /dev/null +++ b/hooks/useSearchParamsManager.jsx @@ -0,0 +1,43 @@ +import { useCallback } from 'react'; +import { useSearchParams } from 'next/navigation'; +import { useRouter } from 'next/router'; + +export default function useSearchParamsManager() { + const { push } = useRouter(); + const searchParams = useSearchParams(); + + const getSearchParams = useCallback( + (key) => + key + ? (searchParams.get(key) ?? '').split(',').filter(Boolean) + : Object.fromEntries(searchParams.entries()), + [searchParams], + ); + + const pushState = useCallback( + (key, value) => { + const query = Object.fromEntries(searchParams.entries()); + if (value) query[key] = value; + else delete query[key]; + push({ query }, undefined, { scroll: false }); + }, + [push, searchParams], + ); + + const generateParamsItems = useCallback( + (arr, keyObj = {}) => { + if (!Array.isArray(arr)) return []; + return arr.reduce((acc, param) => { + const values = getSearchParams(param).filter((value) => + keyObj[param] === 'PASS_STRING' + ? value + : keyObj[param]?.includes(value), + ); + return [...acc, { key: param, values }]; + }, []); + }, + [searchParams], + ); + + return [getSearchParams, pushState, generateParamsItems]; +} diff --git a/jsconfig.json b/jsconfig.json new file mode 100644 index 00000000..2a2e4b3b --- /dev/null +++ b/jsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "paths": { + "@/*": ["./*"] + } + } +} diff --git a/next.config.js b/next.config.js index 43ca88ab..36d314ca 100644 --- a/next.config.js +++ b/next.config.js @@ -1,3 +1,5 @@ +const isDev = process.env.NODE_ENV === 'development'; + const withPWA = require('next-pwa')({ dest: 'public', }); @@ -5,11 +7,23 @@ const withPWA = require('next-pwa')({ module.exports = withPWA({ reactStrictMode: false, images: { - domains: ['imgur.com'], + domains: ['imgur.com', 'images.unsplash.com', 'lh3.googleusercontent.com'], }, env: { HOSTNAME: 'https://www.daoedu.tw', }, + ...(isDev + ? { + async rewrites() { + return [ + { + source: '/dev-proxy-api/:path*', + destination: `${process.env.NEXT_PUBLIC_API_URL}/:path*`, + }, + ]; + }, + } + : {}), // async redirects() { // return [ // { diff --git a/package.json b/package.json index f4ce5b13..117e5699 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "konva": "^7.0.0", "localforage": "^1.10.0", "moment": "^2.29.4", - "next": "^13.0.3", + "next": "^13.5.1", "next-pwa": "^5.6.0", "next-sitemap": "^1.6.203", "node-fetch": "^2.6.1", @@ -62,17 +62,21 @@ "react-typed": "^1.2.0", "redux": "^4.1.0", "redux-logger": "^3.0.6", + "redux-persist": "^6.0.0", "redux-saga": "^1.1.3", "regenerator-runtime": "^0.13.9", - "use-image": "^1.0.10" + "use-image": "^1.0.10", + "zod": "^3.22.4" }, "devDependencies": { "@emotion/babel-plugin": "^11.9.2", "@next/eslint-plugin-next": "^13.2.1", "@types/chrome": "^0.0.206", + "babel-plugin-import": "^1.13.8", "eslint": "^8.35.0", "eslint-config-airbnb": "^18.2.1", "eslint-config-prettier": "^8.6.0", + "eslint-import-resolver-alias": "^1.1.2", "eslint-plugin-html": "^6.1.2", "eslint-plugin-import": "^2.26.0", "eslint-plugin-jest": "^27.2.1", diff --git a/pages/404.jsx b/pages/404.jsx index 23d59198..69e222ca 100644 --- a/pages/404.jsx +++ b/pages/404.jsx @@ -145,15 +145,7 @@ const NotExistPage = () => { margin: '20px 0', }} > - diff --git a/pages/_app.jsx b/pages/_app.jsx index 4c00e61b..8697a183 100644 --- a/pages/_app.jsx +++ b/pages/_app.jsx @@ -2,19 +2,25 @@ import React, { useEffect, useMemo } from 'react'; import { ThemeProvider } from '@mui/material/styles'; import CssBaseline from '@mui/material/CssBaseline'; import { Toaster } from 'react-hot-toast'; -import { Provider, useSelector } from 'react-redux'; +import { Provider, useDispatch, useSelector } from 'react-redux'; import { useRouter } from 'next/router'; import Script from 'next/script'; import Head from 'next/head'; import { initializeApp } from 'firebase/app'; -import GlobalStyle from '../shared/styles/Global'; -import themeFactory from '../shared/styles/themeFactory'; -import storeFactory from '../redux/store'; +import { persistStore } from 'redux-persist'; +import { PersistGate } from 'redux-persist/integration/react'; +import SnackbarProvider from '@/contexts/Snackbar'; +import GlobalStyle from '@/shared/styles/Global'; +import themeFactory from '@/shared/styles/themeFactory'; +import storeFactory from '@/redux/store'; +import { checkLoginValidity } from '@/redux/actions/user'; import { initGA, logPageView } from '../utils/analytics'; import Mode from '../shared/components/Mode'; import 'regenerator-runtime/runtime'; // Speech.js const store = storeFactory(); +const persistor = persistStore(store); + const firebaseConfig = { apiKey: 'AIzaSyBJK-FKcGHwDy1TMcoJcBdEqbTYpEquUi4', authDomain: 'daodaoedu-4ae8f.firebaseapp.com', @@ -92,18 +98,29 @@ const App = ({ Component, pageProps }) => { href="https://www.daoedu.tw/rss/feed.xml" /> + - + + + + + ); }; const ThemeComponentWrap = ({ pageProps, Component }) => { + const dispatch = useDispatch(); const firebaseApp = initializeApp(firebaseConfig); const mode = useSelector((state) => state?.theme?.mode ?? 'light'); const theme = useMemo(() => themeFactory(mode), [mode]); const isEnv = useMemo(() => process.env.NODE_ENV === 'development', []); + + useEffect(() => { + dispatch(checkLoginValidity()); + }, []); + return ( {/* mui normalize css */} diff --git a/pages/group/create/index.jsx b/pages/group/create/index.jsx new file mode 100644 index 00000000..a251aeae --- /dev/null +++ b/pages/group/create/index.jsx @@ -0,0 +1,49 @@ +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'; +import Navigation from '@/shared/components/Navigation_v2'; +import Footer from '@/shared/components/Footer_v2'; + +const GroupForm = dynamic(() => import('@/components/Group/Form'), { + ssr: false, +}); + +function CreateGroupPage() { + const { pushSnackbar } = useSnackbar(); + const router = useRouter(); + const SEOData = useMemo( + () => ({ + title: '發起揪團|島島阿學', + description: + '「島島阿學」揪團專區,結交志同道合的學習夥伴!發起各種豐富多彩的揪團活動,共同探索學習的樂趣。一同參與,共同成長,打造學習的共好社群。加入我們,一起開啟學習的冒險旅程!', + keywords: '島島阿學', + author: '島島阿學', + copyright: '島島阿學', + imgLink: 'https://www.daoedu.tw/preview.webp', + link: `${process.env.HOSTNAME}${router?.asPath}`, + }), + [router?.asPath], + ); + + const { mutate, isLoading } = useMutation('/activity', { + method: 'POST', + onSuccess: () => { + pushSnackbar({ message: '已成功發布揪團' }); + router.replace('/profile?id=my-group'); + }, + }); + + return ( + <> + + + +