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.
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
@ -15,38 +9,32 @@ import os
from dotenv import load_dotenv
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")
DEBUG = os.getenv("DJANGO_DEBUG", "true").lower() == "true"
SECRET_KEY = os.getenv('DJANGO_SECRET_KEY', 'change-me')
DEBUG = os.getenv('DJANGO_DEBUG', 'true').lower() == 'true'
ALLOWED_HOSTS = [
"127.0.0.1",
"localhost",
os.getenv("HOST_FQDN", ""),
'127.0.0.1',
'localhost',
os.getenv('HOST_FQDN', ''),
]
CSRF_TRUSTED_ORIGINS = [
origin for origin in [
os.getenv("HOST_FQDN", ""),
os.getenv("CSRF_TRUSTED_ORIGIN", "")
os.getenv('HOST_FQDN', ''),
os.getenv('CSRF_TRUSTED_ORIGIN', ''),
] if origin
]
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
]
# Cookies must always be HTTPS-only; SameSite=Lax keeps CSRF working behind the proxy.
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SESSION_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
SESSION_COOKIE_SAMESITE = 'None'
CSRF_COOKIE_SAMESITE = 'None'
INSTALLED_APPS = [
'django.contrib.admin',
@ -65,8 +53,6 @@ MIDDLEWARE = [
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
# Disable X-Frame-Options middleware to allow Flatlogic preview iframes.
# 'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
X_FRAME_OPTIONS = 'ALLOWALL'
@ -83,7 +69,6 @@ TEMPLATES = [
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'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',
],
},
@ -92,10 +77,6 @@ TEMPLATES = [
WSGI_APPLICATION = 'config.wsgi.application'
# Database
# https://docs.djangoproject.com/en/5.2/ref/settings/#databases
DATABASES = {
'default': {
'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 = [
{
'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.UserAttributeSimilarityValidator'},
{'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'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/5.2/howto/static-files/
STATIC_URL = 'static/'
# Collect static into a separate folder; avoid overlapping with STATICFILES_DIRS.
STATIC_ROOT = BASE_DIR / 'staticfiles'
STATICFILES_DIRS = [
BASE_DIR / 'static',
BASE_DIR / 'assets',
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_HOST = os.getenv("EMAIL_HOST", "127.0.0.1")
EMAIL_PORT = int(os.getenv("EMAIL_PORT", "587"))
EMAIL_HOST_USER = os.getenv("EMAIL_HOST_USER", "")
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")
EMAIL_BACKEND = os.getenv('EMAIL_BACKEND', 'django.core.mail.backends.smtp.EmailBackend')
EMAIL_HOST = os.getenv('EMAIL_HOST', '127.0.0.1')
EMAIL_PORT = int(os.getenv('EMAIL_PORT', '587'))
EMAIL_HOST_USER = os.getenv('EMAIL_HOST_USER', '')
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 = [
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()
]
# When both TLS and SSL flags are enabled, prefer SSL explicitly
if EMAIL_USE_SSL:
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'

View File

@ -1,29 +1,17 @@
"""
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.urls.static import static
from django.contrib import admin
from django.urls import include, path
urlpatterns = [
path("admin/", admin.site.urls),
path("", include("core.urls")),
path('admin/', admin.site.urls),
path('', include('core.urls')),
]
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.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
# 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 time
from .models import BusinessMembership
ACTIVE_BUSINESS_SESSION_KEY = 'trustforge_active_business_id'
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 {
"project_description": os.getenv("PROJECT_DESCRIPTION", ""),
"project_image_url": os.getenv("PROJECT_IMAGE_URL", ""),
# Used for cache-busting static assets
"deployment_timestamp": int(time.time()),
'project_description': os.getenv('PROJECT_DESCRIPTION', ''),
'project_image_url': os.getenv('PROJECT_IMAGE_URL', ''),
'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">
<head>
<meta charset="UTF-8">
<title>{% block title %}Knowledge Base{% endblock %}</title>
{% if project_description %}
<meta name="description" content="{{ project_description }}">
<meta property="og:description" content="{{ project_description }}">
<meta property="twitter:description" content="{{ project_description }}">
{% endif %}
{% if project_image_url %}
<meta property="og:image" content="{{ project_image_url }}">
<meta property="twitter:image" content="{{ project_image_url }}">
{% endif %}
{% load static %}
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}TrustForge{% endblock %}</title>
<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 name="author" content="TrustForge">
<meta name="keywords" content="proof cards, service business reviews, trust marketing, contractor testimonials, local service SaaS">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Space+Grotesk:wght@500;700&display=swap" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<link rel="stylesheet" href="{% static 'css/custom.css' %}?v={{ deployment_timestamp }}">
{% block head %}{% endblock %}
</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>
{% block content %}{% endblock %}
<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 %}
</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>

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" %}
{% block title %}{{ project_name }}{% endblock %}
{% block head %}
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet">
<style>
:root {
--bg-color-start: #6a11cb;
--bg-color-end: #2575fc;
--text-color: #ffffff;
--card-bg-color: rgba(255, 255, 255, 0.01);
--card-border-color: rgba(255, 255, 255, 0.1);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: 'Inter', sans-serif;
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
color: var(--text-color);
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
text-align: center;
overflow: hidden;
position: relative;
}
body::before {
content: '';
position: absolute;
inset: 0;
background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='100' height='100' viewBox='0 0 100 100'><path d='M-10 10L110 10M10 -10L10 110' stroke-width='1' stroke='rgba(255,255,255,0.05)'/></svg>");
animation: bg-pan 20s linear infinite;
z-index: -1;
}
@keyframes bg-pan {
0% {
background-position: 0% 0%;
}
100% {
background-position: 100% 100%;
}
}
main {
padding: 2rem;
}
.card {
background: var(--card-bg-color);
border: 1px solid var(--card-border-color);
border-radius: 16px;
padding: 2.5rem 2rem;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.25);
}
h1 {
font-size: clamp(2.2rem, 3vw + 1.2rem, 3.2rem);
font-weight: 700;
margin: 0 0 1.2rem;
letter-spacing: -0.02em;
}
p {
margin: 0.5rem 0;
font-size: 1.1rem;
opacity: 0.92;
}
.loader {
margin: 1.5rem auto;
width: 56px;
height: 56px;
border: 4px solid rgba(255, 255, 255, 0.25);
border-top-color: #fff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.runtime code {
background: rgba(0, 0, 0, 0.25);
padding: 0.15rem 0.45rem;
border-radius: 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
footer {
position: absolute;
bottom: 1rem;
width: 100%;
text-align: center;
font-size: 0.85rem;
opacity: 0.75;
}
</style>
{% endblock %}
{% block title %}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 content %}
<main>
<div class="card">
<h1>Analyzing your requirements and generating your app…</h1>
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes">
<span class="sr-only">Loading…</span>
<section class="tf-hero-section py-5 py-lg-6">
<div class="container py-lg-5">
<div class="row align-items-center g-5">
<div class="col-lg-6">
<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 class="row g-3 tf-stat-row">
<div class="col-6 col-md-3">
<div class="tf-stat-chip">
<span>{{ stats.completed_jobs|default:0 }}</span>
Jobs logged
</div>
</div>
<div class="col-6 col-md-3">
<div class="tf-stat-chip">
<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>
<p class="hint">AppWizzy AI is collecting your requirements and applying the first changes.</p>
<p class="hint">This page will refresh automatically as the plan is implemented.</p>
<p class="runtime">
Runtime: Django <code>{{ django_version }}</code> · Python <code>{{ python_version }}</code>
— UTC <code>{{ current_time|date:"Y-m-d H:i:s" }}</code>
</p>
</div>
</main>
<footer>
Page updated: {{ current_time|date:"Y-m-d H:i:s" }} (UTC)
</footer>
{% endblock %}
</section>
<section 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 %}

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.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 .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 = [
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 platform
from functools import wraps
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.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):
"""Render the landing screen with loader and environment details."""
host_name = request.get_host().lower()
agent_brand = "AppWizzy" if host_name == "appwizzy.com" else "Flatlogic"
now = timezone.now()
def _theme_context() -> dict:
return {
'project_name': 'TrustForge',
'project_description': (
'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 = {
"project_name": "New Style",
"agent_brand": agent_brand,
"django_version": django_version(),
"python_version": platform.python_version(),
"current_time": now,
"host_name": host_name,
"project_description": os.getenv("PROJECT_DESCRIPTION", ""),
"project_image_url": os.getenv("PROJECT_IMAGE_URL", ""),
**_theme_context(),
'auth_page_title': 'Create your TrustForge account',
'auth_page_description': 'Start with secure account access, then connect your business workspace, team roles, and protected proof pipeline.',
'form': form,
}
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 */
body {
font-family: system-ui, -apple-system, sans-serif;
/* TrustForge design system */
:root {
--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 {
--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);
--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;
}
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;
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;
text-align: center;
overflow: hidden;
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;
}