diff --git a/.cursor/.prompt/normalboardwork.md b/.cursor/.prompt/done/normalboardwork.md similarity index 100% rename from .cursor/.prompt/normalboardwork.md rename to .cursor/.prompt/done/normalboardwork.md diff --git a/.cursor/.prompt/profilearea.md b/.cursor/.prompt/done/profilearea.md similarity index 100% rename from .cursor/.prompt/profilearea.md rename to .cursor/.prompt/done/profilearea.md diff --git a/.cursor/.prompt/search.md b/.cursor/.prompt/done/search.md similarity index 100% rename from .cursor/.prompt/search.md rename to .cursor/.prompt/done/search.md diff --git a/.cursor/.prompt/worklist.md b/.cursor/.prompt/done/worklist.md similarity index 100% rename from .cursor/.prompt/worklist.md rename to .cursor/.prompt/done/worklist.md diff --git a/package.json b/package.json index 1a06fd8..9257ccc 100644 --- a/package.json +++ b/package.json @@ -8,12 +8,10 @@ "start": "next start", "lint": "biome check", "format": "biome format --write", - "prisma:generate": "prisma generate", - "prisma:migrate": "prisma migrate dev", - "prisma:studio": "prisma studio", - "prisma:db:push": "prisma db push", - "prisma:seed": "node prisma/seed.js", - "prisma:erd": "prisma generate" + "migrate": "prisma migrate dev", + "studio": "prisma studio", + "seed": "node prisma/seed.js", + "dbforce": "prisma migrate reset --force" }, "dependencies": { "@prisma/client": "^6.17.0", diff --git a/prisma/migrations/20251101152100_/migration.sql b/prisma/migrations/20251101152100_/migration.sql new file mode 100644 index 0000000..a3b957d --- /dev/null +++ b/prisma/migrations/20251101152100_/migration.sql @@ -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"); diff --git a/prisma/migrations/20251101161632_/migration.sql b/prisma/migrations/20251101161632_/migration.sql new file mode 100644 index 0000000..a71df66 --- /dev/null +++ b/prisma/migrations/20251101161632_/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "partners" ADD COLUMN "imageUrl" TEXT; diff --git a/prisma/migrations/20251101162445_/migration.sql b/prisma/migrations/20251101162445_/migration.sql new file mode 100644 index 0000000..0e3ad79 --- /dev/null +++ b/prisma/migrations/20251101162445_/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 10c9fe0..dccbad6 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -675,10 +675,13 @@ model Partner { latitude Float longitude Float address String? + imageUrl String? + sortOrder Int @default(0) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([category]) + @@index([sortOrder]) @@map("partners") } @@ -742,3 +745,20 @@ model Setting { @@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") +} diff --git a/prisma/seed.js b/prisma/seed.js index b7cbca3..5389bd7 100644 --- a/prisma/seed.js +++ b/prisma/seed.js @@ -2,16 +2,87 @@ const { PrismaClient } = require("@prisma/client"); 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() { // 카테고리 트리 (projectmemo 기준 상위 그룹) 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: "nearby-partners", 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" }, + { name: "소통방", slug: "community", sortOrder: 3, status: "active" }, ]; const map = {}; for (const c of categories) { @@ -119,20 +190,13 @@ async function upsertBoards(admin, categoryMap) { { name: "무엇이든", slug: "qna", description: "무엇이든 물어보세요", type: "general", sortOrder: 6 }, { name: "마사지꿀팁", slug: "tips", description: "팁", type: "general", sortOrder: 7 }, { 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: "reviews", description: "운영자 승인 후 공개", type: "general", sortOrder: 11, requiresApproval: true, requiredTags: { anyOf: ["업체명", "지역"] } }, // 특수 { 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: "free-coupons", description: "쿠폰", type: "special", sortOrder: 15 }, { 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 = []; @@ -149,22 +213,12 @@ async function upsertBoards(admin, categoryMap) { ranking: "hall-of-fame", "free-coupons": "hall-of-fame", "monthly-stats": "hall-of-fame", - // 주변 제휴업체 - "nearby-partners": "nearby-partners", - // 제휴업소 정보 - "partners-photos": "partner-info", - // 방문후기 - reviews: "reviews", // 소통방(기본값 community로 처리) free: "community", qna: "community", tips: "community", anonymous: "community", - "find-therapist": "community", - "blue-house": "community", // 광고/제휴 - "partner-contact": "ads-affiliates", - "partner-req": "ads-affiliates", }; const categorySlug = mapBySlug[b.slug] || "community"; 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() { // 금칙어 예시 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() { await upsertRoles(); const admin = await upsertAdmin(); const categoryMap = await upsertCategories(); await upsertViewTypes(); + await createRandomUsers(100); + await removeNonPrimaryBoards(); const boards = await upsertBoards(admin, categoryMap); - - // 샘플 글 하나 - 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 createPostsForAllBoards(boards, 100, admin); + await seedPartnerShops(); await seedPolicies(); // 제휴업체 예시 데이터 const partners = [ - { name: "힐링마사지", category: "spa", latitude: 37.5665, longitude: 126.9780, address: "서울 중구" }, - { name: "웰빙테라피", category: "spa", latitude: 37.5700, longitude: 126.9769, address: "서울 종로구" }, + { name: "test", category: "spa", latitude: 37.5700, longitude: 126.9769, address: "서울 종로구" }, { name: "굿짐", category: "gym", latitude: 37.5600, longitude: 126.9820, address: "서울 중구" }, ]; for (const p of partners) { diff --git a/public/uploads/1762011428045-b2uxup0646v.png b/public/uploads/1762011428045-b2uxup0646v.png new file mode 100644 index 0000000..7db214e Binary files /dev/null and b/public/uploads/1762011428045-b2uxup0646v.png differ diff --git a/public/uploads/1762011528689-r5qbw3daoq.png b/public/uploads/1762011528689-r5qbw3daoq.png new file mode 100644 index 0000000..7db214e Binary files /dev/null and b/public/uploads/1762011528689-r5qbw3daoq.png differ diff --git a/public/uploads/1762012666810-zhoeib9y8we.png b/public/uploads/1762012666810-zhoeib9y8we.png new file mode 100644 index 0000000..7db214e Binary files /dev/null and b/public/uploads/1762012666810-zhoeib9y8we.png differ diff --git a/public/uploads/1762013315968-jent9fluatl.png b/public/uploads/1762013315968-jent9fluatl.png new file mode 100644 index 0000000..7db214e Binary files /dev/null and b/public/uploads/1762013315968-jent9fluatl.png differ diff --git a/public/uploads/1762013865590-izqoqn8qgbm.png b/public/uploads/1762013865590-izqoqn8qgbm.png new file mode 100644 index 0000000..7db214e Binary files /dev/null and b/public/uploads/1762013865590-izqoqn8qgbm.png differ diff --git a/public/uploads/1762014531262-2vmcxdk945u.jpg b/public/uploads/1762014531262-2vmcxdk945u.jpg new file mode 100644 index 0000000..44c7728 Binary files /dev/null and b/public/uploads/1762014531262-2vmcxdk945u.jpg differ diff --git a/public/uploads/1762014639578-9e1067twpw.png b/public/uploads/1762014639578-9e1067twpw.png new file mode 100644 index 0000000..2c01eda Binary files /dev/null and b/public/uploads/1762014639578-9e1067twpw.png differ diff --git a/public/uploads/1762014695925-jmes4cxd0vd.png b/public/uploads/1762014695925-jmes4cxd0vd.png new file mode 100644 index 0000000..7db214e Binary files /dev/null and b/public/uploads/1762014695925-jmes4cxd0vd.png differ diff --git a/public/uploads/1762014716631-fgq5a179wwr.png b/public/uploads/1762014716631-fgq5a179wwr.png new file mode 100644 index 0000000..44ab9c6 Binary files /dev/null and b/public/uploads/1762014716631-fgq5a179wwr.png differ diff --git a/public/uploads/1762014821297-9qbwphmxm05.png b/public/uploads/1762014821297-9qbwphmxm05.png new file mode 100644 index 0000000..44ab9c6 Binary files /dev/null and b/public/uploads/1762014821297-9qbwphmxm05.png differ diff --git a/public/uploads/1762015671690-p67kkblxdml.png b/public/uploads/1762015671690-p67kkblxdml.png new file mode 100644 index 0000000..7db214e Binary files /dev/null and b/public/uploads/1762015671690-p67kkblxdml.png differ diff --git a/public/uploads/1762015830912-1wsv0cfchd8.jpg b/public/uploads/1762015830912-1wsv0cfchd8.jpg new file mode 100644 index 0000000..d718a07 Binary files /dev/null and b/public/uploads/1762015830912-1wsv0cfchd8.jpg differ diff --git a/public/uploads/1762016086149-vcxoon8tg8.png b/public/uploads/1762016086149-vcxoon8tg8.png new file mode 100644 index 0000000..7db214e Binary files /dev/null and b/public/uploads/1762016086149-vcxoon8tg8.png differ diff --git a/public/uploads/1762016548518-0d2zhs3f44bq.png b/public/uploads/1762016548518-0d2zhs3f44bq.png new file mode 100644 index 0000000..7db214e Binary files /dev/null and b/public/uploads/1762016548518-0d2zhs3f44bq.png differ diff --git a/public/uploads/1762017507500-08hp85ex35v.png b/public/uploads/1762017507500-08hp85ex35v.png new file mode 100644 index 0000000..789d1ff Binary files /dev/null and b/public/uploads/1762017507500-08hp85ex35v.png differ diff --git a/public/uploads/1762017553592-w9qnbapfb2.png b/public/uploads/1762017553592-w9qnbapfb2.png new file mode 100644 index 0000000..44ab9c6 Binary files /dev/null and b/public/uploads/1762017553592-w9qnbapfb2.png differ diff --git a/public/uploads/1762017624031-rni0unzdl6c.jpg b/public/uploads/1762017624031-rni0unzdl6c.jpg new file mode 100644 index 0000000..6ee9978 Binary files /dev/null and b/public/uploads/1762017624031-rni0unzdl6c.jpg differ diff --git a/src/app/admin/AdminSidebar.tsx b/src/app/admin/AdminSidebar.tsx index 95208a5..d6e3653 100644 --- a/src/app/admin/AdminSidebar.tsx +++ b/src/app/admin/AdminSidebar.tsx @@ -7,6 +7,7 @@ const navItems = [ { href: "/admin/mainpage-settings", label: "메인페이지 설정" }, { href: "/admin/boards", label: "게시판" }, { href: "/admin/banners", label: "배너" }, + { href: "/admin/partners", label: "제휴업체" }, { href: "/admin/users", label: "사용자" }, { href: "/admin/logs", label: "로그" }, ]; diff --git a/src/app/admin/banners/page.tsx b/src/app/admin/banners/page.tsx index db11f27..76acf63 100644 --- a/src/app/admin/banners/page.tsx +++ b/src/app/admin/banners/page.tsx @@ -1,6 +1,7 @@ "use client"; 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()); @@ -8,21 +9,112 @@ export default function AdminBannersPage() { const { data, mutate } = useSWR<{ banners: any[] }>("/api/admin/banners", fetcher); const banners = data?.banners ?? []; const [form, setForm] = useState({ title: "", imageUrl: "", linkUrl: "", active: true, sortOrder: 0 }); + const [uploading, setUploading] = useState(false); + const fileInputRef = useRef(null); + const [showCreateModal, setShowCreateModal] = useState(false); + async function onSelectFile(e: React.ChangeEvent) { + 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() { 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 (
-

배너 관리

-
- setForm({ ...form, title: e.target.value })} /> - setForm({ ...form, imageUrl: e.target.value })} /> - setForm({ ...form, linkUrl: e.target.value })} /> - - setForm({ ...form, sortOrder: Number(e.target.value) })} style={{ width: 80 }} /> - +
+

배너 관리

+
+ setShowCreateModal(false)}> +
+
+

배너 추가

+ {(() => { + const inputStyle: React.CSSProperties = { border: "1px solid #ddd", borderRadius: 6, padding: "8px 10px", width: "100%" }; + return ( +
+
+ + setForm({ ...form, title: e.target.value })} /> +
+
+ + setForm({ ...form, linkUrl: e.target.value })} /> +
+
+ +
+ setForm({ ...form, imageUrl: e.target.value })} /> + + + {uploading && 업로드 중...} +
+
+
+ + +
+
+ + setForm({ ...form, sortOrder: Number(e.target.value) })} /> +
+ {form.imageUrl && ( +
+
미리보기
+ 미리보기 +
+ )} +
+ + +
+
+ ); + })()} +
+
+
    {banners.map((b) => (
  • @@ -31,8 +123,8 @@ export default function AdminBannersPage() {
    {b.title} {b.linkUrl && 링크}
    정렬 {b.sortOrder} · {b.active ? "활성" : "비활성"}
- - + + ))} diff --git a/src/app/admin/partner-shops/page.tsx b/src/app/admin/partner-shops/page.tsx new file mode 100644 index 0000000..d2305f7 --- /dev/null +++ b/src/app/admin/partner-shops/page.tsx @@ -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 ( +
+

제휴 샵 관리

+
+ setForm({ ...form, region: e.target.value })} /> + setForm({ ...form, name: e.target.value })} /> + setForm({ ...form, address: e.target.value })} style={{ width: 320 }} /> + setForm({ ...form, imageUrl: e.target.value })} style={{ width: 280 }} /> + + setForm({ ...form, sortOrder: Number(e.target.value) })} style={{ width: 80 }} /> + +
+
    + {items.map((it) => ( +
  • + {it.name} +
    +
    {it.name} {it.region}
    +
    {it.address}
    +
    정렬 {it.sortOrder} · {it.active ? "활성" : "비활성"}
    +
    + + +
  • + ))} +
+
+ ); +} + + diff --git a/src/app/admin/partners/page.tsx b/src/app/admin/partners/page.tsx new file mode 100644 index 0000000..972a5a3 --- /dev/null +++ b/src/app/admin/partners/page.tsx @@ -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(null); + const editFileInputRef = useRef(null); + const [editUploading, setEditUploading] = useState(false); + const [editingId, setEditingId] = useState(null); + const [editDraft, setEditDraft] = useState(null); + const [showCreateModal, setShowCreateModal] = useState(false); + const [showEditModal, setShowEditModal] = useState(false); + + async function onSelectFile(e: React.ChangeEvent) { + 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) { + 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 ( +
+
+

제휴업체 관리

+ +
+ setShowCreateModal(false)}> +
+
+

제휴업체 추가

+ {(() => { + const inputStyle: React.CSSProperties = { border: "1px solid #ddd", borderRadius: 6, padding: "8px 10px", width: "100%" }; + return ( +
+
+ + setForm({ ...form, name: e.target.value })} /> +
+
+ + setForm({ ...form, category: e.target.value })} /> +
+
+ + setForm({ ...form, latitude: e.target.value })} /> +
+
+ + setForm({ ...form, longitude: e.target.value })} /> +
+
+ +
+ setForm({ ...form, imageUrl: e.target.value })} /> + + + {uploading && 업로드 중...} +
+
+
+ + setForm({ ...form, address: e.target.value })} /> +
+ {form.imageUrl && ( +
+
미리보기
+ 미리보기 +
+ )} +
+ + +
+
+ ); + })()} +
+
+
+ { setShowEditModal(false); setEditingId(null); setEditDraft(null); }}> +
+
+

제휴업체 수정

+ {(() => { + const inputStyle: React.CSSProperties = { border: "1px solid #ddd", borderRadius: 6, padding: "8px 10px", width: "100%" }; + return ( +
+
+ + setEditDraft({ ...editDraft, name: e.target.value })} /> +
+
+ + setEditDraft({ ...editDraft, category: e.target.value })} /> +
+
+ + setEditDraft({ ...editDraft, latitude: e.target.value })} /> +
+
+ + setEditDraft({ ...editDraft, longitude: e.target.value })} /> +
+
+ +
+ setEditDraft({ ...editDraft, imageUrl: e.target.value })} /> + + + {editUploading && 업로드 중...} +
+
+
+ + setEditDraft({ ...editDraft, address: e.target.value })} /> +
+ {editDraft?.imageUrl && ( +
+
미리보기
+ 미리보기 +
+ )} +
+ + +
+
+ ); + })()} +
+
+
+
    + {partners.map((p) => ( +
  • +
    + {p.imageUrl ? ( + {p.name} + ) : ( +
    + )} +
    + {false ? ( + <> + ) : ( + <> +
    +
    {p.name} {p.category}
    +
    {p.address || "(주소 없음)"}
    +
    +
    위도 {p.latitude}
    +
    경도 {p.longitude}
    +
    + + + + +
    + + )} +
  • + ))} +
+
+ ); +} + + diff --git a/src/app/api/admin/banners/route.ts b/src/app/api/admin/banners/route.ts index d84f63c..ab2f092 100644 --- a/src/app/api/admin/banners/route.ts +++ b/src/app/api/admin/banners/route.ts @@ -9,8 +9,11 @@ export async function GET() { const createSchema = z.object({ title: z.string().min(1), - imageUrl: z.string().url(), - linkUrl: z.string().url().optional(), + // 절대 URL 또는 /로 시작하는 상대경로 허용 + 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(), sortOrder: z.coerce.number().int().optional(), startAt: z.coerce.date().optional(), diff --git a/src/app/api/admin/partner-shops/[id]/route.ts b/src/app/api/admin/partner-shops/[id]/route.ts new file mode 100644 index 0000000..2e8baf7 --- /dev/null +++ b/src/app/api/admin/partner-shops/[id]/route.ts @@ -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 }); +} + + diff --git a/src/app/api/admin/partner-shops/route.ts b/src/app/api/admin/partner-shops/route.ts new file mode 100644 index 0000000..703bc3b --- /dev/null +++ b/src/app/api/admin/partner-shops/route.ts @@ -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 }); +} + + diff --git a/src/app/api/admin/partners/[id]/route.ts b/src/app/api/admin/partners/[id]/route.ts new file mode 100644 index 0000000..3373cbf --- /dev/null +++ b/src/app/api/admin/partners/[id]/route.ts @@ -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 }); +} + + diff --git a/src/app/api/admin/partners/route.ts b/src/app/api/admin/partners/route.ts new file mode 100644 index 0000000..64d8148 --- /dev/null +++ b/src/app/api/admin/partners/route.ts @@ -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 }); +} + + diff --git a/src/app/api/partner-shops/route.ts b/src/app/api/partner-shops/route.ts new file mode 100644 index 0000000..27614d6 --- /dev/null +++ b/src/app/api/partner-shops/route.ts @@ -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 }); +} + + diff --git a/src/app/boards/page.tsx b/src/app/boards/page.tsx index b29b30a..063e9ac 100644 --- a/src/app/boards/page.tsx +++ b/src/app/boards/page.tsx @@ -1,3 +1,4 @@ +import Link from "next/link"; import { headers } from "next/headers"; export default async function BoardsPage() { @@ -14,7 +15,7 @@ export default async function BoardsPage() {

게시판

    {boards?.map((b: any) => ( -
  • {b.name}
  • +
  • {b.name}
  • ))}
diff --git a/src/app/components/AppSidebar.tsx b/src/app/components/AppSidebar.tsx index e51cb06..ae73a07 100644 --- a/src/app/components/AppSidebar.tsx +++ b/src/app/components/AppSidebar.tsx @@ -1,9 +1,10 @@ +import Link from "next/link"; export function AppSidebar() { return ( ); diff --git a/src/app/components/HeroBanner.tsx b/src/app/components/HeroBanner.tsx index 724f014..ed39332 100644 --- a/src/app/components/HeroBanner.tsx +++ b/src/app/components/HeroBanner.tsx @@ -2,12 +2,22 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import Image from "next/image"; 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 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 }) { - const [banners, setBanners] = useState([]); + // 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 [isHovered, setIsHovered] = useState(false); const [progress, setProgress] = useState(0); // 0..1 @@ -15,10 +25,6 @@ export function HeroBanner({ subItems, activeSubId }: { subItems?: SubItem[]; ac const rafIdRef = useRef(null); const startedAtRef = useRef(0); - useEffect(() => { - fetch("/api/banners").then((r) => r.json()).then((d) => setBanners(d.banners ?? [])); - }, []); - const numSlides = banners.length; 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]); - if (numSlides === 0) return ; + if (numSlides === 0) { + return ( +
+ + {/* 분리된 하단 블랙 바: 높이 58px, 중앙 서브카테고리 (스켈레톤 상태에서도 동일 레이아웃 유지) */} +
+ {Array.isArray(subItems) && subItems.length > 0 && ( +
+ {subItems.map((s) => ( + + {s.name} + + ))} +
+ )} +
+
+ ); + } return (
{Array.isArray(subItems) && subItems.length > 0 && ( -
- {subItems.map((s) => ( - + {subItems.map((s) => ( + {s.name} - + ))}
)} diff --git a/src/app/components/PersonalWidgets.tsx b/src/app/components/PersonalWidgets.tsx index 9e81330..e38c407 100644 --- a/src/app/components/PersonalWidgets.tsx +++ b/src/app/components/PersonalWidgets.tsx @@ -1,3 +1,4 @@ +import Link from "next/link"; "use client"; import useSWR from "swr"; @@ -16,7 +17,7 @@ export function PersonalWidgets() {
    {(recent?.items ?? []).map((i) => (
  • - {i.title} + {i.title}
  • ))} {(!recent || recent.items.length === 0) &&
  • 최근 본 글이 없습니다.
  • } diff --git a/src/app/components/PostList.tsx b/src/app/components/PostList.tsx index 56da3cb..b6f8fc8 100644 --- a/src/app/components/PostList.tsx +++ b/src/app/components/PostList.tsx @@ -2,6 +2,7 @@ import useSWRInfinite from "swr/infinite"; import useSWR from "swr"; import { useEffect, useMemo, useState } from "react"; +import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; import ViewsIcon from "@/app/svgs/ViewsIcon"; import LikeIcon from "@/app/svgs/LikeIcon"; @@ -129,14 +130,14 @@ export function PostList({ boardId, sort = "recent", q, tag, author, start, end,
    {p.isPinned ? "★" : "•"}
    - + {p.isPinned && 공지} {p.title} - + {!!p.postTags?.length && (
    {p.postTags?.map((pt) => ( - #{pt.tag.name} + #{pt.tag.name} ))}
    )} @@ -227,9 +228,9 @@ export function PostList({ boardId, sort = "recent", q, tag, author, start, end,
    {newPostHref && ( - + - + )} ) : ( diff --git a/src/app/components/QuickActions.tsx b/src/app/components/QuickActions.tsx index 5e62e50..787fd52 100644 --- a/src/app/components/QuickActions.tsx +++ b/src/app/components/QuickActions.tsx @@ -1,3 +1,4 @@ +import Link from "next/link"; "use client"; import { usePermission } from "@/lib/usePermission"; @@ -7,8 +8,8 @@ export function QuickActions() { const isAdmin = can("ADMIN", "ADMINISTER") || can("BOARD", "MODERATE"); return (
    - {canWrite && } - {isAdmin && } + {canWrite && } + {isAdmin && }
    ); } diff --git a/src/app/components/SelectedBanner.tsx b/src/app/components/SelectedBanner.tsx index 316ca1d..398216c 100644 --- a/src/app/components/SelectedBanner.tsx +++ b/src/app/components/SelectedBanner.tsx @@ -2,24 +2,19 @@ import React from "react"; type Props = { - height?: number | string; // ex) 224 or '14rem' + height?: number | string; // ex) 224 or '14rem' (지정 시 고정 높이, 미지정 시 클래스 높이 사용) className?: string; }; // Figma 선택 요소(Container_Event)를 그대로 옮긴 플레이스홀더형 배너 -export function SelectedBanner({ height = 122, className }: Props) { +export function SelectedBanner({ height, className }: Props) { return (
    -
    - - {Array.from({ length: 12 }).map((_, i) => ( - - ))} -
    + {/* 스켈레톤 상태에서는 하단 점(페이지네이션) 제거 */}
    ); } diff --git a/src/app/components/TagFilter.tsx b/src/app/components/TagFilter.tsx index eb30ee7..3cbf3d1 100644 --- a/src/app/components/TagFilter.tsx +++ b/src/app/components/TagFilter.tsx @@ -1,3 +1,4 @@ +import Link from "next/link"; "use client"; import useSWR from "swr"; @@ -8,11 +9,11 @@ export function TagFilter({ basePath, current }: { basePath: string; current?: s const tags = data?.tags ?? []; return (
    - 전체 + 전체 {tags.map((t) => ( - + #{t.name} - + ))}
    ); diff --git a/src/app/components/ui/Modal.tsx b/src/app/components/ui/Modal.tsx index d501a91..24fb347 100644 --- a/src/app/components/ui/Modal.tsx +++ b/src/app/components/ui/Modal.tsx @@ -11,10 +11,11 @@ export function Modal({ open, onClose, children }: Props) { style={{ position: "fixed", inset: 0, - background: "rgba(0,0,0,0.4)", + background: "transparent", display: "flex", alignItems: "center", justifyContent: "center", + zIndex: 50, }} >
    e.stopPropagation()} style={{ background: "#fff", padding: 16, borderRadius: 8 }}> diff --git a/src/app/globals.css b/src/app/globals.css index 64cd572..8e99d55 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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; } +/* 레이아웃 점프 방지: 스크롤바 여백을 항상 확보 */ +html { scrollbar-gutter: stable both-edges; } + /* 유틸: 카드 스켈레톤 색상 헬퍼 (타깃 사이트 톤 유사) */ .bg-neutral-100 { background-color: #f5f5f7; } diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 7d81f7e..bb4794f 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -1,3 +1,4 @@ +import Link from "next/link"; "use client"; import React from "react"; import { Button } from "@/app/components/ui/Button"; @@ -46,7 +47,7 @@ export default function LoginPage() { />
    - 회원가입 + 회원가입
    diff --git a/src/app/page.tsx b/src/app/page.tsx index 6b765bb..a7b54e2 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,4 +1,5 @@ import { HeroBanner } from "@/app/components/HeroBanner"; +import Link from "next/link"; import HorizontalCardScroller from "@/app/components/HorizontalCardScroller"; import { PostList } from "@/app/components/PostList"; 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 }) => (
    - {board.name} - 더보기 + {board.name} + 더보기
    @@ -49,21 +50,29 @@ export default async function Home({ searchParams }: { searchParams: Promise<{ s
)} - {/* 제휴 샾 가로 스크롤 (설정 온오프) */} - {showPartnerShops && (() => { - const items = [ - { id: 1, region: "경기도", name: "라온마사지샾", address: "수원시 팔달구 매산로 45", image: "/sample.jpg" }, - { id: 2, region: "강원도", name: "휴앤힐링마사지샾", address: "춘천시 중앙로 112", image: "/sample.jpg" }, - { id: 3, region: "충청북도", name: "소담마사지샾", address: "청주시 상당구 상당로 88", image: "/sample.jpg" }, - { id: 4, region: "충청남도", name: "아늑마사지샾", address: "천안시 동남구 시민로 21", image: "/sample.jpg" }, - { id: 5, region: "전라북도", name: "편안한마사지샾", address: "전주시 완산구 풍남문로 77", image: "/sample.jpg" }, - { id: 6, region: "전라남도", name: "바른마사지샾", address: "여수시 중앙로 9", image: "/sample.jpg" }, - { id: 7, region: "경상북도", name: "늘봄마사지샾", address: "대구시 중구 동성로3길 12", image: "/sample.jpg" }, - { id: 8, region: "경상남도", name: "편히쉬다마사지샾", address: "창원시 성산구 중앙대로 150", image: "/sample.jpg" }, - { id: 9, region: "제주특별자치도", name: "제주소풍마사지샾", address: "제주시 중앙로 230", image: "/sample.jpg" }, - { id: 10, region: "서울특별시", name: "도심휴식마사지샾", address: "강남구 테헤란로 427", image: "/sample.jpg" }, - ]; - return ; + {/* 제휴 샾 가로 스크롤 (설정 온오프, DB에서 불러오기) + - 우선 partners 테이블(관리자 페이지 관리 대상) 사용 + - 없으면 partner_shops로 대체 */} + {showPartnerShops && (async () => { + // 우선순위: partners(관리자 관리) → partner_shops(폴백) + let partners: any[] = []; + try { + partners = await (prisma as any).partner.findMany({ orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }], take: 10 }); + } catch (_) { + partners = await prisma.partner.findMany({ orderBy: { createdAt: "desc" }, take: 10 }); + } + const items = 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", + })); + if (items.length > 0) return ; + + 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 ; })()} {/* 1행: 프로필 + 선택된 보드 2개 (최대 2개) */} diff --git a/src/app/ranking/page.tsx b/src/app/ranking/page.tsx index cb3be54..9477401 100644 --- a/src/app/ranking/page.tsx +++ b/src/app/ranking/page.tsx @@ -1,3 +1,4 @@ +import Link from "next/link"; "use client"; import useSWR from "swr"; @@ -10,10 +11,10 @@ export default function RankingPage({ searchParams }: { searchParams?: { period?

회원랭킹

- 일간 - 주간 - 월간 - 전체 + 일간 + 주간 + 월간 + 전체
    {(data?.items ?? []).map((i) => (