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):
|
class PlanInline(admin.StackedInline):
|
||||||
"""Inline admin for Plan model"""
|
"""Inline admin for Plan model with sortable ordering"""
|
||||||
|
|
||||||
model = Plan
|
model = Plan
|
||||||
extra = 1
|
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
|
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)
|
@admin.register(Plan)
|
||||||
class PlanAdmin(admin.ModelAdmin):
|
class PlanAdmin(admin.ModelAdmin):
|
||||||
"""Admin configuration for Plan model"""
|
"""Admin configuration for Plan model with sortable ordering"""
|
||||||
|
|
||||||
list_display = ("name", "offering", "price_summary")
|
list_display = ("name", "offering", "is_best", "price_summary", "order")
|
||||||
list_filter = ("offering__service", "offering__cloud_provider")
|
list_filter = ("offering__service", "offering__cloud_provider", "is_best")
|
||||||
search_fields = ("name", "description", "offering__service__name")
|
search_fields = ("name", "description", "offering__service__name")
|
||||||
|
list_editable = ("is_best",)
|
||||||
inlines = [PlanPriceInline]
|
inlines = [PlanPriceInline]
|
||||||
|
fieldsets = (
|
||||||
|
(None, {"fields": ("name", "offering", "description", "plan_description")}),
|
||||||
|
("Display Options", {"fields": ("is_best", "order")}),
|
||||||
|
)
|
||||||
|
|
||||||
def price_summary(self, obj):
|
def price_summary(self, obj):
|
||||||
"""Display a summary of prices for this plan"""
|
"""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"
|
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)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ["name"]
|
ordering = ["order", "name"]
|
||||||
unique_together = [["offering", "name"]]
|
unique_together = [["offering", "name"]]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.offering} - {self.name}"
|
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):
|
def get_price(self, currency_code: str):
|
||||||
price_obj = PlanPrice.objects.filter(plan=self, currency=currency_code).first()
|
price_obj = PlanPrice.objects.filter(plan=self, currency=currency_code).first()
|
||||||
if price_obj:
|
if price_obj:
|
||||||
|
|
|
@ -7,6 +7,39 @@
|
||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
<script defer src="{% static "js/price-calculator.js" %}"></script>
|
<script defer src="{% static "js/price-calculator.js" %}"></script>
|
||||||
<link rel="stylesheet" type="text/css" href='{% static "css/price-calculator.css" %}'>
|
<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>
|
<script>
|
||||||
// Function to select a plan in the dropdown when clicking "Select This Plan"
|
// Function to select a plan in the dropdown when clicking "Select This Plan"
|
||||||
function selectPlan(element) {
|
function selectPlan(element) {
|
||||||
|
@ -425,10 +458,18 @@ function selectPlan(element) {
|
||||||
<div class="row">
|
<div class="row">
|
||||||
{% for plan in offering.plans.all %}
|
{% 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="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 h-100 {% if plan.is_best %}border-success border-2 shadow-sm{% else %}border-primary shadow-sm{% endif %} position-relative">
|
||||||
<div class="card-body">
|
{% if plan.is_best %}
|
||||||
<h5 class="card-title text-primary mb-3">
|
<!-- Best Plan Badge -->
|
||||||
<i class="bi bi-box me-2"></i>{{ plan.name }}
|
<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>
|
</h5>
|
||||||
|
|
||||||
<!-- Plan Description -->
|
<!-- Plan Description -->
|
||||||
|
@ -452,23 +493,27 @@ function selectPlan(element) {
|
||||||
|
|
||||||
<!-- Pricing Information -->
|
<!-- Pricing Information -->
|
||||||
{% if plan.plan_prices.exists %}
|
{% 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">
|
<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>
|
</div>
|
||||||
{% for price in plan.plan_prices.all %}
|
<div class="flex-grow-1">
|
||||||
<div class="d-flex justify-content-between mb-2">
|
<div class="d-flex justify-content-between mb-2">
|
||||||
<span>Monthly Price</span>
|
<span>Monthly Price</span>
|
||||||
<span class="fs-5 fw-bold text-primary">{{ price.currency }} {{ price.amount }}</span>
|
<div class="text-end">
|
||||||
</div>
|
{% 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 %}
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<small class="text-muted mt-2 d-block">
|
<small class="text-muted mt-2 d-block">
|
||||||
<i class="bi bi-info-circle me-1"></i>
|
<i class="bi bi-info-circle me-1"></i>
|
||||||
Prices exclude VAT. Monthly pricing based on 30 days.
|
Prices exclude VAT. Monthly pricing based on 30 days.
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% 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">
|
<div class="text-center text-muted">
|
||||||
<i class="bi bi-envelope me-2"></i>Contact us for pricing details
|
<i class="bi bi-envelope me-2"></i>Contact us for pricing details
|
||||||
</div>
|
</div>
|
||||||
|
@ -476,9 +521,9 @@ function selectPlan(element) {
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Plan Action Button -->
|
<!-- Plan Action Button -->
|
||||||
<div class="text-center mt-4">
|
<div class="text-center mt-auto pt-3">
|
||||||
<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)">
|
<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-cart me-2"></i>Select This Plan
|
<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>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue