Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2447ac66a4 | ||
|
|
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
|
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,67 @@
|
|||||||
|
{% load static %}
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<title>{% block title %}Knowledge Base{% endblock %}</title>
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
{% if project_description %}
|
<title>{% block title %}{{ project_name|default:"ReliefSignal" }}{% endblock %}</title>
|
||||||
<meta name="description" content="{{ project_description }}">
|
<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 %}">
|
||||||
<meta property="og:description" content="{{ project_description }}">
|
|
||||||
<meta property="twitter:description" content="{{ project_description }}">
|
|
||||||
{% endif %}
|
|
||||||
{% if project_image_url %}
|
{% if project_image_url %}
|
||||||
<meta property="og:image" content="{{ project_image_url }}">
|
<meta property="og:image" content="{{ project_image_url }}">
|
||||||
<meta property="twitter:image" content="{{ project_image_url }}">
|
<meta property="twitter:image" content="{{ project_image_url }}">
|
||||||
{% endif %}
|
{% 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 }}">
|
<link rel="stylesheet" href="{% static 'css/custom.css' %}?v={{ deployment_timestamp }}">
|
||||||
{% block head %}{% endblock %}
|
{% block head %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<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 'audio_capture' %}">Audio</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>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
132
core/templates/core/audio_capture.html
Normal file
132
core/templates/core/audio_capture.html
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}Computer Audio Capture | {{ project_name }}{% endblock %}
|
||||||
|
{% block meta_description %}Capture microphone input and browser-approved tab or system audio locally before connecting the workflow to Ytel.{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<header class="page-hero audio-hero">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row align-items-end g-4">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<span class="eyebrow">Computer audio first</span>
|
||||||
|
<h1 class="page-title mt-3 mb-3">Capture mic input and shared output audio in the browser.</h1>
|
||||||
|
<p class="hero-copy mb-0">This prototype lets you test call audio capture before the Ytel-specific integration. Your browser will ask permission for the microphone and for a tab/window/screen with audio.</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<aside class="glass-card audio-guardrail" aria-label="Audio capture guardrails">
|
||||||
|
<strong>Privacy guardrail</strong>
|
||||||
|
<p>No silent background listening. Audio stays local on this page until we add an explicit upload or transcription step.</p>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="section-pad">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row g-4">
|
||||||
|
<div class="col-xl-8">
|
||||||
|
<article class="audio-console glass-card" aria-labelledby="capture-title">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<span>Step 1</span>
|
||||||
|
<h2 id="capture-title">Connect audio sources</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="audio-status-grid mb-4" aria-live="polite">
|
||||||
|
<div class="audio-source-card" data-source-card="mic">
|
||||||
|
<span class="status-pill muted" id="mic-status">Not connected</span>
|
||||||
|
<h3>Microphone input</h3>
|
||||||
|
<p>Captures your voice or headset microphone.</p>
|
||||||
|
<div class="audio-meter" aria-hidden="true"><span id="mic-meter"></span></div>
|
||||||
|
</div>
|
||||||
|
<div class="audio-source-card" data-source-card="system">
|
||||||
|
<span class="status-pill muted" id="system-status">Not connected</span>
|
||||||
|
<h3>Computer/tab output</h3>
|
||||||
|
<p>Use browser screen sharing and select a source with audio enabled.</p>
|
||||||
|
<div class="audio-meter" aria-hidden="true"><span id="system-meter"></span></div>
|
||||||
|
</div>
|
||||||
|
<div class="audio-source-card" data-source-card="mix">
|
||||||
|
<span class="status-pill muted" id="mix-status">Waiting</span>
|
||||||
|
<h3>Combined recording mix</h3>
|
||||||
|
<p>Records whichever approved sources are active.</p>
|
||||||
|
<div class="audio-meter" aria-hidden="true"><span id="mix-meter"></span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="audio-actions">
|
||||||
|
<button class="btn btn-signal" type="button" id="connect-mic">Enable microphone</button>
|
||||||
|
<button class="btn btn-outline-signal" type="button" id="connect-system">Share computer/tab audio</button>
|
||||||
|
<button class="btn btn-signal" type="button" id="start-recording" disabled>Start local recording</button>
|
||||||
|
<button class="btn btn-outline-signal danger" type="button" id="stop-recording" disabled>Stop recording</button>
|
||||||
|
<button class="btn btn-light reset-btn" type="button" id="reset-audio" disabled>Reset</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="recording-result mt-4" id="recording-result" hidden>
|
||||||
|
<div>
|
||||||
|
<span class="small-label">Local test file</span>
|
||||||
|
<h3 class="mb-2">Recording ready</h3>
|
||||||
|
<p class="text-muted-custom mb-3">Preview it here or download it. Next, we can add secure upload + transcription.</p>
|
||||||
|
</div>
|
||||||
|
<audio controls id="recording-preview"></audio>
|
||||||
|
<a class="btn btn-outline-signal" id="download-recording" download="reliefsignal-audio-test.webm">Download WebM</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<video id="shared-video-preview" playsinline muted hidden></video>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-xl-4">
|
||||||
|
<aside class="detail-panel h-100" aria-labelledby="how-title">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<span>Notes</span>
|
||||||
|
<h2 id="how-title">How this works</h2>
|
||||||
|
</div>
|
||||||
|
<ul class="readiness-list audio-note-list">
|
||||||
|
<li><span>Mic</span><strong>Browser permission</strong></li>
|
||||||
|
<li><span>Output audio</span><strong>Share tab/screen audio</strong></li>
|
||||||
|
<li><span>Storage</span><strong>Local browser blob</strong></li>
|
||||||
|
<li><span>Next step</span><strong>Upload/transcribe</strong></li>
|
||||||
|
</ul>
|
||||||
|
<div class="audio-tip mt-4">
|
||||||
|
<strong>Best first test</strong>
|
||||||
|
<p>Open the Ytel webphone or any sample call audio in a browser tab, then choose that tab when clicking <em>Share computer/tab audio</em>. In Chrome, make sure the share dialog has audio enabled.</p>
|
||||||
|
</div>
|
||||||
|
<div class="audio-tip subtle mt-3">
|
||||||
|
<strong>Browser limitation</strong>
|
||||||
|
<p>Some browsers only share tab audio, not full device output. If system audio is not available, use a Chrome tab source or a virtual audio device later.</p>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-4 mt-1">
|
||||||
|
<div class="col-lg-7">
|
||||||
|
<section class="detail-panel" aria-labelledby="event-log-title">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<span>Live</span>
|
||||||
|
<h2 id="event-log-title">Capture log</h2>
|
||||||
|
</div>
|
||||||
|
<ol class="audio-log" id="audio-log" aria-live="polite">
|
||||||
|
<li>Ready. Connect the microphone or shared audio source to begin.</li>
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-5">
|
||||||
|
<section class="detail-panel" aria-labelledby="next-title">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<span>After test</span>
|
||||||
|
<h2 id="next-title">Recommended next slice</h2>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted-custom">Once capture works on your computer, the next practical build is a secure upload/transcription endpoint that turns the local recording into a pilot call transcript.</p>
|
||||||
|
<a class="btn btn-signal" href="{% url 'call_create' %}">Open intake form</a>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
<script src="{% static 'js/audio-capture.js' %}?v={{ deployment_timestamp }}" defer></script>
|
||||||
|
{% endblock %}
|
||||||
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" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}{{ project_name }}{% 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 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 content %}
|
{% block content %}
|
||||||
<main>
|
<section class="hero-section position-relative overflow-hidden">
|
||||||
<div class="card">
|
<div class="orb orb-one"></div>
|
||||||
<h1>Analyzing your requirements and generating your app…</h1>
|
<div class="orb orb-two"></div>
|
||||||
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes">
|
<div class="container position-relative py-5 py-lg-6">
|
||||||
<span class="sr-only">Loading…</span>
|
<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>
|
</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>
|
</div>
|
||||||
</main>
|
</section>
|
||||||
<footer>
|
|
||||||
Page updated: {{ current_time|date:"Y-m-d H:i:s" }} (UTC)
|
<section id="workflow" class="section-pad">
|
||||||
</footer>
|
<div class="container">
|
||||||
{% endblock %}
|
<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 %}
|
||||||
10
core/urls.py
10
core/urls.py
@ -1,7 +1,13 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from .views import home
|
from .views import audio_capture, call_create, call_detail, call_list, home, webhook_generic, webhook_list
|
||||||
|
|
||||||
urlpatterns = [
|
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('audio/', audio_capture, name='audio_capture'),
|
||||||
|
path('webhooks/', webhook_list, name='webhook_list'),
|
||||||
|
path('webhooks/generic/', webhook_generic, name='webhook_generic'),
|
||||||
]
|
]
|
||||||
|
|||||||
296
core/views.py
296
core/views.py
@ -1,25 +1,285 @@
|
|||||||
import os
|
import ipaddress
|
||||||
import platform
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
from django import get_version as django_version
|
from django.contrib import messages
|
||||||
from django.shortcuts import render
|
from django.db.models import Count
|
||||||
from django.utils import timezone
|
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):
|
def home(request):
|
||||||
"""Render the landing screen with loader and environment details."""
|
"""Render the branded landing dashboard for the real-time intake pilot."""
|
||||||
host_name = request.get_host().lower()
|
recent_calls = PilotCall.objects.all()[:3]
|
||||||
agent_brand = "AppWizzy" if host_name == "appwizzy.com" else "Flatlogic"
|
total_calls = PilotCall.objects.count()
|
||||||
now = timezone.now()
|
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 = {
|
context = {
|
||||||
"project_name": "New Style",
|
'project_name': 'ReliefSignal',
|
||||||
"agent_brand": agent_brand,
|
'meta_description': 'Real-time tax relief call intake pilot for live transcription, structured extraction, completion scoring, and CRM readiness.',
|
||||||
"django_version": django_version(),
|
'recent_calls': recent_calls,
|
||||||
"python_version": platform.python_version(),
|
'total_calls': total_calls,
|
||||||
"current_time": now,
|
'urgent_count': urgent_count,
|
||||||
"host_name": host_name,
|
'avg_completion': avg_completion,
|
||||||
"project_description": os.getenv("PROJECT_DESCRIPTION", ""),
|
|
||||||
"project_image_url": os.getenv("PROJECT_IMAGE_URL", ""),
|
|
||||||
}
|
}
|
||||||
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 audio_capture(request):
|
||||||
|
context = {
|
||||||
|
'project_name': 'ReliefSignal',
|
||||||
|
'meta_description': 'Browser-based computer audio capture prototype for microphone input, shared tab/system output audio, local level monitoring, and local recording tests.',
|
||||||
|
}
|
||||||
|
return render(request, 'core/audio_capture.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,431 @@
|
|||||||
/* Custom styles for the application */
|
/* ReliefSignal custom brand layer — intentionally overrides default Bootstrap appearance. */
|
||||||
body {
|
:root {
|
||||||
font-family: system-ui, -apple-system, sans-serif;
|
--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; }
|
||||||
|
|
||||||
|
|
||||||
|
.audio-hero {
|
||||||
|
background: radial-gradient(circle at 82% 18%, rgba(20,184,166,.22), transparent 28%), linear-gradient(135deg, #fff8ed 0%, #ecfff9 100%);
|
||||||
|
}
|
||||||
|
.audio-guardrail {
|
||||||
|
border-radius: 1.4rem;
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
.audio-guardrail strong, .audio-tip strong { display: block; color: var(--rs-ink); margin-bottom: .35rem; }
|
||||||
|
.audio-guardrail p, .audio-tip p { color: var(--rs-muted); margin-bottom: 0; line-height: 1.6; }
|
||||||
|
.audio-console { border-radius: 1.8rem; padding: 1.45rem; }
|
||||||
|
.audio-status-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.audio-source-card {
|
||||||
|
border: 1px solid var(--rs-line);
|
||||||
|
border-radius: 1.35rem;
|
||||||
|
background: #fffdf8;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
.audio-source-card h3 {
|
||||||
|
margin: .85rem 0 .35rem;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: -.025em;
|
||||||
|
color: var(--rs-ink);
|
||||||
|
}
|
||||||
|
.audio-source-card p { min-height: 3.2rem; color: var(--rs-muted); margin-bottom: .85rem; }
|
||||||
|
.status-pill.muted { color: var(--rs-muted); background: #f3f4f1; }
|
||||||
|
.status-pill.active { color: #07564f; background: var(--rs-mint); }
|
||||||
|
.status-pill.recording { color: #8a3d00; background: #fff0d8; }
|
||||||
|
.audio-meter {
|
||||||
|
height: .78rem;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(16,34,31,.08);
|
||||||
|
}
|
||||||
|
.audio-meter span {
|
||||||
|
display: block;
|
||||||
|
width: 0%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: linear-gradient(90deg, var(--rs-primary), var(--rs-accent), var(--rs-secondary));
|
||||||
|
transition: width .08s linear;
|
||||||
|
}
|
||||||
|
.audio-actions { display: flex; flex-wrap: wrap; gap: .75rem; }
|
||||||
|
.btn-outline-signal {
|
||||||
|
color: var(--rs-primary);
|
||||||
|
border: 1px solid rgba(15,118,110,.28);
|
||||||
|
background: rgba(255,255,255,.74);
|
||||||
|
font-weight: 900;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: .8rem 1.05rem;
|
||||||
|
}
|
||||||
|
.btn-outline-signal:hover { color: #fff; background: var(--rs-primary); border-color: var(--rs-primary); }
|
||||||
|
.btn-outline-signal.danger { color: #9a3412; border-color: rgba(249,115,22,.35); }
|
||||||
|
.btn-outline-signal.danger:hover { color: #fff; background: var(--rs-secondary); border-color: var(--rs-secondary); }
|
||||||
|
.reset-btn { border-radius: 999px; padding: .8rem 1.05rem; font-weight: 900; }
|
||||||
|
.recording-result {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
border-radius: 1.35rem;
|
||||||
|
border: 1px solid rgba(20,184,166,.22);
|
||||||
|
background: #ecfff9;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
.recording-result audio { width: 100%; }
|
||||||
|
.audio-note-list strong { max-width: 12rem; }
|
||||||
|
.audio-tip {
|
||||||
|
border-radius: 1.1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: #fffdf8;
|
||||||
|
border: 1px solid var(--rs-line);
|
||||||
|
}
|
||||||
|
.audio-tip.subtle { background: #f7fbf7; }
|
||||||
|
.audio-log {
|
||||||
|
list-style-position: inside;
|
||||||
|
display: grid;
|
||||||
|
gap: .7rem;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
color: var(--rs-muted);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.audio-log li {
|
||||||
|
border-bottom: 1px solid rgba(16,34,31,.08);
|
||||||
|
padding-bottom: .7rem;
|
||||||
|
}
|
||||||
|
.audio-log li:last-child { border-bottom: 0; padding-bottom: 0; }
|
||||||
|
|
||||||
|
@media (max-width: 991px) {
|
||||||
|
.audio-status-grid { grid-template-columns: 1fr; }
|
||||||
|
.audio-source-card p { min-height: auto; }
|
||||||
}
|
}
|
||||||
|
|||||||
330
static/js/audio-capture.js
Normal file
330
static/js/audio-capture.js
Normal file
@ -0,0 +1,330 @@
|
|||||||
|
(() => {
|
||||||
|
const state = {
|
||||||
|
audioContext: null,
|
||||||
|
destination: null,
|
||||||
|
micStream: null,
|
||||||
|
systemStream: null,
|
||||||
|
mixedStream: null,
|
||||||
|
recorder: null,
|
||||||
|
chunks: [],
|
||||||
|
animationFrame: null,
|
||||||
|
analyzers: {},
|
||||||
|
downloadUrl: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const els = {
|
||||||
|
connectMic: document.getElementById('connect-mic'),
|
||||||
|
connectSystem: document.getElementById('connect-system'),
|
||||||
|
start: document.getElementById('start-recording'),
|
||||||
|
stop: document.getElementById('stop-recording'),
|
||||||
|
reset: document.getElementById('reset-audio'),
|
||||||
|
micStatus: document.getElementById('mic-status'),
|
||||||
|
systemStatus: document.getElementById('system-status'),
|
||||||
|
mixStatus: document.getElementById('mix-status'),
|
||||||
|
micMeter: document.getElementById('mic-meter'),
|
||||||
|
systemMeter: document.getElementById('system-meter'),
|
||||||
|
mixMeter: document.getElementById('mix-meter'),
|
||||||
|
log: document.getElementById('audio-log'),
|
||||||
|
result: document.getElementById('recording-result'),
|
||||||
|
preview: document.getElementById('recording-preview'),
|
||||||
|
download: document.getElementById('download-recording'),
|
||||||
|
sharedVideo: document.getElementById('shared-video-preview'),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!navigator.mediaDevices || !window.MediaRecorder) {
|
||||||
|
log('This browser does not support the required MediaDevices/MediaRecorder APIs. Try current Chrome or Edge over HTTPS.');
|
||||||
|
[els.connectMic, els.connectSystem, els.start].forEach((button) => {
|
||||||
|
if (button) button.disabled = true;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
els.connectMic?.addEventListener('click', connectMicrophone);
|
||||||
|
els.connectSystem?.addEventListener('click', connectSystemAudio);
|
||||||
|
els.start?.addEventListener('click', startRecording);
|
||||||
|
els.stop?.addEventListener('click', stopRecording);
|
||||||
|
els.reset?.addEventListener('click', resetAudio);
|
||||||
|
window.addEventListener('beforeunload', cleanupTracks);
|
||||||
|
|
||||||
|
async function ensureAudioGraph() {
|
||||||
|
if (state.audioContext) return;
|
||||||
|
|
||||||
|
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
|
||||||
|
state.audioContext = new AudioContextClass();
|
||||||
|
state.destination = state.audioContext.createMediaStreamDestination();
|
||||||
|
state.mixedStream = state.destination.stream;
|
||||||
|
state.analyzers.mix = createAnalyser();
|
||||||
|
|
||||||
|
const mixSource = state.audioContext.createMediaStreamSource(state.mixedStream);
|
||||||
|
mixSource.connect(state.analyzers.mix);
|
||||||
|
updateStatus(els.mixStatus, 'Waiting', 'muted');
|
||||||
|
startMeters();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connectMicrophone() {
|
||||||
|
try {
|
||||||
|
await ensureAudioGraph();
|
||||||
|
await state.audioContext.resume();
|
||||||
|
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
audio: {
|
||||||
|
echoCancellation: true,
|
||||||
|
noiseSuppression: true,
|
||||||
|
autoGainControl: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
replaceStream('micStream', stream);
|
||||||
|
connectStream(stream, 'mic');
|
||||||
|
updateStatus(els.micStatus, 'Connected', 'active');
|
||||||
|
log('Microphone connected.');
|
||||||
|
refreshButtons();
|
||||||
|
} catch (error) {
|
||||||
|
handleCaptureError('microphone', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connectSystemAudio() {
|
||||||
|
try {
|
||||||
|
await ensureAudioGraph();
|
||||||
|
await state.audioContext.resume();
|
||||||
|
|
||||||
|
const stream = await navigator.mediaDevices.getDisplayMedia({
|
||||||
|
video: true,
|
||||||
|
audio: {
|
||||||
|
echoCancellation: false,
|
||||||
|
noiseSuppression: false,
|
||||||
|
autoGainControl: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!stream.getAudioTracks().length) {
|
||||||
|
stopStream(stream);
|
||||||
|
throw new Error('No audio track was shared. Reopen sharing and enable tab/system audio.');
|
||||||
|
}
|
||||||
|
|
||||||
|
replaceStream('systemStream', stream);
|
||||||
|
connectStream(stream, 'system');
|
||||||
|
els.sharedVideo.srcObject = stream;
|
||||||
|
els.sharedVideo.play().catch(() => {});
|
||||||
|
updateStatus(els.systemStatus, 'Connected', 'active');
|
||||||
|
log('Computer/tab audio connected. Keep the shared source open while recording.');
|
||||||
|
|
||||||
|
stream.getTracks().forEach((track) => {
|
||||||
|
track.addEventListener('ended', () => {
|
||||||
|
if (state.systemStream === stream) {
|
||||||
|
updateStatus(els.systemStatus, 'Stopped', 'muted');
|
||||||
|
log('Shared computer/tab audio stopped by the browser.');
|
||||||
|
state.systemStream = null;
|
||||||
|
refreshButtons();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
refreshButtons();
|
||||||
|
} catch (error) {
|
||||||
|
handleCaptureError('computer/tab audio', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectStream(stream, sourceName) {
|
||||||
|
const audioTracks = stream.getAudioTracks();
|
||||||
|
if (!audioTracks.length) return;
|
||||||
|
|
||||||
|
const audioOnlyStream = new MediaStream(audioTracks);
|
||||||
|
const source = state.audioContext.createMediaStreamSource(audioOnlyStream);
|
||||||
|
const analyser = createAnalyser();
|
||||||
|
state.analyzers[sourceName] = analyser;
|
||||||
|
source.connect(analyser);
|
||||||
|
source.connect(state.destination);
|
||||||
|
updateStatus(els.mixStatus, 'Mix ready', 'active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAnalyser() {
|
||||||
|
const analyser = state.audioContext.createAnalyser();
|
||||||
|
analyser.fftSize = 256;
|
||||||
|
analyser.smoothingTimeConstant = 0.78;
|
||||||
|
return analyser;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startMeters() {
|
||||||
|
if (state.animationFrame) return;
|
||||||
|
const data = new Uint8Array(128);
|
||||||
|
|
||||||
|
const draw = () => {
|
||||||
|
drawMeter(state.analyzers.mic, els.micMeter, data);
|
||||||
|
drawMeter(state.analyzers.system, els.systemMeter, data);
|
||||||
|
drawMeter(state.analyzers.mix, els.mixMeter, data);
|
||||||
|
state.animationFrame = requestAnimationFrame(draw);
|
||||||
|
};
|
||||||
|
|
||||||
|
draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawMeter(analyser, element, data) {
|
||||||
|
if (!element) return;
|
||||||
|
if (!analyser) {
|
||||||
|
element.style.width = '0%';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
analyser.getByteTimeDomainData(data);
|
||||||
|
let sum = 0;
|
||||||
|
for (const value of data) {
|
||||||
|
const centered = value - 128;
|
||||||
|
sum += centered * centered;
|
||||||
|
}
|
||||||
|
const rms = Math.sqrt(sum / data.length);
|
||||||
|
const width = Math.min(100, Math.max(3, rms * 4.6));
|
||||||
|
element.style.width = `${width}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startRecording() {
|
||||||
|
if (!state.mixedStream || !state.mixedStream.getAudioTracks().length) {
|
||||||
|
log('Connect at least one audio source before recording.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanupRecordingUrl();
|
||||||
|
state.chunks = [];
|
||||||
|
const options = pickRecorderOptions();
|
||||||
|
state.recorder = new MediaRecorder(state.mixedStream, options);
|
||||||
|
|
||||||
|
state.recorder.addEventListener('dataavailable', (event) => {
|
||||||
|
if (event.data && event.data.size > 0) state.chunks.push(event.data);
|
||||||
|
});
|
||||||
|
|
||||||
|
state.recorder.addEventListener('stop', () => {
|
||||||
|
const mimeType = state.recorder?.mimeType || 'audio/webm';
|
||||||
|
const blob = new Blob(state.chunks, { type: mimeType });
|
||||||
|
state.downloadUrl = URL.createObjectURL(blob);
|
||||||
|
els.preview.src = state.downloadUrl;
|
||||||
|
els.download.href = state.downloadUrl;
|
||||||
|
els.download.download = `reliefsignal-audio-test-${new Date().toISOString().replace(/[:.]/g, '-')}.webm`;
|
||||||
|
els.result.hidden = false;
|
||||||
|
log(`Recording ready (${formatBytes(blob.size)}).`);
|
||||||
|
refreshButtons();
|
||||||
|
});
|
||||||
|
|
||||||
|
state.recorder.start(1000);
|
||||||
|
updateStatus(els.mixStatus, 'Recording', 'recording');
|
||||||
|
log('Local recording started.');
|
||||||
|
refreshButtons();
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopRecording() {
|
||||||
|
if (state.recorder && state.recorder.state !== 'inactive') {
|
||||||
|
state.recorder.stop();
|
||||||
|
updateStatus(els.mixStatus, 'Mix ready', hasAnySource() ? 'active' : 'muted');
|
||||||
|
log('Recording stopped.');
|
||||||
|
refreshButtons();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetAudio() {
|
||||||
|
if (state.recorder && state.recorder.state !== 'inactive') {
|
||||||
|
state.recorder.stop();
|
||||||
|
}
|
||||||
|
cleanupTracks();
|
||||||
|
cleanupRecordingUrl();
|
||||||
|
state.micStream = null;
|
||||||
|
state.systemStream = null;
|
||||||
|
state.mixedStream = null;
|
||||||
|
state.destination = null;
|
||||||
|
state.analyzers = {};
|
||||||
|
state.audioContext?.close().catch(() => {});
|
||||||
|
state.audioContext = null;
|
||||||
|
state.recorder = null;
|
||||||
|
state.chunks = [];
|
||||||
|
if (state.animationFrame) cancelAnimationFrame(state.animationFrame);
|
||||||
|
state.animationFrame = null;
|
||||||
|
els.preview.removeAttribute('src');
|
||||||
|
els.download.removeAttribute('href');
|
||||||
|
els.result.hidden = true;
|
||||||
|
els.sharedVideo.srcObject = null;
|
||||||
|
updateStatus(els.micStatus, 'Not connected', 'muted');
|
||||||
|
updateStatus(els.systemStatus, 'Not connected', 'muted');
|
||||||
|
updateStatus(els.mixStatus, 'Waiting', 'muted');
|
||||||
|
[els.micMeter, els.systemMeter, els.mixMeter].forEach((meter) => {
|
||||||
|
if (meter) meter.style.width = '0%';
|
||||||
|
});
|
||||||
|
log('Audio capture reset.');
|
||||||
|
refreshButtons();
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshButtons() {
|
||||||
|
const recording = state.recorder && state.recorder.state === 'recording';
|
||||||
|
const hasSource = hasAnySource();
|
||||||
|
els.start.disabled = !hasSource || recording;
|
||||||
|
els.stop.disabled = !recording;
|
||||||
|
els.reset.disabled = !hasSource && !state.downloadUrl;
|
||||||
|
els.connectMic.disabled = Boolean(state.micStream) || recording;
|
||||||
|
els.connectSystem.disabled = Boolean(state.systemStream) || recording;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasAnySource() {
|
||||||
|
return Boolean(
|
||||||
|
state.micStream?.getAudioTracks().some((track) => track.readyState === 'live') ||
|
||||||
|
state.systemStream?.getAudioTracks().some((track) => track.readyState === 'live')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickRecorderOptions() {
|
||||||
|
const candidates = [
|
||||||
|
'audio/webm;codecs=opus',
|
||||||
|
'audio/webm',
|
||||||
|
'audio/mp4',
|
||||||
|
];
|
||||||
|
const mimeType = candidates.find((type) => MediaRecorder.isTypeSupported(type));
|
||||||
|
return mimeType ? { mimeType } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceStream(key, stream) {
|
||||||
|
if (state[key]) stopStream(state[key]);
|
||||||
|
state[key] = stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopStream(stream) {
|
||||||
|
stream?.getTracks().forEach((track) => track.stop());
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupTracks() {
|
||||||
|
stopStream(state.micStream);
|
||||||
|
stopStream(state.systemStream);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupRecordingUrl() {
|
||||||
|
if (state.downloadUrl) {
|
||||||
|
URL.revokeObjectURL(state.downloadUrl);
|
||||||
|
state.downloadUrl = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStatus(element, label, mode) {
|
||||||
|
if (!element) return;
|
||||||
|
element.textContent = label;
|
||||||
|
element.classList.remove('active', 'muted', 'recording');
|
||||||
|
element.classList.add(mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
function log(message) {
|
||||||
|
if (!els.log) return;
|
||||||
|
const item = document.createElement('li');
|
||||||
|
item.textContent = `${new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })} — ${message}`;
|
||||||
|
els.log.prepend(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCaptureError(label, error) {
|
||||||
|
const message = error?.name === 'NotAllowedError'
|
||||||
|
? `Permission was not granted for ${label}.`
|
||||||
|
: error?.message || `Could not connect ${label}.`;
|
||||||
|
log(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes) {
|
||||||
|
if (!bytes) return '0 B';
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
|
||||||
|
return `${(bytes / Math.pow(1024, index)).toFixed(index ? 1 : 0)} ${units[index]}`;
|
||||||
|
}
|
||||||
|
})();
|
||||||
@ -1,21 +1,431 @@
|
|||||||
|
/* ReliefSignal custom brand layer — intentionally overrides default Bootstrap appearance. */
|
||||||
:root {
|
:root {
|
||||||
--bg-color-start: #6a11cb;
|
--rs-ink: #10221f;
|
||||||
--bg-color-end: #2575fc;
|
--rs-muted: #5f716d;
|
||||||
--text-color: #ffffff;
|
--rs-primary: #0f766e;
|
||||||
--card-bg-color: rgba(255, 255, 255, 0.01);
|
--rs-secondary: #f97316;
|
||||||
--card-border-color: rgba(255, 255, 255, 0.1);
|
--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 {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: 'Inter', sans-serif;
|
font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
|
background: linear-gradient(180deg, #fffdf7 0%, #f5fbf7 54%, #fff8ed 100%);
|
||||||
color: var(--text-color);
|
color: var(--rs-ink);
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
min-height: 100vh;
|
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; }
|
||||||
|
|
||||||
|
|
||||||
|
.audio-hero {
|
||||||
|
background: radial-gradient(circle at 82% 18%, rgba(20,184,166,.22), transparent 28%), linear-gradient(135deg, #fff8ed 0%, #ecfff9 100%);
|
||||||
|
}
|
||||||
|
.audio-guardrail {
|
||||||
|
border-radius: 1.4rem;
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
.audio-guardrail strong, .audio-tip strong { display: block; color: var(--rs-ink); margin-bottom: .35rem; }
|
||||||
|
.audio-guardrail p, .audio-tip p { color: var(--rs-muted); margin-bottom: 0; line-height: 1.6; }
|
||||||
|
.audio-console { border-radius: 1.8rem; padding: 1.45rem; }
|
||||||
|
.audio-status-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.audio-source-card {
|
||||||
|
border: 1px solid var(--rs-line);
|
||||||
|
border-radius: 1.35rem;
|
||||||
|
background: #fffdf8;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
.audio-source-card h3 {
|
||||||
|
margin: .85rem 0 .35rem;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: -.025em;
|
||||||
|
color: var(--rs-ink);
|
||||||
|
}
|
||||||
|
.audio-source-card p { min-height: 3.2rem; color: var(--rs-muted); margin-bottom: .85rem; }
|
||||||
|
.status-pill.muted { color: var(--rs-muted); background: #f3f4f1; }
|
||||||
|
.status-pill.active { color: #07564f; background: var(--rs-mint); }
|
||||||
|
.status-pill.recording { color: #8a3d00; background: #fff0d8; }
|
||||||
|
.audio-meter {
|
||||||
|
height: .78rem;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(16,34,31,.08);
|
||||||
|
}
|
||||||
|
.audio-meter span {
|
||||||
|
display: block;
|
||||||
|
width: 0%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: linear-gradient(90deg, var(--rs-primary), var(--rs-accent), var(--rs-secondary));
|
||||||
|
transition: width .08s linear;
|
||||||
|
}
|
||||||
|
.audio-actions { display: flex; flex-wrap: wrap; gap: .75rem; }
|
||||||
|
.btn-outline-signal {
|
||||||
|
color: var(--rs-primary);
|
||||||
|
border: 1px solid rgba(15,118,110,.28);
|
||||||
|
background: rgba(255,255,255,.74);
|
||||||
|
font-weight: 900;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: .8rem 1.05rem;
|
||||||
|
}
|
||||||
|
.btn-outline-signal:hover { color: #fff; background: var(--rs-primary); border-color: var(--rs-primary); }
|
||||||
|
.btn-outline-signal.danger { color: #9a3412; border-color: rgba(249,115,22,.35); }
|
||||||
|
.btn-outline-signal.danger:hover { color: #fff; background: var(--rs-secondary); border-color: var(--rs-secondary); }
|
||||||
|
.reset-btn { border-radius: 999px; padding: .8rem 1.05rem; font-weight: 900; }
|
||||||
|
.recording-result {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
border-radius: 1.35rem;
|
||||||
|
border: 1px solid rgba(20,184,166,.22);
|
||||||
|
background: #ecfff9;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
.recording-result audio { width: 100%; }
|
||||||
|
.audio-note-list strong { max-width: 12rem; }
|
||||||
|
.audio-tip {
|
||||||
|
border-radius: 1.1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: #fffdf8;
|
||||||
|
border: 1px solid var(--rs-line);
|
||||||
|
}
|
||||||
|
.audio-tip.subtle { background: #f7fbf7; }
|
||||||
|
.audio-log {
|
||||||
|
list-style-position: inside;
|
||||||
|
display: grid;
|
||||||
|
gap: .7rem;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
color: var(--rs-muted);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.audio-log li {
|
||||||
|
border-bottom: 1px solid rgba(16,34,31,.08);
|
||||||
|
padding-bottom: .7rem;
|
||||||
|
}
|
||||||
|
.audio-log li:last-child { border-bottom: 0; padding-bottom: 0; }
|
||||||
|
|
||||||
|
@media (max-width: 991px) {
|
||||||
|
.audio-status-grid { grid-template-columns: 1fr; }
|
||||||
|
.audio-source-card p { min-height: auto; }
|
||||||
}
|
}
|
||||||
|
|||||||
330
staticfiles/js/audio-capture.js
Normal file
330
staticfiles/js/audio-capture.js
Normal file
@ -0,0 +1,330 @@
|
|||||||
|
(() => {
|
||||||
|
const state = {
|
||||||
|
audioContext: null,
|
||||||
|
destination: null,
|
||||||
|
micStream: null,
|
||||||
|
systemStream: null,
|
||||||
|
mixedStream: null,
|
||||||
|
recorder: null,
|
||||||
|
chunks: [],
|
||||||
|
animationFrame: null,
|
||||||
|
analyzers: {},
|
||||||
|
downloadUrl: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const els = {
|
||||||
|
connectMic: document.getElementById('connect-mic'),
|
||||||
|
connectSystem: document.getElementById('connect-system'),
|
||||||
|
start: document.getElementById('start-recording'),
|
||||||
|
stop: document.getElementById('stop-recording'),
|
||||||
|
reset: document.getElementById('reset-audio'),
|
||||||
|
micStatus: document.getElementById('mic-status'),
|
||||||
|
systemStatus: document.getElementById('system-status'),
|
||||||
|
mixStatus: document.getElementById('mix-status'),
|
||||||
|
micMeter: document.getElementById('mic-meter'),
|
||||||
|
systemMeter: document.getElementById('system-meter'),
|
||||||
|
mixMeter: document.getElementById('mix-meter'),
|
||||||
|
log: document.getElementById('audio-log'),
|
||||||
|
result: document.getElementById('recording-result'),
|
||||||
|
preview: document.getElementById('recording-preview'),
|
||||||
|
download: document.getElementById('download-recording'),
|
||||||
|
sharedVideo: document.getElementById('shared-video-preview'),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!navigator.mediaDevices || !window.MediaRecorder) {
|
||||||
|
log('This browser does not support the required MediaDevices/MediaRecorder APIs. Try current Chrome or Edge over HTTPS.');
|
||||||
|
[els.connectMic, els.connectSystem, els.start].forEach((button) => {
|
||||||
|
if (button) button.disabled = true;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
els.connectMic?.addEventListener('click', connectMicrophone);
|
||||||
|
els.connectSystem?.addEventListener('click', connectSystemAudio);
|
||||||
|
els.start?.addEventListener('click', startRecording);
|
||||||
|
els.stop?.addEventListener('click', stopRecording);
|
||||||
|
els.reset?.addEventListener('click', resetAudio);
|
||||||
|
window.addEventListener('beforeunload', cleanupTracks);
|
||||||
|
|
||||||
|
async function ensureAudioGraph() {
|
||||||
|
if (state.audioContext) return;
|
||||||
|
|
||||||
|
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
|
||||||
|
state.audioContext = new AudioContextClass();
|
||||||
|
state.destination = state.audioContext.createMediaStreamDestination();
|
||||||
|
state.mixedStream = state.destination.stream;
|
||||||
|
state.analyzers.mix = createAnalyser();
|
||||||
|
|
||||||
|
const mixSource = state.audioContext.createMediaStreamSource(state.mixedStream);
|
||||||
|
mixSource.connect(state.analyzers.mix);
|
||||||
|
updateStatus(els.mixStatus, 'Waiting', 'muted');
|
||||||
|
startMeters();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connectMicrophone() {
|
||||||
|
try {
|
||||||
|
await ensureAudioGraph();
|
||||||
|
await state.audioContext.resume();
|
||||||
|
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
audio: {
|
||||||
|
echoCancellation: true,
|
||||||
|
noiseSuppression: true,
|
||||||
|
autoGainControl: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
replaceStream('micStream', stream);
|
||||||
|
connectStream(stream, 'mic');
|
||||||
|
updateStatus(els.micStatus, 'Connected', 'active');
|
||||||
|
log('Microphone connected.');
|
||||||
|
refreshButtons();
|
||||||
|
} catch (error) {
|
||||||
|
handleCaptureError('microphone', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connectSystemAudio() {
|
||||||
|
try {
|
||||||
|
await ensureAudioGraph();
|
||||||
|
await state.audioContext.resume();
|
||||||
|
|
||||||
|
const stream = await navigator.mediaDevices.getDisplayMedia({
|
||||||
|
video: true,
|
||||||
|
audio: {
|
||||||
|
echoCancellation: false,
|
||||||
|
noiseSuppression: false,
|
||||||
|
autoGainControl: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!stream.getAudioTracks().length) {
|
||||||
|
stopStream(stream);
|
||||||
|
throw new Error('No audio track was shared. Reopen sharing and enable tab/system audio.');
|
||||||
|
}
|
||||||
|
|
||||||
|
replaceStream('systemStream', stream);
|
||||||
|
connectStream(stream, 'system');
|
||||||
|
els.sharedVideo.srcObject = stream;
|
||||||
|
els.sharedVideo.play().catch(() => {});
|
||||||
|
updateStatus(els.systemStatus, 'Connected', 'active');
|
||||||
|
log('Computer/tab audio connected. Keep the shared source open while recording.');
|
||||||
|
|
||||||
|
stream.getTracks().forEach((track) => {
|
||||||
|
track.addEventListener('ended', () => {
|
||||||
|
if (state.systemStream === stream) {
|
||||||
|
updateStatus(els.systemStatus, 'Stopped', 'muted');
|
||||||
|
log('Shared computer/tab audio stopped by the browser.');
|
||||||
|
state.systemStream = null;
|
||||||
|
refreshButtons();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
refreshButtons();
|
||||||
|
} catch (error) {
|
||||||
|
handleCaptureError('computer/tab audio', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectStream(stream, sourceName) {
|
||||||
|
const audioTracks = stream.getAudioTracks();
|
||||||
|
if (!audioTracks.length) return;
|
||||||
|
|
||||||
|
const audioOnlyStream = new MediaStream(audioTracks);
|
||||||
|
const source = state.audioContext.createMediaStreamSource(audioOnlyStream);
|
||||||
|
const analyser = createAnalyser();
|
||||||
|
state.analyzers[sourceName] = analyser;
|
||||||
|
source.connect(analyser);
|
||||||
|
source.connect(state.destination);
|
||||||
|
updateStatus(els.mixStatus, 'Mix ready', 'active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAnalyser() {
|
||||||
|
const analyser = state.audioContext.createAnalyser();
|
||||||
|
analyser.fftSize = 256;
|
||||||
|
analyser.smoothingTimeConstant = 0.78;
|
||||||
|
return analyser;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startMeters() {
|
||||||
|
if (state.animationFrame) return;
|
||||||
|
const data = new Uint8Array(128);
|
||||||
|
|
||||||
|
const draw = () => {
|
||||||
|
drawMeter(state.analyzers.mic, els.micMeter, data);
|
||||||
|
drawMeter(state.analyzers.system, els.systemMeter, data);
|
||||||
|
drawMeter(state.analyzers.mix, els.mixMeter, data);
|
||||||
|
state.animationFrame = requestAnimationFrame(draw);
|
||||||
|
};
|
||||||
|
|
||||||
|
draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawMeter(analyser, element, data) {
|
||||||
|
if (!element) return;
|
||||||
|
if (!analyser) {
|
||||||
|
element.style.width = '0%';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
analyser.getByteTimeDomainData(data);
|
||||||
|
let sum = 0;
|
||||||
|
for (const value of data) {
|
||||||
|
const centered = value - 128;
|
||||||
|
sum += centered * centered;
|
||||||
|
}
|
||||||
|
const rms = Math.sqrt(sum / data.length);
|
||||||
|
const width = Math.min(100, Math.max(3, rms * 4.6));
|
||||||
|
element.style.width = `${width}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startRecording() {
|
||||||
|
if (!state.mixedStream || !state.mixedStream.getAudioTracks().length) {
|
||||||
|
log('Connect at least one audio source before recording.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanupRecordingUrl();
|
||||||
|
state.chunks = [];
|
||||||
|
const options = pickRecorderOptions();
|
||||||
|
state.recorder = new MediaRecorder(state.mixedStream, options);
|
||||||
|
|
||||||
|
state.recorder.addEventListener('dataavailable', (event) => {
|
||||||
|
if (event.data && event.data.size > 0) state.chunks.push(event.data);
|
||||||
|
});
|
||||||
|
|
||||||
|
state.recorder.addEventListener('stop', () => {
|
||||||
|
const mimeType = state.recorder?.mimeType || 'audio/webm';
|
||||||
|
const blob = new Blob(state.chunks, { type: mimeType });
|
||||||
|
state.downloadUrl = URL.createObjectURL(blob);
|
||||||
|
els.preview.src = state.downloadUrl;
|
||||||
|
els.download.href = state.downloadUrl;
|
||||||
|
els.download.download = `reliefsignal-audio-test-${new Date().toISOString().replace(/[:.]/g, '-')}.webm`;
|
||||||
|
els.result.hidden = false;
|
||||||
|
log(`Recording ready (${formatBytes(blob.size)}).`);
|
||||||
|
refreshButtons();
|
||||||
|
});
|
||||||
|
|
||||||
|
state.recorder.start(1000);
|
||||||
|
updateStatus(els.mixStatus, 'Recording', 'recording');
|
||||||
|
log('Local recording started.');
|
||||||
|
refreshButtons();
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopRecording() {
|
||||||
|
if (state.recorder && state.recorder.state !== 'inactive') {
|
||||||
|
state.recorder.stop();
|
||||||
|
updateStatus(els.mixStatus, 'Mix ready', hasAnySource() ? 'active' : 'muted');
|
||||||
|
log('Recording stopped.');
|
||||||
|
refreshButtons();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetAudio() {
|
||||||
|
if (state.recorder && state.recorder.state !== 'inactive') {
|
||||||
|
state.recorder.stop();
|
||||||
|
}
|
||||||
|
cleanupTracks();
|
||||||
|
cleanupRecordingUrl();
|
||||||
|
state.micStream = null;
|
||||||
|
state.systemStream = null;
|
||||||
|
state.mixedStream = null;
|
||||||
|
state.destination = null;
|
||||||
|
state.analyzers = {};
|
||||||
|
state.audioContext?.close().catch(() => {});
|
||||||
|
state.audioContext = null;
|
||||||
|
state.recorder = null;
|
||||||
|
state.chunks = [];
|
||||||
|
if (state.animationFrame) cancelAnimationFrame(state.animationFrame);
|
||||||
|
state.animationFrame = null;
|
||||||
|
els.preview.removeAttribute('src');
|
||||||
|
els.download.removeAttribute('href');
|
||||||
|
els.result.hidden = true;
|
||||||
|
els.sharedVideo.srcObject = null;
|
||||||
|
updateStatus(els.micStatus, 'Not connected', 'muted');
|
||||||
|
updateStatus(els.systemStatus, 'Not connected', 'muted');
|
||||||
|
updateStatus(els.mixStatus, 'Waiting', 'muted');
|
||||||
|
[els.micMeter, els.systemMeter, els.mixMeter].forEach((meter) => {
|
||||||
|
if (meter) meter.style.width = '0%';
|
||||||
|
});
|
||||||
|
log('Audio capture reset.');
|
||||||
|
refreshButtons();
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshButtons() {
|
||||||
|
const recording = state.recorder && state.recorder.state === 'recording';
|
||||||
|
const hasSource = hasAnySource();
|
||||||
|
els.start.disabled = !hasSource || recording;
|
||||||
|
els.stop.disabled = !recording;
|
||||||
|
els.reset.disabled = !hasSource && !state.downloadUrl;
|
||||||
|
els.connectMic.disabled = Boolean(state.micStream) || recording;
|
||||||
|
els.connectSystem.disabled = Boolean(state.systemStream) || recording;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasAnySource() {
|
||||||
|
return Boolean(
|
||||||
|
state.micStream?.getAudioTracks().some((track) => track.readyState === 'live') ||
|
||||||
|
state.systemStream?.getAudioTracks().some((track) => track.readyState === 'live')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickRecorderOptions() {
|
||||||
|
const candidates = [
|
||||||
|
'audio/webm;codecs=opus',
|
||||||
|
'audio/webm',
|
||||||
|
'audio/mp4',
|
||||||
|
];
|
||||||
|
const mimeType = candidates.find((type) => MediaRecorder.isTypeSupported(type));
|
||||||
|
return mimeType ? { mimeType } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceStream(key, stream) {
|
||||||
|
if (state[key]) stopStream(state[key]);
|
||||||
|
state[key] = stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopStream(stream) {
|
||||||
|
stream?.getTracks().forEach((track) => track.stop());
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupTracks() {
|
||||||
|
stopStream(state.micStream);
|
||||||
|
stopStream(state.systemStream);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupRecordingUrl() {
|
||||||
|
if (state.downloadUrl) {
|
||||||
|
URL.revokeObjectURL(state.downloadUrl);
|
||||||
|
state.downloadUrl = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStatus(element, label, mode) {
|
||||||
|
if (!element) return;
|
||||||
|
element.textContent = label;
|
||||||
|
element.classList.remove('active', 'muted', 'recording');
|
||||||
|
element.classList.add(mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
function log(message) {
|
||||||
|
if (!els.log) return;
|
||||||
|
const item = document.createElement('li');
|
||||||
|
item.textContent = `${new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })} — ${message}`;
|
||||||
|
els.log.prepend(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCaptureError(label, error) {
|
||||||
|
const message = error?.name === 'NotAllowedError'
|
||||||
|
? `Permission was not granted for ${label}.`
|
||||||
|
: error?.message || `Could not connect ${label}.`;
|
||||||
|
log(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes) {
|
||||||
|
if (!bytes) return '0 B';
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
|
||||||
|
return `${(bytes / Math.pow(1024, index)).toFixed(index ? 1 : 0)} ${units[index]}`;
|
||||||
|
}
|
||||||
|
})();
|
||||||
Loading…
x
Reference in New Issue
Block a user