ㄱㄱㄱ

This commit is contained in:
mota
2025-11-18 07:53:09 +09:00
parent 0452ca2c28
commit 4f7b98dffb
20 changed files with 1348 additions and 196 deletions

View File

@@ -0,0 +1,70 @@
'use client';
import ModalCloseSvg from "../svgs/closexsvg";
type Props = {
open: boolean;
onClose: () => void;
onConfirm?: () => void;
};
export default function AccountDeleteModal({ open, onClose, onConfirm }: Props) {
if (!open) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-[2px]"
role="dialog"
aria-modal="true"
>
<div className="w-[528px] rounded-[12px] border border-[#dee1e6] bg-white shadow-[0_10px_30px_rgba(0,0,0,0.06)]">
{/* header */}
<div className="flex items-center justify-between p-6">
<h2 className="text-[20px] font-bold leading-[1.5] text-[#333c47]"> </h2>
<button
type="button"
aria-label="닫기"
onClick={onClose}
className="inline-flex size-6 items-center justify-center text-[#333c47] hover:opacity-80 cursor-pointer"
>
<ModalCloseSvg />
</button>
</div>
{/* body */}
<div className="px-6">
<div className="rounded-[16px] border border-[#dee1e6] bg-gray-50 p-6">
<p className="mb-3 text-[15px] font-bold leading-[1.5] text-[#4c5561]">
.
</p>
<div className="text-[15px] leading-[1.5] text-[#4c5561]">
<p className="mb-0">- .</p>
<p className="mb-0">- , .</p>
<p>- .</p>
</div>
</div>
</div>
{/* footer */}
<div className="flex items-center justify-center gap-3 p-6">
<button
type="button"
onClick={onClose}
className="h-12 w-[136px] rounded-[10px] bg-[#f1f3f5] px-4 text-[16px] font-semibold leading-[1.5] text-[#4c5561] cursor-pointer"
>
</button>
<button
type="button"
onClick={onConfirm}
className="h-12 w-[136px] rounded-[10px] bg-red-50 px-4 text-[16px] font-semibold leading-[1.5] text-[#f64c4c] cursor-pointer"
>
</button>
</div>
</div>
</div>
);
}

View File

@@ -1,27 +1,74 @@
'use client';
import { useState } from "react";
import { useEffect, useState } from "react";
import ModalCloseSvg from "../svgs/closexsvg";
type Props = {
open: boolean;
onClose: () => void;
onSubmit?: (payload: { email: string; code: string; newPassword: string }) => void;
onSubmit?: (payload: { email: string; code?: string; newPassword: string }) => void;
showVerification?: boolean;
devVerificationState?: 'initial' | 'sent' | 'verified' | 'failed';
};
export default function ChangePasswordModal({ open, onClose, onSubmit }: Props) {
export default function ChangePasswordModal({ open, onClose, onSubmit, showVerification = false, devVerificationState }: Props) {
const [email, setEmail] = useState("xrlms2025@gmail.com");
const [code, setCode] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState<string | null>(null); // 인증번호 오류 등
const [requireCode, setRequireCode] = useState<boolean>(showVerification);
const [isCodeSent, setIsCodeSent] = useState<boolean>(showVerification);
const canConfirm = code.trim().length > 0;
const [isVerified, setIsVerified] = useState(false);
const hasError = !!error;
// 외부에서 전달된 개발모드 상태(devVerificationState)에 따라 UI 동기화
useEffect(() => {
if (!devVerificationState) return;
switch (devVerificationState) {
case 'initial':
setRequireCode(false);
setIsCodeSent(false);
setCode("");
setError(null);
setIsVerified(false);
break;
case 'sent':
setRequireCode(true);
setIsCodeSent(true);
setCode("");
setError(null);
setIsVerified(false);
break;
case 'verified':
setRequireCode(true);
setIsCodeSent(true);
setCode("123456");
setError(null);
setIsVerified(true);
break;
case 'failed':
setRequireCode(true);
setIsCodeSent(true);
setCode("");
setError("올바르지 않은 인증번호입니다. 인증번호를 확인해주세요.");
setIsVerified(false);
break;
default:
break;
}
}, [devVerificationState]);
if (!open) return null;
const handleSubmit = () => {
setError(null);
if (!code) {
setError("인증번호를 입력해 주세요.");
return;
if (requireCode) {
if (!code) {
setError("인증번호를 입력해 주세요.");
return;
}
}
if (!newPassword || !confirmPassword) {
setError("새 비밀번호를 입력해 주세요.");
@@ -31,7 +78,7 @@ export default function ChangePasswordModal({ open, onClose, onSubmit }: Props)
setError("새 비밀번호가 일치하지 않습니다.");
return;
}
onSubmit?.({ email, code, newPassword });
onSubmit?.({ email, code: requireCode ? code : undefined, newPassword });
onClose();
};
@@ -49,11 +96,9 @@ export default function ChangePasswordModal({ open, onClose, onSubmit }: Props)
type="button"
aria-label="닫기"
onClick={onClose}
className="inline-flex size-6 items-center justify-center text-[#333c47] hover:opacity-80"
className="inline-flex size-6 items-center justify-center text-[#333c47] hover:opacity-80 cursor-pointer"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="size-[15.2px]">
<path fillRule="evenodd" d="M6.225 4.811a1 1 0 0 1 1.414 0L12 9.172l4.361-4.361a1 1 0 1 1 1.414 1.414L13.414 10.586l4.361 4.361a1 1 0 0 1-1.414 1.414L12 12l-4.361 4.361a1 1 0 1 1-1.414-1.414l4.361-4.361-4.361-4.361a1 1 0 0 1 0-1.414z" clipRule="evenodd" />
</svg>
<ModalCloseSvg />
</button>
</div>
@@ -66,47 +111,75 @@ export default function ChangePasswordModal({ open, onClose, onSubmit }: Props)
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="h-10 flex-1 rounded-[8px] border border-[#dee1e6] bg-white px-3 text-[16px] leading-[1.5] text-[#333c47] placeholder-[#b1b8c0] outline-none"
className={[
"h-10 flex-1 rounded-[8px] border border-[#dee1e6] px-3 text-[16px] leading-[1.5] text-[#333c47] placeholder-[#b1b8c0] outline-none",
hasError ? "bg-white" : isCodeSent ? "bg-neutral-50" : "bg-white",
].join(" ")}
placeholder="이메일"
/>
<button
type="button"
className="h-10 w-[136px] rounded-[8px] bg-[#f1f3f5] px-3 text-[16px] font-semibold leading-[1.5] text-[#333c47]"
onClick={() => {
setRequireCode(true);
setIsCodeSent(true);
setError(null);
}}
className="h-10 w-[136px] rounded-[8px] bg-[#f1f3f5] px-3 text-[16px] font-semibold leading-[1.5] text-[#333c47] cursor-pointer"
>
{isCodeSent ? "인증번호 재전송" : "인증번호 전송"}
</button>
</div>
</div>
<div className="flex flex-col gap-2">
<div className="w-[100px] text-[15px] font-semibold leading-[1.5] text-[#6c7682]"></div>
<div className="flex items-center gap-3">
<input
type="text"
value={code}
onChange={(e) => setCode(e.target.value)}
className="h-10 flex-1 rounded-[8px] border border-[#dee1e6] bg-white px-3 text-[16px] leading-[1.5] text-[#333c47] placeholder-[#b1b8c0] outline-none"
placeholder="인증번호 6자리"
/>
<button
type="button"
className="h-10 w-[136px] rounded-[8px] bg-[#f1f3f5] px-3 text-[16px] font-semibold leading-[1.5] text-[#4c5561]"
>
</button>
{requireCode ? (
<div className="flex flex-col gap-2">
<div className="w-[100px] text-[15px] font-semibold leading-[1.5] text-[#6c7682]"></div>
<div className="flex items-center gap-3">
<input
type="text"
value={code}
onChange={(e) => setCode(e.target.value)}
className="h-10 flex-1 rounded-[8px] border border-[#dee1e6] bg-white px-3 text-[16px] leading-[1.5] text-[#333c47] placeholder-[#b1b8c0] outline-none"
placeholder="인증번호를 입력해 주세요."
/>
<button
type="button"
disabled={!canConfirm}
className={[
"h-10 w-[136px] rounded-[8px] px-3 text-[16px] font-semibold leading-[1.5] cursor-pointer disabled:cursor-default",
canConfirm ? "bg-[#f1f3f5] text-[#4c5561]" : "bg-gray-50 text-[#b1b8c0]",
].join(" ")}
>
</button>
</div>
{isCodeSent && !hasError && !isVerified ? (
<div className="px-1">
<p className="text-[13px] font-semibold leading-[1.4] text-[#384fbf]">
.
</p>
<p className="text-[13px] font-semibold leading-[1.4] text-[#384fbf]">
.
</p>
</div>
) : null}
{error ? (
<p className="px-1 text-[13px] font-semibold leading-[1.4] text-[#f64c4c]">
{error}
</p>
) : null}
</div>
{error ? (
<p className="px-1 text-[13px] font-semibold leading-[1.4] text-[#f64c4c]">
{error}
</p>
) : null}
</div>
) : null}
<div className="flex flex-col gap-2">
<label className="text-[15px] font-semibold leading-[1.5] text-[#6c7682]"> </label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className="h-10 rounded-[8px] border border-[#dee1e6] bg-neutral-50 px-3 text-[16px] leading-[1.5] text-[#333c47] placeholder-[#b1b8c0] outline-none"
disabled={!isVerified}
className={[
"h-10 rounded-[8px] border border-[#dee1e6] px-3 text-[16px] leading-[1.5] text-[#333c47] placeholder-[#b1b8c0] outline-none",
isVerified ? "bg-white" : "bg-neutral-50",
].join(" ")}
placeholder="새 비밀번호"
/>
</div>
@@ -118,7 +191,11 @@ export default function ChangePasswordModal({ open, onClose, onSubmit }: Props)
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="h-10 rounded-[8px] border border-[#dee1e6] bg-neutral-50 px-3 text-[16px] leading-[1.5] text-[#333c47] placeholder-[#b1b8c0] outline-none"
disabled={!isVerified}
className={[
"h-10 rounded-[8px] border border-[#dee1e6] px-3 text-[16px] leading-[1.5] text-[#333c47] placeholder-[#b1b8c0] outline-none",
isVerified ? "bg-white" : "bg-neutral-50",
].join(" ")}
placeholder="새 비밀번호 확인"
/>
</div>
@@ -129,14 +206,14 @@ export default function ChangePasswordModal({ open, onClose, onSubmit }: Props)
<button
type="button"
onClick={onClose}
className="h-12 w-[136px] rounded-[10px] bg-[#f1f3f5] px-4 text-[16px] font-semibold leading-[1.5] text-[#4c5561]"
className="h-12 w-[136px] rounded-[10px] bg-[#f1f3f5] px-4 text-[16px] font-semibold leading-[1.5] text-[#4c5561] cursor-pointer"
>
</button>
<button
type="button"
onClick={handleSubmit}
className="h-12 w-[136px] rounded-[10px] bg-[#8598e8] px-4 text-[16px] font-semibold leading-[1.5] text-white"
className="h-12 w-[136px] rounded-[10px] bg-[#8598e8] px-4 text-[16px] font-semibold leading-[1.5] text-white cursor-pointer"
>
</button>

View File

@@ -0,0 +1,56 @@
'use client';
import Link from "next/link";
import ModalCloseSvg from "../svgs/closexsvg";
type Props = {
open: boolean;
onClose: () => void;
};
export default function PasswordChangeDoneModal({ open, onClose }: Props) {
if (!open) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-[2px]"
role="dialog"
aria-modal="true"
>
<div className="w-[480px] h-[437px] rounded-[12px] border border-[#dee1e6] bg-white shadow-[0_10px_30px_rgba(0,0,0,0.06)] flex flex-col justify-between">
<div className="flex h-[80px] items-center justify-end p-6">
<button
type="button"
aria-label="닫기"
onClick={onClose}
className="inline-flex size-6 items-center justify-center text-[#333c47] hover:opacity-80 cursor-pointer"
>
<ModalCloseSvg />
</button>
</div>
<div className="px-6 pb-0 ">
<div className="w-full mx-auto flex flex-col items-center gap-4">
<h2 className="text-[24px] font-extrabold leading-[1.45] text-[#333c47]">
.
</h2>
<p className="text-[18px] leading-[1.5] text-[#6c7682] text-center">
.
</p>
</div>
</div>
<div className="flex items-center justify-center p-6">
<Link
href="/login"
className="h-12 w-[284px] rounded-[10px] bg-[#1f2b91] text-[16px] font-semibold leading-[1.5] text-white inline-flex items-center justify-center cursor-pointer"
>
</Link>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,151 @@
'use client';
import { useState } from "react";
type VerificationState = 'initial' | 'sent' | 'verified' | 'failed' | 'changed';
type MenuAccountOptionProps = {
verificationState: VerificationState;
setVerificationState: (state: VerificationState) => void;
deleteOpen?: boolean;
setDeleteOpen?: (open: boolean) => void;
};
export default function MenuAccountOption({
verificationState,
setVerificationState,
deleteOpen,
setDeleteOpen,
}: MenuAccountOptionProps) {
const [isOpen, setIsOpen] = useState(false);
const itemClass = (active: boolean) =>
[
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors",
active ? "bg-blue-600" : "bg-gray-300",
].join(" ");
const knobClass = (active: boolean) =>
[
"inline-block h-5 w-5 transform rounded-full bg-white transition",
active ? "translate-x-5" : "translate-x-1",
].join(" ");
const is = {
initial: verificationState === 'initial',
sent: verificationState === 'sent',
verified: verificationState === 'verified',
failed: verificationState === 'failed',
changed: verificationState === 'changed',
};
return (
<div>
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className={`fixed bottom-2 right-2 bg-red-400 cursor-pointer rounded-full w-[40px] h-[40px] shadow-xl z-100`}
>
</button>
{isOpen && (
<div className="fixed inset-0 flex items-center justify-center z-50">
<div className="w-[500px] h-[600px] flex bg-white/80 p-10 border rounded-lg relative">
<button
type="button"
aria-label="닫기"
onClick={() => setIsOpen(false)}
className="absolute top-3 right-3 inline-flex items-center justify-center rounded-full w-8 h-8 bg-gray-200 hover:bg-gray-300 text-gray-700"
>
×
</button>
<div className="w-full h-full overflow-auto">
<div className="mb-6">
<p className="text-sm text-gray-700"> : <span className="font-semibold">{verificationState}</span></p>
</div>
<ul className="flex flex-col gap-4">
<li className="flex items-center justify-between">
<p className="mr-4"> </p>
<button
type="button"
aria-label="초기 상태로 설정"
aria-pressed={is.initial}
onClick={() => setVerificationState('initial')}
className={itemClass(is.initial)}
>
<span className={knobClass(is.initial)} />
</button>
</li>
<li className="flex items-center justify-between">
<p className="mr-4"> </p>
<button
type="button"
aria-label="인증번호 전송 상태로 설정"
aria-pressed={is.sent}
onClick={() => setVerificationState('sent')}
className={itemClass(is.sent)}
>
<span className={knobClass(is.sent)} />
</button>
</li>
<li className="flex items-center justify-between">
<p className="mr-4"> </p>
<button
type="button"
aria-label="인증 완료 상태로 설정"
aria-pressed={is.verified}
onClick={() => setVerificationState('verified')}
className={itemClass(is.verified)}
>
<span className={knobClass(is.verified)} />
</button>
</li>
<li className="flex items-center justify-between">
<p className="mr-4"> </p>
<button
type="button"
aria-label="인증 실패 상태로 설정"
aria-pressed={is.failed}
onClick={() => setVerificationState('failed')}
className={itemClass(is.failed)}
>
<span className={knobClass(is.failed)} />
</button>
</li>
</ul>
<div className="mt-6 border-t pt-6">
<li className="flex items-center justify-between">
<p className="mr-4"> </p>
<button
type="button"
aria-label="비밀번호 변경 완료 상태로 설정"
aria-pressed={is.changed}
onClick={() =>
setVerificationState(is.changed ? 'initial' : 'changed')
}
className={itemClass(is.changed)}
>
<span className={knobClass(is.changed)} />
</button>
</li>
<li className="mt-4 flex items-center justify-between">
<p className="mr-4"> </p>
<button
type="button"
aria-label="회원 탈퇴 모달 토글"
aria-pressed={!!deleteOpen}
onClick={() => setDeleteOpen?.(!deleteOpen)}
className={itemClass(!!deleteOpen)}
>
<span className={knobClass(!!deleteOpen)} />
</button>
</li>
</div>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,10 +1,23 @@
'use client';
import { useState } from "react";
import { useEffect, useState } from "react";
import ChangePasswordModal from "../ChangePasswordModal";
import PasswordChangeDoneModal from "../PasswordChangeDoneModal";
import AccountDeleteModal from "../AccountDeleteModal";
import MenuAccountOption from "@/app/menu/account/MenuAccountOption";
type VerificationState = 'initial' | 'sent' | 'verified' | 'failed' | 'changed';
export default function AccountPage() {
const [open, setOpen] = useState(false);
const [verificationState, setVerificationState] = useState<VerificationState>('initial');
const [doneOpen, setDoneOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
// 개발 옵션에서 'changed'로 전환하면 완료 모달 표시
useEffect(() => {
setDoneOpen(verificationState === 'changed');
}, [verificationState]);
return (
<main className="flex w-full flex-col">
@@ -40,7 +53,11 @@ export default function AccountPage() {
</div>
</div>
<div className="mt-6">
<button className="text-[15px] font-medium leading-[1.5] text-[#f64c4c] underline">
<button
type="button"
onClick={() => setDeleteOpen(true)}
className="text-[15px] font-medium leading-[1.5] text-[#f64c4c] underline cursor-pointer"
>
</button>
</div>
@@ -52,6 +69,28 @@ export default function AccountPage() {
onSubmit={() => {
// TODO: integrate API
}}
devVerificationState={verificationState}
/>
<MenuAccountOption
verificationState={verificationState}
setVerificationState={setVerificationState}
deleteOpen={deleteOpen}
setDeleteOpen={setDeleteOpen}
/>
<PasswordChangeDoneModal
open={doneOpen}
onClose={() => setDoneOpen(false)}
/>
<AccountDeleteModal
open={deleteOpen}
onClose={() => setDeleteOpen(false)}
onConfirm={() => {
// TODO: 탈퇴 API 연동
setDeleteOpen(false);
}}
/>
</main>
);

View File

@@ -0,0 +1,131 @@
'use client';
import Image from "next/image";
import { useState } from "react";
type Lesson = {
id: string;
title: string;
durationMin: number;
progressPct: number; // 0~100
isCompleted: boolean;
};
type Course = {
id: string;
title: string;
description: string;
thumbnail: string;
status: "전체" | "수강 예정" | "수강중" | "수강 완료";
progressPct: number;
lessons: Lesson[];
};
function ProgressBar({ value }: { value: number }) {
const pct = Math.max(0, Math.min(100, value));
return (
<div className="relative h-2 w-full overflow-hidden rounded-full bg-[#ecf0ff]">
<div
className="h-full rounded-full bg-[#384fbf] transition-[width] duration-300 ease-out"
style={{ width: `${pct}%` }}
/>
</div>
);
}
export default function CourseCard({ course }: { course: Course }) {
const [open, setOpen] = useState(false);
return (
<article className="rounded-xl border border-[#ecf0ff] bg-white shadow-[0_2px_8px_rgba(0,0,0,0.02)]">
<header className="flex items-center gap-4 p-4">
<div className="relative h-[76px] w-[120px] overflow-hidden rounded-md bg-[#f1f3f5]">
<Image src={course.thumbnail} alt="" fill sizes="120px" className="object-cover" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="rounded bg-[#e5f5ec] px-2 py-0.5 text-[12px] font-semibold leading-[1.4] text-[#0c9d61]">
{course.status}
</span>
<h2 className="truncate text-[18px] font-bold leading-[1.5] text-[#1b2027]">
{course.title}
</h2>
</div>
<p className="mt-1 line-clamp-2 text-[14px] leading-[1.5] text-[#4c5561]">{course.description}</p>
<div className="mt-2 flex items-center gap-3">
<ProgressBar value={course.progressPct} />
<span className="w-[72px] text-right text-[13px] font-medium leading-[1.4] text-[#6c7682]">
{course.progressPct}%
</span>
</div>
</div>
<div className="flex items-center gap-2 self-start">
<button
type="button"
className="rounded-md border border-[#dee1e6] px-3 py-2 text-[13px] font-semibold leading-[1.4] text-[#333c47] hover:bg-[#f9fafb]"
>
</button>
<button
type="button"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
className="flex h-8 w-8 items-center justify-center rounded-full border border-[#dee1e6] text-[#6c7682] hover:bg-[#f9fafb]"
aria-label={open ? "접기" : "펼치기"}
>
<span
className={["inline-block transition-transform", open ? "rotate-180" : "rotate-0"].join(" ")}
>
</span>
</button>
</div>
</header>
{open ? (
<div className="px-4 pb-4">
<ul className="flex flex-col gap-2">
{course.lessons.map((lesson, idx) => (
<li key={lesson.id} className="rounded-lg border border-[#ecf0ff]">
<div className="flex items-center justify-between gap-4 px-4 py-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-3">
<span className="w-[20px] text-[13px] font-semibold leading-[1.4] text-[#8c95a1]">
{idx + 1}.
</span>
<p className="truncate text-[14px] font-medium leading-[1.5] text-[#333c47]">
{lesson.title}
</p>
</div>
<div className="mt-2 flex items-center gap-3">
<ProgressBar value={lesson.progressPct} />
<span className="w-[80px] text-right text-[13px] leading-[1.4] text-[#8c95a1]">
{lesson.progressPct}% · {lesson.durationMin}
</span>
</div>
</div>
<div className="flex items-center gap-2">
{lesson.isCompleted ? (
<span className="rounded bg-[#e5f5ec] px-2 py-1 text-[12px] font-semibold leading-[1.4] text-[#0c9d61]">
</span>
) : (
<button
type="button"
className="rounded-md border border-[#dee1e6] px-3 py-2 text-[13px] font-semibold leading-[1.4] text-[#333c47] hover:bg-[#f9fafb]"
>
</button>
)}
</div>
</div>
</li>
))}
</ul>
</div>
) : null}
</article>
);
}

View File

@@ -0,0 +1,61 @@
'use client';
import Image from "next/image";
export type CourseGridItemProps = {
id: string;
thumbnail: string;
title: string;
isNew?: boolean;
category?: string; // ex) 안전, 공정 등
meta?: string; // ex) "VOD · 총 6강 · 4시간 20분"
};
export default function CourseGridItem({
id,
thumbnail,
title,
isNew,
category,
meta,
}: CourseGridItemProps) {
return (
<li
key={id}
className="group flex w-full flex-col gap-2"
>
<div className="relative aspect-[16/10] w-full overflow-hidden rounded-lg border border-[#ecf0ff] bg-[#f9fafb]">
<Image
src={thumbnail}
alt=""
fill
sizes="(max-width: 768px) 50vw, (max-width: 1200px) 25vw, 240px"
className="object-cover transition-transform duration-300 group-hover:scale-[1.02]"
priority={false}
/>
</div>
<div className="flex flex-col">
<div className="flex items-center gap-2">
{category ? (
<span className="rounded bg-[#e5f5ec] px-1.5 py-0.5 text-[12px] font-semibold leading-[1.4] text-[#0c9d61]">
{category}
</span>
) : null}
{isNew ? (
<span className="rounded bg-[#ecf0ff] px-1.5 py-0.5 text-[12px] font-semibold leading-[1.4] text-[#1f2b91]">
NEW
</span>
) : null}
</div>
<h3 className="mt-1 line-clamp-1 text-[14px] font-semibold leading-[1.5] text-[#333c47]">
{title}
</h3>
{meta ? (
<p className="mt-0.5 text-[12px] leading-[1.4] text-[#8c95a1]">{meta}</p>
) : null}
</div>
</li>
);
}

View File

@@ -1,14 +1,179 @@
'use client';
import { useMemo, useState } from "react";
import CourseCard from "./CourseCard";
type CourseStatus = "전체" | "수강 예정" | "수강중" | "수강 완료";
type Lesson = {
id: string;
title: string;
durationMin: number;
progressPct: number; // 0~100
isCompleted: boolean;
};
type Course = {
id: string;
title: string;
description: string;
thumbnail: string;
status: CourseStatus;
progressPct: number;
lessons: Lesson[];
};
const MOCK_COURSES: Course[] = [
{
id: "c1",
title: "원자재 출입 전 계동",
description:
"원자재 이송/보관 기준을 기반으로 가이드하며, 일련의 단계 별 강의에서 세부적인 작업 지침을 다룹니다.",
thumbnail: "/imgs/talk.png",
status: "수강중",
progressPct: 80,
lessons: [
{ id: "c1l1", title: "1. 운반과 기준 및 방출 절차", durationMin: 12, progressPct: 100, isCompleted: true },
{ id: "c1l2", title: "2. 라벨링 원칙과 현장 케어", durationMin: 9, progressPct: 100, isCompleted: true },
{ id: "c1l3", title: "3. 배치/현황 기록 및 문서 관리", durationMin: 15, progressPct: 60, isCompleted: false },
{ id: "c1l4", title: "4. 보관 적재 기준 점검", durationMin: 8, progressPct: 0, isCompleted: false },
{ id: "c1l5", title: "5. 입고 검사 방법 (AQL) 및 유의점", durationMin: 11, progressPct: 0, isCompleted: false },
{ id: "c1l6", title: "6. 장비 사용, 손질 및 일지 필기", durationMin: 13, progressPct: 0, isCompleted: false },
],
},
{
id: "c2",
title: "학점과 평가",
description:
"학점과 평가 항목 기준을 가이드하며, 일과 관련된 기본 평가 체계와 피드백 처리 방법에 대해 배웁니다.",
thumbnail: "/imgs/talk.png",
status: "수강 완료",
progressPct: 100,
lessons: [
{ id: "c2l1", title: "평가 기준 이해", durationMin: 10, progressPct: 100, isCompleted: true },
],
},
{
id: "c3",
title: "부서간 연협",
description:
"부서간 협업 절차와 기록 기준을 가이드하며, 일련 단계 별 협업에서 생길 수 있는 리스크 관리법을 다룹니다.",
thumbnail: "/imgs/talk.png",
status: "수강중",
progressPct: 60,
lessons: [
{ id: "c3l1", title: "의사소통 원칙", durationMin: 9, progressPct: 100, isCompleted: true },
{ id: "c3l2", title: "문서 공유와 승인", durationMin: 14, progressPct: 30, isCompleted: false },
],
},
{
id: "c4",
title: "작업별 방사선의 이해",
description:
"작업별 방사선 안전 기준을 가이드하며, 일과 관련된 위험과 보호 장비 선택법을 배웁니다.",
thumbnail: "/imgs/talk.png",
status: "수강 예정",
progressPct: 0,
lessons: [
{ id: "c4l1", title: "기초 이론", durationMin: 12, progressPct: 0, isCompleted: false },
],
},
];
const TABS: CourseStatus[] = ["전체", "수강 예정", "수강중", "수강 완료"];
export default function CoursesPage() {
return (
<main className="flex w-full flex-col">
<div className="flex h-[100px] items-center px-8">
<h1 className="text-[24px] font-bold leading-[1.5] text-[#1b2027]"> </h1>
</div>
<div className="px-8 pb-20">
<p className="text-[16px] leading-[1.5] text-[#4c5561]"> .</p>
</div>
</main>
);
const [activeTab, setActiveTab] = useState<CourseStatus>("전체");
const countsByStatus = useMemo(() => {
return {
전체: MOCK_COURSES.length,
"수강 예정": MOCK_COURSES.filter((c) => c.status === "수강 예정").length,
수강중: MOCK_COURSES.filter((c) => c.status === "수강중").length,
"수강 완료": MOCK_COURSES.filter((c) => c.status === "수강 완료").length,
};
}, []);
const filtered = useMemo(() => {
if (activeTab === "전체") return MOCK_COURSES;
return MOCK_COURSES.filter((c) => c.status === activeTab);
}, [activeTab]);
return (
<main className="flex w-full flex-col">
<div className="flex h-[100px] items-center px-8">
<h1 className="text-[24px] font-bold leading-[1.5] text-[#1b2027]"> </h1>
</div>
<div className="px-8">
<div className="border-b border-[#ecf0ff]">
<ul className="flex gap-6">
{TABS.map((tab) => {
const isActive = activeTab === tab;
return (
<li key={tab}>
<button
type="button"
onClick={() => setActiveTab(tab)}
className={[
"relative -mb-px h-12 px-1 text-[14px] leading-[1.5]",
isActive ? "font-semibold text-[#1f2b91]" : "font-medium text-[#6c7682]",
].join(" ")}
>
{tab}{" "}
<span className="ml-1 text-[#8c95a1]">
{countsByStatus[tab as keyof typeof countsByStatus] ?? 0}
</span>
{isActive ? (
<span className="absolute inset-x-0 -bottom-[1px] h-[2px] bg-[#1f2b91]" />
) : null}
</button>
</li>
);
})}
</ul>
</div>
</div>
<div className="px-8 pb-16 pt-6">
<div className="flex flex-col gap-4">
{filtered.map((course) => (
<CourseCard key={course.id} course={course} />
))}
</div>
{/* pagination */}
<div className="mt-10 flex items-center justify-center gap-2">
<button
type="button"
className="flex h-8 w-8 items-center justify-center rounded-full text-[#8c95a1] hover:bg-[#f1f3f5]"
aria-label="이전 페이지"
>
&#x2039;
</button>
{[1, 2, 3].map((p) => (
<button
key={p}
type="button"
className={[
"flex h-8 w-8 items-center justify-center rounded-full text-[13px] leading-[1.4]",
p === 2 ? "bg-[#1f2b91] text-white" : "text-[#4c5561] hover:bg-[#f1f3f5]",
].join(" ")}
>
{p}
</button>
))}
<button
type="button"
className="flex h-8 w-8 items-center justify-center rounded-full text-[#8c95a1] hover:bg-[#f1f3f5]"
aria-label="다음 페이지"
>
&#x203A;
</button>
</div>
</div>
</main>
);
}

View File

@@ -3,7 +3,7 @@ import MenuSidebar from "./MenuSidebar";
export default function MenuLayout({ children }: { children: ReactNode }) {
return (
<div className="mx-auto flex w-full max-w-[1440px]">
<div className="mx-auto flex w-full max-w-[1440px] min-h-full">
<aside className="w-[320px] border-r border-[#dee1e6] px-4 py-6">
<MenuSidebar />
</aside>

View File

@@ -1,12 +1,118 @@
type ResultRow = {
programTitle: string;
courseTitle: string;
completedAt: string;
score: string;
instructor: string;
feedbackLink?: string;
certificateLink?: string;
};
const mockResults: ResultRow[] = [
{
programTitle: "XR 안전 기본 과정",
courseTitle: "{강좌명}",
completedAt: "2025-09-10",
score: "-",
instructor: "이공필",
},
{
programTitle: "건설 현장 안전 실무",
courseTitle: "{강좌명}",
completedAt: "2025-09-10",
score: "70 / 100",
instructor: "이공필",
feedbackLink: "#",
certificateLink: "#",
},
{
programTitle: "전기 설비 위험성 평가",
courseTitle: "{강좌명}",
completedAt: "2025-09-10",
score: "70 / 100",
instructor: "이공필",
feedbackLink: "#",
certificateLink: "#",
},
];
export default function ResultsPage() {
return (
<main className="flex w-full flex-col">
<div className="flex h-[100px] items-center px-8">
<h1 className="text-[24px] font-bold leading-[1.5] text-[#1b2027]"> </h1>
</div>
<div className="px-8 pb-20">
<p className="text-[16px] leading-[1.5] text-[#4c5561]"> .</p>
</div>
<section className="px-8 pb-20">
<div className="rounded-[8px]">
<div className="w-full overflow-x-auto rounded-[8px] border border-[#dee1e6]">
<table className="min-w-full border-collapse">
<colgroup>
<col />
<col />
<col style={{ width: 140 }} />
<col style={{ width: 140 }} />
<col style={{ width: 140 }} />
<col style={{ width: 76 }} />
<col style={{ width: 76 }} />
</colgroup>
<thead>
<tr className="h-12 bg-gray-50 text-left">
<th className="border-r border-[#dee1e6] px-4 text-[14px] font-semibold leading-[1.5] text-[#4c5561]"> </th>
<th className="border-r border-[#dee1e6] px-4 text-[14px] font-semibold leading-[1.5] text-[#4c5561]"></th>
<th className="border-r border-[#dee1e6] px-4 text-[14px] font-semibold leading-[1.5] text-[#4c5561]"> </th>
<th className="border-r border-[#dee1e6] px-4 text-[14px] font-semibold leading-[1.5] text-[#4c5561]"> ()</th>
<th className="border-r border-[#dee1e6] px-4 text-[14px] font-semibold leading-[1.5] text-[#4c5561]"> </th>
<th className="border-r border-[#dee1e6] px-4 text-center text-[14px] font-semibold leading-[1.5] text-[#4c5561]"></th>
<th className="px-4 text-center text-[14px] font-semibold leading-[1.5] text-[#4c5561]"></th>
</tr>
</thead>
<tbody>
{mockResults.map((row, idx) => (
<tr
key={`${row.programTitle}-${idx}`}
className={idx === 1 ? "h-12 bg-[rgba(236,240,255,0.5)]" : "h-12"}
>
<td className="border-t border-r border-[#dee1e6] px-4 text-[15px] leading-[1.5] text-[#1b2027]">
{row.programTitle}
</td>
<td className="border-t border-r border-[#dee1e6] px-4 text-[15px] leading-[1.5] text-[#1b2027]">
{row.courseTitle}
</td>
<td className="border-t border-r border-[#dee1e6] px-4 text-[15px] leading-[1.5] text-[#1b2027]">
{row.completedAt}
</td>
<td className="border-t border-r border-[#dee1e6] px-4 text-[15px] leading-[1.5] text-[#1b2027]">
{row.score}
</td>
<td className="border-t border-r border-[#dee1e6] px-4 text-[15px] leading-[1.5] text-[#1b2027]">
{row.instructor}
</td>
<td className="border-t border-r border-[#dee1e6] px-4 text-center text-[15px] leading-[1.5] text-[#1b2027]">
{row.feedbackLink ? (
<a href={row.feedbackLink} className="text-[12px] text-blue-500 underline underline-offset-[3px]">
</a>
) : (
"-"
)}
</td>
<td className="border-t border-[#dee1e6] px-4 text-center text-[15px] leading-[1.5] text-[#1b2027]">
{row.certificateLink ? (
<a href={row.certificateLink} className="text-[12px] text-blue-500 underline underline-offset-[3px]">
</a>
) : (
"-"
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</section>
</main>
);
}