@@ -746,6 +746,10 @@ model PartnerRequest {
|
|||||||
longitude Float
|
longitude Float
|
||||||
address String?
|
address String?
|
||||||
contact String?
|
contact String?
|
||||||
|
region String?
|
||||||
|
imageUrl String?
|
||||||
|
sortOrder Int @default(0)
|
||||||
|
active Boolean @default(true)
|
||||||
status String @default("pending") // pending/approved/rejected
|
status String @default("pending") // pending/approved/rejected
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
approvedAt DateTime?
|
approvedAt DateTime?
|
||||||
@@ -766,18 +770,4 @@ model Setting {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 메인 노출용 제휴 샵 가로 스크롤 데이터
|
// 메인 노출용 제휴 샵 가로 스크롤 데이터
|
||||||
model PartnerShop {
|
// PartnerShop 모델 제거됨 (PartnerRequest로 통합)
|
||||||
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")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -463,10 +463,10 @@ async function seedPartnerShops() {
|
|||||||
{ region: "강원도", name: "test2", address: "춘천시 중앙로 112", imageUrl: "/sample.jpg", sortOrder: 2 },
|
{ region: "강원도", name: "test2", address: "춘천시 중앙로 112", imageUrl: "/sample.jpg", sortOrder: 2 },
|
||||||
];
|
];
|
||||||
for (const it of items) {
|
for (const it of items) {
|
||||||
await prisma.partnerShop.upsert({
|
await prisma.partnerRequest.upsert({
|
||||||
where: { name_region: { name: it.name, region: it.region } },
|
where: { id: `${it.region}-${it.name}` },
|
||||||
update: { address: it.address, imageUrl: it.imageUrl, sortOrder: it.sortOrder, active: true },
|
update: { address: it.address, imageUrl: it.imageUrl, sortOrder: it.sortOrder, active: true, status: "approved" },
|
||||||
create: it,
|
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");
|
await prisma.$executeRawUnsafe("ALTER TABLE partners ADD COLUMN categoryId TEXT");
|
||||||
// 외래키 제약은 생략 (SQLite에서는 제약 추가가 까다로움)
|
// 외래키 제약은 생략 (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) {
|
} catch (e) {
|
||||||
console.warn("SQLite schema ensure failed:", e);
|
console.warn("SQLite schema ensure failed:", e);
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
public/uploads/1762520156525-1dqijvt0rge.jpg
Normal file
BIN
public/uploads/1762520156525-1dqijvt0rge.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
BIN
public/uploads/1762521903585-d2gxpaoocil.jpg
Normal file
BIN
public/uploads/1762521903585-d2gxpaoocil.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
@@ -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"]) {
|
for (const k of ["region", "name", "address", "imageUrl", "active", "sortOrder"]) {
|
||||||
if (k in body) data[k] = body[k];
|
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 });
|
return NextResponse.json({ item });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE(_: Request, context: { params: Promise<{ id: string }> }) {
|
export async function DELETE(_: Request, context: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await context.params;
|
const { id } = await context.params;
|
||||||
await prisma.partnerShop.delete({ where: { id } });
|
await prisma.partnerRequest.delete({ where: { id } });
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,12 @@ import { z } from "zod";
|
|||||||
import type { Prisma } from "@prisma/client";
|
import type { Prisma } from "@prisma/client";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const items = await prisma.partnerShop.findMany({ orderBy: [{ active: "desc" }, { sortOrder: "asc" }, { createdAt: "desc" }] });
|
const rows = await prisma.partnerRequest.findMany({
|
||||||
return NextResponse.json({ items });
|
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({
|
const createSchema = z.object({
|
||||||
@@ -21,7 +25,8 @@ 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 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 });
|
return NextResponse.json({ item }, { status: 201 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,13 @@ import { NextResponse } from "next/server";
|
|||||||
import prisma from "@/lib/prisma";
|
import prisma from "@/lib/prisma";
|
||||||
|
|
||||||
export async function GET() {
|
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" }],
|
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 });
|
return NextResponse.json({ items });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,32 +1,24 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import prisma from "@/lib/prisma";
|
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) {
|
export async function GET(req: Request) {
|
||||||
const { searchParams } = new URL(req.url);
|
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 category = searchParams.get("category") || undefined;
|
||||||
const categoryId = searchParams.get("categoryId") || undefined;
|
const categoryId = searchParams.get("categoryId") || undefined;
|
||||||
const radius = Number(searchParams.get("radius")) || 10; // km
|
let where: any = {};
|
||||||
const where: any = categoryId
|
if (categoryId) {
|
||||||
? { categoryId }
|
// 카테고리 ID 매칭 + 과거 데이터 호환(문자열 category명 매칭)
|
||||||
: (category ? { 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 partners = await prisma.partner.findMany({ where, orderBy: { createdAt: "desc" } });
|
||||||
const withDistance = isFinite(lat) && isFinite(lon)
|
return NextResponse.json({ partners });
|
||||||
? 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 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ const listQuerySchema = z.object({
|
|||||||
pageSize: z.coerce.number().min(1).max(100).default(10),
|
pageSize: z.coerce.number().min(1).max(100).default(10),
|
||||||
boardId: z.string().optional(),
|
boardId: z.string().optional(),
|
||||||
q: 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
|
tag: z.string().optional(), // Tag.slug
|
||||||
author: z.string().optional(), // User.nickname contains
|
author: z.string().optional(), // User.nickname contains
|
||||||
authorId: z.string().optional(), // User.userId exact match
|
authorId: z.string().optional(), // User.userId exact match
|
||||||
@@ -103,8 +103,12 @@ export async function GET(req: Request) {
|
|||||||
prisma.post.findMany({
|
prisma.post.findMany({
|
||||||
where,
|
where,
|
||||||
orderBy:
|
orderBy:
|
||||||
sort === "popular"
|
sort === "popular" || sort === "likes"
|
||||||
? [{ isPinned: "desc" }, { stat: { recommendCount: "desc" } }, { createdAt: "desc" }]
|
? [{ 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" }],
|
: [{ isPinned: "desc" }, { createdAt: "desc" }],
|
||||||
skip: (page - 1) * pageSize,
|
skip: (page - 1) * pageSize,
|
||||||
take: pageSize,
|
take: pageSize,
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export default async function BoardDetail({ params, searchParams }: { params: an
|
|||||||
const p = params?.then ? await params : params;
|
const p = params?.then ? await params : params;
|
||||||
const sp = searchParams?.then ? await searchParams : searchParams;
|
const sp = searchParams?.then ? await searchParams : searchParams;
|
||||||
const idOrSlug = p.id as string;
|
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 조회 (새 글 페이지 프리셋 전달)
|
// 보드 slug 조회 (새 글 페이지 프리셋 전달)
|
||||||
const h = await headers();
|
const h = await headers();
|
||||||
const host = h.get("host") ?? "localhost:3000";
|
const host = h.get("host") ?? "localhost:3000";
|
||||||
@@ -70,8 +70,8 @@ export default async function BoardDetail({ params, searchParams }: { params: an
|
|||||||
href={`/boards/${b.slug}`}
|
href={`/boards/${b.slug}`}
|
||||||
className={
|
className={
|
||||||
b.id === id
|
b.id === id
|
||||||
? "px-3 h-[28px] rounded-full bg-[#F94B37] text-white 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] 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}
|
{b.name}
|
||||||
|
|||||||
@@ -60,13 +60,9 @@ export function BoardToolbar({ boardId }: { boardId: string }) {
|
|||||||
<div className="order-2 md:order-1 flex items-center gap-2">
|
<div className="order-2 md:order-1 flex items-center gap-2">
|
||||||
<select aria-label="정렬" className="h-8 px-2 rounded-md border border-neutral-300 bg-white text-sm" defaultValue={sort} onChange={onChangeSort}>
|
<select aria-label="정렬" className="h-8 px-2 rounded-md border border-neutral-300 bg-white text-sm" defaultValue={sort} onChange={onChangeSort}>
|
||||||
<option value="recent">최신순</option>
|
<option value="recent">최신순</option>
|
||||||
<option value="popular">인기순</option>
|
<option value="views">조회순</option>
|
||||||
</select>
|
<option value="likes">좋아요순</option>
|
||||||
<select aria-label="기간" className="h-8 px-2 rounded-md border border-neutral-300 bg-white text-sm" defaultValue={period} onChange={onChangePeriod}>
|
<option value="comments">댓글순</option>
|
||||||
<option value="all">전체기간</option>
|
|
||||||
<option value="1d">24시간</option>
|
|
||||||
<option value="1w">1주일</option>
|
|
||||||
<option value="1m">1개월</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,7 +10,38 @@ type SubItem = { id: string; name: string; href: string };
|
|||||||
|
|
||||||
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
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<string>("");
|
||||||
|
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 캐시 사용: 페이지 전환 시 재요청/깜빡임 최소화
|
// SWR 캐시 사용: 페이지 전환 시 재요청/깜빡임 최소화
|
||||||
const { data } = useSWR<{ banners: Banner[] }>("/api/banners", fetcher, {
|
const { data } = useSWR<{ banners: Banner[] }>("/api/banners", fetcher, {
|
||||||
revalidateOnFocus: false,
|
revalidateOnFocus: false,
|
||||||
@@ -78,23 +109,46 @@ export function HeroBanner({ subItems, activeSubId, hideSubOnMobile }: { subItem
|
|||||||
<section className="relative w-full overflow-hidden rounded-xl bg-neutral-900 text-white" aria-roledescription="carousel">
|
<section className="relative w-full overflow-hidden rounded-xl bg-neutral-900 text-white" aria-roledescription="carousel">
|
||||||
<SelectedBanner className="h-56 sm:h-72 md:h-[264px]" />
|
<SelectedBanner className="h-56 sm:h-72 md:h-[264px]" />
|
||||||
{/* 분리된 하단 블랙 바: 높이 58px, 중앙 서브카테고리 (스켈레톤 상태에서도 동일 레이아웃 유지) */}
|
{/* 분리된 하단 블랙 바: 높이 58px, 중앙 서브카테고리 (스켈레톤 상태에서도 동일 레이아웃 유지) */}
|
||||||
<div className={`${hideSubOnMobile ? "hidden md:flex" : "flex"} h-[58px] bg-black rounded-xl items-center justify-center px-2`}>
|
<div className={`${hideSubOnMobile ? "hidden md:flex" : "flex"} h-[58px] bg-black rounded-xl items-end justify-center px-2`}>
|
||||||
{Array.isArray(subItems) && subItems.length > 0 && (
|
{usePartnerCats ? (
|
||||||
<div className="flex flex-wrap items-center gap-[8px]">
|
<div className="flex flex-wrap items-center h-[74%] gap-[24px]">
|
||||||
|
{categories.map((c: any) => (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
onClick={() => onSelectCategory(c.id)}
|
||||||
|
className={
|
||||||
|
(selectedCatId || categories[0]?.id) === c.id
|
||||||
|
? "px-3 h-full inline-flex items-center bg-white text-[#d73b29] text-[20px] font-[700] rounded-tl-[14px] rounded-tr-[14px] whitespace-nowrap"
|
||||||
|
: "px-3 h-full inline-flex items-center bg-transparent text-[#d5d5d5] hover:bg-white hover:text-[#d73b29] hover:font-[700] hover:rounded-tl-[14px] hover:rounded-tr-[14px] text-[20px] font-[500] whitespace-nowrap"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{c.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
Array.isArray(subItems) && subItems.length > 0 && (
|
||||||
|
<div className="flex flex-wrap items-center h-[74%] gap-[24px]">
|
||||||
{subItems.map((s) => (
|
{subItems.map((s) => (
|
||||||
<Link
|
<Link
|
||||||
key={s.id}
|
key={s.id}
|
||||||
href={s.href}
|
href={s.href}
|
||||||
className={
|
className={
|
||||||
s.id === activeSubId
|
s.id === activeSubId
|
||||||
? "px-3 h-[28px] rounded-full bg-[#F94B37] text-white text-[12px] leading-[28px] whitespace-nowrap"
|
? "px-3 h-full inline-flex items-center bg-white text-[#d73b29] text-[20px] font-[700] rounded-tl-[14px] rounded-tr-[14px] whitespace-nowrap"
|
||||||
: "px-3 h-[28px] rounded-full bg-transparent text-white/85 hover:bg-[#F94B37] hover:text-white text-[12px] leading-[28px] whitespace-nowrap"
|
: "px-3 h-full inline-flex items-center bg-transparent text-[#d5d5d5] hover:bg-white hover:text-[#d73b29] hover:font-[700] hover:rounded-tl-[14px] hover:rounded-tr-[14px] text-[20px] font-[500] whitespace-nowrap"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{s.name}
|
{s.name}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{!usePartnerCats && (!Array.isArray(subItems) || subItems.length === 0) && (
|
||||||
|
<div className="flex items-center gap-[8px]">
|
||||||
|
<span className="px-3 h-[28px] rounded-full bg-transparent text-white/85 text-[12px] leading-[28px] whitespace-nowrap cursor-default">암실소문</span>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -177,23 +231,46 @@ export function HeroBanner({ subItems, activeSubId, hideSubOnMobile }: { subItem
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 분리된 하단 블랙 바: 높이 58px, 중앙 서브카테고리 */}
|
{/* 분리된 하단 블랙 바: 높이 58px, 중앙 서브카테고리 */}
|
||||||
<div className={`${hideSubOnMobile ? "hidden md:flex" : "flex"} h-[58px] bg-black rounded-xl items-center justify-center px-2`}>
|
<div className={`${hideSubOnMobile ? "hidden md:flex" : "flex"} h-[58px] bg-black rounded-xl items-end justify-center px-2`}>
|
||||||
{Array.isArray(subItems) && subItems.length > 0 && (
|
{usePartnerCats ? (
|
||||||
<div className="flex flex-wrap items-center gap-[8px]">
|
<div className="flex flex-wrap items-center h-[74%] gap-[24px]">
|
||||||
|
{categories.map((c: any) => (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
onClick={() => onSelectCategory(c.id)}
|
||||||
|
className={
|
||||||
|
(selectedCatId || categories[0]?.id) === c.id
|
||||||
|
? "px-3 h-full inline-flex items-center bg-white text-[#d73b29] text-[20px] font-[700] rounded-tl-[14px] rounded-tr-[14px] whitespace-nowrap"
|
||||||
|
: "px-3 h-full inline-flex items-center bg-transparent text-[#d5d5d5] hover:bg-white hover:text-[#d73b29] hover:font-[700] hover:rounded-tl-[14px] hover:rounded-tr-[14px] text-[20px] font-[500] whitespace-nowrap"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{c.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
Array.isArray(subItems) && subItems.length > 0 && (
|
||||||
|
<div className="flex flex-wrap items-center h-[74%] gap-[24px]">
|
||||||
{subItems.map((s) => (
|
{subItems.map((s) => (
|
||||||
<Link
|
<Link
|
||||||
key={s.id}
|
key={s.id}
|
||||||
href={s.href}
|
href={s.href}
|
||||||
className={
|
className={
|
||||||
s.id === activeSubId
|
s.id === activeSubId
|
||||||
? "px-3 h-[28px] rounded-full bg-[#F94B37] text-white text-[12px] leading-[28px] whitespace-nowrap"
|
? "px-3 h-full inline-flex items-center bg-white text-[#d73b29] text-[20px] font-[700] rounded-tl-[14px] rounded-tr-[14px] whitespace-nowrap"
|
||||||
: "px-3 h-[28px] rounded-full bg-transparent text-white/85 hover:bg-[#F94B37] hover:text-white text-[12px] leading-[28px] whitespace-nowrap"
|
: "px-3 h-full inline-flex items-center bg-transparent text-[#d5d5d5] hover:bg-white hover:text-[#d73b29] hover:font-[700] hover:rounded-tl-[14px] hover:rounded-tr-[14px] text-[20px] font-[500] whitespace-nowrap"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{s.name}
|
{s.name}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{!usePartnerCats && (!Array.isArray(subItems) || subItems.length === 0) && (
|
||||||
|
<div className="flex items-center gap-[8px]">
|
||||||
|
<span className="px-3 h-[28px] rounded-full bg-transparent text-white/85 text-[12px] leading-[28px] whitespace-nowrap cursor-default">암실소문</span>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ export default function HorizontalCardScroller({ items }: HorizontalCardScroller
|
|||||||
const CARD_WIDTH = 384;
|
const CARD_WIDTH = 384;
|
||||||
const CARD_GAP = 16; // Tailwind gap-4
|
const CARD_GAP = 16; // Tailwind gap-4
|
||||||
const SCROLL_STEP = CARD_WIDTH + CARD_GAP;
|
const SCROLL_STEP = CARD_WIDTH + CARD_GAP;
|
||||||
|
useEffect(() => {
|
||||||
|
console.log(items);
|
||||||
|
}, [items]);
|
||||||
|
|
||||||
const updateThumb = useCallback(() => {
|
const updateThumb = useCallback(() => {
|
||||||
const scroller = scrollRef.current;
|
const scroller = scrollRef.current;
|
||||||
@@ -145,7 +148,7 @@ export default function HorizontalCardScroller({ items }: HorizontalCardScroller
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pointer-events-none absolute bottom-[20px] left-1/2 -translate-x-1/2 z-20 flex items-center gap-6">
|
<div className="pointer-events-none absolute bottom-[-5px] left-1/2 -translate-x-1/2 z-20 flex items-center gap-6">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="이전"
|
aria-label="이전"
|
||||||
|
|||||||
63
src/app/components/PartnerScroller.tsx
Normal file
63
src/app/components/PartnerScroller.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
"use client";
|
||||||
|
import useSWR from "swr";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import HorizontalCardScroller from "@/app/components/HorizontalCardScroller";
|
||||||
|
|
||||||
|
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
||||||
|
|
||||||
|
export default function PartnerScroller() {
|
||||||
|
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);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedId) {
|
||||||
|
let id = "";
|
||||||
|
try {
|
||||||
|
id = (window as any).__partnerCategoryId || localStorage.getItem("selectedPartnerCategoryId") || "";
|
||||||
|
} catch {}
|
||||||
|
if (!id) id = defaultCatId;
|
||||||
|
if (id) setSelectedId(id);
|
||||||
|
}
|
||||||
|
}, [defaultCatId, selectedId]);
|
||||||
|
|
||||||
|
// Listen to HeroBanner selection events
|
||||||
|
useEffect(() => {
|
||||||
|
// initialize from global if present
|
||||||
|
try {
|
||||||
|
const initial = (window as any).__partnerCategoryId;
|
||||||
|
const stored = localStorage.getItem("selectedPartnerCategoryId");
|
||||||
|
if (initial) setSelectedId(initial);
|
||||||
|
else if (stored) setSelectedId(stored);
|
||||||
|
} catch {}
|
||||||
|
const handler = (e: Event) => {
|
||||||
|
const ce = e as CustomEvent<{ id: string }>;
|
||||||
|
if (ce?.detail?.id) setSelectedId(ce.detail.id);
|
||||||
|
};
|
||||||
|
window.addEventListener("partnerCategorySelect", handler as EventListener);
|
||||||
|
return () => window.removeEventListener("partnerCategorySelect", handler as EventListener);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const partnersQuery = useMemo(() => (selectedId ? `/api/partners?categoryId=${encodeURIComponent(selectedId)}` : "/api/partners"), [selectedId]);
|
||||||
|
const { data: partnersData } = useSWR<{ partners: any[] }>(partnersQuery, fetcher);
|
||||||
|
const partners = partnersData?.partners ?? [];
|
||||||
|
|
||||||
|
// Fallback to approved partner requests if no partners
|
||||||
|
const { data: reqData } = useSWR<{ items: any[] }>(partners.length === 0 ? "/api/partner-shops" : null, fetcher);
|
||||||
|
const activeCategoryName = useMemo(() => categories.find((c: any) => c.id === selectedId)?.name, [categories, selectedId]);
|
||||||
|
const fallbackItems = useMemo(() => {
|
||||||
|
if (!reqData?.items) return [] as any[];
|
||||||
|
const filtered = activeCategoryName ? reqData.items.filter((it: any) => it.category === activeCategoryName) : reqData.items;
|
||||||
|
return filtered.map((s: any) => ({ id: s.id, region: s.region, name: s.name, address: s.address, image: s.imageUrl || "/sample.jpg" }));
|
||||||
|
}, [reqData, activeCategoryName]);
|
||||||
|
|
||||||
|
const items = partners.length > 0
|
||||||
|
? 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" }))
|
||||||
|
: fallbackItems;
|
||||||
|
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
return <HorizontalCardScroller items={items} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@ type Resp = {
|
|||||||
page: number;
|
page: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
items: Item[];
|
items: Item[];
|
||||||
sort: "recent" | "popular";
|
sort: "recent" | "popular" | "views" | "likes" | "comments";
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
||||||
@@ -35,7 +35,7 @@ function stripHtml(html: string | null | undefined): string {
|
|||||||
return html.replace(/<[^>]*>/g, "").trim();
|
return html.replace(/<[^>]*>/g, "").trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PostList({ boardId, sort = "recent", q, tag, author, authorId, start, end, variant = "default", newPostHref, titleHoverOrange, pageSizeOverride, compact }: { boardId?: string; sort?: "recent" | "popular"; q?: string; tag?: string; author?: string; authorId?: string; start?: string; end?: string; variant?: "default" | "board"; newPostHref?: string; titleHoverOrange?: boolean; pageSizeOverride?: number; compact?: boolean }) {
|
export function PostList({ boardId, sort = "recent", q, tag, author, authorId, start, end, variant = "default", newPostHref, titleHoverOrange, pageSizeOverride, compact }: { boardId?: string; sort?: "recent" | "popular" | "views" | "likes" | "comments"; q?: string; tag?: string; author?: string; authorId?: string; start?: string; end?: string; variant?: "default" | "board"; newPostHref?: string; titleHoverOrange?: boolean; pageSizeOverride?: number; compact?: boolean }) {
|
||||||
const sp = useSearchParams();
|
const sp = useSearchParams();
|
||||||
const listContainerRef = useRef<HTMLDivElement | null>(null);
|
const listContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [lockedMinHeight, setLockedMinHeight] = useState<number | null>(null);
|
const [lockedMinHeight, setLockedMinHeight] = useState<number | null>(null);
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { HeroBanner } from "@/app/components/HeroBanner";
|
import { HeroBanner } from "@/app/components/HeroBanner";
|
||||||
import PartnerCategorySection from "@/app/components/PartnerCategorySection";
|
// PartnerCategorySection removed per request
|
||||||
import Link from "next/link";
|
|
||||||
import HorizontalCardScroller from "@/app/components/HorizontalCardScroller";
|
import HorizontalCardScroller from "@/app/components/HorizontalCardScroller";
|
||||||
|
import PartnerScroller from "@/app/components/PartnerScroller";
|
||||||
|
import Link from "next/link";
|
||||||
import { PostList } from "@/app/components/PostList";
|
import { PostList } from "@/app/components/PostList";
|
||||||
import ProfileLabelIcon from "@/app/svgs/profilelableicon";
|
import ProfileLabelIcon from "@/app/svgs/profilelableicon";
|
||||||
import SearchIcon from "@/app/svgs/SearchIcon";
|
import SearchIcon from "@/app/svgs/SearchIcon";
|
||||||
@@ -201,37 +202,16 @@ export default async function Home({ searchParams }: { searchParams: Promise<{ s
|
|||||||
{/* 히어로 섹션: 상단 대형 비주얼 영역 (설정 온오프) */}
|
{/* 히어로 섹션: 상단 대형 비주얼 영역 (설정 온오프) */}
|
||||||
{showBanner && (
|
{showBanner && (
|
||||||
<section>
|
<section>
|
||||||
<HeroBanner />
|
<HeroBanner showPartnerCats={showPartnerShops} />
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 배너 아래: 파트너 카테고리 탭 + 파트너 리스트 */}
|
{/* 배너 아래: 카테고리 탭 섹션 제거됨 */}
|
||||||
<PartnerCategorySection />
|
|
||||||
|
|
||||||
{/* 제휴 샾 가로 스크롤 (설정 온오프, DB에서 불러오기)
|
{/* 제휴 샾 가로 스크롤 (설정 온오프, DB에서 불러오기)
|
||||||
- 우선 partners 테이블(관리자 페이지 관리 대상) 사용
|
- 우선 partners 테이블(관리자 페이지 관리 대상) 사용
|
||||||
- 없으면 partner_shops로 대체 */}
|
- 없으면 partner_shops로 대체 */}
|
||||||
{showPartnerShops && (async () => {
|
{showPartnerShops && <PartnerScroller />}
|
||||||
// 우선순위: 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 <HorizontalCardScroller items={items} />;
|
|
||||||
|
|
||||||
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 <HorizontalCardScroller items={shopItems} />;
|
|
||||||
})()}
|
|
||||||
|
|
||||||
{/* 1행: 프로필 + 선택된 보드 2개 (최대 2개) */}
|
{/* 1행: 프로필 + 선택된 보드 2개 (최대 2개) */}
|
||||||
{(firstTwo.length > 0) && (
|
{(firstTwo.length > 0) && (
|
||||||
|
|||||||
Reference in New Issue
Block a user