/* ============================================================================ * 서명전에 — TDS BottomCTA / FixedBottomCTA 컴포넌트 (Task #159) * * · TDS Mobile 2026-03 BottomCTA 스펙. 화면 하단의 Call-to-Action 영역을 * 표준화한 컴포넌트. "Single"(버튼 1개)·"Double"(버튼 2개)·`fixed` 포지션· * 스크롤 시 숨김·키보드 위 띄움·세이프에어리어 패딩 등 화면 하단 CTA 가 * 다뤄야 할 거의 모든 케이스를 커버해요. * * · 두 가지 사용 방식 (스펙상 완전 동일): * * * * * · 구조: * BottomCTA ─ 루트 컨테이너 (Single/Double 자식 형태에 맞춰 dispatch) * ├── BottomCTA.Single ─ 풀폭 버튼 1개 (children = Button) * ├── BottomCTA.Double ─ leftButton + rightButton 2개 * ├── BottomCTA.Button ─ 편의 alias — display="block" + size="xlarge" 기본 Button * └── BottomCTA.Spacer ─ takeSpace 모드에서 바깥에서 직접 고정 높이 채우고 싶을 때 * FixedBottomCTA ─ 의 베이크된 별칭 * ├── FixedBottomCTA.Single * └── FixedBottomCTA.Double * * · 핵심 props (Single/Double 공통): * fixed boolean. true 면 viewport 하단 fixed 포지션. * FixedBottomCTA 는 항상 true. * show boolean (기본 true). false 면 화면에서 숨김 (트랜지션 적용). * showAfterDelay number(ms) — 0/미지정이면 즉시 등장. 양수면 그 시간 후 fade/slide-in. * hideOnScroll boolean. true 면 사용자가 위로 스크롤 시 숨김 / 아래로 시 다시 등장. * hideOnScrollDistanceThreshold number(px, 기본 8). 스크롤 누적이 이 값 넘어야 숨김 토글. * hasSafeAreaPadding boolean (기본 true). webview 의 하단 세이프에어리어 만큼 패딩 추가. * hasPaddingBottom boolean (기본 true). 기본 20 px 하단 여백. * false 면 위 두 패딩 모두 0. * takeSpace boolean. 부모 흐름에서 본인 높이만큼 자리 차지(스페이서 자동 삽입). * fixed=true 인데 화면 하단 컨텐츠가 가려지면 안 될 때. * background "default"(기본) | "none". default 는 위쪽 페이드(투명→흰색) 그라데이션. * animation "fade"(기본) | "scale" | "slide". 등장/숨김 트랜지션. * topAccessory ReactNode. 버튼 위에 오는 보조 영역 (예: 동의 체크박스 줄). * bottomAccessory ReactNode. 버튼 아래에 오는 보조 영역 (예: 하단 안내 문구). * containerStyle CSSProperties. 루트 컨테이너 인라인 스타일 패스스루. * containerRef Ref. 루트 컨테이너 DOM ref. * className string. 루트 클래스 추가. * onShowChange(show) boolean 콜백 — 트랜지션 종료 시 show 의 최종값. * * · Single 전용: * children 필수 — 단일 버튼 (또는 임의 노드). 풀폭 자동 적용. * fixedAboveKeyboard boolean. 가상 키보드 등장 시 키보드 바로 위로 따라 올라옴. * window.visualViewport API 사용. fixed=true 와 같이 써야 의미 있음. * * · Double 전용: * leftButton 필수 — 왼쪽 버튼 (보통 보조 액션 — variant="weak"). * rightButton 필수 — 오른쪽 버튼 (보통 메인 액션 — variant="fill"). * ratio "1:1"(기본) | "1:2" | "2:1" | "1:3" | "3:1" * 좌/우 버튼의 가로 비율. * * · CSS 변수 (외부 오버라이드 가능): * --bottom-cta-padding-bottom = max(--toss-safe-area-bottom, env(safe-area-inset-bottom), 20px) * (hasSafeAreaPadding=false 면 0, hasPaddingBottom=false 도 0) * --bottom-cta-padding-x = 16px (좌우) * --bottom-cta-padding-top = 12px * --bottom-cta-fade-h = 24px (background="default" 의 위쪽 페이드 높이) * --bottom-cta-z = 50 (fixed 일 때만 의미) * * · 모든 색·간격은 var(--…) 토큰 참조. 하드코딩 금지. * · prefers-reduced-motion 시 모든 transition/transform OFF. * * · 다른 컴포넌트와의 역할 구분: * - vs Top.LowerCTA(#156) : Top 영역에 들러붙어 스크롤 같이 따라가는 헤더 하단 액션 * 은 Top.LowerCTA. 페이지 하단·뷰포트 하단에 고정/임시 고정되는 페이지 액션은 BottomCTA. * - vs BottomSheet.CTA(#125) : BottomSheet 내부 닫기/확인 액션은 BottomSheet.CTA·DoubleCTA. * 화면 자체의 액션은 BottomCTA / FixedBottomCTA. * - vs Button(#127) 단독 : Button 만 단독으로 두면 위치/패딩/세이프에어리어/페이드를 * 직접 잡아줘야 함. 화면 하단 메인 액션이라면 BottomCTA 로 감싸기. * ========================================================================== */ (function ensureAdaptiveForBCTA() { if (typeof window === "undefined") return; if (window.adaptive) return; // AgreementV4 / Asset 와 동일한 토큰 매핑. 단독 로드 대비 가드. var hues = ["red", "orange", "yellow", "green", "blue", "purple", "pink", "grey"]; var steps = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900]; var out = { white: "#ffffff", black: "#000000", transparent: "transparent" }; hues.forEach(function (h) { steps.forEach(function (s) { out[h + s] = "var(--tds-" + h + "-" + s + ")"; }); }); window.adaptive = out; })(); // ─── 상수 ───────────────────────────────────────────────────────────── const __BCTA_ANIMATIONS = ["fade", "scale", "slide"]; const __BCTA_BACKGROUNDS = ["default", "none"]; const __BCTA_RATIOS = { "1:1": "1fr 1fr", "1:2": "1fr 2fr", "2:1": "2fr 1fr", "1:3": "1fr 3fr", "3:1": "3fr 1fr", }; function __bctaWarn(msg) { if (typeof console !== "undefined" && console.warn) console.warn("[BottomCTA] " + msg); } function __bctaEnum(value, allowed, fallback, name) { if (value == null) return fallback; if (allowed.indexOf(value) === -1) { __bctaWarn(name + ': "' + value + '" 은 지원하지 않아요. 가능한 값: ' + allowed.join(", ") + ". \"" + fallback + '" 로 폴백.'); return fallback; } return value; } // ─── 훅: 등장 지연 (showAfterDelay) ────────────────────────────────── function __bctaUseEntrance(show, showAfterDelay) { const [entered, setEntered] = React.useState(() => { return show && (!showAfterDelay || showAfterDelay <= 0); }); React.useEffect(() => { if (!show) { setEntered(false); return; } if (!showAfterDelay || showAfterDelay <= 0) { // requestAnimationFrame 으로 한 틱 미루어야 트랜지션이 적용돼요. let raf = requestAnimationFrame(() => setEntered(true)); return () => cancelAnimationFrame(raf); } const id = setTimeout(() => setEntered(true), showAfterDelay); return () => clearTimeout(id); }, [show, showAfterDelay]); return entered; } // ─── 훅: 스크롤 시 숨김 (hideOnScroll) ─────────────────────────────── function __bctaUseScrollHide(enabled, threshold) { const [hidden, setHidden] = React.useState(false); React.useEffect(() => { if (!enabled) { setHidden(false); return; } let lastY = window.pageYOffset || document.documentElement.scrollTop || 0; let acc = 0; const onScroll = () => { const y = window.pageYOffset || document.documentElement.scrollTop || 0; const dy = y - lastY; lastY = y; // 같은 방향으로 누적, 방향 바뀌면 리셋. if ((dy > 0 && acc < 0) || (dy < 0 && acc > 0)) acc = 0; acc += dy; const limit = typeof threshold === "number" && threshold >= 0 ? threshold : 8; if (acc > limit) { setHidden(true); acc = 0; } else if (acc < -limit) { setHidden(false); acc = 0; } // 페이지 최상단/최하단 부근에서는 항상 보이도록. if (y <= 0) setHidden(false); }; window.addEventListener("scroll", onScroll, { passive: true }); return () => window.removeEventListener("scroll", onScroll); }, [enabled, threshold]); return hidden; } // ─── 훅: 컨테이너 높이 측정 (takeSpace) ────────────────────────────── function __bctaUseHeight(targetRef, enabled) { const [h, setH] = React.useState(0); React.useEffect(() => { if (!enabled) return; const el = targetRef && targetRef.current; if (!el) return; const measure = () => { const rect = el.getBoundingClientRect(); setH(Math.round(rect.height)); }; measure(); let ro = null; if (typeof ResizeObserver !== "undefined") { ro = new ResizeObserver(measure); ro.observe(el); } else { window.addEventListener("resize", measure); } return () => { if (ro) ro.disconnect(); else window.removeEventListener("resize", measure); }; }, [targetRef, enabled]); return h; } // ─── 훅: 키보드 위 따라가기 (fixedAboveKeyboard) ───────────────────── function __bctaUseKeyboardOffset(enabled) { const [bottom, setBottom] = React.useState(0); React.useEffect(() => { if (!enabled) { setBottom(0); return; } const vv = typeof window !== "undefined" ? window.visualViewport : null; if (!vv) return; // 비지원 환경 (visualViewport API 없음) const onChange = () => { // visualViewport.height + offsetTop 가 화면에서 보이는 영역의 바닥. // window.innerHeight 와의 차이가 키보드가 차지한 높이. const innerH = window.innerHeight || document.documentElement.clientHeight; const visibleBottom = vv.height + vv.offsetTop; const occluded = Math.max(0, innerH - visibleBottom); setBottom(occluded); }; onChange(); vv.addEventListener("resize", onChange); vv.addEventListener("scroll", onChange); return () => { vv.removeEventListener("resize", onChange); vv.removeEventListener("scroll", onChange); }; }, [enabled]); return bottom; } // ─── 공통 컨테이너 props 빌더 ──────────────────────────────────────── function __bctaBuildClasses(opts) { return [ "tds-bcta", "tds-bcta--" + opts.kind, // single | double "tds-bcta--bg-" + opts.background, // default | none "tds-bcta--anim-" + opts.animation, // fade | scale | slide opts.fixed && "tds-bcta--fixed", opts.entered ? "is-entered" : "is-pre", opts.hidden && "is-hidden", !opts.show && "is-hide-requested", opts.hasSafeAreaPadding === false && "tds-bcta--no-safe", opts.hasPaddingBottom === false && "tds-bcta--no-pb", opts.className, ] .filter(Boolean) .join(" "); } // ─── 공통 컨테이너 ─────────────────────────────────────────────────── function __BCTAContainer({ kind, fixed, show, showAfterDelay, hideOnScroll, hideOnScrollDistanceThreshold, hasSafeAreaPadding, hasPaddingBottom, takeSpace, background, animation, topAccessory, bottomAccessory, containerStyle, containerRef, className, onShowChange, fixedAboveKeyboard, // single only — 외부에서는 0/false 로 보장 children, }) { const innerRef = React.useRef(null); // containerRef 는 ref 객체({current}) / callback ref 둘 다 지원. React.useEffect(() => { if (!containerRef) return; if (typeof containerRef === "function") { containerRef(innerRef.current); return () => containerRef(null); } if (typeof containerRef === "object") { containerRef.current = innerRef.current; return () => { // 언마운트 시 외부 ref 가 stale 한 노드를 가리키지 않게 정리. if (containerRef && containerRef.current === innerRef.current) { containerRef.current = null; } }; } }, [containerRef]); const safeBg = __bctaEnum(background, __BCTA_BACKGROUNDS, "default", "background"); const safeAnim = __bctaEnum(animation, __BCTA_ANIMATIONS, "fade", "animation"); const entered = __bctaUseEntrance(!!show, showAfterDelay); const scrollHidden = __bctaUseScrollHide(!!hideOnScroll && !!show, hideOnScrollDistanceThreshold); const hidden = !show || scrollHidden; const measuredH = __bctaUseHeight(innerRef, !!takeSpace); const kbOffset = __bctaUseKeyboardOffset(!!fixedAboveKeyboard && !!fixed); // show prop 변경 시 트랜지션 종료 후 onShowChange 알림. React.useEffect(() => { if (!onShowChange) return; const el = innerRef.current; if (!el) return; const onEnd = (e) => { if (e.target !== el) return; onShowChange(!!show && !scrollHidden); }; el.addEventListener("transitionend", onEnd); return () => el.removeEventListener("transitionend", onEnd); }, [show, scrollHidden, onShowChange]); const cls = __bctaBuildClasses({ kind: kind, fixed: !!fixed, show: !!show, entered: entered, hidden: hidden, background: safeBg, animation: safeAnim, hasSafeAreaPadding: hasSafeAreaPadding, hasPaddingBottom: hasPaddingBottom, className: className || "", }); // 키보드 오프셋은 fixed 일 때만 의미 있음 (translateY 로 위로 밀어 올리기). const dynStyle = {}; if (fixed && kbOffset > 0) { dynStyle["--bottom-cta-keyboard-offset"] = kbOffset + "px"; } const mergedStyle = Object.assign({}, dynStyle, containerStyle || {}); const node = (
{topAccessory ?
{topAccessory}
: null}
{children}
{bottomAccessory ?
{bottomAccessory}
: null}
{takeSpace && fixed ? (