μΉ΄μΉ΄μ€ν‘μΌλ‘ κΈ°νν°μ½ μ λ¬Ό λ°κΈ°, λ무 μ§λΆν μμΌμ΄μ§ μλμ? HABDAYλ₯Ό μ΄μ©ν΄ μΉκ΅¬λ€μκ² μ λ¬Ό νλ©μ λ°μ보μΈμ!
HABDAYλ μΉκ΅¬λ€κ³Ό ν¨κ»νλ μ λ¬Ό νλ© νλ«νΌμ
λλ€.
μμ μ΄ μνλ μ λ¬Όμ μΉκ΅¬λ€μκ² νλ©μ λ°κ³ , κ·Έλμ κ°κ³ μΆμλ κ³ κ°μ μ λ¬Όμ ꡬ맀ν μ μμ΅λλ€.
λΏλ§ μλλΌ, μΉκ΅¬λ€μ λμμΌλ‘ κΏμ μ€νν μλ μλ νμ μ μΈ νλ«νΌμ
λλ€.
Next.js
- React κΈ°λ°μ μΉ κ°λ° νλ μμν¬
- κ²μμμ§ μ΅μ ν(SEO)μ μλ²μ¬μ΄λ λ λλ§(SSR)μ μ₯μ μ κ°μ§κ³ μμ
- Routingμ νΈμμ±μ΄ μλΉμ€ νΉμ§κ³Ό μ λ§λ¬Όλ¦¬κΈ° λλ¬Έμ μ¬μ©
Typescript
- Javascriptμ νμ μ΄ μΆκ°λ μ μ νμ μΈμ΄
- complie λ¨κ³μμ μλ¬λ₯Ό λ°κ²¬ν΄λΌ μ μμ΄ ν¨μ¨μ μΈ κ°λ° κ°λ₯
React-query
- μλ² μνκ΄λ¦¬λ₯Ό μν λΌμ΄λΈλ¬λ¦¬
Recoil
- μ μ ν΄λΌμ΄μΈνΈ μνκ΄λ¦¬λ₯Ό μν λΌμ΄λΈλ¬λ¦¬
Styled-components
- λμ μ€νμΌλ§μ μ©μ΄νκ² ν΄μ£Όλ μ€νμΌλ§ λΌμ΄λΈλ¬λ¦¬
Axios
- HTTP μμ²μ μ©μ΄νκ² ν΄μ£Όλ Promise κΈ°λ° λΌμ΄λΈλ¬λ¦¬
- μ°Έμ¬μκ° μμ±μκ° κ³΅μ ν λ§ν¬λ‘ μ§μ νκ² λλ©΄, λ‘κ·ΈμΈ νλ©΄μ 보μ¬μ€λ€.
λ€μ΄λ²λ‘ μμνκΈ°
λ²νΌμ ν΄λ¦νλ©΄, λ€μ΄λ²λ‘κ·ΈμΈ λ§ν¬λ‘ μ μνλ€.- μ°Έμ¬μκ° λ€μ΄λ² μμ΄λμ λΉλ°λ²νΈλ₯Ό μ λ ₯νμ¬ λ‘κ·ΈμΈμ μ±κ³΅νλ©΄ μΈκ°μ½λλ₯Ό λ°κΈλ°λλ€.
- λ°κΈλ°μ μΈκ°μ½λλ₯Ό μλ²μ μ λ¬ν΄, μ‘μΈμ€ ν ν°μ λ°κΈνλ€.
- λ°κΈλ°μ μ‘μΈμ€ ν ν°μ μμΌλ‘μ μλ² μμ² μ headersμ λ£μ΄ μ¬μ©μ μλ³μ μ¬μ©λλ€.
- λ§μ½ μ΅μ΄λ‘ λ‘κ·ΈμΈν μ¬μ©μμ΄λ©΄, μΆκ° μ 보λ₯Ό μ λ ₯νμ¬ κ°μ μ μλ£νλ€.
- λ‘κ·ΈμΈμ μ±κ³΅νλ©΄ νλ© μμΈλ³΄κΈ° λ·°λ‘ μ§μ νλ©°, μμ±μ μ΄λ¦, νλ© μ΄λ¦, νλ© μ¬μ§, λͺ¨μΈ κΈμ‘μ΄ νμλλ€.
νλ©μ μ°Έμ¬ν λμ
λ²νΌμ ν΄λ¦νλ©΄ νλ© μ°Έμ¬λ₯Ό μν μ 보 μ λ ₯ λ·°λ‘ μ΄λνλ€.
- νλ© μ°Έμ¬μμ μ΄λ¦, νλ©ν κΈμ‘, μμ λ©μμ§ λ±μ μ λ ₯ν΄ νλ©μ μ°Έμ¬ν μ μλ€.
- μ λ ₯ν μ 보λ μΆν νλ© μμ±μμκ² μ λ¬λλ€.
- κ²°μ μλ¨μ μ΄μ μ μ λ ₯νλ μΉ΄λκ° μμΌλ©΄ μ νν΄μ κ²°μ ν μ μλ€.
- μ λ¬Όλ κΈμ‘μ΄ νλ© μμ±μμ μ νμ λ°λΌ λ€λ₯Έ μν ꡬ맀μ μ°μΌ μ μμΌλ―λ‘, ν΄λΉ μ¬νμ λμν΄μΌ μ΅μ’ κ²°μ κ° κ°λ₯νλ€.
- λ§μΌ μμ§ κ²°μ μλ¨μ μ λ ₯νμ§ μμκ±°λ μλ‘μ΄ κ²°μ μλ¨μ μ λ ₯νκ³ μΆλ€λ©΄ μΉ΄λμ 보λ₯Ό μ λ ₯ν μ μλ€.
- μΉ΄λ μ 보λ₯Ό μ¬λ°λ₯΄κ² μ λ ₯νμ§ μμ κ²½μ°, μλ¦Όμ°½μ΄ λ¨λ©° μ¬λ°λ₯Έ κ°μ μ λ ₯νλλ‘ μ λνλ€.
- μ΅μ’ μ μΌλ‘ μ°Έμ¬κ° μλ£λμμμ μ리λ νλ©΄μ΄λ€.
μ°Έμ¬λ΄μ 보λ¬κ°κΈ°
λ₯Ό ν΄λ¦ν΄ μ°Έμ¬ν νλ© λ¦¬μ€νΈλ₯Ό μ‘°νν μ μλ€.
- μ°Έμ¬νλ νλ© λ΄μμ νμΈν μ μλ λ·°μ΄λ€.
- νλ©μ ν΄λ¦ν΄ μ°Έμ¬νλ νλ©μ μ·¨μν μ μμΌλ©°, μ·¨μ λ μ΄νμλ cancel μνλ‘ λ³κ²½λλ€.
- νλ© μμ±μκ° νλ©μ΄ μ±κ³΅ν ν 2μ£Ό μ΄λ΄λ‘ μ±μ ν΅ν΄ μΈμ¦μ νλ©΄, κΈ°μ‘΄μ νλ© urlλ‘ μ§μ νμ λ νλ© λ·°κ° μλ μΈμ¦ λ·°κ° λ¬λ€.
- μΈμ¦ μμΈλ³΄κΈ° λ·°μμ μ€μ λ‘ μ λ¬Όμ ꡬμ νλμ§ μ¬λΆμ κ°μ¬ λ©μμ§λ₯Ό νμΈν μ μλ€.
π¦
ββΒ .eslintrc.json
ββΒ .gitignore
ββΒ .prettierrc
ββΒ README.md
ββΒ api
ββΒ assets
ββΒ components
βΒ Β ββΒ common
βΒ Β Β Β Β ββΒ Greeting.tsx
βΒ Β Β Β Β ββΒ Layout.tsx
βΒ Β Β Β Β ββΒ Progress.tsx
βΒ Β Β Β Β ββΒ modal
ββΒ hooks
ββΒ pages
βΒ Β ββΒ _app.tsx
βΒ Β ββΒ _document.tsx
βΒ Β ββΒ card
βΒ Β ββΒ complet
βΒ Β ββΒ detai
βΒ Β ββΒ fun
βΒ Β ββΒ index.ts
βΒ Β ββΒ landing
βΒ Β βΒ Β ββΒ [itemId].tsx // Dynamic routing: μ΅μ΄ μ§μ
νμ΄μ§
βΒ Β ββΒ list
βΒ Β ββΒ revie
βΒ Β ββΒ signu
ββΒ public
ββΒ states // for atoms
ββΒ styles // for global styling
ββΒ types // for common types
ββΒ util // for constants
ββΒ yarn.lock
Β©generated by Project Tree Generator
- 컀μ€ν ν μ 컨νΈλ‘€λ¬ μν λ‘ λ MVC ν¨ν΄
- 컀μ€ν ν μ μ¬μ©νλ©΄ UIμ λΉμ¦λμ€ λ‘μ§μ λΆλ¦¬ν μ μμ΅λλ€.
React 컀μ€ν ν (Custom Hook) μ΄λ? React ν¨μ μ»΄ν¬λνΈμμ μν κ΄λ¦¬, λΌμ΄νμ¬μ΄ν΄ κΈ°λ₯ λ±μ μΆμννμ¬ μ¬μ¬μ© κ°λ₯ν λ‘μ§μ ꡬννκ³ κ³΅μ ν μ μκ² ν΄μ£Όλ ν
/landing/μμ΄ν id
λ‘ μ§μ νκ² λλ©΄, Landing μ»΄ν¬λνΈλ₯Ό λ λλ§ν©λλ€.getServersizeProps
λ©μλλ‘ paramsλ₯Ό λ°μμ, μλ²μ ν΄λΉ idμ νλ©μμΈμ 보λ₯Ό μμ²ν©λλ€.
import React, { useEffect } from 'react';
import Layout from '../../components/common/Layout';
import { useFundDetail } from '../../hooks/fund/useFundDetail';
import { useRouter } from 'next/router';
import { useSetRecoilState } from 'recoil';
import { fundingIdState } from '../../states/atom';
import Greeting from '../../components/common/Greeting';
export interface ParamProps {
params: ItemProps;
}
export interface ItemProps {
itemId: string;
}
const STATUS = {
PROGRESS: 'PROGRESS',
FAILED: 'FAILED',
SUCCESS: 'SUCCESS',
};
export default function Landing({ itemId }: ItemProps) {
const router = useRouter();
const { detail, isLoading, isError } = useFundDetail(parseInt(itemId));
const setFundingId = useSetRecoilState(fundingIdState);
const NAVER_AUTH_URL = `https://nid.naver.com/oauth2.0/authorize?response_type=code&client_id=${process.env.NEXT_PUBLIC_CLIENT_ID}&state=${process.env.NEXT_PUBLIC_LOGIN_STATE}&redirect_uri=${process.env.NEXT_PUBLIC_REDIRECT_URL}`;
const onClickLogin = () => window.location.assign(NAVER_AUTH_URL);
useEffect(() => {
setFundingId(parseInt(itemId));
}, [detail]);
if (isLoading) {
return <div>loading...</div>;
}
if (isError || detail?.status === STATUS.FAILED) {
return <div>error! μ‘΄μ¬νμ§ μλ νλ©μ
λλ€</div>;
}
if (detail?.isConfirmation) {
return (
<Layout>
<Greeting message="νλ© μΈμ¦μ΄ λμ°©νμ΄μ!" isPing onClickIcon={onClickLogin} />
</Layout>
);
}
if (detail?.status === STATUS.SUCCESS) {
return (
<Layout link="μ°Έμ¬μ΄λ ₯ 보λ¬κ°κΈ°">
<Greeting message="νλ©μ μ±κ³΅νμ΄μ, κ°μ¬ν©λλ€!" />
</Layout>
);
}
return (
<Layout isNaver buttons={['λ€μ΄λ²λ‘ μμνκΈ°']} link="HABDAYκ° μ²μμ΄μΈμ?" onClickButton={onClickLogin}>
<Greeting message={`${detail?.hostName}λμ νλ©μ μ°Έμ¬ν΄λ³΄μΈμ!`} />
</Layout>
);
}
export async function getServerSideProps({ params }: ParamProps) {
const itemId = params.itemId;
return { props: { itemId } };
}
- μμ£Ό μ¬μ©νλ λ₯Ό
common/Layout
μΌλ‘ μ μΈν΄, κ³΅ν΅ μ»΄ν¬λνΈν νμμ΅λλ€.
interface LayoutProps {
children: React.ReactNode;
buttons?: string[];
link?: string;
onClickButton?: () => void;
onClickLeftButton?: () => void;
isNaver?: boolean;
}
export default function Layout(props: LayoutProps) {
const { children, buttons, link, onClickButton, onClickLeftButton, isNaver } = props;
return (
<Styled.Root>
<Styled.Main>{children}</Styled.Main>
<Styled.Footer isButtons={buttons?.length === 2}>
{buttons && buttons?.length == 2 && (
<Styled.ButtonLeft onClick={onClickLeftButton}>{buttons[1]}</Styled.ButtonLeft>
)}
{buttons && buttons?.length >= 1 && (
<Styled.Button isNaver={isNaver} onClick={onClickButton}>
{isNaver && <Image alt="λ€μ΄λ² λ‘κ³ " src={NaverImg} height={42} width={42} />}
{buttons[0]}
</Styled.Button>
)}
{link && <Styled.Link>{link}</Styled.Link>}
</Styled.Footer>
</Styled.Root>
);
}
- 컀μ€ν
ν
useFundDetail
μ μ μΈν΄ UIμ λΉμ¦λμ€ λ‘μ§μ λΆλ¦¬νμμ΅λλ€. useFundDetail
μfetchFundDetail
ν¨μλ₯Ό νΈμΆν©λλ€. axios λΌμ΄λΈλ¬λ¦¬λ₯Ό μ¬μ©ν΄ λμ± ν¨μ¨μ μΈ REST API ν΅μ μ ꡬνν©λλ€.
// useFundDetail.ts
import { useQuery } from 'react-query';
import { fetchFundDetail } from '../../api/fund';
import { useSetRecoilState } from 'recoil';
import { QUERY_KEY } from '..';
export const useFundDetail = (itemId: number) => {
const { isLoading, isError, data } = useQuery([QUERY_KEY.fundDetail], () => fetchFundDetail(itemId));
return { detail: data, isLoading, isError };
};
// fund.ts
import { client } from '.';
import { Response } from '../types';
import { DetailOutput } from '../types/responses/fund';
export const fetchFundDetail = async (itemId: number) => {
const {
data: { data },
} = await client.get<Response<DetailOutput>>(`/funding/showFundingContent?itemId=${itemId}`);
return data;
};
- νλ© μμΈλ³΄κΈ°
Detail
λ·°μμλgetServersideProps
λ‘ query paramμ μΈκ°μ½λλ₯Ό κ°μ Έμ΅λλ€. - μΈκ°μ½λλ₯Ό μ¬μ©ν΄
useAccessToken
μ νΈμΆνλ©΄, μ체 μ‘μΈμ€ ν ν°μ λ°κΈν΄ recoil atomμ μ μ₯ν©λλ€.
interface codeProps {
code: string;
}
export default function Detail({ code }: codeProps) {
const router = useRouter();
const itemId = useRecoilValue(fundingIdState);
const { detail } = useFundDetail(itemId);
const { accessToken, isLoading } = useAccessToken(code);
// const signupStat = useRecoilValue(signupLogState);
const { isRegister } = useIsRegister();
useEffect(() => {
if (code === undefined || isRegister === undefined) return;
if (!isRegister) router.push('/signup');
else if (detail?.isConfirmation) router.push('/review');
}, [code, detail, accessToken, isRegister]);
if (isLoading) return <div>λ‘λ©μ€...</div>;
return (
<Layout buttons={['νλ©μ μ°Έμ¬ν λμ']} onClickButton={() => router.push('/fund')}>
<Styled.Titles>
<Styled.Title>{detail?.hostName}λμ</Styled.Title>
<Styled.BoldTitle>{detail?.fundingName}</Styled.BoldTitle>
<Styled.Title>λ₯Ό(μ) κ°κ³ μΆμ΄ν΄μ</Styled.Title>
</Styled.Titles>
<Styled.Images>
<Styled.ImageContainer>
<Image
src={detail?.fundingItemImg ?? AirpodImg}
alt="νλ©μμ΄ν
μ΄λ―Έμ§"
width={222}
height={222}
placeholder="blur"
blurDataURL="asstes/default.svg"
priority
/>
</Styled.ImageContainer>
</Styled.Images>
<Styled.ProgressContainer>
<Styled.ProgressTitle>νμ¬κΉμ§ λͺ¨μΈ κΈμ‘</Styled.ProgressTitle>
<Styled.ProgressAmount>οΏ¦ {priceFormatter(detail?.totalPrice ?? 0)}</Styled.ProgressAmount>
<Progress totalPrice={detail?.totalPrice ?? 0} goalPrice={detail?.goalPrice ?? 0} />
</Styled.ProgressContainer>
</Layout>
);
}
export async function getServerSideProps(context: GetServerSidePropsContext) {
return { props: { code: context.query.code ?? '' } };
}
AxiosInterceptor
μμ λ°κΈλ accessTokenμ headerμ λ£μ΅λλ€.
export const BASE_URL = process.env.NEXT_PUBLIC_END;
const client = axios.create({
baseURL: BASE_URL,
headers: { 'Content-Type': 'application/json' },
});
function AxiosInterceptor({ children }: PropsWithChildren) {
const router = useRouter();
const accessToken = useRecoilValue(accessTokenState);
const requestIntercept = client.interceptors.request.use((config) => {
if (config.headers && !config.headers['accessToken']) {
config.headers['accessToken'] = accessToken ? `${accessToken}` : '';
return config;
}
return config;
});
const responseIntercept = client.interceptors.response.use(
(config) => config,
async (error) => {
const config = error.config;
console.log(error);
if (error.response.status === 401) {
alert('λ‘κ·ΈμΈ ν μ΄μ©ν΄ μ£ΌμΈμ');
}
return Promise.reject(error);
}
);
useEffect(() => {
return () => {
client.interceptors.request.eject(requestIntercept);
client.interceptors.response.eject(responseIntercept);
};
}, [requestIntercept]);
return <>{children}</>;
}
export { client, AxiosInterceptor };
/fund
μ§μ μ νλ©μ μ°Έμ¬ν μ μλFund
μ»΄ν¬λνΈλ₯Ό νΈμΆν©λλ€.- λ€μν inputλ€μ λΉμ¦λμ€ λ‘μ§μ μ²λ¦¬νκΈ° μν΄
useParticipantForm
컀μ€ν ν μ μ¬μ©νμμ΅λλ€. usePaymentList
λ κΈ°μ‘΄μ λ±λ‘ν΄λ κ²°μ μλ¨ μ 보λ₯Ό κ°μ Έμ΅λλ€.
export default function Fund() {
const router = useRouter();
const itemId = useRecoilValue(fundingIdState);
const { detail } = useFundDetail(itemId);
const { participant, setParticipantForm, submitPariticipant, toggleAgree } = useParticipantForm(async () => {
router.push('/complete');
});
const { isError, isLoading, paymentList } = usePaymentList();
useEffect(() => {
paymentList.length && setParticipantForm({ paymentId: paymentList[0].paymentId });
}, [paymentList]);
return (
<Layout buttons={['λ€μ']} onClickButton={submitPariticipant}>
<Styled.Title>{detail?.hostName} λμκ²</Styled.Title>
<Styled.Form>
<Styled.Label>보λ΄λ λΆ μ±ν¨</Styled.Label>
<Styled.Input
value={participant.name}
id="buyer"
type="text"
onChange={(e) => setParticipantForm({ name: e.target.value })}
/>
</Styled.Form>
<Styled.Form>
<Styled.Label>νλ© κΈμ‘</Styled.Label>
<Progress
goalPrice={detail?.goalPrice ?? 0}
totalPrice={detail?.totalPrice ?? 0}
isPing
amount={participant.amount}
/>
<Styled.Input
id="amount"
type="number"
max={`${detail?.goalPrice ?? 0 - (detail?.totalPrice ?? 0)}`}
placeholder={`μ΅λ ${priceFormatter(detail?.goalPrice ?? 0 - (detail?.totalPrice ?? 0))}μκΉμ§ κ°λ₯ν΄μ`}
onChange={(e) => setParticipantForm({ amount: parseInt(e.target.value) })}
/>
</Styled.Form>
<Styled.Form>
<Styled.Label>μμ λ©μμ§</Styled.Label>
<Styled.Textarea
value={participant.message}
onChange={(e) => setParticipantForm({ message: e.target.value })}
/>
<Styled.Maxline>{participant.message.length || 0}/60</Styled.Maxline>
</Styled.Form>
<Styled.Form>
<Styled.Label>
μΉ΄λ κ²°μ
<Styled.AddCardButton onClick={() => router.push('/card')}>μΉ΄λ μΆκ°</Styled.AddCardButton>
</Styled.Label>
{paymentList.length ? (
<Styled.Select defaultValue={0}>
{paymentList.map(({ paymentId, paymentName }, index) => (
<option key={paymentId} onClick={() => setParticipantForm({ paymentId: paymentId })}>
{paymentName}
</option>
))}
</Styled.Select>
) : (
<Styled.Message>κ²°μ μλ¨μ μΆκ°ν΄μ£ΌμΈμ</Styled.Message>
)}
<Styled.Check>
μ λ¬Όνμ€ κΈμ‘μ λͺ©μ κΈμ‘ λ―Έλ¬μ±μ λ€λ₯Έ μνꡬ맀μ
<br />
μ¬μ©λ μ μμ΅λλ€. λμνμκ² μ΅λκΉ?
<input type="checkbox" onClick={toggleAgree} />
</Styled.Check>
</Styled.Form>
</Layout>
);
}
- μ¬μ©μ μ
λ ₯μ μ²λ¦¬νλ
useParticipantForm
μ recoil atomμ μ¬μ©νμ¬ μ¬μ©μμ μ λ ₯κ°μ΄ μ μ§λλλ‘ ν©λλ€. submitPariticipant
ν¨μμμ μλ¬νΈλ€λ§μ μννκ³ μμ΅λλ€.- react query λΌμ΄λΈλ¬λ¦¬λ₯Ό μ¬μ©νμ¬, λ°μ΄ν° ν¨μΉ μ±κ³΅μμ κΈ°μ‘΄μ μλ
fundDetail
λ°μ΄ν°λ₯Ό μΊμ±νκ³ μ±κ³΅ νμ ν΄μΌν μΌμ μνν¨μΌλ‘μ¨ λ°μ΄ν° μ ν©μ±μ 보μ₯νκ³ μλ² μνκ΄λ¦¬λ₯Ό μνν©λλ€.
export const useParticipateMutation = (onSuccessMutation: () => void) => {
const participant = useRecoilValue(participantState);
const queryClient = useQueryClient();
return useMutation(() => postParticipate(participant), {
onSuccess() {
queryClient.invalidateQueries([QUERY_KEY.fundDetail]);
onSuccessMutation();
},
onError({ response }: ParticipateErrorResponse) {
alert(response.data.msg);
},
});
};
export const useParticipantForm = (onSuccessMutation: () => void) => {
const [participant, setParticipant] = useRecoilState(participantSelector);
const [isAgree, setIsAgree] = useState<boolean>(false);
const participantMutation = useParticipateMutation(onSuccessMutation);
const setParticipantForm = (input: Partial<ParticipateInput>) => {
setParticipant({ ...participant, ...input });
};
const submitPariticipant = () => {
if (participant.paymentId === -99) alert('κ²°μ μλ¨μ μ νν΄μ£ΌμΈμ');
else if (participant.amount < 101) alert('μ΅μ κΈμ‘μ 101μμ
λλ€');
else if (!participant.name.length) alert('μ±ν¨μ μ
λ ₯ν΄μ£ΌμΈμ');
else if (!isAgree) alert('μ½κ΄μ λμν΄μ£ΌμΈμ');
else participantMutation.mutate();
};
const toggleAgree = () => setIsAgree((prev) => !prev);
return { participant, setParticipantForm, submitPariticipant, toggleAgree };
};