This commit is contained in:
3
middleware.ts
Normal file
3
middleware.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { middleware, config } from "./src/middleware";
|
||||
|
||||
|
||||
@@ -229,8 +229,8 @@ model User {
|
||||
nickname String @unique
|
||||
passwordHash String?
|
||||
name String
|
||||
birth DateTime
|
||||
phone String @unique
|
||||
birth DateTime?
|
||||
phone String? @unique
|
||||
rank Int @default(0)
|
||||
// 누적 포인트, 레벨, 등급(0~10)
|
||||
points Int @default(0)
|
||||
@@ -259,6 +259,7 @@ model User {
|
||||
blocksInitiated Block[] @relation("Blocker")
|
||||
blocksReceived Block[] @relation("Blocked")
|
||||
pointTxns PointTransaction[]
|
||||
attendances Attendance[]
|
||||
sanctions Sanction[]
|
||||
nicknameChanges NicknameChange[]
|
||||
passwordResetTokens PasswordResetToken[] @relation("PasswordResetUser")
|
||||
@@ -506,6 +507,20 @@ model PointTransaction {
|
||||
@@map("point_transactions")
|
||||
}
|
||||
|
||||
// 출석부 기록 (사용자별 일자 단위 출석)
|
||||
model Attendance {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
date DateTime // 자정 기준 날짜만 사용
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [userId], references: [userId], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, date])
|
||||
@@index([userId, date])
|
||||
@@map("attendance")
|
||||
}
|
||||
|
||||
// 레벨 임계치(선택)
|
||||
model LevelThreshold {
|
||||
id String @id @default(cuid())
|
||||
|
||||
@@ -270,6 +270,25 @@ async function upsertAdmin() {
|
||||
return admin;
|
||||
}
|
||||
|
||||
async function seedAdminAttendance(admin) {
|
||||
try {
|
||||
const now = new Date();
|
||||
const year = now.getUTCFullYear();
|
||||
const days = [1, 2, 5, 6]; // 11월 1,2,5,6일
|
||||
for (const d of days) {
|
||||
const date = new Date(Date.UTC(year, 10, d, 0, 0, 0, 0)); // 10 = November (0-based)
|
||||
// @@unique([userId, date]) 기준으로 업서트
|
||||
await prisma.attendance.upsert({
|
||||
where: { userId_date: { userId: admin.userId, date } },
|
||||
update: {},
|
||||
create: { userId: admin.userId, date },
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("seedAdminAttendance failed:", e);
|
||||
}
|
||||
}
|
||||
|
||||
async function upsertBoards(admin, categoryMap) {
|
||||
const boards = [
|
||||
// 일반
|
||||
@@ -292,11 +311,12 @@ async function upsertBoards(admin, categoryMap) {
|
||||
];
|
||||
|
||||
const created = [];
|
||||
// 특수 랭킹/텍스트/미리보기 뷰 타입 ID 조회 (사전에 upsertViewTypes로 생성됨)
|
||||
// 텍스트/특수랭킹/특수출석 뷰 타입 ID 조회 (사전에 upsertViewTypes로 생성됨)
|
||||
const mainText = await prisma.boardViewType.findUnique({ where: { key: "main_text" } });
|
||||
const listText = await prisma.boardViewType.findUnique({ where: { key: "list_text" } });
|
||||
const mainSpecial = await prisma.boardViewType.findUnique({ where: { key: "main_special_rank" } });
|
||||
const listSpecial = await prisma.boardViewType.findUnique({ where: { key: "list_special_rank" } });
|
||||
const mainText = await prisma.boardViewType.findUnique({ where: { key: "main_text" } });
|
||||
const mainPreview = await prisma.boardViewType.findUnique({ where: { key: "main_preview" } });
|
||||
const listSpecialAttendance = await prisma.boardViewType.findUnique({ where: { key: "list_special_attendance" } });
|
||||
|
||||
for (const b of boards) {
|
||||
// 카테고리 매핑 규칙 (트리 기준 상위 카테고리)
|
||||
@@ -330,13 +350,12 @@ async function upsertBoards(admin, categoryMap) {
|
||||
allowAnonymousPost: !!b.allowAnonymousPost,
|
||||
readLevel: b.readLevel || undefined,
|
||||
categoryId: category ? category.id : undefined,
|
||||
// 메인뷰: 기본이 아니라 텍스트(main_text)로 설정, 랭킹 보드는 특수 랭킹 유지, TEST는 미리보기
|
||||
...(b.slug === "ranking"
|
||||
? (mainSpecial ? { mainPageViewTypeId: mainSpecial.id } : {})
|
||||
: b.slug === "test"
|
||||
? (mainPreview ? { mainPageViewTypeId: mainPreview.id } : {})
|
||||
: (mainText ? { mainPageViewTypeId: mainText.id } : {})),
|
||||
// 기본은 텍스트, 'ranking'은 특수랭킹, 'attendance'는 특수출석으로 오버라이드
|
||||
...(mainText ? { mainPageViewTypeId: mainText.id } : {}),
|
||||
...(listText ? { listViewTypeId: listText.id } : {}),
|
||||
...(b.slug === "ranking" && mainSpecial ? { mainPageViewTypeId: mainSpecial.id } : {}),
|
||||
...(b.slug === "ranking" && listSpecial ? { listViewTypeId: listSpecial.id } : {}),
|
||||
...(b.slug === "attendance" && listSpecialAttendance ? { listViewTypeId: listSpecialAttendance.id } : {}),
|
||||
},
|
||||
create: {
|
||||
name: b.name,
|
||||
@@ -346,13 +365,12 @@ async function upsertBoards(admin, categoryMap) {
|
||||
allowAnonymousPost: !!b.allowAnonymousPost,
|
||||
readLevel: b.readLevel || undefined,
|
||||
categoryId: category ? category.id : undefined,
|
||||
// 메인뷰: 기본이 아니라 텍스트(main_text)로 설정, 랭킹 보드는 특수 랭킹 유지, TEST는 미리보기
|
||||
...(b.slug === "ranking"
|
||||
? (mainSpecial ? { mainPageViewTypeId: mainSpecial.id } : {})
|
||||
: b.slug === "test"
|
||||
? (mainPreview ? { mainPageViewTypeId: mainPreview.id } : {})
|
||||
: (mainText ? { mainPageViewTypeId: mainText.id } : {})),
|
||||
// 기본은 텍스트, 'ranking'은 특수랭킹, 'attendance'는 특수출석으로 오버라이드
|
||||
...(mainText ? { mainPageViewTypeId: mainText.id } : {}),
|
||||
...(listText ? { listViewTypeId: listText.id } : {}),
|
||||
...(b.slug === "ranking" && mainSpecial ? { mainPageViewTypeId: mainSpecial.id } : {}),
|
||||
...(b.slug === "ranking" && listSpecial ? { listViewTypeId: listSpecial.id } : {}),
|
||||
...(b.slug === "attendance" && listSpecialAttendance ? { listViewTypeId: listSpecialAttendance.id } : {}),
|
||||
},
|
||||
});
|
||||
created.push(board);
|
||||
@@ -373,16 +391,15 @@ async function upsertBoards(admin, categoryMap) {
|
||||
|
||||
async function upsertViewTypes() {
|
||||
const viewTypes = [
|
||||
// main scope
|
||||
{ key: "main_default", name: "기본", scope: "main" },
|
||||
// main scope (기본/없음 제거, 텍스트 중심)
|
||||
{ key: "main_text", name: "텍스트", scope: "main" },
|
||||
{ key: "main_preview", name: "미리보기", scope: "main" },
|
||||
{ key: "main_special_rank", name: "특수랭킹", scope: "main" },
|
||||
// list scope
|
||||
{ key: "list_default", name: "기본", scope: "list" },
|
||||
// list scope (기본/없음 제거, 텍스트 중심)
|
||||
{ key: "list_text", name: "텍스트", scope: "list" },
|
||||
{ key: "list_preview", name: "미리보기", scope: "list" },
|
||||
{ key: "list_special_rank", name: "특수랭킹", scope: "list" },
|
||||
{ key: "list_special_attendance", name: "특수출석", scope: "list" },
|
||||
];
|
||||
for (const vt of viewTypes) {
|
||||
await prisma.boardViewType.upsert({
|
||||
@@ -404,6 +421,8 @@ async function createPostsForAllBoards(boards, countPerBoard = 100, admin) {
|
||||
const users = await prisma.user.findMany({ select: { userId: true } });
|
||||
const userIds = users.map((u) => u.userId);
|
||||
for (const board of boards) {
|
||||
// 회원랭킹 보드는 특수랭킹용이라 게시글을 시드하지 않습니다.
|
||||
if (board.slug === "ranking") continue;
|
||||
const data = [];
|
||||
for (let i = 0; i < countPerBoard; i++) {
|
||||
const authorId = ["notice", "bug-report"].includes(board.slug)
|
||||
@@ -530,6 +549,21 @@ async function main() {
|
||||
);
|
||||
await prisma.$executeRawUnsafe("CREATE INDEX IF NOT EXISTS idx_partner_categories_sortOrder ON partner_categories(sortOrder)");
|
||||
}
|
||||
// Attendance 테이블 보장 (마이그레이션 미실행 환경 대응)
|
||||
const att = await prisma.$queryRaw`SELECT name FROM sqlite_master WHERE type='table' AND name='attendance'`;
|
||||
if (!Array.isArray(att) || att.length === 0) {
|
||||
console.log("Creating missing table: attendance");
|
||||
await prisma.$executeRawUnsafe(
|
||||
"CREATE TABLE IF NOT EXISTS attendance (\n" +
|
||||
"id TEXT PRIMARY KEY,\n" +
|
||||
"userId TEXT NOT NULL,\n" +
|
||||
"date DATETIME NOT NULL,\n" +
|
||||
"createdAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP\n" +
|
||||
")"
|
||||
);
|
||||
await prisma.$executeRawUnsafe("CREATE UNIQUE INDEX IF NOT EXISTS idx_attendance_user_date ON attendance(userId, date)");
|
||||
await prisma.$executeRawUnsafe("CREATE INDEX IF NOT EXISTS idx_attendance_date ON attendance(date)");
|
||||
}
|
||||
const cols = await prisma.$queryRaw`PRAGMA table_info('partners')`;
|
||||
const hasCategoryId = Array.isArray(cols) && cols.some((c) => (c.name || c.COLUMN_NAME) === 'categoryId');
|
||||
if (!hasCategoryId) {
|
||||
@@ -566,6 +600,7 @@ async function main() {
|
||||
await createRandomUsers(100);
|
||||
await removeNonPrimaryBoards();
|
||||
const boards = await upsertBoards(admin, categoryMap);
|
||||
await seedAdminAttendance(admin);
|
||||
await seedMainpageVisibleBoards(boards);
|
||||
await createPostsForAllBoards(boards, 100, admin);
|
||||
await seedPartnerShops();
|
||||
|
||||
@@ -33,6 +33,8 @@ export default function AdminBoardsPage() {
|
||||
const listTypes = useMemo(() => viewTypes.filter((v: any) => v.scope === 'list'), [viewTypes]);
|
||||
const defaultMainTypeId = useMemo(() => (mainTypes.find((t: any) => t.key === 'main_default' || t.key === 'default')?.id ?? null), [mainTypes]);
|
||||
const defaultListTypeId = useMemo(() => (listTypes.find((t: any) => t.key === 'list_default' || t.key === 'default')?.id ?? null), [listTypes]);
|
||||
const textMainTypeId = useMemo(() => (mainTypes.find((t: any) => t.key === 'main_text')?.id ?? null), [mainTypes]);
|
||||
const textListTypeId = useMemo(() => (listTypes.find((t: any) => t.key === 'list_text')?.id ?? null), [listTypes]);
|
||||
const categories = useMemo(() => {
|
||||
const merged = rawCategories.map((c: any) => ({ ...c, ...(dirtyCats[c.id] ?? {}) }));
|
||||
return merged.sort((a: any, b: any) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
|
||||
@@ -237,7 +239,7 @@ export default function AdminBoardsPage() {
|
||||
return;
|
||||
}
|
||||
const sortOrder = (currentItems?.length ?? 0) + 1;
|
||||
await fetch(`/api/admin/boards`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name, slug, categoryId: catId === "uncat" ? null : catId, sortOrder, status: "active", mainPageViewTypeId: defaultMainTypeId, listViewTypeId: defaultListTypeId }) });
|
||||
await fetch(`/api/admin/boards`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name, slug, categoryId: catId === "uncat" ? null : catId, sortOrder, status: "active", mainPageViewTypeId: textMainTypeId, listViewTypeId: textListTypeId }) });
|
||||
await mutateBoards();
|
||||
}
|
||||
|
||||
@@ -436,12 +438,20 @@ export default function AdminBoardsPage() {
|
||||
|
||||
function BoardRowCells({ b, onDirty, onDelete, allowMove, categories, onMove, mainTypes, listTypes, onAddType, defaultMainTypeId, defaultListTypeId }: { b: any; onDirty: (id: string, draft: any) => void; onDelete: () => void; allowMove?: boolean; categories?: any[]; onMove?: (toId: string) => void; mainTypes?: any[]; listTypes?: any[]; onAddType?: (scope: 'main'|'list') => Promise<string | null>; defaultMainTypeId?: string | null; defaultListTypeId?: string | null }) {
|
||||
const [edit, setEdit] = useState(b);
|
||||
const effectiveMainTypeId = edit.mainPageViewTypeId ?? defaultMainTypeId ?? '';
|
||||
const effectiveListTypeId = edit.listViewTypeId ?? defaultListTypeId ?? '';
|
||||
// 선택 가능 옵션에서 '기본' 타입은 제외
|
||||
const selectableMainTypes = (mainTypes ?? []).filter((t: any) => t.id !== defaultMainTypeId);
|
||||
const selectableListTypes = (listTypes ?? []).filter((t: any) => t.id !== defaultListTypeId);
|
||||
// 표시 값: 현재 값이 선택 가능 목록에 없으면 첫 번째 항목을 사용
|
||||
const effectiveMainTypeId = selectableMainTypes.some((t: any) => t.id === edit.mainPageViewTypeId)
|
||||
? edit.mainPageViewTypeId
|
||||
: (selectableMainTypes[0]?.id ?? '');
|
||||
const effectiveListTypeId = selectableListTypes.some((t: any) => t.id === edit.listViewTypeId)
|
||||
? edit.listViewTypeId
|
||||
: (selectableListTypes[0]?.id ?? '');
|
||||
return (
|
||||
<>
|
||||
<td className="px-3 py-2"><input className="h-9 w-full rounded-md border border-neutral-300 px-2 text-sm" value={edit.name} onChange={(e) => { const v = { ...edit, name: e.target.value }; setEdit(v); onDirty(b.id, v); }} /></td>
|
||||
<td className="px-3 py-2"><input className="h-9 w-full rounded-md border border-neutral-300 px-2 text-sm" value={edit.slug} onChange={(e) => { const v = { ...edit, slug: e.target.value }; setEdit(v); onDirty(b.id, v); }} /></td>
|
||||
<td className="px-3 py-2"><input className="h-9 w-full min-w-[160px] rounded-md border border-neutral-300 px-2 text-sm" value={edit.name} onChange={(e) => { const v = { ...edit, name: e.target.value }; setEdit(v); onDirty(b.id, v); }} /></td>
|
||||
<td className="px-3 py-2"><input className="h-9 w-full min-w-[200px] rounded-md border border-neutral-300 px-2 text-sm" value={edit.slug} onChange={(e) => { const v = { ...edit, slug: e.target.value }; setEdit(v); onDirty(b.id, v); }} /></td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<select
|
||||
className="h-9 rounded-md border border-neutral-300 px-2 text-sm"
|
||||
@@ -453,12 +463,11 @@ function BoardRowCells({ b, onDirty, onDelete, allowMove, categories, onMove, ma
|
||||
e.currentTarget.value = id ?? '';
|
||||
return;
|
||||
}
|
||||
const v = { ...edit, mainPageViewTypeId: e.target.value || null };
|
||||
const v = { ...edit, mainPageViewTypeId: e.target.value };
|
||||
setEdit(v); onDirty(b.id, v);
|
||||
}}
|
||||
>
|
||||
<option value="">(없음)</option>
|
||||
{(mainTypes ?? []).map((t: any) => (<option key={t.id} value={t.id}>{t.name}</option>))}
|
||||
{(selectableMainTypes ?? []).map((t: any) => (<option key={t.id} value={t.id}>{t.name}</option>))}
|
||||
<option value="__add__">+ 새 타입 추가…</option>
|
||||
</select>
|
||||
</td>
|
||||
@@ -473,12 +482,11 @@ function BoardRowCells({ b, onDirty, onDelete, allowMove, categories, onMove, ma
|
||||
e.currentTarget.value = id ?? '';
|
||||
return;
|
||||
}
|
||||
const v = { ...edit, listViewTypeId: e.target.value || null };
|
||||
const v = { ...edit, listViewTypeId: e.target.value };
|
||||
setEdit(v); onDirty(b.id, v);
|
||||
}}
|
||||
>
|
||||
<option value="">(없음)</option>
|
||||
{(listTypes ?? []).map((t: any) => (<option key={t.id} value={t.id}>{t.name}</option>))}
|
||||
{(selectableListTypes ?? []).map((t: any) => (<option key={t.id} value={t.id}>{t.name}</option>))}
|
||||
<option value="__add__">+ 새 타입 추가…</option>
|
||||
</select>
|
||||
</td>
|
||||
@@ -551,8 +559,8 @@ function CategoryHeaderContent({ g, onDirty }: { g: any; onDirty: (payload: any)
|
||||
return (
|
||||
<>
|
||||
<div className="w-10" />
|
||||
<input className="h-8 rounded-md border border-neutral-300 px-2 text-sm w-48" value={edit.name} onChange={(e) => { const v = { ...edit, name: e.target.value }; setEdit(v); onDirty(v); }} />
|
||||
<input className="h-8 rounded-md border border-neutral-300 px-2 text-sm w-48" value={edit.slug} onChange={(e) => { const v = { ...edit, slug: e.target.value }; setEdit(v); onDirty(v); }} />
|
||||
<input className="h-8 rounded-md border border-neutral-300 px-2 text-sm w-48 min-w-[160px]" value={edit.name} onChange={(e) => { const v = { ...edit, name: e.target.value }; setEdit(v); onDirty(v); }} />
|
||||
<input className="h-8 rounded-md border border-neutral-300 px-2 text-sm w-48 min-w-[200px]" value={edit.slug} onChange={(e) => { const v = { ...edit, slug: e.target.value }; setEdit(v); onDirty(v); }} />
|
||||
<div className="flex-1" />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,32 @@
|
||||
import type { Metadata } from "next";
|
||||
import AdminSidebar from "@/app/admin/AdminSidebar";
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Admin | ASSM",
|
||||
};
|
||||
|
||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
// 서버에서 쿠키 기반 접근 제어 (미들웨어 보조)
|
||||
const h = await headers();
|
||||
const cookieHeader = h.get("cookie") || "";
|
||||
const uid = cookieHeader
|
||||
.split(";")
|
||||
.map((s) => s.trim())
|
||||
.find((pair) => pair.startsWith("uid="))
|
||||
?.split("=")[1];
|
||||
const isAdmin = cookieHeader
|
||||
.split(";")
|
||||
.map((s) => s.trim())
|
||||
.find((pair) => pair.startsWith("isAdmin="))
|
||||
?.split("=")[1];
|
||||
if (!uid) {
|
||||
redirect("/login");
|
||||
}
|
||||
if (isAdmin !== "1") {
|
||||
redirect("/");
|
||||
}
|
||||
return (
|
||||
<div className="min-h-[calc(100vh-0px)] flex">
|
||||
<AdminSidebar />
|
||||
|
||||
66
src/app/api/attendance/me-stats/route.ts
Normal file
66
src/app/api/attendance/me-stats/route.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { getUserIdFromRequest } from "@/lib/auth";
|
||||
|
||||
function toYmdUTC(d: Date): string {
|
||||
const yy = d.getUTCFullYear();
|
||||
const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
|
||||
const dd = String(d.getUTCDate()).padStart(2, "0");
|
||||
return `${yy}-${mm}-${dd}`;
|
||||
}
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const userId = getUserIdFromRequest(req);
|
||||
if (!userId) return NextResponse.json({ total: 0, currentStreak: 0, maxStreak: 0 });
|
||||
|
||||
// 총 출석일
|
||||
const total = await prisma.attendance.count({ where: { userId } });
|
||||
|
||||
// 모든 출석일(UTC 자정 기준) 가져와서 streak 계산
|
||||
const rows = await prisma.attendance.findMany({
|
||||
where: { userId },
|
||||
select: { date: true },
|
||||
orderBy: { date: "asc" },
|
||||
});
|
||||
const days = Array.from(new Set(rows.map((r) => toYmdUTC(new Date(r.date))))); // unique, asc
|
||||
|
||||
// 현재 연속 출석
|
||||
let currentStreak = 0;
|
||||
if (days.length > 0) {
|
||||
const set = new Set(days);
|
||||
const now = new Date();
|
||||
let cursor = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), 0, 0, 0, 0));
|
||||
while (true) {
|
||||
const ymd = toYmdUTC(cursor);
|
||||
if (set.has(ymd)) {
|
||||
currentStreak += 1;
|
||||
cursor.setUTCDate(cursor.getUTCDate() - 1);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 최대 연속 출석
|
||||
let maxStreak = 0;
|
||||
if (days.length > 0) {
|
||||
// scan through sorted list
|
||||
let localMax = 1;
|
||||
for (let i = 1; i < days.length; i++) {
|
||||
const prev = new Date(days[i - 1] + "T00:00:00.000Z");
|
||||
const cur = new Date(days[i] + "T00:00:00.000Z");
|
||||
const diff = (cur.getTime() - prev.getTime()) / (24 * 60 * 60 * 1000);
|
||||
if (diff === 1) {
|
||||
localMax += 1;
|
||||
} else if (diff > 1) {
|
||||
if (localMax > maxStreak) maxStreak = localMax;
|
||||
localMax = 1;
|
||||
}
|
||||
}
|
||||
if (localMax > maxStreak) maxStreak = localMax;
|
||||
}
|
||||
|
||||
return NextResponse.json({ total, currentStreak, maxStreak });
|
||||
}
|
||||
|
||||
|
||||
83
src/app/api/attendance/rankings/route.ts
Normal file
83
src/app/api/attendance/rankings/route.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import prisma from "@/lib/prisma";
|
||||
|
||||
function toYmdUTC(d: Date): string {
|
||||
const yy = d.getUTCFullYear();
|
||||
const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
|
||||
const dd = String(d.getUTCDate()).padStart(2, "0");
|
||||
return `${yy}-${mm}-${dd}`;
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
// 전체 출석 누적 상위 (Top 10)
|
||||
const overallGroups = await prisma.attendance.groupBy({
|
||||
by: ["userId"],
|
||||
_count: { _all: true },
|
||||
orderBy: { _count: { _all: "desc" } },
|
||||
take: 20,
|
||||
});
|
||||
const overallUserIds = overallGroups.map((g) => g.userId);
|
||||
const overallUsers = await prisma.user.findMany({
|
||||
where: { userId: { in: overallUserIds } },
|
||||
select: { userId: true, nickname: true, profileImage: true, grade: true },
|
||||
});
|
||||
const userMeta = new Map(overallUsers.map((u) => [u.userId, u]));
|
||||
const overall = overallGroups.map((g) => ({
|
||||
userId: g.userId,
|
||||
nickname: userMeta.get(g.userId)?.nickname ?? "회원",
|
||||
count: g._count._all,
|
||||
profileImage: userMeta.get(g.userId)?.profileImage ?? null,
|
||||
grade: userMeta.get(g.userId)?.grade ?? 0,
|
||||
}));
|
||||
|
||||
// 연속 출석 상위 (Top 10, 현재 연속 기준)
|
||||
const now = new Date();
|
||||
const todayUTC = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), 0, 0, 0, 0));
|
||||
const past = new Date(todayUTC);
|
||||
past.setUTCDate(past.getUTCDate() - 60); // 최근 60일 데이터만으로 streak 계산
|
||||
const recent = await prisma.attendance.findMany({
|
||||
where: { date: { gte: past } },
|
||||
select: { userId: true, date: true },
|
||||
orderBy: { userId: "asc" },
|
||||
});
|
||||
const map = new Map<string, Set<string>>();
|
||||
for (const r of recent) {
|
||||
const set = map.get(r.userId) ?? new Set<string>();
|
||||
set.add(toYmdUTC(new Date(r.date)));
|
||||
map.set(r.userId, set);
|
||||
}
|
||||
const streakArr: { userId: string; streak: number }[] = [];
|
||||
for (const [userId, set] of map.entries()) {
|
||||
let streak = 0;
|
||||
let cursor = new Date(todayUTC);
|
||||
while (streak < 60) {
|
||||
const ymd = toYmdUTC(cursor);
|
||||
if (set.has(ymd)) {
|
||||
streak += 1;
|
||||
cursor.setUTCDate(cursor.getUTCDate() - 1);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (streak > 0) streakArr.push({ userId, streak });
|
||||
}
|
||||
streakArr.sort((a, b) => b.streak - a.streak);
|
||||
const topStreak = streakArr.slice(0, 20);
|
||||
const streakUserIds = topStreak.map((s) => s.userId);
|
||||
const streakUsers = await prisma.user.findMany({
|
||||
where: { userId: { in: streakUserIds } },
|
||||
select: { userId: true, nickname: true, profileImage: true, grade: true },
|
||||
});
|
||||
const streakMeta = new Map(streakUsers.map((u) => [u.userId, u]));
|
||||
const streak = topStreak.map((s) => ({
|
||||
userId: s.userId,
|
||||
nickname: streakMeta.get(s.userId)?.nickname ?? "회원",
|
||||
streak: s.streak,
|
||||
profileImage: streakMeta.get(s.userId)?.profileImage ?? null,
|
||||
grade: streakMeta.get(s.userId)?.grade ?? 0,
|
||||
}));
|
||||
|
||||
return NextResponse.json({ overall, streak });
|
||||
}
|
||||
|
||||
|
||||
@@ -4,13 +4,38 @@ import { getUserIdFromRequest } from "@/lib/auth";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const userId = getUserIdFromRequest(req);
|
||||
if (!userId) return NextResponse.json({ today: null, count: 0 });
|
||||
if (!userId) return NextResponse.json({ today: null, count: 0, days: [] });
|
||||
const url = new URL(req.url);
|
||||
const year = url.searchParams.get("year");
|
||||
const month = url.searchParams.get("month"); // 1-12
|
||||
const start = new Date(); start.setHours(0,0,0,0);
|
||||
const end = new Date(); end.setHours(23,59,59,999);
|
||||
const today = await prisma.pointTransaction.findFirst({
|
||||
where: { userId, reason: "attendance", createdAt: { gte: start, lte: end } },
|
||||
const today = await prisma.attendance.findFirst({
|
||||
where: { userId, date: { gte: start, lte: end } },
|
||||
});
|
||||
const count = await prisma.pointTransaction.count({ where: { userId, reason: "attendance" } });
|
||||
const count = await prisma.attendance.count({ where: { userId } });
|
||||
// 월별 출석 일자 목록
|
||||
if (year && month) {
|
||||
const y = parseInt(year, 10);
|
||||
const m = parseInt(month, 10);
|
||||
if (!isNaN(y) && !isNaN(m) && m >= 1 && m <= 12) {
|
||||
const firstDay = new Date(Date.UTC(y, m - 1, 1, 0, 0, 0, 0));
|
||||
const lastDay = new Date(Date.UTC(y, m, 0, 23, 59, 59, 999));
|
||||
const records = await prisma.attendance.findMany({
|
||||
where: { userId, date: { gte: firstDay, lte: lastDay } },
|
||||
select: { date: true },
|
||||
orderBy: { date: "asc" },
|
||||
});
|
||||
const days = records.map(r => {
|
||||
const d = new Date(r.date);
|
||||
const yy = d.getUTCFullYear();
|
||||
const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
|
||||
const dd = String(d.getUTCDate()).padStart(2, "0");
|
||||
return `${yy}-${mm}-${dd}`;
|
||||
});
|
||||
return NextResponse.json({ today: !!today, count, days });
|
||||
}
|
||||
}
|
||||
return NextResponse.json({ today: !!today, count });
|
||||
}
|
||||
|
||||
@@ -19,9 +44,12 @@ export async function POST(req: Request) {
|
||||
if (!userId) return NextResponse.json({ error: "login required" }, { status: 401 });
|
||||
const start = new Date(); start.setHours(0,0,0,0);
|
||||
const end = new Date(); end.setHours(23,59,59,999);
|
||||
const exists = await prisma.pointTransaction.findFirst({ where: { userId, reason: "attendance", createdAt: { gte: start, lte: end } } });
|
||||
const exists = await prisma.attendance.findFirst({ where: { userId, date: { gte: start, lte: end } } });
|
||||
if (exists) return NextResponse.json({ ok: true, duplicated: true });
|
||||
await prisma.pointTransaction.create({ data: { userId, amount: 10, reason: "attendance" } });
|
||||
// normalize to UTC midnight
|
||||
const now = new Date();
|
||||
const normalized = new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0));
|
||||
await prisma.attendance.create({ data: { userId, date: normalized } });
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
|
||||
@@ -8,15 +8,13 @@ export async function POST(req: Request) {
|
||||
const parsed = registerSchema.safeParse(body);
|
||||
if (!parsed.success)
|
||||
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
||||
const { nickname, name, phone, birth, password } = parsed.data;
|
||||
const exists = await prisma.user.findFirst({ where: { OR: [{ nickname }, { phone }] } });
|
||||
const { nickname, name, password } = parsed.data;
|
||||
const exists = await prisma.user.findFirst({ where: { nickname } });
|
||||
if (exists) return NextResponse.json({ error: "이미 존재하는 사용자" }, { status: 409 });
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
nickname,
|
||||
name,
|
||||
phone,
|
||||
birth: new Date(birth),
|
||||
passwordHash: hashPassword(password),
|
||||
agreementTermsAt: new Date(),
|
||||
},
|
||||
|
||||
31
src/app/api/me/profile-image/route.ts
Normal file
31
src/app/api/me/profile-image/route.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { getUserIdOrAdmin } from "@/lib/auth";
|
||||
|
||||
export async function PUT(req: Request) {
|
||||
const userId = await getUserIdOrAdmin(req);
|
||||
if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
try {
|
||||
const body = await req.json();
|
||||
const url: string | null = body?.url ?? null;
|
||||
if (url !== null && typeof url !== "string") {
|
||||
return NextResponse.json({ error: "invalid url" }, { status: 400 });
|
||||
}
|
||||
// 간단 검증: 내부 업로드 경로 또는 http(s) 허용
|
||||
if (url) {
|
||||
const ok = url.startsWith("/uploads/") || url.startsWith("http://") || url.startsWith("https://");
|
||||
if (!ok) return NextResponse.json({ error: "invalid url" }, { status: 400 });
|
||||
if (url.length > 1000) return NextResponse.json({ error: "url too long" }, { status: 400 });
|
||||
}
|
||||
const user = await prisma.user.update({
|
||||
where: { userId },
|
||||
data: { profileImage: url || null },
|
||||
select: { userId: true, profileImage: true },
|
||||
});
|
||||
return NextResponse.json({ ok: true, user });
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Bad Request" }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { RankIcon1st } from "@/app/components/RankIcon1st";
|
||||
import { RankIcon2nd } from "@/app/components/RankIcon2nd";
|
||||
import { RankIcon3rd } from "@/app/components/RankIcon3rd";
|
||||
import { GradeIcon } from "@/app/components/GradeIcon";
|
||||
import AttendanceCalendar from "@/app/components/AttendanceCalendar";
|
||||
|
||||
// Next 15: params/searchParams가 Promise가 될 수 있어 안전 언랩 처리합니다.
|
||||
export default async function BoardDetail({ params, searchParams }: { params: any; searchParams: any }) {
|
||||
@@ -21,6 +22,14 @@ export default async function BoardDetail({ params, searchParams }: { params: an
|
||||
const host = h.get("host") ?? "localhost:3000";
|
||||
const proto = h.get("x-forwarded-proto") ?? "http";
|
||||
const base = process.env.NEXT_PUBLIC_BASE_URL || `${proto}://${host}`;
|
||||
// 로그인 여부 파악
|
||||
const cookieHeader = h.get("cookie") || "";
|
||||
const uid = cookieHeader
|
||||
.split(";")
|
||||
.map((s) => s.trim())
|
||||
.find((pair) => pair.startsWith("uid="))
|
||||
?.split("=")[1];
|
||||
const isLoggedIn = !!uid;
|
||||
const res = await fetch(new URL("/api/boards", base).toString(), { cache: "no-store" });
|
||||
const { boards } = await res.json();
|
||||
const board = (boards || []).find((b: any) => b.slug === idOrSlug || b.id === idOrSlug);
|
||||
@@ -33,12 +42,13 @@ export default async function BoardDetail({ params, searchParams }: { params: an
|
||||
const parsed = settingRow ? JSON.parse(settingRow.value as string) : {};
|
||||
const showBanner: boolean = parsed.showBanner ?? true;
|
||||
|
||||
// 리스트 뷰 타입 확인 (특수랭킹일 경우 게시글 대신 랭킹 노출)
|
||||
// 리스트 뷰 타입 확인 (특수랭킹/출석부 등)
|
||||
const boardView = await prisma.board.findUnique({
|
||||
where: { id },
|
||||
select: { listViewType: { select: { key: true } } },
|
||||
});
|
||||
const isSpecialRanking = boardView?.listViewType?.key === "list_special_rank";
|
||||
const isAttendance = boardView?.listViewType?.key === "list_special_attendance";
|
||||
|
||||
let rankingItems: { userId: string; nickname: string; points: number; profileImage: string | null; grade: number }[] = [];
|
||||
if (isSpecialRanking) {
|
||||
@@ -84,7 +94,7 @@ export default async function BoardDetail({ params, searchParams }: { params: an
|
||||
|
||||
{/* 검색/필터 툴바 + 리스트 */}
|
||||
<section className="px-[0px] md:px-[30px] ">
|
||||
{!isSpecialRanking && <BoardToolbar boardId={board?.slug} />}
|
||||
{!isSpecialRanking && !isAttendance && <BoardToolbar boardId={board?.slug} />}
|
||||
<div className="p-0">
|
||||
{isSpecialRanking ? (
|
||||
<div className="w-full">
|
||||
@@ -128,6 +138,10 @@ export default async function BoardDetail({ params, searchParams }: { params: an
|
||||
<div className="px-4 py-10 text-center text-neutral-500">랭킹 데이터가 없습니다.</div>
|
||||
)}
|
||||
</div>
|
||||
) : isAttendance ? (
|
||||
<div className="w-full py-4">
|
||||
<AttendanceCalendar isLoggedIn={isLoggedIn} />
|
||||
</div>
|
||||
) : (
|
||||
<PostList
|
||||
boardId={id}
|
||||
|
||||
@@ -471,27 +471,39 @@ export function AppHeader() {
|
||||
</div>
|
||||
</div>
|
||||
<div id="dummy" className="block"></div>
|
||||
<div className="hidden xl:flex xl:flex-1 justify-end">
|
||||
<div className="hidden xl:flex xl:flex-1 justify-end items-center gap-3">
|
||||
<SearchBar/>
|
||||
{authData?.user && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await fetch("/api/auth/session", { method: "DELETE" });
|
||||
} finally {
|
||||
window.location.reload();
|
||||
}
|
||||
}}
|
||||
className="ml-3 inline-flex items-center px-3 h-10 rounded-md border border-neutral-300 text-neutral-700 hover:bg-neutral-100"
|
||||
aria-label="로그아웃"
|
||||
>
|
||||
로그아웃
|
||||
</button>
|
||||
)}
|
||||
{!authData?.user && (
|
||||
{authData?.user ? (
|
||||
<>
|
||||
{/* 인사 + 로그아웃을 하나의 배지로 묶어 자연스럽게 표시 */}
|
||||
<div className="inline-flex items-center h-10 rounded-md border border-neutral-300 bg-white overflow-hidden">
|
||||
<Link
|
||||
href="/my-page"
|
||||
aria-label="마이페이지"
|
||||
className="h-full px-3 inline-flex items-center text-sm text-neutral-800 hover:bg-neutral-100 cursor-pointer truncate max-w-[220px]"
|
||||
>
|
||||
{authData.user.nickname}님 안녕하세요
|
||||
</Link>
|
||||
<span aria-hidden className="w-px h-5 bg-neutral-200" />
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await fetch("/api/auth/session", { method: "DELETE" });
|
||||
} finally {
|
||||
window.location.reload();
|
||||
}
|
||||
}}
|
||||
className="h-full px-3 text-sm text-neutral-700 hover:bg-neutral-100 focus:outline-none cursor-pointer"
|
||||
aria-label="로그아웃"
|
||||
>
|
||||
로그아웃
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Link
|
||||
href={`/login?next=${encodeURIComponent((pathname || "/") + (searchParams?.toString() ? `?${searchParams.toString()}` : ""))}`}
|
||||
className="ml-3 inline-flex items-center px-3 h-10 rounded-md border border-neutral-300 text-neutral-700 hover:bg-neutral-100"
|
||||
className="inline-flex items-center px-3 h-10 rounded-md border border-neutral-300 text-neutral-700 hover:bg-neutral-100"
|
||||
aria-label="로그인"
|
||||
>
|
||||
로그인
|
||||
|
||||
231
src/app/components/AttendanceCalendar.tsx
Normal file
231
src/app/components/AttendanceCalendar.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import useSWR from "swr";
|
||||
|
||||
function getMonthRange(date: Date) {
|
||||
const y = date.getFullYear();
|
||||
const m = date.getMonth(); // 0-based
|
||||
const first = new Date(y, m, 1);
|
||||
const last = new Date(y, m + 1, 0);
|
||||
return { y, m, first, last };
|
||||
}
|
||||
|
||||
export default function AttendanceCalendar({ isLoggedIn = true }: { isLoggedIn?: boolean }) {
|
||||
const [current, setCurrent] = React.useState(new Date());
|
||||
const [days, setDays] = React.useState<string[]>([]);
|
||||
const [todayChecked, setTodayChecked] = React.useState<boolean | null>(null);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
|
||||
const { y, m } = getMonthRange(current);
|
||||
const ymKey = `${y}-${String(m + 1).padStart(2, "0")}`;
|
||||
const today = React.useMemo(() => {
|
||||
const d = new Date();
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
if (!isLoggedIn) {
|
||||
setDays([]);
|
||||
setTodayChecked(null);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/attendance?year=${y}&month=${m + 1}`);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!cancelled) {
|
||||
setDays(Array.isArray(data?.days) ? data.days : []);
|
||||
setTodayChecked(!!data?.today);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [y, m, ymKey, isLoggedIn]);
|
||||
|
||||
const goPrev = () => setCurrent(new Date(current.getFullYear(), current.getMonth() - 1, 1));
|
||||
const goNext = () => setCurrent(new Date(current.getFullYear(), current.getMonth() + 1, 1));
|
||||
|
||||
const handleCheckIn = async () => {
|
||||
if (!isLoggedIn) return;
|
||||
try {
|
||||
const res = await fetch("/api/attendance", { method: "POST" });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (res.ok && !data?.duplicated) {
|
||||
// 오늘 날짜 문자열
|
||||
const d = new Date();
|
||||
const s = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
setDays((prev) => prev.includes(s) ? prev : [...prev, s]);
|
||||
setTodayChecked(true);
|
||||
} else {
|
||||
setTodayChecked(true);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
// 캘린더 데이터 생성
|
||||
const firstOfMonth = new Date(y, m, 1);
|
||||
const startWeekday = firstOfMonth.getDay(); // 0:Sun
|
||||
const lastOfMonth = new Date(y, m + 1, 0).getDate();
|
||||
const weeks: Array<Array<{ d: number | null; dateStr?: string; checked?: boolean }>> = [];
|
||||
let day = 1;
|
||||
for (let w = 0; w < 6; w++) {
|
||||
const row: any[] = [];
|
||||
for (let wd = 0; wd < 7; wd++) {
|
||||
if (w === 0 && wd < startWeekday) {
|
||||
row.push({ d: null });
|
||||
} else if (day > lastOfMonth) {
|
||||
row.push({ d: null });
|
||||
} else {
|
||||
const dateStr = `${y}-${String(m + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
||||
row.push({ d: day, dateStr, checked: days.includes(dateStr) });
|
||||
day++;
|
||||
}
|
||||
}
|
||||
weeks.push(row);
|
||||
if (day > lastOfMonth) break;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-[520px] mx-auto bg-white rounded-xl border border-neutral-200 p-4">
|
||||
<div className="relative">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<button onClick={goPrev} className="h-8 w-8 rounded-md border border-neutral-300 hover:bg-neutral-50">‹</button>
|
||||
<div className="text-sm font-semibold text-neutral-800">
|
||||
{y}년 {m + 1}월
|
||||
</div>
|
||||
<button onClick={goNext} className="h-8 w-8 rounded-md border border-neutral-300 hover:bg-neutral-50">›</button>
|
||||
</div>
|
||||
<table className="w-full text-center text-sm">
|
||||
<thead className="text-neutral-500">
|
||||
<tr>
|
||||
{["일","월","화","수","목","금","토"].map((h) => (<th key={h} className="py-1">{h}</th>))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{weeks.map((row, i) => (
|
||||
<tr key={i}>
|
||||
{row.map((cell, j) => (
|
||||
<td key={`${i}-${j}`} className="h-9 align-middle">
|
||||
{cell.d ? (
|
||||
(() => {
|
||||
const isToday = cell.dateStr === today;
|
||||
const base = "mx-auto w-8 h-8 leading-8 rounded-full";
|
||||
const color = cell.checked ? " bg-[#F94B37] text-white" : " text-neutral-700";
|
||||
const ring = isToday ? (cell.checked ? " ring-2 ring-offset-2 ring-[#F94B37]" : " ring-2 ring-[#F94B37]") : "";
|
||||
return (
|
||||
<div className={`${base}${color}${ring}`}>
|
||||
{cell.d}
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
) : (
|
||||
<div className="w-7 h-7 mx-auto" />
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="mt-2 flex items-center justify-center gap-3 text-[11px] text-neutral-600">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="inline-block w-3 h-3 rounded-full bg-[#F94B37]" />
|
||||
출석일
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="inline-block w-3 h-3 rounded-full ring-2 ring-[#F94B37]" />
|
||||
오늘
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center justify-center">
|
||||
<button
|
||||
onClick={handleCheckIn}
|
||||
disabled={todayChecked === true || loading || !isLoggedIn}
|
||||
className="h-9 px-4 rounded-md bg-neutral-900 text-white text-sm hover:bg-neutral-800 disabled:opacity-50"
|
||||
>
|
||||
{todayChecked ? "오늘 출석 완료" : "오늘 출석하기"}
|
||||
</button>
|
||||
</div>
|
||||
{!isLoggedIn && (
|
||||
<div className="absolute inset-0 bg-white/70 backdrop-blur-[1px] rounded-lg flex items-center justify-center">
|
||||
<div className="text-sm font-semibold text-neutral-700">로그인이 필요합니다</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* 내 출석 통계 */}
|
||||
{isLoggedIn && <MyAttendanceStats />}
|
||||
<Rankings />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MyAttendanceStats() {
|
||||
const { data } = useSWR<{ total: number; currentStreak: number; maxStreak: number }>(
|
||||
"/api/attendance/me-stats",
|
||||
(u) => fetch(u).then((r) => r.json())
|
||||
);
|
||||
const total = data?.total ?? 0;
|
||||
const current = data?.currentStreak ?? 0;
|
||||
const max = data?.maxStreak ?? 0;
|
||||
return (
|
||||
<div className="mt-4 grid grid-cols-3 gap-2">
|
||||
<div className="rounded-lg border border-neutral-200 px-3 py-2 text-center">
|
||||
<div className="text-[11px] text-neutral-600">내 출석일수</div>
|
||||
<div className="text-lg font-semibold text-neutral-900">{total}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-neutral-200 px-3 py-2 text-center">
|
||||
<div className="text-[11px] text-neutral-600">연속출석</div>
|
||||
<div className="text-lg font-semibold text-neutral-900">{current}일</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-neutral-200 px-3 py-2 text-center">
|
||||
<div className="text-[11px] text-neutral-600">최대 연속</div>
|
||||
<div className="text-lg font-semibold text-neutral-900">{max}일</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Rankings() {
|
||||
const { data } = useSWR<{ overall: any[]; streak: any[] }>("/api/attendance/rankings", (u) => fetch(u).then((r) => r.json()));
|
||||
const overall = data?.overall ?? [];
|
||||
const streak = data?.streak ?? [];
|
||||
return (
|
||||
<div className="mt-6 grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="rounded-lg border border-neutral-200">
|
||||
<div className="px-3 py-2 border-b border-neutral-200 text-sm font-semibold">연속출석 순위</div>
|
||||
<ol className="p-3 space-y-2">
|
||||
{streak.length === 0 && <li className="text-xs text-neutral-500">데이터가 없습니다.</li>}
|
||||
{streak.map((u, idx) => (
|
||||
<li key={u.userId} className="flex items-center gap-3 text-sm">
|
||||
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-neutral-900 text-white text-xs">{idx + 1}</span>
|
||||
<span className="truncate">{u.nickname}</span>
|
||||
<span className="ml-auto text-xs text-neutral-600">{u.streak}일</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
<div className="rounded-lg border border-neutral-200">
|
||||
<div className="px-3 py-2 border-b border-neutral-200 text-sm font-semibold">전체 출석 순위</div>
|
||||
<ol className="p-3 space-y-2">
|
||||
{overall.length === 0 && <li className="text-xs text-neutral-500">데이터가 없습니다.</li>}
|
||||
{overall.map((u, idx) => (
|
||||
<li key={u.userId} className="flex items-center gap-3 text-sm">
|
||||
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-neutral-900 text-white text-xs">{idx + 1}</span>
|
||||
<span className="truncate">{u.nickname}</span>
|
||||
<span className="ml-auto text-xs text-neutral-600">{u.count}회</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
63
src/app/components/InlineLoginForm.tsx
Normal file
63
src/app/components/InlineLoginForm.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
export function InlineLoginForm({ next = "/" }: { next?: string }) {
|
||||
const [nickname, setNickname] = React.useState("");
|
||||
const [password, setPassword] = React.useState("");
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const onSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch("/api/auth/session", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ nickname, password }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data?.error || "로그인 실패");
|
||||
// 성공 시 다음 경로로 이동 (기본은 홈)
|
||||
window.location.href = next || "/";
|
||||
} catch (err: any) {
|
||||
setError(err?.message || "로그인 실패");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="w-[300px] flex flex-col gap-2">
|
||||
<input
|
||||
placeholder="닉네임"
|
||||
value={nickname}
|
||||
onChange={(e) => setNickname(e.target.value)}
|
||||
className="h-10 rounded-md border border-neutral-300 px-3 text-sm"
|
||||
/>
|
||||
<input
|
||||
placeholder="비밀번호"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="h-10 rounded-md border border-neutral-300 px-3 text-sm"
|
||||
/>
|
||||
{error && <div className="text-xs text-red-600 mt-1">{error}</div>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full h-[40px] rounded-full bg-[#8c8c8c] hover:bg-[#5c5c5c] text-white text-[14px] font-[700] flex items-center justify-center disabled:opacity-60"
|
||||
>
|
||||
{loading ? "로그인 중..." : "로그인"}
|
||||
</button>
|
||||
<div className="flex justify-end">
|
||||
<Link href="/register" className="text-[13px] text-[#5c5c5c] hover:underline">회원가입</Link>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -56,6 +56,42 @@ export default function PartnerScroller() {
|
||||
? partners.map((p: any) => ({ id: p.id, region: p.address ? String(p.address).split(" ")[0] : p.category, name: p.name, address: p.address || "", image: p.imageUrl || "/sample.jpg" }))
|
||||
: fallbackItems;
|
||||
|
||||
const isLoading = !partnersData && (!reqData || partners.length === 0);
|
||||
|
||||
if (isLoading) {
|
||||
// 스켈레톤: 실제 카드와 동일 사이즈(384x308)로 5~6개 표시
|
||||
const skeletons = Array.from({ length: 6 }).map((_, i) => i);
|
||||
return (
|
||||
<div className="relative h-[400px]">
|
||||
<div className="scrollbar-hidden h-full overflow-x-auto overflow-y-hidden">
|
||||
<div className="flex h-full items-center gap-4">
|
||||
{skeletons.map((i) => (
|
||||
<article
|
||||
key={`sk-${i}`}
|
||||
className="flex-shrink-0 w-[384px] h-[308px] rounded-[16px] bg-white overflow-hidden shadow-[0_1px_2px_rgba(0,0,0,0.05),0_0_2px_rgba(0,0,0,0.05)]"
|
||||
>
|
||||
<div className="grid grid-rows-[192px_116px] h-full">
|
||||
<div className="w-full h-[192px] overflow-hidden rounded-t-[16px] bg-neutral-200 animate-pulse" />
|
||||
<div className="h-[116px] px-8 py-4 grid grid-rows-[26px_auto_16px]">
|
||||
<div className="w-[68px] h-[26px] rounded-[20px] bg-neutral-200 animate-pulse" />
|
||||
<div className="self-center">
|
||||
<div className="h-6 w-[70%] rounded bg-neutral-200 animate-pulse" />
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-4 w-[60%] rounded bg-neutral-200 animate-pulse" />
|
||||
<div className="h-4 w-8 rounded bg-neutral-200 animate-pulse" />
|
||||
<div className="h-4 w-8 rounded bg-neutral-200 animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (items.length === 0) return null;
|
||||
return <HorizontalCardScroller items={items} />;
|
||||
}
|
||||
|
||||
63
src/app/components/ProfileImageEditor.tsx
Normal file
63
src/app/components/ProfileImageEditor.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
import React from "react";
|
||||
import { UploadButton } from "@/app/components/UploadButton";
|
||||
import { useToast } from "@/app/components/ui/ToastProvider";
|
||||
|
||||
type Props = {
|
||||
initialUrl?: string | null;
|
||||
};
|
||||
|
||||
export function ProfileImageEditor({ initialUrl }: Props) {
|
||||
const { show } = useToast();
|
||||
const [url, setUrl] = React.useState<string>(initialUrl || "");
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
|
||||
async function save(newUrl: string | null) {
|
||||
try {
|
||||
setSaving(true);
|
||||
const res = await fetch("/api/me/profile-image", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url: newUrl }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data?.error || "저장 실패");
|
||||
show("프로필 이미지가 저장되었습니다");
|
||||
setUrl(newUrl || "");
|
||||
} catch (e: any) {
|
||||
show(e.message || "저장 실패");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="flex gap-2">
|
||||
<UploadButton
|
||||
onUploaded={(u) => {
|
||||
// 정사각 권장
|
||||
save(u);
|
||||
}}
|
||||
aspectRatio={1}
|
||||
maxWidth={512}
|
||||
maxHeight={512}
|
||||
quality={0.9}
|
||||
/>
|
||||
{url ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => save(null)}
|
||||
className="px-3 py-2 border border-neutral-300 rounded-md text-sm"
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? "처리 중..." : "이미지 제거"}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{url ? <span className="text-xs text-neutral-500 break-all">{url}</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import prisma from "@/lib/prisma";
|
||||
import Link from "next/link";
|
||||
import { UserAvatar } from "@/app/components/UserAvatar";
|
||||
@@ -6,6 +7,7 @@ import { GradeIcon, getGradeName } from "@/app/components/GradeIcon";
|
||||
import { HeroBanner } from "@/app/components/HeroBanner";
|
||||
import { PostList } from "@/app/components/PostList";
|
||||
import ProfileLabelIcon from "@/app/svgs/profilelableicon";
|
||||
import { ProfileImageEditor } from "@/app/components/ProfileImageEditor";
|
||||
export default async function MyPage({ searchParams }: { searchParams: Promise<{ tab?: string; page?: string; sort?: string; q?: string }> }) {
|
||||
const sp = await searchParams;
|
||||
const activeTab = sp?.tab || "posts";
|
||||
@@ -26,35 +28,38 @@ export default async function MyPage({ searchParams }: { searchParams: Promise<{
|
||||
try {
|
||||
const h = await headers();
|
||||
const cookieHeader = h.get("cookie") || "";
|
||||
let isAdmin = false;
|
||||
const uid = cookieHeader
|
||||
.split(";")
|
||||
.map((s) => s.trim())
|
||||
.find((pair) => pair.startsWith("uid="))
|
||||
?.split("=")[1];
|
||||
const isAdminVal = cookieHeader
|
||||
.split(";")
|
||||
.map((s) => s.trim())
|
||||
.find((pair) => pair.startsWith("isAdmin="))
|
||||
?.split("=")[1];
|
||||
isAdmin = isAdminVal === "1";
|
||||
|
||||
if (!uid) {
|
||||
redirect(`/login?next=/my-page`);
|
||||
}
|
||||
if (uid) {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { userId: decodeURIComponent(uid) },
|
||||
select: { userId: true, nickname: true, profileImage: true, points: true, level: true, grade: true },
|
||||
});
|
||||
if (user) currentUser = user;
|
||||
if (user) {
|
||||
currentUser = { ...user, isAdmin } as any;
|
||||
} else {
|
||||
redirect(`/login?next=/my-page`);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// 에러 무시
|
||||
}
|
||||
|
||||
// 로그인되지 않은 경우 어드민 사용자 가져오기
|
||||
if (!currentUser) {
|
||||
const admin = await prisma.user.findUnique({
|
||||
where: { nickname: "admin" },
|
||||
select: { userId: true, nickname: true, profileImage: true, points: true, level: true, grade: true },
|
||||
});
|
||||
if (admin) currentUser = admin;
|
||||
}
|
||||
|
||||
if (!currentUser) {
|
||||
return <div className="p-4">로그인이 필요합니다.</div>;
|
||||
}
|
||||
if (!currentUser) redirect(`/login?next=/my-page`);
|
||||
|
||||
// 통계 정보 가져오기
|
||||
const [postsCount, commentsCount, receivedMessagesCount, sentMessagesCount] = await Promise.all([
|
||||
@@ -84,7 +89,12 @@ export default async function MyPage({ searchParams }: { searchParams: Promise<{
|
||||
<div className="space-y-6">
|
||||
{/* 히어로 배너 */}
|
||||
<section>
|
||||
<HeroBanner />
|
||||
<HeroBanner
|
||||
subItems={[
|
||||
{ id: "my-page", name: "마이페이지", href: "/my-page" },
|
||||
]}
|
||||
activeSubId="my-page"
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* 프로필 섹션 (모바일 대응) */}
|
||||
@@ -114,12 +124,25 @@ export default async function MyPage({ searchParams }: { searchParams: Promise<{
|
||||
<div className="flex items-center gap-[4px]">
|
||||
<span className="text-[20px] font-bold text-[#5c5c5c] truncate max-w-[200px]">{currentUser.nickname || "사용자"}</span>
|
||||
<button className="w-[20px] h-[20px] shrink-0">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M11.7188 3.125L13.5938 4.99999L5.3125 13.2812L3.4375 11.4062L11.7188 3.125ZM14.0625 1.5625L15.3125 2.8125C15.625 3.12499 15.625 3.62499 15.3125 3.93749L13.4375 5.81249L11.5625 3.93749L13.4375 2.06249C13.75 1.74999 14.25 1.74999 14.5625 2.06249L14.0625 1.5625ZM3.125 14.375L9.0625 12.1875L7.1875 10.3125L3.125 14.375Z" fill="#707070"/>
|
||||
<svg width="20" height="20" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1.05882 10.9412H1.94929L9.17506 3.71541L8.28459 2.82494L1.05882 10.0507V10.9412ZM0.638118 12C0.457294 12 0.305765 11.9388 0.183529 11.8165C0.0611764 11.6942 0 11.5427 0 11.3619V10.1389C0 9.96682 0.0330587 9.80277 0.0991764 9.64677C0.165176 9.49077 0.256118 9.35483 0.372 9.23894L9.31094 0.304058C9.41765 0.207117 9.53547 0.132235 9.66441 0.0794118C9.79347 0.0264706 9.92877 0 10.0703 0C10.2118 0 10.3489 0.025118 10.4815 0.0753533C10.6142 0.125589 10.7316 0.20547 10.8339 0.315L11.6959 1.18782C11.8055 1.29006 11.8835 1.40771 11.9301 1.54076C11.9767 1.67382 12 1.80688 12 1.93994C12 2.08194 11.9758 2.21741 11.9273 2.34635C11.8788 2.47541 11.8017 2.59329 11.6959 2.7L2.76106 11.628C2.64518 11.7439 2.50924 11.8348 2.35324 11.9008C2.19724 11.9669 2.03318 12 1.86106 12H0.638118ZM8.72206 3.27794L8.28459 2.82494L9.17506 3.71541L8.72206 3.27794Z" fill="#707070"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/* 관리자 설정 버튼 */}
|
||||
{"isAdmin" in (currentUser as any) && (currentUser as any).isAdmin && (
|
||||
<div className="mt-1">
|
||||
<Link
|
||||
href="/admin"
|
||||
className="inline-flex items-center h-9 px-4 rounded-md bg-neutral-900 text-white text-sm hover:bg-neutral-800"
|
||||
>
|
||||
관리자설정
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* 프로필 이미지 편집 */}
|
||||
<ProfileImageEditor initialUrl={currentUser.profileImage || null} />
|
||||
{/* 레벨/등급/포인트 정보 - 가로 배치 */}
|
||||
<div className="flex gap-3 md:gap-[16px] items-center justify-center">
|
||||
<div className="flex items-center gap-[4px] min-w-0">
|
||||
|
||||
@@ -15,6 +15,7 @@ import { BoardPanelClient } from "@/app/components/BoardPanelClient";
|
||||
import { GradeIcon, getGradeName } from "@/app/components/GradeIcon";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { headers } from "next/headers";
|
||||
import { InlineLoginForm } from "@/app/components/InlineLoginForm";
|
||||
|
||||
export default async function Home({ searchParams }: { searchParams: Promise<{ sort?: "recent" | "popular" } | undefined> }) {
|
||||
const sp = await searchParams;
|
||||
@@ -292,15 +293,9 @@ export default async function Home({ searchParams }: { searchParams: Promise<{ s
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center gap-6 relative z-10">
|
||||
<div className="mt-[56px] flex flex-col items-center gap-4">
|
||||
<div className="w-[120px] h-[120px] rounded-full bg-[#e9e9e9] border border-neutral-200" />
|
||||
<div className="text-[18px] text-[#5c5c5c] font-[700]">로그인이 필요합니다</div>
|
||||
<div className="text-[13px] text-neutral-600">내 정보와 등급/포인트를 확인하려면 로그인하세요.</div>
|
||||
</div>
|
||||
<Link href={`/login?next=/`} className="w-[300px] h-[40px] rounded-full bg-[#8c8c8c] hover:bg-[#5c5c5c] text-white text-[14px] font-[700] flex items-center justify-center">
|
||||
로그인 하러 가기
|
||||
</Link>
|
||||
<div className="row-start-1 row-end-[-1] flex flex-col items-center justify-center gap-4 relative z-10">
|
||||
<div className="text-[18px] text-[#5c5c5c] font-[700]">로그인이 필요합니다</div>
|
||||
<InlineLoginForm next="/" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -8,8 +8,6 @@ export default function RegisterPage() {
|
||||
const [form, setForm] = React.useState({
|
||||
nickname: "",
|
||||
name: "",
|
||||
phone: "",
|
||||
birth: "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
agreeTerms: false,
|
||||
@@ -49,22 +47,14 @@ export default function RegisterPage() {
|
||||
<div style={{ maxWidth: 480, margin: "40px auto" }}>
|
||||
<h1>회원가입</h1>
|
||||
<form onSubmit={onSubmit} style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<input name="nickname" placeholder="닉네임" value={form.nickname} onChange={onChange} aria-invalid={!!errors.nickname} aria-describedby="err-nickname" style={{ padding: 8, border: "1px solid #ddd", borderRadius: 6 }} />
|
||||
<input name="nickname" placeholder="아이디" value={form.nickname} onChange={onChange} aria-invalid={!!errors.nickname} aria-describedby="err-nickname" style={{ padding: 8, border: "1px solid #ddd", borderRadius: 6 }} />
|
||||
{errors.nickname?.length ? (
|
||||
<span id="err-nickname" style={{ color: "#c00", fontSize: 12 }}>{errors.nickname[0]}</span>
|
||||
) : null}
|
||||
<input name="name" placeholder="이름" value={form.name} onChange={onChange} aria-invalid={!!errors.name} aria-describedby="err-name" style={{ padding: 8, border: "1px solid #ddd", borderRadius: 6 }} />
|
||||
<input name="name" placeholder="닉네임" value={form.name} onChange={onChange} aria-invalid={!!errors.name} aria-describedby="err-name" style={{ padding: 8, border: "1px solid #ddd", borderRadius: 6 }} />
|
||||
{errors.name?.length ? (
|
||||
<span id="err-name" style={{ color: "#c00", fontSize: 12 }}>{errors.name[0]}</span>
|
||||
) : null}
|
||||
<input name="phone" placeholder="전화번호" value={form.phone} onChange={onChange} aria-invalid={!!errors.phone} aria-describedby="err-phone" style={{ padding: 8, border: "1px solid #ddd", borderRadius: 6 }} />
|
||||
{errors.phone?.length ? (
|
||||
<span id="err-phone" style={{ color: "#c00", fontSize: 12 }}>{errors.phone[0]}</span>
|
||||
) : null}
|
||||
<input name="birth" placeholder="생년월일 (YYYY-MM-DD)" value={form.birth} onChange={onChange} aria-invalid={!!errors.birth} aria-describedby="err-birth" style={{ padding: 8, border: "1px solid #ddd", borderRadius: 6 }} />
|
||||
{errors.birth?.length ? (
|
||||
<span id="err-birth" style={{ color: "#c00", fontSize: 12 }}>{errors.birth[0]}</span>
|
||||
) : null}
|
||||
<input name="password" placeholder="비밀번호" type="password" value={form.password} onChange={onChange} aria-invalid={!!errors.password} aria-describedby="err-password" style={{ padding: 8, border: "1px solid #ddd", borderRadius: 6 }} />
|
||||
{errors.password?.length ? (
|
||||
<span id="err-password" style={{ color: "#c00", fontSize: 12 }}>{errors.password[0]}</span>
|
||||
@@ -73,9 +63,25 @@ export default function RegisterPage() {
|
||||
{errors.confirmPassword?.length ? (
|
||||
<span id="err-confirmPassword" style={{ color: "#c00", fontSize: 12 }}>{errors.confirmPassword[0]}</span>
|
||||
) : null}
|
||||
<label style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<input name="agreeTerms" type="checkbox" checked={form.agreeTerms} onChange={onChange} /> 약관에 동의합니다
|
||||
</label>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<textarea
|
||||
readOnly
|
||||
value={`[이용 약관]\n\n본 서비스 이용 시 커뮤니티 가이드라인을 준수해야 하며, 불법/유해 콘텐츠 게시 금지 등 운영 정책을 따릅니다.\n개인정보는 서비스 제공 및 보안 목적에 한해 처리되며, 자세한 내용은 개인정보 처리방침을 확인하세요.`}
|
||||
style={{ width: "100%", minHeight: 140, padding: 8, border: "1px solid #ddd", borderRadius: 6, resize: "vertical", background: "#fafafa" }}
|
||||
/>
|
||||
<label style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="agreeTerms"
|
||||
checked={form.agreeTerms}
|
||||
onChange={onChange}
|
||||
/>
|
||||
<span style={{ fontSize: 14, color: "#333" }}>약관에 동의합니다</span>
|
||||
</label>
|
||||
{errors.agreeTerms?.length ? (
|
||||
<span id="err-agree" style={{ color: "#c00", fontSize: 12 }}>{errors.agreeTerms[0]}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<Button type="submit" disabled={loading}>{loading ? "가입 중..." : "가입하기"}</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
9
src/app/svgs/editicon.svg
Normal file
9
src/app/svgs/editicon.svg
Normal file
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 12 12" fill="none">
|
||||
|
||||
|
||||
|
||||
<path d="M1.05882 10.9412H1.94929L9.17506 3.71541L8.28459 2.82494L1.05882 10.0507V10.9412ZM0.638118 12C0.457294 12 0.305765 11.9388 0.183529 11.8165C0.0611764 11.6942 0 11.5427 0 11.3619V10.1389C0 9.96682 0.0330587 9.80277 0.0991764 9.64677C0.165176 9.49077 0.256118 9.35483 0.372 9.23894L9.31094 0.304058C9.41765 0.207117 9.53547 0.132235 9.66441 0.0794118C9.79347 0.0264706 9.92877 0 10.0703 0C10.2118 0 10.3489 0.025118 10.4815 0.0753533C10.6142 0.125589 10.7316 0.20547 10.8339 0.315L11.6959 1.18782C11.8055 1.29006 11.8835 1.40771 11.9301 1.54076C11.9767 1.67382 12 1.80688 12 1.93994C12 2.08194 11.9758 2.21741 11.9273 2.34635C11.8788 2.47541 11.8017 2.59329 11.6959 2.7L2.76106 11.628C2.64518 11.7439 2.50924 11.8348 2.35324 11.9008C2.19724 11.9669 2.03318 12 1.86106 12H0.638118ZM8.72206 3.27794L8.28459 2.82494L9.17506 3.71541L8.72206 3.27794Z" fill="white"/>
|
||||
|
||||
</svg>
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 980 B |
@@ -4,13 +4,6 @@ export const registerSchema = z
|
||||
.object({
|
||||
nickname: z.string().min(2).max(20),
|
||||
name: z.string().min(1).max(50),
|
||||
phone: z
|
||||
.string()
|
||||
.regex(/^[0-9\-+]{9,15}$/)
|
||||
.transform((s) => s.replace(/[^0-9]/g, "")),
|
||||
birth: z
|
||||
.string()
|
||||
.refine((s) => !Number.isNaN(Date.parse(s)), { message: "Invalid date" }),
|
||||
password: z.string().min(8).max(100),
|
||||
confirmPassword: z.string().min(8).max(100),
|
||||
agreeTerms: z.literal(true, { errorMap: () => ({ message: "약관 동의 필요" }) }),
|
||||
|
||||
Reference in New Issue
Block a user