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 %} + + + + {% if messages %} +
+ {% for message in messages %} + + {% 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.

+
+ +
+
+ {% csrf_token %} + {% include "core/partials/form_errors.html" with form=ticket_form %} +
+
+ + {{ ticket_form.requester_name }} + {% include "core/partials/field_errors.html" with field=ticket_form.requester_name %} +
+
+ + {{ ticket_form.requester_email }} + {% include "core/partials/field_errors.html" with field=ticket_form.requester_email %} +
+
+ + {{ ticket_form.category }} + {% include "core/partials/field_errors.html" with field=ticket_form.category %} +
+
+ + {{ ticket_form.priority }} + {% include "core/partials/field_errors.html" with field=ticket_form.priority %} +
+
+ + {{ ticket_form.subject }} + {% include "core/partials/field_errors.html" with field=ticket_form.subject %} +
+
+ + {{ ticket_form.description }} + {% include "core/partials/field_errors.html" with field=ticket_form.description %} +
+
+ +
+
+
-

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

-
- -{% 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.

+
+
1SubmitRequester creates a ticket
+
2TriageStaff filters, assigns, and updates status
+
3ResolveThreaded 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 %} + +{% 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

+
+ {% csrf_token %} + + {% include "core/partials/form_errors.html" with form=reply_form %} +
+
+ + {{ reply_form.author_name }} + {% include "core/partials/field_errors.html" with field=reply_form.author_name %} +
+
+ + {{ reply_form.author_email }} + {% include "core/partials/field_errors.html" with field=reply_form.author_email %} +
+
+ + {{ reply_form.body }} + {% include "core/partials/field_errors.html" with field=reply_form.body %} +
+
+ +
+
+
+ + +
+
+{% 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.

+
+
+
+
+
+ +
+
+
+
+
+ {% csrf_token %} + {% include "core/partials/form_errors.html" with form=form %} +
+
+ + {{ form.requester_name }} + {% include "core/partials/field_errors.html" with field=form.requester_name %} +
+
+ + {{ form.requester_email }} + {% include "core/partials/field_errors.html" with field=form.requester_email %} +
+
+ + {{ form.category }} + {% include "core/partials/field_errors.html" with field=form.category %} +
+
+ + {{ form.priority }} + {% include "core/partials/field_errors.html" with field=form.priority %} +
+
+ + {{ form.subject }} + {% include "core/partials/field_errors.html" with field=form.subject %} +
+
+ + {{ form.description }} + {% include "core/partials/field_errors.html" with field=form.description %} +
+
+
+ + View 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.

+
+ +
+
+
+ +
+
+
Total{{ stats.total }}
+
Open{{ stats.open }}
+
In triage{{ stats.triage }}
+
Resolved{{ stats.resolved }}
+
+
+ +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ + {% if tickets %} +
+ {% for ticket in tickets %} + +
+
+ {{ ticket.ticket_number }} + {{ ticket.get_status_display }} + {{ ticket.get_priority_display }} +
+

{{ ticket.subject }}

+

{{ ticket.requester_name }} · {{ ticket.get_category_display }} · {{ ticket.reply_count }} repl{{ ticket.reply_count|pluralize:"y,ies" }}

+
+
+ Updated {{ ticket.updated_at|timesince }} ago + {% if ticket.assigned_to %}Assigned to {{ ticket.assigned_to.get_username }}{% else %}Unassigned{% endif %} +
+
+ {% endfor %} +
+ {% 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