Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: newsletter subscription #1523

Merged
merged 26 commits into from
Feb 27, 2024
Merged
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
7c8f601
feat: newsletter-checkbox on registration page
Oct 19, 2023
c6aa922
feat: subscription status on profile settings page
Dec 19, 2023
3df4409
fix: fixed angular 16 errors
Jan 26, 2024
839e40c
fix: implemented suggested changes, missing config-check in facade
Jan 26, 2024
8d12015
test: fix newsletter effect jests
Jan 26, 2024
ef4e421
fix: fixed jests
Jan 26, 2024
6b00117
fix: removed server-config check from actions/effects
Feb 8, 2024
3b652c7
fix: registration page guard for server-config
Feb 8, 2024
9bef87a
fix: changed formly model into stream to correctly display values on …
Feb 15, 2024
ecdc618
test: added tests for newsletter-status selector
Feb 15, 2024
916e868
refactor: updateNewsletterSubscriptionStatus service method
SGrueber Feb 19, 2024
6311670
fix: load only the newsletter if this feature is configured
SGrueber Feb 19, 2024
8776a1e
fix: flaky login-user e2e test
SGrueber Feb 19, 2024
0d9bf76
i18n: adjust DE localization
mglatter Feb 20, 2024
8088584
fixup! fix: load only the newsletter if this feature is configured
SGrueber Feb 21, 2024
b4cbdd1
i18n: adjust FR localization
mglatter Feb 21, 2024
229a0c3
test: fixed jest
Feb 22, 2024
2070b3c
fix: added suggestions for translation + docu
Feb 22, 2024
9a5d0ee
i18n: adjust FR localization after change of EN source text
mglatter Feb 22, 2024
071196e
test: removed unused testbed declarations
Feb 22, 2024
db6e7eb
fix: account-profile: don't display subscription status while value i…
Feb 22, 2024
81ce9d5
docs: newlsetter subscription guide added
SGrueber Feb 23, 2024
7f7e2f2
i18n: further adjustments of EN and FR localizations
mglatter Feb 23, 2024
df24f1f
formatting improvement and maybe sentence improvement
shauke Feb 26, 2024
627d9f9
docs: adjustments after review
mglatter Feb 26, 2024
5864675
feat: support newsletter subscription in case of sso registration
SGrueber Feb 26, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,4 @@ kb_sync_latest_only
- [Guide - Monitoring with Prometheus](./guides/prometheus-monitoring.md)
- [Guide - Store Locator with Google Maps](./guides/store-locator.md)
- [Guide - Address Check with Address Doctor](./guides/address-doctor.md)
- [Guide - E-Mail Marketing/Newsletter Subscription](./guides/newsletter-subscription.md)
26 changes: 26 additions & 0 deletions docs/guides/newsletter-subscription.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<!--
kb_guide
kb_pwa
kb_everyone
kb_sync_latest_only
-->

# E-Mail Marketing/Newsletter Subscription

## Introduction

In the PWA registered users can subscribe/unsubscribe to a newsletter service.

## Configuration

To enable this feature an e-mail marketing provider has to be configured in the ICM channel preferences / E-mail Marketing.
The PWA gets the information whether an e-mail provider is configured or not via the configurations REST call under `marketing.newsletterSubscriptionEnabled`.

## Storefront

If the newsletter subscription feature is enabled on the registration page and the account profile page a checkbox is shown that enables the user to subscribe or unsubscribe from the newsletter service.

## Further References

[Intershop Knowledge Base | Concept - E-Mail Marketing / Newsletter Subscription](https://support.intershop.com/kb/index.php/Display/2G9985)
[Intershop Knowledge Base | Cookbook - E-Mail Marketing / Newsletter Subscription](https://support.intershop.com/kb/index.php/Display/30973Y)
17 changes: 11 additions & 6 deletions e2e/cypress/e2e/specs/account/login-user.b2c.e2e-spec.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,23 @@
import { at } from '../../framework';
import { createUserViaREST } from '../../framework/users';
import { LoginPage } from '../../pages/account/login.page';
import { MyAccountPage } from '../../pages/account/my-account.page';
import { sensibleDefaults } from '../../pages/account/registration.page';
import { HomePage } from '../../pages/home.page';

const _ = {
name: 'Patricia Miller',
email: 'patricia@test.intershop.de',
user: { ...sensibleDefaults, login: `testuser${new Date().getTime()}@test.intershop.de` },
password: '!InterShop00!',
wrongPassword: 'wrong',
};

describe('Returning User', () => {
describe('with valid password', () => {
before(() => HomePage.navigateTo());
before(() => {
createUserViaREST(_.user);

HomePage.navigateTo();
});

it('should press login and be routed to login page', () => {
at(HomePage, page => {
Expand All @@ -24,11 +29,11 @@ describe('Returning User', () => {
it('should enter credentials and submit and be directed to my-account', () => {
at(LoginPage, page => {
page.errorText.should('not.exist');
page.fillForm(_.email, _.password);
page.fillForm(_.user.login, _.password);
page.submit().its('response.statusCode').should('equal', 200);
});
at(MyAccountPage, page => {
page.header.myAccountLink.should('have.text', _.name);
page.header.myAccountLink.should('have.text', `${_.user.firstName} ${_.user.lastName}`);
});
});

Expand Down Expand Up @@ -66,7 +71,7 @@ describe('Returning User', () => {

it('should enter wrong credentials and submit and be be still at login page', () => {
at(LoginPage, page => {
page.fillForm(_.email, _.wrongPassword);
page.fillForm(_.user.login, _.wrongPassword);
page.submit().its('response.statusCode').should('equal', 401);
page.errorText.should('be.visible');
});
Expand Down
29 changes: 28 additions & 1 deletion src/app/core/facades/account.facade.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Injectable } from '@angular/core';
import { Store, select } from '@ngrx/store';
import { Subject } from 'rxjs';
import { Observable, Subject, of } from 'rxjs';
import { map, switchMap, take, tap } from 'rxjs/operators';

import { Address } from 'ish-core/models/address/address.model';
Expand All @@ -13,6 +13,7 @@ import { PaymentInstrument } from 'ish-core/models/payment-instrument/payment-in
import { User } from 'ish-core/models/user/user.model';
import { OrderListQuery } from 'ish-core/services/order/order.service';
import { MessagesPayloadType } from 'ish-core/store/core/messages';
import { getServerConfigParameter } from 'ish-core/store/core/server-config';
import {
createCustomerAddress,
deleteCustomerAddress,
Expand Down Expand Up @@ -48,6 +49,7 @@ import {
getCustomerApprovalEmail,
getLoggedInCustomer,
getLoggedInUser,
getNewsletterSubscriptionStatus,
getPasswordReminderError,
getPasswordReminderSuccess,
getPriceDisplayType,
Expand All @@ -68,6 +70,7 @@ import {
updateUserPassword,
updateUserPasswordByPasswordReminder,
updateUserPreferredPayment,
userNewsletterActions,
} from 'ish-core/store/customer/user';
import { whenTruthy } from 'ish-core/utils/operators';

Expand Down Expand Up @@ -259,6 +262,30 @@ export class AccountFacade {
this.store.dispatch(updateCustomerAddress({ address }));
}

// NEWSLETTER
subscribedToNewsletter$ = this.store.pipe(select(getNewsletterSubscriptionStatus));

LucasHengelhaupt marked this conversation as resolved.
Show resolved Hide resolved
loadNewsletterSubscription(): Observable<boolean> {
return this.store.pipe(
select(getServerConfigParameter<boolean>('marketing.newsletterSubscriptionEnabled')),
take(1),
switchMap(enabled => {
if (enabled) {
this.store.dispatch(userNewsletterActions.loadUserNewsletterSubscription());
return this.store.pipe(select(getNewsletterSubscriptionStatus));
}
return of(false);
})
);
}

// should only be called when the server-configuration-parameter 'marketing.newsletterSubscriptionEnabled' is true
updateNewsletterSubscription(subscribedToNewsletter: boolean) {
LucasHengelhaupt marked this conversation as resolved.
Show resolved Hide resolved
this.store.dispatch(
userNewsletterActions.updateUserNewsletterSubscription({ subscriptionStatus: subscribedToNewsletter })
);
}

// DATA REQUESTS

dataRequestLoading$ = this.store.pipe(select(getDataRequestLoading));
Expand Down
1 change: 1 addition & 0 deletions src/app/core/models/customer/customer.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export type CustomerUserType = {
export type CustomerRegistrationType = {
credentials?: Credentials;
address: Address;
subscribedToNewsletter?: boolean;
} & CustomerUserType &
Captcha;

Expand Down
107 changes: 107 additions & 0 deletions src/app/core/services/newsletter/newsletter.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { TestBed } from '@angular/core/testing';
import { MockStore, provideMockStore } from '@ngrx/store/testing';
import { of, throwError } from 'rxjs';
import { anything, instance, mock, verify, when } from 'ts-mockito';

import { ApiService } from 'ish-core/services/api/api.service';
import { getNewsletterSubscriptionStatus } from 'ish-core/store/customer/user';
import { makeHttpError } from 'ish-core/utils/dev/api-service-utils';

import { NewsletterService } from './newsletter.service';

describe('Newsletter Service', () => {
let newsletterService: NewsletterService;
let apiServiceMock: ApiService;
let store$: MockStore;

let userEmail: string;

beforeEach(() => {
apiServiceMock = mock(ApiService);
TestBed.configureTestingModule({
providers: [{ provide: ApiService, useFactory: () => instance(apiServiceMock) }, provideMockStore()],
});
newsletterService = TestBed.inject(NewsletterService);
store$ = TestBed.inject(MockStore);

userEmail = 'user@test.com';

store$.overrideSelector(getNewsletterSubscriptionStatus, false);
});

it("should subscribe user to newsletter when 'updateNewsletterSubscriptionStatus' is called with 'true'", done => {
when(apiServiceMock.post(anything(), anything())).thenReturn(of(true));

const newStatus = true;

newsletterService.updateNewsletterSubscriptionStatus(newStatus, userEmail).subscribe(subscriptionStatus => {
verify(apiServiceMock.post(`subscriptions`, anything())).once();
expect(subscriptionStatus).toBeTrue();
done();
});
});

it("should unsubscribe user from the newsletter when 'updateNewsletterSubscriptionStatus' is called with 'false'", done => {
when(apiServiceMock.delete(anything())).thenReturn(of(false));
store$.overrideSelector(getNewsletterSubscriptionStatus, true);

const newStatus = false;

newsletterService.updateNewsletterSubscriptionStatus(newStatus, userEmail).subscribe(subscriptionStatus => {
verify(apiServiceMock.delete(`subscriptions/${userEmail}`)).once();
expect(subscriptionStatus).toBeFalse();
done();
});
});

it("should not make an API call when calling 'updateNewsletterSubscriptionStatus' and the status hasn't changed", done => {
when(apiServiceMock.delete(anything())).thenReturn(of(false));
store$.overrideSelector(getNewsletterSubscriptionStatus, true);

const newStatus = true;

newsletterService.updateNewsletterSubscriptionStatus(newStatus, userEmail).subscribe(subscriptionStatus => {
verify(apiServiceMock.delete(`subscriptions/${userEmail}`)).never();
expect(subscriptionStatus).toBeTrue();
done();
});
});

it("should get the users subscription-status when 'getSubscription' is called", done => {
when(apiServiceMock.get(anything())).thenReturn(of({ active: true }));

newsletterService.getSubscription(userEmail).subscribe(subscriptionStatus => {
verify(apiServiceMock.get(`subscriptions/${userEmail}`)).once();
expect(subscriptionStatus).toBeTrue();
done();
});

when(apiServiceMock.get(anything())).thenReturn(of({ active: false }));

newsletterService.getSubscription(userEmail).subscribe(subscriptionStatus => {
verify(apiServiceMock.get(`subscriptions/${userEmail}`)).once();
expect(subscriptionStatus).toBeFalse();
done();
});
});

it('should return false when "getSubscription" is called and a 404-error is thrown', done => {
when(apiServiceMock.get(anything())).thenReturn(
throwError(() => makeHttpError({ message: 'No subscription found', status: 404 }))
);

newsletterService.getSubscription(userEmail).subscribe(subscriptionStatus => {
verify(apiServiceMock.get(`subscriptions/${userEmail}`)).once();
expect(subscriptionStatus).toBeFalse();
done();
});

when(apiServiceMock.get(anything())).thenReturn(of({ active: false }));

newsletterService.getSubscription(userEmail).subscribe(subscriptionStatus => {
verify(apiServiceMock.get(`subscriptions/${userEmail}`)).once();
expect(subscriptionStatus).toBeFalse();
done();
});
});
});
78 changes: 78 additions & 0 deletions src/app/core/services/newsletter/newsletter.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { Injectable } from '@angular/core';
import { Store, select } from '@ngrx/store';
import { Observable, catchError, map, of, switchMap, take, throwError } from 'rxjs';

import { ApiService } from 'ish-core/services/api/api.service';
import { getNewsletterSubscriptionStatus } from 'ish-core/store/customer/user';

/**
* The Newsletter Service handles the newsletter related interaction with the 'subscriptions' REST API.
*/
@Injectable({ providedIn: 'root' })
export class NewsletterService {
constructor(private apiService: ApiService, private store: Store) {}

private newsletterSubscriptionStatus$ = this.store.pipe(select(getNewsletterSubscriptionStatus), take(1));

/**
* Gets the current newsletter subscription status of the user.
*
* @param userEmail The user email.
* @returns The current newsletter subscription status.
* Returns 'false' when a 404-error is thrown, which is the APIs response for "no subscription found".
*/
getSubscription(userEmail: string): Observable<boolean> {
LucasHengelhaupt marked this conversation as resolved.
Show resolved Hide resolved
return this.apiService.get(`subscriptions/${userEmail}`).pipe(
map((params: { active: boolean }) => params.active),
catchError(error => {
if (error.status === 404) {
return of(false);
}
return throwError(() => error);
})
);
}

/**
* Updates the newsletter subscription status of the user.
* Doesn't make a REST call when newStatus and currentStatus are the same.
*
* @param newStatus The new newsletter subscription status of the user.
* @param userEmail The user e-mail.
* @returns The new newsletter subscription status.
* Returns the current status when newStatus and currentStatus are the same.
*/
updateNewsletterSubscriptionStatus(newStatus: boolean, userEmail: string): Observable<boolean> {
// only make a REST-call when the status has changed
return this.newsletterSubscriptionStatus$.pipe(
switchMap(currentStatus => {
if (currentStatus === newStatus) {
return of(currentStatus);
}

return newStatus ? this.subscribeToNewsletter(userEmail) : this.unsubscribeFromNewsletter(userEmail);
})
);
}

/**
* always returns 'true'
*/
private subscribeToNewsletter(userEmail: string): Observable<boolean> {
const requestBody = {
name: 'Newsletter',
type: 'Subscription',
active: true,
recipient: userEmail,
};

return this.apiService.post(`subscriptions`, requestBody).pipe(map(() => true));
}

/**
* always returns 'false'
*/
private unsubscribeFromNewsletter(userEmail: string): Observable<boolean> {
return this.apiService.delete(`subscriptions/${userEmail}`).pipe(map(() => false));
}
}
2 changes: 2 additions & 0 deletions src/app/core/store/customer/customer-store.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { OrganizationManagementEffects } from './organization-management/organiz
import { RequisitionManagementEffects } from './requisition-management/requisition-management.effects';
import { SsoRegistrationEffects } from './sso-registration/sso-registration.effects';
import { ssoRegistrationReducer } from './sso-registration/sso-registration.reducer';
import { UserNewsletterEffects } from './user/user-newsletter.effects';
import { UserEffects } from './user/user.effects';
import { userReducer } from './user/user.reducer';

Expand Down Expand Up @@ -53,6 +54,7 @@ const customerEffects = [
RequisitionManagementEffects,
SsoRegistrationEffects,
DataRequestsEffects,
UserNewsletterEffects,
];

@Injectable()
Expand Down
2 changes: 2 additions & 0 deletions src/app/core/store/customer/customer-store.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { ConfigurationService } from 'ish-core/services/configuration/configurat
import { CountryService } from 'ish-core/services/country/country.service';
import { DataRequestsService } from 'ish-core/services/data-requests/data-requests.service';
import { FilterService } from 'ish-core/services/filter/filter.service';
import { NewsletterService } from 'ish-core/services/newsletter/newsletter.service';
import { OrderService } from 'ish-core/services/order/order.service';
import { PaymentService } from 'ish-core/services/payment/payment.service';
import { PricesService } from 'ish-core/services/prices/prices.service';
Expand Down Expand Up @@ -178,6 +179,7 @@ describe('Customer Store', () => {
{ provide: CookiesService, useFactory: () => instance(mock(CookiesService)) },
{ provide: DataRequestsService, useFactory: () => instance(mock(DataRequestsService)) },
{ provide: FilterService, useFactory: () => instance(mock(FilterService)) },
{ provide: NewsletterService, useFactory: () => instance(mock(NewsletterService)) },
{ provide: OrderService, useFactory: () => instance(mock(OrderService)) },
{ provide: PaymentService, useFactory: () => instance(mock(PaymentService)) },
{ provide: PricesService, useFactory: () => instance(productPriceServiceMock) },
Expand Down
Loading
Loading