frontend price calculator

This commit is contained in:
Tobias Brunner 2025-06-02 16:22:54 +02:00
parent 4f9a39fd36
commit 475a4643fd
No known key found for this signature in database
4 changed files with 617 additions and 64 deletions

View file

@ -0,0 +1,36 @@
.form-range::-webkit-slider-thumb {
background: #6f42c1;
}
.form-range::-moz-range-thumb {
background: #6f42c1;
border: none;
}
.btn-check:checked+.btn-outline-primary {
background-color: #6f42c1;
border-color: #6f42c1;
color: white;
}
.card {
transition: box-shadow 0.2s ease-in-out;
}
.card:hover {
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
}
#selectedPlanDetails {
animation: fadeIn 0.3s ease-in;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}

View file

@ -0,0 +1,411 @@
/**
* Price Calculator for Service Offerings
* Handles interactive pricing calculation with sliders and plan selection
*/
class PriceCalculator {
constructor() {
this.pricingData = null;
this.storagePrice = 0.15; // CHF per GB per month
this.currentOffering = null;
this.init();
}
// Initialize calculator elements and event listeners
init() {
// Get offering info from URL
const pathParts = window.location.pathname.split('/');
if (pathParts.length >= 4 && pathParts[1] === 'offering') {
this.currentOffering = {
provider_slug: pathParts[2],
service_slug: pathParts[3]
};
}
// Initialize DOM elements
this.initElements();
// Load pricing data and setup calculator
if (this.currentOffering) {
this.loadPricingData();
}
}
// Initialize DOM element references
initElements() {
// Calculator controls
this.cpuRange = document.getElementById('cpuRange');
this.memoryRange = document.getElementById('memoryRange');
this.storageRange = document.getElementById('storageRange');
this.cpuValue = document.getElementById('cpuValue');
this.memoryValue = document.getElementById('memoryValue');
this.storageValue = document.getElementById('storageValue');
this.serviceLevelInputs = document.querySelectorAll('input[name="serviceLevel"]');
this.planSelect = document.getElementById('planSelect');
// Result display elements
this.planMatchStatus = document.getElementById('planMatchStatus');
this.selectedPlanDetails = document.getElementById('selectedPlanDetails');
this.noMatchFound = document.getElementById('noMatchFound');
// Plan detail elements
this.planGroup = document.getElementById('planGroup');
this.planName = document.getElementById('planName');
this.planDescription = document.getElementById('planDescription');
this.planCpus = document.getElementById('planCpus');
this.planMemory = document.getElementById('planMemory');
this.computePrice = document.getElementById('computePrice');
this.servicePrice = document.getElementById('servicePrice');
this.storagePriceEl = document.getElementById('storagePrice');
this.storageAmount = document.getElementById('storageAmount');
this.totalPrice = document.getElementById('totalPrice');
}
// Load pricing data from API endpoint
async loadPricingData() {
try {
const response = await fetch(`/offering/${this.currentOffering.provider_slug}/${this.currentOffering.service_slug}/?pricing=json`);
if (!response.ok) {
throw new Error('Failed to load pricing data');
}
this.pricingData = await response.json();
this.setupEventListeners();
this.populatePlanDropdown();
this.updatePricing();
} catch (error) {
console.error('Error loading pricing data:', error);
this.showError('Failed to load pricing information');
}
}
// Setup event listeners for calculator controls
setupEventListeners() {
if (!this.cpuRange || !this.memoryRange || !this.storageRange) return;
// Setup service levels based on available data
this.setupServiceLevels();
// Slider event listeners
this.cpuRange.addEventListener('input', () => {
this.cpuValue.textContent = this.cpuRange.value;
this.updatePricing();
});
this.memoryRange.addEventListener('input', () => {
this.memoryValue.textContent = this.memoryRange.value;
this.updatePricing();
});
this.storageRange.addEventListener('input', () => {
this.storageValue.textContent = this.storageRange.value;
this.updatePricing();
});
// Service level change listeners
this.serviceLevelInputs.forEach(input => {
input.addEventListener('change', () => {
this.populatePlanDropdown();
this.updatePricing();
});
});
// Plan selection listener
if (this.planSelect) {
this.planSelect.addEventListener('change', () => {
if (this.planSelect.value) {
const selectedPlan = JSON.parse(this.planSelect.value);
// Update sliders to match selected plan
this.cpuRange.value = selectedPlan.vcpus;
this.memoryRange.value = selectedPlan.ram;
this.cpuValue.textContent = selectedPlan.vcpus;
this.memoryValue.textContent = selectedPlan.ram;
this.updatePricingWithPlan(selectedPlan);
} else {
this.updatePricing();
}
});
}
}
// Setup service levels dynamically from pricing data
setupServiceLevels() {
if (!this.pricingData) return;
const serviceLevelGroup = document.getElementById('serviceLevelGroup');
if (!serviceLevelGroup) return;
// Get all available service levels from the pricing data
const availableServiceLevels = new Set();
Object.keys(this.pricingData).forEach(groupName => {
const group = this.pricingData[groupName];
Object.keys(group).forEach(serviceLevel => {
availableServiceLevels.add(serviceLevel);
});
});
// Clear existing service level buttons
serviceLevelGroup.innerHTML = '';
// Create buttons for each available service level
let isFirst = true;
availableServiceLevels.forEach(serviceLevel => {
const inputId = `serviceLevel${serviceLevel.replace(/\s+/g, '')}`;
// Create radio input
const input = document.createElement('input');
input.type = 'radio';
input.className = 'btn-check';
input.name = 'serviceLevel';
input.id = inputId;
input.value = serviceLevel;
if (isFirst) {
input.checked = true;
isFirst = false;
}
// Create label
const label = document.createElement('label');
label.className = 'btn btn-outline-primary';
label.setAttribute('for', inputId);
label.textContent = serviceLevel;
// Add event listener
input.addEventListener('change', () => {
this.populatePlanDropdown();
this.updatePricing();
});
serviceLevelGroup.appendChild(input);
serviceLevelGroup.appendChild(label);
});
// Update the serviceLevelInputs reference
this.serviceLevelInputs = document.querySelectorAll('input[name="serviceLevel"]');
// Calculate and set slider maximums based on available plans
this.updateSliderMaximums();
}
// Calculate maximum values for sliders based on available plans
updateSliderMaximums() {
if (!this.pricingData || !this.cpuRange || !this.memoryRange) return;
let maxCpus = 0;
let maxMemory = 0;
// Find maximum CPU and memory across all plans
Object.keys(this.pricingData).forEach(groupName => {
const group = this.pricingData[groupName];
Object.keys(group).forEach(serviceLevel => {
group[serviceLevel].forEach(plan => {
const planCpus = parseFloat(plan.vcpus);
const planMemory = parseFloat(plan.ram);
if (planCpus > maxCpus) maxCpus = planCpus;
if (planMemory > maxMemory) maxMemory = planMemory;
});
});
});
// Set slider maximums with some padding
if (maxCpus > 0) {
this.cpuRange.max = Math.ceil(maxCpus);
// Update the max display under the slider
const cpuMaxDisplay = this.cpuRange.parentElement.querySelector('.d-flex.justify-content-between .text-muted span:last-child');
if (cpuMaxDisplay) cpuMaxDisplay.textContent = Math.ceil(maxCpus);
}
if (maxMemory > 0) {
this.memoryRange.max = Math.ceil(maxMemory);
// Update the max display under the slider
const memoryMaxDisplay = this.memoryRange.parentElement.querySelector('.d-flex.justify-content-between .text-muted span:last-child');
if (memoryMaxDisplay) memoryMaxDisplay.textContent = Math.ceil(maxMemory) + ' GB';
}
}
// Populate plan dropdown based on selected service level
populatePlanDropdown() {
if (!this.planSelect || !this.pricingData) return;
const serviceLevel = document.querySelector('input[name="serviceLevel"]:checked')?.value;
if (!serviceLevel) return;
// Clear existing options
this.planSelect.innerHTML = '<option value="">Auto-select best matching plan</option>';
// Collect all plans for selected service level
const availablePlans = [];
Object.keys(this.pricingData).forEach(groupName => {
const group = this.pricingData[groupName];
if (group[serviceLevel]) {
group[serviceLevel].forEach(plan => {
availablePlans.push({
...plan,
groupName: groupName
});
});
}
});
// Sort plans by vCPU, then by RAM
availablePlans.sort((a, b) => {
if (parseInt(a.vcpus) !== parseInt(b.vcpus)) {
return parseInt(a.vcpus) - parseInt(b.vcpus);
}
return parseInt(a.ram) - parseInt(b.ram);
});
// Add plans to dropdown
availablePlans.forEach(plan => {
const option = document.createElement('option');
option.value = JSON.stringify(plan);
option.textContent = `${plan.compute_plan} - ${plan.vcpus} vCPUs, ${plan.ram} GB RAM (CHF ${parseFloat(plan.final_price).toFixed(2)}/month)`;
this.planSelect.appendChild(option);
});
}
// Find best matching plan based on requirements
findBestMatchingPlan(cpus, memory, serviceLevel) {
if (!this.pricingData) return null;
let bestMatch = null;
let bestScore = Infinity;
// Iterate through all groups and service levels
Object.keys(this.pricingData).forEach(groupName => {
const group = this.pricingData[groupName];
if (group[serviceLevel]) {
group[serviceLevel].forEach(plan => {
const planCpus = parseInt(plan.vcpus);
const planMemory = parseInt(plan.ram);
// Check if plan meets minimum requirements
if (planCpus >= cpus && planMemory >= memory) {
// Calculate efficiency score (lower is better)
const cpuOverhead = planCpus - cpus;
const memoryOverhead = planMemory - memory;
const score = cpuOverhead + memoryOverhead + plan.final_price * 0.1;
if (score < bestScore) {
bestScore = score;
bestMatch = {
...plan,
groupName: groupName
};
}
}
});
}
});
return bestMatch;
}
// Update pricing with specific plan
updatePricingWithPlan(selectedPlan) {
const storage = parseInt(this.storageRange?.value || 20);
this.showPlanDetails(selectedPlan, storage);
this.updateStatusMessage('Plan selected directly!', 'success');
}
// Main pricing update function
updatePricing() {
if (!this.pricingData || !this.cpuRange || !this.memoryRange || !this.storageRange) return;
// Reset plan selection if in auto-select mode
if (!this.planSelect?.value) {
const cpus = parseInt(this.cpuRange.value);
const memory = parseInt(this.memoryRange.value);
const storage = parseInt(this.storageRange.value);
const serviceLevel = document.querySelector('input[name="serviceLevel"]:checked')?.value;
if (!serviceLevel) return;
// Find best matching plan
const matchedPlan = this.findBestMatchingPlan(cpus, memory, serviceLevel);
if (matchedPlan) {
this.showPlanDetails(matchedPlan, storage);
this.updateStatusMessage('Perfect match found!', 'success');
} else {
this.showNoMatch();
}
} else {
// Plan is directly selected, update storage pricing
const selectedPlan = JSON.parse(this.planSelect.value);
this.updatePricingWithPlan(selectedPlan);
}
}
// Show plan details in the UI
showPlanDetails(plan, storage) {
if (!this.selectedPlanDetails) return;
// Show plan details section
this.planMatchStatus.style.display = 'block';
this.selectedPlanDetails.style.display = 'block';
if (this.noMatchFound) this.noMatchFound.style.display = 'none';
// Update plan information
if (this.planGroup) this.planGroup.textContent = plan.groupName;
if (this.planName) this.planName.textContent = plan.compute_plan;
if (this.planDescription) this.planDescription.textContent = plan.compute_plan_group_description || '';
if (this.planCpus) this.planCpus.textContent = plan.vcpus;
if (this.planMemory) this.planMemory.textContent = plan.ram + ' GB';
// Calculate pricing
const computePriceValue = parseFloat(plan.compute_plan_price);
const servicePriceValue = parseFloat(plan.sla_price);
const storagePriceValue = storage * this.storagePrice;
const totalPriceValue = computePriceValue + servicePriceValue + storagePriceValue;
// Update pricing display
if (this.computePrice) this.computePrice.textContent = computePriceValue.toFixed(2);
if (this.servicePrice) this.servicePrice.textContent = servicePriceValue.toFixed(2);
if (this.storagePriceEl) this.storagePriceEl.textContent = storagePriceValue.toFixed(2);
if (this.storageAmount) this.storageAmount.textContent = storage;
if (this.totalPrice) this.totalPrice.textContent = totalPriceValue.toFixed(2);
}
// Show no matching plan found
showNoMatch() {
if (this.planMatchStatus) this.planMatchStatus.style.display = 'none';
if (this.selectedPlanDetails) this.selectedPlanDetails.style.display = 'none';
if (this.noMatchFound) this.noMatchFound.style.display = 'block';
}
// Update status message
updateStatusMessage(message, type) {
if (!this.planMatchStatus) return;
const iconClass = type === 'success' ? 'bi-check-circle' : 'bi-info-circle';
const textClass = type === 'success' ? 'text-success' : '';
const alertClass = type === 'success' ? 'alert-success' : 'alert-info';
this.planMatchStatus.innerHTML = `<i class="bi ${iconClass} me-2 ${textClass}"></i><span class="${textClass}">${message}</span>`;
this.planMatchStatus.className = `alert ${alertClass} mb-3`;
this.planMatchStatus.style.display = 'block';
}
// Show error message
showError(message) {
if (this.planMatchStatus) {
this.planMatchStatus.innerHTML = `<i class="bi bi-exclamation-triangle me-2 text-danger"></i><span class="text-danger">${message}</span>`;
this.planMatchStatus.className = 'alert alert-danger mb-3';
this.planMatchStatus.style.display = 'block';
}
}
}
// Initialize calculator when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
// Only initialize if we're on an offering detail page with pricing calculator
if (document.getElementById('cpuRange')) {
new PriceCalculator();
}
});

View file

@ -4,6 +4,11 @@
{% block title %}Managed {{ offering.service.name }} on {{ offering.cloud_provider.name }}{% endblock %} {% block title %}Managed {{ offering.service.name }} on {{ offering.cloud_provider.name }}{% endblock %}
{% block extra_js %}
<script defer src="{% static "js/price-calculator.js" %}"></script>
<link rel="stylesheet" type="text/css" href='{% static "css/price-calculator.css" %}'>
{% endblock %}
{% block content %} {% block content %}
<section class="section bg-primary-subtle"> <section class="section bg-primary-subtle">
<div class="container mx-auto px-20 px-lg-0 pt-40 pb-60"> <div class="container mx-auto px-20 px-lg-0 pt-40 pb-60">
@ -152,76 +157,153 @@
</div> </div>
{% endif %} {% endif %}
<!-- Plans or Service Plans --> <!-- Price Calculator -->
<div class="pt-24" id="plans" style="scroll-margin-top: 30px;"> <div class="pt-24" id="plans" style="scroll-margin-top: 30px;">
{% if offering.msp == "VS" and pricing_data_by_group_and_service_level %} {% if offering.msp == "VS" and pricing_data_by_group_and_service_level %}
<!-- Service Plans with Pricing Data --> <!-- Interactive Price Calculator -->
<h3 class="fs-24 fw-semibold lh-1 mb-12">Service Plans</h3> <h3 class="fs-24 fw-semibold lh-1 mb-12">Configure Your Plan</h3>
<div class="accordion" id="servicePlansAccordion"> <div class="bg-light rounded-4 p-4 mb-4">
{% for group_name, service_levels in pricing_data_by_group_and_service_level.items %} <div class="row">
<div class="accordion-item"> <!-- Calculator Controls -->
<h2 class="accordion-header" id="heading{{ forloop.counter }}"> <div class="col-12 col-lg-6">
<button class="accordion-button{% if not forloop.first %} collapsed{% endif %}" type="button" data-bs-toggle="collapse" data-bs-target="#collapse{{ forloop.counter }}" aria-expanded="{% if forloop.first %}true{% else %}false{% endif %}" aria-controls="collapse{{ forloop.counter }}"> <div class="card h-100">
<strong>{{ group_name }}</strong> <div class="card-body">
</button> <h5 class="card-title mb-4">Customize Your Configuration</h5>
</h2>
<div id="collapse{{ forloop.counter }}" class="accordion-collapse collapse{% if forloop.first %} show{% endif %}" aria-labelledby="heading{{ forloop.counter }}" data-bs-parent="#servicePlansAccordion">
<div class="accordion-body">
{% comment %} Display group description from first available plan {% endcomment %}
{% for service_level, pricing_data in service_levels.items %}
{% if pricing_data and forloop.first %}
{% with pricing_data.0 as representative_plan %}
{% if representative_plan.compute_plan_group_description %}
<p class="text-muted mb-3">{{ representative_plan.compute_plan_group_description }}</p>
{% endif %}
{% endwith %}
{% endif %}
{% if forloop.first %}
{% comment %} Only show description for first service level {% endcomment %}
{% endif %}
{% endfor %}
{% for service_level, pricing_data in service_levels.items %} <!-- CPU Slider -->
<div class="mb-4"> <div class="mb-4">
<h4 class="mb-3 text-primary">{{ service_level }}</h4> <label for="cpuRange" class="form-label d-flex justify-content-between">
{% if pricing_data %} <span>vCPUs</span>
<div class="table-responsive"> <span class="fw-bold" id="cpuValue">2</span>
<table class="table table-striped table-sm"> </label>
<thead class="table-dark"> <input type="range" class="form-range" id="cpuRange" min="1" max="32" value="2" step="1">
<tr> <div class="d-flex justify-content-between text-muted small">
<th>Compute Plan</th> <span>1</span>
<th>vCPUs</th> <span>32</span>
<th>RAM (GB)</th>
<th>Currency</th>
<th>Compute Price</th>
<th>Service Price</th>
<th class="table-warning">Total Price</th>
</tr>
</thead>
<tbody>
{% for row in pricing_data %}
<tr>
<td>{{ row.compute_plan }}</td>
<td>{{ row.vcpus }}</td>
<td>{{ row.ram }}</td>
<td>{{ row.currency }}</td>
<td>{{ row.compute_plan_price|floatformat:2 }}</td>
<td>{{ row.sla_price|floatformat:2 }}</td>
<td class="table-warning fw-bold">{{ row.final_price|floatformat:2 }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted">No pricing data available for {{ service_level }}.</p>
{% endif %}
</div> </div>
{% endfor %} </div>
<!-- Memory Slider -->
<div class="mb-4">
<label for="memoryRange" class="form-label d-flex justify-content-between">
<span>Memory (GB)</span>
<span class="fw-bold" id="memoryValue">4</span>
</label>
<input type="range" class="form-range" id="memoryRange" min="1" max="128" value="4" step="1">
<div class="d-flex justify-content-between text-muted small">
<span>1 GB</span>
<span>128 GB</span>
</div>
</div>
<!-- Storage Slider -->
<div class="mb-4">
<label for="storageRange" class="form-label d-flex justify-content-between">
<span>Storage (GB)</span>
<span class="fw-bold" id="storageValue">20</span>
</label>
<input type="range" class="form-range" id="storageRange" min="10" max="1000" value="20" step="10">
<div class="d-flex justify-content-between text-muted small">
<span>10 GB</span>
<span>1000 GB</span>
</div>
</div>
<!-- Service Level Selection -->
<div class="mb-4">
<label class="form-label">Service Level</label>
<div class="btn-group w-100" role="group" id="serviceLevelGroup">
<input type="radio" class="btn-check" name="serviceLevel" id="serviceLevelBestEffort" value="Best Effort" checked>
<label class="btn btn-outline-primary" for="serviceLevelBestEffort">Best Effort</label>
<input type="radio" class="btn-check" name="serviceLevel" id="serviceLevelGuaranteed" value="Guaranteed">
<label class="btn btn-outline-primary" for="serviceLevelGuaranteed">Guaranteed Availability</label>
</div>
</div>
<!-- Direct Plan Selection -->
<div class="mb-4">
<label for="planSelect" class="form-label">Or choose a specific plan</label>
<select class="form-select" id="planSelect">
<option value="">Auto-select best matching plan</option>
</select>
<small class="form-text text-muted">Selecting a plan will override the slider configuration</small>
</div>
</div> </div>
</div> </div>
</div> </div>
{% endfor %}
<!-- Results Panel -->
<div class="col-12 col-lg-6">
<div class="card h-100 border-primary">
<div class="card-body">
<h5 class="card-title text-primary mb-4">Your Configuration</h5>
<!-- Plan Match Status -->
<div id="planMatchStatus" class="alert alert-info mb-3">
<i class="bi bi-info-circle me-2"></i>
<span>Finding best matching plan...</span>
</div>
<!-- Selected Plan Details -->
<div id="selectedPlanDetails" style="display: none;">
<div class="mb-3">
<div class="d-flex align-items-center mb-2">
<span class="badge me-2" id="planGroup"></span>
<strong id="planName"></strong>
</div>
<small class="text-muted" id="planDescription"></small>
</div>
<div class="row mb-3">
<div class="col-6">
<small class="text-muted">vCPUs</small>
<div class="fw-bold" id="planCpus"></div>
</div>
<div class="col-6">
<small class="text-muted">Memory</small>
<div class="fw-bold" id="planMemory"></div>
</div>
</div>
<!-- Pricing Breakdown -->
<div class="border-top pt-3">
<div class="d-flex justify-content-between mb-2">
<span>Compute Plan</span>
<span class="fw-bold">CHF <span id="computePrice">0.00</span></span>
</div>
<div class="d-flex justify-content-between mb-2">
<span>Service Price</span>
<span class="fw-bold">CHF <span id="servicePrice">0.00</span></span>
</div>
<div class="d-flex justify-content-between mb-2">
<span>Storage (<span id="storageAmount">20</span> GB)</span>
<span class="fw-bold">CHF <span id="storagePrice">0.00</span></span>
</div>
<hr>
<div class="d-flex justify-content-between">
<span class="fs-5 fw-bold">Total Monthly Price</span>
<span class="fs-4 fw-bold text-primary">CHF <span id="totalPrice">0.00</span></span>
</div>
</div>
</div>
<!-- No Match Found -->
<div id="noMatchFound" style="display: none;" class="alert alert-warning">
<i class="bi bi-exclamation-triangle me-2"></i>
No matching plan found for your requirements. Please adjust your configuration.
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Order Button -->
<div class="text-center mt-4">
<a href="#interest" class="btn btn-primary btn-lg px-5 py-3 fw-semibold">
<i class="bi bi-cart me-2"></i>Order This Configuration
</a>
</div> </div>
{% elif offering.plans.all %} {% elif offering.plans.all %}
<!-- Traditional Plans --> <!-- Traditional Plans -->

View file

@ -1,9 +1,11 @@
import re import re
import yaml import yaml
import json
from decimal import Decimal
from django.shortcuts import render, get_object_or_404 from django.shortcuts import render, get_object_or_404
from django.db.models import Q from django.db.models import Q
from django.http import HttpResponse from django.http import HttpResponse, JsonResponse
from django.template.loader import render_to_string from django.template.loader import render_to_string
from hub.services.models import ( from hub.services.models import (
ServiceOffering, ServiceOffering,
@ -17,6 +19,17 @@ from collections import defaultdict
from markdownify import markdownify from markdownify import markdownify
def decimal_to_float(obj):
"""Convert Decimal objects to float for JSON serialization"""
if isinstance(obj, Decimal):
return float(obj)
elif isinstance(obj, dict):
return {key: decimal_to_float(value) for key, value in obj.items()}
elif isinstance(obj, list):
return [decimal_to_float(item) for item in obj]
return obj
def natural_sort_key(name): def natural_sort_key(name):
"""Extract numeric part from compute plan name for natural sorting""" """Extract numeric part from compute plan name for natural sorting"""
match = re.search(r"compute-std-(\d+)", name) match = re.search(r"compute-std-(\d+)", name)
@ -84,6 +97,17 @@ def offering_detail(request, provider_slug, service_slug):
service__slug=service_slug, service__slug=service_slug,
) )
# Check if JSON pricing data is requested
if request.GET.get("pricing") == "json":
pricing_data = None
if offering.msp == "VS":
pricing_data = generate_pricing_data(offering)
if pricing_data:
# Convert Decimal objects to float for JSON serialization
pricing_data = decimal_to_float(pricing_data)
return JsonResponse(pricing_data or {})
# Check if Exoscale marketplace YAML is requested # Check if Exoscale marketplace YAML is requested
if request.GET.get("exo_marketplace") == "true": if request.GET.get("exo_marketplace") == "true":
return generate_exoscale_marketplace_yaml(offering) return generate_exoscale_marketplace_yaml(offering)