diff --git a/config/__pycache__/__init__.cpython-311.pyc b/config/__pycache__/__init__.cpython-311.pyc
index 896bb4f..841a2c9 100644
Binary files a/config/__pycache__/__init__.cpython-311.pyc and b/config/__pycache__/__init__.cpython-311.pyc differ
diff --git a/config/__pycache__/settings.cpython-311.pyc b/config/__pycache__/settings.cpython-311.pyc
index d79d6a7..2d47f12 100644
Binary files a/config/__pycache__/settings.cpython-311.pyc and b/config/__pycache__/settings.cpython-311.pyc differ
diff --git a/config/__pycache__/urls.cpython-311.pyc b/config/__pycache__/urls.cpython-311.pyc
index 8cf22af..821ff2b 100644
Binary files a/config/__pycache__/urls.cpython-311.pyc and b/config/__pycache__/urls.cpython-311.pyc differ
diff --git a/config/__pycache__/wsgi.cpython-311.pyc b/config/__pycache__/wsgi.cpython-311.pyc
index a1b4aa7..3f1bdf7 100644
Binary files a/config/__pycache__/wsgi.cpython-311.pyc and b/config/__pycache__/wsgi.cpython-311.pyc differ
diff --git a/core/__pycache__/__init__.cpython-311.pyc b/core/__pycache__/__init__.cpython-311.pyc
index 3f553f6..c67fb7c 100644
Binary files a/core/__pycache__/__init__.cpython-311.pyc and b/core/__pycache__/__init__.cpython-311.pyc differ
diff --git a/core/__pycache__/admin.cpython-311.pyc b/core/__pycache__/admin.cpython-311.pyc
index 5e8987a..6437003 100644
Binary files a/core/__pycache__/admin.cpython-311.pyc and b/core/__pycache__/admin.cpython-311.pyc differ
diff --git a/core/__pycache__/apps.cpython-311.pyc b/core/__pycache__/apps.cpython-311.pyc
index 2fa4a49..d38e87a 100644
Binary files a/core/__pycache__/apps.cpython-311.pyc and b/core/__pycache__/apps.cpython-311.pyc differ
diff --git a/core/__pycache__/context_processors.cpython-311.pyc b/core/__pycache__/context_processors.cpython-311.pyc
index 75bf223..9f0d029 100644
Binary files a/core/__pycache__/context_processors.cpython-311.pyc and b/core/__pycache__/context_processors.cpython-311.pyc differ
diff --git a/core/__pycache__/forms.cpython-311.pyc b/core/__pycache__/forms.cpython-311.pyc
new file mode 100644
index 0000000..2e26f7d
Binary files /dev/null and b/core/__pycache__/forms.cpython-311.pyc differ
diff --git a/core/__pycache__/models.cpython-311.pyc b/core/__pycache__/models.cpython-311.pyc
index a251b5f..29cd558 100644
Binary files a/core/__pycache__/models.cpython-311.pyc and b/core/__pycache__/models.cpython-311.pyc differ
diff --git a/core/__pycache__/tests.cpython-311.pyc b/core/__pycache__/tests.cpython-311.pyc
new file mode 100644
index 0000000..1aa5105
Binary files /dev/null and b/core/__pycache__/tests.cpython-311.pyc differ
diff --git a/core/__pycache__/urls.cpython-311.pyc b/core/__pycache__/urls.cpython-311.pyc
index f705988..c4cd525 100644
Binary files a/core/__pycache__/urls.cpython-311.pyc and b/core/__pycache__/urls.cpython-311.pyc differ
diff --git a/core/__pycache__/views.cpython-311.pyc b/core/__pycache__/views.cpython-311.pyc
index 2f0989c..69c5b0c 100644
Binary files a/core/__pycache__/views.cpython-311.pyc and b/core/__pycache__/views.cpython-311.pyc differ
diff --git a/core/admin.py b/core/admin.py
index 8c38f3f..9b133e2 100644
--- a/core/admin.py
+++ b/core/admin.py
@@ -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")
diff --git a/core/forms.py b/core/forms.py
new file mode 100644
index 0000000..4652660
--- /dev/null
+++ b/core/forms.py
@@ -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()
diff --git a/core/migrations/0001_initial.py b/core/migrations/0001_initial.py
new file mode 100644
index 0000000..e66206c
--- /dev/null
+++ b/core/migrations/0001_initial.py
@@ -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'),
+ ),
+ ]
diff --git a/core/migrations/__pycache__/0001_initial.cpython-311.pyc b/core/migrations/__pycache__/0001_initial.cpython-311.pyc
new file mode 100644
index 0000000..7e591fa
Binary files /dev/null and b/core/migrations/__pycache__/0001_initial.cpython-311.pyc differ
diff --git a/core/migrations/__pycache__/__init__.cpython-311.pyc b/core/migrations/__pycache__/__init__.cpython-311.pyc
index 7995815..775fc1c 100644
Binary files a/core/migrations/__pycache__/__init__.cpython-311.pyc and b/core/migrations/__pycache__/__init__.cpython-311.pyc differ
diff --git a/core/models.py b/core/models.py
index 71a8362..48eee85 100644
--- a/core/models.py
+++ b/core/models.py
@@ -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}"
diff --git a/core/templates/base.html b/core/templates/base.html
index 1e7e5fb..f35051c 100644
--- a/core/templates/base.html
+++ b/core/templates/base.html
@@ -1,25 +1,71 @@
+{% load static %}
- {% block title %}Knowledge Base{% endblock %}
- {% if project_description %}
-
-
-
- {% endif %}
+
+ {% block title %}{{ project_name|default:"RelayDesk" }}{% endblock %}
+
{% if project_image_url %}
{% endif %}
- {% load static %}
+
+
+
+
{% block head %}{% endblock %}
- {% block content %}{% endblock %}
+ Skip to content
+
+
+
+
+ {% if messages %}
+
+ {% for message in messages %}
+
{{ message }}
+ {% endfor %}
+
+ {% endif %}
+
+
+ {% block content %}{% endblock %}
+
+
+
+
+
diff --git a/core/templates/core/index.html b/core/templates/core/index.html
index faec813..4d36f96 100644
--- a/core/templates/core/index.html
+++ b/core/templates/core/index.html
@@ -1,145 +1,154 @@
{% extends "base.html" %}
-{% block title %}{{ project_name }}{% endblock %}
-
-{% block head %}
-
-
-
-
-{% 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 %}
-
-
-
Analyzing your requirements and generating your app…
-
-
Loading…
+
+
+
+
+
+
+
Internal support center
+
Support that moves every ticket from request to resolved.
+
RelayDesk gives your team a lightweight intake desk, live status board, searchable ticket queue, and threaded replies in a polished Bootstrap workspace.
+
+
+
+
+
+
+
+
Fast intake
+
Create a ticket
+
Capture the request and start a trackable support thread.
+
+
✦
+
+
+
+
-
AppWizzy AI is collecting your requirements and applying the first changes.
-
This page will refresh automatically as the plan is implemented.
-
- Runtime: Django {{ django_version }} · Python {{ python_version }}
- — UTC {{ current_time|date:"Y-m-d H:i:s" }}
-
-
-
- Page updated: {{ current_time|date:"Y-m-d H:i:s" }} (UTC)
-
-{% endblock %}
\ No newline at end of file
+
+
+
+
+
+
+ Total tickets
+ {{ stats.total }}
+
+
+
+
+ Open
+ {{ stats.open }}
+
+
+
+
+ In triage
+ {{ stats.triage }}
+
+
+
+
+ Resolved
+ {{ stats.resolved }}
+
+
+
+
+
+
+
+
+
+
Workflow
+
Simple, visible triage
+
Every request starts with category and priority, lands in the queue, then moves through status updates and replies until it is resolved.
+
+
1 Submit Requester creates a ticket
+
2 Triage Staff filters, assigns, and updates status
+
3 Resolve Threaded replies keep context together
+
+
+
+
+
+
+
+ Live queue
+
Recent tickets
+
+
View all
+
+ {% if recent_tickets %}
+
+ {% else %}
+
+
+
No tickets yet
+
Create the first request from the intake form and it will appear here instantly.
+
+ {% endif %}
+
+
+
+
+{% endblock %}
diff --git a/core/templates/core/partials/field_errors.html b/core/templates/core/partials/field_errors.html
new file mode 100644
index 0000000..1e2daf5
--- /dev/null
+++ b/core/templates/core/partials/field_errors.html
@@ -0,0 +1,5 @@
+{% if field.errors %}
+
+ {% for error in field.errors %}{{ error }}{% if not forloop.last %} {% endif %}{% endfor %}
+
+{% endif %}
diff --git a/core/templates/core/partials/form_errors.html b/core/templates/core/partials/form_errors.html
new file mode 100644
index 0000000..a599161
--- /dev/null
+++ b/core/templates/core/partials/form_errors.html
@@ -0,0 +1,5 @@
+{% if form.non_field_errors %}
+
+ {% for error in form.non_field_errors %}
{{ error }}
{% endfor %}
+
+{% endif %}
diff --git a/core/templates/core/ticket_detail.html b/core/templates/core/ticket_detail.html
new file mode 100644
index 0000000..0783046
--- /dev/null
+++ b/core/templates/core/ticket_detail.html
@@ -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 %}
+
+
+
+
+
+ {{ ticket.ticket_number }}
+ {{ ticket.get_status_display }}
+ {{ ticket.get_priority_display }}
+
+
{{ ticket.subject }}
+
Submitted by {{ ticket.requester_name }} in {{ ticket.get_category_display }} · Updated {{ ticket.updated_at|timesince }} ago
+
+
+
+
+
+
+
+
+
+
+ Original request
+ {{ ticket.description|linebreaksbr }}
+
+
+
+
+
+ Threaded replies
+
Conversation
+
+
{{ ticket.replies.count }} repl{{ ticket.replies.count|pluralize:"y,ies" }}
+
+
+
+
+
+
+
+ {{ ticket.requester_name }}
+ opened this ticket · {{ ticket.created_at|date:"M j, Y g:i A" }}
+
+
{{ ticket.description|linebreaksbr }}
+
+
+ {% for reply in ticket.replies.all %}
+
+
+
+
+ {{ reply.author_name }}
+ {% if reply.is_staff_reply %}support agent{% else %}requester{% endif %} · {{ reply.created_at|date:"M j, Y g:i A" }}
+
+
{{ reply.body|linebreaksbr }}
+
+
+ {% empty %}
+
No replies yet. Add the first update below.
+ {% endfor %}
+
+
+
+
+
Add update
+
Post a reply
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/core/templates/core/ticket_form.html b/core/templates/core/ticket_form.html
new file mode 100644
index 0000000..fe5a9c0
--- /dev/null
+++ b/core/templates/core/ticket_form.html
@@ -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 %}
+
+
+
+
+
New support request
+
Tell the team what needs attention.
+
Your submission creates a trackable ticket with a dedicated reply thread and status timeline.
+
+
+
+
What happens next?
+
We’ll create your ticket, show a confirmation page, and add it to the searchable support queue.
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/core/templates/core/ticket_list.html b/core/templates/core/ticket_list.html
new file mode 100644
index 0000000..9550e69
--- /dev/null
+++ b/core/templates/core/ticket_list.html
@@ -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 %}
+
+
+
+
+
Ticket queue
+
Search, filter, and triage support work.
+
A clean queue for incoming requests, active triage, waiting tickets, and resolved work.
+
+
+
+
+
+
+
+
+
+
+
In triage {{ stats.triage }}
+
Resolved {{ stats.resolved }}
+
+
+
+
+
+
+ {% if tickets %}
+
+ {% else %}
+
+
+
No tickets match these filters
+
Try clearing filters or create the first ticket for this queue.
+
Submit ticket
+
+ {% endif %}
+
+{% endblock %}
diff --git a/core/templates/core/ticket_success.html b/core/templates/core/ticket_success.html
new file mode 100644
index 0000000..984d54e
--- /dev/null
+++ b/core/templates/core/ticket_success.html
@@ -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 %}
+
+
+
✓
+
Ticket created
+
{{ ticket.ticket_number }} is ready to track.
+
We captured your request and created a dedicated thread for replies and status updates.
+
+
Status {{ ticket.get_status_display }}
+
Priority {{ ticket.get_priority_display }}
+
Category {{ ticket.get_category_display }}
+
Requester {{ ticket.requester_name }}
+
+
+
+
+{% endblock %}
diff --git a/core/tests.py b/core/tests.py
index 7ce503c..d657110 100644
--- a/core/tests.py
+++ b/core/tests.py
@@ -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")
diff --git a/core/urls.py b/core/urls.py
index 6299e3d..4c566ac 100644
--- a/core/urls.py
+++ b/core/urls.py
@@ -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/
/", ticket_detail, name="ticket_detail"),
+ path("tickets//submitted/", ticket_success, name="ticket_success"),
]
diff --git a/core/views.py b/core/views.py
index c9aed12..41cf181 100644
--- a/core/views.py
+++ b/core/views.py
@@ -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)
diff --git a/main-page-before.png b/main-page-before.png
new file mode 100644
index 0000000..5e9c763
Binary files /dev/null and b/main-page-before.png differ
diff --git a/open-queue-desktop.png b/open-queue-desktop.png
new file mode 100644
index 0000000..3dbfe0f
Binary files /dev/null and b/open-queue-desktop.png differ
diff --git a/open-queue-mobile.png b/open-queue-mobile.png
new file mode 100644
index 0000000..c76fc59
Binary files /dev/null and b/open-queue-mobile.png differ
diff --git a/static/css/custom.css b/static/css/custom.css
index 925f6ed..d612e10 100644
--- a/static/css/custom.css
+++ b/static/css/custom.css
@@ -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;
+ }
}
diff --git a/staticfiles/css/custom.css b/staticfiles/css/custom.css
index 108056f..d612e10 100644
--- a/staticfiles/css/custom.css
+++ b/staticfiles/css/custom.css
@@ -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;
+ }
}
diff --git a/submit-ticket-desktop.png b/submit-ticket-desktop.png
new file mode 100644
index 0000000..28210c7
Binary files /dev/null and b/submit-ticket-desktop.png differ
diff --git a/submit-ticket-mobile.png b/submit-ticket-mobile.png
new file mode 100644
index 0000000..5edecdd
Binary files /dev/null and b/submit-ticket-mobile.png differ