← back to jeonghoon0126__covering-spot

Function bodies 369 total

All specs Real LLM only Function bodies
handleSave function · typescript · L180-L208 (29 LOC)
src/app/booking/manage/page.tsx
  async function handleSave(id: string) {
    if (!editForm) return;
    setSaving(true);
    try {
      const token = getBookingToken();
      const headers: Record<string, string> = { "Content-Type": "application/json" };
      if (token) headers["x-booking-token"] = token;
      const res = await fetch(`/api/bookings/${id}`, {
        method: "PUT",
        headers,
        body: JSON.stringify(editForm),
      });
      if (res.ok) {
        const data = await res.json();
        setBookings((prev) =>
          prev.map((b) => (b.id === id ? data.booking : b)),
        );
        setEditingId(null);
        setEditForm(null);
      } else {
        const err = await res.json();
        alert(err.error || "수정 실패");
      }
    } catch {
      alert("네트워크 오류");
    } finally {
      setSaving(false);
    }
  }
fetchSlots function · typescript · L210-L220 (11 LOC)
src/app/booking/manage/page.tsx
  async function fetchSlots(date: string, excludeId: string) {
    setSlotsLoading(true);
    try {
      const res = await fetch(`/api/slots?date=${date}&excludeId=${excludeId}`);
      if (res.ok) {
        const data = await res.json();
        setAvailableSlots(data.slots || []);
      }
    } catch { /* ignore */ }
    finally { setSlotsLoading(false); }
  }
startReschedule function · typescript · L222-L226 (5 LOC)
src/app/booking/manage/page.tsx
  function startReschedule(b: Booking) {
    setReschedulingId(b.id);
    setRescheduleForm({ date: b.date, timeSlot: b.timeSlot || "" });
    fetchSlots(b.date, b.id);
  }
cancelReschedule function · typescript · L228-L232 (5 LOC)
src/app/booking/manage/page.tsx
  function cancelReschedule() {
    setReschedulingId(null);
    setRescheduleForm(null);
    setAvailableSlots([]);
  }
handleUserConfirm function · typescript · L234-L249 (16 LOC)
src/app/booking/manage/page.tsx
  async function handleUserConfirm(id: string) {
    const token = getBookingToken();
    const headers: Record<string, string> = { "Content-Type": "application/json" };
    if (token) headers["x-booking-token"] = token;
    const res = await fetch(`/api/bookings/${id}`, {
      method: "PUT",
      headers,
      body: JSON.stringify({ action: "user_confirm" })
    });
    if (res.ok) {
      setBookings(prev => prev.map(b => b.id === id ? { ...b, status: "user_confirmed" as const } : b));
    } else {
      const err = await res.json().catch(() => ({}));
      alert(err.error || "확인 처리 실패");
    }
  }
handleReschedule function · typescript · L251-L277 (27 LOC)
src/app/booking/manage/page.tsx
  async function handleReschedule(id: string) {
    if (!rescheduleForm) return;
    setRescheduleSaving(true);
    try {
      const token = getBookingToken();
      const headers: Record<string, string> = { "Content-Type": "application/json" };
      if (token) headers["x-booking-token"] = token;
      const res = await fetch(`/api/bookings/${id}`, {
        method: "PUT",
        headers,
        body: JSON.stringify(rescheduleForm),
      });
      if (res.ok) {
        const data = await res.json();
        setBookings((prev) => prev.map((b) => (b.id === id ? data.booking : b)));
        setReschedulingId(null);
        setRescheduleForm(null);
      } else {
        const err = await res.json();
        alert(err.error || "일정 변경 실패");
      }
    } catch {
      alert("네트워크 오류");
    } finally {
      setRescheduleSaving(false);
    }
  }
getMonthDays function · typescript · L34-L41 (8 LOC)
src/app/booking/page.tsx
function getMonthDays(year: number, month: number) {
  const firstDay = new Date(year, month, 1).getDay();
  const daysInMonth = new Date(year, month + 1, 0).getDate();
  const days: (number | null)[] = [];
  for (let i = 0; i < firstDay; i++) days.push(null);
  for (let d = 1; d <= daysInMonth; d++) days.push(d);
  return days;
}
Want fix-PRs on findings? Install Repobility's GitHub App · github.com/apps/repobility-bot
BookingPage function · typescript · L43-L49 (7 LOC)
src/app/booking/page.tsx
export default function BookingPage() {
  return (
    <Suspense fallback={<div className="text-center py-20"><LoadingSpinner size="lg" /></div>}>
      <BookingPageContent />
    </Suspense>
  );
}
handlePhotoChange function · typescript · L260-L271 (12 LOC)
src/app/booking/page.tsx
  function handlePhotoChange(e: React.ChangeEvent<HTMLInputElement>) {
    const files = e.target.files;
    if (!files) return;
    const newFiles = Array.from(files);
    setPhotos((prev) => {
      const combined = [...prev, ...newFiles];
      const result = combined.slice(0, 5);
      track("booking_photo_upload", { count: result.length });
      return result;
    });
    if (fileInputRef.current) fileInputRef.current.value = "";
  }
removePhoto function · typescript · L274-L276 (3 LOC)
src/app/booking/page.tsx
  function removePhoto(index: number) {
    setPhotos((prev) => prev.filter((_, i) => i !== index));
  }
updateItemQty function · typescript · L369-L397 (29 LOC)
src/app/booking/page.tsx
  function updateItemQty(
    cat: string,
    name: string,
    displayName: string,
    price: number,
    delta: number,
  ) {
    if (delta > 0) {
      track("booking_item_select", { category: cat, name, price });
    }
    setSelectedItems((prev) => {
      const idx = prev.findIndex(
        (i) => i.category === cat && i.name === name,
      );
      if (idx >= 0) {
        const next = [...prev];
        next[idx] = { ...next[idx], quantity: next[idx].quantity + delta };
        if (next[idx].quantity <= 0) next.splice(idx, 1);
        return next;
      }
      if (delta > 0) {
        const spotCat = categories.find((c) => c.name === cat);
        const spotItem = spotCat?.items.find((i) => i.name === name);
        const loadingCube = spotItem?.loadingCube ?? 0;
        return [...prev, { category: cat, name, displayName, price, quantity: 1, loadingCube }];
      }
      return prev;
    });
  }
getItemQty function · typescript · L399-L404 (6 LOC)
src/app/booking/page.tsx
  function getItemQty(cat: string, name: string) {
    return (
      selectedItems.find((i) => i.category === cat && i.name === name)
        ?.quantity || 0
    );
  }
handleSubmit function · typescript · L407-L505 (99 LOC)
src/app/booking/page.tsx
  async function handleSubmit() {
    if (!quote) return;
    track(editMode ? "booking_edit_submit" : "booking_submit", { itemCount: selectedItems.length, estimatedTotal: quote.estimateMin });
    setLoading(true);
    try {
      // 1. 사진이 있으면 먼저 업로드
      let photoUrls: string[] = [];
      if (photos.length > 0) {
        const formData = new FormData();
        photos.forEach((file) => formData.append("photos", file));
        const uploadRes = await fetch("/api/upload", {
          method: "POST",
          body: formData,
        });
        if (!uploadRes.ok) {
          alert("사진 업로드에 실패했습니다. 다시 시도해주세요.");
          setLoading(false);
          return;
        }
        const uploadData = await uploadRes.json();
        photoUrls = uploadData.urls || [];
      }

      const bookingData = {
        date: selectedDate,
        timeSlot: selectedTime,
        area: selectedArea,
        items: selectedItems,
        totalPrice: quote.totalPrice,
        estimateMin: quote.estima
getKSTDate function · typescript · L40-L45 (6 LOC)
src/app/driver/dashboard/page.tsx
function getKSTDate(offset = 0): string {
  // setHours 방식은 날짜 경계에서 부정확 → epoch ms 기준으로 정확하게 계산
  const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
  const kstMs = Date.now() + KST_OFFSET_MS + offset * 24 * 60 * 60 * 1000;
  return new Date(kstMs).toISOString().slice(0, 10);
}
formatDateShort function · typescript · L47-L51 (5 LOC)
src/app/driver/dashboard/page.tsx
function formatDateShort(dateStr: string): string {
  const d = new Date(dateStr + "T00:00:00");
  const weekdays = ["일", "월", "화", "수", "목", "금", "토"];
  return `${d.getMonth() + 1}/${d.getDate()} (${weekdays[d.getDay()]})`;
}
Repobility (the analyzer behind this table) · https://repobility.com
openMap function · typescript · L53-L66 (14 LOC)
src/app/driver/dashboard/page.tsx
function openMap(address: string) {
  const encoded = encodeURIComponent(address);
  const kakaoAppUrl = `kakaomap://search?q=${encoded}`;
  const kakaoWebUrl = `https://map.kakao.com/?q=${encoded}`;

  // 앱 전환 여부 감지: blur = 카카오맵 앱으로 전환됨 → 타이머 취소
  // blur 없이 1.5s 경과 = 앱 없음 → 카카오맵 웹 새 탭 (현재 대시보드 유지)
  const timeout = setTimeout(() => {
    window.open(kakaoWebUrl, "_blank", "noopener,noreferrer");
  }, 1500);

  window.location.href = kakaoAppUrl;
  window.addEventListener("blur", () => clearTimeout(timeout), { once: true });
}
showToast function · typescript · L83-L87 (5 LOC)
src/app/driver/dashboard/page.tsx
  function showToast(msg: string) {
    if (toastTimerRef.current) clearTimeout(toastTimerRef.current);
    setToastMsg(msg);
    toastTimerRef.current = setTimeout(() => setToastMsg(null), 3000);
  }
handleLogout function · typescript · L133-L137 (5 LOC)
src/app/driver/dashboard/page.tsx
  function handleLogout() {
    sessionStorage.removeItem("driver_token");
    sessionStorage.removeItem("driver_name");
    router.replace("/driver");
  }
handleQuickAction function · typescript · L168-L219 (52 LOC)
src/app/driver/dashboard/page.tsx
  async function handleQuickAction(
    booking: Partial<Booking>,
    newStatus: string,
  ) {
    if (!booking.id) return; // non-null 방어

    setPendingAction(null);

    // 낙관적 업데이트: API 호출 전 로컬 상태 먼저 변경 → 즉각 피드백
    const prevStatus = booking.status;
    setBookings((prev) =>
      prev.map((b) =>
        b.id === booking.id
          ? { ...b, status: newStatus as Booking["status"] }
          : b,
      ),
    );

    setActionLoading(booking.id);
    try {
      const res = await fetch(`/api/driver/bookings/${booking.id}`, {
        method: "PUT",
        headers: {
          "Content-Type": "application/json",
          Authorization: `Bearer ${token}`,
        },
        body: JSON.stringify({ status: newStatus }),
      });
      if (res.ok) {
        await fetchBookings(); // 서버 응답으로 최종 동기화 (await로 완료 후 loading 해제)
      } else {
        const data = await res.json();
        // 롤백
        setBookings((prev) =>
          prev.map((b) =>
            b.id === booking.id ? { .
DriverLoginPage function · typescript · L6-L138 (133 LOC)
src/app/driver/page.tsx
export default function DriverLoginPage() {
  const router = useRouter();
  const [phone, setPhone] = useState("");
  const [error, setError] = useState("");
  const [loading, setLoading] = useState(false);
  const inputRef = useRef<HTMLInputElement>(null);

  // 이미 로그인된 상태면 대시보드로
  useEffect(() => {
    if (sessionStorage.getItem("driver_token")) {
      router.replace("/driver/dashboard");
    }
  }, [router]);

  function formatPhoneInput(raw: string): string {
    const digits = raw.replace(/[^\d]/g, "").slice(0, 11);
    if (digits.length <= 3) return digits;
    if (digits.length <= 7) return `${digits.slice(0, 3)}-${digits.slice(3)}`;
    return `${digits.slice(0, 3)}-${digits.slice(3, 7)}-${digits.slice(7)}`;
  }

  function handlePhoneChange(e: React.ChangeEvent<HTMLInputElement>) {
    setPhone(formatPhoneInput(e.target.value));
    setError("");
  }

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    const digits = phone.replace(/[^\d]/g, "");
  
formatPhoneInput function · typescript · L20-L25 (6 LOC)
src/app/driver/page.tsx
  function formatPhoneInput(raw: string): string {
    const digits = raw.replace(/[^\d]/g, "").slice(0, 11);
    if (digits.length <= 3) return digits;
    if (digits.length <= 7) return `${digits.slice(0, 3)}-${digits.slice(3)}`;
    return `${digits.slice(0, 3)}-${digits.slice(3, 7)}-${digits.slice(7)}`;
  }
handlePhoneChange function · typescript · L27-L30 (4 LOC)
src/app/driver/page.tsx
  function handlePhoneChange(e: React.ChangeEvent<HTMLInputElement>) {
    setPhone(formatPhoneInput(e.target.value));
    setError("");
  }
handleSubmit function · typescript · L32-L65 (34 LOC)
src/app/driver/page.tsx
  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    const digits = phone.replace(/[^\d]/g, "");
    if (digits.length < 10) {
      setError("전화번호를 정확히 입력해주세요");
      inputRef.current?.focus();
      return;
    }

    setLoading(true);
    setError("");

    try {
      const res = await fetch("/api/driver/auth", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ phone }),
      });
      const data = await res.json();

      if (res.ok && data.token) {
        sessionStorage.setItem("driver_token", data.token);
        sessionStorage.setItem("driver_name", data.driverName || "");
        router.push("/driver/dashboard");
      } else {
        setError(data.error || "인증 실패");
        inputRef.current?.focus();
      }
    } catch {
      setError("네트워크 오류가 발생했습니다");
    } finally {
      setLoading(false);
    }
  }
Repobility's GitHub App fixes findings like these · https://github.com/apps/repobility-bot
RootLayout function · typescript · L98-L215 (118 LOC)
src/app/layout.tsx
export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="ko">
      <head>
        {/* LocalBusiness + Service 구조화 데이터 */}
        <script
          type="application/ld+json"
          dangerouslySetInnerHTML={{
            __html: JSON.stringify({
              "@context": "https://schema.org",
              "@type": "LocalBusiness",
              name: SITE_NAME,
              description: SITE_DESC,
              url: SITE_URL,
              address: {
                "@type": "PostalAddress",
                addressLocality: "서울특별시",
                addressRegion: "서울",
                addressCountry: "KR",
              },
              areaServed: [
                { "@type": "City", name: "서울특별시" },
                { "@type": "State", name: "경기도" },
                { "@type": "City", name: "인천광역시" },
              ],
              priceRange: "₩₩",
              hasOfferCatalog: {
                "@type": "Offe
OfflinePage function · typescript · L3-L29 (27 LOC)
src/app/offline/page.tsx
export default function OfflinePage() {
  return (
    <div className="min-h-screen flex flex-col items-center justify-center px-4 text-center">
      <div className="w-16 h-16 rounded-full bg-bg-warm2 flex items-center justify-center mb-6">
        <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-text-muted">
          <line x1="1" y1="1" x2="23" y2="23" />
          <path d="M16.72 11.06A10.94 10.94 0 0 1 19 12.55" />
          <path d="M5 12.55a10.94 10.94 0 0 1 5.17-2.39" />
          <path d="M10.71 5.05A16 16 0 0 1 22.56 9" />
          <path d="M1.42 9a15.91 15.91 0 0 1 4.7-2.88" />
          <path d="M8.53 16.11a6 6 0 0 1 6.95 0" />
          <line x1="12" y1="20" x2="12.01" y2="20" />
        </svg>
      </div>
      <h1 className="text-xl font-bold mb-2">오프라인 상태입니다</h1>
      <p className="text-text-sub text-sm mb-6">
        인터넷에 연결한 후 다시 시도해 주세요
      </p>
      <b
Page function · typescript · L20-L43 (24 LOC)
src/app/page.tsx
export default function Page() {
  return (
    <>
      {/* Nav/FloatingCTA는 Splash 밖에 배치 — Splash의 transform이 fixed 포지셔닝을 깨뜨리기 때문 */}
      <Nav />
      <FloatingCTA />
      <Splash>
        <section className="hero-section" id="hero">
          <Hero />
        </section>
        <TrustBar />
        <ItemsCarousel items={carouselItems} />
        <Process />
        <Pricing />
        <ItemPrices categories={priceCategories} />
        <Compare />
        <FAQ items={faqItems} />
        <CTASection />
        {/* <AppDownload /> — 유저 대상 앱 설치 비활성화 (어드민/매니저만 사용) */}
        <Footer />
      </Splash>
    </>
  );
}
robots function · typescript · L4-L15 (12 LOC)
src/app/robots.ts
export default function robots(): MetadataRoute.Robots {
  return {
    rules: [
      {
        userAgent: "*",
        allow: "/",
        disallow: ["/admin", "/admin/*", "/api/*", "/booking/complete", "/booking/manage"],
      },
    ],
    sitemap: `${SITE_URL}/sitemap.xml`,
  };
}
sitemap function · typescript · L4-L25 (22 LOC)
src/app/sitemap.ts
export default function sitemap(): MetadataRoute.Sitemap {
  return [
    {
      url: SITE_URL,
      lastModified: new Date(),
      changeFrequency: "weekly",
      priority: 1,
    },
    {
      url: `${SITE_URL}/booking`,
      lastModified: new Date(),
      changeFrequency: "weekly",
      priority: 0.9,
    },
    {
      url: `${SITE_URL}/booking/manage`,
      lastModified: new Date(),
      changeFrequency: "monthly",
      priority: 0.7,
    },
  ];
}
ABTest function · typescript · L27-L48 (22 LOC)
src/components/ABTest.tsx
export function ABTest({ experiment, variants, fallback }: ABTestProps) {
  const { getVariant } = useExperiment();
  const variant = getVariant(experiment);

  // variant가 매칭되면 해당 컴포넌트 렌더링
  if (variant && variants[variant]) {
    return <>{variants[variant]}</>;
  }

  // fallback이 있으면 사용, 없으면 control variant 사용
  if (fallback) {
    return <>{fallback}</>;
  }

  if (variants.control) {
    return <>{variants.control}</>;
  }

  // control도 없으면 첫 번째 variant 렌더링
  const firstKey = Object.keys(variants)[0];
  return firstKey ? <>{variants[firstKey]}</> : null;
}
sanitizeColor function · typescript · L84-L86 (3 LOC)
src/components/admin/KakaoMap.tsx
function sanitizeColor(c: string): string {
  return /^#[0-9a-fA-F]{3,8}$/.test(c) ? c : "#3B82F6";
}
AnalyticsProvider function · typescript · L8-L85 (78 LOC)
src/components/analytics/AnalyticsProvider.tsx
export function AnalyticsProvider({ children }: { children: ReactNode }) {
  const pathname = usePathname();

  // Page view
  useEffect(() => {
    track("page_view");
  }, [pathname]);

  // Scroll depth tracking
  useEffect(() => {
    const thresholds = [25, 50, 75, 100] as const;
    const fired = new Set<number>();

    const handle = () => {
      const pct = Math.round(
        (window.scrollY /
          (document.documentElement.scrollHeight - window.innerHeight)) *
          100
      );
      for (const t of thresholds) {
        if (pct >= t && !fired.has(t)) {
          fired.add(t);
          track("scroll_depth", { depth: t });
        }
      }
    };

    window.addEventListener("scroll", handle, { passive: true });
    return () => window.removeEventListener("scroll", handle);
  }, []);

  const mixpanelToken = process.env.NEXT_PUBLIC_MIXPANEL_TOKEN;
  const airbridgeToken = process.env.NEXT_PUBLIC_AIRBRIDGE_WEB_TOKEN;
  const gaId = process.env.NEXT_PUBLIC_GA4_ID;

Repobility · MCP-ready · https://repobility.com
FloatingCTA function · typescript · L8-L80 (73 LOC)
src/components/layout/FloatingCTA.tsx
export function FloatingCTA() {
  const [show, setShow] = useState(false);

  useEffect(() => {
    const handle = () => {
      const heroCta = document.getElementById("hero-cta");
      const hero = document.getElementById("hero");
      const cta = document.getElementById("cta");
      if (!hero || !cta) return;

      // Hero CTA 버튼이 뷰포트에서 사라지면 즉시 표시
      const heroCtaGone = heroCta
        ? heroCta.getBoundingClientRect().bottom < 0
        : hero.getBoundingClientRect().bottom < 0;
      const ctaVisible = cta.getBoundingClientRect().top < window.innerHeight;
      // 페이지 하단 300px 이내면 숨김 (푸터/앱다운로드 영역)
      const nearBottom = document.documentElement.scrollHeight - window.scrollY - window.innerHeight < 300;
      setShow(heroCtaGone && !ctaVisible && !nearBottom);
    };

    window.addEventListener("scroll", handle, { passive: true });
    handle();
    return () => window.removeEventListener("scroll", handle);
  }, []);

  return (
    <div
      className={`fixed bottom-0 le
Footer function · typescript · L14-L101 (88 LOC)
src/components/layout/Footer.tsx
export function Footer() {
  return (
    <footer className="bg-brand-900 text-text-muted pt-16 pb-10">
      <div className="max-w-[1200px] mx-auto px-20 max-lg:px-10 max-sm:px-5">
        {/* Top */}
        <div className="flex justify-between items-start gap-12 pb-10 border-b border-white/10 max-md:flex-col max-md:gap-10">
          {/* Logo + Desc */}
          <div className="flex flex-col gap-4">
            <div className="flex items-center gap-2.5">
              <img src="/images/logo.png" alt="커버링" className="w-8 h-8 rounded-sm" />
              <span className="text-white font-bold text-[17px]">커버링 방문 수거</span>
            </div>
            <p className="text-sm leading-relaxed max-w-[320px]">
              소량부터 대량까지, 카톡 한 번이면 끝<br />
              사전 견적 = 최종 금액, 추가 비용 없는 투명한 가격
            </p>
          </div>

          {/* Links Grid */}
          <div className="flex gap-16 max-sm:gap-8 max-sm:flex-wrap">
            <div>
              <div className="text-white text
Nav function · typescript · L16-L186 (171 LOC)
src/components/layout/Nav.tsx
export function Nav() {
  const scrollY = useScrollPosition();
  const scrolled = scrollY > 10;
  // const { canInstall, install } = usePWAInstall(); — 유저 대상 앱 설치 비활성화
  const [menuOpen, setMenuOpen] = useState(false);
  const [hasBooking, setHasBooking] = useState(false);

  useEffect(() => {
    setHasBooking(!!localStorage.getItem("covering_spot_booking_token"));
  }, []);

  // 스크롤 시 메뉴 닫기
  useEffect(() => {
    if (!menuOpen) return;
    const close = () => setMenuOpen(false);
    window.addEventListener("scroll", close, { passive: true });
    return () => window.removeEventListener("scroll", close);
  }, [menuOpen]);

  const toggleMenu = useCallback(() => setMenuOpen((v) => !v), []);

  return (
    <nav
      className={`fixed top-4 left-1/2 -translate-x-1/2 z-[1000] w-[calc(100%-40px)] max-w-[1160px] flex flex-col rounded-lg transition-all duration-300 max-sm:top-2 max-sm:w-[calc(100%-16px)] ${
        scrolled
          ? "bg-white/80 backdrop-blur-[20px] shadow-[0_4px_24px
isIOS function · typescript · L7-L10 (4 LOC)
src/components/sections/AppDownload.tsx
function isIOS(): boolean {
  if (typeof navigator === "undefined") return false;
  return /iPhone|iPad|iPod/i.test(navigator.userAgent);
}
isStandalone function · typescript · L12-L18 (7 LOC)
src/components/sections/AppDownload.tsx
function isStandalone(): boolean {
  if (typeof window === "undefined") return false;
  return (
    window.matchMedia("(display-mode: standalone)").matches ||
    ("standalone" in navigator && (navigator as unknown as { standalone: boolean }).standalone)
  );
}
AppDownload function · typescript · L20-L106 (87 LOC)
src/components/sections/AppDownload.tsx
export function AppDownload() {
  const { canInstall, install } = usePWAInstall();
  const [ios, setIos] = useState(false);
  const [installed, setInstalled] = useState(false);

  useEffect(() => {
    setIos(isIOS());
    setInstalled(isStandalone());
  }, []);

  // 이미 설치된 경우 숨김
  if (installed) return null;

  return (
    <section className="py-16 bg-[#F0F7FF] max-md:py-12">
      <div className="max-w-[1200px] mx-auto px-20 max-lg:px-10 max-sm:px-5">
        <div className="flex items-center gap-8 max-md:flex-col max-md:text-center">
          {/* 앱 아이콘 */}
          <div className="shrink-0">
            <Image
              src="/images/logo.png"
              alt="커버링"
              width={80}
              height={80}
              className="w-20 h-20 rounded-lg shadow-lg"
            />
          </div>

          {/* 텍스트 */}
          <div className="flex-1 min-w-0">
            <h3 className="text-[22px] font-bold text-text-primary tracking-[-0.5px] max-md:text-[20px]">
  
extractPrice function · typescript · L10-L13 (4 LOC)
src/components/sections/Compare.tsx
function extractPrice(text: string): number {
  const match = text.match(/([\d,]+)원/);
  return match ? parseInt(match[1].replace(/,/g, ""), 10) : 0;
}
formatPrice function · typescript · L15-L17 (3 LOC)
src/components/sections/Compare.tsx
function formatPrice(n: number): string {
  return n.toLocaleString("ko-KR") + "원";
}
Want fix-PRs on findings? Install Repobility's GitHub App · github.com/apps/repobility-bot
Compare function · typescript · L178-L285 (108 LOC)
src/components/sections/Compare.tsx
export function Compare() {
  const good = compareCards[0];
  const bad = compareCards[1];

  const goodTotal = good.lines.reduce((s, l) => s + extractPrice(l.text), 0);
  const badTotal = bad.lines.reduce((s, l) => s + extractPrice(l.text), 0);
  const maxTotal = Math.max(goodTotal, badTotal);
  const saving = badTotal - goodTotal;

  /* IntersectionObserver for bar animation */
  const barRef = useRef<HTMLDivElement>(null);
  const [barAnimated, setBarAnimated] = useState(false);

  useEffect(() => {
    const el = barRef.current;
    if (!el) return;

    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
      setBarAnimated(true);
      return;
    }

    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          setBarAnimated(true);
          observer.unobserve(el);
        }
      },
      { threshold: 0.3, rootMargin: "0px 0px -40px 0px" },
    );

    observer.observe(el);
    return () => observer.disconnec
CTASection function · typescript · L6-L62 (57 LOC)
src/components/sections/CTASection.tsx
export function CTASection() {
  return (
    <section
      className="py-[120px] text-center bg-gradient-to-br from-[#0B1120] via-[#0F172A] to-[#162032] relative overflow-hidden max-md:py-20"
      id="cta"
    >
      {/* Primary radial glow */}
      <div className="absolute -top-1/2 -left-1/2 w-[200%] h-[200%] bg-[radial-gradient(circle_at_30%_50%,rgba(26,163,255,0.12)_0%,transparent_50%)] pointer-events-none" />
      {/* Secondary glow - right side */}
      <div className="absolute -bottom-1/2 -right-1/4 w-[150%] h-[150%] bg-[radial-gradient(circle_at_70%_60%,rgba(26,163,255,0.08)_0%,transparent_45%)] pointer-events-none" />
      {/* Subtle dot pattern */}
      <div className="absolute inset-0 opacity-[0.03] pointer-events-none" style={{ backgroundImage: "radial-gradient(circle, #fff 1px, transparent 1px)", backgroundSize: "32px 32px" }} />
      <div className="max-w-[1200px] mx-auto px-20 max-lg:px-10 max-sm:px-5 relative">
        <ScrollReveal>
          <h2 className="te
FAQ function · typescript · L79-L113 (35 LOC)
src/components/sections/FAQ.tsx
export function FAQ({ items }: Props) {
  const [openIndex, setOpenIndex] = useState<number | null>(null);

  const toggle = useCallback((index: number) => {
    setOpenIndex((prev) => {
      if (prev === index) return null;
      track("faq_open", { question: items[index].question, index });
      return index;
    });
  }, [items]);

  return (
    <section className="py-[120px] bg-bg max-md:py-20" id="faq">
      <div className="max-w-[1200px] mx-auto px-20 max-lg:px-10 max-sm:px-5">
        <ScrollReveal>
          <SectionHeader title="자주 묻는 질문" center />
        </ScrollReveal>

        <ScrollReveal>
          <div className="max-w-[720px] mx-auto">
            {items.map((item, i) => (
              <FAQItemCard
                key={i}
                item={item}
                index={i}
                isOpen={openIndex === i}
                onToggle={() => toggle(i)}
              />
            ))}
          </div>
        </ScrollReveal>
      </div>
    </section>
  );
TypingIndicator function · typescript · L37-L49 (13 LOC)
src/components/sections/Hero.tsx
function TypingIndicator() {
  return (
    <div className="flex items-end gap-1.5">
      <div className="px-4 py-3 bg-bg-warm2 rounded-[16px_16px_16px_4px] shadow-sm">
        <div className="flex gap-1 items-center h-[21px]">
          <span className="w-[6px] h-[6px] rounded-full bg-text-muted/60 animate-[typing_1.4s_ease-in-out_infinite]" />
          <span className="w-[6px] h-[6px] rounded-full bg-text-muted/60 animate-[typing_1.4s_ease-in-out_0.2s_infinite]" />
          <span className="w-[6px] h-[6px] rounded-full bg-text-muted/60 animate-[typing_1.4s_ease-in-out_0.4s_infinite]" />
        </div>
      </div>
    </div>
  );
}
ChatMessage function · typescript · L52-L75 (24 LOC)
src/components/sections/Hero.tsx
function ChatMessage({
  children,
  visible,
  align = "left",
  delay = 0,
}: {
  children: React.ReactNode;
  visible: boolean;
  align?: "left" | "right";
  delay?: number;
}) {
  return (
    <div
      className={`transition-all duration-500 ease-[cubic-bezier(0.16,1,0.3,1)] ${
        visible
          ? "opacity-100 translate-y-0"
          : "opacity-0 translate-y-3"
      } ${align === "right" ? "flex justify-end items-end gap-1.5" : "flex items-end gap-1.5"}`}
      style={{ transitionDelay: `${delay}ms` }}
    >
      {children}
    </div>
  );
}
WaveLayer function · typescript · L78-L108 (31 LOC)
src/components/sections/Hero.tsx
function WaveLayer({
  d,
  opacity,
  animationName,
  duration,
  top,
}: {
  d: string;
  opacity: number;
  animationName: string;
  duration: string;
  top: string;
}) {
  return (
    <svg
      viewBox="0 0 1440 320"
      fill="none"
      xmlns="http://www.w3.org/2000/svg"
      className="absolute w-[160%] left-[-30%]"
      preserveAspectRatio="none"
      style={{
        top,
        height: "320px",
        animation: `${animationName} ${duration} ease-in-out infinite`,
        opacity,
      }}
    >
      <path d={d} fill="#1AA3FF" />
    </svg>
  );
}
Hero function · typescript · L110-L398 (289 LOC)
src/components/sections/Hero.tsx
export function Hero() {
  const { ref: leftRef, visible: leftVisible } = useScrollReveal(0, true);
  const { ref: rightRef, visible: rightVisible } = useScrollReveal(0, true);

  /* ── 텍스트 등장 애니메이션 ── */
  const [textReady, setTextReady] = useState(false);

  useEffect(() => {
    if (leftVisible && !textReady) {
      setTextReady(true);
    }
  }, [leftVisible, textReady]);

  /* ── 채팅 애니메이션 시퀀스 ── */
  const [step, setStep] = useState(0);

  useEffect(() => {
    if (!rightVisible) return;
    // step 0: 카드 보임
    // step 1: 첫 유저 메시지 (0.4s)
    // step 2: 타이핑 인디케이터 (1.0s)
    // step 3: 봇 견적 응답 (2.0s)
    // step 4: 유저 감탄 메시지 (3.2s)
    const timers = [
      setTimeout(() => setStep(1), 400),
      setTimeout(() => setStep(2), 1000),
      setTimeout(() => setStep(3), 2000),
      setTimeout(() => setStep(4), 3200),
    ];
    return () => timers.forEach(clearTimeout);
  }, [rightVisible]);

  return (
    <section className="relative pt-[160px] pb-32 overflow-hidden max-md:pt-[12
ItemPrices function · typescript · L170-L307 (138 LOC)
src/components/sections/ItemPrices.tsx
export function ItemPrices({ categories }: Props) {
  const [activeId, setActiveId] = useState(categories[0].id);
  const cardRef = useRef<HTMLDivElement>(null);
  const [barsAnimated, setBarsAnimated] = useState(false);

  const active = categories.find((c) => c.id === activeId) ?? categories[0];

  useEffect(() => {
    const el = cardRef.current;
    if (!el) return;

    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          setBarsAnimated(true);
          observer.unobserve(el);
        }
      },
      { threshold: 0.3 }
    );

    observer.observe(el);
    return () => observer.disconnect();
  }, []);

  useEffect(() => {
    setBarsAnimated(false);
    const timer = setTimeout(() => setBarsAnimated(true), 50);
    return () => clearTimeout(timer);
  }, [activeId]);

  return (
    <section className="py-[120px] bg-bg-warm max-md:py-20" id="item-price">
      <div className="max-w-[1200px] mx-auto px-20 max-lg:px-10 max-sm
Repobility (the analyzer behind this table) · https://repobility.com
ItemsCarousel function · typescript · L13-L146 (134 LOC)
src/components/sections/ItemsCarousel.tsx
export function ItemsCarousel({ items }: Props) {
  const {
    trackRef,
    wrapperRef,
    currentPage,
    totalPages,
    goTo,
    next,
    prev,
    stopAutoplay,
    startAutoplay,
  } = useCarousel({ totalItems: items.length });

  return (
    <section className="py-[120px] bg-bg max-md:py-20" id="items">
      <div className="max-w-[1200px] mx-auto px-20 max-lg:px-10 max-sm:px-5">
        <ScrollReveal>
          <SectionHeader
            tag="품목 안내"
            title="수거 가능한 품목"
            desc="가구부터 가전, 운동기구까지 500여 가지 대형 폐기물을 수거해요"
          />
        </ScrollReveal>

        <ScrollReveal delay={0.1}>
          <div className="relative">
            <div ref={wrapperRef} className="overflow-hidden w-full">
              <div
                ref={trackRef}
                className="carousel-track flex gap-5 transition-transform duration-500 ease-[cubic-bezier(0.25,0.46,0.45,0.94)]"
              >
                {items.map((item) => (
                  <div
         
Pricing function · typescript · L42-L124 (83 LOC)
src/components/sections/Pricing.tsx
export function Pricing() {
  return (
    <section className="py-[120px] bg-bg max-md:py-20" id="pricing">
      <div className="max-w-[1200px] mx-auto px-20 max-lg:px-10 max-sm:px-5">
        {/* ── 견적 구성 ── */}
        <ScrollReveal>
          <SectionHeader
            tag="가격 안내"
            title="이렇게 견적이 정해져요"
            desc="명확한 기준으로 투명하게 안내드려요"
            center
          />
        </ScrollReveal>

        {/* 단일 카드 안에 테이블 형태로 구성 */}
        <ScrollReveal>
          <div className="max-w-[720px] mx-auto rounded-lg border border-border bg-bg-warm overflow-hidden">
            {pricingItems.map((item, i) => (
              <div
                key={item.title}
                className={`flex gap-6 px-10 py-8 max-sm:px-6 max-sm:py-6 max-sm:flex-col max-sm:gap-3 ${
                  i < pricingItems.length - 1
                    ? "border-b border-border"
                    : ""
                }`}
              >
                {/* 좌측: 라벨 + 제목 */}
                <div cla
ProcessCard function · typescript · L42-L107 (66 LOC)
src/components/sections/Process.tsx
function ProcessCard({ step, index }: { step: typeof processSteps[number]; index: number }) {
  const ref = useRef<HTMLDivElement>(null);
  const [visible, setVisible] = useState(false);

  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    const observer = new IntersectionObserver(
      ([entry]) => { if (entry.isIntersecting) { setVisible(true); observer.disconnect(); } },
      { threshold: 0.3 },
    );
    observer.observe(el);
    return () => observer.disconnect();
  }, []);

  return (
    <div ref={ref} className="relative">
      {/* 스텝 간 연결선 (마지막 카드 제외) */}
      {index < processSteps.length - 1 && (
        <div className="absolute top-10 -right-8 w-8 h-[2px] z-[1] max-lg:hidden">
          <div className="w-full h-full border-t-2 border-dashed border-primary/20" />
          <svg
            className="absolute -right-1 top-1/2 -translate-y-1/2 text-primary/30"
            width="8"
            height="10"
            viewBox="0 0 8 10"
            fil
‹ prevpage 4 / 8next ›