diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f6c0bce..6ee36ec 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -674,6 +674,7 @@ model Partner { id String @id @default(cuid()) name String @unique category String + categoryId String? latitude Float longitude Float address String? @@ -682,11 +683,27 @@ model Partner { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + categoryRef PartnerCategory? @relation(fields: [categoryId], references: [id]) + @@index([category]) + @@index([categoryId]) @@index([sortOrder]) @@map("partners") } +// 제휴업체 카테고리(관리자 생성/삭제) +model PartnerCategory { + id String @id @default(cuid()) + name String @unique + sortOrder Int @default(0) + createdAt DateTime @default(now()) + + partners Partner[] + + @@index([sortOrder]) + @@map("partner_categories") +} + // 배너/공지 노출용 model Banner { id String @id @default(cuid()) diff --git a/prisma/seed.js b/prisma/seed.js index 1a38481..c7dce42 100644 --- a/prisma/seed.js +++ b/prisma/seed.js @@ -509,6 +509,37 @@ async function seedMainpageVisibleBoards(boards) { } async function main() { + console.log("DATABASE_URL:", process.env.DATABASE_URL); + try { + const tables = await prisma.$queryRaw`SELECT name FROM sqlite_master WHERE type='table'`; + console.log("SQLite tables:", tables.map((t) => t.name || t.NAME || JSON.stringify(t))); + } catch {} + + // SQLite 수동 보정: partner_categories 테이블과 partners.categoryId 컬럼 보장 + try { + const rows = await prisma.$queryRaw`SELECT name FROM sqlite_master WHERE type='table' AND name='partner_categories'`; + if (!Array.isArray(rows) || rows.length === 0) { + console.log("Creating missing table: partner_categories"); + await prisma.$executeRawUnsafe( + "CREATE TABLE IF NOT EXISTS partner_categories (\n" + + "id TEXT PRIMARY KEY,\n" + + "name TEXT NOT NULL UNIQUE,\n" + + "sortOrder INTEGER NOT NULL DEFAULT 0,\n" + + "createdAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP\n" + + ")" + ); + await prisma.$executeRawUnsafe("CREATE INDEX IF NOT EXISTS idx_partner_categories_sortOrder ON partner_categories(sortOrder)"); + } + const cols = await prisma.$queryRaw`PRAGMA table_info('partners')`; + const hasCategoryId = Array.isArray(cols) && cols.some((c) => (c.name || c.COLUMN_NAME) === 'categoryId'); + if (!hasCategoryId) { + console.log("Adding missing column: partners.categoryId"); + await prisma.$executeRawUnsafe("ALTER TABLE partners ADD COLUMN categoryId TEXT"); + // 외래키 제약은 생략 (SQLite에서는 제약 추가가 까다로움) + } + } catch (e) { + console.warn("SQLite schema ensure failed:", e); + } await upsertRoles(); const admin = await upsertAdmin(); const categoryMap = await upsertCategories(); @@ -528,8 +559,22 @@ async function main() { { name: "test", category: "spa", latitude: 37.5700, longitude: 126.9769, address: "서울 종로구" }, { name: "굿짐", category: "gym", latitude: 37.5600, longitude: 126.9820, address: "서울 중구" }, ]; + // 파트너 카테고리(PartnerCategory) 생성 및 매핑 + const partnerCategoryNames = Array.from(new Set(partners.map((p) => p.category).filter(Boolean))); + const partnerCategoryMap = {}; + for (let i = 0; i < partnerCategoryNames.length; i++) { + const name = partnerCategoryNames[i]; + const created = await prisma.partnerCategory.upsert({ + where: { name }, + update: { sortOrder: i + 1 }, + create: { name, sortOrder: i + 1 }, + }); + partnerCategoryMap[name] = created; + } for (const p of partners) { - await prisma.partner.upsert({ where: { name: p.name }, update: p, create: p }); + const categoryRef = p.category ? partnerCategoryMap[p.category] : null; + const data = { ...p, categoryId: categoryRef ? categoryRef.id : null }; + await prisma.partner.upsert({ where: { name: p.name }, update: data, create: data }); } } diff --git a/public/uploads/1762439529544-pfdpsiv372l.jpg b/public/uploads/1762439529544-pfdpsiv372l.jpg new file mode 100644 index 0000000..7d68f4e Binary files /dev/null and b/public/uploads/1762439529544-pfdpsiv372l.jpg differ diff --git a/public/uploads/1762439795788-41zbv74p6l9.png b/public/uploads/1762439795788-41zbv74p6l9.png new file mode 100644 index 0000000..7db214e Binary files /dev/null and b/public/uploads/1762439795788-41zbv74p6l9.png differ diff --git a/public/uploads/1762444179265-fuj8zoahblc.jpg b/public/uploads/1762444179265-fuj8zoahblc.jpg new file mode 100644 index 0000000..d718a07 Binary files /dev/null and b/public/uploads/1762444179265-fuj8zoahblc.jpg differ diff --git a/src/app/admin/partners/page.tsx b/src/app/admin/partners/page.tsx index 972a5a3..063f707 100644 --- a/src/app/admin/partners/page.tsx +++ b/src/app/admin/partners/page.tsx @@ -7,8 +7,10 @@ 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 { data: catData, mutate: mutateCategories } = useSWR<{ categories: any[] }>("/api/admin/partner-categories", fetcher); const partners = data?.partners ?? []; - const [form, setForm] = useState({ name: "", category: "", latitude: "", longitude: "", address: "", imageUrl: "" }); + const categories = catData?.categories ?? []; + const [form, setForm] = useState({ name: "", latitude: "", longitude: "", address: "", imageUrl: "", categoryId: "" }); const [uploading, setUploading] = useState(false); const fileInputRef = useRef(null); const editFileInputRef = useRef(null); @@ -62,8 +64,8 @@ export default function AdminPartnersPage() { async function create() { // 필수값 검증: 이름/카테고리/위도/경도 - if (!form.name || !form.category || !String(form.latitude).trim() || !String(form.longitude).trim()) { - alert("이름, 카테고리, 위도, 경도를 모두 입력해 주세요."); + if (!form.name || !form.categoryId || !String(form.latitude).trim() || !String(form.longitude).trim()) { + alert("이름, 카테고리, 위도, 경도를 모두 선택/입력해 주세요."); return; } const lat = Number(form.latitude); @@ -72,12 +74,21 @@ export default function AdminPartnersPage() { alert("위도/경도는 숫자여야 합니다."); return; } - const payload = { ...form, latitude: lat, longitude: lon } as any; + const payload = { ...form, latitude: lat, longitude: lon, categoryId: form.categoryId } 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: "" }); + setForm({ name: "", latitude: "", longitude: "", address: "", imageUrl: "", categoryId: "" }); mutate(); setShowCreateModal(false); + } else { + let msg = "저장에 실패했습니다."; + try { + const j = await r.json(); + msg = j?.message || j?.error || msg; + if (r.status === 409 && j?.error === "duplicate_name") msg = "이미 존재하는 업체명입니다."; + if (r.status === 400) msg = msg || "입력값을 확인해 주세요."; + } catch {} + alert(msg); } } @@ -107,7 +118,12 @@ export default function AdminPartnersPage() {
- setForm({ ...form, category: e.target.value })} /> +
@@ -178,7 +194,12 @@ export default function AdminPartnersPage() {
- setEditDraft({ ...editDraft, category: e.target.value })} /> +
@@ -216,7 +237,23 @@ export default function AdminPartnersPage() { )}
); } +function CategoryManager({ categories, onChanged }: { categories: any[]; onChanged: () => void }) { + const [name, setName] = useState(""); + return ( +
+
+ setName(e.target.value)} + className="h-9 px-3 rounded-md border border-neutral-300 flex-1" + /> + +
+
    + {categories.map((c: any) => ( +
  • + {c.name} + +
  • + ))} +
+
+ ); +} + diff --git a/src/app/api/admin/partner-categories/[id]/route.ts b/src/app/api/admin/partner-categories/[id]/route.ts new file mode 100644 index 0000000..11c2ef2 --- /dev/null +++ b/src/app/api/admin/partner-categories/[id]/route.ts @@ -0,0 +1,28 @@ +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", "sortOrder"]) if (k in body) data[k] = body[k]; + try { + const category = await prisma.partnerCategory.update({ where: { id }, data }); + return NextResponse.json({ category }); + } catch (e: any) { + if (e?.code === 'P2002') return NextResponse.json({ error: 'duplicate_name', message: '이미 존재하는 카테고리입니다.' }, { status: 409 }); + return NextResponse.json({ error: 'unknown_error' }, { status: 500 }); + } +} + +export async function DELETE(_: Request, context: { params: Promise<{ id: string }> }) { + const { id } = await context.params; + try { + await prisma.partnerCategory.delete({ where: { id } }); + return NextResponse.json({ ok: true }); + } catch (e: any) { + return NextResponse.json({ error: 'category_in_use', message: '해당 카테고리를 사용하는 제휴업체가 있습니다.' }, { status: 409 }); + } +} + + diff --git a/src/app/api/admin/partner-categories/route.ts b/src/app/api/admin/partner-categories/route.ts new file mode 100644 index 0000000..f8b9948 --- /dev/null +++ b/src/app/api/admin/partner-categories/route.ts @@ -0,0 +1,25 @@ +import { NextResponse } from "next/server"; +import prisma from "@/lib/prisma"; +import { z } from "zod"; + +export async function GET() { + const categories = await prisma.partnerCategory.findMany({ orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }] }); + return NextResponse.json({ categories }); +} + +const createSchema = z.object({ name: z.string().min(1), 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 }); + try { + const category = await prisma.partnerCategory.create({ data: { name: parsed.data.name, sortOrder: parsed.data.sortOrder ?? 0 } }); + return NextResponse.json({ category }, { status: 201 }); + } catch (e: any) { + if (e?.code === 'P2002') return NextResponse.json({ error: 'duplicate_name', message: '이미 존재하는 카테고리입니다.' }, { status: 409 }); + return NextResponse.json({ error: 'unknown_error' }, { status: 500 }); + } +} + + diff --git a/src/app/api/admin/partner-types/[id]/route.ts b/src/app/api/admin/partner-types/[id]/route.ts new file mode 100644 index 0000000..dae34b7 --- /dev/null +++ b/src/app/api/admin/partner-types/[id]/route.ts @@ -0,0 +1,15 @@ +import { NextResponse } from "next/server"; +import prisma from "@/lib/prisma"; + +export async function DELETE(_: Request, context: { params: Promise<{ id: string }> }) { + const { id } = await context.params; + // 파트너가 참조 중이면 삭제 실패(외래키 제약), 클라이언트에서 안내 필요 + try { + await prisma.partnerType.delete({ where: { typeId: id } }); + return NextResponse.json({ ok: true }); + } catch (e) { + return NextResponse.json({ error: "type_in_use" }, { status: 409 }); + } +} + + diff --git a/src/app/api/admin/partner-types/route.ts b/src/app/api/admin/partner-types/route.ts new file mode 100644 index 0000000..b4ae7e2 --- /dev/null +++ b/src/app/api/admin/partner-types/route.ts @@ -0,0 +1,20 @@ +import { NextResponse } from "next/server"; +import prisma from "@/lib/prisma"; +import { z } from "zod"; + +export async function GET() { + const types = await prisma.partnerType.findMany({ orderBy: { createdAt: "desc" } }); + return NextResponse.json({ types }); +} + +const createSchema = z.object({ name: z.string().min(1).max(50) }); + +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 type = await prisma.partnerType.create({ data: { name: parsed.data.name } }); + return NextResponse.json({ type }, { status: 201 }); +} + + diff --git a/src/app/api/admin/partners/[id]/route.ts b/src/app/api/admin/partners/[id]/route.ts index 3373cbf..ecd1225 100644 --- a/src/app/api/admin/partners/[id]/route.ts +++ b/src/app/api/admin/partners/[id]/route.ts @@ -5,12 +5,19 @@ export async function PATCH(req: Request, context: { params: Promise<{ id: strin 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"]) { + for (const k of ["name", "category", "latitude", "longitude", "address", "imageUrl", "sortOrder", "categoryId"]) { 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); + // categoryId가 들어왔고 category 문자열이 비어있으면 카테고리명으로 채움 + if (typeof data.categoryId !== "undefined" && (typeof data.category === "undefined" || data.category === null)) { + if (data.categoryId) { + const cat = await prisma.partnerCategory.findUnique({ where: { id: String(data.categoryId) } }); + if (cat) data.category = cat.name; + } + } try { const partner = await prisma.partner.update({ where: { id }, data }); return NextResponse.json({ partner }); diff --git a/src/app/api/admin/partners/route.ts b/src/app/api/admin/partners/route.ts index 64d8148..155d31a 100644 --- a/src/app/api/admin/partners/route.ts +++ b/src/app/api/admin/partners/route.ts @@ -4,19 +4,19 @@ import { z } from "zod"; export async function GET() { try { - // 정렬용 컬럼(sortOrder)이 있는 경우 우선 사용 - const partners = await (prisma as any).partner.findMany({ orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }] }); + // 카테고리 조인 포함 + const partners = await (prisma as any).partner.findMany({ orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }], include: { categoryRef: true } }); return NextResponse.json({ partners }); } catch (_) { // 컬럼이 아직 DB에 없으면 createdAt 기준으로 폴백 - const partners = await prisma.partner.findMany({ orderBy: { createdAt: "desc" } }); + const partners = await prisma.partner.findMany({ orderBy: { createdAt: "desc" }, include: { categoryRef: true } }); return NextResponse.json({ partners }); } } const createSchema = z.object({ name: z.string().min(1), - category: z.string().min(1), + category: z.string().min(1).optional(), latitude: z.coerce.number(), longitude: z.coerce.number(), address: z.string().min(1).optional(), @@ -27,14 +27,28 @@ const createSchema = z.object({ }) .optional(), sortOrder: z.coerce.number().int().optional(), + categoryId: z.string().min(1), }); 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 }); + try { + const { categoryId } = parsed.data as any; + const cat = await prisma.partnerCategory.findUnique({ where: { id: categoryId } }); + if (!cat) return NextResponse.json({ error: 'invalid_category', message: '유효하지 않은 카테고리입니다.' }, { status: 400 }); + const data: any = { ...parsed.data }; + if (!data.category) data.category = cat.name; + const partner = await prisma.partner.create({ data }); + return NextResponse.json({ partner }, { status: 201 }); + } catch (e: any) { + // Unique name 에러 처리 + if (e?.code === 'P2002') { + return NextResponse.json({ error: 'duplicate_name', message: '이미 존재하는 업체명입니다.' }, { status: 409 }); + } + return NextResponse.json({ error: 'unknown_error' }, { status: 500 }); + } } diff --git a/src/app/api/partner-categories/route.ts b/src/app/api/partner-categories/route.ts new file mode 100644 index 0000000..5ab46ae --- /dev/null +++ b/src/app/api/partner-categories/route.ts @@ -0,0 +1,9 @@ +import { NextResponse } from "next/server"; +import prisma from "@/lib/prisma"; + +export async function GET() { + const categories = await prisma.partnerCategory.findMany({ orderBy: [{ sortOrder: "asc" }, { name: "asc" }] }); + return NextResponse.json({ categories }); +} + + diff --git a/src/app/api/partners/route.ts b/src/app/api/partners/route.ts index 7bded57..279d7ff 100644 --- a/src/app/api/partners/route.ts +++ b/src/app/api/partners/route.ts @@ -16,8 +16,11 @@ export async function GET(req: Request) { const lat = Number(searchParams.get("lat")); const lon = Number(searchParams.get("lon")); const category = searchParams.get("category") || undefined; + const categoryId = searchParams.get("categoryId") || undefined; const radius = Number(searchParams.get("radius")) || 10; // km - const where = category ? { category } : {}; + const where: any = categoryId + ? { categoryId } + : (category ? { category } : {}); const partners = await prisma.partner.findMany({ where, orderBy: { createdAt: "desc" } }); const withDistance = isFinite(lat) && isFinite(lon) ? partners.map((p) => ({ ...p, distance: haversine(lat, lon, p.latitude, p.longitude) })).filter((p) => p.distance <= radius) diff --git a/src/app/components/PartnerCategorySection.tsx b/src/app/components/PartnerCategorySection.tsx new file mode 100644 index 0000000..d7af5ec --- /dev/null +++ b/src/app/components/PartnerCategorySection.tsx @@ -0,0 +1,61 @@ +"use client"; +import useSWR from "swr"; +import { useMemo, useState } from "react"; + +const fetcher = (url: string) => fetch(url).then((r) => r.json()); + +export default function PartnerCategorySection() { + const { data: catData } = useSWR<{ categories: any[] }>("/api/partner-categories", fetcher); + const categories = catData?.categories ?? []; + const defaultCatId = categories[0]?.id || ""; + const [selectedId, setSelectedId] = useState(defaultCatId); + + const query = useMemo(() => { + const id = selectedId || defaultCatId; + return id ? `/api/partners?categoryId=${encodeURIComponent(id)}` : "/api/partners"; + }, [selectedId, defaultCatId]); + + const { data: partnersData, isLoading } = useSWR<{ partners: any[] }>(query, fetcher); + const partners = partnersData?.partners ?? []; + + return ( +
+ {/* 카테고리 탭 */} +
+ {categories.map((c: any) => ( + + ))} +
+ + {/* 파트너 리스트 */} +
+ {isLoading && partners.length === 0 && ( +
불러오는 중...
+ )} + {partners.map((p: any) => ( +
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {p.name} +
+
+
{p.name}
+
{p.address || ""}
+
+
+ ))} + {!isLoading && partners.length === 0 && ( +
표시할 제휴업체가 없습니다.
+ )} +
+
+ ); +} + + diff --git a/src/app/page.tsx b/src/app/page.tsx index 86be0a3..9caa858 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,4 +1,5 @@ import { HeroBanner } from "@/app/components/HeroBanner"; +import PartnerCategorySection from "@/app/components/PartnerCategorySection"; import Link from "next/link"; import HorizontalCardScroller from "@/app/components/HorizontalCardScroller"; import { PostList } from "@/app/components/PostList"; @@ -204,6 +205,9 @@ export default async function Home({ searchParams }: { searchParams: Promise<{ s )} + {/* 배너 아래: 파트너 카테고리 탭 + 파트너 리스트 */} + + {/* 제휴 샾 가로 스크롤 (설정 온오프, DB에서 불러오기) - 우선 partners 테이블(관리자 페이지 관리 대상) 사용 - 없으면 partner_shops로 대체 */}