Skip to content

Commit 536c0d7

Browse files
committed
fix: email validation
1 parent 763231e commit 536c0d7

3 files changed

Lines changed: 166 additions & 60 deletions

File tree

src/app/subscribe/page.tsx

Lines changed: 12 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { Button } from '@ui/button';
1212
import { Card, CardContent, CardHeader } from '@ui/card';
1313
import { Input } from '@ui/input';
1414
import { Label } from '@ui/label';
15-
import type { SubscribeResponse } from '@utils/resend';
15+
import type { SubscribeMutationResponse } from '@server/routers/mailingList';
1616
import { trpc } from '@utils/trpc';
1717
import { PostHogPageview } from '../posthog-provider';
1818

@@ -31,9 +31,7 @@ export default function NewsletterSignupPage() {
3131
} = useForm<FormData>();
3232

3333
const addSubscriberMutation = trpc.mailingList.subscribe.useMutation({
34-
onSuccess: (data: SubscribeResponse) => {
35-
reset();
36-
34+
onSuccess: (data: SubscribeMutationResponse) => {
3735
if (data?.error?.name === 'already_subscribed') {
3836
toast.success('Already subscribed! 🎉', {
3937
description: data.error.message,
@@ -44,6 +42,7 @@ export default function NewsletterSignupPage() {
4442
source: 'subscribe-page',
4543
});
4644

45+
reset();
4746
return;
4847
}
4948

@@ -58,10 +57,10 @@ export default function NewsletterSignupPage() {
5857
posthog.capture('newsletter/subscribed', {
5958
source: 'subscribe-page',
6059
});
60+
61+
reset();
6162
},
6263
onError: (error) => {
63-
reset();
64-
6564
toast.error('Subscription failed', {
6665
description:
6766
'Please try again or contact hello@mikebifulco.com for help.',
@@ -72,26 +71,20 @@ export default function NewsletterSignupPage() {
7271
source: 'subscribe-page',
7372
error,
7473
});
74+
75+
reset();
7576
},
7677
});
7778

78-
const onSubmit = async (data: FormData) => {
79+
const onSubmit = (data: FormData) => {
7980
posthog.identify(data.email, {
8081
firstName: data.firstName,
8182
});
8283

83-
try {
84-
await addSubscriberMutation.mutateAsync({
85-
email: data.email,
86-
firstName: data.firstName,
87-
});
88-
} catch (error) {
89-
// Handle TRPC input validation errors or other errors that don't trigger onError
90-
console.error('Form submission error:', error);
91-
}
92-
93-
// Always reset the form after submission attempt, regardless of outcome
94-
reset();
84+
addSubscriberMutation.mutate({
85+
email: data.email,
86+
firstName: data.firstName,
87+
});
9588
};
9689

9790
if (isSubmitted) {

src/components/SubscriptionForm/SubscriptionForm.tsx

Lines changed: 97 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import { useRef, useState } from 'react';
2-
import Link from 'next/link';
32
import useNewsletterStats from '@hooks/useNewsletterStats';
43
import posthog from 'posthog-js';
54
import { toast } from 'sonner';
65

76
import Button from '@components/Button';
87
import { Input } from '@ui/input';
98
import { trpc } from '@utils/trpc';
9+
import type { SubscribeMutationResponse } from '@server/routers/mailingList';
1010

1111
type SubscriptionFormProps = {
1212
tags?: string[];
@@ -21,10 +21,15 @@ const SubscriptionForm: React.FC<SubscriptionFormProps> = ({
2121
}) => {
2222
const [getHoneypottedNerd, setGetHoneypottedNerd] = useState<boolean>(false);
2323
const [alreadySubscribed, setAlreadySubscribed] = useState<boolean>(false);
24+
const [validationError, setValidationError] = useState<string | null>(null);
25+
2426
const addSubscriberMutation = trpc.mailingList.subscribe.useMutation({
25-
onSuccess: (data) => {
27+
onSuccess: (data: SubscribeMutationResponse) => {
28+
// Clear any validation errors on success
29+
setValidationError(null);
30+
2631
// Check if this is the "already subscribed" case
27-
if (data?.error?.name === 'already_subscribed') {
32+
if (data.error?.name === 'already_subscribed') {
2833
const email = emailRef.current?.value;
2934
const firstName = firstNameRef.current?.value;
3035

@@ -75,17 +80,34 @@ const SubscriptionForm: React.FC<SubscriptionFormProps> = ({
7580
const email = emailRef.current?.value;
7681
const firstName = firstNameRef.current?.value;
7782

83+
// Handle validation errors specifically
84+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
85+
const errorData = error as any;
86+
if (errorData?.data?.zodError?.fieldErrors) {
87+
const fieldErrors = errorData.data.zodError.fieldErrors;
88+
if (fieldErrors.email) {
89+
setValidationError('Please enter a valid email address (e.g., person@gmail.com)');
90+
} else if (fieldErrors.firstName) {
91+
setValidationError('Please enter your first name');
92+
} else {
93+
setValidationError('Please check your input and try again');
94+
}
95+
} else if (errorData?.message) {
96+
setValidationError(errorData.message);
97+
} else {
98+
setValidationError('Something went wrong. Please try again or contact support.');
99+
}
100+
78101
toast.error('Subscription failed', {
79-
description:
80-
'Please try again or contact hello@mikebifulco.com for help.',
102+
description: 'Please check your input and try again.',
81103
duration: 5000,
82104
});
83105

84106
posthog.capture('newsletter/subscribe_error', {
85107
source,
86108
email,
87109
firstName,
88-
error,
110+
error: errorData?.message || 'Unknown error',
89111
});
90112
},
91113
});
@@ -97,9 +119,12 @@ const SubscriptionForm: React.FC<SubscriptionFormProps> = ({
97119
const firstNameRef = useRef<HTMLInputElement>(null);
98120
const honeypotRef = useRef<HTMLInputElement>(null);
99121

100-
const handleSubmission = async (e: React.FormEvent<HTMLFormElement>) => {
122+
const handleSubmission = (e: React.FormEvent<HTMLFormElement>) => {
101123
e.preventDefault();
102124

125+
// Clear any previous validation errors
126+
setValidationError(null);
127+
103128
const email = emailRef.current?.value;
104129
const firstName = firstNameRef.current?.value;
105130
const honeypot = honeypotRef.current?.value;
@@ -110,37 +135,85 @@ const SubscriptionForm: React.FC<SubscriptionFormProps> = ({
110135
}
111136

112137
if (!email) {
138+
setValidationError('Please enter your email address');
139+
return;
140+
}
141+
142+
// Basic email validation on frontend
143+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
144+
if (!emailRegex.test(email)) {
145+
setValidationError('Please enter a valid email address (e.g., person@gmail.com)');
113146
return;
114147
}
115148

116149
posthog.identify(email, {
117150
firstName,
118151
});
119152

120-
await addSubscriberMutation.mutateAsync({
153+
addSubscriberMutation.mutate({
121154
email,
122155
firstName,
123156
});
124157
};
125158

126-
if (addSubscriberMutation.error) {
159+
// Show validation error inline with the form
160+
if (validationError) {
127161
return (
128162
<div className="flex flex-col gap-2">
129-
<p className="text-xl font-semibold text-inherit">
130-
{addSubscriberMutation.error.message}
131-
</p>
132-
<p className="text-inherit">
133-
If you continue to have issues, please email{' '}
134-
<Link
135-
className="text-pink-600 hover:underline"
136-
href="mailto:hello@mikebifulco.com"
137-
rel="noopener noreferrer"
138-
target="_blank"
139-
>
140-
hello@mikebifulco.com
141-
</Link>{' '}
142-
and I&apos;ll help get this sorted.
143-
</p>
163+
<div className="rounded-md border border-red-200 bg-red-50 p-4">
164+
<p className="text-sm font-medium text-red-800">
165+
{validationError}
166+
</p>
167+
</div>
168+
<form ref={formRef} className="w-full" onSubmit={handleSubmission}>
169+
<fieldset disabled={addSubscriberMutation.isPending}>
170+
<div data-style="clean">
171+
<div
172+
className="seva-fields formkit-fields grid w-full items-center rounded-sm"
173+
data-element="fields"
174+
data-stacked="false"
175+
>
176+
<Input
177+
type="text"
178+
aria-label="Last Name"
179+
ref={honeypotRef}
180+
style={{ display: 'none' }}
181+
name="fields[last_name]"
182+
/>
183+
<Input
184+
className="h-10 w-full grow rounded-t rounded-b-none border border-b-0 border-solid border-pink-600 bg-white px-[2ch] py-[1ch] font-normal text-gray-950"
185+
aria-label="First Name"
186+
name="fields[first_name]"
187+
required
188+
placeholder="First Name"
189+
type="text"
190+
ref={firstNameRef}
191+
/>
192+
<Input
193+
className="h-10 w-full grow rounded-b-none rounded-none border border-b-0 border-solid border-pink-600 bg-white px-[2ch] py-[1ch] font-normal text-gray-950"
194+
name="email_address"
195+
aria-label="Email Address"
196+
placeholder="Email Address"
197+
required
198+
type="email"
199+
ref={emailRef}
200+
/>
201+
<Button
202+
type="submit"
203+
data-element="submit"
204+
className="formkit-submit formkit-submit padding-[1ch 2ch] h-10 grow rounded-t-none rounded-b font-normal"
205+
>
206+
<div className="formkit-spinner">
207+
<div></div>
208+
<div></div>
209+
<div></div>
210+
</div>
211+
<span>💌 {buttonText}</span>
212+
</Button>
213+
</div>
214+
</div>
215+
</fieldset>
216+
</form>
144217
</div>
145218
);
146219
}

src/server/routers/mailingList.ts

Lines changed: 57 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,28 @@
11
import { TRPCError } from '@trpc/server';
2+
import { z } from 'zod';
23

34
import { getSubscriberCount, subscribe, subscribeSchema } from '@utils/resend';
45
import { procedure, router } from '../trpc';
56

7+
// Define the return type for the subscribe mutation
8+
const subscribeResponseSchema = z.union([
9+
z.object({
10+
data: z.object({
11+
id: z.string(),
12+
}),
13+
error: z.null(),
14+
}),
15+
z.object({
16+
data: z.null(),
17+
error: z.object({
18+
name: z.literal('already_subscribed'),
19+
message: z.string(),
20+
}),
21+
}),
22+
]);
23+
24+
export type SubscribeMutationResponse = z.infer<typeof subscribeResponseSchema>;
25+
626
export const mailingListRouter = router({
727
stats: procedure.query(async () => {
828
const subscriberCount = await getSubscriberCount();
@@ -11,21 +31,41 @@ export const mailingListRouter = router({
1131
subscribers: subscriberCount,
1232
};
1333
}),
14-
subscribe: procedure.input(subscribeSchema).mutation(async ({ input }) => {
15-
const { email, firstName, lastName } = input;
16-
try {
17-
const res = await subscribe({
18-
email,
19-
firstName,
20-
lastName,
21-
});
22-
return res;
23-
} catch (e) {
24-
throw new TRPCError({
25-
message: 'Error subscribing',
26-
code: 'BAD_REQUEST',
27-
cause: (e as Error).message || undefined,
28-
});
29-
}
30-
}),
34+
subscribe: procedure
35+
.input(subscribeSchema)
36+
.output(subscribeResponseSchema)
37+
.mutation(async ({ input }: { input: z.infer<typeof subscribeSchema> }): Promise<SubscribeMutationResponse> => {
38+
const { email, firstName, lastName } = input;
39+
try {
40+
const res = await subscribe({
41+
email,
42+
firstName,
43+
lastName,
44+
});
45+
46+
// Validate the response matches our expected schema
47+
return subscribeResponseSchema.parse(res);
48+
} catch (e) {
49+
// If it's a Zod validation error, provide better error details
50+
if (e instanceof z.ZodError) {
51+
throw new TRPCError({
52+
message: 'Validation failed',
53+
code: 'BAD_REQUEST',
54+
cause: e,
55+
});
56+
}
57+
58+
// If it's already a TRPC error, re-throw it
59+
if (e instanceof TRPCError) {
60+
throw e;
61+
}
62+
63+
// For other errors, provide a generic message
64+
throw new TRPCError({
65+
message: 'Error subscribing to newsletter',
66+
code: 'INTERNAL_SERVER_ERROR',
67+
cause: (e as Error).message || undefined,
68+
});
69+
}
70+
}),
3171
});

0 commit comments

Comments
 (0)