introduce plan ordering and marking as best
This commit is contained in:
parent
9e0ccb6025
commit
c93f8de717
4 changed files with 132 additions and 21 deletions
|
@ -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"""
|
||||
|
|
32
hub/services/migrations/0038_add_plan_ordering_and_best.py
Normal file
32
hub/services/migrations/0038_add_plan_ordering_and_best.py
Normal 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)",
|
||||
),
|
||||
),
|
||||
]
|
|
@ -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:
|
||||
|
|
|
@ -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="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 class="flex-grow-1">
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<span>Monthly Price</span>
|
||||
<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>
|
||||
{% endfor %}
|
||||
<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>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue