@@ -674,6 +674,7 @@ model Partner {
|
|||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String @unique
|
name String @unique
|
||||||
category String
|
category String
|
||||||
|
categoryId String?
|
||||||
latitude Float
|
latitude Float
|
||||||
longitude Float
|
longitude Float
|
||||||
address String?
|
address String?
|
||||||
@@ -682,11 +683,27 @@ model Partner {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
categoryRef PartnerCategory? @relation(fields: [categoryId], references: [id])
|
||||||
|
|
||||||
@@index([category])
|
@@index([category])
|
||||||
|
@@index([categoryId])
|
||||||
@@index([sortOrder])
|
@@index([sortOrder])
|
||||||
@@map("partners")
|
@@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 {
|
model Banner {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
|
|||||||
@@ -509,6 +509,37 @@ async function seedMainpageVisibleBoards(boards) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
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();
|
await upsertRoles();
|
||||||
const admin = await upsertAdmin();
|
const admin = await upsertAdmin();
|
||||||
const categoryMap = await upsertCategories();
|
const categoryMap = await upsertCategories();
|
||||||
@@ -528,8 +559,22 @@ async function main() {
|
|||||||
{ name: "test", 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: "서울 중구" },
|
{ 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) {
|
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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
BIN
public/uploads/1762439529544-pfdpsiv372l.jpg
Normal file
BIN
public/uploads/1762439529544-pfdpsiv372l.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
BIN
public/uploads/1762439795788-41zbv74p6l9.png
Normal file
BIN
public/uploads/1762439795788-41zbv74p6l9.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
BIN
public/uploads/1762444179265-fuj8zoahblc.jpg
Normal file
BIN
public/uploads/1762444179265-fuj8zoahblc.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.7 MiB |
@@ -7,8 +7,10 @@ const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
|||||||
|
|
||||||
export default function AdminPartnersPage() {
|
export default function AdminPartnersPage() {
|
||||||
const { data, mutate } = useSWR<{ partners: any[] }>("/api/admin/partners", fetcher);
|
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 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 [uploading, setUploading] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const editFileInputRef = useRef<HTMLInputElement>(null);
|
const editFileInputRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -62,8 +64,8 @@ export default function AdminPartnersPage() {
|
|||||||
|
|
||||||
async function create() {
|
async function create() {
|
||||||
// 필수값 검증: 이름/카테고리/위도/경도
|
// 필수값 검증: 이름/카테고리/위도/경도
|
||||||
if (!form.name || !form.category || !String(form.latitude).trim() || !String(form.longitude).trim()) {
|
if (!form.name || !form.categoryId || !String(form.latitude).trim() || !String(form.longitude).trim()) {
|
||||||
alert("이름, 카테고리, 위도, 경도를 모두 입력해 주세요.");
|
alert("이름, 카테고리, 위도, 경도를 모두 선택/입력해 주세요.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const lat = Number(form.latitude);
|
const lat = Number(form.latitude);
|
||||||
@@ -72,12 +74,21 @@ export default function AdminPartnersPage() {
|
|||||||
alert("위도/경도는 숫자여야 합니다.");
|
alert("위도/경도는 숫자여야 합니다.");
|
||||||
return;
|
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) });
|
const r = await fetch("/api/admin/partners", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(payload) });
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
setForm({ name: "", category: "", latitude: "", longitude: "", address: "", imageUrl: "" });
|
setForm({ name: "", latitude: "", longitude: "", address: "", imageUrl: "", categoryId: "" });
|
||||||
mutate();
|
mutate();
|
||||||
setShowCreateModal(false);
|
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() {
|
|||||||
</div>
|
</div>
|
||||||
<div style={{ gridColumn: "span 3" }}>
|
<div style={{ gridColumn: "span 3" }}>
|
||||||
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}>카테고리</label>
|
<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 })} />
|
<select style={inputStyle} value={form.categoryId} onChange={(e) => setForm({ ...form, categoryId: e.target.value })}>
|
||||||
|
<option value="">(없음)</option>
|
||||||
|
{categories.map((c: any) => (
|
||||||
|
<option key={c.id} value={c.id}>{c.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ gridColumn: "span 3" }}>
|
<div style={{ gridColumn: "span 3" }}>
|
||||||
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}>위도</label>
|
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}>위도</label>
|
||||||
@@ -178,7 +194,12 @@ export default function AdminPartnersPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div style={{ gridColumn: "span 3" }}>
|
<div style={{ gridColumn: "span 3" }}>
|
||||||
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}>카테고리</label>
|
<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 })} />
|
<select style={inputStyle} value={editDraft?.categoryId ?? ""} onChange={(e) => setEditDraft({ ...editDraft, categoryId: e.target.value })}>
|
||||||
|
<option value="">(없음)</option>
|
||||||
|
{categories.map((c: any) => (
|
||||||
|
<option key={c.id} value={c.id}>{c.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ gridColumn: "span 3" }}>
|
<div style={{ gridColumn: "span 3" }}>
|
||||||
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}>위도</label>
|
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}>위도</label>
|
||||||
@@ -216,7 +237,23 @@ export default function AdminPartnersPage() {
|
|||||||
)}
|
)}
|
||||||
<div style={{ gridColumn: "span 6", display: "flex", justifyContent: "flex-end", gap: 8 }}>
|
<div style={{ gridColumn: "span 6", display: "flex", justifyContent: "flex-end", gap: 8 }}>
|
||||||
<button
|
<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(); }}
|
onClick={async () => {
|
||||||
|
if (!editingId) return;
|
||||||
|
const resp = await fetch(`/api/admin/partners/${editingId}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ ...editDraft, categoryId: (editDraft?.categoryId || null) }) });
|
||||||
|
if (!resp.ok) {
|
||||||
|
let msg = "저장에 실패했습니다.";
|
||||||
|
try {
|
||||||
|
const j = await resp.json();
|
||||||
|
msg = j?.message || j?.error || msg;
|
||||||
|
} catch {}
|
||||||
|
alert(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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"
|
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"
|
||||||
>
|
>
|
||||||
저장
|
저장
|
||||||
@@ -250,14 +287,14 @@ export default function AdminPartnersPage() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
<div><strong>{p.name}</strong> <span style={{ marginLeft: 6, fontSize: 12, opacity: .7 }}>{p.category}</span></div>
|
<div><strong>{p.name}</strong> {p.categoryRef ? <span style={{ marginLeft: 6, fontSize: 12, opacity: .7 }}>[{p.categoryRef.name}]</span> : null}</div>
|
||||||
<div style={{ fontSize: 12, opacity: .7 }}>{p.address || "(주소 없음)"}</div>
|
<div style={{ fontSize: 12, opacity: .7 }}>{p.address || "(주소 없음)"}</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 12, opacity: .7 }}>위도 {p.latitude}</div>
|
<div style={{ fontSize: 12, opacity: .7 }}>위도 {p.latitude}</div>
|
||||||
<div style={{ fontSize: 12, opacity: .7 }}>경도 {p.longitude}</div>
|
<div style={{ fontSize: 12, opacity: .7 }}>경도 {p.longitude}</div>
|
||||||
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
|
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
|
||||||
<button
|
<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); }}
|
onClick={() => { setEditingId(p.id); setEditDraft({ name: p.name, categoryId: p.categoryId ?? "", 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"
|
className="h-8 px-3 rounded-md border border-neutral-300 text-sm cursor-pointer hover:bg-neutral-100 active:translate-y-px transition"
|
||||||
>
|
>
|
||||||
수정
|
수정
|
||||||
@@ -288,8 +325,49 @@ export default function AdminPartnersPage() {
|
|||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
<div>
|
||||||
|
<hr style={{ margin: "24px 0" }} />
|
||||||
|
<h2 className="text-lg font-bold mb-2">카테고리 관리</h2>
|
||||||
|
<CategoryManager categories={categories} onChanged={mutateCategories} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function CategoryManager({ categories, onChanged }: { categories: any[]; onChanged: () => void }) {
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
return (
|
||||||
|
<div className="border border-neutral-200 rounded-lg p-3">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<input
|
||||||
|
placeholder="카테고리 이름"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
className="h-9 px-3 rounded-md border border-neutral-300 flex-1"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={async () => { if (!name.trim()) return; const r = await fetch("/api/admin/partner-categories", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: name.trim() }) }); if (r.ok) { setName(""); onChanged(); } else { try { const j = await r.json(); alert(j?.message || j?.error || "생성 실패"); } catch { alert("생성 실패"); } } }}
|
||||||
|
className="h-9 px-4 rounded-md bg-neutral-900 text-white text-sm cursor-pointer hover:bg-neutral-800 active:translate-y-px transition"
|
||||||
|
>
|
||||||
|
추가
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<ul className="flex flex-wrap gap-2">
|
||||||
|
{categories.map((c: any) => (
|
||||||
|
<li key={c.id} className="flex items-center gap-2 px-2 py-1 rounded-full border border-neutral-300 text-sm">
|
||||||
|
<span>{c.name}</span>
|
||||||
|
<button
|
||||||
|
title="삭제"
|
||||||
|
onClick={async () => { const r = await fetch(`/api/admin/partner-categories/${c.id}`, { method: "DELETE" }); if (r.ok) onChanged(); else { try { const j = await r.json(); alert(j?.message || j?.error || "삭제 실패"); } catch { alert("삭제 실패"); } } }}
|
||||||
|
className="px-2 h-6 rounded-md border border-red-200 text-red-600 hover:bg-red-100 hover:border-red-300 hover:text-red-700"
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
28
src/app/api/admin/partner-categories/[id]/route.ts
Normal file
28
src/app/api/admin/partner-categories/[id]/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
25
src/app/api/admin/partner-categories/route.ts
Normal file
25
src/app/api/admin/partner-categories/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
15
src/app/api/admin/partner-types/[id]/route.ts
Normal file
15
src/app/api/admin/partner-types/[id]/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
20
src/app/api/admin/partner-types/route.ts
Normal file
20
src/app/api/admin/partner-types/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -5,12 +5,19 @@ export async function PATCH(req: Request, context: { params: Promise<{ id: strin
|
|||||||
const { id } = await context.params;
|
const { id } = await context.params;
|
||||||
const body = await req.json().catch(() => ({}));
|
const body = await req.json().catch(() => ({}));
|
||||||
const data: any = {};
|
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 (k in body) data[k] = body[k];
|
||||||
}
|
}
|
||||||
if (typeof data.latitude !== "undefined") data.latitude = Number(data.latitude);
|
if (typeof data.latitude !== "undefined") data.latitude = Number(data.latitude);
|
||||||
if (typeof data.longitude !== "undefined") data.longitude = Number(data.longitude);
|
if (typeof data.longitude !== "undefined") data.longitude = Number(data.longitude);
|
||||||
if (typeof data.sortOrder !== "undefined") data.sortOrder = Number(data.sortOrder);
|
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 {
|
try {
|
||||||
const partner = await prisma.partner.update({ where: { id }, data });
|
const partner = await prisma.partner.update({ where: { id }, data });
|
||||||
return NextResponse.json({ partner });
|
return NextResponse.json({ partner });
|
||||||
|
|||||||
@@ -4,19 +4,19 @@ import { z } from "zod";
|
|||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
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 });
|
return NextResponse.json({ partners });
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// 컬럼이 아직 DB에 없으면 createdAt 기준으로 폴백
|
// 컬럼이 아직 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 });
|
return NextResponse.json({ partners });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const createSchema = z.object({
|
const createSchema = z.object({
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
category: z.string().min(1),
|
category: z.string().min(1).optional(),
|
||||||
latitude: z.coerce.number(),
|
latitude: z.coerce.number(),
|
||||||
longitude: z.coerce.number(),
|
longitude: z.coerce.number(),
|
||||||
address: z.string().min(1).optional(),
|
address: z.string().min(1).optional(),
|
||||||
@@ -27,14 +27,28 @@ const createSchema = z.object({
|
|||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
sortOrder: z.coerce.number().int().optional(),
|
sortOrder: z.coerce.number().int().optional(),
|
||||||
|
categoryId: z.string().min(1),
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
const body = await req.json().catch(() => ({}));
|
const body = await req.json().catch(() => ({}));
|
||||||
const parsed = createSchema.safeParse(body);
|
const parsed = createSchema.safeParse(body);
|
||||||
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
||||||
const partner = await prisma.partner.create({ data: parsed.data as any });
|
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 });
|
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 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
9
src/app/api/partner-categories/route.ts
Normal file
9
src/app/api/partner-categories/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -16,8 +16,11 @@ export async function GET(req: Request) {
|
|||||||
const lat = Number(searchParams.get("lat"));
|
const lat = Number(searchParams.get("lat"));
|
||||||
const lon = Number(searchParams.get("lon"));
|
const lon = Number(searchParams.get("lon"));
|
||||||
const category = searchParams.get("category") || undefined;
|
const category = searchParams.get("category") || undefined;
|
||||||
|
const categoryId = searchParams.get("categoryId") || undefined;
|
||||||
const radius = Number(searchParams.get("radius")) || 10; // km
|
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 partners = await prisma.partner.findMany({ where, orderBy: { createdAt: "desc" } });
|
||||||
const withDistance = isFinite(lat) && isFinite(lon)
|
const withDistance = isFinite(lat) && isFinite(lon)
|
||||||
? partners.map((p) => ({ ...p, distance: haversine(lat, lon, p.latitude, p.longitude) })).filter((p) => p.distance <= radius)
|
? partners.map((p) => ({ ...p, distance: haversine(lat, lon, p.latitude, p.longitude) })).filter((p) => p.distance <= radius)
|
||||||
|
|||||||
61
src/app/components/PartnerCategorySection.tsx
Normal file
61
src/app/components/PartnerCategorySection.tsx
Normal file
@@ -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<string>(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 (
|
||||||
|
<section>
|
||||||
|
{/* 카테고리 탭 */}
|
||||||
|
<div className="flex gap-2 overflow-x-auto pb-2">
|
||||||
|
{categories.map((c: any) => (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
onClick={() => setSelectedId(c.id)}
|
||||||
|
className={`px-3 h-8 rounded-full border text-sm whitespace-nowrap ${ (selectedId || defaultCatId) === c.id ? "bg-neutral-900 text-white border-neutral-900" : "border-neutral-300 hover:bg-neutral-100"}`}
|
||||||
|
>
|
||||||
|
{c.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 파트너 리스트 */}
|
||||||
|
<div className="mt-4 grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-3">
|
||||||
|
{isLoading && partners.length === 0 && (
|
||||||
|
<div className="col-span-full text-sm text-neutral-500">불러오는 중...</div>
|
||||||
|
)}
|
||||||
|
{partners.map((p: any) => (
|
||||||
|
<div key={p.id} className="rounded-lg border border-neutral-200 overflow-hidden bg-white">
|
||||||
|
<div className="w-full aspect-[4/3] bg-neutral-100">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img src={p.imageUrl || "/sample.jpg"} alt={p.name} className="w-full h-full object-cover" />
|
||||||
|
</div>
|
||||||
|
<div className="p-2">
|
||||||
|
<div className="text-sm font-semibold truncate">{p.name}</div>
|
||||||
|
<div className="text-xs text-neutral-500 truncate">{p.address || ""}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!isLoading && partners.length === 0 && (
|
||||||
|
<div className="col-span-full text-sm text-neutral-500">표시할 제휴업체가 없습니다.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { HeroBanner } from "@/app/components/HeroBanner";
|
import { HeroBanner } from "@/app/components/HeroBanner";
|
||||||
|
import PartnerCategorySection from "@/app/components/PartnerCategorySection";
|
||||||
import Link from "next/link";
|
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";
|
||||||
@@ -204,6 +205,9 @@ export default async function Home({ searchParams }: { searchParams: Promise<{ s
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 배너 아래: 파트너 카테고리 탭 + 파트너 리스트 */}
|
||||||
|
<PartnerCategorySection />
|
||||||
|
|
||||||
{/* 제휴 샾 가로 스크롤 (설정 온오프, DB에서 불러오기)
|
{/* 제휴 샾 가로 스크롤 (설정 온오프, DB에서 불러오기)
|
||||||
- 우선 partners 테이블(관리자 페이지 관리 대상) 사용
|
- 우선 partners 테이블(관리자 페이지 관리 대상) 사용
|
||||||
- 없으면 partner_shops로 대체 */}
|
- 없으면 partner_shops로 대체 */}
|
||||||
|
|||||||
Reference in New Issue
Block a user