diff --git a/.storybook/main.ts b/.storybook/main.ts
index a830d656..56aad86f 100644
--- a/.storybook/main.ts
+++ b/.storybook/main.ts
@@ -30,5 +30,6 @@ const config: StorybookConfig = {
docs: {
autodocs: 'tag',
},
+ staticDirs: ['../public'],
};
export default config;
diff --git a/.storybook/preview.ts b/.storybook/preview.ts
index 673fd362..02ba084a 100644
--- a/.storybook/preview.ts
+++ b/.storybook/preview.ts
@@ -1,7 +1,12 @@
import type { Preview } from '@storybook/react';
import '../app/globals.css';
+import { handlers } from '../app/mocks/handlers';
+import { initialize, mswDecorator } from 'msw-storybook-addon';
+
+initialize({}, [...handlers]);
const preview: Preview = {
+ decorators: [mswDecorator],
parameters: {
actions: { argTypesRegex: '^on[A-Z].*' },
controls: {
diff --git a/app/(sub-page)/sign-up/components/sign-up-container.tsx b/app/(sub-page)/sign-up/components/sign-up-container.tsx
new file mode 100644
index 00000000..e66478f6
--- /dev/null
+++ b/app/(sub-page)/sign-up/components/sign-up-container.tsx
@@ -0,0 +1,34 @@
+'use client';
+import useFunnel from '@/app/hooks/useFunnel';
+import SignUpForm from '@/app/ui/user/sign-up-form/sign-up-form';
+import SignUpTerm from './sign-up-terms';
+import SignUpSuccess from './sign-up-success';
+import ContentContainer from '@/app/ui/view/atom/content-container';
+
+export default function SignUpContainer() {
+ const { Funnel, setStep } = useFunnel<'terms' | 'form' | 'success'>('terms');
+
+ return (
+
+
+
+ {
+ setStep('form');
+ }}
+ />
+
+
+ {
+ setStep('success');
+ }}
+ />
+
+
+
+
+
+
+ );
+}
diff --git a/app/(sub-page)/sign-up/components/sign-up-success.tsx b/app/(sub-page)/sign-up/components/sign-up-success.tsx
new file mode 100644
index 00000000..a27e7758
--- /dev/null
+++ b/app/(sub-page)/sign-up/components/sign-up-success.tsx
@@ -0,0 +1,25 @@
+import Button from '@/app/ui/view/atom/button/button';
+import Link from 'next/link';
+
+// 내용이랑 스타일은 mock인 상태입니다.
+export default function SignUpSuccess() {
+ return (
+
+
+
+
Youre all set.
+
+ Thanks for signing up! We just need to verify your email address to complete the process.
+
+
+
+
+
+ );
+}
diff --git a/app/(sub-page)/sign-up/components/sign-up-terms.tsx b/app/(sub-page)/sign-up/components/sign-up-terms.tsx
new file mode 100644
index 00000000..8a5deaf7
--- /dev/null
+++ b/app/(sub-page)/sign-up/components/sign-up-terms.tsx
@@ -0,0 +1,56 @@
+import Button from '@/app/ui/view/atom/button/button';
+
+interface SignUpTermProps {
+ onNext?: () => void;
+}
+
+// 약관 내용이랑 스타일은 mock인 상태입니다.
+export default function SignUpTerm({ onNext }: SignUpTermProps) {
+ const handleAgreeButtonClick = () => {
+ onNext?.();
+ };
+
+ return (
+
+
알림독의 안내문
+
+ -
+ 현재 저희 기능은 한국-한국은 아니라 한국-외국도 가능합니다. 각사별에서 수하인 없더라도 저희가 받는데까지는
+ 무관합니다만, 꼭 관세사무소와 협의하세요!
+
+ - 대상: 국외발송, 북부발송, 사회복지대상, ICT용품대상, 일반대상, 미래용품대상(확인)
+ - 발송: 16 ~ 22시발
+
+
+ -
+ 교직, 디자인, 연계개발, 물품, 전자, 자원관리/회계지원에 해당하는 사용자는 각사 기준에 따른 선정되지 않아 각사
+ 별 관리하는데요.
+
+ - 검사를 위해서 선적품을 직접 연락드려야만 PC화면에서 진행하는 것을 권장합니다.
+ -
+ 검사 기준은 최신버전 확인내역(2023.07.24) 반영하여 선정되었으며, 학사내역은 매년 개편되므로 자사이 외고 있는
+ 구버전과 다를 수 있습니다.
+
+
+ -
+ 본 서비스 정보는 공식적인 확인을 전제 않으며, 정확한 증상조사결과를 위해 서류 또는 담당과 교류해야할 사항을
+ 잊지 않습니다.
+
+ -
+ 전자문 서화지 데이터베이스는 의무화되어 저희가 고유축적 및 교육과정 등에서 사용되며, 어떤 다른 용도로 사용
+ 되지 않습니다.
+
+ - 특허요건 기준이 전문 선정되었거나, 오류발생 시 우측 하단 채팅창으로 또는 담당 부서로문의합니다.
+
+
+
+
+
+ );
+}
diff --git a/app/(sub-page)/sign-up/page.tsx b/app/(sub-page)/sign-up/page.tsx
new file mode 100644
index 00000000..8395093c
--- /dev/null
+++ b/app/(sub-page)/sign-up/page.tsx
@@ -0,0 +1,21 @@
+import ContentContainer from '@/app/ui/view/atom/content-container';
+import SignUpContainer from './components/sign-up-container';
+import { Suspense } from 'react';
+import LoadingSpinner from '@/app/ui/view/atom/loading-spinner';
+
+// Refactor: fallback 스켈레톤으로 대체
+export default function Page() {
+ return (
+
+
+
+
+ }
+ >
+
+
+
+ );
+}
diff --git a/app/__test__/ui/invoice/revenu-chart.test.tsx b/app/__test__/ui/invoice/revenu-chart.test.tsx
deleted file mode 100644
index 9e127507..00000000
--- a/app/__test__/ui/invoice/revenu-chart.test.tsx
+++ /dev/null
@@ -1,14 +0,0 @@
-import { render, screen } from '@testing-library/react';
-import RevenueChart from '../../../ui/invoice/revenu-chart';
-
-// test sample: RSC 테스트할 때
-// 참고로 정상적인 방법은 아님, next 문서를 보면 jest에서 아직 RSC를 공식적으로 지원하지 않기 때문에, RSC는 E2E 테스트를 권장하고 있음. 즉 어떤 지옥이 펼쳐질 지 모른다..
-// https://nextjs.org/docs/app/building-your-application/testing/jest
-describe('RevenueChart', () => {
- it('RevenueChart를 보여준다.', async () => {
- render(await RevenueChart());
-
- expect(await screen.findByText(/Recent Revenue/i)).toBeInTheDocument();
- expect(await screen.findByText(/2000/i)).toBeInTheDocument();
- });
-});
diff --git a/app/business/api-path.ts b/app/business/api-path.ts
index c05f51fe..6bb0a801 100644
--- a/app/business/api-path.ts
+++ b/app/business/api-path.ts
@@ -1,8 +1,9 @@
-const BASE_URL = process.env.API_MOCKING === 'enable' ? 'http://localhost:9090' : 'http://mock.api.com';
+const BASE_URL = process.env.API_MOCKING === 'enable' ? 'http://localhost:9090' : 'https://mock.api.com';
export const API_PATH = {
revenue: `${BASE_URL}/revenue`,
registerUserGrade: `${BASE_URL}/registerUserGrade`,
parsePDFtoText: `${BASE_URL}/parsePDFtoText`,
takenLectures: `${BASE_URL}/taken-lectures`,
+ user: `${BASE_URL}/users`,
};
diff --git a/app/business/auth/user.command.ts b/app/business/auth/user.command.ts
deleted file mode 100644
index 3bd9f010..00000000
--- a/app/business/auth/user.command.ts
+++ /dev/null
@@ -1,67 +0,0 @@
-'use server';
-
-import { FormState } from '@/app/ui/view/molecule/form/form-root';
-import { z } from 'zod';
-
-// message name은 logic 구현할 때 통일할 예정
-const SignUpFormSchema = z
- .object({
- userId: z
- .string()
- .min(6, {
- message: 'User ID must be at least 6 characters',
- })
- .max(20, {
- message: 'User ID must be at most 20 characters',
- }),
- password: z.string().regex(/^(?=.*[A-Za-z])(?=.*\d)(?=.*[@$!^%*#?&])[A-Za-z\d@$!^%*#?&]{8,}$/, {
- message: 'Password must contain at least 8 characters, one letter, one number and one special character',
- }),
- confirmPassword: z.string(),
- studentNumber: z.string().length(8, { message: '학번은 8자리 입니다' }).startsWith('60', {
- message: '학번은 60으로 시작합니다',
- }),
- english: z.enum(['basic', 'level12', 'level34', 'bypass']),
- })
- .superRefine(({ confirmPassword, password }, ctx) => {
- console.log('refind', confirmPassword, password);
- if (confirmPassword !== password) {
- ctx.addIssue({
- code: 'custom',
- message: 'The passwords did not match',
- path: ['confirmPassword'],
- });
- }
- });
-
-type User = z.infer;
-
-export async function createUser(prevState: FormState, formData: FormData): Promise {
- const validatedFields = SignUpFormSchema.safeParse({
- userId: formData.get('userId'),
- password: formData.get('password'),
- confirmPassword: formData.get('confirmPassword'),
- studentNumber: formData.get('studentNumber'),
- english: formData.get('english'),
- });
-
- if (!validatedFields.success) {
- return {
- errors: validatedFields.error.flatten().fieldErrors,
- message: 'error',
- };
- }
-
- // Call the API to create a user
- // but now mock the response
- await new Promise((resolve) => {
- setTimeout(() => {
- resolve('');
- }, 3000);
- });
-
- return {
- errors: {},
- message: 'blacnk',
- };
-}
diff --git a/app/business/lecture/taken-lecture.query.ts b/app/business/lecture/taken-lecture.query.ts
index 906e3236..b671a2f7 100644
--- a/app/business/lecture/taken-lecture.query.ts
+++ b/app/business/lecture/taken-lecture.query.ts
@@ -1,7 +1,7 @@
import { LectureInfo } from '@/app/type/lecture';
import { API_PATH } from '../api-path';
-interface TakenLectures {
+export interface TakenLectures {
totalCredit: number;
takenLectures: LectureInfo[];
}
diff --git a/app/business/user/user.command.ts b/app/business/user/user.command.ts
new file mode 100644
index 00000000..d56ee9fc
--- /dev/null
+++ b/app/business/user/user.command.ts
@@ -0,0 +1,69 @@
+'use server';
+
+import { FormState } from '@/app/ui/view/molecule/form/form-root';
+import { API_PATH } from '../api-path';
+import { SignUpRequestBody } from './user.type';
+import { httpErrorHandler } from '@/app/utils/http/http-error-handler';
+import { BadRequestError } from '@/app/utils/http/http-error';
+import { SignUpFormSchema } from './user.validation';
+
+export async function createUser(prevState: FormState, formData: FormData): Promise {
+ const validatedFields = SignUpFormSchema.safeParse({
+ authId: formData.get('authId'),
+ password: formData.get('password'),
+ confirmPassword: formData.get('confirmPassword'),
+ studentNumber: formData.get('studentNumber'),
+ engLv: formData.get('engLv'),
+ });
+
+ if (!validatedFields.success) {
+ return {
+ isSuccess: false,
+ isFailure: true,
+ validationError: validatedFields.error.flatten().fieldErrors,
+ message: '양식에 맞춰 다시 입력해주세요.',
+ };
+ }
+
+ const { authId, password, studentNumber, engLv } = validatedFields.data;
+ const body: SignUpRequestBody = {
+ authId,
+ password,
+ studentNumber,
+ engLv,
+ };
+
+ try {
+ const response = await fetch(`${API_PATH.user}/sign-up`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(body),
+ });
+
+ const result = await response.json();
+
+ httpErrorHandler(response, result);
+ } catch (error) {
+ if (error instanceof BadRequestError) {
+ // 잘못된 요청 처리 로직
+ return {
+ isSuccess: false,
+ isFailure: true,
+ validationError: {},
+ message: error.message,
+ };
+ } else {
+ // 나머지 에러는 더 상위 수준에서 처리
+ throw error;
+ }
+ }
+
+ return {
+ isSuccess: true,
+ isFailure: false,
+ validationError: {},
+ message: '회원가입이 완료되었습니다.',
+ };
+}
diff --git a/app/business/user/user.type.ts b/app/business/user/user.type.ts
new file mode 100644
index 00000000..3bd86c27
--- /dev/null
+++ b/app/business/user/user.type.ts
@@ -0,0 +1,9 @@
+// https://stackoverflow.com/questions/76957592/error-only-async-functions-are-allowed-to-be-exported-in-a-use-server-file
+// server action 파일에서는 async function만 export 가능
+
+export interface SignUpRequestBody {
+ authId: string;
+ password: string;
+ studentNumber: string;
+ engLv: string;
+}
diff --git a/app/business/user/user.validation.ts b/app/business/user/user.validation.ts
new file mode 100644
index 00000000..a9939ebf
--- /dev/null
+++ b/app/business/user/user.validation.ts
@@ -0,0 +1,36 @@
+import { z } from 'zod';
+
+export const SignUpFormSchema = z
+ .object({
+ authId: z
+ .string()
+ .min(6, {
+ message: '아이디는 6자 이상 20자 이하여야 합니다.',
+ })
+ .max(20, {
+ message: 'User ID must be at most 20 characters',
+ }),
+ password: z
+ .string()
+ .min(8, { message: '비밀번호는 8자 이상이어야 합니다.' })
+ .regex(/^(?=.*[A-Za-z])(?=.*\d)(?=.*[!@#$%^&*])[A-Za-z\d!@#$%^&*]{8,}$/, {
+ message: '비밀번호는 문자, 숫자, 특수문자(!@#$%^&*)를 포함해야 합니다.',
+ })
+ .max(20, { message: '비밀번호는 20자 이하여야 합니다.' }),
+ confirmPassword: z.string(),
+ studentNumber: z.string().length(8, { message: '학번은 8자리여야 합니다.' }).startsWith('60', {
+ message: '학번은 60으로 시작해야 합니다.',
+ }),
+ engLv: z.enum(['basic', 'ENG12', 'ENG34', 'bypass'], {
+ invalid_type_error: '올바른 영어 레벨을 선택해주세요.',
+ }),
+ })
+ .superRefine(({ confirmPassword, password }, ctx) => {
+ if (confirmPassword !== password) {
+ ctx.addIssue({
+ code: 'custom',
+ message: '비밀번호가 일치하지 않습니다.',
+ path: ['confirmPassword'],
+ });
+ }
+ });
diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx
index 381ca101..4a31401e 100644
--- a/app/dashboard/page.tsx
+++ b/app/dashboard/page.tsx
@@ -6,11 +6,7 @@ export default async function Page() {
return (
Dashboard
-
- }>
-
-
-
+
);
}
diff --git a/app/hooks/useFunnel.tsx b/app/hooks/useFunnel.tsx
new file mode 100644
index 00000000..68b76e59
--- /dev/null
+++ b/app/hooks/useFunnel.tsx
@@ -0,0 +1,57 @@
+import React, { useCallback, useEffect } from 'react';
+import { usePathname, useRouter, useSearchParams } from 'next/navigation';
+
+const DEFAULT_STEP_QUERY_KEY = 'funnel-step';
+
+export default function useFunnel(
+ defaultStep: Steps,
+ options?: {
+ stepQueryKey?: string;
+ },
+) {
+ const router = useRouter();
+ const pathname = usePathname();
+ const searchParams = useSearchParams();
+
+ const stepQueryKey = options?.stepQueryKey ?? DEFAULT_STEP_QUERY_KEY;
+
+ const step = searchParams.get(stepQueryKey) as Steps | undefined;
+
+ const createUrl = useCallback(
+ (step: Steps) => {
+ const params = new URLSearchParams(searchParams.toString());
+ params.delete(stepQueryKey);
+ params.set(stepQueryKey, step);
+
+ return `${pathname}?${params.toString()}`;
+ },
+ [searchParams, stepQueryKey],
+ );
+
+ const setStep = useCallback(
+ (step: Steps) => {
+ router.push(createUrl(step));
+ },
+ [searchParams, createUrl],
+ );
+
+ useEffect(() => {
+ setStep(step ?? defaultStep);
+ }, [defaultStep, step, setStep]);
+
+ const Step = ({ name, children }: React.PropsWithChildren<{ name: Steps }>) => {
+ return <>{children}>;
+ };
+
+ const FunnelRoot = ({ children }: React.PropsWithChildren) => {
+ const targetStep = React.Children.toArray(children).find((childStep) => {
+ return React.isValidElement(childStep) && childStep.props.name === step;
+ });
+
+ return <>{targetStep}>;
+ };
+
+ const Funnel = Object.assign(FunnelRoot, { Step });
+
+ return { Funnel, setStep };
+}
diff --git a/app/mocks/browser.mock.ts b/app/mocks/browser.mock.ts
index 78b6a76b..0a564278 100644
--- a/app/mocks/browser.mock.ts
+++ b/app/mocks/browser.mock.ts
@@ -1,4 +1,4 @@
import { setupWorker } from 'msw/browser';
-import { handlers } from './handlers.mock';
+import { handlers } from './handlers';
export const worker = setupWorker(...handlers);
diff --git a/app/mocks/db.mock.ts b/app/mocks/db.mock.ts
new file mode 100644
index 00000000..1c7788b4
--- /dev/null
+++ b/app/mocks/db.mock.ts
@@ -0,0 +1,56 @@
+import { TakenLectures } from '../business/lecture/taken-lecture.query';
+import { SignUpRequestBody } from '../business/user/user.type';
+import { takenLectures } from './data.mock';
+
+interface MockUser {
+ authId: string;
+ password: string;
+ studentNumber: string;
+ engLv: string;
+ major?: string;
+}
+
+interface MockDatabaseState {
+ takenLectures: TakenLectures[];
+ users: MockUser[];
+}
+
+type MockDatabaseAction = {
+ getTakenLectures: () => TakenLectures[];
+ getUser: (authId: string) => MockUser | undefined;
+ createUser: (user: MockUser) => boolean;
+};
+
+export const mockDatabase: MockDatabaseAction = {
+ getTakenLectures: () => mockDatabaseStore.takenLectures,
+ getUser: (authId: string) => mockDatabaseStore.users.find((user) => user.authId === authId),
+ createUser: (user: SignUpRequestBody) => {
+ if (mockDatabaseStore.users.find((u) => u.authId === user.authId || u.studentNumber === user.studentNumber)) {
+ return false;
+ }
+ mockDatabaseStore.users = [...mockDatabaseStore.users, user];
+ return true;
+ },
+};
+
+const initialState: MockDatabaseState = {
+ takenLectures: [takenLectures],
+ users: [
+ {
+ authId: 'admin',
+ password: 'admin',
+ studentNumber: '60000000',
+ engLv: 'ENG12',
+ },
+ ],
+};
+
+function initStore(): MockDatabaseState {
+ return JSON.parse(JSON.stringify(initialState));
+}
+
+export let mockDatabaseStore = initStore();
+
+export const resetMockDB = () => {
+ mockDatabaseStore = initStore();
+};
diff --git a/app/mocks/handlers/index.ts b/app/mocks/handlers/index.ts
new file mode 100644
index 00000000..9c0a203a
--- /dev/null
+++ b/app/mocks/handlers/index.ts
@@ -0,0 +1,4 @@
+import { takenLectureHandlers } from './taken-lecture-handler.mock';
+import { userHandlers } from './user-handler.mock';
+
+export const handlers = [...userHandlers, ...takenLectureHandlers];
diff --git a/app/mocks/handlers.mock.ts b/app/mocks/handlers/taken-lecture-handler.mock.ts
similarity index 54%
rename from app/mocks/handlers.mock.ts
rename to app/mocks/handlers/taken-lecture-handler.mock.ts
index b4bd6cca..7cb6f787 100644
--- a/app/mocks/handlers.mock.ts
+++ b/app/mocks/handlers/taken-lecture-handler.mock.ts
@@ -1,14 +1,14 @@
import { HttpResponse, http, delay } from 'msw';
-import { revenue, parsePDF, takenLectures } from './data.mock';
-import { API_PATH } from '../business/api-path';
+import { API_PATH } from '../../business/api-path';
+import { mockDatabase } from '../db.mock';
+import { parsePDF } from '../data.mock';
-export const handlers = [
- http.get(API_PATH.revenue, async () => {
- await delay(1000);
- console.log(revenue);
- return HttpResponse.json(revenue);
- }),
+export const takenLectureHandlers = [
+ http.get(API_PATH.takenLectures, () => {
+ const takenLectures = mockDatabase.getTakenLectures();
+ return HttpResponse.json(takenLectures[0]);
+ }),
http.post(API_PATH.parsePDFtoText, async () => {
await delay(1000);
console.log(parsePDF);
@@ -19,8 +19,4 @@ export const handlers = [
await delay(1000);
throw new HttpResponse(null, { status: 200 });
}),
-
- http.get(API_PATH.takenLectures, () => {
- return HttpResponse.json(takenLectures);
- }),
];
diff --git a/app/mocks/handlers/user-handler.mock.ts b/app/mocks/handlers/user-handler.mock.ts
new file mode 100644
index 00000000..c1e77efc
--- /dev/null
+++ b/app/mocks/handlers/user-handler.mock.ts
@@ -0,0 +1,19 @@
+import { HttpResponse, http, delay } from 'msw';
+import { API_PATH } from '../../business/api-path';
+import { mockDatabase } from '../db.mock';
+import { SignUpRequestBody } from '@/app/business/user/user.type';
+
+export const userHandlers = [
+ http.post(`${API_PATH.user}/sign-up`, async ({ request }) => {
+ const userData = await request.json();
+
+ const isSuccess = mockDatabase.createUser(userData);
+ await delay(500);
+
+ if (!isSuccess) {
+ return HttpResponse.json({ status: 400, message: '이미 가입된 학번입니다.' }, { status: 400 });
+ }
+
+ return HttpResponse.json({ status: 200 });
+ }),
+];
diff --git a/app/mocks/http.ts b/app/mocks/http.ts
index a53a6704..5a2058ab 100644
--- a/app/mocks/http.ts
+++ b/app/mocks/http.ts
@@ -4,7 +4,7 @@
import express from 'express';
import { createMiddleware } from '@mswjs/http-middleware';
-import { handlers } from './handlers.mock';
+import { handlers } from './handlers';
const app = express();
const port = 9090;
diff --git a/app/mocks/server.mock.ts b/app/mocks/server.mock.ts
index bf4bd299..e52fee0a 100644
--- a/app/mocks/server.mock.ts
+++ b/app/mocks/server.mock.ts
@@ -1,4 +1,4 @@
import { setupServer } from 'msw/node';
-import { handlers } from './handlers.mock';
+import { handlers } from './handlers';
export const server = setupServer(...handlers);
diff --git a/app/ui/user/sign-up-form/sign-up-form.stories.tsx b/app/ui/user/sign-up-form/sign-up-form.stories.tsx
new file mode 100644
index 00000000..8b48d133
--- /dev/null
+++ b/app/ui/user/sign-up-form/sign-up-form.stories.tsx
@@ -0,0 +1,97 @@
+import type { Meta, StoryObj } from '@storybook/react';
+
+import SignUpForm from './sign-up-form';
+
+import { userEvent, within, expect, fn, waitFor } from '@storybook/test';
+import { resetMockDB } from '@/app/mocks/db.mock';
+
+const meta = {
+ title: 'ui/user/SignUpForm',
+ component: SignUpForm,
+ args: {
+ onNext: fn(),
+ },
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+} as Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const SuccessSenario: Story = {
+ play: async ({ args, canvasElement, step }) => {
+ resetMockDB();
+ const canvas = within(canvasElement);
+
+ await step('사용자가 양식에 맞춰서 폼을 입력하면', async () => {
+ await userEvent.type(canvas.getByLabelText('아이디'), 'testtest');
+ await userEvent.type(canvas.getByLabelText('비밀번호'), 'test1234!');
+ await userEvent.type(canvas.getByLabelText('비밀번호 확인'), 'test1234!');
+ await userEvent.type(canvas.getByLabelText('학번'), '60000001');
+ await userEvent.selectOptions(canvas.getByLabelText('영어'), 'basic');
+
+ await userEvent.click(canvas.getByText('회원가입'));
+ });
+
+ await step('회원가입에 성공한다', async () => {
+ await waitFor(() => expect(args.onNext).toHaveBeenCalled());
+ });
+ },
+};
+
+export const FailureSenarioWithValidation: Story = {
+ play: async ({ args, canvasElement, step }) => {
+ resetMockDB();
+ const canvas = within(canvasElement);
+
+ await step('사용자가 양식에 맞춰서 폼을 입력하지 않으면', async () => {
+ await userEvent.type(canvas.getByLabelText('아이디'), 'test');
+ await userEvent.type(canvas.getByLabelText('비밀번호'), 'test1234');
+ await userEvent.type(canvas.getByLabelText('비밀번호 확인'), 'test1234!');
+ await userEvent.type(canvas.getByLabelText('학번'), '600000');
+ await userEvent.selectOptions(canvas.getByLabelText('영어'), 'basic');
+
+ await userEvent.click(canvas.getByText('회원가입'));
+ });
+
+ await step('유효성 검사에 실패한다.', async () => {
+ await waitFor(() => {
+ expect(args.onNext).not.toHaveBeenCalled();
+ expect(canvas.getByText('양식에 맞춰 다시 입력해주세요.')).toBeInTheDocument();
+ expect(canvas.getByText('아이디는 6자 이상 20자 이하여야 합니다.')).toBeInTheDocument();
+ expect(canvas.getByText('비밀번호는 문자, 숫자, 특수문자(!@#$%^&*)를 포함해야 합니다.')).toBeInTheDocument();
+ expect(canvas.getByText('비밀번호가 일치하지 않습니다.')).toBeInTheDocument();
+ expect(canvas.getByText('학번은 8자리여야 합니다.')).toBeInTheDocument();
+ });
+ });
+ },
+};
+
+export const FailureSenarioWithDuplicatedStudentNumber: Story = {
+ play: async ({ args, canvasElement, step }) => {
+ resetMockDB();
+ const canvas = within(canvasElement);
+
+ await step('사용자가 중복된 학번으로 회원가입을 시도하면', async () => {
+ await userEvent.type(canvas.getByLabelText('아이디'), 'testtest');
+ await userEvent.type(canvas.getByLabelText('비밀번호'), 'test1234!');
+ await userEvent.type(canvas.getByLabelText('비밀번호 확인'), 'test1234!');
+ await userEvent.type(canvas.getByLabelText('학번'), '60000000');
+ await userEvent.selectOptions(canvas.getByLabelText('영어'), 'basic');
+
+ await userEvent.click(canvas.getByText('회원가입'));
+ });
+
+ await step('회원가입에 실패한다.', async () => {
+ await waitFor(() => {
+ expect(args.onNext).not.toHaveBeenCalled();
+ expect(canvas.getByText('이미 가입된 학번입니다.')).toBeInTheDocument();
+ });
+ });
+ },
+};
diff --git a/app/ui/user/sign-up-form/sign-up-form.tsx b/app/ui/user/sign-up-form/sign-up-form.tsx
new file mode 100644
index 00000000..65cc4f4d
--- /dev/null
+++ b/app/ui/user/sign-up-form/sign-up-form.tsx
@@ -0,0 +1,36 @@
+'use client';
+import { createUser } from '@/app/business/user/user.command';
+import Form from '../../view/molecule/form';
+
+interface SignUpFormProps {
+ onNext?: () => void;
+}
+
+export default function SignUpForm({ onNext }: SignUpFormProps) {
+ return (
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/ui/view/atom/alert.tsx b/app/ui/view/atom/alert.tsx
new file mode 100644
index 00000000..c35ccaa3
--- /dev/null
+++ b/app/ui/view/atom/alert.tsx
@@ -0,0 +1,44 @@
+import * as React from 'react';
+import { cva, type VariantProps } from 'class-variance-authority';
+
+import { cn } from '@/app/utils/shadcn/utils';
+
+const alertVariants = cva(
+ 'relative w-full rounded-lg border border-slate-200 px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-slate-950 [&>svg~*]:pl-7 dark:border-slate-800 dark:[&>svg]:text-slate-50',
+ {
+ variants: {
+ variant: {
+ default: 'bg-white text-slate-950 dark:bg-slate-950 dark:text-slate-50',
+ destructive:
+ 'border-red-500/50 text-red-500 dark:border-red-500 [&>svg]:text-red-500 dark:border-red-900/50 dark:text-red-900 dark:dark:border-red-900 dark:[&>svg]:text-red-900',
+ },
+ },
+ defaultVariants: {
+ variant: 'default',
+ },
+ },
+);
+
+const Alert = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes & VariantProps
+>(({ className, variant, ...props }, ref) => (
+
+));
+Alert.displayName = 'Alert';
+
+const AlertTitle = React.forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ ),
+);
+AlertTitle.displayName = 'AlertTitle';
+
+const AlertDescription = React.forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ ),
+);
+AlertDescription.displayName = 'AlertDescription';
+
+export { Alert, AlertTitle, AlertDescription };
diff --git a/app/ui/view/molecule/alert-destructive/alert-destructive.stories.tsx b/app/ui/view/molecule/alert-destructive/alert-destructive.stories.tsx
new file mode 100644
index 00000000..cbcc750e
--- /dev/null
+++ b/app/ui/view/molecule/alert-destructive/alert-destructive.stories.tsx
@@ -0,0 +1,24 @@
+import type { Meta, StoryObj } from '@storybook/react';
+
+import AlertDestructive from './alert-destructive';
+
+const meta = {
+ title: 'ui/view/molecule/AlertDestructive',
+ component: AlertDestructive,
+} as Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {
+ description: 'This is a destructive alert',
+ },
+};
+
+export const WithTitle: Story = {
+ args: {
+ title: 'Destructive alert',
+ description: 'This is a destructive alert',
+ },
+};
diff --git a/app/ui/view/molecule/alert-destructive/alert-destructive.tsx b/app/ui/view/molecule/alert-destructive/alert-destructive.tsx
new file mode 100644
index 00000000..c370d3b3
--- /dev/null
+++ b/app/ui/view/molecule/alert-destructive/alert-destructive.tsx
@@ -0,0 +1,17 @@
+import { ExclamationTriangleIcon } from '@heroicons/react/24/outline';
+import { Alert, AlertTitle, AlertDescription } from '../../atom/alert';
+
+interface AlertDestructiveProps {
+ title?: string;
+ description: string;
+}
+
+export default function AlertDestructive({ title, description }: AlertDestructiveProps) {
+ return (
+
+
+ {title}
+ {description}
+
+ );
+}
diff --git a/app/ui/view/molecule/form/form-root.tsx b/app/ui/view/molecule/form/form-root.tsx
index 18478df2..d578de16 100644
--- a/app/ui/view/molecule/form/form-root.tsx
+++ b/app/ui/view/molecule/form/form-root.tsx
@@ -1,12 +1,16 @@
-import React from 'react';
+'use client';
+import React, { useEffect } from 'react';
import { useFormState } from 'react-dom';
import { FormSubmitButton } from './form-submit-button';
import { FormContext } from './form.context';
import { filterChildrenByType } from '@/app/utils/component.util';
+import AlertDestructive from '../alert-destructive/alert-destructive';
export interface FormState {
+ isSuccess: boolean;
+ isFailure: boolean;
message: string | null;
- errors: Record;
+ validationError: Record;
}
const getFormSubmitButton = (children: React.ReactNode) => {
@@ -15,13 +19,20 @@ const getFormSubmitButton = (children: React.ReactNode) => {
interface FormRootProps {
id: string;
+ onSuccess?: () => void;
action: (prevState: FormState, formData: FormData) => Promise | FormState;
}
-export function FormRoot({ id, action, children }: React.PropsWithChildren) {
- const initialState: FormState = { message: null, errors: {} };
+export function FormRoot({ id, action, onSuccess, children }: React.PropsWithChildren) {
+ const initialState: FormState = { isSuccess: false, isFailure: false, message: null, validationError: {} };
const [formState, dispatch] = useFormState(action, initialState);
+ useEffect(() => {
+ if (formState.isSuccess) {
+ onSuccess?.();
+ }
+ }, [formState]);
+
const formSubmitButton = getFormSubmitButton(children);
const renderWithoutSubmitButton = () => {
@@ -37,7 +48,12 @@ export function FormRoot({ id, action, children }: React.PropsWithChildren
+
+ {formState.isFailure ? (
+
+ ) : null}