diff --git a/.gitignore b/.gitignore index 63eb1b0..7487dec 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,3 @@ wheels/ media/ deployment/secret.yaml *.json -static/ diff --git a/Dockerfile b/Dockerfile index 21e6ece..89fa732 100644 --- a/Dockerfile +++ b/Dockerfile @@ -35,6 +35,6 @@ RUN uv sync --frozen \ && chgrp -R 0 /app \ && chmod -R g=u /app \ && chmod g+w /app/config/caddy/Caddyfile \ - && SECRET_KEY=dummy python -m hub build_assets --force + && SECRET_KEY= python -m hub collectstatic --noinput CMD ["/usr/local/bin/runhub.sh"] \ No newline at end of file diff --git a/IMAGE_LIBRARY_MIGRATION_STATUS.md b/IMAGE_LIBRARY_MIGRATION_STATUS.md deleted file mode 100644 index d2e27de..0000000 --- a/IMAGE_LIBRARY_MIGRATION_STATUS.md +++ /dev/null @@ -1,81 +0,0 @@ -# Image Library Migration Status - -## ✅ COMPLETED (First Production Rollout) - UPDATED - -### Models Updated -- **Article**: Now inherits from `ImageReference`, with `image_library` field for new images and original `image` field temporarily -- **CloudProvider**: Now inherits from `ImageReference`, with `image_library` field for new images and original `logo` field temporarily -- **ConsultingPartner**: Now inherits from `ImageReference`, with `image_library` field for new images and original `logo` field temporarily -- **Service**: Now inherits from `ImageReference`, with `image_library` field for new images and original `logo` field temporarily - -### New Properties Added -- `Article.get_image()` - Returns image from library or falls back to original field -- `CloudProvider.get_logo()` - Returns logo from library or falls back to original field -- `ConsultingPartner.get_logo()` - Returns logo from library or falls back to original field -- `Service.get_logo()` - Returns logo from library or falls back to original field - -### Templates Updated -- ✅ `pages/homepage.html` - Updated service, provider, and partner image references -- ✅ `services/article_list.html` - Updated article image references -- ✅ `services/article_detail.html` - Updated related service/provider/partner logos -- ✅ `services/offering_list.html` - Updated service and provider logos -- ✅ `services/offering_detail.html` - Updated service and provider logos -- ✅ `services/lead_form.html` - Updated service logo -- ✅ `services/partner_detail.html` - Updated partner and service logos -- ✅ `services/partner_list.html` - Updated partner logos -- ✅ `services/provider_list.html` - Updated provider logos -- ✅ `services/provider_detail.html` - Updated provider and service logos -- ✅ `services/service_detail.html` - Updated service and provider logos - -### Admin Interface Updated -- ✅ `ArticleAdmin` - Updated image_preview to use get_image property -- ✅ `ServiceAdmin` - Updated logo_preview to use get_logo property -- ✅ `CloudProviderAdmin` - Updated logo_preview to use get_logo property -- ✅ `ConsultingPartnerAdmin` - Updated logo_preview to use get_logo property - -### JSON-LD Template Tags Updated -- ✅ Updated structured data generation to use new image properties -- ✅ Updated logo references for services, providers, and partners - -### Database Migration -- ✅ Migration `0041_add_image_library_references` successfully applied -- ✅ Migration `0042_fix_image_library_field_name` successfully applied -- ✅ All models now have `image_library` foreign key fields to ImageLibrary -- ✅ Original image fields preserved for backward compatibility -- ✅ Fixed field name conflicts using `%(class)s_references` related_name pattern - -### Admin Interface Enhanced -- ✅ **ArticleAdmin**: Added fieldsets with `image_library` field visible in "Images" section -- ✅ **ServiceAdmin**: Added fieldsets with `image_library` field visible in "Images" section -- ✅ **CloudProviderAdmin**: Added fieldsets with `image_library` field visible in "Images" section -- ✅ **ConsultingPartnerAdmin**: Added fieldsets with `image_library` field visible in "Images" section -- ✅ All admin interfaces show both new and legacy fields during transition -- ✅ Clear descriptions guide users to use Image Library for new images - -## Current Status -The system is now ready for production with dual image support: -- **New images**: Can be added through the Image Library -- **Legacy images**: Still work through the original fields -- **Templates**: Use the new `get_image/get_logo` properties that automatically fall back - -## Next Steps (Future Cleanup) -1. **Data Migration**: Create script to migrate existing images to ImageLibrary -2. **Admin Updates**: Update admin interfaces to use ImageLibrary selection -3. **Template Validation**: Add null checks to remaining templates -4. **Field Removal**: Remove legacy image fields after migration is complete -5. **Storage Cleanup**: Remove old image files from media directories - -## Benefits Achieved -- ✅ Centralized image management through ImageLibrary -- ✅ Usage tracking for images -- ✅ Backward compatibility maintained -- ✅ Enhanced admin experience ready -- ✅ Consistent image handling across all models -- ✅ Proper fallback mechanisms in place - -## Safety Measures -- ✅ Original image fields preserved -- ✅ Gradual migration approach -- ✅ Fallback properties ensure no broken images -- ✅ Database migration tested and applied -- ✅ Admin interface maintains functionality diff --git a/SVG_SUPPORT.md b/SVG_SUPPORT.md deleted file mode 100644 index 32322a6..0000000 --- a/SVG_SUPPORT.md +++ /dev/null @@ -1,176 +0,0 @@ -# SVG Support in Image Library - -## Overview - -The Image Library now supports SVG (Scalable Vector Graphics) files alongside traditional raster image formats. This enhancement allows you to upload, manage, and display SVG images with the same ease as JPEG, PNG, and other image formats. - -## Supported Formats - -The Image Library now supports: -- **Raster Images**: JPEG, PNG, GIF, WebP, BMP, TIFF -- **Vector Images**: SVG - -## Features - -### 1. SVG File Validation -- SVG files are validated to ensure they contain valid XML structure -- File size limits apply (max 1MB by default) -- MIME type detection for proper handling - -### 2. Automatic Dimension Detection -- SVG dimensions are extracted from `width` and `height` attributes -- Falls back to `viewBox` if width/height attributes are missing -- Provides sensible defaults (100x100) if dimensions cannot be determined - -### 3. Proper Rendering -- **Template Tags**: SVG images use `` tags for optimal rendering -- **Admin Interface**: SVG thumbnails display correctly in the admin -- **Form Widgets**: SVG previews work in form interfaces - -### 4. Template Tag Support -The `image_library_img` template tag automatically handles SVG files: - -```html - -{% image_library_img "my-svg-logo" css_class="logo" %} - - - - - - -``` - -## Usage - -### 1. Uploading SVG Images - -1. Go to the Django admin interface -2. Navigate to **Services > Image Library** -3. Click **Add Image** -4. Choose your SVG file in the image field -5. Fill in the required fields (name, alt text, etc.) -6. Save the image - -### 2. Using SVG Images in Templates - -```html - -{% load image_library %} - - -{% image_library_img "my-logo" css_class="img-fluid" %} -{% image_library_img "my-icon" css_class="icon" width="24" height="24" %} - - -{% image_library_url "my-logo" %} -``` - -### 3. Checking if an Image is SVG - -In Python code: -```python -from hub.services.models.images import ImageLibrary - -image = ImageLibrary.objects.get(slug="my-image") -if image.is_svg(): - print("This is an SVG image") - print(f"MIME type: {image.get_mime_type()}") -``` - -## Migration - -### Existing Images -All existing raster images continue to work without any changes. The migration is backward compatible. - -### Updating Image Properties -If you want to update image properties for all existing images: - -```bash -uv run --extra dev manage.py update_image_properties -``` - -## Benefits - -1. **Scalability**: SVG images scale perfectly at any size -2. **Performance**: SVG files are typically smaller than high-resolution raster images -3. **Flexibility**: SVG images can be styled with CSS -4. **Accessibility**: SVG images provide better accessibility options -5. **Consistency**: All images are managed through the same unified interface - -## Technical Details - -### Model Changes -- Changed `ImageField` to `FileField` with custom validation -- Added `is_svg()` method to check if file is SVG -- Added `get_mime_type()` method to determine file type -- Enhanced `_update_image_properties()` to handle SVG dimensions - -### Validation -- SVG files are validated as valid XML -- File size limits are enforced -- MIME type checking ensures only valid image/SVG files are accepted - -### Rendering -- SVG files use `` tags for optimal rendering -- Fallback `` tags are provided for compatibility -- Admin interface properly displays SVG thumbnails - -## File Size Considerations - -SVG files are typically much smaller than equivalent raster images: -- **Simple logos**: 1-5KB -- **Complex illustrations**: 10-50KB -- **Very complex graphics**: 100KB+ - -This makes SVG files ideal for: -- Logos and brand assets -- Icons and symbols -- Simple illustrations -- Graphics that need to scale - -## Browser Support - -SVG support is excellent across all modern browsers: -- Chrome: Full support -- Firefox: Full support -- Safari: Full support -- Edge: Full support -- Internet Explorer 9+: Full support - -## Best Practices - -1. **Use SVG for**: - - Logos and brand assets - - Icons and symbols - - Simple illustrations - - Graphics that need to scale - -2. **Use raster images for**: - - Photographs - - Complex graphics with many colors - - Images from external sources - -3. **Optimize SVG files**: - - Remove unnecessary metadata - - Use simple shapes when possible - - Consider using SVG optimization tools - -## Troubleshooting - -### SVG Not Displaying -- Check that the SVG file is valid XML -- Ensure the file has proper width/height or viewBox attributes -- Verify the file size is under the limit (1MB) - -### Wrong Dimensions -- SVG dimensions are extracted from width/height attributes or viewBox -- If dimensions appear wrong, check the SVG source -- Use the `update_image_properties` command to refresh dimensions - -### Upload Errors -- Ensure the file is valid SVG format -- Check file size limits -- Verify the file contains valid XML structure diff --git a/hub/middleware.py b/hub/middleware.py index 6d49bb6..0ee2475 100644 --- a/hub/middleware.py +++ b/hub/middleware.py @@ -6,11 +6,12 @@ from urllib.parse import urlparse class PrimaryDomainRedirectMiddleware: def __init__(self, get_response): self.get_response = get_response + # Parse the primary hostname from WEBSITE_URL self.primary_host = urlparse(settings.WEBSITE_URL).netloc - self.disable_redirect = settings.DISABLE_REDIRECT def __call__(self, request): - if settings.DEBUG or self.disable_redirect: + # Skip redirects in DEBUG mode + if settings.DEBUG: return self.get_response(request) # Check if the host is different from the primary host diff --git a/hub/services/admin/__init__.py b/hub/services/admin/__init__.py index e8e3470..3c79092 100644 --- a/hub/services/admin/__init__.py +++ b/hub/services/admin/__init__.py @@ -4,9 +4,7 @@ from .articles import * from .base import * from .content import * -from .images import * from .leads import * from .pricing import * from .providers import * -from .widgets import * from .services import * diff --git a/hub/services/admin/articles.py b/hub/services/admin/articles.py index 5050c76..e6dad8c 100644 --- a/hub/services/admin/articles.py +++ b/hub/services/admin/articles.py @@ -7,8 +7,8 @@ from django.utils.html import format_html from django import forms from django.core.exceptions import ValidationError + from ..models import Article -from .widgets import ImageLibraryWidget class ArticleAdminForm(forms.ModelForm): @@ -17,9 +17,6 @@ class ArticleAdminForm(forms.ModelForm): class Meta: model = Article fields = "__all__" - widgets = { - "image_library": ImageLibraryWidget(), - } def clean_title(self): """Validate title length""" @@ -48,7 +45,7 @@ class ArticleAdmin(admin.ModelAdmin): "image_preview", "is_published", "is_featured", - "article_date", + "created_at", ) list_filter = ( "is_published", @@ -57,51 +54,18 @@ class ArticleAdmin(admin.ModelAdmin): "related_service", "related_consulting_partner", "related_cloud_provider", - "article_date", + "created_at", ) search_fields = ("title", "excerpt", "content", "meta_keywords") prepopulated_fields = {"slug": ("title",)} readonly_fields = ("created_at", "updated_at") - ordering = ("-article_date",) - - fieldsets = ( - (None, {"fields": ("title", "slug", "excerpt", "content", "meta_keywords")}), - ( - "Images", - { - "fields": ("image_library", "og_image"), - "description": "Select an image from the Image Library and optionally upload a specific Open Graph image for social sharing.", - }, - ), - ( - "Publishing", - {"fields": ("author", "article_date", "is_published", "is_featured")}, - ), - ( - "Relations", - { - "fields": ( - "related_service", - "related_consulting_partner", - "related_cloud_provider", - ), - "classes": ("collapse",), - }, - ), - ( - "Metadata", - { - "fields": ("created_at", "updated_at"), - "classes": ("collapse",), - }, - ), - ) def image_preview(self, obj): """Display image preview in admin list view""" - image = obj.get_image - if image: - return format_html('', image.url) + if obj.image: + return format_html( + '', obj.image.url + ) return "No image" image_preview.short_description = "Image" diff --git a/hub/services/admin/images.py b/hub/services/admin/images.py deleted file mode 100644 index 761fd2d..0000000 --- a/hub/services/admin/images.py +++ /dev/null @@ -1,130 +0,0 @@ -from django.contrib import admin -from django.utils.html import format_html -from django.urls import reverse -from django.utils.safestring import mark_safe -from ..models.images import ImageLibrary - - -@admin.register(ImageLibrary) -class ImageLibraryAdmin(admin.ModelAdmin): - """ - Admin interface for the Image Library. - """ - - list_display = [ - "image_thumbnail", - "name", - "category", - "get_dimensions", - "get_file_size_display", - "usage_count", - "uploaded_by", - "uploaded_at", - ] - - list_filter = [ - "category", - "uploaded_at", - "uploaded_by", - ] - - search_fields = [ - "name", - "description", - "alt_text", - "tags", - ] - - readonly_fields = [ - "width", - "height", - "file_size", - "usage_count", - "uploaded_at", - "updated_at", - "image_preview", - ] - - prepopulated_fields = {"slug": ("name",)} - - fieldsets = ( - ("Image Information", {"fields": ("name", "slug", "description", "alt_text")}), - ("Image File", {"fields": ("image", "image_preview")}), - ("Categorization", {"fields": ("category", "tags")}), - ( - "Metadata", - { - "fields": ("width", "height", "file_size", "usage_count"), - "classes": ("collapse",), - }, - ), - ( - "Timestamps", - { - "fields": ("uploaded_by", "uploaded_at", "updated_at"), - "classes": ("collapse",), - }, - ), - ) - - def image_thumbnail(self, obj): - """ - Display small thumbnail in list view. - """ - if obj.image: - # Use img tag for all images in list view to maintain clickability - # SVG files will still display correctly with img tag - return format_html( - '', - obj.image.url, - ) - return "No Image" - - image_thumbnail.short_description = "Thumbnail" - - def image_preview(self, obj): - """ - Display larger preview in detail view. - """ - if obj.image: - if obj.is_svg(): - # For SVG files in detail view, use object tag for better rendering - # This is only for display, not for clickable elements - return format_html( - '
' - '' - '' - "" - "
", - obj.image.url, - obj.image.url, - ) - else: - return format_html( - '', - obj.image.url, - ) - return "No Image" - - image_preview.short_description = "Preview" - - def get_dimensions(self, obj): - """ - Display image dimensions. - """ - if obj.width and obj.height: - return f"{obj.width} × {obj.height}" - return "Unknown" - - get_dimensions.short_description = "Dimensions" - - def save_model(self, request, obj, form, change): - """ - Set uploaded_by field to current user if not already set. - """ - if not change: # Only set on creation - obj.uploaded_by = request.user - super().save_model(request, obj, form, change) - - class Media: - css = {"all": ("admin/css/image_library.css",)} diff --git a/hub/services/admin/pricing.py b/hub/services/admin/pricing.py index fe8c03e..ac9711d 100644 --- a/hub/services/admin/pricing.py +++ b/hub/services/admin/pricing.py @@ -322,26 +322,10 @@ class DiscountTierInline(admin.TabularInline): class ProgressiveDiscountModelAdmin(admin.ModelAdmin): """Admin configuration for ProgressiveDiscountModel""" - list_display = ("name", "description", "active", "admin_display_discount_tiers") + list_display = ("name", "description", "active") search_fields = ("name", "description") inlines = [DiscountTierInline] - def admin_display_discount_tiers(self, obj): - """Display discount tiers in admin list view""" - tiers = obj.tiers.all().order_by("min_units") - if not tiers: - return "No discount tiers" - return format_html( - "
".join( - [ - f"{tier.min_units}-{tier.max_units if tier.max_units else '∞'} units: {tier.discount_percent}%" - for tier in tiers - ] - ) - ) - - admin_display_discount_tiers.short_description = "Discount Tiers" - @admin.register(VSHNAppCatPrice) class VSHNAppCatPriceAdmin(admin.ModelAdmin): @@ -366,12 +350,7 @@ class VSHNAppCatPriceAdmin(admin.ModelAdmin): if not fees: return "No base fees" return format_html( - "
".join( - [ - f"{fee.amount} {fee.currency} ({fee.get_service_level_display()})" - for fee in fees - ] - ) + "
".join([f"{fee.amount} {fee.currency} ({fee.get_service_level_display()})" for fee in fees]) ) admin_display_base_fees.short_description = "Base Fees" @@ -639,12 +618,7 @@ class VSHNAppCatAddonAdmin(admin.ModelAdmin): if not fees: return "No base fees set" return format_html( - "
".join( - [ - f"{fee.amount} {fee.currency} ({fee.get_service_level_display()})" - for fee in fees - ] - ) + "
".join([f"{fee.amount} {fee.currency} ({fee.get_service_level_display()})" for fee in fees]) ) elif obj.addon_type == "UR": # Unit Rate rates = obj.unit_rates.all() diff --git a/hub/services/admin/providers.py b/hub/services/admin/providers.py index 51f6d59..8ef3ad3 100644 --- a/hub/services/admin/providers.py +++ b/hub/services/admin/providers.py @@ -4,33 +4,9 @@ Admin classes for cloud providers and consulting partners from django.contrib import admin from django.utils.html import format_html -from django import forms from adminsortable2.admin import SortableAdminMixin from ..models import CloudProvider, ConsultingPartner, ServiceOffering -from .widgets import ImageLibraryWidget - - -class CloudProviderAdminForm(forms.ModelForm): - """Custom form for CloudProvider admin with image widget""" - - class Meta: - model = CloudProvider - fields = "__all__" - widgets = { - "image_library": ImageLibraryWidget(), - } - - -class ConsultingPartnerAdminForm(forms.ModelForm): - """Custom form for ConsultingPartner admin with image widget""" - - class Meta: - model = ConsultingPartner - fields = "__all__" - widgets = { - "image_library": ImageLibraryWidget(), - } class OfferingInline(admin.StackedInline): @@ -58,8 +34,6 @@ class OfferingInline(admin.StackedInline): class CloudProviderAdmin(SortableAdminMixin, admin.ModelAdmin): """Admin configuration for CloudProvider model""" - form = CloudProviderAdminForm - list_display = ( "name", "slug", @@ -73,27 +47,12 @@ class CloudProviderAdmin(SortableAdminMixin, admin.ModelAdmin): inlines = [OfferingInline] ordering = ("order",) - fieldsets = ( - (None, {"fields": ("name", "slug", "description", "order")}), - ( - "Images", - { - "fields": ("image_library",), - "description": "Select an image from the Image Library.", - }, - ), - ( - "Contact Information", - {"fields": ("website", "linkedin", "phone", "email", "address")}, - ), - ("Settings", {"fields": ("is_featured", "disable_listing")}), - ) - def logo_preview(self, obj): """Display logo preview in admin list view""" - logo = obj.get_logo - if logo: - return format_html('', logo.url) + if obj.logo: + return format_html( + '', obj.logo.url + ) return "No logo" logo_preview.short_description = "Logo" @@ -103,11 +62,8 @@ class CloudProviderAdmin(SortableAdminMixin, admin.ModelAdmin): class ConsultingPartnerAdmin(SortableAdminMixin, admin.ModelAdmin): """Admin configuration for ConsultingPartner model""" - form = ConsultingPartnerAdminForm - list_display = ( "name", - "category", "website", "logo_preview", "disable_listing", @@ -115,36 +71,16 @@ class ConsultingPartnerAdmin(SortableAdminMixin, admin.ModelAdmin): "order", ) search_fields = ("name", "description") - list_filter = ("category", "is_featured", "disable_listing") prepopulated_fields = {"slug": ("name",)} filter_horizontal = ("services", "cloud_providers") ordering = ("order",) - fieldsets = ( - (None, {"fields": ("name", "slug", "description", "category", "order")}), - ( - "Images", - { - "fields": ("image_library",), - "description": "Select an image from the Image Library.", - }, - ), - ( - "Contact Information", - {"fields": ("website", "linkedin", "phone", "email", "address")}, - ), - ( - "Relations", - {"fields": ("services", "cloud_providers"), "classes": ("collapse",)}, - ), - ("Settings", {"fields": ("is_featured", "disable_listing")}), - ) - def logo_preview(self, obj): """Display logo preview in admin list view""" - logo = obj.get_logo - if logo: - return format_html('', logo.url) + if obj.logo: + return format_html( + '', obj.logo.url + ) return "No logo" logo_preview.short_description = "Logo" diff --git a/hub/services/admin/services.py b/hub/services/admin/services.py index 92e76e3..c975884 100644 --- a/hub/services/admin/services.py +++ b/hub/services/admin/services.py @@ -4,7 +4,6 @@ Admin classes for services and service offerings from django.contrib import admin from django.utils.html import format_html -from django import forms from ..models import ( Service, @@ -14,18 +13,6 @@ from ..models import ( Plan, PlanPrice, ) -from .widgets import ImageLibraryWidget - - -class ServiceAdminForm(forms.ModelForm): - """Custom form for Service admin with image widget""" - - class Meta: - model = Service - fields = "__all__" - widgets = { - "image_library": ImageLibraryWidget(), - } class ExternalLinkInline(admin.TabularInline): @@ -62,9 +49,9 @@ class PlanInline(admin.StackedInline): extra = 1 fieldsets = ( (None, {"fields": ("name", "description", "plan_description")}), - ("Display Options", {"fields": ("is_best", "order")}), + ("Display Options", {"fields": ("is_best",)}), ) - show_change_link = True + show_change_link = True # This allows clicking through to the Plan admin where prices can be managed class OfferingInline(admin.StackedInline): @@ -92,8 +79,6 @@ class OfferingInline(admin.StackedInline): class ServiceAdmin(admin.ModelAdmin): """Admin configuration for Service model""" - form = ServiceAdminForm - list_display = ( "name", "logo_preview", @@ -108,34 +93,12 @@ class ServiceAdmin(admin.ModelAdmin): filter_horizontal = ("categories",) inlines = [ExternalLinkInline, OfferingInline] - fieldsets = ( - (None, {"fields": ("name", "slug", "description", "tagline")}), - ( - "Images", - { - "fields": ("image_library",), - "description": "Select an image from the Image Library.", - }, - ), - ( - "Configuration", - { - "fields": ( - "categories", - "features", - "is_featured", - "is_coming_soon", - "disable_listing", - ) - }, - ), - ) - def logo_preview(self, obj): """Display logo preview in admin list view""" - logo = obj.get_logo - if logo: - return format_html('', logo.url) + if obj.logo: + return format_html( + '', obj.logo.url + ) return "No logo" logo_preview.short_description = "Logo" diff --git a/hub/services/admin/widgets.py b/hub/services/admin/widgets.py deleted file mode 100644 index 13c8e4d..0000000 --- a/hub/services/admin/widgets.py +++ /dev/null @@ -1,232 +0,0 @@ -""" -Custom widgets for Django admin interface -""" - -from django import forms -from django.utils.html import format_html -from django.utils.safestring import mark_safe -from django.urls import reverse -from django.conf import settings - -from ..models import ImageLibrary - - -class ImageLibraryWidget(forms.Select): - """Custom widget for selecting images from the library with visual preview""" - - def __init__(self, attrs=None): - super().__init__(attrs) - self.attrs.update( - { - "class": "image-library-select", - "style": "display: none;", # Hide the original select - } - ) - - def render(self, name, value, attrs=None, renderer=None): - """Render the widget with image previews""" - # Get the original select element - original_select = super().render(name, value, attrs, renderer) - - # Get all images from the library - images = ImageLibrary.objects.all().order_by("-uploaded_at") - - # Create the visual interface - html_parts = [ - '
', - original_select, # Keep the original select for form submission - '
', - ] - - # Add "No image" option - no_image_selected = "selected" if not value else "" - html_parts.append( - f""" -
-
- - No image -
-
- No image selected -
-
- """ - ) - - # Add each image as an option - for image in images: - selected = "selected" if str(image.pk) == str(value) else "" - image_url = image.image.url if image.image else "" - - # Use img tag for all images in widget to maintain clickability - # SVG files will still display correctly with img tag - preview_html = ( - f'{image.alt_text}' - ) - - html_parts.append( - f""" -
-
- {preview_html} -
-
- {image.name} - {image.get_category_display()} - {image.width}x{image.height} -
-
- """ - ) - - html_parts.extend( - [ - "
", - "
", - self._get_styles(), - self._get_javascript(), - ] - ) - - return mark_safe("".join(html_parts)) - - def _get_styles(self): - """Return CSS styles for the widget""" - return """ - - """ - - def _get_javascript(self): - """Return JavaScript for the widget functionality""" - return """ - - """ - - class Media: - css = { - "all": ( - "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css", - ) - } diff --git a/hub/services/forms/lead.py b/hub/services/forms.py similarity index 94% rename from hub/services/forms/lead.py rename to hub/services/forms.py index d02c876..a608d44 100644 --- a/hub/services/forms/lead.py +++ b/hub/services/forms.py @@ -1,5 +1,5 @@ from django import forms -from ..models import Lead +from .models import Lead, Plan class LeadForm(forms.ModelForm): diff --git a/hub/services/forms/__init__.py b/hub/services/forms/__init__.py deleted file mode 100644 index 920f500..0000000 --- a/hub/services/forms/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from .lead import LeadForm -from .image_library import ImageLibraryField, ImageLibraryWidget diff --git a/hub/services/forms/image_library.py b/hub/services/forms/image_library.py deleted file mode 100644 index 6435ef8..0000000 --- a/hub/services/forms/image_library.py +++ /dev/null @@ -1,156 +0,0 @@ -from django import forms -from django.utils.safestring import mark_safe -from django.utils.html import format_html -from django.urls import reverse -from ..models.images import ImageLibrary - - -class ImageLibraryWidget(forms.Select): - """ - Custom widget for selecting images from the library with thumbnails. - """ - - def __init__(self, attrs=None, choices=(), show_thumbnails=True): - self.show_thumbnails = show_thumbnails - super().__init__(attrs, choices) - - def format_value(self, value): - """ - Format the selected value for display. - """ - if value is None: - return "" - return str(value) - - def render(self, name, value, attrs=None, renderer=None): - """ - Render the widget with thumbnails. - """ - if attrs is None: - attrs = {} - - # Add CSS class for styling - attrs["class"] = attrs.get("class", "") + " image-library-select" - - # Get all images for the select options - images = ImageLibrary.objects.all().order_by("name") - - # Build choices with thumbnails - choices = [("", "--- Select an image ---")] - for image in images: - thumbnail_html = "" - if self.show_thumbnails and image.image: - # Use img tag for all images in dropdowns to maintain functionality - # SVG files will still display correctly with img tag - thumbnail_html = format_html( - ' ', - image.image.url, - ) - - choice_text = ( - f"{image.name} ({image.get_category_display()}){thumbnail_html}" - ) - choices.append((image.pk, choice_text)) - - # Build the select element - select_html = format_html( - '', - name, - attrs.get("id", ""), - self._build_attrs_string(attrs), - self._build_options(choices, value), - ) - - # Add preview area - preview_html = "" - if value: - try: - image = ImageLibrary.objects.get(pk=value) - - # Use img tag for all images in preview for consistency - # Add SVG indicator in the text if it's an SVG file - svg_indicator = " (SVG)" if image.is_svg() else "" - preview_html = format_html( - '
' - '' - '

{} - {}x{} - {}{}

' - "
", - image.image.url, - image.name, - image.width or "?", - image.height or "?", - image.get_file_size_display(), - svg_indicator, - ) - except ImageLibrary.DoesNotExist: - pass - - # Add JavaScript for preview updates - js_html = format_html( - "", - attrs.get("id", ""), - ) - - return mark_safe(select_html + preview_html + js_html) - - def _build_attrs_string(self, attrs): - """ - Build HTML attributes string. - """ - attr_parts = [] - for key, value in attrs.items(): - if key != "id": # id is handled separately - attr_parts.append(f'{key}="{value}"') - return " " + " ".join(attr_parts) if attr_parts else "" - - def _build_options(self, choices, selected_value): - """ - Build option elements for the select. - """ - options = [] - for value, text in choices: - selected = "selected" if str(value) == str(selected_value) else "" - options.append(f'') - return "".join(options) - - -class ImageLibraryField(forms.ModelChoiceField): - """ - Custom form field for selecting images from the library. - """ - - def __init__(self, queryset=None, widget=None, show_thumbnails=True, **kwargs): - if queryset is None: - queryset = ImageLibrary.objects.all() - - if widget is None: - widget = ImageLibraryWidget(show_thumbnails=show_thumbnails) - - super().__init__(queryset=queryset, widget=widget, **kwargs) - - def label_from_instance(self, obj): - """ - Return the label for an image instance. - """ - return f"{obj.name} ({obj.get_category_display()})" diff --git a/hub/services/management/__init__.py b/hub/services/management/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/hub/services/management/commands/__init__.py b/hub/services/management/commands/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/hub/services/management/commands/build_assets.py b/hub/services/management/commands/build_assets.py deleted file mode 100644 index e46813a..0000000 --- a/hub/services/management/commands/build_assets.py +++ /dev/null @@ -1,32 +0,0 @@ -from django.core.management.base import BaseCommand -from django.core.management import call_command - - -class Command(BaseCommand): - help = "Build and compress static assets for production" - - def add_arguments(self, parser): - parser.add_argument( - "--force", - action="store_true", - help="Force compression even if files exist", - ) - - def handle(self, *args, **options): - self.stdout.write("Building static assets...") - - # Compress CSS and JS files - self.stdout.write("Compressing CSS and JavaScript...") - call_command( - "compress", - force=options.get("force", False), - verbosity=options.get("verbosity", 1), - ) - - # Collect all static files - self.stdout.write("Collecting static files...") - call_command( - "collectstatic", interactive=False, verbosity=options.get("verbosity", 1) - ) - - self.stdout.write(self.style.SUCCESS("Successfully built static assets")) diff --git a/hub/services/management/commands/migrate_images.py b/hub/services/management/commands/migrate_images.py deleted file mode 100644 index ca90dcb..0000000 --- a/hub/services/management/commands/migrate_images.py +++ /dev/null @@ -1,293 +0,0 @@ -from django.core.management.base import BaseCommand -from django.core.files.base import ContentFile -from django.utils.text import slugify -from hub.services.models import ( - ImageLibrary, - Service, - CloudProvider, - ConsultingPartner, - Article, -) -import os -import shutil - - -class Command(BaseCommand): - help = "Migrate existing images to the Image Library" - - def add_arguments(self, parser): - parser.add_argument( - "--dry-run", - action="store_true", - help="Show what would be migrated without actually doing it", - ) - parser.add_argument( - "--force", - action="store_true", - help="Force migration even if images already exist in library", - ) - - def handle(self, *args, **options): - """ - Main command handler to migrate existing images to the library. - """ - dry_run = options["dry_run"] - force = options["force"] - - self.stdout.write( - self.style.SUCCESS( - f'Starting image migration {"(DRY RUN)" if dry_run else ""}' - ) - ) - - # Migrate different types of images - self.migrate_service_logos(dry_run, force) - self.migrate_cloud_provider_logos(dry_run, force) - self.migrate_partner_logos(dry_run, force) - self.migrate_article_images(dry_run, force) - - self.stdout.write( - self.style.SUCCESS( - f'Image migration completed {"(DRY RUN)" if dry_run else ""}' - ) - ) - - def migrate_service_logos(self, dry_run, force): - """ - Migrate service logos to the image library. - """ - self.stdout.write("Migrating service logos...") - - services = Service.objects.filter(logo__isnull=False).exclude(logo="") - - for service in services: - if not service.logo: - continue - - # Check if image already exists in library - existing_image = ImageLibrary.objects.filter( - name=f"{service.name} Logo" - ).first() - - if existing_image and not force: - self.stdout.write( - self.style.WARNING( - f" - Skipping {service.name} logo (already exists)" - ) - ) - continue - - if dry_run: - self.stdout.write( - self.style.SUCCESS(f" - Would migrate: {service.name} logo") - ) - continue - - # Create image library entry - image_lib = ImageLibrary( - name=f"{service.name} Logo", - slug=slugify(f"{service.name}-logo"), - description=f"Logo for {service.name} service", - alt_text=f"{service.name} logo", - category="logo", - tags=f"service, logo, {service.name.lower()}", - ) - - # Copy the image file - if service.logo and os.path.exists(service.logo.path): - with open(service.logo.path, "rb") as f: - image_lib.image.save( - os.path.basename(service.logo.name), - ContentFile(f.read()), - save=True, - ) - - self.stdout.write( - self.style.SUCCESS(f" - Migrated: {service.name} logo") - ) - else: - self.stdout.write( - self.style.ERROR( - f" - Failed to migrate: {service.name} logo (file not found)" - ) - ) - - def migrate_cloud_provider_logos(self, dry_run, force): - """ - Migrate cloud provider logos to the image library. - """ - self.stdout.write("Migrating cloud provider logos...") - - providers = CloudProvider.objects.filter(logo__isnull=False).exclude(logo="") - - for provider in providers: - if not provider.logo: - continue - - # Check if image already exists in library - existing_image = ImageLibrary.objects.filter( - name=f"{provider.name} Logo" - ).first() - - if existing_image and not force: - self.stdout.write( - self.style.WARNING( - f" - Skipping {provider.name} logo (already exists)" - ) - ) - continue - - if dry_run: - self.stdout.write( - self.style.SUCCESS(f" - Would migrate: {provider.name} logo") - ) - continue - - # Create image library entry - image_lib = ImageLibrary( - name=f"{provider.name} Logo", - slug=slugify(f"{provider.name}-logo"), - description=f"Logo for {provider.name} cloud provider", - alt_text=f"{provider.name} logo", - category="logo", - tags=f"cloud, provider, logo, {provider.name.lower()}", - ) - - # Copy the image file - if provider.logo and os.path.exists(provider.logo.path): - with open(provider.logo.path, "rb") as f: - image_lib.image.save( - os.path.basename(provider.logo.name), - ContentFile(f.read()), - save=True, - ) - - self.stdout.write( - self.style.SUCCESS(f" - Migrated: {provider.name} logo") - ) - else: - self.stdout.write( - self.style.ERROR( - f" - Failed to migrate: {provider.name} logo (file not found)" - ) - ) - - def migrate_partner_logos(self, dry_run, force): - """ - Migrate consulting partner logos to the image library. - """ - self.stdout.write("Migrating consulting partner logos...") - - partners = ConsultingPartner.objects.filter(logo__isnull=False).exclude(logo="") - - for partner in partners: - if not partner.logo: - continue - - # Check if image already exists in library - existing_image = ImageLibrary.objects.filter( - name=f"{partner.name} Logo" - ).first() - - if existing_image and not force: - self.stdout.write( - self.style.WARNING( - f" - Skipping {partner.name} logo (already exists)" - ) - ) - continue - - if dry_run: - self.stdout.write( - self.style.SUCCESS(f" - Would migrate: {partner.name} logo") - ) - continue - - # Create image library entry - image_lib = ImageLibrary( - name=f"{partner.name} Logo", - slug=slugify(f"{partner.name}-logo"), - description=f"Logo for {partner.name} consulting partner", - alt_text=f"{partner.name} logo", - category="logo", - tags=f"consulting, partner, logo, {partner.name.lower()}", - ) - - # Copy the image file - if partner.logo and os.path.exists(partner.logo.path): - with open(partner.logo.path, "rb") as f: - image_lib.image.save( - os.path.basename(partner.logo.name), - ContentFile(f.read()), - save=True, - ) - - self.stdout.write( - self.style.SUCCESS(f" - Migrated: {partner.name} logo") - ) - else: - self.stdout.write( - self.style.ERROR( - f" - Failed to migrate: {partner.name} logo (file not found)" - ) - ) - - def migrate_article_images(self, dry_run, force): - """ - Migrate article images to the image library. - """ - self.stdout.write("Migrating article images...") - - articles = Article.objects.filter(image__isnull=False).exclude(image="") - - for article in articles: - if not article.image: - continue - - # Check if image already exists in library - existing_image = ImageLibrary.objects.filter( - name=f"{article.title} Image" - ).first() - - if existing_image and not force: - self.stdout.write( - self.style.WARNING( - f" - Skipping {article.title} image (already exists)" - ) - ) - continue - - if dry_run: - self.stdout.write( - self.style.SUCCESS(f" - Would migrate: {article.title} image") - ) - continue - - # Create image library entry - image_lib = ImageLibrary( - name=f"{article.title} Image", - slug=slugify(f"{article.title}-image"), - description=f"Feature image for article: {article.title}", - alt_text=f"{article.title} feature image", - category="article", - tags=f"article, {article.title.lower()}", - ) - - # Copy the image file - if article.image and os.path.exists(article.image.path): - with open(article.image.path, "rb") as f: - image_lib.image.save( - os.path.basename(article.image.name), - ContentFile(f.read()), - save=True, - ) - - self.stdout.write( - self.style.SUCCESS(f" - Migrated: {article.title} image") - ) - else: - self.stdout.write( - self.style.ERROR( - f" - Failed to migrate: {article.title} image (file not found)" - ) - ) diff --git a/hub/services/management/commands/update_image_properties.py b/hub/services/management/commands/update_image_properties.py deleted file mode 100644 index 965c823..0000000 --- a/hub/services/management/commands/update_image_properties.py +++ /dev/null @@ -1,40 +0,0 @@ -from django.core.management.base import BaseCommand -from hub.services.models.images import ImageLibrary - - -class Command(BaseCommand): - help = "Update image properties for existing images in the library" - - def handle(self, *args, **options): - """ - Update image properties for all images in the library. - This is especially useful after adding SVG support. - """ - images = ImageLibrary.objects.all() - updated_count = 0 - error_count = 0 - - self.stdout.write(f"Updating properties for {images.count()} images...") - - for image in images: - try: - # Force update of image properties - image._update_image_properties() - updated_count += 1 - - # Show progress - if updated_count % 10 == 0: - self.stdout.write(f"Updated {updated_count} images...") - - except Exception as e: - error_count += 1 - self.stdout.write( - self.style.ERROR(f"Error updating {image.name}: {str(e)}") - ) - - self.stdout.write( - self.style.SUCCESS( - f"Successfully updated {updated_count} images. " - f"Errors: {error_count}" - ) - ) diff --git a/hub/services/migrations/0039_article_article_date.py b/hub/services/migrations/0039_article_article_date.py deleted file mode 100644 index 69acc86..0000000 --- a/hub/services/migrations/0039_article_article_date.py +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by Django 5.2 on 2025-07-04 13:48 - -import django.utils.timezone -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("services", "0038_add_plan_ordering_and_best"), - ] - - operations = [ - migrations.AddField( - model_name="article", - name="article_date", - field=models.DateField( - default=django.utils.timezone.now, - help_text="Date of the article publishing", - ), - ), - ] diff --git a/hub/services/migrations/0040_add_image_library.py b/hub/services/migrations/0040_add_image_library.py deleted file mode 100644 index bc06e33..0000000 --- a/hub/services/migrations/0040_add_image_library.py +++ /dev/null @@ -1,144 +0,0 @@ -# Generated by Django 5.2 on 2025-07-04 14:19 - -import django.db.models.deletion -import hub.services.models.base -import hub.services.models.images -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("services", "0039_article_article_date"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name="ImageLibrary", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "name", - models.CharField( - help_text="Descriptive name for the image", max_length=200 - ), - ), - ( - "slug", - models.SlugField( - help_text="URL-friendly version of the name", - max_length=250, - unique=True, - ), - ), - ( - "description", - models.TextField( - blank=True, help_text="Optional description of the image" - ), - ), - ( - "alt_text", - models.CharField( - help_text="Alternative text for accessibility", max_length=255 - ), - ), - ( - "image", - models.ImageField( - help_text="Upload image file (max 1MB)", - upload_to=hub.services.models.images.get_image_upload_path, - validators=[hub.services.models.base.validate_image_size], - ), - ), - ( - "width", - models.PositiveIntegerField( - blank=True, help_text="Image width in pixels", null=True - ), - ), - ( - "height", - models.PositiveIntegerField( - blank=True, help_text="Image height in pixels", null=True - ), - ), - ( - "file_size", - models.PositiveIntegerField( - blank=True, help_text="File size in bytes", null=True - ), - ), - ( - "category", - models.CharField( - choices=[ - ("logo", "Logo"), - ("article", "Article Image"), - ("banner", "Banner"), - ("icon", "Icon"), - ("screenshot", "Screenshot"), - ("photo", "Photo"), - ("other", "Other"), - ], - default="other", - help_text="Category of the image", - max_length=20, - ), - ), - ( - "tags", - models.CharField( - blank=True, - help_text="Comma-separated tags for searching", - max_length=500, - ), - ), - ( - "uploaded_at", - models.DateTimeField( - auto_now_add=True, - help_text="Date and time when image was uploaded", - ), - ), - ( - "updated_at", - models.DateTimeField( - auto_now=True, - help_text="Date and time when image was last updated", - ), - ), - ( - "usage_count", - models.PositiveIntegerField( - default=0, help_text="Number of times this image is referenced" - ), - ), - ( - "uploaded_by", - models.ForeignKey( - blank=True, - help_text="User who uploaded the image", - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to=settings.AUTH_USER_MODEL, - ), - ), - ], - options={ - "verbose_name": "Image", - "verbose_name_plural": "Image Library", - "ordering": ["-uploaded_at"], - }, - ), - ] diff --git a/hub/services/migrations/0041_add_image_library_references.py b/hub/services/migrations/0041_add_image_library_references.py deleted file mode 100644 index 231520b..0000000 --- a/hub/services/migrations/0041_add_image_library_references.py +++ /dev/null @@ -1,57 +0,0 @@ -# Generated by Django 5.2 on 2025-07-04 15:04 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("services", "0040_add_image_library"), - ] - - operations = [ - migrations.AddField( - model_name="cloudprovider", - name="image", - field=models.ForeignKey( - blank=True, - help_text="Select an image from the library", - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to="services.imagelibrary", - ), - ), - migrations.AddField( - model_name="consultingpartner", - name="image", - field=models.ForeignKey( - blank=True, - help_text="Select an image from the library", - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to="services.imagelibrary", - ), - ), - migrations.AddField( - model_name="service", - name="image", - field=models.ForeignKey( - blank=True, - help_text="Select an image from the library", - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to="services.imagelibrary", - ), - ), - migrations.AlterField( - model_name="article", - name="image", - field=models.ImageField( - blank=True, - help_text="Title picture for the article", - null=True, - upload_to="article_images/", - ), - ), - ] diff --git a/hub/services/migrations/0042_fix_image_library_field_name.py b/hub/services/migrations/0042_fix_image_library_field_name.py deleted file mode 100644 index 0996f8b..0000000 --- a/hub/services/migrations/0042_fix_image_library_field_name.py +++ /dev/null @@ -1,74 +0,0 @@ -# Generated by Django 5.2 on 2025-07-04 15:22 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("services", "0041_add_image_library_references"), - ] - - operations = [ - migrations.RemoveField( - model_name="cloudprovider", - name="image", - ), - migrations.RemoveField( - model_name="consultingpartner", - name="image", - ), - migrations.RemoveField( - model_name="service", - name="image", - ), - migrations.AddField( - model_name="article", - name="image_library", - field=models.ForeignKey( - blank=True, - help_text="Select an image from the library", - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="%(class)s_references", - to="services.imagelibrary", - ), - ), - migrations.AddField( - model_name="cloudprovider", - name="image_library", - field=models.ForeignKey( - blank=True, - help_text="Select an image from the library", - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="%(class)s_references", - to="services.imagelibrary", - ), - ), - migrations.AddField( - model_name="consultingpartner", - name="image_library", - field=models.ForeignKey( - blank=True, - help_text="Select an image from the library", - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="%(class)s_references", - to="services.imagelibrary", - ), - ), - migrations.AddField( - model_name="service", - name="image_library", - field=models.ForeignKey( - blank=True, - help_text="Select an image from the library", - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="%(class)s_references", - to="services.imagelibrary", - ), - ), - ] diff --git a/hub/services/migrations/0043_remove_article_image_remove_cloudprovider_logo_and_more.py b/hub/services/migrations/0043_remove_article_image_remove_cloudprovider_logo_and_more.py deleted file mode 100644 index e8d3612..0000000 --- a/hub/services/migrations/0043_remove_article_image_remove_cloudprovider_logo_and_more.py +++ /dev/null @@ -1,29 +0,0 @@ -# Generated by Django 5.2 on 2025-07-08 09:37 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ("services", "0042_fix_image_library_field_name"), - ] - - operations = [ - migrations.RemoveField( - model_name="article", - name="image", - ), - migrations.RemoveField( - model_name="cloudprovider", - name="logo", - ), - migrations.RemoveField( - model_name="consultingpartner", - name="logo", - ), - migrations.RemoveField( - model_name="service", - name="logo", - ), - ] diff --git a/hub/services/migrations/0044_add_svg_support.py b/hub/services/migrations/0044_add_svg_support.py deleted file mode 100644 index 904a04f..0000000 --- a/hub/services/migrations/0044_add_svg_support.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 5.2 on 2025-07-08 10:51 - -import hub.services.models.base -import hub.services.models.images -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("services", "0043_remove_article_image_remove_cloudprovider_logo_and_more"), - ] - - operations = [ - migrations.AlterField( - model_name="imagelibrary", - name="image", - field=models.FileField( - help_text="Upload image file (max 1MB) - supports JPEG, PNG, GIF, WebP, BMP, TIFF, and SVG", - upload_to=hub.services.models.images.get_image_upload_path, - validators=[hub.services.models.base.validate_image_or_svg], - ), - ), - ] diff --git a/hub/services/migrations/0045_add_og_image_to_article.py b/hub/services/migrations/0045_add_og_image_to_article.py deleted file mode 100644 index ff4d13d..0000000 --- a/hub/services/migrations/0045_add_og_image_to_article.py +++ /dev/null @@ -1,25 +0,0 @@ -# Generated by Django 5.2 on 2025-07-08 13:53 - -import hub.services.models.base -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("services", "0044_add_svg_support"), - ] - - operations = [ - migrations.AddField( - model_name="article", - name="og_image", - field=models.ImageField( - blank=True, - help_text="Optional Open Graph image for social sharing (max 1MB). If not provided, the article's main image will be used.", - null=True, - upload_to="article_og_images/", - validators=[hub.services.models.base.validate_image_size], - ), - ), - ] diff --git a/hub/services/migrations/0046_add_partner_category.py b/hub/services/migrations/0046_add_partner_category.py deleted file mode 100644 index 109943e..0000000 --- a/hub/services/migrations/0046_add_partner_category.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 5.2 on 2025-07-11 08:40 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("services", "0045_add_og_image_to_article"), - ] - - operations = [ - migrations.AddField( - model_name="consultingpartner", - name="category", - field=models.CharField( - choices=[("CONSULTING", "Consulting"), ("TRAINING", "Training")], - default="CONSULTING", - help_text="Category of the consulting partner", - max_length=20, - ), - ), - ] diff --git a/hub/services/models/__init__.py b/hub/services/models/__init__.py index 68159a4..b29a71e 100644 --- a/hub/services/models/__init__.py +++ b/hub/services/models/__init__.py @@ -1,7 +1,6 @@ from .articles import * from .base import * from .content import * -from .images import * from .leads import * from .pricing import * from .providers import * diff --git a/hub/services/models/articles.py b/hub/services/models/articles.py index 627bea0..781c54c 100644 --- a/hub/services/models/articles.py +++ b/hub/services/models/articles.py @@ -2,27 +2,27 @@ from django.db import models from django.urls import reverse from django.utils.text import slugify from django.contrib.auth.models import User -from django.utils import timezone -from .base import validate_image_size, get_prose_editor_field +from django_prose_editor.fields import ProseEditorField +from .base import validate_image_size from .services import Service from .providers import CloudProvider, ConsultingPartner -from .images import ImageReference -class Article(ImageReference): +class Article(models.Model): title = models.CharField(max_length=200) slug = models.SlugField(max_length=250, unique=True) excerpt = models.TextField( max_length=500, help_text="Brief description of the article" ) - content = get_prose_editor_field() + content = ProseEditorField() meta_keywords = models.CharField( max_length=255, blank=True, help_text="SEO keywords separated by commas" ) - author = models.ForeignKey(User, on_delete=models.CASCADE, related_name="articles") - article_date = models.DateField( - default=timezone.now, help_text="Date of the article publishing" + image = models.ImageField( + upload_to="article_images/", + help_text="Title picture for the article", ) + author = models.ForeignKey(User, on_delete=models.CASCADE, related_name="articles") # Relations to other models related_service = models.ForeignKey( @@ -50,15 +50,6 @@ class Article(ImageReference): help_text="Link this article to a cloud provider", ) - # Open Graph image for social sharing - og_image = models.ImageField( - upload_to="article_og_images/", - blank=True, - null=True, - validators=[validate_image_size], - help_text="Optional Open Graph image for social sharing (max 1MB). If not provided, the article's main image will be used.", - ) - # Publishing controls is_published = models.BooleanField( default=False, help_text="Only published articles are visible to users" @@ -91,22 +82,6 @@ class Article(ImageReference): def get_absolute_url(self): return reverse("services:article_detail", kwargs={"slug": self.slug}) - @property - def get_image(self): - """Returns the image from the library""" - if self.image_library and self.image_library.image: - return self.image_library.image - return None - - @property - def get_og_image(self): - """Returns the Open Graph image for social sharing""" - # Use specific OG image if available - if self.og_image: - return self.og_image - # Fall back to main article image - return self.get_image - @property def related_to(self): """Returns a string describing what this article is related to""" diff --git a/hub/services/models/base.py b/hub/services/models/base.py index cbbcf52..9a7abce 100644 --- a/hub/services/models/base.py +++ b/hub/services/models/base.py @@ -2,89 +2,12 @@ from django.db import models from django.core.exceptions import ValidationError from django.utils.text import slugify from django_prose_editor.fields import ProseEditorField -import mimetypes -import xml.etree.ElementTree as ET -# Centralized ProseEditorField configuration -PROSE_EDITOR_CONFIG = { - "extensions": { - "Bold": True, - "Italic": True, - "Strike": True, - "Underline": True, - "HardBreak": True, - "Heading": {"levels": [1, 2, 3, 4, 5, 6]}, - "BulletList": True, - "OrderedList": True, - "Blockquote": True, - "Link": True, - "Table": True, - "History": True, - "HTML": True, - "Typographic": True, - }, - "sanitize": True, -} - - -def get_prose_editor_field(**kwargs): - """ - Returns a ProseEditorField with the standard configuration. - Additional kwargs can be passed to override or add field options. - """ - config = PROSE_EDITOR_CONFIG.copy() - config.update(kwargs) - return ProseEditorField(**config) - - -def validate_image_size(value, mb=1): +def validate_image_size(value): filesize = value.size - if filesize > mb * 1024 * 1024: - raise ValidationError(f"Maximum file size is {mb} MB") - - -def validate_image_or_svg(value): - """ - Validate that the uploaded file is either a valid image or SVG file. - """ - # Check file size first - validate_image_size(value) - - # Get the file extension and MIME type - filename = value.name.lower() - mime_type, _ = mimetypes.guess_type(filename) - - # List of allowed image formats - allowed_image_types = [ - "image/jpeg", - "image/png", - "image/gif", - "image/webp", - "image/bmp", - "image/tiff", - "image/svg+xml", - ] - - # Check if it's an SVG file - if filename.endswith(".svg") or mime_type == "image/svg+xml": - try: - # Reset file pointer and read content - value.seek(0) - content = value.read() - value.seek(0) # Reset for later use - - # Try to parse as XML to ensure it's valid SVG - ET.fromstring(content) - return # Valid SVG - except ET.ParseError: - raise ValidationError("Invalid SVG file format") - - # For non-SVG files, check MIME type - if mime_type not in allowed_image_types: - raise ValidationError( - f"Unsupported file type. Allowed types: JPEG, PNG, GIF, WebP, BMP, TIFF, SVG" - ) + if filesize > 1 * 1024 * 1024: # 1MB + raise ValidationError("Maximum file size is 1MB") class Currency(models.TextChoices): @@ -106,11 +29,6 @@ class Unit(models.TextChoices): CPU = "CPU", "vCPU" -class PartnerCategory(models.TextChoices): - CONSULTING = "CONSULTING", "Consulting" - TRAINING = "TRAINING", "Training" - - # This should be a relation, but for now this is good enough :TM: class ManagedServiceProvider(models.TextChoices): VS = "VS", "VSHN" @@ -125,7 +43,7 @@ class ReusableText(models.Model): blank=True, related_name="children", ) - text = get_prose_editor_field() + text = ProseEditorField() class Meta: ordering = ["name"] diff --git a/hub/services/models/content.py b/hub/services/models/content.py index a2f1d7b..6f4eba1 100644 --- a/hub/services/models/content.py +++ b/hub/services/models/content.py @@ -1,10 +1,10 @@ from django.db import models -from .base import get_prose_editor_field +from django_prose_editor.fields import ProseEditorField class WebsiteFaq(models.Model): question = models.CharField(max_length=200) - answer = get_prose_editor_field() + answer = ProseEditorField() order = models.IntegerField(default=0) class Meta: diff --git a/hub/services/models/images.py b/hub/services/models/images.py deleted file mode 100644 index ccaa26e..0000000 --- a/hub/services/models/images.py +++ /dev/null @@ -1,320 +0,0 @@ -import os -import mimetypes -import xml.etree.ElementTree as ET -from django.db import models -from django.utils import timezone -from django.contrib.auth.models import User -from django.core.exceptions import ValidationError -from django.utils.text import slugify -from PIL import Image as PILImage -from .base import validate_image_or_svg - - -def get_image_upload_path(instance, filename): - """ - Generate upload path for images based on the image library structure. - """ - return f"image_library/{filename}" - - -class ImageLibrary(models.Model): - """ - Generic image library model that can be referenced by other models - to avoid duplicate uploads and provide centralized image management. - """ - - # Image metadata - name = models.CharField(max_length=200, help_text="Descriptive name for the image") - slug = models.SlugField( - max_length=250, unique=True, help_text="URL-friendly version of the name" - ) - description = models.TextField( - blank=True, help_text="Optional description of the image" - ) - alt_text = models.CharField( - max_length=255, help_text="Alternative text for accessibility" - ) - - # Image file - image = models.FileField( - upload_to=get_image_upload_path, - validators=[validate_image_or_svg], - help_text="Upload image file (max 1MB) - supports JPEG, PNG, GIF, WebP, BMP, TIFF, and SVG", - ) - - # Image properties (automatically populated) - width = models.PositiveIntegerField( - null=True, blank=True, help_text="Image width in pixels" - ) - height = models.PositiveIntegerField( - null=True, blank=True, help_text="Image height in pixels" - ) - file_size = models.PositiveIntegerField( - null=True, blank=True, help_text="File size in bytes" - ) - - # Categorization - CATEGORY_CHOICES = [ - ("logo", "Logo"), - ("article", "Article Image"), - ("banner", "Banner"), - ("icon", "Icon"), - ("screenshot", "Screenshot"), - ("photo", "Photo"), - ("other", "Other"), - ] - category = models.CharField( - max_length=20, - choices=CATEGORY_CHOICES, - default="other", - help_text="Category of the image", - ) - - # Tags for easier searching - tags = models.CharField( - max_length=500, blank=True, help_text="Comma-separated tags for searching" - ) - - # Metadata - uploaded_by = models.ForeignKey( - User, - on_delete=models.SET_NULL, - null=True, - blank=True, - help_text="User who uploaded the image", - ) - uploaded_at = models.DateTimeField( - auto_now_add=True, help_text="Date and time when image was uploaded" - ) - updated_at = models.DateTimeField( - auto_now=True, help_text="Date and time when image was last updated" - ) - - # Usage tracking - usage_count = models.PositiveIntegerField( - default=0, help_text="Number of times this image is referenced" - ) - - class Meta: - ordering = ["-uploaded_at"] - verbose_name = "Image" - verbose_name_plural = "Image Library" - - def __str__(self): - return self.name - - def save(self, *args, **kwargs): - """ - Override save to automatically populate image properties and slug. - """ - # Generate slug if not provided - if not self.slug: - self.slug = slugify(self.name) - - # Save the model first to get the image file - super().save(*args, **kwargs) - - # Update image properties if image exists - if self.image: - self._update_image_properties() - - def _update_image_properties(self): - """ - Update image properties like width, height, and file size. - """ - try: - # Get file size - self.file_size = self.image.size - - # Check if it's an SVG file - filename = self.image.name.lower() - mime_type, _ = mimetypes.guess_type(filename) - - if filename.endswith(".svg") or mime_type == "image/svg+xml": - # For SVG files, try to extract dimensions from the SVG content - try: - with open(self.image.path, "r", encoding="utf-8") as f: - content = f.read() - - # Parse the SVG to extract width and height - root = ET.fromstring(content) - - # Get width and height attributes - width = root.get("width") - height = root.get("height") - - # Extract numeric values if they exist - if width and height: - # Remove units like 'px', 'em', etc. and convert to int - try: - width_val = int( - float( - width.replace("px", "") - .replace("em", "") - .replace("pt", "") - ) - ) - height_val = int( - float( - height.replace("px", "") - .replace("em", "") - .replace("pt", "") - ) - ) - self.width = width_val - self.height = height_val - except (ValueError, TypeError): - # If we can't parse dimensions, try viewBox - viewbox = root.get("viewBox") - if viewbox: - try: - viewbox_parts = viewbox.split() - if len(viewbox_parts) >= 4: - self.width = int(float(viewbox_parts[2])) - self.height = int(float(viewbox_parts[3])) - except (ValueError, TypeError): - # Default SVG dimensions if we can't parse - self.width = 100 - self.height = 100 - else: - # Check for viewBox if width/height attributes don't exist - viewbox = root.get("viewBox") - if viewbox: - try: - viewbox_parts = viewbox.split() - if len(viewbox_parts) >= 4: - self.width = int(float(viewbox_parts[2])) - self.height = int(float(viewbox_parts[3])) - except (ValueError, TypeError): - self.width = 100 - self.height = 100 - else: - # Default SVG dimensions - self.width = 100 - self.height = 100 - - except (ET.ParseError, FileNotFoundError, UnicodeDecodeError): - # If SVG parsing fails, set default dimensions - self.width = 100 - self.height = 100 - else: - # For raster images, use PIL - with PILImage.open(self.image.path) as img: - self.width = img.width - self.height = img.height - - # Save without calling the full save method to avoid recursion - ImageLibrary.objects.filter(pk=self.pk).update( - width=self.width, height=self.height, file_size=self.file_size - ) - except Exception as e: - # Log error but don't fail the save - print(f"Error updating image properties: {e}") - - def get_file_size_display(self): - """ - Return human-readable file size. - """ - if not self.file_size: - return "Unknown" - - size = self.file_size - for unit in ["B", "KB", "MB", "GB"]: - if size < 1024.0: - return f"{size:.1f} {unit}" - size /= 1024.0 - return f"{size:.1f} TB" - - def get_tags_list(self): - """ - Return tags as a list. - """ - if not self.tags: - return [] - return [tag.strip() for tag in self.tags.split(",") if tag.strip()] - - def increment_usage(self): - """ - Increment usage count when image is referenced. - """ - self.usage_count += 1 - self.save(update_fields=["usage_count"]) - - def decrement_usage(self): - """ - Decrement usage count when reference is removed. - """ - if self.usage_count > 0: - self.usage_count -= 1 - self.save(update_fields=["usage_count"]) - - def is_svg(self): - """ - Check if the uploaded file is an SVG. - """ - if not self.image: - return False - - filename = self.image.name.lower() - mime_type, _ = mimetypes.guess_type(filename) - - return filename.endswith(".svg") or mime_type == "image/svg+xml" - - def get_mime_type(self): - """ - Return the MIME type of the image file. - """ - if not self.image: - return None - - mime_type, _ = mimetypes.guess_type(self.image.name) - return mime_type - - -class ImageReference(models.Model): - """ - Abstract base class for models that want to reference images from the library. - This helps track usage and provides a consistent interface. - """ - - image_library = models.ForeignKey( - ImageLibrary, - on_delete=models.SET_NULL, - null=True, - blank=True, - help_text="Select an image from the library", - related_name="%(class)s_references", - ) - - class Meta: - abstract = True - - def save(self, *args, **kwargs): - """ - Override save to update usage count. - """ - # Track if image changed - old_image = None - if self.pk: - try: - old_instance = self.__class__.objects.get(pk=self.pk) - old_image = old_instance.image_library - except self.__class__.DoesNotExist: - pass - - super().save(*args, **kwargs) - - # Update usage counts - if old_image and old_image != self.image_library: - old_image.decrement_usage() - - if self.image_library and self.image_library != old_image: - self.image_library.increment_usage() - - def delete(self, *args, **kwargs): - """ - Override delete to update usage count. - """ - if self.image_library: - self.image_library.decrement_usage() - super().delete(*args, **kwargs) diff --git a/hub/services/models/providers.py b/hub/services/models/providers.py index 0877111..e3257ea 100644 --- a/hub/services/models/providers.py +++ b/hub/services/models/providers.py @@ -1,20 +1,26 @@ from django.db import models from django.urls import reverse from django.utils.text import slugify +from django_prose_editor.fields import ProseEditorField -from .base import validate_image_size, get_prose_editor_field, PartnerCategory -from .images import ImageReference +from .base import validate_image_size -class CloudProvider(ImageReference): +class CloudProvider(models.Model): name = models.CharField(max_length=100) slug = models.SlugField(unique=True) - description = get_prose_editor_field() + description = ProseEditorField() website = models.URLField() linkedin = models.URLField(blank=True) phone = models.CharField(max_length=25, blank=True, null=True) email = models.EmailField(max_length=254, blank=True, null=True) address = models.TextField(max_length=250, blank=True, null=True) + logo = models.ImageField( + upload_to="cloud_provider_logos/", + validators=[validate_image_size], + null=True, + blank=True, + ) order = models.IntegerField(default=0) is_featured = models.BooleanField(default=False) disable_listing = models.BooleanField(default=False) @@ -33,32 +39,23 @@ class CloudProvider(ImageReference): def get_absolute_url(self): return reverse("services:provider_detail", kwargs={"slug": self.slug}) - @property - def get_logo(self): - """Returns the logo from the library""" - if self.image_library and self.image_library.image: - return self.image_library.image - return None - -class ConsultingPartner(ImageReference): +class ConsultingPartner(models.Model): name = models.CharField(max_length=200) slug = models.SlugField(unique=True) - description = get_prose_editor_field() + description = ProseEditorField() + logo = models.ImageField( + upload_to="partner_logos/", + validators=[validate_image_size], + null=True, + blank=True, + ) website = models.URLField(blank=True) linkedin = models.URLField(blank=True) phone = models.CharField(max_length=25, blank=True, null=True) email = models.EmailField(max_length=254, blank=True, null=True) address = models.TextField(max_length=250, blank=True, null=True) - # Partner category (hardcoded choices as requested) - category = models.CharField( - max_length=20, - choices=PartnerCategory.choices, - default=PartnerCategory.CONSULTING, - help_text="Category of the partner", - ) - services = models.ManyToManyField( "services.Service", related_name="consulting_partners", blank=True ) @@ -77,10 +74,7 @@ class ConsultingPartner(ImageReference): ordering = ["order"] def __str__(self): - return f"{self.name} ({self.get_category_display()})" - - def get_category_display_badge(self): - return f"Servala {self.get_category_display()} Partner" + return self.name def save(self, *args, **kwargs): if not self.slug: @@ -89,10 +83,3 @@ class ConsultingPartner(ImageReference): def get_absolute_url(self): return reverse("services:partner_detail", kwargs={"slug": self.slug}) - - @property - def get_logo(self): - """Returns the logo from the library""" - if self.image_library and self.image_library.image: - return self.image_library.image - return None diff --git a/hub/services/models/services.py b/hub/services/models/services.py index a884949..8b6984d 100644 --- a/hub/services/models/services.py +++ b/hub/services/models/services.py @@ -11,19 +11,23 @@ from .base import ( ManagedServiceProvider, validate_image_size, Currency, - get_prose_editor_field, ) from .providers import CloudProvider -from .images import ImageReference -class Service(ImageReference): +class Service(models.Model): name = models.CharField(max_length=200) slug = models.SlugField(max_length=250, unique=True) - description = get_prose_editor_field() + description = ProseEditorField() tagline = models.TextField(max_length=500, blank=True, null=True) + logo = models.ImageField( + upload_to="service_logos/", + validators=[validate_image_size], + null=True, + blank=True, + ) categories = models.ManyToManyField(Category, related_name="services") - features = get_prose_editor_field() + features = ProseEditorField() is_featured = models.BooleanField(default=False) is_coming_soon = models.BooleanField(default=False) disable_listing = models.BooleanField(default=False) @@ -54,13 +58,6 @@ class Service(ImageReference): def get_absolute_url(self): return reverse("services:service_detail", kwargs={"slug": self.slug}) - @property - def get_logo(self): - """Returns the logo from the library""" - if self.image_library and self.image_library.image: - return self.image_library.image - return None - class ServiceOffering(models.Model): service = models.ForeignKey( @@ -75,7 +72,7 @@ class ServiceOffering(models.Model): cloud_provider = models.ForeignKey( CloudProvider, on_delete=models.CASCADE, related_name="offerings" ) - description = get_prose_editor_field(blank=True, null=True) + description = ProseEditorField(blank=True, null=True) offer_description = models.ForeignKey( ReusableText, on_delete=models.PROTECT, @@ -130,7 +127,7 @@ class PlanPrice(models.Model): class Plan(models.Model): name = models.CharField(max_length=100) - description = get_prose_editor_field(blank=True, null=True) + description = ProseEditorField(blank=True, null=True) plan_description = models.ForeignKey( ReusableText, on_delete=models.PROTECT, diff --git a/hub/services/static/admin/css/image_library.css b/hub/services/static/admin/css/image_library.css deleted file mode 100644 index 06c4af0..0000000 --- a/hub/services/static/admin/css/image_library.css +++ /dev/null @@ -1,109 +0,0 @@ -/* CSS for Image Library Admin */ - -/* Thumbnail styling in list view */ -.image-thumbnail { - border-radius: 4px; - object-fit: cover; -} - -/* Preview styling in detail view */ -.image-preview { - border-radius: 4px; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); -} - -/* Form styling */ -.image-library-form .form-row { - margin-bottom: 15px; -} - -.image-library-form .help { - font-size: 11px; - color: #666; - margin-top: 5px; -} - -/* Usage count styling */ -.usage-count { - font-weight: bold; - color: #0066cc; -} - -.usage-count.high { - color: #cc0000; -} - -/* Category badges */ -.category-badge { - display: inline-block; - padding: 2px 6px; - border-radius: 3px; - font-size: 10px; - font-weight: bold; - text-transform: uppercase; -} - -.category-badge.logo { - background-color: #e8f4f8; - color: #2c6e92; -} - -.category-badge.article { - background-color: #f0f8e8; - color: #5a7c3a; -} - -.category-badge.banner { - background-color: #fef4e8; - color: #d2691e; -} - -.category-badge.icon { - background-color: #f8e8f8; - color: #8b4c8b; -} - -.category-badge.screenshot { - background-color: #e8f8f4; - color: #3a7c5a; -} - -.category-badge.photo { - background-color: #f4e8f8; - color: #923c92; -} - -.category-badge.other { - background-color: #f0f0f0; - color: #666; -} - -/* SVG support */ -.svg-preview { - background: #f5f5f5; - border: 1px solid #ddd; - border-radius: 4px; - padding: 5px; -} - -.svg-preview object { - width: 100%; - height: 100%; -} - -/* SVG thumbnails in admin */ -.image-thumbnail object { - background: #f5f5f5; - border-radius: 4px; -} - -.image-preview object { - background: #f5f5f5; - border-radius: 4px; -} - -/* Category badges */ -.category-badge.svg { - background-color: #f3e8ff; - color: #7c3aed; -} \ No newline at end of file diff --git a/hub/services/static/css/price-calculator.css b/hub/services/static/css/price-calculator.css index a65d8cc..5efe5f6 100644 --- a/hub/services/static/css/price-calculator.css +++ b/hub/services/static/css/price-calculator.css @@ -49,9 +49,9 @@ /* 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.151); - color: rgb(255, 255, 255); + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); white-space: nowrap; font-size: 0.75rem; padding: 0.5rem 0.75rem; diff --git a/hub/services/static/css/servala-main.css b/hub/services/static/css/servala-main.css index 38874aa..a766b6e 100644 --- a/hub/services/static/css/servala-main.css +++ b/hub/services/static/css/servala-main.css @@ -351,8 +351,7 @@ dl { ol ol, ul ul, ol ul, -ul ol, -li p { +ul ol { margin-bottom: 0 } @@ -12535,8 +12534,4 @@ a.btn:focus { .article-content a { font-weight: bold; text-decoration: underline; -} - -.article-content h2 { - margin-top: 3rem; } \ No newline at end of file diff --git a/hub/services/static/img/servala-logo.png b/hub/services/static/img/servala-logo.png index af5de89..af1fd9d 100644 Binary files a/hub/services/static/img/servala-logo.png and b/hub/services/static/img/servala-logo.png differ diff --git a/hub/services/static/js/price-calculator.js b/hub/services/static/js/price-calculator.js index c2c8eb1..9ea66bb 100644 --- a/hub/services/static/js/price-calculator.js +++ b/hub/services/static/js/price-calculator.js @@ -1,82 +1,1841 @@ /** - * Price Calculator Module Loader - * Loads the modular price calculator components - * The original monolithic code has been split into multiple files for better maintainability + * Price Calculator for Service Offerings + * Handles interactive pricing calculation with sliders and plan selection */ -(function () { - 'use strict'; - - // Check if we're on a page that needs the price calculator - if (!document.getElementById('cpuRange')) { - return; +class PriceCalculator { + constructor() { + this.pricingData = null; + this.storagePrice = null; + this.currentOffering = null; + this.selectedConfiguration = null; + this.replicaInfo = null; + this.addonsData = null; + this.init(); } - // Get the current script's directory - const getCurrentScriptPath = () => { - const scripts = document.getElementsByTagName('script'); - // Find the script that contains 'price-calculator.js' - for (let script of scripts) { - if (script.src.includes('price-calculator.js')) { - const scriptPath = script.src; - const lastSlash = scriptPath.lastIndexOf('/'); - return scriptPath.substring(0, lastSlash + 1); + // 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(); + } + + // Setup order button click handler + this.setupOrderButton(); + } + + // Initialize DOM element references + initElements() { + // Calculator controls + this.cpuRange = document.getElementById('cpuRange'); + this.memoryRange = document.getElementById('memoryRange'); + this.storageRange = document.getElementById('storageRange'); + this.instancesRange = document.getElementById('instancesRange'); + this.cpuValue = document.getElementById('cpuValue'); + this.memoryValue = document.getElementById('memoryValue'); + this.storageValue = document.getElementById('storageValue'); + this.instancesValue = document.getElementById('instancesValue'); + this.serviceLevelInputs = document.querySelectorAll('input[name="serviceLevel"]'); + this.planSelect = document.getElementById('planSelect'); + + // Addon elements + this.addonsContainer = document.getElementById('addonsContainer'); + this.addonPricingContainer = document.getElementById('addonPricingContainer'); + + // 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.planInstances = document.getElementById('planInstances'); + this.planServiceLevel = document.getElementById('planServiceLevel'); + this.managedServicePrice = document.getElementById('managedServicePrice'); + this.storagePriceEl = document.getElementById('storagePrice'); + this.storageAmount = document.getElementById('storageAmount'); + this.totalPrice = document.getElementById('totalPrice'); + + // Order button + this.orderButton = document.querySelector('a[href="#order-form"]'); + } + + // Update slider display values (min/max text below sliders) + updateSliderDisplayValues() { + // Update CPU slider display + if (this.cpuRange) { + const cpuMinDisplay = document.getElementById('cpuMinDisplay'); + const cpuMaxDisplay = document.getElementById('cpuMaxDisplay'); + if (cpuMinDisplay) cpuMinDisplay.textContent = this.cpuRange.min; + if (cpuMaxDisplay) cpuMaxDisplay.textContent = this.cpuRange.max; + } + + // Update Memory slider display + if (this.memoryRange) { + const memoryMinDisplay = document.getElementById('memoryMinDisplay'); + const memoryMaxDisplay = document.getElementById('memoryMaxDisplay'); + if (memoryMinDisplay) memoryMinDisplay.textContent = this.memoryRange.min + ' GB'; + if (memoryMaxDisplay) memoryMaxDisplay.textContent = this.memoryRange.max + ' GB'; + } + + // Update Storage slider display + if (this.storageRange) { + const storageMinDisplay = document.getElementById('storageMinDisplay'); + const storageMaxDisplay = document.getElementById('storageMaxDisplay'); + if (storageMinDisplay) storageMinDisplay.textContent = this.storageRange.min + ' GB'; + if (storageMaxDisplay) storageMaxDisplay.textContent = this.storageRange.max + ' GB'; + } + + // Update Instances slider display + if (this.instancesRange) { + const instancesMinDisplay = document.getElementById('instancesMinDisplay'); + const instancesMaxDisplay = document.getElementById('instancesMaxDisplay'); + if (instancesMinDisplay) instancesMinDisplay.textContent = this.instancesRange.min; + if (instancesMaxDisplay) instancesMaxDisplay.textContent = this.instancesRange.max; + } + } + + // Setup order button click handler + setupOrderButton() { + if (this.orderButton) { + this.orderButton.addEventListener('click', (e) => { + e.preventDefault(); + this.handleOrderClick(); + }); + } + } + + // Handle order button click + handleOrderClick() { + if (this.selectedConfiguration) { + // Pre-fill the contact form with configuration details + this.prefillContactForm(); + + // Scroll to the contact form + const contactForm = document.getElementById('order-form'); + if (contactForm) { + contactForm.scrollIntoView({ behavior: 'smooth', block: 'start' }); } } - return '/static/js/'; - }; + } - // Define the modules to load in dependency order - const modules = [ - 'price-calculator/dom-manager.js', - 'price-calculator/pricing-data-manager.js', - 'price-calculator/plan-manager.js', - 'price-calculator/addon-manager.js', - 'price-calculator/ui-manager.js', - 'price-calculator/order-manager.js', - 'price-calculator/price-calculator.js' - ]; + // Pre-fill contact form with selected configuration + prefillContactForm() { + if (!this.selectedConfiguration) return; - // Helper function to load a script - const loadScript = (src) => { - return new Promise((resolve, reject) => { - const script = document.createElement('script'); - script.src = src; - script.onload = resolve; - script.onerror = reject; - document.head.appendChild(script); + const config = this.selectedConfiguration; + + // Create configuration summary message + const configMessage = this.generateConfigurationMessage(config); + + // Find and fill the message textarea in the contact form + const messageField = document.querySelector('#order-form textarea[name="message"]'); + if (messageField) { + messageField.value = configMessage; + } + + // Store configuration details in hidden field + const detailsField = document.querySelector('#order-form input[name="details"]'); + if (detailsField) { + detailsField.value = JSON.stringify({ + plan: config.planName, + vcpus: config.vcpus, + memory: config.memory, + storage: config.storage, + instances: config.instances, + serviceLevel: config.serviceLevel, + totalPrice: config.totalPrice, + addons: config.addons || [] + }); + } + } + + // Generate human-readable configuration message + generateConfigurationMessage(config) { + let message = `I would like to order the following configuration: + +Plan: ${config.planName} (${config.planGroup}) +vCPUs: ${config.vcpus} +Memory: ${config.memory} GB +Storage: ${config.storage} GB +Instances: ${config.instances} +Service Level: ${config.serviceLevel}`; + + // Add addons to the message if any are selected + if (config.addons && config.addons.length > 0) { + message += '\n\nSelected Add-ons:'; + config.addons.forEach(addon => { + message += `\n- ${addon.name}: CHF ${addon.price}`; + }); + } + + message += `\n\nTotal Monthly Price: CHF ${config.totalPrice} + +Please contact me with next steps for ordering this configuration.`; + + return message; + } + + // 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'); + } + + const data = await response.json(); + this.pricingData = data.pricing || data; + + // Extract addons data from the plans - addons are embedded in each plan + this.extractAddonsData(); + + // Extract storage price from the first available plan + this.extractStoragePrice(); + + this.setupEventListeners(); + this.populatePlanDropdown(); + this.updateAddons(); + this.updatePricing(); + } catch (error) { + console.error('Error loading pricing data:', error); + this.showError('Failed to load pricing information'); + } + } + + // Extract replica information and storage price from pricing data + extractStoragePrice() { + if (!this.pricingData) return; + + // Find the first plan with storage pricing data and replica info + for (const groupName of Object.keys(this.pricingData)) { + const group = this.pricingData[groupName]; + for (const serviceLevel of Object.keys(group)) { + const plans = group[serviceLevel]; + if (plans.length > 0 && plans[0].storage_price !== undefined) { + this.storagePrice = parseFloat(plans[0].storage_price); + this.replicaInfo = { + ha_replica_min: plans[0].ha_replica_min || 1, + ha_replica_max: plans[0].ha_replica_max || 1 + }; + return; + } + } + } + } + + // Extract addons data from pricing plans + extractAddonsData() { + if (!this.pricingData) return; + + this.addonsData = {}; + + // Extract addons from the first available plan for each service level + Object.keys(this.pricingData).forEach(groupName => { + const group = this.pricingData[groupName]; + Object.keys(group).forEach(serviceLevel => { + const plans = group[serviceLevel]; + if (plans.length > 0) { + // Use the first plan's addon data for this service level + const plan = plans[0]; + const allAddons = []; + + // Add mandatory addons + if (plan.mandatory_addons) { + plan.mandatory_addons.forEach(addon => { + allAddons.push({ + ...addon, + is_mandatory: true, + addon_type: addon.addon_type === "Base Fee" ? "BASE_FEE" : "UNIT_RATE" + }); + }); + } + + // Add optional addons + if (plan.optional_addons) { + plan.optional_addons.forEach(addon => { + allAddons.push({ + ...addon, + is_mandatory: false, + addon_type: addon.addon_type === "Base Fee" ? "BASE_FEE" : "UNIT_RATE" + }); + }); + } + + this.addonsData[serviceLevel] = allAddons; + } + }); }); - }; + } - // Load modules sequentially - const loadModules = async () => { - const basePath = getCurrentScriptPath(); + // Setup event listeners for calculator controls + setupEventListeners() { + if (!this.cpuRange || !this.memoryRange || !this.storageRange || !this.instancesRange) return; - for (const module of modules) { - try { - await loadScript(basePath + module); - } catch (error) { - console.error(`Failed to load module: ${module}`, error); + // 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(); + }); + + this.instancesRange.addEventListener('input', () => { + this.instancesValue.textContent = this.instancesRange.value; + this.updatePricing(); + }); + + // Service level change listeners + this.serviceLevelInputs.forEach(input => { + input.addEventListener('change', () => { + this.updateInstancesSlider(); + this.populatePlanDropdown(); + this.updateAddons(); + 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; + + // Fade out CPU and Memory sliders since plan is manually selected + this.fadeOutSliders(['cpu', 'memory']); + + // Update addons for the new configuration + this.updateAddons(); + // Update pricing with the selected plan + this.updatePricingWithPlan(selectedPlan); + } else { + // Auto-select mode - reset sliders to default values + this.resetSlidersToDefaults(); + + // Auto-select mode - fade sliders back in + this.fadeInSliders(['cpu', 'memory']); + + // Auto-select mode - update addons and recalculate + this.updateAddons(); + this.updatePricing(); + } + }); + } + + // Initialize instances slider + this.updateInstancesSlider(); + } + + // Update instances slider based on service level and replica info + updateInstancesSlider() { + if (!this.instancesRange || !this.replicaInfo) return; + + const serviceLevel = document.querySelector('input[name="serviceLevel"]:checked')?.value; + + if (serviceLevel === 'Guaranteed Availability') { + // For GA, min is ha_replica_min + this.instancesRange.min = this.replicaInfo.ha_replica_min; + this.instancesRange.value = Math.max(this.instancesRange.value, this.replicaInfo.ha_replica_min); + } else { + // For BE, min is 1 + this.instancesRange.min = 1; + this.instancesRange.value = Math.max(this.instancesRange.value, 1); + } + + // Set max to ha_replica_max + this.instancesRange.max = this.replicaInfo.ha_replica_max; + + // Update display value + this.instancesValue.textContent = this.instancesRange.value; + + // Update the min/max display under the slider using direct IDs + const instancesMinDisplay = document.getElementById('instancesMinDisplay'); + const instancesMaxDisplay = document.getElementById('instancesMaxDisplay'); + + if (instancesMinDisplay) instancesMinDisplay.textContent = this.instancesRange.min; + if (instancesMaxDisplay) instancesMaxDisplay.textContent = this.instancesRange.max; + } + + // 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.updateInstancesSlider(); + this.populatePlanDropdown(); + this.updateAddons(); + 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 will call updateSliderDisplayValues() + 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); + } + + if (maxMemory > 0) { + this.memoryRange.max = Math.ceil(maxMemory); + } + + // Update display values after changing min/max - moved to end and call explicitly + this.updateSliderDisplayValues(); + } + + // 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 = ''; + + // 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`; + this.planSelect.appendChild(option); + }); + } + + // Update addons based on current configuration + updateAddons() { + if (!this.addonsContainer || !this.addonsData) { + // Hide addons section if no container or data + const addonsSection = document.getElementById('addonsSection'); + if (addonsSection) addonsSection.style.display = 'none'; + return; + } + + const serviceLevel = document.querySelector('input[name="serviceLevel"]:checked')?.value; + if (!serviceLevel || !this.addonsData[serviceLevel]) { + // Hide addons section if no service level or no addons for this level + const addonsSection = document.getElementById('addonsSection'); + if (addonsSection) addonsSection.style.display = 'none'; + return; + } + + const addons = this.addonsData[serviceLevel]; + + // Clear existing addons + this.addonsContainer.innerHTML = ''; + + // Show or hide addons section based on availability + const addonsSection = document.getElementById('addonsSection'); + if (addons && addons.length > 0) { + if (addonsSection) addonsSection.style.display = 'block'; + } else { + if (addonsSection) addonsSection.style.display = 'none'; + return; + } + + // Add each addon + addons.forEach(addon => { + const addonElement = document.createElement('div'); + addonElement.className = `addon-item mb-2 p-2 border rounded ${addon.is_mandatory ? 'bg-light' : ''}`; + + addonElement.innerHTML = ` +
+ + +
+ `; + + this.addonsContainer.appendChild(addonElement); + + // Add event listener for optional addons + if (!addon.is_mandatory) { + const checkbox = addonElement.querySelector('.addon-checkbox'); + checkbox.addEventListener('change', () => { + // Update addon prices and recalculate total + this.updateAddonPrices(); + this.updatePricing(); + }); + } + }); + + // Update addon prices + this.updateAddonPrices(); + } // Update addon prices based on current configuration + updateAddonPrices() { + if (!this.addonsContainer) return; + + const cpus = parseInt(this.cpuRange?.value || 2); + const memory = parseInt(this.memoryRange?.value || 4); + const storage = parseInt(this.storageRange?.value || 20); + const instances = parseInt(this.instancesRange?.value || 1); + + // Find the current plan data to get variable_unit for addon calculations + const matchedPlan = this.getCurrentPlan(); + const variableUnit = matchedPlan?.variable_unit || 'CPU'; + const units = variableUnit === 'CPU' ? cpus : memory; + const totalUnits = units * instances; + + const addonCheckboxes = this.addonsContainer.querySelectorAll('.addon-checkbox'); + addonCheckboxes.forEach(checkbox => { + const addon = JSON.parse(checkbox.dataset.addon); + const priceElement = checkbox.parentElement.querySelector('.addon-price-value'); + + let calculatedPrice = 0; + + // Calculate addon price based on type + if (addon.addon_type === 'BASE_FEE') { + // Base fee: price per instance + calculatedPrice = parseFloat(addon.price || 0) * instances; + } else if (addon.addon_type === 'UNIT_RATE') { + // Unit rate: price per unit (CPU or memory) across all instances + calculatedPrice = parseFloat(addon.price_per_unit || 0) * totalUnits; + } + + // Update the display price + if (priceElement) { + priceElement.textContent = calculatedPrice.toFixed(2); + } + + // Store the calculated price for later use in total calculations + checkbox.dataset.calculatedPrice = calculatedPrice.toString(); + }); + } + + // Get current plan based on configuration + getCurrentPlan() { + const cpus = parseInt(this.cpuRange?.value || 2); + const memory = parseInt(this.memoryRange?.value || 4); + const serviceLevel = document.querySelector('input[name="serviceLevel"]:checked')?.value; + + if (this.planSelect?.value) { + return JSON.parse(this.planSelect.value); + } + + return this.findBestMatchingPlan(cpus, memory, serviceLevel); + } + + // 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); + const instances = parseInt(this.instancesRange?.value || 1); + + // Update addon prices first to ensure calculated prices are current + this.updateAddonPrices(); + + this.showPlanDetails(selectedPlan, storage, instances); + this.updateStatusMessage('Plan selected directly!', 'success'); + } + + // Main pricing update function + updatePricing() { + if (!this.pricingData || !this.cpuRange || !this.memoryRange || !this.storageRange || !this.instancesRange) return; + + // Update addon prices first to ensure they're current + this.updateAddonPrices(); + + // 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 instances = parseInt(this.instancesRange.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, instances); + this.updateStatusMessage('Perfect match found!', 'success'); + } else { + this.showNoMatch(); + } + } else { + // Plan is directly selected, update storage pricing + const selectedPlan = JSON.parse(this.planSelect.value); + const storage = parseInt(this.storageRange.value); + const instances = parseInt(this.instancesRange.value); + + // Update addon prices for current configuration + this.updateAddonPrices(); + this.showPlanDetails(selectedPlan, storage, instances); + this.updateStatusMessage('Plan selected directly!', 'success'); + } + } + + // Show plan details in the UI + showPlanDetails(plan, storage, instances) { + 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'; + + // Get current service level + const serviceLevel = document.querySelector('input[name="serviceLevel"]:checked')?.value || 'Best Effort'; + + // 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'; + if (this.planInstances) this.planInstances.textContent = instances; + if (this.planServiceLevel) this.planServiceLevel.textContent = serviceLevel; + + // Ensure addon prices are calculated with current configuration + this.updateAddonPrices(); + + // Calculate pricing using final price from plan data (which already includes mandatory addons) + // plan.final_price = compute_plan_price + sla_price (where sla_price includes mandatory addons) + const managedServicePricePerInstance = parseFloat(plan.final_price); + + // Collect addon information for display and calculation + let mandatoryAddonTotal = 0; + let optionalAddonTotal = 0; + const mandatoryAddons = []; + const selectedOptionalAddons = []; + + if (this.addonsContainer) { + const addonCheckboxes = this.addonsContainer.querySelectorAll('.addon-checkbox'); + addonCheckboxes.forEach(checkbox => { + const addon = JSON.parse(checkbox.dataset.addon); + const calculatedPrice = parseFloat(checkbox.dataset.calculatedPrice || 0); + + if (addon.is_mandatory) { + // Mandatory addons are already included in plan.final_price + // We collect them for display purposes only + mandatoryAddons.push({ + name: addon.name, + price: calculatedPrice.toFixed(2) + }); + } else if (checkbox.checked) { + // Only count checked optional addons + optionalAddonTotal += calculatedPrice; + selectedOptionalAddons.push({ + name: addon.name, + price: calculatedPrice.toFixed(2) + }); + } + }); + } + + const managedServicePrice = managedServicePricePerInstance * instances; + + // Use storage price from plan data or fallback to instance variable + const storageUnitPrice = plan.storage_price !== undefined ? parseFloat(plan.storage_price) : this.storagePrice; + const storagePriceValue = storage * storageUnitPrice * instances; + + // Total price = managed service price (includes mandatory addons) + storage + optional addons + const totalPriceValue = managedServicePrice + storagePriceValue + optionalAddonTotal; + + // Update pricing display + if (this.managedServicePrice) this.managedServicePrice.textContent = managedServicePrice.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); + + // Update addon pricing display + this.updateAddonPricingDisplay(mandatoryAddons, selectedOptionalAddons); + + // Store current configuration for order button + this.selectedConfiguration = { + planName: plan.compute_plan, + planGroup: plan.groupName, + vcpus: plan.vcpus, + memory: plan.ram, + storage: storage, + instances: instances, + serviceLevel: serviceLevel, + totalPrice: totalPriceValue.toFixed(2), + addons: [...mandatoryAddons, ...selectedOptionalAddons] + }; + } + + // Update addon pricing display in the results panel + updateAddonPricingDisplay(mandatoryAddons, selectedOptionalAddons) { + if (!this.addonPricingContainer) return; + + // Clear existing addon pricing display + this.addonPricingContainer.innerHTML = ''; + + // Add mandatory addons to pricing breakdown (for informational purposes only) + if (mandatoryAddons && mandatoryAddons.length > 0) { + // Add a note explaining mandatory addons are included + const mandatoryNote = document.createElement('div'); + mandatoryNote.className = 'text-muted small mb-2'; + mandatoryNote.innerHTML = 'Required add-ons (included in managed service price):'; + this.addonPricingContainer.appendChild(mandatoryNote); + + mandatoryAddons.forEach(addon => { + const addonRow = document.createElement('div'); + addonRow.className = 'd-flex justify-content-between mb-1 ps-3'; + addonRow.innerHTML = ` + ${addon.name} + CHF ${addon.price} + `; + this.addonPricingContainer.appendChild(addonRow); + }); + + // Add separator if there are also optional addons + if (selectedOptionalAddons && selectedOptionalAddons.length > 0) { + const separator = document.createElement('hr'); + separator.className = 'my-2'; + this.addonPricingContainer.appendChild(separator); } } - // Initialize the calculator after modules are loaded - if (window.PriceCalculator) { - window.priceCalculator = new window.PriceCalculator(); - } else { - console.error('PriceCalculator class not found after module loading'); + // Add optional addons to pricing breakdown (these are added to total) + if (selectedOptionalAddons && selectedOptionalAddons.length > 0) { + selectedOptionalAddons.forEach(addon => { + const addonRow = document.createElement('div'); + addonRow.className = 'd-flex justify-content-between mb-2'; + addonRow.innerHTML = ` + Add-on: ${addon.name} + CHF ${addon.price} + `; + this.addonPricingContainer.appendChild(addonRow); + }); } - }; - - // Start loading modules when DOM is ready - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', loadModules); - } else { - loadModules(); } -})(); -// Global function for traditional plan selection (used by template buttons) + // 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 = `${message}`; + this.planMatchStatus.className = `alert ${alertClass} mb-3`; + this.planMatchStatus.style.display = 'block'; + } + + // Show error message + showError(message) { + if (this.planMatchStatus) { + this.planMatchStatus.innerHTML = `${message}`; + this.planMatchStatus.className = 'alert alert-danger mb-3'; + this.planMatchStatus.style.display = 'block'; + } + } + + // Fade out specified sliders when plan is manually selected + fadeOutSliders(sliderTypes) { + sliderTypes.forEach(type => { + const sliderContainer = this.getSliderContainer(type); + if (sliderContainer) { + sliderContainer.style.transition = 'opacity 0.3s ease-in-out'; + sliderContainer.style.opacity = '0.3'; + sliderContainer.style.pointerEvents = 'none'; + + // Add visual indicator that sliders are disabled + const slider = sliderContainer.querySelector('.form-range'); + if (slider) { + slider.style.cursor = 'not-allowed'; + } + } + }); + } + + // Fade in specified sliders when auto-select mode is chosen + fadeInSliders(sliderTypes) { + sliderTypes.forEach(type => { + const sliderContainer = this.getSliderContainer(type); + if (sliderContainer) { + sliderContainer.style.transition = 'opacity 0.3s ease-in-out'; + sliderContainer.style.opacity = '1'; + sliderContainer.style.pointerEvents = 'auto'; + + // Remove visual indicator + const slider = sliderContainer.querySelector('.form-range'); + if (slider) { + slider.style.cursor = 'pointer'; + } + } + }); + } + + // Get slider container element by type + getSliderContainer(type) { + switch (type) { + case 'cpu': + return this.cpuRange?.closest('.mb-4'); + case 'memory': + return this.memoryRange?.closest('.mb-4'); + case 'storage': + return this.storageRange?.closest('.mb-4'); + case 'instances': + return this.instancesRange?.closest('.mb-4'); + default: + return null; + } + } + + // Reset sliders to their default values + resetSlidersToDefaults() { + // Reset CPU slider to default value (2) + if (this.cpuRange) { + this.cpuRange.value = '2'; + if (this.cpuValue) this.cpuValue.textContent = '2'; + } + + // Reset Memory slider to default value (4 GB) + if (this.memoryRange) { + this.memoryRange.value = '4'; + if (this.memoryValue) this.memoryValue.textContent = '4'; + } + + // Reset Storage slider to default value (20 GB) + if (this.storageRange) { + this.storageRange.value = '20'; + if (this.storageValue) this.storageValue.textContent = '20'; + } + + // Reset Instances slider to default value (1) + if (this.instancesRange) { + this.instancesRange.value = '1'; + if (this.instancesValue) this.instancesValue.textContent = '1'; + } + } + + // Setup order button click handler + setupOrderButton() { + if (this.orderButton) { + this.orderButton.addEventListener('click', (e) => { + e.preventDefault(); + this.handleOrderClick(); + }); + } + } + + // Handle order button click + handleOrderClick() { + if (this.selectedConfiguration) { + // Pre-fill the contact form with configuration details + this.prefillContactForm(); + + // Scroll to the contact form + const contactForm = document.getElementById('order-form'); + if (contactForm) { + contactForm.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + } + } + + // Pre-fill contact form with selected configuration + prefillContactForm() { + if (!this.selectedConfiguration) return; + + const config = this.selectedConfiguration; + + // Create configuration summary message + const configMessage = this.generateConfigurationMessage(config); + + // Find and fill the message textarea in the contact form + const messageField = document.querySelector('#order-form textarea[name="message"]'); + if (messageField) { + messageField.value = configMessage; + } + + // Store configuration details in hidden field + const detailsField = document.querySelector('#order-form input[name="details"]'); + if (detailsField) { + detailsField.value = JSON.stringify({ + plan: config.planName, + vcpus: config.vcpus, + memory: config.memory, + storage: config.storage, + instances: config.instances, + serviceLevel: config.serviceLevel, + totalPrice: config.totalPrice, + addons: config.addons || [] + }); + } + } + + // Generate human-readable configuration message + generateConfigurationMessage(config) { + let message = `I would like to order the following configuration: + +Plan: ${config.planName} (${config.planGroup}) +vCPUs: ${config.vcpus} +Memory: ${config.memory} GB +Storage: ${config.storage} GB +Instances: ${config.instances} +Service Level: ${config.serviceLevel}`; + + // Add addons to the message if any are selected + if (config.addons && config.addons.length > 0) { + message += '\n\nSelected Add-ons:'; + config.addons.forEach(addon => { + message += `\n- ${addon.name}: CHF ${addon.price}`; + }); + } + + message += `\n\nTotal Monthly Price: CHF ${config.totalPrice} + +Please contact me with next steps for ordering this configuration.`; + + return message; + } + + // 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'); + } + + const data = await response.json(); + this.pricingData = data.pricing || data; + + // Extract addons data from the plans - addons are embedded in each plan + this.extractAddonsData(); + + // Extract storage price from the first available plan + this.extractStoragePrice(); + + this.setupEventListeners(); + this.populatePlanDropdown(); + this.updateAddons(); + this.updatePricing(); + } catch (error) { + console.error('Error loading pricing data:', error); + this.showError('Failed to load pricing information'); + } + } + + // Extract replica information and storage price from pricing data + extractStoragePrice() { + if (!this.pricingData) return; + + // Find the first plan with storage pricing data and replica info + for (const groupName of Object.keys(this.pricingData)) { + const group = this.pricingData[groupName]; + for (const serviceLevel of Object.keys(group)) { + const plans = group[serviceLevel]; + if (plans.length > 0 && plans[0].storage_price !== undefined) { + this.storagePrice = parseFloat(plans[0].storage_price); + this.replicaInfo = { + ha_replica_min: plans[0].ha_replica_min || 1, + ha_replica_max: plans[0].ha_replica_max || 1 + }; + return; + } + } + } + } + + // Extract addons data from pricing plans + extractAddonsData() { + if (!this.pricingData) return; + + this.addonsData = {}; + + // Extract addons from the first available plan for each service level + Object.keys(this.pricingData).forEach(groupName => { + const group = this.pricingData[groupName]; + Object.keys(group).forEach(serviceLevel => { + const plans = group[serviceLevel]; + if (plans.length > 0) { + // Use the first plan's addon data for this service level + const plan = plans[0]; + const allAddons = []; + + // Add mandatory addons + if (plan.mandatory_addons) { + plan.mandatory_addons.forEach(addon => { + allAddons.push({ + ...addon, + is_mandatory: true, + addon_type: addon.addon_type === "Base Fee" ? "BASE_FEE" : "UNIT_RATE" + }); + }); + } + + // Add optional addons + if (plan.optional_addons) { + plan.optional_addons.forEach(addon => { + allAddons.push({ + ...addon, + is_mandatory: false, + addon_type: addon.addon_type === "Base Fee" ? "BASE_FEE" : "UNIT_RATE" + }); + }); + } + + this.addonsData[serviceLevel] = allAddons; + } + }); + }); + } + + // Setup event listeners for calculator controls + setupEventListeners() { + if (!this.cpuRange || !this.memoryRange || !this.storageRange || !this.instancesRange) 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(); + }); + + this.instancesRange.addEventListener('input', () => { + this.instancesValue.textContent = this.instancesRange.value; + this.updatePricing(); + }); + + // Service level change listeners + this.serviceLevelInputs.forEach(input => { + input.addEventListener('change', () => { + this.updateInstancesSlider(); + this.populatePlanDropdown(); + this.updateAddons(); + 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; + + // Fade out CPU and Memory sliders since plan is manually selected + this.fadeOutSliders(['cpu', 'memory']); + + // Update addons for the new configuration + this.updateAddons(); + // Update pricing with the selected plan + this.updatePricingWithPlan(selectedPlan); + } else { + // Auto-select mode - reset sliders to default values + this.resetSlidersToDefaults(); + + // Auto-select mode - fade sliders back in + this.fadeInSliders(['cpu', 'memory']); + + // Auto-select mode - update addons and recalculate + this.updateAddons(); + this.updatePricing(); + } + }); + } + + // Initialize instances slider + this.updateInstancesSlider(); + } + + // Update instances slider based on service level and replica info + updateInstancesSlider() { + if (!this.instancesRange || !this.replicaInfo) return; + + const serviceLevel = document.querySelector('input[name="serviceLevel"]:checked')?.value; + + if (serviceLevel === 'Guaranteed Availability') { + // For GA, min is ha_replica_min + this.instancesRange.min = this.replicaInfo.ha_replica_min; + this.instancesRange.value = Math.max(this.instancesRange.value, this.replicaInfo.ha_replica_min); + } else { + // For BE, min is 1 + this.instancesRange.min = 1; + this.instancesRange.value = Math.max(this.instancesRange.value, 1); + } + + // Set max to ha_replica_max + this.instancesRange.max = this.replicaInfo.ha_replica_max; + + // Update display value + this.instancesValue.textContent = this.instancesRange.value; + + // Update the min/max display under the slider using direct IDs + const instancesMinDisplay = document.getElementById('instancesMinDisplay'); + const instancesMaxDisplay = document.getElementById('instancesMaxDisplay'); + + if (instancesMinDisplay) instancesMinDisplay.textContent = this.instancesRange.min; + if (instancesMaxDisplay) instancesMaxDisplay.textContent = this.instancesRange.max; + } + + // 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.updateInstancesSlider(); + this.populatePlanDropdown(); + this.updateAddons(); + 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 will call updateSliderDisplayValues() + 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); + } + + if (maxMemory > 0) { + this.memoryRange.max = Math.ceil(maxMemory); + } + + // Update display values after changing min/max - moved to end and call explicitly + this.updateSliderDisplayValues(); + } + + // 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 = ''; + + // 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`; + this.planSelect.appendChild(option); + }); + } + + // Update addons based on current configuration + updateAddons() { + if (!this.addonsContainer || !this.addonsData) { + // Hide addons section if no container or data + const addonsSection = document.getElementById('addonsSection'); + if (addonsSection) addonsSection.style.display = 'none'; + return; + } + + const serviceLevel = document.querySelector('input[name="serviceLevel"]:checked')?.value; + if (!serviceLevel || !this.addonsData[serviceLevel]) { + // Hide addons section if no service level or no addons for this level + const addonsSection = document.getElementById('addonsSection'); + if (addonsSection) addonsSection.style.display = 'none'; + return; + } + + const addons = this.addonsData[serviceLevel]; + + // Clear existing addons + this.addonsContainer.innerHTML = ''; + + // Show or hide addons section based on availability + const addonsSection = document.getElementById('addonsSection'); + if (addons && addons.length > 0) { + if (addonsSection) addonsSection.style.display = 'block'; + } else { + if (addonsSection) addonsSection.style.display = 'none'; + return; + } + + // Add each addon + addons.forEach(addon => { + const addonElement = document.createElement('div'); + addonElement.className = `addon-item mb-2 p-2 border rounded ${addon.is_mandatory ? 'bg-light' : ''}`; + + addonElement.innerHTML = ` +
+ + +
+ `; + + this.addonsContainer.appendChild(addonElement); + + // Add event listener for optional addons + if (!addon.is_mandatory) { + const checkbox = addonElement.querySelector('.addon-checkbox'); + checkbox.addEventListener('change', () => { + // Update addon prices and recalculate total + this.updateAddonPrices(); + this.updatePricing(); + }); + } + }); + + // Update addon prices + this.updateAddonPrices(); + } // Update addon prices based on current configuration + updateAddonPrices() { + if (!this.addonsContainer) return; + + const cpus = parseInt(this.cpuRange?.value || 2); + const memory = parseInt(this.memoryRange?.value || 4); + const storage = parseInt(this.storageRange?.value || 20); + const instances = parseInt(this.instancesRange?.value || 1); + + // Find the current plan data to get variable_unit for addon calculations + const matchedPlan = this.getCurrentPlan(); + const variableUnit = matchedPlan?.variable_unit || 'CPU'; + const units = variableUnit === 'CPU' ? cpus : memory; + const totalUnits = units * instances; + + const addonCheckboxes = this.addonsContainer.querySelectorAll('.addon-checkbox'); + addonCheckboxes.forEach(checkbox => { + const addon = JSON.parse(checkbox.dataset.addon); + const priceElement = checkbox.parentElement.querySelector('.addon-price-value'); + + let calculatedPrice = 0; + + // Calculate addon price based on type + if (addon.addon_type === 'BASE_FEE') { + // Base fee: price per instance + calculatedPrice = parseFloat(addon.price || 0) * instances; + } else if (addon.addon_type === 'UNIT_RATE') { + // Unit rate: price per unit (CPU or memory) across all instances + calculatedPrice = parseFloat(addon.price_per_unit || 0) * totalUnits; + } + + // Update the display price + if (priceElement) { + priceElement.textContent = calculatedPrice.toFixed(2); + } + + // Store the calculated price for later use in total calculations + checkbox.dataset.calculatedPrice = calculatedPrice.toString(); + }); + } + + // Get current plan based on configuration + getCurrentPlan() { + const cpus = parseInt(this.cpuRange?.value || 2); + const memory = parseInt(this.memoryRange?.value || 4); + const serviceLevel = document.querySelector('input[name="serviceLevel"]:checked')?.value; + + if (this.planSelect?.value) { + return JSON.parse(this.planSelect.value); + } + + return this.findBestMatchingPlan(cpus, memory, serviceLevel); + } + + // 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); + const instances = parseInt(this.instancesRange?.value || 1); + + // Update addon prices first to ensure calculated prices are current + this.updateAddonPrices(); + + this.showPlanDetails(selectedPlan, storage, instances); + this.updateStatusMessage('Plan selected directly!', 'success'); + } + + // Main pricing update function + updatePricing() { + if (!this.pricingData || !this.cpuRange || !this.memoryRange || !this.storageRange || !this.instancesRange) return; + + // Update addon prices first to ensure they're current + this.updateAddonPrices(); + + // 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 instances = parseInt(this.instancesRange.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, instances); + this.updateStatusMessage('Perfect match found!', 'success'); + } else { + this.showNoMatch(); + } + } else { + // Plan is directly selected, update storage pricing + const selectedPlan = JSON.parse(this.planSelect.value); + const storage = parseInt(this.storageRange.value); + const instances = parseInt(this.instancesRange.value); + + // Update addon prices for current configuration + this.updateAddonPrices(); + this.showPlanDetails(selectedPlan, storage, instances); + this.updateStatusMessage('Plan selected directly!', 'success'); + } + } + + // Show plan details in the UI + showPlanDetails(plan, storage, instances) { + 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'; + + // Get current service level + const serviceLevel = document.querySelector('input[name="serviceLevel"]:checked')?.value || 'Best Effort'; + + // 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'; + if (this.planInstances) this.planInstances.textContent = instances; + if (this.planServiceLevel) this.planServiceLevel.textContent = serviceLevel; + + // Ensure addon prices are calculated with current configuration + this.updateAddonPrices(); + + // Calculate pricing using final price from plan data (which already includes mandatory addons) + // plan.final_price = compute_plan_price + sla_price (where sla_price includes mandatory addons) + const managedServicePricePerInstance = parseFloat(plan.final_price); + + // Collect addon information for display and calculation + let mandatoryAddonTotal = 0; + let optionalAddonTotal = 0; + const mandatoryAddons = []; + const selectedOptionalAddons = []; + + if (this.addonsContainer) { + const addonCheckboxes = this.addonsContainer.querySelectorAll('.addon-checkbox'); + addonCheckboxes.forEach(checkbox => { + const addon = JSON.parse(checkbox.dataset.addon); + const calculatedPrice = parseFloat(checkbox.dataset.calculatedPrice || 0); + + if (addon.is_mandatory) { + // Mandatory addons are already included in plan.final_price + // We collect them for display purposes only + mandatoryAddons.push({ + name: addon.name, + price: calculatedPrice.toFixed(2) + }); + } else if (checkbox.checked) { + // Only count checked optional addons + optionalAddonTotal += calculatedPrice; + selectedOptionalAddons.push({ + name: addon.name, + price: calculatedPrice.toFixed(2) + }); + } + }); + } + + const managedServicePrice = managedServicePricePerInstance * instances; + + // Use storage price from plan data or fallback to instance variable + const storageUnitPrice = plan.storage_price !== undefined ? parseFloat(plan.storage_price) : this.storagePrice; + const storagePriceValue = storage * storageUnitPrice * instances; + + // Total price = managed service price (includes mandatory addons) + storage + optional addons + const totalPriceValue = managedServicePrice + storagePriceValue + optionalAddonTotal; + + // Update pricing display + if (this.managedServicePrice) this.managedServicePrice.textContent = managedServicePrice.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); + + // Update addon pricing display + this.updateAddonPricingDisplay(mandatoryAddons, selectedOptionalAddons); + + // Store current configuration for order button + this.selectedConfiguration = { + planName: plan.compute_plan, + planGroup: plan.groupName, + vcpus: plan.vcpus, + memory: plan.ram, + storage: storage, + instances: instances, + serviceLevel: serviceLevel, + totalPrice: totalPriceValue.toFixed(2), + addons: [...mandatoryAddons, ...selectedOptionalAddons] + }; + } + + // Update addon pricing display in the results panel + updateAddonPricingDisplay(mandatoryAddons, selectedOptionalAddons) { + if (!this.addonPricingContainer) return; + + // Clear existing addon pricing display + this.addonPricingContainer.innerHTML = ''; + + // Add mandatory addons to pricing breakdown (for informational purposes only) + if (mandatoryAddons && mandatoryAddons.length > 0) { + // Add a note explaining mandatory addons are included + const mandatoryNote = document.createElement('div'); + mandatoryNote.className = 'text-muted small mb-2'; + mandatoryNote.innerHTML = 'Required add-ons (included in managed service price):'; + this.addonPricingContainer.appendChild(mandatoryNote); + + mandatoryAddons.forEach(addon => { + const addonRow = document.createElement('div'); + addonRow.className = 'd-flex justify-content-between mb-1 ps-3'; + addonRow.innerHTML = ` + ${addon.name} + CHF ${addon.price} + `; + this.addonPricingContainer.appendChild(addonRow); + }); + + // Add separator if there are also optional addons + if (selectedOptionalAddons && selectedOptionalAddons.length > 0) { + const separator = document.createElement('hr'); + separator.className = 'my-2'; + this.addonPricingContainer.appendChild(separator); + } + } + + // Add optional addons to pricing breakdown (these are added to total) + if (selectedOptionalAddons && selectedOptionalAddons.length > 0) { + selectedOptionalAddons.forEach(addon => { + const addonRow = document.createElement('div'); + addonRow.className = 'd-flex justify-content-between mb-2'; + addonRow.innerHTML = ` + Add-on: ${addon.name} + CHF ${addon.price} + `; + this.addonPricingContainer.appendChild(addonRow); + }); + } + } + + // 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 = `${message}`; + this.planMatchStatus.className = `alert ${alertClass} mb-3`; + this.planMatchStatus.style.display = 'block'; + } + + // Show error message + showError(message) { + if (this.planMatchStatus) { + this.planMatchStatus.innerHTML = `${message}`; + this.planMatchStatus.className = 'alert alert-danger mb-3'; + this.planMatchStatus.style.display = 'block'; + } + } + + // Fade out specified sliders when plan is manually selected + fadeOutSliders(sliderTypes) { + sliderTypes.forEach(type => { + const sliderContainer = this.getSliderContainer(type); + if (sliderContainer) { + sliderContainer.style.transition = 'opacity 0.3s ease-in-out'; + sliderContainer.style.opacity = '0.3'; + sliderContainer.style.pointerEvents = 'none'; + + // Add visual indicator that sliders are disabled + const slider = sliderContainer.querySelector('.form-range'); + if (slider) { + slider.style.cursor = 'not-allowed'; + } + } + }); + } + + // Fade in specified sliders when auto-select mode is chosen + fadeInSliders(sliderTypes) { + sliderTypes.forEach(type => { + const sliderContainer = this.getSliderContainer(type); + if (sliderContainer) { + sliderContainer.style.transition = 'opacity 0.3s ease-in-out'; + sliderContainer.style.opacity = '1'; + sliderContainer.style.pointerEvents = 'auto'; + + // Remove visual indicator + const slider = sliderContainer.querySelector('.form-range'); + if (slider) { + slider.style.cursor = 'pointer'; + } + } + }); + } + + // Get slider container element by type + getSliderContainer(type) { + switch (type) { + case 'cpu': + return this.cpuRange?.closest('.mb-4'); + case 'memory': + return this.memoryRange?.closest('.mb-4'); + case 'storage': + return this.storageRange?.closest('.mb-4'); + case 'instances': + return this.instancesRange?.closest('.mb-4'); + default: + return null; + } + } +} + +// 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(); + } +}); + function selectPlan(element) { const planId = element.getAttribute('data-plan-id'); const planName = element.getAttribute('data-plan-name'); diff --git a/hub/services/static/js/price-calculator/addon-manager.js b/hub/services/static/js/price-calculator/addon-manager.js deleted file mode 100644 index daf4735..0000000 --- a/hub/services/static/js/price-calculator/addon-manager.js +++ /dev/null @@ -1,176 +0,0 @@ -/** - * Addon Manager - Handles addon functionality - */ -class AddonManager { - constructor(pricingDataManager) { - this.pricingDataManager = pricingDataManager; - } - - // Update addons based on current configuration - updateAddons(domManager) { - const addonsContainer = domManager.get('addonsContainer'); - const addonsData = this.pricingDataManager.getAddonsData(); - - if (!addonsContainer || !addonsData) { - // Hide addons section if no container or data - const addonsSection = document.getElementById('addonsSection'); - if (addonsSection) addonsSection.style.display = 'none'; - return; - } - - const serviceLevel = domManager.getSelectedServiceLevel(); - if (!serviceLevel || !addonsData[serviceLevel]) { - // Hide addons section if no service level or no addons for this level - const addonsSection = document.getElementById('addonsSection'); - if (addonsSection) addonsSection.style.display = 'none'; - return; - } - - const addons = addonsData[serviceLevel]; - - // Clear existing addons - addonsContainer.innerHTML = ''; - - // Show or hide addons section based on availability - const addonsSection = document.getElementById('addonsSection'); - if (addons && addons.length > 0) { - if (addonsSection) addonsSection.style.display = 'block'; - } else { - if (addonsSection) addonsSection.style.display = 'none'; - return; - } - - // Add each addon - addons.forEach(addon => { - const addonElement = document.createElement('div'); - addonElement.className = `addon-item mb-2 p-2 border rounded ${addon.is_mandatory ? 'bg-light' : ''}`; - - addonElement.innerHTML = ` -
- - -
- `; - - addonsContainer.appendChild(addonElement); - - // Add event listener for optional addons - if (!addon.is_mandatory) { - const checkbox = addonElement.querySelector('.addon-checkbox'); - checkbox.addEventListener('change', () => { - // Update addon prices and recalculate total - this.updateAddonPrices(domManager); - // Trigger pricing update through custom event - window.dispatchEvent(new CustomEvent('addon-changed')); - }); - } - }); - - // Update addon prices - this.updateAddonPrices(domManager); - } - - // Update addon prices based on current configuration - updateAddonPrices(domManager, planManager) { - const addonsContainer = domManager.get('addonsContainer'); - if (!addonsContainer) return; - - const config = domManager.getCurrentConfiguration(); - - // Find the current plan data to get variable_unit for addon calculations - const matchedPlan = planManager ? planManager.getCurrentPlan(domManager) : null; - const variableUnit = matchedPlan?.variable_unit || 'CPU'; - const units = variableUnit === 'CPU' ? config.cpus : config.memory; - const totalUnits = units * config.instances; - - const addonCheckboxes = addonsContainer.querySelectorAll('.addon-checkbox'); - addonCheckboxes.forEach(checkbox => { - const addon = JSON.parse(checkbox.dataset.addon); - const priceElement = checkbox.parentElement.querySelector('.addon-price-value'); - - let calculatedPrice = 0; - - // Calculate addon price based on type - if (addon.addon_type === 'BASE_FEE') { - // Base fee: price per instance - calculatedPrice = parseFloat(addon.price || 0) * config.instances; - } else if (addon.addon_type === 'UNIT_RATE') { - // Unit rate: price per unit (CPU or memory) across all instances - calculatedPrice = parseFloat(addon.price_per_unit || 0) * totalUnits; - } - - // Update the display price - if (priceElement) { - priceElement.textContent = calculatedPrice.toFixed(2); - } - - // Store the calculated price for later use in total calculations - checkbox.dataset.calculatedPrice = calculatedPrice.toString(); - }); - } - - // Get selected addons with their calculated prices - getSelectedAddons(domManager) { - const addonsContainer = domManager.get('addonsContainer'); - if (!addonsContainer) return { mandatory: [], optional: [] }; - - const mandatoryAddons = []; - const selectedOptionalAddons = []; - - const addonCheckboxes = addonsContainer.querySelectorAll('.addon-checkbox'); - addonCheckboxes.forEach(checkbox => { - const addon = JSON.parse(checkbox.dataset.addon); - const calculatedPrice = parseFloat(checkbox.dataset.calculatedPrice || 0); - - if (addon.is_mandatory) { - mandatoryAddons.push({ - name: addon.name, - price: calculatedPrice.toFixed(2) - }); - } else if (checkbox.checked) { - selectedOptionalAddons.push({ - name: addon.name, - price: calculatedPrice.toFixed(2) - }); - } - }); - - return { - mandatory: mandatoryAddons, - optional: selectedOptionalAddons - }; - } - - // Calculate total optional addon price - calculateOptionalAddonTotal(domManager) { - const addonsContainer = domManager.get('addonsContainer'); - if (!addonsContainer) return 0; - - let total = 0; - const addonCheckboxes = addonsContainer.querySelectorAll('.addon-checkbox'); - - addonCheckboxes.forEach(checkbox => { - const addon = JSON.parse(checkbox.dataset.addon); - if (!addon.is_mandatory && checkbox.checked) { - const calculatedPrice = parseFloat(checkbox.dataset.calculatedPrice || 0); - total += calculatedPrice; - } - }); - - return total; - } -} - -// Export for use in other modules -window.AddonManager = AddonManager; diff --git a/hub/services/static/js/price-calculator/dom-manager.js b/hub/services/static/js/price-calculator/dom-manager.js deleted file mode 100644 index 42c4626..0000000 --- a/hub/services/static/js/price-calculator/dom-manager.js +++ /dev/null @@ -1,160 +0,0 @@ -/** - * DOM Manager - Handles DOM element references and basic manipulation - */ -class DOMManager { - constructor() { - this.elements = {}; - this.initElements(); - } - - // Initialize DOM element references - initElements() { - // Calculator controls - this.elements.cpuRange = document.getElementById('cpuRange'); - this.elements.memoryRange = document.getElementById('memoryRange'); - this.elements.storageRange = document.getElementById('storageRange'); - this.elements.instancesRange = document.getElementById('instancesRange'); - this.elements.cpuValue = document.getElementById('cpuValue'); - this.elements.memoryValue = document.getElementById('memoryValue'); - this.elements.storageValue = document.getElementById('storageValue'); - this.elements.instancesValue = document.getElementById('instancesValue'); - this.elements.serviceLevelInputs = document.querySelectorAll('input[name="serviceLevel"]'); - this.elements.planSelect = document.getElementById('planSelect'); - - // Addon elements - this.elements.addonsContainer = document.getElementById('addonsContainer'); - this.elements.addonPricingContainer = document.getElementById('addonPricingContainer'); - this.elements.managedServiceIncludesContainer = document.getElementById('managedServiceIncludesContainer'); - - // Result display elements - this.elements.planMatchStatus = document.getElementById('planMatchStatus'); - this.elements.selectedPlanDetails = document.getElementById('selectedPlanDetails'); - this.elements.noMatchFound = document.getElementById('noMatchFound'); - - // Plan detail elements - this.elements.planGroup = document.getElementById('planGroup'); - this.elements.planName = document.getElementById('planName'); - this.elements.planDescription = document.getElementById('planDescription'); - this.elements.planCpus = document.getElementById('planCpus'); - this.elements.planMemory = document.getElementById('planMemory'); - this.elements.planInstances = document.getElementById('planInstances'); - this.elements.planServiceLevel = document.getElementById('planServiceLevel'); - this.elements.managedServicePrice = document.getElementById('managedServicePrice'); - this.elements.storagePriceEl = document.getElementById('storagePrice'); - this.elements.storageAmount = document.getElementById('storageAmount'); - this.elements.totalPrice = document.getElementById('totalPrice'); - - // Order button - this.elements.orderButton = document.querySelector('a[href="#order-form"]'); - - // Service level group - this.elements.serviceLevelGroup = document.getElementById('serviceLevelGroup'); - } - - // Get element by key - get(key) { - return this.elements[key]; - } - - // Check if element exists - has(key) { - return this.elements[key] && this.elements[key] !== null; - } - - // Update slider display values (min/max text below sliders) - updateSliderDisplayValues() { - // Update CPU slider display - if (this.elements.cpuRange) { - const cpuMinDisplay = document.getElementById('cpuMinDisplay'); - const cpuMaxDisplay = document.getElementById('cpuMaxDisplay'); - if (cpuMinDisplay) cpuMinDisplay.textContent = this.elements.cpuRange.min; - if (cpuMaxDisplay) cpuMaxDisplay.textContent = this.elements.cpuRange.max; - } - - // Update Memory slider display - if (this.elements.memoryRange) { - const memoryMinDisplay = document.getElementById('memoryMinDisplay'); - const memoryMaxDisplay = document.getElementById('memoryMaxDisplay'); - if (memoryMinDisplay) memoryMinDisplay.textContent = this.elements.memoryRange.min; - if (memoryMaxDisplay) memoryMaxDisplay.textContent = this.elements.memoryRange.max; - } - - // Update Storage slider display - if (this.elements.storageRange) { - const storageMinDisplay = document.getElementById('storageMinDisplay'); - const storageMaxDisplay = document.getElementById('storageMaxDisplay'); - if (storageMinDisplay) storageMinDisplay.textContent = this.elements.storageRange.min; - if (storageMaxDisplay) storageMaxDisplay.textContent = this.elements.storageRange.max; - } - - // Update Instances slider display - if (this.elements.instancesRange) { - const instancesMinDisplay = document.getElementById('instancesMinDisplay'); - const instancesMaxDisplay = document.getElementById('instancesMaxDisplay'); - if (instancesMinDisplay) instancesMinDisplay.textContent = this.elements.instancesRange.min; - if (instancesMaxDisplay) instancesMaxDisplay.textContent = this.elements.instancesRange.max; - } - } - - // Get slider container element by type - getSliderContainer(type) { - switch (type) { - case 'cpu': - return this.elements.cpuRange?.closest('.mb-4'); - case 'memory': - return this.elements.memoryRange?.closest('.mb-4'); - case 'storage': - return this.elements.storageRange?.closest('.mb-4'); - case 'instances': - return this.elements.instancesRange?.closest('.mb-4'); - default: - return null; - } - } - - // Reset sliders to their default values - resetSlidersToDefaults() { - // Reset CPU slider to default value (0.5 vCPUs) - if (this.elements.cpuRange) { - this.elements.cpuRange.value = '0.5'; - if (this.elements.cpuValue) this.elements.cpuValue.textContent = '0.5'; - } - - // Reset Memory slider to default value (1 GB) - if (this.elements.memoryRange) { - this.elements.memoryRange.value = '1'; - if (this.elements.memoryValue) this.elements.memoryValue.textContent = '1'; - } - - // Reset Storage slider to default value (20 GB) - if (this.elements.storageRange) { - this.elements.storageRange.value = '20'; - if (this.elements.storageValue) this.elements.storageValue.textContent = '20'; - } - - // Reset Instances slider to default value (1) - if (this.elements.instancesRange) { - this.elements.instancesRange.value = '1'; - if (this.elements.instancesValue) this.elements.instancesValue.textContent = '1'; - } - } - - // Get current selected service level - getSelectedServiceLevel() { - return document.querySelector('input[name="serviceLevel"]:checked')?.value; - } - - // Get current configuration values - getCurrentConfiguration() { - return { - cpus: parseFloat(this.elements.cpuRange?.value || 0.5), - memory: parseFloat(this.elements.memoryRange?.value || 1), - storage: parseInt(this.elements.storageRange?.value || 20), - instances: parseInt(this.elements.instancesRange?.value || 1), - serviceLevel: this.getSelectedServiceLevel() - }; - } -} - -// Export for use in other modules -window.DOMManager = DOMManager; diff --git a/hub/services/static/js/price-calculator/order-manager.js b/hub/services/static/js/price-calculator/order-manager.js deleted file mode 100644 index 99ea5a8..0000000 --- a/hub/services/static/js/price-calculator/order-manager.js +++ /dev/null @@ -1,113 +0,0 @@ -/** - * Order Manager - Handles order form functionality - */ -class OrderManager { - constructor() { - this.selectedConfiguration = null; - } - - // Setup order button click handler - setupOrderButton(domManager) { - const orderButton = domManager.get('orderButton'); - if (orderButton) { - orderButton.addEventListener('click', (e) => { - e.preventDefault(); - this.handleOrderClick(); - }); - } - } - - // Handle order button click - handleOrderClick() { - if (this.selectedConfiguration) { - // Pre-fill the contact form with configuration details - this.prefillContactForm(); - - // Scroll to the contact form - const contactForm = document.getElementById('order-form'); - if (contactForm) { - contactForm.scrollIntoView({ behavior: 'smooth', block: 'start' }); - } - } - } - - // Pre-fill contact form with selected configuration - prefillContactForm() { - if (!this.selectedConfiguration) return; - - const config = this.selectedConfiguration; - - // Create configuration summary message - const configMessage = this.generateConfigurationMessage(config); - - // Find and fill the message textarea in the contact form - const messageField = document.querySelector('#order-form textarea[name="message"]'); - if (messageField) { - messageField.value = configMessage; - } - - // Store configuration details in hidden field - const detailsField = document.querySelector('#order-form input[name="details"]'); - if (detailsField) { - detailsField.value = JSON.stringify({ - plan: config.planName, - vcpus: config.vcpus, - memory: config.memory, - storage: config.storage, - instances: config.instances, - serviceLevel: config.serviceLevel, - totalPrice: config.totalPrice, - addons: config.addons || [] - }); - } - } - - // Generate human-readable configuration message - generateConfigurationMessage(config) { - let message = `I would like to order the following configuration: - -Plan: ${config.planName} (${config.planGroup}) -vCPUs: ${config.vcpus} -Memory: ${config.memory} GB -Storage: ${config.storage} GB -Instances: ${config.instances} -Service Level: ${config.serviceLevel}`; - - // Add addons to the message if any are selected - if (config.addons && config.addons.length > 0) { - message += '\n\nSelected Add-ons:'; - config.addons.forEach(addon => { - message += `\n- ${addon.name}: CHF ${addon.price}`; - }); - } - - message += `\n\nTotal Monthly Price: CHF ${config.totalPrice} - -Please contact me with next steps for ordering this configuration.`; - - return message; - } - - // Store current configuration for order button - storeConfiguration(plan, config, serviceLevel, totalPrice, addons) { - this.selectedConfiguration = { - planName: plan.compute_plan, - planGroup: plan.groupName, - vcpus: plan.vcpus, - memory: plan.ram, - storage: config.storage, - instances: config.instances, - serviceLevel: serviceLevel, - totalPrice: totalPrice, - addons: addons - }; - } - - // Get stored configuration - getStoredConfiguration() { - return this.selectedConfiguration; - } -} - -// Export for use in other modules -window.OrderManager = OrderManager; diff --git a/hub/services/static/js/price-calculator/plan-manager.js b/hub/services/static/js/price-calculator/plan-manager.js deleted file mode 100644 index 9abdc0f..0000000 --- a/hub/services/static/js/price-calculator/plan-manager.js +++ /dev/null @@ -1,104 +0,0 @@ -/** - * Plan Manager - Handles plan selection and matching logic - */ -class PlanManager { - constructor(pricingDataManager) { - this.pricingDataManager = pricingDataManager; - } - - // Find best matching plan based on requirements - findBestMatchingPlan(cpus, memory, serviceLevel) { - const pricingData = this.pricingDataManager.getPricingData(); - - if (!pricingData) return null; - - let bestMatch = null; - let bestScore = Infinity; - - // Iterate through all groups and service levels - Object.keys(pricingData).forEach(groupName => { - const group = pricingData[groupName]; - - if (group[serviceLevel]) { - group[serviceLevel].forEach(plan => { - const planCpus = parseFloat(plan.vcpus); - const planMemory = parseFloat(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; - } - - // Get current plan based on configuration - getCurrentPlan(domManager) { - const config = domManager.getCurrentConfiguration(); - const planSelect = domManager.get('planSelect'); - - if (planSelect?.value) { - return JSON.parse(planSelect.value); - } - - return this.findBestMatchingPlan(config.cpus, config.memory, config.serviceLevel); - } - - // Populate plan dropdown based on selected service level - populatePlanDropdown(domManager) { - const planSelect = domManager.get('planSelect'); - if (!planSelect) return; - - const serviceLevel = domManager.getSelectedServiceLevel(); - if (!serviceLevel) return; - - // Clear existing options - planSelect.innerHTML = ''; - - // Get plans for the selected service level - const availablePlans = this.pricingDataManager.getPlansForServiceLevel(serviceLevel); - - // 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`; - planSelect.appendChild(option); - }); - } - - // Update sliders to match selected plan - updateSlidersForPlan(plan, domManager) { - const cpuRange = domManager.get('cpuRange'); - const memoryRange = domManager.get('memoryRange'); - const cpuValue = domManager.get('cpuValue'); - const memoryValue = domManager.get('memoryValue'); - - if (cpuRange && cpuValue) { - cpuRange.value = plan.vcpus; - cpuValue.textContent = plan.vcpus; - } - - if (memoryRange && memoryValue) { - memoryRange.value = plan.ram; - memoryValue.textContent = plan.ram; - } - } -} - -// Export for use in other modules -window.PlanManager = PlanManager; diff --git a/hub/services/static/js/price-calculator/price-calculator.js b/hub/services/static/js/price-calculator/price-calculator.js deleted file mode 100644 index ecde2ac..0000000 --- a/hub/services/static/js/price-calculator/price-calculator.js +++ /dev/null @@ -1,252 +0,0 @@ -/** - * Price Calculator - Main orchestrator class - * Coordinates all the different managers to provide pricing calculation functionality - */ -class PriceCalculator { - constructor() { - // Initialize managers - this.domManager = new DOMManager(); - this.currentOffering = this.extractOfferingFromURL(); - this.pricingDataManager = new PricingDataManager(this.currentOffering); - this.planManager = new PlanManager(this.pricingDataManager); - this.addonManager = new AddonManager(this.pricingDataManager); - this.uiManager = new UIManager(); - this.orderManager = new OrderManager(); - - // Initialize the calculator - this.init(); - } - - // Extract offering info from URL - extractOfferingFromURL() { - const pathParts = window.location.pathname.split('/'); - if (pathParts.length >= 4 && pathParts[1] === 'offering') { - return { - provider_slug: pathParts[2], - service_slug: pathParts[3] - }; - } - return null; - } - - // Initialize calculator - async init() { - try { - // Load pricing data and setup calculator - if (this.currentOffering) { - await this.pricingDataManager.loadPricingData(); - - this.setupEventListeners(); - this.setupUI(); - this.orderManager.setupOrderButton(this.domManager); - this.updateCalculator(); - } else { - console.warn('No current offering found, calculator not initialized'); - } - } catch (error) { - console.error('Error initializing price calculator:', error); - this.uiManager.showError(this.domManager, 'Failed to load pricing information'); - } - } - - // Setup initial UI components - setupUI() { - // Setup service levels based on available data - this.uiManager.setupServiceLevels(this.domManager, this.pricingDataManager); - - // Calculate and set slider maximums - this.uiManager.updateSliderMaximums(this.domManager, this.pricingDataManager); - - // Populate plan dropdown - this.planManager.populatePlanDropdown(this.domManager); - - // Initialize instances slider - this.uiManager.updateInstancesSlider(this.domManager, this.pricingDataManager); - } - - // Setup event listeners for calculator controls - setupEventListeners() { - const cpuRange = this.domManager.get('cpuRange'); - const memoryRange = this.domManager.get('memoryRange'); - const storageRange = this.domManager.get('storageRange'); - const instancesRange = this.domManager.get('instancesRange'); - - if (!cpuRange || !memoryRange || !storageRange || !instancesRange) return; - - // Slider event listeners - cpuRange.addEventListener('input', () => { - this.domManager.get('cpuValue').textContent = cpuRange.value; - this.updatePricing(); - }); - - memoryRange.addEventListener('input', () => { - this.domManager.get('memoryValue').textContent = memoryRange.value; - this.updatePricing(); - }); - - storageRange.addEventListener('input', () => { - this.domManager.get('storageValue').textContent = storageRange.value; - this.updatePricing(); - }); - - instancesRange.addEventListener('input', () => { - this.domManager.get('instancesValue').textContent = instancesRange.value; - this.updatePricing(); - }); - - // Service level change listeners - const serviceLevelInputs = this.domManager.get('serviceLevelInputs'); - serviceLevelInputs.forEach(input => { - input.addEventListener('change', () => { - this.uiManager.updateInstancesSlider(this.domManager, this.pricingDataManager); - this.planManager.populatePlanDropdown(this.domManager); - this.addonManager.updateAddons(this.domManager); - this.updatePricing(); - }); - }); - - // Plan selection listener - const planSelect = this.domManager.get('planSelect'); - if (planSelect) { - planSelect.addEventListener('change', () => { - if (planSelect.value) { - const selectedPlan = JSON.parse(planSelect.value); - - // Update sliders to match selected plan - this.planManager.updateSlidersForPlan(selectedPlan, this.domManager); - - // Fade out CPU and Memory sliders since plan is manually selected - this.uiManager.fadeOutSliders(this.domManager, ['cpu', 'memory']); - - // Update addons for the new configuration - this.addonManager.updateAddons(this.domManager); - - // Update pricing with the selected plan - this.updatePricingWithPlan(selectedPlan); - } else { - // Auto-select mode - reset sliders to default values - this.domManager.resetSlidersToDefaults(); - - // Auto-select mode - fade sliders back in - this.uiManager.fadeInSliders(this.domManager, ['cpu', 'memory']); - - // Auto-select mode - update addons and recalculate - this.addonManager.updateAddons(this.domManager); - this.updatePricing(); - } - }); - } - - // Listen for addon changes - window.addEventListener('addon-changed', () => { - this.updatePricing(); - }); - } - - // Update calculator (initial setup) - updateCalculator() { - this.addonManager.updateAddons(this.domManager); - this.updatePricing(); - } - - // Update pricing with specific plan - updatePricingWithPlan(selectedPlan) { - const config = this.domManager.getCurrentConfiguration(); - - // Update addon prices first to ensure calculated prices are current - this.addonManager.updateAddonPrices(this.domManager, this.planManager); - - this.showPlanDetails(selectedPlan, config.storage, config.instances); - this.uiManager.updateStatusMessage(this.domManager, 'Plan selected directly!', 'success'); - } - - // Main pricing update function - updatePricing() { - // Update addon prices first to ensure they're current - this.addonManager.updateAddonPrices(this.domManager, this.planManager); - - const planSelect = this.domManager.get('planSelect'); - - // Reset plan selection if in auto-select mode - if (!planSelect?.value) { - const config = this.domManager.getCurrentConfiguration(); - - if (!config.serviceLevel) { - return; - } - - // Find best matching plan - const matchedPlan = this.planManager.findBestMatchingPlan(config.cpus, config.memory, config.serviceLevel); - - if (matchedPlan) { - this.showPlanDetails(matchedPlan, config.storage, config.instances); - this.uiManager.updateStatusMessage(this.domManager, 'Perfect match found!', 'success'); - } else { - this.uiManager.showNoMatch(this.domManager); - } - } else { - // Plan is directly selected, update storage pricing - const selectedPlan = JSON.parse(planSelect.value); - const config = this.domManager.getCurrentConfiguration(); - - // Update addon prices for current configuration - this.addonManager.updateAddonPrices(this.domManager, this.planManager); - this.showPlanDetails(selectedPlan, config.storage, config.instances); - this.uiManager.updateStatusMessage(this.domManager, 'Plan selected directly!', 'success'); - } - } - - // Show plan details in the UI - showPlanDetails(plan, storage, instances) { - // Get current service level - const serviceLevel = this.domManager.getSelectedServiceLevel() || 'Best Effort'; - - // Ensure addon prices are calculated with current configuration - this.addonManager.updateAddonPrices(this.domManager, this.planManager); - - // Calculate pricing using final price from plan data (which already includes mandatory addons) - const managedServicePricePerInstance = parseFloat(plan.final_price); - - // Collect addon information for display and calculation - const addons = this.addonManager.getSelectedAddons(this.domManager); - const optionalAddonTotal = this.addonManager.calculateOptionalAddonTotal(this.domManager); - - const managedServicePrice = managedServicePricePerInstance * instances; - - // Use storage price from plan data or fallback to instance variable - const storageUnitPrice = plan.storage_price !== undefined ? - parseFloat(plan.storage_price) : - this.pricingDataManager.getStoragePrice(); - const storagePriceValue = storage * storageUnitPrice * instances; - - // Total price = managed service price (includes mandatory addons) + storage + optional addons - const totalPriceValue = managedServicePrice + storagePriceValue + optionalAddonTotal; - - // Show plan details in UI - this.uiManager.showPlanDetails( - this.domManager, - plan, - storage, - instances, - serviceLevel, - managedServicePrice, - storagePriceValue, - totalPriceValue - ); - - // Update addon pricing display - this.uiManager.updateAddonPricingDisplay(this.domManager, addons.mandatory, addons.optional); - - // Store current configuration for order button - this.orderManager.storeConfiguration( - plan, - { storage, instances }, - serviceLevel, - totalPriceValue.toFixed(2), - [...addons.mandatory, ...addons.optional] - ); - } -} - -// Export for use in other modules -window.PriceCalculator = PriceCalculator; diff --git a/hub/services/static/js/price-calculator/pricing-data-manager.js b/hub/services/static/js/price-calculator/pricing-data-manager.js deleted file mode 100644 index 147086d..0000000 --- a/hub/services/static/js/price-calculator/pricing-data-manager.js +++ /dev/null @@ -1,190 +0,0 @@ -/** - * Pricing Data Manager - Handles API calls and data extraction - */ -class PricingDataManager { - constructor(currentOffering) { - this.currentOffering = currentOffering; - this.pricingData = null; - this.storagePrice = null; - this.replicaInfo = null; - this.addonsData = null; - } - - // 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: ${response.status} ${response.statusText}`); - } - - const data = await response.json(); - - this.pricingData = data.pricing || data; - - // Extract addons data from the plans - addons are embedded in each plan - this.extractAddonsData(); - - // Extract storage price from the first available plan - this.extractStoragePrice(); - - return this.pricingData; - } catch (error) { - console.error('Error loading pricing data:', error); - throw error; - } - } - - // Extract replica information and storage price from pricing data - extractStoragePrice() { - if (!this.pricingData) return; - - // Find the first plan with storage pricing data and replica info - for (const groupName of Object.keys(this.pricingData)) { - const group = this.pricingData[groupName]; - for (const serviceLevel of Object.keys(group)) { - const plans = group[serviceLevel]; - if (plans.length > 0 && plans[0].storage_price !== undefined) { - this.storagePrice = parseFloat(plans[0].storage_price); - this.replicaInfo = { - ha_replica_min: plans[0].ha_replica_min || 1, - ha_replica_max: plans[0].ha_replica_max || 1 - }; - return; - } - } - } - } - - // Extract addons data from pricing plans - extractAddonsData() { - if (!this.pricingData) return; - - this.addonsData = {}; - - // Extract addons from the first available plan for each service level - Object.keys(this.pricingData).forEach(groupName => { - const group = this.pricingData[groupName]; - Object.keys(group).forEach(serviceLevel => { - const plans = group[serviceLevel]; - if (plans.length > 0) { - // Use the first plan's addon data for this service level - const plan = plans[0]; - const allAddons = []; - - // Add mandatory addons - if (plan.mandatory_addons) { - plan.mandatory_addons.forEach(addon => { - allAddons.push({ - ...addon, - is_mandatory: true, - addon_type: addon.addon_type === "Base Fee" ? "BASE_FEE" : "UNIT_RATE" - }); - }); - } - - // Add optional addons - if (plan.optional_addons) { - plan.optional_addons.forEach(addon => { - allAddons.push({ - ...addon, - is_mandatory: false, - addon_type: addon.addon_type === "Base Fee" ? "BASE_FEE" : "UNIT_RATE" - }); - }); - } - - this.addonsData[serviceLevel] = allAddons; - } - }); - }); - } - - // Get available service levels from pricing data - getAvailableServiceLevels() { - if (!this.pricingData) return new Set(); - - const availableServiceLevels = new Set(); - Object.keys(this.pricingData).forEach(groupName => { - const group = this.pricingData[groupName]; - Object.keys(group).forEach(serviceLevel => { - availableServiceLevels.add(serviceLevel); - }); - }); - - return availableServiceLevels; - } - - // Get maximum CPU and memory values from all plans - getSliderMaximums() { - if (!this.pricingData) return { maxCpus: 0, maxMemory: 0 }; - - 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; - }); - }); - }); - - return { maxCpus, maxMemory }; - } - - // Get all plans for a specific service level - getPlansForServiceLevel(serviceLevel) { - if (!this.pricingData || !serviceLevel) return []; - - 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 (parseFloat(a.vcpus) !== parseFloat(b.vcpus)) { - return parseFloat(a.vcpus) - parseFloat(b.vcpus); - } - return parseFloat(a.ram) - parseFloat(b.ram); - }); - - return availablePlans; - } - - // Getters - getPricingData() { - return this.pricingData; - } - - getStoragePrice() { - return this.storagePrice; - } - - getReplicaInfo() { - return this.replicaInfo; - } - - getAddonsData() { - return this.addonsData; - } -} - -// Export for use in other modules -window.PricingDataManager = PricingDataManager; diff --git a/hub/services/static/js/price-calculator/ui-manager.js b/hub/services/static/js/price-calculator/ui-manager.js deleted file mode 100644 index e1a099a..0000000 --- a/hub/services/static/js/price-calculator/ui-manager.js +++ /dev/null @@ -1,269 +0,0 @@ -/** - * UI Manager - Handles UI updates and visual feedback - */ -class UIManager { - constructor() { - // Visual feedback states - this.isSlidersFaded = false; - } - - // Update status message - updateStatusMessage(domManager, message, type) { - const planMatchStatus = domManager.get('planMatchStatus'); - if (!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'; - - planMatchStatus.innerHTML = `${message}`; - planMatchStatus.className = `alert ${alertClass} mb-3`; - planMatchStatus.style.display = 'block'; - } - - // Show error message - showError(domManager, message) { - const planMatchStatus = domManager.get('planMatchStatus'); - if (planMatchStatus) { - planMatchStatus.innerHTML = `${message}`; - planMatchStatus.className = 'alert alert-danger mb-3'; - planMatchStatus.style.display = 'block'; - } - } - - // Show no matching plan found - showNoMatch(domManager) { - const planMatchStatus = domManager.get('planMatchStatus'); - const selectedPlanDetails = domManager.get('selectedPlanDetails'); - const noMatchFound = domManager.get('noMatchFound'); - - if (planMatchStatus) planMatchStatus.style.display = 'none'; - if (selectedPlanDetails) selectedPlanDetails.style.display = 'none'; - if (noMatchFound) noMatchFound.style.display = 'block'; - } - - // Show plan details in the UI - showPlanDetails(domManager, plan, storage, instances, serviceLevel, managedServicePrice, storagePriceValue, totalPriceValue) { - const selectedPlanDetails = domManager.get('selectedPlanDetails'); - if (!selectedPlanDetails) return; - - // Show plan details section - const planMatchStatus = domManager.get('planMatchStatus'); - const noMatchFound = domManager.get('noMatchFound'); - - if (planMatchStatus) planMatchStatus.style.display = 'block'; - selectedPlanDetails.style.display = 'block'; - if (noMatchFound) noMatchFound.style.display = 'none'; - - // Update plan information - const planGroup = domManager.get('planGroup'); - const planName = domManager.get('planName'); - const planDescription = domManager.get('planDescription'); - const planCpus = domManager.get('planCpus'); - const planMemory = domManager.get('planMemory'); - const planInstances = domManager.get('planInstances'); - const planServiceLevel = domManager.get('planServiceLevel'); - const managedServicePriceEl = domManager.get('managedServicePrice'); - const storagePriceEl = domManager.get('storagePriceEl'); - const storageAmount = domManager.get('storageAmount'); - const totalPrice = domManager.get('totalPrice'); - - if (planGroup) planGroup.textContent = plan.groupName; - if (planName) planName.textContent = plan.compute_plan; - if (planDescription) planDescription.textContent = plan.compute_plan_group_description || ''; - if (planCpus) planCpus.textContent = plan.vcpus; - if (planMemory) planMemory.textContent = plan.ram + ' GB'; - if (planInstances) planInstances.textContent = instances; - if (planServiceLevel) planServiceLevel.textContent = serviceLevel; - - // Update pricing display - if (managedServicePriceEl) managedServicePriceEl.textContent = managedServicePrice.toFixed(2); - if (storagePriceEl) storagePriceEl.textContent = storagePriceValue.toFixed(2); - if (storageAmount) storageAmount.textContent = storage; - if (totalPrice) totalPrice.textContent = totalPriceValue.toFixed(2); - } - - // Update addon pricing display in the results panel - updateAddonPricingDisplay(domManager, mandatoryAddons, selectedOptionalAddons) { - // Update mandatory addons in the managed service includes container - const managedServiceIncludesContainer = domManager.get('managedServiceIncludesContainer'); - if (managedServiceIncludesContainer) { - // Clear existing content - managedServiceIncludesContainer.innerHTML = ''; - - // Add mandatory addons to the managed service includes section - if (mandatoryAddons && mandatoryAddons.length > 0) { - mandatoryAddons.forEach(addon => { - const addonRow = document.createElement('div'); - addonRow.className = 'd-flex justify-content-between small text-muted mb-1'; - addonRow.innerHTML = ` - ${addon.name} - CHF ${addon.price} - `; - managedServiceIncludesContainer.appendChild(addonRow); - }); - } - } - - // Update optional addons in the addon pricing container - const addonPricingContainer = domManager.get('addonPricingContainer'); - if (!addonPricingContainer) return; - - // Clear existing addon pricing display - addonPricingContainer.innerHTML = ''; - - // Add optional addons to pricing breakdown (these are added to total) - if (selectedOptionalAddons && selectedOptionalAddons.length > 0) { - selectedOptionalAddons.forEach(addon => { - const addonRow = document.createElement('div'); - addonRow.className = 'd-flex justify-content-between mb-2'; - addonRow.innerHTML = ` - Add-on: ${addon.name} - CHF ${addon.price} - `; - addonPricingContainer.appendChild(addonRow); - }); - } - } - - // Fade out specified sliders when plan is manually selected - fadeOutSliders(domManager, sliderTypes) { - sliderTypes.forEach(type => { - const sliderContainer = domManager.getSliderContainer(type); - if (sliderContainer) { - sliderContainer.style.transition = 'opacity 0.3s ease-in-out'; - sliderContainer.style.opacity = '0.3'; - sliderContainer.style.pointerEvents = 'none'; - - // Add visual indicator that sliders are disabled - const slider = sliderContainer.querySelector('.form-range'); - if (slider) { - slider.style.cursor = 'not-allowed'; - } - } - }); - this.isSlidersFaded = true; - } - - // Fade in specified sliders when auto-select mode is chosen - fadeInSliders(domManager, sliderTypes) { - sliderTypes.forEach(type => { - const sliderContainer = domManager.getSliderContainer(type); - if (sliderContainer) { - sliderContainer.style.transition = 'opacity 0.3s ease-in-out'; - sliderContainer.style.opacity = '1'; - sliderContainer.style.pointerEvents = 'auto'; - - // Remove visual indicator - const slider = sliderContainer.querySelector('.form-range'); - if (slider) { - slider.style.cursor = 'pointer'; - } - } - }); - this.isSlidersFaded = false; - } - - // Setup service levels dynamically from pricing data - setupServiceLevels(domManager, pricingDataManager) { - const serviceLevelGroup = domManager.get('serviceLevelGroup'); - if (!serviceLevelGroup) return; - - // Get all available service levels from the pricing data - const availableServiceLevels = pricingDataManager.getAvailableServiceLevels(); - - // 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; - - serviceLevelGroup.appendChild(input); - serviceLevelGroup.appendChild(label); - }); - - // Update the serviceLevelInputs reference - domManager.elements.serviceLevelInputs = document.querySelectorAll('input[name="serviceLevel"]'); - } - - // Update slider maximums based on pricing data - updateSliderMaximums(domManager, pricingDataManager) { - const cpuRange = domManager.get('cpuRange'); - const memoryRange = domManager.get('memoryRange'); - - if (!cpuRange || !memoryRange) return; - - const { maxCpus, maxMemory } = pricingDataManager.getSliderMaximums(); - - // Set slider maximums with some padding - if (maxCpus > 0) { - cpuRange.min = "0.25"; - cpuRange.max = Math.ceil(maxCpus); - } - - if (maxMemory > 0) { - memoryRange.min = "0.25"; - memoryRange.max = Math.ceil(maxMemory); - } - - // Update display values after changing min/max - domManager.updateSliderDisplayValues(); - } - - // Update instances slider based on service level and replica info - updateInstancesSlider(domManager, pricingDataManager) { - const instancesRange = domManager.get('instancesRange'); - const instancesValue = domManager.get('instancesValue'); - const replicaInfo = pricingDataManager.getReplicaInfo(); - - if (!instancesRange || !replicaInfo) return; - - const serviceLevel = domManager.getSelectedServiceLevel(); - - if (serviceLevel === 'Guaranteed Availability') { - // For GA, min is ha_replica_min - instancesRange.min = replicaInfo.ha_replica_min; - instancesRange.value = Math.max(instancesRange.value, replicaInfo.ha_replica_min); - } else { - // For BE, min is 1 - instancesRange.min = 1; - instancesRange.value = Math.max(instancesRange.value, 1); - } - - // Set max to ha_replica_max - instancesRange.max = replicaInfo.ha_replica_max; - - // Update display value - if (instancesValue) instancesValue.textContent = instancesRange.value; - - // Update the min/max display under the slider - const instancesMinDisplay = document.getElementById('instancesMinDisplay'); - const instancesMaxDisplay = document.getElementById('instancesMaxDisplay'); - - if (instancesMinDisplay) instancesMinDisplay.textContent = instancesRange.min; - if (instancesMaxDisplay) instancesMaxDisplay.textContent = instancesRange.max; - } -} - -// Export for use in other modules -window.UIManager = UIManager; diff --git a/hub/services/templates/base.html b/hub/services/templates/base.html index 57d59ff..9cef2f2 100644 --- a/hub/services/templates/base.html +++ b/hub/services/templates/base.html @@ -55,9 +55,9 @@ + + {% if partner.cloud_providers.exists %} +
+

Cloud Providers

+ +
+ {% endif %} + {% if related_articles %}
@@ -132,7 +153,7 @@

{{ partner.name }}

- +
@@ -147,26 +168,23 @@ {% if services %}
-

- {% if partner.category == 'TRAINING' %} - Training for Services - {% else %} - Consulting for Services - {% endif %} -

+

Consulting for Services

{% for service in services %}
-
- {% if service.get_logo %} -
- - {{ service.name }} logo + {% if service.logo %} + + {% endif %} +

{{ service.name }}

diff --git a/hub/services/templates/services/partner_list.html b/hub/services/templates/services/partner_list.html index 0746547..fb196dc 100644 --- a/hub/services/templates/services/partner_list.html +++ b/hub/services/templates/services/partner_list.html @@ -94,23 +94,6 @@

- -
-
- -
-
- -
-
-
Clear @@ -127,21 +110,17 @@
- {% if partner.category %} -
- {{ partner.get_category_display_badge }} -
- {% endif %}
- {% if partner.get_logo %} -
diff --git a/hub/services/templates/services/pricelist.html b/hub/services/templates/services/pricelist.html index a3a0c0c..5cdec7b 100644 --- a/hub/services/templates/services/pricelist.html +++ b/hub/services/templates/services/pricelist.html @@ -159,11 +159,65 @@

Complete Price List - All Service Variants

- +
- See VSHN Wiki for a detailed explanation + +
+
+
+
+
Price Components
+
    +
  • + Compute Plan Price + Base infrastructure cost for CPU, memory, and storage +
  • +
  • + SLA Base + Fixed cost for the service level agreement +
  • +
  • + Units × SLA Per Unit + Variable cost based on scale/usage +
  • +
  • + Mandatory Add-ons + Required additional services (backup, monitoring, etc.) +
  • +
+
+
+
Final Price Formula
+
+ + Compute Plan Price + + SLA Base + + (Units × SLA Per Unit) + + Mandatory Add-ons = + Final Price + +
+

+ + This transparent pricing model ensures you understand exactly what you're paying for. + The table below breaks down each component for every service variant we offer. +

+ Price Comparisons: When enabled, you'll see: +
External Providers - Competitor prices from AWS, Google Cloud, etc. +
Other Servala Providers - Same service specs on different cloud providers within our network +
+

+
+
+
+
- +
@@ -172,18 +226,16 @@
- +
- +