diff --git a/apps/bottle/src/app/profile/create/CreateProfileProvider.tsx b/apps/bottle/src/app/profile/create/CreateProfileProvider.tsx new file mode 100644 index 0000000..1004133 --- /dev/null +++ b/apps/bottle/src/app/profile/create/CreateProfileProvider.tsx @@ -0,0 +1,10 @@ +'use client'; + +import { createFunnelValuesContext } from '@/features/funnel-values/createFunnelValuesContext'; +import { Profile } from '@/models/profile'; + +export interface CreateProfileValues extends Profile { + kakaoId: string; +} + +export const [CreateProfileProvider, useCreateProfileValues] = createFunnelValuesContext(); diff --git a/apps/bottle/src/app/profile/create/SignupProvider.tsx b/apps/bottle/src/app/profile/create/SignupProvider.tsx new file mode 100644 index 0000000..5a93269 --- /dev/null +++ b/apps/bottle/src/app/profile/create/SignupProvider.tsx @@ -0,0 +1,13 @@ +'use client'; + +import { createFunnelValuesContext } from '@/features/funnel-values/createFunnelValuesContext'; + +export interface SignupProfileValues { + birthDay: number; + birthMonth: number; + birthYear: number; + gender: 'MALE' | 'FEMALE'; + name: string; +} + +export const [SignupProfileProvider, useSignupProfileValues] = createFunnelValuesContext(); diff --git a/apps/bottle/src/app/profile/create/_steps/MBTI/MBTI.spec.tsx b/apps/bottle/src/app/profile/create/_steps/MBTI/MBTI.spec.tsx new file mode 100644 index 0000000..9e67942 --- /dev/null +++ b/apps/bottle/src/app/profile/create/_steps/MBTI/MBTI.spec.tsx @@ -0,0 +1,40 @@ +import { StepProvider } from '@/features/steps/StepProvider'; +import { userInfoQueryOptions } from '@/store/query/useUserInfoQuery'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render } from '@testing-library/react'; +import { CreateProfileProvider } from '../../CreateProfileProvider'; +import { MBTI } from '.'; + +vi.mock('next/navigation', () => ({ + useSearchParams: () => ({ get: vi.fn() }), + useRouter: () => ({ push: vi.fn() }), +})); + +it('sets previous selected MBTI to initial state for each mbti selections', async () => { + const testClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: Infinity, + gcTime: Infinity, + }, + }, + }); + testClient.setQueryData(userInfoQueryOptions({ accessToken: '', refreshToken: '' }).queryKey, { name: 'taehwan' }); + + const MBTIRender = () => ( + + + + + + + + ); + + const screen = render(); + const EButton = screen.getByText('E'); + const nextButton = screen.getByText('다음'); + + expect(EButton).toHaveAttribute('aria-selected', 'true'); + expect(nextButton).not.toBeDisabled(); +}); diff --git a/apps/bottle/src/app/profile/create/_steps/MBTI/MBTIStyle.css.ts b/apps/bottle/src/app/profile/create/_steps/MBTI/MBTIStyle.css.ts new file mode 100644 index 0000000..760d777 --- /dev/null +++ b/apps/bottle/src/app/profile/create/_steps/MBTI/MBTIStyle.css.ts @@ -0,0 +1,26 @@ +import { spacings } from '@bottlesteam/ui'; +import { style } from '@vanilla-extract/css'; + +export const bodyStyle = style({ + marginTop: spacings.xxl, + display: 'flex', + flexDirection: 'column', + gap: spacings.xl, + paddingBottom: spacings.xxl, +}); + +export const controlStyle = style({ + display: 'flex', + flexDirection: 'column', + gap: spacings.sm, +}); + +export const buttonsContainerStyle = style({ + display: 'flex', + width: '100%', + gap: spacings.sm, +}); + +export const spacingStyle = style({ + height: spacings.sm, +}); diff --git a/apps/bottle/src/app/profile/create/_steps/MBTI/index.tsx b/apps/bottle/src/app/profile/create/_steps/MBTI/index.tsx new file mode 100644 index 0000000..99ece69 --- /dev/null +++ b/apps/bottle/src/app/profile/create/_steps/MBTI/index.tsx @@ -0,0 +1,106 @@ +import { Control, toggle } from '@/components/control'; +import { Step } from '@/features/steps/StepContainer'; +import { useStep } from '@/features/steps/StepProvider'; +import { EIType, JPType, SNType, TFType } from '@/models/profile/MBTI'; +import { useUserInfoQuery } from '@/store/query/useUserInfoQuery'; +import { Button, ButtonProps } from '@bottlesteam/ui'; +import { useMemo, useState } from 'react'; +import { useCreateProfileValues } from '../../CreateProfileProvider'; +import { bodyStyle, buttonsContainerStyle, controlStyle } from './MBTIStyle.css'; + +export function MBTI() { + const { onNextStep } = useStep(); + const { setValue, getValue } = useCreateProfileValues(); + const { + data: { name }, + } = useUserInfoQuery(); + + const selected = getValue('mbti'); + + const [EI, setEI] = useState(() => (selected != null ? (selected[0] as EIType) : undefined)); + const [SN, setSN] = useState(selected != null ? (selected[1] as SNType) : undefined); + const [TF, setTF] = useState(selected != null ? (selected[2] as TFType) : undefined); + const [JP, setJP] = useState(selected != null ? (selected[3] as JPType) : undefined); + + const isDisabled = useMemo(() => EI == null || SN == null || TF == null || JP == null, [EI, JP, TF, SN]); + + return ( + <> + {name}님의 성격에 대해 알고 싶어요 +
+
+ 외향형 · 내향형 +
+ + setEI(prev => toggle(prev, 'E'))}> + E + + setEI(prev => toggle(prev, 'I'))}> + I + + +
+
+
+ 감각형 · 직관형 +
+ + setSN(prev => toggle(prev, 'S'))}> + S + + setSN(prev => toggle(prev, 'N'))}> + N + + +
+
+
+ 사고형 · 감정형 +
+ + setTF(prev => toggle(prev, 'T'))}> + T + + setTF(prev => toggle(prev, 'F'))}> + F + + +
+
+
+ 판단형 · 인식형 +
+ + setJP(prev => toggle(prev, 'J'))}> + J + + setJP(prev => toggle(prev, 'P'))}> + P + + +
+
+
+ { + if (EI == null || SN == null || TF == null || JP == null) { + return; + } + setValue('mbti', `${EI}${SN}${TF}${JP}`); + onNextStep(); + }} + > + 다음 + + + ); +} + +function ItemButton(props: Omit) { + return ( + + ); +} diff --git a/apps/bottle/src/app/profile/create/_steps/alcohol/alcoholStyle.css.ts b/apps/bottle/src/app/profile/create/_steps/alcohol/alcoholStyle.css.ts new file mode 100644 index 0000000..dfab814 --- /dev/null +++ b/apps/bottle/src/app/profile/create/_steps/alcohol/alcoholStyle.css.ts @@ -0,0 +1,9 @@ +import { spacings } from '@bottlesteam/ui'; +import { style } from '@vanilla-extract/css'; + +export const alcoholStyle = style({ + marginTop: spacings.xxl, + display: 'flex', + flexDirection: 'column', + gap: spacings.sm, +}); diff --git a/apps/bottle/src/app/profile/create/_steps/alcohol/index.tsx b/apps/bottle/src/app/profile/create/_steps/alcohol/index.tsx new file mode 100644 index 0000000..8eff527 --- /dev/null +++ b/apps/bottle/src/app/profile/create/_steps/alcohol/index.tsx @@ -0,0 +1,44 @@ +import { Control, toggle } from '@/components/control'; +import { Step } from '@/features/steps/StepContainer'; +import { useStep } from '@/features/steps/StepProvider'; +import { Alcohol as AlcoholType, alcoholList } from '@/models/profile/alcohol'; +import { Button } from '@bottlesteam/ui'; +import { useState } from 'react'; +import { useCreateProfileValues } from '../../CreateProfileProvider'; +import { alcoholStyle } from './alcoholStyle.css'; + +export function Alcohol() { + const { setValue, getValue } = useCreateProfileValues(); + const { onNextStep } = useStep(); + + const [alcohol, setAlcohol] = useState(getValue('alcohol')); + + return ( + <> + 술은 얼마나 즐기나요? + +
+ {alcoholList.map((item, index) => ( + setAlcohol(prev => toggle(prev, item))}> + + + ))} +
+
+ { + if (alcohol === undefined) { + throw new Error(); + } + setValue('alcohol', alcohol); + onNextStep(); + }} + > + 다음 + + + ); +} diff --git a/apps/bottle/src/app/profile/create/_steps/height/heightStyle.css.ts b/apps/bottle/src/app/profile/create/_steps/height/heightStyle.css.ts new file mode 100644 index 0000000..c8faa2b --- /dev/null +++ b/apps/bottle/src/app/profile/create/_steps/height/heightStyle.css.ts @@ -0,0 +1,7 @@ +import { spacings } from '@bottlesteam/ui'; +import { style } from '@vanilla-extract/css'; + +export const wheelPickerContainerStyle = style({ + width: '100%', + marginTop: spacings.xxl, +}); diff --git a/apps/bottle/src/app/profile/create/_steps/height/index.tsx b/apps/bottle/src/app/profile/create/_steps/height/index.tsx new file mode 100644 index 0000000..7da0281 --- /dev/null +++ b/apps/bottle/src/app/profile/create/_steps/height/index.tsx @@ -0,0 +1,51 @@ +'use client'; + +import { Step } from '@/features/steps/StepContainer'; +import { useStep } from '@/features/steps/StepProvider'; +import { WheelPicker, colors } from '@bottlesteam/ui'; +import { useState } from 'react'; +import { useCreateProfileValues } from '../../CreateProfileProvider'; +import { wheelPickerContainerStyle } from './heightStyle.css'; + +const OFFSET = 140; +const DEFAULT_ID = '168'; + +const heightData = Array.from({ length: 61 }, (_, index) => ({ + id: `${index + OFFSET}`, + value: `${index + OFFSET}cm`, +})); + +export function Height() { + const { setValue, getValue } = useCreateProfileValues(); + const { onNextStep } = useStep(); + + const [height, setHeight] = useState(getValue('height') ?? Number(DEFAULT_ID)); + + return ( + <> + 키는 어떻게 되나요? +
+ setHeight(Number(id))} + selectedID={String(height)} + fontSize={14} + height={250} + width={'100%'} + itemHeight={56} + shadowColor="none" + activeColor={colors.neutral900} + color={colors.neutral600} + /> +
+ { + setValue('height', height); + onNextStep(); + }} + > + 다음 + + + ); +} diff --git a/apps/bottle/src/app/profile/create/_steps/information/index.tsx b/apps/bottle/src/app/profile/create/_steps/information/index.tsx new file mode 100644 index 0000000..4efeb6a --- /dev/null +++ b/apps/bottle/src/app/profile/create/_steps/information/index.tsx @@ -0,0 +1,112 @@ +import { Control } from '@/components/control'; +import { Step } from '@/features/steps/StepContainer'; +import { useStep } from '@/features/steps/StepProvider'; +import { useSignupProfileMutation } from '@/store/mutation/useSignupProfileMutation'; +import { Button, TextField } from '@bottlesteam/ui'; +import { useState } from 'react'; +import { useSignupProfileValues, SignupProfileValues } from '../../SignupProvider'; +import { birthDateWrapper, buttonsWrapper, containerStyle, fieldStyle } from './informationStyle.css'; + +export function Information() { + const { onNextStep } = useStep(); + const { setValues, getValues } = useSignupProfileValues(); + const { mutateAsync } = useSignupProfileMutation(); + + const [name, setName] = useState(''); + const [year, setYear] = useState(''); + const [month, setMonth] = useState(''); + const [day, setDay] = useState(''); + const [gender, setGender] = useState(); + + // TODO: validate year, month, day + const isDisabled = + name.length === 0 || + year.length !== 4 || + Number(year) > 2003 || + month.length < 1 || + month.length > 2 || + day.length < 1 || + day.length > 2 || + gender === undefined; + + return ( + <> + {'프로필 생성을 위해\n개인 정보를 입력해 주세요'} +
+
+ + + + setName(e.currentTarget.value)} /> +
+
+ + + +
+ setYear(e.currentTarget.value)} + /> + setMonth(e.currentTarget.value)} + /> + setDay(e.currentTarget.value)} + /> +
+
+
+ + + +
+ + setGender('MALE')}> + + + setGender('FEMALE')}> + + + +
+
+
+ { + if (isDisabled) { + return; + } + setValues([ + { key: 'name', value: name }, + { key: 'birthYear', value: Number(year) }, + { key: 'birthMonth', value: Number(month) }, + { key: 'birthDay', value: Number(day) }, + { key: 'gender', value: gender }, + ]); + const signupProfileValues = getValues() as SignupProfileValues; + await mutateAsync(signupProfileValues); + onNextStep(); + }} + > + 다음 + + + ); +} diff --git a/apps/bottle/src/app/profile/create/_steps/information/informationStyle.css.ts b/apps/bottle/src/app/profile/create/_steps/information/informationStyle.css.ts new file mode 100644 index 0000000..5119bbf --- /dev/null +++ b/apps/bottle/src/app/profile/create/_steps/information/informationStyle.css.ts @@ -0,0 +1,25 @@ +import { spacings } from '@bottlesteam/ui'; +import { style } from '@vanilla-extract/css'; + +export const containerStyle = style({ + marginTop: spacings.xxl, + display: 'flex', + flexDirection: 'column', + gap: spacings.xl, +}); + +export const fieldStyle = style({ + display: 'flex', + flexDirection: 'column', + gap: spacings.sm, +}); + +export const birthDateWrapper = style({ + display: 'flex', + gap: spacings.xxs, +}); + +export const buttonsWrapper = style({ + display: 'flex', + gap: spacings.sm, +}); diff --git a/apps/bottle/src/app/profile/create/_steps/interests/index.tsx b/apps/bottle/src/app/profile/create/_steps/interests/index.tsx new file mode 100644 index 0000000..d173418 --- /dev/null +++ b/apps/bottle/src/app/profile/create/_steps/interests/index.tsx @@ -0,0 +1,109 @@ +import { Control } from '@/components/control'; +import { Step } from '@/features/steps/StepContainer'; +import { useStep } from '@/features/steps/StepProvider'; +import { Culture, ETC, Entertainment, Sports, culture, entertainment, etc, sports } from '@/models/profile/interests'; +import { Button, ButtonProps, spacings } from '@bottlesteam/ui'; +import { useState } from 'react'; +import { CreateProfileValues, useCreateProfileValues } from '../../CreateProfileProvider'; +import { interestsStyle } from './interestsStyle.css'; + +export type Interest = Culture | Sports | Entertainment | ETC; + +const MIN_SELECTD = 3; +const MAX_SELECTED = 10; + +function processSelected(selected: CreateProfileValues['interest']) { + /** + * NOTE: Prototype Object.values() causes incorrect type inference + * used spread operator instead + */ + return [...selected.culture, ...selected.sports, ...selected.entertainment, ...selected.etc] as Interest[]; +} + +export function Interests() { + const { onNextStep } = useStep(); + const { setValue, getValue } = useCreateProfileValues(); + + const selected = getValue('interest'); + + const [interests, setInterests] = useState(selected != null ? processSelected(selected) : []); + + const filterPredicate = (item: Interest) => interests.includes(item); + + const handleClick = (item: Interest) => { + if (interests.length >= MAX_SELECTED && !interests.includes(item)) { + // TODO: replace alert to Native.onOpenToast() + alert('최대 10개까지 선택할 수 있어요'); + return; + } + setInterests(prev => { + if (prev.includes(item)) { + return prev.filter(prevItem => item !== prevItem); + } + return [...prev, item]; + }); + }; + + return ( + <> + 푹 빠진 취미는 무엇인가요? + 최소 3개, 최대 10개까지 선택할 수 있어요 + 문화 예술 + +
+ {culture.map((item, index) => ( + handleClick(item)}> + {item} + + ))} +
+ 스포츠 +
+ {sports.map((item, index) => ( + handleClick(item)}> + {item} + + ))} +
+ 오락 +
+ {entertainment.map((item, index) => ( + handleClick(item)}> + {item} + + ))} +
+ 기타 +
+ {etc.map((item, index) => ( + handleClick(item)}> + {item} + + ))} +
+
+ { + setValue('interest', { + culture: culture.filter(filterPredicate), + sports: sports.filter(filterPredicate), + entertainment: entertainment.filter(filterPredicate), + etc: etc.filter(filterPredicate), + }); + onNextStep(); + }} + > + {`다음 ${interests.length} / ${MAX_SELECTED}`} + + + ); +} + +function ItemButton(props: Omit) { + return ( + + ); +} diff --git a/apps/bottle/src/app/profile/create/_steps/interests/interestsStyle.css.ts b/apps/bottle/src/app/profile/create/_steps/interests/interestsStyle.css.ts new file mode 100644 index 0000000..6d2a2a8 --- /dev/null +++ b/apps/bottle/src/app/profile/create/_steps/interests/interestsStyle.css.ts @@ -0,0 +1,11 @@ +import { spacings } from '@bottlesteam/ui'; +import { style } from '@vanilla-extract/css'; + +export const interestsStyle = style({ + width: '100%', + display: 'flex', + flexWrap: 'wrap', + rowGap: spacings.sm, + columnGap: spacings.xs, + marginTop: spacings.sm, +}); diff --git a/apps/bottle/src/app/profile/create/_steps/job/index.tsx b/apps/bottle/src/app/profile/create/_steps/job/index.tsx new file mode 100644 index 0000000..540e01f --- /dev/null +++ b/apps/bottle/src/app/profile/create/_steps/job/index.tsx @@ -0,0 +1,43 @@ +import { Control, toggle } from '@/components/control'; +import { Step } from '@/features/steps/StepContainer'; +import { useStep } from '@/features/steps/StepProvider'; +import { Job as JobType, jobList } from '@/models/profile/job'; +import { Button } from '@bottlesteam/ui'; +import { useState } from 'react'; +import { useCreateProfileValues } from '../../CreateProfileProvider'; +import { jobStyle } from './jobStyle.css'; + +export function Job() { + const { setValue, getValue } = useCreateProfileValues(); + const { onNextStep } = useStep(); + + const [job, setJob] = useState(getValue('job')); + return ( + <> + 지금 어떤 일을 하고 있나요? +
+ + {jobList.map((item, index) => ( + setJob(prev => toggle(prev, item))}> + + + ))} + +
+ { + if (job === undefined) { + return; + } + setValue('job', job); + onNextStep(); + }} + > + 다음 + + + ); +} diff --git a/apps/bottle/src/app/profile/create/_steps/job/jobStyle.css.ts b/apps/bottle/src/app/profile/create/_steps/job/jobStyle.css.ts new file mode 100644 index 0000000..1e77626 --- /dev/null +++ b/apps/bottle/src/app/profile/create/_steps/job/jobStyle.css.ts @@ -0,0 +1,9 @@ +import { spacings } from '@bottlesteam/ui'; +import { style } from '@vanilla-extract/css'; + +export const jobStyle = style({ + marginTop: spacings.xxl, + display: 'grid', + gridTemplateColumns: '1fr 1fr', + gap: spacings.sm, +}); diff --git a/apps/bottle/src/app/profile/create/_steps/kakao-id/index.tsx b/apps/bottle/src/app/profile/create/_steps/kakao-id/index.tsx new file mode 100644 index 0000000..d762333 --- /dev/null +++ b/apps/bottle/src/app/profile/create/_steps/kakao-id/index.tsx @@ -0,0 +1,48 @@ +import { AppBridgeMessageType, useAppBridge } from '@/features/app-bridge'; +import { POST, createInit } from '@/features/server'; +import { getClientSideTokens } from '@/features/server/clientSideTokens'; +import { Step } from '@/features/steps/StepContainer'; +import { TextField, spacings } from '@bottlesteam/ui'; +import { useState } from 'react'; +import { useCreateProfileValues } from '../../CreateProfileProvider'; + +const KAKAO_ID_REGEX = /^[A-Za-z\d._-]{4,20}$/; +const ERROR_CAPTION = '카카오톡 아이디를 확인해주세요'; + +export function KaKaoId() { + const { send } = useAppBridge(); + const { setValue, getValue, getValues } = useCreateProfileValues(); + + const [kakaoId, setKakaoId] = useState(getValue('kakaoId') ?? ''); + const isError = kakaoId.trim().length > 0 && !KAKAO_ID_REGEX.test(kakaoId.trim()); + const disabled = kakaoId.trim().length === 0 || isError; + + return ( + <> + {'연락처 공유를 위해\n카카오톡 아이디를 입력해 주세요'} + 오타가 없는지 한 번 더 확인해 주세요 + setKakaoId(e.currentTarget.value)} + style={{ marginTop: spacings.xxl }} + /> + {isError && ERROR_CAPTION} + { + setValue('kakaoId', kakaoId); + + await POST( + `/api/v1/profile/choice`, + getClientSideTokens(), + createInit(getClientSideTokens().accessToken, { ...getValues() }) + ); + send({ type: AppBridgeMessageType.CREATE_PROFILE_COMPLETE }); + }} + > + 완료 + + + ); +} diff --git a/apps/bottle/src/app/profile/create/_steps/keywords/index.tsx b/apps/bottle/src/app/profile/create/_steps/keywords/index.tsx new file mode 100644 index 0000000..76846aa --- /dev/null +++ b/apps/bottle/src/app/profile/create/_steps/keywords/index.tsx @@ -0,0 +1,63 @@ +import { Control } from '@/components/control'; +import { Step } from '@/features/steps/StepContainer'; +import { useStep } from '@/features/steps/StepProvider'; +import { Keyword, keywordList } from '@/models/profile/keywords'; +import { Button } from '@bottlesteam/ui'; +import { useState } from 'react'; +import { useCreateProfileValues } from '../../CreateProfileProvider'; +import { keywordsStyle } from './keywordsStyle.css'; + +const MIN_SELECTED = 3; +const MAX_SELECTED = 5; + +export function Keywords() { + const { onNextStep } = useStep(); + const { setValue, getValue } = useCreateProfileValues(); + + const [keywords, setKeywords] = useState(getValue('keyword') ?? []); + + return ( + <> + 나를 표현하는 키워드는? + 최소 3개, 최대 5개까지 선택할 수 있어요 + +
+ {keywordList.map((item, index) => ( + { + if (keywords.length >= MAX_SELECTED && !keywords.includes(item)) { + alert('최대 5개까지 선택할 수 있어요'); + return; + } + setKeywords(prev => { + if (prev.includes(item)) { + return prev.filter(prevItem => item !== prevItem); + } + return [...prev, item]; + }); + }} + > + + + ))} +
+
+ { + if (keywords.length === 0) { + return; + } + setValue('keyword', keywords); + onNextStep(); + }} + > + {`다음 ${keywords.length} / ${MAX_SELECTED}`} + + + ); +} diff --git a/apps/bottle/src/app/profile/create/_steps/keywords/keywordsStyle.css.ts b/apps/bottle/src/app/profile/create/_steps/keywords/keywordsStyle.css.ts new file mode 100644 index 0000000..4856fab --- /dev/null +++ b/apps/bottle/src/app/profile/create/_steps/keywords/keywordsStyle.css.ts @@ -0,0 +1,11 @@ +import { spacings } from '@bottlesteam/ui'; +import { style } from '@vanilla-extract/css'; + +export const keywordsStyle = style({ + width: '100%', + display: 'flex', + flexWrap: 'wrap', + rowGap: spacings.sm, + columnGap: spacings.xs, + marginTop: spacings.xxl, +}); diff --git a/apps/bottle/src/app/profile/create/_steps/region/bottom-sheet/RegionBottomSheet.tsx b/apps/bottle/src/app/profile/create/_steps/region/bottom-sheet/RegionBottomSheet.tsx new file mode 100644 index 0000000..32cff91 --- /dev/null +++ b/apps/bottle/src/app/profile/create/_steps/region/bottom-sheet/RegionBottomSheet.tsx @@ -0,0 +1,77 @@ +import { Asset, Paragraph, colors, BottomSheet, BottomSheetProps, Chip } from '@bottlesteam/ui'; +import { useEffect, useState } from 'react'; +import { itemStyle, listStyle, tabBarStyle, tabItemsStyle } from './regionBottomSheetStyle.css'; + +interface Props extends Omit { + items: string[]; + selected: string | undefined; + onSelect(item?: string): void; + type: 'city' | 'state'; +} + +export function RegionBottomSheet({ onSelect, selected, items, type, ...bottomSheetProps }: Props) { + const [localSelected, setLocalSelected] = useState(selected); + + useEffect(() => { + if (bottomSheetProps.isOpen) { + window.document.body.style.overflow = 'hidden'; + } else { + document.body.style.removeProperty('overflow'); + } + }, [bottomSheetProps.isOpen]); + + return ( + + +
    + {items.map(item => ( +
  • { + setLocalSelected(item); + }} + > + + {item} + +
  • + ))} +
+ + } + button={ + { + onSelect(localSelected); + bottomSheetProps.onClose(); + }} + > + 완료 + + } + /> + ); +} + +function TabBar({ type, onClose }: { type: 'city' | 'state'; onClose: BottomSheetProps['onClose'] }) { + return ( +
+
+ 전체 지역 + + 상세 지역 +
+
+ +
+
+ ); +} diff --git a/apps/bottle/src/app/profile/create/_steps/region/bottom-sheet/regionBottomSheetStyle.css.ts b/apps/bottle/src/app/profile/create/_steps/region/bottom-sheet/regionBottomSheetStyle.css.ts new file mode 100644 index 0000000..c79c584 --- /dev/null +++ b/apps/bottle/src/app/profile/create/_steps/region/bottom-sheet/regionBottomSheetStyle.css.ts @@ -0,0 +1,34 @@ +import { spacings } from '@bottlesteam/ui'; +import { style } from '@vanilla-extract/css'; + +export const tabBarStyle = style({ + width: '100%', + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + + marginBottom: spacings.xl, +}); + +export const tabItemsStyle = style({ + display: 'flex', + gap: spacings.xxs, + alignItems: 'center', +}); + +export const listStyle = style({ + width: '100%', + height: '208px', + overflowY: 'auto', + display: 'flex', + flexDirection: 'column', + gap: spacings.sm, +}); + +export const itemStyle = style({ + width: '100%', + display: 'flex', + alignItems: 'center', +}); + +export const closeIconStyle = style({}); diff --git a/apps/bottle/src/app/profile/create/_steps/region/index.tsx b/apps/bottle/src/app/profile/create/_steps/region/index.tsx new file mode 100644 index 0000000..3a839e2 --- /dev/null +++ b/apps/bottle/src/app/profile/create/_steps/region/index.tsx @@ -0,0 +1,121 @@ +import { AppBridgeMessageType, useAppBridge } from '@/features/app-bridge'; +import { Step } from '@/features/steps/StepContainer'; +import { useStep } from '@/features/steps/StepProvider'; +import { useRegionsQuery } from '@/store/query/useRegionsQuery'; +import { spacings } from '@bottlesteam/ui'; +import { OverlayProvider, overlay } from 'overlay-kit'; +import { useState } from 'react'; +import { useCreateProfileValues } from '../../CreateProfileProvider'; +import { spacingStyle } from '../MBTI/MBTIStyle.css'; +import { RegionBottomSheet } from './bottom-sheet/RegionBottomSheet'; +import { regionStyle } from './regionStyle.css'; +import { SelectInput } from './select-input/SelectInput'; + +export interface RegionData { + city: string; + state: string[]; +} + +export interface Regions { + regions: RegionData[]; +} + +export function Region() { + const { send } = useAppBridge(); + const { onNextStep } = useStep(); + const { setValue, getValue } = useCreateProfileValues(); + + const { data: regionsData } = useRegionsQuery(); + + const selected = getValue('region'); + const [city, setCity] = useState(selected != null ? selected.city : undefined); + const [state, setState] = useState(selected != null ? selected.state : undefined); + + return ( + <> + + 주로 생활하는 지역은 어딘가요? + 전체 지역 +
+ { + if (regionsData == null) { + return; + } + const selectedCity = await openRegionBottomSheet( + 'city', + regionsData?.regions.map(({ city }) => city), + city + ); + if (selectedCity !== city) { + setCity(selectedCity); + setState(undefined); + } + + if (selectedCity != null) { + const selectedState = await openRegionBottomSheet( + 'state', + (regionsData?.regions.find(region => region.city === selectedCity) as RegionData).state, + state + ); + setState(selectedState); + } + }} + placeholder={'전체 지역을 선택해 주세요'} + value={city} + /> + 시 · 군 · 구 +
+ { + if (city === undefined) { + send({ type: AppBridgeMessageType.TOAST_OPEN, payload: { message: '전체 지역을 먼저 선택해주세요.' } }); + return; + } + const selectedState = await openRegionBottomSheet( + 'state', + (regionsData?.regions.find(region => region.city === city) as RegionData).state, + state + ); + setState(selectedState); + }} + placeholder={'상세 지역을 선택해 주세요'} + value={state} + /> +
+ + { + if (city === undefined || state === undefined) { + return; + } + setValue('region', { city, state }); + onNextStep(); + }} + > + 다음 + + + ); +} + +const openRegionBottomSheet = async ( + type: 'city' | 'state', + items: string[], + selected: string | undefined +): Promise => + await overlay.openAsync(({ isOpen, close, unmount }) => { + return ( + <> + + + ); + }); diff --git a/apps/bottle/src/app/profile/create/_steps/region/regionStyle.css.ts b/apps/bottle/src/app/profile/create/_steps/region/regionStyle.css.ts new file mode 100644 index 0000000..12ecf2e --- /dev/null +++ b/apps/bottle/src/app/profile/create/_steps/region/regionStyle.css.ts @@ -0,0 +1,9 @@ +import { spacings } from '@bottlesteam/ui'; +import { style } from '@vanilla-extract/css'; + +export const regionStyle = style({ + marginTop: spacings.xxl, + display: 'grid', + gridTemplateColumns: '1fr 1fr', + gap: spacings.sm, +}); diff --git a/apps/bottle/src/app/profile/create/_steps/region/select-input/SelectInput.tsx b/apps/bottle/src/app/profile/create/_steps/region/select-input/SelectInput.tsx new file mode 100644 index 0000000..b00b3f2 --- /dev/null +++ b/apps/bottle/src/app/profile/create/_steps/region/select-input/SelectInput.tsx @@ -0,0 +1,27 @@ +import { Asset, Paragraph } from '@bottlesteam/ui'; +import { ComponentProps } from 'react'; +import { selectInputStyle, inputStyle } from './selectInputStyle.css'; + +interface SelectProps extends ComponentProps<'div'> { + placeholder?: string; + value?: string; +} + +export const SelectInput = ({ style, value, placeholder, onClick, ...rest }: SelectProps) => { + return ( +
+
+ {value && value.length > 0 ? ( + + {value} + + ) : ( + + {placeholder} + + )} +
+ +
+ ); +}; diff --git a/apps/bottle/src/app/profile/create/_steps/region/select-input/selectInputStyle.css.ts b/apps/bottle/src/app/profile/create/_steps/region/select-input/selectInputStyle.css.ts new file mode 100644 index 0000000..9f612a2 --- /dev/null +++ b/apps/bottle/src/app/profile/create/_steps/region/select-input/selectInputStyle.css.ts @@ -0,0 +1,21 @@ +import { colors, radius, spacings, typography } from '@bottlesteam/ui'; +import { style } from '@vanilla-extract/css'; + +export const selectInputStyle = style({ + width: '100%', + height: '56px', + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + padding: spacings.md, + backgroundColor: colors.white100, + borderRadius: radius.sm, + border: `1px solid ${colors.neutral300}`, +}); + +export const inputStyle = style({ + outline: 'none', + border: 'none', + color: colors.neutral900, + ...typography.bo, +}); diff --git a/apps/bottle/src/app/profile/create/_steps/religion/index.tsx b/apps/bottle/src/app/profile/create/_steps/religion/index.tsx new file mode 100644 index 0000000..75db059 --- /dev/null +++ b/apps/bottle/src/app/profile/create/_steps/religion/index.tsx @@ -0,0 +1,44 @@ +import { Control, toggle } from '@/components/control'; +import { Step } from '@/features/steps/StepContainer'; +import { useStep } from '@/features/steps/StepProvider'; +import { Religion as ReligionType, religionList } from '@/models/profile/religion'; +import { Button } from '@bottlesteam/ui'; +import { useState } from 'react'; +import { useCreateProfileValues } from '../../CreateProfileProvider'; +import { religionStyle } from './religionStyle.css'; + +export function Religion() { + const { setValue, getValue } = useCreateProfileValues(); + const { onNextStep } = useStep(); + + const [religion, setReligion] = useState(getValue('religion')); + + return ( + <> + 어떤 종교를 가지고 있나요? +
+ + {religionList.map((item, index) => ( + setReligion(prev => toggle(prev, item))}> + + + ))} + +
+ { + if (religion === undefined) { + return; + } + setValue('religion', religion); + onNextStep(); + }} + > + 다음 + + + ); +} diff --git a/apps/bottle/src/app/profile/create/_steps/religion/religionStyle.css.ts b/apps/bottle/src/app/profile/create/_steps/religion/religionStyle.css.ts new file mode 100644 index 0000000..ccff956 --- /dev/null +++ b/apps/bottle/src/app/profile/create/_steps/religion/religionStyle.css.ts @@ -0,0 +1,9 @@ +import { spacings } from '@bottlesteam/ui'; +import { style } from '@vanilla-extract/css'; + +export const religionStyle = style({ + marginTop: spacings.xxl, + display: 'grid', + gridTemplateColumns: '1fr 1fr', + gap: spacings.sm, +}); diff --git a/apps/bottle/src/app/profile/create/_steps/smoking/index.tsx b/apps/bottle/src/app/profile/create/_steps/smoking/index.tsx new file mode 100644 index 0000000..6de6ad2 --- /dev/null +++ b/apps/bottle/src/app/profile/create/_steps/smoking/index.tsx @@ -0,0 +1,44 @@ +import { Control, toggle } from '@/components/control'; +import { Step } from '@/features/steps/StepContainer'; +import { useStep } from '@/features/steps/StepProvider'; +import { Smoking as SmokingType, smokingList } from '@/models/profile/smoking'; +import { Button } from '@bottlesteam/ui'; +import { useState } from 'react'; +import { useCreateProfileValues } from '../../CreateProfileProvider'; +import { smokingStyle } from './smokingStyle.css'; + +export function Smoking() { + const { setValue, getValue } = useCreateProfileValues(); + const { onNextStep } = useStep(); + + const [smoking, setSmoking] = useState(getValue('smoking')); + + return ( + <> + 흡연 스타일이 궁금해요 + +
+ {smokingList.map((item, index) => ( + setSmoking(prev => toggle(prev, item))}> + + + ))} +
+
+ { + if (smoking === undefined) { + throw new Error(); + } + setValue('smoking', smoking); + onNextStep(); + }} + > + 다음 + + + ); +} diff --git a/apps/bottle/src/app/profile/create/_steps/smoking/smokingStyle.css.ts b/apps/bottle/src/app/profile/create/_steps/smoking/smokingStyle.css.ts new file mode 100644 index 0000000..04e95b7 --- /dev/null +++ b/apps/bottle/src/app/profile/create/_steps/smoking/smokingStyle.css.ts @@ -0,0 +1,9 @@ +import { spacings } from '@bottlesteam/ui'; +import { style } from '@vanilla-extract/css'; + +export const smokingStyle = style({ + marginTop: spacings.xxl, + display: 'flex', + flexDirection: 'column', + gap: spacings.sm, +}); diff --git a/apps/bottle/src/app/profile/create/layout.tsx b/apps/bottle/src/app/profile/create/layout.tsx new file mode 100644 index 0000000..e3082bd --- /dev/null +++ b/apps/bottle/src/app/profile/create/layout.tsx @@ -0,0 +1,22 @@ +import { getServerSideTokens } from '@/features/server/serverSideTokens'; +import { StepProvider } from '@/features/steps/StepProvider'; +import { ServerFetchBoundary } from '@/store/query/ServerFetchBoundary'; +import { userInfoQueryOptions } from '@/store/query/useUserInfoQuery'; +import { regionsQueryOptions } from '@/store/query/useRegionsQuery'; +import { ReactNode, Suspense } from 'react'; + +export default async function CreateProfileLayout({ children }: { children: ReactNode }) { + const tokens = getServerSideTokens(); + + const serverFetchOptions = [regionsQueryOptions(tokens), userInfoQueryOptions(tokens)]; + + return ( + + + + {children} + + + + ); +} diff --git a/apps/bottle/src/app/profile/create/page.tsx b/apps/bottle/src/app/profile/create/page.tsx new file mode 100644 index 0000000..5268d2c --- /dev/null +++ b/apps/bottle/src/app/profile/create/page.tsx @@ -0,0 +1,72 @@ +'use client'; + +import { Header } from '@/components/header'; +import { Step } from '@/features/steps/StepContainer'; +import { useStep } from '@/features/steps/StepProvider'; +import { Asset } from '@bottlesteam/ui'; +import { useRouter } from 'next/navigation'; +import { CreateProfileProvider } from './CreateProfileProvider'; +import { MBTI } from './_steps/MBTI'; +import { Alcohol } from './_steps/alcohol'; +import { Height } from './_steps/height'; +import { Interests } from './_steps/interests'; +import { Job } from './_steps/job'; +import { KaKaoId } from './_steps/kakao-id'; +import { Keywords } from './_steps/keywords'; +import { Region } from './_steps/region'; +import { Religion } from './_steps/religion'; +import { Smoking } from './_steps/smoking'; + +const MAX_STEPS = 10; + +const steps = [ + + + , + + + , + + + , + + + , + + + , + + + , + + + , + + + , + + + , + + + , +] as const; +export default function CreateProfilePage() { + const router = useRouter(); + const { currentStep } = useStep(); + + return ( + <> + +
+ {currentStep > 1 && ( + + )} +
+ {steps[currentStep - 1]} +
+ + ); +} diff --git a/apps/bottle/src/middleware.ts b/apps/bottle/src/middleware.ts index 7b519c7..91d200b 100644 --- a/apps/bottle/src/middleware.ts +++ b/apps/bottle/src/middleware.ts @@ -43,5 +43,5 @@ export async function middleware(request: NextRequest) { } export const config = { - matcher: ['/bottles/:path*', '/my', '/create-profile/:path*'], + matcher: ['/bottles/:path*', '/my', '/create-profile/:path*', '/profile/create/:path*'], }; diff --git a/packages/e2e/package.json b/packages/e2e/package.json index fd8b0f8..e591b15 100644 --- a/packages/e2e/package.json +++ b/packages/e2e/package.json @@ -8,7 +8,8 @@ }, "scripts": { "test:dev": "playwright test", - "test:e2e": "start-server-and-test \"(cd ../../apps/bottle && pnpm run start)\" http://localhost:3000 \"playwright test\"" + "test:e2e": "start-server-and-test \"(cd ../../apps/bottle && pnpm run start)\" http://localhost:3000 \"playwright test\"", + "test:e2e-ui": "start-server-and-test \"(cd ../../apps/bottle && pnpm run start)\" http://localhost:3000 \"playwright test --ui\"" }, "dependencies": { "@bottlesteam/eslint-config": "workspace:*", diff --git a/packages/e2e/tests/create-profile.spec.ts b/packages/e2e/tests/create-profile.spec.ts index 867282b..b399822 100644 --- a/packages/e2e/tests/create-profile.spec.ts +++ b/packages/e2e/tests/create-profile.spec.ts @@ -1,4 +1,4 @@ -import { test } from '@playwright/test'; +import { expect, test } from '@playwright/test'; import dotenv from 'dotenv'; import path from 'path'; @@ -6,12 +6,14 @@ dotenv.config({ path: path.resolve(__dirname, '..', '.env.local') }); const tokenParams = `accessToken=${process.env.TEST_ACCESS_TOKEN}&refreshToken=${process.env.TEST_REFRESH_TOKEN}`; -console.log('[debug]', `?? + ${process.env.NEXT_PUBLIC_SERVER_BASE_URL}`); +const CREATE_PROFILE_URL = '/profile/create'; test('Create Profile Funnel Basic Flow', async ({ page }) => { + await test.step('Expect redirect with token params removed', async () => { + await page.goto(`${CREATE_PROFILE_URL}?${tokenParams}`); + await page.waitForURL(`**${CREATE_PROFILE_URL}`); + }); await test.step('Set MBTI and move to step 2', async () => { - await page.goto(`/create-profile?${tokenParams}`); - const nextButtonText = page.getByText('다음'); const EButton = page.getByRole('button', { name: 'E', exact: true }); const SButton = page.getByRole('button', { name: 'S', exact: true }); @@ -23,7 +25,7 @@ test('Create Profile Funnel Basic Flow', async ({ page }) => { await FButton.click(); await JButton.click(); await nextButtonText.click(); - await page.waitForURL('**/create-profile?step=2'); + await page.waitForURL(`**${CREATE_PROFILE_URL}?step=2`); }); await test.step('Select keywords and move to step 3', async () => { const nextButtonText = page.getByText('다음'); @@ -37,7 +39,7 @@ test('Create Profile Funnel Basic Flow', async ({ page }) => { await select3.click(); await select4.click(); await nextButtonText.click(); - await page.waitForURL('**/create-profile?step=3'); + await page.waitForURL(`**${CREATE_PROFILE_URL}?step=3`); }); await test.step('Select interests and move to step 4', async () => { const nextButtonText = page.getByText('다음'); @@ -51,7 +53,7 @@ test('Create Profile Funnel Basic Flow', async ({ page }) => { await select3.click(); await select4.click(); await nextButtonText.click(); - await page.waitForURL('**/create-profile?step=4'); + await page.waitForURL(`**${CREATE_PROFILE_URL}?step=4`); }); await test.step('Select job and move to step 5', async () => { const nextButtonText = page.getByText('다음'); @@ -59,20 +61,20 @@ test('Create Profile Funnel Basic Flow', async ({ page }) => { await jobSelect.click(); await nextButtonText.click(); - await page.waitForURL('**/create-profile?step=5'); + await page.waitForURL(`**${CREATE_PROFILE_URL}?step=5`); + }); + await test.step('Go back to step4 and expect previous selected job button to be selected. Then, go to step5', async () => { + const goBackButton = page.getByLabel('go-back-icon'); + await goBackButton.click(); + + await page.waitForURL(`**${CREATE_PROFILE_URL}?step=4`); + const jobSelect = page.getByRole('button', { name: '직장인', exact: true }); + expect(jobSelect).toHaveAttribute('aria-selected', 'true'); + + const nextButtonText = page.getByText('다음'); + await nextButtonText.click(); + await page.waitForURL(`**${CREATE_PROFILE_URL}?step=5`); }); - // await test.step('Go back to step4 and expect previous selected job button to be selected. Then, go to step5', async () => { - // const goBackButton = page.getByLabel('go-back-icon'); - // await goBackButton.click(); - - // await page.waitForURL('**/create-profile?step=4'); - // const jobSelect = page.getByRole('button', { name: '직장인', exact: true }); - // expect(jobSelect).toHaveAttribute('aria-selected', 'true'); - - // const nextButtonText = page.getByText('다음'); - // await nextButtonText.click(); - // await page.waitForURL('**/create-profile?step=5'); - // }); await test.step('Select height and move to step6', async () => { const _169Select = page.getByLabel('169cm'); await _169Select.click(); @@ -80,7 +82,7 @@ test('Create Profile Funnel Basic Flow', async ({ page }) => { const nextButtonText = page.getByText('다음'); await nextButtonText.click(); - await page.waitForURL('**/create-profile?step=6'); + await page.waitForURL(`**${CREATE_PROFILE_URL}?step=6`); }); await test.step('Select smoking and move to step 7', async () => { const smokingSelect = page.getByRole('button', { name: '자주 피워요', exact: true }); @@ -89,7 +91,7 @@ test('Create Profile Funnel Basic Flow', async ({ page }) => { const nextButtonText = page.getByText('다음'); await nextButtonText.click(); - await page.waitForURL('**/create-profile?step=7'); + await page.waitForURL(`**${CREATE_PROFILE_URL}?step=7`); }); await test.step('Select alcohol and move to step 8', async () => { const alcoholSelect = page.getByRole('button', { name: '때에 따라 적당히 즐겨요', exact: true }); @@ -98,7 +100,7 @@ test('Create Profile Funnel Basic Flow', async ({ page }) => { const nextButtonText = page.getByText('다음'); await nextButtonText.click(); - await page.waitForURL('**/create-profile?step=8'); + await page.waitForURL(`**${CREATE_PROFILE_URL}?step=8`); }); await test.step('Select religion and move to step 9', async () => { const religionSelect = page.getByRole('button', { name: '기독교', exact: true }); @@ -107,7 +109,7 @@ test('Create Profile Funnel Basic Flow', async ({ page }) => { const nextButtonText = page.getByText('다음'); await nextButtonText.click(); - await page.waitForURL('**/create-profile?step=9'); + await page.waitForURL(`**${CREATE_PROFILE_URL}?step=9`); }); await test.step('Select region and move to step 10', async () => { const cityPlaceholder = page.getByText('전체 지역을 선택해 주세요'); @@ -126,39 +128,6 @@ test('Create Profile Funnel Basic Flow', async ({ page }) => { const nextButtonText = page.getByText('다음'); await nextButtonText.click(); - await page.waitForURL('**/create-profile?step=10'); + await page.waitForURL(`**${CREATE_PROFILE_URL}?step=10`); }); }); - -// test('Previously selected values should be kept when going back between steps. ', async ({ page }) => { -// await test.step('Set MBTI and move to step 2', async () => { -// await page.goto(`/create-profile?${tokenParams}`); - -// const nextButtonText = page.getByText('다음'); -// const EButton = page.getByRole('button', { name: 'E', exact: true }); -// const SButton = page.getByRole('button', { name: 'S', exact: true }); -// const FButton = page.getByRole('button', { name: 'F', exact: true }); -// const JButton = page.getByRole('button', { name: 'J', exact: true }); -// await EButton.click(); -// await SButton.click(); -// await FButton.click(); -// await JButton.click(); -// await nextButtonText.click(); -// await page.waitForURL('**/create-profile?step=2'); -// }); -// await test.step('ESFJ buttons should be selected default, if user goes back to step 1', async () => { -// const goBackButton = page.getByLabel('go-back-icon'); -// await goBackButton.click(); -// await page.waitForURL(`/create-profile`); -// const previouslySelectedButtons = [ -// page.getByRole('button', { name: 'E', exact: true }), -// page.getByRole('button', { name: 'S', exact: true }), -// page.getByRole('button', { name: 'F', exact: true }), -// page.getByRole('button', { name: 'J', exact: true }), -// ]; - -// previouslySelectedButtons.forEach(button => { -// expect(button).toHaveAttribute('aria-selected', 'true'); -// }); -// }); -// });