Skip to content

Commit

Permalink
Merge pull request #78 from DDD-Community/feature/73
Browse files Browse the repository at this point in the history
[Feature/73] 탈퇴 페이지 구현
  • Loading branch information
junseublim authored Aug 11, 2024
2 parents 307d25b + 602fcb8 commit 4f3c82e
Show file tree
Hide file tree
Showing 17 changed files with 298 additions and 8 deletions.
3 changes: 3 additions & 0 deletions public/icons/arrow_down.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions public/icons/arrow_up.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions public/icons/check.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion src/app/(home)/components/TotalCount.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ const TotalCount = async () => {
if (!sharedCount) return null

return (
<div className="text-center mb-2.5">
<div className="mb-2.5 text-center">
지금까지
<span className="font-semiBold">{sharedCount.toLocaleString()}</span>
보드가 만들어졌어요!
Expand Down
22 changes: 22 additions & 0 deletions src/app/mypage/leave/components/LeaveConfirmModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import Modal from '@/components/Modal'
import TwoPolaroidsIcon from 'public/icons/twopolaroids.svg'

interface LeaveConfirmModalProps {
isOpen: boolean
onClose: () => void
}
const LeaveConfirmModal = ({ isOpen, onClose }: LeaveConfirmModalProps) => {
const title = 'POLABO 탈퇴가 완료되었습니다.'
const content = '그동안 POLABO를\n이용해주셔서 감사합니다.'
return (
<Modal isOpen={isOpen} onClose={onClose} closeOnOutsideClick={false}>
<Modal.CenterModal icon={<TwoPolaroidsIcon />}>
<Modal.Title>{title}</Modal.Title>
<Modal.Content>{content}</Modal.Content>
<Modal.CenterConfirm confirmText="확인" />
</Modal.CenterModal>
</Modal>
)
}

export default LeaveConfirmModal
91 changes: 91 additions & 0 deletions src/app/mypage/leave/components/LeaveForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
'use client'

import TextArea from '@/components/TextArea'
import Button from '@/components/Button'
import { useEffect, useState } from 'react'
import Select from '@/components/Select'
import { withdraw } from '@/lib'
import { signOut } from 'next-auth/react'
import LeaveConfirmModal from './LeaveConfirmModal'

const WITHDRAW_TYPE = {
SELECT: '선택해주세요.',
UNUSED: '더 이상 이용하지 않아요.',
PRIVACY: '개인정보가 걱정돼요.',
DELETE_DATA: '제 데이터를 삭제하고 싶어요.',
NEW_ACCOUNT: '새로운 계정을 만들고 싶어요.',
ETC: '기타',
}

const LeaveForm = () => {
const [withdrawType, setWithdrawType] = useState<string>(WITHDRAW_TYPE.SELECT)
const [customReason, setCustomReason] = useState<string>('')
const [isFormValid, setIsFormValid] = useState<boolean>(false)
const [isLeaveConfirmModalOpen, setIsLeaveConfirmModalOpen] = useState(false)
const errorMessage =
customReason.length > 50 ? '50자까지 입력 가능합니다.' : ''
const isCustomReasonValid = customReason && !errorMessage

useEffect(() => {
if (withdrawType === WITHDRAW_TYPE.SELECT) {
setIsFormValid(false)
} else if (withdrawType === WITHDRAW_TYPE.ETC && !isCustomReasonValid) {
setIsFormValid(false)
} else {
setIsFormValid(true)
}
}, [withdrawType, customReason])

const submit = async () => {
if (!isFormValid) {
return
}

await withdraw({
type: withdrawType,
reason: customReason,
})

setIsLeaveConfirmModalOpen(true)
}

const onCloseLeaveConfirmModal = () => {
signOut({ callbackUrl: '/' })
}

return (
<form className="flex flex-1 flex-col justify-between">
<div className="flex flex-col gap-8">
<Select
value={withdrawType}
options={Object.values(WITHDRAW_TYPE)}
onSelect={setWithdrawType}
/>
{withdrawType === WITHDRAW_TYPE.ETC && (
<TextArea
placeholder="이유를 입력해주세요."
value={customReason}
setValue={setCustomReason}
description={`${customReason.length}/50`}
hasError={!!errorMessage}
errorMessage={errorMessage}
/>
)}
</div>
<Button
size="lg"
onClick={submit}
disabled={!isFormValid}
className="w-full"
>
탈퇴하기
</Button>
<LeaveConfirmModal
isOpen={isLeaveConfirmModalOpen}
onClose={onCloseLeaveConfirmModal}
/>
</form>
)
}

export default LeaveForm
7 changes: 7 additions & 0 deletions src/app/mypage/leave/components/Title.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { ReactNode } from 'react'

const Title = ({ children }: { children: ReactNode }) => {
return <h1 className="mb-3.5 text-xl font-semiBold">{children}</h1>
}

export default Title
20 changes: 15 additions & 5 deletions src/app/mypage/leave/page.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
import Header from '@/components/Header'
import LeaveForm from '@/app/mypage/leave/components/LeaveForm'
import Title from './components/Title'

const Page = () => {
const leaveCheckTitle = '탈퇴전 꼭 확인해주세요.'
const leaveReasonTitle = '탈퇴하려는 이유를 알려주세요.'
const leaveDescription =
'계정을 삭제하면 복구가 불가능하며, 같은 계정으로\n재가입이 어려워요. 회원정보 및 보드가 모두\n삭제되며, 삭제된 데이터는 복구가 불가능해요.'

return (
<div className="min-h-dvh">
<div className="flex h-dvh flex-col bg-gray-50">
<Header title="탈퇴하기" leftButton={<Header.BackButton />} />
<div>
Lorem ipsum dolor sit amet, consectetur adipisicing elit. Accusamus amet
atque consectetur culpa, doloribus earum eius eos eum fuga fugiat harum
ipsam laborum omnis quae sint tenetur totam ut voluptates.
<div className="relative flex flex-1 flex-col px-8 pb-8 pt-9">
<Title>{leaveCheckTitle}</Title>
<div className="mb-9 whitespace-pre text-sm text-gray-700">
{leaveDescription}
</div>
<Title>{leaveReasonTitle}</Title>
<LeaveForm />
</div>
</div>
)
Expand Down
2 changes: 1 addition & 1 deletion src/components/Header/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ const Header = ({
}: HeaderProps) => {
return (
<>
<header className="fixed grid h-16 w-full max-w-md grid-cols-3 justify-between bg-gray-0 p-5 shadow-header">
<header className="fixed z-10 grid h-16 w-full max-w-md grid-cols-3 justify-between bg-gray-0 p-5 shadow-header">
<div className="justify-self-start">{leftButton}</div>
<div className="justify-self-center">
<div className="text-center text-md font-semiBold leading-6">
Expand Down
2 changes: 1 addition & 1 deletion src/components/Modal/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ const ModalOverlay = ({

return (
<div
className={`fixed inset-0 flex items-center justify-center bg-gray-900/60 ${
className={`fixed inset-0 z-20 flex items-center justify-center bg-gray-900/60 ${
isVisible ? 'opacity-100' : 'opacity-0'
}`}
onClick={handleClick}
Expand Down
67 changes: 67 additions & 0 deletions src/components/Select/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
'use client'

import ArrowUpIcon from 'public/icons/arrow_up.svg'
import ArrowDownIcon from 'public/icons/arrow_down.svg'
import CheckIcon from 'public/icons/check.svg'
import { useState } from 'react'

interface SelectOptionProps {
text: string
selected: boolean
onSelect: (value: string) => void
}

const SelectOption = ({ text, selected, onSelect }: SelectOptionProps) => {
return (
<button
type="button"
onClick={() => onSelect(text)}
className="ml-1.5 grid grid-cols-[13px_minmax(0,1fr)] items-center justify-items-start gap-1 border-b-[0.5px] border-b-gray-200 py-1.5 text-sm active:bg-gray-100"
>
{selected ? <CheckIcon /> : <div />}
{text}
</button>
)
}

interface SelectProps {
value: string
options: string[]
onSelect: (value: string) => void
}

const Select = ({ value, options, onSelect }: SelectProps) => {
const [isOpen, setIsOpen] = useState(false)

const onSelectItem = (selectedValue: string) => {
onSelect(selectedValue)
setIsOpen(false)
}

return (
<div className="relative">
<button
className={`flex h-12 w-full items-center justify-between rounded-md border ${isOpen ? 'border-gray-400' : 'border-gray-800'} px-3.5`}
type="button"
onClick={() => setIsOpen((prev) => !prev)}
>
<span>{value}</span>
{isOpen ? <ArrowDownIcon /> : <ArrowUpIcon />}
</button>
{isOpen && (
<div className="absolute left-0 top-12 z-10 mt-1 flex w-full flex-col rounded-md border border-gray-400 bg-gray-0">
{options.map((option) => (
<SelectOption
key={option}
text={option}
selected={value === option}
onSelect={onSelectItem}
/>
))}
</div>
)}
</div>
)
}

export default Select
54 changes: 54 additions & 0 deletions src/components/TextArea/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
'use client'

import { ChangeEvent, useRef } from 'react'
import { twMerge } from 'tailwind-merge'

interface TextInputProps {
value: string
setValue: (value: string) => void
hasError: boolean
description: string
errorMessage: string
placeholder: string
}

const TextInput = ({
value,
setValue,
hasError,
description,
errorMessage,
placeholder,
}: TextInputProps) => {
const borderClass = twMerge(
'flex items-center mb-2 border-b ',
hasError ? 'border-negative' : 'border-gray-950',
)
const textareaRef = useRef<HTMLTextAreaElement>(null)

return (
<div className={`w-full ${hasError ? 'text-negative' : ''}`}>
<div className={borderClass}>
<textarea
placeholder={placeholder}
ref={textareaRef}
rows={1}
value={value}
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => {
if (textareaRef.current) {
textareaRef.current.style.height = 'auto'
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`
}
setValue(e.target.value)
}}
className="w-full resize-none overflow-y-hidden bg-transparent p-1 outline-none"
/>
</div>
<div className="text-right text-xs">
{hasError ? errorMessage : description}
</div>
</div>
)
}

export default TextInput
16 changes: 16 additions & 0 deletions src/lib/api/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,22 @@ export const post = async (
'데이터를 저장하는데 실패했습니다.',
)

export const put = async (
path: string,
options: RequestInit = {},
useMocked = false,
) =>
fetchApi(
path,
{
...options,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
},
useMocked,
'데이터를 저장하는데 실패했습니다.',
)

export const deleteApi = async (
path: string,
options: RequestInit = {},
Expand Down
1 change: 1 addition & 0 deletions src/lib/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export * from './board'
export * from './file'
export * from './polaroid'
export * from './auth'
export * from './user'
8 changes: 8 additions & 0 deletions src/lib/api/user.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { put } from '@/lib/api/base'
import { WithdrawUserPayload } from '@/types'

export const withdraw = async (body: WithdrawUserPayload) => {
return put('/api/v1/user/withdraw', {
body: JSON.stringify(body),
})
}
1 change: 1 addition & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export * from './board'
export * from './polaroid'
export * from './file'
export * from './auth'
export * from './user'
4 changes: 4 additions & 0 deletions src/types/user.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export type WithdrawUserPayload = {
type: string
reason: string
}

0 comments on commit 4f3c82e

Please sign in to comment.