custom.js & $functions
Overview
Purple Experience provides two JavaScript extension files:
File | Path | When it runs | What it can set |
|---|---|---|---|
custom.server.js | storefront/assets/scripts/custom.server.js | Server (SSR) + client | window.$functions only |
custom.js | storefront/assets/scripts/custom.js | Browser only | window.storefrontHooks, window.isIssueLocked, window.calculateStorefrontUserTags |
$functions must be defined in custom.server.js, not custom.js. This is because $functions are used during config resolution, which happens on the server during SSR. custom.server.js runs a strict sandbox — any attempt to access or set window.* properties other than $functions is blocked.
custom.js is browser-only and is the correct place for lifecycle hooks and lock-state overrides.
When to use this
Use custom.server.js when you need to:
- Expose helper functions that views.json can call during config resolution ($functions)
Use custom.js when you need to:
- Track page views in an external analytics system (onNavigationEnd)
- Run custom logic after content loads (onContentLoaded)
- Intercept or customise purchase flows (beforeContentPurchase, beforeSubscriptionPurchase)
- Initialise ad networks (onAdInit)
- Customise lock state display for issues (isIssueLocked)
- Compute and set user tags based on purchase/subscription data (calculateStorefrontUserTags)
Basic Example
custom.server.js — helper functions for config resolution:
window.$functions = {
getIssueYear: (issue) => (issue ? new Date(issue.publicationDate).getFullYear().toString() : ''),
getOptionalSourceIssueId: (issue) => (issue && issue.sourceIssue ? issue.sourceIssue.id : ''),
};custom.js — lifecycle hooks (browser only):
window.storefrontHooks = {
onNavigationEnd: ({ event, viewContext }) => {
analytics.track('page_view', {
path: event.urlAfterRedirects,
platform: viewContext.platform,
});
},
};In views.json:
{
"type": "html",
"tag": "span",
"text": "$functions.getIssueYear($context.issue)"
}Configuration
window.storefrontHooks — lifecycle callbacks
Register callbacks on window.storefrontHooks to react to framework lifecycle events. All hooks are optional — define only the ones you need.
For the full hook reference (parameters, return values, and examples for each hook), see Storefront Hooks Documentation
Available hooks at a glance:
Hook | When it fires |
|---|---|
onPurpleServiceInit | Purple Service is ready |
onNavigationEnd | After Angular NavigationEnd |
onNavigationStable | App stable after navigation |
onContentLoaded | Catalog content fully loaded |
beforeContentPurchase | Before a content purchase |
beforeSubscriptionPurchase | Before a subscription purchase |
entitlementChanged | Login / logout / subscription change |
subscriptionsChanged | User subscriptions updated |
onAdInit | Ad network initialisation |
beforeRenderAd | Before an ad slot renders |
isAdRefreshPaused | Ad refresh pause check |
isAdDisabled | Per-slot disable check |
getIntersectionMargin | Lazy-load trigger distance |
window.$functions — callable from views.json
Register functions on window.$functions. They are called synchronously during config resolution whenever $functions.myFn(...) appears in views.json.
window.$functions = {
// Return a formatted year string from an issue object
getIssueYear: (issue) => (issue ? new Date(issue.publicationDate).getFullYear().toString() : ''),
// Return empty string if issue has no sourceIssue
getOptionalSourceIssueId: (issue) => (issue && issue.sourceIssue ? issue.sourceIssue.id : ''),
// Functions can call other $functions or receive $context parts
formatTitle: (title, suffix) => (title ? `${title} — ${suffix}` : suffix),
};Calling from views.json:
{
"type": "html",
"tag": "span",
"text": "$functions.formatTitle($context.issue.name, $context.publication.name)"
}- Functions receive the already-resolved argument values — $context.* substitution happens before the function is called
- Functions can be nested: $functions.outer($functions.inner($context.attr))
- Return undefined or empty string to produce no output
- Functions must be synchronous — async functions are not supported in config resolution
window.isIssueLocked(issue, purpleService) — custom lock logic
Override this async function to customise when an issue is shown as locked. The default always returns false (no additional lock conditions). This is called only after the mandatory check (purchasable: true AND purchased: false) returns false.
window.isIssueLocked = async function (issue, purpleService) {
// Lock issues that have a custom property set
if (issue.properties && issue.properties['requires-premium'] === 'true') {
const isPremium = await purpleService.hasSubscription('premium-plan-id');
return !isPremium;
}
return false;
};Parameter | Description |
|---|---|
issue | The CatalogIssue object being evaluated |
purpleService | Purple Service instance — provides access to entitlement and purchase APIs |
window.calculateStorefrontUserTags(subs, issues, pubProds) — custom user tags
When defined, this function is called after the user's purchase data is loaded. It computes a set of boolean tags that are then accessible as $context.userTags.<tag>.
Important: The function must always return ALL possible tags (even the ones that are false), because the tracking service uses the full set to remove tags that are no longer active.
window.calculateStorefrontUserTags = (subs, issues, pubProds) => {
const hasActiveSub = subs.some((s) => s.purchased);
const hasBoughtIssue = issues.length > 0;
return {
isSubscriber: hasActiveSub,
hasPurchasedIssue: hasBoughtIssue,
isPremiumUser: hasActiveSub && hasBoughtIssue,
};
};Access in views.json:
{
"type": "section",
"condition": {
"value": "$context.userTags.isSubscriber",
"operation": "EQUALS",
"compareValue": "true"
},
"content": [{ "type": "html", "tag": "p", "text": "Subscriber content" }]
}Parameter | Type | Description |
|---|---|---|
subs | AppSubscription[] | All subscription plans (purchased and not purchased) |
issues | CatalogIssue[] | All purchased issues |
pubProds | PublicationProduct[] | All purchased publication products |
Testing Notes / Edge Cases
- $functions must be synchronous: If a function returns a Promise, the promise object is used as the value — it is not awaited. This will produce broken output. Keep $functions purely synchronous.
- calculateStorefrontUserTags must return all tags: If a tag key is missing from the return value, the previous value for that tag remains in context indefinitely. Always return every tag with an explicit true or false.
- isIssueLocked is called per issue, per render: Keep it lightweight. Avoid heavy API calls inside it; cache results if needed.
- Hook error handling: Experience wraps hooks in try-catch. If a hook throws, an error is logged but the app continues. Never rely on hooks throwing to cancel flows — return the appropriate result object instead. See Storefront Hooks Documentation for hook-specific notes.
Related Topics
- TODO: insert link to Dynamic URL Resolving