diff --git a/landing/.gitignore b/landing/.gitignore index 27d1340..730170c 100644 --- a/landing/.gitignore +++ b/landing/.gitignore @@ -1,3 +1,4 @@ node_modules build .react-router +.vercel diff --git a/landing/api/lead.ts b/landing/api/lead.ts new file mode 100644 index 0000000..5f39d94 --- /dev/null +++ b/landing/api/lead.ts @@ -0,0 +1,114 @@ +import type { VercelRequest, VercelResponse } from "@vercel/node" + +/* + * 리드 수집 엔드포인트 (Vercel 서버리스 함수). + * + * 랜딩은 ssr:false 정적 빌드라 서버 라우트가 없다. 그래서 /api 디렉터리의 함수로 받는다. + * 클라이언트는 같은 오리진으로 쏘므로 엔드포인트 환경변수도, CORS 설정도 필요 없다. + * + * ── 리드를 잃지 않는 것이 이 파일의 유일한 책임이다 ────────────────────────── + * 기존 문의 폼은 1.2초 뒤 성공 화면만 띄우고 아무 데도 보내지 않았다. 화면은 + * "접수되었습니다"인데 영업이 받을 리드는 없고, 아무도 그 사실을 몰랐다. + * + * 그래서 순서를 이렇게 잡는다. + * 1) 먼저 구조화된 로그를 남긴다. 웹훅이 죽어도 Vercel 로그에 리드가 남는다. + * 2) LEAD_WEBHOOK_URL 이 있으면 그리로 전달한다(Slack Incoming Webhook·Zapier·자체 API 모두 POST 를 받는다). + * 3) 웹훅 전달이 실패해도 200 을 준다 — 1) 에서 이미 리드를 확보했으므로 방문자에게 + * 다시 입력하라고 할 이유가 없다. 대신 실패는 로그에 크게 남긴다. + * + * 로그는 CRM 이 아니라 최후의 그물이다. LEAD_WEBHOOK_URL 은 반드시 설정해야 한다. + */ + +const MAX_FIELD = 500 + +type LeadBody = { + source?: string + name?: string + email?: string + company?: string + phone?: string + message?: string + /* 봇 함정. 사람 눈에 안 보이는 필드라 값이 차 있으면 자동 제출이다. */ + website?: string +} + +const clean = (v: unknown) => (typeof v === "string" ? v.trim().slice(0, MAX_FIELD) : "") + +/** 느슨한 검사. 정규식으로 이메일을 엄밀히 검증하려는 시도는 늘 진짜 주소를 막는다. */ +const emailLike = (v: string) => v.length >= 5 && v.includes("@") && !v.startsWith("@") && !v.endsWith("@") && !/\s/.test(v) + +export default async function handler(req: VercelRequest, res: VercelResponse) { + if (req.method !== "POST") { + res.setHeader("Allow", "POST") + return res.status(405).json({ ok: false, error: "method_not_allowed" }) + } + + const body = (typeof req.body === "string" ? safeParse(req.body) : req.body) as LeadBody | null + if (!body) return res.status(400).json({ ok: false, error: "invalid_json" }) + + // 봇은 조용히 돌려보낸다. 400 을 주면 어떤 필드가 함정인지 알려주는 셈이다. + if (clean(body.website)) return res.status(200).json({ ok: true }) + + const lead = { + source: clean(body.source) || "unknown", + name: clean(body.name), + email: clean(body.email), + company: clean(body.company), + phone: clean(body.phone), + message: clean(body.message), + submittedAt: new Date().toISOString(), + userAgent: clean(req.headers["user-agent"]), + } + + if (!lead.name || !emailLike(lead.email)) { + return res.status(400).json({ ok: false, error: "invalid_input" }) + } + + // 1) 무슨 일이 있어도 먼저 남긴다. + console.log("[lead]", JSON.stringify(lead)) + + // 2) 사람이 실제로 보는 곳으로 전달. + const webhook = process.env.LEAD_WEBHOOK_URL + if (!webhook) { + console.warn("[lead] LEAD_WEBHOOK_URL 미설정 — 리드가 로그에만 남습니다. 웹훅을 설정하세요.") + return res.status(200).json({ ok: true, delivered: false }) + } + + try { + const upstream = await fetch(webhook, { + method: "POST", + headers: { "Content-Type": "application/json" }, + // Slack Incoming Webhook 은 text 를 읽고, 나머지 수신처는 보통 원본 필드를 읽는다. + // 둘 다 담아 보내면 수신처를 바꿀 때 이 파일을 고칠 일이 없다. + body: JSON.stringify({ text: summarize(lead), ...lead }), + }) + if (!upstream.ok) { + console.error("[lead] 웹훅 전달 실패", upstream.status, JSON.stringify(lead)) + return res.status(200).json({ ok: true, delivered: false }) + } + return res.status(200).json({ ok: true, delivered: true }) + } catch (e) { + console.error("[lead] 웹훅 예외", e, JSON.stringify(lead)) + return res.status(200).json({ ok: true, delivered: false }) + } +} + +function safeParse(s: string) { + try { + return JSON.parse(s) + } catch { + return null + } +} + +function summarize(l: { source: string; name: string; email: string; company: string; phone: string; message: string }) { + const rows = [ + `*새 리드* (${l.source})`, + `이름: ${l.name}`, + `이메일: ${l.email}`, + l.company && `회사: ${l.company}`, + l.phone && `연락처: ${l.phone}`, + l.message && `내용: ${l.message}`, + ].filter(Boolean) + return rows.join("\n") +} diff --git a/landing/app/app.css b/landing/app/app.css index 55530ca..e810418 100644 --- a/landing/app/app.css +++ b/landing/app/app.css @@ -10,38 +10,112 @@ src: url('/fonts/PretendardVariable.woff2') format('woff2'); } +/* 영문 디스플레이 서체 — 이탤릭 가변(400~900), latin 서브셋. + INFINITH 디자인 시스템과 같은 Playfair Display 를 쓴다. */ +@font-face { + font-family: 'Playfair Display'; + font-style: italic; + font-weight: 400 900; + font-display: swap; + src: url('/fonts/PlayfairDisplay-Italic.woff2') format('woff2'); +} + /* - * 랜딩 디자인 토큰 (Toss 스타일 라이트 팔레트). + * 랜딩 디자인 토큰. * 색은 전부 여기서만 정의하고, 컴포넌트는 토큰 클래스(text-ink, bg-primary …)만 쓴다. + * + * 구조는 두 겹이다. + * - 다크 무대(stage) : 히어로·협상 데모. 협상이 벌어지는 순간에만 조명을 끈다. + * - 라이트 본문 : 나머지 섹션. 광고 유입 전환율을 위해 밝게 유지. */ @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; + /* 영문 디스플레이 — 라벨·강조 전용. 한글 본문은 절대 이걸로 쓰지 않는다 + (Playfair 는 한글 글리프가 없어 Pretendard 로 폴백되며 톤이 깨진다). */ + --font-display: 'Playfair Display', 'Pretendard Variable', Georgia, serif; - /* 브랜드 */ - --color-primary: #476EFF; - --color-primary-deep: #2F52E0; /* hover */ - --color-primary-soft: #ECF0FF; /* 연한 파랑 배경 — 봇 말풍선, 배지 */ + /* ── 브랜드 액센트 ────────────────────────────────────────────── + 여기 3줄이 브랜드 색상각의 단일 소스다. 딥틸(#005961 / #00434A / #E3F4F5)로 + 되돌리려면 이 3줄만 갈아끼우면 되고, 나머지 토큰·컴포넌트는 손대지 않는다. */ + --color-primary: #0101F3; /* 일렉트릭 블루 — 라이트 배경 전용. 레퍼런스 실측값 */ + /* 다크 무대용 액센트. #0101F3 을 #070E24 위에 얹으면 명암비가 2.0:1 로, + WCAG 최소(4.5:1)의 절반도 안 된다 — 둘 다 어두운 색이라 그렇다. + 아래 값은 무대 배경 대비 약 6:1 이다. 무대 위 파란 글자는 반드시 이걸 쓴다. */ + --color-primary-on-stage: #7C8CFF; + --color-primary-deep: #0101C4; /* hover */ + --color-primary-soft: #EAEAFF; /* 연한 배경 — 에이전트 말풍선, 배지 */ - /* 텍스트. ink 는 다크 섹션에선 배경색으로도 쓴다 */ - --color-ink: #191F28; - --color-ink-soft: #4E5968; /* 보조 본문 */ - --color-ink-muted: #8B95A1; /* 캡션 */ - --color-ink-faint: #B0B8C1; /* 플레이스홀더·각주 */ + /* ── 상대편(공급사) ──────────────────────────────────────────── + 협상은 두 주체가 매 턴 구분돼야 읽힌다. 민트는 블루와 색상각 70° 차이라 + 구분되면서도 같은 한색 계열이라 화면이 따로 놀지 않는다. + NEGO WIZ 로고 민트(#0FFFD6)에서 왔다. */ + --color-counter: #0FFFD6; /* 무대 위 상대측 텍스트·강조 */ + --color-counter-deep: #077A66; /* 라이트 배경 위 텍스트 (흰 바탕 대비 확보용) */ + --color-counter-surface: #0A2F35; /* 무대 위 상대측 말풍선 바닥 */ + + /* ── 다크 무대 ────────────────────────────────────────────────── */ + --color-stage: #070E24; /* 무대 바닥 (그라데이션의 중간값) */ + --color-stage-deep: #03060F; /* 무대 가장자리 — 어두워지며 공간이 뒤로 물러난다 */ + --color-stage-lift: #16205A; /* 무대 상단 광원 — 여기서 빛이 온다 */ + --color-stage-raised: #101A3D; /* 무대 위 카드·상대측 말풍선 */ + --color-stage-line: #1E2A52; /* 무대 위 테두리 */ + --color-on-stage: #FFFFFF; /* 무대 위 1차 텍스트 */ + /* 2·3차 텍스트는 중립 회색 대신 연보라 계열로 둔다. 네이비 위에 색상각이 다른 + 회색을 얹으면 탁해지는데, 같은 한색 가족이면 밝게 올려도 화면이 정돈된다. + #F5F3FF 는 ADO2 디자인 시스템의 연보라 패널 색이다. */ + --color-on-stage-soft: #F5F3FF; /* 무대 위 2차 텍스트 — 2톤 헤드라인의 약한 쪽 */ + --color-on-stage-muted: #C6C0E4; /* 무대 위 라벨·캡션 */ + + /* ── 라이트 본문 텍스트 ──────────────────────────────────────── + 먹색에 브랜드 색조를 섞어둔다. 순수 회색이면 페이지가 따로 논다. */ + --color-ink: #0B1024; + --color-ink-soft: #454E6B; /* 보조 본문 */ + --color-ink-muted: #838CAA; /* 캡션 */ + --color-ink-faint: #AAB1C7; /* 플레이스홀더·각주 */ /* 면·선 */ - --color-surface: #F9FAFB; /* 회색 섹션 배경 */ - --color-surface-neu: #F2F4F7; /* 뉴모피즘 히어로 전용 배경 */ - --color-fill: #F2F4F6; /* 회색 채움 — 탭 트랙, 보조 버튼 */ - --color-fill-hover: #EAECEF; - --color-line: #F2F4F6; /* 섹션 구분선 */ - --color-line-strong: #E5E8EB; /* 카드 테두리 */ + --color-surface: #F7F8FC; /* 회색 섹션 배경 */ + --color-surface-neu: #F1F3F9; /* 보조 섹션 배경 */ + --color-fill: #F0F2F8; /* 회색 채움 — 탭 트랙, 보조 버튼 */ + --color-fill-hover: #E6E9F2; + --color-line: #EEF0F6; /* 섹션 구분선 */ + --color-line-strong: #E0E4EE; /* 카드 테두리 */ --color-positive: #0B8F57; /* 낙찰·성공 강조 */ --color-positive-soft: #E8F7EF; /* 연한 초록 배경 — 낙찰 배지 */ --color-accent: #8F52FF; /* 보라 강조 — 결과 탭·히어로 장식 */ --color-accent-soft: #F4EFFF; /* 연한 보라 배경 */ + + /* ── 모서리 ──────────────────────────────────────────────────── + 레퍼런스 실측은 3.5~7px 이다. 20~32px 짜리 뭉툭한 카드가 제네릭 SaaS 의 + 가장 강한 신호라, 여기서는 세 값으로만 고정한다. + card — 카드·패널·목업 + control — 탭·배지·입력 등 작은 요소 + full — 버튼·아바타 (Tailwind 기본 rounded-full 사용) */ + --radius-card: 8px; + --radius-control: 4px; +} + +/* 다크 무대 배경 그라데이션. + 단색 배경은 아무리 입자에 원근을 줘도 평면으로 읽힌다. 위쪽에 광원을 두고 + 가장자리로 갈수록 어두워지게 하면 화면 자체에 안팎이 생겨 깊이가 성립한다. */ +.stage-bg { + background: + radial-gradient(120% 78% at 50% 8%, var(--color-stage-lift) 0%, transparent 62%), + radial-gradient(100% 100% at 50% 42%, var(--color-stage) 0%, var(--color-stage-deep) 100%); +} + +/* 드래그 선택 색. + 루트에 걸린 selection:text-primary 는 라이트 본문 기준이다. 그 값(#0101F3)을 다크 무대 + 위에 얹으면 명암비가 2.0:1 이라 — 위 --color-primary-on-stage 주석이 경고하는 바로 그 + 상황 — 무대 섹션에서 텍스트를 드래그하면 글자가 사라진다. + 무대 안에서는 무대용 액센트로 갈아끼운다. 라이트 본문은 기존 그대로. */ +.stage-bg ::selection, +[data-stage-hero] ::selection { + background-color: color-mix(in srgb, var(--color-primary-on-stage) 30%, transparent); + color: var(--color-on-stage); } html { diff --git a/landing/app/components/sections/comparison.tsx b/landing/app/components/sections/comparison.tsx index af6ea76..4e934f5 100644 --- a/landing/app/components/sections/comparison.tsx +++ b/landing/app/components/sections/comparison.tsx @@ -1,9 +1,9 @@ import { useEffect, useRef, useState } from "react" import { AnimatePresence, motion } from "motion/react" -import { Bot, Clock, Database, Handshake, Scale, TrendingUp, UserRound, type LucideIcon } from "lucide-react" +import { Clock, Database, Handshake, Scale, TrendingUp, UserRound, type LucideIcon } from "lucide-react" import { Section } from "@/components/ui/section" -import { SectionHeading } from "@/components/ui/section-heading" +import { CONTROL_GAP, SectionHeading } from "@/components/ui/section-heading" import { Typography } from "@/components/ui/typography" import { useFadeUp } from "@/lib/motion" @@ -44,28 +44,31 @@ export function Comparison() { return (
- + - {/* 모드 토글 — 이용 가이드 탭과 같은 세그먼트 문법 */} - -
+ {/* 모드 토글 — 이용 가이드 탭과 같은 세그먼트 문법. + 아래 간격(CONTROL_GAP)은 머리 간격보다 좁다. 토글은 자기가 조종하는 + 카드들과 한 덩어리로 읽혀야지, 제목에 붙으면 무엇을 바꾸는 스위치인지 흐려진다. */} + + {/* 액티브를 bg-white 로 두면 트랙(bg-fill #F0F2F8) 과 거의 같은 밝기라 + 멀리서 어느 쪽이 켜졌는지 안 보인다. 채움색으로 확실히 갈라준다. */} +
{MODES.map((m) => ( ))} @@ -85,7 +88,7 @@ type Mode = "before" | "after" const MODES: { key: Mode; label: string }[] = [ { key: "before", label: "기존 방식" }, - { key: "after", label: "negotium 도입 후" }, + { key: "after", label: "네고시움 도입 후" }, ] type CompareItem = { label: string; icon: LucideIcon; before: string; after: string } @@ -97,13 +100,13 @@ function CompareCard({ item, mode, index }: { item: CompareItem; mode: Mode; ind return (
@@ -156,14 +159,14 @@ const ITEMS: CompareItem[] = [ { label: "사람", icon: UserRound, - before: "반복 흥정과 감정 노동에 지쳐 고급 인력이 이탈합니다.", - after: "감정 소모는 봇이 맡고, 사람은 전략 업무와 대형 거래에 집중합니다.", + before: "반복 협상에 시간을 뺏겨 전략 업무가 계속 밀립니다.", + after: "반복 협상은 에이전트가 맡고, 담당자는 전략 구매와 대형 건에 집중합니다.", }, { label: "파트너 관계", icon: Handshake, - before: "감정 과열과 실랑이로 장기 파트너십이 흔들립니다.", - after: "악역은 봇이 — 압박 없는 상시 협상으로 관계가 개선됩니다.", + before: "담당자마다 기준이 달라 협력사가 조건을 예측하기 어렵습니다.", + after: "에이전트가 같은 기준으로 상시 응대해 조건이 일관됩니다.", }, { label: "의사 결정", diff --git a/landing/app/components/sections/contact.tsx b/landing/app/components/sections/contact.tsx index b7be376..5b4e377 100644 --- a/landing/app/components/sections/contact.tsx +++ b/landing/app/components/sections/contact.tsx @@ -1,6 +1,6 @@ import { useState } from "react" import { AnimatePresence, motion } from "motion/react" -import { Building, CheckCircle2, Loader2, Mail, Phone, Send } from "lucide-react" +import { Check, Loader2 } from "lucide-react" import { Button } from "@/components/ui/button" import { Input, Textarea } from "@/components/ui/input" @@ -8,7 +8,7 @@ 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" +import { submitLead } from "@/lib/lead" const EMPTY_FORM = { companyName: '', @@ -18,19 +18,33 @@ const EMPTY_FORM = { message: '', } -/** 도입 문의 폼. 백엔드 미연결 — 제출은 데모 처리(1.2초 후 성공 화면). */ +/* + * 상담 신청 폼 — 고관여 경로. 저관여 리드 캡처는 데모 요청 모달이 맡는다. + * + * 예전에는 백엔드가 없어서 1.2초 뒤 성공 화면만 띄웠다. 화면은 "접수되었습니다"인데 + * 영업이 받을 리드는 존재하지 않았고, 아무도 그 사실을 몰랐다. 지금은 데모 요청과 + * 같은 서버리스 함수(api/lead.ts)로 보내고, 서버가 리드를 확보했을 때만 성공을 띄운다. + */ export function Contact() { const [formData, setFormData] = useState(EMPTY_FORM) - const [status, setStatus] = useState<'idle' | 'submitting' | 'success'>('idle') + const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle') - const handleSubmit = (e: React.FormEvent) => { + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() if (!formData.companyName || !formData.contactName || !formData.email || !formData.phone) { return } setStatus('submitting') - setTimeout(() => setStatus('success'), 1200) + const result = await submitLead({ + source: 'contact', + name: formData.contactName, + email: formData.email, + company: formData.companyName, + phone: formData.phone, + message: formData.message, + }) + setStatus(result.ok ? 'success' : 'error') } const handleChange = (e: React.ChangeEvent) => { @@ -41,22 +55,12 @@ export function Contact() { const submitting = status === 'submitting' return ( -
-
-
- - } - > +
@@ -70,10 +74,10 @@ export function Contact() { animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -20 }} transition={{ duration: 0.5, ease: EASE_OUT_EXPO }} - className="space-y-6" + className="space-y-9" > -
- +
+
-
- +
+ - + + {/* 대체 연락처(이메일 등)를 넣으려면 실제 영업 주소를 받아서 채워야 한다. + 임의의 주소를 공개 랜딩에 박아둘 수는 없다. */} + {status === 'error' && ( +

+ 지금은 접수가 어렵습니다. 잠시 후 다시 시도해 주세요. +

+ )} + - * 제출 시 입력하신 정보는 문의 사항 응답 및 도입 검토 목적에 한하여 사용되며, 관련 법령에 의거하여 안전하게 - 보호됩니다. + 입력하신 정보는 문의 응답과 도입 검토 목적으로만 사용됩니다.
-
@@ -162,15 +170,14 @@ export function Contact() { 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" + className="text-center py-20 px-6 border border-line-strong rounded-card text-ink break-keep" > -
- +
+
-

도입 문의 신청이 접수되었습니다

-

- 작성해주신 담당자 정보({formData.contactName} 님)를 통해 24시간 이내에 전담 혁신 컨설턴트가 유선 혹은 이메일로 - 연락을 드리겠습니다. +

도입 문의 신청이 접수되었습니다

+

+ {formData.contactName} 님께 영업일 기준 하루 안에 연락드리겠습니다.

+
- - 지금도 구매 예산은
- 조용히 새고 있을지 모릅니다 -
- - - 수작업으로 진행되어 수많은 단가 타협 기회를 흘려보내던 파트너사 흥정, 규칙 준수에 압도적으로 특화된 negotium 봇에 - 위임하고 재무 마진을 안전히 지키십시오. - - - - + {/* 결과 화면. 예전엔 negotiation_annotated_3d.mp4 였는데 컴포넌트로 바꿨다 — + 영상은 화자("아이마켓 구매 MD")와 색이 픽셀에 구워져 있어 둘 다 못 고쳤다. + 다크 무대에 네이티브로 그리니 밝은 영상이 구멍처럼 뜨던 문제도 같이 사라져서, + 카드로 감싸고 그림자로 띄우던 처리가 더는 필요 없다. */} + +
-
+
) } diff --git a/landing/app/components/sections/footer.tsx b/landing/app/components/sections/footer.tsx index 4125419..5f00f9c 100644 --- a/landing/app/components/sections/footer.tsx +++ b/landing/app/components/sections/footer.tsx @@ -8,7 +8,7 @@ export function Footer() {
- negotium은 협상 봇이 공급사와 1:1로 단가를 조율하고 낙찰까지 처리하는 B2B 구매 협상 자동화 솔루션입니다. + 네고시움은 협상 에이전트가 공급사와 1:1로 단가를 조율하고 낙찰까지 처리하는 B2B 구매 협상 자동화 솔루션입니다.

© {new Date().getFullYear()} negotium Co., Ltd. All rights reserved. diff --git a/landing/app/components/sections/header.tsx b/landing/app/components/sections/header.tsx index 833101f..9a723a7 100644 --- a/landing/app/components/sections/header.tsx +++ b/landing/app/components/sections/header.tsx @@ -5,36 +5,78 @@ import { Button } from "@/components/ui/button" import { Logo } from "@/components/ui/logo" export function Header() { - const [scrolled, setScrolled] = useState(false) + // 두 상태는 조건이 다르다. 하나로 묶으면 다크 히어로 위에 흰 바가 떠버린다. + // compact — 조금이라도 스크롤하면 높이를 줄인다. + // onStage — 다크 히어로를 벗어나기 전까지. 이때는 배경을 깔지 않고 로고를 반전시킨다. + const [compact, setCompact] = useState(false) + const [onStage, setOnStage] = useState(true) - // 스크롤 시 헤더를 반투명 블러 배경으로 전환 useEffect(() => { - const handleScroll = () => setScrolled(window.scrollY > 40) + const handleScroll = () => setCompact(window.scrollY > 40) window.addEventListener("scroll", handleScroll, { passive: true }) handleScroll() return () => window.removeEventListener("scroll", handleScroll) }, []) + /* "다크 히어로 위에 있는가"는 스크롤 수치가 아니라 교차 상태다. + scrollY 로 계산하면 하이드레이션 전에 스크롤 위치가 잡힌 경우(ScrollRestoration, + 새로고침, 뒤로가기) 초기 계산이 0 에서 끝나고 이후 스크롤 이벤트가 없으면 + 영영 갱신되지 않는다 — 흰 배경 위에 흰 글자 버튼이 남는다. + IntersectionObserver 는 관찰 시작 즉시 올바른 값을 준다. */ + useEffect(() => { + const stage = document.querySelector("[data-stage-hero]") + if (!stage) { + setOnStage(false) // 다크 히어로가 없는 페이지에서는 항상 라이트 + return + } + // 헤더 높이(약 80px)만큼 위에서 미리 전환되도록 상단 마진을 음수로 준다. + const io = new IntersectionObserver(([entry]) => setOnStage(entry.isIntersecting), { + rootMargin: "-80px 0px 0px 0px", + threshold: 0, + }) + io.observe(stage) + return () => io.disconnect() + }, []) + return (

- + -