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

+
+
+ +
+
+
+ {% csrf_token %} + {% if form.non_field_errors %} +
{{ form.non_field_errors }}
+ {% endif %} + +
+
01

Call identity

+
+ {% for field in form %} + {% if field.name in 'client_name phone email call_source ytel_audio_status logics_api_status' %} +
+ + {{ field }} + {% for error in field.errors %}
{{ error }}
{% endfor %} +
+ {% endif %} + {% endfor %} +
+
+ +
+
02

Transcript

+ + {{ form.transcript }} +

For the live MVP, this represents the text coming from speech-to-text every few seconds.

+ {% for error in form.transcript.errors %}
{{ error }}
{% endfor %} +
+ +
+
03

Extracted intake schema

+
+ {% for field in form %} + {% if field.name not in 'client_name phone email call_source ytel_audio_status logics_api_status transcript irs_payment_plan levy garnishment assets agent_notes' %} +
+ + {{ field }} + {% for error in field.errors %}
{{ error }}
{% endfor %} +
+ {% endif %} + {% endfor %} +
+ + {{ form.assets }} +
+
+
+ +
+
04

Rules engine flags

+
+
+
+ {% for field in form %} + {% if field.name in 'irs_payment_plan levy garnishment' %} + + {% endif %} + {% endfor %} +
+
+
+ + {{ form.agent_notes }} +
+
+
+ +
+ Cancel + +
+
+
+
+{% 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 %} +
+
+ + + + + + + + + + + + + {% for call in calls %} + + + + + + + + + {% endfor %} + +
ClientSourceCompletionReadinessFlagsOpen
{{ 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
+
+
+ {% 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?

+
+
+
IRS Debt$24,000
+
Employment1099
+
AlertLevy
+
Completion80%
+
+
+
-

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

+
+ +
+
+
+ +
+
+
+
+
+
+ 01 +

Receiver endpoint

+
+

Point test providers here. Add ?provider=ytel when you start sending Ytel samples.

+
+ POST endpoint + {{ endpoint_url }} +
+
curl -X POST "{{ endpoint_url }}?provider=test" \
+  -H "Content-Type: application/json" \
+  -d '{"event_type":"call.ended","external_id":"demo-call-001","status":"completed"}'
+
    +
  • JSON bodiesAccepted
  • +
  • Form postsAccepted
  • +
  • Auth/signature headersRedacted
  • +
  • Max body size512 KB
  • +
+
+
+ +
+
+
+
+ {{ total_events }} total received +

Recent webhook events

+
+ Open in admin +
+ + {% if events %} +
+ + + + + + + + + + + + {% for event in events %} + + + + + + + + {% endfor %} + +
ReceivedProviderEventExternal IDPreview
#{{ 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 }}
+
+ {% 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; }