Service pages improvements #152

Open
rixx wants to merge 8 commits from 132-frontend-improvements into main
13 changed files with 156 additions and 60 deletions

View file

@ -3,8 +3,8 @@ import urlman
from django.conf import settings from django.conf import settings
from django.db import models, transaction from django.db import models, transaction
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.text import slugify
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django_scopes import ScopedManager, scopes_disabled from django_scopes import ScopedManager, scopes_disabled

View file

@ -71,9 +71,14 @@ class Service(ServalaModelMixin, models.Model):
logo = models.ImageField( logo = models.ImageField(
upload_to="public/services", blank=True, null=True, verbose_name=_("Logo") upload_to="public/services", blank=True, null=True, verbose_name=_("Logo")
) )
# TODO schema
external_links = models.JSONField( external_links = models.JSONField(
null=True, blank=True, verbose_name=_("External links") null=True,
blank=True,
verbose_name=_("External links"),
help_text=(
'JSON array of link objects: {"url": "", "title": "", "featured": false}. '
"Featured links will be shown on the service list page, all other links will only show on the service and offering detail pages."
),
) )
class Meta: class Meta:
@ -83,6 +88,13 @@ class Service(ServalaModelMixin, models.Model):
def __str__(self): def __str__(self):
return self.name return self.name
@property
def featured_links(self):
"""Return external links marked as featured."""
if not self.external_links:
return []
return [link for link in self.external_links if link.get("featured")]
def validate_dict(data, required_fields=None, allow_empty=True): def validate_dict(data, required_fields=None, allow_empty=True):
if not data: if not data:
@ -263,7 +275,10 @@ class CloudProvider(ServalaModelMixin, models.Model):
verbose_name=_("Logo"), verbose_name=_("Logo"),
) )
external_links = models.JSONField( external_links = models.JSONField(
null=True, blank=True, verbose_name=_("External links") null=True,
blank=True,
verbose_name=_("External links"),
help_text=('JSON array of link objects: {"url": "", "title": ""}. '),
) )
class Meta: class Meta:

View file

@ -1,21 +1,22 @@
{% extends "frontend/base.html" %} {% extends "frontend/base.html" %}
{% load static i18n %} {% load static i18n %}
{% load allauth account socialaccount %} {% load allauth account socialaccount %}
{% block html_title %} {% block html_title %}
{% translate "Sign in" %} {% translate "Sign in" %}
{% endblock html_title %} {% endblock html_title %}
{% block page_title %} {% block page_title %}
{% translate "Welcome to Servala" %} {% translate "Welcome to Servala" %}
{% endblock page_title %} {% endblock page_title %}
{% block card_header %} {% block card_header %}
<div class="card-header text-center py-4" style="background: linear-gradient(135deg, var(--bs-primary), #8B5CF6); border-radius: 0.5rem 0.5rem 0 0;"> <div class="card-header text-center py-4"
<img src="{% static 'img/Servala-4.png' %}" alt="Servala" class="mb-3" style="height: 70px;"> style="background: linear-gradient(135deg, var(--bs-primary), #8B5CF6);
border-radius: 0.5rem 0.5rem 0 0">
<img src="{% static 'img/Servala-4.png' %}"
alt="Servala"
class="mb-3"
style="height: 70px">
</div> </div>
{% endblock card_header %} {% endblock card_header %}
{% block card_content %} {% block card_content %}
<!-- Main Sign In Section --> <!-- Main Sign In Section -->
{% if SOCIALACCOUNT_ENABLED %} {% if SOCIALACCOUNT_ENABLED %}
@ -24,9 +25,10 @@
<div class="mb-4"> <div class="mb-4">
<div class="text-center mb-4"> <div class="text-center mb-4">
<h5 class="text-primary mb-2">{% translate "Ready to get started?" %}</h5> <h5 class="text-primary mb-2">{% translate "Ready to get started?" %}</h5>
<p class="text-muted mb-0">{% translate "Sign in to access your managed service instances and the Servala service catalog" %}</p> <p class="text-muted mb-0">
{% translate "Sign in to access your managed service instances and the Servala service catalog" %}
</p>
</div> </div>
{% for provider in socialaccount_providers %} {% for provider in socialaccount_providers %}
{% provider_login_url provider process=process scope=scope auth_params=auth_params as href %} {% provider_login_url provider process=process scope=scope auth_params=auth_params as href %}
<form method="post" action="{{ href }}"> <form method="post" action="{{ href }}">
@ -35,7 +37,9 @@
<button type="submit" <button type="submit"
class="btn btn-primary btn-lg w-100 py-3 mb-4 fw-semibold" class="btn btn-primary btn-lg w-100 py-3 mb-4 fw-semibold"
title="{{ provider.name }}" title="{{ provider.name }}"
style="border-radius: 12px; box-shadow: 0 4px 15px rgba(154, 99, 236, 0.2); background: linear-gradient(135deg, var(--bs-primary), #8B5CF6);"> style="border-radius: 12px;
box-shadow: 0 4px 15px rgba(154, 99, 236, 0.2);
background: linear-gradient(135deg, var(--bs-primary), #8B5CF6)">
<span>{% translate "Sign in with VSHN Account" %}</span> <span>{% translate "Sign in with VSHN Account" %}</span>
</button> </button>
</form> </form>
@ -43,7 +47,6 @@
</div> </div>
{% endif %} {% endif %}
{% endif %} {% endif %}
<!-- Feature Preview & Learn More Section --> <!-- Feature Preview & Learn More Section -->
<div class="mt-4 pt-3 border-top"> <div class="mt-4 pt-3 border-top">
<div class="row g-3 text-center"> <div class="row g-3 text-center">
@ -72,8 +75,8 @@
<i class="bi bi-info-circle" style="font-size: 1.2rem;"></i> <i class="bi bi-info-circle" style="font-size: 1.2rem;"></i>
</div> </div>
<small class="text-muted fw-medium"> <small class="text-muted fw-medium">
<a href="https://servala.com" <a href="https://servala.com"
target="_blank" target="_blank"
class="text-decoration-none text-muted"> class="text-decoration-none text-muted">
{% translate "Learn more" %} {% translate "Learn more" %}
<i class="bi bi-arrow-up-right ms-1" style="font-size: 0.7rem;"></i> <i class="bi bi-arrow-up-right ms-1" style="font-size: 0.7rem;"></i>
@ -82,7 +85,6 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Alternative Login Options (Admin) --> <!-- Alternative Login Options (Admin) -->
<div class="mt-4 pt-3 border-top text-center"> <div class="mt-4 pt-3 border-top text-center">
<small class="text-muted"> <small class="text-muted">
@ -96,7 +98,6 @@
</a> </a>
</small> </small>
</div> </div>
<div class="collapse mt-3" id="login-form"> <div class="collapse mt-3" id="login-form">
<div class="card bg-light border-0 shadow-sm" style="border-radius: 12px;"> <div class="card bg-light border-0 shadow-sm" style="border-radius: 12px;">
<div class="card-body p-4"> <div class="card-body p-4">

View file

@ -5,13 +5,18 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="{% static 'mazer/compiled/css/app.css' %}"> <link rel="stylesheet" href="{% static 'mazer/compiled/css/app.css' %}">
<link rel="stylesheet" href="{% static 'mazer/compiled/css/app-dark.css' %}"> <link rel="stylesheet"
href="{% static 'mazer/compiled/css/app-dark.css' %}">
<link rel="stylesheet" href="{% static 'mazer/compiled/css/iconly.css' %}"> <link rel="stylesheet" href="{% static 'mazer/compiled/css/iconly.css' %}">
<link rel="stylesheet" href="{% static 'css/servala.css' %}"> <link rel="stylesheet" href="{% static 'css/servala.css' %}">
<link rel="icon" type="image/x-icon" href="{% static 'img/favicon.ico' %}"> <link rel="icon" type="image/x-icon" href="{% static 'img/favicon.ico' %}">
<script src="{% static "js/htmx.min.js" %}" defer></script> <script src="{% static "js/htmx.min.js" %}" defer></script>
</head> </head>
<title>{% block html_title %}Dashboard{% endblock html_title %} Servala</title> <title>
{% block html_title %}
Dashboard
{% endblock html_title %}
Servala</title>
</head> </head>
<body hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'> <body hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>
<script src="{% static 'mazer/static/js/initTheme.js' %}"></script> <script src="{% static 'mazer/static/js/initTheme.js' %}"></script>

View file

@ -26,6 +26,24 @@
<div class="row"> <div class="row">
<p>{{ service.description|default:"No description available."|urlize }}</p> <p>{{ service.description|default:"No description available."|urlize }}</p>
</div> </div>
{% if service.external_links %}
<div class="row mt-3">
<div class="col-12">
<h6 class="mb-3">{% translate "External Links" %}</h6>
<div class="d-flex flex-wrap gap-2">
{% for link in service.external_links %}
<a href="{{ link.url }}"
target="_blank"
rel="noopener noreferrer"
class="btn btn-outline-primary btn-sm">
{{ link.title }}
<i class="bi bi-box-arrow-up-right ms-1"></i>
</a>
{% endfor %}
</div>
</div>
</div>
{% endif %}
</div> </div>
</div> </div>
<div class="row"> <div class="row">

View file

@ -40,6 +40,13 @@
</div> </div>
</div> </div>
<div class="card-body"> <div class="card-body">
{% if offering.description %}
<div class="row mb-3">
<div class="col-12">
<p>{{ offering.description|urlize }}</p>
</div>
</div>
{% endif %}
{% if not has_control_planes %} {% if not has_control_planes %}
<p>{% translate "We currently cannot offer this service, sorry!" %}</p> <p>{% translate "We currently cannot offer this service, sorry!" %}</p>
{% else %} {% else %}
@ -49,6 +56,24 @@
{{ select_form }} {{ select_form }}
</form> </form>
{% endif %} {% endif %}
{% if service.external_links %}
<div class="row mt-3">
<div class="col-12">
<h6 class="mb-3">{% translate "External Links" %}</h6>
<div class="d-flex flex-wrap gap-2">
{% for link in service.external_links %}
<a href="{{ link.url }}"
target="_blank"
rel="noopener noreferrer"
class="btn btn-outline-primary btn-sm">
{{ link.title }}
<i class="bi bi-box-arrow-up-right ms-1"></i>
</a>
{% endfor %}
</div>
</div>
</div>
{% endif %}
</div> </div>
</div> </div>
<div id="service-form">{% partial service-form %}</div> <div id="service-form">{% partial service-form %}</div>

View file

@ -16,10 +16,10 @@
</div> </div>
</div> </div>
</div> </div>
<div class="row"> <div class="row service-cards-container">
{% for service in services %} {% for service in services %}
<div class="col-6 col-lg-3 col-md-4"> <div class="col-6 col-lg-3 col-md-4">
<div class="card"> <div class="card h-100 d-flex flex-column">
<div class="card-header d-flex align-items-center"> <div class="card-header d-flex align-items-center">
{% if service.logo %} {% if service.logo %}
<img src="{{ service.logo.url }}" <img src="{{ service.logo.url }}"
@ -33,11 +33,23 @@
<small class="text-muted">{{ service.category }}</small> <small class="text-muted">{{ service.category }}</small>
</div> </div>
</div> </div>
<div class="card-body"> <div class="card-body flex-grow-1">
{% if service.description %}<p class="card-text">{{ service.description|urlize }}</p>{% endif %} {% if service.description %}<p class="card-text">{{ service.description|urlize }}</p>{% endif %}
</div> </div>
<div class="card-footer d-flex justify-content-between"> <div class="card-footer d-flex justify-content-between align-items-center gap-2">
<span></span> {% if service.featured_links %}
{% with featured_link=service.featured_links.0 %}
<a href="{{ featured_link.url }}"
target="_blank"
rel="noopener noreferrer"
class="btn btn-outline-primary">
{{ featured_link.title }}
<i class="bi bi-box-arrow-up-right ms-1"></i>
</a>
{% endwith %}
{% else %}
<span></span>
{% endif %}
<a href="{{ service.slug }}/" class="btn btn-light-primary">{% translate "View Availability" %}</a> <a href="{{ service.slug }}/" class="btn btn-light-primary">{% translate "View Availability" %}</a>
</div> </div>
</div> </div>

View file

@ -116,7 +116,8 @@
{% endblocktranslate %} {% endblocktranslate %}
</p> </p>
<div> <div>
<a href="{{ account_href }}" target="_blank" <a href="{{ account_href }}"
target="_blank"
class="btn btn-primary btn-lg icon icon-left btn-keycloak"> class="btn btn-primary btn-lg icon icon-left btn-keycloak">
<span class="mx-1">{% translate "VSHN Account Console" %}</span> <span class="mx-1">{% translate "VSHN Account Console" %}</span>
</a> </a>

View file

@ -1,14 +1,12 @@
{% if show_error %} {% if show_error %}
<div class="{{ css_class }}"> <div class="{{ css_class }}">
{% if has_list %} {% if has_list %}
{% if message %}{{ message }}{% endif %} {% if message %}{{ message }}{% endif %}
<ul> <ul>
{% for error in errors %} {% for error in errors %}<li>{{ error }}</li>{% endfor %}
<li>{{ error }}</li> </ul>
{% endfor %} {% else %}
</ul> {{ message }}
{% else %} {% endif %}
{{ message }} </div>
{% endif %}
</div>
{% endif %} {% endif %}

View file

@ -1,28 +1,29 @@
<div class="alert alert-{{ message.tags }} alert-dismissible" id="auto-dismiss-alert-{{ forloop.counter0|default:'0' }}"> <div class="alert alert-{{ message.tags }} alert-dismissible"
id="auto-dismiss-alert-{{ forloop.counter0|default:'0' }}">
{{ message }} {{ message }}
<button type="button" <button type="button"
class="btn-close" class="btn-close"
data-bs-dismiss="alert" data-bs-dismiss="alert"
aria-label="Close"></button> aria-label="Close"></button>
</div> </div>
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
const alert = document.getElementById('auto-dismiss-alert-{{ forloop.counter0|default:'0' }}'); const alert = document.getElementById('auto-dismiss-alert-{{ forloop.counter0|default:'
if (alert && !alert.classList.contains('alert-danger')) { 0 ' }}');
setTimeout(function() { if (alert && !alert.classList.contains('alert-danger')) {
let opacity = 1; setTimeout(function() {
const fadeOutInterval = setInterval(function() { let opacity = 1;
if (opacity > 0.05) { const fadeOutInterval = setInterval(function() {
opacity -= 0.05; if (opacity > 0.05) {
alert.style.opacity = opacity; opacity -= 0.05;
} else { alert.style.opacity = opacity;
clearInterval(fadeOutInterval); } else {
const bsAlert = new bootstrap.Alert(alert); clearInterval(fadeOutInterval);
bsAlert.close(); const bsAlert = new bootstrap.Alert(alert);
} bsAlert.close();
}, 25); }
}, 5000); }, 25);
} }, 5000);
}); }
</script> });
</script>

View file

@ -3,6 +3,7 @@ Template filters for safe error formatting.
""" """
import html import html
from django import template from django import template
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe

View file

@ -51,7 +51,8 @@ class SupportView(OrganizationViewMixin, FormView):
self.request, self.request,
mark_safe( mark_safe(
_( _(
'There was an error submitting your support request. Please try again or contact us directly at <a href="mailto:servala-support@vshn.ch">servala-support@vshn.ch</a>.' "There was an error submitting your support request. "
'Please try again or contact us directly at <a href="mailto:servala-support@vshn.ch">servala-support@vshn.ch</a>.'
) )
), ),
) )

View file

@ -174,3 +174,21 @@ a.btn-keycloak {
margin-top: -16px; margin-top: -16px;
padding-right: 28px; padding-right: 28px;
} }
/* Service cards equal height styling */
.service-cards-container .card {
height: 100%;
display: flex;
flex-direction: column;
}
.service-cards-container .card-body {
flex-grow: 1;
display: flex;
flex-direction: column;
justify-content: flex-start;
}
.service-cards-container .card-footer {
margin-top: auto;
}