Building Extensions - Developer Guide¶
This guide provides comprehensive technical documentation for building custom extensions in Badger Commerce. Whether you're creating seasonal effects, integrating third-party services, or enhancing the shopping experience, this guide covers everything you need to know.
Table of Contents¶
- Extension Architecture
- Getting Started
- Extension Types
- Building Your First Extension
- Configuration Management
- Templates and Resources
- Testing Extensions
- Deployment
- Tutorial: Christmas Mode Extension
- Best Practices
- Troubleshooting
Extension Architecture¶
Core Interfaces and Classes¶
The extension system is built around a hierarchical class structure:
BadgerExtension (interface)
↓
BaseBadgerExtension (abstract class)
↓
AdministratableBaseBadgerExtension (abstract class)
↓
AdministratableBaseBadgerExtensionV2 (abstract class) ← Use this!
BadgerExtension Interface¶
The root interface defining the contract for all extensions.
Key constants:
- TEMPLATE_PATH_KEY - Key for template path in model
- PAGE_LOCATION_KEY - Key for page slot location
- CSS_LIST_LOCATION_KEY - Key for CSS files list
- JS_LIST_LOCATION_KEY - Key for JavaScript files list
- PAGE_RENDER_ORDER - Key for display priority
Required methods:
- getName() - Unique extension identifier
- getDescription() - Human-readable description
- getAuthor() - Extension author
- getVersion() - Semantic version (e.g., "1.0.0")
- isGlobal() - Whether extension applies globally
- getSiteId() - Site identifier (use ALL_SITES constant)
BaseBadgerExtension¶
Provides utility methods for extension development.
Useful methods:
// Create a model map for rendering
Map<String, Object> createModel(
String pageLocation,
int displayPriority,
String templatePath,
ExtensionConfigurationBean configuration
);
// Add JavaScript file to extension
void requiresJS(Map<String, Object> model, String jsPath);
// Add CSS file to extension
void requiresCSS(Map<String, Object> model, String cssPath);
// Declare that the extension's JS needs Stripe.js on the page
void requiresStripeJS(Map<String, Object> model);
// Find configuration value
<T> Optional<T> findPropertyConfiguration(
String key,
ExtensionConfigurationBean configuration,
Class<T> type
);
AdministratableBaseBadgerExtension (V1 Pattern)¶
Older pattern that separates admin and view rendering.
Methods to implement:
- getDefaultPageLocation() - Default slot for extension
- generateViewExtensionModel() - Render for customers
- generateAdminExtensionModel() - Render for admin interface
Not recommended for new extensions - use V2 instead.
AdministratableBaseBadgerExtensionV2 (V2 Pattern)¶
Recommended for all new extensions. Uses declarative configuration with EditableField<?>.
Methods to implement:
@NotNull String getDefaultPageLocation();
@NotNull List<EditableField<?>> getEditableFields();
List<String> getAdminJSPaths(); // Optional
List<String> getAdminCSSPaths(); // Optional
Map<String, Object> generateViewExtensionModel(...);
Map<String, Object> generateViewExtensionModelForFormPost(...);
Extension Service¶
The ExtensionService is the central registry for all extensions.
Key responsibilities: - Auto-discovers extensions via Spring dependency injection - Manages extension lifecycle - Renders extensions for given contexts - Handles global and item-level extensions
Main entry point:
Map<String, List<Map<String, Object>>> renderExtensions(
ExtensionConfigurationContainer item,
SiteContext siteContext,
User user,
Order order,
ExtensionRequestContext requestContext
);
Returns a map of page slots → list of extension models.
Page Slots¶
Extensions are rendered in specific page slots. Common slots defined in SlotNames:
TOP_BANNER- Top of page (breadcrumbs, alerts)MAIN_SECTION- Main content areaSIDEBAR- Side panelFOOTER- Bottom of pageCHECKOUT_SUMMARY- Order summary in checkoutPRODUCT_DETAILS- Product detail sectionSEARCH_RESULTS- Search/collection results
You can also use custom slot names.
Getting Started¶
Prerequisites¶
- Java 21+
- Spring Boot 3.3.4+
- Maven 3.3.9+
- Understanding of Thymeleaf templates
- Basic knowledge of JavaScript and CSS
Project Structure¶
Extensions follow a multi-module structure:
badger-commerce/
├── commerce-core/
│ └── src/main/java/.../extensions/
│ └── seasonal/
│ └── ChristmasModeExtension.java ← Extension class
│
├── web-mvc/
│ └── src/main/resources/
│ ├── templates/
│ │ ├── bootstrap/extensions/christmasMode/
│ │ │ └── main.html ← Bootstrap template
│ │ └── nova/extensions/christmasMode/
│ │ └── main.html ← Nova template
│ │
│ └── resources/settBuilder/themes/
│ ├── bootstrap/
│ │ ├── css/extensions/christmasMode/
│ │ │ └── christmas.css
│ │ └── js/extensions/christmasMode/
│ │ └── christmas.js
│ │
│ └── nova/
│ ├── css/extensions/christmasMode/
│ │ └── christmas.css
│ └── js/extensions/christmasMode/
│ └── christmas.js
Development Workflow¶
- Create Extension Class in
commerce-core/src/main/java/.../extensions/ - Implement Required Methods using V2 pattern
- Add Extension Name to
messages.propertiesresource bundle - Create Templates for each theme in
web-mvc/src/main/resources/templates/{theme}/extensions/ - Add CSS/JS Resources in
web-mvc/src/main/resources/resources/settBuilder/themes/{theme}/ - Build Project:
mvn clean package -DskipTests - Start Dev Environment:
./dev-scripts/devEnv.sh - Test Extension via admin interface
- Iterate with hot reloading support
Extension Types¶
By Functionality¶
Content Extensions¶
Display marketing content, carousels, videos, and rich media.
Examples: Static Carousel, HTML Fragment, YouTube Video
Typical slots: TOP_BANNER, MAIN_SECTION
Product Extensions¶
Enhance product display with galleries, attributes, and bundles.
Examples: Product Gallery, Size Guide, Bundle Builder
Typical slots: PRODUCT_DETAILS, SIDEBAR
Checkout Extensions¶
Add functionality to the checkout flow.
Examples: Apple Pay, Address Validation, Gift Options
Typical slots: CHECKOUT_SUMMARY, PAYMENT_OPTIONS
Social Extensions¶
Reviews, ratings, social sharing, and user-generated content.
Examples: Product Reviews, Social Links, Trending Products
Typical slots: PRODUCT_DETAILS, MAIN_SECTION
Seasonal Extensions¶
Time-based effects for holidays and special events.
Examples: Christmas Mode, Valentine's Day, Black Friday
Typical slots: TOP_BANNER (global effects)
By Scope¶
Global Extensions¶
Applied to all pages automatically.
Use cases: Site-wide effects, analytics, chat widgets
Item-Level Extensions¶
Added to specific pages, products, or collections.
Use cases: Product-specific features, page-specific content
Building Your First Extension¶
Minimal Extension Example¶
Let's build a simple "Alert Banner" extension.
Step 1: Create Extension Class¶
commerce-core/src/main/java/.../extensions/content/AlertBannerExtension.java
Important: After creating the extension class, you must also add its display name to the resource bundle (see Step 5).
/* (C)2025 Badger Commerce Limited, a subsidiary of Kedos Consulting Limited */
package uk.co.kedos.badger.settbuilder.extensions.content;
import java.util.List;
import java.util.Map;
import org.jetbrains.annotations.NotNull;
import org.springframework.stereotype.Component;
import uk.co.kedos.badger.settbuilder.catalogue.*;
import uk.co.kedos.badger.settbuilder.extensions.*;
import uk.co.kedos.badger.settbuilder.extensions.fields.*;
import uk.co.kedos.badger.settbuilder.extensions.slots.SlotNames;
import uk.co.kedos.badger.settbuilder.extensions.v2.AdministratableBaseBadgerExtensionV2;
@Component
public class AlertBannerExtension extends AdministratableBaseBadgerExtensionV2 {
public AlertBannerExtension(ExtensionUtils extensionUtils) {
super(extensionUtils);
}
@Override
public @NotNull String getDefaultPageLocation() {
return SlotNames.TOP_BANNER;
}
@Override
public @NotNull List<EditableField<?>> getEditableFields() {
return List.of(
new SingleValueEditableField<>(
"message",
"Alert Message",
"Important announcement!",
String.class,
"Message to display in the banner"
),
new SingleValueEditableField<>(
"type",
"Alert Type",
"info",
String.class,
"Type: info, success, warning, error"
)
);
}
@Override
public List<String> getAdminJSPaths() {
return null; // No admin-specific JS needed
}
@Override
public List<String> getAdminCSSPaths() {
return null; // No admin-specific CSS needed
}
@Override
public Map<String, Object> generateViewExtensionModel(
ExtensionConfigurationContainer itemBeingRendered,
SiteContext siteContext,
User user,
Order order,
ExtensionConfigurationBean configuration,
ExtensionRequestContext requestContext,
String extensionId) {
Map<String, Object> model = createModel(
getDefaultPageLocation(),
DEFAULT_DISPLAY_PRIORITY,
"extensions/alertBanner/main",
configuration
);
// Extract configuration
String message = findPropertyConfiguration("message", configuration, String.class)
.orElse("Important announcement!");
String type = findPropertyConfiguration("type", configuration, String.class)
.orElse("info");
model.put("message", message);
model.put("alertType", type);
// Add CSS for styling
requiresCSS(model, "/extensions/alertBanner/alert.css");
return model;
}
@Override
public Map<String, Object> generateViewExtensionModelForFormPost(
ExtensionConfigurationContainer itemBeingRendered,
SiteContext siteContext,
User user,
Order order,
ExtensionConfigurationBean configuration,
ExtensionRequestContext requestContext,
String extensionId) {
return generateViewExtensionModel(
itemBeingRendered, siteContext, user, order,
configuration, requestContext, extensionId
);
}
@Override
public String getName() {
return "alertBanner";
}
@Override
public String getDescription() {
return "Displays a customizable alert banner at the top of pages";
}
@Override
public String getAuthor() {
return "Kedos Consulting Limited";
}
@Override
public String getVersion() {
return "1.0.0";
}
@Override
public boolean isGlobal() {
return false;
}
@Override
public String getSiteId() {
return ALL_SITES;
}
}
Step 2: Create Templates¶
Bootstrap Template: templates/bootstrap/extensions/alertBanner/main.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="en">
<th:block th:fragment="extension(model)">
<div th:class="'alert alert-' + ${model.alertType}" role="alert">
<span th:text="${model.message}">Alert message</span>
</div>
</th:block>
</html>
Nova Template: templates/nova/extensions/alertBanner/main.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="en">
<th:block th:fragment="extension(model)">
<div
th:class="'alert-banner alert-' + ${model.alertType}"
role="alert"
th:style="'padding: var(--space-4); border-radius: var(--radius-md);'"
>
<span th:text="${model.message}">Alert message</span>
</div>
</th:block>
</html>
Step 3: Add CSS¶
Bootstrap CSS: themes/bootstrap/css/extensions/alertBanner/alert.css
/* (C)2025 Badger Commerce Limited, a subsidiary of Kedos Consulting Limited */
.alert {
padding: 15px;
margin-bottom: 20px;
border: 1px solid transparent;
border-radius: 4px;
}
.alert-info {
background-color: #d9edf7;
border-color: #bce8f1;
color: #31708f;
}
.alert-success {
background-color: #dff0d8;
border-color: #d6e9c6;
color: #3c763d;
}
.alert-warning {
background-color: #fcf8e3;
border-color: #faebcc;
color: #8a6d3b;
}
.alert-error {
background-color: #f2dede;
border-color: #ebccd1;
color: #a94442;
}
Nova CSS: themes/nova/css/extensions/alertBanner/alert.css
/* (C)2025 Badger Commerce Limited, a subsidiary of Kedos Consulting Limited */
.alert-banner {
padding: var(--space-4);
margin-bottom: var(--space-5);
border-radius: var(--radius-md);
font-size: var(--font-size-base);
}
.alert-info {
background-color: #dbeafe;
color: #1e40af;
}
.alert-success {
background-color: #dcfce7;
color: #166534;
}
.alert-warning {
background-color: #fef3c7;
color: #92400e;
}
.alert-error {
background-color: #fee2e2;
color: #991b1b;
}
Step 4: Add Extension Name to Resource Bundle¶
All extensions must have a display name entry in the resource bundle.
File: commerce-core/src/main/resources/messages.properties
Add the following line in alphabetical order:
Pattern:
Where {extensionName} matches the value returned by your extension's getName() method.
Why this is required: - The admin interface uses this entry to display the extension name - Without it, the extension may show as "undefined" or fail to render - Maintains internationalization support for future localization
Step 5: Build and Test¶
# Build the project
mvn clean package -DskipTests
# Start dev environment
./dev-scripts/devEnv.sh
# Access admin at https://localhost:9443/admin
# Add "Alert Banner" extension to a page
# Configure message and type
# View the page to see your extension
Configuration Management¶
Editable Fields¶
Extensions expose configuration through EditableField<?> objects.
SingleValueEditableField¶
For simple scalar values.
new SingleValueEditableField<>(
"fieldName", // Key for storage
"Display Label", // Shown in admin UI
"default value", // Default value
String.class, // Data type
"Help text" // Description (optional)
);
Supported types:
- String.class
- Integer.class
- Boolean.class
- Double.class
- Long.class
MultiValueEditableField¶
For lists of values.
new MultiValueEditableField<>(
"tags",
"Product Tags",
List.of("featured", "sale"),
String.class,
"Tags to filter products"
);
Example: Complex Configuration¶
@Override
public @NotNull List<EditableField<?>> getEditableFields() {
return List.of(
// Text field
new SingleValueEditableField<>(
"title",
"Title",
"Trending Now",
String.class,
"Heading to display above products"
),
// Number field
new SingleValueEditableField<>(
"productCount",
"Number of Products",
5,
Integer.class,
"How many products to show (1-20)"
),
// Boolean field
new SingleValueEditableField<>(
"showPrices",
"Show Prices",
true,
Boolean.class,
"Display product prices"
),
// List field
new MultiValueEditableField<>(
"categories",
"Categories",
List.of("electronics", "clothing"),
String.class,
"Categories to include"
)
);
}
Reading Configuration Values¶
Use findPropertyConfiguration() to read values in generateViewExtensionModel():
// With default fallback
String title = findPropertyConfiguration("title", configuration, String.class)
.orElse("Default Title");
Integer count = findPropertyConfiguration("productCount", configuration, Integer.class)
.orElse(5);
Boolean showPrices = findPropertyConfiguration("showPrices", configuration, Boolean.class)
.orElse(true);
// Handle missing values
Optional<String> optionalValue = findPropertyConfiguration(
"optionalField",
configuration,
String.class
);
if (optionalValue.isPresent()) {
// Use value
}
Configuration Validation¶
Validate and sanitize configuration values:
Integer count = findPropertyConfiguration("productCount", configuration, Integer.class)
.orElse(5);
// Clamp to valid range
count = Math.max(1, Math.min(20, count));
String type = findPropertyConfiguration("alertType", configuration, String.class)
.orElse("info");
// Validate enum-like values
if (!List.of("info", "success", "warning", "error").contains(type)) {
type = "info";
}
Templates and Resources¶
Template Structure¶
Templates use Thymeleaf's fragment system:
/* (C)2025 Badger Commerce Limited, a subsidiary of Kedos Consulting Limited */
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="en">
<th:block th:fragment="extension(model)">
<!-- Extension content here -->
<!-- Access model data with ${model.propertyName} -->
</th:block>
</html>
Model Variables¶
The model map contains:
| Key | Description | Type |
|---|---|---|
templateFilePath |
Path to template | String |
pageLocationName |
Page slot name | String |
formPrefix |
Unique ID for forms | String |
| Custom keys | Your extension data | Any |
Accessing Configuration in Templates¶
<div class="extension">
<h2 th:text="${model.title}">Default Title</h2>
<p th:if="${model.showDescription}" th:text="${model.description}">Description</p>
<ul>
<li th:each="item : ${model.items}" th:text="${item.name}">Item</li>
</ul>
</div>
Resource Paths¶
CSS Files¶
Add CSS to the model in Java:
This maps to:
web-mvc/src/main/resources/resources/settBuilder/themes/{theme}/css/extensions/myExtension/styles.css
JavaScript Files¶
Add JavaScript to the model in Java:
This maps to:
External Resources¶
For CDN or external resources:
// External CDN (starts with //)
requiresJS(model, "//cdn.jsdelivr.net/npm/[email protected]/dist/lib.min.js");
// Vendor library (@ prefix)
requiresJS(model, "@vendor/sweetalert2/9.17.2/dist/sweetalert2.all.min.js");
Stripe.js¶
Stripe.js (https://js.stripe.com/v3/, ~1 MB) is not loaded on every page. The theme head only
injects it when an extension rendered on the page calls requiresStripeJS(model), which the
ExtensionService lifts to the page model as stripeJsRequired. Any extension whose client code
calls Stripe(...) must declare it:
Stripe.js loads asynchronously, so initialise through the global queue that every page defines:
Current consumers: PaymentExtension, UnifiedCheckoutExtension, ApplePayExtension (quick-pay,
which can appear on product and basket pages).
A site can opt back into loading Stripe.js on every page with the stripe-js-on-every-page site
config key (payment-config, default off). Stripe recommends this so Radar sees the whole browsing
session, not just checkout. StripeJsEverywhereInterceptor then sets stripeJsRequired on every
rendered page, including pages that don't go through the extension pipeline. Extensions still have
to call requiresStripeJS, because the default is off.
Images¶
Use the shared fragment/responsive-image :: responsiveImage(baseUrl, imagePath, altText) fragment for
product/media images. It emits loading="lazy", decoding="async" and width/height by default.
For an above-the-fold (LCP) image, set responsiveImagePriority around the include to get
loading="eager" fetchpriority="high" instead:
<th:block th:with="responsiveImagePriority=${stat.first}">
<div th:insert="~{fragment/responsive-image :: responsiveImage(${siteContext.productImagePath}, ${img.url}, ${img.altText})}"></div>
</th:block>
Theme Support¶
Extensions should support all themes by creating templates for each:
templates/
├── bootstrap/extensions/myExtension/main.html
├── nova/extensions/myExtension/main.html
└── hardware/extensions/myExtension/main.html
Theme resolution:
1. Check for template in active theme (e.g., nova/extensions/myExtension/main.html)
2. Fall back to base theme (e.g., bootstrap/extensions/myExtension/main.html)
3. Error if neither exists
CSS/JS resolution follows the same pattern:
- themes/nova/css/extensions/myExtension/styles.css (preferred)
- themes/bootstrap/css/extensions/myExtension/styles.css (fallback)
Inline Configuration in Templates¶
Pass configuration from Java to JavaScript:
<script th:inline="javascript">
window.myExtensionConfig = {
enabled: /*[[${model.enabled}]]*/ true,
count: /*[[${model.count}]]*/ 10,
apiKey: /*[[${model.apiKey}]]*/ 'default-key'
};
</script>
Access in JavaScript:
Testing Extensions¶
Manual Testing¶
-
Build Project
-
Start Dev Environment
-
Access Admin Interface
- Navigate to
https://localhost:9443/admin -
Log in with admin credentials
-
Add Extension
- Go to a page, product, or collection
- Click "Add Extension"
- Select your extension
-
Configure settings
-
View Extension
- Navigate to the page where you added the extension
-
Verify rendering and functionality
-
Test Configuration Changes
- Modify extension settings
- Refresh page
- Verify changes take effect
Unit Testing¶
Create unit tests for extension logic:
package uk.co.kedos.badger.settbuilder.extensions.content;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.BeforeEach;
import static org.junit.jupiter.api.Assertions.*;
class AlertBannerExtensionTest {
private AlertBannerExtension extension;
private ExtensionUtils extensionUtils;
@BeforeEach
void setUp() {
extensionUtils = mock(ExtensionUtils.class);
extension = new AlertBannerExtension(extensionUtils);
}
@Test
void testGetName() {
assertEquals("alertBanner", extension.getName());
}
@Test
void testGetDescription() {
assertNotNull(extension.getDescription());
assertTrue(extension.getDescription().length() > 0);
}
@Test
void testEditableFields() {
var fields = extension.getEditableFields();
assertNotNull(fields);
assertEquals(2, fields.size());
var messageField = fields.get(0);
assertEquals("message", messageField.getName());
assertEquals(String.class, messageField.getType());
}
@Test
void testDefaultPageLocation() {
assertEquals(SlotNames.TOP_BANNER, extension.getDefaultPageLocation());
}
@Test
void testGenerateViewExtensionModel() {
// Arrange
var itemBeingRendered = mock(ExtensionConfigurationContainer.class);
var siteContext = mock(SiteContext.class);
var configuration = mock(ExtensionConfigurationBean.class);
var requestContext = mock(ExtensionRequestContext.class);
// Act
var model = extension.generateViewExtensionModel(
itemBeingRendered, siteContext, null, null,
configuration, requestContext, "ext-123"
);
// Assert
assertNotNull(model);
assertTrue(model.containsKey("message"));
assertTrue(model.containsKey("alertType"));
}
}
Integration Testing¶
Test extensions in the full application context:
@SpringBootTest
@AutoConfigureMockMvc
class AlertBannerExtensionIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ExtensionService extensionService;
@Test
void testExtensionRegistered() {
var extensions = extensionService.getAllExtensions();
assertTrue(extensions.stream()
.anyMatch(ext -> "alertBanner".equals(ext.getName())));
}
@Test
void testExtensionRendering() throws Exception {
// Create a page with the extension
// ...
mockMvc.perform(get("/test-page"))
.andExpect(status().isOk())
.andExpect(content().string(containsString("alert-banner")));
}
}
Debugging¶
Enable debug logging:
@Slf4j
public class AlertBannerExtension extends AdministratableBaseBadgerExtensionV2 {
@Override
public Map<String, Object> generateViewExtensionModel(...) {
log.debug("Generating view model for AlertBanner");
log.debug("Configuration: {}", configuration);
// ... extension logic
log.debug("Model created: {}", model);
return model;
}
}
View logs:
Deployment¶
Build Process¶
Extensions are compiled and bundled with the main application:
# Clean build
mvn clean package
# Skip tests for faster builds
mvn clean package -DskipTests
# Include integration tests
./dev-scripts/runTests.sh both
Code Formatting¶
Extensions must follow Google Java Format with Spotless:
Note: Don't run Spotless automatically—let developers handle it.
Deployment Checklist¶
- [ ] Extension class implements all required methods
- [ ] Extension name added to messages.properties (
admin.extensions.{extensionName}.name=Display Name) - [ ] Templates created for all themes (at minimum: bootstrap, nova)
- [ ] CSS/JS resources added for all themes
- [ ] Configuration fields properly documented
- [ ] Unit tests written and passing
- [ ] Integration tests passing
- [ ] Code formatted with Spotless
- [ ] Copyright header present in all files
- [ ] Extension tested in dev environment
- [ ] No hardcoded theme names in Java code
- [ ] Resource paths use relative paths (not absolute)
Copyright Header¶
All files must include the copyright header:
Versioning¶
Use semantic versioning for extensions:
- MAJOR: Breaking changes
- MINOR: New features (backwards compatible)
- PATCH: Bug fixes
Tutorial: Christmas Mode Extension¶
Let's walk through the complete Christmas Mode extension as a real-world example.
Overview¶
What it does: - Adds falling snowflakes - Shows snow accumulation on cards - Applies festive color accents - Displays corner emoji decorations - Provides user control via disable button
Features:
- Works with all themes
- Configurable via admin interface
- Performance-optimized animations
- Accessibility support (respects prefers-reduced-motion)
- localStorage for user preferences
Step 1: Extension Class¶
commerce-core/src/main/java/.../extensions/seasonal/ChristmasModeExtension.java
/* (C)2025 Badger Commerce Limited, a subsidiary of Kedos Consulting Limited */
package uk.co.kedos.badger.settbuilder.extensions.seasonal;
import java.util.List;
import java.util.Map;
import lombok.extern.slf4j.Slf4j;
import org.jetbrains.annotations.NotNull;
import org.springframework.stereotype.Component;
import uk.co.kedos.badger.settbuilder.catalogue.*;
import uk.co.kedos.badger.settbuilder.extensions.*;
import uk.co.kedos.badger.settbuilder.extensions.fields.*;
import uk.co.kedos.badger.settbuilder.extensions.slots.SlotNames;
import uk.co.kedos.badger.settbuilder.extensions.v2.AdministratableBaseBadgerExtensionV2;
@Component
@Slf4j
public class ChristmasModeExtension extends AdministratableBaseBadgerExtensionV2 {
private static final String EXTENSION_NAME = "christmasMode";
private static final String DEFAULT_TEMPLATE = "extensions/christmasMode/main";
public ChristmasModeExtension(ExtensionUtils extensionUtils) {
super(extensionUtils);
}
@Override
public @NotNull String getDefaultPageLocation() {
return SlotNames.TOP_BANNER;
}
@Override
public @NotNull List<EditableField<?>> getEditableFields() {
return List.of(
new SingleValueEditableField<>(
"snowflakeCount",
"Number of Snowflakes",
50,
Integer.class,
"How many snowflakes to show (10-100)"
),
new SingleValueEditableField<>(
"snowAccumulation",
"Enable Snow Accumulation",
true,
Boolean.class,
"Show snow accumulating on cards and elements"
),
new SingleValueEditableField<>(
"festiveColors",
"Enable Festive Colors",
true,
Boolean.class,
"Apply subtle festive color accents"
),
new SingleValueEditableField<>(
"showEmojis",
"Show Festive Emojis",
true,
Boolean.class,
"Add festive emoji decorations"
)
);
}
@Override
public List<String> getAdminJSPaths() {
return null;
}
@Override
public List<String> getAdminCSSPaths() {
return null;
}
@Override
public Map<String, Object> generateViewExtensionModel(
ExtensionConfigurationContainer itemBeingRendered,
SiteContext siteContext,
User user,
Order order,
ExtensionConfigurationBean configuration,
ExtensionRequestContext requestContext,
String extensionId) {
Map<String, Object> model = createModel(
getDefaultPageLocation(),
DEFAULT_DISPLAY_PRIORITY,
DEFAULT_TEMPLATE,
configuration
);
// Extract and validate configuration
int snowflakeCount = findPropertyConfiguration(
"snowflakeCount", configuration, Integer.class
).orElse(50);
boolean snowAccumulation = findPropertyConfiguration(
"snowAccumulation", configuration, Boolean.class
).orElse(true);
boolean festiveColors = findPropertyConfiguration(
"festiveColors", configuration, Boolean.class
).orElse(true);
boolean showEmojis = findPropertyConfiguration(
"showEmojis", configuration, Boolean.class
).orElse(true);
// Add to model
model.put("snowflakeCount", snowflakeCount);
model.put("snowAccumulation", snowAccumulation);
model.put("festiveColors", festiveColors);
model.put("showEmojis", showEmojis);
// Inject resources
requiresCSS(model, "/extensions/christmasMode/christmas.css");
requiresJS(model, "/extensions/christmasMode/christmas.js");
log.debug("Christmas mode extension loaded with {} snowflakes", snowflakeCount);
return model;
}
@Override
public Map<String, Object> generateViewExtensionModelForFormPost(
ExtensionConfigurationContainer itemBeingRendered,
SiteContext siteContext,
User user,
Order order,
ExtensionConfigurationBean configuration,
ExtensionRequestContext requestContext,
String extensionId) {
return generateViewExtensionModel(
itemBeingRendered, siteContext, user, order,
configuration, requestContext, extensionId
);
}
@Override
public String getName() {
return EXTENSION_NAME;
}
@Override
public String getDescription() {
return "Adds festive Christmas effects including snowfall, snow accumulation, "
+ "and subtle color accents. Works with all themes.";
}
@Override
public String getAuthor() {
return "Kedos Consulting Limited";
}
@Override
public String getVersion() {
return "1.0.0";
}
@Override
public boolean isGlobal() {
return false;
}
@Override
public String getSiteId() {
return ALL_SITES;
}
}
Key points:
- Uses @Component for Spring auto-discovery
- Uses @Slf4j for logging
- Extends AdministratableBaseBadgerExtensionV2 (V2 pattern)
- Exposes 4 configurable fields
- Injects CSS and JS resources
- Renders in TOP_BANNER slot
Step 2: Add to Resource Bundle¶
Add the extension name to commerce-core/src/main/resources/messages.properties:
Insert it in alphabetical order with other extension entries.
Step 3: Templates¶
Bootstrap Template: templates/bootstrap/extensions/christmasMode/main.html
/* (C)2025 Badger Commerce Limited, a subsidiary of Kedos Consulting Limited */
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="en">
<th:block th:fragment="extension(model)">
<!-- Pass configuration to JavaScript -->
<script th:inline="javascript">
window.christmasModeConfig = {
snowflakeCount: /*[[${model.snowflakeCount}]]*/ 50,
snowAccumulation: /*[[${model.snowAccumulation}]]*/ true,
festiveColors: /*[[${model.festiveColors}]]*/ true,
showEmojis: /*[[${model.showEmojis}]]*/ true
};
</script>
<!-- Debug metadata -->
<meta
name="christmas-mode"
th:content="'v1.0.0 - ' + ${model.snowflakeCount} + ' snowflakes'"
th:if="${model.snowflakeCount > 0}"
/>
</th:block>
</html>
Nova Template: (identical)
templates/nova/extensions/christmasMode/main.html
/* (C)2025 Badger Commerce Limited, a subsidiary of Kedos Consulting Limited */
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="en">
<th:block th:fragment="extension(model)">
<script th:inline="javascript">
window.christmasModeConfig = {
snowflakeCount: /*[[${model.snowflakeCount}]]*/ 50,
snowAccumulation: /*[[${model.snowAccumulation}]]*/ true,
festiveColors: /*[[${model.festiveColors}]]*/ true,
showEmojis: /*[[${model.showEmojis}]]*/ true
};
</script>
<meta
name="christmas-mode"
th:content="'v1.0.0 - ' + ${model.snowflakeCount} + ' snowflakes'"
th:if="${model.snowflakeCount > 0}"
/>
</th:block>
</html>
Why minimal templates? - All visual effects handled by CSS/JS - Configuration passed via inline script - Theme-agnostic approach - Same template works for all themes
Step 4: CSS (Theme-Agnostic Styling)¶
Both Bootstrap and Nova use identical CSS (theme-agnostic selectors):
themes/bootstrap/css/extensions/christmasMode/christmas.css
themes/nova/css/extensions/christmasMode/christmas.css
/* (C)2025 Badger Commerce Limited, a subsidiary of Kedos Consulting Limited */
/* ============================================
Christmas Mode Extension - Festive Styles
============================================ */
/* Snowflake Container */
#christmas-snowflakes {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 9998;
overflow: hidden;
}
/* Individual Snowflake */
.christmas-snowflake {
position: absolute;
top: -10px;
color: #fff;
font-size: 1em;
user-select: none;
pointer-events: none;
animation: snowfall linear infinite;
text-shadow: 0 0 5px rgba(255, 255, 255, 0.8);
}
/* Snowfall Animation */
@keyframes snowfall {
0% {
transform: translateY(0) rotate(0deg);
opacity: 1;
}
100% {
transform: translateY(100vh) rotate(360deg);
opacity: 0.8;
}
}
/* Snow Accumulation on Cards (theme-agnostic selectors) */
[data-christmas-snow="true"] .card::before,
[data-christmas-snow="true"] .nova-product-card::before,
[data-christmas-snow="true"] .panel::before,
[data-christmas-snow="true"] .well::before,
[data-christmas-snow="true"] .product-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 8px;
background: linear-gradient(
to bottom,
rgba(255, 255, 255, 0.9) 0%,
rgba(240, 248, 255, 0.7) 50%,
transparent 100%
);
border-radius: 0 0 50% 50% / 0 0 100% 100%;
pointer-events: none;
z-index: 1;
opacity: 0;
animation: snowAccumulate 2s ease-in forwards;
animation-delay: 3s;
}
@keyframes snowAccumulate {
from {
opacity: 0;
height: 0;
}
to {
opacity: 1;
height: 8px;
}
}
/* Festive Color Accents */
[data-christmas-colors="true"] .btn-primary:hover,
[data-christmas-colors="true"] .nova-btn:hover,
[data-christmas-colors="true"] button[type="submit"]:hover {
box-shadow: 0 0 20px rgba(220, 38, 38, 0.3),
0 0 40px rgba(34, 197, 94, 0.2);
}
[data-christmas-colors="true"] .navbar::after,
[data-christmas-colors="true"] .header::after,
[data-christmas-colors="true"] nav::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(
90deg,
#dc2626 0%,
#22c55e 25%,
#dc2626 50%,
#22c55e 75%,
#dc2626 100%
);
opacity: 0.3;
animation: festiveShimmer 3s ease-in-out infinite;
}
@keyframes festiveShimmer {
0%, 100% { opacity: 0.3; }
50% { opacity: 0.6; }
}
/* Festive Emoji Decorations */
.christmas-emoji {
position: fixed;
font-size: 2rem;
pointer-events: none;
z-index: 9997;
opacity: 0.4;
user-select: none;
}
.christmas-emoji.top-left {
top: 20px;
left: 20px;
animation: gentle-float 6s ease-in-out infinite;
}
.christmas-emoji.top-right {
top: 20px;
right: 20px;
animation: gentle-float 7s ease-in-out infinite 0.5s;
}
.christmas-emoji.bottom-left {
bottom: 20px;
left: 20px;
animation: gentle-float 8s ease-in-out infinite 1s;
}
.christmas-emoji.bottom-right {
bottom: 20px;
right: 20px;
animation: gentle-float 5s ease-in-out infinite 1.5s;
}
@keyframes gentle-float {
0%, 100% {
transform: translateY(0) rotate(0deg);
}
50% {
transform: translateY(-10px) rotate(5deg);
}
}
/* Disable Button */
#christmas-disable-btn {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 9999;
padding: 10px 16px;
background: rgba(220, 38, 38, 0.9);
color: white;
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
transition: all 0.3s ease;
backdrop-filter: blur(10px);
}
#christmas-disable-btn:hover {
background: rgba(185, 28, 28, 1);
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4);
}
#christmas-disable-btn::before {
content: '❄️ ';
margin-right: 4px;
}
/* Hidden State */
body[data-christmas-disabled="true"] #christmas-snowflakes,
body[data-christmas-disabled="true"] .christmas-emoji {
display: none !important;
}
/* Accessibility: Respect prefers-reduced-motion */
@media (prefers-reduced-motion: reduce) {
.christmas-snowflake,
.christmas-emoji {
animation: none;
opacity: 0.3;
}
}
/* Mobile Responsive */
@media (max-width: 768px) {
.christmas-snowflake {
font-size: 0.8em;
}
.christmas-emoji {
font-size: 1.5rem;
}
#christmas-disable-btn {
bottom: 10px;
right: 10px;
padding: 8px 12px;
font-size: 12px;
}
}
Key CSS Features:
- Theme-agnostic selectors: .card, .nova-product-card, .panel, etc.
- Data attributes: Control features via data-christmas-snow, data-christmas-colors
- CSS animations: Hardware-accelerated for performance
- Accessibility: Respects prefers-reduced-motion
- Responsive: Adjusts for mobile screens
- Z-index management: Layers effects properly
Step 5: JavaScript¶
Both Bootstrap and Nova use identical JavaScript:
themes/bootstrap/js/extensions/christmasMode/christmas.js
themes/nova/js/extensions/christmasMode/christmas.js
/* (C)2025 Badger Commerce Limited, a subsidiary of Kedos Consulting Limited */
/**
* Christmas Mode Extension - JavaScript Controller
*/
(function() {
'use strict';
const STORAGE_KEY = 'christmasModeDisabled';
const SNOWFLAKE_CHARS = ['❄', '❅', '❆'];
const FESTIVE_EMOJIS = ['🎄', '🎅', '⛄', '🎁', '🔔', '⭐', '🕯️', '🦌'];
let config = {
snowflakeCount: 50,
snowAccumulation: true,
festiveColors: true,
showEmojis: true
};
/**
* Initialize Christmas Mode
*/
function init() {
// Check if user has disabled Christmas mode
if (isDisabled()) {
document.body.setAttribute('data-christmas-disabled', 'true');
createDisableButton(true);
return;
}
// Read configuration
readConfig();
// Apply features
if (config.snowflakeCount > 0) {
createSnowfall();
}
if (config.snowAccumulation) {
enableSnowAccumulation();
}
if (config.festiveColors) {
enableFestiveColors();
}
if (config.showEmojis) {
createFestiveEmojis();
}
// Create disable button
createDisableButton(false);
console.log('🎄 Christmas mode activated!');
}
/**
* Read configuration from window
*/
function readConfig() {
if (window.christmasModeConfig) {
config = { ...config, ...window.christmasModeConfig };
}
// Validate snowflake count
config.snowflakeCount = Math.max(10, Math.min(100, config.snowflakeCount));
}
/**
* Check if disabled via localStorage
*/
function isDisabled() {
try {
return localStorage.getItem(STORAGE_KEY) === 'true';
} catch (e) {
return false;
}
}
/**
* Set disabled state
*/
function setDisabled(disabled) {
try {
if (disabled) {
localStorage.setItem(STORAGE_KEY, 'true');
} else {
localStorage.removeItem(STORAGE_KEY);
}
} catch (e) {
console.warn('Could not save Christmas mode preference:', e);
}
}
/**
* Create snowfall container and snowflakes
*/
function createSnowfall() {
const container = document.createElement('div');
container.id = 'christmas-snowflakes';
document.body.appendChild(container);
for (let i = 0; i < config.snowflakeCount; i++) {
createSnowflake(container);
}
}
/**
* Create a single snowflake
*/
function createSnowflake(container) {
const snowflake = document.createElement('div');
snowflake.className = 'christmas-snowflake';
snowflake.textContent = SNOWFLAKE_CHARS[
Math.floor(Math.random() * SNOWFLAKE_CHARS.length)
];
// Random properties
snowflake.style.left = Math.random() * 100 + '%';
snowflake.style.fontSize = (0.5 + Math.random() * 1) + 'em';
snowflake.style.opacity = 0.6 + Math.random() * 0.4;
const duration = 10 + Math.random() * 20;
const delay = Math.random() * 10;
snowflake.style.animationDuration = duration + 's';
snowflake.style.animationDelay = delay + 's';
container.appendChild(snowflake);
// Infinite loop: remove and recreate
setTimeout(() => {
snowflake.remove();
if (!isDisabled()) {
createSnowflake(container);
}
}, (duration + delay) * 1000);
}
/**
* Enable snow accumulation
*/
function enableSnowAccumulation() {
document.body.setAttribute('data-christmas-snow', 'true');
}
/**
* Enable festive colors
*/
function enableFestiveColors() {
document.body.setAttribute('data-christmas-colors', 'true');
}
/**
* Create emoji decorations
*/
function createFestiveEmojis() {
document.body.setAttribute('data-christmas-emojis', 'true');
const positions = ['top-left', 'top-right', 'bottom-left', 'bottom-right'];
positions.forEach((pos, index) => {
const emoji = document.createElement('div');
emoji.className = `christmas-emoji ${pos}`;
emoji.textContent = FESTIVE_EMOJIS[index % FESTIVE_EMOJIS.length];
document.body.appendChild(emoji);
});
}
/**
* Create disable button
*/
function createDisableButton(isCurrentlyDisabled) {
const button = document.createElement('button');
button.id = 'christmas-disable-btn';
button.textContent = isCurrentlyDisabled
? 'Enable Christmas Mode'
: 'Disable Christmas Mode';
button.setAttribute('type', 'button');
button.setAttribute(
'aria-label',
isCurrentlyDisabled ? 'Enable festive effects' : 'Disable festive effects'
);
button.addEventListener('click', toggleChristmasMode);
document.body.appendChild(button);
}
/**
* Toggle Christmas mode
*/
function toggleChristmasMode() {
const currentlyDisabled = isDisabled();
setDisabled(!currentlyDisabled);
window.location.reload();
}
/**
* Cleanup (for SPA navigation)
*/
function cleanup() {
const container = document.getElementById('christmas-snowflakes');
if (container) container.remove();
document.querySelectorAll('.christmas-emoji').forEach(el => el.remove());
const button = document.getElementById('christmas-disable-btn');
if (button) button.remove();
document.body.removeAttribute('data-christmas-snow');
document.body.removeAttribute('data-christmas-colors');
document.body.removeAttribute('data-christmas-emojis');
document.body.removeAttribute('data-christmas-disabled');
}
// Initialize
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
// Expose cleanup for SPA frameworks
window.christmasModeCleanup = cleanup;
})();
JavaScript Features:
- IIFE pattern: Avoids global namespace pollution
- Configuration from template: Reads window.christmasModeConfig
- localStorage persistence: Remembers user preference
- Dynamic DOM manipulation: Creates snowflakes and emojis
- Performance optimization: Regenerates snowflakes instead of keeping all in DOM
- Accessibility: ARIA labels on button
- SPA support: Cleanup function exposed
Step 6: Build and Test¶
# Build
mvn clean package -DskipTests
# Start dev environment
./dev-scripts/devEnv.sh
# Test in browser
# 1. Navigate to https://localhost:9443/admin
# 2. Add Christmas Mode extension to a page
# 3. Configure settings
# 4. View the page
# 5. Test disable button
Step 7: Configuration Testing¶
Test different configurations:
Subtle Mode: - Snowflakes: 20 - Snow Accumulation: Off - Festive Colors: On - Emojis: Off
Full Festive: - Snowflakes: 100 - Snow Accumulation: On - Festive Colors: On - Emojis: On
Performance Mode: - Snowflakes: 10 - Snow Accumulation: Off - Festive Colors: Off - Emojis: Off
Key Takeaways¶
- Theme-Agnostic Design: Use generic CSS selectors and data attributes
- Configuration Flexibility: Expose settings through
EditableField - Performance: Optimize animations and DOM manipulation
- Accessibility: Support
prefers-reduced-motionand ARIA - User Control: Always provide a disable option
- Minimal Templates: Let CSS/JS do the heavy lifting
- Logging: Use SLF4J for debugging
Best Practices¶
Code Organization¶
✅ Do:
- Use V2 pattern (AdministratableBaseBadgerExtensionV2)
- Group related extensions in packages (e.g., seasonal/, social/, checkout/)
- Keep extension logic in Java, presentation in templates
- Use constants for strings and magic numbers
- Add comprehensive logging with SLF4J
❌ Don't: - Use V1 pattern for new extensions - Hardcode theme names in Java - Mix business logic in templates - Duplicate code across themes
Template Design¶
✅ Do: - Create templates for all themes (at minimum: bootstrap, nova) - Use data attributes for CSS targeting - Pass configuration via inline scripts - Use semantic HTML - Include accessibility attributes
❌ Don't: - Hardcode values in templates - Use theme-specific class names exclusively - Embed large JavaScript blocks in templates - Forget mobile responsiveness
CSS Best Practices¶
✅ Do:
- Use theme-agnostic selectors
- Leverage CSS custom properties when available
- Use CSS animations for performance
- Support prefers-reduced-motion
- Namespace classes (e.g., .christmas-, .alert-)
❌ Don't:
- Override core theme styles
- Use !important unless absolutely necessary
- Hardcode colors (prefer CSS variables)
- Forget mobile breakpoints
JavaScript Best Practices¶
✅ Do:
- Use IIFE pattern to avoid global pollution
- Read configuration from window object
- Handle errors gracefully (try/catch)
- Use requestAnimationFrame for animations
- Expose cleanup functions for SPA support
❌ Don't: - Pollute global namespace - Assume jQuery is available - Forget error handling - Create memory leaks
Configuration Design¶
✅ Do: - Provide sensible defaults - Validate and clamp values - Use descriptive field names - Include help text for complex fields - Group related fields
❌ Don't: - Expose every internal variable - Use technical jargon in labels - Forget to validate user input - Make configuration too complex
Performance¶
✅ Do:
- Lazy load resources when possible
- Use CSS animations (GPU-accelerated)
- Minimize DOM manipulation
- Debounce expensive operations
- Use will-change for animated elements
❌ Don't: - Create thousands of DOM elements - Use JavaScript for animations that CSS can handle - Animate expensive properties (width, height) - Forget to cleanup on disable
Accessibility¶
✅ Do:
- Support prefers-reduced-motion
- Add ARIA labels to interactive elements
- Ensure keyboard navigation works
- Test with screen readers
- Maintain color contrast
❌ Don't:
- Rely solely on color for information
- Trap keyboard focus
- Forget alt text on images
- Use animations that could trigger seizures
Testing¶
✅ Do: - Write unit tests for extension logic - Test all configuration combinations - Test on mobile devices - Test with different themes - Test disable/enable functionality
❌ Don't: - Skip integration tests - Test only on desktop - Assume configuration will be valid - Forget edge cases
Troubleshooting¶
Extension Not Showing Up¶
Problem: Extension doesn't appear in admin interface
Solutions:
1. Check if extension has @Component annotation
2. Verify extension class is in correct package
3. Rebuild project: mvn clean package -DskipTests
4. Restart dev environment: ./dev-scripts/devEnv.sh
5. Check logs for Spring bean initialization errors
Template Not Rendering¶
Problem: Template doesn't render or shows errors
Solutions:
1. Verify template path matches generateViewExtensionModel() return value
2. Check template exists in templates/{theme}/extensions/{name}/
3. Ensure fragment name is extension(model)
4. Verify Thymeleaf syntax is correct
5. Check for null values in model
CSS Not Loading¶
Problem: Styles don't apply
Solutions:
1. Verify CSS path in requiresCSS() matches file location
2. Check file exists in themes/{theme}/css/extensions/{name}/
3. Clear browser cache
4. Check browser console for 404 errors
5. Verify CSS syntax is valid
JavaScript Not Executing¶
Problem: JavaScript doesn't run
Solutions:
1. Verify JS path in requiresJS() matches file location
2. Check file exists in themes/{theme}/js/extensions/{name}/
3. Check browser console for errors
4. Verify JavaScript syntax is valid
5. Ensure configuration object is passed correctly
Configuration Not Saving¶
Problem: Configuration changes don't persist
Solutions:
1. Verify EditableField names match keys used in findPropertyConfiguration()
2. Check extension is properly associated with item/page
3. Verify database connection is working
4. Check logs for save errors
5. Ensure field types match expected types
Performance Issues¶
Problem: Extension causes lag or slow page loads
Solutions: 1. Reduce number of DOM elements created 2. Use CSS animations instead of JavaScript 3. Debounce expensive operations 4. Optimize animation frame rate 5. Reduce resource file sizes
Theme Compatibility Issues¶
Problem: Extension works in one theme but not another
Solutions: 1. Create theme-specific templates for all themes 2. Use theme-agnostic CSS selectors 3. Avoid hardcoding theme-specific class names 4. Test with all themes enabled 5. Check template resolution logs
Additional Resources¶
Example Extensions in Codebase¶
- Trending Products:
extensions/social/TrendingProducts.java - Static Carousel:
extensions/html/StaticCarouselExtension.java - Apple Pay:
extensions/checkout/ApplePayExtension.java - Christmas Mode:
extensions/seasonal/ChristmasModeExtension.java
Key Files to Reference¶
- Base Classes:
extensions/BadgerExtension.java,extensions/v2/AdministratableBaseBadgerExtensionV2.java - Extension Service:
extensions/ExtensionService.java - Slot Names:
extensions/slots/SlotNames.java - Field Types:
extensions/fields/*.java
Documentation¶
- Feature Overview:
/docs/features/extension-system.md - Project Setup:
CLAUDE.mdin repository root - Nova Theme Guide:
CLAUDE.mdNova Theme section
Getting Help¶
- GitHub Issues: Report bugs and request features
- JavaDoc: Available in codebase for API reference
- Code Examples: Browse existing extensions in
commerce-core/src/main/java/.../extensions/
Summary¶
Building extensions in Badger Commerce follows a clear pattern:
- Extend
AdministratableBaseBadgerExtensionV2 - Define configuration with
EditableField<?> - Create templates for each theme
- Add CSS/JS resources
- Build and test in dev environment
- Deploy with confidence
Extensions enable rapid feature development without core platform changes, provide configuration flexibility for business users, and maintain clean separation of concerns.
Happy extending! 🎄