[feat] negodata/front: 온보딩 이모지→아이콘 교체, step0 레이아웃·애니메이션 등 개편
This commit is contained in:
parent
21f90ed84a
commit
3d515055fb
@ -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: '협상 실패 — 투찰 없음, 낙찰 후보 아님' },
|
||||
];
|
||||
|
||||
// 마감·낙찰 — 언제 닫히고, 무엇을 보고, 케이스별로 어떻게 갈리나.
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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); }
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user