diff --git a/negodata/front/src/features/onboarding/OnboardingGuideModal.tsx b/negodata/front/src/features/onboarding/OnboardingGuideModal.tsx
index 9b2e28b..3f6f02b 100644
--- a/negodata/front/src/features/onboarding/OnboardingGuideModal.tsx
+++ b/negodata/front/src/features/onboarding/OnboardingGuideModal.tsx
@@ -116,8 +116,8 @@ const QUOTATION_TYPES: { key: string; label: string; scope: string; desc: string
const BOT_MOVES: { cond: string; action: string }[] = [
{ cond: '앵커링가 이하로 부르면', action: '바로 협상완료 — 투찰 확정' },
{ cond: '앵커링가에 아깝게 가까우면', action: '와일드카드로 마지막 압박 — 협상당 한 번' },
- { cond: '아직 높으면', action: '협상카드를 한 장씩 쓰며 재제안 요청' },
- { cond: '고른 카드를 다 써도 안 내려오면', action: '협상 실패 — 투찰 없음, 낙찰 후보 아님' },
+ { cond: '아직 높으면', action: '협상카드를 한 장씩 쓰며 재제안 — 최대 3번' },
+ { cond: '카드를 다 쓰거나 3번을 넘겨도 안 내려오면', action: '협상 실패 — 투찰 없음, 낙찰 후보 아님' },
];
// 마감·낙찰 — 언제 닫히고, 무엇을 보고, 케이스별로 어떻게 갈리나.
diff --git a/negodata/front/src/features/onboarding/components/DetailSteps.tsx b/negodata/front/src/features/onboarding/components/DetailSteps.tsx
index 68d97bb..0960702 100644
--- a/negodata/front/src/features/onboarding/components/DetailSteps.tsx
+++ b/negodata/front/src/features/onboarding/components/DetailSteps.tsx
@@ -1,5 +1,5 @@
import { useState, type ReactNode } from 'react';
-import { AlertTriangle, Bell, CheckCircle2, Clock3, FileCheck2, Info, RefreshCw, Trophy, UserCheck } from 'lucide-react';
+import { AlertTriangle, ArrowRight, Bell, Building2, Check, CheckCircle2, Clock3, FileCheck2, FileText, Handshake, Info, Layers, Link2, Mail, Package, RefreshCw, Target, Trophy, UserCheck, type LucideIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Typography } from '@/components/ui/typography';
import { InfoTip } from './InfoTip';
@@ -51,7 +51,7 @@ export function Callout({ tone = 'info', title, children }: { tone?: CalloutTone
}
/* ── STEP 1 · 준비 ─────────────────────────────────────────── */
-function RegCard({ ico, sub, name, meta, connect, optional, tone = 'primary' }: { ico: string; sub: string; name: ReactNode; meta: ReactNode; connect?: boolean; optional?: boolean; tone?: 'primary' | 'must' }) {
+function RegCard({ Icon, sub, name, meta, connect, optional, tone = 'primary' }: { Icon: LucideIcon; sub: string; name: ReactNode; meta: ReactNode; connect?: boolean; optional?: boolean; tone?: 'primary' | 'must' }) {
const activeCls = optional ? 'bg-muted/40 text-muted-foreground' : tone === 'must' ? 'bg-destructive/5' : 'bg-primary/5';
const activeTextCls = tone === 'must' ? 'text-destructive' : 'text-primary';
return (
@@ -61,14 +61,14 @@ function RegCard({ ico, sub, name, meta, connect, optional, tone = 'primary' }:
선택사항
)}
-
{ico}
+
{sub}
{name}
{meta}
);
}
-const RegArrow = () => → ;
+const RegArrow = () => ;
export function PrepareStep() {
return (
@@ -76,11 +76,11 @@ export function PrepareStep() {
무엇을 살지(상품), 누구에게 보낼지(협력사), 그리고 그 협력사가 A4용지를 어떤 방식으로 조달하는지 연결해요.
-
+
- 담당자 이메일 ✓ >} />
+ 담당자 이메일 >} />
-
+
@@ -106,10 +106,10 @@ function QuoteStatus({ children, tone }: { children: ReactNode; tone: QuoteStatu
);
}
-function QuoteLine({ ico, label, status, statusTone = 'manual', value, tip }: { ico: string; label: string; status?: ReactNode; statusTone?: QuoteStatusTone; value: ReactNode; tip: ReactNode }) {
+function QuoteLine({ Icon, label, status, statusTone = 'manual', value, tip }: { Icon: LucideIcon; label: string; status?: ReactNode; statusTone?: QuoteStatusTone; value: ReactNode; tip: ReactNode }) {
return (
- {ico}
+
{label}
@@ -140,11 +140,11 @@ export function QuoteStep() {
- 여러 후보가 있으면 여러 곳 경쟁(견적) , 단골 한 곳을 더 깎고 싶으면 한 곳 협상 . 협상카드 밀당은 '한 곳 협상'에서 돌아요.>} />
- 협상에 부칠 상품과 견적을 보낼 협력사예요. 한 곳 협상은 이 협력사와 1:1로 진행됩니다.>} />
- 목표가는 상품 기준 가격에서 자동으로 잡고, 앵커링가는 협상 완료 기준선 이에요. 협력사 제시가가 앵커링가 이하로 내려오면 협상완료로 넘어갑니다.>} />
- 한 곳 협상에서 봇이 쓸 협상카드를 고릅니다. 선택한 일반 협상카드를 모두 쓰거나 라운드 상한에 걸릴 때까지 앵커링가 이하로 내려오지 못하면 협상은 실패로 끝납니다.>} />
- 협상이 끝났을 때 어디까지를 낙찰로 볼지 미리 정해요. 엄격(앵커링가까지)하면 더 싸게 사지만 낙찰이 덜 되고, 느슨(목표가까지)하면 낙찰은 잘 되는 대신 절감폭이 줄어요. (여러 곳 경쟁은 단독 최저가 자동 낙찰.)>} />
+ 여러 후보가 있으면 여러 곳 경쟁(견적) , 단골 한 곳을 더 깎고 싶으면 한 곳 협상 . 협상카드 밀당은 '한 곳 협상'에서 돌아요.>} />
+ 협상에 부칠 상품과 견적을 보낼 협력사예요. 한 곳 협상은 이 협력사와 1:1로 진행됩니다.>} />
+ 목표가는 상품 기준 가격에서 자동으로 잡고, 앵커링가는 협상 완료 기준선 이에요. 협력사 제시가가 앵커링가 이하로 내려오면 협상완료로 넘어갑니다.>} />
+ 한 곳 협상에서 봇이 쓸 협상카드를 고릅니다. 봇은 고른 카드를 한 장씩 쓰며 최대 3번까지 재제안하고, 그때까지 앵커링가 이하로 내려오지 못하면 협상은 실패로 끝납니다.>} />
+ 협상이 끝났을 때 어디까지를 낙찰로 볼지 미리 정해요. 엄격(앵커링가까지)하면 더 싸게 사지만 낙찰이 덜 되고, 느슨(목표가까지)하면 낙찰은 잘 되는 대신 절감폭이 줄어요. (여러 곳 경쟁은 단독 최저가 자동 낙찰.)>} />
@@ -163,11 +163,11 @@ export function InviteStep() {
여기가 제일 놓치기 쉬운 곳이에요. 견적을 만들었다고 협력사에게 자동으로 알려지는 건 아니에요.
-
+
-
+
-
+
@@ -313,7 +313,7 @@ function NegotiationSlider() {
그래도 가격을 안 낮추면?
- 선택한 협상카드 를 다 쓰거나 라운드 상한에 걸릴 때까지 더 낮추지 못하면 협상은 실패로 끝나요. 이 경우는 협상완료·투찰 확정이 아니어서 낙찰 후보가 되지 않습니다.
+ 봇은 고른 협상카드를 한 장씩 쓰며 최대 3번까지 재제안 해요. 그때까지 앵커링가 밑으로 못 내려오면 협상은 실패로 끝나고, 이 경우는 협상완료·투찰 확정이 아니어서 낙찰 후보가 되지 않습니다.
diff --git a/negodata/front/src/features/onboarding/components/InfoTip.tsx b/negodata/front/src/features/onboarding/components/InfoTip.tsx
index 87df430..8d0a44f 100644
--- a/negodata/front/src/features/onboarding/components/InfoTip.tsx
+++ b/negodata/front/src/features/onboarding/components/InfoTip.tsx
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState, type ReactNode } from 'react';
+import { Lightbulb } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Typography } from '@/components/ui/typography';
@@ -41,8 +42,8 @@ export function InfoTip({ children, label = '왜 필요해요?', mini = false, a
{mini ? (
'?'
) : (
-
- 💡 {label}
+
+ {label}
)}
diff --git a/negodata/front/src/features/onboarding/components/OnboardingWizard.tsx b/negodata/front/src/features/onboarding/components/OnboardingWizard.tsx
index 18eb6e1..3fa367e 100644
--- a/negodata/front/src/features/onboarding/components/OnboardingWizard.tsx
+++ b/negodata/front/src/features/onboarding/components/OnboardingWizard.tsx
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router';
+import { ArrowLeft, ArrowRight, Check, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Typography } from '@/components/ui/typography';
@@ -50,7 +51,7 @@ export function OnboardingWizard() {
{/* 건너뛰기 */}
- 건너뛰기 ✕
+ 건너뛰기
@@ -109,13 +110,15 @@ export function OnboardingWizard() {
{/* 하단 네비 (고정) */}
setStep((s) => Math.max(0, s - 1))}>
- ← 이전
+ 이전
{step === 0 ? '전체 흐름 · 한눈에' : `${step} / ${total - 1} 단계`}
-
{step === 0 ? '하나씩 볼게요 →' : atEnd ? '시작하기 ✓' : '다음 →'}
+
+ {step === 0 ? <>하나씩 볼게요 > : atEnd ? <>시작하기 > : <>다음 >}
+
);
diff --git a/negodata/front/src/features/onboarding/components/OverviewStep.tsx b/negodata/front/src/features/onboarding/components/OverviewStep.tsx
index edeb12b..5251132 100644
--- a/negodata/front/src/features/onboarding/components/OverviewStep.tsx
+++ b/negodata/front/src/features/onboarding/components/OverviewStep.tsx
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react';
-import { RotateCcw } from 'lucide-react';
+import { ArrowDown, Bot, Building2, ClipboardList, Handshake, Layers, Mail, RotateCcw, Trophy, type LucideIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Typography } from '@/components/ui/typography';
@@ -22,29 +22,31 @@ const MESSAGES: Msg[] = [
{ side: 'bot', card: 'end', text: '네, 94,800원으로 협상을 마무리할게요.' },
];
-const CARD_TAG: Record = {
- nego: { label: '🃏 협상카드', cls: 'text-primary bg-primary/10 border-primary/20' },
- wild: { label: '🃏 와일드카드 · 막판', cls: 'text-amber-600 dark:text-amber-400 bg-amber-500/10 border-amber-500/40' },
+const CARD_TAG: Record = {
+ nego: { label: '협상카드', cls: 'text-primary bg-primary/10 border-primary/20', Icon: Layers },
+ wild: { label: '와일드카드 · 막판', cls: 'text-amber-600 dark:text-amber-400 bg-amber-500/10 border-amber-500/40', Icon: Layers },
end: { label: '협상 완료', cls: 'text-muted-foreground bg-muted border-border' },
};
-const CHIPS = [
- { icon: '📋', label: '준비' },
- { icon: '✉️', label: '발송' },
- { icon: '🤝', label: '협상' },
- { icon: '🏆', label: '낙찰' },
+const CHIPS: { Icon: LucideIcon; label: string }[] = [
+ { Icon: ClipboardList, label: '준비' },
+ { Icon: Mail, label: '발송' },
+ { Icon: Handshake, label: '협상' },
+ { Icon: Trophy, label: '낙찰' },
];
export function OverviewStep() {
const [runId, setRunId] = useState(0);
const [phase, setPhase] = useState(0); // 0 등록 · 1 이메일 · 2 협상 · 3 낙찰
const [typeSel, setTypeSel] = useState(false);
- const [target, setTarget] = useState('···');
+ const [regStep, setRegStep] = useState(0); // 0 없음 · 1 진행방식 · 2 상품 · 3 공급사
+ const [anchorNum, setAnchorNum] = useState(null); // 앵커링가 산정 결과(스마트 카운트)
const [envOpen, setEnvOpen] = useState(false);
const [msgIdx, setMsgIdx] = useState(-1);
const [priceNum, setPriceNum] = useState(null);
const [drop, setDrop] = useState(null);
const displayPrice = useCountDown(priceNum);
+ const displayAnchor = useCountDown(anchorNum);
const [speaking, setSpeaking] = useState(null);
const [bidMoved, setBidMoved] = useState(false);
const [stamp, setStamp] = useState(false);
@@ -54,46 +56,45 @@ export function OverviewStep() {
const timers: number[] = [];
const at = (t: number, fn: () => void) => timers.push(window.setTimeout(fn, t));
- setPhase(0); setTypeSel(false); setTarget('···'); setEnvOpen(false);
- setMsgIdx(-1); setPriceNum(null); setDrop(null); setSpeaking(null);
- setBidMoved(false); setStamp(false); setReplay(false);
-
- // 1) 등록 — 유형 선택 + 공략가 스마트 산정
- at(900, () => setTypeSel(true));
- at(1700, () => setTarget('97,000'));
- at(2000, () => setTarget('94,000'));
- at(2300, () => setTarget('95,800'));
- at(2750, () => setTarget('95,000원'));
-
- // 2) 이메일 — 편지 커지며 사라짐
- at(3800, () => setPhase(1));
- at(4100, () => setEnvOpen(true));
-
- // 3) 협상 — 말풍선 하나씩 + 제시가 하락
- const chatStart = 5500, gap = 1400;
- at(chatStart - 200, () => setPhase(2));
- let prev: number | null = null;
- MESSAGES.forEach((m, i) => {
- at(chatStart + i * gap, () => {
- setMsgIdx(i);
- setSpeaking(m.side);
- if (m.priceNum != null) {
- setDrop(prev != null ? `▼ ${(prev - m.priceNum).toLocaleString('en-US')}` : null);
- setPriceNum(m.priceNum);
- prev = m.priceNum;
- }
+ // 현재 단계 애니메이션 재생 후 다음 단계로 자동 전환. 마지막(낙찰)에서 '다시 보기' 노출.
+ if (phase === 0) {
+ setRegStep(0); setTypeSel(false); setAnchorNum(null); setReplay(false);
+ at(850, () => setRegStep(1)); // 진행 방식 등장
+ at(1700, () => setTypeSel(true)); // 협상 선택됨
+ at(2600, () => setRegStep(2)); // 상품
+ at(3450, () => setRegStep(3)); // 공급사
+ at(4400, () => setAnchorNum(110_000)); // 기준가에서 등장
+ at(4900, () => setAnchorNum(95_000)); // 앵커링가로 카운트다운(스마트 산정)
+ at(7800, () => setPhase(1));
+ } else if (phase === 1) {
+ setEnvOpen(false);
+ at(700, () => setEnvOpen(true));
+ at(3000, () => setPhase(2));
+ } else if (phase === 2) {
+ setMsgIdx(-1); setPriceNum(null); setDrop(null); setSpeaking(null);
+ const start = 700, gap = 1300;
+ let prev: number | null = null;
+ MESSAGES.forEach((m, i) => {
+ at(start + i * gap, () => {
+ setMsgIdx(i);
+ setSpeaking(m.side);
+ if (m.priceNum != null) {
+ setDrop(prev != null ? `▼ ${(prev - m.priceNum).toLocaleString('en-US')}` : null);
+ setPriceNum(m.priceNum);
+ prev = m.priceNum;
+ }
+ });
});
- });
-
- // 4) 낙찰 — 스펙트럼 + 스탬프
- const chatEnd = chatStart + MESSAGES.length * gap;
- at(chatEnd + 400, () => { setPhase(3); setSpeaking(null); });
- at(chatEnd + 850, () => setBidMoved(true));
- at(chatEnd + 2800, () => setStamp(true));
- at(chatEnd + 3500, () => setReplay(true));
+ at(start + MESSAGES.length * gap, () => setPhase(3));
+ } else if (phase === 3) {
+ setSpeaking(null); setBidMoved(false); setStamp(false);
+ at(500, () => setBidMoved(true));
+ at(1900, () => setStamp(true));
+ at(2900, () => setReplay(true));
+ }
return () => timers.forEach(clearTimeout);
- }, [runId]);
+ }, [phase, runId]);
const cur = msgIdx >= 0 ? MESSAGES[msgIdx] : null;
@@ -113,63 +114,105 @@ export function OverviewStep() {
- {/* 진행 칩 */}
-
- {CHIPS.map((c, i) => {
- const active = phase === i;
- return (
-
- {c.icon}
- {c.label}
-
- );
- })}
+ {/* 진행 스텝 — 아이콘 원 + 원 중심을 잇는 연결선(현재까지 채움) */}
+
+
+ {/* 연결선: 원 중심 높이에서 뒤에 깔리고, 현재 단계까지 채워짐 */}
+
+
+
+ {CHIPS.map((c, i) => {
+ const active = phase === i;
+ const done = phase > i;
+ return (
+
+
+
+
+
+ {c.label}
+
+
+ );
+ })}
+
{/* 스테이지 */}
{phase === 0 && (
-
-
-
-
🤝 협상 · 1:1
-
한 곳과 밀당
+
+
+ {/* 등록·선택 값 (요약 리스트 — 라벨 왼쪽 / 값 오른쪽) */}
+
+ {/* 진행 방식 = 협상/견적 세그먼트 토글 */}
+
= 1 ? 'translate-y-0 opacity-100' : 'translate-y-1 opacity-0')}>
+
진행 방식
+
+
+
+ 협상·1:1
+
+
+ 견적·1:N
+
+
+
+
= 2} label="상품" value="A4용지" />
+ = 2} label="기준가" value="110,000원" mono />
+ = 3} label="공급사" value="대성문구" />
-
-
🏢 견적 · 1:N
-
여러 곳 경쟁
+
+ {/* 결과 — 위 값들 근거로 AI가 앵커링가 스마트 산정 (영수증 '합계' 자리) */}
+
+
+
+ 앵커링가
+
+ AI 스마트 산정
+
+ {displayAnchor == null ? (
+ 산정 중…
+ ) : (
+
+ {displayAnchor.toLocaleString('en-US')}원
+
+ )}
-
- 📍
-
- 앵커링가 자동 산정
- 목표가보다 낮은 협상 완료 기준선
-
-
- {target}
-
-
)}
{phase === 1 && (
)}
{phase === 2 && (
-
+
{/* 제시가 바 — 이전값에서 새 제시가까지 숫자가 주르륵 내려간다 */}
상대회사 제시가
@@ -182,32 +225,36 @@ export function OverviewStep() {
{displayPrice != null ? `${displayPrice.toLocaleString('en-US')}원` : '—'}
- {/* 대화 (아바타 + 현재 말풍선) */}
-
+ {/* 대화 (아바타 + 현재 말풍선) — 버블은 flex로 세로중앙정렬(transform 미사용 → 흔들림 없음) */}
+
{cur && (
- {cur.side === 'bot' ? (
-
- {cur.card && (
-
- {CARD_TAG[cur.card].label}
-
- )}
- {cur.text}
-
- ) : (
-
- {cur.text}
-
- )}
+
+ {cur.side === 'bot' ? (
+
+ {cur.card && (() => {
+ const tag = CARD_TAG[cur.card];
+ const TagIcon = tag.Icon;
+ return (
+
+ {TagIcon && }
+ {tag.label}
+
+ );
+ })()}
+ {cur.text}
+
+ ) : (
+
+ {cur.text}
+
+ )}
+
)}
@@ -215,22 +262,26 @@ export function OverviewStep() {
)}
{phase === 3 && (
-
-
- {stamp && (
-
- 낙찰!
-
- )}
+
)}
-
+
{replay && (
setRunId((n) => n + 1)}
+ onClick={() => { setPhase(0); setRunId((n) => n + 1); }}
className="mx-auto inline-flex cursor-pointer items-center gap-1.5 rounded-full border border-border bg-muted px-4 py-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground animate-in fade-in"
>
@@ -238,8 +289,9 @@ export function OverviewStep() {
)}
-
- 다음 을 누르면 각 단계를 하나씩 자세히 볼게요 ↓
+
+ 다음 을 누르면 각 단계를 하나씩 자세히 볼게요
+
@@ -254,15 +306,10 @@ function AwardTrack({ moved }: { moved: boolean }) {
const targetPct = 66;
const glide = 'transition-[left,width] duration-[1600ms] ease-[cubic-bezier(.3,.7,.3,1)]';
return (
-
-
-
- 협상 결과 · 투찰가
- 94,800원
-
-
- 낙찰 구간
-
+
+
+ 투찰가
+ 94,800원
@@ -321,19 +368,29 @@ function useCountDown(target: number | null, duration = 700) {
return display;
}
+// 요약 행 — 라벨 왼쪽 / 값 오른쪽. show 되면 아래에서 떠오름.
+function PrepRow({ show, label, value, mono }: { show: boolean; label: string; value: string; mono?: boolean }) {
+ return (
+
+ {label}
+ {value}
+
+ );
+}
+
function Avatar({ side, speaking }: { side: Side; speaking: boolean }) {
const isBot = side === 'bot';
return (
- {isBot ? '🤖' : '🏢'}
+ {isBot ? : }
{isBot ? '협상봇' : '상대회사'}
diff --git a/negodata/front/src/index.css b/negodata/front/src/index.css
index 283c2de..8ddc4c8 100644
--- a/negodata/front/src/index.css
+++ b/negodata/front/src/index.css
@@ -53,4 +53,14 @@ body {
html {
@apply font-sans;
}
+}
+
+/* 낙찰 도장 슬램 — 크게 들어와 탁 박히고(0.9로 오버슈트) 엷게(0.75) settle. rotate 포함해 transform 충돌 방지 */
+@theme {
+ --animate-stamp: stamp 0.42s cubic-bezier(0.2, 0.8, 0.2, 1) both;
+}
+@keyframes stamp {
+ 0% { opacity: 0; transform: rotate(-12deg) scale(1.8); }
+ 55% { opacity: 0.95; transform: rotate(-12deg) scale(0.9); }
+ 100% { opacity: 0.75; transform: rotate(-12deg) scale(1); }
}
\ No newline at end of file