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
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>
);
}
9 changes: 9 additions & 0 deletions apps/web/src/services/authApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { apiClient } from "./apiClient";
import type { AuthUser, SignupRequest } from "../types/auth";

export function signup(payload: SignupRequest) {
return apiClient<AuthUser>("/auth/signup", {
method: "POST",
body: payload,
});
}
20 changes: 11 additions & 9 deletions apps/web/src/stores/userStore.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,37 @@
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) {
return 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 {
Expand Down
Loading
Loading