Skip to content
Merged
Binary file added public/zerowoni_walk.webm
Binary file not shown.
39 changes: 39 additions & 0 deletions src/app/(landing)/class/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { Metadata } from 'next';

export const metadata: Metadata = {
title: '바이브 코딩 입문자 코스 | ZERO-ONE',
description: '코딩 몰라도 OK. 따라만 하면 나도 AI 시대의 경쟁력.',
alternates: { canonical: 'https://www.zeroone.it.kr/class' },
robots: {
index: true,
follow: true,
'max-image-preview': 'large',
'max-snippet': -1,
'max-video-preview': -1,
},
openGraph: {
title: '바이브 코딩 입문자 코스 | ZERO-ONE',
description: '코딩 몰라도 OK. 따라만 하면 나도 AI 시대의 경쟁력.',
url: 'https://www.zeroone.it.kr/class',
images: [
{
url: 'https://www.zeroone.it.kr/images/og-image.png',
width: 1200,
height: 630,
alt: '바이브 코딩 입문자 코스 | ZERO-ONE',
},
],
},
twitter: {
title: '바이브 코딩 입문자 코스 | ZERO-ONE',
description: '코딩 몰라도 OK. 따라만 하면 나도 AI 시대의 경쟁력.',
},
};

export default function ClassLayout({
children,
}: {
children: React.ReactNode;
}) {
return <>{children}</>;
}
5 changes: 2 additions & 3 deletions src/app/(landing)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,7 @@ import { getOrganizationSchema, getWebsiteSchema } from '@/utils/seo';

export const metadata: Metadata = {
title: 'ZERO-ONE - 1:1 기상 스터디 플랫폼',
description:
'매일 아침을 함께 시작하는 1:1 기상 스터디 플랫폼. 현직 멘토와 함께 성장하세요.',
description: '코딩 몰라도 OK. 따라만 하면 나도 AI 시대의 경쟁력.',
keywords: ['스터디', '기상', '멘토링', '1:1 스터디', '개발자', '면접 준비'],
icons: {
icon: '/favicon.ico',
Expand All @@ -34,7 +33,7 @@ export const metadata: Metadata = {
url: 'https://www.zeroone.it.kr',
siteName: 'ZERO-ONE',
title: 'ZERO-ONE - 1:1 기상 스터디 플랫폼',
description: '매일 아침을 함께 시작하는 1:1 기상 스터디 플랫폼',
description: '코딩 몰라도 OK. 따라만 하면 나도 AI 시대의 경쟁력.',
images: [
{
url: 'https://www.zeroone.it.kr/images/banner.png',
Expand Down
36 changes: 27 additions & 9 deletions src/app/(landing)/page.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,9 @@
import { Metadata } from 'next';
import LandingContent from '@/components/pages/landing/landing-content';
import { generateMetadata as generateSEOMetadata } from '@/utils/seo';

export const metadata: Metadata = generateSEOMetadata({
title: 'ZERO-ONE - 바이브코더 X 개발자 성장 루프',
description:
'AI로 해결이 어려운 문제는 바이브코더의 답변으로. 개발자는 활동하면 돈이 되는 실전 커뮤니티/스터디 플랫폼입니다.',
path: '/',
ogImage: 'https://www.zeroone.it.kr/images/og-image.png',
export const metadata: Metadata = {
title: 'ZERO-ONE | 따라만 하면 완성되는 바이브 코딩',
description: '코딩 몰라도 OK. 따라만 하면 나도 AI 시대의 경쟁력.',
keywords: [
'바이브코더',
'개발자 커뮤니티',
Expand All @@ -18,8 +14,30 @@ export const metadata: Metadata = generateSEOMetadata({
'인사이트',
'주니어 개발자',
],
canonicalUrl: 'https://www.zeroone.it.kr/',
});
alternates: { canonical: 'https://www.zeroone.it.kr' },
robots: {
index: true,
follow: true,
'max-image-preview': 'large',
'max-snippet': -1,
'max-video-preview': -1,
},
openGraph: {
type: 'website',
url: 'https://www.zeroone.it.kr',
title: 'ZERO-ONE | 따라만 하면 완성되는 바이브 코딩',
description: '코딩 몰라도 OK. 따라만 하면 나도 AI 시대의 경쟁력.',
siteName: 'ZERO-ONE',
images: [
{
url: 'https://www.zeroone.it.kr/images/og-image.png',
width: 1200,
height: 630,
alt: 'ZERO-ONE | 따라만 하면 완성되는 바이브 코딩',
},
],
},
};

export default async function Landing() {
return <LandingContent />;
Expand Down
11 changes: 11 additions & 0 deletions src/app/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,17 @@ https://velog.io/@oneook/tailwindcss-4.0-%EB%AC%B4%EC%97%87%EC%9D%B4-%EB%8B%AC%E
--spacing-7925: 634px;
--spacing-9175: 734px;
--spacing-8875: 710px;
--spacing-62: 5px;
--spacing-112: 9px;
--spacing-587: 47px;
--spacing-950: 76px;
--spacing-1062: 85px;
--spacing-1112: 89px;
--spacing-1662: 133px;
--spacing-1837: 147px;
--spacing-2225: 178px;
--spacing-2900: 232px;
--spacing-3625: 290px;

/* fluid layout */
--min-h-hero-section: clamp(260px, 30.2vw, 580px);
Expand Down
109 changes: 58 additions & 51 deletions src/components/pages/class/benefit-scroll-character.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,21 @@
'use client';

import { motion, useSpring } from 'framer-motion';
import { motion, useReducedMotion, useSpring } from 'framer-motion';
import Image from 'next/image';
import { useEffect, useRef, useState } from 'react';

const CHAR_HEIGHT = 223;
const CHAR_TOP_OFFSET = 38;

export function BenefitScrollCharacter() {
interface Props {
activeIndex: number;
}

export function BenefitScrollCharacter({ activeIndex }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const cardsRef = useRef<HTMLElement[]>([]);
const [activeIndex, setActiveIndex] = useState(0);
const [cardYOffsets, setCardYOffsets] = useState<number[]>([]);
const videoRef = useRef<HTMLVideoElement>(null);
const reducedMotion = useReducedMotion();

const springY = useSpring(0, { stiffness: 80, damping: 22 });

Expand All @@ -27,37 +32,16 @@ export function BenefitScrollCharacter() {
cardsRef.current = cards;

const computeOffsets = () =>
cards.map((card) => card.offsetTop + card.offsetHeight - CHAR_HEIGHT);
cards.map((card) => card.offsetTop + CHAR_TOP_OFFSET);

const updateOffsets = () => setCardYOffsets(computeOffsets());
updateOffsets();

const ro = new ResizeObserver(updateOffsets);
cards.forEach((card) => ro.observe(card));

const handleScroll = () => {
const viewportMid = window.innerHeight / 2;
let bestIdx = 0;
let bestDist = Infinity;

cardsRef.current.forEach((card, i) => {
const rect = card.getBoundingClientRect();
const dist = Math.abs(rect.top + rect.height / 2 - viewportMid);
if (dist < bestDist) {
bestDist = dist;
bestIdx = i;
}
});

setActiveIndex(bestIdx);
};

handleScroll();
window.addEventListener('scroll', handleScroll, { passive: true });

return () => {
ro.disconnect();
window.removeEventListener('scroll', handleScroll);
};
}, []);

Expand All @@ -84,20 +68,46 @@ export function BenefitScrollCharacter() {
};
}, []);

// Pause on prefers-reduced-motion
useEffect(() => {
if (!videoRef.current) return;
if (reducedMotion) {
videoRef.current.pause();
} else {
videoRef.current.play().catch(() => {});
}
}, [reducedMotion]);

// Pause when off-screen; re-evaluates when reducedMotion changes
useEffect(() => {
const el = containerRef.current;
if (!el || !videoRef.current) return;
const io = new IntersectionObserver(
([entry]) => {
if (!videoRef.current) return;
if (entry.isIntersecting && !reducedMotion) {
videoRef.current.play().catch(() => {});
} else {
videoRef.current.pause();
}
},
{ threshold: 0 },
);
io.observe(el);
return () => io.disconnect();
}, [reducedMotion]);

return (
<div
ref={containerRef}
className="absolute inset-0 hidden pointer-events-none lg:block"
className="pointer-events-none absolute inset-0 hidden lg:block"
>
<motion.div
style={{ y: springY }}
className="absolute right-0 top-0 pointer-events-none"
className="pointer-events-none absolute right-300 top-0"
>
{/* Left outer shadow */}
<div
className="absolute"
style={{ left: 50, top: 192, width: 90, height: 9 }}
>
<div className="absolute left-525 top-3625 h-112 w-950">
<div
style={{
position: 'absolute',
Expand All @@ -116,10 +126,7 @@ export function BenefitScrollCharacter() {
</div>
</div>
{/* Left inner shadow */}
<div
className="absolute"
style={{ left: 67, top: 192, width: 56, height: 9 }}
>
<div className="absolute left-700 top-3625 h-112 w-587">
<div
style={{
position: 'absolute',
Expand All @@ -138,10 +145,7 @@ export function BenefitScrollCharacter() {
</div>
</div>
{/* Right outer shadow */}
<div
className="absolute"
style={{ left: 158, top: 192, width: 90, height: 9 }}
>
<div className="absolute left-1662 top-3625 h-112 w-950">
<div
style={{
position: 'absolute',
Expand All @@ -160,10 +164,7 @@ export function BenefitScrollCharacter() {
</div>
</div>
{/* Right inner shadow */}
<div
className="absolute"
style={{ left: 175, top: 192, width: 56, height: 9 }}
>
<div className="absolute left-1837 top-3625 h-112 w-587">
<div
style={{
position: 'absolute',
Expand All @@ -181,13 +182,19 @@ export function BenefitScrollCharacter() {
/>
</div>
</div>
<Image
src="/class/detail/character-cats.png"
alt=""
width={298}
height={223}
priority={false}
/>
<video
ref={videoRef}
autoPlay
loop
muted
playsInline
width={251}
height={337}
aria-hidden="true"
className="block"
>
<source src="/zerowoni_walk.webm" type="video/webm" />
</video>
</motion.div>
</div>
);
Expand Down
Loading
Loading