Docs Hub Simo route

Vodafone SIM-Only Storefront

web-shop-simo is the Vodafone UK SIM-only online storefront. It serves consumer and business customers across multiple purchase journeys — acquisition, upgrade, second line, pay-as-you-go migration, tariff migration, and seamless customer transfer.

🛒

Multi-Journey

Supports acquisition, upgrade, secondline, P2P, tariff migration, and seamless migration flows in a single app.

👥

Consumer & Business

Shared codebase with segment branching. Components adapt based on isBusiness and segment.

🔗

HATEOAS-Driven

Backend returns hypermedia links that drive all state transitions. No hardcoded API endpoints on the client.

🚀

Micro-Frontend Ready

Federated builds via single-spa and SystemJS for shell integration within the Vodafone web ecosystem.

🏴

Feature Flag Driven

45+ LaunchDarkly flags control features, CRO experiments, and kill-switches without deploys.

📊

Full Observability

Datadog RUM + browser logs, Tealium analytics (70+ events), OneTrust consent, and session tracking.

Change Impact Quick Start

If you are trying to understand where a change belongs, start with the section that matches the type of behavior you are touching.

Tech Stack

Layer Technology Version / Notes
Runtime Node.js 22.22.0
Package Manager Yarn 1.x (Classic)
UI Framework React 18
Language TypeScript ES6 target, experimental decorators
State Management MobX With makeObservable pattern
Styling styled-components Colocated in *.style.ts
Design System Source Web (WS10) 100+ @source-web/* components
Server Express Middleware-driven architecture
Build Tool Vite 3.2.7
Transpilation Babel Legacy decorators + async transform
HTTP Client Axios 1.15.2
i18n i18next + react-i18next English via @source-web/language-packs
CMS Contentful Rich text rendering
Unit Testing Jest + RTL 70% line, 60% branch thresholds
E2E Testing Cypress BDD with Cucumber preprocessor
Feature Flags LaunchDarkly 45+ flags
Observability Datadog RUM + browser logs
Analytics Tealium 70+ tracked events
Auth IDM OAuth2 Encrypted session cookies
Formatting Prettier Width 120
Linting ESLint + Stylelint Custom rules

Getting Started

Prerequisites

Commands

# Install dependencies
yarn install

# Start dev server (INT1 environment, HTTPS, port 8000)
yarn start

# Alternative environments
yarn start:qc1       # QC1
yarn start:qc2       # QC2
yarn start:prod      # Production simulation
yarn start:prerender # With prerender enabled

# Testing
yarn test                   # Run all unit tests
yarn test MyComponent.test  # Run specific test
yarn test:coverage          # Full coverage report

# Linting
yarn lint        # ESLint + Stylelint
yarn lint:js     # ESLint only
yarn lint:styles # Stylelint only

# Build
yarn build:federated  # Micro-frontend SystemJS bundle

# QA pipeline (lint + test + coverage)
yarn analyse:cicd
💡 Tip

The dev server starts on https://localhost:8000. Routes not whitelisted in useLocalMiddlewares are proxied to the INT1 environment.

High-Level Architecture

The application follows a layered architecture with clear separation between the Express server (routing, auth, proxy) and the React client (UI, state, API calls).

Browser Request
Express Server
Middleware Chain
React SPA
React Components
MobX Stores
Services (HATEOAS)
DXL API Gateway

Key Architectural Principles

Architecture Views

This repo usually needs two explanations: one for product and delivery stakeholders who want to understand the customer journey, and one for engineers who need to understand runtime control flow.

Customer lands on a journey URL
Node server prepares the page
React loads the storefront
Backend returns plans, links, flags, notifications
MobX stores decide what to show
User moves through Plans → Extras → Basket

Journey-aware storefront

The same app serves acquisition, upgrade, second line, P2P, tariff migration, and seamless migration. The route and session determine which journey rules apply.

Backend decides eligibility

Plans, filters, notifications, extras availability, operation mode, and some error states come from backend responses, not from static frontend rules.

Frontend decides presentation

The client takes backend data and turns it into cards, banners, steps, modals, sorting options, and segment-specific content.

Server makes the app platform-safe

The Node layer adds auth, feature flags, prerendering, proxies, cookies, analytics scripts, and environment variables before the React app starts.

Layer Main Responsibility Important Inputs Representative Files
Express server Serve HTML, auth, proxy, flags, prerender, runtime env injection Cookies, env vars, route path, feature flag services src/server/production/server.ts, src/server/development/vite.server.config.ts
Bootstrap Warm auth, apply feature overrides, mount or hydrate the app window.VFUK.env, cookies, query params, prerender marker src/client/index.tsx
Page entry points Create one root store per page variant and provide it by context Current route, page type src/client/pages/Simo/Simo.tsx, src/client/pages/SeamlessMigration/SeamlessMigration.tsx
MobX root store Own journey state, child stores, and service orchestration Journey responses, session, feature flags, CMS content src/client/stores/SimoStore/
Service layer Execute HATEOAS links and explicit platform APIs Journey links, session platform ID, current path src/client/services/simOnlyService/, src/client/services/journeyService/
Templates and components Render the current step using observer components MobX observables, CMS content, flags, notifications src/client/templates/, src/client/components/

Express Server

The production server (src/server/production/server.ts) uses a carefully ordered middleware chain. Order matters — changing it can break auth, caching, or routing.

Middleware Chain (Production)

1. SeamlessMigrationRedirectMiddleware

Handles customer-transfer route redirects

2. Auth Layer

loginCallback → IDM → shopLogout → accountRedirect → useLocalMiddlewares → DXL auth

3. avoidApiCache

Prevents browser caching on /api/* routes

4. cleanRouteMiddleware

Normalizes URL paths (trailing slashes, etc.)

5. contentAPITransformer

Transforms Contentful API responses

6. shopAnonymousSessionMiddleware

Establishes anonymous sessions for unauthenticated users

7. loginRedirect

Redirects to IDM if assurance level < 3 (dual-mode: allows anon access too)

8. dxlProxyMiddleware

Proxies API calls to the AWS API Gateway (DXL). Channel: eShop-simo

9. Static Federated Assets

Serves the /federated folder for micro-frontend bundles

10. featureFlaggingMiddleware

Fetches flags from LaunchDarkly and injects into response

11. prerenderMiddleware

5-minute Redis cache for server-rendered pages. Skipped if basketId cookie present.

12. Index Routing

Serves HTML with dynamic injection: env vars, OneTrust, Tealium scripts, feature flags

Dynamic Client Injection

The server injects configuration into window.VFUK.env at runtime:

window.VFUK.env = {
  FEATURE_FLAGS: { showAddonPage: true, showMidContractRise: false, ... },
  ENVIRONMENT: 'prod1-green',
  TRACKING_ENVIRONMENT: 'prod',
  TEALIUM_REINVENT: 'https://tags.tiqcdn.com/utag/vodafone/uk-reinvent/prod',
  AUTH_COOKIE_PREFIX: 'eShop-auth',
  DATA_DOG_RUM_APPLICATION_ID: '...',
  DATA_DOG_RUM_CLIENT_TOKEN: '...',
  BUILD_NUMBER: '12345',
  ASSET_URL: 'https://cdn.vodafone.co.uk',
  CDN_DOMAIN: 'https://cdn.vodafone.co.uk',
  // ... and more
}

Why the Node Web Server Exists

Detailed Middleware Responsibilities

Ingress, redirects, and auth bootstrap

SeamlessMigrationRedirectMiddleware normalises customer-transfer entry points before the rest of the stack runs. The auth-related middleware group then handles login callback processing, IDM integration, logout, account switching, and anonymous session setup.

One subtlety: useLocalMiddlewares is mounted in development and in debug / PR-style production runs, not as a normal consumer-facing production behavior. That distinction matters when you are debugging route proxy behavior locally.

Request shaping and platform safety

avoidApiCache stops browsers caching API responses under /api/*. cleanRouteMiddleware normalises request paths. contentAPITransformer rewrites Contentful shell responses so header and footer assets match what the client expects.

API gateway and backend connectivity

dxlAuthServiceMiddleware and dxlProxyMiddleware are the bridge from the storefront to backend estate APIs. They use the eShop-simo channel, encrypt and forward cookies appropriately, and apply the whitelist in whitelist.config.ts so only approved endpoints are exposed.

Experience shaping: flags, prerender, and index injection

featureFlaggingMiddleware fetches or resolves LaunchDarkly state, prerenderMiddleware optionally returns cached or rendered HTML, and useIndexRouting emits the final document with scripts, env vars, consent tags, and CDN asset references.

Development-only server additions

The development server mounts debugPanelApiMiddleware, AIM middleware, Vite middlewares, and a simplified auth setup. That is why local behavior feels closer to a full platform environment than a bare Vite dev server would.

Proxying & Query Parameters

There are two distinct kinds of proxying in this repo. One is page-level proxying used in local and PR/debug contexts so only SIMO routes are served locally. The other is API proxying, where the Node layer forwards approved endpoints to Vodafone backends.

Page proxying: what stays local and what gets forwarded

useLocalMiddlewares serves the storefront's own routes locally and proxies everything else to an environment host such as int1-blue or qc1-blue. That lets engineers work inside the SIM-only app while still navigating a broader site shell when needed.

Request Behavior Why
/sim-only/best-sim-only-deals Served locally Main consumer SIMO route
/business/business-sim-only Served locally Main business route
/customer-transfer/best-sim-only-deals Served locally Seamless migration route
/migration/basics/test, /migration/phase5/test Served locally P2P test journeys
/web-shop/login, /web-shop/logout Served locally Auth route ownership
/en/assets Proxied to www.vodafone.co.uk Static shared assets
Anything else, for example /my-vodafone Proxied to ENVIRONMENT_LOOKUP[ENVIRONMENT] Keep the rest of the platform available during local work
API proxying: DXL gateway and whitelist rules

dxlProxyMiddleware forwards approved API routes through the eShop-simo channel to the AWS gateway defined by AWS_GATEWAY_DAL_URL, AWS_GATEWAY_DAL_API_KEY, and related host header settings.

dxlProxyConfig = {
  apiGateway: {
    defaultChannelId: 'eShop-simo',
    config: {
      'eShop-simo': {
        apiKey,
        environment,
        host,
        url,
      },
    },
  },
  timeout: 30000,
}

Representative whitelisted endpoints:

  • /content-service/v2/content for Contentful page data
  • /simo-purchase/paym/v2/*/journeys/*/plans for plan retrieval
  • /simo-purchase/paym/v2/*/journeys/*/package/extras for extras selection
  • /simo-purchase/seamless-migration/v1/*/journeys/*/plans for customer-transfer plans
  • /device-xsell-purchase/v1/addOns/{groupType}/{segment}/{journeyType} for insurance add-ons / cross-sell data
Query parameters used by the app

Parameters directly read by client or server logic

Parameter Read By Effect
features src/client/index.tsx Overrides feature flags in non-production or debug-enabled environments
debug Development server + AIM tooling Used to open the debug panel workflow locally
disableLoginPrompt SimoStore Sets the DisableLoginPrompt cookie and suppresses the login prompt banner
referrer SimoStore, journey services Passed through to journey creation and analytics context
packageId SimoStore, simOnlyService Resumes an existing package-edit journey instead of creating a fresh journey
continueShopping SimoStore Forces a fresh journey path even when the user is already authenticated
selectedPlanId PlanStore Used after P2P login to auto-select a requested plan
transactionId Seamless migration services Required to create or retrieve customer-transfer journeys
sortBy FilterStore, deep-link helpers Overrides default sort; default / recommended are normalised to an empty backend sort value
operationMode simOnlyService.setOperationMode() Controls line mode such as SINGLE or multi-line business variants

Parameters retained by prerender and URL continuity helpers

Parameter Why It Is Retained
commitmentPeriod Preserves plan-length filtering across prerendered or rehydrated page loads
segment Preserves consumer / business routing intent where needed
referrer Keeps attribution and deep-link context stable
dataAllowance Retains server-driven filter state
monthlyPrice Retains filter state for plan-price-driven journeys
entertainmentPromotion Preserves entertainment-related deep-linking and plan views
roamingInclusiveProductId Preserves roaming-related product context
salesPromotion Preserves marketing-led deep links
planId Retains plan-specific deep links
recommendationCategory Preserves recommendation-driven entry state
journeyType Retains journey-specific routing or migration context
sortBy Keeps plan ordering stable when prerendered HTML is reused
detailsId Preserves detail overlay or deep-link continuity
disableLoginPrompt Prevents prompt state from flipping during navigation

Outbound-only parameter: skipBasket=true is appended when an upgrade journey runs in WebView with the bypass-basket flag enabled. The app creates it during basket handoff rather than parsing it during bootstrap.

React Client

Bootstrap Process (src/client/index.tsx)

MobX Config
Auth warmup (authService.session())
Flag Injection
Render App
What “auth warmup” means
  • The client makes an early authService.session() call before the app mounts.
  • This does not log the user in. It checks whether session information is already available and primes the auth/session library.
  • If the call fails, the app still continues booting. The failure is logged with Datadog under SESSION_UNAVAILABLE.
  • Why it exists: many later API calls need auth/session cookies and the platform session ID, so the app tries to stabilise that state as early as possible.
What happens before the first screen appears
  • MobX is configured with enforceActions: 'never'.
  • WS10 theme asset locations are set so icons and fonts resolve from the Vodafone CDN.
  • The app reads the features cookie and, in local/debug mode, the ?features= query parameter.
  • Local accessibility auditing is enabled through @axe-core/react in development.
  • The root element is inspected to decide between hydrateRoot() and createRoot().render().
Provider hierarchy and why the order matters
SourceProvider (theme-ws10, i18n, assets)
  └─ StyleSheetManager (shouldForwardProp filter)
       └─ Provider (constate wrapper)
            └─ Router / BrowserRouter
                 └─ AppRoutes
  • SourceProvider makes the design system and language config available.
  • StyleSheetManager stops non-DOM props leaking into HTML elements.
  • Provider is part of the repo's wrapper stack and sits above routing.
  • If you reorder this stack, theme, styles, routing, or context consumers can fail in non-obvious ways.
Hydrate vs render
  • If the server already returned HTML and the root has children, the client calls hydrateRoot().
  • If there is no pre-rendered markup, the client does a normal createRoot().render().
  • window._isPrerender is the marker used to identify prerender mode.
  • During prerender, StyleSheetManager disables CSSOM injection so styled-components can line up with the existing HTML more safely.
Client rendering is backend-shaped

The frontend mostly formats and sequences backend-owned state. Plans, eligibility, notifications, OTB payloads, selected package summary, filters, and many next actions come from API responses, not from hardcoded UI rules.

Component Architecture

This inventory is based on the current top-level component tree: 11 atoms, 45 molecules, 7 organisms, 10 CRO components, 1 seamless-migration component, and 14 templates.

How to read this section

Each inventory item will show what the component does, how often it is used in app code, how tightly it is coupled to the journey stores, and how reusable it is outside SIMO-specific flows.

What the inventory is counting

The list covers top-level exported UI units under atoms, molecules, organisms, CRO, seamless-migration, and templates. It does not try to list every nested helper subcomponent.

What “reusability” means

A high score means the component is mostly prop-driven. A low score means it is tightly bound to SimoContext, journey-specific state, or a single journey path.

Render Tree Breakdown

This section is different from the inventory above. It follows the actual runtime ownership chain from route wrapper to page bootstrap to root template to direct child components, and labels what mainly drives each piece.

CMS / ContentStore

Mostly derived from Contentful payloads loaded into ContentStore: headers, banners, rich text, SEO assets, SimX content, and promo blocks.

Journey / SimoStore

Mostly derived from backend journey data resolved into SimoStore and its child stores: plans, OTB, selected plan state, errors, basket state, and modal visibility.

Feature Flags

Visibility or layout is switched by FeatureFlagStore, even when the actual content still comes from CMS or backend payloads.

Mixed

The component only makes sense when multiple inputs line up, usually CMS copy plus journey state plus feature flags.

What is included here

The tables focus on app-owned components rendered directly by wrappers, pages, templates, and major slice components. Source Web primitives like Container, Heading, and Spacing are intentionally omitted unless they control behaviour.

Route → Page → Template Diagram

Visual tree showing how routes map to pages, which instantiate templates, which compose step templates.

graph TD
  subgraph "HeaderFooterTemplate (outlet)"
    HFT["HeaderFooterTemplate"]
    HFT --> SIMO_CON["/sim-only/best-sim-only-deals → Simo"]
    HFT --> SIMO_BIZ["/business/business-sim-only → Simo"]
    HFT --> SIMO_SHELL["/sim-only/...-shell → Simo (non-prod)"]
    HFT --> P2P_B["/migration/basics/test → P2PMigration (non-prod)"]
    HFT --> P2P_P["/migration/phase5/test → P2PMigration (non-prod)"]
    HFT --> NF["* → NotFound"]
  end

  subgraph "SeamlessHeaderFooterTemplate (outlet)"
    SHFT["SeamlessHeaderFooterTemplate"]
    SHFT --> SM["/customer-transfer/best-sim-only-deals → SeamlessMigration"]
  end

  SIMX["/sim-only/.../simx-details → SimxDetails (no wrapper)"]
  PD["/sim-only/.../:planId/plan-details → PlanDetails (no wrapper)"]

  SIMO_CON --> ST["SimoTemplate"]
  SIMO_BIZ --> ST
  SIMO_SHELL --> ST

  ST --> SPT["SimoPlansTemplate"]
  ST --> SRT["SimoReviewTemplate"]
  ST --> SET["SimoExtrasTemplate"]
  ST --> SIT["SimoInsuranceTemplate"]

  SM --> SMT["SeamlessMigrationTemplate"]
  SMT --> SMPT["SeamlessMigrationPlansTemplate"]

  P2P_B --> P2PT["P2PMigrationTemplate"]
  P2P_P --> P2PT
  P2PT --> P2PPT["P2PPlansTemplate"]

  PD --> PDT["PlanDetailsTemplate"]
  SIMX --> SIMXT["SimxDetailsTemplate"]
          

MobX Store Architecture

State management uses a composition pattern. The root SimoStore creates and holds 18 child stores. Each child keeps a back-reference to the root.

SimoStore (Root)
ViewStore
PlanStore
FilterStore
ContentStore
ExtraStore
InsuranceStore
FeatureFlagStore
AnalyticsStore
LoginStore
MismatchStore
ToggleStore
P2PStore
KeepOrReplaceStore
NewOrExistingStore
OptionsToBuyStore
PlanDetailsStore
AddOnDetailsStore
StickyManager

Key Root Store State

// Journey & Segment
journeyType: 'acquisition' | 'upgrade' | 'secondline' | 'p2p'
journeySubType: 'seamlessMigration' | ''
segment: 'consumer' | 'business'
isSeamlessMigration: boolean

// Plans & Commerce
plans: Plan[]
basketTotal: { buildSteps, cta, monthlyCost, upfrontCost }
summary: SubscriptionSummary   // existing contract (upgrades)
loyalty: { discountAmount, preText, postText, value, uom }

// UI State
error: Simo.Error | null
errorCode: string | null
showLoginNotification: boolean

MobX Pattern

class MyStore {
  someValue = ''
  
  constructor(private simoStore: SimoStore) {
    makeObservable(this, {
      someValue: observable,
      derivedValue: computed,
      updateValue: action,
    })
  }

  get derivedValue() { return this.someValue.toUpperCase() }
  
  updateValue(val: string) { this.someValue = val }
}
MobX Convention

enforceActions is set to 'never' — mutations outside actions are allowed but discouraged. Always use @action for clarity.

MobX & React Context

This repo uses one React context to carry one root store object. MobX is then responsible for deciding which observer components need to rerender.

Page component
useState(new SimoStore())
SimoContext.Provider
observer components
ViewStore
PlanStore
FilterStore
ContentStore
ExtraStore
Root store lifecycle
  • Each page entry point creates one store instance with useState(new SimoStore()).
  • The store is stable for the life of that mounted page. It is not a process-wide singleton.
  • Simo.tsx, SeamlessMigration.tsx, and other page entries call the relevant initialiser such as store.initJourney().
  • The page then provides the root store through SimoContext.Provider.
What React Context is doing here
  • src/client/contexts.tsx exports a bare createContext() value.
  • Components use useContext(SimoContext) to get the root store.
  • The context does not hold dozens of separate values. It only distributes the root store reference.
What MobX is doing here
  • SimoStore owns journey-wide state and composes child stores such as PlanStore, FilterStore, ViewStore, and ContentStore.
  • Child stores keep a back-reference to the root store so they can coordinate with sibling stores.
  • Observer components rerender only when the observables they read actually change.
What “context churn” means here
  • “Context churn” means unnecessary rerenders caused by putting many frequently changing values directly into React Context.
  • This repo avoids that by keeping only one stable store reference in context.
  • MobX then handles fine-grained change tracking inside that store tree.
Why page steps are not separate routes
  • The main journey steps are mostly controlled by ViewStore.pageView, not by separate React routes.
  • That lets the app keep one store instance and one page shell while the user moves between Plans, Extras, Review, and basket handoff.
  • ViewStore also coordinates hash changes, scroll behavior, and when the flow should leave the SPA for basket.
Backend-derived values that change the UI
Backend Field How The Frontend Uses It
journey._links Controls which actions are possible, such as GET_EXTRAS, GET_OTB, or GET_PLAN_DETAILS
journey.notification Drives banners, warning modals, lost extras messaging, AOM exceptions, and upgrade messaging
journey.operationMode Controls business multi-line behavior and dropdown availability
journey.state Marks states such as insurance already selected
journey.otbData Enables options-to-buy cross-sell modal content
journey.filters, sortBy, plansPerCommitmentPeriod Seed FilterStore and define what the plan list can expose
packageBuildSummary.buildSteps Stored on simoStore.basketTotal; currently filtered in some cases, but not used as the direct source of the visible stepper component
Important clarification on buildSteps

The current visible journey stepper is JourneyStepsTracker, which is driven by ViewStore.pageView plus the showJourneyStepsTracker flag. The backend buildSteps payload is still carried in basketTotal, but it is not the thing rendering that stepper today.

Plan Filtering & Visibility

The app does not simply dump journey.plans onto the screen. It refetches plans from the backend when filters change, then applies a smaller set of frontend-only merchandising rules.

Backend journey.plans
FilterStore state + query string
getFilteredPlans()
PlanStore.visiblePlans
CRO / entertainment / trending transforms
Plan cards rendered
What “Basics plans” means in this codebase

It is not a vague product label. In PlanStore, a plan is treated as a Basics plan only when the relevant type string is exactly 'basics plan' after lowercasing.

What gets filtered out and why

Stage What Is Hidden or Changed Reason
Backend filtering Plans outside the current backend filter response are not sent back to the client The client rebuilds the get-plans URL and re-requests plans instead of filtering every commercial rule itself
filterBasicPlans() Non-P2P flows hide plans whose subType is Basics Plan, unless the current subscription summary type is also Basics or the segment is business Keeps Basics offers restricted to the journeys where they are meant to be shown
visiblePlans offer mode Only offer cards are shown when offer-filter buttons are active on 24-month consumer flows Separate merchandising mode for certain CRO or promotional layouts
filterNonAomAndNonTrendingPlans() AOM recommended plans and trending plans are removed from the standard list when those surfaces are rendered elsewhere Avoids duplicate cards appearing in both the main list and the dedicated merchandising block
filterNonEntertainmentPlans() Grouped entertainment plans are removed from the base list They are paired with their base plan and handled through dedicated entertainment UI
Price pill filter Plans are split into All, Below £25, and £25+ Fast CRO segmentation; uses gross price for consumer and net price for business
CRO FilterOptions Only plans matching one selected data range and one selected price range remain Client-side experiment filter UI layered on top of the already visible plan set
Backend-driven filtering path
  • FilterStore builds a query string such as commitmentPeriod=24 Months&sortBy=monthlyPrice.asc.
  • PlanStore.getFilteredPlans() patches the GET_PLANS HATEOAS link with that query.
  • The service refetches plans from the backend and replaces the visible plan set with the response.
  • The backend is still the source of truth for which plans are commercially valid.
FilterStore responsibilities
  • setInitialFilters() seeds filter state from journey payloads.
  • selectCommitmentPeriod() changes contract length and triggers a new plans fetch.
  • sortByChange() updates server-driven sorting.
  • toggleFilter() applies CRO data/price filters and client-side sorting on the already visible set.
Exactly how Basics filtering works in code
  • PlanStore.isBasicsType(type) returns true only when the lowercased string is basics plan.
  • filterBasicPlans() immediately returns all plans for P2P journeys.
  • Outside P2P, the function looks at simoStore.summary?.type, whether the segment is business, and each plan's subType.
  • If the current summary is not Basics and the user is not business, any plan with subType === 'Basics Plan' is removed.
  • After that, the function also filters plans down to the selected commitment period.
Other special plan-list rules worth remembering
  • Trending plans: only considered for non-upgrade, non-business journeys.
  • AOM bundles: can be repositioned ahead of the normal list in upgrade flows.
  • Entertainment bundles: base plans are arranged and paired with related entertainment plans instead of showing every grouped product directly.
  • No plans handling: nextCommitmentPeriodWithPlans can steer the user toward the next valid contract length rather than failing immediately.

Add-ons & Accessories Flow

What people usually call the add-ons step is actually two related mechanisms: the journey extras page driven by GET_EXTRAS, and a separate insurance / device cross-sell path that talks to device add-on APIs.

User selects a plan
ViewStore.goToNextStep()
Decide extras vs basket
ContentStore.loadAddonsContent()
ExtraStore.loadExtras()
SimoExtrasTemplate renders
How the extras page is entered

ViewStore.goToNextStep() decides whether the user should see extras or go straight to basket. Extras are skipped for many journeys if the add-ons page flag is off, but upgrade and business flows still get special handling because lost extras and retention rules matter there.

If the page should show extras, goToExtras() loads extra CMS content, fetches extras through the GET_EXTRAS HATEOAS link, changes pageView to EXTRAS, and scrolls the user to the top.

What actually renders on the extras page

SimoExtrasTemplate only renders when ViewStore.pageView === PAGE_VIEW.EXTRAS. It shows upgrade-specific headings and lost-extras notifications, then renders ExtraCardList if extras are available.

ExtraStore enriches each extra with CMS-driven “what’s included” content, formatted prices, and select / remove CTA text. Toggling an extra calls either selectExtra or removeExtra, then re-fetches extras so package totals and selections stay backend-authoritative.

Where accessories fit

The proxy whitelist includes /plans/{planId}/accessories and device add-on APIs, but the visible SIMO “extras page” in this repo is mainly driven by ExtraStore and GET_EXTRAS.

Insurance and device-related add-ons live on a related but separate path. InsuranceStore.getInsuranceAddons(deviceId) calls device-xsell-purchase/v1/addOns/SIMO/consumer/acquisition, and device lists can be loaded via GET_DEVICES_FROM_CATALOG. So the repo supports add-on style cross-sell beyond the plain extras page, but not all of it is the same storefront step.

API Services

Service Map

Service Path Responsibility
journeyService services/journeyService/ Journey CRUD, auth token extraction, session ID resolution
simOnlyService services/simOnlyService/ Plans, OTB, subscription summary, insurance, extras
contentService services/contentService/ Contentful CMS fetching through /api/content-service/v2/content for page assets, modals, collections, and dynamic content

API URL Construction

// Base pattern
/api/digital/v1/{platformSessionId}/simo-purchase/journeys

// Where platformSessionId comes from the auth session cookie
const session = getSession()
const url = `/api/digital/v1/${session.platformSessionId}/simo-purchase/journeys`

Error Handling Pattern

import to from 'await-to-js'

const [err, data] = await to(apiCall())

if (err) {
  logger.error(
    DD_LOG_MAP[DD_ERRORS.GET_PLANS_VARIANT],
    'simOnlyService.getPlans',
    { links, journeyType },
    err
  )
  return Promise.reject({ ...err, step: 'getPlans' })
}

return data

simOnlyService: what it actually does

Its main job
  • It is the main storefront service for journeys, plans, OTB, extras, insurance, plan details, and package updates.
  • Some methods call explicit REST URLs with axios. Others follow HATEOAS links through runHateoasLink().
  • It also normalises backend responses into the shapes that stores expect, for example parsed plans, selected plans, filters, and notifications.
getJourney(): the orchestration method
  • Chooses between journeyService.createReinventJourney() and journeyService.getReinventJourney().
  • Stores the returned journey ID for later use.
  • Extracts journey type, segment, journey sub-type, operation mode, notifications, and HATEOAS links.
  • If the backend provides SET_OPERATION_MODE, it sets the mode first and then refetches the journey.
  • If the backend provides GET_OTB, it fetches OTB before plans and can resolve early with noPlansCalled = true.
  • If the backend provides GET_PLANS, it deep-link-patches the link, fetches plans, parses them, and merges them into the journey result.
getPlans(): more than just a fetch
  • Starts from the GET_PLANS HATEOAS link and patches the URL with query parameters.
  • Calls getSubscriptionSummary() first for upgrade and tariff-migration journeys.
  • Applies a special rule for basic upgrades: if the summary indicates a basic upgrade, the 24-month request is rewritten to 12 months.
  • Uses the v2 digital prefix when executing the plans request.
  • Parses plans, aomRecommendedBundlePlans, selectedPlan, and parentPlan with the plan parser helpers.
  • Returns a merged result containing plans, summary, filters, sort data, commitment-period counts, and notifications.
Other important methods
Method What it does Important note
getSubscriptionSummary() Fetches existing subscription/allowance data for upgrade-style journeys Only runs when the correct link exists and the journey type needs it
getOptionsToBuy() Thin wrapper over the GET_OTB HATEOAS link Called from getJourney() and some mismatch flows
setOperationMode() Sends operationMode from the query string to the backend Used for single vs multi-line edit flows
getPlan() Fetches a single plan by explicit URL Uses axios, not HATEOAS
getPlanDetailsPlan() Fetches /plans/{planId}/details Used by the dedicated plan-details page
getExtras() Follows the GET_EXTRAS link Patches linesQuantity into the request when needed

makeAPIRequest / runHateoasLink()

What the helper is for
  • It is the transport helper behind most HATEOAS-driven service calls.
  • Input: a _links object, the name of the link to execute, the current path, optional cookies, headers, and request options.
  • Output: a promise that resolves with backend data or rejects with an error after logging.
Exactly how the request is built
  • The helper chooses /api/digital/v1 or /api/digital/v2 based on the version argument.
  • It appends the HATEOAS href to that prefix.
  • It always adds an Accept header, defaulting to application/hal+json.
  • If cookies are supplied, it forwards cookies.PlatformAccessToken as the client Authorization header.
  • It can also add a bingo-journey: true header and arbitrary custom headers.
HTTP method handling
  • GET: direct request.get().
  • POST: request body = HATEOAS parameters merged with any extra params, then request.post().
  • PUT / PATCH: still sent via post(), but with setHTTPMethodOverride().
  • DELETE: also sent via post() with method override set to DELETE.
Special behaviour after a response arrives
  • If the returned payload contains a sign-in link, the helper redirects the browser to the login page and includes success/error return URLs based on the current path.
  • If the returned payload contains a go-to-basket link, the helper redirects to basket.
  • If the requested link is missing, it rejects with a synthetic 404-style error-invalid-hateoas-link object.
  • Any failure is logged to Datadog under RUN_HATEOS_LINKS.

OTB: Options To Buy

What OTB is in this app
  • OTB stands for Options To Buy.
  • It is backend-supplied decision data shown mostly to logged-in customers before the normal plans flow continues.
  • Typical uses include showing upgrade choices, second-line choices, account-context changes, or arrears-related restrictions/warnings.
How it is fetched and why it can block plans
  • simOnlyService.getJourney() checks whether the journey response has a GET_OTB link.
  • If it does, the service calls getOptionsToBuy() before calling getPlans().
  • When OTB resolves, the service sets data.otbData, marks noPlansCalled = true, and resolves early.
  • That means the user can be pushed into an OTB-driven decision path instead of seeing the normal plan list first.
How the frontend uses the payload
  • SimoStore.setJourneyData() copies journey.otbData into optionsToBuyStore.
  • OptionsToBuyStore.modalOpen returns true when data exists and the payload is not an arrears modal case.
  • OptionsToBuyStore.showArrearsModal checks the first notification code against ARREARS_NOTIFICATION_CODES.
  • Button clicks are tracked via onOTBButtonClick() and related analytics reactions.

packageBuildSummary.buildSteps: what it is and where it is used

What the backend sends
  • The backend journey payload includes packageBuildSummary.
  • That summary includes the current CTA plus monthly/upfront prices and an optional buildSteps array.
  • The frontend stores the whole object on simoStore.basketTotal.
Where the current frontend uses it
  • SimoStore.setJourneyData() assigns journey.packageBuildSummary to basketTotal.
  • If the segment is consumer and the add-ons page is disabled, the store filters out any step whose name is Add-ons.
  • After plan selection, PlanStore.handlePlanSelection() updates the summary through updatePackageSummaryAndLinks().
  • After extras load/toggle, ExtraStore also refreshes simoStore.basketTotal from the backend response.
Important clarification
  • The current StickyBasket uses basketTotal.monthlyCost, basketTotal.upfrontCost, and basketTotal.cta.
  • It does not currently render the individual buildSteps entries into the visible basket summary UI.
  • The visible journey stepper is JourneyStepsTracker, and that is driven by ViewStore.pageView plus showJourneyStepsTracker.

Plan Details Deep Dive

The plan details experience has two separate surfaces that reuse the same content-mapping ideas. The first is the in-journey modal opened from the plan list. The second is the dedicated route page at /sim-only/best-sim-only-deals/:planId/plan-details. Both use the same underlying CMS sources, but they render them in different containers and bootstrap in different ways.

The two plan-details surfaces

  • Modal flow: opened from PlanListContent via plan cards and rendered by PlanCardDetailsModal.
  • Route flow: opened via /sim-only/best-sim-only-deals/:planId/plan-details and rendered by the standalone PlanDetails page.
  • Shared idea: both flows need a selected plan, a shared plan-details overlay dataset, and a page-specific plan-content dataset.

Why this matters

  • The modal is optimised for staying in the journey and selecting a plan without leaving the list.
  • The route page is a dedicated deep-linkable experience and is useful when the plan-details screen needs its own URL and bootstrap path.
  • If you change content-mapping logic, both surfaces can be affected even if only one UI changes.

How each tab gets rendered

  1. Overview tab: does not use CMS plan-details content. It renders directly from the selected plan object, including name, benefit items, price, badge, entertainment promo, and the select-plan button.
  2. What’s included: uses getContentForPlan, which is the orchestration helper for the most complex tab.
  3. Additional charges: uses getPlanDetailsTabContent with the page-specific plan-content collection.
  4. What you need to know: also uses getPlanDetailsTabContent.
  5. About speed: also uses getPlanDetailsTabContent.
Key split: Overview is mostly plan-object driven. The other tabs are CMS-content driven.

What this object means in the modal

planDetails: {
  sharedPlanDetails,
  pageSpecificPlanDetails,
}

This is the core input to the plan-details content resolver. sharedPlanDetails contains the reusable overlay content loaded from the shared journey-app asset plan_details_overlay_app. pageSpecificPlanDetails is the shop-page-specific plan-content entry for the current page and segment. The helpers combine both because some content is generic and some is page or plan-subtype specific.

Shared plan details

  • Loaded from plan_details_overlay_app.
  • Contains shared content buckets such as band/type-based handset-detail groups and reusable FAQ-style sections.
  • Used heavily by the what’s-included resolution path.

Page-specific plan details

  • Comes from contentStore.content.planContent.
  • Derived from the current shop-page asset via getPlanContent(pageContent).
  • Used for tab content keyed like packagelistsimo_*.

How ContentStore feeds all plan-details calls

The content store prepares plan-details data in multiple places, depending on which journey is loading.

  1. Main SIMO load: fetchContent() requests the shop-page content, planDetailsOverlay, and supporting assets.
  2. P2P load: fetchP2PContent() does the same pattern for P2P-specific content.
  3. Seamless migration load: fetchSeamlessMigrationContent() repeats the pattern for the seamless page.
  4. Plan-details route load: loadPlanDetailsContent() is the route-specific bootstrap path and fetches just the content needed for the standalone page.

In all of these paths, the important request is the same: planDetailsOverlayRequest. That request fetches the shared overlay asset and stores it as content.planDetailsOverlay. The content store then also resolves planContent from the shop-page asset, which becomes the page-specific input used by the tab helpers.

What is the planListContent prop?

There is not a prop literally named planListContent in the plan-details modal flow. The naming usually refers to one of two things:

The important prop for plan-details is actually planContent on PlanListContent. That object feeds the plan-card mapper, which enriches raw plans with UI-facing fields that are later used when a plan is opened in the modal.

How the modal flow works end to end

  1. SimoPlansTemplate renders PlanListContent and passes plan-selection and modal-tracking callbacks into it.
  2. PlanListContent maps raw plans through plansCardMapper and renders PlanCardList.
  3. A plan card calls openPlanListModal({ ...plan, buttonName, buttonState }) when the user clicks See plan details.
  4. PlanStore.openPlanListModal stores that enriched plan in planListModal and sets isPlanListModalOpen.
  5. SimoTemplate renders PlanCardDetailsModal when the flag is open.
  6. PlanCardDetailsModal reads the selected plan from planStore.plans, constructs tabs, and calls the content helpers for each tab.
  7. The non-overview tabs render through PlanDetailsOverlayModal, which simply hands CMS-shaped content to HeadingAndContent.

How getContentForPlan works

getContentForPlan is the orchestrator for the tab content and especially for the What’s included tab.

  1. If no plan exists, it returns an empty array early.
  2. It calls getPlanDetailsHelper(plan, sharedPlanDetails) to find the shared overlay group based on type and band.
  3. If the tab is not whatsincluded, it delegates to getPlanDetailsTabContent.
  4. For whatsincluded, it checks whether airtime-benefit content exists in the shared overlay content.
  5. It optionally pulls SIMX benefit content.
  6. If airtime content exists, it merges plan benefits and subtype-specific shared-plan-group content.
  7. If no airtime content exists but a subtype exists, it tries a page-specific packagelistsimo_${‘{subType}’} entry.
  8. If all else fails, it falls back to packagelistsimo_essentials.

Each helper in getPlanDetailsContent

  • getContentForPlan: the top-level resolver used by the modal and the standalone page.
  • getPlanDetailsHelper: resolves the shared overlay content bucket using band and airtime type.
  • getAirtimePlanType: reduces type/subtype into a simplified GB or UNLTD category for content keys.
  • getPlanDetailsTabContent: resolves non-what’s-included tabs from page-specific CMS entries.
  • getPlanDetailsWhatsIncludedTab: merges and formats what’s-included content from plan-specific entries, benefit items, and SIMX content.
  • getPlanBenefits: filters benefit content from the shared plan group based on inclusive products and segment.
  • getPlanSubType: normalizes backend subtype values into CMS subtype keys.
  • getPlanDetailsSimxContent: extracts SIMX-only benefit items when the subtype matches.
  • formatPlanDetailsHelper: normalizes different CMS item shapes into the UI shape expected by HeadingAndContent.
  • getHighlandsModalContent: builds Highlands FAQ data from the shared overlay asset for the dedicated Highlands flow.

What happens if we add a new plan.type or subType?

Yes, that can affect content resolution. Whether existing helpers can be reused depends on whether the new type or subtype can map onto the current CMS key strategy.

Rule of thumb: reuse the current helpers when the new plan can be mapped onto existing content conventions. Extend the helper chain only when the key structure or content-shape assumptions change.

Plan-details analytics events

The modal does not send analytics directly from the UI component alone. It writes state into PlanDetailsStore, and the analytics reaction layer watches that state.

What the dedicated route is for

<Route path=’/sim-only/best-sim-only-deals/:planId/plan-details’ element={<PlanDetails />} />

Contentful / CMS Content Architecture

The repo has two distinct Contentful paths. The main client path is driven by ContentStore and contentServiceV2.getAssetModelV2() for page content. A separate server path uses contentAPITransformer for shell content such as header navigation and footer.

Two Content Pipelines

Pipeline Entry Point What It Fetches Where spaceName Comes From
Client page content src/client/stores/ContentStore/ContentStore.ts Shop pages, plan details, notifications, signposting, insurance content, dynamic modal content Hardcoded to consumer inside getAssetModelV2.ts
Server shell content src/server/common/config/contentAPITransformer.config.ts Header navigation and footer transformation Explicit per config entry, for example consumer or business

Client Content Fetch Flow

Template / Component needs CMS content
SimoStore.ContentStore
loadContent / loadPlanDetailsContent / getContentByHref
hydrate(...)
contentServiceV2.getAssetModelV2()
/api/content-service/v2/content
Response body
mapPageContent + helper mappers
consumerContent / businessContent

Actual Client Request Shape

const searchParams = new URLSearchParams({
  contentEntryKey,
  contentType,
  spaceName: 'consumer',
})

requestInstance(`/api/content-service/v2/content?${searchParams.toString()}`)
  .get()
How spaceName is determined today

For the client content service, spaceName is not inferred from segment, isBusiness, or journeyType. It is currently hardcoded to consumer in src/client/services/contentService/getAssetModelV2.ts. Business-vs-consumer page content is mostly selected by different contentEntryKey values such as packagelistsimo_business or contact_us_flyout_business.

What ContentStore Actually Does

Method Role Notes
fetchContent() Builds the main consumer/business CMS request set Uses Promise.all with many hydrate()-wrapped Contentful calls
fetchSeamlessMigrationContent() Loads migration-specific page assets Adds seamless migration login notifications and header content
fetchP2PContent() Loads pay-as-you-go migration assets Fetches P2P page, plan details overlay, and Highlands content
loadContent() Maps fetched raw CMS responses into store state Populates both businessContent and consumerContent
loadSeamlessMigrationContent() Stores seamless migration content Also extracts the special seamless migration header
loadP2PContent() Stores P2P-specific content Builds P2P modal content and plan content
loadAddonsContent() Fetches extras content incrementally Used when add-ons content is needed later in the journey
loadPlanDetailsContent() Fetches plan detail overlays incrementally Keeps plan details loading separate from the initial page load
getContentByHref() Fetches dynamic assets from HREF query params Caches by segment in cachedContent
getHighlandsDynamicContentByHref() Fetches Highlands modal content on demand Annotates fetched entries with productId in the key
mapPageContent() Main normalization function Turns large Contentful responses into the app-specific content model

Main Helpers and Constants

Helper / Constant Location Why It Matters
getAssetModelV2() src/client/services/contentService/getAssetModelV2.ts Builds the request URL, adds spaceName, and logs failures to Datadog
hydrate() External helper from @vfuk/lib-web-prerender Deduplicates and reuses CMS fetches across prerender and client rendering
CONTENTFUL_CONTENT_ENTRY_KEYS src/client/constants/constants.ts Central registry of entry keys the store fetches or looks up inside responses
CONTENTFUL_PATH_CONFIG src/client/constants/constants.ts Central registry of response paths used with lodash/get
findByContentEntryKey() src/client/stores/helpers/findByContentEntryKey Primary lookup helper for nested Contentful entries
shouldShowContentByTag() src/client/stores/helpers/shouldShowContentByTag Filters marketing and family content by segment and journey tags
documentToHtmlString() @contentful/rich-text-html-renderer Converts rich text documents to HTML strings for banners, signposting, and footnotes
mapBingoBenefits() src/client/stores/helpers/mappers/mapBingoBenefits Normalizes bingo benefit content into the shape used by the UI
formatFootnotes() src/client/stores/helpers/formatFootnotes Converts Contentful footnotes into app-ready values
formatAddonNotifications() src/client/stores/helpers/formatAddonNotifications Maps add-on notification collections
formatPlanBenefits() src/client/stores/helpers/formatPlanBenefits Maps plan benefit collections into display content
formatAomRecommendedContent() src/client/stores/helpers/formatAomRecommendedContent Maps AOM recommendation content used in plan experiences
mapToImeiInsuranceModal() src/client/stores/helpers/mapToImeiInsuranceModal Shapes insurance content into the modal data contract the UI expects
mapToDeviceInsuranceSelectionContent() src/client/stores/helpers/mapToDeviceInsuranceSelectionContent Shapes insurance device selection content
mapToInsurancePromotionsAdvertBanner() src/client/stores/helpers/mapToInsurancePromotionsAdvertBanner Maps insurance promo banner content into UI-ready properties
getPackageListEntries(), getAdditionalDataContent(), getIlsDiscountBanner(), getExtraFootnote() ContentStore.ts Small local helpers used during the final normalization step

How Responses Are Mapped

mapPageContent() is the core transformation function inside ContentStore. It uses CONTENTFUL_PATH_CONFIG to pull large branches out of the raw response, then repeatedly uses findByContentEntryKey() to extract specific entries.

const channelAppContent = get(
  pageContent,
  `${CONTENTFUL_PATH_CONFIG.basePath}.${CONTENTFUL_PATH_CONFIG.channelApp}.fields.content`,
  [],
)

const reviewHeaderContent = findByContentEntryKey(
  channelAppContent,
  CONTENTFUL_CONTENT_ENTRY_KEYS.reviewHeader,
)

const secureNetContent = findByContentEntryKey(
  channelAppContent,
  CONTENTFUL_CONTENT_ENTRY_KEYS.secureNetUspContent,
)
Raw Contentful response
CONTENTFUL_PATH_CONFIG + lodash/get
findByContentEntryKey()
Mapper helpers
consumerContent / businessContent slices

Dynamic CMS Fetches and Caching

Not every CMS request happens at initial page load. Some assets are fetched later from HREFs returned by other content, then cached in cachedContent by segment.

cachedContent: Map<segment, Map<contentName, resolvedContent>>

getContentByHref(contentName, href)
  - parses contentEntryKey + contentType from the href
  - calls getAssetModelV2(...)
  - stores the resolved content under the current segment

getHighlandsDynamicContentByHref(productId, href)
  - parses contentEntryKey + contentType
  - avoids duplicate fetches for the same product
  - appends the fetched modal content into highlandsModalContent

Server-Side Header/Footer Content Flow

The server has a separate Contentful path for shell assets. This is the only place in the repo where spaceName is already varied between consumer and business without changing the client content service.

Index request
contentAPITransformer middleware
contentAPITransformer.config.ts
assetName + assetType + spaceName
Header / Footer content returned
businessFooter: {
  v2: {
    assetName: 'business_footer',
    assetType: 'footer',
    spaceName: 'business',
  },
}

How to Add a New Contentful Call

Case 1: New content in the existing client path

  1. Add a constant to CONTENTFUL_CONTENT_ENTRY_KEYS if the new CMS asset will be looked up by key inside a larger page response.
  2. If you need a new response path, add it to CONTENTFUL_PATH_CONFIG.
  3. Add a new hydrate(...) request in the relevant ContentStore fetch method such as fetchContent(), fetchSeamlessMigrationContent(), or fetchP2PContent().
  4. Include the request in the surrounding Promise.all.
  5. Map the returned payload inside loadContent() or mapPageContent() so components can read it from consumerContent or businessContent.
  6. If the content is rendered conditionally, route it through helpers like findByContentEntryKey() and shouldShowContentByTag() instead of duplicating traversal logic in components.
  7. If the content should work in local dev without live CMS access, update mocks or fixtures as needed.

Case 2: You actually need a new client-side spaceName

The current client service cannot do this without code changes because spaceName is fixed to consumer. To support a new value, you would need to:

  1. Extend ContentParams in getAssetModelV2.ts with spaceName?: string.
  2. Default it safely, for example spaceName = 'consumer', so current call sites keep working.
  3. Pass the new spaceName from the specific ContentStore fetch path or new caller that needs it.
  4. Add tests for the new query-string behavior in getAssetModelV2.test.ts.
  5. Only do this if a new Contentful space is really required; in this repo, most business-vs-consumer variation is already handled through entry keys, not through the client-side spaceName.
export interface ContentParams {
  contentEntryKey: string
  contentType: string
  spaceName?: string
}

const getAssetModelV2 = ({ contentEntryKey, contentType, spaceName = 'consumer' }: ContentParams) => {
  const searchParams = new URLSearchParams({ contentEntryKey, contentType, spaceName })
  return requestInstance(`/api/content-service/v2/content?${searchParams.toString()}`).get()
}

Case 3: New server-side shell asset with its own spaceName

  1. Add a new entry to src/server/common/config/contentAPITransformer.config.ts.
  2. Set the correct assetName, assetType, and spaceName in the v2 config.
  3. Ensure the server middleware path that consumes it is already covered by the current transformer configuration flow.
AIM / local mock note

The local mock system already knows about /api/content-service/v2/content. In aim.config.ts the content service path is normalized and included in the ignored hash patterns, so CMS requests can participate in the local replay flow.

Routing

Route Component Segment Notes
/sim-only/best-sim-only-deals Simo Consumer Main page
/business/business-sim-only Simo Business Business variant
/customer-transfer/best-sim-only-deals SeamlessMigration Migration Customer transfer
.../simx-details SimxDetails Entertainment bundle
.../:planId/plan-details PlanDetails Plan breakdown
/migration/basics/test P2PMigration Non-prod only
* NotFound 404 fallback
Route Whitelist

In development and in debug / PR-style environments, these routes are owned by the SIMO app and other routes can proxy to an environment host through useLocalMiddlewares. In normal production, the important proxy boundary is API-level rather than page-level.

Request Lifecycle

Here's what happens when a user visits /sim-only/best-sim-only-deals:

1. Browser GET
2. Express receives
3. Auth check (IDM)
4. Feature flags fetched
5. Prerender cache check
6. HTML served with injected env vars
7. React boots (Vite bundle)
8. SimoStore created
9. initJourney()
10. Journey created/fetched
11. HATEOAS links received
12. getPlans() via link
13. Plans rendered to user

Prerendering

Prerender is a backend HTML-caching feature. The server can return already-rendered markup for a page, and the React app then hydrates that HTML instead of starting from a blank root.

Incoming HTML request
prerenderMiddleware
Redis / MockRedis lookup
Cache miss → prerender service
HTML returned to browser
hydrateRoot()
Prerender Setting What This Repo Does
Enablement If PRERENDER_SERVICE_URL is missing, the middleware exits early and prerender is disabled
Cache store Uses Redis via ioredis or MockRedis when PRERENDER_MOCK_REDIS=true
TTL cacheSeconds = 300 (5 minutes)
Cache busting Includes BUILD_BUILDID as buildHash, so new builds do not reuse old HTML
Skip rule Skips prerender if the request already has a basketId cookie
Retained cookies features and customerSegment
Retained query params Uses the array returned by getQueryParamsToRetain()
Hydration handshake The client checks rootElement.hasChildNodes(); if true it calls hydrateRoot(), otherwise createRoot().render()
What prerender is doing at runtime
  • The Express app mounts @vfuk/lib-web-prerender inside prerenderMiddleware.
  • The middleware is only enabled when PRERENDER_SERVICE_URL exists.
  • On a cache hit, the browser receives stored HTML immediately.
  • On a cache miss, the prerender service renders HTML, stores it, and returns it.
What Redis is and where it lives
  • Redis is a backend key-value datastore used here as the prerender HTML cache.
  • It is not a frontend library or browser feature.
  • This repo connects to Redis through ioredis in connectRedis.ts.
  • For local or mocked environments, the app can swap to MockRedis with PRERENDER_MOCK_REDIS=true.
Why the skip rule matters
  • If a request has a basketId cookie, the middleware skips prerender.
  • Reason: that request is now user-specific and may represent an in-progress basket state.
  • Serving cached generic HTML for a personalised basket journey would be risky.
What the client does vs what the server does
  • Backend responsibility: cache HTML, decide cache keys, retain selected cookies/query params, connect to Redis, and call the prerender service.
  • Frontend responsibility: detect that HTML already exists and hydrate it with React.
  • So prerender is mainly a backend/platform concern with one small frontend hydration handshake.
Short answer: is Redis FE or BE?

Backend. The browser never talks to Redis directly in this repo. Redis is only used by the server-side prerender middleware.

Authentication Flow

Authentication is handled through Vodafone IDM. The app can serve both anonymous and authenticated users, but some journeys or actions require a stronger logged-in state.

What IDM is and why this app uses it

What it is

IDM is the identity service used for login, logout, callback processing, assurance levels, and auth cookie issuance.

Why SIMO uses it

The storefront needs a trusted way to know who the customer is, which accounts/subscriptions they have, and what level of assurance the session currently has.

What it gives this app

Encrypted auth cookies, access tokens for downstream APIs, and identity/session claims such as platformSessionId and assuranceLevel.

IDM OAuth2 Flow

User visits route
loginRedirect checks assuranceLevel
↓ (if level < 3)
Redirect to /web-shop/login (IDM)
User authenticates
IDM → /web-shop/login/callback
loginCallbackMiddleware sets cookies
Redirect back to original URL (authenticated)

Auth Configuration

{
  loginScope: 'openid vf-profile vf-account vf-subscription vf-contact',
  authLevel: 3,              // Highest assurance
  dualMode: true,            // Allow both auth + anon
  channelId: 'eShop-simo',   // DXL channel identifier
  basePath: '/web-shop/login'
}
What “authLevel: 3” means in practice
  • The login redirect middleware checks the user's current assuranceLevel.
  • If the page/action requires stronger assurance and the user is below level 3, the app redirects through IDM.
  • This is how the storefront distinguishes a light session from a fully authenticated one.
What “dual mode” means
  • The page shell itself can still be viewed anonymously.
  • But some actions inside the journey, such as certain upgrade paths or account-sensitive flows, can trigger login or OTB handling.
Example
  • An anonymous customer can land on the SIM-only page and browse plans.
  • An existing customer trying to enter an upgrade-specific path may be redirected to IDM so the app can resolve their subscriptions and eligibility.
  • After IDM returns to /web-shop/login/callback, the callback middleware sets encrypted cookies and sends the user back to the original page.

HATEOAS API Pattern

The backend uses Hypertext As The Engine Of Application State. Instead of hardcoding API URLs, the client discovers endpoints dynamically from response links.

How It Works

1. Create/Get Journey
2. Receive _links object
3. Extract link by name
4. Execute via makeAPIRequest
5. Response includes new _links
6. Follow next link...

Code Example

// Step 1: Create or retrieve journey
const journey = await journeyService.createReinventJourney({
  journeyType: 'acquisition',
  platformSessionId,
})

// Step 2: Journey response includes _links
// { _links: { 'get-plans': { href: '...', method: 'GET' }, ... } }

// Step 3: Use link to fetch plans
const plans = await simOnlyService.getPlans({
  links: journey._links,
  journeyType: 'acquisition',
  userType: 'consumer',
})

Common HATEOAS Link Names

Journey Initialization

When the Simo page mounts, SimoStore.initJourney() orchestrates the entire setup sequence:

Detect journey type & segment

From URL path (/sim-only/ vs /business/) and cookies (customerSegment)

Create or resume journey

journeyService.createReinventJourney() or .getReinventJourney()

Set operation mode

Configures the journey backend state (single plan, multi-plan, etc.)

Fetch plans

simOnlyService.getPlans() via HATEOAS link. Returns plans, filters, notifications.

Load CMS content

Contentful content for headers, modals, notifications

Fire analytics

Page load event sent to Tealium with journey context

Journey Types

Type Description Entry Point
acquisition New customer purchasing a SIM /sim-only/best-sim-only-deals
upgrade Existing customer upgrading contract Same URL, detected via auth
secondline Adding another line to account Same URL, detected via auth
tariffMigration Changing plan on existing subscription Same URL, detected via auth
p2p Pay-as-you-go → Pay Monthly /migration/basics/test
seamlessMigration Transfer from competitor /customer-transfer/best-sim-only-deals

All API Calls — Quick Reference

Every HTTP call the frontend makes goes through the Express proxy (dxlProxyMiddleware) to the AWS DXL API Gateway. The frontend never talks to backend services directly. All calls are discovered via HATEOAS links returned in previous responses — the frontend holds no hardcoded backend URLs except the initial journey entry point.

How HATEOAS drives the flow

The journey response contains _links. Each link has an href, a type (GET / POST / PATCH / DELETE), and optional parameters / queryParameters. The frontend calls runHateoasLink(links, 'link-name') — it prepends the API gateway prefix, sets the body from parameters, and fires the request. No URL is hardcoded beyond the initial journey endpoint.

CallMethodHATEOAS link / URLTriggerKey data sentKey data received
Journey LatestGET .../journeys/latest?segment= Page load segment, referrer id, state, _links, filters, notification
Create JourneyPOST .../journeys Deep link / new session journeyType: "NOTSET" Same as Journey Latest
Resume JourneyGET .../journeys?packageId= ?packageId= in URL packageId Journey with selectedPlan
Get PlansGET get-plans After journey init & filter changes commitmentPeriod, sortBy, filter params plans[], selectedPlan, filters
Select PlanPOST select-plan (on plan object) User clicks "Choose" linesQuantity (business) packageBuildSummary, state, new _links
Get Subscription SummaryGET get-subscription-summary Upgrade/tariff migration journeys Current contract details
Get OTBGET get-offers-and-buy-options Journey init (if link present) Arrears data, cross-sell offers
Set Operation ModePOST set-operation-mode Business multi-line edit from basket operationMode: "SINGLE" | "MULTIPLE" Updated journey
Get ExtrasGET get-extras Navigate to extras page linesQuantity (if multi-line) available[], packageBuildSummary
Select ExtraPOST select-extra (on extra object) User toggles extra on HATEOAS params — (reloads extras)
Remove ExtraDELETE remove-extra (on extra object) User toggles extra off linesQuantity — (reloads extras)
Keep PackagePOST select-keep-package Keep/Replace modal — Keep HATEOAS params Updated journey, both plans in basket
Replace PackagePATCH select-replace-package Keep/Replace modal — Replace planId Updated journey, new plan only
Change SegmentPOST sync-bskt-seg-to-jrny Mismatch — Continue & clear extras HATEOAS params Updated journey in new segment
Keep SegmentPOST sync-jrny-seg-to-bskt Mismatch — Cancel switch HATEOAS params Journey reverted to original segment
Empty BasketPOST empty-basket Mismatch — Empty & continue HATEOAS params Empty journey, fresh start
Get Insurance Add-onsGET /device-xsell-purchase/v1/addOns/SIMO/{segment}/{journeyType} Insurance tab/step opens addOnRequestType=INSURANCE, deviceId, insuranceInBasketCount bingoInsurances.insuranceOptions[]
Get Devices (BYOD)GET get-BYODeviceList Insurance device picker loads BYODeviceList[] (make/model/memory/color/IMEI)
Submit InsurancePOST add-byod-insurance or .../package/insurance User submits insurance form insuranceId, deviceInfo (IMEI, model, etc.) Updated journey with insurance

Journey Init & Resume

Starting a journey — what happens on page load

SimoStore.initJourney() decides which journey call to make based on URL query params and journey type:

// Decision logic (SimoStore.ts)
if (packageId) {
  // Returning user with an existing package
  → getSimoJourneyForPackage(packageId)
    GET .../journeys?packageId={packageId}

} else if (isP2P) {
  → getSimoJourneyLatestP2P...()
    GET .../journeys/latest?segment=...&planType=BASICS|PHASE5

} else if (isSeamlessMigration) {
  → getSeamlessMigrationJourneyLatest()
    GET .../journeys/latest?transactionId=...&journeySubType=seamlessmigration

} else {
  // Standard acquisition / upgrade
  → getSimoJourney()
    GET .../journeys/latest?segment=consumer|business[&referrer=...]
}

Journey Latest — example response (key fields)

{
  "id": "abc-123-journey-id",
  "journeyType": "acquisition",           // or "upgrade" | "secondline" | "tariffMigration"
  "segment": "consumer",
  "operationMode": "SINGLE",
  "state": "Created",                     // or "Created,planSelected" if plan was previously chosen
  "packageBuildSummary": {
    "monthlyPrice": { "gross": { "value": 0 } },
    "upfrontCost":  { "gross": { "value": 0 } },
    "buildSteps":   []                    // Empty until plan selected
  },
  "notification": [],                     // Mismatch/warning codes if any
  "_links": {
    "get-plans": {
      "href": "/simo-purchase/paym/v2/{sessionId}/journeys/abc-123/plans",
      "type": "GET",
      "queryParameters": { "commitmentPeriod": "24 Months", "segment": "consumer" }
    },
    "get-offers-and-buy-options": { "href": "...", "type": "GET" },
    "set-operation-mode": {
      "href": "...", "type": "POST",
      "parameters": { "operationMode": "SINGLE" }
    }
  }
}

Returning user — plan already selected

When a user previously selected a plan (e.g. left the browser tab open, or returns with the same session), the journey state field tells the frontend what's already done:

// state values (comma-separated, cumulative):
"Created"                                   // Fresh journey
"Created,planSelected"                      // Plan was chosen
"Created,planSelected,extraSelected"        // Plan + extras chosen

The plans response will also include a selectedPlan field with the full plan object. PlanStore uses this to highlight the already-selected card and pre-populate the basket summary.

URL query param — resuming a specific package

If ?packageId=xxx is present in the URL (e.g. user navigated back from basket), the app calls getSimoJourneyForPackage() instead of latest. This retrieves the journey associated with that package, restoring the full selection state.

GET /api/.../journeys?packageId=abc-package-id
// Response includes: selectedPlan, packageBuildSummary with steps completed

Plans API

The plans call is made immediately after the journey is initialised, and again any time filters change. The URL is taken directly from the journey's _links["get-plans"] — the frontend never constructs this URL itself.

What the frontend sends

// The HATEOAS link already contains the base query params (e.g. commitmentPeriod).
// FilterStore merges in any user-selected filter values before firing:

GET /api/digital/v2/simo-purchase/paym/v2/{sessionId}/journeys/{journeyId}/plans
  ?commitmentPeriod=24+Months
  &sortBy=monthlyPrice.asc     // (optional, if user sorted)
  &segment=consumer

Example response (trimmed)

{
  "plans": [
    {
      "id": "plan-001",
      "name": "Unlimited Lite",
      "type": "airtime",
      "subType": "Unlimited",
      "commitmentPeriod": "24 Months",
      "monthlyPrice": { "gross": { "value": 1200 } },   // pence
      "dataInfo":  { "prefix": "", "main": "Unlimited" },
      "priceInfo": { "unit": "£", "text": { "main": "12.00", "suffix": "/month" } },
      "benefitItems": {
        "benefitItems": [
          { "productId": "030060", "name": "Wi-Fi Calling" },
          { "productId": "050100", "name": "Entertainment" }
        ]
      },
      "inclusiveProducts": [
        { "id": "030060", "name": "Wi-Fi Calling" }
      ],
      "isRecommended": false,
      "isSelected": false,
      "_links": {
        "select-plan": {
          "href": "/simo-purchase/paym/v2/{sessionId}/journeys/{id}/package/plan",
          "type": "POST",
          "parameters": { "planId": "plan-001", "linesQuantity": 1 }
        }
      }
    }
  ],
  "selectedPlan": null,           // Non-null if plan already selected (returning user)
  "filters": {
    "commitmentPeriod": [
      { "value": "12 Months", "totalNoOfPlans": 5 },
      { "value": "24 Months", "totalNoOfPlans": 8 }
    ]
  },
  "sortBy": ["monthlyPrice.asc", "monthlyPrice.desc"],
  "noOfFiltersApplied": 0
}

Memoisation

getPlans() is wrapped with memoize() (maxSize: 8, deep-equal key). Identical filter states do not fire a second network request — the cached promise is returned. The cache is per-call-signature, not persisted across page loads.

Plan Selection Flow

What happens when the user clicks "Choose"

User clicks CTA
PlanStore.selectPlan(id)
simOnlyService.selectPlan(plan)
POST to plan._links["select-plan"].href
Response updates basketTotal, links
Navigate to extras or basket

What is sent

// POST body is taken from plan._links["select-plan"].parameters:
{
  "planId": "plan-001",
  "linesQuantity": 1       // 1 for consumer; 2–5 for business multi-line
}

What comes back

{
  "packageBuildSummary": {
    "monthlyPrice": { "gross": { "value": 1200 } },
    "upfrontCost":  { "gross": { "value": 0 } },
    "buildSteps": [
      { "name": "Plans",    "completed": true },
      { "name": "Add-ons",  "completed": false },
      { "name": "Review",   "completed": false }
    ]
  },
  "state": "Created,planSelected",
  "_links": {
    "get-extras":              { "href": "...", "type": "GET" },
    "select-keep-package":     { "href": "...", "type": "POST" },
    "select-replace-package":  { "href": "...", "type": "PATCH" }
  }
}

How the plan persists across sessions

After a plan is selected, the backend associates it with the journey via its own persistence layer. The next time the user arrives (same session or new session with the same authenticated account), journeys/latest returns state: "Created,planSelected" and the selectedPlan field is populated in the plans response. The frontend has no role in persisting the selection — it only reads back what the backend reports.

No client-side persistence of plan selection

There is no cookie, localStorage, or sessionStorage involved in plan selection state. Everything lives in the backend journey. The basketId cookie is set by the backend checkout service when the user proceeds further — not by the SIMO storefront itself.

KeepOrReplaceStore — Basket Conflict

When a logged-in user selects a plan, the backend may detect that their basket already contains a package from a previous session. The backend signals this via a notification code in the journey response, and the frontend shows a modal asking the user what to do.

When it appears

// SimoStore — after plan selection response:
if (shouldShowPackageModal(journey)) {
  this.keepOrReplaceStore.errorCode = journey.notification[0].code
  this.keepOrReplaceStore.links     = journey._links
  // → Modal is displayed
}

The two choices

Keep existing package

The user keeps their previous basket package and adds the newly selected plan on top. Both end up in the basket together (e.g. an upgrade + a new SIM).

POST select-keep-package
// No extra body — HATEOAS params only
// Response: basket now has both plans

Replace with new plan

The existing basket package is removed and replaced by the plan just selected.

PATCH select-replace-package
{ "planId": "plan-001" }
// Response: basket has only the new plan

What KeepOrReplaceStore manages

MismatchStore — Segment & Session Conflicts

A mismatch is when the backend detects that the current journey context is incompatible with the user's account or basket state. It is different from a Keep/Replace conflict — mismatches are about the user's identity or eligibility, not their basket contents.

Common mismatch scenarios

Notification codeWhat it meansUI action available
warning_…_session-expired-login-or-empty-basket Session timed out; basket may be empty Log in again or start fresh
warning_…_not-eligible-segment-deeplink User deep-linked to a segment they're not eligible for (e.g. business deep link as consumer) Redirect to the correct segment
warning_…_lms-down-deeplink Loyalty Management System unavailable for deep link journey Retry or start standard journey
Segment mismatch codes Basket is consumer segment but user is viewing business plans (or vice versa) Switch segment + clear extras, or keep original segment

Resolution API calls

// Continue in new segment (clear extras from old segment):
POST sync-bskt-seg-to-jrny
// → Journey switches segment, extras cleared, plans refetched

// Cancel — revert to original segment:
POST sync-jrny-seg-to-bskt
// → Journey reverts, user stays on original segment

// Empty basket and start fresh:
POST empty-basket
// → All basket contents cleared, journey reset to "Created"

What MismatchStore manages

Extras / Add-ons API

Loading extras

// ExtraStore.loadExtras() → simOnlyService.getExtras(links)

GET (via get-extras HATEOAS link)
/simo-purchase/paym/v2/{sessionId}/journeys/{journeyId}/package/extras
  [?linesQuantity=2]    // only for multi-line business

// Response:
{
  "available": [
    {
      "id": "extra-001",
      "name": "Roam Further",
      "description": "Use your data in more countries",
      "monthlyPrice": { "gross": { "value": 500 } },   // pence
      "isSelected": false,
      "_links": {
        "select-extra": { "href": "...", "type": "POST", "parameters": { "extraId": "extra-001" } },
        "remove-extra":  { "href": "...", "type": "DELETE", "parameters": { "extraId": "extra-001" } }
      }
    }
  ],
  "packageBuildSummary": { "monthlyPrice": { ... }, "buildSteps": [...] },
  "notifications": []
}

Selecting / removing an extra

// ExtraStore.toggleExtra(extra)
// If extra.isSelected === false  → selectExtra  → POST select-extra
// If extra.isSelected === true   → removeExtra  → DELETE remove-extra

// After either call, loadExtras() is called again to get the updated list
// The response to select/remove itself is not used — the follow-up GET provides fresh state

Extra detail content (CMS)

Each extra has optional "What's included" detail content loaded from Contentful. ExtraStore.getExtraContent(extra) looks up the CMS key extraswhatsincluded_sku{extra.id} from the pre-loaded packagelistsimoextraswhatsincluded content block. If no CMS entry exists for that ID, the detail link is hidden.

// Key pattern:
`extraswhatsincluded_sku${extra.id}`
// e.g. "extraswhatsincluded_skuROAM001"
// If no match → extra.includeViewDetails = false

Insurance API

Insurance is a separate add-on flow, distinct from extras. It requires the user to select a device (Bring Your Own Device) before a price can be shown.

1. Get available insurance options

// InsuranceStore.getInsuranceAddons()
// URL is NOT from HATEOAS — it is constructed directly:

GET /sim-only/best-sim-only-deals/api/device-xsell-purchase/v1/addOns/SIMO/consumer/acquisition
  ?addOnRequestType=INSURANCE
  &deviceId={deviceId}
  &insuranceInBasketCount={count}    // from journey HATEOAS link param

// Response:
{
  "bingoInsurances": {
    "insuranceOptions": [
      {
        "id": "insurance-001",
        "name": "Total Care",
        "monthlyPrice": { "gross": { "value": 899 } },
        "stepPrices": [                              // Multi-step pricing (promotional)
          { "price": { "consumer": 499 }, "label": "Months 1–3", "upToMonth": 3 },
          { "price": { "consumer": 899 }, "label": "Month 4+",   "afterMonth": 3 }
        ]
      }
    ],
    "insurancePromotionEligibility": "MULTILINE"
  }
}

2. Load device list (BYOD)

// InsuranceStore.getDevices()
// Uses HATEOAS link "get-BYODeviceList" if present; otherwise hardcoded fallback:

GET /api/digital/v2/productCatalog/device/BYODeviceModel/deviceType/handset

// Response:
{
  "BYODeviceList": [
    {
      "displayModel": "iPhone 15 Pro",
      "make": "Apple",
      "memoryVariants": [
        {
          "memory": "128GB",
          "colorVariants": [
            {
              "color": "Black",
              "genericDeviceId": "dev-sku-001",
              "oracleAssurantId": "assurant-001"   // Required for insurance submission
            }
          ]
        }
      ]
    }
  ]
}

3. Submit insurance

// InsuranceStore.submitInsuranceDetailsFn()
// Uses HATEOAS link "add-byod-insurance" if present (updating existing insurance);
// otherwise constructs URL directly (first-time submission):

POST .../journeys/{journeyId}/package/insurance
{
  "insuranceId":   "insurance-001",
  "deviceInfo": {
    "deviceName":       "iPhone 15 Pro",
    "make":             "Apple",
    "model":            "A2846",
    "memorySize":       "128GB",
    "deviceColor":      "Black",
    "imei":             "123456789012345",    // User-entered
    "genericDeviceSkuId": "dev-sku-001",
    "assurantId":       "assurant-001"
  }
}
Insurance URL is partially hardcoded

Unlike every other call, the first-time insurance submission URL is constructed manually in submitInsuranceDetails() using a hardcoded path prefix '/sim-only/best-sim-only-deals/api/digital/v2/simo-purchase'. This is a 3onv risk — the path prefix would need to change for the 3onv brand.

Feature Flags

LaunchDarkly flags are fetched server-side, injected to the client, and mirrored into MobX state via FeatureFlagStore.

Flag Lifecycle

LaunchDarkly (Server)
featureFlaggingMiddleware
window.VFUK.env.FEATURE_FLAGS
FeatureFlagStore (MobX)
Components read flags

Local Override Methods

// Method 1: Cookie
document.cookie = 'features=showMidContractRise=true,showTrendingPlans=true'

// Method 2: Query parameter
https://localhost:8000/sim-only/best-sim-only-deals?features=showMidContractRise=true

Default-on flags

  • showAddonPage
  • showCroComparisonTable
  • showCroAomPlansRepositionEnabled
  • showLoginSubHeader

Why this matters

  • Flags control UI rollouts, CRO experiments, kill-switches, and operational safeguards.
  • Some flags are booleans, some are numeric thresholds, and one is currently present but inactive.

All flags

Adding New Flags

Always add to src/server/common/config/featureFlags/featureFlags.config.ts first, then access via FeatureFlagStore.

Cookie Management

Cookie Purpose Set By
{prefix}_p_id_token Parsed session data (assuranceLevel, givenName, platformSessionId, etc.) IDM callback middleware
{prefix}_id_token JWT access token (Bearer token for API calls) IDM callback middleware
basketId Active basket identifier. Prevents prerender caching. API response
features Feature flag overrides (flagName=true,flag2=false) Developer / QA
customerSegment Consumer or business segment Backend / auth

Default cookie prefix: eShop-auth (configurable via AUTH_COOKIE_PREFIX env var).

Session Management

Session data is extracted from the {prefix}_p_id_token cookie by the getSession() helper:

const session = getSession()
// Returns:
{
  assuranceLevel: 3,              // Auth confidence (0-3)
  givenName: 'John',             // User first name
  numberOfAccounts: 1,
  numberOfSubscriptions: 2,
  accountCategory: 'Consumer',   // or 'Business'
  subscriptionIdHash: 'abc...',
  phoneNumberHash: 'def...',
  subscriptionType: 'PAYM',      // Pay Monthly
  platformSessionId: 'UUID',     // Used in API URL construction
}
Platform Session ID

The platformSessionId is critical — it's used to construct all API URLs: /api/digital/v1/{platformSessionId}/simo-purchase/journeys

When the Journey Starts (Initialization Flow)

When a user lands on the SIM-only landing page (such as /sim-only/best-sim-only-deals), the application initializes a purchase journey. Here is exactly what is set, read, and invoked during this lifecycle sequence:

1. Middlewares Establish Session & Cookies

Depending on whether the user is logged in:

  • Anonymous Users: The server-side shopAnonymousSessionMiddleware runs, establishing an anonymous session and setting the {prefix}_p_id_token cookie. This cookie contains a unique, generated platformSessionId associated with their session.
  • Authenticated Users: If the user completes the IDM OAuth2 login flow, the loginCallbackMiddleware sets the encrypted auth cookies:
    • {prefix}_p_id_token (containing their parsed account, subscriptions, assuranceLevel: 3, and platformSessionId)
    • {prefix}_id_token (the raw JWT access token used in authorization headers)
    • customerSegment (specifying consumer or business)

2. Client-Side Auth Warmup

Before the React app mounts, the entry point src/client/index.tsx executes an early warmup:

await authService.session()

This retrieves the existing session and extracts the critical platformSessionId so later stores can access it. If no session is available, it gracefully handles it, logging a warning to Datadog.

3. MobX State & Content Load

Once the main Simo page component (e.g., Simo.tsx, P2PMigration.tsx, or SeamlessMigration.tsx) mounts, it triggers store.initJourney().

First, the ContentStore fetches CMS content bundles so the UI can be populated immediately.

4. First Journey API Call

Second, the application makes its first HTTP request to fetch/create the purchase journey. Under the hood, SimoStore.journeyToStart decides which method on simoService to trigger:

  • Normal Journey: Invokes getSimoJourney which runs a GET request:
    GET /api/digital/v1/simo-purchase/{platformSessionId}/journeys/latest
  • Deep Link / Continuation: If the user came from a deep link, or clicked "Continue Shopping", it invokes createJourney which runs a POST request to initialize a new active journey:
    POST /api/digital/v1/simo-purchase/{platformSessionId}/journeys
    Body: { "journeyType": "NOTSET" } (plus potential query parameters / bodyOptions)
  • Migrations (P2P / Seamless): Runs specific migration journey endpoints to check eligibility and fetch suitable plans.

Headers sent with this request:

  • Accept: application/hal+json
  • Authorization: Bearer <id_token> (Only if authenticated; extracted from {prefix}_id_token)

5. Basket Cookie Creation (Adding to Cart)

When the user selects a plan and advances the journey (via simOnlyService.selectPlan), the backend response updates the session and triggers setting the basketId cookie in the user's browser.

Significance of basketId:

  • It represents the operational ID of the customer's active shopping basket.
  • The server-side prerenderMiddleware actively checks for this cookie. If present, it bypasses the 5-minute server/Redis HTML page cache so the user sees live basket updates dynamically without cache stale issues.

Environment Variables

Server-Side (process.env)

Variable Purpose Example
NODE_ENV Runtime mode production / development
ENVIRONMENT Deployment environment prod1-green, int1-blue
PORT Server port 8000
APP_ENABLE_DEBUG_UTILS Enable debug panel & auth true
AUTH_COOKIE_PREFIX Cookie name prefix eShop-auth
IDM_AUTHORITY_URL OAuth2 authority IDM service URL
AWS_GATEWAY_DAL_URL API Gateway endpoint AWS URL
AWS_GATEWAY_DAL_API_KEY Gateway API key Secret
CDN_DOMAIN CDN for assets https://cdn.vodafone.co.uk
TEALIUM_REINVENT Analytics tag URL Tealium CDN URL
DISABLE_LD_SERVICE_CONNECTION Skip LaunchDarkly true
DISABLE_LOGIN_REDIRECT Skip auth redirect true
PRERENDER_SERVICE_URL Prerender service Internal URL
DEFAULT_COMMITMENT_PERIOD Default plan length 24 Months

Client-Side (window.VFUK.env)

These are injected by the server at runtime into the HTML page — not build-time constants:

FEATURE_FLAGS          // Object of all 45+ flag values
ENVIRONMENT            // 'prod1-green', 'int1-blue', etc.
TRACKING_ENVIRONMENT   // 'prod' or 'dev'
AUTH_COOKIE_PREFIX     // 'eShop-auth'
BUILD_NUMBER           // CI build number
ASSET_URL              // CDN URL for assets
DATA_DOG_RUM_*         // Datadog application/client tokens
USE_DD_BROWSER_LOGS    // Enable browser logging
DD_CLIENT_TOKEN        // Datadog client token

Development Server & AIM Mocking

Dev Server Features

AIM (API Interaction Mock)

AIM records and replays API responses from __mockapi__/ so you can develop without a live backend.

__mockapi__/
├── journey/
│ ├── consumer/ — Consumer acquisition mocks
│ ├── business/ — Business mocks
│ ├── seamlessMigrationPlans/ — Migration mocks
│ ├── phoenixUpgrades/ — Upgrade journey mocks
│ ├── errors/ — Error scenario mocks
│ └── __shared__/ — Shared across journeys
├── CRO/ — CRO experiment mocks
├── default/ — Default GET/POST responses
└── MCPR-price_rise/ — Mid-contract price rise mocks
Path Normalization

AIM normalises both /sim-only/... and /customer-transfer/... prefixes — one set of mocks covers both SIMO and Seamless Migration journeys.

AIM Naming & Matching Conventions

Scenario folders vs generated endpoint files

The top-level folders under __mockapi__/journey/ are human-readable scenario groupings such as consumer, business, phoenixUpgrades, or seamlessMigrationPlans. Inside those folders, AIM-generated mock files are based on normalised request paths and request hashes.

How AIM normalises SIMO paths

hashIgnoredPathPrefix() strips the route prefix for both /sim-only/best-sim-only-deals/api and /customer-transfer/best-sim-only-deals/api. Content-service paths are normalised to a shared /contentful-api-v2-content prefix so one CMS mock can be reused across equivalent routes.

How AIM avoids unstable file names

hashIgnoredPathPatterns() masks UUID-like or journey-specific segments such as platform session IDs, journey IDs, package IDs, plan IDs, and some content-service URLs. That keeps generated file names stable even when the live request path contains changing identifiers.

Example generated style: api-content-service-v2-content-*.json or api-simo-purchase-paym-v2-*/journeys-*/plans-*

How to use it during development

The development server mounts debugPanelApiMiddleware and AIM middleware. The intended workflow is to start the app locally, use the debug panel via ?debug, pick a profile, and let AIM replay the matching mock responses from __mockapi__/.

Tooling (NPS / Vite)

The repo does not wire commands directly by hand in every case. It uses NPS as the high-level script entrypoint, shared Vodafone boilerplate script factories underneath, and Vite as the client build / dev toolchain.

NPS: what it is doing here

package.json scripts such as yarn start call yarn nps start. That delegates to package-scripts.js, which merges shared boilerplate start, build, test, lint, cypress, git, and lock-scan commands into this repo's command surface.

The benefit is consistency across apps. The repo keeps its local customisations, but the common script behavior lives in @vfuk/web-client-boilerplate-configs.

Vite client build: what is special in this repo

The client Vite config is built on top of the Vodafone boilerplate Vite config. This repo then adds aliases, HTTPS dev server settings, runtime env shims, copied Source Web icon assets, CSS module loading for .scss, and an HTML replacement plugin.

  • Base route: derived from server route config
  • Assets: Source Web icons and logos are copied into the build output
  • Dev server: HTTPS on port 8000 by default
  • Boot behavior: in development the Vite config launches the custom Express + Vite server, not just Vite alone
Federated build
  • yarn build:federated runs vite build -c tools/vite/client/vite.federated.config.ts.
  • The build output goes to =build/federated.
  • The input entry is src/client/index.federated.tsx and the emitted SystemJS entry name is vfuk-shop-simo.js.
  • React, ReactDOM, styled-components, single-spa, and Vodafone shell packages are externalised so the host shell can own them.
Babel support for MobX and legacy patterns

The Vite React Babel config enables legacy decorators, loose class properties, optional chaining, runtime transforms, and a MobX-oriented async-to-generator transform so nested MobX flows keep working as expected.

Testing

Jest Unit Tests

Setting Value
Config tools/unitTests/jest/config.js
Environment jsdom
Line coverage 70%
Branch coverage 60%
CSS mocking identity-obj-proxy
Image mocking jest.filemock.js

Test Utilities

import { render, renderWithContext } from '@helpers/testUtils/testUtils'

// Standard render (wraps with SourceProvider + OverlayProvider)
render(<MyComponent />)

// With SimoStore context
const mockStore = { plans: [...], segment: 'consumer', ... }
renderWithContext(<MyComponent />, mockStore)

Cypress E2E Tests

Setting Value
Base URL http://127.0.0.1:8000
Retries (CI) 5
BDD Framework Cucumber preprocessor
Snapshot threshold 1% pixel difference
cypress/e2e/
├── auth/ — Authentication flow tests
├── core/ — Core journey tests (plans, checkout)
└── general/ — General feature tests

Build & Deployment

Standard Build (Vite)

The standard app build uses Vite with React plugin, CSS modules, and path alias resolution.

Federated Build (Micro-Frontend)

# Builds SystemJS bundle for single-spa shell integration
yarn build:federated

# Output: =build/federated/vfuk-shop-simo.js
# Entry: src/client/index.federated.tsx
# Lifecycle exported: bootstrap → mount → unmount
What the federated build actually contains
  • The bundle format is system, so it can be loaded by a SystemJS/single-spa shell.
  • index.federated.tsx wraps the app with the same theme/router/provider stack, then exports single-spa lifecycle methods.
  • routes.federated.tsx only exposes the shell routes intended for federated use, mainly /sim-only/best-sim-only-deals-shell in non-prod contexts.
How to run it in practice
  1. Use yarn start for normal day-to-day local development. That is the main supported developer workflow in this repo.
  2. Use yarn build:federated when you need the shell-consumable artifact.
  3. Consume the emitted file from a shell/system integration environment; this repo does not expose a separate federated dev-server command of its own.
Why the externals matter
  • The host shell is expected to provide react, react-dom, styled-components, single-spa, and certain Vodafone shell packages.
  • That keeps the federated artifact smaller and prevents duplicate framework instances inside the host page.

CI/CD Pipeline

PR Validation
Lint + Test
Build
Cypress
Deploy

Pipeline definitions in cicd/ — build, PR validation, merge, release, and Renovate bot configs.

Renovate

Renovate is the repo's automated dependency-update bot. In this codebase it is configured for Azure DevOps, groups updates into predictable PRs, and deliberately disables certain risky upgrades so dependency maintenance stays reviewable.

Behavior Current Setup
Platform Azure DevOps repository Digital/web-shop-simo
Grouping All dependencies grouped together, all devDependencies grouped together, plus dedicated groups for Source Web, eslint, stylelint, and testing-library
Scheduling Source Web updates are scheduled monthly
Risk controls Major updates for packages such as i18next, @types/react, @types/node, and some Vodafone platform packages are disabled
Merge policy automerge: false; updates must be reviewed
Concurrency At most 5 Renovate PRs at once
Release age Waits 3 days before adopting a release
Ignored paths Skips infra-heavy directories such as cicd, infrastructure, and terraform
Why this matters

Without Renovate, a repo with this many platform and design-system dependencies drifts quickly. With Renovate, updates arrive in smaller batches, are easier to review, and can be coordinated with test and release windows.

Observability

Datadog logging

How browser logging is enabled
  • The logger service name is web-shop-simo.
  • The browser logger is only fully enabled when both USE_DD_BROWSER_LOGS is true and the feature flag showDDBrowserLogs is enabled.
  • Even when Datadog shipping is disabled, the logger still writes to the console in development/Cypress to aid debugging.
What gets sent
  • BaseLogger initialises the browser log service and sets placeholder user data to avoid leaking PII.
  • Each log call includes a message, a component/source name, optional context, and an error object.
  • DD_LOG_MAP turns internal error keys such as GET_OTB into readable messages such as Get OTB has errored.
  • filteredLogs suppresses noisy generic messages such as Script error..
Example from app bootstrap
try {
  await to(authService.session())
} catch (e) {
  logger.error(
    DD_LOG_MAP[DD_ERRORS.SESSION_UNAVAILABLE],
    'src/client/index',
    {},
    e,
  )
}
  • The readable message is Session not available.
  • The component/source is src/client/index.
  • The final argument is the caught error object.

Log Categories

Tealium Analytics

OneTrust

Consent management scripts injected before all other third-party scripts. Controls what tracking is allowed.

File Structure

src/
├── client/
│ ├── index.tsx — App bootstrap (providers, auth, flags)
│ ├── routes.tsx — Route definitions
│ ├── contexts.tsx — SimoContext creation
│ ├── pages/ — Page-level components (Simo, SeamlessMigration, etc.)
│ ├── templates/ — 14 page layout templates
│ ├── components/ — atoms, molecules, organisms, CRO
│ ├── stores/ — 18 MobX stores + root SimoStore
│ ├── services/ — API services (journey, simOnly, content)
│ ├── helpers/ — Utility functions (getSession, parseHateoas, etc.)
│ ├── constants/ — Enums, URLs, HATEOAS link names
│ ├── types/ — TypeScript type definitions
│ ├── analytics/ — Tealium event config (70+ events)
│ ├── config/ — i18n, app configuration
│ └── styles/ — Global styles
├── server/
│ ├── production/ — Production Express server
│ ├── development/ — Dev server with Vite + AIM
│ └── common/
│ ├── config/ — Server configs (IDM, DXL, flags, env vars)
│ ├── middleware/ — All middleware (auth, proxy, cache, flags)
│ └── helpers/ — Server utility functions
├── common/ — Shared config between client and server
└── utils/
└── datadog/ — Logger implementations

__mockapi__/ — AIM mock responses
cypress/ — E2E test suites
tools/
├── vite/ — Vite build configs (standard + federated)
├── unitTests/ — Jest configuration
└── scripts/ — Build/utility scripts

Path Aliases

These aliases are configured in three places that must stay in sync: tsconfig.json, babel.config.js, and tools/vite/.

Alias Resolves To
@analytics/* src/client/analytics/*
@stores/* src/client/stores/*
@services/* src/client/services/*
@atoms/* src/client/components/atoms/*
@molecules/* src/client/components/molecules/*
@organisms/* src/client/components/organisms/*
@CRO/* src/client/components/CRO/*
@templates/* src/client/templates/*
@helpers/* src/client/helpers/*
@constants src/client/constants
@contexts src/client/contexts
@typings/* src/client/types/*
@datadogLoggers/* src/utils/datadog/loggers/*
@server/* src/server/*
@common/* src/common/*

Constants & Globals

Journey Types

const JOURNEY_TYPES = {
  ACQUISITION: 'acquisition',
  UPGRADE: 'upgrade',
  SECONDLINE: 'secondline',
  TARIFF_MIGRATION: 'tariffMigration',
  P2P: 'p2p',
}
// Plus: journeySubType = 'seamlessMigration'

Segments

'consumer' | 'business'

Key URLs

URLS = {
  SIMO: '/sim-only/best-sim-only-deals',
  BUSINESS_SIMO: '/business/business-sim-only',
  CUSTOMER_TRANSFER: '/customer-transfer/best-sim-only-deals',
  UPGRADES_AND_OFFERS: '/upgrade-and-offers',
  LOGIN: '/web-shop/login',
  LOGOUT: '/web-shop/logout',
  BASKET: '/basket',
}

API Constants

BASE_URL = '/api/'
HAL_JSON = 'application/hal+json'
MICROSERVICE_TYPE = 'simo-purchase'
DXL_CHANNEL = 'eShop-simo'

Mismatch / Error Codes

SESSION_EXPIRED_LOGIN_OR_EMPTY_BASKET
NOT_ELIGIBLE_SEGMENT_DEEPLINK
LMS_DOWN_DEEPLINK
RECOMMENDED_PLAN_NOTIFICATION
LOST_SMARTWATCH_BENEFIT

Key Packages

UI & Design System

Package Purpose
@source-web/theme-ws10 Vodafone WS10 design system theme
@source-web/source-provider Theme + i18n provider wrapper
styled-components CSS-in-JS styling
100+ @source-web/* Buttons, cards, modals, inputs, carousels, etc.

State & Data

Package Purpose
mobx Observable state management
constate Lightweight context state
axios HTTP client for API calls
await-to-js Promise error handling without try/catch
lodash Utility functions

Auth & Session

Package Purpose
@vfuk/utils-shop-auth-service Auth session management
@vfuk/web-middleware-idm IDM OAuth2 middleware
@vfuk/web-middleware-login-redirect Auth redirect logic
@vfuk/web-middleware-dxl-proxy API gateway proxy

Observability

Package Purpose
@datadog/browser-logs Browser-side log collection
@vfuk/lib-web-analytics Tealium analytics integration
@vfuk/lib-web-feature-flagging LaunchDarkly client wrapper

Content & i18n

Package Purpose
@contentful/rich-text-react-renderer CMS rich text rendering
i18next + react-i18next Internationalization framework
@source-web/language-packs English translations

Build & Testing

Package Purpose
vite Build tool and dev server
jest Unit testing framework
@testing-library/react Component testing utilities
cypress End-to-end testing
cypress-cucumber-preprocessor BDD-style test writing

Journey Latest & Commitment Period

Who controls whether 12-month or 24-month plans are shown on initial load?

Backend owns the initial commitment period

The frontend does not send a commitmentPeriod in the Journey Latest call. The backend decides what commitment period to use and encodes it inside the HATEOAS GET_PLANS link it returns. The frontend follows that link verbatim.

Flow

Journey Latest call
/api/simo-purchase/{billingType}/v2/{sessionId}/journeys/latest?segment={type}
Backend response
_links.GET_PLANS.href (already contains commitmentPeriod=24+Months)
FE follows link
simOnlyService.getPlans()

The one frontend override: Basic Upgrade

In simOnlyService.ts, when the journey is detected as a basic upgrade (isBasicUpgrade), the frontend rewrites the link before following it, replacing 24+Months with 12+Months:

// src/client/services/simOnlyService/simOnlyService.ts
href: plansLink.href.replace('24+Months', '12+Months'),
parameters: { ...plansLink.parameters, commitmentPeriod: '12 Months' },
queryParameters: { ...plansLink.queryParameters, commitmentPeriod: '12 Months' },

All other commitment-period changes (user switching from 12 to 24 months via filter tabs) go through FilterStore, which rebuilds the GET_PLANS query string and re-requests plans from the backend. The frontend never hard-codes an initial value independently.

3onv consideration

No action needed here for 3onv

Because the backend drives the initial commitment period via HATEOAS links, a 3onv brand should receive the correct default from the backend without any frontend changes — provided the backend returns the right GET_PLANS link for that brand.

Backend ID → CMS Content Map 3onv Risk

This is the core migration risk for 3onv. Several pieces of visible content on the plan card and plan details modal are resolved by matching a backend-supplied identifier against a Contentful entry key. If the backend sends different IDs for 3onv, those lookups silently return nothing and the content disappears.

Silent failures

There are no hard errors when an ID does not match a CMS entry. The benefit item, icon, or modal body simply does not render. This makes ID mismatches easy to miss in testing.

Full inventory of backend-to-CMS bindings

Surface Backend field used as key CMS entry key pattern How the match works File
Plan card benefit icons (Highlands) inclusiveProduct.id (numeric string) benefit_item_{id} Regex extracts first number from Contentful key; matched against inclusiveProduct.id. Icon, text, and tags come from the matched CMS entry. mapHighlandsContentfulBenefits.ts
Plan card benefit icons (non-Highlands) benefitItem.id / key suffix benefit_item_{productId} Key constructed as benefit_item_{productId} and looked up in CMS benefit entries. Enriches display name and tags. orderAndEnrichBenefitItems.ts
Plan details modal — "What's included" tab plan.type + plan.subType packagelistsimo_{subtype}
devicedetailshandset_{airtimePlanType}_{band}
getContentForPlan() maps plan.subType via SUBTYPES constant to a CMS slug. Also maps plan.upsellBand + plan.planBand + plan.type to a band number (1–3), then constructs the handset key. getContentForPlan/index.ts, getPlanDetailsHelper/index.ts
Plan details modal — other tabs (what you need to know, additional charges, about speed) None — tab content is fetched by contentEntryKey and contentType e.g. simo_plan_benefits_details_content Shared/static CMS keys not derived from per-plan backend data. These are the same for all plans on a given journey and are not at risk from ID changes. ContentStore.ts
Highlands dynamic benefit modal content inclusiveProduct.id + contentEntryKey Dynamic — fetched per benefit on click ContentStore.getHighlandsDynamicContentByHref() fetches per-benefit modal content. The request uses the product's href + CMS entry key. Cached in highlandsModalContent. ContentStore.ts
OTB (Options to Buy) content journey.otbData (product IDs from backend) OTB-specific CMS entries OTB product data from the backend journey response drives what cross-sell content is shown. OptionsToBuyStore

Type and subtype constants

The SUBTYPES constant in src/client/stores/helpers/getPlanDetailsContent/constants.ts maps backend plan.subType strings to CMS slug fragments. If 3onv sends different subtype strings, these mappings will fail to resolve.

// constants.ts (representative — check file for full list)
SUBTYPES = {
  'basics plan': 'basics',
  'red plan':    'red',
  // ... other subtypes
}

Band mapping for handset/device details

getUpsellBand() combines plan.upsellBand, plan.planBand, plan.type, and plan.subType to produce a band number (1, 2, or 3). This is combined with GB/UNLTD to build a CMS key like devicedetailshandset_gb_2. If band values change for 3onv, the wrong (or no) handset details will render.

3onv migration options

Option A — Backend sends 3onv IDs that match existing CMS keys

The 3onv backend maps its product IDs to match existing Contentful keys. No frontend change needed. Risk: tight coupling between two systems; harder to manage long-term.

Option B — Frontend brand-aware lookup

Pass a brand identifier (e.g. brand: 'three') from the server into window.VFUK.env. CMS lookup helpers read the brand and fetch from brand-specific Contentful entries. Similar to the existing isBusiness segment split.

Option C — Contentful multi-brand entries

Add a brand field to benefit items and plan detail entries in Contentful. The lookup helpers filter by brand after fetching. No backend change; CMS work required.

Plan Card Benefit Items

Benefit items shown on a plan card come from the backend inclusiveProducts array in the plans response, but the display content (icon, label, tags, modal body) comes from Contentful. The two are joined by ID matching at render time.

Backend fields involved

Field Where Used for
plan.inclusiveProducts[] Plans API response Source of truth for which benefits exist on a plan (Highlands path)
inclusiveProduct.id Each item in inclusiveProducts Primary key for CMS lookup — matched against benefit_item_{id} Contentful key
inclusiveProduct.name / .planBenefit Each item Fallback display text if CMS has no text for this benefit
plan.benefitItems[] Plans API response Used in the non-Highlands path as the enrichment source

Highlands benefit rendering path

plan.inclusiveProducts
Build map keyed by product.id
Iterate Contentful benefit entries
Extract numeric ID from CMS key
benefit_item_456 → "456"
Match to inclusiveProducts["456"]
Merge: backend data + CMS icon/text/tags

Benefit tags control visibility

Tags on the Contentful benefit entry determine where/how the benefit appears:

Non-Highlands path

Uses orderAndEnrichBenefitItems(). Starts from plan.benefitItems and enriches each item with CMS data by constructing the key benefit_item_{productId}. Tags and display order come from Contentful.

3onv risk

If 3onv inclusiveProduct.id values differ from the IDs referenced in the Contentful entry keys, no CMS icon or text will load. Benefits will either render with fallback text only (from product.name) or not render at all depending on tag filtering.

getContentForPlan Helper

File: src/client/stores/helpers/getPlanDetailsContent/helpers/getContentForPlan/index.ts

This is the single function that resolves which CMS content block renders inside the plan details modal for a given plan and tab. It is called once per tab render inside PlanCardDetailsModal.

Inputs

ParameterDescription
tabActive tab: 'whatsincluded' | 'whatyouneedtoknow' | 'additionalcharges' | 'aboutspeed' | 'deviceDetailsHandset'
planThe backend plan object. Key fields read: type, subType, upsellBand, planBand
planDetails.sharedPlanDetailsCMS overlay content loaded once — contains the shared tab blocks (charges, speed, need to know)
planDetails.pageSpecificPlanDetailsCMS shop-page content for the current segment (consumer or business). Contains per-subtype entries[]
customerSegmentTypeCmsKey'consumer' or 'soletrader' — selects segment-variant CMS benefit content

What is pageSpecificPlanDetails.fields.entries?

pageSpecificPlanDetails is the Contentful shopPage for the current segment. It is loaded by ContentStore.loadPlanDetailsContent() using:

// ContentStore.ts
contentServiceV2.getAssetModelV2({
  contentEntryKey: 'packagelistsimo_consumer',  // or 'packagelistsimo_business'
  contentType: 'shopPage',
})

The Contentful shopPage contains a list of child entries (fields.entries). Each entry has a contentEntryKey that the frontend uses as a lookup key. Examples:

contentEntryKey exampleWhat it contains
packagelistsimo_unlimited51"What's included" content for the Unlimited 51 subtype
packagelistsimo_red51"What's included" content for Red 51 subtype
packagelistsimo_basicsplan"What's included" content for Basics Plan subtype
packagelistsimo_essentialsFallback "essentials" content — shown when no subtype match found
devicedetailshandset_gb_1Device details for GB airtime band 1 plans
devicedetailshandset_genericGeneric fallback device/handset details block

findByContentEntryKey(entries, key) simply does an array .find() on these entries matching on contentEntryKey. It returns the first match or undefined.

Internal helper functions (all defined in-file, not exported)

These are const functions defined at the top of getContentForPlan/index.ts. They are not exported or callable from outside this file.

getUpsellBand(upsellBand, planBand, type, subType) — inline const

Maps the plan's band fields to a band number (1, 2, or 3) combined with an airtime type (gb or unltd). Uses the BANDS_HANDSET_MAPPING constant which maps band name strings (e.g. 'gold', 'silver', 'bronze') to numbers.

// Returns one of: 'gb_1' | 'gb_2' | 'gb_3' | 'unltd_1' | 'unltd_2' | 'unltd_3' | 'generic'
// The result is appended to 'devicedetailshandset_' to form the CMS lookup key

getAirtimePlanType(type, subType) (also inline) returns 'GB' if type === 'red' or subType includes 'red'; otherwise returns 'UNLTD'.

hasAirtimeBenefitsContent(plan, planContentList) — inline const

Calls getUpsellBand() to get a band key, then uses findByContentEntryKey() to check whether a Contentful entry exists for devicedetailshandset_{band} or devicedetailshandset_generic. Returns true if an entry is found — meaning this plan should use the airtime/device-details path rather than the subtype path.

Decision flow for "What's Included" tab

1. Check if plan has airtime/device-details content

hasAirtimeBenefitsContent(plan, sharedPlanDetails) — looks for a devicedetailshandset_* entry in the shared CMS data for this plan's band.

2a. Airtime path (has device details)

Calls getPlanBenefits(plan, planContent, customerSegmentTypeCmsKey) to get segment-specific benefit content. Then calls getPlanSubType(plan.subType) to normalise the subtype string and constructs:

`devicedetailshandset_${getPlanSubType(plan.subType)}`.toLowerCase()
// e.g. 'devicedetailshandset_unlimited51'

Finds this key in pageSpecificPlanDetails.fields.entries via findByContentEntryKey(). Passes result to getPlanDetailsWhatsIncludedTab().

2b. Subtype-only path (no airtime device details)

Calls getPlanSubType(plan.subType), looks up in SUBTYPES constant, then searches pageSpecificPlanDetails.fields.entries for key packagelistsimo_{subtype}.

3. Fallback — neither path matched

Hardcoded fallback: searches for 'packagelistsimo_essentials' in pageSpecificPlanDetails.fields.entries. Shows generic content. No error is thrown.

getPlanSubType — what it does

File: src/client/stores/helpers/getPlanDetailsContent/helpers/getPlanSubType/index.ts

getPlanSubType("Unlimited51")
// 1. Not null
// 2. Doesn't include 'simx' → no SIMX stripping
// 3. Not in ['unlimitedMax51','unlimitedMax'] → no alias
// 4. Not in entertainment alias list
// 5. planSubType = "unlimited51" (lowercased)
// 6. SUBTYPES["unlimited51"] = "unlimited51"  ← match found
// → returns "unlimited51"

getPlanSubType("3onv Plan")
// → planSubType = "3onv plan"
// → SUBTYPES["3onv plan"] = undefined
// → undefined?.toLowerCase() = undefined
// → CMS key becomes "devicedetailshandset_undefined" → no match → fallback to essentials
Key rule for 3onv

The lookup is case-insensitive (.toLowerCase() applied before lookup), but the value in the SUBTYPES key must match the backend plan.subType string exactly after lowercasing. See the Subtype Reference for all known values.

Subtype Reference

File: src/client/stores/helpers/getPlanDetailsContent/constants.tsSUBTYPES object. These are all the plan.subType values the frontend currently recognises. The key is what the backend sends (lowercased), the value is the slug used in the Contentful entry key.

These are Vodafone-specific and will need updating for 3onv

Any plan.subType not in this table will silently fall back to the packagelistsimo_essentials CMS block. Matching is case-insensitive.

Backend plan.subType (lowercased)Contentful slug valueCMS entry key patternNotes
red51red51packagelistsimo_red51
unlimited51unlimited51packagelistsimo_unlimited51
unlimitedentertainment51unlimitedEntertainment51packagelistsimo_unlimitedentertainment51
redentertainment51redEntertainment51packagelistsimo_redentertainment51
unlimited81unlimited81packagelistsimo_unlimited81
unlimitedmax81unlimitedMax81packagelistsimo_unlimitedmax81
unlimitedentertainment81unlimitedEntertainment81packagelistsimo_unlimitedentertainment81
unlimitedmaxentertainment81unlimitedMaxEntertainment81packagelistsimo_unlimitedmaxentertainment81
unlimited83unlimited81packagelistsimo_unlimited81Roaming variant — maps to 81 content
unlimitedmax83unlimitedMax81packagelistsimo_unlimitedmax81Roaming variant — maps to Max81
unlimitedentertainment83unlimitedEntertainment81packagelistsimo_unlimitedentertainment81Roaming variant
unlimitedmaxentertainment83unlimitedMaxEntertainment81packagelistsimo_unlimitedmaxentertainment81Roaming variant
unlimited100mbpsunlimited100mbpspackagelistsimo_unlimited100mbpsSpeed unlimited plus
unlimited100mbps51unlimited100mbps51packagelistsimo_unlimited100mbps51
unlimited100mbps83unlimited100mbps83packagelistsimo_unlimited100mbps83
unlimitedentertainment100mbpsunlimitedEntertainment100mbpspackagelistsimo_unlimitedentertainment100mbps
unlimitedentertainment100mbps51unlimitedEntertainment100mbps51packagelistsimo_unlimitedentertainment100mbps51
unlimitedentertainment100mbps83unlimitedEntertainment100mbps83packagelistsimo_unlimitedentertainment100mbps83
redredpackagelistsimo_red
red83red83packagelistsimo_red83
redentertainment83redEntertainment83packagelistsimo_redentertainment83
redentertainmentredEntertainmentpackagelistsimo_redentertainment
unlimitedunlimitedpackagelistsimo_unlimited
unlimitedentertainmentunlimitedEntertainmentpackagelistsimo_unlimitedentertainment
unlimitedmaxunlimitedMaxpackagelistsimo_unlimitedmax
unlimitedmaxentertainmentunlimitedMaxEntertainmentpackagelistsimo_unlimitedmaxentertainment
basics planbasicsplanpackagelistsimo_basicsplanNote: key has a space; matches after .toLowerCase()
anything elseundefinedFalls back to packagelistsimo_essentialsSilent fallback — no error

There are also two alias mappings handled in getPlanSubType() before the table lookup:

Plans Grid Page — Everything Except the Plan Cards

File: src/client/templates/SimoPlansTemplate/SimoPlansTemplate.tsx

SimoPlansTemplate is the top-level template for the plans grid page. Most of the file is not about rendering plan cards at all — renderPlanListContent() (which renders <PlanListContent> containing the actual grid of <PlanCard>s) is a single function call. Everything else in the template is surrounding content: headers, notifications, banners, filters, and — at the very bottom — a fully CMS-driven marketing content zone rendered via <MarketingComponent>.

Page layout, top to bottom

OrderElementCondition to renderContent source
1Main heading!aomRecommendedBundlePlans?.lengthcontentStore.getMainHeader()
2<AcceptableUsePolicy>Same as aboveStatic / CMS copy
3<ComparisonTable> (CRO)isCroComparisonTableEnabled + business + 24mo + acquisitionCMS
4<UspHeader>Not tariff migration, no AOM bundlesCMS
5Sub-header copySame as abovecontent.mainCopy (raw HTML)
6<TariffMigration>Self-guards on isTariffMigration — see Tariff MigrationHardcoded strings
7Signposting notificationcontentStore.signpostingContent existsCMS
8<SubscriptionSummary>Plans page + not tariff migrationStore-derived
9<SimXNotificationBanner>Flag + SIMX plan availableCMS
10<DiscountBanner>store.isDiscountBannerVisibleLoyalty data
11<MidContractRise> (above AOM carousel)Flag + AOM bundles presentCMS
12<AomRecommendedPlans> carouselAOM bundle plans presentStore + CMS
13Filters (<PlanFilterWrapper>, mobile, pills, list)Various flagsStore
14<FamilyDiscountBanner>, <TrendingPlans>Various flagsCMS
15The plan grid itselfvisiblePlans.lengthrenderPlanListContent()<PlanListContent>
16<NoPlansFound> / <NoPlansScenario>No plans match filtersCMS
175G Ultra / 3G shutdown notificationsFlags + CMS content presentCMS
18<ScrollButton> (back to top)isBackToTopButtonEnabled-
19<SecureNetModal>, <GenericBenefitItemBottomTray>, <FamilyLearnMorePopup>Self-guarding on store stateCMS
20<MarketingComponent>Always rendered; content array may be emptycontentStore.getPostBodyMarketingContent()
The bottom marketing zone is the most CMS-flexible part of the page

Every other section above is a specific hardcoded component reading a specific CMS field. The <MarketingComponent> at the very bottom of the page is different: it is a generic renderer that can display any number of arbitrary Contentful entries, each dispatched to a different component based on its Contentful content type. This is where the FAQ accordion lives — see below.

How <MarketingComponent> works

File: src/client/components/molecules/MarketingComponent/MarketingComponent.tsx

<Container paddingLevel={0} appearance='secondary'>
  <MarketingComponent content={contentStore.getPostBodyMarketingContent()} />
</Container>

It receives an array of Contentful entries. For each entry, it reads item.sys.contentType.sys.id — the Contentful content type slug — and dispatches to the matching mapper component:

Contentful content type IDRendered byTypical use
standardBannerStandardBannerMapPromotional image + CTA banner
partnerBannerPartnerBannerMapThird-party partner promo (e.g. streaming)
partnerBannerApplePartnerBannerAppleMapApple-specific partner promo layout
advertAdvertMapSimple advert block
contentBlockContentBlockMapHeading + rich text content block
accordionAccordionMapFAQ sections — collapsible Q&A list
iconSnippetListIconSnippetMap"Why choose Vodafone?" icon+text grid

Any content type not in this map is silently skipped (returns null). This means editors can add new entries in Contentful without a deploy, as long as the content type is already supported here.

Where the array comes from: getPostBodyMarketingContent()

File: src/client/stores/ContentStore/ContentStore.ts

getPostBodyMarketingContent = () => {
  const journeyType = this.simoStore.journeyType
  const segment = this.simoStore.segment

  return this.content.postBodyMarketingContent.filter(({ fields: { tags } }) => {
    const checks = [tags ? tags.includes(MARKETING_SLOT_TAG_NAMES.SLOT_3) : false]
    return shouldShowContentByTag(checks, tags || [], segment, journeyType)
  })
}

this.content.postBodyMarketingContent is populated once, during mapPageContent(), by reading straight off the raw Contentful shop page response:

postBodyMarketingContent: get(
  pageContent,
  `${CONTENTFUL_PATH_CONFIG.basePath}.${CONTENTFUL_PATH_CONFIG.postBodyMarketingContent}`,
  [],
)
// CONTENTFUL_PATH_CONFIG.basePath = 'body.items'
// CONTENTFUL_PATH_CONFIG.postBodyMarketingContent = 'fields.postBodyMarketingContent'
// → reads pageContent.body.items.fields.postBodyMarketingContent

So the CMS field is fetched once for the whole page and holds every marketing entry regardless of journey or segment. getPostBodyMarketingContent() is what filters that raw list down to only the entries relevant to the current visitor, using the tag-matching helper shouldShowContentByTag().

The tag-matching gate: shouldShowContentByTag()

File: src/client/stores/helpers/shouldShowContentByTag/shouldShowContentByTag.ts

const shouldShowContentByTag = (checks, tags, customerSegmentCmsKey, customerJourneyTypeCmsKey) => {
  if (!customerJourneyTypeCmsKey) return false          // no journeyType → never show

  checks.push(tags.includes(customerJourneyTypeCmsKey)) // must match journeyType tag
  if (customerSegmentCmsKey) {
    checks.push(tags.includes(customerSegmentCmsKey))   // must match segment tag too, if provided
  }

  return Boolean(checks.every((c) => !!c))              // ALL checks must be true
}

This is an AND gate. For a marketing entry to appear on the page, its Contentful tags array must contain all three:

Real example from the CMS mock

From __mockapi__/journey/consumer, postBodyMarketingContent currently contains 5 entries:

[
  { contentType: 'iconSnippetList', tags: ['slot3', 'consumer', 'acquisition', 'upgrade', 'secondline'] },
  { contentType: 'contentBlock',    tags: ['consumer', 'acquisition', 'secondline', 'upgrade', 'slot3'] },
  { contentType: 'contentBlock',    tags: ['consumer', 'acquisition', 'secondline', 'upgrade', 'slot3'] },
  { contentType: 'accordion',       tags: ['slot3', 'consumer', 'acquisition', 'secondline', 'upgrade'] },  // ← FAQ
  { contentType: 'accordion',       tags: ['slot3', 'consumer', 'acquisition', 'upgrade', 'secondline'] },  // ← FAQ
]

For a consumer on the acquisition journey, all five tags match (slot3 + consumer + acquisition), so all five render, in array order: icon snippet grid, two content blocks, then two accordions (FAQ sections) at the very bottom of the page.

The FAQ Accordion Below the Plan Grid

File: src/client/stores/helpers/mappers/AccordionMap/index.tsx — rendered via <MarketingComponent>, see above.

The "Frequently asked questions" block visible at the bottom of the plans page is not a bespoke FAQ component. It is a Contentful entry of type accordion inside postBodyMarketingContent, rendered generically by AccordionMap — the same mapper used for any other accordion content anywhere else it might be reused on the page.

Contentful shape

{
  "sys": { "contentType": { "sys": { "id": "accordion" } } },
  "fields": {
    "sectionHeading": "Frequently asked questions",
    "sectionHeadingLevel": 2,
    "theme": "Light",                 // or "Dark" → maps to appearance
    "singleOpen": false,               // true = only one segment open at a time
    "initiallyOpenId": null,           // optionally pre-open one segment by heading text
    "accordionSegments": [
      {
        "fields": {
          "headingText": "What is a SIM only deal?",
          "bodyText": { /* Contentful rich text document */ }
        }
      },
      { "fields": { "headingText": "What's the best place to find cheap SIM only deals?", "bodyText": { ... } } }
      // …15 segments in the current mock
    ],
    "tags": ["slot3", "consumer", "acquisition", "secondline", "upgrade"]
  }
}

Rendering pipeline

Contentful entry
contentType.sys.id === 'accordion'
MarketingComponent
dispatches to AccordionMap
AccordionMap
destructures theme, singleOpen, initiallyOpenId, accordionSegments
Wraps in <ContentBlockWrapper fields={content.fields}>
renders sectionHeading as the block title
Maps each segment to a
<CollapsibleContainer>
Header = headingText
Body = <ContentfulRichText document={bodyText} />

AccordionMap source

const AccordionMap = ({ content }) => {
  const { theme, singleOpen, initiallyOpenId, accordionSegments } = content.fields
  if (!accordionSegments.length) return null

  const props = {
    appearance: theme === 'Dark' ? 'secondary' : 'primary',
    multi: !singleOpen || undefined,
    initiallyOpenIds: initiallyOpenId ? [initiallyOpenId] : undefined,
  }

  return (
    <ContentBlockWrapper fields={content.fields}>
      <Accordion {...props}>
        {accordionSegments.map(({ fields: { headingText, bodyText } }, index) => (
          <CollapsibleContainer key={index} id={headingText}>
            <CollapsibleContainerHeader>{headingText}</CollapsibleContainerHeader>
            <CollapsibleContainerBody>
              {bodyText && <ContentfulRichText document={bodyText} />}
            </CollapsibleContainerBody>
          </CollapsibleContainer>
        ))}
      </Accordion>
    </ContentBlockWrapper>
  )
}

Why sectionHeading renders even though AccordionMap doesn't destructure it

AccordionMap passes the entire content.fields object into <ContentBlockWrapper> as a prop, not just the accordion-specific fields it uses itself. ContentBlockWrapper (file: src/client/stores/helpers/mappers/ContentBlockWrapperMap/index.tsx) is a shared wrapper used by several mapper components (ContentBlockMap, AccordionMap, etc). It independently reads sectionHeading, sectionBodyText, and footnote off those same fields:

const shouldRenderSimpleBlockWrapper = ({ sectionHeading, sectionBodyText, footnote }) =>
  !sectionHeading && !sectionBodyText && !footnote

// If any of the three exist, wraps children in a 
// with heading = { text: sectionHeading, level: sectionHeadingLevel }
// Otherwise falls back to a plain  with no heading chrome

Because the FAQ entry has sectionHeading: "Frequently asked questions", it renders the heading via FunctionalContentBlock above the accordion segments — this is why the heading text and the accordion body appear to come from a single component, but are actually two separate concerns (wrapper heading vs. accordion body) reading the same flat fields object.

Multiple FAQ blocks on one page

Because postBodyMarketingContent is an array, there is no restriction on having more than one accordion entry. The current consumer mock has two — likely one for general SIM-only FAQs and one for a more specific topic. MarketingComponent renders every matching entry in the array order returned by Contentful, so editors control ordering entirely from the CMS without a frontend change.

To change or add FAQ content

No code change is required. Editors update the accordion entry's accordionSegments in Contentful under the shop page's postBodyMarketingContent field. The entry must carry the slot3 tag plus the relevant journeyType and segment tags for it to appear — see tag matching above. To add a brand-new FAQ block (e.g. for 3onv), create a new accordion entry with its own tag combination.

PlanCard Anatomy — The Live BAU Card, Region by Region

This section documents the live, default (non-Highlands, non-CRO) plan card — the one every visitor sees today. It covers PlanCard.tsx and every child it renders, region by region, stating for each piece of visible content whether it is backend-driven, CMS/Contentful-driven, or a local hardcoded constant, plus exactly where in the codebase that value comes from.

Root file: src/client/components/molecules/PlanCard/PlanCard.tsx. Excludes PlanCardEntertainment and PlanCardSimplified (legacy CRO variants — see the CRO section) and the Highlands modal path (see Highlands Overview) — both are alternate rendering paths gated by feature flags, not part of the default card.

This mockup is static HTML/CSS for illustration only

The card below is a hand-built replica matching a design reference, annotated with numbered pins. It is not rendered by React or driven by any store — it exists purely so each visual region can be pinned to a row in the table underneath. Two pins (3 and 5) are orange because, after tracing the code, those two elements could not be matched to anything currently rendered by the live PlanCard — see the notes under their table rows.

1Save £240
25G
3Standard
4Unlimited + Entertainment
5Speed: Maximum download of 100 Mbps
Data
6Unlimited
Minutes & Texts
7Unlimited
Monthly
8£33
9Was £43
10£35.50 on 1 April 2027
£38 on 1 April 2028
11Choose plan
12Free 3-month Secure Net trial
13Streaming of your choice
or
14Your choice of entertainment for 24 Months
or
15500 international minutes to EU
16See full plan details →

Master legend — every numbered region

#ElementSourceWhere the value comes fromComponent / Constants file
1 "Save £240" ribbon Backend primaryPromotion.name → mapped to plan.promoText by the external plansCardMapper package, then passed into <CardBuilder container.label> PlanCard.tsx (container.label.text)
2 5G badge icon Backend + local logic plan.badgeURL string is pattern-matched (5gultra_icon / 5g-plus-icon) to pick icon name 5g, 5g-ultra, or 5g-plus getBadgeIconName() in PlanCard/helpers/PlanCardHelpers.ts
3 "Standard" label Unresolved No string literal "Standard" exists anywhere in src/client/. The only "prefix" slot on the heading (getHeadingInfoSlotProps.ts) is only populated when showAomPlanTenure is true, and renders values from AOM_TENURE_TEXT (e.g. "24 month plan") — not "Standard". This element could not be traced to current live code. — (flag for confirmation)
4 "Unlimited + Entertainment" heading Backend plan.planNameInfo.heading.name getHeadingInfoSlotProps()<HeadingInfoSlot>
5 "Speed: Maximum download of 100 Mbps" Unresolved Simo.DataInfo only has prefix/main — no speed suffix field. The external mapper's benefit-building logic explicitly filters out any benefit whose name starts with "Speed:". Two feature flag keys exist — SpeedInDescription (web-shop-simo-show-speed-in-description-enabled) and plansDetailsInData — in featureFlags.config.ts, but neither has any reference anywhere else in the frontend. Likely dead/future flags; this line is not rendered by current live code. — (flag for confirmation)
6 Data value ("Unlimited") Backend plan.dataInfo.main = raw backend data field. The "Data" label itself is a hardcoded prefix string inside the external mapper package, not CMS. TextStackSlot primaryTextStack in PlanCard.tsx; mapper: @vfuk/utils-shop-plans-card-mapper
7 Minutes & Texts value ("Unlimited") Backend (derived) Computed by scanning plan.inclusiveProducts[] for an item whose planBenefit contains "minutes & texts", then stripping the word "Unlimited" prefix text down to just "Unlimited". The "Minutes & Texts" label is hardcoded in the mapper. @vfuk/utils-shop-plans-card-mapper (internal getMinutesText-style logic)
8 Price ("£33") Backend plan.priceInfo.text.main, computed from monthlyPrice.gross (consumer) or .net (business), formatted via formatCurrency() getPlanPrice() in @vfuk/utils-shop-plans-card-mapper
9 "Was £43" savings text Backend plan.savingsAppliedBanner — a raw string sent directly by the backend. When present, it overrides the mapper's computed discount suffix (which would otherwise read something like "Was £43" built from primaryPromotion.priceEstablishedLabel) PlanCard.tsxhasSavingsApplied ternary on the price suffix
10 Price rise lines ("£35.50 on 1 April 2027"…) Backend plan.mcpr.priceRise[] — array of { labelText, monthlyPrice } objects from the backend, one entry per future price change formatPriceRises() in src/client/helpers/formatPriceRises/
11 "Choose plan" CTA button text Local / hardcoded Button text strings ("Choose plan", "Selected", "Switch Plan", "Continue to entertainment", "Choose additional plan") are hardcoded in JS, chosen based on journeyType, whether a plan is already selected, and feature flags — not sourced from CMS setButtonName() / handleJourneyType() in PlanCardHelpers.ts
12 "Free 3-month Secure Net trial" CMS (with backend gate) Text defaults to the hardcoded constant but is overridden if a matching Contentful entry exists. Contentful key: benefit_item_secure_net_trial (inside planCardBenefitItemContent.fields.entries). Only injected at all when store.isSecureNetEnabled (LD flag) is true. Constant: SECURE_NET_BENEFIT_ITEM in constants.ts; logic: generateSecureNetBenefitItem.ts
13 "Streaming of your choice" + logo image Backend Text: benefit item with productId === '3' (PLAN_BENEFIT_IDS.ENTERTAINMENT_BENEFIT_ID), text from inclusiveProducts[].name/.planBenefit or CMS fallback (bingoBenefits). Image: the "Disney+ or Prime" graphic is one single image asset, not two icons — URL from plan.entertainmentPromotion.mediaUrl. EntertainmentBenefitItem.tsx + EntertainmentImageSlot.tsx
14 "Your choice of entertainment for 24 Months" + logo image Backend Same pipeline as #13, but for productId === '106194' (PLAN_BENEFIT_IDS.BUNDLED_ENTERTAINMENT_BENEFIT_ID — internally called the "Phoenix" streaming benefit) EntertainmentBenefitItem.tsx; constant in constants.ts
15 "500 international minutes to EU" (plain text) Backend A plain inclusiveProducts[] item with a name or planBenefit string and no _links['get-contentful'] — renders as non-clickable, non-underlined plain text (no CMS enrichment needed since the backend supplies display text directly) BenefitItem.tsx — plain heading.text branch (no link)
16 "See full plan details →" link Not currently live This entire <InteractionSlot linkWithIcon> block is commented out in the current PlanCard.tsx (wrapped in a JSX comment). It is not rendered by the live component today, even though the constants it would use still exist. Commented block in PlanCard.tsx; constants SEE_FULL_PLAN_DETAILS / SEE_PLAN_DETAILS in predefinedContent.ts

Region: Ribbon, Badges & Pills

Files: PlanCard.tsx · components/PillAndIconSlot/ · helpers/getPillAndIconSlotProps.ts

Two visually distinct systems produce badges on the card, and they are easy to confuse:

SystemWhat it rendersDriven by
CardBuilder container.label The top-left corner ribbon (e.g. "Save £240") plan.promoText — pure backend passthrough, no local logic beyond a truthy check
<PillAndIconSlot> Icons (5G/5G Ultra, Vodafone Together family icon) and pills (bestseller, roaming, SIMX) rendered as a row above the heading Combination of backend fields and feature flags — see table below

getPillAndIconSlotProps() decision table

Pill / IconConditionText source
5G / 5G Ultra / 5G Plus iconbadgeIcon exists & not simplified card flagIcon name derived from plan.badgeURL string
Vodafone Together family iconfamilyPlan && !aomPlanHardcoded icon name vodafone-together
"Our bestselling plan" pillplan.isRecommended & trending/SIMX flags offConstant BESTSELLING_PLAN_PILL_COPY in predefinedContent.ts
Roaming pill (e.g. "84 roaming destinations")Plan has a roaming benefit ID & journey/flag conditions met (see shouldDisplayRoamingPill())Constant map INCLUSIVE_ROAMING_CONTENT in predefinedContent.ts, keyed by INCLUSIVE_PRODUCTS_IDS.BENEFIT_DESTINATION_52_ID / _84_ID
"SIMX plan" pillsubType includes "simx" & isSimxPillAndBannerNotificationEnabled flag onHardcoded string "SIMX plan"

Region: Heading, Speed Line & Data Row

Files: helpers/getHeadingInfoSlotProps.ts · PlanCard.tsx (TextStackSlot)

const getHeadingInfoSlotProps = (planNameInfo, commitmentPeriod, showAomPlanTenure) => {
  const formattedCommitmentPeriod = commitmentPeriod?.replace(' Months', '')
  const tenureText = AOM_TENURE_TEXT[formattedCommitmentPeriod] ?? ''
  return {
    heading: { text: planNameInfo?.heading?.name },        // ← backend: plan.planNameInfo.heading.name
    suffix: { text: { suffix: planNameInfo?.suffix } },      // ← backend: plan.planNameInfo.suffix
    ...(showAomPlanTenure && !!tenureText.length && { prefix: tenureText }),  // AOM-only prefix
  }
}

The heading has an optional prefix slot, but it is only populated for AOM (Account On Move / upgrade recommendation) cards when showAomPlanTenure is true, and the text comes from AOM_TENURE_TEXT (constants.ts) — values like "24 month plan". This is the only "prefix" mechanism found in the live heading code, and it does not produce the word "Standard" seen in the reference design.

The Data and Minutes & Texts row uses <TextStackSlot> twice — once fed plan.dataInfo, once fed plan.minInfo. Both objects are entirely constructed by the external @vfuk/utils-shop-plans-card-mapper package from the raw backend plan payload — none of this is Contentful-driven.

Region: Price, Savings & Price Rises

Files: PlanCard.tsx · helpers/formatPriceRises/

// PlanCard.tsx
<TextStackSlot
  primaryTextStack={{
    currency: { symbol: plan?.priceInfo?.unit },
    text: {
      prefix: plan?.priceInfo?.text?.prefix,                 // "Monthly" — from mapper
      main: formatCurrency(plan?.priceInfo?.text?.main, true),// price — from backend monthlyPrice
      suffix: hasSavingsApplied ? plan?.savingsAppliedBanner  // backend override string
                                 : plan?.priceInfo?.text?.suffix, // OR computed "Was £X" from mapper
      suffix2: store.isBusiness && featureFlags.isShowMcpr && MCPR_BUSINESS_VAT_TEXT,
    },
  }}
  secondaryTextStack={{
    priceRiseText: { priceRises: formattedPriceRises },       // from plan.mcpr.priceRise[]
  }}
/>
PieceSourceNotes
"Monthly" labelExternal mapper (hardcoded)Not CMS — same for every plan
Price valueBackend: monthlyPrice.gross / .netBusiness segment uses net and appends MCPR_BUSINESS_VAT_TEXT = "All prices ex. VAT" (constants.ts)
"Was £43" savings textBackend: plan.savingsAppliedBannerTakes priority over the mapper's own computed discount suffix when present
Price rise linesBackend: plan.mcpr.priceRise[].labelText + .monthlyPriceFormatted by formatPriceRises() — breaks the loop at the first entry missing labelText

Region: CTA Button & Lines Dropdown

Files: helpers/PlanCardHelpers.ts (setButtonName) · components/MultipleLinesDropdown/

The CTA button text is entirely local/hardcoded JS logic — there is no CMS involvement. It reacts to journeyType, whether the plan is already selected, and a couple of feature flags:

ScenarioButton text
Default acquisition"Choose plan"
journeyType === 'secondline'"Choose additional plan"
P2P migration journey"Switch with this plan"
Another plan already selected"Switch Plan"
This plan is selected, has related entertainment bundle"Continue to entertainment"
This plan is selected (no bundle)"Selected"

Business-only: when showLinesDropdown is true, a <MultipleLinesDropdown> renders next to the CTA, letting a business buyer select 1–5 lines. The line count options (range(1, 6)) are hardcoded, and selecting a value recalculates the displayed price client-side (oldPrice / currentLines * selectedLines) before the plan is actually re-fetched from the backend. Not visible on this consumer-segment reference card.

Region: Benefit List Items (Non-Highlands)

Files: CardBenefitsSlot/components/BenefitItems/BenefitItems.tsx · helpers/orderAndEnrichBenefitItems/ · helpers/generateBenefitItemComponents/ · helpers/generateSecureNetBenefitItem/

Every benefit row on the live card goes through the same enrichment pipeline before rendering. This is the BAU path documented in full at Benefit Items Order & Tags — this section focuses specifically on how the pipeline explains the three items pinned above (#12, #15, and the entertainment rows #13/#14 covered in the next section).

plan.benefitItems.benefitItems[]
from backend (via external mapper)
SecureNet injected if missing
generateSecureNetBenefitItem()
orderAndEnrichBenefitItems()
merges CMS text/icon by benefit_item_<id> key
generateBenefitItemComponents()
picks renderer per item
Has _links['get-contentful']?
YES → clickable link row
NO → plain text row
Benefit rowHow it's classifiedRender style
"Free 3-month Secure Net trial" (#12) Synthetic — injected by generateSecureNetBenefitItem(), not present in inclusiveProducts at all. Only added when store.isSecureNetEnabled. Underlined link (has an onClick that opens the SecureNet modal)
"500 international minutes to EU" (#15) Real backend product with plain name/planBenefit text, no _links['get-contentful'] Plain paragraph text — not clickable
Two layers of CMS enrichment exist in the legacy path

1) The external plansCardMapper package uses a bingoBenefits CMS collection purely as a name fallback — only consulted when a backend inclusiveProducts item has neither name nor planBenefit text. 2) The local orderAndEnrichBenefitItems() then separately re-enriches every item against planCardBenefitItemContent (keyed benefit_item_<id>) for icon and priority ordering, regardless of whether step 1 ran. Both can apply to the same item.

Region: Entertainment Image Rows

Files: components/EntertainmentBenefitItem/ · components/EntertainmentImageSlot/

The two rows showing streaming service logos ("Disney+ or Prime") are not built from individual icon components with a text separator. Each is a single pre-composited image asset rendered by <EntertainmentImageSlot>, with its URL supplied entirely by the backend:

// plan.entertainmentPromotion.mediaUrl → passed down through:
PlanCard.tsx: entertainmentImageUrl={plan.entertainmentPromotion?.mediaUrl}
  → CardBenefitsSlot → BenefitItems → generateBenefitItemComponents()
  → EntertainmentBenefitItem → EntertainmentImageSlot
      <Image sm={{ src: entertainmentImageUrl, height: PLAN_ENTERTAINMENT_IMAGE_HEIGHT }} />
// PLAN_ENTERTAINMENT_IMAGE_HEIGHT = '44px' (constants.ts)

Two separate rows appear because two distinct backend product IDs both trigger the entertainment renderer in generateBenefitItemComponents():

Product IDConstantInternal name
3PLAN_BENEFIT_IDS.ENTERTAINMENT_BENEFIT_IDStandard entertainment benefit
106194PLAN_BENEFIT_IDS.BUNDLED_ENTERTAINMENT_BENEFIT_ID"Phoenix" streaming benefit

If the Phoenix item also has _links['get-contentful'] (and Highlands is off), its heading text is converted into a clickable link via generateBenefitItemWithModal() — this is why "Your choice of entertainment for 24 Months" is underlined, same mechanism as any other CMS-modal benefit.

PlanCardShout — Not Currently Used by the Live Card

File: src/client/components/molecules/PlanCardShout/PlanCardShout.tsx

Important correction

PlanCardShout renders a promo ribbon (visually similar in purpose to pin #1 above), but tracing its only call site shows it is not rendered alongside the live <PlanCard>. In PlanCardList.tsx, it is only rendered in the same branch as <PlanCardEntertainment>, gated entirely behind featureFlagStore.isCroPlanCardEntertainmentEnabled — the legacy CRO entertainment variant explicitly out of scope for this section.

// PlanCardList.tsx — getPlanCardItem()
if (featureFlagStore.isCroPlanCardEntertainmentEnabled) {
  return (
    <>
      <PlanCardShout promoText={currentPlan?.promoText} ... />
      <PlanCardEntertainment ... />
      ...
    </>
  )
}
return <PlanCard plan={plan} ... />  // ← the live default path never touches PlanCardShout

On the live PlanCard, the equivalent ribbon function (pin #1, "Save £240") is handled entirely inside <CardBuilder container.label> — a different mechanism, same backend field (plan.promoText).

Promo Cards Injected Into the Plan Grid

File: src/client/components/molecules/PlanListContent/PlanListContent.tsx

Before the grid ever reaches <PlanCardList>, PlanListContent takes the real plans array and splices extra, non-plan items into specific array positions. These placeholder objects don't have plan data — they're just flags (e.g. { loginBanner: true }) that PlanCardList's getPlanCardItem() later detects and swaps for a promo component instead of a <PlanCard>.

The four promo card types

CardTrigger conditionContent sourceInjected at
<FamilyCard> Slot would otherwise show login/handset/offer banner, getFamilyContent() has entries, and (Family Phase 2 flag OR journey is secondline/upgrade) Fully CMS — contentStore.getFamilyContent(), tag-filtered like other marketing content Same position as whichever banner it replaces (see below)
<OfferCard> (login banner) journeyType === 'acquisition' Backend fallback content (OFFER_CARD constants) with optional CMS override via mapOfferCardFields() Position 2 (mobile-ish early slot) if trending plans enabled, else position 6, else position 4 — see getPlansWithLoginBanner()
<OfferCard> (handset banner) isHandsetBannerEnabled flag + journeyType === 'upgrade' + consumer + not seamless migration CMS — handsetBannerContent, mapped via mapOfferCardFields() Fixed grid position 4 or 5 depending on CRO flags (plansWithBannerPosition)
<OfferBanner> Seamless migration + logged in + secondline, OR isOfferBannerEnabled flag + secondline + consumer + (12 or 24 month commitment) Backend — matches a specific plan by OFFER_BANNER.PLAN_ID (constant: '116497') Same fixed position as the handset/login banner slot

How the splicing works

getFilteredPlans() (a memoized method) runs the raw plans through plansCardMapper(), sorts/reorders them, then splices in placeholder objects at computed array indices:

// PlanListContent.tsx (simplified)
getPlansWithLoginBanner = (plans) => {
  if (trendingPlansEnabled && isTrendingPlansAvailable) {
    return [...plans.slice(0, 6), this.loginBannerProps, ...plans.slice(6)]   // after 6th plan
  }
  if (isCroPlanCardEntertainmentEnabled) {
    // inserted after the 4th non-entertainment plan
    return [...plans.slice(0, indexToPush), this.loginBannerProps, ...plans.slice(indexToPush)]
  }
  return [...plans.slice(0, 4), this.loginBannerProps, ...plans.slice(4)]     // default: after 4th plan
}

// Then separately, a handset/offer banner is spliced at position 4 or 5:
const plansWithBannerPosition = (isCroFilterOptionsEnabled || isCroAomPlansRepositionEnabled || isSeamlessMigration) ? 4 : 5
const plansWithBanner = [
  ...plansWithLoginBanner.slice(0, plansWithBannerPosition),
  this.bannerProps,          // { handsetBanner: true } or { offerBanner: true, offerBannerPlanId }
  ...plansWithLoginBanner.slice(plansWithBannerPosition),
]

Downstream, PlanCardList.tsx's getPlanCardItem() checks currentPlan.loginBanner, currentPlan.handsetBanner, and currentPlan.offerBanner flags (in that priority order, with showFamilyCard checked first) to decide whether to render a promo component instead of the plan at that grid position.

Only one banner type shows at a time

hasHandsetBanner and hasOfferBanner are mutually-exclusive getters (bannerProps picks whichever evaluates true first), and the login banner is a separate, independent splice. In practice, on any given page load a visitor will see at most a login banner slot and a handset/offer banner slot — never more than one of each — plus a family card if eligible (which visually replaces whichever of the above would have shown).

Highlands Feature — Plan Details & Interactive Benefit Modals

Highlands is the internal codename for a complete redesign of the plan card benefits row, plan details page, and interactive modals. It shifts the storefront from a static, hardcoded layout to a fully Contentful CMS-driven architecture where each benefit on a plan card is individually clickable and opens its own rich detail overlay.

Files: src/client/components/molecules/PlanCard/components/Highlands*/ · src/client/components/molecules/CardBenefitsSlot/components/BenefitItems/helpers/mapHighlandsContentfulBenefits/ · src/client/helpers/highlandsBenefitClickCheckGuard/ · src/client/helpers/getHighlandsBenefitName/

Feature Flag Control

Flag Name LaunchDarkly Key MobX Getter Default
highlandsPhaseOne web-shop-simo-highlands-phase-one-enabled FeatureFlagStore.isHighlandsPhaseOneEnabled false
Legacy vs. Highlands split

When isHighlandsPhaseOneEnabled is false (current default), the site uses the legacy 5-tab PlanCardDetailsModal. When true, the app mounts the interactive Highlands overlay ecosystem instead.

Full Component Tree

HighlandsModalContainer is rendered once at the PlanCardList level — not per-card. It observes MobX state and mounts the right modal or tray when a plan or benefit is selected.

PlanCardList.tsx
├── <HighlandsModalContainer />  ← one instance, always mounted when Highlands flag is on
│   ├── Guard: isHighlandsBenefitModalOpen && isHighlandsPhaseOneEnabled && contentStore exists
│   ├── → <HighlandsPlanDetailsModal>  (when a plan has been selected)
│   │   ├── <HighlandsPlanHeader>           "Plan details" heading
│   │   └── <HighlandsPlanDetailsModalBody>
│   │       ├── [View A] <HighlandsPlanDetailsModalContent>   (benefit list — default)
│   │       │   ├── generateBenefitItemComponents()  ← main benefits (visible tag)
│   │       │   ├── generateBenefitItemComponents()  ← modal-only benefits
│   │       │   ├── <HighlandsXtraBenefits>          ← xtra section
│   │       │   └── <HighlandsFaqs>                 ← CMS accordion FAQs
│   │       └── [View B] <HighlandsModalBenefitContentRenderer>  (after clicking a benefit)
│   │           heading · bodyText · heroSnippets · snippets · linksWithIcon · faqs
│   └── → <BottomTray> + <HighlandsModalBenefitContentRenderer>
│         (when selected benefit has tag 'tray' and no plan modal is open)
└── <PlanCard>[]
    └── <CardBenefitsSlot> → <BenefitItems> → generateBenefitItemComponents()

PlanDetailsTemplate.tsx  ← standalone /plan-details URL (deep-link)
└── if (isHighlandsPhaseOneEnabled) → <HighlandsModalContainer isMVAModal />
    else                            → <PlanDetails />  (legacy)

How Highlands Intercepts Legacy Components

The flag split happens in two places:

The Two Contentful Content Systems

This is the most important thing to understand about Highlands. It uses two separate Contentful fetches at two different points in the user journey.

Content System 1 — Card Display & Benefit List Content System 2 — Per-Benefit Rich Detail
When fetched Eagerly on page load inside loadContent() On-demand when user clicks a clickable benefit
Contentful entry simo_plan_benefits_details_content (type: planDetails) inclusive_product_<productId> (type: dynamicJourneyContent)
URL source Hardcoded in ContentStore.ts Backend plans response: inclusiveProduct._links['get-contentful'].href
Stored in ContentStore.highlandsModalContent (initial array) ContentStore.highlandsModalContent (appended on click)
Accessed via planCardBenefitItemContent from useCMSContentStore() ContentStore.highlandsModalContent.find(...)
Used by mapHighlandsContentfulBenefits() — builds the benefit list HighlandsModalBenefitContentRenderer — renders the rich overlay

The get-contentful HATEOAS Link — What It Is and Why It Exists

Some inclusiveProducts in the backend plans response carry a _links['get-contentful'] object. This is a HATEOAS pointer telling the frontend that this specific product has rich detail content in Contentful.

Benefit WITH rich content (get-contentful present)

{
  "id": "122533",
  "name": "Speed Boost in busy areas",
  "benefitType": "Product",
  "premiumBenefit": true,
  "benefitFeatures": {
    "cmsContentKey": "contentEntryKey=inclusive_product_122533
      &contentType=dynamicJourneyContent
      &spaceName=consumer",
    "isNew": true
  },
  "_links": {
    "get-contentful": {
      "href": "/content-service/v2/content
        ?contentEntryKey=inclusive_product_122533
        &contentType=dynamicJourneyContent
        &spaceName=consumer",
      "type": "GET"
    }
  }
}

Regular benefit WITHOUT rich content

{
  "id": "030010",
  "benefitType": "Advertisement",
  "premiumBenefit": true
  // No _links, no benefitFeatures.cmsContentKey
  // Renders as a plain icon+text row — not clickable
}

// Also: basic plan benefit with no extra structure
{
  "id": "2",
  "premiumBenefit": false,
  "planBenefit": "Unlimited minutes & texts"
}
Why the backend provides the Contentful URL

The simo-purchase API (Titans team) knows which products have rich Contentful content. Rather than the frontend hardcoding product IDs, the API supplies the exact Contentful URL as a HATEOAS link — exactly the same pattern as get-plans, get-extras, etc. The frontend never constructs this URL itself.

This URL is NOT called on page load. It is lazy-fetched only when the user actually clicks that specific benefit in the Highlands modal.

Note on benefitFeatures.cmsContentKey vs _links['get-contentful'].href: Both point to the same Contentful content. cmsContentKey is a query string (e.g. contentEntryKey=inclusive_product_122533&contentType=dynamicJourneyContent). _links['get-contentful'].href is the full proxied URL. In practice the frontend uses cmsContentKey (passed as benefitItem.benefitFeatures.cmsContentKey) when triggering the lazy fetch.

Highlands Data Flow: inclusiveProducts vs. benefitItems

Path Backend Source Mapping Function Behaviour
Legacy / BAU plan.benefitItems.benefitItems orderAndEnrichBenefitItems() Static text list. No clicking, no rich overlays, no tag-based routing.
Highlands plan.inclusiveProducts[] mapHighlandsContentfulBenefits() CMS-driven. Supports clickable items, icons from CMS, bottom trays, rich modals, xtra sections.

The ID Matching Engine — Backend Product → CMS Entry

mapHighlandsContentfulBenefits() cross-references plan.inclusiveProducts[] (from the backend) against planCardBenefitItemContent.fields.entries (from Contentful) using a numeric ID extracted from the CMS key.

plan.inclusiveProducts[]
e.g. [{ id: "122533", name: "Speed Boost" }]
Build object keyed by product.id
{ "122533": product, … }
Loop benefitsContent.fields.entries from CMS
extractBenefitId(entry.fields.key)
"benefit_item_122533""122533"
(regex: /\d+/g)
Does mappedProducts["122533"] exist?
If YES → createMappedBenefit(product, cmsFields)
Routed to bucket by tag:
mainBenefits / modalOnlyBenefits / xtraBenefits

CMS fields used during merge:

Benefit Tag Classification

TagWhere it rendersEffect
visiblePlan card + Highlands modal main listRequired to appear on the plan card benefit row at all
clickablePlan card + modal (Highlands only)Benefit row item becomes interactive — click triggers the lazy Contentful fetch and opens the detail overlay
modalOnlyInside HighlandsPlanDetailsModal onlyHidden on the plan card; only shown in the full modal's secondary list
xtraHighlandsXtraBenefits sectionRouted to the Xtra plan benefits box inside the modal (e.g. Vodafone Together benefits)
trayBottomTray + HighlandsModalBenefitContentRendererBenefit opens a slide-up bottom drawer instead of navigating into the full modal

The Benefit Click Flow — Step by Step

What happens when a user clicks a benefit item in the Highlands plan details modal:

1. onBenefitItemClick(productId)

Fired from BenefitItem when isHighlandsPhaseOneEnabled is true and the item has a clickable tag.

2. getHighlandsBenefitName(productId, benefitItem)

Resolves the human-readable name for analytics. For most products this is just benefitItem.heading.text. Special synthetic IDs map to hardcoded strings: view_additional_charges → "View additional charges information", find_out_more_red / find_out_more_xtra → "Find out more".

File: src/client/helpers/getHighlandsBenefitName/getHighlandsBenefitName.ts

3. planStore.setHighlandsBenefitName(name)

Stores name in MobX state for analytics event firing.

4. highlandsBenefitClickCheckGuard(productId, cmsContentKey, …)

File: src/client/helpers/highlandsBenefitClickCheckGuard/highlandsBenefitClickCheckGuard.ts

  • Searches ContentStore.highlandsModalContent for a cached entry whose contentEntryKey matches productId via matchesProductId() (extracts the numeric portion with regex)
  • If cached: skip fetch, call callback(productId) immediately
  • If not cached & cmsContentKey present: call ContentStore.getHighlandsDynamicContentByHref(productId, cmsContentKey)
  • If not cached & no cmsContentKey: return early — this benefit has no detail content

5. ContentStore.getHighlandsDynamicContentByHref(productId, href)

File: src/client/stores/ContentStore/ContentStore.ts

// Parses the query string from cmsContentKey:
// "contentEntryKey=inclusive_product_122533&contentType=dynamicJourneyContent&spaceName=consumer"
const params = new URLSearchParams(href)
const contentEntryKey = params.get('contentEntryKey')  // "inclusive_product_122533"
const contentType     = params.get('contentType')       // "dynamicJourneyContent"

// Calls the content service:
contentServiceV2.getAssetModelV2({ contentEntryKey, contentType })

// Appends to highlandsModalContent with productId prefix for reliable matching:
this.highlandsModalContent = [
  ...this.highlandsModalContent,
  {
    ...dynamicContent,
    fields: {
      ...dynamicContent.fields,
      contentEntryKey: `${productId}_${dynamicContent.fields.contentEntryKey}`,
    },
  },
]

6. planStore.setHighlandsSelectedBenefitId(productId)

MobX observable changes → HighlandsModalContainer re-renders. selectedBenefitContent is now populated from highlandsModalContent.

7. View switch

HighlandsPlanDetailsModalBody switches from View A (benefit list) → View B (HighlandsModalBenefitContentRenderer), which renders the rich content: heading, bodyText (Contentful rich text), hero snippets, icon snippet lists, links-with-icon, FAQs, and a "Go back" navigation link.

BottomTray vs. Full Modal — When Each Opens

User clicks benefit on plan card or in modal list
Does benefit have tray tag AND no full plan modal is open?
↓ YES
<BottomTray> slide-up sheet
Contains <HighlandsModalBenefitContentRenderer>
↓ NO (all other cases)
Full <HighlandsPlanDetailsModal> dialog
Then clicking a benefit within it switches to View B (detail renderer)

Special / Synthetic Benefit IDs

These are UI-only action items that appear in the Highlands modal but don't correspond to a real numeric inclusiveProduct.id. Because the regex /\d+/g can't extract a number from their contentEntryKey, handleMissingExtractedId() handles them via hardcoded pairs.

Product ID (synthetic)What it doesCMS content key
view_additional_chargesLinks to out-of-bundle charges infoplandetailshandset_charges_out_of_bundle_charges
find_out_more_red"Find out more" CTA for Red plansplandetails_included_find_out_more_red_plan
find_out_more_xtra"Find out more" CTA for Xtra plansplandetails_included_find_out_more_xtra_plan
secure_net_trialSecureNet benefit (when isSecureNetEnabled flag is true)plandetails_included_simo_secure_net_trial

SecureNet is also special in that it's injected into the benefit list by processSecureNetBenefit() inside mapHighlandsContentfulBenefits — it appears even if it isn't present in plan.inclusiveProducts, as long as isSecureNetEnabled is true and the CMS entry has a visible tag.

The Joining Engine (Backend ID → CMS Mapping)

A worked example from the __mockapi__/journey/highlands scenario:

// Backend returns (plan.inclusiveProducts):
{ id: "122533", name: "Speed Boost in busy areas", benefitFeatures: { cmsContentKey: "...", isNew: true }, _links: { "get-contentful": { href: "..." } } }
{ id: "030060", benefitType: "Advertisement", _links: { "get-contentful": { href: "..." } } }
{ id: "030010", benefitType: "Advertisement" }          // No _links — not clickable
{ id: "104922", name: "Unlimited Picture Messages" }
{ id: "111068", name: "Inclusive roaming in 84 worldwide destinations" }
{ id: "030260", name: "100% OneNumber Discount", _links: { "get-contentful": { href: "..." } } }
{ id: "2",      planBenefit: "Unlimited minutes & texts" }
{ id: "111871", name: "Uncapped speeds" }

// mapHighlandsContentfulBenefits() cross-references against CMS entries
// whose keys like "benefit_item_122533" → extract "122533" → match product.id
// CMS tags then route each matched item to:
//   mainBenefits      (visible tag) → shown on plan card row + modal
//   modalOnlyBenefits (modalOnly tag) → modal only
//   xtraBenefits      (xtra tag) → HighlandsXtraBenefits section

// Products with no matching CMS entry are silently dropped

The Joining Engine Flow Diagram

plan.inclusiveProducts[]
from backend
Keyed object by product.id
CMS planCardBenefitItemContent
(fetched on page load)
Loop CMS entries
extractBenefitId(key)
"benefit_item_122533""122533"
Match against products object?
YES → createMappedBenefit()
↓ Sort by tag
mainBenefits
(visible)
modalOnlyBenefits
(modalOnly)
xtraBenefits
(xtra)

Step-by-Step: Adding a New Benefit ID

If the backend adds a new product with ID 777123 that should be clickable in Highlands:

1. Backend

Backend includes the product in plan.inclusiveProducts. If it has rich content, it adds benefitFeatures.cmsContentKey and _links['get-contentful'].href pointing to the Contentful entry.

2. Contentful — Card Display Entry

Create an entry in simo_plan_benefits_details_content (type: planDetails) with: key: benefit_item_777123 · text: display label · icon: shield-check:system · tags: ["visible", "clickable"]

3. Contentful — Rich Detail Entry (if clickable)

Create a dynamicJourneyContent entry with contentEntryKey: inclusive_product_777123. Populate heading, bodyText (rich text), snippets, FAQs as needed.

4. Verify

The ID mapping uses regex /\d+/g on the CMS key. As long as benefit_item_777123 contains the digits 777123, matching is automatic. No frontend code change required.

Shared Rendering: generateBenefitItemComponents()

Both the plan card and the Highlands modal use the same rendering function: src/client/components/molecules/CardBenefitsSlot/components/BenefitItems/helpers/generateBenefitItemComponents/generateBenefitItemComponents.tsx

Plan Card Context

Benefits are rendered inside <CardBenefitsSlot>. If count exceeds numberOfRegularBenefits (LD flag threshold), overflow wraps into a collapsible accordion. Items are truncated by the flag value.

Highlands Modal Context

Called directly from HighlandsPlanDetailsModal with isHighlandsPlanModal: true. No truncation — all mainBenefits and modalOnlyBenefits render in full. Benefit clicks open the rich detail renderer within the modal body.

The pre-existing get-contentful path (non-Highlands)

There is an older code path in generateBenefitItemComponents() that checks benefitItem._links?.['get-contentful']?.href?.length and renders a BenefitItem with a modal link — but only when !isHighlandsPhaseOneEnabled. This was the predecessor mechanism before Highlands. When the Highlands flag is enabled, this path is skipped entirely and the new click-guard flow described above is used instead.

Plan Details — Standard (BAU)

The non-Highlands plan details experience. Highlands is disabled by default — this is what all users see today.

How the modal opens

The user clicks a plan card. handleCardInteraction() in PlanCard.tsx checks the isHighlandsPhaseOneEnabled flag. When false (the default), it calls openPlanListModal({ ...plan, buttonName, buttonState }), which triggers the PlanCardDetailsModal organism.

PlanCardDetailsModal — five tabs

File: src/client/components/organisms/PlanCardDetailsModal/PlanCardDetailsModal.tsx

TabCMS resolverWhat drives the content
OverviewStatic — plan data onlyPlan name, price, data allowance from the backend plan object
What's includedgetContentForPlan({ tab: 'whatsincluded', plan, … })plan.type + plan.subType → CMS key; see getContentForPlan
Additional chargesgetContentForPlan({ tab: 'additionalcharges', … })Shared CMS key — same content for all plans
What you need to knowgetContentForPlan({ tab: 'whatyouneedtoknow', … })Shared CMS key — same content for all plans
About speedgetContentForPlan({ tab: 'aboutspeed', … })Shared CMS key — same content for all plans

Data sources for the modal

// PlanCardDetailsModal.tsx
const planSelected = planStore.plans.find(p => p.id === planListModal?.id)
const sharedPlanDetails  = contentStore.content?.planDetailsOverlay  // CMS shared content
const pageSpecificPlanDetails = contentStore.content?.planContent   // CMS per-plan content
Only "What's included" varies by plan type

The other four tabs fetch static shared CMS blocks. Only the "What's included" tab is personalised to the plan's type and subType. This is where 3onv ID changes create risk.

PlanDetails standalone page

There is also a dedicated route /sim-only/.../:planId/plan-details that renders PlanDetails.tsx. This page calls store.initPlanDetails(planId), creates a fresh SimoStore, and wraps the same PlanDetailsTemplate. It is used for deep-link sharing of a specific plan's detail view.

PlanCard Anatomy

File: src/client/components/molecules/PlanCard/PlanCard.tsx

PlanCard receives a single plan prop (the transformed backend plan object, shaped by plansCardMapper from @vfuk/utils-shop-plans-card-mapper) plus callbacks and feature flags. It renders a CardBuilder with named slots.

Slot-by-slot breakdown

Slot / ComponentKey backend fields consumedWhat it renders
PillAndIconSlot plan.badgeURL, plan.isRecommended, plan.subType, plan.recommendationId, roaming benefit ID Badge pill (e.g. "Best Value"), recommended flag, roaming icon
HeadingInfoSlot plan.planNameInfo.heading.name, plan.planNameInfo.suffix, plan.commitmentPeriod Plan name (e.g. "Unlimited Lite") and contract length label
TextStackSlot (data) plan.dataInfo.prefix, plan.dataInfo.main, plan.minInfo.prefix, plan.minInfo.main Data allowance (e.g. "Unlimited" or "5GB") and minutes
TextStackSlot (price) plan.priceInfo.unit, plan.priceInfo.text.prefix/main/suffix, plan.savingsAppliedBanner Monthly price, savings banner (e.g. "Save £5/month")
MultipleLinesDropdown plan, linesQuantity, selectedPlan Business multi-line selector (only shown when showLinesDropdown is true)
InteractionSlot plan.id, processingPlanId, buttonName, buttonState Primary CTA button ("Choose" / "Select" / "Upgrade")
CardBenefitsSlot plan.benefitItems, plan.inclusiveProducts, plan.subType, plan.entertainmentPromotion?.mediaUrl, plan.commitmentPeriod Benefit icons row + entertainment image (if applicable)
AOM reason message plan.reasonMessage[] "Great fit for you" ribbon on AOM recommended plans

Complete list of backend plan fields read by PlanCard

plan.id
plan.isSelected
plan.badgeURL                        // Badge icon URL → mapped to icon name
plan.isRecommended
plan.recommendationId
plan.planNameInfo.heading.name       // Plan display name
plan.planNameInfo.suffix
plan.commitmentPeriod                // e.g. "24 Months"
plan.dataInfo.prefix / .main         // e.g. prefix="" main="Unlimited"
plan.minInfo.prefix / .main          // minutes display
plan.priceInfo.unit                  // e.g. "£"
plan.priceInfo.text.prefix           // e.g. "from"
plan.priceInfo.text.main             // e.g. "12.00"
plan.priceInfo.text.suffix           // e.g. "/month"
plan.savingsAppliedBanner            // savings message string
plan.benefitItems.benefitItems[]     // benefit array (non-Highlands)
plan.benefitItems.heading            // benefit section heading
plan.inclusiveProducts[]             // benefit array (Highlands path)
plan.subType                         // e.g. "Unlimited", "Basics Plan"
plan.type                            // e.g. "airtime"
plan.entertainmentPromotion?.mediaUrl // entertainment image URL
plan.mcpr?.priceRise                 // mid-contract price rise flag
plan.reasonMessage[]                 // AOM recommendation reason
plan.footerText                      // legal footer text
plan.promoText                       // promotional label

3onv type variation examples

These are the plan.subType values the frontend currently knows about. A 3onv backend sending a different string will fall through to the essentials fallback in getContentForPlan.

Current Vodafone subtypes (examples)

  • "Unlimited" → CMS key: packagelistsimo_unlimited
  • "Unlimited Max" → CMS key: packagelistsimo_unlimitedmax
  • "Red" → CMS key: packagelistsimo_red
  • "Basics Plan" → CMS key: packagelistsimo_basicsplan
  • "Unlimited Entertainment"packagelistsimo_unlimitedentertainment

Potential 3onv subtypes (unknown)

  • Any new string not in SUBTYPES constant → undefined → falls back to packagelistsimo_essentials CMS block
  • To support a new type: add to constants.ts SUBTYPES map and add the matching Contentful entry

Benefit Items — Order & Enrichment (BAU)

File: src/client/components/molecules/CardBenefitsSlot/components/BenefitItems/helpers/orderAndEnrichBenefitItems/orderAndEnrichBenefitItems.ts

This function is the BAU (non-Highlands) path for benefit items. It takes the raw backend benefit array, enriches each item with CMS content, and returns them in CMS-defined display order.

Function signature

orderAndEnrichBenefitItems(
  planBenefitItemsFromResponse: BenefitItemType[] | undefined,  // from plan.benefitItems.benefitItems
  planBenefitItemsContent:      ContentfulData | undefined,      // from ContentStore CMS data
  commitmentPeriod?:            string,                          // e.g. "24 Months"
  isHighlandsPhaseOneEnabled?:  boolean,                         // flag — false in BAU
)

Step-by-step execution

1. Guard — no backend data

If planBenefitItemsFromResponse is undefined, return [].

2. Guard — no CMS content

If planBenefitItemsContent is undefined, return the backend array as-is (filtered for unused IDs).

3. Extract CMS priority order

The CMS planBenefitItemsContent.fields.entries array is ordered by editors. Each entry has a key like benefit_item_456. Strip the prefix to get the product ID priority list:

benefitItemIdsByPriority = ["456", "123"]  // CMS editor ordering

4. Enrich each backend benefit with CMS data

For each backend item, find the matching CMS entry: content.key === `benefit_item_${item.productId}`

// Backend item: { productId: "123", name: "Roaming" }
// CMS match:   { key: "benefit_item_123", text: "EU Roaming", tags: ["visible"], icon: "globe" }
// Result:      { ...backendItem, heading: { text: "EU Roaming" }, tags: ["visible"], icon: { name: "globe" } }

Special case: for the entertainment benefit (PLAN_BENEFIT_IDS.ENTERTAINMENT_BENEFIT_ID), the CMS text may contain {"{{commitmentPeriod}}"} which is replaced with the actual period, e.g. "Enjoy 24 months of entertainment".

5. Filter by priority list and sort

filterAndSortByPriority() keeps only benefits whose productId appears in the CMS priority list, then sorts by that list's index. Benefits not in the CMS list are dropped.

Concrete example

Backend sends: [{"{ productId: "456", name: "Music" }"}, {"{ productId: "123", name: "Roaming" }"}]

CMS has entries in this order: benefit_item_456, benefit_item_123

// Step 3 → priority list (CMS order):
benefitItemIdsByPriority = ["456", "123"]

// Step 4 → enriched:
[
  { productId: "456", heading: { text: "Spotify Music" }, tags: ["visible"], icon: { name: "music" } },
  { productId: "123", heading: { text: "EU Roaming" }, tags: ["visible"], icon: { name: "globe" } },
]

// Step 5 → sorted by CMS priority (456 first, 123 second):
[
  { productId: "456", ... },  // Music first
  { productId: "123", ... },  // Roaming second
]

// ✅ Display order is controlled entirely by Contentful, not backend order

What happens when a productId has no CMS match

// Backend sends: { productId: "999", name: "Some New Benefit" }
// CMS has no entry: benefit_item_999

// Step 4: benefitItemContent = undefined
//   → no text enrichment, no icon, no tags added
//   → item.heading remains as-is from backend

// Step 5: filterAndSortByPriority checks if "999" is in priority list
//   → "999" NOT in list → item is DROPPED from output
// Result: benefit "999" does not render at all
3onv risk: new productIds are silently dropped

If 3onv sends benefit items with IDs not present in Contentful, they will not appear on the card. No error is thrown. To support new IDs, add corresponding benefit_item_{id} entries to Contentful and ensure the CMS content is published.

The role of tags on benefit items

Tags are a string array assigned to each benefit item during the CMS enrichment step (Step 4). They come from Contentful — the CMS editor adds them to each benefit_item_{id} entry. Two constants define what the app recognises:

// BAU — only these two tags are read by the front end:
CMS_BENEFIT_ITEM_VISIBLE_TAG = 'visible'   // item should appear on the plan card
CMS_BENEFIT_ITEM_BOLD_TAG    = 'bold'      // item label should be bold

How tags are used in the BAU (non-Highlands) path

In the BAU path, tags are assigned in Step 4 — item.tags = benefitItemContent?.tags. However, the 'visible' tag is only used for filtering when isHighlandsPhaseOneEnabled is true. In standard BAU mode (flag off), the tag is present on the object but is not used to filter items out. All enriched items that appear in the CMS priority list are shown.

// Inside orderAndEnrichBenefitItems.ts
item.tags = benefitItemContent?.tags   // e.g. ['visible', 'bold']

// Filtering is conditional:
if (isHighlandsPhaseOneEnabled) {
  // Only keep items tagged 'visible'
  filteredBenefits = enrichedBenefits.filter(item => item?.tags?.includes('visible'))
} else {
  // BAU: no tag-based filtering — show all enriched items
  filteredBenefits = enrichedBenefits
}

How tags are used in the Highlands path

File: mapHighlandsContentfulBenefits/mapHighlandsContentfulBenefits.ts
In the Highlands path, tags control where the benefit appears — not just whether it shows. Three tags are recognised:

Tag stringEffect
visibleItem appears in the main benefit list on the plan card
modalOnlyItem appears only inside the plan details modal, not on the card surface
xtraItem is placed in the "Xtra benefits" section (separate slot on the card)

A benefit item with none of these tags will still be present in the enriched array but will not be rendered in any section.

Entertainment Benefit Icons

Entertainment is a special-case benefit on plan cards. Detection, icon, and image handling all work differently from standard benefit items.

How entertainment is detected

// PlanCard.tsx
const isEntertainmentPlan = plan?.subType?.toLowerCase().includes('entertainment')
// true for subtypes: "UnlimitedEntertainment", "RedEntertainment51", etc.

const isEntertainmentParentPlan = hasRelatedBundles(plan)
// true if the plan has related bundle options to choose from

Entertainment benefit item — ID-based detection

Inside generateBenefitItemComponents.tsx, a benefit item is rendered as an EntertainmentBenefitItem when its productId matches a hardcoded constant:

if (
  benefitItem?.productId === PLAN_BENEFIT_IDS.ENTERTAINMENT_BENEFIT_ID ||
  benefitItem?.productId === PLAN_BENEFIT_IDS.BUNDLED_ENTERTAINMENT_BENEFIT_ID
) {
  return <EntertainmentBenefitItem ... />
}
// All other benefits render as standard BenefitItem
Hardcoded product IDs for entertainment

PLAN_BENEFIT_IDS.ENTERTAINMENT_BENEFIT_ID and PLAN_BENEFIT_IDS.BUNDLED_ENTERTAINMENT_BENEFIT_ID are constants in the frontend codebase. If 3onv uses different product IDs for entertainment benefits, the special entertainment component will not render — the benefit will appear as a plain benefit item instead.

Entertainment image (not icon)

The entertainment image (e.g. a streaming service logo) comes directly from the backend: plan.entertainmentPromotion?.mediaUrl. This is a URL string — no Contentful lookup is involved. The image URL is passed as entertainmentImageUrl to EntertainmentImageSlot and rendered via the Source Web Image component.

Entertainment text in benefit items

The text label for the entertainment benefit comes from CMS (via orderAndEnrichBenefitItems) using the key benefit_item_{ENTERTAINMENT_BENEFIT_ID}. The CMS text may include a {"{{commitmentPeriod}}"} placeholder that is replaced at runtime with e.g. "24 months".

Entertainment plan interaction

When a user clicks "Choose" on an entertainment parent plan, instead of selecting immediately, setSelectedBundledEntertainmentPlan(plan.id) and setEntertainmentTrayOpen(true) are called. This opens a tray for the user to pick their entertainment bundle variant.

Summary: what is hardcoded vs dynamic

ElementSourceHardcoded?
Entertainment detection (plan level)plan.subType.includes('entertainment')String match — depends on backend subType
Entertainment benefit item detectionproductId === ENTERTAINMENT_BENEFIT_IDYes — frontend constant, risk for 3onv
Entertainment image URLplan.entertainmentPromotion.mediaUrlNo — fully backend-driven
Entertainment benefit textCMS via benefit_item_{id}ID-matched — risk if ID changes

Adding a New Plan Type

This is the end-to-end guide for what happens when the backend starts returning a plan.subType the frontend does not know about, and what must change to support it.

Example scenario: backend starts sending plan.subType = "Essentials Plus"

Step 1 — What breaks immediately (without any code change)

Trace through getContentForPlan()getPlanSubType("Essentials Plus"):

const planSubType = "essentials plus"  // after .toLowerCase()
SUBTYPES["essentials plus"]  // → undefined  (not in constants.ts)

// CMS key constructed:
`devicedetailshandset_${undefined}`.toLowerCase()
// = "devicedetailshandset_undefined"

// findByContentEntryKey() searches for "devicedetailshandset_undefined"
// → no match found → planGroupSpecific = undefined

// Falls through to hardcoded fallback:
findByContentEntryKey(entries, 'packagelistsimo_essentials')
// Shows generic "Essentials" content regardless of the actual plan type

Visible result: The "What's included" tab in the plan details modal renders with generic essentials content. No error shown to the user. For 3onv this would show Vodafone Essentials content instead of the correct 3 plan content.

Step 2 — Add the subtype mapping (frontend)

File: src/client/stores/helpers/getPlanDetailsContent/constants.ts

export const SUBTYPES = {
  // ... existing entries ...
  'essentials plus': 'essentialsplus',  // ← add this line
}

The value ('essentialsplus') becomes the slug fragment used in the CMS key: packagelistsimo_essentialsplus and devicedetailshandset_{airtimeType}_essentialsplus.

Step 3 — Add the Contentful entries

Two CMS entries are needed:

  • packagelistsimo_essentialsplus — the "What's included" tab content block for this subtype. This is the plan group content shown when the user opens the modal.
  • devicedetailshandset_gb_essentialsplus (and _unltd_ variant if needed) — the handset/device details content for airtime band plans of this subtype.
Step 4 — Add benefit item entries for new product IDs (if IDs also changed)

If 3onv also uses new inclusiveProduct.id / benefitItems[].productId values:

  • Add benefit_item_{newId} Contentful entries with icon, text, and tags.
  • Publish them in the same Contentful content model as existing benefit items — the ordering in Contentful controls display order on the card.
  • If entertainment detection relies on a new ID, update PLAN_BENEFIT_IDS in the frontend constants.
Example: "Basics Plan" — what already works

"Basics Plan" IS in SUBTYPES:

'basics plan': 'basicsplan'
// → CMS key: packagelistsimo_basicsplan

The plans list also filters Basics plans out for non-P2P journeys unless the user's existing subscription is also a Basics plan (filterBasicPlans() in PlanStore). So supporting "Basics Plan" for 3onv would require checking whether that filter should apply.

Contract Lengths & Commitment Periods

How the frontend handles commitment periods

The frontend has no hardcoded list of valid commitment periods (12, 24, etc.). The backend returns available commitment periods inside journey.filters.commitmentPeriod, and FilterStore uses that array to build the filter UI. Selecting one triggers a new plans fetch with the chosen period as a query parameter.

18-month plans — does it work?

18-month contracts are already supported

If the backend returns '18 Months' in the filters.commitmentPeriod array, the filter tab appears automatically and the plans request will include commitmentPeriod=18+Months. No frontend code change needed.

// FilterStore.selectCommitmentPeriod() — generic, works for any period string
selectCommitmentPeriod = async (selectedCommitmentPeriodId: string) => {
  this.selectedCommitmentPeriodId = selectedCommitmentPeriodId  // e.g. '18 Months'
  const updatedState = this.createUpdatedState('commitmentPeriod', selectedCommitmentPeriodId)
  if (updatedState) this.updateFilterStates('commitmentPeriod', updatedState)
  await this.applyFilters()  // → new GET_PLANS request with commitmentPeriod=18+Months
}

The one exception: Basic Upgrade override

This is the only place the frontend actively rewrites a commitment period. When isBasicUpgrade(summary, journeyType) is true, the frontend rewrites the GET_PLANS HATEOAS link before following it:

// simOnlyService.ts
if (isBasicUpgrade(summary, journeyType)) {
  links[HATEOAS_LINKS.GET_PLANS] = {
    ...links[HATEOAS_LINKS.GET_PLANS],
    href: plansLink.href.replace('24+Months', '12+Months'),
    parameters:     { ...plansLink.parameters,     commitmentPeriod: '12 Months' },
    queryParameters:{ ...plansLink.queryParameters, commitmentPeriod: '12 Months' },
  }
}

Comparison: 12-month Basic Upgrade vs standard period selection

Basic Upgrade overrideStandard filter selection (18 months)
When it happensOn initial journey setup, before the first plans fetchWhen user taps a filter tab after plans are loaded
Who triggers itFrontend detects isBasicUpgrade and patches the linkUser action → FilterStore.selectCommitmentPeriod()
MechanismString replace on the HATEOAS hrefRebuilds query string and re-requests plans
Backend requirementBackend must send a 24-month link; FE rewrites to 12-monthBackend must include '18 Months' in filters.commitmentPeriod
3onv considerationIf 3onv basic upgrades should also use 12 months, same logic appliesNo change needed — works with any period the backend declares

Extras / Add-ons Page

Extras and Add-ons are the same thing

The code uses PAGE_VIEW.EXTRAS, the template is called SimoExtrasTemplate, but the UI header reads "Choose your Add-ons" and the build step is labelled "Add-ons". They are one and the same page.

ViewStore decision: when is the extras page shown?

File: src/client/stores/ViewStore/ViewStore.tsgoToExtras()

User selects a plan
goToNextStep()
goToExtras()
goToExtras = async () => {
  // Gate 1: non-upgrade consumer — skip extras unless flag is on
  if (!featureFlagStore.isAddonsPageEnabled &&
      !(journeyType === JOURNEY_TYPES.UPGRADE || segment === 'business')) {
    return this.goToBasket()
  }

  await contentStore.loadAddonsContent()
  await extraStore.loadExtras()

  // Gate 2: flag off AND no "losing extras" notification → skip
  if (!featureFlagStore.isAddonsPageEnabled && !this.losingExtras) {
    return this.goToBasket()
  }

  this.setPage(PAGE_VIEW.EXTRAS)

  // Gate 3: no actual extras data AND no insurance → skip back to basket
  if (!featureFlagStore.isSimoInsuranceEnabled && extraStore.extrasData.length === 0) {
    this.goToBasket()
  }
}

Who sees the extras page

Journey typeExtras page shown?Condition
UpgradeYes (default)Always enters goToExtras(); shown unless no extras data and no insurance
Business (any)Yes (default)segment === 'business' bypasses Gate 1; losingExtras getter returns true for business
Acquisition (consumer)No (default)Skipped at Gate 1 unless isAddonsPageEnabled flag is on
Acquisition with losing extrasYesNotification code matches LOSING_EXTRAS_NOTIFICATIONS list

Required HATEOAS link

// ExtraStore.loadExtras()
if (this.simoStore.links[HATEOAS_LINKS.GET_EXTRAS] === undefined) return
// → If backend does not return a "get-extras" link, loadExtras() exits early
// → extrasData will be empty → Gate 3 will redirect to basket

How to see the extras page for acquisition locally

Option 1: Override the feature flag (recommended)
// In browser console after page load:
window.VFUK.env.FEATURE_FLAGS.showAddonPage = true
// Then navigate — the flag is read from window.VFUK.env at runtime

Or use the AIM debug panel to set showAddonPage to true.

Option 2: Mock the journey response to include get-extras link + losing-extras notification

In __mockapi__/, add a get-extras link to the journey response and one of the following notification codes to the journey payload:

notifications: [{
  code: 'warning_add-ons-extras-opt-in',
  // ...
}]

With this notification present, losingExtras returns true, bypassing Gate 2 even without the feature flag.

Extras page content — what renders

File: src/client/templates/SimoExtrasTemplate/SimoExtrasTemplate.tsx

ElementSourceHardcoded?
"Choose your Add-ons" headingHardcoded string in templateYes
Lost extras notification bannerextraStore.notifications from backendNo — backend-driven notification code
Extra cards listextraStore.extras — from backend response.available[]No
Extra card "What's included" detailCMS: extraswhatsincluded_sku{extra.id}ID is dynamic (backend); CMS key prefix is hardcoded
Addons CMS blockcontentEntryKey: 'packagelistsimoextraswhatsincluded'Yes — hardcoded Contentful key in ContentStore.loadAddonsContent()
No-thanks / skip buttonHardcodedYes
Hardcoded keys to be aware of for 3onv

The Contentful entry key 'packagelistsimoextraswhatsincluded' is hardcoded in ContentStore.loadAddonsContent(). If 3onv needs a different extras CMS content block, this key would need to become brand-aware (similar to the isBusiness content split). Each extra's detail content uses extraswhatsincluded_sku{extra.id} — if extra IDs differ for 3onv, the detail modals will be empty.

For a specific brand / journey type — rendering extras for 3onv acquisition

Currently there is no brand concept. The closest parallel is the isBusiness/segment split. To show extras for a 3onv acquisition journey without enabling it for all Vodafone acquisition journeys, the cleanest approach is:

  1. Add a brand observable to SimoStore (set from the backend journey response or a query param).
  2. In ViewStore.goToExtras(), add: || this.simoStore.brand === 'three' to Gate 1.
  3. Pass brand to ContentStore.loadAddonsContent() to fetch a brand-specific CMS block.

Cookie Flow

Cookies serve three purposes in this app: authentication, anonymous session management, and prerender cache control. Here is what each cookie does and when it is read or written.

Key cookies and their roles

Cookie name / patternSet byRead byPurpose
{AUTH_COOKIE_PREFIX}_id_token
(e.g. eShop-auth_id_token)
IDM OAuth2 callback (server) journeyService.ts — sent as Authorization header on journey/plans calls Proves the user is logged in; included in every upstream API request
{AUTH_COOKIE_PREFIX}_p_id_token IDM OAuth2 callback (server) getSession.ts — extracts platform session ID for API base URL Platform session ID embedded in every API path: /api/…/{platformSessionId}/…
Session Platform (Vodafone shell) setCachePolicyForRequest.ts — reads assuranceLevel Assurance level ≥ 3 means logged-in; used to decide if the page should be cached
basketId Backend (checkout service) Prerender middleware, SimoStore Signals an active basket — prerender skips caching; SimoStore reads it as this.basketId
customerSegment Platform / query param SimoStore; retained by prerender Consumer vs. business segment — persists across the prerendered page cache
features Debug tools / query param ?features= Client bootstrap (index.tsx); retained by prerender Feature flag overrides in non-production environments
DisableLoginPrompt Set by SimoStore when ?disableLoginPrompt=true is in the URL SimoStore Suppresses the login prompt banner for the session
JourneyID Backend (journey service) setCachePolicyForRequest.ts Like basketId — presence means the page should not be read from or written to cache

How cookies flow through a journey request

Browser sends cookies with page request
Express reads Session, basketId, JourneyID
setCachePolicyForRequest: set req.readCache / req.writeCache
prerenderMiddleware: skip if basketId present
React app boots — reads cookies client-side
authService.session() + journey init
Journey Latest request: auth token from eShop-auth_id_tokenAuthorization header
DXL proxy forwards with channel header eShop-simo

Prerender cache and cookies

// prerender.middleware.ts
prerender({
  cookiesToRetain:  ['features', 'customerSegment'],   // Baked into cached HTML
  skipChecker:      (req) => req.cookies.basketId,     // Don't cache if basket active
})

// setCachePolicyForRequest.ts
const hasJourney = req.cookies.basketId
                || req.cookies.JourneyID
                || session.assuranceLevel >= 3

// Only unauthenticated users with no active journey get cached pages
req.readCache  = !hasJourney
req.writeCache = !hasJourney
Anonymous users get cached pages; authenticated users never do

This means the prerendered plan listing is served only to unauthenticated visitors. Once a user logs in or starts a journey, every request goes to the live Express server with fresh data.

IDM OAuth2 Login Flow and eShop-auth_p_id_token

Authentication is handled server-side using Vodafone's IDM OAuth2 service via two middleware packages: loginCallbackMiddleware and idmMiddleware from @vfuk/*.

Config: src/server/common/config/idm.config.ts — Middleware: src/server/common/middleware/

1. Login redirect

When the frontend detects an unauthenticated state that requires login (e.g. from a sign-in HATEOAS link in an API response), it directs the user to /web-shop/login. This is the basePath configured in idm.config.ts.

2. IDM redirect (external)

The Express server redirects the browser to the Vodafone IDM (Identity Management) service at an external URL. The user authenticates there (username/password, MFA etc.).

3. OAuth2 callback

IDM redirects back to /web-shop/login/callback (loginCallbackUrl: '/login/callback' appended to basePath) with an authorisation code in the query string.

4. loginCallbackMiddleware exchanges the code

The middleware performs the token exchange with IDM, gets back an ID token and access token, then passes to idmMiddleware.

5. idmMiddleware sets encrypted cookies

Two cookies are set on the response (names vary by prefix — configured in idm.config.ts):

  • eShop-auth_id_token — the raw JWT ID token; used as the Authorization: Bearer … header on every upstream API request
  • eShop-auth_p_id_token — an encrypted cookie containing platform session claims. The encryption uses a server-side secret so the client cannot read the raw JWT — only the Express server can decrypt it.

6. Redirect back to the SIM-only storefront

The user is redirected back to the plan selection page with the cookies now set.

getSession.ts — reading the platform session client-side

File: src/client/helpers/getSession/getSession.ts

Despite the eShop-auth_p_id_token cookie being server-side encrypted, the Express server exposes a /session API endpoint that decrypts it and returns the claims as JSON. getSession.ts is a client-side helper that reads this endpoint (or alternatively parses the decrypted value if it has already been server-side rendered into window.VFUK).

The session object returned contains:

interface SessionData {
  assuranceLevel:    number   // 0 = anonymous, 3 = logged in
  platformSessionId: string   // used in API URLs: /api/digital/v1/{platformSessionId}/...
  givenName:         string   // user's first name — shown in header
  numberOfAccounts:  number   // how many Vodafone accounts the user has
  // ... additional claims
}

The platformSessionId is the key value used by runHateoasLink() when constructing API request URLs — it is embedded in the path as a path segment, not a query parameter.

3onv consideration

The IDM integration is Vodafone platform infrastructure (@vfuk/* packages). For the 3onv spike, the same IDM middleware should work because it is brand-agnostic. However, the AUTH_COOKIE_PREFIX (eShop-auth) and the basePath (/web-shop/login) may need to change if 3onv runs under a different URL path.

Losing Extras

"Losing extras" is the scenario where a user changes their plan selection and the backend determines that one or more of their previously selected extras (add-ons) can no longer be applied to the new plan. The backend communicates this via notification codes in the plan selection response.

When does this happen?

When the user calls SELECT_PLAN (POST to the HATEOAS selectPlan link) or changes their commitment period, the backend may return a notification[] array containing one or more codes that indicate extras are being lost. These codes are stored in ExtraStore.notifications.

Notification codes that trigger "losing extras"

// ViewStore.ts — LOSING_EXTRAS_NOTIFICATIONS
const LOSING_EXTRAS_NOTIFICATIONS = [
  LOST_SMARTWATCH_BENEFIT,         // 'notification_simo-purchase_existing-smartwatch-removed'
  INSURANCE_AND_EXTRAS_OPT_IN,     // 'notification_simo-purchase_insurance-and-extras-opt-in'
  INSURANCE_OPT_IN,                // 'notification_simo-purchase_insurance-opt-in'
  EXTRAS_OPT_IN,                   // 'notification_simo-purchase_extras-opt-in'
]

The losingExtras getter in ViewStore

File: src/client/stores/ViewStore/ViewStore.ts lines 63–71

get losingExtras(): boolean {
  // Business segment always considered to be "losing extras" — forces extras page
  if (segment === 'business') return true

  // Not applicable if no notifications
  if (!extraStore?.notifications) return false

  // True if ANY notification code matches the losing-extras list
  return extraStore?.notifications.some((el) =>
    LOSING_EXTRAS_NOTIFICATIONS.includes(el.code)
  )
}

What losingExtras = true causes

Forces navigation to the extras page

ViewStore.losingExtras is checked in the journey step progression logic. If true, the app will not allow the user to skip the extras/add-ons page — they must visit it (even if they don't change anything) so the system can confirm their choices after the plan change.

AddOnNotification banner

On the extras/add-ons page, a banner component (AddOnNotification) reads extraStore.notifications and renders a list of the extras that have been removed. Each notification code maps to a human-readable message explaining which add-on was lost and why.

ExtraStore.lostExtras and lostExtrasIds

The ExtraStore also tracks which specific extras were lost via two computed properties:

  • lostExtras — array of extra objects that were previously selected but can no longer be applied
  • lostExtrasIds — just the IDs, used for UI comparison and deduplication

These are populated by diffing the user's previously selected extras against what the new plan supports, as indicated by the backend's extras response after plan selection.

Business segment special case

Business always shows extras page

For segment === 'business', losingExtras always returns true regardless of notification codes. This means business customers always see the add-ons page after plan selection — because business extras have different eligibility rules and the UI always prompts them to confirm their selections.

3onv consideration

Notification codes are hardcoded strings

The LOSING_EXTRAS_NOTIFICATIONS array contains Vodafone-specific notification code strings. If 3onv backend returns different notification codes for the same "losing extras" scenarios, the losingExtras getter will return false and users will not be redirected to the extras page. These constants need to be reviewed and updated as part of the 3onv migration.

PlanCardEntertainment (CRO — Off by Default)

This component is not currently running in production

PlanCardEntertainment is a CRO (Conversion Rate Optimisation) experiment component. It is controlled by the feature flag isCroPlanCardEntertainmentEnabled. As of the time of this spike, the flag is off and the standard PlanCard renders instead.

File: src/client/components/CRO/PlanCardEntertainment/PlanCardEntertainment.tsx
Swap-in point: src/client/components/molecules/PlanCardList/PlanCardList.tsx lines 158–204

How it differs from the standard PlanCard

AspectStandard PlanCardPlanCardEntertainment (CRO)
Benefits slot component CardBenefitsSlot EntertainmentBenefitsSlot — shows entertainment benefits in a prominent layout
Entertainment plan prop Not applicable — entertainment handled inline Receives entertainmentPlan, isEntertainmentPlanSelected, showEntertainmentPlan, toggleEntertainmentPlan
Plan toggle behaviour Standard select/deselect Includes extra toggle state for showing the entertainment sub-plan
When rendered Always (default) Only when isCroPlanCardEntertainmentEnabled === true

How the swap works in PlanCardList.tsx

// PlanCardList.tsx lines 158–204 (simplified)
{isCroPlanCardEntertainmentEnabled ? (
  <PlanCardEntertainment
    plan={plan}
    entertainmentPlan={entertainmentPlan}
    isEntertainmentPlanSelected={isEntertainmentPlanSelected}
    showEntertainmentPlan={showEntertainmentPlan}
    toggleEntertainmentPlan={toggleEntertainmentPlan}
    {...sharedProps}
  />
) : (
  <PlanCard
    plan={plan}
    {...sharedProps}
  />
)}

3onv consideration

Since this component is off by default, it does not affect the initial 3onv migration. The standard PlanCard is what needs to be adapted for 3onv content. If the CRO experiment is ever re-enabled, the same entertainment ID hardcoding risks apply (see the Entertainment Icons section).

SimoErrorModal — Global Error Modal Overview

File: src/client/components/molecules/SimoErrorModal/SimoErrorModal.tsx. Mounted once per page, near the root of every top-level page component: Simo.tsx, SeamlessMigration.tsx, P2PMigration.tsx, SimxDetails.tsx, PlanDetails.tsx.

SimoErrorModal is the app's catch-all error surface. It doesn't fetch or own any error data itself — it purely reads SimoStore.error (a single MobX observable) and decides which of four possible UI states to render. Any store, anywhere in the app, can trigger it by calling simoStore.processError(error).

Where the error comes from

// SimoStore.ts
processError = (error: Simo.Error) => {
  console.warn(error)
  const errorCode = error?.data?.errorCode
  this.error = error          // ← setting this is the ONLY trigger for the modal
  this.errorCode = errorCode
}

clearError = () => {
  this.error = null           // ← setting this back to null is the only way to dismiss it
}

processError is called from the catch block of nearly every async store action that talks to the backend: PlanStore (fetch plans, select plan), ExtraStore (toggle extra), FilterStore, ViewStore, MismatchStore, ToggleStore, KeepOrReplaceStore, and NewOrExistingStore. Whatever error object those stores catch — usually the rejected promise from runHateoasLink() — gets passed through mostly unmodified.

The four render states

simoStore.error becomes truthy
Is it a Seamless Migration journey with a redirect-eligible code?
↓ NO
Is errorCode === EXCEED_MAX_PLANS_ERROR_CODE?
YES → <MaxPlansTemplate> inside a plain <Modal>
↓ NO
Session-expired shape match?
errorCode === SESSION_EXPIRED_ERROR_CODE
+ message includes Authorization text
YES → session-expired <ErrorStatusModal> with "Log in" button
↓ NO
Fallback
Generic <ErrorStatusModal> — text looked up from API_ERRORS[errorCode], button is "Close" or "Refresh"

SimoErrorModal — Every Scenario & Its Error Code

#ScenarioTrigger conditionError codeWhat renders
1 Max plans reached errorCode === EXCEED_MAX_PLANS_ERROR_CODE warning_simo-purchase_select-plan_max_packages <MaxPlansTemplate> — "Order limit reached" / "The limit is 10 plans" copy, button "Go to basket"
2 Session expired errorCode === SESSION_EXPIRED_ERROR_CODE AND errorMessage includes "Required request header 'Authorization'" error_simo-purchase_select-plan_failed Heading "Your session has expired", text "Your basket will be emptied, please log back in to be able to see you basket.", button "Log in" → redirects to /web-shop/login (or a seamless-migration-specific login URL)
3 Generic device/plan/insurance failures Any other key present in API_ERRORS (11 keys — see next section) e.g. error_simo-purchase_get-plans_failed, error_device-purchase_select-plan_failed, SYS_ERR_DEF, … Generic <ErrorStatusModal>, heading "Sorry something has gone wrong", body text is the matching API_ERRORS string
4 Truly unknown error code errorCode not found in API_ERRORS at all Any value, or missing Same generic modal, but body text falls back to DEFAULT_ERROR_TEXT = "An unknown error has occurred"
5 Close vs. Refresh button error.action is one of 'selectPlan' / 'removeExtra' / 'selectExtra' / 'keepReplacePackage' → "Close" button (just dismisses); anything else → "Refresh" button (reloads the page) See Gotchas — in practice only 'keepReplacePackage' ever actually reaches this check
6 Seamless Migration redirect (bypasses the modal entirely) isSeamlessMigrationJourneyPath is true AND (missing customerTransferTxnId cookie OR errorCode is one of the 9 SEAMLESS_MIGRATION_ERROR_CODES) DATA_NOT_FOUND_DB_FOR_JOURNEYTXNID, TRANSACTION_ID_INVALID_UUID, SJC_INVALID_INPUT, SJC_MISSING_MANDATORY_INPUT, NO_LEAD_OFFERS_FOUND, REQUIRED_CUSTOMER_INFO_NOT_FOUND, REQUIRED_DATA_MISSING, error_simo-purchase_get-plans_failed, warning_simo_purchase_selected_plan_id_is_not_available No modal at all — full page redirect via window.location.assign('/customer-transfer/error'). Bypassed entirely when running locally (isLocal check in errorRedirect.ts)
7 Invalid segment on Seamless Migration (explicit exception to #6) errorCode === SEAMLESS_MIGRATION_INVALID_SEGMENT SJC_INVALID_SEGMENT Component returns null — nothing renders at all, no modal, no redirect

SimoErrorModal — Hardcoded vs. Contentful

Everything in this component is hardcoded. Nothing is Contentful-driven.

Unlike almost every other UI surface documented elsewhere in this doc (plan cards, marketing content, FAQs), none of the text, headings, or button labels in SimoErrorModal come from Contentful. Every string is a JS constant checked in to the repo.

Piece of contentSourceFile
Modal heading ("Sorry something has gone wrong")Hardcoded constant ERROR_TITLEconstants.ts
Body text per error code (11 messages)Hardcoded constant API_ERRORS mapconstants/apiErrors.ts
Fallback body textHardcoded constant DEFAULT_ERROR_TEXTconstants.ts
Session-expired heading & bodyHardcoded constant SESSION_EXPIRED_MODALconstants.ts
Session-expired "Log in" buttonHardcoded string, inline in SimoErrorModal.tsxSimoErrorModal.tsx
"Close" / "Refresh" button textHardcoded strings, inline in SimoErrorModal.tsxSimoErrorModal.tsx
Max plans modal — full copy & "Go to basket" buttonHardcoded JSX text, including the number 10 as a local const maxPlans = 10MaxPlansTemplate.tsx
Which error codes trigger which behaviourHardcoded constants: EXCEED_MAX_PLANS_ERROR_CODE, SESSION_EXPIRED_ERROR_CODE, SESSION_EXPIRED_ERROR_MESSAGE, SEAMLESS_MIGRATION_ERROR_CODES, SEAMLESS_MIGRATION_INVALID_SEGMENTconstants.ts

The full API_ERRORS map, verbatim:

export const API_ERRORS = {
  cannot_find_device: 'We have not been able to get the details for this device',
  'error-invalid-hateoas-link': 'We have not been able to get the details for this device',
  'error_device-purchase_get-device-variants_failed': 'We have not been able to get the details for this device',
  'error_device-purchase_get-insurance-options_failed': 'We have not been able to get the Insurance products for this device',
  'error_device-purchase_get-plans_failed': 'We have not been able to get the plans for this device',
  'error_device-purchase_get-specifications_failed': 'We have not been able to get the specifications for this device',
  'error_device-purchase_select-insurance-option_failed': 'We have not been able to add this insurance product to your basket',
  'error_device-purchase_select-plan_failed': 'We have not been able to add this plan to your basket',
  'error_simo-purchase_get-plans_failed': 'We have not been able to display the Sim Only plans',
  'error_simo-purchase_select-plan_failed': 'We have not been able to add this Sim Only plan to the basket',
  'warning_simo-purchase_select-plan_max_packages': 'You have reached the maximum number of packages within your bracket, please remove one if you would like to add another',
  'error_simo-purchase-generic-error': 'We are unable to continue this journey at this time. Please reload this page to try again',
  'error_simo-purchase_select-replace-error': 'We have not been able to replace this package at this time, please try again',
  SYS_ERR_DEF: 'We have not been able to get the details for this page',
  'error_simo-purchase_select-extra_failed': 'We have not been able to add this extra to your basket',
  'error_simo-purchase_remove-extra_failed': 'We have been unable to remove this extras from your basket',
}

Note that most of these keys are device-purchase errors (handset journeys), carried over from a shared error-message map — only a handful (simo-purchase prefixed) are actually reachable from the SIM-only plans journey this doc covers.

SimoErrorModal — Gotchas & Shape Mismatches

Two real inconsistencies were found while tracing this component end-to-end. Both are worth knowing about if you're debugging why an error modal shows the wrong text or the wrong button.

Gotcha 1 — the "Close" button logic is effectively dead code for 3 of its 4 conditions

SimoErrorModal.tsx checks error.action against 'selectPlan', 'removeExtra', 'selectExtra', and 'keepReplacePackage' to decide whether to show a "Close" button instead of "Refresh". But tracing the real service calls in simOnlyService.ts shows they attach the property step, not action:

// simOnlyService.ts — selectPlan()
const error = { ...err, step: 'selectPlan' }        // ← "step", not "action"

// simOnlyService.ts — selectExtra()
const error = { ...err, reload: false, step: 'selectExtra' }   // ← "step", not "action"

// simOnlyService.ts — removeExtra() follows the same "step" pattern

The only place action: is genuinely set on a real error object is KeepOrReplaceStore.onKeepOrReplaceFail():

// KeepOrReplaceStore.ts
this.simoStore.processError({
  errorCode: error.errorCode,
  action: 'keepReplacePackage',   // ← this one really does match
  inline: false,
})

The literal string action: 'selectPlan' only appears in SimoErrorModal.test.tsx's mocked test fixture — it doesn't reflect what production code actually sends. Practical effect: selecting a plan, selecting an extra, or removing an extra that fails will always fall through to the generic "Refresh" button branch (which reloads the whole page) rather than the intended "Close" (dismiss only) behaviour. Only the keep-or-replace failure path gets the "Close" treatment as designed.

Gotcha 2 — KeepOrReplace errors likely always show the generic fallback text

SimoErrorModal.tsx reads the error code from error?.data?.errorCode — i.e. it expects the code nested under a .data object (matching the shape { status, data: {...} } that @vfuk/web-middleware-request-utils's handleError() produces for real HTTP failures). But KeepOrReplaceStore.onKeepOrReplaceFail() constructs a flat object with errorCode at the top level, no .data wrapper:

// What SimoErrorModal reads:
const errorCode = get(error?.data, 'errorCode', '')   // looks for error.data.errorCode

// What KeepOrReplaceStore actually sends:
{ errorCode: error.errorCode, action: 'keepReplacePackage', inline: false }   // errorCode is top-level, no .data

Because error.data is undefined here, get() returns the fallback empty string, API_ERRORS[''] is undefined, and the modal text falls back to DEFAULT_ERROR_TEXT ("An unknown error has occurred") — regardless of what the actual keep- or-replace failure reason was. If you see this generic message when you'd expect a specific keep-or-replace error, this mismatch is very likely why.

Gotcha 3 — API_ERRORS is shared with the handset/device journey

Most keys in API_ERRORS are prefixed device-purchase, not simo-purchase. If a backend team renames or adds a SIM-only error code without updating this shared map, it silently falls back to DEFAULT_ERROR_TEXT rather than failing loudly — there's no build-time or runtime check tying backend error codes to this frontend map.

SimoBreadcrumbs — Overview

Files: src/client/components/molecules/SimoBreadcrumbs/SimoBreadcrumbs.tsx · src/client/helpers/breadcrumbsBuilder/breadcrumbsBuilder.ts

SimoBreadcrumbs renders the breadcrumb trail above the plan grid (e.g. "Back to plans" / "Upgrades and Offers" / "Add-ons"), plus two things that are easy to miss because they're invisible in the browser: a JSON-LD BreadcrumbList schema for SEO, and — conditionally — the consumer/business <SegmentToggle> switcher.

Guard condition

const breadcrumbContent = get(contentStore, 'content.breadCrumbContent')
if (!(breadcrumbContent && journeyType)) return null

Nothing renders at all — not even the JSON-LD schema — until both a matching Contentful entry exists and journeyType is set. If Contentful is missing the breadcrumb entry for a segment (see Hardcoded vs. Contentful), the whole component silently disappears — no fallback text is shown.

What actually renders — three pieces

PieceComponentNotes
Visible breadcrumb links <Breadcrumbs> from @source-web/breadcrumbs Only rendered on non-webview (!isWebView() — hidden inside the My Vodafone app webview). See the callout below — this currently shows no links.
SEO structured data <script type="application/ld+json"> Always rendered regardless of webview. Built from the same breadcrumbs array as the visible component.
Segment toggle <SegmentToggle> from SimoPlansTemplate/components/SegmentToggle Only rendered when toggleStore.shouldShowBusinessToggle is true — lets a user flip between consumer and business SIM-only pricing.
items prop is currently commented out (WIP, not a bug)

The <Breadcrumbs> component from @source-web/breadcrumbs declares items: Item[] as a required prop. In the current checked-in SimoBreadcrumbs.tsx the line that supplies it is commented out — this is a deliberate, temporary state for local testing and is expected to be uncommented before this lands as final:

<Breadcrumbs
  id='breadcrumbs'
  dataSelectorPrefix={SELECTORS.BREADCRUMBS}
  // items={breadcrumbs}          ← commented out (temporary)
  dataAttributes={{ [TEST_ATTR]: 'simo-breadcrumbs' }}
/>

The breadcrumbs array is still fully built by breadcrumbsBuilder() and used to populate the JSON-LD schema regardless — so the SEO-facing trail is unaffected either way. If you're reading this in a build where the line is still commented, the visible desktop/mobile breadcrumb links simply won't render yet; re-add items={breadcrumbs} to restore them.

SimoBreadcrumbs — Link Shape & Build Pipeline

The shape of a single breadcrumb link

// breadcrumbsBuilder.types.ts
export interface IBreadcrumbs {
  id: string      // used as React key + anchor id; lowercased title for CMS-driven items
  text: string     // the visible label
  href: string     // absolute or relative URL
}

Example — a fully built breadcrumb array for a consumer, desktop, acquisition-journey visitor:

[
  { id: 'home',                text: 'Home',                    href: 'https://www.vodafone.co.uk' },        // ← from Contentful
  { id: 'mobile',               text: 'Mobile',                  href: 'https://www.vodafone.co.uk/mobile' }, // ← from Contentful
  { id: 'best-sim-only-deals',  text: 'Our best Sim Only deals',  href: 'https://www.vodafone.co.uk/sim-only/best-sim-only-deals#plans' },
]

How breadcrumbsBuilder() assembles the array

Four independent pieces are concatenated together, in a fixed order. Which pieces actually contribute items depends on device, journey type, and page view:

contentBreadcrumbs()
from Contentful breadcrumbItems[]
upgradesAndOffersBreadcrumbs()
hardcoded, journeyType-gated
simoBreadcrumbs()
hardcoded, page/device-aware
Desktop: all three, in order
Mobile: Contentful items are dropped entirely — only upgradesAndOffersBreadcrumbs() + simoBreadcrumbs() run, and every label gets prefixed "Back to "
FunctionProducesConditionSource
contentBreadcrumbs() 0..N items, one per Contentful breadcrumbItems entry Desktop only — entirely skipped when isMobile CMS
upgradesAndOffersBreadcrumbs() 0 or 1 item: { id: 'upgrades-and-offers', text: 'Upgrades and Offers', href: URLS.UPGRADES_AND_OFFERS } journeyType is upgrade or secondline Hardcoded — text and URL (/upgrade-and-offers) are JS constants
getInitialSimoBreadcrumbs() (inside simoBreadcrumbs()) 1 item (the current page): { id: 'best-sim-only-deals', text: isMobile ? 'Back to plans' : title, href: '\{path\}#plans' }. On mobile, acquisition/tariff-migration journeys also prepend { id: 'home', text: 'Back to Shop', href: '.../business' or '.../mobile' } Always runs Hardcoded structure; title and path are passed in (see below)
getExtrasBreadcrumbs() / getInsuranceBreadcrumbs() (inside simoBreadcrumbs()) 1 item: "Add-ons" (upgrade journey) or "Insurance" (all other journeys) — only on the extras page view view === PAGE_VIEW.EXTRAS Hardcoded — text and href suffix (#extras / #insurance) are JS constants
Tariff migration override (inside simoBreadcrumbs()) Overwrites the last breadcrumb's text to "Change plan", short-circuits everything else isTariffMigration is true Hardcoded

Where path and title come from — the bold, non-link "current page" crumb

The last item in the trail — the one rendered bold and unclickable because it represents the current page (e.g. "Our best Sim Only deals" in the screenshot above, after "Home" and "SIM only") — is built entirely inside getInitialSimoBreadcrumbs() from two values passed down from SimoBreadcrumbs.tsx:

// SimoBreadcrumbs.tsx
breadcrumbsBuilder({
  ...
  path: isBusiness
    ? 'https://www.vodafone.co.uk/business/business-sim-only#plans'   // ← hardcoded, brand + segment specific
    : 'https://www.vodafone.co.uk/sim-only/best-sim-only-deals#plans', // ← hardcoded, brand + segment specific
  title: get(contentStore, 'content.breadCrumbTitle'),                  // ← this becomes the bold text
})

// breadcrumbsBuilder.ts — getInitialSimoBreadcrumbs()
{
  id: 'best-sim-only-deals',
  text: isMobile ? 'Back to plans' : title,   // ← desktop uses "title" verbatim
  href: `${path}#plans`,
}

path is two literal, fully-qualified vodafone.co.uk URLs baked directly into the component — this is the single biggest brand-awareness blocker in this component (see Brand-Aware Checklist).

title — the actual bold text — comes from content.breadCrumbTitle, set once in ContentStore.ts per segment. Neither branch is Contentful. Traced all the way down:

// ContentStore.ts
// Business:
breadCrumbTitle: 'Business SIM Only',                             // ← hardcoded literal, right here in this repo

// Consumer:
breadCrumbTitle: systemText.getCopy('SIMO_BREADCRUMB_DEFAULT'),   // ← looked up from an npm package, not Contentful

// @vfuk/utils-shop-constants/dist/systemTextResources.js — the actual value:
SIMO_BREADCRUMB_DEFAULT: 'Our best Sim Only deals',
Three different flavours of "hardcoded" in this one field

breadCrumbTitle is a good example of why "hardcoded vs. CMS" isn't always a clean binary. Business's value is hardcoded directly in this repo's ContentStore.ts. Consumer's value is hardcoded too, but lives in a separate shared npm package (@vfuk/utils-shop-constants) used across multiple Vodafone shop microsites — so changing "Our best Sim Only deals" for consumer requires a release of that package, not a change in this repo, and definitely not a Contentful edit. For 3onv, both are equally blocking: neither can be swapped by simply pointing at different Contentful content.

SimoBreadcrumbs — Hardcoded vs. Contentful

Piece of contentSourceDetail
The 0..N "Home / Mobile / …" links at the start of the desktop trail CMS Contentful entry with contentEntryKey = package_list_simo_breadcrumb_consumer or package_list_simo_breadcrumb_business (built from CONTENTFUL_CONTENT_ENTRY_KEYS.breadcrumbs = 'package_list_simo_breadcrumb' + _<segment>). Found inside the page's marketingContent array — the same array documented in Marketing Components.
Each link's label CMS breadcrumbItems[].fields.title
Each link's URL CMS breadcrumbItems[].fields.externalUrl (takes priority) or .internalLink.fields.urlPath, resolved by getPageUrl() — falls back to an empty string if neither is set
Bold, non-link "current page" breadcrumb ("Our best Sim Only deals") Hardcoded (both segments — not CMS) Consumer: systemText.getCopy('SIMO_BREADCRUMB_DEFAULT') → resolves to the literal string 'Our best Sim Only deals', hardcoded inside the external npm package @vfuk/utils-shop-constants. Business: hardcoded literal 'Business SIM Only' directly in this repo's ContentStore.ts. Neither touches Contentful.
"Upgrades and Offers" label & URL Hardcoded Text literal + URLS.UPGRADES_AND_OFFERS = '/upgrade-and-offers' in constants.ts
"Add-ons" / "Insurance" labels & URL suffixes Hardcoded Inline string literals in breadcrumbsBuilder.ts (extrasBreadcrumb() / insuranceBreadcrumb())
"Back to plans" / "Back to Shop" / "Back to <X>" (mobile) Hardcoded String literals and a `Back to ${el.text}` template in breadcrumbsBuilder.ts
"Change plan" (tariff migration) Hardcoded String literal in simoBreadcrumbs()
Base page path (used to build every #plans / #extras / #insurance href) Hardcoded Two full vodafone.co.uk URLs literal in SimoBreadcrumbs.tsx: https://www.vodafone.co.uk/sim-only/best-sim-only-deals#plans (consumer) and https://www.vodafone.co.uk/business/business-sim-only#plans (business)
"Back to Shop" home link URL (mobile only) Hardcoded `https://www.vodafone.co.uk/${isBusiness ? 'business' : 'mobile'}` in breadcrumbsBuilder.ts

How to add or update a breadcrumb link

CMS-driven links (the "Home / Mobile / …" trail)

Edit the Contentful entry package_list_simo_breadcrumb_consumer (or _business) and add/reorder/remove entries in its breadcrumbItems collection. Each item needs a title and either an externalUrl or an internalLink. No frontend deploy required. Order in the array = order rendered.

Hardcoded links (Upgrades and Offers, Add-ons, Insurance, Change plan, Back to…)

Requires a code change in src/client/helpers/breadcrumbsBuilder/breadcrumbsBuilder.ts — edit the relevant function (upgradesAndOffersBreadcrumbs(), extrasBreadcrumb(), insuranceBreadcrumb(), or the mobile "Back to" mapping inside simoBreadcrumbs()) and the matching test in breadcrumbsBuilder.test.ts.

Adding a brand-new breadcrumb type (e.g. a new page view)

Add a new get<X>Breadcrumbs() function following the existing pattern (returns IBreadcrumbs[]), then call it conditionally inside simoBreadcrumbs() based on props.view or props.journeyType. Remember: the array order is the render order, and every item needs a unique id.

SimoBreadcrumbs — Brand-Aware Checklist (3onv)

For a Three-branded (3onv) journey to show correct breadcrumbs, every hardcoded vodafone.co.uk reference in this component's pipeline needs a brand-aware equivalent. Here is every single one, in the order a developer would encounter them:

#Hardcoded valueFileBrand-aware fix needed
1 https://www.vodafone.co.uk/sim-only/best-sim-only-deals#plans SimoBreadcrumbs.tsx (path — consumer) Needs to resolve to a Three-equivalent base URL for 3onv consumer journeys
2 https://www.vodafone.co.uk/business/business-sim-only#plans SimoBreadcrumbs.tsx (path — business) Needs to resolve to a Three-equivalent base URL for 3onv business journeys
3 https://www.vodafone.co.uk/${isBusiness ? 'business' : 'mobile'} breadcrumbsBuilder.ts (getInitialSimoBreadcrumbs(), mobile "Back to Shop" home link) Same domain problem, only reachable on mobile for acquisition/tariff-migration journeys
4 'Business SIM Only' breadcrumb title ContentStore.ts Hardcoded string with zero CMS involvement — would show Vodafone-flavoured business copy on a 3onv business page unless changed
5 Contentful entries package_list_simo_breadcrumb_consumer / _business Contentful space These already vary by segment but not by brand. A 3onv journey reusing the same content-entry keys would show Vodafone's "Home / Mobile" links pointing at vodafone.co.uk. Needs either brand-specific Contentful entries (e.g. suffixed _consumer_3onv) or a brand parameter threaded into the entry key lookup in ContentStore.ts line breadCrumbContentEntryKey.
6 URLS.UPGRADES_AND_OFFERS = '/upgrade-and-offers' constants.ts Relative path — least risky of the group, but confirm the equivalent page exists on the 3onv domain/routing structure before reusing as-is
Fix brand-awareness before (or alongside) re-enabling items

While the items prop is commented out for local testing (see Overview), any brand-awareness bugs in the hardcoded URLs above only leak into the JSON-LD SEO schema — most manual testers checking the visible page won't catch them. The moment items={breadcrumbs} is restored, all six items in this table become visible, user-facing behaviour simultaneously. Worth fixing brand-awareness before or in the same change as re-enabling the prop, so 3onv testing doesn't uncover a batch of URL bugs after the fact.

SEO Overview & SimoSeoTags

There is exactly one production component responsible for rendering page-level SEO tags: SimoTemplate/components/SimoSeoTags/SimoSeoTags.tsx. It is mounted once, at the top level, in SimoTemplate.tsx, so every route that renders through SimoTemplate (plans, review, extras, insurance, etc.) inherits it "for free" — none of those nested templates set their own SEO tags. No other template or page imports it.

It wraps the @source-web/head-tags design-system component (HeadTags):

const SimoSeoTags = () => {
  const [seoAsset, setSeoAsset] = useState({})
  const { contentStore } = useContext(SimoContext)
  const { businessContent, consumerContent } = contentStore
  const { pathname } = useLocation()

  useEffect(() => {
    const content = pathname?.includes('business') ? businessContent : consumerContent
    setSeoAsset(get(content, 'seoAsset', {}))
  }, [pathname])

  return (
    <HeadTags
      title={seoAsset.title}
      canonical={seoAsset.canonicalUrl}
      description={seoAsset.description}
      robots={seoAsset.robots}
      ampHtml={seoAsset.ampHtml}
      openGraphType={seoAsset.openGraphType || 'website'}
      openGraphTitle={seoAsset.openGraphTitle}
      openGraphUrl={seoAsset.openGraphUrl}
      openGraphImage={openGraphImage || twitterImage}
      openGraphDescription={seoAsset.openGraphDescription}
      twitterTitle={seoAsset.twitterTitle}
      twitterDescription={seoAsset.twitterDescription}
      twitterImage={twitterImage}
      twitterSite={seoAsset.twitterSite}
      twitterCreator={seoAsset.twitterCreator}
      twitterCard={seoAsset.twitterCard}
      schema={seoAsset.schema?.fields?.schema || []}
    />
  )
}

Every value is 100% CMS-driven — there is no hardcoded copy inside this file. seoAsset comes from ContentStore.ts:

seoAsset: get(pageContent, `${CONTENTFUL_PATH_CONFIG.basePath}.${CONTENTFUL_PATH_CONFIG.pageSeo}.fields`, {})

i.e. pulled straight out of the pageSeo Contentful content-model key (constants.ts, CONTENTFUL_PATH_CONFIG). The only branch in SimoSeoTags is consumer vs. business segment (via pathname.includes('business')) — that is a customer-segment split, not a brand split.

Props defined by HeadTags but never populated here

The underlying @source-web/head-tags component also supports hrefLang, htmlLang, and organisationSchema props (see its type definitions), but SimoSeoTags never passes them. So this app currently ships with no hreflang tags, no explicit html lang override, and no organisation-level JSON-LD schema beyond whatever page-level seoAsset.schema Contentful supplies.

Duplicate/Competing Mechanisms

SEO/head-tag logic is not unified — there are three separate, independent code paths:

#MechanismLocationNotes
1 SimoSeoTags via @source-web/head-tags SimoTemplate.tsx Main, CMS-driven mechanism. Sets title, canonical, description, robots, OG, Twitter, JSON-LD.
2 react-helmet's <Helmet> src/client/pages/NotFound/NotFound.tsx A completely different library from #1. Only sets one tag — <meta name="prerender-status-code" content="404" />. No title, canonical, description, OG, Twitter, or schema is set for the 404 page.
3 Static robots config string src/server/common/config/core.config.ts (robots: 'User-agent: *\nDisallow:') Site-wide, hardcoded, non-CMS robots directive at the server-config level — separate from and potentially conflicting with the per-page seoAsset.robots field rendered by SimoSeoTags.

There is no shared <Seo>/<Head> wrapper reused across surfaces — each surface implements its own head-tag logic independently, which is why the 404 page has materially weaker SEO coverage than every other route.

Brand-Aware Audit (3onv)

Searching the repo for VFRED, VFT03, a brand resolver, or a BrandStore returns zero live matches. Brand resolution exists as a dependency but is fully disabled:

Conclusion: there is no brand-conditional logic anywhere in the SEO path. At the component level, SimoSeoTags.tsx has zero hardcoded brand strings — every SEO field (title/description/canonical/OG/Twitter/schema) passes straight through whatever Contentful returns, so in isolation the component itself is not "hardcoded to Vodafone". The risk is architectural: the rest of the app around it is single-brand throughout, so if a 3onv (Three) journey ever reused this exact deployment without a brand-scoped Contentful space, the SEO tags themselves might vary by CMS content, but surrounding on-page links would still leak Vodafone URLs. Same class of finding as the Breadcrumbs Brand-Aware Checklist:

Hardcoded valueFileRelevance to SEO
https://cdn.vodafone.co.uk/shared-assets/ src/client/index.tsx, index.federated.tsx Base asset CDN — would affect any relative image asset used as an OG/Twitter image fallback.
CONTENTFUL_IMAGE_DOMAIN = 'https://images.vodafone.co.uk/gbnnsauqav4t' src/client/constants/constants.ts Hardcoded Vodafone CDN domain — same code path used to resolve openGraphImage/twitterImage asset URLs.
https://www.vodafone.co.uk/business/business-sim-only#plans, https://www.vodafone.co.uk/sim-only/best-sim-only-deals#plans SimoBreadcrumbs.tsx Feeds breadcrumb JSON-LD-adjacent structured navigation rendered alongside page SEO tags.
`https://www.vodafone.co.uk/${isBusiness ? 'business' : 'mobile'}` breadcrumbsBuilder.ts Same domain problem, feeds the same structured navigation.
themeWS10 Vodafone WS10 design system, no brand switch src/client/index.tsx Global theme setup with no brand-conditional branch.
Plausible brand-canonical mismatch

If a Three (3onv) deployment reused this exact codebase and only swapped the Contentful space (so seoAsset.canonicalUrl correctly returns a three.co.uk URL), the hardcoded vodafone.co.uk URLs above would still render inside breadcrumb navigation and the asset CDN base — a canonical tag that says one domain while on-page structured navigation links to another.

Gaps & Dead Code

Prerender

The app uses @vfuk/lib-web-prerender to serve cached, server-side rendered HTML to unauthenticated visitors. This avoids the cost of React hydration for every anonymous page load and improves Core Web Vitals / SEO.

How it works at a high level

1. A headless Chrome bot visits the page

@vfuk/lib-web-prerender orchestrates a headless browser that renders the React app fully (including all MobX store hydration and API calls). The resulting HTML is stored in Redis.

2. Redis cache (5-minute TTL)

The cached HTML is stored in Redis with a TTL of cacheSeconds: 300 (5 minutes). In local development there is no Redis instance, so MockRedis is used — meaning prerender effectively does nothing locally.

// prerender.middleware.ts
const redisClient = process.env.PRERENDER_SERVICE_URL
  ? new Redis(process.env.PRERENDER_SERVICE_URL)
  : new MockRedis()

3. Incoming requests check the cache

The Express prerenderMiddleware checks the Redis cache. If a valid entry exists for the current URL + segment, the cached HTML is served immediately — no React rendering needed.

4. Skip conditions

Prerender is skipped (cache not used) if:

  • req.cookies.basketId is present (user has an active basket)
  • req.cookies.JourneyID is present (user has an active journey)
  • PRERENDER_SERVICE_URL env var is not set (local dev)
  • Session assurance level ≥ 3 (user is authenticated)
// prerender.middleware.ts
skipChecker: (req) => req.cookies.basketId

The window._isPrerender flag

File: src/client/index.tsx

When the prerender bot is rendering the page, it sets a global flag on the window object before React boots. The React bootstrap code reads this:

// src/client/index.tsx
const isPrerender = window._isPrerender === true

// Different root creation based on context:
if (rootElement.hasChildNodes()) {
  // SSR/prerender has already put HTML in the DOM — hydrate it
  hydrateRoot(rootElement, <App />)
} else {
  // Fresh browser render — create a new root
  createRoot(rootElement).render(<App />)
}

CSS injection during prerender

CSS-in-JS (styled-components) normally injects styles via the CSSOM API, which is invisible to the prerender bot's HTML snapshot. To ensure styles are captured in the static HTML:

// src/client/index.tsx
<StyleSheetManager disableCSSOMInjection={isPrerender}>
  <App />
</StyleSheetManager>

// When isPrerender is true:
//   → styles injected as <style> tags in the HTML (visible to snapshot)
// When isPrerender is false:
//   → styles use faster CSSOM API (standard browser behaviour)

Cookies retained in cached HTML

// prerender.middleware.ts
cookiesToRetain: ['features', 'customerSegment']
// These two cookies are baked into the cached HTML so that the correct
// segment variant (consumer/business) and feature flags are preserved
// in the cached snapshot.

Summary table

AspectValue
Package@vfuk/lib-web-prerender
Cache backendRedis (production) / MockRedis (local — no caching)
Cache TTL300 seconds (5 minutes)
Env var to enablePRERENDER_SERVICE_URL
Skipped whenbasketId cookie present; user logged in
Cookies baked infeatures, customerSegment
Root modehydrateRoot (prerender) vs createRoot (fresh)
CSS modedisableCSSOMInjection={true} during prerender

3onv consideration

Prerender is brand-agnostic but URL-keyed

Redis cache keys are derived from the request URL. If 3onv runs under a different URL path (e.g. /three/sim-only/ vs /sim-only/), prerender will work automatically for that path with no code changes. The customerSegment cookie baking may need reviewing if 3onv uses different segment identifiers.

makeAPIRequest / runHateoasLink()

File: src/client/services/simOnlyService/helpers/makeAPIRequest/makeAPIRequest.ts

All backend API calls in this app flow through a single function: runHateoasLink(). It is the gateway between the MobX stores and the backend. It handles URL construction, HTTP method overrides, authentication headers, and special HATEOAS response actions.

Flow diagram

Store calls runHateoasLink(link, method, body)
1. URL construction
Prepend /api/digital/v1 or /api/digital/v2 to the link href
2. HTTP method override for PATCH/DELETE
POST with X-HTTP-Method-Override: PATCH header via setHTTPMethodOverride()
3. requestInstance from @vfuk/web-middleware-request-utils
Adds Accept: application/hal+json; handles auth headers
4. Response received
5. Check response _links for special actions
sign-in → redirect to IDM login
or
go-to-basket → redirect to checkout
or
normal response → returned to calling store

URL construction in detail

// Backend HATEOAS link href comes in two forms:
// v1: "/simo/v1/journey/abc123/plans"
// v2: "/simo/v2/journey/abc123/plans"

// runHateoasLink detects the version and prepends the correct proxy path:
const prefix = href.includes('/v2/') ? '/api/digital/v2' : '/api/digital/v1'
const fullUrl = `${prefix}${href}`
// → "/api/digital/v1/simo/v1/journey/abc123/plans"
// The Express /api proxy middleware strips the prefix and forwards to DXL

patchHateoasLinkWithQueryParam()

A utility function used before calling runHateoasLink() when a query parameter needs to be appended to a HATEOAS href. For example, the commitment period override for basic upgrades uses this to replace the commitment_period query param in the GET_PLANS href before the plans call is made.

// Usage pattern:
const modifiedLink = patchHateoasLinkWithQueryParam(
  originalLink,
  'commitment_period',
  '12+Months'
)
await runHateoasLink(modifiedLink, 'GET')

HTTP methods handled

Logical methodActual HTTP method sentExtra header
GETGET
POSTPOST
PATCHPOSTX-HTTP-Method-Override: PATCH
DELETEPOSTX-HTTP-Method-Override: DELETE

PATCH and DELETE are sent as POST because some proxy layers and CDN configurations block non-standard HTTP methods. The backend reads the override header and treats the request as a PATCH or DELETE accordingly.

Special HATEOAS response handling

// After receiving a response, runHateoasLink checks _links:
if (response._links?.['sign-in']) {
  // Session has expired or user needs to authenticate
  // → redirect to IDM login flow via /web-shop/login
  window.location.href = '/web-shop/login'
  return
}

if (response._links?.['go-to-basket']) {
  // Plan selection is complete and the backend wants to move to checkout
  // → redirect to the basket/checkout URL from the link
  window.location.href = response._links['go-to-basket'].href
  return
}

// Otherwise: return response data to the calling store

3onv consideration

All API URLs are HATEOAS-driven — no hardcoding beyond the initial journey URL

runHateoasLink() never constructs backend paths itself — it only follows hrefs given by the backend. The only entry point URL that is hardcoded is the initial journey call (/api/digital/v1/simo/v1/journey or similar). If 3onv uses a different initial endpoint, only that one constant needs changing. All subsequent calls follow the backend's own _links.

Mismatch Codes

When the backend detects a conflict between the user's login state and their basket contents (e.g. logged in as a consumer but shopping as a business, or session expired with items in basket), it returns one or more notification objects in the API response. The frontend intercepts these and shows a modal with contextual messaging and action buttons.

getMismatchCodes() — the detection function

File: src/client/helpers/getMismatchCodes/getMismatchCodes.ts

const getMismatchCodes = (response: Object = {}): string => {
  // 1. Extract the notification array from the response
  const notificationArray = get(response, 'notification', [])

  // 2. Find the FIRST notification code that exists in MISMATCH_STATUS_CODES
  const notification = notificationArray.find((singleNotification) => {
    return !!MISMATCH_STATUS_CODES[singleNotification.code]
  })

  // 3. Return the code string (or undefined if none matched)
  return notification && notification.code
}

Only the first matching code is returned. If the backend sends multiple mismatch notifications, only the first one that has a corresponding entry in MISMATCH_STATUS_CODES is acted on.

How the modal is triggered

1. API response arrives

After any plan-selection or segment-toggle API call, the response is passed through getMismatchCodes(response).

2. Code found

If a mismatch code is returned, MismatchStore.setMismatchCode(code) is called.

3. Modal renders

MismatchStore.mismatchCode being non-null triggers the MismatchModal component to render. The modal reads heading, body, and CTA labels from MISMATCH_STATUS_CODES[code].

4. User acts

Each CTA button calls the function named in acceptFunction or declineFunction — these are string keys mapped to actual handler functions in MismatchStore.

Complete mismatch code reference

File: src/client/constants/mismatchStatusCodes.ts

Code Heading Scenario Accept CTA → handler Decline CTA → handler
warning_simo-purchase_set-segment-type_previous-business-package-segment-mis-match Before we make this change User switching to consumer but has business items in a previous basket Continue and empty my basket → continueAndClearExtras No thanks → keepBasketAndResetToggle
warning_simo-purchase_set-segment-type_previous-consumer-package-segment-mis-match Before we make this change User switching to business but has consumer items in a previous basket Continue and empty my basket → continueAndClearExtras No thanks → keepBasketAndResetToggle
warning_simo-purchase_set-segment-type_current-business-package-segment-mis-match Before we make this change User switching to consumer but has business items in the current journey Continue and shop as a consumer → continueAndClearExtras No thanks → keepBasketAndResetToggle
warning_simo-purchase_set-segment-type_current-consumer-package-segment-mis-match Before we make this change User switching to business but has consumer items in the current journey Continue and shop as a business → continueAndClearExtras No thanks → keepBasketAndResetToggle
warning_simo-purchase_set-segment-type_business-package-segment-mis-match Before we make this change Generic segment mismatch — business items, continue shopping scenario Continue and empty my basket → continueAndClearExtras No thanks → keepBasketAndResetToggle
warning_simo-purchase_set-segment-type_consumer-package-segment-mis-match Before we make this change Generic segment mismatch — consumer items, continue shopping scenario Continue and empty my basket → continueAndClearExtras No thanks → keepBasketAndResetToggle
warning_simo-purchase_journey_existing-business-newJourney-login-mismatch Before you continue Logged in as consumer but has business items from a previous journey Continue and empty my basket → emptyBasketAndContinue Log out and try again → logOut (+ Switch account link)
warning_simo-purchase_journey_existing-consumer-newJourney-login-mismatch Before you continue Logged in as business but has consumer items from a previous journey Continue and empty my basket → emptyBasketAndContinue Log out and try again → logOut (+ Switch account link)
warning_simo-purchase_journey_existing-business-thisJourney-login-mismatch Before you continue Logged in as consumer but has business items from this journey Continue and shop as a consumer → emptyBasketAndContinue Log out and try again → logOut (+ Switch account link)
warning_simo-purchase_journey_existing-consumer-thisJourney-login-mismatch Before you continue Logged in as business but has consumer items from this journey Continue and shop as a business → emptyBasketAndContinue Log out and try again → logOut (+ Switch account link)
warning_session-expired_login_or_empty-basket
(MISMATCH_ERROR_CODES.SESSION_EXPIRED_LOGIN_OR_EMPTY_BASKET)
Before you continue Session expired; basket has items — user can sign in or clear basket Sign in and continue → signIn Empty basket and continue → emptyBasketAndContinue
warning_session-expired_login
(MISMATCH_ERROR_CODES.SESSION_EXPIRED_LOGIN)
Log back in to continue Session expired due to inactivity — must log back in Log in → signIn No decline CTA
NOT_ELIGIBLE_UPGARDES_OR_SECONDLINE
(MISMATCH_ERROR_CODES.NOT_ELIGIBLE_SEGMENT_DEEPLINK)
You're not eligible for a SIM only upgrade yet User deep-linked to a plan they can't upgrade to yet Add as additional plan → simoSecondline Upgrade to Phone Plan → handsetPage
warning-simo-purchase-lms-is-down
(MISMATCH_ERROR_CODES.LMS_DOWN_DEEPLINK)
Uh oh, something went wrong Loyalty Management System is down — cannot process upgrade Log out → logOut No decline CTA

Handler function reference

Handler keyWhat it does
continueAndClearExtrasClears extras from the basket, confirms the segment switch, continues the journey
keepBasketAndResetToggleCancels the segment toggle — keeps the user on their existing segment; dismisses modal
emptyBasketAndContinueEmpties the basket entirely then continues with the current login/segment
signInRedirects to /web-shop/login — triggers IDM OAuth2 flow
logOutCalls the logout endpoint and redirects to the sign-out landing page
simoSecondlineRedirects to the SIM-only second-line/additional plan purchase flow
handsetPageRedirects to the handset/phone upgrade page

3onv consideration

All mismatch code strings are Vodafone-specific

These are backend-generated notification codes. If the 3onv backend uses the same DXL notification framework with the same code strings, this will work without changes. If 3onv uses different codes, none of the mismatches will be caught — getMismatchCodes() will return undefined for all notifications and no modal will be shown. The MISMATCH_STATUS_CODES map and MISMATCH_ERROR_CODES constants will need extending with 3onv-specific codes.

Marketing Components

The SimoPlansTemplate renders a <MarketingComponent> at the very bottom of the page, below all plans and notifications. A second slot exists for pre-body marketing content (used in other templates). Both slots are populated via ContentStore using a tag-based filtering system against Contentful content.

Template: src/client/templates/SimoPlansTemplate/SimoPlansTemplate.tsx
ContentStore: src/client/stores/ContentStore/ContentStore.ts
Component: src/client/components/molecules/MarketingComponent/MarketingComponent.tsx

Where the content comes from in Contentful

The Contentful page response has a top-level structure. Two fields in that structure feed the marketing slots:

// CONTENTFUL_PATH_CONFIG (constants.ts)
basePath:                 'body.items'
marketingContent:         'fields.marketingContent'          // slot 1 — pre/mid page
postBodyMarketingContent: 'fields.postBodyMarketingContent'  // slot 3 — below all plans

// So the full paths resolved in mapPageContent() are:
// body.items.fields.marketingContent          → this.content.marketingContent
// body.items.fields.postBodyMarketingContent  → this.content.postBodyMarketingContent

Both fields are arrays of Contentful entries. Each entry has a fields.tags string array — this is how the frontend decides whether to render that entry for the current visitor.

Tag-based filtering — shouldShowContentByTag()

File: src/client/stores/helpers/shouldShowContentByTag/shouldShowContentByTag.ts

This helper takes a set of boolean checks plus the tag array and returns true only if all checks pass:

const shouldShowContentByTag = (
  checks: boolean[],       // mandatory pre-conditions (e.g. has slot tag)
  tags: string[],          // full tags array from the CMS entry
  customerSegmentCmsKey?,  // e.g. 'consumer' or 'business'
  customerJourneyTypeCmsKey?  // e.g. 'acquisition', 'upgrade', 'tariffmigration'
) => {
  if (!customerJourneyTypeCmsKey) return false  // always false if no journey type

  checks.push(tags.includes(customerJourneyTypeCmsKey))  // must match journey

  if (customerSegmentCmsKey) {
    checks.push(tags.includes(customerSegmentCmsKey))    // must match segment
  }

  return Boolean(checks.every((c) => !!c))  // ALL must be true
}

The two marketing slots

SlotTag requiredContentStore methodWhere rendered
Slot 1 (main / pre-plan area) slot1 getMarketingContent() Used in other templates; also feeds the banner carousel
Slot 3 (post-body) slot3 getPostBodyMarketingContent() Bottom of SimoPlansTemplate — below all plans
Date countdowns datecountdowns getDateCountDownContent() Used in promotional countdown banners

There is no slot 2 defined in MARKETING_SLOT_TAG_NAMES. The constant only has SLOT_1, SLOT_3, and DATE_COUNTDOWNS.

getPostBodyMarketingContent() — full filter logic

// ContentStore.ts
getPostBodyMarketingContent = () => {
  const journeyType = this.simoStore.journeyType  // e.g. 'acquisition'
  const segment    = this.simoStore.segment        // e.g. 'consumer'

  return this.content.postBodyMarketingContent.filter(
    ({ fields: { tags } }) => {
      const checks = [
        tags ? tags.includes(MARKETING_SLOT_TAG_NAMES.SLOT_3) : false
        //      ↑ tag: 'slot3' must be present
      ]
      return shouldShowContentByTag(checks, tags || [], segment, journeyType)
      //     shouldShowContentByTag also pushes:
      //       tags.includes(journeyType)  → e.g. tags includes 'acquisition'
      //       tags.includes(segment)      → e.g. tags includes 'consumer'
    }
  )
}

// So a CMS entry is rendered in slot 3 ONLY if it has ALL THREE tags:
//   ['slot3', 'acquisition', 'consumer']   ← shows for consumer acquisition journey
//   ['slot3', 'upgrade', 'consumer']       ← shows for consumer upgrade journey
//   ['slot3', 'upgrade', 'business']       ← shows for business upgrade journey

MarketingComponent — content type dispatch

MarketingComponent receives the filtered array and renders each entry by reading item.sys.contentType.sys.id to pick the right renderer from a component map:

Contentful content typeRenderer componentWhat it typically renders
standardBannerStandardBannerMapFull-width marketing banner with heading, body, and CTA
partnerBannerPartnerBannerMapCo-branded partner promotion banner
partnerBannerApplePartnerBannerAppleMapApple-specific partner banner variant
advertAdvertMapDisplay advert / promo block
contentBlockContentBlockMapRich-text content block (FAQs, legal copy, etc.)
accordionAccordionMapCollapsible accordion FAQ sections
iconSnippetListIconSnippetMapIcon + text list (e.g. plan features overview)
anything elsenullSilently not rendered — no error

End-to-end flow

1. ContentStore.loadContent()

Fetches the Contentful shopPage. mapPageContent() extracts body.items.fields.postBodyMarketingContent and stores the raw entry array as this.content.postBodyMarketingContent.

2. SimoPlansTemplate renders

At the bottom of the JSX: contentStore.getPostBodyMarketingContent() is called inline — it filters the raw array by slot + journey + segment tags and returns only the matching entries.

3. MarketingComponent renders

Iterates the filtered array. For each entry, looks up item.sys.contentType.sys.id in its componentMap and calls the corresponding renderer. Unknown types are silently skipped.

3onv consideration

Tag values must match journeyType and segment exactly

The tag matching is a string comparison — tags.includes('tariffmigration'). If 3onv introduces new journey types (e.g. '3migration'), the Contentful editors must tag the relevant marketing entries with that exact string for them to appear. No code change is needed unless a new slot tag (beyond slot1, slot3) is required — that would need adding to MARKETING_SLOT_TAG_NAMES and a new filter method in ContentStore.

Tariff Migration

Tariff migration is a Vodafone-specific journey where an existing customer is proactively moved off a legacy (discontinued) plan and asked to pick a replacement from a curated set of eligible plans. It is a variation of the standard SIM-only upgrade journey but with a different page layout, different header content, and different plan-loading behaviour.

How the journey is identified

The backend determines whether the current journey is a tariff migration. When the journey is initialised (the GET_JOURNEY HATEOAS call), the backend response includes a journeyType field. When this equals 'tariffMigration', the frontend enters tariff migration mode.

// SimoStore.ts
get isTariffMigration() {
  return this.journeyType?.toLowerCase() === 'tariffmigration'
}
// Case-insensitive — 'tariffMigration', 'TariffMigration', 'tariffmigration' all match

This is not controlled by a LaunchDarkly feature flag. It is purely driven by what the backend returns for that customer's journey type. There is no way to force it from the frontend; eligibility is determined server-side.

What changes in the UI when isTariffMigration is true

TariffMigration component replaces the normal header area

The <TariffMigration /> organism renders inside the header <Container>. It is wrapped in a guard: if (!store.isTariffMigration) return null — so it is completely inert in standard journeys.

The normal sub-header copy, USP header, and comparison table are all hidden when isTariffMigration is true:

// SimoPlansTemplate.tsx
{!store.isTariffMigration && !planStore.aomRecommendedBundlePlans?.length && (
  <>
    {isComparisonTableVisible && <ComparisonTable />}
    <UspHeader ... />
    <Styled.SubTitleConatiner>...mainCopy...</Styled.SubTitleConatiner>
  </>
)}
TariffMigration component content (hardcoded strings)

Unlike almost every other piece of UI copy in this app, the tariff migration header content is hardcoded in predefinedContent.ts — it does not come from Contentful:

// src/client/constants/predefinedContent.ts
export const TARIFF_MIGRATION_CONTENT = {
  MAIN_TITLE:       'Change your plan',
  MAIN_TITLE_FAILURE: 'Looking to change your plan?',
  SUB_TITLE:        "Switch to a plan that's better suited to your needs",
  BODY: [
    {
      heading: { text: "Your current agreement end date won't change" },
      icon:    { name: 'tick', state: 'success' },
    },
  ],
  PLAN_TITLE: 'Your options',
}

export const TARIFF_MIGRATION_PLANS_FAILURE_CONTENT = {
  TITLE: "Sorry we couldn't find any plans",
  BODY:  "We can't find any plans right now. You can chat to us online for assistance or call us on 191 from your Vodafone mobile",
  CHAT_BTN_ID: 'B2BD2A-Black',  // ← hardcoded webchat widget ID
}

The CHAT_BTN_ID is a hardcoded ID for the Vodafone live chat widget.

SubscriptionSummary — shown inside TariffMigration

The <TariffMigration> component renders a <SubscriptionSummary> directly inside it — showing the customer's current plan summary. This is the same SubscriptionSummary molecule used elsewhere, but here it appears inside the migration header rather than as a separate page slot.

Plan loading — tariffMigrationPlansFailure

In PlanStore.dispatchPlansNotification(), when isTariffMigration is true, a separate failure check runs:

if (this.simoStore?.isTariffMigration) {
  this.tariffMigrationPlansFailure =
    isRecommendedProductServiceFailure(notifications) || isEmpty(plans)
  return
}

tariffMigrationPlansFailure = true means the backend either returned an error notification or returned an empty plans list. When this happens:

  • The plans section is hidden: {!planStore.tariffMigrationPlansFailure && (...)}
  • The <TariffMigration> header switches to the failure state: shows "Looking to change your plan?" + a StateNotification info box with the error message and a webchat button
Filter bar behaviour

The plan filter bar is still rendered, but the isTariffMigration prop is passed to PlanFilterWrapper and PlanFilterWrapperMobile:

// PlanFilterWrapper.tsx
// When isTariffMigration is true:
//   - the commitment period filter tabs may be hidden or styled differently
//   - some filter options that don't apply to migration plans are suppressed

The styled component Styled.PlanFilterContainer also receives isTariffMigration as a prop and applies different padding/margin via styled-component props.

Breadcrumbs

SimoBreadcrumbs receives isTariffMigration and uses it to conditionally modify the breadcrumb trail — typically removing or relabelling steps that don't apply in a migration flow (e.g. hiding "Choose a plan" if the customer has no choice context, just a migration offer).

isSlotContentDisplayed

The subscription summary slot (the <SubscriptionSummary /> that appears above the plan list in a standard journey) is also hidden:

// ViewStore.ts
isSlotContentDisplayed = (isTariffMigration: boolean) => {
  return this.pageView === PAGE_VIEW.PLANS && !isTariffMigration
}

// SimoPlansTemplate.tsx uses:
const isSlotContentDisplayed = () => {
  return viewStore.pageView === PAGE_VIEW.PLANS && !store.isTariffMigration
}
// When isTariffMigration=true, SubscriptionSummary is NOT shown in the normal slot
// because TariffMigration component renders its own SubscriptionSummary inside itself

Full render difference — standard vs tariff migration

Page elementStandard journeyTariff migration
Page headingFrom Contentful simo_heading_{segment}_{journeyType}Hardcoded: "Change your plan"
Sub-headingFrom Contentful mainCopyHidden
USP headerShownHidden
Comparison tableShown (if eligible)Hidden
Benefit item rowNot shown in headerHardcoded: "Your current agreement end date won't change"
SubscriptionSummarySeparate slot above plan listInside TariffMigration header component
Plan listNormal plansNormal plans (or hidden if tariffMigrationPlansFailure)
On plan load failureStandard error stateCustom info notification + webchat widget

3onv consideration

Tariff migration copy is hardcoded — not in Contentful

All the heading text, sub-heading, and failure messages for the tariff migration journey live in predefinedContent.ts as string constants. If 3onv needs different copy for its own migration journey (which it will, given different brand voice), these strings need to be updated — or ideally migrated to Contentful so editors can manage them without a code change.

The webchat button ID ('B2BD2A-Black') is also Vodafone-specific. 3onv would need a different widget ID.

AIM Mocking (API Interceptor Middleware)

AIM (API Interceptor Middleware) is the local development mocking system used in web-shop-simo. It lets developers work entirely offline or on restricted networks by intercepting outbound API calls, proxying them to the real backend on first run to record responses, then replaying those saved responses on subsequent runs without hitting the backend at all.

The underlying package is @vfuk/lib-web-aim (v10.2.1). It is a Vodafone-internal library. The __mockapi__ folder in the repo root is where all recorded responses live.

Middleware wiring: src/server/development/vite.server.config.ts
AIM middleware: src/server/common/middleware/Aim/aim.middleware.ts
AIM config: src/server/common/config/aim.config.ts
Proxy whitelist: src/server/common/config/whitelist.config.ts
Recorded responses: __mockapi__/

Dev-only — AIM is commented out in production

In src/server/production/server.ts the AIM import and debugPanelApiMiddleware are commented out. AIM is active only in the Vite development server (vite.server.config.ts).

How it is wired up (dev server)

// vite.server.config.ts (simplified)
import debugPanelApiMiddleware from '@vfuk/ecare-core-debug-panel-api-middleware'
import aimMiddleware from '@server/common/middleware/Aim'

// 1. Debug panel — exposes the UI at /?debug and the API endpoints at /api/aim/*
debugPanelApiMiddleware(app, {
  baseRoutePrefix: `${baseRoutePrefix}/api`,
  aimMocksPath: path.resolve(process.cwd(), '__mockapi__'),  // ← root of mock files
})

// 2. AIM proxy + cache middleware
aimMiddleware(app, {
  serverBaseRoute: baseRoutePrefix,           // e.g. '/sim-only/best-sim-only-deals'
  targetProxy: `https://localhost:8000`,      // proxy target (self-proxy → DXL)
})

Step-by-step: what happens on a request

1. Request arrives at Express

Any request matching {baseRoute}/api/* is intercepted by AIM before it reaches the DXL proxy middleware.

2. AIM computes the cache key (file path)

AIM derives the storage filename from the request using three pieces:

filePath = `{scenario}/{METHOD}/{prefixKey}{rewrittenPath}-{hash}.json`

// scenario  → active scenario name, default: "default"
//             set via POST /api/aim/setScenario  { "scenario": "consumer" }
// METHOD    → "GET" | "POST" | "DELETE" etc.
// prefixKey → value of the "AIM" or "aim" request header, if present (e.g. "vfthree-")
// rewrittenPath → URL path after rewriteMockPathName() transformation + slashes → dashes
// hash      → 8-char MD5 of { body: req.body, query: req.query } — "000" if both empty

3. Check if the file exists in __mockapi__/

AIM looks for the file at:

__mockapi__/{scenario}/{METHOD}/{path}-{hash}.json
// Falls back to:
__mockapi__/default/{METHOD}/{path}-{hash}.json  (if scenario file not found)

If found → serve it immediately (cache hit). Skip to step 5.

4. Cache miss → proxy to real backend

AIM forwards the request to targetProxy (the local server's own DXL proxy, which in turn forwards to the real Vodafone backend). AIM adds one header: x-aim-request: true — all other incoming headers are passed through unchanged, including Authorization, cookies, custom headers, and any X-Brand-ID you add.

When the backend response arrives, AIM saves it to disk at the computed file path.

5. Response returned to client

Whether from cache or freshly recorded, the JSON body is returned to the React app exactly as the backend sent it.

URL rewriting — how raw paths become filenames

Raw backend URLs contain the app's full path prefix and UUID session/journey IDs. The rewriteMockPathName function in aim.config.ts strips those to keep filenames short and readable:

// aim.config.ts — hashIgnoredPathPrefix (used as rewriteMockPathName)
// Contentful calls:
"/sim-only/best-sim-only-deals/api/content-service/v2/content/..."
  → "/contentful-api-v2-content/..."

// All other SIMO API calls:
"/sim-only/best-sim-only-deals/api/digital/v1/simo-purchase/..."
  → "/simo-purchase/..."
"/sim-only/best-sim-only-deals/api/digital/v2/simo-purchase/..."
  → "/simo-purchase/..."

// After rewriting, slashes become dashes:
"/simo-purchase/paym/v2/@/journeys/latest"
  → "simo-purchase-paym-v2-@-journeys-latest"

UUIDs in path segments (platform session ID, journey ID, plan ID) are wildcarded via hashIgnoredPathPatterns — they are replaced with @ before the path is turned into a filename. This ensures the same mock file is used regardless of which UUID is in the URL.

// aim.config.ts — hashIgnoredPathPatterns examples:
`${serverBaseRoute}/api/simo-purchase/paym/v2/*/journeys/latest`
`${serverBaseRoute}/api/simo-purchase/paym/v2/*/journeys/*/plans/*`
`${serverBaseRoute}/api/digital/v2/simo-purchase-paym-v2-*/journeys/*/plans/*`

// Result: any UUID in a wildcard (* ) position → replaced with "@" in filename

The __mockapi__ folder structure

__mockapi__/
├── default/                        ← active when no scenario is set
│   ├── GET/
│   │   └── contentful-api-v2-content-c39eb258.json
│   └── POST/
│       └── v2-simo-purchase-paym-v2-@-journeys-@-package-39e9fafc.json
│
├── journey/                        ← scenario: "journey"
│   ├── __shared__/                 ← shared overrides (fallback if scenario file missing)
│   ├── consumer/
│   │   ├── GET/
│   │   │   ├── simo-purchase-paym-v2-@-journeys-latest-022a954f.json
│   │   │   ├── simo-purchase-paym-v2-@-journeys-@-plans-8465ac18.json
│   │   │   └── contentful-api-v2-content-*.json
│   │   └── POST/
│   │       └── v2-simo-purchase-paym-v2-@-journeys-@-package-*.json
│   └── business/
│       └── GET/
│           └── ...
│
├── highlands/                      ← scenario for Highlands CRO testing
├── seamlessMigrationPlans/         ← scenario for seamless migration
├── phoenixUpgrades/                ← scenario for Phoenix upgrade journey
├── p2p/                            ← P2P journey scenario
└── errors/                         ← error state scenarios

Each top-level directory is a scenario. The active scenario is stored in the AIM server-side session and can be switched without restarting the server.

Switching scenarios — the debug panel

Access the debug panel by appending ?debug to the local URL. From there you can:

The scenario can also be set programmatically:

// POST to the AIM config endpoint
POST /sim-only/best-sim-only-deals/api/aim/setScenario
{ "scenario": "journey/consumer" }

// GET to check the current scenario
GET /sim-only/best-sim-only-deals/api/aim/getScenario

Hash computation — exactly what is included

The 8-character hash suffix on each filename is an MD5 of the request body and query string only. This is how AIM distinguishes between two calls to the same URL with different parameters:

// From lib-web-aim/dist/index.js source:
function getReqHash(req) {
  const hashLength = 8;
  let hash = "000";  // ← "000" when both body and query are empty (e.g. simple GETs)
  if (!isEmpty(req.body) || !isEmpty(req.query)) {
    hash = md5(JSON.stringify({ body: req.body, query: req.query })).substring(0, hashLength);
  }
  return hash;
}

// INCLUDED in hash: req.body, req.query
// NOT included:     req.headers, req.cookies, req.params (path segments)
Headers are NOT part of the cache key

req.headers is completely excluded from the hash calculation. This is the most important thing to understand about AIM's limitations for the 3onv migration.

AIM modes: recording vs mocking vs pass-through

AIM has three operating modes, switched per browser session. Understanding these is key to understanding why brand headers matter.

Request arrives at /api/*
AIM checks session state:
isRecordingEnabled
isMockingEnabled
PASS-THROUGH
Both OFF
Proxy to backend,
no file read/write
RECORDING
Recording ON
Proxy to backend,
save response to file
MOCKING
Mocking ON
Deliberately send to
localhost:0000 (fails),
serve from file instead

The mocking trick of routing to an invalid address (localhost:0000) is how AIM intentionally triggers the onProxyError hook even when mocking is on — that hook then reads from disk instead. This means in mocking mode, the backend is never called.

Why recording works fine with X-Brand-ID

In recording mode the real proxy fires. Looking at the onProxyRequest function in the AIM source:

function onProxyRequest(proxyReqRes, req) {
  proxyReqRes.setHeader("x-aim-request", "true");  // adds one marker header
  if (req.body) {
    const bodyData = JSON.stringify(req.body);
    proxyReqRes.setHeader("Content-Type", "application/json");
    proxyReqRes.setHeader("Content-Length", Buffer.byteLength(bodyData));
    proxyReqRes.write(bodyData);
  }
  // ↑ That's it. All other incoming request headers (Authorization, X-Brand-ID,
  //   cookies, Accept, etc.) pass straight through because http-proxy-middleware
  //   copies them automatically. AIM does NOT strip or modify them.
}

So when you record with X-Brand-ID: VFThree, the backend receives that header and returns a brand-appropriate response. AIM saves that response to disk. Recording is fully brand-aware already.

Why mocking/playback is NOT brand-aware — the root cause

The problem is in how AIM decides which file to load. It calls getCacheStorageKey(req) to build the filename. Let's trace exactly what data feeds into that filename:

// Step 1: getFilteredReqHash — builds the object that determines the filename
function getFilteredReqHash(req) {
  let filteredReq = {
    method: req.method,   // ✅ included
    path:   req.path,     // ✅ included (then wildcarded and rewritten)
    body:   req.body,     // ✅ included
    query:  req.query,    // ✅ included
    // headers: req.headers  ← NOT HERE. Never read. Not in the object.
  };
  filteredReq = filterReqBodyKeys(filteredReq);   // strips ignored body keys
  filteredReq = filterReqQueryKeys(filteredReq);  // strips ignored query keys
  filteredReq = filterReqPath(filteredReq);       // wildcards UUID path segments
  return filteredReq;
}

// Step 2: getReqHash — computes the 8-char suffix
function getReqHash(req) {
  let hash = "000";
  if (!isEmpty(req.body) || !isEmpty(req.query)) {
    hash = md5(JSON.stringify({
      body:  req.body,   // ✅
      query: req.query,  // ✅
      // X-Brand-ID is not here
    })).substring(0, 8);
  }
  return hash;
}

// Step 3: getCacheStorageKey — assembles the full file path
function getCacheStorageKey(req) {
  const scenario   = sessionStore.get(req, "scenario") || "default";
  const prefixKey  = getCachePrefixKey(req);  // from "aim"/"AIM" request header only
  const filteredReq = getFilteredReqHash(req);
  const hash       = getReqHash(filteredReq);
  filteredReq.path = rewriteMockPathName(filteredReq.path);  // strip URL prefix

  return {
    filePath:         `${filteredReq.method}/${prefixKey}${encodeUrl(filteredReq.path)}-${hash}`,
    scenarioFilePath: `${scenario}/${filteredReq.method}/${prefixKey}${encodeUrl(filteredReq.path)}-${hash}`
  };
}

The result is this: two requests that differ only in a header always produce the same filename. The file lookup is completely header-blind.

// Both of these requests produce IDENTICAL cache keys:
GET /api/simo-purchase/paym/v2/{uuid}/journeys/latest
  Headers: { Authorization: "...", X-Brand-ID: "VFRED"  }
  ↓ getCacheStorageKey output:
  "default/GET/simo-purchase-paym-v2-@-journeys-latest-022a954f.json"

GET /api/simo-purchase/paym/v2/{uuid}/journeys/latest
  Headers: { Authorization: "...", X-Brand-ID: "VFThree" }
  ↓ getCacheStorageKey output:
  "default/GET/simo-purchase-paym-v2-@-journeys-latest-022a954f.json"
  ↑ Same file. Whichever was recorded last is what gets served.
This is a design decision, not a bug

AIM was built to mock APIs where the same URL always returns the same logical response regardless of caller identity. Headers like Authorization are bearer tokens that change per-session but don't change the shape of the response — so excluding them from the hash makes sense in the general case. The library was never designed for the multi-brand scenario 3onv introduces.

The minimal change needed in lib-web-aim

Because the fix needs to work for every service using AIM (not just web-shop-simo), the principal engineer's approach of patching the library is correct. Here is exactly what needs to change — it is a small, targeted modification with no breaking changes for existing consumers:

New config option (additive — existing setups are unaffected):

// In each service's aim.config.ts — opt-in only
export default {
  // ... existing config ...

  // NEW: list of header names whose values should be included in the hash
  hashIncludedReqHeaders: ['x-brand-id'],  // ← new field in lib-web-aim config
}

Change 1 in lib-web-aim — include headers in the filtered request object:

// getFilteredReqHash — change ~5 lines
function getFilteredReqHash(req) {
  // NEW: pick up any headers that should be part of the hash
  const hashIncludedHeaders = configController.config.hashIncludedReqHeaders || [];
  const includedHeaders = {};
  for (const headerName of hashIncludedHeaders) {
    const value = req.headers[headerName.toLowerCase()];
    if (value) includedHeaders[headerName] = value;
  }

  let filteredReq = {
    method:  req.method,
    path:    req.path,
    body:    req.body,
    query:   req.query,
    headers: includedHeaders,   // ← NEW: only the explicitly listed headers
  };
  filteredReq = filterReqBodyKeys(filteredReq);
  filteredReq = filterReqQueryKeys(filteredReq);
  filteredReq = filterReqPath(filteredReq);
  return filteredReq;
}

Change 2 in lib-web-aim — include headers in the MD5 hash:

// getReqHash — change ~3 lines
function getReqHash(req) {
  const hashLength = 8;
  let hash = "000";
  const hashIncludedHeaders = configController.config.hashIncludedReqHeaders || [];
  const includedHeaders = {};
  for (const headerName of hashIncludedHeaders) {
    const value = req.headers[headerName.toLowerCase()];
    if (value) includedHeaders[headerName] = value;
  }

  if (!isEmpty(req.body) || !isEmpty(req.query) || !isEmpty(includedHeaders)) {
    hash = md5(JSON.stringify({
      body:    req.body,
      query:   req.query,
      headers: includedHeaders,   // ← NEW: brand header folds into the hash
    })).substring(0, hashLength);
  }
  return hash;
}

Result after the change:

// With hashIncludedReqHeaders: ['x-brand-id'] configured:

GET /api/.../journeys/latest  { X-Brand-ID: "VFRED" }
  → hash = md5({ body:{}, query:{}, headers:{"x-brand-id":"VFRED"} })
  → "default/GET/simo-purchase-paym-v2-@-journeys-latest-a1b2c3d4.json"

GET /api/.../journeys/latest  { X-Brand-ID: "VFThree" }
  → hash = md5({ body:{}, query:{}, headers:{"x-brand-id":"VFThree"} })
  → "default/GET/simo-purchase-paym-v2-@-journeys-latest-e5f6g7h8.json"

// ✅ Two different files. Brand responses stored and served independently.
// ✅ Services without the new config option behave exactly as before (hash = "000" for empty gets).

Change 3 — add the config option to the validator (so the library validates the new field correctly):

// In the config validation section of lib-web-aim:
function validateHashIncludedReqHeaders(config) {
  if (typeof config.hashIncludedReqHeaders === "undefined") return;
  if (!Array.isArray(config.hashIncludedReqHeaders)) {
    return { configKey: "hashIncludedReqHeaders", messages: [{ message: "must be an array of header name strings" }] };
  }
}
// Add to the errors array in configValidator():
errors.push(validateHashIncludedReqHeaders(config));

3onv — X-Brand-ID header analysis

The plan for 3onv is to pass a new X-Brand-ID header (e.g. VFRED or VFThree) on every backend API request to make the backend brand-aware. Here is exactly what that means for AIM:

OperationDoes X-Brand-ID affect it?Detail
Recording (proxy to real backend) ✅ Yes — works correctly AIM passes all incoming request headers through to the backend unchanged (only adds x-aim-request: true). The backend will receive X-Brand-ID: VFThree and return brand-correct responses. These are recorded to disk.
Playback (serving from file) ❌ Headers are invisible The cache key is computed from path + body + query only. A request with X-Brand-ID: VFRED and X-Brand-ID: VFThree to the same URL with the same query string will produce the identical filename. AIM will serve whichever file was recorded last.
Scenario separation ✅ Workaround available Use different AIM scenarios for different brands. Record Vodafone responses in __mockapi__/journey/consumer/ and 3onv responses in a new __mockapi__/3onv/consumer/ scenario directory. Switch via the debug panel.

The collision risk in detail

// Both of these requests produce the SAME AIM file path:
GET /api/digital/v2/simo-purchase/paym/v2/{id}/journeys/latest
  Headers: { X-Brand-ID: "VFRED" }     → "simo-purchase-paym-v2-@-journeys-latest-022a954f.json"

GET /api/digital/v2/simo-purchase/paym/v2/{id}/journeys/latest
  Headers: { X-Brand-ID: "VFThree" }   → "simo-purchase-paym-v2-@-journeys-latest-022a954f.json"
                                           ↑ identical filename — one overwrites the other

The recommended approach for 3onv AIM recording

Option A — Dedicated 3onv scenario (recommended)
  1. Create __mockapi__/3onv/consumer/GET/ and __mockapi__/3onv/consumer/POST/ directories
  2. Switch AIM to proxy mode (disable mock serving, enable recording) via the debug panel
  3. Switch the active scenario to "3onv/consumer"
  4. Make sure the frontend is sending X-Brand-ID: VFThree on all requests
  5. Click through the journey — AIM will proxy each request to the backend (with the header) and save responses under __mockapi__/3onv/consumer/
  6. Switch back to mock mode
  7. Any developer can now switch to the "3onv/consumer" scenario and work fully offline with brand-correct responses
Option B — Use the aim header prefix

AIM reads a special aim (or AIM) request header and uses it as a filename prefix within the current scenario:

// If the frontend sends: aim: vfthree
// AIM produces filename:
//   default/GET/vfthree-simo-purchase-paym-v2-@-journeys-latest-022a954f.json
// vs no header:
//   default/GET/simo-purchase-paym-v2-@-journeys-latest-022a954f.json

This would allow brand-differentiated files in the same scenario directory. However, it requires the frontend to actively set the aim header (in addition to X-Brand-ID), which adds complexity and is not how AIM is currently used. Option A (separate scenarios) is cleaner.

Option C — Modify aim.config.ts (not currently supported)

There is no hashIgnoredReqHeaders or equivalent option in @vfuk/lib-web-aim v10.2.1. The full list of supported config keys is:

  • hashIgnoredReqBodyKeys
  • hashIgnoredReqQueryKeys
  • hashIgnoredReqPathPatterns
  • ignoredPaths
  • proxy
  • rewriteMockPathName
  • storageInterfaceRootPath

Headers cannot be injected into the hash via configuration alone. To add header-based hashing would require either a fork of lib-web-aim or a custom Express middleware that maps the brand header into a query parameter before AIM sees the request (so AIM's query-based hashing picks it up naturally).

Adding a new endpoint to AIM

When a new backend endpoint is introduced (e.g. a 3onv-specific journey endpoint), two files need updating for it to work with AIM:

1. Add to whitelist.config.ts

The whitelist is the authoritative list of endpoints the DXL proxy allows. Any endpoint not listed here is blocked. Add the new endpoint with the correct HTTP method and path pattern:

// src/server/common/config/whitelist.config.ts
{
  method: GET,
  endpoint: `/simo-purchase/paym/v2/*/journeys/*/3onv-specific-endpoint`,
  swagger: `/simo-purchase/paym/v2/{platformSessionId}/journeys/{journeyId}/3onv-specific-endpoint`,
}

The whitelist entries are also consumed by aim.config.ts which auto-generates the matching hashIgnoredPathPatterns from them — so any wildcard in the endpoint path will also be wildcard-matched in AIM file naming.

2. Record a real response in the correct scenario

Set AIM to proxy mode, set the scenario to the target directory (e.g. "3onv/consumer"), trigger the call in the browser, and AIM will automatically save the response to __mockapi__/3onv/consumer/GET/{filename}.json.

You can also create the JSON file manually — AIM will serve any valid JSON file that matches the expected filename pattern.

Contentful mocks

Content service calls are handled slightly differently. The hashIgnoredPathPrefix function rewrites their URL before filename generation:

// Input URL:
"/sim-only/best-sim-only-deals/api/content-service/v2/content/shopPage?..."

// After rewrite:
"/contentful-api-v2-content/..."

// Resulting filename pattern:
"contentful-api-v2-content-{hash}.json"

// Examples in __mockapi__/default/GET/:
"contentful-api-v2-content-c39eb258.json"  // consumer shop page
"contentful-api-v2-content-1af94fe2.json"  // business shop page
"contentful-api-v2-content-48a7d5f3.json"  // plan details content
// etc. — each unique set of content-service query params gets its own hash

For 3onv, if the Contentful space is different (different contentEntryKey values or a completely separate Contentful environment), the hash will naturally differ because the query parameters will be different. No special AIM configuration is needed for this.

Summary: what AIM does and does not hash

Request componentIn hash?Can be ignored via config?
URL path (after rewrite)✅ YesYes — hashIgnoredReqPathPatterns wildcards segments
Query string (?foo=bar)✅ YesYes — hashIgnoredReqQueryKeys
Request body (POST/PATCH)✅ YesYes — hashIgnoredReqBodyKeys
HTTP method✅ Yes (in path)No
Request headers (incl. X-Brand-ID)❌ NoNo — not supported in v10.2.1
Cookies❌ NoNo
Active scenario✅ Yes (in directory prefix)N/A — this is the brand-separation mechanism

Journey Type Matrix

This repo is easiest to understand when you stop treating it as one storefront and instead see it as a shared runtime for several purchase journeys. Most regressions come from changing shared code while only thinking about one branch.

Journey / Variant Entry Route Page / Template Important Differences
Acquisition /sim-only/best-sim-only-deals Simo.tsxSimoTemplate Default consumer storefront. No existing-account upgrade recovery logic.
Upgrade /sim-only/best-sim-only-deals?journeyType=upgrade Simo.tsxSimoTemplate Keep-or-replace basket conflict flow, subscription summary, more auth/session coupling.
Second line ?journeyType=secondline Simo.tsxSimoTemplate Family discount logic, loyalty-aware copy, second-line eligibility rules.
Business /business/business-sim-only Simo.tsxSimoTemplate Segment branch, different content keys, several consumer-only flags disabled.
P2P migration /migration/basics/test, /migration/phase5/test P2PMigration.tsxP2PMigrationTemplate Separate page and template, dedicated login/eligibility modals, non-prod route surface.
Tariff migration Internal journey branch inside main page Simo.tsxSimoTemplate Simplified plan presentation, reduced slot content, some standard merchandising skipped.
Seamless migration /customer-transfer/best-sim-only-deals SeamlessMigration.tsxSeamlessMigrationTemplate Separate header/footer, custom messaging, no keep-or-replace path, different mock scenarios.
Network trial second line Second-line journey plus flag Main SimoTemplate stack Enabled only when networkTrialSecondLineEnabled is on, so impact analysis must include flags and auth state together.
Plan details page /:planId/plan-details PlanDetails.tsxPlanDetailsTemplate Can render the standard static details page or the Highlands modal container, depending on feature flags.
Business is not just a theme switch

Many flags and content decisions explicitly branch on segment or isBusiness. When changing shared components, treat business as a separate runtime path, not a cosmetic variant.

Fast mental model

Main page family

Acquisition, upgrade, second line, tariff migration, and business mostly share the SimoStore + SimoTemplate stack.

Alternate page family

Seamless migration and P2P diverge much earlier. They have their own page entry points, content, and recovery behavior.

Flag-modified overlays

Highlands, entertainment trays, insurance, and network-trial variants reuse core data but change how the journey is rendered and instrumented.

Middleware Chain Order

The server is not a thin file server. Middleware order determines auth handling, proxy routing, session creation, content transformation, feature-flag injection, and prerender behavior. If the order changes, the storefront can behave correctly in one environment and fail in another.

Production bootstrap: src/server/production/server.ts
Development bootstrap: src/server/development/vite.server.config.ts
Local route/proxy split: src/server/common/middleware/useLocalMiddlewares/useLocalMiddlewares.ts

Production order

Stage Middleware What it does
1vfukServer.init()Base Express/server wiring from the shared Vodafone server package.
2useExpressStaticGzip()Serves built client assets efficiently.
3seamlessMigrationRedirectMiddleware()Normalizes customer-transfer routes before the rest of the chain runs.
4Debug auth chain when enabledloginCallback, idmMiddleware, logout, account switching, local routing, DAL auth.
5avoidApiCacheStops API responses being cached incorrectly.
6cleanRouteMiddlewareStrips the base route prefix so downstream handlers see stable paths.
7contentAPITransformerTransforms shell content payloads such as footer and meganav.
8shopAnonymousSessionMiddlewareEnsures anonymous session state exists before redirect/proxy logic.
9loginRedirectProtects paths that require authenticated state.
10dxlProxyMiddlewareForwards API traffic to the backend integration layer.
11featureFlaggingMiddlewareInjects feature-flag data into the runtime HTML/env payload.
12prerenderMiddlewareHandles prerender cache and skips basket-bearing sessions.
13useIndexRouting()Delivers the HTML shell with injected env vars and scripts.

Development-only additions

AIM and debug panel

The Vite dev server inserts debugPanelApiMiddleware and aimMiddleware before local routing, which is why mock recording and scenario switching work locally but are not part of the production chain.

Vite HTML/middleware layer

Development appends vite.middlewares after the app-specific chain, so the docs and app behavior are influenced by both Express middleware order and Vite's dev server behavior.

Middleware Map

Visual order of the main production chain. The development server inserts AIM and Vite middleware around this flow, but this is the core request pipeline that explains most server-side behavior.

flowchart TD
  A[Incoming request] --> B[vfukServer.init / static gzip]
  B --> C[seamlessMigrationRedirectMiddleware]
  C --> D{Debug utils or PR env?}
  D -->|Yes| E[loginCallback / IDM / logout / account switch]
  E --> F[useLocalMiddlewares + DAL auth]
  D -->|No| G[avoidApiCache]
  F --> G[avoidApiCache]
  G --> H[cleanRouteMiddleware]
  H --> I[contentAPITransformer]
  I --> J[shopAnonymousSessionMiddleware]
  J --> K[loginRedirect]
  K --> L[dxlProxyMiddleware]
  L --> M[featureFlaggingMiddleware]
  M --> N[prerenderMiddleware]
  N --> O[useIndexRouting / HTML shell]
          
Why ordering matters here

Session creation, login redirect, proxying, and prerendering all depend on request state created by the earlier middlewares. Reordering them can produce bugs that look like auth failures, stale CMS content, or missing cookies even though the underlying cause is server sequencing.

State Ownership Map

SimoStore is the root store, but it is not the owner of every detail. The child stores own domain-specific slices and coordinate through a back-reference to simoStore. This matters when deciding where a bug belongs and where new logic should live.

Store Owns Typical mutations Watch-outs
PlanStore Plans list, selected plan, Highlands selection, entertainment tray state selectPlan(), setHighlandsSelectedPlanId(), setIsHighlandsBenefitModalOpen() Shared by standard plan flow, Highlands, CRO entertainment, and analytics reactions.
FilterStore Price/data/commitment/sort filters Filter changes, deep-link filter hydration Often combined with feature flags such as CRO filter options or price pills.
ContentStore CMS payloads, signposting, plan details content, Highlands modal content cache loadContent(), loadPlanDetailsContent(), getHighlandsDynamicContentByHref() Segment-aware in content keys, but client content fetches still use a hardcoded consumer space.
ViewStore Top-level in-page state, mainly plans vs extras setPage(), goToNextStep(), goToBasket() Do not assume it owns the full checkout state machine; it owns only the in-page transition slice.
ExtraStore Add-ons/extras selection and notification codes loadExtras(), add/remove selection flows Its notifications influence whether extras is skipped and whether “losing extras” UI appears.
InsuranceStore Device lookup, quotes, insurance basket state Quote generation, selected device, add-to-basket flow Heavily flag-gated and consumer-only in several places.
KeepOrReplaceStore Upgrade basket conflict resolution modal state onKeepOrReplace(), onCloseKeepOrReplaceModal() Depends on returned HATEOAS links rather than hardcoded endpoints.
MismatchStore Segment mismatch, session-expiry, and eligibility recovery modal state triggerMismatch(), continueAndClearExtras(), emptyBasketAndContinue() Acts as the user-facing recovery layer for several backend error conditions.
FeatureFlagStore LaunchDarkly flags mirrored into MobX getters Proxy-driven live updates, computed flag getters Some getters branch further on isBusiness, journeyType, or P2P state.
AnalyticsStore Analytics reactions and event side effects Triggered indirectly by plan, modal, filter, and journey changes Often the hidden dependency when UI changes require analytics updates.

Practical rule

If you are changing what the user selected, look first at PlanStore, ExtraStore, or InsuranceStore. If you are changing what appears on the screen for a mismatch or conflict, look first at MismatchStore or KeepOrReplaceStore. If you are changing copy, modal content, or overlay data, look first at ContentStore.

View / Journey State Machine

One easy mistake is assuming ViewStore.pageView represents the whole journey. It does not. It only tracks the in-page switch between the plans view and extras view. Basket and checkout are external redirects, while many overlays are controlled by other stores.

The real rule

ViewStore owns plans and extras. Review, basket, plan details, Highlands modals, mismatch overlays, and insurance flows sit outside that single enum and are composed from other stores and route transitions.

Initial page load
pageView = plans
User selects a plan
User continues
Skip extras?
!showAddonPage, business, tariff migration, or no valid extras path
Yes: goToBasket()
external redirect
↓ (otherwise)
Load add-ons content
setPage('extras')
Continue to basket redirect

State Machine Diagram

This focuses on the state ViewStore actually owns. Basket is a redirect, and overlays are siblings controlled by other stores rather than extra values in pageView.

flowchart TD
  A[Route loads storefront] --> B[ViewStore.pageView = plans]
  B --> C[User selects a plan]
  C --> D{goToNextStep}
  D -->|P2P logged in| E[goToBasket redirect]
  D -->|Upgrade or business shortcut| E
  D -->|Add-ons disabled or skipped| E
  D -->|Extras required| F[loadAddonsContent + loadExtras]
  F --> G[setPage extras]
  G --> H[User continues]
  H --> E

  B -. separate store .-> I[PlanDetails / Highlands modals]
  B -. separate store .-> J[Mismatch / KeepOrReplace overlays]
  G -. separate store .-> K[Insurance and extras side flows]
          

What triggers the branches

Condition Branch owner Outcome
pageView === plans and extras allowedViewStore.goToExtras()Load CMS add-ons content and extras data, then move to #extras.
Business segment or add-ons disabledViewStore.goToExtras() plus flagsSkip extras page and redirect to basket sooner.
P2P logged-in special caseViewStore.shouldGoToBasketFromP2P()Bypass extras and jump straight to basket.
Insurance/basket modification logicViewStore.shouldGoToBasketFromPlans()Decides whether the user should skip straight to basket from the plans step.
Hash changes in URLViewStore.checkLocation()Synchronizes window.location.hash with the tracked page view.

Error Handling & Recovery Matrix

Error handling here is not one generic catch block. Different stores own different recovery experiences, and most of the user-facing actions are driven by backend error codes or HATEOAS links rather than by static UI rules.

Problem Primary owner User-facing recovery Implementation note
Session expired / login required MismatchStore Login/logout prompt or empty-basket resolution modal Triggered from mismatch codes and journey notifications, not from a generic banner system.
Segment mismatch between journey and basket MismatchStore Continue and clear extras, keep current segment, or empty basket Uses HATEOAS actions such as changeJourneySegment, keepJourneySegment, and emptyBasket.
Upgrade basket already contains items KeepOrReplaceStore Keep current package or replace it The modal opens when the store receives links; closing it re-fetches plans with the new links.
Generic API failure SimoStore.processError() Inline error, modal, or route-level failure depending on the caller The same method is the convergence point for many store-level catch blocks.
Plan/details lookup failure PlanStore or plan-details initialization Fallback error handling rather than a partial render Plan details page initialization loads CMS content and plan data in parallel.

Logging pattern

Stores generally use await-to-js for service calls, log failures with the browser Datadog logger, and then delegate to simoStore.processError(). That pattern is a strong clue that service changes, store changes, and observability changes are coupled here.

MismatchStore recovery actions

MismatchStore is the recovery UI for session-expiry, eligibility, and segment conflicts. Its actions include signIn(), logOut(), continueAndClearExtras(), cancelSwitchSegment(), and emptyBasketAndContinue().

KeepOrReplaceStore recovery actions

KeepOrReplaceStore is narrower. It only owns the upgrade conflict modal and chooses between selectKeepPackage and selectReplacePackage. If that flow fails, it delegates back to processError() with an explicit action name.

Contentful Authoring Cookbook

The CMS layer is broad enough that “just update Contentful” is not a useful instruction. Different features use different entry keys, collections, fallback rules, and segment conventions.

Fetch layer: src/client/services/contentService/getAssetModelV2.ts
Orchestrator: src/client/stores/ContentStore/ContentStore.ts
Key constants: CONTENTFUL_CONTENT_ENTRY_KEYS and CONTENTFUL_PATH_CONFIG

How to decide where new content belongs

If you are changing… Look for… Common example
Main page copy, headers, footnotes, standard bannerspackagelistsimo_* entry keyspackagelistsimo_business, review header, list-slot labels
Plan-card or benefit-row contentplanCardBenefitItemContent / plan benefit collectionsStandard benefits, Highlands benefit items, entertainment tray content
Plan details overlay contentplanDetailsOverlayApp and related overlay pathsStandard plan details, Highlands plan-level overlay, FAQs
Segment-specific contact or signpostingEntry keys with _${segment} suffixcontact_us_flyout_consumer, business signposting keys
Journey-specific migration contentSeamless or P2P entry keysseamless_migration_*, p2p_*

Authoring rules that matter in code

1. Entry key naming is behavior

The app frequently finds content by exact key match. Renaming a key is often a runtime change, not just an editorial change.

2. Segment branching usually happens in the key

Business vs consumer content commonly uses separate entry keys even though the client fetch still points at the same Contentful space.

3. Highlands content is two-layered

Plan-level overlay content is loaded up front, but benefit-level modal content can be lazy-loaded and cached by getHighlandsDynamicContentByHref().

4. Tags are layout instructions

Tags such as visible, clickable, modalOnly, xtra, and tray are not editorial decoration. They directly alter rendering behavior.

Safe checklist when adding CMS-backed content

  1. Find the existing entry key or collection constant before inventing a new pattern.
  2. Check whether the content is segment-specific, journey-specific, or flag-gated.
  3. Verify whether the content is read from a flat entry, fields.content, or nested fields.entries.
  4. If it is clickable or modal-driven, confirm which store caches or re-fetches it.
  5. Update mocks/tests if the content shape or required entry key changes.

Analytics & Telemetry Map

This codebase has two observability layers that often move together: customer-facing analytics events and engineering-facing Datadog telemetry. UI changes can break one, the other, or both.

Surface Main owner What it captures
Page and UI analyticssrc/client/analytics/analyticsConfig.tsPage events, overlays, links, benefit modals, filters, plan selection, insurance actions.
Analytics constantssrc/client/analytics/analyticsConstants.tsNormalized names for buttons, page names, tabs, commitment periods, and action types.
Analytics reactionssrc/client/stores/AnalyticsStore/Store-driven side effects that emit events when state changes.
Datadog browser logssrc/utils/datadog/loggers/browserLogger/Structured warning/error logs from stores and client services.
Datadog RUMEnv-injected analytics configClient runtime monitoring, app version, environment, and session-linked traces.

Common event groupings

Page events

pageError, pageUpdate, and deep-link/filter events describe page-level state.

Overlay events

Modals and trays have dedicated events, including Highlands overlays and bundled-entertainment trays.

Interaction events

Choose-plan clicks, filter selections, quote actions, bundle toggles, and sticky-basket actions live in the nested analytics configuration.

When you probably need an analytics change

Testing by Change Type

A general “run tests” instruction is too vague for this repo. The safest way to work is to choose tests based on the layer and journey you changed.

If you changed… First checks Why
MobX store logicTargeted Jest store testsStore transitions often control multiple pages and overlays indirectly.
React component renderingRTL component tests via customRender() or renderWithContext()The provider stack matters for Source Web, icons, and store-backed props.
Service or HATEOAS behaviorService unit tests plus one journey smoke pathSmall service changes can cascade through several stores.
Journey branching or login/session behaviorCypress journey tests in the relevant folderThese bugs usually only appear with real route/cookie state.
Content or benefit mappingUnit tests plus mock-content validationMany CMS regressions do not fail until plan cards or overlays are rendered.
Server middleware or proxy rulesLocal startup plus the affected route/journey pathOrdering and proxy behavior are integration-heavy and hard to prove with one unit test.

Useful test locations

Unit / component

Co-located *.test.ts and *.test.tsx files, backed by src/client/helpers/testUtils/testUtils.tsx.

E2E / journey

cypress/e2e/auth/, cypress/e2e/general/, and related journey-specific directories such as upgrade, second line, P2P, OTB, and insurance suites.

Most common testing mistake

Verifying only the component you touched. In this repo, many regressions are store-driven or journey-driven, so the absence of a local rendering failure does not mean the flow is safe.

Mocking Strategy

There is more than one way to fake data here. Choosing the wrong mocking surface creates brittle tests or misleading local behavior. The question is not “how do I mock?” but “which layer should own the fake data?”

Goal Best mocking surface Typical files
Full local journey using backend-shaped JSONAIM + __mockapi____mockapi__/journey/…, __mockapi__/default/…
CMS payload for unit or Cypress content setupFixture JSONcypress/fixtures/content/…
Single component/store testJest/RTL mocks and fake store valuesCo-located tests plus testUtils.tsx
Server route split or proxy behaviorLocal middleware configuseLocalMiddlewares.ts, aim.config.ts

What AIM is best at

What AIM is not best at

Rule of thumb

If you are validating a user journey, start with AIM. If you are validating rendering logic or one store, start with fixtures or in-test mocks. If you are validating route/proxy behavior, start with the server middleware chain and only use AIM when the request shape itself matters.

Feature Flag Impact Map

The existing feature-flags section tells you what flags exist. This map focuses on where the important ones branch runtime behavior, which is the information you need during debugging and change-impact analysis.

Flag Main runtime surface What actually changes
highlandsPhaseOnePlan details / plan card overlaysSwitches from standard plan-details experience to Highlands modal-driven rendering.
showAddonPageViewStore journey transitionsChanges whether the extras page is a real step or skipped entirely.
showSimoInsurancePlans and insurance flowEnables consumer insurance surface and related quote journey entry points.
showCroFilterOptionsFiltering UITurns on the richer CRO filtering surface for eligible journeys.
showCroPricePillsFilterFilter pillsAdds the under/over price-pill interaction for consumer flows.
showCroAomPlansRepositionEnabledPlan orderingChanges how AOM recommended plans are positioned in the list.
showCroPlanCardEntertainmentPlan card benefit renderingEnables bundled-entertainment benefit display on cards.
showCroPlanCardEntertainmentModalEntertainment overlay UXControls entertainment-specific modal behavior layered onto plan cards.
showMidContractRiseUpgrade notificationsShows the mid-contract price-rise messaging path and supporting content.
showJourneyStepsTrackerJourney chromeEnables the breadcrumb/step-tracker UI, even though build steps live elsewhere.
networkTrialSecondLineEnabledAuth second-line variantUnlocks an otherwise dormant second-line branch that should always be tested with auth state.
showDDBrowserLogsObservabilityAllows additional Datadog browser logging, especially useful in debugging environments.

Why this matters during changes

Flag getters in FeatureFlagStore are not always simple passthroughs. Several combine the raw flag value with segment, journey type, or P2P constraints. That means “the flag is on” is often not enough to prove the code path is actually reachable.

Two-level branching is common

A flag may enable the feature globally, while a store getter still disables it for business users, P2P, or specific journey types. Always inspect both the LaunchDarkly key and the computed getter.

Vodafone UK — web-shop-simo Technical Documentation

Generated from codebase analysis. Keep AGENTS.md as the authoritative source.