├── public ├── .gitkeep └── favicon.ico ├── src ├── app │ ├── app.component.scss │ ├── app.component.html │ ├── contentful │ │ ├── root │ │ │ ├── index.ts │ │ │ ├── feature-name.ts │ │ │ ├── public_api.ts │ │ │ ├── contentful-root.module.ts │ │ │ └── config │ │ │ │ ├── default-contentful-config.ts │ │ │ │ └── contentful-config.ts │ │ ├── core │ │ │ ├── public_api.ts │ │ │ ├── helpers.ts │ │ │ ├── contentful-core.module.ts │ │ │ ├── decorators │ │ │ │ ├── index.ts │ │ │ │ ├── contentful-component-decorator.ts │ │ │ │ └── contentful-component-decorator.spec.ts │ │ │ ├── services │ │ │ │ ├── contentful-restrictions.service.ts │ │ │ │ ├── contentful-angular.service.ts │ │ │ │ ├── contentful-restrictions.service.spec.ts │ │ │ │ ├── contentful.service.ts │ │ │ │ ├── contentful-live-preview.service.ts │ │ │ │ ├── contentful-angular.service.spec.ts │ │ │ │ └── contentful-live-preview.service.spec.ts │ │ │ ├── content-types.ts │ │ │ ├── type-guards.ts │ │ │ └── type-guards.spec.ts │ │ ├── assets │ │ │ └── translations │ │ │ │ ├── de │ │ │ │ ├── index.ts │ │ │ │ ├── order.json │ │ │ │ └── product.json │ │ │ │ ├── en │ │ │ │ ├── index.ts │ │ │ │ ├── order.json │ │ │ │ └── product.json │ │ │ │ └── translations.ts │ │ ├── contentful.module.ts │ │ └── cms │ │ │ ├── adapters │ │ │ ├── converters │ │ │ │ ├── contentful-cms-component-normalizer.ts │ │ │ │ ├── components │ │ │ │ │ ├── contentful-cms-product-carousel-component-normalizer.ts │ │ │ │ │ ├── contentful-cms-banner-component-normalizer.ts │ │ │ │ │ ├── contentful-cms-navigation-component-normalizer.ts │ │ │ │ │ ├── contentful-cms-product-carousel-component-normalizer.spec.ts │ │ │ │ │ ├── contentful-cms-navigation-component-normalizer.spec.ts │ │ │ │ │ └── contentful-cms-banner-component-normalizer.spec.ts │ │ │ │ ├── contentful-cms-component-normalizer.spec.ts │ │ │ │ ├── contentful-cms-normalizers.ts │ │ │ │ ├── contentful-cms-page-normalizer.ts │ │ │ │ ├── contentful-cms-page-normalizer.spec.ts │ │ │ │ └── contentful-cms-normalizer.spec.ts │ │ │ ├── contentful-cms-component.adapter.ts │ │ │ ├── contentful-cms-page.adapter.ts │ │ │ ├── contentful-cms-component-adpater.spec.ts │ │ │ └── contentful-cms-page-adapter.spec.ts │ │ │ └── contentful-cms.module.ts │ ├── app.component.ts │ ├── spartacus │ │ ├── features │ │ │ ├── checkout │ │ │ │ ├── checkout-wrapper.module.ts │ │ │ │ └── checkout-feature.module.ts │ │ │ ├── tracking │ │ │ │ └── personalization-feature.module.ts │ │ │ ├── asm │ │ │ │ ├── asm-feature.module.ts │ │ │ │ └── asm-customer360-feature.module.ts │ │ │ ├── order │ │ │ │ └── order-feature.module.ts │ │ │ ├── cart │ │ │ │ ├── cart-saved-cart-feature.module.ts │ │ │ │ ├── cart-quick-order-feature.module.ts │ │ │ │ ├── cart-import-export-feature.module.ts │ │ │ │ ├── wish-list-feature.module.ts │ │ │ │ └── cart-base-feature.module.ts │ │ │ ├── storefinder │ │ │ │ └── store-finder-feature.module.ts │ │ │ ├── product │ │ │ │ ├── product-variants-feature.module.ts │ │ │ │ └── product-image-zoom-feature.module.ts │ │ │ ├── organization │ │ │ │ ├── organization-unit-order-feature.module.ts │ │ │ │ ├── organization-administration-feature.module.ts │ │ │ │ ├── organization-order-approval-feature.module.ts │ │ │ │ ├── organization-account-summary-feature.module.ts │ │ │ │ └── organization-user-registration-feature.module.ts │ │ │ ├── customer-ticketing │ │ │ │ └── customer-ticketing-feature.module.ts │ │ │ ├── contentful │ │ │ │ └── contentful-cms-feature.module.ts │ │ │ ├── quote │ │ │ │ └── quote-feature.module.ts │ │ │ └── user │ │ │ │ └── user-feature.module.ts │ │ ├── spartacus.module.ts │ │ ├── spartacus-configuration.module.ts │ │ └── spartacus-features.module.ts │ ├── app.component.spec.ts │ └── app.module.ts ├── styles-config.scss ├── styles │ └── spartacus │ │ ├── asm.scss │ │ ├── cart.scss │ │ ├── order.scss │ │ ├── quote.scss │ │ ├── user.scss │ │ ├── product.scss │ │ ├── checkout.scss │ │ ├── organization.scss │ │ ├── storefinder.scss │ │ └── customer-ticketing.scss ├── main.ts ├── index.html ├── environments │ ├── environment.dev.ts │ ├── environment.production.ts │ ├── environment.ts │ └── environment.local.ts └── styles.scss ├── renovate.json ├── .npmrc ├── tsconfig.spec.json ├── tsconfig.app.json ├── manifest.json ├── .editorconfig ├── .prettierrc ├── .github └── workflows │ └── codeql.yml ├── .gitignore ├── tsconfig.json ├── .eslintrc.json ├── karma.conf.js ├── package.json ├── README.md └── angular.json /public/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/app.component.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/styles-config.scss: -------------------------------------------------------------------------------- 1 | $styleVersion: 2211.31; 2 | -------------------------------------------------------------------------------- /src/app/app.component.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/app/contentful/root/index.ts: -------------------------------------------------------------------------------- 1 | export * from './public_api'; 2 | -------------------------------------------------------------------------------- /src/app/contentful/root/feature-name.ts: -------------------------------------------------------------------------------- 1 | export const CONTENTFUL_FEATURE = 'contentful'; 2 | -------------------------------------------------------------------------------- /src/styles/spartacus/asm.scss: -------------------------------------------------------------------------------- 1 | @import '../../styles-config'; 2 | @import '@spartacus/asm'; 3 | -------------------------------------------------------------------------------- /src/styles/spartacus/cart.scss: -------------------------------------------------------------------------------- 1 | @import '../../styles-config'; 2 | @import '@spartacus/cart'; 3 | -------------------------------------------------------------------------------- /src/styles/spartacus/order.scss: -------------------------------------------------------------------------------- 1 | @import '../../styles-config'; 2 | @import '@spartacus/order'; 3 | -------------------------------------------------------------------------------- /src/styles/spartacus/quote.scss: -------------------------------------------------------------------------------- 1 | @import '../../styles-config'; 2 | @import '@spartacus/quote'; 3 | -------------------------------------------------------------------------------- /src/styles/spartacus/user.scss: -------------------------------------------------------------------------------- 1 | @import '../../styles-config'; 2 | @import '@spartacus/user'; 3 | -------------------------------------------------------------------------------- /src/styles/spartacus/product.scss: -------------------------------------------------------------------------------- 1 | @import '../../styles-config'; 2 | @import '@spartacus/product'; 3 | -------------------------------------------------------------------------------- /src/styles/spartacus/checkout.scss: -------------------------------------------------------------------------------- 1 | @import '../../styles-config'; 2 | @import '@spartacus/checkout'; 3 | -------------------------------------------------------------------------------- /src/styles/spartacus/organization.scss: -------------------------------------------------------------------------------- 1 | @import '../../styles-config'; 2 | @import '@spartacus/organization'; 3 | -------------------------------------------------------------------------------- /src/styles/spartacus/storefinder.scss: -------------------------------------------------------------------------------- 1 | @import '../../styles-config'; 2 | @import '@spartacus/storefinder'; 3 | -------------------------------------------------------------------------------- /src/app/contentful/root/public_api.ts: -------------------------------------------------------------------------------- 1 | export * from './feature-name'; 2 | export * from './contentful-root.module'; 3 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/contentful/composable-storefront-integration-example/main/public/favicon.ico -------------------------------------------------------------------------------- /src/styles/spartacus/customer-ticketing.scss: -------------------------------------------------------------------------------- 1 | @import '../../styles-config'; 2 | @import '@spartacus/customer-ticketing'; 3 | -------------------------------------------------------------------------------- /src/app/contentful/core/public_api.ts: -------------------------------------------------------------------------------- 1 | export * from './services/contentful.service'; 2 | export * from './contentful-core.module'; 3 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /src/app/contentful/assets/translations/de/index.ts: -------------------------------------------------------------------------------- 1 | import order from './order.json'; 2 | import product from './product.json'; 3 | 4 | export const de = { 5 | product, 6 | order, 7 | }; 8 | -------------------------------------------------------------------------------- /src/app/contentful/assets/translations/en/index.ts: -------------------------------------------------------------------------------- 1 | import order from './order.json'; 2 | import product from './product.json'; 3 | 4 | export const en = { 5 | product, 6 | order, 7 | }; 8 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | @spartacus:registry=https://73554900100900004337.npmsrv.base.repositories.cloud.sap/ 2 | //73554900100900004337.npmsrv.base.repositories.cloud.sap/:_auth=${SAP_RBSC_TOKEN} 3 | always-auth=true 4 | ignore-scripts=true 5 | -------------------------------------------------------------------------------- /src/app/contentful/contentful.module.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule } from '@angular/common'; 2 | import { NgModule } from '@angular/core'; 3 | 4 | @NgModule({ 5 | declarations: [], 6 | imports: [CommonModule], 7 | }) 8 | export class ContentfulModule {} 9 | -------------------------------------------------------------------------------- /src/app/contentful/root/contentful-root.module.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule } from '@angular/common'; 2 | import { NgModule } from '@angular/core'; 3 | 4 | @NgModule({ 5 | declarations: [], 6 | imports: [CommonModule], 7 | }) 8 | export class ContentfulRootModule {} 9 | -------------------------------------------------------------------------------- /src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-root', 5 | templateUrl: './app.component.html', 6 | standalone: false, 7 | }) 8 | export class AppComponent { 9 | title = 'contentful-spartacus-demo'; 10 | } 11 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; 2 | 3 | import { AppModule } from './app/app.module'; 4 | 5 | platformBrowserDynamic() 6 | .bootstrapModule(AppModule, { ngZoneEventCoalescing: true }) 7 | .catch((err) => console.error(err)); 8 | -------------------------------------------------------------------------------- /tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "./out-tsc/spec", 6 | "types": ["jasmine"] 7 | }, 8 | "include": ["src/**/*.spec.ts", "src/**/*.d.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "./out-tsc/app", 6 | "types": [] 7 | }, 8 | "files": ["src/main.ts"], 9 | "include": ["src/**/*.d.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /src/app/contentful/core/helpers.ts: -------------------------------------------------------------------------------- 1 | export type DeepPartial = Depth extends 0 2 | ? T 3 | : T extends object 4 | ? { 5 | [P in keyof T]?: DeepPartial; 6 | } 7 | : T; 8 | 9 | type Prev = [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]; 10 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "applications": [ 3 | { 4 | "name": "cfi-gui", 5 | "path": "apps/cfi-gui", 6 | "ssr": { 7 | "enabled": false, 8 | "path": "dist/server/server.mjs" 9 | }, 10 | "csr": { 11 | "webroot": "dist/browser/" 12 | } 13 | } 14 | ], 15 | "nodeVersion": "20" 16 | } 17 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.ts] 12 | quote_type = single 13 | 14 | [*.md] 15 | max_line_length = off 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /src/app/contentful/core/contentful-core.module.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule } from '@angular/common'; 2 | import { NgModule } from '@angular/core'; 3 | 4 | import { contentfulDecorators } from './decorators'; 5 | 6 | @NgModule({ 7 | declarations: [], 8 | imports: [CommonModule], 9 | providers: [...contentfulDecorators], 10 | }) 11 | export class ContentfulCoreModule {} 12 | -------------------------------------------------------------------------------- /src/app/contentful/assets/translations/en/order.json: -------------------------------------------------------------------------------- 1 | { 2 | "6ApUVJOYpbKBiUG16o8QoU": { 3 | "tabs": { 4 | "2wPye9ZYiLx2VSxhdgLOTz": "ALL ORDERS ({{param}})", 5 | "3tkIzuR2T0rDVezsq73JH6": "RETURNS ({{param}})" 6 | }, 7 | "tabPanelContainerRegion": "Group with order history details", 8 | "tabPanelContainerRegionGroup": "Group with order history details" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ContentfulSpartacusDemo 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/app/contentful/assets/translations/de/order.json: -------------------------------------------------------------------------------- 1 | { 2 | "6ApUVJOYpbKBiUG16o8QoU": { 3 | "tabs": { 4 | "2wPye9ZYiLx2VSxhdgLOTz": "ALLE BESTELLUNGEN ({{param}})", 5 | "3tkIzuR2T0rDVezsq73JH6": "RÜCKSENDUNGEN ({{param}})" 6 | }, 7 | "tabPanelContainerRegion": "Mit Bestellverlaufdetails gruppieren", 8 | "tabPanelContainerRegionGroup": "Mit Bestellverlaufdetails gruppieren" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/app/contentful/core/decorators/index.ts: -------------------------------------------------------------------------------- 1 | import { Provider } from '@angular/core'; 2 | 3 | import { ComponentDecorator } from '@spartacus/core'; 4 | 5 | import { ContentfulComponentDecorator } from './contentful-component-decorator'; 6 | 7 | export const contentfulDecorators: Provider[] = [ 8 | { 9 | provide: ComponentDecorator, 10 | useExisting: ContentfulComponentDecorator, 11 | multi: true, 12 | }, 13 | ]; 14 | -------------------------------------------------------------------------------- /src/environments/environment.dev.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: true, 3 | baseUrl: 'YOUR_BASE_URL', 4 | contentful: { 5 | spaceId: 'YOUR_SPACE_ID', 6 | accessToken: 'YOUR_DELIVERY_API_TOKEN', 7 | previewAccessToken: 'YOUR_PREVIEW_API_TOKEN', 8 | environment: 'master', 9 | deliveryApiUrl: 'cdn.contentful.com', 10 | previewApiUrl: 'preview.contentful.com', 11 | }, 12 | }; 13 | 14 | export const externalModules = []; 15 | -------------------------------------------------------------------------------- /src/environments/environment.production.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: true, 3 | baseUrl: 'YOUR_BASE_URL', 4 | contentful: { 5 | spaceId: 'YOUR_SPACE_ID', 6 | accessToken: 'YOUR_DELIVERY_API_TOKEN', 7 | previewAccessToken: 'YOUR_PREVIEW_API_TOKEN', 8 | environment: 'master', 9 | deliveryApiUrl: 'cdn.contentful.com', 10 | previewApiUrl: 'preview.contentful.com', 11 | }, 12 | }; 13 | 14 | export const externalModules = []; 15 | -------------------------------------------------------------------------------- /src/app/contentful/assets/translations/en/product.json: -------------------------------------------------------------------------------- 1 | { 2 | "14QaYVMY2vuPfKSwrGgOKO": { 3 | "tabs": { 4 | "1DIVdEYj2gtcWzSFJFTXFg": "Product Details", 5 | "3l7vKtJvzrzun6t9PQJ9ld": "Specs", 6 | "18KSAUBqfYEV2AcDk4Q8WJ": "Reviews", 7 | "505vF6MGBuMNkbUCHPoLWK": "Shipping" 8 | }, 9 | "tabPanelContainerRegion": "Tab group with more product details", 10 | "tabPanelContainerRegionGroup": "Group with more product details" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/app/contentful/assets/translations/de/product.json: -------------------------------------------------------------------------------- 1 | { 2 | "14QaYVMY2vuPfKSwrGgOKO": { 3 | "tabs": { 4 | "1DIVdEYj2gtcWzSFJFTXFg": "Produktdetails", 5 | "3l7vKtJvzrzun6t9PQJ9ld": "Spezifikationen", 6 | "18KSAUBqfYEV2AcDk4Q8WJ": "Rezensionen", 7 | "505vF6MGBuMNkbUCHPoLWK": "Versand" 8 | }, 9 | "tabPanelContainerRegion": "Registerkartengruppe mit weiteren Produktdetails", 10 | "tabPanelContainerRegionGroup": "Gruppe mit weiteren Produktdetails" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/app/spartacus/features/checkout/checkout-wrapper.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | 3 | import { CheckoutB2BModule } from '@spartacus/checkout/b2b'; 4 | import { CheckoutModule } from '@spartacus/checkout/base'; 5 | import { CheckoutScheduledReplenishmentModule } from '@spartacus/checkout/scheduled-replenishment'; 6 | 7 | @NgModule({ 8 | declarations: [], 9 | imports: [CheckoutModule, CheckoutB2BModule, CheckoutScheduledReplenishmentModule], 10 | }) 11 | export class CheckoutWrapperModule {} 12 | -------------------------------------------------------------------------------- /src/app/spartacus/spartacus.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | 3 | import { BaseStorefrontModule } from '@spartacus/storefront'; 4 | 5 | import { SpartacusConfigurationModule } from './spartacus-configuration.module'; 6 | import { SpartacusFeaturesModule } from './spartacus-features.module'; 7 | 8 | @NgModule({ 9 | declarations: [], 10 | imports: [BaseStorefrontModule, SpartacusFeaturesModule, SpartacusConfigurationModule], 11 | exports: [BaseStorefrontModule], 12 | }) 13 | export class SpartacusModule {} 14 | -------------------------------------------------------------------------------- /src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | // This file can be replaced during build by using the `fileReplacements` array. 2 | // `ng build --prod` replaces `environment.ts` with `environment.prod.ts`. 3 | // The list of file replacements can be found in `angular.json`. 4 | 5 | export const environment = { 6 | production: false, 7 | baseUrl: '', 8 | contentful: { 9 | spaceId: '', 10 | accessToken: '', 11 | previewAccessToken: '', 12 | environment: '', 13 | deliveryApiUrl: '', 14 | previewApiUrl: '', 15 | }, 16 | }; 17 | 18 | export const externalModules = []; 19 | -------------------------------------------------------------------------------- /src/environments/environment.local.ts: -------------------------------------------------------------------------------- 1 | import { StoreDevtoolsModule } from '@ngrx/store-devtools'; 2 | 3 | export const environment = { 4 | production: false, 5 | baseUrl: 'https://localhost:9002', 6 | contentful: { 7 | spaceId: 'YOUR_SPACE_ID', 8 | accessToken: 'YOUR_DELIVERY_API_TOKEN', 9 | previewAccessToken: 'YOUR_PREVIEW_API_TOKEN', 10 | environment: 'master', 11 | deliveryApiUrl: 'cdn.contentful.com', 12 | previewApiUrl: 'preview.contentful.com', 13 | }, 14 | }; 15 | 16 | export const externalModules = [ 17 | StoreDevtoolsModule.instrument({ 18 | maxAge: 25, 19 | }), 20 | ]; 21 | -------------------------------------------------------------------------------- /src/app/contentful/root/config/default-contentful-config.ts: -------------------------------------------------------------------------------- 1 | import { environment } from '../../../../environments/environment'; 2 | import { ContentfulConfig } from './contentful-config'; 3 | 4 | export const defaultContentfulConfig: ContentfulConfig = { 5 | contentful: { 6 | spaceId: environment.contentful.spaceId, 7 | accessToken: environment.contentful.accessToken, 8 | previewAccessToken: environment.contentful.previewAccessToken, 9 | environment: environment.contentful.environment, 10 | deliveryApiUrl: environment.contentful.deliveryApiUrl, 11 | previewApiUrl: environment.contentful.previewApiUrl, 12 | }, 13 | }; 14 | -------------------------------------------------------------------------------- /src/app/contentful/root/config/contentful-config.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | 3 | import { Config } from '@spartacus/core'; 4 | 5 | @Injectable({ 6 | providedIn: 'root', 7 | useExisting: Config, 8 | }) 9 | export abstract class ContentfulConfig { 10 | contentful?: { 11 | spaceId?: string; 12 | accessToken?: string; 13 | previewAccessToken?: string; 14 | environment?: string; 15 | deliveryApiUrl?: string; 16 | previewApiUrl?: string; 17 | slugMapping?: { 18 | [key: string]: string; 19 | }; 20 | }; 21 | } 22 | 23 | declare module '@spartacus/core' { 24 | interface Config extends ContentfulConfig {} 25 | } 26 | -------------------------------------------------------------------------------- /src/app/contentful/assets/translations/translations.ts: -------------------------------------------------------------------------------- 1 | import { translationChunksConfig } from '@spartacus/assets'; 2 | import { TranslationChunksConfig, TranslationResources } from '@spartacus/core'; 3 | import { orderTranslationChunksConfig } from '@spartacus/order/assets'; 4 | 5 | import { de } from './de'; 6 | import { en } from './en'; 7 | 8 | export const contentfulTranslations: TranslationResources = { 9 | en, 10 | de, 11 | }; 12 | 13 | export const contentfulTranslationChunksConfig: TranslationChunksConfig = { 14 | product: [...translationChunksConfig['product'], '14QaYVMY2vuPfKSwrGgOKO'], 15 | order: [...orderTranslationChunksConfig['order'], '6ApUVJOYpbKBiUG16o8QoU'], 16 | }; 17 | -------------------------------------------------------------------------------- /src/app/spartacus/features/tracking/personalization-feature.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | 3 | import { CmsConfig, provideConfig } from '@spartacus/core'; 4 | import { PERSONALIZATION_FEATURE, PersonalizationRootModule } from '@spartacus/tracking/personalization/root'; 5 | 6 | @NgModule({ 7 | declarations: [], 8 | imports: [PersonalizationRootModule], 9 | providers: [ 10 | provideConfig({ 11 | featureModules: { 12 | [PERSONALIZATION_FEATURE]: { 13 | module: () => import('@spartacus/tracking/personalization').then((m) => m.PersonalizationModule), 14 | }, 15 | }, 16 | }), 17 | ], 18 | }) 19 | export class PersonalizationFeatureModule {} 20 | -------------------------------------------------------------------------------- /src/app/app.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { TestBed } from '@angular/core/testing'; 3 | 4 | import { AppComponent } from './app.component'; 5 | 6 | @Component({ 7 | selector: 'cx-storefront', 8 | template: '', 9 | standalone: false, 10 | }) 11 | class MockCxStorefrontComponent {} 12 | 13 | describe('AppComponent', () => { 14 | beforeEach(async () => { 15 | await TestBed.configureTestingModule({ 16 | declarations: [AppComponent, MockCxStorefrontComponent], 17 | }).compileComponents(); 18 | }); 19 | 20 | it('should create the app', () => { 21 | const fixture = TestBed.createComponent(AppComponent); 22 | const app = fixture.componentInstance; 23 | expect(app).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "bracketSpacing": true, 4 | "endOfLine": "auto", 5 | "bracketSameLine": false, 6 | "singleQuote": true, 7 | "printWidth": 160, 8 | "proseWrap": "never", 9 | "quoteProps": "preserve", 10 | "semi": true, 11 | "tabWidth": 2, 12 | "trailingComma": "all", 13 | "useTabs": false, 14 | "importOrder": [ 15 | "^@(?!spartacus)(.*)/(.*)$", 16 | "^@spartacus/(.*)$", 17 | "^rxjs/(.*)$", 18 | "", 19 | "^[./](.*).module$", 20 | "^[./](.*).component$", 21 | "^[./]" 22 | ], 23 | "importOrderParserPlugins": ["typescript", "classProperties", "decorators-legacy"], 24 | "importOrderSeparation": true, 25 | "importOrderSortSpecifiers": true, 26 | "plugins": ["@trivago/prettier-plugin-sort-imports"] 27 | } 28 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: "CodeQL Scan for GitHub Actions Workflows" 3 | 4 | on: 5 | push: 6 | branches: [main] 7 | paths: [".github/workflows/**"] 8 | pull_request: 9 | branches: [main] 10 | paths: [".github/workflows/**"] 11 | 12 | jobs: 13 | analyze: 14 | name: Analyze GitHub Actions workflows 15 | runs-on: ubuntu-latest 16 | permissions: 17 | actions: read 18 | contents: read 19 | security-events: write 20 | 21 | steps: 22 | - uses: actions/checkout@v5 23 | 24 | - name: Initialize CodeQL 25 | uses: github/codeql-action/init@v3 26 | with: 27 | languages: actions 28 | 29 | - name: Run CodeQL Analysis 30 | uses: github/codeql-action/analyze@v3 31 | with: 32 | category: actions 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files. 2 | 3 | # Compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | /bazel-out 8 | .nx/ 9 | .github/ 10 | 11 | # Node 12 | /node_modules 13 | npm-debug.log 14 | yarn-error.log 15 | 16 | # IDEs and editors 17 | .idea/ 18 | .project 19 | .classpath 20 | .c9/ 21 | *.launch 22 | .settings/ 23 | *.sublime-workspace 24 | 25 | # Visual Studio Code 26 | .vscode/* 27 | !.vscode/settings.json 28 | !.vscode/tasks.json 29 | !.vscode/launch.json 30 | !.vscode/extensions.json 31 | .history/* 32 | 33 | # Miscellaneous 34 | /.angular/cache 35 | .sass-cache/ 36 | /connect.lock 37 | /coverage 38 | /libpeerconnection.log 39 | testem.log 40 | /typings 41 | /reports 42 | .sonarcache 43 | 44 | # System files 45 | .DS_Store 46 | Thumbs.db 47 | -------------------------------------------------------------------------------- /src/app/spartacus/features/asm/asm-feature.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | 3 | import { asmTranslationChunksConfig, asmTranslations } from '@spartacus/asm/assets'; 4 | import { ASM_FEATURE, AsmRootModule } from '@spartacus/asm/root'; 5 | import { CmsConfig, I18nConfig, provideConfig } from '@spartacus/core'; 6 | 7 | @NgModule({ 8 | declarations: [], 9 | imports: [AsmRootModule], 10 | providers: [ 11 | provideConfig({ 12 | featureModules: { 13 | [ASM_FEATURE]: { 14 | module: () => import('@spartacus/asm').then((m) => m.AsmModule), 15 | }, 16 | }, 17 | }), 18 | provideConfig({ 19 | i18n: { 20 | resources: asmTranslations, 21 | chunks: asmTranslationChunksConfig, 22 | }, 23 | }), 24 | ], 25 | }) 26 | export class AsmFeatureModule {} 27 | -------------------------------------------------------------------------------- /src/app/spartacus/features/order/order-feature.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | 3 | import { CmsConfig, I18nConfig, provideConfig } from '@spartacus/core'; 4 | import { orderTranslationChunksConfig, orderTranslations } from '@spartacus/order/assets'; 5 | import { ORDER_FEATURE, OrderRootModule } from '@spartacus/order/root'; 6 | 7 | @NgModule({ 8 | declarations: [], 9 | imports: [OrderRootModule], 10 | providers: [ 11 | provideConfig({ 12 | featureModules: { 13 | [ORDER_FEATURE]: { 14 | module: () => import('@spartacus/order').then((m) => m.OrderModule), 15 | }, 16 | }, 17 | }), 18 | provideConfig({ 19 | i18n: { 20 | resources: orderTranslations, 21 | chunks: orderTranslationChunksConfig, 22 | }, 23 | }), 24 | ], 25 | }) 26 | export class OrderFeatureModule {} 27 | -------------------------------------------------------------------------------- /src/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import { provideHttpClient, withFetch, withInterceptorsFromDi } from '@angular/common/http'; 2 | import { NgModule } from '@angular/core'; 3 | import { BrowserModule } from '@angular/platform-browser'; 4 | import { EffectsModule } from '@ngrx/effects'; 5 | import { StoreModule } from '@ngrx/store'; 6 | 7 | import { AppRoutingModule } from '@spartacus/storefront'; 8 | 9 | import { SpartacusModule } from './spartacus/spartacus.module'; 10 | 11 | import { AppComponent } from './app.component'; 12 | 13 | import { externalModules } from '../environments/environment'; 14 | 15 | @NgModule({ 16 | declarations: [AppComponent], 17 | imports: [BrowserModule, StoreModule.forRoot({}), AppRoutingModule, EffectsModule.forRoot([]), SpartacusModule, ...externalModules], 18 | providers: [provideHttpClient(withFetch(), withInterceptorsFromDi())], 19 | bootstrap: [AppComponent], 20 | }) 21 | export class AppModule {} 22 | -------------------------------------------------------------------------------- /src/app/spartacus/features/cart/cart-saved-cart-feature.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | 3 | import { savedCartTranslationChunksConfig, savedCartTranslations } from '@spartacus/cart/saved-cart/assets'; 4 | import { CART_SAVED_CART_FEATURE, SavedCartRootModule } from '@spartacus/cart/saved-cart/root'; 5 | import { CmsConfig, I18nConfig, provideConfig } from '@spartacus/core'; 6 | 7 | @NgModule({ 8 | declarations: [], 9 | imports: [SavedCartRootModule], 10 | providers: [ 11 | provideConfig({ 12 | featureModules: { 13 | [CART_SAVED_CART_FEATURE]: { 14 | module: () => import('@spartacus/cart/saved-cart').then((m) => m.SavedCartModule), 15 | }, 16 | }, 17 | }), 18 | provideConfig({ 19 | i18n: { 20 | resources: savedCartTranslations, 21 | chunks: savedCartTranslationChunksConfig, 22 | }, 23 | }), 24 | ], 25 | }) 26 | export class CartSavedCartFeatureModule {} 27 | -------------------------------------------------------------------------------- /src/app/spartacus/features/storefinder/store-finder-feature.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | 3 | import { CmsConfig, I18nConfig, provideConfig } from '@spartacus/core'; 4 | import { storeFinderTranslationChunksConfig, storeFinderTranslations } from '@spartacus/storefinder/assets'; 5 | import { STORE_FINDER_FEATURE, StoreFinderRootModule } from '@spartacus/storefinder/root'; 6 | 7 | @NgModule({ 8 | declarations: [], 9 | imports: [StoreFinderRootModule], 10 | providers: [ 11 | provideConfig({ 12 | featureModules: { 13 | [STORE_FINDER_FEATURE]: { 14 | module: () => import('@spartacus/storefinder').then((m) => m.StoreFinderModule), 15 | }, 16 | }, 17 | }), 18 | provideConfig({ 19 | i18n: { 20 | resources: storeFinderTranslations, 21 | chunks: storeFinderTranslationChunksConfig, 22 | }, 23 | }), 24 | ], 25 | }) 26 | export class StoreFinderFeatureModule {} 27 | -------------------------------------------------------------------------------- /src/app/spartacus/features/cart/cart-quick-order-feature.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | 3 | import { quickOrderTranslationChunksConfig, quickOrderTranslations } from '@spartacus/cart/quick-order/assets'; 4 | import { CART_QUICK_ORDER_FEATURE, QuickOrderRootModule } from '@spartacus/cart/quick-order/root'; 5 | import { CmsConfig, I18nConfig, provideConfig } from '@spartacus/core'; 6 | 7 | @NgModule({ 8 | declarations: [], 9 | imports: [QuickOrderRootModule], 10 | providers: [ 11 | provideConfig({ 12 | featureModules: { 13 | [CART_QUICK_ORDER_FEATURE]: { 14 | module: () => import('@spartacus/cart/quick-order').then((m) => m.QuickOrderModule), 15 | }, 16 | }, 17 | }), 18 | provideConfig({ 19 | i18n: { 20 | resources: quickOrderTranslations, 21 | chunks: quickOrderTranslationChunksConfig, 22 | }, 23 | }), 24 | ], 25 | }) 26 | export class CartQuickOrderFeatureModule {} 27 | -------------------------------------------------------------------------------- /src/app/contentful/core/decorators/contentful-component-decorator.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Renderer2 } from '@angular/core'; 2 | 3 | import { ComponentDecorator, ContentSlotComponentData } from '@spartacus/core'; 4 | 5 | import { ContentfulLivePreviewService } from '../services/contentful-live-preview.service'; 6 | 7 | @Injectable({ 8 | providedIn: 'root', 9 | }) 10 | export class ContentfulComponentDecorator extends ComponentDecorator { 11 | constructor(protected contentfulLivePreviewService: ContentfulLivePreviewService) { 12 | super(); 13 | } 14 | 15 | decorate(element: Element, renderer: Renderer2, component: ContentSlotComponentData): void { 16 | if (!component) { 17 | return; 18 | } 19 | 20 | if (!this.contentfulLivePreviewService.hasInspectorModeTags(element)) { 21 | this.contentfulLivePreviewService.addInspectorModeTags(element, renderer, component); 22 | } 23 | 24 | this.contentfulLivePreviewService.initComponentLiveUpdate(component); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "compileOnSave": false, 4 | "compilerOptions": { 5 | "outDir": "./dist/out-tsc", 6 | "strict": true, 7 | "noImplicitOverride": true, 8 | "noPropertyAccessFromIndexSignature": true, 9 | "noImplicitReturns": true, 10 | "noFallthroughCasesInSwitch": true, 11 | "skipLibCheck": true, 12 | "esModuleInterop": true, 13 | "resolveJsonModule": true, 14 | "sourceMap": true, 15 | "declaration": false, 16 | "experimentalDecorators": true, 17 | "moduleResolution": "node", 18 | "importHelpers": true, 19 | "target": "ES2022", 20 | "module": "ES2022", 21 | "useDefineForClassFields": false, 22 | "lib": ["ES2022", "dom"] 23 | }, 24 | "angularCompilerOptions": { 25 | "enableI18nLegacyMessageIdFormat": false, 26 | "strictInjectionParameters": true, 27 | "strictInputAccessModifiers": true, 28 | "strictTemplates": true 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/app/contentful/core/services/contentful-restrictions.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | 3 | import { User } from '@spartacus/core'; 4 | 5 | import { Entry } from 'contentful'; 6 | 7 | @Injectable({ 8 | providedIn: 'root', 9 | }) 10 | export class RestrictionsService { 11 | private permissions: string[] = []; 12 | 13 | setUserPermissions(user: User | undefined) { 14 | this.permissions = this.extractPermissionsFromUser(user); 15 | } 16 | 17 | isEntryAccessible(entry: Entry): boolean { 18 | if (entry.metadata.tags.length === 0) { 19 | return true; 20 | } 21 | 22 | return !entry.metadata.tags.some((tag) => tag.sys.id.startsWith('_require-') && !this.permissions.includes(tag.sys.id)); 23 | } 24 | 25 | private extractPermissionsFromUser(user: User | undefined): string[] { 26 | if (!user) { 27 | return ['_require-anonymous']; 28 | } 29 | 30 | return ['_require-login', ...(user.roles ?? []).map((role) => `_require-${role}`)]; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/app/spartacus/features/asm/asm-customer360-feature.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | 3 | import { asmCustomer360TranslationChunksConfig, asmCustomer360Translations } from '@spartacus/asm/customer-360/assets'; 4 | import { ASM_CUSTOMER_360_FEATURE, AsmCustomer360RootModule } from '@spartacus/asm/customer-360/root'; 5 | import { CmsConfig, I18nConfig, provideConfig } from '@spartacus/core'; 6 | 7 | @NgModule({ 8 | declarations: [], 9 | imports: [AsmCustomer360RootModule], 10 | providers: [ 11 | provideConfig({ 12 | featureModules: { 13 | [ASM_CUSTOMER_360_FEATURE]: { 14 | module: () => import('@spartacus/asm/customer-360').then((m) => m.AsmCustomer360Module), 15 | }, 16 | }, 17 | }), 18 | provideConfig({ 19 | i18n: { 20 | resources: asmCustomer360Translations, 21 | chunks: asmCustomer360TranslationChunksConfig, 22 | }, 23 | }), 24 | ], 25 | }) 26 | export class AsmCustomer360FeatureModule {} 27 | -------------------------------------------------------------------------------- /src/app/spartacus/features/cart/cart-import-export-feature.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | 3 | import { importExportTranslationChunksConfig, importExportTranslations } from '@spartacus/cart/import-export/assets'; 4 | import { CART_IMPORT_EXPORT_FEATURE, ImportExportRootModule } from '@spartacus/cart/import-export/root'; 5 | import { CmsConfig, I18nConfig, provideConfig } from '@spartacus/core'; 6 | 7 | @NgModule({ 8 | declarations: [], 9 | imports: [ImportExportRootModule], 10 | providers: [ 11 | provideConfig({ 12 | featureModules: { 13 | [CART_IMPORT_EXPORT_FEATURE]: { 14 | module: () => import('@spartacus/cart/import-export').then((m) => m.ImportExportModule), 15 | }, 16 | }, 17 | }), 18 | provideConfig({ 19 | i18n: { 20 | resources: importExportTranslations, 21 | chunks: importExportTranslationChunksConfig, 22 | }, 23 | }), 24 | ], 25 | }) 26 | export class CartImportExportFeatureModule {} 27 | -------------------------------------------------------------------------------- /src/app/spartacus/features/product/product-variants-feature.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | 3 | import { CmsConfig, I18nConfig, provideConfig } from '@spartacus/core'; 4 | import { productVariantsTranslationChunksConfig, productVariantsTranslations } from '@spartacus/product/variants/assets'; 5 | import { PRODUCT_VARIANTS_FEATURE, ProductVariantsRootModule } from '@spartacus/product/variants/root'; 6 | 7 | @NgModule({ 8 | declarations: [], 9 | imports: [ProductVariantsRootModule], 10 | providers: [ 11 | provideConfig({ 12 | featureModules: { 13 | [PRODUCT_VARIANTS_FEATURE]: { 14 | module: () => import('@spartacus/product/variants').then((m) => m.ProductVariantsModule), 15 | }, 16 | }, 17 | }), 18 | provideConfig({ 19 | i18n: { 20 | resources: productVariantsTranslations, 21 | chunks: productVariantsTranslationChunksConfig, 22 | }, 23 | }), 24 | ], 25 | }) 26 | export class ProductVariantsFeatureModule {} 27 | -------------------------------------------------------------------------------- /src/app/spartacus/features/organization/organization-unit-order-feature.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | 3 | import { CmsConfig, I18nConfig, provideConfig } from '@spartacus/core'; 4 | import { unitOrderTranslationChunksConfig, unitOrderTranslations } from '@spartacus/organization/unit-order/assets'; 5 | import { ORGANIZATION_UNIT_ORDER_FEATURE, UnitOrderRootModule } from '@spartacus/organization/unit-order/root'; 6 | 7 | @NgModule({ 8 | declarations: [], 9 | imports: [UnitOrderRootModule], 10 | providers: [ 11 | provideConfig({ 12 | featureModules: { 13 | [ORGANIZATION_UNIT_ORDER_FEATURE]: { 14 | module: () => import('@spartacus/organization/unit-order').then((m) => m.UnitOrderModule), 15 | }, 16 | }, 17 | }), 18 | provideConfig({ 19 | i18n: { 20 | resources: unitOrderTranslations, 21 | chunks: unitOrderTranslationChunksConfig, 22 | }, 23 | }), 24 | ], 25 | }) 26 | export class OrganizationUnitOrderFeatureModule {} 27 | -------------------------------------------------------------------------------- /src/app/spartacus/features/product/product-image-zoom-feature.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | 3 | import { CmsConfig, I18nConfig, provideConfig } from '@spartacus/core'; 4 | import { productImageZoomTranslationChunksConfig, productImageZoomTranslations } from '@spartacus/product/image-zoom/assets'; 5 | import { PRODUCT_IMAGE_ZOOM_FEATURE, ProductImageZoomRootModule } from '@spartacus/product/image-zoom/root'; 6 | 7 | @NgModule({ 8 | declarations: [], 9 | imports: [ProductImageZoomRootModule], 10 | providers: [ 11 | provideConfig({ 12 | featureModules: { 13 | [PRODUCT_IMAGE_ZOOM_FEATURE]: { 14 | module: () => import('@spartacus/product/image-zoom').then((m) => m.ProductImageZoomModule), 15 | }, 16 | }, 17 | }), 18 | provideConfig({ 19 | i18n: { 20 | resources: productImageZoomTranslations, 21 | chunks: productImageZoomTranslationChunksConfig, 22 | }, 23 | }), 24 | ], 25 | }) 26 | export class ProductImageZoomFeatureModule {} 27 | -------------------------------------------------------------------------------- /src/app/spartacus/features/customer-ticketing/customer-ticketing-feature.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | 3 | import { CmsConfig, I18nConfig, provideConfig } from '@spartacus/core'; 4 | import { customerTicketingTranslationChunksConfig, customerTicketingTranslations } from '@spartacus/customer-ticketing/assets'; 5 | import { CUSTOMER_TICKETING_FEATURE, CustomerTicketingRootModule } from '@spartacus/customer-ticketing/root'; 6 | 7 | @NgModule({ 8 | declarations: [], 9 | imports: [CustomerTicketingRootModule], 10 | providers: [ 11 | provideConfig({ 12 | featureModules: { 13 | [CUSTOMER_TICKETING_FEATURE]: { 14 | module: () => import('@spartacus/customer-ticketing').then((m) => m.CustomerTicketingModule), 15 | }, 16 | }, 17 | }), 18 | provideConfig({ 19 | i18n: { 20 | resources: customerTicketingTranslations, 21 | chunks: customerTicketingTranslationChunksConfig, 22 | }, 23 | }), 24 | ], 25 | }) 26 | export class CustomerTicketingFeatureModule {} 27 | -------------------------------------------------------------------------------- /src/app/spartacus/features/organization/organization-administration-feature.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | 3 | import { CmsConfig, I18nConfig, provideConfig } from '@spartacus/core'; 4 | import { organizationTranslationChunksConfig, organizationTranslations } from '@spartacus/organization/administration/assets'; 5 | import { AdministrationRootModule, ORGANIZATION_ADMINISTRATION_FEATURE } from '@spartacus/organization/administration/root'; 6 | 7 | @NgModule({ 8 | declarations: [], 9 | imports: [AdministrationRootModule], 10 | providers: [ 11 | provideConfig({ 12 | featureModules: { 13 | [ORGANIZATION_ADMINISTRATION_FEATURE]: { 14 | module: () => import('@spartacus/organization/administration').then((m) => m.AdministrationModule), 15 | }, 16 | }, 17 | }), 18 | provideConfig({ 19 | i18n: { 20 | resources: organizationTranslations, 21 | chunks: organizationTranslationChunksConfig, 22 | }, 23 | }), 24 | ], 25 | }) 26 | export class OrganizationAdministrationFeatureModule {} 27 | -------------------------------------------------------------------------------- /src/app/spartacus/features/organization/organization-order-approval-feature.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | 3 | import { CmsConfig, I18nConfig, provideConfig } from '@spartacus/core'; 4 | import { orderApprovalTranslationChunksConfig, orderApprovalTranslations } from '@spartacus/organization/order-approval/assets'; 5 | import { ORGANIZATION_ORDER_APPROVAL_FEATURE, OrderApprovalRootModule } from '@spartacus/organization/order-approval/root'; 6 | 7 | @NgModule({ 8 | declarations: [], 9 | imports: [OrderApprovalRootModule], 10 | providers: [ 11 | provideConfig({ 12 | featureModules: { 13 | [ORGANIZATION_ORDER_APPROVAL_FEATURE]: { 14 | module: () => import('@spartacus/organization/order-approval').then((m) => m.OrderApprovalModule), 15 | }, 16 | }, 17 | }), 18 | provideConfig({ 19 | i18n: { 20 | resources: orderApprovalTranslations, 21 | chunks: orderApprovalTranslationChunksConfig, 22 | }, 23 | }), 24 | ], 25 | }) 26 | export class OrganizationOrderApprovalFeatureModule {} 27 | -------------------------------------------------------------------------------- /src/app/spartacus/features/organization/organization-account-summary-feature.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | 3 | import { CmsConfig, I18nConfig, provideConfig } from '@spartacus/core'; 4 | import { accountSummaryTranslationChunksConfig, accountSummaryTranslations } from '@spartacus/organization/account-summary/assets'; 5 | import { AccountSummaryRootModule, ORGANIZATION_ACCOUNT_SUMMARY_FEATURE } from '@spartacus/organization/account-summary/root'; 6 | 7 | @NgModule({ 8 | declarations: [], 9 | imports: [AccountSummaryRootModule], 10 | providers: [ 11 | provideConfig({ 12 | featureModules: { 13 | [ORGANIZATION_ACCOUNT_SUMMARY_FEATURE]: { 14 | module: () => import('@spartacus/organization/account-summary').then((m) => m.AccountSummaryModule), 15 | }, 16 | }, 17 | }), 18 | provideConfig({ 19 | i18n: { 20 | resources: accountSummaryTranslations, 21 | chunks: accountSummaryTranslationChunksConfig, 22 | }, 23 | }), 24 | ], 25 | }) 26 | export class OrganizationAccountSummaryFeatureModule {} 27 | -------------------------------------------------------------------------------- /src/app/contentful/cms/adapters/converters/contentful-cms-component-normalizer.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | 3 | import { CmsComponent, Converter } from '@spartacus/core'; 4 | 5 | import { Entry } from 'contentful'; 6 | 7 | import { ComponentSkeleton } from '../../../core/content-types'; 8 | import { RestrictionsService } from '../../../core/services/contentful-restrictions.service'; 9 | 10 | @Injectable({ providedIn: 'root' }) 11 | export class ContentfulCmsComponentNormalizer implements Converter, CmsComponent> { 12 | constructor(private readonly restrictionsService: RestrictionsService) {} 13 | 14 | convert(source: Entry, target: CmsComponent): CmsComponent { 15 | if (!this.restrictionsService.isEntryAccessible(source)) { 16 | return { 17 | uid: source.sys.id, 18 | }; 19 | } 20 | 21 | target = { 22 | ...target, 23 | ...source.fields, 24 | uid: source.sys.id, 25 | typeCode: source.sys.contentType.sys.id, 26 | }; 27 | 28 | return target; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/styles.scss: -------------------------------------------------------------------------------- 1 | @import 'styles-config'; 2 | 3 | // ORDER IMPORTANT: Spartacus core first 4 | @import '@spartacus/styles/scss/core'; 5 | 6 | // ORDER IMPORTANT: Copy of Bootstrap files next 7 | @import '@spartacus/styles/vendor/bootstrap/scss/reboot'; 8 | @import '@spartacus/styles/vendor/bootstrap/scss/type'; 9 | @import '@spartacus/styles/vendor/bootstrap/scss/grid'; 10 | @import '@spartacus/styles/vendor/bootstrap/scss/utilities'; 11 | @import '@spartacus/styles/vendor/bootstrap/scss/transitions'; 12 | @import '@spartacus/styles/vendor/bootstrap/scss/dropdown'; 13 | @import '@spartacus/styles/vendor/bootstrap/scss/card'; 14 | @import '@spartacus/styles/vendor/bootstrap/scss/nav'; 15 | @import '@spartacus/styles/vendor/bootstrap/scss/buttons'; 16 | @import '@spartacus/styles/vendor/bootstrap/scss/forms'; 17 | @import '@spartacus/styles/vendor/bootstrap/scss/custom-forms'; 18 | @import '@spartacus/styles/vendor/bootstrap/scss/modal'; 19 | @import '@spartacus/styles/vendor/bootstrap/scss/close'; 20 | @import '@spartacus/styles/vendor/bootstrap/scss/alert'; 21 | @import '@spartacus/styles/vendor/bootstrap/scss/tooltip'; 22 | 23 | // ORDER IMPORTANT: Spartacus styles last 24 | @import '@spartacus/styles/index'; 25 | -------------------------------------------------------------------------------- /src/app/spartacus/features/organization/organization-user-registration-feature.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | 3 | import { CmsConfig, I18nConfig, provideConfig } from '@spartacus/core'; 4 | import { 5 | organizationUserRegistrationTranslationChunksConfig, 6 | organizationUserRegistrationTranslations, 7 | } from '@spartacus/organization/user-registration/assets'; 8 | import { ORGANIZATION_USER_REGISTRATION_FEATURE, OrganizationUserRegistrationRootModule } from '@spartacus/organization/user-registration/root'; 9 | 10 | @NgModule({ 11 | declarations: [], 12 | imports: [OrganizationUserRegistrationRootModule], 13 | providers: [ 14 | provideConfig({ 15 | featureModules: { 16 | [ORGANIZATION_USER_REGISTRATION_FEATURE]: { 17 | module: () => import('@spartacus/organization/user-registration').then((m) => m.OrganizationUserRegistrationModule), 18 | }, 19 | }, 20 | }), 21 | provideConfig({ 22 | i18n: { 23 | resources: organizationUserRegistrationTranslations, 24 | chunks: organizationUserRegistrationTranslationChunksConfig, 25 | }, 26 | }), 27 | ], 28 | }) 29 | export class OrganizationUserRegistrationFeatureModule {} 30 | -------------------------------------------------------------------------------- /src/app/spartacus/features/cart/wish-list-feature.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | 3 | import { wishListTranslationChunksConfig, wishListTranslations } from '@spartacus/cart/wish-list/assets'; 4 | import { ADD_TO_WISHLIST_FEATURE, CART_WISH_LIST_FEATURE, WishListRootModule } from '@spartacus/cart/wish-list/root'; 5 | import { CmsConfig, I18nConfig, provideConfig } from '@spartacus/core'; 6 | 7 | @NgModule({ 8 | declarations: [], 9 | imports: [WishListRootModule], 10 | providers: [ 11 | provideConfig({ 12 | featureModules: { 13 | [CART_WISH_LIST_FEATURE]: { 14 | module: () => import('@spartacus/cart/wish-list').then((m) => m.WishListModule), 15 | }, 16 | }, 17 | }), 18 | provideConfig({ 19 | featureModules: { 20 | [ADD_TO_WISHLIST_FEATURE]: { 21 | module: () => import('@spartacus/cart/wish-list/components/add-to-wishlist').then((m) => m.AddToWishListModule), 22 | }, 23 | }, 24 | }), 25 | provideConfig({ 26 | i18n: { 27 | resources: wishListTranslations, 28 | chunks: wishListTranslationChunksConfig, 29 | }, 30 | }), 31 | ], 32 | }) 33 | export class WishListFeatureModule {} 34 | -------------------------------------------------------------------------------- /src/app/spartacus/features/contentful/contentful-cms-feature.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | 3 | import { CmsConfig, I18nConfig, provideConfig } from '@spartacus/core'; 4 | 5 | import { ContentfulCmsModule } from '../../../contentful/cms/contentful-cms.module'; 6 | import { ContentfulCoreModule } from '../../../contentful/core/contentful-core.module'; 7 | 8 | import { contentfulTranslationChunksConfig, contentfulTranslations } from '../../../contentful/assets/translations/translations'; 9 | import { CONTENTFUL_FEATURE, ContentfulRootModule } from '../../../contentful/root'; 10 | 11 | @NgModule({ 12 | declarations: [], 13 | imports: [ContentfulRootModule, ContentfulCmsModule, ContentfulCoreModule], 14 | providers: [ 15 | provideConfig({ 16 | featureModules: { 17 | [CONTENTFUL_FEATURE]: { 18 | module: () => import('../../../contentful/contentful.module').then((m) => m.ContentfulModule), 19 | }, 20 | }, 21 | cmsComponents: {}, // custom components 22 | }), 23 | provideConfig({ 24 | i18n: { 25 | resources: contentfulTranslations, 26 | chunks: contentfulTranslationChunksConfig, 27 | }, 28 | }), 29 | ], 30 | }) 31 | export class ContentfulCMSFeatureModule {} 32 | -------------------------------------------------------------------------------- /src/app/contentful/cms/adapters/converters/components/contentful-cms-product-carousel-component-normalizer.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | 3 | import { CmsComponent, CmsProductCarouselComponent, Converter } from '@spartacus/core'; 4 | 5 | import { Entry } from 'contentful'; 6 | 7 | import { ComponentSkeleton } from '../../../../core/content-types'; 8 | import { isString } from '../../../../core/type-guards'; 9 | 10 | @Injectable({ providedIn: 'root' }) 11 | export class ContentfulCmsProductCarouselComponentNormalizer implements Converter, CmsComponent> { 12 | constructor() {} 13 | 14 | convert(source: Entry, target: CmsComponent): CmsComponent { 15 | if (source.sys.contentType.sys.id === 'ProductCarouselComponent') { 16 | this.normalizeProductCodes(source, target); 17 | } 18 | 19 | return target; 20 | } 21 | 22 | private normalizeProductCodes(source: Entry, component: CmsProductCarouselComponent): void { 23 | if (Array.isArray(source.fields['products'])) { 24 | component.productCodes = source.fields['products'] 25 | .map((productUrl) => (isString(productUrl) ? productUrl.split('/').pop() : '')) 26 | .filter(Boolean) 27 | .join(' '); 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "ignorePatterns": ["projects/**/*", "src/app/contentful/root/config/contentful-config.ts"], 4 | "overrides": [ 5 | { 6 | "files": ["*.ts"], 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:@typescript-eslint/recommended", 10 | "plugin:@angular-eslint/recommended", 11 | "plugin:@angular-eslint/template/process-inline-templates", 12 | "prettier" 13 | ], 14 | "rules": { 15 | "@angular-eslint/directive-selector": [ 16 | "error", 17 | { 18 | "type": "attribute", 19 | "prefix": "app", 20 | "style": "camelCase" 21 | } 22 | ], 23 | "@angular-eslint/component-selector": [ 24 | "error", 25 | { 26 | "type": "element", 27 | "prefix": ["app", "cx"], 28 | "style": "kebab-case" 29 | } 30 | ], 31 | "@angular-eslint/prefer-standalone": "off" 32 | } 33 | }, 34 | { 35 | "files": ["*.html"], 36 | "extends": ["plugin:@angular-eslint/template/recommended", "plugin:@angular-eslint/template/accessibility"], 37 | "rules": {} 38 | }, 39 | { 40 | "files": ["*.spec.ts"], 41 | "rules": { 42 | "@typescript-eslint/no-explicit-any": "off" 43 | } 44 | } 45 | ] 46 | } 47 | -------------------------------------------------------------------------------- /src/app/spartacus/features/quote/quote-feature.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | 3 | import { CmsConfig, I18nConfig, provideConfig } from '@spartacus/core'; 4 | import { quoteTranslationChunksConfig, quoteTranslations } from '@spartacus/quote/assets'; 5 | import { QUOTE_CART_GUARD_FEATURE, QUOTE_FEATURE, QUOTE_REQUEST_FEATURE, QuoteRootModule } from '@spartacus/quote/root'; 6 | 7 | @NgModule({ 8 | declarations: [], 9 | imports: [QuoteRootModule], 10 | providers: [ 11 | provideConfig({ 12 | featureModules: { 13 | [QUOTE_FEATURE]: { 14 | module: () => import('@spartacus/quote').then((m) => m.QuoteModule), 15 | }, 16 | }, 17 | }), 18 | provideConfig({ 19 | featureModules: { 20 | [QUOTE_CART_GUARD_FEATURE]: { 21 | module: () => import('@spartacus/quote/components/cart-guard').then((m) => m.QuoteCartGuardComponentModule), 22 | }, 23 | }, 24 | }), 25 | provideConfig({ 26 | featureModules: { 27 | [QUOTE_REQUEST_FEATURE]: { 28 | module: () => import('@spartacus/quote/components/request-button').then((m) => m.QuoteRequestButtonModule), 29 | }, 30 | }, 31 | }), 32 | provideConfig({ 33 | i18n: { 34 | resources: quoteTranslations, 35 | chunks: quoteTranslationChunksConfig, 36 | }, 37 | }), 38 | ], 39 | }) 40 | export class QuoteFeatureModule {} 41 | -------------------------------------------------------------------------------- /src/app/spartacus/features/cart/cart-base-feature.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | 3 | import { cartBaseTranslationChunksConfig, cartBaseTranslations } from '@spartacus/cart/base/assets'; 4 | import { ADD_TO_CART_FEATURE, CART_BASE_FEATURE, CartBaseRootModule, MINI_CART_FEATURE } from '@spartacus/cart/base/root'; 5 | import { CmsConfig, I18nConfig, provideConfig } from '@spartacus/core'; 6 | 7 | @NgModule({ 8 | declarations: [], 9 | imports: [CartBaseRootModule], 10 | providers: [ 11 | provideConfig({ 12 | featureModules: { 13 | [CART_BASE_FEATURE]: { 14 | module: () => import('@spartacus/cart/base').then((m) => m.CartBaseModule), 15 | }, 16 | }, 17 | }), 18 | provideConfig({ 19 | featureModules: { 20 | [MINI_CART_FEATURE]: { 21 | module: () => import('@spartacus/cart/base/components/mini-cart').then((m) => m.MiniCartModule), 22 | }, 23 | }, 24 | }), 25 | provideConfig({ 26 | featureModules: { 27 | [ADD_TO_CART_FEATURE]: { 28 | module: () => import('@spartacus/cart/base/components/add-to-cart').then((m) => m.AddToCartModule), 29 | }, 30 | }, 31 | }), 32 | provideConfig({ 33 | i18n: { 34 | resources: cartBaseTranslations, 35 | chunks: cartBaseTranslationChunksConfig, 36 | }, 37 | }), 38 | ], 39 | }) 40 | export class CartBaseFeatureModule {} 41 | -------------------------------------------------------------------------------- /src/app/spartacus/features/user/user-feature.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | 3 | import { CmsConfig, I18nConfig, provideConfig } from '@spartacus/core'; 4 | import { userAccountTranslationChunksConfig, userAccountTranslations } from '@spartacus/user/account/assets'; 5 | import { USER_ACCOUNT_FEATURE, UserAccountRootModule } from '@spartacus/user/account/root'; 6 | import { userProfileTranslationChunksConfig, userProfileTranslations } from '@spartacus/user/profile/assets'; 7 | import { USER_PROFILE_FEATURE, UserProfileRootModule } from '@spartacus/user/profile/root'; 8 | 9 | @NgModule({ 10 | declarations: [], 11 | imports: [UserAccountRootModule, UserProfileRootModule], 12 | providers: [ 13 | provideConfig({ 14 | featureModules: { 15 | [USER_ACCOUNT_FEATURE]: { 16 | module: () => import('@spartacus/user/account').then((m) => m.UserAccountModule), 17 | }, 18 | }, 19 | }), 20 | provideConfig({ 21 | i18n: { 22 | resources: userAccountTranslations, 23 | chunks: userAccountTranslationChunksConfig, 24 | }, 25 | }), 26 | provideConfig({ 27 | featureModules: { 28 | [USER_PROFILE_FEATURE]: { 29 | module: () => import('@spartacus/user/profile').then((m) => m.UserProfileModule), 30 | }, 31 | }, 32 | }), 33 | provideConfig({ 34 | i18n: { 35 | resources: userProfileTranslations, 36 | chunks: userProfileTranslationChunksConfig, 37 | }, 38 | }), 39 | ], 40 | }) 41 | export class UserFeatureModule {} 42 | -------------------------------------------------------------------------------- /src/app/spartacus/features/checkout/checkout-feature.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | 3 | import { checkoutB2BTranslationChunksConfig, checkoutB2BTranslations } from '@spartacus/checkout/b2b/assets'; 4 | import { CheckoutB2BRootModule } from '@spartacus/checkout/b2b/root'; 5 | import { checkoutTranslationChunksConfig, checkoutTranslations } from '@spartacus/checkout/base/assets'; 6 | import { CHECKOUT_FEATURE, CheckoutRootModule } from '@spartacus/checkout/base/root'; 7 | import { 8 | checkoutScheduledReplenishmentTranslationChunksConfig, 9 | checkoutScheduledReplenishmentTranslations, 10 | } from '@spartacus/checkout/scheduled-replenishment/assets'; 11 | import { CheckoutScheduledReplenishmentRootModule } from '@spartacus/checkout/scheduled-replenishment/root'; 12 | import { CmsConfig, I18nConfig, provideConfig } from '@spartacus/core'; 13 | 14 | @NgModule({ 15 | declarations: [], 16 | imports: [CheckoutRootModule, CheckoutB2BRootModule, CheckoutScheduledReplenishmentRootModule], 17 | providers: [ 18 | provideConfig({ 19 | featureModules: { 20 | [CHECKOUT_FEATURE]: { 21 | module: () => import('./checkout-wrapper.module').then((m) => m.CheckoutWrapperModule), 22 | }, 23 | }, 24 | }), 25 | provideConfig({ 26 | i18n: { 27 | resources: checkoutTranslations, 28 | chunks: checkoutTranslationChunksConfig, 29 | }, 30 | }), 31 | provideConfig({ 32 | i18n: { 33 | resources: checkoutB2BTranslations, 34 | chunks: checkoutB2BTranslationChunksConfig, 35 | }, 36 | }), 37 | provideConfig({ 38 | i18n: { 39 | resources: checkoutScheduledReplenishmentTranslations, 40 | chunks: checkoutScheduledReplenishmentTranslationChunksConfig, 41 | }, 42 | }), 43 | ], 44 | }) 45 | export class CheckoutFeatureModule {} 46 | -------------------------------------------------------------------------------- /src/app/contentful/core/content-types.ts: -------------------------------------------------------------------------------- 1 | import { EntryFieldType, EntryFieldTypes } from 'contentful'; 2 | 3 | export type PageSkeleton = { 4 | contentTypeId: 'cmsPage'; 5 | fields: { 6 | internalName: EntryFieldTypes.Symbol; 7 | title: EntryFieldTypes.Symbol; 8 | label: EntryFieldTypes.Symbol; 9 | description: EntryFieldTypes.Symbol; 10 | robots: EntryFieldTypes.Symbol; 11 | slug: EntryFieldTypes.Symbol; 12 | type: EntryFieldTypes.Symbol; 13 | template: EntryFieldTypes.Symbol; 14 | header: EntryFieldTypes.EntryLink; 15 | footer: EntryFieldTypes.EntryLink; 16 | }; 17 | }; 18 | 19 | export type HeaderSkeleton = { 20 | contentTypeId: 'cmsHeader'; 21 | fields: { 22 | [key: string]: EntryFieldType; 23 | }; 24 | }; 25 | 26 | export type FooterSkeleton = { 27 | contentTypeId: 'cmsFooter'; 28 | fields: { 29 | [key: string]: EntryFieldType; 30 | }; 31 | }; 32 | 33 | export type ComponentSkeleton = { 34 | contentTypeId: string; 35 | fields: { 36 | [key: string]: EntryFieldType; 37 | }; 38 | }; 39 | 40 | export type NavigationNodeSkeleton = { 41 | contentTypeId: 'NavNode'; 42 | fields: { 43 | uid: EntryFieldTypes.Symbol; 44 | title?: EntryFieldTypes.Symbol; 45 | children?: EntryFieldTypes.Array>; 46 | entries?: EntryFieldTypes.Array>; 47 | }; 48 | }; 49 | 50 | export type CMSLinkComponentSkeleton = { 51 | contentTypeId: 'CMSLinkComponent'; 52 | fields: { 53 | name: EntryFieldTypes.Symbol; 54 | linkName: EntryFieldTypes.Symbol; 55 | url: EntryFieldTypes.Symbol; 56 | target: EntryFieldTypes.Boolean; 57 | }; 58 | }; 59 | 60 | export type MediaContainerSkeleton = { 61 | contentTypeId: 'MediaContainer'; 62 | fields: { 63 | name: EntryFieldTypes.Symbol; 64 | desktop: EntryFieldTypes.AssetLink; 65 | mobile: EntryFieldTypes.AssetLink; 66 | tablet: EntryFieldTypes.AssetLink; 67 | widescreen: EntryFieldTypes.AssetLink; 68 | }; 69 | }; 70 | -------------------------------------------------------------------------------- /src/app/contentful/cms/adapters/converters/components/contentful-cms-banner-component-normalizer.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | 3 | import { CmsBannerComponent, CmsComponent, Converter } from '@spartacus/core'; 4 | import { CmsBannerComponentMedia } from '@spartacus/core'; 5 | 6 | import { Asset, Entry, UnresolvedLink } from 'contentful'; 7 | 8 | import { ComponentSkeleton } from '../../../../core/content-types'; 9 | import { isAsset, isMediaContainer } from '../../../../core/type-guards'; 10 | 11 | @Injectable({ providedIn: 'root' }) 12 | export class ContentfulCmsBannerComponentNormalizer implements Converter, CmsComponent> { 13 | constructor() {} 14 | 15 | convert(source: Entry, target: CmsComponent): CmsComponent { 16 | if (source.sys.contentType.sys.id === 'SimpleResponsiveBannerComponent') { 17 | this.normalizeMedia(source, target); 18 | } 19 | 20 | return target; 21 | } 22 | 23 | private normalizeMedia(source: Entry, component: CmsBannerComponent): void { 24 | if (isMediaContainer(source.fields?.['mediaContainer'])) { 25 | const mediaContainer = source.fields['mediaContainer']; 26 | 27 | component.media = { 28 | desktop: this.normalizeMediaAsset(mediaContainer.fields['desktop']), 29 | mobile: this.normalizeMediaAsset(mediaContainer.fields['mobile']), 30 | tablet: this.normalizeMediaAsset(mediaContainer.fields['tablet']), 31 | widescreen: this.normalizeMediaAsset(mediaContainer.fields['widescreen']), 32 | }; 33 | return; 34 | } 35 | 36 | if (isAsset(source.fields?.['media'])) { 37 | component.media = this.normalizeMediaAsset(source.fields['media']); 38 | } 39 | } 40 | 41 | private normalizeMediaAsset(media: UnresolvedLink<'Asset'> | Asset): CmsBannerComponentMedia | undefined { 42 | if (isAsset(media)) { 43 | return { 44 | altText: '', 45 | code: '', 46 | mime: media.fields.file?.contentType ?? '', 47 | url: media.fields.file?.url ?? '', 48 | }; 49 | } 50 | return undefined; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/app/contentful/cms/contentful-cms.module.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule } from '@angular/common'; 2 | import { NgModule } from '@angular/core'; 3 | 4 | import { CMS_COMPONENT_NORMALIZER, CMS_PAGE_NORMALIZER, CmsComponentAdapter, CmsPageAdapter } from '@spartacus/core'; 5 | 6 | import { ContentfulCmsComponentAdapter } from './adapters/contentful-cms-component.adapter'; 7 | import { ContentfulCmsPageAdapter } from './adapters/contentful-cms-page.adapter'; 8 | import { ContentfulCmsBannerComponentNormalizer } from './adapters/converters/components/contentful-cms-banner-component-normalizer'; 9 | import { ContentfulCmsNavigationComponentNormalizer } from './adapters/converters/components/contentful-cms-navigation-component-normalizer'; 10 | import { ContentfulCmsProductCarouselComponentNormalizer } from './adapters/converters/components/contentful-cms-product-carousel-component-normalizer'; 11 | import { ContentfulCmsComponentNormalizer } from './adapters/converters/contentful-cms-component-normalizer'; 12 | import { ContentfulCmsPageNormalizer } from './adapters/converters/contentful-cms-page-normalizer'; 13 | 14 | @NgModule({ 15 | declarations: [], 16 | imports: [CommonModule], 17 | providers: [ 18 | { 19 | provide: CmsPageAdapter, 20 | useExisting: ContentfulCmsPageAdapter, 21 | }, 22 | { 23 | provide: CMS_PAGE_NORMALIZER, 24 | useExisting: ContentfulCmsPageNormalizer, 25 | multi: true, 26 | }, 27 | { 28 | provide: CmsComponentAdapter, 29 | useExisting: ContentfulCmsComponentAdapter, 30 | }, 31 | { 32 | provide: CMS_COMPONENT_NORMALIZER, 33 | useExisting: ContentfulCmsComponentNormalizer, 34 | multi: true, 35 | }, 36 | { 37 | provide: CMS_COMPONENT_NORMALIZER, 38 | useExisting: ContentfulCmsBannerComponentNormalizer, 39 | multi: true, 40 | }, 41 | { 42 | provide: CMS_COMPONENT_NORMALIZER, 43 | useExisting: ContentfulCmsProductCarouselComponentNormalizer, 44 | multi: true, 45 | }, 46 | { 47 | provide: CMS_COMPONENT_NORMALIZER, 48 | useExisting: ContentfulCmsNavigationComponentNormalizer, 49 | multi: true, 50 | }, 51 | ], 52 | }) 53 | export class ContentfulCmsModule {} 54 | -------------------------------------------------------------------------------- /src/app/contentful/cms/adapters/converters/contentful-cms-component-normalizer.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { CmsBannerComponent, CmsComponent } from '@spartacus/core'; 4 | 5 | import { Entry } from 'contentful'; 6 | 7 | import { ComponentSkeleton } from '../../../core/content-types'; 8 | import { DeepPartial } from '../../../core/helpers'; 9 | import { RestrictionsService } from '../../../core/services/contentful-restrictions.service'; 10 | import { ContentfulCmsComponentNormalizer } from './contentful-cms-component-normalizer'; 11 | 12 | const mockComponent: DeepPartial> = { 13 | 'sys': { 14 | 'id': 'entryId', 15 | 'type': 'Entry', 16 | 'contentType': { 17 | 'sys': { 18 | 'type': 'Link', 19 | 'linkType': 'ContentType', 20 | 'id': 'SimpleResponsiveBannerComponent', 21 | }, 22 | }, 23 | }, 24 | 'fields': { 25 | 'urlLink': '/Open-Catalogue/Tools/c/1355', 26 | 'name': 'Powertools Hompage Splash Banner Component', 27 | }, 28 | }; 29 | 30 | describe('ContentfulCmsComponentNormalizer', () => { 31 | let normalizer: ContentfulCmsComponentNormalizer; 32 | let mockRestrictionsService: jasmine.SpyObj; 33 | 34 | beforeEach(() => { 35 | mockRestrictionsService = jasmine.createSpyObj('RestrictionsService', ['isEntryAccessible']); 36 | mockRestrictionsService.isEntryAccessible.and.returnValue(true); 37 | TestBed.configureTestingModule({ 38 | providers: [ContentfulCmsComponentNormalizer, { provide: RestrictionsService, useValue: mockRestrictionsService }], 39 | }); 40 | 41 | normalizer = TestBed.inject(ContentfulCmsComponentNormalizer); 42 | }); 43 | 44 | it('should set uid and typeCode correctly', () => { 45 | const target: CmsComponent = {}; 46 | const result = normalizer.convert(mockComponent as Entry, target); 47 | 48 | expect(result).toEqual({ 49 | name: 'Powertools Hompage Splash Banner Component', 50 | uid: 'entryId', 51 | typeCode: 'SimpleResponsiveBannerComponent', 52 | urlLink: '/Open-Catalogue/Tools/c/1355', 53 | } as CmsBannerComponent); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /src/app/contentful/cms/adapters/converters/components/contentful-cms-navigation-component-normalizer.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | 3 | import { CmsComponent, CmsNavigationComponent, CmsNavigationEntry, CmsNavigationNode, Converter } from '@spartacus/core'; 4 | 5 | import { Entry } from 'contentful'; 6 | 7 | import { CMSLinkComponentSkeleton, ComponentSkeleton, NavigationNodeSkeleton } from '../../../../core/content-types'; 8 | import { isNavigationNode, isResolvedEntry, isString } from '../../../../core/type-guards'; 9 | 10 | @Injectable({ providedIn: 'root' }) 11 | export class ContentfulCmsNavigationComponentNormalizer implements Converter, CmsComponent> { 12 | constructor() {} 13 | 14 | convert(source: Entry, target: CmsComponent): CmsComponent { 15 | if (['CategoryNavigationComponent', 'FooterNavigationComponent', 'NavigationComponent'].includes(source.sys.contentType.sys.id)) { 16 | this.normalizeNavigationNode(source, target); 17 | } 18 | 19 | return target; 20 | } 21 | 22 | private normalizeNavigationNode(source: Entry, component: CmsNavigationComponent): void { 23 | if (isNavigationNode(source.fields?.['navigationNode'])) { 24 | const sourceNavigationNode = source.fields['navigationNode']; 25 | component.navigationNode = this.normalizeNavigationNodeChild(sourceNavigationNode); 26 | } 27 | } 28 | 29 | private normalizeNavigationNodeChild(source: Entry): CmsNavigationNode { 30 | return { 31 | uid: source.sys.id, 32 | title: isString(source.fields['title']) ? source.fields['title'] : '', 33 | children: source.fields['children']?.filter(isResolvedEntry).map(this.normalizeNavigationNodeChild.bind(this)), 34 | entries: source.fields['entries']?.filter(isResolvedEntry).map(this.normalizeNavigationNodeEntry.bind(this)), 35 | }; 36 | } 37 | 38 | private normalizeNavigationNodeEntry(source: Entry): CmsNavigationEntry { 39 | return { 40 | itemId: source.sys.id, 41 | itemSuperType: 'AbstractCMSComponent', 42 | itemType: 'CMSLinkComponent', 43 | }; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/app/contentful/cms/adapters/contentful-cms-component.adapter.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | 3 | import { CMS_COMPONENT_NORMALIZER, CmsComponent, CmsComponentAdapter, ConverterService, LanguageService } from '@spartacus/core'; 4 | import { UserAccountFacade } from '@spartacus/user/account/root'; 5 | 6 | import { map } from 'rxjs/operators'; 7 | 8 | import { Entry } from 'contentful'; 9 | import { Observable, combineLatest, switchMap } from 'rxjs'; 10 | 11 | import { ComponentSkeleton } from '../../core/content-types'; 12 | import { RestrictionsService } from '../../core/services/contentful-restrictions.service'; 13 | import { ContentService } from '../../core/services/contentful.service'; 14 | 15 | export interface ContentfulCmsComponentRequest { 16 | componentType?: string; 17 | componentIds?: string[]; 18 | } 19 | 20 | @Injectable({ 21 | providedIn: 'root', 22 | }) 23 | export class ContentfulCmsComponentAdapter implements CmsComponentAdapter { 24 | constructor( 25 | protected converter: ConverterService, 26 | protected contentService: ContentService, 27 | protected restrictionService: RestrictionsService, 28 | protected languageService: LanguageService, 29 | protected userAccount: UserAccountFacade, 30 | ) {} 31 | 32 | load(id: string): Observable { 33 | return this.languageService.getActive().pipe( 34 | switchMap((language) => 35 | this.contentService.getComponents({ componentIds: [id] }, language).pipe( 36 | map((componentsEntries) => componentsEntries.items[0]), 37 | this.converter.pipeable, T>(CMS_COMPONENT_NORMALIZER), 38 | ), 39 | ), 40 | ); 41 | } 42 | 43 | findComponentsByIds(ids: string[]): Observable { 44 | return combineLatest([this.languageService.getActive(), this.userAccount.get()]).pipe( 45 | switchMap(([language, user]) => { 46 | this.restrictionService.setUserPermissions(user); 47 | return this.contentService.getComponents({ componentIds: ids }, language).pipe( 48 | map((componentsEntries) => componentsEntries.items), 49 | this.converter.pipeableMany(CMS_COMPONENT_NORMALIZER), 50 | ); 51 | }), 52 | ); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/app/contentful/core/services/contentful-angular.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { ContentfulLivePreview, ContentfulLivePreviewInitConfig, LivePreviewProps } from '@contentful/live-preview'; 3 | import { Argument, SubscribeCallback } from '@contentful/live-preview/dist/types'; 4 | 5 | interface Options { 6 | locale?: string; 7 | skip?: boolean; 8 | } 9 | 10 | @Injectable({ 11 | providedIn: 'root', 12 | }) 13 | export class ContentfulAngularService { 14 | private config: ContentfulLivePreviewInitConfig = { locale: '' }; 15 | 16 | init({ 17 | locale, 18 | space, 19 | environment, 20 | debugMode = false, 21 | enableInspectorMode = true, 22 | enableLiveUpdates = true, 23 | targetOrigin, 24 | experimental, 25 | }: ContentfulLivePreviewInitConfig): void { 26 | ContentfulLivePreview.init({ 27 | locale, 28 | space, 29 | environment, 30 | debugMode, 31 | enableInspectorMode, 32 | enableLiveUpdates, 33 | targetOrigin, 34 | experimental, 35 | }); 36 | 37 | this.config = { 38 | locale, 39 | space, 40 | environment, 41 | debugMode, 42 | enableInspectorMode, 43 | enableLiveUpdates, 44 | targetOrigin, 45 | }; 46 | } 47 | 48 | getInspectorModeTags(config: LivePreviewProps) { 49 | if (this.config.enableInspectorMode) { 50 | return ContentfulLivePreview.getProps(config); 51 | } 52 | 53 | return null; 54 | } 55 | 56 | private shouldSubscribe(data: T): boolean { 57 | if (!this.config.enableLiveUpdates) { 58 | return false; 59 | } 60 | 61 | if (Array.isArray(data) && data.length) { 62 | return true; 63 | } 64 | 65 | return !!(data && typeof data === 'object' && Object.keys(data).length); 66 | } 67 | 68 | activateLiveUpdates(data: T, callback: SubscribeCallback, optionsOrLocale?: Options | string) { 69 | const options = typeof optionsOrLocale === 'object' ? optionsOrLocale : { locale: optionsOrLocale }; 70 | 71 | if (this.shouldSubscribe(data)) { 72 | return ContentfulLivePreview.subscribe('edit', { 73 | data: data, 74 | locale: options.locale, 75 | callback, 76 | }); 77 | } 78 | return undefined; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/app/contentful/core/services/contentful-restrictions.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { User } from '@spartacus/core'; 4 | 5 | import { Entry } from 'contentful'; 6 | 7 | import { ComponentSkeleton } from '../content-types'; 8 | import { DeepPartial } from '../helpers'; 9 | import { RestrictionsService } from './contentful-restrictions.service'; 10 | 11 | const mockUserAccount: User = { 12 | roles: ['b2badministrator', 'b2bcustomer'], 13 | }; 14 | 15 | const mockEntryNoTags: DeepPartial> = { 16 | 'metadata': { 17 | 'tags': [], 18 | }, 19 | }; 20 | 21 | const mockEntryOtherTags: DeepPartial> = { 22 | 'metadata': { 23 | 'tags': [ 24 | { 25 | sys: { id: 'test' }, 26 | }, 27 | ], 28 | }, 29 | }; 30 | 31 | const mockEntryWithTags: DeepPartial> = { 32 | 'metadata': { 33 | 'tags': [ 34 | { 35 | sys: { id: '_require-login' }, 36 | }, 37 | ], 38 | }, 39 | }; 40 | 41 | describe('RestrictionsService', () => { 42 | let service: RestrictionsService; 43 | 44 | beforeEach(() => { 45 | TestBed.configureTestingModule({ 46 | providers: [RestrictionsService], 47 | }); 48 | service = TestBed.inject(RestrictionsService); 49 | }); 50 | 51 | it('should be created', () => { 52 | expect(service).toBeTruthy(); 53 | }); 54 | 55 | describe('validateEntry', () => { 56 | it('should return true for Entry with no tags', () => { 57 | expect(service.isEntryAccessible(mockEntryNoTags as Entry)).toBeTrue(); 58 | }); 59 | 60 | it('should return true for Entry with non-require tags', () => { 61 | expect(service.isEntryAccessible(mockEntryOtherTags as Entry)).toBeTrue(); 62 | }); 63 | 64 | it('should return true for Entry with tags whose name matches the role that was given to the user', () => { 65 | service.setUserPermissions(mockUserAccount); 66 | expect(service.isEntryAccessible(mockEntryWithTags as Entry)).toBeTrue(); 67 | }); 68 | 69 | it('should return false for Entry with tags whose name doesnt match any of the roles that were given to the user', () => { 70 | expect(service.isEntryAccessible(mockEntryWithTags as Entry)).toBeFalse(); 71 | }); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /src/app/contentful/core/type-guards.ts: -------------------------------------------------------------------------------- 1 | import { BREAKPOINT, LayoutSlotConfig, SlotConfig, SlotGroup } from '@spartacus/storefront'; 2 | 3 | import { Asset, Entry, EntrySkeletonType, UnresolvedLink } from 'contentful'; 4 | 5 | import { MediaContainerSkeleton, NavigationNodeSkeleton } from './content-types'; 6 | 7 | export function isString(field: unknown): field is string { 8 | return typeof field === 'string'; 9 | } 10 | 11 | export function isAsset(field: unknown): field is Asset { 12 | return ( 13 | typeof field === 'object' && 14 | field !== null && 15 | 'sys' in field && 16 | typeof field.sys === 'object' && 17 | field.sys !== null && 18 | 'type' in field.sys && 19 | field.sys.type === 'Asset' 20 | ); 21 | } 22 | 23 | export function isEntry(field: unknown): field is Entry { 24 | return ( 25 | typeof field === 'object' && 26 | field !== null && 27 | 'sys' in field && 28 | typeof field.sys === 'object' && 29 | field.sys !== null && 30 | 'type' in field.sys && 31 | field.sys.type === 'Entry' 32 | ); 33 | } 34 | 35 | export function isNavigationNode(field: unknown): field is Entry { 36 | return isEntry(field) && field.sys.contentType.sys.id === 'NavNode'; 37 | } 38 | 39 | export function isMediaContainer(field: unknown): field is Entry { 40 | return isEntry(field) && field.sys.contentType.sys.id === 'MediaContainer'; 41 | } 42 | 43 | export function isResolvedEntry( 44 | entry: UnresolvedLink<'Entry'> | Entry, 45 | ): entry is Entry { 46 | return entry.sys.type === 'Entry'; 47 | } 48 | 49 | export function isSlotConfig(configurationSlots: LayoutSlotConfig | SlotConfig | SlotGroup | undefined): configurationSlots is SlotConfig { 50 | return ( 51 | typeof configurationSlots === 'object' && 52 | configurationSlots !== null && 53 | 'slots' in configurationSlots && 54 | Array.isArray(configurationSlots.slots) && 55 | (configurationSlots.slots.length === 0 || configurationSlots.slots.every((slot) => typeof slot === 'string')) 56 | ); 57 | } 58 | 59 | export function isSlotGroup(configurationSlots: LayoutSlotConfig | SlotConfig | SlotGroup | undefined): configurationSlots is SlotGroup { 60 | return ( 61 | typeof configurationSlots === 'object' && 62 | configurationSlots !== null && 63 | Object.values(BREAKPOINT) 64 | .filter((value) => value !== BREAKPOINT.xl) 65 | .some((value) => value in configurationSlots) 66 | ); 67 | } 68 | -------------------------------------------------------------------------------- /src/app/contentful/cms/adapters/contentful-cms-page.adapter.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | 3 | import { 4 | CMS_PAGE_NORMALIZER, 5 | CmsPageAdapter, 6 | CmsStructureModel, 7 | ConverterService, 8 | HOME_PAGE_CONTEXT, 9 | LanguageService, 10 | PageContext, 11 | PageType, 12 | SMART_EDIT_CONTEXT, 13 | } from '@spartacus/core'; 14 | import { UserAccountFacade } from '@spartacus/user/account/root'; 15 | 16 | import { Observable, combineLatest, switchMap } from 'rxjs'; 17 | 18 | import { RestrictionsService } from '../../core/services/contentful-restrictions.service'; 19 | import { ContentService } from '../../core/services/contentful.service'; 20 | 21 | type Slug = 'catalog' | 'category' | 'product' | 'content'; 22 | 23 | const pageTypeToSlugMap: Record = { 24 | [PageType.CATALOG_PAGE]: 'catalog', 25 | [PageType.CATEGORY_PAGE]: 'category', 26 | [PageType.PRODUCT_PAGE]: 'product', 27 | [PageType.CONTENT_PAGE]: 'content', 28 | }; 29 | 30 | export interface ContentfulCmsPageRequest { 31 | pageSlug?: string; 32 | } 33 | 34 | @Injectable({ 35 | providedIn: 'root', 36 | }) 37 | export class ContentfulCmsPageAdapter implements CmsPageAdapter { 38 | constructor( 39 | protected converter: ConverterService, 40 | protected contentService: ContentService, 41 | protected restrictionService: RestrictionsService, 42 | protected languageService: LanguageService, 43 | protected userAccount: UserAccountFacade, 44 | ) {} 45 | 46 | /** 47 | * @override returns the Contentful CMS page data for the given context and converts 48 | * the data by any configured `CMS_PAGE_NORMALIZER`. 49 | */ 50 | load(pageContext: PageContext): Observable { 51 | const params = this.getPagesRequestParams(pageContext); 52 | return combineLatest([this.languageService.getActive(), this.userAccount.get()]).pipe( 53 | switchMap(([language, user]) => { 54 | this.restrictionService.setUserPermissions(user); 55 | return this.contentService.getPage(params, language).pipe(this.converter.pipeable(CMS_PAGE_NORMALIZER)); 56 | }), 57 | ); 58 | } 59 | 60 | /** 61 | * We query Contentful CMS API for pages according to their Label. 62 | */ 63 | protected getPagesRequestParams(context: PageContext): ContentfulCmsPageRequest { 64 | if (context.id === HOME_PAGE_CONTEXT || context.id === SMART_EDIT_CONTEXT) { 65 | return { 66 | pageSlug: 'homepage', 67 | }; 68 | } 69 | 70 | const httpParams: ContentfulCmsPageRequest = {}; 71 | if (context.type === PageType.CONTENT_PAGE) { 72 | httpParams.pageSlug = context.id.substring(context.id.indexOf('/') + 1); 73 | } else if (context.type) { 74 | httpParams.pageSlug = pageTypeToSlugMap[context.type]; 75 | } 76 | return httpParams; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration file, see link for more information 2 | // https://karma-runner.github.io/1.0/config/configuration-file.html 3 | 4 | process.env.CHROME_BIN = require('puppeteer').executablePath(); 5 | 6 | module.exports = function (config) { 7 | config.set({ 8 | basePath: '', 9 | frameworks: ['jasmine', '@angular-devkit/build-angular'], 10 | plugins: [ 11 | require('karma-jasmine'), 12 | require('karma-chrome-launcher'), 13 | require('karma-jasmine-html-reporter'), 14 | require('karma-sonarqube-unit-reporter'), 15 | require('karma-junit-reporter'), 16 | require('karma-coverage'), 17 | require('karma-coverage-istanbul-reporter'), 18 | require('@angular-devkit/build-angular/plugins/karma'), 19 | ], 20 | client: { 21 | jasmine: { 22 | // you can add configuration options for Jasmine here 23 | // the possible options are listed at https://jasmine.github.io/api/edge/Configuration.html 24 | // for example, you can disable the random execution with `random: false` 25 | // or set a specific seed with `seed: 4321` 26 | }, 27 | clearContext: false, // leave Jasmine Spec Runner output visible in browser 28 | }, 29 | jasmineHtmlReporter: { 30 | suppressAll: true, // removes the duplicated traces 31 | }, 32 | coverageIstanbulReporter: { 33 | dir: require('path').join(__dirname, './reports/coverage'), 34 | reports: ['html', 'lcovonly', 'text-summary', 'cobertura'], 35 | fixWebpackSourcePaths: true, 36 | 'report-config': { 37 | cobertura: { 38 | projectRoot: './', 39 | }, 40 | }, 41 | }, 42 | sonarQubeUnitReporter: { 43 | sonarQubeVersion: 'LATEST', 44 | outputFile: 'reports/ut_report.xml', 45 | useBrowserName: false, 46 | overrideTestDescription: true, 47 | }, 48 | // the default configuration 49 | junitReporter: { 50 | outputDir: 'reports/junit', // results will be saved as $outputDir/$browserName.xml 51 | outputFile: 'junit.xml', // if included, results will be saved as $outputDir/$browserName/$outputFile 52 | useBrowserName: false, // add browser name to report and classes names 53 | }, 54 | reporters: ['progress', 'kjhtml', 'sonarqubeUnit', 'junit', 'coverage-istanbul'], 55 | port: 9876, 56 | colors: true, 57 | logLevel: config.LOG_INFO, 58 | autoWatch: true, 59 | browsers: ['ChromeNoSandbox', 'ChromeHeadless'], 60 | customLaunchers: { 61 | HeadlessChrome: { 62 | base: 'ChromeHeadless', 63 | flags: ['--no-sandbox', '--proxy-auto-detect'], 64 | }, 65 | ChromeNoSandbox: { 66 | base: 'Chrome', 67 | flags: ['--no-sandbox', '--disable-dev-shm-usage', '--proxy-auto-detect'], 68 | }, 69 | }, 70 | singleRun: false, 71 | restartOnFileChange: true, 72 | }); 73 | }; 74 | -------------------------------------------------------------------------------- /src/app/contentful/cms/adapters/converters/contentful-cms-normalizers.ts: -------------------------------------------------------------------------------- 1 | import { CmsBannerComponent, CmsNavigationComponent, CmsNavigationEntry, CmsNavigationNode, CmsProductCarouselComponent } from '@spartacus/core'; 2 | 3 | import { Asset, Entry, UnresolvedLink } from 'contentful'; 4 | 5 | import { CMSLinkComponentSkeleton, ComponentSkeleton, NavigationNodeSkeleton } from '../../../core/content-types'; 6 | import { isAsset, isMediaContainer, isNavigationNode, isResolvedEntry, isString } from '../../../core/type-guards'; 7 | 8 | export function normalizeMedia(source: Entry, component: CmsBannerComponent): void { 9 | if (isMediaContainer(source.fields?.['mediaContainer'])) { 10 | const mediaContainer = source.fields['mediaContainer']; 11 | 12 | component.media = { 13 | desktop: normalizeMediaAsset(mediaContainer.fields['desktop']), 14 | mobile: normalizeMediaAsset(mediaContainer.fields['mobile']), 15 | tablet: normalizeMediaAsset(mediaContainer.fields['tablet']), 16 | widescreen: normalizeMediaAsset(mediaContainer.fields['widescreen']), 17 | }; 18 | return; 19 | } 20 | 21 | if (isAsset(source.fields?.['media'])) { 22 | component.media = normalizeMediaAsset(source.fields['media']); 23 | } 24 | } 25 | 26 | function normalizeMediaAsset(media: UnresolvedLink<'Asset'> | Asset) { 27 | if (isAsset(media)) { 28 | return { 29 | altText: '', 30 | code: '', 31 | mime: media.fields.file?.contentType ?? '', 32 | url: media.fields.file?.url ?? '', 33 | }; 34 | } 35 | return undefined; 36 | } 37 | 38 | export function normalizeProductCodes(source: Entry, component: CmsProductCarouselComponent): void { 39 | if (Array.isArray(source.fields['products'])) { 40 | component.productCodes = source.fields['products'] 41 | .map((productUrl) => (isString(productUrl) ? productUrl.split('/').pop() : '')) 42 | .filter(Boolean) 43 | .join(' '); 44 | } 45 | } 46 | 47 | export function normalizeNavigationNode(source: Entry, component: CmsNavigationComponent): void { 48 | if (isNavigationNode(source.fields?.['navigationNode'])) { 49 | const sourceNavigationNode = source.fields['navigationNode']; 50 | component.navigationNode = normalizeNavigationNodeChild(sourceNavigationNode); 51 | } 52 | } 53 | 54 | function normalizeNavigationNodeChild(source: Entry): CmsNavigationNode { 55 | return { 56 | uid: source.sys.id, 57 | title: isString(source.fields['title']) ? source.fields['title'] : '', 58 | children: source.fields['children']?.filter(isResolvedEntry).map(normalizeNavigationNodeChild), 59 | entries: source.fields['entries']?.filter(isResolvedEntry).map(normalizeNavigationNodeEntry), 60 | }; 61 | } 62 | 63 | function normalizeNavigationNodeEntry(source: Entry): CmsNavigationEntry { 64 | return { 65 | itemId: source.sys.id, 66 | itemSuperType: 'AbstractCMSComponent', 67 | itemType: 'CMSLinkComponent', 68 | }; 69 | } 70 | -------------------------------------------------------------------------------- /src/app/contentful/core/decorators/contentful-component-decorator.spec.ts: -------------------------------------------------------------------------------- 1 | import { Renderer2 } from '@angular/core'; 2 | import { TestBed } from '@angular/core/testing'; 3 | 4 | import { ContentSlotComponentData } from '@spartacus/core'; 5 | 6 | import { ContentfulLivePreviewService } from '../services/contentful-live-preview.service'; 7 | import { ContentfulComponentDecorator } from './contentful-component-decorator'; 8 | 9 | describe('ContentfulComponentDecorator', () => { 10 | let decorator: ContentfulComponentDecorator; 11 | let livePreviewService: jasmine.SpyObj; 12 | let renderer: jasmine.SpyObj; 13 | let element: HTMLElement; 14 | let component: ContentSlotComponentData; 15 | 16 | beforeEach(() => { 17 | const livePreviewServiceSpy = jasmine.createSpyObj('ContentfulLivePreviewService', [ 18 | 'hasInspectorModeTags', 19 | 'addInspectorModeTags', 20 | 'initComponentLiveUpdate', 21 | ]); 22 | const rendererSpy = jasmine.createSpyObj('Renderer2', ['setAttribute']); 23 | 24 | TestBed.configureTestingModule({ 25 | providers: [ 26 | ContentfulComponentDecorator, 27 | { provide: ContentfulLivePreviewService, useValue: livePreviewServiceSpy }, 28 | { provide: Renderer2, useValue: rendererSpy }, 29 | ], 30 | }); 31 | 32 | decorator = TestBed.inject(ContentfulComponentDecorator); 33 | livePreviewService = TestBed.inject(ContentfulLivePreviewService) as jasmine.SpyObj; 34 | renderer = TestBed.inject(Renderer2) as jasmine.SpyObj; 35 | element = document.createElement('div'); 36 | component = { uid: 'testComponent' } as ContentSlotComponentData; 37 | }); 38 | 39 | it('should be created', () => { 40 | expect(decorator).toBeTruthy(); 41 | }); 42 | 43 | it('should not decorate if component is not provided', () => { 44 | decorator.decorate(element, renderer, null as any); 45 | expect(livePreviewService.hasInspectorModeTags).not.toHaveBeenCalled(); 46 | expect(livePreviewService.addInspectorModeTags).not.toHaveBeenCalled(); 47 | expect(livePreviewService.initComponentLiveUpdate).not.toHaveBeenCalled(); 48 | }); 49 | 50 | it('should add inspector mode tags if not present', () => { 51 | livePreviewService.hasInspectorModeTags.and.returnValue(false); 52 | 53 | decorator.decorate(element, renderer, component); 54 | 55 | expect(livePreviewService.hasInspectorModeTags).toHaveBeenCalledWith(element); 56 | expect(livePreviewService.addInspectorModeTags).toHaveBeenCalledWith(element, renderer, component); 57 | expect(livePreviewService.initComponentLiveUpdate).toHaveBeenCalledWith(component); 58 | }); 59 | 60 | it('should not add inspector mode tags if already present', () => { 61 | livePreviewService.hasInspectorModeTags.and.returnValue(true); 62 | 63 | decorator.decorate(element, renderer, component); 64 | 65 | expect(livePreviewService.hasInspectorModeTags).toHaveBeenCalledWith(element); 66 | expect(livePreviewService.addInspectorModeTags).not.toHaveBeenCalled(); 67 | expect(livePreviewService.initComponentLiveUpdate).toHaveBeenCalledWith(component); 68 | }); 69 | }); 70 | -------------------------------------------------------------------------------- /src/app/spartacus/spartacus-configuration.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | 3 | import { translationChunksConfig, translations } from '@spartacus/assets'; 4 | import { FeaturesConfig, I18nConfig, OccConfig, SiteContextConfig, provideConfig, provideDefaultConfig } from '@spartacus/core'; 5 | import { defaultB2bOccConfig } from '@spartacus/setup'; 6 | import { defaultCmsContentProviders, layoutConfig, mediaConfig } from '@spartacus/storefront'; 7 | 8 | import { environment } from '../../environments/environment'; 9 | import { ContentfulConfig } from '../contentful/root/config/contentful-config'; 10 | 11 | @NgModule({ 12 | declarations: [], 13 | imports: [], 14 | providers: [ 15 | provideConfig(layoutConfig), 16 | provideConfig(mediaConfig), 17 | ...defaultCmsContentProviders, 18 | provideConfig({ 19 | backend: { 20 | occ: { 21 | baseUrl: environment.baseUrl, 22 | prefix: '/occ/v2/', 23 | }, 24 | }, 25 | }), 26 | provideConfig({ 27 | context: { 28 | urlParameters: ['baseSite', 'language', 'currency'], 29 | baseSite: ['powertools-spa'], 30 | currency: ['USD', 'EUR'], 31 | language: ['en', 'de'], 32 | }, 33 | }), 34 | provideDefaultConfig({ 35 | i18n: { 36 | resources: translations, 37 | chunks: { 38 | ...translationChunksConfig, 39 | myAccount: ['closeAccount', 'updatePasswordForm', 'updateProfileForm', 'consentManagementForm', 'myCoupons', 'notificationPreference', 'myInterests'], 40 | }, 41 | fallbackLang: 'en', 42 | }, 43 | }), 44 | provideConfig({ 45 | features: { 46 | level: '2211.43', 47 | }, 48 | }), 49 | provideConfig({ 50 | contentful: { 51 | spaceId: environment.contentful.spaceId, 52 | accessToken: environment.contentful.accessToken, 53 | previewAccessToken: environment.contentful.previewAccessToken, 54 | environment: environment.contentful.environment, 55 | deliveryApiUrl: environment.contentful.deliveryApiUrl, 56 | previewApiUrl: environment.contentful.previewApiUrl, 57 | slugMapping: { 58 | '^organization/units/[a-zA-Z0-9]+$': 'organization/units', 59 | '^organization/account-summary/details/[a-zA-Z0-9]+$': 'organization/account-summary/details', 60 | '^my-account/saved-cart/[a-zA-Z0-9]+$': 'my-account/saved-cart', 61 | '^my-account/order/[a-zA-Z0-9]+$': 'my-account/order', 62 | '^my-account/order/cancel/confirmation/[a-zA-Z0-9]+$': 'my-account/order/cancel/confirmation', 63 | '^my-account/order/cancel/[a-zA-Z0-9]+$': 'my-account/order/cancel', 64 | '^my-account/support-ticket/[a-zA-Z0-9]+$': 'my-account/support-ticket', 65 | '^my-account/unitLevelOrderDetails/[a-zA-Z0-9]+$': 'my-account/unitLevelOrderDetails', 66 | '^my-account/order/return/confirmation/[a-zA-Z0-9]+$': 'my-account/order/return/confirmation', 67 | '^my-account/order/return/[a-zA-Z0-9]+$': 'my-account/order/return', 68 | '^my-account/return-request/[a-zA-Z0-9]+$': 'my-account/return-request', 69 | '^my-account/quote/[a-zA-Z0-9]+$': 'my-account/quote', 70 | }, 71 | }, 72 | }), 73 | provideConfig(defaultB2bOccConfig), 74 | ], 75 | }) 76 | export class SpartacusConfigurationModule {} 77 | -------------------------------------------------------------------------------- /src/app/contentful/cms/adapters/converters/components/contentful-cms-product-carousel-component-normalizer.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { CmsProductCarouselComponent } from '@spartacus/core'; 4 | 5 | import { Entry } from 'contentful'; 6 | 7 | import { ComponentSkeleton } from '../../../../core/content-types'; 8 | import { DeepPartial } from '../../../../core/helpers'; 9 | import { ContentfulCmsProductCarouselComponentNormalizer } from './contentful-cms-product-carousel-component-normalizer'; 10 | 11 | const mockProducts: string[] = [ 12 | 'https://api.cm77gs48zv-contentfu1-d1-public.model-t.cc.commerce.ondemand.com/occ/v2/powertools-spa/products/3755219', 13 | 'https://api.cm77gs48zv-contentfu1-d1-public.model-t.cc.commerce.ondemand.com/occ/v2/powertools-spa/products/3881018', 14 | 'https://api.cm77gs48zv-contentfu1-d1-public.model-t.cc.commerce.ondemand.com/occ/v2/powertools-spa/products/3592865', 15 | 'https://api.cm77gs48zv-contentfu1-d1-public.model-t.cc.commerce.ondemand.com/occ/v2/powertools-spa/products/2116279', 16 | 'https://api.cm77gs48zv-contentfu1-d1-public.model-t.cc.commerce.ondemand.com/occ/v2/powertools-spa/products/3755204', 17 | 'https://api.cm77gs48zv-contentfu1-d1-public.model-t.cc.commerce.ondemand.com/occ/v2/powertools-spa/products/1128762', 18 | 'https://api.cm77gs48zv-contentfu1-d1-public.model-t.cc.commerce.ondemand.com/occ/v2/powertools-spa/products/3092788', 19 | ]; 20 | 21 | const initialMockComponentFields = { 22 | 'products': mockProducts, 23 | 'urlLink': '/Open-Catalogue/Tools/c/1355', 24 | 'name': 'Powertools Hompage Splash Banner Component', 25 | }; 26 | 27 | const mockComponent: DeepPartial> = { 28 | 'sys': { 29 | 'id': 'entryId', 30 | 'type': 'Entry', 31 | 'contentType': { 32 | 'sys': { 33 | 'type': 'Link', 34 | 'linkType': 'ContentType', 35 | 'id': 'ProductCarouselComponent', 36 | }, 37 | }, 38 | }, 39 | 'fields': { ...initialMockComponentFields }, 40 | }; 41 | 42 | describe('Contentful CMS Product Carousel Normalizer', () => { 43 | let normalizer: ContentfulCmsProductCarouselComponentNormalizer; 44 | 45 | beforeEach(() => { 46 | mockComponent.fields = { ...initialMockComponentFields }; 47 | 48 | TestBed.configureTestingModule({ 49 | providers: [ContentfulCmsProductCarouselComponentNormalizer], 50 | }); 51 | 52 | normalizer = TestBed.inject(ContentfulCmsProductCarouselComponentNormalizer); 53 | }); 54 | 55 | it('should normalize product codes', () => { 56 | const component: CmsProductCarouselComponent = { 57 | typeCode: 'ProductCarouselComponent', 58 | productCodes: '', 59 | }; 60 | normalizer.convert(mockComponent as Entry, component); 61 | expect(component.productCodes).toEqual('3755219 3881018 3592865 2116279 3755204 1128762 3092788'); 62 | }); 63 | 64 | it('should normalize product codes with non-string values', () => { 65 | const component: CmsProductCarouselComponent = { 66 | typeCode: 'ProductCarouselComponent', 67 | productCodes: '', 68 | }; 69 | 70 | const mockComponentWithNonStringProducts = { ...mockComponent, fields: { ...initialMockComponentFields, products: [1, 2, 3, 4, 5, 6, 7] } }; 71 | 72 | normalizer.convert(mockComponentWithNonStringProducts as Entry, component); 73 | expect(component.productCodes).toEqual(''); 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /src/app/contentful/core/type-guards.spec.ts: -------------------------------------------------------------------------------- 1 | import { SlotConfig, SlotGroup } from '@spartacus/storefront'; 2 | 3 | import { Asset, Entry, UnresolvedLink } from 'contentful'; 4 | 5 | import { NavigationNodeSkeleton } from './content-types'; 6 | import { DeepPartial } from './helpers'; 7 | import { isAsset, isEntry, isNavigationNode, isResolvedEntry, isSlotConfig, isSlotGroup } from './type-guards'; 8 | 9 | const mockNavigationNode: DeepPartial> = { 10 | 'sys': { 11 | 'type': 'Entry', 12 | 'contentType': { 13 | 'sys': { 14 | 'type': 'Link', 15 | 'linkType': 'ContentType', 16 | 'id': 'NavNode', 17 | }, 18 | }, 19 | }, 20 | 'fields': { 21 | 'uid': 'SandersNavNode', 22 | 'entries': [], 23 | 'title': 'Sanders', 24 | }, 25 | }; 26 | 27 | const mockUnresolvedEntry: DeepPartial> = { 28 | 'sys': { 29 | 'type': 'Link', 30 | 'linkType': 'Entry', 31 | 'id': 'unresolvedEntryId', 32 | }, 33 | }; 34 | 35 | const mockMedia: DeepPartial> = { 36 | 'sys': { 37 | 'type': 'Asset', 38 | 'locale': 'en', 39 | }, 40 | 'fields': { 41 | 'title': 'Powertools_350x280_25Deal_EN_01_350W.jpg', 42 | 'description': '25% Great Prices and Great Deals', 43 | 'file': { 44 | 'url': '//images.ctfassets.net/iuusg1rrhk56/6Kl4PourJx6vEokqgQuBgE/6a199949381a04d4f9d36e8b0bc3c15b/Powertools-350x280-25Deal-EN-01-350W.jpg', 45 | 'details': { 46 | 'size': 9010, 47 | 'image': { 48 | 'width': 350, 49 | 'height': 280, 50 | }, 51 | }, 52 | 'fileName': 'Powertools-350x280-25Deal-EN-01-350W.jpg', 53 | 'contentType': 'image/jpeg', 54 | }, 55 | }, 56 | }; 57 | 58 | const mockSlotConfig: SlotConfig = { 59 | 'pageFold': 'Section2B', 60 | 'slots': ['Section1', 'Section2A', 'Section2B', 'Section2C', 'Section3', 'Section4', 'Section5'], 61 | }; 62 | 63 | const mockSlotGroupConfig: SlotConfig & SlotGroup = { 64 | 'lg': { 65 | 'slots': [], 66 | }, 67 | 'slots': ['SiteLogin', 'NavigationBar', 'SiteContext', 'SiteLinks'], 68 | }; 69 | 70 | describe('Type Guards', () => { 71 | it('should identify an Asset', () => { 72 | expect(isAsset(mockMedia)).toBe(true); 73 | expect(isAsset(mockNavigationNode)).toBe(false); 74 | }); 75 | 76 | it('should identify an Entry', () => { 77 | expect(isEntry(mockNavigationNode)).toBe(true); 78 | expect(isEntry(mockMedia)).toBe(false); 79 | }); 80 | 81 | it('should identify a NavigationNode', () => { 82 | expect(isNavigationNode(mockNavigationNode)).toBe(true); 83 | expect(isNavigationNode(mockMedia)).toBe(false); 84 | }); 85 | 86 | it('should identify a Resolved Entry', () => { 87 | expect(isResolvedEntry(mockNavigationNode as Entry)).toBe(true); 88 | expect(isResolvedEntry(mockUnresolvedEntry as UnresolvedLink<'Entry'>)).toBe(false); 89 | }); 90 | 91 | it('should identify a SlotConfig', () => { 92 | expect(isSlotConfig(mockSlotConfig)).toBe(true); 93 | expect(isSlotConfig(mockSlotGroupConfig)).toBe(true); 94 | expect(isSlotConfig(undefined)).toBe(false); 95 | }); 96 | 97 | it('should identify a SlotGroup', () => { 98 | expect(isSlotGroup(mockSlotGroupConfig)).toBe(true); 99 | expect(isSlotGroup(mockSlotConfig)).toBe(false); 100 | }); 101 | }); 102 | -------------------------------------------------------------------------------- /src/app/contentful/core/services/contentful.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | 3 | import { LoggerService, WindowRef } from '@spartacus/core'; 4 | 5 | import { map } from 'rxjs/operators'; 6 | 7 | import { ContentfulClientApi, Entry, createClient } from 'contentful'; 8 | import { Observable, from, of } from 'rxjs'; 9 | 10 | import { ContentfulCmsComponentRequest } from '../../cms/adapters/contentful-cms-component.adapter'; 11 | import { ContentfulCmsPageRequest } from '../../cms/adapters/contentful-cms-page.adapter'; 12 | import { ContentfulConfig } from '../../root/config/contentful-config'; 13 | import { defaultContentfulConfig } from '../../root/config/default-contentful-config'; 14 | import { ComponentSkeleton, PageSkeleton } from '../content-types'; 15 | 16 | @Injectable({ 17 | providedIn: 'root', 18 | }) 19 | export class ContentService { 20 | private readonly client: ContentfulClientApi; 21 | 22 | constructor( 23 | protected config: ContentfulConfig, 24 | protected windowRef: WindowRef, 25 | protected logger: LoggerService, 26 | ) { 27 | let isPreview = false; 28 | 29 | if (this.windowRef.isBrowser()) { 30 | const urlParams = new URLSearchParams(windowRef.nativeWindow?.location.search); 31 | isPreview = urlParams.get('preview') === 'true'; 32 | } 33 | 34 | this.client = this.initializeContentfulClient(isPreview); 35 | } 36 | 37 | protected initializeContentfulClient(isPreview: boolean): ContentfulClientApi { 38 | const accessToken = this.config.contentful?.accessToken ?? defaultContentfulConfig.contentful?.accessToken ?? ''; 39 | const previewAccessToken = this.config.contentful?.previewAccessToken ?? defaultContentfulConfig.contentful?.previewAccessToken ?? ''; 40 | const previewUrl = this.config.contentful?.previewApiUrl ?? defaultContentfulConfig.contentful?.previewApiUrl ?? ''; 41 | const deliveryApiUrl = this.config.contentful?.deliveryApiUrl ?? defaultContentfulConfig.contentful?.deliveryApiUrl ?? ''; 42 | 43 | return createClient({ 44 | space: this.config.contentful?.spaceId ?? defaultContentfulConfig.contentful?.spaceId ?? '', 45 | environment: this.config.contentful?.environment ?? defaultContentfulConfig.contentful?.environment ?? '', 46 | accessToken: isPreview ? previewAccessToken : accessToken, 47 | host: isPreview ? previewUrl : deliveryApiUrl, 48 | }); 49 | } 50 | 51 | private transformSlug(slug: string): string { 52 | const slugMapping = this.config.contentful?.slugMapping ?? {}; 53 | for (const pattern in slugMapping) { 54 | const regex = new RegExp(pattern); 55 | if (regex.test(slug)) { 56 | return slugMapping[pattern]; 57 | } 58 | } 59 | return slug; 60 | } 61 | 62 | getPage(req: ContentfulCmsPageRequest, locale?: string): Observable | null> { 63 | if (!req.pageSlug) { 64 | this.logger.warn(`WARNING: Page slug is empty. Cannot fetch page.`); 65 | return of(null); 66 | } 67 | 68 | const slug = this.transformSlug(req.pageSlug); 69 | 70 | return from(this.client.getEntries({ content_type: 'cmsPage', 'fields.slug': slug, include: 10, locale })).pipe( 71 | map((pages) => { 72 | if (pages.total === 0) { 73 | this.logger.warn(`WARNING: No page found for slug "${slug}".`); 74 | return null; 75 | } 76 | 77 | return pages.items[0]; 78 | }), 79 | ); 80 | } 81 | 82 | getComponents(req: ContentfulCmsComponentRequest, locale?: string) { 83 | return from(this.client.getEntries({ 'sys.id[in]': req.componentIds, include: 10, locale })); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/app/contentful/cms/adapters/contentful-cms-component-adpater.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { CMS_COMPONENT_NORMALIZER, CmsComponent, ConverterService, LanguageService } from '@spartacus/core'; 4 | import { UserAccountFacade } from '@spartacus/user/account/root'; 5 | 6 | import { of } from 'rxjs'; 7 | 8 | import { RestrictionsService } from '../../core/services/contentful-restrictions.service'; 9 | import { ContentService } from '../../core/services/contentful.service'; 10 | import { ContentfulCmsComponentAdapter } from './contentful-cms-component.adapter'; 11 | 12 | describe('ContentfulCmsComponentAdapter', () => { 13 | let service: ContentfulCmsComponentAdapter; 14 | let mockContentService: jasmine.SpyObj; 15 | let mockLanguageService: jasmine.SpyObj; 16 | let mockRestrictionsService: jasmine.SpyObj; 17 | let mockUserAccountService: jasmine.SpyObj; 18 | let converterService: ConverterService; 19 | 20 | beforeEach(() => { 21 | mockContentService = jasmine.createSpyObj('ContentService', ['getComponents']); 22 | mockLanguageService = jasmine.createSpyObj('LanguageService', ['getActive']); 23 | mockLanguageService.getActive.and.returnValue(of('en')); 24 | mockRestrictionsService = jasmine.createSpyObj('RestrictionsService', ['setUserPermissions']); 25 | mockUserAccountService = jasmine.createSpyObj('UserAccountFacade', ['get']); 26 | mockUserAccountService.get.and.returnValue(of(undefined)); 27 | 28 | TestBed.configureTestingModule({ 29 | providers: [ 30 | ContentfulCmsComponentAdapter, 31 | { provide: ContentService, useValue: mockContentService }, 32 | { provide: LanguageService, useValue: mockLanguageService }, 33 | { provide: RestrictionsService, useValue: mockRestrictionsService }, 34 | { provide: UserAccountFacade, useValue: mockUserAccountService }, 35 | ], 36 | }); 37 | 38 | service = TestBed.inject(ContentfulCmsComponentAdapter); 39 | 40 | converterService = TestBed.inject(ConverterService); 41 | spyOn(converterService, 'pipeable').and.callThrough(); 42 | spyOn(converterService, 'pipeableMany').and.callThrough(); 43 | }); 44 | 45 | it('should be created', () => { 46 | expect(service).toBeTruthy(); 47 | }); 48 | 49 | describe('load', () => { 50 | it('should pass correct params to contentService.getComponents', () => { 51 | const mockComponentId = 'test-component'; 52 | const mockComponent = { id: mockComponentId } as CmsComponent; 53 | 54 | mockContentService.getComponents.and.returnValue(of({ items: [mockComponent] } as any)); 55 | 56 | service.load(mockComponentId).subscribe(); 57 | 58 | expect(mockContentService.getComponents).toHaveBeenCalledWith({ componentIds: [mockComponentId] }, 'en'); 59 | expect(converterService.pipeable).toHaveBeenCalledWith(CMS_COMPONENT_NORMALIZER); 60 | }); 61 | }); 62 | 63 | describe('findComponentsByIds', () => { 64 | it('should pass correct params to contentService.getComponents for multiple components', () => { 65 | const mockComponentIds = ['component1', 'component2']; 66 | const mockComponents = [{ id: 'component1' } as CmsComponent, { id: 'component2' } as CmsComponent]; 67 | 68 | mockContentService.getComponents.and.returnValue(of({ items: mockComponents } as any)); 69 | 70 | service.findComponentsByIds(mockComponentIds).subscribe(); 71 | 72 | expect(mockContentService.getComponents).toHaveBeenCalledWith({ componentIds: mockComponentIds }, 'en'); 73 | expect(converterService.pipeableMany).toHaveBeenCalledWith(CMS_COMPONENT_NORMALIZER); 74 | }); 75 | }); 76 | }); 77 | -------------------------------------------------------------------------------- /src/app/contentful/core/services/contentful-live-preview.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpClient } from '@angular/common/http'; 2 | import { Injectable, NgZone, Renderer2 } from '@angular/core'; 3 | import { InspectorModeDataAttributes, InspectorModeEntryTags } from '@contentful/live-preview/dist/inspectorMode/types'; 4 | import { Argument } from '@contentful/live-preview/dist/types'; 5 | import { Store } from '@ngrx/store'; 6 | 7 | import { 8 | CMS_COMPONENT_NORMALIZER, 9 | CmsActions, 10 | ContentSlotComponentData, 11 | ConverterService, 12 | LanguageService, 13 | PageContext, 14 | RoutingService, 15 | StateWithCms, 16 | } from '@spartacus/core'; 17 | 18 | import { take } from 'rxjs/operators'; 19 | 20 | import { ContentfulConfig } from '../../root/config/contentful-config'; 21 | import { ContentfulAngularService } from './contentful-angular.service'; 22 | 23 | @Injectable({ 24 | providedIn: 'root', 25 | }) 26 | export class ContentfulLivePreviewService { 27 | liveUpdateSubscriptions = new Map(); 28 | 29 | constructor( 30 | protected contentfulAngularService: ContentfulAngularService, 31 | protected config: ContentfulConfig, 32 | protected zone: NgZone, 33 | protected http: HttpClient, 34 | protected store: Store, 35 | protected converterService: ConverterService, 36 | protected routingService: RoutingService, 37 | protected languageService: LanguageService, 38 | ) { 39 | this.languageService.getActive().subscribe((language) => 40 | contentfulAngularService.init({ 41 | locale: language, 42 | enableInspectorMode: true, 43 | enableLiveUpdates: true, 44 | debugMode: true, 45 | }), 46 | ); 47 | } 48 | 49 | public hasInspectorModeTags(element: Element): boolean { 50 | return element.hasAttribute(InspectorModeDataAttributes.FIELD_ID); 51 | } 52 | 53 | public addInspectorModeTags(element: Element, renderer: Renderer2, component: ContentSlotComponentData): void { 54 | const props = this.contentfulAngularService.getInspectorModeTags({ 55 | entryId: component.uid ?? '', 56 | fieldId: component.flexType ?? '', 57 | }) as InspectorModeEntryTags; 58 | 59 | renderer.setAttribute(element, InspectorModeDataAttributes.FIELD_ID, props?.[InspectorModeDataAttributes.FIELD_ID] ?? ''); 60 | renderer.setAttribute(element, InspectorModeDataAttributes.ENTRY_ID, props?.[InspectorModeDataAttributes.ENTRY_ID] ?? ''); 61 | } 62 | 63 | public initComponentLiveUpdate(component: ContentSlotComponentData) { 64 | if (!component.uid) { 65 | return; 66 | } 67 | 68 | this.unsubscribeLiveUpdate(component.uid); 69 | 70 | const unsubscribe = this.contentfulAngularService.activateLiveUpdates(component.properties?.data, this.updateCmsComponent.bind(this)); 71 | 72 | if (unsubscribe) { 73 | this.subscribeLiveUpdate(component.uid, unsubscribe); 74 | } 75 | } 76 | 77 | protected subscribeLiveUpdate(componentId: string, unsubscribe: VoidFunction) { 78 | this.liveUpdateSubscriptions.set(componentId, unsubscribe); 79 | } 80 | 81 | protected unsubscribeLiveUpdate(componentId: string) { 82 | const unsubscribe = this.liveUpdateSubscriptions.get(componentId); 83 | unsubscribe?.(); 84 | this.liveUpdateSubscriptions.delete(componentId); 85 | } 86 | 87 | protected updateCmsComponent(updatedComponentData: Argument) { 88 | const component = this.converterService.convert(updatedComponentData, CMS_COMPONENT_NORMALIZER); 89 | 90 | if (component.uid) { 91 | this.routingService 92 | .getPageContext() 93 | .pipe(take(1)) 94 | .subscribe((pageContext: PageContext) => { 95 | this.store.dispatch( 96 | new CmsActions.LoadCmsComponentSuccess({ 97 | component, 98 | uid: component.uid, 99 | pageContext, 100 | }), 101 | ); 102 | }); 103 | } 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "contentful-spartacus-demo", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "ng": "ng", 6 | "local": "ng serve --ssl --configuration local", 7 | "dev": "ng serve --ssl --configuration development", 8 | "start": "ng serve --ssl", 9 | "build": "ng build", 10 | "watch": "ng build --watch --configuration development", 11 | "test": "ng test --browsers ChromeNoSandbox", 12 | "prettier:write": "prettier --write .", 13 | "ci:lint": "ng lint", 14 | "ci:build": "ng build", 15 | "ci:test": "ng test --watch false --browsers HeadlessChrome --code-coverage", 16 | "ci:prettier": "prettier --check ." 17 | }, 18 | "private": true, 19 | "dependencies": { 20 | "@angular/animations": "^19.2.15", 21 | "@angular/common": "^19.2.15", 22 | "@angular/compiler": "^19.2.15", 23 | "@angular/core": "^19.2.15", 24 | "@angular/forms": "^19.2.15", 25 | "@angular/platform-browser": "^19.2.15", 26 | "@angular/platform-browser-dynamic": "^19.2.15", 27 | "@angular/router": "^19.2.15", 28 | "@angular/service-worker": "^19.2.15", 29 | "@angular/ssr": "^19.2.15", 30 | "@contentful/live-preview": "^4.6.1", 31 | "@fontsource/open-sans": "^5.1.0", 32 | "@fortawesome/fontawesome-free": "6.7.2", 33 | "@ng-select/ng-select": "^14.0.0", 34 | "@ngrx/effects": "^19.2.0", 35 | "@ngrx/router-store": "^19.2.0", 36 | "@ngrx/store": "^19.2.0", 37 | "@ngrx/store-devtools": "^19.2.0", 38 | "@spartacus/asm": "~2211.43.0", 39 | "@spartacus/assets": "~2211.43.0", 40 | "@spartacus/cart": "~2211.43.0", 41 | "@spartacus/checkout": "~2211.43.0", 42 | "@spartacus/core": "~2211.43.0", 43 | "@spartacus/customer-ticketing": "~2211.43.0", 44 | "@spartacus/order": "~2211.43.0", 45 | "@spartacus/organization": "~2211.43.0", 46 | "@spartacus/pdf-invoices": "~2211.43.0", 47 | "@spartacus/product": "~2211.43.0", 48 | "@spartacus/quote": "~2211.43.0", 49 | "@spartacus/setup": "~2211.43.0", 50 | "@spartacus/storefinder": "~2211.43.0", 51 | "@spartacus/storefront": "~2211.43.0", 52 | "@spartacus/styles": "~2211.43.0", 53 | "@spartacus/tracking": "~2211.43.0", 54 | "@spartacus/user": "~2211.43.0", 55 | "angular-oauth2-oidc": "19.0.0", 56 | "contentful": "^11.2.5", 57 | "i18next": "^24.2.1", 58 | "i18next-http-backend": "^3.0.1", 59 | "i18next-resources-to-backend": "^1.2.1", 60 | "ngx-infinite-scroll": "19.0.0", 61 | "rxjs": "~7.8.0", 62 | "tslib": "^2.3.0", 63 | "zone.js": "~0.15.0", 64 | "@ngrx/operators": "^19.0.1" 65 | }, 66 | "devDependencies": { 67 | "@angular-devkit/build-angular": "^19.2.15", 68 | "@angular-devkit/core": "^19.2.15", 69 | "@angular-devkit/schematics": "^19.2.15", 70 | "@angular-eslint/builder": "19.3.0", 71 | "@angular-eslint/eslint-plugin": "19.3.0", 72 | "@angular-eslint/eslint-plugin-template": "19.3.0", 73 | "@angular-eslint/schematics": "19.3.0", 74 | "@angular-eslint/template-parser": "19.3.0", 75 | "@angular/cli": "^19.2.16", 76 | "@angular/compiler-cli": "^19.2.15", 77 | "@schematics/angular": "^19.2.15", 78 | "@spartacus/schematics": "~2211.43.0", 79 | "@trivago/prettier-plugin-sort-imports": "^5.0.0", 80 | "@types/jasmine": "~5.1.0", 81 | "@typescript-eslint/eslint-plugin": "8.46.2", 82 | "@typescript-eslint/parser": "8.46.2", 83 | "eslint": "^8.57.0", 84 | "eslint-config-prettier": "^10.0.0", 85 | "jasmine-core": "~5.12.0", 86 | "jsonc-parser": "~3.3.1", 87 | "karma": "~6.4.0", 88 | "karma-chrome-launcher": "~3.2.0", 89 | "karma-coverage": "~2.2.0", 90 | "karma-coverage-istanbul-reporter": "^3.0.3", 91 | "karma-jasmine": "~5.1.0", 92 | "karma-jasmine-html-reporter": "~2.1.0", 93 | "karma-junit-reporter": "^2.0.1", 94 | "karma-sonarqube-unit-reporter": "^0.0.23", 95 | "parse5": "^7.1.2", 96 | "prettier": "^3.4.1", 97 | "puppeteer": "^24.0.0", 98 | "typescript": "~5.8.0" 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/app/contentful/cms/adapters/contentful-cms-page-adapter.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { ConverterService, LanguageService, PageContext, PageType } from '@spartacus/core'; 4 | import { UserAccountFacade } from '@spartacus/user/account/root'; 5 | 6 | import { Entry } from 'contentful'; 7 | import { of } from 'rxjs'; 8 | 9 | import { PageSkeleton } from '../../core/content-types'; 10 | import { RestrictionsService } from '../../core/services/contentful-restrictions.service'; 11 | import { ContentService } from '../../core/services/contentful.service'; 12 | import { ContentfulCmsPageAdapter } from './contentful-cms-page.adapter'; 13 | 14 | const homepagePageContext: PageContext = { 15 | id: 'homepage', 16 | type: PageType.CONTENT_PAGE, 17 | }; 18 | 19 | const smarteditPageContext: PageContext = { 20 | id: 'smartedit-preview', 21 | type: PageType.CONTENT_PAGE, 22 | }; 23 | 24 | const catalogPageContext: PageContext = { 25 | id: '', 26 | type: PageType.CATALOG_PAGE, 27 | }; 28 | 29 | const categoryPageContext: PageContext = { 30 | id: '1358', 31 | type: PageType.CATALOG_PAGE, 32 | }; 33 | 34 | const contentPageContext: PageContext = { 35 | id: 'content/test', 36 | type: PageType.CONTENT_PAGE, 37 | }; 38 | 39 | const productPageContext: PageContext = { 40 | id: '3755219', 41 | type: PageType.PRODUCT_PAGE, 42 | }; 43 | 44 | const undefinedPageContext: PageContext = { 45 | id: '', 46 | type: undefined, 47 | }; 48 | 49 | describe('ContentfulCmsPageAdapter', () => { 50 | let adapter: ContentfulCmsPageAdapter; 51 | let mockContentService: jasmine.SpyObj; 52 | let mockLanguageService: jasmine.SpyObj; 53 | let mockRestrictionsService: jasmine.SpyObj; 54 | let mockUserAccountService: jasmine.SpyObj; 55 | let converterService: ConverterService; 56 | 57 | beforeEach(() => { 58 | mockContentService = jasmine.createSpyObj('ContentService', ['getPage']); 59 | mockLanguageService = jasmine.createSpyObj('LanguageService', ['getActive']); 60 | mockRestrictionsService = jasmine.createSpyObj('RestrictionsService', ['setUserPermissions']); 61 | mockUserAccountService = jasmine.createSpyObj('UserAccountFacade', ['get']); 62 | mockUserAccountService.get.and.returnValue(of(undefined)); 63 | 64 | TestBed.configureTestingModule({ 65 | providers: [ 66 | ContentfulCmsPageAdapter, 67 | { provide: ContentService, useValue: mockContentService }, 68 | { provide: LanguageService, useValue: mockLanguageService }, 69 | { provide: RestrictionsService, useValue: mockRestrictionsService }, 70 | { provide: UserAccountFacade, useValue: mockUserAccountService }, 71 | ], 72 | }); 73 | 74 | adapter = TestBed.inject(ContentfulCmsPageAdapter); 75 | 76 | converterService = TestBed.inject(ConverterService); 77 | mockContentService = TestBed.inject(ContentService) as jasmine.SpyObj; 78 | mockLanguageService = TestBed.inject(LanguageService) as jasmine.SpyObj; 79 | 80 | spyOn(converterService, 'pipeable').and.callThrough(); 81 | mockContentService.getPage.and.returnValue(of({} as Entry)); 82 | mockLanguageService.getActive.and.returnValue(of('en')); 83 | }); 84 | 85 | it('should be created', () => { 86 | expect(adapter).toBeTruthy(); 87 | }); 88 | 89 | describe('load', () => { 90 | it('should pass correct params for homepage context', (done) => { 91 | adapter.load(homepagePageContext).subscribe(() => { 92 | expect(mockContentService.getPage).toHaveBeenCalledWith({ pageSlug: 'homepage' }, 'en'); 93 | done(); 94 | }); 95 | }); 96 | 97 | it('should pass correct params for catalog context', (done) => { 98 | adapter.load(catalogPageContext).subscribe(() => { 99 | expect(mockContentService.getPage).toHaveBeenCalledWith({ pageSlug: 'catalog' }, 'en'); 100 | done(); 101 | }); 102 | }); 103 | 104 | it('should pass correct params for category context', (done) => { 105 | adapter.load(categoryPageContext).subscribe(() => { 106 | expect(mockContentService.getPage).toHaveBeenCalledWith({ pageSlug: 'catalog' }, 'en'); 107 | done(); 108 | }); 109 | }); 110 | 111 | it('should pass correct params for content context', (done) => { 112 | adapter.load(contentPageContext).subscribe(() => { 113 | expect(mockContentService.getPage).toHaveBeenCalledWith({ pageSlug: 'test' }, 'en'); 114 | done(); 115 | }); 116 | }); 117 | 118 | it('should pass correct params for product context', (done) => { 119 | adapter.load(productPageContext).subscribe(() => { 120 | expect(mockContentService.getPage).toHaveBeenCalledWith({ pageSlug: 'product' }, 'en'); 121 | done(); 122 | }); 123 | }); 124 | 125 | it('should pass correct params for smartedit context', (done) => { 126 | adapter.load(smarteditPageContext).subscribe(() => { 127 | expect(mockContentService.getPage).toHaveBeenCalledWith({ pageSlug: 'homepage' }, 'en'); 128 | done(); 129 | }); 130 | }); 131 | 132 | it('should return empty params for undefined context', (done) => { 133 | adapter.load(undefinedPageContext).subscribe(() => { 134 | expect(mockContentService.getPage).toHaveBeenCalledWith({}, 'en'); 135 | done(); 136 | }); 137 | }); 138 | }); 139 | }); 140 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ContentfulSpartacusDemo 2 | 3 | This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 19.2.4. 4 | 5 | ## Configuration 6 | 7 | In order to configure the Contentful CMS connection please add the proper space, branch and tokens token values to the `src/environments/environment.local.ts` file, which will be used during local app startup with `ng serve`. 8 | 9 | ## Development server 10 | 11 | Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The application will automatically reload if you change any of the source files. 12 | 13 | ## Code scaffolding 14 | 15 | Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`. 16 | 17 | ## Build 18 | 19 | Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory. 20 | 21 | ## Running unit tests 22 | 23 | Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io). 24 | 25 | ## Running end-to-end tests 26 | 27 | Run `ng e2e` to execute the end-to-end tests via a platform of your choice. To use this command, you need to first add a package that implements end-to-end testing capabilities. 28 | 29 | ## Further help 30 | 31 | For more information on the Composable Storefront Integration Library for Contentful check out the [Getting Started - Demo Setup](https://github.com/contentful/composable-storefront-integration-library/wiki/Getting-started-%E2%80%90-Demo-setup) page. 32 | 33 | To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.io/cli) page. 34 | 35 | ## Spartacus 36 | 37 | Selected default features and modules during initialization: 38 | 39 | - ✅ Assisted Services Module 40 | - ✅ ASM Customer 360 41 | - ✅ Import/Export 42 | - ✅ Saved Cart 43 | - ✅ Quick Order 44 | - ⛔️ Customer Data Cloud Integration - B2C 45 | - ⛔️ Customer Data Cloud Integration - B2B 46 | - ⛔️ Customer Data Platform Integration 47 | - ⛔️ Context-Driven Services Integration 48 | - ✅ Checkout base 49 | - ✅ Checkout B2B (b2b feature, requires Base Checkout) 50 | - ✅ Checkout Scheduled Replenishment (b2b feature, requires Base and B2B Checkout) 51 | - ✅ Cart 52 | - ✅ WishList 53 | - ✅ Order 54 | - ⛔️ Digital Payments Integration 55 | - ⛔️ EPD Visualization Integration 56 | - ✅ Organization - Administration (b2b feature) 57 | - ✅ Organization - User Registration (b2b feature) 58 | - ✅ Organization - Unit Order (b2b feature) 59 | - ✅ Organization - Order Approval (b2b feature) 60 | - ✅ Organization - Account Summary (b2b feature, requires Organization - Administration) 61 | - ⛔️ Product - Bulk Pricing (b2b feature) 62 | - ✅ Product - Variants 63 | - ⛔️ Product Multi-Dimensional - Selector 64 | - ⛔️ Product Multi-Dimensional - PLP price range 65 | - ✅ Product - Image Zoom 66 | - ⛔️ Product - Future Stock 67 | - ⛔️ Product Configurator - Variant Configurator 68 | - ⛔️ Product Configurator - Textfield Configurator 69 | - ⛔️ Product Configurator - CPQ Configurator (b2b feature) 70 | - ⛔️ PDF Invoices 71 | - ⛔️ Qualtrics 72 | - ⛔️ Requested Delivery Date 73 | - ⛔️ Estimated Delivery Date 74 | - ⛔️ S/4HANA Order Management (b2b feature) 75 | - ⛔️ CPQ Quote Integration (b2b feature) 76 | - ⛔️ S/4HANA Service integration (b2b feature) 77 | - ⛔️ SAP Order Management Foundation Integration 78 | - ⛔️ SmartEdit 79 | - ✅ Store Finder 80 | - ✅ Tracking - Personalization 81 | - ⛔️ Segment Reference 82 | - ⛔️ Tracking - Tag Management System - Google Tag Manager 83 | - ⛔️ Tracking - Tag Management System - Adobe Experience Platform Launch 84 | - ⛔️ Omnichannel Promotion Pricing Service (OPPS) Integration 85 | - ✅ User - Account 86 | - ✅ User - Profile 87 | - ✅ Quote 88 | - ✅ Customer Ticketing 89 | - ⛔️ Pickup in Store 90 | 91 | ## Spartacus RBSC Token 92 | 93 | Before running `npm install`, replace the `${SAP_RBSC_TOKEN}` from the `.npmrc` file with the provided Spartacus RBSC token. 94 | 95 | ## Permissions and component restrictions 96 | 97 | Quick overview of the restricted components and the user roles required for them: 98 | 99 | | Component | Anonymous | Login | Customer | Approver | Manager | Admin | Unit Level Order Viewer | 100 | | :--------------------------------------- | :-------: | :---: | :------: | :------: | :-----: | :---: | :---------------------: | 101 | | Anonymous Consent Management Banner | ✅ | | | | | | | 102 | | Anonymous Consent Management Open Dialog | ✅ | | | | | | | 103 | | My Account | | ✅ | | | | | | 104 | | PaymentDetailsLink | | | ✅ | | | | | 105 | | OrderHistoryLink | | | ✅ | | | | | 106 | | AddressBookLink | | | ✅ | | | | | 107 | | ApprovalDashboardLink | | | | ✅ | | | | 108 | | MyCompanyLink | | | | | | ✅ | | 109 | | UnitLevelOrderLink | | | | | | | ✅ | 110 | 111 | -------------------------------------------------------------------------------- /angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "contentful-spartacus-demo": { 7 | "projectType": "application", 8 | "schematics": { 9 | "@schematics/angular:component": { 10 | "style": "scss", 11 | "standalone": false 12 | }, 13 | "@schematics/angular:directive": { 14 | "standalone": false 15 | }, 16 | "@schematics/angular:pipe": { 17 | "standalone": false 18 | } 19 | }, 20 | "root": "", 21 | "sourceRoot": "src", 22 | "prefix": "app", 23 | "architect": { 24 | "build": { 25 | "builder": "@angular-devkit/build-angular:application", 26 | "options": { 27 | "outputPath": "dist/contentful-spartacus-demo", 28 | "index": "src/index.html", 29 | "browser": "src/main.ts", 30 | "polyfills": ["zone.js"], 31 | "tsConfig": "tsconfig.app.json", 32 | "inlineStyleLanguage": "scss", 33 | "assets": [ 34 | { 35 | "glob": "**/*", 36 | "input": "public" 37 | } 38 | ], 39 | "styles": [ 40 | "src/styles.scss", 41 | "src/styles/spartacus/user.scss", 42 | "src/styles/spartacus/cart.scss", 43 | "src/styles/spartacus/order.scss", 44 | "src/styles/spartacus/checkout.scss", 45 | "src/styles/spartacus/storefinder.scss", 46 | "src/styles/spartacus/asm.scss", 47 | "src/styles/spartacus/product.scss", 48 | "src/styles/spartacus/organization.scss", 49 | "src/styles/spartacus/quote.scss", 50 | "src/styles/spartacus/customer-ticketing.scss" 51 | ], 52 | "scripts": [], 53 | "stylePreprocessorOptions": { 54 | "includePaths": ["node_modules/"], 55 | "sass": { 56 | "silenceDeprecations": ["import"] 57 | } 58 | } 59 | }, 60 | "configurations": { 61 | "production": { 62 | "budgets": [ 63 | { 64 | "type": "initial", 65 | "maximumWarning": "500kb", 66 | "maximumError": "3.5mb" 67 | }, 68 | { 69 | "type": "anyComponentStyle", 70 | "maximumWarning": "2kb", 71 | "maximumError": "4kb" 72 | } 73 | ], 74 | "fileReplacements": [ 75 | { 76 | "replace": "src/environments/environment.ts", 77 | "with": "src/environments/environment.production.ts" 78 | } 79 | ], 80 | "outputHashing": "all" 81 | }, 82 | "development": { 83 | "optimization": false, 84 | "extractLicenses": false, 85 | "sourceMap": true, 86 | "fileReplacements": [ 87 | { 88 | "replace": "src/environments/environment.ts", 89 | "with": "src/environments/environment.dev.ts" 90 | } 91 | ] 92 | }, 93 | "local": { 94 | "fileReplacements": [ 95 | { 96 | "replace": "src/environments/environment.ts", 97 | "with": "src/environments/environment.local.ts" 98 | } 99 | ], 100 | "aot": true, 101 | "optimization": false, 102 | "sourceMap": true, 103 | "outputHashing": "all" 104 | } 105 | }, 106 | "defaultConfiguration": "production" 107 | }, 108 | "serve": { 109 | "builder": "@angular-devkit/build-angular:dev-server", 110 | "configurations": { 111 | "production": { 112 | "buildTarget": "contentful-spartacus-demo:build:production" 113 | }, 114 | "development": { 115 | "buildTarget": "contentful-spartacus-demo:build:development" 116 | }, 117 | "local": { 118 | "buildTarget": "contentful-spartacus-demo:build:local" 119 | } 120 | }, 121 | "defaultConfiguration": "local" 122 | }, 123 | "extract-i18n": { 124 | "builder": "@angular-devkit/build-angular:extract-i18n", 125 | "options": { 126 | "buildTarget": "contentful-spartacus-demo:build" 127 | } 128 | }, 129 | "test": { 130 | "builder": "@angular-devkit/build-angular:karma", 131 | "options": { 132 | "polyfills": ["zone.js", "zone.js/testing"], 133 | "tsConfig": "tsconfig.spec.json", 134 | "inlineStyleLanguage": "scss", 135 | "assets": [ 136 | { 137 | "glob": "**/*", 138 | "input": "public" 139 | } 140 | ], 141 | "styles": [ 142 | "src/styles.scss", 143 | "src/styles/spartacus/user.scss", 144 | "src/styles/spartacus/cart.scss", 145 | "src/styles/spartacus/order.scss", 146 | "src/styles/spartacus/checkout.scss", 147 | "src/styles/spartacus/storefinder.scss", 148 | "src/styles/spartacus/asm.scss", 149 | "src/styles/spartacus/product.scss", 150 | "src/styles/spartacus/organization.scss", 151 | "src/styles/spartacus/quote.scss", 152 | "src/styles/spartacus/customer-ticketing.scss" 153 | ], 154 | "scripts": [], 155 | "stylePreprocessorOptions": { 156 | "includePaths": ["node_modules/"] 157 | }, 158 | "karmaConfig": "karma.conf.js" 159 | } 160 | }, 161 | "lint": { 162 | "builder": "@angular-eslint/builder:lint", 163 | "options": { 164 | "lintFilePatterns": ["src/**/*.ts", "src/**/*.html"] 165 | } 166 | } 167 | } 168 | } 169 | }, 170 | "cli": { 171 | "schematicCollections": ["@angular-eslint/schematics"] 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /src/app/contentful/core/services/contentful-angular.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | import { ContentfulLivePreview, ContentfulLivePreviewInitConfig, LivePreviewProps } from '@contentful/live-preview'; 3 | import { InspectorModeDataAttributes, InspectorModeTags } from '@contentful/live-preview/dist/inspectorMode/types'; 4 | 5 | import { ContentfulAngularService } from './contentful-angular.service'; 6 | 7 | describe('ContentfulAngularService', () => { 8 | let service: ContentfulAngularService; 9 | 10 | beforeEach(() => { 11 | TestBed.configureTestingModule({ 12 | providers: [ContentfulAngularService], 13 | }); 14 | service = TestBed.inject(ContentfulAngularService); 15 | spyOn(ContentfulLivePreview, 'init'); 16 | }); 17 | 18 | it('should be created', () => { 19 | expect(service).toBeTruthy(); 20 | }); 21 | 22 | describe('init', () => { 23 | it('should initialize ContentfulLivePreview with the given config', () => { 24 | const config: ContentfulLivePreviewInitConfig = { 25 | locale: 'en', 26 | space: 'spaceId', 27 | environment: 'env', 28 | debugMode: true, 29 | enableInspectorMode: true, 30 | enableLiveUpdates: true, 31 | targetOrigin: 'http://localhost', 32 | experimental: { ignoreManuallyTaggedElements: false }, 33 | }; 34 | 35 | service.init(config); 36 | 37 | expect(ContentfulLivePreview.init).toHaveBeenCalledWith(config); 38 | }); 39 | }); 40 | 41 | describe('getInspectorModeTags', () => { 42 | it('should return inspector mode tags if enableInspectorMode is true', () => { 43 | const config: LivePreviewProps = { entryId: 'entryId', fieldId: 'fieldId' }; 44 | const tags: InspectorModeTags = { [InspectorModeDataAttributes.ENTRY_ID]: 'entryId', [InspectorModeDataAttributes.FIELD_ID]: 'fieldId' }; 45 | 46 | service.init({ enableInspectorMode: true, locale: 'en-US' }); 47 | spyOn(ContentfulLivePreview, 'getProps').and.returnValue(tags); 48 | 49 | const result = service.getInspectorModeTags(config); 50 | 51 | expect(result).toEqual(tags); 52 | }); 53 | 54 | it('should return null if enableInspectorMode is false', () => { 55 | const config: LivePreviewProps = { entryId: 'entryId', fieldId: 'fieldId' }; 56 | 57 | service.init({ enableInspectorMode: false, locale: 'en-US' }); 58 | 59 | const result = service.getInspectorModeTags(config); 60 | 61 | expect(result).toBeNull(); 62 | }); 63 | }); 64 | 65 | describe('activateLiveUpdates', () => { 66 | it('should subscribe to live updates if data is valid', () => { 67 | const data = { some: 'data' }; 68 | const callback = jasmine.createSpy('callback'); 69 | const unsubscribe = jasmine.createSpy('unsubscribe'); 70 | 71 | service.init({ enableLiveUpdates: true, locale: 'en-US' }); 72 | spyOn(ContentfulLivePreview, 'subscribe').and.returnValue(unsubscribe); 73 | 74 | const result = service.activateLiveUpdates(data, callback); 75 | 76 | expect(ContentfulLivePreview.subscribe).toHaveBeenCalledWith('edit', { 77 | data, 78 | locale: undefined, 79 | callback, 80 | }); 81 | expect(result).toBe(unsubscribe); 82 | }); 83 | 84 | it('should subscribe to live updates if data is a non-empty array', () => { 85 | const data = [1]; 86 | const callback = jasmine.createSpy('callback'); 87 | const unsubscribe = jasmine.createSpy('unsubscribe'); 88 | 89 | spyOn(ContentfulLivePreview, 'subscribe').and.returnValue(unsubscribe); 90 | service.init({ enableLiveUpdates: true, locale: 'en-US' }); 91 | 92 | const result = service.activateLiveUpdates(data, callback); 93 | 94 | expect(ContentfulLivePreview.subscribe).toHaveBeenCalledWith('edit', { 95 | data, 96 | locale: undefined, 97 | callback, 98 | }); 99 | expect(result).toBe(unsubscribe); 100 | }); 101 | 102 | it('should subscribe to live updates with locale', () => { 103 | const data = { some: 'data' }; 104 | const callback = jasmine.createSpy('callback'); 105 | const unsubscribe = jasmine.createSpy('unsubscribe'); 106 | 107 | spyOn(ContentfulLivePreview, 'subscribe').and.returnValue(unsubscribe); 108 | service.init({ enableLiveUpdates: true, locale: 'en-US' }); 109 | 110 | const result = service.activateLiveUpdates(data, callback, 'en-US'); 111 | 112 | expect(ContentfulLivePreview.subscribe).toHaveBeenCalledWith('edit', { 113 | data, 114 | locale: 'en-US', 115 | callback, 116 | }); 117 | expect(result).toBe(unsubscribe); 118 | }); 119 | 120 | it('should subscribe to live updates with options object', () => { 121 | const data = { some: 'data' }; 122 | const callback = jasmine.createSpy('callback'); 123 | const unsubscribe = jasmine.createSpy('unsubscribe'); 124 | 125 | spyOn(ContentfulLivePreview, 'subscribe').and.returnValue(unsubscribe); 126 | service.init({ enableLiveUpdates: true, locale: 'en-US' }); 127 | 128 | const result = service.activateLiveUpdates(data, callback, { locale: 'en-US' }); 129 | 130 | expect(ContentfulLivePreview.subscribe).toHaveBeenCalledWith('edit', { 131 | data, 132 | locale: 'en-US', 133 | callback, 134 | }); 135 | expect(result).toBe(unsubscribe); 136 | }); 137 | 138 | it('should not subscribe to live updates if live updates are disabled', () => { 139 | const data = {}; 140 | const callback = jasmine.createSpy('callback'); 141 | 142 | service.init({ enableLiveUpdates: false, locale: 'en-US' }); 143 | spyOn(ContentfulLivePreview, 'subscribe'); 144 | 145 | const result = service.activateLiveUpdates(data, callback); 146 | 147 | expect(ContentfulLivePreview.subscribe).not.toHaveBeenCalled(); 148 | expect(result).toBeUndefined(); 149 | }); 150 | 151 | it('should not subscribe to live updates if data is invalid', () => { 152 | const data = {}; 153 | const callback = jasmine.createSpy('callback'); 154 | 155 | service.init({ enableLiveUpdates: true, locale: 'en-US' }); 156 | spyOn(ContentfulLivePreview, 'subscribe'); 157 | 158 | const result = service.activateLiveUpdates(data, callback); 159 | 160 | expect(ContentfulLivePreview.subscribe).not.toHaveBeenCalled(); 161 | expect(result).toBeUndefined(); 162 | }); 163 | 164 | it('should not subscribe to live updates if data is an empty array', () => { 165 | const data: unknown[] = []; 166 | const callback = jasmine.createSpy('callback'); 167 | 168 | service.init({ enableLiveUpdates: true, locale: 'en-US' }); 169 | spyOn(ContentfulLivePreview, 'subscribe'); 170 | 171 | const result = service.activateLiveUpdates(data, callback); 172 | 173 | expect(ContentfulLivePreview.subscribe).not.toHaveBeenCalled(); 174 | expect(result).toBeUndefined(); 175 | }); 176 | 177 | it('should not subscribe to live updates if data is an empty object', () => { 178 | const data = {}; 179 | const callback = jasmine.createSpy('callback'); 180 | 181 | service.init({ enableLiveUpdates: true, locale: 'en-US' }); 182 | spyOn(ContentfulLivePreview, 'subscribe'); 183 | 184 | const result = service.activateLiveUpdates(data, callback); 185 | 186 | expect(ContentfulLivePreview.subscribe).not.toHaveBeenCalled(); 187 | expect(result).toBeUndefined(); 188 | }); 189 | }); 190 | }); 191 | -------------------------------------------------------------------------------- /src/app/contentful/cms/adapters/converters/components/contentful-cms-navigation-component-normalizer.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { CmsNavigationComponent } from '@spartacus/core'; 4 | 5 | import { Entry } from 'contentful'; 6 | 7 | import { ComponentSkeleton, NavigationNodeSkeleton } from '../../../../core/content-types'; 8 | import { DeepPartial } from '../../../../core/helpers'; 9 | import { ContentfulCmsNavigationComponentNormalizer } from './contentful-cms-navigation-component-normalizer'; 10 | 11 | const mockNavigationNode: DeepPartial> = { 12 | 'sys': { 13 | 'id': 'categoryNavNodeId', 14 | 'type': 'Entry', 15 | 'contentType': { 16 | 'sys': { 17 | 'type': 'Link', 18 | 'linkType': 'ContentType', 19 | 'id': 'NavNode', 20 | }, 21 | }, 22 | }, 23 | 'fields': { 24 | 'uid': 'PowertoolsCategoryNavNode', 25 | 'children': [ 26 | { 27 | 'sys': { 28 | 'id': 'firstLevelNavNodeId', 29 | 'type': 'Entry', 30 | 'contentType': { 31 | 'sys': { 32 | 'type': 'Link', 33 | 'linkType': 'ContentType', 34 | 'id': 'NavNode', 35 | }, 36 | }, 37 | }, 38 | 'fields': { 39 | 'uid': 'SafetyNavNode', 40 | 'children': [ 41 | { 42 | 'metadata': { 43 | 'tags': [], 44 | 'concepts': [], 45 | }, 46 | 'sys': { 47 | 'id': 'secondLevelNavNodeId', 48 | 'type': 'Entry', 49 | 'contentType': { 50 | 'sys': { 51 | 'type': 'Link', 52 | 'linkType': 'ContentType', 53 | 'id': 'NavNode', 54 | }, 55 | }, 56 | }, 57 | 'fields': { 58 | 'uid': 'FootwearLinksNavNode', 59 | 'children': [ 60 | { 61 | 'metadata': { 62 | 'tags': [], 63 | 'concepts': [], 64 | }, 65 | 'sys': { 66 | 'id': 'thirdLevelNavNodeId', 67 | 'type': 'Entry', 68 | 'contentType': { 69 | 'sys': { 70 | 'type': 'Link', 71 | 'linkType': 'ContentType', 72 | 'id': 'NavNode', 73 | }, 74 | }, 75 | }, 76 | 'fields': { 77 | 'uid': 'FootwearMensCategoryNavNode', 78 | 'entries': [ 79 | { 80 | 'sys': { 81 | 'id': 'linkEntryId', 82 | 'type': 'Entry', 83 | 'contentType': { 84 | 'sys': { 85 | 'type': 'Link', 86 | 'linkType': 'ContentType', 87 | 'id': 'CMSLinkComponent', 88 | }, 89 | }, 90 | }, 91 | 'fields': { 92 | 'name': "Footwear Men's Category Link", 93 | 'linkName': "Men's", 94 | 'url': '/Open-Catalogue/Office-Equipment%2C-Supplies-%26-Accessories/Hand-Tools/Safety/Footwear/Mens/c/1805', 95 | 'target': false, 96 | }, 97 | }, 98 | ], 99 | }, 100 | }, 101 | ], 102 | 'title': 'Footwear', 103 | }, 104 | }, 105 | ], 106 | 'entries': [ 107 | { 108 | 'metadata': { 109 | 'tags': [], 110 | 'concepts': [], 111 | }, 112 | 'sys': { 113 | 'id': 'linkEntryCategoryId', 114 | 'type': 'Entry', 115 | 'contentType': { 116 | 'sys': { 117 | 'type': 'Link', 118 | 'linkType': 'ContentType', 119 | 'id': 'CMSLinkComponent', 120 | }, 121 | }, 122 | }, 123 | 'fields': { 124 | 'name': 'Safety Category Link', 125 | 'linkName': 'Safety', 126 | 'url': '/Open-Catalogue/Office-Equipment%2C-Supplies-%26-Accessories/Hand-Tools/Safety/c/1800', 127 | 'target': false, 128 | }, 129 | }, 130 | ], 131 | 'title': 'Safety', 132 | }, 133 | }, 134 | ], 135 | }, 136 | }; 137 | 138 | const initialMockComponentFields = { 139 | 'navigationNode': mockNavigationNode as Entry, 140 | 'urlLink': '/Open-Catalogue/Tools/c/1355', 141 | 'name': 'Powertools Hompage Splash Banner Component', 142 | }; 143 | 144 | const mockComponent: DeepPartial> = { 145 | 'sys': { 146 | 'id': 'entryId', 147 | 'type': 'Entry', 148 | 'contentType': { 149 | 'sys': { 150 | 'type': 'Link', 151 | 'linkType': 'ContentType', 152 | 'id': 'CategoryNavigationComponent', 153 | }, 154 | }, 155 | }, 156 | 'fields': { ...initialMockComponentFields }, 157 | }; 158 | 159 | describe('Contentful CMS Navigation Node Normalizer', () => { 160 | let normalizer: ContentfulCmsNavigationComponentNormalizer; 161 | 162 | beforeEach(() => { 163 | mockComponent.fields = { ...initialMockComponentFields }; 164 | 165 | TestBed.configureTestingModule({ 166 | providers: [ContentfulCmsNavigationComponentNormalizer], 167 | }); 168 | 169 | normalizer = TestBed.inject(ContentfulCmsNavigationComponentNormalizer); 170 | }); 171 | 172 | it('should normalize navigation node', () => { 173 | const component: CmsNavigationComponent = { 174 | typeCode: 'CategoryNavigationComponent', 175 | navigationNode: {}, 176 | }; 177 | normalizer.convert(mockComponent as Entry, component); 178 | 179 | expect(component.navigationNode).toEqual({ 180 | uid: 'categoryNavNodeId', 181 | title: '', 182 | children: [ 183 | { 184 | uid: 'firstLevelNavNodeId', 185 | title: 'Safety', 186 | children: [ 187 | { 188 | uid: 'secondLevelNavNodeId', 189 | title: 'Footwear', 190 | children: [ 191 | { 192 | uid: 'thirdLevelNavNodeId', 193 | title: '', 194 | children: undefined, 195 | entries: [ 196 | { 197 | itemId: 'linkEntryId', 198 | itemSuperType: 'AbstractCMSComponent', 199 | itemType: 'CMSLinkComponent', 200 | }, 201 | ], 202 | }, 203 | ], 204 | entries: undefined, 205 | }, 206 | ], 207 | entries: [ 208 | { 209 | itemId: 'linkEntryCategoryId', 210 | itemSuperType: 'AbstractCMSComponent', 211 | itemType: 'CMSLinkComponent', 212 | }, 213 | ], 214 | }, 215 | ], 216 | entries: undefined, 217 | }); 218 | }); 219 | }); 220 | -------------------------------------------------------------------------------- /src/app/contentful/core/services/contentful-live-preview.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; 2 | import { provideHttpClientTesting } from '@angular/common/http/testing'; 3 | import { NgZone, Renderer2, RendererFactory2 } from '@angular/core'; 4 | import { TestBed } from '@angular/core/testing'; 5 | import { InspectorModeDataAttributes } from '@contentful/live-preview/dist/inspectorMode/types'; 6 | import { Store, StoreModule } from '@ngrx/store'; 7 | 8 | import { CmsActions, ContentSlotComponentData, ConverterService, LanguageService, PageContext, RoutingService } from '@spartacus/core'; 9 | 10 | import { of } from 'rxjs'; 11 | 12 | import { ContentfulConfig } from '../../root/config/contentful-config'; 13 | import { ContentfulAngularService } from './contentful-angular.service'; 14 | import { ContentfulLivePreviewService } from './contentful-live-preview.service'; 15 | 16 | describe('ContentfulLivePreviewService', () => { 17 | let service: ContentfulLivePreviewService; 18 | let contentfulAngularService: jasmine.SpyObj; 19 | let routingService: jasmine.SpyObj; 20 | let converterService: jasmine.SpyObj; 21 | let renderer: Renderer2; 22 | let storeSpy: jasmine.SpyObj; 23 | 24 | const locale = 'en'; 25 | 26 | beforeEach(() => { 27 | const contentfulAngularServiceSpy = jasmine.createSpyObj('ContentfulAngularService', ['init', 'getInspectorModeTags', 'activateLiveUpdates']); 28 | const routingServiceSpy = jasmine.createSpyObj('RoutingService', ['getPageContext']); 29 | const converterServiceSpy = jasmine.createSpyObj('ConverterService', ['convert']); 30 | storeSpy = jasmine.createSpyObj('Store', ['dispatch']); 31 | 32 | TestBed.configureTestingModule({ 33 | imports: [StoreModule.forRoot({})], 34 | providers: [ 35 | { provide: ContentfulAngularService, useValue: contentfulAngularServiceSpy }, 36 | { 37 | provide: ContentfulConfig, 38 | useValue: { 39 | 'init': jasmine.createSpy(), 40 | }, 41 | }, 42 | { provide: NgZone, useValue: new NgZone({}) }, 43 | { 44 | provide: LanguageService, 45 | useValue: { 46 | getActive: () => of(locale), 47 | }, 48 | }, 49 | { provide: RoutingService, useValue: routingServiceSpy }, 50 | { provide: ConverterService, useValue: converterServiceSpy }, 51 | { provide: Store, useValue: storeSpy }, 52 | provideHttpClient(withInterceptorsFromDi()), 53 | provideHttpClientTesting(), 54 | ], 55 | }); 56 | service = TestBed.inject(ContentfulLivePreviewService); 57 | contentfulAngularService = TestBed.inject(ContentfulAngularService) as jasmine.SpyObj; 58 | routingService = TestBed.inject(RoutingService) as jasmine.SpyObj; 59 | converterService = TestBed.inject(ConverterService) as jasmine.SpyObj; 60 | 61 | const rendererFactory = TestBed.inject(RendererFactory2); 62 | renderer = rendererFactory.createRenderer(null, null); 63 | }); 64 | 65 | it('should be created', () => { 66 | expect(service).toBeTruthy(); 67 | }); 68 | 69 | it('should initialize ContentfulAngularService with language', () => { 70 | expect(contentfulAngularService.init).toHaveBeenCalledWith({ 71 | locale: locale, 72 | enableInspectorMode: true, 73 | enableLiveUpdates: true, 74 | debugMode: jasmine.any(Boolean), 75 | }); 76 | }); 77 | 78 | describe('hasInspectorModeTags', () => { 79 | it('should return true if element has inspector mode tags', () => { 80 | const element = document.createElement('div'); 81 | element.setAttribute(InspectorModeDataAttributes.FIELD_ID, 'fieldId'); 82 | expect(service.hasInspectorModeTags(element)).toBeTrue(); 83 | }); 84 | 85 | it('should return false if element does not have inspector mode tags', () => { 86 | const element = document.createElement('div'); 87 | expect(service.hasInspectorModeTags(element)).toBeFalse(); 88 | }); 89 | }); 90 | 91 | describe('addInspectorModeTags', () => { 92 | it('should add inspector mode tags to element', () => { 93 | const element = document.createElement('div'); 94 | const component: ContentSlotComponentData = { uid: 'uid', flexType: 'flexType' }; 95 | const tags = { [InspectorModeDataAttributes.ENTRY_ID]: 'uid', [InspectorModeDataAttributes.FIELD_ID]: 'flexType' }; 96 | 97 | contentfulAngularService.getInspectorModeTags.and.returnValue(tags); 98 | service.addInspectorModeTags(element, renderer, component); 99 | 100 | expect(element.getAttribute(InspectorModeDataAttributes.FIELD_ID)).toBe('flexType'); 101 | expect(element.getAttribute(InspectorModeDataAttributes.ENTRY_ID)).toBe('uid'); 102 | }); 103 | 104 | it('should add inspector mode tags to element for empty entry and field ids', () => { 105 | const element = document.createElement('div'); 106 | const component: ContentSlotComponentData = {}; 107 | 108 | service.addInspectorModeTags(element, renderer, component); 109 | 110 | expect(element.getAttribute(InspectorModeDataAttributes.FIELD_ID)).toBe(''); 111 | expect(element.getAttribute(InspectorModeDataAttributes.ENTRY_ID)).toBe(''); 112 | }); 113 | 114 | it('should not add inspector mode tags to element if inspector mode is disabled', () => { 115 | const element = document.createElement('div'); 116 | const component: ContentSlotComponentData = { uid: 'uid', flexType: 'flexType' }; 117 | 118 | contentfulAngularService.getInspectorModeTags.and.returnValue(null); 119 | service.addInspectorModeTags(element, renderer, component); 120 | 121 | expect(element.getAttribute(InspectorModeDataAttributes.FIELD_ID)).toBe(''); 122 | expect(element.getAttribute(InspectorModeDataAttributes.ENTRY_ID)).toBe(''); 123 | }); 124 | }); 125 | 126 | describe('initComponentLiveUpdate', () => { 127 | it('should subscribe to live updates if component has uid', () => { 128 | const component: ContentSlotComponentData = { uid: 'uid', properties: { data: { 'sys.id': 'testId' } } }; 129 | const pageContext: PageContext = { id: 'pageId' }; 130 | const unsubscribe = jasmine.createSpy('unsubscribe'); 131 | 132 | contentfulAngularService.activateLiveUpdates.and.returnValue(unsubscribe); 133 | converterService.convert.and.returnValue(component); 134 | routingService.getPageContext.and.returnValue(of(pageContext)); 135 | service.initComponentLiveUpdate(component); 136 | 137 | expect(contentfulAngularService.activateLiveUpdates).toHaveBeenCalledWith({ 'sys.id': 'testId' }, jasmine.any(Function)); 138 | expect(service.liveUpdateSubscriptions.get('uid')).toBe(unsubscribe); 139 | }); 140 | 141 | it('should not subscribe to live updates if component does not have uid', () => { 142 | const component: ContentSlotComponentData = { properties: { data: 'data' } }; 143 | service.initComponentLiveUpdate(component); 144 | 145 | expect(contentfulAngularService.activateLiveUpdates).not.toHaveBeenCalled(); 146 | }); 147 | }); 148 | 149 | describe('updateCmsComponent', () => { 150 | it('should convert component data and dispatch action when uid exists', () => { 151 | const updatedComponentData = { id: 'test-id' }; // Mock input 152 | const convertedComponent = { uid: 'test-uid' }; // Mock output of convert 153 | const mockPageContext = { id: 'homepage' }; // Mock page context 154 | 155 | converterService.convert.and.returnValue(convertedComponent); 156 | routingService.getPageContext.and.returnValue(of(mockPageContext)); 157 | 158 | service['updateCmsComponent'](updatedComponentData); 159 | 160 | expect(converterService.convert).toHaveBeenCalledWith(updatedComponentData, jasmine.any(Object)); 161 | expect(routingService.getPageContext).toHaveBeenCalled(); 162 | expect(storeSpy.dispatch).toHaveBeenCalledWith( 163 | new CmsActions.LoadCmsComponentSuccess({ 164 | component: convertedComponent, 165 | uid: 'test-uid', 166 | pageContext: mockPageContext, 167 | }), 168 | ); 169 | }); 170 | }); 171 | }); 172 | -------------------------------------------------------------------------------- /src/app/contentful/cms/adapters/converters/components/contentful-cms-banner-component-normalizer.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { CmsBannerComponent, CmsResponsiveBannerComponentMedia } from '@spartacus/core'; 4 | 5 | import { Asset, Entry } from 'contentful'; 6 | 7 | import { ComponentSkeleton, MediaContainerSkeleton } from '../../../../core/content-types'; 8 | import { DeepPartial } from '../../../../core/helpers'; 9 | import { ContentfulCmsBannerComponentNormalizer } from './contentful-cms-banner-component-normalizer'; 10 | 11 | const mockMedia: DeepPartial> = { 12 | 'sys': { 13 | 'type': 'Asset', 14 | 'locale': 'en', 15 | }, 16 | 'fields': { 17 | 'title': 'Powertools_350x280_25Deal_EN_01_350W.jpg', 18 | 'description': '25% Great Prices and Great Deals', 19 | 'file': { 20 | 'url': '//images.ctfassets.net/spaceId/2I0Vb1eknVwnABIEZBzmKq/05205806d60e4eab3df932e39319acc8/Powertools-1400x480-BigSplash-EN-01-1400W.jpg', 21 | 'details': { 22 | 'size': 9010, 23 | 'image': { 24 | 'width': 350, 25 | 'height': 280, 26 | }, 27 | }, 28 | 'fileName': 'Powertools-350x280-25Deal-EN-01-350W.jpg', 29 | 'contentType': 'image/jpeg', 30 | }, 31 | }, 32 | }; 33 | 34 | const mockMediaWithoutFile: DeepPartial> = { 35 | 'sys': { 36 | 'type': 'Asset', 37 | 'locale': 'en', 38 | }, 39 | 'fields': { 40 | 'title': 'Powertools_350x280_25Deal_EN_01_350W.jpg', 41 | 'description': '25% Great Prices and Great Deals', 42 | }, 43 | }; 44 | 45 | const initialMockMediaContainerFields: DeepPartial['fields']> = { 46 | 'name': 'Powertools Homepage Splash', 47 | 'desktop': { 48 | 'sys': { 49 | 'id': 'mockMediaDesktopId', 50 | 'type': 'Asset', 51 | }, 52 | 'fields': { 53 | 'title': 'Powertools Homepage Splash Desktop', 54 | 'description': '', 55 | 'file': { 56 | 'url': '//images.ctfassets.net/iuusg1rrhk56/7LMGJdyn2gwTeJcQ0cI4aX/5e0af86064bb70cf04892ef58fb6bf55/Powertools_960x400_BigSplash_EN_01_960W.jpg', 57 | 'fileName': 'Powertools_960x400_BigSplash_EN_01_960W.jpg', 58 | 'contentType': 'image/jpeg', 59 | }, 60 | }, 61 | }, 62 | 'mobile': { 63 | 'sys': { 64 | 'id': 'mockMediaMobileId', 65 | 'type': 'Asset', 66 | }, 67 | 'fields': { 68 | 'title': 'Powertools Homepage Splash Mobile', 69 | 'description': '', 70 | 'file': { 71 | 'url': '//images.ctfassets.net/iuusg1rrhk56/5J4I6oBiine9HXDXPUcKsA/ed75757c741f9c9e378db9327e577f95/Powertools_320x300_BigSplash_EN_01_320W.jpg', 72 | 'contentType': 'image/jpeg', 73 | }, 74 | }, 75 | }, 76 | 'tablet': { 77 | 'sys': { 78 | 'id': 'mockMediaTabletId', 79 | 'type': 'Asset', 80 | }, 81 | 'fields': { 82 | 'title': 'Powertools Homepage Splash Tablet', 83 | 'description': '', 84 | 'file': { 85 | 'url': '//images.ctfassets.net/iuusg1rrhk56/3oj9q97TcWkYcNZnZNpn8B/fb2608c01f7e27b22b62e0855f21ff49/Powertools_770x350_BigSplash_EN_01_770W.jpg', 86 | 'contentType': 'image/jpeg', 87 | }, 88 | }, 89 | }, 90 | 'widescreen': { 91 | 'sys': { 92 | 'id': 'mockMediaWidescreenId', 93 | 'type': 'Asset', 94 | }, 95 | 'fields': { 96 | 'title': 'Powertools Homepage Splash Widescreen', 97 | 'description': '', 98 | 'file': { 99 | 'url': '//images.ctfassets.net/iuusg1rrhk56/6SQPtppPW3JhyI41IFrilV/5d3345ea81b9bcbb50f95f543e1190d2/Powertools_1400x480_BigSplash_EN_01_1400W.jpg', 100 | 'contentType': 'image/jpeg', 101 | }, 102 | }, 103 | }, 104 | }; 105 | 106 | const mockMediaContainer: DeepPartial> = { 107 | 'sys': { 108 | 'id': 'mockMediaContainerId', 109 | 'type': 'Entry', 110 | 'contentType': { 111 | 'sys': { 112 | 'type': 'Link', 113 | 'linkType': 'ContentType', 114 | 'id': 'MediaContainer', 115 | }, 116 | }, 117 | }, 118 | 'fields': { ...initialMockMediaContainerFields }, 119 | }; 120 | 121 | const mockProducts: string[] = [ 122 | 'https://api.cm77gs48zv-contentfu1-d1-public.model-t.cc.commerce.ondemand.com/occ/v2/powertools-spa/products/3755219', 123 | 'https://api.cm77gs48zv-contentfu1-d1-public.model-t.cc.commerce.ondemand.com/occ/v2/powertools-spa/products/3881018', 124 | 'https://api.cm77gs48zv-contentfu1-d1-public.model-t.cc.commerce.ondemand.com/occ/v2/powertools-spa/products/3592865', 125 | 'https://api.cm77gs48zv-contentfu1-d1-public.model-t.cc.commerce.ondemand.com/occ/v2/powertools-spa/products/2116279', 126 | 'https://api.cm77gs48zv-contentfu1-d1-public.model-t.cc.commerce.ondemand.com/occ/v2/powertools-spa/products/3755204', 127 | 'https://api.cm77gs48zv-contentfu1-d1-public.model-t.cc.commerce.ondemand.com/occ/v2/powertools-spa/products/1128762', 128 | 'https://api.cm77gs48zv-contentfu1-d1-public.model-t.cc.commerce.ondemand.com/occ/v2/powertools-spa/products/3092788', 129 | ]; 130 | 131 | const initialMockComponentFields = { 132 | 'media': mockMedia as Asset, 133 | }; 134 | 135 | const mockComponent: DeepPartial> = { 136 | 'sys': { 137 | 'id': 'entryId', 138 | 'type': 'Entry', 139 | 'contentType': { 140 | 'sys': { 141 | 'type': 'Link', 142 | 'linkType': 'ContentType', 143 | 'id': 'SimpleResponsiveBannerComponent', 144 | }, 145 | }, 146 | }, 147 | 'fields': { ...initialMockComponentFields }, 148 | }; 149 | 150 | describe('Contentful CMS Banner Normalizer', () => { 151 | let normalizer: ContentfulCmsBannerComponentNormalizer; 152 | 153 | beforeEach(() => { 154 | mockComponent.fields = { ...initialMockComponentFields }; 155 | mockMediaContainer.fields = { ...initialMockMediaContainerFields }; 156 | 157 | TestBed.configureTestingModule({ 158 | providers: [ContentfulCmsBannerComponentNormalizer], 159 | }); 160 | 161 | normalizer = TestBed.inject(ContentfulCmsBannerComponentNormalizer); 162 | }); 163 | 164 | it('should normalize media', () => { 165 | const component: CmsBannerComponent = { 166 | typeCode: 'SimpleResponsiveBannerComponent', 167 | media: {}, 168 | }; 169 | 170 | mockComponent.fields = { ...initialMockComponentFields, media: mockMedia }; 171 | 172 | normalizer.convert(mockComponent as Entry, component); 173 | expect(component.media).toEqual({ 174 | altText: '', 175 | code: '', 176 | mime: 'image/jpeg', 177 | url: '//images.ctfassets.net/spaceId/2I0Vb1eknVwnABIEZBzmKq/05205806d60e4eab3df932e39319acc8/Powertools-1400x480-BigSplash-EN-01-1400W.jpg', 178 | }); 179 | }); 180 | 181 | it('should normalize media without file', () => { 182 | const component: CmsBannerComponent = { 183 | typeCode: 'SimpleResponsiveBannerComponent', 184 | media: {}, 185 | }; 186 | 187 | mockComponent.fields = { ...initialMockComponentFields, media: mockMediaWithoutFile }; 188 | 189 | normalizer.convert(mockComponent as Entry, component); 190 | expect(component.media).toEqual({ 191 | altText: '', 192 | code: '', 193 | mime: '', 194 | url: '', 195 | }); 196 | }); 197 | 198 | it('should not normalize non-asset media property', () => { 199 | const component: CmsBannerComponent = { 200 | typeCode: 'SimpleResponsiveBannerComponent', 201 | media: {}, 202 | }; 203 | 204 | mockComponent.fields = { ...initialMockComponentFields, media: mockProducts }; 205 | 206 | normalizer.convert(mockComponent as Entry, component); 207 | expect(component.media).toEqual({}); 208 | }); 209 | 210 | it('should normalize media container', () => { 211 | const component: CmsBannerComponent = { 212 | typeCode: 'SimpleResponsiveBannerComponent', 213 | media: {}, 214 | }; 215 | mockComponent.fields = { ...initialMockComponentFields, media: mockMedia, mediaContainer: mockMediaContainer }; 216 | 217 | normalizer.convert(mockComponent as Entry, component); 218 | 219 | expect(component.media).toEqual({ 220 | desktop: { 221 | altText: '', 222 | code: '', 223 | mime: 'image/jpeg', 224 | url: '//images.ctfassets.net/iuusg1rrhk56/7LMGJdyn2gwTeJcQ0cI4aX/5e0af86064bb70cf04892ef58fb6bf55/Powertools_960x400_BigSplash_EN_01_960W.jpg', 225 | }, 226 | mobile: { 227 | altText: '', 228 | code: '', 229 | mime: 'image/jpeg', 230 | url: '//images.ctfassets.net/iuusg1rrhk56/5J4I6oBiine9HXDXPUcKsA/ed75757c741f9c9e378db9327e577f95/Powertools_320x300_BigSplash_EN_01_320W.jpg', 231 | }, 232 | tablet: { 233 | altText: '', 234 | code: '', 235 | mime: 'image/jpeg', 236 | url: '//images.ctfassets.net/iuusg1rrhk56/3oj9q97TcWkYcNZnZNpn8B/fb2608c01f7e27b22b62e0855f21ff49/Powertools_770x350_BigSplash_EN_01_770W.jpg', 237 | }, 238 | widescreen: { 239 | altText: '', 240 | code: '', 241 | mime: 'image/jpeg', 242 | url: '//images.ctfassets.net/iuusg1rrhk56/6SQPtppPW3JhyI41IFrilV/5d3345ea81b9bcbb50f95f543e1190d2/Powertools_1400x480_BigSplash_EN_01_1400W.jpg', 243 | }, 244 | }); 245 | }); 246 | 247 | it('should not normalize non-asset media container property', () => { 248 | const component: CmsBannerComponent = { 249 | typeCode: 'SimpleResponsiveBannerComponent', 250 | media: {}, 251 | }; 252 | 253 | const mediaContainer = { ...mockMediaContainer, fields: { ...initialMockMediaContainerFields, desktop: mockProducts } }; 254 | 255 | mockComponent.fields = { ...initialMockComponentFields, mediaContainer: mediaContainer }; 256 | normalizer.convert(mockComponent as Entry, component); 257 | 258 | expect((component.media as CmsResponsiveBannerComponentMedia).desktop).toEqual(undefined); 259 | }); 260 | }); 261 | -------------------------------------------------------------------------------- /src/app/contentful/cms/adapters/converters/contentful-cms-page-normalizer.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | 3 | import { 4 | CMS_COMPONENT_NORMALIZER, 5 | CMS_FLEX_COMPONENT_TYPE, 6 | CmsComponent, 7 | CmsStructureModel, 8 | ContentSlotComponentData, 9 | ContentSlotData, 10 | Converter, 11 | ConverterService, 12 | Page, 13 | PageRobotsMeta, 14 | } from '@spartacus/core'; 15 | import { BREAKPOINT, LayoutConfig, LayoutSlotConfig, SlotConfig, SlotGroup } from '@spartacus/storefront'; 16 | 17 | import { Entry } from 'contentful'; 18 | 19 | import { ComponentSkeleton, FooterSkeleton, HeaderSkeleton, PageSkeleton } from '../../../core/content-types'; 20 | import { RestrictionsService } from '../../../core/services/contentful-restrictions.service'; 21 | import { isEntry, isResolvedEntry, isSlotConfig, isSlotGroup } from '../../../core/type-guards'; 22 | 23 | type EntryWithContentSlots = Entry | Entry | Entry; 24 | 25 | @Injectable({ providedIn: 'root' }) 26 | export class ContentfulCmsPageNormalizer implements Converter, CmsStructureModel> { 27 | constructor( 28 | private readonly config: LayoutConfig, 29 | private readonly restrictionsService: RestrictionsService, 30 | private readonly converter: ConverterService, 31 | ) {} 32 | 33 | convert(source: Entry, target: CmsStructureModel = {}): CmsStructureModel { 34 | this.normalizePageData(source, target); 35 | this.normalizeEntryData(source, [source.fields.template], target, true); 36 | 37 | if (isResolvedEntry(source.fields.header)) { 38 | this.normalizeEntryData(source.fields.header, ['header', 'navigation'], target); 39 | } 40 | 41 | if (isResolvedEntry(source.fields.footer)) { 42 | this.normalizeEntryData(source.fields.footer, ['footer'], target); 43 | } 44 | return target; 45 | } 46 | 47 | /** 48 | * Converts the Contentful CMS Page model to the `Page` in the `CmsStructureModel`. 49 | */ 50 | protected normalizePageData(source: Entry, target: CmsStructureModel): void { 51 | const page: Page = {}; 52 | 53 | if (source.fields.internalName) { 54 | page.name = source.fields.internalName; 55 | } 56 | if (source.fields.slug) { 57 | page.label = `/${source.fields.slug}`; 58 | } 59 | if (source.fields.template) { 60 | page.template = source.fields.template; 61 | } 62 | if (source.sys.id) { 63 | page.pageId = source.sys.id; 64 | } 65 | if (source.fields.title) { 66 | page.title = source.fields.title; 67 | } 68 | if (source.fields.description) { 69 | page.description = source.fields.description; 70 | } 71 | if (source.fields.type) { 72 | page.type = source.fields.type; 73 | } 74 | 75 | this.normalizeRobots(source, page); 76 | 77 | target.page = page; 78 | } 79 | 80 | /** 81 | * Takes an Entry with fields that are named after Slots in corresponding Templates and maps it to the: 82 | * 1. Page-level slot data 83 | * 2. Slot-level component data 84 | * 3. Cms structure-level component data 85 | */ 86 | protected normalizeEntryData(source: EntryWithContentSlots, templateNames: string[], target: CmsStructureModel, hasBottomHeaderSlot: boolean = false) { 87 | const entrySlotComponents = this.getSlotComponentsFromEntry(source, templateNames, hasBottomHeaderSlot); 88 | this.normalizeEntrySlotData(Array.from(entrySlotComponents.keys()), target); 89 | this.normalizeEntryComponentData(entrySlotComponents, target); 90 | this.normalizeComponentData(entrySlotComponents, target); 91 | } 92 | 93 | /** 94 | * Takes an Entry with fields that are named after Slots in corresponding Templates 95 | * and returns a map of component arrays that are contained within the Entry fields 96 | */ 97 | protected getSlotComponentsFromEntry( 98 | source: EntryWithContentSlots, 99 | templateNames: string[], 100 | hasBottomHeaderSlot?: boolean, 101 | ): Map[]> { 102 | const templateSlotNames = this.getSlotNamesFromConfiguration(templateNames); 103 | templateSlotNames.push('HeaderLinks'); 104 | if (hasBottomHeaderSlot) { 105 | templateSlotNames.push('BottomHeaderSlot'); 106 | } 107 | const entryComponents = new Map[]>(); 108 | templateSlotNames 109 | .filter((slotName) => slotName in source.fields) 110 | .forEach((slotName) => entryComponents.set(slotName, this.getComponents(source, slotName))); 111 | return entryComponents; 112 | } 113 | 114 | /** 115 | * Takes an array of Slot names and creates empty slots within target Page 116 | */ 117 | protected normalizeEntrySlotData(slotNames: string[], target: CmsStructureModel): void { 118 | target.page = target.page ?? {}; 119 | target.page.slots = target.page.slots ?? {}; 120 | for (const slot of slotNames) { 121 | target.page.slots[slot] = {} as ContentSlotData; 122 | } 123 | } 124 | 125 | /** 126 | * Takes map of slot names and respective component arrays 127 | * and creates basic component data that gets pushed into page's conten slots 128 | */ 129 | protected normalizeEntryComponentData(entrySlotComponents: Map[]>, target: CmsStructureModel): void { 130 | entrySlotComponents.forEach((components, slotName) => { 131 | components.filter(isResolvedEntry).forEach((component) => { 132 | const targetComponent: ContentSlotComponentData = { 133 | uid: component.sys.id, 134 | typeCode: component.sys.contentType.sys.id, 135 | flexType: this.getFlexTypeFromComponent(component), 136 | properties: { 137 | data: component, 138 | }, 139 | }; 140 | 141 | const targetSlot = target.page?.slots?.[slotName]; 142 | if (targetSlot) { 143 | targetSlot.components ??= []; 144 | targetSlot.components.push(targetComponent); 145 | } 146 | }); 147 | }); 148 | } 149 | 150 | /** 151 | * Takes map of slot names and respective component arrays 152 | * and creates advanced component data that gets pushed into resulting 153 | * CMS structure object 154 | */ 155 | protected normalizeComponentData(entrySlotComponents: Map[]>, target: CmsStructureModel): void { 156 | entrySlotComponents.forEach((components) => { 157 | components.filter(isResolvedEntry).forEach((sourceComponent) => { 158 | target.components ??= []; 159 | 160 | const newComponent: CmsComponent = this.converter.convert, CmsComponent>( 161 | sourceComponent, 162 | CMS_COMPONENT_NORMALIZER, 163 | ); 164 | 165 | target.components.push(newComponent); 166 | }); 167 | }); 168 | } 169 | 170 | /** 171 | * Returns a list of non-repeating content slot names used in the templates provided 172 | */ 173 | protected getSlotNamesFromConfiguration(templateNames: string[]): string[] { 174 | const slotNames = new Set(); 175 | for (const templateName of templateNames) { 176 | const layoutConfiguration = this.config.layoutSlots?.[templateName]; 177 | const templateSlotNames = this.extractSlotNames(layoutConfiguration); 178 | templateSlotNames.forEach((slotName) => slotNames.add(slotName)); 179 | } 180 | return Array.from(slotNames); 181 | } 182 | 183 | /** 184 | * Returns a list of content slot names used in the layout configuration provided 185 | */ 186 | protected extractSlotNames(layoutConfiguration: LayoutSlotConfig | SlotConfig | SlotGroup | undefined): string[] { 187 | const slotNames: string[] = []; 188 | if (isSlotConfig(layoutConfiguration)) { 189 | slotNames.push(...(layoutConfiguration.slots ?? [])); 190 | } 191 | if (isSlotGroup(layoutConfiguration)) { 192 | Object.values(BREAKPOINT).forEach((value) => { 193 | if (value !== BREAKPOINT.xl) { 194 | slotNames.push(...this.extractSlotNames(layoutConfiguration[value])); 195 | } 196 | }); 197 | } 198 | return slotNames; 199 | } 200 | 201 | /** 202 | * Returns an array of components from an entry field 203 | */ 204 | protected getComponents(entry: Entry, field: string): Entry[] { 205 | const components = entry.fields[field] as unknown; 206 | 207 | if (!Array.isArray(components)) { 208 | return []; 209 | } 210 | 211 | return components.filter((component) => isEntry(component) && this.restrictionsService.isEntryAccessible(component)); 212 | } 213 | 214 | /** 215 | * Returns the flex type based on the configuration of component properties 216 | */ 217 | protected getFlexTypeFromComponent(component: Entry): string { 218 | if (component.sys.contentType.sys.id === CMS_FLEX_COMPONENT_TYPE) { 219 | return component.fields['flexType']; 220 | } 221 | return component.sys.contentType.sys.id; 222 | } 223 | 224 | /** 225 | * Normalizes the page robot string to an array of `PageRobotsMeta` items. 226 | */ 227 | protected normalizeRobots(source: Entry, target: Page): void { 228 | const robots = []; 229 | if (source.fields.robots) { 230 | switch (source.fields.robots) { 231 | case 'index, follow': 232 | robots.push(PageRobotsMeta.INDEX); 233 | robots.push(PageRobotsMeta.FOLLOW); 234 | break; 235 | case 'noindex': 236 | robots.push(PageRobotsMeta.NOINDEX); 237 | robots.push(PageRobotsMeta.FOLLOW); 238 | break; 239 | case 'nofollow': 240 | robots.push(PageRobotsMeta.INDEX); 241 | robots.push(PageRobotsMeta.NOFOLLOW); 242 | break; 243 | case 'noindex, nofollow': 244 | robots.push(PageRobotsMeta.NOINDEX); 245 | robots.push(PageRobotsMeta.NOFOLLOW); 246 | break; 247 | } 248 | } 249 | 250 | target.robots = robots; 251 | } 252 | } 253 | -------------------------------------------------------------------------------- /src/app/spartacus/spartacus-features.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | 3 | import { 4 | AnonymousConsentsModule, 5 | AuthModule, 6 | CostCenterOccModule, 7 | ExternalRoutesModule, 8 | ProductModule, 9 | ProductOccModule, 10 | UserModule, 11 | UserOccModule, 12 | provideFeatureToggles, 13 | } from '@spartacus/core'; 14 | import { 15 | AnonymousConsentManagementBannerModule, 16 | AnonymousConsentsDialogModule, 17 | BannerCarouselModule, 18 | BannerModule, 19 | BreadcrumbModule, 20 | CategoryNavigationModule, 21 | CmsParagraphModule, 22 | ConsentManagementModule, 23 | FooterNavigationModule, 24 | HamburgerMenuModule, 25 | HomePageEventModule, 26 | LinkModule, 27 | LoginRouteModule, 28 | LogoutModule, 29 | MyAccountV2Module, 30 | MyCouponsModule, 31 | MyInterestsModule, 32 | NavigationEventModule, 33 | NavigationModule, 34 | NotificationPreferenceModule, 35 | PDFModule, 36 | PageTitleModule, 37 | PaymentMethodsModule, 38 | ProductCarouselModule, 39 | ProductDetailsPageModule, 40 | ProductFacetNavigationModule, 41 | ProductImagesModule, 42 | ProductIntroModule, 43 | ProductListModule, 44 | ProductListingPageModule, 45 | ProductPageEventModule, 46 | ProductReferencesModule, 47 | ProductSummaryModule, 48 | ProductTabsModule, 49 | ScrollToTopModule, 50 | SearchBoxModule, 51 | SiteContextSelectorModule, 52 | SiteThemeSwitcherModule, 53 | StockNotificationModule, 54 | TabParagraphContainerModule, 55 | VideoModule, 56 | } from '@spartacus/storefront'; 57 | 58 | import { AsmCustomer360FeatureModule } from './features/asm/asm-customer360-feature.module'; 59 | import { AsmFeatureModule } from './features/asm/asm-feature.module'; 60 | import { CartBaseFeatureModule } from './features/cart/cart-base-feature.module'; 61 | import { CartImportExportFeatureModule } from './features/cart/cart-import-export-feature.module'; 62 | import { CartQuickOrderFeatureModule } from './features/cart/cart-quick-order-feature.module'; 63 | import { CartSavedCartFeatureModule } from './features/cart/cart-saved-cart-feature.module'; 64 | import { WishListFeatureModule } from './features/cart/wish-list-feature.module'; 65 | import { CheckoutFeatureModule } from './features/checkout/checkout-feature.module'; 66 | import { ContentfulCMSFeatureModule } from './features/contentful/contentful-cms-feature.module'; 67 | import { CustomerTicketingFeatureModule } from './features/customer-ticketing/customer-ticketing-feature.module'; 68 | import { OrderFeatureModule } from './features/order/order-feature.module'; 69 | import { OrganizationAccountSummaryFeatureModule } from './features/organization/organization-account-summary-feature.module'; 70 | import { OrganizationAdministrationFeatureModule } from './features/organization/organization-administration-feature.module'; 71 | import { OrganizationOrderApprovalFeatureModule } from './features/organization/organization-order-approval-feature.module'; 72 | import { OrganizationUnitOrderFeatureModule } from './features/organization/organization-unit-order-feature.module'; 73 | import { OrganizationUserRegistrationFeatureModule } from './features/organization/organization-user-registration-feature.module'; 74 | import { ProductImageZoomFeatureModule } from './features/product/product-image-zoom-feature.module'; 75 | import { ProductVariantsFeatureModule } from './features/product/product-variants-feature.module'; 76 | import { QuoteFeatureModule } from './features/quote/quote-feature.module'; 77 | import { StoreFinderFeatureModule } from './features/storefinder/store-finder-feature.module'; 78 | import { PersonalizationFeatureModule } from './features/tracking/personalization-feature.module'; 79 | import { UserFeatureModule } from './features/user/user-feature.module'; 80 | 81 | @NgModule({ 82 | declarations: [], 83 | imports: [ 84 | AuthModule.forRoot(), 85 | LogoutModule, 86 | LoginRouteModule, 87 | HamburgerMenuModule, 88 | SiteContextSelectorModule, 89 | LinkModule, 90 | BannerModule, 91 | CmsParagraphModule, 92 | TabParagraphContainerModule, 93 | BannerCarouselModule, 94 | CategoryNavigationModule, 95 | NavigationModule, 96 | FooterNavigationModule, 97 | BreadcrumbModule, 98 | ScrollToTopModule, 99 | PageTitleModule, 100 | VideoModule, 101 | PDFModule, 102 | SiteThemeSwitcherModule, 103 | UserModule, 104 | UserOccModule, 105 | PaymentMethodsModule, 106 | NotificationPreferenceModule, 107 | MyInterestsModule, 108 | MyAccountV2Module, 109 | StockNotificationModule, 110 | ConsentManagementModule, 111 | MyCouponsModule, 112 | AnonymousConsentsModule.forRoot(), 113 | AnonymousConsentsDialogModule, 114 | AnonymousConsentManagementBannerModule, 115 | ProductModule.forRoot(), 116 | ProductOccModule, 117 | ProductDetailsPageModule, 118 | ProductListingPageModule, 119 | ProductListModule, 120 | SearchBoxModule, 121 | ProductFacetNavigationModule, 122 | ProductTabsModule, 123 | ProductCarouselModule, 124 | ProductReferencesModule, 125 | ProductImagesModule, 126 | ProductSummaryModule, 127 | ProductIntroModule, 128 | CostCenterOccModule, 129 | NavigationEventModule, 130 | HomePageEventModule, 131 | ProductPageEventModule, 132 | ExternalRoutesModule.forRoot(), 133 | UserFeatureModule, 134 | CartBaseFeatureModule, 135 | CartSavedCartFeatureModule, 136 | WishListFeatureModule, 137 | CartQuickOrderFeatureModule, 138 | CartImportExportFeatureModule, 139 | OrderFeatureModule, 140 | CheckoutFeatureModule, 141 | PersonalizationFeatureModule, 142 | StoreFinderFeatureModule, 143 | AsmFeatureModule, 144 | AsmCustomer360FeatureModule, 145 | ProductVariantsFeatureModule, 146 | ProductImageZoomFeatureModule, 147 | ContentfulCMSFeatureModule, 148 | OrganizationAdministrationFeatureModule, 149 | OrganizationOrderApprovalFeatureModule, 150 | OrganizationUserRegistrationFeatureModule, 151 | OrganizationUnitOrderFeatureModule, 152 | OrganizationAccountSummaryFeatureModule, 153 | QuoteFeatureModule, 154 | CustomerTicketingFeatureModule, 155 | ], 156 | providers: [ 157 | provideFeatureToggles({ 158 | showDeliveryOptionsTranslation: true, 159 | // formErrorsDescriptiveMessages: true, 160 | // showSearchingCustomerByOrderInASM: true, 161 | // showStyleChangesInASM: true, 162 | // shouldHideAddToCartForUnpurchasableProducts: true, 163 | // useExtractedBillingAddressComponent: true, 164 | // showBillingAddressInDigitalPayments: true, 165 | // showDownloadProposalButton: true, 166 | searchBoxV2: true, 167 | trendingSearches: true, 168 | // pdfInvoicesSortByInvoiceDate: true, 169 | useProductCarouselBatchApi: true, 170 | // productConfiguratorAttributeTypesV2: true, 171 | propagateErrorsToServer: true, 172 | ssrStrictErrorHandlingForHttpAndNgrx: true, 173 | productConfiguratorDeltaRendering: true, 174 | // a11yRequiredAsterisks: true, 175 | // a11yQuantityOrderTabbing: true, 176 | // a11yNavigationUiKeyboardControls: true, 177 | a11yNavMenuExpandStateReadout: true, 178 | // a11yOrderConfirmationHeadingOrder: true, 179 | // a11yStarRating: true, 180 | // a11yViewChangeAssistiveMessage: true, 181 | a11yPreventHorizontalScroll: true, 182 | // a11yReorderDialog: true, 183 | // a11yPopoverFocus: true, 184 | // a11yScheduleReplenishment: true, 185 | // a11yScrollToTop: true, 186 | // a11ySavedCartsZoom: true, 187 | // a11ySortingOptionsTruncation: true, 188 | // a11yExpandedFocusIndicator: true, 189 | // a11yCheckoutDeliveryFocus: true, 190 | // a11yMobileVisibleFocus: true, 191 | // a11yOrganizationsBanner: true, 192 | // a11yOrganizationListHeadingOrder: true, 193 | a11yCartImportConfirmationMessage: true, 194 | // a11yReplenishmentOrderFieldset: true, 195 | // a11yListOversizedFocus: true, 196 | // a11yStoreFinderOverflow: true, 197 | a11yMobileFocusOnFirstNavigationItem: true, 198 | // a11yCartSummaryHeadingOrder: true, 199 | // a11ySearchBoxMobileFocus: true, 200 | // a11yFacetKeyboardNavigation: true, 201 | // a11yUnitsListKeyboardControls: true, 202 | // a11yCartItemsLinksStyles: true, 203 | a11ySearchboxLabel: true, 204 | // a11yHideSelectBtnForSelectedAddrOrPayment: true, 205 | // a11yFocusableCarouselControls: true, 206 | a11yUseTrapTabInsteadOfTrapInDialogs: true, 207 | // cmsGuardsServiceUseGuardsComposer: true, 208 | // cartQuickOrderRemoveListeningToFailEvent: true, 209 | a11yKeyboardAccessibleZoom: true, 210 | // a11yOrganizationLinkableCells: true, 211 | // a11yVisibleFocusOverflows: true, 212 | // a11yTruncatedTextForResponsiveView: true, 213 | // a11ySemanticPaginationLabel: true, 214 | a11yPreventCartItemsFormRedundantRecreation: true, 215 | // a11yPreventSRFocusOnHiddenElements: true, 216 | // a11yMyAccountLinkOutline: true, 217 | // a11yCloseProductImageBtnFocus: true, 218 | // a11yNotificationPreferenceFieldset: true, 219 | // a11yImproveContrast: true, 220 | // a11yEmptyWishlistHeading: true, 221 | // a11yScreenReaderBloatFix: true, 222 | // a11yUseButtonsForBtnLinks: true, 223 | a11yTabComponent: true, 224 | a11yCarouselArrowKeysNavigation: true, 225 | a11yPickupOptionsTabs: true, 226 | // a11yNotificationsOnConsentChange: true, 227 | // a11yDisabledCouponAndQuickOrderActionButtonsInsteadOfRequiredFields: true, 228 | // a11yFacetsDialogFocusHandling: true, 229 | headerLayoutForSmallerViewports: true, 230 | // a11yStoreFinderAlerts: true, 231 | // a11yFormErrorMuteIcon: true, 232 | // a11yCxMessageFocus: true, 233 | a11yLinkBtnsToTertiaryBtns: true, 234 | a11yRepeatedPageTitleFix: true, 235 | // a11yDeliveryModeRadiogroup: true, 236 | a11yNgSelectOptionsCount: true, 237 | a11yRepeatedCancelOrderError: true, 238 | a11yAddedToCartActiveDialog: true, 239 | // a11yNgSelectMobileReadout: true, 240 | a11yDeliveryMethodFieldset: true, 241 | a11yShowMoreReviewsBtnFocus: true, 242 | a11yQuickOrderAriaControls: true, 243 | a11yRemoveStatusLoadedRole: true, 244 | a11yDialogsHeading: true, 245 | a11yDialogTriggerRefocus: true, 246 | a11yAddToWishlistFocus: true, 247 | a11ySearchBoxFocusOnEscape: true, 248 | a11yUpdatingCartNoNarration: true, 249 | a11yPasswordVisibliltyBtnValueOverflow: true, 250 | a11yItemCounterFocus: true, 251 | a11yScrollToReviewByShowReview: true, 252 | a11yViewHoursButtonIconContrast: true, 253 | a11yCheckoutStepsLandmarks: true, 254 | a11yQTY2Quantity: true, 255 | a11yDeleteButton2First: true, 256 | // occCartNameAndDescriptionInHttpRequestBody: true, 257 | // cmsBottomHeaderSlotUsingFlexStyles: true, 258 | useSiteThemeService: true, 259 | enableConsecutiveCharactersPasswordRequirement: true, 260 | enablePasswordsCannotMatchInPasswordUpdateForm: true, 261 | allPageMetaResolversEnabledInCsr: true, 262 | useExtendedMediaComponentConfiguration: true, 263 | enableCarouselCategoryProducts: true, 264 | enableClaimCustomerCouponWithCodeInRequestBody: true, 265 | opfEnablePreventingFromCheckoutWithoutEmail: true, 266 | enableReadDomainValuesOnDemand: true, 267 | storeFinderFacadeCleanup: true, 268 | defaultProductPageRouteAllowsNoProductName: true, 269 | reserveHorizontalSpaceStarRating: true, 270 | topProgressBarUseTransformAnimation: true, 271 | readMoreDirective: true, 272 | productReviewCharactersLeft: true, 273 | consistentSizeProductCards: true, 274 | disableCxPageSlotMarginAnimation: true, 275 | productCarouselScrolling: true, 276 | createMediaPreconnectLink: true, 277 | unifiedDefaultHeaderSlotsAcrossBreakpoints: true, 278 | reserveSpaceForImagesOnPdpAndPlp: true, 279 | lazyLoadImagesByDefault: true, 280 | defaultLayoutConfigWithoutPageFold: true, 281 | }), 282 | ], 283 | }) 284 | export class SpartacusFeaturesModule {} 285 | -------------------------------------------------------------------------------- /src/app/contentful/cms/adapters/converters/contentful-cms-page-normalizer.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { CmsComponent, CmsStructureModel, Converter, ConverterService, PageRobotsMeta } from '@spartacus/core'; 4 | import { LayoutConfig } from '@spartacus/storefront'; 5 | 6 | import { Entry } from 'contentful'; 7 | 8 | import { ComponentSkeleton, FooterSkeleton, HeaderSkeleton, PageSkeleton } from '../../../core/content-types'; 9 | import { DeepPartial } from '../../../core/helpers'; 10 | import { RestrictionsService } from '../../../core/services/contentful-restrictions.service'; 11 | import { ContentfulCmsPageNormalizer } from './contentful-cms-page-normalizer'; 12 | 13 | const mockSiteLogoComponent: DeepPartial> = { 14 | 'sys': { 15 | 'id': 'siteLogo', 16 | 'type': 'Entry', 17 | 'contentType': { 18 | 'sys': { 19 | 'type': 'Link', 20 | 'linkType': 'ContentType', 21 | 'id': 'SimpleResponsiveBannerComponent', 22 | }, 23 | }, 24 | }, 25 | 'fields': { 26 | 'name': 'Site Logo', 27 | 'media': { 28 | 'sys': { 29 | 'id': 'logoAsset', 30 | 'type': 'Asset', 31 | }, 32 | 'fields': { 33 | 'title': 'SAP_scrn_R.png', 34 | 'description': 'SAP logo', 35 | 'file': { 36 | 'url': '//images.ctfassets.net/iuusg1rrhk56/7zshSvLWmN9YVXzbVjLjtl/688d76cd436e7a52c71641faeba2eb5a/SAP-scrn-R.png', 37 | 'details': { 38 | 'size': 10127, 39 | 'image': { 40 | 'width': 300, 41 | 'height': 149, 42 | }, 43 | }, 44 | 'fileName': 'SAP-scrn-R.png', 45 | 'contentType': 'image/png', 46 | }, 47 | }, 48 | }, 49 | 'urlLink': '/', 50 | }, 51 | }; 52 | 53 | const mockMiniCartComponent: DeepPartial> = { 54 | 'sys': { 55 | 'id': 'miniCart', 56 | 'type': 'Entry', 57 | 'contentType': { 58 | 'sys': { 59 | 'type': 'Link', 60 | 'linkType': 'ContentType', 61 | 'id': 'MiniCartComponent', 62 | }, 63 | }, 64 | }, 65 | 'fields': { 66 | 'name': 'Mini Cart', 67 | 'shownProductCount': 3, 68 | 'title': 'YOUR SHOPPING CART', 69 | 'totalDisplay': 'SUBTOTAL', 70 | }, 71 | }; 72 | 73 | const mockHeader: DeepPartial> = { 74 | 'sys': { 75 | 'id': 'header', 76 | 'type': 'Entry', 77 | 'contentType': { 78 | 'sys': { 79 | 'type': 'Link', 80 | 'linkType': 'ContentType', 81 | 'id': 'cmsHeader', 82 | }, 83 | }, 84 | }, 85 | 'fields': { 86 | 'name': 'Powertools Store Default Header', 87 | 'SiteLogo': [mockSiteLogoComponent as Entry], 88 | 'MiniCart': [mockMiniCartComponent as Entry], 89 | }, 90 | }; 91 | 92 | const mockFooterNavigationComponent: DeepPartial> = { 93 | 'sys': { 94 | 'id': 'footerNavigation', 95 | 'type': 'Entry', 96 | 'contentType': { 97 | 'sys': { 98 | 'type': 'Link', 99 | 'linkType': 'ContentType', 100 | 'id': 'FooterNavigationComponent', 101 | }, 102 | }, 103 | }, 104 | 'fields': { 105 | 'name': 'Footer Navigation Component', 106 | 'notice': 'Copyright © 2024 SAP SE or an SAP affiliate company. All rights reserved.', 107 | 'navigationNode': { 108 | 'sys': { 109 | 'id': 'footerNavNode', 110 | 'type': 'Entry', 111 | 'contentType': { 112 | 'sys': { 113 | 'type': 'Link', 114 | 'linkType': 'ContentType', 115 | 'id': 'NavNode', 116 | }, 117 | }, 118 | }, 119 | 'fields': { 120 | 'uid': 'FooterNavNode', 121 | 'children': [], 122 | }, 123 | }, 124 | 'wrapAfter': 4, 125 | 'showLanguageCurrency': true, 126 | }, 127 | }; 128 | 129 | const mockAnonymousConsentOpenDialogComponent: DeepPartial> = { 130 | 'sys': { 131 | 'id': 'anonymousConsentOpenDialog', 132 | 'type': 'Entry', 133 | 'contentType': { 134 | 'sys': { 135 | 'type': 'Link', 136 | 'linkType': 'ContentType', 137 | 'id': 'CMSFlexComponent', 138 | }, 139 | }, 140 | }, 141 | 'fields': { 142 | 'name': 'Anonymous Consent Management Open Dialog', 143 | 'flexType': 'AnonymousConsentOpenDialogComponent', 144 | }, 145 | }; 146 | 147 | const mockFooter: DeepPartial> = { 148 | 'sys': { 149 | 'id': 'footer', 150 | 'type': 'Entry', 151 | 'contentType': { 152 | 'sys': { 153 | 'type': 'Link', 154 | 'linkType': 'ContentType', 155 | 'id': 'cmsFooter', 156 | }, 157 | }, 158 | }, 159 | 'fields': { 160 | 'name': 'Powertools Store Default Footer', 161 | 'Footer': [ 162 | mockFooterNavigationComponent as Entry, 163 | mockAnonymousConsentOpenDialogComponent as Entry, 164 | ], 165 | }, 166 | }; 167 | 168 | const mockSection1Component1: DeepPartial> = { 169 | 'sys': { 170 | 'id': 'section1Component1', 171 | 'type': 'Entry', 172 | 'contentType': { 173 | 'sys': { 174 | 'type': 'Link', 175 | 'linkType': 'ContentType', 176 | 'id': 'SimpleResponsiveBannerComponent', 177 | }, 178 | }, 179 | }, 180 | 'fields': { 181 | 'name': 'The Most Powerful Tools Banner', 182 | 'media': { 183 | 'sys': { 184 | 'id': '2I0Vb1eknVwnABIEZBzmKq', 185 | 'type': 'Asset', 186 | }, 187 | 'fields': { 188 | 'title': 'Powertools_1400x480_BigSplash_EN_01_1400W.jpg', 189 | 'description': 'The Most Powerful Tools in their Price Range', 190 | 'file': { 191 | 'url': '//images.ctfassets.net/iuusg1rrhk56/2I0Vb1eknVwnABIEZBzmKq/05205806d60e4eab3df932e39319acc8/Powertools-1400x480-BigSplash-EN-01-1400W.jpg', 192 | 'details': { 193 | 'size': 102485, 194 | 'image': { 195 | 'width': 1400, 196 | 'height': 384, 197 | }, 198 | }, 199 | 'fileName': 'Powertools-1400x480-BigSplash-EN-01-1400W.jpg', 200 | 'contentType': 'image/jpeg', 201 | }, 202 | }, 203 | }, 204 | 'urlLink': '/Open-Catalogue/Tools/c/1355', 205 | }, 206 | }; 207 | 208 | const mockSection1Component2: DeepPartial> = { 209 | 'sys': { 210 | 'id': 'section1Component2', 211 | 'type': 'Entry', 212 | 'contentType': { 213 | 'sys': { 214 | 'type': 'Link', 215 | 'linkType': 'ContentType', 216 | 'id': 'SimpleResponsiveBannerComponent', 217 | }, 218 | }, 219 | }, 220 | 'fields': { 221 | 'name': 'Brands Banner', 222 | 'media': { 223 | 'sys': { 224 | 'id': 'bannerAsset', 225 | 'type': 'Asset', 226 | }, 227 | 'fields': { 228 | 'title': 'Powertools_1400x70_Brands_EN_01_1400W.jpg', 229 | 'description': 'Bosch | Black & Decker | Einhall | Skil | Hitachi', 230 | 'file': { 231 | 'url': '//images.ctfassets.net/iuusg1rrhk56/30b3mP1z5VuIcj3lqAMQxR/9a65517036b8ccc334c788d87ff8b3fe/Powertools-1400x70-Brands-EN-01-1400W.jpg', 232 | 'details': { 233 | 'size': 22768, 234 | 'image': { 235 | 'width': 1400, 236 | 'height': 70, 237 | }, 238 | }, 239 | 'fileName': 'Powertools-1400x70-Brands-EN-01-1400W.jpg', 240 | 'contentType': 'image/jpeg', 241 | }, 242 | }, 243 | }, 244 | 'urlLink': '/Brands/c/brands', 245 | }, 246 | }; 247 | 248 | const mockBreadcrumbComponent: DeepPartial> = { 249 | 'sys': { 250 | 'type': 'Entry', 251 | 'id': 'breadcrumbComponent', 252 | 'contentType': { 253 | 'sys': { 254 | 'type': 'Link', 255 | 'linkType': 'ContentType', 256 | 'id': 'BreadcrumbComponent', 257 | }, 258 | }, 259 | }, 260 | 'fields': { 261 | 'name': 'Breadcrumb CMS Component', 262 | }, 263 | }; 264 | 265 | const mockPage: DeepPartial> = { 266 | 'sys': { 267 | 'id': 'page', 268 | 'type': 'Entry', 269 | 'contentType': { 270 | 'sys': { 271 | 'type': 'Link', 272 | 'linkType': 'ContentType', 273 | 'id': 'cmsPage', 274 | }, 275 | }, 276 | 'locale': 'en', 277 | }, 278 | 'fields': { 279 | 'internalName': 'Homepage', 280 | 'title': 'Homepage', 281 | 'label': 'Homepage', 282 | 'description': 'Powertools store homepage', 283 | 'robots': 'index, follow', 284 | 'slug': 'homepage', 285 | 'type': 'ContentPage', 286 | 'header': mockHeader as Entry, 287 | 'footer': mockFooter as Entry, 288 | 'template': 'LandingPage2Template', 289 | 'Section1': [mockSection1Component1 as Entry, mockSection1Component2 as Entry], 290 | 'BottomHeaderSlot': [mockBreadcrumbComponent as Entry], 291 | } as any, 292 | }; 293 | 294 | const mockLayoutConfig: LayoutConfig = { 295 | layoutSlots: { 296 | LandingPage2Template: { 297 | slots: ['Section1', 'SiteLogo', 'MiniCart', 'Footer'], 298 | }, 299 | header: { 300 | slots: ['SiteLogo', 'MiniCart'], 301 | }, 302 | footer: { 303 | slots: ['Footer'], 304 | }, 305 | }, 306 | }; 307 | 308 | describe('ContentfulCmsPageNormalizer', () => { 309 | let normalizer: ContentfulCmsPageNormalizer; 310 | let mockRestrictionsService: jasmine.SpyObj; 311 | let mockConverter: jasmine.SpyObj, CmsComponent>>; 312 | 313 | beforeEach(() => { 314 | mockRestrictionsService = jasmine.createSpyObj('RestrictionsService', ['isEntryAccessible']); 315 | mockRestrictionsService.isEntryAccessible.and.returnValue(true); 316 | 317 | mockConverter = jasmine.createSpyObj('ConverterService', ['convert']); 318 | 319 | TestBed.configureTestingModule({ 320 | providers: [ 321 | ContentfulCmsPageNormalizer, 322 | { provide: LayoutConfig, useValue: mockLayoutConfig }, 323 | { provide: RestrictionsService, useValue: mockRestrictionsService }, 324 | { provide: ConverterService, useValue: mockConverter }, 325 | ], 326 | }); 327 | normalizer = TestBed.inject(ContentfulCmsPageNormalizer); 328 | }); 329 | 330 | it('should normalize page data correctly', () => { 331 | const target: CmsStructureModel = {}; 332 | 333 | normalizer.convert(mockPage as Entry, target); 334 | 335 | expect(target.page).toBeDefined(); 336 | expect(target.page?.name).toBe('Homepage'); 337 | expect(target.page?.label).toBe('/homepage'); 338 | expect(target.page?.title).toBe('Homepage'); 339 | expect(target.page?.description).toBe('Powertools store homepage'); 340 | expect(target.page?.template).toBe('LandingPage2Template'); 341 | expect(target.page?.type).toBe('ContentPage'); 342 | expect(target.page?.pageId).toBe('page'); 343 | expect(target.page?.robots).toEqual([PageRobotsMeta.INDEX, PageRobotsMeta.FOLLOW]); 344 | }); 345 | 346 | it('should normalize entry slot data correctly', () => { 347 | const target: CmsStructureModel = {}; 348 | 349 | normalizer.convert(mockPage as Entry, target); 350 | 351 | expect(target.page?.slots?.['Section1'].components).toBeDefined(); 352 | expect(target.page?.slots?.['SiteLogo'].components).toBeDefined(); 353 | expect(target.page?.slots?.['MiniCart'].components).toBeDefined(); 354 | expect(target.page?.slots?.['Footer'].components).toBeDefined(); 355 | expect(target.page?.slots?.['BottomHeaderSlot'].components).toBeDefined(); 356 | }); 357 | 358 | it('should normalize entry component data correctly', () => { 359 | const target: CmsStructureModel = {}; 360 | 361 | normalizer.convert(mockPage as Entry, target); 362 | 363 | expect(target.page?.slots?.['Section1'].components?.length).toBe(2); 364 | expect(target.page?.slots?.['Section1'].components?.some((component) => component.uid === 'section1Component1')).toBeTrue(); 365 | 366 | const section1Component2 = target.page?.slots?.['Section1'].components?.find((component) => component.uid === 'section1Component2'); 367 | expect(section1Component2?.flexType).toBe('SimpleResponsiveBannerComponent'); 368 | expect(section1Component2?.typeCode).toBe('SimpleResponsiveBannerComponent'); 369 | expect(section1Component2?.properties?.data).toBeDefined(); 370 | expect(section1Component2?.properties?.data.sys.id).toBe('section1Component2'); 371 | 372 | expect(target.page?.slots?.['SiteLogo'].components?.length).toBe(1); 373 | expect(target.page?.slots?.['SiteLogo'].components?.some((component) => component.uid === 'siteLogo')).toBeTrue(); 374 | 375 | expect(target.page?.slots?.['MiniCart'].components?.length).toBe(1); 376 | expect(target.page?.slots?.['MiniCart'].components?.some((component) => component.uid === 'miniCart')).toBeTrue(); 377 | 378 | expect(target.page?.slots?.['Footer'].components?.length).toBe(2); 379 | expect(target.page?.slots?.['Footer'].components?.some((component) => component.uid === 'footerNavigation')).toBeTrue(); 380 | 381 | const anonymousConsentOpenDialog = target.page?.slots?.['Footer'].components?.find((component) => component.uid === 'anonymousConsentOpenDialog'); 382 | expect(anonymousConsentOpenDialog?.flexType).toBe('AnonymousConsentOpenDialogComponent'); 383 | expect(anonymousConsentOpenDialog?.typeCode).toBe('CMSFlexComponent'); 384 | expect(anonymousConsentOpenDialog?.properties?.data).toBeDefined(); 385 | expect(anonymousConsentOpenDialog?.properties?.data.sys.id).toBe('anonymousConsentOpenDialog'); 386 | }); 387 | 388 | it('should normalize component data correctly', () => { 389 | const target: CmsStructureModel = {}; 390 | 391 | normalizer.convert(mockPage as Entry, target); 392 | 393 | expect(mockConverter.convert).toHaveBeenCalledWith(mockSection1Component1 as Entry, jasmine.any(Object)); 394 | expect(mockConverter.convert).toHaveBeenCalledWith(mockSection1Component2 as Entry, jasmine.any(Object)); 395 | expect(mockConverter.convert).toHaveBeenCalledWith(mockBreadcrumbComponent as Entry, jasmine.any(Object)); 396 | expect(mockConverter.convert).toHaveBeenCalledWith(mockSiteLogoComponent as Entry, jasmine.any(Object)); 397 | expect(mockConverter.convert).toHaveBeenCalledWith(mockMiniCartComponent as Entry, jasmine.any(Object)); 398 | expect(mockConverter.convert).toHaveBeenCalledWith(mockFooterNavigationComponent as Entry, jasmine.any(Object)); 399 | expect(mockConverter.convert).toHaveBeenCalledWith( 400 | mockAnonymousConsentOpenDialogComponent as Entry, 401 | jasmine.any(Object), 402 | ); 403 | }); 404 | }); 405 | -------------------------------------------------------------------------------- /src/app/contentful/cms/adapters/converters/contentful-cms-normalizer.spec.ts: -------------------------------------------------------------------------------- 1 | import { CmsBannerComponent, CmsNavigationComponent, CmsProductCarouselComponent, CmsResponsiveBannerComponentMedia } from '@spartacus/core'; 2 | 3 | import { Asset, Entry } from 'contentful'; 4 | 5 | import { ComponentSkeleton, MediaContainerSkeleton, NavigationNodeSkeleton } from '../../../core/content-types'; 6 | import { DeepPartial } from '../../../core/helpers'; 7 | import { normalizeMedia, normalizeNavigationNode, normalizeProductCodes } from './contentful-cms-normalizers'; 8 | 9 | const mockMedia: DeepPartial> = { 10 | 'sys': { 11 | 'type': 'Asset', 12 | 'locale': 'en', 13 | }, 14 | 'fields': { 15 | 'title': 'Powertools_350x280_25Deal_EN_01_350W.jpg', 16 | 'description': '25% Great Prices and Great Deals', 17 | 'file': { 18 | 'url': '//images.ctfassets.net/spaceId/2I0Vb1eknVwnABIEZBzmKq/05205806d60e4eab3df932e39319acc8/Powertools-1400x480-BigSplash-EN-01-1400W.jpg', 19 | 'details': { 20 | 'size': 9010, 21 | 'image': { 22 | 'width': 350, 23 | 'height': 280, 24 | }, 25 | }, 26 | 'fileName': 'Powertools-350x280-25Deal-EN-01-350W.jpg', 27 | 'contentType': 'image/jpeg', 28 | }, 29 | }, 30 | }; 31 | 32 | const mockMediaWithoutFile: DeepPartial> = { 33 | 'sys': { 34 | 'type': 'Asset', 35 | 'locale': 'en', 36 | }, 37 | 'fields': { 38 | 'title': 'Powertools_350x280_25Deal_EN_01_350W.jpg', 39 | 'description': '25% Great Prices and Great Deals', 40 | }, 41 | }; 42 | 43 | const initialMockMediaContainerFields: DeepPartial['fields']> = { 44 | 'name': 'Powertools Homepage Splash', 45 | 'desktop': { 46 | 'sys': { 47 | 'id': 'mockMediaDesktopId', 48 | 'type': 'Asset', 49 | }, 50 | 'fields': { 51 | 'title': 'Powertools Homepage Splash Desktop', 52 | 'description': '', 53 | 'file': { 54 | 'url': '//images.ctfassets.net/iuusg1rrhk56/7LMGJdyn2gwTeJcQ0cI4aX/5e0af86064bb70cf04892ef58fb6bf55/Powertools_960x400_BigSplash_EN_01_960W.jpg', 55 | 'fileName': 'Powertools_960x400_BigSplash_EN_01_960W.jpg', 56 | 'contentType': 'image/jpeg', 57 | }, 58 | }, 59 | }, 60 | 'mobile': { 61 | 'sys': { 62 | 'id': 'mockMediaMobileId', 63 | 'type': 'Asset', 64 | }, 65 | 'fields': { 66 | 'title': 'Powertools Homepage Splash Mobile', 67 | 'description': '', 68 | 'file': { 69 | 'url': '//images.ctfassets.net/iuusg1rrhk56/5J4I6oBiine9HXDXPUcKsA/ed75757c741f9c9e378db9327e577f95/Powertools_320x300_BigSplash_EN_01_320W.jpg', 70 | 'contentType': 'image/jpeg', 71 | }, 72 | }, 73 | }, 74 | 'tablet': { 75 | 'sys': { 76 | 'id': 'mockMediaTabletId', 77 | 'type': 'Asset', 78 | }, 79 | 'fields': { 80 | 'title': 'Powertools Homepage Splash Tablet', 81 | 'description': '', 82 | 'file': { 83 | 'url': '//images.ctfassets.net/iuusg1rrhk56/3oj9q97TcWkYcNZnZNpn8B/fb2608c01f7e27b22b62e0855f21ff49/Powertools_770x350_BigSplash_EN_01_770W.jpg', 84 | 'contentType': 'image/jpeg', 85 | }, 86 | }, 87 | }, 88 | 'widescreen': { 89 | 'sys': { 90 | 'id': 'mockMediaWidescreenId', 91 | 'type': 'Asset', 92 | }, 93 | 'fields': { 94 | 'title': 'Powertools Homepage Splash Widescreen', 95 | 'description': '', 96 | 'file': { 97 | 'url': '//images.ctfassets.net/iuusg1rrhk56/6SQPtppPW3JhyI41IFrilV/5d3345ea81b9bcbb50f95f543e1190d2/Powertools_1400x480_BigSplash_EN_01_1400W.jpg', 98 | 'contentType': 'image/jpeg', 99 | }, 100 | }, 101 | }, 102 | }; 103 | 104 | const mockMediaContainer: DeepPartial> = { 105 | 'sys': { 106 | 'id': 'mockMediaContainerId', 107 | 'type': 'Entry', 108 | 'contentType': { 109 | 'sys': { 110 | 'type': 'Link', 111 | 'linkType': 'ContentType', 112 | 'id': 'MediaContainer', 113 | }, 114 | }, 115 | }, 116 | 'fields': { ...initialMockMediaContainerFields }, 117 | }; 118 | 119 | const mockProducts: string[] = [ 120 | 'https://api.cm77gs48zv-contentfu1-d1-public.model-t.cc.commerce.ondemand.com/occ/v2/powertools-spa/products/3755219', 121 | 'https://api.cm77gs48zv-contentfu1-d1-public.model-t.cc.commerce.ondemand.com/occ/v2/powertools-spa/products/3881018', 122 | 'https://api.cm77gs48zv-contentfu1-d1-public.model-t.cc.commerce.ondemand.com/occ/v2/powertools-spa/products/3592865', 123 | 'https://api.cm77gs48zv-contentfu1-d1-public.model-t.cc.commerce.ondemand.com/occ/v2/powertools-spa/products/2116279', 124 | 'https://api.cm77gs48zv-contentfu1-d1-public.model-t.cc.commerce.ondemand.com/occ/v2/powertools-spa/products/3755204', 125 | 'https://api.cm77gs48zv-contentfu1-d1-public.model-t.cc.commerce.ondemand.com/occ/v2/powertools-spa/products/1128762', 126 | 'https://api.cm77gs48zv-contentfu1-d1-public.model-t.cc.commerce.ondemand.com/occ/v2/powertools-spa/products/3092788', 127 | ]; 128 | 129 | const mockNavigationNode: DeepPartial> = { 130 | 'sys': { 131 | 'id': 'categoryNavNodeId', 132 | 'type': 'Entry', 133 | 'contentType': { 134 | 'sys': { 135 | 'type': 'Link', 136 | 'linkType': 'ContentType', 137 | 'id': 'NavNode', 138 | }, 139 | }, 140 | }, 141 | 'fields': { 142 | 'uid': 'PowertoolsCategoryNavNode', 143 | 'children': [ 144 | { 145 | 'sys': { 146 | 'id': 'firstLevelNavNodeId', 147 | 'type': 'Entry', 148 | 'contentType': { 149 | 'sys': { 150 | 'type': 'Link', 151 | 'linkType': 'ContentType', 152 | 'id': 'NavNode', 153 | }, 154 | }, 155 | }, 156 | 'fields': { 157 | 'uid': 'SafetyNavNode', 158 | 'children': [ 159 | { 160 | 'metadata': { 161 | 'tags': [], 162 | 'concepts': [], 163 | }, 164 | 'sys': { 165 | 'id': 'secondLevelNavNodeId', 166 | 'type': 'Entry', 167 | 'contentType': { 168 | 'sys': { 169 | 'type': 'Link', 170 | 'linkType': 'ContentType', 171 | 'id': 'NavNode', 172 | }, 173 | }, 174 | }, 175 | 'fields': { 176 | 'uid': 'FootwearLinksNavNode', 177 | 'children': [ 178 | { 179 | 'metadata': { 180 | 'tags': [], 181 | 'concepts': [], 182 | }, 183 | 'sys': { 184 | 'id': 'thirdLevelNavNodeId', 185 | 'type': 'Entry', 186 | 'contentType': { 187 | 'sys': { 188 | 'type': 'Link', 189 | 'linkType': 'ContentType', 190 | 'id': 'NavNode', 191 | }, 192 | }, 193 | }, 194 | 'fields': { 195 | 'uid': 'FootwearMensCategoryNavNode', 196 | 'entries': [ 197 | { 198 | 'sys': { 199 | 'id': 'linkEntryId', 200 | 'type': 'Entry', 201 | 'contentType': { 202 | 'sys': { 203 | 'type': 'Link', 204 | 'linkType': 'ContentType', 205 | 'id': 'CMSLinkComponent', 206 | }, 207 | }, 208 | }, 209 | 'fields': { 210 | 'name': "Footwear Men's Category Link", 211 | 'linkName': "Men's", 212 | 'url': '/Open-Catalogue/Office-Equipment%2C-Supplies-%26-Accessories/Hand-Tools/Safety/Footwear/Mens/c/1805', 213 | 'target': false, 214 | }, 215 | }, 216 | ], 217 | }, 218 | }, 219 | ], 220 | 'title': 'Footwear', 221 | }, 222 | }, 223 | ], 224 | 'entries': [ 225 | { 226 | 'metadata': { 227 | 'tags': [], 228 | 'concepts': [], 229 | }, 230 | 'sys': { 231 | 'id': 'linkEntryCategoryId', 232 | 'type': 'Entry', 233 | 'contentType': { 234 | 'sys': { 235 | 'type': 'Link', 236 | 'linkType': 'ContentType', 237 | 'id': 'CMSLinkComponent', 238 | }, 239 | }, 240 | }, 241 | 'fields': { 242 | 'name': 'Safety Category Link', 243 | 'linkName': 'Safety', 244 | 'url': '/Open-Catalogue/Office-Equipment%2C-Supplies-%26-Accessories/Hand-Tools/Safety/c/1800', 245 | 'target': false, 246 | }, 247 | }, 248 | ], 249 | 'title': 'Safety', 250 | }, 251 | }, 252 | ], 253 | }, 254 | }; 255 | 256 | const initialMockComponentFields = { 257 | 'products': mockProducts, 258 | 'navigationNode': mockNavigationNode as Entry, 259 | 'urlLink': '/Open-Catalogue/Tools/c/1355', 260 | 'name': 'Powertools Hompage Splash Banner Component', 261 | 'media': mockMedia as Asset, 262 | }; 263 | 264 | const mockComponent: DeepPartial> = { 265 | 'sys': { 266 | 'id': 'entryId', 267 | 'type': 'Entry', 268 | 'contentType': { 269 | 'sys': { 270 | 'type': 'Link', 271 | 'linkType': 'ContentType', 272 | 'id': 'SimpleResponsiveBannerComponent', 273 | }, 274 | }, 275 | }, 276 | 'fields': { ...initialMockComponentFields }, 277 | }; 278 | 279 | describe('Contentful CMS Normalizer', () => { 280 | beforeEach(() => { 281 | mockComponent.fields = { ...initialMockComponentFields }; 282 | mockMediaContainer.fields = { ...initialMockMediaContainerFields }; 283 | }); 284 | 285 | it('should normalize media', () => { 286 | const component: CmsBannerComponent = { 287 | media: {}, 288 | }; 289 | 290 | mockComponent.fields = { ...initialMockComponentFields, media: mockMedia }; 291 | 292 | normalizeMedia(mockComponent as Entry, component); 293 | expect(component.media).toEqual({ 294 | altText: '', 295 | code: '', 296 | mime: 'image/jpeg', 297 | url: '//images.ctfassets.net/spaceId/2I0Vb1eknVwnABIEZBzmKq/05205806d60e4eab3df932e39319acc8/Powertools-1400x480-BigSplash-EN-01-1400W.jpg', 298 | }); 299 | }); 300 | 301 | it('should normalize media without file', () => { 302 | const component: CmsBannerComponent = { 303 | media: {}, 304 | }; 305 | 306 | mockComponent.fields = { ...initialMockComponentFields, media: mockMediaWithoutFile }; 307 | 308 | normalizeMedia(mockComponent as Entry, component); 309 | expect(component.media).toEqual({ 310 | altText: '', 311 | code: '', 312 | mime: '', 313 | url: '', 314 | }); 315 | }); 316 | 317 | it('should not normalize non-asset media property', () => { 318 | const component: CmsBannerComponent = { 319 | media: {}, 320 | }; 321 | 322 | mockComponent.fields = { ...initialMockComponentFields, media: mockProducts }; 323 | 324 | normalizeMedia(mockComponent as Entry, component); 325 | expect(component.media).toEqual({}); 326 | }); 327 | 328 | it('should normalize media container', () => { 329 | const component: CmsBannerComponent = { 330 | media: {}, 331 | }; 332 | mockComponent.fields = { ...initialMockComponentFields, media: mockMedia, mediaContainer: mockMediaContainer }; 333 | 334 | normalizeMedia(mockComponent as Entry, component); 335 | 336 | expect(component.media).toEqual({ 337 | desktop: { 338 | altText: '', 339 | code: '', 340 | mime: 'image/jpeg', 341 | url: '//images.ctfassets.net/iuusg1rrhk56/7LMGJdyn2gwTeJcQ0cI4aX/5e0af86064bb70cf04892ef58fb6bf55/Powertools_960x400_BigSplash_EN_01_960W.jpg', 342 | }, 343 | mobile: { 344 | altText: '', 345 | code: '', 346 | mime: 'image/jpeg', 347 | url: '//images.ctfassets.net/iuusg1rrhk56/5J4I6oBiine9HXDXPUcKsA/ed75757c741f9c9e378db9327e577f95/Powertools_320x300_BigSplash_EN_01_320W.jpg', 348 | }, 349 | tablet: { 350 | altText: '', 351 | code: '', 352 | mime: 'image/jpeg', 353 | url: '//images.ctfassets.net/iuusg1rrhk56/3oj9q97TcWkYcNZnZNpn8B/fb2608c01f7e27b22b62e0855f21ff49/Powertools_770x350_BigSplash_EN_01_770W.jpg', 354 | }, 355 | widescreen: { 356 | altText: '', 357 | code: '', 358 | mime: 'image/jpeg', 359 | url: '//images.ctfassets.net/iuusg1rrhk56/6SQPtppPW3JhyI41IFrilV/5d3345ea81b9bcbb50f95f543e1190d2/Powertools_1400x480_BigSplash_EN_01_1400W.jpg', 360 | }, 361 | }); 362 | }); 363 | 364 | it('should not normalize non-asset media container property', () => { 365 | const component: CmsBannerComponent = { 366 | media: {}, 367 | }; 368 | 369 | const mediaContainer = { ...mockMediaContainer, fields: { ...initialMockMediaContainerFields, desktop: mockProducts } }; 370 | 371 | mockComponent.fields = { ...initialMockComponentFields, mediaContainer: mediaContainer }; 372 | normalizeMedia(mockComponent as Entry, component); 373 | 374 | expect((component.media as CmsResponsiveBannerComponentMedia).desktop).toEqual(undefined); 375 | }); 376 | 377 | it('should normalize product codes', () => { 378 | const component: CmsProductCarouselComponent = { 379 | productCodes: '', 380 | }; 381 | normalizeProductCodes(mockComponent as Entry, component); 382 | expect(component.productCodes).toEqual('3755219 3881018 3592865 2116279 3755204 1128762 3092788'); 383 | }); 384 | 385 | it('should normalize product codes with non-string values', () => { 386 | const component: CmsProductCarouselComponent = { 387 | productCodes: '', 388 | }; 389 | 390 | const mockComponentWithNonStringProducts = { ...mockComponent, fields: { ...initialMockComponentFields, products: [1, 2, 3, 4, 5, 6, 7] } }; 391 | 392 | normalizeProductCodes(mockComponentWithNonStringProducts as Entry, component); 393 | expect(component.productCodes).toEqual(''); 394 | }); 395 | 396 | it('should normalize navigation node', () => { 397 | const component: CmsNavigationComponent = { 398 | navigationNode: {}, 399 | }; 400 | normalizeNavigationNode(mockComponent as Entry, component); 401 | 402 | expect(component.navigationNode).toEqual({ 403 | uid: 'categoryNavNodeId', 404 | title: '', 405 | children: [ 406 | { 407 | uid: 'firstLevelNavNodeId', 408 | title: 'Safety', 409 | children: [ 410 | { 411 | uid: 'secondLevelNavNodeId', 412 | title: 'Footwear', 413 | children: [ 414 | { 415 | uid: 'thirdLevelNavNodeId', 416 | title: '', 417 | children: undefined, 418 | entries: [ 419 | { 420 | itemId: 'linkEntryId', 421 | itemSuperType: 'AbstractCMSComponent', 422 | itemType: 'CMSLinkComponent', 423 | }, 424 | ], 425 | }, 426 | ], 427 | entries: undefined, 428 | }, 429 | ], 430 | entries: [ 431 | { 432 | itemId: 'linkEntryCategoryId', 433 | itemSuperType: 'AbstractCMSComponent', 434 | itemType: 'CMSLinkComponent', 435 | }, 436 | ], 437 | }, 438 | ], 439 | entries: undefined, 440 | }); 441 | }); 442 | }); 443 | --------------------------------------------------------------------------------