Function bodies 369 total
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.estimagetKSTDate 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": "OffeOfflinePage 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>
<bPage 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 leFooter 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 textNav 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_24pxisIOS 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.disconnecCTASection 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="teFAQ 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-[12ItemPrices 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-smRepobility (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 claProcessCard 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