Skip to content

Commit 5849753

Browse files
committed
Merge branch 'email-course-landing-page' into plus
2 parents b910d96 + 1afea04 commit 5849753

File tree

10 files changed

+480
-0
lines changed

10 files changed

+480
-0
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
"fathom-client": "^3.7.2",
2323
"feed": "^4.2.2",
2424
"framer-motion": "^11.16.0",
25+
"motion": "^12.4.7",
2526
"next": "15.1.6",
2627
"open-graph-scraper-lite": "^2.1.0",
2728
"react": "^19.0.0",

pnpm-lock.yaml

Lines changed: 60 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
"use client";
2+
3+
import { useRef } from "react";
4+
import { useState } from "react";
5+
import { AnimatePresence, motion } from "motion/react";
6+
import { Button, Dialog, DialogBackdrop, DialogPanel } from "@headlessui/react";
7+
8+
export function SignUpForm() {
9+
return (
10+
<form className="flex" method="POST" action="https://app.kit.com/forms/7712177/subscriptions">
11+
<div className="flex max-w-full items-center rounded-full bg-white">
12+
<input
13+
required
14+
type="email"
15+
id="email"
16+
name="email_address"
17+
className="w-3xs min-w-0 shrink grow rounded-full bg-transparent px-4 py-2 text-sm/6 text-gray-950 focus:outline-none"
18+
placeholder="Enter your email"
19+
aria-label="Email address"
20+
/>
21+
<button className="mr-0.5 shrink-0 overflow-hidden rounded-full bg-gray-950 px-3 py-1.5 text-sm/6 font-semibold text-nowrap text-white hover:bg-gray-950/85">
22+
Get the course
23+
</button>
24+
</div>
25+
</form>
26+
);
27+
}
28+
29+
export function HeroActions({
30+
onWatchPreview = () => {},
31+
onClosePreview = () => {},
32+
}: {
33+
onWatchPreview?: () => void;
34+
onClosePreview?: () => void;
35+
}) {
36+
let [signUpState, setSignUpState] = useState<"closed" | "open">("closed");
37+
let input = useRef<HTMLInputElement>(null);
38+
let getCourseButton = useRef(null);
39+
let containerRef = useRef<HTMLInputElement>(null);
40+
let [isDialogOpen, setIsDialogOpen] = useState(false);
41+
42+
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
43+
if (!input.current) return;
44+
45+
// Check if the related target (where focus is going) is within our container
46+
if (input.current.value.length === 0 && !containerRef.current?.contains(e.relatedTarget)) {
47+
setSignUpState("closed");
48+
}
49+
};
50+
51+
return (
52+
<div className="group relative flex gap-4">
53+
<div className="isolate flex items-center">
54+
<motion.div
55+
ref={containerRef}
56+
layout
57+
transition={{ duration: signUpState === "open" ? 0.1 : 0.2, ease: "circOut" }}
58+
className="relative flex items-center bg-white data-[state=open]:overflow-hidden"
59+
style={{ borderRadius: 20 }}
60+
data-state={signUpState}
61+
>
62+
<AnimatePresence mode="popLayout" initial={false}>
63+
{signUpState === "open" && (
64+
<motion.form
65+
action="https://app.kit.com/forms/7712177/subscriptions"
66+
method="POST"
67+
className="flex items-center"
68+
layout
69+
onKeyDown={(e) => {
70+
if (e.key === "Escape" && input.current) {
71+
input.current.value = "";
72+
setSignUpState("closed");
73+
}
74+
}}
75+
exit={{
76+
opacity: 0,
77+
transition: {
78+
duration: 0.2,
79+
},
80+
}}
81+
initial={{
82+
opacity: 0,
83+
}}
84+
animate={{
85+
opacity: 1,
86+
}}
87+
transition={{ duration: 0.1 }}
88+
>
89+
<input
90+
autoFocus
91+
required
92+
ref={input}
93+
onBlur={handleBlur}
94+
type="email"
95+
name="email_address"
96+
className="w-3xs rounded-full bg-transparent px-4 py-2 text-sm/6 text-gray-950 focus:outline-none"
97+
placeholder="Enter your email"
98+
aria-label="Email address"
99+
/>
100+
<button
101+
type="submit"
102+
className="mr-0.5 shrink-0 overflow-hidden rounded-full bg-gray-950 px-3 py-1.5 text-sm/6 font-semibold text-nowrap text-white hover:bg-gray-950/85"
103+
>
104+
Sign up
105+
</button>
106+
</motion.form>
107+
)}
108+
{signUpState === "closed" && (
109+
<motion.button
110+
layout
111+
ref={getCourseButton}
112+
key="get-course-button"
113+
onClick={() => {
114+
setSignUpState("open");
115+
}}
116+
type="button"
117+
className="inline-flex rounded-full bg-transparent px-4 py-2 text-sm/6 font-semibold text-gray-950 hover:bg-gray-100 focus:outline-none focus-visible:outline focus-visible:outline-offset-2 focus-visible:outline-white focus-visible:outline-solid"
118+
exit={{
119+
opacity: 0,
120+
transition: {
121+
duration: 0.05,
122+
},
123+
}}
124+
initial={{
125+
opacity: 0,
126+
}}
127+
animate={{
128+
opacity: 1,
129+
}}
130+
transition={{ duration: 0.2 }}
131+
>
132+
Get the free course &rarr;
133+
</motion.button>
134+
)}
135+
</AnimatePresence>
136+
</motion.div>
137+
</div>
138+
<AnimatePresence initial={false}>
139+
{signUpState === "closed" && (
140+
<Button
141+
as={motion.button}
142+
onClick={() => {
143+
onWatchPreview();
144+
setIsDialogOpen(true);
145+
}}
146+
layout
147+
transition={{ duration: 0.3 }}
148+
className="inline-flex flex-nowrap items-baseline gap-1.5 self-center rounded-full bg-white/25 px-4 py-2 pl-3 text-sm/6 font-semibold whitespace-nowrap text-white hover:bg-white/30 focus:not-data-focus:outline-none"
149+
initial={{
150+
opacity: 0,
151+
}}
152+
animate={{
153+
opacity: 1,
154+
}}
155+
exit={{
156+
opacity: 0,
157+
}}
158+
>
159+
<svg width={20} height={20} fill="none" className="self-center stroke-white">
160+
<path d="M19 10a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
161+
<path
162+
d="M13.91 9.67a.37.37 0 0 1 0 .66l-5.6 3.11a.38.38 0 0 1-.56-.33V6.9c0-.29.3-.47.56-.33l5.6 3.11Z"
163+
strokeLinecap="round"
164+
strokeLinejoin="round"
165+
/>
166+
</svg>
167+
<span>
168+
Watch <span className="max-sm:hidden">the</span> intro <span className="max-sm:hidden">video</span>
169+
</span>
170+
</Button>
171+
)}
172+
</AnimatePresence>
173+
<Dialog
174+
open={isDialogOpen}
175+
onClose={() => {
176+
setIsDialogOpen(false);
177+
onClosePreview();
178+
}}
179+
>
180+
<DialogBackdrop className="fixed inset-0 bg-black/85" />
181+
<div className="fixed inset-0 grid place-items-center">
182+
<DialogPanel className="w-full max-w-7xl p-4 sm:p-8">
183+
<video autoPlay controls className="aspect-video w-full rounded-2xl">
184+
<source src="https://assets.tailwindcss.com/build-uis-that-dont-suck/intro.mp4" type="video/mp4" />
185+
</video>
186+
</DialogPanel>
187+
</div>
188+
</Dialog>
189+
</div>
190+
);
191+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { Logo } from "@/components/logo";
2+
import { GridContainer } from "../grid-container";
3+
4+
export default async function Course() {
5+
return (
6+
<div className="dark relative grid min-h-dvh grid-cols-1 place-items-center px-4 py-8 sm:px-0">
7+
<div>
8+
<GridContainer>
9+
<div className="flex justify-center p-2">
10+
<Logo className="h-7" />
11+
</div>
12+
</GridContainer>
13+
<div className="mt-6 space-y-4">
14+
<GridContainer>
15+
<h1 className="text-center text-5xl tracking-tighter text-balance text-white lg:text-8xl">You’re in!</h1>
16+
</GridContainer>
17+
<GridContainer>
18+
<p className="mx-auto max-w-xl text-center text-lg/7 font-medium text-pretty text-gray-400">
19+
Look for the first video in your inbox any minute, and check again every morning for the next one in the
20+
series.
21+
</p>
22+
</GridContainer>
23+
</div>
24+
</div>
25+
</div>
26+
);
27+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import BaseContainer from "@/components/grid-container";
2+
3+
export function GridContainer({ children }: { children: React.ReactNode }) {
4+
return (
5+
<BaseContainer>
6+
<div className="px-0 py-2 sm:px-2">{children}</div>
7+
</BaseContainer>
8+
);
9+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
"use client";
2+
3+
import { Logo } from "@/components/logo";
4+
import { GridContainer } from "./grid-container";
5+
import { HeroActions } from "./call-to-action";
6+
import { useRef } from "react";
7+
8+
export function HeroSection() {
9+
let videoRef = useRef<HTMLVideoElement>(null);
10+
11+
return (
12+
<>
13+
<div aria-hidden="true" className="absolute inset-x-0 top-0 left-1/5 -z-10 aspect-video opacity-50">
14+
<video ref={videoRef} autoPlay loop muted playsInline className="absolute size-full object-right">
15+
<source src="https://assets.tailwindcss.com/build-uis-that-dont-suck/hero-loop.mp4" type="video/mp4" />
16+
Your browser does not support the video tag.
17+
</video>
18+
<div className="absolute inset-0 size-full bg-linear-to-r from-gray-950 to-75%"></div>
19+
<div className="absolute inset-0 size-full bg-linear-to-t from-gray-950 to-50%"></div>
20+
</div>
21+
<GridContainer>
22+
<div className="p-2">
23+
<Logo className="h-7" />
24+
</div>
25+
</GridContainer>
26+
<div className="mt-20 flex flex-col gap-4 sm:mt-24">
27+
<GridContainer>
28+
<p className="font-mono text-sm/6 tracking-wider text-gray-400 uppercase">5-part mini-course</p>
29+
<h1 className="mt-2 text-5xl tracking-tighter text-balance text-white sm:text-8xl">
30+
Build UIs that don’t suck.
31+
</h1>
32+
</GridContainer>
33+
<GridContainer>
34+
<p className="max-w-2xl text-lg/7 font-medium text-gray-400">
35+
<strong className="font-medium text-white">Short, tactical video lessons</strong> from the creator of
36+
Tailwind CSS, delivered directly to your inbox{" "}
37+
<strong className="font-medium text-white">every day for a week</strong>.
38+
</p>
39+
</GridContainer>
40+
<GridContainer>
41+
<HeroActions
42+
onWatchPreview={() => {
43+
videoRef.current?.pause();
44+
}}
45+
onClosePreview={() => {
46+
videoRef.current?.play();
47+
}}
48+
/>
49+
</GridContainer>
50+
</div>
51+
</>
52+
);
53+
}

0 commit comments

Comments
 (0)