Compare commits

...

1 Commits

Author SHA1 Message Date
Flatlogic Bot
4634eea463 Autosave: 20260526-155514 2026-05-26 15:54:52 +00:00
37 changed files with 2395 additions and 188 deletions

Binary file not shown.

Binary file not shown.

View File

@ -1,3 +1,26 @@
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
View 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()

View 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'),
),
]

View File

@ -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}"

View File

@ -1,25 +1,71 @@
{% load static %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>{% block title %}Knowledge Base{% endblock %}</title>
{% if project_description %}
<meta name="description" content="{{ project_description }}">
<meta property="og:description" content="{{ project_description }}">
<meta property="twitter:description" content="{{ project_description }}">
{% endif %}
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}{{ project_name|default:"RelayDesk" }}{% endblock %}</title>
<meta name="description" content="{% block meta_description %}{{ page_description|default:project_description|default:'Lightweight support ticketing for small teams.' }}{% endblock %}">
{% if project_image_url %}
<meta property="og:image" content="{{ project_image_url }}">
<meta property="twitter:image" content="{{ project_image_url }}">
{% 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 }}">
{% block head %}{% endblock %}
</head>
<body>
{% block content %}{% endblock %}
<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 %}
</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>
</html>

View File

@ -1,145 +1,154 @@
{% extends "base.html" %}
{% block title %}{{ project_name }}{% 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 title %}RelayDesk Support Center{% endblock %}
{% block meta_description %}Submit, triage, and resolve lightweight support tickets with threaded replies and clean status tracking.{% endblock %}
{% block content %}
<main>
<div class="card">
<h1>Analyzing your requirements and generating your app…</h1>
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes">
<span class="sr-only">Loading…</span>
<section class="hero-section position-relative overflow-hidden">
<div class="hero-shape hero-shape-one" aria-hidden="true"></div>
<div class="hero-shape hero-shape-two" aria-hidden="true"></div>
<div class="container position-relative py-5 py-lg-6">
<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>
<div class="col-lg-5">
<div class="glass-card quick-intake-card">
<div class="d-flex align-items-start justify-content-between gap-3 mb-3">
<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>
<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>
</main>
<footer>
Page updated: {{ current_time|date:"Y-m-d H:i:s" }} (UTC)
</footer>
{% endblock %}
</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 %}

View 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 %}

View 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 %}

View 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 %}

View 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">Well 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 %}

View 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 %}

View 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 %}

View File

@ -1,3 +1,37 @@
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")

View File

@ -1,7 +1,11 @@
from django.urls import path
from .views import home
from .views import home, ticket_create, ticket_detail, ticket_list, ticket_success
urlpatterns = [
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"),
]

View File

@ -1,25 +1,160 @@
import os
import platform
from django.contrib import messages
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 django.shortcuts import render
from django.utils import timezone
from .forms import ReplyForm, TicketForm, TicketTriageForm
from .models import Ticket
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):
"""Render the landing screen with loader and environment details."""
host_name = request.get_host().lower()
agent_brand = "AppWizzy" if host_name == "appwizzy.com" else "Flatlogic"
now = timezone.now()
"""Render the branded support center landing page and quick ticket intake."""
if request.method == "POST":
ticket_form = TicketForm(request.POST)
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 = {
"project_name": "New Style",
"agent_brand": agent_brand,
"django_version": django_version(),
"python_version": platform.python_version(),
"current_time": now,
"host_name": host_name,
"project_description": os.getenv("PROJECT_DESCRIPTION", ""),
"project_image_url": os.getenv("PROJECT_IMAGE_URL", ""),
"project_name": "RelayDesk",
"page_description": "A lightweight support center for ticket submission, triage, threaded replies, and status tracking.",
"ticket_form": ticket_form,
"stats": _ticket_stats(),
"status_choices": Ticket.Status.choices,
"status_counts": status_counts,
"recent_tickets": recent_tickets,
}
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

Binary file not shown.

After

Width:  |  Height:  |  Size: 798 KiB

BIN
open-queue-desktop.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 595 KiB

BIN
open-queue-mobile.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 KiB

View File

@ -1,4 +1,699 @@
/* Custom styles for the application */
body {
font-family: system-ui, -apple-system, sans-serif;
/* RelayDesk custom theme: Bootstrap 5 + branded overrides */
:root {
--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;
}
}

View File

@ -1,21 +1,699 @@
/* RelayDesk custom theme: Bootstrap 5 + branded overrides */
: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);
--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 {
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;
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;
}
}

BIN
submit-ticket-desktop.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 569 KiB

BIN
submit-ticket-mobile.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 KiB