Compare commits
No commits in common. "main" and "design/frontend" have entirely different histories.
main
...
design/fro
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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 = (
|
||||
|
||||
@ -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) 치환용. 없으면 프론트 기본값")
|
||||
|
||||
|
||||
|
||||
@ -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 에 병합")
|
||||
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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"], " ") # 공백만 → 사유 없음
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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'
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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'
|
||||
|
||||
@ -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 }
|
||||
})
|
||||
}
|
||||
|
||||
@ -32,8 +32,6 @@ const initialState: ChatInitData = {
|
||||
quotation_end_time: '',
|
||||
custom: {},
|
||||
labels: {},
|
||||
reject_reason: '',
|
||||
reject_price: null,
|
||||
}
|
||||
|
||||
export const useChatInitStore = create<ChatInitStore>((set) => ({
|
||||
|
||||
@ -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 // 거부와 함께 낸 공급 희망 가격(원)
|
||||
}
|
||||
|
||||
124
frontend/src/features/list/components/RejectPopup.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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)} />
|
||||
)}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -14,6 +14,4 @@ export type ListItem = {
|
||||
renegotiationMemo: string // 담당자 심사 메모(반려 사유)
|
||||
result: number // 협상 결과: 0=미정 1=낙찰 2=미낙찰 3=결렬(개찰)
|
||||
hasChat: boolean // 대화 이력 존재 — 종료된 협상의 '결과 보기' 노출 조건
|
||||
rejectReason: string // 협상 거부로 끝난 건이 제출한 사유. 거부 건이 아니면 ''
|
||||
rejectPrice: number | null // 거부와 함께 낸 공급 희망 가격(원)
|
||||
}
|
||||
|
||||
@ -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
@ -1,4 +1,3 @@
|
||||
node_modules
|
||||
build
|
||||
.react-router
|
||||
.vercel
|
||||
|
||||
@ -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")
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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: "의사 결정",
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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: "기존 시스템과 연동되나요?",
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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>
|
||||
|
||||
51
landing/app/components/sections/hero-console.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
85
landing/app/components/sections/hero-glassmorphic.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
73
landing/app/components/sections/hero-neumorphic.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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를 배치하시면 이 영역에 자동으로 재생됩니다.",
|
||||
|
||||
324
landing/app/components/sections/negotiation-console.tsx
Normal 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 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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: "무리하게 후려쳐 관계를 깨는 대신, 성사되는 선에서 최대한 끌어냅니다.",
|
||||
},
|
||||
]
|
||||
|
||||
158
landing/app/components/sections/roi-simulator.tsx
Normal 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()}만 원`
|
||||
}
|
||||
@ -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" },
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
28
landing/app/components/ui/range-slider.tsx
Normal 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 }
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
@ -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 }
|
||||
}
|
||||
@ -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 }) {
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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` |
|
||||
@ -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
@ -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"
|
||||
|
||||
BIN
landing/public/gifs/hero_console.jpg
Normal file
|
After Width: | Height: | Size: 111 KiB |
BIN
landing/public/gifs/hero_console.mp4
Normal file
|
Before Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 42 KiB |
BIN
landing/public/gifs/negotiation_annotated.jpg
Normal file
|
After Width: | Height: | Size: 99 KiB |
BIN
landing/public/gifs/negotiation_annotated.mp4
Normal file
|
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 4.1 MiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 56 KiB |
@ -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" }]
|
||||
}
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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) 구간에 적용.
|
||||
(투찰가≤앵커링가 는 항상 낙찰.) 기준 미달이면 개찰(낙찰자 미정 마감) — 재협상·결렬 없음.
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -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
|
||||
),
|
||||
)
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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 # 앵커링가(원). 목표가×(1000−anchoring_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 로 구성
|
||||
|
||||
@ -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)
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||