introduce plan ordering and marking as best

This commit is contained in:
Tobias Brunner 2025-06-23 13:13:27 +02:00
parent 9e0ccb6025
commit c93f8de717
No known key found for this signature in database
4 changed files with 132 additions and 21 deletions

View file

@ -43,11 +43,14 @@ class PlanPriceInline(admin.TabularInline):
class PlanInline(admin.StackedInline):
"""Inline admin for Plan model"""
"""Inline admin for Plan model with sortable ordering"""
model = Plan
extra = 1
fieldsets = ((None, {"fields": ("name", "description", "plan_description")}),)
fieldsets = (
(None, {"fields": ("name", "description", "plan_description")}),
("Display Options", {"fields": ("is_best",)}),
)
show_change_link = True # This allows clicking through to the Plan admin where prices can be managed
@ -138,12 +141,17 @@ class ServiceOfferingAdmin(admin.ModelAdmin):
@admin.register(Plan)
class PlanAdmin(admin.ModelAdmin):
"""Admin configuration for Plan model"""
"""Admin configuration for Plan model with sortable ordering"""
list_display = ("name", "offering", "price_summary")
list_filter = ("offering__service", "offering__cloud_provider")
list_display = ("name", "offering", "is_best", "price_summary", "order")
list_filter = ("offering__service", "offering__cloud_provider", "is_best")
search_fields = ("name", "description", "offering__service__name")
list_editable = ("is_best",)
inlines = [PlanPriceInline]
fieldsets = (
(None, {"fields": ("name", "offering", "description", "plan_description")}),
("Display Options", {"fields": ("is_best", "order")}),
)
def price_summary(self, obj):
"""Display a summary of prices for this plan"""

View file

@ -0,0 +1,32 @@
# Generated by Django 5.2 on 2025-06-23 10:34
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("services", "0037_remove_plan_pricing_planprice"),
]
operations = [
migrations.AlterModelOptions(
name="plan",
options={"ordering": ["order", "name"]},
),
migrations.AddField(
model_name="plan",
name="is_best",
field=models.BooleanField(
default=False, help_text="Mark this plan as the best/recommended option"
),
),
migrations.AddField(
model_name="plan",
name="order",
field=models.PositiveIntegerField(
default=0,
help_text="Order of this plan in the offering (lower numbers appear first)",
),
),
]

View file

@ -139,16 +139,42 @@ class Plan(models.Model):
ServiceOffering, on_delete=models.CASCADE, related_name="plans"
)
# Ordering and highlighting fields
order = models.PositiveIntegerField(
default=0,
help_text="Order of this plan in the offering (lower numbers appear first)",
)
is_best = models.BooleanField(
default=False, help_text="Mark this plan as the best/recommended option"
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["name"]
ordering = ["order", "name"]
unique_together = [["offering", "name"]]
def __str__(self):
return f"{self.offering} - {self.name}"
def clean(self):
# Ensure only one plan per offering can be marked as "best"
if self.is_best:
existing_best = Plan.objects.filter(
offering=self.offering, is_best=True
).exclude(pk=self.pk)
if existing_best.exists():
from django.core.exceptions import ValidationError
raise ValidationError(
"Only one plan per offering can be marked as the best option."
)
def save(self, *args, **kwargs):
self.clean()
super().save(*args, **kwargs)
def get_price(self, currency_code: str):
price_obj = PlanPrice.objects.filter(plan=self, currency=currency_code).first()
if price_obj:

View file

@ -7,6 +7,39 @@
{% block extra_js %}
<script defer src="{% static "js/price-calculator.js" %}"></script>
<link rel="stylesheet" type="text/css" href='{% static "css/price-calculator.css" %}'>
<style>
/* Subtle styling for the best plan */
.card.border-success.border-2 {
box-shadow: 0 0.25rem 0.75rem rgba(25, 135, 84, 0.1) !important;
transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
}
.card.border-success.border-2:hover {
transform: translateY(-2px);
box-shadow: 0 0.5rem 1rem rgba(25, 135, 84, 0.15) !important;
}
/* Best choice badge styling */
.badge.bg-success {
background: linear-gradient(135deg, #198754 0%, #20c997 100%) !important;
border: 2px solid white;
text-shadow: 0 1px 2px rgba(0,0,0,0.1);
white-space: nowrap;
font-size: 0.75rem;
padding: 0.5rem 0.75rem;
min-width: max-content;
}
/* Subtle enhancement for best plan button */
.btn-success.shadow {
transition: all 0.2s ease-in-out;
}
.btn-success.shadow:hover {
transform: translateY(-1px);
box-shadow: 0 0.25rem 0.75rem rgba(25, 135, 84, 0.2) !important;
}
</style>
<script>
// Function to select a plan in the dropdown when clicking "Select This Plan"
function selectPlan(element) {
@ -425,10 +458,18 @@ function selectPlan(element) {
<div class="row">
{% for plan in offering.plans.all %}
<div class="col-12 {% if offering.plans.all|length == 1 %}col-lg-8 mx-auto{% elif offering.plans.all|length == 2 %}col-lg-6{% else %}col-lg-4{% endif %} mb-4">
<div class="card h-100 border-primary shadow-sm">
<div class="card-body">
<h5 class="card-title text-primary mb-3">
<i class="bi bi-box me-2"></i>{{ plan.name }}
<div class="card h-100 {% if plan.is_best %}border-success border-2 shadow-sm{% else %}border-primary shadow-sm{% endif %} position-relative">
{% if plan.is_best %}
<!-- Best Plan Badge -->
<div class="position-absolute top-0 start-50 translate-middle" style="z-index: 10;">
<span class="badge bg-success px-3 py-2 fs-6 fw-bold shadow-sm text-nowrap">
<i class="bi bi-star-fill me-1"></i>Best Choice
</span>
</div>
{% endif %}
<div class="card-body pt-3 d-flex flex-column">
<h5 class="card-title {% if plan.is_best %}text-success{% else %}text-primary{% endif %} mb-3 fw-bold">
<i class="bi bi-{% if plan.is_best %}award{% else %}box{% endif %} me-2"></i>{{ plan.name }}
</h5>
<!-- Plan Description -->
@ -452,23 +493,27 @@ function selectPlan(element) {
<!-- Pricing Information -->
{% if plan.plan_prices.exists %}
<div class="border-top pt-3 mt-3">
<div class="{% if plan.is_best %}border-top border-success{% else %}border-top{% endif %} pt-3 mt-3 flex-grow-1 d-flex flex-column">
<div class="mb-2">
<small class="text-muted">Pricing</small>
<small class="{% if plan.is_best %}text-success fw-semibold{% else %}text-muted{% endif %}">Pricing</small>
</div>
{% for price in plan.plan_prices.all %}
<div class="flex-grow-1">
<div class="d-flex justify-content-between mb-2">
<span>Monthly Price</span>
<span class="fs-5 fw-bold text-primary">{{ price.currency }} {{ price.amount }}</span>
</div>
<div class="text-end">
{% for price in plan.plan_prices.all %}
<div class="fs-5 fw-bold {% if plan.is_best %}text-success{% else %}text-primary{% endif %}">{{ price.currency }} {{ price.amount }}</div>
{% endfor %}
</div>
</div>
</div>
<small class="text-muted mt-2 d-block">
<i class="bi bi-info-circle me-1"></i>
Prices exclude VAT. Monthly pricing based on 30 days.
</small>
</div>
{% else %}
<div class="border-top pt-3 mt-3">
<div class="{% if plan.is_best %}border-top border-success{% else %}border-top{% endif %} pt-3 mt-3 flex-grow-1 d-flex align-items-center justify-content-center">
<div class="text-center text-muted">
<i class="bi bi-envelope me-2"></i>Contact us for pricing details
</div>
@ -476,9 +521,9 @@ function selectPlan(element) {
{% endif %}
<!-- Plan Action Button -->
<div class="text-center mt-4">
<a href="#plan-order-form" class="btn btn-primary btn-lg px-4 py-2 fw-semibold w-100" data-plan-id="{{ plan.id }}" data-plan-name="{{ plan.name }}" onclick="selectPlan(this)">
<i class="bi bi-cart me-2"></i>Select This Plan
<div class="text-center mt-auto pt-3">
<a href="#plan-order-form" class="btn {% if plan.is_best %}btn-success btn-lg px-4 py-2 shadow{% else %}btn-primary btn-lg px-4 py-2{% endif %} fw-semibold w-100" data-plan-id="{{ plan.id }}" data-plan-name="{{ plan.name }}" onclick="selectPlan(this)">
<i class="bi bi-{% if plan.is_best %}star-fill{% else %}cart{% endif %} me-2"></i>{% if plan.is_best %}Choose Best Plan{% else %}Select This Plan{% endif %}
</a>
</div>
</div>