148 lines
8.2 KiB
TypeScript
148 lines
8.2 KiB
TypeScript
import { useEffect, useRef, type Dispatch, type SetStateAction } from 'react';
|
|
import { Typography } from '@/components/ui/typography';
|
|
import { type ComboOption } from '@/components/ui/combobox';
|
|
import { CONDITION_VARIABLE, CONDITION_LABEL } from '@/features/cards/editor/variables';
|
|
import type { NegotiationCard } from '../types';
|
|
|
|
type CardDetail = { code: string; title: string; isWildcard: boolean };
|
|
|
|
type UseCardGatingParams = {
|
|
cardRows: NegotiationCard[];
|
|
productId: string;
|
|
internetLowest: number | null;
|
|
estimatedTargetPrice: number | null;
|
|
open: boolean;
|
|
selectedCardIds: string[];
|
|
cardDetails: Map<string, CardDetail>;
|
|
setSelectedCardIds: Dispatch<SetStateAction<string[]>>;
|
|
setCardDetails: Dispatch<SetStateAction<Map<string, CardDetail>>>;
|
|
/** 직전 라운드에서 쓴 카드 id — 목록에 '직전 사용' 배지를 달아 같은 멘트를 또 던지는 걸 눈에 띄게 한다(재생성 전용). */
|
|
previousCardIds?: string[];
|
|
};
|
|
|
|
// 카드 선택 게이팅 — 협상 멘트에 변수 토큰이 그대로 노출되거나 논리가 모순되는 카드를 선택 단계에서 막는다.
|
|
// 추천 상위 3개 자동 선택·부적합 카드 자동 해제 effect 도 여기서 소유한다(상태는 컴포넌트가 보유, 세터로 갱신).
|
|
export function useCardGating({
|
|
cardRows,
|
|
productId,
|
|
internetLowest,
|
|
estimatedTargetPrice,
|
|
open,
|
|
selectedCardIds,
|
|
cardDetails,
|
|
setSelectedCardIds,
|
|
setCardDetails,
|
|
previousCardIds,
|
|
}: UseCardGatingParams) {
|
|
const previousSet = new Set(previousCardIds ?? []);
|
|
// (1) 조건 전략(customer_condition) 미작성 — 저장 시 조건 내용이 있으면 script 에 실제 문구가
|
|
// 주입되고(slate.serialize), 없으면 {customer_condition} 토큰이 그대로 남는다.
|
|
const conditionUnfilled = (c: NegotiationCard) =>
|
|
(c.scriptPreview ?? '').includes(`{${CONDITION_VARIABLE}}`);
|
|
// 시장가(인터넷최저가) 인용 카드({internet_lowest_price} 토큰)인지 — (2)·(4) 공용 술어.
|
|
const citesInternetLowest = (c: NegotiationCard) => (c.scriptPreview ?? '').includes('{internet_lowest_price}');
|
|
// (2) 인터넷 최저가 인용 카드는 선택 상품에 최저가가 수집돼 있을 때만 —
|
|
// 최저가 없는 상품(items.internet_lowest_price=NULL/0)의 견적에 넣으면 협상 시 토큰이 노출된다.
|
|
// 상품 미선택 상태에선 판정 불가라 막지 않는다(상품 선택 후에만 게이팅).
|
|
const lowestUnavailable = !!productId && !(internetLowest && internetLowest > 0);
|
|
const lowestPriceLeak = (c: NegotiationCard) => lowestUnavailable && citesInternetLowest(c);
|
|
// (4) 시장가 논리 모순 — 인터넷최저가 ≥ 목표가면 "시장가가 더 싸다" 근거가 성립 안 한다.
|
|
// 이런 상품에 시장가 인용 카드를 넣으면 목표가보다 높은 값을 근거로 깎으라는 모순 멘트가 나간다.
|
|
const lowestAboveTarget = internetLowest != null && estimatedTargetPrice != null && internetLowest >= estimatedTargetPrice;
|
|
const marketContradiction = (c: NegotiationCard) => lowestAboveTarget && citesInternetLowest(c);
|
|
// (3) 미승인 와일드카드(INACTIVE) — 목록·순위엔 보이되 선택은 막는다(수동 승인 전).
|
|
const blockReason = (c: NegotiationCard): string | null =>
|
|
c.isWildcard && c.status !== 'ACTIVE'
|
|
? '미승인 와일드카드'
|
|
: conditionUnfilled(c)
|
|
? `${CONDITION_LABEL} 미작성`
|
|
: lowestPriceLeak(c)
|
|
? '인터넷 최저가 미수집'
|
|
: marketContradiction(c)
|
|
? '시장가 근거 성립 불가'
|
|
: null;
|
|
// 성공률(사용 세션 중 타결 비율) 내림차순 — 표본 없는 카드는 뒤로. 상위 3개에 1·2·3위 배지가 붙는다.
|
|
// 미승인 와일드카드도 목록·순위엔 노출(선택은 blockReason 으로 disabled).
|
|
const rankedCards = cardRows
|
|
.slice()
|
|
.sort((a, b) => b.successRate - a.successRate || b.usedCount - a.usedCount);
|
|
const cardOptions: ComboOption[] = rankedCards
|
|
.map((card, i) => {
|
|
const reason = blockReason(card);
|
|
return {
|
|
id: card.id,
|
|
label: card.title,
|
|
disabled: !!reason,
|
|
node: (
|
|
<div className={reason ? 'opacity-60' : undefined}>
|
|
<div className="flex items-center gap-1.5">
|
|
{card.usedCount > 0 && (
|
|
<span className={`text-[9px] font-mono px-1.5 py-0.5 rounded leading-none ${i < 3 ? 'bg-indigo-50 text-indigo-700' : 'bg-zinc-100 text-zinc-500'}`}>
|
|
{i + 1}위 · 성공률 {Math.round(card.successRate * 100)}%
|
|
</span>
|
|
)}
|
|
<Typography as="span" variant="small" className="text-muted-foreground font-mono block leading-none">{card.code}</Typography>
|
|
<span className={`text-[9px] font-mono px-1.5 py-0.5 rounded leading-none ${card.isWildcard ? 'bg-amber-50 text-amber-700' : 'bg-zinc-100 text-zinc-600'}`}>
|
|
{card.isWildcard ? '와일드' : '협상'}
|
|
</span>
|
|
{previousSet.has(card.id) && (
|
|
<span className="text-[9px] font-mono px-1.5 py-0.5 rounded leading-none bg-violet-50 text-violet-700">
|
|
직전 사용
|
|
</span>
|
|
)}
|
|
{reason && (
|
|
<span className="text-[9px] font-mono px-1.5 py-0.5 rounded leading-none bg-rose-50 text-rose-600">
|
|
{reason}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<Typography as="span" variant="small" className="mt-1 block leading-tight">{card.title}</Typography>
|
|
</div>
|
|
),
|
|
};
|
|
});
|
|
|
|
// 1·2·3위 배지가 붙는 카드(상위 3개, 사용이력 있는 것만) — 기본 선택 대상. 선택 불가(조건 미작성·최저가 미수집) 카드는 제외.
|
|
const topRankedCards = rankedCards.filter((c) => !blockReason(c)).slice(0, 3).filter((c) => c.usedCount > 0);
|
|
const topRankedKey = topRankedCards.map((c) => c.id).join(',');
|
|
const autoSelectedRef = useRef(false);
|
|
|
|
// 모달을 열면 추천 상위 3개를 기본 선택해 둔다. 열려 있는 동안 1회만 — 이후 사용자의 추가/해제는 건드리지 않는다.
|
|
useEffect(() => {
|
|
if (!open) {
|
|
autoSelectedRef.current = false;
|
|
return;
|
|
}
|
|
if (autoSelectedRef.current || topRankedCards.length === 0) return; // 목록 로드 전이면 다음 렌더에 재시도
|
|
autoSelectedRef.current = true;
|
|
setCardDetails((m) => {
|
|
const next = new Map(m);
|
|
topRankedCards.forEach((c) => next.set(c.id, { code: c.code, title: c.title, isWildcard: c.isWildcard }));
|
|
return next;
|
|
});
|
|
setSelectedCardIds((prev) => (prev.length > 0 ? prev : topRankedCards.map((c) => c.id)));
|
|
// topRankedKey = 목록이 확정된 시점만 감지 (배열 재생성으로 매 렌더 도는 것 방지)
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [open, topRankedKey]);
|
|
|
|
// 이미 고른 카드 중, 시장가 인용 카드({internet_lowest_price})가 부적합해지면 자동 해제.
|
|
// 부적합 = 최저가 미수집(lowestUnavailable) 또는 인터넷최저가 ≥ 목표가(lowestAboveTarget, 시장가 근거 모순).
|
|
// (picklist disabled 는 신규 선택만 막으므로, 상품·목표가 변경 후 잔존 선택분을 여기서 정리해 노출·모순을 막는다.)
|
|
useEffect(() => {
|
|
if (!lowestUnavailable && !lowestAboveTarget) return;
|
|
const leakIds = new Set(cardRows.filter(citesInternetLowest).map((c) => c.id));
|
|
if (leakIds.size === 0) return;
|
|
setSelectedCardIds((prev) => (prev.some((id) => leakIds.has(id)) ? prev.filter((id) => !leakIds.has(id)) : prev));
|
|
// 상품·목표가 변경 시점에만 정리 (cardRows 재생성으로 매 렌더 도는 것 방지)
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [lowestUnavailable, lowestAboveTarget, productId]);
|
|
|
|
// 선택된 카드 표시행 — 캐시에서 번호/유형/카드명을 읽어 검색어와 무관하게 유지한다.
|
|
const selectedCardRows = selectedCardIds.map((id) => {
|
|
const d = cardDetails.get(id);
|
|
return { id, code: d?.code ?? '', title: d?.title ?? id, isWildcard: d?.isWildcard ?? false };
|
|
});
|
|
|
|
return { blockReason, cardOptions, selectedCardRows };
|
|
}
|