Merge branch 'design/frontend' into feature/landing
This commit is contained in:
commit
a9db0f7a1b
@ -36,7 +36,7 @@ function ChatList() {
|
||||
if (!chats || chats.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<p className="text-base text-neutral-60">채팅 내역이 없습니다.</p>
|
||||
<p className="text-sm text-neutral-60">채팅 내역이 없습니다.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,11 +1,13 @@
|
||||
import { useChatStore } from '@/features/chat/stores/useChatStore'
|
||||
import { ChatMessage } from '@/features/chat/components/ChatMessage'
|
||||
import { UserButton } from '@/features/chat/components/UserButton'
|
||||
import { MobileStepBar } from '@/features/chat/components/MobileStepBar'
|
||||
|
||||
export function ChatSection() {
|
||||
const { userButtonConfig } = useChatStore()
|
||||
return (
|
||||
<div className="flex flex-1 flex-col w-full h-full min-h-0 bg-surface">
|
||||
<MobileStepBar />
|
||||
<ChatMessage />
|
||||
{/* 하단 액션 덱 */}
|
||||
<div className="shrink-0 border-t border-border bg-white shadow-[0_-4px_20px_rgba(0,0,0,0.03)]">
|
||||
|
||||
46
frontend/src/features/chat/components/MobileStepBar.tsx
Normal file
46
frontend/src/features/chat/components/MobileStepBar.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import { Check } from 'lucide-react'
|
||||
import { cn } from '@/lib'
|
||||
import { useChatStore } from '@/features/chat/stores/useChatStore'
|
||||
import { STEPS } from '@/features/chat/components/menu/NegoStep'
|
||||
|
||||
// 모바일 전용: 협상절차를 채팅 상단에 가로 스텝바로 항상 표시한다. (데스크톱은 우측 패널 사용)
|
||||
export function MobileStepBar() {
|
||||
const chats = useChatStore((s) => s.messages)
|
||||
const currentStep = chats[chats.length - 1]?.display_step || STEPS[0].name
|
||||
const idx = Math.max(
|
||||
0,
|
||||
STEPS.findIndex((s) => s.name === currentStep),
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="shrink-0 border-b border-border bg-white px-4 py-2.5 lg:hidden">
|
||||
<div className="flex items-center">
|
||||
{STEPS.map((step, i) => {
|
||||
const done = i < idx
|
||||
const current = i === idx
|
||||
return (
|
||||
<div key={step.name} className={cn('flex items-center', i < STEPS.length - 1 && 'flex-1')}>
|
||||
<div
|
||||
className={cn(
|
||||
'flex size-5 shrink-0 items-center justify-center rounded-full text-[10px] font-bold transition-colors',
|
||||
done && 'bg-success text-white',
|
||||
current && 'bg-brand-600 text-white ring-4 ring-brand-light',
|
||||
!done && !current && 'border border-border bg-white text-neutral-50',
|
||||
)}
|
||||
>
|
||||
{done ? <Check className="size-3" strokeWidth={3} /> : i + 1}
|
||||
</div>
|
||||
{i < STEPS.length - 1 && (
|
||||
<div className={cn('mx-1 h-0.5 flex-1 rounded-full', i < idx ? 'bg-success' : 'bg-neutral-20')} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<p className="mt-1.5 truncate text-[11px] font-bold text-brand-600">
|
||||
{idx + 1}. {STEPS[idx].name}
|
||||
<span className="ml-1 font-normal text-neutral-50">{STEPS[idx].desc}</span>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -20,7 +20,7 @@ export function RemainingTime() {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-bold whitespace-nowrap',
|
||||
'inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-bold whitespace-nowrap tabular-nums',
|
||||
ended ? 'bg-neutral-20 text-neutral-60' : 'bg-brand-light text-brand-600',
|
||||
)}
|
||||
>
|
||||
|
||||
@ -1,33 +1,14 @@
|
||||
import { useState } from 'react'
|
||||
import { interactive, cn } from '@/lib'
|
||||
import { ServiceGuidePopup } from '@/features/chat/components/popup/ServiceGuidePopup'
|
||||
|
||||
// 헬프데스크 — 이용 가이드 버튼 클릭 시 안내 팝업을 연다.
|
||||
// 헬프데스크 — 연락처. (안내 팝업 진입은 상단 '유의사항 및 이용방법' 섹션으로 일원화)
|
||||
export function Contact() {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<section className="w-full pt-4">
|
||||
<div className="border-b border-border pb-2">
|
||||
<span className="text-[11px] font-bold tracking-wide text-neutral-60">헬프데스크</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 pt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(true)}
|
||||
className={cn(
|
||||
'w-fit rounded-lg border border-border bg-white px-3 py-1.5 text-sm font-semibold text-neutral-70 hover:bg-neutral-10',
|
||||
interactive,
|
||||
)}
|
||||
>
|
||||
이용 가이드
|
||||
</button>
|
||||
<div className="flex flex-col gap-0.5 text-sm text-neutral-60">
|
||||
<span>010-0000-0000</span>
|
||||
<span>o2odev@o2o.kr</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 pt-3 text-sm text-neutral-60">
|
||||
<span>010-0000-0000</span>
|
||||
<span>o2odev@o2o.kr</span>
|
||||
</div>
|
||||
{isOpen && <ServiceGuidePopup onClose={() => setIsOpen(false)} />}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@ -7,7 +7,7 @@ interface Props {
|
||||
setIsOpen: (isOpen: boolean) => void
|
||||
}
|
||||
|
||||
const STEPS = [
|
||||
export const STEPS = [
|
||||
{ name: '서비스안내', desc: '협상 방식과 유의사항을 확인합니다.' },
|
||||
{ name: '담당자확인', desc: '협상 담당자 본인 여부를 확인합니다.' },
|
||||
{ name: '협상품목안내', desc: '대상 품목과 기준 단가를 확인합니다.' },
|
||||
@ -61,7 +61,7 @@ export function NegoStep({ isOpen, setIsOpen }: Props) {
|
||||
<div className="min-w-0">
|
||||
<p
|
||||
className={cn(
|
||||
'text-sm leading-tight',
|
||||
'text-xs leading-tight',
|
||||
current && 'font-extrabold text-brand-600',
|
||||
done && 'font-semibold text-success',
|
||||
!done && !current && 'font-medium text-neutral-80',
|
||||
@ -69,7 +69,7 @@ export function NegoStep({ isOpen, setIsOpen }: Props) {
|
||||
>
|
||||
{step.name}
|
||||
</p>
|
||||
<p className={cn('mt-0.5 text-[11px] leading-snug', current ? 'text-brand-600/80' : 'text-neutral-50')}>
|
||||
<p className={cn('mt-0.5 text-[10px] leading-snug', current ? 'text-brand-600/80' : 'text-neutral-50')}>
|
||||
{step.desc}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -3,14 +3,13 @@ import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
|
||||
|
||||
// 협상 유의사항 및 서비스 이용 방법 안내 — 팝업 2종(자동 안내/메뉴 가이드)이 공유하는 본문.
|
||||
// VAT/배송비 문구는 채팅 init 메타(useChatInitStore)를 읽어 상품별로 동적 표시한다.
|
||||
// break-keep: 한국어 단어가 중간에서 잘려 줄바꿈되지 않도록 한다.
|
||||
const textStyle = 'text-[17px] font-normal leading-[150%] tracking-[-0.34px] text-neutral-90 break-keep'
|
||||
const bodyStyle = 'text-sm font-normal leading-relaxed text-neutral-70 break-keep'
|
||||
|
||||
function Bullet({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-start py-[6px]">
|
||||
<p className="ml-[8px] mr-[6px] flex-shrink-0">•</p>
|
||||
<div className={`${textStyle} flex-1`}>{children}</div>
|
||||
<div className="flex items-start gap-2.5 py-1.5">
|
||||
<span className="mt-[7px] size-1.5 shrink-0 rounded-full bg-brand-600" />
|
||||
<div className={`${bodyStyle} flex-1`}>{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -23,24 +22,26 @@ export function GuideContent() {
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col">
|
||||
<div className="mb-8 break-keep text-center text-lg font-bold text-neutral-90">
|
||||
<div className="mb-5 break-keep text-center text-base font-bold text-neutral-90">
|
||||
협상 유의 사항 및 서비스 이용 방법 안내
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-start">
|
||||
<div className={`${textStyle} mb-2`}>
|
||||
협상과 관련하여 다음 사항을 참고하여 견적에 참여해 주시기 바랍니다.
|
||||
</div>
|
||||
<div className={`${bodyStyle} mb-2`}>
|
||||
협상과 관련하여 다음 사항을 참고하여 견적에 참여해 주시기 바랍니다.
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col divide-y divide-border/60 rounded-xl border border-border px-4 py-1">
|
||||
<Bullet>
|
||||
협상 개시는
|
||||
<span className="font-bold">Negosium 시스템의 협상 참여 버튼을 클릭하는 순간부터 시작</span>
|
||||
<span className="font-semibold text-neutral-90">
|
||||
Negosium 시스템의 협상 참여 버튼을 클릭하는 순간부터 시작
|
||||
</span>
|
||||
됩니다.
|
||||
</Bullet>
|
||||
|
||||
<Bullet>
|
||||
부여된 협상 시간에
|
||||
<span className="font-bold">
|
||||
<span className="font-semibold text-neutral-90">
|
||||
응찰하지 않는 경우, 협상 참여의사가 없는 것으로 간주하여 재견적으로 진행
|
||||
</span>
|
||||
될 수 있습니다.
|
||||
@ -48,25 +49,23 @@ export function GuideContent() {
|
||||
|
||||
<Bullet>
|
||||
협상에 입력되는 모든 가격은
|
||||
<span className="font-bold text-negative">
|
||||
<span className="font-semibold text-negative">
|
||||
{vat} 및 {deliveryFee}
|
||||
</span>
|
||||
기준이며,
|
||||
<span className="font-bold">
|
||||
<span className="font-semibold text-neutral-90">
|
||||
할인을 요청하는 경우 기존 공급가격에 할인율이 적용된 가격으로 환산
|
||||
</span>
|
||||
되어 제시됩니다.
|
||||
</Bullet>
|
||||
|
||||
<Bullet>
|
||||
본 협상 결과에 대해서는 협상자와 협상대상자 간의 비밀 유지 조건으로 진행되고, 협상에서
|
||||
얻어진 결과나 내용에 대해서는 당사자를 제외하고 제 3자에 공유할 수 없으며, 비밀 유지를
|
||||
전제로 진행됩니다.
|
||||
본 협상 결과에 대해서는 협상자와 협상대상자 간의 비밀 유지 조건으로 진행되고, 협상에서 얻어진 결과나 내용에
|
||||
대해서는 당사자를 제외하고 제 3자에 공유할 수 없으며, 비밀 유지를 전제로 진행됩니다.
|
||||
</Bullet>
|
||||
|
||||
<Bullet>
|
||||
협상이 종결되면 특별한 사유 없이 취소 변경이 불가하니, 신중하게 협상에 참여해 주시기
|
||||
바랍니다.
|
||||
협상이 종결되면 특별한 사유 없이 취소 변경이 불가하니, 신중하게 협상에 참여해 주시기 바랍니다.
|
||||
</Bullet>
|
||||
|
||||
<Bullet>안내된 사항 외 부분은 기존 견적 프로세스와 동일한 부분 유의 바랍니다.</Bullet>
|
||||
@ -78,13 +77,12 @@ export function GuideContent() {
|
||||
// 하단 문의 안내 박스
|
||||
export function GuideContactBox() {
|
||||
return (
|
||||
<div className="flex w-full flex-col items-center gap-2 rounded-[16px] bg-neutral-10 p-6">
|
||||
<div className={`${textStyle} text-center`}>
|
||||
기타 협상과정에서 궁금한 사항이나 문의하실 사항은 아래의 연락처로 상담을 부탁드립니다.
|
||||
<div className="flex w-full flex-col items-center gap-1.5 rounded-xl bg-neutral-10 p-4">
|
||||
<div className={`${bodyStyle} text-center`}>
|
||||
기타 협상 과정에서 궁금하거나 문의하실 사항은 아래 연락처로 상담 부탁드립니다.
|
||||
</div>
|
||||
{/* textStyle 의 font-normal 과 같은 요소에서 충돌하지 않도록 bold 는 별도 조합으로 준다 */}
|
||||
<div className="break-keep text-center text-[17px] font-bold leading-[150%] tracking-[-0.34px] text-neutral-90">
|
||||
헬프데스크 010-0000-0000, 이메일: o2odev@o2o.kr
|
||||
<div className="break-keep text-center text-sm font-bold text-neutral-90">
|
||||
헬프데스크 010-0000-0000 · o2odev@o2o.kr
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -53,7 +53,7 @@ export function RejectCM() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full p-8 bg-white rounded-2xl border border-border shadow-sm gap-2 transition-all duration-300">
|
||||
<div className="flex w-full items-center justify-center gap-2 rounded-xl bg-[#FFF1F1] px-4 py-3 text-base font-bold text-[#E5484D]">
|
||||
<div className="flex w-full items-center justify-center gap-2 rounded-xl bg-[#FFF1F1] px-4 py-3 text-sm font-bold text-[#E5484D]">
|
||||
<AlertTriangle className="size-5 shrink-0" />
|
||||
최종 공급 희망 가격 입력
|
||||
</div>
|
||||
|
||||
@ -82,7 +82,7 @@ export function RejectRSP() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full p-8 bg-white rounded-2xl border border-border shadow-sm gap-2 transition-all duration-300">
|
||||
<div className="flex w-full items-center justify-center gap-2 rounded-xl bg-[#FFF1F1] px-4 py-3 text-base font-bold text-[#E5484D]">
|
||||
<div className="flex w-full items-center justify-center gap-2 rounded-xl bg-[#FFF1F1] px-4 py-3 text-sm font-bold text-[#E5484D]">
|
||||
<AlertTriangle className="size-5 shrink-0" />
|
||||
협상 합의 불가 사유 및 최종 제안 단가 입력
|
||||
</div>
|
||||
|
||||
@ -32,13 +32,13 @@ function InputWithUnit({
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onKeyDown={(e) => e.key === 'Enter' && onSubmit()}
|
||||
className="h-[52px] flex-1 bg-neutral-10 rounded-xl outline-none border-2 border-brand-600 text-lg font-bold text-neutral-90 placeholder:text-sm placeholder:font-normal placeholder:text-neutral-50 pl-4 pr-[124px]"
|
||||
className="h-[52px] flex-1 bg-neutral-10 rounded-xl outline-none border-2 border-brand-600 text-base font-bold text-neutral-90 placeholder:text-sm placeholder:font-normal placeholder:text-neutral-50 pl-4 pr-[124px]"
|
||||
placeholder={placeholder}
|
||||
aria-label={ariaLabel}
|
||||
aria-invalid={!!error}
|
||||
aria-describedby={error ? errorId : undefined}
|
||||
/>
|
||||
<div className="absolute left-4 text-lg font-bold text-neutral-70 pointer-events-none">
|
||||
<div className="absolute left-4 text-base font-bold text-neutral-70 pointer-events-none">
|
||||
<span className="opacity-0">{value}</span>
|
||||
{value && <span className="ml-1">{unit}</span>}
|
||||
</div>
|
||||
|
||||
@ -19,9 +19,9 @@
|
||||
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
||||
|
||||
/* 브랜드 */
|
||||
--color-primary: #3182F6;
|
||||
--color-primary-deep: #1B64DA; /* hover */
|
||||
--color-primary-soft: #E8F3FF; /* 연한 파랑 배경 — 봇 말풍선, 배지 */
|
||||
--color-primary: #476EFF;
|
||||
--color-primary-deep: #2F52E0; /* hover */
|
||||
--color-primary-soft: #ECF0FF; /* 연한 파랑 배경 — 봇 말풍선, 배지 */
|
||||
|
||||
/* 텍스트. ink 는 다크 섹션에선 배경색으로도 쓴다 */
|
||||
--color-ink: #191F28;
|
||||
@ -38,6 +38,10 @@
|
||||
--color-line-strong: #E5E8EB; /* 카드 테두리 */
|
||||
|
||||
--color-positive: #0B8F57; /* 낙찰·성공 강조 */
|
||||
--color-positive-soft: #E8F7EF; /* 연한 초록 배경 — 낙찰 배지 */
|
||||
|
||||
--color-accent: #8F52FF; /* 보라 강조 — 결과 탭·히어로 장식 */
|
||||
--color-accent-soft: #F4EFFF; /* 연한 보라 배경 */
|
||||
}
|
||||
|
||||
html {
|
||||
|
||||
@ -7,22 +7,33 @@ import { SectionHeading } from "@/components/ui/section-heading"
|
||||
import { Typography } from "@/components/ui/typography"
|
||||
import { useFadeUp } from "@/lib/motion"
|
||||
|
||||
/** 기존 방식 ↔ 도입 후 토글 비교 — 화면 진입 시 자동으로 오가고, 사용자가 누르면 수동 고정. */
|
||||
/** 기존 방식 ↔ 도입 후 토글 비교 — 화면 진입 시 자동으로 오가고, 누르면 잠시 수동(8초 후 자동 재개). */
|
||||
export function Comparison() {
|
||||
const fadeUp = useFadeUp()
|
||||
const toggleFadeUp = useFadeUp(0.1)
|
||||
const [mode, setMode] = useState<Mode>("before")
|
||||
// 사용자가 직접 토글을 누르면 자동 전환 중단
|
||||
// 사용자가 토글을 누르면 잠시 자동 전환 중단, 8초 무조작 시 재개
|
||||
const [locked, setLocked] = useState(false)
|
||||
const [inView, setInView] = useState(false)
|
||||
const sectionRef = useRef<HTMLDivElement>(null)
|
||||
const lockTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const selectMode = (key: Mode) => {
|
||||
setMode(key)
|
||||
setLocked(true)
|
||||
if (lockTimerRef.current) clearTimeout(lockTimerRef.current)
|
||||
lockTimerRef.current = setTimeout(() => setLocked(false), 8000)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const el = sectionRef.current
|
||||
if (!el) return
|
||||
const observer = new IntersectionObserver(([entry]) => setInView(entry.isIntersecting), { threshold: 0.35 })
|
||||
observer.observe(el)
|
||||
return () => observer.disconnect()
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
if (lockTimerRef.current) clearTimeout(lockTimerRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
@ -32,13 +43,13 @@ export function Comparison() {
|
||||
}, [inView, locked])
|
||||
|
||||
return (
|
||||
<Section id="comparison" bordered>
|
||||
<Section id="comparison">
|
||||
<motion.div {...fadeUp} className="mb-12">
|
||||
<SectionHeading
|
||||
align="center"
|
||||
eyebrow="WHY NEGOTIUM"
|
||||
title="협상 방식을 바꾸면, 무엇이 달라질까요"
|
||||
description="기존 가격 협상의 문제는 사람이 아니라 구조입니다. 버튼을 눌러 직접 비교해 보세요."
|
||||
description="기존 가격 협상의 문제는 사람이 아니라 구조입니다."
|
||||
descriptionClassName="mt-4 text-[17px] max-w-2xl"
|
||||
/>
|
||||
</motion.div>
|
||||
@ -49,10 +60,7 @@ export function Comparison() {
|
||||
{MODES.map((m) => (
|
||||
<button
|
||||
key={m.key}
|
||||
onClick={() => {
|
||||
setLocked(true)
|
||||
setMode(m.key)
|
||||
}}
|
||||
onClick={() => selectMode(m.key)}
|
||||
className={`px-5 py-2.5 rounded-[10px] text-xs sm:text-sm font-bold transition-all flex items-center gap-2 cursor-pointer ${
|
||||
mode === m.key ? "bg-white text-primary shadow-sm" : "text-ink-soft hover:text-ink"
|
||||
}`}
|
||||
|
||||
@ -13,7 +13,7 @@ export function Faq() {
|
||||
const [openIndex, setOpenIndex] = useState<number | null>(0)
|
||||
|
||||
return (
|
||||
<Section id="faq" width="sm" bordered>
|
||||
<Section id="faq" width="sm">
|
||||
<motion.div {...fadeUp} className="mb-24">
|
||||
<SectionHeading
|
||||
align="center"
|
||||
@ -107,6 +107,7 @@ const FAQS: FaqEntry[] = [
|
||||
},
|
||||
{
|
||||
question: "기존 시스템과 연동되나요?",
|
||||
answer: "견적·발주 데이터 연동을 지원합니다. 도입 상담에서 사내 ERP 환경에 맞는 연동 방식을 안내해 드립니다.",
|
||||
answer:
|
||||
"별도 연동 없이 바로 시작할 수 있습니다. 상품·공급사 데이터는 엑셀 일괄 업로드로 쉽게 등록되고, ERP 연동은 도입 상담에서 사내 환경에 맞춰 협의합니다.",
|
||||
},
|
||||
]
|
||||
|
||||
@ -4,12 +4,11 @@ import { Typography } from "@/components/ui/typography"
|
||||
export function Footer() {
|
||||
return (
|
||||
<footer className="bg-white text-ink-soft border-t border-line py-24 font-sans text-sm relative z-10">
|
||||
<div className="max-w-4xl mx-auto px-6 grid grid-cols-1 md:grid-cols-12 gap-12">
|
||||
<div className="max-w-4xl mx-auto px-6 grid grid-cols-1 md:grid-cols-12 gap-12 text-center md:text-left">
|
||||
<div className="md:col-span-8">
|
||||
<Logo className="mb-6" />
|
||||
<Typography variant="caption" className="max-w-sm mb-6 font-medium">
|
||||
negotium은 기업 구매 부서가 통제 가이드라인을 정의해두면 지능형 흥정 봇이 대신하여 다수 공급자와 투명하게 단가를
|
||||
조율하는 B2B 자동 중개 대행 플랫폼입니다.
|
||||
negotium은 협상 봇이 공급사와 1:1로 단가를 조율하고 낙찰까지 처리하는 B2B 구매 협상 자동화 솔루션입니다.
|
||||
</Typography>
|
||||
<p className="text-xs text-ink-faint font-semibold">
|
||||
© {new Date().getFullYear()} negotium Co., Ltd. All rights reserved.
|
||||
|
||||
@ -44,9 +44,8 @@ export function Header() {
|
||||
}
|
||||
|
||||
const NAV_LINKS = [
|
||||
{ href: "#how-it-works", label: "실시간 협상" },
|
||||
{ href: "#how-it-works", label: "협상 데모" },
|
||||
{ href: "#how-it-works-demo", label: "이용 가이드" },
|
||||
{ href: "#core-values", label: "솔루션 이점" },
|
||||
{ href: "#reinforcement", label: "강화학습" },
|
||||
{ href: "#comparison", label: "도입 전후 비교" },
|
||||
{ href: "#faq", label: "자주 묻는 질문" },
|
||||
]
|
||||
|
||||
@ -11,8 +11,8 @@ export function HeroGlassmorphic() {
|
||||
<section className="relative min-h-screen bg-surface text-ink flex flex-col justify-center pt-32 overflow-hidden">
|
||||
{/* 흐릿한 파스텔 블롭 배경 */}
|
||||
<div className="absolute top-[10%] right-[15%] w-[480px] h-[480px] bg-primary/10 rounded-full filter blur-[120px] pointer-events-none" />
|
||||
<div className="absolute bottom-[10%] left-[10%] w-[450px] h-[450px] bg-[#8F52FF]/10 rounded-full filter blur-[120px] pointer-events-none" />
|
||||
<div className="absolute top-[30%] left-[30%] w-[380px] h-[380px] bg-sky-300/10 rounded-full filter blur-[100px] pointer-events-none" />
|
||||
<div className="absolute bottom-[10%] left-[10%] w-[450px] h-[450px] bg-accent/10 rounded-full filter blur-[120px] pointer-events-none" />
|
||||
<div className="absolute top-[30%] left-[30%] w-[380px] h-[380px] bg-primary/10 rounded-full filter blur-[100px] pointer-events-none" />
|
||||
|
||||
{/* 저채도 그리드 패턴 오버레이 */}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(rgba(0,0,0,0.01)_1px,transparent_1px),linear-gradient(90deg,rgba(0,0,0,0.01)_1px,transparent_1px)] bg-[size:40px_40px] pointer-events-none" />
|
||||
@ -27,7 +27,7 @@ export function HeroGlassmorphic() {
|
||||
<motion.div
|
||||
animate={{ rotate: -360 }}
|
||||
transition={{ duration: 80, repeat: Infinity, ease: "linear" }}
|
||||
className="absolute bottom-[18%] left-[6vw] lg:left-[12%] w-28 h-28 sm:w-48 sm:h-48 border-2 border-dashed border-[#8F52FF]/30 rounded-full pointer-events-none z-0"
|
||||
className="absolute bottom-[18%] left-[6vw] lg:left-[12%] w-28 h-28 sm:w-48 sm:h-48 border-2 border-dashed border-accent/30 rounded-full pointer-events-none z-0"
|
||||
/>
|
||||
<motion.div
|
||||
animate={{ x: [0, 10, 0], y: [0, 10, 0] }}
|
||||
|
||||
@ -2,14 +2,13 @@ import { motion } from "motion/react"
|
||||
import { ArrowDown, ArrowRight } from "lucide-react"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { PhoneFrame } from "@/components/ui/device-mockups"
|
||||
import { Typography } from "@/components/ui/typography"
|
||||
|
||||
/** 대안 히어로 — 뉴모피즘 배경 + 실제 모바일 협상 화면(GIF) 폰 목업. */
|
||||
export function HeroNeumorphic() {
|
||||
return (
|
||||
<section className="relative min-h-screen bg-surface-neu text-ink flex flex-col justify-center pt-32 pb-20 overflow-hidden">
|
||||
<div className="mesh-bg absolute top-[-300px] right-[-100px] opacity-40 pointer-events-none" />
|
||||
{/* 우측 상단 메시 제거 — 영상 배경(#F2F4F7)과 섹션 배경을 완전 동일하게 유지해 경계선 없이 블렌딩 */}
|
||||
|
||||
<div className="max-w-5xl mx-auto px-6 w-full relative z-10 grid grid-cols-1 lg:grid-cols-12 gap-16 items-center">
|
||||
{/* 좌: 카피 + CTA */}
|
||||
@ -49,7 +48,7 @@ export function HeroNeumorphic() {
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* 우: 협력사 모바일 협상 실제 화면 (GIF) */}
|
||||
{/* 우: 모바일 협상 채팅 + 외부 설명 UI(제안가·협상카드·낙찰 하이라이트) 합성 영상 */}
|
||||
<div className="lg:col-span-5 flex justify-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
@ -57,13 +56,15 @@ export function HeroNeumorphic() {
|
||||
transition={{ duration: 0.8, delay: 0.2 }}
|
||||
className="w-full flex justify-center"
|
||||
>
|
||||
<PhoneFrame>
|
||||
<img
|
||||
src="/gifs/negotiation.gif"
|
||||
alt="협력사 모바일 협상 포털 실제 화면"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</PhoneFrame>
|
||||
<video
|
||||
src="/gifs/negotiation_annotated.mp4"
|
||||
poster="/gifs/negotiation_annotated.jpg"
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
className="w-full max-w-[440px] h-auto"
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -44,7 +44,7 @@ export function HowItWorksDemo() {
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-12 items-center min-h-[500px]">
|
||||
{/* 좌: 단계 설명 */}
|
||||
<div className="lg:col-span-5 space-y-6">
|
||||
<div className="lg:col-span-5 space-y-6 text-center lg:text-left">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={`${activeTab.key}-text`}
|
||||
@ -67,7 +67,7 @@ export function HowItWorksDemo() {
|
||||
|
||||
<div className="border-t border-line pt-5 mt-2 space-y-3">
|
||||
{activeTab.checks.map((check, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2 text-xs font-bold text-ink-soft">
|
||||
<div key={idx} className="flex items-center gap-2 text-xs font-bold text-ink-soft justify-center lg:justify-start">
|
||||
<Check className={`w-4 h-4 ${activeTab.checkClassName}`} />
|
||||
<span>{check}</span>
|
||||
</div>
|
||||
@ -128,6 +128,22 @@ function GifSlot({ tab }: { tab: DemoTab }) {
|
||||
)
|
||||
}
|
||||
|
||||
// video(mp4)가 있으면 우선 재생 — 줌 스크린캐스트는 GIF보다 용량 1/5·화질 우위. 없으면 GIF.
|
||||
if (tab.video) {
|
||||
return (
|
||||
<video
|
||||
src={tab.video}
|
||||
poster={tab.poster}
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
onError={() => setError(true)}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={tab.gif}
|
||||
@ -151,6 +167,8 @@ type DemoTab = {
|
||||
mockup: "browser" | "phone"
|
||||
url?: string
|
||||
gif: string
|
||||
video?: string
|
||||
poster?: string
|
||||
gifAlt: string
|
||||
fallback: {
|
||||
icon: LucideIcon
|
||||
@ -188,6 +206,8 @@ const DEMO_TABS: DemoTab[] = [
|
||||
mockup: "browser",
|
||||
url: "https://console.negotium.ai/estimates/new",
|
||||
gif: "/gifs/quote_generation.gif",
|
||||
video: "/gifs/quote_generation.mp4",
|
||||
poster: "/gifs/quote_generation.jpg",
|
||||
gifAlt: "견적 생성 가이드라인 수립 시연",
|
||||
fallback: {
|
||||
icon: Monitor,
|
||||
@ -201,12 +221,12 @@ const DEMO_TABS: DemoTab[] = [
|
||||
key: "negotiate",
|
||||
tabIcon: Smartphone,
|
||||
tabLabel: "2단계: AI 자동 밀당 협상",
|
||||
badge: { icon: Bot, label: "협력사 모바일 포털 (모바일)", className: "bg-emerald-50 border border-emerald-100 text-positive" },
|
||||
checkClassName: "text-emerald-500",
|
||||
badge: { icon: Bot, label: "협력사 협상 포털 (웹·모바일)", className: "bg-positive-soft border border-positive/15 text-positive" },
|
||||
checkClassName: "text-positive",
|
||||
heading: (
|
||||
<>
|
||||
공급사는 설치 없이 <br />
|
||||
모바일 웹으로 언제든 협상
|
||||
웹·모바일 어디서든 협상
|
||||
</>
|
||||
),
|
||||
paragraphs: [
|
||||
@ -214,11 +234,11 @@ const DEMO_TABS: DemoTab[] = [
|
||||
각 파트너사 담당자는 초대 메일로 <b>비대면 협상 모바일 포털</b>에 접속해, 언제든 단가를 조율합니다.
|
||||
</>,
|
||||
<>
|
||||
단순 마진 깎기가 아닌, <b>"물량 보증"</b> 혹은 <b>"지불 주기 단축"</b> 등의 와일드카드 거래 카드를 연동 제안하여
|
||||
파트너사의 자발적인 마진 타협을 부드럽게 이끌어냅니다.
|
||||
단순 마진 깎기가 아닙니다. 봇은 준비된 협상 카드를 상황에 맞게 꺼내, 파트너사의 자발적인 마진 타협을 부드럽게
|
||||
이끌어냅니다.
|
||||
</>,
|
||||
],
|
||||
checks: ["1:1 프라이빗 대화형 역경매 포털 제공", "불필요한 실랑이를 예방하여 파트너십 보호"],
|
||||
checks: ["1:1 비대면 협상 포털 제공", "불필요한 실랑이를 예방하여 파트너십 보호"],
|
||||
mockup: "phone",
|
||||
gif: "/gifs/negotiation.gif",
|
||||
video: "/gifs/negotiation.mp4",
|
||||
@ -226,7 +246,7 @@ const DEMO_TABS: DemoTab[] = [
|
||||
gifAlt: "AI 모바일 자동 협상 시연",
|
||||
fallback: {
|
||||
icon: Smartphone,
|
||||
iconWrapClassName: "w-12 h-12 bg-emerald-50 border border-emerald-100 rounded-full text-emerald-500",
|
||||
iconWrapClassName: "w-12 h-12 bg-positive-soft border border-positive/15 rounded-full text-positive",
|
||||
codeClassName: "bg-fill text-positive",
|
||||
title: "협상 시연 GIF 공간",
|
||||
hint: "실제 모바일 협상 시뮬레이션 GIF를 배치해 주세요.",
|
||||
@ -236,8 +256,8 @@ const DEMO_TABS: DemoTab[] = [
|
||||
key: "result",
|
||||
tabIcon: Monitor,
|
||||
tabLabel: "3단계: AI 협상 결과 확인",
|
||||
badge: { icon: Monitor, label: "구매 관리자 콘솔 (웹)", className: "bg-purple-50 border border-purple-100 text-purple-600" },
|
||||
checkClassName: "text-purple-500",
|
||||
badge: { icon: Monitor, label: "구매 관리자 콘솔 (웹)", className: "bg-accent-soft border border-accent/15 text-accent" },
|
||||
checkClassName: "text-accent",
|
||||
heading: (
|
||||
<>
|
||||
자동 분석 및 타결 보고서 <br />
|
||||
@ -257,11 +277,13 @@ const DEMO_TABS: DemoTab[] = [
|
||||
mockup: "browser",
|
||||
url: "https://console.negotium.ai/dashboard/reports",
|
||||
gif: "/gifs/negotiation_result.gif",
|
||||
video: "/gifs/negotiation_result.mp4",
|
||||
poster: "/gifs/negotiation_result.jpg",
|
||||
gifAlt: "협상 결과 분석 대시보드 시연",
|
||||
fallback: {
|
||||
icon: Monitor,
|
||||
iconWrapClassName: "w-14 h-14 bg-purple-50 rounded-2xl text-purple-600",
|
||||
codeClassName: "bg-white text-purple-600",
|
||||
iconWrapClassName: "w-14 h-14 bg-accent-soft rounded-2xl text-accent",
|
||||
codeClassName: "bg-white text-accent",
|
||||
title: "협상 결과 확인 시연 GIF 공간",
|
||||
hint: "결과 보고 대시보드 기동 GIF를 배치하시면 이 영역에 자동으로 재생됩니다.",
|
||||
},
|
||||
|
||||
@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react"
|
||||
import { AnimatePresence, motion } from "motion/react"
|
||||
import { Bot, Handshake, ShieldCheck } from "lucide-react"
|
||||
|
||||
import { PriceSparkline } from "@/components/ui/price-sparkline"
|
||||
import { SlateRenderer } from "@/components/ui/slate-renderer"
|
||||
import { Typography } from "@/components/ui/typography"
|
||||
import { EASE_OUT_EXPO } from "@/lib/motion"
|
||||
@ -75,16 +76,16 @@ export function NegotiationConsole() {
|
||||
Live Replay
|
||||
</Typography>
|
||||
<Typography variant="heading" as="h2" className="mt-1">
|
||||
봇이 깎아내는 과정, 그대로 재생
|
||||
봇은 물러서지 않습니다
|
||||
</Typography>
|
||||
</div>
|
||||
|
||||
<div className="max-w-4xl mx-auto grid grid-cols-1 md:grid-cols-12 gap-12 md:gap-16 items-center w-full">
|
||||
{/* 좌: 실시간 단가 패널 */}
|
||||
<div className="md:col-span-5 flex flex-col justify-center space-y-6">
|
||||
<div className="md:col-span-5 flex flex-col justify-center space-y-6 text-center md:text-left">
|
||||
<div>
|
||||
<span className="text-[11px] font-bold text-ink-muted uppercase tracking-wider block mb-1">실시간 협상 단가</span>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<div className="flex items-baseline gap-1.5 justify-center md:justify-start">
|
||||
<span className="text-3xl font-bold text-primary">₩</span>
|
||||
<span className="text-4xl md:text-5xl font-black tracking-tight text-primary">
|
||||
{animatedPrice.toLocaleString()}
|
||||
@ -105,7 +106,11 @@ export function NegotiationConsole() {
|
||||
|
||||
{/* 계단식 하락 스파크라인 + 목표가 기준선 */}
|
||||
<div>
|
||||
<PriceSparkline activeStep={activeStep} />
|
||||
<PriceSparkline
|
||||
prices={NEGOTIATION_STEPS.map((step) => step.price)}
|
||||
targetPrice={TARGET_PRICE}
|
||||
progress={activeStep < 0 ? 0 : (activeStep + 1) / NEGOTIATION_STEPS.length}
|
||||
/>
|
||||
<div className="flex justify-between mt-2">
|
||||
<Typography variant="micro">시작가 {START_PRICE.toLocaleString()}</Typography>
|
||||
<Typography variant="micro" className="text-primary">
|
||||
@ -125,7 +130,7 @@ export function NegotiationConsole() {
|
||||
initial={{ scale: 0.95, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.95, opacity: 0 }}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-emerald-50 text-positive border border-emerald-100 text-xs font-bold"
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-positive-soft text-positive border border-positive/15 text-xs font-bold"
|
||||
>
|
||||
<ShieldCheck className="w-4 h-4 shrink-0" />
|
||||
{NEGOTIATION_STEPS[activeStep].badge}
|
||||
@ -220,54 +225,6 @@ export function NegotiationConsole() {
|
||||
const START_PRICE = 1200000
|
||||
const TARGET_PRICE = 1080000
|
||||
|
||||
/** 대화 진행에 맞춰 그려지는 계단식 가격 하락 라인 + 목표가 점선. */
|
||||
function PriceSparkline({ activeStep }: { activeStep: number }) {
|
||||
const W = 100
|
||||
const H = 44
|
||||
// 위아래 여백을 둔 가격 범위 매핑
|
||||
const MAX = 1215000
|
||||
const MIN = 1035000
|
||||
const y = (price: number) => ((MAX - price) / (MAX - MIN)) * H
|
||||
|
||||
const prices = NEGOTIATION_STEPS.map((step) => step.price)
|
||||
const stepX = (i: number) => (i / (prices.length - 1)) * W
|
||||
|
||||
// step-after 계단 경로
|
||||
let d = `M 0 ${y(prices[0]).toFixed(1)}`
|
||||
for (let i = 1; i < prices.length; i++) {
|
||||
d += ` L ${stepX(i).toFixed(1)} ${y(prices[i - 1]).toFixed(1)} L ${stepX(i).toFixed(1)} ${y(prices[i]).toFixed(1)}`
|
||||
}
|
||||
|
||||
const progress = activeStep < 0 ? 0 : Math.min((activeStep + 1) / prices.length, 1)
|
||||
|
||||
return (
|
||||
<svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" className="w-full h-16 overflow-visible" aria-hidden>
|
||||
<defs>
|
||||
<clipPath id="nego-spark-clip">
|
||||
<rect x="0" y="-4" width={progress * W} height={H + 8} style={{ transition: "width 0.5s ease-out" }} />
|
||||
</clipPath>
|
||||
</defs>
|
||||
{/* 목표가 기준선 */}
|
||||
<g className="text-primary/40">
|
||||
<line
|
||||
x1="0"
|
||||
y1={y(TARGET_PRICE)}
|
||||
x2={W}
|
||||
y2={y(TARGET_PRICE)}
|
||||
stroke="currentColor"
|
||||
strokeWidth="1"
|
||||
strokeDasharray="4 4"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
</g>
|
||||
{/* 가격 하락 계단 — 진행률만큼 클립으로 드러남 */}
|
||||
<g clipPath="url(#nego-spark-clip)" className="text-primary">
|
||||
<path d={d} fill="none" stroke="currentColor" strokeWidth="2.5" vectorEffect="non-scaling-stroke" strokeLinejoin="round" />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// 데모 대화 시나리오 — 1,200,000원 제시에서 1,050,000원 낙찰까지 6스텝
|
||||
const NEGOTIATION_STEPS: DialogueStep[] = [
|
||||
{
|
||||
|
||||
@ -14,6 +14,7 @@ export function Reinforcement() {
|
||||
<Section id="reinforcement" bg="surface" bordered className="border-b border-line">
|
||||
<motion.div {...fadeUp} className="mb-20">
|
||||
<SectionHeading
|
||||
className="text-center md:text-left"
|
||||
eyebrow="REINFORCEMENT LEARNING"
|
||||
title="협상할수록, 더 좋은 조건으로"
|
||||
description={
|
||||
|
||||
@ -77,7 +77,7 @@ export function ROISimulator() {
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 bg-surface p-5 rounded-2xl border border-line">
|
||||
<ShieldCheck className="w-5 h-5 text-emerald-500 flex-shrink-0 mt-0.5" />
|
||||
<ShieldCheck className="w-5 h-5 text-positive flex-shrink-0 mt-0.5" />
|
||||
<Typography variant="caption" className="text-ink-soft">
|
||||
본 시뮬레이션은 글로벌 최저가 스캔(LPS) 시장 표준 및 다수 B2B 자율 협상 데이터의 보수적인 평균 수치(3.1% ~ 4.2%
|
||||
절감)를 적용하여 산정되었습니다. 실사 수준의 검증은 파일 연동을 통해 무상 지원됩니다.
|
||||
|
||||
@ -15,11 +15,12 @@ const buttonVariants = cva(
|
||||
// 밝은 배경 위 반투명 보조 버튼 (글라스 히어로)
|
||||
glass: "bg-white/70 hover:bg-white text-ink-soft hover:text-ink border border-white shadow-sm",
|
||||
},
|
||||
// radius 는 사이즈 무관 rounded-2xl(16px) 로 통일 — 알약형 금지
|
||||
size: {
|
||||
pill: "px-4.5 py-2.5 text-sm font-semibold rounded-full",
|
||||
md: "px-6 py-2.5 text-xs font-bold rounded-full",
|
||||
lg: "px-8 py-4.5 text-base font-bold rounded-[20px]",
|
||||
xl: "px-10 py-5 text-base font-bold rounded-[22px]",
|
||||
pill: "px-4.5 py-2.5 text-sm font-semibold rounded-2xl",
|
||||
md: "px-6 py-2.5 text-xs font-bold rounded-2xl",
|
||||
lg: "px-8 py-4.5 text-base font-bold rounded-2xl",
|
||||
xl: "px-10 py-5 text-base font-bold rounded-2xl",
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: "primary", size: "lg" },
|
||||
|
||||
@ -7,6 +7,7 @@ import { Faq } from "@/components/sections/faq";
|
||||
import { FinalCTA } from "@/components/sections/final-cta";
|
||||
import { Footer } from "@/components/sections/footer";
|
||||
import { Header } from "@/components/sections/header";
|
||||
import { HeroConsole } from "@/components/sections/hero-console";
|
||||
import { HeroGlassmorphic } from "@/components/sections/hero-glassmorphic";
|
||||
import { HeroNeumorphic } from "@/components/sections/hero-neumorphic";
|
||||
import { HowItWorksDemo } from "@/components/sections/how-it-works-demo";
|
||||
@ -29,11 +30,11 @@ export function meta() {
|
||||
];
|
||||
}
|
||||
|
||||
type HeroTheme = "neumorphic" | "glassmorphic";
|
||||
type HeroTheme = "neumorphic" | "glassmorphic" | "console";
|
||||
|
||||
export default function Home() {
|
||||
// 히어로 디자인 비교용 — 스위처는 dev 에서만 노출, 배포본은 glassmorphic 고정
|
||||
const [heroTheme, setHeroTheme] = useState<HeroTheme>("glassmorphic");
|
||||
// 히어로 디자인 비교용 — 스위처는 dev 에서만 노출, 배포본은 console(Live Console) 고정
|
||||
const [heroTheme, setHeroTheme] = useState<HeroTheme>("console");
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white text-ink font-sans antialiased selection:bg-primary/10 selection:text-primary">
|
||||
@ -43,6 +44,7 @@ export default function Home() {
|
||||
{import.meta.env.DEV && <HeroThemeSwitcher value={heroTheme} onChange={setHeroTheme} />}
|
||||
{heroTheme === "neumorphic" && <HeroNeumorphic />}
|
||||
{heroTheme === "glassmorphic" && <HeroGlassmorphic />}
|
||||
{heroTheme === "console" && <HeroConsole />}
|
||||
</div>
|
||||
|
||||
<NegotiationConsole />
|
||||
@ -61,6 +63,7 @@ export default function Home() {
|
||||
const HERO_OPTIONS: { value: HeroTheme; label: string }[] = [
|
||||
{ value: "neumorphic", label: "Phone Demo" },
|
||||
{ value: "glassmorphic", label: "Soft Ambient" },
|
||||
{ value: "console", label: "Live Console" },
|
||||
];
|
||||
|
||||
function HeroThemeSwitcher({ value, onChange }: { value: HeroTheme; onChange: (theme: HeroTheme) => void }) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user