diff --git a/.changeset/hot-plums-give.md b/.changeset/hot-plums-give.md
index 6e42c80a42..e55241f7c8 100644
--- a/.changeset/hot-plums-give.md
+++ b/.changeset/hot-plums-give.md
@@ -1,5 +1,5 @@
---
-"@bigcommerce/catalyst-core": patch
+"@bigcommerce/catalyst-core": minor
---
Make newsletter signup component on homepage render conditionally based on BigCommerce settings.
diff --git a/.changeset/tall-walls-tan.md b/.changeset/tall-walls-tan.md
index 882e34e0d5..8236fdf4df 100644
--- a/.changeset/tall-walls-tan.md
+++ b/.changeset/tall-walls-tan.md
@@ -1,5 +1,5 @@
---
-"@bigcommerce/catalyst-core": patch
+"@bigcommerce/catalyst-core": minor
---
Implement functional newsletter subscription feature with BigCommerce GraphQL API integration.
@@ -73,7 +73,7 @@ Add the following translation keys to your locale files (e.g., `messages/en.json
"title": "Sign up for our newsletter",
"placeholder": "Enter your email",
"description": "Stay up to date with the latest news and offers from our store.",
- "subscribedToNewsletter": "You have been subscribed to our newsletter.",
+ "subscribedToNewsletter": "You have been subscribed to our newsletter!",
"Errors": {
"invalidEmail": "Please enter a valid email address.",
"somethingWentWrong": "Something went wrong. Please try again later."
diff --git a/.changeset/violet-adults-do.md b/.changeset/violet-adults-do.md
new file mode 100644
index 0000000000..64f7b562ea
--- /dev/null
+++ b/.changeset/violet-adults-do.md
@@ -0,0 +1,181 @@
+---
+"@bigcommerce/catalyst-core": minor
+---
+
+Add newsletter subscription toggle to account settings page, allowing customers to manage their marketing preferences directly from their account.
+
+## What Changed
+
+- Added `NewsletterSubscriptionForm` component with a toggle switch for subscribing/unsubscribing to newsletters
+- Created `updateNewsletterSubscription` server action that handles both subscribe and unsubscribe operations via BigCommerce GraphQL API
+- Updated `AccountSettingsSection` to conditionally display the newsletter subscription form when enabled
+- Enhanced `CustomerSettingsQuery` to fetch `isSubscribedToNewsletter` status and `showNewsletterSignup` store setting
+- Updated account settings page to pass newsletter subscription props and bind customer info to the action
+- Added translation keys for newsletter subscription UI in `Account.Settings.NewsletterSubscription` namespace
+- Added E2E tests for subscribing and unsubscribing functionality
+
+## Migration Guide
+
+To add the newsletter subscription toggle to your account settings page:
+
+### Step 1: Copy the server action
+
+Copy the new server action file to your account settings directory:
+
+```bash
+cp core/app/[locale]/(default)/account/settings/_actions/update-newsletter-subscription.ts \
+ your-app/app/[locale]/(default)/account/settings/_actions/update-newsletter-subscription.ts
+```
+
+### Step 2: Update the GraphQL query
+
+Update `core/app/[locale]/(default)/account/settings/page-data.tsx` to include newsletter subscription fields:
+
+```tsx
+// Renamed CustomerSettingsQuery to AccountSettingsQuery
+const AccountSettingsQuery = graphql(`
+ query AccountSettingsQuery(...) {
+ customer {
+ ...
+ isSubscribedToNewsletter # Add this field
+ }
+ site {
+ settings {
+ ...
+ newsletter { # Add this section
+ showNewsletterSignup
+ }
+ }
+ }
+ }
+`);
+```
+
+Also update the return statement to include `newsletterSettings`:
+
+```tsx
+const newsletterSettings = response.data.site.settings?.newsletter;
+
+return {
+ ...
+ newsletterSettings, // Add this
+};
+```
+
+### Step 3: Copy the NewsletterSubscriptionForm component
+
+Copy the new form component:
+
+```bash
+cp core/vibes/soul/sections/account-settings/newsletter-subscription-form.tsx \
+ your-app/vibes/soul/sections/account-settings/newsletter-subscription-form.tsx
+```
+
+### Step 4: Update AccountSettingsSection
+
+Update `core/vibes/soul/sections/account-settings/index.tsx`:
+
+1. Import the new component:
+```tsx
+import {
+ NewsletterSubscriptionForm,
+ UpdateNewsletterSubscriptionAction,
+} from './newsletter-subscription-form';
+```
+
+2. Add props to the interface:
+```tsx
+export interface AccountSettingsSectionProps {
+ ...
+ newsletterSubscriptionEnabled?: boolean;
+ isAccountSubscribed?: boolean;
+ newsletterSubscriptionTitle?: string;
+ newsletterSubscriptionLabel?: string;
+ newsletterSubscriptionCtaLabel?: string;
+ updateNewsletterSubscriptionAction?: UpdateNewsletterSubscriptionAction;
+}
+```
+
+3. Add the form section in the component (after the change password form):
+```tsx
+{newsletterSubscriptionEnabled && updateNewsletterSubscriptionAction && (
+
+
+ {newsletterSubscriptionTitle}
+
+
+
+)}
+```
+
+### Step 5: Update the account settings page
+
+Update `core/app/[locale]/(default)/account/settings/page.tsx`:
+
+1. Import the action:
+```tsx
+import { updateNewsletterSubscription } from './_actions/update-newsletter-subscription';
+```
+
+2. Extract newsletter settings from the query:
+```tsx
+const newsletterSubscriptionEnabled = accountSettings.storeSettings?.showNewsletterSignup;
+const isAccountSubscribed = accountSettings.customerInfo.isSubscribedToNewsletter;
+```
+
+3. Bind customer info to the action:
+```tsx
+const updateNewsletterSubscriptionActionWithCustomerInfo = updateNewsletterSubscription.bind(
+ null,
+ {
+ customerInfo: accountSettings.customerInfo,
+ },
+);
+```
+
+4. Pass props to `AccountSettingsSection`:
+```tsx
+
+```
+
+### Step 6: Add translation keys
+
+Add the following keys to your locale files (e.g., `messages/en.json`):
+
+```json
+{
+ "Account": {
+ "Settings": {
+ ...
+ "NewsletterSubscription": {
+ "title": "Marketing preferences",
+ "label": "Opt-in to receive emails about new products and promotions.",
+ "marketingPreferencesUpdated": "Marketing preferences have been updated successfully!",
+ "somethingWentWrong": "Something went wrong. Please try again later."
+ }
+ }
+ }
+}
+```
+
+### Step 7: Verify the feature
+
+1. Ensure your BigCommerce store has newsletter signup enabled in store settings
+2. Navigate to `/account/settings` as a logged-in customer
+3. Verify the newsletter subscription toggle appears below the change password form
+4. Test subscribing and unsubscribing functionality
+
+The newsletter subscription form will only display if `newsletterSubscriptionEnabled` is `true` (controlled by the `showNewsletterSignup` store setting).
diff --git a/core/app/[locale]/(default)/account/settings/_actions/update-newsletter-subscription.ts b/core/app/[locale]/(default)/account/settings/_actions/update-newsletter-subscription.ts
new file mode 100644
index 0000000000..b984b53601
--- /dev/null
+++ b/core/app/[locale]/(default)/account/settings/_actions/update-newsletter-subscription.ts
@@ -0,0 +1,151 @@
+'use server';
+
+import { BigCommerceGQLError } from '@bigcommerce/catalyst-client';
+import { SubmissionResult } from '@conform-to/react';
+import { parseWithZod } from '@conform-to/zod';
+import { unstable_expireTag } from 'next/cache';
+import { getTranslations } from 'next-intl/server';
+import { z } from 'zod';
+
+import { client } from '~/client';
+import { graphql } from '~/client/graphql';
+import { TAGS } from '~/client/tags';
+
+const updateNewsletterSubscriptionSchema = z.object({
+ intent: z.enum(['subscribe', 'unsubscribe']),
+});
+
+const SubscribeToNewsletterMutation = graphql(`
+ mutation SubscribeToNewsletterMutation($input: CreateSubscriberInput!) {
+ newsletter {
+ subscribe(input: $input) {
+ errors {
+ __typename
+ ... on CreateSubscriberAlreadyExistsError {
+ message
+ }
+ ... on CreateSubscriberEmailInvalidError {
+ message
+ }
+ ... on CreateSubscriberUnexpectedError {
+ message
+ }
+ ... on CreateSubscriberLastNameInvalidError {
+ message
+ }
+ ... on CreateSubscriberFirstNameInvalidError {
+ message
+ }
+ }
+ }
+ }
+ }
+`);
+
+const UnsubscribeFromNewsletterMutation = graphql(`
+ mutation UnsubscribeFromNewsletterMutation($input: RemoveSubscriberInput!) {
+ newsletter {
+ unsubscribe(input: $input) {
+ errors {
+ __typename
+ ... on RemoveSubscriberEmailInvalidError {
+ message
+ }
+ ... on RemoveSubscriberUnexpectedError {
+ message
+ }
+ }
+ }
+ }
+ }
+`);
+
+export const updateNewsletterSubscription = async (
+ {
+ customerInfo,
+ }: {
+ customerInfo: {
+ email: string;
+ firstName: string;
+ lastName: string;
+ };
+ },
+ _prevState: { lastResult: SubmissionResult | null },
+ formData: FormData,
+) => {
+ const t = await getTranslations('Account.Settings.NewsletterSubscription');
+
+ const submission = parseWithZod(formData, { schema: updateNewsletterSubscriptionSchema });
+
+ if (submission.status !== 'success') {
+ return { lastResult: submission.reply() };
+ }
+
+ try {
+ let errors;
+
+ if (submission.value.intent === 'subscribe') {
+ const response = await client.fetch({
+ document: SubscribeToNewsletterMutation,
+ variables: {
+ input: {
+ email: customerInfo.email,
+ firstName: customerInfo.firstName,
+ lastName: customerInfo.lastName,
+ },
+ },
+ });
+
+ errors = response.data.newsletter.subscribe.errors;
+ } else {
+ const response = await client.fetch({
+ document: UnsubscribeFromNewsletterMutation,
+ variables: {
+ input: {
+ email: customerInfo.email,
+ },
+ },
+ });
+
+ errors = response.data.newsletter.unsubscribe.errors;
+ }
+
+ if (errors.length > 0) {
+ // Not handling returned errors from API since we will display a generic error message to the user
+ // Still returning the errors to the client for debugging purposes
+ return {
+ lastResult: submission.reply({
+ formErrors: errors.map(({ message }) => message),
+ }),
+ };
+ }
+
+ unstable_expireTag(TAGS.customer);
+
+ return {
+ lastResult: submission.reply(),
+ successMessage: t('marketingPreferencesUpdated'),
+ };
+ } catch (error) {
+ // eslint-disable-next-line no-console
+ console.error(error);
+
+ if (error instanceof BigCommerceGQLError) {
+ return {
+ lastResult: submission.reply({
+ formErrors: error.errors.map(({ message }) => message),
+ }),
+ };
+ }
+
+ if (error instanceof Error) {
+ return {
+ lastResult: submission.reply({ formErrors: [error.message] }),
+ };
+ }
+
+ return {
+ lastResult: submission.reply({ formErrors: [String(error)] }),
+ };
+ }
+};
diff --git a/core/app/[locale]/(default)/account/settings/page-data.tsx b/core/app/[locale]/(default)/account/settings/page-data.tsx
index 43dfa5d323..f0db7b0f6a 100644
--- a/core/app/[locale]/(default)/account/settings/page-data.tsx
+++ b/core/app/[locale]/(default)/account/settings/page-data.tsx
@@ -6,9 +6,9 @@ import { graphql, VariablesOf } from '~/client/graphql';
import { TAGS } from '~/client/tags';
import { FormFieldsFragment } from '~/data-transformers/form-field-transformer/fragment';
-const CustomerSettingsQuery = graphql(
+const AccountSettingsQuery = graphql(
`
- query CustomerSettingsQuery(
+ query AccountSettingsQuery(
$customerFilters: FormFieldFiltersInput
$customerSortBy: FormFieldSortInput
$addressFilters: FormFieldFiltersInput
@@ -20,6 +20,7 @@ const CustomerSettingsQuery = graphql(
firstName
lastName
company
+ isSubscribedToNewsletter
}
site {
settings {
@@ -31,6 +32,9 @@ const CustomerSettingsQuery = graphql(
...FormFieldsFragment
}
}
+ newsletter {
+ showNewsletterSignup
+ }
}
}
}
@@ -38,7 +42,7 @@ const CustomerSettingsQuery = graphql(
[FormFieldsFragment],
);
-type Variables = VariablesOf;
+type Variables = VariablesOf;
interface Props {
address?: {
@@ -52,11 +56,11 @@ interface Props {
};
}
-export const getCustomerSettingsQuery = cache(async ({ address, customer }: Props = {}) => {
+export const getAccountSettingsQuery = cache(async ({ address, customer }: Props = {}) => {
const customerAccessToken = await getSessionCustomerAccessToken();
const response = await client.fetch({
- document: CustomerSettingsQuery,
+ document: AccountSettingsQuery,
variables: {
addressFilters: address?.filters,
addressSortBy: address?.sortBy,
@@ -70,6 +74,7 @@ export const getCustomerSettingsQuery = cache(async ({ address, customer }: Prop
const addressFields = response.data.site.settings?.formFields.shippingAddress;
const customerFields = response.data.site.settings?.formFields.customer;
const customerInfo = response.data.customer;
+ const newsletterSettings = response.data.site.settings?.newsletter;
if (!addressFields || !customerFields || !customerInfo) {
return null;
@@ -79,5 +84,6 @@ export const getCustomerSettingsQuery = cache(async ({ address, customer }: Prop
addressFields,
customerFields,
customerInfo,
+ newsletterSettings,
};
});
diff --git a/core/app/[locale]/(default)/account/settings/page.tsx b/core/app/[locale]/(default)/account/settings/page.tsx
index b37db8e530..6d074e9431 100644
--- a/core/app/[locale]/(default)/account/settings/page.tsx
+++ b/core/app/[locale]/(default)/account/settings/page.tsx
@@ -1,3 +1,4 @@
+/* eslint-disable react/jsx-no-bind */
import { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { getTranslations, setRequestLocale } from 'next-intl/server';
@@ -6,7 +7,8 @@ import { AccountSettingsSection } from '@/vibes/soul/sections/account-settings';
import { changePassword } from './_actions/change-password';
import { updateCustomer } from './_actions/update-customer';
-import { getCustomerSettingsQuery } from './page-data';
+import { updateNewsletterSubscription } from './_actions/update-newsletter-subscription';
+import { getAccountSettingsQuery } from './page-data';
interface Props {
params: Promise<{ locale: string }>;
@@ -29,24 +31,40 @@ export default async function Settings({ params }: Props) {
const t = await getTranslations('Account.Settings');
- const customerSettings = await getCustomerSettingsQuery();
+ const accountSettings = await getAccountSettingsQuery();
- if (!customerSettings) {
+ if (!accountSettings) {
notFound();
}
+ const newsletterSubscriptionEnabled = accountSettings.newsletterSettings?.showNewsletterSignup;
+ const isAccountSubscribed = accountSettings.customerInfo.isSubscribedToNewsletter;
+
+ const updateNewsletterSubscriptionActionWithCustomerInfo = updateNewsletterSubscription.bind(
+ null,
+ {
+ customerInfo: accountSettings.customerInfo,
+ },
+ );
+
return (
);
}
diff --git a/core/components/subscribe/_actions/subscribe.ts b/core/components/subscribe/_actions/subscribe.ts
index 25dbe6ef3c..62a82ec774 100644
--- a/core/components/subscribe/_actions/subscribe.ts
+++ b/core/components/subscribe/_actions/subscribe.ts
@@ -57,13 +57,13 @@ export const subscribe = async (
const errors = response.data.newsletter.subscribe.errors;
- const subcriberAlreadyExists = errors.some(
+ // If subscriber already exists, treat it as success for privacy reasons
+ // We don't want to reveal that the email is already subscribed
+ const subscriberAlreadyExists = errors.some(
({ __typename }) => __typename === 'CreateSubscriberAlreadyExistsError',
);
- // If there are no errors or the subscriber already exists, we want to reset the form and show the success message
- // This is for privacy reasons, we don't want to show the error message to the user if they are already subscribed
- if (!errors.length || subcriberAlreadyExists) {
+ if (subscriberAlreadyExists) {
return {
lastResult: submission.reply(),
successMessage: t('subscribedToNewsletter'),
@@ -87,7 +87,11 @@ export const subscribe = async (
};
}
- return { lastResult: submission.reply({ formErrors: [t('Errors.somethingWentWrong')] }) };
+ // If there are no errors, we want to show the success message to the user
+ return {
+ lastResult: submission.reply(),
+ successMessage: t('subscribedToNewsletter'),
+ };
} catch (error) {
// eslint-disable-next-line no-console
console.error(error);
@@ -104,6 +108,6 @@ export const subscribe = async (
return { lastResult: submission.reply({ formErrors: [error.message] }) };
}
- return { lastResult: submission.reply({ formErrors: [t('Errors.somethingWentWrong')] }) };
+ return { lastResult: submission.reply({ formErrors: [String(error)] }) };
}
};
diff --git a/core/messages/en.json b/core/messages/en.json
index ffe0a75353..88172788f1 100644
--- a/core/messages/en.json
+++ b/core/messages/en.json
@@ -198,7 +198,13 @@
"currentPassword": "Current password",
"newPassword": "New password",
"confirmPassword": "Confirm password",
- "cta": "Update"
+ "cta": "Update",
+ "NewsletterSubscription": {
+ "title": "Marketing preferences",
+ "label": "Opt-in to receive emails about new products and promotions.",
+ "marketingPreferencesUpdated": "Marketing preferences have been updated successfully!",
+ "somethingWentWrong": "Something went wrong. Please try again later."
+ }
}
},
"Wishlist": {
@@ -504,7 +510,7 @@
"title": "Sign up for our newsletter",
"placeholder": "Enter your email",
"description": "Stay up to date with the latest news and offers from our store.",
- "subscribedToNewsletter": "You have been subscribed to our newsletter.",
+ "subscribedToNewsletter": "You have been subscribed to our newsletter!",
"Errors": {
"invalidEmail": "Please enter a valid email address.",
"somethingWentWrong": "Something went wrong. Please try again later."
diff --git a/core/tests/fixtures/subscribe/index.ts b/core/tests/fixtures/subscribe/index.ts
index 4bb2f7af0c..84ba6518b1 100644
--- a/core/tests/fixtures/subscribe/index.ts
+++ b/core/tests/fixtures/subscribe/index.ts
@@ -7,6 +7,20 @@ export class SubscribeFixture extends Fixture {
this.subscribedEmails.push(email);
}
+ async subscribe(email: string, firstName: string, lastName: string): Promise {
+ this.skipIfReadonly();
+
+ await this.api.subscribe.subscribe(email, firstName, lastName);
+
+ this.trackSubscription(email);
+ }
+
+ async unsubscribe(email: string): Promise {
+ this.skipIfReadonly();
+
+ await this.api.subscribe.unsubscribe(email);
+ }
+
async cleanup(): Promise {
this.skipIfReadonly();
diff --git a/core/tests/fixtures/utils/api/subscribe/http.ts b/core/tests/fixtures/utils/api/subscribe/http.ts
index e02729828b..b6c6876a43 100644
--- a/core/tests/fixtures/utils/api/subscribe/http.ts
+++ b/core/tests/fixtures/utils/api/subscribe/http.ts
@@ -3,6 +3,13 @@ import { httpClient } from '../client';
import { SubscribeApi } from '.';
export const subscribeHttpClient: SubscribeApi = {
+ subscribe: async (email: string, firstName: string, lastName: string) => {
+ await httpClient.post('/v3/customers/subscribers', {
+ email,
+ first_name: firstName,
+ last_name: lastName,
+ });
+ },
unsubscribe: async (email: string) => {
await httpClient.delete(`/v3/customers/subscribers?email=${encodeURIComponent(email)}`);
},
diff --git a/core/tests/fixtures/utils/api/subscribe/index.ts b/core/tests/fixtures/utils/api/subscribe/index.ts
index 68bd544b07..cfe108c234 100644
--- a/core/tests/fixtures/utils/api/subscribe/index.ts
+++ b/core/tests/fixtures/utils/api/subscribe/index.ts
@@ -1,4 +1,5 @@
export interface SubscribeApi {
+ subscribe(email: string, firstName: string, lastName: string): Promise;
unsubscribe(email: string): Promise;
}
diff --git a/core/tests/ui/e2e/account/account-settings.spec.ts b/core/tests/ui/e2e/account/account-settings.spec.ts
index 1545115344..ce8dabf08e 100644
--- a/core/tests/ui/e2e/account/account-settings.spec.ts
+++ b/core/tests/ui/e2e/account/account-settings.spec.ts
@@ -2,6 +2,7 @@ import { faker } from '@faker-js/faker';
import { expect, test } from '~/tests/fixtures';
import { getTranslations } from '~/tests/lib/i18n';
+import { TAGS } from '~/tests/tags';
test('Updating account information works as expected', async ({ page, customer }) => {
const t = await getTranslations('Account.Settings');
@@ -18,7 +19,7 @@ test('Updating account information works as expected', async ({ page, customer }
// TODO: Account settings form fields need to be translated
await expect(page.getByLabel('First Name')).toHaveValue(testCustomer.firstName);
await expect(page.getByLabel('Last Name')).toHaveValue(testCustomer.lastName);
- await expect(page.getByLabel('Email')).toHaveValue(testCustomer.email);
+ await expect(page.getByLabel('Email').first()).toHaveValue(testCustomer.email);
await page.getByLabel('First Name').fill(updatedFirstName);
await page.getByLabel('Last Name').fill(updatedLastName);
@@ -61,7 +62,7 @@ test('Changing password works as expected', async ({ page, customer }) => {
await page.getByLabel(t('confirmPassword')).fill(newPassword);
await page
.getByRole('button', { name: t('cta') })
- .last()
+ .nth(1) // The second button is the update password button
.click();
await page.waitForLoadState('networkidle');
@@ -77,3 +78,93 @@ test('Changing password works as expected', async ({ page, customer }) => {
await expect(page).toHaveURL('/account/orders/');
});
+
+test(
+ 'Subscribing to newsletter works as expected',
+ { tag: [TAGS.writesData] },
+ async ({ page, customer, subscribe }) => {
+ const t = await getTranslations('Account.Settings');
+ const tNewsletter = await getTranslations('Account.Settings.NewsletterSubscription');
+ const testCustomer = await customer.createNewCustomer();
+
+ // Ensure customer is unsubscribed initially
+ await subscribe.unsubscribe(testCustomer.email);
+
+ await customer.loginAs(testCustomer);
+
+ await page.goto('/account/settings');
+ await expect(page.getByRole('heading', { name: t('title') })).toBeVisible();
+
+ // Find the newsletter subscription switch
+ const newsletterSwitch = page.getByLabel(t('NewsletterSubscription.label'));
+
+ await expect(newsletterSwitch).toBeVisible();
+
+ // Verify switch is unchecked (customer is not subscribed)
+ const switchElement = page.getByRole('switch', { name: t('NewsletterSubscription.label') });
+
+ await expect(switchElement).not.toBeChecked();
+
+ // Click the switch to subscribe
+ await switchElement.click();
+
+ // Click the submit button (should be the last button on the page)
+ await page
+ .getByRole('button', { name: t('cta') })
+ .last()
+ .click();
+
+ await page.waitForLoadState('networkidle');
+
+ // Verify success message appears
+ await expect(page.getByText(tNewsletter('marketingPreferencesUpdated'))).toBeVisible();
+
+ // Track subscription for cleanup
+ subscribe.trackSubscription(testCustomer.email);
+ },
+);
+
+test(
+ 'Unsubscribing from newsletter works as expected',
+ { tag: [TAGS.writesData] },
+ async ({ page, customer, subscribe }) => {
+ const t = await getTranslations('Account.Settings');
+ const tNewsletter = await getTranslations('Account.Settings.NewsletterSubscription');
+ const testCustomer = await customer.createNewCustomer();
+
+ // Ensure customer is subscribed initially
+ await subscribe.subscribe(testCustomer.email, testCustomer.firstName, testCustomer.lastName);
+
+ await customer.loginAs(testCustomer);
+
+ await page.goto('/account/settings');
+ await expect(page.getByRole('heading', { name: t('title') })).toBeVisible();
+
+ // Find the newsletter subscription switch
+ const newsletterSwitch = page.getByLabel(t('NewsletterSubscription.label'));
+
+ await expect(newsletterSwitch).toBeVisible();
+
+ // Verify switch is checked (customer is subscribed)
+ const switchElement = page.getByRole('switch', { name: t('NewsletterSubscription.label') });
+
+ await expect(switchElement).toBeChecked();
+
+ // Click the switch to unsubscribe
+ await switchElement.click();
+
+ // Click the submit button (should be the last button on the page)
+ await page
+ .getByRole('button', { name: t('cta') })
+ .last()
+ .click();
+
+ await page.waitForLoadState('networkidle');
+
+ // Verify success message appears
+ await expect(page.getByText(tNewsletter('marketingPreferencesUpdated'))).toBeVisible();
+
+ // Track subscription for cleanup
+ subscribe.trackSubscription(testCustomer.email);
+ },
+);
diff --git a/core/tests/ui/e2e/subscribe.spec.ts b/core/tests/ui/e2e/subscribe.spec.ts
index b0c28d3402..273971b736 100644
--- a/core/tests/ui/e2e/subscribe.spec.ts
+++ b/core/tests/ui/e2e/subscribe.spec.ts
@@ -26,6 +26,7 @@ test(
await expect(page.getByText(t('subscribedToNewsletter'))).toBeVisible();
+ // Track that we attempted to subscribe this email
subscribe.trackSubscription(email);
},
);
diff --git a/core/vibes/soul/sections/account-settings/index.tsx b/core/vibes/soul/sections/account-settings/index.tsx
index 583894341d..8c6377d621 100644
--- a/core/vibes/soul/sections/account-settings/index.tsx
+++ b/core/vibes/soul/sections/account-settings/index.tsx
@@ -1,4 +1,8 @@
import { ChangePasswordAction, ChangePasswordForm } from './change-password-form';
+import {
+ NewsletterSubscriptionForm,
+ UpdateNewsletterSubscriptionAction,
+} from './newsletter-subscription-form';
import { Account, UpdateAccountAction, UpdateAccountForm } from './update-account-form';
export interface AccountSettingsSectionProps {
@@ -12,6 +16,12 @@ export interface AccountSettingsSectionProps {
confirmPasswordLabel?: string;
currentPasswordLabel?: string;
newPasswordLabel?: string;
+ newsletterSubscriptionEnabled?: boolean;
+ isAccountSubscribed?: boolean;
+ newsletterSubscriptionTitle?: string;
+ newsletterSubscriptionLabel?: string;
+ newsletterSubscriptionCtaLabel?: string;
+ updateNewsletterSubscriptionAction?: UpdateNewsletterSubscriptionAction;
}
// eslint-disable-next-line valid-jsdoc
@@ -39,6 +49,12 @@ export function AccountSettingsSection({
confirmPasswordLabel,
currentPasswordLabel,
newPasswordLabel,
+ newsletterSubscriptionEnabled = false,
+ isAccountSubscribed = false,
+ newsletterSubscriptionTitle = 'Marketing preferences',
+ newsletterSubscriptionLabel = 'Opt-in to receive emails about new products and promotions.',
+ newsletterSubscriptionCtaLabel = 'Save preferences',
+ updateNewsletterSubscriptionAction,
}: AccountSettingsSectionProps) {
return (
@@ -56,7 +72,7 @@ export function AccountSettingsSection({
submitLabel={updateAccountSubmitLabel}
/>
-
+
{changePasswordTitle}
@@ -68,6 +84,19 @@ export function AccountSettingsSection({
submitLabel={changePasswordSubmitLabel}
/>
+ {newsletterSubscriptionEnabled && updateNewsletterSubscriptionAction && (
+
+
+ {newsletterSubscriptionTitle}
+
+
+
+ )}
diff --git a/core/vibes/soul/sections/account-settings/newsletter-subscription-form.tsx b/core/vibes/soul/sections/account-settings/newsletter-subscription-form.tsx
new file mode 100644
index 0000000000..efc24eb954
--- /dev/null
+++ b/core/vibes/soul/sections/account-settings/newsletter-subscription-form.tsx
@@ -0,0 +1,71 @@
+'use client';
+
+import { SubmissionResult } from '@conform-to/react';
+import { useTranslations } from 'next-intl';
+import { useActionState, useEffect, useState } from 'react';
+
+import { Switch } from '@/vibes/soul/form/switch';
+import { Button } from '@/vibes/soul/primitives/button';
+import { toast } from '@/vibes/soul/primitives/toaster';
+
+type Action = (state: Awaited, payload: P) => S | Promise;
+
+interface State {
+ lastResult: SubmissionResult | null;
+ successMessage?: string;
+}
+
+export type UpdateNewsletterSubscriptionAction = Action;
+
+export interface NewsletterSubscriptionFormProps {
+ action: UpdateNewsletterSubscriptionAction;
+ isAccountSubscribed: boolean;
+ label?: string;
+ ctaLabel?: string;
+}
+
+export function NewsletterSubscriptionForm({
+ action,
+ isAccountSubscribed,
+ label = 'Opt-in to receive emails about new products and promotions.',
+ ctaLabel = 'Update',
+}: NewsletterSubscriptionFormProps) {
+ const t = useTranslations('Account.Settings.NewsletterSubscription');
+
+ const [checked, setChecked] = useState(isAccountSubscribed);
+ const [state, formAction, isPending] = useActionState(action, {
+ lastResult: null,
+ });
+
+ const onCheckedChange = (value: boolean) => {
+ setChecked(value);
+ };
+
+ useEffect(() => {
+ if (state.lastResult?.status === 'success' && state.successMessage != null) {
+ toast.success(state.successMessage);
+ }
+
+ if (state.lastResult?.error) {
+ // eslint-disable-next-line no-console
+ console.log(state.lastResult.error);
+ toast.error(t('somethingWentWrong'));
+ }
+ }, [state, t]);
+
+ return (
+
+ );
+}