This commit is contained in:
koreacomp5
2025-11-02 02:46:20 +09:00
parent 27cf98eef2
commit 0bf18968ad
50 changed files with 892 additions and 121 deletions

View File

@@ -8,12 +8,10 @@
"start": "next start", "start": "next start",
"lint": "biome check", "lint": "biome check",
"format": "biome format --write", "format": "biome format --write",
"prisma:generate": "prisma generate", "migrate": "prisma migrate dev",
"prisma:migrate": "prisma migrate dev", "studio": "prisma studio",
"prisma:studio": "prisma studio", "seed": "node prisma/seed.js",
"prisma:db:push": "prisma db push", "dbforce": "prisma migrate reset --force"
"prisma:seed": "node prisma/seed.js",
"prisma:erd": "prisma generate"
}, },
"dependencies": { "dependencies": {
"@prisma/client": "^6.17.0", "@prisma/client": "^6.17.0",

View File

@@ -0,0 +1,18 @@
-- CreateTable
CREATE TABLE "partner_shops" (
"id" TEXT NOT NULL PRIMARY KEY,
"region" TEXT NOT NULL,
"name" TEXT NOT NULL,
"address" TEXT NOT NULL,
"imageUrl" TEXT NOT NULL,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateIndex
CREATE INDEX "partner_shops_active_sortOrder_idx" ON "partner_shops"("active", "sortOrder");
-- CreateIndex
CREATE UNIQUE INDEX "partner_shops_name_region_key" ON "partner_shops"("name", "region");

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "partners" ADD COLUMN "imageUrl" TEXT;

View File

@@ -0,0 +1,23 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_partners" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"category" TEXT NOT NULL,
"latitude" REAL NOT NULL,
"longitude" REAL NOT NULL,
"address" TEXT,
"imageUrl" TEXT,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
INSERT INTO "new_partners" ("address", "category", "createdAt", "id", "imageUrl", "latitude", "longitude", "name", "updatedAt") SELECT "address", "category", "createdAt", "id", "imageUrl", "latitude", "longitude", "name", "updatedAt" FROM "partners";
DROP TABLE "partners";
ALTER TABLE "new_partners" RENAME TO "partners";
CREATE UNIQUE INDEX "partners_name_key" ON "partners"("name");
CREATE INDEX "partners_category_idx" ON "partners"("category");
CREATE INDEX "partners_sortOrder_idx" ON "partners"("sortOrder");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@@ -675,10 +675,13 @@ model Partner {
latitude Float latitude Float
longitude Float longitude Float
address String? address String?
imageUrl String?
sortOrder Int @default(0)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@index([category]) @@index([category])
@@index([sortOrder])
@@map("partners") @@map("partners")
} }
@@ -742,3 +745,20 @@ model Setting {
@@map("settings") @@map("settings")
} }
// 메인 노출용 제휴 샵 가로 스크롤 데이터
model PartnerShop {
id String @id @default(cuid())
region String
name String
address String
imageUrl String
sortOrder Int @default(0)
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([name, region])
@@index([active, sortOrder])
@@map("partner_shops")
}

View File

@@ -2,16 +2,87 @@ const { PrismaClient } = require("@prisma/client");
const prisma = new PrismaClient(); const prisma = new PrismaClient();
function randomInt(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
function randomDate(startYear, endYear) {
const start = new Date(`${startYear}-01-01`).getTime();
const end = new Date(`${endYear}-12-31`).getTime();
return new Date(randomInt(start, end));
}
function generateRandomKoreanName() {
const lastNames = ["김", "이", "박", "최", "정", "강", "조", "윤", "장", "임"];
const firstParts = ["민", "서", "도", "현", "지", "아", "윤", "준", "하", "유", "채", "은", "수", "태", "나"];
const secondParts = ["우", "영", "민", "서", "진", "현", "빈", "율", "솔", "연", "환", "호", "린", "훈", "경"];
const last = lastNames[randomInt(0, lastNames.length - 1)];
const first = firstParts[randomInt(0, firstParts.length - 1)] + secondParts[randomInt(0, secondParts.length - 1)];
return last + first;
}
function generateUniquePhone(i) {
const mid = String(2000 + (i % 8000)).padStart(4, "0");
const last = String(3000 + i).padStart(4, "0");
return `010-${mid}-${last}`;
}
function generateNickname(i) {
const suffix = Math.random().toString(36).slice(2, 4);
return `user${String(i + 1).padStart(3, "0")}${suffix}`;
}
async function createRandomUsers(count = 100) {
const roleUser = await prisma.role.findUnique({ where: { name: "user" } });
// 사용되지 않은 전화번호를 찾는 보조 함수
async function findAvailablePhone(startIndex) {
let offset = 0;
while (true) {
const candidate = generateUniquePhone(startIndex + offset);
const exists = await prisma.user.findUnique({ where: { phone: candidate } });
if (!exists) return candidate;
offset += 1;
}
}
for (let i = 0; i < count; i++) {
// 닉네임을 결정론적으로 생성해 재실행 시 중복 생성 방지
const nickname = `user${String(i + 1).padStart(3, "0")}`;
const existing = await prisma.user.findUnique({ where: { nickname } });
let user = existing;
if (!existing) {
const name = generateRandomKoreanName();
const birth = randomDate(1975, 2005);
const phone = await findAvailablePhone(i + 2); // admin이 0001 사용하므로 겹치지 않도록 오프셋
user = await prisma.user.create({
data: {
nickname,
name,
birth,
phone,
agreementTermsAt: new Date(),
authLevel: "USER",
isAdultVerified: Math.random() < 0.6,
lastLoginAt: Math.random() < 0.8 ? new Date() : null,
},
});
}
if (roleUser && user) {
await prisma.userRole.upsert({
where: { userId_roleId: { userId: user.userId, roleId: roleUser.roleId } },
update: {},
create: { userId: user.userId, roleId: roleUser.roleId },
});
}
}
}
async function upsertCategories() { async function upsertCategories() {
// 카테고리 트리 (projectmemo 기준 상위 그룹) // 카테고리 트리 (projectmemo 기준 상위 그룹)
const categories = [ const categories = [
{ name: "암실소문", slug: "main", sortOrder: 1, status: "active" }, { name: "메인", slug: "main", sortOrder: 1, status: "active" },
{ name: "명예의 전당", slug: "hall-of-fame", sortOrder: 2, status: "active" }, { name: "명예의 전당", slug: "hall-of-fame", sortOrder: 2, status: "active" },
{ name: "주변 제휴업체", slug: "nearby-partners", sortOrder: 3, status: "active" }, { name: "소통방", slug: "community", sortOrder: 3, status: "active" },
{ name: "제휴업소 정보", slug: "partner-info", sortOrder: 4, status: "active" },
{ name: "방문후기", slug: "reviews", sortOrder: 5, status: "active" },
{ name: "소통방", slug: "community", sortOrder: 6, status: "active" },
{ name: "광고/제휴", slug: "ads-affiliates", sortOrder: 7, status: "active" },
]; ];
const map = {}; const map = {};
for (const c of categories) { for (const c of categories) {
@@ -119,20 +190,13 @@ async function upsertBoards(admin, categoryMap) {
{ name: "무엇이든", slug: "qna", description: "무엇이든 물어보세요", type: "general", sortOrder: 6 }, { name: "무엇이든", slug: "qna", description: "무엇이든 물어보세요", type: "general", sortOrder: 6 },
{ name: "마사지꿀팁", slug: "tips", description: "팁", type: "general", sortOrder: 7 }, { name: "마사지꿀팁", slug: "tips", description: "팁", type: "general", sortOrder: 7 },
{ name: "익명게시판", slug: "anonymous", description: "익명", type: "general", sortOrder: 8, allowAnonymousPost: true, allowSecretComment: true }, { name: "익명게시판", slug: "anonymous", description: "익명", type: "general", sortOrder: 8, allowAnonymousPost: true, allowSecretComment: true },
{ name: "관리사찾아요", slug: "find-therapist", description: "구인/구직", type: "general", sortOrder: 9 },
{ name: "청와대", slug: "blue-house", description: "레벨 제한", type: "general", sortOrder: 10, readLevel: "member" }, { name: "청와대", slug: "blue-house", description: "레벨 제한", type: "general", sortOrder: 10, readLevel: "member" },
{ name: "방문후기", slug: "reviews", description: "운영자 승인 후 공개", type: "general", sortOrder: 11, requiresApproval: true, requiredTags: { anyOf: ["업체명", "지역"] } },
// 특수 // 특수
{ name: "출석부", slug: "attendance", description: "데일리 체크인", type: "special", sortOrder: 12 }, { name: "출석부", slug: "attendance", description: "데일리 체크인", type: "special", sortOrder: 12 },
{ name: "주변 제휴업체", slug: "nearby-partners", description: "위치 기반", type: "special", sortOrder: 13 },
{ name: "회원랭킹", slug: "ranking", description: "랭킹", type: "special", sortOrder: 14 }, { name: "회원랭킹", slug: "ranking", description: "랭킹", type: "special", sortOrder: 14 },
{ name: "무료쿠폰", slug: "free-coupons", description: "쿠폰", type: "special", sortOrder: 15 }, { name: "무료쿠폰", slug: "free-coupons", description: "쿠폰", type: "special", sortOrder: 15 },
{ name: "월간집계", slug: "monthly-stats", description: "월간 통계", type: "special", sortOrder: 16 }, { name: "월간집계", slug: "monthly-stats", description: "월간 통계", type: "special", sortOrder: 16 },
// 제휴업소 일반 // 광고/제휴 계열 게시판은 제거(메인/명예의전당/소통방 외)
{ name: "제휴업소", slug: "partners-photos", description: "사진 전용 게시판", type: "general", sortOrder: 17, requiredFields: { imageOnly: true, minImages: 1, maxImages: 10 } },
// 광고/제휴
{ name: "제휴문의", slug: "partner-contact", description: "제휴문의", type: "general", sortOrder: 18, requiredFields: { imageOnly: true, minImages: 1, maxImages: 10 } },
{ name: "제휴업소 요청", slug: "partner-req", description: "제휴업소 요청", type: "general", sortOrder: 19, requiredFields: { imageOnly: true, minImages: 1, maxImages: 10 } },
]; ];
const created = []; const created = [];
@@ -149,22 +213,12 @@ async function upsertBoards(admin, categoryMap) {
ranking: "hall-of-fame", ranking: "hall-of-fame",
"free-coupons": "hall-of-fame", "free-coupons": "hall-of-fame",
"monthly-stats": "hall-of-fame", "monthly-stats": "hall-of-fame",
// 주변 제휴업체
"nearby-partners": "nearby-partners",
// 제휴업소 정보
"partners-photos": "partner-info",
// 방문후기
reviews: "reviews",
// 소통방(기본값 community로 처리) // 소통방(기본값 community로 처리)
free: "community", free: "community",
qna: "community", qna: "community",
tips: "community", tips: "community",
anonymous: "community", anonymous: "community",
"find-therapist": "community",
"blue-house": "community",
// 광고/제휴 // 광고/제휴
"partner-contact": "ads-affiliates",
"partner-req": "ads-affiliates",
}; };
const categorySlug = mapBySlug[b.slug] || "community"; const categorySlug = mapBySlug[b.slug] || "community";
const category = categoryMap[categorySlug]; const category = categoryMap[categorySlug];
@@ -229,6 +283,35 @@ async function upsertViewTypes() {
} }
} }
async function removeNonPrimaryBoards() {
// 메인/명예의전당/소통방에 해당하지 않는 게시판(광고/제휴 계열) 정리
await prisma.board.deleteMany({
where: { slug: { in: ["partners-photos", "partner-contact", "partner-req"] } },
});
}
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) {
const data = [];
for (let i = 0; i < countPerBoard; i++) {
const authorId = ["notice", "bug-report"].includes(board.slug)
? admin.userId
: userIds[randomInt(0, userIds.length - 1)];
data.push({
boardId: board.id,
authorId,
title: `${board.name} 샘플 글 ${i + 1}`,
content: `이 게시판(${board.slug})의 자동 시드 게시물 #${i + 1} 입니다.\n\n테스트용 내용입니다.`,
status: "published",
isAnonymous: board.allowAnonymousPost ? Math.random() < 0.3 : false,
});
}
await prisma.post.createMany({ data });
}
}
async function seedPolicies() { async function seedPolicies() {
// 금칙어 예시 // 금칙어 예시
const banned = [ const banned = [
@@ -259,37 +342,46 @@ async function seedPolicies() {
} }
} }
async function seedPartnerShops() {
const items = [
{ region: "경기도", name: "test1", address: "수원시 팔달구 매산로 45", imageUrl: "/sample.jpg", sortOrder: 1 },
{ region: "강원도", name: "test2", address: "춘천시 중앙로 112", imageUrl: "/sample.jpg", sortOrder: 2 },
];
for (const it of items) {
await prisma.partnerShop.upsert({
where: { name_region: { name: it.name, region: it.region } },
update: { address: it.address, imageUrl: it.imageUrl, sortOrder: it.sortOrder, active: true },
create: it,
});
}
// 표시 토글 기본값 보장
const SETTINGS_KEY = "mainpage_settings";
const setting = await prisma.setting.findUnique({ where: { key: SETTINGS_KEY } });
const current = setting ? JSON.parse(setting.value) : {};
const next = { showPartnerShops: true, ...current };
await prisma.setting.upsert({
where: { key: SETTINGS_KEY },
update: { value: JSON.stringify(next) },
create: { key: SETTINGS_KEY, value: JSON.stringify(next) },
});
}
async function main() { async function main() {
await upsertRoles(); await upsertRoles();
const admin = await upsertAdmin(); const admin = await upsertAdmin();
const categoryMap = await upsertCategories(); const categoryMap = await upsertCategories();
await upsertViewTypes(); await upsertViewTypes();
await createRandomUsers(100);
await removeNonPrimaryBoards();
const boards = await upsertBoards(admin, categoryMap); const boards = await upsertBoards(admin, categoryMap);
await createPostsForAllBoards(boards, 100, admin);
// 샘플 글 하나 await seedPartnerShops();
const free = boards.find((b) => b.slug === "free") || boards[0];
const post = await prisma.post.create({
data: {
boardId: free.id,
authorId: admin.userId,
title: "첫 글",
content: "메시지 앱 초기 설정 완료",
status: "published",
},
});
await prisma.comment.createMany({
data: [
{ postId: post.id, authorId: admin.userId, content: "환영합니다!" },
{ postId: post.id, authorId: admin.userId, content: "댓글 테스트" },
],
});
await seedPolicies(); await seedPolicies();
// 제휴업체 예시 데이터 // 제휴업체 예시 데이터
const partners = [ const partners = [
{ name: "힐링마사지", category: "spa", latitude: 37.5665, longitude: 126.9780, address: "서울 구" }, { name: "test", category: "spa", latitude: 37.5700, longitude: 126.9769, address: "서울 종로구" },
{ name: "웰빙테라피", category: "spa", latitude: 37.5700, longitude: 126.9769, address: "서울 종로구" },
{ name: "굿짐", category: "gym", latitude: 37.5600, longitude: 126.9820, address: "서울 중구" }, { name: "굿짐", category: "gym", latitude: 37.5600, longitude: 126.9820, address: "서울 중구" },
]; ];
for (const p of partners) { for (const p of partners) {

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 411 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

View File

@@ -7,6 +7,7 @@ const navItems = [
{ href: "/admin/mainpage-settings", label: "메인페이지 설정" }, { href: "/admin/mainpage-settings", label: "메인페이지 설정" },
{ href: "/admin/boards", label: "게시판" }, { href: "/admin/boards", label: "게시판" },
{ href: "/admin/banners", label: "배너" }, { href: "/admin/banners", label: "배너" },
{ href: "/admin/partners", label: "제휴업체" },
{ href: "/admin/users", label: "사용자" }, { href: "/admin/users", label: "사용자" },
{ href: "/admin/logs", label: "로그" }, { href: "/admin/logs", label: "로그" },
]; ];

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import useSWR from "swr"; import useSWR from "swr";
import { useState } from "react"; import { useRef, useState } from "react";
import { Modal } from "@/app/components/ui/Modal";
const fetcher = (url: string) => fetch(url).then((r) => r.json()); const fetcher = (url: string) => fetch(url).then((r) => r.json());
@@ -8,21 +9,112 @@ export default function AdminBannersPage() {
const { data, mutate } = useSWR<{ banners: any[] }>("/api/admin/banners", fetcher); const { data, mutate } = useSWR<{ banners: any[] }>("/api/admin/banners", fetcher);
const banners = data?.banners ?? []; const banners = data?.banners ?? [];
const [form, setForm] = useState({ title: "", imageUrl: "", linkUrl: "", active: true, sortOrder: 0 }); const [form, setForm] = useState({ title: "", imageUrl: "", linkUrl: "", active: true, sortOrder: 0 });
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const [showCreateModal, setShowCreateModal] = useState(false);
async function onSelectFile(e: React.ChangeEvent<HTMLInputElement>) {
const inputEl = e.currentTarget; // 이벤트 풀링 대비 사본 보관
const file = inputEl.files?.[0];
if (!file) return;
try {
setUploading(true);
const fd = new FormData();
fd.append("file", file);
const r = await fetch("/api/uploads", { method: "POST", body: fd });
const json = await r.json();
if (!r.ok) throw new Error(json?.error || "upload_failed");
setForm((f) => ({ ...f, imageUrl: json.url }));
} catch (err) {
console.error(err);
alert("이미지 업로드 중 오류가 발생했습니다.");
} finally {
setUploading(false);
if (inputEl) inputEl.value = "";
}
}
async function create() { async function create() {
const r = await fetch("/api/admin/banners", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(form) }); const r = await fetch("/api/admin/banners", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(form) });
if (r.ok) { setForm({ title: "", imageUrl: "", linkUrl: "", active: true, sortOrder: 0 }); mutate(); } if (r.ok) { setForm({ title: "", imageUrl: "", linkUrl: "", active: true, sortOrder: 0 }); mutate(); setShowCreateModal(false); }
} }
return ( return (
<div> <div>
<h1> </h1> <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 12 }}>
<div style={{ display: "flex", gap: 8, marginBottom: 12 }}> <h1> </h1>
<input placeholder="제목" value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} /> <button
<input placeholder="이미지 URL" value={form.imageUrl} onChange={(e) => setForm({ ...form, imageUrl: e.target.value })} /> type="button"
<input placeholder="링크 URL(선택)" value={form.linkUrl} onChange={(e) => setForm({ ...form, linkUrl: e.target.value })} /> onClick={() => setShowCreateModal(true)}
<label><input type="checkbox" checked={form.active} onChange={(e) => setForm({ ...form, active: e.target.checked })} /> </label> className="h-9 px-4 rounded-md bg-neutral-900 text-white text-sm shadow cursor-pointer hover:bg-neutral-800 active:translate-y-px transition"
<input type="number" placeholder="정렬" value={form.sortOrder} onChange={(e) => setForm({ ...form, sortOrder: Number(e.target.value) })} style={{ width: 80 }} /> >
<button onClick={create}></button>
</button>
</div> </div>
<Modal open={showCreateModal} onClose={() => setShowCreateModal(false)}>
<div className="w-[720px] max-w-[90vw]">
<div className="p-6">
<h2 className="text-lg font-bold mb-4"> </h2>
{(() => {
const inputStyle: React.CSSProperties = { border: "1px solid #ddd", borderRadius: 6, padding: "8px 10px", width: "100%" };
return (
<div style={{ display: "grid", gridTemplateColumns: "repeat(6, minmax(0, 1fr))", gap: 12, alignItems: "end" }}>
<div style={{ gridColumn: "span 3" }}>
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}></label>
<input style={inputStyle} value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} />
</div>
<div style={{ gridColumn: "span 3" }}>
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}> URL()</label>
<input style={inputStyle} value={form.linkUrl} onChange={(e) => setForm({ ...form, linkUrl: e.target.value })} />
</div>
<div style={{ gridColumn: "span 6" }}>
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}> URL</label>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<input style={inputStyle} value={form.imageUrl} onChange={(e) => setForm({ ...form, imageUrl: e.target.value })} />
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className="h-9 px-4 w-36 rounded-md border border-neutral-300 text-sm cursor-pointer hover:bg-neutral-100 hover:border-neutral-400 active:translate-y-px transition"
>
</button>
<input ref={fileInputRef} type="file" accept="image/*" onChange={onSelectFile} style={{ display: "none" }} />
{uploading && <span style={{ fontSize: 12, opacity: .7 }}> ...</span>}
</div>
</div>
<div style={{ gridColumn: "span 3" }}>
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}></label>
<label className="inline-flex items-center gap-2 text-sm"><input type="checkbox" checked={form.active} onChange={(e) => setForm({ ...form, active: e.target.checked })} /> </label>
</div>
<div style={{ gridColumn: "span 3" }}>
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}></label>
<input type="number" style={inputStyle} value={form.sortOrder} onChange={(e) => setForm({ ...form, sortOrder: Number(e.target.value) })} />
</div>
{form.imageUrl && (
<div style={{ gridColumn: "span 6" }}>
<div style={{ fontSize: 12, opacity: .7, marginBottom: 6 }}></div>
<img src={form.imageUrl} alt="미리보기" style={{ width: 320, height: 160, objectFit: "cover", borderRadius: 6, border: "1px solid #eee" }} />
</div>
)}
<div style={{ gridColumn: "span 6", display: "flex", justifyContent: "flex-end", gap: 8 }}>
<button
onClick={create}
className="h-9 px-4 rounded-md bg-neutral-900 text-white text-sm shadow cursor-pointer hover:bg-neutral-800 active:translate-y-px transition"
>
</button>
<button
type="button"
onClick={() => setShowCreateModal(false)}
className="h-9 px-4 rounded-md border border-neutral-300 text-sm cursor-pointer hover:bg-neutral-100 active:translate-y-px transition"
>
</button>
</div>
</div>
);
})()}
</div>
</div>
</Modal>
<ul style={{ display: "flex", flexDirection: "column", gap: 8 }}> <ul style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{banners.map((b) => ( {banners.map((b) => (
<li key={b.id} style={{ border: "1px solid #eee", borderRadius: 8, padding: 12, display: "flex", gap: 12, alignItems: "center" }}> <li key={b.id} style={{ border: "1px solid #eee", borderRadius: 8, padding: 12, display: "flex", gap: 12, alignItems: "center" }}>
@@ -31,8 +123,8 @@ export default function AdminBannersPage() {
<div><strong>{b.title}</strong> {b.linkUrl && <a style={{ marginLeft: 8 }} href={b.linkUrl}></a>}</div> <div><strong>{b.title}</strong> {b.linkUrl && <a style={{ marginLeft: 8 }} href={b.linkUrl}></a>}</div>
<div style={{ fontSize: 12, opacity: 0.7 }}> {b.sortOrder} · {b.active ? "활성" : "비활성"}</div> <div style={{ fontSize: 12, opacity: 0.7 }}> {b.sortOrder} · {b.active ? "활성" : "비활성"}</div>
</div> </div>
<button onClick={async () => { await fetch(`/api/admin/banners/${b.id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ active: !b.active }) }); mutate(); }}>{b.active ? "비활성" : "활성"}</button> <button onClick={async () => { await fetch(`/api/admin/banners/${b.id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ active: !b.active }) }); mutate(); }} className="h-8 px-3 rounded-md border border-neutral-300 text-sm cursor-pointer hover:bg-neutral-100 active:translate-y-px transition">{b.active ? "비활성" : "활성"}</button>
<button onClick={async () => { await fetch(`/api/admin/banners/${b.id}`, { method: "DELETE" }); mutate(); }}></button> <button onClick={async () => { await fetch(`/api/admin/banners/${b.id}`, { method: "DELETE" }); mutate(); }} className="h-8 px-3 rounded-md border border-red-200 text-red-600 text-sm cursor-pointer hover:bg-red-100 hover:border-red-300 hover:text-red-700 active:translate-y-px transition"></button>
</li> </li>
))} ))}
</ul> </ul>

View File

@@ -0,0 +1,45 @@
"use client";
import useSWR from "swr";
import { useState } from "react";
const fetcher = (url: string) => fetch(url).then((r) => r.json());
export default function AdminPartnerShopsPage() {
const { data, mutate } = useSWR<{ items: any[] }>("/api/admin/partner-shops", fetcher);
const items = data?.items ?? [];
const [form, setForm] = useState({ region: "", name: "", address: "", imageUrl: "", active: true, sortOrder: 0 });
async function create() {
const r = await fetch("/api/admin/partner-shops", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(form) });
if (r.ok) { setForm({ region: "", name: "", address: "", imageUrl: "", active: true, sortOrder: 0 }); mutate(); }
}
return (
<div>
<h1> </h1>
<div style={{ display: "flex", gap: 8, marginBottom: 12, flexWrap: "wrap" }}>
<input placeholder="지역" value={form.region} onChange={(e) => setForm({ ...form, region: e.target.value })} />
<input placeholder="이름" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
<input placeholder="주소" value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })} style={{ width: 320 }} />
<input placeholder="이미지 URL" value={form.imageUrl} onChange={(e) => setForm({ ...form, imageUrl: e.target.value })} style={{ width: 280 }} />
<label><input type="checkbox" checked={form.active} onChange={(e) => setForm({ ...form, active: e.target.checked })} /> </label>
<input type="number" placeholder="정렬" value={form.sortOrder} onChange={(e) => setForm({ ...form, sortOrder: Number(e.target.value) })} style={{ width: 80 }} />
<button onClick={create}></button>
</div>
<ul style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{items.map((it) => (
<li key={it.id} style={{ border: "1px solid #eee", borderRadius: 8, padding: 12, display: "flex", gap: 12, alignItems: "center" }}>
<img src={it.imageUrl} alt={it.name} style={{ width: 80, height: 48, objectFit: "cover", borderRadius: 6 }} />
<div style={{ flex: 1 }}>
<div><strong>{it.name}</strong> <span style={{ marginLeft: 8, fontSize: 12, opacity: .7 }}>{it.region}</span></div>
<div style={{ fontSize: 12, opacity: 0.7 }}>{it.address}</div>
<div style={{ fontSize: 12, opacity: 0.7 }}> {it.sortOrder} · {it.active ? "활성" : "비활성"}</div>
</div>
<button onClick={async () => { await fetch(`/api/admin/partner-shops/${it.id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ active: !it.active }) }); mutate(); }}>{it.active ? "비활성" : "활성"}</button>
<button onClick={async () => { await fetch(`/api/admin/partner-shops/${it.id}`, { method: "DELETE" }); mutate(); }}></button>
</li>
))}
</ul>
</div>
);
}

View File

@@ -0,0 +1,295 @@
"use client";
import useSWR from "swr";
import { useState, useRef } from "react";
import { Modal } from "@/app/components/ui/Modal";
const fetcher = (url: string) => fetch(url).then((r) => r.json());
export default function AdminPartnersPage() {
const { data, mutate } = useSWR<{ partners: any[] }>("/api/admin/partners", fetcher);
const partners = data?.partners ?? [];
const [form, setForm] = useState({ name: "", category: "", latitude: "", longitude: "", address: "", imageUrl: "" });
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const editFileInputRef = useRef<HTMLInputElement>(null);
const [editUploading, setEditUploading] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [editDraft, setEditDraft] = useState<any>(null);
const [showCreateModal, setShowCreateModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
async function onSelectFile(e: React.ChangeEvent<HTMLInputElement>) {
const inputEl = e.currentTarget;
const file = inputEl.files?.[0];
if (!file) return;
try {
setUploading(true);
const fd = new FormData();
fd.append("file", file);
const r = await fetch("/api/uploads", { method: "POST", body: fd });
const json = await r.json();
if (!r.ok) throw new Error(json?.error || "upload_failed");
setForm((f) => ({ ...f, imageUrl: json.url }));
} catch (err) {
console.error(err);
alert("이미지 업로드 중 오류가 발생했습니다.");
} finally {
setUploading(false);
if (inputEl) inputEl.value = "";
}
}
async function onSelectEditFile(e: React.ChangeEvent<HTMLInputElement>) {
const inputEl = e.currentTarget;
const file = inputEl.files?.[0];
if (!file) return;
try {
setEditUploading(true);
const fd = new FormData();
fd.append("file", file);
const r = await fetch("/api/uploads", { method: "POST", body: fd });
const json = await r.json();
if (!r.ok) throw new Error(json?.error || "upload_failed");
setEditDraft((d: any) => ({ ...(d || {}), imageUrl: json.url }));
} catch (err) {
console.error(err);
alert("이미지 업로드 중 오류가 발생했습니다.");
} finally {
setEditUploading(false);
if (inputEl) inputEl.value = "";
}
}
async function create() {
// 필수값 검증: 이름/카테고리/위도/경도
if (!form.name || !form.category || !String(form.latitude).trim() || !String(form.longitude).trim()) {
alert("이름, 카테고리, 위도, 경도를 모두 입력해 주세요.");
return;
}
const lat = Number(form.latitude);
const lon = Number(form.longitude);
if (!isFinite(lat) || !isFinite(lon)) {
alert("위도/경도는 숫자여야 합니다.");
return;
}
const payload = { ...form, latitude: lat, longitude: lon } as any;
const r = await fetch("/api/admin/partners", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(payload) });
if (r.ok) {
setForm({ name: "", category: "", latitude: "", longitude: "", address: "", imageUrl: "" });
mutate();
setShowCreateModal(false);
}
}
return (
<div>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 12 }}>
<h1> </h1>
<button
type="button"
onClick={() => setShowCreateModal(true)}
className="h-9 px-4 rounded-md bg-neutral-900 text-white text-sm shadow cursor-pointer hover:bg-neutral-800 active:translate-y-px transition"
>
</button>
</div>
<Modal open={showCreateModal} onClose={() => setShowCreateModal(false)}>
<div className="w-[720px] max-w-[90vw]">
<div className="p-6">
<h2 className="text-lg font-bold mb-4"> </h2>
{(() => {
const inputStyle: React.CSSProperties = { border: "1px solid #ddd", borderRadius: 6, padding: "8px 10px", width: "100%" };
return (
<div style={{ display: "grid", gridTemplateColumns: "repeat(6, minmax(0, 1fr))", gap: 12, alignItems: "end" }}>
<div style={{ gridColumn: "span 3" }}>
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}></label>
<input style={inputStyle} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
</div>
<div style={{ gridColumn: "span 3" }}>
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}></label>
<input style={inputStyle} value={form.category} onChange={(e) => setForm({ ...form, category: e.target.value })} />
</div>
<div style={{ gridColumn: "span 3" }}>
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}></label>
<input style={inputStyle} value={form.latitude} onChange={(e) => setForm({ ...form, latitude: e.target.value })} />
</div>
<div style={{ gridColumn: "span 3" }}>
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}></label>
<input style={inputStyle} value={form.longitude} onChange={(e) => setForm({ ...form, longitude: e.target.value })} />
</div>
<div style={{ gridColumn: "span 6" }}>
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}> URL</label>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<input style={inputStyle} value={form.imageUrl} onChange={(e) => setForm({ ...form, imageUrl: e.target.value })} />
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className="h-9 px-4 w-36 rounded-md border border-neutral-300 text-sm cursor-pointer hover:bg-neutral-100 hover:border-neutral-400 active:translate-y-px transition"
>
</button>
<input ref={fileInputRef} type="file" accept="image/*" onChange={onSelectFile} style={{ display: "none" }} />
{uploading && <span style={{ fontSize: 12, opacity: .7 }}> ...</span>}
</div>
</div>
<div style={{ gridColumn: "span 6" }}>
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}>()</label>
<input style={inputStyle} value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })} />
</div>
{form.imageUrl && (
<div style={{ gridColumn: "span 6" }}>
<div style={{ fontSize: 12, opacity: .7, marginBottom: 6 }}></div>
<img src={form.imageUrl} alt="미리보기" style={{ width: 240, height: 120, objectFit: "cover", borderRadius: 6, border: "1px solid #eee" }} />
</div>
)}
<div style={{ gridColumn: "span 6", display: "flex", justifyContent: "flex-end", gap: 8 }}>
<button
onClick={create}
className="h-9 px-4 rounded-md bg-neutral-900 text-white text-sm shadow cursor-pointer hover:bg-neutral-800 active:translate-y-px transition"
>
</button>
<button
type="button"
onClick={() => setShowCreateModal(false)}
className="h-9 px-4 rounded-md border border-neutral-300 text-sm cursor-pointer hover:bg-neutral-100 active:translate-y-px transition"
>
</button>
</div>
</div>
);
})()}
</div>
</div>
</Modal>
<Modal open={showEditModal} onClose={() => { setShowEditModal(false); setEditingId(null); setEditDraft(null); }}>
<div className="w-[720px] max-w-[90vw]">
<div className="p-6">
<h2 className="text-lg font-bold mb-4"> </h2>
{(() => {
const inputStyle: React.CSSProperties = { border: "1px solid #ddd", borderRadius: 6, padding: "8px 10px", width: "100%" };
return (
<div style={{ display: "grid", gridTemplateColumns: "repeat(6, minmax(0, 1fr))", gap: 12, alignItems: "end" }}>
<div style={{ gridColumn: "span 3" }}>
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}></label>
<input style={inputStyle} value={editDraft?.name ?? ""} onChange={(e) => setEditDraft({ ...editDraft, name: e.target.value })} />
</div>
<div style={{ gridColumn: "span 3" }}>
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}></label>
<input style={inputStyle} value={editDraft?.category ?? ""} onChange={(e) => setEditDraft({ ...editDraft, category: e.target.value })} />
</div>
<div style={{ gridColumn: "span 3" }}>
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}></label>
<input style={inputStyle} value={editDraft?.latitude ?? ""} onChange={(e) => setEditDraft({ ...editDraft, latitude: e.target.value })} />
</div>
<div style={{ gridColumn: "span 3" }}>
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}></label>
<input style={inputStyle} value={editDraft?.longitude ?? ""} onChange={(e) => setEditDraft({ ...editDraft, longitude: e.target.value })} />
</div>
<div style={{ gridColumn: "span 6" }}>
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}> URL</label>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<input style={inputStyle} value={editDraft?.imageUrl ?? ""} onChange={(e) => setEditDraft({ ...editDraft, imageUrl: e.target.value })} />
<button
type="button"
onClick={() => editFileInputRef.current?.click()}
disabled={editUploading}
className="h-9 px-4 w-36 rounded-md border border-neutral-300 text-sm cursor-pointer hover:bg-neutral-100 hover:border-neutral-400 active:translate-y-px transition"
>
</button>
<input ref={editFileInputRef} type="file" accept="image/*" onChange={onSelectEditFile} style={{ display: "none" }} />
{editUploading && <span style={{ fontSize: 12, opacity: .7 }}> ...</span>}
</div>
</div>
<div style={{ gridColumn: "span 6" }}>
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}></label>
<input style={inputStyle} value={editDraft?.address ?? ""} onChange={(e) => setEditDraft({ ...editDraft, address: e.target.value })} />
</div>
{editDraft?.imageUrl && (
<div style={{ gridColumn: "span 6" }}>
<div style={{ fontSize: 12, opacity: .7, marginBottom: 6 }}></div>
<img src={editDraft.imageUrl} alt="미리보기" style={{ width: 240, height: 120, objectFit: "cover", borderRadius: 6, border: "1px solid #eee" }} />
</div>
)}
<div style={{ gridColumn: "span 6", display: "flex", justifyContent: "flex-end", gap: 8 }}>
<button
onClick={async () => { if (!editingId) return; await fetch(`/api/admin/partners/${editingId}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ ...editDraft }) }); setEditingId(null); setEditDraft(null); setShowEditModal(false); mutate(); }}
className="h-9 px-4 rounded-md bg-neutral-900 text-white text-sm shadow cursor-pointer hover:bg-neutral-800 active:translate-y-px transition"
>
</button>
<button
type="button"
onClick={() => { setShowEditModal(false); setEditingId(null); setEditDraft(null); }}
className="h-9 px-4 rounded-md border border-neutral-300 text-sm cursor-pointer hover:bg-neutral-100 active:translate-y-px transition"
>
</button>
</div>
</div>
);
})()}
</div>
</div>
</Modal>
<ul style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{partners.map((p) => (
<li key={p.id} style={{ border: "1px solid #eee", borderRadius: 8, padding: 12, display: "grid", gridTemplateColumns: "auto 1fr 1fr 1fr auto", gap: 8, alignItems: "center" }}>
<div style={{ width: 80 }}>
{p.imageUrl ? (
<img src={p.imageUrl} alt={p.name} style={{ width: 80, height: 48, objectFit: "cover", borderRadius: 6 }} />
) : (
<div style={{ width: 80, height: 48, border: "1px solid #eee", borderRadius: 6, background: "#fafafa" }} />
)}
</div>
{false ? (
<></>
) : (
<>
<div>
<div><strong>{p.name}</strong> <span style={{ marginLeft: 6, fontSize: 12, opacity: .7 }}>{p.category}</span></div>
<div style={{ fontSize: 12, opacity: .7 }}>{p.address || "(주소 없음)"}</div>
</div>
<div style={{ fontSize: 12, opacity: .7 }}> {p.latitude}</div>
<div style={{ fontSize: 12, opacity: .7 }}> {p.longitude}</div>
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
<button
onClick={() => { setEditingId(p.id); setEditDraft({ name: p.name, category: p.category, latitude: String(p.latitude), longitude: String(p.longitude), address: p.address ?? "", imageUrl: p.imageUrl ?? "", sortOrder: p.sortOrder ?? 0 }); setShowEditModal(true); }}
className="h-8 px-3 rounded-md border border-neutral-300 text-sm cursor-pointer hover:bg-neutral-100 active:translate-y-px transition"
>
</button>
<button
onClick={async () => { await fetch(`/api/admin/partners/${p.id}`, { method: "DELETE" }); mutate(); }}
className="h-8 px-3 rounded-md border border-red-200 text-red-600 text-sm cursor-pointer hover:bg-red-100 hover:border-red-300 hover:text-red-700 active:translate-y-px transition"
>
</button>
<button
title="위로"
onClick={async () => { await fetch(`/api/admin/partners/${p.id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ sortOrder: (p.sortOrder ?? 0) - 1 }) }); mutate(); }}
className="h-8 px-3 rounded-md border border-neutral-300 text-sm cursor-pointer hover:bg-neutral-100 active:translate-y-px transition"
>
</button>
<button
title="아래로"
onClick={async () => { await fetch(`/api/admin/partners/${p.id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ sortOrder: (p.sortOrder ?? 0) + 1 }) }); mutate(); }}
className="h-8 px-3 rounded-md border border-neutral-300 text-sm cursor-pointer hover:bg-neutral-100 active:translate-y-px transition"
>
</button>
</div>
</>
)}
</li>
))}
</ul>
</div>
);
}

View File

@@ -9,8 +9,11 @@ export async function GET() {
const createSchema = z.object({ const createSchema = z.object({
title: z.string().min(1), title: z.string().min(1),
imageUrl: z.string().url(), // 절대 URL 또는 /로 시작하는 상대경로 허용
linkUrl: z.string().url().optional(), imageUrl: z.string().refine((v) => /^https?:\/\//.test(v) || v.startsWith("/"), {
message: "imageUrl must be absolute URL or start with /",
}),
linkUrl: z.string().refine((v) => !v || /^https?:\/\//.test(v), { message: "linkUrl must be http(s) URL" }).optional(),
active: z.boolean().optional(), active: z.boolean().optional(),
sortOrder: z.coerce.number().int().optional(), sortOrder: z.coerce.number().int().optional(),
startAt: z.coerce.date().optional(), startAt: z.coerce.date().optional(),

View File

@@ -0,0 +1,21 @@
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
export async function PATCH(req: Request, context: { params: Promise<{ id: string }> }) {
const { id } = await context.params;
const body = await req.json().catch(() => ({}));
const data: any = {};
for (const k of ["region", "name", "address", "imageUrl", "active", "sortOrder"]) {
if (k in body) data[k] = body[k];
}
const item = await prisma.partnerShop.update({ where: { id }, data });
return NextResponse.json({ item });
}
export async function DELETE(_: Request, context: { params: Promise<{ id: string }> }) {
const { id } = await context.params;
await prisma.partnerShop.delete({ where: { id } });
return NextResponse.json({ ok: true });
}

View File

@@ -0,0 +1,27 @@
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
import { z } from "zod";
export async function GET() {
const items = await prisma.partnerShop.findMany({ orderBy: [{ active: "desc" }, { sortOrder: "asc" }, { createdAt: "desc" }] });
return NextResponse.json({ items });
}
const createSchema = z.object({
region: z.string().min(1),
name: z.string().min(1),
address: z.string().min(1),
imageUrl: z.string().min(1),
sortOrder: z.coerce.number().int().optional(),
active: z.boolean().optional(),
});
export async function POST(req: Request) {
const body = await req.json().catch(() => ({}));
const parsed = createSchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
const item = await prisma.partnerShop.create({ data: parsed.data });
return NextResponse.json({ item }, { status: 201 });
}

View File

@@ -0,0 +1,34 @@
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
export async function PATCH(req: Request, context: { params: Promise<{ id: string }> }) {
const { id } = await context.params;
const body = await req.json().catch(() => ({}));
const data: any = {};
for (const k of ["name", "category", "latitude", "longitude", "address", "imageUrl", "sortOrder"]) {
if (k in body) data[k] = body[k];
}
if (typeof data.latitude !== "undefined") data.latitude = Number(data.latitude);
if (typeof data.longitude !== "undefined") data.longitude = Number(data.longitude);
if (typeof data.sortOrder !== "undefined") data.sortOrder = Number(data.sortOrder);
try {
const partner = await prisma.partner.update({ where: { id }, data });
return NextResponse.json({ partner });
} catch (e) {
// DB에 sortOrder 컬럼이 아직 없는 환경 대비: 해당 키 제거 후 재시도
if (Object.prototype.hasOwnProperty.call(data, "sortOrder")) {
const { sortOrder, ...rest } = data;
const partner = await prisma.partner.update({ where: { id }, data: rest });
return NextResponse.json({ partner });
}
throw e;
}
}
export async function DELETE(_: Request, context: { params: Promise<{ id: string }> }) {
const { id } = await context.params;
await prisma.partner.delete({ where: { id } });
return NextResponse.json({ ok: true });
}

View File

@@ -0,0 +1,40 @@
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
import { z } from "zod";
export async function GET() {
try {
// 정렬용 컬럼(sortOrder)이 있는 경우 우선 사용
const partners = await (prisma as any).partner.findMany({ orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }] });
return NextResponse.json({ partners });
} catch (_) {
// 컬럼이 아직 DB에 없으면 createdAt 기준으로 폴백
const partners = await prisma.partner.findMany({ orderBy: { createdAt: "desc" } });
return NextResponse.json({ partners });
}
}
const createSchema = z.object({
name: z.string().min(1),
category: z.string().min(1),
latitude: z.coerce.number(),
longitude: z.coerce.number(),
address: z.string().min(1).optional(),
imageUrl: z
.string()
.refine((v) => !v || /^https?:\/\//.test(v) || v.startsWith("/"), {
message: "imageUrl must be http(s) URL or start with /",
})
.optional(),
sortOrder: z.coerce.number().int().optional(),
});
export async function POST(req: Request) {
const body = await req.json().catch(() => ({}));
const parsed = createSchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
const partner = await prisma.partner.create({ data: parsed.data as any });
return NextResponse.json({ partner }, { status: 201 });
}

View File

@@ -0,0 +1,12 @@
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
export async function GET() {
const items = await prisma.partnerShop.findMany({
where: { active: true },
orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }],
});
return NextResponse.json({ items });
}

View File

@@ -1,3 +1,4 @@
import Link from "next/link";
import { headers } from "next/headers"; import { headers } from "next/headers";
export default async function BoardsPage() { export default async function BoardsPage() {
@@ -14,7 +15,7 @@ export default async function BoardsPage() {
<h1></h1> <h1></h1>
<ul style={{ display: "flex", flexDirection: "column", gap: 6 }}> <ul style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{boards?.map((b: any) => ( {boards?.map((b: any) => (
<li key={b.id}><a href={`/boards/${b.id}`}>{b.name}</a></li> <li key={b.id}><Link href={`/boards/${b.id}`}>{b.name}</Link></li>
))} ))}
</ul> </ul>
</div> </div>

View File

@@ -1,9 +1,10 @@
import Link from "next/link";
export function AppSidebar() { export function AppSidebar() {
return ( return (
<aside style={{ width: 200, borderRight: "1px solid #eee", padding: 12 }}> <aside style={{ width: 200, borderRight: "1px solid #eee", padding: 12 }}>
<ul style={{ display: "flex", flexDirection: "column", gap: 8 }}> <ul style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<li><a href="/boards"></a></li> <li><Link href="/boards"></Link></li>
<li><a href="/admin"></a></li> <li><Link href="/admin"></Link></li>
</ul> </ul>
</aside> </aside>
); );

View File

@@ -2,12 +2,22 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import Image from "next/image"; import Image from "next/image";
import { SelectedBanner } from "@/app/components/SelectedBanner"; import { SelectedBanner } from "@/app/components/SelectedBanner";
import Link from "next/link";
import useSWR from "swr";
type Banner = { id: string; title: string; imageUrl: string; linkUrl?: string | null }; type Banner = { id: string; title: string; imageUrl: string; linkUrl?: string | null };
type SubItem = { id: string; name: string; href: string }; type SubItem = { id: string; name: string; href: string };
const fetcher = (url: string) => fetch(url).then((r) => r.json());
export function HeroBanner({ subItems, activeSubId }: { subItems?: SubItem[]; activeSubId?: string }) { export function HeroBanner({ subItems, activeSubId }: { subItems?: SubItem[]; activeSubId?: string }) {
const [banners, setBanners] = useState<Banner[]>([]); // SWR 캐시 사용: 페이지 전환 시 재요청/깜빡임 최소화
const { data } = useSWR<{ banners: Banner[] }>("/api/banners", fetcher, {
revalidateOnFocus: false,
dedupingInterval: 10 * 60 * 1000, // 10분 간 동일 요청 합치기
keepPreviousData: true,
});
const banners = data?.banners ?? [];
const [activeIndex, setActiveIndex] = useState(0); const [activeIndex, setActiveIndex] = useState(0);
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
const [progress, setProgress] = useState(0); // 0..1 const [progress, setProgress] = useState(0); // 0..1
@@ -15,10 +25,6 @@ export function HeroBanner({ subItems, activeSubId }: { subItems?: SubItem[]; ac
const rafIdRef = useRef<number | null>(null); const rafIdRef = useRef<number | null>(null);
const startedAtRef = useRef<number>(0); const startedAtRef = useRef<number>(0);
useEffect(() => {
fetch("/api/banners").then((r) => r.json()).then((d) => setBanners(d.banners ?? []));
}, []);
const numSlides = banners.length; const numSlides = banners.length;
const canAutoPlay = numSlides > 1 && !isHovered; const canAutoPlay = numSlides > 1 && !isHovered;
@@ -67,7 +73,33 @@ export function HeroBanner({ subItems, activeSubId }: { subItems?: SubItem[]; ac
const translatePercent = useMemo(() => (numSlides > 0 ? -(activeIndex * 100) : 0), [activeIndex, numSlides]); const translatePercent = useMemo(() => (numSlides > 0 ? -(activeIndex * 100) : 0), [activeIndex, numSlides]);
if (numSlides === 0) return <SelectedBanner height={224} />; if (numSlides === 0) {
return (
<section className="relative w-full overflow-hidden rounded-xl bg-neutral-900 text-white" aria-roledescription="carousel">
<SelectedBanner className="h-56 sm:h-72 md:h-[264px]" />
{/* 분리된 하단 블랙 바: 높이 58px, 중앙 서브카테고리 (스켈레톤 상태에서도 동일 레이아웃 유지) */}
<div className="mt-2 h-[58px] bg-black rounded-xl flex items-center justify-center px-2">
{Array.isArray(subItems) && subItems.length > 0 && (
<div className="flex flex-wrap items-center gap-[8px]">
{subItems.map((s) => (
<Link
key={s.id}
href={s.href}
className={
s.id === activeSubId
? "px-3 h-[28px] rounded-full bg-[#F94B37] text-white text-[12px] leading-[28px] whitespace-nowrap"
: "px-3 h-[28px] rounded-full bg-transparent text-white/85 hover:text-white border border-white/30 text-[12px] leading-[28px] whitespace-nowrap"
}
>
{s.name}
</Link>
))}
</div>
)}
</div>
</section>
);
}
return ( return (
<section <section
@@ -121,11 +153,11 @@ export function HeroBanner({ subItems, activeSubId }: { subItems?: SubItem[]; ac
{/* 분리된 하단 블랙 바: 높이 58px, 중앙 서브카테고리 */} {/* 분리된 하단 블랙 바: 높이 58px, 중앙 서브카테고리 */}
<div className="mt-2 h-[58px] bg-black rounded-xl flex items-center justify-center px-2"> <div className="mt-2 h-[58px] bg-black rounded-xl flex items-center justify-center px-2">
{Array.isArray(subItems) && subItems.length > 0 && ( {Array.isArray(subItems) && subItems.length > 0 && (
<div className="flex items-center gap-[8px] overflow-x-auto no-scrollbar"> <div className="flex flex-wrap items-center gap-[8px]">
{subItems.map((s) => ( {subItems.map((s) => (
<a <Link
key={s.id} key={s.id}
href={s.href} href={s.href}
className={ className={
s.id === activeSubId s.id === activeSubId
? "px-3 h-[28px] rounded-full bg-[#F94B37] text-white text-[12px] leading-[28px] whitespace-nowrap" ? "px-3 h-[28px] rounded-full bg-[#F94B37] text-white text-[12px] leading-[28px] whitespace-nowrap"
@@ -133,7 +165,7 @@ export function HeroBanner({ subItems, activeSubId }: { subItems?: SubItem[]; ac
} }
> >
{s.name} {s.name}
</a> </Link>
))} ))}
</div> </div>
)} )}

View File

@@ -1,3 +1,4 @@
import Link from "next/link";
"use client"; "use client";
import useSWR from "swr"; import useSWR from "swr";
@@ -16,7 +17,7 @@ export function PersonalWidgets() {
<ul style={{ display: "flex", flexDirection: "column", gap: 6 }}> <ul style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{(recent?.items ?? []).map((i) => ( {(recent?.items ?? []).map((i) => (
<li key={i.id}> <li key={i.id}>
<a href={`/posts/${i.id}`}>{i.title}</a> <Link href={`/posts/${i.id}`}>{i.title}</Link>
</li> </li>
))} ))}
{(!recent || recent.items.length === 0) && <li> .</li>} {(!recent || recent.items.length === 0) && <li> .</li>}

View File

@@ -2,6 +2,7 @@
import useSWRInfinite from "swr/infinite"; import useSWRInfinite from "swr/infinite";
import useSWR from "swr"; import useSWR from "swr";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import ViewsIcon from "@/app/svgs/ViewsIcon"; import ViewsIcon from "@/app/svgs/ViewsIcon";
import LikeIcon from "@/app/svgs/LikeIcon"; import LikeIcon from "@/app/svgs/LikeIcon";
@@ -129,14 +130,14 @@ export function PostList({ boardId, sort = "recent", q, tag, author, start, end,
<div className="hidden md:flex items-center justify-center text-[#f94b37]">{p.isPinned ? "★" : "•"}</div> <div className="hidden md:flex items-center justify-center text-[#f94b37]">{p.isPinned ? "★" : "•"}</div>
<div className="min-w-0"> <div className="min-w-0">
<a href={`/posts/${p.id}`} className="block truncate text-[15px] md:text-base text-neutral-900"> <Link href={`/posts/${p.id}`} className="block truncate text-[15px] md:text-base text-neutral-900">
{p.isPinned && <span className="mr-2 inline-flex items-center rounded bg-orange-100 text-orange-700 px-1.5 py-0.5 text-[11px]"></span>} {p.isPinned && <span className="mr-2 inline-flex items-center rounded bg-orange-100 text-orange-700 px-1.5 py-0.5 text-[11px]"></span>}
{p.title} {p.title}
</a> </Link>
{!!p.postTags?.length && ( {!!p.postTags?.length && (
<div className="mt-1 hidden md:flex flex-wrap gap-1 text-[11px] text-neutral-500"> <div className="mt-1 hidden md:flex flex-wrap gap-1 text-[11px] text-neutral-500">
{p.postTags?.map((pt) => ( {p.postTags?.map((pt) => (
<a key={pt.tag.slug} href={`/search?tag=${pt.tag.slug}`} className="hover:underline">#{pt.tag.name}</a> <Link key={pt.tag.slug} href={`/search?tag=${pt.tag.slug}`} className="hover:underline">#{pt.tag.name}</Link>
))} ))}
</div> </div>
)} )}
@@ -227,9 +228,9 @@ export function PostList({ boardId, sort = "recent", q, tag, author, start, end,
</button> </button>
</div> </div>
{newPostHref && ( {newPostHref && (
<a href={newPostHref} className="shrink-0"> <Link href={newPostHref} className="shrink-0">
<button className="h-9 px-4 rounded-[10px] bg-[#f94b37] text-white text-sm border border-[#d73b29] hover:opacity-95"></button> <button className="h-9 px-4 rounded-[10px] bg-[#f94b37] text-white text-sm border border-[#d73b29] hover:opacity-95"></button>
</a> </Link>
)} )}
</div> </div>
) : ( ) : (

View File

@@ -1,3 +1,4 @@
import Link from "next/link";
"use client"; "use client";
import { usePermission } from "@/lib/usePermission"; import { usePermission } from "@/lib/usePermission";
@@ -7,8 +8,8 @@ export function QuickActions() {
const isAdmin = can("ADMIN", "ADMINISTER") || can("BOARD", "MODERATE"); const isAdmin = can("ADMIN", "ADMINISTER") || can("BOARD", "MODERATE");
return ( return (
<div style={{ display: "flex", gap: 8 }}> <div style={{ display: "flex", gap: 8 }}>
{canWrite && <a href="/posts/new"><button></button></a>} {canWrite && <Link href="/posts/new"><button></button></Link>}
{isAdmin && <a href="/admin"><button></button></a>} {isAdmin && <Link href="/admin"><button></button></Link>}
</div> </div>
); );
} }

View File

@@ -2,24 +2,19 @@
import React from "react"; import React from "react";
type Props = { type Props = {
height?: number | string; // ex) 224 or '14rem' height?: number | string; // ex) 224 or '14rem' (지정 시 고정 높이, 미지정 시 클래스 높이 사용)
className?: string; className?: string;
}; };
// Figma 선택 요소(Container_Event)를 그대로 옮긴 플레이스홀더형 배너 // Figma 선택 요소(Container_Event)를 그대로 옮긴 플레이스홀더형 배너
export function SelectedBanner({ height = 122, className }: Props) { export function SelectedBanner({ height, className }: Props) {
return ( return (
<section <section
className={`relative w-full overflow-hidden rounded-[12px] bg-[#D9D9D9] ${className ?? ""}`} className={`relative w-full overflow-hidden rounded-[12px] bg-[#D9D9D9] ${className ?? ""}`}
style={{ height }} style={height != null ? { height } : undefined}
aria-label="banner" aria-label="banner"
> >
<div className="absolute left-1/2 -translate-x-1/2 flex items-center gap-[6px]" style={{ bottom: 12 }}> {/* 스켈레톤 상태에서는 하단 점(페이지네이션) 제거 */}
<span className="block h-[4px] w-[18px] rounded-full bg-[#F94B37]" aria-hidden />
{Array.from({ length: 12 }).map((_, i) => (
<span key={i} className="block h-[6px] w-[6px] rounded-full bg-[#B9B9B9]" aria-hidden />
))}
</div>
</section> </section>
); );
} }

View File

@@ -1,3 +1,4 @@
import Link from "next/link";
"use client"; "use client";
import useSWR from "swr"; import useSWR from "swr";
@@ -8,11 +9,11 @@ export function TagFilter({ basePath, current }: { basePath: string; current?: s
const tags = data?.tags ?? []; const tags = data?.tags ?? [];
return ( return (
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: 8 }}> <div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: 8 }}>
<a href={`${basePath}`} style={{ textDecoration: !current ? "underline" : "none" }}></a> <Link href={`${basePath}`} style={{ textDecoration: !current ? "underline" : "none" }}></Link>
{tags.map((t) => ( {tags.map((t) => (
<a key={t.slug} href={`${basePath}?tag=${t.slug}`} style={{ textDecoration: current === t.slug ? "underline" : "none" }}> <Link key={t.slug} href={`${basePath}?tag=${t.slug}`} style={{ textDecoration: current === t.slug ? "underline" : "none" }}>
#{t.name} #{t.name}
</a> </Link>
))} ))}
</div> </div>
); );

View File

@@ -11,10 +11,11 @@ export function Modal({ open, onClose, children }: Props) {
style={{ style={{
position: "fixed", position: "fixed",
inset: 0, inset: 0,
background: "rgba(0,0,0,0.4)", background: "transparent",
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
zIndex: 50,
}} }}
> >
<div onClick={(e) => e.stopPropagation()} style={{ background: "#fff", padding: 16, borderRadius: 8 }}> <div onClick={(e) => e.stopPropagation()} style={{ background: "#fff", padding: 16, borderRadius: 8 }}>

View File

@@ -26,6 +26,9 @@ body {
font-family: Pretendard, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", Arial, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", sans-serif; font-family: Pretendard, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", Arial, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", sans-serif;
} }
/* 레이아웃 점프 방지: 스크롤바 여백을 항상 확보 */
html { scrollbar-gutter: stable both-edges; }
/* 유틸: 카드 스켈레톤 색상 헬퍼 (타깃 사이트 톤 유사) */ /* 유틸: 카드 스켈레톤 색상 헬퍼 (타깃 사이트 톤 유사) */
.bg-neutral-100 { background-color: #f5f5f7; } .bg-neutral-100 { background-color: #f5f5f7; }

View File

@@ -1,3 +1,4 @@
import Link from "next/link";
"use client"; "use client";
import React from "react"; import React from "react";
import { Button } from "@/app/components/ui/Button"; import { Button } from "@/app/components/ui/Button";
@@ -46,7 +47,7 @@ export default function LoginPage() {
/> />
<Button type="submit" disabled={loading}>{loading ? "로그인 중..." : "로그인"}</Button> <Button type="submit" disabled={loading}>{loading ? "로그인 중..." : "로그인"}</Button>
<div style={{ display: "flex", justifyContent: "flex-end" }}> <div style={{ display: "flex", justifyContent: "flex-end" }}>
<a href="/register"></a> <Link href="/register"></Link>
</div> </div>
</form> </form>
</div> </div>

View File

@@ -1,4 +1,5 @@
import { HeroBanner } from "@/app/components/HeroBanner"; import { HeroBanner } from "@/app/components/HeroBanner";
import Link from "next/link";
import HorizontalCardScroller from "@/app/components/HorizontalCardScroller"; import HorizontalCardScroller from "@/app/components/HorizontalCardScroller";
import { PostList } from "@/app/components/PostList"; import { PostList } from "@/app/components/PostList";
import ProfileLabelIcon from "@/app/svgs/profilelableicon"; import ProfileLabelIcon from "@/app/svgs/profilelableicon";
@@ -31,8 +32,8 @@ export default async function Home({ searchParams }: { searchParams: Promise<{ s
const renderBoardPanel = (board: { id: string; name: string }) => ( const renderBoardPanel = (board: { id: string; name: string }) => (
<div key={board.id} className="rounded-xl overflow-hidden h-full min-h-0 flex flex-col bg-white"> <div key={board.id} className="rounded-xl overflow-hidden h-full min-h-0 flex flex-col bg-white">
<div className="px-3 py-2 border-b border-neutral-200 flex items-center justify-between"> <div className="px-3 py-2 border-b border-neutral-200 flex items-center justify-between">
<a href={`/boards/${board.id}`} className="text-lg md:text-xl font-bold text-neutral-800 truncate">{board.name}</a> <Link href={`/boards/${board.id}`} className="text-lg md:text-xl font-bold text-neutral-800 truncate">{board.name}</Link>
<a href={`/boards/${board.id}`} className="text-xs px-3 py-1 rounded-full border border-neutral-300 text-neutral-700 hover:bg-neutral-100"></a> <Link href={`/boards/${board.id}`} className="text-xs px-3 py-1 rounded-full border border-neutral-300 text-neutral-700 hover:bg-neutral-100"></Link>
</div> </div>
<div className="flex-1 min-h-0 overflow-hidden p-0"> <div className="flex-1 min-h-0 overflow-hidden p-0">
<PostList boardId={board.id} sort={sort} /> <PostList boardId={board.id} sort={sort} />
@@ -49,21 +50,29 @@ export default async function Home({ searchParams }: { searchParams: Promise<{ s
</section> </section>
)} )}
{/* 제휴 샾 가로 스크롤 (설정 온오프) */} {/* 제휴 샾 가로 스크롤 (설정 온오프, DB에서 불러오기)
{showPartnerShops && (() => { - 우선 partners 테이블(관리자 페이지 관리 대상) 사용
const items = [ - 없으면 partner_shops로 대체 */}
{ id: 1, region: "경기도", name: "라온마사지샾", address: "수원시 팔달구 매산로 45", image: "/sample.jpg" }, {showPartnerShops && (async () => {
{ id: 2, region: "강원도", name: "휴앤힐링마사지샾", address: "춘천시 중앙로 112", image: "/sample.jpg" }, // 우선순위: partners(관리자 관리) → partner_shops(폴백)
{ id: 3, region: "충청북도", name: "소담마사지샾", address: "청주시 상당구 상당로 88", image: "/sample.jpg" }, let partners: any[] = [];
{ id: 4, region: "충청남도", name: "아늑마사지샾", address: "천안시 동남구 시민로 21", image: "/sample.jpg" }, try {
{ id: 5, region: "전라북도", name: "편안한마사지샾", address: "전주시 완산구 풍남문로 77", image: "/sample.jpg" }, partners = await (prisma as any).partner.findMany({ orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }], take: 10 });
{ id: 6, region: "전라남도", name: "바른마사지샾", address: "여수시 중앙로 9", image: "/sample.jpg" }, } catch (_) {
{ id: 7, region: "경상북도", name: "늘봄마사지샾", address: "대구시 중구 동성로3길 12", image: "/sample.jpg" }, partners = await prisma.partner.findMany({ orderBy: { createdAt: "desc" }, take: 10 });
{ id: 8, region: "경상남도", name: "편히쉬다마사지샾", address: "창원시 성산구 중앙대로 150", image: "/sample.jpg" }, }
{ id: 9, region: "제주특별자치도", name: "제주소풍마사지샾", address: "제주시 중앙로 230", image: "/sample.jpg" }, const items = partners.map((p: any) => ({
{ id: 10, region: "서울특별시", name: "도심휴식마사지샾", address: "강남구 테헤란로 427", image: "/sample.jpg" }, id: p.id,
]; region: p.address ? String(p.address).split(" ")[0] : p.category,
return <HorizontalCardScroller items={items} />; name: p.name,
address: p.address || "",
image: p.imageUrl || "/sample.jpg",
}));
if (items.length > 0) return <HorizontalCardScroller items={items} />;
const shops = await (prisma as any).partnerShop.findMany({ where: { active: true }, orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }] });
const shopItems = shops.map((s: any) => ({ id: s.id, region: s.region, name: s.name, address: s.address, image: s.imageUrl }));
return <HorizontalCardScroller items={shopItems} />;
})()} })()}
{/* 1행: 프로필 + 선택된 보드 2개 (최대 2개) */} {/* 1행: 프로필 + 선택된 보드 2개 (최대 2개) */}

View File

@@ -1,3 +1,4 @@
import Link from "next/link";
"use client"; "use client";
import useSWR from "swr"; import useSWR from "swr";
@@ -10,10 +11,10 @@ export default function RankingPage({ searchParams }: { searchParams?: { period?
<div> <div>
<h1></h1> <h1></h1>
<div style={{ display: "flex", gap: 8, margin: "8px 0" }}> <div style={{ display: "flex", gap: 8, margin: "8px 0" }}>
<a href={`/ranking?period=daily`}></a> <Link href={`/ranking?period=daily`}></Link>
<a href={`/ranking?period=weekly`}></a> <Link href={`/ranking?period=weekly`}></Link>
<a href={`/ranking?period=monthly`}></a> <Link href={`/ranking?period=monthly`}></Link>
<a href={`/ranking?period=all`}></a> <Link href={`/ranking?period=all`}></Link>
</div> </div>
<ol> <ol>
{(data?.items ?? []).map((i) => ( {(data?.items ?? []).map((i) => (