Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
31 changes: 25 additions & 6 deletions app/_components/BubbleDiv.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,33 @@
import Image from "next/image";
import React from "react";

const BubbleDiv = () => {
const BubbleDiv = ({
children,
w = 226,
h = 41.77,
typo = "typo-16-600",
top = 1,
}: {
children?: React.ReactNode;
w?: number;
h?: number;
typo?: string;
top?: number;
}) => {
return (
<div className="relative h-[41.77px] w-[226px]">
<span className="typo-16-600 absolute top-1 left-0 z-20 w-full text-center text-black">
현재 <span className="text-bubble-text-highight">775명 </span>
참여중이에요!
<div className="relative" style={{ width: `${w}px`, height: `${h}px` }}>
<span
className={`${typo} absolute left-0 z-20 w-full text-center text-black`}
style={{ top: `${top}px` }}
>
{children || (
<>
현재 <span className="text-bubble-text-highight">775명 </span>
참여중이에요!
</>
)}
</span>
<Image src="/bubble/bubble.svg" alt="bubble" width={226} height={41.77} />
<Image src="/bubble/bubble.svg" alt="말풍선" width={w} height={h} />
</div>
);
};
Expand Down
8 changes: 4 additions & 4 deletions app/_components/LoginActionSection.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
"use client";
import BubbleDiv from "@/app/_components/BubbleDiv";
import Button from "@/components/ui/Button";
import { KakaoLoginButton, GoogleLoginButton } from "./SocialButtonList";
import {
Drawer,
DrawerContent,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
import Link from "next/link";

export default function ScreenLoginActionSection() {
const handleKakaoLogin = () => {
Expand Down Expand Up @@ -65,12 +65,12 @@ export default function ScreenLoginActionSection() {
</GoogleLoginButton>
<div className="typo-14-500 text-bottomsheet-text-caption mt-6 flex flex-col items-center">
<span>혹은</span>
<button
type="button"
<Link
href="/login"
className="all-[unset] cursor-pointer underline"
>
이메일로 로그인
</button>
</Link>
</div>
</DrawerContent>
</Drawer>
Expand Down
17 changes: 17 additions & 0 deletions app/login/_components/LocalLoginIntro.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import React from "react";

const LocalLoginIntro = () => {
return (
<section className="mt-6 flex flex-col items-start gap-2">
<span className="typo-26-700 leading-[1.4] text-black">
이메일로
<br /> 코매칭 시작하기
</span>
<span className="typo-16-500 text-color-text-caption2">
로그인하거나 새로운 계정을 만들어보세요.
</span>
</section>
);
};

export default LocalLoginIntro;
85 changes: 85 additions & 0 deletions app/login/_components/LoginForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
"use client";
import BubbleDiv from "@/app/_components/BubbleDiv";
import Button from "@/components/ui/Button";
import { User } from "lucide-react";
import React, { useActionState } from "react";
import { loginAction } from "@/lib/actions/loginAction";

const INPUT_STYLE = {
background:
"linear-gradient(180deg, rgba(248, 248, 248, 0.03) 0%, rgba(248, 248, 248, 0.24) 100%)",
};
const INPUT_CLASSNAME =
"all:unset box-border w-full border-b border-gray-300 px-2 py-[14.5px] leading-[19px] placeholder:text-[#B3B3B3]";

export const LoginForm = () => {
// React 19: useActionState로 폼 상태 및 팬딩 처리 관리
const [state, formAction, isPending] = useActionState(loginAction, {
success: false,
message: "",
});

return (
<section className="mt-10 flex w-full flex-1 flex-col items-start gap-6">
<form className="flex w-full flex-col gap-4" action={formAction}>
<div className="flex w-full flex-col gap-2">
<label htmlFor="email" className="typo-14-500 text-gray-700">
아이디(이메일)
</label>
<input
id="email"
type="email"
name="email"
placeholder="이메일 입력"
required
autoComplete="email"
className={INPUT_CLASSNAME}
style={INPUT_STYLE}
/>
</div>

<div className="relative mb-6 flex w-full flex-col gap-2">
<label htmlFor="password" className="typo-14-500 text-gray-700">
비밀번호
</label>
<input
id="password"
type="password"
name="password"
placeholder="비밀번호 입력"
required
autoComplete="current-password"
className={INPUT_CLASSNAME}
style={INPUT_STYLE}
/>
{!state.success && (
<span className="typo-12-400 text-color-flame-700 absolute bottom-[-25px] left-0">
* 이메일 혹은 비밀번호가 틀립니다
</span>
)}
</div>
<Button shadow={true} type="submit" disabled={isPending}>
{isPending ? "로그인 중..." : "로그인"}
</Button>
</form>
<div className="typo-14-500 text-color-text-caption2 flex w-full justify-center">
<button type="button">이메일 찾기</button>
<span className="mx-4">|</span>
<button type="button">비밀번호 변경</button>
</div>

<div className="mt-auto flex w-full flex-col items-center gap-4">
<BubbleDiv w={162} h={26} typo="typo-12-600" top={3}>
아직 계정이 없으신가요?!
</BubbleDiv>
<button
type="button"
className="typo-14-500 flex items-center gap-1 border-b-2 border-gray-500 text-gray-500"
>
<User />
이메일로 회원가입
</button>
</div>
</section>
);
};
16 changes: 16 additions & 0 deletions app/login/_components/ScreenLocalLoginPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import React from "react";
import { BackButton } from "@/components/ui/BackButton";
import LocalLoginIntro from "./LocalLoginIntro";
import { LoginForm } from "./LoginForm";

const ScreenLocalLoginPage = () => {
return (
<main className="flex h-dvh flex-col items-start px-4 pt-2 pb-[6.2vh]">
<BackButton />
<LocalLoginIntro />
<LoginForm />
</main>
);
};

export default ScreenLocalLoginPage;
14 changes: 14 additions & 0 deletions app/login/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import React from "react";
import { Metadata } from "next";
import ScreenLocalLoginPage from "./_components/ScreenLocalLoginPage";

export const metadata: Metadata = {
title: "로그인 | COMAtching",
description: "COMAtching 로그인 페이지",
};

const LoginPage = () => {
return <ScreenLocalLoginPage />;
};

export default LoginPage;
36 changes: 36 additions & 0 deletions components/ui/BackButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"use client";
import React from "react";
import { useRouter } from "next/navigation";
import { ChevronLeft } from "lucide-react";
import { cn } from "@/lib/utils";

type BackButtonProps = {
variant?: "absolute" | "static";
className?: string;
onClick?: () => void;
};

export const BackButton = ({
variant = "static",
className = "",
onClick,
}: BackButtonProps) => {
const router = useRouter();
const positionClass = variant === "absolute" ? "absolute left-4 top-2" : "";
const handleClick = onClick ?? (() => router.back());

return (
<button
type="button"
onClick={handleClick}
aria-label="뒤로 가기"
className={cn(
"flex h-12 w-12 cursor-pointer items-center justify-center rounded-full border border-[#FFFFFF]/30 bg-[#FFFFFF]/50 [box-shadow:0px_4px_8px_rgba(0,0,0,0.08),0px_0px_16px_rgba(0,0,0,0.1)] backdrop-blur-[15px]",
positionClass,
className,
)}
>
<ChevronLeft className="text-[#282828]" size={20} />
</button>
);
};
2 changes: 1 addition & 1 deletion components/ui/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export default function Button({
disabled={disabled}
className={cn(
// 기본 스타일 (기본 높이 h-12, 너비 w-full, 폰트 등 복구)
"typo-20-600 text-button-primary-text-default flex h-12 w-full items-center justify-center rounded-[12px] transition-colors duration-100",
"typo-20-600 text-button-primary-text-default bg-button-primary flex h-12 w-full items-center justify-center rounded-[16px] transition-colors duration-100",
fixed && "fixed z-50 mx-auto",
disabled ? "cursor-not-allowed" : "cursor-pointer",
// 1. 사용자 className 먼저 적용 (여기서 h-10 등을 넣으면 위 h-12가 덮어씌워짐)
Expand Down
31 changes: 31 additions & 0 deletions lib/actions/loginAction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"use server";

type LoginState = {
success: boolean;
message: string;
};

export async function loginAction(
prevState: LoginState,
formData: FormData,
): Promise<LoginState> {
const email = formData.get("email");
const password = formData.get("password");
Comment on lines +12 to +13
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

현재 서버 액션에서 formData로부터 이메일과 비밀번호를 받은 후 바로 사용하고 있습니다. 보안과 안정성을 위해 서버 측에서도 반드시 입력값에 대한 유효성 검사를 수행해야 합니다. 예를 들어, 이메일 형식이 올바른지, 비밀번호가 비어있지 않은지 등을 확인하는 로직이 필요합니다. zod와 같은 라이브러리를 사용하면 스키마 기반으로 간편하게 유효성 검사를 추가할 수 있습니다.

예시 (zod 사용):

import { z } from "zod";

const loginSchema = z.object({
  email: z.string().email({ message: "올바른 이메일 형식이 아닙니다." }),
  password: z.string().min(1, { message: "비밀번호를 입력해주세요." }),
});

export async function loginAction(...) {
  const validatedFields = loginSchema.safeParse({
    email: formData.get("email"),
    password: formData.get("password"),
  });

  if (!validatedFields.success) {
    return {
      success: false,
      message: validatedFields.error.errors[0].message,
    };
  }

  const { email, password } = validatedFields.data;
  // ... 이후 로직
}
References
  1. 사용자 입력은 클라이언트뿐만 아니라 서버 측에서도 반드시 검증해야 합니다. 현재 서버 액션에 입력값 유효성 검사가 누락되어 있습니다. (link)


// Mock delay
await new Promise((resolve) => setTimeout(resolve, 1000));

// TODO: 실제 백엔드 API 호출로 교체 필요
// const response = await fetch("https://api.comatching.com/login", {
// method: "POST",
// body: JSON.stringify({ email, password }),
// headers: { "Content-Type": "application/json" },
// });

// Mock logic
if (email === "test@test.com" && password === "1234") {
return { success: true, message: "" };
}

return { success: false, message: "이메일 혹은 비밀번호가 틀립니다" };
}
Loading