g
This commit is contained in:
parent
6bef74886c
commit
4f209aac8e
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
core/__pycache__/forms.cpython-311.pyc
Normal file
BIN
core/__pycache__/forms.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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')}),
|
||||
)
|
||||
|
||||
46
core/forms.py
Normal file
46
core/forms.py
Normal file
@ -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
|
||||
56
core/migrations/0001_initial.py
Normal file
56
core/migrations/0001_initial.py
Normal file
@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
38
core/migrations/0002_webhookevent.py
Normal file
38
core/migrations/0002_webhookevent.py
Normal file
@ -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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
BIN
core/migrations/__pycache__/0001_initial.cpython-311.pyc
Normal file
BIN
core/migrations/__pycache__/0001_initial.cpython-311.pyc
Normal file
Binary file not shown.
BIN
core/migrations/__pycache__/0002_webhookevent.cpython-311.pyc
Normal file
BIN
core/migrations/__pycache__/0002_webhookevent.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
152
core/models.py
152
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'
|
||||
|
||||
@ -1,25 +1,66 @@
|
||||
{% load static %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>{% block title %}Knowledge Base{% endblock %}</title>
|
||||
{% if project_description %}
|
||||
<meta name="description" content="{{ project_description }}">
|
||||
<meta property="og:description" content="{{ project_description }}">
|
||||
<meta property="twitter:description" content="{{ project_description }}">
|
||||
{% endif %}
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}{{ project_name|default:"ReliefSignal" }}{% endblock %}</title>
|
||||
<meta name="description" content="{% block meta_description %}{{ meta_description|default:project_description|default:'Real-time tax relief call intake pilot for transcription, structured extraction, completion scoring, and CRM readiness.' }}{% endblock %}">
|
||||
{% if project_image_url %}
|
||||
<meta property="og:image" content="{{ project_image_url }}">
|
||||
<meta property="twitter:image" content="{{ project_image_url }}">
|
||||
{% endif %}
|
||||
{% load static %}
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Fraunces:opsz,wght@9..144,700&display=swap" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="{% static 'css/custom.css' %}?v={{ deployment_timestamp }}">
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
|
||||
<body>
|
||||
{% block content %}{% endblock %}
|
||||
<a class="skip-link" href="#main-content">Skip to content</a>
|
||||
<nav class="navbar navbar-expand-lg app-nav sticky-top" aria-label="Primary navigation">
|
||||
<div class="container py-2">
|
||||
<a class="navbar-brand brand-mark" href="{% url 'home' %}">
|
||||
<span class="brand-icon">RS</span>
|
||||
<span>ReliefSignal</span>
|
||||
</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#mainNav" aria-controls="mainNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="mainNav">
|
||||
<ul class="navbar-nav ms-auto align-items-lg-center gap-lg-2">
|
||||
<li class="nav-item"><a class="nav-link" href="{% url 'home' %}#workflow">Workflow</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="{% url 'call_list' %}">History</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="{% url 'webhook_list' %}">Webhooks</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="/admin/">Admin</a></li>
|
||||
<li class="nav-item"><a class="btn btn-signal ms-lg-2" href="{% url 'call_create' %}">New pilot call</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{% if messages %}
|
||||
<div class="container message-stack" aria-live="polite">
|
||||
{% for message in messages %}
|
||||
<div class="alert alert-{{ message.tags|default:'info' }} shadow-sm mb-2" role="alert">{{ message }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<main id="main-content">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<footer class="site-footer">
|
||||
<div class="container d-flex flex-column flex-md-row justify-content-between gap-3">
|
||||
<span>ReliefSignal pilot console for tax relief call intake.</span>
|
||||
<span>Real-time audio first · CRM sync ready · Completion scored</span>
|
||||
</div>
|
||||
</footer>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
83
core/templates/core/call_detail.html
Normal file
83
core/templates/core/call_detail.html
Normal file
@ -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 %}
|
||||
<section class="page-hero compact detail-hero">
|
||||
<div class="container">
|
||||
<div class="d-flex flex-column flex-lg-row justify-content-between gap-4 align-items-lg-end">
|
||||
<div>
|
||||
<span class="eyebrow">Pilot call review</span>
|
||||
<h1 class="page-title">{{ call.client_name }}</h1>
|
||||
<p class="hero-copy mb-0">{{ call.phone }}{% if call.email %} · {{ call.email }}{% endif %}</p>
|
||||
</div>
|
||||
<div class="score-circle" aria-label="Completion score {{ call.completion_score }} percent"><strong>{{ call.completion_score }}%</strong><span>complete</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section-pad pt-4">
|
||||
<div class="container">
|
||||
<div class="row g-4">
|
||||
<div class="col-lg-8">
|
||||
<div class="detail-panel mb-4">
|
||||
<div class="panel-heading"><span>Transcript</span><h2>Conversation feed</h2></div>
|
||||
<div class="transcript-review">{{ call.transcript|linebreaks }}</div>
|
||||
</div>
|
||||
<div class="detail-panel">
|
||||
<div class="panel-heading"><span>Schema</span><h2>Extracted intake fields</h2></div>
|
||||
<div class="row g-3">
|
||||
{% for label, value in intake_fields %}
|
||||
<div class="col-md-6 col-xl-4">
|
||||
<div class="field-tile {% if not value %}missing{% endif %}">
|
||||
<span>{{ label }}</span>
|
||||
<strong>{% if value %}{{ value }}{% else %}Missing{% endif %}</strong>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<aside class="col-lg-4">
|
||||
<div class="detail-panel mb-4">
|
||||
<div class="panel-heading"><span>Readiness</span><h2>Integrations</h2></div>
|
||||
<ul class="readiness-list">
|
||||
<li><span>Call source</span><strong>{{ call.get_call_source_display }}</strong></li>
|
||||
<li><span>Ytel live audio</span><strong>{{ call.get_ytel_audio_status_display }}</strong></li>
|
||||
<li><span>Logics API</span><strong>{{ call.get_logics_api_status_display }}</strong></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="detail-panel mb-4">
|
||||
<div class="panel-heading"><span>Rules</span><h2>Coach alerts</h2></div>
|
||||
{% if call.urgent_flags %}
|
||||
<div class="flag-stack">
|
||||
{% for flag in call.urgent_flags %}<span class="alert-flag">{{ flag }}</span>{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted-custom mb-0">No urgent rules triggered yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="detail-panel mb-4">
|
||||
<div class="panel-heading"><span>Missing</span><h2>Required fields</h2></div>
|
||||
{% if call.missing_required_labels %}
|
||||
<ul class="missing-list">
|
||||
{% for label in call.missing_required_labels %}<li>{{ label }}</li>{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="text-muted-custom mb-0">All required fields are captured.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="detail-panel">
|
||||
<div class="panel-heading"><span>Notes</span><h2>Next steps</h2></div>
|
||||
<p>{{ call.agent_notes|default:'No notes yet.'|linebreaksbr }}</p>
|
||||
<div class="d-grid gap-2 mt-3">
|
||||
<a class="btn btn-signal" href="{% url 'call_create' %}">Create another call</a>
|
||||
<a class="btn btn-ghost" href="{% url 'call_list' %}">Back to history</a>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
94
core/templates/core/call_form.html
Normal file
94
core/templates/core/call_form.html
Normal file
@ -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 %}
|
||||
<section class="page-hero compact">
|
||||
<div class="container">
|
||||
<span class="eyebrow">Create pilot intake</span>
|
||||
<h1 class="page-title">Start a real-time call proof point.</h1>
|
||||
<p class="hero-copy mb-0">Paste a transcript segment, capture the tax relief fields your AI should extract, and record whether Ytel audio + Logics updates are ready.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section-pad pt-4">
|
||||
<div class="container">
|
||||
<form method="post" class="intake-form" novalidate>
|
||||
{% csrf_token %}
|
||||
{% if form.non_field_errors %}
|
||||
<div class="alert alert-danger">{{ form.non_field_errors }}</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="form-panel mb-4">
|
||||
<div class="panel-heading"><span>01</span><h2>Call identity</h2></div>
|
||||
<div class="row g-4">
|
||||
{% for field in form %}
|
||||
{% if field.name in 'client_name phone email call_source ytel_audio_status logics_api_status' %}
|
||||
<div class="col-md-6 col-lg-4">
|
||||
<label class="form-label" for="{{ field.id_for_label }}">{{ field.label }}</label>
|
||||
{{ field }}
|
||||
{% for error in field.errors %}<div class="invalid-note">{{ error }}</div>{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-panel mb-4">
|
||||
<div class="panel-heading"><span>02</span><h2>Transcript</h2></div>
|
||||
<label class="form-label" for="{{ form.transcript.id_for_label }}">{{ form.transcript.label }}</label>
|
||||
{{ form.transcript }}
|
||||
<p class="form-hint">For the live MVP, this represents the text coming from speech-to-text every few seconds.</p>
|
||||
{% for error in form.transcript.errors %}<div class="invalid-note">{{ error }}</div>{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="form-panel mb-4">
|
||||
<div class="panel-heading"><span>03</span><h2>Extracted intake schema</h2></div>
|
||||
<div class="row g-4">
|
||||
{% 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' %}
|
||||
<div class="col-md-6 col-lg-4">
|
||||
<label class="form-label" for="{{ field.id_for_label }}">{{ field.label }}</label>
|
||||
{{ field }}
|
||||
{% for error in field.errors %}<div class="invalid-note">{{ error }}</div>{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<div class="col-12">
|
||||
<label class="form-label" for="{{ form.assets.id_for_label }}">{{ form.assets.label }}</label>
|
||||
{{ form.assets }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-panel mb-4">
|
||||
<div class="panel-heading"><span>04</span><h2>Rules engine flags</h2></div>
|
||||
<div class="row g-4 align-items-start">
|
||||
<div class="col-lg-5">
|
||||
<div class="checks-grid">
|
||||
{% for field in form %}
|
||||
{% if field.name in 'irs_payment_plan levy garnishment' %}
|
||||
<label class="form-check signal-check">
|
||||
{{ field }}
|
||||
<span>{{ field.label }}</span>
|
||||
</label>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-7">
|
||||
<label class="form-label" for="{{ form.agent_notes.id_for_label }}">{{ form.agent_notes.label }}</label>
|
||||
{{ form.agent_notes }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-column flex-sm-row gap-3 justify-content-end">
|
||||
<a class="btn btn-ghost" href="{% url 'call_list' %}">Cancel</a>
|
||||
<button class="btn btn-signal" type="submit">Save and review score</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
64
core/templates/core/call_list.html
Normal file
64
core/templates/core/call_list.html
Normal file
@ -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 %}
|
||||
<section class="page-hero compact">
|
||||
<div class="container d-flex flex-column flex-lg-row justify-content-between gap-4 align-items-lg-end">
|
||||
<div>
|
||||
<span class="eyebrow">History</span>
|
||||
<h1 class="page-title">Pilot call records</h1>
|
||||
<p class="hero-copy mb-0">Every saved call becomes evidence for what the MVP captures and what integrations still need validation.</p>
|
||||
</div>
|
||||
<a class="btn btn-signal" href="{% url 'call_create' %}">New pilot call</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section-pad pt-4">
|
||||
<div class="container">
|
||||
{% if calls %}
|
||||
<div class="history-shell">
|
||||
<div class="table-responsive">
|
||||
<table class="table align-middle history-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Client</th>
|
||||
<th>Source</th>
|
||||
<th>Completion</th>
|
||||
<th>Readiness</th>
|
||||
<th>Flags</th>
|
||||
<th class="text-end">Open</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for call in calls %}
|
||||
<tr>
|
||||
<td><strong>{{ call.client_name }}</strong><br><span>{{ call.phone }}</span></td>
|
||||
<td>{{ call.get_call_source_display }}<br><span>{{ call.created_at|date:"M j, Y g:i A" }}</span></td>
|
||||
<td><span class="score-badge">{{ call.completion_score }}%</span></td>
|
||||
<td><span>Ytel: {{ call.get_ytel_audio_status_display }}</span><br><span>Logics: {{ call.get_logics_api_status_display }}</span></td>
|
||||
<td>
|
||||
{% if call.urgent_flags %}
|
||||
{% for flag in call.urgent_flags %}<span class="mini-flag">{{ flag }}</span>{% endfor %}
|
||||
{% else %}
|
||||
<span class="text-muted-custom">None</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-end"><a class="btn btn-outline-signal btn-sm" href="{% url 'call_detail' call.pk %}">Review</a></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<h2>No call history yet</h2>
|
||||
<p>Save your first pilot call to confirm the workflow from transcript capture to scored intake review.</p>
|
||||
<a class="btn btn-signal" href="{% url 'call_create' %}">Create first call</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@ -1,145 +1,104 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ project_name }}{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--bg-color-start: #6a11cb;
|
||||
--bg-color-end: #2575fc;
|
||||
--text-color: #ffffff;
|
||||
--card-bg-color: rgba(255, 255, 255, 0.01);
|
||||
--card-border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'Inter', sans-serif;
|
||||
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
|
||||
color: var(--text-color);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='100' height='100' viewBox='0 0 100 100'><path d='M-10 10L110 10M10 -10L10 110' stroke-width='1' stroke='rgba(255,255,255,0.05)'/></svg>");
|
||||
animation: bg-pan 20s linear infinite;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
@keyframes bg-pan {
|
||||
0% {
|
||||
background-position: 0% 0%;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: 100% 100%;
|
||||
}
|
||||
}
|
||||
|
||||
main {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--card-bg-color);
|
||||
border: 1px solid var(--card-border-color);
|
||||
border-radius: 16px;
|
||||
padding: 2.5rem 2rem;
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: clamp(2.2rem, 3vw + 1.2rem, 3.2rem);
|
||||
font-weight: 700;
|
||||
margin: 0 0 1.2rem;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0.5rem 0;
|
||||
font-size: 1.1rem;
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.loader {
|
||||
margin: 1.5rem auto;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border: 4px solid rgba(255, 255, 255, 0.25);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.runtime code {
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
padding: 0.15rem 0.45rem;
|
||||
border-radius: 4px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
border: 0;
|
||||
}
|
||||
|
||||
footer {
|
||||
position: absolute;
|
||||
bottom: 1rem;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.75;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% block title %}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 %}
|
||||
<main>
|
||||
<div class="card">
|
||||
<h1>Analyzing your requirements and generating your app…</h1>
|
||||
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes">
|
||||
<span class="sr-only">Loading…</span>
|
||||
<section class="hero-section position-relative overflow-hidden">
|
||||
<div class="orb orb-one"></div>
|
||||
<div class="orb orb-two"></div>
|
||||
<div class="container position-relative py-5 py-lg-6">
|
||||
<div class="row align-items-center g-5">
|
||||
<div class="col-lg-7">
|
||||
<span class="eyebrow">Real-time tax relief intake MVP</span>
|
||||
<h1 class="display-title mt-3">Prove live call extraction before building the whole platform.</h1>
|
||||
<p class="hero-copy mt-4">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.</p>
|
||||
<div class="d-flex flex-column flex-sm-row gap-3 mt-4">
|
||||
<a class="btn btn-signal btn-lg" href="{% url 'call_create' %}">Start a pilot call</a>
|
||||
<a class="btn btn-ghost btn-lg" href="{% url 'call_list' %}">View history</a>
|
||||
</div>
|
||||
<div class="hero-proof mt-5" aria-label="Pilot metrics">
|
||||
<div><strong>{{ total_calls }}</strong><span>Pilot calls</span></div>
|
||||
<div><strong>{{ avg_completion }}%</strong><span>Avg completion</span></div>
|
||||
<div><strong>{{ urgent_count }}</strong><span>Urgent flags</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-5">
|
||||
<div class="live-card glass-card">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<span class="pulse-dot"></span>
|
||||
<span class="small-label">Live Call Screen</span>
|
||||
</div>
|
||||
<span class="status-pill">Ytel audio: validate</span>
|
||||
</div>
|
||||
<div class="transcript-box mb-4">
|
||||
<p><strong>Client:</strong> I owe around $24,000 to the IRS and I’m worried about a levy.</p>
|
||||
<p><strong>Agent:</strong> Which years are included, and what is your monthly income?</p>
|
||||
</div>
|
||||
<div class="row g-3">
|
||||
<div class="col-6"><div class="metric-tile"><span>IRS Debt</span><strong>$24,000</strong></div></div>
|
||||
<div class="col-6"><div class="metric-tile"><span>Employment</span><strong>1099</strong></div></div>
|
||||
<div class="col-6"><div class="metric-tile urgent"><span>Alert</span><strong>Levy</strong></div></div>
|
||||
<div class="col-6"><div class="metric-tile"><span>Completion</span><strong>80%</strong></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint">AppWizzy AI is collecting your requirements and applying the first changes.</p>
|
||||
<p class="hint">This page will refresh automatically as the plan is implemented.</p>
|
||||
<p class="runtime">
|
||||
Runtime: Django <code>{{ django_version }}</code> · Python <code>{{ python_version }}</code>
|
||||
— UTC <code>{{ current_time|date:"Y-m-d H:i:s" }}</code>
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
<footer>
|
||||
Page updated: {{ current_time|date:"Y-m-d H:i:s" }} (UTC)
|
||||
</footer>
|
||||
{% endblock %}
|
||||
</section>
|
||||
|
||||
<section id="workflow" class="section-pad">
|
||||
<div class="container">
|
||||
<div class="row g-4 align-items-stretch">
|
||||
<div class="col-lg-4">
|
||||
<div class="section-kicker">First workflow</div>
|
||||
<h2 class="section-title">Capture → score → review.</h2>
|
||||
<p class="text-muted-custom">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.</p>
|
||||
</div>
|
||||
<div class="col-lg-8">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4"><article class="step-card"><span>01</span><h3>Create</h3><p>Paste live transcript text and extracted intake values.</p></article></div>
|
||||
<div class="col-md-4"><article class="step-card"><span>02</span><h3>Validate</h3><p>Track Ytel audio and Logics API readiness per pilot call.</p></article></div>
|
||||
<div class="col-md-4"><article class="step-card"><span>03</span><h3>Coach</h3><p>Surface missing fields and urgent tax relief flags.</p></article></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section-pad pt-0">
|
||||
<div class="container">
|
||||
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-end gap-3 mb-4">
|
||||
<div>
|
||||
<div class="section-kicker">Recent pilot calls</div>
|
||||
<h2 class="section-title mb-0">History starts here.</h2>
|
||||
</div>
|
||||
<a class="btn btn-outline-signal" href="{% url 'call_list' %}">Open history</a>
|
||||
</div>
|
||||
{% if recent_calls %}
|
||||
<div class="row g-3">
|
||||
{% for call in recent_calls %}
|
||||
<div class="col-md-4">
|
||||
<a class="call-card-link" href="{% url 'call_detail' call.pk %}">
|
||||
<article class="call-card h-100">
|
||||
<div class="d-flex justify-content-between align-items-start gap-3">
|
||||
<div><h3>{{ call.client_name }}</h3><p>{{ call.phone }}</p></div>
|
||||
<span class="score-badge">{{ call.completion_score }}%</span>
|
||||
</div>
|
||||
<p class="mb-0 small-label">{{ call.get_call_source_display }} · {{ call.created_at|date:"M j, Y" }}</p>
|
||||
</article>
|
||||
</a>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<h3>No pilot calls yet</h3>
|
||||
<p>Create the first call record to test transcript capture, intake fields, completion scoring, and urgent flags.</p>
|
||||
<a class="btn btn-signal" href="{% url 'call_create' %}">Create first call</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
94
core/templates/core/webhook_list.html
Normal file
94
core/templates/core/webhook_list.html
Normal file
@ -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 %}
|
||||
<section class="page-hero compact">
|
||||
<div class="container">
|
||||
<span class="eyebrow">Generic webhook inbox</span>
|
||||
<div class="row align-items-end g-4 mt-2">
|
||||
<div class="col-lg-8">
|
||||
<h1 class="display-title">Capture provider events before we commit to Ytel-specific logic.</h1>
|
||||
<p class="hero-copy mt-3">Use this receiver to collect real webhook payloads, inspect headers safely, and map the fields we need for call intake automation.</p>
|
||||
</div>
|
||||
<div class="col-lg-4 text-lg-end">
|
||||
<a class="btn btn-signal" href="{% url 'call_list' %}">Back to call history</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section-pad">
|
||||
<div class="container">
|
||||
<div class="row g-4">
|
||||
<div class="col-lg-5">
|
||||
<div class="glass-card form-panel h-100">
|
||||
<div class="panel-heading">
|
||||
<span>01</span>
|
||||
<h2>Receiver endpoint</h2>
|
||||
</div>
|
||||
<p class="text-muted-custom">Point test providers here. Add <strong>?provider=ytel</strong> when you start sending Ytel samples.</p>
|
||||
<div class="endpoint-box mt-3">
|
||||
<span>POST endpoint</span>
|
||||
<code>{{ endpoint_url }}</code>
|
||||
</div>
|
||||
<pre class="endpoint-code mt-3"><code>curl -X POST "{{ endpoint_url }}?provider=test" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"event_type":"call.ended","external_id":"demo-call-001","status":"completed"}'</code></pre>
|
||||
<ul class="readiness-list mt-4">
|
||||
<li><span>JSON bodies</span><strong>Accepted</strong></li>
|
||||
<li><span>Form posts</span><strong>Accepted</strong></li>
|
||||
<li><span>Auth/signature headers</span><strong>Redacted</strong></li>
|
||||
<li><span>Max body size</span><strong>512 KB</strong></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-7">
|
||||
<div class="glass-card history-shell h-100">
|
||||
<div class="d-flex flex-column flex-md-row justify-content-between gap-3 align-items-md-center mb-4">
|
||||
<div>
|
||||
<span class="eyebrow">{{ total_events }} total received</span>
|
||||
<h2 class="h3 fw-bold mb-0">Recent webhook events</h2>
|
||||
</div>
|
||||
<a class="btn btn-outline-signal btn-sm" href="/admin/core/webhookevent/">Open in admin</a>
|
||||
</div>
|
||||
|
||||
{% if events %}
|
||||
<div class="table-responsive">
|
||||
<table class="table history-table align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Received</th>
|
||||
<th>Provider</th>
|
||||
<th>Event</th>
|
||||
<th>External ID</th>
|
||||
<th>Preview</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for event in events %}
|
||||
<tr>
|
||||
<td><strong>#{{ event.pk }}</strong><br><span>{{ event.received_at|date:"M j, Y g:i A" }}</span></td>
|
||||
<td><span class="mini-flag">{{ event.provider }}</span></td>
|
||||
<td>{{ event.display_event_type }}</td>
|
||||
<td>{% if event.external_id %}<code>{{ event.external_id }}</code>{% else %}<span class="text-muted-custom">Not supplied</span>{% endif %}</td>
|
||||
<td><code class="webhook-preview">{{ event.raw_body|default:"No body"|truncatechars:110 }}</code></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<h2>No webhooks received yet</h2>
|
||||
<p>Send a test POST to the endpoint to verify connectivity, then we can map the real Ytel fields into pilot call records.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@ -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/<int:pk>/', call_detail, name='call_detail'),
|
||||
path('webhooks/', webhook_list, name='webhook_list'),
|
||||
path('webhooks/generic/', webhook_generic, name='webhook_generic'),
|
||||
]
|
||||
|
||||
288
core/views.py
288
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
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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; }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user