diff --git a/components/Group/Form/Fields/AreaCheckbox.jsx b/components/Group/Form/Fields/AreaCheckbox.jsx
index ccfaa3b6..17feb88c 100644
--- a/components/Group/Form/Fields/AreaCheckbox.jsx
+++ b/components/Group/Form/Fields/AreaCheckbox.jsx
@@ -1,3 +1,4 @@
+import { useEffect, useState } from 'react';
import Box from '@mui/material/Box';
import FormControlLabel from '@mui/material/FormControlLabel';
import Checkbox from '@mui/material/Checkbox';
@@ -9,27 +10,78 @@ export default function AreaCheckbox({
itemValue,
name,
value,
- onChange,
+ 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="實體活動" />
+ }
+ label="實體活動"
+ checked={isPhysicalArea}
+ />
- } label="線上" />
+ handleCheckboxChange('線上')} />}
+ label="線上"
+ checked={value.includes('線上')}
+ />
- } label="待討論" />
+ handleCheckboxChange('待討論')} />}
+ label="待討論"
+ checked={value.includes('待討論')}
+ />
>
);
diff --git a/components/Group/Form/Fields/Select.jsx b/components/Group/Form/Fields/Select.jsx
index 2b7c9843..b63a28e3 100644
--- a/components/Group/Form/Fields/Select.jsx
+++ b/components/Group/Form/Fields/Select.jsx
@@ -1,3 +1,4 @@
+import { useState } from 'react';
import FormControl from '@mui/material/FormControl';
import MuiSelect from '@mui/material/Select';
import MenuItem from '@mui/material/MenuItem';
@@ -5,14 +6,16 @@ import MenuItem from '@mui/material/MenuItem';
export default function Select({
id,
name,
- value,
placeholder,
options = [],
itemLabel = 'label',
fullWidth = true,
multiple,
- onChange,
sx,
+ disabled,
+ control,
+ value,
+ error,
}) {
const getValue = (any, key) => (typeof any === 'object' ? any[key] : any);
const renderValue = (selected) => {
@@ -37,7 +40,8 @@ export default function Select({
...sx,
}}
value={value}
- onChange={onChange}
+ disabled={disabled}
+ {...control}
>
{placeholder && (
))}
+ {error}
);
}
diff --git a/components/Group/Form/Fields/TagsField.jsx b/components/Group/Form/Fields/TagsField.jsx
index 052ba148..f5337ca4 100644
--- a/components/Group/Form/Fields/TagsField.jsx
+++ b/components/Group/Form/Fields/TagsField.jsx
@@ -5,35 +5,44 @@ import ClearIcon from '@mui/icons-material/Clear';
import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline';
import { StyledChip, StyledTagsField } from '../Form.styled';
-function TagsField({ label, helperText, ...props }) {
- const [tags, setTags] = useState([]);
+function TagsField({ name, helperText, control, value = [] }) {
const [input, setInput] = useState('');
const [error, setError] = useState('');
const handleInput = (e) => {
- const { value } = e.target;
- if (value.length > 8) setError('標籤最多 8 個字');
+ const _value = e.target.value;
+ if (_value.length > 8) setError('標籤最多 8 個字');
else setError('');
- setInput(value);
+ setInput(_value);
};
const handleKeyDown = (e) => {
if (error) return;
const tag = input.trim();
if (e.key !== 'Enter' || !tag) return;
- if (tags.indexOf(tag) > -1) return;
- setTags((pre) => [...pre, tag]);
+ if (value.indexOf(tag) > -1) return;
setInput('');
+ control.onChange({
+ target: {
+ name,
+ value: [...value, tag],
+ },
+ });
};
const handleDelete = (tag) => () => {
- setTags((pre) => pre.filter((t) => t !== tag));
+ control.onChange({
+ target: {
+ name,
+ value: value.filter((t) => t !== tag),
+ },
+ });
};
return (
<>
- {tags.map((tag) => (
+ {value.map((tag) => (
))}
- {tags.length < 8 && (
+ {value.length < 8 && (
{
- if (value.length > max) setError(errorMessage);
- else setError('');
- }, [max, value]);
-
return (
<>
{error}
>
diff --git a/components/Group/Form/Fields/Upload.jsx b/components/Group/Form/Fields/Upload.jsx
index e9baeeea..75309471 100644
--- a/components/Group/Form/Fields/Upload.jsx
+++ b/components/Group/Form/Fields/Upload.jsx
@@ -5,8 +5,8 @@ import DeleteSvg from '@/public/assets/icons/delete.svg';
import { StyledUpload } from '../Form.styled';
import UploadSvg from './UploadSvg';
-export default function Upload({ name, onChange }) {
- const [preview, setPreview] = useState('');
+export default function Upload({ name, value, control }) {
+ const [preview, setPreview] = useState(value || '');
const [error, setError] = useState('');
const inputRef = useRef();
@@ -17,17 +17,25 @@ export default function Upload({ name, onChange }) {
value: file,
},
};
- onChange(event);
+ 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);
diff --git a/components/Group/Form/Fields/index.jsx b/components/Group/Form/Fields/index.jsx
index 202c1f25..f8383205 100644
--- a/components/Group/Form/Fields/index.jsx
+++ b/components/Group/Form/Fields/index.jsx
@@ -1,56 +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';
-import useWrapperProps from './useWrapperProps';
-const Fields = {};
+const withWrapper = (Component) => (props) => {
+ const id = useId();
+ const formItemId = `form-item-${id}`;
+ const { required, label, tooltip } = props;
-Fields.AreaCheckbox = (props) => {
- const wrapperProps = useWrapperProps(props);
return (
-
-
+
+
);
};
-Fields.Select = (props) => {
- const wrapperProps = useWrapperProps(props);
- return (
-
-
-
- );
-};
-
-Fields.TagsField = (props) => {
- const wrapperProps = useWrapperProps(props);
- return (
-
-
-
- );
-};
-
-Fields.TextField = (props) => {
- const wrapperProps = useWrapperProps(props);
- return (
-
-
-
- );
-};
-
-Fields.Upload = (props) => {
- const wrapperProps = useWrapperProps(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/Fields/useWrapperProps.jsx b/components/Group/Form/Fields/useWrapperProps.jsx
deleted file mode 100644
index 80c00bce..00000000
--- a/components/Group/Form/Fields/useWrapperProps.jsx
+++ /dev/null
@@ -1,8 +0,0 @@
-import { useId } from 'react';
-
-export default function useWrapperProps({ required, label, tooltip }) {
- const id = useId();
- const formItemId = `form-item-${id}`;
-
- return { id: formItemId, required, label, tooltip };
-}
diff --git a/components/Group/Form/Form.styled.jsx b/components/Group/Form/Form.styled.jsx
index 6ec44c61..e05eeed1 100644
--- a/components/Group/Form/Form.styled.jsx
+++ b/components/Group/Form/Form.styled.jsx
@@ -67,6 +67,18 @@ export const StyledChip = styled(Chip)`
}
`;
+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;
diff --git a/components/Group/Form/index.jsx b/components/Group/Form/index.jsx
index b9cb30d2..103f6f81 100644
--- a/components/Group/Form/index.jsx
+++ b/components/Group/Form/index.jsx
@@ -1,10 +1,7 @@
-import { useEffect, useState } from 'react';
-import { useRouter } from 'next/router';
-import { useSelector } from 'react-redux';
+import { useEffect } from 'react';
import Box from '@mui/material/Box';
-import { CATEGORIES } from '@/constants/category';
-import { AREAS } from '@/constants/areas';
-import { EDUCATION_STEP } from '@/constants/member';
+import Switch from '@mui/material/Switch';
+import CircularProgress from '@mui/material/CircularProgress';
import Button from '@/shared/components/Button';
import StyledPaper from '../Paper.styled';
import {
@@ -12,40 +9,28 @@ import {
StyledDescription,
StyledContainer,
StyledFooter,
+ StyledSwitchWrapper,
} from './Form.styled';
import Fields from './Fields';
+import useGroupForm, {
+ areasOptions,
+ categoriesOptions,
+ eduOptions,
+} from './useGroupForm';
-const TaiwanAreas = AREAS.filter((area) => area.label !== '線上');
-const EduStep = EDUCATION_STEP.slice(0, 7);
-
-EduStep.push({ key: 'noLimit', value: 'noLimit', label: '不限' });
-
-export default function GroupForm({ mode }) {
- const router = useRouter();
- const user = useSelector((state) => state.user);
- const [resource, setResource] = useState({
- userId: user._id,
- title: '',
- photoURL: '',
- photoAlt: '',
- category: [],
- area: [],
- time: '',
- partnerStyle: '',
- partnerEducationStep: [],
- description: '',
- tagList: [],
- isGrouping: true,
- });
+export default function GroupForm({
+ mode,
+ defaultValues,
+ isLoading,
+ onSubmit,
+}) {
+ const { control, values, errors, isDirty, setValues, handleSubmit } =
+ useGroupForm();
const isCreateMode = mode === 'create';
- const handleChange = ({ target: { name, value } }) => {
- setResource((pre) => ({ ...pre, [name]: value }));
- };
-
useEffect(() => {
- if (!user?._id) router.push('/login');
- }, [user, router]);
+ if (defaultValues) setValues(defaultValues);
+ }, [defaultValues]);
return (
@@ -60,24 +45,25 @@ export default function GroupForm({ mode }) {
@@ -102,25 +90,28 @@ export default function GroupForm({ mode }) {
+ {!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..c6ca4dbd
--- /dev/null
+++ b/components/Group/Form/useGroupForm.jsx
@@ -0,0 +1,119 @@
+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';
+
+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,
+ photoURL: '',
+ photoAlt: '',
+ category: [],
+ area: [],
+ time: '',
+ partnerStyle: '',
+ partnerEducationStep: [],
+ description: '',
+ tagList: [],
+ isGrouping: true,
+};
+
+const rules = {
+ userId: z.string(),
+ title: z.string().min(1, '請輸入標題').max(50, '請勿輸入超過 50 字'),
+ file: z.any(),
+ photoURL: z.string(),
+ photoAlt: z.string(),
+ category: z
+ .array(z.enum(categoriesOptions.map(({ value }) => value)))
+ .min(1, '請選擇學習領域'),
+ area: z
+ .array(z.enum(areasOptions.map(({ name }) => name)))
+ .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) => () => {
+ if (schema.safeParse(values).success) {
+ onValid(values);
+ return;
+ }
+ const updatedErrors = Object.fromEntries(
+ Object.entries(rules).map(([key, rule]) => [
+ key,
+ rule.safeParse(values[key]).error?.issues?.[0]?.message,
+ ]),
+ );
+ setErrors(updatedErrors);
+ };
+
+ 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
index d93af04d..35d6834d 100644
--- a/components/Group/GroupList/GroupCard.jsx
+++ b/components/Group/GroupList/GroupCard.jsx
@@ -41,7 +41,7 @@ function GroupCard({
{partnerEducationStep || '皆可'}
-
+
{description}
diff --git a/components/Group/GroupList/GroupCard.styled.jsx b/components/Group/GroupList/GroupCard.styled.jsx
index 652c3b1a..e14a65e9 100644
--- a/components/Group/GroupList/GroupCard.styled.jsx
+++ b/components/Group/GroupList/GroupCard.styled.jsx
@@ -94,15 +94,8 @@ export const StyledGroupCard = styled(Link)`
position: relative;
background: #fff;
padding: 0.5rem;
- transition: transform 0.15s, box-shadow 0.15s;
border-radius: 4px;
- &:hover {
- z-index: 1;
- transform: scale(1.0125);
- box-shadow: 0 0 6px 2px #0001;
- }
-
img {
vertical-align: middle;
}
diff --git a/components/Group/GroupList/index.jsx b/components/Group/GroupList/index.jsx
index 0b077247..7f47f458 100644
--- a/components/Group/GroupList/index.jsx
+++ b/components/Group/GroupList/index.jsx
@@ -14,6 +14,14 @@ 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%;
diff --git a/components/Group/SearchField/SelectedEducationStep.jsx b/components/Group/SearchField/SelectedEducationStep.jsx
index 8fc66b42..d1f856da 100644
--- a/components/Group/SearchField/SelectedEducationStep.jsx
+++ b/components/Group/SearchField/SelectedEducationStep.jsx
@@ -2,6 +2,8 @@ import Select from '@/shared/components/Select';
import { EDUCATION_STEP } from '@/constants/member';
import useSearchParamsManager from '@/hooks/useSearchParamsManager';
+const EduStep = EDUCATION_STEP.slice(0, 7);
+
export default function SelectedEducationStep() {
const QUERY_KEY = 'partnerEducationStep';
const [getSearchParams, pushState] = useSearchParamsManager();
@@ -15,7 +17,7 @@ export default function SelectedEducationStep() {
multiple
value={getSearchParams(QUERY_KEY)}
onChange={handleChange}
- items={EDUCATION_STEP}
+ items={EduStep}
itemLabel="label"
itemValue="label"
renderValue={(selected) =>
diff --git a/components/Group/detail/Contact/index.jsx b/components/Group/detail/Contact/index.jsx
index bf9a4f6b..c45d412d 100644
--- a/components/Group/detail/Contact/index.jsx
+++ b/components/Group/detail/Contact/index.jsx
@@ -15,6 +15,7 @@ import {
useMediaQuery,
} from '@mui/material';
import CloseIcon from '@mui/icons-material/Close';
+import { BASE_URL } from '@/constants/common';
import { ROLE } from '@/constants/member';
import chatSvg from '@/public/assets/icons/chat.svg';
import Feedback from './Feedback';
@@ -43,18 +44,17 @@ const Transition = forwardRef((props, ref) => {
});
function ContactButton({
+ user,
children,
title,
description,
descriptionPlaceholder,
- onSubmit,
- onClose,
isLoading,
}) {
// 判斷是否登入
const id = useId();
const router = useRouter();
- const user = useSelector((state) => state.user);
+ const me = useSelector((state) => state.user);
const [open, setOpen] = useState(false);
const [message, setMessage] = useState('');
const [contact, setContact] = useState('');
@@ -68,21 +68,39 @@ function ContactButton({
ROLE.find(({ key }) => user?.roleList?.includes(key))?.label || '暫無資料';
const handleClose = () => {
- if (onClose) onClose();
setOpen(false);
setMessage('');
setContact('');
};
const handleSubmit = () => {
- if (onSubmit) onSubmit({ message, contact });
- handleClose();
- setFeedback('error');
+ fetch(`${BASE_URL}/email`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ ...user,
+ subject: '【島島阿學】點開 Email,認識新夥伴',
+ title: '你發起的揪團有人來信!',
+ to: user.email,
+ text: message,
+ information: [me.email || 'tutelary.maomao@gmail.com', contact],
+ }),
+ })
+ .then(() => {
+ handleClose();
+ setFeedback('success');
+ })
+ .catch(() => {
+ handleClose();
+ setFeedback('error');
+ });
};
useEffect(() => {
- if (!user?._id && open) router.push('/login');
- }, [user, open, router]);
+ if (!me?._id && open) router.push('/login');
+ }, [me, open, router]);
return (
<>
diff --git a/components/Group/detail/Empty.jsx b/components/Group/detail/Empty.jsx
index b77e5708..2e088bf1 100644
--- a/components/Group/detail/Empty.jsx
+++ b/components/Group/detail/Empty.jsx
@@ -5,7 +5,7 @@ import Button from '@/shared/components/Button';
import StyledPaper from '../Paper.styled';
import { StyledContainer } from './Detail.styled';
-function GroupDetail() {
+function EmptyGroup() {
return (
@@ -40,4 +40,4 @@ function GroupDetail() {
);
}
-export default GroupDetail;
+export default EmptyGroup;
diff --git a/components/Group/detail/More.jsx b/components/Group/detail/More.jsx
index 2a0bc09d..a771de74 100644
--- a/components/Group/detail/More.jsx
+++ b/components/Group/detail/More.jsx
@@ -1,21 +1,35 @@
import { useState } from 'react';
+import { useSelector } from 'react-redux';
+import { useRouter } from 'next/router';
+import Button from '@mui/material/Button';
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() {
+export default function More({ groupId, userId }) {
+ const router = useRouter();
const [anchorEl, setAnchorEl] = useState(null);
+ const me = useSelector((state) => state.user);
+ const isMyGroup = userId === me?._id;
const handleMenu = (event) => {
setAnchorEl(event.currentTarget);
};
- const handleClose = (event) => {
+ const handleClose = () => {
setAnchorEl(null);
};
- return (
+ return isMyGroup ? (
+
+ ) : (
<>
@@ -36,7 +36,7 @@ function GroupDetail({ source, isLoading }) {
) : (
已結束
)}
-
+
{isLoading ? : source?.title}
@@ -55,6 +55,7 @@ function GroupDetail({ source, isLoading }) {
}}
>
+ fetch(`${GROUP_API_URL}/${_id}`, {
+ method: 'PUT',
+ body: JSON.stringify({ isGrouping: !isGrouping }),
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ }),
+ { onSuccess: refetch },
+ );
+
+ const apiDeleteGroup = useMutation(
+ () =>
+ fetch(`${GROUP_API_URL}/${_id}`, {
+ method: 'DELETE',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ }),
+ { onSuccess: refetch },
+ );
+
+ const handleMenu = (event) => {
+ event.preventDefault();
+ setAnchorEl(event.currentTarget);
+ };
+
+ const handleClose = () => {
+ setAnchorEl(null);
+ };
+
+ const handleGrouping = () => {
+ handleClose();
+ apiGrouping.mutate();
+ };
+
+ const handleDeleteGroup = () => {
+ handleClose();
+ apiDeleteGroup.mutate();
+ };
+
+ return (
+ <>
+
+
+
+ {title}
+
+ {description}
+
+
+
+ {area}
+
+
+ {timeDuration(updatedDate)}
+
+ {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..839a3961
--- /dev/null
+++ b/components/Profile/MyGroup/GroupCard.styled.jsx
@@ -0,0 +1,107 @@
+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'};
+`;
+
+export const StyledTitle = styled.h2`
+ font-size: 16px;
+ font-weight: bold;
+ line-height: 1.4;
+ 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;
+ 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;
+ 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;
+`;
+
+export const StyledAreas = styled.div`
+ padding: 4px 0;
+ display: flex;
+ align-items: center;
+`;
+
+export const StyledGroupCard = styled(Link)`
+ display: flex;
+ position: relative;
+ background: #fff;
+ padding: 10px;
+ border-radius: 4px;
+ gap: 16px;
+
+ 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..76554a52
--- /dev/null
+++ b/components/Profile/MyGroup/LoadingCard.jsx
@@ -0,0 +1,56 @@
+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,
+ StyledStatus,
+} 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..903ea46c
--- /dev/null
+++ b/components/Profile/MyGroup/index.jsx
@@ -0,0 +1,49 @@
+import { Fragment } from 'react';
+import { Box, Typography } from '@mui/material';
+import { useRouter } from 'next/router';
+import { useDispatch, useSelector } from 'react-redux';
+import { GROUP_API_URL } from '@/redux/actions/group';
+import useFetch from '@/hooks/useFetch';
+import GroupCard from './GroupCard';
+import LoadingCard from './LoadingCard';
+import { StyledDivider } from './GroupCard.styled';
+
+const MyGroup = () => {
+ const { data, isFetching, refetch } = useFetch(
+ `${GROUP_API_URL}/user/${'65a7e0300604d7c3f4641bf9'}`,
+ );
+
+ return (
+
+
+ 我的揪團
+
+
+ {isFetching ? (
+
+ ) : (
+ Array.isArray(data?.data) &&
+ data.data.map((item, index) => (
+
+ {index > 0 && }
+
+
+ ))
+ )}
+
+
+ );
+};
+
+export default MyGroup;
diff --git a/hooks/useFetch.jsx b/hooks/useFetch.jsx
index 295c79bc..6ac43122 100644
--- a/hooks/useFetch.jsx
+++ b/hooks/useFetch.jsx
@@ -1,6 +1,7 @@
-import { useEffect, useState } from 'react';
+import { useEffect, useReducer, useState } from 'react';
const useFetch = (url, { initialValue } = {}) => {
+ const [render, refetch] = useReducer((pre) => !pre, true);
const [data, setData] = useState(initialValue);
const [isFetching, setIsFetching] = useState(true);
const [isError, setIsError] = useState(false);
@@ -8,7 +9,7 @@ const useFetch = (url, { initialValue } = {}) => {
useEffect(() => {
let pass = true;
- if (url.includes('undefined')) return;
+ if (url.includes('undefined')) return undefined;
setIsFetching(true);
setIsError(false);
@@ -19,10 +20,12 @@ const useFetch = (url, { initialValue } = {}) => {
.catch(() => setIsError(true))
.finally(() => setIsFetching(false));
- return () => pass = false;
- }, [url]);
+ return () => {
+ pass = false;
+ };
+ }, [url, render]);
- return { data, isFetching, isError };
+ return { data, isFetching, isError, refetch };
};
export default useFetch;
diff --git a/hooks/useMutation.jsx b/hooks/useMutation.jsx
new file mode 100644
index 00000000..47f13dcf
--- /dev/null
+++ b/hooks/useMutation.jsx
@@ -0,0 +1,23 @@
+import { useState } from 'react';
+
+const useMutation = (mutateFn, { onSuccess, onError } = {}) => {
+ const [isLoading, setIsLoading] = useState(false);
+ const [isError, setIsError] = useState(false);
+ const mutate = (values) => {
+ setIsLoading(true);
+ setIsError(false);
+
+ mutateFn(values)
+ .then((res) => res.json())
+ .then(onSuccess)
+ .catch((e) => {
+ onError(e);
+ setIsError(true);
+ })
+ .finally(() => setIsLoading(false));
+ };
+
+ return { mutate, isLoading, isError };
+};
+
+export default useMutation;
diff --git a/pages/group/create/index.jsx b/pages/group/create/index.jsx
index 790b3009..a6ab226c 100644
--- a/pages/group/create/index.jsx
+++ b/pages/group/create/index.jsx
@@ -1,11 +1,13 @@
import React, { useMemo } from 'react';
import { useRouter } from 'next/router';
-import SEOConfig from '@/shared/components/SEO';
import GroupForm from '@/components/Group/Form';
+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';
+import { GROUP_API_URL } from '@/redux/actions/group';
-function GroupPage() {
+function CreateGroupPage() {
const router = useRouter();
const SEOData = useMemo(
@@ -22,14 +24,28 @@ function GroupPage() {
[router?.asPath],
);
+ const { mutate, isLoading } = useMutation(
+ (values) =>
+ fetch(GROUP_API_URL, {
+ method: 'POST',
+ body: JSON.stringify(values),
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ }),
+ {
+ onSuccess: () => router.replace('/profile'),
+ },
+ );
+
return (
<>
-
+
>
);
}
-export default GroupPage;
+export default CreateGroupPage;
diff --git a/pages/group/detail/index.jsx b/pages/group/detail/index.jsx
index 72f9c29c..06625afb 100644
--- a/pages/group/detail/index.jsx
+++ b/pages/group/detail/index.jsx
@@ -33,7 +33,7 @@ function GroupPage() {
{(id || isFetching) && !isError ? (
-
+
) : (
)}
diff --git a/pages/group/edit/index.jsx b/pages/group/edit/index.jsx
new file mode 100644
index 00000000..df1cd632
--- /dev/null
+++ b/pages/group/edit/index.jsx
@@ -0,0 +1,88 @@
+import React, { useEffect, useMemo } from 'react';
+import { useSelector } from 'react-redux';
+import { useRouter } from 'next/router';
+import { Box, CircularProgress } from '@mui/material';
+import GroupForm from '@/components/Group/Form';
+import useFetch from '@/hooks/useFetch';
+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';
+import { GROUP_API_URL } from '@/redux/actions/group';
+
+function EditGroupPage() {
+ const router = useRouter();
+ const me = useSelector((state) => state.user);
+ const { id } = router.query;
+ const { data, isFetching } = useFetch(
+ `https://daodao-server.vercel.app/activity/${id}`,
+ );
+ const source = data?.data?.[0];
+
+ const SEOData = useMemo(
+ () => ({
+ title: '編輯揪團|島島阿學',
+ description:
+ '「島島阿學」揪團專區,結交志同道合的學習夥伴!發起各種豐富多彩的揪團活動,共同探索學習的樂趣。一同參與,共同成長,打造學習的共好社群。加入我們,一起開啟學習的冒險旅程!',
+ keywords: '島島阿學',
+ author: '島島阿學',
+ copyright: '島島阿學',
+ imgLink: 'https://www.daoedu.tw/preview.webp',
+ link: `${process.env.HOSTNAME}${router?.asPath}`,
+ }),
+ [router?.asPath],
+ );
+
+ const goToDetail = () => router.replace(`/group/detail?id=${id}`);
+
+ const { mutate, isLoading } = useMutation(
+ (values) => {
+ if (!id || id.includes('/')) return Promise.reject();
+
+ return fetch(`${GROUP_API_URL}/${id}`, {
+ method: 'PUT',
+ body: JSON.stringify(values),
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ });
+ },
+ { onSuccess: goToDetail },
+ );
+
+ useEffect(() => {
+ if (!me?._id) router.push('/login');
+ if (isFetching) return;
+ if (source?.userId !== me._id) goToDetail();
+ }, [me, source, isFetching, id]);
+
+ return (
+ <>
+
+
+ {isFetching && (
+
+
+
+ )}
+
+
+ >
+ );
+}
+
+export default EditGroupPage;
diff --git a/pages/profile/index.jsx b/pages/profile/index.jsx
index db23d97f..88c6b925 100644
--- a/pages/profile/index.jsx
+++ b/pages/profile/index.jsx
@@ -7,6 +7,7 @@ import Box from '@mui/material/Box';
import Edit from '@/components/Profile/Edit';
import Footer from '@/shared/components/Footer_v2';
import Navigation from '@/shared/components/Navigation_v2';
+import MyGroup from '@/components/Profile/MyGroup';
import AccountSetting from '@/components/Profile/Accountsetting';
import useMediaQuery from '@mui/material/useMediaQuery';
@@ -71,7 +72,7 @@ const ProfilePage = () => {
{
label="個人資料編輯"
{...a11yProps(0)}
/>
+
{
},
}}
label="帳號設定"
- {...a11yProps(1)}
+ {...a11yProps(2)}
/>
-
+
+
+
+
diff --git a/redux/actions/group.js b/redux/actions/group.js
index 25bdba2c..32862c6a 100644
--- a/redux/actions/group.js
+++ b/redux/actions/group.js
@@ -1,11 +1,11 @@
+import { BASE_URL } from '@/constants/common';
+
export const DEFAULT_PAGE_SIZE = 6;
export const SET_PAGE_SIZE = 'SET_PAGE_SIZE';
export const SET_QUERY = 'SET_QUERY';
export const GET_GROUP_ITEMS_SUCCESS = 'GET_GROUP_ITEMS_SUCCESS';
export const GET_GROUP_ITEMS_FAILURE = 'GET_GROUP_ITEMS_FAILURE';
-export const BASE_API_URL =
- process.env.NEXT_PUBLIC_API_URL || 'https://daodao-server.onrender.com';
-export const GROUP_API_URL = `${BASE_API_URL}/activity`;
+export const GROUP_API_URL = `${BASE_URL}/activity`;
export function setPageSize(pageSize) {
return {