Compare commits

...

2 Commits

Author SHA1 Message Date
Flatlogic Bot
5809ee0af7 Auto commit: 2026-04-11T02:09:51.860Z 2026-04-11 02:09:51 +00:00
Flatlogic Bot
159e91248c 1 2026-04-11 01:49:55 +00:00
55 changed files with 5274 additions and 294 deletions

View File

@ -2,12 +2,6 @@
Django settings for config project. Django settings for config project.
Generated by 'django-admin startproject' using Django 5.2.7. Generated by 'django-admin startproject' using Django 5.2.7.
For more information on this file, see
https://docs.djangoproject.com/en/5.2/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/5.2/ref/settings/
""" """
from pathlib import Path from pathlib import Path
@ -15,38 +9,32 @@ import os
from dotenv import load_dotenv from dotenv import load_dotenv
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
load_dotenv(BASE_DIR.parent / ".env") load_dotenv(BASE_DIR.parent / '.env')
SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "change-me") SECRET_KEY = os.getenv('DJANGO_SECRET_KEY', 'change-me')
DEBUG = os.getenv("DJANGO_DEBUG", "true").lower() == "true" DEBUG = os.getenv('DJANGO_DEBUG', 'true').lower() == 'true'
ALLOWED_HOSTS = [ ALLOWED_HOSTS = [
"127.0.0.1", '127.0.0.1',
"localhost", 'localhost',
os.getenv("HOST_FQDN", ""), os.getenv('HOST_FQDN', ''),
] ]
CSRF_TRUSTED_ORIGINS = [ CSRF_TRUSTED_ORIGINS = [
origin for origin in [ origin for origin in [
os.getenv("HOST_FQDN", ""), os.getenv('HOST_FQDN', ''),
os.getenv("CSRF_TRUSTED_ORIGIN", "") os.getenv('CSRF_TRUSTED_ORIGIN', ''),
] if origin ] if origin
] ]
CSRF_TRUSTED_ORIGINS = [ CSRF_TRUSTED_ORIGINS = [
f"https://{host}" if not host.startswith(("http://", "https://")) else host f'https://{host}' if not host.startswith(('http://', 'https://')) else host
for host in CSRF_TRUSTED_ORIGINS for host in CSRF_TRUSTED_ORIGINS
] ]
# Cookies must always be HTTPS-only; SameSite=Lax keeps CSRF working behind the proxy.
SESSION_COOKIE_SECURE = True SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True CSRF_COOKIE_SECURE = True
SESSION_COOKIE_SAMESITE = "None" SESSION_COOKIE_SAMESITE = 'None'
CSRF_COOKIE_SAMESITE = "None" CSRF_COOKIE_SAMESITE = 'None'
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
# Application definition
INSTALLED_APPS = [ INSTALLED_APPS = [
'django.contrib.admin', 'django.contrib.admin',
@ -65,8 +53,6 @@ MIDDLEWARE = [
'django.middleware.csrf.CsrfViewMiddleware', 'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware', 'django.contrib.messages.middleware.MessageMiddleware',
# Disable X-Frame-Options middleware to allow Flatlogic preview iframes.
# 'django.middleware.clickjacking.XFrameOptionsMiddleware',
] ]
X_FRAME_OPTIONS = 'ALLOWALL' X_FRAME_OPTIONS = 'ALLOWALL'
@ -83,7 +69,6 @@ TEMPLATES = [
'django.template.context_processors.request', 'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth', 'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages', 'django.contrib.messages.context_processors.messages',
# IMPORTANT: do not remove injects PROJECT_DESCRIPTION/PROJECT_IMAGE_URL and cache-busting timestamp
'core.context_processors.project_context', 'core.context_processors.project_context',
], ],
}, },
@ -92,10 +77,6 @@ TEMPLATES = [
WSGI_APPLICATION = 'config.wsgi.application' WSGI_APPLICATION = 'config.wsgi.application'
# Database
# https://docs.djangoproject.com/en/5.2/ref/settings/#databases
DATABASES = { DATABASES = {
'default': { 'default': {
'ENGINE': 'django.db.backends.mysql', 'ENGINE': 'django.db.backends.mysql',
@ -110,73 +91,48 @@ DATABASES = {
}, },
} }
# Password validation
# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [ AUTH_PASSWORD_VALIDATORS = [
{ {'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'},
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', {'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'},
}, {'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'},
{ {'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'},
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
] ]
# Internationalization
# https://docs.djangoproject.com/en/5.2/topics/i18n/
LANGUAGE_CODE = 'en-us' LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC' TIME_ZONE = 'UTC'
USE_I18N = True USE_I18N = True
USE_TZ = True USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/5.2/howto/static-files/
STATIC_URL = 'static/' STATIC_URL = 'static/'
# Collect static into a separate folder; avoid overlapping with STATICFILES_DIRS.
STATIC_ROOT = BASE_DIR / 'staticfiles' STATIC_ROOT = BASE_DIR / 'staticfiles'
STATICFILES_DIRS = [ STATICFILES_DIRS = [
BASE_DIR / 'static', BASE_DIR / 'static',
BASE_DIR / 'assets', BASE_DIR / 'assets',
BASE_DIR / 'node_modules', BASE_DIR / 'node_modules',
] ]
MEDIA_URL = 'media/'
MEDIA_ROOT = BASE_DIR / 'media'
# Email EMAIL_BACKEND = os.getenv('EMAIL_BACKEND', 'django.core.mail.backends.smtp.EmailBackend')
EMAIL_BACKEND = os.getenv( EMAIL_HOST = os.getenv('EMAIL_HOST', '127.0.0.1')
"EMAIL_BACKEND", EMAIL_PORT = int(os.getenv('EMAIL_PORT', '587'))
"django.core.mail.backends.smtp.EmailBackend" EMAIL_HOST_USER = os.getenv('EMAIL_HOST_USER', '')
) EMAIL_HOST_PASSWORD = os.getenv('EMAIL_HOST_PASSWORD', '')
EMAIL_HOST = os.getenv("EMAIL_HOST", "127.0.0.1") EMAIL_USE_TLS = os.getenv('EMAIL_USE_TLS', 'true').lower() == 'true'
EMAIL_PORT = int(os.getenv("EMAIL_PORT", "587")) EMAIL_USE_SSL = os.getenv('EMAIL_USE_SSL', 'false').lower() == 'true'
EMAIL_HOST_USER = os.getenv("EMAIL_HOST_USER", "") DEFAULT_FROM_EMAIL = os.getenv('DEFAULT_FROM_EMAIL', 'no-reply@example.com')
EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD", "")
EMAIL_USE_TLS = os.getenv("EMAIL_USE_TLS", "true").lower() == "true"
EMAIL_USE_SSL = os.getenv("EMAIL_USE_SSL", "false").lower() == "true"
DEFAULT_FROM_EMAIL = os.getenv("DEFAULT_FROM_EMAIL", "no-reply@example.com")
CONTACT_EMAIL_TO = [ CONTACT_EMAIL_TO = [
item.strip() item.strip()
for item in os.getenv("CONTACT_EMAIL_TO", DEFAULT_FROM_EMAIL).split(",") for item in os.getenv('CONTACT_EMAIL_TO', DEFAULT_FROM_EMAIL).split(',')
if item.strip() if item.strip()
] ]
# When both TLS and SSL flags are enabled, prefer SSL explicitly
if EMAIL_USE_SSL: if EMAIL_USE_SSL:
EMAIL_USE_TLS = False EMAIL_USE_TLS = False
# Default primary key field type
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field LOGIN_URL = 'login'
LOGIN_REDIRECT_URL = 'dashboard'
LOGOUT_REDIRECT_URL = 'home'
PASSWORD_RESET_TIMEOUT = 60 * 60 * 24
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'

View File

@ -1,29 +1,17 @@
""" """
URL configuration for config project. URL configuration for config project.
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/5.2/topics/http/urls/
Examples:
Function views
1. Add an import: from my_app import views
2. Add a URL to urlpatterns: path('', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
""" """
from django.contrib import admin
from django.urls import include, path
from django.conf import settings from django.conf import settings
from django.conf.urls.static import static from django.conf.urls.static import static
from django.contrib import admin
from django.urls import include, path
urlpatterns = [ urlpatterns = [
path("admin/", admin.site.urls), path('admin/', admin.site.urls),
path("", include("core.urls")), path('', include('core.urls')),
] ]
if settings.DEBUG: if settings.DEBUG:
urlpatterns += static("/assets/", document_root=settings.BASE_DIR / "assets") urlpatterns += static('/assets/', document_root=settings.BASE_DIR / 'assets')
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

Binary file not shown.

Binary file not shown.

View File

@ -1,3 +1,59 @@
from django.contrib import admin from django.contrib import admin
# Register your models here. from .models import Business, BusinessMembership, Customer, Feedback, Job, JobMedia, ProofCard, ReviewRequest
class JobMediaInline(admin.TabularInline):
model = JobMedia
extra = 0
@admin.register(Business)
class BusinessAdmin(admin.ModelAdmin):
list_display = ('name', 'industry', 'primary_city', 'primary_state', 'is_active')
list_filter = ('industry', 'is_active')
search_fields = ('name', 'slug', 'primary_city')
prepopulated_fields = {'slug': ('name',)}
@admin.register(BusinessMembership)
class BusinessMembershipAdmin(admin.ModelAdmin):
list_display = ('user', 'business', 'role', 'created_at')
list_filter = ('role', 'business')
search_fields = ('user__email', 'user__first_name', 'user__last_name', 'business__name')
@admin.register(Customer)
class CustomerAdmin(admin.ModelAdmin):
list_display = ('full_name', 'business', 'city', 'state', 'email', 'phone')
list_filter = ('business', 'state')
search_fields = ('full_name', 'email', 'phone')
@admin.register(Job)
class JobAdmin(admin.ModelAdmin):
list_display = ('service_type', 'business', 'customer', 'city', 'state', 'completed_at', 'status')
list_filter = ('business', 'status', 'state', 'completed_at')
search_fields = ('service_type', 'customer__full_name', 'technician_name')
inlines = [JobMediaInline]
@admin.register(ReviewRequest)
class ReviewRequestAdmin(admin.ModelAdmin):
list_display = ('job', 'channel', 'status', 'sent_at', 'reviewed_at')
list_filter = ('channel', 'status')
search_fields = ('job__customer__full_name', 'job__service_type')
@admin.register(Feedback)
class FeedbackAdmin(admin.ModelAdmin):
list_display = ('review_request', 'experience', 'rating', 'follow_up_required', 'is_public_approved', 'created_at')
list_filter = ('experience', 'follow_up_required', 'is_public_approved')
search_fields = ('review_request__job__customer__full_name', 'testimonial')
@admin.register(ProofCard)
class ProofCardAdmin(admin.ModelAdmin):
list_display = ('job', 'customer_display_name', 'status', 'is_featured', 'rating', 'published_at')
list_filter = ('status', 'is_featured')
search_fields = ('customer_display_name', 'job__service_type', 'testimonial_quote')

View File

@ -1,13 +1,33 @@
import os import os
import time import time
from .models import BusinessMembership
ACTIVE_BUSINESS_SESSION_KEY = 'trustforge_active_business_id'
def project_context(request): def project_context(request):
""" """
Adds project-specific environment variables to the template context globally. Adds project-specific environment variables and active workspace context globally.
""" """
current_membership = None
memberships = []
if getattr(request, 'user', None) and request.user.is_authenticated:
memberships = list(
BusinessMembership.objects.select_related('business').filter(
user=request.user,
business__is_active=True,
)
)
active_business_id = request.session.get(ACTIVE_BUSINESS_SESSION_KEY)
current_membership = next((item for item in memberships if item.business_id == active_business_id), None)
if current_membership is None and memberships:
current_membership = memberships[0]
return { return {
"project_description": os.getenv("PROJECT_DESCRIPTION", ""), 'project_description': os.getenv('PROJECT_DESCRIPTION', ''),
"project_image_url": os.getenv("PROJECT_IMAGE_URL", ""), 'project_image_url': os.getenv('PROJECT_IMAGE_URL', ''),
# Used for cache-busting static assets 'deployment_timestamp': int(time.time()),
"deployment_timestamp": int(time.time()), 'current_membership': current_membership,
'user_memberships': memberships,
} }

372
core/forms.py Normal file
View File

@ -0,0 +1,372 @@
from __future__ import annotations
import logging
from django import forms
from django.contrib.auth import authenticate, get_user_model
from django.contrib.auth.forms import AuthenticationForm, PasswordResetForm, SetPasswordForm, UserCreationForm
from django.utils import timezone
from .models import Business, BusinessMembership, Feedback, ProofCard, ReviewRequest
logger = logging.getLogger(__name__)
User = get_user_model()
class TrustForgeAuthenticationForm(AuthenticationForm):
username = forms.CharField(
label='Work email',
widget=forms.EmailInput(
attrs={
'class': 'form-control form-control-lg',
'placeholder': 'you@company.com',
'autocomplete': 'email',
}
),
)
password = forms.CharField(
label='Password',
strip=False,
widget=forms.PasswordInput(
attrs={
'class': 'form-control form-control-lg',
'placeholder': 'Enter your password',
'autocomplete': 'current-password',
}
),
)
def clean(self):
email = (self.cleaned_data.get('username') or '').strip().lower()
password = self.cleaned_data.get('password')
if email and password:
matched_user = User._default_manager.filter(email__iexact=email).first()
auth_username = matched_user.get_username() if matched_user else email
self.user_cache = authenticate(self.request, username=auth_username, password=password)
if self.user_cache is None:
raise self.get_invalid_login_error()
self.confirm_login_allowed(self.user_cache)
return self.cleaned_data
class SignUpForm(UserCreationForm):
first_name = forms.CharField(
required=False,
max_length=150,
widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Avery'}),
)
last_name = forms.CharField(
required=False,
max_length=150,
widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Stone'}),
)
email = forms.EmailField(
widget=forms.EmailInput(
attrs={
'class': 'form-control form-control-lg',
'placeholder': 'owner@servicebrand.com',
'autocomplete': 'email',
}
)
)
password1 = forms.CharField(
label='Password',
strip=False,
widget=forms.PasswordInput(
attrs={
'class': 'form-control form-control-lg',
'placeholder': 'Create a password',
'autocomplete': 'new-password',
}
),
)
password2 = forms.CharField(
label='Confirm password',
strip=False,
widget=forms.PasswordInput(
attrs={
'class': 'form-control form-control-lg',
'placeholder': 'Repeat your password',
'autocomplete': 'new-password',
}
),
)
class Meta(UserCreationForm.Meta):
model = User
fields = ('first_name', 'last_name', 'email', 'password1', 'password2')
def clean_email(self):
email = (self.cleaned_data.get('email') or '').strip().lower()
if User._default_manager.filter(email__iexact=email).exists() or User._default_manager.filter(username__iexact=email).exists():
raise forms.ValidationError('An account with this email already exists.')
return email
def save(self, commit=True):
user = super().save(commit=False)
email = self.cleaned_data['email']
user.username = email
user.email = email
user.first_name = self.cleaned_data.get('first_name', '').strip()
user.last_name = self.cleaned_data.get('last_name', '').strip()
if commit:
user.save()
return user
class ProfileSettingsForm(forms.ModelForm):
first_name = forms.CharField(
required=False,
max_length=150,
widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Avery'}),
)
last_name = forms.CharField(
required=False,
max_length=150,
widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Stone'}),
)
email = forms.EmailField(
widget=forms.EmailInput(
attrs={
'class': 'form-control form-control-lg',
'placeholder': 'you@company.com',
'autocomplete': 'email',
}
)
)
class Meta:
model = User
fields = ('first_name', 'last_name', 'email')
def clean_email(self):
email = (self.cleaned_data.get('email') or '').strip().lower()
email_exists = User._default_manager.filter(email__iexact=email).exclude(pk=self.instance.pk).exists()
username_exists = User._default_manager.filter(username__iexact=email).exclude(pk=self.instance.pk).exists()
if email_exists or username_exists:
raise forms.ValidationError('Another account already uses this email.')
return email
def save(self, commit=True):
user = super().save(commit=False)
email = self.cleaned_data['email']
user.email = email
user.username = email
user.first_name = self.cleaned_data.get('first_name', '').strip()
user.last_name = self.cleaned_data.get('last_name', '').strip()
if commit:
user.save()
return user
class TrustForgePasswordResetForm(PasswordResetForm):
email = forms.EmailField(
widget=forms.EmailInput(
attrs={
'class': 'form-control form-control-lg',
'placeholder': 'you@company.com',
'autocomplete': 'email',
}
)
)
def send_mail(self, *args, **kwargs):
try:
return super().send_mail(*args, **kwargs)
except Exception:
logger.exception('Password reset email failed to send.')
class TrustForgeSetPasswordForm(SetPasswordForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['new_password1'].widget.attrs.update(
{
'class': 'form-control form-control-lg',
'placeholder': 'Create a new password',
'autocomplete': 'new-password',
}
)
self.fields['new_password2'].widget.attrs.update(
{
'class': 'form-control form-control-lg',
'placeholder': 'Confirm the new password',
'autocomplete': 'new-password',
}
)
class BusinessOnboardingForm(forms.ModelForm):
class Meta:
model = Business
fields = ('name', 'industry', 'primary_city', 'primary_state', 'google_review_url')
widgets = {
'name': forms.TextInput(attrs={'class': 'form-control form-control-lg', 'placeholder': 'Summit Home Services'}),
'industry': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'HVAC, Roofing, Plumbing, Junk Removal…'}),
'primary_city': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Austin'}),
'primary_state': forms.TextInput(attrs={'class': 'form-control text-uppercase', 'placeholder': 'TX'}),
'google_review_url': forms.URLInput(attrs={'class': 'form-control', 'placeholder': 'https://g.page/r/.../review'}),
}
def clean_primary_state(self):
return (self.cleaned_data.get('primary_state') or '').upper()
class BusinessSettingsForm(BusinessOnboardingForm):
pass
class TeamMemberInviteForm(forms.Form):
first_name = forms.CharField(
required=False,
max_length=150,
widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Jamie'}),
)
last_name = forms.CharField(
required=False,
max_length=150,
widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Rivera'}),
)
email = forms.EmailField(
widget=forms.EmailInput(attrs={'class': 'form-control', 'placeholder': 'tech@servicebrand.com'})
)
role = forms.ChoiceField(
choices=BusinessMembership.Role.choices,
initial=BusinessMembership.Role.TECHNICIAN,
widget=forms.Select(attrs={'class': 'form-select'}),
)
def clean_email(self):
return (self.cleaned_data.get('email') or '').strip().lower()
class JobIntakeForm(forms.Form):
business = forms.ModelChoiceField(
queryset=Business.objects.filter(is_active=True),
empty_label=None,
widget=forms.Select(attrs={'class': 'form-select form-control-lg'}),
)
customer_name = forms.CharField(
max_length=160,
widget=forms.TextInput(attrs={'class': 'form-control form-control-lg', 'placeholder': 'Homeowner or business contact'}),
)
customer_email = forms.EmailField(
required=False,
widget=forms.EmailInput(attrs={'class': 'form-control', 'placeholder': 'customer@example.com'}),
)
customer_phone = forms.CharField(
required=False,
max_length=40,
widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': '(555) 555-0123'}),
)
service_type = forms.CharField(
max_length=120,
widget=forms.TextInput(attrs={'class': 'form-control form-control-lg', 'placeholder': 'Roof replacement, HVAC tune-up, junk removal…'}),
)
description = forms.CharField(
required=False,
widget=forms.Textarea(attrs={'class': 'form-control', 'rows': 3, 'placeholder': 'What did the crew complete on-site?'}),
)
customer_city = forms.CharField(
max_length=120,
widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Austin'}),
)
customer_state = forms.CharField(
max_length=2,
widget=forms.TextInput(attrs={'class': 'form-control text-uppercase', 'placeholder': 'TX'}),
)
technician_name = forms.CharField(
required=False,
max_length=120,
widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Luis R.'}),
)
completion_date = forms.DateField(
initial=timezone.localdate,
widget=forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}),
)
project_value = forms.DecimalField(
required=False,
min_value=0,
max_digits=10,
decimal_places=2,
widget=forms.NumberInput(attrs={'class': 'form-control', 'placeholder': '4500'}),
)
before_photo = forms.FileField(
required=False,
widget=forms.ClearableFileInput(attrs={'class': 'form-control', 'accept': 'image/*'}),
)
after_photo = forms.FileField(
required=False,
widget=forms.ClearableFileInput(attrs={'class': 'form-control', 'accept': 'image/*'}),
)
anonymize_customer = forms.BooleanField(
required=False,
initial=True,
widget=forms.CheckboxInput(attrs={'class': 'form-check-input'}),
)
send_review_request = forms.BooleanField(
required=False,
initial=True,
widget=forms.CheckboxInput(attrs={'class': 'form-check-input'}),
)
review_channel = forms.ChoiceField(
choices=ReviewRequest.Channel.choices,
initial=ReviewRequest.Channel.EMAIL,
widget=forms.Select(attrs={'class': 'form-select'}),
)
def __init__(self, *args, business: Business | None = None, **kwargs):
super().__init__(*args, **kwargs)
if business is not None:
self.fields['business'].queryset = Business.objects.filter(pk=business.pk)
self.fields['business'].initial = business
self.fields['business'].widget = forms.HiddenInput()
def clean_customer_state(self):
return self.cleaned_data['customer_state'].upper()
class PublicFeedbackForm(forms.Form):
experience = forms.ChoiceField(
choices=Feedback.Experience.choices,
widget=forms.RadioSelect,
)
testimonial = forms.CharField(
required=False,
widget=forms.Textarea(attrs={'class': 'form-control', 'rows': 4, 'placeholder': 'Tell us what stood out about the service…'}),
)
def clean(self):
cleaned_data = super().clean()
experience = cleaned_data.get('experience')
testimonial = (cleaned_data.get('testimonial') or '').strip()
if experience in {Feedback.Experience.GREAT, Feedback.Experience.GOOD} and len(testimonial) < 12:
self.add_error('testimonial', 'For positive feedback, add a short testimonial so it can power the proof card.')
return cleaned_data
class ProofCardForm(forms.ModelForm):
class Meta:
model = ProofCard
fields = [
'customer_display_name',
'is_anonymized',
'testimonial_quote',
'rating',
'status',
'is_featured',
'attached_widget_label',
'attached_pages',
]
widgets = {
'customer_display_name': forms.TextInput(attrs={'class': 'form-control'}),
'is_anonymized': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'testimonial_quote': forms.Textarea(attrs={'class': 'form-control', 'rows': 4}),
'rating': forms.NumberInput(attrs={'class': 'form-control', 'min': 1, 'max': 5}),
'status': forms.Select(attrs={'class': 'form-select'}),
'is_featured': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'attached_widget_label': forms.TextInput(attrs={'class': 'form-control'}),
'attached_pages': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
}

View File

@ -0,0 +1,143 @@
# Generated by Django 5.2.7 on 2026-04-11 01:16
import core.models
import django.db.models.deletion
import django.utils.timezone
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Business',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=160)),
('slug', models.SlugField(unique=True)),
('industry', models.CharField(default='Home Services', max_length=120)),
('primary_city', models.CharField(max_length=120)),
('primary_state', models.CharField(max_length=2)),
('google_review_url', models.URLField(blank=True)),
('is_active', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'verbose_name_plural': 'businesses',
'ordering': ['name'],
},
),
migrations.CreateModel(
name='Customer',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('full_name', models.CharField(max_length=160)),
('email', models.EmailField(blank=True, max_length=254)),
('phone', models.CharField(blank=True, max_length=40)),
('city', models.CharField(max_length=120)),
('state', models.CharField(max_length=2)),
('created_at', models.DateTimeField(auto_now_add=True)),
('business', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='customers', to='core.business')),
],
options={
'ordering': ['full_name'],
},
),
migrations.CreateModel(
name='Job',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('service_type', models.CharField(max_length=120)),
('description', models.TextField(blank=True)),
('technician_name', models.CharField(blank=True, max_length=120)),
('city', models.CharField(max_length=120)),
('state', models.CharField(max_length=2)),
('completed_at', models.DateField(default=django.utils.timezone.localdate)),
('project_value', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)),
('status', models.CharField(choices=[('completed', 'Completed'), ('review_requested', 'Review requested'), ('proof_ready', 'Proof ready')], default='completed', max_length=32)),
('is_verified', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('business', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='jobs', to='core.business')),
('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='jobs', to='core.customer')),
],
options={
'ordering': ['-completed_at', '-created_at'],
},
),
migrations.CreateModel(
name='JobMedia',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('media_type', models.CharField(choices=[('before', 'Before'), ('after', 'After')], max_length=16)),
('file', models.FileField(blank=True, upload_to=core.models.job_media_upload_path)),
('caption', models.CharField(blank=True, max_length=140)),
('display_order', models.PositiveSmallIntegerField(default=0)),
('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='media', to='core.job')),
],
options={
'verbose_name_plural': 'job media',
'ordering': ['display_order', 'id'],
},
),
migrations.CreateModel(
name='ProofCard',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('customer_display_name', models.CharField(max_length=160)),
('is_anonymized', models.BooleanField(default=False)),
('testimonial_quote', models.TextField(blank=True)),
('rating', models.PositiveSmallIntegerField(blank=True, null=True)),
('status', models.CharField(choices=[('draft', 'Draft'), ('published', 'Published'), ('hidden', 'Hidden')], default='draft', max_length=16)),
('is_featured', models.BooleanField(default=False)),
('attached_widget_label', models.CharField(blank=True, max_length=120)),
('attached_pages', models.CharField(blank=True, max_length=200)),
('published_at', models.DateTimeField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('job', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='proof_card', to='core.job')),
],
options={
'ordering': ['-is_featured', '-updated_at'],
},
),
migrations.CreateModel(
name='ReviewRequest',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('token', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
('status', models.CharField(choices=[('sent', 'Sent'), ('viewed', 'Viewed'), ('responded', 'Responded')], default='sent', max_length=20)),
('channel', models.CharField(choices=[('email', 'Email'), ('sms', 'SMS'), ('manual', 'Manual share')], default='email', max_length=16)),
('sent_at', models.DateTimeField(default=django.utils.timezone.now)),
('last_opened_at', models.DateTimeField(blank=True, null=True)),
('reviewed_at', models.DateTimeField(blank=True, null=True)),
('delivery_note', models.CharField(blank=True, max_length=200)),
('job', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='review_request', to='core.job')),
],
options={
'ordering': ['-sent_at'],
},
),
migrations.CreateModel(
name='Feedback',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('experience', models.CharField(choices=[('great', 'Great'), ('good', 'Good'), ('okay', 'Okay'), ('bad', 'Bad')], max_length=12)),
('rating', models.PositiveSmallIntegerField(blank=True, null=True)),
('testimonial', models.TextField(blank=True)),
('follow_up_required', models.BooleanField(default=False)),
('is_public_approved', models.BooleanField(default=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('review_request', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='feedback', to='core.reviewrequest')),
],
options={
'ordering': ['-created_at'],
},
),
]

View File

@ -0,0 +1,98 @@
from django.db import migrations
from django.utils import timezone
def seed_trustforge_demo(apps, schema_editor):
Business = apps.get_model('core', 'Business')
Customer = apps.get_model('core', 'Customer')
Job = apps.get_model('core', 'Job')
ProofCard = apps.get_model('core', 'ProofCard')
ReviewRequest = apps.get_model('core', 'ReviewRequest')
Feedback = apps.get_model('core', 'Feedback')
business, _ = Business.objects.get_or_create(
slug='summit-home-services',
defaults={
'name': 'Summit Home Services',
'industry': 'Roofing & Exterior',
'primary_city': 'Austin',
'primary_state': 'TX',
'google_review_url': 'https://example.com/google-review',
'is_active': True,
},
)
customer, _ = Customer.objects.get_or_create(
business=business,
full_name='Avery Johnson',
defaults={
'email': 'avery@example.com',
'phone': '(512) 555-0148',
'city': 'Austin',
'state': 'TX',
},
)
job, _ = Job.objects.get_or_create(
business=business,
customer=customer,
service_type='Roof replacement',
defaults={
'description': 'Replaced weather-damaged shingles, sealed flashing, and completed a same-day cleanup.',
'technician_name': 'Luis Ramirez',
'city': 'Austin',
'state': 'TX',
'completed_at': timezone.localdate(),
'project_value': '14250.00',
'status': 'proof_ready',
'is_verified': True,
},
)
proof_card, _ = ProofCard.objects.get_or_create(
job=job,
defaults={
'customer_display_name': 'Verified homeowner',
'is_anonymized': True,
'testimonial_quote': 'The crew communicated clearly, protected the landscaping, and finished the roof beautifully in one day.',
'rating': 5,
'status': 'published',
'is_featured': True,
'attached_widget_label': 'Homepage proof gallery',
'attached_pages': 'Homepage, Roofing service page',
'published_at': timezone.now(),
},
)
review_request, _ = ReviewRequest.objects.get_or_create(
job=job,
defaults={
'status': 'responded',
'channel': 'email',
'sent_at': timezone.now(),
'reviewed_at': timezone.now(),
'delivery_note': 'Seeded demo review request',
},
)
Feedback.objects.get_or_create(
review_request=review_request,
defaults={
'experience': 'great',
'rating': 5,
'testimonial': proof_card.testimonial_quote,
'follow_up_required': False,
'is_public_approved': True,
},
)
def remove_trustforge_demo(apps, schema_editor):
Business = apps.get_model('core', 'Business')
Business.objects.filter(slug='summit-home-services').delete()
class Migration(migrations.Migration):
dependencies = [
('core', '0001_initial'),
]
operations = [
migrations.RunPython(seed_trustforge_demo, remove_trustforge_demo),
]

View File

@ -0,0 +1,31 @@
# Generated by Django 5.2.7 on 2026-04-11 01:43
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0002_seed_trustforge_demo'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='BusinessMembership',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('role', models.CharField(choices=[('owner', 'Owner'), ('admin', 'Admin'), ('manager', 'Manager'), ('technician', 'Technician')], default='owner', max_length=24)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('business', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='memberships', to='core.business')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='business_memberships', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['business__name', 'user__email'],
'unique_together': {('business', 'user')},
},
),
]

View File

@ -1,3 +1,221 @@
from django.db import models from __future__ import annotations
# Create your models here. import uuid
from django.conf import settings
from django.db import models
from django.utils import timezone
class Business(models.Model):
name = models.CharField(max_length=160)
slug = models.SlugField(unique=True)
industry = models.CharField(max_length=120, default='Home Services')
primary_city = models.CharField(max_length=120)
primary_state = models.CharField(max_length=2)
google_review_url = models.URLField(blank=True)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['name']
verbose_name_plural = 'businesses'
def __str__(self) -> str:
return self.name
@property
def initials(self) -> str:
words = [chunk for chunk in self.name.split() if chunk]
if not words:
return 'TF'
return ''.join(word[0] for word in words[:2]).upper()
class BusinessMembership(models.Model):
class Role(models.TextChoices):
OWNER = 'owner', 'Owner'
ADMIN = 'admin', 'Admin'
MANAGER = 'manager', 'Manager'
TECHNICIAN = 'technician', 'Technician'
business = models.ForeignKey(Business, on_delete=models.CASCADE, related_name='memberships')
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='business_memberships')
role = models.CharField(max_length=24, choices=Role.choices, default=Role.OWNER)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['business__name', 'user__email']
unique_together = ('business', 'user')
def __str__(self) -> str:
identity = self.user.get_full_name() or self.user.email or self.user.username
return f'{identity} · {self.business.name} ({self.get_role_display()})'
@property
def can_manage_workspace(self) -> bool:
return self.role in {self.Role.OWNER, self.Role.ADMIN}
@property
def can_manage_proof(self) -> bool:
return self.role in {self.Role.OWNER, self.Role.ADMIN, self.Role.MANAGER}
class Customer(models.Model):
business = models.ForeignKey(Business, on_delete=models.CASCADE, related_name='customers')
full_name = models.CharField(max_length=160)
email = models.EmailField(blank=True)
phone = models.CharField(max_length=40, blank=True)
city = models.CharField(max_length=120)
state = models.CharField(max_length=2)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['full_name']
def __str__(self) -> str:
return f'{self.full_name} · {self.city}, {self.state}'
class Job(models.Model):
class Status(models.TextChoices):
COMPLETED = 'completed', 'Completed'
REVIEW_REQUESTED = 'review_requested', 'Review requested'
PROOF_READY = 'proof_ready', 'Proof ready'
business = models.ForeignKey(Business, on_delete=models.CASCADE, related_name='jobs')
customer = models.ForeignKey(Customer, on_delete=models.CASCADE, related_name='jobs')
service_type = models.CharField(max_length=120)
description = models.TextField(blank=True)
technician_name = models.CharField(max_length=120, blank=True)
city = models.CharField(max_length=120)
state = models.CharField(max_length=2)
completed_at = models.DateField(default=timezone.localdate)
project_value = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True)
status = models.CharField(max_length=32, choices=Status.choices, default=Status.COMPLETED)
is_verified = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['-completed_at', '-created_at']
def __str__(self) -> str:
return f'{self.service_type} for {self.customer.full_name}'
def _get_media_by_type(self, media_type: str):
prefetched_media = getattr(self, '_prefetched_objects_cache', {}).get('media')
if prefetched_media is not None:
return next((media for media in prefetched_media if media.media_type == media_type), None)
return self.media.filter(media_type=media_type).first()
@property
def before_media(self):
return self._get_media_by_type(JobMedia.MediaType.BEFORE)
@property
def after_media(self):
return self._get_media_by_type(JobMedia.MediaType.AFTER)
def job_media_upload_path(instance: 'JobMedia', filename: str) -> str:
return f'jobs/job_{instance.job_id}/{instance.media_type}_{filename}'
class JobMedia(models.Model):
class MediaType(models.TextChoices):
BEFORE = 'before', 'Before'
AFTER = 'after', 'After'
job = models.ForeignKey(Job, on_delete=models.CASCADE, related_name='media')
media_type = models.CharField(max_length=16, choices=MediaType.choices)
file = models.FileField(upload_to=job_media_upload_path, blank=True)
caption = models.CharField(max_length=140, blank=True)
display_order = models.PositiveSmallIntegerField(default=0)
class Meta:
ordering = ['display_order', 'id']
verbose_name_plural = 'job media'
def __str__(self) -> str:
return f'{self.job} · {self.get_media_type_display()}'
class ReviewRequest(models.Model):
class Status(models.TextChoices):
SENT = 'sent', 'Sent'
VIEWED = 'viewed', 'Viewed'
RESPONDED = 'responded', 'Responded'
class Channel(models.TextChoices):
EMAIL = 'email', 'Email'
SMS = 'sms', 'SMS'
MANUAL = 'manual', 'Manual share'
job = models.OneToOneField(Job, on_delete=models.CASCADE, related_name='review_request')
token = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
status = models.CharField(max_length=20, choices=Status.choices, default=Status.SENT)
channel = models.CharField(max_length=16, choices=Channel.choices, default=Channel.EMAIL)
sent_at = models.DateTimeField(default=timezone.now)
last_opened_at = models.DateTimeField(null=True, blank=True)
reviewed_at = models.DateTimeField(null=True, blank=True)
delivery_note = models.CharField(max_length=200, blank=True)
class Meta:
ordering = ['-sent_at']
def __str__(self) -> str:
return f'Review request for {self.job}'
class Feedback(models.Model):
class Experience(models.TextChoices):
GREAT = 'great', 'Great'
GOOD = 'good', 'Good'
OKAY = 'okay', 'Okay'
BAD = 'bad', 'Bad'
review_request = models.OneToOneField(ReviewRequest, on_delete=models.CASCADE, related_name='feedback')
experience = models.CharField(max_length=12, choices=Experience.choices)
rating = models.PositiveSmallIntegerField(null=True, blank=True)
testimonial = models.TextField(blank=True)
follow_up_required = models.BooleanField(default=False)
is_public_approved = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['-created_at']
def __str__(self) -> str:
return f'{self.get_experience_display()} feedback for {self.review_request.job}'
class ProofCard(models.Model):
class Status(models.TextChoices):
DRAFT = 'draft', 'Draft'
PUBLISHED = 'published', 'Published'
HIDDEN = 'hidden', 'Hidden'
job = models.OneToOneField(Job, on_delete=models.CASCADE, related_name='proof_card')
customer_display_name = models.CharField(max_length=160)
is_anonymized = models.BooleanField(default=False)
testimonial_quote = models.TextField(blank=True)
rating = models.PositiveSmallIntegerField(null=True, blank=True)
status = models.CharField(max_length=16, choices=Status.choices, default=Status.DRAFT)
is_featured = models.BooleanField(default=False)
attached_widget_label = models.CharField(max_length=120, blank=True)
attached_pages = models.CharField(max_length=200, blank=True)
published_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['-is_featured', '-updated_at']
def __str__(self) -> str:
return f'Proof card · {self.job.service_type} · {self.customer_display_name}'
@property
def verified_label(self) -> str:
return 'Verified job' if self.job.is_verified else 'Pending verification'

View File

@ -1,25 +1,133 @@
<!DOCTYPE html> {% load static %}
<!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 %}TrustForge{% endblock %}</title>
<meta name="description" content="{{ project_description }}"> <meta name="description" content="{% block meta_description %}{{ project_description|default:'TrustForge turns completed jobs into visual proof, testimonials, and conversion assets for service businesses.' }}{% endblock %}">
<meta property="og:description" content="{{ project_description }}"> <meta name="author" content="TrustForge">
<meta property="twitter:description" content="{{ project_description }}"> <meta name="keywords" content="proof cards, service business reviews, trust marketing, contractor testimonials, local service SaaS">
{% endif %} <link rel="preconnect" href="https://fonts.googleapis.com">
{% if project_image_url %} <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<meta property="og:image" content="{{ project_image_url }}"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Space+Grotesk:wght@500;700&display=swap" rel="stylesheet">
<meta property="twitter:image" content="{{ project_image_url }}"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
{% endif %}
{% load static %}
<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 class="trustforge-body">
<div class="tf-background-glow tf-background-glow-1"></div>
<div class="tf-background-glow tf-background-glow-2"></div>
<body> <header class="tf-site-header sticky-top">
<nav class="navbar navbar-expand-lg tf-navbar">
<div class="container py-2">
<a class="navbar-brand tf-brand" href="{% url 'home' %}">
<span class="tf-brand-mark">TF</span>
<span>TrustForge</span>
</a>
<button class="navbar-toggler tf-nav-toggle" type="button" data-bs-toggle="collapse" data-bs-target="#tfNav" aria-controls="tfNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="tfNav">
<ul class="navbar-nav ms-auto align-items-lg-center gap-lg-2">
{% if request.user.is_authenticated %}
{% if current_membership %}
<li class="nav-item d-none d-lg-flex">
<div class="tf-workspace-chip">
<span class="tf-workspace-chip-mark">{{ current_membership.business.initials }}</span>
<span>
<strong>{{ current_membership.business.name }}</strong>
<small>{{ current_membership.get_role_display }}</small>
</span>
</div>
</li>
<li class="nav-item"><a class="nav-link" href="{% url 'dashboard' %}">Dashboard</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'jobs_list' %}">Jobs</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'proof_cards_list' %}">Proof Cards</a></li>
<li class="nav-item ms-lg-2"><a class="btn tf-btn tf-btn-primary" href="{% url 'job_create' %}">Complete a job</a></li>
{% else %}
<li class="nav-item"><a class="nav-link" href="{% url 'business_onboarding' %}">Create workspace</a></li>
{% endif %}
<li class="nav-item dropdown ms-lg-2">
<button class="btn tf-user-menu dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
<span class="tf-user-menu-label">{{ request.user.first_name|default:request.user.email|truncatechars:18 }}</span>
</button>
<ul class="dropdown-menu dropdown-menu-end tf-user-dropdown">
{% if current_membership %}
<li class="dropdown-item-text tf-dropdown-label">
<strong>{{ current_membership.business.name }}</strong>
<span>{{ current_membership.get_role_display }}</span>
</li>
<li><a class="dropdown-item" href="{% url 'dashboard' %}">Workspace dashboard</a></li>
{% if current_membership.can_manage_workspace %}
<li><a class="dropdown-item" href="{% url 'workspace_settings' %}">Workspace settings</a></li>
{% endif %}
{% else %}
<li><a class="dropdown-item" href="{% url 'business_onboarding' %}">Create workspace</a></li>
{% endif %}
<li><a class="dropdown-item" href="{% url 'profile_settings' %}">Profile &amp; settings</a></li>
<li><a class="dropdown-item" href="/admin/">Admin</a></li>
{% if user_memberships|length > 1 %}
<li><hr class="dropdown-divider"></li>
<li class="dropdown-item-text tf-dropdown-section">Switch workspace</li>
{% for membership in user_memberships %}
<li>
<form method="post" action="{% url 'switch_workspace' membership.business_id %}" class="px-2 pb-2">
{% csrf_token %}
<input type="hidden" name="next" value="{{ request.path }}">
<button type="submit" class="dropdown-item tf-workspace-switch{% if current_membership and membership.business_id == current_membership.business_id %} active{% endif %}">
{{ membership.business.name }}
<small>{{ membership.get_role_display }}</small>
</button>
</form>
</li>
{% endfor %}
{% endif %}
<li><hr class="dropdown-divider"></li>
<li>
<form method="post" action="{% url 'logout' %}" class="px-2 pb-2">
{% csrf_token %}
<button type="submit" class="dropdown-item tf-logout-link">Log out</button>
</form>
</li>
</ul>
</li>
{% else %}
<li class="nav-item"><a class="nav-link" href="{% url 'login' %}">Login</a></li>
<li class="nav-item ms-lg-2"><a class="btn tf-btn tf-btn-primary" href="{% url 'signup' %}">Get Started</a></li>
{% endif %}
</ul>
</div>
</div>
</nav>
</header>
{% if messages %}
<div class="container pt-4">
{% for message in messages %}
<div class="alert tf-alert alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endfor %}
</div>
{% endif %}
<main>
{% block content %}{% endblock %} {% block content %}{% endblock %}
</body> </main>
<footer class="tf-footer py-5">
<div class="container d-flex flex-column flex-lg-row gap-3 justify-content-between align-items-lg-center">
<div>
<div class="tf-footer-brand">TrustForge</div>
<p class="mb-0 text-secondary-emphasis">Proof &gt; reviews for home service businesses.</p>
</div>
<div class="small text-secondary-emphasis">Built for contractors, HVAC, roofing, plumbing, electrical, landscaping, and local service teams.</div>
</div>
</footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
</body>
</html> </html>

View File

@ -0,0 +1,76 @@
{% extends "base.html" %}
{% block title %}Create Your Workspace | TrustForge{% endblock %}
{% block meta_description %}Create your TrustForge business workspace, set your service area, and start the protected proof pipeline.{% endblock %}
{% block content %}
<section class="py-5">
<div class="container">
<div class="row g-4 align-items-start">
<div class="col-lg-5">
<div class="tf-panel sticky-lg-top tf-sticky-panel">
<div class="tf-eyebrow">Business onboarding</div>
<h1 class="tf-page-title">Create your first TrustForge workspace</h1>
<p class="tf-page-subtitle">This workspace becomes the protected home for your completed jobs, review requests, proof cards, and team permissions.</p>
<div class="d-grid gap-3 mt-4">
<div class="tf-check-row"><span>01</span> Create your business workspace</div>
<div class="tf-check-row"><span>02</span> Become the owner automatically</div>
<div class="tf-check-row"><span>03</span> Invite admins, managers, and technicians</div>
<div class="tf-check-row"><span>04</span> Keep every job and proof asset scoped securely</div>
</div>
</div>
</div>
<div class="col-lg-7">
<div class="tf-panel">
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
<div>
<div class="tf-eyebrow">Workspace details</div>
<h2 class="h3 mb-2">Set up the business your team will operate inside</h2>
<p class="text-secondary-emphasis mb-0">You can update branding details later from workspace settings. The trust pipeline and team access will use this business as the default scope.</p>
</div>
<div class="tf-inline-stat">
<span>Protected SaaS</span>
<strong>Multi-tenant ready</strong>
</div>
</div>
<form method="post" class="row g-3">
{% csrf_token %}
<div class="col-12">
<label class="form-label">Business name</label>
{{ form.name }}
{% if form.name.errors %}<div class="text-danger small mt-1">{{ form.name.errors|join:', ' }}</div>{% endif %}
</div>
<div class="col-md-6">
<label class="form-label">Industry</label>
{{ form.industry }}
{% if form.industry.errors %}<div class="text-danger small mt-1">{{ form.industry.errors|join:', ' }}</div>{% endif %}
</div>
<div class="col-md-3">
<label class="form-label">Primary city</label>
{{ form.primary_city }}
{% if form.primary_city.errors %}<div class="text-danger small mt-1">{{ form.primary_city.errors|join:', ' }}</div>{% endif %}
</div>
<div class="col-md-3">
<label class="form-label">State</label>
{{ form.primary_state }}
{% if form.primary_state.errors %}<div class="text-danger small mt-1">{{ form.primary_state.errors|join:', ' }}</div>{% endif %}
</div>
<div class="col-12">
<label class="form-label">Google review URL <span class="text-secondary-emphasis">(optional)</span></label>
{{ form.google_review_url }}
{% if form.google_review_url.errors %}<div class="text-danger small mt-1">{{ form.google_review_url.errors|join:', ' }}</div>{% endif %}
</div>
{% if form.non_field_errors %}
<div class="col-12"><div class="text-danger small">{{ form.non_field_errors|join:', ' }}</div></div>
{% endif %}
<div class="col-12 pt-2 d-flex flex-wrap gap-3 align-items-center">
<button type="submit" class="btn tf-btn tf-btn-primary">Create workspace</button>
<span class="small text-secondary-emphasis">You will be assigned as the owner and land inside your dashboard immediately.</span>
</div>
</form>
</div>
</div>
</div>
</div>
</section>
{% endblock %}

View File

@ -0,0 +1,84 @@
{% extends "base.html" %}
{% block title %}Dashboard | TrustForge{% endblock %}
{% block meta_description %}TrustForge dashboard for completed jobs, review requests, proof cards, and conversion momentum.{% endblock %}
{% block content %}
<section class="py-5">
<div class="container">
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
<div>
<div class="tf-eyebrow">Dashboard</div>
<h1 class="tf-page-title">{{ current_membership.business.name }} proof momentum</h1>
<p class="tf-page-subtitle">This dashboard is scoped to your current workspace only. Track completed jobs, response volume, published proof, and the conversion signal your team is creating this week.</p>
</div>
<div class="d-flex flex-wrap gap-2">
<a class="btn tf-btn tf-btn-secondary" href="{% url 'public_proof_gallery' current_membership.business.slug %}" target="_blank" rel="noopener">View public gallery</a>
{% if current_membership.can_manage_workspace %}<a class="btn tf-btn tf-btn-secondary" href="{% url 'workspace_settings' %}">Workspace settings</a>{% endif %}
<a class="btn tf-btn tf-btn-primary" href="{% url 'job_create' %}">Log a new completed job</a>
</div>
</div>
<div class="row g-4 mb-4">
<div class="col-md-6 col-xl-3"><div class="tf-metric-card"><span>{{ stats.completed_jobs|default:0 }}</span><p>Jobs completed</p></div></div>
<div class="col-md-6 col-xl-3"><div class="tf-metric-card"><span>{{ stats.review_requests|default:0 }}</span><p>Review requests</p></div></div>
<div class="col-md-6 col-xl-3"><div class="tf-metric-card"><span>{{ stats.proof_cards|default:0 }}</span><p>Proof cards created</p></div></div>
<div class="col-md-6 col-xl-3"><div class="tf-metric-card"><span>{{ conversion_rate }}%</span><p>Positive response rate</p></div></div>
</div>
<div class="row g-4">
<div class="col-lg-7">
<div class="tf-panel h-100">
<div class="d-flex justify-content-between align-items-center mb-3">
<h2 class="h4 mb-0">Recent jobs</h2>
<a href="{% url 'jobs_list' %}" class="tf-inline-link">View all</a>
</div>
<div class="d-grid gap-3">
{% for job in recent_jobs %}
<a href="{% url 'job_detail' job.id %}" class="tf-activity-link">
<div class="tf-activity-row tf-activity-row-soft">
<div>
<strong>{{ job.service_type }}</strong>
<div class="small text-secondary-emphasis">{{ job.customer.full_name }} · {{ job.city }}, {{ job.state }}</div>
</div>
<span class="tf-status-pill tf-status-{{ job.status }}">{{ job.get_status_display }}</span>
</div>
</a>
{% empty %}
<div class="tf-empty-state text-start">
<h3 class="h5 mb-2">No jobs yet</h3>
<p class="text-secondary-emphasis mb-0">Use the field intake form to start the first proof workflow for {{ current_membership.business.name }}.</p>
</div>
{% endfor %}
</div>
</div>
</div>
<div class="col-lg-5">
<div class="tf-panel h-100">
<div class="d-flex justify-content-between align-items-center mb-3">
<h2 class="h4 mb-0">Recent proof cards</h2>
<a href="{% url 'proof_cards_list' %}" class="tf-inline-link">Open gallery</a>
</div>
<div class="d-grid gap-3">
{% for proof in recent_proofs %}
<a href="{% url 'proof_card_detail' proof.id %}" class="tf-proof-mini">
<div>
<div class="tf-card-tag mb-2">{{ proof.job.service_type }}</div>
<strong>{{ proof.job.city }}, {{ proof.job.state }}</strong>
<div class="small text-secondary-emphasis">{{ proof.customer_display_name }}</div>
</div>
<span class="tf-status-pill tf-status-{{ proof.status }}">{{ proof.get_status_display }}</span>
</a>
{% empty %}
<div class="tf-empty-state text-start">
<h3 class="h5 mb-2">No proof cards yet</h3>
<p class="text-secondary-emphasis mb-0">Every completed job inside this workspace instantly creates a draft proof card.</p>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
</div>
</section>
{% endblock %}

View File

@ -0,0 +1,14 @@
<div class="tf-proof-media-grid{% if grid_class %} {{ grid_class }}{% endif %}">
<div class="tf-photo-slot{% if job.before_media and job.before_media.file %} tf-photo-slot-has-media{% endif %}">
{% if job.before_media and job.before_media.file %}
<img src="{{ job.before_media.file.url }}" alt="Before photo for {{ job.service_type|lower }} in {{ job.city }}, {{ job.state }}" class="tf-photo-image" width="960" height="720">
{% endif %}
<span class="tf-photo-label">Before</span>
</div>
<div class="tf-photo-slot tf-photo-slot-after{% if job.after_media and job.after_media.file %} tf-photo-slot-has-media{% endif %}">
{% if job.after_media and job.after_media.file %}
<img src="{{ job.after_media.file.url }}" alt="After photo for {{ job.service_type|lower }} in {{ job.city }}, {{ job.state }}" class="tf-photo-image" width="960" height="720">
{% endif %}
<span class="tf-photo-label">After</span>
</div>
</div>

View File

@ -1,145 +1,195 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}{{ project_name }}{% endblock %} {% block title %}TrustForge | Turn completed jobs into proof that wins the next customer{% endblock %}
{% block meta_description %}TrustForge helps service businesses transform completed jobs into proof cards, review requests, and conversion assets that win more booked work.{% 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="tf-hero-section py-5 py-lg-6">
<div class="card"> <div class="container py-lg-5">
<h1>Analyzing your requirements and generating your app…</h1> <div class="row align-items-center g-5">
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes"> <div class="col-lg-6">
<span class="sr-only">Loading…</span> <div class="tf-eyebrow mb-3">Trust engine for service businesses</div>
<h1 class="tf-display mb-4">Every completed job becomes proof that closes the next one.</h1>
<p class="tf-lead mb-4">TrustForge gives contractors, roofers, HVAC teams, plumbers, electricians, junk removal crews, and landscapers a fast field workflow: finish the job, send the review request, generate a premium proof card, and publish conversion-ready assets.</p>
<div class="d-flex flex-wrap gap-3 mb-4">
<a class="btn tf-btn tf-btn-primary btn-lg" href="{% url 'job_create' %}">Start a completed job</a>
<a class="btn tf-btn tf-btn-secondary btn-lg" href="{% url 'dashboard' %}">View dashboard</a>
</div> </div>
<p class="hint">AppWizzy AI is collecting your requirements and applying the first changes.</p> <div class="row g-3 tf-stat-row">
<p class="hint">This page will refresh automatically as the plan is implemented.</p> <div class="col-6 col-md-3">
<p class="runtime"> <div class="tf-stat-chip">
Runtime: Django <code>{{ django_version }}</code> · Python <code>{{ python_version }}</code> <span>{{ stats.completed_jobs|default:0 }}</span>
— UTC <code>{{ current_time|date:"Y-m-d H:i:s" }}</code> Jobs logged
</p>
</div> </div>
</main> </div>
<footer> <div class="col-6 col-md-3">
Page updated: {{ current_time|date:"Y-m-d H:i:s" }} (UTC) <div class="tf-stat-chip">
</footer> <span>{{ stats.review_requests|default:0 }}</span>
Requests sent
</div>
</div>
<div class="col-6 col-md-3">
<div class="tf-stat-chip">
<span>{{ stats.proof_cards|default:0 }}</span>
Proof cards
</div>
</div>
<div class="col-6 col-md-3">
<div class="tf-stat-chip">
<span>{{ business_count }}</span>
Active businesses
</div>
</div>
</div>
</div>
<div class="col-lg-6">
<div class="tf-device-card">
<div class="tf-device-header">
<span></span><span></span><span></span>
</div>
<div class="tf-device-body">
<div class="tf-mini-step active">1. Job completed</div>
<div class="tf-mini-step active">2. Review requested</div>
<div class="tf-mini-step active">3. Proof created</div>
<div class="tf-proof-preview mt-4">
{% if featured_proofs %}
{% with proof=featured_proofs.0 %}
<div class="mb-3">
{% include "core/includes/proof_media_grid.html" with job=proof.job %}
</div>
<div class="d-flex justify-content-between align-items-start gap-3 mb-3">
<div>
<div class="tf-card-tag">{{ proof.job.service_type }}</div>
<h2 class="tf-card-title h4 mt-2 mb-1">{{ proof.job.city }}, {{ proof.job.state }}</h2>
<div class="text-secondary-emphasis small">Verified completion · {{ proof.job.completed_at|date:"M j, Y" }}</div>
</div>
<div class="tf-rating-pill">{% if proof.rating %}★ {{ proof.rating }}.0{% else %}Verified{% endif %}</div>
</div>
<p class="mb-3">{% if proof.testimonial_quote %}“{{ proof.testimonial_quote|truncatechars:120 }}”{% else %}Premium proof cards turn real field work into a conversion asset that belongs on your homepage and service pages.{% endif %}</p>
<div class="d-flex justify-content-between small text-secondary-emphasis">
<span>{{ proof.customer_display_name }}</span>
<span>{{ proof.verified_label }}</span>
</div>
{% endwith %}
{% else %}
<div class="tf-empty-proof">
<div class="tf-proof-media-grid mb-3">
<div class="tf-photo-slot">Before</div>
<div class="tf-photo-slot tf-photo-slot-after">After</div>
</div>
<h2 class="h4 mb-2">Your first proof card appears here</h2>
<p class="mb-0 text-secondary-emphasis">Log a completed job to instantly generate the draft card, review request, and proof pipeline.</p>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<section class="py-5">
<div class="container">
<div class="row g-4">
<div class="col-lg-4">
<div class="tf-panel h-100">
<div class="tf-panel-icon"></div>
<h2 class="h4 mb-3">Fast field workflow</h2>
<p class="mb-0 text-secondary-emphasis">Capture a completed job, upload before/after photos, and send a review request from a mobile-friendly form designed for technicians on-site.</p>
</div>
</div>
<div class="col-lg-4">
<div class="tf-panel h-100">
<div class="tf-panel-icon">🛡️</div>
<h2 class="h4 mb-3">Proof-first cards</h2>
<p class="mb-0 text-secondary-emphasis">Every job creates a premium proof card with service, location, photos, verification status, testimonial, and publishing controls.</p>
</div>
</div>
<div class="col-lg-4">
<div class="tf-panel h-100">
<div class="tf-panel-icon">📈</div>
<h2 class="h4 mb-3">Conversion-ready assets</h2>
<p class="mb-0 text-secondary-emphasis">Feature standout work on your landing page, service pages, and proof gallery to give new customers visible confidence.</p>
</div>
</div>
</div>
</div>
</section>
<section class="pb-5">
<div class="container">
<div class="d-flex justify-content-between align-items-end flex-wrap gap-3 mb-4">
<div>
<div class="tf-eyebrow">Featured proof</div>
<h2 class="tf-section-title">Recent conversion assets</h2>
</div>
{% if request.user.is_authenticated and current_membership %}
<a class="btn tf-btn tf-btn-secondary" href="{% url 'proof_cards_list' %}">Open your proof cards</a>
{% else %}
<a class="btn tf-btn tf-btn-secondary" href="{% url 'signup' %}">Get started free</a>
{% endif %}
</div>
<div class="row g-4">
{% for proof in featured_proofs %}
<div class="col-lg-4">
<a class="tf-proof-card-link" href="{% url 'public_proof_detail' proof.job.business.slug proof.id %}">
<article class="tf-proof-card h-100">
{% include "core/includes/proof_media_grid.html" with job=proof.job %}
<div class="tf-proof-card-body">
<div class="d-flex justify-content-between align-items-center mb-3 gap-3">
<span class="tf-card-tag">{{ proof.job.service_type }}</span>
<span class="tf-badge-verified">{{ proof.verified_label }}</span>
</div>
<h3 class="h5 mb-1">{{ proof.job.city }}, {{ proof.job.state }}</h3>
<p class="small text-secondary-emphasis mb-3">Completed {{ proof.job.completed_at|date:"M j, Y" }}</p>
<p class="mb-3">{% if proof.testimonial_quote %}“{{ proof.testimonial_quote|truncatechars:115 }}”{% else %}A ready-to-publish proof card waiting for review feedback and promotion.{% endif %}</p>
<div class="d-flex justify-content-between small text-secondary-emphasis">
<span>{{ proof.customer_display_name }}</span>
<span>{% if proof.rating %}★ {{ proof.rating }}{% else %}No rating yet{% endif %}</span>
</div>
</div>
</article>
</a>
</div>
{% empty %}
<div class="col-12">
<div class="tf-empty-state text-center">
<h3 class="h4 mb-2">No proof cards featured yet</h3>
<p class="text-secondary-emphasis mb-4">Create your first completed job to populate the TrustForge proof gallery.</p>
<a class="btn tf-btn tf-btn-primary" href="{% url 'job_create' %}">Create the first job</a>
</div>
</div>
{% endfor %}
</div>
</div>
</section>
<section class="pb-5">
<div class="container">
<div class="tf-panel tf-panel-dark">
<div class="row g-4 align-items-center">
<div class="col-lg-7">
<div class="tf-eyebrow tf-eyebrow-light">Pipeline</div>
<h2 class="tf-section-title text-white">Job Completed → Review Requested → Proof Card Created → Displayed → Converts Next Customer</h2>
</div>
<div class="col-lg-5">
<div class="d-grid gap-3">
{% for job in recent_jobs %}
<div class="tf-activity-row">
<div>
<strong>{{ job.service_type }}</strong>
<div class="small text-secondary-emphasis">{{ job.business.name }} · {{ job.city }}, {{ job.state }}</div>
</div>
<span class="tf-status-pill tf-status-{{ job.status }}">{{ job.get_status_display }}</span>
</div>
{% empty %}
<p class="mb-0 text-secondary-emphasis">No jobs yet. The first intake will immediately show up here.</p>
{% endfor %}
</div>
</div>
</div>
</div>
</div>
</section>
{% endblock %} {% endblock %}

View File

@ -0,0 +1,78 @@
{% extends "base.html" %}
{% block title %}{{ job.service_type }} | TrustForge{% endblock %}
{% block meta_description %}TrustForge job detail for {{ job.service_type }} in {{ job.city }}, {{ job.state }}.{% endblock %}
{% block content %}
<section class="py-5">
<div class="container">
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
<div>
<div class="tf-eyebrow">Job detail</div>
<h1 class="tf-page-title">{{ job.service_type }}</h1>
<p class="tf-page-subtitle">{{ job.customer.full_name }} · {{ job.city }}, {{ job.state }} · Completed {{ job.completed_at|date:"F j, Y" }}</p>
</div>
<div class="d-flex flex-wrap gap-2">
<a class="btn tf-btn tf-btn-secondary" href="{% url 'jobs_list' %}">All jobs</a>
<a class="btn tf-btn tf-btn-primary" href="{% url 'proof_card_detail' job.proof_card.id %}">Open proof card</a>
</div>
</div>
<div class="row g-4">
<div class="col-lg-7">
<div class="tf-panel h-100">
<div class="d-flex justify-content-between align-items-center mb-3">
<h2 class="h4 mb-0">Job summary</h2>
<span class="tf-status-pill tf-status-{{ job.status }}">{{ job.get_status_display }}</span>
</div>
<div class="row g-3 mb-4">
<div class="col-md-6"><div class="tf-detail-box"><span>Business</span><strong>{{ job.business.name }}</strong></div></div>
<div class="col-md-6"><div class="tf-detail-box"><span>Technician</span><strong>{{ job.technician_name|default:'Not added yet' }}</strong></div></div>
<div class="col-md-6"><div class="tf-detail-box"><span>Project value</span><strong>{% if job.project_value %}${{ job.project_value }}{% else %}Optional{% endif %}</strong></div></div>
<div class="col-md-6"><div class="tf-detail-box"><span>Verified</span><strong>{% if job.is_verified %}Yes{% else %}Pending{% endif %}</strong></div></div>
</div>
<div class="mb-4">
<h3 class="h6 text-uppercase text-secondary-emphasis mb-2">Description</h3>
<p class="mb-0">{{ job.description|default:'No extra description was added for this completed job.' }}</p>
</div>
<div>
<h3 class="h6 text-uppercase text-secondary-emphasis mb-2">Job photos</h3>
{% include "core/includes/proof_media_grid.html" with job=job %}
</div>
</div>
</div>
<div class="col-lg-5">
<div class="tf-panel mb-4">
<h2 class="h4 mb-3">Review request</h2>
{% if job.review_request %}
<div class="tf-detail-box mb-3"><span>Status</span><strong>{{ job.review_request.get_status_display }}</strong></div>
<div class="tf-detail-box mb-3"><span>Channel</span><strong>{{ job.review_request.get_channel_display }}</strong></div>
<div class="tf-detail-box mb-3"><span>Delivery note</span><strong>{{ job.review_request.delivery_note|default:'Ready to share' }}</strong></div>
<a href="{% url 'review_request' job.review_request.token %}" class="btn tf-btn tf-btn-secondary w-100">Open customer review page</a>
{% else %}
<p class="text-secondary-emphasis">No review request has been sent for this job yet.</p>
<form method="post" class="d-grid gap-3">
{% csrf_token %}
<input type="hidden" name="action" value="send_review_request">
<select name="channel" class="form-select">
<option value="email">Email</option>
<option value="sms">SMS</option>
<option value="manual">Manual share</option>
</select>
<button type="submit" class="btn tf-btn tf-btn-primary">Send review request</button>
</form>
{% endif %}
</div>
<div class="tf-panel">
<h2 class="h4 mb-3">Proof card</h2>
<div class="tf-detail-box mb-3"><span>Status</span><strong>{{ job.proof_card.get_status_display }}</strong></div>
<div class="tf-detail-box mb-3"><span>Display name</span><strong>{{ job.proof_card.customer_display_name }}</strong></div>
<div class="tf-detail-box mb-4"><span>Widget target</span><strong>{{ job.proof_card.attached_widget_label|default:'Not set' }}</strong></div>
<a href="{% url 'proof_card_detail' job.proof_card.id %}" class="btn tf-btn tf-btn-primary w-100">Manage proof card</a>
</div>
</div>
</div>
</div>
</section>
{% endblock %}

View File

@ -0,0 +1,114 @@
{% extends "base.html" %}
{% block title %}Complete a Job | TrustForge{% endblock %}
{% block meta_description %}Log a completed service job in TrustForge, upload before and after photos, and trigger the proof workflow.{% endblock %}
{% block content %}
<section class="py-5">
<div class="container">
<div class="row g-4 align-items-start">
<div class="col-lg-5">
<div class="tf-panel sticky-lg-top tf-sticky-panel">
<div class="tf-eyebrow">Technician flow</div>
<h1 class="tf-page-title">Complete a job in under 30 seconds</h1>
<p class="tf-page-subtitle">You are logging this inside <strong>{{ current_membership.business.name }}</strong>. Add the customer, upload photos, and trigger a review request. TrustForge creates the proof card automatically.</p>
<div class="d-grid gap-3 mt-4">
<div class="tf-check-row"><span>01</span> Save the completed job</div>
<div class="tf-check-row"><span>02</span> Draft proof card is generated</div>
<div class="tf-check-row"><span>03</span> Review request is ready to share</div>
<div class="tf-check-row"><span>04</span> Positive feedback can auto-publish proof</div>
</div>
<div class="tf-detail-box mt-4"><span>Workspace scope</span><strong>{{ current_membership.business.primary_city }}, {{ current_membership.business.primary_state }} · {{ current_membership.get_role_display }}</strong></div>
</div>
</div>
<div class="col-lg-7">
<div class="tf-panel">
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
<div>
<div class="tf-eyebrow">Job intake</div>
<h2 class="h3 mb-2">Capture the completed work</h2>
<p class="text-secondary-emphasis mb-0">Every field below feeds the trust pipeline: completed job → review request → proof card.</p>
</div>
<div class="tf-inline-stat">
<span>Business scope</span>
<strong>{{ current_membership.business.name }}</strong>
</div>
</div>
<form method="post" enctype="multipart/form-data" class="row g-3">
{% csrf_token %}
{{ form.business }}
<div class="col-md-6">
<label class="form-label">Customer name</label>
{{ form.customer_name }}
</div>
<div class="col-md-6">
<label class="form-label">Service type</label>
{{ form.service_type }}
</div>
<div class="col-md-6">
<label class="form-label">Customer email</label>
{{ form.customer_email }}
</div>
<div class="col-md-6">
<label class="form-label">Customer phone</label>
{{ form.customer_phone }}
</div>
<div class="col-md-8">
<label class="form-label">City</label>
{{ form.customer_city }}
</div>
<div class="col-md-4">
<label class="form-label">State</label>
{{ form.customer_state }}
</div>
<div class="col-md-6">
<label class="form-label">Technician</label>
{{ form.technician_name }}
</div>
<div class="col-md-6">
<label class="form-label">Completion date</label>
{{ form.completion_date }}
</div>
<div class="col-md-6">
<label class="form-label">Project value</label>
{{ form.project_value }}
</div>
<div class="col-md-6">
<label class="form-label">Review channel</label>
{{ form.review_channel }}
</div>
<div class="col-12">
<label class="form-label">Job description</label>
{{ form.description }}
</div>
<div class="col-md-6">
<label class="form-label">Before photo</label>
{{ form.before_photo }}
</div>
<div class="col-md-6">
<label class="form-label">After photo</label>
{{ form.after_photo }}
</div>
<div class="col-md-6">
<div class="form-check tf-check-field">
{{ form.anonymize_customer }}
<label class="form-check-label" for="{{ form.anonymize_customer.id_for_label }}">Anonymize customer on proof card</label>
</div>
</div>
<div class="col-md-6">
<div class="form-check tf-check-field">
{{ form.send_review_request }}
<label class="form-check-label" for="{{ form.send_review_request.id_for_label }}">Send review request after save</label>
</div>
</div>
<div class="col-12 pt-2 d-flex flex-wrap gap-3">
<button type="submit" class="btn tf-btn tf-btn-primary">Save completed job</button>
<a class="btn tf-btn tf-btn-secondary" href="{% url 'jobs_list' %}">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
</section>
{% endblock %}

View File

@ -0,0 +1,59 @@
{% extends "base.html" %}
{% block title %}Jobs | TrustForge{% endblock %}
{% block meta_description %}Browse completed jobs in TrustForge and open each proof workflow detail.{% endblock %}
{% block content %}
<section class="py-5">
<div class="container">
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
<div>
<div class="tf-eyebrow">Jobs</div>
<h1 class="tf-page-title">{{ current_membership.business.name }} completed job pipeline</h1>
<p class="tf-page-subtitle">Every job below belongs only to this workspace and can lead to a proof card, published testimonial, and higher conversion confidence.</p>
</div>
<a class="btn tf-btn tf-btn-primary" href="{% url 'job_create' %}">Add completed job</a>
</div>
<div class="tf-panel p-0 overflow-hidden">
{% if jobs %}
<div class="table-responsive">
<table class="table tf-table align-middle mb-0">
<thead>
<tr>
<th>Customer</th>
<th>Service</th>
<th>Location</th>
<th>Completed</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
{% for job in jobs %}
<tr>
<td>
<strong>{{ job.customer.full_name }}</strong>
<div class="small text-secondary-emphasis">{{ job.technician_name|default:'Technician not added yet' }}</div>
</td>
<td>{{ job.service_type }}</td>
<td>{{ job.city }}, {{ job.state }}</td>
<td>{{ job.completed_at|date:"M j, Y" }}</td>
<td><span class="tf-status-pill tf-status-{{ job.status }}">{{ job.get_status_display }}</span></td>
<td class="text-end"><a href="{% url 'job_detail' job.id %}" class="tf-inline-link">Open</a></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="tf-empty-state text-center m-4">
<h2 class="h4 mb-2">No jobs completed yet</h2>
<p class="text-secondary-emphasis mb-4">Start with one field intake and TrustForge will spin up the proof workflow automatically for this business.</p>
<a class="btn tf-btn tf-btn-primary" href="{% url 'job_create' %}">Create first job</a>
</div>
{% endif %}
</div>
</div>
</section>
{% endblock %}

View File

@ -0,0 +1,77 @@
{% extends "base.html" %}
{% block title %}Profile & Settings | TrustForge{% endblock %}
{% block meta_description %}Manage your TrustForge profile, workspace memberships, and account settings.{% endblock %}
{% block content %}
<section class="py-5">
<div class="container">
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
<div>
<div class="tf-eyebrow">Profile settings</div>
<h1 class="tf-page-title">Your account identity</h1>
<p class="tf-page-subtitle">Update your login profile and review which TrustForge workspaces you belong to.</p>
</div>
{% if current_membership and current_membership.can_manage_workspace %}
<a class="btn tf-btn tf-btn-secondary" href="{% url 'workspace_settings' %}">Workspace settings</a>
{% elif not current_membership %}
<a class="btn tf-btn tf-btn-primary" href="{% url 'business_onboarding' %}">Create workspace</a>
{% endif %}
</div>
<div class="row g-4">
<div class="col-lg-7">
<div class="tf-panel h-100">
<h2 class="h4 mb-3">Account details</h2>
<form method="post" class="row g-3">
{% csrf_token %}
<div class="col-md-6">
<label class="form-label">First name</label>
{{ form.first_name }}
</div>
<div class="col-md-6">
<label class="form-label">Last name</label>
{{ form.last_name }}
</div>
<div class="col-12">
<label class="form-label">Email</label>
{{ form.email }}
<div class="small text-secondary-emphasis mt-2">This email is also your login and password reset destination.</div>
</div>
<div class="col-12 pt-2">
<button type="submit" class="btn tf-btn tf-btn-primary">Save profile</button>
</div>
</form>
</div>
</div>
<div class="col-lg-5">
<div class="tf-panel h-100">
<h2 class="h4 mb-3">Workspace access</h2>
<div class="d-grid gap-3">
{% for membership in memberships %}
<div class="tf-team-member{% if current_membership and membership.business_id == current_membership.business_id %} tf-team-member-active{% endif %}">
<div>
<strong>{{ membership.business.name }}</strong>
<div class="small text-secondary-emphasis">{{ membership.business.primary_city }}, {{ membership.business.primary_state }}</div>
</div>
<div class="text-end">
<span class="tf-status-pill tf-role-pill">{{ membership.get_role_display }}</span>
{% if current_membership and membership.business_id == current_membership.business_id %}
<div class="small text-secondary-emphasis mt-1">Current workspace</div>
{% endif %}
</div>
</div>
{% empty %}
<div class="tf-empty-state text-start">
<h3 class="h5 mb-2">No workspace yet</h3>
<p class="text-secondary-emphasis mb-4">Create your first business workspace to turn TrustForge into a real tenant-scoped SaaS account.</p>
<a class="btn tf-btn tf-btn-primary" href="{% url 'business_onboarding' %}">Create workspace</a>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
</div>
</section>
{% endblock %}

View File

@ -0,0 +1,84 @@
{% extends "base.html" %}
{% block title %}Proof Card | TrustForge{% endblock %}
{% block meta_description %}View a TrustForge proof card and manage publishing controls for the active workspace.{% endblock %}
{% block content %}
<section class="py-5">
<div class="container">
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
<div>
<div class="tf-eyebrow">Proof card</div>
<h1 class="tf-page-title">{{ proof_card.job.service_type }} in {{ proof_card.job.city }}, {{ proof_card.job.state }}</h1>
<p class="tf-page-subtitle">{{ proof_card.customer_display_name }} · Scoped to {{ proof_card.job.business.name }} · Completed {{ proof_card.job.completed_at|date:"F j, Y" }}</p>
</div>
<div class="d-flex flex-wrap gap-2">
<a class="btn tf-btn tf-btn-secondary" href="{% url 'proof_cards_list' %}">All proof cards</a>
{% if proof_card.status == 'published' %}<a class="btn tf-btn tf-btn-secondary" href="{% url 'public_proof_detail' proof_card.job.business.slug proof_card.id %}" target="_blank" rel="noopener">Open public proof</a>{% endif %}
{% if current_membership.can_manage_proof %}<a class="btn tf-btn tf-btn-primary" href="{% url 'proof_card_edit' proof_card.id %}">Edit proof card</a>{% endif %}
</div>
</div>
<div class="row g-4">
<div class="col-lg-7">
<article class="tf-proof-card tf-proof-card-expanded h-100">
{% include "core/includes/proof_media_grid.html" with job=proof_card.job grid_class='tf-proof-media-grid-large' %}
<div class="tf-proof-card-body">
<div class="d-flex justify-content-between align-items-center mb-3 gap-3 flex-wrap">
<span class="tf-card-tag">{{ proof_card.job.service_type }}</span>
<span class="tf-badge-verified">{{ proof_card.verified_label }}</span>
</div>
<blockquote class="tf-testimonial mb-4">{% if proof_card.testimonial_quote %}“{{ proof_card.testimonial_quote }}”{% else %}Add a testimonial or collect one through the review request page to strengthen this proof asset.{% endif %}</blockquote>
<div class="row g-3">
<div class="col-md-4"><div class="tf-detail-box"><span>Rating</span><strong>{% if proof_card.rating %}★ {{ proof_card.rating }} / 5{% else %}Pending{% endif %}</strong></div></div>
<div class="col-md-4"><div class="tf-detail-box"><span>Featured</span><strong>{% if proof_card.is_featured %}Yes{% else %}No{% endif %}</strong></div></div>
<div class="col-md-4"><div class="tf-detail-box"><span>Widget target</span><strong>{{ proof_card.attached_widget_label|default:'Not set' }}</strong></div></div>
</div>
</div>
</article>
</div>
<div class="col-lg-5">
<div class="tf-panel mb-4">
<h2 class="h4 mb-3">Quick actions</h2>
{% if current_membership.can_manage_proof %}
<div class="d-grid gap-3">
<form method="post">
{% csrf_token %}
<input type="hidden" name="action" value="publish">
<button type="submit" class="btn tf-btn tf-btn-primary w-100">Publish proof card</button>
</form>
<form method="post">
{% csrf_token %}
<input type="hidden" name="action" value="hide">
<button type="submit" class="btn tf-btn tf-btn-secondary w-100">Hide from public proof gallery</button>
</form>
<form method="post">
{% csrf_token %}
<input type="hidden" name="action" value="toggle_featured">
<button type="submit" class="btn tf-btn tf-btn-secondary w-100">{% if proof_card.is_featured %}Remove from featured proof{% else %}Feature on landing experience{% endif %}</button>
</form>
</div>
{% else %}
<div class="tf-empty-state text-start">
<h3 class="h5 mb-2">View-only access</h3>
<p class="text-secondary-emphasis mb-0">Your current role can view proof assets in this workspace but cannot change publishing controls.</p>
</div>
{% endif %}
</div>
<div class="tf-panel">
<h2 class="h4 mb-3">Placement</h2>
<div class="tf-detail-box mb-3"><span>Attach to widgets</span><strong>{{ proof_card.attached_widget_label|default:'Homepage proof gallery' }}</strong></div>
<div class="tf-detail-box mb-3"><span>Attach to pages</span><strong>{{ proof_card.attached_pages|default:'Homepage' }}</strong></div>
<div class="tf-detail-box mb-3"><span>Public gallery</span><strong><a class="tf-inline-link" href="{% url 'public_proof_gallery' proof_card.job.business.slug %}" target="_blank" rel="noopener">/proof/{{ proof_card.job.business.slug }}/</a></strong></div>
{% if proof_card.status == 'published' %}
<div class="tf-detail-box mb-3"><span>Public proof page</span><strong><a class="tf-inline-link" href="{% url 'public_proof_detail' proof_card.job.business.slug proof_card.id %}" target="_blank" rel="noopener">/proof/{{ proof_card.job.business.slug }}/{{ proof_card.id }}/</a></strong></div>
{% endif %}
{% if proof_card.job.review_request %}
<a class="btn tf-btn tf-btn-secondary w-100" href="{% url 'review_request' proof_card.job.review_request.token %}">Open review request page</a>
{% endif %}
</div>
</div>
</div>
</div>
</section>
{% endblock %}

View File

@ -0,0 +1,65 @@
{% extends "base.html" %}
{% block title %}Edit Proof Card | TrustForge{% endblock %}
{% block meta_description %}Edit a TrustForge proof cards testimonial, status, featuring, and placement settings.{% endblock %}
{% block content %}
<section class="py-5">
<div class="container">
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
<div>
<div class="tf-eyebrow">Edit proof card</div>
<h1 class="tf-page-title">Refine the conversion asset</h1>
<p class="tf-page-subtitle">Update the customer display name, testimonial, placement, and publish settings without touching the underlying job record.</p>
</div>
<a class="btn tf-btn tf-btn-secondary" href="{% url 'proof_card_detail' proof_card.id %}">Back to proof card</a>
</div>
<div class="tf-panel">
<form method="post" class="row g-3">
{% csrf_token %}
<div class="col-md-6">
<label class="form-label">Customer display name</label>
{{ form.customer_display_name }}
</div>
<div class="col-md-6">
<label class="form-label">Rating</label>
{{ form.rating }}
</div>
<div class="col-12">
<label class="form-label">Testimonial quote</label>
{{ form.testimonial_quote }}
</div>
<div class="col-md-6">
<label class="form-label">Status</label>
{{ form.status }}
</div>
<div class="col-md-6">
<label class="form-label">Attach to widget</label>
{{ form.attached_widget_label }}
</div>
<div class="col-12">
<label class="form-label">Attach to pages</label>
{{ form.attached_pages }}
</div>
<div class="col-md-6">
<div class="form-check tf-check-field">
{{ form.is_anonymized }}
<label class="form-check-label" for="{{ form.is_anonymized.id_for_label }}">Anonymize customer</label>
</div>
</div>
<div class="col-md-6">
<div class="form-check tf-check-field">
{{ form.is_featured }}
<label class="form-check-label" for="{{ form.is_featured.id_for_label }}">Feature this proof card</label>
</div>
</div>
<div class="col-12 d-flex gap-3 pt-2">
<button type="submit" class="btn tf-btn tf-btn-primary">Save proof card</button>
<a href="{% url 'proof_card_detail' proof_card.id %}" class="btn tf-btn tf-btn-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</section>
{% endblock %}

View File

@ -0,0 +1,58 @@
{% extends "base.html" %}
{% block title %}Proof Cards | TrustForge{% endblock %}
{% block meta_description %}Browse TrustForge proof cards scoped to your current workspace.{% endblock %}
{% block content %}
<section class="py-5">
<div class="container">
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
<div>
<div class="tf-eyebrow">Proof cards</div>
<h1 class="tf-page-title">{{ current_membership.business.name }} proof gallery</h1>
<p class="tf-page-subtitle">These conversion assets are tenant-scoped to the active workspace and ready for review, publishing, or featuring.</p>
</div>
<div class="d-flex flex-wrap gap-2">
<a class="btn tf-btn tf-btn-secondary" href="{% url 'public_proof_gallery' current_membership.business.slug %}" target="_blank" rel="noopener">Open public gallery</a>
<a class="btn tf-btn tf-btn-secondary" href="{% url 'dashboard' %}">Back to dashboard</a>
</div>
</div>
<div class="row g-4">
{% for proof in proof_cards %}
<div class="col-lg-4 col-md-6">
<a class="tf-proof-card-link" href="{% url 'proof_card_detail' proof.id %}">
<article class="tf-proof-card h-100">
{% include "core/includes/proof_media_grid.html" with job=proof.job %}
<div class="tf-proof-card-body">
<div class="d-flex justify-content-between align-items-center mb-3 gap-3">
<span class="tf-card-tag">{{ proof.job.service_type }}</span>
<span class="tf-badge-verified">{{ proof.verified_label }}</span>
</div>
<h2 class="h5 mb-1">{{ proof.job.city }}, {{ proof.job.state }}</h2>
<p class="small text-secondary-emphasis mb-3">{{ proof.customer_display_name }} · {{ proof.job.completed_at|date:"M j, Y" }}</p>
<p class="mb-3">{% if proof.testimonial_quote %}“{{ proof.testimonial_quote|truncatechars:110 }}”{% else %}This job is ready to become a published proof asset for the next customer.{% endif %}</p>
<div class="d-flex justify-content-between align-items-center small text-secondary-emphasis">
<span class="tf-status-pill tf-status-{{ proof.status }}">{{ proof.get_status_display }}</span>
<span>{% if proof.rating %}★ {{ proof.rating }}{% else %}No rating yet{% endif %}</span>
</div>
{% if proof.status == 'published' %}
<p class="small text-secondary-emphasis mt-3 mb-0">Live at <span class="tf-inline-link">/proof/{{ current_membership.business.slug }}/{{ proof.id }}/</span></p>
{% endif %}
</div>
</article>
</a>
</div>
{% empty %}
<div class="col-12">
<div class="tf-empty-state text-center">
<h2 class="h4 mb-2">No proof cards yet</h2>
<p class="text-secondary-emphasis mb-4">Log a completed job to create the first proof asset in this workspace.</p>
<a class="btn tf-btn tf-btn-primary" href="{% url 'job_create' %}">Create first job</a>
</div>
</div>
{% endfor %}
</div>
</div>
</section>
{% endblock %}

View File

@ -0,0 +1,74 @@
{% extends "base.html" %}
{% block title %}{{ proof_card.job.service_type }} in {{ proof_card.job.city }} | {{ business.name }} Proof{% endblock %}
{% block meta_description %}Published proof from {{ business.name }} for {{ proof_card.job.service_type|lower }} in {{ proof_card.job.city }}, {{ proof_card.job.state }}.{% endblock %}
{% block content %}
<section class="py-5">
<div class="container">
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
<div>
<div class="tf-eyebrow">Published proof</div>
<h1 class="tf-page-title">{{ proof_card.job.service_type }} in {{ proof_card.job.city }}, {{ proof_card.job.state }}</h1>
<p class="tf-page-subtitle">Completed by {{ business.name }} · {{ proof_card.customer_display_name }} · {{ proof_card.job.completed_at|date:"F j, Y" }}</p>
</div>
<div class="d-flex flex-wrap gap-2">
<a class="btn tf-btn tf-btn-secondary" href="{% url 'public_proof_gallery' business.slug %}">Back to gallery</a>
{% if business.google_review_url %}<a class="btn tf-btn tf-btn-primary" href="{{ business.google_review_url }}" target="_blank" rel="noopener">Read more reviews</a>{% elif request.user.is_authenticated and current_membership and current_membership.business_id == business.id %}<a class="btn tf-btn tf-btn-primary" href="{% url 'proof_card_detail' proof_card.id %}">Manage this proof card</a>{% endif %}
</div>
</div>
<div class="row g-4">
<div class="col-lg-7">
<article class="tf-proof-card tf-proof-card-expanded h-100">
{% include "core/includes/proof_media_grid.html" with job=proof_card.job grid_class='tf-proof-media-grid-large' %}
<div class="tf-proof-card-body">
<div class="d-flex justify-content-between align-items-center mb-3 gap-3 flex-wrap">
<span class="tf-card-tag">{{ proof_card.job.service_type }}</span>
<span class="tf-badge-verified">{{ proof_card.verified_label }}</span>
</div>
<blockquote class="tf-testimonial mb-4">{% if proof_card.testimonial_quote %}“{{ proof_card.testimonial_quote }}”{% else %}This published proof card confirms a completed job by {{ business.name }}.{% endif %}</blockquote>
<div class="row g-3">
<div class="col-md-4"><div class="tf-detail-box"><span>Rating</span><strong>{% if proof_card.rating %}★ {{ proof_card.rating }} / 5{% else %}Verified{% endif %}</strong></div></div>
<div class="col-md-4"><div class="tf-detail-box"><span>Completed</span><strong>{{ proof_card.job.completed_at|date:"M j, Y" }}</strong></div></div>
<div class="col-md-4"><div class="tf-detail-box"><span>Service area</span><strong>{{ proof_card.job.city }}, {{ proof_card.job.state }}</strong></div></div>
</div>
</div>
</article>
</div>
<div class="col-lg-5">
<div class="tf-panel mb-4">
<h2 class="h4 mb-3">Why this matters</h2>
<div class="d-grid gap-3">
<div class="tf-detail-box"><span>Business</span><strong>{{ business.name }}</strong></div>
<div class="tf-detail-box"><span>Customer display</span><strong>{{ proof_card.customer_display_name }}</strong></div>
<div class="tf-detail-box"><span>Proof status</span><strong>{{ proof_card.get_status_display }}</strong></div>
<div class="tf-detail-box"><span>Placement</span><strong>{{ proof_card.attached_pages|default:'Homepage' }}</strong></div>
</div>
</div>
<div class="tf-panel">
<h2 class="h4 mb-3">More published proof</h2>
<div class="d-grid gap-3">
{% for related in related_proofs %}
<a href="{% url 'public_proof_detail' business.slug related.id %}" class="tf-proof-mini">
<div>
<div class="tf-card-tag mb-2">{{ related.job.service_type }}</div>
<strong>{{ related.job.city }}, {{ related.job.state }}</strong>
<div class="small text-secondary-emphasis">{{ related.customer_display_name }}</div>
</div>
<span class="tf-status-pill tf-status-{{ related.status }}">{{ related.get_status_display }}</span>
</a>
{% empty %}
<div class="tf-empty-state text-start">
<h3 class="h5 mb-2">This is the first published proof card</h3>
<p class="text-secondary-emphasis mb-0">Return to the gallery later as {{ business.name }} publishes more completed work.</p>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
</div>
</section>
{% endblock %}

View File

@ -0,0 +1,94 @@
{% extends "base.html" %}
{% block title %}{{ business.name }} Proof Gallery | TrustForge{% endblock %}
{% block meta_description %}Browse published proof cards for {{ business.name }} in {{ business.primary_city }}, {{ business.primary_state }}.{% endblock %}
{% block content %}
<section class="py-5">
<div class="container">
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
<div>
<div class="tf-eyebrow">Public proof gallery</div>
<h1 class="tf-page-title">{{ business.name }} completed work</h1>
<p class="tf-page-subtitle">Published proof cards from real completed jobs in {{ business.primary_city }}, {{ business.primary_state }}. Use this gallery to validate the quality, consistency, and trust signal behind the brand.</p>
</div>
<div class="d-flex flex-wrap gap-2">
{% if business.google_review_url %}<a class="btn tf-btn tf-btn-secondary" href="{{ business.google_review_url }}" target="_blank" rel="noopener">Read Google reviews</a>{% endif %}
{% if request.user.is_authenticated and current_membership and current_membership.business_id == business.id %}<a class="btn tf-btn tf-btn-primary" href="{% url 'proof_cards_list' %}">Manage proof cards</a>{% else %}<a class="btn tf-btn tf-btn-primary" href="{% url 'signup' %}">Create your own gallery</a>{% endif %}
</div>
</div>
{% if featured_proofs %}
<div class="tf-panel mb-4">
<div class="d-flex justify-content-between align-items-center flex-wrap gap-3 mb-3">
<div>
<div class="tf-eyebrow">Featured proof</div>
<h2 class="h3 mb-0">Standout published jobs</h2>
</div>
<div class="small text-secondary-emphasis">{{ proof_cards|length }} published proof card{{ proof_cards|length|pluralize }}</div>
</div>
<div class="row g-4">
{% for proof in featured_proofs %}
<div class="col-lg-4">
<a class="tf-proof-card-link" href="{% url 'public_proof_detail' business.slug proof.id %}">
<article class="tf-proof-card h-100">
{% include "core/includes/proof_media_grid.html" with job=proof.job %}
<div class="tf-proof-card-body">
<div class="d-flex justify-content-between align-items-center mb-3 gap-3">
<span class="tf-card-tag">{{ proof.job.service_type }}</span>
<span class="tf-badge-verified">{{ proof.verified_label }}</span>
</div>
<h2 class="h5 mb-1">{{ proof.job.city }}, {{ proof.job.state }}</h2>
<p class="small text-secondary-emphasis mb-3">{{ proof.customer_display_name }} · {{ proof.job.completed_at|date:"M j, Y" }}</p>
<p class="mb-3">{% if proof.testimonial_quote %}“{{ proof.testimonial_quote|truncatechars:110 }}”{% else %}Published proof from a real completed job by {{ business.name }}.{% endif %}</p>
<div class="d-flex justify-content-between align-items-center small text-secondary-emphasis">
<span class="tf-status-pill tf-status-{{ proof.status }}">{{ proof.get_status_display }}</span>
<span>{% if proof.rating %}★ {{ proof.rating }}{% else %}Verified{% endif %}</span>
</div>
</div>
</article>
</a>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<div class="row g-4">
{% for proof in proof_cards %}
<div class="col-lg-4 col-md-6">
<a class="tf-proof-card-link" href="{% url 'public_proof_detail' business.slug proof.id %}">
<article class="tf-proof-card h-100">
<div class="tf-proof-media-grid">
<div class="tf-photo-slot">Before</div>
<div class="tf-photo-slot tf-photo-slot-after">After</div>
</div>
<div class="tf-proof-card-body">
<div class="d-flex justify-content-between align-items-center mb-3 gap-3">
<span class="tf-card-tag">{{ proof.job.service_type }}</span>
<span class="tf-badge-verified">{{ proof.verified_label }}</span>
</div>
<h2 class="h5 mb-1">{{ proof.job.city }}, {{ proof.job.state }}</h2>
<p class="small text-secondary-emphasis mb-3">{{ proof.customer_display_name }} · {{ proof.job.completed_at|date:"M j, Y" }}</p>
<p class="mb-3">{% if proof.testimonial_quote %}“{{ proof.testimonial_quote|truncatechars:120 }}”{% else %}Published proof asset ready for customers comparing local providers.{% endif %}</p>
<div class="d-flex justify-content-between align-items-center small text-secondary-emphasis">
<span class="tf-status-pill tf-status-{{ proof.status }}">{{ proof.get_status_display }}</span>
<span>{% if proof.rating %}★ {{ proof.rating }}{% else %}Verified{% endif %}</span>
</div>
</div>
</article>
</a>
</div>
{% empty %}
<div class="col-12">
<div class="tf-empty-state text-center">
<h2 class="h4 mb-2">No public proof cards yet</h2>
<p class="text-secondary-emphasis mb-4">{{ business.name }} has not published any proof cards yet. Check back soon for verified completed work.</p>
<a class="btn tf-btn tf-btn-primary" href="{% url 'signup' %}">Build a proof gallery like this</a>
</div>
</div>
{% endfor %}
</div>
</div>
</section>
{% endblock %}

View File

@ -0,0 +1,54 @@
{% extends "base.html" %}
{% block title %}Share Feedback | TrustForge{% endblock %}
{% block meta_description %}Share feedback on your recent service experience and help create verified proof of work.{% endblock %}
{% block content %}
<section class="py-5">
<div class="container">
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="tf-panel tf-panel-centered">
<div class="tf-eyebrow">Customer feedback</div>
<h1 class="tf-page-title">How was your experience?</h1>
<p class="tf-page-subtitle">{{ job.business.name }} completed {{ job.service_type|lower }} in {{ job.city }}, {{ job.state }}. Your feedback helps verify real work and guide follow-up.</p>
{% if submitted %}
<div class="tf-empty-state text-center mt-4">
{% if positive %}
<h2 class="h3 mb-3">Thank you — this proof is ready to help the next customer.</h2>
<p class="text-secondary-emphasis mb-4">Your feedback has been saved. The business can now publish this as verified proof of work.</p>
{% if redirect_url %}
<a href="{{ redirect_url }}" class="btn tf-btn tf-btn-primary">Continue to Google review</a>
{% endif %}
{% else %}
<h2 class="h3 mb-3">Thanks for the feedback.</h2>
<p class="text-secondary-emphasis mb-0">This response stays internal so the business can follow up directly and improve the experience.</p>
{% endif %}
</div>
{% else %}
<form method="post" class="text-start mt-4">
{% csrf_token %}
<div class="tf-feedback-grid mb-4">
{% for radio in form.experience %}
<label class="tf-feedback-option">
{{ radio.tag }}
<span>{{ radio.choice_label }}</span>
</label>
{% endfor %}
</div>
{% if form.experience.errors %}<div class="text-danger small mb-3">{{ form.experience.errors|join:', ' }}</div>{% endif %}
<div class="mb-3">
<label class="form-label">Optional testimonial</label>
{{ form.testimonial }}
{% if form.testimonial.errors %}<div class="text-danger small mt-1">{{ form.testimonial.errors|join:', ' }}</div>{% endif %}
</div>
<button type="submit" class="btn tf-btn tf-btn-primary w-100">Submit feedback</button>
</form>
{% endif %}
</div>
</div>
</div>
</div>
</section>
{% endblock %}

View File

@ -0,0 +1,132 @@
{% extends "base.html" %}
{% block title %}Workspace Settings | TrustForge{% endblock %}
{% block meta_description %}Manage your TrustForge workspace, business profile, and team roles.{% endblock %}
{% block content %}
<section class="py-5">
<div class="container">
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
<div>
<div class="tf-eyebrow">Workspace settings</div>
<h1 class="tf-page-title">{{ current_membership.business.name }}</h1>
<p class="tf-page-subtitle">Manage the business profile, service territory, review destination, and who has access to this protected trust engine.</p>
</div>
<a class="btn tf-btn tf-btn-secondary" href="{% url 'dashboard' %}">Back to dashboard</a>
</div>
<div class="row g-4">
<div class="col-lg-7">
<div class="tf-panel h-100">
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
<div>
<h2 class="h4 mb-2">Business profile</h2>
<p class="text-secondary-emphasis mb-0">These details anchor onboarding, workspace identity, and public review routing.</p>
</div>
<div class="tf-inline-stat">
<span>Current role</span>
<strong>{{ current_membership.get_role_display }}</strong>
</div>
</div>
<form method="post" class="row g-3">
{% csrf_token %}
<input type="hidden" name="action" value="update_business">
<div class="col-12">
<label class="form-label">Business name</label>
{{ business_form.name }}
</div>
<div class="col-md-6">
<label class="form-label">Industry</label>
{{ business_form.industry }}
</div>
<div class="col-md-3">
<label class="form-label">Primary city</label>
{{ business_form.primary_city }}
</div>
<div class="col-md-3">
<label class="form-label">State</label>
{{ business_form.primary_state }}
</div>
<div class="col-12">
<label class="form-label">Google review URL</label>
{{ business_form.google_review_url }}
</div>
<div class="col-12 pt-2">
<button type="submit" class="btn tf-btn tf-btn-primary">Save workspace</button>
</div>
</form>
</div>
</div>
<div class="col-lg-5">
<div class="tf-panel h-100">
<h2 class="h4 mb-3">Role access model</h2>
<div class="d-grid gap-3">
<div class="tf-role-card"><strong>Owner / Admin</strong><span>Manage workspace settings, team access, jobs, and proof publishing.</span></div>
<div class="tf-role-card"><strong>Manager</strong><span>Run the job-to-proof workflow and edit proof cards, but not workspace administration.</span></div>
<div class="tf-role-card"><strong>Technician</strong><span>Log completed jobs and view pipeline activity inside the assigned business only.</span></div>
</div>
</div>
</div>
</div>
<div class="row g-4 mt-1">
<div class="col-lg-5">
<div class="tf-panel h-100">
<h2 class="h4 mb-3">Add team member</h2>
<p class="text-secondary-emphasis">Attach a user to this workspace. New users can use the forgot-password flow to activate access if they do not have a password yet.</p>
<form method="post" class="row g-3">
{% csrf_token %}
<input type="hidden" name="action" value="invite_member">
<div class="col-md-6">
<label class="form-label">First name</label>
{{ invite_form.first_name }}
</div>
<div class="col-md-6">
<label class="form-label">Last name</label>
{{ invite_form.last_name }}
</div>
<div class="col-12">
<label class="form-label">Email</label>
{{ invite_form.email }}
</div>
<div class="col-12">
<label class="form-label">Role</label>
{{ invite_form.role }}
</div>
<div class="col-12 pt-2">
<button type="submit" class="btn tf-btn tf-btn-primary">Add member</button>
</div>
</form>
</div>
</div>
<div class="col-lg-7">
<div class="tf-panel h-100">
<div class="d-flex justify-content-between align-items-center mb-3">
<h2 class="h4 mb-0">Workspace team</h2>
<span class="tf-card-tag">{{ team_members|length }} seats</span>
</div>
<div class="d-grid gap-3">
{% for membership in team_members %}
<div class="tf-team-member">
<div>
<strong>{{ membership.user.get_full_name|default:membership.user.email }}</strong>
<div class="small text-secondary-emphasis">{{ membership.user.email }}</div>
</div>
<div class="text-end">
<span class="tf-status-pill tf-role-pill">{{ membership.get_role_display }}</span>
<div class="small text-secondary-emphasis mt-1">Joined {{ membership.created_at|date:"M j, Y" }}</div>
</div>
</div>
{% empty %}
<div class="tf-empty-state text-start">
<h3 class="h5 mb-2">No team members yet</h3>
<p class="text-secondary-emphasis mb-0">Invite admins, managers, and technicians to turn this workspace into a real multi-user SaaS account.</p>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
</div>
</section>
{% endblock %}

View File

@ -0,0 +1,54 @@
{% extends "base.html" %}
{% block title %}Login | TrustForge{% endblock %}
{% block meta_description %}Sign in to TrustForge to manage jobs, review requests, and proof cards securely.{% endblock %}
{% block content %}
<section class="py-5 py-lg-6">
<div class="container">
<div class="row g-4 align-items-center">
<div class="col-lg-5">
<div class="tf-panel tf-panel-centered h-100">
<div class="tf-eyebrow">Secure access</div>
<h1 class="tf-page-title">{{ auth_page_title }}</h1>
<p class="tf-page-subtitle">{{ auth_page_description }}</p>
<div class="tf-auth-points mt-4">
<div class="tf-check-field"><div class="tf-check-row"><span></span><div>Access dashboard, jobs, and proof cards securely</div></div></div>
<div class="tf-check-field"><div class="tf-check-row"><span></span><div>Preserve the premium TrustForge workflow without exposing customer data publicly</div></div></div>
<div class="tf-check-field"><div class="tf-check-row"><span></span><div>Next step adds business onboarding and role-based team access</div></div></div>
</div>
</div>
</div>
<div class="col-lg-7">
<div class="tf-panel tf-panel-centered tf-auth-card">
<div class="tf-card-tag mb-3">Login</div>
<h2 class="h3 mb-2">Continue into TrustForge</h2>
<p class="text-secondary-emphasis mb-4">Use your work email and password to open the product workspace.</p>
<form method="post" novalidate>
{% csrf_token %}
{% if form.non_field_errors %}
<div class="alert alert-danger">{{ form.non_field_errors|join:', ' }}</div>
{% endif %}
<div class="mb-3">
<label class="form-label">Work email</label>
{{ form.username }}
{% if form.username.errors %}<div class="text-danger small mt-1">{{ form.username.errors|join:', ' }}</div>{% endif %}
</div>
<div class="mb-3">
<div class="d-flex justify-content-between align-items-center mb-2">
<label class="form-label mb-0">Password</label>
<a class="tf-inline-link small" href="{% url 'password_reset' %}">Forgot password?</a>
</div>
{{ form.password }}
{% if form.password.errors %}<div class="text-danger small mt-1">{{ form.password.errors|join:', ' }}</div>{% endif %}
</div>
{% if next %}<input type="hidden" name="next" value="{{ next }}">{% endif %}
<button type="submit" class="btn tf-btn tf-btn-primary w-100">Log in</button>
</form>
<p class="text-secondary-emphasis small mt-4 mb-0">New to TrustForge? <a class="tf-inline-link" href="{% url 'signup' %}">Create your account</a></p>
</div>
</div>
</div>
</div>
</section>
{% endblock %}

View File

@ -0,0 +1,21 @@
{% extends "base.html" %}
{% block title %}Password Updated | TrustForge{% endblock %}
{% block meta_description %}Your TrustForge password has been updated successfully.{% endblock %}
{% block content %}
<section class="py-5 py-lg-6">
<div class="container">
<div class="row justify-content-center">
<div class="col-lg-7 col-xl-6">
<div class="tf-panel tf-panel-centered text-center tf-auth-card">
<div class="tf-eyebrow">All set</div>
<h1 class="tf-page-title">{{ auth_page_title }}</h1>
<p class="tf-page-subtitle mx-auto">{{ auth_page_description }}</p>
<a class="btn tf-btn tf-btn-primary mt-4" href="{% url 'login' %}">Log in now</a>
</div>
</div>
</div>
</div>
</section>
{% endblock %}

View File

@ -0,0 +1,45 @@
{% extends "base.html" %}
{% block title %}Set New Password | TrustForge{% endblock %}
{% block meta_description %}Create a new password for your TrustForge account securely.{% endblock %}
{% block content %}
<section class="py-5 py-lg-6">
<div class="container">
<div class="row justify-content-center">
<div class="col-lg-7 col-xl-6">
<div class="tf-panel tf-panel-centered tf-auth-card">
<div class="tf-eyebrow">Create new password</div>
<h1 class="tf-page-title">{{ auth_page_title }}</h1>
<p class="tf-page-subtitle">{{ auth_page_description }}</p>
{% if validlink %}
<form method="post" novalidate class="mt-4">
{% csrf_token %}
{% if form.non_field_errors %}
<div class="alert alert-danger">{{ form.non_field_errors|join:', ' }}</div>
{% endif %}
<div class="mb-3">
<label class="form-label">New password</label>
{{ form.new_password1 }}
{% if form.new_password1.errors %}<div class="text-danger small mt-1">{{ form.new_password1.errors|join:', ' }}</div>{% endif %}
</div>
<div class="mb-3">
<label class="form-label">Confirm new password</label>
{{ form.new_password2 }}
{% if form.new_password2.errors %}<div class="text-danger small mt-1">{{ form.new_password2.errors|join:', ' }}</div>{% endif %}
</div>
<button type="submit" class="btn tf-btn tf-btn-primary w-100">Update password</button>
</form>
{% else %}
<div class="tf-empty-state text-center mt-4">
<h2 class="h4 mb-2">Reset link unavailable</h2>
<p class="text-secondary-emphasis mb-0">This reset link is invalid or has already been used. Request a fresh one below.</p>
</div>
<a class="btn tf-btn tf-btn-primary mt-4" href="{% url 'password_reset' %}">Request a new link</a>
{% endif %}
</div>
</div>
</div>
</div>
</section>
{% endblock %}

View File

@ -0,0 +1,25 @@
{% extends "base.html" %}
{% block title %}Check Your Email | TrustForge{% endblock %}
{% block meta_description %}Password reset instructions for your TrustForge account have been sent if the email exists.{% endblock %}
{% block content %}
<section class="py-5 py-lg-6">
<div class="container">
<div class="row justify-content-center">
<div class="col-lg-7 col-xl-6">
<div class="tf-panel tf-panel-centered text-center tf-auth-card">
<div class="tf-eyebrow">Email sent</div>
<h1 class="tf-page-title">{{ auth_page_title }}</h1>
<p class="tf-page-subtitle mx-auto">{{ auth_page_description }}</p>
<div class="tf-empty-state text-center mt-4">
<h2 class="h4 mb-2">Next step</h2>
<p class="text-secondary-emphasis mb-0">Open the email from TrustForge and follow the secure link to create a new password.</p>
</div>
<a class="btn tf-btn tf-btn-secondary mt-4" href="{% url 'login' %}">Return to login</a>
</div>
</div>
</div>
</div>
</section>
{% endblock %}

View File

@ -0,0 +1,10 @@
Hi,
We received a request to reset the password for your TrustForge account.
Use the link below to choose a new password:
{{ protocol }}://{{ domain }}{% url 'password_reset_confirm' uidb64=uid token=token %}
If you did not request this, you can ignore this email.
— TrustForge

View File

@ -0,0 +1,33 @@
{% extends "base.html" %}
{% block title %}Forgot Password | TrustForge{% endblock %}
{% block meta_description %}Request a secure password reset link for your TrustForge account.{% endblock %}
{% block content %}
<section class="py-5 py-lg-6">
<div class="container">
<div class="row justify-content-center">
<div class="col-lg-7 col-xl-6">
<div class="tf-panel tf-panel-centered tf-auth-card">
<div class="tf-eyebrow">Password reset</div>
<h1 class="tf-page-title">{{ auth_page_title }}</h1>
<p class="tf-page-subtitle">{{ auth_page_description }}</p>
<form method="post" novalidate class="mt-4">
{% csrf_token %}
{% if form.non_field_errors %}
<div class="alert alert-danger">{{ form.non_field_errors|join:', ' }}</div>
{% endif %}
<div class="mb-3">
<label class="form-label">Work email</label>
{{ form.email }}
{% if form.email.errors %}<div class="text-danger small mt-1">{{ form.email.errors|join:', ' }}</div>{% endif %}
</div>
<button type="submit" class="btn tf-btn tf-btn-primary w-100">Send reset link</button>
</form>
<p class="text-secondary-emphasis small mt-4 mb-0"><a class="tf-inline-link" href="{% url 'login' %}">Back to login</a></p>
</div>
</div>
</div>
</div>
</section>
{% endblock %}

View File

@ -0,0 +1 @@
Reset your TrustForge password

View File

@ -0,0 +1,67 @@
{% extends "base.html" %}
{% block title %}Get Started | TrustForge{% endblock %}
{% block meta_description %}Create your TrustForge account and start turning completed jobs into proof that wins the next customer.{% endblock %}
{% block content %}
<section class="py-5 py-lg-6">
<div class="container">
<div class="row g-4 align-items-center">
<div class="col-lg-5">
<div class="tf-panel tf-panel-centered h-100">
<div class="tf-eyebrow">Get started</div>
<h1 class="tf-page-title">{{ auth_page_title }}</h1>
<p class="tf-page-subtitle">{{ auth_page_description }}</p>
<div class="tf-auth-points mt-4">
<div class="tf-check-field"><div class="tf-check-row"><span>01</span><div>Create secure email/password access</div></div></div>
<div class="tf-check-field"><div class="tf-check-row"><span>02</span><div>Land inside the product instead of a demo-only experience</div></div></div>
<div class="tf-check-field"><div class="tf-check-row"><span>03</span><div>Next step will connect your account to a business workspace</div></div></div>
</div>
</div>
</div>
<div class="col-lg-7">
<div class="tf-panel tf-panel-centered tf-auth-card">
<div class="tf-card-tag mb-3">Sign up</div>
<h2 class="h3 mb-2">Start your TrustForge workspace</h2>
<p class="text-secondary-emphasis mb-4">Create the account that will own your dashboard access and future business onboarding.</p>
<form method="post" novalidate>
{% csrf_token %}
{% if form.non_field_errors %}
<div class="alert alert-danger">{{ form.non_field_errors|join:', ' }}</div>
{% endif %}
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">First name</label>
{{ form.first_name }}
{% if form.first_name.errors %}<div class="text-danger small mt-1">{{ form.first_name.errors|join:', ' }}</div>{% endif %}
</div>
<div class="col-md-6">
<label class="form-label">Last name</label>
{{ form.last_name }}
{% if form.last_name.errors %}<div class="text-danger small mt-1">{{ form.last_name.errors|join:', ' }}</div>{% endif %}
</div>
<div class="col-12">
<label class="form-label">Work email</label>
{{ form.email }}
{% if form.email.errors %}<div class="text-danger small mt-1">{{ form.email.errors|join:', ' }}</div>{% endif %}
</div>
<div class="col-md-6">
<label class="form-label">Password</label>
{{ form.password1 }}
{% if form.password1.errors %}<div class="text-danger small mt-1">{{ form.password1.errors|join:', ' }}</div>{% endif %}
</div>
<div class="col-md-6">
<label class="form-label">Confirm password</label>
{{ form.password2 }}
{% if form.password2.errors %}<div class="text-danger small mt-1">{{ form.password2.errors|join:', ' }}</div>{% endif %}
</div>
</div>
<button type="submit" class="btn tf-btn tf-btn-primary w-100 mt-4">Create account</button>
</form>
<p class="text-secondary-emphasis small mt-4 mb-0">Already have an account? <a class="tf-inline-link" href="{% url 'login' %}">Log in</a></p>
</div>
</div>
</div>
</div>
</section>
{% endblock %}

View File

@ -1,3 +1,221 @@
from django.contrib.auth import get_user_model
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase from django.test import TestCase
from django.urls import reverse
# Create your tests here. from .models import Business, BusinessMembership, Customer, Job, JobMedia, ProofCard, ReviewRequest
User = get_user_model()
class TrustForgeFlowTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(
username='owner@example.com',
email='owner@example.com',
password='StrongPass123!',
)
self.business = Business.objects.create(
name='Forge Roofing',
slug='forge-roofing',
industry='Roofing',
primary_city='Austin',
primary_state='TX',
google_review_url='https://example.com/google-review',
)
self.membership = BusinessMembership.objects.create(
user=self.user,
business=self.business,
role=BusinessMembership.Role.OWNER,
)
self.customer = Customer.objects.create(
business=self.business,
full_name='Jordan Lee',
email='jordan@example.com',
city='Austin',
state='TX',
)
self.job = Job.objects.create(
business=self.business,
customer=self.customer,
service_type='Roof repair',
city='Austin',
state='TX',
)
JobMedia.objects.create(
job=self.job,
media_type=JobMedia.MediaType.BEFORE,
file=SimpleUploadedFile('before-sample.jpg', b'before-image-bytes', content_type='image/jpeg'),
)
JobMedia.objects.create(
job=self.job,
media_type=JobMedia.MediaType.AFTER,
file=SimpleUploadedFile('after-sample.jpg', b'after-image-bytes', content_type='image/jpeg'),
)
self.proof_card = ProofCard.objects.create(job=self.job, customer_display_name='Verified homeowner')
self.review_request = ReviewRequest.objects.create(job=self.job)
def test_home_loads(self):
response = self.client.get(reverse('home'))
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'TrustForge')
def test_dashboard_requires_login(self):
response = self.client.get(reverse('dashboard'))
self.assertEqual(response.status_code, 302)
self.assertIn(reverse('login'), response.url)
def test_login_with_email_redirects_to_dashboard(self):
response = self.client.post(
reverse('login'),
{'username': 'owner@example.com', 'password': 'StrongPass123!'},
)
self.assertRedirects(response, reverse('dashboard'))
def test_logged_in_user_without_membership_redirects_to_onboarding(self):
user = User.objects.create_user(
username='solo@example.com',
email='solo@example.com',
password='StrongPass123!',
)
self.client.force_login(user)
response = self.client.get(reverse('dashboard'))
self.assertRedirects(response, reverse('business_onboarding'))
def test_dashboard_only_shows_active_business_data(self):
other_business = Business.objects.create(
name='Hidden Plumbing',
slug='hidden-plumbing',
industry='Plumbing',
primary_city='Dallas',
primary_state='TX',
)
other_customer = Customer.objects.create(
business=other_business,
full_name='Taylor Shade',
city='Dallas',
state='TX',
)
Job.objects.create(
business=other_business,
customer=other_customer,
service_type='Leak repair',
city='Dallas',
state='TX',
)
self.client.force_login(self.user)
response = self.client.get(reverse('dashboard'))
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'Forge Roofing proof momentum')
self.assertNotContains(response, 'Leak repair')
def test_technician_cannot_edit_proof_card(self):
technician = User.objects.create_user(
username='tech@example.com',
email='tech@example.com',
password='StrongPass123!',
)
BusinessMembership.objects.create(
user=technician,
business=self.business,
role=BusinessMembership.Role.TECHNICIAN,
)
self.client.force_login(technician)
response = self.client.get(reverse('proof_card_edit', args=[self.proof_card.id]))
self.assertEqual(response.status_code, 403)
def test_public_review_positive_feedback_publishes_proof(self):
response = self.client.post(
reverse('review_request', args=[self.review_request.token]),
{'experience': 'great', 'testimonial': 'They showed up on time and the roof looks incredible.'},
)
self.assertEqual(response.status_code, 200)
self.proof_card.refresh_from_db()
self.assertEqual(self.proof_card.status, 'published')
self.assertEqual(self.proof_card.rating, 5)
def test_public_gallery_only_shows_published_cards_for_requested_business(self):
self.proof_card.status = ProofCard.Status.PUBLISHED
self.proof_card.is_featured = True
self.proof_card.testimonial_quote = 'Published proof for Forge Roofing.'
self.proof_card.save(update_fields=['status', 'is_featured', 'testimonial_quote'])
other_business = Business.objects.create(
name='Quiet Electric',
slug='quiet-electric',
industry='Electrical',
primary_city='Denver',
primary_state='CO',
)
other_customer = Customer.objects.create(
business=other_business,
full_name='Morgan Bright',
city='Denver',
state='CO',
)
other_job = Job.objects.create(
business=other_business,
customer=other_customer,
service_type='Panel upgrade',
city='Denver',
state='CO',
)
ProofCard.objects.create(
job=other_job,
customer_display_name='Verified homeowner',
status=ProofCard.Status.PUBLISHED,
testimonial_quote='This should not appear in Forge Roofing gallery.',
)
draft_customer = Customer.objects.create(
business=self.business,
full_name='Casey Draft',
city='Austin',
state='TX',
)
draft_job = Job.objects.create(
business=self.business,
customer=draft_customer,
service_type='Draft-only repair',
city='Austin',
state='TX',
)
ProofCard.objects.create(
job=draft_job,
customer_display_name='Hidden draft',
status=ProofCard.Status.DRAFT,
testimonial_quote='Draft cards should stay private.',
)
response = self.client.get(reverse('public_proof_gallery', args=[self.business.slug]))
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'Forge Roofing completed work')
self.assertContains(response, 'Published proof for Forge Roofing.')
self.assertContains(response, self.job.before_media.file.url)
self.assertContains(response, self.job.after_media.file.url)
self.assertNotContains(response, 'This should not appear in Forge Roofing gallery.')
self.assertNotContains(response, 'Draft cards should stay private.')
def test_workspace_proof_detail_renders_uploaded_media(self):
self.client.force_login(self.user)
response = self.client.get(reverse('proof_card_detail', args=[self.proof_card.id]))
self.assertEqual(response.status_code, 200)
self.assertContains(response, self.job.before_media.file.url)
self.assertContains(response, self.job.after_media.file.url)
def test_public_proof_detail_requires_published_status(self):
response = self.client.get(reverse('public_proof_detail', args=[self.business.slug, self.proof_card.id]))
self.assertEqual(response.status_code, 404)
self.proof_card.status = ProofCard.Status.PUBLISHED
self.proof_card.testimonial_quote = 'Proof card is now public.'
self.proof_card.save(update_fields=['status', 'testimonial_quote'])
response = self.client.get(reverse('public_proof_detail', args=[self.business.slug, self.proof_card.id]))
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'Proof card is now public.')
self.assertContains(response, self.job.before_media.file.url)
self.assertContains(response, self.job.after_media.file.url)

View File

@ -1,7 +1,51 @@
from django.contrib.auth.views import LogoutView
from django.urls import path from django.urls import path
from .views import home from .views import (
TrustForgeLoginView,
TrustForgePasswordResetCompleteView,
TrustForgePasswordResetConfirmView,
TrustForgePasswordResetDoneView,
TrustForgePasswordResetView,
business_onboarding,
dashboard,
home,
job_create,
job_detail,
jobs_list,
profile_settings,
proof_card_detail,
proof_card_edit,
proof_cards_list,
public_proof_detail,
public_proof_gallery,
review_request_view,
signup,
switch_workspace,
workspace_settings,
)
urlpatterns = [ urlpatterns = [
path("", home, name="home"), path('', home, name='home'),
path('login/', TrustForgeLoginView.as_view(), name='login'),
path('signup/', signup, name='signup'),
path('logout/', LogoutView.as_view(), name='logout'),
path('forgot-password/', TrustForgePasswordResetView.as_view(), name='password_reset'),
path('forgot-password/sent/', TrustForgePasswordResetDoneView.as_view(), name='password_reset_done'),
path('reset-password/<uidb64>/<token>/', TrustForgePasswordResetConfirmView.as_view(), name='password_reset_confirm'),
path('reset-password/complete/', TrustForgePasswordResetCompleteView.as_view(), name='password_reset_complete'),
path('onboarding/business/', business_onboarding, name='business_onboarding'),
path('workspace/<int:business_id>/switch/', switch_workspace, name='switch_workspace'),
path('workspace/settings/', workspace_settings, name='workspace_settings'),
path('profile/', profile_settings, name='profile_settings'),
path('dashboard/', dashboard, name='dashboard'),
path('jobs/', jobs_list, name='jobs_list'),
path('jobs/new/', job_create, name='job_create'),
path('jobs/<int:job_id>/', job_detail, name='job_detail'),
path('proof-cards/', proof_cards_list, name='proof_cards_list'),
path('proof-cards/<int:card_id>/', proof_card_detail, name='proof_card_detail'),
path('proof-cards/<int:card_id>/edit/', proof_card_edit, name='proof_card_edit'),
path('proof/<slug:slug>/', public_proof_gallery, name='public_proof_gallery'),
path('proof/<slug:slug>/<int:card_id>/', public_proof_detail, name='public_proof_detail'),
path('reviews/<uuid:token>/', review_request_view, name='review_request'),
] ]

View File

@ -1,25 +1,729 @@
from __future__ import annotations
import os import os
import platform import platform
from functools import wraps
from django import get_version as django_version from django import get_version as django_version
from django.shortcuts import render from django.contrib import messages
from django.contrib.auth import get_user_model, login
from django.contrib.auth.decorators import login_required
from django.contrib.auth.views import (
LoginView,
PasswordResetCompleteView,
PasswordResetConfirmView,
PasswordResetDoneView,
PasswordResetView,
)
from django.core.exceptions import PermissionDenied
from django.core.mail import send_mail
from django.db import transaction
from django.db.models import Count, Q
from django.http import HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse, reverse_lazy
from django.utils import timezone from django.utils import timezone
from django.utils.text import slugify
from .forms import (
BusinessOnboardingForm,
BusinessSettingsForm,
JobIntakeForm,
ProfileSettingsForm,
ProofCardForm,
PublicFeedbackForm,
SignUpForm,
TeamMemberInviteForm,
TrustForgeAuthenticationForm,
TrustForgePasswordResetForm,
TrustForgeSetPasswordForm,
)
from .models import Business, BusinessMembership, Customer, Feedback, Job, JobMedia, ProofCard, ReviewRequest
User = get_user_model()
ACTIVE_BUSINESS_SESSION_KEY = 'trustforge_active_business_id'
POSITIVE_EXPERIENCES = {Feedback.Experience.GREAT, Feedback.Experience.GOOD}
RATING_MAP = {
Feedback.Experience.GREAT: 5,
Feedback.Experience.GOOD: 4,
Feedback.Experience.OKAY: 3,
Feedback.Experience.BAD: 2,
}
def home(request): def _theme_context() -> dict:
"""Render the landing screen with loader and environment details.""" return {
host_name = request.get_host().lower() 'project_name': 'TrustForge',
agent_brand = "AppWizzy" if host_name == "appwizzy.com" else "Flatlogic" 'project_description': (
now = timezone.now() 'TrustForge turns completed service jobs into proof cards, testimonials, and conversion assets '
'for contractors, HVAC teams, roofers, plumbers, and local service businesses.'
),
'project_image_url': os.getenv('PROJECT_IMAGE_URL', ''),
}
class ThemedAuthContextMixin:
auth_page_title = 'TrustForge Account'
auth_page_description = 'Secure access to your proof pipeline, review engine, and published proof assets.'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context.update(_theme_context())
context.setdefault('auth_page_title', self.auth_page_title)
context.setdefault('auth_page_description', self.auth_page_description)
return context
class TrustForgeLoginView(ThemedAuthContextMixin, LoginView):
template_name = 'registration/login.html'
authentication_form = TrustForgeAuthenticationForm
redirect_authenticated_user = True
auth_page_title = 'Welcome back to your trust engine'
auth_page_description = 'Sign in to manage completed jobs, review requests, proof cards, and every asset that helps the next customer say yes.'
def get_success_url(self):
redirect_url = self.get_redirect_url()
if redirect_url:
return redirect_url
if _get_active_membership(self.request) is None:
return reverse('business_onboarding')
return reverse('dashboard')
class TrustForgePasswordResetView(ThemedAuthContextMixin, PasswordResetView):
template_name = 'registration/password_reset_form.html'
email_template_name = 'registration/password_reset_email.txt'
subject_template_name = 'registration/password_reset_subject.txt'
success_url = reverse_lazy('password_reset_done')
form_class = TrustForgePasswordResetForm
auth_page_title = 'Reset your TrustForge password'
auth_page_description = 'Enter your work email and we will send a secure reset link so you can get back into your proof pipeline.'
class TrustForgePasswordResetDoneView(ThemedAuthContextMixin, PasswordResetDoneView):
template_name = 'registration/password_reset_done.html'
auth_page_title = 'Check your email'
auth_page_description = 'If that email is tied to an account, a secure reset link is on its way.'
class TrustForgePasswordResetConfirmView(ThemedAuthContextMixin, PasswordResetConfirmView):
template_name = 'registration/password_reset_confirm.html'
form_class = TrustForgeSetPasswordForm
success_url = reverse_lazy('password_reset_complete')
auth_page_title = 'Create a new password'
auth_page_description = 'Set a new password for your account and return to the TrustForge dashboard securely.'
class TrustForgePasswordResetCompleteView(ThemedAuthContextMixin, PasswordResetCompleteView):
template_name = 'registration/password_reset_complete.html'
auth_page_title = 'Password updated'
auth_page_description = 'Your password has been changed successfully. You can sign back into TrustForge now.'
def _get_memberships_queryset(user):
return BusinessMembership.objects.select_related('business').filter(user=user, business__is_active=True)
def _get_user_memberships(request: HttpRequest) -> list[BusinessMembership]:
if not request.user.is_authenticated:
return []
cached = getattr(request, '_trustforge_memberships', None)
if cached is None:
cached = list(_get_memberships_queryset(request.user))
request._trustforge_memberships = cached
return cached
def _get_active_membership(request: HttpRequest) -> BusinessMembership | None:
if not request.user.is_authenticated:
return None
cached = getattr(request, '_trustforge_active_membership', None)
if cached is not None:
return cached
memberships = _get_user_memberships(request)
active_business_id = request.session.get(ACTIVE_BUSINESS_SESSION_KEY)
membership = next((item for item in memberships if item.business_id == active_business_id), None)
if membership is None and memberships:
membership = memberships[0]
request._trustforge_active_membership = membership
return membership
def _set_active_membership(request: HttpRequest, business_id: int) -> None:
request.session[ACTIVE_BUSINESS_SESSION_KEY] = business_id
request._trustforge_active_membership = None
def _generate_unique_business_slug(name: str) -> str:
base_slug = slugify(name)[:45] or 'business'
candidate = base_slug
counter = 2
while Business.objects.filter(slug=candidate).exists():
candidate = f'{base_slug}-{counter}'[:55]
counter += 1
return candidate
def business_required(view_func):
@wraps(view_func)
def wrapped(request: HttpRequest, *args, **kwargs):
if _get_active_membership(request) is None:
messages.info(request, 'Create or join a business workspace to unlock your protected TrustForge pipeline.')
return redirect('business_onboarding')
return view_func(request, *args, **kwargs)
return wrapped
def membership_role_required(*allowed_roles: str):
def decorator(view_func):
@wraps(view_func)
def wrapped(request: HttpRequest, *args, **kwargs):
membership = _get_active_membership(request)
if membership is None:
messages.info(request, 'Create or join a business workspace to continue.')
return redirect('business_onboarding')
if membership.role not in allowed_roles:
raise PermissionDenied('Your role does not allow this action in the current workspace.')
return view_func(request, *args, **kwargs)
return wrapped
return decorator
def _build_review_link(request: HttpRequest, review_request: ReviewRequest) -> str:
return request.build_absolute_uri(reverse('review_request', args=[str(review_request.token)]))
@transaction.atomic
def _create_review_request(request: HttpRequest, job: Job, channel: str) -> ReviewRequest:
review_request, created = ReviewRequest.objects.get_or_create(
job=job,
defaults={
'channel': channel,
'status': ReviewRequest.Status.SENT,
},
)
if created:
review_request.delivery_note = 'Share this link manually from the field app.'
if channel == ReviewRequest.Channel.EMAIL and job.customer.email:
review_link = _build_review_link(request, review_request)
email_sent = send_mail(
subject=f'How was your {job.service_type.lower()} experience?',
message=(
f'Hi {job.customer.full_name}\n\n'
f'Thanks for choosing {job.business.name}. Please share your feedback here: {review_link}\n\n'
'Your response helps us build verified proof of work for future customers.'
),
from_email=None,
recipient_list=[job.customer.email],
fail_silently=True,
)
review_request.delivery_note = 'Email sent automatically.' if email_sent else 'Email backend unavailable — copy link manually.'
review_request.status = ReviewRequest.Status.SENT
review_request.sent_at = timezone.now()
review_request.save()
job.status = Job.Status.REVIEW_REQUESTED
job.save(update_fields=['status'])
return review_request
@transaction.atomic
def signup(request: HttpRequest) -> HttpResponse:
if request.user.is_authenticated:
return redirect('dashboard' if _get_active_membership(request) else 'business_onboarding')
if request.method == 'POST':
form = SignUpForm(request.POST)
if form.is_valid():
user = form.save()
login(request, user)
messages.success(request, 'Your TrustForge account is ready. Now lets create your first business workspace.')
return redirect('business_onboarding')
else:
form = SignUpForm()
context = { context = {
"project_name": "New Style", **_theme_context(),
"agent_brand": agent_brand, 'auth_page_title': 'Create your TrustForge account',
"django_version": django_version(), 'auth_page_description': 'Start with secure account access, then connect your business workspace, team roles, and protected proof pipeline.',
"python_version": platform.python_version(), 'form': form,
"current_time": now,
"host_name": host_name,
"project_description": os.getenv("PROJECT_DESCRIPTION", ""),
"project_image_url": os.getenv("PROJECT_IMAGE_URL", ""),
} }
return render(request, "core/index.html", context) return render(request, 'registration/signup.html', context)
@login_required
@transaction.atomic
def business_onboarding(request: HttpRequest) -> HttpResponse:
current_membership = _get_active_membership(request)
if current_membership is not None:
return redirect('dashboard')
if request.method == 'POST':
form = BusinessOnboardingForm(request.POST)
if form.is_valid():
business = form.save(commit=False)
business.slug = _generate_unique_business_slug(business.name)
business.save()
membership = BusinessMembership.objects.create(
business=business,
user=request.user,
role=BusinessMembership.Role.OWNER,
)
_set_active_membership(request, membership.business_id)
messages.success(request, 'Workspace created. Your jobs, proof cards, and reviews are now scoped to this business.')
return redirect('dashboard')
else:
form = BusinessOnboardingForm()
context = {
**_theme_context(),
'form': form,
}
return render(request, 'core/business_onboarding.html', context)
@login_required
@transaction.atomic
def switch_workspace(request: HttpRequest, business_id: int) -> HttpResponse:
membership = get_object_or_404(_get_memberships_queryset(request.user), business_id=business_id)
_set_active_membership(request, membership.business_id)
messages.success(request, f'Workspace switched to {membership.business.name}.')
next_url = request.POST.get('next') or reverse('dashboard')
return redirect(next_url)
@login_required
@transaction.atomic
def profile_settings(request: HttpRequest) -> HttpResponse:
current_membership = _get_active_membership(request)
memberships = _get_user_memberships(request)
if request.method == 'POST':
form = ProfileSettingsForm(request.POST, instance=request.user)
if form.is_valid():
form.save()
messages.success(request, 'Profile settings updated.')
return redirect('profile_settings')
else:
form = ProfileSettingsForm(instance=request.user)
context = {
**_theme_context(),
'form': form,
'current_membership': current_membership,
'memberships': memberships,
}
return render(request, 'core/profile_settings.html', context)
@login_required
@membership_role_required(BusinessMembership.Role.OWNER, BusinessMembership.Role.ADMIN)
@transaction.atomic
def workspace_settings(request: HttpRequest) -> HttpResponse:
current_membership = _get_active_membership(request)
business = current_membership.business
team_members = BusinessMembership.objects.select_related('user').filter(business=business).order_by('created_at', 'id')
if request.method == 'POST':
action = request.POST.get('action')
if action == 'update_business':
business_form = BusinessSettingsForm(request.POST, instance=business)
invite_form = TeamMemberInviteForm()
if business_form.is_valid():
business_form.save()
messages.success(request, 'Workspace settings updated.')
return redirect('workspace_settings')
elif action == 'invite_member':
business_form = BusinessSettingsForm(instance=business)
invite_form = TeamMemberInviteForm(request.POST)
if invite_form.is_valid():
email = invite_form.cleaned_data['email']
user, created = User.objects.get_or_create(
email=email,
defaults={
'username': email,
'email': email,
'first_name': invite_form.cleaned_data.get('first_name', '').strip(),
'last_name': invite_form.cleaned_data.get('last_name', '').strip(),
},
)
if created:
user.set_unusable_password()
user.save(update_fields=['password'])
else:
updated_fields = []
first_name = invite_form.cleaned_data.get('first_name', '').strip()
last_name = invite_form.cleaned_data.get('last_name', '').strip()
if first_name and not user.first_name:
user.first_name = first_name
updated_fields.append('first_name')
if last_name and not user.last_name:
user.last_name = last_name
updated_fields.append('last_name')
if updated_fields:
user.save(update_fields=updated_fields)
membership, membership_created = BusinessMembership.objects.update_or_create(
business=business,
user=user,
defaults={'role': invite_form.cleaned_data['role']},
)
if membership_created or created:
messages.success(request, 'Team member added. If this is a brand-new user, they can use “Forgot password” to set access.')
else:
messages.success(request, 'Team member role updated for this workspace.')
return redirect('workspace_settings')
else:
business_form = BusinessSettingsForm(instance=business)
invite_form = TeamMemberInviteForm()
else:
business_form = BusinessSettingsForm(instance=business)
invite_form = TeamMemberInviteForm()
context = {
**_theme_context(),
'business_form': business_form,
'invite_form': invite_form,
'current_membership': current_membership,
'team_members': team_members,
}
return render(request, 'core/workspace_settings.html', context)
@transaction.atomic
def home(request: HttpRequest) -> HttpResponse:
businesses = Business.objects.count()
stats = Job.objects.aggregate(
completed_jobs=Count('id'),
review_requests=Count('review_request'),
proof_cards=Count('proof_card'),
published_proof=Count('proof_card', filter=Q(proof_card__status=ProofCard.Status.PUBLISHED)),
)
featured_proofs = ProofCard.objects.select_related('job__customer', 'job__business').prefetch_related('job__media').filter(
is_featured=True,
status=ProofCard.Status.PUBLISHED,
job__business__is_active=True,
)[:3]
recent_jobs = Job.objects.select_related('customer', 'business').prefetch_related('media')[:4]
context = {
**_theme_context(),
'django_version': django_version(),
'python_version': platform.python_version(),
'current_time': timezone.now(),
'business_count': businesses,
'stats': stats,
'featured_proofs': featured_proofs,
'recent_jobs': recent_jobs,
}
return render(request, 'core/index.html', context)
@transaction.atomic
def public_proof_gallery(request: HttpRequest, slug: str) -> HttpResponse:
business = get_object_or_404(Business, slug=slug, is_active=True)
proof_cards = ProofCard.objects.select_related('job__customer', 'job__business').prefetch_related('job__media').filter(
job__business=business,
status=ProofCard.Status.PUBLISHED,
)
featured_proofs = proof_cards.filter(is_featured=True)[:3]
context = {
**_theme_context(),
'business': business,
'proof_cards': proof_cards,
'featured_proofs': featured_proofs,
}
return render(request, 'core/public_proof_gallery.html', context)
@transaction.atomic
def public_proof_detail(request: HttpRequest, slug: str, card_id: int) -> HttpResponse:
proof_card = get_object_or_404(
ProofCard.objects.select_related('job__customer', 'job__business').prefetch_related('job__media'),
id=card_id,
job__business__slug=slug,
job__business__is_active=True,
status=ProofCard.Status.PUBLISHED,
)
related_proofs = ProofCard.objects.select_related('job__customer', 'job__business').prefetch_related('job__media').filter(
job__business=proof_card.job.business,
status=ProofCard.Status.PUBLISHED,
).exclude(id=proof_card.id)[:3]
context = {
**_theme_context(),
'business': proof_card.job.business,
'proof_card': proof_card,
'related_proofs': related_proofs,
}
return render(request, 'core/public_proof_detail.html', context)
@login_required
@business_required
@transaction.atomic
def dashboard(request: HttpRequest) -> HttpResponse:
current_membership = _get_active_membership(request)
business = current_membership.business
jobs = Job.objects.filter(business=business)
stats = jobs.aggregate(
completed_jobs=Count('id'),
review_requests=Count('review_request'),
proof_cards=Count('proof_card'),
published_cards=Count('proof_card', filter=Q(proof_card__status=ProofCard.Status.PUBLISHED)),
)
feedback_qs = Feedback.objects.filter(review_request__job__business=business)
positive_feedback = feedback_qs.filter(experience__in=POSITIVE_EXPERIENCES).count()
total_feedback = feedback_qs.count()
conversion_rate = round((positive_feedback / total_feedback) * 100, 1) if total_feedback else 0
recent_jobs = jobs.select_related('customer', 'business').prefetch_related('media')[:5]
recent_proofs = ProofCard.objects.select_related('job__customer', 'job__business').prefetch_related('job__media').filter(job__business=business)[:4]
context = {
**_theme_context(),
'current_membership': current_membership,
'stats': stats,
'conversion_rate': conversion_rate,
'recent_jobs': recent_jobs,
'recent_proofs': recent_proofs,
}
return render(request, 'core/dashboard.html', context)
@login_required
@business_required
@transaction.atomic
def jobs_list(request: HttpRequest) -> HttpResponse:
current_membership = _get_active_membership(request)
jobs = Job.objects.select_related('customer', 'business').prefetch_related('media').filter(business=current_membership.business)
context = {**_theme_context(), 'jobs': jobs, 'current_membership': current_membership}
return render(request, 'core/jobs_list.html', context)
@login_required
@business_required
@transaction.atomic
def job_create(request: HttpRequest) -> HttpResponse:
current_membership = _get_active_membership(request)
business = current_membership.business
if request.method == 'POST':
form = JobIntakeForm(request.POST, request.FILES, business=business)
if form.is_valid():
customer = Customer.objects.create(
business=business,
full_name=form.cleaned_data['customer_name'],
email=form.cleaned_data['customer_email'],
phone=form.cleaned_data['customer_phone'],
city=form.cleaned_data['customer_city'],
state=form.cleaned_data['customer_state'],
)
job = Job.objects.create(
business=business,
customer=customer,
service_type=form.cleaned_data['service_type'],
description=form.cleaned_data['description'],
technician_name=form.cleaned_data['technician_name'],
city=form.cleaned_data['customer_city'],
state=form.cleaned_data['customer_state'],
completed_at=form.cleaned_data['completion_date'],
project_value=form.cleaned_data['project_value'],
status=Job.Status.COMPLETED,
)
for media_type, upload in (
(JobMedia.MediaType.BEFORE, form.cleaned_data.get('before_photo')),
(JobMedia.MediaType.AFTER, form.cleaned_data.get('after_photo')),
):
if upload:
JobMedia.objects.create(job=job, media_type=media_type, file=upload)
display_name = 'Verified homeowner' if form.cleaned_data['anonymize_customer'] else customer.full_name
ProofCard.objects.create(
job=job,
customer_display_name=display_name,
is_anonymized=form.cleaned_data['anonymize_customer'],
attached_widget_label='Homepage proof gallery',
attached_pages='Homepage, Service pages',
status=ProofCard.Status.DRAFT,
)
if form.cleaned_data['send_review_request']:
_create_review_request(request, job, form.cleaned_data['review_channel'])
messages.success(request, 'Job logged inside your workspace. Proof card drafted and ready for review workflow.')
return redirect('job_detail', job_id=job.id)
else:
form = JobIntakeForm(
business=business,
initial={
'business': business,
'customer_city': business.primary_city,
'customer_state': business.primary_state,
'technician_name': request.user.get_full_name(),
},
)
context = {**_theme_context(), 'form': form, 'current_membership': current_membership}
return render(request, 'core/job_form.html', context)
@login_required
@business_required
@transaction.atomic
def job_detail(request: HttpRequest, job_id: int) -> HttpResponse:
current_membership = _get_active_membership(request)
job = get_object_or_404(
Job.objects.select_related('customer', 'business', 'proof_card', 'review_request').prefetch_related('media'),
id=job_id,
business=current_membership.business,
)
if request.method == 'POST' and request.POST.get('action') == 'send_review_request':
channel = request.POST.get('channel', ReviewRequest.Channel.EMAIL)
review_request = _create_review_request(request, job, channel)
messages.success(request, f'Review request sent. Share link: {_build_review_link(request, review_request)}')
return redirect('job_detail', job_id=job.id)
context = {**_theme_context(), 'job': job, 'current_membership': current_membership}
return render(request, 'core/job_detail.html', context)
@login_required
@business_required
@transaction.atomic
def proof_cards_list(request: HttpRequest) -> HttpResponse:
current_membership = _get_active_membership(request)
proof_cards = ProofCard.objects.select_related('job__customer', 'job__business').prefetch_related('job__media').filter(
job__business=current_membership.business
)
context = {**_theme_context(), 'proof_cards': proof_cards, 'current_membership': current_membership}
return render(request, 'core/proof_cards_list.html', context)
@login_required
@business_required
@transaction.atomic
def proof_card_detail(request: HttpRequest, card_id: int) -> HttpResponse:
current_membership = _get_active_membership(request)
proof_card = get_object_or_404(
ProofCard.objects.select_related('job__customer', 'job__business').prefetch_related('job__media'),
id=card_id,
job__business=current_membership.business,
)
if request.method == 'POST':
if not current_membership.can_manage_proof:
raise PermissionDenied('Your role does not allow proof publishing controls.')
action = request.POST.get('action')
if action == 'publish':
proof_card.status = ProofCard.Status.PUBLISHED
proof_card.published_at = timezone.now()
proof_card.save(update_fields=['status', 'published_at', 'updated_at'])
proof_card.job.status = Job.Status.PROOF_READY
proof_card.job.save(update_fields=['status'])
messages.success(request, 'Proof card published to the trust gallery.')
elif action == 'hide':
proof_card.status = ProofCard.Status.HIDDEN
proof_card.save(update_fields=['status', 'updated_at'])
messages.success(request, 'Proof card hidden from public display.')
elif action == 'toggle_featured':
proof_card.is_featured = not proof_card.is_featured
proof_card.save(update_fields=['is_featured', 'updated_at'])
messages.success(request, 'Featured flag updated.')
return redirect('proof_card_detail', card_id=proof_card.id)
context = {**_theme_context(), 'proof_card': proof_card, 'current_membership': current_membership}
return render(request, 'core/proof_card_detail.html', context)
@login_required
@membership_role_required(BusinessMembership.Role.OWNER, BusinessMembership.Role.ADMIN, BusinessMembership.Role.MANAGER)
@transaction.atomic
def proof_card_edit(request: HttpRequest, card_id: int) -> HttpResponse:
current_membership = _get_active_membership(request)
proof_card = get_object_or_404(
ProofCard.objects.select_related('job__customer', 'job__business'),
id=card_id,
job__business=current_membership.business,
)
if request.method == 'POST':
form = ProofCardForm(request.POST, instance=proof_card)
if form.is_valid():
proof_card = form.save(commit=False)
if proof_card.status == ProofCard.Status.PUBLISHED and not proof_card.published_at:
proof_card.published_at = timezone.now()
proof_card.save()
messages.success(request, 'Proof card updated.')
return redirect('proof_card_detail', card_id=proof_card.id)
else:
form = ProofCardForm(instance=proof_card)
context = {**_theme_context(), 'form': form, 'proof_card': proof_card, 'current_membership': current_membership}
return render(request, 'core/proof_card_form.html', context)
@transaction.atomic
def review_request_view(request: HttpRequest, token: str) -> HttpResponse:
review_request = get_object_or_404(
ReviewRequest.objects.select_related('job__customer', 'job__business').prefetch_related('job__media'),
token=token,
)
proof_card = review_request.job.proof_card
if review_request.status == ReviewRequest.Status.SENT:
review_request.status = ReviewRequest.Status.VIEWED
review_request.last_opened_at = timezone.now()
review_request.save(update_fields=['status', 'last_opened_at'])
submitted = False
positive = False
redirect_url = review_request.job.business.google_review_url
if request.method == 'POST':
form = PublicFeedbackForm(request.POST)
if form.is_valid():
experience = form.cleaned_data['experience']
testimonial = form.cleaned_data['testimonial'].strip()
positive = experience in POSITIVE_EXPERIENCES
feedback, _ = Feedback.objects.update_or_create(
review_request=review_request,
defaults={
'experience': experience,
'rating': RATING_MAP[experience],
'testimonial': testimonial,
'follow_up_required': not positive,
'is_public_approved': positive,
},
)
review_request.status = ReviewRequest.Status.RESPONDED
review_request.reviewed_at = timezone.now()
review_request.save(update_fields=['status', 'reviewed_at'])
proof_card.rating = feedback.rating
proof_card.testimonial_quote = testimonial
if positive:
proof_card.status = ProofCard.Status.PUBLISHED
proof_card.published_at = timezone.now()
else:
proof_card.status = ProofCard.Status.DRAFT
proof_card.save(update_fields=['rating', 'testimonial_quote', 'status', 'published_at', 'updated_at'])
review_request.job.status = Job.Status.PROOF_READY if positive else Job.Status.REVIEW_REQUESTED
review_request.job.save(update_fields=['status'])
submitted = True
form = PublicFeedbackForm()
else:
form = PublicFeedbackForm()
context = {
**_theme_context(),
'review_request': review_request,
'job': review_request.job,
'proof_card': proof_card,
'form': form,
'submitted': submitted,
'positive': positive,
'redirect_url': redirect_url,
}
return render(request, 'core/review_request.html', context)

View File

@ -1,4 +1,840 @@
/* Custom styles for the application */ /* TrustForge design system */
body { :root {
font-family: system-ui, -apple-system, sans-serif; --tf-bg: #f4f7f6;
--tf-surface: rgba(255, 255, 255, 0.82);
--tf-surface-strong: #ffffff;
--tf-surface-dark: #0f172a;
--tf-border: rgba(15, 23, 42, 0.08);
--tf-primary: #0f766e;
--tf-primary-deep: #115e59;
--tf-secondary: #1e293b;
--tf-accent: #f97316;
--tf-accent-soft: #fff1e8;
--tf-success: #15803d;
--tf-text: #0f172a;
--tf-muted: #64748b;
--tf-shadow: 0 20px 60px rgba(15, 23, 42, 0.10);
--tf-shadow-soft: 0 12px 32px rgba(15, 23, 42, 0.08);
--tf-radius-xl: 28px;
--tf-radius-lg: 22px;
--tf-radius-md: 16px;
--tf-spacing: 1.5rem;
}
html {
scroll-behavior: smooth;
}
body.trustforge-body {
font-family: 'Inter', system-ui, sans-serif;
color: var(--tf-text);
background:
radial-gradient(circle at top left, rgba(15, 118, 110, 0.14), transparent 35%),
radial-gradient(circle at top right, rgba(249, 115, 22, 0.12), transparent 28%),
linear-gradient(180deg, #fbfcfb 0%, var(--tf-bg) 100%);
min-height: 100vh;
position: relative;
}
h1, h2, h3, h4, h5, .navbar-brand, .tf-display, .tf-section-title, .tf-page-title {
font-family: 'Space Grotesk', 'Inter', sans-serif;
letter-spacing: -0.03em;
}
a {
color: inherit;
text-decoration: none;
}
a:hover {
color: inherit;
}
.tf-background-glow {
position: fixed;
border-radius: 999px;
filter: blur(60px);
pointer-events: none;
z-index: 0;
opacity: 0.85;
}
.tf-background-glow-1 {
width: 320px;
height: 320px;
background: rgba(15, 118, 110, 0.12);
top: 10%;
left: -4%;
}
.tf-background-glow-2 {
width: 360px;
height: 360px;
background: rgba(249, 115, 22, 0.10);
right: -6%;
top: 12%;
}
main, .tf-site-header, .tf-footer {
position: relative;
z-index: 1;
}
.py-lg-6 {
padding-top: 5rem !important;
padding-bottom: 5rem !important;
}
.tf-navbar {
background: rgba(255, 255, 255, 0.74);
backdrop-filter: blur(18px);
border-bottom: 1px solid rgba(255, 255, 255, 0.68);
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.4);
}
.tf-brand {
display: inline-flex;
align-items: center;
gap: 0.8rem;
font-weight: 700;
color: var(--tf-secondary);
}
.tf-brand-mark {
width: 42px;
height: 42px;
border-radius: 14px;
display: inline-flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--tf-primary), #2dd4bf);
color: white;
box-shadow: 0 12px 30px rgba(15, 118, 110, 0.28);
}
.tf-nav-toggle {
border: 0;
}
.tf-navbar .nav-link {
color: var(--tf-muted);
font-weight: 600;
padding: 0.6rem 0.95rem;
border-radius: 999px;
}
.tf-navbar .nav-link:hover,
.tf-navbar .nav-link:focus {
background: rgba(15, 118, 110, 0.08);
color: var(--tf-primary-deep);
}
.tf-btn {
border-radius: 999px;
padding: 0.85rem 1.35rem;
font-weight: 700;
border: 0;
box-shadow: var(--tf-shadow-soft);
transition: transform 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
}
.tf-btn:hover,
.tf-btn:focus {
transform: translateY(-1px);
box-shadow: 0 16px 32px rgba(15, 23, 42, 0.12);
}
.tf-btn-primary {
background: linear-gradient(135deg, var(--tf-primary), #14b8a6);
color: #fff;
}
.tf-btn-primary:hover,
.tf-btn-primary:focus {
color: #fff;
}
.tf-btn-secondary {
background: rgba(255, 255, 255, 0.72);
color: var(--tf-secondary);
border: 1px solid rgba(15, 23, 42, 0.08);
}
.tf-alert {
border-radius: 18px;
border: 1px solid rgba(15, 118, 110, 0.12);
background: rgba(236, 253, 245, 0.88);
color: var(--tf-primary-deep);
box-shadow: var(--tf-shadow-soft);
}
.tf-hero-section {
overflow: hidden;
}
.tf-eyebrow {
display: inline-flex;
align-items: center;
gap: 0.5rem;
border-radius: 999px;
background: rgba(15, 118, 110, 0.08);
color: var(--tf-primary-deep);
padding: 0.5rem 0.95rem;
font-size: 0.82rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.tf-eyebrow-light {
background: rgba(255, 255, 255, 0.08);
color: #cbd5e1;
}
.tf-display {
font-size: clamp(2.75rem, 7vw, 5.2rem);
line-height: 0.98;
max-width: 13ch;
}
.tf-lead,
.tf-page-subtitle {
color: var(--tf-muted);
font-size: 1.08rem;
line-height: 1.75;
max-width: 62ch;
}
.tf-page-title {
font-size: clamp(2rem, 4vw, 3.3rem);
margin-bottom: 0.75rem;
}
.tf-section-title {
font-size: clamp(1.75rem, 3.2vw, 2.6rem);
margin-bottom: 0;
}
.tf-stat-row span,
.tf-metric-card span {
display: block;
font-family: 'Space Grotesk', sans-serif;
font-size: 2rem;
line-height: 1;
color: var(--tf-secondary);
margin-bottom: 0.35rem;
}
.tf-stat-chip,
.tf-metric-card {
background: rgba(255, 255, 255, 0.72);
border: 1px solid rgba(255, 255, 255, 0.7);
border-radius: var(--tf-radius-md);
padding: 1.1rem 1rem;
box-shadow: var(--tf-shadow-soft);
color: var(--tf-muted);
}
.tf-device-card,
.tf-panel,
.tf-proof-card,
.tf-empty-state {
background: var(--tf-surface);
backdrop-filter: blur(18px);
border: 1px solid rgba(255, 255, 255, 0.75);
border-radius: var(--tf-radius-xl);
box-shadow: var(--tf-shadow);
}
.tf-device-card {
padding: 1.25rem;
position: relative;
overflow: hidden;
}
.tf-device-card::after {
content: '';
position: absolute;
width: 160px;
height: 160px;
background: linear-gradient(135deg, rgba(249, 115, 22, 0.18), transparent);
border-radius: 36px;
right: -30px;
bottom: -40px;
transform: rotate(18deg);
}
.tf-device-header {
display: flex;
gap: 0.45rem;
margin-bottom: 1rem;
}
.tf-device-header span {
width: 12px;
height: 12px;
border-radius: 999px;
background: rgba(15, 23, 42, 0.16);
}
.tf-device-body {
position: relative;
z-index: 1;
}
.tf-mini-step,
.tf-activity-row,
.tf-proof-mini {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
padding: 1rem 1.1rem;
border-radius: 18px;
background: rgba(255, 255, 255, 0.72);
border: 1px solid rgba(15, 23, 42, 0.06);
}
.tf-mini-step.active {
color: var(--tf-secondary);
font-weight: 700;
}
.tf-proof-preview,
.tf-panel {
padding: 1.5rem;
}
.tf-panel-dark {
background: linear-gradient(135deg, #0f172a, #1e293b);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.tf-panel-icon {
width: 52px;
height: 52px;
border-radius: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(15, 118, 110, 0.10);
margin-bottom: 1rem;
font-size: 1.25rem;
}
.tf-proof-media-grid,
.tf-proof-media-grid-large,
.tf-proof-media-grid-static {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.85rem;
}
.tf-proof-media-grid-static {
grid-template-columns: 1fr;
}
.tf-photo-slot {
position: relative;
isolation: isolate;
overflow: hidden;
min-height: 170px;
border-radius: 20px;
background:
linear-gradient(180deg, rgba(255,255,255,0.08), rgba(255,255,255,0)),
linear-gradient(135deg, rgba(15, 23, 42, 0.88), rgba(15, 118, 110, 0.82));
color: rgba(255,255,255,0.92);
font-weight: 700;
display: flex;
align-items: end;
justify-content: start;
padding: 1rem;
box-shadow: inset 0 1px 0 rgba(255,255,255,0.10);
}
.tf-photo-slot::before {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgba(15, 23, 42, 0.08), rgba(15, 23, 42, 0.56));
z-index: 1;
}
.tf-photo-slot-after {
background: linear-gradient(135deg, rgba(15, 118, 110, 0.94), rgba(45, 212, 191, 0.86));
}
.tf-photo-slot-has-media {
background: rgba(15, 23, 42, 0.95);
}
.tf-photo-image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
z-index: 0;
}
.tf-photo-label {
position: relative;
z-index: 2;
display: inline-flex;
align-items: center;
padding: 0.5rem 0.8rem;
border-radius: 999px;
background: rgba(15, 23, 42, 0.42);
backdrop-filter: blur(8px);
}
.tf-empty-proof {
border-radius: 22px;
background: rgba(248, 250, 252, 0.72);
padding: 1rem;
}
.tf-proof-card-link {
display: block;
height: 100%;
}
.tf-proof-card {
overflow: hidden;
}
.tf-proof-card-large .tf-proof-media-grid-large .tf-photo-slot {
min-height: 240px;
}
.tf-proof-card-body {
padding: 1.35rem;
}
.tf-card-tag {
display: inline-flex;
align-items: center;
padding: 0.45rem 0.8rem;
border-radius: 999px;
background: rgba(15, 118, 110, 0.08);
color: var(--tf-primary-deep);
font-size: 0.85rem;
font-weight: 700;
}
.tf-badge-verified,
.tf-rating-pill {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.42rem 0.75rem;
border-radius: 999px;
font-size: 0.78rem;
font-weight: 800;
}
.tf-badge-verified {
background: rgba(21, 128, 61, 0.10);
color: var(--tf-success);
}
.tf-rating-pill {
background: var(--tf-accent-soft);
color: var(--tf-accent);
}
.tf-status-pill {
display: inline-flex;
align-items: center;
gap: 0.4rem;
border-radius: 999px;
padding: 0.4rem 0.7rem;
font-size: 0.78rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.tf-status-completed,
.tf-status-draft {
background: rgba(15, 23, 42, 0.08);
color: var(--tf-secondary);
}
.tf-status-review_requested,
.tf-status-sent,
.tf-status-viewed {
background: rgba(249, 115, 22, 0.12);
color: var(--tf-accent);
}
.tf-status-proof_ready,
.tf-status-published,
.tf-status-responded {
background: rgba(21, 128, 61, 0.12);
color: var(--tf-success);
}
.tf-status-hidden {
background: rgba(100, 116, 139, 0.12);
color: var(--tf-muted);
}
.tf-inline-link {
color: var(--tf-primary-deep);
font-weight: 700;
}
.tf-inline-link:hover,
.tf-inline-link:focus {
color: var(--tf-primary);
}
.tf-activity-row-soft {
background: rgba(248, 250, 252, 0.9);
}
.tf-activity-link,
.tf-proof-mini {
color: inherit;
}
.tf-proof-mini {
text-decoration: none;
}
.tf-table thead th {
border-bottom-width: 0;
color: var(--tf-muted);
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 1.1rem 1.25rem;
}
.tf-table tbody td {
padding: 1.1rem 1.25rem;
border-color: rgba(15, 23, 42, 0.06);
}
.tf-detail-box {
background: rgba(248, 250, 252, 0.92);
border-radius: 18px;
padding: 1rem;
border: 1px solid rgba(15, 23, 42, 0.06);
}
.tf-detail-box span {
display: block;
color: var(--tf-muted);
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 0.35rem;
}
.tf-detail-box strong {
display: block;
font-size: 1rem;
}
.tf-check-row {
display: flex;
gap: 0.9rem;
align-items: center;
font-weight: 600;
}
.tf-check-row span {
width: 34px;
height: 34px;
border-radius: 12px;
background: rgba(15, 118, 110, 0.12);
color: var(--tf-primary-deep);
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: 800;
}
.tf-check-field {
background: rgba(248, 250, 252, 0.92);
padding: 1rem;
border-radius: 18px;
border: 1px solid rgba(15, 23, 42, 0.06);
}
.tf-sticky-panel {
top: 6rem;
}
.tf-feedback-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
.tf-feedback-option {
position: relative;
display: flex;
align-items: center;
justify-content: center;
min-height: 96px;
border-radius: 22px;
background: rgba(248, 250, 252, 0.92);
border: 1px solid rgba(15, 23, 42, 0.06);
font-weight: 700;
cursor: pointer;
}
.tf-feedback-option input {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
}
.tf-feedback-option:has(input:checked) {
border-color: rgba(15, 118, 110, 0.55);
background: rgba(236, 253, 245, 0.95);
color: var(--tf-primary-deep);
box-shadow: 0 12px 28px rgba(15, 118, 110, 0.14);
}
.tf-testimonial {
font-size: 1.1rem;
line-height: 1.8;
color: var(--tf-secondary);
border-left: 4px solid rgba(15, 118, 110, 0.22);
padding-left: 1rem;
}
.tf-panel-centered {
padding: 2rem;
}
.tf-footer {
border-top: 1px solid rgba(15, 23, 42, 0.06);
}
.tf-footer-brand {
font-family: 'Space Grotesk', sans-serif;
font-size: 1.1rem;
font-weight: 700;
margin-bottom: 0.35rem;
}
.form-control,
.form-select {
min-height: 50px;
border-radius: 16px;
border-color: rgba(15, 23, 42, 0.10);
box-shadow: none;
}
textarea.form-control {
min-height: 120px;
}
.form-control:focus,
.form-select:focus,
.form-check-input:focus,
.btn:focus {
border-color: rgba(15, 118, 110, 0.4);
box-shadow: 0 0 0 0.25rem rgba(15, 118, 110, 0.15);
}
@media (max-width: 991.98px) {
.tf-display {
max-width: 100%;
}
.tf-feedback-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 767.98px) {
.tf-device-card,
.tf-panel,
.tf-proof-card,
.tf-empty-state {
border-radius: 22px;
}
.tf-proof-media-grid,
.tf-proof-media-grid-large {
grid-template-columns: 1fr;
}
.tf-photo-slot,
.tf-proof-card-large .tf-proof-media-grid-large .tf-photo-slot {
min-height: 180px;
}
}
.tf-user-menu {
border: 1px solid rgba(15, 23, 42, 0.06);
background: rgba(255, 255, 255, 0.78);
border-radius: 999px;
padding: 0.8rem 1rem;
box-shadow: var(--tf-shadow-soft);
color: var(--tf-secondary);
font-weight: 700;
}
.tf-user-menu:hover,
.tf-user-menu:focus {
color: var(--tf-primary-deep);
}
.tf-user-dropdown {
border-radius: 18px;
border: 1px solid rgba(15, 23, 42, 0.08);
box-shadow: var(--tf-shadow-soft);
padding-top: 0.5rem;
padding-bottom: 0.5rem;
}
.tf-user-dropdown .dropdown-item {
font-weight: 600;
color: var(--tf-secondary);
border-radius: 12px;
}
.tf-user-dropdown .dropdown-item:hover,
.tf-user-dropdown .dropdown-item:focus,
.tf-logout-link:hover,
.tf-logout-link:focus {
background: rgba(15, 118, 110, 0.08);
color: var(--tf-primary-deep);
}
.tf-logout-link {
background: transparent;
border: 0;
width: 100%;
text-align: left;
padding: 0.5rem 0.75rem;
}
.tf-auth-card {
max-width: 720px;
margin: 0 auto;
}
.tf-auth-points {
display: grid;
gap: 0.9rem;
}
@media (max-width: 991.98px) {
.tf-user-menu {
width: 100%;
justify-content: center;
}
}
.tf-workspace-chip {
display: inline-flex;
align-items: center;
gap: 0.85rem;
padding: 0.55rem 0.9rem;
border-radius: 999px;
background: rgba(15, 118, 110, 0.12);
border: 1px solid rgba(15, 118, 110, 0.16);
color: #0f3f3b;
}
.tf-workspace-chip strong,
.tf-dropdown-label strong {
display: block;
font-size: 0.92rem;
}
.tf-workspace-chip small,
.tf-dropdown-label span {
display: block;
color: rgba(30, 41, 59, 0.72);
font-size: 0.75rem;
}
.tf-workspace-chip-mark {
width: 2rem;
height: 2rem;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #0f766e, #14b8a6);
color: white;
font-size: 0.8rem;
font-weight: 700;
}
.tf-dropdown-label,
.tf-dropdown-section {
color: #0f172a;
font-size: 0.82rem;
}
.tf-workspace-switch {
border-radius: 0.85rem;
}
.tf-workspace-switch small {
display: block;
color: rgba(30, 41, 59, 0.7);
}
.tf-workspace-switch.active {
background: rgba(15, 118, 110, 0.08);
}
.tf-inline-stat {
padding: 0.9rem 1rem;
border-radius: 1rem;
background: rgba(15, 118, 110, 0.08);
border: 1px solid rgba(15, 118, 110, 0.15);
}
.tf-inline-stat span {
display: block;
font-size: 0.78rem;
color: rgba(30, 41, 59, 0.7);
}
.tf-inline-stat strong {
display: block;
color: #0f172a;
}
.tf-role-card,
.tf-team-member {
display: flex;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.1rem;
border-radius: 1rem;
background: rgba(255, 255, 255, 0.7);
border: 1px solid rgba(15, 118, 110, 0.12);
}
.tf-role-card {
flex-direction: column;
}
.tf-role-card span {
color: rgba(30, 41, 59, 0.76);
}
.tf-team-member-active {
box-shadow: 0 0 0 2px rgba(15, 118, 110, 0.18);
}
.tf-role-pill {
background: rgba(249, 115, 22, 0.14);
color: #9a3412;
} }

View File

@ -1,21 +1,840 @@
/* TrustForge design system */
:root { :root {
--bg-color-start: #6a11cb; --tf-bg: #f4f7f6;
--bg-color-end: #2575fc; --tf-surface: rgba(255, 255, 255, 0.82);
--text-color: #ffffff; --tf-surface-strong: #ffffff;
--card-bg-color: rgba(255, 255, 255, 0.01); --tf-surface-dark: #0f172a;
--card-border-color: rgba(255, 255, 255, 0.1); --tf-border: rgba(15, 23, 42, 0.08);
--tf-primary: #0f766e;
--tf-primary-deep: #115e59;
--tf-secondary: #1e293b;
--tf-accent: #f97316;
--tf-accent-soft: #fff1e8;
--tf-success: #15803d;
--tf-text: #0f172a;
--tf-muted: #64748b;
--tf-shadow: 0 20px 60px rgba(15, 23, 42, 0.10);
--tf-shadow-soft: 0 12px 32px rgba(15, 23, 42, 0.08);
--tf-radius-xl: 28px;
--tf-radius-lg: 22px;
--tf-radius-md: 16px;
--tf-spacing: 1.5rem;
} }
body {
margin: 0; html {
font-family: 'Inter', sans-serif; scroll-behavior: smooth;
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end)); }
color: var(--text-color);
display: flex; body.trustforge-body {
justify-content: center; font-family: 'Inter', system-ui, sans-serif;
align-items: center; color: var(--tf-text);
background:
radial-gradient(circle at top left, rgba(15, 118, 110, 0.14), transparent 35%),
radial-gradient(circle at top right, rgba(249, 115, 22, 0.12), transparent 28%),
linear-gradient(180deg, #fbfcfb 0%, var(--tf-bg) 100%);
min-height: 100vh; min-height: 100vh;
text-align: center;
overflow: hidden;
position: relative; position: relative;
} }
h1, h2, h3, h4, h5, .navbar-brand, .tf-display, .tf-section-title, .tf-page-title {
font-family: 'Space Grotesk', 'Inter', sans-serif;
letter-spacing: -0.03em;
}
a {
color: inherit;
text-decoration: none;
}
a:hover {
color: inherit;
}
.tf-background-glow {
position: fixed;
border-radius: 999px;
filter: blur(60px);
pointer-events: none;
z-index: 0;
opacity: 0.85;
}
.tf-background-glow-1 {
width: 320px;
height: 320px;
background: rgba(15, 118, 110, 0.12);
top: 10%;
left: -4%;
}
.tf-background-glow-2 {
width: 360px;
height: 360px;
background: rgba(249, 115, 22, 0.10);
right: -6%;
top: 12%;
}
main, .tf-site-header, .tf-footer {
position: relative;
z-index: 1;
}
.py-lg-6 {
padding-top: 5rem !important;
padding-bottom: 5rem !important;
}
.tf-navbar {
background: rgba(255, 255, 255, 0.74);
backdrop-filter: blur(18px);
border-bottom: 1px solid rgba(255, 255, 255, 0.68);
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.4);
}
.tf-brand {
display: inline-flex;
align-items: center;
gap: 0.8rem;
font-weight: 700;
color: var(--tf-secondary);
}
.tf-brand-mark {
width: 42px;
height: 42px;
border-radius: 14px;
display: inline-flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--tf-primary), #2dd4bf);
color: white;
box-shadow: 0 12px 30px rgba(15, 118, 110, 0.28);
}
.tf-nav-toggle {
border: 0;
}
.tf-navbar .nav-link {
color: var(--tf-muted);
font-weight: 600;
padding: 0.6rem 0.95rem;
border-radius: 999px;
}
.tf-navbar .nav-link:hover,
.tf-navbar .nav-link:focus {
background: rgba(15, 118, 110, 0.08);
color: var(--tf-primary-deep);
}
.tf-btn {
border-radius: 999px;
padding: 0.85rem 1.35rem;
font-weight: 700;
border: 0;
box-shadow: var(--tf-shadow-soft);
transition: transform 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
}
.tf-btn:hover,
.tf-btn:focus {
transform: translateY(-1px);
box-shadow: 0 16px 32px rgba(15, 23, 42, 0.12);
}
.tf-btn-primary {
background: linear-gradient(135deg, var(--tf-primary), #14b8a6);
color: #fff;
}
.tf-btn-primary:hover,
.tf-btn-primary:focus {
color: #fff;
}
.tf-btn-secondary {
background: rgba(255, 255, 255, 0.72);
color: var(--tf-secondary);
border: 1px solid rgba(15, 23, 42, 0.08);
}
.tf-alert {
border-radius: 18px;
border: 1px solid rgba(15, 118, 110, 0.12);
background: rgba(236, 253, 245, 0.88);
color: var(--tf-primary-deep);
box-shadow: var(--tf-shadow-soft);
}
.tf-hero-section {
overflow: hidden;
}
.tf-eyebrow {
display: inline-flex;
align-items: center;
gap: 0.5rem;
border-radius: 999px;
background: rgba(15, 118, 110, 0.08);
color: var(--tf-primary-deep);
padding: 0.5rem 0.95rem;
font-size: 0.82rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.tf-eyebrow-light {
background: rgba(255, 255, 255, 0.08);
color: #cbd5e1;
}
.tf-display {
font-size: clamp(2.75rem, 7vw, 5.2rem);
line-height: 0.98;
max-width: 13ch;
}
.tf-lead,
.tf-page-subtitle {
color: var(--tf-muted);
font-size: 1.08rem;
line-height: 1.75;
max-width: 62ch;
}
.tf-page-title {
font-size: clamp(2rem, 4vw, 3.3rem);
margin-bottom: 0.75rem;
}
.tf-section-title {
font-size: clamp(1.75rem, 3.2vw, 2.6rem);
margin-bottom: 0;
}
.tf-stat-row span,
.tf-metric-card span {
display: block;
font-family: 'Space Grotesk', sans-serif;
font-size: 2rem;
line-height: 1;
color: var(--tf-secondary);
margin-bottom: 0.35rem;
}
.tf-stat-chip,
.tf-metric-card {
background: rgba(255, 255, 255, 0.72);
border: 1px solid rgba(255, 255, 255, 0.7);
border-radius: var(--tf-radius-md);
padding: 1.1rem 1rem;
box-shadow: var(--tf-shadow-soft);
color: var(--tf-muted);
}
.tf-device-card,
.tf-panel,
.tf-proof-card,
.tf-empty-state {
background: var(--tf-surface);
backdrop-filter: blur(18px);
border: 1px solid rgba(255, 255, 255, 0.75);
border-radius: var(--tf-radius-xl);
box-shadow: var(--tf-shadow);
}
.tf-device-card {
padding: 1.25rem;
position: relative;
overflow: hidden;
}
.tf-device-card::after {
content: '';
position: absolute;
width: 160px;
height: 160px;
background: linear-gradient(135deg, rgba(249, 115, 22, 0.18), transparent);
border-radius: 36px;
right: -30px;
bottom: -40px;
transform: rotate(18deg);
}
.tf-device-header {
display: flex;
gap: 0.45rem;
margin-bottom: 1rem;
}
.tf-device-header span {
width: 12px;
height: 12px;
border-radius: 999px;
background: rgba(15, 23, 42, 0.16);
}
.tf-device-body {
position: relative;
z-index: 1;
}
.tf-mini-step,
.tf-activity-row,
.tf-proof-mini {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
padding: 1rem 1.1rem;
border-radius: 18px;
background: rgba(255, 255, 255, 0.72);
border: 1px solid rgba(15, 23, 42, 0.06);
}
.tf-mini-step.active {
color: var(--tf-secondary);
font-weight: 700;
}
.tf-proof-preview,
.tf-panel {
padding: 1.5rem;
}
.tf-panel-dark {
background: linear-gradient(135deg, #0f172a, #1e293b);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.tf-panel-icon {
width: 52px;
height: 52px;
border-radius: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(15, 118, 110, 0.10);
margin-bottom: 1rem;
font-size: 1.25rem;
}
.tf-proof-media-grid,
.tf-proof-media-grid-large,
.tf-proof-media-grid-static {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.85rem;
}
.tf-proof-media-grid-static {
grid-template-columns: 1fr;
}
.tf-photo-slot {
position: relative;
isolation: isolate;
overflow: hidden;
min-height: 170px;
border-radius: 20px;
background:
linear-gradient(180deg, rgba(255,255,255,0.08), rgba(255,255,255,0)),
linear-gradient(135deg, rgba(15, 23, 42, 0.88), rgba(15, 118, 110, 0.82));
color: rgba(255,255,255,0.92);
font-weight: 700;
display: flex;
align-items: end;
justify-content: start;
padding: 1rem;
box-shadow: inset 0 1px 0 rgba(255,255,255,0.10);
}
.tf-photo-slot::before {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgba(15, 23, 42, 0.08), rgba(15, 23, 42, 0.56));
z-index: 1;
}
.tf-photo-slot-after {
background: linear-gradient(135deg, rgba(15, 118, 110, 0.94), rgba(45, 212, 191, 0.86));
}
.tf-photo-slot-has-media {
background: rgba(15, 23, 42, 0.95);
}
.tf-photo-image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
z-index: 0;
}
.tf-photo-label {
position: relative;
z-index: 2;
display: inline-flex;
align-items: center;
padding: 0.5rem 0.8rem;
border-radius: 999px;
background: rgba(15, 23, 42, 0.42);
backdrop-filter: blur(8px);
}
.tf-empty-proof {
border-radius: 22px;
background: rgba(248, 250, 252, 0.72);
padding: 1rem;
}
.tf-proof-card-link {
display: block;
height: 100%;
}
.tf-proof-card {
overflow: hidden;
}
.tf-proof-card-large .tf-proof-media-grid-large .tf-photo-slot {
min-height: 240px;
}
.tf-proof-card-body {
padding: 1.35rem;
}
.tf-card-tag {
display: inline-flex;
align-items: center;
padding: 0.45rem 0.8rem;
border-radius: 999px;
background: rgba(15, 118, 110, 0.08);
color: var(--tf-primary-deep);
font-size: 0.85rem;
font-weight: 700;
}
.tf-badge-verified,
.tf-rating-pill {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.42rem 0.75rem;
border-radius: 999px;
font-size: 0.78rem;
font-weight: 800;
}
.tf-badge-verified {
background: rgba(21, 128, 61, 0.10);
color: var(--tf-success);
}
.tf-rating-pill {
background: var(--tf-accent-soft);
color: var(--tf-accent);
}
.tf-status-pill {
display: inline-flex;
align-items: center;
gap: 0.4rem;
border-radius: 999px;
padding: 0.4rem 0.7rem;
font-size: 0.78rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.tf-status-completed,
.tf-status-draft {
background: rgba(15, 23, 42, 0.08);
color: var(--tf-secondary);
}
.tf-status-review_requested,
.tf-status-sent,
.tf-status-viewed {
background: rgba(249, 115, 22, 0.12);
color: var(--tf-accent);
}
.tf-status-proof_ready,
.tf-status-published,
.tf-status-responded {
background: rgba(21, 128, 61, 0.12);
color: var(--tf-success);
}
.tf-status-hidden {
background: rgba(100, 116, 139, 0.12);
color: var(--tf-muted);
}
.tf-inline-link {
color: var(--tf-primary-deep);
font-weight: 700;
}
.tf-inline-link:hover,
.tf-inline-link:focus {
color: var(--tf-primary);
}
.tf-activity-row-soft {
background: rgba(248, 250, 252, 0.9);
}
.tf-activity-link,
.tf-proof-mini {
color: inherit;
}
.tf-proof-mini {
text-decoration: none;
}
.tf-table thead th {
border-bottom-width: 0;
color: var(--tf-muted);
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 1.1rem 1.25rem;
}
.tf-table tbody td {
padding: 1.1rem 1.25rem;
border-color: rgba(15, 23, 42, 0.06);
}
.tf-detail-box {
background: rgba(248, 250, 252, 0.92);
border-radius: 18px;
padding: 1rem;
border: 1px solid rgba(15, 23, 42, 0.06);
}
.tf-detail-box span {
display: block;
color: var(--tf-muted);
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 0.35rem;
}
.tf-detail-box strong {
display: block;
font-size: 1rem;
}
.tf-check-row {
display: flex;
gap: 0.9rem;
align-items: center;
font-weight: 600;
}
.tf-check-row span {
width: 34px;
height: 34px;
border-radius: 12px;
background: rgba(15, 118, 110, 0.12);
color: var(--tf-primary-deep);
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: 800;
}
.tf-check-field {
background: rgba(248, 250, 252, 0.92);
padding: 1rem;
border-radius: 18px;
border: 1px solid rgba(15, 23, 42, 0.06);
}
.tf-sticky-panel {
top: 6rem;
}
.tf-feedback-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
.tf-feedback-option {
position: relative;
display: flex;
align-items: center;
justify-content: center;
min-height: 96px;
border-radius: 22px;
background: rgba(248, 250, 252, 0.92);
border: 1px solid rgba(15, 23, 42, 0.06);
font-weight: 700;
cursor: pointer;
}
.tf-feedback-option input {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
}
.tf-feedback-option:has(input:checked) {
border-color: rgba(15, 118, 110, 0.55);
background: rgba(236, 253, 245, 0.95);
color: var(--tf-primary-deep);
box-shadow: 0 12px 28px rgba(15, 118, 110, 0.14);
}
.tf-testimonial {
font-size: 1.1rem;
line-height: 1.8;
color: var(--tf-secondary);
border-left: 4px solid rgba(15, 118, 110, 0.22);
padding-left: 1rem;
}
.tf-panel-centered {
padding: 2rem;
}
.tf-footer {
border-top: 1px solid rgba(15, 23, 42, 0.06);
}
.tf-footer-brand {
font-family: 'Space Grotesk', sans-serif;
font-size: 1.1rem;
font-weight: 700;
margin-bottom: 0.35rem;
}
.form-control,
.form-select {
min-height: 50px;
border-radius: 16px;
border-color: rgba(15, 23, 42, 0.10);
box-shadow: none;
}
textarea.form-control {
min-height: 120px;
}
.form-control:focus,
.form-select:focus,
.form-check-input:focus,
.btn:focus {
border-color: rgba(15, 118, 110, 0.4);
box-shadow: 0 0 0 0.25rem rgba(15, 118, 110, 0.15);
}
@media (max-width: 991.98px) {
.tf-display {
max-width: 100%;
}
.tf-feedback-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 767.98px) {
.tf-device-card,
.tf-panel,
.tf-proof-card,
.tf-empty-state {
border-radius: 22px;
}
.tf-proof-media-grid,
.tf-proof-media-grid-large {
grid-template-columns: 1fr;
}
.tf-photo-slot,
.tf-proof-card-large .tf-proof-media-grid-large .tf-photo-slot {
min-height: 180px;
}
}
.tf-user-menu {
border: 1px solid rgba(15, 23, 42, 0.06);
background: rgba(255, 255, 255, 0.78);
border-radius: 999px;
padding: 0.8rem 1rem;
box-shadow: var(--tf-shadow-soft);
color: var(--tf-secondary);
font-weight: 700;
}
.tf-user-menu:hover,
.tf-user-menu:focus {
color: var(--tf-primary-deep);
}
.tf-user-dropdown {
border-radius: 18px;
border: 1px solid rgba(15, 23, 42, 0.08);
box-shadow: var(--tf-shadow-soft);
padding-top: 0.5rem;
padding-bottom: 0.5rem;
}
.tf-user-dropdown .dropdown-item {
font-weight: 600;
color: var(--tf-secondary);
border-radius: 12px;
}
.tf-user-dropdown .dropdown-item:hover,
.tf-user-dropdown .dropdown-item:focus,
.tf-logout-link:hover,
.tf-logout-link:focus {
background: rgba(15, 118, 110, 0.08);
color: var(--tf-primary-deep);
}
.tf-logout-link {
background: transparent;
border: 0;
width: 100%;
text-align: left;
padding: 0.5rem 0.75rem;
}
.tf-auth-card {
max-width: 720px;
margin: 0 auto;
}
.tf-auth-points {
display: grid;
gap: 0.9rem;
}
@media (max-width: 991.98px) {
.tf-user-menu {
width: 100%;
justify-content: center;
}
}
.tf-workspace-chip {
display: inline-flex;
align-items: center;
gap: 0.85rem;
padding: 0.55rem 0.9rem;
border-radius: 999px;
background: rgba(15, 118, 110, 0.12);
border: 1px solid rgba(15, 118, 110, 0.16);
color: #0f3f3b;
}
.tf-workspace-chip strong,
.tf-dropdown-label strong {
display: block;
font-size: 0.92rem;
}
.tf-workspace-chip small,
.tf-dropdown-label span {
display: block;
color: rgba(30, 41, 59, 0.72);
font-size: 0.75rem;
}
.tf-workspace-chip-mark {
width: 2rem;
height: 2rem;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #0f766e, #14b8a6);
color: white;
font-size: 0.8rem;
font-weight: 700;
}
.tf-dropdown-label,
.tf-dropdown-section {
color: #0f172a;
font-size: 0.82rem;
}
.tf-workspace-switch {
border-radius: 0.85rem;
}
.tf-workspace-switch small {
display: block;
color: rgba(30, 41, 59, 0.7);
}
.tf-workspace-switch.active {
background: rgba(15, 118, 110, 0.08);
}
.tf-inline-stat {
padding: 0.9rem 1rem;
border-radius: 1rem;
background: rgba(15, 118, 110, 0.08);
border: 1px solid rgba(15, 118, 110, 0.15);
}
.tf-inline-stat span {
display: block;
font-size: 0.78rem;
color: rgba(30, 41, 59, 0.7);
}
.tf-inline-stat strong {
display: block;
color: #0f172a;
}
.tf-role-card,
.tf-team-member {
display: flex;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.1rem;
border-radius: 1rem;
background: rgba(255, 255, 255, 0.7);
border: 1px solid rgba(15, 118, 110, 0.12);
}
.tf-role-card {
flex-direction: column;
}
.tf-role-card span {
color: rgba(30, 41, 59, 0.76);
}
.tf-team-member-active {
box-shadow: 0 0 0 2px rgba(15, 118, 110, 0.18);
}
.tf-role-pill {
background: rgba(249, 115, 22, 0.14);
color: #9a3412;
}