Skip to content

Commit 7c626a7

Browse files
authored
feat(core): allow customer subscription to Newsletter (#2784)
1 parent 052c147 commit 7c626a7

File tree

10 files changed

+289
-8
lines changed

10 files changed

+289
-8
lines changed

.changeset/tall-walls-tan.md

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
---
2+
"@bigcommerce/catalyst-core": patch
3+
---
4+
5+
Implement functional newsletter subscription feature with BigCommerce GraphQL API integration.
6+
7+
## What Changed
8+
9+
- Replaced the mock implementation in `subscribe.ts` with a real BigCommerce GraphQL API call using the `SubscribeToNewsletterMutation`.
10+
- Added comprehensive error handling for invalid emails, already-subscribed users, and unexpected errors.
11+
- Improved form error handling in `InlineEmailForm` to use `form.errors` instead of field-level errors for better error display.
12+
- Added comprehensive E2E tests and test fixtures for subscription functionality.
13+
14+
## Migration Guide
15+
16+
Replace the `subscribe` action in `core/components/subscribe/_actions/subscribe.ts` with the latest changes to include:
17+
- BigCommerce GraphQL mutation for newsletter subscription
18+
- Error handling for invalid emails, already-subscribed users, and unexpected errors
19+
- Proper error messages returned via Conform's `submission.reply()`
20+
21+
Update `inline-email-form` to fix issue of not showing server-side error messages from form actions.
22+
23+
**`core/vibes/soul/primitives/inline-email-form/index.tsx`**
24+
25+
1. Add import for `FieldError` component:
26+
```tsx
27+
import { FieldError } from '@/vibes/soul/form/field-error';
28+
```
29+
30+
2. Remove the field errors extraction:
31+
```tsx
32+
// Remove: const { errors = [] } = fields.email;
33+
```
34+
35+
3. Update border styling to check both form and field errors:
36+
```tsx
37+
// Changed from:
38+
errors.length ? 'border-error' : 'border-black',
39+
40+
// Changed to:
41+
form.errors?.length || fields.email.errors?.length
42+
? 'border-error focus-within:border-error'
43+
: 'border-black focus-within:border-primary',
44+
```
45+
46+
4. Update error rendering to display both field-level and form-level errors:
47+
```tsx
48+
// Changed from:
49+
{errors.map((error, index) => (
50+
<FormStatus key={index} type="error">
51+
{error}
52+
</FormStatus>
53+
))}
54+
55+
// Changed to:
56+
{fields.email.errors?.map((error) => (
57+
<FieldError key={error}>{error}</FieldError>
58+
))}
59+
{form.errors?.map((error, index) => (
60+
<FormStatus key={index} type="error">
61+
{error}
62+
</FormStatus>
63+
))}
64+
```
65+
66+
This change ensures that server-side error messages returned from form actions (like `formErrors` from Conform's `submission.reply()`) are now properly displayed to users.
67+
68+
Add the following translation keys to your locale files (e.g., `messages/en.json`):
69+
```json
70+
{
71+
"Components": {
72+
"Subscribe": {
73+
"title": "Sign up for our newsletter",
74+
"placeholder": "Enter your email",
75+
"description": "Stay up to date with the latest news and offers from our store.",
76+
"success": "You have been subscribed to our newsletter.",
77+
"Errors": {
78+
"subcriberAlreadyExists": "You are already subscribed to our newsletter.",
79+
"invalidEmail": "Please enter a valid email address.",
80+
"somethingWentWrong": "Something went wrong. Please try again later."
81+
}
82+
}
83+
}
84+
}
85+
```

core/components/subscribe/_actions/subscribe.ts

Lines changed: 80 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,34 @@
11
'use server';
22

3+
import { BigCommerceGQLError } from '@bigcommerce/catalyst-client';
34
import { SubmissionResult } from '@conform-to/react';
45
import { parseWithZod } from '@conform-to/zod';
56
import { getTranslations } from 'next-intl/server';
67

78
import { schema } from '@/vibes/soul/primitives/inline-email-form/schema';
9+
import { client } from '~/client';
10+
import { graphql } from '~/client/graphql';
11+
12+
const SubscribeToNewsletterMutation = graphql(`
13+
mutation SubscribeToNewsletterMutation($input: CreateSubscriberInput!) {
14+
newsletter {
15+
subscribe(input: $input) {
16+
errors {
17+
__typename
18+
... on CreateSubscriberEmailInvalidError {
19+
message
20+
}
21+
... on CreateSubscriberAlreadyExistsError {
22+
message
23+
}
24+
... on CreateSubscriberUnexpectedError {
25+
message
26+
}
27+
}
28+
}
29+
}
30+
}
31+
`);
832

933
export const subscribe = async (
1034
_lastResult: { lastResult: SubmissionResult | null },
@@ -18,8 +42,61 @@ export const subscribe = async (
1842
return { lastResult: submission.reply() };
1943
}
2044

21-
// Simulate a network request
22-
await new Promise((resolve) => setTimeout(resolve, 1000));
45+
try {
46+
const response = await client.fetch({
47+
document: SubscribeToNewsletterMutation,
48+
variables: {
49+
input: {
50+
email: submission.value.email,
51+
},
52+
},
53+
fetchOptions: {
54+
cache: 'no-store',
55+
},
56+
});
57+
58+
const errors = response.data.newsletter.subscribe.errors;
59+
60+
if (!errors.length) {
61+
return { lastResult: submission.reply({ resetForm: true }), successMessage: t('success') };
62+
}
63+
64+
if (errors.length > 0) {
65+
return {
66+
lastResult: submission.reply({
67+
formErrors: errors.map(({ __typename }) => {
68+
switch (__typename) {
69+
case 'CreateSubscriberAlreadyExistsError':
70+
return t('Errors.subcriberAlreadyExists');
71+
72+
case 'CreateSubscriberEmailInvalidError':
73+
return t('Errors.invalidEmail');
2374

24-
return { lastResult: submission.reply(), successMessage: t('success') };
75+
default:
76+
return t('Errors.somethingWentWrong');
77+
}
78+
}),
79+
}),
80+
};
81+
}
82+
83+
return { lastResult: submission.reply({ formErrors: [t('Errors.somethingWentWrong')] }) };
84+
} catch (error) {
85+
// eslint-disable-next-line no-console
86+
console.error(error);
87+
88+
if (error instanceof BigCommerceGQLError) {
89+
return {
90+
lastResult: submission.reply({
91+
formErrors: error.errors.map(({ message }) => message),
92+
}),
93+
};
94+
}
95+
96+
if (error instanceof Error) {
97+
return { lastResult: submission.reply({ formErrors: [error.message] }) };
98+
}
99+
100+
return { lastResult: submission.reply({ formErrors: [t('Errors.somethingWentWrong')] }) };
101+
}
25102
};

core/messages/en.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -504,7 +504,12 @@
504504
"title": "Sign up for our newsletter",
505505
"placeholder": "Enter your email",
506506
"description": "Stay up to date with the latest news and offers from our store.",
507-
"success": "Thank you for your interest! Newsletter feature is coming soon!"
507+
"success": "You have been subscribed to our newsletter.",
508+
"Errors": {
509+
"subcriberAlreadyExists": "You are already subscribed to our newsletter.",
510+
"invalidEmail": "Please enter a valid email address.",
511+
"somethingWentWrong": "Something went wrong. Please try again later."
512+
}
508513
},
509514
"ConsentManager": {
510515
"Common": {

core/tests/fixtures/index.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { extendedPage, toHaveURL } from './page';
1616
import { PromotionFixture } from './promotion';
1717
import { RedirectsFixture } from './redirects';
1818
import { SettingsFixture } from './settings';
19+
import { SubscribeFixture } from './subscribe';
1920
import { WebPageFixture } from './webpage';
2021

2122
interface Fixtures {
@@ -27,6 +28,7 @@ interface Fixtures {
2728
promotion: PromotionFixture;
2829
redirects: RedirectsFixture;
2930
settings: SettingsFixture;
31+
subscribe: SubscribeFixture;
3032
webPage: WebPageFixture;
3133
/**
3234
* 'reuseCustomerSession' sets the the configuration for the customer fixture and determines whether to reuse the customer session.
@@ -131,6 +133,16 @@ export const test = baseTest.extend<Fixtures>({
131133
},
132134
{ scope: 'test' },
133135
],
136+
subscribe: [
137+
async ({ page }, use, currentTest) => {
138+
const subscribeFixture = new SubscribeFixture(page, currentTest);
139+
140+
await use(subscribeFixture);
141+
142+
await subscribeFixture.cleanup();
143+
},
144+
{ scope: 'test' },
145+
],
134146
webPage: [
135147
async ({ page }, use, currentTest) => {
136148
const webPageFixture = new WebPageFixture(page, currentTest);
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { Fixture } from '~/tests/fixtures/fixture';
2+
3+
export class SubscribeFixture extends Fixture {
4+
subscribedEmails: string[] = [];
5+
6+
trackSubscription(email: string): void {
7+
this.subscribedEmails.push(email);
8+
}
9+
10+
async cleanup(): Promise<void> {
11+
this.skipIfReadonly();
12+
13+
await Promise.all(this.subscribedEmails.map((email) => this.api.subscribe.unsubscribe(email)));
14+
15+
this.subscribedEmails = [];
16+
}
17+
}

core/tests/fixtures/utils/api/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { OrdersApi, ordersHttpClient } from '~/tests/fixtures/utils/api/orders';
66
import { PromotionsApi, promotionsHttpClient } from '~/tests/fixtures/utils/api/promotions';
77
import { RedirectsApi, redirectsHttpClient } from '~/tests/fixtures/utils/api/redirects';
88
import { SettingsApi, settingsHttpClient } from '~/tests/fixtures/utils/api/settings';
9+
import { SubscribeApi, subscribeHttpClient } from '~/tests/fixtures/utils/api/subscribe';
910
import { WebPagesApi, webPagesHttpClient } from '~/tests/fixtures/utils/api/webpages';
1011

1112
export interface ApiClient {
@@ -16,6 +17,7 @@ export interface ApiClient {
1617
orders: OrdersApi;
1718
promotions: PromotionsApi;
1819
settings: SettingsApi;
20+
subscribe: SubscribeApi;
1921
webPages: WebPagesApi;
2022
redirects: RedirectsApi;
2123
}
@@ -28,6 +30,7 @@ export const httpApiClient: ApiClient = {
2830
orders: ordersHttpClient,
2931
promotions: promotionsHttpClient,
3032
settings: settingsHttpClient,
33+
subscribe: subscribeHttpClient,
3134
webPages: webPagesHttpClient,
3235
redirects: redirectsHttpClient,
3336
};
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { httpClient } from '../client';
2+
3+
import { SubscribeApi } from '.';
4+
5+
export const subscribeHttpClient: SubscribeApi = {
6+
unsubscribe: async (email: string) => {
7+
await httpClient.delete(`/v3/customers/subscribers?email=${encodeURIComponent(email)}`);
8+
},
9+
};
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export interface SubscribeApi {
2+
unsubscribe(email: string): Promise<void>;
3+
}
4+
5+
export { subscribeHttpClient } from './http';
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { faker } from '@faker-js/faker';
2+
3+
import { expect, test } from '~/tests/fixtures';
4+
import { getTranslations } from '~/tests/lib/i18n';
5+
import { TAGS } from '~/tests/tags';
6+
7+
test(
8+
'Successfully subscribes a user to the newsletter',
9+
{ tag: [TAGS.writesData] },
10+
async ({ page, subscribe }) => {
11+
const t = await getTranslations('Components.Subscribe');
12+
13+
await page.goto('/');
14+
await page.waitForLoadState('networkidle');
15+
16+
const email = faker.internet.email();
17+
18+
const emailInput = page.getByPlaceholder(t('placeholder'));
19+
20+
await emailInput.fill(email);
21+
22+
const submitButton = page.locator('input[type="email"]').locator('..').getByRole('button');
23+
24+
await submitButton.click();
25+
await page.waitForLoadState('networkidle');
26+
27+
await expect(page.getByText(t('success'))).toBeVisible();
28+
29+
subscribe.trackSubscription(email);
30+
},
31+
);
32+
33+
test('Shows error when user tries to subscribe again with the same email', async ({
34+
page,
35+
subscribe,
36+
}) => {
37+
const t = await getTranslations('Components.Subscribe');
38+
39+
await page.goto('/');
40+
await page.waitForLoadState('networkidle');
41+
42+
const email = faker.internet.email();
43+
44+
const emailInput = page.getByPlaceholder(t('placeholder'));
45+
46+
const submitButton = page.locator('input[type="email"]').locator('..').getByRole('button');
47+
48+
// Subscribe with the email
49+
await emailInput.fill(email);
50+
await submitButton.click();
51+
await page.waitForLoadState('networkidle');
52+
53+
await expect(page.getByText(t('success'))).toBeVisible();
54+
55+
// Try to subscribe again with the same email
56+
await emailInput.fill(email);
57+
await submitButton.click();
58+
await page.waitForLoadState('networkidle');
59+
60+
await expect(page.getByText(t('Errors.subcriberAlreadyExists'))).toBeVisible();
61+
62+
// Track that we attempted to subscribe this email
63+
subscribe.trackSubscription(email);
64+
});

core/vibes/soul/primitives/inline-email-form/index.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { clsx } from 'clsx';
66
import { ArrowRight } from 'lucide-react';
77
import { useActionState } from 'react';
88

9+
import { FieldError } from '@/vibes/soul/form/field-error';
910
import { FormStatus } from '@/vibes/soul/form/form-status';
1011
import { Button } from '@/vibes/soul/primitives/button';
1112

@@ -40,14 +41,14 @@ export function InlineEmailForm({
4041
shouldRevalidate: 'onInput',
4142
});
4243

43-
const { errors = [] } = fields.email;
44-
4544
return (
4645
<form {...getFormProps(form)} action={formAction} className={clsx('space-y-2', className)}>
4746
<div
4847
className={clsx(
4948
'relative rounded-xl border bg-background text-base transition-colors duration-200 focus-within:border-primary focus:outline-none',
50-
errors.length ? 'border-error' : 'border-black',
49+
form.errors?.length || fields.email.errors?.length
50+
? 'border-error focus-within:border-error'
51+
: 'border-black focus-within:border-primary',
5152
)}
5253
>
5354
<input
@@ -70,7 +71,10 @@ export function InlineEmailForm({
7071
</Button>
7172
</div>
7273
</div>
73-
{errors.map((error, index) => (
74+
{fields.email.errors?.map((error) => (
75+
<FieldError key={error}>{error}</FieldError>
76+
))}
77+
{form.errors?.map((error, index) => (
7478
<FormStatus key={index} type="error">
7579
{error}
7680
</FormStatus>

0 commit comments

Comments
 (0)