Skip to content

Commit 511bcfc

Browse files
committed
feat(web): add oma showcase, mascot, and polish landing animations
1 parent 1d154c6 commit 511bcfc

30 files changed

Lines changed: 1149 additions & 137 deletions

app/favicon.ico

-25.3 KB
Binary file not shown.

app/globals.css

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,16 @@ html {
5757
scroll-behavior: smooth;
5858
}
5959

60+
@media (prefers-reduced-motion: no-preference) and (min-width: 1024px) {
61+
html {
62+
scroll-snap-type: y proximity;
63+
}
64+
#top {
65+
scroll-snap-align: start;
66+
scroll-snap-stop: normal;
67+
}
68+
}
69+
6070
@utility container-prose {
6171
max-width: 56ch;
6272
margin-inline: auto;

app/layout.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,12 @@ export default function RootLayout({
6969
return (
7070
<html lang="ko" className="h-full">
7171
<body className="min-h-full antialiased flex flex-col">
72+
<script
73+
dangerouslySetInnerHTML={{
74+
__html:
75+
"if('scrollRestoration' in history){history.scrollRestoration='manual';if(!location.hash){window.scrollTo(0,0);}}",
76+
}}
77+
/>
7278
{children}
7379
<Analytics />
7480
<script

app/page.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ import { Header } from "@/components/site/header";
22
import { Hero } from "@/components/site/hero";
33
import { CompanyIntro } from "@/components/site/company-intro";
44
import { SolutionsGrid } from "@/components/site/solutions-grid";
5+
import { OmaSection } from "@/components/site/oma-section";
56
import { ContactSection } from "@/components/site/contact-section";
67
import { Footer } from "@/components/site/footer";
8+
import { BackToTop } from "@/components/site/back-to-top";
79

810
export default function Home() {
911
return (
@@ -12,10 +14,12 @@ export default function Home() {
1214
<main className="flex-1">
1315
<Hero />
1416
<CompanyIntro />
17+
<OmaSection />
1518
<SolutionsGrid />
1619
<ContactSection />
1720
</main>
1821
<Footer />
22+
<BackToTop />
1923
</>
2024
);
2125
}

components/site/back-to-top.tsx

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
"use client";
2+
3+
import { useEffect, useState } from "react";
4+
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
5+
6+
export function BackToTop() {
7+
const [visible, setVisible] = useState(false);
8+
const reduceMotion = useReducedMotion();
9+
10+
useEffect(() => {
11+
const handler = () => setVisible(window.scrollY > 600);
12+
handler();
13+
window.addEventListener("scroll", handler, { passive: true });
14+
return () => window.removeEventListener("scroll", handler);
15+
}, []);
16+
17+
const handleClick = () => {
18+
window.scrollTo({
19+
top: 0,
20+
behavior: reduceMotion ? "auto" : "smooth",
21+
});
22+
};
23+
24+
return (
25+
<AnimatePresence>
26+
{visible && (
27+
<motion.button
28+
type="button"
29+
onClick={handleClick}
30+
aria-label="맨 위로"
31+
className="fixed right-6 bottom-6 z-40 flex h-11 w-11 cursor-pointer items-center justify-center rounded-full bg-[var(--color-primary)] text-white shadow-[0_8px_24px_-4px_rgba(15,76,58,0.4)] transition-[background-color,transform] duration-200 ease-out hover:scale-105 hover:bg-[var(--color-primary)]/90 active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-primary)]/50 focus-visible:ring-offset-2 md:right-8 md:bottom-8 md:h-12 md:w-12"
32+
initial={{ opacity: 0, scale: 0.8, y: 10 }}
33+
animate={{ opacity: 1, scale: 1, y: 0 }}
34+
exit={{ opacity: 0, scale: 0.8, y: 10 }}
35+
transition={{ duration: 0.2, ease: "easeOut" }}
36+
>
37+
<svg
38+
xmlns="http://www.w3.org/2000/svg"
39+
width={18}
40+
height={18}
41+
viewBox="0 0 24 24"
42+
fill="none"
43+
stroke="currentColor"
44+
strokeWidth={2.5}
45+
strokeLinecap="round"
46+
strokeLinejoin="round"
47+
aria-hidden
48+
>
49+
<path d="M12 19V5" />
50+
<path d="m5 12 7-7 7 7" />
51+
</svg>
52+
</motion.button>
53+
)}
54+
</AnimatePresence>
55+
);
56+
}

components/site/company-intro.tsx

Lines changed: 91 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,102 @@
1+
"use client";
2+
3+
import { useRef } from "react";
4+
import Image from "next/image";
5+
import { motion, useReducedMotion, useScroll, useTransform } from "motion/react";
6+
import { SectionHeadingUnderline } from "@/components/site/section-heading-underline";
7+
8+
const PARAGRAPHS = [
9+
{
10+
className: "font-medium",
11+
text: "AI와 기술로 더 나은 일상을 만드는 팀입니다.",
12+
},
13+
{
14+
className: "",
15+
text: "퍼스트플루크는 기능을 많이 담는 서비스보다, 사람들이 오래 쓰는 경험을 고민합니다. 복잡한 기술을 전면에 드러내는 대신, 사용자가 편하게 이해하고 쓸 수 있도록 구조와 흐름을 설계합니다.",
16+
},
17+
{
18+
className: "",
19+
text: "AI, 콘텐츠, 자동화를 기반으로 다양한 디지털 제품과 서비스를 기획하고 개발합니다. 아이디어 단계부터 실제 사용까지 이어지는, 실행 중심의 프로덕트를 만듭니다.",
20+
},
21+
{
22+
className: "pt-2 font-medium text-[var(--color-primary)]",
23+
text: "기술 자체보다 ‘왜 필요한가’와 ‘어떤 경험을 남기는가’를 먼저 생각합니다.",
24+
},
25+
];
26+
127
export function CompanyIntro() {
28+
const reduceMotion = useReducedMotion();
29+
const sectionRef = useRef<HTMLElement>(null);
30+
const { scrollYProgress } = useScroll({
31+
target: sectionRef,
32+
offset: ["start end", "end start"],
33+
});
34+
const cloverY = useTransform(scrollYProgress, [0, 1], [40, -80]);
35+
36+
const container = {
37+
hidden: {},
38+
show: reduceMotion
39+
? { transition: { staggerChildren: 0 } }
40+
: { transition: { staggerChildren: 0.1, delayChildren: 0.05 } },
41+
};
42+
43+
const item = {
44+
hidden: reduceMotion ? { opacity: 0 } : { opacity: 0, y: 14 },
45+
show: reduceMotion
46+
? { opacity: 1, transition: { duration: 0.3 } }
47+
: {
48+
opacity: 1,
49+
y: 0,
50+
transition: { duration: 0.5, ease: [0.22, 1, 0.36, 1] as const },
51+
},
52+
};
53+
254
return (
355
<section
56+
ref={sectionRef}
457
id="about"
5-
className="border-y border-[var(--color-border)] bg-white py-20 md:py-28"
58+
className="relative overflow-hidden border-t border-[var(--color-border)] bg-white py-20 md:py-32 lg:py-44"
659
>
7-
<div className="mx-auto w-full max-w-6xl px-6 md:px-12">
8-
<div className="container-prose">
9-
<h2 className="text-2xl font-bold text-[var(--color-primary)] md:text-3xl">
10-
FIRST FLUKE란
11-
</h2>
12-
13-
<div className="mt-8 space-y-7 text-[17px] leading-[1.75] text-[var(--color-fg)] md:text-lg md:leading-[1.7]">
14-
<p className="font-medium">
15-
AI와 기술로 더 나은 일상을 만드는 팀입니다.
16-
</p>
17-
18-
<p>
19-
퍼스트플루크는 기능을 많이 담는 서비스보다, 사람들이 오래 쓰는
20-
경험을 고민합니다. 복잡한 기술을 전면에 드러내는 대신, 사용자가
21-
편하게 이해하고 쓸 수 있도록 구조와 흐름을 설계합니다.
60+
<motion.div
61+
aria-hidden
62+
className="pointer-events-none absolute -right-20 -bottom-24 select-none md:-right-24 md:-bottom-32"
63+
style={reduceMotion ? undefined : { y: cloverY }}
64+
>
65+
<Image
66+
src="/logo.png"
67+
alt=""
68+
width={720}
69+
height={720}
70+
unoptimized
71+
className="h-[360px] w-[360px] opacity-[0.09] motion-safe:animate-[spin_120s_linear_infinite] md:h-[640px] md:w-[640px]"
72+
/>
73+
</motion.div>
74+
<div className="relative mx-auto w-full max-w-6xl px-6 md:px-12">
75+
<motion.div
76+
className="grid gap-10 lg:grid-cols-[minmax(220px,1fr)_minmax(560px,2.5fr)] lg:gap-20"
77+
variants={container}
78+
initial="hidden"
79+
whileInView="show"
80+
viewport={{ once: true, amount: 0.3 }}
81+
>
82+
<motion.div variants={item}>
83+
<p className="text-[13px] font-semibold uppercase tracking-[0.18em] text-[var(--color-primary)] md:text-sm">
84+
About Us
2285
</p>
86+
<h2 className="mt-3 text-2xl font-bold text-[var(--color-primary)] md:text-3xl lg:text-4xl">
87+
FIRST FLUKE
88+
</h2>
89+
<SectionHeadingUnderline />
90+
</motion.div>
2391

24-
<p>
25-
AI, 콘텐츠, 자동화를 기반으로 다양한 디지털 제품과 서비스를
26-
기획하고 개발합니다. 아이디어 단계부터 실제 사용까지 이어지는,
27-
실행 중심의 프로덕트를 만듭니다.
28-
</p>
29-
30-
<p className="pt-2 font-medium text-[var(--color-primary)]">
31-
기술 자체보다 ‘왜 필요한가’와 ‘어떤 경험을 남기는가’를 먼저
32-
생각합니다.
33-
</p>
92+
<div className="space-y-7 break-keep text-[17px] leading-[1.75] text-[var(--color-fg)] md:space-y-9 md:text-lg md:leading-[1.75] lg:space-y-10 lg:text-xl lg:leading-[1.8]">
93+
{PARAGRAPHS.map((p, i) => (
94+
<motion.p key={i} variants={item} className={p.className}>
95+
{p.text}
96+
</motion.p>
97+
))}
3498
</div>
35-
</div>
99+
</motion.div>
36100
</div>
37101
</section>
38102
);

components/site/contact-form.tsx

Lines changed: 72 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"use client";
22

33
import * as React from "react";
4+
import { motion, useReducedMotion } from "motion/react";
45
import { Button } from "@/components/ui/button";
56
import { Input } from "@/components/ui/input";
67
import { Textarea } from "@/components/ui/textarea";
@@ -21,6 +22,29 @@ export function ContactForm() {
2122

2223
const isSubmitting = status === "submitting";
2324
const isDisabled = !agree || isSubmitting;
25+
const reduceMotion = useReducedMotion();
26+
const isFormValid = React.useMemo(
27+
() => ContactFormSchema.safeParse({ email, message, agree, _hp: hp }).success,
28+
[email, message, agree, hp],
29+
);
30+
const shouldPulse = isFormValid && !isSubmitting && !reduceMotion;
31+
32+
const fieldStagger = {
33+
hidden: {},
34+
show: reduceMotion
35+
? { transition: { staggerChildren: 0 } }
36+
: { transition: { staggerChildren: 0.07, delayChildren: 0.02 } },
37+
};
38+
const fieldItem = {
39+
hidden: reduceMotion ? { opacity: 0 } : { opacity: 0, y: 12 },
40+
show: reduceMotion
41+
? { opacity: 1, transition: { duration: 0.3 } }
42+
: {
43+
opacity: 1,
44+
y: 0,
45+
transition: { duration: 0.45, ease: [0.22, 1, 0.36, 1] as const },
46+
},
47+
};
2448

2549
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
2650
event.preventDefault();
@@ -85,7 +109,15 @@ export function ContactForm() {
85109
}
86110

87111
return (
88-
<form onSubmit={handleSubmit} noValidate className="flex flex-col gap-5">
112+
<motion.form
113+
onSubmit={handleSubmit}
114+
noValidate
115+
className="flex flex-col gap-5"
116+
variants={fieldStagger}
117+
initial="hidden"
118+
whileInView="show"
119+
viewport={{ once: true, amount: 0.2 }}
120+
>
89121
{serverError && (
90122
<div
91123
role="alert"
@@ -95,7 +127,7 @@ export function ContactForm() {
95127
</div>
96128
)}
97129

98-
<div className="flex flex-col gap-2">
130+
<motion.div variants={fieldItem} className="flex flex-col gap-2">
99131
<label
100132
htmlFor="contact-email"
101133
className="text-sm font-medium text-[var(--color-fg)]"
@@ -123,9 +155,9 @@ export function ContactForm() {
123155
{errors.email}
124156
</p>
125157
)}
126-
</div>
158+
</motion.div>
127159

128-
<div className="flex flex-col gap-2">
160+
<motion.div variants={fieldItem} className="flex flex-col gap-2">
129161
<label
130162
htmlFor="contact-message"
131163
className="text-sm font-medium text-[var(--color-fg)]"
@@ -134,8 +166,8 @@ export function ContactForm() {
134166
</label>
135167
<Textarea
136168
id="contact-message"
137-
placeholder="필요한 일을 알려주세요."
138-
rows={6}
169+
placeholder="문의 내용을 알려주세요."
170+
rows={4}
139171
value={message}
140172
onChange={(e) => setMessage(e.target.value)}
141173
aria-invalid={Boolean(errors.message)}
@@ -153,7 +185,7 @@ export function ContactForm() {
153185
{errors.message}
154186
</p>
155187
)}
156-
</div>
188+
</motion.div>
157189

158190
{/* Honeypot — hidden from users, bots fill it */}
159191
<input
@@ -167,7 +199,7 @@ export function ContactForm() {
167199
className="absolute left-[-9999px] h-0 w-0 opacity-0"
168200
/>
169201

170-
<div className="flex flex-col gap-2">
202+
<motion.div variants={fieldItem} className="flex flex-col gap-2">
171203
<Checkbox
172204
checked={agree}
173205
onChange={(e) => setAgree(e.target.checked)}
@@ -191,17 +223,38 @@ export function ContactForm() {
191223
{errors.agree}
192224
</p>
193225
)}
194-
</div>
226+
</motion.div>
195227

196-
<Button
197-
type="submit"
198-
size="lg"
199-
disabled={isDisabled}
200-
aria-disabled={isDisabled}
201-
className="self-start"
202-
>
203-
{isSubmitting ? "보내는 중…" : "보내기"}
204-
</Button>
205-
</form>
228+
<motion.div variants={fieldItem} className="self-start">
229+
<motion.div
230+
animate={
231+
shouldPulse
232+
? {
233+
boxShadow: [
234+
"0 0 0 0 rgba(15,84,64,0.0)",
235+
"0 0 0 10px rgba(15,84,64,0.18)",
236+
"0 0 0 0 rgba(15,84,64,0.0)",
237+
],
238+
}
239+
: { boxShadow: "0 0 0 0 rgba(15,84,64,0.0)" }
240+
}
241+
transition={
242+
shouldPulse
243+
? { duration: 1.6, repeat: Infinity, ease: "easeOut" }
244+
: { duration: 0.2 }
245+
}
246+
style={{ borderRadius: 9999 }}
247+
>
248+
<Button
249+
type="submit"
250+
size="lg"
251+
disabled={isDisabled}
252+
aria-disabled={isDisabled}
253+
>
254+
{isSubmitting ? "보내는 중…" : "보내기"}
255+
</Button>
256+
</motion.div>
257+
</motion.div>
258+
</motion.form>
206259
);
207260
}

0 commit comments

Comments
 (0)