Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4634eea463 |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
core/__pycache__/forms.cpython-311.pyc
Normal file
BIN
core/__pycache__/forms.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
core/__pycache__/tests.cpython-311.pyc
Normal file
BIN
core/__pycache__/tests.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,3 +1,26 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
# Register your models here.
|
from .models import Ticket, TicketReply
|
||||||
|
|
||||||
|
|
||||||
|
class TicketReplyInline(admin.TabularInline):
|
||||||
|
model = TicketReply
|
||||||
|
extra = 0
|
||||||
|
fields = ("author_name", "author_email", "body", "is_staff_reply", "created_at")
|
||||||
|
readonly_fields = ("created_at",)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Ticket)
|
||||||
|
class TicketAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("ticket_number", "subject", "requester_name", "status", "priority", "category", "assigned_to", "updated_at")
|
||||||
|
list_filter = ("status", "priority", "category", "created_at")
|
||||||
|
search_fields = ("subject", "description", "requester_name", "requester_email")
|
||||||
|
readonly_fields = ("public_id", "created_at", "updated_at")
|
||||||
|
inlines = [TicketReplyInline]
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(TicketReply)
|
||||||
|
class TicketReplyAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("ticket", "author_name", "is_staff_reply", "created_at")
|
||||||
|
list_filter = ("is_staff_reply", "created_at")
|
||||||
|
search_fields = ("author_name", "author_email", "body", "ticket__subject")
|
||||||
|
|||||||
74
core/forms.py
Normal file
74
core/forms.py
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
from django import forms
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
|
from .models import Ticket, TicketReply
|
||||||
|
|
||||||
|
|
||||||
|
class TicketForm(forms.ModelForm):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
for field in self.fields.values():
|
||||||
|
widget = field.widget
|
||||||
|
base_class = "form-select" if isinstance(widget, forms.Select) else "form-control"
|
||||||
|
widget.attrs["class"] = f"{widget.attrs.get('class', '')} {base_class}".strip()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Ticket
|
||||||
|
fields = [
|
||||||
|
"requester_name",
|
||||||
|
"requester_email",
|
||||||
|
"category",
|
||||||
|
"priority",
|
||||||
|
"subject",
|
||||||
|
"description",
|
||||||
|
]
|
||||||
|
widgets = {
|
||||||
|
"requester_name": forms.TextInput(attrs={"placeholder": "Alex Morgan"}),
|
||||||
|
"requester_email": forms.EmailInput(attrs={"placeholder": "alex@company.com"}),
|
||||||
|
"subject": forms.TextInput(attrs={"placeholder": "Briefly describe the issue"}),
|
||||||
|
"description": forms.Textarea(attrs={"rows": 5, "placeholder": "Share what happened, who is affected, and any useful context."}),
|
||||||
|
}
|
||||||
|
labels = {
|
||||||
|
"requester_name": "Your name",
|
||||||
|
"requester_email": "Email",
|
||||||
|
"description": "What can we help with?",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ReplyForm(forms.ModelForm):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
for field in self.fields.values():
|
||||||
|
widget = field.widget
|
||||||
|
base_class = "form-select" if isinstance(widget, forms.Select) else "form-control"
|
||||||
|
widget.attrs["class"] = f"{widget.attrs.get('class', '')} {base_class}".strip()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = TicketReply
|
||||||
|
fields = ["author_name", "author_email", "body"]
|
||||||
|
widgets = {
|
||||||
|
"author_name": forms.TextInput(attrs={"placeholder": "Your name"}),
|
||||||
|
"author_email": forms.EmailInput(attrs={"placeholder": "you@company.com"}),
|
||||||
|
"body": forms.Textarea(attrs={"rows": 4, "placeholder": "Write a helpful update or reply..."}),
|
||||||
|
}
|
||||||
|
labels = {
|
||||||
|
"author_name": "Name",
|
||||||
|
"author_email": "Email",
|
||||||
|
"body": "Reply",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TicketTriageForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = Ticket
|
||||||
|
fields = ["status", "priority", "assigned_to"]
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
User = get_user_model()
|
||||||
|
self.fields["assigned_to"].queryset = User.objects.filter(is_staff=True).order_by("username")
|
||||||
|
self.fields["assigned_to"].empty_label = "Unassigned"
|
||||||
|
for field in self.fields.values():
|
||||||
|
widget = field.widget
|
||||||
|
base_class = "form-select" if isinstance(widget, forms.Select) else "form-control"
|
||||||
|
widget.attrs["class"] = f"{widget.attrs.get('class', '')} {base_class}".strip()
|
||||||
67
core/migrations/0001_initial.py
Normal file
67
core/migrations/0001_initial.py
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-05-26 15:01
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import django.utils.timezone
|
||||||
|
import uuid
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Ticket',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('public_id', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
|
||||||
|
('requester_name', models.CharField(max_length=120)),
|
||||||
|
('requester_email', models.EmailField(max_length=254)),
|
||||||
|
('subject', models.CharField(max_length=180)),
|
||||||
|
('description', models.TextField()),
|
||||||
|
('category', models.CharField(choices=[('technical', 'Technical'), ('billing', 'Billing'), ('access', 'Access'), ('general', 'General')], default='general', max_length=24)),
|
||||||
|
('priority', models.CharField(choices=[('low', 'Low'), ('normal', 'Normal'), ('high', 'High'), ('urgent', 'Urgent')], default='normal', max_length=16)),
|
||||||
|
('status', models.CharField(choices=[('new', 'New'), ('triage', 'In triage'), ('waiting', 'Waiting on requester'), ('resolved', 'Resolved')], default='new', max_length=24)),
|
||||||
|
('created_at', models.DateTimeField(default=django.utils.timezone.now)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('assigned_to', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_tickets', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-updated_at', '-created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='TicketReply',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('author_name', models.CharField(max_length=120)),
|
||||||
|
('author_email', models.EmailField(blank=True, max_length=254)),
|
||||||
|
('body', models.TextField()),
|
||||||
|
('is_staff_reply', models.BooleanField(default=False)),
|
||||||
|
('created_at', models.DateTimeField(default=django.utils.timezone.now)),
|
||||||
|
('author_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='ticket_replies', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('ticket', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='replies', to='core.ticket')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='ticket',
|
||||||
|
index=models.Index(fields=['status', 'priority'], name='core_ticket_status_ecde4b_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='ticket',
|
||||||
|
index=models.Index(fields=['created_at'], name='core_ticket_created_fe6f19_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='ticket',
|
||||||
|
index=models.Index(fields=['public_id'], name='core_ticket_public__952cbe_idx'),
|
||||||
|
),
|
||||||
|
]
|
||||||
BIN
core/migrations/__pycache__/0001_initial.cpython-311.pyc
Normal file
BIN
core/migrations/__pycache__/0001_initial.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
@ -1,3 +1,90 @@
|
|||||||
from django.db import models
|
import uuid
|
||||||
|
|
||||||
# Create your models here.
|
from django.conf import settings
|
||||||
|
from django.db import models
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
|
||||||
|
class Ticket(models.Model):
|
||||||
|
class Category(models.TextChoices):
|
||||||
|
TECHNICAL = "technical", "Technical"
|
||||||
|
BILLING = "billing", "Billing"
|
||||||
|
ACCESS = "access", "Access"
|
||||||
|
GENERAL = "general", "General"
|
||||||
|
|
||||||
|
class Priority(models.TextChoices):
|
||||||
|
LOW = "low", "Low"
|
||||||
|
NORMAL = "normal", "Normal"
|
||||||
|
HIGH = "high", "High"
|
||||||
|
URGENT = "urgent", "Urgent"
|
||||||
|
|
||||||
|
class Status(models.TextChoices):
|
||||||
|
NEW = "new", "New"
|
||||||
|
TRIAGE = "triage", "In triage"
|
||||||
|
WAITING = "waiting", "Waiting on requester"
|
||||||
|
RESOLVED = "resolved", "Resolved"
|
||||||
|
|
||||||
|
public_id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
|
||||||
|
requester_name = models.CharField(max_length=120)
|
||||||
|
requester_email = models.EmailField()
|
||||||
|
subject = models.CharField(max_length=180)
|
||||||
|
description = models.TextField()
|
||||||
|
category = models.CharField(max_length=24, choices=Category.choices, default=Category.GENERAL)
|
||||||
|
priority = models.CharField(max_length=16, choices=Priority.choices, default=Priority.NORMAL)
|
||||||
|
status = models.CharField(max_length=24, choices=Status.choices, default=Status.NEW)
|
||||||
|
assigned_to = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name="assigned_tickets",
|
||||||
|
)
|
||||||
|
created_at = models.DateTimeField(default=timezone.now)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-updated_at", "-created_at"]
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["status", "priority"]),
|
||||||
|
models.Index(fields=["created_at"]),
|
||||||
|
models.Index(fields=["public_id"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"#{self.pk or 'new'} {self.subject}"
|
||||||
|
|
||||||
|
def get_absolute_url(self):
|
||||||
|
return reverse("ticket_detail", kwargs={"public_id": self.public_id})
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ticket_number(self):
|
||||||
|
if not self.pk:
|
||||||
|
return "SUP-new"
|
||||||
|
return f"SUP-{self.pk:04d}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_open(self):
|
||||||
|
return self.status != self.Status.RESOLVED
|
||||||
|
|
||||||
|
|
||||||
|
class TicketReply(models.Model):
|
||||||
|
ticket = models.ForeignKey(Ticket, on_delete=models.CASCADE, related_name="replies")
|
||||||
|
author_name = models.CharField(max_length=120)
|
||||||
|
author_email = models.EmailField(blank=True)
|
||||||
|
author_user = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name="ticket_replies",
|
||||||
|
)
|
||||||
|
body = models.TextField()
|
||||||
|
is_staff_reply = models.BooleanField(default=False)
|
||||||
|
created_at = models.DateTimeField(default=timezone.now)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["created_at"]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Reply by {self.author_name} on {self.ticket.ticket_number}"
|
||||||
|
|||||||
@ -1,25 +1,71 @@
|
|||||||
|
{% load static %}
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<title>{% block title %}Knowledge Base{% endblock %}</title>
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
{% if project_description %}
|
<title>{% block title %}{{ project_name|default:"RelayDesk" }}{% endblock %}</title>
|
||||||
<meta name="description" content="{{ project_description }}">
|
<meta name="description" content="{% block meta_description %}{{ page_description|default:project_description|default:'Lightweight support ticketing for small teams.' }}{% endblock %}">
|
||||||
<meta property="og:description" content="{{ project_description }}">
|
|
||||||
<meta property="twitter:description" content="{{ project_description }}">
|
|
||||||
{% endif %}
|
|
||||||
{% if project_image_url %}
|
{% if project_image_url %}
|
||||||
<meta property="og:image" content="{{ project_image_url }}">
|
<meta property="og:image" content="{{ project_image_url }}">
|
||||||
<meta property="twitter:image" content="{{ project_image_url }}">
|
<meta property="twitter:image" content="{{ project_image_url }}">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% load static %}
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Manrope:wght@600;700;800&display=swap" rel="stylesheet">
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
|
||||||
<link rel="stylesheet" href="{% static 'css/custom.css' %}?v={{ deployment_timestamp }}">
|
<link rel="stylesheet" href="{% static 'css/custom.css' %}?v={{ deployment_timestamp }}">
|
||||||
{% block head %}{% endblock %}
|
{% block head %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
<a class="skip-link" href="#main-content">Skip to content</a>
|
||||||
|
<nav class="navbar navbar-expand-lg navbar-light navbar-glass sticky-top" aria-label="Main navigation">
|
||||||
|
<div class="container py-2">
|
||||||
|
<a class="navbar-brand d-flex align-items-center gap-2" href="{% url 'home' %}">
|
||||||
|
<span class="brand-mark" aria-hidden="true">RD</span>
|
||||||
|
<span class="brand-copy">RelayDesk</span>
|
||||||
|
</a>
|
||||||
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#mainNav" aria-controls="mainNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
<div class="collapse navbar-collapse" id="mainNav">
|
||||||
|
<ul class="navbar-nav ms-auto align-items-lg-center gap-lg-2">
|
||||||
|
<li class="nav-item"><a class="nav-link" href="{% url 'ticket_list' %}">Tickets</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="/admin/">Admin</a></li>
|
||||||
|
<li class="nav-item"><a class="btn btn-primary-soft" href="{% url 'ticket_create' %}">Submit ticket</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{% if messages %}
|
||||||
|
<div class="container message-stack" aria-live="polite" aria-atomic="true">
|
||||||
|
{% for message in messages %}
|
||||||
|
<div class="alert alert-{{ message.tags|default:'info' }} shadow-sm mb-2" role="alert">{{ message }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<main id="main-content">
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="site-footer py-4 mt-5">
|
||||||
|
<div class="container d-flex flex-column flex-md-row align-items-center justify-content-between gap-3">
|
||||||
|
<div>
|
||||||
|
<strong>RelayDesk</strong> keeps support moving from request to resolution.
|
||||||
|
</div>
|
||||||
|
<div class="footer-links d-flex gap-3">
|
||||||
|
<a href="{% url 'ticket_list' %}">Browse tickets</a>
|
||||||
|
<a href="{% url 'ticket_create' %}">New ticket</a>
|
||||||
|
<a href="/admin/">Admin</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous" defer></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -1,145 +1,154 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}{{ project_name }}{% endblock %}
|
{% block title %}RelayDesk Support Center{% endblock %}
|
||||||
|
{% block meta_description %}Submit, triage, and resolve lightweight support tickets with threaded replies and clean status tracking.{% endblock %}
|
||||||
{% block head %}
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet">
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
--bg-color-start: #6a11cb;
|
|
||||||
--bg-color-end: #2575fc;
|
|
||||||
--text-color: #ffffff;
|
|
||||||
--card-bg-color: rgba(255, 255, 255, 0.01);
|
|
||||||
--card-border-color: rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
font-family: 'Inter', sans-serif;
|
|
||||||
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
|
|
||||||
color: var(--text-color);
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
min-height: 100vh;
|
|
||||||
text-align: center;
|
|
||||||
overflow: hidden;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
body::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='100' height='100' viewBox='0 0 100 100'><path d='M-10 10L110 10M10 -10L10 110' stroke-width='1' stroke='rgba(255,255,255,0.05)'/></svg>");
|
|
||||||
animation: bg-pan 20s linear infinite;
|
|
||||||
z-index: -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes bg-pan {
|
|
||||||
0% {
|
|
||||||
background-position: 0% 0%;
|
|
||||||
}
|
|
||||||
|
|
||||||
100% {
|
|
||||||
background-position: 100% 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main {
|
|
||||||
padding: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
background: var(--card-bg-color);
|
|
||||||
border: 1px solid var(--card-border-color);
|
|
||||||
border-radius: 16px;
|
|
||||||
padding: 2.5rem 2rem;
|
|
||||||
backdrop-filter: blur(20px);
|
|
||||||
-webkit-backdrop-filter: blur(20px);
|
|
||||||
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: clamp(2.2rem, 3vw + 1.2rem, 3.2rem);
|
|
||||||
font-weight: 700;
|
|
||||||
margin: 0 0 1.2rem;
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin: 0.5rem 0;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
opacity: 0.92;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loader {
|
|
||||||
margin: 1.5rem auto;
|
|
||||||
width: 56px;
|
|
||||||
height: 56px;
|
|
||||||
border: 4px solid rgba(255, 255, 255, 0.25);
|
|
||||||
border-top-color: #fff;
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.runtime code {
|
|
||||||
background: rgba(0, 0, 0, 0.25);
|
|
||||||
padding: 0.15rem 0.45rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sr-only {
|
|
||||||
position: absolute;
|
|
||||||
width: 1px;
|
|
||||||
height: 1px;
|
|
||||||
padding: 0;
|
|
||||||
margin: -1px;
|
|
||||||
overflow: hidden;
|
|
||||||
clip: rect(0, 0, 0, 0);
|
|
||||||
border: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
footer {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 1rem;
|
|
||||||
width: 100%;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
opacity: 0.75;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<main>
|
<section class="hero-section position-relative overflow-hidden">
|
||||||
<div class="card">
|
<div class="hero-shape hero-shape-one" aria-hidden="true"></div>
|
||||||
<h1>Analyzing your requirements and generating your app…</h1>
|
<div class="hero-shape hero-shape-two" aria-hidden="true"></div>
|
||||||
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes">
|
<div class="container position-relative py-5 py-lg-6">
|
||||||
<span class="sr-only">Loading…</span>
|
<div class="row align-items-center g-5">
|
||||||
|
<div class="col-lg-7">
|
||||||
|
<span class="eyebrow">Internal support center</span>
|
||||||
|
<h1 class="display-4 fw-bold mt-3 mb-4">Support that moves every ticket from request to resolved.</h1>
|
||||||
|
<p class="lead text-muted mb-4">RelayDesk gives your team a lightweight intake desk, live status board, searchable ticket queue, and threaded replies in a polished Bootstrap workspace.</p>
|
||||||
|
<form class="hero-search d-flex flex-column flex-sm-row gap-2" action="{% url 'ticket_list' %}" method="get" role="search">
|
||||||
|
<label class="visually-hidden" for="hero-search">Search tickets</label>
|
||||||
|
<input id="hero-search" class="form-control form-control-lg" type="search" name="q" placeholder="Search subject, requester, or ticket details">
|
||||||
|
<button class="btn btn-primary btn-lg" type="submit">Search tickets</button>
|
||||||
|
</form>
|
||||||
|
<div class="d-flex flex-wrap gap-3 mt-4">
|
||||||
|
<a class="btn btn-primary btn-lg" href="{% url 'ticket_create' %}">Submit a ticket</a>
|
||||||
|
<a class="btn btn-outline-dark btn-lg" href="{% url 'ticket_list' %}">Open queue</a>
|
||||||
</div>
|
</div>
|
||||||
<p class="hint">AppWizzy AI is collecting your requirements and applying the first changes.</p>
|
|
||||||
<p class="hint">This page will refresh automatically as the plan is implemented.</p>
|
|
||||||
<p class="runtime">
|
|
||||||
Runtime: Django <code>{{ django_version }}</code> · Python <code>{{ python_version }}</code>
|
|
||||||
— UTC <code>{{ current_time|date:"Y-m-d H:i:s" }}</code>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
<div class="col-lg-5">
|
||||||
<footer>
|
<div class="glass-card quick-intake-card">
|
||||||
Page updated: {{ current_time|date:"Y-m-d H:i:s" }} (UTC)
|
<div class="d-flex align-items-start justify-content-between gap-3 mb-3">
|
||||||
</footer>
|
<div>
|
||||||
|
<span class="section-kicker">Fast intake</span>
|
||||||
|
<h2 class="h3 mb-1">Create a ticket</h2>
|
||||||
|
<p class="text-muted mb-0">Capture the request and start a trackable support thread.</p>
|
||||||
|
</div>
|
||||||
|
<span class="spark-icon" aria-hidden="true">✦</span>
|
||||||
|
</div>
|
||||||
|
<form method="post" novalidate>
|
||||||
|
{% csrf_token %}
|
||||||
|
{% include "core/partials/form_errors.html" with form=ticket_form %}
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label" for="{{ ticket_form.requester_name.id_for_label }}">{{ ticket_form.requester_name.label }}</label>
|
||||||
|
{{ ticket_form.requester_name }}
|
||||||
|
{% include "core/partials/field_errors.html" with field=ticket_form.requester_name %}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label" for="{{ ticket_form.requester_email.id_for_label }}">{{ ticket_form.requester_email.label }}</label>
|
||||||
|
{{ ticket_form.requester_email }}
|
||||||
|
{% include "core/partials/field_errors.html" with field=ticket_form.requester_email %}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label" for="{{ ticket_form.category.id_for_label }}">Category</label>
|
||||||
|
{{ ticket_form.category }}
|
||||||
|
{% include "core/partials/field_errors.html" with field=ticket_form.category %}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label" for="{{ ticket_form.priority.id_for_label }}">Priority</label>
|
||||||
|
{{ ticket_form.priority }}
|
||||||
|
{% include "core/partials/field_errors.html" with field=ticket_form.priority %}
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label" for="{{ ticket_form.subject.id_for_label }}">Subject</label>
|
||||||
|
{{ ticket_form.subject }}
|
||||||
|
{% include "core/partials/field_errors.html" with field=ticket_form.subject %}
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label" for="{{ ticket_form.description.id_for_label }}">{{ ticket_form.description.label }}</label>
|
||||||
|
{{ ticket_form.description }}
|
||||||
|
{% include "core/partials/field_errors.html" with field=ticket_form.description %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-secondary w-100 mt-4" type="submit">Create support thread</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="container metrics-strip my-5">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-6 col-lg-3">
|
||||||
|
<div class="metric-card">
|
||||||
|
<span>Total tickets</span>
|
||||||
|
<strong>{{ stats.total }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-lg-3">
|
||||||
|
<div class="metric-card">
|
||||||
|
<span>Open</span>
|
||||||
|
<strong>{{ stats.open }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-lg-3">
|
||||||
|
<div class="metric-card">
|
||||||
|
<span>In triage</span>
|
||||||
|
<strong>{{ stats.triage }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-lg-3">
|
||||||
|
<div class="metric-card">
|
||||||
|
<span>Resolved</span>
|
||||||
|
<strong>{{ stats.resolved }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="container my-5">
|
||||||
|
<div class="row g-4 align-items-stretch">
|
||||||
|
<div class="col-lg-5">
|
||||||
|
<div class="support-card h-100">
|
||||||
|
<span class="section-kicker">Workflow</span>
|
||||||
|
<h2>Simple, visible triage</h2>
|
||||||
|
<p class="text-muted">Every request starts with category and priority, lands in the queue, then moves through status updates and replies until it is resolved.</p>
|
||||||
|
<div class="workflow-steps">
|
||||||
|
<div><span>1</span><strong>Submit</strong><small>Requester creates a ticket</small></div>
|
||||||
|
<div><span>2</span><strong>Triage</strong><small>Staff filters, assigns, and updates status</small></div>
|
||||||
|
<div><span>3</span><strong>Resolve</strong><small>Threaded replies keep context together</small></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-7">
|
||||||
|
<div class="support-card h-100">
|
||||||
|
<div class="d-flex flex-column flex-md-row justify-content-between gap-3 mb-3">
|
||||||
|
<div>
|
||||||
|
<span class="section-kicker">Live queue</span>
|
||||||
|
<h2 class="mb-0">Recent tickets</h2>
|
||||||
|
</div>
|
||||||
|
<a class="btn btn-outline-dark align-self-start" href="{% url 'ticket_list' %}">View all</a>
|
||||||
|
</div>
|
||||||
|
{% if recent_tickets %}
|
||||||
|
<div class="ticket-list-mini">
|
||||||
|
{% for ticket in recent_tickets %}
|
||||||
|
<a class="ticket-mini-row" href="{{ ticket.get_absolute_url }}">
|
||||||
|
<span>
|
||||||
|
<strong>{{ ticket.ticket_number }}</strong>
|
||||||
|
<small>{{ ticket.subject }}</small>
|
||||||
|
</span>
|
||||||
|
<span class="status-badge status-{{ ticket.status }}">{{ ticket.get_status_display }}</span>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-shape" aria-hidden="true"></div>
|
||||||
|
<h3>No tickets yet</h3>
|
||||||
|
<p>Create the first request from the intake form and it will appear here instantly.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
5
core/templates/core/partials/field_errors.html
Normal file
5
core/templates/core/partials/field_errors.html
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{% if field.errors %}
|
||||||
|
<div class="invalid-feedback d-block">
|
||||||
|
{% for error in field.errors %}{{ error }}{% if not forloop.last %}<br>{% endif %}{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
5
core/templates/core/partials/form_errors.html
Normal file
5
core/templates/core/partials/form_errors.html
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{% if form.non_field_errors %}
|
||||||
|
<div class="alert alert-danger" role="alert">
|
||||||
|
{% for error in form.non_field_errors %}<div>{{ error }}</div>{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
147
core/templates/core/ticket_detail.html
Normal file
147
core/templates/core/ticket_detail.html
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ ticket.ticket_number }} · RelayDesk{% endblock %}
|
||||||
|
{% block meta_description %}Threaded replies and status tracking for {{ ticket.ticket_number }}.{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="page-hero py-5">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row align-items-start g-4">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="d-flex flex-wrap align-items-center gap-2 mb-3">
|
||||||
|
<span class="eyebrow">{{ ticket.ticket_number }}</span>
|
||||||
|
<span class="status-badge status-{{ ticket.status }}">{{ ticket.get_status_display }}</span>
|
||||||
|
<span class="priority-pill priority-{{ ticket.priority }}">{{ ticket.get_priority_display }}</span>
|
||||||
|
</div>
|
||||||
|
<h1 class="display-6 fw-bold">{{ ticket.subject }}</h1>
|
||||||
|
<p class="lead text-muted mb-0">Submitted by {{ ticket.requester_name }} in {{ ticket.get_category_display }} · Updated {{ ticket.updated_at|timesince }} ago</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-4 text-lg-end">
|
||||||
|
<a class="btn btn-outline-dark" href="{% url 'ticket_list' %}">Back to queue</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="container mb-5">
|
||||||
|
<div class="row g-4">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<article class="support-card mb-4">
|
||||||
|
<span class="section-kicker">Original request</span>
|
||||||
|
<p class="ticket-description mb-0">{{ ticket.description|linebreaksbr }}</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<div class="support-card mb-4">
|
||||||
|
<div class="d-flex justify-content-between align-items-center gap-3 mb-4">
|
||||||
|
<div>
|
||||||
|
<span class="section-kicker">Threaded replies</span>
|
||||||
|
<h2 class="h3 mb-0">Conversation</h2>
|
||||||
|
</div>
|
||||||
|
<span class="reply-count">{{ ticket.replies.count }} repl{{ ticket.replies.count|pluralize:"y,ies" }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="timeline">
|
||||||
|
<div class="timeline-item requester">
|
||||||
|
<div class="timeline-dot" aria-hidden="true"></div>
|
||||||
|
<div class="reply-card">
|
||||||
|
<div class="reply-meta">
|
||||||
|
<strong>{{ ticket.requester_name }}</strong>
|
||||||
|
<span>opened this ticket · {{ ticket.created_at|date:"M j, Y g:i A" }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="mb-0">{{ ticket.description|linebreaksbr }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% for reply in ticket.replies.all %}
|
||||||
|
<div class="timeline-item {% if reply.is_staff_reply %}staff{% else %}requester{% endif %}">
|
||||||
|
<div class="timeline-dot" aria-hidden="true"></div>
|
||||||
|
<div class="reply-card">
|
||||||
|
<div class="reply-meta">
|
||||||
|
<strong>{{ reply.author_name }}</strong>
|
||||||
|
<span>{% if reply.is_staff_reply %}support agent{% else %}requester{% endif %} · {{ reply.created_at|date:"M j, Y g:i A" }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="mb-0">{{ reply.body|linebreaksbr }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<div class="empty-thread">No replies yet. Add the first update below.</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="support-card">
|
||||||
|
<span class="section-kicker">Add update</span>
|
||||||
|
<h2 class="h3">Post a reply</h2>
|
||||||
|
<form method="post" novalidate>
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="reply_submit" value="1">
|
||||||
|
{% include "core/partials/form_errors.html" with form=reply_form %}
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label" for="{{ reply_form.author_name.id_for_label }}">{{ reply_form.author_name.label }}</label>
|
||||||
|
{{ reply_form.author_name }}
|
||||||
|
{% include "core/partials/field_errors.html" with field=reply_form.author_name %}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label" for="{{ reply_form.author_email.id_for_label }}">{{ reply_form.author_email.label }}</label>
|
||||||
|
{{ reply_form.author_email }}
|
||||||
|
{% include "core/partials/field_errors.html" with field=reply_form.author_email %}
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label" for="{{ reply_form.body.id_for_label }}">{{ reply_form.body.label }}</label>
|
||||||
|
{{ reply_form.body }}
|
||||||
|
{% include "core/partials/field_errors.html" with field=reply_form.body %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary mt-4" type="submit">Add reply</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside class="col-lg-4">
|
||||||
|
<div class="support-card sticky-summary mb-4">
|
||||||
|
<span class="section-kicker">Status tracking</span>
|
||||||
|
<dl class="ticket-facts">
|
||||||
|
<div><dt>Status</dt><dd><span class="status-badge status-{{ ticket.status }}">{{ ticket.get_status_display }}</span></dd></div>
|
||||||
|
<div><dt>Priority</dt><dd>{{ ticket.get_priority_display }}</dd></div>
|
||||||
|
<div><dt>Category</dt><dd>{{ ticket.get_category_display }}</dd></div>
|
||||||
|
<div><dt>Assigned</dt><dd>{% if ticket.assigned_to %}{{ ticket.assigned_to.get_username }}{% else %}Unassigned{% endif %}</dd></div>
|
||||||
|
<div><dt>Requester</dt><dd>{{ ticket.requester_name }}<br><a href="mailto:{{ ticket.requester_email }}">{{ ticket.requester_email }}</a></dd></div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if user.is_staff %}
|
||||||
|
<div class="support-card">
|
||||||
|
<span class="section-kicker">Agent controls</span>
|
||||||
|
<h2 class="h4">Triage ticket</h2>
|
||||||
|
<form method="post" novalidate>
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="triage_submit" value="1">
|
||||||
|
{% include "core/partials/form_errors.html" with form=triage_form %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label" for="{{ triage_form.status.id_for_label }}">Status</label>
|
||||||
|
{{ triage_form.status }}
|
||||||
|
{% include "core/partials/field_errors.html" with field=triage_form.status %}
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label" for="{{ triage_form.priority.id_for_label }}">Priority</label>
|
||||||
|
{{ triage_form.priority }}
|
||||||
|
{% include "core/partials/field_errors.html" with field=triage_form.priority %}
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label" for="{{ triage_form.assigned_to.id_for_label }}">Assigned to</label>
|
||||||
|
{{ triage_form.assigned_to }}
|
||||||
|
{% include "core/partials/field_errors.html" with field=triage_form.assigned_to %}
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-secondary w-100" type="submit">Update triage</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="mini-panel">
|
||||||
|
<strong>Agent controls</strong>
|
||||||
|
<p class="mb-0">Staff can sign in through Admin to assign owners and update ticket status.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
73
core/templates/core/ticket_form.html
Normal file
73
core/templates/core/ticket_form.html
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Submit a Ticket · RelayDesk{% endblock %}
|
||||||
|
{% block meta_description %}Submit a support ticket with requester details, category, priority, and issue description.{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="page-hero py-5">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row align-items-center g-4">
|
||||||
|
<div class="col-lg-7">
|
||||||
|
<span class="eyebrow">New support request</span>
|
||||||
|
<h1 class="display-5 fw-bold mt-3">Tell the team what needs attention.</h1>
|
||||||
|
<p class="lead text-muted">Your submission creates a trackable ticket with a dedicated reply thread and status timeline.</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-5">
|
||||||
|
<div class="mini-panel">
|
||||||
|
<strong>What happens next?</strong>
|
||||||
|
<p class="mb-0">We’ll create your ticket, show a confirmation page, and add it to the searchable support queue.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="container mb-5">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-9">
|
||||||
|
<div class="support-card">
|
||||||
|
<form method="post" novalidate>
|
||||||
|
{% csrf_token %}
|
||||||
|
{% include "core/partials/form_errors.html" with form=form %}
|
||||||
|
<div class="row g-4">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label" for="{{ form.requester_name.id_for_label }}">{{ form.requester_name.label }}</label>
|
||||||
|
{{ form.requester_name }}
|
||||||
|
{% include "core/partials/field_errors.html" with field=form.requester_name %}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label" for="{{ form.requester_email.id_for_label }}">{{ form.requester_email.label }}</label>
|
||||||
|
{{ form.requester_email }}
|
||||||
|
{% include "core/partials/field_errors.html" with field=form.requester_email %}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label" for="{{ form.category.id_for_label }}">Category</label>
|
||||||
|
{{ form.category }}
|
||||||
|
{% include "core/partials/field_errors.html" with field=form.category %}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label" for="{{ form.priority.id_for_label }}">Priority</label>
|
||||||
|
{{ form.priority }}
|
||||||
|
{% include "core/partials/field_errors.html" with field=form.priority %}
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label" for="{{ form.subject.id_for_label }}">Subject</label>
|
||||||
|
{{ form.subject }}
|
||||||
|
{% include "core/partials/field_errors.html" with field=form.subject %}
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label" for="{{ form.description.id_for_label }}">{{ form.description.label }}</label>
|
||||||
|
{{ form.description }}
|
||||||
|
{% include "core/partials/field_errors.html" with field=form.description %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex flex-column flex-sm-row gap-3 mt-4">
|
||||||
|
<button class="btn btn-primary btn-lg" type="submit">Submit ticket</button>
|
||||||
|
<a class="btn btn-outline-dark btn-lg" href="{% url 'ticket_list' %}">View queue</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
100
core/templates/core/ticket_list.html
Normal file
100
core/templates/core/ticket_list.html
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Ticket Queue · RelayDesk{% endblock %}
|
||||||
|
{% block meta_description %}Search and filter support tickets by status, priority, category, requester, and content.{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="page-hero py-5">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row align-items-end g-4">
|
||||||
|
<div class="col-lg-7">
|
||||||
|
<span class="eyebrow">Ticket queue</span>
|
||||||
|
<h1 class="display-5 fw-bold mt-3">Search, filter, and triage support work.</h1>
|
||||||
|
<p class="lead text-muted mb-0">A clean queue for incoming requests, active triage, waiting tickets, and resolved work.</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-5 text-lg-end">
|
||||||
|
<a class="btn btn-primary btn-lg" href="{% url 'ticket_create' %}">Submit ticket</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="container metrics-strip mb-4">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-6 col-lg-3"><div class="metric-card"><span>Total</span><strong>{{ stats.total }}</strong></div></div>
|
||||||
|
<div class="col-6 col-lg-3"><div class="metric-card"><span>Open</span><strong>{{ stats.open }}</strong></div></div>
|
||||||
|
<div class="col-6 col-lg-3"><div class="metric-card"><span>In triage</span><strong>{{ stats.triage }}</strong></div></div>
|
||||||
|
<div class="col-6 col-lg-3"><div class="metric-card"><span>Resolved</span><strong>{{ stats.resolved }}</strong></div></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="container mb-5">
|
||||||
|
<div class="support-card mb-4">
|
||||||
|
<form class="row g-3 align-items-end" method="get">
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<label class="form-label" for="ticket-q">Search</label>
|
||||||
|
<input id="ticket-q" class="form-control" type="search" name="q" value="{{ query }}" placeholder="Subject, requester, details">
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-6 col-lg-2">
|
||||||
|
<label class="form-label" for="ticket-status">Status</label>
|
||||||
|
<select id="ticket-status" class="form-select" name="status">
|
||||||
|
<option value="">Any status</option>
|
||||||
|
{% for value,label in status_choices %}
|
||||||
|
<option value="{{ value }}" {% if selected_status == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-6 col-lg-2">
|
||||||
|
<label class="form-label" for="ticket-priority">Priority</label>
|
||||||
|
<select id="ticket-priority" class="form-select" name="priority">
|
||||||
|
<option value="">Any priority</option>
|
||||||
|
{% for value,label in priority_choices %}
|
||||||
|
<option value="{{ value }}" {% if selected_priority == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-6 col-lg-2">
|
||||||
|
<label class="form-label" for="ticket-category">Category</label>
|
||||||
|
<select id="ticket-category" class="form-select" name="category">
|
||||||
|
<option value="">Any category</option>
|
||||||
|
{% for value,label in category_choices %}
|
||||||
|
<option value="{{ value }}" {% if selected_category == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-6 col-lg-2 d-grid">
|
||||||
|
<button class="btn btn-primary" type="submit">Apply filters</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if tickets %}
|
||||||
|
<div class="ticket-table support-card p-0 overflow-hidden">
|
||||||
|
{% for ticket in tickets %}
|
||||||
|
<a class="ticket-row" href="{{ ticket.get_absolute_url }}">
|
||||||
|
<div class="ticket-row-main">
|
||||||
|
<div class="d-flex flex-wrap align-items-center gap-2 mb-2">
|
||||||
|
<strong>{{ ticket.ticket_number }}</strong>
|
||||||
|
<span class="status-badge status-{{ ticket.status }}">{{ ticket.get_status_display }}</span>
|
||||||
|
<span class="priority-pill priority-{{ ticket.priority }}">{{ ticket.get_priority_display }}</span>
|
||||||
|
</div>
|
||||||
|
<h2 class="h5 mb-1">{{ ticket.subject }}</h2>
|
||||||
|
<p class="mb-0 text-muted">{{ ticket.requester_name }} · {{ ticket.get_category_display }} · {{ ticket.reply_count }} repl{{ ticket.reply_count|pluralize:"y,ies" }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="ticket-row-meta">
|
||||||
|
<span>Updated {{ ticket.updated_at|timesince }} ago</span>
|
||||||
|
<small>{% if ticket.assigned_to %}Assigned to {{ ticket.assigned_to.get_username }}{% else %}Unassigned{% endif %}</small>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state support-card">
|
||||||
|
<div class="empty-shape" aria-hidden="true"></div>
|
||||||
|
<h2>No tickets match these filters</h2>
|
||||||
|
<p>Try clearing filters or create the first ticket for this queue.</p>
|
||||||
|
<a class="btn btn-primary" href="{% url 'ticket_create' %}">Submit ticket</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
25
core/templates/core/ticket_success.html
Normal file
25
core/templates/core/ticket_success.html
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Ticket Submitted · RelayDesk{% endblock %}
|
||||||
|
{% block meta_description %}Support ticket confirmation and next steps.{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="container py-5">
|
||||||
|
<div class="confirmation-card mx-auto">
|
||||||
|
<div class="success-orb" aria-hidden="true">✓</div>
|
||||||
|
<span class="eyebrow">Ticket created</span>
|
||||||
|
<h1 class="display-5 fw-bold mt-3">{{ ticket.ticket_number }} is ready to track.</h1>
|
||||||
|
<p class="lead text-muted">We captured your request and created a dedicated thread for replies and status updates.</p>
|
||||||
|
<div class="ticket-summary-grid my-4">
|
||||||
|
<div><span>Status</span><strong>{{ ticket.get_status_display }}</strong></div>
|
||||||
|
<div><span>Priority</span><strong>{{ ticket.get_priority_display }}</strong></div>
|
||||||
|
<div><span>Category</span><strong>{{ ticket.get_category_display }}</strong></div>
|
||||||
|
<div><span>Requester</span><strong>{{ ticket.requester_name }}</strong></div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex flex-column flex-sm-row gap-3 justify-content-center">
|
||||||
|
<a class="btn btn-primary btn-lg" href="{{ ticket.get_absolute_url }}">Open ticket thread</a>
|
||||||
|
<a class="btn btn-outline-dark btn-lg" href="{% url 'ticket_list' %}">View queue</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@ -1,3 +1,37 @@
|
|||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
# Create your tests here.
|
from .models import Ticket
|
||||||
|
|
||||||
|
|
||||||
|
class TicketWorkflowTests(TestCase):
|
||||||
|
def test_submit_ticket_creates_record_and_redirects_to_confirmation(self):
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("ticket_create"),
|
||||||
|
{
|
||||||
|
"requester_name": "Alex Morgan",
|
||||||
|
"requester_email": "alex@example.com",
|
||||||
|
"category": Ticket.Category.TECHNICAL,
|
||||||
|
"priority": Ticket.Priority.HIGH,
|
||||||
|
"subject": "Cannot access dashboard",
|
||||||
|
"description": "The dashboard returns an error after login.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
ticket = Ticket.objects.get()
|
||||||
|
self.assertRedirects(response, reverse("ticket_success", kwargs={"public_id": ticket.public_id}))
|
||||||
|
self.assertEqual(ticket.status, Ticket.Status.NEW)
|
||||||
|
|
||||||
|
def test_ticket_list_search_finds_requester(self):
|
||||||
|
Ticket.objects.create(
|
||||||
|
requester_name="Jamie",
|
||||||
|
requester_email="jamie@example.com",
|
||||||
|
category=Ticket.Category.ACCESS,
|
||||||
|
priority=Ticket.Priority.NORMAL,
|
||||||
|
subject="VPN access",
|
||||||
|
description="Please enable VPN.",
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.get(reverse("ticket_list"), {"q": "Jamie"})
|
||||||
|
|
||||||
|
self.assertContains(response, "VPN access")
|
||||||
|
|||||||
@ -1,7 +1,11 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from .views import home
|
from .views import home, ticket_create, ticket_detail, ticket_list, ticket_success
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("", home, name="home"),
|
path("", home, name="home"),
|
||||||
|
path("tickets/", ticket_list, name="ticket_list"),
|
||||||
|
path("tickets/new/", ticket_create, name="ticket_create"),
|
||||||
|
path("tickets/<uuid:public_id>/", ticket_detail, name="ticket_detail"),
|
||||||
|
path("tickets/<uuid:public_id>/submitted/", ticket_success, name="ticket_success"),
|
||||||
]
|
]
|
||||||
|
|||||||
169
core/views.py
169
core/views.py
@ -1,25 +1,160 @@
|
|||||||
import os
|
from django.contrib import messages
|
||||||
import platform
|
from django.db.models import Count, Q
|
||||||
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
|
|
||||||
from django import get_version as django_version
|
from .forms import ReplyForm, TicketForm, TicketTriageForm
|
||||||
from django.shortcuts import render
|
from .models import Ticket
|
||||||
from django.utils import timezone
|
|
||||||
|
|
||||||
|
def _ticket_stats():
|
||||||
|
return {
|
||||||
|
"total": Ticket.objects.count(),
|
||||||
|
"open": Ticket.objects.exclude(status=Ticket.Status.RESOLVED).count(),
|
||||||
|
"triage": Ticket.objects.filter(status=Ticket.Status.TRIAGE).count(),
|
||||||
|
"resolved": Ticket.objects.filter(status=Ticket.Status.RESOLVED).count(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def home(request):
|
def home(request):
|
||||||
"""Render the landing screen with loader and environment details."""
|
"""Render the branded support center landing page and quick ticket intake."""
|
||||||
host_name = request.get_host().lower()
|
if request.method == "POST":
|
||||||
agent_brand = "AppWizzy" if host_name == "appwizzy.com" else "Flatlogic"
|
ticket_form = TicketForm(request.POST)
|
||||||
now = timezone.now()
|
if ticket_form.is_valid():
|
||||||
|
ticket = ticket_form.save()
|
||||||
|
messages.success(request, "Ticket submitted. Your support thread is ready.")
|
||||||
|
return redirect("ticket_success", public_id=ticket.public_id)
|
||||||
|
else:
|
||||||
|
ticket_form = TicketForm()
|
||||||
|
|
||||||
|
status_counts = dict(
|
||||||
|
Ticket.objects.values_list("status").annotate(total=Count("id"))
|
||||||
|
)
|
||||||
|
recent_tickets = Ticket.objects.select_related("assigned_to")[:5]
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
"project_name": "New Style",
|
"project_name": "RelayDesk",
|
||||||
"agent_brand": agent_brand,
|
"page_description": "A lightweight support center for ticket submission, triage, threaded replies, and status tracking.",
|
||||||
"django_version": django_version(),
|
"ticket_form": ticket_form,
|
||||||
"python_version": platform.python_version(),
|
"stats": _ticket_stats(),
|
||||||
"current_time": now,
|
"status_choices": Ticket.Status.choices,
|
||||||
"host_name": host_name,
|
"status_counts": status_counts,
|
||||||
"project_description": os.getenv("PROJECT_DESCRIPTION", ""),
|
"recent_tickets": recent_tickets,
|
||||||
"project_image_url": os.getenv("PROJECT_IMAGE_URL", ""),
|
|
||||||
}
|
}
|
||||||
return render(request, "core/index.html", context)
|
return render(request, "core/index.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
def ticket_create(request):
|
||||||
|
if request.method == "POST":
|
||||||
|
form = TicketForm(request.POST)
|
||||||
|
if form.is_valid():
|
||||||
|
ticket = form.save()
|
||||||
|
messages.success(request, "Ticket submitted. We created a trackable support thread for you.")
|
||||||
|
return redirect("ticket_success", public_id=ticket.public_id)
|
||||||
|
else:
|
||||||
|
form = TicketForm()
|
||||||
|
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"core/ticket_form.html",
|
||||||
|
{
|
||||||
|
"form": form,
|
||||||
|
"project_name": "RelayDesk",
|
||||||
|
"page_description": "Submit a support ticket with category, priority, and details.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ticket_success(request, public_id):
|
||||||
|
ticket = get_object_or_404(Ticket.objects.select_related("assigned_to"), public_id=public_id)
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"core/ticket_success.html",
|
||||||
|
{
|
||||||
|
"ticket": ticket,
|
||||||
|
"project_name": "RelayDesk",
|
||||||
|
"page_description": f"Confirmation for support ticket {ticket.ticket_number}.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ticket_list(request):
|
||||||
|
tickets = Ticket.objects.select_related("assigned_to").annotate(reply_count=Count("replies"))
|
||||||
|
query = request.GET.get("q", "").strip()
|
||||||
|
status = request.GET.get("status", "").strip()
|
||||||
|
priority = request.GET.get("priority", "").strip()
|
||||||
|
category = request.GET.get("category", "").strip()
|
||||||
|
|
||||||
|
if query:
|
||||||
|
tickets = tickets.filter(
|
||||||
|
Q(subject__icontains=query)
|
||||||
|
| Q(description__icontains=query)
|
||||||
|
| Q(requester_name__icontains=query)
|
||||||
|
| Q(requester_email__icontains=query)
|
||||||
|
)
|
||||||
|
if status in Ticket.Status.values:
|
||||||
|
tickets = tickets.filter(status=status)
|
||||||
|
if priority in Ticket.Priority.values:
|
||||||
|
tickets = tickets.filter(priority=priority)
|
||||||
|
if category in Ticket.Category.values:
|
||||||
|
tickets = tickets.filter(category=category)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"project_name": "RelayDesk",
|
||||||
|
"page_description": "Search, filter, and triage support tickets.",
|
||||||
|
"tickets": tickets,
|
||||||
|
"stats": _ticket_stats(),
|
||||||
|
"query": query,
|
||||||
|
"selected_status": status,
|
||||||
|
"selected_priority": priority,
|
||||||
|
"selected_category": category,
|
||||||
|
"status_choices": Ticket.Status.choices,
|
||||||
|
"priority_choices": Ticket.Priority.choices,
|
||||||
|
"category_choices": Ticket.Category.choices,
|
||||||
|
}
|
||||||
|
return render(request, "core/ticket_list.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
def ticket_detail(request, public_id):
|
||||||
|
ticket = get_object_or_404(
|
||||||
|
Ticket.objects.select_related("assigned_to").prefetch_related("replies"),
|
||||||
|
public_id=public_id,
|
||||||
|
)
|
||||||
|
reply_form = ReplyForm(
|
||||||
|
initial={
|
||||||
|
"author_name": request.user.get_full_name() or request.user.get_username() if request.user.is_authenticated else "",
|
||||||
|
"author_email": request.user.email if request.user.is_authenticated else "",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
triage_form = TicketTriageForm(instance=ticket)
|
||||||
|
|
||||||
|
if request.method == "POST" and "reply_submit" in request.POST:
|
||||||
|
reply_form = ReplyForm(request.POST)
|
||||||
|
if reply_form.is_valid():
|
||||||
|
reply = reply_form.save(commit=False)
|
||||||
|
reply.ticket = ticket
|
||||||
|
if request.user.is_authenticated:
|
||||||
|
reply.author_user = request.user
|
||||||
|
reply.is_staff_reply = request.user.is_staff
|
||||||
|
if not reply.author_name:
|
||||||
|
reply.author_name = request.user.get_full_name() or request.user.get_username()
|
||||||
|
if not reply.author_email:
|
||||||
|
reply.author_email = request.user.email
|
||||||
|
reply.save()
|
||||||
|
messages.success(request, "Reply added to the ticket thread.")
|
||||||
|
return redirect("ticket_detail", public_id=ticket.public_id)
|
||||||
|
|
||||||
|
if request.method == "POST" and "triage_submit" in request.POST and request.user.is_staff:
|
||||||
|
triage_form = TicketTriageForm(request.POST, instance=ticket)
|
||||||
|
if triage_form.is_valid():
|
||||||
|
triage_form.save()
|
||||||
|
messages.success(request, "Ticket triage fields updated.")
|
||||||
|
return redirect("ticket_detail", public_id=ticket.public_id)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"project_name": "RelayDesk",
|
||||||
|
"page_description": f"Ticket thread and status for {ticket.ticket_number}.",
|
||||||
|
"ticket": ticket,
|
||||||
|
"reply_form": reply_form,
|
||||||
|
"triage_form": triage_form,
|
||||||
|
}
|
||||||
|
return render(request, "core/ticket_detail.html", context)
|
||||||
|
|||||||
BIN
main-page-before.png
Normal file
BIN
main-page-before.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 798 KiB |
BIN
open-queue-desktop.png
Normal file
BIN
open-queue-desktop.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 595 KiB |
BIN
open-queue-mobile.png
Normal file
BIN
open-queue-mobile.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 253 KiB |
@ -1,4 +1,699 @@
|
|||||||
/* Custom styles for the application */
|
/* RelayDesk custom theme: Bootstrap 5 + branded overrides */
|
||||||
body {
|
:root {
|
||||||
font-family: system-ui, -apple-system, sans-serif;
|
--rd-ink: #132321;
|
||||||
|
--rd-ink-soft: #334e49;
|
||||||
|
--rd-muted: #64748b;
|
||||||
|
--rd-primary: #0f766e;
|
||||||
|
--rd-primary-dark: #0b5f59;
|
||||||
|
--rd-primary-soft: #d9f7f2;
|
||||||
|
--rd-secondary: #f97316;
|
||||||
|
--rd-secondary-dark: #c64f05;
|
||||||
|
--rd-accent: #22c55e;
|
||||||
|
--rd-cream: #fff7ed;
|
||||||
|
--rd-bg: #f5fbf8;
|
||||||
|
--rd-surface: #ffffff;
|
||||||
|
--rd-border: #dce9e5;
|
||||||
|
--rd-shadow: 0 24px 70px rgba(15, 60, 55, 0.14);
|
||||||
|
--rd-radius: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
color: var(--rd-ink);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 8% 8%, rgba(34, 197, 94, 0.13), transparent 30rem),
|
||||||
|
radial-gradient(circle at 88% 4%, rgba(249, 115, 22, 0.12), transparent 26rem),
|
||||||
|
linear-gradient(180deg, #fbfffd 0%, var(--rd-bg) 48%, #ffffff 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5, h6, .brand-copy {
|
||||||
|
font-family: "Manrope", "Inter", sans-serif;
|
||||||
|
letter-spacing: -0.035em;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--rd-primary-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color: var(--rd-secondary-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skip-link {
|
||||||
|
position: absolute;
|
||||||
|
left: 1rem;
|
||||||
|
top: -4rem;
|
||||||
|
z-index: 2000;
|
||||||
|
background: var(--rd-ink);
|
||||||
|
color: #fff;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
transition: top 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skip-link:focus {
|
||||||
|
top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-glass {
|
||||||
|
background: rgba(255, 255, 255, 0.82);
|
||||||
|
border-bottom: 1px solid rgba(220, 233, 229, 0.9);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
-webkit-backdrop-filter: blur(18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar .nav-link {
|
||||||
|
color: var(--rd-ink-soft);
|
||||||
|
font-weight: 700;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding-inline: 0.9rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar .nav-link:hover,
|
||||||
|
.navbar .nav-link:focus {
|
||||||
|
color: var(--rd-primary-dark);
|
||||||
|
background: rgba(15, 118, 110, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-mark {
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
border-radius: 0.95rem;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 900;
|
||||||
|
background: linear-gradient(135deg, var(--rd-primary), var(--rd-accent));
|
||||||
|
box-shadow: 0 12px 26px rgba(15, 118, 110, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-copy {
|
||||||
|
font-weight: 900;
|
||||||
|
color: var(--rd-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
border-radius: 999px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
padding: 0.78rem 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-lg {
|
||||||
|
padding: 0.95rem 1.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
--bs-btn-bg: var(--rd-primary);
|
||||||
|
--bs-btn-border-color: var(--rd-primary);
|
||||||
|
--bs-btn-hover-bg: var(--rd-primary-dark);
|
||||||
|
--bs-btn-hover-border-color: var(--rd-primary-dark);
|
||||||
|
--bs-btn-active-bg: var(--rd-primary-dark);
|
||||||
|
--bs-btn-active-border-color: var(--rd-primary-dark);
|
||||||
|
box-shadow: 0 14px 28px rgba(15, 118, 110, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
--bs-btn-bg: var(--rd-secondary);
|
||||||
|
--bs-btn-border-color: var(--rd-secondary);
|
||||||
|
--bs-btn-hover-bg: var(--rd-secondary-dark);
|
||||||
|
--bs-btn-hover-border-color: var(--rd-secondary-dark);
|
||||||
|
--bs-btn-active-bg: var(--rd-secondary-dark);
|
||||||
|
--bs-btn-active-border-color: var(--rd-secondary-dark);
|
||||||
|
box-shadow: 0 14px 28px rgba(249, 115, 22, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary-soft {
|
||||||
|
color: var(--rd-primary-dark);
|
||||||
|
background: var(--rd-primary-soft);
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary-soft:hover,
|
||||||
|
.btn-primary-soft:focus {
|
||||||
|
color: #fff;
|
||||||
|
background: var(--rd-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline-dark {
|
||||||
|
--bs-btn-color: var(--rd-ink);
|
||||||
|
--bs-btn-border-color: rgba(19, 35, 33, 0.2);
|
||||||
|
--bs-btn-hover-bg: var(--rd-ink);
|
||||||
|
--bs-btn-hover-border-color: var(--rd-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control,
|
||||||
|
.form-select {
|
||||||
|
border: 1px solid var(--rd-border);
|
||||||
|
border-radius: 1rem;
|
||||||
|
padding: 0.82rem 1rem;
|
||||||
|
color: var(--rd-ink);
|
||||||
|
background-color: rgba(255, 255, 255, 0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control:focus,
|
||||||
|
.form-select:focus {
|
||||||
|
border-color: var(--rd-primary);
|
||||||
|
box-shadow: 0 0 0 0.22rem rgba(15, 118, 110, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea.form-control {
|
||||||
|
min-height: 8.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
color: var(--rd-ink-soft);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-stack {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1080;
|
||||||
|
top: 5.5rem;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
max-width: 720px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-success {
|
||||||
|
--bs-alert-color: #0b5f40;
|
||||||
|
--bs-alert-bg: #dcfce7;
|
||||||
|
--bs-alert-border-color: #bbf7d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-section {
|
||||||
|
padding-block: 3rem 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.py-lg-6 {
|
||||||
|
padding-top: 5.5rem !important;
|
||||||
|
padding-bottom: 5.5rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-shape {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 999px;
|
||||||
|
filter: blur(3px);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-shape-one {
|
||||||
|
width: 18rem;
|
||||||
|
height: 18rem;
|
||||||
|
right: 6%;
|
||||||
|
top: 6%;
|
||||||
|
background: linear-gradient(135deg, rgba(34, 197, 94, 0.2), rgba(15, 118, 110, 0.08));
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-shape-two {
|
||||||
|
width: 12rem;
|
||||||
|
height: 12rem;
|
||||||
|
left: 4%;
|
||||||
|
bottom: 0;
|
||||||
|
background: linear-gradient(135deg, rgba(249, 115, 22, 0.18), rgba(255, 247, 237, 0.8));
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow,
|
||||||
|
.section-kicker {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
color: var(--rd-primary-dark);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow::before,
|
||||||
|
.section-kicker::before {
|
||||||
|
content: "";
|
||||||
|
width: 0.65rem;
|
||||||
|
height: 0.65rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--rd-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead {
|
||||||
|
color: var(--rd-muted) !important;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-search {
|
||||||
|
max-width: 720px;
|
||||||
|
padding: 0.45rem;
|
||||||
|
background: rgba(255, 255, 255, 0.72);
|
||||||
|
border: 1px solid rgba(220, 233, 229, 0.95);
|
||||||
|
border-radius: 1.35rem;
|
||||||
|
box-shadow: 0 18px 54px rgba(15, 60, 55, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-search .form-control {
|
||||||
|
border-color: transparent;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-card,
|
||||||
|
.support-card,
|
||||||
|
.metric-card,
|
||||||
|
.mini-panel,
|
||||||
|
.confirmation-card {
|
||||||
|
background: rgba(255, 255, 255, 0.86);
|
||||||
|
border: 1px solid rgba(220, 233, 229, 0.95);
|
||||||
|
border-radius: var(--rd-radius);
|
||||||
|
box-shadow: var(--rd-shadow);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
-webkit-backdrop-filter: blur(18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-intake-card,
|
||||||
|
.support-card,
|
||||||
|
.mini-panel {
|
||||||
|
padding: clamp(1.25rem, 2.3vw, 2rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spark-icon {
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
min-width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
border-radius: 1.1rem;
|
||||||
|
color: var(--rd-secondary-dark);
|
||||||
|
background: var(--rd-cream);
|
||||||
|
font-size: 1.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card {
|
||||||
|
padding: 1.25rem;
|
||||||
|
min-height: 8.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card span {
|
||||||
|
color: var(--rd-muted);
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card strong {
|
||||||
|
color: var(--rd-ink);
|
||||||
|
font-family: "Manrope", sans-serif;
|
||||||
|
font-size: clamp(2rem, 5vw, 3rem);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-steps {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-steps div {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
gap: 0.15rem 0.9rem;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 1.2rem;
|
||||||
|
background: #f7fbf9;
|
||||||
|
border: 1px solid var(--rd-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-steps span {
|
||||||
|
grid-row: span 2;
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 2.25rem;
|
||||||
|
height: 2.25rem;
|
||||||
|
border-radius: 0.85rem;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 900;
|
||||||
|
background: var(--rd-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-steps small,
|
||||||
|
.ticket-row-meta,
|
||||||
|
.reply-meta span {
|
||||||
|
color: var(--rd-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-list-mini {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-mini-row,
|
||||||
|
.ticket-row {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-mini-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid var(--rd-border);
|
||||||
|
border-radius: 1.25rem;
|
||||||
|
background: #fbfefd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-mini-row:hover,
|
||||||
|
.ticket-row:hover {
|
||||||
|
color: inherit;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
border-color: rgba(15, 118, 110, 0.35);
|
||||||
|
box-shadow: 0 18px 42px rgba(15, 60, 55, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-mini-row small {
|
||||||
|
display: block;
|
||||||
|
color: var(--rd-muted);
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge,
|
||||||
|
.priority-pill,
|
||||||
|
.reply-count {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: max-content;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.38rem 0.72rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 900;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-new {
|
||||||
|
color: #0f5d56;
|
||||||
|
background: #ccfbf1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-triage {
|
||||||
|
color: #9a3412;
|
||||||
|
background: #ffedd5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-waiting {
|
||||||
|
color: #854d0e;
|
||||||
|
background: #fef3c7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-resolved {
|
||||||
|
color: #166534;
|
||||||
|
background: #dcfce7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.priority-low {
|
||||||
|
color: #166534;
|
||||||
|
background: #dcfce7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.priority-normal {
|
||||||
|
color: #0f5d56;
|
||||||
|
background: #e0f2fe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.priority-high {
|
||||||
|
color: #9a3412;
|
||||||
|
background: #ffedd5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.priority-urgent {
|
||||||
|
color: #991b1b;
|
||||||
|
background: #fee2e2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-hero {
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 82% 20%, rgba(34, 197, 94, 0.12), transparent 18rem),
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.75), rgba(245, 251, 248, 0.75));
|
||||||
|
border-bottom: 1px solid rgba(220, 233, 229, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-table {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
gap: 1.5rem;
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--rd-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-row:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-row-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 0.25rem;
|
||||||
|
min-width: 12rem;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-shape {
|
||||||
|
width: 5.5rem;
|
||||||
|
height: 5.5rem;
|
||||||
|
margin: 0 auto 1.2rem;
|
||||||
|
border-radius: 1.65rem;
|
||||||
|
background: linear-gradient(135deg, var(--rd-primary-soft), var(--rd-cream));
|
||||||
|
box-shadow: inset -16px -16px 28px rgba(15, 118, 110, 0.08), 0 18px 42px rgba(15, 60, 55, 0.14);
|
||||||
|
transform: rotate(8deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirmation-card {
|
||||||
|
max-width: 820px;
|
||||||
|
padding: clamp(2rem, 5vw, 4rem);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-orb {
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 4.5rem;
|
||||||
|
height: 4.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 900;
|
||||||
|
background: linear-gradient(135deg, var(--rd-primary), var(--rd-accent));
|
||||||
|
box-shadow: 0 18px 38px rgba(15, 118, 110, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-summary-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-summary-grid div {
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid var(--rd-border);
|
||||||
|
border-radius: 1.2rem;
|
||||||
|
background: #fbfefd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-summary-grid span {
|
||||||
|
display: block;
|
||||||
|
color: var(--rd-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-summary-grid strong {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-description {
|
||||||
|
color: var(--rd-ink-soft);
|
||||||
|
font-size: 1.04rem;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0.69rem;
|
||||||
|
top: 0.8rem;
|
||||||
|
bottom: 0.8rem;
|
||||||
|
width: 2px;
|
||||||
|
background: var(--rd-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.45rem 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-dot {
|
||||||
|
width: 0.9rem;
|
||||||
|
height: 0.9rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
border: 3px solid #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--rd-secondary);
|
||||||
|
box-shadow: 0 0 0 3px rgba(249, 115, 22, 0.2);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item.staff .timeline-dot {
|
||||||
|
background: var(--rd-primary);
|
||||||
|
box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-card {
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid var(--rd-border);
|
||||||
|
border-radius: 1.2rem;
|
||||||
|
background: #fbfefd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item.staff .reply-card {
|
||||||
|
background: #eefcf8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-thread {
|
||||||
|
margin-left: 2.45rem;
|
||||||
|
padding: 1rem;
|
||||||
|
color: var(--rd-muted);
|
||||||
|
background: #fbfefd;
|
||||||
|
border: 1px dashed var(--rd-border);
|
||||||
|
border-radius: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sticky-summary {
|
||||||
|
position: sticky;
|
||||||
|
top: 6.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-facts {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.85rem;
|
||||||
|
margin: 1rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-facts div {
|
||||||
|
padding-bottom: 0.85rem;
|
||||||
|
border-bottom: 1px solid var(--rd-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-facts div:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-facts dt {
|
||||||
|
color: var(--rd-muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 900;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-facts dd {
|
||||||
|
margin: 0.2rem 0 0;
|
||||||
|
color: var(--rd-ink);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-footer {
|
||||||
|
color: var(--rd-muted);
|
||||||
|
background: rgba(255, 255, 255, 0.74);
|
||||||
|
border-top: 1px solid var(--rd-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-links a {
|
||||||
|
color: var(--rd-ink-soft);
|
||||||
|
font-weight: 800;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-links a:hover {
|
||||||
|
color: var(--rd-primary-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 991.98px) {
|
||||||
|
.ticket-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-row-meta {
|
||||||
|
align-items: flex-start;
|
||||||
|
min-width: 0;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sticky-summary {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767.98px) {
|
||||||
|
.hero-section {
|
||||||
|
padding-block: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-summary-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-stack {
|
||||||
|
top: 4.75rem;
|
||||||
|
width: calc(100% - 2rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 575.98px) {
|
||||||
|
.ticket-summary-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-mini-row {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,21 +1,699 @@
|
|||||||
|
/* RelayDesk custom theme: Bootstrap 5 + branded overrides */
|
||||||
:root {
|
:root {
|
||||||
--bg-color-start: #6a11cb;
|
--rd-ink: #132321;
|
||||||
--bg-color-end: #2575fc;
|
--rd-ink-soft: #334e49;
|
||||||
--text-color: #ffffff;
|
--rd-muted: #64748b;
|
||||||
--card-bg-color: rgba(255, 255, 255, 0.01);
|
--rd-primary: #0f766e;
|
||||||
--card-border-color: rgba(255, 255, 255, 0.1);
|
--rd-primary-dark: #0b5f59;
|
||||||
|
--rd-primary-soft: #d9f7f2;
|
||||||
|
--rd-secondary: #f97316;
|
||||||
|
--rd-secondary-dark: #c64f05;
|
||||||
|
--rd-accent: #22c55e;
|
||||||
|
--rd-cream: #fff7ed;
|
||||||
|
--rd-bg: #f5fbf8;
|
||||||
|
--rd-surface: #ffffff;
|
||||||
|
--rd-border: #dce9e5;
|
||||||
|
--rd-shadow: 0 24px 70px rgba(15, 60, 55, 0.14);
|
||||||
|
--rd-radius: 26px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
|
||||||
font-family: 'Inter', sans-serif;
|
|
||||||
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
|
|
||||||
color: var(--text-color);
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
text-align: center;
|
font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
overflow: hidden;
|
color: var(--rd-ink);
|
||||||
position: relative;
|
background:
|
||||||
|
radial-gradient(circle at 8% 8%, rgba(34, 197, 94, 0.13), transparent 30rem),
|
||||||
|
radial-gradient(circle at 88% 4%, rgba(249, 115, 22, 0.12), transparent 26rem),
|
||||||
|
linear-gradient(180deg, #fbfffd 0%, var(--rd-bg) 48%, #ffffff 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5, h6, .brand-copy {
|
||||||
|
font-family: "Manrope", "Inter", sans-serif;
|
||||||
|
letter-spacing: -0.035em;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--rd-primary-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color: var(--rd-secondary-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skip-link {
|
||||||
|
position: absolute;
|
||||||
|
left: 1rem;
|
||||||
|
top: -4rem;
|
||||||
|
z-index: 2000;
|
||||||
|
background: var(--rd-ink);
|
||||||
|
color: #fff;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
transition: top 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skip-link:focus {
|
||||||
|
top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-glass {
|
||||||
|
background: rgba(255, 255, 255, 0.82);
|
||||||
|
border-bottom: 1px solid rgba(220, 233, 229, 0.9);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
-webkit-backdrop-filter: blur(18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar .nav-link {
|
||||||
|
color: var(--rd-ink-soft);
|
||||||
|
font-weight: 700;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding-inline: 0.9rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar .nav-link:hover,
|
||||||
|
.navbar .nav-link:focus {
|
||||||
|
color: var(--rd-primary-dark);
|
||||||
|
background: rgba(15, 118, 110, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-mark {
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
border-radius: 0.95rem;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 900;
|
||||||
|
background: linear-gradient(135deg, var(--rd-primary), var(--rd-accent));
|
||||||
|
box-shadow: 0 12px 26px rgba(15, 118, 110, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-copy {
|
||||||
|
font-weight: 900;
|
||||||
|
color: var(--rd-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
border-radius: 999px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
padding: 0.78rem 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-lg {
|
||||||
|
padding: 0.95rem 1.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
--bs-btn-bg: var(--rd-primary);
|
||||||
|
--bs-btn-border-color: var(--rd-primary);
|
||||||
|
--bs-btn-hover-bg: var(--rd-primary-dark);
|
||||||
|
--bs-btn-hover-border-color: var(--rd-primary-dark);
|
||||||
|
--bs-btn-active-bg: var(--rd-primary-dark);
|
||||||
|
--bs-btn-active-border-color: var(--rd-primary-dark);
|
||||||
|
box-shadow: 0 14px 28px rgba(15, 118, 110, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
--bs-btn-bg: var(--rd-secondary);
|
||||||
|
--bs-btn-border-color: var(--rd-secondary);
|
||||||
|
--bs-btn-hover-bg: var(--rd-secondary-dark);
|
||||||
|
--bs-btn-hover-border-color: var(--rd-secondary-dark);
|
||||||
|
--bs-btn-active-bg: var(--rd-secondary-dark);
|
||||||
|
--bs-btn-active-border-color: var(--rd-secondary-dark);
|
||||||
|
box-shadow: 0 14px 28px rgba(249, 115, 22, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary-soft {
|
||||||
|
color: var(--rd-primary-dark);
|
||||||
|
background: var(--rd-primary-soft);
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary-soft:hover,
|
||||||
|
.btn-primary-soft:focus {
|
||||||
|
color: #fff;
|
||||||
|
background: var(--rd-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline-dark {
|
||||||
|
--bs-btn-color: var(--rd-ink);
|
||||||
|
--bs-btn-border-color: rgba(19, 35, 33, 0.2);
|
||||||
|
--bs-btn-hover-bg: var(--rd-ink);
|
||||||
|
--bs-btn-hover-border-color: var(--rd-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control,
|
||||||
|
.form-select {
|
||||||
|
border: 1px solid var(--rd-border);
|
||||||
|
border-radius: 1rem;
|
||||||
|
padding: 0.82rem 1rem;
|
||||||
|
color: var(--rd-ink);
|
||||||
|
background-color: rgba(255, 255, 255, 0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control:focus,
|
||||||
|
.form-select:focus {
|
||||||
|
border-color: var(--rd-primary);
|
||||||
|
box-shadow: 0 0 0 0.22rem rgba(15, 118, 110, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea.form-control {
|
||||||
|
min-height: 8.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
color: var(--rd-ink-soft);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-stack {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1080;
|
||||||
|
top: 5.5rem;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
max-width: 720px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-success {
|
||||||
|
--bs-alert-color: #0b5f40;
|
||||||
|
--bs-alert-bg: #dcfce7;
|
||||||
|
--bs-alert-border-color: #bbf7d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-section {
|
||||||
|
padding-block: 3rem 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.py-lg-6 {
|
||||||
|
padding-top: 5.5rem !important;
|
||||||
|
padding-bottom: 5.5rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-shape {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 999px;
|
||||||
|
filter: blur(3px);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-shape-one {
|
||||||
|
width: 18rem;
|
||||||
|
height: 18rem;
|
||||||
|
right: 6%;
|
||||||
|
top: 6%;
|
||||||
|
background: linear-gradient(135deg, rgba(34, 197, 94, 0.2), rgba(15, 118, 110, 0.08));
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-shape-two {
|
||||||
|
width: 12rem;
|
||||||
|
height: 12rem;
|
||||||
|
left: 4%;
|
||||||
|
bottom: 0;
|
||||||
|
background: linear-gradient(135deg, rgba(249, 115, 22, 0.18), rgba(255, 247, 237, 0.8));
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow,
|
||||||
|
.section-kicker {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
color: var(--rd-primary-dark);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow::before,
|
||||||
|
.section-kicker::before {
|
||||||
|
content: "";
|
||||||
|
width: 0.65rem;
|
||||||
|
height: 0.65rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--rd-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead {
|
||||||
|
color: var(--rd-muted) !important;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-search {
|
||||||
|
max-width: 720px;
|
||||||
|
padding: 0.45rem;
|
||||||
|
background: rgba(255, 255, 255, 0.72);
|
||||||
|
border: 1px solid rgba(220, 233, 229, 0.95);
|
||||||
|
border-radius: 1.35rem;
|
||||||
|
box-shadow: 0 18px 54px rgba(15, 60, 55, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-search .form-control {
|
||||||
|
border-color: transparent;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-card,
|
||||||
|
.support-card,
|
||||||
|
.metric-card,
|
||||||
|
.mini-panel,
|
||||||
|
.confirmation-card {
|
||||||
|
background: rgba(255, 255, 255, 0.86);
|
||||||
|
border: 1px solid rgba(220, 233, 229, 0.95);
|
||||||
|
border-radius: var(--rd-radius);
|
||||||
|
box-shadow: var(--rd-shadow);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
-webkit-backdrop-filter: blur(18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-intake-card,
|
||||||
|
.support-card,
|
||||||
|
.mini-panel {
|
||||||
|
padding: clamp(1.25rem, 2.3vw, 2rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spark-icon {
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
min-width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
border-radius: 1.1rem;
|
||||||
|
color: var(--rd-secondary-dark);
|
||||||
|
background: var(--rd-cream);
|
||||||
|
font-size: 1.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card {
|
||||||
|
padding: 1.25rem;
|
||||||
|
min-height: 8.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card span {
|
||||||
|
color: var(--rd-muted);
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card strong {
|
||||||
|
color: var(--rd-ink);
|
||||||
|
font-family: "Manrope", sans-serif;
|
||||||
|
font-size: clamp(2rem, 5vw, 3rem);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-steps {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-steps div {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
gap: 0.15rem 0.9rem;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 1.2rem;
|
||||||
|
background: #f7fbf9;
|
||||||
|
border: 1px solid var(--rd-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-steps span {
|
||||||
|
grid-row: span 2;
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 2.25rem;
|
||||||
|
height: 2.25rem;
|
||||||
|
border-radius: 0.85rem;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 900;
|
||||||
|
background: var(--rd-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-steps small,
|
||||||
|
.ticket-row-meta,
|
||||||
|
.reply-meta span {
|
||||||
|
color: var(--rd-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-list-mini {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-mini-row,
|
||||||
|
.ticket-row {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-mini-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid var(--rd-border);
|
||||||
|
border-radius: 1.25rem;
|
||||||
|
background: #fbfefd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-mini-row:hover,
|
||||||
|
.ticket-row:hover {
|
||||||
|
color: inherit;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
border-color: rgba(15, 118, 110, 0.35);
|
||||||
|
box-shadow: 0 18px 42px rgba(15, 60, 55, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-mini-row small {
|
||||||
|
display: block;
|
||||||
|
color: var(--rd-muted);
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge,
|
||||||
|
.priority-pill,
|
||||||
|
.reply-count {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: max-content;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.38rem 0.72rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 900;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-new {
|
||||||
|
color: #0f5d56;
|
||||||
|
background: #ccfbf1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-triage {
|
||||||
|
color: #9a3412;
|
||||||
|
background: #ffedd5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-waiting {
|
||||||
|
color: #854d0e;
|
||||||
|
background: #fef3c7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-resolved {
|
||||||
|
color: #166534;
|
||||||
|
background: #dcfce7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.priority-low {
|
||||||
|
color: #166534;
|
||||||
|
background: #dcfce7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.priority-normal {
|
||||||
|
color: #0f5d56;
|
||||||
|
background: #e0f2fe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.priority-high {
|
||||||
|
color: #9a3412;
|
||||||
|
background: #ffedd5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.priority-urgent {
|
||||||
|
color: #991b1b;
|
||||||
|
background: #fee2e2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-hero {
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 82% 20%, rgba(34, 197, 94, 0.12), transparent 18rem),
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.75), rgba(245, 251, 248, 0.75));
|
||||||
|
border-bottom: 1px solid rgba(220, 233, 229, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-table {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
gap: 1.5rem;
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--rd-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-row:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-row-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 0.25rem;
|
||||||
|
min-width: 12rem;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-shape {
|
||||||
|
width: 5.5rem;
|
||||||
|
height: 5.5rem;
|
||||||
|
margin: 0 auto 1.2rem;
|
||||||
|
border-radius: 1.65rem;
|
||||||
|
background: linear-gradient(135deg, var(--rd-primary-soft), var(--rd-cream));
|
||||||
|
box-shadow: inset -16px -16px 28px rgba(15, 118, 110, 0.08), 0 18px 42px rgba(15, 60, 55, 0.14);
|
||||||
|
transform: rotate(8deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirmation-card {
|
||||||
|
max-width: 820px;
|
||||||
|
padding: clamp(2rem, 5vw, 4rem);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-orb {
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 4.5rem;
|
||||||
|
height: 4.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 900;
|
||||||
|
background: linear-gradient(135deg, var(--rd-primary), var(--rd-accent));
|
||||||
|
box-shadow: 0 18px 38px rgba(15, 118, 110, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-summary-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-summary-grid div {
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid var(--rd-border);
|
||||||
|
border-radius: 1.2rem;
|
||||||
|
background: #fbfefd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-summary-grid span {
|
||||||
|
display: block;
|
||||||
|
color: var(--rd-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-summary-grid strong {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-description {
|
||||||
|
color: var(--rd-ink-soft);
|
||||||
|
font-size: 1.04rem;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0.69rem;
|
||||||
|
top: 0.8rem;
|
||||||
|
bottom: 0.8rem;
|
||||||
|
width: 2px;
|
||||||
|
background: var(--rd-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.45rem 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-dot {
|
||||||
|
width: 0.9rem;
|
||||||
|
height: 0.9rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
border: 3px solid #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--rd-secondary);
|
||||||
|
box-shadow: 0 0 0 3px rgba(249, 115, 22, 0.2);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item.staff .timeline-dot {
|
||||||
|
background: var(--rd-primary);
|
||||||
|
box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-card {
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid var(--rd-border);
|
||||||
|
border-radius: 1.2rem;
|
||||||
|
background: #fbfefd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item.staff .reply-card {
|
||||||
|
background: #eefcf8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-thread {
|
||||||
|
margin-left: 2.45rem;
|
||||||
|
padding: 1rem;
|
||||||
|
color: var(--rd-muted);
|
||||||
|
background: #fbfefd;
|
||||||
|
border: 1px dashed var(--rd-border);
|
||||||
|
border-radius: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sticky-summary {
|
||||||
|
position: sticky;
|
||||||
|
top: 6.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-facts {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.85rem;
|
||||||
|
margin: 1rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-facts div {
|
||||||
|
padding-bottom: 0.85rem;
|
||||||
|
border-bottom: 1px solid var(--rd-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-facts div:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-facts dt {
|
||||||
|
color: var(--rd-muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 900;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-facts dd {
|
||||||
|
margin: 0.2rem 0 0;
|
||||||
|
color: var(--rd-ink);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-footer {
|
||||||
|
color: var(--rd-muted);
|
||||||
|
background: rgba(255, 255, 255, 0.74);
|
||||||
|
border-top: 1px solid var(--rd-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-links a {
|
||||||
|
color: var(--rd-ink-soft);
|
||||||
|
font-weight: 800;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-links a:hover {
|
||||||
|
color: var(--rd-primary-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 991.98px) {
|
||||||
|
.ticket-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-row-meta {
|
||||||
|
align-items: flex-start;
|
||||||
|
min-width: 0;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sticky-summary {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767.98px) {
|
||||||
|
.hero-section {
|
||||||
|
padding-block: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-summary-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-stack {
|
||||||
|
top: 4.75rem;
|
||||||
|
width: calc(100% - 2rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 575.98px) {
|
||||||
|
.ticket-summary-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-mini-row {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
submit-ticket-desktop.png
Normal file
BIN
submit-ticket-desktop.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 569 KiB |
BIN
submit-ticket-mobile.png
Normal file
BIN
submit-ticket-mobile.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 300 KiB |
Loading…
x
Reference in New Issue
Block a user