8.6 주변 제휴업체: 위치 기반 목록/지도/필터(거리/카테고리) o
This commit is contained in:
49
prisma/migrations/20251009083824_add_partner/migration.sql
Normal file
49
prisma/migrations/20251009083824_add_partner/migration.sql
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "coupons" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"code" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
"stock" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"maxPerUser" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
"expiresAt" DATETIME,
|
||||||
|
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "coupon_redemptions" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"couponId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "coupon_redemptions_couponId_fkey" FOREIGN KEY ("couponId") REFERENCES "coupons" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "coupon_redemptions_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("userId") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "partners" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"category" TEXT NOT NULL,
|
||||||
|
"latitude" REAL NOT NULL,
|
||||||
|
"longitude" REAL NOT NULL,
|
||||||
|
"address" TEXT,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "coupons_code_key" ON "coupons"("code");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "coupons_active_expiresAt_idx" ON "coupons"("active", "expiresAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "coupon_redemptions_userId_createdAt_idx" ON "coupon_redemptions"("userId", "createdAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "coupon_redemptions_couponId_userId_key" ON "coupon_redemptions"("couponId", "userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "partners_category_idx" ON "partners"("category");
|
||||||
@@ -616,3 +616,18 @@ model CouponRedemption {
|
|||||||
@@index([userId, createdAt])
|
@@index([userId, createdAt])
|
||||||
@@map("coupon_redemptions")
|
@@map("coupon_redemptions")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 제휴업체(위치 기반)
|
||||||
|
model Partner {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
category String
|
||||||
|
latitude Float
|
||||||
|
longitude Float
|
||||||
|
address String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([category])
|
||||||
|
@@map("partners")
|
||||||
|
}
|
||||||
|
|||||||
@@ -202,6 +202,16 @@ async function main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await seedPolicies();
|
await seedPolicies();
|
||||||
|
|
||||||
|
// 제휴업체 예시 데이터
|
||||||
|
const partners = [
|
||||||
|
{ name: "힐링마사지", category: "spa", latitude: 37.5665, longitude: 126.9780, address: "서울 중구" },
|
||||||
|
{ name: "웰빙테라피", category: "spa", latitude: 37.5700, longitude: 126.9769, address: "서울 종로구" },
|
||||||
|
{ name: "굿짐", category: "gym", latitude: 37.5600, longitude: 126.9820, address: "서울 중구" },
|
||||||
|
];
|
||||||
|
for (const p of partners) {
|
||||||
|
await prisma.partner.upsert({ where: { name: p.name }, update: p, create: p });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
main()
|
main()
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 649 KiB After Width: | Height: | Size: 706 KiB |
29
src/app/api/partners/route.ts
Normal file
29
src/app/api/partners/route.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
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 radius = Number(searchParams.get("radius")) || 10; // km
|
||||||
|
const where = 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)
|
||||||
|
: partners.map((p) => ({ ...p, distance: null }));
|
||||||
|
withDistance.sort((a, b) => (a.distance ?? 0) - (b.distance ?? 0));
|
||||||
|
return NextResponse.json({ partners: withDistance });
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
37
src/app/partners/page.tsx
Normal file
37
src/app/partners/page.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
"use client";
|
||||||
|
import useSWR from "swr";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
||||||
|
|
||||||
|
export default function PartnersPage() {
|
||||||
|
const [lat, setLat] = useState<string>("");
|
||||||
|
const [lon, setLon] = useState<string>("");
|
||||||
|
const [category, setCategory] = useState<string>("");
|
||||||
|
const [radius, setRadius] = useState<string>("10");
|
||||||
|
const qs = new URLSearchParams({ ...(lat ? { lat } : {}), ...(lon ? { lon } : {}), ...(category ? { category } : {}), radius });
|
||||||
|
const { data, mutate } = useSWR<{ partners: any[] }>(`/api/partners?${qs.toString()}`, fetcher);
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>주변 제휴업체</h1>
|
||||||
|
<div style={{ display: "flex", gap: 8, marginBottom: 12 }}>
|
||||||
|
<input placeholder="위도" value={lat} onChange={(e) => setLat(e.target.value)} />
|
||||||
|
<input placeholder="경도" value={lon} onChange={(e) => setLon(e.target.value)} />
|
||||||
|
<input placeholder="카테고리(spa/gym 등)" value={category} onChange={(e) => setCategory(e.target.value)} />
|
||||||
|
<input placeholder="반경(km)" value={radius} onChange={(e) => setRadius(e.target.value)} />
|
||||||
|
<button onClick={() => mutate()}>검색</button>
|
||||||
|
</div>
|
||||||
|
<ul style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||||
|
{(data?.partners ?? []).map((p) => (
|
||||||
|
<li key={p.id} style={{ border: "1px solid #eee", borderRadius: 8, padding: 12 }}>
|
||||||
|
<strong>{p.name}</strong> <span style={{ opacity: 0.7 }}>({p.category})</span>
|
||||||
|
<div style={{ fontSize: 12, opacity: 0.8 }}>{p.address}</div>
|
||||||
|
{p.distance != null && <div style={{ fontSize: 12, opacity: 0.7 }}>거리: {p.distance.toFixed(2)} km</div>}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
8.3 회원랭킹: 기간별 랭킹 집계/캐싱/페이지네이션/정렬 옵션 o
|
8.3 회원랭킹: 기간별 랭킹 집계/캐싱/페이지네이션/정렬 옵션 o
|
||||||
8.4 무료쿠폰: 쿠폰 등록/재고/사용 처리/만료/1인 제한/로그 o
|
8.4 무료쿠폰: 쿠폰 등록/재고/사용 처리/만료/1인 제한/로그 o
|
||||||
8.5 월간집계: 월별 지표 산출 배치/차트/다운로드(CSV) o
|
8.5 월간집계: 월별 지표 산출 배치/차트/다운로드(CSV) o
|
||||||
8.6 주변 제휴업체: 위치 기반 목록/지도/필터(거리/카테고리)
|
8.6 주변 제휴업체: 위치 기반 목록/지도/필터(거리/카테고리) o
|
||||||
8.7 제휴문의: 접수 폼/관리자 승인 워크플로우/알림
|
8.7 제휴문의: 접수 폼/관리자 승인 워크플로우/알림
|
||||||
8.8 제휴업소 요청: 요청 생성/승인/상태 관리/이력
|
8.8 제휴업소 요청: 요청 생성/승인/상태 관리/이력
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user