[feat] negodata/front: 온보딩 이모지→아이콘 교체, step0 레이아웃·애니메이션 등 개편

This commit is contained in:
Mina Choi 2026-07-15 16:38:13 +09:00
parent 21f90ed84a
commit 3d515055fb
6 changed files with 225 additions and 154 deletions

View File

@ -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: '협상 실패 — 투찰 없음, 낙찰 후보 아님' },
];
// 마감·낙찰 — 언제 닫히고, 무엇을 보고, 케이스별로 어떻게 갈리나.

View File

@ -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' }:
선택사항
</span>
)}
<div className="text-[26px] leading-none">{ico}</div>
<Icon className={cn('mx-auto size-6', connect && !optional ? activeTextCls : 'text-muted-foreground')} />
<Typography variant="label" className={cn('mt-2 block text-[11px]', connect && !optional ? activeTextCls : 'text-muted-foreground')}>{sub}</Typography>
<Typography variant="small" className={cn('mt-1.5 block font-bold leading-snug', connect && !optional && activeTextCls)}>{name}</Typography>
<Typography variant="caption" className="mt-1 block text-muted-foreground">{meta}</Typography>
</div>
);
}
const RegArrow = () => <span className="flex items-center px-1.5 text-muted-foreground max-[640px]:justify-center max-[640px]:rotate-90 max-[640px]:py-1">→</span>;
const RegArrow = () => <span className="flex items-center px-1.5 text-muted-foreground max-[640px]:justify-center max-[640px]:rotate-90 max-[640px]:py-1"><ArrowRight className="size-4" /></span>;
export function PrepareStep() {
return (
@ -76,11 +76,11 @@ export function PrepareStep() {
<StepHeader num={1} kicker="먼저 할 일" actor="내가" tone="user" title="상품·협력사를 등록하고 연결해요" />
<Typography variant="muted" className="mb-5">무엇을 살지(상품), 누구에게 보낼지(협력사), 그리고 그 협력사가 A4용지를 어떤 방식으로 조달하는지 연결해요.</Typography>
<div className="flex items-stretch justify-center gap-1 max-[640px]:flex-col">
<RegCard ico="📦" sub="무엇을 살까" name="A4용지" meta="기준가 100,000원" />
<RegCard Icon={Package} sub="무엇을 살까" name="A4용지" meta="기준가 100,000원" />
<RegArrow />
<RegCard ico="🏢" sub="누구에게 보낼까" name="우리제지" meta={<>담당자 이메일 <span className="font-extrabold text-emerald-600 dark:text-emerald-400">✓</span></>} />
<RegCard Icon={Building2} sub="누구에게 보낼까" name="우리제지" meta={<>담당자 이메일 <Check className="ml-0.5 inline size-3.5 align-text-bottom text-emerald-600 dark:text-emerald-400" /></>} />
<RegArrow />
<RegCard ico="🔗" sub="어떻게 조달하나" name="우리제지 → A4용지" meta="조달방식: 제조" connect optional />
<RegCard Icon={Link2} sub="어떻게 조달하나" name="우리제지 → A4용지" meta="조달방식: 제조" connect optional />
</div>
<div className="mt-4">
<Callout title="왜 조달방식을 묻나요?">
@ -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 (
<div className="grid min-h-[64px] grid-cols-[2rem_minmax(0,1fr)_auto] items-center gap-3 px-4 py-3">
<span className="grid size-8 flex-none place-items-center rounded-md bg-muted text-base">{ico}</span>
<span className="grid size-8 flex-none place-items-center rounded-md bg-muted text-muted-foreground"><Icon className="size-4" /></span>
<span className="min-w-0">
<span className="flex flex-wrap items-center gap-1.5">
<Typography as="span" variant="caption" className="font-bold text-muted-foreground">{label}</Typography>
@ -140,11 +140,11 @@ export function QuoteStep() {
</div>
</div>
<div className="divide-y divide-border/80">
<QuoteLine ico="🤝" label="진행 방식" status="직접 설정" value="한 곳과 협상 · 1:1" tip={<>여러 후보가 있으면 <b className="text-foreground">여러 곳 경쟁(견적)</b>, 단골 한 곳을 더 깎고 싶으면 <b className="text-foreground">한 곳 협상</b>. 협상카드 밀당은 '한 곳 협상'에서 돌아요.</>} />
<QuoteLine ico="📦" label="대상" value="A4용지 · 우리제지" tip={<>협상에 부칠 상품과 견적을 보낼 협력사예요. 한 곳 협상은 이 협력사와 1:1로 진행됩니다.</>} />
<QuoteLine ico="🎯" label="가격 기준" status="자동" statusTone="auto" value="목표가 100,000원 · 앵커링가 95,000원" tip={<>목표가는 상품 기준 가격에서 자동으로 잡고, 앵커링가는 <b className="text-foreground">협상 완료 기준선</b>이에요. 협력사 제시가가 앵커링가 이하로 내려오면 협상완료로 넘어갑니다.</>} />
<QuoteLine ico="🃏" label="협상 한도" status="직접 설정" value="협상카드 선택 · 사용한도 설정" tip={<>한 곳 협상에서 봇이 쓸 협상카드를 고릅니다. 선택한 일반 협상카드를 모두 쓰거나 라운드 상한에 걸릴 때까지 앵커링가 이하로 내려오지 못하면 협상은 실패로 끝납니다.</>} />
<QuoteLine ico="🏆" label="낙찰 기준" status="직접 설정" value="앵커링가까지 · 엄격" tip={<>협상이 끝났을 때 <b className="text-foreground">어디까지를 낙찰로 볼지</b> 미리 정해요. 엄격(앵커링가까지)하면 더 싸게 사지만 낙찰이 덜 되고, 느슨(목표가까지)하면 낙찰은 잘 되는 대신 절감폭이 줄어요. (여러 곳 경쟁은 단독 최저가 자동 낙찰.)</>} />
<QuoteLine Icon={Handshake} label="진행 방식" status="직접 설정" value="한 곳과 협상 · 1:1" tip={<>여러 후보가 있으면 <b className="text-foreground">여러 곳 경쟁(견적)</b>, 단골 한 곳을 더 깎고 싶으면 <b className="text-foreground">한 곳 협상</b>. 협상카드 밀당은 '한 곳 협상'에서 돌아요.</>} />
<QuoteLine Icon={Package} label="대상" value="A4용지 · 우리제지" tip={<>협상에 부칠 상품과 견적을 보낼 협력사예요. 한 곳 협상은 이 협력사와 1:1로 진행됩니다.</>} />
<QuoteLine Icon={Target} label="가격 기준" status="자동" statusTone="auto" value="목표가 100,000원 · 앵커링가 95,000원" tip={<>목표가는 상품 기준 가격에서 자동으로 잡고, 앵커링가는 <b className="text-foreground">협상 완료 기준선</b>이에요. 협력사 제시가가 앵커링가 이하로 내려오면 협상완료로 넘어갑니다.</>} />
<QuoteLine Icon={Layers} label="협상 한도" status="직접 설정" value="협상카드 선택 · 사용한도 설정" tip={<>한 곳 협상에서 봇이 쓸 협상카드를 고릅니다. 봇은 고른 카드를 한 장씩 쓰며 최대 3번까지 재제안하고, 그때까지 앵커링가 이하로 내려오지 못하면 협상은 실패로 끝납니다.</>} />
<QuoteLine Icon={Trophy} label="낙찰 기준" status="직접 설정" value="앵커링가까지 · 엄격" tip={<>협상이 끝났을 때 <b className="text-foreground">어디까지를 낙찰로 볼지</b> 미리 정해요. 엄격(앵커링가까지)하면 더 싸게 사지만 낙찰이 덜 되고, 느슨(목표가까지)하면 낙찰은 잘 되는 대신 절감폭이 줄어요. (여러 곳 경쟁은 단독 최저가 자동 낙찰.)</>} />
</div>
</div>
<div className="mt-4">
@ -163,11 +163,11 @@ export function InviteStep() {
<StepHeader num={3} kicker="그다음 · 놓치지 마세요" actor="내가" tone="must" title="협력사에게 이메일을 보내요" />
<Typography variant="muted" className="mb-5">여기가 제일 놓치기 쉬운 곳이에요. 견적을 만들었다고 협력사에게 자동으로 알려지는 건 아니에요.</Typography>
<div className="flex items-stretch justify-center gap-1 max-[640px]:flex-col">
<RegCard ico="📄" sub="만든 견적" name="A4용지 협상" meta="EST-2026-014" />
<RegCard Icon={FileText} sub="만든 견적" name="A4용지 협상" meta="EST-2026-014" />
<RegArrow />
<RegCard ico="✉️" sub="꼭 해야 할 일" name="이메일 발송하기" meta="협상 링크 포함" connect tone="must" />
<RegCard Icon={Mail} sub="꼭 해야 할 일" name="이메일 발송하기" meta="협상 링크 포함" connect tone="must" />
<RegArrow />
<RegCard ico="🏢" sub="협력사" name="우리제지" meta="링크 받고 입장" />
<RegCard Icon={Building2} sub="협력사" name="우리제지" meta="링크 받고 입장" />
</div>
<div className="mt-3">
<Callout tone="warning" title="안 보내면 아무 일도 일어나지 않아요">
@ -313,7 +313,7 @@ function NegotiationSlider() {
<div className="mt-3 rounded-lg bg-background px-3.5 py-3">
<Typography variant="small" className="font-extrabold text-foreground">그래도 가격을 안 낮추면?</Typography>
<Typography variant="caption" className="mt-1 block leading-relaxed text-muted-foreground">
<b className="text-foreground">선택한 협상카드</b>를 다 쓰거나 라운드 상한에 걸릴 때까지 더 낮추지 못하면 협상은 실패로 끝나요. 이 경우는 <b className="text-foreground">협상완료·투찰 확정이 아니어서</b> 낙찰 후보가 되지 않습니다.
봇은 <b className="text-foreground">고른 협상카드를 한 장씩 쓰며 최대 3번까지 재제안</b>해요. 그때까지 앵커링가 밑으로 못 내려오면 협상은 실패로 끝나고, 이 경우는 <b className="text-foreground">협상완료·투찰 확정이 아니어서</b> 낙찰 후보가 되지 않습니다.
</Typography>
</div>
</div>

View File

@ -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 ? (
'?'
) : (
<Typography as="span" variant="caption" className="font-bold text-primary">
💡 {label}
<Typography as="span" variant="caption" className="inline-flex items-center gap-1 font-bold text-primary">
<Lightbulb className="size-3.5" /> {label}
</Typography>
)}
</button>

View File

@ -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() {
{/* 건너뛰기 */}
<div className="mb-2 flex flex-none justify-end">
<button type="button" onClick={finish} className="cursor-pointer p-1 text-muted-foreground transition-colors hover:text-foreground">
<Typography as="span" variant="caption">건너뛰기 ✕</Typography>
<Typography as="span" variant="caption" className="inline-flex items-center gap-1">건너뛰기 <X className="size-3.5" /></Typography>
</button>
</div>
@ -109,13 +110,15 @@ export function OnboardingWizard() {
{/* 하단 네비 (고정) */}
<div className="mt-4 flex flex-none items-center gap-3 border-t border-border/70 pt-4">
<Button variant="outline" disabled={step === 0} onClick={() => setStep((s) => Math.max(0, s - 1))}>
← 이전
<ArrowLeft className="size-4" /> 이전
</Button>
<div className="flex-1" />
<Typography as="span" variant="caption" className="tabular-nums text-muted-foreground">
{step === 0 ? '전체 흐름 · 한눈에' : `${step} / ${total - 1} 단계`}
</Typography>
<Button onClick={next}>{step === 0 ? '하나씩 볼게요 →' : atEnd ? '시작하기 ✓' : '다음 →'}</Button>
<Button onClick={next}>
{step === 0 ? <>하나씩 볼게요 <ArrowRight className="size-4" /></> : atEnd ? <>시작하기 <Check className="size-4" /></> : <>다음 <ArrowRight className="size-4" /></>}
</Button>
</div>
</div>
);

View File

@ -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<Card, { label: string; cls: string }> = {
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<Card, { label: string; cls: string; Icon?: LucideIcon }> = {
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<number | null>(null); // 앵커링가 산정 결과(스마트 카운트)
const [envOpen, setEnvOpen] = useState(false);
const [msgIdx, setMsgIdx] = useState(-1);
const [priceNum, setPriceNum] = useState<number | null>(null);
const [drop, setDrop] = useState<string | null>(null);
const displayPrice = useCountDown(priceNum);
const displayAnchor = useCountDown(anchorNum);
const [speaking, setSpeaking] = useState<Side | null>(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() {
</Typography>
<div>
{/* 진행 칩 */}
<div className="mb-3 grid grid-cols-4 gap-2">
{CHIPS.map((c, i) => {
const active = phase === i;
return (
<div
key={c.label}
className={cn(
'rounded-lg border px-2.5 py-2 text-center transition-all duration-300',
active
? 'border-amber-500 bg-muted/45 text-amber-600 dark:text-amber-400'
: 'border-transparent bg-muted/45 text-muted-foreground',
)}
>
<Typography as="span" variant="caption" className="block text-base leading-none">{c.icon}</Typography>
<Typography as="span" variant="caption" className="mt-1 block font-extrabold text-inherit">{c.label}</Typography>
</div>
);
})}
{/* 진행 스텝 — 아이콘 원 + 원 중심을 잇는 연결선(현재까지 채움) */}
<div className="mb-5 px-2">
<div className="relative grid grid-cols-4">
{/* 연결선: 원 중심 높이에서 뒤에 깔리고, 현재 단계까지 채워짐 */}
<div
aria-hidden
className="pointer-events-none absolute top-[18px] z-0 h-0.5 bg-border/70"
style={{ left: `${100 / (CHIPS.length * 2)}%`, right: `${100 / (CHIPS.length * 2)}%` }}
>
<span
className="block h-full bg-amber-500 transition-[width] duration-300"
style={{ width: `${(phase / (CHIPS.length - 1)) * 100}%` }}
/>
</div>
{CHIPS.map((c, i) => {
const active = phase === i;
const done = phase > i;
return (
<div key={c.label} className="relative z-[1] flex flex-col items-center gap-1.5">
<span
className={cn(
'grid size-9 place-items-center rounded-full border-2 bg-background transition-colors duration-300',
active
? 'border-amber-500 text-amber-600 dark:text-amber-400'
: done
? 'border-amber-500/60 text-amber-600/80 dark:text-amber-400/80'
: 'border-border text-muted-foreground/50',
)}
>
<c.Icon className="size-[18px]" />
</span>
<Typography as="span" variant="caption" className={cn('font-extrabold transition-colors', active ? 'text-amber-600 dark:text-amber-400' : done ? 'text-foreground' : 'text-muted-foreground/60')}>
{c.label}
</Typography>
</div>
);
})}
</div>
</div>
{/* 스테이지 */}
<div className="relative min-h-[252px] rounded-xl border border-border bg-card p-4 shadow-sm">
{phase === 0 && (
<div className="animate-in fade-in duration-300">
<div className="grid grid-cols-2 gap-2">
<div className={cn('rounded-lg p-3 text-center transition-colors', typeSel ? 'bg-primary/5 ring-1 ring-primary/15' : 'bg-muted/70')}>
<Typography variant="small" className={cn('font-bold', typeSel && 'text-primary')}>🤝 협상 · 1:1</Typography>
<Typography variant="caption" className="mt-1 block text-muted-foreground">한 곳과 밀당</Typography>
<div className="flex min-h-[220px] flex-col justify-center animate-in fade-in duration-300">
<div className="mx-auto w-full max-w-[320px]">
{/* 등록·선택 값 (요약 리스트 — 라벨 왼쪽 / 값 오른쪽) */}
<div className="space-y-3">
{/* 진행 방식 = 협상/견적 세그먼트 토글 */}
<div className={cn('flex items-center justify-between gap-3 transition-all duration-300', regStep >= 1 ? 'translate-y-0 opacity-100' : 'translate-y-1 opacity-0')}>
<Typography as="span" variant="small" className="flex-none font-bold text-muted-foreground">진행 방식</Typography>
<div className="relative flex w-[152px] flex-none rounded-full bg-muted p-0.5">
<span
className={cn(
'absolute inset-y-0.5 left-0.5 w-[calc(50%-2px)] rounded-full bg-background shadow-sm transition-all duration-300',
typeSel ? 'scale-100 opacity-100' : 'scale-90 opacity-0',
)}
/>
<span className={cn('relative z-10 flex flex-1 items-center justify-center gap-1 py-1 text-[11px] font-bold transition-colors duration-300', typeSel ? 'text-primary' : 'text-muted-foreground')}>
<Handshake className="size-3" /> 협상·1:1
</span>
<span className="relative z-10 flex flex-1 items-center justify-center gap-1 py-1 text-[11px] font-bold text-muted-foreground">
<Building2 className="size-3" /> 견적·1:N
</span>
</div>
</div>
<PrepRow show={regStep >= 2} label="상품" value="A4용지" />
<PrepRow show={regStep >= 2} label="기준가" value="110,000원" mono />
<PrepRow show={regStep >= 3} label="공급사" value="대성문구" />
</div>
<div className="rounded-lg bg-muted/70 p-3 text-center">
<Typography variant="small" className="font-bold">🏢 견적 · 1:N</Typography>
<Typography variant="caption" className="mt-1 block text-muted-foreground">여러 곳 경쟁</Typography>
{/* 결과 — 위 값들 근거로 AI가 앵커링가 스마트 산정 (영수증 '합계' 자리) */}
<div className="mt-4 flex items-center justify-between gap-3 border-t border-border/60 pt-4">
<span>
<Typography as="span" variant="small" className="inline-flex items-center gap-1 font-bold text-foreground">
<Bot className="size-3.5 text-primary" /> 앵커링가
</Typography>
<Typography as="span" variant="caption" className="mt-0.5 block text-muted-foreground">AI 스마트 산정</Typography>
</span>
{displayAnchor == null ? (
<Typography as="span" variant="small" className="flex-none font-semibold text-muted-foreground/50">산정 중…</Typography>
) : (
<Typography as="span" className={cn('flex-none text-[28px] font-black leading-none tabular-nums transition-colors', anchorNum === 95_000 ? 'text-primary' : 'text-muted-foreground/40')}>
{displayAnchor.toLocaleString('en-US')}원
</Typography>
)}
</div>
</div>
<div className="mt-3 grid min-h-[70px] grid-cols-[2rem_minmax(0,1fr)_auto] items-center gap-3 rounded-lg bg-muted/70 px-4 py-3">
<span className="grid size-8 place-items-center rounded-md bg-background text-base">📍</span>
<span className="min-w-0">
<Typography as="span" variant="caption" className="block font-bold text-muted-foreground">앵커링가 자동 산정</Typography>
<Typography as="span" variant="small" className="mt-1 block font-extrabold text-foreground">목표가보다 낮은 협상 완료 기준선</Typography>
</span>
<Typography as="span" variant="h4" className={cn('font-black tabular-nums transition-colors', target.includes('원') ? 'text-primary' : 'text-muted-foreground')}>
{target}
</Typography>
</div>
</div>
)}
{phase === 1 && (
<div className="flex min-h-[220px] flex-col items-center justify-center gap-3 overflow-hidden animate-in fade-in duration-300">
<div className={cn('text-[52px] leading-none transition-all duration-1000 ease-[cubic-bezier(.3,.6,.4,1)]', envOpen ? 'scale-[2.4] opacity-0' : 'scale-100 opacity-100')}>✉️</div>
<Mail className={cn('size-[52px] text-primary transition-all duration-1000 ease-[cubic-bezier(.3,.6,.4,1)]', envOpen ? 'scale-[2.4] opacity-0' : 'scale-100 opacity-100')} />
<Typography variant="small" className="font-semibold text-muted-foreground">협력사에게 이메일을 발송했어요</Typography>
</div>
)}
{phase === 2 && (
<div className="animate-in fade-in duration-300">
<div className="flex min-h-[220px] flex-col animate-in fade-in duration-300">
{/* 제시가 바 — 이전값에서 새 제시가까지 숫자가 주르륵 내려간다 */}
<div className="mb-3 flex items-center gap-2.5 rounded-lg bg-muted/70 px-3.5 py-2.5">
<Typography as="span" variant="caption" className="font-bold text-muted-foreground">상대회사 제시가</Typography>
@ -182,32 +225,36 @@ export function OverviewStep() {
{displayPrice != null ? `${displayPrice.toLocaleString('en-US')}원` : '—'}
</Typography>
</div>
{/* 대화 (아바타 + 현재 말풍선) */}
<div className="relative min-h-[158px]">
{/* 대화 (아바타 + 현재 말풍선) — 버블은 flex로 세로중앙정렬(transform 미사용 → 흔들림 없음) */}
<div className="relative flex min-h-[158px] flex-1 items-center px-[54px]">
<Avatar side="bot" speaking={speaking === 'bot'} />
<Avatar side="sp" speaking={speaking === 'sp'} />
{cur && (
<div
key={msgIdx}
className={cn(
'absolute top-1/2 max-w-[calc(100%-112px)] -translate-y-1/2 animate-in fade-in duration-300',
cur.side === 'bot' ? 'left-[54px] slide-in-from-left-2' : 'right-[54px] slide-in-from-right-2',
)}
className={cn('flex w-full', cur.side === 'bot' ? 'justify-start' : 'justify-end')}
>
{cur.side === 'bot' ? (
<div className={cn('rounded-2xl rounded-bl-md px-3.5 py-2.5', cur.card === 'wild' ? 'bg-amber-500/10' : 'bg-muted/70')}>
{cur.card && (
<span className={cn('mb-1.5 inline-block w-fit rounded-md border px-1.5 py-0.5', CARD_TAG[cur.card].cls)}>
<Typography as="span" variant="caption" className="text-[10px] font-extrabold text-inherit">{CARD_TAG[cur.card].label}</Typography>
</span>
)}
<Typography as="p" variant="small" className="text-[13px] leading-snug">{cur.text}</Typography>
</div>
) : (
<div className="rounded-2xl rounded-br-md bg-primary px-3.5 py-2.5 text-primary-foreground">
<Typography as="span" variant="small" className="text-sm font-bold tabular-nums text-primary-foreground">{cur.text}</Typography>
</div>
)}
<div className={cn('max-w-full animate-in fade-in zoom-in-75 duration-300', cur.side === 'bot' ? 'origin-bottom-left' : 'origin-bottom-right')}>
{cur.side === 'bot' ? (
<div className={cn('rounded-2xl rounded-bl-md px-3.5 py-2.5', cur.card === 'wild' ? 'bg-amber-500/10' : 'bg-muted/70')}>
{cur.card && (() => {
const tag = CARD_TAG[cur.card];
const TagIcon = tag.Icon;
return (
<span className={cn('mb-1.5 inline-flex w-fit items-center gap-1 rounded-md border px-1.5 py-0.5', tag.cls)}>
{TagIcon && <TagIcon className="size-3" />}
<Typography as="span" variant="caption" className="text-[10px] font-extrabold text-inherit">{tag.label}</Typography>
</span>
);
})()}
<Typography as="p" variant="small" className="text-[13px] leading-snug">{cur.text}</Typography>
</div>
) : (
<div className="rounded-2xl rounded-br-md bg-primary px-3.5 py-2.5 text-primary-foreground">
<Typography as="span" variant="small" className="text-sm font-bold tabular-nums text-primary-foreground">{cur.text}</Typography>
</div>
)}
</div>
</div>
)}
</div>
@ -215,22 +262,26 @@ export function OverviewStep() {
)}
{phase === 3 && (
<div className="relative animate-in fade-in duration-300">
<AwardTrack moved={bidMoved} />
{stamp && (
<div className="pointer-events-none absolute left-1/2 top-[64%] z-[4] -translate-x-1/2 -translate-y-1/2 -rotate-12 rounded-xl border-4 border-emerald-600 px-4 py-1 animate-in zoom-in duration-500 dark:border-emerald-500">
<Typography as="span" variant="h3" className="text-2xl font-black text-emerald-600 dark:text-emerald-400">낙찰!</Typography>
</div>
)}
<div className="flex min-h-[220px] flex-col justify-center animate-in fade-in duration-300">
<div className="relative">
<AwardTrack moved={bidMoved} />
{stamp && (
<div className="pointer-events-none absolute left-1/2 top-[64%] z-50 -translate-x-1/2 -translate-y-1/2">
<div className="animate-stamp rounded-xl border-4 border-emerald-600 px-5 py-1.5 dark:border-emerald-500">
<Typography as="span" variant="h3" className="text-2xl font-black text-emerald-600 dark:text-emerald-400">낙찰!</Typography>
</div>
</div>
)}
</div>
</div>
)}
</div>
<div className="mt-3.5 h-8 text-center">
<div className="mt-4 h-8 text-center">
{replay && (
<button
type="button"
onClick={() => 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"
>
<RotateCcw className="size-3.5" />
@ -238,8 +289,9 @@ export function OverviewStep() {
</button>
)}
</div>
<Typography variant="caption" className="mt-3 block text-center text-muted-foreground">
<b className="font-semibold">다음</b>을 누르면 각 단계를 하나씩 자세히 볼게요 ↓
<Typography variant="caption" className="mt-3 flex items-center justify-center gap-1 text-center text-muted-foreground">
<span><b className="font-semibold">다음</b>을 누르면 각 단계를 하나씩 자세히 볼게요</span>
<ArrowDown className="size-3" />
</Typography>
</div>
</div>
@ -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 (
<div className="rounded-xl bg-muted/70 p-4">
<div className="flex items-end justify-between gap-2">
<div>
<Typography variant="caption" className="font-bold text-muted-foreground">협상 결과 · 투찰가</Typography>
<Typography as="p" variant="h3" className="mt-1 text-2xl font-black tabular-nums text-emerald-600 dark:text-emerald-400">94,800원</Typography>
</div>
<span className="rounded-full bg-emerald-500/10 px-2.5 py-1">
<Typography as="span" variant="caption" className="font-extrabold text-emerald-600 dark:text-emerald-400">낙찰 구간</Typography>
</span>
<div>
<div>
<Typography variant="caption" className="font-bold text-muted-foreground">투찰가</Typography>
<Typography as="p" variant="h3" className="mt-1 text-2xl font-black tabular-nums text-emerald-600 dark:text-emerald-400">94,800원</Typography>
</div>
<div className="relative mt-6 pb-8">
@ -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 (
<div className={cn('flex items-center justify-between gap-3 transition-all duration-300', show ? 'translate-y-0 opacity-100' : 'translate-y-1 opacity-0')}>
<Typography as="span" variant="small" className="flex-none font-bold text-muted-foreground">{label}</Typography>
<Typography as="span" variant="small" className={cn('flex-none font-bold text-foreground', mono && 'tabular-nums')}>{value}</Typography>
</div>
);
}
function Avatar({ side, speaking }: { side: Side; speaking: boolean }) {
const isBot = side === 'bot';
return (
<div className={cn('absolute top-1/2 w-12 -translate-y-1/2 text-center', isBot ? 'left-0' : 'right-0')}>
<div
className={cn(
'mx-auto grid size-10 place-items-center rounded-full border text-lg transition',
isBot ? 'border-primary/30 bg-primary/10' : 'border-border bg-muted',
'mx-auto grid size-10 place-items-center rounded-full border transition',
isBot ? 'border-primary/30 bg-primary/10 text-primary' : 'border-border bg-muted text-muted-foreground',
speaking && 'scale-110 ring-4',
speaking && (isBot ? 'ring-primary/10' : 'ring-muted'),
)}
>
{isBot ? '🤖' : '🏢'}
{isBot ? <Bot className="size-5" /> : <Building2 className="size-5" />}
</div>
<Typography variant="caption" className="mt-1 block font-bold text-muted-foreground">{isBot ? '협상봇' : '상대회사'}</Typography>
</div>

View File

@ -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); }
}