Compare commits

..

No commits in common. "main" and "design/frontend" have entirely different histories.

139 changed files with 1799 additions and 6607 deletions

View File

@ -320,8 +320,7 @@ class ChatService:
await QTablePolicyStore.persist_cell(repo, version_id, policy, idx, decision.action_id)
session.context["last_state"] = idx
session.context["last_action"] = decision.action_id
await self._log(repo, session, idx, decision.action_id, card_id, snap, reward, done=False,
decision=decision, policy=policy)
await self._log(repo, session, idx, decision.action_id, card_id, snap, reward, decision.propensity, done=False)
res.card_id = card_id
res.policy = policy.name
@ -421,7 +420,7 @@ class ChatService:
policy.update(Transition(state_index=last_state, action_id=last_action, reward=reward.total, done=True))
await QTablePolicyStore.persist_cell(repo, version_id, policy, last_state, last_action)
await self._log(repo, session, last_state, last_action,
self._card_id_for_action(engine, session, last_action), snap, reward, done=True)
self._card_id_for_action(engine, session, last_action), snap, reward, None, done=True)
res.updated_q = float(policy.qtable.q[last_state, last_action])
@staticmethod
@ -500,26 +499,13 @@ class ChatService:
prior[a] = 0.3 * (n - rank) / n
return prior if prior.any() else None
async def _log(self, repo: LearningRepository, session, state_index, action_id, card_id, snap, reward, done,
decision=None, policy=None):
async def _log(self, repo: LearningRepository, session, state_index, action_id, card_id, snap, reward, propensity, done):
data = {
"session_id": session.session_id, "state_index": state_index, "action_id": action_id,
"card_id": card_id, "snapshot": snap.to_dict(),
"card_id": card_id, "snapshot": snap.to_dict(), "propensity": propensity,
"turn": snap.round_number, "reward": reward.total, "done": done,
"settled_price": int(snap.input_price) if snap.outcome == NegotiationOutcome.SUCCESS else None,
}
if decision is not None and policy is not None:
# 선택 근거(Q값·UCB·방문수)를 그 턴 값 그대로 박제한다 — 사후에 q_values 를 읽으면 이미 갱신된 뒤라
# "그때 왜 이 카드였나"를 복원할 수 없다. negodata 협상 학습 화면이 이 컬럼들을 읽는다.
# 종료 로그(카드 선택 없는 done 행)는 decision 이 없어 NULL — 화면 집계(avg/max)가 무시한다.
data.update({
"propensity": decision.propensity,
"available_actions": decision.available_actions,
"q_value_at_selection": decision.q_value,
"ucb_score_at_selection": decision.ucb_score,
"visit_count_at_selection": int(policy.qtable.visits[state_index, action_id]),
"total_visits_at_selection": int(policy.qtable.state_visits(state_index)),
})
try:
await DB_SESSION_MNG.execute_lambda_run([DBType.MAIN.value], [lambda s: repo.log_transition(s, data)])
except Exception as ex:

View File

@ -97,10 +97,10 @@ class NegotiationService:
# 7) experience_logs 기록
if req.log:
res.logged = await self._log(engine, session_id, idx, decision, snap, reward, policy)
res.logged = await self._log(engine, session_id, idx, decision, snap, reward)
return res
async def _log(self, engine, session_id, idx, decision, snap, reward, policy) -> bool:
async def _log(self, engine, session_id, idx, decision, snap, reward) -> bool:
repo = LearningRepository(engine.company_id)
data = {
"session_id": session_id, "state_index": idx, "action_id": decision.action_id,
@ -108,8 +108,6 @@ class NegotiationService:
"turn": snap.round_number, "available_actions": decision.available_actions,
"reward": reward.total, "done": snap.outcome != NegotiationOutcome.ONGOING,
"q_value_at_selection": decision.q_value, "ucb_score_at_selection": decision.ucb_score,
"visit_count_at_selection": int(policy.qtable.visits[idx, decision.action_id]),
"total_visits_at_selection": int(policy.qtable.state_visits(idx)),
"settled_price": int(snap.input_price) if snap.outcome == NegotiationOutcome.SUCCESS else None,
}
try:

View File

@ -143,9 +143,6 @@ class SessionCRUD(ISessionCRUD):
sessions.supplier_id, # 이 세션 소유 공급사(=조회자). 낙찰자와 대조
# 대화 이력 유무 — 종료된 협상의 '결과 보기'(열람) 버튼을 띄울지 판단용. 열 게 없으면 프론트가 감춘다.
select(1).where(chats.session_id == sessions.session_id, chats.deleted == False).exists(), # noqa: E712
# 거부 건이 제출한 사유·희망가 — 목록의 '거부 내역' 열람용(의견은 custom.opinion).
sessions.reject_reason,
sessions.reject_price,
)
.join(items, items.item_id == sessions.item_id)
.join(quotations, quotations.qt_id == sessions.quotation_id)
@ -227,7 +224,7 @@ class SessionCRUD(ISessionCRUD):
) -> ErrorType:
try:
values = {"status": status, "reject_reason": reject_reason}
# 공급 희망 가격은 선택 입력이라 안 들어올 수 있다 — 그때는 컬럼을 건드리지 않는다.
# 공급 희망 가격은 채팅 중 거부에서만 들어온다 — 목록 거부는 가격 없이 사유만 남긴다.
if reject_price is not None:
values["reject_price"] = reject_price
query = (

View File

@ -79,8 +79,6 @@ class Res_ChatInit(Res_WebPacketProtocol):
item_vat_yn: Optional[bool] = Field(None, description="VAT 포함 여부(미설정 시 null)")
item_delivery_fee_yn: Optional[bool] = Field(None, description="배송비 포함 여부(미설정 시 null)")
custom: dict = Field(default_factory=dict, description="협상완료 부가정보 기존 입력값(sessions.custom). 재진입 시 폼 프리필용")
reject_reason: str = Field("", description="협상 거부 시 제출한 사유. 거부 건이 아니면 빈 문자열")
reject_price: Optional[int] = Field(None, description="협상 거부 시 함께 낸 공급 희망 가격(원). 미입력이면 null")
labels: dict = Field(default_factory=dict, description="회사 커스텀 라벨(companies.settings.labels). 상품 상세 필드명(예: lead_time) 치환용. 없으면 프론트 기본값")

View File

@ -22,8 +22,6 @@ class ListItem(WebPacketProtocol):
renegotiation_memo: str = Field("", description="담당자 심사 메모(반려 사유). 없으면 빈 문자열")
result: int = Field(0, description="공급사 관점 협상 결과(SessionResult): 0=미정 1=낙찰 2=미낙찰 3=결렬(개찰, 재협상 대상)")
has_chat: bool = Field(False, description="대화 이력 존재 여부 — 종료된 협상(미참여·거부)의 '결과 보기' 노출 판단용")
reject_reason: str = Field("", description="협상 거부 시 제출한 사유. 거부 건이 아니면 빈 문자열")
reject_price: Optional[int] = Field(None, description="협상 거부 시 함께 낸 공급 희망 가격(원). 미입력이면 null")
class Res_SessionList(Res_WebPacketProtocol):
@ -39,7 +37,7 @@ class Res_Participate(Res_WebPacketProtocol):
class Req_Reject(WebPacketProtocol):
reject_reason: str = Field("", max_length=255, description="거부 사유 (단종/품절 프리셋 라벨 또는 직접 입력)")
reject_price: Optional[int] = Field(None, description="공급 희망 가격(원). 선택 입력 — 없으면 컬럼 미변경")
reject_price: Optional[int] = Field(None, description="공급 희망 가격(원). 채팅 중 거부에서만 들어온다 — 목록 거부엔 없음")
opinion: Optional[str] = Field(None, max_length=255, description="추가 의견 — sessions.custom.opinion 에 병합")

View File

@ -71,14 +71,6 @@ class ChatService:
opinion = None
if ", 의견-" in s:
s, opinion = s.split(", 의견-", 1)
# 폼이 아닌 자유 입력("협상 포기합니다" 등)은 원문이 곧 사유다. 폼 마커가 없으면 가격도 읽지 않는다
# — 문장에 섞인 숫자를 희망가로 오인해 저장하는 것을 막는다.
if "합의불가사유-" not in s and "공급희망가격-" not in s:
return {
"offer_price": None,
"reason": s.strip()[:255] or None,
"opinion": (opinion.strip() or None) if opinion is not None else None,
}
reason = None
if ", 합의불가사유-" in s:
price_part, reason = s.split(", 합의불가사유-", 1)
@ -259,9 +251,6 @@ class ChatService:
res.item_vat_yn = item.vat_yn
res.item_delivery_fee_yn = item.delivery_fee_yn
res.custom = sess.custom or {}
# 거부로 끝난 세션은 대화에 남지 않는 제출 내역(사유·희망가)을 열람용으로 함께 내린다.
res.reject_reason = sess.reject_reason or ""
res.reject_price = sess.reject_price
# 회사 커스텀 라벨(companies.settings.labels) — 상품 상세 필드명 치환용(예: lead_time→표준납기). 실패해도 빈 dict 폴백.
_e, settings = await DB_SESSION_MNG.execute_lambda(

View File

@ -184,8 +184,6 @@ class NegotiationService:
renegotiation_memo=renego.get("memo") or "",
result=NegotiationService._to_result(r[10], r[11], r[13], r[14]),
has_chat=bool(r[15]),
reject_reason=r[16] or "",
reject_price=r[17],
)
@staticmethod

View File

@ -220,23 +220,6 @@ async def test_chat_init_returns_meta(client, chat_seed):
assert body["quotation_end_time"] # 타이머용 마감 시각
async def test_chat_init_returns_reject_detail(client, chat_seed, db_engine):
"""검증: 협상 거부로 끝난 세션에 재진입('결과 보기')했을 때의 init 응답.
기대결과: 대화에 남지 않는 제출 내역(reject_reason·reject_price) 실려 열람 카드를 그릴 있다."""
token = await _login_token(client)
sid = chat_seed["sids"]["P"]
await client.post(
f"/v1/negotiation/sessions/{sid}/reject",
headers={"Authorization": f"Bearer {token}"},
json={"reject_reason": "단종", "reject_price": 91000, "opinion": "후속 모델로 제안 가능합니다"},
)
body = (await _init(client, token, sid)).json()
assert body["session_status"] == 5
assert body["reject_reason"] == "단종"
assert body["reject_price"] == 91000
assert body["custom"]["opinion"] == "후속 모델로 제안 가능합니다"
async def test_chat_init_forbidden_other_supplier(client, chat_seed):
token = await _login_token(client)
body = (await _init(client, token, chat_seed["sids"]["X"])).json()

View File

@ -419,31 +419,6 @@ async def test_reject_without_price_keeps_null(client, nego_seed, db_engine):
assert row.reject_price is None and row.custom is None
async def test_list_returns_reject_detail(client, nego_seed):
# 거부 제출 내역은 대화에 남지 않는다 — 목록이 사유·희망가를 실어야 '거부 내역'을 열람할 수 있다.
token = await _login_token(client)
sid = nego_seed["sids"]["B"]
await client.post(
f"/v1/negotiation/sessions/{sid}/reject",
headers={"Authorization": f"Bearer {token}"},
json={"reject_reason": "품절", "reject_price": 77000, "opinion": "재고 확보 후 연락드리겠습니다"},
)
items = (await _list(client, token)).json()["items"]
row = next(i for i in items if i["session_id"] == str(sid))
assert row["reject_reason"] == "품절"
assert row["reject_price"] == 77000
assert row["custom"]["opinion"] == "재고 확보 후 연락드리겠습니다"
async def test_list_reject_detail_empty_for_active(client, nego_seed):
# 거부 건이 아니면 빈 값 — 프론트가 '거부 내역' 버튼 노출을 상태로만 판단하므로 값이 새면 안 된다.
token = await _login_token(client)
items = (await _list(client, token)).json()["items"]
row = next(i for i in items if i["session_id"] == str(nego_seed["sids"]["A"]))
assert row["reject_reason"] == ""
assert row.get("reject_price") is None
async def test_reject_empty_reason(client, nego_seed):
token = await _login_token(client)
r = await _reject(client, token, nego_seed["sids"]["B"], " ") # 공백만 → 사유 없음

View File

@ -49,8 +49,6 @@ export interface ChatInitResponse {
item_delivery_fee_yn?: boolean
custom?: Record<string, unknown>
labels?: Record<string, string>
reject_reason?: string
reject_price?: number | null
}
export interface ChatMessagesResponse {
@ -110,7 +108,5 @@ export function mapInit(r: ChatInitResponse): ChatInitData {
quotation_memo: r.quotation_memo ?? '',
quotation_end_time: r.quotation_end_time ?? '',
labels: r.labels ?? {},
reject_reason: r.reject_reason ?? '',
reject_price: r.reject_price ?? null,
}
}

View File

@ -63,8 +63,6 @@ export interface SessionListItem {
renegotiation_memo: string // 담당자 심사 메모(반려 사유)
result: number // 협상 결과(SessionResult): 0=미정 1=낙찰 2=미낙찰 3=결렬(개찰)
has_chat: boolean // 대화 이력 존재 여부 — 종료된 협상의 '결과 보기' 노출 판단용
reject_reason: string // 협상 거부 시 제출한 사유. 거부 건이 아니면 ''
reject_price?: number | null // 거부와 함께 낸 공급 희망 가격(원). 미입력이면 null
}
/** 공급사 관점 협상 결과 (sessions 파생) */
@ -121,9 +119,9 @@ export interface ParticipateResponse {
}
// --- 거부 (POST /v1/negotiation/sessions/{id}/reject) ---------------------
// 목록의 협상 거부와 채팅 중 협상 거부가 같은 폼(components/RejectPopup)·같은 엔드포인트를 쓴다.
// reject_reason: 프리셋(단종/품절) 라벨 또는 '기타' 직접 입력 텍스트. 필수.
// reject_price/opinion: 선택 입력 — 빈 값이면 아예 보내지 않는다(opinion 은 sessions.custom 에 병합).
// 목록의 협상 거부와 채팅 중 협상 거부가 같은 엔드포인트를 쓴다.
// reject_reason: 프리셋(단종/품절) 라벨 또는 '기타' 직접 입력 텍스트.
// reject_price/opinion: 채팅 중 거부에서만 채운다(목록 거부는 사유만).
export interface RejectRequest {
reject_reason: string
reject_price?: number

View File

@ -1,81 +0,0 @@
import { X } from 'lucide-react'
import { Modal } from '@/components/Modal'
import { numberToKorean } from '@/lib'
export interface RejectDetailPopupProps {
onClose: () => void
/** 어느 건인지 식별용 부제 (견적번호 · 상품명) */
subtitle?: string
/** sessions.reject_reason — 프리셋 라벨 또는 '기타' 직접 입력 텍스트 */
reason: string
/** sessions.reject_price — 미입력이면 null */
price: number | null
/** sessions.custom.opinion */
opinion: string
/** 'VAT포함'/'VAT별도' — 품목 VAT 를 아는 화면에서만 넘긴다 */
vatLabel?: string
}
// 협상 거부 사유 열람 팝업 — 목록의 '협상완료 부가정보(보기)'와 같은 규격(읽기전용 필드 나열).
// 거부는 제출 내역이 대화에 남지 않아 세션 컬럼이 유일한 기록이다.
export function RejectDetailPopup({ onClose, subtitle, reason, price, opinion, vatLabel }: RejectDetailPopupProps) {
const priceText = price
? `${price.toLocaleString()}원 (${numberToKorean(price)}원)${vatLabel ? ` ${vatLabel}` : ''}`
: '미입력'
return (
<Modal onClose={onClose}>
<div className="w-full max-w-md overflow-hidden rounded-2xl border border-border bg-white shadow-xl animate-scale-in">
<div className="flex items-center justify-between border-b border-border p-5">
<div>
<h3 className="text-base font-bold text-neutral-90"> ()</h3>
<p className="mt-0.5 text-xs text-neutral-60">
{subtitle}
{subtitle ? ' · ' : ''} .
</p>
</div>
<button
type="button"
onClick={onClose}
aria-label="닫기"
className="flex size-8 items-center justify-center rounded-full text-neutral-60 hover:bg-neutral-10"
>
<X className="size-4" />
</button>
</div>
<div className="space-y-4 p-5">
<Field label="거부 사유" value={reason || '-'} />
<Field label="공급 희망 가격" value={priceText} />
<Field label="의견" value={opinion} rows={2} placeholder="남긴 의견 없음" />
</div>
<div className="flex gap-2 border-t border-border p-5">
<button
type="button"
onClick={onClose}
className="h-11 flex-1 rounded-xl border border-border text-sm font-bold text-neutral-70 hover:bg-neutral-10"
>
</button>
</div>
</div>
</Modal>
)
}
// 부가정보 팝업의 읽기전용 필드와 같은 모양(라벨 + disabled 입력).
function Field({ label, value, rows, placeholder }: { label: string; value: string; rows?: number; placeholder?: string }) {
const style =
'w-full rounded-xl border border-border bg-neutral-10 px-3 text-sm text-neutral-60 cursor-not-allowed outline-none'
return (
<div className="space-y-1.5">
<label className="block text-sm font-semibold text-neutral-80">{label}</label>
{rows ? (
<textarea value={value} rows={rows} disabled placeholder={placeholder} className={`${style} resize-none py-2`} />
) : (
<input type="text" value={value} disabled placeholder={placeholder} className={`${style} h-11`} />
)}
</div>
)
}

View File

@ -1,200 +0,0 @@
import { useState } from 'react'
import { X } from 'lucide-react'
import { Modal } from '@/components/Modal'
import { cn, numberToKorean } from '@/lib'
const MAX_PRICE = 999999999999999
// 거부 사유는 협상을 시작하지 않겠다는 사유(단종/품절/기타)다. 협상해보고 합의가 안 된
// 결렬 폼(단가인상·수량 포함 5종)과는 성격이 달라 목록을 맞추지 않는다.
const REASONS = ['단종', '품절', '기타'] as const
export interface RejectSubmitPayload {
/** 프리셋 라벨 또는 '기타' 직접 입력 텍스트 */
reject_reason: string
reject_price?: number
opinion?: string
}
export interface RejectPopupProps {
onClose: () => void
onSubmit: (payload: RejectSubmitPayload) => void
isPending?: boolean
/** 가격 옆 'VAT 별도' 표기 — 품목 VAT 를 아는 화면(채팅)에서만 켠다 */
isVatExcluded?: boolean
}
// 협상 거부 팝업 — 목록과 채팅 양쪽이 같은 폼·같은 엔드포인트(/reject)를 쓴다.
// 사유는 필수, 공급 희망 가격과 의견은 선택.
export function RejectPopup({ onClose, onSubmit, isPending = false, isVatExcluded = false }: RejectPopupProps) {
const [selectedReason, setSelectedReason] = useState<string | null>(null)
const [customReason, setCustomReason] = useState('')
const [price, setPrice] = useState('')
const [opinion, setOpinion] = useState('')
const [showError, setShowError] = useState(false)
const isEtcOpen = selectedReason === '기타'
const isSubmitDisabled = !selectedReason || (isEtcOpen && !customReason.trim()) || isPending
const handleReasonClick = (reason: string) => {
setShowError(false)
if (selectedReason === reason) {
setSelectedReason(null)
setCustomReason('')
} else {
setSelectedReason(reason)
if (reason !== '기타') setCustomReason('')
}
}
const handlePriceChange = (value: string) => {
const numeric = value.replace(/\D/g, '')
if (numeric && parseInt(numeric) > MAX_PRICE) return
setPrice(numeric)
}
const handleSubmit = () => {
if (isEtcOpen && !customReason.trim()) {
setShowError(true)
return
}
if (isSubmitDisabled || !selectedReason) return
onSubmit({
reject_reason: isEtcOpen ? customReason.trim() : selectedReason,
// 빈 값이면 아예 보내지 않아 컬럼을 건드리지 않는다.
...(price ? { reject_price: parseInt(price) } : {}),
...(opinion.trim() ? { opinion: opinion.trim() } : {}),
})
}
const koreanPrice = price && parseInt(price) > 0 ? `[${numberToKorean(parseInt(price))} 원]` : ''
return (
<Modal onClose={onClose}>
<div className="flex max-h-[85vh] w-full max-w-md flex-col overflow-hidden rounded-2xl border border-border bg-white shadow-xl animate-scale-in">
{/* 헤더 */}
<div className="flex shrink-0 items-center justify-between border-b border-border p-5">
<h3 className="text-base font-bold text-neutral-90"> </h3>
<button
type="button"
onClick={onClose}
aria-label="닫기"
className="flex size-8 items-center justify-center rounded-full text-neutral-60 hover:bg-neutral-10"
>
<X className="size-4" />
</button>
</div>
{/* 본문 */}
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-5">
<div className="grid grid-cols-3 gap-2">
{REASONS.map((reason) => (
<button
key={reason}
type="button"
onClick={() => handleReasonClick(reason)}
disabled={isPending}
className={cn(
'h-11 rounded-xl border text-sm font-bold transition-all active:scale-[0.98] disabled:opacity-50',
selectedReason === reason
? 'border-brand-600 bg-brand-light text-brand-600'
: 'border-border bg-white text-neutral-70 hover:bg-neutral-10',
)}
>
{reason}
</button>
))}
</div>
{isEtcOpen && (
<div className="animate-fade-in">
<textarea
placeholder="사유를 입력하여 주십시오"
value={customReason}
onChange={(e) => {
setCustomReason(e.target.value)
setShowError(false)
}}
rows={3}
disabled={isPending}
className={cn(
'w-full resize-none rounded-xl border bg-white p-3 text-sm text-neutral-90 outline-none transition-all',
'placeholder:text-neutral-50 focus:ring-1',
showError
? 'border-destructive focus:border-destructive focus:ring-destructive'
: 'border-border focus:border-brand-600 focus:ring-brand-600',
)}
/>
{showError && <p className="mt-1.5 text-xs font-medium text-destructive"> </p>}
</div>
)}
{/* 공급 희망 가격 (선택) — 결렬 폼과 같은 라벨·표기 */}
<div className="flex flex-col gap-1.5">
<div className="text-sm font-bold text-neutral-90">
<span className="font-medium text-neutral-60">()</span>
</div>
<div className="flex flex-wrap items-center gap-2">
<input
type="text"
className={cn(
'h-10 max-w-[200px] rounded-xl border px-3 text-right text-sm outline-none transition-all placeholder:text-neutral-50',
isPending
? 'cursor-not-allowed border-border bg-neutral-10 text-neutral-50'
: 'border-border bg-white text-neutral-90 focus:border-brand-600 focus:ring-1 focus:ring-brand-600',
)}
value={price ? parseInt(price).toLocaleString() : ''}
onChange={(e) => handlePriceChange(e.target.value)}
placeholder="0"
disabled={isPending}
/>
<span className="whitespace-nowrap text-sm text-neutral-70">{isVatExcluded && '(VAT 별도)'}</span>
{koreanPrice && <span className="whitespace-nowrap text-sm text-neutral-50">{koreanPrice}</span>}
</div>
</div>
{/* 의견 (선택) — 결렬 폼과 동일 */}
<div className="flex flex-col gap-1.5">
<div className="text-sm font-bold text-neutral-90">
<span className="font-medium text-neutral-60">()</span>
</div>
<textarea
value={opinion}
onChange={(e) => setOpinion(e.target.value)}
rows={2}
maxLength={255}
disabled={isPending}
placeholder="추가로 남길 의견이 있으면 작성해 주세요."
className={cn(
'w-full resize-none rounded-xl border px-3 py-2 text-sm outline-none transition-all placeholder:text-neutral-50',
isPending
? 'cursor-not-allowed border-border bg-neutral-10 text-neutral-50'
: 'border-border bg-white text-neutral-90 focus:border-brand-600 focus:ring-1 focus:ring-brand-600',
)}
/>
</div>
</div>
{/* 푸터 */}
<div className="flex shrink-0 gap-2 border-t border-border p-4">
<button
type="button"
onClick={onClose}
disabled={isPending}
className="h-11 flex-1 rounded-xl border border-border bg-white text-sm font-bold text-neutral-70 transition-all hover:bg-neutral-10 active:scale-[0.98] disabled:opacity-50"
>
</button>
<button
type="button"
onClick={handleSubmit}
disabled={isSubmitDisabled}
className="h-11 flex-1 rounded-xl bg-brand-600 text-sm font-bold text-white shadow-sm transition-all hover:bg-brand-700 active:scale-[0.98] disabled:opacity-40"
>
</button>
</div>
</div>
</Modal>
)
}

View File

@ -7,7 +7,3 @@ export { Logo } from '@/components/Logo'
export type { LogoProps, LogoVariant } from '@/components/Logo'
export { ErrorPage } from '@/components/ErrorPage'
export type { ErrorPageProps } from '@/components/ErrorPage'
export { RejectPopup } from '@/components/RejectPopup'
export type { RejectPopupProps, RejectSubmitPayload } from '@/components/RejectPopup'
export { RejectDetailPopup } from '@/components/RejectDetailPopup'
export type { RejectDetailPopupProps } from '@/components/RejectDetailPopup'

View File

@ -7,8 +7,6 @@ import { renderEmphasis } from '@/features/chat/lib/emphasis'
import { Indicator } from '@/features/chat/components/templates/Indicator'
import { Summary } from '@/features/chat/components/templates/Summary'
import { BidSummary } from '@/features/chat/components/templates/BidSummary'
import { RejectSummary } from '@/features/chat/components/templates/RejectSummary'
import { RejectedNotice } from '@/features/chat/components/templates/RejectedNotice'
const AI_LABEL = '아이마켓코리아 (구매담당자)'
@ -59,7 +57,6 @@ function ChatList({ scrollRef }: { scrollRef: RefObject<HTMLDivElement | null> }
{chats.map((message, index) => (
<MessageItem key={message.chat_id || index} message={message} messages={chats} currentIndex={index} />
))}
<RejectedNotice />
{isLoading && <TypingBubble />}
</div>
)
@ -90,16 +87,11 @@ const MessageItem = memo(function MessageItem({
}) {
const isBot = message.sender === 'bot'
// 결렬 폼 답변은 제출 문자열(공급희망가격-…, 합의불가사유-…)이라 말풍선 대신 요약 카드로 낸다.
// 폼은 세션 종료와 함께 사라지므로 이 카드가 없으면 결렬 사유가 대화에 안 남는다.
// 직전이 reject 폼이면 사용자 답변 말풍선은 숨긴다(폼 자체가 답변을 담고 있음)
if (!isBot && currentIndex > 0) {
const prev = messages[currentIndex - 1]
if (prev?.bot_chat_type === 'rejectRSP' || prev?.bot_chat_type === 'rejectCM') {
return (
<div className="mb-5 animate-fade-in">
<RejectSummary script={message.script || ''} />
</div>
)
return null
}
}

View File

@ -1,20 +1,23 @@
import { useState, useMemo } from 'react'
import { AlertTriangle } from 'lucide-react'
import { cn } from '@/lib'
import { numberToKorean } from '@/lib'
import { numberToKorean } from '@/features/chat/lib/koreanNumber'
import { useChatStore } from '@/features/chat/stores/useChatStore'
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
import { SelectRadio, SubmitButton } from '@/features/chat/components/templates/rejectControls'
import { OtherReason } from '@/features/chat/components/templates/OtherReason'
import {
findRestoreScript,
restoreRejectRSP,
extractPart,
REJECT_REASONS,
} from '@/features/chat/lib/rejectForm'
import { findRestoreScript, restoreRejectRSP, extractPart } from '@/features/chat/lib/rejectForm'
const MAX_PRICE = 999999999999999
// 합의 불가 사유 프리셋 (rejectRSP 와 동일 목록)
const REASONS = [
{ value: '단가인상', text: "'원재료 가격 상승' 또는 '제조사 가격 인상'으로 요청한 공급가격을 맞출 수 없습니다." },
{ value: '수량', text: '주문 수량이 적어, 소량 생산 시 발생하는 제조비용으로 맞출 수 없습니다.' },
{ value: '단종', text: '현재 단종된 제품으로 물량 수급이 원활하지 않아 가격을 맞출 수 없습니다.' },
{ value: '품절', text: '해당 상품이 품절되어 납품할 수 없습니다.' },
]
// 통일된 협상 결렬 폼 — 최종 제안 단가 + 합의 불가 사유 + 의견.
// rejectRSP / rejectCM 두 유형 모두 이 폼 하나로 받는다(액션바 렌더).
export function RejectForm() {
@ -126,7 +129,7 @@ export function RejectForm() {
<div className="flex w-full gap-3">
<div className="w-[100px] shrink-0 pt-2 text-sm font-bold text-neutral-90"> </div>
<div className="flex w-full flex-col mt-2 gap-2">
{REJECT_REASONS.map((r) => (
{REASONS.map((r) => (
<SelectRadio
key={r.value}
text={r.text}

View File

@ -1,35 +1,210 @@
import { useState } from 'react'
import { useNavigate } from 'react-router'
import { RejectPopup as RejectPopupForm } from '@/components'
import { X } from 'lucide-react'
import { Modal } from '@/components'
import { getApiErrorMessage, useRejectMutation } from '@/apis'
import { toast } from '@/lib'
import { cn, toast } from '@/lib'
import { numberToKorean } from '@/features/chat/lib/koreanNumber'
import { useChatStore } from '@/features/chat/stores/useChatStore'
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
// 협상 진행 중 거부 — 폼은 목록 거부와 공용이고, 여기서는 대화 세션에 붙여 제출·이탈만 처리한다.
const MAX_PRICE = 999999999999999
// 사유 목록은 목록 화면의 거부 팝업과 동일하게 맞춘다.
const REASONS = ['단종', '품절', '기타'] as const
// 협상 진행 중 거부 팝업 — 목록의 거부 팝업과 같은 어휘·같은 엔드포인트를 쓰고,
// 대화 중이라 이미 오간 가격이 있으므로 공급 희망 가격과 의견을 더 받는다.
export function RejectPopup({ onClose }: { onClose: () => void }) {
const navigate = useNavigate()
const reject = useRejectMutation()
const sessionId = useChatStore((s) => s.sessionId)
const itemVatYn = useChatInitStore((s) => s.item_vat_yn)
const isVAT = itemVatYn === 'VAT별도'
const [selectedReason, setSelectedReason] = useState<string | null>(null)
const [customReason, setCustomReason] = useState('')
const [price, setPrice] = useState('')
const [opinion, setOpinion] = useState('')
const [showError, setShowError] = useState(false)
const isEtcOpen = selectedReason === '기타'
const isPending = reject.isPending
const isSubmitDisabled = !selectedReason || (isEtcOpen && !customReason.trim()) || isPending
const handleReasonClick = (reason: string) => {
setShowError(false)
if (selectedReason === reason) {
setSelectedReason(null)
setCustomReason('')
} else {
setSelectedReason(reason)
if (reason !== '기타') setCustomReason('')
}
}
const handlePriceChange = (value: string) => {
const numeric = value.replace(/\D/g, '')
if (numeric && parseInt(numeric) > MAX_PRICE) return
setPrice(numeric)
}
const handleSubmit = () => {
if (isEtcOpen && !customReason.trim()) {
setShowError(true)
return
}
if (isSubmitDisabled || !selectedReason) return
reject.mutate(
{
sessionId,
request: {
reject_reason: isEtcOpen ? customReason.trim() : selectedReason,
// 선택 입력 — 빈 값이면 보내지 않아 컬럼을 건드리지 않는다.
...(price ? { reject_price: parseInt(price) } : {}),
...(opinion.trim() ? { opinion: opinion.trim() } : {}),
},
},
{
onSuccess: () => {
onClose()
toast.warning('협상 거부가 완료되었습니다.')
navigate('/list')
},
onError: (error) => toast.error(getApiErrorMessage(error, '거부 처리에 실패했습니다.')),
},
)
}
const koreanPrice = price && parseInt(price) > 0 ? `[${numberToKorean(parseInt(price))} 원]` : ''
return (
<RejectPopupForm
onClose={onClose}
isPending={reject.isPending}
isVatExcluded={itemVatYn === 'VAT별도'}
onSubmit={(request) =>
reject.mutate(
{ sessionId, request },
{
onSuccess: () => {
onClose()
toast.warning('협상 거부가 완료되었습니다.')
navigate('/list')
},
onError: (error) => toast.error(getApiErrorMessage(error, '거부 처리에 실패했습니다.')),
},
)
}
/>
<Modal onClose={onClose}>
<div className="flex max-h-[85vh] w-full max-w-md flex-col overflow-hidden rounded-2xl border border-border bg-white shadow-xl animate-scale-in">
{/* 헤더 */}
<div className="flex shrink-0 items-center justify-between border-b border-border p-5">
<h3 className="text-base font-bold text-neutral-90"> </h3>
<button
type="button"
onClick={onClose}
aria-label="닫기"
className="flex size-8 items-center justify-center rounded-full text-neutral-60 hover:bg-neutral-10"
>
<X className="size-4" />
</button>
</div>
{/* 본문 */}
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-5">
<div className="grid grid-cols-3 gap-2">
{REASONS.map((reason) => (
<button
key={reason}
type="button"
onClick={() => handleReasonClick(reason)}
disabled={isPending}
className={cn(
'h-11 rounded-xl border text-sm font-bold transition-all active:scale-[0.98] disabled:opacity-50',
selectedReason === reason
? 'border-brand-600 bg-brand-light text-brand-600'
: 'border-border bg-white text-neutral-70 hover:bg-neutral-10',
)}
>
{reason}
</button>
))}
</div>
{isEtcOpen && (
<div className="animate-fade-in">
<textarea
placeholder="사유를 입력하여 주십시오"
value={customReason}
onChange={(e) => {
setCustomReason(e.target.value)
setShowError(false)
}}
rows={3}
disabled={isPending}
className={cn(
'w-full resize-none rounded-xl border bg-white p-3 text-sm text-neutral-90 outline-none transition-all',
'placeholder:text-neutral-50 focus:ring-1',
showError
? 'border-destructive focus:border-destructive focus:ring-destructive'
: 'border-border focus:border-brand-600 focus:ring-brand-600',
)}
/>
{showError && <p className="mt-1.5 text-xs font-medium text-destructive"> </p>}
</div>
)}
{/* 공급 희망 가격 (선택) — 결렬 폼과 같은 라벨·표기 */}
<div className="flex flex-col gap-1.5">
<div className="text-sm font-bold text-neutral-90">
<span className="font-medium text-neutral-60">()</span>
</div>
<div className="flex flex-wrap items-center gap-2">
<input
type="text"
className={cn(
'h-10 max-w-[200px] rounded-xl border px-3 text-right text-sm outline-none transition-all placeholder:text-neutral-50',
isPending
? 'cursor-not-allowed border-border bg-neutral-10 text-neutral-50'
: 'border-border bg-white text-neutral-90 focus:border-brand-600 focus:ring-1 focus:ring-brand-600',
)}
value={price ? parseInt(price).toLocaleString() : ''}
onChange={(e) => handlePriceChange(e.target.value)}
placeholder="0"
disabled={isPending}
/>
<span className="whitespace-nowrap text-sm text-neutral-70">{isVAT && '(VAT 별도)'}</span>
{koreanPrice && <span className="whitespace-nowrap text-sm text-neutral-50">{koreanPrice}</span>}
</div>
</div>
{/* 의견 (선택) — 결렬 폼과 동일 */}
<div className="flex flex-col gap-1.5">
<div className="text-sm font-bold text-neutral-90">
<span className="font-medium text-neutral-60">()</span>
</div>
<textarea
value={opinion}
onChange={(e) => setOpinion(e.target.value)}
rows={2}
maxLength={255}
disabled={isPending}
placeholder="추가로 남길 의견이 있으면 작성해 주세요."
className={cn(
'w-full resize-none rounded-xl border px-3 py-2 text-sm outline-none transition-all placeholder:text-neutral-50',
isPending
? 'cursor-not-allowed border-border bg-neutral-10 text-neutral-50'
: 'border-border bg-white text-neutral-90 focus:border-brand-600 focus:ring-1 focus:ring-brand-600',
)}
/>
</div>
</div>
{/* 푸터 — 목록 거부 팝업과 동일 규격 */}
<div className="flex shrink-0 gap-2 border-t border-border p-4">
<button
type="button"
onClick={onClose}
disabled={isPending}
className="h-11 flex-1 rounded-xl border border-border bg-white text-sm font-bold text-neutral-70 transition-all hover:bg-neutral-10 active:scale-[0.98] disabled:opacity-50"
>
</button>
<button
type="button"
onClick={handleSubmit}
disabled={isSubmitDisabled}
className="h-11 flex-1 rounded-xl bg-brand-600 text-sm font-bold text-white shadow-sm transition-all hover:bg-brand-700 active:scale-[0.98] disabled:opacity-40"
>
</button>
</div>
</div>
</Modal>
)
}

View File

@ -1,5 +1,5 @@
import { CheckCircle2 } from 'lucide-react'
import { numberToKorean } from '@/lib'
import { numberToKorean } from '@/features/chat/lib/koreanNumber'
import { useMeQuery } from '@/apis'
import type { SessionField } from '@/apis/auth/auth.type'
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'

View File

@ -1,52 +0,0 @@
import { AlertTriangle } from 'lucide-react'
import { numberToKorean } from '@/lib'
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
import { parseRejectSubmission } from '@/features/chat/lib/rejectForm'
// 결렬 제출 요약 — 타결의 BidSummary 와 대칭.
export function RejectSummary({ script }: { script: string }) {
// VAT 표기는 회사 설정 기준(item_vat_yn). 미관리(hidden)면 비어 라벨을 생략한다.
const item_vat_yn = useChatInitStore((s) => s.item_vat_yn)
const { price, reasonLabel, reasonDetail, opinion, extras } = parseRejectSubmission(script)
return (
<div className="w-full rounded-2xl border-2 border-[#FFD9D9] bg-white p-5 shadow-md">
<div className="mb-4 flex items-center gap-2">
<AlertTriangle className="size-5 text-[#E5484D]" />
<h3 className="text-sm font-bold text-neutral-90"> </h3>
</div>
<div className="divide-y divide-border/60 rounded-xl border border-border">
<div className="flex items-start justify-between gap-3 px-4 py-3">
<span className="shrink-0 text-sm text-neutral-60"> </span>
<span className="min-w-0 flex-1 break-keep text-right text-sm">
<span className="font-bold text-[#E5484D]">{price.toLocaleString()}</span>
<span className="text-neutral-70"> ({numberToKorean(price)})</span>
{item_vat_yn ? <span className="text-neutral-70"> {item_vat_yn}</span> : null}
</span>
</div>
<div className="flex items-start justify-between gap-3 px-4 py-3">
<span className="shrink-0 text-sm text-neutral-60"> </span>
<span className="min-w-0 flex-1 break-keep text-right">
<span className="text-sm font-semibold text-neutral-90">{reasonLabel || '-'}</span>
{reasonDetail && <span className="mt-1 block text-xs text-neutral-70">{reasonDetail}</span>}
</span>
</div>
{extras.map((e) => (
<Row key={e.label} label={e.label} value={e.value} />
))}
{/* 의견은 타결·결렬 공통 기본 필드 — 값이 없어도 항상 표시 */}
<Row label="의견" value={opinion || '-'} />
</div>
</div>
)
}
function Row({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-start justify-between gap-3 px-4 py-3">
<span className="shrink-0 text-sm text-neutral-60">{label}</span>
<span className="min-w-0 flex-1 break-keep text-right text-sm font-semibold text-neutral-90">{value}</span>
</div>
)
}

View File

@ -1,56 +0,0 @@
import { AlertTriangle } from 'lucide-react'
import { SessionStatus } from '@/apis'
import { numberToKorean } from '@/lib'
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
// 협상 거부로 끝난 세션의 제출 내역 — 결렬의 RejectSummary 와 같은 카드 규격.
// 거부는 팝업으로 내 대화에 남지 않아 대화 끝에 따로 붙인다.
export function RejectedNotice() {
const sessionStatus = useChatInitStore((s) => s.session_status)
const reason = useChatInitStore((s) => s.reject_reason)
const price = useChatInitStore((s) => s.reject_price)
const custom = useChatInitStore((s) => s.custom)
const item_vat_yn = useChatInitStore((s) => s.item_vat_yn)
if (sessionStatus !== SessionStatus.REJECTED || !reason) return null
const opinion = String(custom?.opinion ?? '')
return (
<div className="mb-6 w-full rounded-2xl border-2 border-[#FFD9D9] bg-white p-5 shadow-md">
<div className="mb-4 flex items-center gap-2">
<AlertTriangle className="size-5 text-[#E5484D]" />
<h3 className="text-sm font-bold text-neutral-90"> </h3>
</div>
<div className="divide-y divide-border/60 rounded-xl border border-border">
<div className="flex items-start justify-between gap-3 px-4 py-3">
<span className="shrink-0 text-sm text-neutral-60"> </span>
<span className="min-w-0 flex-1 break-keep text-right text-sm">
{price ? (
<>
<span className="font-bold text-[#E5484D]">{price.toLocaleString()}</span>
<span className="text-neutral-70"> ({numberToKorean(price)})</span>
{item_vat_yn ? <span className="text-neutral-70"> {item_vat_yn}</span> : null}
</>
) : (
<span className="font-semibold text-neutral-90"></span>
)}
</span>
</div>
<Row label="거부 사유" value={reason} />
{/* 의견은 타결·결렬·거부 공통 기본 필드 — 값이 없어도 항상 표시 */}
<Row label="의견" value={opinion || '-'} />
</div>
</div>
)
}
function Row({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-start justify-between gap-3 px-4 py-3">
<span className="shrink-0 text-sm text-neutral-60">{label}</span>
<span className="min-w-0 flex-1 break-keep text-right text-sm font-semibold text-neutral-90">{value}</span>
</div>
)
}

View File

@ -1,5 +1,5 @@
import { CheckCircle2 } from 'lucide-react'
import { numberToKorean } from '@/lib'
import { numberToKorean } from '@/features/chat/lib/koreanNumber'
import { formatLeadTime } from '@/features/chat/lib/format'
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
import type { ChatSummary } from '@/features/chat/types'

View File

@ -1,13 +1,5 @@
import type { ChatMessage } from '@/features/chat/types'
// value = sessions.reject_reason 에 저장되는 값
export const REJECT_REASONS = [
{ value: '단가인상', text: "'원재료 가격 상승' 또는 '제조사 가격 인상'으로 요청한 공급가격을 맞출 수 없습니다." },
{ value: '수량', text: '주문 수량이 적어, 소량 생산 시 발생하는 제조비용으로 맞출 수 없습니다.' },
{ value: '단종', text: '현재 단종된 제품으로 물량 수급이 원활하지 않아 가격을 맞출 수 없습니다.' },
{ value: '품절', text: '해당 상품이 품절되어 납품할 수 없습니다.' },
] as const
// reject 폼 직전 사용자 답변(script)을 찾아 복원용으로 반환
export function findRestoreScript(messages: ChatMessage[], type: 'rejectCM' | 'rejectRSP'): string | null {
const reversed = [...messages].reverse()
@ -30,28 +22,3 @@ export function restoreRejectRSP(script: string | null) {
if (reasonRaw.startsWith('기타-')) return { price, selectedReason: '기타', reason: reasonRaw.replace('기타-', '') }
return { price, selectedReason: reasonRaw, reason: '' }
}
// 제출 script → 표시값
export function parseRejectSubmission(script: string | null) {
const { price, selectedReason, reason } = restoreRejectRSP(script)
const preset = REJECT_REASONS.find((r) => r.value === selectedReason)
return {
price: price ? parseInt(price) : 0,
reasonLabel: selectedReason,
reasonDetail: selectedReason === '기타' ? reason : (preset?.text ?? ''),
opinion: extractPart(script, '의견-'),
extras: extraParts(script),
}
}
// 통합 폼 이전 제출분의 '배송형태-' 등. 사유 뒤는 자유서술이 섞여 앞 구간만 훑는다.
function extraParts(script: string | null): { label: string; value: string }[] {
const head = (script ?? '').split(', 의견-')[0].split(', 합의불가사유-')[0]
return head
.split(', ')
.filter((p) => p && !p.startsWith('공급희망가격-'))
.map((p) => {
const i = p.indexOf('-')
return i > 0 ? { label: p.slice(0, i), value: p.slice(i + 1) } : { label: '항목', value: p }
})
}

View File

@ -32,8 +32,6 @@ const initialState: ChatInitData = {
quotation_end_time: '',
custom: {},
labels: {},
reject_reason: '',
reject_price: null,
}
export const useChatInitStore = create<ChatInitStore>((set) => ({

View File

@ -86,6 +86,4 @@ export type ChatInitData = {
quotation_end_time: string
custom: Record<string, unknown> // 협상완료 부가정보 기존 입력값(프리필용)
labels: Record<string, string> // 회사 커스텀 라벨(companies.settings.labels). 상품 상세 필드명 치환용
reject_reason: string // 협상 거부로 끝난 세션의 제출 사유(의견은 custom.opinion)
reject_price: number | null // 거부와 함께 낸 공급 희망 가격(원)
}

View File

@ -0,0 +1,124 @@
import { useState } from 'react'
import { X } from 'lucide-react'
import { Modal } from '@/components'
import { cn } from '@/lib'
const REASONS = ['단종', '품절', '기타'] as const
export interface RejectPopupProps {
onClose: () => void
/** 최종 거부 사유 (프리셋 라벨 또는 기타 입력 텍스트) */
onSubmit: (reason: string) => void
}
// 거부 사유 입력 팝업 (단종/품절/기타).
export function RejectPopup({ onClose, onSubmit }: RejectPopupProps) {
const [selectedReason, setSelectedReason] = useState<string | null>(null)
const [customReason, setCustomReason] = useState('')
const [showError, setShowError] = useState(false)
const isEtcOpen = selectedReason === '기타'
const isSubmitDisabled = !selectedReason || (isEtcOpen && !customReason.trim())
const handleReasonClick = (reason: string) => {
setShowError(false)
if (selectedReason === reason) {
setSelectedReason(null)
setCustomReason('')
} else {
setSelectedReason(reason)
if (reason !== '기타') setCustomReason('')
}
}
const handleSubmit = () => {
if (isEtcOpen && !customReason.trim()) {
setShowError(true)
return
}
if (isSubmitDisabled || !selectedReason) return
onSubmit(isEtcOpen ? customReason.trim() : selectedReason)
onClose()
}
return (
<Modal onClose={onClose}>
<div className="w-full max-w-md overflow-hidden rounded-2xl border border-border bg-white shadow-xl animate-scale-in">
{/* 헤더 */}
<div className="flex items-center justify-between border-b border-border p-5">
<h3 className="text-base font-bold text-neutral-90"> </h3>
<button
type="button"
onClick={onClose}
aria-label="닫기"
className="flex size-8 items-center justify-center rounded-full text-neutral-60 hover:bg-neutral-10"
>
<X className="size-4" />
</button>
</div>
{/* 본문 */}
<div className="space-y-4 p-5">
<div className="grid grid-cols-3 gap-2">
{REASONS.map((reason) => (
<button
key={reason}
type="button"
onClick={() => handleReasonClick(reason)}
className={cn(
'h-11 rounded-xl border text-sm font-bold transition-all active:scale-[0.98]',
selectedReason === reason
? 'border-brand-600 bg-brand-light text-brand-600'
: 'border-border bg-white text-neutral-70 hover:bg-neutral-10',
)}
>
{reason}
</button>
))}
</div>
{isEtcOpen && (
<div className="animate-fade-in">
<textarea
placeholder="사유를 입력하여 주십시오"
value={customReason}
onChange={(e) => {
setCustomReason(e.target.value)
setShowError(false)
}}
rows={3}
className={cn(
'w-full resize-none rounded-xl border bg-white p-3 text-sm text-neutral-90 outline-none transition-all',
'placeholder:text-neutral-50 focus:ring-1',
showError
? 'border-destructive focus:border-destructive focus:ring-destructive'
: 'border-border focus:border-brand-600 focus:ring-brand-600',
)}
/>
{showError && <p className="mt-1.5 text-xs font-medium text-destructive"> </p>}
</div>
)}
</div>
{/* 푸터 */}
<div className="flex gap-2 border-t border-border p-4">
<button
type="button"
onClick={onClose}
className="h-11 flex-1 rounded-xl border border-border bg-white text-sm font-bold text-neutral-70 transition-all hover:bg-neutral-10 active:scale-[0.98]"
>
</button>
<button
type="button"
onClick={handleSubmit}
disabled={isSubmitDisabled}
className="h-11 flex-1 rounded-xl bg-brand-600 text-sm font-bold text-white shadow-sm transition-all hover:bg-brand-700 active:scale-[0.98] disabled:opacity-40"
>
</button>
</div>
</div>
</Modal>
)
}

View File

@ -13,11 +13,10 @@ interface WorkspaceCardsProps {
onExtraInfo: (item: ListItem) => void
onRenegotiate: (item: ListItem) => void
onMemo: (item: ListItem) => void
onRejectDetail: (item: ListItem) => void
}
// 모바일(lg 미만) 협상 목록: 테이블 대신 카드 스택.
export function WorkspaceCards({ items, isLoading, busyId, onEnter, onReject, onExtraInfo, onRenegotiate, onMemo, onRejectDetail }: WorkspaceCardsProps) {
export function WorkspaceCards({ items, isLoading, busyId, onEnter, onReject, onExtraInfo, onRenegotiate, onMemo }: WorkspaceCardsProps) {
if (isLoading) {
return (
<div className="flex items-center justify-center py-16">
@ -32,7 +31,7 @@ export function WorkspaceCards({ items, isLoading, busyId, onEnter, onReject, on
return (
<div className="divide-y divide-border">
{items.map((item) => (
<Card key={item.session_id} item={item} busy={busyId === item.session_id} onEnter={onEnter} onReject={onReject} onExtraInfo={onExtraInfo} onRenegotiate={onRenegotiate} onMemo={onMemo} onRejectDetail={onRejectDetail} />
<Card key={item.session_id} item={item} busy={busyId === item.session_id} onEnter={onEnter} onReject={onReject} onExtraInfo={onExtraInfo} onRenegotiate={onRenegotiate} onMemo={onMemo} />
))}
</div>
)
@ -46,7 +45,6 @@ function Card({
onExtraInfo,
onRenegotiate,
onMemo,
onRejectDetail,
}: {
item: ListItem
busy: boolean
@ -55,14 +53,11 @@ function Card({
onExtraInfo: (item: ListItem) => void
onRenegotiate: (item: ListItem) => void
onMemo: (item: ListItem) => void
onRejectDetail: (item: ListItem) => void
}) {
const meta = statusMeta(item.session_status)
const ended = isEndedStatus(item.session_status)
const canEnter = canEnterChat(item.session_status, item.hasChat)
const canReject = ['협상생성', '협상중'].includes(item.session_status)
// 거부 건은 제출 내역이 대화에 남지 않는다 — 세션에 적힌 사유를 여기서만 다시 볼 수 있다.
const isRejected = item.session_status === '협상거부'
const isDone = item.session_status === '협상완료'
const enterLabel = ended ? '결과 보기' : '협상 입장'
// 재협상: 요청 가능하면 버튼, 이미 요청했으면 진행 상태를 보여준다.
@ -118,17 +113,8 @@ function Card({
</div>
))}
{(canEnter || canReject || isDone || isRejected || item.renegotiable) && (
{(canEnter || canReject || isDone || item.renegotiable) && (
<div className="mt-3 flex gap-2">
{isRejected && (
<button
type="button"
onClick={() => onRejectDetail(item)}
className="flex-1 rounded-lg border border-brand-600/40 py-2 text-xs font-bold text-brand-700 transition-all hover:bg-brand-50 active:scale-[0.98]"
>
</button>
)}
{item.renegotiable && (
<button
type="button"

View File

@ -14,14 +14,13 @@ interface WorkspaceTableProps {
onExtraInfo: (item: ListItem) => void
onRenegotiate: (item: ListItem) => void
onMemo: (item: ListItem) => void
onRejectDetail: (item: ListItem) => void
}
// th 기본 정렬은 center — 정렬은 베이스에 넣지 않고 컬럼마다 명시한다(cn 이 tailwind-merge 가 아니라 충돌 시 승자가 불명확).
const HEAD = 'px-5 py-3.5 text-[11px] font-bold uppercase tracking-wider text-neutral-60 whitespace-nowrap'
const CELL = 'px-5 py-4 align-middle text-sm text-neutral-80'
export function WorkspaceTable({ items, isLoading, busyId, onEnter, onReject, onExtraInfo, onRenegotiate, onMemo, onRejectDetail }: WorkspaceTableProps) {
export function WorkspaceTable({ items, isLoading, busyId, onEnter, onReject, onExtraInfo, onRenegotiate, onMemo }: WorkspaceTableProps) {
return (
<div className="w-full overflow-x-auto">
<table className="w-full min-w-[900px] border-collapse">
@ -47,7 +46,7 @@ export function WorkspaceTable({ items, isLoading, busyId, onEnter, onReject, on
</StateRow>
) : (
items.map((item) => (
<Row key={item.session_id} item={item} busy={busyId === item.session_id} onEnter={onEnter} onReject={onReject} onExtraInfo={onExtraInfo} onRenegotiate={onRenegotiate} onMemo={onMemo} onRejectDetail={onRejectDetail} />
<Row key={item.session_id} item={item} busy={busyId === item.session_id} onEnter={onEnter} onReject={onReject} onExtraInfo={onExtraInfo} onRenegotiate={onRenegotiate} onMemo={onMemo} />
))
)}
</tbody>
@ -64,7 +63,6 @@ function Row({
onExtraInfo,
onRenegotiate,
onMemo,
onRejectDetail,
}: {
item: ListItem
busy: boolean
@ -73,14 +71,11 @@ function Row({
onExtraInfo: (item: ListItem) => void
onRenegotiate: (item: ListItem) => void
onMemo: (item: ListItem) => void
onRejectDetail: (item: ListItem) => void
}) {
const meta = statusMeta(item.session_status)
const ended = isEndedStatus(item.session_status)
const canEnter = canEnterChat(item.session_status, item.hasChat)
const canReject = ['협상생성', '협상중'].includes(item.session_status)
// 거부 건은 제출 내역이 대화에 남지 않는다 — 세션에 적힌 사유를 여기서만 다시 볼 수 있다.
const isRejected = item.session_status === '협상거부'
const isDone = item.session_status === '협상완료'
const enterLabel = ended ? '결과 보기' : '협상 입장'
@ -150,15 +145,6 @@ function Row({
</button>
)}
{isRejected && (
<button
type="button"
onClick={() => onRejectDetail(item)}
className="rounded-lg border border-brand-600/40 px-3 py-1.5 text-xs font-bold text-brand-700 transition-all hover:bg-brand-50 active:scale-[0.98]"
>
</button>
)}
{canReject && (
<button
type="button"

View File

@ -10,7 +10,6 @@ import {
useRequestRenegotiationMutation,
useSaveExtraInfoMutation,
} from '@/apis'
import { RejectDetailPopup, RejectPopup, type RejectSubmitPayload } from '@/components'
import { cn, toast } from '@/lib'
import { useList } from '@/features/list/hooks/useList'
import { useListStore } from '@/features/list/stores/useListStore'
@ -20,6 +19,7 @@ import { SortControl } from '@/features/list/components/SortControl'
import { WorkspaceTable } from '@/features/list/components/WorkspaceTable'
import { WorkspaceCards } from '@/features/list/components/WorkspaceCards'
import { Pagination } from '@/features/list/components/Pagination'
import { RejectPopup } from '@/features/list/components/RejectPopup'
import { ExtraInfoPopup } from '@/features/list/components/ExtraInfoPopup'
import { RenegotiationPopup } from '@/features/list/components/RenegotiationPopup'
import { RenegotiationMemoPopup } from '@/features/list/components/RenegotiationMemoPopup'
@ -68,7 +68,6 @@ export function ListWorkspace() {
const [extraTarget, setExtraTarget] = useState<ListItem | null>(null)
const [renegoTarget, setRenegoTarget] = useState<ListItem | null>(null)
const [memoTarget, setMemoTarget] = useState<ListItem | null>(null)
const [rejectDetailTarget, setRejectDetailTarget] = useState<ListItem | null>(null)
const [guideOpen, setGuideOpen] = useState(false)
const handleEnter = (item: ListItem) => {
@ -96,15 +95,12 @@ export function ListWorkspace() {
setRejectTarget(item)
}
const handleRejectSubmit = (request: RejectSubmitPayload) => {
const handleRejectSubmit = (reason: string) => {
if (!rejectTarget) return
reject.mutate(
{ sessionId: rejectTarget.session_id, request },
{ sessionId: rejectTarget.session_id, request: { reject_reason: reason } },
{
onSuccess: () => {
setRejectTarget(null)
toast.warning('협상 거부가 완료되었습니다.')
},
onSuccess: () => toast.warning('협상 거부가 완료되었습니다.'),
onError: (error) => toast.error(getApiErrorMessage(error, '거부 처리에 실패했습니다.')),
},
)
@ -194,7 +190,6 @@ export function ListWorkspace() {
onExtraInfo={setExtraTarget}
onRenegotiate={setRenegoTarget}
onMemo={setMemoTarget}
onRejectDetail={setRejectDetailTarget}
/>
</div>
<div className="lg:hidden">
@ -207,7 +202,6 @@ export function ListWorkspace() {
onExtraInfo={setExtraTarget}
onRenegotiate={setRenegoTarget}
onMemo={setMemoTarget}
onRejectDetail={setRejectDetailTarget}
/>
</div>
@ -219,11 +213,7 @@ export function ListWorkspace() {
{guideOpen && <GuidePopup onClose={() => setGuideOpen(false)} />}
{rejectTarget && (
<RejectPopup
onClose={() => setRejectTarget(null)}
onSubmit={handleRejectSubmit}
isPending={reject.isPending}
/>
<RejectPopup onClose={() => setRejectTarget(null)} onSubmit={handleRejectSubmit} />
)}
{extraTarget && (
@ -238,16 +228,6 @@ export function ListWorkspace() {
/>
)}
{rejectDetailTarget && (
<RejectDetailPopup
onClose={() => setRejectDetailTarget(null)}
subtitle={`${rejectDetailTarget.qt_number} · ${rejectDetailTarget.item_name}`}
reason={rejectDetailTarget.rejectReason}
price={rejectDetailTarget.rejectPrice}
opinion={String(rejectDetailTarget.custom?.opinion ?? '')}
/>
)}
{memoTarget && (
<RenegotiationMemoPopup target={memoTarget} onClose={() => setMemoTarget(null)} />
)}

View File

@ -43,7 +43,5 @@ export function toListItem(api: SessionListItem): ListItem {
renegotiationMemo: api.renegotiation_memo ?? '',
result: api.result ?? 0,
hasChat: api.has_chat ?? false,
rejectReason: api.reject_reason ?? '',
rejectPrice: api.reject_price ?? null,
}
}

View File

@ -14,6 +14,4 @@ export type ListItem = {
renegotiationMemo: string // 담당자 심사 메모(반려 사유)
result: number // 협상 결과: 0=미정 1=낙찰 2=미낙찰 3=결렬(개찰)
hasChat: boolean // 대화 이력 존재 — 종료된 협상의 '결과 보기' 노출 조건
rejectReason: string // 협상 거부로 끝난 건이 제출한 사유. 거부 건이 아니면 ''
rejectPrice: number | null // 거부와 함께 낸 공급 희망 가격(원)
}

View File

@ -4,4 +4,3 @@ export type { ClassValue } from '@/lib/cn'
export { interactive } from '@/lib/interactive'
export { toast } from '@/lib/toast'
export { formatKstDateTime, KST_TIME_ZONE } from '@/lib/datetime'
export { numberToKorean } from '@/lib/koreanNumber'

1
landing/.gitignore vendored
View File

@ -1,4 +1,3 @@
node_modules
build
.react-router
.vercel

View File

@ -1,114 +0,0 @@
import type { VercelRequest, VercelResponse } from "@vercel/node"
/*
* (Vercel ).
*
* ssr:false . /api .
* , CORS .
*
*
* 1.2 .
* "접수되었습니다" , .
*
* .
* 1) . Vercel .
* 2) LEAD_WEBHOOK_URL (Slack Incoming Webhook·Zapier· API POST ).
* 3) 200 1)
* . .
*
* CRM . LEAD_WEBHOOK_URL .
*/
const MAX_FIELD = 500
type LeadBody = {
source?: string
name?: string
email?: string
company?: string
phone?: string
message?: string
/* 봇 함정. 사람 눈에 안 보이는 필드라 값이 차 있으면 자동 제출이다. */
website?: string
}
const clean = (v: unknown) => (typeof v === "string" ? v.trim().slice(0, MAX_FIELD) : "")
/** 느슨한 검사. 정규식으로 이메일을 엄밀히 검증하려는 시도는 늘 진짜 주소를 막는다. */
const emailLike = (v: string) => v.length >= 5 && v.includes("@") && !v.startsWith("@") && !v.endsWith("@") && !/\s/.test(v)
export default async function handler(req: VercelRequest, res: VercelResponse) {
if (req.method !== "POST") {
res.setHeader("Allow", "POST")
return res.status(405).json({ ok: false, error: "method_not_allowed" })
}
const body = (typeof req.body === "string" ? safeParse(req.body) : req.body) as LeadBody | null
if (!body) return res.status(400).json({ ok: false, error: "invalid_json" })
// 봇은 조용히 돌려보낸다. 400 을 주면 어떤 필드가 함정인지 알려주는 셈이다.
if (clean(body.website)) return res.status(200).json({ ok: true })
const lead = {
source: clean(body.source) || "unknown",
name: clean(body.name),
email: clean(body.email),
company: clean(body.company),
phone: clean(body.phone),
message: clean(body.message),
submittedAt: new Date().toISOString(),
userAgent: clean(req.headers["user-agent"]),
}
if (!lead.name || !emailLike(lead.email)) {
return res.status(400).json({ ok: false, error: "invalid_input" })
}
// 1) 무슨 일이 있어도 먼저 남긴다.
console.log("[lead]", JSON.stringify(lead))
// 2) 사람이 실제로 보는 곳으로 전달.
const webhook = process.env.LEAD_WEBHOOK_URL
if (!webhook) {
console.warn("[lead] LEAD_WEBHOOK_URL 미설정 — 리드가 로그에만 남습니다. 웹훅을 설정하세요.")
return res.status(200).json({ ok: true, delivered: false })
}
try {
const upstream = await fetch(webhook, {
method: "POST",
headers: { "Content-Type": "application/json" },
// Slack Incoming Webhook 은 text 를 읽고, 나머지 수신처는 보통 원본 필드를 읽는다.
// 둘 다 담아 보내면 수신처를 바꿀 때 이 파일을 고칠 일이 없다.
body: JSON.stringify({ text: summarize(lead), ...lead }),
})
if (!upstream.ok) {
console.error("[lead] 웹훅 전달 실패", upstream.status, JSON.stringify(lead))
return res.status(200).json({ ok: true, delivered: false })
}
return res.status(200).json({ ok: true, delivered: true })
} catch (e) {
console.error("[lead] 웹훅 예외", e, JSON.stringify(lead))
return res.status(200).json({ ok: true, delivered: false })
}
}
function safeParse(s: string) {
try {
return JSON.parse(s)
} catch {
return null
}
}
function summarize(l: { source: string; name: string; email: string; company: string; phone: string; message: string }) {
const rows = [
`*새 리드* (${l.source})`,
`이름: ${l.name}`,
`이메일: ${l.email}`,
l.company && `회사: ${l.company}`,
l.phone && `연락처: ${l.phone}`,
l.message && `내용: ${l.message}`,
].filter(Boolean)
return rows.join("\n")
}

View File

@ -10,112 +10,38 @@
src: url('/fonts/PretendardVariable.woff2') format('woff2');
}
/* 영문 디스플레이 서체 이탤릭 가변(400~900), latin 서브셋.
INFINITH 디자인 시스템과 같은 Playfair Display 쓴다. */
@font-face {
font-family: 'Playfair Display';
font-style: italic;
font-weight: 400 900;
font-display: swap;
src: url('/fonts/PlayfairDisplay-Italic.woff2') format('woff2');
}
/*
* 랜딩 디자인 토큰.
* 랜딩 디자인 토큰 (Toss 스타일 라이트 팔레트).
* 색은 전부 여기서만 정의하고, 컴포넌트는 토큰 클래스(text-ink, bg-primary ) 쓴다.
*
* 구조는 겹이다.
* - 다크 무대(stage) : 히어로·협상 데모. 협상이 벌어지는 순간에만 조명을 끈다.
* - 라이트 본문 : 나머지 섹션. 광고 유입 전환율을 위해 밝게 유지.
*/
@theme {
--font-sans: 'Pretendard Variable', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Apple SD Gothic Neo", "Malgun Gothic", sans-serif;
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
/* 영문 디스플레이 라벨·강조 전용. 한글 본문은 절대 이걸로 쓰지 않는다
(Playfair 한글 글리프가 없어 Pretendard 폴백되며 톤이 깨진다). */
--font-display: 'Playfair Display', 'Pretendard Variable', Georgia, serif;
/* 브랜드 액센트
여기 3줄이 브랜드 색상각의 단일 소스다. 딥틸(#005961 / #00434A / #E3F4F5)
되돌리려면 3줄만 갈아끼우면 되고, 나머지 토큰·컴포넌트는 손대지 않는다. */
--color-primary: #0101F3; /* 일렉트릭 블루 — 라이트 배경 전용. 레퍼런스 실측값 */
/* 다크 무대용 액센트. #0101F3 #070E24 위에 얹으면 명암비가 2.0:1 ,
WCAG 최소(4.5:1) 절반도 된다 어두운 색이라 그렇다.
아래 값은 무대 배경 대비 6:1 이다. 무대 파란 글자는 반드시 이걸 쓴다. */
--color-primary-on-stage: #7C8CFF;
--color-primary-deep: #0101C4; /* hover */
--color-primary-soft: #EAEAFF; /* 연한 배경 — 에이전트 말풍선, 배지 */
/* 브랜드 */
--color-primary: #476EFF;
--color-primary-deep: #2F52E0; /* hover */
--color-primary-soft: #ECF0FF; /* 연한 파랑 배경 — 봇 말풍선, 배지 */
/* 상대편(공급사)
협상은 주체가 구분돼야 읽힌다. 민트는 블루와 색상각 70° 차이라
구분되면서도 같은 한색 계열이라 화면이 따로 놀지 않는다.
NEGO WIZ 로고 민트(#0FFFD6)에서 왔다. */
--color-counter: #0FFFD6; /* 무대 위 상대측 텍스트·강조 */
--color-counter-deep: #077A66; /* 라이트 배경 위 텍스트 (흰 바탕 대비 확보용) */
--color-counter-surface: #0A2F35; /* 무대 위 상대측 말풍선 바닥 */
/* ── 다크 무대 ────────────────────────────────────────────────── */
--color-stage: #070E24; /* 무대 바닥 (그라데이션의 중간값) */
--color-stage-deep: #03060F; /* 무대 가장자리 — 어두워지며 공간이 뒤로 물러난다 */
--color-stage-lift: #16205A; /* 무대 상단 광원 — 여기서 빛이 온다 */
--color-stage-raised: #101A3D; /* 무대 위 카드·상대측 말풍선 */
--color-stage-line: #1E2A52; /* 무대 위 테두리 */
--color-on-stage: #FFFFFF; /* 무대 위 1차 텍스트 */
/* 2·3차 텍스트는 중립 회색 대신 연보라 계열로 둔다. 네이비 위에 색상각이 다른
회색을 얹으면 탁해지는데, 같은 한색 가족이면 밝게 올려도 화면이 정돈된다.
#F5F3FF ADO2 디자인 시스템의 연보라 패널 색이다. */
--color-on-stage-soft: #F5F3FF; /* 무대 위 2차 텍스트 — 2톤 헤드라인의 약한 쪽 */
--color-on-stage-muted: #C6C0E4; /* 무대 위 라벨·캡션 */
/* 라이트 본문 텍스트
먹색에 브랜드 색조를 섞어둔다. 순수 회색이면 페이지가 따로 논다. */
--color-ink: #0B1024;
--color-ink-soft: #454E6B; /* 보조 본문 */
--color-ink-muted: #838CAA; /* 캡션 */
--color-ink-faint: #AAB1C7; /* 플레이스홀더·각주 */
/* 텍스트. ink 는 다크 섹션에선 배경색으로도 쓴다 */
--color-ink: #191F28;
--color-ink-soft: #4E5968; /* 보조 본문 */
--color-ink-muted: #8B95A1; /* 캡션 */
--color-ink-faint: #B0B8C1; /* 플레이스홀더·각주 */
/* 면·선 */
--color-surface: #F7F8FC; /* 회색 섹션 배경 */
--color-surface-neu: #F1F3F9; /* 보조 섹션 배경 */
--color-fill: #F0F2F8; /* 회색 채움 — 탭 트랙, 보조 버튼 */
--color-fill-hover: #E6E9F2;
--color-line: #EEF0F6; /* 섹션 구분선 */
--color-line-strong: #E0E4EE; /* 카드 테두리 */
--color-surface: #F9FAFB; /* 회색 섹션 배경 */
--color-surface-neu: #F2F4F7; /* 뉴모피즘 히어로 전용 배경 */
--color-fill: #F2F4F6; /* 회색 채움 — 탭 트랙, 보조 버튼 */
--color-fill-hover: #EAECEF;
--color-line: #F2F4F6; /* 섹션 구분선 */
--color-line-strong: #E5E8EB; /* 카드 테두리 */
--color-positive: #0B8F57; /* 낙찰·성공 강조 */
--color-positive-soft: #E8F7EF; /* 연한 초록 배경 — 낙찰 배지 */
--color-accent: #8F52FF; /* 보라 강조 — 결과 탭·히어로 장식 */
--color-accent-soft: #F4EFFF; /* 연한 보라 배경 */
/* 모서리
레퍼런스 실측은 3.5~7px 이다. 20~32px 짜리 뭉툭한 카드가 제네릭 SaaS
가장 강한 신호라, 여기서는 값으로만 고정한다.
card 카드·패널·목업
control ·배지·입력 작은 요소
full 버튼·아바타 (Tailwind 기본 rounded-full 사용) */
--radius-card: 8px;
--radius-control: 4px;
}
/* 다크 무대 배경 그라데이션.
단색 배경은 아무리 입자에 원근을 줘도 평면으로 읽힌다. 위쪽에 광원을 두고
가장자리로 갈수록 어두워지게 하면 화면 자체에 안팎이 생겨 깊이가 성립한다. */
.stage-bg {
background:
radial-gradient(120% 78% at 50% 8%, var(--color-stage-lift) 0%, transparent 62%),
radial-gradient(100% 100% at 50% 42%, var(--color-stage) 0%, var(--color-stage-deep) 100%);
}
/* 드래그 선택 .
루트에 걸린 selection:text-primary 라이트 본문 기준이다. (#0101F3) 다크 무대
위에 얹으면 명암비가 2.0:1 이라 --color-primary-on-stage 주석이 경고하는 바로
상황 무대 섹션에서 텍스트를 드래그하면 글자가 사라진다.
무대 안에서는 무대용 액센트로 갈아끼운다. 라이트 본문은 기존 그대로. */
.stage-bg ::selection,
[data-stage-hero] ::selection {
background-color: color-mix(in srgb, var(--color-primary-on-stage) 30%, transparent);
color: var(--color-on-stage);
}
html {

View File

@ -1,9 +1,9 @@
import { useEffect, useRef, useState } from "react"
import { AnimatePresence, motion } from "motion/react"
import { Clock, Database, Handshake, Scale, TrendingUp, UserRound, type LucideIcon } from "lucide-react"
import { Bot, Clock, Database, Handshake, Scale, TrendingUp, UserRound, type LucideIcon } from "lucide-react"
import { Section } from "@/components/ui/section"
import { CONTROL_GAP, SectionHeading } from "@/components/ui/section-heading"
import { SectionHeading } from "@/components/ui/section-heading"
import { Typography } from "@/components/ui/typography"
import { useFadeUp } from "@/lib/motion"
@ -44,31 +44,28 @@ export function Comparison() {
return (
<Section id="comparison">
<motion.div {...fadeUp}>
<motion.div {...fadeUp} className="mb-12">
<SectionHeading
align="center"
eyebrow="Before & After"
eyebrow="WHY NEGOTIUM"
title="협상 방식을 바꾸면, 무엇이 달라질까요"
description="기존 가격 협상의 문제는 사람이 아니라 구조입니다."
descriptionClassName="max-w-2xl"
descriptionClassName="mt-4 text-[17px] max-w-2xl"
/>
</motion.div>
{/* .
(CONTROL_GAP) .
, . */}
<motion.div {...toggleFadeUp} className={`flex justify-center ${CONTROL_GAP}`}>
{/* bg-white (bg-fill #F0F2F8)
. . */}
<div className="p-1.5 bg-fill rounded-full inline-flex gap-1.5">
{/* 모드 토글 — 이용 가이드 탭과 같은 세그먼트 문법 */}
<motion.div {...toggleFadeUp} className="flex justify-center mb-14">
<div className="p-1 bg-fill rounded-[14px] inline-flex gap-1">
{MODES.map((m) => (
<button
key={m.key}
onClick={() => selectMode(m.key)}
className={`px-7 py-3 rounded-full text-[15px] font-semibold transition-colors cursor-pointer ${
mode === m.key ? "bg-primary text-white" : "text-ink-muted hover:text-ink"
className={`px-5 py-2.5 rounded-[10px] text-xs sm:text-sm font-bold transition-all flex items-center gap-2 cursor-pointer ${
mode === m.key ? "bg-white text-primary shadow-sm" : "text-ink-soft hover:text-ink"
}`}
>
{m.key === "after" && <Bot className="w-4 h-4" />}
<span>{m.label}</span>
</button>
))}
@ -88,7 +85,7 @@ type Mode = "before" | "after"
const MODES: { key: Mode; label: string }[] = [
{ key: "before", label: "기존 방식" },
{ key: "after", label: "네고시움 도입 후" },
{ key: "after", label: "negotium 도입 후" },
]
type CompareItem = { label: string; icon: LucideIcon; before: string; after: string }
@ -100,13 +97,13 @@ function CompareCard({ item, mode, index }: { item: CompareItem; mode: Mode; ind
return (
<motion.div
{...fadeUp}
className={`p-7 rounded-card flex flex-col gap-5 transition-colors duration-500 ${
className={`p-7 rounded-[28px] flex flex-col gap-5 transition-colors duration-500 ${
active ? "bg-primary-soft/60" : "bg-surface"
}`}
>
<div className="flex items-center justify-between">
<div
className={`w-11 h-11 rounded-card flex items-center justify-center transition-colors duration-500 ${
className={`w-11 h-11 rounded-2xl flex items-center justify-center transition-colors duration-500 ${
active ? "bg-primary/10 text-primary" : "bg-ink-muted/10 text-ink-muted"
}`}
>
@ -159,14 +156,14 @@ const ITEMS: CompareItem[] = [
{
label: "사람",
icon: UserRound,
before: "반복 협상에 시간을 뺏겨 전략 업무가 계속 밀립니다.",
after: "반복 협상은 에이전트가 맡고, 담당자는 전략 구매와 대형 건에 집중합니다.",
before: "반복 흥정과 감정 노동에 지쳐 고급 인력이 이탈합니다.",
after: "감정 소모는 봇이 맡고, 사람은 전략 업무와 대형 거래에 집중합니다.",
},
{
label: "파트너 관계",
icon: Handshake,
before: "담당자마다 기준이 달라 협력사가 조건을 예측하기 어렵습니다.",
after: "에이전트가 같은 기준으로 상시 응대해 조건이 일관됩니다.",
before: "감정 과열과 실랑이로 장기 파트너십이 흔들립니다.",
after: "악역은 봇이 — 압박 없는 상시 협상으로 관계가 개선됩니다.",
},
{
label: "의사 결정",

View File

@ -1,6 +1,6 @@
import { useState } from "react"
import { AnimatePresence, motion } from "motion/react"
import { Check, Loader2 } from "lucide-react"
import { Building, CheckCircle2, Loader2, Mail, Phone, Send } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input, Textarea } from "@/components/ui/input"
@ -8,7 +8,7 @@ import { Section } from "@/components/ui/section"
import { SectionHeading } from "@/components/ui/section-heading"
import { Typography } from "@/components/ui/typography"
import { EASE_OUT_EXPO } from "@/lib/motion"
import { submitLead } from "@/lib/lead"
import type { LucideIcon } from "lucide-react"
const EMPTY_FORM = {
companyName: '',
@ -18,33 +18,19 @@ const EMPTY_FORM = {
message: '',
}
/*
* . .
*
* 1.2 . "접수되었습니다"
* , .
* (api/lead.ts) , .
*/
/** 도입 문의 폼. 백엔드 미연결 — 제출은 데모 처리(1.2초 후 성공 화면). */
export function Contact() {
const [formData, setFormData] = useState(EMPTY_FORM)
const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle')
const [status, setStatus] = useState<'idle' | 'submitting' | 'success'>('idle')
const handleSubmit = async (e: React.FormEvent) => {
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!formData.companyName || !formData.contactName || !formData.email || !formData.phone) {
return
}
setStatus('submitting')
const result = await submitLead({
source: 'contact',
name: formData.contactName,
email: formData.email,
company: formData.companyName,
phone: formData.phone,
message: formData.message,
})
setStatus(result.ok ? 'success' : 'error')
setTimeout(() => setStatus('success'), 1200)
}
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
@ -55,12 +41,22 @@ export function Contact() {
const submitting = status === 'submitting'
return (
<Section id="contact-section" bordered>
<Section
id="contact-section"
bordered
decor={
<>
<div className="absolute top-[20%] right-[10%] w-72 h-72 bg-primary/5 blur-[100px] rounded-full pointer-events-none" />
<div className="absolute bottom-[10%] left-[5%] w-96 h-96 bg-primary/3 blur-[120px] rounded-full pointer-events-none" />
</>
}
>
<SectionHeading
align="center"
eyebrow="Get in Touch"
title="도입 문의"
description="품목과 협력사 규모를 알려주시면, 예상 절감 구간과 도입 절차를 정리해 드립니다."
className="mb-16"
eyebrow="GET IN TOUCH"
title="도입 문의 및 컨택하기"
description="사내 ERP 연동부터 우리 기업에 맞춘 흥정 시나리오 구성까지, negotium의 구매 혁신 컨설턴트가 상세히 안내해 드립니다."
descriptionClassName="max-w-xl"
/>
@ -74,10 +70,10 @@ export function Contact() {
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.5, ease: EASE_OUT_EXPO }}
className="space-y-9"
className="space-y-6"
>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-9">
<FormField label="회사명" required>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
<FormField label="회사명" icon={Building} required>
<Input
type="text"
name="companyName"
@ -102,8 +98,8 @@ export function Contact() {
</FormField>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-9">
<FormField label="이메일 주소" required>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
<FormField label="이메일 주소" icon={Mail} required>
<Input
type="email"
name="email"
@ -115,7 +111,7 @@ export function Contact() {
/>
</FormField>
<FormField label="연락처" required>
<FormField label="연락처" icon={Phone} required>
<Input
type="tel"
name="phone"
@ -132,34 +128,30 @@ export function Contact() {
<Textarea
name="message"
rows={4}
placeholder="협상 자동화를 검토 중인 품목이나 현재 겪는 어려움을 적어주세요."
placeholder="현재 겪고 계신 구매 조율 상의 번거로움이나, 자동화를 원하시는 구체적인 부자재 품목 정보를 남겨주시면 더욱 맞춤화된 상담이 가능합니다."
value={formData.message}
onChange={handleChange}
disabled={submitting}
/>
</FormField>
{/* ( ) .
. */}
{status === 'error' && (
<p role="alert" className="text-[13px] text-ink-soft leading-[1.6] break-keep">
. .
</p>
)}
<Typography variant="caption" className="font-medium">
.
* ,
.
</Typography>
<div className="pt-2">
<Button type="submit" disabled={submitting} size="lg" className="w-full">
<Button type="submit" disabled={submitting} className="w-full shadow-sm hover:shadow-md">
{submitting ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
<span> ...</span>
</>
) : (
<span> </span>
<>
<Send className="w-5 h-5" />
<span> </span>
</>
)}
</Button>
</div>
@ -170,14 +162,15 @@ export function Contact() {
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.6, ease: EASE_OUT_EXPO }}
className="text-center py-20 px-6 border border-line-strong rounded-card text-ink break-keep"
className="text-center py-16 px-6 bg-primary/5 rounded-[32px] text-ink break-keep"
>
<div className="w-12 h-12 bg-primary text-white rounded-full flex items-center justify-center mx-auto mb-6">
<Check className="w-6 h-6" strokeWidth={2.5} />
<div className="w-16 h-16 bg-primary/10 text-primary rounded-full flex items-center justify-center mx-auto mb-6">
<CheckCircle2 className="w-8 h-8" />
</div>
<h3 className="text-[24px] font-semibold tracking-[-0.02em] text-ink mb-3"> </h3>
<p className="text-ink-soft text-[15px] leading-[1.6] max-w-md mx-auto mb-8">
. , {formData.contactName} .
<h3 className="text-2xl font-extrabold text-ink mb-3"> </h3>
<p className="text-ink-soft font-semibold text-[15px] leading-relaxed max-w-md mx-auto mb-8">
({formData.contactName} ) 24
.
</p>
<Button
type="button"
@ -197,14 +190,22 @@ export function Contact() {
)
}
/* ""
, . . */
function FormField({ label, required = false, children }: { label: string; required?: boolean; children: React.ReactNode }) {
function FormField({
label,
icon: Icon,
required = false,
children,
}: {
label: string
icon?: LucideIcon
required?: boolean
children: React.ReactNode
}) {
return (
<div className="space-y-2.5">
<label className="block text-[14px] font-medium text-ink">
{label}
{required && <span className="text-primary ml-1" aria-hidden>*</span>}
<div className="space-y-2">
<label className="text-xs font-bold text-ink-soft flex items-center gap-1.5">
{Icon && <Icon className="w-3.5 h-3.5 text-ink-muted" />}
{label} {required && <span className="text-primary">*</span>}
</label>
{children}
</div>

View File

@ -12,12 +12,13 @@ export function CoreValues() {
return (
<Section id="core-values">
<motion.div {...fadeUp}>
<motion.div {...fadeUp} className="mb-24">
<SectionHeading
align="center"
eyebrow="Beyond Price"
eyebrow="BEYOND EXPECTED VALUE"
title="가격 그 이상의 이점"
description="절감액은 시작일 뿐 — 관계, 투명성, 그리고 사람의 시간까지 지킵니다."
descriptionClassName="mt-4 text-[17px]"
/>
</motion.div>
@ -36,7 +37,7 @@ function ValueStrip({ value, delay }: { value: Value; delay: number }) {
const fadeUp = useFadeUp(delay)
return (
<motion.div {...fadeUp} className="bg-surface p-8 rounded-card flex items-start gap-5 transition-all hover:translate-y-[-4px]">
<motion.div {...fadeUp} className="bg-surface p-8 rounded-[32px] flex items-start gap-5 transition-all hover:translate-y-[-4px]">
<div className="w-10 h-10 rounded-full bg-primary/5 flex items-center justify-center text-primary shrink-0 mt-1">
<value.icon className="w-5 h-5" />
</div>
@ -55,7 +56,7 @@ const VALUES: Value[] = [
icon: Users,
title: "파트너사 관계 수호",
description:
"단가 조율은 에이전트가 정해진 기준으로 반복합니다. 담당자는 협력사와의 신뢰와 동반 성장에 집중할 수 있습니다.",
"단가를 깎는 악역과 감정 소모는 봇이 맡습니다. 담당자는 협력사와의 신뢰와 동반 성장, 큰 틀의 파트너십에만 집중하세요.",
},
{
icon: Lock,
@ -67,7 +68,7 @@ const VALUES: Value[] = [
icon: Briefcase,
title: "핵심 전략에만 집중",
description:
"이메일·메신저로 오가던 단가 협의는 에이전트가 전담합니다. 인력은 공급망 위기 대응과 신규 공급처 발굴에 투입하세요.",
"이메일·메신저로 반복되던 흥정 수작업은 봇이 전담합니다. 인력은 공급망 위기 대처, 우량 공급처 발굴 같은 전략 업무와 대형 거래에 투입하세요.",
},
{
icon: Clock,

View File

@ -14,12 +14,13 @@ export function Faq() {
return (
<Section id="faq" width="sm">
<motion.div {...fadeUp}>
<motion.div {...fadeUp} className="mb-24">
<SectionHeading
align="center"
eyebrow="FAQ"
title="자주 묻는 질문"
description="도입 검토에서 가장 많이 받는 질문들입니다."
descriptionClassName="mt-4 text-[17px]"
/>
</motion.div>
@ -54,7 +55,7 @@ function FaqItem({
const fadeUp = useFadeUp(delay)
return (
<motion.div {...fadeUp} className="bg-surface rounded-card overflow-hidden">
<motion.div {...fadeUp} className="bg-surface rounded-[28px] overflow-hidden">
<button
type="button"
onClick={onToggle}
@ -95,14 +96,14 @@ function FaqItem({
const FAQS: FaqEntry[] = [
{
question: "협력사가 에이전트와의 협상을 꺼리지 않을까요?",
question: "공급사가 봇과의 협상을 싫어하지 않을까요?",
answer:
"이 건들 대부분은 지금껏 협상 테이블에 오르지도 못하던 거래입니다. 에이전트는 협력사가 편한 시간에, 늘 같은 기준으로 응대합니다. 글로벌 동종 서비스의 공급사 만족도는 82%로 보고됩니다.",
"오히려 반대입니다. 이 거래들 대부분은 지금껏 협상 테이블에 오르지도 못하던 건입니다. 봇은 24시간 원하는 시간에, 압박 없이, 늘 같은 기준으로 응대합니다. 글로벌 동종 서비스의 공급사 만족도는 82%에 이릅니다.",
},
{
question: "에이전트가 우리 기준을 벗어나 합의해 버리면요?",
question: "봇이 우리 기준을 벗어나 합의해 버리면요?",
answer:
"그럴 수 없습니다. 에이전트는 견적을 만들 때 정한 목표가와 낙찰 기준 밖으로 나가지 않고, 최종 낙찰 규칙도 사용자가 정합니다.",
"그럴 수 없습니다. 봇은 견적을 만들 때 정한 목표가와 낙찰 기준 밖으로 나가지 않고, 최종 낙찰 규칙도 사용자가 정합니다.",
},
{
question: "기존 시스템과 연동되나요?",

View File

@ -2,71 +2,58 @@ import { motion } from "motion/react"
import { ArrowRight } from "lucide-react"
import { Button } from "@/components/ui/button"
import { NegotiationReplay } from "@/components/ui/negotiation-replay"
import { Section } from "@/components/ui/section"
import { CTA_GAP, SectionHeading } from "@/components/ui/section-heading"
import { useFadeUp } from "@/lib/motion"
/**
* CTA .
*
* 있었다: bg-ink( ), font-extrabold,
* , blur(120px) , (text-ink-muted).
* . AI .
*
* Section/SectionHeading .
* 20px ( 12px).
*
* . "실제 화면으로 보여드립니다"
* , .
*/
/** 최종 CTA — 다크 몰입 섹션. */
export function FinalCTA() {
const fadeUp = useFadeUp()
const mediaFadeUp = useFadeUp(0.12)
return (
/* width md(max-w-4xl) lg(max-w-5xl) .
4xl 2 . */
<Section bg="stage" width="lg">
<div className="grid grid-cols-1 lg:grid-cols-12 gap-12 lg:gap-16 items-center">
<motion.div {...fadeUp} className="lg:col-span-7 text-center lg:text-left">
<SectionHeading
align="left"
className="text-center lg:text-left"
tone="stage"
size="display"
gap="none"
eyebrow="Start Today"
/* CTA (74px) (64px) .
2 lg . */
titleClassName="text-[32px] sm:text-[44px] md:text-[56px] lg:text-[48px]"
title={
<>
<span className="text-white"> </span>
<br />
<span className="text-on-stage-soft/60"> </span>
</>
}
description="협상 테이블에 오르지 못하고 그냥 넘어가던 건들이 있습니다. 네고시움 에이전트가 정해진 기준 안에서 그 건들을 대신 조율합니다."
descriptionClassName="max-w-xl text-on-stage-soft/75 mx-auto lg:mx-0"
/>
<section className="relative bg-ink text-white py-36 md:py-48 overflow-hidden">
{/* 다크 섹션 중앙의 은은한 파란 글로우 */}
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[800px] h-[800px] rounded-full bg-primary/15 blur-[120px] pointer-events-none" />
<div className={`${CTA_GAP} flex justify-center lg:justify-start`}>
<Button href="#contact-section" variant="stage" size="xl" className="group">
<span> </span>
<ArrowRight className="w-4 h-4 transition-transform group-hover:translate-x-0.5" />
</Button>
</div>
<div className="max-w-3xl mx-auto px-6 text-center relative z-10">
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
whileInView={{ opacity: 1, scale: 1 }}
viewport={{ once: true }}
className="inline-flex items-center gap-2 px-4 py-1.5 bg-primary/10 border border-primary/20 rounded-full text-xs font-bold text-primary mb-8"
>
<span>START RECLAIMING YOUR BUDGET TODAY</span>
</motion.div>
{/* . negotiation_annotated_3d.mp4
("아이마켓 구매 MD") .
,
. */}
<motion.div {...mediaFadeUp} className="lg:col-span-5">
<NegotiationReplay className="mx-auto max-w-[360px] lg:max-w-none" />
<motion.h2
initial={{ opacity: 0, y: 24 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.8, delay: 0.1 }}
className="text-3xl sm:text-4xl md:text-[54px] font-extrabold tracking-tighter mb-8 text-white leading-[1.2] break-keep"
>
<br className="sm:hidden" />
</motion.h2>
<motion.p
initial={{ opacity: 0, y: 24 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.8, delay: 0.2 }}
className="text-[17px] text-ink-muted max-w-xl mx-auto mb-14 leading-relaxed font-semibold break-keep"
>
, negotium
.
</motion.p>
<motion.div
initial={{ opacity: 0, y: 16 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.8, delay: 0.3 }}
>
<Button href="#contact-section" size="xl" className="gap-2.5 hover:-translate-y-px">
<span> </span>
<ArrowRight className="w-5 h-5" />
</Button>
</motion.div>
</div>
</Section>
</section>
)
}

View File

@ -8,7 +8,7 @@ export function Footer() {
<div className="md:col-span-8">
<Logo className="mb-6" />
<Typography variant="caption" className="max-w-sm mb-6 font-medium">
1:1로 B2B .
negotium은 1:1로 B2B .
</Typography>
<p className="text-xs text-ink-faint font-semibold">
© {new Date().getFullYear()} negotium Co., Ltd. All rights reserved.

View File

@ -5,78 +5,36 @@ import { Button } from "@/components/ui/button"
import { Logo } from "@/components/ui/logo"
export function Header() {
// 두 상태는 조건이 다르다. 하나로 묶으면 다크 히어로 위에 흰 바가 떠버린다.
// compact — 조금이라도 스크롤하면 높이를 줄인다.
// onStage — 다크 히어로를 벗어나기 전까지. 이때는 배경을 깔지 않고 로고를 반전시킨다.
const [compact, setCompact] = useState(false)
const [onStage, setOnStage] = useState(true)
const [scrolled, setScrolled] = useState(false)
// 스크롤 시 헤더를 반투명 블러 배경으로 전환
useEffect(() => {
const handleScroll = () => setCompact(window.scrollY > 40)
const handleScroll = () => setScrolled(window.scrollY > 40)
window.addEventListener("scroll", handleScroll, { passive: true })
handleScroll()
return () => window.removeEventListener("scroll", handleScroll)
}, [])
/* " " .
scrollY (ScrollRestoration,
, ) 0
.
IntersectionObserver . */
useEffect(() => {
const stage = document.querySelector("[data-stage-hero]")
if (!stage) {
setOnStage(false) // 다크 히어로가 없는 페이지에서는 항상 라이트
return
}
// 헤더 높이(약 80px)만큼 위에서 미리 전환되도록 상단 마진을 음수로 준다.
const io = new IntersectionObserver(([entry]) => setOnStage(entry.isIntersecting), {
rootMargin: "-80px 0px 0px 0px",
threshold: 0,
})
io.observe(stage)
return () => io.disconnect()
}, [])
return (
<header
/* " " , .
nav . */
className={`fixed top-0 left-0 right-0 z-40 safe-t safe-x transition-all duration-300 ${compact ? "py-4" : "py-6"} ${
!compact
? "bg-transparent border-b border-transparent"
: onStage
? "bg-stage/80 backdrop-blur-xl border-b border-stage-line"
: "bg-white/80 backdrop-blur-xl border-b border-line"
className={`fixed top-0 left-0 right-0 z-40 safe-t safe-x transition-all duration-300 ${
scrolled ? "py-4 bg-white/80 backdrop-blur-xl border-b border-line" : "py-6 bg-transparent border-b border-transparent"
}`}
>
<div className="max-w-5xl mx-auto px-6 flex items-center justify-between">
<a href="#">
<Logo className={onStage ? "brightness-0 invert" : undefined} />
<Logo />
</a>
<nav
className={`hidden md:flex items-center gap-8 text-[15px] font-semibold transition-colors ${
onStage ? "text-on-stage-soft" : "text-ink-soft"
}`}
>
<nav className="hidden md:flex items-center gap-8 text-[15px] font-semibold text-ink-soft">
{NAV_LINKS.map(({ href, label }) => (
<a
key={href}
href={href}
className={`transition-colors ${onStage ? "hover:text-on-stage" : "hover:text-ink"}`}
>
<a key={href} href={href} className="hover:text-ink transition-colors">
{label}
</a>
))}
</nav>
<Button
href="#contact-section"
variant={onStage ? "stageGhost" : "primary"}
size={onStage ? "stageRound" : "pill"}
className="gap-1.5"
>
<Button href="#contact-section" size="pill" className="gap-1.5">
<span> </span>
<ArrowUpRight className="w-4 h-4" />
</Button>

View File

@ -0,0 +1,51 @@
import { motion } from "motion/react"
import { ArrowDown, ArrowRight } from "lucide-react"
import { Button } from "@/components/ui/button"
/** 히어로 변형 — 실시간 협상 콘솔 시즐 영상을 전면에 세운 풀와이드 히어로. */
export function HeroConsole() {
return (
<section className="relative min-h-screen bg-surface-neu text-ink flex flex-col justify-center pt-32 pb-20 overflow-hidden">
<div className="mesh-bg absolute top-[-260px] left-[-160px] opacity-40 pointer-events-none" />
<div className="max-w-6xl mx-auto px-6 w-full relative z-10">
<motion.div
initial={{ opacity: 0, y: 28, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ duration: 0.8, delay: 0.1 }}
className="rounded-[28px] overflow-hidden border border-line-strong shadow-[0_30px_80px_rgba(20,25,45,0.12)] bg-white"
>
<video
src="/gifs/hero_console.mp4"
poster="/gifs/hero_console.jpg"
autoPlay
muted
loop
playsInline
className="w-full h-auto block"
/>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.35 }}
className="flex flex-col sm:flex-row justify-center gap-5 pt-10"
>
<Button
href="#contact-section"
className="w-full sm:w-auto group shadow-[0_8px_24px_rgba(49,130,246,0.15)] hover:scale-[1.02] active:scale-[0.98]"
>
<span> </span>
<ArrowRight className="w-5 h-5 transition-transform group-hover:translate-x-1" />
</Button>
<Button href="#how-it-works" variant="glass" className="w-full sm:w-auto gap-1.5">
<span> </span>
<ArrowDown className="w-4 h-4 text-ink-soft" />
</Button>
</motion.div>
</div>
</section>
)
}

View File

@ -1,133 +0,0 @@
import { useState } from "react"
import { ArrowRight } from "lucide-react"
import { Button } from "@/components/ui/button"
import { DemoRequestModal } from "@/components/ui/demo-request-modal"
import { CTA_GAP, DISPLAY_LEAD_GAP } from "@/components/ui/section-heading"
import { Typography } from "@/components/ui/typography"
/**
* .
*
* negotium ( 240·6 · ),
* . .
* (CLUSTER_ANCHORS) .
* .
*
* 1. :
* - . ,
* .
* - . .
*/
export function HeroDataFlow() {
const [demoOpen, setDemoOpen] = useState(false)
return (
<section
data-stage-hero
className="stage-bg relative min-h-screen text-on-stage flex flex-col overflow-hidden"
>
{/* .
16:9 object-cover 62% 38%
1.7 (848x1220 )
. .
(stage-bg) . .
prefers-reduced-motion . */}
<div
aria-hidden
className="absolute inset-0 hidden landscape:block motion-reduce:hidden bg-cover bg-center"
style={{ backgroundImage: "url(/gifs/hero_loop.jpg)" }}
>
<video
className="w-full h-full object-cover"
src="/gifs/hero_loop.mp4"
poster="/gifs/hero_loop.jpg"
autoPlay
muted
loop
playsInline
preload="metadata"
/>
</div>
{/* .
(stage-deep) stop
.
90/60/95 . (/60)
, 40%
. .
nav( ),
, (h-32) . */}
<div
aria-hidden
className="absolute inset-0 pointer-events-none bg-linear-to-b from-stage-deep/50 via-transparent to-stage-deep/45"
/>
{/* .
.
( 74px) (17px) .
.
. */}
<div
aria-hidden
className="absolute inset-0 pointer-events-none"
style={{
background:
"radial-gradient(58% 42% at 50% 45%," +
" color-mix(in srgb, var(--color-stage-deep) 85%, transparent) 0%," +
" color-mix(in srgb, var(--color-stage-deep) 55%, transparent) 45%," +
" transparent 72%)",
}}
/>
{/* 아래 라이트 섹션과의 경계를 부드럽게 */}
<div aria-hidden className="absolute inset-x-0 bottom-0 h-32 bg-linear-to-b from-transparent to-stage pointer-events-none" />
{/* pt-[104px] 는 fixed 헤더 회피용 바닥. 그 아래 남은 공간에서 수직 중앙 정렬한다. */}
<div className="relative z-10 w-full flex-1 pt-[104px] pb-16 flex items-center">
<div className="max-w-4xl mx-auto px-6 w-full text-center">
{/* .
LCP opacity:0 ,
JS . . */}
{/* 2 .
, 60% .
. 2 CTA ( ). */}
<Typography variant="stageDisplay">
<span className="text-white"> 1:1로,</span>
<br />
<span className="text-white"> .</span>
</Typography>
{/* h1 SectionHeading(h2 ) .
CTA . */}
<p className={`${DISPLAY_LEAD_GAP} text-base sm:text-lg text-on-stage-soft leading-relaxed break-keep max-w-2xl mx-auto`}>
·· . ,
.
</p>
{/* 2 CTA (#contact-section) .
, ( ) .
"클릭하면 무슨 일이 일어나는가" .
. "예시" ,
"직접 경험해 보세요"·"해보기" "보기" .
. "체험"
. */}
<div className={`${CTA_GAP} flex flex-col sm:flex-row gap-3 justify-center`}>
<Button href="#how-it-works" variant="stage" size="stageRound" className="group">
<span> </span>
<ArrowRight className="w-4 h-4 transition-transform group-hover:translate-x-0.5" />
</Button>
<Button type="button" variant="stageGhost" size="stageRound" onClick={() => setDemoOpen(true)}>
</Button>
</div>
</div>
</div>
<DemoRequestModal open={demoOpen} onClose={() => setDemoOpen(false)} />
</section>
)
}

View File

@ -0,0 +1,85 @@
import { motion } from "motion/react"
import { ArrowDown, ArrowRight } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Typography } from "@/components/ui/typography"
import { EASE_OUT_EXPO } from "@/lib/motion"
/** 기본 히어로 — 파스텔 블롭 + 그리드 + 떠다니는 와이어프레임 도형. */
export function HeroGlassmorphic() {
return (
<section className="relative min-h-screen bg-surface text-ink flex flex-col justify-center pt-32 overflow-hidden">
{/* 흐릿한 파스텔 블롭 배경 */}
<div className="absolute top-[10%] right-[15%] w-[480px] h-[480px] bg-primary/10 rounded-full filter blur-[120px] pointer-events-none" />
<div className="absolute bottom-[10%] left-[10%] w-[450px] h-[450px] bg-accent/10 rounded-full filter blur-[120px] pointer-events-none" />
<div className="absolute top-[30%] left-[30%] w-[380px] h-[380px] bg-primary/10 rounded-full filter blur-[100px] pointer-events-none" />
{/* 저채도 그리드 패턴 오버레이 */}
<div className="absolute inset-0 bg-[linear-gradient(rgba(0,0,0,0.01)_1px,transparent_1px),linear-gradient(90deg,rgba(0,0,0,0.01)_1px,transparent_1px)] bg-[size:40px_40px] pointer-events-none" />
{/* 떠다니는 와이어프레임 도형 — max-w-7xl 안에 가둠 */}
<div className="absolute inset-0 max-w-7xl mx-auto pointer-events-none z-0">
<motion.div
animate={{ y: [0, -18, 0], rotate: [12, 42, 12] }}
transition={{ duration: 8, repeat: Infinity, ease: "easeInOut" }}
className="absolute top-[15%] left-[8vw] lg:left-[10%] w-16 h-16 sm:w-28 sm:h-28 border-2 border-primary/40 bg-white/40 shadow-[0_8px_32px_rgba(49,130,246,0.05)] rounded-[28px] pointer-events-none z-0"
/>
<motion.div
animate={{ rotate: -360 }}
transition={{ duration: 80, repeat: Infinity, ease: "linear" }}
className="absolute bottom-[18%] left-[6vw] lg:left-[12%] w-28 h-28 sm:w-48 sm:h-48 border-2 border-dashed border-accent/30 rounded-full pointer-events-none z-0"
/>
<motion.div
animate={{ x: [0, 10, 0], y: [0, 10, 0] }}
transition={{ duration: 10, repeat: Infinity, ease: "easeInOut" }}
className="absolute top-[30%] right-[8vw] lg:right-[12%] w-20 h-20 sm:w-36 sm:h-36 border-2 border-primary/20 rounded-[40px] pointer-events-none z-0"
>
<div className="w-full h-full bg-gradient-to-tr from-primary/5 to-transparent rounded-[38px]" />
</motion.div>
</div>
<div className="max-w-4xl mx-auto px-6 w-full py-24 relative z-10 flex flex-col items-center text-center">
<motion.div
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.9, delay: 0.1, ease: EASE_OUT_EXPO }}
>
<Typography variant="display" className="mb-8">
, <span className="text-primary"> </span> <br className="hidden sm:inline" />
</Typography>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.9, delay: 0.2, ease: EASE_OUT_EXPO }}
>
<Typography variant="lead" className="md:text-[20px] max-w-2xl mb-12">
. <br className="hidden sm:inline" />
, , 24 .
</Typography>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.9, delay: 0.3, ease: EASE_OUT_EXPO }}
className="flex flex-col sm:flex-row gap-4 items-center justify-center w-full max-w-md z-20"
>
<Button
href="#contact-section"
className="w-full sm:w-auto group shadow-[0_8px_24px_rgba(49,130,246,0.15)] hover:scale-[1.02] active:scale-[0.98]"
>
<span> </span>
<ArrowRight className="w-5 h-5 transition-transform group-hover:translate-x-1" />
</Button>
<Button href="#how-it-works" variant="glass" className="w-full sm:w-auto gap-1.5">
<span> </span>
<ArrowDown className="w-4 h-4 text-ink-soft" />
</Button>
</motion.div>
</div>
</section>
)
}

View File

@ -0,0 +1,73 @@
import { motion } from "motion/react"
import { ArrowDown, ArrowRight } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Typography } from "@/components/ui/typography"
/** 대안 히어로 — 뉴모피즘 배경 + 실제 모바일 협상 화면(GIF) 폰 목업. */
export function HeroNeumorphic() {
return (
<section className="relative min-h-screen bg-surface-neu text-ink flex flex-col justify-center pt-32 pb-20 overflow-hidden">
{/* 우측 상단 메시 제거 — 영상 배경(#F2F4F7)과 섹션 배경을 완전 동일하게 유지해 경계선 없이 블렌딩 */}
<div className="max-w-5xl mx-auto px-6 w-full relative z-10 grid grid-cols-1 lg:grid-cols-12 gap-16 items-center">
{/* 좌: 카피 + CTA */}
<div className="lg:col-span-7 space-y-8 text-left">
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.8, delay: 0.1 }}>
<Typography variant="display" className="text-4xl sm:text-5xl lg:text-[54px] leading-[1.15]">
, <br />
<span className="text-primary"> </span> <br />
</Typography>
</motion.div>
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.8, delay: 0.2 }}>
<Typography variant="lead" className="max-w-xl">
, .
24 .
</Typography>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.3 }}
className="flex flex-col sm:flex-row gap-5 pt-4"
>
<Button
href="#contact-section"
className="w-full sm:w-auto group shadow-[0_8px_24px_rgba(49,130,246,0.15)] hover:scale-[1.02] active:scale-[0.98]"
>
<span> </span>
<ArrowRight className="w-5 h-5 transition-transform group-hover:translate-x-1" />
</Button>
<Button href="#how-it-works" variant="glass" className="w-full sm:w-auto gap-1.5">
<span> </span>
<ArrowDown className="w-4 h-4 text-ink-soft" />
</Button>
</motion.div>
</div>
{/* 우: 모바일 협상 채팅 + 외부 설명 UI(제안가·협상카드·낙찰 하이라이트) 합성 영상 */}
<div className="lg:col-span-5 flex justify-center">
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.8, delay: 0.2 }}
className="w-full flex justify-center"
>
<video
src="/gifs/negotiation_annotated_3d.mp4?v=w3"
poster="/gifs/negotiation_annotated_3d.jpg?v=w3"
autoPlay
muted
loop
playsInline
className="w-full max-w-[440px] h-auto"
/>
</motion.div>
</div>
</div>
</section>
)
}

View File

@ -1,10 +1,10 @@
import { useState } from "react"
import { AnimatePresence, motion } from "motion/react"
import { Check, Monitor, Smartphone, type LucideIcon } from "lucide-react"
import { Bot, Check, Monitor, Smartphone, Sparkles, type LucideIcon } from "lucide-react"
import { BrowserFrame, PhoneFrame } from "@/components/ui/device-mockups"
import { Section } from "@/components/ui/section"
import { CONTROL_GAP, SectionHeading } from "@/components/ui/section-heading"
import { SectionHeading } from "@/components/ui/section-heading"
import { Typography } from "@/components/ui/typography"
/** 3단계 이용 가이드 — 탭 전환식 GIF 시연 (데스크톱/모바일 목업). */
@ -17,20 +17,21 @@ export function HowItWorksDemo() {
<Section id="how-it-works-demo" width="lg" bordered className="overflow-hidden">
<SectionHeading
align="center"
eyebrow="How it Works"
className="mb-20"
eyebrow="SERVICE DEMONSTRATION"
title="견적 생성부터 낙찰까지 한눈에 보기"
description="견적을 열고 협상이 끝나기까지, 실제 화면으로 보여드립니다."
descriptionClassName="max-w-2xl"
description="복잡해 보이는 구매 과정이 어떻게 자동화되는지 실제 작동 화면(GIF)을 통해 쉽고 직관적으로 확인해 보세요."
descriptionClassName="text-[17px] max-w-2xl"
/>
{/* 탭 스위치 — 비교 섹션 토글과 같은 간격 규칙(본문 쪽에 묶인다) */}
<div className={`flex justify-center ${CONTROL_GAP}`}>
<div className="p-1 bg-fill rounded-control inline-flex flex-wrap justify-center gap-1">
{/* 탭 스위치 */}
<div className="flex justify-center mb-16">
<div className="p-1 bg-fill rounded-[14px] inline-flex flex-wrap justify-center gap-1">
{DEMO_TABS.map((tab) => (
<button
key={tab.key}
onClick={() => setActiveKey(tab.key)}
className={`px-5 py-2.5 rounded-control text-xs sm:text-sm font-bold transition-all flex items-center gap-2 cursor-pointer ${
className={`px-5 py-2.5 rounded-[10px] text-xs sm:text-sm font-bold transition-all flex items-center gap-2 cursor-pointer ${
activeKey === tab.key ? "bg-white text-primary" : "text-ink-soft hover:text-ink"
}`}
>
@ -53,9 +54,10 @@ export function HowItWorksDemo() {
transition={{ duration: 0.4 }}
className="space-y-6"
>
{/* + .
11px/600/ normal . */}
<div className={`text-[11px] font-semibold ${activeTab.badge.className}`}>{activeTab.badge.label}</div>
<div className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-lg text-xs font-bold ${activeTab.badge.className}`}>
<activeTab.badge.icon className="w-3.5 h-3.5" />
{activeTab.badge.label}
</div>
<Typography variant="heading">{activeTab.heading}</Typography>
{activeTab.paragraphs.map((paragraph, idx) => (
<Typography key={idx} variant="body">
@ -157,7 +159,7 @@ type DemoTab = {
key: "create" | "negotiate" | "result"
tabIcon: LucideIcon
tabLabel: string
badge: { label: string; className: string }
badge: { icon: LucideIcon; label: string; className: string }
checkClassName: string
heading: React.ReactNode
paragraphs: React.ReactNode[]
@ -181,19 +183,19 @@ const DEMO_TABS: DemoTab[] = [
{
key: "create",
tabIcon: Monitor,
tabLabel: "1. 견적 · 기준 수립",
badge: { label: "구매 관리자 콘솔", className: "text-primary" },
tabLabel: "1단계: AI 견적 & 가이드 수립",
badge: { icon: Sparkles, label: "구매 관리자 콘솔 (웹)", className: "bg-primary-soft text-primary" },
checkClassName: "text-primary",
heading: (
<>
<br />
<br />
</>
),
paragraphs: [
<>
, <b> (LPS)</b> {" "}
<b className="text-primary"> .</b>
, negotium<b> (LPS)</b> {" "}
<b className="text-primary"> .</b>
</>,
<>
, {" "}
@ -209,7 +211,7 @@ const DEMO_TABS: DemoTab[] = [
gifAlt: "견적 생성 가이드라인 수립 시연",
fallback: {
icon: Monitor,
iconWrapClassName: "w-14 h-14 bg-primary-soft rounded-card text-primary",
iconWrapClassName: "w-14 h-14 bg-primary-soft rounded-2xl text-primary",
codeClassName: "bg-white text-primary",
title: "견적 생성 시연 GIF 공간",
hint: "실제 기동 GIF를 배치하시면 이 영역에 자동으로 재생됩니다.",
@ -218,8 +220,8 @@ const DEMO_TABS: DemoTab[] = [
{
key: "negotiate",
tabIcon: Smartphone,
tabLabel: "2. 협력사별 1:1 협상",
badge: { label: "협력사 협상 포털", className: "text-positive" },
tabLabel: "2단계: AI 자동 밀당 협상",
badge: { icon: Bot, label: "협력사 협상 포털 (웹·모바일)", className: "bg-positive-soft border border-positive/15 text-positive" },
checkClassName: "text-positive",
heading: (
<>
@ -232,18 +234,14 @@ const DEMO_TABS: DemoTab[] = [
<b> </b> , .
</>,
<>
. ,
. ,
.
</>,
],
checks: ["협력사별 1:1 비대면 협상 포털", "같은 기준으로 응대해 관계 부담 없음"],
checks: ["1:1 비대면 협상 포털 제공", "불필요한 실랑이를 예방하여 파트너십 보호"],
mockup: "phone",
/* ( UI) . 390x780
( 4)(18,480 ), 1.5. gif JS ( 228). */
gif: "/gifs/negotiation.gif",
video: "/gifs/negotiation.mp4",
poster: "/gifs/negotiation.jpg",
gifAlt: "협력사 포털 모바일 협상 화면",
gifAlt: "AI 모바일 자동 협상 시연",
fallback: {
icon: Smartphone,
iconWrapClassName: "w-12 h-12 bg-positive-soft border border-positive/15 rounded-full text-positive",
@ -256,7 +254,7 @@ const DEMO_TABS: DemoTab[] = [
key: "result",
tabIcon: Monitor,
tabLabel: "3단계: AI 협상 결과 확인",
badge: { label: "구매 관리자 콘솔", className: "text-accent" },
badge: { icon: Monitor, label: "구매 관리자 콘솔 (웹)", className: "bg-accent-soft border border-accent/15 text-accent" },
checkClassName: "text-accent",
heading: (
<>
@ -282,7 +280,7 @@ const DEMO_TABS: DemoTab[] = [
gifAlt: "협상 결과 분석 대시보드 시연",
fallback: {
icon: Monitor,
iconWrapClassName: "w-14 h-14 bg-accent-soft rounded-card text-accent",
iconWrapClassName: "w-14 h-14 bg-accent-soft rounded-2xl text-accent",
codeClassName: "bg-white text-accent",
title: "협상 결과 확인 시연 GIF 공간",
hint: "결과 보고 대시보드 기동 GIF를 배치하시면 이 영역에 자동으로 재생됩니다.",

View File

@ -0,0 +1,324 @@
import { useEffect, useRef, useState } from "react"
import { AnimatePresence, motion } from "motion/react"
import { Bot, Handshake, ShieldCheck } from "lucide-react"
import { PriceSparkline } from "@/components/ui/price-sparkline"
import { SlateRenderer } from "@/components/ui/slate-renderer"
import { Typography } from "@/components/ui/typography"
import { EASE_OUT_EXPO } from "@/lib/motion"
import type { DialogueStep } from "@/types"
/** 스크롤 연동 sticky 협상 데모 — 스크롤 진행률에 따라 대화가 쌓이고 단가가 내려간다. */
export function NegotiationConsole() {
// -1 = 아직 섹션 진입 전(온보딩 가이드 표시)
const [activeStep, setActiveStep] = useState(-1)
const [animatedPrice, setAnimatedPrice] = useState(1200000)
const sectionRef = useRef<HTMLDivElement>(null)
const chatContainerRef = useRef<HTMLDivElement>(null)
// 섹션 내 스크롤 진행률 → 대화 스텝 매핑
useEffect(() => {
const handleScroll = () => {
if (!sectionRef.current) return
const rect = sectionRef.current.getBoundingClientRect()
const totalScrollable = rect.height - window.innerHeight
if (totalScrollable <= 0) return
const scrolled = -rect.top
if (scrolled < 0) {
setActiveStep(-1)
return
}
const clampedRatio = Math.max(0, Math.min(scrolled / totalScrollable, 0.99))
setActiveStep(Math.floor(clampedRatio * NEGOTIATION_STEPS.length))
}
window.addEventListener("scroll", handleScroll, { passive: true })
handleScroll()
return () => window.removeEventListener("scroll", handleScroll)
}, [])
// 단가 오도미터 애니메이션 (500ms ease-out)
useEffect(() => {
const targetPrice = activeStep === -1 ? 1200000 : NEGOTIATION_STEPS[activeStep].price
if (animatedPrice === targetPrice) return
const start = animatedPrice
const duration = 500
const startTime = performance.now()
const animate = (currentTime: number) => {
const progress = Math.min((currentTime - startTime) / duration, 1)
const easeProgress = progress * (2 - progress)
setAnimatedPrice(Math.round(start + (targetPrice - start) * easeProgress))
if (progress < 1) requestAnimationFrame(animate)
}
requestAnimationFrame(animate)
}, [activeStep])
// 새 말풍선이 붙으면 채팅 영역을 바닥으로 스크롤
useEffect(() => {
chatContainerRef.current?.scrollTo({ top: chatContainerRef.current.scrollHeight, behavior: "smooth" })
}, [activeStep])
return (
<section
id="how-it-works"
ref={sectionRef}
className="relative bg-surface text-ink w-full font-sans border-t border-line h-[240vh] md:h-[280vh]"
>
<div className="sticky top-0 h-screen flex items-center justify-center w-full overflow-hidden px-6 md:px-8">
{/* 섹션 헤딩 — sticky 상단 고정 (모바일은 공간상 생략) */}
<div className="absolute top-24 left-0 right-0 text-center hidden md:block">
<Typography variant="eyebrow" className="block">
Live Replay
</Typography>
<Typography variant="heading" as="h2" className="mt-1">
</Typography>
</div>
<div className="max-w-4xl mx-auto grid grid-cols-1 md:grid-cols-12 gap-12 md:gap-16 items-center w-full">
{/* 좌: 실시간 단가 패널 */}
<div className="md:col-span-5 flex flex-col justify-center space-y-6 text-center md:text-left">
<div>
<span className="text-[11px] font-bold text-ink-muted uppercase tracking-wider block mb-1"> </span>
<div className="flex items-baseline gap-1.5 justify-center md:justify-start">
<span className="text-3xl font-bold text-primary"></span>
<span className="text-4xl md:text-5xl font-black tracking-tight text-primary">
{animatedPrice.toLocaleString()}
</span>
</div>
{/* 시작가 대비 누적 절감 델타 — 높이 고정으로 레이아웃 점프 방지 */}
<div className="h-6 mt-1.5">
{animatedPrice < START_PRICE && (
<span className="inline-flex items-baseline gap-1.5 text-positive font-bold text-sm">
{(START_PRICE - animatedPrice).toLocaleString()}
<span className="text-xs font-semibold">
({(((START_PRICE - animatedPrice) / START_PRICE) * 100).toFixed(1)}%)
</span>
</span>
)}
</div>
</div>
{/* 계단식 하락 스파크라인 + 목표가 기준선 */}
<div>
<PriceSparkline
prices={NEGOTIATION_STEPS.map((step) => step.price)}
targetPrice={TARGET_PRICE}
progress={activeStep < 0 ? 0 : (activeStep + 1) / NEGOTIATION_STEPS.length}
/>
<div className="flex justify-between mt-2">
<Typography variant="micro"> {START_PRICE.toLocaleString()}</Typography>
<Typography variant="micro" className="text-primary">
{TARGET_PRICE.toLocaleString()}
</Typography>
</div>
<Typography variant="caption" className="mt-3">
.
</Typography>
</div>
<div className="h-10">
<AnimatePresence mode="wait">
{activeStep !== -1 && NEGOTIATION_STEPS[activeStep]?.badge && (
<motion.div
key={NEGOTIATION_STEPS[activeStep].badge}
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.95, opacity: 0 }}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-positive-soft text-positive border border-positive/15 text-xs font-bold"
>
<ShieldCheck className="w-4 h-4 shrink-0" />
{NEGOTIATION_STEPS[activeStep].badge}
</motion.div>
)}
</AnimatePresence>
</div>
</div>
{/* 우: 대화 말풍선 (봇 좌 / 파트너 우) */}
<div className="md:col-span-7 flex flex-col justify-end space-y-5 h-[340px] md:h-[420px] relative overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-b from-surface via-transparent to-transparent h-12 pointer-events-none z-10" />
<div
ref={chatContainerRef}
className="flex flex-col gap-5 overflow-y-auto pr-1 scrollbar-none w-full h-full justify-center"
>
<AnimatePresence mode="wait" initial={false}>
{activeStep === -1 ? (
<motion.div
key="onboarding-guide"
initial={{ opacity: 0, y: 15, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -15, scale: 0.97 }}
transition={{ duration: 0.4 }}
className="flex flex-col items-center justify-center text-center p-8 border border-dashed border-line-strong rounded-[28px] bg-white shadow-[0_4px_20px_rgba(0,0,0,0.01)] max-w-md mx-auto"
>
<div className="w-12 h-12 rounded-full bg-primary-soft flex items-center justify-center text-primary mb-4">
<Bot className="w-6 h-6 animate-pulse" />
</div>
<h3 className="text-base font-bold text-ink mb-1.5"> AI </h3>
<p className="text-xs text-ink-soft font-semibold leading-relaxed break-keep">
.
</p>
<div className="mt-4 flex items-center gap-1.5 text-[11px] font-bold text-primary animate-bounce">
<span> </span>
<span></span>
</div>
</motion.div>
) : (
<div className="flex flex-col gap-5 w-full mt-auto">
{NEGOTIATION_STEPS.slice(0, activeStep + 1).map((item, idx) => {
const isBot = item.editorNodes[0].sender === "bot"
return (
<motion.div
key={idx}
initial={{ opacity: 0, y: 15, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -10, scale: 0.97 }}
transition={{ duration: 0.4, ease: EASE_OUT_EXPO }}
className={`flex flex-col ${isBot ? "items-start" : "items-end"} w-full`}
>
<div className={`flex items-center gap-1.5 mb-1 text-[11px] font-bold text-ink-muted ${isBot ? "" : "flex-row-reverse"}`}>
<span className={`flex items-center gap-1 ${isBot ? "text-primary" : "text-ink-soft"}`}>
{isBot ? (
<>
<Bot className="w-3.5 h-3.5" />
<span>NEGOTIUM BOT</span>
</>
) : (
<>
<Handshake className="w-3.5 h-3.5" />
<span>PARTNER</span>
</>
)}
</span>
</div>
<div
className={`max-w-[85%] rounded-[20px] p-4 text-sm leading-relaxed font-semibold shadow-[0_2px_8px_rgba(0,0,0,0.02)] transition-all duration-300 ${
isBot ? "bg-primary-soft text-ink rounded-tl-none" : "bg-white text-ink rounded-tr-none"
}`}
>
<SlateRenderer nodes={item.editorNodes} />
</div>
</motion.div>
)
})}
</div>
)}
</AnimatePresence>
</div>
</div>
</div>
</div>
</section>
)
}
// 시작가(첫 제시가)·목표가 — 좌측 패널 델타·스파크라인 기준값
const START_PRICE = 1200000
const TARGET_PRICE = 1080000
// 데모 대화 시나리오 — 1,200,000원 제시에서 1,050,000원 낙찰까지 6스텝
const NEGOTIATION_STEPS: DialogueStep[] = [
{
step: 1,
price: 1200000,
editorNodes: [
{
type: 'paragraph',
sender: 'supplier',
children: [
{ text: '협력사', bold: true },
{ text: ' : ' },
{ text: '현재 글로벌 원재료 상승 요인으로 제안할 수 있는 최선의 단가는 1,200,000원입니다. 이 이하로는 마진 확보가 어렵습니다.' },
],
},
],
},
{
step: 2,
price: 1150000,
editorNodes: [
{
type: 'paragraph',
sender: 'bot',
children: [
{ text: 'AI 흥정 봇', bold: true },
{ text: ' : ' },
{ text: '제시해주신 1,200,000원은 당사 타 유사 품목 이력 및 시중 원가 인덱스 데이터 대비 약 8.3% 높게 책정되어 있습니다. ', italic: true },
{ text: '상호 호혜적 장기 계약 체결을 전제로 조율가 범위를 반영해 제안해 드립니다.', code: true },
],
},
],
},
{
step: 3,
price: 1150000,
editorNodes: [
{
type: 'paragraph',
sender: 'supplier',
children: [
{ text: '협력사', bold: true },
{ text: ' : ' },
{ text: '제조 공정상 급격한 인하는 무리가 있으나, 상생 협력 차원에서 1,150,000원까지는 즉시 조정해 드릴 용의가 있습니다.' },
],
},
],
},
{
step: 4,
price: 1080000,
editorNodes: [
{
type: 'paragraph',
sender: 'bot',
children: [
{ text: 'AI 흥정 봇', bold: true },
{ text: ' : ' },
{ text: '적극적인 협조에 감사드립니다. 만약 연간 최소 발주 수량을 보증하고 공급망 일정을 다소 유연화해주신다면, 목표가인 1,080,000원 선까지 맞출 수 있을까요?', italic: true },
],
},
],
},
{
step: 5,
price: 1050000,
editorNodes: [
{
type: 'paragraph',
sender: 'supplier',
children: [
{ text: '협력사', bold: true },
{ text: ' : ' },
{ text: '좋습니다. 제안하신 연간 개런티 확보 및 대금 현금 결제 기한 단축을 승인해 주시는 조건으로, ' },
{ text: '최종 조율가 1,050,000원으로 맞춰서 계약을 체결하겠습니다.', bold: true },
],
},
],
},
{
step: 6,
price: 1050000,
badge: '낙찰 성공 · 12.5% 예산 절감',
editorNodes: [
{
type: 'paragraph',
sender: 'bot',
children: [
{ text: 'AI 흥정 봇', bold: true },
{ text: ' : ' },
{ text: '최종 합의 접수 완료 — 가이드 상한가(1,200,000원) 대비 합의 낙찰가 1,050,000원으로 최종 계약 승인 처리 완료되었습니다. ', bold: true },
{ text: '본 흥정 마일스톤 및 단가 타결 히스토리는 사내 투명성 보증을 위해 보존 기록됩니다.', code: true },
],
},
],
},
]

View File

@ -1,466 +0,0 @@
import { useEffect, useRef, useState } from "react"
import { motion } from "motion/react"
import { ArrowRight, RotateCcw } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Section } from "@/components/ui/section"
import { SectionHeading } from "@/components/ui/section-heading"
import { Typography } from "@/components/ui/typography"
import { EASE_OUT_EXPO } from "@/lib/motion"
import {
DEMO_ITEMS,
MAX_CARDS,
respondToOffer,
round10,
savings,
simulateBuyerRun,
won,
type DemoItem,
type Outcome,
type Role,
type Turn,
} from "@/lib/negotiation-sim"
type Step = "role" | "play"
/**
* .
*
* .
* .
*
* lib/negotiation-sim .
*/
export function NegotiationDemo() {
const [step, setStep] = useState<Step>("role")
const [role, setRole] = useState<Role>("seller")
const [item, setItem] = useState<DemoItem>(DEMO_ITEMS[0])
const [turns, setTurns] = useState<Turn[]>([])
const [cardsUsed, setCardsUsed] = useState(0)
const [outcome, setOutcome] = useState<Outcome>("running")
const [offer, setOffer] = useState(DEMO_ITEMS[0].listPrice)
/* . offer
"1" . blur/Enter . */
const [offerText, setOfferText] = useState(won(DEMO_ITEMS[0].listPrice))
const [finalPrice, setFinalPrice] = useState(DEMO_ITEMS[0].listPrice)
const logRef = useRef<HTMLDivElement>(null)
const timers = useRef<number[]>([])
// 재생 중인 buyer 시나리오 타이머는 리셋·언마운트 때 반드시 걷어낸다.
const clearTimers = () => {
timers.current.forEach(clearTimeout)
timers.current = []
}
useEffect(() => clearTimers, [])
/** .
, . */
const restart = () => {
reset()
setStep("role")
}
const reset = (nextItem = item, nextRole = role) => {
clearTimers()
setTurns([])
setCardsUsed(0)
setOutcome("running")
setFinalPrice(nextItem.listPrice)
const start = nextRole === "seller" ? nextItem.listPrice : nextItem.marketLow
setOffer(start)
setOfferText(won(start))
}
const pickRole = (r: Role) => {
setRole(r)
reset(item, r)
setStep("play")
}
const pickItem = (it: DemoItem) => {
setItem(it)
reset(it, role)
}
// 새 말풍선이 붙으면 기록을 바닥으로 붙인다.
useEffect(() => {
logRef.current?.scrollTo({ top: logRef.current.scrollHeight, behavior: "smooth" })
}, [turns])
/** seller — 사용자가 공급사가 되어 값을 제시하고 에이전트가 응수한다. */
const submitOffer = () => {
if (outcome !== "running") return
const priced = round10(offer)
const mine: Turn = { id: turns.length, side: "counterpart", text: `${won(priced)}원까지는 맞춰드릴 수 있습니다.`, price: priced }
setTurns((prev) => [...prev, mine])
const res = respondToOffer(item, priced, cardsUsed)
const id = window.setTimeout(() => {
setTurns((prev) => [...prev, { id: prev.length, side: "agent", text: res.text, price: res.price }])
setFinalPrice(res.outcome === "award" ? priced : res.price)
setOutcome(res.outcome)
if (res.outcome === "running") {
setCardsUsed((c) => c + 1)
// 에이전트가 부른 값에서 다시 시작. 입력란도 같이 갱신해야 슬라이더와 숫자가 어긋나지 않는다.
setOffer(res.price)
setOfferText(won(res.price))
}
}, 620)
timers.current.push(id)
}
/** buyer — 목표가만 정하면 에이전트가 알아서 붙는 걸 지켜본다. */
const runBuyer = () => {
if (turns.length > 0) return
const target = round10(offer)
const run = simulateBuyerRun(item, target)
run.turns.forEach((turn, i) => {
const id = window.setTimeout(() => {
setTurns((prev) => [...prev, turn])
if (turn.price) setFinalPrice(turn.price)
if (i === run.turns.length - 1) setOutcome(run.outcome)
}, 500 + i * 900)
timers.current.push(id)
})
}
const done = outcome !== "running"
const { amount, rate } = savings(item, finalPrice)
const sliderMin = role === "seller" ? round10(item.anchor * 0.94) : round10(item.anchor * 0.94)
const sliderMax = role === "seller" ? item.listPrice : item.marketLow
/* .
. */
const inputLocked = role === "buyer" && turns.length > 0
/* .
. */
const recommended = role === "seller" ? item.marketLow : item.anchor
const recommendReason =
role === "seller"
? `같은 사양 시장 최저가 선입니다. 여기서부터 에이전트가 근거를 들고 응수합니다.`
: `결렬 없이 도달 가능한 최저선입니다. 더 낮추면 협상이 성사되지 않습니다.`
/** 슬라이더·직접입력·추천적용이 모두 거쳐가는 단일 확정 경로. 범위로 당기고 10원 단위로 맞춘다. */
const commitOffer = (raw: number) => {
if (inputLocked) return
const next = Number.isFinite(raw) && raw > 0 ? round10(Math.min(sliderMax, Math.max(sliderMin, raw))) : offer
setOffer(next)
/* won()
1018800 . . */
setOfferText(won(next))
}
return (
<Section id="how-it-works" bg="stage" width="lg">
{/* SectionHeading .
20px( 12px)
. */}
{/* .
.
"에이전트는 정해진 기준을 벗어나지 않습니다" . FAQ 2
("에이전트가 우리 기준을 벗어나 합의해 버리면요?")
, .
.
FAQ . */}
<SectionHeading
align="center"
tone="stage"
eyebrow="Try it Yourself"
title={
<>
<span className="text-white"> ,</span>
<br />
<span className="text-on-stage-soft/60"> !</span>
</>
}
/>
{/* AnimatePresence mode="wait" .
0.8
. . */}
{step === "role" ? (
<motion.div
key="role"
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.28, ease: EASE_OUT_EXPO }}
className="grid grid-cols-1 sm:grid-cols-2 gap-4"
>
<RoleCard
title="공급사로 해보기"
lead="직접 단가를 제시하면 에이전트가 응수합니다."
detail="협상 카드 3장 안에서 단가가 어디까지 조율되는지 확인하실 수 있습니다."
onClick={() => pickRole("seller")}
/>
<RoleCard
title="구매 담당자로 해보기"
lead="목표가만 정하면 에이전트가 협력사와 조율합니다."
detail="기준을 벗어난 목표가를 설정했을 때의 처리 방식도 함께 확인하실 수 있습니다."
onClick={() => pickRole("buyer")}
/>
</motion.div>
) : (
<motion.div
key="play"
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.28, ease: EASE_OUT_EXPO }}
/* .
, .
2 CTA + backdrop-blur +
+ . .
white/12 .
, . */
className={
"rounded-card border border-white/12 bg-white/[0.06] " +
"backdrop-blur-xl backdrop-saturate-150 " +
"shadow-[inset_0_1px_0_0_rgba(255,255,255,0.12),0_24px_60px_-24px_rgba(3,6,15,0.9)] " +
"p-6 sm:p-8 lg:p-10"
}
>
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 lg:gap-12">
{/* 좌 — 품목·컨트롤·현재가 */}
<div className="lg:col-span-5 space-y-8">
<div className="flex flex-wrap gap-2">
{DEMO_ITEMS.map((it) => (
<button
key={it.id}
type="button"
onClick={() => pickItem(it)}
className={`px-4 py-2 rounded-full text-[13px] font-semibold transition-colors ${
it.id === item.id
? "bg-white text-stage"
: "bg-white/5 text-on-stage-soft/75 hover:bg-white/10 hover:text-white"
}`}
>
{it.name}
</button>
))}
</div>
<div>
<div className="text-[13px] text-on-stage-soft/55 mb-2">{item.spec}</div>
<div className="flex items-baseline gap-2">
<span className="text-[24px] font-medium text-on-stage-soft/55"></span>
<span className="text-[52px] md:text-[64px] font-extrabold tracking-[-0.045em] leading-none tabular-nums">
{won(finalPrice)}
</span>
</div>
<div className="h-7 mt-2">
{amount > 0 && (
<span className="text-[15px] font-semibold text-counter">
{won(amount)} · {rate.toFixed(1)}%
</span>
)}
</div>
</div>
{!done && (
<div className="space-y-4">
{/* .
anchor( , reachable = target >= anchor),
marketLow( ).
. . */}
<div className="flex items-center justify-between gap-3 rounded-control border border-primary-on-stage/25 bg-primary-on-stage/10 px-4 py-3">
<div className="min-w-0">
<div className="text-[12px] font-semibold text-primary-on-stage mb-0.5"> </div>
<div className="text-[13px] text-on-stage-soft/75 break-keep">{recommendReason}</div>
</div>
<button
type="button"
onClick={() => commitOffer(recommended)}
disabled={inputLocked}
className="shrink-0 rounded-control bg-primary-on-stage/20 hover:bg-primary-on-stage/30 px-3 py-2 text-[13px] font-bold tabular-nums text-white transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
>
{won(recommended)}
</button>
</div>
<div className="flex items-baseline justify-between">
<label htmlFor="nego-offer" className="text-[14px] font-medium text-on-stage-soft/85">
{role === "seller" ? "제시 단가" : "목표 단가"}
</label>
{/* .
"얼마까지" . clamp
blur/Enter . . */}
<div className="flex items-baseline gap-1">
<input
id="nego-offer-text"
type="text"
inputMode="numeric"
aria-label={role === "seller" ? "제시 단가 직접 입력" : "목표 단가 직접 입력"}
value={offerText}
onChange={(e) => setOfferText(e.target.value.replace(/[^\d]/g, ""))}
onBlur={() => commitOffer(Number(offerText.replace(/[^\d]/g, "")))}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
commitOffer(Number(offerText.replace(/[^\d]/g, "")))
}
}}
disabled={inputLocked}
className="w-32 bg-transparent border-0 border-b-2 border-stage-line focus:border-primary-on-stage hover:border-on-stage-muted rounded-none px-0 py-1 text-right text-[18px] font-bold tabular-nums text-white outline-none transition-colors disabled:opacity-40"
/>
<span className="text-[15px] font-semibold text-on-stage-soft/70"></span>
</div>
</div>
<input
id="nego-offer"
type="range"
aria-label={role === "seller" ? "제시 단가 조절" : "목표 단가 조절"}
min={sliderMin}
max={sliderMax}
step={10_000}
value={Math.min(sliderMax, Math.max(sliderMin, offer))}
onChange={(e) => commitOffer(Number(e.target.value))}
disabled={inputLocked}
className="w-full accent-primary disabled:opacity-40"
/>
<div className="flex justify-between text-[12px] text-on-stage-soft/45">
<span>{won(sliderMin)}</span>
<span>{won(sliderMax)}</span>
</div>
<Button
type="button"
variant="stage"
size="lg"
className="w-full"
onClick={role === "seller" ? submitOffer : runBuyer}
disabled={inputLocked}
>
{role === "seller" ? "이 값으로 제시하기" : "협상 시작"}
</Button>
</div>
)}
{role === "seller" && (
<div className="flex items-center gap-3">
<span className="text-[13px] text-on-stage-soft/55"> </span>
<div className="flex gap-1.5">
{Array.from({ length: MAX_CARDS }, (_, i) => (
<span
key={i}
className={`w-7 h-1.5 rounded-full ${i < MAX_CARDS - cardsUsed ? "bg-primary-on-stage" : "bg-white/15"}`}
/>
))}
</div>
</div>
)}
</div>
{/* 우 — 협상 기록 */}
<div className="lg:col-span-7">
<div
ref={logRef}
className="h-[360px] md:h-[440px] overflow-y-auto scrollbar-none flex flex-col gap-4 pr-1"
>
{turns.length === 0 && (
<div className="m-auto text-center text-[15px] text-on-stage-soft/45 max-w-xs break-keep">
{role === "seller"
? "단가를 정해 제시하면 에이전트가 근거와 함께 응수합니다."
: "목표가를 정하고 협상을 시작하면 턴마다 재생됩니다."}
</div>
)}
{turns.map((turn) => (
<motion.div
key={turn.id}
initial={{ opacity: 0, y: 14 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.35, ease: EASE_OUT_EXPO }}
className={`flex flex-col ${turn.side === "agent" ? "items-start" : "items-end"}`}
>
<span
className={`text-[12px] font-semibold mb-1.5 ${
turn.side === "agent" ? "text-primary-on-stage" : "text-counter"
}`}
>
{turn.side === "agent" ? "협상 에이전트" : role === "seller" ? "나 (협력사)" : "협력사"}
</span>
<div
className={`max-w-[88%] rounded-card px-5 py-4 text-[15px] leading-[1.6] break-keep ${
turn.side === "agent"
? "bg-primary text-white"
: "bg-counter-surface text-counter border border-counter/20"
}`}
>
{turn.text}
</div>
</motion.div>
))}
</div>
{done && (
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, ease: EASE_OUT_EXPO }}
className="mt-6 border-t border-stage-line pt-6"
>
<Typography variant="cardTitle" className="text-white mb-1.5">
{outcome === "award"
? `낙찰 · ${won(amount)}원 절감`
: "개찰 — 낙찰자 미정으로 마감했습니다"}
</Typography>
<p className="text-[15px] text-on-stage-soft/70 leading-[1.6] break-keep mb-6">
{outcome === "award"
? `최초 제시가 ${won(item.listPrice)}원에서 ${rate.toFixed(1)}% 내려왔습니다. 실제 운영에서는 협력사 수만큼 이 협상이 동시에 진행됩니다.`
: "기준을 벗어나는 값은 억지로 맞추지 않고 담당자에게 넘깁니다. 결렬이 아니라 사람이 판단할 자리를 남기는 것입니다."}
</p>
<div className="flex flex-col sm:flex-row gap-3">
<Button href="#contact-section" variant="stage" size="lg" className="group">
<span> </span>
<ArrowRight className="w-4 h-4 transition-transform group-hover:translate-x-0.5" />
</Button>
<Button type="button" variant="stageGhost" size="lg" onClick={restart}>
<RotateCcw className="w-4 h-4" />
<span> </span>
</Button>
</div>
</motion.div>
)}
</div>
</div>
</motion.div>
)}
<p className="mt-12 text-center text-[13px] text-on-stage-soft/40">
(· 3·) . .
</p>
</Section>
)
}
function RoleCard({
title,
lead,
detail,
onClick,
}: {
title: string
lead: string
detail: string
onClick: () => void
}) {
return (
<button
type="button"
onClick={onClick}
className="group text-left rounded-card border border-stage-line bg-white/[0.03] hover:bg-white/[0.07] hover:border-white/25 transition-colors p-8 md:p-10"
>
<Typography variant="heading" as="h3" className="text-white mb-3">
{title}
</Typography>
<p className="text-[16px] text-on-stage-soft/85 leading-[1.6] break-keep mb-2">{lead}</p>
<p className="text-[14px] text-on-stage-soft/55 leading-[1.6] break-keep">{detail}</p>
<span className="mt-6 inline-flex items-center gap-1.5 text-[16px] font-semibold text-primary-on-stage">
<ArrowRight className="w-4 h-4 transition-transform group-hover:translate-x-0.5" />
</span>
</button>
)
}

View File

@ -2,9 +2,8 @@ import { motion } from "motion/react"
import { GitMerge, RefreshCw, Scale, type LucideIcon } from "lucide-react"
import { Section } from "@/components/ui/section"
import { HEADING_GAP, SectionHeading } from "@/components/ui/section-heading"
import { SectionHeading } from "@/components/ui/section-heading"
import { Typography } from "@/components/ui/typography"
import { OrbitRing } from "@/components/ui/orbit-ring"
import { useFadeUp } from "@/lib/motion"
/** 강화학습 섹션 — 협상할수록 좋아진다는 3개 필러 카드. */
@ -12,40 +11,22 @@ export function Reinforcement() {
const fadeUp = useFadeUp()
return (
<Section id="reinforcement" bg="stage" width="lg">
{/* ,
"협상할수록 좋아진다" , . */}
{/* gap="none" .
, mb items-center
. . */}
<motion.div {...fadeUp} className={`grid grid-cols-1 lg:grid-cols-12 gap-12 lg:gap-16 items-center ${HEADING_GAP}`}>
<div className="lg:col-span-7">
<SectionHeading
className="text-center lg:text-left"
gap="none"
tone="stage"
eyebrow="Reinforcement Learning"
/* . break-keep + text-balance
"협상할수록, 더 / 좋은 조건으로"
. */
title={
<>
,
<br />
</>
}
description={
<>
릿 , .
<span className="font-semibold text-on-stage"></span> .
, .
</>
}
/>
</div>
<div className="lg:col-span-5 flex justify-center">
<OrbitRing className="w-[280px] md:w-[340px] h-auto" />
</div>
<Section id="reinforcement" bg="surface" bordered className="border-b border-line">
<motion.div {...fadeUp} className="mb-20">
<SectionHeading
className="text-center md:text-left"
eyebrow="REINFORCEMENT LEARNING"
title="협상할수록, 더 좋은 조건으로"
description={
<>
릿 , . negotium은
<span className="font-bold text-primary"> </span> .
,
.
</>
}
descriptionClassName="mt-8 max-w-3xl"
/>
</motion.div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
@ -63,15 +44,15 @@ function PillarCard({ pillar, delay }: { pillar: Pillar; delay: number }) {
const fadeUp = useFadeUp(delay)
return (
<motion.div {...fadeUp} className="bg-stage-raised border border-stage-line p-8 rounded-card flex flex-col justify-between h-[240px]">
<div className="w-11 h-11 rounded-control bg-primary/15 text-on-stage flex items-center justify-center">
<pillar.icon className="w-5 h-5" />
<motion.div {...fadeUp} className="bg-white p-8 rounded-[28px] flex flex-col justify-between h-[240px]">
<div className="w-12 h-12 rounded-2xl bg-primary/5 text-primary flex items-center justify-center">
<pillar.icon className="w-6 h-6" />
</div>
<div>
<Typography variant="cardTitle" className="mb-2 text-on-stage">
<Typography variant="cardTitle" className="mb-2">
{pillar.title}
</Typography>
<Typography variant="caption" className="text-on-stage-soft">
<Typography variant="caption" className="text-ink-soft">
{pillar.description}
</Typography>
</div>
@ -87,12 +68,12 @@ const PILLARS: Pillar[] = [
},
{
icon: RefreshCw,
title: "쓸수록 올라가는 기준선",
description: "협상이 쌓일수록 기준선이 올라갑니다. 운영 기간이 그대로 협상력이 됩니다.",
title: "지속적인 전략 최적화",
description: "전략 강화 학습을 통해 기준선이 상승하고, 점진적으로 개선됩니다. 시간이 곧 협상력이 됩니다.",
},
{
icon: GitMerge,
title: "결렬 없는 합의",
description: "성사되지 않을 선까지 밀지 않습니다. 합의 가능한 범위 안에서 최선을 찾습니다.",
title: "낙찰 성사율 극대화",
description: "무리하게 후려쳐 관계를 깨는 대신, 성사되는 선에서 최대한 끌어냅니다.",
},
]

View File

@ -0,0 +1,158 @@
import { useState } from "react"
import { ArrowRight, Clock, ShieldCheck } from "lucide-react"
import { RangeSlider } from "@/components/ui/range-slider"
import { Section } from "@/components/ui/section"
import { SectionHeading } from "@/components/ui/section-heading"
import { Typography } from "@/components/ui/typography"
/** 도입 ROI 시뮬레이터 — 예산·협력사 수 슬라이더로 예상 절감액을 즉시 계산. */
export function ROISimulator() {
// 연간 총 구매 예산(억 원)
const [budget, setBudget] = useState(50)
// 관리 중인 협력사 수
const [suppliers, setSuppliers] = useState(50)
// 보수적 절감율 2.8%~4.5% — 협력사가 많을수록 병렬 대안이 늘어 상향
const savingRate = Math.min(0.045, 0.028 + (suppliers / 300) * 0.015)
const estimatedSavingsValue = budget * savingRate
// 협력사당 왕복 흥정 수작업 절약분을 ~6.5시간으로 잡은 추산
const savedHours = Math.round(suppliers * 6.5)
return (
<Section id="benchmarks" width="lg" bordered className="border-b border-line">
<SectionHeading
align="center"
className="mb-24"
eyebrow="ROI SIMULATION"
title={
<>
negotium <br />
</>
}
description={
<>
. <br />
.
</>
}
descriptionClassName="mt-4 text-[17px] max-w-2xl"
/>
<div className="grid grid-cols-1 lg:grid-cols-12 gap-16 items-start">
{/* 좌: 입력 슬라이더 */}
<div className="lg:col-span-6 space-y-12">
<div className="space-y-4">
<div className="flex justify-between items-center">
<label className="text-[16px] font-bold text-ink"> </label>
<span className="text-lg font-black text-primary font-mono">{formatCurrency(budget)}</span>
</div>
<Typography variant="caption">, , , .</Typography>
<div className="pt-2">
<RangeSlider min={5} max={1000} step={5} value={budget} onChange={setBudget} />
<div className="flex justify-between text-[11px] text-ink-faint font-bold pt-2">
<span>5 </span>
<span>500 </span>
<span>1 </span>
</div>
</div>
</div>
<div className="space-y-4">
<div className="flex justify-between items-center">
<label className="text-[16px] font-bold text-ink"> </label>
<span className="text-lg font-black text-primary font-mono">{suppliers}</span>
</div>
<Typography variant="caption">AI "밀당" .</Typography>
<div className="pt-2">
<RangeSlider min={5} max={300} step={5} value={suppliers} onChange={setSuppliers} />
<div className="flex justify-between text-[11px] text-ink-faint font-bold pt-2">
<span>5</span>
<span>150</span>
<span>300</span>
</div>
</div>
</div>
<div className="flex gap-3 bg-surface p-5 rounded-2xl border border-line">
<ShieldCheck className="w-5 h-5 text-positive flex-shrink-0 mt-0.5" />
<Typography variant="caption" className="text-ink-soft">
(LPS) B2B (3.1% ~ 4.2%
) . .
</Typography>
</div>
</div>
{/* 우: 결과 지표 */}
<div className="lg:col-span-6 lg:pl-10 space-y-12">
<div className="space-y-3">
<Typography variant="micro" className="text-primary block">
ESTIMATED ANNUAL SAVINGS
</Typography>
<h3 className="text-sm font-semibold text-ink-muted"> ( )</h3>
<div className="text-3xl sm:text-[44px] font-black text-primary font-mono tracking-tight leading-tight pt-1">
{formatSavings(estimatedSavingsValue)}
</div>
<Typography variant="caption" className="text-ink-soft font-medium pt-1">
(Tail Spend) ,
.
</Typography>
</div>
<div className="border-t border-line pt-8 space-y-3">
<div className="flex items-center gap-3">
<Clock className="w-5 h-5 text-ink-muted" />
<span className="text-xs font-bold text-ink-soft">
: <b className="text-ink font-mono text-sm ml-1">{savedHours.toLocaleString()}</b>
</span>
</div>
<Typography variant="caption">
, , AI 24
.
</Typography>
</div>
<div className="border-t border-line pt-8 flex items-center justify-between">
<div>
<h4 className="text-xs font-bold text-ink"> ROI </h4>
<p className="text-[11px] text-ink-muted font-semibold mt-1"> </p>
</div>
<a
href="#contact-section"
className="inline-flex items-center gap-2 text-xs font-bold text-primary hover:gap-3 transition-all"
>
<span> </span>
<ArrowRight className="w-4 h-4" />
</a>
</div>
</div>
</div>
</Section>
)
}
/** 억 원 단위 → "50억 원" / "1조 200억 원" 표기 */
function formatCurrency(val: number) {
if (val >= 100) {
const b = Math.floor(val / 100)
const m = val % 100
return m > 0 ? `${b}${m}00억 원` : `${b}조 원`
}
return `${val}억 원`
}
/** 억 원 단위 → "1억 5,500만 원" 표기 */
function formatSavings(val: number) {
const rawWon = val * 100000000
if (rawWon >= 100000000) {
const eonPart = Math.floor(rawWon / 100000000)
const manPart = Math.floor((rawWon % 100000000) / 10000)
if (manPart > 0) {
return `${eonPart}${manPart.toLocaleString()}만 원`
}
return `${eonPart}억 원`
}
return `${Math.floor(rawWon / 10000).toLocaleString()}만 원`
}

View File

@ -6,9 +6,7 @@ import { cn } from "@/lib/utils"
// CTA 버튼 토큰. 랜딩의 CTA 는 전부 앵커 스크롤이라 href 를 주면 <a> 로 렌더한다
// (SSG 특성상 하이드레이션 전에도 동작).
const buttonVariants = cva(
/* transition-colors . transition-all CTA variant
. */
"inline-flex items-center justify-center gap-2 transition-colors duration-200 cursor-pointer disabled:opacity-50",
"inline-flex items-center justify-center gap-2 transition-all cursor-pointer disabled:opacity-50",
{
variants: {
variant: {
@ -16,40 +14,13 @@ const buttonVariants = cva(
secondary: "bg-fill hover:bg-fill-hover text-ink-soft hover:text-ink",
// 밝은 배경 위 반투명 보조 버튼 (글라스 히어로)
glass: "bg-white/70 hover:bg-white text-ink-soft hover:text-ink border border-white shadow-sm",
// 다크 무대 위 1차 CTA
stage: "bg-primary hover:bg-primary-deep text-white",
/* 2 CTA .
stage-line(#1E2A52) . (#070E24)
, (#16205A) GNB 1.09:1
. nav .
.
"누를 수 있는 것" . */
/* (glassmorphism). .
, backdrop-blur, 1px
( ), ( ).
(saturate) .
white/40 . /30 (#16205A)
2.60:1 WCAG 1.4.11(UI 3:1)
. blur .
hover .
. */
stageGhost:
"bg-white/12 hover:bg-white/25 text-white border border-white/40 hover:border-white/60 " +
"backdrop-blur-md backdrop-saturate-150 " +
"shadow-[inset_0_1px_0_0_rgba(255,255,255,0.30),0_8px_24px_-10px_rgba(3,6,15,0.7)]",
},
/* 레퍼런스(statworx) 실측: 10.4px / 600 / (radius 39px) / 12·24 / 33px.
.
보정: 10.4px 12~13px .
( "알약형 금지" .) */
// radius 는 사이즈 무관 rounded-2xl(16px) 로 통일 — 알약형 금지
size: {
pill: "px-5 py-2.5 text-[12px] font-semibold rounded-full",
md: "px-6 py-3 text-[12px] font-semibold rounded-full",
lg: "px-7 py-3.5 text-[13px] font-semibold rounded-full",
xl: "px-8 py-4 text-[14px] font-semibold rounded-full",
stageRound: "px-7 py-3.5 text-[13px] font-semibold rounded-full",
pill: "px-4.5 py-2.5 text-sm font-semibold rounded-2xl",
md: "px-6 py-2.5 text-xs font-bold rounded-2xl",
lg: "px-8 py-4.5 text-base font-bold rounded-2xl",
xl: "px-10 py-5 text-base font-bold rounded-2xl",
},
},
defaultVariants: { variant: "primary", size: "lg" },

View File

@ -1,228 +0,0 @@
import { useEffect, useId, useRef, useState } from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { Check, Loader2, X } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Typography } from "@/components/ui/typography"
import { EASE_OUT_EXPO } from "@/lib/motion"
import { isEmailLike, submitLead } from "@/lib/lead"
/*
* · .
*
* (contact.tsx) ··· .
* .
* , .
*
* .
* . .
*/
export function DemoRequestModal({ open, onClose }: { open: boolean; onClose: () => void }) {
const [name, setName] = useState("")
const [email, setEmail] = useState("")
const [website, setWebsite] = useState("") // 봇 함정
const [status, setStatus] = useState<"idle" | "submitting" | "success" | "error">("idle")
const dialogRef = useRef<HTMLDivElement>(null)
const firstFieldRef = useRef<HTMLInputElement>(null)
const restoreFocusTo = useRef<HTMLElement | null>(null)
const reduce = useReducedMotion()
const titleId = useId()
/* 열릴 때마다 초기화. 남겨두면 성공 화면이 다시 뜨거나 이전 에러가 붙어 나온다. */
useEffect(() => {
if (!open) return
setStatus("idle")
restoreFocusTo.current = document.activeElement as HTMLElement | null
const t = setTimeout(() => firstFieldRef.current?.focus(), 60)
return () => clearTimeout(t)
}, [open])
/* 배경 스크롤 잠금. 모달 뒤에서 페이지가 움직이면 모달이 페이지의 일부처럼 읽힌다. */
useEffect(() => {
if (!open) return
const prev = document.body.style.overflow
document.body.style.overflow = "hidden"
return () => {
document.body.style.overflow = prev
restoreFocusTo.current?.focus?.()
}
}, [open])
/* Esc + Tab .
, · . */
useEffect(() => {
if (!open) return
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose()
return
}
if (e.key !== "Tab") return
const focusables = dialogRef.current?.querySelectorAll<HTMLElement>(
'button:not([disabled]), input:not([disabled]), a[href]',
)
if (!focusables?.length) return
const first = focusables[0]
const last = focusables[focusables.length - 1]
if (e.shiftKey && document.activeElement === first) {
e.preventDefault()
last.focus()
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault()
first.focus()
}
}
document.addEventListener("keydown", onKeyDown)
return () => document.removeEventListener("keydown", onKeyDown)
}, [open, onClose])
const valid = name.trim().length > 0 && isEmailLike(email)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!valid || status === "submitting") return
setStatus("submitting")
const result = await submitLead({ source: "demo-request", name, email, website })
setStatus(result.ok ? "success" : "error")
}
return (
<AnimatePresence>
{open && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-6">
<motion.div
className="absolute inset-0 bg-stage-deep/80 backdrop-blur-sm"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
onClick={onClose}
/>
<motion.div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
className="relative w-full max-w-md bg-white rounded-card p-8 sm:p-10 shadow-2xl"
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 16, scale: 0.98 }}
animate={reduce ? { opacity: 1 } : { opacity: 1, y: 0, scale: 1 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: 8, scale: 0.99 }}
transition={{ duration: 0.28, ease: EASE_OUT_EXPO }}
>
<button
type="button"
onClick={onClose}
aria-label="닫기"
className="absolute top-4 right-4 p-2 text-ink-muted hover:text-ink transition-colors cursor-pointer"
>
<X className="w-5 h-5" />
</button>
{status === "success" ? (
<div className="text-center py-4">
<div className="w-12 h-12 rounded-full bg-positive-soft text-positive flex items-center justify-center mx-auto mb-5">
<Check className="w-6 h-6" />
</div>
<Typography variant="cardTitle" as="h2" id={titleId} className="mb-3">
</Typography>
<Typography variant="small">
. Demo를 .
</Typography>
</div>
) : (
<>
{/* . "" ""
. */}
<Typography variant="cardTitle" as="h2" id={titleId} className="mb-2">
</Typography>
<Typography variant="small" className="mb-8">
, Demo를 !
</Typography>
<form onSubmit={handleSubmit} className="space-y-7">
{/* . display:none
. aria-hidden + tabIndex . */}
<div className="absolute w-px h-px -left-[9999px] overflow-hidden" aria-hidden>
<input
type="text"
name="website"
tabIndex={-1}
autoComplete="off"
value={website}
onChange={(e) => setWebsite(e.target.value)}
/>
</div>
<div>
<label htmlFor="lead-name" className="block text-[13px] font-semibold text-ink-soft mb-2">
</label>
<Input
ref={firstFieldRef}
id="lead-name"
name="name"
type="text"
required
autoComplete="name"
placeholder="예: 홍길동"
value={name}
onChange={(e) => setName(e.target.value)}
disabled={status === "submitting"}
/>
</div>
<div>
<label htmlFor="lead-email" className="block text-[13px] font-semibold text-ink-soft mb-2">
</label>
<Input
id="lead-email"
name="email"
type="email"
required
autoComplete="email"
placeholder="예: hong@company.co.kr"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={status === "submitting"}
/>
</div>
{status === "error" && (
<p role="alert" className="text-[13px] text-ink-soft leading-[1.6] break-keep">
.{" "}
<a href="#contact-section" onClick={onClose} className="text-primary font-semibold underline">
</a>
.
</p>
)}
<Button
type="submit"
variant="primary"
size="lg"
className="w-full"
disabled={!valid || status === "submitting"}
>
{status === "submitting" ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
<span> </span>
</>
) : (
<span> </span>
)}
</Button>
</form>
</>
)}
</motion.div>
</div>
)}
</AnimatePresence>
)
}

View File

@ -3,13 +3,13 @@ import type * as React from "react"
/** 데스크톱 브라우저 목업 프레임 — 신호등 버튼 + 주소창 + 16:10 워크스페이스. */
function BrowserFrame({ url, children }: { url: string; children: React.ReactNode }) {
return (
<div className="bg-white rounded-card border border-line-strong shadow-[0_12px_40px_rgba(0,0,0,0.06)] overflow-hidden w-full max-w-2xl">
<div className="bg-white rounded-2xl border border-line-strong shadow-[0_12px_40px_rgba(0,0,0,0.06)] overflow-hidden w-full max-w-2xl">
<div className="bg-fill px-4 py-3.5 flex items-center justify-between border-b border-line-strong">
<div className="flex items-center gap-2">
<span className="w-3 h-3 rounded-full bg-[#FF5F56] inline-block" />
<span className="w-3 h-3 rounded-full bg-[#FFBD2E] inline-block" />
<span className="w-3 h-3 rounded-full bg-[#27C93F] inline-block" />
<span className="text-[11px] text-ink-muted font-mono ml-3 font-semibold bg-white px-3 py-1 rounded-control border border-line-strong truncate max-w-[180px] sm:max-w-none">
<span className="text-[11px] text-ink-muted font-mono ml-3 font-semibold bg-white px-3 py-1 rounded-md border border-line-strong truncate max-w-[180px] sm:max-w-none">
{url}
</span>
</div>
@ -22,9 +22,9 @@ function BrowserFrame({ url, children }: { url: string; children: React.ReactNod
/** 모바일 폰 목업 프레임 — 노치 포함 9:18 스크린. */
function PhoneFrame({ children }: { children: React.ReactNode }) {
return (
<div className="relative bg-black rounded-card p-3 shadow-[0_20px_50px_rgba(0,0,0,0.15)] border-4 border-line-strong w-full max-w-[290px] aspect-[9/18] overflow-hidden flex flex-col">
<div className="relative bg-black rounded-[48px] p-3 shadow-[0_20px_50px_rgba(0,0,0,0.15)] border-4 border-line-strong w-full max-w-[290px] aspect-[9/18] overflow-hidden flex flex-col">
{/* 노치는 화면 '안쪽'에 작은 아일랜드로 — 베젤과 붙으면 검은 덩어리처럼 보인다 */}
<div className="bg-white rounded-card flex-1 overflow-hidden relative flex flex-col justify-center items-center">
<div className="bg-white rounded-[36px] flex-1 overflow-hidden relative flex flex-col justify-center items-center">
<div className="absolute top-1 left-1/2 -translate-x-1/2 bg-black/70 h-0.5 w-3.5 rounded-full z-20" />
{children}
</div>

View File

@ -2,33 +2,16 @@ import * as React from "react"
import { cn } from "@/lib/utils"
/*
* .
*
* 릿 .
* "입력 상자 더미" .
*
* ( ),
* 2px .
* 2px .
*
* 16px iOS 16px
* , .
*/
// 문의 폼 필드 공통 스타일 — 테두리 없는 회색 채움, 포커스 시 흰 배경 + 파란 링.
const fieldClass =
"w-full bg-transparent border-0 border-b-2 border-line-strong rounded-none px-0 py-3 " +
"text-[16px] font-normal text-ink placeholder:text-ink-faint placeholder:font-normal " +
"outline-none transition-colors hover:border-ink-muted focus:border-primary " +
"disabled:opacity-50 disabled:cursor-not-allowed"
"w-full px-5 py-3.5 rounded-2xl bg-surface hover:bg-fill focus:bg-white border-0 focus:ring-2 focus:ring-primary/20 text-sm font-semibold text-ink transition-all placeholder:text-ink-faint outline-none"
/* ComponentPropsWithRef ref (React 19 forwardRef ).
ref . */
function Input({ className, ...props }: React.ComponentPropsWithRef<"input">) {
function Input({ className, ...props }: React.InputHTMLAttributes<HTMLInputElement>) {
return <input className={cn(fieldClass, className)} {...props} />
}
function Textarea({ className, ...props }: React.ComponentPropsWithRef<"textarea">) {
return <textarea className={cn(fieldClass, "resize-none leading-[1.6]", className)} {...props} />
function Textarea({ className, ...props }: React.TextareaHTMLAttributes<HTMLTextAreaElement>) {
return <textarea className={cn(fieldClass, "resize-none", className)} {...props} />
}
export { Input, Textarea }

View File

@ -1,179 +0,0 @@
import { useEffect, useRef, useState } from "react"
import { motion, useReducedMotion } from "motion/react"
import { Check } from "lucide-react"
import { DEMO_ITEMS, won } from "@/lib/negotiation-sim"
import { EASE_OUT_EXPO } from "@/lib/motion"
/*
* .
*
* negotiation_annotated_3d.mp4(677K) . .
* - "아이마켓 구매 MD" . POC
* MRO .
* - . #5A68CD, #B96175,
* #DB840B ,
* (#0FFFD6, ) .
*
* . ,
* . 677K 0 .
*
* DEMO_ITEMS[0]
* · , .
*/
const ITEM = DEMO_ITEMS[0]
const FINAL = ITEM.target // 1,080,000 — 데모의 목표가와 동일
const SAVED = ITEM.listPrice - FINAL
const RATE = (SAVED / ITEM.listPrice) * 100
type Line = { side: "buyer" | "supplier"; text: string; price: number }
/* " " . ,
. */
const LINES: Line[] = [
{ side: "supplier", text: `원자재가 올라서 이번 분기는 ${won(ITEM.listPrice)}원이 최선입니다.`, price: ITEM.listPrice },
{ side: "buyer", text: `같은 사양 시장 최저가가 ${won(ITEM.marketLow)}원입니다. 연간 물량을 보증하면 어느 선까지 가능하신가요?`, price: ITEM.marketLow },
{ side: "supplier", text: `공정상 한 번에 내리긴 어렵고, ${won(1_140_000)}원까지는 조정하겠습니다.`, price: 1_140_000 },
{ side: "buyer", text: `목표가까지 얼마 남지 않았습니다. ${won(FINAL)}원이면 지금 바로 낙찰 처리하겠습니다.`, price: FINAL },
{ side: "supplier", text: `좋습니다. ${won(FINAL)}원으로 맞추겠습니다.`, price: FINAL },
]
const STEP_MS = 1600
/** 마지막 줄 뒤 결과 카드가 머무는 시간. 짧으면 결론을 못 읽고 지나간다. */
const HOLD_MS = 3200
export function NegotiationReplay({ className }: { className?: string }) {
const reduce = useReducedMotion()
const [shown, setShown] = useState(reduce ? LINES.length : 0)
const rootRef = useRef<HTMLDivElement>(null)
const logRef = useRef<HTMLDivElement>(null)
const [inView, setInView] = useState(false)
/* .
. */
useEffect(() => {
const el = rootRef.current
if (!el) return
const io = new IntersectionObserver(([e]) => setInView(e.isIntersecting), { threshold: 0.25 })
io.observe(el)
return () => io.disconnect()
}, [])
useEffect(() => {
if (reduce || !inView) return
const done = shown >= LINES.length
const id = window.setTimeout(() => setShown((n) => (n >= LINES.length ? 0 : n + 1)), done ? HOLD_MS : STEP_MS)
return () => clearTimeout(id)
}, [shown, inView, reduce])
// 새 줄이 붙으면 바닥으로 붙인다.
useEffect(() => {
logRef.current?.scrollTo({ top: logRef.current.scrollHeight, behavior: "smooth" })
}, [shown])
const current = shown > 0 ? LINES[shown - 1].price : ITEM.listPrice
const settled = shown >= LINES.length
/* 정가에서 목표가까지 얼마나 내려왔는지. 1 을 넘지 않게 자른다. */
const progress = Math.min(1, (ITEM.listPrice - current) / (ITEM.listPrice - FINAL))
return (
<div
ref={rootRef}
className={className}
role="img"
aria-label={`협상 리플레이. 최초 제시가 ${won(ITEM.listPrice)}원에서 ${won(FINAL)}원으로 낙찰되어 ${won(SAVED)}원 절감된 예시입니다.`}
>
<div className="rounded-card border border-stage-line bg-stage-raised/70 backdrop-blur-sm p-5 sm:p-6">
{/* 품목 + 목표가 — 구매 담당자가 위임한 기준이 무엇인지 먼저 보여준다. */}
<div className="flex items-start justify-between gap-3 mb-4">
<div className="min-w-0">
<div className="text-[14px] font-bold text-on-stage truncate">{ITEM.name}</div>
<div className="text-[12px] text-on-stage-muted">{ITEM.spec}</div>
</div>
<div className="text-right shrink-0">
<div className="text-[11px] font-semibold text-on-stage-muted"></div>
<div className="text-[14px] font-bold tabular-nums text-on-stage">{won(FINAL)}</div>
</div>
</div>
{/* 현재 제안가 + 진행 막대. 파랑에서 민트로 차오르며 양쪽이 만나는 지점을 그린다. */}
<div className="mb-5">
<div className="flex items-baseline justify-between mb-2">
<span className="text-[11px] font-semibold text-on-stage-muted"> </span>
<span className="text-[22px] font-extrabold tabular-nums tracking-[-0.02em] text-on-stage">
{won(current)}
<span className="text-[14px] font-semibold text-on-stage-muted ml-0.5"></span>
</span>
</div>
<div className="h-1.5 rounded-full bg-white/10 overflow-hidden">
<motion.div
className="h-full rounded-full bg-linear-to-r from-primary-on-stage to-counter"
animate={{ width: `${progress * 100}%` }}
transition={{ duration: 0.6, ease: EASE_OUT_EXPO }}
/>
</div>
</div>
{/* 대화. 높이를 고정해 줄이 늘어도 레이아웃이 튀지 않는다. */}
<div ref={logRef} className="h-[210px] overflow-hidden space-y-3 scrollbar-none">
{LINES.slice(0, shown).map((line, i) => (
<motion.div
key={i}
initial={reduce ? false : { opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: EASE_OUT_EXPO }}
className={`flex flex-col ${line.side === "buyer" ? "items-start" : "items-end"}`}
>
<span
className={`text-[11px] font-semibold mb-1 ${
line.side === "buyer" ? "text-primary-on-stage" : "text-counter"
}`}
>
{line.side === "buyer" ? "구매 담당자" : "협력사"}
</span>
<div
className={`max-w-[90%] rounded-card px-3.5 py-2.5 text-[13px] leading-[1.55] break-keep ${
line.side === "buyer"
? "bg-primary text-white"
: "bg-counter-surface text-counter border border-counter/20"
}`}
>
{line.text}
</div>
</motion.div>
))}
</div>
{/* .
. */}
<div className="mt-4 h-[74px]">
{settled && (
<motion.div
initial={reduce ? false : { opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, ease: EASE_OUT_EXPO }}
className="h-full rounded-card border border-counter/25 bg-counter-surface px-3.5 py-3 flex items-center gap-2.5"
>
<div className="w-8 h-8 rounded-full bg-counter/15 text-counter flex items-center justify-center shrink-0">
<Check className="w-4 h-4" />
</div>
{/* . 74px (
) . . */}
<div className="min-w-0">
<div className="text-[11px] font-semibold text-counter mb-0.5 whitespace-nowrap"> </div>
<div className="text-[16px] font-extrabold tabular-nums text-on-stage whitespace-nowrap">{won(FINAL)}</div>
</div>
<div className="text-right shrink-0 ml-auto">
<div className="text-[11px] font-semibold text-on-stage-muted mb-0.5 whitespace-nowrap"></div>
<div className="text-[15px] font-extrabold tabular-nums text-counter whitespace-nowrap">
{won(SAVED)} <span className="text-[12px]">{RATE.toFixed(1)}%</span>
</div>
</div>
</motion.div>
)}
</div>
</div>
</div>
)
}

View File

@ -1,166 +0,0 @@
import { useEffect, useRef, useState, type RefObject } from "react"
/* 6. ,
. */
export type DataFlowPhase = "scatter" | "cluster" | "stream" | "card" | "negotiate" | "learn"
/**
* .
*
* 3D , .
* negotium "닫힌 루프" .
*
* (infinith) , .
* .
*/
const NODES: { key: DataFlowPhase; label: string }[] = [
{ key: "scatter", label: "Collect" },
{ key: "cluster", label: "Classify" },
{ key: "stream", label: "Benchmark" },
{ key: "card", label: "Anchor" },
{ key: "negotiate", label: "Negotiate" },
{ key: "learn", label: "Learn" },
]
/* viewBox . ,
"Benchmark" (SVG viewBox ).
. */
const VB_W = 176
const VB_H = 150
const CX = VB_W / 2
const CY = VB_H / 2
const R = 46 // 궤도 반지름
const R_LABEL = 58 // 라벨은 궤도 바깥에
const rad = (ratio: number) => ratio * Math.PI * 2 - Math.PI / 2 // 12시에서 시계방향
const at = (ratio: number, radius: number) => ({
x: CX + Math.cos(rad(ratio)) * radius,
y: CY + Math.sin(rad(ratio)) * radius,
})
const LOOP_MS = 10_500 // 히어로 캔버스와 같은 주기
export function OrbitRing({
phase,
progressRef,
className = "",
}: {
/** 밖에서 단계를 주면 그걸 따르고, 없으면 자체 시계로 돈다. */
phase?: DataFlowPhase
/** 캔버스가 매 프레임 써 넣는 루프 진행도 0..1. 없으면 스스로 센다. */
progressRef?: RefObject<number>
className?: string
}) {
const headRef = useRef<SVGCircleElement>(null)
const [ownIndex, setOwnIndex] = useState(0)
const activeIndex = phase ? Math.max(0, NODES.findIndex((n) => n.key === phase)) : ownIndex
/* rAF 60fps setState .
( 0.5) . */
useEffect(() => {
let raf = 0
let last = -1
const startedAt = performance.now()
const tick = (now: number) => {
const p = progressRef ? (progressRef.current ?? 0) : ((now - startedAt) % LOOP_MS) / LOOP_MS
const { x, y } = at(p, R)
if (headRef.current) {
headRef.current.setAttribute("cx", String(x))
headRef.current.setAttribute("cy", String(y))
}
if (!phase) {
const i = Math.min(NODES.length - 1, Math.floor(p * NODES.length))
if (i !== last) {
last = i
setOwnIndex(i)
}
}
raf = requestAnimationFrame(tick)
}
raf = requestAnimationFrame(tick)
return () => cancelAnimationFrame(raf)
}, [progressRef, phase])
return (
<svg viewBox={`0 0 ${VB_W} ${VB_H}`} className={className} role="img" aria-label="협상 파이프라인 순환">
<defs>
<linearGradient id="orbitGrad" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor="var(--color-primary-on-stage)" stopOpacity="0.9" />
<stop offset="55%" stopColor="var(--color-on-stage-soft)" stopOpacity="0.35" />
<stop offset="100%" stopColor="var(--color-counter)" stopOpacity="0.55" />
</linearGradient>
</defs>
{/* 점선 궤도 — 이 레이어만 돈다 */}
<g className="animate-[spin_22s_linear_infinite] motion-reduce:animate-none" style={{ transformOrigin: `${CX}px ${CY}px` }}>
<circle cx={CX} cy={CY} r={R} fill="none" stroke="url(#orbitGrad)" strokeWidth="0.6" strokeDasharray="2.2 2.4" />
</g>
{/* 바깥 보조 호 — 정지. 원이 하나면 심심하고, 둘이면 공간이 생긴다. */}
<circle
cx={CX}
cy={CY}
r={R + 9}
fill="none"
stroke="var(--color-on-stage-soft)"
strokeOpacity="0.1"
strokeWidth="0.4"
/>
{/* 마디 + 라벨 — 고정 레이어 */}
{NODES.map((n, i) => {
const ratio = i / NODES.length
const p = at(ratio, R)
const l = at(ratio, R_LABEL)
const cos = Math.cos(rad(ratio))
const anchor = cos > 0.3 ? "start" : cos < -0.3 ? "end" : "middle"
const active = i === activeIndex
return (
<g key={n.key}>
<circle
cx={p.x}
cy={p.y}
r={active ? 2.6 : 1.4}
fill={active ? "var(--color-primary-on-stage)" : "var(--color-on-stage-soft)"}
opacity={active ? 1 : 0.45}
style={{ transition: "r 240ms ease-out, opacity 240ms ease-out" }}
/>
<text
x={l.x}
y={l.y + 1.6}
textAnchor={anchor}
fontSize="5"
fontWeight={active ? 700 : 500}
fill={active ? "#FFFFFF" : "var(--color-on-stage-soft)"}
opacity={active ? 1 : 0.5}
style={{ transition: "opacity 240ms ease-out" }}
>
{n.label}
</text>
</g>
)
})}
{/* 궤도를 도는 머리 */}
<circle ref={headRef} cx={CX} cy={CY - R} r="2" fill="#FFFFFF" opacity="0.95" />
{/* 가운데 */}
<text x={CX} y={CY - 1} textAnchor="middle" fontSize="7" fontWeight="700" fill="#FFFFFF">
{NODES[activeIndex].label}
</text>
<text
x={CX}
y={CY + 8.5}
textAnchor="middle"
fontSize="4.2"
fontWeight="500"
fill="var(--color-on-stage-soft)"
opacity="0.5"
>
{String(activeIndex + 1).padStart(2, "0")} / {String(NODES.length).padStart(2, "0")}
</text>
</svg>
)
}

View File

@ -0,0 +1,28 @@
type RangeSliderProps = {
min: number
max: number
step: number
value: number
onChange: (value: number) => void
}
/** 채워진 트랙이 값을 따라가는 파란 슬라이더 (ROI 시뮬레이터·히어로 콘솔 공용). */
function RangeSlider({ min, max, step, value, onChange }: RangeSliderProps) {
const filled = ((value - min) / (max - min)) * 100
return (
<input
type="range"
min={min}
max={max}
step={step}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="w-full h-1.5 rounded-lg appearance-none cursor-pointer accent-primary transition-all"
style={{
background: `linear-gradient(to right, var(--color-primary) 0%, var(--color-primary) ${filled}%, var(--color-line-strong) ${filled}%, var(--color-line-strong) 100%)`,
}}
/>
)
}
export { RangeSlider }

View File

@ -3,102 +3,25 @@ import * as React from "react"
import { Typography } from "@/components/ui/typography"
import { cn } from "@/lib/utils"
/*
* .
*
* eyebrow , .
* ,
* "작은 글씨" .
* ( 3.5~4) 2 .
*
*
* . mb-12 / mb-16 /
* mb-20 / mb-24 . ( )
* , "정돈이 안 됐다"
* .
*
* . .
* "어디까지가 한 덩어리인가" .
*
* eyebrow title 16px
* title description 20 / 32px . 64px 20px
* 56 / 80px
*
* mb-· mt- .
* import , .
* .
*/
/** 머리 → 본문. 머리를 직접 조판하는 섹션(무대 데모)도 이 상수를 쓴다. */
export const HEADING_GAP = "mb-14 md:mb-20"
/** 탭·토글 행 → 본문. 머리 간격보다 좁아서 컨트롤이 위가 아니라 본문 쪽에 묶인다. */
export const CONTROL_GAP = "mb-12"
/** 리드 문단 → 버튼 행 (히어로·최종 CTA). */
export const CTA_GAP = "mt-12"
/** display 티어 제목 → 리드 문단. 히어로는 h1 이라 SectionHeading 을 못 쓰고 이 상수를 직접 쓴다. */
export const DISPLAY_LEAD_GAP = "mt-8"
/*
* .
* Typography "크기·굵기·조임은 같이 움직인다" , .
*/
const SIZES = {
/** 섹션 표준 h2 (34 / 64px) */
title: { variant: "title", descriptionGap: "mt-5" },
/** 히어로·최종 CTA 급 디스플레이 (38~74px) */
display: { variant: "stageDisplay", descriptionGap: DISPLAY_LEAD_GAP },
} as const
type SectionHeadingProps = {
eyebrow?: string
eyebrow: string
title: React.ReactNode
description?: React.ReactNode
align?: "left" | "center"
/** 다크 무대 섹션(bg="stage")에서는 "stage" 를 준다. Typography 변형이 먹색을 물고 있어서 필요하다. */
tone?: "light" | "stage"
/** 제목 조판 티어. display 는 히어로·최종 CTA 전용. */
size?: keyof typeof SIZES
/** 머리 아래 표준 간격을 끈다. 뒤에 컨트롤 행이 붙는 섹션에서만 쓴다. */
gap?: "heading" | "none"
className?: string
/** 제목 크기 미세조정 (예: 최종 CTA 의 축소 디스플레이) */
titleClassName?: string
/** 리드 문단 폭 제한·색 조정 (예: "max-w-2xl"). 세로 간격은 여기서 주지 않는다. */
/** 리드 문단 폭 제한 등 (예: "max-w-2xl") */
descriptionClassName?: string
}
function SectionHeading({
eyebrow,
title,
description,
align = "left",
tone = "light",
size = "title",
gap = "heading",
className,
titleClassName,
descriptionClassName,
}: SectionHeadingProps) {
const onStage = tone === "stage"
const { variant, descriptionGap } = SIZES[size]
function SectionHeading({ eyebrow, title, description, align = "left", className, descriptionClassName }: SectionHeadingProps) {
return (
<div className={cn(align === "center" && "text-center", gap === "heading" && HEADING_GAP, className)}>
{/* eyebrow span mb + 30px .
eyebrow , . */}
{eyebrow && (
<Typography variant="eyebrow" className={cn("mb-4 block", onStage && "text-primary-on-stage")}>
{eyebrow}
</Typography>
)}
<Typography variant={variant} as="h2" className={cn(onStage && "text-on-stage", titleClassName)}>
{title}
<div className={cn(align === "center" && "text-center", className)}>
<Typography variant="eyebrow" className="mb-3 block">
{eyebrow}
</Typography>
<Typography variant="title">{title}</Typography>
{description && (
<Typography
variant="lead"
className={cn(descriptionGap, align === "center" && "mx-auto", onStage && "text-on-stage-soft", descriptionClassName)}
>
<Typography variant="lead" className={cn("mt-5", align === "center" && "mx-auto", descriptionClassName)}>
{description}
</Typography>
)}

View File

@ -6,8 +6,6 @@ const sectionBg = {
white: "bg-white",
surface: "bg-surface",
dark: "bg-ink text-white",
/** 히어로와 같은 무대. 어두운 섹션은 이걸 쓴다 — 페이지 안에 어둠이 두 종류면 따로 논다. */
stage: "stage-bg text-on-stage",
}
const sectionWidth = {
@ -39,8 +37,7 @@ function Section({
}: SectionProps) {
return (
<section
/* 레퍼런스 섹션 패딩은 104~139px. 176px 은 과해서 "채울 내용이 없어" 보인다. */
className={cn("py-24 md:py-32", sectionBg[bg], bordered && "border-t border-line", decor && "relative overflow-hidden", className)}
className={cn("py-36 md:py-44", sectionBg[bg], bordered && "border-t border-line", decor && "relative overflow-hidden", className)}
{...props}
>
{decor}

View File

@ -56,7 +56,7 @@ function SlateLeaf({ leaf }: { leaf: SlateText }) {
children = <em className="italic">{children}</em>
}
if (leaf.code) {
children = <code className="bg-primary/10 px-1.5 py-0.5 rounded-control font-mono text-xs text-primary font-bold">{children}</code>
children = <code className="bg-primary/10 px-1.5 py-0.5 rounded-lg font-mono text-xs text-primary font-bold">{children}</code>
}
return <span>{children}</span>

View File

@ -3,60 +3,28 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
/*
* . text-[44px]/font-black/
* variant . · className .
*
* (statworx.com) . :
*
* 1) ·· . 44px 900 , 72px 800 +
* -0.044em + 1.0 (jitter.video 실측: 72px/800/-0.044em/0.95).
* , .
* 1-1) "글자가 화면 폭의 몇 %를 쓰는가" ( statworx 48%).
* 주의: h1 getBoundingClientRect .
* Range .
* 74px 40%. .
* 2) break-keep . break-keep
* "에이전트는 기준 / 밖으로 안 나갑니다"
* . text-balance .
* 3) . -0.042em· 1.0 , .
* 0.95 ·
* . 0.95~1.0
* . 1.07 .
* 3) normal. 10.4px/600/ normal .
* 14px + tracking-wider + + AI .
*
* 보정: 본문은 (13.9px) . 14px
* . (디스플레이:본문 4.5:1) .
*/
// 랜딩 텍스트 스케일 단일 소스. 페이지마다 text-[44px]/font-black/…을 직접 박지 말고
// variant 로 통일한다. 색·크기 미세조정은 className 으로 합성.
const typographyVariants = cva("", {
variants: {
variant: {
// 라이트 섹션 h1
display: "text-[44px] sm:text-[58px] md:text-[68px] font-extrabold tracking-[-0.04em] leading-[1.14] text-ink break-keep text-balance",
// 다크 무대 히어로 h1. 색은 쓰는 쪽에서 준다(2톤 헤드라인).
stageDisplay:
"text-[38px] sm:text-[52px] md:text-[64px] lg:text-[74px] font-extrabold tracking-[-0.042em] leading-[1.1] break-keep text-balance",
// 히어로 h1
display: "text-3xl sm:text-4xl md:text-[50px] font-black tracking-tight leading-[1.2] text-ink break-keep",
// 섹션 제목 h2
/* (15px) 4.3.
, .
조인다: 52px -0.021em 64px . */
title: "text-[34px] md:text-[64px] font-extrabold tracking-[-0.042em] leading-[1.16] text-ink break-keep text-balance",
title: "text-3xl md:text-[44px] font-black tracking-tight leading-tight text-ink break-keep",
// 섹션 내 서브 제목 (데모 패널 등)
heading: "text-[24px] md:text-[32px] font-bold tracking-[-0.034em] leading-[1.14] text-ink break-keep text-balance",
heading: "text-2xl md:text-3xl font-bold tracking-tight text-ink break-keep",
// 카드 제목
cardTitle: "text-[20px] font-bold tracking-[-0.028em] leading-[1.3] text-ink break-keep",
/* . title , .
SaaS .
. . */
eyebrow: "font-display italic text-[24px] md:text-[30px] font-normal text-primary tracking-[0.005em]",
cardTitle: "text-lg font-bold text-ink break-keep",
// 섹션 상단 오버라인 라벨
eyebrow: "text-sm font-semibold text-primary tracking-wider uppercase",
// 섹션 리드 문단
lead: "text-[16px] sm:text-[17px] text-ink-soft font-normal leading-[1.6] break-keep",
body: "text-[15px] text-ink-soft font-normal leading-[1.6] break-keep",
small: "text-[14px] text-ink-soft font-normal leading-[1.6] break-keep",
caption: "text-[12.5px] text-ink-muted font-normal leading-[1.55] break-keep",
// 메타 라벨 (단가 패널·푸터 컬럼 제목 등)
micro: "text-[11px] font-semibold text-ink-muted tracking-normal",
lead: "text-base sm:text-lg text-ink-soft font-medium leading-relaxed break-keep",
body: "text-[15px] text-ink-soft font-medium leading-relaxed break-keep",
small: "text-sm text-ink-soft font-medium leading-relaxed break-keep",
caption: "text-xs text-ink-muted font-semibold leading-relaxed break-keep",
// 11px 트래킹 대문자 메타 라벨 (단가 패널·푸터 컬럼 제목 등)
micro: "text-[11px] font-bold text-ink-muted uppercase tracking-wider",
},
},
defaultVariants: { variant: "body" },
@ -64,7 +32,6 @@ const typographyVariants = cva("", {
const defaultTag: Record<NonNullable<VariantProps<typeof typographyVariants>["variant"]>, React.ElementType> = {
display: "h1",
stageDisplay: "h1",
title: "h2",
heading: "h3",
cardTitle: "h3",

View File

@ -1,58 +0,0 @@
/*
* .
*
* [] / mailto .
* '열릴' , UI "접수 완료"
* "메일 앱을 열었습니다 — 전송해 주세요" (fake success ). API/ .
*/
// 리드 수신 주소 — 임시. 실제 영업 주소로 바꾸고, 이후 API 붙이면 mailto 자체를 교체할 것.
const LEAD_EMAIL = "o2odev@o2o.kr"
export type LeadSource = "demo-request" | "contact"
export type Lead = {
source: LeadSource
name: string
email: string
company?: string
phone?: string
message?: string
/** 봇 함정. 화면에서 감춘 필드라 값이 차 있으면 자동 제출이다. 서버가 조용히 버린다. */
website?: string
}
export type LeadResult = { ok: true } | { ok: false; reason: "invalid" | "network" | "rejected" }
/** 느슨한 검사. 정규식으로 이메일을 엄밀히 검증하려는 시도는 늘 진짜 주소를 막는다. */
export function isEmailLike(value: string) {
const v = value.trim()
return v.length >= 5 && v.includes("@") && !v.startsWith("@") && !v.endsWith("@") && !/\s/.test(v)
}
export async function submitLead(lead: Lead): Promise<LeadResult> {
if (!lead.name.trim() || !isEmailLike(lead.email)) return { ok: false, reason: "invalid" }
const label = lead.source === "demo-request" ? "데모 요청" : "도입 문의"
const body = [
`[${label}]`,
`회사명: ${lead.company || "-"}`,
`담당자: ${lead.name}`,
`이메일: ${lead.email}`,
`연락처: ${lead.phone || "-"}`,
`내용: ${lead.message || "-"}`,
].join("\n")
const href = `mailto:${LEAD_EMAIL}?subject=${encodeURIComponent(`[${label}] ${lead.company || lead.name}`)}&body=${encodeURIComponent(body)}`
// 방문자의 메일 앱을 연다 — 앵커 클릭이 location.href 할당보다 핸들러 발동이 확실하다.
// 실제 전송은 방문자가 하므로 성공을 단정하지 않는다(호출부 UI 는 안내 문구).
if (typeof document !== "undefined") {
const a = document.createElement("a")
a.href = href
a.style.display = "none"
document.body.appendChild(a)
a.click()
a.remove()
}
return { ok: true }
}

View File

@ -1,198 +0,0 @@
/**
* ( ).
*
* negotium (agent ) .
* , .
*
* - (anchor) .
* - . 3.
* - ( ). .
*
* React . (/api/demo/negotiate)
* .
*/
export type Role = "buyer" | "seller"
export type DemoItem = {
id: string
name: string
spec: string
/** 협력사 최초 제시가 */
listPrice: number
/** LPS 인터넷 최저가 — 에이전트가 근거로 인용한다 */
marketLow: number
/** 앵커링가. 이 이하면 무조건 낙찰 */
anchor: number
/** 견적 생성 때 정한 목표가 */
target: number
}
export const MAX_CARDS = 3
export const DEMO_ITEMS: DemoItem[] = [
{
id: "glove",
name: "니트릴 코팅 안전장갑",
spec: "1,000켤레 · 월 정기",
listPrice: 1_200_000,
marketLow: 1_110_000,
anchor: 1_020_000,
target: 1_080_000,
},
{
id: "paper",
name: "A4 복사용지 80g",
spec: "500박스 · 분기 발주",
listPrice: 8_750_000,
marketLow: 8_200_000,
anchor: 7_900_000,
target: 8_100_000,
},
{
id: "oil",
name: "산업용 윤활유 20L",
spec: "300통 · 반기 발주",
listPrice: 5_400_000,
marketLow: 5_050_000,
anchor: 4_850_000,
target: 4_980_000,
},
]
export type Turn = {
id: number
side: "agent" | "counterpart"
text: string
/** 이 턴에서 테이블에 올라온 가격 */
price?: number
}
export type Outcome = "running" | "award" | "open"
/** 협상에서 실제로 오가는 조건들. 가격만 깎는 게 아니라는 걸 보여주는 장치. */
const LEVERS = ["연간 물량 보증", "납기 2주 연장", "대금 지급일 15일 단축"] as const
/** 10원 단위 반올림 — 실제 엔진의 앵커링 정돈 규칙과 같다. */
export const round10 = (n: number) => Math.round(n / 10) * 10
export const won = (n: number) => n.toLocaleString("ko-KR")
/**
* .
*
* .
* 0 , 1 .
* .
*/
const CONCESSION = [0, 0.34, 0.62]
/** 협력사(사용자) 제시가에 대한 에이전트의 판정. seller 모드의 핵심. */
export function respondToOffer(
item: DemoItem,
offer: number,
cardsUsed: number,
): { outcome: Outcome; price: number; text: string } {
if (offer <= item.anchor) {
return {
outcome: "award",
price: offer,
text: `${won(offer)}원으로 확정하겠습니다. 목표가 ${won(item.target)}원 대비 ${won(
item.target - offer,
)} . .`,
}
}
if (cardsUsed >= MAX_CARDS) {
return {
outcome: "open",
price: offer,
text: `${won(offer)}원은 이번 견적의 낙찰 기준을 넘습니다. 협상 카드를 다 썼으니 이 건은 개찰로 마감하고 담당자에게 넘기겠습니다.`,
}
}
const ask = round10(item.anchor + (offer - item.anchor) * CONCESSION[cardsUsed])
const lever = LEVERS[cardsUsed]
/* .
marketLow , .
CONCESSION[0] = 0 (ask) ,
. ( )
. */
const gap = offer - ask
const reason =
cardsUsed > 0
? `${won(offer)}원과 저희 기준 사이가 아직 ${won(gap)}원 남았습니다.`
: offer >= item.listPrice
? `정가 그대로는 검토가 어렵습니다. 같은 사양 인터넷 최저가가 ${won(item.marketLow)}원입니다.`
: offer > item.marketLow
? `${won(offer)}원은 같은 사양 인터넷 최저가 ${won(item.marketLow)}원보다 ${won(
offer - item.marketLow,
)} .`
: `${won(offer)}원이면 시장 최저가 선까지 내려오셨습니다. 다만 이번 견적 기준까지 ${won(gap)}원 남았습니다.`
return {
outcome: "running",
price: ask,
text: `${reason} ${won(ask)}원까지 맞춰주시면 ${lever}으로 보전해 드리겠습니다.`,
}
}
/**
* buyer .
* .
*/
export function simulateBuyerRun(item: DemoItem, target: number): { turns: Turn[]; outcome: Outcome; finalPrice: number } {
const turns: Turn[] = []
let id = 0
const push = (side: Turn["side"], text: string, price?: number) => turns.push({ id: id++, side, text, price })
push("counterpart", `원자재가 올라서 이번 분기는 ${won(item.listPrice)}원이 최선입니다.`, item.listPrice)
// 목표가가 앵커보다 낮으면 협력사가 받아들일 수 없는 구간이다.
// 에이전트는 기준 밖으로 나가지 않으므로 개찰로 끝난다 — 제품의 안전장치를 보여주는 케이스다.
const reachable = target >= item.anchor
/* . marketLow
.
( ) . */
const stance =
target < item.marketLow
? `시장 최저가보다 낮은 목표라 근거부터 깔겠습니다.`
: `시장 최저가 선이라 무리 없이 접근하겠습니다.`
push(
"agent",
`목표가 ${won(target)}원 받았습니다. ${stance} 같은 사양 인터넷 최저가가 ${won(
item.marketLow,
)}. ?`,
item.marketLow,
)
const mid = round10((item.listPrice + Math.max(target, item.anchor)) / 2)
push("counterpart", `공정상 한 번에 내리긴 어렵고, ${won(mid)}원까지는 조정하겠습니다.`, mid)
if (!reachable) {
push(
"agent",
`목표가 ${won(target)}원은 협력사가 받아들일 수 있는 선 아래입니다. 무리하게 밀지 않고 이 건은 개찰로 마감하겠습니다.`,
mid,
)
return { turns, outcome: "open", finalPrice: mid }
}
push("agent", `${won(target)}원이면 연 12회 정기 발주로 확정하겠습니다. 대신 납기는 2주 여유를 드리겠습니다.`, target)
push("counterpart", `정기 발주와 결제 조건을 지켜주신다면 ${won(target)}원으로 맞추겠습니다.`, target)
push(
"agent",
`${won(target)}원으로 확정했습니다. 최초 제시가 대비 ${won(item.listPrice - target)}원 절감입니다.`,
target,
)
return { turns, outcome: "award", finalPrice: target }
}
/** 절감액·절감률. 결과 화면과 상담 신청 연결에 쓴다. */
export function savings(item: DemoItem, finalPrice: number) {
const amount = Math.max(0, item.listPrice - finalPrice)
return { amount, rate: (amount / item.listPrice) * 100 }
}

View File

@ -8,9 +8,6 @@ export const links: Route.LinksFunction = () => [
{ rel: "icon", href: "/favicon.svg", type: "image/svg+xml" },
// 본문 서체 프리로드 — 프리렌더된 첫 화면의 FOUT 최소화
{ rel: "preload", href: "/fonts/PretendardVariable.woff2", as: "font", type: "font/woff2", crossOrigin: "anonymous" },
/* self-host (latin 39KB).
CDN LCP IP .
preload . */
];
export function Layout({ children }: { children: ReactNode }) {

View File

@ -5,16 +5,14 @@ import { Faq } from "@/components/sections/faq";
import { FinalCTA } from "@/components/sections/final-cta";
import { Footer } from "@/components/sections/footer";
import { Header } from "@/components/sections/header";
import { HeroDataFlow } from "@/components/sections/hero-dataflow";
import { HeroNeumorphic } from "@/components/sections/hero-neumorphic";
import { HowItWorksDemo } from "@/components/sections/how-it-works-demo";
import { NegotiationDemo } from "@/components/sections/negotiation-demo";
import { NegotiationConsole } from "@/components/sections/negotiation-console";
import { Reinforcement } from "@/components/sections/reinforcement";
/* · . ""
"negotium" . */
const TITLE = "네고시움(negotium) — AI 구매 협상 자동화";
const TITLE = "negotium — AI 구매 협상 자동화";
const DESCRIPTION =
"품목·목표가·마감일만 정하면 협상 에이전트가 협력사마다 1:1로 단가를 조율하고 낙찰까지 판정합니다. 협상 기록이 쌓일수록 조건이 좋아지는 B2B 구매 협상 자동화 솔루션.";
"가이드라인만 정하면 AI 흥정 봇이 여러 협력사와 단가를 대신 조율합니다. 흥정부터 낙찰까지 자동으로, 협상할수록 강화학습으로 더 좋은 조건을 만드는 B2B 구매 협상 자동화 솔루션.";
export function meta() {
return [
@ -32,8 +30,8 @@ export default function Home() {
return (
<div className="min-h-screen bg-white text-ink font-sans antialiased selection:bg-primary/10 selection:text-primary">
<Header />
<HeroDataFlow />
<NegotiationDemo />
<HeroNeumorphic />
<NegotiationConsole />
<HowItWorksDemo />
<Reinforcement />
<CoreValues />

View File

@ -1,69 +0,0 @@
# 히어로 CTA 문구 변경 결정 기록
- **일자**: 2026-08-07
- **상태**: 적용·배포 완료
- **범위**: 히어로 CTA 2종, 데모 요청 모달 제목
- **관련 커밋**: `77d96cd`
---
## 요약
히어로 상단 CTA 2종의 문구를 변경했다. 최초 지시안을 검토 과정에서 일부 수정했으며,
수정안으로 승인·적용·배포를 완료했다.
## 1. 변경 내역
| 위치 | 기존 | 최초 지시안 | **최종 적용** |
|---|---|---|---|
| 1차 CTA | 여기서 직접 체험 | 협상 예시 보기 | **협상 예시 체험** |
| 2차 CTA | 데모 요청 | 직접 체험 | **실제 데모 받기** |
| 모달 제목 | 데모 요청 | (미지정) | **실제 데모 받기** |
## 2. 최초 지시안을 수정한 사유
두 버튼은 클릭 후 경험이 전혀 다르다. 1차는 페이지 내에서 즉시 조작하는 인터랙티브
데모(`#how-it-works`)이고, 2차는 이름·이메일을 받는 리드 수집 폼(모달)이다.
| 지시안 | 확인된 문제 | 사업적 영향 |
|---|---|---|
| 협상 예시 **보기** | 도착 섹션이 "직접 경험해 보세요", 역할 카드가 "공급사로 해보기"로 조작을 요구함. 버튼은 관람을 약속하고 목적지는 참여를 요구 | 클릭 전 기대와 실제 요구 불일치 → 데모 진입 단계 이탈 |
| **직접 체험** | 클릭 시 실제로는 입력 폼이 노출됨. 체험은 이메일 수신 이후 시작 | 즉시 체험을 기대한 방문자의 신뢰 저하 → 리드 폼 이탈 |
## 3. 최종안이 지시 의도를 유지하는 방식
최초 지시의 핵심 의도는 **"페이지 내 데모는 시뮬레이션, 이메일 데모가 실제 제품"**
이라는 구분이었으며, 최종안은 이를 그대로 반영했다.
| 요소 | 반영 방식 |
|---|---|
| 시뮬레이션임을 명시 | "**예시**" 유지 — 고정 데이터 기반임을 표기 |
| 목적지와의 정합성 | "**체험**" 유지 — 섹션 헤드라인·역할 카드와 충돌 없음 |
| 실제 제품 구분 | "**실제** 데모" — 온페이지 예시와 명확히 분리 |
| 전달 경로 명시 | "**받기**" — 이메일 수신 방식임이 드러나 폼 노출이 자연스러움 |
## 4. 부수 조치
| 항목 | 조치 | 사유 |
|---|---|---|
| 모달 제목 | "데모 요청" → "실제 데모 받기" | 버튼과 창 제목 불일치 시 전환 순간 이탈 방지 |
| 모달 제출 버튼 | "데모 신청" **유지** | 해당 클릭의 실제 결과는 신청 접수이며, 데모 수령은 이메일 단계에서 발생 |
| 데모 섹션 헤드라인 | **변경 없음** | 최종안이 "체험"을 유지하여 수정 불필요 |
## 5. 적용 원칙
> **버튼 문구는 클릭 시 실제로 발생하는 동작을 기술한다.**
> 문구와 동작이 어긋나면 전환 손실로 직결된다.
이 원칙은 이후 CTA 문구를 손볼 때 같은 기준으로 적용한다. 특히 아래 두 경우를 주의한다.
- **목적지와 동사가 어긋나는 경우** — 버튼이 "보기"인데 도착지가 조작을 요구하면 안 된다.
- **폼을 여는 버튼에 결과를 약속하는 경우** — "체험"·"시작" 류는 즉시 그 일이 일어날 때만 쓴다.
## 6. 참고 — 관련 코드 위치
| 대상 | 파일 |
|---|---|
| 히어로 CTA 2종 | `app/components/sections/hero-dataflow.tsx` |
| 모달 제목·제출 버튼 | `app/components/ui/demo-request-modal.tsx` |
| 도착 섹션 헤드라인·역할 카드 | `app/components/sections/negotiation-demo.tsx` |

View File

@ -1,99 +0,0 @@
/*
* 리드 수신용 Google Apps Script 데모 요청·상담 신청을 스프레드시트에 적재한다.
*
* 파일은 랜딩 번들에 포함되지 않는다. Google Apps Script 편집기에 붙여넣을 원본이고,
* 여기 두는 이유는 배포 코드가 어디에도 남지 않으면 나중에 아무도 손댈 없기 때문이다.
*
* 설치 ( 3)
* 1. Google 스프레드시트를 만든다. 시트 이름은 그대로 둬도 된다.
* 2. 확장 프로그램 Apps Script 기본 코드를 지우고 파일 전체를 붙여넣는다.
* 3. 배포 배포 유형 "웹 앱"
* 실행 계정 :
* 액세스 권한 : 모든 사용자 이걸 "나" 두면 랜딩에서 호출이 막힌다
* 4. 배포하면 나오는 URL(https://script.google.com/macros/s/…/exec)을 복사한다.
* 5. Vercel negotium-landing Settings Environment Variables
* 이름 LEAD_WEBHOOK_URL / 복사한 URL / Production 체크
* 6. 다시 배포한다. 환경변수는 배포 시점에 주입되므로 재배포 전에는 적용되지 않는다.
*
* 동작
* api/lead.ts 아래 형태로 POST 한다. 필드가 늘어도 헤더를 자동으로 확장하므로
* 스크립트를 다시 고칠 일은 거의 없다.
* { text, source, name, email, company, phone, message, submittedAt, userAgent }
*
* 주의: 앱은 POST 302 돌려주고 script.googleusercontent.com 으로 넘긴다.
* api/lead.ts fetch 리다이렉트를 따라가므로 최종 200 받는다 정상이다.
*/
/** 리드가 쌓일 시트 이름. 없으면 자동 생성한다. */
var SHEET_NAME = 'leads'
/** 항상 이 순서로 왼쪽부터 채운다. 나머지 필드는 뒤에 자동으로 붙는다. */
var PREFERRED = ['submittedAt', 'source', 'name', 'email', 'company', 'phone', 'message', 'userAgent']
function doPost(e) {
try {
var payload = JSON.parse((e && e.postData && e.postData.contents) || '{}')
// text 는 Slack 전용 요약이라 시트에는 넣지 않는다. 같은 내용이 개별 필드에 이미 있다.
delete payload.text
var lock = LockService.getScriptLock()
lock.waitLock(20000) // 동시 제출이 같은 행에 겹쳐 쓰는 것을 막는다
try {
var sheet = getSheet_()
var header = ensureHeader_(sheet, payload)
var row = header.map(function (key) {
return payload[key] === undefined ? '' : payload[key]
})
sheet.appendRow(row)
} finally {
lock.releaseLock()
}
return json_({ ok: true })
} catch (err) {
// 실패해도 랜딩 쪽 api/lead.ts 가 이미 로그를 남겼으므로 리드 자체는 보존된다.
return json_({ ok: false, error: String(err) })
}
}
/** 브라우저로 URL 을 열었을 때 살아있는지 확인용. 배포 직후 점검에 쓴다. */
function doGet() {
return json_({ ok: true, service: 'negotium lead sink' })
}
function getSheet_() {
var ss = SpreadsheetApp.getActiveSpreadsheet()
return ss.getSheetByName(SHEET_NAME) || ss.insertSheet(SHEET_NAME)
}
/** 헤더가 없으면 만들고, 처음 보는 필드가 오면 열을 덧붙인다. */
function ensureHeader_(sheet, payload) {
var lastCol = sheet.getLastColumn()
var header = lastCol ? sheet.getRange(1, 1, 1, lastCol).getValues()[0].filter(String) : []
if (!header.length) {
header = PREFERRED.filter(function (k) {
return k in payload
})
Object.keys(payload).forEach(function (k) {
if (header.indexOf(k) === -1) header.push(k)
})
sheet.getRange(1, 1, 1, header.length).setValues([header]).setFontWeight('bold')
sheet.setFrozenRows(1)
return header
}
var added = Object.keys(payload).filter(function (k) {
return header.indexOf(k) === -1
})
if (added.length) {
sheet.getRange(1, header.length + 1, 1, added.length).setValues([added]).setFontWeight('bold')
header = header.concat(added)
}
return header
}
function json_(obj) {
return ContentService.createTextOutput(JSON.stringify(obj)).setMimeType(ContentService.MimeType.JSON)
}

2422
landing/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -13,13 +13,13 @@
"@react-router/node": "^7.17.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"isbot": "^5",
"lucide-react": "^0.546.0",
"motion": "^12.23.24",
"react": "^19.0.1",
"react-dom": "^19.0.1",
"react-router": "^7.17.0",
"tailwind-merge": "^3.6.0"
"tailwind-merge": "^3.6.0",
"isbot": "^5"
},
"devDependencies": {
"@react-router/dev": "^7.17.0",
@ -27,7 +27,6 @@
"@types/node": "^22.14.0",
"@types/react": "^19.0.1",
"@types/react-dom": "^19.0.1",
"@vercel/node": "^5.9.5",
"tailwindcss": "^4.1.14",
"typescript": "~5.8.2",
"vite": "^6.2.3"

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 4.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 56 KiB

View File

@ -1,7 +0,0 @@
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"framework": null,
"buildCommand": "npm run build",
"outputDirectory": "build/client",
"rewrites": [{ "source": "/((?!api/).*)", "destination": "/index.html" }]
}

View File

@ -309,14 +309,12 @@ class quotations(MainTableMixin, MAIN_BASE):
equal_bid_yn = Column(Boolean, nullable=True)
equal_bid_data = Column(JSONB, nullable=True)
close_reason = Column(SmallInteger, nullable=True) # CloseReason 코드. 마감 시 사유 기록(재생성 한도 카운팅·유찰 사유 구분). 미마감이면 NULL
award_type = Column(SmallInteger, nullable=True) # AwardType 코드(1=자동/2=직접). 낙찰 방식 — 통계에서 AI 자동낙찰과 담당자 직접낙찰 구분. 미낙찰이면 NULL
# 낙찰 기준(가격게이트) — 견적 단위. 마감 판정(close_and_decide)이 이 행값을 읽는다. 기준 미달이면 개찰(낙찰자 미정 마감).
# 1:1 협상: over 는 항상 OPEN(목표 초과=개찰), mid 만 앵커/목표 택1. 1:N 경매: mid=over=AWARD 강제(무조건 최저가 낙찰).
mid_action = Column(SmallInteger, nullable=False, server_default=text("1"), default=1) # PriceGateAction: 앵커링가<투찰가≤목표가 처리(1=낙찰/2=개찰)
over_action = Column(SmallInteger, nullable=False, server_default=text("1"), default=1) # PriceGateAction: 목표가<투찰가 처리(1=낙찰/2=개찰)
done_ceiling_rate = Column(SmallInteger, nullable=True) # 협상 완료 상한율(‰) 견적별 override. NULL 이면 quotation_settings 값
custom = Column(JSONB, nullable=True) # 견적 단위 부가정보. 직접 낙찰 시 award={reason,by,at}(사유·처리자·시각). 표시·감사용(집계 안 함)
class sessions(MainTableMixin, MAIN_BASE):
@ -342,7 +340,6 @@ class sessions(MainTableMixin, MAIN_BASE):
reject_reason = Column(String(255), nullable=True)
reject_price = Column(BigInteger, nullable=True)
reject_delivery_type = Column(SmallInteger, nullable=True) # DeliveryType 코드
contract_price = Column(BigInteger, nullable=True) # 직접 낙찰 계약가(원). 자동낙찰은 NULL(bid_price 가 계약가). 통계는 coalesce(contract_price, bid_price)
email_sent_at = Column(DateTime(timezone=True), nullable=True) # 협상 초청 메일 발송 시각(NULL=미발송)
custom = Column(JSONB, nullable=True) # 협상완료 부가정보 값 {key: value} (정의는 companies.settings.session_fields)

View File

@ -213,15 +213,6 @@ class CloseReason(CodeEnum):
OPEN_REJECT = 8 # 개찰: 협상거부 존재
class AwardType(CodeEnum):
"""quotations.award_type 코드값(SMALLINT). 낙찰이 어떻게 확정됐는지 — 통계에서 AI 협상 성과와
담당자 오프라인 낙찰을 나눈다. 미마감·미낙찰(개찰)이면 NULL.
AUTO=시스템이 투찰가로 자동 낙찰 / MANUAL=담당자가 개찰 건을 오프라인 협상해 계약가로 직접 낙찰."""
AUTO = 1 # 자동 낙찰(close_and_decide, 투찰가 기준)
MANUAL = 2 # 직접 낙찰(담당자 계약가 입력 — sessions.custom.offline_award 와 짝)
class PriceGateAction(CodeEnum):
"""낙찰 기준(견적 단위) 가격게이트 판정값. '앵커링가<투찰가≤목표가'(mid) / '목표가<투찰가'(over) 구간에 적용.
(투찰가앵커링가 항상 낙찰.) 기준 미달이면 개찰(낙찰자 미정 마감) 재협상·결렬 없음.

View File

@ -5,7 +5,7 @@ from sqlalchemy import select, func, and_, or_, update, case
from sqlalchemy.ext.asyncio import AsyncSession
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import users, chats, sessions, quotations
from common.database.model.models import users, chats, sessions
from common.enums import ErrorType, SessionStatus
from common.logger import LOG
from common.utils.gtime import GTime
@ -38,7 +38,7 @@ class ICardCRUD(ABC):
pass
@abstractmethod
async def card_success_map(self, cdb: AsyncSession, company_id) -> Tuple[ErrorType, dict]:
async def card_success_map(self, cdb: AsyncSession) -> Tuple[ErrorType, dict]:
pass
@abstractmethod
@ -83,10 +83,9 @@ class CardCRUD(ICardCRUD):
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, [], 0
async def card_success_map(self, cdb: AsyncSession, company_id) -> Tuple[ErrorType, dict]:
async def card_success_map(self, cdb: AsyncSession) -> Tuple[ErrorType, dict]:
# 카드별 성공률: 카드 사용(card_used_yn) 채팅이 속한 세션의 타결(DONE) 비율.
# {card_id(UUID): (used_sessions, won_sessions)}. 성공=세션 DONE(협상완료).
# 집계 범위는 내 회사 견적에서 쓰인 이력만 — quotations 까지 조인해 작성자 회사로 좁힌다.
try:
stmt = (
select(
@ -95,15 +94,7 @@ class CardCRUD(ICardCRUD):
func.count(func.distinct(case((sessions.status == SessionStatus.DONE.value, chats.session_id)))).label("won"),
)
.join(sessions, sessions.session_id == chats.session_id)
.join(quotations, quotations.qt_id == sessions.quotation_id)
.where(
chats.card_used_yn == True, # noqa: E712
chats.card_id.isnot(None),
chats.deleted == False, # noqa: E712
sessions.deleted == False, # noqa: E712
quotations.deleted == False, # noqa: E712
quotations.user_id.in_(select(users.user_id).where(users.company_id == company_id)),
)
.where(chats.card_used_yn == True, chats.card_id.isnot(None), chats.deleted == False) # noqa: E712
.group_by(chats.card_id)
)
err, rows = await DB_SESSION_MNG.execute(cdb, stmt)

View File

@ -15,10 +15,6 @@ _EXPERIENCE_LOGS = table(
column("company_id"), column("session_id"), column("card_id"), column("reward"),
column("settled_price"), column("is_invalidated"), column("created_at"),
column("turn"),
# 선택 시점 박제값 — 카드가 왜 뽑혔는지의 근거. UCB = Q + 탐색보너스 라
# 둘의 차이가 "학습으로 뽑혔나 / 덜 써봐서 뽑혔나"를 가른다.
column("q_value_at_selection"), column("ucb_score_at_selection"),
column("visit_count_at_selection"),
schema="learning",
)
_ANCHORING_CURRENT = table(
@ -82,16 +78,11 @@ class LearningCRUD(ILearningCRUD):
return ErrorType.SUCCESS, tuple(rows[0])
async def card_usage(self, cdb: AsyncSession, company_id) -> Tuple[ErrorType, list]:
"""카드별 사용 현황 — (카드번호, 사용 협상 수, 사용 횟수, 평균 라운드, 마지막 사용,
평균 Q값, 평균 UCB, 누적 방문수).
"""카드별 사용 현황 — (카드번호, 사용 협상 수, 사용 횟수, 평균 라운드, 마지막 사용).
협상 결과(타결·가격) 카드 장의 성과로 나눌 없어 다루지 않는다 협상에 여러 장이
나가 어느 장의 몫인지 가릴 근거가 없고, 카드 배정도 국면에 따라 정해져 무작위가 아니다.
평균 라운드 = 카드가 협상의 번째 라운드에 나갔는지(설정한 국면과 실제가 맞는지 대조).
Q값/방문수는 선택 시점 박제값(experience_logs)이라 q_values 테이블을 따로 읽는다
"그때 무슨 근거로 뽑혔나" 사후 현재값보다 사용 현황에 맞는 기준이다.
방문수는 마지막 선택 시점 누적값(단조 증가라 max 최신).
"""
query = (
select(
@ -100,9 +91,6 @@ class LearningCRUD(ILearningCRUD):
func.count(),
func.avg(_EXPERIENCE_LOGS.c.turn),
func.max(_EXPERIENCE_LOGS.c.created_at),
func.avg(_EXPERIENCE_LOGS.c.q_value_at_selection),
func.avg(_EXPERIENCE_LOGS.c.ucb_score_at_selection),
func.max(_EXPERIENCE_LOGS.c.visit_count_at_selection),
)
.where(and_(*_valid(company_id), _EXPERIENCE_LOGS.c.card_id.isnot(None)))
.group_by(_EXPERIENCE_LOGS.c.card_id)

View File

@ -2,8 +2,7 @@ from abc import ABC, abstractmethod
from datetime import datetime
from typing import Optional, Tuple
from sqlalchemy import select, func, and_, or_, update, cast, text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy import select, func, and_, or_, update
from sqlalchemy.ext.asyncio import AsyncSession
from common.database.db_session_manager import DB_SESSION_MNG
@ -11,7 +10,7 @@ from common.database.model.models import (
quotations, sessions, chats, nego_cards, wild_cards, items, suppliers, quotation_settings,
version_nego_cards, version_wild_cards, users, supplier_items, companies,
)
from common.enums import AwardType, CloseReason, ErrorType, QuotationStatus, SessionStatus
from common.enums import CloseReason, ErrorType, QuotationStatus, SessionStatus
from common.logger import LOG
from common.utils.gtime import GTime
@ -80,14 +79,6 @@ class IQuotationCRUD(ABC):
async def update_sessions_status(self, cdb: AsyncSession, qt_id, from_statuses: list[int], to_status: int) -> ErrorType:
pass
@abstractmethod
async def merge_quotation_custom(self, cdb: AsyncSession, qt_id, patch: dict) -> ErrorType:
pass
@abstractmethod
async def set_contract_price(self, cdb: AsyncSession, session_id, price: int) -> ErrorType:
pass
@abstractmethod
async def soft_delete(self, cdb: AsyncSession, qt_id) -> ErrorType:
"""견적과 연결된 하위 데이터(세션·대화)를 함께 소프트 삭제."""
@ -551,7 +542,6 @@ class QuotationCRUD(IQuotationCRUD):
)
.values(
close_reason=CloseReason.AWARDED.value,
award_type=AwardType.MANUAL.value, # 담당자 직접 낙찰(오프라인 계약가)
preferred_sp_yn=True,
preferred_sp_id=supplier_id,
preferred_sp_name=supplier_name,
@ -581,35 +571,6 @@ class QuotationCRUD(IQuotationCRUD):
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED
async def merge_quotation_custom(self, cdb: AsyncSession, qt_id, patch: dict) -> ErrorType:
# quotations.custom 부분 갱신(기존 키 보존). 직접 낙찰 사유(award) 등 견적 부가정보가 같은 컬럼을 쓴다.
try:
query = (
update(quotations)
.where(quotations.qt_id == qt_id)
.values(
custom=func.coalesce(quotations.custom, cast(text("'{}'"), JSONB)).op("||")(cast(patch, JSONB)),
updated_at=GTime.UTC(),
)
)
return await DB_SESSION_MNG.add(cdb, query)
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED
async def set_contract_price(self, cdb: AsyncSession, session_id, price: int) -> ErrorType:
# 직접 낙찰 계약가를 낙찰 세션 컬럼에 박는다(자동낙찰 bid_price 와 같은 축).
try:
query = (
update(sessions)
.where(sessions.session_id == session_id)
.values(contract_price=price, updated_at=GTime.UTC())
)
return await DB_SESSION_MNG.add(cdb, query)
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED
async def soft_delete(self, cdb: AsyncSession, qt_id) -> ErrorType:
"""견적 + 연결된 하위 데이터(협상 세션·대화)를 한 트랜잭션으로 소프트 삭제한다.
대화(chats)세션(sessions)견적(quotations) 순서로 deleted=True. 스텝이라도 실패하면 execute_lambda_run 롤백한다."""
@ -742,16 +703,14 @@ class QuotationCRUD(IQuotationCRUD):
return ErrorType.DB_RUN_FAILED, 0
async def list_sessions_status(self, cdb: AsyncSession, qt_id) -> Tuple[ErrorType, list]:
"""[마감 판정] 견적의 모든 세션 → (status, supplier_id, bid_price, name, target_price, anchoring_price, reject_price, session_id). 삭제 제외.
"""[마감 판정] 견적의 모든 세션 → (status, supplier_id, bid_price, name, target_price, anchoring_price, reject_price). 삭제 제외.
공급사가 지워졌어도 세션 집계엔 포함되도록 outerjoin(이때 name None).
target/anchoring 마감 가격게이트 입력(견적당 상품 1개라 세션 공통값).
reject_price 거부 협력사의 공급 희망 가격 자동 마감 판정엔 쓰고 화면 표기에만 쓴다.
session_id 직접 낙찰이 계약가를 세션 custom 적을 쓴다."""
reject_price 거부 협력사의 공급 희망 가격 자동 마감 판정엔 쓰고 수동 직접 낙찰 후보에서만 본다."""
try:
query = (
select(sessions.status, sessions.supplier_id, sessions.bid_price, suppliers.name,
sessions.target_price, sessions.anchoring_price, sessions.reject_price,
sessions.session_id)
sessions.target_price, sessions.anchoring_price, sessions.reject_price)
.outerjoin(suppliers, suppliers.supplier_id == sessions.supplier_id)
.where(sessions.quotation_id == qt_id, sessions.deleted == False) # noqa: E712
)

View File

@ -7,7 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import quotations, sessions, items, chats, users
from common.enums import AwardType, ErrorType, QuotationStatus, CloseReason, SessionStatus, CardType, ChatSender
from common.enums import ErrorType, QuotationStatus, CloseReason, SessionStatus, CardType, ChatSender
from common.logger import LOG
@ -76,12 +76,10 @@ class StatisticsCRUD(IStatisticsCRUD):
async def winning_sessions(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]:
# 낙찰 마감 견적의 '낙찰 세션'(supplier_id=preferred_sp_id) 행 — 절감/추이/유형/카테고리/앵커도달률의 단일 원천.
# 파생: 저장 안 하고 조회 때 조인. category 는 items LEFT JOIN(자유텍스트·NULL 허용).
# 계약가 = 담당자가 확정한 값이 있으면 그 값, 없으면 협력사 투찰가.
# 결렬·미응찰 건을 오프라인으로 다시 협상하고 직접 낙찰하면 시스템 투찰가가 없거나 실제 계약가와
# 다르기 때문. 컬럼 이름은 bid_price 로 유지해 statistics_service 는 그대로 쓴다.
# 계약가는 coalesce(투찰가, 거부 시 공급 희망가) — 결렬 건을 담당자가 직접 낙찰하면 bid_price 가 없어
# 절감 집계에서 통째로 빠지기 때문. 이름은 bid_price 로 유지해 statistics_service 는 그대로 쓴다.
try:
# 계약가 = 직접 낙찰 계약가(있으면) 우선, 없으면 투찰가. 둘 다 세션 컬럼이라 인덱스·타입 안전.
award_price = func.coalesce(sessions.contract_price, sessions.bid_price).label("bid_price")
award_price = func.coalesce(sessions.bid_price, sessions.reject_price).label("bid_price")
stmt = (
select(
quotations.updated_at,
@ -90,8 +88,6 @@ class StatisticsCRUD(IStatisticsCRUD):
sessions.target_price,
award_price,
sessions.anchoring_price,
# 직접 낙찰 여부는 quotations.award_type(전용 컬럼) 기준. 옛 데이터(백필 전)만 offline_award 폴백.
(func.coalesce(quotations.award_type, 0) == AwardType.MANUAL.value).label("is_offline"),
)
.select_from(quotations)
.join(
@ -99,7 +95,7 @@ class StatisticsCRUD(IStatisticsCRUD):
and_(
sessions.quotation_id == quotations.qt_id,
sessions.supplier_id == quotations.preferred_sp_id,
or_(sessions.bid_price.isnot(None), sessions.contract_price.isnot(None)),
or_(sessions.bid_price.isnot(None), sessions.reject_price.isnot(None)),
sessions.deleted == False, # noqa: E712
),
)

View File

@ -29,11 +29,6 @@ class CardUsageRow(LearningProtocol):
share: float = 0.0 # 전체 카드 사용 중 이 카드의 비중
avg_turn: float = 0.0 # 평균 몇 번째 라운드에 나갔는지 — 설정한 국면과 대조용
last_used_at: Optional[datetime] = None
# 선택 근거 — UCB = Q + 탐색보너스. 봇은 이 점수가 가장 높은 카드를 고른다.
avg_q: Optional[float] = None # 선택 시점 Q값 평균 — 학습이 매긴 이 카드의 값어치
avg_ucb: Optional[float] = None # 선택 시점 UCB 평균
explore_bonus: Optional[float] = None # avg_ucb - avg_q — 클수록 '아직 덜 검증돼서' 뽑힌 것
visits: int = 0 # 마지막 선택 시점 누적 방문수 — 적으면 Q값이 아직 추측
class AnchoringCell(LearningProtocol):

View File

@ -4,7 +4,7 @@ from typing import Any, Optional
from pydantic import ConfigDict
from common.enums import AwardType, CardType, ChatSender, CloseReason, DeliveryType, PriceGateAction, QuotationStatus, QuotationType, SessionStatus
from common.enums import CardType, ChatSender, CloseReason, DeliveryType, PriceGateAction, QuotationStatus, QuotationType, SessionStatus
from common.models.gmodel import Res_PageProtocol, Res_WebPacketProtocol, WebPacketProtocol
@ -46,9 +46,7 @@ class Req_RegenerateQuotation(QuotationProtocol):
class Req_AwardQuotation(QuotationProtocol):
winner_supplier_id: uuid.UUID # 담당자가 직접 낙찰시킬 협력사(견적에 초청된 협력사 중 선택)
contract_price: int # 최종 계약가(원). 오프라인 협상 결과를 담당자가 확정해 넣는다
contract_note: Optional[str] = None # 계약가 근거 메모(오프라인 협상 요약)
winner_supplier_id: uuid.UUID # 담당자가 직접 낙찰시킬 협력사(투찰한 협상완료 세션 중 선택)
class QuotationData(WebPacketProtocol):
@ -77,8 +75,6 @@ class QuotationData(WebPacketProtocol):
equal_bid_yn: Optional[bool] = None
equal_bid_data: Optional[Any] = None
close_reason: Optional[CloseReason] = None # 마감 사유(CloseReason). 미마감이면 None
award_type: Optional[AwardType] = None # 낙찰 방식(AwardType 1=자동/2=직접). 미낙찰이면 None
custom: Optional[dict] = None # 견적 부가정보. 직접 낙찰 시 award={reason,by,at}(사유·처리자·시각)
mid_action: Optional[int] = None # 낙찰 기준(견적 단위). 상세 드로어 낙찰기준 표시용
over_action: Optional[int] = None
done_ceiling_rate: Optional[int] = None # 타결 상한율(‰) 견적 override. None 이면 견적 세팅값을 따름
@ -114,7 +110,6 @@ class SessionData(WebPacketProtocol):
qt_type: QuotationType
target_price: int
anchoring_price: Optional[int] = None # 앵커링가(원). 목표가×(1000anchoring_value)//1000
done_ceiling_price: Optional[int] = None # 완료 상한가(원). 생성 시 박제 = 목표가×(1+완료상한율/1000)
status: SessionStatus
bid_price: Optional[int] = None
bid_at: Optional[datetime] = None
@ -122,7 +117,6 @@ class SessionData(WebPacketProtocol):
reject_reason: Optional[str] = None
reject_price: Optional[int] = None
reject_delivery_type: Optional[DeliveryType] = None
contract_price: Optional[int] = None # 직접 낙찰 계약가(원). 자동낙찰은 None(bid_price 가 계약가)
email_sent_at: Optional[datetime] = None # 협상 초청 메일 발송 시각(None=미발송). 프론트 발송배지/재발송 판단
custom: Optional[dict] = None # 협상완료 부가정보 값 {key: value} (공급사가 타결 후 입력, 정의는 companies.settings.session_fields)
url: str = "" # 세션 chat 실행 URL(공급사 협상 프론트). DB 미저장 — session_id 로 구성

View File

@ -67,10 +67,7 @@ async def award_quotation(
):
# 권한(본인 견적만/OWNER 예외)은 서비스에서 판정하도록 호출자 user_id·role 을 넘긴다.
return RemoveNoneResponse(
await service.award_quotation(
str(qt_id), user_info.company_id, user_info.user_id, user_info.role,
req.winner_supplier_id, req.contract_price, req.contract_note,
)
await service.award_quotation(str(qt_id), user_info.company_id, user_info.user_id, user_info.role, req.winner_supplier_id)
)

View File

@ -16,7 +16,6 @@ class StatKpi(WebPacketProtocol):
closed_count: int = 0 # 마감 견적 수(창)
regen_avg_round: float = 0.0 # 평균 재견적 라운드
markup_suppression_rate: float = 0.0 # 인상억제율(재협상: 직전 라운드 투찰가 대비 이번 투찰가 인하율, 파생)
offline_award_count: int = 0 # 낙찰 중 오프라인 협상 결과를 담당자가 반영한 건수(절감액엔 포함)
class StatMonthPoint(WebPacketProtocol):

View File

@ -142,7 +142,7 @@ class CardService:
# 카드 성공률(#12) — 카드 사용→타결 집계를 page 카드에 매핑.
_e, success_map = await DB_SESSION_MNG.execute_lambda(
nego_cards.DBType(), DBWRType.DB_READ.value,
lambda s: self.card_crud.card_success_map(s, company_uuid),
lambda s: self.card_crud.card_success_map(s),
)
success_map = success_map or {}
for c in page:

View File

@ -59,8 +59,8 @@ class LearningService:
names = await self._read(lambda s: self.crud.card_names(s), default=[])
name_map = {str(number): (name, is_wild, card_pk) for number, name, is_wild, card_pk in names if number}
total_uses = sum(int(r[2] or 0) for r in rows) or 1
for card_id, used_sessions, uses, avg_turn, last_used, avg_q, avg_ucb, visits in rows:
total_uses = sum(int(uses or 0) for _n, _s, uses, _t, _l in rows) or 1
for card_id, used_sessions, uses, avg_turn, last_used in rows:
number = str(card_id)
name, is_wild, card_pk = name_map.get(number, (None, 0, None))
res.cards.append(CardUsageRow(
@ -73,12 +73,6 @@ class LearningService:
share=round(int(uses or 0) / total_uses, 3),
avg_turn=round(float(avg_turn), 1) if avg_turn is not None else 0.0,
last_used_at=last_used,
avg_q=round(float(avg_q), 4) if avg_q is not None else None,
avg_ucb=round(float(avg_ucb), 4) if avg_ucb is not None else None,
# 탐색보너스는 두 값이 다 있을 때만 — 한쪽만 있으면 차이가 의미를 잃는다.
explore_bonus=(round(float(avg_ucb) - float(avg_q), 4)
if avg_q is not None and avg_ucb is not None else None),
visits=int(visits or 0),
))
res.kpi = LearningKpi(

View File

@ -5,13 +5,22 @@ from typing import Optional
from common.authz import is_owner_or_admin
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import quotations, sessions
from common.enums import AwardType, CloseOutcome, CloseReason, DBWRType, ErrorType, NotificationType, PriceGateAction, QuotationStatus, SessionStatus
from common.logger import LOG
from common.utils.gtime import GTime
from common.enums import CloseOutcome, CloseReason, DBWRType, ErrorType, NotificationType, PriceGateAction, QuotationStatus, SessionStatus
from router.v1.quotation.protocol import Res_Quotation
from services.notification import create_notification
def _award_price(row) -> Optional[int]:
"""직접 낙찰 시 계약 기준가 — 타결했으면 투찰가, 협상이 거부로 끝났으면 그때 써낸 공급 희망 가격.
없으면(가격을 번도 협력사) None 낙찰 후보가 아니다.
자동 마감 판정(close_and_decide) 함수를 쓰지 않는다 자동 낙찰은 투찰가만 본다."""
if row.status == SessionStatus.DONE.value and row.bid_price is not None:
return int(row.bid_price)
if row.status == SessionStatus.REJECTED.value and row.reject_price is not None:
return int(row.reject_price)
return None
class ClosingMixin:
@staticmethod
def _pick_winner(done_rows) -> tuple[Optional[dict], Optional[dict]]:
@ -116,7 +125,6 @@ class ClosingMixin:
await self._close(qt_uuid, CloseReason.AWARDED.value, {
"preferred_sp_yn": True, "preferred_sp_id": winner["supplier_id"],
"preferred_sp_name": (winner["name"] or "")[:20], "equal_bid_yn": False,
"award_type": AwardType.AUTO.value, # 시스템 자동 낙찰(투찰가 기준)
})
await create_notification(
original.user_id, NotificationType.SUCCESS,
@ -160,25 +168,17 @@ class ClosingMixin:
await self.close_and_decide(qt_uuid)
return await self.get_quotation(qt_id, company_id)
async def award_quotation(
self, qt_id: str, company_id, user_id, role, winner_supplier_id,
contract_price: int, contract_note: Optional[str] = None,
) -> Res_Quotation:
async def award_quotation(self, qt_id: str, company_id, user_id, role, winner_supplier_id) -> Res_Quotation:
"""[프론트] 개찰(낙찰자 미정 마감) 견적을 담당자가 직접 낙찰 처리한다.
협상이 결렬·미응찰로 끝나면 오프라인으로 다시 협상하고 결과를 여기서 반영한다 그래서
후보는 견적에 초청된 협력사 전부이고, 계약가는 담당자가 확정해 넣는다(시스템 제출가와 다를 있다).
계약가는 sessions.custom.offline_award 근거 메모·작성자·시각과 함께 남겨 통계가 값을 계약가로 읽는.
가격을 써낸 세션 고른 협력사를 낙찰자로 박고 close_reason AWARDED 바꾼다(직접 낙찰).
후보는 투찰한 협상완료(DONE) + 공급 희망 가격을 남긴 협상거부(REJECTED) 협상이 결렬돼도
마지막에 제출한 가격으로 계약을 진행하는 운영 방침을 시스템에서 그대로 처리하기 위함이.
자동 낙찰(close_and_decide) 결과 컬럼은 같되, 알림에 manual 플래그로 '직접 낙찰'임을 남긴다.
권한: 본인이 생성한 견적만. 최고관리자(OWNER) 회사 남의 견적도 낙찰할 있다."""
res = Res_Quotation()
qt_uuid = uuid.UUID(qt_id)
sp_uuid = winner_supplier_id if isinstance(winner_supplier_id, uuid.UUID) else uuid.UUID(str(winner_supplier_id))
if not contract_price or contract_price <= 0:
res.result.SetResult(ErrorType.INVALID_REQUEST_DATA)
res.msg = "계약가를 입력해야 낙찰할 수 있습니다."
return res
# 존재 확인(+회사 가드) — 남의 회사 견적은 NOT_FOUND.
err_type, original = await self._fetch(qt_uuid, company_id)
if err_type != ErrorType.SUCCESS or original is None:
@ -200,21 +200,20 @@ class ClosingMixin:
res.msg = "개찰(낙찰자 미정) 상태의 견적만 직접 낙찰할 수 있습니다."
return res
# 낙찰 후보 = 이 견적에 초청된 협력사 전부. 오프라인 협상 결과를 반영하는 자리라 시스템에
# 가격을 안 낸 협력사(전원 미응찰 견적 포함)도 고를 수 있다. close_and_decide 와 같은 조회
# (list_sessions_status, 공급사 삭제돼도 포함되는 outerjoin)를 쓴다.
# 낙찰 후보 = 가격을 써낸 세션. close_and_decide 와 같은 조회(list_sessions_status,
# 공급사 삭제돼도 포함되는 outerjoin)를 쓴다.
err_type, rows = await DB_SESSION_MNG.execute_lambda(
sessions.DBType(),
DBWRType.DB_READ.value,
lambda s: self.quotation_crud.list_sessions_status(s, qt_uuid),
)
rows = rows if err_type == ErrorType.SUCCESS else []
winner = next((r for r in rows if r.supplier_id == sp_uuid), None)
winner = next((r for r in rows if r.supplier_id == sp_uuid and _award_price(r) is not None), None)
if winner is None:
res.result.SetResult(ErrorType.INVALID_REQUEST_DATA)
res.msg = "선택한 협력사는 이 견적에 초청된 협력사가 아닙니다."
res.msg = "선택한 협력사는 이 견적의 낙찰 후보(가격을 제출한 협력사)가 아닙니다."
return res
winner_price = int(contract_price)
winner_price = _award_price(winner)
# [동시 직접낙찰 가드] 개찰→낙찰 원자 선점. 실제로 전이한 호출자만 통과(재클릭·경합 방어).
claim_err, claimed = await DB_SESSION_MNG.execute_lambda_claim(
@ -226,24 +225,6 @@ class ClosingMixin:
res.msg = "이미 낙찰 처리된 견적입니다."
return res
# 계약가는 낙찰 세션의 contract_price 컬럼에(협력사 가격 — bid_price/reject_price 와 같은 축, 통계 집계 대상).
# 사유·처리자·시각은 견적의 custom.award 에(견적 단위 결정 — 표시·감사용, 집계 안 함). 둘 다 한 트랜잭션.
award_meta = {
"reason": (contract_note or "").strip()[:255],
"by": str(user_id) if user_id else "",
"at": GTime.UTC().isoformat(timespec="seconds"),
}
award_err = await DB_SESSION_MNG.execute_lambda_run(
[sessions.DBType()],
[
lambda s: self.quotation_crud.set_contract_price(s, winner.session_id, winner_price),
lambda s: self.quotation_crud.merge_quotation_custom(s, qt_uuid, {"award": award_meta}),
],
)
if award_err != ErrorType.SUCCESS:
# 낙찰(견적)은 이미 선점 전이됐다 — 계약가·사유만 못 남긴 상태라 되돌리지 않고 경고로 남긴다.
LOG.w(f"[award] 계약가/사유 기록 실패 qt_id={qt_id} session_id={winner.session_id} price={winner_price}")
# 작성자 알림 — 자동낙찰과 같은 SUCCESS 코드, manual 플래그로 '직접 낙찰' 구분.
await create_notification(
original.user_id, NotificationType.SUCCESS,

Some files were not shown because too many files have changed in this diff Show More