diff --git a/components/Group/Form/Fields/AreaCheckbox.jsx b/components/Group/Form/Fields/AreaCheckbox.jsx index 17feb88c..13030b8e 100644 --- a/components/Group/Form/Fields/AreaCheckbox.jsx +++ b/components/Group/Form/Fields/AreaCheckbox.jsx @@ -57,12 +57,14 @@ export default function AreaCheckbox({ } label="實體活動" + disabled={value.includes('待討論')} checked={isPhysicalArea} /> handleCheckboxChange('線上')} />} label="線上" + disabled={value.includes('待討論')} checked={value.includes('線上')} /> @@ -81,6 +84,7 @@ export default function AreaCheckbox({ control={ handleCheckboxChange('待討論')} />} label="待討論" checked={value.includes('待討論')} + disabled={value.some((item) => item !== '待討論')} /> > diff --git a/components/Group/Form/Fields/CheckboxGroup.jsx b/components/Group/Form/Fields/CheckboxGroup.jsx new file mode 100644 index 00000000..3b7ab1db --- /dev/null +++ b/components/Group/Form/Fields/CheckboxGroup.jsx @@ -0,0 +1,41 @@ +import Box from '@mui/material/Box'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import Checkbox from '@mui/material/Checkbox'; + +export default function CheckboxGroup({ + options, + name, + value, + control, + handleValues, +}) { + const handleCheckboxChange = (_value) => { + const hasValue = value.includes(_value); + const updatedValue = hasValue + ? value.filter((v) => v !== _value) + : [...value, _value]; + const newValue = handleValues( + hasValue ? 'remove' : 'add', + _value, + updatedValue, + ); + + control.onChange({ target: { name, value: newValue } }); + }; + + return ( + + {options.map((option) => ( + handleCheckboxChange(option.value)} /> + } + label={option.label} + checked={value.includes(option.value)} + /> + ))} + + ); +} diff --git a/components/Group/Form/Fields/DateRadio.jsx b/components/Group/Form/Fields/DateRadio.jsx new file mode 100644 index 00000000..40ff4167 --- /dev/null +++ b/components/Group/Form/Fields/DateRadio.jsx @@ -0,0 +1,68 @@ +import dayjs from 'dayjs'; +import { useEffect, useState } from 'react'; +import Box from '@mui/material/Box'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import Checkbox from '@mui/material/Checkbox'; +import TextField from '@mui/material/TextField'; +import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; +import { MobileDatePicker } from '@mui/x-date-pickers/MobileDatePicker'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; + +export default function DateRadio({ + name, + customValueName, + value, + isCustomValue, + control, +}) { + const [isCustomDate, setIsCustomDate] = useState(isCustomValue); + const [date, setDate] = useState(value); + + useEffect(() => { + control.onChange({ target: { name, value: date } }); + control.onChange({ + target: { name: customValueName, value: isCustomDate }, + }); + }, [name, date, customValueName, isCustomDate]); + + return ( + + + setIsCustomDate(true)} />} + label="自訂" + checked={isCustomDate} + /> + setIsCustomDate(true)} + minDate={dayjs().add(1, 'day')} + maxDate={dayjs().add(4, 'year')} + renderInput={(params) => ( + + )} + /> + + + setIsCustomDate(false)} />} + label="不限" + checked={!isCustomDate} + /> + + + ); +} diff --git a/components/Group/Form/Fields/Select.jsx b/components/Group/Form/Fields/Select.jsx index b63a28e3..ba48d2de 100644 --- a/components/Group/Form/Fields/Select.jsx +++ b/components/Group/Form/Fields/Select.jsx @@ -1,4 +1,3 @@ -import { useState } from 'react'; import FormControl from '@mui/material/FormControl'; import MuiSelect from '@mui/material/Select'; import MenuItem from '@mui/material/MenuItem'; @@ -27,6 +26,7 @@ export default function Select({ return ( control.setRef?.(name, element)} displayEmpty multiple={multiple} fullWidth={fullWidth} diff --git a/components/Group/Form/Fields/TagsField.jsx b/components/Group/Form/Fields/TagsField.jsx index 79ccee0f..cb54c6c4 100644 --- a/components/Group/Form/Fields/TagsField.jsx +++ b/components/Group/Form/Fields/TagsField.jsx @@ -60,6 +60,7 @@ function TagsField({ name, helperText, control, value = [] }) { ))} {value.length < 8 && ( control.setRef?.(name, element)} value={input} onCompositionStart={() => { isComposing.current = true; diff --git a/components/Group/Form/Fields/TextField.jsx b/components/Group/Form/Fields/TextField.jsx index edf16965..7409e59a 100644 --- a/components/Group/Form/Fields/TextField.jsx +++ b/components/Group/Form/Fields/TextField.jsx @@ -13,6 +13,7 @@ export default function TextField({ return ( <> control.setRef?.(name, element)} fullWidth id={id} name={name} @@ -21,7 +22,7 @@ export default function TextField({ placeholder={placeholder} value={value} multiline={multiline} - rows={multiline && 10} + rows={multiline && 6} helperText={helperText} {...control} /> diff --git a/components/Group/Form/Fields/index.jsx b/components/Group/Form/Fields/index.jsx index f8383205..629cb82d 100644 --- a/components/Group/Form/Fields/index.jsx +++ b/components/Group/Form/Fields/index.jsx @@ -5,6 +5,8 @@ import TagsField from './TagsField'; import TextField from './TextField'; import Upload from './Upload'; import Wrapper from './Wrapper'; +import CheckboxGroup from './CheckboxGroup'; +import DateRadio from './DateRadio'; const withWrapper = (Component) => (props) => { const id = useId(); @@ -25,6 +27,8 @@ const withWrapper = (Component) => (props) => { const Fields = { AreaCheckbox: withWrapper(AreaCheckbox), + CheckboxGroup: withWrapper(CheckboxGroup), + DateRadio: withWrapper(DateRadio), Select: withWrapper(Select), TagsField: withWrapper(TagsField), TextField: withWrapper(TextField), diff --git a/components/Group/Form/Form.styled.jsx b/components/Group/Form/Form.styled.jsx index 634d1de6..f527d2c4 100644 --- a/components/Group/Form/Form.styled.jsx +++ b/components/Group/Form/Form.styled.jsx @@ -43,7 +43,9 @@ export const StyledLabel = styled(InputLabel)` `; export const StyledGroup = styled.div` - margin-bottom: 20px; + & + & { + margin-top: 20px; + } .error-message { font-size: 14px; diff --git a/components/Group/Form/index.jsx b/components/Group/Form/index.jsx index ab635d72..e3e71c94 100644 --- a/components/Group/Form/index.jsx +++ b/components/Group/Form/index.jsx @@ -1,8 +1,13 @@ -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; +import Link from 'next/link'; +import styled from '@emotion/styled'; 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 Checkbox from '@mui/material/Checkbox'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import { activityCategoryList } from '@/constants/activityCategory'; import StyledPaper from '../Paper.styled'; import { StyledHeading, @@ -18,6 +23,16 @@ import useGroupForm, { eduOptions, } from './useGroupForm'; +const StyledDesc = styled.p` + font-size: 14px; + color: #92989a; + + a { + color: #92989a; + text-decoration: underline; + } +`; + export default function GroupForm({ mode, defaultValues, @@ -33,6 +48,7 @@ export default function GroupForm({ setValues, handleSubmit, } = useGroupForm(); + const [isChecked, setIsChecked] = useState(false); const isCreateMode = mode === 'create'; useEffect(() => { @@ -43,6 +59,19 @@ export default function GroupForm({ }); }, [defaultValues]); + const desc = ( + + 請確認揪團未涉及不雅內容並符合本網站{' '} + + 使用者條款 + + + ); + + const checkbox = ( + setIsChecked((pre) => !pre)} /> + ); + if (notLogin) { return ; } @@ -72,6 +101,22 @@ export default function GroupForm({ value={values.photoURL} control={control} /> + { + if (action === 'add' && value === 'Other') { + return ['Other']; + } + if (action === 'remove' && !activityCategory.length) { + return ['Other']; + } + return activityCategory.filter((item) => item !== 'Other'); + }} + control={control} + value={values.activityCategory} + options={activityCategoryList} + /> + - + + + + @@ -142,6 +225,16 @@ export default function GroupForm({ helperText="標籤填寫完成後,會用 Hashtag 的形式呈現,例如: #一起學日文" /> + + + {!isCreateMode && ( @@ -158,10 +251,19 @@ export default function GroupForm({ )} + + + + + {isCreateMode ? '送出' : '發布修改'} diff --git a/components/Group/Form/useGroupForm.jsx b/components/Group/Form/useGroupForm.jsx index cf5134fc..1088a367 100644 --- a/components/Group/Form/useGroupForm.jsx +++ b/components/Group/Form/useGroupForm.jsx @@ -1,4 +1,5 @@ -import { useEffect, useState } from 'react'; +import dayjs from 'dayjs'; +import { useEffect, useRef, useState } from 'react'; import { useSelector } from 'react-redux'; import { ZodType, z } from 'zod'; import { CATEGORIES } from '@/constants/category'; @@ -6,6 +7,7 @@ import { AREAS } from '@/constants/areas'; import { EDUCATION_STEP } from '@/constants/member'; import { BASE_URL } from '@/constants/common'; import openLoginWindow from '@/utils/openLoginWindow'; +import { activityCategoryList } from '@/constants/activityCategory'; const _eduOptions = EDUCATION_STEP.filter( (edu) => !['master', 'doctor', 'other'].includes(edu.value), @@ -23,12 +25,19 @@ const DEFAULT_VALUES = { originPhotoURL: '', photoURL: '', photoAlt: '', + activityCategory: ['Other'], category: [], + participator: '', area: [], time: '', partnerStyle: '', partnerEducationStep: [], - description: '', + motivation: '', + content: '', + outcome: '', + notice: '', + deadline: dayjs().add(7, 'day'), + isNeedDeadline: false, tagList: [], isGrouping: true, }; @@ -39,19 +48,33 @@ const rules = { file: z.any(), photoURL: z.string().or(z.instanceof(Blob)), photoAlt: z.string(), + activityCategory: z.array( + z.enum(activityCategoryList.map(({ value }) => value)), + ), category: z .array(z.enum(categoriesOptions.map(({ value }) => value))) .min(1, '請選擇學習領域'), + participator: z + .string() + .regex(/^(100|[1-9]?\d)$/, '請輸入整數,需大於 0,不可超過 100'), area: z.array(z.string()).min(1, '請選擇地點'), time: z.string().max(50, '請勿輸入超過 50 字'), - partnerStyle: z.string().max(50, '請勿輸入超過 50 字'), + partnerStyle: z + .string() + .max(50, '請勿輸入超過 50 字') + .min(1, '請輸入想找的夥伴類型'), partnerEducationStep: z .array(z.enum(eduOptions.map(({ label }) => label))) - .min(1, '請選擇適合的學習階段'), - description: z + .min(1, '請選擇適合的教育階段'), + motivation: z.string().max(50, '請勿輸入超過 50 字').min(1, '請輸入揪團動機'), + content: z .string() - .min(1, '請輸入揪團描述') + .min(1, '請輸入揪團內容與運作方式') .max(2000, '請勿輸入超過 2000 字'), + outcome: z.string().max(50, '請勿輸入超過 50 字').min(1, '請輸入期待成果'), + notice: z.string().min(1, '請輸入注意事項').max(2000, '請勿輸入超過 2000 字'), + deadline: z.any(), + isNeedDeadline: z.boolean(), tagList: z.array(z.string()), isGrouping: z.boolean(), }; @@ -65,6 +88,7 @@ export default function useGroupForm() { userId: me?._id, }); const [errors, setErrors] = useState({}); + const refs = useRef({}); const schema = z.object(rules); const onChange = ({ target }) => { @@ -84,19 +108,31 @@ export default function useGroupForm() { }; const onBlur = onChange; + const setRef = (name, element) => { + refs.current[name] = element; + }; const control = { + setRef, onChange, onBlur, }; const handleSubmit = (onValid) => async () => { if (!schema.safeParse(values).success) { + let isFocus = false; const updatedErrors = Object.fromEntries( - Object.entries(rules).map(([key, rule]) => [ - key, - rule.safeParse(values[key]).error?.issues?.[0]?.message, - ]), + Object.entries(rules).map(([key, rule]) => { + const errorMessage = rule.safeParse(values[key]).error?.issues?.[0] + ?.message; + + if (errorMessage && !isFocus) { + isFocus = true; + refs.current[key]?.focus(); + } + + return [key, errorMessage]; + }), ); setErrors(updatedErrors); return; diff --git a/components/Group/GroupList/GroupCard.jsx b/components/Group/GroupList/GroupCard.jsx index 57501760..cc337e5c 100644 --- a/components/Group/GroupList/GroupCard.jsx +++ b/components/Group/GroupList/GroupCard.jsx @@ -21,7 +21,7 @@ function GroupCard({ title = '未定義主題', category = [], partnerEducationStep, - description, + content, area, isGrouping, updatedDate, @@ -47,8 +47,8 @@ function GroupCard({ {formatToString(partnerEducationStep, '皆可')} - - {description} + + {content} diff --git a/components/Group/SearchField/SelectedActivityCategoryStep.jsx b/components/Group/SearchField/SelectedActivityCategoryStep.jsx new file mode 100644 index 00000000..92a6512e --- /dev/null +++ b/components/Group/SearchField/SelectedActivityCategoryStep.jsx @@ -0,0 +1,36 @@ +import Select from '@/shared/components/Select'; +import useSearchParamsManager from '@/hooks/useSearchParamsManager'; +import { activityCategoryList } from '@/constants/activityCategory'; + +export default function SelectedActivityCategoryStep() { + const QUERY_KEY = 'activityCategory'; + const [getSearchParams, pushState] = useSearchParamsManager(); + + const handleChange = ({ target: { value } }) => { + pushState(QUERY_KEY, value.toString()); + }; + + return ( + + selected.length === 0 + ? '揪團類型' + : activityCategoryList + .filter((item) => selected.includes(item.value)) + .map((item) => item.label) + .join('、') + } + sx={{ + '@media (max-width: 767px)': { + width: '100%', + }, + }} + /> + ); +} diff --git a/components/Group/SearchField/SelectedAreas.jsx b/components/Group/SearchField/SelectedAreas.jsx index 25354144..7ccea24d 100644 --- a/components/Group/SearchField/SelectedAreas.jsx +++ b/components/Group/SearchField/SelectedAreas.jsx @@ -2,6 +2,8 @@ import Select from '@/shared/components/Select'; import { AREAS } from '@/constants/areas'; import useSearchParamsManager from '@/hooks/useSearchParamsManager'; +const AREAS_WITH_TBD = AREAS.concat({ name: '待討論', label: '待討論' }); + export default function SelectedAreas() { const QUERY_KEY = 'area'; const [getSearchParams, pushState] = useSearchParamsManager(); @@ -15,7 +17,7 @@ export default function SelectedAreas() { multiple value={getSearchParams(QUERY_KEY)} onChange={handleChange} - items={AREAS} + items={AREAS_WITH_TBD} renderValue={(selected) => selected.length === 0 ? '地點' : selected.join('、') } diff --git a/components/Group/SearchField/index.jsx b/components/Group/SearchField/index.jsx index 35f7a40e..dff729de 100644 --- a/components/Group/SearchField/index.jsx +++ b/components/Group/SearchField/index.jsx @@ -1,6 +1,7 @@ import styled from '@emotion/styled'; import SearchInput from './SearchInput'; import SelectedAreas from './SelectedAreas'; +import SelectedActivityCategoryStep from './SelectedActivityCategoryStep'; import SelectedEducationStep from './SelectedEducationStep'; import CheckboxGrouping from './CheckboxGrouping'; @@ -14,6 +15,12 @@ const StyledSearchField = styled.div` align-items: center; gap: 16px; + @media (max-width: 1024px) { + > * { + max-width: 180px; + } + } + @media (max-width: 767px) { margin: 10px 0; flex-direction: column; @@ -28,6 +35,7 @@ const SearchField = () => { + diff --git a/components/Group/detail/Contact/ContactPopup.jsx b/components/Group/detail/Contact/ContactPopup.jsx index 491062db..5feb215d 100644 --- a/components/Group/detail/Contact/ContactPopup.jsx +++ b/components/Group/detail/Contact/ContactPopup.jsx @@ -1,4 +1,5 @@ import { useId, useState } from 'react'; +import Link from 'next/link'; import styled from '@emotion/styled'; import { Avatar, @@ -11,6 +12,8 @@ import { TextareaAutosize, useMediaQuery, } from '@mui/material'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import Checkbox from '@mui/material/Checkbox'; import CloseIcon from '@mui/icons-material/Close'; import { ROLE } from '@/constants/member'; import TransitionSlide from './TransitionSlide'; @@ -34,6 +37,26 @@ const StyledTextArea = styled(TextareaAutosize)` min-height: 128px; `; +const StyledDesc = styled.p` + font-size: 14px; + color: #92989a; + + a { + color: #92989a; + text-decoration: underline; + } +`; + +const desc = ( + + 您填的資訊將透過島島阿學 email + 給這位夥伴,請確認訊息未涉及個人隱私並符合本網站{' '} + + 使用者條款 + + +); + function ContactPopup({ open, user, @@ -47,6 +70,7 @@ function ContactPopup({ const isMobileScreen = useMediaQuery('(max-width: 560px)'); const [message, setMessage] = useState(''); const [contact, setContact] = useState(''); + const [isChecked, setIsChecked] = useState(false); const id = useId(); const titleId = `modal-title-${id}`; const descriptionId = `modal-description-${id}`; @@ -65,6 +89,10 @@ function ContactPopup({ onSubmit({ message, contact }); }; + const checkbox = ( + setIsChecked((pre) => !pre)} /> + ); + return ( + + + + 送出 diff --git a/components/Group/detail/OrganizerCard.jsx b/components/Group/detail/OrganizerCard.jsx index d29903b8..62d79bf4 100644 --- a/components/Group/detail/OrganizerCard.jsx +++ b/components/Group/detail/OrganizerCard.jsx @@ -1,3 +1,4 @@ +import Link from 'next/link'; import styled from '@emotion/styled'; import Skeleton from '@mui/material/Skeleton'; import Avatar from '@mui/material/Avatar'; @@ -5,7 +6,7 @@ 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'; -import Link from 'next/link'; +import TextWithLinks from '@/shared/components/TextWithLinks'; const StyledHeader = styled.div` display: flex; @@ -45,7 +46,7 @@ const StyledTag = styled.div` `; const StyledTags = styled.div` - margin-top: 10px; + margin-top: 20px; margin-bottom: 20px; display: flex; flex-wrap: wrap; @@ -125,7 +126,12 @@ function OrganizerCard({ data = {}, isLoading }) { ) : ( - data?.description + + {data?.motivation} + {data?.content} + {data?.outcome} + {data?.notice} + )} diff --git a/components/Group/detail/TeamInfoCard.jsx b/components/Group/detail/TeamInfoCard.jsx index 27c98b8f..f3979aee 100644 --- a/components/Group/detail/TeamInfoCard.jsx +++ b/components/Group/detail/TeamInfoCard.jsx @@ -1,6 +1,7 @@ import styled from '@emotion/styled'; import Skeleton from '@mui/material/Skeleton'; import bachelorCapSvg from '@/public/assets/icons/bachelorCap.svg'; +import activityCategorySvg from '@/public/assets/icons/activityCategory.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'; @@ -55,12 +56,18 @@ const labels = [ icon: categorySvg.src, text: '學習領域', }, + { + key: 'activityCategory', + icon: activityCategorySvg.src, + text: '揪團類型', + }, { key: 'area', icon: locationSvg.src, text: '地點', }, { key: 'time', icon: clockSvg.src, text: '時間' }, + { key: 'participator', icon: personSvg.src, text: '徵求人數' }, { key: 'partnerStyle', icon: personSvg.src, text: '想找的夥伴' }, { key: 'partnerEducationStep', diff --git a/components/Profile/MyGroup/GroupCard.jsx b/components/Profile/MyGroup/GroupCard.jsx index f5dba38b..f2ed64b0 100644 --- a/components/Profile/MyGroup/GroupCard.jsx +++ b/components/Profile/MyGroup/GroupCard.jsx @@ -28,7 +28,7 @@ function GroupCard({ photoURL, photoAlt, title = '未定義主題', - description, + content, area, isGrouping, userId, @@ -86,8 +86,8 @@ function GroupCard({ {title} - - {description} + + {content} diff --git a/constants/activityCategory.js b/constants/activityCategory.js new file mode 100644 index 00000000..09547e38 --- /dev/null +++ b/constants/activityCategory.js @@ -0,0 +1,11 @@ +export const activityCategoryList = [ + { label: '讀書會', value: 'BookClub' }, + { label: '工作坊', value: 'Workshop' }, + { label: '專案', value: 'Project' }, + { label: '競賽', value: 'Competition' }, + { label: '活動', value: 'Activity' }, + { label: '社團', value: 'Club' }, + { label: '課程', value: 'Course' }, + { label: '實習', value: 'Internship' }, + { label: '其他', value: 'Other' }, +]; diff --git a/pages/group/detail/index.jsx b/pages/group/detail/index.jsx index db41b9a2..dde5606f 100644 --- a/pages/group/detail/index.jsx +++ b/pages/group/detail/index.jsx @@ -11,7 +11,10 @@ function GroupPage() { const { data, isFetching, isError } = useFetch(`/activity/${id}`, { enabled: !!id, }); - const source = data?.data?.[0]; + const source = { + ...data?.data?.[0], + content: data?.data?.[0]?.content || data?.data?.[0]?.description, + }; const SEOData = useMemo( () => ({ diff --git a/pages/group/edit/index.jsx b/pages/group/edit/index.jsx index 956ce3d1..3aa2154b 100644 --- a/pages/group/edit/index.jsx +++ b/pages/group/edit/index.jsx @@ -20,7 +20,10 @@ function EditGroupPage() { const { data, isFetching } = useFetch(`/activity/${id}`, { enabled: !!id, }); - const source = data?.data?.[0]; + const source = { + ...data?.data?.[0], + content: data?.data?.[0]?.content || data?.data?.[0]?.description, + }; const SEOData = useMemo( () => ({ diff --git a/public/assets/icons/activityCategory.svg b/public/assets/icons/activityCategory.svg new file mode 100644 index 00000000..3fd6bb79 --- /dev/null +++ b/public/assets/icons/activityCategory.svg @@ -0,0 +1,4 @@ + + + + diff --git a/redux/actions/group.js b/redux/actions/group.js index 32862c6a..04cf1873 100644 --- a/redux/actions/group.js +++ b/redux/actions/group.js @@ -29,7 +29,12 @@ export function getGroupItemsSuccess({ data = [], totalCount = 0 } = {}) { return { type: GET_GROUP_ITEMS_SUCCESS, payload: { - items: data, + items: Array.isArray(data) + ? data.map((item) => ({ + ...item, + content: item.content || item.description, + })) + : [], total: totalCount, }, }; diff --git a/redux/sagas/groupSaga.js b/redux/sagas/groupSaga.js index 2e122183..909e415d 100644 --- a/redux/sagas/groupSaga.js +++ b/redux/sagas/groupSaga.js @@ -2,6 +2,7 @@ import { put, takeLatest, select } from 'redux-saga/effects'; import { AREAS } from '@/constants/areas'; import { CATEGORIES } from '@/constants/category'; import { EDUCATION_STEP } from '@/constants/member'; +import { activityCategoryList } from '@/constants/activityCategory'; import req from '@/utils/request'; import { @@ -18,26 +19,31 @@ function* getGroupItems() { } = yield select(); const urlSearchParams = new URLSearchParams({ pageSize }); - const searchParamsOptions = { - area: AREAS, - category: CATEGORIES, - partnerEducationStep: EDUCATION_STEP, + const searchParamsConfigs = { + area: [AREAS, 'label'], + category: [CATEGORIES, 'label'], + activityCategory: [activityCategoryList, 'value'], + partnerEducationStep: [EDUCATION_STEP, 'label'], isGrouping: true, search: true, }; - Object.keys(searchParamsOptions).forEach((key) => { + Object.keys(searchParamsConfigs).forEach((key) => { const searchParam = query[key]; - const option = searchParamsOptions[key]; + const config = searchParamsConfigs[key]; - if (!searchParam || !option) return; + if (!searchParam || !config) return; + + if (Array.isArray(config)) { + const [options, optionKey] = config; - if (Array.isArray(option)) { urlSearchParams.append( key, searchParam .split(',') - .filter((item) => option.some((_option) => _option.label === item)) + .filter((item) => + options.some((_option) => _option[optionKey] === item), + ) .join(','), ); } else { diff --git a/shared/components/TextWithLinks.jsx b/shared/components/TextWithLinks.jsx new file mode 100644 index 00000000..3b6b7c40 --- /dev/null +++ b/shared/components/TextWithLinks.jsx @@ -0,0 +1,204 @@ +import { useState, forwardRef, useId } from 'react'; +import Link from 'next/link'; +import styled from '@emotion/styled'; +import { + Dialog, + DialogTitle, + Box, + Button, + Slide, + Typography, + useMediaQuery, + FormControlLabel, + Checkbox, +} from '@mui/material'; +import { getTrustWebsitesStorage } from '@/utils/storage'; + +const TransitionSlide = forwardRef((props, ref) => { + return ; +}); + +const StyledText = styled.p` + a { + color: #1a73e8; + } +`; + +export default function TextWithLinks({ children }) { + const id = useId(); + const isMobileScreen = useMediaQuery('(max-width: 560px)'); + const [externalLink, setExternalLink] = useState(null); + const [isTrust, setIsTrust] = useState(false); + const titleId = `modal-title-${id}`; + const descriptionId = `modal-description-${id}`; + const urlRegex = /(https:\/\/[^\s]+)/g; + const text = typeof children === 'string' ? children : ''; + + const checkbox = ( + setIsTrust((pre) => !pre)} /> + ); + + const handleClose = () => { + setExternalLink(null); + setIsTrust(false); + }; + + const handleGoToWebsite = () => { + const trustWebsites = getTrustWebsitesStorage().get(); + const data = Array.isArray(trustWebsites) ? trustWebsites : []; + + if (isTrust && externalLink) data.push(externalLink.hostname); + + getTrustWebsitesStorage().set(data); + handleClose(); + }; + + const parts = text.split(urlRegex).map((part) => { + if (!urlRegex.test(part)) return part; + + try { + const link = new URL(part); + const href = decodeURI(link.href); + + if (window.location.hostname === href.hostname) { + return ( + + {href} + + ); + } + + const trustWebsites = getTrustWebsitesStorage().get(); + const data = Array.isArray(trustWebsites) ? trustWebsites : []; + + return ( + { + if (data.includes(link.hostname)) return; + e.preventDefault(); + setExternalLink(link); + }} + > + {href} + + ); + } catch { + return part; + } + }); + + return ( + + {parts} + + + + 正在離開島島阿學 + + {externalLink && ( + <> + + 這個連結將帶您前往以下網站 + + {decodeURI(externalLink.href)} + + + + {`從現在開始信任 ${externalLink.hostname} 連結`} + } + checked={isTrust} + /> + + + + 前往網站 + + + 返回 + + + > + )} + + + ); +} diff --git a/utils/storage.js b/utils/storage.js index 4c2901dd..eb41e6f2 100644 --- a/utils/storage.js +++ b/utils/storage.js @@ -16,4 +16,5 @@ export default function createStorage(key, storage = localStorage) { return { set, get, remove }; } -export const getRedirectionStorage = () => createStorage('_redirection'); +export const getRedirectionStorage = () => createStorage('_r'); +export const getTrustWebsitesStorage = () => createStorage('_trustWeb');