Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .github/auto_assign.yml
Original file line number Diff line number Diff line change
@@ -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
23 changes: 23 additions & 0 deletions .github/workflows/auto-assign-reviewers.yml
Original file line number Diff line number Diff line change
@@ -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
24 changes: 22 additions & 2 deletions apps/web/src/components/layout/MainLayout.tsx
Original file line number Diff line number Diff line change
@@ -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<AuthUser | null>(() => 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();
Expand Down
140 changes: 137 additions & 3 deletions apps/web/src/features/auth/LoginPage.tsx
Original file line number Diff line number Diff line change
@@ -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<LoginFormState>(initialFormState);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(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<HTMLFormElement>) => {
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 (
<section className="page-section">
<h1>로그인</h1>
<p>D2C-36에서 로그인 폼과 사용자 상태 저장 흐름을 구현합니다.</p>
<section className="auth-page">
<div className="auth-card">
<div className="auth-header">
<p className="section-eyebrow">Sign In</p>
<h1>로그인</h1>
<p>
기존 계정으로 로그인하여 장바구니, 주문, 리뷰 흐름을 이어서 검증합니다.
</p>
</div>

<form className="auth-form" onSubmit={handleSubmit}>
<label className="form-field">
<span>이메일</span>
<input
type="email"
value={form.email}
placeholder="user@example.com"
autoComplete="email"
onChange={(event) => handleInputChange("email", event.target.value)}
/>
</label>

<label className="form-field">
<span>비밀번호</span>
<input
type="password"
value={form.password}
placeholder="비밀번호 입력"
autoComplete="current-password"
onChange={(event) => handleInputChange("password", event.target.value)}
/>
</label>

{errorMessage && <div className="state-box error">{errorMessage}</div>}
{successMessage && <div className="state-box success">{successMessage}</div>}

<button
type="submit"
className="primary-button auth-submit-button"
disabled={isSubmitting}
>
{isSubmitting ? "로그인 처리 중..." : "로그인"}
</button>
</form>

<div className="auth-footer">
<span>아직 계정이 없으신가요?</span>
<Link to="/signup">회원가입</Link>
</div>
</div>
</section>
);
}
170 changes: 167 additions & 3 deletions apps/web/src/features/auth/SignupPage.tsx
Original file line number Diff line number Diff line change
@@ -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<SignupFormState>(initialFormState);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(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<HTMLFormElement>) => {
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 (
<section className="page-section">
<h1>회원가입</h1>
<p>D2C-35에서 회원가입 폼과 사용자 생성 API를 연동합니다.</p>
<section className="auth-page">
<div className="auth-card">
<div className="auth-header">
<p className="section-eyebrow">Create Account</p>
<h1>회원가입</h1>
<p>
D2C Commerce Prototype의 상품 탐색, 장바구니, 주문, 리뷰 흐름을
검증하기 위한 사용자 계정을 생성합니다.
</p>
</div>

<form className="auth-form" onSubmit={handleSubmit}>
<label className="form-field">
<span>이름</span>
<input
type="text"
value={form.user_name}
placeholder="홍길동"
onChange={(event) => handleInputChange("user_name", event.target.value)}
/>
</label>

<label className="form-field">
<span>이메일</span>
<input
type="email"
value={form.email}
placeholder="user@example.com"
onChange={(event) => handleInputChange("email", event.target.value)}
/>
</label>

<label className="form-field">
<span>비밀번호</span>
<input
type="password"
value={form.password}
placeholder="8자 이상 입력"
onChange={(event) => handleInputChange("password", event.target.value)}
/>
</label>

<label className="checkbox-field">
<input
type="checkbox"
checked={form.marketing_opt_in_yn}
onChange={(event) =>
handleInputChange("marketing_opt_in_yn", event.target.checked)
}
/>
<span>마케팅 정보 수신에 동의합니다.</span>
</label>

{errorMessage && <div className="state-box error">{errorMessage}</div>}
{successMessage && <div className="state-box success">{successMessage}</div>}

<button type="submit" className="primary-button auth-submit-button" disabled={isSubmitting}>
{isSubmitting ? "가입 처리 중..." : "회원가입"}
</button>
</form>

<div className="auth-footer">
<span>이미 계정이 있으신가요?</span>
<Link to="/login">로그인</Link>
</div>
</div>
</section>
);
}
Loading
Loading