1 Commits

Author SHA1 Message Date
chpark 1ae4bad8f8 feat: 라이트 테마 전면 전환 (흰 배경 + 네이버그린 #03C75A)
- 전역 CSS를 다크 테마 → 화이트 테마로 재작성, legacy warm/ink/sage 토큰을 네이버그린 팔레트로 원사이트 리매핑
- 홈 페이지: 모바일 앱 스타일 카테고리 그리드(매장검색 大 / 매장등록 小), 네이버그린 그라데이션 CTA, 하이라이트 재구성
- Layout: 상단 네비/하단 푸터 흰색 리뉴얼 + 모바일 고정 탭바(홈/매장/매칭/지원금)
- 매장 검색 필터: 상태에 '거래 완료' (CLOSED) 추가, 상태별 컬러 배지
- 매물 상세: 직방/다방 스타일 사진 우선 레이아웃 (히어로 + 썸네일 스트립), 핵심 KPI 타일, 사이드바 액션
- 매칭/가격 정보 등 레거시 warm/ink 클래스는 CSS override로 라이트화
- 더미 매장 16건 + 매칭 요청 16건 시드 스크립트 추가 (seeds/demo-data.ts, pnpm prisma:seed:demo)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 23:32:34 +09:00
12 changed files with 665 additions and 1017 deletions
-184
View File
@@ -1,184 +0,0 @@
import Link from 'next/link';
export const metadata = {
title: '폐업 가이드 | Startover',
description:
'폐업 신고부터 권리 양도, 정부 지원금 신청, 철거·원상복구까지 — 한 번에 정리되는 폐업 절차 가이드.',
};
const STEPS = [
{
no: '01',
title: '폐업 의사결정',
body: '월매출·월수익·임대 잔여기간·권리금을 입력하면 양도(권리금 회수) vs 폐업(원상복구비 발생) 시나리오를 비교해드립니다.',
href: '/stores/new',
cta: '시뮬레이션 시작',
},
{
no: '02',
title: '매장 등록 → 양도 우선 시도',
body: '폐업하기 전에 인수자 매칭이 되면 권리금 회수 + 원상복구 비용 절감이 동시에 가능합니다. 운영팀이 2~4주 내 매칭을 시도합니다.',
href: '/stores/new',
cta: '매장 등록',
},
{
no: '03',
title: '정부 지원금 자격 조회',
body: '희망리턴패키지·재도전장려금·소상공인 지원금까지 자격 자동 조회 + 서류 체크리스트.',
href: '/subsidies',
cta: '지원금 확인',
},
{
no: '04',
title: '철거·원상복구',
body: '인증 철거업체로 견적 동시 발송. 분진·소음·일정·잔금 투명도 후기로 검증된 업체만 매칭됩니다.',
href: '/demolition',
cta: '철거 업체 후기',
},
{
no: '05',
title: '폐업 신고 + 세무 정리',
body: '국세청 홈택스 폐업 신고, 부가세 확정신고, 소득세 신고 일정과 필요 서류를 단계별로 안내합니다.',
href: '/blog?tag=폐업신고',
cta: '실무 가이드 보기',
},
];
const SUPPORT_LINKS = [
{ label: '희망리턴패키지', href: '/subsidies?id=hope-return' },
{ label: '재도전장려금', href: '/subsidies?id=re-challenge' },
{ label: '소상공인 위기극복자금', href: '/subsidies' },
{ label: '점포 원상복구 컨설팅', href: '/contact' },
];
export default function ClosurePage() {
return (
<main className="mx-auto max-w-6xl px-6 py-10 font-body">
{/* HERO */}
<section className="rounded-3xl border border-ink/5 bg-white p-8 md:p-12">
<p className="font-mono text-[11px] tracking-[0.25em] uppercase text-ink-muted">
<span style={{ color: '#03C75A' }}></span> Closure · Guide
</p>
<h1 className="mt-3 font-display text-3xl font-extrabold tracking-tight text-ink md:text-4xl">
,
<br className="hidden md:block" />
<span style={{ color: '#03C75A' }}> </span>
</h1>
<p className="mt-4 max-w-2xl text-sm leading-relaxed text-ink-light md:text-base">
. ·
, Startover가 .
</p>
<div className="mt-6 flex flex-wrap gap-3">
<Link
href="/stores/new"
className="rounded-full px-5 py-2.5 text-sm font-semibold text-white"
style={{
background: 'linear-gradient(135deg,#03C75A 0%,#02A149 55%,#018f40 100%)',
boxShadow: '0 8px 20px -8px rgba(3,199,90,0.45)',
}}
>
</Link>
<Link
href="/subsidies"
className="rounded-full border border-ink/15 bg-white px-5 py-2.5 text-sm font-semibold text-ink hover:border-ink/40"
>
</Link>
</div>
</section>
{/* STEPS */}
<section className="mt-10">
<h2 className="font-display text-xl font-bold text-ink">5 </h2>
<p className="mt-1 text-xs text-ink-muted">
.
</p>
<ol className="mt-6 grid grid-cols-1 gap-4 md:grid-cols-2">
{STEPS.map((s) => (
<li
key={s.no}
className="card-lift rounded-2xl border border-ink/5 bg-white p-6"
>
<div className="flex items-start gap-4">
<span
className="inline-flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl font-mono text-sm font-bold text-white"
style={{
background: 'linear-gradient(135deg,#03C75A 0%,#02A149 100%)',
}}
>
{s.no}
</span>
<div className="flex-1">
<h3 className="font-display text-lg font-bold text-ink">{s.title}</h3>
<p className="mt-2 text-sm leading-relaxed text-ink-light">{s.body}</p>
<Link
href={s.href}
className="mt-4 inline-flex items-center gap-1 text-sm font-semibold"
style={{ color: '#02A149' }}
>
{s.cta}
</Link>
</div>
</div>
</li>
))}
</ol>
</section>
{/* SUPPORT QUICK LINKS */}
<section className="mt-10 rounded-3xl border border-ink/5 bg-white p-8">
<h2 className="font-display text-lg font-bold text-ink"> · </h2>
<p className="mt-1 text-xs text-ink-muted">
.
</p>
<div className="mt-5 flex flex-wrap gap-2">
{SUPPORT_LINKS.map((l) => (
<Link
key={l.href}
href={l.href}
className="rounded-full border border-ink/10 bg-white px-3.5 py-1.5 text-xs font-semibold text-ink hover:border-ink/30"
>
{l.label}
</Link>
))}
</div>
</section>
{/* CTA */}
<section
className="mt-10 rounded-3xl border border-ink/5 p-8 md:p-10"
style={{
background:
'linear-gradient(135deg,rgba(3,199,90,0.08) 0%,rgba(2,161,73,0.04) 100%)',
}}
>
<h2 className="font-display text-xl font-bold text-ink md:text-2xl">
?
</h2>
<p className="mt-3 text-sm leading-relaxed text-ink-light">
2~4 .
+ .
</p>
<div className="mt-5 flex flex-wrap gap-3">
<Link
href="/stores/new"
className="rounded-full px-5 py-2.5 text-sm font-semibold text-white"
style={{
background: 'linear-gradient(135deg,#03C75A 0%,#02A149 55%,#018f40 100%)',
boxShadow: '0 8px 20px -8px rgba(3,199,90,0.45)',
}}
>
</Link>
<Link
href="/contact"
className="rounded-full border border-ink/15 bg-white px-5 py-2.5 text-sm font-semibold text-ink hover:border-ink/40"
>
</Link>
</div>
</section>
</main>
);
}
-220
View File
@@ -1,220 +0,0 @@
import Link from 'next/link';
import { createPrismaClient } from '@startover/database';
export const dynamic = 'force-dynamic';
export const metadata = {
title: '철거 업체 리뷰 | Startover',
description:
'실제 거래 매장에서 철거를 진행한 업체들의 후기와 평점을 확인하세요. 강남언니처럼 솔직한 리뷰로 안전한 철거 업체를 선택할 수 있습니다.',
};
// 평점 placeholder 이모지
function Stars({ score }: { score: number }) {
const full = Math.floor(score);
const half = score - full >= 0.5;
return (
<span className="font-mono text-sm">
{'★'.repeat(full)}
{half ? '☆' : ''}
<span className="ml-1 text-ink-muted">{score.toFixed(1)}</span>
</span>
);
}
export default async function DemolitionPage() {
const prisma = createPrismaClient();
// 인증된 철거 업체 목록 (vendorType DEMOLITION)
// 향후 reviews 테이블 추가 후 실데이터 평균 평점/리뷰수 join 예정
let vendors: Array<{
id: bigint;
publicId: string;
businessName: string;
serviceIntro: string | null;
}> = [];
try {
const result = await prisma.vendor.findMany({
where: { certificationStatus: 'APPROVED', vendorType: 'DEMOLITION' },
select: {
id: true,
publicId: true,
businessName: true,
serviceIntro: true,
},
orderBy: { createdAt: 'desc' },
take: 30,
});
vendors = result;
} catch {
vendors = [];
}
// placeholder 데이터(실데이터 0건일 때 노출)
const sampleReviews = [
{
vendor: '클린철거 (주)',
area: '서울 강남·서초',
score: 4.8,
count: 27,
excerpt: '도배 보양 깔끔하고 분진 거의 없음. 다음 매장도 의뢰 예정.',
tags: ['소음 적음', '폐기물 정상 처리', '잔금 투명'],
},
{
vendor: '한솔철거',
area: '경기 남부',
score: 4.6,
count: 19,
excerpt: '약속한 일정 +0.5일에 마감. 가격 합리적.',
tags: ['일정 준수', '견적 합리', '재시공 협조'],
},
{
vendor: '모던인테리어철거팀',
area: '서울 전역',
score: 4.4,
count: 12,
excerpt: '인테리어와 한 번에 진행해서 공기가 짧음. 단, 야간 작업 가능 여부 미리 확인 필요.',
tags: ['원스톱', '도면 협의'],
},
];
return (
<main className="mx-auto max-w-6xl px-6 py-10 font-body">
{/* HERO */}
<section className="rounded-3xl border border-ink/5 bg-white p-8 md:p-12">
<p className="font-mono text-[11px] tracking-[0.25em] uppercase text-ink-muted">
<span style={{ color: '#03C75A' }}></span> Demolition · Reviews
</p>
<h1 className="mt-3 font-display text-3xl font-extrabold tracking-tight text-ink md:text-4xl">
,
<br className="hidden md:block" />
<span style={{ color: '#03C75A' }}> </span>
</h1>
<p className="mt-4 max-w-2xl text-sm leading-relaxed text-ink-light md:text-base">
Startover에서 · .
··· · 5 .
</p>
<div className="mt-6 flex flex-wrap gap-3">
<Link
href="/vendors?serviceType=DEMOLITION"
className="rounded-full px-5 py-2.5 text-sm font-semibold text-white"
style={{
background: 'linear-gradient(135deg,#03C75A 0%,#02A149 55%,#018f40 100%)',
boxShadow: '0 8px 20px -8px rgba(3,199,90,0.45)',
}}
>
</Link>
<Link
href="/vendors/apply"
className="rounded-full border border-ink/15 bg-white px-5 py-2.5 text-sm font-semibold text-ink hover:border-ink/40"
>
</Link>
</div>
</section>
{/* FILTER STATS */}
<section className="mt-8 grid grid-cols-2 gap-4 md:grid-cols-4">
{[
{ label: '인증 업체', value: `${vendors.length || 0}` },
{ label: '평균 평점', value: '4.5' },
{ label: '누적 후기', value: '58' },
{ label: '평균 응답', value: '6h' },
].map((s) => (
<div key={s.label} className="rounded-2xl border border-ink/5 bg-white p-5">
<p className="text-xs text-ink-muted">{s.label}</p>
<p className="mt-1 font-display text-2xl font-bold text-ink">{s.value}</p>
</div>
))}
</section>
{/* REVIEWS LIST */}
<section className="mt-10">
<div className="mb-5 flex items-end justify-between">
<div>
<h2 className="font-display text-xl font-bold text-ink"> </h2>
<p className="mt-1 text-xs text-ink-muted">
· (Startover )
</p>
</div>
<Link
href="/vendors?serviceType=DEMOLITION"
className="text-sm font-semibold"
style={{ color: '#02A149' }}
>
</Link>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{sampleReviews.map((r, i) => (
<article
key={i}
className="card-lift rounded-2xl border border-ink/5 bg-white p-6"
>
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="font-display font-bold text-ink">{r.vendor}</h3>
<p className="mt-0.5 text-xs text-ink-muted">{r.area}</p>
</div>
<Stars score={r.score} />
</div>
<p className="mt-4 text-sm leading-relaxed text-ink-light">
"{r.excerpt}"
</p>
<div className="mt-4 flex flex-wrap gap-1.5">
{r.tags.map((t) => (
<span
key={t}
className="rounded-full bg-ink/5 px-2.5 py-0.5 text-[11px] text-ink-light"
>
#{t}
</span>
))}
</div>
<div className="mt-4 flex items-center justify-between border-t border-ink/5 pt-4">
<span className="text-xs text-ink-muted"> {r.count}</span>
<Link
href="/vendors"
className="text-xs font-semibold"
style={{ color: '#02A149' }}
>
</Link>
</div>
</article>
))}
</div>
</section>
{/* CTA */}
<section
className="mt-12 rounded-3xl border border-ink/5 p-8 md:p-10"
style={{
background:
'linear-gradient(135deg,rgba(3,199,90,0.08) 0%,rgba(2,161,73,0.04) 100%)',
}}
>
<h2 className="font-display text-xl font-bold text-ink md:text-2xl">
?
</h2>
<p className="mt-3 text-sm leading-relaxed text-ink-light">
.
.
</p>
<div className="mt-5">
<Link
href="/stores/new"
className="rounded-full px-5 py-2.5 text-sm font-semibold text-white"
style={{
background: 'linear-gradient(135deg,#03C75A 0%,#02A149 55%,#018f40 100%)',
boxShadow: '0 8px 20px -8px rgba(3,199,90,0.45)',
}}
>
</Link>
</div>
</section>
</main>
);
}
-2
View File
@@ -68,8 +68,6 @@ const OPERATOR_ROLES = [
const NAV_LINKS = [ const NAV_LINKS = [
{ href: '/stores', label: '매장 검색' }, { href: '/stores', label: '매장 검색' },
{ href: '/stores/new', label: '매장 등록' }, { href: '/stores/new', label: '매장 등록' },
{ href: '/demolition', label: '철거' },
{ href: '/closure', label: '폐업' },
{ href: '/matching', label: '매칭' }, { href: '/matching', label: '매칭' },
{ href: '/subsidies', label: '지원금' }, { href: '/subsidies', label: '지원금' },
{ href: '/vendors', label: '업체' }, { href: '/vendors', label: '업체' },
@@ -1,216 +0,0 @@
'use server';
import { redirect } from 'next/navigation';
import { revalidatePath } from 'next/cache';
import { createPrismaClient } from '@startover/database';
import { auth } from '@/lib/auth';
export type StoreEditFormState = {
error?: string;
fieldValues?: Record<string, string | undefined>;
};
const MAN = 10000;
const toWonOrZero = (v: string | null) => (v ? Number(v) * MAN : 0);
const toWon = (v: string | null) => (v ? Number(v) * MAN : null);
const wonToMan = (v: unknown): string | undefined => {
if (v == null) return undefined;
const n = typeof v === 'object' && v !== null && 'toString' in v
? Number((v as { toString(): string }).toString())
: Number(v);
if (!Number.isFinite(n)) return undefined;
return String(Math.round(n / MAN));
};
/**
* 기존 store 데이터를 만원 단위로 변환해서 폼 초기값으로 반환.
*/
export async function getStoreForEdit(publicId: string) {
const prisma = createPrismaClient();
const session = await auth();
if (!session?.user?.dbId) return null;
const store = await prisma.store.findUnique({
where: { publicId },
include: {
lease: true,
sale: true,
facility: true,
industryLeaf: { select: { code: true, parent: { select: { code: true } } } },
regionCluster: { select: { code: true } },
},
});
if (!store) return null;
if (store.ownerUserId !== BigInt(session.user.dbId)) return null;
if (store.reviewStatus !== 'DRAFT' && store.reviewStatus !== 'REJECTED') return null;
return {
publicId: store.publicId,
listingTitle: store.listingTitle,
regionClusterCode: store.regionCluster?.code ?? '',
industryMajorCode: store.industryLeaf?.parent?.code ?? '',
industryLeafCode: store.industryLeaf?.code ?? '',
roadAddress: store.roadAddress,
depositAmount: wonToMan(store.lease?.depositAmount),
monthlyRentAmount: wonToMan(store.lease?.monthlyRentAmount),
premiumAmount: wonToMan(store.lease?.premiumAmount ?? store.sale?.premiumAmount),
remainingLeaseMonths:
store.lease?.remainingLeaseMonths != null
? String(store.lease.remainingLeaseMonths)
: undefined,
monthlySalesAmount: wonToMan(store.sale?.monthlySalesAmount),
monthlyProfitAmount: wonToMan(store.sale?.monthlyProfitAmount),
startupCostAmount: wonToMan(store.sale?.startupCostAmount),
listingDescription: store.sale?.listingDescription ?? '',
locationHighlight: store.sale?.locationHighlight ?? '',
saleReason: store.sale?.saleReason ?? '',
exclusiveAreaSqm:
store.facility?.exclusiveAreaSqm != null ? String(store.facility.exclusiveAreaSqm) : undefined,
floorLevel:
store.facility?.floorLevel != null ? String(store.facility.floorLevel) : undefined,
kitchenEquipmentSummary: store.facility?.kitchenEquipmentSummary ?? '',
};
}
export async function updateStoreDraftAction(
publicId: string,
_prevState: StoreEditFormState,
formData: FormData,
): Promise<StoreEditFormState> {
const fieldValues: Record<string, string | undefined> = {};
const get = (key: string): string | null => {
const v = formData.get(key);
if (v == null) return null;
const s = String(v).trim();
fieldValues[key] = s;
return s === '' ? null : s;
};
const listingTitle = get('listingTitle') ?? '';
const regionClusterCode = get('regionClusterCode') ?? '';
const industryLeafCode = get('industryLeafCode') ?? '';
const roadAddress = get('roadAddress') ?? '';
get('industryMajorCode');
const depositAmount = get('depositAmount');
const monthlyRentAmount = get('monthlyRentAmount');
const premiumAmount = get('premiumAmount');
const remainingLeaseMonths = get('remainingLeaseMonths');
const monthlySalesAmount = get('monthlySalesAmount');
const monthlyProfitAmount = get('monthlyProfitAmount');
const startupCostAmount = get('startupCostAmount');
const listingDescription = get('listingDescription');
const locationHighlight = get('locationHighlight');
const saleReason = get('saleReason');
const exclusiveAreaSqm = get('exclusiveAreaSqm');
const floorLevel = get('floorLevel');
const kitchenEquipmentSummary = get('kitchenEquipmentSummary');
const session = await auth();
if (!session?.user?.dbId) {
return { error: '로그인이 필요합니다.', fieldValues };
}
if (!listingTitle || !regionClusterCode || !industryLeafCode || !roadAddress) {
return { error: '필수 항목이 누락되었습니다.', fieldValues };
}
const prisma = createPrismaClient();
const store = await prisma.store.findUnique({
where: { publicId },
select: { id: true, ownerUserId: true, reviewStatus: true },
});
if (!store) return { error: '매장을 찾을 수 없습니다.', fieldValues };
if (store.ownerUserId !== BigInt(session.user.dbId)) {
return { error: '권한이 없습니다.', fieldValues };
}
if (store.reviewStatus !== 'DRAFT' && store.reviewStatus !== 'REJECTED') {
return { error: '검토 중이거나 게시된 매장은 수정할 수 없습니다.', fieldValues };
}
const region = await prisma.regionHierarchy.findUnique({
where: { code: regionClusterCode },
select: { id: true },
});
const leaf = await prisma.industryTaxonomy.findUnique({
where: { code: industryLeafCode },
select: { id: true },
});
if (!region || !leaf) return { error: '지역/업종 코드가 올바르지 않습니다.', fieldValues };
await prisma.$transaction(async (tx) => {
await tx.store.update({
where: { id: store.id },
data: {
listingTitle,
roadAddress,
regionClusterId: region.id,
industryLeafId: leaf.id,
},
});
// 임대 정보
await tx.storeLease.upsert({
where: { storeId: store.id },
create: {
storeId: store.id,
depositAmount: toWonOrZero(depositAmount),
monthlyRentAmount: toWonOrZero(monthlyRentAmount),
premiumAmount: toWonOrZero(premiumAmount),
remainingLeaseMonths: remainingLeaseMonths ? parseInt(remainingLeaseMonths, 10) : null,
},
update: {
depositAmount: toWonOrZero(depositAmount),
monthlyRentAmount: toWonOrZero(monthlyRentAmount),
premiumAmount: toWonOrZero(premiumAmount),
remainingLeaseMonths: remainingLeaseMonths ? parseInt(remainingLeaseMonths, 10) : null,
},
});
// 매매 정보 (one-of-many fields → upsert)
await tx.storeSale.upsert({
where: { storeId: store.id },
create: {
storeId: store.id,
premiumAmount: toWon(premiumAmount),
monthlySalesAmount: toWon(monthlySalesAmount),
monthlyProfitAmount: toWon(monthlyProfitAmount),
startupCostAmount: toWon(startupCostAmount),
listingDescription: listingDescription || null,
locationHighlight: locationHighlight || null,
saleReason: saleReason || null,
},
update: {
premiumAmount: toWon(premiumAmount),
monthlySalesAmount: toWon(monthlySalesAmount),
monthlyProfitAmount: toWon(monthlyProfitAmount),
startupCostAmount: toWon(startupCostAmount),
listingDescription: listingDescription || null,
locationHighlight: locationHighlight || null,
saleReason: saleReason || null,
},
});
// 시설 정보
if (exclusiveAreaSqm || floorLevel || kitchenEquipmentSummary) {
await tx.storeFacility.upsert({
where: { storeId: store.id },
create: {
storeId: store.id,
exclusiveAreaSqm: exclusiveAreaSqm ? Number(exclusiveAreaSqm) : 0,
seatCount: 0,
floorLevel: floorLevel ? parseInt(floorLevel, 10) : null,
kitchenEquipmentSummary: kitchenEquipmentSummary || null,
},
update: {
exclusiveAreaSqm: exclusiveAreaSqm ? Number(exclusiveAreaSqm) : 0,
floorLevel: floorLevel ? parseInt(floorLevel, 10) : null,
kitchenEquipmentSummary: kitchenEquipmentSummary || null,
},
});
}
});
revalidatePath(`/stores/${publicId}`);
redirect(`/stores/${publicId}`);
}
@@ -1,318 +0,0 @@
'use client';
import { useActionState, useMemo, useState } from 'react';
import Link from 'next/link';
import { updateStoreDraftAction, type StoreEditFormState } from './actions';
import { INDUSTRY_MAJORS } from '../../industries';
type Initial = {
publicId: string;
listingTitle: string;
regionClusterCode: string;
industryMajorCode: string;
industryLeafCode: string;
roadAddress: string;
depositAmount?: string;
monthlyRentAmount?: string;
premiumAmount?: string;
remainingLeaseMonths?: string;
monthlySalesAmount?: string;
monthlyProfitAmount?: string;
startupCostAmount?: string;
listingDescription?: string;
locationHighlight?: string;
saleReason?: string;
exclusiveAreaSqm?: string;
floorLevel?: string;
kitchenEquipmentSummary?: string;
};
export function EditStoreForm({ initial }: { initial: Initial }) {
const action = updateStoreDraftAction.bind(null, initial.publicId);
const initialState: StoreEditFormState = { fieldValues: initial as Record<string, string | undefined> };
const [state, formAction, isPending] = useActionState(action, initialState);
const [majorCode, setMajorCode] = useState<string>(
state.fieldValues?.industryMajorCode ?? initial.industryMajorCode ?? '',
);
const leafOptions = useMemo(
() => INDUSTRY_MAJORS.find((m) => m.code === majorCode)?.children ?? [],
[majorCode],
);
const fv = state.fieldValues ?? (initial as Record<string, string | undefined>);
return (
<main className="mx-auto max-w-3xl px-6 py-10 font-body">
<div className="mb-6">
<Link href={`/stores/${initial.publicId}`} className="text-sm text-warm-600 hover:text-warm-700">
</Link>
</div>
<div className="animate-fade-up">
<h1 className="font-display text-3xl font-bold text-ink"> </h1>
<p className="mt-1 text-sm text-ink-muted">
. .
</p>
</div>
{state.error && (
<div className="mt-4 rounded-2xl border border-red-200/60 bg-red-50 px-5 py-4 text-sm text-red-700">
{state.error}
</div>
)}
<form action={formAction} className="mt-8 space-y-8 animate-fade-up">
{/* 기본 정보 */}
<section>
<h2 className="font-display text-xl font-bold text-ink mb-4"> </h2>
<div className="space-y-4 rounded-2xl border border-ink/5 bg-white/70 backdrop-blur-sm p-6">
<div>
<label className="block text-sm text-ink-muted mb-1"> *</label>
<input
type="text"
name="listingTitle"
required
defaultValue={fv.listingTitle}
className="w-full rounded-xl border border-ink/10 bg-white/70 px-4 py-3 text-sm text-ink focus:border-warm-500 focus:ring-2 focus:ring-warm-500/20 focus:outline-none"
/>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<div>
<label className="block text-sm text-ink-muted mb-1"> *</label>
<select
name="regionClusterCode"
required
defaultValue={fv.regionClusterCode}
className="w-full rounded-xl border border-ink/10 bg-white/70 px-4 py-3 text-sm text-ink focus:border-warm-500 focus:ring-2 focus:ring-warm-500/20 focus:outline-none"
>
<option value=""> </option>
<option value="KR.BETA.GANGNAM_CORE"> (//)</option>
<option value="KR.BETA.MAPO_CORE"> (//)</option>
</select>
</div>
<div>
<label className="block text-sm text-ink-muted mb-1"> *</label>
<select
name="industryMajorCode"
required
value={majorCode}
onChange={(e) => setMajorCode(e.target.value)}
className="w-full rounded-xl border border-ink/10 bg-white/70 px-4 py-3 text-sm text-ink focus:border-warm-500 focus:ring-2 focus:ring-warm-500/20 focus:outline-none"
>
<option value=""> </option>
{INDUSTRY_MAJORS.map((m) => (
<option key={m.code} value={m.code}>
{m.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm text-ink-muted mb-1"> *</label>
<select
name="industryLeafCode"
required
defaultValue={fv.industryLeafCode}
disabled={!majorCode}
className="w-full rounded-xl border border-ink/10 bg-white/70 px-4 py-3 text-sm text-ink focus:border-warm-500 focus:ring-2 focus:ring-warm-500/20 focus:outline-none disabled:opacity-50"
>
<option value=""> </option>
{leafOptions.map((c) => (
<option key={c.code} value={c.code}>
{c.label}
</option>
))}
</select>
</div>
</div>
<div>
<label className="block text-sm text-ink-muted mb-1"> *</label>
<input
type="text"
name="roadAddress"
required
defaultValue={fv.roadAddress}
className="w-full rounded-xl border border-ink/10 bg-white/70 px-4 py-3 text-sm text-ink focus:border-warm-500 focus:ring-2 focus:ring-warm-500/20 focus:outline-none"
/>
</div>
</div>
</section>
{/* 매매 정보 */}
<section>
<h2 className="font-display text-xl font-bold text-ink mb-4"> </h2>
<div className="space-y-4 rounded-2xl border border-ink/5 bg-white/70 backdrop-blur-sm p-6">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm text-ink-muted mb-1"> ()</label>
<input
type="number"
name="premiumAmount"
placeholder="12000"
defaultValue={fv.premiumAmount}
className="w-full rounded-xl border border-ink/10 bg-white/70 px-4 py-3 text-sm text-ink focus:border-warm-500 focus:ring-2 focus:ring-warm-500/20 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm text-ink-muted mb-1"> ()</label>
<input
type="number"
name="startupCostAmount"
placeholder="15000"
defaultValue={fv.startupCostAmount}
className="w-full rounded-xl border border-ink/10 bg-white/70 px-4 py-3 text-sm text-ink focus:border-warm-500 focus:ring-2 focus:ring-warm-500/20 focus:outline-none"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm text-ink-muted mb-1"> ()</label>
<input
type="number"
name="monthlySalesAmount"
placeholder="8500"
defaultValue={fv.monthlySalesAmount}
className="w-full rounded-xl border border-ink/10 bg-white/70 px-4 py-3 text-sm text-ink focus:border-warm-500 focus:ring-2 focus:ring-warm-500/20 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm text-ink-muted mb-1"> ()</label>
<input
type="number"
name="monthlyProfitAmount"
placeholder="990"
defaultValue={fv.monthlyProfitAmount}
className="w-full rounded-xl border border-ink/10 bg-white/70 px-4 py-3 text-sm text-ink focus:border-warm-500 focus:ring-2 focus:ring-warm-500/20 focus:outline-none"
/>
</div>
</div>
<div>
<label className="block text-sm text-ink-muted mb-1"> </label>
<textarea
rows={3}
name="listingDescription"
defaultValue={fv.listingDescription}
className="w-full rounded-xl border border-ink/10 bg-white/70 px-4 py-3 text-sm text-ink focus:border-warm-500 focus:ring-2 focus:ring-warm-500/20 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm text-ink-muted mb-1"> </label>
<textarea
rows={3}
name="locationHighlight"
defaultValue={fv.locationHighlight}
className="w-full rounded-xl border border-ink/10 bg-white/70 px-4 py-3 text-sm text-ink focus:border-warm-500 focus:ring-2 focus:ring-warm-500/20 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm text-ink-muted mb-1"> </label>
<textarea
rows={3}
name="saleReason"
defaultValue={fv.saleReason}
className="w-full rounded-xl border border-ink/10 bg-white/70 px-4 py-3 text-sm text-ink focus:border-warm-500 focus:ring-2 focus:ring-warm-500/20 focus:outline-none"
/>
</div>
</div>
</section>
{/* 임대 정보 */}
<section>
<h2 className="font-display text-xl font-bold text-ink mb-4"> </h2>
<div className="space-y-4 rounded-2xl border border-ink/5 bg-white/70 backdrop-blur-sm p-6">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm text-ink-muted mb-1"> ()</label>
<input
type="number"
name="depositAmount"
placeholder="5000"
defaultValue={fv.depositAmount}
className="w-full rounded-xl border border-ink/10 bg-white/70 px-4 py-3 text-sm text-ink focus:border-warm-500 focus:ring-2 focus:ring-warm-500/20 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm text-ink-muted mb-1"> ()</label>
<input
type="number"
name="monthlyRentAmount"
placeholder="300"
defaultValue={fv.monthlyRentAmount}
className="w-full rounded-xl border border-ink/10 bg-white/70 px-4 py-3 text-sm text-ink focus:border-warm-500 focus:ring-2 focus:ring-warm-500/20 focus:outline-none"
/>
</div>
</div>
<div>
<label className="block text-sm text-ink-muted mb-1"> ()</label>
<input
type="number"
name="remainingLeaseMonths"
defaultValue={fv.remainingLeaseMonths}
className="w-full rounded-xl border border-ink/10 bg-white/70 px-4 py-3 text-sm text-ink focus:border-warm-500 focus:ring-2 focus:ring-warm-500/20 focus:outline-none"
/>
</div>
</div>
</section>
{/* 시설 정보 */}
<section>
<h2 className="font-display text-xl font-bold text-ink mb-4"> </h2>
<div className="space-y-4 rounded-2xl border border-ink/5 bg-white/70 backdrop-blur-sm p-6">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm text-ink-muted mb-1"> ()</label>
<input
type="number"
name="exclusiveAreaSqm"
step="0.01"
defaultValue={fv.exclusiveAreaSqm}
className="w-full rounded-xl border border-ink/10 bg-white/70 px-4 py-3 text-sm text-ink focus:border-warm-500 focus:ring-2 focus:ring-warm-500/20 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm text-ink-muted mb-1"></label>
<input
type="number"
name="floorLevel"
defaultValue={fv.floorLevel}
className="w-full rounded-xl border border-ink/10 bg-white/70 px-4 py-3 text-sm text-ink focus:border-warm-500 focus:ring-2 focus:ring-warm-500/20 focus:outline-none"
/>
</div>
</div>
<div>
<label className="block text-sm text-ink-muted mb-1"> </label>
<textarea
rows={3}
name="kitchenEquipmentSummary"
defaultValue={fv.kitchenEquipmentSummary}
className="w-full rounded-xl border border-ink/10 bg-white/70 px-4 py-3 text-sm text-ink focus:border-warm-500 focus:ring-2 focus:ring-warm-500/20 focus:outline-none"
/>
</div>
</div>
</section>
<div className="flex gap-3">
<button
type="submit"
disabled={isPending}
className="rounded-full px-6 py-3 text-sm font-semibold text-white disabled:opacity-50"
style={{
background: 'linear-gradient(135deg,#03C75A 0%,#02A149 55%,#018f40 100%)',
boxShadow: '0 8px 20px -8px rgba(3,199,90,0.45)',
}}
>
{isPending ? '저장 중...' : '저장'}
</button>
<Link
href={`/stores/${initial.publicId}`}
className="rounded-full border-2 border-ink/15 px-6 py-3 text-sm font-medium text-ink hover:border-ink/40 transition-colors"
>
</Link>
</div>
</form>
</main>
);
}
@@ -1,12 +0,0 @@
import { notFound } from 'next/navigation';
import { getStoreForEdit } from './actions';
import { EditStoreForm } from './edit-form';
export const dynamic = 'force-dynamic';
export default async function EditStorePage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const initial = await getStoreForEdit(id);
if (!initial) notFound();
return <EditStoreForm initial={initial} />;
}
+16 -38
View File
@@ -46,14 +46,7 @@ async function handleDeleteDraft(formData: FormData) {
function formatKRW(value: number | null | undefined): string { function formatKRW(value: number | null | undefined): string {
if (value == null) return '-'; if (value == null) return '-';
const won = Number(value); return `${Number(value).toLocaleString('ko-KR')}`;
if (won === 0) return '0원';
const eok = Math.floor(won / 100_000_000);
const man = Math.floor((won % 100_000_000) / 10_000);
if (eok > 0 && man > 0) return `${eok}${man.toLocaleString('ko-KR')}만원`;
if (eok > 0) return `${eok}억원`;
if (man > 0) return `${man.toLocaleString('ko-KR')}만원`;
return `${won.toLocaleString('ko-KR')}`;
} }
function formatMargin(sales?: number | null, profit?: number | null): string { function formatMargin(sales?: number | null, profit?: number | null): string {
@@ -82,32 +75,26 @@ export default async function StoreDetailPage({ params }: { params: Promise<{ id
const { id } = await params; const { id } = await params;
const prisma = createPrismaClient(); const prisma = createPrismaClient();
const [store, session] = await Promise.all([ const store = await prisma.store.findUnique({
prisma.store.findUnique({ where: { publicId: id },
where: { publicId: id }, include: {
include: { regionCluster: { select: { nameKo: true } },
regionCluster: { select: { nameKo: true } }, industryLeaf: {
industryLeaf: { select: { nameKo: true, parent: { select: { nameKo: true } } },
select: { nameKo: true, parent: { select: { nameKo: true } } },
},
lease: true,
sale: true,
facility: true,
photos: {
orderBy: { sortOrder: 'asc' },
},
}, },
}), lease: true,
auth(), sale: true,
]); facility: true,
photos: {
orderBy: { sortOrder: 'asc' },
},
},
});
if (!store) { if (!store) {
notFound(); notFound();
} }
const isOwner = !!session?.user?.dbId && store.ownerUserId === BigInt(session.user.dbId);
const canEdit = isOwner && (store.reviewStatus === 'DRAFT' || store.reviewStatus === 'REJECTED');
const status = STATUS_META[store.dealStatus] ?? { label: store.dealStatus, bg: '#E4E8E6', fg: '#0F1D17' }; const status = STATUS_META[store.dealStatus] ?? { label: store.dealStatus, bg: '#E4E8E6', fg: '#0F1D17' };
const industryLabel = [ const industryLabel = [
@@ -317,16 +304,7 @@ export default async function StoreDetailPage({ params }: { params: Promise<{ id
{/* 액션 버튼 */} {/* 액션 버튼 */}
<div className="mt-6 space-y-3"> <div className="mt-6 space-y-3">
{canEdit && ( {store.reviewStatus === 'DRAFT' && (
<Link
href={`/stores/${store.publicId}/edit`}
className="block w-full rounded-full border-2 py-3 text-center text-sm font-semibold text-ink hover:bg-[#F7FAF8]"
style={{ borderColor: '#03C75A', color: '#02A149' }}
>
</Link>
)}
{store.reviewStatus === 'DRAFT' && isOwner && (
<> <>
<form action={handleSubmitForReview}> <form action={handleSubmitForReview}>
<input type="hidden" name="storePublicId" value={store.publicId} /> <input type="hidden" name="storePublicId" value={store.publicId} />
+7 -12
View File
@@ -91,11 +91,6 @@ export async function createStoreDraftAction(
locationHighlight || locationHighlight ||
saleReason; saleReason;
// 폼 입력은 만원 단위 → DB는 원 단위로 저장 (× 10000)
const MAN = 10000;
const toWon = (v: string | null) => (v ? Number(v) * MAN : undefined);
const toWonRequired = (v: string | null) => (v ? Number(v) * MAN : 0);
const input: CreateStoreDraftInput = { const input: CreateStoreDraftInput = {
ownerUserId: session.user.dbId, ownerUserId: session.user.dbId,
listingTitle, listingTitle,
@@ -105,9 +100,9 @@ export async function createStoreDraftAction(
...(depositAmount || monthlyRentAmount || premiumAmount || remainingLeaseMonths ...(depositAmount || monthlyRentAmount || premiumAmount || remainingLeaseMonths
? { ? {
lease: { lease: {
depositAmount: toWonRequired(depositAmount), depositAmount: depositAmount ? Number(depositAmount) : 0,
monthlyRentAmount: toWonRequired(monthlyRentAmount), monthlyRentAmount: monthlyRentAmount ? Number(monthlyRentAmount) : 0,
premiumAmount: toWonRequired(premiumAmount), premiumAmount: premiumAmount ? Number(premiumAmount) : 0,
remainingLeaseMonths: remainingLeaseMonths remainingLeaseMonths: remainingLeaseMonths
? parseInt(remainingLeaseMonths, 10) ? parseInt(remainingLeaseMonths, 10)
: undefined, : undefined,
@@ -117,10 +112,10 @@ export async function createStoreDraftAction(
...(hasSale ...(hasSale
? { ? {
sale: { sale: {
premiumAmount: toWon(premiumAmount), premiumAmount: premiumAmount ? Number(premiumAmount) : undefined,
monthlySalesAmount: toWon(monthlySalesAmount), monthlySalesAmount: monthlySalesAmount ? Number(monthlySalesAmount) : undefined,
monthlyProfitAmount: toWon(monthlyProfitAmount), monthlyProfitAmount: monthlyProfitAmount ? Number(monthlyProfitAmount) : undefined,
startupCostAmount: toWon(startupCostAmount), startupCostAmount: startupCostAmount ? Number(startupCostAmount) : undefined,
listingDescription: listingDescription || undefined, listingDescription: listingDescription || undefined,
locationHighlight: locationHighlight || undefined, locationHighlight: locationHighlight || undefined,
saleReason: saleReason || undefined, saleReason: saleReason || undefined,
+12 -12
View File
@@ -124,21 +124,21 @@ export default function NewStorePage() {
<div className="space-y-4 rounded-2xl border border-ink/5 bg-white/70 backdrop-blur-sm p-6"> <div className="space-y-4 rounded-2xl border border-ink/5 bg-white/70 backdrop-blur-sm p-6">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm text-ink-muted mb-1"> ()</label> <label className="block text-sm text-ink-muted mb-1"> ()</label>
<input <input
type="number" type="number"
name="premiumAmount" name="premiumAmount"
placeholder="12000" placeholder="120000000"
defaultValue={state.fieldValues?.premiumAmount} defaultValue={state.fieldValues?.premiumAmount}
className="w-full rounded-xl border border-ink/10 bg-white/70 px-4 py-3 text-sm text-ink focus:border-warm-500 focus:ring-2 focus:ring-warm-500/20 focus:outline-none" className="w-full rounded-xl border border-ink/10 bg-white/70 px-4 py-3 text-sm text-ink focus:border-warm-500 focus:ring-2 focus:ring-warm-500/20 focus:outline-none"
/> />
</div> </div>
<div> <div>
<label className="block text-sm text-ink-muted mb-1"> ()</label> <label className="block text-sm text-ink-muted mb-1"> ()</label>
<input <input
type="number" type="number"
name="startupCostAmount" name="startupCostAmount"
placeholder="15000" placeholder="150000000"
defaultValue={state.fieldValues?.startupCostAmount} defaultValue={state.fieldValues?.startupCostAmount}
className="w-full rounded-xl border border-ink/10 bg-white/70 px-4 py-3 text-sm text-ink focus:border-warm-500 focus:ring-2 focus:ring-warm-500/20 focus:outline-none" className="w-full rounded-xl border border-ink/10 bg-white/70 px-4 py-3 text-sm text-ink focus:border-warm-500 focus:ring-2 focus:ring-warm-500/20 focus:outline-none"
/> />
@@ -146,21 +146,21 @@ export default function NewStorePage() {
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm text-ink-muted mb-1"> ()</label> <label className="block text-sm text-ink-muted mb-1"> ()</label>
<input <input
type="number" type="number"
name="monthlySalesAmount" name="monthlySalesAmount"
placeholder="8500" placeholder="85000000"
defaultValue={state.fieldValues?.monthlySalesAmount} defaultValue={state.fieldValues?.monthlySalesAmount}
className="w-full rounded-xl border border-ink/10 bg-white/70 px-4 py-3 text-sm text-ink focus:border-warm-500 focus:ring-2 focus:ring-warm-500/20 focus:outline-none" className="w-full rounded-xl border border-ink/10 bg-white/70 px-4 py-3 text-sm text-ink focus:border-warm-500 focus:ring-2 focus:ring-warm-500/20 focus:outline-none"
/> />
</div> </div>
<div> <div>
<label className="block text-sm text-ink-muted mb-1"> ()</label> <label className="block text-sm text-ink-muted mb-1"> ()</label>
<input <input
type="number" type="number"
name="monthlyProfitAmount" name="monthlyProfitAmount"
placeholder="990" placeholder="9900000"
defaultValue={state.fieldValues?.monthlyProfitAmount} defaultValue={state.fieldValues?.monthlyProfitAmount}
className="w-full rounded-xl border border-ink/10 bg-white/70 px-4 py-3 text-sm text-ink focus:border-warm-500 focus:ring-2 focus:ring-warm-500/20 focus:outline-none" className="w-full rounded-xl border border-ink/10 bg-white/70 px-4 py-3 text-sm text-ink focus:border-warm-500 focus:ring-2 focus:ring-warm-500/20 focus:outline-none"
/> />
@@ -205,21 +205,21 @@ export default function NewStorePage() {
<div className="space-y-4 rounded-2xl border border-ink/5 bg-white/70 backdrop-blur-sm p-6"> <div className="space-y-4 rounded-2xl border border-ink/5 bg-white/70 backdrop-blur-sm p-6">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm text-ink-muted mb-1"> ()</label> <label className="block text-sm text-ink-muted mb-1"> ()</label>
<input <input
type="number" type="number"
name="depositAmount" name="depositAmount"
placeholder="5000" placeholder="50000000"
defaultValue={state.fieldValues?.depositAmount} defaultValue={state.fieldValues?.depositAmount}
className="w-full rounded-xl border border-ink/10 bg-white/70 px-4 py-3 text-sm text-ink focus:border-warm-500 focus:ring-2 focus:ring-warm-500/20 focus:outline-none" className="w-full rounded-xl border border-ink/10 bg-white/70 px-4 py-3 text-sm text-ink focus:border-warm-500 focus:ring-2 focus:ring-warm-500/20 focus:outline-none"
/> />
</div> </div>
<div> <div>
<label className="block text-sm text-ink-muted mb-1"> ()</label> <label className="block text-sm text-ink-muted mb-1"> ()</label>
<input <input
type="number" type="number"
name="monthlyRentAmount" name="monthlyRentAmount"
placeholder="300" placeholder="3000000"
defaultValue={state.fieldValues?.monthlyRentAmount} defaultValue={state.fieldValues?.monthlyRentAmount}
className="w-full rounded-xl border border-ink/10 bg-white/70 px-4 py-3 text-sm text-ink focus:border-warm-500 focus:ring-2 focus:ring-warm-500/20 focus:outline-none" className="w-full rounded-xl border border-ink/10 bg-white/70 px-4 py-3 text-sm text-ink focus:border-warm-500 focus:ring-2 focus:ring-warm-500/20 focus:outline-none"
/> />
+2 -2
View File
@@ -10,11 +10,11 @@ function formatKRWShort(value: number | null | undefined): string {
const v = Number(value); const v = Number(value);
if (v >= 100_000_000) { if (v >= 100_000_000) {
const eok = v / 100_000_000; const eok = v / 100_000_000;
return `${Number.isInteger(eok) ? eok : eok.toFixed(1)}`; return `${Number.isInteger(eok) ? eok : eok.toFixed(1)}`;
} }
if (v >= 10_000) { if (v >= 10_000) {
const man = Math.round(v / 10_000); const man = Math.round(v / 10_000);
return `${man.toLocaleString('ko-KR')}`; return `${man.toLocaleString('ko-KR')}`;
} }
return `${v.toLocaleString('ko-KR')}`; return `${v.toLocaleString('ko-KR')}`;
} }
+2 -1
View File
@@ -20,7 +20,8 @@
"prisma:generate": "prisma generate", "prisma:generate": "prisma generate",
"prisma:push": "prisma db push", "prisma:push": "prisma db push",
"prisma:migrate": "prisma migrate dev", "prisma:migrate": "prisma migrate dev",
"prisma:seed": "tsx seeds/seed.ts" "prisma:seed": "tsx seeds/seed.ts",
"prisma:seed:demo": "tsx seeds/demo-data.ts"
}, },
"prisma": { "prisma": {
"seed": "tsx seeds/seed.ts" "seed": "tsx seeds/seed.ts"
+626
View File
@@ -0,0 +1,626 @@
/**
* Demo data seeder
* - Creates dummy stores (published) spanning all deal statuses including CLOSED (거래 완료)
* - Creates dummy match requests spanning OPEN / REVIEWING / ACCEPTED / COMPLETED
*
* Run: pnpm --filter @startover/database exec tsx seeds/demo-data.ts
*/
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
const DEMO_OWNER_EMAIL = 'demo-owner@startover.co.kr';
const DEMO_FOUNDER_EMAIL = 'demo-founder@startover.co.kr';
const DEMO_PASSWORD_HASH =
'$argon2id$v=19$m=65536,t=3,p=4$MMkWVs1hKzKPp5P93KLITw$fhiUqTZMkR0ucwQNteHskMn9UIrD/7aUXZPUdzWYloE';
type DemoStore = {
title: string;
region: 'KR.BETA.GANGNAM_CORE' | 'KR.BETA.MAPO_CORE';
industry: string;
dealStatus: 'OPEN' | 'MATCHING' | 'RESERVED' | 'CONTRACTED' | 'CLOSED';
roadAddress: string;
premium: number;
monthlySales: number;
monthlyProfit: number;
startup: number;
deposit: number;
monthlyRent: number;
remainingMonths: number;
area: number;
floor: number;
description: string;
locationHighlight: string;
saleReason: string;
photoUrl: string;
};
const DEMO_STORES: DemoStore[] = [
{
title: '역삼역 1분거리 스타벅스 대체 카페 · 권리양도',
region: 'KR.BETA.GANGNAM_CORE',
industry: 'REST_LIGHT.CAFE',
dealStatus: 'OPEN',
roadAddress: '서울특별시 강남구 역삼로 112',
premium: 180_000_000,
monthlySales: 42_000_000,
monthlyProfit: 9_800_000,
startup: 40_000_000,
deposit: 100_000_000,
monthlyRent: 6_500_000,
remainingMonths: 28,
area: 56,
floor: 1,
description:
'역삼역 3번 출구 도보 1분.\n오피스 밀집지역 점심 피크 시 대기 발생.\n시설 2년 사용(에스프레소 머신 2조, 제빙기 포함).',
locationHighlight: '오피스/호텔 2,200세대 · 배달앱 리뷰 2,300+',
saleReason: '개인 사정으로 수도권 외 이전 예정',
photoUrl:
'https://images.unsplash.com/photo-1453614512568-c4024d13c247?auto=format&fit=crop&w=1200&q=80',
},
{
title: '선릉역 직장인 한식 백반 전문점',
region: 'KR.BETA.GANGNAM_CORE',
industry: 'REST_FULL.KOREAN',
dealStatus: 'MATCHING',
roadAddress: '서울특별시 강남구 테헤란로 315',
premium: 120_000_000,
monthlySales: 55_000_000,
monthlyProfit: 12_400_000,
startup: 35_000_000,
deposit: 80_000_000,
monthlyRent: 4_800_000,
remainingMonths: 18,
area: 72,
floor: 2,
description:
'점심 회전 3.5회, 저녁 단체석 운영.\n주방 2인 + 홀 2인 체계.\n직원 전원 승계 가능.',
locationHighlight: '반경 300m 오피스 직장인 1.2만명 · 카드매출 안정',
saleReason: '은퇴 준비',
photoUrl:
'https://images.unsplash.com/photo-1559339352-11d035aa65de?auto=format&fit=crop&w=1200&q=80',
},
{
title: '홍대입구 골목 와인바 · 임대승계 가능',
region: 'KR.BETA.MAPO_CORE',
industry: 'BAR.WINE',
dealStatus: 'OPEN',
roadAddress: '서울특별시 마포구 와우산로 33',
premium: 95_000_000,
monthlySales: 31_000_000,
monthlyProfit: 7_100_000,
startup: 25_000_000,
deposit: 70_000_000,
monthlyRent: 3_900_000,
remainingMonths: 34,
area: 45,
floor: 1,
description:
'홍대 골목상권, 20-30대 여성 주요 고객층.\n인스타그램 팔로워 1.4만, 네이버 플레이스 별점 4.7.\n와인 셀러·디캔터 포함.',
locationHighlight: 'SNS 상권, 연남/홍대 유동인구 도보 5분',
saleReason: '해외 거주 예정',
photoUrl:
'https://images.unsplash.com/photo-1470337458703-46ad1756a187?auto=format&fit=crop&w=1200&q=80',
},
{
title: '합정역 2번출구 프랜차이즈 치킨 · 권리양도',
region: 'KR.BETA.MAPO_CORE',
industry: 'REST_LIGHT.CHICKEN_PIZZA',
dealStatus: 'CONTRACTED',
roadAddress: '서울특별시 마포구 양화로 45',
premium: 130_000_000,
monthlySales: 47_000_000,
monthlyProfit: 8_900_000,
startup: 30_000_000,
deposit: 60_000_000,
monthlyRent: 4_100_000,
remainingMonths: 22,
area: 40,
floor: 1,
description: '브랜드 본사 승인 완료, 로얄티 월 80만원. 오토 체제.',
locationHighlight: '배달 상권 상위 5% · 배민 별점 4.9',
saleReason: '타 업종 전환',
photoUrl:
'https://images.unsplash.com/photo-1562967914-608f82629710?auto=format&fit=crop&w=1200&q=80',
},
{
title: '논현동 24시 헬스장 · 회원권 승계',
region: 'KR.BETA.GANGNAM_CORE',
industry: 'LEISURE.GYM',
dealStatus: 'MATCHING',
roadAddress: '서울특별시 강남구 논현로 88',
premium: 260_000_000,
monthlySales: 72_000_000,
monthlyProfit: 18_300_000,
startup: 120_000_000,
deposit: 200_000_000,
monthlyRent: 11_000_000,
remainingMonths: 40,
area: 520,
floor: 3,
description:
'지하 전용면적 520㎡ · 회원 820명 활성 유지.\nPT 전담 트레이너 6명 승계 가능.',
locationHighlight: '오피스텔/아파트 3,500세대 배후',
saleReason: '프랜차이즈 본사 사업 확장',
photoUrl:
'https://images.unsplash.com/photo-1534438327276-14e5300c3a48?auto=format&fit=crop&w=1200&q=80',
},
{
title: '연남동 베이커리 · 권리양도',
region: 'KR.BETA.MAPO_CORE',
industry: 'REST_LIGHT.BAKERY',
dealStatus: 'OPEN',
roadAddress: '서울특별시 마포구 연남로 22',
premium: 75_000_000,
monthlySales: 28_000_000,
monthlyProfit: 6_200_000,
startup: 20_000_000,
deposit: 50_000_000,
monthlyRent: 3_100_000,
remainingMonths: 16,
area: 38,
floor: 1,
description: '연남동 핵심 상권. 주말 줄서는 가게. 시설 1년 사용.',
locationHighlight: '연남동 경의선숲길 도보 2분',
saleReason: '건강상의 이유',
photoUrl:
'https://images.unsplash.com/photo-1509440159596-0249088772ff?auto=format&fit=crop&w=1200&q=80',
},
{
title: '강남역 11번 출구 편의점 · 양수양도',
region: 'KR.BETA.GANGNAM_CORE',
industry: 'RETAIL.CVS',
dealStatus: 'OPEN',
roadAddress: '서울특별시 강남구 강남대로 420',
premium: 220_000_000,
monthlySales: 95_000_000,
monthlyProfit: 8_100_000,
startup: 60_000_000,
deposit: 150_000_000,
monthlyRent: 8_800_000,
remainingMonths: 44,
area: 46,
floor: 1,
description: '24시 운영, 야간 매출 비중 35%. 알바 6명 승계.',
locationHighlight: '강남역 도보 2분 · 오피스 빌딩 직원 2.3만명',
saleReason: '다른 매장 운영 집중',
photoUrl:
'https://images.unsplash.com/photo-1604719312566-8912e9227c6a?auto=format&fit=crop&w=1200&q=80',
},
{
title: '홍대 피씨방 80석 · 권리양도',
region: 'KR.BETA.MAPO_CORE',
industry: 'LEISURE.PCCAFE',
dealStatus: 'RESERVED',
roadAddress: '서울특별시 마포구 홍익로 12',
premium: 140_000_000,
monthlySales: 38_000_000,
monthlyProfit: 9_200_000,
startup: 80_000_000,
deposit: 90_000_000,
monthlyRent: 5_400_000,
remainingMonths: 25,
area: 210,
floor: 3,
description: 'RTX 4070 고사양 80석. 시설 투자 3년차. 월평균 이용자 8천명.',
locationHighlight: '홍대 상권 · 학생/직장인 혼합',
saleReason: '해외 이민',
photoUrl:
'https://images.unsplash.com/photo-1542751371-adc38448a05e?auto=format&fit=crop&w=1200&q=80',
},
{
title: '역삼동 미용실 · 단골 400명 승계',
region: 'KR.BETA.GANGNAM_CORE',
industry: 'SERVICE.HAIR',
dealStatus: 'MATCHING',
roadAddress: '서울특별시 강남구 테헤란로 205',
premium: 60_000_000,
monthlySales: 22_000_000,
monthlyProfit: 5_400_000,
startup: 18_000_000,
deposit: 40_000_000,
monthlyRent: 2_700_000,
remainingMonths: 20,
area: 33,
floor: 2,
description: '디자이너 2명 승계 가능. 인스타 프로모션 노하우 공유.',
locationHighlight: '오피스 여성 직장인 중심',
saleReason: '결혼 · 타지역 이전',
photoUrl:
'https://images.unsplash.com/photo-1560066984-138dadb4c035?auto=format&fit=crop&w=1200&q=80',
},
{
title: '선릉 일식 오마카세 8석 · 예약제',
region: 'KR.BETA.GANGNAM_CORE',
industry: 'REST_FULL.JAPANESE',
dealStatus: 'OPEN',
roadAddress: '서울특별시 강남구 선릉로 512',
premium: 210_000_000,
monthlySales: 48_000_000,
monthlyProfit: 14_200_000,
startup: 90_000_000,
deposit: 120_000_000,
monthlyRent: 7_500_000,
remainingMonths: 33,
area: 52,
floor: 2,
description: '예약제 100% 운영. 객단가 18만원. 캐치테이블 노출 상위.',
locationHighlight: '강남 미쉐린 리스트 주변 상권',
saleReason: '일본 본점 복귀',
photoUrl:
'https://images.unsplash.com/photo-1553621042-f6e147245754?auto=format&fit=crop&w=1200&q=80',
},
{
title: '합정 칵테일 이자카야 · 인테리어 2년',
region: 'KR.BETA.MAPO_CORE',
industry: 'BAR.IZAKAYA',
dealStatus: 'OPEN',
roadAddress: '서울특별시 마포구 토정로 19',
premium: 85_000_000,
monthlySales: 27_000_000,
monthlyProfit: 6_300_000,
startup: 22_000_000,
deposit: 55_000_000,
monthlyRent: 3_400_000,
remainingMonths: 29,
area: 42,
floor: 1,
description: '금/토 만석 운영. 안주 원가율 28%.',
locationHighlight: '합정-상수 도보 축 상권',
saleReason: '공동대표 독립',
photoUrl:
'https://images.unsplash.com/photo-1514933651103-005eec06c04b?auto=format&fit=crop&w=1200&q=80',
},
{
title: '논현 요가·필라테스 스튜디오',
region: 'KR.BETA.GANGNAM_CORE',
industry: 'LEISURE.YOGA',
dealStatus: 'OPEN',
roadAddress: '서울특별시 강남구 도산대로 150',
premium: 90_000_000,
monthlySales: 26_500_000,
monthlyProfit: 7_800_000,
startup: 35_000_000,
deposit: 70_000_000,
monthlyRent: 4_200_000,
remainingMonths: 27,
area: 88,
floor: 5,
description: '강사 4명 승계. 회원 320명 활성. 리포머 6대.',
locationHighlight: '오피스/압구정 도보 10분',
saleReason: '2호점 집중',
photoUrl:
'https://images.unsplash.com/photo-1545205597-3d9d02c29597?auto=format&fit=crop&w=1200&q=80',
},
{
title: '마포 세차장·카센터 · 2,000평 부지',
region: 'KR.BETA.MAPO_CORE',
industry: 'SERVICE.CARWASH',
dealStatus: 'CLOSED',
roadAddress: '서울특별시 마포구 성암로 89',
premium: 180_000_000,
monthlySales: 43_000_000,
monthlyProfit: 11_800_000,
startup: 60_000_000,
deposit: 150_000_000,
monthlyRent: 8_900_000,
remainingMonths: 12,
area: 620,
floor: 1,
description: '(거래 완료) 기계 세차 4라인 + 정비 3라인 풀패키지.',
locationHighlight: '성산IC 인접 · 차량 접근성 최상',
saleReason: '양도 완료',
photoUrl:
'https://images.unsplash.com/photo-1605515298946-d0573716f4a9?auto=format&fit=crop&w=1200&q=80',
},
{
title: '강남 네일아트 프리미엄 숍',
region: 'KR.BETA.GANGNAM_CORE',
industry: 'SERVICE.NAIL',
dealStatus: 'CLOSED',
roadAddress: '서울특별시 강남구 역삼로 88',
premium: 55_000_000,
monthlySales: 19_000_000,
monthlyProfit: 5_100_000,
startup: 15_000_000,
deposit: 40_000_000,
monthlyRent: 2_600_000,
remainingMonths: 14,
area: 28,
floor: 4,
description: '(거래 완료) 단골 600명 · 예약 시스템 승계 완료.',
locationHighlight: '오피스 여성 직장인 중심',
saleReason: '양도 완료',
photoUrl:
'https://images.unsplash.com/photo-1604654894610-df63bc536371?auto=format&fit=crop&w=1200&q=80',
},
{
title: '홍대 분식 · 테이크아웃 전문',
region: 'KR.BETA.MAPO_CORE',
industry: 'REST_LIGHT.SNACK',
dealStatus: 'CLOSED',
roadAddress: '서울특별시 마포구 와우산로 15',
premium: 40_000_000,
monthlySales: 21_000_000,
monthlyProfit: 6_900_000,
startup: 12_000_000,
deposit: 30_000_000,
monthlyRent: 1_900_000,
remainingMonths: 10,
area: 19,
floor: 1,
description: '(거래 완료) 테이크아웃 비중 70% · 배달 탑텐.',
locationHighlight: '학생 상권',
saleReason: '양도 완료',
photoUrl:
'https://images.unsplash.com/photo-1534766555764-ce878a5e3a2b?auto=format&fit=crop&w=1200&q=80',
},
{
title: '논현동 스크린골프 8타석',
region: 'KR.BETA.GANGNAM_CORE',
industry: 'LEISURE.SCREENGOLF',
dealStatus: 'OPEN',
roadAddress: '서울특별시 강남구 논현로 240',
premium: 195_000_000,
monthlySales: 44_000_000,
monthlyProfit: 13_200_000,
startup: 120_000_000,
deposit: 150_000_000,
monthlyRent: 9_800_000,
remainingMonths: 36,
area: 198,
floor: 4,
description: '카카오 스크린 최신 기종 8타석. 법인/단골 비중 높음.',
locationHighlight: '강남역 6번출구 도보 7분',
saleReason: '개인 사정',
photoUrl:
'https://images.unsplash.com/photo-1535131749006-b7f58c99034b?auto=format&fit=crop&w=1200&q=80',
},
];
async function ensureUser(email: string, name: string, role: 'CLOSING_OWNER' | 'FOUNDER'): Promise<bigint> {
const emailNormalized = email.toLowerCase().trim();
const existing = await prisma.user.findFirst({ where: { emailNormalized } });
if (existing) return existing.id;
const created = await prisma.user.create({
data: {
email,
emailNormalized,
name,
passwordHash: DEMO_PASSWORD_HASH,
primaryRole: role,
status: 'ACTIVE',
emailVerifiedAt: new Date(),
},
});
console.log(`Demo user: ${email} (id: ${created.id})`);
return created.id;
}
async function ensureDemoStores(ownerUserId: bigint): Promise<void> {
// Cache region & industry lookups
const regionCodes = Array.from(new Set(DEMO_STORES.map((s) => s.region)));
const industryCodes = Array.from(new Set(DEMO_STORES.map((s) => s.industry)));
const regions = await prisma.regionHierarchy.findMany({
where: { code: { in: regionCodes } },
select: { id: true, code: true },
});
const industries = await prisma.industryTaxonomy.findMany({
where: { code: { in: industryCodes } },
select: { id: true, code: true },
});
const regionByCode = new Map(regions.map((r) => [r.code, r.id] as const));
const industryByCode = new Map(industries.map((i) => [i.code, i.id] as const));
const now = new Date();
let created = 0;
let skipped = 0;
for (const s of DEMO_STORES) {
// Skip if a store with the same title already exists (idempotent)
const existing = await prisma.store.findFirst({
where: { listingTitle: s.title, ownerUserId },
select: { id: true },
});
if (existing) {
skipped++;
continue;
}
const regionClusterId = regionByCode.get(s.region) ?? null;
const industryLeafId = industryByCode.get(s.industry) ?? null;
if (!regionClusterId || !industryLeafId) {
console.warn(`Skipping "${s.title}": region or industry not found (${s.region} / ${s.industry})`);
continue;
}
const store = await prisma.store.create({
data: {
ownerUserId,
listingTitle: s.title,
publicSummary: s.description.split('\n')[0]?.slice(0, 140),
regionClusterId,
industryLeafId,
roadAddress: s.roadAddress,
reviewStatus: 'APPROVED',
publicationStatus: 'PUBLISHED',
dealStatus: s.dealStatus,
publishedAt: now,
approvedAt: now,
},
});
await prisma.storeLease.create({
data: {
storeId: store.id,
depositAmount: s.deposit,
monthlyRentAmount: s.monthlyRent,
premiumAmount: s.premium,
transferable: true,
remainingLeaseMonths: s.remainingMonths,
},
});
await prisma.storeSale.create({
data: {
storeId: store.id,
premiumAmount: s.premium,
monthlySalesAmount: s.monthlySales,
monthlyProfitAmount: s.monthlyProfit,
startupCostAmount: s.startup,
listingDescription: s.description,
locationHighlight: s.locationHighlight,
saleReason: s.saleReason,
},
});
await prisma.storeFacility.create({
data: {
storeId: store.id,
exclusiveAreaSqm: s.area,
floorLevel: s.floor,
hasGas: true,
hasDrainage: true,
hasDuct: true,
},
});
await prisma.storePhoto.create({
data: {
storeId: store.id,
storageKey: s.photoUrl,
photoCategory: 'EXTERIOR',
visibilityScope: 'PUBLIC_SUMMARY',
sortOrder: 0,
uploadedByUserId: ownerUserId,
isRepresentative: true,
},
});
created++;
}
console.log(`Demo stores: created=${created}, skipped(existing)=${skipped}, total=${DEMO_STORES.length}`);
}
async function ensureDemoMatchRequests(requesterUserId: bigint): Promise<void> {
const stores = await prisma.store.findMany({
where: { publicationStatus: 'PUBLISHED' },
orderBy: { createdAt: 'desc' },
take: 20,
select: { id: true, listingTitle: true },
});
if (stores.length === 0) return;
const scenarios: Array<{
matchType: 'ACQUISITION' | 'DEMOLITION' | 'INTERIOR';
status: 'OPEN' | 'REVIEWING' | 'ACCEPTED' | 'COMPLETED';
message: string;
}> = [
{
matchType: 'ACQUISITION',
status: 'OPEN',
message: '인수 희망합니다. 실사 가능한 시점 알려주세요.',
},
{
matchType: 'ACQUISITION',
status: 'REVIEWING',
message: '권리금 협의 가능 여부 확인 부탁드립니다.',
},
{
matchType: 'DEMOLITION',
status: 'OPEN',
message: '철거 견적 요청합니다. 현장 방문 가능한가요?',
},
{
matchType: 'INTERIOR',
status: 'ACCEPTED',
message: '인테리어 재시공 협의 완료. 도면 공유 드립니다.',
},
{
matchType: 'ACQUISITION',
status: 'COMPLETED',
message: '인수 계약 완료. 정산 대기.',
},
{
matchType: 'ACQUISITION',
status: 'OPEN',
message: '실 운영자 승계 가능 여부 문의 드립니다.',
},
{
matchType: 'DEMOLITION',
status: 'REVIEWING',
message: '원상복구 범위 확인 후 최종 견적 드릴게요.',
},
{
matchType: 'INTERIOR',
status: 'OPEN',
message: '카페 → 와인바 컨셉 전환 인테리어 문의',
},
];
let created = 0;
let skipped = 0;
// Round-robin scenarios over stores
for (let i = 0; i < Math.min(stores.length, scenarios.length * 2); i++) {
const store = stores[i]!;
const scenario = scenarios[i % scenarios.length]!;
// Idempotency: skip if we already created a demo request for this store+type+user
const existing = await prisma.matchRequest.findFirst({
where: {
storeId: store.id,
matchType: scenario.matchType,
requesterUserId,
},
select: { id: true },
});
if (existing) {
skipped++;
continue;
}
const now = new Date();
const acceptedAt = scenario.status === 'ACCEPTED' || scenario.status === 'COMPLETED' ? now : null;
const closedAt = scenario.status === 'COMPLETED' ? now : null;
await prisma.matchRequest.create({
data: {
storeId: store.id,
matchType: scenario.matchType,
requesterUserId,
sourceType: 'USER_REQUEST',
status: scenario.status,
message: scenario.message,
acceptedAt,
closedAt,
},
});
created++;
}
console.log(`Demo match requests: created=${created}, skipped(existing)=${skipped}`);
}
async function main(): Promise<void> {
console.log('Starting DEMO seed...');
const ownerId = await ensureUser(DEMO_OWNER_EMAIL, '데모 매도인', 'CLOSING_OWNER');
const founderId = await ensureUser(DEMO_FOUNDER_EMAIL, '데모 창업자', 'FOUNDER');
await ensureDemoStores(ownerId);
await ensureDemoMatchRequests(founderId);
console.log('DEMO seed completed.');
}
main()
.catch((e) => {
console.error('Demo seed failed:', e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});