diff --git a/public/images/profile-image-default-org.png b/public/images/profile-image-default-org.png new file mode 100644 index 0000000..6694417 Binary files /dev/null and b/public/images/profile-image-default-org.png differ diff --git a/src/components/checkbox/Checkbox.tsx b/src/components/checkbox/Checkbox.tsx new file mode 100644 index 0000000..1a20fe5 --- /dev/null +++ b/src/components/checkbox/Checkbox.tsx @@ -0,0 +1,9 @@ +import CheckboxGroup from "components/checkbox/items/CheckboxGroup"; +import CheckboxItem from "components/checkbox/items/CheckboxItem"; + +const Checkbox = () => {}; + +Checkbox.Group = CheckboxGroup; +Checkbox.Item = CheckboxItem; + +export default Checkbox; diff --git a/src/components/checkbox/items/CheckboxGroup.tsx b/src/components/checkbox/items/CheckboxGroup.tsx new file mode 100644 index 0000000..363ad8c --- /dev/null +++ b/src/components/checkbox/items/CheckboxGroup.tsx @@ -0,0 +1,39 @@ +import styled from "@emotion/styled"; +import { Children, HTMLAttributes, ReactNode, cloneElement, isValidElement } from "react"; + +export interface Props extends HTMLAttributes { + children: ReactNode; + checkedList: string[]; + setCheckedItem: (checkedList: string) => void; +} + +export interface CheckboxItemProps { + checked: boolean; + value: string; + label: string; + children: ReactNode; + setChecked: (value: string) => void; +} + +const CheckboxGroup = ({ children, checkedList, setCheckedItem, ...props }: Props) => { + const childrenWithProps = Children.map(children, (child) => { + if (isValidElement(child)) { + const checked = checkedList.includes(child.props.value); + const value = child.props.value; + + const setChecked = () => setCheckedItem(value); + + return cloneElement(child, { checked, setChecked, value }); + } + return child; + }); + + return {childrenWithProps}; +}; + +export default CheckboxGroup; + +const EmotionWrapper = styled.ul` + display: flex; + flex-direction: column; +`; diff --git a/src/components/checkbox/items/CheckboxItem.tsx b/src/components/checkbox/items/CheckboxItem.tsx new file mode 100644 index 0000000..56062eb --- /dev/null +++ b/src/components/checkbox/items/CheckboxItem.tsx @@ -0,0 +1,104 @@ +import styled from "@emotion/styled"; +import { ReactNode } from "react"; + +export interface Props { + children: ReactNode; + checked?: boolean; + setChecked?: (checked: boolean | string) => void; + value: string; +} + +const CheckboxItem = ({ children, checked, setChecked, value }: Props) => { + const handleClickCheckbox = () => { + setChecked?.(!checked); + }; + + return ( + +
+ + + + ); +}; + +export default CheckboxItem; + +const EmotionWrapper = styled.button` + display: flex; + align-items: center; + padding: 4px 8px; + border: 1px solid ${({ theme }) => theme.color.gray200}; + border-radius: 4px; + margin: 4px 0; + cursor: pointer; + color: ${({ theme }) => theme.color.gray400}; + + &[data-checked="true"] { + border: 1px solid ${({ theme }) => theme.color.primary500}; + color: ${({ theme }) => theme.color.primary600}; + font-weight: 500; + } + + div { + width: 20px; + height: 20px; + border: 1px solid ${({ theme }) => theme.color.gray200}; + border-radius: 4px; + margin-right: 4px; + cursor: pointer; + + &[data-checked="true"] { + background: ${({ theme }) => theme.color.primary500}; + border: 1px solid ${({ theme }) => theme.color.primary600}; + + &::after { + content: ""; + display: block; + width: 6px; + height: 10px; + border: solid #fff; + border-width: 0 2px 2px 0; + transform: rotate(45deg); + position: relative; + top: 2px; + left: 5px; + + &:hover { + border: solid #fff; + border-width: 0 2px 2px 0; + + transform: rotate(45deg); + position: relative; + top: 2px; + left: 5px; + } + } + } + } + label { + padding: 10px 10px; + white-space: wrap; + word-break: break-all; + + /* &:hover { + border: 1px solid ${({ theme }) => theme.color.primary500}; + color: ${({ theme }) => theme.color.primary500}; + font-weight: 500; + } + + &[data-disabled="true"] { + border: 1px solid ${({ theme }) => theme.color.gray200}; + color: ${({ theme }) => theme.color.gray200}; + background-color: ${({ theme }) => theme.color.gray100}; + cursor: not-allowed; + } + + &[data-checked="true"] { + color: #fff; + background: ${({ theme }) => theme.color.primary500}; + border: 1px solid ${({ theme }) => theme.color.primary600}; + font-weight: 500; + } */ + } +`; diff --git a/src/components/inputs/AreaInput/AreaInput.tsx b/src/components/inputs/AreaInput/AreaInput.tsx index e918f31..d1cee43 100644 --- a/src/components/inputs/AreaInput/AreaInput.tsx +++ b/src/components/inputs/AreaInput/AreaInput.tsx @@ -3,18 +3,24 @@ import { useState, useEffect } from "react"; import AreaInputSido from "components/inputs/AreaInput/items/AreaInputSido"; import AreaInputSigungu from "components/inputs/AreaInput/items/AreaInputSigungu"; import AreaInputDong from "components/inputs/AreaInput/items/AreaInputDong"; +import usePrevious from "hooks/usePrevious"; interface Props { label?: string; value?: number; // 기존에 선택된 지역 id onSelectValueChange?: (value: number) => void; // 지역 id 콜백 함수 + className?: string; } -const AreaInput: React.FC = ({ label, value, onSelectValueChange }) => { +const AreaInput: React.FC = ({ label, value, onSelectValueChange, className }) => { const [sido, setSido] = useState(); const [sigungu, setSigungu] = useState(); const [dong, setDong] = useState(); + const previousSido = usePrevious(sido); + const previousSigungu = usePrevious(sigungu); + const previousDong = usePrevious(dong); + const handleSidoChange = (value: string | undefined) => { setSido(value); }; @@ -28,21 +34,29 @@ const AreaInput: React.FC = ({ label, value, onSelectValueChange }) => { }; useEffect(() => { - if (value && !sido && !sigungu && !dong) { - // 초기값이 존재하고, sido가 선택되지 않은 경우 + const areaId = `${sido ?? "NN"}${sigungu ?? "NNN"}${dong ?? "NNN"}`; + if (!sido || !sigungu || !dong) return; + onSelectValueChange?.(parseInt(areaId)); + }, [sido, sigungu, dong, onSelectValueChange]); + + useEffect(() => { + if (value) { const area = value.toString(); - setSido(area.slice(1, 3)); - setSigungu(area.slice(3, 6)); - setDong(area.slice(6)); - } - if (onSelectValueChange && dong) { - onSelectValueChange(parseInt(dong)); + const nextSido = area.slice(0, 2); + const nextSigungu = area.slice(2, 5); + const nextDong = area.slice(5); + + if (nextSido === previousSido && nextSigungu === previousSigungu && nextDong === previousDong) + return; + setSido(nextSido); + setSigungu(nextSigungu); + setDong(nextDong); } - }, [value, sido, sigungu, dong, onSelectValueChange]); + }, [value, sido, sigungu, dong, previousSido, previousSigungu, previousDong]); return ( - + {label && {label}}
diff --git a/src/components/inputs/ImageInput/ImageInput.tsx b/src/components/inputs/ImageInput/ImageInput.tsx index a2af11d..1d7787e 100644 --- a/src/components/inputs/ImageInput/ImageInput.tsx +++ b/src/components/inputs/ImageInput/ImageInput.tsx @@ -8,9 +8,15 @@ interface Props { maxImageCount?: number; // 최대 입력할 수 있는 이미지 개수 existingImageUrlList?: string[]; // 이미 서버에 존재하던 이미지 URL 리스트 label?: string; // 이미지 인풋 레이블 + className?: string; } -const ImageInput: React.FC = ({ maxImageCount = 1, existingImageUrlList = [], label }) => { +const ImageInput: React.FC = ({ + maxImageCount = 1, + existingImageUrlList = [], + label, + className, +}) => { const imageUrlRef = useRef(null); // 이미 서버에 존재하던 이미지 URL 리스트 const fileInputRef = useRef(null); // 로컬에서 업로드한 이미지 리스트 const fileInputUniqueIndexCount = useRef(0); // 로컬 이미지 리스트의 uniqueIndex를 위한 카운트 @@ -89,7 +95,7 @@ const ImageInput: React.FC = ({ maxImageCount = 1, existingImageUrlList = }, [imageUrlListState]); return ( - +

최대 {maxImageCount}장 선택

diff --git a/src/components/inputs/TextInput/TextInput.tsx b/src/components/inputs/TextInput/TextInput.tsx index 33318fb..24c76c3 100644 --- a/src/components/inputs/TextInput/TextInput.tsx +++ b/src/components/inputs/TextInput/TextInput.tsx @@ -1,10 +1,10 @@ -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, InputHTMLAttributes } from "react"; import styled from "@emotion/styled"; import { css, Theme } from "@emotion/react"; import CheckIcon from "components/inputs/TextInput/CheckIcon"; import { TConditionCheck } from "./types/TConditionCheck"; -interface Props { +interface Props extends InputHTMLAttributes { name?: string; label?: string; value?: string; @@ -13,6 +13,7 @@ interface Props { conditionCheckList?: TConditionCheck[]; multiline?: boolean; onTextChange?: (value: string, isValid: boolean) => void; + className?: string; } const TextInput: React.FC = ({ @@ -24,6 +25,8 @@ const TextInput: React.FC = ({ conditionCheckList, multiline = false, onTextChange, + className, + ...props // input 태그의 나머지 속성들 }) => { const [status, setStatus] = useState(value === "" ? "default" : "success"); // default / success / invalid / focus const [enteredValue, setEnteredValue] = useState(value); @@ -75,8 +78,13 @@ const TextInput: React.FC = ({ } }, [enteredValue, status, onTextChange]); + // 비동기적인 value 변경에 대한 처리 (API 호출 시) + useEffect(() => { + setEnteredValue(value); + }, [value]); + return ( - + {label && {label}} {conditionList && (
@@ -97,6 +105,7 @@ const TextInput: React.FC = ({ onFocus={handleFocus} onBlur={handleBlur} data-status={status} + {...props} /> ) : ( = ({ onFocus={handleFocus} onBlur={handleBlur} data-status={status} + {...props} /> )} {status == "success" && ( diff --git a/src/constant/limit.ts b/src/constant/limit.ts new file mode 100644 index 0000000..d8d1eeb --- /dev/null +++ b/src/constant/limit.ts @@ -0,0 +1,7 @@ +// 닉네임 최대 글자수 제한, 파일 업로드 최대 용량 제한 등 각종 제한 사항을 정의하는 파일입니다. +export const NICKNAME_MAX_LENGTH = 10; +export const ORGANIZATION_NAME_MAX_LENGTH = 15; +export const ORGANIZATION_PASSWORD_MIN_LENGTH = 4; +export const ORGANIZATION_PASSWORD_MAX_LENGTH = 15; + +export const ORGANIZATION_IMAGE_MAX_COUNT = 1; diff --git a/src/feature/organizations/components/OrganizationForm.tsx b/src/feature/organizations/components/OrganizationForm.tsx new file mode 100644 index 0000000..5deb0f6 --- /dev/null +++ b/src/feature/organizations/components/OrganizationForm.tsx @@ -0,0 +1,213 @@ +import styled from "@emotion/styled"; +import Button from "components/button/Button"; +import Checkbox from "components/checkbox/Checkbox"; +import AreaInput from "components/inputs/AreaInput/AreaInput"; +import ImageInput from "components/inputs/ImageInput/ImageInput"; +import TextInput from "components/inputs/TextInput/TextInput"; +import { ORGANIZATION_IMAGE_MAX_COUNT } from "constant/limit"; +import { ORGANIZATION_TYPE_CHECKBOX_ITEM_LIST } from "feature/organizations/constants/orgnizationTypeCheckboxItemList"; +import { + validateOrganizationNameEmpty, + validateOrganizationNameLength, + validateOrganizationNicknameEmpty, + validateOrganizationNicknameLength, + validateOrganizationPasswordEmpty, + validateOrganizationPasswordLength, +} from "feature/organizations/functions/validateOrganizationForm"; +import { MOCKUP_ORGANIZATION_INITIAL_DATA } from "feature/organizations/mockups/mockupOrganiationInitialData"; +import { TOrganizationFormValues } from "feature/organizations/types/TOrganizationFormValues"; +import { useCallback, useEffect, useState } from "react"; + +type TCustomFormTarget = { + imageInput: { files: FileList }; + imageUrlInput: { value: string }; +}; + +interface Props { + isEditMode?: boolean; +} + +const OrganizationForm = ({ isEditMode = false }: Props) => { + const [formValues, setFormValues] = useState({ + name: "", + type: "STUDENT_ORGANIZATION", + areaId: 0, + isPublic: true, + password: "", + nickname: "", + }); + + const handleClickSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + const target = e.target as typeof e.target & TCustomFormTarget; + + console.log("submit"); + console.log(formValues); + + console.log(target.imageInput.files); + console.log(target.imageUrlInput.value); + }; + + const handleSetFormValues = useCallback( + (key: keyof TOrganizationFormValues) => (value: unknown) => { + setFormValues((prev) => ({ ...prev, [key]: value })); + }, + [] + ); + + const handleSetOrgName = useCallback( + (value: unknown, isValid: boolean) => handleSetFormValues("name")(value), + [] + ); + + const handleSetOrgNickname = useCallback( + (value: unknown, isValid: boolean) => handleSetFormValues("nickname")(value), + [] + ); + + const handleSetOrgAreaId = useCallback( + (value: unknown) => handleSetFormValues("areaId")(value), + [] + ); + + const handleSetOrgPassword = useCallback( + (value: unknown, isValid: boolean) => handleSetFormValues("password")(value), + [] + ); + + const handleSetIsPublic = useCallback((value: unknown) => { + const isPublicBoolean = value === "true"; + setFormValues((prev) => ({ + ...prev, + ["isPublic" as keyof TOrganizationFormValues]: isPublicBoolean, + })); + }, []); + + const { type, isPublic, name, password, nickname, areaId } = formValues; + const organizationImageSrc = null; // 서버에서 받아온 단체 프로필 이미지 + const existingImageUrlList = organizationImageSrc ? [organizationImageSrc] : []; + + // 데이터 fetching 로직이 들어올 시 비동기적으로 form values 를 업데이트 해야함 + useEffect(() => { + if (isEditMode) setFormValues(MOCKUP_ORGANIZATION_INITIAL_DATA); + }, [isEditMode]); + + return ( + +

{isEditMode ? "단체 정보 수정" : "단체 만들기"}

+

1. 내 단체는 어떤 단체인가요?

+ + {ORGANIZATION_TYPE_CHECKBOX_ITEM_LIST.map((item) => { + const { value, name } = item; + + return ( + + {name} + + ); + })} + +

2. 단체 이름을 지어보아요!

+ +

3. 나는 이 단체에서 이 이름을 쓸거에요!

+ +

4. 우리 단체는 이 지역에서 주로 활동합니다!

+ +

5. 우리 단체를 대표하는 이미지를 설정해보세요!

+ + +

6. 단체를 비공개로 설정할 수 있어요!

+ handleSetIsPublic(value)} + className="organization-form-item" + > + 공개 + 비공개 + + +
+

7. 비밀번호를 설정해 보아요!

+ +
+ + +
+ ); +}; + +export default OrganizationForm; + +const EmotionWrapper = styled.form` + h1 { + font-size: 24px; + font-weight: 700; + margin-bottom: 36px; + line-height: 1.5; + } + + p.text-question { + margin-bottom: 16px; + font-weight: 500; + font-size: 18px; + } + + .organization-form-item { + margin-bottom: 36px; + } + + .set-password-container { + max-height: 0; + overflow: hidden; + transition: max-height 0.5s ease-in-out; + + &[data-ispublic="false"] { + max-height: 1000px; + } + } + + button { + float: right; + } +`; diff --git a/src/feature/organizations/constants/orgnizationTypeCheckboxItemList.ts b/src/feature/organizations/constants/orgnizationTypeCheckboxItemList.ts new file mode 100644 index 0000000..701a6fc --- /dev/null +++ b/src/feature/organizations/constants/orgnizationTypeCheckboxItemList.ts @@ -0,0 +1,20 @@ +import { TOrganizationTypeCheckboxItem } from "feature/organizations/types/TOrganizationTypeCheckboxItem"; + +export const ORGANIZATION_TYPE_CHECKBOX_ITEM_LIST: TOrganizationTypeCheckboxItem[] = [ + { + name: "학생 단체", + value: "STUDENT_ORGANIZATION", + }, + { + name: "회사", + value: "CORPORATE", + }, + { + name: "가족", + value: "FAMILY", + }, + { + name: "기타", + value: "OTHERS", + }, +]; diff --git a/src/feature/organizations/functions/validateOrganizationForm.ts b/src/feature/organizations/functions/validateOrganizationForm.ts new file mode 100644 index 0000000..5f99392 --- /dev/null +++ b/src/feature/organizations/functions/validateOrganizationForm.ts @@ -0,0 +1,56 @@ +import { NICKNAME_MAX_LENGTH, ORGANIZATION_NAME_MAX_LENGTH } from "constant/limit"; + +// 단체명 유효성 검사 조건을 정의합니다. +export const validateOrganizationNameLength = { + condition: (value: string): boolean => { + if (value?.length > ORGANIZATION_NAME_MAX_LENGTH) return false; + return true; + }, + messageOnError: `단체명은 ${ORGANIZATION_NAME_MAX_LENGTH}자 이내로 입력해주세요.`, +}; + +export const validateOrganizationNameEmpty = { + condition: (value: string): boolean => { + if (!value) return false; + if (value?.length === 0) return false; + return true; + }, + messageOnError: "단체명을 입력해주세요.", +}; + +// 단체 내 닉네임 유효성 검사 조건을 정의합니다. +export const validateOrganizationNicknameLength = { + condition: (value: string): boolean => { + if (value?.length > NICKNAME_MAX_LENGTH) return false; + return true; + }, + messageOnError: `단체 내 닉네임은 ${NICKNAME_MAX_LENGTH}자 이내로 입력해주세요.`, +}; + +export const validateOrganizationNicknameEmpty = { + condition: (value: string): boolean => { + if (!value) return false; + if (value?.length === 0) return false; + return true; + }, + messageOnError: "단체 내 닉네임을 입력해주세요.", +}; + +// 단체 비밀번호 유효성 검사 조건을 정의합니다. +export const validateOrganizationPasswordLength = { + condition: (value: string): boolean => { + if (value?.length < 4 || value?.length > 15) return false; + return true; + }, + + messageOnError: "비밀번호는 4자 이상 15자 이하로 입력해주세요.", +}; + +export const validateOrganizationPasswordEmpty = { + condition: (value: string): boolean => { + if (!value) return false; + if (value?.length === 0) return false; + return true; + }, + messageOnError: "비밀번호를 입력해주세요.", +}; diff --git a/src/feature/organizations/mockups/mockupOrganiationInitialData.ts b/src/feature/organizations/mockups/mockupOrganiationInitialData.ts new file mode 100644 index 0000000..88c5d2e --- /dev/null +++ b/src/feature/organizations/mockups/mockupOrganiationInitialData.ts @@ -0,0 +1,10 @@ +import { TOrganizationFormValues } from "feature/organizations/types/TOrganizationFormValues"; + +export const MOCKUP_ORGANIZATION_INITIAL_DATA: TOrganizationFormValues = { + name: "우리가족", + type: "FAMILY", + areaId: 26170520, + isPublic: false, + password: "1234", + nickname: "아빠", +}; diff --git a/src/feature/organizations/organizations.create/views/ViewOrganizationCreate.tsx b/src/feature/organizations/organizations.create/views/ViewOrganizationCreate.tsx new file mode 100644 index 0000000..37b63d9 --- /dev/null +++ b/src/feature/organizations/organizations.create/views/ViewOrganizationCreate.tsx @@ -0,0 +1,14 @@ +import styled from "@emotion/styled"; +import FormOrganization from "feature/organizations/components/OrganizationForm"; + +const ViewOrganizationCreate = () => { + return ( + + + + ); +}; + +export default ViewOrganizationCreate; + +const EmotionWrapper = styled.div``; diff --git a/src/feature/organizations/organizations.edit/views/ViewOrganizationEdit.tsx b/src/feature/organizations/organizations.edit/views/ViewOrganizationEdit.tsx new file mode 100644 index 0000000..185c6bc --- /dev/null +++ b/src/feature/organizations/organizations.edit/views/ViewOrganizationEdit.tsx @@ -0,0 +1,14 @@ +import styled from "@emotion/styled"; +import FormOrganization from "feature/organizations/components/OrganizationForm"; + +const ViewOrganizationEdit = () => { + return ( + + + + ); +}; + +export default ViewOrganizationEdit; + +const EmotionWrapper = styled.div``; diff --git a/src/feature/organizations/types/TOrganizationFormValues.ts b/src/feature/organizations/types/TOrganizationFormValues.ts new file mode 100644 index 0000000..4e29897 --- /dev/null +++ b/src/feature/organizations/types/TOrganizationFormValues.ts @@ -0,0 +1,10 @@ +import { TOrganizationType } from "feature/organizations/types/TOrganizationType"; + +export type TOrganizationFormValues = { + name: string; // 단체명 + type: TOrganizationType; // 단체 종류 (학생 단체, 회사, 가족, 기타) + areaId: number; // 단체 활동 지역 + isPublic: boolean; // 직접 가입 가능 여부 (비밀번호 없이) + password?: string; // 직접 가입 시 필요한 비밀번호 + nickname: string; // 단체 소유자의 단체 내의 닉네임 +}; diff --git a/src/feature/organizations/types/TOrganizationType.ts b/src/feature/organizations/types/TOrganizationType.ts new file mode 100644 index 0000000..cbfa735 --- /dev/null +++ b/src/feature/organizations/types/TOrganizationType.ts @@ -0,0 +1 @@ +export type TOrganizationType = "STUDENT_ORGANIZATION" | "CORPORATE" | "FAMILY" | "OTHERS"; diff --git a/src/feature/organizations/types/TOrganizationTypeCheckboxItem.ts b/src/feature/organizations/types/TOrganizationTypeCheckboxItem.ts new file mode 100644 index 0000000..e25847a --- /dev/null +++ b/src/feature/organizations/types/TOrganizationTypeCheckboxItem.ts @@ -0,0 +1,6 @@ +import { TOrganizationType } from "feature/organizations/types/TOrganizationType"; + +export type TOrganizationTypeCheckboxItem = { + name: string; + value: TOrganizationType; +}; diff --git a/src/hooks/.gitkeep b/src/hooks/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/hooks/usePrevious.ts b/src/hooks/usePrevious.ts new file mode 100644 index 0000000..8e7bc01 --- /dev/null +++ b/src/hooks/usePrevious.ts @@ -0,0 +1,10 @@ +import { useEffect, useRef } from "react"; + +// 바로 직전 상태의 state 값을 반환하는 hook +export default function usePrevious(value: T): T | undefined { + const ref = useRef(); + useEffect(() => { + ref.current = value; + }); + return ref.current; +} diff --git a/src/pages/organizations/[organizationId]/edit.tsx b/src/pages/organizations/[organizationId]/edit.tsx new file mode 100644 index 0000000..585e310 --- /dev/null +++ b/src/pages/organizations/[organizationId]/edit.tsx @@ -0,0 +1,7 @@ +import ViewOrganizationEdit from "feature/organizations/organizations.edit/views/ViewOrganizationEdit"; + +const PageOrganizationEdit = () => { + return ; +}; + +export default PageOrganizationEdit; diff --git a/src/pages/organizations/[organizationId].tsx b/src/pages/organizations/[organizationId]/index.tsx similarity index 100% rename from src/pages/organizations/[organizationId].tsx rename to src/pages/organizations/[organizationId]/index.tsx diff --git a/src/pages/organizations/create.tsx b/src/pages/organizations/create.tsx new file mode 100644 index 0000000..5193338 --- /dev/null +++ b/src/pages/organizations/create.tsx @@ -0,0 +1,7 @@ +import ViewOrganizationCreate from "feature/organizations/organizations.create/views/ViewOrganizationCreate"; + +const PageOrganizationCreate = () => { + return ; +}; + +export default PageOrganizationCreate;