[feat] landing: 솔루션 랜딩페이지 신규 — 히어로·데모·ROI 시뮬레이터 섹션, docker 서비스(:3100) 추가
This commit is contained in:
parent
25e13d7b01
commit
03c22f9474
@ -7,6 +7,7 @@
|
|||||||
# negosium 서버: http://localhost:9300/docs
|
# negosium 서버: http://localhost:9300/docs
|
||||||
# negosium 프론트: http://localhost:3300
|
# negosium 프론트: http://localhost:3300
|
||||||
# negodata 서버: http://localhost:9400/docs
|
# negodata 서버: http://localhost:9400/docs
|
||||||
|
# 솔루션 랜딩: http://localhost:3100
|
||||||
# agent 서버: http://localhost:9500/docs
|
# agent 서버: http://localhost:9500/docs
|
||||||
# anchoring 배치: 포트 없음 — 상주 스케줄러(격주 토 00:00 KST), docker logs anchoring 으로 확인
|
# anchoring 배치: 포트 없음 — 상주 스케줄러(격주 토 00:00 KST), docker logs anchoring 으로 확인
|
||||||
#
|
#
|
||||||
@ -60,6 +61,17 @@ services:
|
|||||||
- /app/node_modules
|
- /app/node_modules
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# 솔루션 랜딩페이지 (react-router dev 서버. 배포는 `npm run build` 산출물 build/client 정적 서빙).
|
||||||
|
landing:
|
||||||
|
build: ./landing
|
||||||
|
container_name: negosium-landing
|
||||||
|
ports:
|
||||||
|
- "3100:3100"
|
||||||
|
volumes:
|
||||||
|
- ./landing:/app
|
||||||
|
- /app/node_modules
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
# 협상 에이전트 (negosium_db 공유, learning 스키마 사용).
|
# 협상 에이전트 (negosium_db 공유, learning 스키마 사용).
|
||||||
agent:
|
agent:
|
||||||
build: ./agent
|
build: ./agent
|
||||||
|
|||||||
3
landing/.gitignore
vendored
Normal file
3
landing/.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
build
|
||||||
|
.react-router
|
||||||
15
landing/Dockerfile
Normal file
15
landing/Dockerfile
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
# front 와 동일 구성 — Debian slim(glibc) 로 alpine(musl)+esbuild 의 ETXTBSY 회피.
|
||||||
|
FROM node:22-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 의존성 먼저 설치 (레이어 캐시)
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 3100
|
||||||
|
|
||||||
|
# react-router dev — --host 0.0.0.0 로 컨테이너 외부 노출 (package.json scripts.dev)
|
||||||
|
CMD ["npm", "run", "dev"]
|
||||||
73
landing/app/app.css
Normal file
73
landing/app/app.css
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
/* Pretendard 가변폰트 self-host (public/fonts).
|
||||||
|
format 은 표준 'woff2' 고정 — 'woff2-variations' 는 윈도우 브라우저가 무시함. */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Pretendard Variable';
|
||||||
|
font-weight: 45 920;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('/fonts/PretendardVariable.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 랜딩 디자인 토큰 (Toss 스타일 라이트 팔레트).
|
||||||
|
* 색은 전부 여기서만 정의하고, 컴포넌트는 토큰 클래스(text-ink, bg-primary …)만 쓴다.
|
||||||
|
*/
|
||||||
|
@theme {
|
||||||
|
--font-sans: 'Pretendard Variable', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Apple SD Gothic Neo", "Malgun Gothic", sans-serif;
|
||||||
|
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
||||||
|
|
||||||
|
/* 브랜드 */
|
||||||
|
--color-primary: #3182F6;
|
||||||
|
--color-primary-deep: #1B64DA; /* hover */
|
||||||
|
--color-primary-soft: #E8F3FF; /* 연한 파랑 배경 — 봇 말풍선, 배지 */
|
||||||
|
|
||||||
|
/* 텍스트. ink 는 다크 섹션에선 배경색으로도 쓴다 */
|
||||||
|
--color-ink: #191F28;
|
||||||
|
--color-ink-soft: #4E5968; /* 보조 본문 */
|
||||||
|
--color-ink-muted: #8B95A1; /* 캡션 */
|
||||||
|
--color-ink-faint: #B0B8C1; /* 플레이스홀더·각주 */
|
||||||
|
|
||||||
|
/* 면·선 */
|
||||||
|
--color-surface: #F9FAFB; /* 회색 섹션 배경 */
|
||||||
|
--color-surface-neu: #F2F4F7; /* 뉴모피즘 히어로 전용 배경 */
|
||||||
|
--color-fill: #F2F4F6; /* 회색 채움 — 탭 트랙, 보조 버튼 */
|
||||||
|
--color-fill-hover: #EAECEF;
|
||||||
|
--color-line: #F2F4F6; /* 섹션 구분선 */
|
||||||
|
--color-line-strong: #E5E8EB; /* 카드 테두리 */
|
||||||
|
|
||||||
|
--color-positive: #0B8F57; /* 낙찰·성공 강조 */
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
background-color: #ffffff;
|
||||||
|
color: var(--color-ink);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 스크롤은 되지만 스크롤바는 숨김 (협상 콘솔 채팅 영역) */
|
||||||
|
.scrollbar-none::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.scrollbar-none {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 은은한 원형 글로우 장식 (히어로 배경) */
|
||||||
|
.mesh-bg {
|
||||||
|
position: absolute;
|
||||||
|
width: 1000px;
|
||||||
|
height: 1000px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: radial-gradient(circle, rgba(49, 130, 246, 0.08) 0%, rgba(255, 255, 255, 0) 70%);
|
||||||
|
filter: blur(100px);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
213
landing/app/components/sections/contact.tsx
Normal file
213
landing/app/components/sections/contact.tsx
Normal file
@ -0,0 +1,213 @@
|
|||||||
|
import { useState } from "react"
|
||||||
|
import { AnimatePresence, motion } from "motion/react"
|
||||||
|
import { Building, CheckCircle2, Loader2, Mail, Phone, Send } from "lucide-react"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input, Textarea } from "@/components/ui/input"
|
||||||
|
import { Section } from "@/components/ui/section"
|
||||||
|
import { SectionHeading } from "@/components/ui/section-heading"
|
||||||
|
import { Typography } from "@/components/ui/typography"
|
||||||
|
import { EASE_OUT_EXPO } from "@/lib/motion"
|
||||||
|
import type { LucideIcon } from "lucide-react"
|
||||||
|
|
||||||
|
const EMPTY_FORM = {
|
||||||
|
companyName: '',
|
||||||
|
contactName: '',
|
||||||
|
email: '',
|
||||||
|
phone: '',
|
||||||
|
message: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 도입 문의 폼. 백엔드 미연결 — 제출은 데모 처리(1.2초 후 성공 화면). */
|
||||||
|
export function Contact() {
|
||||||
|
const [formData, setFormData] = useState(EMPTY_FORM)
|
||||||
|
const [status, setStatus] = useState<'idle' | 'submitting' | 'success'>('idle')
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!formData.companyName || !formData.contactName || !formData.email || !formData.phone) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus('submitting')
|
||||||
|
setTimeout(() => setStatus('success'), 1200)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||||
|
const { name, value } = e.target
|
||||||
|
setFormData((prev) => ({ ...prev, [name]: value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitting = status === 'submitting'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Section
|
||||||
|
id="contact-section"
|
||||||
|
bordered
|
||||||
|
decor={
|
||||||
|
<>
|
||||||
|
<div className="absolute top-[20%] right-[10%] w-72 h-72 bg-primary/5 blur-[100px] rounded-full pointer-events-none" />
|
||||||
|
<div className="absolute bottom-[10%] left-[5%] w-96 h-96 bg-primary/3 blur-[120px] rounded-full pointer-events-none" />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SectionHeading
|
||||||
|
align="center"
|
||||||
|
className="mb-16"
|
||||||
|
eyebrow="GET IN TOUCH"
|
||||||
|
title="도입 문의 및 컨택하기"
|
||||||
|
description="사내 ERP 연동부터 우리 기업에 맞춘 흥정 시나리오 구성까지, negotium의 구매 혁신 컨설턴트가 상세히 안내해 드립니다."
|
||||||
|
descriptionClassName="max-w-xl"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="max-w-2xl mx-auto">
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
{status !== 'success' ? (
|
||||||
|
<motion.form
|
||||||
|
key="contact-form"
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -20 }}
|
||||||
|
transition={{ duration: 0.5, ease: EASE_OUT_EXPO }}
|
||||||
|
className="space-y-6"
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||||
|
<FormField label="회사명" icon={Building} required>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
name="companyName"
|
||||||
|
required
|
||||||
|
placeholder="예: 주식회사 네고시움"
|
||||||
|
value={formData.companyName}
|
||||||
|
onChange={handleChange}
|
||||||
|
disabled={submitting}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="담당자 성함 / 직책" required>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
name="contactName"
|
||||||
|
required
|
||||||
|
placeholder="예: 홍길동 팀장"
|
||||||
|
value={formData.contactName}
|
||||||
|
onChange={handleChange}
|
||||||
|
disabled={submitting}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||||
|
<FormField label="이메일 주소" icon={Mail} required>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
required
|
||||||
|
placeholder="example@company.com"
|
||||||
|
value={formData.email}
|
||||||
|
onChange={handleChange}
|
||||||
|
disabled={submitting}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="연락처" icon={Phone} required>
|
||||||
|
<Input
|
||||||
|
type="tel"
|
||||||
|
name="phone"
|
||||||
|
required
|
||||||
|
placeholder="010-0000-0000"
|
||||||
|
value={formData.phone}
|
||||||
|
onChange={handleChange}
|
||||||
|
disabled={submitting}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField label="상세 문의 및 요구사항">
|
||||||
|
<Textarea
|
||||||
|
name="message"
|
||||||
|
rows={4}
|
||||||
|
placeholder="현재 겪고 계신 구매 조율 상의 번거로움이나, 자동화를 원하시는 구체적인 부자재 품목 정보를 남겨주시면 더욱 맞춤화된 상담이 가능합니다."
|
||||||
|
value={formData.message}
|
||||||
|
onChange={handleChange}
|
||||||
|
disabled={submitting}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<Typography variant="caption" className="font-medium">
|
||||||
|
* 제출 시 입력하신 정보는 문의 사항 응답 및 도입 검토 목적에 한하여 사용되며, 관련 법령에 의거하여 안전하게
|
||||||
|
보호됩니다.
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<div className="pt-2">
|
||||||
|
<Button type="submit" disabled={submitting} className="w-full shadow-sm hover:shadow-md">
|
||||||
|
{submitting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin" />
|
||||||
|
<span>신청서 전송 중...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Send className="w-5 h-5" />
|
||||||
|
<span>도입 문의 및 상담 신청하기</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</motion.form>
|
||||||
|
) : (
|
||||||
|
<motion.div
|
||||||
|
key="success-message"
|
||||||
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ duration: 0.6, ease: EASE_OUT_EXPO }}
|
||||||
|
className="text-center py-16 px-6 bg-primary/5 rounded-[32px] text-ink break-keep"
|
||||||
|
>
|
||||||
|
<div className="w-16 h-16 bg-primary/10 text-primary rounded-full flex items-center justify-center mx-auto mb-6">
|
||||||
|
<CheckCircle2 className="w-8 h-8" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-2xl font-extrabold text-ink mb-3">도입 문의 신청이 접수되었습니다</h3>
|
||||||
|
<p className="text-ink-soft font-semibold text-[15px] leading-relaxed max-w-md mx-auto mb-8">
|
||||||
|
작성해주신 담당자 정보({formData.contactName} 님)를 통해 24시간 이내에 전담 혁신 컨설턴트가 유선 혹은 이메일로
|
||||||
|
연락을 드리겠습니다.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="md"
|
||||||
|
onClick={() => {
|
||||||
|
setFormData(EMPTY_FORM)
|
||||||
|
setStatus('idle')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
새로 문의하기
|
||||||
|
</Button>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormField({
|
||||||
|
label,
|
||||||
|
icon: Icon,
|
||||||
|
required = false,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
icon?: LucideIcon
|
||||||
|
required?: boolean
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-bold text-ink-soft flex items-center gap-1.5">
|
||||||
|
{Icon && <Icon className="w-3.5 h-3.5 text-ink-muted" />}
|
||||||
|
{label} {required && <span className="text-primary">*</span>}
|
||||||
|
</label>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
79
landing/app/components/sections/core-values.tsx
Normal file
79
landing/app/components/sections/core-values.tsx
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
import { motion } from "motion/react"
|
||||||
|
import { Briefcase, Clock, Lock, Users, type LucideIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { Section } from "@/components/ui/section"
|
||||||
|
import { SectionHeading } from "@/components/ui/section-heading"
|
||||||
|
import { Typography } from "@/components/ui/typography"
|
||||||
|
import { useFadeUp } from "@/lib/motion"
|
||||||
|
|
||||||
|
/** 가격 외 보조 이점 4종 스트립. */
|
||||||
|
export function CoreValues() {
|
||||||
|
const fadeUp = useFadeUp()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Section id="core-values">
|
||||||
|
<motion.div {...fadeUp} className="mb-24">
|
||||||
|
<SectionHeading
|
||||||
|
align="center"
|
||||||
|
eyebrow="BEYOND EXPECTED VALUE"
|
||||||
|
title="가격 그 이상의 이점"
|
||||||
|
description="단순 업무 긴축을 넘어선 진정한 구매 거버넌스 혁신"
|
||||||
|
descriptionClassName="mt-4 text-[17px]"
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-8">
|
||||||
|
{VALUES.map((value, idx) => (
|
||||||
|
<ValueStrip key={value.title} value={value} delay={0.1 * (idx + 1)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Value = { icon: LucideIcon; title: string; description: string }
|
||||||
|
|
||||||
|
function ValueStrip({ value, delay }: { value: Value; delay: number }) {
|
||||||
|
const fadeUp = useFadeUp(delay)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div {...fadeUp} className="bg-surface p-8 rounded-[32px] flex items-start gap-5 transition-all hover:translate-y-[-4px]">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-primary/5 flex items-center justify-center text-primary shrink-0 mt-1">
|
||||||
|
<value.icon className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Typography variant="cardTitle" className="mb-2">
|
||||||
|
{value.title}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="small">{value.description}</Typography>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const VALUES: Value[] = [
|
||||||
|
{
|
||||||
|
icon: Users,
|
||||||
|
title: "파트너사 관계 수호",
|
||||||
|
description:
|
||||||
|
"단가를 조율하는 과정에서 수반되는 피로감과 실랑이는 온전히 봇이 감당합니다. 담당 부서는 협력사 담당자와 신뢰할 수 있는 인간적 유대를 단단히 하고 동반 성장을 도모하는 큰 틀의 파트너십에만 집중할 수 있습니다.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Lock,
|
||||||
|
title: "완벽하고 투명한 거버넌스",
|
||||||
|
description:
|
||||||
|
"모든 제안 접수와 흥정 로그, 조율 타임라인이 위변조가 차단된 고유 원장에 실시간 적재됩니다. 감사 대응이나 의사결정 추적 분석 시 완벽에 가까운 거버넌스를 보장합니다.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Briefcase,
|
||||||
|
title: "핵심 전략에만 집중",
|
||||||
|
description:
|
||||||
|
"수많은 이메일 교환, 메신저 단가 실랑이 등 정형적이고 반복적인 흥정 수작업에서 구매 부서를 구해냅니다. 공급망 위기 대처, 우량 소스 발굴과 같은 진정 가치 있는 혁신적 과업에 인재들을 투입하세요.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Clock,
|
||||||
|
title: "협상 리드타임 대폭 긴축",
|
||||||
|
description:
|
||||||
|
"수백 공급사와 동시에 병렬 조율을 자동으로 전개하므로 업무량의 한계가 없습니다. 통상 수주일 동안 지루하게 이어지던 최종 가격 타결 주기를 단 수일 안으로 긴축 단축시킬 수 있습니다.",
|
||||||
|
},
|
||||||
|
]
|
||||||
59
landing/app/components/sections/final-cta.tsx
Normal file
59
landing/app/components/sections/final-cta.tsx
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import { motion } from "motion/react"
|
||||||
|
import { ArrowRight } from "lucide-react"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
||||||
|
/** 최종 CTA — 다크 몰입 섹션. */
|
||||||
|
export function FinalCTA() {
|
||||||
|
return (
|
||||||
|
<section className="relative bg-ink text-white py-36 md:py-48 overflow-hidden">
|
||||||
|
{/* 다크 섹션 중앙의 은은한 파란 글로우 */}
|
||||||
|
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[800px] h-[800px] rounded-full bg-primary/15 blur-[120px] pointer-events-none" />
|
||||||
|
|
||||||
|
<div className="max-w-3xl mx-auto px-6 text-center relative z-10">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
|
whileInView={{ opacity: 1, scale: 1 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-1.5 bg-primary/10 border border-primary/20 rounded-full text-xs font-bold text-primary mb-8"
|
||||||
|
>
|
||||||
|
<span>START RECLAIMING YOUR BUDGET TODAY</span>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.h2
|
||||||
|
initial={{ opacity: 0, y: 24 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.8, delay: 0.1 }}
|
||||||
|
className="text-3xl sm:text-4xl md:text-[54px] font-extrabold tracking-tighter mb-8 text-white leading-[1.2] break-keep"
|
||||||
|
>
|
||||||
|
지금도 구매 예산은 <br className="sm:hidden" />
|
||||||
|
조용히 새고 있을지 모릅니다
|
||||||
|
</motion.h2>
|
||||||
|
|
||||||
|
<motion.p
|
||||||
|
initial={{ opacity: 0, y: 24 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.8, delay: 0.2 }}
|
||||||
|
className="text-[17px] text-ink-muted max-w-xl mx-auto mb-14 leading-relaxed font-semibold break-keep"
|
||||||
|
>
|
||||||
|
수작업으로 진행되어 수많은 단가 타협 기회를 흘려보내던 파트너사 흥정, 규칙 준수에 압도적으로 특화된 negotium 봇에
|
||||||
|
위임하고 재무 마진을 안전히 지키십시오.
|
||||||
|
</motion.p>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 16 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.8, delay: 0.3 }}
|
||||||
|
>
|
||||||
|
<Button href="#contact-section" size="xl" className="gap-2.5 hover:-translate-y-px">
|
||||||
|
<span>도입 및 계약 상담 신청하기</span>
|
||||||
|
<ArrowRight className="w-5 h-5" />
|
||||||
|
</Button>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
33
landing/app/components/sections/footer.tsx
Normal file
33
landing/app/components/sections/footer.tsx
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { Logo } from "@/components/ui/logo"
|
||||||
|
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="md:col-span-8">
|
||||||
|
<Logo className="mb-6" />
|
||||||
|
<Typography variant="caption" className="max-w-sm mb-6 font-medium">
|
||||||
|
negotium은 기업 구매 부서가 통제 가이드라인을 정의해두면 지능형 흥정 봇이 대신하여 다수 공급자와 투명하게 단가를
|
||||||
|
조율하는 B2B 자동 중개 대행 플랫폼입니다.
|
||||||
|
</Typography>
|
||||||
|
<p className="text-xs text-ink-faint font-semibold">
|
||||||
|
© {new Date().getFullYear()} negotium Co., Ltd. All rights reserved.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="md:col-span-4">
|
||||||
|
<Typography variant="micro" as="h4" className="text-ink mb-4 block">
|
||||||
|
Core Module
|
||||||
|
</Typography>
|
||||||
|
<ul className="space-y-3 text-xs text-ink-soft font-semibold">
|
||||||
|
<li>규칙 기반 단가 흥정 엔진</li>
|
||||||
|
<li>다중 파트너 포털 채널</li>
|
||||||
|
<li>강화학습 템포 보완 모듈</li>
|
||||||
|
<li>투명 거버넌스 디지털 원장</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
)
|
||||||
|
}
|
||||||
52
landing/app/components/sections/header.tsx
Normal file
52
landing/app/components/sections/header.tsx
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { ArrowUpRight } from "lucide-react"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Logo } from "@/components/ui/logo"
|
||||||
|
|
||||||
|
export function Header() {
|
||||||
|
const [scrolled, setScrolled] = useState(false)
|
||||||
|
|
||||||
|
// 스크롤 시 헤더를 반투명 블러 배경으로 전환
|
||||||
|
useEffect(() => {
|
||||||
|
const handleScroll = () => setScrolled(window.scrollY > 40)
|
||||||
|
window.addEventListener("scroll", handleScroll, { passive: true })
|
||||||
|
handleScroll()
|
||||||
|
return () => window.removeEventListener("scroll", handleScroll)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header
|
||||||
|
className={`fixed top-0 left-0 right-0 z-40 transition-all duration-300 ${
|
||||||
|
scrolled ? "py-4 bg-white/80 backdrop-blur-xl border-b border-line" : "py-6 bg-transparent"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="max-w-5xl mx-auto px-6 flex items-center justify-between">
|
||||||
|
<a href="#">
|
||||||
|
<Logo />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<nav className="hidden md:flex items-center gap-8 text-[15px] font-semibold text-ink-soft">
|
||||||
|
{NAV_LINKS.map(({ href, label }) => (
|
||||||
|
<a key={href} href={href} className="hover:text-ink transition-colors">
|
||||||
|
{label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<Button href="#contact-section" size="pill" className="gap-1.5">
|
||||||
|
<span>상담 신청</span>
|
||||||
|
<ArrowUpRight className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const NAV_LINKS = [
|
||||||
|
{ href: "#how-it-works", label: "실시간 협상" },
|
||||||
|
{ href: "#how-it-works-demo", label: "이용 가이드" },
|
||||||
|
{ href: "#core-values", label: "솔루션 이점" },
|
||||||
|
{ href: "#reinforcement", label: "강화학습" },
|
||||||
|
{ href: "#benchmarks", label: "보안 및 신뢰성" },
|
||||||
|
]
|
||||||
85
landing/app/components/sections/hero-glassmorphic.tsx
Normal file
85
landing/app/components/sections/hero-glassmorphic.tsx
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import { motion } from "motion/react"
|
||||||
|
import { ArrowDown, ArrowRight } from "lucide-react"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Typography } from "@/components/ui/typography"
|
||||||
|
import { EASE_OUT_EXPO } from "@/lib/motion"
|
||||||
|
|
||||||
|
/** 기본 히어로 — 파스텔 블롭 + 그리드 + 떠다니는 와이어프레임 도형. */
|
||||||
|
export function HeroGlassmorphic() {
|
||||||
|
return (
|
||||||
|
<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 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" />
|
||||||
|
|
||||||
|
{/* 떠다니는 와이어프레임 도형 — max-w-7xl 안에 가둠 */}
|
||||||
|
<div className="absolute inset-0 max-w-7xl mx-auto pointer-events-none z-0">
|
||||||
|
<motion.div
|
||||||
|
animate={{ y: [0, -18, 0], rotate: [12, 42, 12] }}
|
||||||
|
transition={{ duration: 8, repeat: Infinity, ease: "easeInOut" }}
|
||||||
|
className="absolute top-[15%] left-[8vw] lg:left-[10%] w-16 h-16 sm:w-28 sm:h-28 border-2 border-primary/40 bg-white/40 shadow-[0_8px_32px_rgba(49,130,246,0.05)] rounded-[28px] pointer-events-none z-0"
|
||||||
|
/>
|
||||||
|
<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"
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
animate={{ x: [0, 10, 0], y: [0, 10, 0] }}
|
||||||
|
transition={{ duration: 10, repeat: Infinity, ease: "easeInOut" }}
|
||||||
|
className="absolute top-[30%] right-[8vw] lg:right-[12%] w-20 h-20 sm:w-36 sm:h-36 border-2 border-primary/20 rounded-[40px] pointer-events-none z-0"
|
||||||
|
>
|
||||||
|
<div className="w-full h-full bg-gradient-to-tr from-primary/5 to-transparent rounded-[38px]" />
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-4xl mx-auto px-6 w-full py-24 relative z-10 flex flex-col items-center text-center">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 24 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.9, delay: 0.1, ease: EASE_OUT_EXPO }}
|
||||||
|
>
|
||||||
|
<Typography variant="display" className="mb-8">
|
||||||
|
구매 협상, 이제 <span className="text-primary">봇이 알아서</span> <br />
|
||||||
|
흥정부터 낙찰까지 자동으로
|
||||||
|
</Typography>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 24 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.9, delay: 0.2, ease: EASE_OUT_EXPO }}
|
||||||
|
>
|
||||||
|
<Typography variant="lead" className="md:text-[20px] max-w-2xl mb-12">
|
||||||
|
협상할수록 강화학습으로 흥정 전략을 알아서 다듬어갑니다. <br className="hidden sm:inline" />
|
||||||
|
파트너사 이탈(결렬) 없이, 최선의 계약 조건만 완벽하게 성사시키세요.
|
||||||
|
</Typography>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 24 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.9, delay: 0.3, ease: EASE_OUT_EXPO }}
|
||||||
|
className="flex flex-col sm:flex-row gap-4 items-center justify-center w-full max-w-md z-20"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
href="#contact-section"
|
||||||
|
className="w-full sm:w-auto group shadow-[0_8px_24px_rgba(49,130,246,0.15)] hover:scale-[1.02] active:scale-[0.98]"
|
||||||
|
>
|
||||||
|
<span>도입 문의 상담 받기</span>
|
||||||
|
<ArrowRight className="w-5 h-5 transition-transform group-hover:translate-x-1" />
|
||||||
|
</Button>
|
||||||
|
<Button href="#how-it-works" variant="glass" className="w-full sm:w-auto gap-1.5">
|
||||||
|
<span>작동 방식 보기</span>
|
||||||
|
<ArrowDown className="w-4 h-4 text-ink-soft" />
|
||||||
|
</Button>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
83
landing/app/components/sections/hero-neumorphic.tsx
Normal file
83
landing/app/components/sections/hero-neumorphic.tsx
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import { motion } from "motion/react"
|
||||||
|
import { ArrowDown, ArrowRight, Sparkles } from "lucide-react"
|
||||||
|
|
||||||
|
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" />
|
||||||
|
|
||||||
|
<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 */}
|
||||||
|
<div className="lg:col-span-7 space-y-8 text-left">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 15 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.7 }}
|
||||||
|
className="inline-flex items-center gap-2 px-3.5 py-1.5 bg-surface-neu rounded-full text-xs font-bold text-primary shadow-[3px_3px_6px_#dbdee3,-3px_-3px_6px_#ffffff]"
|
||||||
|
>
|
||||||
|
<Sparkles className="w-3.5 h-3.5" />
|
||||||
|
<span>LIVE NEGOTIATION DEMO</span>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.8, delay: 0.1 }}>
|
||||||
|
<Typography variant="display" className="text-4xl sm:text-5xl lg:text-[54px] leading-[1.15]">
|
||||||
|
구매 협상, 이제 <span className="text-primary">봇이 알아서</span> <br />
|
||||||
|
흥정부터 낙찰까지 자동으로
|
||||||
|
</Typography>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.8, delay: 0.2 }}>
|
||||||
|
<Typography variant="lead" className="max-w-xl">
|
||||||
|
불필요한 실랑이와 감정 소모 없이, 예산 상한선 내에서 최적의 원가 절감을 달성하세요. 데이터 기반의 자율 흥정 봇이
|
||||||
|
24시간 실시간 대리 협상을 완료합니다.
|
||||||
|
</Typography>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.8, delay: 0.3 }}
|
||||||
|
className="flex flex-col sm:flex-row gap-5 pt-4"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href="#contact-section"
|
||||||
|
className="px-8 py-4.5 bg-primary hover:bg-primary-deep text-white text-base font-bold rounded-[20px] cursor-pointer flex items-center justify-center gap-2 group transition-all shadow-[6px_6px_12px_rgba(49,130,246,0.25),-6px_-6px_12px_#ffffff] hover:shadow-[4px_4px_8px_rgba(49,130,246,0.25),-4px_-4px_8px_#ffffff] active:shadow-none translate-y-0 active:translate-y-0.5"
|
||||||
|
>
|
||||||
|
<span>도입 문의 상담 받기</span>
|
||||||
|
<ArrowRight className="w-5 h-5 transition-transform group-hover:translate-x-1" />
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="#how-it-works"
|
||||||
|
className="px-8 py-4.5 bg-surface-neu text-ink-soft hover:text-ink text-base font-bold rounded-[20px] transition-all flex items-center justify-center gap-1.5 shadow-[6px_6px_12px_#dbdee3,-6px_-6px_12px_#ffffff] hover:shadow-[4px_4px_8px_#dbdee3,-4px_-4px_8px_#ffffff] active:shadow-[inset_3px_3px_6px_#dbdee3,inset_-3px_-3px_6px_#ffffff] translate-y-0 active:translate-y-0.5"
|
||||||
|
>
|
||||||
|
<span>작동 방식 보기</span>
|
||||||
|
<ArrowDown className="w-4 h-4 text-ink-soft" />
|
||||||
|
</a>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 우: 협력사 모바일 협상 실제 화면 (GIF) */}
|
||||||
|
<div className="lg:col-span-5 flex justify-center">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
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>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
268
landing/app/components/sections/how-it-works-demo.tsx
Normal file
268
landing/app/components/sections/how-it-works-demo.tsx
Normal file
@ -0,0 +1,268 @@
|
|||||||
|
import { useState } from "react"
|
||||||
|
import { AnimatePresence, motion } from "motion/react"
|
||||||
|
import { Bot, Check, Monitor, Smartphone, Sparkles, type LucideIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { BrowserFrame, PhoneFrame } from "@/components/ui/device-mockups"
|
||||||
|
import { Section } from "@/components/ui/section"
|
||||||
|
import { SectionHeading } from "@/components/ui/section-heading"
|
||||||
|
import { Typography } from "@/components/ui/typography"
|
||||||
|
|
||||||
|
/** 3단계 이용 가이드 — 탭 전환식 GIF 시연 (데스크톱/모바일 목업). */
|
||||||
|
export function HowItWorksDemo() {
|
||||||
|
const [activeKey, setActiveKey] = useState<DemoTab["key"]>("create")
|
||||||
|
|
||||||
|
const activeTab = DEMO_TABS.find((tab) => tab.key === activeKey)!
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Section id="how-it-works-demo" width="lg" bordered className="overflow-hidden">
|
||||||
|
<SectionHeading
|
||||||
|
align="center"
|
||||||
|
className="mb-20"
|
||||||
|
eyebrow="SERVICE DEMONSTRATION"
|
||||||
|
title="견적 생성부터 AI 협상까지 한눈에 보기"
|
||||||
|
description="복잡해 보이는 구매 과정이 어떻게 자동화되는지 실제 작동 화면(GIF)을 통해 쉽고 직관적으로 확인해 보세요."
|
||||||
|
descriptionClassName="text-[17px] max-w-2xl"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 탭 스위치 */}
|
||||||
|
<div className="flex justify-center mb-16">
|
||||||
|
<div className="p-1 bg-fill rounded-[14px] inline-flex flex-wrap justify-center gap-1">
|
||||||
|
{DEMO_TABS.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.key}
|
||||||
|
onClick={() => setActiveKey(tab.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 ${
|
||||||
|
activeKey === tab.key ? "bg-white text-primary" : "text-ink-soft hover:text-ink"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<tab.tabIcon className="w-4 h-4" />
|
||||||
|
<span>{tab.tabLabel}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
<motion.div
|
||||||
|
key={`${activeTab.key}-text`}
|
||||||
|
initial={{ opacity: 0, x: -20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, x: 20 }}
|
||||||
|
transition={{ duration: 0.4 }}
|
||||||
|
className="space-y-6"
|
||||||
|
>
|
||||||
|
<div className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-lg text-xs font-bold ${activeTab.badge.className}`}>
|
||||||
|
<activeTab.badge.icon className="w-3.5 h-3.5" />
|
||||||
|
{activeTab.badge.label}
|
||||||
|
</div>
|
||||||
|
<Typography variant="heading">{activeTab.heading}</Typography>
|
||||||
|
{activeTab.paragraphs.map((paragraph, idx) => (
|
||||||
|
<Typography key={idx} variant="body">
|
||||||
|
{paragraph}
|
||||||
|
</Typography>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<Check className={`w-4 h-4 ${activeTab.checkClassName}`} />
|
||||||
|
<span>{check}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 우: 디바이스 목업 + GIF */}
|
||||||
|
<div className="lg:col-span-7 flex justify-center items-center">
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
<motion.div
|
||||||
|
key={`${activeTab.key}-mockup`}
|
||||||
|
initial={{ opacity: 0, scale: 0.96, y: 15 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.96, y: -15 }}
|
||||||
|
transition={{ duration: 0.4 }}
|
||||||
|
className={`w-full ${activeTab.mockup === "phone" ? "flex justify-center" : ""}`}
|
||||||
|
>
|
||||||
|
{activeTab.mockup === "browser" ? (
|
||||||
|
<BrowserFrame url={activeTab.url!}>
|
||||||
|
<GifSlot tab={activeTab} />
|
||||||
|
</BrowserFrame>
|
||||||
|
) : (
|
||||||
|
<PhoneFrame>
|
||||||
|
<GifSlot tab={activeTab} />
|
||||||
|
</PhoneFrame>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GIF 로드 실패 시(파일 미배치) 배치 안내 폴백을 보여주는 슬롯. */
|
||||||
|
function GifSlot({ tab }: { tab: DemoTab }) {
|
||||||
|
const [error, setError] = useState(false)
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
const phone = tab.mockup === "phone"
|
||||||
|
return (
|
||||||
|
<div className={phone ? "text-center p-6 space-y-4 w-full" : "text-center p-8 max-w-sm space-y-4"}>
|
||||||
|
<div className={`${tab.fallback.iconWrapClassName} flex items-center justify-center mx-auto animate-pulse`}>
|
||||||
|
<tab.fallback.icon className={phone ? "w-6 h-6" : "w-7 h-7"} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className={`${phone ? "text-xs" : "text-sm"} font-bold text-ink break-keep`}>{tab.fallback.title}</h4>
|
||||||
|
<p className={`${phone ? "text-[10px] px-2" : "text-[11px]"} text-ink-muted font-semibold mt-1 leading-relaxed break-keep`}>
|
||||||
|
프로젝트 루트의{" "}
|
||||||
|
<code className={`px-1.5 py-0.5 rounded border ${tab.fallback.codeClassName}`}>public{tab.gif}</code> 경로에{" "}
|
||||||
|
{tab.fallback.hint}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={tab.gif}
|
||||||
|
alt={tab.gifAlt}
|
||||||
|
onError={() => setError(true)}
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type DemoTab = {
|
||||||
|
key: "create" | "negotiate" | "result"
|
||||||
|
tabIcon: LucideIcon
|
||||||
|
tabLabel: string
|
||||||
|
badge: { icon: LucideIcon; label: string; className: string }
|
||||||
|
checkClassName: string
|
||||||
|
heading: React.ReactNode
|
||||||
|
paragraphs: React.ReactNode[]
|
||||||
|
checks: string[]
|
||||||
|
mockup: "browser" | "phone"
|
||||||
|
url?: string
|
||||||
|
gif: string
|
||||||
|
gifAlt: string
|
||||||
|
fallback: {
|
||||||
|
icon: LucideIcon
|
||||||
|
iconWrapClassName: string
|
||||||
|
codeClassName: string
|
||||||
|
title: string
|
||||||
|
hint: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEMO_TABS: DemoTab[] = [
|
||||||
|
{
|
||||||
|
key: "create",
|
||||||
|
tabIcon: Monitor,
|
||||||
|
tabLabel: "1단계: AI 견적 & 가이드 수립",
|
||||||
|
badge: { icon: Sparkles, label: "구매 관리자 콘솔 (웹)", className: "bg-primary-soft text-primary" },
|
||||||
|
checkClassName: "text-primary",
|
||||||
|
heading: (
|
||||||
|
<>
|
||||||
|
엑셀 등록만으로 끝나는 <br />
|
||||||
|
초정밀 가이드라인 자율 수립
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
paragraphs: [
|
||||||
|
<>
|
||||||
|
품목 명과 시중 시장 가격을 입력하면, negotium의 지능형 <b>LPS(Lowest Price Scanning)</b> 기술이 실시간 시세를 자동
|
||||||
|
연동합니다.
|
||||||
|
</>,
|
||||||
|
<>
|
||||||
|
과거 거래 데이터 분석을 바탕으로, 파트너사가 반발심을 가질 확률을 최저로 억제하면서 사내 마진율을 방어할 수 있는{" "}
|
||||||
|
<b className="text-primary">최적 목표 단가(Target Price)</b> 가이드라인을 기획해냅니다.
|
||||||
|
</>,
|
||||||
|
],
|
||||||
|
checks: ["원자재 및 공정 시장 실시간 시세 연동", "이탈 저항 임계 모델 기반 가이드 자동 완성"],
|
||||||
|
mockup: "browser",
|
||||||
|
url: "https://console.negotium.ai/estimates/new",
|
||||||
|
gif: "/gifs/quote_generation.gif",
|
||||||
|
gifAlt: "견적 생성 가이드라인 수립 시연",
|
||||||
|
fallback: {
|
||||||
|
icon: Monitor,
|
||||||
|
iconWrapClassName: "w-14 h-14 bg-primary-soft rounded-2xl text-primary",
|
||||||
|
codeClassName: "bg-white text-primary",
|
||||||
|
title: "견적 생성 시연 GIF 공간",
|
||||||
|
hint: "실제 기동 GIF를 배치하시면 이 영역에 자동으로 재생됩니다.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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",
|
||||||
|
heading: (
|
||||||
|
<>
|
||||||
|
로그인도 필요 없이 <br />
|
||||||
|
전용 링크로 간편하게 밀당 조율
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
paragraphs: [
|
||||||
|
<>
|
||||||
|
각 파트너사 담당자는 번거로운 가입 절차 없이 발송된 <b>비대면 협상 모바일 포털</b>에 접속해 단가를 실시간 조율합니다.
|
||||||
|
</>,
|
||||||
|
<>
|
||||||
|
단순 마진 깎기가 아닌, <b>"물량 보증"</b> 혹은 <b>"지불 주기 단축"</b> 등의 와일드카드 거래 카드를 연동 제안하여
|
||||||
|
파트너사의 자발적인 마진 타협을 부드럽게 이끌어냅니다.
|
||||||
|
</>,
|
||||||
|
],
|
||||||
|
checks: ["1:1 프라이빗 대화형 역경매 포털 제공", "불필요한 실랑이를 예방하여 파트너십 보호"],
|
||||||
|
mockup: "phone",
|
||||||
|
gif: "/gifs/negotiation.gif",
|
||||||
|
gifAlt: "AI 모바일 자동 협상 시연",
|
||||||
|
fallback: {
|
||||||
|
icon: Smartphone,
|
||||||
|
iconWrapClassName: "w-12 h-12 bg-emerald-50 border border-emerald-100 rounded-full text-emerald-500",
|
||||||
|
codeClassName: "bg-fill text-positive",
|
||||||
|
title: "협상 시연 GIF 공간",
|
||||||
|
hint: "실제 모바일 협상 시뮬레이션 GIF를 배치해 주세요.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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",
|
||||||
|
heading: (
|
||||||
|
<>
|
||||||
|
자동 분석 및 타결 보고서 <br />
|
||||||
|
대시보드에서 성과 확인
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
paragraphs: [
|
||||||
|
<>
|
||||||
|
협상이 실시간 완료되면, 구매 관리자는 AI가 도출해 낸 파트너사별 최종 타결 단가와 절감된 사내 재무 지표 리포트를
|
||||||
|
대시보드에서 즉시 확인합니다.
|
||||||
|
</>,
|
||||||
|
<>
|
||||||
|
어떤 파트너사가 어느 카드(정산 단축, 물량 보증)를 수용하여 단가를 낮추었는지 시각적으로 분석되어 최저 계약 체결을
|
||||||
|
전면 검토할 수 있습니다.
|
||||||
|
</>,
|
||||||
|
],
|
||||||
|
checks: ["최종 단가 조율 결과 및 계약 체결 자동 확정", "총 누적 절감액(Savings) 및 마진 방어 통계 실시간 표기"],
|
||||||
|
mockup: "browser",
|
||||||
|
url: "https://console.negotium.ai/dashboard/reports",
|
||||||
|
gif: "/gifs/negotiation_result.gif",
|
||||||
|
gifAlt: "협상 결과 분석 대시보드 시연",
|
||||||
|
fallback: {
|
||||||
|
icon: Monitor,
|
||||||
|
iconWrapClassName: "w-14 h-14 bg-purple-50 rounded-2xl text-purple-600",
|
||||||
|
codeClassName: "bg-white text-purple-600",
|
||||||
|
title: "협상 결과 확인 시연 GIF 공간",
|
||||||
|
hint: "결과 보고 대시보드 기동 GIF를 배치하시면 이 영역에 자동으로 재생됩니다.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
279
landing/app/components/sections/negotiation-console.tsx
Normal file
279
landing/app/components/sections/negotiation-console.tsx
Normal file
@ -0,0 +1,279 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react"
|
||||||
|
import { AnimatePresence, motion } from "motion/react"
|
||||||
|
import { Bot, Handshake, ShieldCheck } from "lucide-react"
|
||||||
|
|
||||||
|
import { SlateRenderer } from "@/components/ui/slate-renderer"
|
||||||
|
import { EASE_OUT_EXPO } from "@/lib/motion"
|
||||||
|
import type { DialogueStep } from "@/types"
|
||||||
|
|
||||||
|
/** 스크롤 연동 sticky 협상 데모 — 스크롤 진행률에 따라 대화가 쌓이고 단가가 내려간다. */
|
||||||
|
export function NegotiationConsole() {
|
||||||
|
// -1 = 아직 섹션 진입 전(온보딩 가이드 표시)
|
||||||
|
const [activeStep, setActiveStep] = useState(-1)
|
||||||
|
const [animatedPrice, setAnimatedPrice] = useState(1200)
|
||||||
|
const sectionRef = useRef<HTMLDivElement>(null)
|
||||||
|
const chatContainerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
// 섹션 내 스크롤 진행률 → 대화 스텝 매핑
|
||||||
|
useEffect(() => {
|
||||||
|
const handleScroll = () => {
|
||||||
|
if (!sectionRef.current) return
|
||||||
|
const rect = sectionRef.current.getBoundingClientRect()
|
||||||
|
const totalScrollable = rect.height - window.innerHeight
|
||||||
|
if (totalScrollable <= 0) return
|
||||||
|
|
||||||
|
const scrolled = -rect.top
|
||||||
|
if (scrolled < 0) {
|
||||||
|
setActiveStep(-1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const clampedRatio = Math.max(0, Math.min(scrolled / totalScrollable, 0.99))
|
||||||
|
setActiveStep(Math.floor(clampedRatio * NEGOTIATION_STEPS.length))
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("scroll", handleScroll, { passive: true })
|
||||||
|
handleScroll()
|
||||||
|
return () => window.removeEventListener("scroll", handleScroll)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 단가 오도미터 애니메이션 (500ms ease-out)
|
||||||
|
useEffect(() => {
|
||||||
|
const targetPrice = activeStep === -1 ? 1200 : NEGOTIATION_STEPS[activeStep].price
|
||||||
|
if (animatedPrice === targetPrice) return
|
||||||
|
|
||||||
|
const start = animatedPrice
|
||||||
|
const duration = 500
|
||||||
|
const startTime = performance.now()
|
||||||
|
|
||||||
|
const animate = (currentTime: number) => {
|
||||||
|
const progress = Math.min((currentTime - startTime) / duration, 1)
|
||||||
|
const easeProgress = progress * (2 - progress)
|
||||||
|
setAnimatedPrice(Math.round(start + (targetPrice - start) * easeProgress))
|
||||||
|
if (progress < 1) requestAnimationFrame(animate)
|
||||||
|
}
|
||||||
|
|
||||||
|
requestAnimationFrame(animate)
|
||||||
|
}, [activeStep])
|
||||||
|
|
||||||
|
// 새 말풍선이 붙으면 채팅 영역을 바닥으로 스크롤
|
||||||
|
useEffect(() => {
|
||||||
|
chatContainerRef.current?.scrollTo({ top: chatContainerRef.current.scrollHeight, behavior: "smooth" })
|
||||||
|
}, [activeStep])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
id="how-it-works"
|
||||||
|
ref={sectionRef}
|
||||||
|
className="relative bg-surface text-ink w-full font-sans border-t border-line h-[240vh] md:h-[280vh]"
|
||||||
|
>
|
||||||
|
<div className="sticky top-0 h-screen flex items-center justify-center w-full overflow-hidden px-6 md:px-8">
|
||||||
|
<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>
|
||||||
|
<span className="text-[11px] font-bold text-ink-muted uppercase tracking-wider block mb-1">실시간 협상 단가</span>
|
||||||
|
<div className="flex items-baseline gap-1.5">
|
||||||
|
<span className="text-3xl font-bold text-primary">₩</span>
|
||||||
|
<span className="text-5xl md:text-6xl font-black tracking-tight text-primary">
|
||||||
|
{animatedPrice.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-10">
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
{activeStep !== -1 && NEGOTIATION_STEPS[activeStep]?.badge && (
|
||||||
|
<motion.div
|
||||||
|
key={NEGOTIATION_STEPS[activeStep].badge}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<ShieldCheck className="w-4 h-4 shrink-0" />
|
||||||
|
{NEGOTIATION_STEPS[activeStep].badge}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 우: 대화 말풍선 (파트너 좌 / 봇 우) */}
|
||||||
|
<div className="md:col-span-7 flex flex-col justify-end space-y-5 h-[340px] md:h-[420px] relative overflow-hidden">
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-b from-surface via-transparent to-transparent h-12 pointer-events-none z-10" />
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={chatContainerRef}
|
||||||
|
className="flex flex-col gap-5 overflow-y-auto pr-1 scrollbar-none w-full h-full justify-center"
|
||||||
|
>
|
||||||
|
<AnimatePresence mode="wait" initial={false}>
|
||||||
|
{activeStep === -1 ? (
|
||||||
|
<motion.div
|
||||||
|
key="onboarding-guide"
|
||||||
|
initial={{ opacity: 0, y: 15, scale: 0.97 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, y: -15, scale: 0.97 }}
|
||||||
|
transition={{ duration: 0.4 }}
|
||||||
|
className="flex flex-col items-center justify-center text-center p-8 border border-dashed border-line-strong rounded-[28px] bg-white shadow-[0_4px_20px_rgba(0,0,0,0.01)] max-w-md mx-auto"
|
||||||
|
>
|
||||||
|
<div className="w-12 h-12 rounded-full bg-primary-soft flex items-center justify-center text-primary mb-4">
|
||||||
|
<Bot className="w-6 h-6 animate-pulse" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-base font-bold text-ink mb-1.5">실시간 AI 협상 대화</h3>
|
||||||
|
<p className="text-xs text-ink-soft font-semibold leading-relaxed break-keep">
|
||||||
|
화면을 스크롤하면 실시간 단가 조율 대화가 시작됩니다.
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 flex items-center gap-1.5 text-[11px] font-bold text-primary animate-bounce">
|
||||||
|
<span>마우스 휠 스크롤하기</span>
|
||||||
|
<span>↓</span>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-5 w-full mt-auto">
|
||||||
|
{NEGOTIATION_STEPS.slice(0, activeStep + 1).map((item, idx) => {
|
||||||
|
const isBot = item.editorNodes[0].sender === "bot"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={idx}
|
||||||
|
initial={{ opacity: 0, y: 15, scale: 0.97 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, y: -10, scale: 0.97 }}
|
||||||
|
transition={{ duration: 0.4, ease: EASE_OUT_EXPO }}
|
||||||
|
className={`flex flex-col ${isBot ? "items-end" : "items-start"} w-full`}
|
||||||
|
>
|
||||||
|
<div className={`flex items-center gap-1.5 mb-1 text-[11px] font-bold text-ink-muted ${isBot ? "flex-row-reverse" : ""}`}>
|
||||||
|
<span className={`flex items-center gap-1 ${isBot ? "text-primary" : "text-ink-soft"}`}>
|
||||||
|
{isBot ? (
|
||||||
|
<>
|
||||||
|
<Bot className="w-3.5 h-3.5" />
|
||||||
|
<span>NEGOTIUM BOT</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Handshake className="w-3.5 h-3.5" />
|
||||||
|
<span>PARTNER</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`max-w-[85%] rounded-[20px] p-4 text-sm leading-relaxed font-semibold shadow-[0_2px_8px_rgba(0,0,0,0.02)] transition-all duration-300 ${
|
||||||
|
isBot ? "bg-primary-soft text-ink rounded-tr-none" : "bg-white text-ink rounded-tl-none"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<SlateRenderer nodes={item.editorNodes} />
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 데모 대화 시나리오 — 1,200원 제시에서 1,050원 낙찰까지 6스텝
|
||||||
|
const NEGOTIATION_STEPS: DialogueStep[] = [
|
||||||
|
{
|
||||||
|
step: 1,
|
||||||
|
price: 1200,
|
||||||
|
editorNodes: [
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
sender: 'supplier',
|
||||||
|
children: [
|
||||||
|
{ text: '협력사', bold: true },
|
||||||
|
{ text: ' : ' },
|
||||||
|
{ text: '현재 글로벌 원재료 상승 요인으로 제안할 수 있는 최선의 단가는 1,200원입니다. 이 이하로는 마진 확보가 어렵습니다.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
step: 2,
|
||||||
|
price: 1150,
|
||||||
|
editorNodes: [
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
sender: 'bot',
|
||||||
|
children: [
|
||||||
|
{ text: 'AI 흥정 봇', bold: true },
|
||||||
|
{ text: ' : ' },
|
||||||
|
{ text: '제시해주신 1,200원은 당사 타 유사 품목 이력 및 시중 원가 인덱스 데이터 대비 약 8.3% 높게 책정되어 있습니다. ', italic: true },
|
||||||
|
{ text: '상호 호혜적 장기 계약 체결을 전제로 조율가 범위를 반영해 제안해 드립니다.', code: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
step: 3,
|
||||||
|
price: 1150,
|
||||||
|
editorNodes: [
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
sender: 'supplier',
|
||||||
|
children: [
|
||||||
|
{ text: '협력사', bold: true },
|
||||||
|
{ text: ' : ' },
|
||||||
|
{ text: '제조 공정상 급격한 인하는 무리가 있으나, 상생 협력 차원에서 1,150원까지는 즉시 조정해 드릴 용의가 있습니다.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
step: 4,
|
||||||
|
price: 1080,
|
||||||
|
editorNodes: [
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
sender: 'bot',
|
||||||
|
children: [
|
||||||
|
{ text: 'AI 흥정 봇', bold: true },
|
||||||
|
{ text: ' : ' },
|
||||||
|
{ text: '적극적인 협조에 감사드립니다. 만약 연간 최소 발주 수량을 보증하고 공급망 일정을 다소 유연화해주신다면, 목표가인 1,080원 선까지 맞출 수 있을까요?', italic: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
step: 5,
|
||||||
|
price: 1050,
|
||||||
|
editorNodes: [
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
sender: 'supplier',
|
||||||
|
children: [
|
||||||
|
{ text: '협력사', bold: true },
|
||||||
|
{ text: ' : ' },
|
||||||
|
{ text: '좋습니다. 제안하신 연간 개런티 확보 및 대금 현금 결제 기한 단축을 승인해 주시는 조건으로, ' },
|
||||||
|
{ text: '최종 조율가 1,050원으로 맞춰서 계약을 체결하겠습니다.', bold: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
step: 6,
|
||||||
|
price: 1050,
|
||||||
|
badge: '낙찰 성공 · 12.5% 예산 절감',
|
||||||
|
editorNodes: [
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
sender: 'bot',
|
||||||
|
children: [
|
||||||
|
{ text: 'AI 흥정 봇', bold: true },
|
||||||
|
{ text: ' : ' },
|
||||||
|
{ text: '최종 합의 접수 완료 — 가이드 상한가(1,200원) 대비 합의 낙찰가 1,050원으로 최종 계약 승인 처리 완료되었습니다. ', bold: true },
|
||||||
|
{ text: '본 흥정 마일스톤 및 단가 타결 히스토리는 사내 투명성 보증을 위해 보존 기록됩니다.', code: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
78
landing/app/components/sections/reinforcement.tsx
Normal file
78
landing/app/components/sections/reinforcement.tsx
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import { motion } from "motion/react"
|
||||||
|
import { GitMerge, RefreshCw, Scale, type LucideIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { Section } from "@/components/ui/section"
|
||||||
|
import { SectionHeading } from "@/components/ui/section-heading"
|
||||||
|
import { Typography } from "@/components/ui/typography"
|
||||||
|
import { useFadeUp } from "@/lib/motion"
|
||||||
|
|
||||||
|
/** 강화학습 섹션 — 협상할수록 좋아진다는 3개 필러 카드. */
|
||||||
|
export function Reinforcement() {
|
||||||
|
const fadeUp = useFadeUp()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Section id="reinforcement" bg="surface" bordered className="border-b border-line">
|
||||||
|
<motion.div {...fadeUp} className="mb-20">
|
||||||
|
<SectionHeading
|
||||||
|
eyebrow="REINFORCEMENT LEARNING"
|
||||||
|
title="협상할수록, 더 좋은 조건으로"
|
||||||
|
description={
|
||||||
|
<>
|
||||||
|
negotium은 진행된 모든 거래 데이터와 파트너 거절 피드백을{" "}
|
||||||
|
<span className="font-bold text-primary">강화학습 기술</span>에 투입합니다. 무턱대고 가격을 후려쳐 상대방의
|
||||||
|
반발만 사고 결렬로 치닫는 경직된 협상이 아니라, 파트너사가 충분히 받아들일 수 있는 범위 내에서 최선의 마진율을
|
||||||
|
뽑아내도록 조율 페이스를 학습합니다.
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
descriptionClassName="mt-8 max-w-3xl"
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||||
|
{PILLARS.map((pillar, idx) => (
|
||||||
|
<PillarCard key={pillar.title} pillar={pillar} delay={0.1 * (idx + 1)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Pillar = { icon: LucideIcon; title: string; description: string }
|
||||||
|
|
||||||
|
function PillarCard({ pillar, delay }: { pillar: Pillar; delay: number }) {
|
||||||
|
const fadeUp = useFadeUp(delay)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div {...fadeUp} className="bg-white p-8 rounded-[28px] flex flex-col justify-between h-[240px]">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-primary/5 text-primary flex items-center justify-center">
|
||||||
|
<pillar.icon className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Typography variant="cardTitle" className="mb-2">
|
||||||
|
{pillar.title}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" className="text-ink-soft">
|
||||||
|
{pillar.description}
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const PILLARS: Pillar[] = [
|
||||||
|
{
|
||||||
|
icon: Scale,
|
||||||
|
title: "흥정의 일관성 보존",
|
||||||
|
description: "개인 감정이나 친분에 휘둘리지 않고 사내 재무 원칙과 가이드라인을 완벽히 관철시킵니다.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: RefreshCw,
|
||||||
|
title: "지속적인 전략 최적화",
|
||||||
|
description: "매 조율 피드백을 축적하여 상대방의 마진 수용성 확률 지도를 고도화해 나갑니다.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: GitMerge,
|
||||||
|
title: "낙찰 성사율 극대화",
|
||||||
|
description: "단순한 협상 결렬(No-deal) 리스크를 상시 추산해 안전하면서도 최고 이익률의 균형점을 확보합니다.",
|
||||||
|
},
|
||||||
|
]
|
||||||
158
landing/app/components/sections/roi-simulator.tsx
Normal file
158
landing/app/components/sections/roi-simulator.tsx
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
import { useState } from "react"
|
||||||
|
import { ArrowRight, Clock, ShieldCheck } from "lucide-react"
|
||||||
|
|
||||||
|
import { RangeSlider } from "@/components/ui/range-slider"
|
||||||
|
import { Section } from "@/components/ui/section"
|
||||||
|
import { SectionHeading } from "@/components/ui/section-heading"
|
||||||
|
import { Typography } from "@/components/ui/typography"
|
||||||
|
|
||||||
|
/** 도입 ROI 시뮬레이터 — 예산·협력사 수 슬라이더로 예상 절감액을 즉시 계산. */
|
||||||
|
export function ROISimulator() {
|
||||||
|
// 연간 총 구매 예산(억 원)
|
||||||
|
const [budget, setBudget] = useState(50)
|
||||||
|
// 관리 중인 협력사 수
|
||||||
|
const [suppliers, setSuppliers] = useState(50)
|
||||||
|
|
||||||
|
// 보수적 절감율 2.8%~4.5% — 협력사가 많을수록 병렬 대안이 늘어 상향
|
||||||
|
const savingRate = Math.min(0.045, 0.028 + (suppliers / 300) * 0.015)
|
||||||
|
const estimatedSavingsValue = budget * savingRate
|
||||||
|
|
||||||
|
// 협력사당 왕복 흥정 수작업 절약분을 ~6.5시간으로 잡은 추산
|
||||||
|
const savedHours = Math.round(suppliers * 6.5)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Section id="benchmarks" width="lg" bordered className="border-b border-line">
|
||||||
|
<SectionHeading
|
||||||
|
align="center"
|
||||||
|
className="mb-24"
|
||||||
|
eyebrow="ROI SIMULATION"
|
||||||
|
title={
|
||||||
|
<>
|
||||||
|
negotium 도입 시 <br />
|
||||||
|
예상 절감액 확인하기
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
description={
|
||||||
|
<>
|
||||||
|
아직 실제 도입 실증 사례가 없는 신규 카테고리여도 신뢰할 수 있습니다. <br />
|
||||||
|
연간 구매 예산과 공급사 수만 입력해 보수적인 시뮬레이션 성과를 즉시 시각화해 보세요.
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
descriptionClassName="mt-4 text-[17px] max-w-2xl"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-16 items-start">
|
||||||
|
{/* 좌: 입력 슬라이더 */}
|
||||||
|
<div className="lg:col-span-6 space-y-12">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<label className="text-[16px] font-bold text-ink">연간 총 구매 예산</label>
|
||||||
|
<span className="text-lg font-black text-primary font-mono">{formatCurrency(budget)}</span>
|
||||||
|
</div>
|
||||||
|
<Typography variant="caption">원자재, 부자재, 가공비, 운송비 등 협상에 연동할 총 연간 구매 규모입니다.</Typography>
|
||||||
|
<div className="pt-2">
|
||||||
|
<RangeSlider min={5} max={1000} step={5} value={budget} onChange={setBudget} />
|
||||||
|
<div className="flex justify-between text-[11px] text-ink-faint font-bold pt-2">
|
||||||
|
<span>5억 원</span>
|
||||||
|
<span>500억 원</span>
|
||||||
|
<span>1조 원</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<label className="text-[16px] font-bold text-ink">관리 중인 총 협력사 수</label>
|
||||||
|
<span className="text-lg font-black text-primary font-mono">{suppliers}개사</span>
|
||||||
|
</div>
|
||||||
|
<Typography variant="caption">AI 봇 "밀당"이 동시에 일대일로 자동 조율할 잠재 공급사 수량입니다.</Typography>
|
||||||
|
<div className="pt-2">
|
||||||
|
<RangeSlider min={5} max={300} step={5} value={suppliers} onChange={setSuppliers} />
|
||||||
|
<div className="flex justify-between text-[11px] text-ink-faint font-bold pt-2">
|
||||||
|
<span>5개사</span>
|
||||||
|
<span>150개사</span>
|
||||||
|
<span>300개사</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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" />
|
||||||
|
<Typography variant="caption" className="text-ink-soft">
|
||||||
|
본 시뮬레이션은 글로벌 최저가 스캔(LPS) 시장 표준 및 다수 B2B 자율 협상 데이터의 보수적인 평균 수치(3.1% ~ 4.2%
|
||||||
|
절감)를 적용하여 산정되었습니다. 실사 수준의 검증은 파일 연동을 통해 무상 지원됩니다.
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 우: 결과 지표 */}
|
||||||
|
<div className="lg:col-span-6 lg:pl-10 space-y-12">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Typography variant="micro" className="text-primary block">
|
||||||
|
ESTIMATED ANNUAL SAVINGS
|
||||||
|
</Typography>
|
||||||
|
<h3 className="text-sm font-semibold text-ink-muted">매년 절감 가능한 구매 원가 (보수적 추산)</h3>
|
||||||
|
<div className="text-3xl sm:text-[44px] font-black text-primary font-mono tracking-tight leading-tight pt-1">
|
||||||
|
{formatSavings(estimatedSavingsValue)}
|
||||||
|
</div>
|
||||||
|
<Typography variant="caption" className="text-ink-soft font-medium pt-1">
|
||||||
|
사각지대에 방치되던 꼬리 거래(Tail Spend)의 상시 병렬 협상 및 인터넷 실시간 앵커가 시세 연동을 통해, 기존 수동
|
||||||
|
흥정으로 놓치고 있던 원가 이탈을 완벽히 방어해냅니다.
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-line pt-8 space-y-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Clock className="w-5 h-5 text-ink-muted" />
|
||||||
|
<span className="text-xs font-bold text-ink-soft">
|
||||||
|
연간 실무 단축 시간: <b className="text-ink font-mono text-sm ml-1">{savedHours.toLocaleString()}시간</b>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Typography variant="caption">
|
||||||
|
메일 전송, 유선 흥정, 엑셀 취합 등에 낭비되던 소모적인 절차가 실시간 링크 전송과 AI 봇의 24시간 자율 대응으로
|
||||||
|
완전히 생략되어 고부가가치 전략 구매 기획에 전념할 수 있습니다.
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-line pt-8 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-xs font-bold text-ink">예상 도입 ROI 회수 기간</h4>
|
||||||
|
<p className="text-[11px] text-ink-muted font-semibold mt-1">도입 즉시 마진 확보 개시</p>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href="#contact-section"
|
||||||
|
className="inline-flex items-center gap-2 text-xs font-bold text-primary hover:gap-3 transition-all"
|
||||||
|
>
|
||||||
|
<span>무상 맞춤 검증 신청하기</span>
|
||||||
|
<ArrowRight className="w-4 h-4" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 억 원 단위 → "50억 원" / "1조 200억 원" 표기 */
|
||||||
|
function formatCurrency(val: number) {
|
||||||
|
if (val >= 100) {
|
||||||
|
const b = Math.floor(val / 100)
|
||||||
|
const m = val % 100
|
||||||
|
return m > 0 ? `${b}조 ${m}00억 원` : `${b}조 원`
|
||||||
|
}
|
||||||
|
return `${val}억 원`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 억 원 단위 → "1억 5,500만 원" 표기 */
|
||||||
|
function formatSavings(val: number) {
|
||||||
|
const rawWon = val * 100000000
|
||||||
|
if (rawWon >= 100000000) {
|
||||||
|
const eonPart = Math.floor(rawWon / 100000000)
|
||||||
|
const manPart = Math.floor((rawWon % 100000000) / 10000)
|
||||||
|
if (manPart > 0) {
|
||||||
|
return `${eonPart}억 ${manPart.toLocaleString()}만 원`
|
||||||
|
}
|
||||||
|
return `${eonPart}억 원`
|
||||||
|
}
|
||||||
|
return `${Math.floor(rawWon / 10000).toLocaleString()}만 원`
|
||||||
|
}
|
||||||
40
landing/app/components/ui/button.tsx
Normal file
40
landing/app/components/ui/button.tsx
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
// CTA 버튼 토큰. 랜딩의 CTA 는 전부 앵커 스크롤이라 href 를 주면 <a> 로 렌더한다
|
||||||
|
// (SSG 특성상 하이드레이션 전에도 동작).
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 transition-all cursor-pointer disabled:opacity-50",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
primary: "bg-primary hover:bg-primary-deep text-white",
|
||||||
|
secondary: "bg-fill hover:bg-fill-hover text-ink-soft hover:text-ink",
|
||||||
|
// 밝은 배경 위 반투명 보조 버튼 (글라스 히어로)
|
||||||
|
glass: "bg-white/70 hover:bg-white text-ink-soft hover:text-ink border border-white shadow-sm",
|
||||||
|
},
|
||||||
|
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]",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: { variant: "primary", size: "lg" },
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
type ButtonProps = VariantProps<typeof buttonVariants> &
|
||||||
|
React.ButtonHTMLAttributes<HTMLButtonElement> &
|
||||||
|
React.AnchorHTMLAttributes<HTMLAnchorElement> & {
|
||||||
|
href?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function Button({ className, variant, size, href, ...props }: ButtonProps) {
|
||||||
|
const Comp: React.ElementType = href ? "a" : "button"
|
||||||
|
return <Comp href={href} className={cn(buttonVariants({ variant, size }), className)} {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
35
landing/app/components/ui/device-mockups.tsx
Normal file
35
landing/app/components/ui/device-mockups.tsx
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import type * as React from "react"
|
||||||
|
|
||||||
|
/** 데스크톱 브라우저 목업 프레임 — 신호등 버튼 + 주소창 + 16:10 워크스페이스. */
|
||||||
|
function BrowserFrame({ url, children }: { url: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-2xl border border-line-strong shadow-[0_12px_40px_rgba(0,0,0,0.06)] overflow-hidden w-full max-w-2xl">
|
||||||
|
<div className="bg-fill px-4 py-3.5 flex items-center justify-between border-b border-line-strong">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-3 h-3 rounded-full bg-[#FF5F56] inline-block" />
|
||||||
|
<span className="w-3 h-3 rounded-full bg-[#FFBD2E] inline-block" />
|
||||||
|
<span className="w-3 h-3 rounded-full bg-[#27C93F] inline-block" />
|
||||||
|
<span className="text-[11px] text-ink-muted font-mono ml-3 font-semibold bg-white px-3 py-1 rounded-md border border-line-strong truncate max-w-[180px] sm:max-w-none">
|
||||||
|
{url}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="relative bg-surface aspect-[16/10] flex flex-col justify-center items-center overflow-hidden">{children}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 모바일 폰 목업 프레임 — 노치 포함 9:18 스크린. */
|
||||||
|
function PhoneFrame({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="relative bg-black rounded-[48px] p-3 shadow-[0_20px_50px_rgba(0,0,0,0.15)] border-4 border-line-strong w-full max-w-[290px] aspect-[9/18] overflow-hidden flex flex-col">
|
||||||
|
<div className="absolute top-4 left-1/2 -translate-x-1/2 bg-black h-5 w-28 rounded-full z-20 flex items-center justify-around px-4">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-[#1F2022]" />
|
||||||
|
<span className="w-10 h-1 bg-[#1F2022] rounded-full" />
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-[36px] flex-1 overflow-hidden relative flex flex-col justify-center items-center">{children}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { BrowserFrame, PhoneFrame }
|
||||||
17
landing/app/components/ui/input.tsx
Normal file
17
landing/app/components/ui/input.tsx
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
// 문의 폼 필드 공통 스타일 — 테두리 없는 회색 채움, 포커스 시 흰 배경 + 파란 링.
|
||||||
|
const fieldClass =
|
||||||
|
"w-full px-5 py-3.5 rounded-2xl bg-surface hover:bg-fill focus:bg-white border-0 focus:ring-2 focus:ring-primary/20 text-sm font-semibold text-ink transition-all placeholder:text-ink-faint outline-none"
|
||||||
|
|
||||||
|
function Input({ className, ...props }: React.InputHTMLAttributes<HTMLInputElement>) {
|
||||||
|
return <input className={cn(fieldClass, className)} {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function Textarea({ className, ...props }: React.TextareaHTMLAttributes<HTMLTextAreaElement>) {
|
||||||
|
return <textarea className={cn(fieldClass, "resize-none", className)} {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Input, Textarea }
|
||||||
8
landing/app/components/ui/logo.tsx
Normal file
8
landing/app/components/ui/logo.tsx
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
/** NEGOTIUM 워드마크 (public/logo.png). 헤더·푸터 공용 — 크기는 className 의 h-* 로 조정. */
|
||||||
|
function Logo({ className }: { className?: string }) {
|
||||||
|
return <img src="/logo.png" alt="NEGOTIUM" className={cn("h-4 w-auto", className)} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Logo }
|
||||||
28
landing/app/components/ui/range-slider.tsx
Normal file
28
landing/app/components/ui/range-slider.tsx
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
type RangeSliderProps = {
|
||||||
|
min: number
|
||||||
|
max: number
|
||||||
|
step: number
|
||||||
|
value: number
|
||||||
|
onChange: (value: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 채워진 트랙이 값을 따라가는 파란 슬라이더 (ROI 시뮬레이터·히어로 콘솔 공용). */
|
||||||
|
function RangeSlider({ min, max, step, value, onChange }: RangeSliderProps) {
|
||||||
|
const filled = ((value - min) / (max - min)) * 100
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
step={step}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(Number(e.target.value))}
|
||||||
|
className="w-full h-1.5 rounded-lg appearance-none cursor-pointer accent-primary transition-all"
|
||||||
|
style={{
|
||||||
|
background: `linear-gradient(to right, var(--color-primary) 0%, var(--color-primary) ${filled}%, var(--color-line-strong) ${filled}%, var(--color-line-strong) 100%)`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { RangeSlider }
|
||||||
32
landing/app/components/ui/section-heading.tsx
Normal file
32
landing/app/components/ui/section-heading.tsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { Typography } from "@/components/ui/typography"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
type SectionHeadingProps = {
|
||||||
|
eyebrow: string
|
||||||
|
title: React.ReactNode
|
||||||
|
description?: React.ReactNode
|
||||||
|
align?: "left" | "center"
|
||||||
|
className?: string
|
||||||
|
/** 리드 문단 폭 제한 등 (예: "max-w-2xl") */
|
||||||
|
descriptionClassName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionHeading({ eyebrow, title, description, align = "left", className, descriptionClassName }: SectionHeadingProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn(align === "center" && "text-center", className)}>
|
||||||
|
<Typography variant="eyebrow" className="mb-3 block">
|
||||||
|
{eyebrow}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="title">{title}</Typography>
|
||||||
|
{description && (
|
||||||
|
<Typography variant="lead" className={cn("mt-5", align === "center" && "mx-auto", descriptionClassName)}>
|
||||||
|
{description}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { SectionHeading }
|
||||||
49
landing/app/components/ui/section.tsx
Normal file
49
landing/app/components/ui/section.tsx
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const sectionBg = {
|
||||||
|
white: "bg-white",
|
||||||
|
surface: "bg-surface",
|
||||||
|
dark: "bg-ink text-white",
|
||||||
|
}
|
||||||
|
|
||||||
|
const sectionWidth = {
|
||||||
|
sm: "max-w-3xl",
|
||||||
|
md: "max-w-4xl",
|
||||||
|
lg: "max-w-5xl",
|
||||||
|
}
|
||||||
|
|
||||||
|
type SectionProps = React.HTMLAttributes<HTMLElement> & {
|
||||||
|
bg?: keyof typeof sectionBg
|
||||||
|
/** 내부 컨테이너 최대폭 */
|
||||||
|
width?: keyof typeof sectionWidth
|
||||||
|
/** 상단 구분선. 하단까지 필요하면 className 에 border-b border-line 추가 */
|
||||||
|
bordered?: boolean
|
||||||
|
/** 컨테이너 밖(섹션 전체 기준)에 까는 장식 요소 — 배경 블롭 등 */
|
||||||
|
decor?: React.ReactNode
|
||||||
|
containerClassName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({
|
||||||
|
bg = "white",
|
||||||
|
width = "md",
|
||||||
|
bordered = false,
|
||||||
|
decor,
|
||||||
|
className,
|
||||||
|
containerClassName,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: SectionProps) {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className={cn("py-36 md:py-44", sectionBg[bg], bordered && "border-t border-line", decor && "relative overflow-hidden", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{decor}
|
||||||
|
<div className={cn("mx-auto px-6", sectionWidth[width], decor && "relative z-10", containerClassName)}>{children}</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Section }
|
||||||
65
landing/app/components/ui/slate-renderer.tsx
Normal file
65
landing/app/components/ui/slate-renderer.tsx
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import type * as React from "react"
|
||||||
|
|
||||||
|
import type { SlateElement, SlateText } from "@/types"
|
||||||
|
|
||||||
|
function SlateRenderer({ nodes }: { nodes: SlateElement[] }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{nodes.map((node, index) => (
|
||||||
|
<SlateNode key={index} node={node} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SlateNode({ node }: { node: SlateElement }) {
|
||||||
|
const alignClass = node.align === "center" ? "text-center" : node.align === "right" ? "text-right" : "text-left"
|
||||||
|
|
||||||
|
switch (node.type) {
|
||||||
|
case "heading":
|
||||||
|
return (
|
||||||
|
<h4 className={`text-xs md:text-sm font-mono font-medium tracking-tight ${alignClass} mb-1.5`}>
|
||||||
|
{node.children.map((child, idx) => (
|
||||||
|
<SlateLeaf key={idx} leaf={child} />
|
||||||
|
))}
|
||||||
|
</h4>
|
||||||
|
)
|
||||||
|
case "bullet-list":
|
||||||
|
return (
|
||||||
|
<ul className="list-disc pl-5 mb-1.5 font-sans text-xs md:text-sm">
|
||||||
|
{node.children.map((child, idx) => (
|
||||||
|
<li key={idx} className={alignClass}>
|
||||||
|
{child.text}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
case "paragraph":
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<div className={`text-xs md:text-sm leading-relaxed ${alignClass} font-sans`}>
|
||||||
|
{node.children.map((child, idx) => (
|
||||||
|
<SlateLeaf key={idx} leaf={child} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function SlateLeaf({ leaf }: { leaf: SlateText }) {
|
||||||
|
let children: React.ReactNode = leaf.text
|
||||||
|
|
||||||
|
if (leaf.bold) {
|
||||||
|
children = <strong className="font-bold">{children}</strong>
|
||||||
|
}
|
||||||
|
if (leaf.italic) {
|
||||||
|
children = <em className="italic">{children}</em>
|
||||||
|
}
|
||||||
|
if (leaf.code) {
|
||||||
|
children = <code className="bg-primary/10 px-1.5 py-0.5 rounded-lg font-mono text-xs text-primary font-bold">{children}</code>
|
||||||
|
}
|
||||||
|
|
||||||
|
return <span>{children}</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
export { SlateRenderer }
|
||||||
58
landing/app/components/ui/typography.tsx
Normal file
58
landing/app/components/ui/typography.tsx
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
// 랜딩 텍스트 스케일 단일 소스. 페이지마다 text-[44px]/font-black/…을 직접 박지 말고
|
||||||
|
// variant 로 통일한다. 색·크기 미세조정은 className 으로 합성.
|
||||||
|
const typographyVariants = cva("", {
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
// 히어로 h1
|
||||||
|
display: "text-3xl sm:text-4xl md:text-[50px] font-black tracking-tight leading-[1.2] text-ink break-keep",
|
||||||
|
// 섹션 제목 h2
|
||||||
|
title: "text-3xl md:text-[44px] font-black tracking-tight leading-tight text-ink break-keep",
|
||||||
|
// 섹션 내 서브 제목 (데모 패널 등)
|
||||||
|
heading: "text-2xl md:text-3xl font-bold tracking-tight text-ink break-keep",
|
||||||
|
// 카드 제목
|
||||||
|
cardTitle: "text-lg font-bold text-ink break-keep",
|
||||||
|
// 섹션 상단 오버라인 라벨
|
||||||
|
eyebrow: "text-sm font-semibold text-primary tracking-wider uppercase",
|
||||||
|
// 섹션 리드 문단
|
||||||
|
lead: "text-base sm:text-lg text-ink-soft font-medium leading-relaxed break-keep",
|
||||||
|
body: "text-[15px] text-ink-soft font-medium leading-relaxed break-keep",
|
||||||
|
small: "text-sm text-ink-soft font-medium leading-relaxed break-keep",
|
||||||
|
caption: "text-xs text-ink-muted font-semibold leading-relaxed break-keep",
|
||||||
|
// 11px 트래킹 대문자 메타 라벨 (단가 패널·푸터 컬럼 제목 등)
|
||||||
|
micro: "text-[11px] font-bold text-ink-muted uppercase tracking-wider",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: { variant: "body" },
|
||||||
|
})
|
||||||
|
|
||||||
|
const defaultTag: Record<NonNullable<VariantProps<typeof typographyVariants>["variant"]>, React.ElementType> = {
|
||||||
|
display: "h1",
|
||||||
|
title: "h2",
|
||||||
|
heading: "h3",
|
||||||
|
cardTitle: "h3",
|
||||||
|
eyebrow: "span",
|
||||||
|
lead: "p",
|
||||||
|
body: "p",
|
||||||
|
small: "p",
|
||||||
|
caption: "p",
|
||||||
|
micro: "span",
|
||||||
|
}
|
||||||
|
|
||||||
|
type TypographyProps = React.HTMLAttributes<HTMLElement> &
|
||||||
|
VariantProps<typeof typographyVariants> & {
|
||||||
|
/** 의미상 태그를 강제로 바꾸고 싶을 때 (예: cardTitle 을 h4 로) */
|
||||||
|
as?: React.ElementType
|
||||||
|
htmlFor?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function Typography({ className, variant, as, ...props }: TypographyProps) {
|
||||||
|
const Comp = as ?? defaultTag[variant ?? "body"]
|
||||||
|
return <Comp className={cn(typographyVariants({ variant }), className)} {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Typography, typographyVariants }
|
||||||
15
landing/app/lib/motion.ts
Normal file
15
landing/app/lib/motion.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { useReducedMotion } from "motion/react"
|
||||||
|
|
||||||
|
// Toss 스타일 ease-out-expo 커브. 섹션 등장 애니메이션 공통.
|
||||||
|
export const EASE_OUT_EXPO: [number, number, number, number] = [0.16, 1, 0.3, 1]
|
||||||
|
|
||||||
|
/** 스크롤 진입 시 fade-up 프리셋. motion.* 컴포넌트에 스프레드해서 쓴다. */
|
||||||
|
export function useFadeUp(delay = 0) {
|
||||||
|
const shouldReduceMotion = useReducedMotion()
|
||||||
|
return {
|
||||||
|
initial: shouldReduceMotion ? { opacity: 1, y: 0 } : { opacity: 0, y: 48 },
|
||||||
|
whileInView: { opacity: 1, y: 0 },
|
||||||
|
viewport: { once: true, margin: "-120px" as const },
|
||||||
|
transition: { duration: 0.8, delay, ease: EASE_OUT_EXPO },
|
||||||
|
}
|
||||||
|
}
|
||||||
6
landing/app/lib/utils.ts
Normal file
6
landing/app/lib/utils.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
33
landing/app/root.tsx
Normal file
33
landing/app/root.tsx
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { Links, Meta, Outlet, Scripts, ScrollRestoration } from "react-router";
|
||||||
|
|
||||||
|
import type { Route } from "./+types/root";
|
||||||
|
import "./app.css";
|
||||||
|
|
||||||
|
export const links: Route.LinksFunction = () => [
|
||||||
|
{ rel: "icon", href: "/favicon.svg", type: "image/svg+xml" },
|
||||||
|
// 본문 서체 프리로드 — 프리렌더된 첫 화면의 FOUT 최소화
|
||||||
|
{ rel: "preload", href: "/fonts/PretendardVariable.woff2", as: "font", type: "font/woff2", crossOrigin: "anonymous" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Layout({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charSet="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<Meta />
|
||||||
|
<Links />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{children}
|
||||||
|
<ScrollRestoration />
|
||||||
|
<Scripts />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Root() {
|
||||||
|
return <Outlet />;
|
||||||
|
}
|
||||||
3
landing/app/routes.ts
Normal file
3
landing/app/routes.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import { type RouteConfig, index } from "@react-router/dev/routes";
|
||||||
|
|
||||||
|
export default [index("routes/home.tsx")] satisfies RouteConfig;
|
||||||
82
landing/app/routes/home.tsx
Normal file
82
landing/app/routes/home.tsx
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { Contact } from "@/components/sections/contact";
|
||||||
|
import { CoreValues } from "@/components/sections/core-values";
|
||||||
|
import { FinalCTA } from "@/components/sections/final-cta";
|
||||||
|
import { Footer } from "@/components/sections/footer";
|
||||||
|
import { Header } from "@/components/sections/header";
|
||||||
|
import { HeroGlassmorphic } from "@/components/sections/hero-glassmorphic";
|
||||||
|
import { HeroNeumorphic } from "@/components/sections/hero-neumorphic";
|
||||||
|
import { HowItWorksDemo } from "@/components/sections/how-it-works-demo";
|
||||||
|
import { NegotiationConsole } from "@/components/sections/negotiation-console";
|
||||||
|
import { Reinforcement } from "@/components/sections/reinforcement";
|
||||||
|
import { ROISimulator } from "@/components/sections/roi-simulator";
|
||||||
|
|
||||||
|
const TITLE = "negotium — AI 구매 협상 자동화";
|
||||||
|
const DESCRIPTION =
|
||||||
|
"가이드라인만 정하면 AI 흥정 봇이 여러 협력사와 단가를 대신 조율합니다. 흥정부터 낙찰까지 자동으로, 협상할수록 강화학습으로 더 좋은 조건을 만드는 B2B 구매 협상 자동화 솔루션.";
|
||||||
|
|
||||||
|
export function meta() {
|
||||||
|
return [
|
||||||
|
{ title: TITLE },
|
||||||
|
{ name: "description", content: DESCRIPTION },
|
||||||
|
{ property: "og:type", content: "website" },
|
||||||
|
{ property: "og:site_name", content: "negotium" },
|
||||||
|
{ property: "og:title", content: TITLE },
|
||||||
|
{ property: "og:description", content: DESCRIPTION },
|
||||||
|
{ property: "og:locale", content: "ko_KR" },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
type HeroTheme = "neumorphic" | "glassmorphic";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
// 히어로 디자인 비교용 — 스위처는 dev 에서만 노출, 배포본은 glassmorphic 고정
|
||||||
|
const [heroTheme, setHeroTheme] = useState<HeroTheme>("glassmorphic");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-white text-ink font-sans antialiased selection:bg-primary/10 selection:text-primary">
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
{import.meta.env.DEV && <HeroThemeSwitcher value={heroTheme} onChange={setHeroTheme} />}
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
{heroTheme === "neumorphic" && <HeroNeumorphic />}
|
||||||
|
{heroTheme === "glassmorphic" && <HeroGlassmorphic />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NegotiationConsole />
|
||||||
|
<HowItWorksDemo />
|
||||||
|
<Reinforcement />
|
||||||
|
<CoreValues />
|
||||||
|
<ROISimulator />
|
||||||
|
<FinalCTA />
|
||||||
|
<Contact />
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const HERO_OPTIONS: { value: HeroTheme; label: string }[] = [
|
||||||
|
{ value: "neumorphic", label: "Neumorphic" },
|
||||||
|
{ value: "glassmorphic", label: "Soft Ambient" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function HeroThemeSwitcher({ value, onChange }: { value: HeroTheme; onChange: (theme: HeroTheme) => void }) {
|
||||||
|
return (
|
||||||
|
<div className="fixed top-24 left-1/2 -translate-x-1/2 z-50 flex items-center gap-1.5 p-1 rounded-full shadow-[0_4px_20px_rgba(0,0,0,0.06)] border border-line-strong bg-white/95 text-ink-soft backdrop-blur-md text-xs font-semibold">
|
||||||
|
<span className="pl-3.5 pr-1.5 text-[10px] uppercase tracking-wider text-ink-muted font-bold">Hero Design:</span>
|
||||||
|
{HERO_OPTIONS.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
onClick={() => onChange(option.value)}
|
||||||
|
className={`px-3.5 py-1.5 rounded-full transition-all cursor-pointer ${
|
||||||
|
value === option.value ? "bg-primary text-white shadow-sm font-bold" : "hover:text-ink"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
landing/app/types.ts
Normal file
20
landing/app/types.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
export interface SlateText {
|
||||||
|
text: string;
|
||||||
|
bold?: boolean;
|
||||||
|
italic?: boolean;
|
||||||
|
code?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SlateElement {
|
||||||
|
type: 'paragraph' | 'bullet-list' | 'list-item' | 'heading';
|
||||||
|
align?: 'left' | 'right' | 'center';
|
||||||
|
sender?: 'supplier' | 'bot'; // 'supplier' -> 좌측 말풍선, 'bot' -> 우측 말풍선
|
||||||
|
children: SlateText[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DialogueStep {
|
||||||
|
step: number;
|
||||||
|
price: number; // 해당 시점 협상 단가(원)
|
||||||
|
badge?: string;
|
||||||
|
editorNodes: SlateElement[];
|
||||||
|
}
|
||||||
3113
landing/package-lock.json
generated
Normal file
3113
landing/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
landing/package.json
Normal file
34
landing/package.json
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "negodata-landing",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "react-router dev --port=3100 --host=0.0.0.0",
|
||||||
|
"build": "react-router build",
|
||||||
|
"preview": "vite preview --port=3100",
|
||||||
|
"lint": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@react-router/node": "^7.17.0",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.546.0",
|
||||||
|
"motion": "^12.23.24",
|
||||||
|
"react": "^19.0.1",
|
||||||
|
"react-dom": "^19.0.1",
|
||||||
|
"react-router": "^7.17.0",
|
||||||
|
"tailwind-merge": "^3.6.0",
|
||||||
|
"isbot": "^5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@react-router/dev": "^7.17.0",
|
||||||
|
"@tailwindcss/vite": "^4.1.14",
|
||||||
|
"@types/node": "^22.14.0",
|
||||||
|
"@types/react": "^19.0.1",
|
||||||
|
"@types/react-dom": "^19.0.1",
|
||||||
|
"tailwindcss": "^4.1.14",
|
||||||
|
"typescript": "~5.8.2",
|
||||||
|
"vite": "^6.2.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
landing/public/favicon.svg
Normal file
1
landing/public/favicon.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="8" fill="#3182F6"/><text x="16" y="23" font-family="-apple-system, 'Segoe UI', Arial, sans-serif" font-size="20" font-weight="900" fill="#FFFFFF" text-anchor="middle">n</text></svg>
|
||||||
|
After Width: | Height: | Size: 275 B |
BIN
landing/public/fonts/PretendardVariable.woff2
Normal file
BIN
landing/public/fonts/PretendardVariable.woff2
Normal file
Binary file not shown.
0
landing/public/gifs/.gitkeep
Normal file
0
landing/public/gifs/.gitkeep
Normal file
BIN
landing/public/gifs/negotiation.gif
Normal file
BIN
landing/public/gifs/negotiation.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
BIN
landing/public/gifs/negotiation_result.gif
Normal file
BIN
landing/public/gifs/negotiation_result.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
BIN
landing/public/gifs/quote_generation.gif
Normal file
BIN
landing/public/gifs/quote_generation.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.1 MiB |
BIN
landing/public/logo.png
Normal file
BIN
landing/public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 57 KiB |
2
landing/public/robots.txt
Normal file
2
landing/public/robots.txt
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
7
landing/react-router.config.ts
Normal file
7
landing/react-router.config.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import type { Config } from "@react-router/dev/config";
|
||||||
|
|
||||||
|
// SSG: 빌드 시 라우트를 정적 HTML로 프리렌더 (SEO·OG 미리보기용). 런타임 서버 없음.
|
||||||
|
export default {
|
||||||
|
ssr: false,
|
||||||
|
prerender: ["/"],
|
||||||
|
} satisfies Config;
|
||||||
21
landing/tsconfig.json
Normal file
21
landing/tsconfig.json
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"include": ["**/*.ts", "**/*.tsx", ".react-router/types/**/*"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
||||||
|
"types": ["node", "vite/client"],
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ES2022",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"rootDirs": [".", "./.react-router/types"],
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./app/*"]
|
||||||
|
},
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true
|
||||||
|
}
|
||||||
|
}
|
||||||
13
landing/vite.config.ts
Normal file
13
landing/vite.config.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { reactRouter } from "@react-router/dev/vite";
|
||||||
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
import path from "node:path";
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [tailwindcss(), reactRouter()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(import.meta.dirname, "app"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user