From 3d515055fb28512c2c712eb4b8e794ecd2524659 Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Wed, 15 Jul 2026 16:38:13 +0900 Subject: [PATCH] =?UTF-8?q?[feat]=20negodata/front:=20=EC=98=A8=EB=B3=B4?= =?UTF-8?q?=EB=94=A9=20=EC=9D=B4=EB=AA=A8=EC=A7=80=E2=86=92=EC=95=84?= =?UTF-8?q?=EC=9D=B4=EC=BD=98=20=EA=B5=90=EC=B2=B4,=20step0=20=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=95=84=EC=9B=83=C2=B7=EC=95=A0=EB=8B=88=EB=A9=94?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20=EB=93=B1=20=EA=B0=9C=ED=8E=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../onboarding/OnboardingGuideModal.tsx | 4 +- .../onboarding/components/DetailSteps.tsx | 36 +- .../onboarding/components/InfoTip.tsx | 5 +- .../components/OnboardingWizard.tsx | 9 +- .../onboarding/components/OverviewStep.tsx | 315 +++++++++++------- negodata/front/src/index.css | 10 + 6 files changed, 225 insertions(+), 154 deletions(-) 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() { {/* 하단 네비 (고정) */}
{step === 0 ? '전체 흐름 · 한눈에' : `${step} / ${total - 1} 단계`} - +
); 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 && ( -
- 낙찰! -
- )} +
+
+ + {stamp && ( +
+
+ 낙찰! +
+
+ )} +
)}
-
+
{replay && ( )}
- - 다음을 누르면 각 단계를 하나씩 자세히 볼게요 ↓ + + 다음을 누르면 각 단계를 하나씩 자세히 볼게요 +
@@ -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