Skip to content

Commit 15e1de4

Browse files
feat(footer): refactor email subscription component with expanded state
1 parent 5ce6d07 commit 15e1de4

File tree

1 file changed

+140
-148
lines changed

1 file changed

+140
-148
lines changed

apps/web/src/components/footer.tsx

Lines changed: 140 additions & 148 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,9 @@ import { useForm } from "@tanstack/react-form";
22
import { useMutation } from "@tanstack/react-query";
33
import { Link, useRouterState } from "@tanstack/react-router";
44
import { ArrowRightIcon, ExternalLinkIcon, MailIcon } from "lucide-react";
5-
import { useState } from "react";
5+
import { useEffect, useRef, useState } from "react";
66

77
import { Checkbox } from "@hypr/ui/components/ui/checkbox";
8-
import {
9-
Popover,
10-
PopoverContent,
11-
PopoverTrigger,
12-
} from "@hypr/ui/components/ui/popover";
138
import { cn } from "@hypr/utils";
149

1510
import { Image } from "@/components/image";
@@ -68,13 +63,30 @@ export function Footer() {
6863
}
6964

7065
function BrandSection({ currentYear }: { currentYear: number }) {
71-
const [popoverOpen, setPopoverOpen] = useState(false);
66+
const [expanded, setExpanded] = useState(false);
7267
const [email, setEmail] = useState("");
7368
const [subscriptions, setSubscriptions] = useState({
7469
releaseNotesStable: false,
7570
releaseNotesBeta: false,
7671
newsletter: false,
7772
});
73+
const containerRef = useRef<HTMLDivElement>(null);
74+
75+
useEffect(() => {
76+
if (!expanded) return;
77+
78+
const handleClickOutside = (event: MouseEvent) => {
79+
if (
80+
containerRef.current &&
81+
!containerRef.current.contains(event.target as Node)
82+
) {
83+
setExpanded(false);
84+
}
85+
};
86+
87+
document.addEventListener("mousedown", handleClickOutside);
88+
return () => document.removeEventListener("mousedown", handleClickOutside);
89+
}, [expanded]);
7890

7991
const mutation = useMutation({
8092
mutationFn: async () => {
@@ -90,7 +102,7 @@ function BrandSection({ currentYear }: { currentYear: number }) {
90102
});
91103
},
92104
onSuccess: () => {
93-
setPopoverOpen(false);
105+
setExpanded(false);
94106
setEmail("");
95107
setSubscriptions({
96108
releaseNotesStable: false,
@@ -104,7 +116,6 @@ function BrandSection({ currentYear }: { currentYear: number }) {
104116
defaultValues: { email: "" },
105117
onSubmit: async ({ value }) => {
106118
setEmail(value.email);
107-
setPopoverOpen(true);
108119
},
109120
});
110121

@@ -124,151 +135,132 @@ function BrandSection({ currentYear }: { currentYear: number }) {
124135
</Link>
125136
<p className="text-sm text-neutral-500 mb-4">Fastrepl © {currentYear}</p>
126137

127-
<div className="mb-4">
128-
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
129-
<PopoverTrigger asChild>
130-
<form
131-
onSubmit={(e) => {
132-
e.preventDefault();
133-
form.handleSubmit();
134-
}}
135-
className="flex items-center"
136-
>
137-
<form.Field name="email">
138-
{(field) => (
139-
<div className={cn([
140-
"relative flex items-center max-w-64 border border-neutral-100 laptop:border-l-0 bg-white overflow-hidden transition-all",
141-
"focus-within:ring-1 focus-within:ring-stone-400 focus-within:border-stone-400",
142-
])}>
143-
<MailIcon className="absolute left-2.5 size-3.5 text-neutral-400" />
144-
<input
145-
type="email"
146-
value={field.state.value}
147-
onChange={(e) => field.handleChange(e.target.value)}
148-
placeholder="Subscribe to updates"
149-
className={cn([
150-
"min-w-0 flex-1 pl-8 pr-2 py-1.5 text-sm",
151-
"bg-transparent placeholder:text-neutral-400",
152-
"focus:outline-none",
153-
])}
154-
required
155-
/>
156-
<button
157-
type="submit"
158-
className={cn([
159-
"shrink-0 px-2 transition-colors focus:outline-none",
160-
field.state.value ? "text-stone-600" : "text-neutral-300",
161-
])}
162-
>
163-
<ArrowRightIcon className="size-4" />
164-
</button>
165-
</div>
166-
)}
167-
</form.Field>
168-
</form>
169-
</PopoverTrigger>
170-
<PopoverContent
171-
align="start"
172-
className="w-72 p-4 bg-white border border-neutral-200 shadow-lg"
173-
>
174-
<div className="space-y-4">
175-
<div>
176-
<p className="text-sm font-medium text-neutral-900 mb-1">
177-
What would you like to receive?
178-
</p>
179-
<p className="text-xs text-neutral-500">
180-
Select your preferences for {email}
138+
<div className="mb-4 relative" ref={containerRef}>
139+
{expanded && (
140+
<div className="absolute bottom-full left-0 w-72 bg-white border border-b-0 laptop:border-l-0 border-stone-100 p-4 space-y-4">
141+
<p className="text-sm font-medium text-neutral-900">
142+
What would you like to receive?
143+
</p>
144+
145+
<div className="space-y-3">
146+
<div className="space-y-2">
147+
<p className="text-xs font-medium text-neutral-700 uppercase tracking-wide">
148+
Release Notes
181149
</p>
150+
<label className="flex items-center gap-2 cursor-pointer">
151+
<Checkbox
152+
checked={subscriptions.releaseNotesStable}
153+
onCheckedChange={(checked) =>
154+
setSubscriptions((prev) => ({
155+
...prev,
156+
releaseNotesStable: checked === true,
157+
}))
158+
}
159+
className="data-[state=checked]:bg-black data-[state=checked]:border-black data-[state=checked]:text-white"
160+
/>
161+
<span className="text-sm text-neutral-600">Stable</span>
162+
</label>
163+
<label className="flex items-center gap-2 cursor-pointer">
164+
<Checkbox
165+
checked={subscriptions.releaseNotesBeta}
166+
onCheckedChange={(checked) =>
167+
setSubscriptions((prev) => ({
168+
...prev,
169+
releaseNotesBeta: checked === true,
170+
}))
171+
}
172+
className="data-[state=checked]:bg-black data-[state=checked]:border-black data-[state=checked]:text-white"
173+
/>
174+
<div className="flex items-center gap-1.5">
175+
<span className="text-sm text-neutral-600">Beta</span>
176+
<span className="text-xs text-neutral-400">
177+
- includes beta download link
178+
</span>
179+
</div>
180+
</label>
182181
</div>
183182

184-
<div className="space-y-3">
185-
<div className="space-y-2">
186-
<p className="text-xs font-medium text-neutral-700 uppercase tracking-wide">
187-
Release Notes
188-
</p>
189-
<label className="flex items-center gap-2 cursor-pointer">
190-
<Checkbox
191-
checked={subscriptions.releaseNotesStable}
192-
onCheckedChange={(checked) =>
193-
setSubscriptions((prev) => ({
194-
...prev,
195-
releaseNotesStable: checked === true,
196-
}))
197-
}
198-
/>
199-
<span className="text-sm text-neutral-600">Stable</span>
200-
</label>
201-
<label className="flex items-center gap-2 cursor-pointer">
202-
<Checkbox
203-
checked={subscriptions.releaseNotesBeta}
204-
onCheckedChange={(checked) =>
205-
setSubscriptions((prev) => ({
206-
...prev,
207-
releaseNotesBeta: checked === true,
208-
}))
209-
}
210-
/>
211-
<div className="flex items-center gap-1.5">
212-
<span className="text-sm text-neutral-600">Beta</span>
213-
<span className="text-xs text-neutral-400">
214-
- includes beta download link
215-
</span>
216-
</div>
217-
</label>
218-
</div>
219-
220-
<div className="space-y-2">
221-
<p className="text-xs font-medium text-neutral-700 uppercase tracking-wide">
222-
Newsletter
223-
</p>
224-
<label className="flex items-center gap-2 cursor-pointer">
225-
<Checkbox
226-
checked={subscriptions.newsletter}
227-
onCheckedChange={(checked) =>
228-
setSubscriptions((prev) => ({
229-
...prev,
230-
newsletter: checked === true,
231-
}))
232-
}
233-
/>
234-
<div className="flex flex-col">
235-
<span className="text-sm text-neutral-600">
236-
Subscribe to newsletter
237-
</span>
238-
<span className="text-xs text-neutral-400">
239-
About notetaking, opensource, and AI
240-
</span>
241-
</div>
242-
</label>
243-
</div>
183+
<div className="space-y-2">
184+
<p className="text-xs font-medium text-neutral-700 uppercase tracking-wide">
185+
Newsletter
186+
</p>
187+
<label className="flex items-center gap-2 cursor-pointer">
188+
<Checkbox
189+
checked={subscriptions.newsletter}
190+
onCheckedChange={(checked) =>
191+
setSubscriptions((prev) => ({
192+
...prev,
193+
newsletter: checked === true,
194+
}))
195+
}
196+
className="data-[state=checked]:bg-black data-[state=checked]:border-black data-[state=checked]:text-white"
197+
/>
198+
<span className="text-sm text-neutral-600">Blog</span>
199+
</label>
244200
</div>
201+
</div>
245202

246-
<button
247-
onClick={() => mutation.mutate()}
248-
disabled={!hasSelection || mutation.isPending}
249-
className={cn([
250-
"w-full py-2 px-4 text-sm font-medium rounded-md transition-all",
251-
hasSelection
252-
? "bg-stone-600 text-white hover:bg-stone-700"
253-
: "bg-neutral-100 text-neutral-400 cursor-not-allowed",
254-
mutation.isPending && "opacity-50 cursor-wait",
255-
])}
256-
>
257-
{mutation.isPending
258-
? "Subscribing..."
259-
: mutation.isSuccess
260-
? "Subscribed!"
261-
: "Subscribe"}
262-
</button>
203+
{mutation.isError && (
204+
<p className="text-xs text-red-500">
205+
Something went wrong. Please try again.
206+
</p>
207+
)}
208+
</div>
209+
)}
263210

264-
{mutation.isError && (
265-
<p className="text-xs text-red-500">
266-
Something went wrong. Please try again.
267-
</p>
268-
)}
269-
</div>
270-
</PopoverContent>
271-
</Popover>
211+
<form
212+
onSubmit={(e) => {
213+
e.preventDefault();
214+
if (expanded && hasSelection && email) {
215+
mutation.mutate();
216+
}
217+
}}
218+
className={cn([
219+
"max-w-72 border border-neutral-100 bg-white transition-all laptop:border-l-0",
220+
expanded && "shadow-lg",
221+
])}
222+
>
223+
<form.Field name="email">
224+
{(field) => (
225+
<div className="relative flex items-center">
226+
<MailIcon className="absolute left-2.5 size-3.5 text-neutral-400" />
227+
<input
228+
type="email"
229+
value={field.state.value}
230+
onChange={(e) => {
231+
field.handleChange(e.target.value);
232+
setEmail(e.target.value);
233+
}}
234+
onFocus={() => setExpanded(true)}
235+
placeholder={
236+
expanded ? "Enter your email" : "Subscribe to updates"
237+
}
238+
className={cn([
239+
"min-w-0 flex-1 pl-8 pr-2 py-1.5 text-sm",
240+
"bg-transparent placeholder:text-neutral-400",
241+
"focus:outline-none",
242+
])}
243+
/>
244+
<button
245+
type={expanded ? "submit" : "button"}
246+
onClick={() => !expanded && setExpanded(true)}
247+
disabled={
248+
expanded && (!hasSelection || !email || mutation.isPending)
249+
}
250+
className={cn([
251+
"shrink-0 px-2 transition-colors focus:outline-none",
252+
expanded && hasSelection && email
253+
? "text-stone-600"
254+
: "text-neutral-300",
255+
mutation.isPending && "opacity-50",
256+
])}
257+
>
258+
<ArrowRightIcon className="size-4" />
259+
</button>
260+
</div>
261+
)}
262+
</form.Field>
263+
</form>
272264
</div>
273265

274266
<p className="text-sm text-neutral-500">

0 commit comments

Comments
 (0)