Compare commits
42 Commits
feat/sourc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 69e4240039 | |||
| 8112394aef | |||
| 1a859b198c | |||
| d33648327c | |||
| dce2c42d74 | |||
| 0e4ddf0f43 | |||
| 6eb4dc26f8 | |||
| d02ba20520 | |||
| ac6366a898 | |||
| f1e924931f | |||
| 84090433ab | |||
| e1e519c20c | |||
| e816bfbba4 | |||
| 37fa65707f | |||
| 86d50c1291 | |||
| 436384fa36 | |||
| a405e00bf9 | |||
|
|
7dc925bcd4 | ||
|
|
0c6c431f0a | ||
| 77d96cd4a4 | |||
| d3e33b10f4 | |||
| 3214e4f782 | |||
| e809129ae9 | |||
| 631a501c5c | |||
| ab77900dac | |||
| 09d24f5022 | |||
| 4536a48e40 | |||
| ea1249429a | |||
| d2f1a8f6f4 | |||
| be039dd92b | |||
| 2888ff2c28 | |||
| c53eeefcf6 | |||
| f214fccc49 | |||
| 63717f5eb9 | |||
| 7f15bc10d1 | |||
| d3bcc61609 | |||
| 55e3ab02aa | |||
| bae0da42d2 | |||
| 15369a016d | |||
| 5917942fe3 | |||
| 87b896fc2f | |||
| 6785c8a6f4 |
@ -8,9 +8,8 @@
|
|||||||
2) 변수 정의 — 그 금액을 지금 쓸 수 있는지 (OFFER_VARIABLES 의 계산식 + 유효조건)
|
2) 변수 정의 — 그 금액을 지금 쓸 수 있는지 (OFFER_VARIABLES 의 계산식 + 유효조건)
|
||||||
3) tactic JSONB — 문장으로 알 수 없는 운영 규칙 (min_round·closing)
|
3) tactic JSONB — 문장으로 알 수 없는 운영 규칙 (min_round·closing)
|
||||||
|
|
||||||
유효 조건을 카드가 아니라 '변수'에 붙이는 이유: 절충가가 목표가를 넘을 수 있는 것은
|
유효 조건은 카드가 아니라 '변수'에 붙인다 — 금액이 성립하는지는 계산식의 성질이지 카드의
|
||||||
(직전제안+제시가)/2 라는 계산식의 성질이지 특정 카드의 성질이 아니다. 같은 변수를 쓰는
|
성질이 아니다. 새 변수는 OFFER_VARIABLES 에 한 줄 추가하면 코드 분기 없이 끝난다.
|
||||||
카드가 늘어도 규칙은 한 곳이고, 새 변수는 이 표에 한 줄 추가하면 코드 분기 없이 끝난다.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import re
|
import re
|
||||||
@ -32,6 +31,9 @@ OFFER_VARIABLES: Dict[str, Callable[[float, float, float, float], Optional[float
|
|||||||
|
|
||||||
_TOKEN_RE = re.compile(r"\{([a-z_]+)\}")
|
_TOKEN_RE = re.compile(r"\{([a-z_]+)\}")
|
||||||
|
|
||||||
|
# 절충 계열 변수 — 양측 사이/우리 두 값 사이의 중간을 부르는 카드. 목표가 이상이면 미발동한다.
|
||||||
|
_MID_VARIABLES = ("middle_price", "target_mid_price")
|
||||||
|
|
||||||
|
|
||||||
# 세션 데이터에 따라 값이 없을 수 있는 읽기 전용 변수 → 그 값을 담는 컨텍스트 키.
|
# 세션 데이터에 따라 값이 없을 수 있는 읽기 전용 변수 → 그 값을 담는 컨텍스트 키.
|
||||||
# 스크립트가 이런 변수를 인용하면 값이 있을 때만 카드가 나간다 — 없는데 나가면 협력사 채팅에
|
# 스크립트가 이런 변수를 인용하면 값이 있을 때만 카드가 나간다 — 없는데 나가면 협력사 채팅에
|
||||||
@ -65,9 +67,8 @@ HOLD = CardSpec() # 스펙을 못 찾은 카드(테넌트 데모·회사 커스
|
|||||||
def settle_ceiling(context: Dict[str, Any]) -> float:
|
def settle_ceiling(context: Dict[str, Any]) -> float:
|
||||||
"""이 협상에서 받아줄 수 있는 최고가 — 타결 판정선이자 카드 제안가의 상한.
|
"""이 협상에서 받아줄 수 있는 최고가 — 타결 판정선이자 카드 제안가의 상한.
|
||||||
|
|
||||||
견적 생성 시 세션에 박제한 done_ceiling_price(= 목표가 × (1 + 타결상한율)). 목표가를 조금
|
견적 생성 시 세션에 박제한 done_ceiling_price(= 목표가 × (1 + 타결상한율)) — 목표가를 조금
|
||||||
넘더라도 기존 단가보다 인하됐으면 타결시키기 위한 값이다(IMK: 기존 17,500 / 목표 16,980 /
|
넘더라도 기존 단가보다 인하됐으면 타결시키기 위한 값. 박제가 없으면 목표가로 폴백한다.
|
||||||
최종 17,300 이 결렬되던 케이스). 박제가 없는 옛 세션·데모는 목표가로 폴백 — 종전 동작 유지.
|
|
||||||
"""
|
"""
|
||||||
return float(context.get("done_ceiling_price") or context.get("target_price") or 0)
|
return float(context.get("done_ceiling_price") or context.get("target_price") or 0)
|
||||||
|
|
||||||
@ -75,9 +76,8 @@ def settle_ceiling(context: Dict[str, Any]) -> float:
|
|||||||
def parse_offer_variable(script: Optional[str]) -> Optional[str]:
|
def parse_offer_variable(script: Optional[str]) -> Optional[str]:
|
||||||
"""스크립트가 제시하는 제안가 변수. 없으면 None(설득 카드).
|
"""스크립트가 제시하는 제안가 변수. 없으면 None(설득 카드).
|
||||||
|
|
||||||
제안가 변수가 여럿이면 **마지막에 등장하는 것**이 제안가다 — 카드 문장은 배경을 먼저 깔고
|
변수가 여럿이면 마지막에 등장하는 것이 제안가다 — 카드 문장은 배경을 먼저 깔고 실제 제안을
|
||||||
(예: "당초 검토한 적정가는 {anchoring_price}원이었으나") 실제 제안을 마지막에 하기 때문이다
|
마지막에 하기 때문이다.
|
||||||
(예: "이에 {target_price}원으로 조정하여 제안 드립니다").
|
|
||||||
"""
|
"""
|
||||||
found = [m.group(1) for m in _TOKEN_RE.finditer(script or "") if m.group(1) in OFFER_VARIABLES]
|
found = [m.group(1) for m in _TOKEN_RE.finditer(script or "") if m.group(1) in OFFER_VARIABLES]
|
||||||
return found[-1] if found else None
|
return found[-1] if found else None
|
||||||
@ -115,16 +115,50 @@ def spec_from_context(context: Dict[str, Any], number: Optional[str]) -> CardSpe
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Offer:
|
||||||
|
"""확정된 제안 한 건 — 금액과 그 금액을 만든 재료를 함께 들고 다닌다.
|
||||||
|
|
||||||
|
멘트 치환이 재료를 다시 계산하지 않게 하기 위한 것 — 재계산하면 그 사이 갱신된
|
||||||
|
prev_customer 를 읽어 문장이 자기모순이 된다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
price: int # 협력사에게 제시할 금액(수락 시 타결가)
|
||||||
|
variable: str # 이 금액을 만든 멘트 변수
|
||||||
|
prev_customer: int # 계산에 쓴 당사 직전 제안
|
||||||
|
prev_partner: int # 계산에 쓴 협력사 제시가
|
||||||
|
|
||||||
|
|
||||||
|
def record_offer(context: Dict[str, Any], offer: Offer) -> None:
|
||||||
|
"""확정 제안을 세션에 기록한다 — 수락 판정용 금액과 멘트 치환용 재료를 한 자리에서 쓴다.
|
||||||
|
|
||||||
|
두 키를 항상 함께 써야 표시가와 타결가가 갈라지지 않으므로 기록 지점을 여기 하나로 묶는다.
|
||||||
|
"""
|
||||||
|
context["pending_counter_price"] = offer.price
|
||||||
|
context["pending_offer"] = {
|
||||||
|
"price": offer.price, "variable": offer.variable,
|
||||||
|
"prev_customer": offer.prev_customer, "prev_partner": offer.prev_partner,
|
||||||
|
}
|
||||||
|
context["prev_customer_price"] = offer.price # 갑의 최신 포지션 — 다음 라운드 계산·역행 금지 기준
|
||||||
|
|
||||||
|
|
||||||
|
def compute_offer_detail(spec: CardSpec, context: Dict[str, Any]) -> Optional[Offer]:
|
||||||
|
"""카드가 제시할 금액 + 그 계산에 쓴 재료. 쓸 수 없는 상황이면 None."""
|
||||||
|
price = int(float(context.get("input_price") or 0))
|
||||||
|
prev_customer = int(float(context.get("prev_customer_price") or context.get("anchor_price") or 0))
|
||||||
|
value = compute_offer(spec, context)
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
return Offer(price=value, variable=spec.offer_variable or "", prev_customer=prev_customer, prev_partner=price)
|
||||||
|
|
||||||
|
|
||||||
def compute_offer(spec: CardSpec, context: Dict[str, Any]) -> Optional[int]:
|
def compute_offer(spec: CardSpec, context: Dict[str, Any]) -> Optional[int]:
|
||||||
"""카드가 제시할 금액. 쓸 수 없는 상황이면 None → 호출부가 카드를 건너뛴다.
|
"""카드가 제시할 금액. 쓸 수 없는 상황이면 None → 호출부가 카드를 건너뛴다.
|
||||||
|
|
||||||
변수 공통 유효조건 (전부 만족해야 발동):
|
변수 공통 유효조건 (전부 만족해야 발동):
|
||||||
· 값 ≤ 타결 상한가 — 구매자는 받아줄 수 없는 금액을 부르지 않는다. 넘으면 클램프가 아니라
|
· 값 ≤ 타결 상한가 — 받아줄 수 없는 금액은 부르지 않는다. 넘으면 깎지 않고 미발동
|
||||||
**미발동**(깎아 부르면 "중간에서 만나자"면서 상한을 부르는 모순이 된다).
|
|
||||||
상한은 견적 생성 시 박제한 done_ceiling_price(목표가×(1+율)), 없으면 목표가.
|
|
||||||
· 값 < 협력사 제시가 — 이미 더 싸게 받았는데 더 비싼 값을 부를 이유가 없다
|
· 값 < 협력사 제시가 — 이미 더 싸게 받았는데 더 비싼 값을 부를 이유가 없다
|
||||||
· 값 ≥ 당사 직전 제안 — 역행 금지(IMK 논의). 16,980을 불러놓고 16,810(앵커)을 부르면 협상이
|
· 값 ≥ 당사 직전 제안 — 역행 금지. 제안 시퀀스는 앵커→…→목표가로 단조 수렴해야 한다
|
||||||
좁혀지지 않고 되돌아간다. 제안 시퀀스는 앵커→…→목표가로 단조 수렴해야 한다
|
|
||||||
"""
|
"""
|
||||||
variable = spec.offer_variable
|
variable = spec.offer_variable
|
||||||
if not variable:
|
if not variable:
|
||||||
@ -145,7 +179,16 @@ def compute_offer(spec: CardSpec, context: Dict[str, Any]) -> Optional[int]:
|
|||||||
return None # 재료 부족(앵커 미박제·직전 제안 없음)
|
return None # 재료 부족(앵커 미박제·직전 제안 없음)
|
||||||
if value > settle_ceiling(context):
|
if value > settle_ceiling(context):
|
||||||
return None # 타결 상한 초과 — 받아줄 수 없는 금액이라 지금 못 쓴다
|
return None # 타결 상한 초과 — 받아줄 수 없는 금액이라 지금 못 쓴다
|
||||||
offer = int(value / 10 + 0.5) * 10 # 10원 단위 반올림 — 앵커가·목표가 산정과 표기 통일
|
if variable in _MID_VARIABLES and value >= target:
|
||||||
|
# 절충 계열은 목표가 미만일 때만 의미가 있다. 목표가 이상이면 "절반씩 나누자"면서 목표가를
|
||||||
|
# 부르는 꼴이라 미발동 — 목표가 제시는 목표가 카드(최후통첩)가 할 일이다.
|
||||||
|
return None
|
||||||
|
if variable in ("target_price", "anchoring_price", "anchor_price"):
|
||||||
|
# 원값 인용 변수 — 멘트엔 {target_price} 등 저장 원값이 그대로 나가므로, 반올림하면
|
||||||
|
# 표시가≠타결가 미스매치가 난다(목표가 7652 멘트 → 7650 타결). 저장값 그대로 제시.
|
||||||
|
offer = int(value)
|
||||||
|
else:
|
||||||
|
offer = int(value / 10 + 0.5) * 10 # 파생가(절충·중간) 10원 반올림 — 앵커·목표가 산정과 표기 통일
|
||||||
if offer >= price:
|
if offer >= price:
|
||||||
return None # 제시가가 이미 그 값 이하 → 부를 이유 없음
|
return None # 제시가가 이미 그 값 이하 → 부를 이유 없음
|
||||||
if prev_customer and offer < prev_customer:
|
if prev_customer and offer < prev_customer:
|
||||||
@ -173,7 +216,7 @@ def playable(spec: CardSpec, context: Dict[str, Any], *, closing_phase: bool = F
|
|||||||
|
|
||||||
금액을 인용하는 카드(offer_variable 있음)는 그 금액을 못 부르는 상황이면 설득 폴백으로도
|
금액을 인용하는 카드(offer_variable 있음)는 그 금액을 못 부르는 상황이면 설득 폴백으로도
|
||||||
내보내지 않는다 — 멘트에 무효한 금액(직전 제안보다 낮은 앵커, 제시가보다 높은 목표가)이
|
내보내지 않는다 — 멘트에 무효한 금액(직전 제안보다 낮은 앵커, 제시가보다 높은 목표가)이
|
||||||
글자로 박혀 나가 역행/모순 서사가 되기 때문(IMK 역행 논의). 설득 카드는 금액이 없으니 무관.
|
글자로 박혀 나가 역행/모순 서사가 되기 때문. 설득 카드는 금액이 없으니 무관.
|
||||||
"""
|
"""
|
||||||
if not available(spec, context, closing_phase=closing_phase):
|
if not available(spec, context, closing_phase=closing_phase):
|
||||||
return False
|
return False
|
||||||
|
|||||||
@ -84,6 +84,7 @@ _NEGO_CARDS = table(
|
|||||||
_WILD_CARDS = table(
|
_WILD_CARDS = table(
|
||||||
"wild_cards",
|
"wild_cards",
|
||||||
column("wild_card_id"), column("number"), column("script"), column("tactic"), column("deleted"),
|
column("wild_card_id"), column("number"), column("script"), column("tactic"), column("deleted"),
|
||||||
|
column("available"),
|
||||||
schema="card",
|
schema="card",
|
||||||
)
|
)
|
||||||
# 상품↔협력사 매핑 (2026-07-07 신설): supply_type = 이 협력사가 이 상품을 공급하는 방식(SupplierType).
|
# 상품↔협력사 매핑 (2026-07-07 신설): supply_type = 이 협력사가 이 상품을 공급하는 방식(SupplierType).
|
||||||
@ -374,6 +375,8 @@ class NegoContextCRUD(INegoContextCRUD):
|
|||||||
_VERSION_WILD_CARDS.c.version_id == version_id,
|
_VERSION_WILD_CARDS.c.version_id == version_id,
|
||||||
_VERSION_WILD_CARDS.c.deleted == False, # noqa: E712
|
_VERSION_WILD_CARDS.c.deleted == False, # noqa: E712
|
||||||
_WILD_CARDS.c.deleted == False, # noqa: E712
|
_WILD_CARDS.c.deleted == False, # noqa: E712
|
||||||
|
# 협상 적용 여부(카드 설정 '적용 대기(수동)') — 꺼진 카드는 견적에 담겨 있어도 발동 금지
|
||||||
|
_WILD_CARDS.c.available == True, # noqa: E712
|
||||||
)
|
)
|
||||||
.order_by(_VERSION_WILD_CARDS.c.created_at)
|
.order_by(_VERSION_WILD_CARDS.c.created_at)
|
||||||
)
|
)
|
||||||
|
|||||||
@ -11,11 +11,15 @@ from dataclasses import dataclass, field
|
|||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from negotiation.cards.domain.tactics import (
|
from negotiation.cards.domain.tactics import (
|
||||||
available, compute_offer, is_played, mark_played, playable, settle_ceiling, spec_from_context,
|
OFFER_VARIABLES, Offer, available, compute_offer_detail, is_played, mark_played, playable,
|
||||||
|
record_offer, settle_ceiling, spec_from_context,
|
||||||
)
|
)
|
||||||
from negotiation.chat.service.script_repository import ScriptRepository
|
from negotiation.chat.service.script_repository import ScriptRepository
|
||||||
|
|
||||||
MAX_ROUNDS = 3 # config 미주입 시 폴백 (규칙 정본은 tenant config negotiation.max_counter_rounds)
|
MAX_ROUNDS = 3 # config 미주입 시 폴백 (규칙 정본은 tenant config negotiation.max_counter_rounds)
|
||||||
|
# 멘트에 찍히는 파생 가격 — 값이 다른 값에서 계산돼 나오는 것들(원값 인용 target/anchor 는 제외).
|
||||||
|
_DERIVED_PRICE_VARIABLES = ("target_mid_price", "middle_price")
|
||||||
|
|
||||||
_PRICE_MODES = ("price",)
|
_PRICE_MODES = ("price",)
|
||||||
_CHOICE_MODES = ("yes_no", "confirm", "delivery_type")
|
_CHOICE_MODES = ("yes_no", "confirm", "delivery_type")
|
||||||
|
|
||||||
@ -131,8 +135,9 @@ class ChatEngine:
|
|||||||
if price is None:
|
if price is None:
|
||||||
return self._error(session, "가격을 숫자로 입력해 주세요.")
|
return self._error(session, "가격을 숫자로 입력해 주세요.")
|
||||||
session.context["input_price"] = price
|
session.context["input_price"] = price
|
||||||
# 새 가격 제시 = 직전 카운터 제안 거절 확정 → 대기 중 카운터 폐기.
|
# 새 가격 제시 = 직전 카운터 제안 거절 확정 → 대기 중 카운터·그 재료 폐기.
|
||||||
session.context.pop("pending_counter_price", None)
|
session.context.pop("pending_counter_price", None)
|
||||||
|
session.context.pop("pending_offer", None)
|
||||||
session.context["prev_partner_price"] = price
|
session.context["prev_partner_price"] = price
|
||||||
# 협력사 첫 제시가 — 가격 수용률(첫 제시가 대비 양보율) 동적 계산의 기준값.
|
# 협력사 첫 제시가 — 가격 수용률(첫 제시가 대비 양보율) 동적 계산의 기준값.
|
||||||
session.context.setdefault("first_offer_price", price)
|
session.context.setdefault("first_offer_price", price)
|
||||||
@ -150,6 +155,7 @@ class ChatEngine:
|
|||||||
# 원 제시가 수락 종결 — 는 카운터를 버리고 기존 input_price 로 타결한다.)
|
# 원 제시가 수락 종결 — 는 카운터를 버리고 기존 input_price 로 타결한다.)
|
||||||
if mode in _CHOICE_MODES and nxt in _SUCCESS_STEPS:
|
if mode in _CHOICE_MODES and nxt in _SUCCESS_STEPS:
|
||||||
pending = session.context.pop("pending_counter_price", None)
|
pending = session.context.pop("pending_counter_price", None)
|
||||||
|
session.context.pop("pending_offer", None)
|
||||||
if pending and user_input in _ACCEPT_INPUTS:
|
if pending and user_input in _ACCEPT_INPUTS:
|
||||||
session.context["input_price"] = float(pending)
|
session.context["input_price"] = float(pending)
|
||||||
return self._render(session, nxt)
|
return self._render(session, nxt)
|
||||||
@ -240,7 +246,7 @@ class ChatEngine:
|
|||||||
)
|
)
|
||||||
if exhausted:
|
if exhausted:
|
||||||
# 타결선은 목표가가 아니라 타결 상한가(견적 생성 시 박제) — 목표가를 넘어도
|
# 타결선은 목표가가 아니라 타결 상한가(견적 생성 시 박제) — 목표가를 넘어도
|
||||||
# 상한 이내면 타결한다(IMK: 기존 단가보다 인하됐는데 결렬되던 케이스).
|
# 상한 이내면 타결한다.
|
||||||
ceiling = settle_ceiling(ctx)
|
ceiling = settle_ceiling(ctx)
|
||||||
if not ctx.get("closing_played"):
|
if not ctx.get("closing_played"):
|
||||||
ctx["force_closing"] = True
|
ctx["force_closing"] = True
|
||||||
@ -255,12 +261,8 @@ class ChatEngine:
|
|||||||
|
|
||||||
def _pick_wildcard(self, session: ChatSession) -> str:
|
def _pick_wildcard(self, session: ChatSession) -> str:
|
||||||
"""앵커가에 아주 근접(≤ anchor×wildcard_1pct_ratio)한 구간에서만 1% 인하 요청(wild_card_1pct)으로
|
"""앵커가에 아주 근접(≤ anchor×wildcard_1pct_ratio)한 구간에서만 1% 인하 요청(wild_card_1pct)으로
|
||||||
앵커가 이하로 유도한다. 그 외 구간은 일반 가격협상(카드 플레이)으로 돌린다.
|
앵커가 이하로 유도한다. 그 외 구간은 견적에서 선택한 와일드카드의 전술로 카운터하고,
|
||||||
|
낼 카드가 없으면 일반 가격협상(카드 플레이)으로 돌린다.
|
||||||
과거 여기서 반환하던 '재원부족'(wild_card_budget) 하드코딩 카드는 제거했다 —
|
|
||||||
견적에서 실제 선택한 와일드카드(중간값 절충·목표가 선제안 등)와 매핑되지 않은 채
|
|
||||||
'와일드카드를 하나라도 골랐으면' 조건만으로 발동해, 선택하지도 않은 재원부족 멘트가
|
|
||||||
노출되는 오작동이 있었다.
|
|
||||||
"""
|
"""
|
||||||
ctx = session.context
|
ctx = session.context
|
||||||
price = ctx.get("input_price", 0)
|
price = ctx.get("input_price", 0)
|
||||||
@ -268,15 +270,16 @@ class ChatEngine:
|
|||||||
target = ctx.get("target_price", 0)
|
target = ctx.get("target_price", 0)
|
||||||
if anchor > 0 and price <= anchor * self.rules.wildcard_1pct_ratio:
|
if anchor > 0 and price <= anchor * self.rules.wildcard_1pct_ratio:
|
||||||
offer_1pct = int(price * 0.99 / 10 + 0.5) * 10 # 1% 인하가 — 10원 반올림(앵커·카운터와 통일)
|
offer_1pct = int(price * 0.99 / 10 + 0.5) * 10 # 1% 인하가 — 10원 반올림(앵커·카운터와 통일)
|
||||||
# 제안가 공통 유효조건(≤목표가 · <제시가)은 시스템 1% 카드에도 동일하게 건다.
|
# 제안가 공통 유효조건(≤목표가 · <제시가 · 직전 당사 제안 이상=역행 금지)은 시스템 1% 카드에도
|
||||||
# 기본 앵커 밴드에선 수학적으로 항상 통과하지만, 앵커율 0 등 극단 데이터를 방어한다.
|
# 동일하게 건다. 기본 앵커 밴드에선 수학적으로 항상 통과하지만 극단 데이터를 방어한다.
|
||||||
if 0 < offer_1pct < price and (target <= 0 or offer_1pct <= target):
|
prev_customer = ctx.get("prev_customer_price") or 0
|
||||||
|
if 0 < offer_1pct < price and (target <= 0 or offer_1pct <= target) and offer_1pct >= prev_customer:
|
||||||
# 와일드카드는 실제로 노출할 때만 '사용됨'으로 마킹한다 — 가격협상으로 돌아가는
|
# 와일드카드는 실제로 노출할 때만 '사용됨'으로 마킹한다 — 가격협상으로 돌아가는
|
||||||
# 경우에도 마킹하면 이후 라운드에서 정당한 1% 카드까지 억제된다.
|
# 경우에도 마킹하면 이후 라운드에서 정당한 1% 카드까지 억제된다.
|
||||||
ctx["wildcard_used"] = True
|
ctx["wildcard_used"] = True
|
||||||
ctx["offer_1pct"] = offer_1pct
|
ctx["offer_1pct"] = offer_1pct
|
||||||
ctx["pending_counter_price"] = offer_1pct # 수락 시 이 가격으로 타결
|
record_offer(ctx, Offer(price=offer_1pct, variable="offer_1pct",
|
||||||
ctx["prev_customer_price"] = offer_1pct # 갑의 최신 포지션 — 이후 절충가 계산 기준
|
prev_customer=int(prev_customer or anchor), prev_partner=int(price)))
|
||||||
return "wild_card_1pct"
|
return "wild_card_1pct"
|
||||||
# 1.02 초과 ~ entry(1.05) 구간: 견적에서 선택한 와일드카드의 전술로 카운터 제시.
|
# 1.02 초과 ~ entry(1.05) 구간: 견적에서 선택한 와일드카드의 전술로 카운터 제시.
|
||||||
# (구현 전에는 이 구간이 일반 가격협상으로 회귀해 선택형 WC 가 영영 발동하지 않던 갭.)
|
# (구현 전에는 이 구간이 일반 가격협상으로 회귀해 선택형 WC 가 영영 발동하지 않던 갭.)
|
||||||
@ -288,11 +291,10 @@ class ChatEngine:
|
|||||||
# 이미 쓴 카드도 제외(같은 멘트 반복 방지).
|
# 이미 쓴 카드도 제외(같은 멘트 반복 방지).
|
||||||
if not available(spec, ctx) or is_played(ctx, number):
|
if not available(spec, ctx) or is_played(ctx, number):
|
||||||
continue
|
continue
|
||||||
offer = compute_offer(spec, ctx)
|
offer = compute_offer_detail(spec, ctx)
|
||||||
if offer is not None:
|
if offer is not None:
|
||||||
ctx["wildcard_used"] = True
|
ctx["wildcard_used"] = True
|
||||||
ctx["pending_counter_price"] = offer
|
record_offer(ctx, offer)
|
||||||
ctx["prev_customer_price"] = offer # 갑의 최신 포지션 — "당사 제안 ○원" 멘트가 실제 이력과 일치
|
|
||||||
ctx["active_wild_card_number"] = number
|
ctx["active_wild_card_number"] = number
|
||||||
mark_played(ctx, number)
|
mark_played(ctx, number)
|
||||||
return "wild_card_dynamic"
|
return "wild_card_dynamic"
|
||||||
@ -341,25 +343,31 @@ class ChatEngine:
|
|||||||
# 전술 카운터 변수(카드 시드 멘트의 가격 변수) — tactics.OFFER_VARIABLES 산식과 동일 정의.
|
# 전술 카운터 변수(카드 시드 멘트의 가격 변수) — tactics.OFFER_VARIABLES 산식과 동일 정의.
|
||||||
anchor = ctx.get("anchor_price") or 0
|
anchor = ctx.get("anchor_price") or 0
|
||||||
target = ctx.get("target_price") or 0
|
target = ctx.get("target_price") or 0
|
||||||
if "input_price" in ctx:
|
# 표시 기준값 — 제안이 확정된 턴이면 그 계산에 쓴 재료(pending_offer)를 쓴다.
|
||||||
out["prev_partner_price"] = int(ctx.get("prev_partner_price") or ctx["input_price"])
|
# prev_customer_price 는 확정 즉시 새 제안가로 갱신되므로, 그대로 읽으면 멘트가
|
||||||
prev_customer = ctx.get("prev_customer_price") or anchor
|
# "당사 제안과 귀사 제안의 절반이 당사 제안" 같은 자기모순이 된다.
|
||||||
|
pending_offer = ctx.get("pending_offer") or {}
|
||||||
|
prev_customer = int(pending_offer.get("prev_customer") or ctx.get("prev_customer_price") or anchor or 0)
|
||||||
|
partner_price = int(pending_offer.get("prev_partner") or ctx.get("prev_partner_price") or ctx.get("input_price") or 0)
|
||||||
|
offer_price = int(pending_offer.get("price") or ctx.get("pending_counter_price") or 0)
|
||||||
|
offer_variable = pending_offer.get("variable") or ""
|
||||||
if prev_customer:
|
if prev_customer:
|
||||||
out["prev_customer_price"] = int(prev_customer)
|
out["prev_customer_price"] = prev_customer
|
||||||
if anchor and target:
|
if partner_price:
|
||||||
out["target_mid_price"] = int(round((anchor + target) / 2))
|
out["prev_partner_price"] = partner_price
|
||||||
if prev_customer and "input_price" in ctx:
|
if offer_price:
|
||||||
out["middle_price"] = int(round((prev_customer + ctx["input_price"]) / 2))
|
out["counter_price"] = offer_price
|
||||||
if ctx.get("pending_counter_price"):
|
# 파생 가격(절충가·중간가) — 제안가로 확정된 변수는 그 금액을 그대로 쓴다(멘트에 보이는 금액과
|
||||||
# 카운터 제시 중: 멘트에 보이는 제시가와 수락 시 타결가(pending)를 반드시 일치시킨다.
|
# 수락 시 타결가는 항상 같아야 한다). 나머지는 참고 인용이므로 tactics 산식으로 채운다.
|
||||||
# 절충/중간 변수(middle_price·target_mid_price)는 vars_for 재계산 값이 compute_offer 의
|
# 산식을 여기 복사해 두면 갱신 시점 차이로 표시가와 제안가가 갈라지므로 정의를 호출만 한다.
|
||||||
# target 클램프·prev_customer 갱신과 어긋나, 멘트엔 1,740,000 이 보이는데 실제로는
|
# 어느 변수가 제안가인지 모르는 진행 중 세션(구버전 기록)은 종전대로 전부 제안가로 고정한다.
|
||||||
# 1,700,000 으로 타결되던 버그(표시가≠투찰가)가 있었다. pending 은 이 시점 유일한 '제안가'이므로
|
for name in _DERIVED_PRICE_VARIABLES:
|
||||||
# 세 변수 모두 pending 으로 고정한다(카운터 제시 턴에만 적용 — 비-카운터 렌더는 원 계산값 유지).
|
if offer_price and (name == offer_variable or not offer_variable):
|
||||||
pending_i = int(ctx["pending_counter_price"])
|
out[name] = offer_price
|
||||||
out["counter_price"] = pending_i
|
continue
|
||||||
out["middle_price"] = pending_i
|
value = OFFER_VARIABLES[name](target, anchor, partner_price, prev_customer)
|
||||||
out["target_mid_price"] = pending_i
|
if value:
|
||||||
|
out[name] = int(value / 10 + 0.5) * 10 # 10원 반올림 — compute_offer 와 동일
|
||||||
# 인하율 = (협상 기준가 - 제시가) / 기준가 * 100. 기준가 없으면 미표시(0.0).
|
# 인하율 = (협상 기준가 - 제시가) / 기준가 * 100. 기준가 없으면 미표시(0.0).
|
||||||
# 제시가가 기준가보다 높으면(인상 제시) 음수가 나오는데, "-1.3% 인하된 금액" 같은
|
# 제시가가 기준가보다 높으면(인상 제시) 음수가 나오는데, "-1.3% 인하된 금액" 같은
|
||||||
# 모순 표현이 되므로 discount_rate 는 0 미만 금지하고, 인상/동일/인하를 구분한
|
# 모순 표현이 되므로 discount_rate 는 0 미만 금지하고, 인상/동일/인하를 구분한
|
||||||
@ -395,8 +403,8 @@ class ChatEngine:
|
|||||||
return self._error(session, f"다음 단계를 찾을 수 없습니다: {step_key}")
|
return self._error(session, f"다음 단계를 찾을 수 없습니다: {step_key}")
|
||||||
# 가드레일(최후 방어선): 구매자 대리는 타결 상한가를 넘겨 타결하지 않는다.
|
# 가드레일(최후 방어선): 구매자 대리는 타결 상한가를 넘겨 타결하지 않는다.
|
||||||
# 상한 = 견적 생성 시 박제한 done_ceiling_price(목표가×(1+타결상한율)), 미박제면 목표가.
|
# 상한 = 견적 생성 시 박제한 done_ceiling_price(목표가×(1+타결상한율)), 미박제면 목표가.
|
||||||
# 목표가를 조금 넘어도 상한 이내면 타결이 정상이므로(IMK: 기존 단가보다 인하됐는데
|
# 목표가를 조금 넘어도 상한 이내면 타결이 정상이므로 여기서 뒤집지 않는다.
|
||||||
# 결렬되던 케이스) 여기서 뒤집으면 안 된다. 상한까지 넘은 경우만 결렬로 강제 전환한다.
|
# 상한까지 넘은 경우만 결렬로 강제 전환한다.
|
||||||
if step_key in _SUCCESS_STEPS and self.rq_type == "재협상":
|
if step_key in _SUCCESS_STEPS and self.rq_type == "재협상":
|
||||||
ctx = session.context
|
ctx = session.context
|
||||||
ceiling = settle_ceiling(ctx)
|
ceiling = settle_ceiling(ctx)
|
||||||
|
|||||||
@ -13,7 +13,9 @@ from common.enums import DBType, ErrorType
|
|||||||
from common.database.db_session_manager import DB_SESSION_MNG
|
from common.database.db_session_manager import DB_SESSION_MNG
|
||||||
from common.logger import LOG
|
from common.logger import LOG
|
||||||
from config.server_configs import agent_config
|
from config.server_configs import agent_config
|
||||||
from negotiation.cards.domain.tactics import available, compute_offer, is_played, mark_played, playable, spec_from_context
|
from negotiation.cards.domain.tactics import (
|
||||||
|
Offer, available, compute_offer_detail, is_played, mark_played, playable, record_offer, spec_from_context,
|
||||||
|
)
|
||||||
from negotiation.chat.service.chat_engine import (
|
from negotiation.chat.service.chat_engine import (
|
||||||
_CHOICE_MODES, _PRICE_MODES, ChatEngine, ChatSession, StepView,
|
_CHOICE_MODES, _PRICE_MODES, ChatEngine, ChatSession, StepView,
|
||||||
)
|
)
|
||||||
@ -309,16 +311,17 @@ class ChatService:
|
|||||||
# 협력사가 수락하면 이 가격으로 즉시 타결된다(chat_engine 의 수락 메커니즘).
|
# 협력사가 수락하면 이 가격으로 즉시 타결된다(chat_engine 의 수락 메커니즘).
|
||||||
# 유효조건(≤목표가 · <제시가) 미달이면 None → 금액 없이 설득 멘트만 나간다(HOLD 강등).
|
# 유효조건(≤목표가 · <제시가) 미달이면 None → 금액 없이 설득 멘트만 나간다(HOLD 강등).
|
||||||
spec = spec_from_context(session.context, card_id)
|
spec = spec_from_context(session.context, card_id)
|
||||||
counter = compute_offer(spec, session.context) if available(spec, session.context) else None
|
offer = compute_offer_detail(spec, session.context) if available(spec, session.context) else None
|
||||||
if counter is not None:
|
counter = offer.price if offer else None
|
||||||
session.context["pending_counter_price"] = counter
|
if offer is not None:
|
||||||
session.context["prev_customer_price"] = counter # 갑의 최신 포지션(절충가 계산 기준)
|
record_offer(session.context, offer)
|
||||||
reward = RewardCalculator(engine.config.reward, engine.config.state).calculate(snap)
|
reward = RewardCalculator(engine.config.reward, engine.config.state).calculate(snap)
|
||||||
policy.update(Transition(state_index=idx, action_id=decision.action_id, reward=reward.total, done=False))
|
policy.update(Transition(state_index=idx, action_id=decision.action_id, reward=reward.total, done=False))
|
||||||
await QTablePolicyStore.persist_cell(repo, version_id, policy, idx, decision.action_id)
|
await QTablePolicyStore.persist_cell(repo, version_id, policy, idx, decision.action_id)
|
||||||
session.context["last_state"] = idx
|
session.context["last_state"] = idx
|
||||||
session.context["last_action"] = decision.action_id
|
session.context["last_action"] = decision.action_id
|
||||||
await self._log(repo, session, idx, decision.action_id, card_id, snap, reward, decision.propensity, done=False)
|
await self._log(repo, session, idx, decision.action_id, card_id, snap, reward, done=False,
|
||||||
|
decision=decision, policy=policy)
|
||||||
|
|
||||||
res.card_id = card_id
|
res.card_id = card_id
|
||||||
res.policy = policy.name
|
res.policy = policy.name
|
||||||
@ -369,26 +372,29 @@ class ChatService:
|
|||||||
ctx["closing_played"] = True
|
ctx["closing_played"] = True
|
||||||
# 선택 와일드카드 중 종결 전용 카드(closing) — 이미 쓴 카드는 건너뛰고(같은 멘트 반복 방지),
|
# 선택 와일드카드 중 종결 전용 카드(closing) — 이미 쓴 카드는 건너뛰고(같은 멘트 반복 방지),
|
||||||
# 제안가 유효조건(≤목표가 · <제시가) 미달 카드도 건너뛴다(예: 절충가가 목표가 초과 → 미발동).
|
# 제안가 유효조건(≤목표가 · <제시가) 미달 카드도 건너뛴다(예: 절충가가 목표가 초과 → 미발동).
|
||||||
closing_number, counter = None, None
|
closing_number, closing_offer = None, None
|
||||||
for n in (ctx.get("selected_wild_card_numbers") or []):
|
for n in (ctx.get("selected_wild_card_numbers") or []):
|
||||||
n = str(n)
|
n = str(n)
|
||||||
spec = spec_from_context(ctx, n)
|
spec = spec_from_context(ctx, n)
|
||||||
if not available(spec, ctx, closing_phase=True) or is_played(ctx, n):
|
if not available(spec, ctx, closing_phase=True) or is_played(ctx, n):
|
||||||
continue
|
continue
|
||||||
offer = compute_offer(spec, ctx)
|
offer = compute_offer_detail(spec, ctx)
|
||||||
if offer is not None:
|
if offer is not None:
|
||||||
closing_number, counter = n, offer
|
closing_number, closing_offer = n, offer
|
||||||
break
|
break
|
||||||
if counter is None:
|
if closing_offer is None:
|
||||||
# 폴백 최후통첩: 목표가 제시 (여기 도달 = 제시가 > target 이므로 항상 유효한 카운터).
|
# 폴백 최후통첩: 목표가 제시 (여기 도달 = 제시가 > target 이므로 항상 유효한 카운터).
|
||||||
closing_number = None
|
closing_number = None
|
||||||
target = int(ctx.get("target_price") or 0)
|
target = int(ctx.get("target_price") or 0)
|
||||||
counter = target if 0 < target < ctx.get("input_price", 0) else None
|
price = int(ctx.get("input_price") or 0)
|
||||||
if counter is None:
|
if 0 < target < price:
|
||||||
|
closing_offer = Offer(price=target, variable="target_price",
|
||||||
|
prev_customer=int(ctx.get("prev_customer_price") or ctx.get("anchor_price") or 0),
|
||||||
|
prev_partner=price)
|
||||||
|
if closing_offer is None:
|
||||||
return # 컨텍스트 이상 — 기존 가격협상 스텝 그대로(재제안 요구)
|
return # 컨텍스트 이상 — 기존 가격협상 스텝 그대로(재제안 요구)
|
||||||
mark_played(ctx, closing_number) # None(폴백 최후통첩)이면 no-op
|
mark_played(ctx, closing_number) # None(폴백 최후통첩)이면 no-op
|
||||||
ctx["pending_counter_price"] = counter
|
record_offer(ctx, closing_offer)
|
||||||
ctx["prev_customer_price"] = counter
|
|
||||||
|
|
||||||
template = None
|
template = None
|
||||||
if closing_number:
|
if closing_number:
|
||||||
@ -415,7 +421,7 @@ class ChatService:
|
|||||||
policy.update(Transition(state_index=last_state, action_id=last_action, reward=reward.total, done=True))
|
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 QTablePolicyStore.persist_cell(repo, version_id, policy, last_state, last_action)
|
||||||
await self._log(repo, session, last_state, last_action,
|
await self._log(repo, session, last_state, last_action,
|
||||||
self._card_id_for_action(engine, session, last_action), snap, reward, None, done=True)
|
self._card_id_for_action(engine, session, last_action), snap, reward, done=True)
|
||||||
res.updated_q = float(policy.qtable.q[last_state, last_action])
|
res.updated_q = float(policy.qtable.q[last_state, last_action])
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@ -494,13 +500,26 @@ class ChatService:
|
|||||||
prior[a] = 0.3 * (n - rank) / n
|
prior[a] = 0.3 * (n - rank) / n
|
||||||
return prior if prior.any() else None
|
return prior if prior.any() else None
|
||||||
|
|
||||||
async def _log(self, repo: LearningRepository, session, state_index, action_id, card_id, snap, reward, propensity, done):
|
async def _log(self, repo: LearningRepository, session, state_index, action_id, card_id, snap, reward, done,
|
||||||
|
decision=None, policy=None):
|
||||||
data = {
|
data = {
|
||||||
"session_id": session.session_id, "state_index": state_index, "action_id": action_id,
|
"session_id": session.session_id, "state_index": state_index, "action_id": action_id,
|
||||||
"card_id": card_id, "snapshot": snap.to_dict(), "propensity": propensity,
|
"card_id": card_id, "snapshot": snap.to_dict(),
|
||||||
"turn": snap.round_number, "reward": reward.total, "done": done,
|
"turn": snap.round_number, "reward": reward.total, "done": done,
|
||||||
"settled_price": int(snap.input_price) if snap.outcome == NegotiationOutcome.SUCCESS else None,
|
"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:
|
try:
|
||||||
await DB_SESSION_MNG.execute_lambda_run([DBType.MAIN.value], [lambda s: repo.log_transition(s, data)])
|
await DB_SESSION_MNG.execute_lambda_run([DBType.MAIN.value], [lambda s: repo.log_transition(s, data)])
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
|
|||||||
@ -97,10 +97,10 @@ class NegotiationService:
|
|||||||
|
|
||||||
# 7) experience_logs 기록
|
# 7) experience_logs 기록
|
||||||
if req.log:
|
if req.log:
|
||||||
res.logged = await self._log(engine, session_id, idx, decision, snap, reward)
|
res.logged = await self._log(engine, session_id, idx, decision, snap, reward, policy)
|
||||||
return res
|
return res
|
||||||
|
|
||||||
async def _log(self, engine, session_id, idx, decision, snap, reward) -> bool:
|
async def _log(self, engine, session_id, idx, decision, snap, reward, policy) -> bool:
|
||||||
repo = LearningRepository(engine.company_id)
|
repo = LearningRepository(engine.company_id)
|
||||||
data = {
|
data = {
|
||||||
"session_id": session_id, "state_index": idx, "action_id": decision.action_id,
|
"session_id": session_id, "state_index": idx, "action_id": decision.action_id,
|
||||||
@ -108,6 +108,8 @@ class NegotiationService:
|
|||||||
"turn": snap.round_number, "available_actions": decision.available_actions,
|
"turn": snap.round_number, "available_actions": decision.available_actions,
|
||||||
"reward": reward.total, "done": snap.outcome != NegotiationOutcome.ONGOING,
|
"reward": reward.total, "done": snap.outcome != NegotiationOutcome.ONGOING,
|
||||||
"q_value_at_selection": decision.q_value, "ucb_score_at_selection": decision.ucb_score,
|
"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,
|
"settled_price": int(snap.input_price) if snap.outcome == NegotiationOutcome.SUCCESS else None,
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
|
|||||||
@ -1,18 +1,33 @@
|
|||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from typing import Tuple
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
from sqlalchemy import case, cast, func, nulls_last, or_, select, text, update
|
from sqlalchemy import and_, case, cast, func, nulls_last, or_, select, text, update
|
||||||
from sqlalchemy.dialects.postgresql import JSONB
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from common.database.db_session_manager import DB_SESSION_MNG
|
from common.database.db_session_manager import DB_SESSION_MNG
|
||||||
from common.database.model.models import items, quotations, sessions
|
from common.database.model.models import chats, items, quotations, sessions
|
||||||
from common.enums import CloseReason, ErrorType, QuotationStatus, RENEGOTIABLE_CLOSE_REASONS, SessionStatus
|
from common.enums import CloseReason, ErrorType, QuotationStatus, RENEGOTIABLE_CLOSE_REASONS, SessionStatus
|
||||||
from common.logger import LOG
|
from common.logger import LOG
|
||||||
|
|
||||||
|
|
||||||
# 협상 세션 CRUD. 목록은 세션(negotiation) ⨝ 상품(partner) ⨝ 견적(quotation) 조인으로 만든다.
|
# 협상 세션 CRUD. 목록은 세션(negotiation) ⨝ 상품(partner) ⨝ 견적(quotation) 조인으로 만든다.
|
||||||
# 마감일(qt_end_time)은 견적(quotation.end_time)이 진실값이다(session.end_time 은 협상 종료 시점 기록용).
|
# 마감일(qt_end_time)은 견적(quotation.end_time)이 진실값이다(session.end_time 은 협상 종료 시점 기록용).
|
||||||
|
|
||||||
|
|
||||||
|
def _effective_status():
|
||||||
|
"""표시용 세션 상태. 견적이 마감됐거나 마감시간이 지났으면 협상생성(1)은 더 참여할 수 없으므로 미참여(4)로 본다.
|
||||||
|
|
||||||
|
참여/채팅진입이 진입 시점에 하는 전이(negotiation_service._load_actionable_session, chat_service.init)와 같은 규칙을
|
||||||
|
목록에서는 쓰기 없이 파생으로만 맞춘다. 마감 일괄정리 이후에 만들어진 세션도 '협상 대기'로 남지 않는다.
|
||||||
|
"""
|
||||||
|
ended = or_(quotations.status == QuotationStatus.CLOSED.value, quotations.end_time < func.now())
|
||||||
|
return case(
|
||||||
|
(and_(sessions.status == SessionStatus.CREATED.value, ended), SessionStatus.NOT_PARTICIPATED.value),
|
||||||
|
else_=sessions.status,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ISessionCRUD(ABC):
|
class ISessionCRUD(ABC):
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def list_by_supplier(self, cdb: AsyncSession, supplier_id, status, qt_type, order, offset, limit, keyword=None, result=None) -> Tuple[ErrorType, list]:
|
async def list_by_supplier(self, cdb: AsyncSession, supplier_id, status, qt_type, order, offset, limit, keyword=None, result=None) -> Tuple[ErrorType, list]:
|
||||||
@ -39,7 +54,9 @@ class ISessionCRUD(ABC):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def update_session_reject(self, cdb: AsyncSession, session_id, status: int, reject_reason: str) -> ErrorType:
|
async def update_session_reject(
|
||||||
|
self, cdb: AsyncSession, session_id, status: int, reject_reason: str, reject_price: Optional[int] = None,
|
||||||
|
) -> ErrorType:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
@ -47,19 +64,6 @@ class ISessionCRUD(ABC):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def chain_max_round(self, cdb: AsyncSession, number: str) -> Tuple[ErrorType, int]:
|
|
||||||
# 같은 견적번호(체인)의 최대 차수. 이미 다음 라운드가 있으면 재협상 요청은 의미가 없다.
|
|
||||||
try:
|
|
||||||
query = select(func.max(quotations.round)).where(quotations.number == number, quotations.deleted == False) # noqa: E712
|
|
||||||
err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
|
|
||||||
if err_type != ErrorType.SUCCESS:
|
|
||||||
return err_type, 0
|
|
||||||
top = rows[0][0] if rows and rows[0] else None
|
|
||||||
return ErrorType.SUCCESS, int(top or 0)
|
|
||||||
except Exception as ex:
|
|
||||||
LOG.e_no_callstack(ex)
|
|
||||||
return ErrorType.DB_RUN_FAILED, 0
|
|
||||||
|
|
||||||
async def merge_session_custom(self, cdb: AsyncSession, session_id, supplier_id, patch: dict) -> ErrorType:
|
async def merge_session_custom(self, cdb: AsyncSession, session_id, supplier_id, patch: dict) -> ErrorType:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@ -73,7 +77,8 @@ class SessionCRUD(ISessionCRUD):
|
|||||||
def __filters(supplier_id, status, qt_type, keyword=None, result=None):
|
def __filters(supplier_id, status, qt_type, keyword=None, result=None):
|
||||||
conds = [sessions.supplier_id == supplier_id, sessions.deleted == False] # noqa: E712
|
conds = [sessions.supplier_id == supplier_id, sessions.deleted == False] # noqa: E712
|
||||||
if status is not None:
|
if status is not None:
|
||||||
conds.append(sessions.status == status)
|
# 표시 상태로 필터 — 탭/KPI 카운트가 목록 배지와 어긋나지 않게 파생값을 그대로 쓴다.
|
||||||
|
conds.append(_effective_status() == status)
|
||||||
if qt_type is not None:
|
if qt_type is not None:
|
||||||
conds.append(sessions.qt_type == qt_type)
|
conds.append(sessions.qt_type == qt_type)
|
||||||
# 검색: 견적번호·상품명·상품코드 부분일치(대소문자 무시). items 는 목록/카운트 둘 다 조인돼 있다.
|
# 검색: 견적번호·상품명·상품코드 부분일치(대소문자 무시). items 는 목록/카운트 둘 다 조인돼 있다.
|
||||||
@ -113,7 +118,7 @@ class SessionCRUD(ISessionCRUD):
|
|||||||
else:
|
else:
|
||||||
# 그룹별로 정렬 방향이 달라, case 로 '자기 그룹 행만 end_time' 을 갖는 키를 만들고
|
# 그룹별로 정렬 방향이 달라, case 로 '자기 그룹 행만 end_time' 을 갖는 키를 만들고
|
||||||
# 반대 그룹은 NULL 로 눌러 간섭을 없앤다. status_rank 가 1차 키라 그룹 경계는 항상 유지.
|
# 반대 그룹은 NULL 로 눌러 간섭을 없앤다. status_rank 가 1차 키라 그룹 경계는 항상 유지.
|
||||||
actionable = sessions.status.in_((SessionStatus.CREATED.value, SessionStatus.IN_PROGRESS.value))
|
actionable = _effective_status().in_((SessionStatus.CREATED.value, SessionStatus.IN_PROGRESS.value))
|
||||||
status_rank = case((actionable, 0), else_=1)
|
status_rank = case((actionable, 0), else_=1)
|
||||||
action_order = case((actionable, quotations.end_time), else_=None).asc()
|
action_order = case((actionable, quotations.end_time), else_=None).asc()
|
||||||
done_order = case((~actionable, quotations.end_time), else_=None).desc()
|
done_order = case((~actionable, quotations.end_time), else_=None).desc()
|
||||||
@ -122,7 +127,7 @@ class SessionCRUD(ISessionCRUD):
|
|||||||
query = (
|
query = (
|
||||||
select(
|
select(
|
||||||
sessions.session_id,
|
sessions.session_id,
|
||||||
sessions.status,
|
_effective_status(), # 마감 후 남은 협상생성은 미참여로 내린다
|
||||||
sessions.qt_type,
|
sessions.qt_type,
|
||||||
sessions.qt_number,
|
sessions.qt_number,
|
||||||
quotations.end_time, # qt_end_time = 견적 마감 시각
|
quotations.end_time, # qt_end_time = 견적 마감 시각
|
||||||
@ -136,6 +141,11 @@ class SessionCRUD(ISessionCRUD):
|
|||||||
quotations.round,
|
quotations.round,
|
||||||
quotations.preferred_sp_id, # 낙찰자(공급사) — 나와 같으면 낙찰, 다르면 미낙찰
|
quotations.preferred_sp_id, # 낙찰자(공급사) — 나와 같으면 낙찰, 다르면 미낙찰
|
||||||
sessions.supplier_id, # 이 세션 소유 공급사(=조회자). 낙찰자와 대조
|
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(items, items.item_id == sessions.item_id)
|
||||||
.join(quotations, quotations.qt_id == sessions.quotation_id)
|
.join(quotations, quotations.qt_id == sessions.quotation_id)
|
||||||
@ -212,12 +222,18 @@ class SessionCRUD(ISessionCRUD):
|
|||||||
LOG.e_no_callstack(ex)
|
LOG.e_no_callstack(ex)
|
||||||
return ErrorType.DB_RUN_FAILED
|
return ErrorType.DB_RUN_FAILED
|
||||||
|
|
||||||
async def update_session_reject(self, cdb: AsyncSession, session_id, status: int, reject_reason: str) -> ErrorType:
|
async def update_session_reject(
|
||||||
|
self, cdb: AsyncSession, session_id, status: int, reject_reason: str, reject_price: Optional[int] = None,
|
||||||
|
) -> ErrorType:
|
||||||
try:
|
try:
|
||||||
|
values = {"status": status, "reject_reason": reject_reason}
|
||||||
|
# 공급 희망 가격은 선택 입력이라 안 들어올 수 있다 — 그때는 컬럼을 건드리지 않는다.
|
||||||
|
if reject_price is not None:
|
||||||
|
values["reject_price"] = reject_price
|
||||||
query = (
|
query = (
|
||||||
update(sessions)
|
update(sessions)
|
||||||
.where(sessions.session_id == session_id)
|
.where(sessions.session_id == session_id)
|
||||||
.values(status=status, reject_reason=reject_reason)
|
.values(**values)
|
||||||
)
|
)
|
||||||
return await DB_SESSION_MNG.add(cdb, query)
|
return await DB_SESSION_MNG.add(cdb, query)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
@ -231,7 +247,8 @@ class SessionCRUD(ISessionCRUD):
|
|||||||
err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
|
err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
|
||||||
if err_type != ErrorType.SUCCESS:
|
if err_type != ErrorType.SUCCESS:
|
||||||
return err_type, 0
|
return err_type, 0
|
||||||
top = rows[0][0] if rows and rows[0] else None
|
# 단일 컬럼 select 는 scalars() 로 내려와 rows 가 값 리스트다(행 튜플이 아님).
|
||||||
|
top = rows[0] if rows else None
|
||||||
return ErrorType.SUCCESS, int(top or 0)
|
return ErrorType.SUCCESS, int(top or 0)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
LOG.e_no_callstack(ex)
|
LOG.e_no_callstack(ex)
|
||||||
|
|||||||
@ -79,6 +79,8 @@ class Res_ChatInit(Res_WebPacketProtocol):
|
|||||||
item_vat_yn: Optional[bool] = Field(None, description="VAT 포함 여부(미설정 시 null)")
|
item_vat_yn: Optional[bool] = Field(None, description="VAT 포함 여부(미설정 시 null)")
|
||||||
item_delivery_fee_yn: Optional[bool] = Field(None, description="배송비 포함 여부(미설정 시 null)")
|
item_delivery_fee_yn: Optional[bool] = Field(None, description="배송비 포함 여부(미설정 시 null)")
|
||||||
custom: dict = Field(default_factory=dict, description="협상완료 부가정보 기존 입력값(sessions.custom). 재진입 시 폼 프리필용")
|
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) 치환용. 없으면 프론트 기본값")
|
labels: dict = Field(default_factory=dict, description="회사 커스텀 라벨(companies.settings.labels). 상품 상세 필드명(예: lead_time) 치환용. 없으면 프론트 기본값")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -21,6 +21,9 @@ class ListItem(WebPacketProtocol):
|
|||||||
renegotiation_status: int = Field(0, description="현재 재협상 요청 상태(RenegotiationStatus). 요청 이력이 없으면 0")
|
renegotiation_status: int = Field(0, description="현재 재협상 요청 상태(RenegotiationStatus). 요청 이력이 없으면 0")
|
||||||
renegotiation_memo: str = Field("", description="담당자 심사 메모(반려 사유). 없으면 빈 문자열")
|
renegotiation_memo: str = Field("", description="담당자 심사 메모(반려 사유). 없으면 빈 문자열")
|
||||||
result: int = Field(0, description="공급사 관점 협상 결과(SessionResult): 0=미정 1=낙찰 2=미낙찰 3=결렬(개찰, 재협상 대상)")
|
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):
|
class Res_SessionList(Res_WebPacketProtocol):
|
||||||
@ -36,6 +39,8 @@ class Res_Participate(Res_WebPacketProtocol):
|
|||||||
|
|
||||||
class Req_Reject(WebPacketProtocol):
|
class Req_Reject(WebPacketProtocol):
|
||||||
reject_reason: str = Field("", max_length=255, description="거부 사유 (단종/품절 프리셋 라벨 또는 직접 입력)")
|
reject_reason: str = Field("", max_length=255, description="거부 사유 (단종/품절 프리셋 라벨 또는 직접 입력)")
|
||||||
|
reject_price: Optional[int] = Field(None, description="공급 희망 가격(원). 선택 입력 — 없으면 컬럼 미변경")
|
||||||
|
opinion: Optional[str] = Field(None, max_length=255, description="추가 의견 — sessions.custom.opinion 에 병합")
|
||||||
|
|
||||||
|
|
||||||
class Res_Reject(Res_WebPacketProtocol):
|
class Res_Reject(Res_WebPacketProtocol):
|
||||||
|
|||||||
@ -62,7 +62,7 @@ async def participate(
|
|||||||
path="/sessions/{session_id}/reject",
|
path="/sessions/{session_id}/reject",
|
||||||
response_model=Res_Reject,
|
response_model=Res_Reject,
|
||||||
summary="협상 거부",
|
summary="협상 거부",
|
||||||
description="세션 참여를 거부한다. 소유(공급사)·세션상태(완료/미참여/거부 불가)·견적마감·마감시간 검증 후 협상거부로 전이하고 사유를 저장.",
|
description="세션 참여를 거부하거나 진행 중인 협상을 거부한다. 소유(공급사)·세션상태(완료/미참여/거부 불가)·견적마감·마감시간 검증 후 협상거부로 전이하고 사유·공급 희망 가격·의견을 저장.",
|
||||||
)
|
)
|
||||||
async def reject(
|
async def reject(
|
||||||
session_id: str = Path(description="대상 협상 세션 uuid"),
|
session_id: str = Path(description="대상 협상 세션 uuid"),
|
||||||
@ -71,7 +71,11 @@ async def reject(
|
|||||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||||
service: NegotiationService = Depends(),
|
service: NegotiationService = Depends(),
|
||||||
):
|
):
|
||||||
return RemoveNoneResponse(await service.reject(user_info, credentials.credentials, session_id, req.reject_reason))
|
return RemoveNoneResponse(
|
||||||
|
await service.reject(
|
||||||
|
user_info, credentials.credentials, session_id, req.reject_reason, req.reject_price, req.opinion,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
|
|||||||
@ -71,6 +71,14 @@ class ChatService:
|
|||||||
opinion = None
|
opinion = None
|
||||||
if ", 의견-" in s:
|
if ", 의견-" in s:
|
||||||
s, opinion = s.split(", 의견-", 1)
|
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
|
reason = None
|
||||||
if ", 합의불가사유-" in s:
|
if ", 합의불가사유-" in s:
|
||||||
price_part, reason = s.split(", 합의불가사유-", 1)
|
price_part, reason = s.split(", 합의불가사유-", 1)
|
||||||
@ -227,11 +235,8 @@ class ChatService:
|
|||||||
)
|
)
|
||||||
sess.status = SessionStatus.NOT_PARTICIPATED.value
|
sess.status = SessionStatus.NOT_PARTICIPATED.value
|
||||||
|
|
||||||
# 미참여/협상거부 상태는 진입(열람) 불가 (participate/reject 와 동일 규칙).
|
# 미참여/협상거부 세션도 '결과 보기'로 지난 대화를 열람할 수 있다(중간 이탈·거부로 끝난 건).
|
||||||
# 위 마감 변환으로 미참여가 된 세션도 여기서 함께 막힌다.
|
# 대화 재개는 send() 가 협상중(2)만 허용하므로 여기서 막지 않아도 읽기 전용이다.
|
||||||
if sess.status in (SessionStatus.NOT_PARTICIPATED.value, SessionStatus.REJECTED.value):
|
|
||||||
res.result.SetResult(ErrorType.NEGO_NOT_PARTICIPABLE)
|
|
||||||
return res
|
|
||||||
|
|
||||||
await self._ensure_in_progress(sess, quote)
|
await self._ensure_in_progress(sess, quote)
|
||||||
|
|
||||||
@ -254,6 +259,9 @@ class ChatService:
|
|||||||
res.item_vat_yn = item.vat_yn
|
res.item_vat_yn = item.vat_yn
|
||||||
res.item_delivery_fee_yn = item.delivery_fee_yn
|
res.item_delivery_fee_yn = item.delivery_fee_yn
|
||||||
res.custom = sess.custom or {}
|
res.custom = sess.custom or {}
|
||||||
|
# 거부로 끝난 세션은 대화에 남지 않는 제출 내역(사유·희망가)을 열람용으로 함께 내린다.
|
||||||
|
res.reject_reason = sess.reject_reason or ""
|
||||||
|
res.reject_price = sess.reject_price
|
||||||
|
|
||||||
# 회사 커스텀 라벨(companies.settings.labels) — 상품 상세 필드명 치환용(예: lead_time→표준납기). 실패해도 빈 dict 폴백.
|
# 회사 커스텀 라벨(companies.settings.labels) — 상품 상세 필드명 치환용(예: lead_time→표준납기). 실패해도 빈 dict 폴백.
|
||||||
_e, settings = await DB_SESSION_MNG.execute_lambda(
|
_e, settings = await DB_SESSION_MNG.execute_lambda(
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import Depends
|
from fastapi import Depends
|
||||||
|
|
||||||
@ -182,6 +183,9 @@ class NegotiationService:
|
|||||||
renegotiation_status=status,
|
renegotiation_status=status,
|
||||||
renegotiation_memo=renego.get("memo") or "",
|
renegotiation_memo=renego.get("memo") or "",
|
||||||
result=NegotiationService._to_result(r[10], r[11], r[13], r[14]),
|
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
|
@staticmethod
|
||||||
@ -434,7 +438,10 @@ class NegotiationService:
|
|||||||
res.session_id = str(sess.session_id)
|
res.session_id = str(sess.session_id)
|
||||||
return res
|
return res
|
||||||
|
|
||||||
async def reject(self, user_info: UserInfo, access_token: str, session_id_str: str, reject_reason: str) -> Res_Reject:
|
async def reject(
|
||||||
|
self, user_info: UserInfo, access_token: str, session_id_str: str, reject_reason: str,
|
||||||
|
reject_price: Optional[int] = None, opinion: Optional[str] = None,
|
||||||
|
) -> Res_Reject:
|
||||||
res = Res_Reject()
|
res = Res_Reject()
|
||||||
|
|
||||||
# 거부 사유 필수
|
# 거부 사유 필수
|
||||||
@ -454,11 +461,19 @@ class NegotiationService:
|
|||||||
res.result.SetResult(err_type)
|
res.result.SetResult(err_type)
|
||||||
return res
|
return res
|
||||||
|
|
||||||
# 거부 처리 — 세션을 협상거부로 전이하고 사유 저장
|
# 거부 처리 — 세션을 협상거부로 전이하고 사유·공급 희망 가격 저장.
|
||||||
err_type = await DB_SESSION_MNG.execute_lambda_run(
|
# 의견은 부가정보와 같은 custom 컬럼이라 병합(덮어쓰기 금지) — 채팅 결렬 폼과 같은 자리.
|
||||||
[sessions.DBType()],
|
funcs = [
|
||||||
[lambda s: self.session_crud.update_session_reject(s, sess.session_id, SessionStatus.REJECTED.value, reason)],
|
lambda s: self.session_crud.update_session_reject(
|
||||||
|
s, sess.session_id, SessionStatus.REJECTED.value, reason, reject_price,
|
||||||
)
|
)
|
||||||
|
]
|
||||||
|
note = (opinion or "").strip()[:255]
|
||||||
|
if note:
|
||||||
|
funcs.append(
|
||||||
|
lambda s: self.session_crud.merge_session_custom(s, sess.session_id, sess.supplier_id, {"opinion": note})
|
||||||
|
)
|
||||||
|
err_type = await DB_SESSION_MNG.execute_lambda_run([sessions.DBType()], funcs)
|
||||||
if err_type != ErrorType.SUCCESS:
|
if err_type != ErrorType.SUCCESS:
|
||||||
res.result.SetResult(err_type)
|
res.result.SetResult(err_type)
|
||||||
return res
|
return res
|
||||||
|
|||||||
@ -220,6 +220,23 @@ async def test_chat_init_returns_meta(client, chat_seed):
|
|||||||
assert body["quotation_end_time"] # 타이머용 마감 시각
|
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):
|
async def test_chat_init_forbidden_other_supplier(client, chat_seed):
|
||||||
token = await _login_token(client)
|
token = await _login_token(client)
|
||||||
body = (await _init(client, token, chat_seed["sids"]["X"])).json()
|
body = (await _init(client, token, chat_seed["sids"]["X"])).json()
|
||||||
@ -380,23 +397,28 @@ async def test_send_blocked_when_prev_turn_pending(client, chat_seed, db_engine)
|
|||||||
|
|
||||||
|
|
||||||
async def test_init_marks_expired_created_as_not_participated(client, chat_seed, db_engine):
|
async def test_init_marks_expired_created_as_not_participated(client, chat_seed, db_engine):
|
||||||
|
"""검증: 마감시간이 지난 협상생성 세션으로 채팅 진입.
|
||||||
|
기대결과: DB 상태가 미참여(4)로 정리되고, init 자체는 열람용으로 성공한다."""
|
||||||
token = await _login_token(client)
|
token = await _login_token(client)
|
||||||
sid, qid = chat_seed["sids"]["C"], chat_seed["qids"]["C"] # 협상생성(1)
|
sid, qid = chat_seed["sids"]["C"], chat_seed["qids"]["C"] # 협상생성(1)
|
||||||
async with db_engine.begin() as conn:
|
async with db_engine.begin() as conn:
|
||||||
await conn.execute(text("UPDATE quotation.quotations SET end_time = now() - make_interval(hours => 1) WHERE qt_id = :qid"), {"qid": qid})
|
await conn.execute(text("UPDATE quotation.quotations SET end_time = now() - make_interval(hours => 1) WHERE qt_id = :qid"), {"qid": qid})
|
||||||
body = (await _init(client, token, sid)).json()
|
body = (await _init(client, token, sid)).json()
|
||||||
# 마감된 협상생성은 DB 상 미참여로 정리되고, 미참여는 진입 불가라 init 은 에러로 막는다.
|
assert body["result"]["success"] is True
|
||||||
assert body["result"]["code"] == 1301 # NEGO_NOT_PARTICIPABLE
|
assert body["session_status"] == 4
|
||||||
assert await _session_status(db_engine, sid) == 4 # DB 는 미참여로 전이됨
|
assert await _session_status(db_engine, sid) == 4 # DB 도 미참여로 전이
|
||||||
|
|
||||||
|
|
||||||
async def test_init_blocks_rejected_session(client, chat_seed, db_engine):
|
async def test_init_allows_viewing_rejected_session(client, chat_seed, db_engine):
|
||||||
|
"""검증: 협상거부(5)로 끝난 세션에 '결과 보기'로 재진입.
|
||||||
|
기대결과: init 성공(열람 허용) — 대화 재개는 send 가 협상중만 허용해 막는다."""
|
||||||
token = await _login_token(client)
|
token = await _login_token(client)
|
||||||
sid = chat_seed["sids"]["P"]
|
sid = chat_seed["sids"]["P"]
|
||||||
async with db_engine.begin() as conn:
|
async with db_engine.begin() as conn:
|
||||||
await conn.execute(text("UPDATE negotiation.sessions SET status = 5 WHERE session_id = :sid"), {"sid": sid}) # 협상거부
|
await conn.execute(text("UPDATE negotiation.sessions SET status = 5 WHERE session_id = :sid"), {"sid": sid}) # 협상거부
|
||||||
body = (await _init(client, token, sid)).json()
|
body = (await _init(client, token, sid)).json()
|
||||||
assert body["result"]["code"] == 1301 # NEGO_NOT_PARTICIPABLE — 거부 세션 진입 차단
|
assert body["result"]["success"] is True and body["session_status"] == 5
|
||||||
|
assert (await _send(client, token, sid, "네")).json()["result"]["code"] == 1400 # CHAT_NOT_IN_PROGRESS
|
||||||
|
|
||||||
|
|
||||||
# ---- 순수 헬퍼 단위 테스트 (DB 불필요, ChatService @staticmethod) ----------
|
# ---- 순수 헬퍼 단위 테스트 (DB 불필요, ChatService @staticmethod) ----------
|
||||||
|
|||||||
@ -16,45 +16,10 @@ TEST_SUPPLIER_NAME = "파이테스트협상공급사"
|
|||||||
MARK = "PYTESTNEGO-" # 시드 식별용 prefix (item code / qt number)
|
MARK = "PYTESTNEGO-" # 시드 식별용 prefix (item code / qt number)
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
async def _seed_case(conn, code, sess_st, qt_type, hrs, quote_st, sup):
|
||||||
async def nego_seed(db_engine):
|
"""상품·견적·세션 1세트 시드. 코드/견적번호에 MARK prefix 를 달아 cleanup 이 함께 지운다.
|
||||||
"""공급사 + 유저 + 세션/견적 3건(본인) + 1건(타 공급사) 시드. 세션/견적 id 를 반환."""
|
hrs 는 마감(quotation.end_time)까지의 시간 — 음수면 이미 마감시간이 지난 건. 반환: (session_id, qt_id)."""
|
||||||
supplier_id = uuid.uuid4()
|
|
||||||
other_supplier_id = uuid.uuid4()
|
|
||||||
pw_hash = bcrypt.hashpw(TEST_PW.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
|
||||||
|
|
||||||
# (code, session.status, qt_type, 마감까지 시간(h), quotation.status, 소속 공급사)
|
|
||||||
specs = [
|
|
||||||
("A", 1, 2, 2, 1, supplier_id), # 협상생성 / 재견적 / +2h / 견적생성
|
|
||||||
("B", 2, 1, 1, 2, supplier_id), # 협상중 / 재협상 / +1h / 견적진행중
|
|
||||||
("C", 3, 2, 3, 2, supplier_id), # 협상완료 / 재견적 / +3h / 견적진행중
|
|
||||||
("X", 1, 1, 1, 1, other_supplier_id), # 타 공급사 → 목록/참여에서 제외/차단
|
|
||||||
]
|
|
||||||
sids, qids = {}, {}
|
|
||||||
|
|
||||||
async def _cleanup(conn):
|
|
||||||
await conn.execute(text(f"DELETE FROM negotiation.sessions WHERE qt_number LIKE '{MARK}%'"))
|
|
||||||
await conn.execute(text(f"DELETE FROM quotation.quotations WHERE number LIKE '{MARK}%'"))
|
|
||||||
await conn.execute(text(f"DELETE FROM partner.items WHERE code LIKE '{MARK}%'"))
|
|
||||||
await conn.execute(text("DELETE FROM supplier.supplier_users WHERE id = :id"), {"id": TEST_LOGIN_ID})
|
|
||||||
await conn.execute(text("DELETE FROM partner.suppliers WHERE name = :n"), {"n": TEST_SUPPLIER_NAME})
|
|
||||||
|
|
||||||
async with db_engine.begin() as conn:
|
|
||||||
await _cleanup(conn)
|
|
||||||
await conn.execute(
|
|
||||||
text("INSERT INTO partner.suppliers (supplier_id, company_id, user_id, name) VALUES (:sid, gen_random_uuid(), gen_random_uuid(), :name)"),
|
|
||||||
{"sid": supplier_id, "name": TEST_SUPPLIER_NAME},
|
|
||||||
)
|
|
||||||
await conn.execute(
|
|
||||||
text(
|
|
||||||
"INSERT INTO supplier.supplier_users (supplier_id, id, password, name, last_accessed_at, status, role) "
|
|
||||||
"VALUES (:sid, :id, :pw, '협상담당자', now(), 1, 1)"
|
|
||||||
),
|
|
||||||
{"sid": supplier_id, "id": TEST_LOGIN_ID, "pw": pw_hash},
|
|
||||||
)
|
|
||||||
for code, sess_st, qt_type, hrs, quote_st, sup in specs:
|
|
||||||
item_id, qt_id, session_id = uuid.uuid4(), uuid.uuid4(), uuid.uuid4()
|
item_id, qt_id, session_id = uuid.uuid4(), uuid.uuid4(), uuid.uuid4()
|
||||||
sids[code], qids[code] = session_id, qt_id
|
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
text(
|
text(
|
||||||
"INSERT INTO partner.items (item_id, company_id, user_id, name, code, model_name, manufacturer) "
|
"INSERT INTO partner.items (item_id, company_id, user_id, name, code, model_name, manufacturer) "
|
||||||
@ -77,6 +42,53 @@ async def nego_seed(db_engine):
|
|||||||
),
|
),
|
||||||
{"sesid": session_id, "qid": qt_id, "iid": item_id, "sup": sup, "qtn": f"{MARK}{code}", "qtt": qt_type, "st": sess_st},
|
{"sesid": session_id, "qid": qt_id, "iid": item_id, "sup": sup, "qtn": f"{MARK}{code}", "qtt": qt_type, "st": sess_st},
|
||||||
)
|
)
|
||||||
|
return session_id, qt_id
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def nego_seed(db_engine):
|
||||||
|
"""공급사 + 유저 + 세션/견적 3건(본인) + 1건(타 공급사) 시드. 세션/견적 id 를 반환."""
|
||||||
|
supplier_id = uuid.uuid4()
|
||||||
|
other_supplier_id = uuid.uuid4()
|
||||||
|
pw_hash = bcrypt.hashpw(TEST_PW.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
||||||
|
|
||||||
|
# (code, session.status, qt_type, 마감까지 시간(h), quotation.status, 소속 공급사)
|
||||||
|
specs = [
|
||||||
|
("A", 1, 2, 2, 1, supplier_id), # 협상생성 / 재견적 / +2h / 견적생성
|
||||||
|
("B", 2, 1, 1, 2, supplier_id), # 협상중 / 재협상 / +1h / 견적진행중
|
||||||
|
("C", 3, 2, 3, 2, supplier_id), # 협상완료 / 재견적 / +3h / 견적진행중
|
||||||
|
("X", 1, 1, 1, 1, other_supplier_id), # 타 공급사 → 목록/참여에서 제외/차단
|
||||||
|
]
|
||||||
|
sids, qids = {}, {}
|
||||||
|
|
||||||
|
async def _cleanup(conn):
|
||||||
|
# 대화는 세션보다 먼저 지운다(세션이 사라지면 대상을 못 고른다).
|
||||||
|
await conn.execute(text(
|
||||||
|
f"DELETE FROM negotiation.chats WHERE session_id IN "
|
||||||
|
f"(SELECT session_id FROM negotiation.sessions WHERE qt_number LIKE '{MARK}%')"
|
||||||
|
))
|
||||||
|
await conn.execute(text(f"DELETE FROM negotiation.sessions WHERE qt_number LIKE '{MARK}%'"))
|
||||||
|
await conn.execute(text(f"DELETE FROM quotation.quotations WHERE number LIKE '{MARK}%'"))
|
||||||
|
await conn.execute(text(f"DELETE FROM partner.items WHERE code LIKE '{MARK}%'"))
|
||||||
|
await conn.execute(text("DELETE FROM supplier.supplier_users WHERE id = :id"), {"id": TEST_LOGIN_ID})
|
||||||
|
await conn.execute(text("DELETE FROM partner.suppliers WHERE name = :n"), {"n": TEST_SUPPLIER_NAME})
|
||||||
|
|
||||||
|
async with db_engine.begin() as conn:
|
||||||
|
await _cleanup(conn)
|
||||||
|
await conn.execute(
|
||||||
|
text("INSERT INTO partner.suppliers (supplier_id, company_id, user_id, name) VALUES (:sid, gen_random_uuid(), gen_random_uuid(), :name)"),
|
||||||
|
{"sid": supplier_id, "name": TEST_SUPPLIER_NAME},
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO supplier.supplier_users (supplier_id, id, password, name, last_accessed_at, status, role) "
|
||||||
|
"VALUES (:sid, :id, :pw, '협상담당자', now(), 1, 1)"
|
||||||
|
),
|
||||||
|
{"sid": supplier_id, "id": TEST_LOGIN_ID, "pw": pw_hash},
|
||||||
|
)
|
||||||
|
for spec in specs:
|
||||||
|
code = spec[0]
|
||||||
|
sids[code], qids[code] = await _seed_case(conn, *spec)
|
||||||
|
|
||||||
yield {"supplier_id": supplier_id, "sids": sids, "qids": qids}
|
yield {"supplier_id": supplier_id, "sids": sids, "qids": qids}
|
||||||
|
|
||||||
@ -141,6 +153,63 @@ async def test_list_filter_status(client, nego_seed):
|
|||||||
assert body["total"] == 1 and body["items"][0]["item_code"] == f"{MARK}B"
|
assert body["total"] == 1 and body["items"][0]["item_code"] == f"{MARK}B"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_list_shows_closed_quotation_created_session_as_not_participated(client, db_engine, nego_seed):
|
||||||
|
"""검증: 견적이 마감(3)된 뒤에도 세션이 협상생성(1)으로 남아 있는 건(마감 일괄정리 이후 생성 등).
|
||||||
|
기대결과: 목록 상태는 미참여(4) — '협상 대기'로 새지 않고, status=1 필터에서도 빠지고 status=4 필터에 잡힌다."""
|
||||||
|
async with db_engine.begin() as conn:
|
||||||
|
await _seed_case(conn, "CLOSED1", 1, 2, -1, 3, nego_seed["supplier_id"])
|
||||||
|
token = await _login_token(client)
|
||||||
|
|
||||||
|
listed = next(i for i in (await _list(client, token)).json()["items"] if i["item_code"] == f"{MARK}CLOSED1")
|
||||||
|
assert listed["session_status"] == 4
|
||||||
|
|
||||||
|
waiting = (await _list(client, token, status=1)).json()
|
||||||
|
assert waiting["total"] == 1 and {i["item_code"] for i in waiting["items"]} == {f"{MARK}A"}
|
||||||
|
assert f"{MARK}CLOSED1" in {i["item_code"] for i in (await _list(client, token, status=4)).json()["items"]}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_list_shows_deadline_passed_created_session_as_not_participated(client, db_engine, nego_seed):
|
||||||
|
"""검증: 견적은 아직 진행중(2)인데 마감시간(end_time)만 지난 협상생성 세션.
|
||||||
|
기대결과: 미참여(4) — 참여/입장이 막히는 건이라 목록도 같은 상태로 보인다(DB 값은 그대로)."""
|
||||||
|
async with db_engine.begin() as conn:
|
||||||
|
session_id, _ = await _seed_case(conn, "OVERDUE", 1, 2, -3, 2, nego_seed["supplier_id"])
|
||||||
|
token = await _login_token(client)
|
||||||
|
|
||||||
|
listed = next(i for i in (await _list(client, token)).json()["items"] if i["item_code"] == f"{MARK}OVERDUE")
|
||||||
|
assert listed["session_status"] == 4
|
||||||
|
assert await _session_status(db_engine, session_id) == 1 # 목록은 파생 표시만, 쓰기는 하지 않는다
|
||||||
|
|
||||||
|
|
||||||
|
async def test_list_marks_stale_round_not_renegotiable(client, db_engine, nego_seed):
|
||||||
|
"""검증: 개찰(결렬) 마감된 1차 견적에 2차가 이미 생성돼 있는 체인.
|
||||||
|
기대결과: renegotiable False — 다음 라운드가 있으면 재협상 요청 대상이 아니다(체인 최대 차수 판정)."""
|
||||||
|
async with db_engine.begin() as conn:
|
||||||
|
await _seed_case(conn, "CHAIN", 3, 2, -2, 3, nego_seed["supplier_id"])
|
||||||
|
await conn.execute(text(
|
||||||
|
f"UPDATE quotation.quotations SET close_reason = 5 WHERE number = '{MARK}CHAIN'"))
|
||||||
|
# 같은 견적번호의 2차 — 번호가 같아야 체인으로 묶인다.
|
||||||
|
await conn.execute(text(
|
||||||
|
"INSERT INTO quotation.quotations (qt_id, user_id, qt_setting_id, version_id, name, number, type, status, round, start_time, end_time) "
|
||||||
|
f"VALUES (gen_random_uuid(), gen_random_uuid(), gen_random_uuid(), gen_random_uuid(), '견적 CHAIN 2차', '{MARK}CHAIN', 2, 2, 2, now(), now() + make_interval(hours => 2))"))
|
||||||
|
token = await _login_token(client)
|
||||||
|
listed = next(i for i in (await _list(client, token)).json()["items"] if i["item_code"] == f"{MARK}CHAIN")
|
||||||
|
assert listed["result"] == 3 and listed["renegotiable"] is False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_list_has_chat_flags_sessions_with_history(client, db_engine, nego_seed):
|
||||||
|
"""검증: 대화 이력이 있는 세션과 없는 세션의 has_chat.
|
||||||
|
기대결과: 이력 있는 건만 True — 종료 건의 '결과 보기' 노출이 이 값으로 갈린다."""
|
||||||
|
async with db_engine.begin() as conn:
|
||||||
|
await conn.execute(
|
||||||
|
text("INSERT INTO negotiation.chats (session_id, seq, sender, target_price) VALUES (:sid, 1, 1, 0)"),
|
||||||
|
{"sid": nego_seed["sids"]["C"]},
|
||||||
|
)
|
||||||
|
token = await _login_token(client)
|
||||||
|
by_code = {i["item_code"]: i["has_chat"] for i in (await _list(client, token)).json()["items"]}
|
||||||
|
assert by_code[f"{MARK}C"] is True
|
||||||
|
assert by_code[f"{MARK}A"] is False
|
||||||
|
|
||||||
|
|
||||||
async def test_list_filter_qt_type(client, nego_seed):
|
async def test_list_filter_qt_type(client, nego_seed):
|
||||||
token = await _login_token(client)
|
token = await _login_token(client)
|
||||||
body = (await _list(client, token, qt_type=2)).json()
|
body = (await _list(client, token, qt_type=2)).json()
|
||||||
@ -316,6 +385,65 @@ async def test_reject_success(client, nego_seed, db_engine):
|
|||||||
assert status == 5 and reason == "단종 상품입니다" # REJECTED + 사유 저장
|
assert status == 5 and reason == "단종 상품입니다" # REJECTED + 사유 저장
|
||||||
|
|
||||||
|
|
||||||
|
async def test_reject_with_price_and_opinion(client, nego_seed, db_engine):
|
||||||
|
# 채팅 내 협상 거부 경로 — 사유 외에 공급 희망 가격과 의견까지 함께 남긴다.
|
||||||
|
token = await _login_token(client)
|
||||||
|
sid = nego_seed["sids"]["B"]
|
||||||
|
r = await client.post(
|
||||||
|
f"/v1/negotiation/sessions/{sid}/reject",
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
json={"reject_reason": "품절", "reject_price": 88000, "opinion": "대체품으로 재견적 부탁드립니다"},
|
||||||
|
)
|
||||||
|
assert r.json()["result"]["success"] is True
|
||||||
|
async with db_engine.begin() as conn:
|
||||||
|
row = (await conn.execute(
|
||||||
|
text("SELECT status, reject_reason, reject_price, custom FROM negotiation.sessions WHERE session_id = :sid"),
|
||||||
|
{"sid": sid},
|
||||||
|
)).first()
|
||||||
|
assert row.status == 5 and row.reject_reason == "품절"
|
||||||
|
assert row.reject_price == 88000
|
||||||
|
assert row.custom["opinion"] == "대체품으로 재견적 부탁드립니다"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_reject_without_price_keeps_null(client, nego_seed, db_engine):
|
||||||
|
# 목록 거부 경로 — 가격이 없으면 reject_price 를 건드리지 않는다.
|
||||||
|
token = await _login_token(client)
|
||||||
|
sid = nego_seed["sids"]["B"]
|
||||||
|
r = await _reject(client, token, sid, "단종")
|
||||||
|
assert r.json()["result"]["success"] is True
|
||||||
|
async with db_engine.begin() as conn:
|
||||||
|
row = (await conn.execute(
|
||||||
|
text("SELECT reject_price, custom FROM negotiation.sessions WHERE session_id = :sid"),
|
||||||
|
{"sid": sid},
|
||||||
|
)).first()
|
||||||
|
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):
|
async def test_reject_empty_reason(client, nego_seed):
|
||||||
token = await _login_token(client)
|
token = await _login_token(client)
|
||||||
r = await _reject(client, token, nego_seed["sids"]["B"], " ") # 공백만 → 사유 없음
|
r = await _reject(client, token, nego_seed["sids"]["B"], " ") # 공백만 → 사유 없음
|
||||||
|
|||||||
@ -49,6 +49,8 @@ export interface ChatInitResponse {
|
|||||||
item_delivery_fee_yn?: boolean
|
item_delivery_fee_yn?: boolean
|
||||||
custom?: Record<string, unknown>
|
custom?: Record<string, unknown>
|
||||||
labels?: Record<string, string>
|
labels?: Record<string, string>
|
||||||
|
reject_reason?: string
|
||||||
|
reject_price?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatMessagesResponse {
|
export interface ChatMessagesResponse {
|
||||||
@ -108,5 +110,7 @@ export function mapInit(r: ChatInitResponse): ChatInitData {
|
|||||||
quotation_memo: r.quotation_memo ?? '',
|
quotation_memo: r.quotation_memo ?? '',
|
||||||
quotation_end_time: r.quotation_end_time ?? '',
|
quotation_end_time: r.quotation_end_time ?? '',
|
||||||
labels: r.labels ?? {},
|
labels: r.labels ?? {},
|
||||||
|
reject_reason: r.reject_reason ?? '',
|
||||||
|
reject_price: r.reject_price ?? null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -62,6 +62,9 @@ export interface SessionListItem {
|
|||||||
renegotiation_status: number // 1=심사대기 2=승인 3=반려 4=철회, 이력 없으면 0
|
renegotiation_status: number // 1=심사대기 2=승인 3=반려 4=철회, 이력 없으면 0
|
||||||
renegotiation_memo: string // 담당자 심사 메모(반려 사유)
|
renegotiation_memo: string // 담당자 심사 메모(반려 사유)
|
||||||
result: number // 협상 결과(SessionResult): 0=미정 1=낙찰 2=미낙찰 3=결렬(개찰)
|
result: number // 협상 결과(SessionResult): 0=미정 1=낙찰 2=미낙찰 3=결렬(개찰)
|
||||||
|
has_chat: boolean // 대화 이력 존재 여부 — 종료된 협상의 '결과 보기' 노출 판단용
|
||||||
|
reject_reason: string // 협상 거부 시 제출한 사유. 거부 건이 아니면 ''
|
||||||
|
reject_price?: number | null // 거부와 함께 낸 공급 희망 가격(원). 미입력이면 null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 공급사 관점 협상 결과 (sessions 파생) */
|
/** 공급사 관점 협상 결과 (sessions 파생) */
|
||||||
@ -118,10 +121,13 @@ export interface ParticipateResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- 거부 (POST /v1/negotiation/sessions/{id}/reject) ---------------------
|
// --- 거부 (POST /v1/negotiation/sessions/{id}/reject) ---------------------
|
||||||
// reject_reason: 프리셋(단종/품절) 라벨 또는 '기타' 직접 입력 텍스트.
|
// 목록의 협상 거부와 채팅 중 협상 거부가 같은 폼(components/RejectPopup)·같은 엔드포인트를 쓴다.
|
||||||
// (백엔드 sessions.reject_reason 컬럼에 대응. 엔드포인트는 백엔드 추가 예정)
|
// reject_reason: 프리셋(단종/품절) 라벨 또는 '기타' 직접 입력 텍스트. 필수.
|
||||||
|
// reject_price/opinion: 선택 입력 — 빈 값이면 아예 보내지 않는다(opinion 은 sessions.custom 에 병합).
|
||||||
export interface RejectRequest {
|
export interface RejectRequest {
|
||||||
reject_reason: string
|
reject_reason: string
|
||||||
|
reject_price?: number
|
||||||
|
opinion?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RejectResponse {
|
export interface RejectResponse {
|
||||||
|
|||||||
81
frontend/src/components/RejectDetailPopup.tsx
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
200
frontend/src/components/RejectPopup.tsx
Normal file
@ -0,0 +1,200 @@
|
|||||||
|
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,3 +7,7 @@ export { Logo } from '@/components/Logo'
|
|||||||
export type { LogoProps, LogoVariant } from '@/components/Logo'
|
export type { LogoProps, LogoVariant } from '@/components/Logo'
|
||||||
export { ErrorPage } from '@/components/ErrorPage'
|
export { ErrorPage } from '@/components/ErrorPage'
|
||||||
export type { ErrorPageProps } 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,6 +7,8 @@ import { renderEmphasis } from '@/features/chat/lib/emphasis'
|
|||||||
import { Indicator } from '@/features/chat/components/templates/Indicator'
|
import { Indicator } from '@/features/chat/components/templates/Indicator'
|
||||||
import { Summary } from '@/features/chat/components/templates/Summary'
|
import { Summary } from '@/features/chat/components/templates/Summary'
|
||||||
import { BidSummary } from '@/features/chat/components/templates/BidSummary'
|
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 = '아이마켓코리아 (구매담당자)'
|
const AI_LABEL = '아이마켓코리아 (구매담당자)'
|
||||||
|
|
||||||
@ -57,6 +59,7 @@ function ChatList({ scrollRef }: { scrollRef: RefObject<HTMLDivElement | null> }
|
|||||||
{chats.map((message, index) => (
|
{chats.map((message, index) => (
|
||||||
<MessageItem key={message.chat_id || index} message={message} messages={chats} currentIndex={index} />
|
<MessageItem key={message.chat_id || index} message={message} messages={chats} currentIndex={index} />
|
||||||
))}
|
))}
|
||||||
|
<RejectedNotice />
|
||||||
{isLoading && <TypingBubble />}
|
{isLoading && <TypingBubble />}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@ -87,11 +90,16 @@ const MessageItem = memo(function MessageItem({
|
|||||||
}) {
|
}) {
|
||||||
const isBot = message.sender === 'bot'
|
const isBot = message.sender === 'bot'
|
||||||
|
|
||||||
// 직전이 reject 폼이면 사용자 답변 말풍선은 숨긴다(폼 자체가 답변을 담고 있음)
|
// 결렬 폼 답변은 제출 문자열(공급희망가격-…, 합의불가사유-…)이라 말풍선 대신 요약 카드로 낸다.
|
||||||
|
// 폼은 세션 종료와 함께 사라지므로 이 카드가 없으면 결렬 사유가 대화에 안 남는다.
|
||||||
if (!isBot && currentIndex > 0) {
|
if (!isBot && currentIndex > 0) {
|
||||||
const prev = messages[currentIndex - 1]
|
const prev = messages[currentIndex - 1]
|
||||||
if (prev?.bot_chat_type === 'rejectRSP' || prev?.bot_chat_type === 'rejectCM') {
|
if (prev?.bot_chat_type === 'rejectRSP' || prev?.bot_chat_type === 'rejectCM') {
|
||||||
return null
|
return (
|
||||||
|
<div className="mb-5 animate-fade-in">
|
||||||
|
<RejectSummary script={message.script || ''} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,10 +1,33 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { SessionStatus } from '@/apis'
|
||||||
import { useChatStore } from '@/features/chat/stores/useChatStore'
|
import { useChatStore } from '@/features/chat/stores/useChatStore'
|
||||||
|
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
|
||||||
import { ChatMessage } from '@/features/chat/components/ChatMessage'
|
import { ChatMessage } from '@/features/chat/components/ChatMessage'
|
||||||
import { UserButton } from '@/features/chat/components/UserButton'
|
import { UserButton } from '@/features/chat/components/UserButton'
|
||||||
|
import { RejectPopup } from '@/features/chat/components/popup/RejectPopup'
|
||||||
|
import { canRejectNegotiation, GO_TO_LIST_TEXT } from '@/features/chat/lib/userButtonConfig'
|
||||||
import { MobileStepBar } from '@/features/chat/components/MobileStepBar'
|
import { MobileStepBar } from '@/features/chat/components/MobileStepBar'
|
||||||
|
import type { UserButtonConfig } from '@/features/chat/types'
|
||||||
|
|
||||||
|
// 미참여·협상거부로 끝난 세션은 '결과 보기'(열람) 진입이라 입력을 잠근다.
|
||||||
|
// 마지막 봇 메시지가 입력을 요구하는 상태로 멈춰 있어도(중간 이탈) 서버가 전송을 막으므로 액션바는 복귀 버튼만 둔다.
|
||||||
|
const READ_ONLY_STATUSES: number[] = [SessionStatus.NOT_PARTICIPATED, SessionStatus.REJECTED]
|
||||||
|
const READ_ONLY_CONFIG: UserButtonConfig = { type: 'one-black', text: GO_TO_LIST_TEXT }
|
||||||
|
|
||||||
|
// 거부 가능한 세션 상태 — 백엔드 /reject 가 허용하는 범위(완료/미참여/거부는 불가)와 같다.
|
||||||
|
const REJECTABLE_STATUSES: number[] = [SessionStatus.CREATED, SessionStatus.IN_PROGRESS]
|
||||||
|
|
||||||
export function ChatSection() {
|
export function ChatSection() {
|
||||||
const { userButtonConfig } = useChatStore()
|
const { messages, userButtonConfig } = useChatStore()
|
||||||
|
const sessionStatus = useChatInitStore((s) => s.session_status)
|
||||||
|
const [isRejectOpen, setIsRejectOpen] = useState(false)
|
||||||
|
|
||||||
|
const isReadOnly = READ_ONLY_STATUSES.includes(sessionStatus)
|
||||||
|
const config = isReadOnly ? READ_ONLY_CONFIG : userButtonConfig
|
||||||
|
// 단종·품절처럼 대화로 풀 수 없는 사유는 봇의 결렬 선언을 기다릴 수 없다 — 진행 중에도 빠져나갈 길을 둔다.
|
||||||
|
const canReject =
|
||||||
|
!isReadOnly && REJECTABLE_STATUSES.includes(sessionStatus) && canRejectNegotiation(messages, userButtonConfig)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 flex-col w-full h-full min-h-0 bg-surface">
|
<div className="flex flex-1 flex-col w-full h-full min-h-0 bg-surface">
|
||||||
<MobileStepBar />
|
<MobileStepBar />
|
||||||
@ -12,8 +35,9 @@ export function ChatSection() {
|
|||||||
{/* 하단 액션 덱 */}
|
{/* 하단 액션 덱 */}
|
||||||
{/* safe-b: 홈 인디케이터에 입력 버튼이 가리지 않도록 하단 안전영역 확보 */}
|
{/* safe-b: 홈 인디케이터에 입력 버튼이 가리지 않도록 하단 안전영역 확보 */}
|
||||||
<div className="shrink-0 safe-b border-t border-border bg-white shadow-[0_-4px_20px_rgba(0,0,0,0.03)]">
|
<div className="shrink-0 safe-b border-t border-border bg-white shadow-[0_-4px_20px_rgba(0,0,0,0.03)]">
|
||||||
<UserButton {...userButtonConfig} />
|
<UserButton {...config} onReject={canReject ? () => setIsRejectOpen(true) : undefined} />
|
||||||
</div>
|
</div>
|
||||||
|
{isRejectOpen && <RejectPopup onClose={() => setIsRejectOpen(false)} />}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,23 +1,20 @@
|
|||||||
import { useState, useMemo } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
import { AlertTriangle } from 'lucide-react'
|
import { AlertTriangle } from 'lucide-react'
|
||||||
import { cn } from '@/lib'
|
import { cn } from '@/lib'
|
||||||
import { numberToKorean } from '@/features/chat/lib/koreanNumber'
|
import { numberToKorean } from '@/lib'
|
||||||
import { useChatStore } from '@/features/chat/stores/useChatStore'
|
import { useChatStore } from '@/features/chat/stores/useChatStore'
|
||||||
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
|
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
|
||||||
import { SelectRadio, SubmitButton } from '@/features/chat/components/templates/rejectControls'
|
import { SelectRadio, SubmitButton } from '@/features/chat/components/templates/rejectControls'
|
||||||
import { OtherReason } from '@/features/chat/components/templates/OtherReason'
|
import { OtherReason } from '@/features/chat/components/templates/OtherReason'
|
||||||
import { findRestoreScript, restoreRejectRSP, extractPart } from '@/features/chat/lib/rejectForm'
|
import {
|
||||||
|
findRestoreScript,
|
||||||
|
restoreRejectRSP,
|
||||||
|
extractPart,
|
||||||
|
REJECT_REASONS,
|
||||||
|
} from '@/features/chat/lib/rejectForm'
|
||||||
|
|
||||||
const MAX_PRICE = 999999999999999
|
const MAX_PRICE = 999999999999999
|
||||||
|
|
||||||
// 합의 불가 사유 프리셋 (rejectRSP 와 동일 목록)
|
|
||||||
const REASONS = [
|
|
||||||
{ value: '단가인상', text: "'원재료 가격 상승' 또는 '제조사 가격 인상'으로 요청한 공급가격을 맞출 수 없습니다." },
|
|
||||||
{ value: '수량', text: '주문 수량이 적어, 소량 생산 시 발생하는 제조비용으로 맞출 수 없습니다.' },
|
|
||||||
{ value: '단종', text: '현재 단종된 제품으로 물량 수급이 원활하지 않아 가격을 맞출 수 없습니다.' },
|
|
||||||
{ value: '품절', text: '해당 상품이 품절되어 납품할 수 없습니다.' },
|
|
||||||
]
|
|
||||||
|
|
||||||
// 통일된 협상 결렬 폼 — 최종 제안 단가 + 합의 불가 사유 + 의견.
|
// 통일된 협상 결렬 폼 — 최종 제안 단가 + 합의 불가 사유 + 의견.
|
||||||
// rejectRSP / rejectCM 두 유형 모두 이 폼 하나로 받는다(액션바 렌더).
|
// rejectRSP / rejectCM 두 유형 모두 이 폼 하나로 받는다(액션바 렌더).
|
||||||
export function RejectForm() {
|
export function RejectForm() {
|
||||||
@ -129,7 +126,7 @@ export function RejectForm() {
|
|||||||
<div className="flex w-full gap-3">
|
<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="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">
|
<div className="flex w-full flex-col mt-2 gap-2">
|
||||||
{REASONS.map((r) => (
|
{REJECT_REASONS.map((r) => (
|
||||||
<SelectRadio
|
<SelectRadio
|
||||||
key={r.value}
|
key={r.value}
|
||||||
text={r.text}
|
text={r.text}
|
||||||
|
|||||||
@ -17,7 +17,8 @@ const style = {
|
|||||||
'flex min-w-[120px] px-[28px] max-[1180px]:min-w-[100px] max-[1180px]:px-[20px] h-[46px] bg-white hover:bg-neutral-10 rounded-xl items-center justify-center text-sm font-bold text-neutral-80 border border-border cursor-pointer whitespace-nowrap transition-all ease-out active:scale-[0.98]',
|
'flex min-w-[120px] px-[28px] max-[1180px]:min-w-[100px] max-[1180px]:px-[20px] h-[46px] bg-white hover:bg-neutral-10 rounded-xl items-center justify-center text-sm font-bold text-neutral-80 border border-border cursor-pointer whitespace-nowrap transition-all ease-out active:scale-[0.98]',
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UserButton({ type, text, textList, priceErrorMessage }: UserButtonConfig) {
|
// onReject: 협상 거부 진입점. 넘어오면 버튼 덱 끝에 붙는다(입력 단계는 인풋이 넓어 좁은 화면에서 아랫줄로 wrap).
|
||||||
|
export function UserButton({ type, text, textList, priceErrorMessage, onReject }: UserButtonConfig & { onReject?: () => void }) {
|
||||||
if (type === '') return null
|
if (type === '') return null
|
||||||
|
|
||||||
// 부가정보 입력은 폼이라 가운데정렬 덱이 아니라 전체폭으로 편다. text = 저장 후 보낼 동의 문구.
|
// 부가정보 입력은 폼이라 가운데정렬 덱이 아니라 전체폭으로 편다. text = 저장 후 보낼 동의 문구.
|
||||||
@ -41,8 +42,13 @@ export function UserButton({ type, text, textList, priceErrorMessage }: UserButt
|
|||||||
// 입력 단계는 에러 말풍선이 위로 삐져나가야 해서 overflow 클리핑 제외 (버튼 덱만 가로 스크롤 허용)
|
// 입력 단계는 에러 말풍선이 위로 삐져나가야 해서 overflow 클리핑 제외 (버튼 덱만 가로 스크롤 허용)
|
||||||
const isInputStep = type === 'percent' || type === 'price'
|
const isInputStep = type === 'percent' || type === 'price'
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex w-full justify-center px-6 py-4 max-[1180px]:px-4', !isInputStep && 'overflow-x-auto')}>
|
<div
|
||||||
<div className="flex justify-center">
|
className={cn(
|
||||||
|
'relative flex w-full flex-wrap items-center justify-center gap-3 px-6 py-4 max-[1180px]:px-4',
|
||||||
|
!isInputStep && 'overflow-x-auto',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center gap-3">
|
||||||
{type === 'one-black' && <OneBlack text={text || '확인'} />}
|
{type === 'one-black' && <OneBlack text={text || '확인'} />}
|
||||||
{type === 'one-gray' && <OneGray text={text || '확인'} />}
|
{type === 'one-gray' && <OneGray text={text || '확인'} />}
|
||||||
{type === 'black-white' && <BlackWhite textList={[textList?.[0] || '예', textList?.[1] || '아니오']} />}
|
{type === 'black-white' && <BlackWhite textList={[textList?.[0] || '예', textList?.[1] || '아니오']} />}
|
||||||
@ -53,6 +59,19 @@ export function UserButton({ type, text, textList, priceErrorMessage }: UserButt
|
|||||||
{type === 'price' && <Price priceErrorMessage={priceErrorMessage} />}
|
{type === 'price' && <Price priceErrorMessage={priceErrorMessage} />}
|
||||||
{type === 'loading' && <LoadingDots />}
|
{type === 'loading' && <LoadingDots />}
|
||||||
</div>
|
</div>
|
||||||
|
{/* 오클릭 방지: 주 CTA 와 붙이지 않는다. 넓은 화면은 우측 끝 고정(가운데 CTA 는 그대로),
|
||||||
|
좁은 화면은 인풋 폭 때문에 같은 줄이 안 나오므로 wrap 되어 아랫줄로 내려간다. */}
|
||||||
|
{onReject && (
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
style.white,
|
||||||
|
'min-[1180px]:absolute min-[1180px]:right-8 min-[1180px]:top-1/2 min-[1180px]:-translate-y-1/2',
|
||||||
|
)}
|
||||||
|
onClick={onReject}
|
||||||
|
>
|
||||||
|
협상 거부
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
35
frontend/src/features/chat/components/popup/RejectPopup.tsx
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { useNavigate } from 'react-router'
|
||||||
|
import { RejectPopup as RejectPopupForm } from '@/components'
|
||||||
|
import { getApiErrorMessage, useRejectMutation } from '@/apis'
|
||||||
|
import { toast } from '@/lib'
|
||||||
|
import { useChatStore } from '@/features/chat/stores/useChatStore'
|
||||||
|
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
|
||||||
|
|
||||||
|
// 협상 진행 중 거부 — 폼은 목록 거부와 공용이고, 여기서는 대화 세션에 붙여 제출·이탈만 처리한다.
|
||||||
|
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)
|
||||||
|
|
||||||
|
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, '거부 처리에 실패했습니다.')),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { CheckCircle2 } from 'lucide-react'
|
import { CheckCircle2 } from 'lucide-react'
|
||||||
import { numberToKorean } from '@/features/chat/lib/koreanNumber'
|
import { numberToKorean } from '@/lib'
|
||||||
import { useMeQuery } from '@/apis'
|
import { useMeQuery } from '@/apis'
|
||||||
import type { SessionField } from '@/apis/auth/auth.type'
|
import type { SessionField } from '@/apis/auth/auth.type'
|
||||||
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
|
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
|
||||||
|
|||||||
@ -0,0 +1,52 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,56 @@
|
|||||||
|
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 { CheckCircle2 } from 'lucide-react'
|
||||||
import { numberToKorean } from '@/features/chat/lib/koreanNumber'
|
import { numberToKorean } from '@/lib'
|
||||||
import { formatLeadTime } from '@/features/chat/lib/format'
|
import { formatLeadTime } from '@/features/chat/lib/format'
|
||||||
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
|
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
|
||||||
import type { ChatSummary } from '@/features/chat/types'
|
import type { ChatSummary } from '@/features/chat/types'
|
||||||
|
|||||||
@ -24,8 +24,9 @@ const TERMINAL_CODES = new Set<number>([
|
|||||||
ErrorCode.NEGO_NOT_FOUND,
|
ErrorCode.NEGO_NOT_FOUND,
|
||||||
])
|
])
|
||||||
|
|
||||||
// 진입 로드(init/messages) 시 '잘못된 접근'으로 볼 코드 → 권한 없음 / 없는(또는 남의) 세션 /
|
// 진입 로드(init/messages) 시 '잘못된 접근'으로 볼 코드 → 권한 없음 / 없는(또는 남의) 세션 / 진입 불가 상태.
|
||||||
// 진입 불가 상태(미참여·거부). 토스트 안내 후 목록으로 복귀시킨다. (인증≠인가 — 로그아웃하지 않는다)
|
// 토스트 안내 후 목록으로 복귀시킨다. (인증≠인가 — 로그아웃하지 않는다)
|
||||||
|
// 종료된 세션(완료·미참여·거부)은 열람 진입이 허용되므로 여기 걸리지 않는다 — 입력만 잠긴다(ChatSection).
|
||||||
const INVALID_ACCESS_CODES = new Set<number>([
|
const INVALID_ACCESS_CODES = new Set<number>([
|
||||||
ErrorCode.NEGO_FORBIDDEN,
|
ErrorCode.NEGO_FORBIDDEN,
|
||||||
ErrorCode.NEGO_NOT_FOUND,
|
ErrorCode.NEGO_NOT_FOUND,
|
||||||
@ -115,8 +116,8 @@ export function useChatController(sessionId: string) {
|
|||||||
if (data.message) s.appendMessage(mapMessage(data.message))
|
if (data.message) s.appendMessage(mapMessage(data.message))
|
||||||
s.setIsLoading(false)
|
s.setIsLoading(false)
|
||||||
// 협상 종료 전이(완료/거부 등): 목록 캐시만 무효화해 /list 복귀 시 최신 상태를 보장한다.
|
// 협상 종료 전이(완료/거부 등): 목록 캐시만 무효화해 /list 복귀 시 최신 상태를 보장한다.
|
||||||
// init 은 '진입 메타'라 화면에서 재조회하지 않는다 — 재조회하면 미참여/거부 진입 게이트(1301)에
|
// init 은 '진입 메타'라 화면에서 재조회하지 않는다 — 방금 종료한 화면의 입력이 열람 모드로
|
||||||
// 걸려 방금 정상 종료한 사용자가 튕겨난다. 재진입 시 최신 status 는 언마운트의 removeQueries 가 보장한다.
|
// 갈아치워지지 않게 한다. 재진입 시 최신 status 는 언마운트의 removeQueries 가 보장한다.
|
||||||
if (data.session_status !== SessionStatus.IN_PROGRESS || data.message?.chat_end) {
|
if (data.session_status !== SessionStatus.IN_PROGRESS || data.message?.chat_end) {
|
||||||
queryClient.invalidateQueries({ queryKey: negotiationKeys.sessions() })
|
queryClient.invalidateQueries({ queryKey: negotiationKeys.sessions() })
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,13 @@
|
|||||||
import type { ChatMessage } from '@/features/chat/types'
|
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)을 찾아 복원용으로 반환
|
// reject 폼 직전 사용자 답변(script)을 찾아 복원용으로 반환
|
||||||
export function findRestoreScript(messages: ChatMessage[], type: 'rejectCM' | 'rejectRSP'): string | null {
|
export function findRestoreScript(messages: ChatMessage[], type: 'rejectCM' | 'rejectRSP'): string | null {
|
||||||
const reversed = [...messages].reverse()
|
const reversed = [...messages].reverse()
|
||||||
@ -22,3 +30,28 @@ export function restoreRejectRSP(script: string | null) {
|
|||||||
if (reasonRaw.startsWith('기타-')) return { price, selectedReason: '기타', reason: reasonRaw.replace('기타-', '') }
|
if (reasonRaw.startsWith('기타-')) return { price, selectedReason: '기타', reason: reasonRaw.replace('기타-', '') }
|
||||||
return { price, selectedReason: reasonRaw, reason: '' }
|
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 }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@ -2,6 +2,17 @@ import type { ChatMessage, UserButtonConfig } from '@/features/chat/types'
|
|||||||
|
|
||||||
export const GO_TO_LIST_TEXT = '상품 목록으로 가기'
|
export const GO_TO_LIST_TEXT = '상품 목록으로 가기'
|
||||||
|
|
||||||
|
// 협상 거부 진입점을 숨기는 액션바 상태.
|
||||||
|
// reject=봇이 이미 결렬을 선언(그 폼이 최종가를 받는다) / extra-info=타결 후 동의 단계 / loading=응답 대기.
|
||||||
|
const NO_REJECT_TYPES = ['', 'loading', 'reject', 'extra-info']
|
||||||
|
|
||||||
|
// 채팅 안에서 협상을 거부할 수 있는 상태인지 — 대화가 끝나지 않고 협력사 입력을 기다리는 동안만.
|
||||||
|
export function canRejectNegotiation(messages: ChatMessage[], config: UserButtonConfig): boolean {
|
||||||
|
if (!messages || messages.length === 0) return false
|
||||||
|
if (messages[messages.length - 1].chat_end) return false
|
||||||
|
return !NO_REJECT_TYPES.includes(config.type)
|
||||||
|
}
|
||||||
|
|
||||||
// 마지막 봇 메시지의 next_input_mode 로 하단 입력 UI 구성을 결정한다.
|
// 마지막 봇 메시지의 next_input_mode 로 하단 입력 UI 구성을 결정한다.
|
||||||
export function deriveUserButtonConfig(
|
export function deriveUserButtonConfig(
|
||||||
messages: ChatMessage[],
|
messages: ChatMessage[],
|
||||||
|
|||||||
@ -32,6 +32,8 @@ const initialState: ChatInitData = {
|
|||||||
quotation_end_time: '',
|
quotation_end_time: '',
|
||||||
custom: {},
|
custom: {},
|
||||||
labels: {},
|
labels: {},
|
||||||
|
reject_reason: '',
|
||||||
|
reject_price: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useChatInitStore = create<ChatInitStore>((set) => ({
|
export const useChatInitStore = create<ChatInitStore>((set) => ({
|
||||||
|
|||||||
@ -86,4 +86,6 @@ export type ChatInitData = {
|
|||||||
quotation_end_time: string
|
quotation_end_time: string
|
||||||
custom: Record<string, unknown> // 협상완료 부가정보 기존 입력값(프리필용)
|
custom: Record<string, unknown> // 협상완료 부가정보 기존 입력값(프리필용)
|
||||||
labels: Record<string, string> // 회사 커스텀 라벨(companies.settings.labels). 상품 상세 필드명 치환용
|
labels: Record<string, string> // 회사 커스텀 라벨(companies.settings.labels). 상품 상세 필드명 치환용
|
||||||
|
reject_reason: string // 협상 거부로 끝난 세션의 제출 사유(의견은 custom.opinion)
|
||||||
|
reject_price: number | null // 거부와 함께 낸 공급 희망 가격(원)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -29,7 +29,7 @@ export function GuidePopup({ onClose }: { onClose: () => void }) {
|
|||||||
<Row badge="협상 중" tone="prog" text="가격을 조율하는 중입니다. 이어서 진행하세요." />
|
<Row badge="협상 중" tone="prog" text="가격을 조율하는 중입니다. 이어서 진행하세요." />
|
||||||
<Row badge="협상 완료" tone="done" text="가격 제출을 마쳤습니다. 최종 결과는 견적 마감 후 아래 '결과'로 표시됩니다." />
|
<Row badge="협상 완료" tone="done" text="가격 제출을 마쳤습니다. 최종 결과는 견적 마감 후 아래 '결과'로 표시됩니다." />
|
||||||
<Row badge="협상 미참여" tone="none" text="기한 내 참여하지 않아 종료된 건입니다." />
|
<Row badge="협상 미참여" tone="none" text="기한 내 참여하지 않아 종료된 건입니다." />
|
||||||
<Row badge="협상 거절" tone="reject" text="내가 참여를 거절한 건입니다." />
|
<Row badge="협상 거부" tone="reject" text="내가 거부한 건입니다." />
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section title="협상 결과" desc="견적이 마감된 뒤 정해지는 낙찰 결과입니다. 마감 전에는 표시되지 않습니다.">
|
<Section title="협상 결과" desc="견적이 마감된 뒤 정해지는 낙찰 결과입니다. 마감 전에는 표시되지 않습니다.">
|
||||||
|
|||||||
@ -1,124 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -2,7 +2,7 @@ import { Loader2, MessageSquareText } from 'lucide-react'
|
|||||||
import { cn, formatKstDateTime } from '@/lib'
|
import { cn, formatKstDateTime } from '@/lib'
|
||||||
import { RENEGO_STATUS_LABEL } from '@/apis/negotiation/negotiation.type'
|
import { RENEGO_STATUS_LABEL } from '@/apis/negotiation/negotiation.type'
|
||||||
import type { ListItem } from '@/features/list/types'
|
import type { ListItem } from '@/features/list/types'
|
||||||
import { statusMeta, RESULT_META } from '@/features/list/lib/status'
|
import { statusMeta, canEnterChat, isEndedStatus, RESULT_META } from '@/features/list/lib/status'
|
||||||
|
|
||||||
interface WorkspaceCardsProps {
|
interface WorkspaceCardsProps {
|
||||||
items: ListItem[]
|
items: ListItem[]
|
||||||
@ -13,10 +13,11 @@ interface WorkspaceCardsProps {
|
|||||||
onExtraInfo: (item: ListItem) => void
|
onExtraInfo: (item: ListItem) => void
|
||||||
onRenegotiate: (item: ListItem) => void
|
onRenegotiate: (item: ListItem) => void
|
||||||
onMemo: (item: ListItem) => void
|
onMemo: (item: ListItem) => void
|
||||||
|
onRejectDetail: (item: ListItem) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
// 모바일(lg 미만) 협상 목록: 테이블 대신 카드 스택.
|
// 모바일(lg 미만) 협상 목록: 테이블 대신 카드 스택.
|
||||||
export function WorkspaceCards({ items, isLoading, busyId, onEnter, onReject, onExtraInfo, onRenegotiate, onMemo }: WorkspaceCardsProps) {
|
export function WorkspaceCards({ items, isLoading, busyId, onEnter, onReject, onExtraInfo, onRenegotiate, onMemo, onRejectDetail }: WorkspaceCardsProps) {
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-16">
|
<div className="flex items-center justify-center py-16">
|
||||||
@ -31,7 +32,7 @@ export function WorkspaceCards({ items, isLoading, busyId, onEnter, onReject, on
|
|||||||
return (
|
return (
|
||||||
<div className="divide-y divide-border">
|
<div className="divide-y divide-border">
|
||||||
{items.map((item) => (
|
{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} />
|
<Card key={item.session_id} item={item} busy={busyId === item.session_id} onEnter={onEnter} onReject={onReject} onExtraInfo={onExtraInfo} onRenegotiate={onRenegotiate} onMemo={onMemo} onRejectDetail={onRejectDetail} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@ -45,6 +46,7 @@ function Card({
|
|||||||
onExtraInfo,
|
onExtraInfo,
|
||||||
onRenegotiate,
|
onRenegotiate,
|
||||||
onMemo,
|
onMemo,
|
||||||
|
onRejectDetail,
|
||||||
}: {
|
}: {
|
||||||
item: ListItem
|
item: ListItem
|
||||||
busy: boolean
|
busy: boolean
|
||||||
@ -53,12 +55,16 @@ function Card({
|
|||||||
onExtraInfo: (item: ListItem) => void
|
onExtraInfo: (item: ListItem) => void
|
||||||
onRenegotiate: (item: ListItem) => void
|
onRenegotiate: (item: ListItem) => void
|
||||||
onMemo: (item: ListItem) => void
|
onMemo: (item: ListItem) => void
|
||||||
|
onRejectDetail: (item: ListItem) => void
|
||||||
}) {
|
}) {
|
||||||
const meta = statusMeta(item.session_status)
|
const meta = statusMeta(item.session_status)
|
||||||
const canEnter = !['미참여', '협상거부'].includes(item.session_status)
|
const ended = isEndedStatus(item.session_status)
|
||||||
|
const canEnter = canEnterChat(item.session_status, item.hasChat)
|
||||||
const canReject = ['협상생성', '협상중'].includes(item.session_status)
|
const canReject = ['협상생성', '협상중'].includes(item.session_status)
|
||||||
|
// 거부 건은 제출 내역이 대화에 남지 않는다 — 세션에 적힌 사유를 여기서만 다시 볼 수 있다.
|
||||||
|
const isRejected = item.session_status === '협상거부'
|
||||||
const isDone = item.session_status === '협상완료'
|
const isDone = item.session_status === '협상완료'
|
||||||
const enterLabel = isDone ? '결과 보기' : '협상 입장'
|
const enterLabel = ended ? '결과 보기' : '협상 입장'
|
||||||
// 재협상: 요청 가능하면 버튼, 이미 요청했으면 진행 상태를 보여준다.
|
// 재협상: 요청 가능하면 버튼, 이미 요청했으면 진행 상태를 보여준다.
|
||||||
const renegoLabel = RENEGO_STATUS_LABEL[item.renegotiationStatus] ?? ''
|
const renegoLabel = RENEGO_STATUS_LABEL[item.renegotiationStatus] ?? ''
|
||||||
|
|
||||||
@ -112,8 +118,17 @@ function Card({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{(canEnter || canReject || isDone || item.renegotiable) && (
|
{(canEnter || canReject || isDone || isRejected || item.renegotiable) && (
|
||||||
<div className="mt-3 flex gap-2">
|
<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 && (
|
{item.renegotiable && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -138,7 +153,7 @@ function Card({
|
|||||||
onClick={() => onReject(item)}
|
onClick={() => onReject(item)}
|
||||||
className="flex-1 rounded-lg border border-border py-2 text-xs font-bold text-neutral-70 transition-all hover:bg-neutral-10 active:scale-[0.98]"
|
className="flex-1 rounded-lg border border-border py-2 text-xs font-bold text-neutral-70 transition-all hover:bg-neutral-10 active:scale-[0.98]"
|
||||||
>
|
>
|
||||||
거절
|
협상 거부
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{canEnter && (
|
{canEnter && (
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { Loader2, MessageSquareText } from 'lucide-react'
|
|||||||
import { cn, formatKstDateTime } from '@/lib'
|
import { cn, formatKstDateTime } from '@/lib'
|
||||||
import { RENEGO_STATUS_LABEL } from '@/apis/negotiation/negotiation.type'
|
import { RENEGO_STATUS_LABEL } from '@/apis/negotiation/negotiation.type'
|
||||||
import type { ListItem } from '@/features/list/types'
|
import type { ListItem } from '@/features/list/types'
|
||||||
import { statusMeta, RESULT_META } from '@/features/list/lib/status'
|
import { statusMeta, canEnterChat, isEndedStatus, RESULT_META } from '@/features/list/lib/status'
|
||||||
|
|
||||||
interface WorkspaceTableProps {
|
interface WorkspaceTableProps {
|
||||||
items: ListItem[]
|
items: ListItem[]
|
||||||
@ -14,13 +14,14 @@ interface WorkspaceTableProps {
|
|||||||
onExtraInfo: (item: ListItem) => void
|
onExtraInfo: (item: ListItem) => void
|
||||||
onRenegotiate: (item: ListItem) => void
|
onRenegotiate: (item: ListItem) => void
|
||||||
onMemo: (item: ListItem) => void
|
onMemo: (item: ListItem) => void
|
||||||
|
onRejectDetail: (item: ListItem) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
// th 기본 정렬은 center — 정렬은 베이스에 넣지 않고 컬럼마다 명시한다(cn 이 tailwind-merge 가 아니라 충돌 시 승자가 불명확).
|
// 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 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'
|
const CELL = 'px-5 py-4 align-middle text-sm text-neutral-80'
|
||||||
|
|
||||||
export function WorkspaceTable({ items, isLoading, busyId, onEnter, onReject, onExtraInfo, onRenegotiate, onMemo }: WorkspaceTableProps) {
|
export function WorkspaceTable({ items, isLoading, busyId, onEnter, onReject, onExtraInfo, onRenegotiate, onMemo, onRejectDetail }: WorkspaceTableProps) {
|
||||||
return (
|
return (
|
||||||
<div className="w-full overflow-x-auto">
|
<div className="w-full overflow-x-auto">
|
||||||
<table className="w-full min-w-[900px] border-collapse">
|
<table className="w-full min-w-[900px] border-collapse">
|
||||||
@ -46,7 +47,7 @@ export function WorkspaceTable({ items, isLoading, busyId, onEnter, onReject, on
|
|||||||
</StateRow>
|
</StateRow>
|
||||||
) : (
|
) : (
|
||||||
items.map((item) => (
|
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} />
|
<Row key={item.session_id} item={item} busy={busyId === item.session_id} onEnter={onEnter} onReject={onReject} onExtraInfo={onExtraInfo} onRenegotiate={onRenegotiate} onMemo={onMemo} onRejectDetail={onRejectDetail} />
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
@ -63,6 +64,7 @@ function Row({
|
|||||||
onExtraInfo,
|
onExtraInfo,
|
||||||
onRenegotiate,
|
onRenegotiate,
|
||||||
onMemo,
|
onMemo,
|
||||||
|
onRejectDetail,
|
||||||
}: {
|
}: {
|
||||||
item: ListItem
|
item: ListItem
|
||||||
busy: boolean
|
busy: boolean
|
||||||
@ -71,12 +73,16 @@ function Row({
|
|||||||
onExtraInfo: (item: ListItem) => void
|
onExtraInfo: (item: ListItem) => void
|
||||||
onRenegotiate: (item: ListItem) => void
|
onRenegotiate: (item: ListItem) => void
|
||||||
onMemo: (item: ListItem) => void
|
onMemo: (item: ListItem) => void
|
||||||
|
onRejectDetail: (item: ListItem) => void
|
||||||
}) {
|
}) {
|
||||||
const meta = statusMeta(item.session_status)
|
const meta = statusMeta(item.session_status)
|
||||||
const canEnter = !['미참여', '협상거부'].includes(item.session_status)
|
const ended = isEndedStatus(item.session_status)
|
||||||
|
const canEnter = canEnterChat(item.session_status, item.hasChat)
|
||||||
const canReject = ['협상생성', '협상중'].includes(item.session_status)
|
const canReject = ['협상생성', '협상중'].includes(item.session_status)
|
||||||
|
// 거부 건은 제출 내역이 대화에 남지 않는다 — 세션에 적힌 사유를 여기서만 다시 볼 수 있다.
|
||||||
|
const isRejected = item.session_status === '협상거부'
|
||||||
const isDone = item.session_status === '협상완료'
|
const isDone = item.session_status === '협상완료'
|
||||||
const enterLabel = isDone ? '결과 보기' : '협상 입장'
|
const enterLabel = ended ? '결과 보기' : '협상 입장'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr className="border-b border-border/60 transition-colors last:border-0 hover:bg-table-hover/70">
|
<tr className="border-b border-border/60 transition-colors last:border-0 hover:bg-table-hover/70">
|
||||||
@ -144,13 +150,22 @@ function Row({
|
|||||||
부가정보 보기
|
부가정보 보기
|
||||||
</button>
|
</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 && (
|
{canReject && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onReject(item)}
|
onClick={() => onReject(item)}
|
||||||
className="rounded-lg border border-border px-3 py-1.5 text-xs font-bold text-neutral-70 transition-all hover:bg-neutral-10 active:scale-[0.98]"
|
className="rounded-lg border border-border px-3 py-1.5 text-xs font-bold text-neutral-70 transition-all hover:bg-neutral-10 active:scale-[0.98]"
|
||||||
>
|
>
|
||||||
거절
|
협상 거부
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{canEnter ? (
|
{canEnter ? (
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import {
|
|||||||
useRequestRenegotiationMutation,
|
useRequestRenegotiationMutation,
|
||||||
useSaveExtraInfoMutation,
|
useSaveExtraInfoMutation,
|
||||||
} from '@/apis'
|
} from '@/apis'
|
||||||
|
import { RejectDetailPopup, RejectPopup, type RejectSubmitPayload } from '@/components'
|
||||||
import { cn, toast } from '@/lib'
|
import { cn, toast } from '@/lib'
|
||||||
import { useList } from '@/features/list/hooks/useList'
|
import { useList } from '@/features/list/hooks/useList'
|
||||||
import { useListStore } from '@/features/list/stores/useListStore'
|
import { useListStore } from '@/features/list/stores/useListStore'
|
||||||
@ -19,11 +20,11 @@ import { SortControl } from '@/features/list/components/SortControl'
|
|||||||
import { WorkspaceTable } from '@/features/list/components/WorkspaceTable'
|
import { WorkspaceTable } from '@/features/list/components/WorkspaceTable'
|
||||||
import { WorkspaceCards } from '@/features/list/components/WorkspaceCards'
|
import { WorkspaceCards } from '@/features/list/components/WorkspaceCards'
|
||||||
import { Pagination } from '@/features/list/components/Pagination'
|
import { Pagination } from '@/features/list/components/Pagination'
|
||||||
import { RejectPopup } from '@/features/list/components/RejectPopup'
|
|
||||||
import { ExtraInfoPopup } from '@/features/list/components/ExtraInfoPopup'
|
import { ExtraInfoPopup } from '@/features/list/components/ExtraInfoPopup'
|
||||||
import { RenegotiationPopup } from '@/features/list/components/RenegotiationPopup'
|
import { RenegotiationPopup } from '@/features/list/components/RenegotiationPopup'
|
||||||
import { RenegotiationMemoPopup } from '@/features/list/components/RenegotiationMemoPopup'
|
import { RenegotiationMemoPopup } from '@/features/list/components/RenegotiationMemoPopup'
|
||||||
import { GuidePopup } from '@/features/list/components/GuidePopup'
|
import { GuidePopup } from '@/features/list/components/GuidePopup'
|
||||||
|
import { isEndedStatus } from '@/features/list/lib/status'
|
||||||
import type { ListItem } from '@/features/list/types'
|
import type { ListItem } from '@/features/list/types'
|
||||||
|
|
||||||
// 상태별 거부 불가 안내
|
// 상태별 거부 불가 안내
|
||||||
@ -67,9 +68,15 @@ export function ListWorkspace() {
|
|||||||
const [extraTarget, setExtraTarget] = useState<ListItem | null>(null)
|
const [extraTarget, setExtraTarget] = useState<ListItem | null>(null)
|
||||||
const [renegoTarget, setRenegoTarget] = useState<ListItem | null>(null)
|
const [renegoTarget, setRenegoTarget] = useState<ListItem | null>(null)
|
||||||
const [memoTarget, setMemoTarget] = useState<ListItem | null>(null)
|
const [memoTarget, setMemoTarget] = useState<ListItem | null>(null)
|
||||||
|
const [rejectDetailTarget, setRejectDetailTarget] = useState<ListItem | null>(null)
|
||||||
const [guideOpen, setGuideOpen] = useState(false)
|
const [guideOpen, setGuideOpen] = useState(false)
|
||||||
|
|
||||||
const handleEnter = (item: ListItem) => {
|
const handleEnter = (item: ListItem) => {
|
||||||
|
// 종료 건(완료·미참여·거부)은 열람 전용 — 참여 API 는 미참여/거부를 막으므로 바로 채팅으로 보낸다.
|
||||||
|
if (isEndedStatus(item.session_status)) {
|
||||||
|
navigate(`/chat?session_id=${item.session_id}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
setEnteringId(item.session_id)
|
setEnteringId(item.session_id)
|
||||||
participate.mutate(item.session_id, {
|
participate.mutate(item.session_id, {
|
||||||
onSuccess: () => navigate(`/chat?session_id=${item.session_id}`),
|
onSuccess: () => navigate(`/chat?session_id=${item.session_id}`),
|
||||||
@ -89,13 +96,16 @@ export function ListWorkspace() {
|
|||||||
setRejectTarget(item)
|
setRejectTarget(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleRejectSubmit = (reason: string) => {
|
const handleRejectSubmit = (request: RejectSubmitPayload) => {
|
||||||
if (!rejectTarget) return
|
if (!rejectTarget) return
|
||||||
reject.mutate(
|
reject.mutate(
|
||||||
{ sessionId: rejectTarget.session_id, request: { reject_reason: reason } },
|
{ sessionId: rejectTarget.session_id, request },
|
||||||
{
|
{
|
||||||
onSuccess: () => toast.warning('참여 거절이 완료되었습니다.'),
|
onSuccess: () => {
|
||||||
onError: (error) => toast.error(getApiErrorMessage(error, '거절 처리에 실패했습니다.')),
|
setRejectTarget(null)
|
||||||
|
toast.warning('협상 거부가 완료되었습니다.')
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(getApiErrorMessage(error, '거부 처리에 실패했습니다.')),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -184,6 +194,7 @@ export function ListWorkspace() {
|
|||||||
onExtraInfo={setExtraTarget}
|
onExtraInfo={setExtraTarget}
|
||||||
onRenegotiate={setRenegoTarget}
|
onRenegotiate={setRenegoTarget}
|
||||||
onMemo={setMemoTarget}
|
onMemo={setMemoTarget}
|
||||||
|
onRejectDetail={setRejectDetailTarget}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="lg:hidden">
|
<div className="lg:hidden">
|
||||||
@ -196,6 +207,7 @@ export function ListWorkspace() {
|
|||||||
onExtraInfo={setExtraTarget}
|
onExtraInfo={setExtraTarget}
|
||||||
onRenegotiate={setRenegoTarget}
|
onRenegotiate={setRenegoTarget}
|
||||||
onMemo={setMemoTarget}
|
onMemo={setMemoTarget}
|
||||||
|
onRejectDetail={setRejectDetailTarget}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -207,7 +219,11 @@ export function ListWorkspace() {
|
|||||||
{guideOpen && <GuidePopup onClose={() => setGuideOpen(false)} />}
|
{guideOpen && <GuidePopup onClose={() => setGuideOpen(false)} />}
|
||||||
|
|
||||||
{rejectTarget && (
|
{rejectTarget && (
|
||||||
<RejectPopup onClose={() => setRejectTarget(null)} onSubmit={handleRejectSubmit} />
|
<RejectPopup
|
||||||
|
onClose={() => setRejectTarget(null)}
|
||||||
|
onSubmit={handleRejectSubmit}
|
||||||
|
isPending={reject.isPending}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{extraTarget && (
|
{extraTarget && (
|
||||||
@ -222,6 +238,16 @@ 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 && (
|
{memoTarget && (
|
||||||
<RenegotiationMemoPopup target={memoTarget} onClose={() => setMemoTarget(null)} />
|
<RenegotiationMemoPopup target={memoTarget} onClose={() => setMemoTarget(null)} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -42,5 +42,8 @@ export function toListItem(api: SessionListItem): ListItem {
|
|||||||
renegotiationStatus: api.renegotiation_status ?? 0,
|
renegotiationStatus: api.renegotiation_status ?? 0,
|
||||||
renegotiationMemo: api.renegotiation_memo ?? '',
|
renegotiationMemo: api.renegotiation_memo ?? '',
|
||||||
result: api.result ?? 0,
|
result: api.result ?? 0,
|
||||||
|
hasChat: api.has_chat ?? false,
|
||||||
|
rejectReason: api.reject_reason ?? '',
|
||||||
|
rejectPrice: api.reject_price ?? null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,13 +11,25 @@ export const STATUS_META: Record<string, StatusMeta> = {
|
|||||||
협상중: { display: '협상 중', badge: 'bg-[#FFF3E5] text-[#F5A623]', dot: 'bg-[#F5A623]' },
|
협상중: { display: '협상 중', badge: 'bg-[#FFF3E5] text-[#F5A623]', dot: 'bg-[#F5A623]' },
|
||||||
협상완료: { display: '협상 완료', badge: 'bg-[#EAFDF3] text-success', dot: 'bg-success' },
|
협상완료: { display: '협상 완료', badge: 'bg-[#EAFDF3] text-success', dot: 'bg-success' },
|
||||||
미참여: { display: '협상 미참여', badge: 'bg-neutral-20 text-neutral-60', dot: 'bg-neutral-60' },
|
미참여: { display: '협상 미참여', badge: 'bg-neutral-20 text-neutral-60', dot: 'bg-neutral-60' },
|
||||||
협상거부: { display: '협상 거절', badge: 'bg-[#FFEBEB] text-[#FF4D4F]', dot: 'bg-[#FF4D4F]' },
|
협상거부: { display: '협상 거부', badge: 'bg-[#FFEBEB] text-[#FF4D4F]', dot: 'bg-[#FF4D4F]' },
|
||||||
}
|
}
|
||||||
|
|
||||||
export function statusMeta(label: string): StatusMeta {
|
export function statusMeta(label: string): StatusMeta {
|
||||||
return STATUS_META[label] ?? { display: label || '-', badge: 'bg-neutral-20 text-neutral-60', dot: 'bg-neutral-60' }
|
return STATUS_META[label] ?? { display: label || '-', badge: 'bg-neutral-20 text-neutral-60', dot: 'bg-neutral-60' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 종료 상태 — 대화를 이어갈 수 없고 지난 이력만 '결과 보기'로 열람한다(백엔드 send 도 협상중만 허용).
|
||||||
|
const ENDED_STATUSES = ['협상완료', '미참여', '협상거부']
|
||||||
|
|
||||||
|
export function isEndedStatus(label: string): boolean {
|
||||||
|
return ENDED_STATUSES.includes(label)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 채팅 진입 가능 여부. 진행 건은 항상, 종료 건은 볼 대화가 있을 때만 — 초청만 받고 끝난 건은 열어도 빈 화면이다.
|
||||||
|
export function canEnterChat(label: string, hasChat: boolean): boolean {
|
||||||
|
return !isEndedStatus(label) || hasChat
|
||||||
|
}
|
||||||
|
|
||||||
// 협상 결과(SessionResult 코드) → 완료 건에 붙는 결과 배지. 0(미정)은 표시하지 않는다.
|
// 협상 결과(SessionResult 코드) → 완료 건에 붙는 결과 배지. 0(미정)은 표시하지 않는다.
|
||||||
// 결렬(3)은 재협상 요청 대상이라 눈에 띄게 노랑으로 둔다.
|
// 결렬(3)은 재협상 요청 대상이라 눈에 띄게 노랑으로 둔다.
|
||||||
export const RESULT_META: Record<number, { label: string; badge: string }> = {
|
export const RESULT_META: Record<number, { label: string; badge: string }> = {
|
||||||
|
|||||||
@ -13,4 +13,7 @@ export type ListItem = {
|
|||||||
renegotiationStatus: number // 0=없음 1=심사중 2=승인 3=반려 4=철회
|
renegotiationStatus: number // 0=없음 1=심사중 2=승인 3=반려 4=철회
|
||||||
renegotiationMemo: string // 담당자 심사 메모(반려 사유)
|
renegotiationMemo: string // 담당자 심사 메모(반려 사유)
|
||||||
result: number // 협상 결과: 0=미정 1=낙찰 2=미낙찰 3=결렬(개찰)
|
result: number // 협상 결과: 0=미정 1=낙찰 2=미낙찰 3=결렬(개찰)
|
||||||
|
hasChat: boolean // 대화 이력 존재 — 종료된 협상의 '결과 보기' 노출 조건
|
||||||
|
rejectReason: string // 협상 거부로 끝난 건이 제출한 사유. 거부 건이 아니면 ''
|
||||||
|
rejectPrice: number | null // 거부와 함께 낸 공급 희망 가격(원)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,3 +4,4 @@ export type { ClassValue } from '@/lib/cn'
|
|||||||
export { interactive } from '@/lib/interactive'
|
export { interactive } from '@/lib/interactive'
|
||||||
export { toast } from '@/lib/toast'
|
export { toast } from '@/lib/toast'
|
||||||
export { formatKstDateTime, KST_TIME_ZONE } from '@/lib/datetime'
|
export { formatKstDateTime, KST_TIME_ZONE } from '@/lib/datetime'
|
||||||
|
export { numberToKorean } from '@/lib/koreanNumber'
|
||||||
|
|||||||
1
landing/.gitignore
vendored
@ -1,3 +1,4 @@
|
|||||||
node_modules
|
node_modules
|
||||||
build
|
build
|
||||||
.react-router
|
.react-router
|
||||||
|
.vercel
|
||||||
|
|||||||
114
landing/api/lead.ts
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
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,38 +10,112 @@
|
|||||||
src: url('/fonts/PretendardVariable.woff2') format('woff2');
|
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 …)만 쓴다.
|
* 색은 전부 여기서만 정의하고, 컴포넌트는 토큰 클래스(text-ink, bg-primary …)만 쓴다.
|
||||||
|
*
|
||||||
|
* 구조는 두 겹이다.
|
||||||
|
* - 다크 무대(stage) : 히어로·협상 데모. 협상이 벌어지는 순간에만 조명을 끈다.
|
||||||
|
* - 라이트 본문 : 나머지 섹션. 광고 유입 전환율을 위해 밝게 유지.
|
||||||
*/
|
*/
|
||||||
@theme {
|
@theme {
|
||||||
--font-sans: 'Pretendard Variable', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Apple SD Gothic Neo", "Malgun Gothic", sans-serif;
|
--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;
|
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
||||||
|
/* 영문 디스플레이 — 라벨·강조 전용. 한글 본문은 절대 이걸로 쓰지 않는다
|
||||||
|
(Playfair 는 한글 글리프가 없어 Pretendard 로 폴백되며 톤이 깨진다). */
|
||||||
|
--font-display: 'Playfair Display', 'Pretendard Variable', Georgia, serif;
|
||||||
|
|
||||||
/* 브랜드 */
|
/* ── 브랜드 액센트 ──────────────────────────────────────────────
|
||||||
--color-primary: #476EFF;
|
여기 3줄이 브랜드 색상각의 단일 소스다. 딥틸(#005961 / #00434A / #E3F4F5)로
|
||||||
--color-primary-deep: #2F52E0; /* hover */
|
되돌리려면 이 3줄만 갈아끼우면 되고, 나머지 토큰·컴포넌트는 손대지 않는다. */
|
||||||
--color-primary-soft: #ECF0FF; /* 연한 파랑 배경 — 봇 말풍선, 배지 */
|
--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; /* 연한 배경 — 에이전트 말풍선, 배지 */
|
||||||
|
|
||||||
/* 텍스트. ink 는 다크 섹션에선 배경색으로도 쓴다 */
|
/* ── 상대편(공급사) ────────────────────────────────────────────
|
||||||
--color-ink: #191F28;
|
협상은 두 주체가 매 턴 구분돼야 읽힌다. 민트는 블루와 색상각 70° 차이라
|
||||||
--color-ink-soft: #4E5968; /* 보조 본문 */
|
구분되면서도 같은 한색 계열이라 화면이 따로 놀지 않는다.
|
||||||
--color-ink-muted: #8B95A1; /* 캡션 */
|
NEGO WIZ 로고 민트(#0FFFD6)에서 왔다. */
|
||||||
--color-ink-faint: #B0B8C1; /* 플레이스홀더·각주 */
|
--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; /* 플레이스홀더·각주 */
|
||||||
|
|
||||||
/* 면·선 */
|
/* 면·선 */
|
||||||
--color-surface: #F9FAFB; /* 회색 섹션 배경 */
|
--color-surface: #F7F8FC; /* 회색 섹션 배경 */
|
||||||
--color-surface-neu: #F2F4F7; /* 뉴모피즘 히어로 전용 배경 */
|
--color-surface-neu: #F1F3F9; /* 보조 섹션 배경 */
|
||||||
--color-fill: #F2F4F6; /* 회색 채움 — 탭 트랙, 보조 버튼 */
|
--color-fill: #F0F2F8; /* 회색 채움 — 탭 트랙, 보조 버튼 */
|
||||||
--color-fill-hover: #EAECEF;
|
--color-fill-hover: #E6E9F2;
|
||||||
--color-line: #F2F4F6; /* 섹션 구분선 */
|
--color-line: #EEF0F6; /* 섹션 구분선 */
|
||||||
--color-line-strong: #E5E8EB; /* 카드 테두리 */
|
--color-line-strong: #E0E4EE; /* 카드 테두리 */
|
||||||
|
|
||||||
--color-positive: #0B8F57; /* 낙찰·성공 강조 */
|
--color-positive: #0B8F57; /* 낙찰·성공 강조 */
|
||||||
--color-positive-soft: #E8F7EF; /* 연한 초록 배경 — 낙찰 배지 */
|
--color-positive-soft: #E8F7EF; /* 연한 초록 배경 — 낙찰 배지 */
|
||||||
|
|
||||||
--color-accent: #8F52FF; /* 보라 강조 — 결과 탭·히어로 장식 */
|
--color-accent: #8F52FF; /* 보라 강조 — 결과 탭·히어로 장식 */
|
||||||
--color-accent-soft: #F4EFFF; /* 연한 보라 배경 */
|
--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 {
|
html {
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
import { useEffect, useRef, useState } from "react"
|
import { useEffect, useRef, useState } from "react"
|
||||||
import { AnimatePresence, motion } from "motion/react"
|
import { AnimatePresence, motion } from "motion/react"
|
||||||
import { Bot, Clock, Database, Handshake, Scale, TrendingUp, UserRound, type LucideIcon } from "lucide-react"
|
import { Clock, Database, Handshake, Scale, TrendingUp, UserRound, type LucideIcon } from "lucide-react"
|
||||||
|
|
||||||
import { Section } from "@/components/ui/section"
|
import { Section } from "@/components/ui/section"
|
||||||
import { SectionHeading } from "@/components/ui/section-heading"
|
import { CONTROL_GAP, SectionHeading } from "@/components/ui/section-heading"
|
||||||
import { Typography } from "@/components/ui/typography"
|
import { Typography } from "@/components/ui/typography"
|
||||||
import { useFadeUp } from "@/lib/motion"
|
import { useFadeUp } from "@/lib/motion"
|
||||||
|
|
||||||
@ -44,28 +44,31 @@ export function Comparison() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Section id="comparison">
|
<Section id="comparison">
|
||||||
<motion.div {...fadeUp} className="mb-12">
|
<motion.div {...fadeUp}>
|
||||||
<SectionHeading
|
<SectionHeading
|
||||||
align="center"
|
align="center"
|
||||||
eyebrow="WHY NEGOTIUM"
|
eyebrow="Before & After"
|
||||||
title="협상 방식을 바꾸면, 무엇이 달라질까요"
|
title="협상 방식을 바꾸면, 무엇이 달라질까요"
|
||||||
description="기존 가격 협상의 문제는 사람이 아니라 구조입니다."
|
description="기존 가격 협상의 문제는 사람이 아니라 구조입니다."
|
||||||
descriptionClassName="mt-4 text-[17px] max-w-2xl"
|
descriptionClassName="max-w-2xl"
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* 모드 토글 — 이용 가이드 탭과 같은 세그먼트 문법 */}
|
{/* 모드 토글 — 이용 가이드 탭과 같은 세그먼트 문법.
|
||||||
<motion.div {...toggleFadeUp} className="flex justify-center mb-14">
|
아래 간격(CONTROL_GAP)은 머리 간격보다 좁다. 토글은 자기가 조종하는
|
||||||
<div className="p-1 bg-fill rounded-[14px] inline-flex gap-1">
|
카드들과 한 덩어리로 읽혀야지, 제목에 붙으면 무엇을 바꾸는 스위치인지 흐려진다. */}
|
||||||
|
<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">
|
||||||
{MODES.map((m) => (
|
{MODES.map((m) => (
|
||||||
<button
|
<button
|
||||||
key={m.key}
|
key={m.key}
|
||||||
onClick={() => selectMode(m.key)}
|
onClick={() => selectMode(m.key)}
|
||||||
className={`px-5 py-2.5 rounded-[10px] text-xs sm:text-sm font-bold transition-all flex items-center gap-2 cursor-pointer ${
|
className={`px-7 py-3 rounded-full text-[15px] font-semibold transition-colors cursor-pointer ${
|
||||||
mode === m.key ? "bg-white text-primary shadow-sm" : "text-ink-soft hover:text-ink"
|
mode === m.key ? "bg-primary text-white" : "text-ink-muted hover:text-ink"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{m.key === "after" && <Bot className="w-4 h-4" />}
|
|
||||||
<span>{m.label}</span>
|
<span>{m.label}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@ -85,7 +88,7 @@ type Mode = "before" | "after"
|
|||||||
|
|
||||||
const MODES: { key: Mode; label: string }[] = [
|
const MODES: { key: Mode; label: string }[] = [
|
||||||
{ key: "before", label: "기존 방식" },
|
{ key: "before", label: "기존 방식" },
|
||||||
{ key: "after", label: "negotium 도입 후" },
|
{ key: "after", label: "네고시움 도입 후" },
|
||||||
]
|
]
|
||||||
|
|
||||||
type CompareItem = { label: string; icon: LucideIcon; before: string; after: string }
|
type CompareItem = { label: string; icon: LucideIcon; before: string; after: string }
|
||||||
@ -97,13 +100,13 @@ function CompareCard({ item, mode, index }: { item: CompareItem; mode: Mode; ind
|
|||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
{...fadeUp}
|
{...fadeUp}
|
||||||
className={`p-7 rounded-[28px] flex flex-col gap-5 transition-colors duration-500 ${
|
className={`p-7 rounded-card flex flex-col gap-5 transition-colors duration-500 ${
|
||||||
active ? "bg-primary-soft/60" : "bg-surface"
|
active ? "bg-primary-soft/60" : "bg-surface"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div
|
<div
|
||||||
className={`w-11 h-11 rounded-2xl flex items-center justify-center transition-colors duration-500 ${
|
className={`w-11 h-11 rounded-card flex items-center justify-center transition-colors duration-500 ${
|
||||||
active ? "bg-primary/10 text-primary" : "bg-ink-muted/10 text-ink-muted"
|
active ? "bg-primary/10 text-primary" : "bg-ink-muted/10 text-ink-muted"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@ -156,14 +159,14 @@ const ITEMS: CompareItem[] = [
|
|||||||
{
|
{
|
||||||
label: "사람",
|
label: "사람",
|
||||||
icon: UserRound,
|
icon: UserRound,
|
||||||
before: "반복 흥정과 감정 노동에 지쳐 고급 인력이 이탈합니다.",
|
before: "반복 협상에 시간을 뺏겨 전략 업무가 계속 밀립니다.",
|
||||||
after: "감정 소모는 봇이 맡고, 사람은 전략 업무와 대형 거래에 집중합니다.",
|
after: "반복 협상은 에이전트가 맡고, 담당자는 전략 구매와 대형 건에 집중합니다.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "파트너 관계",
|
label: "파트너 관계",
|
||||||
icon: Handshake,
|
icon: Handshake,
|
||||||
before: "감정 과열과 실랑이로 장기 파트너십이 흔들립니다.",
|
before: "담당자마다 기준이 달라 협력사가 조건을 예측하기 어렵습니다.",
|
||||||
after: "악역은 봇이 — 압박 없는 상시 협상으로 관계가 개선됩니다.",
|
after: "에이전트가 같은 기준으로 상시 응대해 조건이 일관됩니다.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "의사 결정",
|
label: "의사 결정",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { AnimatePresence, motion } from "motion/react"
|
import { AnimatePresence, motion } from "motion/react"
|
||||||
import { Building, CheckCircle2, Loader2, Mail, Phone, Send } from "lucide-react"
|
import { Check, Loader2 } from "lucide-react"
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input, Textarea } from "@/components/ui/input"
|
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 { SectionHeading } from "@/components/ui/section-heading"
|
||||||
import { Typography } from "@/components/ui/typography"
|
import { Typography } from "@/components/ui/typography"
|
||||||
import { EASE_OUT_EXPO } from "@/lib/motion"
|
import { EASE_OUT_EXPO } from "@/lib/motion"
|
||||||
import type { LucideIcon } from "lucide-react"
|
import { submitLead } from "@/lib/lead"
|
||||||
|
|
||||||
const EMPTY_FORM = {
|
const EMPTY_FORM = {
|
||||||
companyName: '',
|
companyName: '',
|
||||||
@ -18,19 +18,33 @@ const EMPTY_FORM = {
|
|||||||
message: '',
|
message: '',
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 도입 문의 폼. 백엔드 미연결 — 제출은 데모 처리(1.2초 후 성공 화면). */
|
/*
|
||||||
|
* 상담 신청 폼 — 고관여 경로. 저관여 리드 캡처는 데모 요청 모달이 맡는다.
|
||||||
|
*
|
||||||
|
* 예전에는 백엔드가 없어서 1.2초 뒤 성공 화면만 띄웠다. 화면은 "접수되었습니다"인데
|
||||||
|
* 영업이 받을 리드는 존재하지 않았고, 아무도 그 사실을 몰랐다. 지금은 데모 요청과
|
||||||
|
* 같은 서버리스 함수(api/lead.ts)로 보내고, 서버가 리드를 확보했을 때만 성공을 띄운다.
|
||||||
|
*/
|
||||||
export function Contact() {
|
export function Contact() {
|
||||||
const [formData, setFormData] = useState(EMPTY_FORM)
|
const [formData, setFormData] = useState(EMPTY_FORM)
|
||||||
const [status, setStatus] = useState<'idle' | 'submitting' | 'success'>('idle')
|
const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle')
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!formData.companyName || !formData.contactName || !formData.email || !formData.phone) {
|
if (!formData.companyName || !formData.contactName || !formData.email || !formData.phone) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setStatus('submitting')
|
setStatus('submitting')
|
||||||
setTimeout(() => setStatus('success'), 1200)
|
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')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||||
@ -41,22 +55,12 @@ export function Contact() {
|
|||||||
const submitting = status === 'submitting'
|
const submitting = status === 'submitting'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section
|
<Section id="contact-section" bordered>
|
||||||
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
|
<SectionHeading
|
||||||
align="center"
|
align="center"
|
||||||
className="mb-16"
|
eyebrow="Get in Touch"
|
||||||
eyebrow="GET IN TOUCH"
|
title="도입 문의"
|
||||||
title="도입 문의 및 컨택하기"
|
description="품목과 협력사 규모를 알려주시면, 예상 절감 구간과 도입 절차를 정리해 드립니다."
|
||||||
description="사내 ERP 연동부터 우리 기업에 맞춘 흥정 시나리오 구성까지, negotium의 구매 혁신 컨설턴트가 상세히 안내해 드립니다."
|
|
||||||
descriptionClassName="max-w-xl"
|
descriptionClassName="max-w-xl"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@ -70,10 +74,10 @@ export function Contact() {
|
|||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, y: -20 }}
|
exit={{ opacity: 0, y: -20 }}
|
||||||
transition={{ duration: 0.5, ease: EASE_OUT_EXPO }}
|
transition={{ duration: 0.5, ease: EASE_OUT_EXPO }}
|
||||||
className="space-y-6"
|
className="space-y-9"
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-9">
|
||||||
<FormField label="회사명" icon={Building} required>
|
<FormField label="회사명" required>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
name="companyName"
|
name="companyName"
|
||||||
@ -98,8 +102,8 @@ export function Contact() {
|
|||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-9">
|
||||||
<FormField label="이메일 주소" icon={Mail} required>
|
<FormField label="이메일 주소" required>
|
||||||
<Input
|
<Input
|
||||||
type="email"
|
type="email"
|
||||||
name="email"
|
name="email"
|
||||||
@ -111,7 +115,7 @@ export function Contact() {
|
|||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField label="연락처" icon={Phone} required>
|
<FormField label="연락처" required>
|
||||||
<Input
|
<Input
|
||||||
type="tel"
|
type="tel"
|
||||||
name="phone"
|
name="phone"
|
||||||
@ -128,30 +132,34 @@ export function Contact() {
|
|||||||
<Textarea
|
<Textarea
|
||||||
name="message"
|
name="message"
|
||||||
rows={4}
|
rows={4}
|
||||||
placeholder="현재 겪고 계신 구매 조율 상의 번거로움이나, 자동화를 원하시는 구체적인 부자재 품목 정보를 남겨주시면 더욱 맞춤화된 상담이 가능합니다."
|
placeholder="협상 자동화를 검토 중인 품목이나 현재 겪는 어려움을 적어주세요."
|
||||||
value={formData.message}
|
value={formData.message}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</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 variant="caption" className="font-medium">
|
||||||
* 제출 시 입력하신 정보는 문의 사항 응답 및 도입 검토 목적에 한하여 사용되며, 관련 법령에 의거하여 안전하게
|
입력하신 정보는 문의 응답과 도입 검토 목적으로만 사용됩니다.
|
||||||
보호됩니다.
|
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<div className="pt-2">
|
<div className="pt-2">
|
||||||
<Button type="submit" disabled={submitting} className="w-full shadow-sm hover:shadow-md">
|
<Button type="submit" disabled={submitting} size="lg" className="w-full">
|
||||||
{submitting ? (
|
{submitting ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="w-5 h-5 animate-spin" />
|
<Loader2 className="w-5 h-5 animate-spin" />
|
||||||
<span>신청서 전송 중...</span>
|
<span>신청서 전송 중...</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<span>상담 신청</span>
|
||||||
<Send className="w-5 h-5" />
|
|
||||||
<span>도입 문의 및 상담 신청하기</span>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@ -162,15 +170,14 @@ export function Contact() {
|
|||||||
initial={{ opacity: 0, scale: 0.95 }}
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
transition={{ duration: 0.6, ease: EASE_OUT_EXPO }}
|
transition={{ duration: 0.6, ease: EASE_OUT_EXPO }}
|
||||||
className="text-center py-16 px-6 bg-primary/5 rounded-[32px] text-ink break-keep"
|
className="text-center py-20 px-6 border border-line-strong rounded-card text-ink break-keep"
|
||||||
>
|
>
|
||||||
<div className="w-16 h-16 bg-primary/10 text-primary rounded-full flex items-center justify-center mx-auto mb-6">
|
<div className="w-12 h-12 bg-primary text-white rounded-full flex items-center justify-center mx-auto mb-6">
|
||||||
<CheckCircle2 className="w-8 h-8" />
|
<Check className="w-6 h-6" strokeWidth={2.5} />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-2xl font-extrabold text-ink mb-3">도입 문의 신청이 접수되었습니다</h3>
|
<h3 className="text-[24px] font-semibold tracking-[-0.02em] text-ink mb-3">메일 앱에서 문의 내용을 전송해 주세요</h3>
|
||||||
<p className="text-ink-soft font-semibold text-[15px] leading-relaxed max-w-md mx-auto mb-8">
|
<p className="text-ink-soft text-[15px] leading-[1.6] max-w-md mx-auto mb-8">
|
||||||
작성해주신 담당자 정보({formData.contactName} 님)를 통해 24시간 이내에 전담 혁신 컨설턴트가 유선 혹은 이메일로
|
도입 문의 메일 작성 창을 열었습니다. 내용을 확인하고 전송해 주시면, {formData.contactName} 님께 영업일 기준 하루 안에 연락드리겠습니다.
|
||||||
연락을 드리겠습니다.
|
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@ -190,22 +197,14 @@ export function Contact() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function FormField({
|
/* 라벨 옆 작은 아이콘은 정보를 더하지 않는다 — "회사명" 옆의 빌딩 아이콘이 알려주는 건
|
||||||
label,
|
이미 글자가 말한 것뿐이고, 필드마다 반복되면 폼이 산만해진다. 글자만 남긴다. */
|
||||||
icon: Icon,
|
function FormField({ label, required = false, children }: { label: string; required?: boolean; children: React.ReactNode }) {
|
||||||
required = false,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
label: string
|
|
||||||
icon?: LucideIcon
|
|
||||||
required?: boolean
|
|
||||||
children: React.ReactNode
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2.5">
|
||||||
<label className="text-xs font-bold text-ink-soft flex items-center gap-1.5">
|
<label className="block text-[14px] font-medium text-ink">
|
||||||
{Icon && <Icon className="w-3.5 h-3.5 text-ink-muted" />}
|
{label}
|
||||||
{label} {required && <span className="text-primary">*</span>}
|
{required && <span className="text-primary ml-1" aria-hidden>*</span>}
|
||||||
</label>
|
</label>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -12,13 +12,12 @@ export function CoreValues() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Section id="core-values">
|
<Section id="core-values">
|
||||||
<motion.div {...fadeUp} className="mb-24">
|
<motion.div {...fadeUp}>
|
||||||
<SectionHeading
|
<SectionHeading
|
||||||
align="center"
|
align="center"
|
||||||
eyebrow="BEYOND EXPECTED VALUE"
|
eyebrow="Beyond Price"
|
||||||
title="가격 그 이상의 이점"
|
title="가격 그 이상의 이점"
|
||||||
description="절감액은 시작일 뿐 — 관계, 투명성, 그리고 사람의 시간까지 지킵니다."
|
description="절감액은 시작일 뿐 — 관계, 투명성, 그리고 사람의 시간까지 지킵니다."
|
||||||
descriptionClassName="mt-4 text-[17px]"
|
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
@ -37,7 +36,7 @@ function ValueStrip({ value, delay }: { value: Value; delay: number }) {
|
|||||||
const fadeUp = useFadeUp(delay)
|
const fadeUp = useFadeUp(delay)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div {...fadeUp} className="bg-surface p-8 rounded-[32px] flex items-start gap-5 transition-all hover:translate-y-[-4px]">
|
<motion.div {...fadeUp} className="bg-surface p-8 rounded-card 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">
|
<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" />
|
<value.icon className="w-5 h-5" />
|
||||||
</div>
|
</div>
|
||||||
@ -56,7 +55,7 @@ const VALUES: Value[] = [
|
|||||||
icon: Users,
|
icon: Users,
|
||||||
title: "파트너사 관계 수호",
|
title: "파트너사 관계 수호",
|
||||||
description:
|
description:
|
||||||
"단가를 깎는 악역과 감정 소모는 봇이 맡습니다. 담당자는 협력사와의 신뢰와 동반 성장, 큰 틀의 파트너십에만 집중하세요.",
|
"단가 조율은 에이전트가 정해진 기준으로 반복합니다. 담당자는 협력사와의 신뢰와 동반 성장에 집중할 수 있습니다.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: Lock,
|
icon: Lock,
|
||||||
@ -68,7 +67,7 @@ const VALUES: Value[] = [
|
|||||||
icon: Briefcase,
|
icon: Briefcase,
|
||||||
title: "핵심 전략에만 집중",
|
title: "핵심 전략에만 집중",
|
||||||
description:
|
description:
|
||||||
"이메일·메신저로 반복되던 흥정 수작업은 봇이 전담합니다. 인력은 공급망 위기 대처, 우량 공급처 발굴 같은 전략 업무와 대형 거래에 투입하세요.",
|
"이메일·메신저로 오가던 단가 협의는 에이전트가 전담합니다. 인력은 공급망 위기 대응과 신규 공급처 발굴에 투입하세요.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: Clock,
|
icon: Clock,
|
||||||
|
|||||||
@ -14,13 +14,12 @@ export function Faq() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Section id="faq" width="sm">
|
<Section id="faq" width="sm">
|
||||||
<motion.div {...fadeUp} className="mb-24">
|
<motion.div {...fadeUp}>
|
||||||
<SectionHeading
|
<SectionHeading
|
||||||
align="center"
|
align="center"
|
||||||
eyebrow="FAQ"
|
eyebrow="FAQ"
|
||||||
title="자주 묻는 질문"
|
title="자주 묻는 질문"
|
||||||
description="도입 검토에서 가장 많이 받는 질문들입니다."
|
description="도입 검토에서 가장 많이 받는 질문들입니다."
|
||||||
descriptionClassName="mt-4 text-[17px]"
|
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
@ -55,7 +54,7 @@ function FaqItem({
|
|||||||
const fadeUp = useFadeUp(delay)
|
const fadeUp = useFadeUp(delay)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div {...fadeUp} className="bg-surface rounded-[28px] overflow-hidden">
|
<motion.div {...fadeUp} className="bg-surface rounded-card overflow-hidden">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
@ -96,14 +95,14 @@ function FaqItem({
|
|||||||
|
|
||||||
const FAQS: FaqEntry[] = [
|
const FAQS: FaqEntry[] = [
|
||||||
{
|
{
|
||||||
question: "공급사가 봇과의 협상을 싫어하지 않을까요?",
|
question: "협력사가 에이전트와의 협상을 꺼리지 않을까요?",
|
||||||
answer:
|
answer:
|
||||||
"오히려 반대입니다. 이 거래들 대부분은 지금껏 협상 테이블에 오르지도 못하던 건입니다. 봇은 24시간 원하는 시간에, 압박 없이, 늘 같은 기준으로 응대합니다. 글로벌 동종 서비스의 공급사 만족도는 82%에 이릅니다.",
|
"이 건들 대부분은 지금껏 협상 테이블에 오르지도 못하던 거래입니다. 에이전트는 협력사가 편한 시간에, 늘 같은 기준으로 응대합니다. 글로벌 동종 서비스의 공급사 만족도는 82%로 보고됩니다.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: "봇이 우리 기준을 벗어나 합의해 버리면요?",
|
question: "에이전트가 우리 기준을 벗어나 합의해 버리면요?",
|
||||||
answer:
|
answer:
|
||||||
"그럴 수 없습니다. 봇은 견적을 만들 때 정한 목표가와 낙찰 기준 밖으로 나가지 않고, 최종 낙찰 규칙도 사용자가 정합니다.",
|
"그럴 수 없습니다. 에이전트는 견적을 만들 때 정한 목표가와 낙찰 기준 밖으로 나가지 않고, 최종 낙찰 규칙도 사용자가 정합니다.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: "기존 시스템과 연동되나요?",
|
question: "기존 시스템과 연동되나요?",
|
||||||
|
|||||||
@ -2,58 +2,71 @@ import { motion } from "motion/react"
|
|||||||
import { ArrowRight } from "lucide-react"
|
import { ArrowRight } from "lucide-react"
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
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 — 다크 몰입 섹션. */
|
/**
|
||||||
|
* 최종 CTA — 히어로와 같은 다크 무대로 페이지를 닫는다.
|
||||||
|
*
|
||||||
|
* 이 섹션은 원래 디자인 시스템을 우회하고 있었다: bg-ink(무대 색이 아님), font-extrabold,
|
||||||
|
* 알약 배지, blur(120px) 글로우 블롭, 다크 배경 위 라이트 토큰(text-ink-muted).
|
||||||
|
* 전부 토큰으로 돌렸다. 글로우 블롭은 AI 가 만든 히어로의 대표 장식이라 걷어냈다.
|
||||||
|
*
|
||||||
|
* 섹션 껍데기와 머리도 손으로 짜던 걸 Section/SectionHeading 으로 돌렸다.
|
||||||
|
* 우회하는 동안 아이브로우 간격이 여기만 20px 이었다(다른 섹션은 12px).
|
||||||
|
*
|
||||||
|
* 전환을 요청하는 자리에 결과 화면을 같이 둔다. 이용 가이드는 "실제 화면으로 보여드립니다"
|
||||||
|
* 라고 약속해서 재구성 목업을 넣을 수 없지만, 여기는 그 약속이 없어 마케팅 컴포짓이 맞는 자리다.
|
||||||
|
*/
|
||||||
export function FinalCTA() {
|
export function FinalCTA() {
|
||||||
return (
|
const fadeUp = useFadeUp()
|
||||||
<section className="relative bg-ink text-white py-36 md:py-48 overflow-hidden">
|
const mediaFadeUp = useFadeUp(0.12)
|
||||||
{/* 다크 섹션 중앙의 은은한 파란 글로우 */}
|
|
||||||
<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="max-w-3xl mx-auto px-6 text-center relative z-10">
|
return (
|
||||||
<motion.div
|
/* 영상이 붙으면서 width 를 md(max-w-4xl) → lg(max-w-5xl) 로 넓혔다.
|
||||||
initial={{ opacity: 0, scale: 0.95 }}
|
세로 목업과 카피를 4xl 안에 2단으로 밀어넣으면 양쪽 다 좁아진다. */
|
||||||
whileInView={{ opacity: 1, scale: 1 }}
|
<Section bg="stage" width="lg">
|
||||||
viewport={{ once: true }}
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-12 lg:gap-16 items-center">
|
||||||
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"
|
<motion.div {...fadeUp} className="lg:col-span-7 text-center lg:text-left">
|
||||||
>
|
<SectionHeading
|
||||||
<span>START RECLAIMING YOUR BUDGET TODAY</span>
|
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"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<motion.h2
|
{/* 결과 화면. 예전엔 negotiation_annotated_3d.mp4 였는데 컴포넌트로 바꿨다 —
|
||||||
initial={{ opacity: 0, y: 24 }}
|
영상은 화자("아이마켓 구매 MD")와 색이 픽셀에 구워져 있어 둘 다 못 고쳤다.
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
다크 무대에 네이티브로 그리니 밝은 영상이 구멍처럼 뜨던 문제도 같이 사라져서,
|
||||||
viewport={{ once: true }}
|
카드로 감싸고 그림자로 띄우던 처리가 더는 필요 없다. */}
|
||||||
transition={{ duration: 0.8, delay: 0.1 }}
|
<motion.div {...mediaFadeUp} className="lg:col-span-5">
|
||||||
className="text-3xl sm:text-4xl md:text-[54px] font-extrabold tracking-tighter mb-8 text-white leading-[1.2] break-keep"
|
<NegotiationReplay className="mx-auto max-w-[360px] lg:max-w-none" />
|
||||||
>
|
|
||||||
지금도 구매 예산은 <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>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</Section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,7 +8,7 @@ export function Footer() {
|
|||||||
<div className="md:col-span-8">
|
<div className="md:col-span-8">
|
||||||
<Logo className="mb-6" />
|
<Logo className="mb-6" />
|
||||||
<Typography variant="caption" className="max-w-sm mb-6 font-medium">
|
<Typography variant="caption" className="max-w-sm mb-6 font-medium">
|
||||||
negotium은 협상 봇이 공급사와 1:1로 단가를 조율하고 낙찰까지 처리하는 B2B 구매 협상 자동화 솔루션입니다.
|
네고시움은 협상 에이전트가 공급사와 1:1로 단가를 조율하고 낙찰까지 처리하는 B2B 구매 협상 자동화 솔루션입니다.
|
||||||
</Typography>
|
</Typography>
|
||||||
<p className="text-xs text-ink-faint font-semibold">
|
<p className="text-xs text-ink-faint font-semibold">
|
||||||
© {new Date().getFullYear()} negotium Co., Ltd. All rights reserved.
|
© {new Date().getFullYear()} negotium Co., Ltd. All rights reserved.
|
||||||
|
|||||||
@ -5,36 +5,78 @@ import { Button } from "@/components/ui/button"
|
|||||||
import { Logo } from "@/components/ui/logo"
|
import { Logo } from "@/components/ui/logo"
|
||||||
|
|
||||||
export function Header() {
|
export function Header() {
|
||||||
const [scrolled, setScrolled] = useState(false)
|
// 두 상태는 조건이 다르다. 하나로 묶으면 다크 히어로 위에 흰 바가 떠버린다.
|
||||||
|
// compact — 조금이라도 스크롤하면 높이를 줄인다.
|
||||||
|
// onStage — 다크 히어로를 벗어나기 전까지. 이때는 배경을 깔지 않고 로고를 반전시킨다.
|
||||||
|
const [compact, setCompact] = useState(false)
|
||||||
|
const [onStage, setOnStage] = useState(true)
|
||||||
|
|
||||||
// 스크롤 시 헤더를 반투명 블러 배경으로 전환
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleScroll = () => setScrolled(window.scrollY > 40)
|
const handleScroll = () => setCompact(window.scrollY > 40)
|
||||||
window.addEventListener("scroll", handleScroll, { passive: true })
|
window.addEventListener("scroll", handleScroll, { passive: true })
|
||||||
handleScroll()
|
handleScroll()
|
||||||
return () => window.removeEventListener("scroll", 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 (
|
return (
|
||||||
<header
|
<header
|
||||||
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"
|
투명한 채로 두면 히어로 카피가 헤더 밑으로 지나가며 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"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="max-w-5xl mx-auto px-6 flex items-center justify-between">
|
<div className="max-w-5xl mx-auto px-6 flex items-center justify-between">
|
||||||
<a href="#">
|
<a href="#">
|
||||||
<Logo />
|
<Logo className={onStage ? "brightness-0 invert" : undefined} />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<nav className="hidden md:flex items-center gap-8 text-[15px] font-semibold text-ink-soft">
|
<nav
|
||||||
|
className={`hidden md:flex items-center gap-8 text-[15px] font-semibold transition-colors ${
|
||||||
|
onStage ? "text-on-stage-soft" : "text-ink-soft"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{NAV_LINKS.map(({ href, label }) => (
|
{NAV_LINKS.map(({ href, label }) => (
|
||||||
<a key={href} href={href} className="hover:text-ink transition-colors">
|
<a
|
||||||
|
key={href}
|
||||||
|
href={href}
|
||||||
|
className={`transition-colors ${onStage ? "hover:text-on-stage" : "hover:text-ink"}`}
|
||||||
|
>
|
||||||
{label}
|
{label}
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<Button href="#contact-section" size="pill" className="gap-1.5">
|
<Button
|
||||||
|
href="#contact-section"
|
||||||
|
variant={onStage ? "stageGhost" : "primary"}
|
||||||
|
size={onStage ? "stageRound" : "pill"}
|
||||||
|
className="gap-1.5"
|
||||||
|
>
|
||||||
<span>상담 신청</span>
|
<span>상담 신청</span>
|
||||||
<ArrowUpRight className="w-4 h-4" />
|
<ArrowUpRight className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@ -1,51 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
133
landing/app/components/sections/hero-dataflow.tsx
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,85 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,73 +0,0 @@
|
|||||||
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 { useState } from "react"
|
||||||
import { AnimatePresence, motion } from "motion/react"
|
import { AnimatePresence, motion } from "motion/react"
|
||||||
import { Bot, Check, Monitor, Smartphone, Sparkles, type LucideIcon } from "lucide-react"
|
import { Check, Monitor, Smartphone, type LucideIcon } from "lucide-react"
|
||||||
|
|
||||||
import { BrowserFrame, PhoneFrame } from "@/components/ui/device-mockups"
|
import { BrowserFrame, PhoneFrame } from "@/components/ui/device-mockups"
|
||||||
import { Section } from "@/components/ui/section"
|
import { Section } from "@/components/ui/section"
|
||||||
import { SectionHeading } from "@/components/ui/section-heading"
|
import { CONTROL_GAP, SectionHeading } from "@/components/ui/section-heading"
|
||||||
import { Typography } from "@/components/ui/typography"
|
import { Typography } from "@/components/ui/typography"
|
||||||
|
|
||||||
/** 3단계 이용 가이드 — 탭 전환식 GIF 시연 (데스크톱/모바일 목업). */
|
/** 3단계 이용 가이드 — 탭 전환식 GIF 시연 (데스크톱/모바일 목업). */
|
||||||
@ -17,21 +17,20 @@ export function HowItWorksDemo() {
|
|||||||
<Section id="how-it-works-demo" width="lg" bordered className="overflow-hidden">
|
<Section id="how-it-works-demo" width="lg" bordered className="overflow-hidden">
|
||||||
<SectionHeading
|
<SectionHeading
|
||||||
align="center"
|
align="center"
|
||||||
className="mb-20"
|
eyebrow="How it Works"
|
||||||
eyebrow="SERVICE DEMONSTRATION"
|
|
||||||
title="견적 생성부터 낙찰까지 한눈에 보기"
|
title="견적 생성부터 낙찰까지 한눈에 보기"
|
||||||
description="복잡해 보이는 구매 과정이 어떻게 자동화되는지 실제 작동 화면(GIF)을 통해 쉽고 직관적으로 확인해 보세요."
|
description="견적을 열고 협상이 끝나기까지, 실제 화면으로 보여드립니다."
|
||||||
descriptionClassName="text-[17px] max-w-2xl"
|
descriptionClassName="max-w-2xl"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 탭 스위치 */}
|
{/* 탭 스위치 — 비교 섹션 토글과 같은 간격 규칙(본문 쪽에 묶인다) */}
|
||||||
<div className="flex justify-center mb-16">
|
<div className={`flex justify-center ${CONTROL_GAP}`}>
|
||||||
<div className="p-1 bg-fill rounded-[14px] inline-flex flex-wrap justify-center gap-1">
|
<div className="p-1 bg-fill rounded-control inline-flex flex-wrap justify-center gap-1">
|
||||||
{DEMO_TABS.map((tab) => (
|
{DEMO_TABS.map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab.key}
|
key={tab.key}
|
||||||
onClick={() => setActiveKey(tab.key)}
|
onClick={() => setActiveKey(tab.key)}
|
||||||
className={`px-5 py-2.5 rounded-[10px] text-xs sm:text-sm font-bold transition-all flex items-center gap-2 cursor-pointer ${
|
className={`px-5 py-2.5 rounded-control 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"
|
activeKey === tab.key ? "bg-white text-primary" : "text-ink-soft hover:text-ink"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@ -54,10 +53,9 @@ export function HowItWorksDemo() {
|
|||||||
transition={{ duration: 0.4 }}
|
transition={{ duration: 0.4 }}
|
||||||
className="space-y-6"
|
className="space-y-6"
|
||||||
>
|
>
|
||||||
<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" />
|
레퍼런스 라벨은 11px/600/자간 normal 에 배경도 아이콘도 없다. */}
|
||||||
{activeTab.badge.label}
|
<div className={`text-[11px] font-semibold ${activeTab.badge.className}`}>{activeTab.badge.label}</div>
|
||||||
</div>
|
|
||||||
<Typography variant="heading">{activeTab.heading}</Typography>
|
<Typography variant="heading">{activeTab.heading}</Typography>
|
||||||
{activeTab.paragraphs.map((paragraph, idx) => (
|
{activeTab.paragraphs.map((paragraph, idx) => (
|
||||||
<Typography key={idx} variant="body">
|
<Typography key={idx} variant="body">
|
||||||
@ -159,7 +157,7 @@ type DemoTab = {
|
|||||||
key: "create" | "negotiate" | "result"
|
key: "create" | "negotiate" | "result"
|
||||||
tabIcon: LucideIcon
|
tabIcon: LucideIcon
|
||||||
tabLabel: string
|
tabLabel: string
|
||||||
badge: { icon: LucideIcon; label: string; className: string }
|
badge: { label: string; className: string }
|
||||||
checkClassName: string
|
checkClassName: string
|
||||||
heading: React.ReactNode
|
heading: React.ReactNode
|
||||||
paragraphs: React.ReactNode[]
|
paragraphs: React.ReactNode[]
|
||||||
@ -183,19 +181,19 @@ const DEMO_TABS: DemoTab[] = [
|
|||||||
{
|
{
|
||||||
key: "create",
|
key: "create",
|
||||||
tabIcon: Monitor,
|
tabIcon: Monitor,
|
||||||
tabLabel: "1단계: AI 견적 & 가이드 수립",
|
tabLabel: "1. 견적 · 기준 수립",
|
||||||
badge: { icon: Sparkles, label: "구매 관리자 콘솔 (웹)", className: "bg-primary-soft text-primary" },
|
badge: { label: "구매 관리자 콘솔", className: "text-primary" },
|
||||||
checkClassName: "text-primary",
|
checkClassName: "text-primary",
|
||||||
heading: (
|
heading: (
|
||||||
<>
|
<>
|
||||||
엑셀 등록만으로 끝나는 <br />
|
엑셀만 올리면 <br />
|
||||||
초정밀 가이드라인 자율 수립
|
목표 단가가 잡힙니다
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
paragraphs: [
|
paragraphs: [
|
||||||
<>
|
<>
|
||||||
품목명과 시중 시장 가격을 입력하면, negotium이 <b>인터넷 최저가(LPS)</b> 데이터를 상시 반영해{" "}
|
품목명과 시중 시장 가격을 입력하면, 네고시움이 <b>인터넷 최저가(LPS)</b> 데이터를 상시 반영해{" "}
|
||||||
<b className="text-primary">시장 바닥값을 쥐고 시작합니다.</b>
|
<b className="text-primary">시장 최저가를 기준으로 시작합니다.</b>
|
||||||
</>,
|
</>,
|
||||||
<>
|
<>
|
||||||
시중 최저가와 매입 이력, 목표 마진율을 종합해 — 사내 마진은 지키면서 공급사가 받아들일 수 있는{" "}
|
시중 최저가와 매입 이력, 목표 마진율을 종합해 — 사내 마진은 지키면서 공급사가 받아들일 수 있는{" "}
|
||||||
@ -211,7 +209,7 @@ const DEMO_TABS: DemoTab[] = [
|
|||||||
gifAlt: "견적 생성 가이드라인 수립 시연",
|
gifAlt: "견적 생성 가이드라인 수립 시연",
|
||||||
fallback: {
|
fallback: {
|
||||||
icon: Monitor,
|
icon: Monitor,
|
||||||
iconWrapClassName: "w-14 h-14 bg-primary-soft rounded-2xl text-primary",
|
iconWrapClassName: "w-14 h-14 bg-primary-soft rounded-card text-primary",
|
||||||
codeClassName: "bg-white text-primary",
|
codeClassName: "bg-white text-primary",
|
||||||
title: "견적 생성 시연 GIF 공간",
|
title: "견적 생성 시연 GIF 공간",
|
||||||
hint: "실제 기동 GIF를 배치하시면 이 영역에 자동으로 재생됩니다.",
|
hint: "실제 기동 GIF를 배치하시면 이 영역에 자동으로 재생됩니다.",
|
||||||
@ -220,8 +218,8 @@ const DEMO_TABS: DemoTab[] = [
|
|||||||
{
|
{
|
||||||
key: "negotiate",
|
key: "negotiate",
|
||||||
tabIcon: Smartphone,
|
tabIcon: Smartphone,
|
||||||
tabLabel: "2단계: AI 자동 밀당 협상",
|
tabLabel: "2. 협력사별 1:1 협상",
|
||||||
badge: { icon: Bot, label: "협력사 협상 포털 (웹·모바일)", className: "bg-positive-soft border border-positive/15 text-positive" },
|
badge: { label: "협력사 협상 포털", className: "text-positive" },
|
||||||
checkClassName: "text-positive",
|
checkClassName: "text-positive",
|
||||||
heading: (
|
heading: (
|
||||||
<>
|
<>
|
||||||
@ -234,14 +232,18 @@ const DEMO_TABS: DemoTab[] = [
|
|||||||
각 파트너사 담당자는 초대 메일로 <b>비대면 협상 모바일 포털</b>에 접속해, 언제든 단가를 조율합니다.
|
각 파트너사 담당자는 초대 메일로 <b>비대면 협상 모바일 포털</b>에 접속해, 언제든 단가를 조율합니다.
|
||||||
</>,
|
</>,
|
||||||
<>
|
<>
|
||||||
단순 마진 깎기가 아닙니다. 봇은 준비된 협상 카드를 상황에 맞게 꺼내, 파트너사의 자발적인 마진 타협을 부드럽게
|
단순히 깎는 게 아닙니다. 에이전트는 준비된 협상 카드를 상황에 맞게 꺼내, 협력사가 받아들일 조건을
|
||||||
이끌어냅니다.
|
이끌어냅니다.
|
||||||
</>,
|
</>,
|
||||||
],
|
],
|
||||||
checks: ["1:1 비대면 협상 포털 제공", "불필요한 실랑이를 예방하여 파트너십 보호"],
|
checks: ["협력사별 1:1 비대면 협상 포털", "같은 기준으로 응대해 관계 부담 없음"],
|
||||||
mockup: "phone",
|
mockup: "phone",
|
||||||
|
/* 현행 공급사 포털(토스풍 UI)에서 실제로 진행한 협상 스크린캐스트. 390x780 모바일 뷰포트로
|
||||||
|
인트로→가격 밀당(협상 카드 4장)→타결(18,480원 합의)까지, 1.5배속. gif 는 무JS 폴백(폭 228). */
|
||||||
gif: "/gifs/negotiation.gif",
|
gif: "/gifs/negotiation.gif",
|
||||||
gifAlt: "AI 모바일 자동 협상 시연",
|
video: "/gifs/negotiation.mp4",
|
||||||
|
poster: "/gifs/negotiation.jpg",
|
||||||
|
gifAlt: "협력사 포털 모바일 협상 화면",
|
||||||
fallback: {
|
fallback: {
|
||||||
icon: Smartphone,
|
icon: Smartphone,
|
||||||
iconWrapClassName: "w-12 h-12 bg-positive-soft border border-positive/15 rounded-full text-positive",
|
iconWrapClassName: "w-12 h-12 bg-positive-soft border border-positive/15 rounded-full text-positive",
|
||||||
@ -254,7 +256,7 @@ const DEMO_TABS: DemoTab[] = [
|
|||||||
key: "result",
|
key: "result",
|
||||||
tabIcon: Monitor,
|
tabIcon: Monitor,
|
||||||
tabLabel: "3단계: AI 협상 결과 확인",
|
tabLabel: "3단계: AI 협상 결과 확인",
|
||||||
badge: { icon: Monitor, label: "구매 관리자 콘솔 (웹)", className: "bg-accent-soft border border-accent/15 text-accent" },
|
badge: { label: "구매 관리자 콘솔", className: "text-accent" },
|
||||||
checkClassName: "text-accent",
|
checkClassName: "text-accent",
|
||||||
heading: (
|
heading: (
|
||||||
<>
|
<>
|
||||||
@ -280,7 +282,7 @@ const DEMO_TABS: DemoTab[] = [
|
|||||||
gifAlt: "협상 결과 분석 대시보드 시연",
|
gifAlt: "협상 결과 분석 대시보드 시연",
|
||||||
fallback: {
|
fallback: {
|
||||||
icon: Monitor,
|
icon: Monitor,
|
||||||
iconWrapClassName: "w-14 h-14 bg-accent-soft rounded-2xl text-accent",
|
iconWrapClassName: "w-14 h-14 bg-accent-soft rounded-card text-accent",
|
||||||
codeClassName: "bg-white text-accent",
|
codeClassName: "bg-white text-accent",
|
||||||
title: "협상 결과 확인 시연 GIF 공간",
|
title: "협상 결과 확인 시연 GIF 공간",
|
||||||
hint: "결과 보고 대시보드 기동 GIF를 배치하시면 이 영역에 자동으로 재생됩니다.",
|
hint: "결과 보고 대시보드 기동 GIF를 배치하시면 이 영역에 자동으로 재생됩니다.",
|
||||||
|
|||||||
@ -1,324 +0,0 @@
|
|||||||
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 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
466
landing/app/components/sections/negotiation-demo.tsx
Normal file
@ -0,0 +1,466 @@
|
|||||||
|
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,8 +2,9 @@ import { motion } from "motion/react"
|
|||||||
import { GitMerge, RefreshCw, Scale, type LucideIcon } from "lucide-react"
|
import { GitMerge, RefreshCw, Scale, type LucideIcon } from "lucide-react"
|
||||||
|
|
||||||
import { Section } from "@/components/ui/section"
|
import { Section } from "@/components/ui/section"
|
||||||
import { SectionHeading } from "@/components/ui/section-heading"
|
import { HEADING_GAP, SectionHeading } from "@/components/ui/section-heading"
|
||||||
import { Typography } from "@/components/ui/typography"
|
import { Typography } from "@/components/ui/typography"
|
||||||
|
import { OrbitRing } from "@/components/ui/orbit-ring"
|
||||||
import { useFadeUp } from "@/lib/motion"
|
import { useFadeUp } from "@/lib/motion"
|
||||||
|
|
||||||
/** 강화학습 섹션 — 협상할수록 좋아진다는 3개 필러 카드. */
|
/** 강화학습 섹션 — 협상할수록 좋아진다는 3개 필러 카드. */
|
||||||
@ -11,22 +12,40 @@ export function Reinforcement() {
|
|||||||
const fadeUp = useFadeUp()
|
const fadeUp = useFadeUp()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section id="reinforcement" bg="surface" bordered className="border-b border-line">
|
<Section id="reinforcement" bg="stage" width="lg">
|
||||||
<motion.div {...fadeUp} className="mb-20">
|
{/* 순환 링은 원래 히어로에 있었는데, 루프가 의미상 속하는 자리는 여기다 —
|
||||||
|
"협상할수록 좋아진다"를 말하는 섹션이고, 히어로는 이미 세로 예산이 포화였다. */}
|
||||||
|
{/* 머리가 그리드 셀 안에 들어가 있어서 여기만 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
|
<SectionHeading
|
||||||
className="text-center md:text-left"
|
className="text-center lg:text-left"
|
||||||
eyebrow="REINFORCEMENT LEARNING"
|
gap="none"
|
||||||
title="협상할수록, 더 좋은 조건으로"
|
tone="stage"
|
||||||
description={
|
eyebrow="Reinforcement Learning"
|
||||||
|
/* 쉼표에서 줄을 명시적으로 끊는다. break-keep + text-balance 에 맡기면
|
||||||
|
두 줄 길이를 맞추려고 "협상할수록, 더 / 좋은 조건으로" 처럼 쉼표를
|
||||||
|
넘어가서 끊어진다 — 조건절과 결과절이 한 줄에 섞여 의미가 흐려진다. */
|
||||||
|
title={
|
||||||
<>
|
<>
|
||||||
잘 깎는 노하우는 담당자 머릿속에만 남고, 성과는 그날의 감정과 컨디션에 흔들립니다. negotium은 진행된 모든 거래
|
협상할수록,
|
||||||
데이터와 파트너 거절 피드백을 <span className="font-bold text-primary">강화학습 기술</span>에 투입합니다.
|
<br />더 좋은 조건으로
|
||||||
무턱대고 가격을 후려쳐 상대방의 반발만 사고 결렬로 치닫는 경직된 협상이 아니라, 파트너사가 충분히 받아들일 수
|
</>
|
||||||
있는 범위 내에서 최선의 마진율을 뽑아내도록 조율 페이스를 학습합니다.
|
}
|
||||||
|
description={
|
||||||
|
<>
|
||||||
|
잘 깎는 요령은 담당자 머릿속에만 남고, 결과는 그날 컨디션에 흔들립니다. 네고시움은 지난 거래 기록과
|
||||||
|
협력사가 거절한 지점을 <span className="font-semibold text-on-stage">강화학습</span>에 넣습니다. 어디까지가
|
||||||
|
받아들여지는 선인지를 데이터로 익혀서, 결렬 없이 도달 가능한 최선을 찾아갑니다.
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
descriptionClassName="mt-8 max-w-3xl"
|
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="lg:col-span-5 flex justify-center">
|
||||||
|
<OrbitRing className="w-[280px] md:w-[340px] h-auto" />
|
||||||
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||||
@ -44,15 +63,15 @@ function PillarCard({ pillar, delay }: { pillar: Pillar; delay: number }) {
|
|||||||
const fadeUp = useFadeUp(delay)
|
const fadeUp = useFadeUp(delay)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div {...fadeUp} className="bg-white p-8 rounded-[28px] flex flex-col justify-between h-[240px]">
|
<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-12 h-12 rounded-2xl bg-primary/5 text-primary flex items-center justify-center">
|
<div className="w-11 h-11 rounded-control bg-primary/15 text-on-stage flex items-center justify-center">
|
||||||
<pillar.icon className="w-6 h-6" />
|
<pillar.icon className="w-5 h-5" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Typography variant="cardTitle" className="mb-2">
|
<Typography variant="cardTitle" className="mb-2 text-on-stage">
|
||||||
{pillar.title}
|
{pillar.title}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" className="text-ink-soft">
|
<Typography variant="caption" className="text-on-stage-soft">
|
||||||
{pillar.description}
|
{pillar.description}
|
||||||
</Typography>
|
</Typography>
|
||||||
</div>
|
</div>
|
||||||
@ -68,12 +87,12 @@ const PILLARS: Pillar[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: RefreshCw,
|
icon: RefreshCw,
|
||||||
title: "지속적인 전략 최적화",
|
title: "쓸수록 올라가는 기준선",
|
||||||
description: "전략 강화 학습을 통해 기준선이 상승하고, 점진적으로 개선됩니다. 시간이 곧 협상력이 됩니다.",
|
description: "협상이 쌓일수록 기준선이 올라갑니다. 운영 기간이 그대로 협상력이 됩니다.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: GitMerge,
|
icon: GitMerge,
|
||||||
title: "낙찰 성사율 극대화",
|
title: "결렬 없는 합의",
|
||||||
description: "무리하게 후려쳐 관계를 깨는 대신, 성사되는 선에서 최대한 끌어냅니다.",
|
description: "성사되지 않을 선까지 밀지 않습니다. 합의 가능한 범위 안에서 최선을 찾습니다.",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@ -1,158 +0,0 @@
|
|||||||
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,7 +6,9 @@ import { cn } from "@/lib/utils"
|
|||||||
// CTA 버튼 토큰. 랜딩의 CTA 는 전부 앵커 스크롤이라 href 를 주면 <a> 로 렌더한다
|
// CTA 버튼 토큰. 랜딩의 CTA 는 전부 앵커 스크롤이라 href 를 주면 <a> 로 렌더한다
|
||||||
// (SSG 특성상 하이드레이션 전에도 동작).
|
// (SSG 특성상 하이드레이션 전에도 동작).
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"inline-flex items-center justify-center gap-2 transition-all cursor-pointer disabled:opacity-50",
|
/* transition-colors 로 좁힌다. transition-all 이면 헤더 CTA 가 무대↔라이트로 variant 를
|
||||||
|
통째로 바꿀 때 배경이 투명에서 차오르며 플리커로 읽힌다. */
|
||||||
|
"inline-flex items-center justify-center gap-2 transition-colors duration-200 cursor-pointer disabled:opacity-50",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
@ -14,13 +16,40 @@ const buttonVariants = cva(
|
|||||||
secondary: "bg-fill hover:bg-fill-hover text-ink-soft hover:text-ink",
|
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",
|
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)]",
|
||||||
},
|
},
|
||||||
// radius 는 사이즈 무관 rounded-2xl(16px) 로 통일 — 알약형 금지
|
/* 레퍼런스(statworx) 실측: 10.4px / 600 / 알약(radius 39px) / 패딩 12·24 / 높이 33px.
|
||||||
|
작고 조밀한 버튼이 확신 있어 보인다 — 크고 굵은 버튼은 설득이 아니라 호소로 읽힌다.
|
||||||
|
한글 보정: 10.4px 는 판독이 어려워 12~13px 로 올리고 높이도 터치 타깃까지 확보한다.
|
||||||
|
(기존 주석의 "알약형 금지" 규칙은 레퍼런스 채택으로 폐기했다.) */
|
||||||
size: {
|
size: {
|
||||||
pill: "px-4.5 py-2.5 text-sm font-semibold rounded-2xl",
|
pill: "px-5 py-2.5 text-[12px] font-semibold rounded-full",
|
||||||
md: "px-6 py-2.5 text-xs font-bold rounded-2xl",
|
md: "px-6 py-3 text-[12px] font-semibold rounded-full",
|
||||||
lg: "px-8 py-4.5 text-base font-bold rounded-2xl",
|
lg: "px-7 py-3.5 text-[13px] font-semibold rounded-full",
|
||||||
xl: "px-10 py-5 text-base font-bold rounded-2xl",
|
xl: "px-8 py-4 text-[14px] font-semibold rounded-full",
|
||||||
|
stageRound: "px-7 py-3.5 text-[13px] font-semibold rounded-full",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: { variant: "primary", size: "lg" },
|
defaultVariants: { variant: "primary", size: "lg" },
|
||||||
|
|||||||
228
landing/app/components/ui/demo-request-modal.tsx
Normal file
@ -0,0 +1,228 @@
|
|||||||
|
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 워크스페이스. */
|
/** 데스크톱 브라우저 목업 프레임 — 신호등 버튼 + 주소창 + 16:10 워크스페이스. */
|
||||||
function BrowserFrame({ url, children }: { url: string; children: React.ReactNode }) {
|
function BrowserFrame({ url, children }: { url: string; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<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-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-fill px-4 py-3.5 flex items-center justify-between border-b border-line-strong">
|
<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">
|
<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-[#FF5F56] inline-block" />
|
||||||
<span className="w-3 h-3 rounded-full bg-[#FFBD2E] 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="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-md 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-control border border-line-strong truncate max-w-[180px] sm:max-w-none">
|
||||||
{url}
|
{url}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -22,9 +22,9 @@ function BrowserFrame({ url, children }: { url: string; children: React.ReactNod
|
|||||||
/** 모바일 폰 목업 프레임 — 노치 포함 9:18 스크린. */
|
/** 모바일 폰 목업 프레임 — 노치 포함 9:18 스크린. */
|
||||||
function PhoneFrame({ children }: { children: React.ReactNode }) {
|
function PhoneFrame({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<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="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="bg-white rounded-[36px] flex-1 overflow-hidden relative flex flex-col justify-center items-center">
|
<div className="bg-white rounded-card 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" />
|
<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}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -2,16 +2,33 @@ import * as React from "react"
|
|||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
// 문의 폼 필드 공통 스타일 — 테두리 없는 회색 채움, 포커스 시 흰 배경 + 파란 링.
|
/*
|
||||||
|
* 문의 폼 필드.
|
||||||
|
*
|
||||||
|
* 회색 채움 박스는 어느 템플릿에나 있다. 밑줄 필드로 바꾸면 같은 기능인데 훨씬
|
||||||
|
* 정돈돼 보인다 — 폼이 "입력 상자 더미"가 아니라 한 장의 문서처럼 읽힌다.
|
||||||
|
*
|
||||||
|
* 밑줄 폼의 알려진 위험은 입력란으로 안 보이는 것(어포던스 손실)인데,
|
||||||
|
* 라벨을 항상 위에 두고 밑줄을 2px 로 두껍게 잡아 보완했다.
|
||||||
|
* 두께를 항상 2px 로 고정해서 포커스 때 레이아웃이 흔들리지 않는다.
|
||||||
|
*
|
||||||
|
* 글자 16px 은 취향이 아니라 필수다 — iOS 사파리는 16px 미만 입력에 포커스하면
|
||||||
|
* 화면을 자동 확대해서, 광고로 들어온 모바일 사용자의 폼 이탈을 만든다.
|
||||||
|
*/
|
||||||
const fieldClass =
|
const fieldClass =
|
||||||
"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"
|
"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"
|
||||||
|
|
||||||
function Input({ className, ...props }: React.InputHTMLAttributes<HTMLInputElement>) {
|
/* ComponentPropsWithRef 로 두면 ref 가 그대로 전달된다 (React 19 는 forwardRef 불필요).
|
||||||
|
데모 요청 모달이 열릴 때 첫 필드로 포커스를 옮기려면 ref 가 필요하다. */
|
||||||
|
function Input({ className, ...props }: React.ComponentPropsWithRef<"input">) {
|
||||||
return <input className={cn(fieldClass, className)} {...props} />
|
return <input className={cn(fieldClass, className)} {...props} />
|
||||||
}
|
}
|
||||||
|
|
||||||
function Textarea({ className, ...props }: React.TextareaHTMLAttributes<HTMLTextAreaElement>) {
|
function Textarea({ className, ...props }: React.ComponentPropsWithRef<"textarea">) {
|
||||||
return <textarea className={cn(fieldClass, "resize-none", className)} {...props} />
|
return <textarea className={cn(fieldClass, "resize-none leading-[1.6]", className)} {...props} />
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Input, Textarea }
|
export { Input, Textarea }
|
||||||
|
|||||||
179
landing/app/components/ui/negotiation-replay.tsx
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
166
landing/app/components/ui/orbit-ring.tsx
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,28 +0,0 @@
|
|||||||
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,25 +3,102 @@ import * as React from "react"
|
|||||||
import { Typography } from "@/components/ui/typography"
|
import { Typography } from "@/components/ui/typography"
|
||||||
import { cn } from "@/lib/utils"
|
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 = {
|
type SectionHeadingProps = {
|
||||||
eyebrow: string
|
eyebrow?: string
|
||||||
title: React.ReactNode
|
title: React.ReactNode
|
||||||
description?: React.ReactNode
|
description?: React.ReactNode
|
||||||
align?: "left" | "center"
|
align?: "left" | "center"
|
||||||
|
/** 다크 무대 섹션(bg="stage")에서는 "stage" 를 준다. Typography 변형이 먹색을 물고 있어서 필요하다. */
|
||||||
|
tone?: "light" | "stage"
|
||||||
|
/** 제목 조판 티어. display 는 히어로·최종 CTA 전용. */
|
||||||
|
size?: keyof typeof SIZES
|
||||||
|
/** 머리 아래 표준 간격을 끈다. 뒤에 컨트롤 행이 붙는 섹션에서만 쓴다. */
|
||||||
|
gap?: "heading" | "none"
|
||||||
className?: string
|
className?: string
|
||||||
/** 리드 문단 폭 제한 등 (예: "max-w-2xl") */
|
/** 제목 크기 미세조정 (예: 최종 CTA 의 축소 디스플레이) */
|
||||||
|
titleClassName?: string
|
||||||
|
/** 리드 문단 폭 제한·색 조정 (예: "max-w-2xl"). 세로 간격은 여기서 주지 않는다. */
|
||||||
descriptionClassName?: string
|
descriptionClassName?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function SectionHeading({ eyebrow, title, description, align = "left", className, descriptionClassName }: SectionHeadingProps) {
|
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]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn(align === "center" && "text-center", className)}>
|
<div className={cn(align === "center" && "text-center", gap === "heading" && HEADING_GAP, className)}>
|
||||||
<Typography variant="eyebrow" className="mb-3 block">
|
{/* eyebrow 가 없을 때 빈 span 을 렌더하면 mb + 30px 행간만큼 유령 여백이 생긴다.
|
||||||
|
지금은 모든 섹션이 eyebrow 를 넘기지만, 하나만 빼도 그 섹션만 제목이 내려앉는다. */}
|
||||||
|
{eyebrow && (
|
||||||
|
<Typography variant="eyebrow" className={cn("mb-4 block", onStage && "text-primary-on-stage")}>
|
||||||
{eyebrow}
|
{eyebrow}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="title">{title}</Typography>
|
)}
|
||||||
|
<Typography variant={variant} as="h2" className={cn(onStage && "text-on-stage", titleClassName)}>
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
{description && (
|
{description && (
|
||||||
<Typography variant="lead" className={cn("mt-5", align === "center" && "mx-auto", descriptionClassName)}>
|
<Typography
|
||||||
|
variant="lead"
|
||||||
|
className={cn(descriptionGap, align === "center" && "mx-auto", onStage && "text-on-stage-soft", descriptionClassName)}
|
||||||
|
>
|
||||||
{description}
|
{description}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -6,6 +6,8 @@ const sectionBg = {
|
|||||||
white: "bg-white",
|
white: "bg-white",
|
||||||
surface: "bg-surface",
|
surface: "bg-surface",
|
||||||
dark: "bg-ink text-white",
|
dark: "bg-ink text-white",
|
||||||
|
/** 히어로와 같은 무대. 어두운 섹션은 이걸 쓴다 — 페이지 안에 어둠이 두 종류면 따로 논다. */
|
||||||
|
stage: "stage-bg text-on-stage",
|
||||||
}
|
}
|
||||||
|
|
||||||
const sectionWidth = {
|
const sectionWidth = {
|
||||||
@ -37,7 +39,8 @@ function Section({
|
|||||||
}: SectionProps) {
|
}: SectionProps) {
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
className={cn("py-36 md:py-44", sectionBg[bg], bordered && "border-t border-line", decor && "relative overflow-hidden", className)}
|
/* 레퍼런스 섹션 패딩은 104~139px. 176px 은 과해서 "채울 내용이 없어" 보인다. */
|
||||||
|
className={cn("py-24 md:py-32", sectionBg[bg], bordered && "border-t border-line", decor && "relative overflow-hidden", className)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{decor}
|
{decor}
|
||||||
|
|||||||
@ -56,7 +56,7 @@ function SlateLeaf({ leaf }: { leaf: SlateText }) {
|
|||||||
children = <em className="italic">{children}</em>
|
children = <em className="italic">{children}</em>
|
||||||
}
|
}
|
||||||
if (leaf.code) {
|
if (leaf.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>
|
children = <code className="bg-primary/10 px-1.5 py-0.5 rounded-control font-mono text-xs text-primary font-bold">{children}</code>
|
||||||
}
|
}
|
||||||
|
|
||||||
return <span>{children}</span>
|
return <span>{children}</span>
|
||||||
|
|||||||
@ -3,28 +3,60 @@ import { cva, type VariantProps } from "class-variance-authority"
|
|||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
// 랜딩 텍스트 스케일 단일 소스. 페이지마다 text-[44px]/font-black/…을 직접 박지 말고
|
/*
|
||||||
// variant 로 통일한다. 색·크기 미세조정은 className 으로 합성.
|
* 랜딩 텍스트 스케일 단일 소스. 페이지마다 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)를 만든다.
|
||||||
|
*/
|
||||||
const typographyVariants = cva("", {
|
const typographyVariants = cva("", {
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
// 히어로 h1
|
// 라이트 섹션 h1
|
||||||
display: "text-3xl sm:text-4xl md:text-[50px] font-black tracking-tight leading-[1.2] text-ink break-keep",
|
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",
|
||||||
// 섹션 제목 h2
|
// 섹션 제목 h2
|
||||||
title: "text-3xl md:text-[44px] font-black tracking-tight leading-tight text-ink break-keep",
|
/* 본문(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",
|
||||||
// 섹션 내 서브 제목 (데모 패널 등)
|
// 섹션 내 서브 제목 (데모 패널 등)
|
||||||
heading: "text-2xl md:text-3xl font-bold tracking-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",
|
||||||
// 카드 제목
|
// 카드 제목
|
||||||
cardTitle: "text-lg font-bold text-ink break-keep",
|
cardTitle: "text-[20px] font-bold tracking-[-0.028em] leading-[1.3] text-ink break-keep",
|
||||||
// 섹션 상단 오버라인 라벨
|
/* 섹션 상단 라벨. 영문 전용 — 한글 내용은 title 이 맡고, 여기는 조판 요소다.
|
||||||
eyebrow: "text-sm font-semibold text-primary tracking-wider uppercase",
|
작은 대문자 산세리프 라벨은 어느 SaaS 랜딩에나 있다. 세리프 이탤릭으로 두면
|
||||||
|
같은 자리에서 장식이 아니라 편집 디자인처럼 읽힌다. 한글은 넣지 않는다. */
|
||||||
|
eyebrow: "font-display italic text-[24px] md:text-[30px] font-normal text-primary tracking-[0.005em]",
|
||||||
// 섹션 리드 문단
|
// 섹션 리드 문단
|
||||||
lead: "text-base sm:text-lg text-ink-soft font-medium leading-relaxed break-keep",
|
lead: "text-[16px] sm:text-[17px] text-ink-soft font-normal leading-[1.6] break-keep",
|
||||||
body: "text-[15px] text-ink-soft font-medium leading-relaxed break-keep",
|
body: "text-[15px] text-ink-soft font-normal leading-[1.6] break-keep",
|
||||||
small: "text-sm text-ink-soft font-medium leading-relaxed break-keep",
|
small: "text-[14px] text-ink-soft font-normal leading-[1.6] break-keep",
|
||||||
caption: "text-xs text-ink-muted font-semibold leading-relaxed break-keep",
|
caption: "text-[12.5px] text-ink-muted font-normal leading-[1.55] break-keep",
|
||||||
// 11px 트래킹 대문자 메타 라벨 (단가 패널·푸터 컬럼 제목 등)
|
// 메타 라벨 (단가 패널·푸터 컬럼 제목 등)
|
||||||
micro: "text-[11px] font-bold text-ink-muted uppercase tracking-wider",
|
micro: "text-[11px] font-semibold text-ink-muted tracking-normal",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: { variant: "body" },
|
defaultVariants: { variant: "body" },
|
||||||
@ -32,6 +64,7 @@ const typographyVariants = cva("", {
|
|||||||
|
|
||||||
const defaultTag: Record<NonNullable<VariantProps<typeof typographyVariants>["variant"]>, React.ElementType> = {
|
const defaultTag: Record<NonNullable<VariantProps<typeof typographyVariants>["variant"]>, React.ElementType> = {
|
||||||
display: "h1",
|
display: "h1",
|
||||||
|
stageDisplay: "h1",
|
||||||
title: "h2",
|
title: "h2",
|
||||||
heading: "h3",
|
heading: "h3",
|
||||||
cardTitle: "h3",
|
cardTitle: "h3",
|
||||||
|
|||||||
58
landing/app/lib/lead.ts
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
/*
|
||||||
|
* 리드 전송 — 데모 요청 모달과 상담 신청 폼이 함께 쓴다.
|
||||||
|
*
|
||||||
|
* [임시] 백엔드/서버리스 연결 전까지는 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 }
|
||||||
|
}
|
||||||
198
landing/app/lib/negotiation-sim.ts
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
/**
|
||||||
|
* 랜딩 협상 데모의 규칙 엔진 (클라이언트 전용).
|
||||||
|
*
|
||||||
|
* 실제 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,6 +8,9 @@ export const links: Route.LinksFunction = () => [
|
|||||||
{ rel: "icon", href: "/favicon.svg", type: "image/svg+xml" },
|
{ rel: "icon", href: "/favicon.svg", type: "image/svg+xml" },
|
||||||
// 본문 서체 프리로드 — 프리렌더된 첫 화면의 FOUT 최소화
|
// 본문 서체 프리로드 — 프리렌더된 첫 화면의 FOUT 최소화
|
||||||
{ rel: "preload", href: "/fonts/PretendardVariable.woff2", as: "font", type: "font/woff2", crossOrigin: "anonymous" },
|
{ 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 }) {
|
export function Layout({ children }: { children: ReactNode }) {
|
||||||
|
|||||||
@ -5,14 +5,16 @@ import { Faq } from "@/components/sections/faq";
|
|||||||
import { FinalCTA } from "@/components/sections/final-cta";
|
import { FinalCTA } from "@/components/sections/final-cta";
|
||||||
import { Footer } from "@/components/sections/footer";
|
import { Footer } from "@/components/sections/footer";
|
||||||
import { Header } from "@/components/sections/header";
|
import { Header } from "@/components/sections/header";
|
||||||
import { HeroNeumorphic } from "@/components/sections/hero-neumorphic";
|
import { HeroDataFlow } from "@/components/sections/hero-dataflow";
|
||||||
import { HowItWorksDemo } from "@/components/sections/how-it-works-demo";
|
import { HowItWorksDemo } from "@/components/sections/how-it-works-demo";
|
||||||
import { NegotiationConsole } from "@/components/sections/negotiation-console";
|
import { NegotiationDemo } from "@/components/sections/negotiation-demo";
|
||||||
import { Reinforcement } from "@/components/sections/reinforcement";
|
import { Reinforcement } from "@/components/sections/reinforcement";
|
||||||
|
|
||||||
const TITLE = "negotium — AI 구매 협상 자동화";
|
/* 한글·영문을 둘 다 담는다. 본문은 "네고시움"으로 갔지만 타이틀에서 영문을 빼면
|
||||||
|
"negotium" 으로 검색해 들어오던 유입이 끊긴다 — 검색 질의는 사용자가 정하지 우리가 못 정한다. */
|
||||||
|
const TITLE = "네고시움(negotium) — AI 구매 협상 자동화";
|
||||||
const DESCRIPTION =
|
const DESCRIPTION =
|
||||||
"가이드라인만 정하면 AI 흥정 봇이 여러 협력사와 단가를 대신 조율합니다. 흥정부터 낙찰까지 자동으로, 협상할수록 강화학습으로 더 좋은 조건을 만드는 B2B 구매 협상 자동화 솔루션.";
|
"품목·목표가·마감일만 정하면 협상 에이전트가 협력사마다 1:1로 단가를 조율하고 낙찰까지 판정합니다. 협상 기록이 쌓일수록 조건이 좋아지는 B2B 구매 협상 자동화 솔루션.";
|
||||||
|
|
||||||
export function meta() {
|
export function meta() {
|
||||||
return [
|
return [
|
||||||
@ -30,8 +32,8 @@ export default function Home() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-white text-ink font-sans antialiased selection:bg-primary/10 selection:text-primary">
|
<div className="min-h-screen bg-white text-ink font-sans antialiased selection:bg-primary/10 selection:text-primary">
|
||||||
<Header />
|
<Header />
|
||||||
<HeroNeumorphic />
|
<HeroDataFlow />
|
||||||
<NegotiationConsole />
|
<NegotiationDemo />
|
||||||
<HowItWorksDemo />
|
<HowItWorksDemo />
|
||||||
<Reinforcement />
|
<Reinforcement />
|
||||||
<CoreValues />
|
<CoreValues />
|
||||||
|
|||||||
69
landing/docs/2026-08-07-hero-cta-copy-decision.md
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
# 히어로 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` |
|
||||||
99
landing/docs/lead-webhook.google-apps-script.js
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
/*
|
||||||
|
* 리드 수신용 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",
|
"@react-router/node": "^7.17.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"isbot": "^5",
|
||||||
"lucide-react": "^0.546.0",
|
"lucide-react": "^0.546.0",
|
||||||
"motion": "^12.23.24",
|
"motion": "^12.23.24",
|
||||||
"react": "^19.0.1",
|
"react": "^19.0.1",
|
||||||
"react-dom": "^19.0.1",
|
"react-dom": "^19.0.1",
|
||||||
"react-router": "^7.17.0",
|
"react-router": "^7.17.0",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0"
|
||||||
"isbot": "^5"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@react-router/dev": "^7.17.0",
|
"@react-router/dev": "^7.17.0",
|
||||||
@ -27,6 +27,7 @@
|
|||||||
"@types/node": "^22.14.0",
|
"@types/node": "^22.14.0",
|
||||||
"@types/react": "^19.0.1",
|
"@types/react": "^19.0.1",
|
||||||
"@types/react-dom": "^19.0.1",
|
"@types/react-dom": "^19.0.1",
|
||||||
|
"@vercel/node": "^5.9.5",
|
||||||
"tailwindcss": "^4.1.14",
|
"tailwindcss": "^4.1.14",
|
||||||
"typescript": "~5.8.2",
|
"typescript": "~5.8.2",
|
||||||
"vite": "^6.2.3"
|
"vite": "^6.2.3"
|
||||||
|
|||||||
BIN
landing/public/fonts/PlayfairDisplay-Italic.woff2
Normal file
|
Before Width: | Height: | Size: 111 KiB |
BIN
landing/public/gifs/hero_loop.jpg
Normal file
|
After Width: | Height: | Size: 98 KiB |
BIN
landing/public/gifs/hero_loop.mp4
Normal file
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 4.1 MiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 42 KiB |
7
landing/vercel.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://openapi.vercel.sh/vercel.json",
|
||||||
|
"framework": null,
|
||||||
|
"buildCommand": "npm run build",
|
||||||
|
"outputDirectory": "build/client",
|
||||||
|
"rewrites": [{ "source": "/((?!api/).*)", "destination": "/index.html" }]
|
||||||
|
}
|
||||||
@ -198,6 +198,7 @@ docker compose up -d # negosium 스택 + lps-api·lps-worker·lps
|
|||||||
| **[데이터베이스](docs/database.md)** | 개발자/기획자 | 테이블 6종 구조와 코드값(+by_mall·ip_session·proxy_port 장부) |
|
| **[데이터베이스](docs/database.md)** | 개발자/기획자 | 테이블 6종 구조와 코드값(+by_mall·ip_session·proxy_port 장부) |
|
||||||
| **[API 사용법](docs/api.md)** | 연동 개발자 | 엔드포인트·요청/응답·metrics 예시 |
|
| **[API 사용법](docs/api.md)** | 연동 개발자 | 엔드포인트·요청/응답·metrics 예시 |
|
||||||
| **[운영 가이드](docs/operations.md)** | 운영자/개발자 | 실행·병렬·관측(readyz/ops/알림)·**Docker 배포**·문제 해결 |
|
| **[운영 가이드](docs/operations.md)** | 운영자/개발자 | 실행·병렬·관측(readyz/ops/알림)·**Docker 배포**·문제 해결 |
|
||||||
|
| **[2026-08-07 세션 기록](docs/2026-08-07-session-notes.md)** | 팀 | 직전 작업 요약 — 결정 근거·미해결(쿠팡 차단)·다음 할 일·주의사항 |
|
||||||
| **[크롤러 논의](docs/decision-openmarket-crawler.md)** | 팀 | 오픈마켓 크롤러 유지 여부(ROI) 의사결정 메모 |
|
| **[크롤러 논의](docs/decision-openmarket-crawler.md)** | 팀 | 오픈마켓 크롤러 유지 여부(ROI) 의사결정 메모 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
87
lps/docs/2026-08-07-session-notes.md
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
# 2026-08-07 작업 기록 — IP 로테이션 검수 · 네이버 컨테이너 차단 규명 · 결과 상태 정의
|
||||||
|
|
||||||
|
다음 세션이 이어받을 수 있도록 **무엇이 왜 바뀌었고, 무엇이 남았는지**만 적는다.
|
||||||
|
상태 정의는 `result-states.md`, 운영 절차는 `operations.md` 가 소스다.
|
||||||
|
|
||||||
|
## 1. 무슨 일이 있었나
|
||||||
|
|
||||||
|
세 덩어리다. 앞의 둘은 "고장 났다"에서 출발했고, 셋째는 그 과정에서 드러난 설계 문제다.
|
||||||
|
|
||||||
|
**① IP 로테이션이 설계대로 안 돌고 있었다.** 검수해 보니 결함이 6개였고, 그중 하나는
|
||||||
|
`bot_detection.ip_request_no` 를 항상 `1` 로 만들어 **과거 진단 결론까지 오염**시켰다.
|
||||||
|
|
||||||
|
**② 컨테이너에서 네이버만 막혔다.** 원인은 컨테이너가 아니라 **UA 의 플랫폼 토큰**이었다.
|
||||||
|
네이버는 리눅스 데스크톱 Chrome 을 HTTP 405 로 거부한다.
|
||||||
|
|
||||||
|
**③ '못 봤다'를 '없다'고 말하고 있었다.** 차단당해 확인 못 한 몰이 화면에서 '없음(–)'과
|
||||||
|
구분되지 않았다. 상태를 정의하고 4단계로 화면까지 연결했다.
|
||||||
|
|
||||||
|
## 2. 지금 상태
|
||||||
|
|
||||||
|
| 항목 | 상태 |
|
||||||
|
|---|---|
|
||||||
|
| 네이버 | 컨테이너에서 정상(UA 보정). 검색당 40건 |
|
||||||
|
| 쿠팡 | **간헐적 차단** — 해외 IP(`gate`) 사용이 원인으로 추정(아래 4번) |
|
||||||
|
| IP 로테이션 | 예산·회전·소각이 모두 의도대로. 서킷브레이커가 풀 고갈 차단 |
|
||||||
|
| 결과 상태 | 몰별 7상태 → 운영자 화면은 그대로, 사용자 화면은 3가지로 접음 |
|
||||||
|
| 잡 | 한 소스가 막혀도 정상 종료(DEAD 아님) |
|
||||||
|
| 테스트 | lps 292 · negodata 107 passed |
|
||||||
|
| 배포 | 로컬 전체 재빌드·기동 완료. **운영은 마이그레이션 미적용**(아래 5번) |
|
||||||
|
|
||||||
|
## 3. 이번에 정한 것 (되돌리려면 근거부터 볼 것)
|
||||||
|
|
||||||
|
- **예산은 브라우저가 아니라 IP 에 묶는다.** 유휴 정리(120s)는 브라우저만 닫고 같은 IP 로
|
||||||
|
돌아오는데, 브라우저 기준으로 세면 카운터가 매번 초기화돼 **예산이 영영 발화하지 않는다**
|
||||||
|
(실측: 6회 검색이 전부 같은 포트·`ip_req#1`). 시계도 하나(`_session_started_at`)로 통일했다.
|
||||||
|
- **회전해도 소용없는 차단은 IP 를 태우지 않는다.** 서로 다른 IP 3개가 연속으로 **첫 요청부터**
|
||||||
|
막히면 IP 문제가 아니다(평판이면 몇 개는 통과하고, 과사용이면 뒤쪽에서 막힌다). 임계 없이
|
||||||
|
태우면 잡 16건에 100포트가 30분 쿨다운으로 묶인다. 성공 1회로 자동 해제된다.
|
||||||
|
- **임대 없이는 크롤하지 않는다.** 풀이 마르면 예전엔 계산식으로 포트를 골라 **남이 쥔 IP 를
|
||||||
|
같이 썼다**(실측 재현). 장부가 막으려던 바로 그 문제라, 못 잡으면 실패하는 게 맞다.
|
||||||
|
- **UA 는 플랫폼 토큰만 바꾸고 버전은 실제 값을 쓴다.** 통째로 하드코딩하면 컨테이너 Chrome 이
|
||||||
|
업데이트될 때 UA 와 엔진이 어긋나 그 불일치가 새 봇 신호가 된다. 쿠팡은 잘 통과하므로 안 건드린다.
|
||||||
|
- **상태는 하나로 정의·저장하고 표시 단계에서 접는다.** 저장을 단순화하면 운영자가 원인을 못 보고,
|
||||||
|
표시를 상세화하면 사용자가 못 읽는다. `partial` 을 컬럼으로 둔 것도 같은 이유 — 소비자가
|
||||||
|
'어떤 상태가 확인된 것인가'라는 판단 규칙까지 알면 상태 정의가 두 곳으로 흩어진다.
|
||||||
|
- **부분 실패의 `not_found` 는 네거티브 캐시에 넣지 않는다.** 못 본 몰에 있었을 수 있는데
|
||||||
|
'없음'으로 굳히면 TTL 동안 재검색이 막힌다(사용자가 '다시 검색'을 눌러도 캐시 히트).
|
||||||
|
|
||||||
|
## 4. 미해결 — 쿠팡 간헐적 차단
|
||||||
|
|
||||||
|
`사용권한이 제한된`(3.4KB)·`errors.edgesuite.net`(0.4KB) 마커로 막히는데 **IP 에 따라 갈린다**
|
||||||
|
(일부는 통과 → 서킷브레이커가 자동 해제). 즉 환경이 아니라 **IP 평판** 쪽이다.
|
||||||
|
|
||||||
|
유력한 원인: **쿠팡만 `gate`(국가 무지정 = 해외 IP)를 쓴다.** 네이버는 `kr_host`(한국 IP)로
|
||||||
|
바꾼 뒤 안정됐다. 같은 처방이 통할 가능성이 높다.
|
||||||
|
|
||||||
|
> 검증하려던 스크립트가 도구 시간 제한으로 중단됐다. 컨테이너에서 `gate` vs `kr` 통과율을
|
||||||
|
> IP 3개씩 비교하면 된다(네이버 UA 검증과 같은 방식).
|
||||||
|
|
||||||
|
⚠️ 맥 컨테이너는 amd64 를 **Rosetta 로 에뮬레이션**한다. 여기서 쿠팡이 막히는 건 실서버와
|
||||||
|
다를 수 있다 — 실서버에선 쿠팡이 60건 정상이었다. 판단은 실서버 로그로 한다.
|
||||||
|
|
||||||
|
## 5. 다음에 할 일 (우선순위)
|
||||||
|
|
||||||
|
1. **운영 DB 마이그레이션 적용** — 아직 안 됐다. 순서가 중요하다(아래 6번).
|
||||||
|
`6_lps_2026-08_dbeaver.sql`(lps_db) · `2026-08-07-iilp-source-state.sql`(negosium_db)
|
||||||
|
2. **쿠팡 KR 게이트웨이 검증** — 4번. 지금 최저가 커버리지의 최대 구멍이다.
|
||||||
|
3. **예산 튜닝** — F1 수정으로 `ip_request_no` 가 **처음으로 실제 사용량을 반영**한다.
|
||||||
|
`ip_req#1` 위주면 IP 평판, `2` 이상이면 예산 하향. **2026-08-06 이전 데이터는 쓰지 말 것.**
|
||||||
|
4. **AI 매칭 정확도 측정** — 세션 초반에 접근법만 논의하고 미착수. 정답 세트 20~30개로
|
||||||
|
"몇 % 맞나"를 재야 한다. 지금도 최저가 품질의 최대 병목이다.
|
||||||
|
5. lps-admin **잡 목록**에 몰별 상태 노출(상품 화면엔 이미 있음, 우선순위 낮음).
|
||||||
|
|
||||||
|
## 6. 작업 시 주의
|
||||||
|
|
||||||
|
- **마이그레이션 → 코드 순서를 지킬 것.** negodata 가 ORM 전체 엔티티를 조회하므로
|
||||||
|
(`lps_sync_crud.py:96`), 컬럼이 없는 DB 에 새 코드가 붙으면 **최저가 조회가 통째로 실패**한다
|
||||||
|
(재현 확인). 역순(마이그레이션 후 옛 코드)은 안전하다 — 옛 컨테이너로 실증했다.
|
||||||
|
- **워커를 켜 둔 채 pytest 하면 `test_job_queue` 3개가 깨진다.** 워커가 테스트 잡을 집어가서다.
|
||||||
|
코드 문제가 아니다 — `docker compose stop lps-worker` 후 돌리면 292 passed.
|
||||||
|
- **JS 로 브라우저 지문을 덮는 방법은 이 스택에서 통하지 않는다.** patchright 가
|
||||||
|
`add_init_script`(CDP 주입)를 무력화하고, MV3 확장도 값이 안 바뀐다. 다시 시도하기 전에
|
||||||
|
`services/search/fingerprint.py` docstring 을 볼 것.
|
||||||
|
- **로컬 dev 의 `negodata-front` 는 `node_modules` 가 named volume 이다.** 의존성이 늘면
|
||||||
|
이미지를 다시 빌드해도 반영되지 않는다 — 컨테이너 안에서 `npm install` 해야 한다(prod 은
|
||||||
|
`Dockerfile.prod` 라 해당 없음).
|
||||||
|
- 테스트가 실DB를 TRUNCATE 하는 문제는 그대로다(`price_history`·`job`·`ip_session`·`bot_detection`).
|
||||||
@ -309,12 +309,14 @@ class quotations(MainTableMixin, MAIN_BASE):
|
|||||||
equal_bid_yn = Column(Boolean, nullable=True)
|
equal_bid_yn = Column(Boolean, nullable=True)
|
||||||
equal_bid_data = Column(JSONB, nullable=True)
|
equal_bid_data = Column(JSONB, nullable=True)
|
||||||
close_reason = Column(SmallInteger, nullable=True) # CloseReason 코드. 마감 시 사유 기록(재생성 한도 카운팅·유찰 사유 구분). 미마감이면 NULL
|
close_reason = Column(SmallInteger, nullable=True) # CloseReason 코드. 마감 시 사유 기록(재생성 한도 카운팅·유찰 사유 구분). 미마감이면 NULL
|
||||||
|
award_type = Column(SmallInteger, nullable=True) # AwardType 코드(1=자동/2=직접). 낙찰 방식 — 통계에서 AI 자동낙찰과 담당자 직접낙찰 구분. 미낙찰이면 NULL
|
||||||
|
|
||||||
# 낙찰 기준(가격게이트) — 견적 단위. 마감 판정(close_and_decide)이 이 행값을 읽는다. 기준 미달이면 개찰(낙찰자 미정 마감).
|
# 낙찰 기준(가격게이트) — 견적 단위. 마감 판정(close_and_decide)이 이 행값을 읽는다. 기준 미달이면 개찰(낙찰자 미정 마감).
|
||||||
# 1:1 협상: over 는 항상 OPEN(목표 초과=개찰), mid 만 앵커/목표 택1. 1:N 경매: mid=over=AWARD 강제(무조건 최저가 낙찰).
|
# 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=개찰)
|
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=개찰)
|
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 값
|
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):
|
class sessions(MainTableMixin, MAIN_BASE):
|
||||||
@ -340,6 +342,7 @@ class sessions(MainTableMixin, MAIN_BASE):
|
|||||||
reject_reason = Column(String(255), nullable=True)
|
reject_reason = Column(String(255), nullable=True)
|
||||||
reject_price = Column(BigInteger, nullable=True)
|
reject_price = Column(BigInteger, nullable=True)
|
||||||
reject_delivery_type = Column(SmallInteger, nullable=True) # DeliveryType 코드
|
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=미발송)
|
email_sent_at = Column(DateTime(timezone=True), nullable=True) # 협상 초청 메일 발송 시각(NULL=미발송)
|
||||||
custom = Column(JSONB, nullable=True) # 협상완료 부가정보 값 {key: value} (정의는 companies.settings.session_fields)
|
custom = Column(JSONB, nullable=True) # 협상완료 부가정보 값 {key: value} (정의는 companies.settings.session_fields)
|
||||||
|
|
||||||
|
|||||||