Skip to content

Commit 49f88d7

Browse files
committed
feat(core): allow customer subscription to Newsletter
1 parent abcb16a commit 49f88d7

10 files changed

Lines changed: 262 additions & 8 deletions

File tree

.changeset/tall-walls-tan.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
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 the error messages.
22+
**`core/vibes/soul/primitives/inline-email-form/index.tsx`**
23+
```tsx
24+
// Changed from: const { errors = [] } = fields.email;
25+
// Changed to: form.errors?.length
26+
// Changed from: errors.map(...)
27+
// Changed to: form.errors?.map(...)
28+
```
29+
30+
Add the following translation keys to your locale files (e.g., `messages/en.json`):
31+
```json
32+
{
33+
"Components": {
34+
"Subscribe": {
35+
"title": "Sign up for our newsletter",
36+
"placeholder": "Enter your email",
37+
"description": "Stay up to date with the latest news and offers from our store.",
38+
"success": "You have been subscribed to our newsletter.",
39+
"Errors": {
40+
"subcriberAlreadyExists": "You are already subscribed to our newsletter.",
41+
"invalidEmail": "Please enter a valid email address.",
42+
"somethingWentWrong": "Something went wrong. Please try again later."
43+
}
44+
}
45+
}
46+
}
47+
```

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(), 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: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export interface SubscribeApi {
2+
unsubscribe(email: string): Promise<void>;
3+
}
4+
5+
export { subscribeHttpClient } from './http';
6+
7+
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
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+
// Shared email for tests that need to use the same email
8+
const sharedEmail = faker.internet.email();
9+
10+
test(
11+
'Successfully subscribes a user to the newsletter',
12+
{ tag: [TAGS.writesData] },
13+
async ({ page, subscribe }) => {
14+
const t = await getTranslations('Components.Subscribe');
15+
16+
await page.goto('/');
17+
await page.waitForLoadState('networkidle');
18+
19+
const email = sharedEmail;
20+
21+
const emailInput = page.getByPlaceholder(t('placeholder'));
22+
23+
await emailInput.fill(email);
24+
25+
const submitButton = page.locator('input[type="email"]').locator('..').getByRole('button');
26+
27+
await submitButton.click();
28+
await page.waitForLoadState('networkidle');
29+
30+
await expect(page.getByText(t('success'))).toBeVisible();
31+
32+
subscribe.trackSubscription(email);
33+
},
34+
);
35+
36+
test('Shows error when email is invalid', async ({ page }) => {
37+
const t = await getTranslations('Components.Subscribe');
38+
39+
await page.goto('/');
40+
await page.waitForLoadState('networkidle');
41+
42+
const invalidEmail = 'not-an-email';
43+
44+
const emailInput = page.getByPlaceholder(t('placeholder'));
45+
46+
await emailInput.fill(invalidEmail);
47+
48+
const submitButton = page.locator('input[type="email"]').locator('..').getByRole('button');
49+
50+
await submitButton.click();
51+
await page.waitForLoadState('networkidle');
52+
53+
await expect(page.getByText(t('Errors.invalidEmail'))).toBeVisible();
54+
});
55+
56+
test('Shows error when user tries to subscribe again with the same email', async ({
57+
page,
58+
subscribe,
59+
}) => {
60+
const t = await getTranslations('Components.Subscribe');
61+
62+
await page.goto('/');
63+
await page.waitForLoadState('networkidle');
64+
65+
const email = sharedEmail;
66+
67+
const emailInput = page.getByPlaceholder(t('placeholder'));
68+
const submitButton = page.locator('input[type="email"]').locator('..').getByRole('button');
69+
70+
// Try to subscribe again with the same email (already subscribed in first test)
71+
await emailInput.fill(email);
72+
await submitButton.click();
73+
await page.waitForLoadState('networkidle');
74+
75+
await expect(page.getByText(t('Errors.subcriberAlreadyExists'))).toBeVisible();
76+
77+
// Track that we attempted to subscribe this email (already tracked in first test)
78+
subscribe.trackSubscription(email);
79+
});

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

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,14 +40,12 @@ export function InlineEmailForm({
4040
shouldRevalidate: 'onInput',
4141
});
4242

43-
const { errors = [] } = fields.email;
44-
4543
return (
4644
<form {...getFormProps(form)} action={formAction} className={clsx('space-y-2', className)}>
4745
<div
4846
className={clsx(
4947
'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',
48+
form.errors?.length ? 'border-error' : 'border-black',
5149
)}
5250
>
5351
<input
@@ -70,7 +68,7 @@ export function InlineEmailForm({
7068
</Button>
7169
</div>
7270
</div>
73-
{errors.map((error, index) => (
71+
{form.errors?.map((error, index) => (
7472
<FormStatus key={index} type="error">
7573
{error}
7674
</FormStatus>

0 commit comments

Comments
 (0)