Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions src/app/(marketing)/page.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
import { LandingFeaturesSection2 } from "@/components/landing/LandingFeatureSection2";
import { LandingFeaturesSection1 } from "@/components/landing/LandingFeaturesSection1";
import { LandingFullPageWrapper } from "@/components/landing/LandingFullPageWrapper";
import { LandingHeroSection } from "@/components/landing/LandingHeroSection";
import { LandingSpecialFeaturesSection } from "@/components/landing/LandingSpecialFeaturesSection";

export const revalidate = 21600;

export default function Home() {
return (
<>
<LandingHeroSection />
<LandingFeaturesSection1 />
<LandingFeaturesSection2 />
<LandingSpecialFeaturesSection />
</>
<LandingFullPageWrapper>
<LandingHeroSection className="scroll-section" />

<LandingFeaturesSection1 className="scroll-section" />

<LandingFeaturesSection2 className="scroll-section" />

<LandingSpecialFeaturesSection className="scroll-section" />
</LandingFullPageWrapper>
);
}
8 changes: 8 additions & 0 deletions src/components/landing/LandingFeatureSection2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,25 @@

import { LandingFeatureText } from "@/components/landing/LandingFeatureText";
import { LandingLayoutPreview } from "@/components/landing/LandingLayoutPreview";
import { useInView } from "@/hooks/useInView";
import { cn } from "@/lib/utils";
import type { LandingFeaturesSection2Props } from "@/types/landing";

export function LandingFeaturesSection2({ className }: LandingFeaturesSection2Props) {
const { ref, isInView } = useInView({
threshold: 0.5,
once: false,
});
return (
<section
id="landing-features2"
aria-labelledby="landing-features2-title"
ref={ref}
className={cn(
"grid gap-20",
"md:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)] md:items-center md:gap-35",
"transition-all duration-1000 ease-out",
isInView ? "opacity-100 translate-x-0" : "opacity-0 -translate-x-10",
className,
)}
>
Expand Down
8 changes: 8 additions & 0 deletions src/components/landing/LandingFeaturesSection1.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,29 @@
"use client";

import { LandingFeatureGrid } from "@/components/landing/LandingFeatureGrid";
import { useInView } from "@/hooks/useInView";
import { cn } from "@/lib/utils";
import { useFeaturePreviewStore } from "@/stores/featurePreviewStore";
import type { LandingFeaturesSection1Props } from "@/types/landing";
import Image from "next/image";

export function LandingFeaturesSection1({ className }: LandingFeaturesSection1Props) {
const activeFeature = useFeaturePreviewStore((state) => state.activeFeature);
const { ref, isInView } = useInView({
threshold: 0.5,
once: false,
});

return (
<section
id="landing-features1"
aria-labelledby="landing-features1-title"
ref={ref}
className={cn(
"grid gap-18",
"md:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)] md:items-center",
"transition-all duration-1000 ease-out",
isInView ? "opacity-100 translate-x-0" : "opacity-0 translate-x-10",
className,
)}
>
Expand Down
9 changes: 9 additions & 0 deletions src/components/landing/LandingFullPageWrapper.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
"use client";

import { useFullPageScroll } from "@/hooks/useFullPageScroll";
import type { LandingFullPageWrapperProps } from "@/types/landing";

export function LandingFullPageWrapper({ children }: LandingFullPageWrapperProps) {
useFullPageScroll();
return <>{children}</>;
}
2 changes: 1 addition & 1 deletion src/components/landing/LandingHeroCtas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export function LandingHeroCtas() {
return (
<div className={cn("flex items-center flex-col gap-8 mt-10", "md:flex-row md:gap-12 md:mt-20")}>
<Link href="/login">
<Button preset="hero" pill>
<Button preset="hero" pill className="animate-bounce">
<span className="inline-flex items-center gap-4">
<Icon name="monitor" />
<span>Desktop</span>
Expand Down
2 changes: 1 addition & 1 deletion src/components/landing/LandingMainSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export function LandingMainSection({ children, className }: LandingMainSectionPr
className={cn(
"mx-auto w-full max-w-[128rem] px-6 mt-30",
"space-y-[25rem]",
"md:mt-45 lg:mt-70 md:space-y-[35rem]",
"md:mt-45 lg:mt-70 md:space-y-[50rem]",
className,
)}
>
Expand Down
37 changes: 26 additions & 11 deletions src/components/landing/LandingSpecialFeatureGrid.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// src/components/landing/LandingSpecialFeatureGrid.tsx
"use client";

import { SPECIAL_FEATURES } from "@/lib/constants";
Expand All @@ -6,23 +7,37 @@ import { Icon } from "@/shared/Icon";
import { SpecialFeatureCard } from "@/shared/SpecialFeatureCard";
import type { LandingSpecialFeatureGridProps } from "@/types/landing";

export function LandingSpecialFeatureGrid({ className }: LandingSpecialFeatureGridProps) {
export function LandingSpecialFeatureGrid({
className,
isInView = false,
}: LandingSpecialFeatureGridProps) {
return (
<div
className={cn(
"flex flex-col items-center gap-10",
"md:flex md:flex-row md:justify-center md:gap-8",
"flex flex-col items-center gap-10 md:flex-row md:justify-center md:gap-8",
className,
)}
>
{SPECIAL_FEATURES.map((feature) => (
<SpecialFeatureCard
key={feature.title}
icon={<Icon name={feature.iconName} size={28} />}
title={feature.title}
description={feature.description}
/>
))}
{SPECIAL_FEATURES.map((feature, index) => {
const delayMs = index * 200;

return (
<SpecialFeatureCard
key={feature.title}
icon={<Icon name={feature.iconName} size={28} />}
title={feature.title}
description={feature.description}
className={cn(
// 곡톡 νŠΈλžœμ§€μ…˜ + hover 효과
"transition-all duration-700 ease-out",
"hover:-translate-y-1 hover:shadow-[0_18px_40px_rgba(0,0,0,0.12)]",
// 슀크둀 μ§„μž… μ‹œ: μ•„λž˜μ—μ„œ μœ„λ‘œ + νŽ˜μ΄λ“œ 인
isInView ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8",
)}
style={{ transitionDelay: `${delayMs}ms` }}
/>
);
})}
</div>
);
}
20 changes: 14 additions & 6 deletions src/components/landing/LandingSpecialFeaturesSection.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,30 @@
"use client";

import { LandingSpecialFeatureGrid } from "@/components/landing/LandingSpecialFeatureGrid";
import { useInView } from "@/hooks/useInView";
import { cn } from "@/lib/utils";
import type { LandingSpecialFeaturesSectionProps } from "@/types/landing";

export function LandingSpecialFeaturesSection({ className }: LandingSpecialFeaturesSectionProps) {
const { ref, isInView } = useInView({ threshold: 0.5, once: false });

return (
<section
id="landing-special-features"
aria-labelledby="landing-special-features-title"
className={cn("bg-[var(--color-gray-50)] py-24 md:py-32", className)}
>
<div className="container space-y-12 md:space-y-16">
{/* μ„Ήμ…˜ 인트둜: 제λͺ© + λΆ€μ œ */}
<header className="space-y-4 text-center">
<div ref={ref} className="container space-y-12 md:space-y-16">
{/* μ„Ήμ…˜ 인트둜: 제λͺ© + λΆ€μ œ (μ•„λž˜ β†’ μœ„ μŠ¬λΌμ΄λ“œ) */}
<header
className={cn(
"space-y-4 text-center transition-all duration-1000 ease-out",
isInView ? "opacity-100 translate-y-0" : "opacity-0 translate-y-6",
)}
>
<h2
id="landing-special-features-title"
className="t-28-b md:t-40-b text-[var(--color-gray-900)]"
className="t-30-b md:t-40-b text-[var(--color-gray-900)]"
>
PlanMate의 νŠΉλ³„ν•œ κΈ°λŠ₯λ“€
</h2>
Expand All @@ -25,8 +33,8 @@ export function LandingSpecialFeaturesSection({ className }: LandingSpecialFeatu
</p>
</header>

{/* ν•˜λ‹¨ μΉ΄λ“œ κ·Έλ¦¬λ“œ */}
<LandingSpecialFeatureGrid />
{/* ν•˜λ‹¨ μΉ΄λ“œ κ·Έλ¦¬λ“œ (isInView 전달) */}
<LandingSpecialFeatureGrid isInView={isInView} />
</div>
</section>
);
Expand Down
97 changes: 97 additions & 0 deletions src/hooks/useFullPageScroll.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
"use client";

import { useEffect } from "react";

export function useFullPageScroll() {
useEffect(() => {
const sections = document.querySelectorAll<HTMLElement>(".scroll-section");
if (!sections.length) return;

let isScrolling = false;
let startY = 0;

const getClosestSectionIndex = () => {
const scrollY = window.scrollY;
let closestIndex = 0;
let smallestDiff = Number.POSITIVE_INFINITY;

sections.forEach((section, index) => {
const diff = Math.abs(section.offsetTop - scrollY);
if (diff < smallestDiff) {
smallestDiff = diff;
closestIndex = index;
}
});
return closestIndex;
};

const scrollToSection = (index: number) => {
const target = sections[index];
if (!target) return;

const sectionTop = target.offsetTop;
const sectionHeight = target.offsetHeight;
const viewportHeight = window.innerHeight;

const lastIndex = sections.length - 1;
const isLast = index === lastIndex;

// λ§ˆμ§€λ§‰ μ„Ήμ…˜λ§Œ μœ„ μ •λ ¬, λ‚˜λ¨Έμ§€λŠ” 쀑앙 μ •λ ¬
const targetY = isLast ? sectionTop : sectionTop - (viewportHeight - sectionHeight) / 2;

window.scrollTo({
top: targetY,
behavior: "smooth",
});
};

const moveOneSection = (direction: 1 | -1) => {
if (isScrolling) return;
isScrolling = true;

const currentIndex = getClosestSectionIndex();
const nextIndex = Math.min(Math.max(currentIndex + direction, 0), sections.length - 1);

scrollToSection(nextIndex);

setTimeout(() => {
isScrolling = false;
}, 900);
};

const handleWheel = (event: WheelEvent) => {
event.preventDefault();
const deltaY = event.deltaY;
if (Math.abs(deltaY) < 10) return;

const direction: 1 | -1 = deltaY > 0 ? 1 : -1;
moveOneSection(direction);
};

const handleTouchStart = (e: TouchEvent) => {
startY = e.touches[0].clientY;
};

const handleTouchEnd = (e: TouchEvent) => {
const endY = e.changedTouches[0].clientY;
const deltaY = startY - endY;
if (Math.abs(deltaY) < 50) return;

const direction: 1 | -1 = deltaY > 0 ? 1 : -1;
moveOneSection(direction);
};

window.addEventListener("wheel", handleWheel, { passive: false });
window.addEventListener("touchstart", handleTouchStart, { passive: true });
window.addEventListener("touchend", handleTouchEnd, { passive: true });

// 첫 μ§„μž… μ‹œ Hero μ„Ήμ…˜μœΌλ‘œ μŠ€λƒ…
scrollToSection(0);

return () => {
window.removeEventListener("wheel", handleWheel);
window.removeEventListener("touchstart", handleTouchStart);
window.removeEventListener("touchend", handleTouchEnd);
};
}, []);
}
36 changes: 36 additions & 0 deletions src/hooks/useInView.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { UseInViewOptions, UseInViewResult } from "@/types/inView";
import { useEffect, useRef, useState } from "react";

export function useInView(options: UseInViewOptions = {}): UseInViewResult {
const { threshold = 0.2, rootMargin = "0px", once = true } = options;

const ref = useRef<HTMLDivElement | null>(null);
const [isInView, setIsInView] = useState(false);
Comment on lines +1 to +8

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P0 Badge Broaden useInView ref type beyond HTMLDivElement

The hook currently creates ref with useRef<HTMLDivElement | null> and exposes that type. In this commit the hook is attached to <section> elements in LandingFeaturesSection1 and LandingFeaturesSection2, but under the repo’s strict TypeScript configuration a RefObject<HTMLDivElement> cannot be assigned to a section (RefObject<HTMLElement> is required). As a result the new components fail to compile. Consider widening the element type to HTMLElement or making the hook generic so it can be used with non‑div root elements.

Useful? React with πŸ‘Β / πŸ‘Ž.


useEffect(() => {
const node = ref.current;
if (!node) return;

const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setIsInView(true);
if (once) {
observer.unobserve(entry.target);
}
} else if (!once) {
setIsInView(false);
}
});
},
{ threshold, rootMargin },
);

observer.observe(node);

return () => observer.disconnect();
}, [threshold, rootMargin, once]);

return { ref, isInView };
}
6 changes: 6 additions & 0 deletions src/styles/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,12 @@
color-scheme: light;
} /* 라이트 κ³ μ • */

html,
body {
height: 100%;
scroll-behavior: smooth;
}

html {
font-size: 62.5%; /* 1rem = 10px */
font-family: var(--font-sans);
Expand Down
12 changes: 12 additions & 0 deletions src/types/inView.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { RefObject } from "react";

export interface UseInViewOptions {
threshold?: number;
rootMargin?: string;
once?: boolean;
}

export interface UseInViewResult {
ref: RefObject<HTMLDivElement | null>;
isInView: boolean;
}
11 changes: 10 additions & 1 deletion src/types/landing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,9 @@ export type LandingFeatureTextProps = WithClassName;
------------------------------------------------- */
export type LandingSpecialFeaturesSectionProps = WithClassName;

export type LandingSpecialFeatureGridProps = WithClassName;
export type LandingSpecialFeatureGridProps = WithClassName & {
isInView?: boolean;
};

/* -------------------------------------------------
νŠΉλ³„ν•œ κΈ°λŠ₯ μ„Ήμ…˜ β€” Feature 리슀트
Expand All @@ -93,3 +95,10 @@ export interface SpecialFeatureItem {
description: string;
iconName: SpecialFeatureIconName;
}

/* -------------------------------------------------
full page μ»΄ν¬λ„ŒνŠΈ
------------------------------------------------- */
export interface LandingFullPageWrapperProps {
children: React.ReactNode;
}