??
Some checks failed
deploy-on-main / deploy (push) Failing after 22s

This commit is contained in:
koreacomp5
2025-11-07 21:36:34 +09:00
parent 5f72e6ce7c
commit ab81a3da3d
16 changed files with 345 additions and 19 deletions

View File

@@ -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())

View File

@@ -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 });
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 MiB

View File

@@ -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<HTMLInputElement>(null);
const editFileInputRef = useRef<HTMLInputElement>(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() {
</div>
<div style={{ gridColumn: "span 3" }}>
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}></label>
<input style={inputStyle} value={form.category} onChange={(e) => setForm({ ...form, category: e.target.value })} />
<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 style={{ gridColumn: "span 3" }}>
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}></label>
@@ -178,7 +194,12 @@ export default function AdminPartnersPage() {
</div>
<div style={{ gridColumn: "span 3" }}>
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}></label>
<input style={inputStyle} value={editDraft?.category ?? ""} onChange={(e) => setEditDraft({ ...editDraft, category: e.target.value })} />
<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 style={{ gridColumn: "span 3" }}>
<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 }}>
<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"
>
@@ -250,14 +287,14 @@ export default function AdminPartnersPage() {
) : (
<>
<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>
<div style={{ fontSize: 12, opacity: .7 }}> {p.latitude}</div>
<div style={{ fontSize: 12, opacity: .7 }}> {p.longitude}</div>
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
<button
onClick={() => { setEditingId(p.id); setEditDraft({ name: p.name, category: p.category, latitude: String(p.latitude), longitude: String(p.longitude), address: p.address ?? "", imageUrl: p.imageUrl ?? "", sortOrder: p.sortOrder ?? 0 }); setShowEditModal(true); }}
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"
>
@@ -288,8 +325,49 @@ export default function AdminPartnersPage() {
</li>
))}
</ul>
<div>
<hr style={{ margin: "24px 0" }} />
<h2 className="text-lg font-bold mb-2"> </h2>
<CategoryManager categories={categories} onChanged={mutateCategories} />
</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>
);
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}

View File

@@ -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 });

View File

@@ -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 });
}
}

View 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 });
}

View File

@@ -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)

View 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>
);
}

View File

@@ -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
</section>
)}
{/* 배너 아래: 파트너 카테고리 탭 + 파트너 리스트 */}
<PartnerCategorySection />
{/* 제휴 샾 가로 스크롤 (설정 온오프, DB에서 불러오기)
- 우선 partners 테이블(관리자 페이지 관리 대상) 사용
- 없으면 partner_shops로 대체 */}