diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6ee36ec..2cfdceb 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -746,6 +746,10 @@ model PartnerRequest { longitude Float address String? contact String? + region String? + imageUrl String? + sortOrder Int @default(0) + active Boolean @default(true) status String @default("pending") // pending/approved/rejected createdAt DateTime @default(now()) approvedAt DateTime? @@ -766,18 +770,4 @@ model Setting { } // 메인 노출용 제휴 샵 가로 스크롤 데이터 -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") -} +// PartnerShop 모델 제거됨 (PartnerRequest로 통합) diff --git a/prisma/seed.js b/prisma/seed.js index c7dce42..488fa22 100644 --- a/prisma/seed.js +++ b/prisma/seed.js @@ -463,10 +463,10 @@ async function seedPartnerShops() { { 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, + await prisma.partnerRequest.upsert({ + where: { id: `${it.region}-${it.name}` }, + update: { address: it.address, imageUrl: it.imageUrl, sortOrder: it.sortOrder, active: true, status: "approved" }, + create: { id: `${it.region}-${it.name}`, region: it.region, name: it.name, address: it.address, imageUrl: it.imageUrl, sortOrder: it.sortOrder, active: true, status: "approved", category: "misc", latitude: 37.5, longitude: 127.0 }, }); } // 표시 토글 기본값 보장 @@ -537,6 +537,25 @@ async function main() { await prisma.$executeRawUnsafe("ALTER TABLE partners ADD COLUMN categoryId TEXT"); // 외래키 제약은 생략 (SQLite에서는 제약 추가가 까다로움) } + // partner_requests 확장 컬럼 보장 (region, imageUrl, sortOrder, active) + const prCols = await prisma.$queryRaw`PRAGMA table_info('partner_requests')`; + const colHas = (name) => Array.isArray(prCols) && prCols.some((c) => (c.name || c.COLUMN_NAME) === name); + if (!colHas('region')) { + console.log("Adding missing column: partner_requests.region"); + await prisma.$executeRawUnsafe("ALTER TABLE partner_requests ADD COLUMN region TEXT"); + } + if (!colHas('imageUrl')) { + console.log("Adding missing column: partner_requests.imageUrl"); + await prisma.$executeRawUnsafe("ALTER TABLE partner_requests ADD COLUMN imageUrl TEXT"); + } + if (!colHas('sortOrder')) { + console.log("Adding missing column: partner_requests.sortOrder"); + await prisma.$executeRawUnsafe("ALTER TABLE partner_requests ADD COLUMN sortOrder INTEGER NOT NULL DEFAULT 0"); + } + if (!colHas('active')) { + console.log("Adding missing column: partner_requests.active"); + await prisma.$executeRawUnsafe("ALTER TABLE partner_requests ADD COLUMN active BOOLEAN NOT NULL DEFAULT 1"); + } } catch (e) { console.warn("SQLite schema ensure failed:", e); } diff --git a/public/uploads/1762520156525-1dqijvt0rge.jpg b/public/uploads/1762520156525-1dqijvt0rge.jpg new file mode 100644 index 0000000..6ee9978 Binary files /dev/null and b/public/uploads/1762520156525-1dqijvt0rge.jpg differ diff --git a/public/uploads/1762521903585-d2gxpaoocil.jpg b/public/uploads/1762521903585-d2gxpaoocil.jpg new file mode 100644 index 0000000..7d68f4e Binary files /dev/null and b/public/uploads/1762521903585-d2gxpaoocil.jpg differ diff --git a/src/app/api/admin/partner-shops/[id]/route.ts b/src/app/api/admin/partner-shops/[id]/route.ts index 2e8baf7..3158f51 100644 --- a/src/app/api/admin/partner-shops/[id]/route.ts +++ b/src/app/api/admin/partner-shops/[id]/route.ts @@ -8,13 +8,13 @@ export async function PATCH(req: Request, context: { params: Promise<{ id: strin 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 }); + const item = await prisma.partnerRequest.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 } }); + await prisma.partnerRequest.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 index eaafcab..adbf288 100644 --- a/src/app/api/admin/partner-shops/route.ts +++ b/src/app/api/admin/partner-shops/route.ts @@ -4,8 +4,12 @@ import { z } from "zod"; import type { Prisma } from "@prisma/client"; export async function GET() { - const items = await prisma.partnerShop.findMany({ orderBy: [{ active: "desc" }, { sortOrder: "asc" }, { createdAt: "desc" }] }); - return NextResponse.json({ items }); + const rows = await prisma.partnerRequest.findMany({ + where: { status: "approved" }, + orderBy: [{ active: "desc" }, { sortOrder: "asc" }, { createdAt: "desc" }], + select: { id: true, region: true, name: true, address: true, imageUrl: true, sortOrder: true, active: true }, + }); + return NextResponse.json({ items: rows }); } const createSchema = z.object({ @@ -21,7 +25,8 @@ 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 as Prisma.PartnerShopCreateInput }); + // 통합: 승인된 레코드로 등록 + const item = await prisma.partnerRequest.create({ data: { ...(parsed.data as any), status: "approved" } }); return NextResponse.json({ item }, { status: 201 }); } diff --git a/src/app/api/partner-shops/route.ts b/src/app/api/partner-shops/route.ts index 27614d6..13f2eca 100644 --- a/src/app/api/partner-shops/route.ts +++ b/src/app/api/partner-shops/route.ts @@ -2,10 +2,13 @@ import { NextResponse } from "next/server"; import prisma from "@/lib/prisma"; export async function GET() { - const items = await prisma.partnerShop.findMany({ - where: { active: true }, + // 통합: 승인된 파트너 요청을 메인 노출용으로 사용 + const rows = await prisma.partnerRequest.findMany({ + where: { status: "approved", active: true }, orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }], + select: { id: true, region: true, name: true, address: true, imageUrl: true, sortOrder: true, active: true, category: true }, }); + const items = rows.map((r) => ({ id: r.id, region: r.region || "", name: r.name, address: r.address || "", imageUrl: r.imageUrl || "", sortOrder: r.sortOrder, active: r.active, category: r.category })); return NextResponse.json({ items }); } diff --git a/src/app/api/partners/route.ts b/src/app/api/partners/route.ts index 279d7ff..cae78ba 100644 --- a/src/app/api/partners/route.ts +++ b/src/app/api/partners/route.ts @@ -1,32 +1,24 @@ import { NextResponse } from "next/server"; import prisma from "@/lib/prisma"; -function haversine(lat1: number, lon1: number, lat2: number, lon2: number) { - const toRad = (v: number) => (v * Math.PI) / 180; - const R = 6371; // km - const dLat = toRad(lat2 - lat1); - const dLon = toRad(lon2 - lon1); - const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2; - const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); - return R * c; -} - export async function GET(req: Request) { const { searchParams } = new URL(req.url); - 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: any = categoryId - ? { categoryId } - : (category ? { category } : {}); + let where: any = {}; + if (categoryId) { + // 카테고리 ID 매칭 + 과거 데이터 호환(문자열 category명 매칭) + let catName: string | undefined; + try { + const cat = await prisma.partnerCategory.findUnique({ where: { id: categoryId }, select: { name: true } }); + catName = cat?.name; + } catch {} + where = catName ? { OR: [{ categoryId }, { category: catName }] } : { categoryId }; + } else if (category) { + where = { 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) - : partners.map((p) => ({ ...p, distance: null })); - withDistance.sort((a, b) => (a.distance ?? 0) - (b.distance ?? 0)); - return NextResponse.json({ partners: withDistance }); + return NextResponse.json({ partners }); } diff --git a/src/app/api/posts/route.ts b/src/app/api/posts/route.ts index 283e4d8..dc9f7cc 100644 --- a/src/app/api/posts/route.ts +++ b/src/app/api/posts/route.ts @@ -48,7 +48,7 @@ const listQuerySchema = z.object({ pageSize: z.coerce.number().min(1).max(100).default(10), boardId: z.string().optional(), q: z.string().optional(), - sort: z.enum(["recent", "popular"]).default("recent").optional(), + sort: z.enum(["recent", "popular", "views", "likes", "comments"]).default("recent").optional(), tag: z.string().optional(), // Tag.slug author: z.string().optional(), // User.nickname contains authorId: z.string().optional(), // User.userId exact match @@ -103,8 +103,12 @@ export async function GET(req: Request) { prisma.post.findMany({ where, orderBy: - sort === "popular" + sort === "popular" || sort === "likes" ? [{ isPinned: "desc" }, { stat: { recommendCount: "desc" } }, { createdAt: "desc" }] + : sort === "views" + ? [{ isPinned: "desc" }, { stat: { views: "desc" } }, { createdAt: "desc" }] + : sort === "comments" + ? [{ isPinned: "desc" }, { stat: { commentsCount: "desc" } }, { createdAt: "desc" }] : [{ isPinned: "desc" }, { createdAt: "desc" }], skip: (page - 1) * pageSize, take: pageSize, diff --git a/src/app/boards/[id]/page.tsx b/src/app/boards/[id]/page.tsx index 33219f4..6871834 100644 --- a/src/app/boards/[id]/page.tsx +++ b/src/app/boards/[id]/page.tsx @@ -15,7 +15,7 @@ export default async function BoardDetail({ params, searchParams }: { params: an const p = params?.then ? await params : params; const sp = searchParams?.then ? await searchParams : searchParams; const idOrSlug = p.id as string; - const sort = (sp?.sort as "recent" | "popular" | undefined) ?? "recent"; + const sort = (sp?.sort as "recent" | "popular" | "views" | "likes" | "comments" | undefined) ?? "recent"; // 보드 slug 조회 (새 글 페이지 프리셋 전달) const h = await headers(); const host = h.get("host") ?? "localhost:3000"; @@ -70,8 +70,8 @@ export default async function BoardDetail({ params, searchParams }: { params: an href={`/boards/${b.slug}`} className={ b.id === id - ? "px-3 h-[28px] rounded-full bg-[#F94B37] text-white text-[12px] leading-[28px] whitespace-nowrap" - : "px-3 h-[28px] rounded-full bg-transparent text-white/85 hover:text-white border border-white/30 text-[12px] leading-[28px] whitespace-nowrap" + ? "px-3 h-[28px] mt-[11px] rounded-full bg-[#F94B37] text-white text-[12px] font-[700] leading-[28px] whitespace-nowrap" + : "px-3 h-[28px] rounded-full bg-transparent text-white/85 hover:text-white border border-white/30 text-[12px] font-[700] leading-[28px] whitespace-nowrap" } > {b.name} diff --git a/src/app/components/BoardToolbar.tsx b/src/app/components/BoardToolbar.tsx index 17e0e2b..27ffb58 100644 --- a/src/app/components/BoardToolbar.tsx +++ b/src/app/components/BoardToolbar.tsx @@ -60,13 +60,9 @@ export function BoardToolbar({ boardId }: { boardId: string }) {
-
diff --git a/src/app/components/HeroBanner.tsx b/src/app/components/HeroBanner.tsx index 5481bd3..20e1757 100644 --- a/src/app/components/HeroBanner.tsx +++ b/src/app/components/HeroBanner.tsx @@ -10,7 +10,38 @@ type SubItem = { id: string; name: string; href: string }; const fetcher = (url: string) => fetch(url).then((r) => r.json()); -export function HeroBanner({ subItems, activeSubId, hideSubOnMobile }: { subItems?: SubItem[]; activeSubId?: string; hideSubOnMobile?: boolean }) { +export function HeroBanner({ subItems, activeSubId, hideSubOnMobile, showPartnerCats }: { subItems?: SubItem[]; activeSubId?: string; hideSubOnMobile?: boolean; showPartnerCats?: boolean }) { + const usePartnerCats = ((!Array.isArray(subItems) || subItems.length === 0) && showPartnerCats !== false); + // 파트너 카테고리 불러오기 (홈 배너 하단 블랙바에 표시) + const { data: catData } = useSWR<{ categories: any[] }>(usePartnerCats ? "/api/partner-categories" : null, fetcher, { revalidateOnFocus: false, dedupingInterval: 5 * 60 * 1000 }); + const categories = catData?.categories ?? []; + const [selectedCatId, setSelectedCatId] = useState(""); + useEffect(() => { + if (!usePartnerCats) return; + if (!selectedCatId && categories.length > 0) { + let id = ""; + try { + id = (window as any).__partnerCategoryId || localStorage.getItem("selectedPartnerCategoryId") || ""; + } catch {} + if (!id) id = categories[0].id; + setSelectedCatId(id); + try { + (window as any).__partnerCategoryId = id; + localStorage.setItem("selectedPartnerCategoryId", id); + window.dispatchEvent(new CustomEvent("partnerCategorySelect", { detail: { id } })); + } catch {} + } + }, [usePartnerCats, categories, selectedCatId]); + const onSelectCategory = useCallback((id: string) => { + if (!usePartnerCats) return; + setSelectedCatId(id); + // 전역 이벤트로 선택 전달 + try { + (window as any).__partnerCategoryId = id; + localStorage.setItem("selectedPartnerCategoryId", id); + window.dispatchEvent(new CustomEvent("partnerCategorySelect", { detail: { id } })); + } catch {} + }, [usePartnerCats]); // SWR 캐시 사용: 페이지 전환 시 재요청/깜빡임 최소화 const { data } = useSWR<{ banners: Banner[] }>("/api/banners", fetcher, { revalidateOnFocus: false, @@ -78,23 +109,46 @@ export function HeroBanner({ subItems, activeSubId, hideSubOnMobile }: { subItem
{/* 분리된 하단 블랙 바: 높이 58px, 중앙 서브카테고리 (스켈레톤 상태에서도 동일 레이아웃 유지) */} -
- {Array.isArray(subItems) && subItems.length > 0 && ( -
- {subItems.map((s) => ( - + {usePartnerCats ? ( +
+ {categories.map((c: any) => ( + ))}
+ ) : ( + Array.isArray(subItems) && subItems.length > 0 && ( +
+ {subItems.map((s) => ( + + {s.name} + + ))} +
+ ) + )} + {!usePartnerCats && (!Array.isArray(subItems) || subItems.length === 0) && ( +
+ 암실소문 +
)}
@@ -177,23 +231,46 @@ export function HeroBanner({ subItems, activeSubId, hideSubOnMobile }: { subItem {/* 분리된 하단 블랙 바: 높이 58px, 중앙 서브카테고리 */} -
- {Array.isArray(subItems) && subItems.length > 0 && ( -
- {subItems.map((s) => ( - + {usePartnerCats ? ( +
+ {categories.map((c: any) => ( + ))}
+ ) : ( + Array.isArray(subItems) && subItems.length > 0 && ( +
+ {subItems.map((s) => ( + + {s.name} + + ))} +
+ ) + )} + {!usePartnerCats && (!Array.isArray(subItems) || subItems.length === 0) && ( +
+ 암실소문 +
)}
diff --git a/src/app/components/HorizontalCardScroller.tsx b/src/app/components/HorizontalCardScroller.tsx index 73bd8c4..b4ee86b 100644 --- a/src/app/components/HorizontalCardScroller.tsx +++ b/src/app/components/HorizontalCardScroller.tsx @@ -25,6 +25,9 @@ export default function HorizontalCardScroller({ items }: HorizontalCardScroller const CARD_WIDTH = 384; const CARD_GAP = 16; // Tailwind gap-4 const SCROLL_STEP = CARD_WIDTH + CARD_GAP; + useEffect(() => { + console.log(items); + }, [items]); const updateThumb = useCallback(() => { const scroller = scrollRef.current; @@ -145,7 +148,7 @@ export default function HorizontalCardScroller({ items }: HorizontalCardScroller
-
+