diff --git a/config/__pycache__/__init__.cpython-311.pyc b/config/__pycache__/__init__.cpython-311.pyc
index 896bb4f..3a859bf 100644
Binary files a/config/__pycache__/__init__.cpython-311.pyc and b/config/__pycache__/__init__.cpython-311.pyc differ
diff --git a/config/__pycache__/settings.cpython-311.pyc b/config/__pycache__/settings.cpython-311.pyc
index d79d6a7..480eac2 100644
Binary files a/config/__pycache__/settings.cpython-311.pyc and b/config/__pycache__/settings.cpython-311.pyc differ
diff --git a/config/__pycache__/urls.cpython-311.pyc b/config/__pycache__/urls.cpython-311.pyc
index 8cf22af..317ce2b 100644
Binary files a/config/__pycache__/urls.cpython-311.pyc and b/config/__pycache__/urls.cpython-311.pyc differ
diff --git a/config/__pycache__/wsgi.cpython-311.pyc b/config/__pycache__/wsgi.cpython-311.pyc
index a1b4aa7..c21cff0 100644
Binary files a/config/__pycache__/wsgi.cpython-311.pyc and b/config/__pycache__/wsgi.cpython-311.pyc differ
diff --git a/config/settings.py b/config/settings.py
index 291d043..7171816 100644
--- a/config/settings.py
+++ b/config/settings.py
@@ -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'
diff --git a/config/urls.py b/config/urls.py
index bcfc074..e09822d 100644
--- a/config/urls.py
+++ b/config/urls.py
@@ -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)
diff --git a/core/__pycache__/__init__.cpython-311.pyc b/core/__pycache__/__init__.cpython-311.pyc
index 3f553f6..c3387cb 100644
Binary files a/core/__pycache__/__init__.cpython-311.pyc and b/core/__pycache__/__init__.cpython-311.pyc differ
diff --git a/core/__pycache__/admin.cpython-311.pyc b/core/__pycache__/admin.cpython-311.pyc
index 5e8987a..61e8d17 100644
Binary files a/core/__pycache__/admin.cpython-311.pyc and b/core/__pycache__/admin.cpython-311.pyc differ
diff --git a/core/__pycache__/apps.cpython-311.pyc b/core/__pycache__/apps.cpython-311.pyc
index 2fa4a49..2af164d 100644
Binary files a/core/__pycache__/apps.cpython-311.pyc and b/core/__pycache__/apps.cpython-311.pyc differ
diff --git a/core/__pycache__/context_processors.cpython-311.pyc b/core/__pycache__/context_processors.cpython-311.pyc
index 75bf223..b09f643 100644
Binary files a/core/__pycache__/context_processors.cpython-311.pyc and b/core/__pycache__/context_processors.cpython-311.pyc differ
diff --git a/core/__pycache__/forms.cpython-311.pyc b/core/__pycache__/forms.cpython-311.pyc
new file mode 100644
index 0000000..725b63c
Binary files /dev/null and b/core/__pycache__/forms.cpython-311.pyc differ
diff --git a/core/__pycache__/models.cpython-311.pyc b/core/__pycache__/models.cpython-311.pyc
index a251b5f..5b26592 100644
Binary files a/core/__pycache__/models.cpython-311.pyc and b/core/__pycache__/models.cpython-311.pyc differ
diff --git a/core/__pycache__/tests.cpython-311.pyc b/core/__pycache__/tests.cpython-311.pyc
new file mode 100644
index 0000000..399ccbb
Binary files /dev/null and b/core/__pycache__/tests.cpython-311.pyc differ
diff --git a/core/__pycache__/urls.cpython-311.pyc b/core/__pycache__/urls.cpython-311.pyc
index f705988..70a5c84 100644
Binary files a/core/__pycache__/urls.cpython-311.pyc and b/core/__pycache__/urls.cpython-311.pyc differ
diff --git a/core/__pycache__/views.cpython-311.pyc b/core/__pycache__/views.cpython-311.pyc
index 2f0989c..be47382 100644
Binary files a/core/__pycache__/views.cpython-311.pyc and b/core/__pycache__/views.cpython-311.pyc differ
diff --git a/core/admin.py b/core/admin.py
index 8c38f3f..ff0a787 100644
--- a/core/admin.py
+++ b/core/admin.py
@@ -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')
diff --git a/core/context_processors.py b/core/context_processors.py
index 0bf87c3..f632765 100644
--- a/core/context_processors.py
+++ b/core/context_processors.py
@@ -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,
}
diff --git a/core/forms.py b/core/forms.py
new file mode 100644
index 0000000..89cd421
--- /dev/null
+++ b/core/forms.py
@@ -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}),
+ }
diff --git a/core/migrations/0001_initial.py b/core/migrations/0001_initial.py
new file mode 100644
index 0000000..6cf0ff7
--- /dev/null
+++ b/core/migrations/0001_initial.py
@@ -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'],
+ },
+ ),
+ ]
diff --git a/core/migrations/0002_seed_trustforge_demo.py b/core/migrations/0002_seed_trustforge_demo.py
new file mode 100644
index 0000000..21cc3d2
--- /dev/null
+++ b/core/migrations/0002_seed_trustforge_demo.py
@@ -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),
+ ]
diff --git a/core/migrations/0003_businessmembership.py b/core/migrations/0003_businessmembership.py
new file mode 100644
index 0000000..08b9b8b
--- /dev/null
+++ b/core/migrations/0003_businessmembership.py
@@ -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')},
+ },
+ ),
+ ]
diff --git a/core/migrations/__pycache__/0001_initial.cpython-311.pyc b/core/migrations/__pycache__/0001_initial.cpython-311.pyc
new file mode 100644
index 0000000..dd28f9b
Binary files /dev/null and b/core/migrations/__pycache__/0001_initial.cpython-311.pyc differ
diff --git a/core/migrations/__pycache__/0002_seed_trustforge_demo.cpython-311.pyc b/core/migrations/__pycache__/0002_seed_trustforge_demo.cpython-311.pyc
new file mode 100644
index 0000000..52b5b77
Binary files /dev/null and b/core/migrations/__pycache__/0002_seed_trustforge_demo.cpython-311.pyc differ
diff --git a/core/migrations/__pycache__/0003_businessmembership.cpython-311.pyc b/core/migrations/__pycache__/0003_businessmembership.cpython-311.pyc
new file mode 100644
index 0000000..55d6f78
Binary files /dev/null and b/core/migrations/__pycache__/0003_businessmembership.cpython-311.pyc differ
diff --git a/core/migrations/__pycache__/__init__.cpython-311.pyc b/core/migrations/__pycache__/__init__.cpython-311.pyc
index 7995815..fc6b5ce 100644
Binary files a/core/migrations/__pycache__/__init__.cpython-311.pyc and b/core/migrations/__pycache__/__init__.cpython-311.pyc differ
diff --git a/core/models.py b/core/models.py
index 71a8362..2feec86 100644
--- a/core/models.py
+++ b/core/models.py
@@ -1,3 +1,215 @@
-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}'
+
+ @property
+ def before_media(self):
+ return self.media.filter(media_type=JobMedia.MediaType.BEFORE).first()
+
+ @property
+ def after_media(self):
+ return self.media.filter(media_type=JobMedia.MediaType.AFTER).first()
+
+
+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'
diff --git a/core/templates/base.html b/core/templates/base.html
index 1e7e5fb..80cc10c 100644
--- a/core/templates/base.html
+++ b/core/templates/base.html
@@ -1,25 +1,133 @@
-
+{% load static %}
+
-
-
- {% block title %}Knowledge Base{% endblock %}
- {% if project_description %}
-
-
-
- {% endif %}
- {% if project_image_url %}
-
-
- {% endif %}
- {% load static %}
+
+
+ {% block title %}TrustForge{% endblock %}
+
+
+
+
+
+
+
{% block head %}{% endblock %}
+
+
+
-
- {% block content %}{% endblock %}
+
+
+ {% if messages %}
+
+ {% for message in messages %}
+
+ {{ message }}
+
+
+ {% endfor %}
+
+ {% endif %}
+
+
+ {% block content %}{% endblock %}
+
+
+
+
+
-
diff --git a/core/templates/core/business_onboarding.html b/core/templates/core/business_onboarding.html
new file mode 100644
index 0000000..6c624ae
--- /dev/null
+++ b/core/templates/core/business_onboarding.html
@@ -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 %}
+
+
+
+
+
+
Business onboarding
+
Create your first TrustForge workspace
+
This workspace becomes the protected home for your completed jobs, review requests, proof cards, and team permissions.
+
+
01 Create your business workspace
+
02 Become the owner automatically
+
03 Invite admins, managers, and technicians
+
04 Keep every job and proof asset scoped securely
+
+
+
+
+
+
+
+
Workspace details
+
Set up the business your team will operate inside
+
You can update branding details later from workspace settings. The trust pipeline and team access will use this business as the default scope.
+
+
+ Protected SaaS
+ Multi-tenant ready
+
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/core/templates/core/dashboard.html b/core/templates/core/dashboard.html
new file mode 100644
index 0000000..698cefe
--- /dev/null
+++ b/core/templates/core/dashboard.html
@@ -0,0 +1,83 @@
+{% 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 %}
+
+
+
+
+
Dashboard
+
{{ current_membership.business.name }} proof momentum
+
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.
+
+
+
+
+
+
{{ stats.completed_jobs|default:0 }} Jobs completed
+
{{ stats.review_requests|default:0 }} Review requests
+
{{ stats.proof_cards|default:0 }} Proof cards created
+
{{ conversion_rate }}% Positive response rate
+
+
+
+
+
+{% endblock %}
diff --git a/core/templates/core/index.html b/core/templates/core/index.html
index faec813..962013b 100644
--- a/core/templates/core/index.html
+++ b/core/templates/core/index.html
@@ -1,145 +1,195 @@
{% extends "base.html" %}
-{% block title %}{{ project_name }}{% endblock %}
-
-{% block head %}
-
-
-
-
-{% 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 %}
-
-
-
Analyzing your requirements and generating your app…
-
-
Loading…
+
+
+
+
+
Trust engine for service businesses
+
Every completed job becomes proof that closes the next one.
+
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.
+
+
+
+
+ {{ stats.completed_jobs|default:0 }}
+ Jobs logged
+
+
+
+
+ {{ stats.review_requests|default:0 }}
+ Requests sent
+
+
+
+
+ {{ stats.proof_cards|default:0 }}
+ Proof cards
+
+
+
+
+ {{ business_count }}
+ Active businesses
+
+
+
+
+
+
+
+
+
1. Job completed
+
2. Review requested
+
3. Proof created
+
+ {% if featured_proofs %}
+ {% with proof=featured_proofs.0 %}
+
+
+
+
{{ proof.job.service_type }}
+
{{ proof.job.city }}, {{ proof.job.state }}
+
Verified completion · {{ proof.job.completed_at|date:"M j, Y" }}
+
+
{% if proof.rating %}★ {{ proof.rating }}.0{% else %}Verified{% endif %}
+
+
{% 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 %}
+
+ {{ proof.customer_display_name }}
+ {{ proof.verified_label }}
+
+ {% endwith %}
+ {% else %}
+
+
+
Your first proof card appears here
+
Log a completed job to instantly generate the draft card, review request, and proof pipeline.
+
+ {% endif %}
+
+
+
+
-
AppWizzy AI is collecting your requirements and applying the first changes.
-
This page will refresh automatically as the plan is implemented.
-
- Runtime: Django {{ django_version }} · Python {{ python_version }}
- — UTC {{ current_time|date:"Y-m-d H:i:s" }}
-
-
-
- Page updated: {{ current_time|date:"Y-m-d H:i:s" }} (UTC)
-
-{% endblock %}
\ No newline at end of file
+
+
+
+
+
+
+
+
⚡
+
Fast field workflow
+
Capture a completed job, upload before/after photos, and send a review request from a mobile-friendly form designed for technicians on-site.
+
+
+
+
+
🛡️
+
Proof-first cards
+
Every job creates a premium proof card with service, location, photos, verification status, testimonial, and publishing controls.
+
+
+
+
+
📈
+
Conversion-ready assets
+
Feature standout work on your landing page, service pages, and proof gallery to give new customers visible confidence.
+
+
+
+
+
+
+
+
+
+
+
Featured proof
+
Recent conversion assets
+
+
Browse proof cards
+
+
+ {% for proof in featured_proofs %}
+
+ {% empty %}
+
+
+
No proof cards featured yet
+
Create your first completed job to populate the TrustForge proof gallery.
+
Create the first job
+
+
+ {% endfor %}
+
+
+
+
+
+
+
+
+
+
Pipeline
+
Job Completed → Review Requested → Proof Card Created → Displayed → Converts Next Customer
+
+
+
+ {% for job in recent_jobs %}
+
+
+
{{ job.service_type }}
+
{{ job.customer.full_name }} · {{ job.city }}, {{ job.state }}
+
+
{{ job.get_status_display }}
+
+ {% empty %}
+
No jobs yet. The first intake will immediately show up here.
+ {% endfor %}
+
+
+
+
+
+
+{% endblock %}
diff --git a/core/templates/core/job_detail.html b/core/templates/core/job_detail.html
new file mode 100644
index 0000000..ece481b
--- /dev/null
+++ b/core/templates/core/job_detail.html
@@ -0,0 +1,86 @@
+{% 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 %}
+
+
+
+
+
Job detail
+
{{ job.service_type }}
+
{{ job.customer.full_name }} · {{ job.city }}, {{ job.state }} · Completed {{ job.completed_at|date:"F j, Y" }}
+
+
+
+
+
+
+
+
+
Job summary
+ {{ job.get_status_display }}
+
+
+
Business {{ job.business.name }}
+
Technician {{ job.technician_name|default:'Not added yet' }}
+
Project value {% if job.project_value %}${{ job.project_value }}{% else %}Optional{% endif %}
+
Verified {% if job.is_verified %}Yes{% else %}Pending{% endif %}
+
+
+
Description
+
{{ job.description|default:'No extra description was added for this completed job.' }}
+
+
+
+
+
+
+
Review request
+ {% if job.review_request %}
+
Status {{ job.review_request.get_status_display }}
+
Channel {{ job.review_request.get_channel_display }}
+
Delivery note {{ job.review_request.delivery_note|default:'Ready to share' }}
+
Open customer review page
+ {% else %}
+
No review request has been sent for this job yet.
+
+ {% endif %}
+
+
+
+
Proof card
+
Status {{ job.proof_card.get_status_display }}
+
Display name {{ job.proof_card.customer_display_name }}
+
Widget target {{ job.proof_card.attached_widget_label|default:'Not set' }}
+
Manage proof card
+
+
+
+
+
+{% endblock %}
diff --git a/core/templates/core/job_form.html b/core/templates/core/job_form.html
new file mode 100644
index 0000000..1af689b
--- /dev/null
+++ b/core/templates/core/job_form.html
@@ -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 %}
+
+
+
+
+
+
Technician flow
+
Complete a job in under 30 seconds
+
You are logging this inside {{ current_membership.business.name }} . Add the customer, upload photos, and trigger a review request. TrustForge creates the proof card automatically.
+
+
01 Save the completed job
+
02 Draft proof card is generated
+
03 Review request is ready to share
+
04 Positive feedback can auto-publish proof
+
+
Workspace scope {{ current_membership.business.primary_city }}, {{ current_membership.business.primary_state }} · {{ current_membership.get_role_display }}
+
+
+
+
+
+
+
Job intake
+
Capture the completed work
+
Every field below feeds the trust pipeline: completed job → review request → proof card.
+
+
+ Business scope
+ {{ current_membership.business.name }}
+
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/core/templates/core/jobs_list.html b/core/templates/core/jobs_list.html
new file mode 100644
index 0000000..557fa68
--- /dev/null
+++ b/core/templates/core/jobs_list.html
@@ -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 %}
+
+
+
+
+
Jobs
+
{{ current_membership.business.name }} completed job pipeline
+
Every job below belongs only to this workspace and can lead to a proof card, published testimonial, and higher conversion confidence.
+
+
Add completed job
+
+
+
+ {% if jobs %}
+
+
+
+
+ Customer
+ Service
+ Location
+ Completed
+ Status
+
+
+
+
+ {% for job in jobs %}
+
+
+ {{ job.customer.full_name }}
+ {{ job.technician_name|default:'Technician not added yet' }}
+
+ {{ job.service_type }}
+ {{ job.city }}, {{ job.state }}
+ {{ job.completed_at|date:"M j, Y" }}
+ {{ job.get_status_display }}
+ Open
+
+ {% endfor %}
+
+
+
+ {% else %}
+
+
No jobs completed yet
+
Start with one field intake and TrustForge will spin up the proof workflow automatically for this business.
+
Create first job
+
+ {% endif %}
+
+
+
+{% endblock %}
diff --git a/core/templates/core/profile_settings.html b/core/templates/core/profile_settings.html
new file mode 100644
index 0000000..89f3120
--- /dev/null
+++ b/core/templates/core/profile_settings.html
@@ -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 %}
+
+
+
+
+
Profile settings
+
Your account identity
+
Update your login profile and review which TrustForge workspaces you belong to.
+
+ {% if current_membership and current_membership.can_manage_workspace %}
+
Workspace settings
+ {% elif not current_membership %}
+
Create workspace
+ {% endif %}
+
+
+
+
+
+
+
Workspace access
+
+ {% for membership in memberships %}
+
+
+
{{ membership.business.name }}
+
{{ membership.business.primary_city }}, {{ membership.business.primary_state }}
+
+
+
{{ membership.get_role_display }}
+ {% if current_membership and membership.business_id == current_membership.business_id %}
+
Current workspace
+ {% endif %}
+
+
+ {% empty %}
+
+
No workspace yet
+
Create your first business workspace to turn TrustForge into a real tenant-scoped SaaS account.
+
Create workspace
+
+ {% endfor %}
+
+
+
+
+
+
+{% endblock %}
diff --git a/core/templates/core/proof_card_detail.html b/core/templates/core/proof_card_detail.html
new file mode 100644
index 0000000..c96358e
--- /dev/null
+++ b/core/templates/core/proof_card_detail.html
@@ -0,0 +1,82 @@
+{% 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 %}
+
+
+
+
+
Proof card
+
{{ proof_card.job.service_type }} in {{ proof_card.job.city }}, {{ proof_card.job.state }}
+
{{ proof_card.customer_display_name }} · Scoped to {{ proof_card.job.business.name }} · Completed {{ proof_card.job.completed_at|date:"F j, Y" }}
+
+
+
+
+
+
+
+
+
+
+ {{ proof_card.job.service_type }}
+ {{ proof_card.verified_label }}
+
+
{% 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 %}
+
+
Rating {% if proof_card.rating %}★ {{ proof_card.rating }} / 5{% else %}Pending{% endif %}
+
Featured {% if proof_card.is_featured %}Yes{% else %}No{% endif %}
+
Widget target {{ proof_card.attached_widget_label|default:'Not set' }}
+
+
+
+
+
+
+
Quick actions
+ {% if current_membership.can_manage_proof %}
+
+
+
+
+
+ {% else %}
+
+
View-only access
+
Your current role can view proof assets in this workspace but cannot change publishing controls.
+
+ {% endif %}
+
+
+
Placement
+
Attach to widgets {{ proof_card.attached_widget_label|default:'Homepage proof gallery' }}
+
Attach to pages {{ proof_card.attached_pages|default:'Homepage' }}
+ {% if proof_card.job.review_request %}
+
Open review request page
+ {% endif %}
+
+
+
+
+
+{% endblock %}
diff --git a/core/templates/core/proof_card_form.html b/core/templates/core/proof_card_form.html
new file mode 100644
index 0000000..bba4b8d
--- /dev/null
+++ b/core/templates/core/proof_card_form.html
@@ -0,0 +1,65 @@
+{% extends "base.html" %}
+
+{% block title %}Edit Proof Card | TrustForge{% endblock %}
+{% block meta_description %}Edit a TrustForge proof card’s testimonial, status, featuring, and placement settings.{% endblock %}
+
+{% block content %}
+
+
+
+
+
Edit proof card
+
Refine the conversion asset
+
Update the customer display name, testimonial, placement, and publish settings without touching the underlying job record.
+
+
Back to proof card
+
+
+
+
+
+{% endblock %}
diff --git a/core/templates/core/proof_cards_list.html b/core/templates/core/proof_cards_list.html
new file mode 100644
index 0000000..305ff70
--- /dev/null
+++ b/core/templates/core/proof_cards_list.html
@@ -0,0 +1,55 @@
+{% extends "base.html" %}
+
+{% block title %}Proof Cards | TrustForge{% endblock %}
+{% block meta_description %}Browse TrustForge proof cards scoped to your current workspace.{% endblock %}
+
+{% block content %}
+
+
+
+
+
Proof cards
+
{{ current_membership.business.name }} proof gallery
+
These conversion assets are tenant-scoped to the active workspace and ready for review, publishing, or featuring.
+
+
Back to dashboard
+
+
+
+ {% for proof in proof_cards %}
+
+ {% empty %}
+
+
+
No proof cards yet
+
Log a completed job to create the first proof asset in this workspace.
+
Create first job
+
+
+ {% endfor %}
+
+
+
+{% endblock %}
diff --git a/core/templates/core/review_request.html b/core/templates/core/review_request.html
new file mode 100644
index 0000000..91f1989
--- /dev/null
+++ b/core/templates/core/review_request.html
@@ -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 %}
+
+
+
+
+
+
Customer feedback
+
How was your experience?
+
{{ job.business.name }} completed {{ job.service_type|lower }} in {{ job.city }}, {{ job.state }}. Your feedback helps verify real work and guide follow-up.
+
+ {% if submitted %}
+
+ {% if positive %}
+
Thank you — this proof is ready to help the next customer.
+
Your feedback has been saved. The business can now publish this as verified proof of work.
+ {% if redirect_url %}
+
Continue to Google review
+ {% endif %}
+ {% else %}
+
Thanks for the feedback.
+
This response stays internal so the business can follow up directly and improve the experience.
+ {% endif %}
+
+ {% else %}
+
+ {% endif %}
+
+
+
+
+
+{% endblock %}
diff --git a/core/templates/core/workspace_settings.html b/core/templates/core/workspace_settings.html
new file mode 100644
index 0000000..3790a25
--- /dev/null
+++ b/core/templates/core/workspace_settings.html
@@ -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 %}
+
+
+
+
+
Workspace settings
+
{{ current_membership.business.name }}
+
Manage the business profile, service territory, review destination, and who has access to this protected trust engine.
+
+
Back to dashboard
+
+
+
+
+
+
+
+
Business profile
+
These details anchor onboarding, workspace identity, and public review routing.
+
+
+ Current role
+ {{ current_membership.get_role_display }}
+
+
+
+
+
+
+
+
Role access model
+
+
Owner / Admin Manage workspace settings, team access, jobs, and proof publishing.
+
Manager Run the job-to-proof workflow and edit proof cards, but not workspace administration.
+
Technician Log completed jobs and view pipeline activity inside the assigned business only.
+
+
+
+
+
+
+
+
+
Add team member
+
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.
+
+
+
+
+
+
+
Workspace team
+ {{ team_members|length }} seats
+
+
+ {% for membership in team_members %}
+
+
+
{{ membership.user.get_full_name|default:membership.user.email }}
+
{{ membership.user.email }}
+
+
+
{{ membership.get_role_display }}
+
Joined {{ membership.created_at|date:"M j, Y" }}
+
+
+ {% empty %}
+
+
No team members yet
+
Invite admins, managers, and technicians to turn this workspace into a real multi-user SaaS account.
+
+ {% endfor %}
+
+
+
+
+
+
+{% endblock %}
diff --git a/core/templates/registration/login.html b/core/templates/registration/login.html
new file mode 100644
index 0000000..fa08393
--- /dev/null
+++ b/core/templates/registration/login.html
@@ -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 %}
+
+
+
+
+
+
Secure access
+
{{ auth_page_title }}
+
{{ auth_page_description }}
+
+
✓ Access dashboard, jobs, and proof cards securely
+
✓ Preserve the premium TrustForge workflow without exposing customer data publicly
+
✓ Next step adds business onboarding and role-based team access
+
+
+
+
+
+
Login
+
Continue into TrustForge
+
Use your work email and password to open the product workspace.
+
+
New to TrustForge? Create your account
+
+
+
+
+
+{% endblock %}
diff --git a/core/templates/registration/password_reset_complete.html b/core/templates/registration/password_reset_complete.html
new file mode 100644
index 0000000..b6ffeb5
--- /dev/null
+++ b/core/templates/registration/password_reset_complete.html
@@ -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 %}
+
+
+
+
+
+
All set
+
{{ auth_page_title }}
+
{{ auth_page_description }}
+
Log in now
+
+
+
+
+
+{% endblock %}
diff --git a/core/templates/registration/password_reset_confirm.html b/core/templates/registration/password_reset_confirm.html
new file mode 100644
index 0000000..050bf4a
--- /dev/null
+++ b/core/templates/registration/password_reset_confirm.html
@@ -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 %}
+
+
+
+
+
+
Create new password
+
{{ auth_page_title }}
+
{{ auth_page_description }}
+ {% if validlink %}
+
+ {% else %}
+
+
Reset link unavailable
+
This reset link is invalid or has already been used. Request a fresh one below.
+
+
Request a new link
+ {% endif %}
+
+
+
+
+
+{% endblock %}
diff --git a/core/templates/registration/password_reset_done.html b/core/templates/registration/password_reset_done.html
new file mode 100644
index 0000000..c827581
--- /dev/null
+++ b/core/templates/registration/password_reset_done.html
@@ -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 %}
+
+
+
+
+
+
Email sent
+
{{ auth_page_title }}
+
{{ auth_page_description }}
+
+
Next step
+
Open the email from TrustForge and follow the secure link to create a new password.
+
+
Return to login
+
+
+
+
+
+{% endblock %}
diff --git a/core/templates/registration/password_reset_email.txt b/core/templates/registration/password_reset_email.txt
new file mode 100644
index 0000000..6294c69
--- /dev/null
+++ b/core/templates/registration/password_reset_email.txt
@@ -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
diff --git a/core/templates/registration/password_reset_form.html b/core/templates/registration/password_reset_form.html
new file mode 100644
index 0000000..eedf2cf
--- /dev/null
+++ b/core/templates/registration/password_reset_form.html
@@ -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 %}
+
+
+
+
+
+
Password reset
+
{{ auth_page_title }}
+
{{ auth_page_description }}
+
+
Back to login
+
+
+
+
+
+{% endblock %}
diff --git a/core/templates/registration/password_reset_subject.txt b/core/templates/registration/password_reset_subject.txt
new file mode 100644
index 0000000..ec070af
--- /dev/null
+++ b/core/templates/registration/password_reset_subject.txt
@@ -0,0 +1 @@
+Reset your TrustForge password
diff --git a/core/templates/registration/signup.html b/core/templates/registration/signup.html
new file mode 100644
index 0000000..3288724
--- /dev/null
+++ b/core/templates/registration/signup.html
@@ -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 %}
+
+
+
+
+
+
Get started
+
{{ auth_page_title }}
+
{{ auth_page_description }}
+
+
01 Create secure email/password access
+
02 Land inside the product instead of a demo-only experience
+
03 Next step will connect your account to a business workspace
+
+
+
+
+
+
Sign up
+
Start your TrustForge workspace
+
Create the account that will own your dashboard access and future business onboarding.
+
+
Already have an account? Log in
+
+
+
+
+
+{% endblock %}
diff --git a/core/tests.py b/core/tests.py
index 7ce503c..a15c3e2 100644
--- a/core/tests.py
+++ b/core/tests.py
@@ -1,3 +1,125 @@
+from django.contrib.auth import get_user_model
from django.test import TestCase
+from django.urls import reverse
-# Create your tests here.
+from .models import Business, BusinessMembership, Customer, Job, 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',
+ )
+ 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)
diff --git a/core/urls.py b/core/urls.py
index 6299e3d..c2c924e 100644
--- a/core/urls.py
+++ b/core/urls.py
@@ -1,7 +1,47 @@
+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,
+ 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/
//', 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//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//', job_detail, name='job_detail'),
+ path('proof-cards/', proof_cards_list, name='proof_cards_list'),
+ path('proof-cards//', proof_card_detail, name='proof_card_detail'),
+ path('proof-cards//edit/', proof_card_edit, name='proof_card_edit'),
+ path('reviews//', review_request_view, name='review_request'),
]
diff --git a/core/views.py b/core/views.py
index c9aed12..7dc086b 100644
--- a/core/views.py
+++ b/core/views.py
@@ -1,25 +1,686 @@
+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 let’s 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
+ )[: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)
+
+
+@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)
diff --git a/static/css/custom.css b/static/css/custom.css
index 925f6ed..7b80ec6 100644
--- a/static/css/custom.css
+++ b/static/css/custom.css
@@ -1,4 +1,805 @@
-/* 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 {
+ 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-after {
+ background: linear-gradient(135deg, rgba(15, 118, 110, 0.94), rgba(45, 212, 191, 0.86));
+}
+
+.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;
}
diff --git a/staticfiles/css/custom.css b/staticfiles/css/custom.css
index 108056f..7b80ec6 100644
--- a/staticfiles/css/custom.css
+++ b/staticfiles/css/custom.css
@@ -1,21 +1,805 @@
-
+/* 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 {
+ 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-after {
+ background: linear-gradient(135deg, rgba(15, 118, 110, 0.94), rgba(45, 212, 191, 0.86));
+}
+
+.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;
+}