diff --git a/src/app/(auth)/layout.tsx b/src/app/(auth)/layout.tsx index 9143d65..a0ace31 100644 --- a/src/app/(auth)/layout.tsx +++ b/src/app/(auth)/layout.tsx @@ -1,3 +1,11 @@ +import { makePageMetadata } from "@/seo/metadata"; + +export const metadata = makePageMetadata({ + title: "PlanMate — 로그인/회원가입", + description: "PlanMate 계정으로 로그인하거나 회원가입하세요.", + canonical: "/login", +}); + export default function AuthLayout({ children }: { children: React.ReactNode }) { return <>{children}; } diff --git a/src/app/(marketing)/layout.tsx b/src/app/(marketing)/layout.tsx index 9c9b387..cc3fb32 100644 --- a/src/app/(marketing)/layout.tsx +++ b/src/app/(marketing)/layout.tsx @@ -1,3 +1,11 @@ +import { makePageMetadata } from "@/seo/metadata"; + +export const metadata = makePageMetadata({ + title: "PlanMate — 맞춤형 데일리 플래너", + description: "원하는 모듈을 조합해 나만의 플래너를 만드는 PlanMate 랜딩 페이지", + canonical: "/", +}); + export default function MarketingLayout({ children }: { children: React.ReactNode }) { return <>{children}; } diff --git a/src/app/(marketing)/page.tsx b/src/app/(marketing)/page.tsx index 8ea7063..3b4885c 100644 --- a/src/app/(marketing)/page.tsx +++ b/src/app/(marketing)/page.tsx @@ -1,16 +1,17 @@ -import { makePageMetadata } from "@/seo/metadata"; +"use client"; + import { Button } from "@/shared/button"; import { FeatureGroupButton } from "@/shared/feature-group-button"; +import { Input } from "@/shared/input"; import { SpecialFeatureCard } from "@/shared/SpecialFeatureCard"; +import { InputStatus } from "@/types/input"; import { Calendar, Laptop, Monitor, Smartphone } from "lucide-react"; - -export const metadata = makePageMetadata({ - title: "PlanMate — 맞춤형 데일리 플래너", - description: "원하는 모듈을 조합해 나만의 플래너를 만드는 PlanMate 랜딩 페이지", - canonical: "/", -}); +import { useState } from "react"; export default function Home() { + const [email, setEmail] = useState(""); + const [status, setStatus] = useState("default"); + return (
{/* Hero CTA */} @@ -50,6 +51,53 @@ export default function Home() { + +
+

에러 상태

+
+ + + {/** status: "default" | "error" */} + setEmail(e.target.value)} + status={status} // ✅ cva와 1:1 + placeholder="이메일을 입력하세요" + aria-describedby={status === "error" ? "email-error" : undefined} + onFocus={() => setStatus("default")} + /> + + {status === "error" && ( +

+ 이메일 형식을 확인하세요. +

+ )} + +
+ + +
+
+
); } diff --git a/src/app/(setup)/layout.tsx b/src/app/(setup)/layout.tsx index e15c360..8eb0fcd 100644 --- a/src/app/(setup)/layout.tsx +++ b/src/app/(setup)/layout.tsx @@ -1,3 +1,11 @@ +import { makePageMetadata } from "@/seo/metadata"; + +export const metadata = makePageMetadata({ + title: "초기 설정 — MyPlanMate", + description: "모듈을 선택하고 배치해 나만의 플래너를 시작하세요.", + canonical: "/setup", +}); + export default function SetupLayout({ children }: { children: React.ReactNode }) { return <>{children}; } diff --git a/src/app/planner/layout.tsx b/src/app/planner/layout.tsx index b15beaf..6d35fae 100644 --- a/src/app/planner/layout.tsx +++ b/src/app/planner/layout.tsx @@ -1,3 +1,14 @@ +import { makePageMetadata } from "@/seo/metadata"; + +export const metadata = { + ...makePageMetadata({ + title: "플래너", + description: "PlanMate 플래너 대시보드", + canonical: "/planner", + }), + robots: { index: false, follow: false }, // 보호 구역은 검색 제외 +}; + export default function PlannerLayout({ children }: { children: React.ReactNode }) { return <>{children}; } diff --git a/src/app/planner/page.tsx b/src/app/planner/page.tsx index 36904ea..897bf32 100644 --- a/src/app/planner/page.tsx +++ b/src/app/planner/page.tsx @@ -1,14 +1,3 @@ -import { makePageMetadata } from "@/seo/metadata"; - -export const metadata = { - ...makePageMetadata({ - title: "플래너", - description: "PlanMate 플래너 대시보드", - canonical: "/planner", - }), - robots: { index: false, follow: false }, // 보호 구역은 검색 제외 -}; - export default function PlannerPage() { return
플래너 대시보드
; } diff --git a/src/lib/variants/input.auth.ts b/src/lib/variants/input.auth.ts new file mode 100644 index 0000000..11dd7c0 --- /dev/null +++ b/src/lib/variants/input.auth.ts @@ -0,0 +1,47 @@ +// src/lib/variants/input.auth.ts +import { cva, type VariantProps } from "class-variance-authority"; + +/** + * 로그인 인풋 컴포넌트의 스타일 variant + * - 기본: 1px + gray-300 + * - 포커스: 1.5px + gray-900 + * - 에러: 1.5px + danger-600 + * - 에러 상태에서 포커스 시: 포커스 규칙(gray-900)로 복귀 + */ +export const authInputVariants = cva( + [ + // === 기본 레이아웃 === + "w-[36.6rem] h-[4.6rem] px-6 rounded-lg bg-white t-14-m outline-none", + + // === 플레이스홀더 === + "placeholder:text-[var(--color-gray-500)]", + + // === 기본 테두리 (1px + gray-300) === + "border-[1.5px] border-[color:var(--color-gray-300)]", + + // === 포커스 (1.5px + gray-900) === + "focus:border-[2px] focus:border-[color:var(--color-gray-900)]", + + // === 전환 효과 === + "transition-colors duration-150", + ].join(" "), + { + variants: { + status: { + default: "", + + error: [ + "border-[2px]", + "border-[color:var(--color-danger-600)]", + "focus:border-[2px]", + "focus:border-[color:var(--color-gray-900)]", + ].join(" "), + }, + }, + defaultVariants: { + status: "default", + }, + }, +); + +export type AuthInputVariants = VariantProps; diff --git a/src/shared/input.tsx b/src/shared/input.tsx index 5816d96..ae9ea45 100644 --- a/src/shared/input.tsx +++ b/src/shared/input.tsx @@ -1,33 +1,47 @@ +"use client"; + import { cn } from "@/lib/utils"; +import { authInputVariants } from "@/lib/variants/input.auth"; +import type { InputProps } from "@/types/input"; import * as React from "react"; -function Input({ className, type, ...props }: React.ComponentProps<"input">) { +/** + * AuthInput — 기본/포커스/에러 상태 + * - status: "default" | "error" + * - 에러 상태에서 포커스 시 자동으로 기본 상태로 복귀 + */ +export const Input = React.forwardRef< + HTMLInputElement, + React.ComponentPropsWithoutRef<"input"> & InputProps +>(({ className, status = "default", ...rest }, ref) => { + const [currentStatus, setCurrentStatus] = React.useState(status); + + // 외부에서 status가 바뀌면 동기화 + React.useEffect(() => { + setCurrentStatus(status); + }, [status]); + + // 포커스 시 에러 상태면 기본으로 복귀 + const handleFocus = (e: React.FocusEvent) => { + if (currentStatus === "error") setCurrentStatus("default"); + rest.onFocus?.(e); + }; + + const handleBlur = (e: React.FocusEvent) => { + rest.onBlur?.(e); + }; + return ( ); -} +}); -export { Input }; +Input.displayName = "Input"; diff --git a/src/types/input.ts b/src/types/input.ts new file mode 100644 index 0000000..33f4c22 --- /dev/null +++ b/src/types/input.ts @@ -0,0 +1,10 @@ +export type InputStatus = "default" | "error"; + +/** 스타일 상태 Props */ +export interface InputStateProps { + /** 인풋 상태 — 기본(default) 또는 에러(error) */ + status?: InputStatus; +} + +/** 최종 컴포넌트 Props */ +export type InputProps = InputStateProps;