From 89a0538a58056c8c8b400e801507ecffb7bdbaeb Mon Sep 17 00:00:00 2001 From: Zero Date: Wed, 18 Sep 2024 15:22:33 +0900 Subject: [PATCH 01/21] =?UTF-8?q?Bug/#49=20Image=20=EC=A4=84=EB=B0=94?= =?UTF-8?q?=EA=BF=88=EC=9D=B4=20=EB=93=A4=EC=96=B4=EA=B0=80=EB=8A=94=20?= =?UTF-8?q?=ED=98=84=EC=83=81=EC=9D=84=20=EC=88=98=EC=A0=95=ED=95=9C?= =?UTF-8?q?=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitattributes | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.gitattributes b/.gitattributes index fcadb2c..3e77c0e 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,9 @@ -* text eol=lf +*.ts text eol=lf +*.tsx text eol=lf +*.js text eol=lf +*.jsx text eol=lf +*.json text eol=lf + +*.png -text +*.jpg -text +*.gif -text From 7ee3b7d549a6d3ab833e4c816d456d655336cfd1 Mon Sep 17 00:00:00 2001 From: Zero Date: Wed, 18 Sep 2024 17:27:53 +0900 Subject: [PATCH 02/21] =?UTF-8?q?Chore/#6=20expo=20web=20build=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=A9=EB=8B=88?= =?UTF-8?q?=EB=8B=A4.=20(#52)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: expo-web-build yml 파일을 추가한다. * refactor: pull-request 요청시 빌드가 되게 수정 * feat: yml 파일내 Project Dependencies 설치를 추가합니다. * feat: yml 파일내 EXPO_PUBLIC_SERVER_URL를 추가합니다. --- .github/workflows/vercel-web-build.yml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 .github/workflows/vercel-web-build.yml diff --git a/.github/workflows/vercel-web-build.yml b/.github/workflows/vercel-web-build.yml new file mode 100644 index 0000000..7d53b54 --- /dev/null +++ b/.github/workflows/vercel-web-build.yml @@ -0,0 +1,24 @@ +name: Vercel Production Deployment +env: + VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} + EXPO_PUBLIC_SERVER_URL: ${{ secrets.EXPO_PUBLIC_SERVER_URL }} +on: + pull_request: + branches: + - main +jobs: + Deploy-Production: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Install Vercel CLI + run: npm install --global vercel@latest + - name: Pull Vercel Environment Information + run: vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }} + - name: Install Project Dependencies + run: yarn install + - name: Build Project Artifacts + run: vercel build --prod --token=${{ secrets.VERCEL_TOKEN }} + - name: Deploy Project Artifacts to Vercel + run: vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_TOKEN }} From 0c0785bf9a4f8a0ca4e06a6269b5b78e99f1224f Mon Sep 17 00:00:00 2001 From: Zero Date: Fri, 20 Sep 2024 01:42:28 +0900 Subject: [PATCH 03/21] =?UTF-8?q?Feat/#41=20Image=20Picker=EB=A5=BC=20?= =?UTF-8?q?=ED=99=9C=EC=9A=A9=ED=95=9C=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=A9?= =?UTF-8?q?=EB=8B=88=EB=8B=A4.=20(#53)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: web용 모달 스타일을 추가한다. * feat: image-picker를 이용한 프로젝트 이미지 등록을 추가합니다. * feat: date-input을 작성합니다. --- app.json | 6 ++ app/(app)/project/create.tsx | 11 ++- package.json | 1 + src/components/common/date-input/index.tsx | 25 +++++ src/components/common/date-input/style.ts | 9 ++ src/components/common/image-input/index.tsx | 24 +++++ src/components/common/image-input/style.ts | 12 +++ src/components/common/preview-image/index.tsx | 21 ++++ src/components/common/preview-image/style.ts | 14 +++ .../ProjectInviteModal.style.ts | 11 ++- .../project/ProjectRegisterForm/index.tsx | 97 +++++++++++++++++++ .../project/ProjectRegisterForm/style.ts | 40 ++++++++ yarn.lock | 21 ++++ 13 files changed, 286 insertions(+), 6 deletions(-) create mode 100644 src/components/common/date-input/index.tsx create mode 100644 src/components/common/date-input/style.ts create mode 100644 src/components/common/image-input/index.tsx create mode 100644 src/components/common/image-input/style.ts create mode 100644 src/components/common/preview-image/index.tsx create mode 100644 src/components/common/preview-image/style.ts create mode 100644 src/components/project/ProjectRegisterForm/index.tsx create mode 100644 src/components/project/ProjectRegisterForm/style.ts diff --git a/app.json b/app.json index 3ccf3c4..446eb63 100644 --- a/app.json +++ b/app.json @@ -39,6 +39,12 @@ "assets/fonts/Pretendard-Reqular.otf" ] } + ], + [ + "expo-image-picker", + { + "photosPermission": "$(PRODUCT_NAME) 에서 사진 접근 권한을 승인합니다." + } ] ], "experiments": { diff --git a/app/(app)/project/create.tsx b/app/(app)/project/create.tsx index b392e10..7dea93b 100644 --- a/app/(app)/project/create.tsx +++ b/app/(app)/project/create.tsx @@ -1,21 +1,22 @@ -import { SafeAreaView } from 'react-native'; +import { ScrollView } from 'react-native'; -import Typography from '@/components/common/typography'; +import ProjectRegisterForm from '@/components/project/ProjectRegisterForm'; import { useTabBarEffect } from '@/hooks'; import { color } from '@/styles/theme'; import { getSize } from '@/utils'; function Create() { useTabBarEffect(); + return ( - - 프로젝트 생성 - + + ); } diff --git a/package.json b/package.json index 3d3574b..1cc9fbe 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "expo": "~51.0.21", "expo-constants": "~16.0.2", "expo-font": "~12.0.9", + "expo-image-picker": "~15.0.7", "expo-linear-gradient": "~13.0.2", "expo-linking": "~6.3.1", "expo-router": "~3.5.18", diff --git a/src/components/common/date-input/index.tsx b/src/components/common/date-input/index.tsx new file mode 100644 index 0000000..30ab14d --- /dev/null +++ b/src/components/common/date-input/index.tsx @@ -0,0 +1,25 @@ +import { AntDesign } from '@expo/vector-icons'; + +import Typography from '@/components/common/typography'; + +import * as S from './style'; + +type Props = { + onChange?: () => void; + date?: string; +}; + +function DateInput({ onChange, date }: Props) { + return ( + + + {date} + + ); +} + +export default DateInput; diff --git a/src/components/common/date-input/style.ts b/src/components/common/date-input/style.ts new file mode 100644 index 0000000..3d1ff3c --- /dev/null +++ b/src/components/common/date-input/style.ts @@ -0,0 +1,9 @@ +import styled from '@emotion/native'; + +import { flexItemCenter } from '@/styles/common'; + +export const Container = styled.Pressable` + ${flexItemCenter}; + padding: 18px 16px; + border-radius: 8px; +`; diff --git a/src/components/common/image-input/index.tsx b/src/components/common/image-input/index.tsx new file mode 100644 index 0000000..197ecd6 --- /dev/null +++ b/src/components/common/image-input/index.tsx @@ -0,0 +1,24 @@ +import { AntDesign } from '@expo/vector-icons'; + +import { color } from '@/styles/theme'; + +import * as S from './style'; + +type Props = { + onChange: () => void; +}; + +function ImageInput({ onChange }: Props) { + return ( + + + + ); +} + +export default ImageInput; diff --git a/src/components/common/image-input/style.ts b/src/components/common/image-input/style.ts new file mode 100644 index 0000000..8e0ffa3 --- /dev/null +++ b/src/components/common/image-input/style.ts @@ -0,0 +1,12 @@ +import styled from '@emotion/native'; + +import { flexDirectionColumnItemsCenter } from '@/styles/common'; + +export const Container = styled.Pressable` + ${flexDirectionColumnItemsCenter}; + width: 66px; + height: 60px; + padding: 18px 21px; + background: ${({ theme }) => theme.color.Background.Normal}; + border-radius: 8px; +`; diff --git a/src/components/common/preview-image/index.tsx b/src/components/common/preview-image/index.tsx new file mode 100644 index 0000000..92967be --- /dev/null +++ b/src/components/common/preview-image/index.tsx @@ -0,0 +1,21 @@ +import * as S from './style'; + +type Props = { + images: string[]; +}; + +function PreviewImage({ images }: Props) { + return ( + + {images.map((image, index) => ( + + ))} + + ); +} + +export default PreviewImage; diff --git a/src/components/common/preview-image/style.ts b/src/components/common/preview-image/style.ts new file mode 100644 index 0000000..665c224 --- /dev/null +++ b/src/components/common/preview-image/style.ts @@ -0,0 +1,14 @@ +import styled from '@emotion/native'; + +import { flexDirectionRow } from '@/styles/common'; + +export const Container = styled.View` + ${flexDirectionRow}; +`; + +export const Image = styled.Image` + width: 66px; + height: 60px; + background: ${({ theme }) => theme.color.Background.Normal}; + border-radius: 8px; +`; diff --git a/src/components/project/ProjectInviteModal/ProjectInviteModal.style.ts b/src/components/project/ProjectInviteModal/ProjectInviteModal.style.ts index 704e486..09e3bb0 100644 --- a/src/components/project/ProjectInviteModal/ProjectInviteModal.style.ts +++ b/src/components/project/ProjectInviteModal/ProjectInviteModal.style.ts @@ -1,11 +1,20 @@ -import styled from '@emotion/native'; +import styled, { css } from '@emotion/native'; +import { Platform } from 'react-native'; +import { SCREEN_SIZE } from '@/constants'; import { flexDirectionColumn, flexDirectionColumnItemsCenter } from '@/styles/common'; import { color } from '@/styles/theme'; import { getSize } from '@/utils'; +const WebContainerStyle = css` + max-width: ${SCREEN_SIZE.Web + 'px'}; + padding: 20px; + margin: 0 auto; +`; + export const Container = styled.View` ${flexDirectionColumnItemsCenter}; + ${Platform.OS === 'web' && WebContainerStyle} flex: 1; background: ${color.Background.Alternative}; `; diff --git a/src/components/project/ProjectRegisterForm/index.tsx b/src/components/project/ProjectRegisterForm/index.tsx new file mode 100644 index 0000000..d553484 --- /dev/null +++ b/src/components/project/ProjectRegisterForm/index.tsx @@ -0,0 +1,97 @@ +import { launchImageLibraryAsync, MediaTypeOptions } from 'expo-image-picker'; +import { useState } from 'react'; + +import SolidButton from '@/components/common/button/SolidButton'; +import DateInput from '@/components/common/date-input'; +import ImageInput from '@/components/common/image-input'; +import InputField from '@/components/common/input-field'; +import PreviewImage from '@/components/common/preview-image'; +import Typography from '@/components/common/typography'; +import { color } from '@/styles/theme'; + +import * as S from './style'; + +function ProjectRegisterForm() { + const [image, setImage] = useState(null); + + const pickImage = async () => { + const result = await launchImageLibraryAsync({ + mediaTypes: MediaTypeOptions.Images, + allowsEditing: true, + aspect: [4, 4], + quality: 1, + }); + + if (!result.canceled) { + setImage(result.assets[0].uri); + } + }; + + return ( + + + + + 프로젝트 이름 + + + + + + 프로젝트 정보 + + + + + + 프로젝트 이미지 + + + + + + + + + 기간 + + + + - + + + + + + 프로젝트 링크 + + + + + + + 다음 + + + + ); +} + +export default ProjectRegisterForm; diff --git a/src/components/project/ProjectRegisterForm/style.ts b/src/components/project/ProjectRegisterForm/style.ts new file mode 100644 index 0000000..00b8238 --- /dev/null +++ b/src/components/project/ProjectRegisterForm/style.ts @@ -0,0 +1,40 @@ +import styled from '@emotion/native'; + +import { flexDirectionColumn, flexDirectionRow } from '@/styles/common'; + +export const Container = styled.View` + ${flexDirectionColumn}; + gap: 20px; + padding: 20px; +`; + +export const Form = styled.View` + ${flexDirectionColumn}; + gap: 36px; +`; + +export const InputContainer = styled.View` + ${flexDirectionColumn}; + gap: 8px; +`; + +export const ImageBox = styled.View` + ${flexDirectionRow}; + gap: 8px; +`; + +export const DatePickerBox = styled.View` + ${flexDirectionRow}; + gap: 8px; +`; + +export const DateSplitText = styled.Text` + font-family: Pretendard, serif; + font-size: 24px; + line-height: 29px; + color: ${({ theme }) => theme.color.Label.Normal}; +`; + +export const SubmitButtonBox = styled.View` + padding: 12px 0 52px; +`; diff --git a/yarn.lock b/yarn.lock index f463cae..97d376c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9212,6 +9212,7 @@ __metadata: expo: "npm:~51.0.21" expo-constants: "npm:~16.0.2" expo-font: "npm:~12.0.9" + expo-image-picker: "npm:~15.0.7" expo-linear-gradient: "npm:~13.0.2" expo-linking: "npm:~6.3.1" expo-router: "npm:~3.5.18" @@ -10399,6 +10400,26 @@ __metadata: languageName: node linkType: hard +"expo-image-loader@npm:~4.7.0": + version: 4.7.0 + resolution: "expo-image-loader@npm:4.7.0" + peerDependencies: + expo: "*" + checksum: 10c0/7db9df154f1b44e9877361e0707715503eaaf2b18968b6aecb6b0205612cf40bfd99ab8c6e6d7de3a5abd35267e9636963d5050e082af063d8e527763d597eb1 + languageName: node + linkType: hard + +"expo-image-picker@npm:~15.0.7": + version: 15.0.7 + resolution: "expo-image-picker@npm:15.0.7" + dependencies: + expo-image-loader: "npm:~4.7.0" + peerDependencies: + expo: "*" + checksum: 10c0/b8869732fa68e907f8da14038f10477bbbb098f3b144d857e9677c59a3557841bb7beac3dcf3b27896c3223d7723e8a83ffcd3d7376ba1d016ee8c9a76096e46 + languageName: node + linkType: hard + "expo-keep-awake@npm:~13.0.2": version: 13.0.2 resolution: "expo-keep-awake@npm:13.0.2" From 78e72f74be1b4574de0699538779bbf27c8ef66e Mon Sep 17 00:00:00 2001 From: Zero Date: Sun, 22 Sep 2024 21:30:57 +0900 Subject: [PATCH 04/21] =?UTF-8?q?Feat/#41=20Date=20Time=20Picker=EB=A5=BC?= =?UTF-8?q?=20=ED=99=9C=EC=9A=A9=ED=95=9C=20=EB=82=A0=EC=A7=9C=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=20=EA=B8=B0=EB=8A=A5=EC=9D=84=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=ED=95=A9=EB=8B=88=EB=8B=A4.=20(#54)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: date-input을 스타일을 추가합니다. * feat: date-time을 추가합니다. * feat: 날짜 선택 조건문을 추가합니다. * feat: shadow 배열을 추가합니다. * refactor: isBottomSheetOpen을 위로 올립니다. * refactor: shadow를 적용합니다. * refactor: datetimepicker에서 bottomSheet를 제거합니다. --- app/(app)/project/create.tsx | 163 ++++++++++++++++-- .../oauth2/authorization/kakao/index.tsx | 21 ++- package.json | 2 + src/components/common/date-input/index.tsx | 14 +- src/components/common/date-input/style.ts | 7 +- src/components/common/image-input/index.tsx | 5 +- src/components/common/input-field/index.tsx | 13 +- src/components/common/preview-image/index.tsx | 3 + .../project/ProjectRegisterForm/index.tsx | 97 ----------- .../project/ProjectRegisterForm/style.ts | 17 +- src/styles/shadow.ts | 57 ++++++ yarn.lock | 45 ++++- 12 files changed, 305 insertions(+), 139 deletions(-) delete mode 100644 src/components/project/ProjectRegisterForm/index.tsx create mode 100644 src/styles/shadow.ts diff --git a/app/(app)/project/create.tsx b/app/(app)/project/create.tsx index 7dea93b..260da4d 100644 --- a/app/(app)/project/create.tsx +++ b/app/(app)/project/create.tsx @@ -1,22 +1,165 @@ -import { ScrollView } from 'react-native'; +import type BottomSheet from '@gorhom/bottom-sheet'; +import type { DateTimePickerEvent } from '@react-native-community/datetimepicker'; +import DateTimePicker from '@react-native-community/datetimepicker'; +import { launchImageLibraryAsync, MediaTypeOptions } from 'expo-image-picker'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Alert, ScrollView } from 'react-native'; +import { GestureHandlerRootView } from 'react-native-gesture-handler'; -import ProjectRegisterForm from '@/components/project/ProjectRegisterForm'; +import SolidButton from '@/components/common/button/SolidButton'; +import DateInput from '@/components/common/date-input'; +import ImageInput from '@/components/common/image-input'; +import InputField from '@/components/common/input-field'; +import PreviewImage from '@/components/common/preview-image'; +import Typography from '@/components/common/typography'; +import * as S from '@/components/project/ProjectRegisterForm/style'; import { useTabBarEffect } from '@/hooks'; import { color } from '@/styles/theme'; import { getSize } from '@/utils'; function Create() { useTabBarEffect(); + const [image, setImage] = useState(null); + const [selectDate, setSelectDate] = useState<'start' | 'end'>('start'); + const [startDate, setStartDate] = useState(() => new Date()); + const [endDate, setEndDate] = useState(() => new Date()); + + const [dataSheetOpen, setDataSheetOpen] = useState(false); + + const dateSheetRef = useRef(null); + const dataSheetClose = useCallback(() => { + setDataSheetOpen(false); + dateSheetRef.current?.close(); + }, []); + + const pickImage = useCallback(async () => { + const result = await launchImageLibraryAsync({ + mediaTypes: MediaTypeOptions.Images, + allowsEditing: true, + aspect: [4, 4], + quality: 1, + }); + + if (!result.canceled) { + setImage(result.assets[0].uri); + } + }, []); + + const selectDateHandler = useCallback( + (_: DateTimePickerEvent, date = new Date()) => { + dataSheetClose(); + if (selectDate === 'start') { + if (date > endDate) return Alert.alert('이전일보다 늦은 시작일은 선택할 수 없습니다.'); + setStartDate(date); + } else { + if (startDate > date) return Alert.alert('시작일보다 빠른 이전일은 선택할 수 없습니다.'); + setEndDate(date); + } + }, + [dataSheetClose, endDate, selectDate, startDate] + ); + + const startDateOpen = useCallback((select: 'start' | 'end') => { + dateSheetRef.current?.snapToIndex(0); + setDataSheetOpen(true); + setSelectDate(select); + }, []); + + useEffect(() => { + return () => dataSheetClose(); + }, [dataSheetClose]); return ( - - - + + + + + + + 프로젝트 이름 + + + + + + 프로젝트 정보 + + + + + + 프로젝트 이미지 + + + + + + + + + 기간 + + + startDateOpen('start')} + /> + - + startDateOpen('end')} + /> + + + + + 프로젝트 링크 + + + + + + + 다음 + + + + + {dataSheetOpen && ( + + )} + ); } diff --git a/app/(beforeLogin)/oauth2/authorization/kakao/index.tsx b/app/(beforeLogin)/oauth2/authorization/kakao/index.tsx index f4dbb0b..3a5f821 100644 --- a/app/(beforeLogin)/oauth2/authorization/kakao/index.tsx +++ b/app/(beforeLogin)/oauth2/authorization/kakao/index.tsx @@ -1,5 +1,6 @@ import styled from '@emotion/native'; import { Redirect, useRouter } from 'expo-router'; +import React, { useCallback } from 'react'; import { Platform } from 'react-native'; import type { WebViewNavigation } from 'react-native-webview'; import WebView from 'react-native-webview'; @@ -9,14 +10,18 @@ const KAKAO_LOGIN_URI = `${process.env.EXPO_PUBLIC_SERVER_URL}/oauth2/authorizat function KakaoLoginScreen() { const router = useRouter(); - const handleNavigationChangeState = (event: WebViewNavigation) => { - const url = new URL(event.url); - const params = new URLSearchParams(url.search); - - const token = params.get('token'); - const refresh = params.get('refresh'); - router.push({ pathname: '/oauth2/authorization/login', params: { token, refresh } }); - }; + const handleNavigationChangeState = useCallback( + (event: WebViewNavigation) => { + const url = new URL(event.url); + const params = new URLSearchParams(url.search); + const token = params.get('token'); + const refresh = params.get('refresh'); + if (token && refresh) { + router.push({ pathname: '/oauth2/authorization/login', params: { token, refresh } }); + } + }, + [router] + ); if (Platform.OS === 'web') { return ; diff --git a/package.json b/package.json index 1cc9fbe..9372360 100644 --- a/package.json +++ b/package.json @@ -36,10 +36,12 @@ "@emotion/native": "^11.11.0", "@emotion/react": "^11.11.4", "@expo/vector-icons": "^14.0.2", + "@gorhom/bottom-sheet": "^4", "@react-navigation/native": "^6.0.2", "@tanstack/react-query": "^5.51.11", "axios": "^1.7.7", "chromatic": "^11.5.5", + "dayjs": "^1.11.13", "effect": "^3.5.6", "eslint-plugin-simple-import-sort": "^12.1.1", "eslint-plugin-unused-imports": "^4.0.0", diff --git a/src/components/common/date-input/index.tsx b/src/components/common/date-input/index.tsx index 30ab14d..2fd0ce8 100644 --- a/src/components/common/date-input/index.tsx +++ b/src/components/common/date-input/index.tsx @@ -1,23 +1,27 @@ import { AntDesign } from '@expo/vector-icons'; +import dayjs from 'dayjs'; import Typography from '@/components/common/typography'; +import { shadow } from '@/styles/shadow'; import * as S from './style'; type Props = { - onChange?: () => void; - date?: string; + date: Date; + onPress: () => void; }; -function DateInput({ onChange, date }: Props) { +function DateInput({ onPress, date }: Props) { return ( - + - {date} + {dayjs(date).format('YYYY-MM-DD')} ); } diff --git a/src/components/common/date-input/style.ts b/src/components/common/date-input/style.ts index 3d1ff3c..9cd772c 100644 --- a/src/components/common/date-input/style.ts +++ b/src/components/common/date-input/style.ts @@ -1,9 +1,12 @@ import styled from '@emotion/native'; -import { flexItemCenter } from '@/styles/common'; +import { flexDirectionRowItemsCenter } from '@/styles/common'; export const Container = styled.Pressable` - ${flexItemCenter}; + ${flexDirectionRowItemsCenter}; + flex-grow: 1; + gap: 8px; padding: 18px 16px; + background: ${({ theme }) => theme.color.Background.Normal}; border-radius: 8px; `; diff --git a/src/components/common/image-input/index.tsx b/src/components/common/image-input/index.tsx index 197ecd6..2e7e39a 100644 --- a/src/components/common/image-input/index.tsx +++ b/src/components/common/image-input/index.tsx @@ -1,5 +1,6 @@ import { AntDesign } from '@expo/vector-icons'; +import { shadow } from '@/styles/shadow'; import { color } from '@/styles/theme'; import * as S from './style'; @@ -10,7 +11,9 @@ type Props = { function ImageInput({ onChange }: Props) { return ( - + diff --git a/src/components/common/preview-image/index.tsx b/src/components/common/preview-image/index.tsx index 92967be..7596d5f 100644 --- a/src/components/common/preview-image/index.tsx +++ b/src/components/common/preview-image/index.tsx @@ -1,3 +1,5 @@ +import { shadow } from '@/styles/shadow'; + import * as S from './style'; type Props = { @@ -9,6 +11,7 @@ function PreviewImage({ images }: Props) { {images.map((image, index) => ( (null); - - const pickImage = async () => { - const result = await launchImageLibraryAsync({ - mediaTypes: MediaTypeOptions.Images, - allowsEditing: true, - aspect: [4, 4], - quality: 1, - }); - - if (!result.canceled) { - setImage(result.assets[0].uri); - } - }; - - return ( - - - - - 프로젝트 이름 - - - - - - 프로젝트 정보 - - - - - - 프로젝트 이미지 - - - - - - - - - 기간 - - - - - - - - - - - 프로젝트 링크 - - - - - - - 다음 - - - - ); -} - -export default ProjectRegisterForm; diff --git a/src/components/project/ProjectRegisterForm/style.ts b/src/components/project/ProjectRegisterForm/style.ts index 00b8238..77d4c94 100644 --- a/src/components/project/ProjectRegisterForm/style.ts +++ b/src/components/project/ProjectRegisterForm/style.ts @@ -1,6 +1,10 @@ -import styled from '@emotion/native'; +import styled, { css } from '@emotion/native'; -import { flexDirectionColumn, flexDirectionRow } from '@/styles/common'; +import { + flexDirectionColumn, + flexDirectionRow, + flexDirectionRowItemsCenter, +} from '@/styles/common'; export const Container = styled.View` ${flexDirectionColumn}; @@ -24,12 +28,17 @@ export const ImageBox = styled.View` `; export const DatePickerBox = styled.View` - ${flexDirectionRow}; + ${flexDirectionRowItemsCenter}; gap: 8px; `; +const fontFamlily = css({ + fontFamily: 'Pretendard', + fontWeight: 400, +}); + export const DateSplitText = styled.Text` - font-family: Pretendard, serif; + ${fontFamlily}; font-size: 24px; line-height: 29px; color: ${({ theme }) => theme.color.Label.Normal}; diff --git a/src/styles/shadow.ts b/src/styles/shadow.ts new file mode 100644 index 0000000..3020c1f --- /dev/null +++ b/src/styles/shadow.ts @@ -0,0 +1,57 @@ +export const shadow = [ + { + shadowColor: '#000', + shadowOffset: { + width: 0, + height: 1, + }, + shadowOpacity: 0.18, + shadowRadius: 1.0, + + elevation: 1, + }, + { + shadowColor: '#000', + shadowOffset: { + width: 0, + height: 1, + }, + shadowOpacity: 0.2, + shadowRadius: 1.41, + + elevation: 2, + }, + { + shadowColor: '#000', + shadowOffset: { + width: 0, + height: 1, + }, + shadowOpacity: 0.22, + shadowRadius: 2.22, + + elevation: 3, + }, + { + shadowColor: '#000', + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.23, + shadowRadius: 2.62, + + elevation: 4, + }, + { + shadowColor: '#000', + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.25, + shadowRadius: 3.84, + + elevation: 5, + }, +] as const; diff --git a/yarn.lock b/yarn.lock index 97d376c..ffcafe3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2632,6 +2632,40 @@ __metadata: languageName: node linkType: hard +"@gorhom/bottom-sheet@npm:^4": + version: 4.6.4 + resolution: "@gorhom/bottom-sheet@npm:4.6.4" + dependencies: + "@gorhom/portal": "npm:1.0.14" + invariant: "npm:^2.2.4" + peerDependencies: + "@types/react": "*" + "@types/react-native": "*" + react: "*" + react-native: "*" + react-native-gesture-handler: ">=1.10.1" + react-native-reanimated: ">=2.2.0" + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-native": + optional: true + checksum: 10c0/1e724733e55d200490234b00e034f5ffbf312570469c1431358ef123f52b398e3300588ffef17deba82a37deb980ddf316454429600b778344dfd8c36669ab35 + languageName: node + linkType: hard + +"@gorhom/portal@npm:1.0.14": + version: 1.0.14 + resolution: "@gorhom/portal@npm:1.0.14" + dependencies: + nanoid: "npm:^3.3.1" + peerDependencies: + react: "*" + react-native: "*" + checksum: 10c0/86f33afc2ac2656a86a6f3fd1e41565419839576ede2c38333434a93a0a2fe4fb6fc18ab3360579427f2a1fc3b4564b933cc5ae1793a7e2825c93860a00b215f + languageName: node + linkType: hard + "@graphql-typed-document-node/core@npm:^3.1.0": version: 3.2.0 resolution: "@graphql-typed-document-node/core@npm:3.2.0" @@ -8876,6 +8910,13 @@ __metadata: languageName: node linkType: hard +"dayjs@npm:^1.11.13": + version: 1.11.13 + resolution: "dayjs@npm:1.11.13" + checksum: 10c0/a3caf6ac8363c7dade9d1ee797848ddcf25c1ace68d9fe8678ecf8ba0675825430de5d793672ec87b24a69bf04a1544b176547b2539982275d5542a7955f35b7 + languageName: node + linkType: hard + "dayjs@npm:^1.8.15": version: 1.11.11 resolution: "dayjs@npm:1.11.11" @@ -9180,6 +9221,7 @@ __metadata: "@eslint/compat": "npm:^1.1.1" "@eslint/js": "npm:^9.7.0" "@expo/vector-icons": "npm:^14.0.2" + "@gorhom/bottom-sheet": "npm:^4" "@react-native-async-storage/async-storage": "npm:1.24.0" "@react-native-community/datetimepicker": "npm:8.2.0" "@react-native-community/slider": "npm:4.5.2" @@ -9200,6 +9242,7 @@ __metadata: axios: "npm:^1.7.7" babel-loader: "npm:8.3.0" chromatic: "npm:^11.5.5" + dayjs: "npm:^1.11.13" effect: "npm:^3.5.6" eslint: "npm:^8.57.0" eslint-config-expo: "npm:^7.0.0" @@ -14779,7 +14822,7 @@ __metadata: languageName: node linkType: hard -"nanoid@npm:^3.1.23, nanoid@npm:^3.3.7": +"nanoid@npm:^3.1.23, nanoid@npm:^3.3.1, nanoid@npm:^3.3.7": version: 3.3.7 resolution: "nanoid@npm:3.3.7" bin: From 1f3b38031ebd1ac241521849359730302747177f Mon Sep 17 00:00:00 2001 From: Zero Date: Mon, 23 Sep 2024 20:01:08 +0900 Subject: [PATCH 05/21] =?UTF-8?q?Feat/#41=20=ED=94=84=EB=A1=9C=EC=A0=9D?= =?UTF-8?q?=ED=8A=B8=20=EC=83=9D=EC=84=B1=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=ED=8D=BC=EB=B8=94=EB=A6=AC=EC=8B=B1=EC=9D=84=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C=ED=95=A9=EB=8B=88=EB=8B=A4.=20(#55)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 불필요한 ref를 삭제한다. * feat: 프로젝트 생성 페이지 퍼블리싱을 완료합니다. * feat: 유저 리스트 선택 퍼블리싱을 완료한다. * feat: 필수 표시를 추가합니다. --- app/(app)/project/create.tsx | 151 +++++++++++++++--- package.json | 1 + src/components/common/input-field/index.tsx | 46 +++--- src/components/common/input-field/style.ts | 9 +- .../project/ProjectRegisterForm/style.ts | 35 ++++ .../project/SearchUserList/index.tsx | 140 ++++++++++++++++ .../project/SearchUserList/stlye.ts | 47 ++++++ src/styles/theme.ts | 2 +- yarn.lock | 8 + 9 files changed, 390 insertions(+), 49 deletions(-) create mode 100644 src/components/project/SearchUserList/index.tsx create mode 100644 src/components/project/SearchUserList/stlye.ts diff --git a/app/(app)/project/create.tsx b/app/(app)/project/create.tsx index 260da4d..790ff14 100644 --- a/app/(app)/project/create.tsx +++ b/app/(app)/project/create.tsx @@ -1,8 +1,10 @@ -import type BottomSheet from '@gorhom/bottom-sheet'; +import { SimpleLineIcons } from '@expo/vector-icons'; +import type { BottomSheetModal } from '@gorhom/bottom-sheet'; +import BottomSheet, { BottomSheetView } from '@gorhom/bottom-sheet'; import type { DateTimePickerEvent } from '@react-native-community/datetimepicker'; import DateTimePicker from '@react-native-community/datetimepicker'; import { launchImageLibraryAsync, MediaTypeOptions } from 'expo-image-picker'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Alert, ScrollView } from 'react-native'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; @@ -13,23 +15,42 @@ import InputField from '@/components/common/input-field'; import PreviewImage from '@/components/common/preview-image'; import Typography from '@/components/common/typography'; import * as S from '@/components/project/ProjectRegisterForm/style'; +import type { User } from '@/components/project/SearchUserList'; +import SearchUserList from '@/components/project/SearchUserList'; import { useTabBarEffect } from '@/hooks'; +import { shadow } from '@/styles/shadow'; import { color } from '@/styles/theme'; import { getSize } from '@/utils'; function Create() { useTabBarEffect(); const [image, setImage] = useState(null); - const [selectDate, setSelectDate] = useState<'start' | 'end'>('start'); const [startDate, setStartDate] = useState(() => new Date()); const [endDate, setEndDate] = useState(() => new Date()); + const [selectUserList, setSelectUserList] = useState([]); + + const [selectDate, setSelectDate] = useState<'start' | 'end'>('start'); + const userListBottomSheetRef = useRef(null); const [dataSheetOpen, setDataSheetOpen] = useState(false); + const [userListSheetOpen, setUserListSheetOpen] = useState(false); - const dateSheetRef = useRef(null); const dataSheetClose = useCallback(() => { setDataSheetOpen(false); - dateSheetRef.current?.close(); + }, []); + + const sheetHeight = useMemo(() => getSize.screenHeight * 0.75, []); + + const snapPoints = useMemo(() => [sheetHeight], []); + + const openUserListSheet = useCallback(() => { + setUserListSheetOpen(true); + userListBottomSheetRef.current?.snapToIndex(0); + }, []); + + const closeUserListSheet = useCallback(() => { + setUserListSheetOpen(false); + userListBottomSheetRef.current?.close(); }, []); const pickImage = useCallback(async () => { @@ -60,7 +81,6 @@ function Create() { ); const startDateOpen = useCallback((select: 'start' | 'end') => { - dateSheetRef.current?.snapToIndex(0); setDataSheetOpen(true); setSelectDate(select); }, []); @@ -80,30 +100,54 @@ function Create() { - - 프로젝트 이름 - + + + 프로젝트 이름 + + + * + + - - 프로젝트 정보 - + + + 프로젝트 정보 + + + * + + - - 프로젝트 이미지 - + + + 프로젝트 이미지 + + + * + + @@ -128,6 +172,41 @@ function Create() { /> + + + 팀원 + + + + } + disabled + placeholder='팀원의 이름을 검색해주세요.' + /> + + + + {selectUserList.map((user) => ( + + + {user.name} + + + ))} + + - 다음 + 등록하기 @@ -159,6 +238,28 @@ function Create() { onChange={selectDateHandler} /> )} + {userListSheetOpen && ( + + )} + + + {userListSheetOpen && ( + + )} + + ); } diff --git a/package.json b/package.json index 9372360..0199eae 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "eslint-plugin-simple-import-sort": "^12.1.1", "eslint-plugin-unused-imports": "^4.0.0", "expo": "~51.0.21", + "expo-checkbox": "~3.0.0", "expo-constants": "~16.0.2", "expo-font": "~12.0.9", "expo-image-picker": "~15.0.7", diff --git a/src/components/common/input-field/index.tsx b/src/components/common/input-field/index.tsx index 0481a60..5ce0c0a 100644 --- a/src/components/common/input-field/index.tsx +++ b/src/components/common/input-field/index.tsx @@ -11,15 +11,26 @@ import { mergeRefs } from '@/utils'; import * as S from './style'; interface InputFieldProps extends TextInputProps { + isShadow?: boolean; touched?: boolean; disabled?: boolean; + backgroundColor?: string; error?: string; icon?: ReactNode; } const InputField = forwardRef( ( - { touched, disabled = false, error, icon = null, multiline, ...props }: InputFieldProps, + { + backgroundColor = 'transparent', + touched, + isShadow = true, + disabled = false, + error, + icon = null, + multiline, + ...props + }: InputFieldProps, ref?: ForwardedRef ) => { const innerRef = useRef(null); @@ -31,25 +42,24 @@ const InputField = forwardRef( return ( - - {icon} - - + {icon} + {touched && !!error && {error}} diff --git a/src/components/common/input-field/style.ts b/src/components/common/input-field/style.ts index 82d467e..cb3826d 100644 --- a/src/components/common/input-field/style.ts +++ b/src/components/common/input-field/style.ts @@ -24,18 +24,17 @@ export const Container = styled.View<{ }>` ${({ $isError, theme }) => $isError && errorStyle(theme)}; ${({ $disabled, theme }) => $disabled && disabledStyle(theme)}; + ${flexDirectionRow}; + gap: 8px; padding: 18px 16px; background-color: ${({ theme }) => theme.color.Background.Normal}; border-radius: 8px; `; -export const InnerContainer = styled.View<{ $isIcon: boolean }>` - ${({ $isIcon }) => $isIcon && hasIconStyle} -`; - -export const TextInput = styled.TextInput` +export const TextInput = styled.TextInput<{ $isIcon: boolean }>` flex-grow: 1; padding: 0; + ${({ $isIcon }) => $isIcon && hasIconStyle} font-family: Pretendard, serif; font-size: 15px; color: ${(props) => props.theme.color.Label.Normal}; diff --git a/src/components/project/ProjectRegisterForm/style.ts b/src/components/project/ProjectRegisterForm/style.ts index 77d4c94..5c733fe 100644 --- a/src/components/project/ProjectRegisterForm/style.ts +++ b/src/components/project/ProjectRegisterForm/style.ts @@ -17,6 +17,11 @@ export const Form = styled.View` gap: 36px; `; +export const RequiredTitleBox = styled.View` + ${flexDirectionRow}; + gap: 2px; +`; + export const InputContainer = styled.View` ${flexDirectionColumn}; gap: 8px; @@ -47,3 +52,33 @@ export const DateSplitText = styled.Text` export const SubmitButtonBox = styled.View` padding: 12px 0 52px; `; + +export const UserListSheetOpenButtonContainer = styled.View` + position: relative; +`; + +export const UserListSheetOpenButton = styled.Pressable` + position: absolute; + width: 100%; + height: 55px; +`; + +export const BottomSheetBackground = styled.SafeAreaView` + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: ${({ theme }) => theme.color.Material.Dimmer}; +`; + +export const SelectUserList = styled.View` + ${flexDirectionColumn}; + gap: 8px; +`; + +export const SelectUserItem = styled.View` + padding: 18px 16px; + background: ${({ theme }) => theme.color.Background.Normal}; + border-radius: 8px; +`; diff --git a/src/components/project/SearchUserList/index.tsx b/src/components/project/SearchUserList/index.tsx new file mode 100644 index 0000000..0db5c76 --- /dev/null +++ b/src/components/project/SearchUserList/index.tsx @@ -0,0 +1,140 @@ +import { SimpleLineIcons } from '@expo/vector-icons'; +import { Checkbox } from 'expo-checkbox'; +import type { Dispatch, SetStateAction } from 'react'; +import { useCallback, useMemo } from 'react'; +import { useState } from 'react'; +import { Pressable } from 'react-native'; + +import InputField from '@/components/common/input-field'; +import Typography from '@/components/common/typography'; +import { color } from '@/styles/theme'; + +import * as S from './stlye'; + +export type User = { + id: number; + name: string; + userId: string; + profileImage: string; +}; + +type Props = { + selectUserList: User[]; + setSelectUserList: Dispatch>; + closeBottomSheet: () => void; +}; + +function SearchUserList({ selectUserList, setSelectUserList, closeBottomSheet }: Props) { + const [userList, setUserList] = useState(() => selectUserList); + + const previousUserList = useMemo(() => selectUserList, [selectUserList]); + + const addUser = useCallback((user: User) => { + setUserList((prev) => [...prev, user]); + }, []); + + const deleteUser = useCallback((id: number) => { + setUserList((prev) => prev.filter((user) => user.id !== id)); + }, []); + + const addClick = useCallback(() => { + setSelectUserList(userList); + closeBottomSheet(); + }, [closeBottomSheet, setSelectUserList, userList]); + + const cancelClick = useCallback(() => { + setSelectUserList(previousUserList); + closeBottomSheet(); + }, [closeBottomSheet, previousUserList, setSelectUserList]); + + const data = [ + { + id: 1, + name: '양의진', + userId: 'dml1335', + profileImage: 'https://avatars.githubusercontent.com/u/77464040?v=4', + }, + { + id: 2, + name: '양의진', + userId: 'asdf091', + profileImage: 'https://avatars.githubusercontent.com/u/77464040?v=4', + }, + { + id: 3, + name: '이예지', + userId: 'joyn1245', + profileImage: 'https://avatars.githubusercontent.com/u/77464040?v=4', + }, + ]; + + return ( + + + + + 취소 + + + + + 추가 + + + + + + } + placeholder='팀원의 이름을 검색해주세요.' + /> + + {data.map((user) => { + const isChecked = userList.some((u) => u.id === user.id); + return ( + + + + + + {user.name} + + + #{user.userId} + + + + deleteUser(user.id) : () => addUser(user)} + value={isChecked} + color={isChecked ? color.Primary.Normal : undefined} + /> + + ); + })} + + + + ); +} + +export default SearchUserList; diff --git a/src/components/project/SearchUserList/stlye.ts b/src/components/project/SearchUserList/stlye.ts new file mode 100644 index 0000000..dc8e2e2 --- /dev/null +++ b/src/components/project/SearchUserList/stlye.ts @@ -0,0 +1,47 @@ +import styled from '@emotion/native'; + +import { flexDirectionColumn, flexDirectionRowItemsCenter } from '@/styles/common'; + +export const Container = styled.View` + ${flexDirectionColumn}; + gap: 16px; +`; + +export const ActionBox = styled.View` + ${flexDirectionRowItemsCenter}; + justify-content: space-between; + width: 100%; + padding: 0 12px; +`; + +export const Inner = styled.View` + ${flexDirectionColumn}; + gap: 24px; + padding: 20px; +`; + +export const UserItem = styled.View` + ${flexDirectionRowItemsCenter}; + justify-content: space-between; +`; + +export const UserListBox = styled.View` + ${flexDirectionColumn}; + gap: 16px; +`; + +export const UserProfileImage = styled.Image` + width: 48px; + height: 48px; + border: 1px solid ${({ theme }) => theme.color.Line.Normal}; + border-radius: 8px; +`; + +export const ProfileBox = styled.View` + ${flexDirectionRowItemsCenter}; + gap: 12px; +`; + +export const UserTextContainer = styled.View` + ${flexDirectionRowItemsCenter}; +`; diff --git a/src/styles/theme.ts b/src/styles/theme.ts index 432103d..f3c31ed 100644 --- a/src/styles/theme.ts +++ b/src/styles/theme.ts @@ -221,7 +221,7 @@ const semantic = { Alternative: 'rgba(112, 115, 124, 0.05)', }, Material: { - Dimmer: 'rgba(23, 23, 25, 0.52)', + Dimmer: '#0c0c0d', }, }; diff --git a/yarn.lock b/yarn.lock index ffcafe3..8f14d20 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9253,6 +9253,7 @@ __metadata: eslint-plugin-storybook: "npm:^0.8.0" eslint-plugin-unused-imports: "npm:^4.0.0" expo: "npm:~51.0.21" + expo-checkbox: "npm:~3.0.0" expo-constants: "npm:~16.0.2" expo-font: "npm:~12.0.9" expo-image-picker: "npm:~15.0.7" @@ -10411,6 +10412,13 @@ __metadata: languageName: node linkType: hard +"expo-checkbox@npm:~3.0.0": + version: 3.0.0 + resolution: "expo-checkbox@npm:3.0.0" + checksum: 10c0/15aa4a33b6d9065e4203c35a7c6918ab1314718bedcbbb2e7c2ec1429305f0b90d4382d5f3e6b108b54cbedf43c1bef6a656e1d7d5d3d3ef027cd30bd9420652 + languageName: node + linkType: hard + "expo-constants@npm:~16.0.0, expo-constants@npm:~16.0.2": version: 16.0.2 resolution: "expo-constants@npm:16.0.2" From 24b4046f1e7f56b75ce21d7e3575880c846bde96 Mon Sep 17 00:00:00 2001 From: Zero Date: Tue, 24 Sep 2024 11:05:38 +0900 Subject: [PATCH 06/21] =?UTF-8?q?Feat/#22=20CategoryChip=EC=9D=84=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=ED=95=A9=EB=8B=88=EB=8B=A4.=20(#56)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../category-chip/CategoryChip.stories.tsx | 50 +++++++ src/components/common/category-chip/index.tsx | 130 ++++++++++++++++++ src/components/common/category-chip/style.ts | 17 +++ 3 files changed, 197 insertions(+) create mode 100644 src/components/common/category-chip/CategoryChip.stories.tsx create mode 100644 src/components/common/category-chip/index.tsx create mode 100644 src/components/common/category-chip/style.ts diff --git a/src/components/common/category-chip/CategoryChip.stories.tsx b/src/components/common/category-chip/CategoryChip.stories.tsx new file mode 100644 index 0000000..0e00a19 --- /dev/null +++ b/src/components/common/category-chip/CategoryChip.stories.tsx @@ -0,0 +1,50 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import CategoryChip from '@/components/common/category-chip/index'; +import Storybook from '@/components/common/storybook'; + +const CategoryChipMeta: Meta = { + title: 'common/CategoryChip', + component: CategoryChip, + argTypes: { + category: { + control: { + type: 'select', + options: ['기술', '커뮤니케이션', '성실성', '협업', '문서화', '시간관리', '리더십'], + }, + description: '카테고리명을 입력합니다.', + }, + }, +}; + +export default CategoryChipMeta; + +export const Primary: StoryObj = { + args: { + category: '기술', + }, +}; + +export const Preview: StoryObj = { + render: () => { + return ( + + + + + + + + + + + + + + + ); + }, +}; diff --git a/src/components/common/category-chip/index.tsx b/src/components/common/category-chip/index.tsx new file mode 100644 index 0000000..5e2f6b9 --- /dev/null +++ b/src/components/common/category-chip/index.tsx @@ -0,0 +1,130 @@ +import { memo, useMemo } from 'react'; +import Svg, { Ellipse, Path } from 'react-native-svg'; + +import Typography from '@/components/common/typography'; +import { shadow } from '@/styles/shadow'; +import { color } from '@/styles/theme'; + +import * as S from './style'; + +type Category = '기술' | '커뮤니케이션' | '성실성' | '협업' | '문서화' | '시간관리' | '리더십'; + +type Props = { + category: Category; +}; + +function CategoryChip({ category }: Props) { + const Icon = useMemo(() => { + switch (category) { + case '기술': + return ( + + + + ); + case '커뮤니케이션': + return ( + + + + ); + case '성실성': + return ( + + + + ); + case '협업': + return ( + + + + ); + case '문서화': + return ( + + + + ); + case '시간관리': + return ( + + + + ); + case '리더십': + return ( + + + + ); + } + }, [category]); + + return ( + + {Icon} + + {category} + + + ); +} + +export default memo(CategoryChip); diff --git a/src/components/common/category-chip/style.ts b/src/components/common/category-chip/style.ts new file mode 100644 index 0000000..bf2b57f --- /dev/null +++ b/src/components/common/category-chip/style.ts @@ -0,0 +1,17 @@ +import styled from '@emotion/native'; + +import { flexDirectionRowItemsCenter } from '@/styles/common'; + +export const Container = styled.View` + ${flexDirectionRowItemsCenter}; + gap: 6px; + width: fit-content; + padding: 12px 16px; + background: ${({ theme }) => theme.color.Background.Normal}; + border-radius: 4px; +`; + +export const IconWrapper = styled.View` + width: 24px; + height: 24px; +`; From a6ca4be34014b822563a1996b258eb4272f57621 Mon Sep 17 00:00:00 2001 From: Zero Date: Tue, 24 Sep 2024 12:56:50 +0900 Subject: [PATCH 07/21] =?UTF-8?q?Feat/#22=20Skeleton=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=A9?= =?UTF-8?q?=EB=8B=88=EB=8B=A4.=20(#57)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Skeleton 컴포넌트를 추가합니다. * refactor: 기본 height 값을 변경합니다. * feat: 스토리북 설정을 추가합니다. * fix: 잘못된 변수명을 수정합니다. --- .../common/skeleton/Skeleton.stories.tsx | 76 +++++++++++++++++++ src/components/common/skeleton/index.tsx | 64 ++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 src/components/common/skeleton/Skeleton.stories.tsx create mode 100644 src/components/common/skeleton/index.tsx diff --git a/src/components/common/skeleton/Skeleton.stories.tsx b/src/components/common/skeleton/Skeleton.stories.tsx new file mode 100644 index 0000000..f2d1c50 --- /dev/null +++ b/src/components/common/skeleton/Skeleton.stories.tsx @@ -0,0 +1,76 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import Storybook from '@/components/common/storybook'; + +import Skeleton from './'; + +const SkeletonMeta: Meta = { + title: 'common/Skeleton', + component: Skeleton, + argTypes: { + width: { + control: { + type: 'number', + }, + description: '스켈레톤의 너비를 지정합니다.', + }, + height: { + control: { + type: 'number', + }, + description: '스켈레톤의 높이를 지정합니다.', + }, + variant: { + control: { + type: 'select', + options: ['text', 'rounded', 'circular'], + }, + description: '스켈레톤의 모양을 지정합니다.', + }, + }, + parameters: { + layout: 'centered', + }, +}; + +export default SkeletonMeta; + +export const Primary: StoryObj = { + args: { + width: 200, + height: 40, + variant: 'text', + }, +}; + +export const Preview: StoryObj = { + render: () => { + return ( + + + + + + + + + + + ); + }, +}; diff --git a/src/components/common/skeleton/index.tsx b/src/components/common/skeleton/index.tsx new file mode 100644 index 0000000..4bd76c8 --- /dev/null +++ b/src/components/common/skeleton/index.tsx @@ -0,0 +1,64 @@ +import styled, { css, type ReactNativeStyle } from '@emotion/native'; +import { memo } from 'react'; +import Animated, { + Easing, + useAnimatedStyle, + useSharedValue, + withRepeat, + withTiming, +} from 'react-native-reanimated'; + +type Props = { + width: ReactNativeStyle['width']; + height: ReactNativeStyle['height']; + variant: 'text' | 'rounded' | 'circular'; +}; + +type VariantType = 'text' | 'rounded' | 'circular'; + +const variantStyle: Record = { + text: css` + border-radius: 0; + `, + rounded: css` + border-radius: 4px; + `, + circular: css` + border-radius: 9999px; + `, +}; + +function Skeleton({ width = '100%', height = 16, variant = 'text' }: Partial) { + const opacity = useSharedValue(1); + + opacity.value = withRepeat( + withTiming(0.4, { + duration: 1000, + easing: Easing.inOut(Easing.ease), + }), + -1, + true + ); + + const style = useAnimatedStyle(() => { + return { + width, + height, + opacity: opacity.value, + backgroundColor: '#ebebeb', + }; + }); + + return ( + + ); +} + +const Item = styled(Animated.View)<{ variant: VariantType }>` + ${({ variant }) => variantStyle[variant]}; +`; + +export default memo(Skeleton); From 1d1b03e335814c5e021716acb6bc240abb77b0bc Mon Sep 17 00:00:00 2001 From: Zero Date: Tue, 24 Sep 2024 15:24:21 +0900 Subject: [PATCH 08/21] =?UTF-8?q?Refactor/#22=20CategoryChip=20=EC=86=8D?= =?UTF-8?q?=EC=84=B1=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=A9=EB=8B=88?= =?UTF-8?q?=EB=8B=A4.=20(#58)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: categorychip의 속성을 추가합니다. * feat: 온보딩 속성을 추가합니다. --- .../category-chip/CategoryChip.stories.tsx | 194 +++++++++++++++++- src/components/common/category-chip/index.tsx | 45 +++- src/components/common/category-chip/style.ts | 13 +- 3 files changed, 239 insertions(+), 13 deletions(-) diff --git a/src/components/common/category-chip/CategoryChip.stories.tsx b/src/components/common/category-chip/CategoryChip.stories.tsx index 0e00a19..d1892d5 100644 --- a/src/components/common/category-chip/CategoryChip.stories.tsx +++ b/src/components/common/category-chip/CategoryChip.stories.tsx @@ -14,6 +14,24 @@ const CategoryChipMeta: Meta = { }, description: '카테고리명을 입력합니다.', }, + hasIcon: { + control: { + type: 'boolean', + }, + description: '아이콘을 표시할지 결정합니다.', + }, + isActive: { + control: { + type: 'boolean', + }, + description: '칩의 활성화 여부를 결정합니다.', + }, + onboarding: { + control: { + type: 'boolean', + }, + description: '온보딩 칩인지 결정합니다.', + }, }, }; @@ -21,7 +39,10 @@ export default CategoryChipMeta; export const Primary: StoryObj = { args: { + hasIcon: false, + isActive: false, category: '기술', + onboarding: false, }, }; @@ -31,17 +52,174 @@ export const Preview: StoryObj = { + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - + + + + + + + diff --git a/src/components/common/category-chip/index.tsx b/src/components/common/category-chip/index.tsx index 5e2f6b9..bb831e9 100644 --- a/src/components/common/category-chip/index.tsx +++ b/src/components/common/category-chip/index.tsx @@ -1,3 +1,5 @@ +import type { ReactNativeStyle } from '@emotion/native'; +import { css } from '@emotion/native'; import { memo, useMemo } from 'react'; import Svg, { Ellipse, Path } from 'react-native-svg'; @@ -10,10 +12,44 @@ import * as S from './style'; type Category = '기술' | '커뮤니케이션' | '성실성' | '협업' | '문서화' | '시간관리' | '리더십'; type Props = { + onboarding?: boolean; + isActive?: boolean; + hasIcon?: boolean; category: Category; }; -function CategoryChip({ category }: Props) { +const ActiveStyle: Record = { + 기술: css` + background: ${color.Blue['95']}; + border: 1px solid ${color.Blue['90']}; + `, + 성실성: css` + background: ${color.Pink['95']}; + border: 1px solid ${color.Pink['90']}; + `, + 협업: css` + background: ${color.Orange['95']}; + border: 1px solid ${color.Orange['90']}; + `, + 문서화: css` + background: ${color.Red['95']}; + border: 1px solid ${color.Red['90']}; + `, + 커뮤니케이션: css` + background: ${color.LightBlue['95']}; + border: 1px solid ${color.LightBlue['90']}; + `, + 시간관리: css` + background: ${color.Violet['95']}; + border: 1px solid ${color.Violet['90']}; + `, + 리더십: css` + background: #e6fad9; + border: 1px solid #c3f0a3; + `, +}; + +function CategoryChip({ category, onboarding = false, hasIcon = true, isActive = false }: Props) { const Icon = useMemo(() => { switch (category) { case '기술': @@ -115,8 +151,11 @@ function CategoryChip({ category }: Props) { }, [category]); return ( - - {Icon} + + {hasIcon && {Icon}} ` + box-sizing: border-box; + ${({ $isActive }) => $isActive && $isActive} ${flexDirectionRowItemsCenter}; gap: 6px; width: fit-content; padding: 12px 16px; - background: ${({ theme }) => theme.color.Background.Normal}; + background: ${({ theme, $isActive, $onboarding }) => + $onboarding ? theme.color.Background.Normal : !$isActive && theme.color.Background.Alternative}; + border: ${({ theme, $isActive }) => + !$isActive && `1px solid ${theme.color.Background.Alternative}`}; border-radius: 4px; `; From 35b07eaf3d8c25b1c3006ac998bc909328804eea Mon Sep 17 00:00:00 2001 From: Zero Date: Tue, 24 Sep 2024 17:39:28 +0900 Subject: [PATCH 09/21] =?UTF-8?q?Feat/#22=20Category=EC=9D=84=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=ED=95=A9=EB=8B=88=EB=8B=A4.=20(#59)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 카테고리 칩을 추가합니다. * refactor: 카테고리의 타입과 스타일을 분리합니다. * fix: height 값을 통일합니다. * refactor: story 파일을 분리합니다. --- .../category-chip/CategoryChip.stories.tsx | 194 +-------------- src/components/common/category-chip/index.tsx | 160 +----------- src/components/common/category-chip/style.ts | 25 +- .../common/category/Category.stories.tsx | 228 ++++++++++++++++++ src/components/common/category/index.tsx | 47 ++++ src/components/common/category/style.ts | 27 +++ src/components/common/icon/category-icon.tsx | 111 +++++++++ src/styles/category.ts | 36 +++ src/types/category.ts | 8 + 9 files changed, 477 insertions(+), 359 deletions(-) create mode 100644 src/components/common/category/Category.stories.tsx create mode 100644 src/components/common/category/index.tsx create mode 100644 src/components/common/category/style.ts create mode 100644 src/components/common/icon/category-icon.tsx create mode 100644 src/styles/category.ts create mode 100644 src/types/category.ts diff --git a/src/components/common/category-chip/CategoryChip.stories.tsx b/src/components/common/category-chip/CategoryChip.stories.tsx index d1892d5..cd2dea4 100644 --- a/src/components/common/category-chip/CategoryChip.stories.tsx +++ b/src/components/common/category-chip/CategoryChip.stories.tsx @@ -4,7 +4,7 @@ import CategoryChip from '@/components/common/category-chip/index'; import Storybook from '@/components/common/storybook'; const CategoryChipMeta: Meta = { - title: 'common/CategoryChip', + title: 'category/CategoryChip', component: CategoryChip, argTypes: { category: { @@ -14,24 +14,6 @@ const CategoryChipMeta: Meta = { }, description: '카테고리명을 입력합니다.', }, - hasIcon: { - control: { - type: 'boolean', - }, - description: '아이콘을 표시할지 결정합니다.', - }, - isActive: { - control: { - type: 'boolean', - }, - description: '칩의 활성화 여부를 결정합니다.', - }, - onboarding: { - control: { - type: 'boolean', - }, - description: '온보딩 칩인지 결정합니다.', - }, }, }; @@ -39,10 +21,7 @@ export default CategoryChipMeta; export const Primary: StoryObj = { args: { - hasIcon: false, - isActive: false, category: '기술', - onboarding: false, }, }; @@ -56,170 +35,13 @@ export const Preview: StoryObj = { variant={['기술', '커뮤니케이션', '성실성', '협업', '문서화', '시간관리', '리더십']} /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + diff --git a/src/components/common/category-chip/index.tsx b/src/components/common/category-chip/index.tsx index bb831e9..4a8889e 100644 --- a/src/components/common/category-chip/index.tsx +++ b/src/components/common/category-chip/index.tsx @@ -1,165 +1,21 @@ -import type { ReactNativeStyle } from '@emotion/native'; -import { css } from '@emotion/native'; -import { memo, useMemo } from 'react'; -import Svg, { Ellipse, Path } from 'react-native-svg'; +import { memo } from 'react'; import Typography from '@/components/common/typography'; -import { shadow } from '@/styles/shadow'; -import { color } from '@/styles/theme'; +import { CategoryStyle } from '@/styles/category'; +import type { CategoryType } from '@/types/category'; import * as S from './style'; -type Category = '기술' | '커뮤니케이션' | '성실성' | '협업' | '문서화' | '시간관리' | '리더십'; - type Props = { - onboarding?: boolean; - isActive?: boolean; - hasIcon?: boolean; - category: Category; -}; - -const ActiveStyle: Record = { - 기술: css` - background: ${color.Blue['95']}; - border: 1px solid ${color.Blue['90']}; - `, - 성실성: css` - background: ${color.Pink['95']}; - border: 1px solid ${color.Pink['90']}; - `, - 협업: css` - background: ${color.Orange['95']}; - border: 1px solid ${color.Orange['90']}; - `, - 문서화: css` - background: ${color.Red['95']}; - border: 1px solid ${color.Red['90']}; - `, - 커뮤니케이션: css` - background: ${color.LightBlue['95']}; - border: 1px solid ${color.LightBlue['90']}; - `, - 시간관리: css` - background: ${color.Violet['95']}; - border: 1px solid ${color.Violet['90']}; - `, - 리더십: css` - background: #e6fad9; - border: 1px solid #c3f0a3; - `, + category: CategoryType; }; -function CategoryChip({ category, onboarding = false, hasIcon = true, isActive = false }: Props) { - const Icon = useMemo(() => { - switch (category) { - case '기술': - return ( - - - - ); - case '커뮤니케이션': - return ( - - - - ); - case '성실성': - return ( - - - - ); - case '협업': - return ( - - - - ); - case '문서화': - return ( - - - - ); - case '시간관리': - return ( - - - - ); - case '리더십': - return ( - - - - ); - } - }, [category]); - +function CategoryChip({ category }: Props) { return ( - - {hasIcon && {Icon}} + + color='#1D212C' + variant='Caption1'> {category} diff --git a/src/components/common/category-chip/style.ts b/src/components/common/category-chip/style.ts index 5bfadf0..8fdf1ef 100644 --- a/src/components/common/category-chip/style.ts +++ b/src/components/common/category-chip/style.ts @@ -1,26 +1,9 @@ import type { ReactNativeStyle } from '@emotion/native'; import styled from '@emotion/native'; -import { flexDirectionRowItemsCenter } from '@/styles/common'; - -export const Container = styled.View<{ - $onboarding: boolean; - $isActive: ReactNativeStyle | boolean; -}>` - box-sizing: border-box; - ${({ $isActive }) => $isActive && $isActive} - ${flexDirectionRowItemsCenter}; - gap: 6px; +export const Container = styled.View<{ $categoryStyle: ReactNativeStyle }>` + ${({ $categoryStyle }) => $categoryStyle}; width: fit-content; - padding: 12px 16px; - background: ${({ theme, $isActive, $onboarding }) => - $onboarding ? theme.color.Background.Normal : !$isActive && theme.color.Background.Alternative}; - border: ${({ theme, $isActive }) => - !$isActive && `1px solid ${theme.color.Background.Alternative}`}; - border-radius: 4px; -`; - -export const IconWrapper = styled.View` - width: 24px; - height: 24px; + padding: 5px 10px; + border-radius: 3px; `; diff --git a/src/components/common/category/Category.stories.tsx b/src/components/common/category/Category.stories.tsx new file mode 100644 index 0000000..caaa85a --- /dev/null +++ b/src/components/common/category/Category.stories.tsx @@ -0,0 +1,228 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import Category from '@/components/common/category/index'; +import Storybook from '@/components/common/storybook'; + +const CategoryMeta: Meta = { + title: 'category/Category', + component: Category, + argTypes: { + category: { + control: { + type: 'select', + options: ['기술', '커뮤니케이션', '성실성', '협업', '문서화', '시간관리', '리더십'], + }, + description: '카테고리명을 입력합니다.', + }, + hasIcon: { + control: { + type: 'boolean', + }, + description: '아이콘을 표시할지 결정합니다.', + }, + isActive: { + control: { + type: 'boolean', + }, + description: '칩의 활성화 여부를 결정합니다.', + }, + onboarding: { + control: { + type: 'boolean', + }, + description: '온보딩 칩인지 결정합니다.', + }, + }, +}; + +export default CategoryMeta; + +export const Primary: StoryObj = { + args: { + hasIcon: false, + isActive: false, + category: '기술', + onboarding: false, + }, +}; + +export const Preview: StoryObj = { + render: () => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); + }, +}; diff --git a/src/components/common/category/index.tsx b/src/components/common/category/index.tsx new file mode 100644 index 0000000..555488a --- /dev/null +++ b/src/components/common/category/index.tsx @@ -0,0 +1,47 @@ +import { memo } from 'react'; + +import CategoryIcon from '@/components/common/icon/category-icon'; +import Typography from '@/components/common/typography'; +import { CategoryStyle } from '@/styles/category'; +import { shadow } from '@/styles/shadow'; +import { color } from '@/styles/theme'; +import type { CategoryType } from '@/types/category'; + +import * as S from './style'; + +type Props = { + onboarding?: boolean; + isActive?: boolean; + hasIcon?: boolean; + hasShadow?: boolean; + category: CategoryType; +}; + +function Category({ + category, + onboarding = false, + hasIcon = true, + isActive = false, + hasShadow = false, +}: Props) { + return ( + + {hasIcon && ( + + + + )} + + {category} + + + ); +} + +export default memo(Category); diff --git a/src/components/common/category/style.ts b/src/components/common/category/style.ts new file mode 100644 index 0000000..f6393c4 --- /dev/null +++ b/src/components/common/category/style.ts @@ -0,0 +1,27 @@ +import type { ReactNativeStyle } from '@emotion/native'; +import styled from '@emotion/native'; + +import { flexDirectionRowItemsCenter } from '@/styles/common'; + +export const Container = styled.View<{ + $onboarding: boolean; + $isActive: ReactNativeStyle | boolean; +}>` + box-sizing: border-box; + ${({ $isActive }) => $isActive && $isActive} + ${flexDirectionRowItemsCenter}; + gap: 6px; + width: fit-content; + height: 48px; + padding: 12px 16px; + background: ${({ theme, $isActive, $onboarding }) => + $onboarding ? theme.color.Background.Normal : !$isActive && theme.color.Background.Alternative}; + border: ${({ theme, $isActive }) => + !$isActive && `1px solid ${theme.color.Background.Alternative}`}; + border-radius: 4px; +`; + +export const IconWrapper = styled.View` + width: 24px; + height: 24px; +`; diff --git a/src/components/common/icon/category-icon.tsx b/src/components/common/icon/category-icon.tsx new file mode 100644 index 0000000..569a73b --- /dev/null +++ b/src/components/common/icon/category-icon.tsx @@ -0,0 +1,111 @@ +import { memo } from 'react'; +import Svg, { Ellipse, Path } from 'react-native-svg'; + +import { color } from '@/styles/theme'; +import type { CategoryType } from '@/types/category'; + +type Props = { + category: CategoryType; +}; + +function CategoryIcon({ category }: Props) { + switch (category) { + case '기술': + return ( + + + + ); + case '커뮤니케이션': + return ( + + + + ); + case '성실성': + return ( + + + + ); + case '협업': + return ( + + + + ); + case '문서화': + return ( + + + + ); + case '시간관리': + return ( + + + + ); + case '리더십': + return ( + + + + ); + } +} + +export default memo(CategoryIcon); diff --git a/src/styles/category.ts b/src/styles/category.ts new file mode 100644 index 0000000..2cffa2f --- /dev/null +++ b/src/styles/category.ts @@ -0,0 +1,36 @@ +import type { ReactNativeStyle } from '@emotion/native'; +import { css } from '@emotion/native'; + +import { color } from '@/styles/theme'; +import type { CategoryType } from '@/types/category'; + +export const CategoryStyle: Record = { + 기술: css` + background: ${color.Blue['95']}; + border: 1px solid ${color.Blue['90']}; + `, + 성실성: css` + background: ${color.Pink['95']}; + border: 1px solid ${color.Pink['90']}; + `, + 협업: css` + background: ${color.Orange['95']}; + border: 1px solid ${color.Orange['90']}; + `, + 문서화: css` + background: ${color.Red['95']}; + border: 1px solid ${color.Red['90']}; + `, + 커뮤니케이션: css` + background: ${color.LightBlue['95']}; + border: 1px solid ${color.LightBlue['90']}; + `, + 시간관리: css` + background: ${color.Violet['95']}; + border: 1px solid ${color.Violet['90']}; + `, + 리더십: css` + background: #e6fad9; + border: 1px solid #c3f0a3; + `, +} as const; diff --git a/src/types/category.ts b/src/types/category.ts new file mode 100644 index 0000000..fd3f2b9 --- /dev/null +++ b/src/types/category.ts @@ -0,0 +1,8 @@ +export type CategoryType = + | '기술' + | '커뮤니케이션' + | '성실성' + | '협업' + | '문서화' + | '시간관리' + | '리더십'; From 32417bfdac0ffde48b6229d4f0d7154bc45a0093 Mon Sep 17 00:00:00 2001 From: Zero Date: Tue, 24 Sep 2024 18:52:53 +0900 Subject: [PATCH 10/21] =?UTF-8?q?feat:=20ReviewSkeletonCard=EB=A5=BC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=ED=95=A9=EB=8B=88=EB=8B=A4.=20(#61)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/common/category-chip/index.tsx | 2 +- src/components/review/ProjectChip/index.tsx | 21 +++++++ src/components/review/ProjectChip/style.ts | 8 +++ src/components/review/ReviewCard/index.tsx | 42 +++++++++++++ src/components/review/ReviewCard/style.ts | 16 +++++ .../ReviewSkeletonCard.stories.tsx | 60 +++++++++++++++++++ .../review/ReviewSkeletonCard/index.tsx | 32 ++++++++++ .../review/ReviewSkeletonCard/style.ts | 12 ++++ 8 files changed, 192 insertions(+), 1 deletion(-) create mode 100644 src/components/review/ProjectChip/index.tsx create mode 100644 src/components/review/ProjectChip/style.ts create mode 100644 src/components/review/ReviewCard/index.tsx create mode 100644 src/components/review/ReviewCard/style.ts create mode 100644 src/components/review/ReviewSkeletonCard/ReviewSkeletonCard.stories.tsx create mode 100644 src/components/review/ReviewSkeletonCard/index.tsx create mode 100644 src/components/review/ReviewSkeletonCard/style.ts diff --git a/src/components/common/category-chip/index.tsx b/src/components/common/category-chip/index.tsx index 4a8889e..7bc55c9 100644 --- a/src/components/common/category-chip/index.tsx +++ b/src/components/common/category-chip/index.tsx @@ -15,7 +15,7 @@ function CategoryChip({ category }: Props) { + variant='Label1/Reading'> {category} diff --git a/src/components/review/ProjectChip/index.tsx b/src/components/review/ProjectChip/index.tsx new file mode 100644 index 0000000..bb3e82c --- /dev/null +++ b/src/components/review/ProjectChip/index.tsx @@ -0,0 +1,21 @@ +import type { PropsWithChildren } from 'react'; +import { memo } from 'react'; + +import Typography from '@/components/common/typography'; + +import * as S from './style'; + +function ProjectChip({ children }: PropsWithChildren) { + return ( + + + {children} + + + ); +} + +export default memo(ProjectChip); diff --git a/src/components/review/ProjectChip/style.ts b/src/components/review/ProjectChip/style.ts new file mode 100644 index 0000000..6c6d77e --- /dev/null +++ b/src/components/review/ProjectChip/style.ts @@ -0,0 +1,8 @@ +import styled from '@emotion/native'; + +export const Container = styled.View` + width: fit-content; + padding: 6px 12px; + background: ${({ theme }) => theme.color.CoolNeutral['98']}; + border-radius: 4px; +`; diff --git a/src/components/review/ReviewCard/index.tsx b/src/components/review/ReviewCard/index.tsx new file mode 100644 index 0000000..e1b6d9f --- /dev/null +++ b/src/components/review/ReviewCard/index.tsx @@ -0,0 +1,42 @@ +import type { PropsWithChildren } from 'react'; + +import Typography from '@/components/common/typography'; +import ProjectChip from '@/components/review/ProjectChip'; + +import * as S from './style'; + +type DateBoxProps = { + startDate: string; + endDate: string; +}; + +function DateBox({ startDate, endDate }: DateBoxProps) { + return ( + + {`${startDate} - ${endDate}`} + + ); +} + +type Props = { + projectName: string; +}; + +function Card({ projectName, children }: PropsWithChildren) { + return ( + + {projectName} + {children} + + ); +} + +const ReviewCard = Object.assign(Card, { + DateBox, +}); + +export default ReviewCard; diff --git a/src/components/review/ReviewCard/style.ts b/src/components/review/ReviewCard/style.ts new file mode 100644 index 0000000..8a034f6 --- /dev/null +++ b/src/components/review/ReviewCard/style.ts @@ -0,0 +1,16 @@ +import styled from '@emotion/native'; + +import { flexDirectionColumn } from '@/styles/common'; + +export const Container = styled.View` + ${flexDirectionColumn}; + gap: 16px; + padding: 12px; + background-color: ${({ theme }) => theme.color.Background.Normal}; + border-radius: 4px; +`; + +export const ContentsBox = styled.View` + ${flexDirectionColumn}; + gap: 8px; +`; diff --git a/src/components/review/ReviewSkeletonCard/ReviewSkeletonCard.stories.tsx b/src/components/review/ReviewSkeletonCard/ReviewSkeletonCard.stories.tsx new file mode 100644 index 0000000..e4acd2a --- /dev/null +++ b/src/components/review/ReviewSkeletonCard/ReviewSkeletonCard.stories.tsx @@ -0,0 +1,60 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { View } from 'react-native'; + +import { SCREEN_SIZE } from '@/constants'; +import { color } from '@/styles/theme'; + +import ReviewSkeletonCard from './'; + +const ReviewSkeletonCardMeta: Meta = { + title: 'review/ReviewSkeletonCard', + component: ReviewSkeletonCard, + argTypes: { + projectName: { + control: { + type: 'text', + }, + description: '프로젝트 이름을 입력합니다.', + }, + startDate: { + control: { + type: 'text', + description: '프로젝트 시작 날짜를 입력합니다.', + }, + }, + endDate: { + control: { + type: 'text', + description: '프로젝트 끝난 날짜를 입력합니다.', + }, + }, + }, +}; + +export default ReviewSkeletonCardMeta; + +export const Primary: StoryObj = { + args: { + projectName: '프로젝트', + startDate: '2024-07', + endDate: '2024-10', + }, + render: (args) => { + return ( + + + + + + ); + }, +}; diff --git a/src/components/review/ReviewSkeletonCard/index.tsx b/src/components/review/ReviewSkeletonCard/index.tsx new file mode 100644 index 0000000..4fc4e61 --- /dev/null +++ b/src/components/review/ReviewSkeletonCard/index.tsx @@ -0,0 +1,32 @@ +import Skeleton from '@/components/common/skeleton'; +import ReviewCard from '@/components/review/ReviewCard'; + +import * as S from './style'; + +type Props = { + projectName: string; + startDate: string; + endDate: string; +}; + +function ReviewSkeletonCard({ projectName, startDate, endDate }: Props) { + return ( + + + + + + + + + + ); +} + +export default ReviewSkeletonCard; diff --git a/src/components/review/ReviewSkeletonCard/style.ts b/src/components/review/ReviewSkeletonCard/style.ts new file mode 100644 index 0000000..90b4c5c --- /dev/null +++ b/src/components/review/ReviewSkeletonCard/style.ts @@ -0,0 +1,12 @@ +import styled from '@emotion/native'; + +import { flexDirectionColumn } from '@/styles/common'; + +export const Container = styled.View` + padding: 14px; +`; + +export const SkeletonBox = styled.View` + ${flexDirectionColumn}; + gap: 8px; +`; From 134314991e7804a7513a62891946fdff7a287353 Mon Sep 17 00:00:00 2001 From: Zero Date: Wed, 25 Sep 2024 14:28:13 +0900 Subject: [PATCH 11/21] =?UTF-8?q?Feat/#60=20QuestionnaireCheckListSkeleton?= =?UTF-8?q?=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=A9=EB=8B=88=EB=8B=A4.=20(#?= =?UTF-8?q?62)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 폴더 구조를 변경합니다. * feat: QuestionnaireCheckListSkeleton를 추가합니다. --- app/(app)/project/create.tsx | 10 +-- src/components/common/date-input/index.tsx | 12 +-- src/components/common/date-input/style.ts | 4 + src/components/common/icon/radio-icon.tsx | 53 ++++++++++++ src/components/common/input-field/style.ts | 4 +- .../QuestionnaireCheckList/index.tsx | 80 +++++++++++++++++++ .../QuestionnaireCheckList/style.ts | 49 ++++++++++++ ...QuestionnaireCheckListSkeleton.stories.tsx | 26 ++++++ .../QuestionnaireCheckListSkeleton/index.tsx | 44 ++++++++++ .../QuestionnaireCheckListSkeleton/style.ts | 8 ++ .../category-chip/CategoryChip.stories.tsx | 4 +- .../category-chip/index.tsx | 0 .../category-chip/style.ts | 0 .../category/Category.stories.tsx | 4 +- .../category/index.tsx | 0 .../category/style.ts | 1 + src/components/review/ProjectChip/index.tsx | 21 ----- src/components/review/ProjectChip/style.ts | 8 -- src/components/review/ReviewCard/index.tsx | 10 ++- src/components/review/ReviewCard/style.ts | 7 ++ 20 files changed, 297 insertions(+), 48 deletions(-) create mode 100644 src/components/common/icon/radio-icon.tsx create mode 100644 src/components/questionnaire/QuestionnaireCheckList/index.tsx create mode 100644 src/components/questionnaire/QuestionnaireCheckList/style.ts create mode 100644 src/components/questionnaire/QuestionnaireCheckListSkeleton/QuestionnaireCheckListSkeleton.stories.tsx create mode 100644 src/components/questionnaire/QuestionnaireCheckListSkeleton/index.tsx create mode 100644 src/components/questionnaire/QuestionnaireCheckListSkeleton/style.ts rename src/components/{common => questionnaire}/category-chip/CategoryChip.stories.tsx (92%) rename src/components/{common => questionnaire}/category-chip/index.tsx (100%) rename src/components/{common => questionnaire}/category-chip/style.ts (100%) rename src/components/{common => questionnaire}/category/Category.stories.tsx (98%) rename src/components/{common => questionnaire}/category/index.tsx (100%) rename src/components/{common => questionnaire}/category/style.ts (95%) delete mode 100644 src/components/review/ProjectChip/index.tsx delete mode 100644 src/components/review/ProjectChip/style.ts diff --git a/app/(app)/project/create.tsx b/app/(app)/project/create.tsx index 790ff14..afae1bf 100644 --- a/app/(app)/project/create.tsx +++ b/app/(app)/project/create.tsx @@ -1,4 +1,4 @@ -import { SimpleLineIcons } from '@expo/vector-icons'; +import { AntDesign } from '@expo/vector-icons'; import type { BottomSheetModal } from '@gorhom/bottom-sheet'; import BottomSheet, { BottomSheetView } from '@gorhom/bottom-sheet'; import type { DateTimePickerEvent } from '@react-native-community/datetimepicker'; @@ -41,8 +41,6 @@ function Create() { const sheetHeight = useMemo(() => getSize.screenHeight * 0.75, []); - const snapPoints = useMemo(() => [sheetHeight], []); - const openUserListSheet = useCallback(() => { setUserListSheetOpen(true); userListBottomSheetRef.current?.snapToIndex(0); @@ -182,8 +180,8 @@ function Create() { @@ -249,7 +247,7 @@ function Create() { ref={userListBottomSheetRef} index={-1} enablePanDownToClose - snapPoints={snapPoints}> + snapPoints={[sheetHeight]}> {userListSheetOpen && ( - + + + {dayjs(date).format('YYYY-MM-DD')} ); diff --git a/src/components/common/date-input/style.ts b/src/components/common/date-input/style.ts index 9cd772c..3093594 100644 --- a/src/components/common/date-input/style.ts +++ b/src/components/common/date-input/style.ts @@ -10,3 +10,7 @@ export const Container = styled.Pressable` background: ${({ theme }) => theme.color.Background.Normal}; border-radius: 8px; `; + +export const IconBox = styled.View` + ${flexDirectionRowItemsCenter}; +`; diff --git a/src/components/common/icon/radio-icon.tsx b/src/components/common/icon/radio-icon.tsx new file mode 100644 index 0000000..167ea4b --- /dev/null +++ b/src/components/common/icon/radio-icon.tsx @@ -0,0 +1,53 @@ +import Svg, { Circle, Rect } from 'react-native-svg'; + +type Props = { + activeColor: string; + inActiveColor: string; + isChecked: boolean; +}; + +function RadioIcon({ activeColor, inActiveColor, isChecked }: Props) { + if (isChecked) { + return ( + + + + + ); + } else { + return ( + + + + ); + } +} + +export default RadioIcon; diff --git a/src/components/common/input-field/style.ts b/src/components/common/input-field/style.ts index cb3826d..993a87b 100644 --- a/src/components/common/input-field/style.ts +++ b/src/components/common/input-field/style.ts @@ -1,7 +1,7 @@ import styled, { css } from '@emotion/native'; import type { Theme } from '@emotion/react'; -import { flexDirectionRow } from '@/styles/common'; +import { flexDirectionRow, flexDirectionRowItemsCenter } from '@/styles/common'; const errorStyle = (theme: Theme) => css` border-color: ${theme.color.Status.Error}; @@ -24,7 +24,7 @@ export const Container = styled.View<{ }>` ${({ $isError, theme }) => $isError && errorStyle(theme)}; ${({ $disabled, theme }) => $disabled && disabledStyle(theme)}; - ${flexDirectionRow}; + ${flexDirectionRowItemsCenter}; gap: 8px; padding: 18px 16px; background-color: ${({ theme }) => theme.color.Background.Normal}; diff --git a/src/components/questionnaire/QuestionnaireCheckList/index.tsx b/src/components/questionnaire/QuestionnaireCheckList/index.tsx new file mode 100644 index 0000000..356ab29 --- /dev/null +++ b/src/components/questionnaire/QuestionnaireCheckList/index.tsx @@ -0,0 +1,80 @@ +import type { PropsWithChildren } from 'react'; +import { useContext, useState } from 'react'; +import { createContext } from 'react'; +import { memo } from 'react'; + +import RadioIcon from '@/components/common/icon/radio-icon'; +import Typography from '@/components/common/typography'; +import { color } from '@/styles/theme'; +import type { CategoryType } from '@/types/category'; + +import CategoryChip from '../category-chip'; +import * as S from './style'; + +type ItemProps = { + value: string | number; +}; + +const ListContext = createContext<{ + checkValue: string | null | number; + setCheckValue: (newValue: string | null | number) => void; +}>({ + checkValue: null, + setCheckValue: () => null, +}); + +function Item({ children, value }: PropsWithChildren) { + const { checkValue, setCheckValue } = useContext(ListContext); + const isChecked = checkValue === value; + return ( + + {children} + setCheckValue(value)}> + + + + ); +} + +type QuestionnaireCheckListProps = { + title: string; + category: CategoryType; + initialCheckValue?: string | number; +}; + +function CheckList({ + title, + category, + initialCheckValue, + children, +}: PropsWithChildren) { + const [checkValue, setCheckValue] = useState(() => + initialCheckValue ? initialCheckValue : null + ); + return ( + + + + + {title} + + {children} + + + ); +} + +const QuestionnaireCheckList = Object.assign(CheckList, { Item: memo(Item) }); + +export default QuestionnaireCheckList; diff --git a/src/components/questionnaire/QuestionnaireCheckList/style.ts b/src/components/questionnaire/QuestionnaireCheckList/style.ts new file mode 100644 index 0000000..30c59ba --- /dev/null +++ b/src/components/questionnaire/QuestionnaireCheckList/style.ts @@ -0,0 +1,49 @@ +import styled, { css } from '@emotion/native'; +import type { Theme } from '@emotion/react'; + +import { flexDirectionColumn, flexDirectionRowItemsCenter } from '@/styles/common'; + +export const Container = styled.View` + ${flexDirectionColumn}; + gap: 20px; + width: 272px; + padding: 28px 16px; + background: ${({ theme }) => theme.color.Background.Normal}; + border-radius: 13px; +`; + +const inActiveStyle = (theme: Theme) => css` + background: ${theme.color.Background.Normal}; + border: 1px solid ${theme.color.Line.Normal}; +`; + +const activeStyle = (theme: Theme) => css` + background: ${theme.color.Blue['95']}; + border: 1px solid ${theme.color.Primary.Normal}; +`; + +export const ItemContainer = styled.View<{ $isChecked: boolean }>` + ${flexDirectionRowItemsCenter}; + ${({ theme, $isChecked }) => ($isChecked ? activeStyle(theme) : inActiveStyle(theme))}; + justify-content: space-between; + padding: 13px 28px 13px 13px; + border-radius: 7px; +`; + +export const ListContainer = styled.View` + ${flexDirectionColumn}; + gap: 8px; +`; + +export const ItemValue = styled.View` + ${flexDirectionColumn}; +`; + +export const RadioButton = styled.Pressable` + ${flexDirectionRowItemsCenter}; + position: absolute; + top: 0; + right: 0; + height: 100%; + padding: 13px; +`; diff --git a/src/components/questionnaire/QuestionnaireCheckListSkeleton/QuestionnaireCheckListSkeleton.stories.tsx b/src/components/questionnaire/QuestionnaireCheckListSkeleton/QuestionnaireCheckListSkeleton.stories.tsx new file mode 100644 index 0000000..5be4d7a --- /dev/null +++ b/src/components/questionnaire/QuestionnaireCheckListSkeleton/QuestionnaireCheckListSkeleton.stories.tsx @@ -0,0 +1,26 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { View } from 'react-native'; + +import { color } from '@/styles/theme'; + +import QuestionnaireCheckListSkeleton from './'; + +const SkeletonMeta: Meta = { + title: 'questionnaire/QuestionnaireCheckListSkeleton', + component: QuestionnaireCheckListSkeleton, + parameters: { + layout: 'centered', + }, +}; + +export default SkeletonMeta; + +export const Primary: StoryObj = { + render: () => { + return ( + + + + ); + }, +}; diff --git a/src/components/questionnaire/QuestionnaireCheckListSkeleton/index.tsx b/src/components/questionnaire/QuestionnaireCheckListSkeleton/index.tsx new file mode 100644 index 0000000..ba0f344 --- /dev/null +++ b/src/components/questionnaire/QuestionnaireCheckListSkeleton/index.tsx @@ -0,0 +1,44 @@ +import Skeleton from '@/components/common/skeleton'; +import QuestionnaireCheckList from '@/components/questionnaire/QuestionnaireCheckList'; + +import * as S from './style'; + +function SkeletonItem() { + return ( + + + + + ); +} + +function QuestionnaireCheckListSkeleton() { + return ( + + + + + + + + + + + + + + + ); +} + +export default QuestionnaireCheckListSkeleton; diff --git a/src/components/questionnaire/QuestionnaireCheckListSkeleton/style.ts b/src/components/questionnaire/QuestionnaireCheckListSkeleton/style.ts new file mode 100644 index 0000000..db12fb8 --- /dev/null +++ b/src/components/questionnaire/QuestionnaireCheckListSkeleton/style.ts @@ -0,0 +1,8 @@ +import styled from '@emotion/native'; + +import { flexDirectionColumnCenter } from '@/styles/common'; + +export const SkeletonBox = styled.View` + ${flexDirectionColumnCenter}; + gap: 4px; +`; diff --git a/src/components/common/category-chip/CategoryChip.stories.tsx b/src/components/questionnaire/category-chip/CategoryChip.stories.tsx similarity index 92% rename from src/components/common/category-chip/CategoryChip.stories.tsx rename to src/components/questionnaire/category-chip/CategoryChip.stories.tsx index cd2dea4..b98f6b1 100644 --- a/src/components/common/category-chip/CategoryChip.stories.tsx +++ b/src/components/questionnaire/category-chip/CategoryChip.stories.tsx @@ -1,10 +1,10 @@ import type { Meta, StoryObj } from '@storybook/react'; -import CategoryChip from '@/components/common/category-chip/index'; import Storybook from '@/components/common/storybook'; +import CategoryChip from '@/components/questionnaire/category-chip/index'; const CategoryChipMeta: Meta = { - title: 'category/CategoryChip', + title: 'questionnaire/CategoryChip', component: CategoryChip, argTypes: { category: { diff --git a/src/components/common/category-chip/index.tsx b/src/components/questionnaire/category-chip/index.tsx similarity index 100% rename from src/components/common/category-chip/index.tsx rename to src/components/questionnaire/category-chip/index.tsx diff --git a/src/components/common/category-chip/style.ts b/src/components/questionnaire/category-chip/style.ts similarity index 100% rename from src/components/common/category-chip/style.ts rename to src/components/questionnaire/category-chip/style.ts diff --git a/src/components/common/category/Category.stories.tsx b/src/components/questionnaire/category/Category.stories.tsx similarity index 98% rename from src/components/common/category/Category.stories.tsx rename to src/components/questionnaire/category/Category.stories.tsx index caaa85a..b82793a 100644 --- a/src/components/common/category/Category.stories.tsx +++ b/src/components/questionnaire/category/Category.stories.tsx @@ -1,10 +1,10 @@ import type { Meta, StoryObj } from '@storybook/react'; -import Category from '@/components/common/category/index'; import Storybook from '@/components/common/storybook'; +import Category from '@/components/questionnaire/category/index'; const CategoryMeta: Meta = { - title: 'category/Category', + title: 'questionnaire/Category', component: Category, argTypes: { category: { diff --git a/src/components/common/category/index.tsx b/src/components/questionnaire/category/index.tsx similarity index 100% rename from src/components/common/category/index.tsx rename to src/components/questionnaire/category/index.tsx diff --git a/src/components/common/category/style.ts b/src/components/questionnaire/category/style.ts similarity index 95% rename from src/components/common/category/style.ts rename to src/components/questionnaire/category/style.ts index f6393c4..8e5bd52 100644 --- a/src/components/common/category/style.ts +++ b/src/components/questionnaire/category/style.ts @@ -22,6 +22,7 @@ export const Container = styled.View<{ `; export const IconWrapper = styled.View` + ${flexDirectionRowItemsCenter}; width: 24px; height: 24px; `; diff --git a/src/components/review/ProjectChip/index.tsx b/src/components/review/ProjectChip/index.tsx deleted file mode 100644 index bb3e82c..0000000 --- a/src/components/review/ProjectChip/index.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import type { PropsWithChildren } from 'react'; -import { memo } from 'react'; - -import Typography from '@/components/common/typography'; - -import * as S from './style'; - -function ProjectChip({ children }: PropsWithChildren) { - return ( - - - {children} - - - ); -} - -export default memo(ProjectChip); diff --git a/src/components/review/ProjectChip/style.ts b/src/components/review/ProjectChip/style.ts deleted file mode 100644 index 6c6d77e..0000000 --- a/src/components/review/ProjectChip/style.ts +++ /dev/null @@ -1,8 +0,0 @@ -import styled from '@emotion/native'; - -export const Container = styled.View` - width: fit-content; - padding: 6px 12px; - background: ${({ theme }) => theme.color.CoolNeutral['98']}; - border-radius: 4px; -`; diff --git a/src/components/review/ReviewCard/index.tsx b/src/components/review/ReviewCard/index.tsx index e1b6d9f..6c1d7cb 100644 --- a/src/components/review/ReviewCard/index.tsx +++ b/src/components/review/ReviewCard/index.tsx @@ -1,7 +1,6 @@ import type { PropsWithChildren } from 'react'; import Typography from '@/components/common/typography'; -import ProjectChip from '@/components/review/ProjectChip'; import * as S from './style'; @@ -29,7 +28,14 @@ type Props = { function Card({ projectName, children }: PropsWithChildren) { return ( - {projectName} + + + {projectName} + + {children} ); diff --git a/src/components/review/ReviewCard/style.ts b/src/components/review/ReviewCard/style.ts index 8a034f6..8590ff2 100644 --- a/src/components/review/ReviewCard/style.ts +++ b/src/components/review/ReviewCard/style.ts @@ -10,6 +10,13 @@ export const Container = styled.View` border-radius: 4px; `; +export const ProjectChip = styled.View` + width: fit-content; + padding: 6px 12px; + background: ${({ theme }) => theme.color.CoolNeutral['98']}; + border-radius: 4px; +`; + export const ContentsBox = styled.View` ${flexDirectionColumn}; gap: 8px; From 24eecd9353b73a7d173f5343fb7bc0b37143c717 Mon Sep 17 00:00:00 2001 From: Zero Date: Wed, 25 Sep 2024 15:42:24 +0900 Subject: [PATCH 12/21] =?UTF-8?q?Feat#22=20BusinessCard=EB=A5=BC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=ED=95=A9=EB=8B=88=EB=8B=A4.=20(#63)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: BusinessCard를 추가합니다. * feat: BusinessCard에 애니메이션을 추가합니다. --- assets/images/main-mock.png | Bin 0 -> 62916 bytes .../BusinessCard/BusinessCard.stories.tsx | 57 ++++++++++++++ src/components/home/BusinessCard/index.tsx | 74 ++++++++++++++++++ src/components/home/BusinessCard/style.ts | 16 ++++ 4 files changed, 147 insertions(+) create mode 100644 assets/images/main-mock.png create mode 100644 src/components/home/BusinessCard/BusinessCard.stories.tsx create mode 100644 src/components/home/BusinessCard/index.tsx create mode 100644 src/components/home/BusinessCard/style.ts diff --git a/assets/images/main-mock.png b/assets/images/main-mock.png new file mode 100644 index 0000000000000000000000000000000000000000..9de5d320c8b662a69d03c1f0217b7de019ddab0f GIT binary patch literal 62916 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%0Atp<3z$F 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 BC653A literal 0 HcmV?d00001 diff --git a/src/components/home/BusinessCard/BusinessCard.stories.tsx b/src/components/home/BusinessCard/BusinessCard.stories.tsx new file mode 100644 index 0000000..b23b85b --- /dev/null +++ b/src/components/home/BusinessCard/BusinessCard.stories.tsx @@ -0,0 +1,57 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { View } from 'react-native'; + +import { color } from '@/styles/theme'; + +import BusinessCard from './'; + +const SkeletonMeta: Meta = { + title: 'home/BusinessCard', + component: BusinessCard, + argTypes: { + name: { + control: { + type: 'text', + }, + description: '이름을 입력해주세요', + }, + review: { + control: { + type: 'text', + }, + description: '리뷰를 입력해주세요', + }, + projectName: { + control: { + type: 'text', + }, + description: '프로젝트 이름을 입력해주세요', + }, + isActive: { + control: { + type: 'boolean', + }, + description: '스크린에 보이는지 여부를 입력해주세요', + }, + }, + parameters: { + layout: 'centered', + }, +}; + +export default SkeletonMeta; + +export const Primary: StoryObj = { + args: { + review: '문제를 척척 해결하는\n' + '책임감 넘치는 슈퍼히어로', + projectName: 'wepro', + name: '김프로', + }, + render: (args) => { + return ( + + + + ); + }, +}; diff --git a/src/components/home/BusinessCard/index.tsx b/src/components/home/BusinessCard/index.tsx new file mode 100644 index 0000000..778a9a3 --- /dev/null +++ b/src/components/home/BusinessCard/index.tsx @@ -0,0 +1,74 @@ +import { memo } from 'react'; +import { Image } from 'react-native'; +import Animated, { useAnimatedStyle, withTiming } from 'react-native-reanimated'; + +import Typography from '@/components/common/typography'; +import { shadow } from '@/styles/shadow'; +import { color } from '@/styles/theme'; + +import * as S from './style'; + +type Props = { + name: string; + projectName: string; + review: string; + isActive?: boolean; +}; + +function BusinessCard({ name, review, projectName, isActive = false }: Props) { + const animationStyle = useAnimatedStyle(() => { + return { + display: 'flex', + flexDirection: 'column', + position: 'absolute', + bottom: -36, + left: -16, + gap: 6, + width: 210, + paddingHorizontal: 24, + paddingVertical: 16, + borderRadius: 16, + backgroundColor: color.Label.Strong, + opacity: withTiming(isActive ? 1 : 0), + }; + }); + + return ( + + + + {name} + + + + + + {review} + + + #{projectName} + + + + ); +} + +export default memo(BusinessCard); diff --git a/src/components/home/BusinessCard/style.ts b/src/components/home/BusinessCard/style.ts new file mode 100644 index 0000000..1792cdf --- /dev/null +++ b/src/components/home/BusinessCard/style.ts @@ -0,0 +1,16 @@ +import styled from '@emotion/native'; + +export const Container = styled.View` + position: relative; + width: 300px; + padding: 74px 0; + background: ${({ theme }) => theme.color.Background.Normal}; + border-radius: 17px; +`; + +export const NameBox = styled.View` + position: absolute; + top: 0; + left: 0; + padding: 20px; +`; From e0a07100bc0361989cdc2185b86f3a550b307519 Mon Sep 17 00:00:00 2001 From: Zero Date: Thu, 26 Sep 2024 10:15:13 +0900 Subject: [PATCH 13/21] =?UTF-8?q?Refactor/#22=20=EB=B3=80=EA=B2=BD?= =?UTF-8?q?=EB=90=9C=20Onboarding=20Flow=EB=A5=BC=20=EB=B0=98=EC=98=81?= =?UTF-8?q?=ED=95=A9=EB=8B=88=EB=8B=A4.=20(#64)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 변경된 Onboarding Flow를 반영합니다. * fix: font 반영이 안되는 현상을 수정합니다. * fix: test를 위해 useAppOpen으로 분리한다. * fix: 잘못된 경로명을 변경합니다. --- .storybook/preview.tsx | 2 + app/(beforeLogin)/onboarding.tsx | 76 +----------------- app/_layout.tsx | 25 +----- src/components/common/progress-bar/index.tsx | 10 ++- .../BusinessCard/BusinessCard.stories.tsx | 6 ++ src/components/home/BusinessCard/index.tsx | 19 +++-- src/components/home/BusinessCard/style.ts | 4 +- .../OnboardingScreen.stories.tsx | 37 +++++++++ .../onboarding/OnboardingScreen/index.tsx | 70 +++++++++++++++++ .../onboarding/OnboardingScreen/style.ts | 24 +++--- .../onboarding/OnboardingSection/index.tsx | 78 +++++++++++++++++++ .../onboarding/OnboardingSection/style.ts | 17 ++++ .../onboarding/OnboardingTitle/index.tsx | 35 +++++++++ .../onboarding/OnboardingTitle/style.ts | 8 ++ .../ProjectInviteModal.style.ts | 2 +- .../QuestionnaireCheckList/index.tsx | 4 +- .../QuestionnaireCheckListSkeleton/index.tsx | 5 +- src/components/review/ReviewCard/style.ts | 1 + .../ReviewSkeletonCard.stories.tsx | 2 +- src/constants/index.ts | 1 - src/constants/onboarding.ts | 26 ------- src/constants/service.ts | 3 +- src/hooks/index.ts | 1 + src/hooks/useAppOpen.ts | 26 +++++++ src/utils/getSize.ts | 4 +- 25 files changed, 329 insertions(+), 157 deletions(-) create mode 100644 src/components/onboarding/OnboardingScreen/OnboardingScreen.stories.tsx create mode 100644 src/components/onboarding/OnboardingScreen/index.tsx rename app/(beforeLogin)/onboarding.styles.tsx => src/components/onboarding/OnboardingScreen/style.ts (52%) create mode 100644 src/components/onboarding/OnboardingSection/index.tsx create mode 100644 src/components/onboarding/OnboardingSection/style.ts create mode 100644 src/components/onboarding/OnboardingTitle/index.tsx create mode 100644 src/components/onboarding/OnboardingTitle/style.ts delete mode 100644 src/constants/onboarding.ts create mode 100644 src/hooks/useAppOpen.ts diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 9406a3c..99cfe30 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -1,5 +1,6 @@ import type { Preview } from '@storybook/react'; import Provider from '../src/components/common/provider'; +import { useAppOpen } from '../src/hooks'; const preview: Preview = { parameters: { @@ -13,6 +14,7 @@ const preview: Preview = { }, decorators: [ (Story) => { + useAppOpen(); return ( diff --git a/app/(beforeLogin)/onboarding.tsx b/app/(beforeLogin)/onboarding.tsx index ab327f5..7370f37 100644 --- a/app/(beforeLogin)/onboarding.tsx +++ b/app/(beforeLogin)/onboarding.tsx @@ -1,88 +1,18 @@ import { router } from 'expo-router'; -import { useCallback, useState } from 'react'; -import Animated, { useAnimatedStyle, withTiming } from 'react-native-reanimated'; +import { useCallback } from 'react'; -import SolidButton from '@/components/common/button/SolidButton'; -import ProgressBar from '@/components/common/progress-bar'; -import Typography from '@/components/common/typography'; -import { ON_BOARDING } from '@/constants'; +import OnboardingScreen from '@/components/onboarding/OnboardingScreen'; import { useOnboarding } from '@/store/useOnboarding'; -import { color } from '@/styles/theme'; -import { getSize } from '@/utils'; - -import * as S from './onboarding.styles'; function Onboarding() { const { checkOnBoarding } = useOnboarding(); - const [step, setStep] = useState(0); - - const handleStep = () => { - if (step === ON_BOARDING.length - 1) { - return handleLastStep(); - } - setStep((prevStep) => prevStep + 1); - }; const handleLastStep = useCallback(() => { checkOnBoarding(); router.replace('sign-in'); }, [checkOnBoarding]); - const backgroundStyle = useAnimatedStyle(() => ({ - position: 'absolute', - flex: 1, - width: getSize.deviceWidth * ON_BOARDING.length, - height: '100%', - left: withTiming(step * -getSize.deviceWidth), - })); - - return ( - - - - {ON_BOARDING.map(({ heading, title }, index) => { - return step === index ? ( - - - - {title} - - - {heading} - - - - ) : null; - })} - - - - - 다음 - - - - 건너뛰기 - - - - - ); + return ; } export default Onboarding; diff --git a/app/_layout.tsx b/app/_layout.tsx index 5498d7f..b534d57 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,36 +1,17 @@ import styled from '@emotion/native'; -import { useFonts } from 'expo-font'; import { Slot, SplashScreen } from 'expo-router'; -import { useEffect } from 'react'; import { Platform } from 'react-native'; import Provider from '@/components/common/provider'; import { SCREEN_SIZE } from '@/constants'; +import { useAppOpen } from '@/hooks'; import { SessionProvider } from '@/store'; import { OnboardingProvider } from '@/store/useOnboarding'; SplashScreen.preventAutoHideAsync(); export default function Root() { - const [loaded, error] = useFonts({ - Pretendard: require('../assets/fonts/Pretendard-Regular.otf'), - 'Pretendard-Bold': require('../assets/fonts/Pretendard-Bold.otf'), - 'Pretendard-SemiBold': require('../assets/fonts/Pretendard-SemiBold.otf'), - 'Pretendard-Medium': require('../assets/fonts/Pretendard-Medium.otf'), - }); - - useEffect(() => { - if (error) throw error; - }, [error]); - - if (loaded) { - SplashScreen.hideAsync(); - } - - if (!loaded) { - return null; - } - + useAppOpen(); if (Platform.OS === 'web') { return ( @@ -68,7 +49,7 @@ const S = { `, Layout: styled.View` flex: 1; - width: ${SCREEN_SIZE.Web + 'px'}; + width: ${SCREEN_SIZE.WEB_WIDTH + 'px'}; height: 100dvh; `, }; diff --git a/src/components/common/progress-bar/index.tsx b/src/components/common/progress-bar/index.tsx index da108f5..ea5b73e 100644 --- a/src/components/common/progress-bar/index.tsx +++ b/src/components/common/progress-bar/index.tsx @@ -3,6 +3,8 @@ import { memo } from 'react'; import type { ViewProps } from 'react-native'; import Animated, { LinearTransition, useAnimatedStyle, withTiming } from 'react-native-reanimated'; +import { flexDirectionRowItemsCenter } from '@/styles/common'; + type StepBarProps = { isActive: boolean; }; @@ -12,8 +14,8 @@ function StepBar({ isActive }: StepBarProps) { return { marginVertical: 'auto', marginHorizontal: 0, - width: 6, - height: withTiming(isActive ? 16 : 6), + width: withTiming(isActive ? 16 : 6), + height: 6, backgroundColor: withTiming(isActive ? '#000' : '#00000026', { duration: 500 }), borderRadius: 30, alignSelf: 'flex-start', @@ -49,10 +51,10 @@ function ProgressBar({ currentStep, stepLength, ...rest }: Props) { } export const Container = styled.View` - display: flex; - flex-direction: row; + ${flexDirectionRowItemsCenter}; gap: 6px; height: 16px; + margin: 0 auto 22px; `; export default memo(ProgressBar); diff --git a/src/components/home/BusinessCard/BusinessCard.stories.tsx b/src/components/home/BusinessCard/BusinessCard.stories.tsx index b23b85b..4d0ba25 100644 --- a/src/components/home/BusinessCard/BusinessCard.stories.tsx +++ b/src/components/home/BusinessCard/BusinessCard.stories.tsx @@ -33,6 +33,12 @@ const SkeletonMeta: Meta = { }, description: '스크린에 보이는지 여부를 입력해주세요', }, + onboarding: { + control: { + type: 'boolean', + }, + description: '온보딩 화면인지 여부를 입력해주세요', + }, }, parameters: { layout: 'centered', diff --git a/src/components/home/BusinessCard/index.tsx b/src/components/home/BusinessCard/index.tsx index 778a9a3..957cc4a 100644 --- a/src/components/home/BusinessCard/index.tsx +++ b/src/components/home/BusinessCard/index.tsx @@ -12,10 +12,11 @@ type Props = { name: string; projectName: string; review: string; + onboarding?: boolean; isActive?: boolean; }; -function BusinessCard({ name, review, projectName, isActive = false }: Props) { +function BusinessCard({ name, review, projectName, onboarding = false, isActive = false }: Props) { const animationStyle = useAnimatedStyle(() => { return { display: 'flex', @@ -34,10 +35,12 @@ function BusinessCard({ name, review, projectName, isActive = false }: Props) { }); return ( - + {name} @@ -49,20 +52,20 @@ function BusinessCard({ name, review, projectName, isActive = false }: Props) { aspectRatio: 1, borderWidth: 0, }} - source={{ uri: require('/assets/images/main-mock.png') }} + source={require('../../../../assets/images/main-mock.png')} resizeMode='center' - width={300} - height={300} + width={onboarding ? 240 : 300} + height={onboarding ? 240 : 300} /> {review} #{projectName} diff --git a/src/components/home/BusinessCard/style.ts b/src/components/home/BusinessCard/style.ts index 1792cdf..4bf0dd1 100644 --- a/src/components/home/BusinessCard/style.ts +++ b/src/components/home/BusinessCard/style.ts @@ -1,8 +1,8 @@ import styled from '@emotion/native'; -export const Container = styled.View` +export const Container = styled.View<{ $isOnboarding: boolean }>` position: relative; - width: 300px; + width: ${({ $isOnboarding }) => ($isOnboarding ? '240px' : '300px')}; padding: 74px 0; background: ${({ theme }) => theme.color.Background.Normal}; border-radius: 17px; diff --git a/src/components/onboarding/OnboardingScreen/OnboardingScreen.stories.tsx b/src/components/onboarding/OnboardingScreen/OnboardingScreen.stories.tsx new file mode 100644 index 0000000..5fd3b06 --- /dev/null +++ b/src/components/onboarding/OnboardingScreen/OnboardingScreen.stories.tsx @@ -0,0 +1,37 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { View } from 'react-native'; + +import { SCREEN_SIZE } from '@/constants'; + +import OnboardingScreen from './'; + +const ProjectInviteModalMeta: Meta = { + title: 'screens/Onboarding', + component: OnboardingScreen, + argTypes: { + handleLastStep: { + action: '마지막 페이지로 이동합니다.', + description: '마지막 페이지로 이동될 때 실행되는 함수', + }, + }, + parameters: { + layout: 'centered', + }, +}; + +export default ProjectInviteModalMeta; + +export const Preview: StoryObj = { + args: { + handleLastStep: () => { + console.log('마지막 페이지로 이동합니다.'); + }, + }, + render: (args) => { + return ( + + + + ); + }, +}; diff --git a/src/components/onboarding/OnboardingScreen/index.tsx b/src/components/onboarding/OnboardingScreen/index.tsx new file mode 100644 index 0000000..c5b7a14 --- /dev/null +++ b/src/components/onboarding/OnboardingScreen/index.tsx @@ -0,0 +1,70 @@ +import { useCallback, useState } from 'react'; +import Animated, { useAnimatedStyle, withTiming } from 'react-native-reanimated'; + +import SolidButton from '@/components/common/button/SolidButton'; +import ProgressBar from '@/components/common/progress-bar'; +import Typography from '@/components/common/typography'; +import OnboardingSection from '@/components/onboarding/OnboardingSection'; +import OnboardingTitle, { ON_BOARDING } from '@/components/onboarding/OnboardingTitle'; +import { getSize } from '@/utils'; + +import * as S from './style'; + +type Props = { + handleLastStep: () => void; +}; + +function OnboardingScreen({ handleLastStep }: Props) { + const [step, setStep] = useState(0); + + const handleStep = useCallback(() => { + if (step === ON_BOARDING.length - 1) { + return handleLastStep(); + } + setStep((prevStep) => prevStep + 1); + }, [step]); + + const backgroundStyle = useAnimatedStyle(() => ({ + position: 'absolute', + flex: 1, + width: getSize.deviceWidth * ON_BOARDING.length, + height: '100%', + left: withTiming(step * -getSize.deviceWidth), + })); + + return ( + + + + + + + + + + + + 다음 + + + + 건너뛰기 + + + + + ); +} + +export default OnboardingScreen; diff --git a/app/(beforeLogin)/onboarding.styles.tsx b/src/components/onboarding/OnboardingScreen/style.ts similarity index 52% rename from app/(beforeLogin)/onboarding.styles.tsx rename to src/components/onboarding/OnboardingScreen/style.ts index 5d71cda..57a075d 100644 --- a/app/(beforeLogin)/onboarding.styles.tsx +++ b/src/components/onboarding/OnboardingScreen/style.ts @@ -1,9 +1,10 @@ import styled from '@emotion/native'; -import Constants from 'expo-constants/src/Constants'; -import { flexDirectionColumn, flexItemCenter } from '@/styles/common'; - -const statusBarHeight = Constants.statusBarHeight || 0; +import { + flexDirectionColumn, + flexDirectionColumnItemsCenter, + flexItemCenter, +} from '@/styles/common'; export const Container = styled.SafeAreaView` flex: 1; @@ -14,19 +15,14 @@ export const Container = styled.SafeAreaView` export const OnBoardingWrapper = styled.View` ${flexDirectionColumn}; + flex-grow: 1; gap: 32px; - padding-horizontal: 32px; -`; - -export const ContentBox = styled.View` - justify-content: space-between; - ${`padding-top: ${statusBarHeight + 135}px`}; + padding: 0 20px; `; -export const TextWrapper = styled.View` - gap: 12px; - width: 100%; - ${flexDirectionColumn}; +export const ContentWrapperBox = styled.View` + flex-grow: 1; + ${flexDirectionColumnItemsCenter}; `; export const ButtonBox = styled.View` diff --git a/src/components/onboarding/OnboardingSection/index.tsx b/src/components/onboarding/OnboardingSection/index.tsx new file mode 100644 index 0000000..9fa202d --- /dev/null +++ b/src/components/onboarding/OnboardingSection/index.tsx @@ -0,0 +1,78 @@ +import BusinessCard from '@/components/home/BusinessCard'; +import Category from '@/components/questionnaire/category'; +import QuestionnaireCheckListSkeleton from '@/components/questionnaire/QuestionnaireCheckListSkeleton'; +import ReviewSkeletonCard from '@/components/review/ReviewSkeletonCard'; + +import * as S from './style'; + +type Props = { + step: number; +}; + +function OnboardingSection({ step }: Props) { + switch (step) { + case 0: + return ( + + + + + + + + + + ); + case 1: + return ; + case 2: + return ( + + ); + case 3: + return ( + + + + + ); + } +} + +export default OnboardingSection; diff --git a/src/components/onboarding/OnboardingSection/style.ts b/src/components/onboarding/OnboardingSection/style.ts new file mode 100644 index 0000000..3559d77 --- /dev/null +++ b/src/components/onboarding/OnboardingSection/style.ts @@ -0,0 +1,17 @@ +import styled from '@emotion/native'; + +import { flexDirectionColumnItemsCenter } from '@/styles/common'; + +export const ChipContainer = styled.View` + display: flex; + flex-flow: row wrap; + gap: 8px; + justify-content: center; +`; + +export const ReviewCardContainer = styled.View` + ${flexDirectionColumnItemsCenter}; + gap: 11px; + width: 100%; + padding: 0 31px; +`; diff --git a/src/components/onboarding/OnboardingTitle/index.tsx b/src/components/onboarding/OnboardingTitle/index.tsx new file mode 100644 index 0000000..021a3b2 --- /dev/null +++ b/src/components/onboarding/OnboardingTitle/index.tsx @@ -0,0 +1,35 @@ +import Typography from '@/components/common/typography'; +import { color } from '@/styles/theme'; + +import * as S from './style'; + +export const ON_BOARDING = [ + `위프로와 함께 +당신의 경험을 어필해보세요`, + `프로젝트를 함께한 팀원들에게 +능력을 평가받아요`, + `평가와 함께 나를 표현하는 +명함을 받아요`, + `답변의 익명성으로 +솔직한 답변을 받을 수 있어요`, +] as const; + +type Props = { + step: number; +}; + +function OnboardingTitle({ step }: Props) { + return ( + + + {ON_BOARDING[step]} + + + ); +} + +export default OnboardingTitle; diff --git a/src/components/onboarding/OnboardingTitle/style.ts b/src/components/onboarding/OnboardingTitle/style.ts new file mode 100644 index 0000000..e9f837b --- /dev/null +++ b/src/components/onboarding/OnboardingTitle/style.ts @@ -0,0 +1,8 @@ +import styled from '@emotion/native'; +import Constants from 'expo-constants/src/Constants'; + +const statusBarHeight = Constants.statusBarHeight || 0; + +export const Container = styled.View` + padding-top: ${statusBarHeight + 75 + 'px'}; +`; diff --git a/src/components/project/ProjectInviteModal/ProjectInviteModal.style.ts b/src/components/project/ProjectInviteModal/ProjectInviteModal.style.ts index 09e3bb0..9c1bb18 100644 --- a/src/components/project/ProjectInviteModal/ProjectInviteModal.style.ts +++ b/src/components/project/ProjectInviteModal/ProjectInviteModal.style.ts @@ -7,7 +7,7 @@ import { color } from '@/styles/theme'; import { getSize } from '@/utils'; const WebContainerStyle = css` - max-width: ${SCREEN_SIZE.Web + 'px'}; + max-width: ${SCREEN_SIZE.WEB_WIDTH + 'px'}; padding: 20px; margin: 0 auto; `; diff --git a/src/components/questionnaire/QuestionnaireCheckList/index.tsx b/src/components/questionnaire/QuestionnaireCheckList/index.tsx index 356ab29..c60ebff 100644 --- a/src/components/questionnaire/QuestionnaireCheckList/index.tsx +++ b/src/components/questionnaire/QuestionnaireCheckList/index.tsx @@ -43,6 +43,7 @@ function Item({ children, value }: PropsWithChildren) { type QuestionnaireCheckListProps = { title: string; category: CategoryType; + onboarding?: boolean; initialCheckValue?: string | number; }; @@ -50,6 +51,7 @@ function CheckList({ title, category, initialCheckValue, + onboarding, children, }: PropsWithChildren) { const [checkValue, setCheckValue] = useState(() => @@ -64,7 +66,7 @@ function CheckList({ {title} diff --git a/src/components/questionnaire/QuestionnaireCheckListSkeleton/index.tsx b/src/components/questionnaire/QuestionnaireCheckListSkeleton/index.tsx index ba0f344..66e849b 100644 --- a/src/components/questionnaire/QuestionnaireCheckListSkeleton/index.tsx +++ b/src/components/questionnaire/QuestionnaireCheckListSkeleton/index.tsx @@ -23,7 +23,10 @@ function SkeletonItem() { function QuestionnaireCheckListSkeleton() { return ( diff --git a/src/components/review/ReviewCard/style.ts b/src/components/review/ReviewCard/style.ts index 8590ff2..7872fca 100644 --- a/src/components/review/ReviewCard/style.ts +++ b/src/components/review/ReviewCard/style.ts @@ -5,6 +5,7 @@ import { flexDirectionColumn } from '@/styles/common'; export const Container = styled.View` ${flexDirectionColumn}; gap: 16px; + width: 100%; padding: 12px; background-color: ${({ theme }) => theme.color.Background.Normal}; border-radius: 4px; diff --git a/src/components/review/ReviewSkeletonCard/ReviewSkeletonCard.stories.tsx b/src/components/review/ReviewSkeletonCard/ReviewSkeletonCard.stories.tsx index e4acd2a..c10c679 100644 --- a/src/components/review/ReviewSkeletonCard/ReviewSkeletonCard.stories.tsx +++ b/src/components/review/ReviewSkeletonCard/ReviewSkeletonCard.stories.tsx @@ -49,7 +49,7 @@ export const Primary: StoryObj = { }}> diff --git a/src/constants/index.ts b/src/constants/index.ts index aa9be84..890b4e5 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -1,6 +1,5 @@ export * from './navigations'; export * from './numbers'; -export * from './onboarding'; export * from './service'; export * from './siteUrls'; export * from './storageKeys'; diff --git a/src/constants/onboarding.ts b/src/constants/onboarding.ts deleted file mode 100644 index e05b710..0000000 --- a/src/constants/onboarding.ts +++ /dev/null @@ -1,26 +0,0 @@ -export const ON_BOARDING = [ - { - title: '안녕하세요', - heading: `위프로는 효과적으로 -자신을 어필할 수 있도록 -돕는 서비스입니다`, - }, - { - title: '무슨 방법으로요?', - heading: `프로젝트에 따라 -서로의 기술과 협업 능력을 -평가할 수 있어요`, - }, - { - title: '어떻게 보여주나요?', - heading: `팀원의 평가와 함께 -나를 표현할 수 있는 -명함을 받아요`, - }, - { - title: '누가 답변했는지 알 수 있나요?', - heading: `프로젝트에 참여한 사실만 -노출되고 어떤 답변을 했는지 -알 수 없어요`, - }, -] as const; diff --git a/src/constants/service.ts b/src/constants/service.ts index 6a4c526..9dc804a 100644 --- a/src/constants/service.ts +++ b/src/constants/service.ts @@ -1,5 +1,6 @@ export const SCREEN_SIZE = { - Web: 375, + WEB_WIDTH: 375, + WEB_HEIGHT: 812, } as const; export const COMPONENT_SIZE = { diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 39e89a4..2beb5b6 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1,2 +1,3 @@ +export * from './useAppOpen'; export * from './useStorageState'; export * from './useTabBarEffect'; diff --git a/src/hooks/useAppOpen.ts b/src/hooks/useAppOpen.ts new file mode 100644 index 0000000..66da00a --- /dev/null +++ b/src/hooks/useAppOpen.ts @@ -0,0 +1,26 @@ +import { useFonts } from 'expo-font'; +import { SplashScreen } from 'expo-router'; +import { useEffect } from 'react'; + +export function useAppOpen() { + const [loaded, error] = useFonts({ + Pretendard: require('../../assets/fonts/Pretendard-Regular.otf'), + 'Pretendard-Bold': require('../../assets/fonts/Pretendard-Bold.otf'), + 'Pretendard-SemiBold': require('../../assets/fonts/Pretendard-SemiBold.otf'), + 'Pretendard-Medium': require('../../assets/fonts/Pretendard-Medium.otf'), + }); + + useEffect(() => { + if (error) throw error; + }, [error]); + + if (loaded) { + SplashScreen.hideAsync(); + } + + if (!loaded) { + return null; + } + + return [loaded, error]; +} diff --git a/src/utils/getSize.ts b/src/utils/getSize.ts index df07aa5..a613414 100644 --- a/src/utils/getSize.ts +++ b/src/utils/getSize.ts @@ -3,8 +3,8 @@ import { Dimensions, Platform } from 'react-native'; import { SCREEN_SIZE } from '@/constants'; export const getSize = { - deviceWidth: Platform.OS === 'web' ? SCREEN_SIZE.Web : Dimensions.get('window').width, + deviceWidth: Platform.OS === 'web' ? SCREEN_SIZE.WEB_WIDTH : Dimensions.get('window').width, deviceHeight: Dimensions.get('window').height, - screenWidth: Platform.OS === 'web' ? SCREEN_SIZE.Web : Dimensions.get('screen').width, + screenWidth: Platform.OS === 'web' ? SCREEN_SIZE.WEB_WIDTH : Dimensions.get('screen').width, screenHeight: Dimensions.get('screen').height, }; From 55465a79edf5ff84eb757962c17bac555048ac0c Mon Sep 17 00:00:00 2001 From: Zero Date: Thu, 26 Sep 2024 12:06:20 +0900 Subject: [PATCH 14/21] =?UTF-8?q?Feat/#41=20SlideBar=EB=A5=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=ED=95=A9=EB=8B=88=EB=8B=A4.=20#66?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/slide-bar/SlideBar.stories.tsx | 44 +++++++++++++++++++ src/components/common/slide-bar/index.tsx | 29 ++++++++++++ src/components/common/slide-bar/style.ts | 10 +++++ 3 files changed, 83 insertions(+) create mode 100644 src/components/common/slide-bar/SlideBar.stories.tsx create mode 100644 src/components/common/slide-bar/index.tsx create mode 100644 src/components/common/slide-bar/style.ts diff --git a/src/components/common/slide-bar/SlideBar.stories.tsx b/src/components/common/slide-bar/SlideBar.stories.tsx new file mode 100644 index 0000000..5fb64c0 --- /dev/null +++ b/src/components/common/slide-bar/SlideBar.stories.tsx @@ -0,0 +1,44 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { View } from 'react-native'; + +import SlideBar from './'; + +const SlideBarMeta: Meta = { + title: 'common/SlideBar', + component: SlideBar, + argTypes: { + max_value: { + control: { + type: 'number', + }, + description: '최대값을 설정합니다.', + }, + current_value: { + control: { + type: 'number', + }, + description: '현재값을 설정합니다.', + }, + }, + parameters: { + layout: 'centered', + }, + decorators: [ + (Story) => { + return ( + + + + ); + }, + ], +}; + +export default SlideBarMeta; + +export const Primary: StoryObj = { + args: { + current_value: 2, + max_value: 5, + }, +}; diff --git a/src/components/common/slide-bar/index.tsx b/src/components/common/slide-bar/index.tsx new file mode 100644 index 0000000..7df4c87 --- /dev/null +++ b/src/components/common/slide-bar/index.tsx @@ -0,0 +1,29 @@ +import Animated, { useAnimatedStyle, withTiming } from 'react-native-reanimated'; + +import { color } from '@/styles/theme'; + +import * as S from './style'; + +type Props = { + max_value: number; + current_value: number; +}; + +function SlideBar({ max_value, current_value }: Props) { + const slideBarStyle = useAnimatedStyle(() => { + return { + position: 'absolute', + width: withTiming(`${(current_value / max_value) * 100}%`), + height: '100%', + backgroundColor: color.Primary.Normal, + }; + }); + + return ( + + + + ); +} + +export default SlideBar; diff --git a/src/components/common/slide-bar/style.ts b/src/components/common/slide-bar/style.ts new file mode 100644 index 0000000..662ec05 --- /dev/null +++ b/src/components/common/slide-bar/style.ts @@ -0,0 +1,10 @@ +import styled from '@emotion/native'; + +export const Container = styled.View` + position: relative; + width: 100%; + height: 4px; + overflow: hidden; + background: ${({ theme }) => theme.color.Background.Alternative}; + border-radius: 30px; +`; From 425d0691734c0b6cfc82461e8739ed39606aaf7f Mon Sep 17 00:00:00 2001 From: Zero Date: Thu, 26 Sep 2024 14:20:58 +0900 Subject: [PATCH 15/21] =?UTF-8?q?Feat/#41=20=EB=B3=80=EA=B2=BD=EB=90=9C=20?= =?UTF-8?q?ProjectList=EB=A5=BC=20=EC=A0=81=EC=9A=A9=ED=95=A9=EB=8B=88?= =?UTF-8?q?=EB=8B=A4.=20(#67)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: color 값들을 변경합니다. * refactor: 변경된 projectList를 적용합니다. * fix: 가운데 정렬을 통해 글자를 가운데로 맞춥니다. * refactor: web 환경에서는 프로젝트를 등록할 수 없게 합니다. --- app/(app)/project/_layout.tsx | 16 ++-- app/(app)/project/create.tsx | 12 ++- src/__mock__/project/index.ts | 47 +++++++++++ .../ProjectImage/ProjectImage.style.ts | 3 +- .../ProjectItem/ProjectItem.stories.tsx | 56 +++++++++++++ src/components/project/ProjectItem/index.tsx | 43 ++++++++++ src/components/project/ProjectItem/style.ts | 26 ++++++ .../ProjectList/ProjectList.stories.tsx | 78 +---------------- .../project/ProjectList/ProjectList.style.ts | 52 ------------ src/components/project/ProjectList/index.tsx | 84 +++---------------- src/components/project/ProjectList/style.ts | 14 ++++ src/styles/theme.ts | 12 +-- 12 files changed, 227 insertions(+), 216 deletions(-) create mode 100644 src/__mock__/project/index.ts create mode 100644 src/components/project/ProjectItem/ProjectItem.stories.tsx create mode 100644 src/components/project/ProjectItem/index.tsx create mode 100644 src/components/project/ProjectItem/style.ts delete mode 100644 src/components/project/ProjectList/ProjectList.style.ts create mode 100644 src/components/project/ProjectList/style.ts diff --git a/app/(app)/project/_layout.tsx b/app/(app)/project/_layout.tsx index e2d1f8c..b582aba 100644 --- a/app/(app)/project/_layout.tsx +++ b/app/(app)/project/_layout.tsx @@ -1,6 +1,6 @@ import { AntDesign, Feather } from '@expo/vector-icons'; import { Stack, useRouter } from 'expo-router'; -import { Pressable } from 'react-native'; +import { Platform, Pressable } from 'react-native'; import { PROJECT_NAVIGATIONS } from '@/constants'; import { color } from '@/styles/theme'; @@ -33,12 +33,14 @@ function Layout() { size={24} /> - router.push('/project/create')}> - - + {Platform.OS !== 'web' && ( + router.push('/project/create')}> + + + )} ), }} diff --git a/app/(app)/project/create.tsx b/app/(app)/project/create.tsx index afae1bf..77e7483 100644 --- a/app/(app)/project/create.tsx +++ b/app/(app)/project/create.tsx @@ -4,8 +4,9 @@ import BottomSheet, { BottomSheetView } from '@gorhom/bottom-sheet'; import type { DateTimePickerEvent } from '@react-native-community/datetimepicker'; import DateTimePicker from '@react-native-community/datetimepicker'; import { launchImageLibraryAsync, MediaTypeOptions } from 'expo-image-picker'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { Alert, ScrollView } from 'react-native'; +import { useRouter } from 'expo-router'; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { Alert, Platform, ScrollView } from 'react-native'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; import SolidButton from '@/components/common/button/SolidButton'; @@ -23,6 +24,7 @@ import { color } from '@/styles/theme'; import { getSize } from '@/utils'; function Create() { + const router = useRouter(); useTabBarEffect(); const [image, setImage] = useState(null); const [startDate, setStartDate] = useState(() => new Date()); @@ -83,6 +85,12 @@ function Create() { setSelectDate(select); }, []); + useLayoutEffect(() => { + if (Platform.OS === 'web') { + return router.replace('/project'); + } + }, [router]); + useEffect(() => { return () => dataSheetClose(); }, [dataSheetClose]); diff --git a/src/__mock__/project/index.ts b/src/__mock__/project/index.ts new file mode 100644 index 0000000..52d67f8 --- /dev/null +++ b/src/__mock__/project/index.ts @@ -0,0 +1,47 @@ +import type { ProjectItemType } from '@/components/project/ProjectList'; + +export const MOCK_PROJECT_ITEM: ProjectItemType = { + id: 1, + name: '위프로', + profile: 'https://picsum.photos/200', + review_count: 3, + member_num: 6, +}; + +export const MOCK_PROJECT_LIST: ProjectItemType[] = [ + { + id: 1, + name: '위프로', + profile: 'https://picsum.photos/200', + review_count: 3, + member_num: 6, + }, + { + id: 2, + name: '후피', + profile: 'https://picsum.photos/200', + review_count: 1, + member_num: 3, + }, + { + id: 3, + name: 'Code Red', + profile: 'https://picsum.photos/200', + review_count: 2, + member_num: 3, + }, + { + id: 4, + name: 'Veco', + profile: 'https://picsum.photos/200', + review_count: 3, + member_num: 3, + }, + { + id: 5, + name: 'Zero Waste', + profile: 'https://picsum.photos/200', + review_count: 0, + member_num: 3, + }, +] as const; diff --git a/src/components/project/ProjectImage/ProjectImage.style.ts b/src/components/project/ProjectImage/ProjectImage.style.ts index ed29b00..f1087c3 100644 --- a/src/components/project/ProjectImage/ProjectImage.style.ts +++ b/src/components/project/ProjectImage/ProjectImage.style.ts @@ -7,6 +7,7 @@ export const ProjectImageBox = styled.View` position: relative; width: 48px; height: 48px; + overflow: hidden; border-radius: 8px; `; @@ -14,7 +15,7 @@ export const ProjectImageOutline = styled.View` position: absolute; width: 100%; height: 100%; - border: 1px solid rgb(115 112 114); + border: 1px solid ${({ theme }) => theme.color.Line.Normal}; border-radius: 8px; `; diff --git a/src/components/project/ProjectItem/ProjectItem.stories.tsx b/src/components/project/ProjectItem/ProjectItem.stories.tsx new file mode 100644 index 0000000..75be025 --- /dev/null +++ b/src/components/project/ProjectItem/ProjectItem.stories.tsx @@ -0,0 +1,56 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { View } from 'react-native'; + +import { MOCK_PROJECT_ITEM } from '@/__mock__/project'; +import { color } from '@/styles/theme'; + +import ProjectItem from './'; + +const ProjectItemMeta: Meta = { + title: 'project/ProjectItem', + component: ProjectItem, + argTypes: { + member_num: { + control: { + type: 'number', + }, + description: '프로젝트의 총 인원 수를 지정합니다.', + }, + name: { + control: { + type: 'text', + }, + description: '프로젝트의 이름을 지정합니다.', + }, + profile: { + control: { + type: 'text', + }, + description: '프로젝트의 이미지를 지정합니다.', + }, + review_count: { + control: { + type: 'number', + }, + description: '프로젝트의 리뷰 수를 지정합니다.', + }, + id: { + control: { + type: 'number', + }, + description: '프로젝트의 id를 지정합니다.', + }, + }, +}; + +export default ProjectItemMeta; + +export const Primary: StoryObj = { + render: () => { + return ( + + + + ); + }, +}; diff --git a/src/components/project/ProjectItem/index.tsx b/src/components/project/ProjectItem/index.tsx new file mode 100644 index 0000000..2d1ec8b --- /dev/null +++ b/src/components/project/ProjectItem/index.tsx @@ -0,0 +1,43 @@ +import SlideBar from '@/components/common/slide-bar'; +import Typography from '@/components/common/typography'; +import ProjectImage from '@/components/project/ProjectImage'; +import type { ProjectItemType } from '@/components/project/ProjectList'; +import { color } from '@/styles/theme'; + +import * as S from './style'; + +function ProjectItem({ name, member_num, profile, review_count }: ProjectItemType) { + return ( + + + + + 프로젝트 + + + + + {name} + + + {review_count} / {member_num} + + + + + + + ); +} + +export default ProjectItem; diff --git a/src/components/project/ProjectItem/style.ts b/src/components/project/ProjectItem/style.ts new file mode 100644 index 0000000..0d08c2f --- /dev/null +++ b/src/components/project/ProjectItem/style.ts @@ -0,0 +1,26 @@ +import styled from '@emotion/native'; + +import { flexDirectionColumn, flexDirectionRowItemsCenter } from '@/styles/common'; + +export const Container = styled.View` + ${flexDirectionRowItemsCenter}; + gap: 10px; + padding: 16px; + background-color: ${({ theme }) => theme.color.Background.Normal}; + border-radius: 8px; +`; + +export const ProjectStatusBox = styled.View` + ${flexDirectionColumn}; + flex-grow: 1; +`; + +export const ProgressBox = styled.View` + ${flexDirectionColumn}; + gap: 6px; +`; + +export const ProgressInfo = styled.View` + ${flexDirectionRowItemsCenter}; + justify-content: space-between; +`; diff --git a/src/components/project/ProjectList/ProjectList.stories.tsx b/src/components/project/ProjectList/ProjectList.stories.tsx index 6083d10..5812e6e 100644 --- a/src/components/project/ProjectList/ProjectList.stories.tsx +++ b/src/components/project/ProjectList/ProjectList.stories.tsx @@ -1,6 +1,7 @@ import type { Meta, StoryObj } from '@storybook/react'; import { View } from 'react-native'; +import { MOCK_PROJECT_LIST } from '@/__mock__/project'; import { color } from '@/styles/theme'; import ProjectList from './'; @@ -15,84 +16,9 @@ export default ProjectListMeta; export const Primary: StoryObj = { render: () => { - const mockList = [ - { - id: 1, - name: '위프로', - profile: 'https://picsum.photos/200', - member_num: 6, - }, - { - id: 2, - name: '후피', - profile: 'https://picsum.photos/200', - member_num: 3, - }, - { - id: 3, - name: 'Code Red', - profile: 'https://picsum.photos/200', - member_num: 3, - }, - { - id: 4, - name: 'Veco', - profile: 'https://picsum.photos/200', - member_num: 3, - }, - { - id: 5, - name: '위프로', - profile: 'https://picsum.photos/200', - member_num: 6, - }, - { - id: 6, - name: '후피', - profile: 'https://picsum.photos/200', - member_num: 3, - }, - { - id: 7, - name: 'Code Red', - profile: 'https://picsum.photos/200', - member_num: 3, - }, - { - id: 8, - name: 'Veco', - profile: 'https://picsum.photos/200', - member_num: 3, - }, - { - id: 9, - name: '위프로', - profile: 'https://picsum.photos/200', - member_num: 6, - }, - { - id: 10, - name: '후피', - profile: 'https://picsum.photos/200', - member_num: 3, - }, - { - id: 11, - name: 'Code Red', - profile: 'https://picsum.photos/200', - member_num: 3, - }, - { - id: 12, - name: 'Last Project', - profile: 'https://picsum.photos/200', - member_num: 3, - }, - ]; - return ( - + ); }, diff --git a/src/components/project/ProjectList/ProjectList.style.ts b/src/components/project/ProjectList/ProjectList.style.ts deleted file mode 100644 index 2f8523c..0000000 --- a/src/components/project/ProjectList/ProjectList.style.ts +++ /dev/null @@ -1,52 +0,0 @@ -import styled from '@emotion/native'; -import Animated from 'react-native-reanimated'; - -import { COMPONENT_SIZE } from '@/constants'; -import { - flexDirectionColumn, - flexDirectionColumnItemsCenter, - flexDirectionRow, -} from '@/styles/common'; - -export const Container = styled.ScrollView` - padding-bottom: ${COMPONENT_SIZE.BOTTOM_NAV + 'px'}; -`; - -export const ProjectContainer = styled.View` - ${flexDirectionColumn}; - background-color: ${({ theme }) => theme.color.Background.Normal}; - border-radius: 8px; -`; - -export const ProjectInActiveBox = styled.View` - ${flexDirectionRow}; - align-items: center; - justify-content: space-between; - padding: 20px 16px; -`; - -export const ProjectInfoBox = styled.View` - ${flexDirectionRow}; - gap: 8px; -`; - -export const ProjectStatusBox = styled.View` - ${flexDirectionColumn}; -`; - -export const ProjectActiveDivider = styled.View` - height: 1px; - margin: 0 20px; - border-bottom-color: ${({ theme }) => theme.color.Line.Neutral}; - border-bottom-width: 1px; -`; - -export const ProjectActiveBox = styled(Animated.View)` - overflow: 'hidden'; -`; - -export const DeleteButton = styled.Pressable` - ${flexDirectionColumnItemsCenter}; - width: 71px; - background: ${({ theme }) => theme.color.Status.Error}; -`; diff --git a/src/components/project/ProjectList/index.tsx b/src/components/project/ProjectList/index.tsx index 55469f7..4e5a6ea 100644 --- a/src/components/project/ProjectList/index.tsx +++ b/src/components/project/ProjectList/index.tsx @@ -1,16 +1,11 @@ -import { Ionicons } from '@expo/vector-icons'; -import { useState } from 'react'; -import { FlatList, Pressable } from 'react-native'; +import { FlatList } from 'react-native'; import { GestureHandlerRootView, Swipeable } from 'react-native-gesture-handler'; -import { useAnimatedStyle, withTiming } from 'react-native-reanimated'; -import type { ProjectDTO } from '@/apis/project/project.type'; import Typography from '@/components/common/typography'; -import ProjectImage from '@/components/project/ProjectImage'; +import ProjectItem from '@/components/project/ProjectItem'; import { COMPONENT_SIZE } from '@/constants'; -import { color } from '@/styles/theme'; -import * as S from './ProjectList.style'; +import * as S from './style'; type ProjectDeleteButtonProps = { deleteId: string | number; @@ -24,71 +19,16 @@ function ProjectDeleteButton({ deleteId }: ProjectDeleteButtonProps) { ); } -type OneProjectProps = ProjectDTO; - -function OneProject({ - member_num, - profile, - name, -}: Pick) { - const [active, setActive] = useState(() => false); - - const activeStyle = useAnimatedStyle(() => ({ - paddingTop: withTiming(active ? 20 : 0), - paddingBottom: withTiming(active ? 20 : 0), - height: withTiming(active ? 64 : 0), - })); - - return ( - - - - - - - {name} - - - {member_num}명 - - - - setActive((prev) => !prev)}> - {active ? ( - - ) : ( - - )} - - - {active ? : null} - - - 리뷰 만들기 - - - - ); -} +export type ProjectItemType = { + id: string | number; + name: string; + profile: string; + review_count: number; + member_num: number; +}; type Props = { - data: Pick[]; + data: ProjectItemType[]; }; function ProjectList({ data }: Props) { @@ -105,7 +45,7 @@ function ProjectList({ data }: Props) { renderItem={(info) => ( }> - + )} diff --git a/src/components/project/ProjectList/style.ts b/src/components/project/ProjectList/style.ts new file mode 100644 index 0000000..6fd66fc --- /dev/null +++ b/src/components/project/ProjectList/style.ts @@ -0,0 +1,14 @@ +import styled from '@emotion/native'; + +import { COMPONENT_SIZE } from '@/constants'; +import { flexDirectionColumnItemsCenter } from '@/styles/common'; + +export const Container = styled.ScrollView` + padding-bottom: ${COMPONENT_SIZE.BOTTOM_NAV + 'px'}; +`; + +export const DeleteButton = styled.Pressable` + ${flexDirectionColumnItemsCenter}; + width: 71px; + background: ${({ theme }) => theme.color.Status.Error}; +`; diff --git a/src/styles/theme.ts b/src/styles/theme.ts index f3c31ed..26bd740 100644 --- a/src/styles/theme.ts +++ b/src/styles/theme.ts @@ -198,9 +198,9 @@ const semantic = { Disable: '#F7F7F8', }, Line: { - Normal: 'rgba(112, 115, 124, 0.22)', - Neutral: 'rgba(112, 115, 124, 0.16)', - Alternative: 'rgba(112, 115, 124, 0.08)', + Normal: '#E0E0E2', + Neutral: '#E8E8EA', + Alternative: '#F4F4F5', }, Status: { Success: '#00BF40', @@ -216,9 +216,9 @@ const semantic = { Pink: '#F553DA', }, Component: { - Fill: 'rgba(112, 115, 124, 0.08)', - Strong: 'rgba(112, 115, 124, 0.16)', - Alternative: 'rgba(112, 115, 124, 0.05)', + Fill: '#F4F4F5', + Strong: '#E8E8EA', + Alternative: '#F8F8F8', }, Material: { Dimmer: '#0c0c0d', From e9020dc18740c81c02c3927b72c407de2252292d Mon Sep 17 00:00:00 2001 From: Zero Date: Fri, 27 Sep 2024 20:25:16 +0900 Subject: [PATCH 16/21] =?UTF-8?q?Feat/#41=20=ED=94=84=EB=A1=9C=EC=A0=9D?= =?UTF-8?q?=ED=8A=B8=20=EC=83=81=EC=84=B8=ED=8E=98=EC=9D=B4=EC=A7=80?= =?UTF-8?q?=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=A9=EB=8B=88=EB=8B=A4.=20(#?= =?UTF-8?q?69)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: mock 데이터를 변경합니다. * feat: 프로젝트 상세페이지를 추가합니다. * refactor: 스크롤이 있을 경우를 대응하기 위해 grid를 사용한다. --- app/(app)/_layout.tsx | 5 + app/(app)/project/_layout.tsx | 17 +- app/(app)/project/create.tsx | 26 ++- app/(app)/project/detail/[id]/index.tsx | 55 +++++++ app/(app)/project/detail/[id]/style.ts | 5 + app/(app)/project/index.tsx | 93 +---------- .../project/{layout.style.ts => style.ts} | 1 - src/__mock__/project/index.ts | 38 +++++ src/components/common/date-input/index.tsx | 14 +- .../ProjectDetail/ProjectDetail.stories.tsx | 28 ++++ .../project/ProjectDetail/index.tsx | 152 ++++++++++++++++++ src/components/project/ProjectDetail/style.ts | 97 +++++++++++ src/components/project/ProjectItem/index.tsx | 9 +- src/components/project/ProjectItem/style.ts | 2 +- 14 files changed, 433 insertions(+), 109 deletions(-) create mode 100644 app/(app)/project/detail/[id]/index.tsx create mode 100644 app/(app)/project/detail/[id]/style.ts rename app/(app)/project/{layout.style.ts => style.ts} (86%) create mode 100644 src/components/project/ProjectDetail/ProjectDetail.stories.tsx create mode 100644 src/components/project/ProjectDetail/index.tsx create mode 100644 src/components/project/ProjectDetail/style.ts diff --git a/app/(app)/_layout.tsx b/app/(app)/_layout.tsx index 882a5f1..f361bb3 100644 --- a/app/(app)/_layout.tsx +++ b/app/(app)/_layout.tsx @@ -13,6 +13,7 @@ import { SITE_URLS } from '@/constants'; import { useSession } from '@/store'; import { useOnboarding } from '@/store/useOnboarding'; import useTabBar from '@/store/useTabBar'; +import { color } from '@/styles/theme'; const tabBarOptions = { [MAIN_NAVIGATIONS.HOME]: { @@ -135,7 +136,11 @@ export default function Layout() { return ( }> diff --git a/app/(app)/project/_layout.tsx b/app/(app)/project/_layout.tsx index b582aba..c3a07d6 100644 --- a/app/(app)/project/_layout.tsx +++ b/app/(app)/project/_layout.tsx @@ -5,7 +5,7 @@ import { Platform, Pressable } from 'react-native'; import { PROJECT_NAVIGATIONS } from '@/constants'; import { color } from '@/styles/theme'; -import * as S from './layout.style'; +import * as S from './style'; function Layout() { const router = useRouter(); @@ -24,7 +24,6 @@ function Layout() { name={PROJECT_NAVIGATIONS.HOME} options={{ title: '프로젝트', - headerLeft: () => null, headerRight: () => ( @@ -51,14 +50,12 @@ function Layout() { animation: 'flip', title: '프로젝트 등록', headerLeft: ({ canGoBack }) => ( - - (canGoBack ? router.back() : router.push('/project'))}> - - - + (canGoBack ? router.back() : router.push('/project'))}> + + ), }} /> diff --git a/app/(app)/project/create.tsx b/app/(app)/project/create.tsx index 77e7483..79e78d8 100644 --- a/app/(app)/project/create.tsx +++ b/app/(app)/project/create.tsx @@ -29,6 +29,8 @@ function Create() { const [image, setImage] = useState(null); const [startDate, setStartDate] = useState(() => new Date()); const [endDate, setEndDate] = useState(() => new Date()); + const [startDateTouched, setStartDateTouched] = useState(false); + const [endDateTouched, setEndDateTouched] = useState(false); const [selectUserList, setSelectUserList] = useState([]); const [selectDate, setSelectDate] = useState<'start' | 'end'>('start'); @@ -160,21 +162,33 @@ function Create() { - - 기간 - + + + 기간 + + + * + + startDateOpen('start')} + onTouchEnd={() => setStartDateTouched(true)} /> - startDateOpen('end')} + onTouchEnd={() => setEndDateTouched(true)} /> diff --git a/app/(app)/project/detail/[id]/index.tsx b/app/(app)/project/detail/[id]/index.tsx new file mode 100644 index 0000000..ed6d998 --- /dev/null +++ b/app/(app)/project/detail/[id]/index.tsx @@ -0,0 +1,55 @@ +import { Feather } from '@expo/vector-icons'; +import { useNavigation, useRouter } from 'expo-router'; +import { useLayoutEffect } from 'react'; +import { Platform, Pressable } from 'react-native'; + +import { MOCK_PROJECT_DETAIL } from '@/__mock__/project'; +import Typography from '@/components/common/typography'; +import ProjectDetail from '@/components/project/ProjectDetail'; +import { color } from '@/styles/theme'; + +function Page() { + const router = useRouter(); + // const { id } = useLocalSearchParams(); 실제 데이터로 올 경우 해당 id를 이용하여 조회 + const navigation = useNavigation(); + const data = MOCK_PROJECT_DETAIL; + + useLayoutEffect(() => { + navigation.setOptions({ + headerStyle: { + paddingTop: 12, + }, + headerTitle: data.name, + headerTintColor: color.Label.Normal, + headerTitleStyle: { + fontWeight: 600, + fontFamily: 'Pretendard-SemiBold', + fontSize: 18, + lineHeight: 26, + }, + headerLeft: () => ( + + + + ), + headerRight: () => + Platform.OS !== 'web' ? ( + router.push('/project/create')}> + + 편집 + + + ) : null, + }); + }, [navigation]); + + return ; +} + +export default Page; diff --git a/app/(app)/project/detail/[id]/style.ts b/app/(app)/project/detail/[id]/style.ts new file mode 100644 index 0000000..dc5d68f --- /dev/null +++ b/app/(app)/project/detail/[id]/style.ts @@ -0,0 +1,5 @@ +import styled from '@emotion/native'; + +export const Container = styled.SafeAreaView` + padding: 32px 20px 52px; +`; diff --git a/app/(app)/project/index.tsx b/app/(app)/project/index.tsx index 43f7621..ffe83e3 100644 --- a/app/(app)/project/index.tsx +++ b/app/(app)/project/index.tsx @@ -1,19 +1,17 @@ import { useLayoutEffect, useState } from 'react'; import { SafeAreaView } from 'react-native'; +import { MOCK_PROJECT_ITEM, MOCK_PROJECT_LIST } from '@/__mock__/project'; import ProjectInviteModal from '@/components/project/ProjectInviteModal'; import ProjectList from '@/components/project/ProjectList'; import { color } from '@/styles/theme'; +const inviteData = MOCK_PROJECT_ITEM; +const data = MOCK_PROJECT_LIST; + function Project() { const [visible, setVisible] = useState(false); - const data = { - project_name: '위프로', - project_profile: 'https://picsum.photos/200', - member_length: 6, - }; - const onRequestClose = () => { setVisible(false); }; @@ -22,91 +20,16 @@ function Project() { setVisible(true); }, []); - const mockList = [ - { - id: 1, - name: '위프로', - profile: 'https://picsum.photos/200', - member_num: 6, - }, - { - id: 2, - name: '후피', - profile: 'https://picsum.photos/200', - member_num: 3, - }, - { - id: 3, - name: 'Code Red', - profile: 'https://picsum.photos/200', - member_num: 3, - }, - { - id: 4, - name: 'Veco', - profile: 'https://picsum.photos/200', - member_num: 3, - }, - { - id: 5, - name: '위프로', - profile: 'https://picsum.photos/200', - member_num: 6, - }, - { - id: 6, - name: '후피', - profile: 'https://picsum.photos/200', - member_num: 3, - }, - { - id: 7, - name: 'Code Red', - profile: 'https://picsum.photos/200', - member_num: 3, - }, - { - id: 8, - name: 'Veco', - profile: 'https://picsum.photos/200', - member_num: 3, - }, - { - id: 9, - name: '위프로', - profile: 'https://picsum.photos/200', - member_num: 6, - }, - { - id: 10, - name: '후피', - profile: 'https://picsum.photos/200', - member_num: 3, - }, - { - id: 11, - name: 'Code Red', - profile: 'https://picsum.photos/200', - member_num: 3, - }, - { - id: 12, - name: 'Last Project', - profile: 'https://picsum.photos/200', - member_num: 3, - }, - ]; - return ( - + ); } diff --git a/app/(app)/project/layout.style.ts b/app/(app)/project/style.ts similarity index 86% rename from app/(app)/project/layout.style.ts rename to app/(app)/project/style.ts index f9825b4..a4aa1ae 100644 --- a/app/(app)/project/layout.style.ts +++ b/app/(app)/project/style.ts @@ -5,5 +5,4 @@ import { flexDirectionRow } from '@/styles/common'; export const ButtonGroup = styled.View` ${flexDirectionRow}; gap: 10px; - padding: 20px 20px 8px; `; diff --git a/src/__mock__/project/index.ts b/src/__mock__/project/index.ts index 52d67f8..85270b1 100644 --- a/src/__mock__/project/index.ts +++ b/src/__mock__/project/index.ts @@ -1,3 +1,4 @@ +import type { ProjectDetailType } from '@/components/project/ProjectDetail'; import type { ProjectItemType } from '@/components/project/ProjectList'; export const MOCK_PROJECT_ITEM: ProjectItemType = { @@ -45,3 +46,40 @@ export const MOCK_PROJECT_LIST: ProjectItemType[] = [ member_num: 3, }, ] as const; + +export const MOCK_PROJECT_DETAIL: ProjectDetailType = { + id: 1, + name: '위프로', + description: '팀원이 만들어주는 명함 서비스', + profile: 'https://picsum.photos/200', + startDate: '24.07.01', + endDate: '24.09.30', + review_count: 4, + userList: [ + { + id: 1, + name: '이지형', + }, + { + id: 2, + name: '이예지', + }, + { + id: 3, + name: '양의진', + }, + { + id: 4, + name: '조민제', + }, + { + id: 5, + name: '김소현', + }, + { + id: 6, + name: '김희진', + }, + ], + link: 'https://www.naver.com', +} as const; diff --git a/src/components/common/date-input/index.tsx b/src/components/common/date-input/index.tsx index 48944c4..b4547f9 100644 --- a/src/components/common/date-input/index.tsx +++ b/src/components/common/date-input/index.tsx @@ -3,27 +3,33 @@ import dayjs from 'dayjs'; import Typography from '@/components/common/typography'; import { shadow } from '@/styles/shadow'; +import { color } from '@/styles/theme'; import * as S from './style'; type Props = { date: Date; + touched: boolean; onPress: () => void; + onTouchEnd: () => void; }; -function DateInput({ onPress, date }: Props) { +function DateInput({ touched, onPress, onTouchEnd, date }: Props) { return ( + onPress={onPress} + onTouchEnd={onTouchEnd}> - {dayjs(date).format('YYYY-MM-DD')} + + {dayjs(date).format('YYYY-MM-DD')} + ); } diff --git a/src/components/project/ProjectDetail/ProjectDetail.stories.tsx b/src/components/project/ProjectDetail/ProjectDetail.stories.tsx new file mode 100644 index 0000000..e14680d --- /dev/null +++ b/src/components/project/ProjectDetail/ProjectDetail.stories.tsx @@ -0,0 +1,28 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { View } from 'react-native'; + +import { MOCK_PROJECT_DETAIL } from '@/__mock__/project'; +import { SCREEN_SIZE } from '@/constants'; + +import ProjectDetail from './'; + +const ProjectInviteModalMeta: Meta = { + title: 'project/ProjectDetail', + component: ProjectDetail, + parameters: { + layout: 'centered', + }, +}; + +export default ProjectInviteModalMeta; + +export const Primary: StoryObj = { + args: { + data: MOCK_PROJECT_DETAIL, + }, + render: (props) => ( + + + + ), +}; diff --git a/src/components/project/ProjectDetail/index.tsx b/src/components/project/ProjectDetail/index.tsx new file mode 100644 index 0000000..7524fd4 --- /dev/null +++ b/src/components/project/ProjectDetail/index.tsx @@ -0,0 +1,152 @@ +import { AntDesign } from '@expo/vector-icons'; +import { ScrollView } from 'react-native'; + +import SolidButton from '@/components/common/button/SolidButton'; +import SlideBar from '@/components/common/slide-bar'; +import Typography from '@/components/common/typography'; +import { COMPONENT_SIZE } from '@/constants'; +import { shadow } from '@/styles/shadow'; +import { color } from '@/styles/theme'; +import { getSize } from '@/utils'; + +import * as S from './style'; + +export type ProjectDetailType = { + id: number; + name: string; + description: string; + profile: string; + startDate: string; + endDate: string; + review_count: number; + userList: UserType[]; + link: string; +}; + +type UserType = { + id: number; + name: string; +}; + +type Props = { + data: ProjectDetailType; +}; + +function ProjectDetail({ data }: Props) { + return ( + + + + + + + {data.name} + + + {data.description} + + + + + + + 기간 + + + + + {data.startDate} - {data.endDate} + + + + + + + 팀원 + + + + + + + + + {data.review_count} + + + /{data.userList.length} + + + + + {data.userList.map((user) => ( + + + {user.name} + + + ))} + + + + + + + 프로젝트 링크 + + + + {data.link} + + + + + 설문지 만들기 + + + ); +} + +export default ProjectDetail; diff --git a/src/components/project/ProjectDetail/style.ts b/src/components/project/ProjectDetail/style.ts new file mode 100644 index 0000000..a1fec3f --- /dev/null +++ b/src/components/project/ProjectDetail/style.ts @@ -0,0 +1,97 @@ +import styled, { css } from '@emotion/native'; + +import { + flexDirectionColumn, + flexDirectionRow, + flexDirectionRowItemsCenter, +} from '@/styles/common'; +import { getSize, isMobile } from '@/utils'; + +export const Container = styled.SafeAreaView` + ${flexDirectionColumn}; + gap: 24px; + padding: 32px 20px 52px; +`; + +export const ProjectCard = styled.View` + ${flexDirectionRowItemsCenter}; + gap: 12px; +`; + +export const ProjectImage = styled.Image` + width: 80px; + height: 80px; + border-radius: 60px; +`; + +export const ProjectIntro = styled.View` + ${flexDirectionColumn}; +`; + +export const ProjectItem = styled.View` + ${flexDirectionColumn}; + gap: 8px; +`; + +export const DateBox = styled.View` + ${flexDirectionRowItemsCenter}; + gap: 8px; + padding: 16px; + background: ${({ theme }) => theme.color.Background.Normal}; + border-radius: 8px; +`; + +export const ProjectTeamBox = styled.View` + ${flexDirectionColumn}; + gap: 8px; +`; + +export const SlideBarContainer = styled.View` + ${flexDirectionRowItemsCenter}; + gap: 8px; + justify-content: space-between; + width: 100%; + padding: 13px 21px; + background: ${({ theme }) => theme.color.Background.Normal}; + border-radius: 8px; +`; + +export const SlideBarBox = styled.View` + flex-grow: 1; +`; + +export const SlideValueText = styled.View` + ${flexDirectionRow}; +`; + +const ProjectUserListMobileStyle = css` + ${flexDirectionRow}; + flex-wrap: wrap; +`; + +const ProjectUserListWebStyle = css` + display: grid; + grid-template-columns: repeat(2, 1fr); +`; + +export const ProjectUserList = styled.View` + ${isMobile ? ProjectUserListMobileStyle : ProjectUserListWebStyle} + gap: 8px; +`; + +const ProjectUserItemMobileStyle = css` + flex-basis: ${(getSize.screenWidth - 48) / 2 + 'px'}; +`; + +export const ProjectUser = styled.View` + ${isMobile ? ProjectUserItemMobileStyle : flexDirectionColumn}; + padding: 18px 16px; + background: ${({ theme }) => theme.color.Background.Normal}; + border-radius: 8px; +`; + +export const LinkBox = styled.View` + padding: 16px; + background: ${({ theme }) => theme.color.Background.Normal}; + border-radius: 8px; +`; diff --git a/src/components/project/ProjectItem/index.tsx b/src/components/project/ProjectItem/index.tsx index 2d1ec8b..746b1ed 100644 --- a/src/components/project/ProjectItem/index.tsx +++ b/src/components/project/ProjectItem/index.tsx @@ -1,3 +1,5 @@ +import { useRouter } from 'expo-router'; + import SlideBar from '@/components/common/slide-bar'; import Typography from '@/components/common/typography'; import ProjectImage from '@/components/project/ProjectImage'; @@ -6,9 +8,12 @@ import { color } from '@/styles/theme'; import * as S from './style'; -function ProjectItem({ name, member_num, profile, review_count }: ProjectItemType) { +function ProjectItem({ name, member_num, profile, review_count, id }: ProjectItemType) { + const router = useRouter(); + return ( - + router.push({ pathname: '/project/detail/[id]', params: { id } })}> Date: Fri, 27 Sep 2024 21:04:43 +0900 Subject: [PATCH 17/21] =?UTF-8?q?Refactor/#41=20input-field=EB=82=B4?= =?UTF-8?q?=EC=97=90=20=EC=97=90=EB=9F=AC=20=ED=91=9C=EC=8B=9C=EB=A5=BC=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=ED=95=A9=EB=8B=88=EB=8B=A4.=20(#70)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: input-field내에 에러 표시를 수정합니다. * refactor: 스토리북 파일을 임의로 변경합니다. --- .../common/input-field/InputField.stories.tsx | 2 +- src/components/common/input-field/index.tsx | 27 ++++++++++++++----- src/components/common/input-field/style.ts | 20 ++++++++++---- 3 files changed, 37 insertions(+), 12 deletions(-) diff --git a/src/components/common/input-field/InputField.stories.tsx b/src/components/common/input-field/InputField.stories.tsx index 76e82e3..974d8f1 100644 --- a/src/components/common/input-field/InputField.stories.tsx +++ b/src/components/common/input-field/InputField.stories.tsx @@ -55,7 +55,7 @@ export const Primary: StoryObj = { const [error, setError] = useState(''); const onChangeText = useCallback((text: string) => { - if (text.length > 10) { + if (text.length > 8) { setError('올바르지 않은 형식입니다'); } else { setError(''); diff --git a/src/components/common/input-field/index.tsx b/src/components/common/input-field/index.tsx index 5ce0c0a..427222b 100644 --- a/src/components/common/input-field/index.tsx +++ b/src/components/common/input-field/index.tsx @@ -1,9 +1,10 @@ +import { AntDesign } from '@expo/vector-icons'; import type { ForwardedRef, ReactNode } from 'react'; import React, { forwardRef, useRef } from 'react'; import type { TextInput } from 'react-native'; import { type TextInputProps } from 'react-native'; -import { Pressable } from 'react-native'; +import Typography from '@/components/common/typography'; import { shadow } from '@/styles/shadow'; import { color } from '@/styles/theme'; import { mergeRefs } from '@/utils'; @@ -22,7 +23,7 @@ interface InputFieldProps extends TextInputProps { const InputField = forwardRef( ( { - backgroundColor = 'transparent', + backgroundColor = color.Background.Normal, touched, isShadow = true, disabled = false, @@ -40,9 +41,10 @@ const InputField = forwardRef( }; return ( - + {icon} @@ -60,9 +62,22 @@ const InputField = forwardRef( placeholderTextColor={color.Label.Alternative} {...props} /> - {touched && !!error && {error}} - + {touched && Boolean(error) && ( + + + + {error} + + + )} + ); } ); diff --git a/src/components/common/input-field/style.ts b/src/components/common/input-field/style.ts index 993a87b..b1d4e0e 100644 --- a/src/components/common/input-field/style.ts +++ b/src/components/common/input-field/style.ts @@ -1,7 +1,11 @@ import styled, { css } from '@emotion/native'; import type { Theme } from '@emotion/react'; -import { flexDirectionRow, flexDirectionRowItemsCenter } from '@/styles/common'; +import { + flexDirectionColumn, + flexDirectionRow, + flexDirectionRowItemsCenter, +} from '@/styles/common'; const errorStyle = (theme: Theme) => css` border-color: ${theme.color.Status.Error}; @@ -18,7 +22,12 @@ const hasIconStyle = css` gap: 5px; `; +export const WrapperBox = styled.Pressable` + ${flexDirectionColumn}; +`; + export const Container = styled.View<{ + $backgroundColor: string; $isError: boolean; $disabled: boolean; }>` @@ -27,7 +36,8 @@ export const Container = styled.View<{ ${flexDirectionRowItemsCenter}; gap: 8px; padding: 18px 16px; - background-color: ${({ theme }) => theme.color.Background.Normal}; + background-color: ${({ theme, $backgroundColor }) => + $backgroundColor ? $backgroundColor : theme.color.Background.Normal}; border-radius: 8px; `; @@ -40,8 +50,8 @@ export const TextInput = styled.TextInput<{ $isIcon: boolean }>` color: ${(props) => props.theme.color.Label.Normal}; `; -export const ErrorText = styled.Text` +export const ErrorBox = styled.View` + ${flexDirectionRowItemsCenter}; + gap: 4px; padding-top: 5px; - font-size: 12px; - color: ${(props) => props.theme.color.Status.Error}; `; From 2962ef3bd3a41a1faf95bbbe4a95efe2c8d51cbb Mon Sep 17 00:00:00 2001 From: Jihyeong Date: Sun, 29 Sep 2024 21:10:07 +0900 Subject: [PATCH 18/21] =?UTF-8?q?Feat/#41=20=EC=9C=A0=ED=9A=A8=EC=84=B1=20?= =?UTF-8?q?=EA=B2=80=EC=82=AC=20=EB=A1=9C=EC=A7=81=EC=9D=84=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=ED=95=A9=EB=8B=88=EB=8B=A4.=20(#71)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 유효성 검사를 위한 zod + RHF를 설치한다. * refactor: ErrorText를 분리합니다. * refactor: setSelectUserList의 파라미터 타입을 변경합니다. * refactor: 기능 로직을 커스텀훅으로 분리합니다. * feat: 유효성 검사 로직을 추가합니다. * feat: 링크 유효성 검사를 추가합니다. --- app/(app)/project/create.tsx | 335 +++++++++--------- package.json | 3 + .../common/error-text/ErrorText.stories.tsx | 24 ++ src/components/common/error-text/index.tsx | 30 ++ src/components/common/error-text/style.ts | 9 + src/components/common/image-input/index.tsx | 10 +- .../common/input-field/InputField.stories.tsx | 13 - src/components/common/input-field/index.tsx | 22 +- src/components/common/input-field/style.ts | 6 - .../ProjectRegisterForm/ProjectInputField.tsx | 43 +++ .../project/ProjectRegisterForm/style.ts | 14 +- .../project/SearchUserList/index.tsx | 3 +- src/hooks/index.ts | 8 +- src/hooks/useBottomSheet.ts | 31 ++ src/hooks/useSingleImage.ts | 26 ++ yarn.lock | 23 +- 16 files changed, 381 insertions(+), 219 deletions(-) create mode 100644 src/components/common/error-text/ErrorText.stories.tsx create mode 100644 src/components/common/error-text/index.tsx create mode 100644 src/components/common/error-text/style.ts create mode 100644 src/components/project/ProjectRegisterForm/ProjectInputField.tsx create mode 100644 src/hooks/useBottomSheet.ts create mode 100644 src/hooks/useSingleImage.ts diff --git a/app/(app)/project/create.tsx b/app/(app)/project/create.tsx index 79e78d8..2c95e71 100644 --- a/app/(app)/project/create.tsx +++ b/app/(app)/project/create.tsx @@ -1,102 +1,113 @@ import { AntDesign } from '@expo/vector-icons'; -import type { BottomSheetModal } from '@gorhom/bottom-sheet'; import BottomSheet, { BottomSheetView } from '@gorhom/bottom-sheet'; import type { DateTimePickerEvent } from '@react-native-community/datetimepicker'; import DateTimePicker from '@react-native-community/datetimepicker'; -import { launchImageLibraryAsync, MediaTypeOptions } from 'expo-image-picker'; import { useRouter } from 'expo-router'; -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useLayoutEffect, useState } from 'react'; +import { Controller, useForm } from 'react-hook-form'; import { Alert, Platform, ScrollView } from 'react-native'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; +import { z } from 'zod'; import SolidButton from '@/components/common/button/SolidButton'; import DateInput from '@/components/common/date-input'; +import ErrorText from '@/components/common/error-text'; import ImageInput from '@/components/common/image-input'; import InputField from '@/components/common/input-field'; import PreviewImage from '@/components/common/preview-image'; import Typography from '@/components/common/typography'; +import ProjectInputField from '@/components/project/ProjectRegisterForm/ProjectInputField'; import * as S from '@/components/project/ProjectRegisterForm/style'; import type { User } from '@/components/project/SearchUserList'; import SearchUserList from '@/components/project/SearchUserList'; -import { useTabBarEffect } from '@/hooks'; +import { useBottomSheet, useTabBarEffect } from '@/hooks'; +import { useSingleImage } from '@/hooks/useSingleImage'; import { shadow } from '@/styles/shadow'; import { color } from '@/styles/theme'; import { getSize } from '@/utils'; +type CreateFormType = { + name: string; + description: string; + image: string; + startDate: Date; + endDate: Date; + link?: string; + userList: User[]; +}; + +const SHEET_HEIGHT = getSize.screenHeight * 0.75; +const TODAY = new Date(); + function Create() { const router = useRouter(); useTabBarEffect(); - const [image, setImage] = useState(null); - const [startDate, setStartDate] = useState(() => new Date()); - const [endDate, setEndDate] = useState(() => new Date()); + const { + control, + getValues, + handleSubmit, + setValue, + formState: { errors }, + } = useForm({ + defaultValues: { + name: '', + description: '', + image: '', + startDate: TODAY, + endDate: TODAY, + link: '', + userList: [], + }, + }); + const [startDateTouched, setStartDateTouched] = useState(false); const [endDateTouched, setEndDateTouched] = useState(false); - const [selectUserList, setSelectUserList] = useState([]); const [selectDate, setSelectDate] = useState<'start' | 'end'>('start'); - const userListBottomSheetRef = useRef(null); - const [dataSheetOpen, setDataSheetOpen] = useState(false); - const [userListSheetOpen, setUserListSheetOpen] = useState(false); - - const dataSheetClose = useCallback(() => { - setDataSheetOpen(false); - }, []); + const [dateTimeSheetOpen, setDateTimeSheetOpen] = useState(false); - const sheetHeight = useMemo(() => getSize.screenHeight * 0.75, []); - - const openUserListSheet = useCallback(() => { - setUserListSheetOpen(true); - userListBottomSheetRef.current?.snapToIndex(0); - }, []); - - const closeUserListSheet = useCallback(() => { - setUserListSheetOpen(false); - userListBottomSheetRef.current?.close(); - }, []); + useLayoutEffect( + function handleWebAccessRestriction() { + if (Platform.OS === 'web') { + return router.replace('/project'); + } + }, + [router] + ); - const pickImage = useCallback(async () => { - const result = await launchImageLibraryAsync({ - mediaTypes: MediaTypeOptions.Images, - allowsEditing: true, - aspect: [4, 4], - quality: 1, - }); + const pickImage = useSingleImage(); - if (!result.canceled) { - setImage(result.assets[0].uri); - } - }, []); + const [userListSheetOpen, userListBottomSheetRef, openUserListSheet, closeUserListSheet] = + useBottomSheet(); const selectDateHandler = useCallback( (_: DateTimePickerEvent, date = new Date()) => { - dataSheetClose(); + setDateTimeSheetOpen(false); if (selectDate === 'start') { - if (date > endDate) return Alert.alert('이전일보다 늦은 시작일은 선택할 수 없습니다.'); - setStartDate(date); + if (date > getValues('endDate')) + return Alert.alert('이전일보다 늦은 시작일은 선택할 수 없습니다.'); + setValue('startDate', date); } else { - if (startDate > date) return Alert.alert('시작일보다 빠른 이전일은 선택할 수 없습니다.'); - setEndDate(date); + if (getValues('startDate') > date) + return Alert.alert('시작일보다 빠른 이전일은 선택할 수 없습니다.'); + setValue('endDate', date); } }, - [dataSheetClose, endDate, selectDate, startDate] + [getValues, selectDate, setValue] ); + const onSubmit = useCallback(() => { + // console.log(data, 'data'); + // {"description": "ddddd", "endDate": 2024-09-28T02:55:15.891Z, "image": "file:///data/user/0/host.exp.exponent/cache/ExperienceData/%2540anonymous%252Fdnd-119-frontend-3618ca0f-2ced-48cb-a763-65daee5044bd/ImagePicker/ae56ced8-30eb-4133-8435- + // ffa4ccd04335.jpeg", "link": "dasdad", "name": "wepro", "startDate": 2024-09-28T02:55:15.891Z, "userList": [{"id": 1, "name": "양의진", "profileImage": "https://avatars.githubusercontent.com/u/77464040?v=4", "userId": "dml1335"}, {"id": 2, "name": "양의진", "profileImage": "https://avatars.githubusercontent.com/u/77464040?v=4", "userId": "asdf091"}]} + }, []); + const startDateOpen = useCallback((select: 'start' | 'end') => { - setDataSheetOpen(true); + setDateTimeSheetOpen(true); setSelectDate(select); }, []); - useLayoutEffect(() => { - if (Platform.OS === 'web') { - return router.replace('/project'); - } - }, [router]); - - useEffect(() => { - return () => dataSheetClose(); - }, [dataSheetClose]); - return ( - - - - 프로젝트 이름 - - - * - - - - - - - - 프로젝트 정보 - - - * - - - - - - - - 프로젝트 이미지 - - - * - - - - - - - - - + + ( + + )} + name='name' + /> + + + ( + + )} + name='description' + /> + + + ( + + pickImage(onChange)} /> + + + )} + name='image' + /> + {errors.image?.message && } + + + + ( + startDateOpen('start')} + onTouchEnd={() => setStartDateTouched(true)} + /> + )} + name='startDate' + /> - 기간 + - - - * - - - - startDateOpen('start')} - onTouchEnd={() => setStartDateTouched(true)} - /> - - - startDateOpen('end')} - onTouchEnd={() => setEndDateTouched(true)} + ( + startDateOpen('end')} + onTouchEnd={() => setEndDateTouched(true)} + /> + )} + name='endDate' /> - - - - 팀원 - + + - + openUserListSheet(0)} /> - {selectUserList.map((user) => ( + {getValues('userList').map((user) => ( @@ -226,19 +229,35 @@ function Create() { ))} - - - - 프로젝트 링크 - - - + + + { + if (!value) return true; + const result = z.string().url().safeParse(value); + return result.success || '올바른 링크를 입력해주세요'; + }, + }} + render={({ field: { onChange, value, onBlur } }) => ( + + )} + name='link' + /> + {errors.link?.message && } + 등록하기 @@ -246,7 +265,7 @@ function Create() { - {dataSheetOpen && ( + {dateTimeSheetOpen && ( )} @@ -269,12 +288,12 @@ function Create() { ref={userListBottomSheetRef} index={-1} enablePanDownToClose - snapPoints={[sheetHeight]}> + snapPoints={[SHEET_HEIGHT]}> {userListSheetOpen && ( setValue('userList', newUser)} closeBottomSheet={closeUserListSheet} /> )} diff --git a/package.json b/package.json index 0199eae..36c7430 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "@emotion/react": "^11.11.4", "@expo/vector-icons": "^14.0.2", "@gorhom/bottom-sheet": "^4", + "@hookform/resolvers": "^3.9.0", "@react-navigation/native": "^6.0.2", "@tanstack/react-query": "^5.51.11", "axios": "^1.7.7", @@ -61,6 +62,7 @@ "husky": "^9.0.11", "react": "18.2.0", "react-dom": "18.2.0", + "react-hook-form": "^7.53.0", "react-native": "0.74.3", "react-native-gesture-handler": "~2.16.1", "react-native-reanimated": "~3.10.1", @@ -70,6 +72,7 @@ "react-native-web": "~0.19.10", "react-native-webview": "13.8.6", "ts-pattern": "^5.2.0", + "zod": "^3.23.8", "zustand": "^4.5.4" }, "devDependencies": { diff --git a/src/components/common/error-text/ErrorText.stories.tsx b/src/components/common/error-text/ErrorText.stories.tsx new file mode 100644 index 0000000..e369cb0 --- /dev/null +++ b/src/components/common/error-text/ErrorText.stories.tsx @@ -0,0 +1,24 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import ErrorText from './'; + +const ErrorTextMeta: Meta = { + title: 'common/ErrorText', + component: ErrorText, + argTypes: { + error_message: { + control: { + type: 'text', + }, + description: '에러 메시지를 설정합니다.', + }, + }, +}; + +export default ErrorTextMeta; + +export const Primary: StoryObj = { + args: { + error_message: '올바르지 않은 형식입니다', + }, +}; diff --git a/src/components/common/error-text/index.tsx b/src/components/common/error-text/index.tsx new file mode 100644 index 0000000..b953f9b --- /dev/null +++ b/src/components/common/error-text/index.tsx @@ -0,0 +1,30 @@ +import { AntDesign } from '@expo/vector-icons'; +import React from 'react'; + +import Typography from '@/components/common/typography'; +import { color } from '@/styles/theme'; + +import * as S from './style'; + +type Props = { + error_message: string; +}; + +function ErrorText({ error_message }: Props) { + return ( + + + + {error_message} + + + ); +} + +export default ErrorText; diff --git a/src/components/common/error-text/style.ts b/src/components/common/error-text/style.ts new file mode 100644 index 0000000..d730d0b --- /dev/null +++ b/src/components/common/error-text/style.ts @@ -0,0 +1,9 @@ +import styled from '@emotion/native'; + +import { flexDirectionRowItemsCenter } from '@/styles/common'; + +export const Container = styled.View` + ${flexDirectionRowItemsCenter}; + gap: 4px; + padding-top: 5px; +`; diff --git a/src/components/common/image-input/index.tsx b/src/components/common/image-input/index.tsx index 2e7e39a..a6a0f17 100644 --- a/src/components/common/image-input/index.tsx +++ b/src/components/common/image-input/index.tsx @@ -1,19 +1,21 @@ import { AntDesign } from '@expo/vector-icons'; +import type { PressableProps } from 'react-native'; import { shadow } from '@/styles/shadow'; import { color } from '@/styles/theme'; import * as S from './style'; -type Props = { - onChange: () => void; +type Props = PressableProps & { + onPress: () => void; }; -function ImageInput({ onChange }: Props) { +function ImageInput({ onPress, ...rest }: Props) { return ( + onPress={onPress} + {...rest}> = { }, description: '여러 줄 입력 여부를 설정합니다.', }, - touched: { - control: { - type: 'boolean', - }, - description: '터치 여부를 설정합니다.', - }, disabled: { control: { type: 'boolean', @@ -51,7 +45,6 @@ export const Primary: StoryObj = { placeholder: '프로젝트의 이름을 적어주세요', }, render: (args) => { - const [touched, setTouched] = useState(false); const [error, setError] = useState(''); const onChangeText = useCallback((text: string) => { @@ -62,17 +55,11 @@ export const Primary: StoryObj = { } }, []); - const onFocus = useCallback(() => { - setTouched(true); - }, []); - return ( diff --git a/src/components/common/input-field/index.tsx b/src/components/common/input-field/index.tsx index 427222b..249cbb9 100644 --- a/src/components/common/input-field/index.tsx +++ b/src/components/common/input-field/index.tsx @@ -1,10 +1,9 @@ -import { AntDesign } from '@expo/vector-icons'; import type { ForwardedRef, ReactNode } from 'react'; import React, { forwardRef, useRef } from 'react'; import type { TextInput } from 'react-native'; import { type TextInputProps } from 'react-native'; -import Typography from '@/components/common/typography'; +import ErrorText from '@/components/common/error-text'; import { shadow } from '@/styles/shadow'; import { color } from '@/styles/theme'; import { mergeRefs } from '@/utils'; @@ -13,7 +12,6 @@ import * as S from './style'; interface InputFieldProps extends TextInputProps { isShadow?: boolean; - touched?: boolean; disabled?: boolean; backgroundColor?: string; error?: string; @@ -24,7 +22,6 @@ const InputField = forwardRef( ( { backgroundColor = color.Background.Normal, - touched, isShadow = true, disabled = false, error, @@ -46,7 +43,7 @@ const InputField = forwardRef( style={isShadow && shadow[2]} $backgroundColor={backgroundColor} $disabled={disabled} - $isError={Boolean(touched) && Boolean(error)}> + $isError={Boolean(error)}> {icon} - {touched && Boolean(error) && ( - - - - {error} - - - )} + {!!error && } ); } diff --git a/src/components/common/input-field/style.ts b/src/components/common/input-field/style.ts index b1d4e0e..4e379cf 100644 --- a/src/components/common/input-field/style.ts +++ b/src/components/common/input-field/style.ts @@ -49,9 +49,3 @@ export const TextInput = styled.TextInput<{ $isIcon: boolean }>` font-size: 15px; color: ${(props) => props.theme.color.Label.Normal}; `; - -export const ErrorBox = styled.View` - ${flexDirectionRowItemsCenter}; - gap: 4px; - padding-top: 5px; -`; diff --git a/src/components/project/ProjectRegisterForm/ProjectInputField.tsx b/src/components/project/ProjectRegisterForm/ProjectInputField.tsx new file mode 100644 index 0000000..752274f --- /dev/null +++ b/src/components/project/ProjectRegisterForm/ProjectInputField.tsx @@ -0,0 +1,43 @@ +import type { PropsWithChildren } from 'react'; + +import Typography from '@/components/common/typography'; +import * as S from '@/components/project/ProjectRegisterForm/style'; +import { color } from '@/styles/theme'; + +type Props = { + name: string; + required?: boolean; +}; + +function ProjectInputField({ name, required = true, children }: PropsWithChildren) { + return ( + + {required ? ( + + + {name} + + + * + + + ) : ( + + {name} + + )} + {children} + + ); +} + +export default ProjectInputField; diff --git a/src/components/project/ProjectRegisterForm/style.ts b/src/components/project/ProjectRegisterForm/style.ts index 5c733fe..c20889d 100644 --- a/src/components/project/ProjectRegisterForm/style.ts +++ b/src/components/project/ProjectRegisterForm/style.ts @@ -1,4 +1,4 @@ -import styled, { css } from '@emotion/native'; +import styled from '@emotion/native'; import { flexDirectionColumn, @@ -37,18 +37,6 @@ export const DatePickerBox = styled.View` gap: 8px; `; -const fontFamlily = css({ - fontFamily: 'Pretendard', - fontWeight: 400, -}); - -export const DateSplitText = styled.Text` - ${fontFamlily}; - font-size: 24px; - line-height: 29px; - color: ${({ theme }) => theme.color.Label.Normal}; -`; - export const SubmitButtonBox = styled.View` padding: 12px 0 52px; `; diff --git a/src/components/project/SearchUserList/index.tsx b/src/components/project/SearchUserList/index.tsx index 0db5c76..fc32097 100644 --- a/src/components/project/SearchUserList/index.tsx +++ b/src/components/project/SearchUserList/index.tsx @@ -1,6 +1,5 @@ import { SimpleLineIcons } from '@expo/vector-icons'; import { Checkbox } from 'expo-checkbox'; -import type { Dispatch, SetStateAction } from 'react'; import { useCallback, useMemo } from 'react'; import { useState } from 'react'; import { Pressable } from 'react-native'; @@ -20,7 +19,7 @@ export type User = { type Props = { selectUserList: User[]; - setSelectUserList: Dispatch>; + setSelectUserList: (newUser: User[]) => void; closeBottomSheet: () => void; }; diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 2beb5b6..48f16c6 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1,3 +1,5 @@ -export * from './useAppOpen'; -export * from './useStorageState'; -export * from './useTabBarEffect'; +export { useAppOpen } from './useAppOpen'; +export { useBottomSheet } from './useBottomSheet'; +export { useSingleImage } from './useSingleImage'; +export { setStorageItemAsync, useStorageState } from './useStorageState'; +export { useTabBarEffect } from './useTabBarEffect'; diff --git a/src/hooks/useBottomSheet.ts b/src/hooks/useBottomSheet.ts new file mode 100644 index 0000000..325c45d --- /dev/null +++ b/src/hooks/useBottomSheet.ts @@ -0,0 +1,31 @@ +import type { BottomSheetModal } from '@gorhom/bottom-sheet'; +import type { RefObject } from 'react'; +import { useCallback, useRef, useState } from 'react'; + +/** + * @author Jihyeong + * @description BottomSheet를 사용하기 위한 커스텀 훅 + * @example + * const [isOpen, bottomSheetRef, openBottomSheet, closeBottomSheet] = useBottomSheet(); + */ +export function useBottomSheet(): [ + boolean, + RefObject, + (selectIndex: number) => void, + () => void, +] { + const [isOpen, setIsOpen] = useState(false); + const bottomSheetRef = useRef(null); + + const openBottomSheet = useCallback((selectIndex: number) => { + setIsOpen(true); + bottomSheetRef.current?.snapToIndex(selectIndex); + }, []); + + const closeBottomSheet = useCallback(() => { + setIsOpen(false); + bottomSheetRef.current?.close(); + }, []); + + return [isOpen, bottomSheetRef, openBottomSheet, closeBottomSheet]; +} diff --git a/src/hooks/useSingleImage.ts b/src/hooks/useSingleImage.ts new file mode 100644 index 0000000..aa6f98a --- /dev/null +++ b/src/hooks/useSingleImage.ts @@ -0,0 +1,26 @@ +import { launchImageLibraryAsync, MediaTypeOptions } from 'expo-image-picker'; +import { useCallback } from 'react'; + +/** + * @author Jihyeong + * @description 이미지를 하나만 선택하는 훅 + * @example + * const pickImage = useSingleImage(); + */ + +export function useSingleImage() { + const pickImage = useCallback(async (setImage: (image: string) => void) => { + const result = await launchImageLibraryAsync({ + mediaTypes: MediaTypeOptions.Images, + allowsEditing: true, + aspect: [4, 4], + quality: 1, + }); + + if (!result.canceled) { + setImage(result.assets[0].uri); + } + }, []); + + return pickImage; +} diff --git a/yarn.lock b/yarn.lock index 8f14d20..fd06ce1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2691,6 +2691,15 @@ __metadata: languageName: node linkType: hard +"@hookform/resolvers@npm:^3.9.0": + version: 3.9.0 + resolution: "@hookform/resolvers@npm:3.9.0" + peerDependencies: + react-hook-form: ^7.0.0 + checksum: 10c0/0e0e55f63abbd212cf14abbd39afad1f9b6105d6b25ce827fc651b624ed2be467ebe9b186026e0f032062db59ce2370b14e9583b436ae2d057738bdd6f04356c + languageName: node + linkType: hard + "@humanwhocodes/config-array@npm:^0.11.14": version: 0.11.14 resolution: "@humanwhocodes/config-array@npm:0.11.14" @@ -9222,6 +9231,7 @@ __metadata: "@eslint/js": "npm:^9.7.0" "@expo/vector-icons": "npm:^14.0.2" "@gorhom/bottom-sheet": "npm:^4" + "@hookform/resolvers": "npm:^3.9.0" "@react-native-async-storage/async-storage": "npm:1.24.0" "@react-native-community/datetimepicker": "npm:8.2.0" "@react-native-community/slider": "npm:4.5.2" @@ -9275,6 +9285,7 @@ __metadata: pretty-quick: "npm:^4.0.0" react: "npm:18.2.0" react-dom: "npm:18.2.0" + react-hook-form: "npm:^7.53.0" react-native: "npm:0.74.3" react-native-gesture-handler: "npm:~2.16.1" react-native-reanimated: "npm:~3.10.1" @@ -9292,6 +9303,7 @@ __metadata: ts-pattern: "npm:^5.2.0" typescript: "npm:~5.3.3" typescript-eslint: "npm:^7.16.1" + zod: "npm:^3.23.8" zustand: "npm:^4.5.4" languageName: unknown linkType: soft @@ -16425,6 +16437,15 @@ __metadata: languageName: node linkType: hard +"react-hook-form@npm:^7.53.0": + version: 7.53.0 + resolution: "react-hook-form@npm:7.53.0" + peerDependencies: + react: ^16.8.0 || ^17 || ^18 || ^19 + checksum: 10c0/6d62b150618a833c17d59e669b707661499e2bb516a8d340ca37699f99eb448bbba7b5b78324938c8948014e21efaa32e3510c2ba246fd5e97a96fca0cfa7c98 + languageName: node + linkType: hard + "react-is@npm:18.1.0": version: 18.1.0 resolution: "react-is@npm:18.1.0" @@ -20236,7 +20257,7 @@ __metadata: languageName: node linkType: hard -"zod@npm:^3.22.4": +"zod@npm:^3.22.4, zod@npm:^3.23.8": version: 3.23.8 resolution: "zod@npm:3.23.8" checksum: 10c0/8f14c87d6b1b53c944c25ce7a28616896319d95bc46a9660fe441adc0ed0a81253b02b5abdaeffedbeb23bdd25a0bf1c29d2c12dd919aef6447652dd295e3e69 From 299b34f1fa6dde790f399937559c01ff8aca4a73 Mon Sep 17 00:00:00 2001 From: Jihyeong Date: Tue, 1 Oct 2024 00:38:15 +0900 Subject: [PATCH 19/21] =?UTF-8?q?Feat/#22=20=EB=B3=80=EA=B2=BD=EB=90=9C=20?= =?UTF-8?q?=EC=95=84=EC=9D=B4=EC=BD=98=20=EC=84=A4=EC=A0=95=EC=9D=84=20?= =?UTF-8?q?=EB=B0=98=EC=98=81=ED=95=A9=EB=8B=88=EB=8B=A4.=20(#73)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 변경된 아이콘 설정을 반영합니다. * feat: 변경된 스타일을 이용하여 Tag 컴포넌트를 추가합니다. --- src/components/common/icon/category-icon.tsx | 115 +++++-- .../category-chip/CategoryChip.stories.tsx | 33 +- .../category/Category.stories.tsx | 125 ++++++-- src/components/review/Tag/Tag.stories.tsx | 295 ++++++++++++++++++ src/components/review/Tag/index.tsx | 50 +++ src/components/review/Tag/style.ts | 27 ++ src/styles/category.ts | 12 +- src/types/category.ts | 8 +- 8 files changed, 603 insertions(+), 62 deletions(-) create mode 100644 src/components/review/Tag/Tag.stories.tsx create mode 100644 src/components/review/Tag/index.tsx create mode 100644 src/components/review/Tag/style.ts diff --git a/src/components/common/icon/category-icon.tsx b/src/components/common/icon/category-icon.tsx index 569a73b..1cb4ecc 100644 --- a/src/components/common/icon/category-icon.tsx +++ b/src/components/common/icon/category-icon.tsx @@ -1,5 +1,5 @@ import { memo } from 'react'; -import Svg, { Ellipse, Path } from 'react-native-svg'; +import Svg, { Circle, Ellipse, G, Path, Rect } from 'react-native-svg'; import { color } from '@/styles/theme'; import type { CategoryType } from '@/types/category'; @@ -13,25 +13,32 @@ function CategoryIcon({ category }: Props) { case '기술': return ( - + + + ); case '커뮤니케이션': return ( @@ -49,33 +56,20 @@ function CategoryIcon({ category }: Props) { /> ); - case '협업': - return ( - - - - ); case '문서화': return ( ); - case '시간관리': + case '문제해결': return ( ); + case '배려심': + return ( + + + + + ); + case '아이데이션': + return ( + + + + ); + case '팔로워십': + return ( + + + + + ); } } diff --git a/src/components/questionnaire/category-chip/CategoryChip.stories.tsx b/src/components/questionnaire/category-chip/CategoryChip.stories.tsx index b98f6b1..1b61e79 100644 --- a/src/components/questionnaire/category-chip/CategoryChip.stories.tsx +++ b/src/components/questionnaire/category-chip/CategoryChip.stories.tsx @@ -1,7 +1,8 @@ import type { Meta, StoryObj } from '@storybook/react'; import Storybook from '@/components/common/storybook'; -import CategoryChip from '@/components/questionnaire/category-chip/index'; + +import CategoryChip from './'; const CategoryChipMeta: Meta = { title: 'questionnaire/CategoryChip', @@ -10,7 +11,17 @@ const CategoryChipMeta: Meta = { category: { control: { type: 'select', - options: ['기술', '커뮤니케이션', '성실성', '협업', '문서화', '시간관리', '리더십'], + options: [ + '기술', + '성실성', + '문서화', + '문제해결', + '커뮤니케이션', + '리더십', + '팔로워십', + '배려심', + '아이데이션', + ], }, description: '카테고리명을 입력합니다.', }, @@ -32,16 +43,28 @@ export const Preview: StoryObj = { - - + + + + diff --git a/src/components/questionnaire/category/Category.stories.tsx b/src/components/questionnaire/category/Category.stories.tsx index b82793a..54e05d8 100644 --- a/src/components/questionnaire/category/Category.stories.tsx +++ b/src/components/questionnaire/category/Category.stories.tsx @@ -1,7 +1,8 @@ import type { Meta, StoryObj } from '@storybook/react'; import Storybook from '@/components/common/storybook'; -import Category from '@/components/questionnaire/category/index'; + +import Category from './'; const CategoryMeta: Meta = { title: 'questionnaire/Category', @@ -10,7 +11,7 @@ const CategoryMeta: Meta = { category: { control: { type: 'select', - options: ['기술', '커뮤니케이션', '성실성', '협업', '문서화', '시간관리', '리더십'], + options: ['기술', '커뮤니케이션', '성실성', '문제해결', '문서화', '문제해결', '리더십'], }, description: '카테고리명을 입력합니다.', }, @@ -53,7 +54,17 @@ export const Preview: StoryObj = { = { + + = { + + = { + + = { + + diff --git a/src/components/review/Tag/Tag.stories.tsx b/src/components/review/Tag/Tag.stories.tsx new file mode 100644 index 0000000..4ba0a73 --- /dev/null +++ b/src/components/review/Tag/Tag.stories.tsx @@ -0,0 +1,295 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import Storybook from '@/components/common/storybook'; + +import Tag from './'; + +const TagMeta: Meta = { + title: 'questionnaire/Tag', + component: Tag, + argTypes: { + tag: { + control: { + type: 'select', + options: [ + '걸어다니는 위키', + '아이디어 화수분', + '글도 잘쓰는 일잘러', + '확신의 J', + '책임감 넘치는 리더', + '책임감 넘치는 팀원', + '갈등을 못참는 박애주의자', + '성실함의 아이콘', + '몸에 밴 배려', + ], + }, + description: '카테고리명을 입력합니다.', + }, + hasIcon: { + control: { + type: 'boolean', + }, + description: '아이콘을 표시할지 결정합니다.', + }, + isActive: { + control: { + type: 'boolean', + }, + description: '칩의 활성화 여부를 결정합니다.', + }, + }, +}; + +export default TagMeta; + +export const Primary: StoryObj = { + args: { + hasIcon: false, + isActive: false, + tag: '걸어다니는 위키', + }, +}; + +export const Preview: StoryObj = { + render: () => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {' '} + + + + + + + + + + + + + + + + ); + }, +}; diff --git a/src/components/review/Tag/index.tsx b/src/components/review/Tag/index.tsx new file mode 100644 index 0000000..53c24d6 --- /dev/null +++ b/src/components/review/Tag/index.tsx @@ -0,0 +1,50 @@ +import CategoryIcon from '@/components/common/icon/category-icon'; +import Typography from '@/components/common/typography'; +import { CategoryStyle } from '@/styles/category'; +import { shadow } from '@/styles/shadow'; +import { color } from '@/styles/theme'; +import type { CategoryType } from '@/types/category'; + +import * as S from './style'; + +enum TagEnum { + '걸어다니는 위키' = '기술', + '아이디어 화수분' = '아이데이션', + '글도 잘쓰는 일잘러' = '문서화', + '확신의 J' = '문제해결', + '책임감 넘치는 리더' = '리더십', + '책임감 넘치는 팀원' = '팔로워십', + '갈등을 못참는 박애주의자' = '커뮤니케이션', + '성실함의 아이콘' = '성실성', + '몸에 밴 배려' = '배려심', +} + +type Props = { + tag: keyof typeof TagEnum; + hasIcon?: boolean; + isActive?: boolean; + hasShadow?: boolean; +}; + +function Tag({ tag, hasIcon = true, isActive = false, hasShadow = false }: Props) { + const category: CategoryType = TagEnum[tag]; + return ( + + {hasIcon && ( + + + + )} + + {tag} + + + ); +} + +export default Tag; diff --git a/src/components/review/Tag/style.ts b/src/components/review/Tag/style.ts new file mode 100644 index 0000000..6f21249 --- /dev/null +++ b/src/components/review/Tag/style.ts @@ -0,0 +1,27 @@ +import type { ReactNativeStyle } from '@emotion/native'; +import styled from '@emotion/native'; + +import { flexDirectionRowItemsCenter } from '@/styles/common'; + +export const Container = styled.View<{ + $isActive: ReactNativeStyle | boolean; +}>` + box-sizing: border-box; + ${({ $isActive }) => $isActive && $isActive} + ${flexDirectionRowItemsCenter}; + gap: 6px; + width: fit-content; + height: 48px; + padding: 12px 16px; + background: ${({ theme, $isActive }) => + $isActive ? $isActive : theme.color.Background.Alternative}; + border: ${({ theme, $isActive }) => + !$isActive && `1px solid ${theme.color.Background.Alternative}`}; + border-radius: 4px; +`; + +export const IconWrapper = styled.View` + ${flexDirectionRowItemsCenter}; + width: 24px; + height: 24px; +`; diff --git a/src/styles/category.ts b/src/styles/category.ts index 2cffa2f..1bb7f79 100644 --- a/src/styles/category.ts +++ b/src/styles/category.ts @@ -13,7 +13,7 @@ export const CategoryStyle: Record = { background: ${color.Pink['95']}; border: 1px solid ${color.Pink['90']}; `, - 협업: css` + 아이데이션: css` background: ${color.Orange['95']}; border: 1px solid ${color.Orange['90']}; `, @@ -25,7 +25,7 @@ export const CategoryStyle: Record = { background: ${color.LightBlue['95']}; border: 1px solid ${color.LightBlue['90']}; `, - 시간관리: css` + 문제해결: css` background: ${color.Violet['95']}; border: 1px solid ${color.Violet['90']}; `, @@ -33,4 +33,12 @@ export const CategoryStyle: Record = { background: #e6fad9; border: 1px solid #c3f0a3; `, + 배려심: css` + background: ${color.Purple['95']}; + border: 1px solid ${color.Purple['90']}; + `, + 팔로워십: css` + background: #d9fade; + border: 1px solid #a3f0b8; + `, } as const; diff --git a/src/types/category.ts b/src/types/category.ts index fd3f2b9..22ac49c 100644 --- a/src/types/category.ts +++ b/src/types/category.ts @@ -2,7 +2,9 @@ export type CategoryType = | '기술' | '커뮤니케이션' | '성실성' - | '협업' + | '문제해결' | '문서화' - | '시간관리' - | '리더십'; + | '리더십' + | '배려심' + | '팔로워십' + | '아이데이션'; From ab3906fc887d5c34d5d72933afd5e7ae34c1b028 Mon Sep 17 00:00:00 2001 From: Jihyeong Date: Thu, 3 Oct 2024 12:35:19 +0900 Subject: [PATCH 20/21] =?UTF-8?q?fix:=20=EC=A0=81=EC=9A=A9=EB=90=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8A=94=20=EC=8A=A4=ED=83=80=EC=9D=BC?= =?UTF-8?q?=EC=9D=84=20=EC=88=98=EC=A0=95=ED=95=A9=EB=8B=88=EB=8B=A4.=20(#?= =?UTF-8?q?75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/_layout.tsx | 23 +++++++++++-- src/components/home/BusinessCard/index.tsx | 34 +++++++++++++------ src/components/home/BusinessCard/style.ts | 3 ++ .../onboarding/OnboardingSection/index.tsx | 20 +++++++---- .../QuestionnaireCheckList/index.tsx | 4 +-- .../QuestionnaireCheckList/style.ts | 34 +++++++++---------- .../questionnaire/category-chip/style.ts | 1 + src/components/review/ReviewCard/style.ts | 2 +- 8 files changed, 82 insertions(+), 39 deletions(-) diff --git a/app/_layout.tsx b/app/_layout.tsx index b534d57..b7678e2 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,17 +1,36 @@ import styled from '@emotion/native'; +import { useFonts } from 'expo-font'; import { Slot, SplashScreen } from 'expo-router'; +import { useEffect } from 'react'; import { Platform } from 'react-native'; import Provider from '@/components/common/provider'; import { SCREEN_SIZE } from '@/constants'; -import { useAppOpen } from '@/hooks'; import { SessionProvider } from '@/store'; import { OnboardingProvider } from '@/store/useOnboarding'; SplashScreen.preventAutoHideAsync(); export default function Root() { - useAppOpen(); + const [loaded, error] = useFonts({ + Pretendard: require('../assets/fonts/Pretendard-Regular.otf'), + 'Pretendard-Bold': require('../assets/fonts/Pretendard-Bold.otf'), + 'Pretendard-SemiBold': require('../assets/fonts/Pretendard-SemiBold.otf'), + 'Pretendard-Medium': require('../assets/fonts/Pretendard-Medium.otf'), + }); + + useEffect(() => { + if (error) throw error; + }, [error]); + + if (loaded) { + SplashScreen.hideAsync(); + } + + if (!loaded) { + return null; + } + if (Platform.OS === 'web') { return ( diff --git a/src/components/home/BusinessCard/index.tsx b/src/components/home/BusinessCard/index.tsx index 957cc4a..8d96562 100644 --- a/src/components/home/BusinessCard/index.tsx +++ b/src/components/home/BusinessCard/index.tsx @@ -22,12 +22,12 @@ function BusinessCard({ name, review, projectName, onboarding = false, isActive display: 'flex', flexDirection: 'column', position: 'absolute', - bottom: -36, - left: -16, + bottom: onboarding ? -14 : -45, + left: onboarding ? -22 : -17, gap: 6, width: 210, - paddingHorizontal: 24, - paddingVertical: 16, + paddingHorizontal: onboarding ? 19 : 24, + paddingVertical: onboarding ? 13 : 16, borderRadius: 16, backgroundColor: color.Label.Strong, opacity: withTiming(isActive ? 1 : 0), @@ -48,24 +48,36 @@ function BusinessCard({ name, review, projectName, onboarding = false, isActive {review} #{projectName} diff --git a/src/components/home/BusinessCard/style.ts b/src/components/home/BusinessCard/style.ts index 4bf0dd1..6ac3cbe 100644 --- a/src/components/home/BusinessCard/style.ts +++ b/src/components/home/BusinessCard/style.ts @@ -3,7 +3,10 @@ import styled from '@emotion/native'; export const Container = styled.View<{ $isOnboarding: boolean }>` position: relative; width: ${({ $isOnboarding }) => ($isOnboarding ? '240px' : '300px')}; + height: ${({ $isOnboarding }) => ($isOnboarding ? '370px' : 'auto')}; padding: 74px 0; + margin-bottom: ${({ $isOnboarding }) => $isOnboarding && '14px'}; + margin-left: ${({ $isOnboarding }) => $isOnboarding && '22px'}; background: ${({ theme }) => theme.color.Background.Normal}; border-radius: 17px; `; diff --git a/src/components/onboarding/OnboardingSection/index.tsx b/src/components/onboarding/OnboardingSection/index.tsx index 9fa202d..5ec4a7c 100644 --- a/src/components/onboarding/OnboardingSection/index.tsx +++ b/src/components/onboarding/OnboardingSection/index.tsx @@ -20,27 +20,35 @@ function OnboardingSection({ step }: Props) { /> + + ); diff --git a/src/components/questionnaire/QuestionnaireCheckList/index.tsx b/src/components/questionnaire/QuestionnaireCheckList/index.tsx index c60ebff..6fe908d 100644 --- a/src/components/questionnaire/QuestionnaireCheckList/index.tsx +++ b/src/components/questionnaire/QuestionnaireCheckList/index.tsx @@ -51,7 +51,7 @@ function CheckList({ title, category, initialCheckValue, - onboarding, + onboarding = false, children, }: PropsWithChildren) { const [checkValue, setCheckValue] = useState(() => @@ -63,7 +63,7 @@ function CheckList({ checkValue, setCheckValue, }}> - + ` ${flexDirectionColumn}; - gap: 20px; + gap: ${({ $onboarding }) => ($onboarding ? 16 : 24) + 'px'}; width: 272px; + height: ${({ $onboarding }) => ($onboarding ? '420px' : 'auto')}; padding: 28px 16px; background: ${({ theme }) => theme.color.Background.Normal}; border-radius: 13px; `; +export const ListContainer = styled.View` + ${flexDirectionColumn}; + gap: 8px; +`; + const inActiveStyle = (theme: Theme) => css` background: ${theme.color.Background.Normal}; border: 1px solid ${theme.color.Line.Normal}; @@ -23,27 +29,21 @@ const activeStyle = (theme: Theme) => css` `; export const ItemContainer = styled.View<{ $isChecked: boolean }>` - ${flexDirectionRowItemsCenter}; + display: flex; + flex-direction: row; + align-items: center; ${({ theme, $isChecked }) => ($isChecked ? activeStyle(theme) : inActiveStyle(theme))}; - justify-content: space-between; - padding: 13px 28px 13px 13px; + padding: 13px; border-radius: 7px; `; -export const ListContainer = styled.View` - ${flexDirectionColumn}; - gap: 8px; -`; - export const ItemValue = styled.View` ${flexDirectionColumn}; + flex-grow: 1; `; export const RadioButton = styled.Pressable` - ${flexDirectionRowItemsCenter}; - position: absolute; - top: 0; - right: 0; - height: 100%; - padding: 13px; + flex-shrink: 1; + width: 16px; + height: 16px; `; diff --git a/src/components/questionnaire/category-chip/style.ts b/src/components/questionnaire/category-chip/style.ts index 8fdf1ef..caf1d94 100644 --- a/src/components/questionnaire/category-chip/style.ts +++ b/src/components/questionnaire/category-chip/style.ts @@ -3,6 +3,7 @@ import styled from '@emotion/native'; export const Container = styled.View<{ $categoryStyle: ReactNativeStyle }>` ${({ $categoryStyle }) => $categoryStyle}; + align-self: flex-start; width: fit-content; padding: 5px 10px; border-radius: 3px; diff --git a/src/components/review/ReviewCard/style.ts b/src/components/review/ReviewCard/style.ts index 7872fca..1fb0c93 100644 --- a/src/components/review/ReviewCard/style.ts +++ b/src/components/review/ReviewCard/style.ts @@ -12,7 +12,7 @@ export const Container = styled.View` `; export const ProjectChip = styled.View` - width: fit-content; + align-self: flex-start; padding: 6px 12px; background: ${({ theme }) => theme.color.CoolNeutral['98']}; border-radius: 4px; From df1f02fdefc52c3ef7ac003d6b39c8b05093407e Mon Sep 17 00:00:00 2001 From: Eden Date: Sat, 5 Oct 2024 10:34:12 +0900 Subject: [PATCH 21/21] =?UTF-8?q?Feat/#60=20=EB=A6=AC=EB=B7=B0=20=EC=B9=B4?= =?UTF-8?q?=ED=85=8C=EA=B3=A0=EB=A6=AC=20=EC=84=A0=ED=83=9D=ED=99=94?= =?UTF-8?q?=EB=A9=B4=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=A9=EB=8B=88?= =?UTF-8?q?=EB=8B=A4.=20(#76)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 리뷰 탭을 제거한다. * feat: 태그 스타일을 추가합니다. * refactor: 카테고리 스타일을 변경합니다. * feat: 리뷰 카테고리 선택화면을 추가합니다. * refactor: 주소에 맞게 페이지를 수정합니다. --- app/(app)/_layout.tsx | 36 +++---- .../{detail/[id] => [id]/detail}/index.tsx | 20 +++- .../{detail/[id] => [id]/detail}/style.ts | 0 app/(app)/project/[id]/review/_layout.tsx | 40 ++++++++ app/(app)/project/[id]/review/create.tsx | 93 +++++++++++++++++++ app/(app)/{ => project/[id]}/review/index.tsx | 6 +- app/(app)/project/_layout.tsx | 16 +++- app/(app)/project/delete.tsx | 13 --- app/(app)/review/_layout.tsx | 31 ------- app/(app)/review/create.tsx | 13 --- app/(app)/review/delete.tsx | 13 --- src/components/common/icon/fire-svg.tsx | 23 ----- .../onboarding/OnboardingSection/index.tsx | 45 ++------- .../project/ProjectDetail/index.tsx | 15 ++- src/components/project/ProjectItem/index.tsx | 3 +- .../QuestionnaireCheckList/index.tsx | 6 +- .../QuestionnaireCheckList/style.ts | 2 +- .../SelectCategoryChipList/index.tsx | 73 +++++++++++++++ .../SelectCategoryChipList/style.ts | 12 +++ .../questionnaire/category/index.tsx | 9 +- .../questionnaire/category/style.ts | 7 +- src/components/review/Tag/index.tsx | 16 +--- src/components/review/Tag/style.ts | 1 + src/constants/navigations.ts | 2 +- src/constants/siteUrls.ts | 8 ++ src/styles/category.ts | 36 +++---- src/styles/tag.ts | 56 +++++++++++ 27 files changed, 383 insertions(+), 212 deletions(-) rename app/(app)/project/{detail/[id] => [id]/detail}/index.tsx (76%) rename app/(app)/project/{detail/[id] => [id]/detail}/style.ts (100%) create mode 100644 app/(app)/project/[id]/review/_layout.tsx create mode 100644 app/(app)/project/[id]/review/create.tsx rename app/(app)/{ => project/[id]}/review/index.tsx (58%) delete mode 100644 app/(app)/project/delete.tsx delete mode 100644 app/(app)/review/_layout.tsx delete mode 100644 app/(app)/review/create.tsx delete mode 100644 app/(app)/review/delete.tsx delete mode 100644 src/components/common/icon/fire-svg.tsx create mode 100644 src/components/questionnaire/SelectCategoryChipList/index.tsx create mode 100644 src/components/questionnaire/SelectCategoryChipList/style.ts create mode 100644 src/styles/tag.ts diff --git a/app/(app)/_layout.tsx b/app/(app)/_layout.tsx index f361bb3..50463ee 100644 --- a/app/(app)/_layout.tsx +++ b/app/(app)/_layout.tsx @@ -5,7 +5,6 @@ import { Redirect, Tabs } from 'expo-router'; import React from 'react'; import { Text } from 'react-native'; -import FireSvg from '@/components/common/icon/fire-svg'; import Typography from '@/components/common/typography'; import type { MainNavigations } from '@/constants'; import { MAIN_NAVIGATIONS } from '@/constants'; @@ -13,6 +12,7 @@ import { SITE_URLS } from '@/constants'; import { useSession } from '@/store'; import { useOnboarding } from '@/store/useOnboarding'; import useTabBar from '@/store/useTabBar'; +import { flexDirectionRow, flexItemCenter } from '@/styles/common'; import { color } from '@/styles/theme'; const tabBarOptions = { @@ -36,15 +36,6 @@ const tabBarOptions = { /> ), }, - [MAIN_NAVIGATIONS.REVIEW]: { - label: '리뷰', - icon: (color: string) => ( - - ), - }, [MAIN_NAVIGATIONS.MY]: { label: '마이', icon: (color: string) => ( @@ -72,7 +63,7 @@ const TabBar = ({ state, descriptors, navigation }: BottomTabBarProps) => { const isFocused = state.index === index; - if (route.name === 'alarm' || typeof label === 'function') { + if (route.name === 'alarm' || typeof label === 'function' || !tabBarOptions[label]) { return null; } @@ -103,14 +94,13 @@ const TabBar = ({ state, descriptors, navigation }: BottomTabBarProps) => { accessibilityLabel={options.tabBarAccessibilityLabel} testID={options.tabBarTestID} onPress={onPress} - onLongPress={onLongPress} - style={{ flex: 1 }}> - {tabBarOptions[label].icon(isFocused ? '#000000' : '#cccccc')} - + onLongPress={onLongPress}> + {tabBarOptions[label].icon(isFocused ? color.Common['0'] : '#cccccc')} + {tabBarOptions[label].label} - + ); })} @@ -145,7 +135,6 @@ export default function Layout() { tabBar={(tabBar) => }> - ); @@ -153,9 +142,9 @@ export default function Layout() { const S = { TabBar: styled.View` + ${flexDirectionRow}; position: absolute; bottom: 0; - flex-direction: row; align-items: center; justify-content: space-between; width: 100%; @@ -163,8 +152,11 @@ const S = { background-color: white; `, TabBarItem: styled.TouchableOpacity` + ${flexItemCenter}; + flex: 1; gap: 4px; - align-items: center; - justify-content: center; + `, + TabBarText: styled(Typography)<{ $isFocused: boolean }>` + color: ${({ $isFocused }) => ($isFocused ? '#000000' : '#cccccc')}; `, }; diff --git a/app/(app)/project/detail/[id]/index.tsx b/app/(app)/project/[id]/detail/index.tsx similarity index 76% rename from app/(app)/project/detail/[id]/index.tsx rename to app/(app)/project/[id]/detail/index.tsx index ed6d998..349d1fe 100644 --- a/app/(app)/project/detail/[id]/index.tsx +++ b/app/(app)/project/[id]/detail/index.tsx @@ -1,16 +1,17 @@ import { Feather } from '@expo/vector-icons'; -import { useNavigation, useRouter } from 'expo-router'; +import { useLocalSearchParams, useNavigation, useRouter } from 'expo-router'; import { useLayoutEffect } from 'react'; import { Platform, Pressable } from 'react-native'; import { MOCK_PROJECT_DETAIL } from '@/__mock__/project'; import Typography from '@/components/common/typography'; import ProjectDetail from '@/components/project/ProjectDetail'; +import { PROJECT_URLS } from '@/constants'; import { color } from '@/styles/theme'; function Page() { const router = useRouter(); - // const { id } = useLocalSearchParams(); 실제 데이터로 올 경우 해당 id를 이용하여 조회 + const { id } = useLocalSearchParams<{ id: string }>(); const navigation = useNavigation(); const data = MOCK_PROJECT_DETAIL; @@ -37,7 +38,7 @@ function Page() { ), headerRight: () => Platform.OS !== 'web' ? ( - router.push('/project/create')}> + router.push(PROJECT_URLS.PROJECT_CREATE)}> ) : null, }); - }, [navigation]); + }, [data.name, navigation, router]); - return ; + if (!id) { + return null; + } + + return ( + + ); } export default Page; diff --git a/app/(app)/project/detail/[id]/style.ts b/app/(app)/project/[id]/detail/style.ts similarity index 100% rename from app/(app)/project/detail/[id]/style.ts rename to app/(app)/project/[id]/detail/style.ts diff --git a/app/(app)/project/[id]/review/_layout.tsx b/app/(app)/project/[id]/review/_layout.tsx new file mode 100644 index 0000000..c40314e --- /dev/null +++ b/app/(app)/project/[id]/review/_layout.tsx @@ -0,0 +1,40 @@ +import { Feather } from '@expo/vector-icons'; +import { Stack, useRouter } from 'expo-router'; +import { Pressable } from 'react-native'; + +import { PROJECT_URLS, REVIEW_NAVIGATIONS } from '@/constants'; +import { color } from '@/styles/theme'; + +function Layout() { + const router = useRouter(); + return ( + ({ + headerStyle: { height: 40, backgroundColor: color.Background.Normal }, + headerTitleStyle: { + paddingTop: 12, + fontFamily: 'Pretendard-Bold', + }, + headerTitleAlign: 'center', + headerShadowVisible: false, + })}> + ( + (canGoBack ? router.back() : router.push(PROJECT_URLS.PROJECT_HOME))}> + + + ), + }} + /> + + ); +} + +export default Layout; diff --git a/app/(app)/project/[id]/review/create.tsx b/app/(app)/project/[id]/review/create.tsx new file mode 100644 index 0000000..bd8edc3 --- /dev/null +++ b/app/(app)/project/[id]/review/create.tsx @@ -0,0 +1,93 @@ +import styled from '@emotion/native'; +import { useCallback, useState } from 'react'; + +import SolidButton from '@/components/common/button/SolidButton'; +import Typography from '@/components/common/typography'; +import SelectCategoryChipList from '@/components/questionnaire/SelectCategoryChipList'; +import { useTabBarEffect } from '@/hooks'; +import { flexDirectionColumn } from '@/styles/common'; + +const MINIMUM_CATEGORY_COUNT = 5; + +function Review() { + useTabBarEffect(); + // const { id } = useLocalSearchParams<{ id: string }>(); + + const [error, setError] = useState(null); + const [selectedCategoryList, setSelectedCategoryList] = useState([]); + + const addCategory = (category: string) => { + if (error) { + setError(null); + } + setSelectedCategoryList([...selectedCategoryList, category]); + }; + const removeCategory = (category: string) => + setSelectedCategoryList(selectedCategoryList.filter((item) => item !== category)); + + const selectCategory = useCallback(() => { + if (selectedCategoryList.length < MINIMUM_CATEGORY_COUNT) { + setError('5개를 선택해주세요'); + return; + } + setError(null); + }, [selectedCategoryList]); + + return ( + + + + + 받고 싶은 리뷰의{`\n`}카테고리 5개를 골라주세요 + + + 카테고리 별로 설문이 구성돼요 + + + + + + + + 다음 + + + ); +} + +const S = { + Container: styled.SafeAreaView` + flex: 1; + gap: 32px; + justify-content: space-between; + padding: 33px 20px 52px; + background: ${({ theme }) => theme.color.Background.Normal}; + `, + WrapperBox: styled.View` + ${flexDirectionColumn}; + gap: 32px; + `, + ReviewTitle: styled.View` + ${flexDirectionColumn}; + gap: 8px; + `, + TitleText: styled(Typography)` + color: ${({ theme }) => theme.color.Label.Normal}; + `, + SubTitleText: styled(Typography)` + color: ${({ theme }) => theme.color.Label.Alternative}; + `, +}; + +export default Review; diff --git a/app/(app)/review/index.tsx b/app/(app)/project/[id]/review/index.tsx similarity index 58% rename from app/(app)/review/index.tsx rename to app/(app)/project/[id]/review/index.tsx index 7a5fbca..538fe70 100644 --- a/app/(app)/review/index.tsx +++ b/app/(app)/project/[id]/review/index.tsx @@ -2,12 +2,12 @@ import { View } from 'react-native'; import Typography from '@/components/common/typography'; -function Review() { +function Page() { return ( - Review + Page ); } -export default Review; +export default Page; diff --git a/app/(app)/project/_layout.tsx b/app/(app)/project/_layout.tsx index c3a07d6..40eebcf 100644 --- a/app/(app)/project/_layout.tsx +++ b/app/(app)/project/_layout.tsx @@ -1,9 +1,10 @@ import { AntDesign, Feather } from '@expo/vector-icons'; import { Stack, useRouter } from 'expo-router'; -import { Platform, Pressable } from 'react-native'; +import { Pressable } from 'react-native'; -import { PROJECT_NAVIGATIONS } from '@/constants'; +import { PROJECT_NAVIGATIONS, PROJECT_URLS } from '@/constants'; import { color } from '@/styles/theme'; +import { isMobile } from '@/utils'; import * as S from './style'; @@ -32,8 +33,8 @@ function Layout() { size={24} /> - {Platform.OS !== 'web' && ( - router.push('/project/create')}> + {isMobile && ( + router.push(PROJECT_URLS.PROJECT_CREATE)}> ( - (canGoBack ? router.back() : router.push('/project'))}> + (canGoBack ? router.back() : router.push(PROJECT_URLS.PROJECT_HOME))}> + ); } diff --git a/app/(app)/project/delete.tsx b/app/(app)/project/delete.tsx deleted file mode 100644 index 06e96ab..0000000 --- a/app/(app)/project/delete.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { View } from 'react-native'; - -import Typography from '@/components/common/typography'; - -function Delete() { - return ( - - Delete - - ); -} - -export default Delete; diff --git a/app/(app)/review/_layout.tsx b/app/(app)/review/_layout.tsx deleted file mode 100644 index 284e9b6..0000000 --- a/app/(app)/review/_layout.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { Stack } from 'expo-router'; -import { fontFace } from 'polished'; - -function Layout() { - return ( - - - - - ); -} - -export default Layout; diff --git a/app/(app)/review/create.tsx b/app/(app)/review/create.tsx deleted file mode 100644 index 9b11115..0000000 --- a/app/(app)/review/create.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { View } from 'react-native'; - -import Typography from '@/components/common/typography'; - -function Create() { - return ( - - Create - - ); -} - -export default Create; diff --git a/app/(app)/review/delete.tsx b/app/(app)/review/delete.tsx deleted file mode 100644 index 06e96ab..0000000 --- a/app/(app)/review/delete.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { View } from 'react-native'; - -import Typography from '@/components/common/typography'; - -function Delete() { - return ( - - Delete - - ); -} - -export default Delete; diff --git a/src/components/common/icon/fire-svg.tsx b/src/components/common/icon/fire-svg.tsx deleted file mode 100644 index bfa205b..0000000 --- a/src/components/common/icon/fire-svg.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import Svg, { Path } from 'react-native-svg'; - -type Props = { - size: number; - color: string; -}; - -function FireSvg({ size, color }: Props) { - return ( - - - - ); -} - -export default FireSvg; diff --git a/src/components/onboarding/OnboardingSection/index.tsx b/src/components/onboarding/OnboardingSection/index.tsx index 5ec4a7c..22836fc 100644 --- a/src/components/onboarding/OnboardingSection/index.tsx +++ b/src/components/onboarding/OnboardingSection/index.tsx @@ -14,42 +14,15 @@ function OnboardingSection({ step }: Props) { case 0: return ( - - - - - - - - - + + + + + + + + + ); case 1: diff --git a/src/components/project/ProjectDetail/index.tsx b/src/components/project/ProjectDetail/index.tsx index 7524fd4..a36c6b5 100644 --- a/src/components/project/ProjectDetail/index.tsx +++ b/src/components/project/ProjectDetail/index.tsx @@ -1,10 +1,11 @@ import { AntDesign } from '@expo/vector-icons'; +import { router } from 'expo-router'; import { ScrollView } from 'react-native'; import SolidButton from '@/components/common/button/SolidButton'; import SlideBar from '@/components/common/slide-bar'; import Typography from '@/components/common/typography'; -import { COMPONENT_SIZE } from '@/constants'; +import { COMPONENT_SIZE, PROJECT_URLS } from '@/constants'; import { shadow } from '@/styles/shadow'; import { color } from '@/styles/theme'; import { getSize } from '@/utils'; @@ -18,6 +19,7 @@ export type ProjectDetailType = { profile: string; startDate: string; endDate: string; + hasReviewCard: boolean; review_count: number; userList: UserType[]; link: string; @@ -29,10 +31,11 @@ type UserType = { }; type Props = { + id: string; data: ProjectDetailType; }; -function ProjectDetail({ data }: Props) { +function ProjectDetail({ id, data }: Props) { return ( - 설문지 만들기 + + router.push({ pathname: PROJECT_URLS.PROJECT_REVIEW_CREATE, params: { id } }) + } + full> + 설문지 만들기 + ); diff --git a/src/components/project/ProjectItem/index.tsx b/src/components/project/ProjectItem/index.tsx index 746b1ed..e63997d 100644 --- a/src/components/project/ProjectItem/index.tsx +++ b/src/components/project/ProjectItem/index.tsx @@ -4,6 +4,7 @@ import SlideBar from '@/components/common/slide-bar'; import Typography from '@/components/common/typography'; import ProjectImage from '@/components/project/ProjectImage'; import type { ProjectItemType } from '@/components/project/ProjectList'; +import { PROJECT_URLS } from '@/constants'; import { color } from '@/styles/theme'; import * as S from './style'; @@ -13,7 +14,7 @@ function ProjectItem({ name, member_num, profile, review_count, id }: ProjectIte return ( router.push({ pathname: '/project/detail/[id]', params: { id } })}> + onPress={() => router.push({ pathname: PROJECT_URLS.PROJECT_DETAIL, params: { id } })}> ) { const { checkValue, setCheckValue } = useContext(ListContext); const isChecked = checkValue === value; return ( - + setCheckValue(value)} + $isChecked={isChecked}> {children} - setCheckValue(value)}> + css` border: 1px solid ${theme.color.Primary.Normal}; `; -export const ItemContainer = styled.View<{ $isChecked: boolean }>` +export const ItemContainer = styled.Pressable<{ $isChecked: boolean }>` display: flex; flex-direction: row; align-items: center; diff --git a/src/components/questionnaire/SelectCategoryChipList/index.tsx b/src/components/questionnaire/SelectCategoryChipList/index.tsx new file mode 100644 index 0000000..2a53ed9 --- /dev/null +++ b/src/components/questionnaire/SelectCategoryChipList/index.tsx @@ -0,0 +1,73 @@ +import ErrorText from '@/components/common/error-text'; +import Category from '@/components/questionnaire/category'; + +import * as S from './style'; + +type Props = { + item: string[]; + addItem: (item: string) => void; + removeItem: (item: string) => void; + error: string | null; +}; + +function SelectCategoryChipList({ item, addItem, removeItem, error }: Props) { + const isActive = (category: string) => item.includes(category); + return ( + + + (isActive('기술') ? removeItem('기술') : addItem('기술'))} + /> + (isActive('성실성') ? removeItem('성실성') : addItem('성실성'))} + /> + (isActive('배려심') ? removeItem('배려심') : addItem('배려심'))} + /> + + isActive('아이데이션') ? removeItem('아이데이션') : addItem('아이데이션') + } + /> + (isActive('문서화') ? removeItem('문서화') : addItem('문서화'))} + /> + (isActive('문제해결') ? removeItem('문제해결') : addItem('문제해결'))} + /> + (isActive('리더십') ? removeItem('리더십') : addItem('리더십'))} + /> + (isActive('팔로워십') ? removeItem('팔로워십') : addItem('팔로워십'))} + /> + + isActive('커뮤니케이션') ? removeItem('커뮤니케이션') : addItem('커뮤니케이션') + } + /> + + {error && } + + ); +} + +export default SelectCategoryChipList; diff --git a/src/components/questionnaire/SelectCategoryChipList/style.ts b/src/components/questionnaire/SelectCategoryChipList/style.ts new file mode 100644 index 0000000..0023927 --- /dev/null +++ b/src/components/questionnaire/SelectCategoryChipList/style.ts @@ -0,0 +1,12 @@ +import styled from '@emotion/native'; + +import { flexDirectionColumn } from '@/styles/common'; + +export const Container = styled.View` + ${flexDirectionColumn}; + gap: 12px; +`; +export const Wrapper = styled.View` + flex-flow: row wrap; + gap: 8px; +`; diff --git a/src/components/questionnaire/category/index.tsx b/src/components/questionnaire/category/index.tsx index 555488a..23910ad 100644 --- a/src/components/questionnaire/category/index.tsx +++ b/src/components/questionnaire/category/index.tsx @@ -1,4 +1,5 @@ import { memo } from 'react'; +import type { PressableProps } from 'react-native'; import CategoryIcon from '@/components/common/icon/category-icon'; import Typography from '@/components/common/typography'; @@ -15,20 +16,20 @@ type Props = { hasIcon?: boolean; hasShadow?: boolean; category: CategoryType; -}; +} & PressableProps; function Category({ category, - onboarding = false, hasIcon = true, isActive = false, hasShadow = false, + ...rest }: Props) { return ( + style={hasShadow && shadow[2]} + {...rest}> {hasIcon && ( diff --git a/src/components/questionnaire/category/style.ts b/src/components/questionnaire/category/style.ts index 8e5bd52..e18e1d3 100644 --- a/src/components/questionnaire/category/style.ts +++ b/src/components/questionnaire/category/style.ts @@ -3,19 +3,18 @@ import styled from '@emotion/native'; import { flexDirectionRowItemsCenter } from '@/styles/common'; -export const Container = styled.View<{ - $onboarding: boolean; +export const Container = styled.Pressable<{ $isActive: ReactNativeStyle | boolean; }>` box-sizing: border-box; ${({ $isActive }) => $isActive && $isActive} ${flexDirectionRowItemsCenter}; gap: 6px; + align-self: flex-start; width: fit-content; height: 48px; padding: 12px 16px; - background: ${({ theme, $isActive, $onboarding }) => - $onboarding ? theme.color.Background.Normal : !$isActive && theme.color.Background.Alternative}; + background: ${({ theme, $isActive }) => !$isActive && theme.color.Background.Alternative}; border: ${({ theme, $isActive }) => !$isActive && `1px solid ${theme.color.Background.Alternative}`}; border-radius: 4px; diff --git a/src/components/review/Tag/index.tsx b/src/components/review/Tag/index.tsx index 53c24d6..0f0b695 100644 --- a/src/components/review/Tag/index.tsx +++ b/src/components/review/Tag/index.tsx @@ -1,24 +1,12 @@ import CategoryIcon from '@/components/common/icon/category-icon'; import Typography from '@/components/common/typography'; -import { CategoryStyle } from '@/styles/category'; import { shadow } from '@/styles/shadow'; +import { TagEnum, TagStyle } from '@/styles/tag'; import { color } from '@/styles/theme'; import type { CategoryType } from '@/types/category'; import * as S from './style'; -enum TagEnum { - '걸어다니는 위키' = '기술', - '아이디어 화수분' = '아이데이션', - '글도 잘쓰는 일잘러' = '문서화', - '확신의 J' = '문제해결', - '책임감 넘치는 리더' = '리더십', - '책임감 넘치는 팀원' = '팔로워십', - '갈등을 못참는 박애주의자' = '커뮤니케이션', - '성실함의 아이콘' = '성실성', - '몸에 밴 배려' = '배려심', -} - type Props = { tag: keyof typeof TagEnum; hasIcon?: boolean; @@ -30,7 +18,7 @@ function Tag({ tag, hasIcon = true, isActive = false, hasShadow = false }: Props const category: CategoryType = TagEnum[tag]; return ( {hasIcon && ( diff --git a/src/components/review/Tag/style.ts b/src/components/review/Tag/style.ts index 6f21249..b91f7c4 100644 --- a/src/components/review/Tag/style.ts +++ b/src/components/review/Tag/style.ts @@ -10,6 +10,7 @@ export const Container = styled.View<{ ${({ $isActive }) => $isActive && $isActive} ${flexDirectionRowItemsCenter}; gap: 6px; + align-items: flex-start; width: fit-content; height: 48px; padding: 12px 16px; diff --git a/src/constants/navigations.ts b/src/constants/navigations.ts index 55f8359..29db173 100644 --- a/src/constants/navigations.ts +++ b/src/constants/navigations.ts @@ -1,7 +1,6 @@ export const MAIN_NAVIGATIONS = { HOME: 'index', PROJECT: 'project', - REVIEW: 'review', MY: 'my', } as const; @@ -17,6 +16,7 @@ export const PROJECT_NAVIGATIONS = { HOME: 'index', CREATE: 'create', DELETE: 'delete', + REVIEW: '[id]/review', }; export const REVIEW_NAVIGATIONS = { diff --git a/src/constants/siteUrls.ts b/src/constants/siteUrls.ts index 5b26eae..0ec51ff 100644 --- a/src/constants/siteUrls.ts +++ b/src/constants/siteUrls.ts @@ -3,3 +3,11 @@ export const SITE_URLS = { SIGN_IN: '/sign-in', ON_BOARDING: '/onboarding', } as const; + +export const PROJECT_URLS = { + PROJECT_HOME: '/project', + PROJECT_CREATE: '/project/create', + PROJECT_DETAIL: '/project/[id]/detail', + PROJECT_REVIEW: '/project/[id]/review', + PROJECT_REVIEW_CREATE: '/project/[id]/review/create', +} as const; diff --git a/src/styles/category.ts b/src/styles/category.ts index 1bb7f79..3bf0823 100644 --- a/src/styles/category.ts +++ b/src/styles/category.ts @@ -6,39 +6,39 @@ import type { CategoryType } from '@/types/category'; export const CategoryStyle: Record = { 기술: css` - background: ${color.Blue['95']}; - border: 1px solid ${color.Blue['90']}; + background: ${color.Blue['99']}; + border: 1px solid ${color.Blue['55']}; `, 성실성: css` - background: ${color.Pink['95']}; - border: 1px solid ${color.Pink['90']}; + background: ${color.Pink['99']}; + border: 1px solid ${color.Pink['50']}; `, 아이데이션: css` - background: ${color.Orange['95']}; - border: 1px solid ${color.Orange['90']}; + background: ${color.Orange['99']}; + border: 1px solid ${color.Orange['50']}; `, 문서화: css` - background: ${color.Red['95']}; - border: 1px solid ${color.Red['90']}; + background: ${color.Red['99']}; + border: 1px solid ${color.Red['50']}; `, 커뮤니케이션: css` - background: ${color.LightBlue['95']}; - border: 1px solid ${color.LightBlue['90']}; + background: ${color.LightBlue['99']}; + border: 1px solid ${color.LightBlue['50']}; `, 문제해결: css` - background: ${color.Violet['95']}; - border: 1px solid ${color.Violet['90']}; + background: ${color.Violet['99']}; + border: 1px solid ${color.Violet['50']}; `, 리더십: css` - background: #e6fad9; - border: 1px solid #c3f0a3; + background: #f7fff1; + border: 1px solid #93ec67; `, 배려심: css` - background: ${color.Purple['95']}; - border: 1px solid ${color.Purple['90']}; + background: ${color.Purple['99']}; + border: 1px solid ${color.Purple['50']}; `, 팔로워십: css` - background: #d9fade; - border: 1px solid #a3f0b8; + background: #edfff0; + border: 1px solid #4af377; `, } as const; diff --git a/src/styles/tag.ts b/src/styles/tag.ts new file mode 100644 index 0000000..e9abd5f --- /dev/null +++ b/src/styles/tag.ts @@ -0,0 +1,56 @@ +import type { ReactNativeStyle } from '@emotion/native'; +import { css } from '@emotion/native'; + +import { color } from '@/styles/theme'; +import type { CategoryType } from '@/types/category'; + +export enum TagEnum { + '걸어다니는 위키' = '기술', + '아이디어 화수분' = '아이데이션', + '글도 잘쓰는 일잘러' = '문서화', + '확신의 J' = '문제해결', + '책임감 넘치는 리더' = '리더십', + '책임감 넘치는 팀원' = '팔로워십', + '갈등을 못참는 박애주의자' = '커뮤니케이션', + '성실함의 아이콘' = '성실성', + '몸에 밴 배려' = '배려심', +} + +export const TagStyle: Record = { + 기술: css` + background: ${color.Blue['95']}; + border: 1px solid ${color.Blue['90']}; + `, + 성실성: css` + background: ${color.Pink['95']}; + border: 1px solid ${color.Pink['90']}; + `, + 아이데이션: css` + background: ${color.Orange['95']}; + border: 1px solid ${color.Orange['90']}; + `, + 문서화: css` + background: ${color.Red['95']}; + border: 1px solid ${color.Red['90']}; + `, + 커뮤니케이션: css` + background: ${color.LightBlue['95']}; + border: 1px solid ${color.LightBlue['90']}; + `, + 문제해결: css` + background: ${color.Violet['95']}; + border: 1px solid ${color.Violet['90']}; + `, + 리더십: css` + background: #e6fad9; + border: 1px solid #c3f0a3; + `, + 배려심: css` + background: ${color.Purple['95']}; + border: 1px solid ${color.Purple['90']}; + `, + 팔로워십: css` + background: #d9fade; + border: 1px solid #a3f0b8; + `, +} as const;