diff --git a/.github/auto_assign.yml b/.github/auto_assign.yml new file mode 100644 index 0000000..c757ebc --- /dev/null +++ b/.github/auto_assign.yml @@ -0,0 +1,16 @@ +# Automatically assign the pull request author as assignee. +addAssignees: author + +# Reviewer auto-assignment is disabled for this solo project. +addReviewers: false + +# Keep reviewers empty unless external reviewers are added later. +reviewers: [] + +# Skip auto assignment for draft-like PR titles. +skipKeywords: + - wip + - draft + +# 0 means all configured reviewers, but reviewers are disabled above. +numberOfReviewers: 0 \ No newline at end of file diff --git a/.github/workflows/auto-assign-reviewers.yml b/.github/workflows/auto-assign-reviewers.yml new file mode 100644 index 0000000..13f345e --- /dev/null +++ b/.github/workflows/auto-assign-reviewers.yml @@ -0,0 +1,23 @@ +name: Auto Assign PR + +on: + pull_request: + types: + - opened + - ready_for_review + - reopened + +permissions: + pull-requests: write + issues: write + +jobs: + auto-assign: + name: Auto assign PR author + runs-on: ubuntu-latest + + steps: + - name: Auto assign pull request + uses: kentaro-m/auto-assign-action@v2.0.0 + with: + configuration-path: .github/auto_assign.yml \ No newline at end of file diff --git a/apps/web/src/components/layout/MainLayout.tsx b/apps/web/src/components/layout/MainLayout.tsx index e1a8c1e..6c506bb 100644 --- a/apps/web/src/components/layout/MainLayout.tsx +++ b/apps/web/src/components/layout/MainLayout.tsx @@ -1,8 +1,28 @@ +import { useEffect, useState } from "react"; import { Link, Outlet } from "react-router-dom"; -import { clearStoredUser, getStoredUser } from "../../stores/userStore"; +import { + clearStoredUser, + getStoredUser, + USER_STORAGE_EVENT, +} from "../../stores/userStore"; +import type { AuthUser } from "../../types/auth"; export function MainLayout() { - const user = getStoredUser(); + const [user, setUser] = useState(() => getStoredUser()); + + useEffect(() => { + const syncUser = () => { + setUser(getStoredUser()); + }; + + window.addEventListener(USER_STORAGE_EVENT, syncUser); + window.addEventListener("storage", syncUser); + + return () => { + window.removeEventListener(USER_STORAGE_EVENT, syncUser); + window.removeEventListener("storage", syncUser); + }; + }, []); const handleLogout = () => { clearStoredUser(); diff --git a/apps/web/src/features/auth/LoginPage.tsx b/apps/web/src/features/auth/LoginPage.tsx index 8d3a8f7..c9beffc 100644 --- a/apps/web/src/features/auth/LoginPage.tsx +++ b/apps/web/src/features/auth/LoginPage.tsx @@ -1,8 +1,142 @@ +import { type FormEvent, useEffect, useState } from "react"; +import { Link, useNavigate } from "react-router-dom"; +import { login } from "../../services/authApi"; +import { getStoredUser, setStoredUser } from "../../stores/userStore"; + +type LoginFormState = { + email: string; + password: string; +}; + +const initialFormState: LoginFormState = { + email: "", + password: "", +}; + +function validateLoginForm(form: LoginFormState) { + if (!form.email.trim()) { + return "이메일을 입력해주세요."; + } + + if (!form.email.includes("@")) { + return "올바른 이메일 형식으로 입력해주세요."; + } + + if (!form.password.trim()) { + return "비밀번호를 입력해주세요."; + } + + return null; +} + export function LoginPage() { + const navigate = useNavigate(); + + const [form, setForm] = useState(initialFormState); + const [errorMessage, setErrorMessage] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + useEffect(() => { + const storedUser = getStoredUser(); + + if (storedUser) { + navigate("/products", { replace: true }); + } + }, [navigate]); + + const handleInputChange = (field: keyof LoginFormState, value: string) => { + setForm((currentForm) => ({ + ...currentForm, + [field]: value, + })); + }; + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + + const validationMessage = validateLoginForm(form); + + if (validationMessage) { + setErrorMessage(validationMessage); + setSuccessMessage(null); + return; + } + + try { + setIsSubmitting(true); + setErrorMessage(null); + setSuccessMessage(null); + + const loggedInUser = await login({ + email: form.email.trim(), + password: form.password, + }); + + setStoredUser(loggedInUser); + setSuccessMessage("로그인이 완료되었습니다. 상품 목록으로 이동합니다."); + + setTimeout(() => { + navigate("/products"); + }, 600); + } catch { + setErrorMessage("로그인에 실패했습니다. 이메일 또는 비밀번호를 확인해주세요."); + } finally { + setIsSubmitting(false); + } + }; + return ( -
-

로그인

-

D2C-36에서 로그인 폼과 사용자 상태 저장 흐름을 구현합니다.

+
+
+
+

Sign In

+

로그인

+

+ 기존 계정으로 로그인하여 장바구니, 주문, 리뷰 흐름을 이어서 검증합니다. +

+
+ +
+ + + + + {errorMessage &&
{errorMessage}
} + {successMessage &&
{successMessage}
} + + +
+ +
+ 아직 계정이 없으신가요? + 회원가입 +
+
); } \ No newline at end of file diff --git a/apps/web/src/features/auth/SignupPage.tsx b/apps/web/src/features/auth/SignupPage.tsx index d111981..bd725b1 100644 --- a/apps/web/src/features/auth/SignupPage.tsx +++ b/apps/web/src/features/auth/SignupPage.tsx @@ -1,8 +1,172 @@ +import { type FormEvent, useEffect, useState } from "react"; +import { Link, useNavigate } from "react-router-dom"; +import { signup } from "../../services/authApi"; +import { getStoredUser, setStoredUser } from "../../stores/userStore"; + +type SignupFormState = { + email: string; + password: string; + user_name: string; + marketing_opt_in_yn: boolean; +}; + +const initialFormState: SignupFormState = { + email: "", + password: "", + user_name: "", + marketing_opt_in_yn: true, +}; + +function validateSignupForm(form: SignupFormState) { + if (!form.user_name.trim()) { + return "이름을 입력해주세요."; + } + + if (!form.email.trim()) { + return "이메일을 입력해주세요."; + } + + if (!form.email.includes("@")) { + return "올바른 이메일 형식으로 입력해주세요."; + } + + if (!form.password.trim()) { + return "비밀번호를 입력해주세요."; + } + + if (form.password.length < 8) { + return "비밀번호는 8자 이상 입력해주세요."; + } + + return null; +} + export function SignupPage() { + const navigate = useNavigate(); + + const [form, setForm] = useState(initialFormState); + const [errorMessage, setErrorMessage] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + useEffect(() => { + const storedUser = getStoredUser(); + + if (storedUser) { + navigate("/products", { replace: true }); + } + }, [navigate]); + + const handleInputChange = (field: keyof SignupFormState, value: string | boolean) => { + setForm((currentForm) => ({ + ...currentForm, + [field]: value, + })); + }; + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + + const validationMessage = validateSignupForm(form); + + if (validationMessage) { + setErrorMessage(validationMessage); + setSuccessMessage(null); + return; + } + + try { + setIsSubmitting(true); + setErrorMessage(null); + setSuccessMessage(null); + + const createdUser = await signup({ + email: form.email.trim(), + password: form.password, + user_name: form.user_name.trim(), + marketing_opt_in_yn: form.marketing_opt_in_yn, + }); + + setStoredUser(createdUser); + setSuccessMessage("회원가입이 완료되었습니다. 상품 목록으로 이동합니다."); + + setTimeout(() => { + navigate("/products"); + }, 600); + } catch { + setErrorMessage("회원가입에 실패했습니다. 입력 정보를 확인한 뒤 다시 시도해주세요."); + } finally { + setIsSubmitting(false); + } + }; + return ( -
-

회원가입

-

D2C-35에서 회원가입 폼과 사용자 생성 API를 연동합니다.

+
+
+
+

Create Account

+

회원가입

+

+ D2C Commerce Prototype의 상품 탐색, 장바구니, 주문, 리뷰 흐름을 + 검증하기 위한 사용자 계정을 생성합니다. +

+
+ +
+ + + + + + + + + {errorMessage &&
{errorMessage}
} + {successMessage &&
{successMessage}
} + + +
+ +
+ 이미 계정이 있으신가요? + 로그인 +
+
); } \ No newline at end of file diff --git a/apps/web/src/services/authApi.ts b/apps/web/src/services/authApi.ts new file mode 100644 index 0000000..3022f27 --- /dev/null +++ b/apps/web/src/services/authApi.ts @@ -0,0 +1,16 @@ +import { apiClient } from "./apiClient"; +import type { AuthUser, LoginRequest, SignupRequest } from "../types/auth"; + +export function signup(payload: SignupRequest) { + return apiClient("/auth/signup", { + method: "POST", + body: payload, + }); +} + +export function login(payload: LoginRequest) { + return apiClient("/auth/login", { + method: "POST", + body: payload, + }); +} \ No newline at end of file diff --git a/apps/web/src/stores/userStore.ts b/apps/web/src/stores/userStore.ts index 09d3434..481ff4c 100644 --- a/apps/web/src/stores/userStore.ts +++ b/apps/web/src/stores/userStore.ts @@ -1,14 +1,14 @@ +import type { AuthUser } from "../types/auth"; + const USER_STORAGE_KEY = "d2c_user"; const CART_STORAGE_KEY = "d2c_cart_id"; +export const USER_STORAGE_EVENT = "d2c_user_changed"; -export type StoredUser = { - user_id: string; - email: string; - user_name: string; - user_status: string; -}; +function notifyUserChanged() { + window.dispatchEvent(new Event(USER_STORAGE_EVENT)); +} -export function getStoredUser(): StoredUser | null { +export function getStoredUser(): AuthUser | null { const raw = localStorage.getItem(USER_STORAGE_KEY); if (!raw) { @@ -16,20 +16,22 @@ export function getStoredUser(): StoredUser | null { } try { - return JSON.parse(raw) as StoredUser; + return JSON.parse(raw) as AuthUser; } catch { localStorage.removeItem(USER_STORAGE_KEY); return null; } } -export function setStoredUser(user: StoredUser) { +export function setStoredUser(user: AuthUser) { localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(user)); + notifyUserChanged(); } export function clearStoredUser() { localStorage.removeItem(USER_STORAGE_KEY); localStorage.removeItem(CART_STORAGE_KEY); + notifyUserChanged(); } export function getStoredCartId(): string | null { diff --git a/apps/web/src/styles/global.css b/apps/web/src/styles/global.css index 01cf004..075a1cd 100644 --- a/apps/web/src/styles/global.css +++ b/apps/web/src/styles/global.css @@ -746,6 +746,109 @@ textarea { background-color: #f0fdf4; } +/* Auth pages */ +.auth-page { + display: flex; + justify-content: center; +} + +.auth-card { + width: min(520px, 100%); + padding: 34px; + border: 1px solid #e5e7eb; + border-radius: 24px; + background-color: #ffffff; +} + +.auth-header { + margin-bottom: 28px; +} + +.auth-header h1 { + margin: 0 0 12px; + color: #111827; + font-size: clamp(30px, 4vw, 42px); + line-height: 1.18; + letter-spacing: -0.04em; +} + +.auth-header p { + margin: 0; + color: #4b5563; + line-height: 1.7; + word-break: keep-all; +} + +.auth-form { + display: flex; + flex-direction: column; + gap: 16px; +} + +.form-field { + display: flex; + flex-direction: column; + gap: 8px; +} + +.form-field span, +.checkbox-field span { + color: #374151; + font-size: 14px; + font-weight: 600; +} + +.form-field input { + min-height: 44px; + padding: 0 14px; + border: 1px solid #d1d5db; + border-radius: 10px; + color: #111827; + background-color: #ffffff; +} + +.form-field input:focus { + outline: 2px solid #111827; + outline-offset: 2px; +} + +.form-field input:-webkit-autofill { + box-shadow: 0 0 0 1000px #ffffff inset; + -webkit-text-fill-color: #111827; +} + +.checkbox-field { + display: flex; + align-items: center; + gap: 10px; + margin-top: 2px; +} + +.checkbox-field input { + width: 16px; + height: 16px; + accent-color: #111827; +} + +.auth-submit-button { + width: 100%; + margin-top: 4px; +} + +.auth-footer { + display: flex; + justify-content: center; + gap: 8px; + margin-top: 22px; + color: #6b7280; + font-size: 14px; +} + +.auth-footer a { + color: #111827; + font-weight: 700; +} + @media (max-width: 900px) { .product-detail-layout { grid-template-columns: 1fr; @@ -782,6 +885,15 @@ textarea { .product-detail-actions .secondary-link { width: 100%; } + + .auth-card { + padding: 26px 22px; + } + + .auth-footer { + align-items: center; + flex-direction: column; + } } /* Responsive layout */ diff --git a/apps/web/src/types/auth.ts b/apps/web/src/types/auth.ts new file mode 100644 index 0000000..78bd2a4 --- /dev/null +++ b/apps/web/src/types/auth.ts @@ -0,0 +1,19 @@ +export type SignupRequest = { + email: string; + password: string; + user_name: string; + marketing_opt_in_yn: boolean; +}; + +export type LoginRequest = { + email: string; + password: string; +} + +export type AuthUser = { + user_id: string; + email: string; + user_name: string; + user_status: string; + marketing_opt_in_yn?: boolean; +}; \ No newline at end of file