diff --git a/config/__pycache__/__init__.cpython-311.pyc b/config/__pycache__/__init__.cpython-311.pyc
index 896bb4f..1c700f1 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..c73f884 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..02d2eb5 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..720e018 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..d46214a 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..f166cd2 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..6ef7199 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..b3ec9ef 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..d4ceba4
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..3f38032 100644
Binary files a/core/__pycache__/models.cpython-311.pyc and b/core/__pycache__/models.cpython-311.pyc differ
diff --git a/core/__pycache__/urls.cpython-311.pyc b/core/__pycache__/urls.cpython-311.pyc
index f705988..653b1c8 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..0d3d593 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..95f9704 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 PilotCall, WebhookEvent
+
+
+@admin.register(PilotCall)
+class PilotCallAdmin(admin.ModelAdmin):
+ list_display = ('client_name', 'phone', 'call_source', 'ytel_audio_status', 'logics_api_status', 'created_at')
+ list_filter = ('call_source', 'ytel_audio_status', 'logics_api_status', 'levy', 'garnishment')
+ search_fields = ('client_name', 'phone', 'email', 'transcript', 'agent_notes')
+ readonly_fields = ('created_at', 'updated_at')
+
+
+
+@admin.register(WebhookEvent)
+class WebhookEventAdmin(admin.ModelAdmin):
+ list_display = ('provider', 'display_event_type', 'external_id', 'delivery_id', 'source_ip', 'processed', 'received_at')
+ list_filter = ('provider', 'event_type', 'processed', 'received_at')
+ search_fields = ('provider', 'event_type', 'external_id', 'delivery_id', 'raw_body', 'notes')
+ readonly_fields = ('provider', 'event_type', 'external_id', 'delivery_id', 'source_ip', 'content_type', 'headers', 'query_params', 'payload', 'raw_body', 'received_at')
+ list_editable = ('processed',)
+ fieldsets = (
+ ('Routing summary', {'fields': ('provider', 'event_type', 'external_id', 'delivery_id', 'source_ip', 'content_type', 'received_at')}),
+ ('Payload', {'fields': ('query_params', 'headers', 'payload', 'raw_body')}),
+ ('Review', {'fields': ('processed', 'notes')}),
+ )
diff --git a/core/forms.py b/core/forms.py
new file mode 100644
index 0000000..8201e6f
--- /dev/null
+++ b/core/forms.py
@@ -0,0 +1,46 @@
+from django import forms
+
+from .models import PilotCall
+
+
+class PilotCallForm(forms.ModelForm):
+ class Meta:
+ model = PilotCall
+ fields = [
+ 'client_name', 'phone', 'email', 'call_source', 'ytel_audio_status', 'logics_api_status',
+ 'transcript', 'irs_debt', 'state_debt', 'tax_years', 'filing_status', 'employment_type',
+ 'occupation', 'monthly_gross_income', 'spouse_income', 'mortgage', 'rent', 'utilities',
+ 'car_payment', 'insurance', 'student_loans', 'credit_cards', 'assets', 'business_expenses',
+ 'irs_payment_plan', 'levy', 'garnishment', 'best_callback_time', 'agent_notes',
+ ]
+ widgets = {
+ 'client_name': forms.TextInput(attrs={'placeholder': 'Maria Johnson'}),
+ 'phone': forms.TextInput(attrs={'placeholder': '(555) 013-8842'}),
+ 'email': forms.EmailInput(attrs={'placeholder': 'client@example.com'}),
+ 'transcript': forms.Textarea(attrs={'rows': 8, 'placeholder': 'Paste the latest transcript segment from the call…'}),
+ 'assets': forms.Textarea(attrs={'rows': 3, 'placeholder': 'Home equity, vehicles, business assets…'}),
+ 'agent_notes': forms.Textarea(attrs={'rows': 4, 'placeholder': 'Summary, objections, follow-up plan…'}),
+ 'irs_debt': forms.NumberInput(attrs={'placeholder': '24000'}),
+ 'state_debt': forms.NumberInput(attrs={'placeholder': '5000'}),
+ 'monthly_gross_income': forms.NumberInput(attrs={'placeholder': '7200'}),
+ 'spouse_income': forms.NumberInput(attrs={'placeholder': '3800'}),
+ 'mortgage': forms.NumberInput(attrs={'placeholder': '3500'}),
+ 'rent': forms.NumberInput(attrs={'placeholder': '0'}),
+ 'utilities': forms.NumberInput(attrs={'placeholder': '450'}),
+ 'car_payment': forms.NumberInput(attrs={'placeholder': '620'}),
+ 'insurance': forms.NumberInput(attrs={'placeholder': '310'}),
+ 'student_loans': forms.NumberInput(attrs={'placeholder': '250'}),
+ 'credit_cards': forms.NumberInput(attrs={'placeholder': '900'}),
+ 'business_expenses': forms.NumberInput(attrs={'placeholder': '1800'}),
+ }
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ for field in self.fields.values():
+ base_class = 'form-check-input' if isinstance(field.widget, forms.CheckboxInput) else 'form-control'
+ if isinstance(field.widget, forms.Select):
+ base_class = 'form-select'
+ field.widget.attrs['class'] = f"{field.widget.attrs.get('class', '')} {base_class}".strip()
+ self.fields['client_name'].required = True
+ self.fields['phone'].required = True
+ self.fields['transcript'].required = True
diff --git a/core/migrations/0001_initial.py b/core/migrations/0001_initial.py
new file mode 100644
index 0000000..dc46608
--- /dev/null
+++ b/core/migrations/0001_initial.py
@@ -0,0 +1,56 @@
+# Generated by Django 5.2.7 on 2026-07-03 19:19
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='PilotCall',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('client_name', models.CharField(max_length=140)),
+ ('phone', models.CharField(max_length=40)),
+ ('email', models.EmailField(blank=True, max_length=254)),
+ ('call_source', models.CharField(choices=[('ytel', 'Ytel'), ('ringcentral', 'RingCentral fallback'), ('manual', 'Manual/recording test')], default='ytel', max_length=24)),
+ ('ytel_audio_status', models.CharField(choices=[('unconfirmed', 'Not confirmed'), ('available', 'Available'), ('blocked', 'Blocked / not exposed'), ('partner', 'Needs enterprise/partner access')], default='unconfirmed', max_length=24)),
+ ('logics_api_status', models.CharField(choices=[('unconfirmed', 'Not confirmed'), ('available', 'Available'), ('blocked', 'Blocked / not exposed'), ('partner', 'Needs enterprise/partner access')], default='unconfirmed', max_length=24)),
+ ('transcript', models.TextField(help_text='Paste a live transcript segment or recorded-call transcript for the pilot.')),
+ ('irs_debt', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)),
+ ('state_debt', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)),
+ ('tax_years', models.CharField(blank=True, max_length=120)),
+ ('filing_status', models.CharField(blank=True, max_length=80)),
+ ('employment_type', models.CharField(blank=True, max_length=120)),
+ ('occupation', models.CharField(blank=True, max_length=120)),
+ ('monthly_gross_income', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)),
+ ('spouse_income', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)),
+ ('mortgage', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)),
+ ('rent', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)),
+ ('utilities', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)),
+ ('car_payment', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)),
+ ('insurance', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)),
+ ('student_loans', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)),
+ ('credit_cards', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)),
+ ('assets', models.TextField(blank=True)),
+ ('business_expenses', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)),
+ ('irs_payment_plan', models.BooleanField(default=False)),
+ ('levy', models.BooleanField(default=False)),
+ ('garnishment', models.BooleanField(default=False)),
+ ('best_callback_time', models.CharField(blank=True, max_length=120)),
+ ('agent_notes', models.TextField(blank=True)),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ],
+ options={
+ 'verbose_name': 'pilot call',
+ 'verbose_name_plural': 'pilot calls',
+ 'ordering': ['-created_at'],
+ },
+ ),
+ ]
diff --git a/core/migrations/0002_webhookevent.py b/core/migrations/0002_webhookevent.py
new file mode 100644
index 0000000..3354807
--- /dev/null
+++ b/core/migrations/0002_webhookevent.py
@@ -0,0 +1,38 @@
+# Generated by Django 5.2.7 on 2026-07-03 19:35
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('core', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='WebhookEvent',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('provider', models.CharField(default='generic', max_length=60)),
+ ('event_type', models.CharField(blank=True, max_length=120)),
+ ('external_id', models.CharField(blank=True, max_length=160)),
+ ('delivery_id', models.CharField(blank=True, max_length=160)),
+ ('source_ip', models.GenericIPAddressField(blank=True, null=True)),
+ ('content_type', models.CharField(blank=True, max_length=180)),
+ ('headers', models.JSONField(blank=True, default=dict)),
+ ('query_params', models.JSONField(blank=True, default=dict)),
+ ('payload', models.JSONField(blank=True, default=dict)),
+ ('raw_body', models.TextField(blank=True)),
+ ('processed', models.BooleanField(default=False)),
+ ('notes', models.TextField(blank=True)),
+ ('received_at', models.DateTimeField(auto_now_add=True)),
+ ],
+ options={
+ 'verbose_name': 'webhook event',
+ 'verbose_name_plural': 'webhook events',
+ 'ordering': ['-received_at'],
+ 'indexes': [models.Index(fields=['provider', 'event_type'], name='core_webhoo_provide_870e6a_idx'), models.Index(fields=['received_at'], name='core_webhoo_receive_97109f_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..45ff537
Binary files /dev/null and b/core/migrations/__pycache__/0001_initial.cpython-311.pyc differ
diff --git a/core/migrations/__pycache__/0002_webhookevent.cpython-311.pyc b/core/migrations/__pycache__/0002_webhookevent.cpython-311.pyc
new file mode 100644
index 0000000..c5f410f
Binary files /dev/null and b/core/migrations/__pycache__/0002_webhookevent.cpython-311.pyc differ
diff --git a/core/migrations/__pycache__/__init__.cpython-311.pyc b/core/migrations/__pycache__/__init__.cpython-311.pyc
index 7995815..0689164 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..cfde08d 100644
--- a/core/models.py
+++ b/core/models.py
@@ -1,3 +1,151 @@
-from django.db import models
+from decimal import Decimal
-# Create your models here.
+from django.db import models
+from django.urls import reverse
+
+
+class PilotCall(models.Model):
+ SOURCE_YTEL = 'ytel'
+ SOURCE_RINGCENTRAL = 'ringcentral'
+ SOURCE_MANUAL = 'manual'
+
+ CALL_SOURCE_CHOICES = [
+ (SOURCE_YTEL, 'Ytel'),
+ (SOURCE_RINGCENTRAL, 'RingCentral fallback'),
+ (SOURCE_MANUAL, 'Manual/recording test'),
+ ]
+
+ STATUS_UNCONFIRMED = 'unconfirmed'
+ STATUS_AVAILABLE = 'available'
+ STATUS_BLOCKED = 'blocked'
+ STATUS_PARTNER = 'partner'
+
+ INTEGRATION_STATUS_CHOICES = [
+ (STATUS_UNCONFIRMED, 'Not confirmed'),
+ (STATUS_AVAILABLE, 'Available'),
+ (STATUS_BLOCKED, 'Blocked / not exposed'),
+ (STATUS_PARTNER, 'Needs enterprise/partner access'),
+ ]
+
+ client_name = models.CharField(max_length=140)
+ phone = models.CharField(max_length=40)
+ email = models.EmailField(blank=True)
+ call_source = models.CharField(max_length=24, choices=CALL_SOURCE_CHOICES, default=SOURCE_YTEL)
+ ytel_audio_status = models.CharField(max_length=24, choices=INTEGRATION_STATUS_CHOICES, default=STATUS_UNCONFIRMED)
+ logics_api_status = models.CharField(max_length=24, choices=INTEGRATION_STATUS_CHOICES, default=STATUS_UNCONFIRMED)
+
+ transcript = models.TextField(help_text='Paste a live transcript segment or recorded-call transcript for the pilot.')
+ irs_debt = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True)
+ state_debt = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True)
+ tax_years = models.CharField(max_length=120, blank=True)
+ filing_status = models.CharField(max_length=80, blank=True)
+ employment_type = models.CharField(max_length=120, blank=True)
+ occupation = models.CharField(max_length=120, blank=True)
+ monthly_gross_income = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True)
+ spouse_income = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True)
+ mortgage = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True)
+ rent = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True)
+ utilities = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True)
+ car_payment = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True)
+ insurance = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True)
+ student_loans = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True)
+ credit_cards = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True)
+ assets = models.TextField(blank=True)
+ business_expenses = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True)
+ irs_payment_plan = models.BooleanField(default=False)
+ levy = models.BooleanField(default=False)
+ garnishment = models.BooleanField(default=False)
+ best_callback_time = models.CharField(max_length=120, blank=True)
+ agent_notes = models.TextField(blank=True)
+
+ created_at = models.DateTimeField(auto_now_add=True)
+ updated_at = models.DateTimeField(auto_now=True)
+
+ class Meta:
+ ordering = ['-created_at']
+ verbose_name = 'pilot call'
+ verbose_name_plural = 'pilot calls'
+
+ def __str__(self):
+ return f'{self.client_name} · {self.get_call_source_display()}'
+
+ def get_absolute_url(self):
+ return reverse('call_detail', args=[self.pk])
+
+ @property
+ def required_fields(self):
+ return [
+ ('IRS debt', self.irs_debt),
+ ('Tax years', self.tax_years),
+ ('Employment', self.employment_type),
+ ('Monthly income', self.monthly_gross_income),
+ ('Best callback', self.best_callback_time),
+ ]
+
+ @property
+ def completion_score(self):
+ if not self.required_fields:
+ return 0
+ completed = sum(1 for _label, value in self.required_fields if self._has_value(value))
+ return round((completed / len(self.required_fields)) * 100)
+
+ @property
+ def missing_required_labels(self):
+ return [label for label, value in self.required_fields if not self._has_value(value)]
+
+ @property
+ def urgent_flags(self):
+ flags = []
+ if self.levy:
+ flags.append('Levy mentioned')
+ if self.garnishment:
+ flags.append('Wage garnishment mentioned')
+ if self.irs_debt and self.irs_debt > Decimal('50000'):
+ flags.append('Debt over $50,000')
+ if self.monthly_gross_income is not None and self.monthly_gross_income <= 0:
+ flags.append('No monthly income captured')
+ return flags
+
+ @staticmethod
+ def _has_value(value):
+ if value is None:
+ return False
+ if isinstance(value, str):
+ return bool(value.strip())
+ return True
+
+
+
+class WebhookEvent(models.Model):
+ PROVIDER_GENERIC = 'generic'
+
+ provider = models.CharField(max_length=60, default=PROVIDER_GENERIC)
+ event_type = models.CharField(max_length=120, blank=True)
+ external_id = models.CharField(max_length=160, blank=True)
+ delivery_id = models.CharField(max_length=160, blank=True)
+ source_ip = models.GenericIPAddressField(null=True, blank=True)
+ content_type = models.CharField(max_length=180, blank=True)
+ headers = models.JSONField(default=dict, blank=True)
+ query_params = models.JSONField(default=dict, blank=True)
+ payload = models.JSONField(default=dict, blank=True)
+ raw_body = models.TextField(blank=True)
+ processed = models.BooleanField(default=False)
+ notes = models.TextField(blank=True)
+ received_at = models.DateTimeField(auto_now_add=True)
+
+ class Meta:
+ ordering = ['-received_at']
+ indexes = [
+ models.Index(fields=['provider', 'event_type']),
+ models.Index(fields=['received_at']),
+ ]
+ verbose_name = 'webhook event'
+ verbose_name_plural = 'webhook events'
+
+ def __str__(self):
+ label = self.event_type or self.external_id or f'event #{self.pk}'
+ return f'{self.provider}: {label}'
+
+ @property
+ def display_event_type(self):
+ return self.event_type or 'unclassified'
diff --git a/core/templates/base.html b/core/templates/base.html
index 1e7e5fb..526ca8a 100644
--- a/core/templates/base.html
+++ b/core/templates/base.html
@@ -1,25 +1,66 @@
+{% load static %}
- {% block title %}Knowledge Base{% endblock %}
- {% if project_description %}
-
-
-
- {% endif %}
+
+ {% block title %}{{ project_name|default:"ReliefSignal" }}{% 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/call_detail.html b/core/templates/core/call_detail.html
new file mode 100644
index 0000000..2d6c4bd
--- /dev/null
+++ b/core/templates/core/call_detail.html
@@ -0,0 +1,83 @@
+{% extends "base.html" %}
+
+{% block title %}{{ call.client_name }} — Pilot Call Detail{% endblock %}
+{% block meta_description %}Detailed tax relief pilot call record with transcript, extracted intake fields, urgency rules, completion score, and integration readiness.{% endblock %}
+
+{% block content %}
+
+
+
+
+
Pilot call review
+
{{ call.client_name }}
+
{{ call.phone }}{% if call.email %} · {{ call.email }}{% endif %}
+
+
{{ call.completion_score }}% complete
+
+
+
+
+
+
+
+
+
+
Transcript
Conversation feed
+
{{ call.transcript|linebreaks }}
+
+
+
Schema
Extracted intake fields
+
+ {% for label, value in intake_fields %}
+
+
+ {{ label }}
+ {% if value %}{{ value }}{% else %}Missing{% endif %}
+
+
+ {% endfor %}
+
+
+
+
+
+
Readiness
Integrations
+
+ Call source {{ call.get_call_source_display }}
+ Ytel live audio {{ call.get_ytel_audio_status_display }}
+ Logics API {{ call.get_logics_api_status_display }}
+
+
+
+
Rules
Coach alerts
+ {% if call.urgent_flags %}
+
+ {% for flag in call.urgent_flags %}{{ flag }} {% endfor %}
+
+ {% else %}
+
No urgent rules triggered yet.
+ {% endif %}
+
+
+
Missing
Required fields
+ {% if call.missing_required_labels %}
+
+ {% for label in call.missing_required_labels %}{{ label }} {% endfor %}
+
+ {% else %}
+
All required fields are captured.
+ {% endif %}
+
+
+
Notes
Next steps
+
{{ call.agent_notes|default:'No notes yet.'|linebreaksbr }}
+
+
+
+
+
+
+{% endblock %}
diff --git a/core/templates/core/call_form.html b/core/templates/core/call_form.html
new file mode 100644
index 0000000..0416b5d
--- /dev/null
+++ b/core/templates/core/call_form.html
@@ -0,0 +1,94 @@
+{% extends "base.html" %}
+
+{% block title %}New Pilot Call — ReliefSignal{% endblock %}
+{% block meta_description %}Create a tax relief pilot call intake with transcript, extraction fields, integration readiness, and completion scoring.{% endblock %}
+
+{% block content %}
+
+
+
Create pilot intake
+
Start a real-time call proof point.
+
Paste a transcript segment, capture the tax relief fields your AI should extract, and record whether Ytel audio + Logics updates are ready.
+
+
+
+
+{% endblock %}
diff --git a/core/templates/core/call_list.html b/core/templates/core/call_list.html
new file mode 100644
index 0000000..801fb02
--- /dev/null
+++ b/core/templates/core/call_list.html
@@ -0,0 +1,64 @@
+{% extends "base.html" %}
+
+{% block title %}Pilot Call History — ReliefSignal{% endblock %}
+{% block meta_description %}Review saved tax relief pilot calls with source, completion score, CRM readiness, and urgency flags.{% endblock %}
+
+{% block content %}
+
+
+
+
History
+
Pilot call records
+
Every saved call becomes evidence for what the MVP captures and what integrations still need validation.
+
+
New pilot call
+
+
+
+
+
+ {% if calls %}
+
+
+
+
+
+ Client
+ Source
+ Completion
+ Readiness
+ Flags
+ Open
+
+
+
+ {% for call in calls %}
+
+ {{ call.client_name }} {{ call.phone }}
+ {{ call.get_call_source_display }}{{ call.created_at|date:"M j, Y g:i A" }}
+ {{ call.completion_score }}%
+ Ytel: {{ call.get_ytel_audio_status_display }} Logics: {{ call.get_logics_api_status_display }}
+
+ {% if call.urgent_flags %}
+ {% for flag in call.urgent_flags %}{{ flag }} {% endfor %}
+ {% else %}
+ None
+ {% endif %}
+
+ Review
+
+ {% endfor %}
+
+
+
+
+ {% else %}
+
+
No call history yet
+
Save your first pilot call to confirm the workflow from transcript capture to scored intake review.
+
Create first call
+
+ {% endif %}
+
+
+{% endblock %}
diff --git a/core/templates/core/index.html b/core/templates/core/index.html
index faec813..92d9f1a 100644
--- a/core/templates/core/index.html
+++ b/core/templates/core/index.html
@@ -1,145 +1,104 @@
{% extends "base.html" %}
-{% block title %}{{ project_name }}{% endblock %}
-
-{% block head %}
-
-
-
-
-{% endblock %}
+{% block title %}ReliefSignal — Real-Time Tax Relief Intake Pilot{% endblock %}
+{% block meta_description %}A polished MVP console for validating real-time Ytel audio readiness, Logics CRM sync, and AI-assisted tax relief intake completion.{% endblock %}
{% block content %}
-
-
-
Analyzing your requirements and generating your app…
-
-
Loading…
+
+
+
+
+
+
+
Real-time tax relief intake MVP
+
Prove live call extraction before building the whole platform.
+
ReliefSignal turns each pilot call into a structured intake: transcript, IRS debt fields, urgency flags, completion score, and Ytel/Logics readiness notes in one workflow.
+
+
+
{{ total_calls }} Pilot calls
+
{{ avg_completion }}% Avg completion
+
{{ urgent_count }} Urgent flags
+
+
+
+
+
+
+
+ Live Call Screen
+
+
Ytel audio: validate
+
+
+
Client: I owe around $24,000 to the IRS and I’m worried about a levy.
+
Agent: Which years are included, and what is your monthly income?
+
+
+
+
-
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
+
+
+
+
+
+
+
First workflow
+
Capture → score → review.
+
This first iteration intentionally keeps the media integration mocked, but the workflow stores real records so a tax office can test the intake logic immediately.
+
+
+
+
01 Create Paste live transcript text and extracted intake values.
+
02 Validate Track Ytel audio and Logics API readiness per pilot call.
+
03 Coach Surface missing fields and urgent tax relief flags.
+
+
+
+
+
+
+
+
+
+
+
Recent pilot calls
+
History starts here.
+
+
Open history
+
+ {% if recent_calls %}
+
+ {% for call in recent_calls %}
+
+ {% endfor %}
+
+ {% else %}
+
+
No pilot calls yet
+
Create the first call record to test transcript capture, intake fields, completion scoring, and urgent flags.
+
Create first call
+
+ {% endif %}
+
+
+{% endblock %}
diff --git a/core/templates/core/webhook_list.html b/core/templates/core/webhook_list.html
new file mode 100644
index 0000000..d47b124
--- /dev/null
+++ b/core/templates/core/webhook_list.html
@@ -0,0 +1,94 @@
+{% extends "base.html" %}
+
+{% block title %}Webhook Receiver — ReliefSignal{% endblock %}
+{% block meta_description %}Provider-neutral webhook receiver for testing external call-event payloads before adapting the integration to Ytel.{% endblock %}
+
+{% block content %}
+
+
+
Generic webhook inbox
+
+
+
Capture provider events before we commit to Ytel-specific logic.
+
Use this receiver to collect real webhook payloads, inspect headers safely, and map the fields we need for call intake automation.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ total_events }} total received
+
Recent webhook events
+
+
Open in admin
+
+
+ {% if events %}
+
+
+
+
+ Received
+ Provider
+ Event
+ External ID
+ Preview
+
+
+
+ {% for event in events %}
+
+ #{{ event.pk }} {{ event.received_at|date:"M j, Y g:i A" }}
+ {{ event.provider }}
+ {{ event.display_event_type }}
+ {% if event.external_id %}{{ event.external_id }}{% else %}Not supplied {% endif %}
+ {{ event.raw_body|default:"No body"|truncatechars:110 }}
+
+ {% endfor %}
+
+
+
+ {% else %}
+
+
No webhooks received yet
+
Send a test POST to the endpoint to verify connectivity, then we can map the real Ytel fields into pilot call records.
+
+ {% endif %}
+
+
+
+
+
+{% endblock %}
diff --git a/core/urls.py b/core/urls.py
index 6299e3d..adf3301 100644
--- a/core/urls.py
+++ b/core/urls.py
@@ -1,7 +1,12 @@
from django.urls import path
-from .views import home
+from .views import call_create, call_detail, call_list, home, webhook_generic, webhook_list
urlpatterns = [
- path("", home, name="home"),
+ path('', home, name='home'),
+ path('calls/', call_list, name='call_list'),
+ path('calls/new/', call_create, name='call_create'),
+ path('calls/
/', call_detail, name='call_detail'),
+ path('webhooks/', webhook_list, name='webhook_list'),
+ path('webhooks/generic/', webhook_generic, name='webhook_generic'),
]
diff --git a/core/views.py b/core/views.py
index c9aed12..54a130c 100644
--- a/core/views.py
+++ b/core/views.py
@@ -1,25 +1,277 @@
-import os
-import platform
+import ipaddress
+import json
+import logging
-from django import get_version as django_version
-from django.shortcuts import render
-from django.utils import timezone
+from django.contrib import messages
+from django.db.models import Count
+from django.http import JsonResponse
+from django.shortcuts import get_object_or_404, redirect, render
+from django.urls import reverse
+from django.views.decorators.csrf import csrf_exempt
+from django.views.decorators.http import require_http_methods
+
+from .forms import PilotCallForm
+from .models import PilotCall, WebhookEvent
+
+
+logger = logging.getLogger(__name__)
+MAX_WEBHOOK_BODY_BYTES = 512 * 1024
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 landing dashboard for the real-time intake pilot."""
+ recent_calls = PilotCall.objects.all()[:3]
+ total_calls = PilotCall.objects.count()
+ urgent_count = sum(1 for call in PilotCall.objects.all() if call.urgent_flags)
+ avg_completion = 0
+ if total_calls:
+ avg_completion = round(sum(call.completion_score for call in PilotCall.objects.all()) / total_calls)
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': 'ReliefSignal',
+ 'meta_description': 'Real-time tax relief call intake pilot for live transcription, structured extraction, completion scoring, and CRM readiness.',
+ 'recent_calls': recent_calls,
+ 'total_calls': total_calls,
+ 'urgent_count': urgent_count,
+ 'avg_completion': avg_completion,
}
- return render(request, "core/index.html", context)
+ return render(request, 'core/index.html', context)
+
+
+def call_list(request):
+ calls = PilotCall.objects.all()
+ summary = calls.aggregate(total=Count('id'))
+ context = {
+ 'project_name': 'ReliefSignal',
+ 'meta_description': 'Review tax relief pilot calls, completion scores, and urgency flags.',
+ 'calls': calls,
+ 'total_calls': summary['total'] or 0,
+ }
+ return render(request, 'core/call_list.html', context)
+
+
+def call_create(request):
+ if request.method == 'POST':
+ form = PilotCallForm(request.POST)
+ if form.is_valid():
+ call = form.save()
+ messages.success(request, 'Pilot call saved. Review the completion score and missing intake fields below.')
+ return redirect('call_detail', pk=call.pk)
+ else:
+ form = PilotCallForm()
+
+ context = {
+ 'project_name': 'ReliefSignal',
+ 'meta_description': 'Create a tax relief pilot call intake from a live transcript segment.',
+ 'form': form,
+ }
+ return render(request, 'core/call_form.html', context)
+
+
+def call_detail(request, pk):
+ call = get_object_or_404(PilotCall, pk=pk)
+ intake_fields = [
+ ('IRS Debt', call.irs_debt),
+ ('State Debt', call.state_debt),
+ ('Tax Years', call.tax_years),
+ ('Filing Status', call.filing_status),
+ ('Employment', call.employment_type),
+ ('Occupation', call.occupation),
+ ('Monthly Gross Income', call.monthly_gross_income),
+ ('Spouse Income', call.spouse_income),
+ ('Mortgage', call.mortgage),
+ ('Rent', call.rent),
+ ('Utilities', call.utilities),
+ ('Car Payment', call.car_payment),
+ ('Insurance', call.insurance),
+ ('Student Loans', call.student_loans),
+ ('Credit Cards', call.credit_cards),
+ ('Business Expenses', call.business_expenses),
+ ('Best Callback Time', call.best_callback_time),
+ ]
+ context = {
+ 'project_name': 'ReliefSignal',
+ 'meta_description': f'Pilot call detail for {call.client_name}: transcript, extracted tax relief fields, urgency flags, and completion score.',
+ 'call': call,
+ 'intake_fields': intake_fields,
+ }
+ return render(request, 'core/call_detail.html', context)
+
+
+
+def webhook_list(request):
+ events = WebhookEvent.objects.all()[:25]
+ context = {
+ 'project_name': 'ReliefSignal',
+ 'meta_description': 'Provider-neutral webhook inbox for testing Ytel-style call events before a dedicated integration is finalized.',
+ 'events': events,
+ 'total_events': WebhookEvent.objects.count(),
+ 'endpoint_url': _public_absolute_uri(request, reverse('webhook_generic')),
+ }
+ return render(request, 'core/webhook_list.html', context)
+
+
+@csrf_exempt
+@require_http_methods(['GET', 'POST'])
+def webhook_generic(request):
+ if request.method == 'GET':
+ return JsonResponse({
+ 'ok': True,
+ 'name': 'ReliefSignal generic webhook receiver',
+ 'usage': 'POST JSON or form payloads to this endpoint. Add ?provider=ytel later when testing Ytel deliveries.',
+ 'accepted_methods': ['POST'],
+ })
+
+ body = request.body or b''
+ if len(body) > MAX_WEBHOOK_BODY_BYTES:
+ logger.warning('Rejected oversized webhook body: %s bytes', len(body))
+ return JsonResponse({'ok': False, 'error': 'Payload is too large.'}, status=413)
+
+ content_type = request.META.get('CONTENT_TYPE', '')[:180]
+ raw_body = body.decode('utf-8', errors='replace')
+ payload, parsed = _parse_webhook_payload(request, raw_body, content_type)
+
+ provider = _normalise_label(
+ request.GET.get('provider')
+ or request.headers.get('X-Webhook-Provider')
+ or _payload_lookup(payload, 'provider', 'source'),
+ fallback='generic',
+ max_length=60,
+ )
+ event_type = _normalise_label(
+ request.headers.get('X-Event-Type')
+ or request.headers.get('X-Webhook-Event')
+ or request.headers.get('X-Hook-Event')
+ or request.headers.get('X-Ytel-Event-Type')
+ or _payload_lookup(payload, 'event_type', 'eventType', 'event', 'type', 'action', 'status'),
+ fallback='',
+ max_length=120,
+ )
+ delivery_id = _normalise_label(
+ request.headers.get('X-Delivery-Id')
+ or request.headers.get('X-Webhook-Id')
+ or request.headers.get('X-Request-Id')
+ or request.headers.get('X-Event-Id')
+ or request.headers.get('X-Ytel-Delivery-Id')
+ or request.headers.get('X-Ytel-Event-Id')
+ or request.headers.get('Cf-Ray'),
+ fallback='',
+ max_length=160,
+ )
+ external_id = _normalise_label(
+ _payload_lookup(payload, 'external_id', 'externalId', 'event_id', 'eventId', 'id', 'call_id', 'callId', 'uuid', 'sid'),
+ fallback='',
+ max_length=160,
+ )
+
+ event = WebhookEvent.objects.create(
+ provider=provider,
+ event_type=event_type,
+ external_id=external_id,
+ delivery_id=delivery_id,
+ source_ip=_source_ip(request),
+ content_type=content_type,
+ headers=_safe_request_headers(request),
+ query_params=_query_params(request),
+ payload=payload,
+ raw_body=raw_body,
+ )
+
+ return JsonResponse({
+ 'ok': True,
+ 'accepted': True,
+ 'parsed': parsed,
+ 'id': event.pk,
+ 'provider': event.provider,
+ 'event_type': event.display_event_type,
+ 'received_at': event.received_at.isoformat(),
+ }, status=202)
+
+
+def _public_absolute_uri(request, path):
+ url = request.build_absolute_uri(path)
+ host = request.get_host().split(':')[0]
+ if host not in ('127.0.0.1', 'localhost') and url.startswith('http://'):
+ return 'https://' + url[len('http://'):]
+ return url
+
+
+def _parse_webhook_payload(request, raw_body, content_type):
+ if raw_body and ('application/json' in content_type or raw_body.lstrip().startswith(('{', '['))):
+ try:
+ return json.loads(raw_body), True
+ except json.JSONDecodeError:
+ return {'_unparsed_preview': raw_body[:2000]}, False
+
+ if request.POST:
+ return _multi_value_dict(request.POST), True
+
+ if raw_body:
+ return {'_raw_preview': raw_body[:2000]}, False
+
+ return {}, True
+
+
+def _safe_request_headers(request):
+ sensitive_markers = ('authorization', 'cookie', 'token', 'secret', 'key', 'signature')
+ safe_headers = {}
+ for name, value in request.headers.items():
+ if any(marker in name.lower() for marker in sensitive_markers):
+ safe_headers[name] = '[redacted]'
+ else:
+ safe_headers[name] = str(value)[:500]
+ return safe_headers
+
+
+def _query_params(request):
+ return _multi_value_dict(request.GET)
+
+
+def _multi_value_dict(query_dict):
+ return {
+ key: values if len(values := query_dict.getlist(key)) > 1 else query_dict.get(key)
+ for key in query_dict
+ }
+
+
+def _payload_lookup(payload, *keys):
+ if not isinstance(payload, dict):
+ return None
+
+ for key in keys:
+ value = payload.get(key)
+ if value not in (None, ''):
+ return value
+
+ for nested_key in ('data', 'event', 'call', 'payload'):
+ nested = payload.get(nested_key)
+ if isinstance(nested, dict):
+ value = _payload_lookup(nested, *keys)
+ if value not in (None, ''):
+ return value
+
+ return None
+
+
+def _normalise_label(value, fallback='', max_length=120):
+ if value in (None, ''):
+ return fallback
+ if isinstance(value, (dict, list)):
+ value = json.dumps(value, sort_keys=True)
+ return str(value).strip()[:max_length] or fallback
+
+
+def _source_ip(request):
+ raw_ip = (
+ request.headers.get('Cf-Connecting-Ip')
+ or request.headers.get('X-Real-Ip')
+ or request.headers.get('X-Forwarded-For')
+ or request.META.get('REMOTE_ADDR')
+ or ''
+ )
+ candidate = raw_ip.split(',')[0].strip()
+ try:
+ ipaddress.ip_address(candidate)
+ except ValueError:
+ return None
+ return candidate
diff --git a/static/css/custom.css b/static/css/custom.css
index 925f6ed..7186a01 100644
--- a/static/css/custom.css
+++ b/static/css/custom.css
@@ -1,4 +1,334 @@
-/* Custom styles for the application */
-body {
- font-family: system-ui, -apple-system, sans-serif;
+/* ReliefSignal custom brand layer — intentionally overrides default Bootstrap appearance. */
+:root {
+ --rs-ink: #10221f;
+ --rs-muted: #5f716d;
+ --rs-primary: #0f766e;
+ --rs-secondary: #f97316;
+ --rs-accent: #14b8a6;
+ --rs-cream: #fff8ed;
+ --rs-mint: #e7fbf5;
+ --rs-surface: #ffffff;
+ --rs-line: rgba(16, 34, 31, 0.12);
+ --rs-shadow: 0 24px 80px rgba(15, 118, 110, 0.16);
}
+
+* { box-sizing: border-box; }
+
+html { scroll-behavior: smooth; }
+
+body {
+ margin: 0;
+ font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ background: linear-gradient(180deg, #fffdf7 0%, #f5fbf7 54%, #fff8ed 100%);
+ color: var(--rs-ink);
+ min-height: 100vh;
+}
+
+.skip-link {
+ position: absolute;
+ left: 1rem;
+ top: -4rem;
+ z-index: 2000;
+ padding: .75rem 1rem;
+ background: var(--rs-ink);
+ color: #fff;
+ border-radius: 999px;
+ transition: top .2s ease;
+}
+.skip-link:focus { top: 1rem; }
+
+.app-nav {
+ background: rgba(255, 253, 247, 0.82);
+ backdrop-filter: blur(18px);
+ border-bottom: 1px solid rgba(16, 34, 31, 0.08);
+}
+
+.brand-mark {
+ display: inline-flex;
+ align-items: center;
+ gap: .65rem;
+ font-weight: 800;
+ letter-spacing: -0.03em;
+ color: var(--rs-ink) !important;
+}
+
+.brand-icon {
+ display: inline-grid;
+ place-items: center;
+ width: 2.35rem;
+ height: 2.35rem;
+ border-radius: 1rem;
+ color: #fff;
+ background: linear-gradient(135deg, var(--rs-primary), var(--rs-secondary));
+ box-shadow: 0 12px 30px rgba(15, 118, 110, 0.28);
+ font-size: .82rem;
+}
+
+.nav-link {
+ color: rgba(16, 34, 31, 0.72) !important;
+ font-weight: 700;
+}
+.nav-link:hover, .nav-link:focus { color: var(--rs-primary) !important; }
+
+.btn {
+ border-radius: 999px;
+ font-weight: 800;
+ padding: .85rem 1.3rem;
+}
+.btn-sm { padding: .45rem .85rem; }
+.btn-signal {
+ background: linear-gradient(135deg, var(--rs-primary), #0d9488);
+ color: #fff;
+ border: 0;
+ box-shadow: 0 14px 34px rgba(15, 118, 110, 0.24);
+}
+.btn-signal:hover, .btn-signal:focus {
+ color: #fff;
+ transform: translateY(-1px);
+ box-shadow: 0 18px 42px rgba(15, 118, 110, 0.30);
+}
+.btn-ghost, .btn-outline-signal {
+ border: 1px solid rgba(15, 118, 110, .22);
+ color: var(--rs-primary);
+ background: rgba(255,255,255,.68);
+}
+.btn-ghost:hover, .btn-outline-signal:hover { background: var(--rs-mint); color: var(--rs-ink); }
+
+.message-stack { position: fixed; top: 5.5rem; right: 0; left: 0; z-index: 1080; pointer-events: none; }
+.message-stack .alert { max-width: 760px; margin-left: auto; pointer-events: auto; border-radius: 1rem; }
+
+.hero-section {
+ background:
+ radial-gradient(circle at 16% 10%, rgba(249, 115, 22, .18), transparent 28%),
+ radial-gradient(circle at 88% 14%, rgba(20, 184, 166, .22), transparent 30%),
+ linear-gradient(135deg, #fff8ed 0%, #ecfff9 100%);
+}
+.py-lg-6 { padding-top: 6rem !important; padding-bottom: 6rem !important; }
+
+.orb {
+ position: absolute;
+ border-radius: 999px;
+ filter: blur(2px);
+ opacity: .75;
+}
+.orb-one { width: 18rem; height: 18rem; background: rgba(249,115,22,.16); left: -6rem; top: 6rem; }
+.orb-two { width: 22rem; height: 22rem; background: rgba(20,184,166,.18); right: -8rem; bottom: -5rem; }
+
+.eyebrow, .section-kicker, .small-label {
+ display: inline-flex;
+ align-items: center;
+ gap: .45rem;
+ text-transform: uppercase;
+ letter-spacing: .14em;
+ font-size: .77rem;
+ font-weight: 900;
+ color: var(--rs-primary);
+}
+
+.display-title, .page-title, .section-title {
+ font-family: "Fraunces", Georgia, serif;
+ color: var(--rs-ink);
+ letter-spacing: -0.045em;
+ line-height: .98;
+}
+.display-title { font-size: clamp(3rem, 7vw, 6.6rem); max-width: 960px; }
+.page-title { font-size: clamp(2.5rem, 5vw, 4.8rem); max-width: 980px; }
+.section-title { font-size: clamp(2rem, 4vw, 3.4rem); }
+.hero-copy {
+ max-width: 720px;
+ color: var(--rs-muted);
+ font-size: clamp(1.05rem, 1.4vw, 1.28rem);
+ line-height: 1.7;
+}
+.text-muted-custom { color: var(--rs-muted); }
+
+.hero-proof {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 1rem;
+}
+.hero-proof div {
+ min-width: 135px;
+ padding: 1rem 1.15rem;
+ border: 1px solid var(--rs-line);
+ border-radius: 1.25rem;
+ background: rgba(255,255,255,.64);
+}
+.hero-proof strong { display: block; font-size: 1.7rem; color: var(--rs-primary); }
+.hero-proof span { color: var(--rs-muted); font-size: .88rem; font-weight: 700; }
+
+.glass-card, .form-panel, .detail-panel, .history-shell, .empty-state, .call-card, .step-card {
+ background: rgba(255, 255, 255, 0.78);
+ border: 1px solid rgba(16, 34, 31, 0.10);
+ box-shadow: var(--rs-shadow);
+ backdrop-filter: blur(16px);
+}
+.live-card { border-radius: 2rem; padding: 1.3rem; transform: rotate(1deg); }
+.pulse-dot {
+ display: inline-block;
+ width: .78rem;
+ height: .78rem;
+ margin-right: .45rem;
+ border-radius: 999px;
+ background: var(--rs-secondary);
+ box-shadow: 0 0 0 .42rem rgba(249,115,22,.16);
+}
+.status-pill, .score-badge, .mini-flag, .alert-flag {
+ display: inline-flex;
+ align-items: center;
+ border-radius: 999px;
+ font-size: .78rem;
+ font-weight: 900;
+}
+.status-pill { padding: .5rem .75rem; color: #8a3d00; background: #fff0d8; }
+.score-badge { padding: .55rem .7rem; color: #07564f; background: var(--rs-mint); }
+
+.transcript-box, .transcript-review {
+ border-radius: 1.3rem;
+ background: #10221f;
+ color: #eaffe9;
+ padding: 1rem;
+ line-height: 1.65;
+}
+.transcript-box p:last-child, .transcript-review p:last-child { margin-bottom: 0; }
+.metric-tile, .field-tile {
+ border-radius: 1.2rem;
+ padding: 1rem;
+ background: #fffdf8;
+ border: 1px solid var(--rs-line);
+}
+.metric-tile span, .field-tile span { display: block; color: var(--rs-muted); font-size: .82rem; font-weight: 800; }
+.metric-tile strong, .field-tile strong { display: block; color: var(--rs-ink); font-size: 1.25rem; margin-top: .25rem; }
+.metric-tile.urgent { background: #fff0d8; border-color: rgba(249,115,22,.35); }
+
+.section-pad { padding: 5rem 0; }
+.step-card, .call-card, .empty-state { border-radius: 1.5rem; padding: 1.35rem; height: 100%; }
+.step-card span { color: var(--rs-secondary); font-weight: 900; }
+.step-card h3, .call-card h3 { margin: .7rem 0 .45rem; font-weight: 900; letter-spacing: -.03em; }
+.step-card p, .call-card p { color: var(--rs-muted); }
+.call-card-link { color: inherit; text-decoration: none; display: block; height: 100%; }
+.call-card-link:hover .call-card { transform: translateY(-3px); border-color: rgba(15,118,110,.28); }
+.call-card, .btn, .step-card { transition: .18s ease; }
+.empty-state { text-align: center; max-width: 760px; margin: 0 auto; padding: 3rem; }
+
+.page-hero {
+ background: linear-gradient(135deg, #fff8ed 0%, #ecfff9 100%);
+ padding: 5rem 0 3rem;
+ border-bottom: 1px solid rgba(16,34,31,.08);
+}
+.page-hero.compact { padding-top: 4rem; }
+.detail-hero { background: radial-gradient(circle at 78% 10%, rgba(249,115,22,.15), transparent 25%), linear-gradient(135deg, #ecfff9, #fff8ed); }
+
+.form-panel, .detail-panel, .history-shell { border-radius: 1.6rem; padding: 1.4rem; }
+.panel-heading {
+ display: flex;
+ align-items: baseline;
+ gap: .8rem;
+ margin-bottom: 1.2rem;
+}
+.panel-heading span { color: var(--rs-secondary); font-weight: 900; letter-spacing: .08em; text-transform: uppercase; font-size: .78rem; }
+.panel-heading h2 { margin: 0; font-size: 1.25rem; font-weight: 900; letter-spacing: -.03em; }
+.form-label { font-weight: 850; color: var(--rs-ink); }
+.form-control, .form-select {
+ border-radius: 1rem;
+ border: 1px solid rgba(16,34,31,.14);
+ padding: .85rem 1rem;
+ background-color: rgba(255,255,255,.82);
+}
+.form-control:focus, .form-select:focus {
+ border-color: var(--rs-accent);
+ box-shadow: 0 0 0 .22rem rgba(20,184,166,.18);
+}
+.form-hint, .invalid-note { color: var(--rs-muted); font-size: .9rem; margin-top: .5rem; }
+.invalid-note { color: #b42318; font-weight: 800; }
+.checks-grid { display: grid; gap: .9rem; }
+.signal-check {
+ display: flex;
+ align-items: center;
+ gap: .75rem;
+ padding: .85rem 1rem;
+ border-radius: 1rem;
+ background: #fffdf8;
+ border: 1px solid var(--rs-line);
+ font-weight: 850;
+}
+.form-check-input:checked { background-color: var(--rs-primary); border-color: var(--rs-primary); }
+
+.history-table { margin: 0; }
+.history-table thead th {
+ color: var(--rs-muted);
+ text-transform: uppercase;
+ letter-spacing: .08em;
+ font-size: .75rem;
+ border-bottom-color: var(--rs-line);
+}
+.history-table td { padding: 1rem .75rem; border-color: rgba(16,34,31,.08); }
+.history-table td span { color: var(--rs-muted); font-size: .9rem; }
+.mini-flag { padding: .35rem .55rem; margin: .1rem; color: #8a3d00; background: #fff0d8; }
+.alert-flag { padding: .65rem .8rem; color: #8a3d00; background: #fff0d8; }
+.flag-stack { display: flex; flex-wrap: wrap; gap: .5rem; }
+
+.score-circle {
+ width: 8.25rem;
+ height: 8.25rem;
+ border-radius: 999px;
+ display: grid;
+ place-items: center;
+ text-align: center;
+ background: conic-gradient(var(--rs-primary), var(--rs-accent), var(--rs-secondary), var(--rs-primary));
+ color: #fff;
+ box-shadow: var(--rs-shadow);
+}
+.score-circle strong { display: block; font-size: 2rem; line-height: 1; }
+.score-circle span { display: block; font-size: .82rem; font-weight: 900; text-transform: uppercase; letter-spacing: .08em; }
+.field-tile.missing { background: #fff7ed; border-color: rgba(249,115,22,.28); }
+.field-tile.missing strong { color: #9a3412; }
+.readiness-list, .missing-list { list-style: none; padding: 0; margin: 0; display: grid; gap: .8rem; }
+.readiness-list li, .missing-list li {
+ display: flex;
+ justify-content: space-between;
+ gap: 1rem;
+ padding: .85rem 0;
+ border-bottom: 1px solid rgba(16,34,31,.08);
+}
+.readiness-list span { color: var(--rs-muted); font-weight: 800; }
+.readiness-list strong { text-align: right; }
+.missing-list li { display: block; color: #9a3412; font-weight: 900; }
+
+.site-footer {
+ padding: 2rem 0;
+ color: var(--rs-muted);
+ border-top: 1px solid rgba(16,34,31,.08);
+ background: rgba(255,253,247,.74);
+ font-weight: 700;
+}
+
+@media (max-width: 767px) {
+ .section-pad { padding: 3.25rem 0; }
+ .live-card { transform: none; }
+ .hero-proof div { flex: 1 1 100%; }
+ .empty-state { padding: 2rem 1.2rem; }
+ .score-circle { width: 7rem; height: 7rem; }
+}
+
+
+.endpoint-box {
+ display: grid;
+ gap: .45rem;
+ border-radius: 1.1rem;
+ padding: 1rem;
+ background: #fffdf8;
+ border: 1px solid var(--rs-line);
+}
+.endpoint-box span { color: var(--rs-muted); font-size: .78rem; font-weight: 900; letter-spacing: .08em; text-transform: uppercase; }
+.endpoint-box code, .endpoint-code code, .webhook-preview { color: var(--rs-ink); white-space: pre-wrap; word-break: break-word; }
+.endpoint-code {
+ border-radius: 1.15rem;
+ padding: 1rem;
+ background: #10221f;
+ color: #ecfff9;
+ border: 1px solid rgba(255,255,255,.08);
+ overflow-x: auto;
+}
+.endpoint-code code { color: #ecfff9; font-size: .88rem; }
+.webhook-preview { display: inline-block; max-width: 18rem; }
diff --git a/staticfiles/css/custom.css b/staticfiles/css/custom.css
index 108056f..7186a01 100644
--- a/staticfiles/css/custom.css
+++ b/staticfiles/css/custom.css
@@ -1,21 +1,334 @@
-
+/* ReliefSignal custom brand layer — intentionally overrides default Bootstrap appearance. */
: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);
+ --rs-ink: #10221f;
+ --rs-muted: #5f716d;
+ --rs-primary: #0f766e;
+ --rs-secondary: #f97316;
+ --rs-accent: #14b8a6;
+ --rs-cream: #fff8ed;
+ --rs-mint: #e7fbf5;
+ --rs-surface: #ffffff;
+ --rs-line: rgba(16, 34, 31, 0.12);
+ --rs-shadow: 0 24px 80px rgba(15, 118, 110, 0.16);
}
+
+* { 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;
+ font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ background: linear-gradient(180deg, #fffdf7 0%, #f5fbf7 54%, #fff8ed 100%);
+ color: var(--rs-ink);
min-height: 100vh;
- text-align: center;
- overflow: hidden;
- position: relative;
}
+
+.skip-link {
+ position: absolute;
+ left: 1rem;
+ top: -4rem;
+ z-index: 2000;
+ padding: .75rem 1rem;
+ background: var(--rs-ink);
+ color: #fff;
+ border-radius: 999px;
+ transition: top .2s ease;
+}
+.skip-link:focus { top: 1rem; }
+
+.app-nav {
+ background: rgba(255, 253, 247, 0.82);
+ backdrop-filter: blur(18px);
+ border-bottom: 1px solid rgba(16, 34, 31, 0.08);
+}
+
+.brand-mark {
+ display: inline-flex;
+ align-items: center;
+ gap: .65rem;
+ font-weight: 800;
+ letter-spacing: -0.03em;
+ color: var(--rs-ink) !important;
+}
+
+.brand-icon {
+ display: inline-grid;
+ place-items: center;
+ width: 2.35rem;
+ height: 2.35rem;
+ border-radius: 1rem;
+ color: #fff;
+ background: linear-gradient(135deg, var(--rs-primary), var(--rs-secondary));
+ box-shadow: 0 12px 30px rgba(15, 118, 110, 0.28);
+ font-size: .82rem;
+}
+
+.nav-link {
+ color: rgba(16, 34, 31, 0.72) !important;
+ font-weight: 700;
+}
+.nav-link:hover, .nav-link:focus { color: var(--rs-primary) !important; }
+
+.btn {
+ border-radius: 999px;
+ font-weight: 800;
+ padding: .85rem 1.3rem;
+}
+.btn-sm { padding: .45rem .85rem; }
+.btn-signal {
+ background: linear-gradient(135deg, var(--rs-primary), #0d9488);
+ color: #fff;
+ border: 0;
+ box-shadow: 0 14px 34px rgba(15, 118, 110, 0.24);
+}
+.btn-signal:hover, .btn-signal:focus {
+ color: #fff;
+ transform: translateY(-1px);
+ box-shadow: 0 18px 42px rgba(15, 118, 110, 0.30);
+}
+.btn-ghost, .btn-outline-signal {
+ border: 1px solid rgba(15, 118, 110, .22);
+ color: var(--rs-primary);
+ background: rgba(255,255,255,.68);
+}
+.btn-ghost:hover, .btn-outline-signal:hover { background: var(--rs-mint); color: var(--rs-ink); }
+
+.message-stack { position: fixed; top: 5.5rem; right: 0; left: 0; z-index: 1080; pointer-events: none; }
+.message-stack .alert { max-width: 760px; margin-left: auto; pointer-events: auto; border-radius: 1rem; }
+
+.hero-section {
+ background:
+ radial-gradient(circle at 16% 10%, rgba(249, 115, 22, .18), transparent 28%),
+ radial-gradient(circle at 88% 14%, rgba(20, 184, 166, .22), transparent 30%),
+ linear-gradient(135deg, #fff8ed 0%, #ecfff9 100%);
+}
+.py-lg-6 { padding-top: 6rem !important; padding-bottom: 6rem !important; }
+
+.orb {
+ position: absolute;
+ border-radius: 999px;
+ filter: blur(2px);
+ opacity: .75;
+}
+.orb-one { width: 18rem; height: 18rem; background: rgba(249,115,22,.16); left: -6rem; top: 6rem; }
+.orb-two { width: 22rem; height: 22rem; background: rgba(20,184,166,.18); right: -8rem; bottom: -5rem; }
+
+.eyebrow, .section-kicker, .small-label {
+ display: inline-flex;
+ align-items: center;
+ gap: .45rem;
+ text-transform: uppercase;
+ letter-spacing: .14em;
+ font-size: .77rem;
+ font-weight: 900;
+ color: var(--rs-primary);
+}
+
+.display-title, .page-title, .section-title {
+ font-family: "Fraunces", Georgia, serif;
+ color: var(--rs-ink);
+ letter-spacing: -0.045em;
+ line-height: .98;
+}
+.display-title { font-size: clamp(3rem, 7vw, 6.6rem); max-width: 960px; }
+.page-title { font-size: clamp(2.5rem, 5vw, 4.8rem); max-width: 980px; }
+.section-title { font-size: clamp(2rem, 4vw, 3.4rem); }
+.hero-copy {
+ max-width: 720px;
+ color: var(--rs-muted);
+ font-size: clamp(1.05rem, 1.4vw, 1.28rem);
+ line-height: 1.7;
+}
+.text-muted-custom { color: var(--rs-muted); }
+
+.hero-proof {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 1rem;
+}
+.hero-proof div {
+ min-width: 135px;
+ padding: 1rem 1.15rem;
+ border: 1px solid var(--rs-line);
+ border-radius: 1.25rem;
+ background: rgba(255,255,255,.64);
+}
+.hero-proof strong { display: block; font-size: 1.7rem; color: var(--rs-primary); }
+.hero-proof span { color: var(--rs-muted); font-size: .88rem; font-weight: 700; }
+
+.glass-card, .form-panel, .detail-panel, .history-shell, .empty-state, .call-card, .step-card {
+ background: rgba(255, 255, 255, 0.78);
+ border: 1px solid rgba(16, 34, 31, 0.10);
+ box-shadow: var(--rs-shadow);
+ backdrop-filter: blur(16px);
+}
+.live-card { border-radius: 2rem; padding: 1.3rem; transform: rotate(1deg); }
+.pulse-dot {
+ display: inline-block;
+ width: .78rem;
+ height: .78rem;
+ margin-right: .45rem;
+ border-radius: 999px;
+ background: var(--rs-secondary);
+ box-shadow: 0 0 0 .42rem rgba(249,115,22,.16);
+}
+.status-pill, .score-badge, .mini-flag, .alert-flag {
+ display: inline-flex;
+ align-items: center;
+ border-radius: 999px;
+ font-size: .78rem;
+ font-weight: 900;
+}
+.status-pill { padding: .5rem .75rem; color: #8a3d00; background: #fff0d8; }
+.score-badge { padding: .55rem .7rem; color: #07564f; background: var(--rs-mint); }
+
+.transcript-box, .transcript-review {
+ border-radius: 1.3rem;
+ background: #10221f;
+ color: #eaffe9;
+ padding: 1rem;
+ line-height: 1.65;
+}
+.transcript-box p:last-child, .transcript-review p:last-child { margin-bottom: 0; }
+.metric-tile, .field-tile {
+ border-radius: 1.2rem;
+ padding: 1rem;
+ background: #fffdf8;
+ border: 1px solid var(--rs-line);
+}
+.metric-tile span, .field-tile span { display: block; color: var(--rs-muted); font-size: .82rem; font-weight: 800; }
+.metric-tile strong, .field-tile strong { display: block; color: var(--rs-ink); font-size: 1.25rem; margin-top: .25rem; }
+.metric-tile.urgent { background: #fff0d8; border-color: rgba(249,115,22,.35); }
+
+.section-pad { padding: 5rem 0; }
+.step-card, .call-card, .empty-state { border-radius: 1.5rem; padding: 1.35rem; height: 100%; }
+.step-card span { color: var(--rs-secondary); font-weight: 900; }
+.step-card h3, .call-card h3 { margin: .7rem 0 .45rem; font-weight: 900; letter-spacing: -.03em; }
+.step-card p, .call-card p { color: var(--rs-muted); }
+.call-card-link { color: inherit; text-decoration: none; display: block; height: 100%; }
+.call-card-link:hover .call-card { transform: translateY(-3px); border-color: rgba(15,118,110,.28); }
+.call-card, .btn, .step-card { transition: .18s ease; }
+.empty-state { text-align: center; max-width: 760px; margin: 0 auto; padding: 3rem; }
+
+.page-hero {
+ background: linear-gradient(135deg, #fff8ed 0%, #ecfff9 100%);
+ padding: 5rem 0 3rem;
+ border-bottom: 1px solid rgba(16,34,31,.08);
+}
+.page-hero.compact { padding-top: 4rem; }
+.detail-hero { background: radial-gradient(circle at 78% 10%, rgba(249,115,22,.15), transparent 25%), linear-gradient(135deg, #ecfff9, #fff8ed); }
+
+.form-panel, .detail-panel, .history-shell { border-radius: 1.6rem; padding: 1.4rem; }
+.panel-heading {
+ display: flex;
+ align-items: baseline;
+ gap: .8rem;
+ margin-bottom: 1.2rem;
+}
+.panel-heading span { color: var(--rs-secondary); font-weight: 900; letter-spacing: .08em; text-transform: uppercase; font-size: .78rem; }
+.panel-heading h2 { margin: 0; font-size: 1.25rem; font-weight: 900; letter-spacing: -.03em; }
+.form-label { font-weight: 850; color: var(--rs-ink); }
+.form-control, .form-select {
+ border-radius: 1rem;
+ border: 1px solid rgba(16,34,31,.14);
+ padding: .85rem 1rem;
+ background-color: rgba(255,255,255,.82);
+}
+.form-control:focus, .form-select:focus {
+ border-color: var(--rs-accent);
+ box-shadow: 0 0 0 .22rem rgba(20,184,166,.18);
+}
+.form-hint, .invalid-note { color: var(--rs-muted); font-size: .9rem; margin-top: .5rem; }
+.invalid-note { color: #b42318; font-weight: 800; }
+.checks-grid { display: grid; gap: .9rem; }
+.signal-check {
+ display: flex;
+ align-items: center;
+ gap: .75rem;
+ padding: .85rem 1rem;
+ border-radius: 1rem;
+ background: #fffdf8;
+ border: 1px solid var(--rs-line);
+ font-weight: 850;
+}
+.form-check-input:checked { background-color: var(--rs-primary); border-color: var(--rs-primary); }
+
+.history-table { margin: 0; }
+.history-table thead th {
+ color: var(--rs-muted);
+ text-transform: uppercase;
+ letter-spacing: .08em;
+ font-size: .75rem;
+ border-bottom-color: var(--rs-line);
+}
+.history-table td { padding: 1rem .75rem; border-color: rgba(16,34,31,.08); }
+.history-table td span { color: var(--rs-muted); font-size: .9rem; }
+.mini-flag { padding: .35rem .55rem; margin: .1rem; color: #8a3d00; background: #fff0d8; }
+.alert-flag { padding: .65rem .8rem; color: #8a3d00; background: #fff0d8; }
+.flag-stack { display: flex; flex-wrap: wrap; gap: .5rem; }
+
+.score-circle {
+ width: 8.25rem;
+ height: 8.25rem;
+ border-radius: 999px;
+ display: grid;
+ place-items: center;
+ text-align: center;
+ background: conic-gradient(var(--rs-primary), var(--rs-accent), var(--rs-secondary), var(--rs-primary));
+ color: #fff;
+ box-shadow: var(--rs-shadow);
+}
+.score-circle strong { display: block; font-size: 2rem; line-height: 1; }
+.score-circle span { display: block; font-size: .82rem; font-weight: 900; text-transform: uppercase; letter-spacing: .08em; }
+.field-tile.missing { background: #fff7ed; border-color: rgba(249,115,22,.28); }
+.field-tile.missing strong { color: #9a3412; }
+.readiness-list, .missing-list { list-style: none; padding: 0; margin: 0; display: grid; gap: .8rem; }
+.readiness-list li, .missing-list li {
+ display: flex;
+ justify-content: space-between;
+ gap: 1rem;
+ padding: .85rem 0;
+ border-bottom: 1px solid rgba(16,34,31,.08);
+}
+.readiness-list span { color: var(--rs-muted); font-weight: 800; }
+.readiness-list strong { text-align: right; }
+.missing-list li { display: block; color: #9a3412; font-weight: 900; }
+
+.site-footer {
+ padding: 2rem 0;
+ color: var(--rs-muted);
+ border-top: 1px solid rgba(16,34,31,.08);
+ background: rgba(255,253,247,.74);
+ font-weight: 700;
+}
+
+@media (max-width: 767px) {
+ .section-pad { padding: 3.25rem 0; }
+ .live-card { transform: none; }
+ .hero-proof div { flex: 1 1 100%; }
+ .empty-state { padding: 2rem 1.2rem; }
+ .score-circle { width: 7rem; height: 7rem; }
+}
+
+
+.endpoint-box {
+ display: grid;
+ gap: .45rem;
+ border-radius: 1.1rem;
+ padding: 1rem;
+ background: #fffdf8;
+ border: 1px solid var(--rs-line);
+}
+.endpoint-box span { color: var(--rs-muted); font-size: .78rem; font-weight: 900; letter-spacing: .08em; text-transform: uppercase; }
+.endpoint-box code, .endpoint-code code, .webhook-preview { color: var(--rs-ink); white-space: pre-wrap; word-break: break-word; }
+.endpoint-code {
+ border-radius: 1.15rem;
+ padding: 1rem;
+ background: #10221f;
+ color: #ecfff9;
+ border: 1px solid rgba(255,255,255,.08);
+ overflow-x: auto;
+}
+.endpoint-code code { color: #ecfff9; font-size: .88rem; }
+.webhook-preview { display: inline-block; max-width: 18rem; }