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.
If you are trying to understand where a change belongs, start with the section that matches the type of behavior you are touching.
- Journey branch or eligibility change: Journey Type Matrix and View / Journey State Machine
- Server or routing issue: Middleware Chain Order and Request Lifecycle
- Store ownership or modal state issue: State Ownership Map
- Error or recovery bug: Error Handling & Recovery and Mismatch Handling
- CMS or benefit-copy change: Contentful Authoring Cookbook and Contentful & CMS
- Flagged or experiment-driven behavior: Feature Flag Impact Map and Feature Flags
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
- Node.js 22.22.0 (use
nvmorfnm) - Yarn 1.x
- Access to Vodafone VPN (for environment proxying)
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
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).
Key Architectural Principles
- HATEOAS-driven: The backend returns hypermedia links; the client follows them to discover next actions
- Composition over inheritance: Root
SimoStorecomposes 18 child stores - Single context: Entire store tree shared via one
SimoContext - Middleware-first server: Express middleware handles auth, proxy, caching, flags before serving the SPA
- Feature flag-gated: All new features behind LaunchDarkly flags for safe rollout
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.
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
- It makes a React SPA behave like part of a larger Vodafone platform, not like a standalone static site.
- It owns auth redirects, callback handling, anonymous session creation, and platform cookie plumbing.
- It proxies platform APIs so the browser does not need to know AWS gateway details, keys, or host headers.
-
It injects runtime-only values such as feature flags, Datadog tokens, asset domains, and environment names
into
window.VFUK.env. - It optionally prerenders pages and caches HTML so the first paint can arrive before the client app finishes booting.
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/contentfor Contentful page data/simo-purchase/paym/v2/*/journeys/*/plansfor plan retrieval/simo-purchase/paym/v2/*/journeys/*/package/extrasfor extras selection-
/simo-purchase/seamless-migration/v1/*/journeys/*/plansfor 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)
authService.session())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
featurescookie and, in local/debug mode, the?features=query parameter. - Local accessibility auditing is enabled through
@axe-core/reactin development. -
The root element is inspected to decide between
hydrateRoot()andcreateRoot().render().
Provider hierarchy and why the order matters
SourceProvider (theme-ws10, i18n, assets)
└─ StyleSheetManager (shouldForwardProp filter)
└─ Provider (constate wrapper)
└─ Router / BrowserRouter
└─ AppRoutes
SourceProvidermakes the design system and language config available.StyleSheetManagerstops non-DOM props leaking into HTML elements.Provideris 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._isPrerenderis the marker used to identify prerender mode.-
During prerender,
StyleSheetManagerdisables CSSOM injection so styled-components can line up with the existing HTML more safely.
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.
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.
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 }
}
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.
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 asstore.initJourney(). - The page then provides the root store through
SimoContext.Provider.
What React Context is doing here
src/client/contexts.tsxexports a barecreateContext()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
-
SimoStoreowns journey-wide state and composes child stores such asPlanStore,FilterStore,ViewStore, andContentStore. - 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.
-
ViewStorealso 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
|
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.
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
-
FilterStorebuilds a query string such ascommitmentPeriod=24 Months&sortBy=monthlyPrice.asc. -
PlanStore.getFilteredPlans()patches theGET_PLANSHATEOAS 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 isbasics 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'ssubType. -
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:
nextCommitmentPeriodWithPlanscan 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.
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 throughrunHateoasLink(). - 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()andjourneyService.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 withnoPlansCalled = 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_PLANSHATEOAS 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, andparentPlanwith 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
_linksobject, 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/v1or/api/digital/v2based on theversionargument. - It appends the HATEOAS
hrefto that prefix. - It always adds an
Acceptheader, defaulting toapplication/hal+json. -
If cookies are supplied, it forwards
cookies.PlatformAccessTokenas the clientAuthorizationheader. - It can also add a
bingo-journey: trueheader 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 withsetHTTPMethodOverride(). -
DELETE: also sent via
post()with method override set toDELETE.
Special behaviour after a response arrives
-
If the returned payload contains a
sign-inlink, 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-basketlink, the helper redirects to basket. -
If the requested link is missing, it rejects with a synthetic 404-style
error-invalid-hateoas-linkobject. - 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 aGET_OTBlink. -
If it does, the service calls
getOptionsToBuy()before callinggetPlans(). -
When OTB resolves, the service sets
data.otbData, marksnoPlansCalled = 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()copiesjourney.otbDataintooptionsToBuyStore. -
OptionsToBuyStore.modalOpenreturns true when data exists and the payload is not an arrears modal case. -
OptionsToBuyStore.showArrearsModalchecks the first notification code againstARREARS_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
buildStepsarray. - The frontend stores the whole object on
simoStore.basketTotal.
Where the current frontend uses it
-
SimoStore.setJourneyData()assignsjourney.packageBuildSummarytobasketTotal. -
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 throughupdatePackageSummaryAndLinks(). -
After extras load/toggle,
ExtraStorealso refreshessimoStore.basketTotalfrom the backend response.
Important clarification
-
The current
StickyBasketusesbasketTotal.monthlyCost,basketTotal.upfrontCost, andbasketTotal.cta. -
It does not currently render the individual
buildStepsentries into the visible basket summary UI. -
The visible journey stepper is
JourneyStepsTracker, and that is driven byViewStore.pageViewplusshowJourneyStepsTracker.
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
PlanListContentvia plan cards and rendered byPlanCardDetailsModal. -
Route flow: opened via
/sim-only/best-sim-only-deals/:planId/plan-detailsand rendered by the standalonePlanDetailspage. - 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
- 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.
-
What’s included: uses
getContentForPlan, which is the orchestration helper for the most complex tab. -
Additional charges: uses
getPlanDetailsTabContentwith the page-specific plan-content collection. -
What you need to know: also uses
getPlanDetailsTabContent. - About speed: also uses
getPlanDetailsTabContent.
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.
-
Main SIMO load:
fetchContent()requests the shop-page content,planDetailsOverlay, and supporting assets. -
P2P load:
fetchP2PContent()does the same pattern for P2P-specific content. -
Seamless migration load:
fetchSeamlessMigrationContent()repeats the pattern for the seamless page. -
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
PlanListContentcomponent: this is the component that renders the plan cards and is the source of the modal-opening interaction. -
The plan-list CMS content: this is passed as
content={get(content, ‘planList’)}andplanContent={content}into the component.
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
-
SimoPlansTemplaterendersPlanListContentand passes plan-selection and modal-tracking callbacks into it. -
PlanListContentmaps raw plans throughplansCardMapperand rendersPlanCardList. -
A plan card calls
openPlanListModal({ ...plan, buttonName, buttonState })when the user clicksSee plan details. -
PlanStore.openPlanListModalstores that enriched plan inplanListModaland setsisPlanListModalOpen. -
SimoTemplaterendersPlanCardDetailsModalwhen the flag is open. -
PlanCardDetailsModalreads the selected plan fromplanStore.plans, constructs tabs, and calls the content helpers for each tab. -
The non-overview tabs render through
PlanDetailsOverlayModal, which simply hands CMS-shaped content toHeadingAndContent.
How getContentForPlan works
getContentForPlan is the orchestrator for the tab content and especially for
the What’s included tab.
- If no plan exists, it returns an empty array early.
-
It calls
getPlanDetailsHelper(plan, sharedPlanDetails)to find the shared overlay group based on type and band. -
If the tab is not
whatsincluded, it delegates togetPlanDetailsTabContent. -
For
whatsincluded, it checks whether airtime-benefit content exists in the shared overlay content. - It optionally pulls SIMX benefit content.
- If airtime content exists, it merges plan benefits and subtype-specific shared-plan-group content.
-
If no airtime content exists but a subtype exists, it tries a page-specific
packagelistsimo_${‘{subType}’}entry. - 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
GBorUNLTDcategory 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.
-
If the new subtype is only another alias of an existing content family, you can usually reuse the current
helpers by extending
SUBTYPESorgetPlanSubType. -
If the new type changes the
GBvsUNLTDassumption, you will likely need to updategetAirtimePlanTypeand possibly the shared CMS key structure. -
If the new subtype needs its own page-specific CMS block, a matching
packagelistsimo_${‘{newSubtype}’}entry will be needed. -
If the new plan requires its own benefit-item rules, you may also need new shared overlay entries and
possibly an update to
getPlanBenefits.
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.
-
Plan details opened:
trackPlanDetailsModalOpenwrites modal-open state, thenlinksSimoPlanDetailssends a link event and a product-view event. -
Tab changed:
trackPlanDetailsModalTabChangewrites tab state, then the same analytics reaction sends a tab event. -
Modal closed:
trackPlanDetailsModalCloseclears overlay page config. This updates page state rather than firing the same link/tab event pattern. -
Select plan from overview tab: the overview button calls
selectPlanListModalPlan, which routes intoPlanStore.selectPlan. The actual plan-selection analytics are then handled by the broaderchoosePlanreaction, not the plan-details reaction. -
Add-on details modal: separate tracking exists via
trackPlanDetailsAddOnModalOpenandtrackPlanDetailsAddOnModalTabChange.
What the dedicated route is for
<Route path=’/sim-only/best-sim-only-deals/:planId/plan-details’ element={<PlanDetails />} />
- This route provides a full-page, deep-linkable plan-details experience instead of an overlay modal.
-
It creates a fresh
SimoStore, readsplanIdfrom the URL, and runsstore.initPlanDetails(planId). -
It then renders
PlanDetailsTemplateand uses the same helper family to resolve the tab content. -
It is not exactly the same UI as the modal because it lives in a dedicated page shell rather than inside
ModalandTabbedModalTemplate. - It is still based on the same underlying content concepts: overview data from the plan object, and tab content from shared and page-specific CMS assets.
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
Actual Client Request Shape
const searchParams = new URLSearchParams({
contentEntryKey,
contentType,
spaceName: 'consumer',
})
requestInstance(`/api/content-service/v2/content?${searchParams.toString()}`)
.get()
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,
)
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.
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
-
Add a constant to
CONTENTFUL_CONTENT_ENTRY_KEYSif the new CMS asset will be looked up by key inside a larger page response. - If you need a new response path, add it to
CONTENTFUL_PATH_CONFIG. -
Add a new
hydrate(...)request in the relevant ContentStore fetch method such asfetchContent(),fetchSeamlessMigrationContent(), orfetchP2PContent(). - Include the request in the surrounding
Promise.all. -
Map the returned payload inside
loadContent()ormapPageContent()so components can read it fromconsumerContentorbusinessContent. -
If the content is rendered conditionally, route it through helpers like
findByContentEntryKey()andshouldShowContentByTag()instead of duplicating traversal logic in components. - 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:
-
Extend
ContentParamsingetAssetModelV2.tswithspaceName?: string. -
Default it safely, for example
spaceName = 'consumer', so current call sites keep working. -
Pass the new
spaceNamefrom the specific ContentStore fetch path or new caller that needs it. - Add tests for the new query-string behavior in
getAssetModelV2.test.ts. -
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
- Add a new entry to
src/server/common/config/contentAPITransformer.config.ts. -
Set the correct
assetName,assetType, andspaceNamein thev2config. - Ensure the server middleware path that consumes it is already covered by the current transformer configuration flow.
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 |
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:
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.
| 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-prerenderinsideprerenderMiddleware. - The middleware is only enabled when
PRERENDER_SERVICE_URLexists. - 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
ioredisinconnectRedis.ts. -
For local or mocked environments, the app can swap to
MockRediswithPRERENDER_MOCK_REDIS=true.
Why the skip rule matters
- If a request has a
basketIdcookie, 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.
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
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
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
- GET_PLANS
- SELECT_PLAN
- GET_SUBSCRIPTION_SUMMARY
- GET_OTB
- SET_JOURNEY_TYPE
- SET_OPERATION_MODE
- GET_EXTRAS
- SELECT_EXTRA
- REMOVE_EXTRA
- ADD_BYOD_INSURANCE
- GET_PLAN_DETAILS
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.
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.
| Call | Method | HATEOAS link / URL | Trigger | Key data sent | Key data received |
|---|---|---|---|---|---|
| Journey Latest | GET | .../journeys/latest?segment= |
Page load | segment, referrer |
id, state, _links, filters, notification |
| Create Journey | POST | .../journeys |
Deep link / new session | journeyType: "NOTSET" |
Same as Journey Latest |
| Resume Journey | GET | .../journeys?packageId= |
?packageId= in URL |
packageId |
Journey with selectedPlan |
| Get Plans | GET | get-plans |
After journey init & filter changes | commitmentPeriod, sortBy, filter params |
plans[], selectedPlan, filters |
| Select Plan | POST | select-plan (on plan object) |
User clicks "Choose" | linesQuantity (business) |
packageBuildSummary, state, new _links |
| Get Subscription Summary | GET | get-subscription-summary |
Upgrade/tariff migration journeys | — | Current contract details |
| Get OTB | GET | get-offers-and-buy-options |
Journey init (if link present) | — | Arrears data, cross-sell offers |
| Set Operation Mode | POST | set-operation-mode |
Business multi-line edit from basket | operationMode: "SINGLE" | "MULTIPLE" |
Updated journey |
| Get Extras | GET | get-extras |
Navigate to extras page | linesQuantity (if multi-line) |
available[], packageBuildSummary |
| Select Extra | POST | select-extra (on extra object) |
User toggles extra on | HATEOAS params | — (reloads extras) |
| Remove Extra | DELETE | remove-extra (on extra object) |
User toggles extra off | linesQuantity |
— (reloads extras) |
| Keep Package | POST | select-keep-package |
Keep/Replace modal — Keep | HATEOAS params | Updated journey, both plans in basket |
| Replace Package | PATCH | select-replace-package |
Keep/Replace modal — Replace | planId |
Updated journey, new plan only |
| Change Segment | POST | sync-bskt-seg-to-jrny |
Mismatch — Continue & clear extras | HATEOAS params | Updated journey in new segment |
| Keep Segment | POST | sync-jrny-seg-to-bskt |
Mismatch — Cancel switch | HATEOAS params | Journey reverted to original segment |
| Empty Basket | POST | empty-basket |
Mismatch — Empty & continue | HATEOAS params | Empty journey, fresh start |
| Get Insurance Add-ons | GET | /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 Insurance | POST | 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"
PlanStore.selectPlan(id)simOnlyService.selectPlan(plan)plan._links["select-plan"].hrefbasketTotal, linksWhat 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.
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
errorCode— the notification code from the backend (drives the modal copy)links— the_linksfrom the journey response, used to call keep or replaceonKeepOrReplace(replacePackage)— calls the appropriate service and resolves the conflict
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 code | What it means | UI 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
errorCode— notification code from backend; drives which modal variant is shown and which CMS copy loadscontinueAndClearExtras()— callschangeJourneySegment()then refetches planscancelSwitchSegment()— callskeepJourneySegment()emptyBasketAndContinue()— callsemptyBasket()then reloads the journey
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"
}
}
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
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
showAddonPageshowCroComparisonTableshowCroAomPlansRepositionEnabledshowLoginSubHeader
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
Always add to src/server/common/config/featureFlags/featureFlags.config.ts first, then access
via FeatureFlagStore.
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
}
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
shopAnonymousSessionMiddlewareruns, establishing an anonymous session and setting the{prefix}_p_id_tokencookie. This cookie contains a unique, generatedplatformSessionIdassociated with their session. -
Authenticated Users: If the user completes the IDM OAuth2 login flow, the
loginCallbackMiddlewaresets the encrypted auth cookies:{prefix}_p_id_token(containing their parsed account, subscriptions, assuranceLevel: 3, andplatformSessionId){prefix}_id_token(the raw JWT access token used in authorization headers)customerSegment(specifyingconsumerorbusiness)
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
getSimoJourneywhich runs aGETrequest: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
createJourneywhich runs aPOSTrequest 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+jsonAuthorization: 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
prerenderMiddlewareactively 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
- HTTPS by default (self-signed cert)
- Vite HMR for instant updates
- AIM mock replay for offline development
- Debug panel for selecting mock profiles
- Simplified DXL auth (no real OAuth flow needed)
- Routes not in whitelist proxy to live environment
AIM (API Interaction Mock)
AIM records and replays API responses from __mockapi__/ so you can develop without a live
backend.
├── 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
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:federatedrunsvite build -c tools/vite/client/vite.federated.config.ts. - The build output goes to
=build/federated. -
The input entry is
src/client/index.federated.tsxand the emitted SystemJS entry name isvfuk-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 |
├── 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.tsxwraps the app with the same theme/router/provider stack, then exports single-spa lifecycle methods. -
routes.federated.tsxonly exposes the shell routes intended for federated use, mainly/sim-only/best-sim-only-deals-shellin non-prod contexts.
How to run it in practice
-
Use
yarn startfor normal day-to-day local development. That is the main supported developer workflow in this repo. - Use
yarn build:federatedwhen you need the shell-consumable artifact. - 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
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
|
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_LOGSis true and the feature flagshowDDBrowserLogsis enabled. - Even when Datadog shipping is disabled, the logger still writes to the console in development/Cypress to aid debugging.
What gets sent
-
BaseLoggerinitialises 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_MAPturns internal error keys such asGET_OTBinto readable messages such asGet OTB has errored. filteredLogssuppresses noisy generic messages such asScript 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
- Session/Auth
- Journey CRUD
- Plans fetch/select
- Extras toggle
- Insurance
- Content/CMS
- Filters
- Checkout
- Seamless Migration
Tealium Analytics
- 70+ tracked events via
@vfuk/lib-web-analytics - Events: page loads, overlay views, link clicks, cart add/remove, filter actions
- Action types: BUTTON, LINK, TAB, DROPDOWN, ACCORDION, CAROUSEL, TOGGLE
- Configuration in
src/client/analytics/analyticsConfig.ts
OneTrust
Consent management scripts injected before all other third-party scripts. Controls what tracking is allowed.
File Structure
├── 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?
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
/api/simo-purchase/{billingType}/v2/{sessionId}/journeys/latest?segment={type}
_links.GET_PLANS.href (already contains commitmentPeriod=24+Months)
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
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.
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.inclusiveProductsproduct.idbenefit_item_456 → "456"inclusiveProducts["456"]Benefit tags control visibility
Tags on the Contentful benefit entry determine where/how the benefit appears:
visible— shown in main card benefit listmodalOnly— only shown inside the plan details modalxtra— shown in the Xtra benefits section
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.
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
| Parameter | Description |
|---|---|
tab | Active tab: 'whatsincluded' | 'whatyouneedtoknow' | 'additionalcharges' | 'aboutspeed' | 'deviceDetailsHandset' |
plan | The backend plan object. Key fields read: type, subType, upsellBand, planBand |
planDetails.sharedPlanDetails | CMS overlay content loaded once — contains the shared tab blocks (charges, speed, need to know) |
planDetails.pageSpecificPlanDetails | CMS 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 example | What 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_essentials | Fallback "essentials" content — shown when no subtype match found |
devicedetailshandset_gb_1 | Device details for GB airtime band 1 plans |
devicedetailshandset_generic | Generic 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
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.ts — SUBTYPES 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.
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 value | CMS entry key pattern | Notes |
|---|---|---|---|
red51 | red51 | packagelistsimo_red51 | |
unlimited51 | unlimited51 | packagelistsimo_unlimited51 | |
unlimitedentertainment51 | unlimitedEntertainment51 | packagelistsimo_unlimitedentertainment51 | |
redentertainment51 | redEntertainment51 | packagelistsimo_redentertainment51 | |
unlimited81 | unlimited81 | packagelistsimo_unlimited81 | |
unlimitedmax81 | unlimitedMax81 | packagelistsimo_unlimitedmax81 | |
unlimitedentertainment81 | unlimitedEntertainment81 | packagelistsimo_unlimitedentertainment81 | |
unlimitedmaxentertainment81 | unlimitedMaxEntertainment81 | packagelistsimo_unlimitedmaxentertainment81 | |
unlimited83 | unlimited81 | packagelistsimo_unlimited81 | Roaming variant — maps to 81 content |
unlimitedmax83 | unlimitedMax81 | packagelistsimo_unlimitedmax81 | Roaming variant — maps to Max81 |
unlimitedentertainment83 | unlimitedEntertainment81 | packagelistsimo_unlimitedentertainment81 | Roaming variant |
unlimitedmaxentertainment83 | unlimitedMaxEntertainment81 | packagelistsimo_unlimitedmaxentertainment81 | Roaming variant |
unlimited100mbps | unlimited100mbps | packagelistsimo_unlimited100mbps | Speed unlimited plus |
unlimited100mbps51 | unlimited100mbps51 | packagelistsimo_unlimited100mbps51 | |
unlimited100mbps83 | unlimited100mbps83 | packagelistsimo_unlimited100mbps83 | |
unlimitedentertainment100mbps | unlimitedEntertainment100mbps | packagelistsimo_unlimitedentertainment100mbps | |
unlimitedentertainment100mbps51 | unlimitedEntertainment100mbps51 | packagelistsimo_unlimitedentertainment100mbps51 | |
unlimitedentertainment100mbps83 | unlimitedEntertainment100mbps83 | packagelistsimo_unlimitedentertainment100mbps83 | |
red | red | packagelistsimo_red | |
red83 | red83 | packagelistsimo_red83 | |
redentertainment83 | redEntertainment83 | packagelistsimo_redentertainment83 | |
redentertainment | redEntertainment | packagelistsimo_redentertainment | |
unlimited | unlimited | packagelistsimo_unlimited | |
unlimitedentertainment | unlimitedEntertainment | packagelistsimo_unlimitedentertainment | |
unlimitedmax | unlimitedMax | packagelistsimo_unlimitedmax | |
unlimitedmaxentertainment | unlimitedMaxEntertainment | packagelistsimo_unlimitedmaxentertainment | |
basics plan | basicsplan | packagelistsimo_basicsplan | Note: key has a space; matches after .toLowerCase() |
| anything else | undefined | Falls back to packagelistsimo_essentials | Silent fallback — no error |
There are also two alias mappings handled in getPlanSubType() before
the table lookup:
"unlimitedMax51"and"unlimitedMax"→ mapped toSUBTYPES.unlimitedvalue directly (bypasses table)"unlimitedMaxEntertainment51"and"unlimitedMaxEntertainment"→ mapped toSUBTYPES.unlimitedentertainment51value directly- Any subtype containing
'simx'→ the SIMX prefix/suffix is stripped 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
| Order | Element | Condition to render | Content source |
|---|---|---|---|
| 1 | Main heading | !aomRecommendedBundlePlans?.length | contentStore.getMainHeader() |
| 2 | <AcceptableUsePolicy> | Same as above | Static / CMS copy |
| 3 | <ComparisonTable> (CRO) | isCroComparisonTableEnabled + business + 24mo + acquisition | CMS |
| 4 | <UspHeader> | Not tariff migration, no AOM bundles | CMS |
| 5 | Sub-header copy | Same as above | content.mainCopy (raw HTML) |
| 6 | <TariffMigration> | Self-guards on isTariffMigration — see Tariff Migration | Hardcoded strings |
| 7 | Signposting notification | contentStore.signpostingContent exists | CMS |
| 8 | <SubscriptionSummary> | Plans page + not tariff migration | Store-derived |
| 9 | <SimXNotificationBanner> | Flag + SIMX plan available | CMS |
| 10 | <DiscountBanner> | store.isDiscountBannerVisible | Loyalty data |
| 11 | <MidContractRise> (above AOM carousel) | Flag + AOM bundles present | CMS |
| 12 | <AomRecommendedPlans> carousel | AOM bundle plans present | Store + CMS |
| 13 | Filters (<PlanFilterWrapper>, mobile, pills, list) | Various flags | Store |
| 14 | <FamilyDiscountBanner>, <TrendingPlans> | Various flags | CMS |
| 15 | The plan grid itself | visiblePlans.length | renderPlanListContent() → <PlanListContent> |
| 16 | <NoPlansFound> / <NoPlansScenario> | No plans match filters | CMS |
| 17 | 5G Ultra / 3G shutdown notifications | Flags + CMS content present | CMS |
| 18 | <ScrollButton> (back to top) | isBackToTopButtonEnabled | - |
| 19 | <SecureNetModal>, <GenericBenefitItemBottomTray>, <FamilyLearnMorePopup> | Self-guarding on store state | CMS |
| 20 | <MarketingComponent> | Always rendered; content array may be empty | contentStore.getPostBodyMarketingContent() |
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 ID | Rendered by | Typical use |
|---|---|---|
standardBanner | StandardBannerMap | Promotional image + CTA banner |
partnerBanner | PartnerBannerMap | Third-party partner promo (e.g. streaming) |
partnerBannerApple | PartnerBannerAppleMap | Apple-specific partner promo layout |
advert | AdvertMap | Simple advert block |
contentBlock | ContentBlockMap | Heading + rich text content block |
accordion | AccordionMap | FAQ sections — collapsible Q&A list |
iconSnippetList | IconSnippetMap | "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:
- The slot tag —
slot3for post-body content (seeMARKETING_SLOT_TAG_NAMES.SLOT_3) - The current
journeyType(e.g.acquisition,upgrade,secondline) - The current
segment(e.g.consumer,business)
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
contentType.sys.id === 'accordion'MarketingComponentdispatches to
AccordionMapAccordionMapdestructures
theme, singleOpen, initiallyOpenId, accordionSegments<ContentBlockWrapper fields={content.fields}>renders
sectionHeading as the block title<CollapsibleContainer>headingTextBody =
<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.
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.
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.
£38 on 1 April 2028
Master legend — every numbered region
| # | Element | Source | Where the value comes from | Component / 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.tsx — hasSavingsApplied 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:
| System | What it renders | Driven 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 / Icon | Condition | Text source |
|---|---|---|
| 5G / 5G Ultra / 5G Plus icon | badgeIcon exists & not simplified card flag | Icon name derived from plan.badgeURL string |
| Vodafone Together family icon | familyPlan && !aomPlan | Hardcoded icon name vodafone-together |
| "Our bestselling plan" pill | plan.isRecommended & trending/SIMX flags off | Constant 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" pill | subType includes "simx" & isSimxPillAndBannerNotificationEnabled flag on | Hardcoded 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[]
}}
/>
| Piece | Source | Notes |
|---|---|---|
| "Monthly" label | External mapper (hardcoded) | Not CMS — same for every plan |
| Price value | Backend: monthlyPrice.gross / .net | Business segment uses net and appends MCPR_BUSINESS_VAT_TEXT = "All prices ex. VAT" (constants.ts) |
| "Was £43" savings text | Backend: plan.savingsAppliedBanner | Takes priority over the mapper's own computed discount suffix when present |
| Price rise lines | Backend: plan.mcpr.priceRise[].labelText + .monthlyPrice | Formatted 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:
| Scenario | Button 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)
generateSecureNetBenefitItem()orderAndEnrichBenefitItems()merges CMS text/icon by
benefit_item_<id> keygenerateBenefitItemComponents()picks renderer per item
_links['get-contentful']?YES → clickable
link rowNO → plain text row
| Benefit row | How it's classified | Render 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 |
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 ID | Constant | Internal name |
|---|---|---|
3 | PLAN_BENEFIT_IDS.ENTERTAINMENT_BENEFIT_ID | Standard entertainment benefit |
106194 | PLAN_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
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
| Card | Trigger condition | Content source | Injected 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.
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 |
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:
-
Plan card click:
PlanCard.tsx → handleCardInteraction()- Legacy: calls
openPlanListModal()→PlanCardDetailsModal - Highlands: calls
setHighlandsSelectedPlanId(plan.id)+setIsHighlandsBenefitModalOpen(true)→HighlandsModalContainerre-renders
- Legacy: calls
-
Standalone plan-details URL:
PlanDetailsTemplate.tsxchecks the flag and returns<HighlandsModalContainer isMVAModal />instead of<PlanDetails />
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"
}
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" }]product.id{ "122533": product, … }benefitsContent.fields.entries from CMSextractBenefitId(entry.fields.key)"benefit_item_122533" → "122533"(regex:
/\d+/g)mappedProducts["122533"] exist?If YES →
createMappedBenefit(product, cmsFields)mainBenefits / modalOnlyBenefits / xtraBenefitsCMS fields used during merge:
fields.text: Display label. Supports{{commitmentPeriod}}interpolation. Falls back toproduct.nameorproduct.planBenefitif text is@catalogfallbackor empty.fields.icon: Split on:→{ name, group }. e.g."globe:system"→name="globe",group="system".fields.tags: Array of routing directives (see tag table below).fields.benefitFeatures.isNew: Iftrue, benefit is prepended to the list with a "New" pill badge.
Benefit Tag Classification
| Tag | Where it renders | Effect |
|---|---|---|
visible | Plan card + Highlands modal main list | Required to appear on the plan card benefit row at all |
clickable | Plan card + modal (Highlands only) | Benefit row item becomes interactive — click triggers the lazy Contentful fetch and opens the detail overlay |
modalOnly | Inside HighlandsPlanDetailsModal only | Hidden on the plan card; only shown in the full modal's secondary list |
xtra | HighlandsXtraBenefits section | Routed to the Xtra plan benefits box inside the modal (e.g. Vodafone Together benefits) |
tray | BottomTray + HighlandsModalBenefitContentRenderer | Benefit 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.highlandsModalContentfor a cached entry whosecontentEntryKeymatchesproductIdviamatchesProductId()(extracts the numeric portion with regex) - If cached: skip fetch, call
callback(productId)immediately - If not cached &
cmsContentKeypresent: callContentStore.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
tray tag AND no full plan modal is open?<BottomTray> slide-up sheetContains
<HighlandsModalBenefitContentRenderer><HighlandsPlanDetailsModal> dialogThen 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 does | CMS content key |
|---|---|---|
view_additional_charges | Links to out-of-bundle charges info | plandetailshandset_charges_out_of_bundle_charges |
find_out_more_red | "Find out more" CTA for Red plans | plandetails_included_find_out_more_red_plan |
find_out_more_xtra | "Find out more" CTA for Xtra plans | plandetails_included_find_out_more_xtra_plan |
secure_net_trial | SecureNet 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
product.idplanCardBenefitItemContent(fetched on page load)
extractBenefitId(key)"benefit_item_122533" → "122533"YES →
createMappedBenefit()(visible)
(modalOnly)
(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.
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
| Tab | CMS resolver | What drives the content |
|---|---|---|
| Overview | Static — plan data only | Plan name, price, data allowance from the backend plan object |
| What's included | getContentForPlan({ tab: 'whatsincluded', plan, … }) | plan.type + plan.subType → CMS key; see getContentForPlan |
| Additional charges | getContentForPlan({ tab: 'additionalcharges', … }) | Shared CMS key — same content for all plans |
| What you need to know | getContentForPlan({ tab: 'whatyouneedtoknow', … }) | Shared CMS key — same content for all plans |
| About speed | getContentForPlan({ 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
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 / Component | Key backend fields consumed | What 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
SUBTYPESconstant →undefined→ falls back topackagelistsimo_essentialsCMS block - To support a new type: add to
constants.tsSUBTYPES 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
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 string | Effect |
|---|---|
visible | Item appears in the main benefit list on the plan card |
modalOnly | Item appears only inside the plan details modal, not on the card surface |
xtra | Item 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
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
| Element | Source | Hardcoded? |
|---|---|---|
| Entertainment detection (plan level) | plan.subType.includes('entertainment') | String match — depends on backend subType |
| Entertainment benefit item detection | productId === ENTERTAINMENT_BENEFIT_ID | Yes — frontend constant, risk for 3onv |
| Entertainment image URL | plan.entertainmentPromotion.mediaUrl | No — fully backend-driven |
| Entertainment benefit text | CMS 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_IDSin 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?
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 override | Standard filter selection (18 months) | |
|---|---|---|
| When it happens | On initial journey setup, before the first plans fetch | When user taps a filter tab after plans are loaded |
| Who triggers it | Frontend detects isBasicUpgrade and patches the link | User action → FilterStore.selectCommitmentPeriod() |
| Mechanism | String replace on the HATEOAS href | Rebuilds query string and re-requests plans |
| Backend requirement | Backend must send a 24-month link; FE rewrites to 12-month | Backend must include '18 Months' in filters.commitmentPeriod |
| 3onv consideration | If 3onv basic upgrades should also use 12 months, same logic applies | No change needed — works with any period the backend declares |
Extras / Add-ons Page
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.ts — 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 type | Extras page shown? | Condition |
|---|---|---|
| Upgrade | Yes (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 extras | Yes | Notification 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
| Element | Source | Hardcoded? |
|---|---|---|
| "Choose your Add-ons" heading | Hardcoded string in template | Yes |
| Lost extras notification banner | extraStore.notifications from backend | No — backend-driven notification code |
| Extra cards list | extraStore.extras — from backend response.available[] | No |
| Extra card "What's included" detail | CMS: extraswhatsincluded_sku{extra.id} | ID is dynamic (backend); CMS key prefix is hardcoded |
| Addons CMS block | contentEntryKey: 'packagelistsimoextraswhatsincluded' | Yes — hardcoded Contentful key in ContentStore.loadAddonsContent() |
| No-thanks / skip button | Hardcoded | Yes |
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:
- Add a
brandobservable toSimoStore(set from the backend journey response or a query param). - In
ViewStore.goToExtras(), add:|| this.simoStore.brand === 'three'to Gate 1. - Pass
brandtoContentStore.loadAddonsContent()to fetch a brand-specific CMS block.
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 appliedlostExtrasIds— 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
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
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)
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
| Aspect | Standard PlanCard | PlanCardEntertainment (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 truthyerrorCode === EXCEED_MAX_PLANS_ERROR_CODE?<MaxPlansTemplate> inside a plain <Modal>errorCode === SESSION_EXPIRED_ERROR_CODE+ message includes Authorization text
<ErrorStatusModal> with "Log in" button<ErrorStatusModal> — text looked up from API_ERRORS[errorCode], button is "Close" or "Refresh"SimoErrorModal — Every Scenario & Its Error Code
| # | Scenario | Trigger condition | Error code | What 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
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 content | Source | File |
|---|---|---|
| Modal heading ("Sorry something has gone wrong") | Hardcoded constant ERROR_TITLE | constants.ts |
| Body text per error code (11 messages) | Hardcoded constant API_ERRORS map | constants/apiErrors.ts |
| Fallback body text | Hardcoded constant DEFAULT_ERROR_TEXT | constants.ts |
| Session-expired heading & body | Hardcoded constant SESSION_EXPIRED_MODAL | constants.ts |
| Session-expired "Log in" button | Hardcoded string, inline in SimoErrorModal.tsx | SimoErrorModal.tsx |
| "Close" / "Refresh" button text | Hardcoded strings, inline in SimoErrorModal.tsx | SimoErrorModal.tsx |
| Max plans modal — full copy & "Go to basket" button | Hardcoded JSX text, including the number 10 as a local const maxPlans = 10 | MaxPlansTemplate.tsx |
| Which error codes trigger which behaviour | Hardcoded constants: EXCEED_MAX_PLANS_ERROR_CODE, SESSION_EXPIRED_ERROR_CODE, SESSION_EXPIRED_ERROR_MESSAGE, SEAMLESS_MIGRATION_ERROR_CODES, SEAMLESS_MIGRATION_INVALID_SEGMENT | constants.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.
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.
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.
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.
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.
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:
| # | Mechanism | Location | Notes |
|---|---|---|---|
| 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:
package.jsonlists@vf/utils-brand-resolveras a dependency — but it is not imported anywhere insrc.-
src/server/common/config/clientDynamicEnvVariables.config.tsis the only file that ever referenced it, and every line is commented out:// import { resolveBrandId } from '@vf/utils-brand-resolver' // const brandId = resolveBrandId(req) // console.log(`Brand ID ✅: ${brandId}`)brandIdis never computed, never attached tores.localsor the client env payload, and never reachesSimoSeoTagsorContentStore.
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 value | File | Relevance 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. |
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
-
parseSchemaObject.tsis dead/unwired.src/client/helpers/parseSchemaObject/parseSchemaObject.tsrecursively rewritescontext/typekeys to@context/@typefor JSON-LD, but it is not imported bySimoSeoTags.tsxor any other production file —seoAsset.schemais passed toHeadTagsraw, presumably already correctly keyed by Contentful. Its only consumer is its own test file, whose mock fixtures hardcode Vodafone social URLs (facebook.com/vodafoneUK,twitter.com/vodafoneuk,youtube.com/user/VODAFONEUK) — test data only, not shipped. -
No hreflang / htmlLang / organisationSchema. Supported by the underlying
HeadTagscomponent but never passed fromSimoSeoTags— see SEO Overview. -
404 page has minimal SEO coverage.
NotFound.tsxuses a separate library (react-helmet) and sets only a prerender-status meta tag — no title/description/OG/Twitter/schema. See Duplicate/Competing Mechanisms. -
Two independent
robotscontrols. Page-level CMSseoAsset.robots(perSimoSeoTags) vs. the static site-widerobotsstring incore.config.ts— these could disagree and no code ties them together.
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.basketIdis present (user has an active basket)req.cookies.JourneyIDis present (user has an active journey)PRERENDER_SERVICE_URLenv 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
| Aspect | Value |
|---|---|
| Package | @vfuk/lib-web-prerender |
| Cache backend | Redis (production) / MockRedis (local — no caching) |
| Cache TTL | 300 seconds (5 minutes) |
| Env var to enable | PRERENDER_SERVICE_URL |
| Skipped when | basketId cookie present; user logged in |
| Cookies baked in | features, customerSegment |
| Root mode | hydrateRoot (prerender) vs createRoot (fresh) |
| CSS mode | disableCSSOMInjection={true} during prerender |
3onv consideration
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
runHateoasLink(link, method, body)Prepend
/api/digital/v1 or /api/digital/v2 to the link hrefPOST with
X-HTTP-Method-Override: PATCH header via setHTTPMethodOverride()requestInstance from @vfuk/web-middleware-request-utilsAdds
Accept: application/hal+json; handles auth headers_links for special actionsURL 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 method | Actual HTTP method sent | Extra header |
|---|---|---|
| GET | GET | — |
| POST | POST | — |
| PATCH | POST | X-HTTP-Method-Override: PATCH |
| DELETE | POST | X-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
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 key | What it does |
|---|---|
continueAndClearExtras | Clears extras from the basket, confirms the segment switch, continues the journey |
keepBasketAndResetToggle | Cancels the segment toggle — keeps the user on their existing segment; dismisses modal |
emptyBasketAndContinue | Empties the basket entirely then continues with the current login/segment |
signIn | Redirects to /web-shop/login — triggers IDM OAuth2 flow |
logOut | Calls the logout endpoint and redirects to the sign-out landing page |
simoSecondline | Redirects to the SIM-only second-line/additional plan purchase flow |
handsetPage | Redirects to the handset/phone upgrade page |
3onv consideration
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
| Slot | Tag required | ContentStore method | Where 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 type | Renderer component | What it typically renders |
|---|---|---|
standardBanner | StandardBannerMap | Full-width marketing banner with heading, body, and CTA |
partnerBanner | PartnerBannerMap | Co-branded partner promotion banner |
partnerBannerApple | PartnerBannerAppleMap | Apple-specific partner banner variant |
advert | AdvertMap | Display advert / promo block |
contentBlock | ContentBlockMap | Rich-text content block (FAQs, legal copy, etc.) |
accordion | AccordionMap | Collapsible accordion FAQ sections |
iconSnippetList | IconSnippetMap | Icon + text list (e.g. plan features overview) |
| anything else | null | Silently 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
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?" + aStateNotificationinfo 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 element | Standard journey | Tariff migration |
|---|---|---|
| Page heading | From Contentful simo_heading_{segment}_{journeyType} | Hardcoded: "Change your plan" |
| Sub-heading | From Contentful mainCopy | Hidden |
| USP header | Shown | Hidden |
| Comparison table | Shown (if eligible) | Hidden |
| Benefit item row | Not shown in header | Hardcoded: "Your current agreement end date won't change" |
| SubscriptionSummary | Separate slot above plan list | Inside TariffMigration header component |
| Plan list | Normal plans | Normal plans (or hidden if tariffMigrationPlansFailure) |
| On plan load failure | Standard error state | Custom info notification + webchat widget |
3onv consideration
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__/
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:
- See the current active scenario
- Switch to any scenario directory in
__mockapi__/ - Toggle between proxy mode (records from backend) and mock mode (serves files)
- View the AIM config and currently cached file paths
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)
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.
/api/*isRecordingEnabledisMockingEnabledBoth OFF
Proxy to backend,
no file read/write
Recording ON
Proxy to backend,
save response to file
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.
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:
| Operation | Does 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)
- Create
__mockapi__/3onv/consumer/GET/and__mockapi__/3onv/consumer/POST/directories - Switch AIM to proxy mode (disable mock serving, enable recording) via the debug panel
- Switch the active scenario to
"3onv/consumer" - Make sure the frontend is sending
X-Brand-ID: VFThreeon all requests - Click through the journey — AIM will proxy each request to the backend (with the header) and save responses under
__mockapi__/3onv/consumer/ - Switch back to mock mode
- 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:
hashIgnoredReqBodyKeyshashIgnoredReqQueryKeyshashIgnoredReqPathPatternsignoredPathsproxyrewriteMockPathNamestorageInterfaceRootPath
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 component | In hash? | Can be ignored via config? |
|---|---|---|
| URL path (after rewrite) | ✅ Yes | Yes — hashIgnoredReqPathPatterns wildcards segments |
Query string (?foo=bar) | ✅ Yes | Yes — hashIgnoredReqQueryKeys |
| Request body (POST/PATCH) | ✅ Yes | Yes — hashIgnoredReqBodyKeys |
| HTTP method | ✅ Yes (in path) | No |
Request headers (incl. X-Brand-ID) | ❌ No | No — not supported in v10.2.1 |
| Cookies | ❌ No | No |
| 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.tsx → SimoTemplate |
Default consumer storefront. No existing-account upgrade recovery logic. |
| Upgrade | /sim-only/best-sim-only-deals?journeyType=upgrade |
Simo.tsx → SimoTemplate |
Keep-or-replace basket conflict flow, subscription summary, more auth/session coupling. |
| Second line | ?journeyType=secondline |
Simo.tsx → SimoTemplate |
Family discount logic, loyalty-aware copy, second-line eligibility rules. |
| Business | /business/business-sim-only |
Simo.tsx → SimoTemplate |
Segment branch, different content keys, several consumer-only flags disabled. |
| P2P migration | /migration/basics/test, /migration/phase5/test |
P2PMigration.tsx → P2PMigrationTemplate |
Separate page and template, dedicated login/eligibility modals, non-prod route surface. |
| Tariff migration | Internal journey branch inside main page | Simo.tsx → SimoTemplate |
Simplified plan presentation, reduced slot content, some standard merchandising skipped. |
| Seamless migration | /customer-transfer/best-sim-only-deals |
SeamlessMigration.tsx → SeamlessMigrationTemplate |
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.tsx → PlanDetailsTemplate |
Can render the standard static details page or the Highlands modal container, depending on feature flags. |
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 |
|---|---|---|
| 1 | vfukServer.init() | Base Express/server wiring from the shared Vodafone server package. |
| 2 | useExpressStaticGzip() | Serves built client assets efficiently. |
| 3 | seamlessMigrationRedirectMiddleware() | Normalizes customer-transfer routes before the rest of the chain runs. |
| 4 | Debug auth chain when enabled | loginCallback, idmMiddleware, logout, account switching, local routing, DAL auth. |
| 5 | avoidApiCache | Stops API responses being cached incorrectly. |
| 6 | cleanRouteMiddleware | Strips the base route prefix so downstream handlers see stable paths. |
| 7 | contentAPITransformer | Transforms shell content payloads such as footer and meganav. |
| 8 | shopAnonymousSessionMiddleware | Ensures anonymous session state exists before redirect/proxy logic. |
| 9 | loginRedirect | Protects paths that require authenticated state. |
| 10 | dxlProxyMiddleware | Forwards API traffic to the backend integration layer. |
| 11 | featureFlaggingMiddleware | Injects feature-flag data into the runtime HTML/env payload. |
| 12 | prerenderMiddleware | Handles prerender cache and skips basket-bearing sessions. |
| 13 | useIndexRouting() | 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]
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.
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.
pageView = plans!showAddonPage, business, tariff migration, or no valid extras pathgoToBasket()external redirect
setPage('extras')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 allowed | ViewStore.goToExtras() | Load CMS add-ons content and extras data, then move to #extras. |
| Business segment or add-ons disabled | ViewStore.goToExtras() plus flags | Skip extras page and redirect to basket sooner. |
| P2P logged-in special case | ViewStore.shouldGoToBasketFromP2P() | Bypass extras and jump straight to basket. |
| Insurance/basket modification logic | ViewStore.shouldGoToBasketFromPlans() | Decides whether the user should skip straight to basket from the plans step. |
| Hash changes in URL | ViewStore.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 banners | packagelistsimo_* entry keys | packagelistsimo_business, review header, list-slot labels |
| Plan-card or benefit-row content | planCardBenefitItemContent / plan benefit collections | Standard benefits, Highlands benefit items, entertainment tray content |
| Plan details overlay content | planDetailsOverlayApp and related overlay paths | Standard plan details, Highlands plan-level overlay, FAQs |
| Segment-specific contact or signposting | Entry keys with _${segment} suffix | contact_us_flyout_consumer, business signposting keys |
| Journey-specific migration content | Seamless or P2P entry keys | seamless_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
- Find the existing entry key or collection constant before inventing a new pattern.
- Check whether the content is segment-specific, journey-specific, or flag-gated.
- Verify whether the content is read from a flat entry,
fields.content, or nestedfields.entries. - If it is clickable or modal-driven, confirm which store caches or re-fetches it.
- 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 analytics | src/client/analytics/analyticsConfig.ts | Page events, overlays, links, benefit modals, filters, plan selection, insurance actions. |
| Analytics constants | src/client/analytics/analyticsConstants.ts | Normalized names for buttons, page names, tabs, commitment periods, and action types. |
| Analytics reactions | src/client/stores/AnalyticsStore/ | Store-driven side effects that emit events when state changes. |
| Datadog browser logs | src/utils/datadog/loggers/browserLogger/ | Structured warning/error logs from stores and client services. |
| Datadog RUM | Env-injected analytics config | Client 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
- You add a new CTA, tab, modal, tray, or filter choice.
- You rename a user-facing action that already maps to a button label or page name constant.
- You move a user journey from one overlay style to another, such as legacy modal to Highlands.
- You introduce a new flag branch with materially different user interactions.
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 logic | Targeted Jest store tests | Store transitions often control multiple pages and overlays indirectly. |
| React component rendering | RTL component tests via customRender() or renderWithContext() | The provider stack matters for Source Web, icons, and store-backed props. |
| Service or HATEOAS behavior | Service unit tests plus one journey smoke path | Small service changes can cascade through several stores. |
| Journey branching or login/session behavior | Cypress journey tests in the relevant folder | These bugs usually only appear with real route/cookie state. |
| Content or benefit mapping | Unit tests plus mock-content validation | Many CMS regressions do not fail until plan cards or overlays are rendered. |
| Server middleware or proxy rules | Local startup plus the affected route/journey path | Ordering 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.
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 JSON | AIM + __mockapi__ | __mockapi__/journey/…, __mockapi__/default/… |
| CMS payload for unit or Cypress content setup | Fixture JSON | cypress/fixtures/content/… |
| Single component/store test | Jest/RTL mocks and fake store values | Co-located tests plus testUtils.tsx |
| Server route split or proxy behavior | Local middleware config | useLocalMiddlewares.ts, aim.config.ts |
What AIM is best at
- Replaying realistic journey payloads from the real backend shape.
- Sharing stable mocks across SIMO and seamless migration via path normalization.
- Recording once and iterating locally without repeated backend calls.
What AIM is not best at
- Small unit-level tests where a direct object stub is simpler.
- Header-sensitive scenarios, because request headers are not part of the cache key.
- Explaining content intent. CMS fixtures are usually easier to read than hashed AIM filenames.
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 |
|---|---|---|
highlandsPhaseOne | Plan details / plan card overlays | Switches from standard plan-details experience to Highlands modal-driven rendering. |
showAddonPage | ViewStore journey transitions | Changes whether the extras page is a real step or skipped entirely. |
showSimoInsurance | Plans and insurance flow | Enables consumer insurance surface and related quote journey entry points. |
showCroFilterOptions | Filtering UI | Turns on the richer CRO filtering surface for eligible journeys. |
showCroPricePillsFilter | Filter pills | Adds the under/over price-pill interaction for consumer flows. |
showCroAomPlansRepositionEnabled | Plan ordering | Changes how AOM recommended plans are positioned in the list. |
showCroPlanCardEntertainment | Plan card benefit rendering | Enables bundled-entertainment benefit display on cards. |
showCroPlanCardEntertainmentModal | Entertainment overlay UX | Controls entertainment-specific modal behavior layered onto plan cards. |
showMidContractRise | Upgrade notifications | Shows the mid-contract price-rise messaging path and supporting content. |
showJourneyStepsTracker | Journey chrome | Enables the breadcrumb/step-tracker UI, even though build steps live elsewhere. |
networkTrialSecondLineEnabled | Auth second-line variant | Unlocks an otherwise dormant second-line branch that should always be tested with auth state. |
showDDBrowserLogs | Observability | Allows 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.
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.